mdb = $mdb; else { global $mdb; $this -> mdb = $mdb; } $this -> repo = new FakturowniaImportRepository( $this -> mdb ); } public function import() { $this -> repo -> ensureTables(); $config = $this -> resolveConfig(); if ( !$config['ok'] ) return $config; $this -> startDate = $config['start_date']; $this -> apiClient = new FakturowniaApiClient( $config['api_url'], $config['token'], $config['page_limit'] ); $this -> pageLimit = (int)$config['page_limit']; $summary = [ 'imported' => 0, 'skipped' => 0, 'unmapped' => 0, 'errors' => 0 ]; $summary = $this -> processDocumentType( 'income', $summary ); $summary = $this -> processDocumentType( 'cost', $summary ); $lastSummary = json_encode( [ 'at' => date( 'Y-m-d H:i:s' ), 'summary' => $summary ], JSON_UNESCAPED_UNICODE ); $this -> repo -> saveState( 'last_import_summary', $lastSummary ); if ( $summary['errors'] > 0 ) return [ 'status' => 'error', 'msg' => $this -> formatMessage( $summary ), 'summary' => $summary ]; if ( $summary['imported'] === 0 ) return [ 'status' => 'empty', 'msg' => $this -> formatMessage( $summary ), 'summary' => $summary ]; return [ 'status' => 'ok', 'msg' => $this -> formatMessage( $summary ), 'summary' => $summary ]; } private function processDocumentType( $documentType, $summary ) { $page = 1; while ( true ) { try { if ( $documentType === 'income' ) $documents = $this -> apiClient -> fetchSalesDocuments( $this -> startDate, $page ); else $documents = $this -> apiClient -> fetchCostDocuments( $this -> startDate, $page ); } catch ( \Throwable $e ) { $summary['errors']++; break; } if ( !is_array( $documents ) || empty( $documents ) ) break; $hasRelevantDateInPage = false; foreach ( $documents as $document ) { if ( $this -> isDateRelevantForImport( $document ) ) $hasRelevantDateInPage = true; $result = $this -> processSingleDocument( $document, $documentType ); $summary[ $result ]++; } if ( count( $documents ) < $this -> pageLimit ) break; // API zwraca dokumenty malejaco po czasie. Gdy cala strona jest starsza niz startDate, // kolejne strony tez beda starsze i nie ma sensu pobierac dalej. if ( !$hasRelevantDateInPage ) break; $page++; if ( $page > 100 ) break; } return $summary; } private function processSingleDocument( $rawDocument, $documentType ) { if ( !$this -> matchesDocumentType( $rawDocument, $documentType ) ) return 'skipped'; $document = $this -> normalizeDocument( $rawDocument, $documentType ); if ( !$document ) return 'skipped'; if ( $this -> repo -> isDocumentImported( $document['document_key'] ) ) return 'skipped'; if ( strtotime( $document['date'] ) < strtotime( $this -> startDate ) ) return 'skipped'; $clientMap = $this -> repo -> getClientMapping( $document['client_key'] ); if ( !$clientMap ) { $this -> repo -> queueUnmapped( 'client', $document['client_key'], $document['client_name'], [ 'document_id' => $document['external_id'], 'document_number' => $document['number'], 'document_type' => $documentType, 'tax_no' => $document['client_tax_no'] ] ); return 'unmapped'; } $resolvedPositions = []; foreach ( $document['positions'] as $position ) { $itemMap = $this -> repo -> getItemMapping( $position['item_key'] ); if ( !$itemMap ) { $this -> repo -> queueUnmapped( 'item', $position['item_key'], $position['name'], [ 'document_id' => $document['external_id'], 'document_number' => $document['number'], 'document_type' => $documentType, 'buyer_name' => $document['buyer_name'], 'seller_name' => $document['seller_name'] ] ); return 'unmapped'; } $amount = $this -> normalizeAmount( $position['amount'], $documentType ); if ( $amount == 0.0 ) continue; $resolvedPositions[] = [ 'category_id' => (int)$itemMap['finance_category_id'], 'amount' => $amount, 'description' => $this -> buildDescription( $document, $position ) ]; } if ( empty( $resolvedPositions ) ) return 'skipped'; $operationIds = []; // Cala faktura importuje sie atomowo: albo wszystkie pozycje, albo nic. $this -> mdb -> pdo -> beginTransaction(); try { foreach ( $resolvedPositions as $resolvedPosition ) { $this -> mdb -> insert( 'finance_operations', [ 'date' => $document['date'], 'category_id' => $resolvedPosition['category_id'], 'amount' => $resolvedPosition['amount'], 'description' => $resolvedPosition['description'], 'client_id' => (int)$clientMap['crm_client_id'] ] ); $operationIds[] = (int)$this -> mdb -> id(); } $this -> repo -> markDocumentImported( $document['document_key'], $documentType, $document['external_id'], $document['date'], $document['total_amount'], $operationIds, [ 'number' => $document['number'], 'client_name' => $document['client_name'] ] ); $this -> mdb -> pdo -> commit(); } catch ( \Throwable $e ) { if ( $this -> mdb -> pdo ->inTransaction() ) $this -> mdb -> pdo -> rollBack(); throw $e; } return 'imported'; } private function matchesDocumentType( $rawDocument, $documentType ) { if ( isset( $rawDocument['invoice'] ) && is_array( $rawDocument['invoice'] ) ) $rawDocument = $rawDocument['invoice']; if ( !is_array( $rawDocument ) ) return false; if ( !array_key_exists( 'income', $rawDocument ) ) return true; $incomeFlag = $rawDocument['income']; $isIncome = !in_array( $incomeFlag, [ false, 0, '0', 'false', 'FALSE' ], true ); if ( $documentType === 'income' ) return $isIncome; return !$isIncome; } private function isDateRelevantForImport( $rawDocument ) { if ( isset( $rawDocument['invoice'] ) && is_array( $rawDocument['invoice'] ) ) $rawDocument = $rawDocument['invoice']; if ( !is_array( $rawDocument ) ) return false; $date = (string)( $rawDocument['issue_date'] ?? $rawDocument['sell_date'] ?? $rawDocument['created_at'] ?? '' ); $date = substr( $date, 0, 10 ); if ( !$date ) return false; return strtotime( $date ) >= strtotime( $this -> startDate ); } private function normalizeDocument( $rawDocument, $documentType ) { if ( isset( $rawDocument['invoice'] ) && is_array( $rawDocument['invoice'] ) ) $rawDocument = $rawDocument['invoice']; if ( !is_array( $rawDocument ) ) return null; $externalId = isset( $rawDocument['id'] ) ? (string)$rawDocument['id'] : ''; if ( $externalId === '' ) return null; $number = (string)( $rawDocument['number'] ?? $rawDocument['full_number'] ?? '' ); $date = (string)( $rawDocument['issue_date'] ?? $rawDocument['sell_date'] ?? $rawDocument['created_at'] ?? date( 'Y-m-d' ) ); $date = substr( $date, 0, 10 ); if ( $documentType === 'income' ) { $clientName = (string)( $rawDocument['buyer_name'] ?? $rawDocument['client_name'] ?? 'Nieznany klient' ); $clientTaxNo = (string)( $rawDocument['buyer_tax_no'] ?? $rawDocument['tax_no'] ?? '' ); } else { $clientName = (string)( $rawDocument['seller_name'] ?? $rawDocument['client_name'] ?? 'Nieznany kontrahent' ); $clientTaxNo = (string)( $rawDocument['seller_tax_no'] ?? $rawDocument['tax_no'] ?? '' ); } $positions = []; $totalAmount = 0.0; $rawPositions = $this -> resolvePositions( $rawDocument ); foreach ( $rawPositions as $rawPosition ) { if ( !is_array( $rawPosition ) ) continue; $name = trim( (string)( $rawPosition['name'] ?? '' ) ); if ( $name === '' ) $name = 'Pozycja'; $amount = $this -> resolvePositionAmount( $rawPosition ); $totalAmount += $amount; $positions[] = [ 'item_key' => $this -> buildItemKey( $rawPosition, $name ), 'name' => $name, 'amount' => $amount ]; } if ( empty( $positions ) ) { $fallback = (float)( $rawDocument['price_gross'] ?? $rawDocument['price'] ?? 0 ); $fallbackName = trim( (string)( $rawDocument['product_cache'] ?? '' ) ); if ( $fallbackName === '' ) $fallbackName = 'Faktura bez pozycji'; if ( $fallback != 0.0 ) { $positions[] = [ 'item_key' => $this -> buildItemKey( [], $fallbackName ), 'name' => $fallbackName, 'amount' => $fallback ]; $totalAmount = $fallback; } } if ( empty( $positions ) ) return null; return [ 'external_id' => $externalId, 'document_key' => $documentType . ':' . $externalId, 'number' => $number, 'date' => $date, 'buyer_name' => (string)( $rawDocument['buyer_name'] ?? '' ), 'seller_name' => (string)( $rawDocument['seller_name'] ?? '' ), 'client_name' => $clientName, 'client_tax_no' => $clientTaxNo, 'client_key' => $this -> buildClientKey( $rawDocument, $clientName, $clientTaxNo ), 'positions' => $positions, 'total_amount' => $this -> normalizeAmount( $totalAmount, $documentType ) ]; } private function resolvePositions( $rawDocument ) { if ( isset( $rawDocument['positions'] ) && is_array( $rawDocument['positions'] ) && !empty( $rawDocument['positions'] ) ) return $rawDocument['positions']; $invoiceId = isset( $rawDocument['id'] ) ? (int)$rawDocument['id'] : 0; if ( $invoiceId <= 0 ) return []; $details = $this -> apiClient -> fetchInvoiceDetails( $invoiceId ); if ( is_array( $details ) && isset( $details['positions'] ) && is_array( $details['positions'] ) ) return $details['positions']; return []; } private function resolvePositionAmount( $position ) { $quantity = (float)( $position['quantity'] ?? 1 ); if ( $quantity <= 0 ) $quantity = 1; $totalNet = (float)( $position['total_price_net'] ?? 0 ); if ( $totalNet != 0.0 ) return $totalNet; $unitNet = (float)( $position['price'] ?? $position['price_net'] ?? 0 ); if ( $unitNet != 0.0 ) return $unitNet * $quantity; // Fallback dla pozycji, ktore nie maja netto w payloadzie. $totalGross = (float)( $position['total_price_gross'] ?? 0 ); if ( $totalGross != 0.0 ) return $totalGross; $unitGross = (float)( $position['price_gross'] ?? 0 ); if ( $unitGross != 0.0 ) return $unitGross * $quantity; return 0.0; } private function buildClientKey( $rawDocument, $clientName, $clientTaxNo ) { if ( isset( $rawDocument['client_id'] ) && (string)$rawDocument['client_id'] !== '' ) return 'id:' . (string)$rawDocument['client_id']; if ( $clientTaxNo !== '' ) return 'tax:' . preg_replace( '/\s+/', '', $clientTaxNo ); return 'name:' . $this -> normalizeKeyValue( $clientName ); } private function buildItemKey( $rawPosition, $name ) { if ( isset( $rawPosition['product_id'] ) && (string)$rawPosition['product_id'] !== '' ) return 'product:' . (string)$rawPosition['product_id']; return 'name:' . $this -> normalizeKeyValue( $name ); } private function normalizeKeyValue( $value ) { $value = trim( (string)$value ); if ( function_exists( 'mb_strtolower' ) ) return mb_strtolower( $value, 'UTF-8' ); return strtolower( $value ); } private function normalizeAmount( $amount, $documentType ) { $amount = (float)$amount; if ( $documentType === 'cost' ) return abs( $amount ) * -1; return abs( $amount ); } private function buildDescription( $document, $position ) { $parts = [ 'Fakturownia' ]; if ( $document['number'] !== '' ) $parts[] = 'FV: ' . $document['number']; $parts[] = $position['name']; $parts[] = $document['client_name']; return implode( ' | ', $parts ); } private function resolveConfig() { $domain = trim( (string)\Env::get( 'FAKTUROWNIA_API_DOMAIN', '' ) ); $token = trim( (string)\Env::get( 'FAKTUROWNIA_API_TOKEN', '' ) ); $startDate = trim( (string)\Env::get( 'FAKTUROWNIA_START_DATE', '' ) ); $pageLimit = (int)\Env::get( 'FAKTUROWNIA_PAGE_LIMIT', 100 ); if ( $domain === '' || $token === '' || $startDate === '' ) return [ 'status' => 'error', 'ok' => false, 'msg' => 'Import Fakturownia: brak konfiguracji w .env.' ]; if ( !preg_match( '/^\d{4}-\d{2}-\d{2}$/', $startDate ) ) return [ 'status' => 'error', 'ok' => false, 'msg' => 'Import Fakturownia: nieprawidlowa data FAKTUROWNIA_START_DATE.' ]; if ( strpos( $domain, 'http://' ) !== 0 && strpos( $domain, 'https://' ) !== 0 ) $domain = 'https://' . $domain; return [ 'ok' => true, 'api_url' => rtrim( $domain, '/' ), 'token' => $token, 'start_date' => $startDate, 'page_limit' => $pageLimit > 0 ? $pageLimit : 100 ]; } private function formatMessage( $summary ) { return 'Import Fakturownia: zaimportowano ' . (int)$summary['imported'] . ', pominieto ' . (int)$summary['skipped'] . ', brak mapowan ' . (int)$summary['unmapped'] . ', bledy ' . (int)$summary['errors'] . '.'; } }