Phase 115 complete (vertical slice "zamowienie z NIP -> faktura PDF"):
- Task 1: InvoiceRepository + InvoiceService (dual-flow orchestrator) +
InvoiceIssueException + FakturowniaApiClient::createInvoice + buildPdfUrl
- Task 2: InvoiceController + OrdersController::toggleInvoiceRequested +
OrdersRepository::setInvoiceRequested + auto-import invoice_requested z
Allegro (invoice.required) i shopPRO (5-key flexible parser) + show.php
(toggle w zakladce Platnosci + warunkowy przycisk Wystaw fakture)
- Task 3: Lista wystawionych /settings/accounting/invoices/issued z filtrami
+ invoice_preview + invoice_pdf Dompdf template + hub link
- Task 3b (dodany): NIP lookup przez MF Biala Lista (publiczne API, bez
rejestracji) — MfWhitelistApiClient w src/Core/Http/ + /api/nip/lookup +
przycisk "Pobierz z GUS" w formularzu
Auto-fixes podczas smoke testu (5):
- GUS endpoint Fakturowni nie istnial (HTML 404 -> "json is not valid");
switch na MF Biala Liste
- PHP 8.5 curl_close() deprecation wycieka HTML przed JSON; usuniete z
MfWhitelistApiClient i FakturowniaApiClient (3 miejsca)
- Fakturownia 422 payment_to_kind_days (nieistniejace pole) -> usuniete
- Generic "error" w 422 -> parser plaskuje errors: {pole: [...]} +
error_log z 1000 znakow raw body
- Fakturownia security odrzuca seller_*/department_id jako "create new
department"; usuniete z payloadu (Fakturownia uzywa danych konta)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
432 lines
17 KiB
PHP
432 lines
17 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Settings;
|
|
|
|
use App\Core\Support\StringHelper;
|
|
use App\Modules\Automation\AutomationService;
|
|
use App\Modules\Orders\OrderImportRepository;
|
|
use App\Modules\Orders\OrdersRepository;
|
|
use DateTimeImmutable;
|
|
use Throwable;
|
|
|
|
final class ShopproOrdersSyncService
|
|
{
|
|
public function __construct(
|
|
private readonly ShopproIntegrationsRepository $integrations,
|
|
private readonly ShopproOrderSyncStateRepository $syncState,
|
|
private readonly ShopproApiClient $apiClient,
|
|
private readonly OrderImportRepository $orderImportRepository,
|
|
private readonly ShopproStatusMappingRepository $statusMappings,
|
|
private readonly OrdersRepository $orders,
|
|
private readonly ShopproOrderMapper $mapper,
|
|
private readonly ShopproProductImageResolver $imageResolver,
|
|
private readonly ?ShopproPullStatusMappingRepository $pullStatusMappings = null,
|
|
private readonly ?AutomationService $automationService = null
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $options
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function sync(array $options = []): array
|
|
{
|
|
$maxPages = max(1, min(20, (int) ($options['max_pages'] ?? 3)));
|
|
$pageLimit = max(1, min(100, (int) ($options['page_limit'] ?? 50)));
|
|
$maxOrders = max(1, min(1000, (int) ($options['max_orders'] ?? 200)));
|
|
$ignoreOrdersFetchEnabled = !empty($options['ignore_orders_fetch_enabled']);
|
|
$allowedIntegrationIds = $this->normalizeIntegrationIds($options['allowed_integration_ids'] ?? []);
|
|
|
|
$result = [
|
|
'processed' => 0,
|
|
'imported_created' => 0,
|
|
'imported_updated' => 0,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
'checked_integrations' => 0,
|
|
'errors' => [],
|
|
];
|
|
|
|
foreach ($this->integrations->listIntegrations() as $integration) {
|
|
$integrationId = (int) ($integration['id'] ?? 0);
|
|
if ($integrationId <= 0) {
|
|
continue;
|
|
}
|
|
if ($allowedIntegrationIds !== [] && !isset($allowedIntegrationIds[$integrationId])) {
|
|
continue;
|
|
}
|
|
if (empty($integration['is_active']) || empty($integration['has_api_key'])) {
|
|
continue;
|
|
}
|
|
if (!$ignoreOrdersFetchEnabled && empty($integration['orders_fetch_enabled'])) {
|
|
continue;
|
|
}
|
|
|
|
$result['checked_integrations'] = (int) $result['checked_integrations'] + 1;
|
|
$this->syncOneIntegration($integration, $maxPages, $pageLimit, $maxOrders, $result);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $integration
|
|
* @param array<string, mixed> $result
|
|
*/
|
|
private function syncOneIntegration(array $integration, int $maxPages, int $pageLimit, int $maxOrders, array &$result): void
|
|
{
|
|
$integrationId = (int) ($integration['id'] ?? 0);
|
|
$state = $this->syncState->getState($integrationId);
|
|
$this->syncState->markRunStarted($integrationId, new DateTimeImmutable('now'));
|
|
|
|
try {
|
|
$statusMap = $this->buildStatusMap($integrationId);
|
|
$cursorUpdatedAt = StringHelper::nullableString((string) ($state['last_synced_updated_at'] ?? ''));
|
|
$cursorOrderId = StringHelper::nullableString((string) ($state['last_synced_source_order_id'] ?? ''));
|
|
$startDate = $this->resolveStartDate(
|
|
(string) ($integration['orders_fetch_start_date'] ?? ''),
|
|
$cursorUpdatedAt
|
|
);
|
|
$baseUrl = trim((string) ($integration['base_url'] ?? ''));
|
|
$apiKey = $this->integrations->getApiKeyDecrypted($integrationId);
|
|
$timeout = max(1, min(120, (int) ($integration['timeout_seconds'] ?? 10)));
|
|
$productImageCache = [];
|
|
|
|
if ($baseUrl === '' || $apiKey === null || trim($apiKey) === '') {
|
|
throw new \RuntimeException('Brak poprawnych danych API dla integracji.');
|
|
}
|
|
|
|
$latestUpdatedAt = $cursorUpdatedAt;
|
|
$latestOrderId = $cursorOrderId;
|
|
$shouldStop = false;
|
|
|
|
for ($page = 1; $page <= $maxPages; $page++) {
|
|
$items = $this->fetchOrdersPage($baseUrl, (string) $apiKey, $timeout, $page, $pageLimit, $startDate);
|
|
if ($items === []) {
|
|
break;
|
|
}
|
|
$candidates = $this->mapper->buildCandidates($items, $cursorUpdatedAt, $cursorOrderId);
|
|
$this->processPageCandidates(
|
|
$candidates, $integrationId, $baseUrl, (string) $apiKey, $timeout,
|
|
$statusMap, $maxOrders, $result, $productImageCache, $shouldStop,
|
|
$latestUpdatedAt, $latestOrderId
|
|
);
|
|
if ($shouldStop || count($items) < $pageLimit) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
$this->syncState->markRunSuccess($integrationId, new DateTimeImmutable('now'), $latestUpdatedAt, $latestOrderId);
|
|
} catch (Throwable $exception) {
|
|
$this->syncState->markRunFailed($integrationId, new DateTimeImmutable('now'), $exception->getMessage());
|
|
$result['failed'] = (int) $result['failed'] + 1;
|
|
$errors = is_array($result['errors']) ? $result['errors'] : [];
|
|
if (count($errors) < 20) {
|
|
$errors[] = ['integration_id' => $integrationId, 'error' => $exception->getMessage()];
|
|
}
|
|
$result['errors'] = $errors;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function fetchOrdersPage(string $baseUrl, string $apiKey, int $timeout, int $page, int $pageLimit, ?string $startDate): array
|
|
{
|
|
$orders = $this->apiClient->fetchOrders($baseUrl, $apiKey, $timeout, $page, $pageLimit, $startDate);
|
|
if (($orders['ok'] ?? false) !== true) {
|
|
throw new \RuntimeException((string) ($orders['message'] ?? 'Blad pobierania listy zamowien.'));
|
|
}
|
|
|
|
return is_array($orders['items'] ?? null) ? $orders['items'] : [];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $candidates
|
|
* @param array<string, string> $statusMap
|
|
* @param array<string, mixed> $result
|
|
* @param array<int, string> $productImageCache
|
|
*/
|
|
private function processPageCandidates(
|
|
array $candidates,
|
|
int $integrationId,
|
|
string $baseUrl,
|
|
string $apiKey,
|
|
int $timeout,
|
|
array $statusMap,
|
|
int $maxOrders,
|
|
array &$result,
|
|
array &$productImageCache,
|
|
bool &$shouldStop,
|
|
?string &$latestUpdatedAt,
|
|
?string &$latestOrderId
|
|
): void {
|
|
foreach ($candidates as $candidate) {
|
|
if ((int) $result['processed'] >= $maxOrders) {
|
|
$shouldStop = true;
|
|
break;
|
|
}
|
|
|
|
$sourceOrderId = (string) ($candidate['source_order_id'] ?? '');
|
|
$sourceUpdatedAt = (string) ($candidate['source_updated_at'] ?? '');
|
|
$rawOrder = is_array($candidate['payload'] ?? null) ? $candidate['payload'] : [];
|
|
|
|
$details = $this->apiClient->fetchOrderById($baseUrl, $apiKey, $timeout, $sourceOrderId);
|
|
if (($details['ok'] ?? false) === true && is_array($details['order'] ?? null)) {
|
|
$detailsOrder = (array) $details['order'];
|
|
foreach (['products', 'summary', 'paid', 'transport_cost', 'transport', 'transport_description',
|
|
'client_name', 'client_surname', 'client_email', 'client_phone', 'client_city',
|
|
'client_street', 'client_postal_code'] as $protectedKey) {
|
|
if (array_key_exists($protectedKey, $rawOrder)) {
|
|
unset($detailsOrder[$protectedKey]);
|
|
}
|
|
}
|
|
$rawOrder = array_replace($rawOrder, $detailsOrder);
|
|
}
|
|
|
|
$this->importOneOrder(
|
|
$integrationId, $sourceOrderId, $sourceUpdatedAt, $rawOrder,
|
|
$baseUrl, $apiKey, $timeout, $statusMap, $result, $productImageCache
|
|
);
|
|
|
|
if ($latestUpdatedAt === null || $sourceUpdatedAt > $latestUpdatedAt) {
|
|
$latestUpdatedAt = $sourceUpdatedAt;
|
|
$latestOrderId = $sourceOrderId;
|
|
} elseif ($latestUpdatedAt === $sourceUpdatedAt && ($latestOrderId === null || strcmp($sourceOrderId, $latestOrderId) > 0)) {
|
|
$latestOrderId = $sourceOrderId;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $rawOrder
|
|
* @param array<string, string> $statusMap
|
|
* @param array<string, mixed> $result
|
|
* @param array<int, string> $productImageCache
|
|
*/
|
|
private function importOneOrder(
|
|
int $integrationId,
|
|
string $sourceOrderId,
|
|
string $sourceUpdatedAt,
|
|
array $rawOrder,
|
|
string $baseUrl,
|
|
string $apiKey,
|
|
int $timeout,
|
|
array $statusMap,
|
|
array &$result,
|
|
array &$productImageCache
|
|
): void {
|
|
try {
|
|
$productImages = $this->imageResolver->resolveProductImagesForOrder(
|
|
$baseUrl, $apiKey, $timeout, $rawOrder, $productImageCache
|
|
);
|
|
$aggregate = $this->mapper->mapOrderAggregate(
|
|
$integrationId, $rawOrder, $statusMap, $sourceOrderId, $sourceUpdatedAt, $productImages
|
|
);
|
|
$save = $this->orderImportRepository->upsertOrderAggregate(
|
|
$aggregate['order'],
|
|
$aggregate['addresses'],
|
|
$aggregate['items'],
|
|
$aggregate['payments'],
|
|
$aggregate['shipments'],
|
|
$aggregate['notes'],
|
|
$aggregate['status_history']
|
|
);
|
|
$result['processed'] = (int) $result['processed'] + 1;
|
|
if (!empty($save['created'])) {
|
|
$result['imported_created'] = (int) $result['imported_created'] + 1;
|
|
} else {
|
|
$result['imported_updated'] = (int) $result['imported_updated'] + 1;
|
|
}
|
|
$wasCreated = !empty($save['created']);
|
|
$wasPaymentTransition = !empty($save['payment_transition']);
|
|
$savedOrderId = (int) ($save['order_id'] ?? 0);
|
|
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',
|
|
];
|
|
|
|
if (!$this->orders->shouldSkipDuplicateImportActivity($savedOrderId, $details)) {
|
|
$this->orders->recordActivity(
|
|
$savedOrderId,
|
|
'import',
|
|
$summary,
|
|
$details,
|
|
'import',
|
|
'shopPRO'
|
|
);
|
|
}
|
|
|
|
if ($savedOrderId > 0 && $wasCreated) {
|
|
if ($this->shouldRequestInvoice($rawOrder)) {
|
|
$this->orders->setInvoiceRequested($savedOrderId, true);
|
|
}
|
|
}
|
|
|
|
if ($savedOrderId > 0 && $wasCreated && !$wasPaymentTransition && $this->automationService !== null) {
|
|
$this->automationService->trigger('order.imported', $savedOrderId, [
|
|
'source' => 'shoppro',
|
|
'created' => $wasCreated,
|
|
'integration_id' => $integrationId,
|
|
'new_payment_status' => (string) ($aggregate['order']['payment_status'] ?? ''),
|
|
]);
|
|
}
|
|
|
|
if ($savedOrderId > 0 && !$wasCreated && $wasPaymentTransition && $this->automationService !== null) {
|
|
$this->automationService->trigger('payment.status_changed', $savedOrderId, [
|
|
'source' => 'shoppro',
|
|
'integration_id' => $integrationId,
|
|
'old_payment_status' => '',
|
|
'new_payment_status' => (string) ($aggregate['order']['payment_status'] ?? ''),
|
|
]);
|
|
}
|
|
} catch (Throwable $exception) {
|
|
$result['failed'] = (int) $result['failed'] + 1;
|
|
$errors = is_array($result['errors']) ? $result['errors'] : [];
|
|
if (count($errors) < 20) {
|
|
$errors[] = [
|
|
'integration_id' => $integrationId,
|
|
'source_order_id' => $sourceOrderId,
|
|
'error' => $exception->getMessage(),
|
|
];
|
|
}
|
|
$result['errors'] = $errors;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect "klient prosi o fakture" flag from shopPRO raw payload.
|
|
* Tries common keys; returns false when none present (manual toggle still possible).
|
|
*
|
|
* @param array<string, mixed> $rawOrder
|
|
*/
|
|
private function shouldRequestInvoice(array $rawOrder): bool
|
|
{
|
|
foreach ([['wants_invoice'], ['invoice_required'], ['invoice', 'required'], ['buyer', 'wants_invoice'], ['buyer', 'invoice']] as $path) {
|
|
$value = $rawOrder;
|
|
$found = true;
|
|
foreach ($path as $key) {
|
|
if (!is_array($value) || !array_key_exists($key, $value)) {
|
|
$found = false;
|
|
break;
|
|
}
|
|
$value = $value[$key];
|
|
}
|
|
if ($found && (
|
|
$value === true
|
|
|| $value === 1
|
|
|| $value === '1'
|
|
|| (is_string($value) && in_array(strtolower($value), ['true', 'yes', 'tak'], true))
|
|
)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $rawIds
|
|
* @return array<int, true>
|
|
*/
|
|
private function normalizeIntegrationIds(mixed $rawIds): array
|
|
{
|
|
if (!is_array($rawIds)) {
|
|
return [];
|
|
}
|
|
|
|
$result = [];
|
|
foreach ($rawIds as $rawId) {
|
|
$id = (int) $rawId;
|
|
if ($id <= 0) {
|
|
continue;
|
|
}
|
|
$result[$id] = true;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @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 = [];
|
|
foreach ($rows as $row) {
|
|
$shopCode = strtolower(trim((string) ($row['shoppro_status_code'] ?? '')));
|
|
$orderCode = strtolower(trim((string) ($row['orderpro_status_code'] ?? '')));
|
|
if ($shopCode === '' || $orderCode === '') {
|
|
continue;
|
|
}
|
|
if (!isset($map[$shopCode])) {
|
|
$map[$shopCode] = $orderCode;
|
|
}
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
private function resolveStartDate(string $settingsDate, ?string $cursorUpdatedAt): ?string
|
|
{
|
|
$settings = trim($settingsDate);
|
|
$cursor = StringHelper::nullableString((string) $cursorUpdatedAt);
|
|
if ($settings === '' && $cursor === null) {
|
|
return null;
|
|
}
|
|
if ($settings === '') {
|
|
return $cursor;
|
|
}
|
|
if ($cursor === null) {
|
|
return $settings;
|
|
}
|
|
|
|
return $cursor > $settings ? $cursor : $settings;
|
|
}
|
|
|
|
}
|