feat: Enhance InPost service selection and handling in Allegro settings

- Added a check for available InPost services and display a message if none are found.
- Updated the InPost service selection dropdown to include additional data attributes for better handling in JavaScript.
- Improved JavaScript event handling for InPost service selection to correctly populate hidden fields with selected service data.

feat: Introduce Cash on Delivery (COD) functionality in shipment preparation

- Added a new input field for specifying the COD amount and currency in the shipment preparation view.
- Updated the shipment creation logic to handle COD amounts correctly when creating shipments.

refactor: Update OrdersController to include shipment package repository

- Modified the OrdersController to accept a ShipmentPackageRepository for better management of shipment-related data.
- Enhanced order details to include shipment package information.

fix: Ensure internal order numbers are generated upon order creation

- Updated the OrderImportRepository to generate and store internal order numbers when a new order is created.

feat: Implement status synchronization for Allegro orders

- Introduced a new service for syncing order statuses from Allegro to the internal system.
- Added logic to fetch and process orders needing status updates, handling errors gracefully.

chore: Clean up InPost service definitions in AllegroIntegrationController

- Removed hardcoded InPost service definitions and replaced them with dynamic fetching based on available services.

feat: Add activity logging for shipment actions

- Implemented activity logging for various shipment actions, including creation, label downloads, and errors, to improve traceability and auditing.
This commit is contained in:
2026-03-06 20:09:59 +01:00
parent 1b5e403c31
commit 3ba6202770
21 changed files with 1888 additions and 226 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@
- 2026-03-05: Dodano tabele `apaczka_integration_settings` pod konfiguracje klucza API Apaczka.
- 2026-03-05: Dodano tabele `inpost_integration_settings` pod konfiguracje integracji InPost ShipX.
- 2026-03-06: Dodano kolumne `carrier` do tabeli `allegro_delivery_method_mappings` (default 'allegro') - umozliwia mapowanie na roznych przewoznikow (Allegro, InPost).
- 2026-03-06: Wdrozono migracje `20260302_000019_add_internal_order_number_to_orders.sql` - kolumna `internal_order_number` VARCHAR(11) UNIQUE w tabeli `orders`, format `OPXXXXXXXXX` (np. `OP000000001`); backfill istniejacych rekordow; UI: lista i szczegoly zamowien wyswietlaja numer wewnetrzny jako glowny identyfikator.
- 2026-03-04: Poprawiono prezentacje daty zamowienia na liscie (`fallback ordered_at -> source_created_at -> source_updated_at -> fetched_at`) - bez zmian schematu.
## Tabele

View File

@@ -1,6 +1,15 @@
# Tech Changelog
## 2026-03-06
- Fix: synchronizacja statusow Allegro nie aktualizowala zamowien.
- Przyczyna: Allegro API nie zmienia `updatedAt` przy zmianie `fulfillment.status`.
Cursor-based sync (`AllegroOrdersSyncService`) pomijal takie zamowienia.
- Rozwiazanie: `AllegroStatusSyncService` przepisany na podejscie direct-query:
odpytuje baze o zamowienia Allegro w nie-finalnych statusach i re-importuje je
przez `AllegroOrderImportService::importSingleOrder()`.
- `AllegroStatusSyncService` nie zalezy juz od `AllegroOrdersSyncService`.
- Dodano `ensureDefaultSchedulesExist()` w `AllegroIntegrationController`,
aby harmonogramy cron byly tworzone automatycznie.
- Rozszerzono zakladke `Formy dostawy` o wybor przewoznika (Allegro / InPost) per wiersz:
- nowa kolumna `carrier` w tabeli `allegro_delivery_method_mappings`,
- select przewoznika determinuje dostepne uslugi (Allegro z API, InPost statyczna lista),

View File

@@ -262,32 +262,83 @@ a {
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 260px 1fr;
display: flex;
}
.sidebar {
border-right: 1px solid var(--c-border);
border-right-color: #243041;
width: 260px;
min-width: 260px;
flex-shrink: 0;
overflow: hidden;
transition: width 0.22s ease, min-width 0.22s ease;
border-right: 1px solid #243041;
background: #111a28;
padding: 18px 14px;
padding: 18px 10px;
display: flex;
flex-direction: column;
}
.sidebar.is-collapsed {
width: 52px;
min-width: 52px;
}
.sidebar__brand {
margin: 4px 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px 4px 16px;
gap: 6px;
min-width: 0;
}
.sidebar__brand-text {
color: #e9f0ff;
font-size: 24px;
font-weight: 300;
letter-spacing: -0.02em;
white-space: nowrap;
overflow: hidden;
flex: 1;
min-width: 0;
}
.sidebar__brand-text strong {
font-weight: 700;
}
.sidebar__brand strong {
font-weight: 700;
.sidebar__collapse-btn {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid #2a3a54;
border-radius: 6px;
color: #64748b;
cursor: pointer;
padding: 0;
transition: background 0.15s, color 0.15s;
}
.sidebar__collapse-btn:hover {
background: #1b2a3f;
color: #cbd5e1;
}
.sidebar__collapse-icon {
display: block;
transition: transform 0.22s ease;
flex-shrink: 0;
}
.sidebar.is-collapsed .sidebar__collapse-icon {
transform: rotate(180deg);
}
.sidebar__nav {
display: grid;
gap: 6px;
gap: 4px;
}
.sidebar__link {
@@ -310,23 +361,29 @@ a {
.sidebar__group {
display: grid;
gap: 6px;
gap: 2px;
}
.sidebar__group-toggle {
list-style: none;
border-radius: 8px;
padding: 10px 12px;
padding: 9px 10px;
color: #cbd5e1;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 9px;
white-space: nowrap;
user-select: none;
}
.sidebar__group-toggle::-webkit-details-marker {
display: none;
}
.sidebar__group:hover .sidebar__group-toggle {
.sidebar__group:hover .sidebar__group-toggle,
.sidebar__group-toggle:hover {
color: #f8fafc;
background: #1b2a3f;
}
@@ -336,32 +393,78 @@ a {
background: #2e4f93;
}
.sidebar__icon {
flex-shrink: 0;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.85;
}
.sidebar__label {
flex: 1;
min-width: 0;
overflow: hidden;
}
.sidebar__toggle-arrow {
flex-shrink: 0;
margin-left: auto;
opacity: 0.5;
transition: transform 0.18s ease;
}
details[open] > .sidebar__group-toggle .sidebar__toggle-arrow {
transform: rotate(180deg);
}
.sidebar__group-links {
display: grid;
gap: 4px;
padding-left: 8px;
gap: 2px;
padding-left: 12px;
overflow: hidden;
}
.sidebar__sublink {
border-radius: 8px;
padding: 8px 10px;
border-radius: 6px;
padding: 7px 10px 7px 8px;
text-decoration: none;
color: #cbd5e1;
font-size: 13px;
color: #94a3b8;
font-size: 12.5px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.sidebar__sublink::before {
content: "";
flex-shrink: 0;
width: 5px;
height: 5px;
border-radius: 50%;
background: rgba(148, 163, 184, 0.3);
transition: background 0.15s;
}
.sidebar__sublink:hover {
color: #f8fafc;
color: #e2e8f0;
background: #1b2a3f;
}
.sidebar__sublink:hover::before {
background: rgba(148, 163, 184, 0.65);
}
.sidebar__sublink.is-active {
color: #ffffff;
background: #2e4f93;
background: rgba(46, 79, 147, 0.55);
}
.sidebar__sublink.is-active::before {
background: #93c5fd;
}
.app-main {
flex: 1;
min-width: 0;
}
@@ -1165,6 +1268,16 @@ a {
background: #fffbeb;
color: #92400e;
}
.order-tag.is-cod {
border-color: #f9a8d4;
background: #fdf2f8;
color: #9d174d;
}
.order-tag.is-unpaid {
border-color: #fca5a5;
background: #fef2f2;
color: #b91c1c;
}
.orders-mini {
font-size: 14px;
@@ -1541,6 +1654,30 @@ a {
font-size: 12px;
}
.payment-summary {
display: grid;
gap: 6px;
max-width: 420px;
}
.payment-summary__row {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
}
.payment-summary__label {
width: 150px;
flex-shrink: 0;
color: #64748b;
}
.payment-summary__value {
font-weight: 600;
color: #0f172a;
}
.order-kv dt {
color: #64748b;
}
@@ -2052,17 +2189,23 @@ a {
@media (max-width: 768px) {
.app-shell {
grid-template-columns: 1fr;
flex-direction: column;
}
.sidebar {
width: 100% !important;
min-width: 0 !important;
border-right: 0;
border-bottom: 1px solid #243041;
padding: 14px;
overflow-x: auto;
}
.sidebar__brand {
margin: 0 0 10px;
font-size: 22px;
}
.sidebar__collapse-btn {
display: none;
}
.sidebar__nav {
display: flex;
gap: 8px;

View File

@@ -184,6 +184,9 @@ return [
'document' => 'Dokument',
'import' => 'Import',
'note' => 'Notatka',
'shipment_created' => 'Przesylka WZA',
'shipment_label_downloaded' => 'Etykieta pobrana',
'shipment_error' => 'Blad przesylki',
],
'actors' => [
'system' => 'System',

View File

@@ -23,32 +23,85 @@ a {
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 260px 1fr;
display: flex;
}
.sidebar {
border-right: 1px solid var(--c-border);
border-right-color: #243041;
width: 260px;
min-width: 260px;
flex-shrink: 0;
overflow: hidden;
transition: width 0.22s ease, min-width 0.22s ease;
border-right: 1px solid #243041;
background: #111a28;
padding: 18px 14px;
padding: 18px 10px;
display: flex;
flex-direction: column;
}
.sidebar.is-collapsed {
width: 52px;
min-width: 52px;
}
.sidebar__brand {
margin: 4px 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px 4px 16px;
gap: 6px;
min-width: 0;
}
.sidebar__brand-text {
color: #e9f0ff;
font-size: 24px;
font-weight: 300;
letter-spacing: -0.02em;
white-space: nowrap;
overflow: hidden;
flex: 1;
min-width: 0;
strong {
font-weight: 700;
}
}
.sidebar__brand strong {
font-weight: 700;
.sidebar__collapse-btn {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid #2a3a54;
border-radius: 6px;
color: #64748b;
cursor: pointer;
padding: 0;
transition: background 0.15s, color 0.15s;
&:hover {
background: #1b2a3f;
color: #cbd5e1;
}
}
.sidebar__collapse-icon {
display: block;
transition: transform 0.22s ease;
flex-shrink: 0;
}
.sidebar.is-collapsed .sidebar__collapse-icon {
transform: rotate(180deg);
}
.sidebar__nav {
display: grid;
gap: 6px;
gap: 4px;
}
.sidebar__link {
@@ -71,23 +124,29 @@ a {
.sidebar__group {
display: grid;
gap: 6px;
gap: 2px;
}
.sidebar__group-toggle {
list-style: none;
border-radius: 8px;
padding: 10px 12px;
padding: 9px 10px;
color: #cbd5e1;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 9px;
white-space: nowrap;
user-select: none;
}
.sidebar__group-toggle::-webkit-details-marker {
display: none;
}
.sidebar__group:hover .sidebar__group-toggle {
.sidebar__group:hover .sidebar__group-toggle,
.sidebar__group-toggle:hover {
color: #f8fafc;
background: #1b2a3f;
}
@@ -97,32 +156,83 @@ a {
background: #2e4f93;
}
.sidebar__icon {
flex-shrink: 0;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.85;
}
.sidebar__label {
flex: 1;
min-width: 0;
overflow: hidden;
}
.sidebar__toggle-arrow {
flex-shrink: 0;
margin-left: auto;
opacity: 0.5;
transition: transform 0.18s ease;
}
details[open] > .sidebar__group-toggle .sidebar__toggle-arrow {
transform: rotate(180deg);
}
.sidebar__group-links {
display: grid;
gap: 4px;
padding-left: 8px;
gap: 2px;
padding-left: 12px;
overflow: hidden;
}
.sidebar__sublink {
border-radius: 8px;
padding: 8px 10px;
border-radius: 6px;
padding: 7px 10px 7px 8px;
text-decoration: none;
color: #cbd5e1;
font-size: 13px;
color: #94a3b8;
font-size: 12.5px;
font-weight: 500;
}
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
.sidebar__sublink:hover {
color: #f8fafc;
background: #1b2a3f;
}
&::before {
content: '';
flex-shrink: 0;
width: 5px;
height: 5px;
border-radius: 50%;
background: rgba(148, 163, 184, 0.3);
transition: background 0.15s;
}
.sidebar__sublink.is-active {
color: #ffffff;
background: #2e4f93;
&:hover {
color: #e2e8f0;
background: #1b2a3f;
&::before {
background: rgba(148, 163, 184, 0.65);
}
}
&.is-active {
color: #ffffff;
background: rgba(46, 79, 147, 0.55);
&::before {
background: #93c5fd;
}
}
}
.app-main {
flex: 1;
min-width: 0;
}
@@ -944,6 +1054,18 @@ a {
background: #fffbeb;
color: #92400e;
}
&.is-cod {
border-color: #f9a8d4;
background: #fdf2f8;
color: #9d174d;
}
&.is-unpaid {
border-color: #fca5a5;
background: #fef2f2;
color: #b91c1c;
}
}
.orders-mini {
@@ -1316,6 +1438,30 @@ a {
font-size: 12px;
}
.payment-summary {
display: grid;
gap: 6px;
max-width: 420px;
}
.payment-summary__row {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
}
.payment-summary__label {
width: 150px;
flex-shrink: 0;
color: #64748b;
}
.payment-summary__value {
font-weight: 600;
color: #0f172a;
}
.order-kv dt {
color: #64748b;
}
@@ -1853,13 +1999,16 @@ a {
@media (max-width: 768px) {
.app-shell {
grid-template-columns: 1fr;
flex-direction: column;
}
.sidebar {
width: 100% !important;
min-width: 0 !important;
border-right: 0;
border-bottom: 1px solid #243041;
padding: 14px;
overflow-x: auto;
}
.sidebar__brand {
@@ -1867,6 +2016,10 @@ a {
font-size: 22px;
}
.sidebar__collapse-btn {
display: none;
}
.sidebar__nav {
display: flex;
gap: 8px;

View File

@@ -14,13 +14,32 @@
<?php $currentMenu = (string) ($activeMenu ?? ''); ?>
<?php $currentSettings = (string) ($activeSettings ?? ''); ?>
<?php $currentOrders = (string) ($activeOrders ?? ''); ?>
<div class="app-shell">
<aside class="sidebar">
<div class="sidebar__brand"><?= $e($t('brand.name_prefix')) ?><strong><?= $e($t('brand.name_suffix')) ?></strong></div>
<div class="app-shell" id="js-app-shell">
<aside class="sidebar" id="js-sidebar">
<div class="sidebar__brand">
<span class="sidebar__brand-text"><?= $e($t('brand.name_prefix')) ?><strong><?= $e($t('brand.name_suffix')) ?></strong></span>
<button class="sidebar__collapse-btn" id="js-sidebar-collapse" title="Zwiń menu" aria-label="Zwiń menu">
<svg class="sidebar__collapse-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
</button>
</div>
<nav class="sidebar__nav" aria-label="<?= $e($t('navigation.main_menu')) ?>">
<details class="sidebar__group<?= $currentMenu === 'orders' ? ' is-active' : '' ?>"<?= $currentMenu === 'orders' ? ' open' : '' ?>>
<summary class="sidebar__group-toggle"><?= $e($t('navigation.orders')) ?></summary>
<summary class="sidebar__group-toggle">
<span class="sidebar__icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 5H7a2 2 0 00-2 2v13a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/>
<rect x="9" y="3" width="6" height="4" rx="1"/>
<path d="M9 12h6M9 16h4"/>
</svg>
</span>
<span class="sidebar__label"><?= $e($t('navigation.orders')) ?></span>
<svg class="sidebar__toggle-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
</summary>
<div class="sidebar__group-links">
<a class="sidebar__sublink<?= $currentMenu === 'orders' && $currentOrders === 'list' ? ' is-active' : '' ?>" href="/orders/list">
<?= $e($t('navigation.orders_list')) ?>
@@ -29,7 +48,18 @@
</details>
<details class="sidebar__group<?= $currentMenu === 'settings' ? ' is-active' : '' ?>"<?= $currentMenu === 'settings' ? ' open' : '' ?>>
<summary class="sidebar__group-toggle"><?= $e($t('navigation.settings')) ?></summary>
<summary class="sidebar__group-toggle">
<span class="sidebar__icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
</span>
<span class="sidebar__label"><?= $e($t('navigation.settings')) ?></span>
<svg class="sidebar__toggle-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
</summary>
<div class="sidebar__group-links">
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'users' ? ' is-active' : '' ?>" href="/settings/users">
<?= $e($t('navigation.users')) ?>
@@ -77,5 +107,42 @@
</div>
</div>
<script src="/assets/js/modules/jquery-alerts.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/jquery-alerts.js') ?: 0 ?>"></script>
<script>
(function () {
var STORAGE_KEY = 'sidebarCollapsed';
var sidebar = document.getElementById('js-sidebar');
var collapseBtn = document.getElementById('js-sidebar-collapse');
if (!sidebar || !collapseBtn) return;
function setCollapsed(collapsed) {
sidebar.classList.toggle('is-collapsed', collapsed);
collapseBtn.setAttribute('title', collapsed ? 'Rozwiń menu' : 'Zwiń menu');
collapseBtn.setAttribute('aria-label', collapsed ? 'Rozwiń menu' : 'Zwiń menu');
if (collapsed) {
sidebar.querySelectorAll('details[open]').forEach(function (det) {
det.removeAttribute('open');
});
}
try { localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0'); } catch (e) {}
}
try {
if (localStorage.getItem(STORAGE_KEY) === '1') setCollapsed(true);
} catch (e) {}
collapseBtn.addEventListener('click', function () {
setCollapsed(!sidebar.classList.contains('is-collapsed'));
});
sidebar.querySelectorAll('details > summary').forEach(function (summary) {
summary.addEventListener('click', function (e) {
if (sidebar.classList.contains('is-collapsed')) {
e.preventDefault();
setCollapsed(false);
}
});
});
})();
</script>
</body>
</html>

View File

@@ -11,20 +11,6 @@
<h2 class="section-title"><?= $e($t('orders.title')) ?></h2>
<p class="muted mt-12"><?= $e($t('orders.description')) ?></p>
</div>
<div class="orders-stats">
<div class="orders-stat">
<span class="orders-stat__label"><?= $e($t('orders.stats.all')) ?></span>
<strong class="orders-stat__value"><?= $e((string) ((int) ($stats['all'] ?? 0))) ?></strong>
</div>
<div class="orders-stat">
<span class="orders-stat__label"><?= $e($t('orders.stats.paid')) ?></span>
<strong class="orders-stat__value"><?= $e((string) ((int) ($stats['paid'] ?? 0))) ?></strong>
</div>
<div class="orders-stat">
<span class="orders-stat__label"><?= $e($t('orders.stats.shipped')) ?></span>
<strong class="orders-stat__value"><?= $e((string) ((int) ($stats['shipped'] ?? 0))) ?></strong>
</div>
</div>
</div>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--warning mt-12" role="alert">

View File

@@ -4,6 +4,7 @@ $itemsList = is_array($items ?? null) ? $items : [];
$addressesList = is_array($addresses ?? null) ? $addresses : [];
$paymentsList = is_array($payments ?? null) ? $payments : [];
$shipmentsList = is_array($shipments ?? null) ? $shipments : [];
$packagesList = is_array($packages ?? null) ? $packages : [];
$documentsList = is_array($documents ?? null) ? $documents : [];
$notesList = is_array($notes ?? null) ? $notes : [];
$historyList = is_array($history ?? null) ? $history : [];
@@ -36,7 +37,7 @@ foreach ($addressesList as $address) {
<div class="order-details-head">
<div>
<a href="/orders/list" class="order-back-link">&larr; <?= $e($t('navigation.orders_list')) ?></a>
<h2 class="section-title mt-12"><?= $e($t('orders.details.title')) ?> #<?= $e((string) ($orderId ?? 0)) ?></h2>
<h2 class="section-title mt-12"><?= $e($t('orders.details.title')) ?> <?= $e((string) ($orderRow['internal_order_number'] ?? ('#' . ($orderId ?? 0)))) ?></h2>
<div class="order-details-sub mt-12">
<span><?= $e(ucfirst((string) ($orderRow['source'] ?? ''))) ?> <?= $e((string) ($orderRow['external_order_id'] ?? '')) ?></span>
</div>
@@ -95,7 +96,7 @@ foreach ($addressesList as $address) {
<section class="card mt-16 order-details-tabs">
<button type="button" class="order-details-tab is-active" data-order-tab-target="details"><?= $e($t('orders.details.tabs.details')) ?></button>
<button type="button" class="order-details-tab" data-order-tab-target="history"><?= $e($t('orders.details.tabs.history')) ?> (<?= $e((string) count($activityLogList)) ?>)</button>
<button type="button" class="order-details-tab" data-order-tab-target="shipments"><?= $e($t('orders.details.tabs.shipments')) ?> (<?= $e((string) count($shipmentsList)) ?>)</button>
<button type="button" class="order-details-tab" data-order-tab-target="shipments"><?= $e($t('orders.details.tabs.shipments')) ?> (<?= $e((string) (count($shipmentsList) + count($packagesList))) ?>)</button>
<button type="button" class="order-details-tab" data-order-tab-target="payments"><?= $e($t('orders.details.tabs.payments')) ?> (<?= $e((string) count($paymentsList)) ?>)</button>
<button type="button" class="order-details-tab" data-order-tab-target="documents"><?= $e($t('orders.details.tabs.documents')) ?> (<?= $e((string) count($documentsList)) ?>)</button>
</section>
@@ -160,6 +161,7 @@ foreach ($addressesList as $address) {
<h3 class="section-title"><?= $e($t('orders.details.order_info')) ?></h3>
<dl class="order-kv mt-12">
<dt><?= $e($t('orders.details.fields.status')) ?></dt><dd><?= $e((string) ($statusLabel ?? '-')) ?></dd>
<dt>Nr zamowienia</dt><dd><strong><?= $e((string) ($orderRow['internal_order_number'] ?? '-')) ?></strong></dd>
<dt><?= $e($t('orders.details.fields.source_order_id')) ?></dt><dd><?= $e((string) ($orderRow['source_order_id'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.external_order_id')) ?></dt><dd><?= $e((string) ($orderRow['external_order_id'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.ordered_at')) ?></dt><dd><?= $e((string) ($orderRow['ordered_at'] ?? '-')) ?></dd>
@@ -172,6 +174,23 @@ foreach ($addressesList as $address) {
<h3 class="section-title"><?= $e($t('orders.details.payment_shipping')) ?></h3>
<dl class="order-kv mt-12">
<dt><?= $e($t('orders.details.fields.payment_status')) ?></dt><dd><?= $e((string) ($orderRow['payment_status'] ?? '-')) ?></dd>
<dt>Typ platnosci</dt>
<dd>
<?php
$paymentTypeRaw = strtoupper(trim((string) ($orderRow['external_payment_type_id'] ?? '')));
$paymentTypeLabels = [
'CASH_ON_DELIVERY' => 'Za pobraniem',
'ONLINE' => 'Platnosc online',
'TRANSFER' => 'Przelew',
];
$paymentTypeLabel = $paymentTypeLabels[$paymentTypeRaw] ?? ($paymentTypeRaw !== '' ? $paymentTypeRaw : '-');
?>
<?php if ($paymentTypeRaw === 'CASH_ON_DELIVERY'): ?>
<span class="order-tag is-cod"><?= $e($paymentTypeLabel) ?></span>
<?php else: ?>
<?= $e($paymentTypeLabel) ?>
<?php endif; ?>
</dd>
<dt><?= $e($t('orders.details.fields.total_with_tax')) ?></dt><dd><?= $e((string) ($orderRow['total_with_tax'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.total_paid')) ?></dt><dd><?= $e((string) ($orderRow['total_paid'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.carrier')) ?></dt><dd><?= $e((string) ($orderRow['external_carrier_id'] ?? '-')) ?></dd>
@@ -292,16 +311,183 @@ foreach ($addressesList as $address) {
</div>
<div class="order-tab-panel" data-order-tab-panel="shipments">
<?php if ($packagesList !== []): ?>
<section class="card mt-16">
<h3 class="section-title">Wygenerowane przesylki (WZA)</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>
</tr>
</thead>
<tbody>
<?php foreach ($packagesList as $pkg): ?>
<?php
$pkgStatus = (string) ($pkg['status'] ?? 'draft');
$pkgTracking = trim((string) ($pkg['tracking_number'] ?? ''));
$pkgCarrier = trim((string) ($pkg['carrier_id'] ?? ''));
$pkgLabelPath = trim((string) ($pkg['label_path'] ?? ''));
$pkgError = trim((string) ($pkg['error_message'] ?? ''));
?>
<tr>
<td><?= $e((string) ($pkg['id'] ?? '')) ?></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 !== ''): ?>
<a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>/shipment/prepare" class="btn btn--sm btn--secondary">Pobierz</a>
<?php else: ?>
-
<?php endif; ?>
</td>
<td class="text-nowrap"><?= $e((string) ($pkg['created_at'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<?php endif; ?>
<?php if ($shipmentsList !== []): ?>
<section class="card mt-16">
<h3 class="section-title">Wysylki z Allegro</h3>
<div class="table-wrap mt-12">
<table class="table table--details">
<thead>
<tr>
<th>Nr sledzenia</th>
<th>Przewoznik</th>
<th>Data nadania</th>
</tr>
</thead>
<tbody>
<?php foreach ($shipmentsList as $sh): ?>
<tr>
<td><?= $e(trim((string) ($sh['tracking_number'] ?? '-'))) ?></td>
<td><?= $e(trim((string) ($sh['carrier_provider_id'] ?? '-'))) ?></td>
<td class="text-nowrap"><?= $e(trim((string) ($sh['posted_at'] ?? '-'))) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<?php endif; ?>
<?php if ($packagesList === [] && $shipmentsList === []): ?>
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('orders.details.tabs.shipments')) ?></h3>
<div class="order-empty-placeholder mt-12"></div>
<p class="muted mt-12">Brak przesylek dla tego zamowienia.</p>
</section>
<?php endif; ?>
</div>
<div class="order-tab-panel" data-order-tab-panel="payments">
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('orders.details.tabs.payments')) ?></h3>
<div class="order-empty-placeholder mt-12"></div>
<?php
$paymentStatusNum = isset($orderRow['payment_status']) ? (int) $orderRow['payment_status'] : null;
$paymentStatusLabels = [0 => 'Nieopłacone', 1 => 'Częściowo opłacone', 2 => 'Opłacone', 3 => 'Zwrócone'];
$paymentStatusClasses = [0 => 'is-danger', 1 => 'is-warn', 2 => 'is-success', 3 => 'is-neutral'];
$paymentTypeLabels = [
'ONLINE' => 'Płatność online',
'CASH_ON_DELIVERY' => 'Za pobraniem',
'TRANSFER' => 'Przelew bankowy',
];
$providerLabels = [
'AF' => 'Allegro Pay',
'PAYU' => 'PayU',
'PRZELEWY24' => 'Przelewy24',
];
?>
<div class="payment-summary mt-12">
<div class="payment-summary__row">
<span class="payment-summary__label">Status płatności</span>
<span class="payment-summary__value">
<?php if ($paymentStatusNum !== null && isset($paymentStatusLabels[$paymentStatusNum])): ?>
<span class="order-tag <?= $e($paymentStatusClasses[$paymentStatusNum] ?? 'is-neutral') ?>">
<?= $e($paymentStatusLabels[$paymentStatusNum]) ?>
</span>
<?php else: ?>
<span class="muted">—</span>
<?php endif; ?>
</span>
</div>
<div class="payment-summary__row">
<span class="payment-summary__label">Kwota do zapłaty</span>
<span class="payment-summary__value">
<?= $e($orderRow['total_with_tax'] !== null ? number_format((float) $orderRow['total_with_tax'], 2, '.', ' ') . ' ' . ($orderRow['currency'] ?? '') : '—') ?>
</span>
</div>
<div class="payment-summary__row">
<span class="payment-summary__label">Kwota opłacona</span>
<span class="payment-summary__value">
<?= $e($orderRow['total_paid'] !== null ? number_format((float) $orderRow['total_paid'], 2, '.', ' ') . ' ' . ($orderRow['currency'] ?? '') : '—') ?>
</span>
</div>
</div>
<?php if ($paymentsList === []): ?>
<p class="muted mt-16">Brak zarejestrowanych płatności.</p>
<?php else: ?>
<div class="table-wrap mt-16">
<table class="table table--details">
<thead>
<tr>
<th>Data płatności</th>
<th>Typ</th>
<th>Dostawca</th>
<th>Kwota</th>
<th>ID płatności</th>
</tr>
</thead>
<tbody>
<?php foreach ($paymentsList as $payment): ?>
<?php
$ptRaw = strtoupper(trim((string) ($payment['payment_type_id'] ?? '')));
$ptLabel = $paymentTypeLabels[$ptRaw] ?? ($ptRaw !== '' ? $ptRaw : '—');
$providerRaw = strtoupper(trim((string) ($payment['comment'] ?? '')));
$providerLabel = $providerLabels[$providerRaw] ?? ($providerRaw !== '' ? $providerRaw : '—');
$amount = $payment['amount'] !== null
? number_format((float) $payment['amount'], 2, '.', ' ') . ' ' . ($payment['currency'] ?? '')
: '—';
$payDate = (string) ($payment['payment_date'] ?? '');
?>
<tr>
<td class="text-nowrap"><?= $e($payDate !== '' ? $payDate : '—') ?></td>
<td>
<?php if ($ptRaw === 'CASH_ON_DELIVERY'): ?>
<span class="order-tag is-cod"><?= $e($ptLabel) ?></span>
<?php else: ?>
<?= $e($ptLabel) ?>
<?php endif; ?>
</td>
<td><?= $e($providerLabel) ?></td>
<td class="text-nowrap"><strong><?= $e($amount) ?></strong></td>
<td class="muted" style="font-size:11px"><?= $e((string) ($payment['source_payment_id'] ?? '—')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
</div>

View File

@@ -378,14 +378,29 @@ $orderproStatuses = is_array($orderproStatuses ?? null) ? $orderproStatuses : []
<?php // InPost simple select ?>
<div class="dm-inpost-panel" style="<?= $currentCarrier !== 'inpost' ? 'display:none' : '' ?>">
<select class="form-control dm-inpost-select">
<option value="">-- <?= $e($t('settings.allegro.delivery.fields.no_mapping')) ?> --</option>
<?php foreach ($dmInpostServices as $inSvc): ?>
<option value="<?= $e((string) ($inSvc['id'] ?? '')) ?>"<?= $currentCarrier === 'inpost' && $currentAllegroId === (string) ($inSvc['id'] ?? '') ? ' selected' : '' ?>>
<?= $e((string) ($inSvc['name'] ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
<?php if ($dmInpostServices === []): ?>
<div class="muted">Brak uslug InPost (sprawdz polaczenie z Allegro).</div>
<?php else: ?>
<select class="form-control dm-inpost-select">
<option value="">-- <?= $e($t('settings.allegro.delivery.fields.no_mapping')) ?> --</option>
<?php foreach ($dmInpostServices as $inSvc): ?>
<?php
$inSvcId = is_array($inSvc['id'] ?? null) ? $inSvc['id'] : [];
$inSvcMethodId = trim((string) ($inSvcId['deliveryMethodId'] ?? ''));
$inSvcCredentialsId = trim((string) ($inSvcId['credentialsId'] ?? ''));
$inSvcCarrierId = trim((string) ($inSvc['carrierId'] ?? ''));
$inSvcName = trim((string) ($inSvc['name'] ?? ''));
?>
<option
value="<?= $e($inSvcMethodId) ?>"
data-credentials-id="<?= $e($inSvcCredentialsId) ?>"
data-carrier-id="<?= $e($inSvcCarrierId) ?>"
<?= $currentCarrier === 'inpost' && $currentAllegroId === $inSvcMethodId ? 'selected' : '' ?>>
<?= $e($inSvcName) ?>
</option>
<?php endforeach; ?>
</select>
<?php endif; ?>
</div>
<?php // Empty state ?>
@@ -501,8 +516,8 @@ $orderproStatuses = is_array($orderproStatuses ?? null) ? $orderproStatuses : []
inpostSelect.addEventListener('change', function () {
var opt = inpostSelect.options[inpostSelect.selectedIndex];
if (hiddenMethodId) hiddenMethodId.value = inpostSelect.value;
if (hiddenCredentialsId) hiddenCredentialsId.value = '';
if (hiddenCarrierId) hiddenCarrierId.value = '';
if (hiddenCredentialsId) hiddenCredentialsId.value = opt ? (opt.getAttribute('data-credentials-id') || '') : '';
if (hiddenCarrierId) hiddenCarrierId.value = opt ? (opt.getAttribute('data-carrier-id') || '') : '';
if (hiddenServiceName) hiddenServiceName.value = opt ? opt.textContent.trim() : '';
});
}

View File

@@ -26,6 +26,8 @@ $pointId = trim((string) ($receiver['parcel_external_id'] ?? ''));
$pointName = trim((string) ($receiver['parcel_name'] ?? ''));
$totalWithTax = (float) ($orderRow['total_with_tax'] ?? 0);
$currency = strtoupper(trim((string) ($orderRow['currency'] ?? 'PLN')));
$isCod = strtoupper(trim((string) ($orderRow['external_payment_type_id'] ?? ''))) === 'CASH_ON_DELIVERY';
$defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
?>
<section class="card">
@@ -171,14 +173,30 @@ $currency = strtoupper(trim((string) ($orderRow['currency'] ?? 'PLN')));
</div>
<div id="shipment-inpost-panel" style="<?= $preselectedCarrier !== 'inpost' ? 'display:none' : '' ?>">
<select class="form-control" id="shipment-inpost-select">
<option value="">-- Wybierz usluge InPost --</option>
<?php foreach ($inpostSvcList as $inSvc): ?>
<option value="<?= $e((string) ($inSvc['id'] ?? '')) ?>"<?= $mappedCarrier === 'inpost' && $mappedMethodId === (string) ($inSvc['id'] ?? '') ? ' selected' : '' ?>>
<?= $e((string) ($inSvc['name'] ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
<?php if ($inpostSvcList === []): ?>
<div class="muted">Brak uslug InPost (sprawdz polaczenie z Allegro).</div>
<?php else: ?>
<select class="form-control" id="shipment-inpost-select">
<option value="">-- Wybierz usluge InPost --</option>
<?php foreach ($inpostSvcList as $inSvc): ?>
<?php
$inSvcId = is_array($inSvc['id'] ?? null) ? $inSvc['id'] : [];
$inSvcMethodId = trim((string) ($inSvcId['deliveryMethodId'] ?? ''));
$inSvcCredentialsId = trim((string) ($inSvcId['credentialsId'] ?? ''));
$inSvcCarrierId = trim((string) ($inSvc['carrierId'] ?? ''));
$inSvcName = trim((string) ($inSvc['name'] ?? ''));
$inSvcSelected = $mappedCarrier === 'inpost' && $mappedMethodId === $inSvcMethodId;
?>
<option
value="<?= $e($inSvcMethodId) ?>"
data-credentials-id="<?= $e($inSvcCredentialsId) ?>"
data-carrier-id="<?= $e($inSvcCarrierId) ?>"
<?= $inSvcSelected ? 'selected' : '' ?>>
<?= $e($inSvcName) ?>
</option>
<?php endforeach; ?>
</select>
<?php endif; ?>
</div>
<div id="shipment-empty-panel" class="muted" style="<?= $preselectedCarrier !== '' ? 'display:none' : '' ?>">Wybierz przewoznika</div>
@@ -230,6 +248,14 @@ $currency = strtoupper(trim((string) ($orderRow['currency'] ?? 'PLN')));
</label>
</div>
<div class="form-grid-2">
<label class="form-field">
<span class="field-label">Pobranie (<?= $e($currency) ?>)<?= $isCod ? ' <span class="order-tag is-cod" style="font-size:0.7rem;vertical-align:middle">ZA POBRANIEM</span>' : '' ?></span>
<input class="form-control" type="number" name="cod_amount" step="0.01" min="0" value="<?= $e($defaultCodAmount) ?>">
<input type="hidden" name="cod_currency" value="<?= $e($currency) ?>">
</label>
</div>
<label class="form-field">
<span class="field-label">Punkt nadania (opcjonalnie)</span>
<input class="form-control" type="text" name="sender_point_id" maxlength="64" placeholder="np. KRA010">
@@ -482,13 +508,15 @@ $currency = strtoupper(trim((string) ($orderRow['currency'] ?? 'PLN')));
// --- InPost select ---
if (inpostSelect) {
inpostSelect.addEventListener('change', function () {
function syncInpostFields() {
var opt = inpostSelect.options[inpostSelect.selectedIndex];
hiddenInput.value = inpostSelect.value;
credentialsInput.value = '';
carrierInput.value = '';
});
credentialsInput.value = opt ? (opt.getAttribute('data-credentials-id') || '') : '';
carrierInput.value = opt ? (opt.getAttribute('data-carrier-id') || '') : '';
}
inpostSelect.addEventListener('change', syncInpostFields);
if (carrierSelect.value === 'inpost' && inpostSelect.value !== '') {
hiddenInput.value = inpostSelect.value;
syncInpostFields();
}
}
@@ -573,7 +601,7 @@ $currency = strtoupper(trim((string) ($orderRow['currency'] ?? 'PLN')));
fetch('/orders/' + oId + '/shipment/' + pkgId + '/status')
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.status === 'created') {
if (data.status === 'created' || data.status === 'label_ready') {
window.location.reload();
} else if (data.status === 'error') {
if (btn) {

View File

@@ -39,7 +39,8 @@ return static function (Application $app): void {
$authController = new AuthController($template, $auth, $translator);
$usersController = new UsersController($template, $translator, $auth, $app->users());
$ordersController = new OrdersController($template, $translator, $auth, $app->orders());
$shipmentPackageRepositoryForOrders = new ShipmentPackageRepository($app->db());
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders);
$settingsController = new SettingsController($template, $translator, $auth, $app->migrator(), $app->orderStatuses());
$allegroIntegrationRepository = new AllegroIntegrationRepository(
$app->db(),

View File

@@ -297,7 +297,8 @@ final class Application
'allegro_status_sync' => new AllegroStatusSyncHandler(
new AllegroStatusSyncService(
$repository,
$ordersSyncService
$orderImportService,
$this->db
)
),
]

View File

@@ -106,7 +106,12 @@ final class OrderImportRepository
);
$statement->execute($this->orderParams($orderData));
return (int) $this->pdo->lastInsertId();
$newId = (int) $this->pdo->lastInsertId();
$this->pdo->prepare(
"UPDATE orders SET internal_order_number = CONCAT('OP', LPAD(id, 9, '0')) WHERE id = :id AND (internal_order_number IS NULL OR internal_order_number = '')"
)->execute(['id' => $newId]);
return $newId;
}
/**

View File

@@ -9,6 +9,7 @@ use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Shipments\ShipmentPackageRepository;
final class OrdersController
{
@@ -16,7 +17,8 @@ final class OrdersController
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly OrdersRepository $orders
private readonly OrdersRepository $orders,
private readonly ?ShipmentPackageRepository $shipmentPackages = null
) {
}
@@ -153,6 +155,10 @@ final class OrdersController
$allStatuses = $this->buildAllStatusOptions($statusConfig);
$packages = $this->shipmentPackages !== null
? $this->shipmentPackages->findByOrderId($orderId)
: [];
$flashSuccess = (string) ($_SESSION['order_flash_success'] ?? '');
$flashError = (string) ($_SESSION['order_flash_error'] ?? '');
unset($_SESSION['order_flash_success'], $_SESSION['order_flash_error']);
@@ -169,6 +175,7 @@ final class OrdersController
'addresses' => $addresses,
'payments' => $payments,
'shipments' => $shipments,
'packages' => $packages,
'documents' => $documents,
'notes' => $notes,
'history' => $resolvedHistory,
@@ -222,6 +229,7 @@ final class OrdersController
*/
private function toTableRow(array $row, array $statusLabelMap): array
{
$internalOrderNumber = trim((string) ($row['internal_order_number'] ?? ''));
$sourceOrderId = trim((string) ($row['source_order_id'] ?? ''));
$externalOrderId = trim((string) ($row['external_order_id'] ?? ''));
$source = trim((string) ($row['source'] ?? ''));
@@ -232,6 +240,10 @@ final class OrdersController
$currency = trim((string) ($row['currency'] ?? ''));
$totalWithTax = $row['total_with_tax'] !== null ? number_format((float) $row['total_with_tax'], 2, '.', ' ') : '-';
$totalPaid = $row['total_paid'] !== null ? number_format((float) $row['total_paid'], 2, '.', ' ') : '-';
$paymentType = strtoupper(trim((string) ($row['external_payment_type_id'] ?? '')));
$isCod = $paymentType === 'CASH_ON_DELIVERY';
$paymentStatus = isset($row['payment_status']) ? (int) $row['payment_status'] : null;
$isUnpaid = !$isCod && $paymentStatus === 0;
$itemsCount = max(0, (int) ($row['items_count'] ?? 0));
$itemsQty = $this->formatQuantity((float) ($row['items_qty'] ?? 0));
$shipments = max(0, (int) ($row['shipments_count'] ?? 0));
@@ -241,10 +253,10 @@ final class OrdersController
return [
'order_ref' => '<div class="orders-ref">'
. '<div class="orders-ref__main"><a href="/orders/' . (int) ($row['id'] ?? 0) . '">'
. htmlspecialchars($sourceOrderId !== '' ? $sourceOrderId : ('#' . (string) ($row['id'] ?? 0)), ENT_QUOTES, 'UTF-8')
. htmlspecialchars($internalOrderNumber !== '' ? $internalOrderNumber : ('#' . (string) ($row['id'] ?? 0)), ENT_QUOTES, 'UTF-8')
. '</a></div>'
. '<div class="orders-ref__meta">'
. '<span>' . htmlspecialchars($externalOrderId, ENT_QUOTES, 'UTF-8') . '</span>'
. '<span>' . htmlspecialchars($sourceOrderId !== '' ? $sourceOrderId : $externalOrderId, ENT_QUOTES, 'UTF-8') . '</span>'
. '<span>' . htmlspecialchars($source, ENT_QUOTES, 'UTF-8') . '</span>'
. '</div>'
. '</div>',
@@ -260,8 +272,8 @@ final class OrdersController
. '</div>',
'products' => $this->productsHtml($itemsPreview, $itemsCount, $itemsQty),
'totals' => '<div class="orders-money">'
. '<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 class="orders-money__main">' . htmlspecialchars($totalWithTax . ' ' . $currency, ENT_QUOTES, 'UTF-8') . ($isUnpaid ? ' <span class="order-tag is-unpaid">Nieopłacone</span>' : '') . '</div>'
. '<div class="orders-money__meta">' . ($isCod ? '<span class="order-tag is-cod">Za pobraniem</span>' : 'oplacono: ' . htmlspecialchars($totalPaid . ' ' . $currency, ENT_QUOTES, 'UTF-8')) . '</div>'
. '</div>',
'shipping' => $this->shippingHtml(
trim((string) ($row['external_carrier_id'] ?? '')),

View File

@@ -99,6 +99,7 @@ final class OrdersRepository
$listSql = 'SELECT
o.id,
o.internal_order_number,
o.source,
o.source_order_id,
o.external_order_id,
@@ -119,6 +120,7 @@ final class OrdersRepository
a.email AS buyer_email,
a.city AS buyer_city,
o.external_carrier_id,
o.external_payment_type_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,
@@ -156,6 +158,7 @@ final class OrdersRepository
$orderId = (int) ($row['id'] ?? 0);
return [
'id' => $orderId,
'internal_order_number' => (string) ($row['internal_order_number'] ?? ''),
'source' => (string) ($row['source'] ?? ''),
'source_order_id' => (string) ($row['source_order_id'] ?? ''),
'external_order_id' => (string) ($row['external_order_id'] ?? ''),
@@ -172,6 +175,7 @@ final class OrdersRepository
'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'] ?? ''),
'external_payment_type_id' => (string) ($row['external_payment_type_id'] ?? ''),
'buyer_name' => (string) ($row['buyer_name'] ?? ''),
'buyer_email' => (string) ($row['buyer_email'] ?? ''),
'buyer_city' => (string) ($row['buyer_city'] ?? ''),

View File

@@ -73,6 +73,7 @@ final class AllegroIntegrationController
if (trim((string) ($settings['redirect_uri'] ?? '')) === '') {
$settings['redirect_uri'] = $defaultRedirectUri;
}
$this->ensureDefaultSchedulesExist();
$importIntervalSeconds = $this->currentImportIntervalSeconds();
$statusSyncDirection = $this->currentStatusSyncDirection();
$statusSyncIntervalMinutes = $this->currentStatusSyncIntervalMinutes();
@@ -99,7 +100,10 @@ final class AllegroIntegrationController
'orderDeliveryMethods' => $this->deliveryMappings !== null ? $this->deliveryMappings->getDistinctOrderDeliveryMethods() : [],
'allegroDeliveryServices' => $deliveryServicesData[0],
'allegroDeliveryServicesError' => $deliveryServicesData[1],
'inpostDeliveryServices' => $this->inpostServicesList(),
'inpostDeliveryServices' => array_values(array_filter(
$deliveryServicesData[0],
static fn(array $svc) => stripos((string) ($svc['carrierId'] ?? ''), 'inpost') !== false
)),
], 'layouts/app');
return Response::html($html);
@@ -146,6 +150,7 @@ final class AllegroIntegrationController
'orders_fetch_enabled' => (string) $request->input('orders_fetch_enabled', '0') === '1',
'orders_fetch_start_date' => $ordersFetchStartDate,
]);
$this->ensureDefaultSchedulesExist();
Flash::set('settings_success', $this->translator->get('settings.allegro.flash.saved'));
} catch (Throwable $exception) {
Flash::set(
@@ -544,26 +549,6 @@ final class AllegroIntegrationController
return Response::redirect('/settings/integrations/allegro?tab=delivery');
}
/**
* @return array<int, array{id: string, name: string}>
*/
private function inpostServicesList(): array
{
return [
['id' => 'inpost_locker_standard', 'name' => 'Paczkomat Standard'],
['id' => 'inpost_locker_economy', 'name' => 'Paczkomat Economy'],
['id' => 'inpost_locker_allegro', 'name' => 'Allegro Paczkomat InPost'],
['id' => 'inpost_courier_standard', 'name' => 'Kurier InPost'],
['id' => 'inpost_courier_express_1000', 'name' => 'Kurier InPost Express 10:00'],
['id' => 'inpost_courier_express_1200', 'name' => 'Kurier InPost Express 12:00'],
['id' => 'inpost_courier_express_1700', 'name' => 'Kurier InPost Express 17:00'],
['id' => 'inpost_courier_palette', 'name' => 'Kurier InPost Paleta'],
['id' => 'inpost_courier_c2c', 'name' => 'Kurier InPost C2C'],
['id' => 'inpost_courier_local_standard', 'name' => 'Kurier InPost Lokalny'],
['id' => 'inpost_courier_local_express', 'name' => 'Kurier InPost Lokalny Express'],
];
}
/**
* @param array<string, mixed> $settings
* @return array{0: array<int, array<string, mixed>>, 1: string}
@@ -862,4 +847,32 @@ final class AllegroIntegrationController
self::STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO,
];
}
private function ensureDefaultSchedulesExist(): void
{
try {
if ($this->findImportSchedule() === []) {
$this->cronRepository->upsertSchedule(
self::ORDERS_IMPORT_JOB_TYPE,
self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS,
self::ORDERS_IMPORT_DEFAULT_PRIORITY,
self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS,
self::ORDERS_IMPORT_DEFAULT_PAYLOAD,
true
);
}
if ($this->findStatusSyncSchedule() === []) {
$this->cronRepository->upsertSchedule(
self::STATUS_SYNC_JOB_TYPE,
self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES * 60,
self::ORDERS_IMPORT_DEFAULT_PRIORITY,
self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS,
null,
true
);
}
} catch (Throwable) {
// non-critical: schedules will be created when user explicitly saves import settings
}
}
}

View File

@@ -368,12 +368,10 @@ final class AllegroOrderImportService
$pickupAddress = is_array($pickupPoint['address'] ?? null) ? $pickupPoint['address'] : [];
if ($deliveryAddress !== [] || $pickupAddress !== []) {
$isPickupPointDelivery = $pickupAddress !== [];
$name = $isPickupPointDelivery
? $this->nullableString((string) ($pickupPoint['name'] ?? ''))
: $this->fallbackName($deliveryAddress, 'Dostawa');
if ($name === null) {
$name = 'Dostawa';
}
// Always use recipient's personal data from delivery.address for name/phone/email.
// For pickup points, delivery.address still holds the recipient's data (not the machine location).
$name = $this->fallbackName($deliveryAddress, 'Dostawa');
$street = $isPickupPointDelivery
? $this->nullableString((string) ($pickupAddress['street'] ?? ''))
@@ -388,10 +386,13 @@ final class AllegroOrderImportService
? $this->nullableString((string) ($pickupAddress['countryCode'] ?? ''))
: $this->nullableString((string) ($deliveryAddress['countryCode'] ?? ''));
$deliveryPhone = trim((string) ($deliveryAddress['phoneNumber'] ?? ''));
$buyerPhone = trim((string) ($buyer['phoneNumber'] ?? ''));
$result[] = [
'address_type' => 'delivery',
'name' => $name,
'phone' => $this->nullableString((string) ($deliveryAddress['phoneNumber'] ?? '')),
'phone' => $this->nullableString($deliveryPhone !== '' ? $deliveryPhone : $buyerPhone),
'email' => $this->nullableString((string) ($deliveryAddress['email'] ?? $buyer['email'] ?? '')),
'street_name' => $street,
'street_number' => null,

View File

@@ -4,15 +4,21 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use App\Modules\Cron\CronRepository;
use PDO;
use Throwable;
final class AllegroStatusSyncService
{
private const DIRECTION_ALLEGRO_TO_ORDERPRO = 'allegro_to_orderpro';
private const DIRECTION_ORDERPRO_TO_ALLEGRO = 'orderpro_to_allegro';
private const FINAL_STATUSES = ['anulowane', 'cancelled', 'returned', 'zwrocone'];
private const MAX_ORDERS_PER_RUN = 50;
public function __construct(
private readonly CronRepository $cronRepository,
private readonly AllegroOrdersSyncService $ordersSyncService
private readonly AllegroOrderImportService $orderImportService,
private readonly PDO $pdo
) {
}
@@ -38,16 +44,60 @@ final class AllegroStatusSyncService
];
}
$ordersResult = $this->ordersSyncService->sync([
'max_pages' => 3,
'page_limit' => 50,
'max_orders' => 100,
]);
$orders = $this->findOrdersNeedingStatusSync();
return [
$result = [
'ok' => true,
'direction' => $direction,
'orders_sync' => $ordersResult,
'processed' => 0,
'failed' => 0,
'errors' => [],
];
foreach ($orders as $order) {
$sourceOrderId = (string) ($order['source_order_id'] ?? '');
try {
$this->orderImportService->importSingleOrder($sourceOrderId);
$result['processed']++;
} catch (Throwable $exception) {
$result['failed']++;
$errors = is_array($result['errors']) ? $result['errors'] : [];
if (count($errors) < 20) {
$errors[] = [
'source_order_id' => $sourceOrderId,
'error' => $exception->getMessage(),
];
}
$result['errors'] = $errors;
}
}
return $result;
}
/**
* @return array<int, array<string, mixed>>
*/
private function findOrdersNeedingStatusSync(): array
{
$placeholders = implode(',', array_fill(0, count(self::FINAL_STATUSES), '?'));
try {
$statement = $this->pdo->prepare(
'SELECT id, source_order_id, external_status_id
FROM orders
WHERE source = ?
AND LOWER(COALESCE(external_status_id, "")) NOT IN (' . $placeholders . ')
ORDER BY source_updated_at DESC
LIMIT ' . self::MAX_ORDERS_PER_RUN
);
$params = array_merge(['allegro'], self::FINAL_STATUSES);
$statement->execute($params);
return $statement->fetchAll(PDO::FETCH_ASSOC) ?: [];
} catch (Throwable) {
return [];
}
}
}

View File

@@ -104,17 +104,11 @@ final class AllegroShipmentService
$codAmount = (float) ($formData['cod_amount'] ?? 0);
if ($codAmount > 0) {
$cod = [
// Allegro WZA manages COD funds internally iban/ownerName are not accepted
$apiPayload['input']['cashOnDelivery'] = [
'amount' => number_format($codAmount, 2, '.', ''),
'currency' => strtoupper(trim((string) ($formData['cod_currency'] ?? 'PLN'))),
];
if (trim($company['bank_owner_name']) !== '') {
$cod['ownerName'] = $company['bank_owner_name'];
}
if (trim($company['bank_account']) !== '') {
$cod['iban'] = $company['bank_account'];
}
$apiPayload['input']['cashOnDelivery'] = $cod;
}
$credentialsId = trim((string) ($formData['credentials_id'] ?? ''));
@@ -266,14 +260,28 @@ final class AllegroShipmentService
$filePath = $dir . '/' . $filename;
file_put_contents($filePath, $binary);
$relativePath = 'labels/' . $filename;
$this->packages->update($packageId, [
$updateFields = [
'status' => 'label_ready',
'label_path' => $relativePath,
]);
'label_path' => 'labels/' . $filename,
];
// Refresh tracking number if not yet saved (may not have been available at creation time)
if (trim((string) ($package['tracking_number'] ?? '')) === '') {
try {
$details = $this->apiClient->getShipmentDetails($env, $accessToken, $shipmentId);
$trackingNumber = trim((string) ($details['waybill'] ?? ''));
if ($trackingNumber !== '') {
$updateFields['tracking_number'] = $trackingNumber;
}
} catch (Throwable) {
// non-critical label is still saved
}
}
$this->packages->update($packageId, $updateFields);
return [
'label_path' => $relativePath,
'label_path' => 'labels/' . $filename,
'full_path' => $filePath,
];
}

View File

@@ -68,6 +68,11 @@ final class ShipmentController
$deliveryServicesError = $exception->getMessage();
}
$inpostServices = array_values(array_filter(
$deliveryServices,
static fn(array $svc) => stripos((string) ($svc['carrierId'] ?? ''), 'inpost') !== false
));
$flashSuccess = (string) ($_SESSION['shipment_flash_success'] ?? '');
$flashError = (string) ($_SESSION['shipment_flash_error'] ?? '');
unset($_SESSION['shipment_flash_success'], $_SESSION['shipment_flash_error']);
@@ -96,32 +101,12 @@ final class ShipmentController
'flashSuccess' => $flashSuccess,
'flashError' => $flashError,
'deliveryMapping' => $deliveryMapping,
'inpostServices' => $this->inpostServicesList(),
'inpostServices' => $inpostServices,
], 'layouts/app');
return Response::html($html);
}
/**
* @return array<int, array{id: string, name: string}>
*/
private function inpostServicesList(): array
{
return [
['id' => 'inpost_locker_standard', 'name' => 'Paczkomat Standard'],
['id' => 'inpost_locker_economy', 'name' => 'Paczkomat Economy'],
['id' => 'inpost_locker_allegro', 'name' => 'Allegro Paczkomat InPost'],
['id' => 'inpost_courier_standard', 'name' => 'Kurier InPost'],
['id' => 'inpost_courier_express_1000', 'name' => 'Kurier InPost Express 10:00'],
['id' => 'inpost_courier_express_1200', 'name' => 'Kurier InPost Express 12:00'],
['id' => 'inpost_courier_express_1700', 'name' => 'Kurier InPost Express 17:00'],
['id' => 'inpost_courier_palette', 'name' => 'Kurier InPost Paleta'],
['id' => 'inpost_courier_c2c', 'name' => 'Kurier InPost C2C'],
['id' => 'inpost_courier_local_standard', 'name' => 'Kurier InPost Lokalny'],
['id' => 'inpost_courier_local_express', 'name' => 'Kurier InPost Lokalny Express'],
];
}
public function create(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
@@ -135,6 +120,10 @@ final class ShipmentController
return Response::redirect('/orders/' . $orderId . '/shipment/prepare');
}
$user = $this->auth->user();
$actorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : null;
$actorName = ($actorName !== null && $actorName !== '') ? $actorName : null;
try {
$result = $this->shipmentService->createShipment($orderId, [
'delivery_method_id' => (string) $request->input('delivery_method_id', ''),
@@ -163,9 +152,25 @@ final class ShipmentController
]);
$packageId = (int) ($result['package_id'] ?? 0);
$this->ordersRepository->recordActivity(
$orderId,
'shipment_created',
'Zlecono utworzenie przesylki WZA (ID paczki: ' . $packageId . ')',
['package_id' => $packageId, 'command_id' => $result['command_id'] ?? null],
'user',
$actorName
);
$_SESSION['shipment_flash_success'] = 'Komenda tworzenia przesylki wyslana. Sprawdz status.';
return Response::redirect('/orders/' . $orderId . '/shipment/prepare?check=' . $packageId);
} catch (Throwable $exception) {
$this->ordersRepository->recordActivity(
$orderId,
'shipment_error',
'Blad tworzenia przesylki: ' . $exception->getMessage(),
null,
'user',
$actorName
);
$_SESSION['shipment_flash_error'] = 'Blad tworzenia przesylki: ' . $exception->getMessage();
return Response::redirect('/orders/' . $orderId . '/shipment/prepare');
}
@@ -181,6 +186,16 @@ final class ShipmentController
try {
$result = $this->shipmentService->checkCreationStatus($packageId);
if (($result['status'] ?? '') === 'created') {
try {
$this->shipmentService->downloadLabel($packageId, $this->storagePath);
$result['status'] = 'label_ready';
} catch (Throwable) {
// label generation failed return created so user can retry manually
}
}
return Response::json($result);
} catch (Throwable $exception) {
return Response::json(['status' => 'error', 'error' => $exception->getMessage()]);
@@ -201,6 +216,10 @@ final class ShipmentController
return Response::redirect('/orders/' . $orderId . '/shipment/prepare');
}
$user = $this->auth->user();
$actorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : null;
$actorName = ($actorName !== null && $actorName !== '') ? $actorName : null;
try {
$result = $this->shipmentService->downloadLabel($packageId, $this->storagePath);
$fullPath = (string) ($result['full_path'] ?? '');
@@ -210,6 +229,15 @@ final class ShipmentController
$contentType = $labelFormat === 'ZPL' ? 'application/octet-stream' : 'application/pdf';
$filename = basename($fullPath);
$this->ordersRepository->recordActivity(
$orderId,
'shipment_label_downloaded',
'Pobrano etykiete dla przesylki #' . $packageId,
['package_id' => $packageId, 'filename' => $filename],
'user',
$actorName
);
return new Response(
(string) file_get_contents($fullPath),
200,
@@ -222,6 +250,14 @@ final class ShipmentController
$_SESSION['shipment_flash_success'] = 'Etykieta pobrana.';
} catch (Throwable $exception) {
$this->ordersRepository->recordActivity(
$orderId,
'shipment_error',
'Blad pobierania etykiety (paczka #' . $packageId . '): ' . $exception->getMessage(),
null,
'user',
$actorName
);
$_SESSION['shipment_flash_error'] = 'Blad pobierania etykiety: ' . $exception->getMessage();
}