feat(v1.5): complete phases 40-43 workflow cleanup

This commit is contained in:
2026-03-25 22:46:51 +01:00
parent b8dda81e7b
commit 3610571949
37 changed files with 1557 additions and 259 deletions

View File

@@ -250,7 +250,8 @@ final class Application
try {
$factory = new CronHandlerFactory(
$this->db,
(string) $this->config('app.integrations.secret', '')
(string) $this->config('app.integrations.secret', ''),
$this->basePath()
);
$runner = $factory->build($repository, $this->logger);
$runner->run($webLimit);

View File

@@ -14,10 +14,19 @@ use Throwable;
final class AutomationController
{
private const ALLOWED_EVENTS = ['receipt.created'];
private const ALLOWED_CONDITION_TYPES = ['integration'];
private const ALLOWED_EVENTS = ['receipt.created', 'shipment.status_changed'];
private const ALLOWED_CONDITION_TYPES = ['integration', 'shipment_status'];
private const ALLOWED_ACTION_TYPES = ['send_email'];
private const ALLOWED_RECIPIENTS = ['client', 'client_and_company', 'company'];
private const SHIPMENT_STATUS_OPTIONS = [
'registered' => ['label' => 'Przesylka zarejestrowana', 'statuses' => ['created', 'confirmed']],
'ready_for_pickup' => ['label' => 'Przesylka do odbioru', 'statuses' => ['ready_for_pickup']],
'dropped_at_point' => ['label' => 'Przesylka nadana w punkcie', 'statuses' => ['confirmed', 'in_transit']],
'picked_up' => ['label' => 'Przesylka odebrana', 'statuses' => ['delivered']],
'cancelled' => ['label' => 'Przesylka anulowana', 'statuses' => ['cancelled']],
'unclaimed' => ['label' => 'Przesylka nieodebrana', 'statuses' => ['problem']],
'picked_up_return' => ['label' => 'Przesylka odebrana (zwrot)', 'statuses' => ['returned']],
];
public function __construct(
private readonly Template $template,
@@ -185,6 +194,7 @@ final class AutomationController
'conditionTypes' => self::ALLOWED_CONDITION_TYPES,
'actionTypes' => self::ALLOWED_ACTION_TYPES,
'recipientOptions' => self::ALLOWED_RECIPIENTS,
'shipmentStatusOptions' => self::SHIPMENT_STATUS_OPTIONS,
'errorMessage' => Flash::get('settings.automation.error', ''),
], 'layouts/app');
@@ -289,6 +299,21 @@ final class AutomationController
return count($integrationIds) > 0 ? ['integration_ids' => $integrationIds] : null;
}
if ($type === 'shipment_status') {
$keys = $condition['shipment_status_keys'] ?? [];
if (!is_array($keys)) {
$keys = [];
}
$allowedKeys = array_keys(self::SHIPMENT_STATUS_OPTIONS);
$statusKeys = array_values(array_filter(
array_map(static fn (mixed $key): string => trim((string) $key), $keys),
static fn (string $key): bool => $key !== '' && in_array($key, $allowedKeys, true)
));
return count($statusKeys) > 0 ? ['status_keys' => array_values(array_unique($statusKeys))] : null;
}
return null;
}

View File

@@ -10,6 +10,16 @@ use Throwable;
final class AutomationService
{
private const SHIPMENT_STATUS_OPTION_MAP = [
'registered' => ['created', 'confirmed'],
'ready_for_pickup' => ['ready_for_pickup'],
'dropped_at_point' => ['confirmed', 'in_transit'],
'picked_up' => ['delivered'],
'cancelled' => ['cancelled'],
'unclaimed' => ['problem'],
'picked_up_return' => ['returned'],
];
public function __construct(
private readonly AutomationRepository $repository,
private readonly EmailSendingService $emailService,
@@ -18,7 +28,10 @@ final class AutomationService
) {
}
public function trigger(string $eventType, int $orderId): void
/**
* @param array<string, mixed> $context
*/
public function trigger(string $eventType, int $orderId, array $context = []): void
{
$rules = $this->repository->findActiveByEvent($eventType);
if ($rules === []) {
@@ -38,7 +51,7 @@ final class AutomationService
$actions = is_array($rule['actions'] ?? null) ? $rule['actions'] : [];
$ruleName = (string) ($rule['name'] ?? '');
if ($this->evaluateConditions($conditions, $order)) {
if ($this->evaluateConditions($conditions, $order, $context)) {
$this->executeActions($actions, $orderId, $ruleName);
}
} catch (Throwable) {
@@ -50,14 +63,15 @@ final class AutomationService
/**
* @param list<array<string, mixed>> $conditions
* @param array<string, mixed> $order
* @param array<string, mixed> $context
*/
private function evaluateConditions(array $conditions, array $order): bool
private function evaluateConditions(array $conditions, array $order, array $context): bool
{
foreach ($conditions as $condition) {
$type = (string) ($condition['condition_type'] ?? '');
$value = is_array($condition['condition_value'] ?? null) ? $condition['condition_value'] : [];
if (!$this->evaluateSingleCondition($type, $value, $order)) {
if (!$this->evaluateSingleCondition($type, $value, $order, $context)) {
return false;
}
}
@@ -68,12 +82,16 @@ final class AutomationService
/**
* @param array<string, mixed> $value
* @param array<string, mixed> $order
* @param array<string, mixed> $context
*/
private function evaluateSingleCondition(string $type, array $value, array $order): bool
private function evaluateSingleCondition(string $type, array $value, array $order, array $context): bool
{
if ($type === 'integration') {
return $this->evaluateIntegrationCondition($value, $order);
}
if ($type === 'shipment_status') {
return $this->evaluateShipmentStatusCondition($value, $context);
}
return false;
}
@@ -97,6 +115,40 @@ final class AutomationService
return in_array($orderIntegrationId, array_map('intval', $allowedIds), true);
}
/**
* @param array<string, mixed> $value
* @param array<string, mixed> $context
*/
private function evaluateShipmentStatusCondition(array $value, array $context): bool
{
$statusKeys = is_array($value['status_keys'] ?? null) ? $value['status_keys'] : [];
if ($statusKeys === []) {
return false;
}
$deliveryStatus = trim((string) ($context['delivery_status'] ?? ''));
if ($deliveryStatus === '') {
return false;
}
$allowedStatuses = [];
foreach ($statusKeys as $statusKeyRaw) {
$statusKey = trim((string) $statusKeyRaw);
if ($statusKey === '' || !isset(self::SHIPMENT_STATUS_OPTION_MAP[$statusKey])) {
continue;
}
foreach (self::SHIPMENT_STATUS_OPTION_MAP[$statusKey] as $mappedStatus) {
$allowedStatuses[$mappedStatus] = true;
}
}
if ($allowedStatuses === []) {
return false;
}
return isset($allowedStatuses[$deliveryStatus]);
}
/**
* @param list<array<string, mixed>> $actions
*/

View File

@@ -3,7 +3,15 @@ declare(strict_types=1);
namespace App\Modules\Cron;
use App\Core\I18n\Translator;
use App\Core\Support\Logger;
use App\Core\View\Template;
use App\Modules\Accounting\ReceiptRepository;
use App\Modules\Automation\AutomationRepository;
use App\Modules\Automation\AutomationService;
use App\Modules\Email\AttachmentGenerator;
use App\Modules\Email\EmailSendingService;
use App\Modules\Email\VariableResolver;
use App\Modules\Orders\OrderImportRepository;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\AllegroApiClient;
@@ -15,13 +23,21 @@ use App\Modules\Settings\AllegroOrderSyncStateRepository;
use App\Modules\Settings\AllegroStatusMappingRepository;
use App\Modules\Settings\AllegroStatusSyncService;
use App\Modules\Settings\AllegroTokenManager;
use App\Modules\Settings\ApaczkaApiClient;
use App\Modules\Settings\ApaczkaIntegrationRepository;
use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\EmailMailboxRepository;
use App\Modules\Settings\EmailTemplateRepository;
use App\Modules\Settings\InpostIntegrationRepository;
use App\Modules\Settings\IntegrationSecretCipher;
use App\Modules\Settings\ReceiptConfigRepository;
use App\Modules\Settings\ShopproApiClient;
use App\Modules\Settings\ShopproIntegrationsRepository;
use App\Modules\Settings\ShopproOrderMapper;
use App\Modules\Settings\ShopproOrdersSyncService;
use App\Modules\Settings\ShopproOrderSyncStateRepository;
use App\Modules\Settings\ShopproProductImageResolver;
use App\Modules\Settings\ShopproPaymentStatusSyncService;
use App\Modules\Settings\ShopproProductImageResolver;
use App\Modules\Settings\ShopproStatusMappingRepository;
use App\Modules\Settings\ShopproStatusSyncService;
use App\Modules\Shipments\AllegroTrackingService;
@@ -29,17 +45,16 @@ use App\Modules\Shipments\ApaczkaTrackingService;
use App\Modules\Shipments\InpostTrackingService;
use App\Modules\Shipments\ShipmentPackageRepository;
use App\Modules\Shipments\ShipmentTrackingRegistry;
use App\Modules\Settings\ApaczkaApiClient;
use App\Modules\Settings\ApaczkaIntegrationRepository;
use App\Modules\Settings\InpostIntegrationRepository;
use PDO;
final class CronHandlerFactory
{
public function __construct(
private readonly PDO $db,
private readonly string $integrationSecret
) {}
private readonly string $integrationSecret,
private readonly string $basePath
) {
}
public function build(CronRepository $cronRepository, Logger $logger): CronRunner
{
@@ -48,14 +63,17 @@ final class CronHandlerFactory
$tokenManager = new AllegroTokenManager($integrationRepository, $oauthClient);
$apiClient = new AllegroApiClient();
$statusMappingRepository = new AllegroStatusMappingRepository($this->db);
$ordersRepository = new OrdersRepository($this->db);
$orderImportService = new AllegroOrderImportService(
$integrationRepository,
$tokenManager,
$apiClient,
new OrderImportRepository($this->db),
$statusMappingRepository,
new OrdersRepository($this->db)
$ordersRepository
);
$ordersSyncService = new AllegroOrdersSyncService(
$integrationRepository,
new AllegroOrderSyncStateRepository($this->db),
@@ -63,6 +81,7 @@ final class CronHandlerFactory
$apiClient,
$orderImportService
);
$shopproIntegrationsRepo = new ShopproIntegrationsRepository($this->db, $this->integrationSecret);
$shopproApiClient = new ShopproApiClient();
$shopproSyncService = new ShopproOrdersSyncService(
@@ -71,18 +90,21 @@ final class CronHandlerFactory
$shopproApiClient,
new OrderImportRepository($this->db),
new ShopproStatusMappingRepository($this->db),
new OrdersRepository($this->db),
$ordersRepository,
new ShopproOrderMapper(),
new ShopproProductImageResolver($shopproApiClient)
);
$shopproStatusSyncService = new ShopproStatusSyncService($shopproIntegrationsRepo, $shopproSyncService);
$shopproPaymentSyncService = new ShopproPaymentStatusSyncService(
$shopproIntegrationsRepo,
new ShopproApiClient(),
new OrdersRepository($this->db),
$ordersRepository,
$this->db
);
$automationService = $this->buildAutomationService($ordersRepository);
return new CronRunner(
$cronRepository,
$logger,
@@ -124,9 +146,45 @@ final class CronHandlerFactory
$tokenManager
),
]),
new ShipmentPackageRepository($this->db)
new ShipmentPackageRepository($this->db),
$automationService
),
]
);
}
private function buildAutomationService(OrdersRepository $ordersRepository): AutomationService
{
$automationRepository = new AutomationRepository($this->db);
$companySettingsRepository = new CompanySettingsRepository($this->db);
$emailTemplateRepository = new EmailTemplateRepository($this->db);
$emailMailboxRepository = new EmailMailboxRepository(
$this->db,
new IntegrationSecretCipher($this->integrationSecret)
);
$template = new Template(
$this->basePath . '/resources/views',
new Translator($this->basePath . '/resources/lang', 'pl')
);
$emailService = new EmailSendingService(
$this->db,
$ordersRepository,
$emailTemplateRepository,
$emailMailboxRepository,
new VariableResolver(),
new AttachmentGenerator(
new ReceiptRepository($this->db),
new ReceiptConfigRepository($this->db),
$template
)
);
return new AutomationService(
$automationRepository,
$emailService,
$ordersRepository,
$companySettingsRepository
);
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\Automation\AutomationService;
use App\Modules\Shipments\ShipmentPackageRepository;
use App\Modules\Shipments\ShipmentTrackingRegistry;
use Throwable;
@@ -11,7 +12,8 @@ final class ShipmentTrackingHandler
{
public function __construct(
private readonly ShipmentTrackingRegistry $registry,
private readonly ShipmentPackageRepository $repository
private readonly ShipmentPackageRepository $repository,
private readonly AutomationService $automationService
) {
}
@@ -38,12 +40,42 @@ final class ShipmentTrackingHandler
try {
$result = $service->getDeliveryStatus($package);
if ($result !== null) {
$previousStatus = trim((string) ($package['delivery_status'] ?? 'unknown'));
if ($previousStatus === '') {
$previousStatus = 'unknown';
}
$previousStatusRaw = trim((string) ($package['delivery_status_raw'] ?? ''));
$newStatus = trim((string) ($result['status'] ?? 'unknown'));
if ($newStatus === '') {
$newStatus = 'unknown';
}
$newStatusRaw = trim((string) ($result['status_raw'] ?? ''));
$statusChanged = $newStatus !== $previousStatus;
$statusRawChanged = $newStatusRaw !== $previousStatusRaw;
if (!$statusChanged && !$statusRawChanged) {
continue;
}
$this->repository->updateDeliveryStatus(
$packageId,
$result['status'],
$result['status_raw']
$newStatus,
$newStatusRaw !== '' ? $newStatusRaw : null
);
$updated++;
if ($statusChanged) {
$orderId = (int) ($package['order_id'] ?? 0);
if ($orderId > 0) {
$this->automationService->trigger('shipment.status_changed', $orderId, [
'package_id' => $packageId,
'provider' => $provider,
'delivery_status' => $newStatus,
'delivery_status_raw' => $newStatusRaw,
'previous_status' => $previousStatus,
'previous_status_raw' => $previousStatusRaw,
]);
}
}
}
} catch (Throwable) {
$errors++;

View File

@@ -136,14 +136,7 @@ final class OrdersController
'selectable' => true,
'select_name' => 'selected_ids[]',
'select_value_key' => 'id',
'header_actions' => [
[
'type' => 'button',
'label' => 'Drukuj etykiety',
'class' => 'btn btn--secondary js-bulk-print-labels',
'attrs' => ['data-csrf' => Csrf::token()],
],
],
'header_actions' => [],
'empty_message' => $this->translator->get('orders.empty'),
'show_actions' => false,
],

View File

@@ -759,6 +759,59 @@ final class OrdersRepository
]);
}
/**
* @param array<string, mixed> $details
*/
public function shouldSkipDuplicateImportActivity(int $orderId, array $details): bool
{
if ($orderId <= 0 || !empty($details['created'])) {
return false;
}
$sourceOrderId = trim((string) ($details['source_order_id'] ?? ''));
$sourceUpdatedAt = trim((string) ($details['source_updated_at'] ?? ''));
$trigger = trim((string) ($details['trigger'] ?? ''));
if ($sourceOrderId === '' || $sourceUpdatedAt === '' || $trigger === '') {
return false;
}
try {
$stmt = $this->pdo->prepare(
'SELECT details_json
FROM order_activity_log
WHERE order_id = :order_id
AND event_type = :event_type
ORDER BY created_at DESC, id DESC
LIMIT 1'
);
$stmt->execute([
'order_id' => $orderId,
'event_type' => 'import',
]);
$lastDetailsJson = $stmt->fetchColumn();
} catch (Throwable) {
return false;
}
if (!is_string($lastDetailsJson) || trim($lastDetailsJson) === '') {
return false;
}
$lastDetails = json_decode($lastDetailsJson, true);
if (!is_array($lastDetails) || !empty($lastDetails['created'])) {
return false;
}
$lastSourceOrderId = trim((string) ($lastDetails['source_order_id'] ?? ''));
$lastSourceUpdatedAt = trim((string) ($lastDetails['source_updated_at'] ?? ''));
$lastTrigger = trim((string) ($lastDetails['trigger'] ?? ''));
return $lastSourceOrderId === $sourceOrderId
&& $lastSourceUpdatedAt === $sourceUpdatedAt
&& $lastTrigger === $trigger;
}
public function recordStatusChange(
int $orderId,
?string $fromStatus,

View File

@@ -162,69 +162,4 @@ final class PrintApiController
return Response::json(['id' => $jobId, 'status' => 'completed']);
}
public function bulkCreateJobs(Request $request): Response
{
$body = json_decode((string) file_get_contents('php://input'), true);
if (!is_array($body)) {
$body = [];
}
$token = (string) ($body['_token'] ?? $request->input('_token', ''));
if (!Csrf::validate($token)) {
return Response::json(['error' => 'Invalid CSRF token'], 403);
}
$packageIds = $body['package_ids'] ?? $request->input('package_ids', []);
if (!is_array($packageIds) || $packageIds === []) {
$orderIds = $body['order_ids'] ?? $request->input('order_ids', []);
if (!is_array($orderIds) || $orderIds === []) {
return Response::json(['error' => 'package_ids or order_ids required'], 400);
}
$intOrderIds = array_map('intval', $orderIds);
$packages = $this->printJobs->findPackagesWithLabelsByOrderIds($intOrderIds);
$packageIds = array_map(static fn(array $p): int => (int) $p['id'], $packages);
}
$user = $this->auth->user();
$userId = (int) ($user['id'] ?? 0);
$created = [];
$skipped = [];
foreach ($packageIds as $pkgId) {
$pkgId = (int) $pkgId;
if ($pkgId <= 0) {
continue;
}
$existing = $this->printJobs->findPendingByPackageId($pkgId);
if ($existing !== null) {
$skipped[] = ['package_id' => $pkgId, 'reason' => 'already_pending'];
continue;
}
$package = $this->packages->findById($pkgId);
if ($package === null) {
$skipped[] = ['package_id' => $pkgId, 'reason' => 'not_found'];
continue;
}
$labelPath = $this->ensureLabel($pkgId, $package);
if ($labelPath === '') {
$skipped[] = ['package_id' => $pkgId, 'reason' => 'no_label'];
continue;
}
$jobId = $this->printJobs->create([
'order_id' => (int) ($package['order_id'] ?? 0),
'package_id' => $pkgId,
'label_path' => $labelPath,
'created_by' => $userId,
]);
$created[] = ['id' => $jobId, 'package_id' => $pkgId];
}
return Response::json(['created' => $created, 'skipped' => $skipped], 201);
}
}

View File

@@ -86,6 +86,14 @@ final class PrintJobRepository
$statement->execute(['id' => $id]);
}
public function deleteById(int $id): bool
{
$statement = $this->pdo->prepare('DELETE FROM print_jobs WHERE id = :id');
$statement->execute(['id' => $id]);
return $statement->rowCount() > 0;
}
/**
* @return list<int>
*/
@@ -141,25 +149,4 @@ final class PrintJobRepository
return is_array($rows) ? $rows : [];
}
/**
* @param list<int> $orderIds
* @return list<array<string, mixed>>
*/
public function findPackagesWithLabelsByOrderIds(array $orderIds): array
{
if ($orderIds === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($orderIds), '?'));
$statement = $this->pdo->prepare(
"SELECT id, order_id, label_path FROM shipment_packages
WHERE order_id IN ($placeholders) AND label_path IS NOT NULL AND label_path != ''
AND status != 'error'"
);
$statement->execute(array_values($orderIds));
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
}

View File

@@ -13,6 +13,12 @@ use Throwable;
final class AllegroOrderImportService
{
private const IMPORT_TRIGGERS = [
'manual_import' => 'Import reczny',
'orders_sync' => 'Synchronizacja zamowien',
'status_sync' => 'Synchronizacja statusow',
];
public function __construct(
private readonly AllegroIntegrationRepository $integrationRepository,
private readonly AllegroTokenManager $tokenManager,
@@ -26,12 +32,14 @@ final class AllegroOrderImportService
/**
* @return array<string, mixed>
*/
public function importSingleOrder(string $checkoutFormId): array
public function importSingleOrder(string $checkoutFormId, string $trigger = 'manual_import'): array
{
$orderId = trim($checkoutFormId);
if ($orderId === '') {
throw new AllegroApiException('Podaj ID zamowienia Allegro.');
}
$normalizedTrigger = $this->normalizeTrigger($trigger);
$triggerLabel = self::IMPORT_TRIGGERS[$normalizedTrigger];
[$accessToken, $env] = $this->tokenManager->resolveToken();
@@ -61,21 +69,29 @@ final class AllegroOrderImportService
$wasCreated = !empty($saveResult['created']);
if ($savedOrderId > 0) {
$sourceUpdatedAt = trim((string) ($mapped['order']['source_updated_at'] ?? ''));
$summary = $wasCreated
? 'Zaimportowano zamowienie z Allegro'
: 'Zaktualizowano zamowienie z Allegro (re-import)';
$this->ordersRepository->recordActivity(
$savedOrderId,
'import',
$summary,
[
'source' => IntegrationSources::ALLEGRO,
'source_order_id' => trim($checkoutFormId),
'created' => $wasCreated,
],
'import',
'Allegro'
);
$details = [
'source' => IntegrationSources::ALLEGRO,
'source_order_id' => trim($checkoutFormId),
'source_updated_at' => $sourceUpdatedAt,
'created' => $wasCreated,
'trigger' => $normalizedTrigger,
'trigger_label' => $triggerLabel,
];
if (!$this->ordersRepository->shouldSkipDuplicateImportActivity($savedOrderId, $details)) {
$this->ordersRepository->recordActivity(
$savedOrderId,
'import',
$summary,
$details,
'import',
'Allegro'
);
}
}
return [
@@ -86,6 +102,16 @@ final class AllegroOrderImportService
];
}
private function normalizeTrigger(string $trigger): string
{
$value = trim($trigger);
if ($value === '' || !array_key_exists($value, self::IMPORT_TRIGGERS)) {
return 'manual_import';
}
return $value;
}
/**
* @param array<string, mixed> $payload
* @return array{

View File

@@ -127,7 +127,7 @@ final class AllegroOrdersSyncService
$result['processed'] = (int) $result['processed'] + 1;
try {
$importResult = $this->orderImportService->importSingleOrder($sourceOrderId);
$importResult = $this->orderImportService->importSingleOrder($sourceOrderId, 'orders_sync');
if (!empty($importResult['created'])) {
$result['imported_created'] = (int) $result['imported_created'] + 1;
} else {

View File

@@ -59,7 +59,7 @@ final class AllegroStatusSyncService
$sourceOrderId = (string) ($order['source_order_id'] ?? '');
try {
$this->orderImportService->importSingleOrder($sourceOrderId);
$this->orderImportService->importSingleOrder($sourceOrderId, 'status_sync');
$result['processed']++;
$this->markOrderStatusChecked((int) ($order['id'] ?? 0));
} catch (Throwable $exception) {

View File

@@ -91,6 +91,27 @@ final class PrintSettingsController
$this->apiKeys->delete($id);
Flash::set('settings_success', 'Klucz API został usunięty');
return Response::redirect('/settings/printing');
}
public function deleteJob(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/printing');
}
$id = (int) $request->input('id', 0);
if ($id <= 0) {
Flash::set('settings_error', 'Nieprawidlowy ID wpisu kolejki');
return Response::redirect('/settings/printing');
}
if ($this->printJobs->deleteById($id)) {
Flash::set('settings_success', 'Wpis kolejki wydruku zostal usuniety');
} else {
Flash::set('settings_error', 'Nie znaleziono wpisu kolejki do usuniecia');
}
return Response::redirect('/settings/printing');
}
}