This commit is contained in:
2026-04-22 22:54:26 +02:00
parent cd1ea4a9db
commit c73b2fe9f6
26 changed files with 1377 additions and 34 deletions

View File

@@ -23,6 +23,8 @@ use PDO;
final class Application
{
private static ?self $instance = null;
private Router $router;
private Template $template;
private AuthService $authService;
@@ -41,6 +43,7 @@ final class Application
private readonly string $basePath,
private readonly array $config
) {
self::$instance = $this;
$this->router = new Router();
$this->translator = new Translator(
(string) $this->config('app.lang_path'),
@@ -77,6 +80,11 @@ final class Application
$response->send();
}
public static function instance(): ?self
{
return self::$instance;
}
public function basePath(string $path = ''): string
{
if ($path === '') {

View File

@@ -47,6 +47,7 @@ use App\Modules\Settings\ShopproStatusMappingRepository;
use App\Modules\Settings\ShopproStatusSyncService;
use App\Modules\Shipments\AllegroTrackingService;
use App\Modules\Shipments\ApaczkaTrackingService;
use App\Modules\Shipments\DeliveryStatusMappingRepository;
use App\Modules\Shipments\InpostTrackingService;
use App\Modules\Shipments\ShipmentPackageRepository;
use App\Modules\Shipments\ShipmentTrackingRegistry;
@@ -173,7 +174,8 @@ final class CronHandlerFactory
),
]),
new ShipmentPackageRepository($this->db),
$automationService
$automationService,
new DeliveryStatusMappingRepository($this->db)
),
'automation_history_cleanup' => new AutomationHistoryCleanupHandler(
new AutomationExecutionLogRepository($this->db)

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\Automation\AutomationService;
use App\Modules\Shipments\DeliveryStatus;
use App\Modules\Shipments\DeliveryStatusMappingRepository;
use App\Modules\Shipments\ShipmentPackageRepository;
use App\Modules\Shipments\ShipmentTrackingRegistry;
use Throwable;
@@ -15,7 +17,8 @@ final class ShipmentTrackingHandler
public function __construct(
private readonly ShipmentTrackingRegistry $registry,
private readonly ShipmentPackageRepository $repository,
private readonly AutomationService $automationService
private readonly AutomationService $automationService,
private readonly ?DeliveryStatusMappingRepository $mappingRepository = null
) {
}
@@ -32,6 +35,10 @@ final class ShipmentTrackingHandler
$lastAllegroEdgeRequestTime = 0.0;
$overrides = $this->mappingRepository !== null
? $this->mappingRepository->getAllOverrides()
: [];
foreach ($packages as $package) {
$provider = trim((string) ($package['provider'] ?? ''));
$packageId = (int) ($package['id'] ?? 0);
@@ -68,6 +75,12 @@ final class ShipmentTrackingHandler
$newStatus = 'unknown';
}
$newStatusRaw = trim((string) ($result['status_raw'] ?? ''));
// Zastosuj override z DB (delivery_status_mappings) jesli istnieje dla (provider, raw).
// Dzieki temu admin moze bez zmian w kodzie przypisac nowe statusy kuriera przez UI.
if ($newStatusRaw !== '' && $overrides !== []) {
$newStatus = DeliveryStatus::normalizeWithOverrides($provider, $newStatusRaw, $overrides);
}
$statusChanged = $newStatus !== $previousStatus;
$statusRawChanged = $newStatusRaw !== $previousStatusRaw;
if (!$statusChanged && !$statusRawChanged) {

View File

@@ -231,6 +231,8 @@ final class OrdersController
$flashSuccess = (string) Flash::get('order.success', '');
$flashError = (string) Flash::get('order.error', '');
$customerRiskInfo = $this->buildCustomerRiskInfo($order, $orderId);
$html = $this->template->render('orders/show', [
'title' => $this->translator->get('orders.details.title') . ' #' . $orderId,
'activeMenu' => 'orders',
@@ -259,11 +261,69 @@ final class OrdersController
'receiptConfigs' => $activeReceiptConfigs,
'emailTemplates' => $emailTemplates,
'emailMailboxes' => $emailMailboxes,
'customerRiskInfo' => $customerRiskInfo,
], 'layouts/app');
return Response::html($html);
}
/**
* Sklada informacje o historii zwrotow klienta biezacego zamowienia.
*
* @param array<string, mixed> $order
* @return array{count:int, orders:array<int, array<string, mixed>>, email:string, phone:string, name:string, text:string}
*/
private function buildCustomerRiskInfo(array $order, int $orderId): array
{
$count = max(0, (int) ($order['customer_returned_count'] ?? 0));
$email = trim((string) ($order['buyer_email'] ?? ''));
$phone = trim((string) ($order['buyer_phone'] ?? ''));
$name = trim((string) ($order['buyer_name'] ?? ''));
$returnedOrders = [];
if ($count > 0 && $this->shipmentPackages !== null) {
$returnedOrders = $this->shipmentPackages->findReturnedByCustomer(
['email' => $email, 'phone' => $phone, 'name' => $name],
$orderId
);
}
return [
'count' => $count,
'orders' => $returnedOrders,
'email' => $email,
'phone' => $phone,
'name' => $name,
'text' => $this->composeCustomerRiskText($count, $email, $phone, $name),
];
}
private function composeCustomerRiskText(int $count, string $email, string $phone, string $name): string
{
if ($count <= 0) {
return '';
}
$hasPhone = $phone !== '';
$hasEmail = $email !== '';
$hasName = $name !== '';
if ($hasPhone && $hasEmail) {
$subject = 'Osoba o numerze telefonu ' . $phone . ' oraz email ' . $email;
} elseif ($hasEmail) {
$subject = 'Osoba o emailu ' . $email;
} elseif ($hasPhone) {
$subject = 'Osoba o numerze telefonu ' . $phone;
} elseif ($hasName) {
$subject = 'Osoba o imieniu i nazwisku ' . $name;
} else {
$subject = 'Ten klient';
}
$noun = $count === 1 ? 'przesylke' : 'przesylek';
return $subject . ' nie odebrala ' . $count . ' ' . $noun . '.';
}
public function updateDetails(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
@@ -429,6 +489,10 @@ final class OrdersController
$itemsPreview = is_array($row['items_preview'] ?? null) ? $row['items_preview'] : [];
$projectsDone = max(0, (int) ($row['projects_done'] ?? 0));
$projectsTotal = max(0, (int) ($row['projects_total'] ?? 0));
$returnedCount = max(0, (int) ($row['customer_returned_count'] ?? 0));
$returnedBadge = $returnedCount >= 1
? ' <span class="risk-return-badge" title="Klient nie odebral ' . $returnedCount . ' przesylek w historii">zwroty: ' . $returnedCount . '</span>'
: '';
$previewBtn = '<button type="button" class="btn-icon js-order-preview-btn" data-order-id="' . (int) ($row['id'] ?? 0) . '" title="Podglad">'
. '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'
@@ -446,7 +510,7 @@ final class OrdersController
. '</div>'
. '</div>',
'buyer' => '<div class="orders-buyer">'
. '<div class="orders-buyer__name">' . htmlspecialchars($buyerName !== '' ? $buyerName : '-', ENT_QUOTES, 'UTF-8') . '</div>'
. '<div class="orders-buyer__name">' . htmlspecialchars($buyerName !== '' ? $buyerName : '-', ENT_QUOTES, 'UTF-8') . $returnedBadge . '</div>'
. '<div class="orders-buyer__meta">'
. '<span>' . htmlspecialchars($buyerEmail, ENT_QUOTES, 'UTF-8') . '</span>'
. '<span>' . htmlspecialchars($buyerCity, ENT_QUOTES, 'UTF-8') . '</span>'
@@ -466,7 +530,7 @@ final class OrdersController
$documents
),
'ordered_at' => (string) ($row['ordered_at'] ?? ''),
'_row_class' => $this->agedRowClass((string) ($row['ordered_at'] ?? '')),
'_row_class' => trim($this->agedRowClass((string) ($row['ordered_at'] ?? '')) . ($returnedCount >= 1 ? ' is-risk-return' : '')),
];
}

View File

@@ -182,7 +182,8 @@ final class OrdersRepository
COALESCE(oi_agg.projects_total, 0) AS projects_total,
COALESCE(sh_agg.shipments_count, 0) AS shipments_count,
COALESCE(od_agg.documents_count, 0) AS documents_count,
ig.name AS integration_name
ig.name AS integration_name,
' . $this->customerReturnedCountSubquerySql('o', 'a') . ' AS customer_returned_count
FROM orders o
LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code
@@ -246,6 +247,7 @@ final class OrdersRepository
'items_preview' => (array) ($itemPreviewsByOrderId[$orderId] ?? []),
'projects_done' => (int) ($row['projects_done'] ?? 0),
'projects_total' => (int) ($row['projects_total'] ?? 0),
'customer_returned_count' => max(0, (int) ($row['customer_returned_count'] ?? 0)),
];
}
@@ -479,10 +481,15 @@ final class OrdersRepository
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
$orderStmt = $this->pdo->prepare(
'SELECT o.*, ' . $effectiveStatusSql . ' AS effective_status_id,
ig.name AS integration_name
ig.name AS integration_name,
a.email AS buyer_email,
a.phone AS buyer_phone,
a.name AS buyer_name,
' . $this->customerReturnedCountSubquerySql('o', 'a') . ' AS customer_returned_count
FROM orders o
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code
LEFT JOIN integrations ig ON ig.id = o.integration_id
LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"
WHERE o.id = :id
LIMIT 1'
);
@@ -491,6 +498,7 @@ final class OrdersRepository
if (!is_array($order)) {
return null;
}
$order['customer_returned_count'] = max(0, (int) ($order['customer_returned_count'] ?? 0));
return [
'order' => $order,
@@ -669,6 +677,38 @@ final class OrdersRepository
return $result;
}
/**
* Subquery zliczajaca zamowienia klienta biezacego wiersza, ktore w historii
* mialy paczke z delivery_status='returned' (zwrot do nadawcy).
* Matching po email LUB phone (tylko cyfry, min 6) LUB name — identyczne dopasowanie
* po LOWER/TRIM. Wyklucza biezace zamowienie (self-exclusion).
*
* Wymagania: MySQL 8.0+ (REGEXP_REPLACE).
*
* @param string $orderAlias alias tabeli orders w outer query (np. 'o')
* @param string $addressAlias alias joina order_addresses (customer) w outer query (np. 'a')
*/
private function customerReturnedCountSubquerySql(string $orderAlias, string $addressAlias): string
{
return '(SELECT COUNT(DISTINCT sp.order_id)
FROM shipment_packages sp
INNER JOIN order_addresses a2
ON a2.order_id = sp.order_id AND a2.address_type = "customer"
WHERE sp.delivery_status = "returned"
AND sp.order_id != ' . $orderAlias . '.id
AND (
(' . $addressAlias . '.email IS NOT NULL AND ' . $addressAlias . '.email <> ""
AND LOWER(TRIM(a2.email)) = LOWER(TRIM(' . $addressAlias . '.email)))
OR
(' . $addressAlias . '.phone IS NOT NULL
AND LENGTH(REGEXP_REPLACE(' . $addressAlias . '.phone, "[^0-9]+", "")) >= 6
AND REGEXP_REPLACE(a2.phone, "[^0-9]+", "") = REGEXP_REPLACE(' . $addressAlias . '.phone, "[^0-9]+", ""))
OR
(' . $addressAlias . '.name IS NOT NULL AND ' . $addressAlias . '.name <> ""
AND LOWER(TRIM(a2.name)) = LOWER(TRIM(' . $addressAlias . '.name)))
))';
}
private function effectiveStatusSql(string $orderAlias, string $mappingAlias): string
{
return 'CASE

View File

@@ -48,6 +48,7 @@ final class DeliveryStatusMappingController
}
$mappings = [];
$knownRawStatuses = [];
foreach ($defaults as $rawStatus => $default) {
$isCustom = isset($overrideMap[$rawStatus]);
$mappings[] = [
@@ -56,8 +57,25 @@ final class DeliveryStatusMappingController
'normalized_status' => $isCustom ? $overrideMap[$rawStatus]['normalized_status'] : $default['normalized'],
'is_custom' => $isCustom,
];
$knownRawStatuses[$rawStatus] = true;
}
// Overrides moga zawierac wpisy spoza defaultow (dodane wczesniej w reakcji na nowy status kuriera)
foreach ($overrideMap as $rawStatus => $row) {
if (isset($knownRawStatuses[$rawStatus])) {
continue;
}
$mappings[] = [
'raw_status' => $rawStatus,
'description' => $row['description'],
'normalized_status' => $row['normalized_status'],
'is_custom' => true,
];
$knownRawStatuses[$rawStatus] = true;
}
$unmappedRawStatuses = $this->repository->listUnmappedRawStatuses($provider, $knownRawStatuses);
$html = $this->template->render('settings/delivery-status-mappings', [
'title' => 'Mapowanie statusów dostawy',
'activeMenu' => 'settings',
@@ -67,6 +85,7 @@ final class DeliveryStatusMappingController
'provider' => $provider,
'providers' => self::PROVIDERS,
'mappings' => $mappings,
'unmappedRawStatuses' => $unmappedRawStatuses,
'normalizedOptions' => DeliveryStatus::LABEL_PL,
'errorMessage' => (string) Flash::get('dsm_error', ''),
'successMessage' => (string) Flash::get('dsm_success', ''),

View File

@@ -135,10 +135,12 @@ final class DeliveryStatus
'PENDING' => self::CREATED,
'CONFIRMED' => self::CONFIRMED,
'PICKED_UP' => self::IN_TRANSIT,
'PICKUP' => self::IN_TRANSIT,
'IN_TRANSIT' => self::IN_TRANSIT,
'OUT_FOR_DELIVERY' => self::OUT_FOR_DELIVERY,
'DELIVERED' => self::DELIVERED,
'RETURNED' => self::RETURNED,
'RETURNED_TO_SHIPPER' => self::RETURNED,
'CANCELLED' => self::CANCELLED,
'ERROR' => self::PROBLEM,
'WAITING_FOR_PICKUP' => self::READY_FOR_PICKUP,
@@ -161,10 +163,12 @@ final class DeliveryStatus
'PENDING' => 'Oczekuje na przetworzenie',
'CONFIRMED' => 'Zamówienie potwierdzone',
'PICKED_UP' => 'Odebrana przez kuriera',
'PICKUP' => 'Odebrana przez kuriera',
'IN_TRANSIT' => 'W transporcie',
'OUT_FOR_DELIVERY' => 'W doręczeniu',
'DELIVERED' => 'Doręczona',
'RETURNED' => 'Zwrócona do nadawcy',
'RETURNED_TO_SHIPPER' => 'Zwrócona do nadawcy',
'CANCELLED' => 'Anulowana',
'ERROR' => 'Błąd zamówienia',
'WAITING_FOR_PICKUP' => 'Oczekuje na odbiór w punkcie',
@@ -174,6 +178,7 @@ final class DeliveryStatus
private const ALLEGRO_MAP = [
'NEW' => self::CREATED,
'READY_TO_SHIP' => self::CONFIRMED,
'collected_from_sender' => self::IN_TRANSIT,
'IN_TRANSIT' => self::IN_TRANSIT,
'DELIVERED' => self::DELIVERED,
'CANCELLED' => self::CANCELLED,
@@ -184,6 +189,7 @@ final class DeliveryStatus
private const ALLEGRO_DESCRIPTIONS = [
'NEW' => 'Przesyłka utworzona',
'READY_TO_SHIP' => 'Etykieta wygenerowana, oczekuje na nadanie',
'collected_from_sender' => 'Odebrana od nadawcy',
'IN_TRANSIT' => 'Odebrana przez przewoźnika',
'DELIVERED' => 'Doręczona',
'CANCELLED' => 'Anulowana',

View File

@@ -97,6 +97,114 @@ final class DeliveryStatusMappingRepository
$statement->execute(['provider' => $provider]);
}
/**
* Niezmapowane raw statusy wykryte w shipment_packages.
* Zwraca tylko te, ktore nie wystepuja ani w defaultach kodu, ani w overrides DB dla danego providera.
*
* @param array<string, array{normalized: string, description: string}> $knownRawStatuses keyed by raw_status (unia defaultow + overrides)
* @return array<int, array{raw_status: string, count: int, last_seen: ?string}>
*/
public function listUnmappedRawStatuses(string $provider, array $knownRawStatuses): array
{
$provider = strtolower(trim($provider));
if ($provider === '') {
return [];
}
$statement = $this->pdo->prepare(
"SELECT delivery_status_raw AS raw_status,
COUNT(*) AS cnt,
MAX(delivery_status_updated_at) AS last_seen
FROM shipment_packages
WHERE provider = :provider
AND delivery_status_raw IS NOT NULL
AND delivery_status_raw <> ''
GROUP BY delivery_status_raw
ORDER BY cnt DESC, raw_status ASC"
);
$statement->execute(['provider' => $provider]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$rawStatus = (string) ($row['raw_status'] ?? '');
if ($rawStatus === '' || isset($knownRawStatuses[$rawStatus])) {
continue;
}
$result[] = [
'raw_status' => $rawStatus,
'count' => (int) ($row['cnt'] ?? 0),
'last_seen' => $row['last_seen'] !== null ? (string) $row['last_seen'] : null,
];
}
return $result;
}
/**
* Zwraca sumaryczna liczbe niezmapowanych raw statusow dla wszystkich providerow obslugiwanych w UI.
* Wykorzystywane przez badge w menu. Rezultat cache'owany w pamieci per-request.
*/
public function countAllUnmappedForBadge(): int
{
static $cached = null;
if ($cached !== null) {
return $cached;
}
$providers = ['inpost', 'apaczka', 'allegro_wza'];
$knownKeysByProvider = [];
foreach ($providers as $prov) {
$knownKeysByProvider[$prov] = [];
foreach (DeliveryStatus::getDefaultMappings($prov) as $raw => $_) {
$knownKeysByProvider[$prov][$raw] = true;
}
foreach ($this->listByProvider($prov) as $row) {
$knownKeysByProvider[$prov][$row['raw_status']] = true;
}
}
$cached = $this->countUnmappedRawStatuses($knownKeysByProvider);
return $cached;
}
/**
* Sumaryczna liczba niezmapowanych raw statusow we wszystkich providerach.
* Wykorzystywane przez badge w menu.
*/
public function countUnmappedRawStatuses(array $knownKeysByProvider): int
{
$statement = $this->pdo->query(
"SELECT provider, delivery_status_raw
FROM shipment_packages
WHERE delivery_status_raw IS NOT NULL
AND delivery_status_raw <> ''
GROUP BY provider, delivery_status_raw"
);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return 0;
}
$count = 0;
foreach ($rows as $row) {
$provider = (string) ($row['provider'] ?? '');
$rawStatus = (string) ($row['delivery_status_raw'] ?? '');
if ($provider === '' || $rawStatus === '') {
continue;
}
$known = $knownKeysByProvider[$provider] ?? [];
if (!isset($known[$rawStatus])) {
$count++;
}
}
return $count;
}
/**
* @return array<string, array{normalized_status: string, description: string}>
*/

View File

@@ -219,6 +219,78 @@ final class ShipmentPackageRepository
$statement->execute(['id' => $id]);
}
/**
* Lista paczek klienta (match po email/phone/name OR) ze statusem dostawy "returned"
* z wykluczeniem biezacego zamowienia. Uzywane do banera ostrzegawczego.
*
* @param array{email?:string,phone?:string,name?:string} $customer
* @return array<int, array{order_id:int,internal_order_number:string,ordered_at:string,tracking_number:string,provider:string,delivery_status_raw:string}>
*/
public function findReturnedByCustomer(array $customer, int $excludeOrderId, int $limit = 10): array
{
$email = trim((string) ($customer['email'] ?? ''));
$phone = trim((string) ($customer['phone'] ?? ''));
$name = trim((string) ($customer['name'] ?? ''));
$phoneDigits = preg_replace('/[^0-9]+/', '', $phone) ?? '';
if ($email === '' && strlen($phoneDigits) < 6 && $name === '') {
return [];
}
$limit = max(1, min($limit, 50));
$sql = 'SELECT sp.order_id,
o.internal_order_number,
COALESCE(o.ordered_at, o.source_created_at, o.fetched_at) AS ordered_at,
COALESCE(sp.tracking_number, "") AS tracking_number,
COALESCE(sp.provider, "") AS provider,
COALESCE(sp.delivery_status_raw, "") AS delivery_status_raw
FROM shipment_packages sp
INNER JOIN orders o ON o.id = sp.order_id
INNER JOIN order_addresses a2 ON a2.order_id = sp.order_id AND a2.address_type = "customer"
WHERE sp.delivery_status = "returned"
AND sp.order_id != :exclude_id
AND (
(:email <> "" AND LOWER(TRIM(a2.email)) = LOWER(TRIM(:email)))
OR
(LENGTH(:phone_digits) >= 6
AND REGEXP_REPLACE(a2.phone, "[^0-9]+", "") = :phone_digits)
OR
(:name <> "" AND LOWER(TRIM(a2.name)) = LOWER(TRIM(:name)))
)
GROUP BY sp.order_id
ORDER BY ordered_at DESC
LIMIT :lim';
try {
$statement = $this->pdo->prepare($sql);
$statement->bindValue(':exclude_id', $excludeOrderId, PDO::PARAM_INT);
$statement->bindValue(':email', $email, PDO::PARAM_STR);
$statement->bindValue(':phone_digits', $phoneDigits, PDO::PARAM_STR);
$statement->bindValue(':name', $name, PDO::PARAM_STR);
$statement->bindValue(':lim', $limit, PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable) {
return [];
}
if (!is_array($rows)) {
return [];
}
return array_map(static function (array $row): array {
return [
'order_id' => (int) ($row['order_id'] ?? 0),
'internal_order_number' => (string) ($row['internal_order_number'] ?? ''),
'ordered_at' => (string) ($row['ordered_at'] ?? ''),
'tracking_number' => (string) ($row['tracking_number'] ?? ''),
'provider' => (string) ($row['provider'] ?? ''),
'delivery_status_raw' => (string) ($row['delivery_status_raw'] ?? ''),
];
}, $rows);
}
private function nullStr(string $value): ?string
{
$trimmed = trim($value);