Files
crmPRO/autoload/Domain/Finances/FakturowniaInvoiceImporter.php
2026-04-02 12:00:38 +02:00

462 lines
14 KiB
PHP

<?php
namespace Domain\Finances;
class FakturowniaInvoiceImporter
{
private $mdb;
private $repo;
private $apiClient;
private $startDate;
private $pageLimit = 100;
public function __construct( $mdb = null )
{
if ( $mdb )
$this -> 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['imported'] === 0 && $summary['unmapped'] === 0 && $summary['errors'] === 0 )
return [ 'status' => 'empty', 'msg' => 'Import Fakturownia: brak nowych dokumentow.' ];
if ( $summary['errors'] > 0 )
return [ 'status' => 'error', '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
] );
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,
'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'] . '.';
}
}