This commit is contained in:
2026-04-07 20:32:43 +02:00
parent 1933c74395
commit 8fa9ca6439
45 changed files with 2974 additions and 3382 deletions

View File

@@ -28,6 +28,33 @@ final class StringHelper
}
}
private const COD_PAYMENT_TYPES = [
'CASH_ON_DELIVERY',
'COD',
'POBRANIE',
'ZA POBRANIEM',
];
private const COD_PAYMENT_KEYWORDS = [
'PRZY ODBIORZE',
'POBRANIEM',
'POBRANIE',
];
public static function isCodPayment(string $value): bool
{
$normalized = strtoupper(trim($value));
if (in_array($normalized, self::COD_PAYMENT_TYPES, true)) {
return true;
}
foreach (self::COD_PAYMENT_KEYWORDS as $keyword) {
if (str_contains($normalized, $keyword)) {
return true;
}
}
return false;
}
public static function normalizeColorHex(string $value): string
{
$trimmed = trim($value);

View File

@@ -38,6 +38,7 @@ use App\Modules\Settings\ShopproApiClient;
use App\Modules\Settings\ShopproIntegrationsRepository;
use App\Modules\Settings\ShopproOrderMapper;
use App\Modules\Settings\ShopproOrdersSyncService;
use App\Modules\Settings\ShopproPullStatusMappingRepository;
use App\Modules\Settings\ShopproOrderSyncStateRepository;
use App\Modules\Settings\ShopproPaymentStatusSyncService;
use App\Modules\Settings\ShopproProductImageResolver;
@@ -90,6 +91,7 @@ final class CronHandlerFactory
$shopproApiClient = new ShopproApiClient();
$shopproSyncStateRepo = new ShopproOrderSyncStateRepository($this->db);
$shopproStatusMappingRepo = new ShopproStatusMappingRepository($this->db);
$shopproPullStatusMappingRepo = new ShopproPullStatusMappingRepository($this->db);
$shopproSyncService = new ShopproOrdersSyncService(
$shopproIntegrationsRepo,
$shopproSyncStateRepo,
@@ -98,7 +100,8 @@ final class CronHandlerFactory
$shopproStatusMappingRepo,
$ordersRepository,
new ShopproOrderMapper(),
new ShopproProductImageResolver($shopproApiClient)
new ShopproProductImageResolver($shopproApiClient),
$shopproPullStatusMappingRepo
);
$shopproStatusSyncService = new ShopproStatusSyncService(
$shopproIntegrationsRepo,

View File

@@ -38,6 +38,17 @@ final class OrderImportRepository
$sourceOrderId = trim((string) ($orderData['source_order_id'] ?? ''));
$existingOrderId = $this->findOrderIdBySource($source, $sourceOrderId);
$created = $existingOrderId === null;
$paymentTransition = false;
if (!$created) {
$currentStatus = $this->getCurrentStatus($existingOrderId);
$newPaymentStatus = (int) ($orderData['payment_status'] ?? 0);
$paymentTransition = $currentStatus === 'nieoplacone' && $newPaymentStatus === 2;
if (!$paymentTransition) {
$orderData['external_status_id'] = $currentStatus;
}
}
$orderId = $created
? $this->insertOrder($orderData)
: $this->updateOrder($existingOrderId, $orderData);
@@ -50,6 +61,8 @@ final class OrderImportRepository
$this->replacePayments($orderId, $payments);
$this->replaceShipments($orderId, $shipments);
$this->replaceStatusHistory($orderId, $statusHistory);
} elseif ($paymentTransition) {
$this->replacePayments($orderId, $payments);
}
$this->pdo->commit();
@@ -63,6 +76,7 @@ final class OrderImportRepository
return [
'order_id' => $orderId,
'created' => $created,
'payment_transition' => $paymentTransition,
];
}
@@ -87,6 +101,17 @@ final class OrderImportRepository
return $id > 0 ? $id : null;
}
private function getCurrentStatus(int $orderId): string
{
$statement = $this->pdo->prepare(
'SELECT external_status_id FROM orders WHERE id = :id LIMIT 1'
);
$statement->execute(['id' => $orderId]);
$value = $statement->fetchColumn();
return strtolower(trim((string) ($value ?: '')));
}
/**
* @param array<string, mixed> $orderData
*/

View File

@@ -340,7 +340,7 @@ final class OrdersController
$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';
$isCod = StringHelper::isCodPayment($paymentType);
$paymentStatus = isset($row['payment_status']) ? (int) $row['payment_status'] : null;
$isUnpaid = !$isCod && $paymentStatus === 0;
$itemsCount = max(0, (int) ($row['items_count'] ?? 0));
@@ -661,7 +661,7 @@ final class OrdersController
$html .= '<div class="orders-product">'
. $thumb
. '<div class="orders-product__txt">'
. '<div class="orders-product__name">' . htmlspecialchars($name !== '' ? $name : '-', ENT_QUOTES, 'UTF-8') . '</div>'
. '<div class="orders-product__name"' . ($name !== '' ? ' title="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '"' : '') . '>' . htmlspecialchars($name !== '' ? $name : '-', ENT_QUOTES, 'UTF-8') . '</div>'
. '<div class="orders-product__qty">' . htmlspecialchars($qty, ENT_QUOTES, 'UTF-8') . ' szt.</div>'
. '</div>'
. '</div>';
@@ -914,4 +914,17 @@ final class OrdersController
}
}
public function quickSearch(Request $request): Response
{
$query = trim((string) $request->input('q', ''));
if ($query === '' || mb_strlen($query) < 2) {
return Response::json(['results' => []]);
}
$limit = min((int) $request->input('limit', 10), 20);
$results = $this->orders->quickSearch($query, $limit);
return Response::json(['results' => $results]);
}
}

View File

@@ -987,4 +987,60 @@ final class OrdersRepository
return $code;
}
/**
* @return array<int, array{id:int, order_number:string, buyer_name:string, buyer_email:string, buyer_phone:string}>
*/
public function quickSearch(string $query, int $limit = 10): array
{
$query = trim($query);
if ($query === '' || mb_strlen($query) < 2) {
return [];
}
$limit = max(1, min($limit, 20));
$searchVal = '%' . $query . '%';
$sql = 'SELECT o.id, o.source_order_id, o.external_order_id, '
. 'a.name AS buyer_name, a.email AS buyer_email, a.phone AS buyer_phone '
. 'FROM orders o '
. 'LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer" '
. 'WHERE (o.source_order_id LIKE :s1 OR o.external_order_id LIKE :s2 '
. 'OR a.name LIKE :s3 OR a.email LIKE :s4 OR a.phone LIKE :s5 '
. 'OR EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.original_name LIKE :s6)) '
. 'ORDER BY o.ordered_at DESC LIMIT :lim';
try {
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':s1', $searchVal);
$stmt->bindValue(':s2', $searchVal);
$stmt->bindValue(':s3', $searchVal);
$stmt->bindValue(':s4', $searchVal);
$stmt->bindValue(':s5', $searchVal);
$stmt->bindValue(':s6', $searchVal);
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map(static function (array $row): array {
$orderNumber = ((string) ($row['source_order_id'] ?? '')) !== ''
? (string) $row['source_order_id']
: (string) ($row['external_order_id'] ?? '');
return [
'id' => (int) ($row['id'] ?? 0),
'order_number' => $orderNumber,
'buyer_name' => (string) ($row['buyer_name'] ?? ''),
'buyer_email' => (string) ($row['buyer_email'] ?? ''),
'buyer_phone' => (string) ($row['buyer_phone'] ?? ''),
];
}, $rows);
} catch (Throwable) {
return [];
}
}
}

View File

@@ -39,6 +39,7 @@ final class ShopproIntegrationsController
private readonly AuthService $auth,
private readonly ShopproIntegrationsRepository $repository,
private readonly ShopproStatusMappingRepository $statusMappings,
private readonly ShopproPullStatusMappingRepository $pullStatusMappings,
private readonly OrderStatusRepository $orderStatuses,
private readonly CronRepository $cronRepository,
private readonly CarrierDeliveryMethodMappingRepository $deliveryMappings,
@@ -71,6 +72,9 @@ final class ShopproIntegrationsController
$mappingIndex = $integrationId > 0
? $this->buildMappingIndex($integrationId)
: [];
$pullMappingIndex = $integrationId > 0
? $this->buildPullMappingIndex($integrationId)
: [];
$shopproStatuses = $integrationId > 0
? $this->buildExternalStatusOptions($integrationId, $discoveredStatuses)
: [];
@@ -98,6 +102,7 @@ final class ShopproIntegrationsController
'statusSyncIntervalMinutes' => $this->currentStatusSyncIntervalMinutes(),
'paymentSyncIntervalMinutes' => $this->currentPaymentSyncIntervalMinutes(),
'mappingIndex' => $mappingIndex,
'pullMappingIndex' => $pullMappingIndex,
'orderproStatuses' => $this->orderStatuses->listStatuses(),
'shopproStatuses' => $shopproStatuses,
'deliveryMappings' => $deliveryMappings,
@@ -273,6 +278,60 @@ final class ShopproIntegrationsController
return Response::redirect($redirectTo);
}
public function savePullStatusMappings(Request $request): Response
{
$integrationId = max(0, (int) $request->input('integration_id', 0));
$redirectTo = $this->buildRedirectUrl($integrationId, 'statuses');
$accessError = $this->validateCsrfAndIntegrationAccess((string) $request->input('_token', ''), $integrationId, 'statuses');
if ($accessError !== null) {
return $accessError;
}
$shopCodes = $request->input('shoppro_status_code', []);
$shopNames = $request->input('shoppro_status_name', []);
$orderCodes = $request->input('orderpro_status_code', []);
if (!is_array($shopCodes) || !is_array($shopNames) || !is_array($orderCodes)) {
Flash::set('settings_error', $this->translator->get('settings.order_statuses.pull.save_failed'));
return Response::redirect($redirectTo);
}
$allowedOrderpro = $this->resolveAllowedOrderproStatusCodes();
$rowsCount = min(count($shopCodes), count($shopNames), count($orderCodes));
$mappings = [];
for ($index = 0; $index < $rowsCount; $index++) {
$shopCode = trim((string) ($shopCodes[$index] ?? ''));
$shopName = trim((string) ($shopNames[$index] ?? ''));
$orderCode = strtolower(trim((string) ($orderCodes[$index] ?? '')));
if ($shopCode === '' || $orderCode === '') {
continue;
}
if (!isset($allowedOrderpro[$orderCode])) {
continue;
}
$mappings[] = [
'shoppro_status_code' => $shopCode,
'shoppro_status_name' => $shopName,
'orderpro_status_code' => $orderCode,
];
}
try {
$this->pullStatusMappings->replaceForIntegration($integrationId, $mappings);
Flash::set('settings_success', $this->translator->get('settings.order_statuses.pull.saved'));
} catch (Throwable $exception) {
Flash::set(
'settings_error',
$this->translator->get('settings.order_statuses.pull.save_failed') . ' ' . $exception->getMessage()
);
}
return Response::redirect($redirectTo);
}
public function syncStatuses(Request $request): Response
{
$integrationId = max(0, (int) $request->input('integration_id', 0));
@@ -517,6 +576,28 @@ final class ShopproIntegrationsController
return $index;
}
/**
* @return array<string, array{orderpro_status_code:string}>
*/
private function buildPullMappingIndex(int $integrationId): array
{
$rows = $this->pullStatusMappings->listByIntegration($integrationId);
$index = [];
foreach ($rows as $row) {
$shopproCode = trim((string) ($row['shoppro_status_code'] ?? ''));
if ($shopproCode === '') {
continue;
}
$index[$shopproCode] = [
'orderpro_status_code' => strtolower(trim((string) ($row['orderpro_status_code'] ?? ''))),
];
}
return $index;
}
/**
* @param array<int, array{code:string,name:string}> $discoveredStatuses
* @return array<int, array{code:string,name:string}>

View File

@@ -136,7 +136,7 @@ final class ShopproOrderMapper
'external_platform_id' => IntegrationSources::SHOPPRO,
'external_platform_account_id' => null,
'external_status_id' => $effectiveStatus,
'external_payment_type_id' => StringHelper::nullableString((string) $this->readPath($payload, ['payment_method', 'payment.method', 'payments.method'])),
'external_payment_type_id' => $this->normalizeCodPaymentType((string) $this->readPath($payload, ['payment_method', 'payment.method', 'payments.method'])),
'payment_status' => $this->mapPaymentStatus($payload, $isPaid),
'external_carrier_id' => StringHelper::nullableString($deliveryLabel),
'external_carrier_account_id' => StringHelper::nullableString((string) $this->readPath($payload, [
@@ -603,6 +603,16 @@ final class ShopproOrderMapper
}
}
$message = $this->readPath($row, ['message']);
if ($message !== null && $message !== '' && $message !== false) {
$text = str_replace(['<br>', '<br/>', '<br />'], "\n", (string) $message);
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = trim($text);
if ($text !== '') {
$parts[] = 'Wiadomość: ' . $text;
}
}
return $parts !== [] ? implode("\n", $parts) : null;
}
@@ -659,7 +669,7 @@ final class ShopproOrderMapper
*/
private function mapNotes(array $payload): array
{
$comment = StringHelper::nullableString((string) $this->readPath($payload, ['notes', 'comment', 'customer_comment']));
$comment = StringHelper::nullableString((string) $this->readPath($payload, ['notes', 'comment', 'customer_comment', 'message']));
if ($comment === null) {
return [];
}
@@ -827,6 +837,15 @@ final class ShopproOrderMapper
return null;
}
private function normalizeCodPaymentType(string $raw): ?string
{
$value = StringHelper::nullableString($raw);
if ($value === null) {
return null;
}
return StringHelper::isCodPayment($value) ? 'CASH_ON_DELIVERY' : $value;
}
private function readSinglePath(mixed $payload, string $path): mixed
{
if ($path === '') {

View File

@@ -19,7 +19,8 @@ final class ShopproOrdersSyncService
private readonly ShopproStatusMappingRepository $statusMappings,
private readonly OrdersRepository $orders,
private readonly ShopproOrderMapper $mapper,
private readonly ShopproProductImageResolver $imageResolver
private readonly ShopproProductImageResolver $imageResolver,
private readonly ?ShopproPullStatusMappingRepository $pullStatusMappings = null
) {
}
@@ -237,15 +238,21 @@ final class ShopproOrdersSyncService
$result['imported_updated'] = (int) $result['imported_updated'] + 1;
}
$wasCreated = !empty($save['created']);
$wasPaymentTransition = !empty($save['payment_transition']);
$savedOrderId = (int) ($save['order_id'] ?? 0);
$summary = $wasCreated
? 'Import zamowienia z shopPRO'
: 'Zaktualizowano zamowienie z shopPRO (re-import)';
if ($wasPaymentTransition) {
$summary = 'Platnosc potwierdzona z shopPRO — zmiana statusu na w realizacji';
} elseif ($wasCreated) {
$summary = 'Import zamowienia z shopPRO';
} else {
$summary = 'Zaktualizowano zamowienie z shopPRO (re-import)';
}
$details = [
'integration_id' => $integrationId,
'source_order_id' => $sourceOrderId,
'source_updated_at' => $sourceUpdatedAt,
'created' => $wasCreated,
'payment_transition' => $wasPaymentTransition,
'trigger' => 'orders_sync',
'trigger_label' => 'Synchronizacja zamowien',
];
@@ -297,9 +304,40 @@ final class ShopproOrdersSyncService
}
/**
* @return array<string, string> shoppro_status_code => orderpro_status_code (reverse of DB direction)
* @return array<string, string> shoppro_status_code => orderpro_status_code
*/
private function buildStatusMap(int $integrationId): array
{
if ($this->pullStatusMappings !== null) {
return $this->buildStatusMapFromPullTable($integrationId);
}
return $this->buildStatusMapFromPushTable($integrationId);
}
/**
* @return array<string, string>
*/
private function buildStatusMapFromPullTable(int $integrationId): array
{
$rows = $this->pullStatusMappings->listByIntegration($integrationId);
$map = [];
foreach ($rows as $row) {
$shopCode = strtolower(trim((string) ($row['shoppro_status_code'] ?? '')));
$orderCode = strtolower(trim((string) ($row['orderpro_status_code'] ?? '')));
if ($shopCode === '' || $orderCode === '') {
continue;
}
$map[$shopCode] = $orderCode;
}
return $map;
}
/**
* @return array<string, string>
*/
private function buildStatusMapFromPushTable(int $integrationId): array
{
$rows = $this->statusMappings->listByIntegration($integrationId);
$map = [];

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
final class ShopproPullStatusMappingRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array{shoppro_status_code:string,shoppro_status_name:string,orderpro_status_code:string}>
*/
public function listByIntegration(int $integrationId): array
{
if ($integrationId <= 0) {
return [];
}
$statement = $this->pdo->prepare(
'SELECT shoppro_status_code, shoppro_status_name, orderpro_status_code
FROM order_status_pull_mappings
WHERE integration_id = :integration_id
ORDER BY shoppro_status_code ASC'
);
$statement->execute(['integration_id' => $integrationId]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$shopproCode = trim((string) ($row['shoppro_status_code'] ?? ''));
if ($shopproCode === '') {
continue;
}
$result[] = [
'shoppro_status_code' => $shopproCode,
'shoppro_status_name' => trim((string) ($row['shoppro_status_name'] ?? '')),
'orderpro_status_code' => strtolower(trim((string) ($row['orderpro_status_code'] ?? ''))),
];
}
return $result;
}
/**
* @param array<int, array{shoppro_status_code:string,shoppro_status_name:string,orderpro_status_code:string}> $mappings
*/
public function replaceForIntegration(int $integrationId, array $mappings): void
{
if ($integrationId <= 0) {
return;
}
$deleteStatement = $this->pdo->prepare(
'DELETE FROM order_status_pull_mappings WHERE integration_id = :integration_id'
);
$deleteStatement->execute(['integration_id' => $integrationId]);
if ($mappings === []) {
return;
}
$insertStatement = $this->pdo->prepare(
'INSERT INTO order_status_pull_mappings (
integration_id, shoppro_status_code, shoppro_status_name, orderpro_status_code, created_at, updated_at
) VALUES (
:integration_id, :shoppro_status_code, :shoppro_status_name, :orderpro_status_code, NOW(), NOW()
)'
);
foreach ($mappings as $mapping) {
$shopproCode = trim((string) ($mapping['shoppro_status_code'] ?? ''));
$orderpro = strtolower(trim((string) ($mapping['orderpro_status_code'] ?? '')));
if ($shopproCode === '' || $orderpro === '') {
continue;
}
$shopproName = trim((string) ($mapping['shoppro_status_name'] ?? ''));
$insertStatement->execute([
'integration_id' => $integrationId,
'shoppro_status_code' => $shopproCode,
'shoppro_status_name' => $shopproName !== '' ? $shopproName : null,
'orderpro_status_code' => $orderpro,
]);
}
}
}

View File

@@ -429,16 +429,16 @@ final class ShipmentController
}
$result = $delivery;
$deliveryHasAddress = trim((string) ($delivery['street_name'] ?? '')) !== '';
$deliveryName = trim((string) ($delivery['name'] ?? ''));
$customerName = trim((string) ($customer['name'] ?? ''));
if (($this->isPickupPointDelivery($delivery) || $deliveryName === '') && $customerName !== '') {
if ((!$deliveryHasAddress || $this->isPickupPointDelivery($delivery) || $deliveryName === '') && $customerName !== '') {
$result['name'] = $customerName;
}
if (trim((string) ($result['phone'] ?? '')) === '' && trim((string) ($customer['phone'] ?? '')) !== '') {
$result['phone'] = $customer['phone'];
}
if (trim((string) ($result['email'] ?? '')) === '' && trim((string) ($customer['email'] ?? '')) !== '') {
$result['email'] = $customer['email'];
foreach (['phone', 'email', 'street_name', 'street_number', 'city', 'zip_code', 'country'] as $field) {
if (trim((string) ($result[$field] ?? '')) === '' && trim((string) ($customer[$field] ?? '')) !== '') {
$result[$field] = $customer[$field];
}
}
return $result;