466 lines
14 KiB
PHP
466 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['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'] . '.';
|
|
}
|
|
}
|