feat(47-shipment-created-automation): immediate shipment automation trigger

Phase 47 complete:

- add event shipment.created triggered immediately after shipment creation

- add action update_shipment_status with real-change guard and chain-safe emit

- update automation UI/options, docs, and PAUL state artifacts
This commit is contained in:
2026-03-28 13:24:20 +01:00
parent d3f4bdaecd
commit ad9087d5e4
17 changed files with 784 additions and 310 deletions

View File

@@ -15,9 +15,9 @@ use Throwable;
final class AutomationController
{
private const ALLOWED_EVENTS = ['receipt.created', 'shipment.status_changed'];
private const ALLOWED_EVENTS = ['receipt.created', 'shipment.created', 'shipment.status_changed'];
private const ALLOWED_CONDITION_TYPES = ['integration', 'shipment_status'];
private const ALLOWED_ACTION_TYPES = ['send_email', 'issue_receipt'];
private const ALLOWED_ACTION_TYPES = ['send_email', 'issue_receipt', 'update_shipment_status'];
private const ALLOWED_RECIPIENTS = ['client', 'client_and_company', 'company'];
private const ALLOWED_RECEIPT_ISSUE_DATE_MODES = ['today', 'order_date', 'payment_date'];
private const ALLOWED_RECEIPT_DUPLICATE_POLICIES = ['skip_if_exists', 'allow_duplicates'];
@@ -416,6 +416,15 @@ final class AutomationController
];
}
if ($type === 'update_shipment_status') {
$statusKey = trim((string) ($action['shipment_status_key'] ?? ''));
if (!array_key_exists($statusKey, self::SHIPMENT_STATUS_OPTIONS)) {
return null;
}
return ['status_key' => $statusKey];
}
return null;
}

View File

@@ -8,6 +8,8 @@ use App\Modules\Email\EmailSendingService;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\ReceiptConfigRepository;
use App\Modules\Shipments\DeliveryStatus;
use App\Modules\Shipments\ShipmentPackageRepository;
use Throwable;
final class AutomationService
@@ -32,7 +34,8 @@ final class AutomationService
private readonly OrdersRepository $orders,
private readonly CompanySettingsRepository $companySettings,
private readonly ReceiptRepository $receipts,
private readonly ReceiptConfigRepository $receiptConfigs
private readonly ReceiptConfigRepository $receiptConfigs,
private readonly ShipmentPackageRepository $shipmentPackages
) {
}
@@ -187,6 +190,11 @@ final class AutomationService
if ($type === 'issue_receipt') {
$this->handleIssueReceipt($config, $orderId, $ruleName, $context);
continue;
}
if ($type === 'update_shipment_status') {
$this->handleUpdateShipmentStatus($config, $orderId, $ruleName, $context);
}
}
}
@@ -355,6 +363,106 @@ final class AutomationService
}
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $context
*/
private function handleUpdateShipmentStatus(array $config, int $orderId, string $ruleName, array $context): void
{
$statusKey = trim((string) ($config['status_key'] ?? ''));
$targetStatus = $this->resolveStatusFromActionKey($statusKey);
if ($targetStatus === null) {
return;
}
$targetPackage = $this->resolveTargetPackage($orderId, $context);
if ($targetPackage === null) {
return;
}
$packageId = (int) ($targetPackage['id'] ?? 0);
if ($packageId <= 0) {
return;
}
$previousStatus = trim((string) ($targetPackage['delivery_status'] ?? DeliveryStatus::UNKNOWN));
if ($previousStatus === '') {
$previousStatus = DeliveryStatus::UNKNOWN;
}
if ($previousStatus === $targetStatus) {
return;
}
$this->shipmentPackages->updateDeliveryStatus($packageId, $targetStatus, null);
$actorName = 'Automatyzacja: ' . $ruleName;
$this->orders->recordActivity(
$orderId,
'automation_shipment_status_updated',
$actorName . ' - zaktualizowano status przesylki',
[
'package_id' => $packageId,
'previous_status' => $previousStatus,
'delivery_status' => $targetStatus,
'status_key' => $statusKey,
],
'system',
$actorName
);
$this->emitEvent(
'shipment.status_changed',
$orderId,
$context,
[
'package_id' => $packageId,
'provider' => (string) ($targetPackage['provider'] ?? ''),
'delivery_status' => $targetStatus,
'delivery_status_raw' => '',
'previous_status' => $previousStatus,
'previous_status_raw' => '',
'automation_source' => 'update_shipment_status',
'automation_rule' => $ruleName,
]
);
}
private function resolveStatusFromActionKey(string $statusKey): ?string
{
if ($statusKey === '' || !isset(self::SHIPMENT_STATUS_OPTION_MAP[$statusKey])) {
return null;
}
$mappedStatuses = self::SHIPMENT_STATUS_OPTION_MAP[$statusKey];
if ($mappedStatuses === []) {
return null;
}
$candidate = trim((string) $mappedStatuses[0]);
if ($candidate === '' || !in_array($candidate, DeliveryStatus::ALL_STATUSES, true)) {
return null;
}
return $candidate;
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>|null
*/
private function resolveTargetPackage(int $orderId, array $context): ?array
{
$packageId = (int) ($context['package_id'] ?? 0);
if ($packageId > 0) {
$package = $this->shipmentPackages->findById($packageId);
if (is_array($package) && (int) ($package['order_id'] ?? 0) === $orderId) {
return $package;
}
}
return $this->shipmentPackages->findLatestByOrderId($orderId);
}
/**
* @param array<string, mixed> $order
* @param list<array<string, mixed>> $payments

View File

@@ -64,6 +64,7 @@ final class CronHandlerFactory
$apiClient = new AllegroApiClient();
$statusMappingRepository = new AllegroStatusMappingRepository($this->db);
$ordersRepository = new OrdersRepository($this->db);
$allegroSyncStateRepository = new AllegroOrderSyncStateRepository($this->db);
$orderImportService = new AllegroOrderImportService(
$integrationRepository,
@@ -76,7 +77,7 @@ final class CronHandlerFactory
$ordersSyncService = new AllegroOrdersSyncService(
$integrationRepository,
new AllegroOrderSyncStateRepository($this->db),
$allegroSyncStateRepository,
$tokenManager,
$apiClient,
$orderImportService
@@ -128,6 +129,11 @@ final class CronHandlerFactory
new AllegroStatusSyncService(
$cronRepository,
$orderImportService,
$apiClient,
$tokenManager,
$statusMappingRepository,
$allegroSyncStateRepository,
$integrationRepository,
$this->db
)
),
@@ -194,7 +200,8 @@ final class CronHandlerFactory
$ordersRepository,
$companySettingsRepository,
new ReceiptRepository($this->db),
new ReceiptConfigRepository($this->db)
new ReceiptConfigRepository($this->db),
new ShipmentPackageRepository($this->db)
);
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Shipments;
use App\Modules\Automation\AutomationService;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
@@ -26,6 +27,7 @@ final class ShipmentController
private readonly CompanySettingsRepository $companySettings,
private readonly ShipmentProviderRegistry $providerRegistry,
private readonly ShipmentPackageRepository $packageRepository,
private readonly AutomationService $automationService,
private readonly string $storagePath,
private readonly ?CarrierDeliveryMethodMappingRepository $deliveryMappings = null,
private readonly ?\App\Modules\Printing\PrintJobRepository $printJobRepo = null
@@ -213,6 +215,8 @@ final class ShipmentController
'user',
$actorName
);
$this->triggerShipmentCreatedAutomation($orderId, $packageId, $providerCode);
Flash::set('order.success', 'Przesylka utworzona. Sprawdz status w zakladce Przesylki.');
return Response::redirect('/orders/' . $orderId);
} catch (Throwable $exception) {
@@ -382,10 +386,35 @@ final class ShipmentController
$actorName
);
$this->triggerShipmentCreatedAutomation($orderId, $packageId, 'manual');
Flash::set('order.success', 'Numer przesylki dodany.');
return Response::redirect('/orders/' . $orderId);
}
private function triggerShipmentCreatedAutomation(int $orderId, int $packageId, string $providerCode): void
{
if ($orderId <= 0 || $packageId <= 0) {
return;
}
$package = $this->packageRepository->findById($packageId);
$context = [
'package_id' => $packageId,
'provider' => $providerCode,
'package_status' => is_array($package) ? (string) ($package['status'] ?? '') : '',
'tracking_number' => is_array($package) ? (string) ($package['tracking_number'] ?? '') : '',
'delivery_status' => is_array($package)
? (string) ($package['delivery_status'] ?? DeliveryStatus::UNKNOWN)
: DeliveryStatus::UNKNOWN,
];
try {
$this->automationService->trigger('shipment.created', $orderId, $context);
} catch (Throwable) {
// Blad automatyzacji nie powinien blokowac tworzenia przesylki
}
}
/**
* @param array<string, mixed>|null $deliveryAddr
* @param array<string, mixed>|null $customerAddr

View File

@@ -118,6 +118,20 @@ final class ShipmentPackageRepository
return $statement->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
/**
* @return array<string, mixed>|null
*/
public function findLatestByOrderId(int $orderId): ?array
{
$statement = $this->pdo->prepare(
'SELECT * FROM shipment_packages WHERE order_id = :order_id ORDER BY created_at DESC, id DESC LIMIT 1'
);
$statement->execute(['order_id' => $orderId]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
/**
* @return array<string, mixed>|null
*/