This commit is contained in:
2026-04-02 12:00:38 +02:00
parent 46dae22a71
commit e743245cee
21 changed files with 2105 additions and 196 deletions

View File

@@ -8,6 +8,11 @@ class FinancesController
return new \Domain\Finances\FinanceRepository();
}
private static function importRepo()
{
return new \Domain\Finances\FakturowniaImportRepository();
}
private static function requireAuth()
{
global $user;
@@ -53,6 +58,8 @@ class FinancesController
return false;
$repo = self::repo();
$importRepo = self::importRepo();
$importRepo -> ensureTables();
if ( \S::get( 'tag-clear' ) )
unset( $_SESSION['finance-tag-id'] );
@@ -93,10 +100,121 @@ class FinancesController
'wallet_summary' => $repo -> walletSummary( $group_id ),
'wallet_summary_this_month' => $repo -> walletSummaryThisMonth( $group_id ),
'wallet_income_this_month' => $repo -> walletIncomeThisMonth( $group_id ),
'wallet_expenses_this_month' => $repo -> walletExpensesThisMonth( $group_id )
'wallet_expenses_this_month' => $repo -> walletExpensesThisMonth( $group_id ),
'fakturownia_pending_clients' => self::preparePendingRows( $importRepo -> pendingClientMappings() ),
'fakturownia_pending_items' => self::preparePendingRows( $importRepo -> pendingItemMappings() ),
'fakturownia_crm_clients' => $repo -> clientsList(),
'fakturownia_categories' => self::prepareCategoryOptions( $repo -> categoriesFlatList() ),
'fakturownia_last_summary' => self::lastImportSummary( $importRepo )
] );
}
private static function preparePendingRows( $rows )
{
$output = [];
if ( !is_array( $rows ) )
return $output;
foreach ( $rows as $row )
{
$payload = [];
if ( isset( $row['payload_json'] ) && $row['payload_json'] )
{
$decoded = json_decode( $row['payload_json'], true );
if ( is_array( $decoded ) )
$payload = $decoded;
}
$output[] = [
'external_key' => (string)$row['external_key'],
'external_name' => (string)$row['external_name'],
'hits' => (int)$row['hits'],
'last_seen_at' => (string)$row['last_seen_at'],
'payload' => $payload
];
}
return $output;
}
private static function lastImportSummary( $importRepo )
{
$raw = $importRepo -> getState( 'last_import_summary' );
if ( !$raw )
return null;
$decoded = json_decode( $raw, true );
return is_array( $decoded ) ? $decoded : null;
}
private static function prepareCategoryOptions( $categories )
{
$options = [];
if ( !is_array( $categories ) || empty( $categories ) )
return $options;
$byId = [];
foreach ( $categories as $category )
{
$id = isset( $category['id'] ) ? (int)$category['id'] : 0;
if ( $id <= 0 )
continue;
$byId[ $id ] = $category;
}
foreach ( $categories as $category )
{
$id = isset( $category['id'] ) ? (int)$category['id'] : 0;
if ( $id <= 0 )
continue;
$label = self::buildCategoryPathLabel( $id, $byId );
$options[] = [
'id' => $id,
'name' => $label,
'group_id' => isset( $category['group_id'] ) ? (int)$category['group_id'] : 0
];
}
usort( $options, function( $left, $right )
{
return strcmp( $left['name'], $right['name'] );
} );
return $options;
}
private static function buildCategoryPathLabel( $categoryId, $byId )
{
$parts = [];
$guard = 0;
$currentId = (int)$categoryId;
while ( $currentId > 0 && isset( $byId[ $currentId ] ) )
{
$category = $byId[ $currentId ];
$name = trim( (string)( $category['name'] ?? '' ) );
if ( $name !== '' )
array_unshift( $parts, $name );
$parentId = isset( $category['parent_id'] ) ? (int)$category['parent_id'] : 0;
if ( $parentId <= 0 || $parentId === $currentId )
break;
$currentId = $parentId;
$guard++;
if ( $guard > 20 )
break;
}
if ( empty( $parts ) )
return 'Kategoria #' . (int)$categoryId;
return implode( ' > ', $parts );
}
public static function operationEdit()
{
if ( !self::requireAuth() )
@@ -230,4 +348,70 @@ class FinancesController
'date_to' => $date_to
] );
}
public static function fakturowniaClientMappingSave()
{
if ( !self::requireAuth() )
return false;
if ( !\S::csrf_verify() )
{
\S::alert( 'Nieprawidlowy token bezpieczenstwa. Odswiez strone i sproboj ponownie.' );
header( 'Location: /finances/main_view/' );
exit;
}
$externalKey = trim( (string)\S::get( 'external_key' ) );
$externalName = trim( (string)\S::get( 'external_name' ) );
$crmClientId = (int)\S::get( 'crm_client_id' );
$repo = self::repo();
if ( $externalKey === '' || $externalName === '' || $crmClientId <= 0 || !$repo -> clientExists( $crmClientId ) )
{
\S::alert( 'Nie udalo sie zapisac mapowania klienta. Uzupelnij wszystkie pola.' );
header( 'Location: /finances/main_view/' );
exit;
}
$importRepo = self::importRepo();
$importRepo -> ensureTables();
$importRepo -> saveClientMapping( $externalKey, $externalName, $crmClientId );
\S::alert( 'Mapowanie klienta zostalo zapisane.' );
header( 'Location: /finances/main_view/' );
exit;
}
public static function fakturowniaItemMappingSave()
{
if ( !self::requireAuth() )
return false;
if ( !\S::csrf_verify() )
{
\S::alert( 'Nieprawidlowy token bezpieczenstwa. Odswiez strone i sproboj ponownie.' );
header( 'Location: /finances/main_view/' );
exit;
}
$externalKey = trim( (string)\S::get( 'external_key' ) );
$externalName = trim( (string)\S::get( 'external_name' ) );
$financeCategoryId = (int)\S::get( 'finance_category_id' );
$repo = self::repo();
if ( $externalKey === '' || $externalName === '' || $financeCategoryId <= 0 || !$repo -> categoryExists( $financeCategoryId ) )
{
\S::alert( 'Nie udalo sie zapisac mapowania pozycji. Uzupelnij wszystkie pola.' );
header( 'Location: /finances/main_view/' );
exit;
}
$importRepo = self::importRepo();
$importRepo -> ensureTables();
$importRepo -> saveItemMapping( $externalKey, $externalName, $financeCategoryId );
\S::alert( 'Mapowanie pozycji zostalo zapisane.' );
header( 'Location: /finances/main_view/' );
exit;
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace Domain\Finances;
class FakturowniaApiClient
{
private $baseUrl;
private $apiToken;
private $pageLimit;
private $timeout;
public function __construct( $baseUrl, $apiToken, $pageLimit = 100, $timeout = 20 )
{
$this -> baseUrl = rtrim( (string)$baseUrl, '/' );
$this -> apiToken = (string)$apiToken;
$this -> pageLimit = max( 1, (int)$pageLimit );
$this -> timeout = max( 5, (int)$timeout );
}
public function fetchSalesDocuments( $startDate, $page = 1 )
{
$query = [
'page' => (int)$page,
'per_page' => $this -> pageLimit
];
if ( $this -> canUseCurrentMonthPeriod( $startDate ) )
$query['period'] = 'this_month';
return $this -> requestList( '/invoices.json', $query );
}
public function fetchCostDocuments( $startDate, $page = 1 )
{
$queries = [
[
'page' => (int)$page,
'per_page' => $this -> pageLimit,
'income' => 'no'
]
];
if ( $this -> canUseCurrentMonthPeriod( $startDate ) )
$queries[0]['period'] = 'this_month';
$paths = [ '/costs.json', '/expenses.json', '/invoices.json' ];
foreach ( $paths as $path )
{
foreach ( $queries as $query )
{
$response = $this -> requestList( $path, $query, true );
if ( $response['ok'] )
return $response['data'];
}
}
throw new \RuntimeException( 'Nie udalo sie pobrac faktur kosztowych z API Fakturowni.' );
}
public function fetchInvoiceDetails( $invoiceId )
{
$invoiceId = (int)$invoiceId;
if ( $invoiceId <= 0 )
return null;
$result = $this -> request( '/invoices/' . $invoiceId . '.json', [] );
if ( $result['http_code'] >= 400 )
return null;
$data = json_decode( $result['body'], true );
return is_array( $data ) ? $data : null;
}
private function requestList( $path, $query, $softFail = false )
{
$result = $this -> request( $path, $query );
if ( $result['http_code'] >= 400 )
{
if ( $softFail )
return [ 'ok' => false, 'data' => [] ];
throw new \RuntimeException( 'Blad API Fakturowni: HTTP ' . $result['http_code'] . ' dla ' . $path );
}
$data = json_decode( $result['body'], true );
if ( !is_array( $data ) )
{
if ( $softFail )
return [ 'ok' => false, 'data' => [] ];
throw new \RuntimeException( 'API Fakturowni zwrocilo niepoprawny JSON.' );
}
$list = $this -> extractList( $data );
return $softFail ? [ 'ok' => true, 'data' => $list ] : $list;
}
private function request( $path, $query )
{
$query['api_token'] = $this -> apiToken;
$url = $this -> baseUrl . $path . '?' . http_build_query( $query );
$ch = curl_init( $url );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, $this -> timeout );
curl_setopt( $ch, CURLOPT_TIMEOUT, $this -> timeout );
curl_setopt( $ch, CURLOPT_HTTPHEADER, [
'Accept: application/json'
] );
$body = curl_exec( $ch );
$httpCode = (int)curl_getinfo( $ch, CURLINFO_HTTP_CODE );
$error = curl_error( $ch );
curl_close( $ch );
if ( $body === false )
throw new \RuntimeException( 'Blad polaczenia z API Fakturowni: ' . $error );
return [
'http_code' => $httpCode,
'body' => $body
];
}
private function extractList( $data )
{
if ( $this -> isList( $data ) )
return $data;
$keys = [ 'invoices', 'costs', 'expenses', 'data' ];
foreach ( $keys as $key )
{
if ( isset( $data[ $key ] ) && is_array( $data[ $key ] ) )
{
if ( $this -> isList( $data[ $key ] ) )
return $data[ $key ];
}
}
return [];
}
private function isList( $value )
{
if ( !is_array( $value ) )
return false;
if ( $value === [] )
return true;
return array_keys( $value ) === range( 0, count( $value ) - 1 );
}
private function canUseCurrentMonthPeriod( $startDate )
{
if ( !is_string( $startDate ) || !preg_match( '/^\d{4}-\d{2}-\d{2}$/', $startDate ) )
return false;
return $startDate === date( 'Y-m-01' );
}
}

View File

@@ -0,0 +1,318 @@
<?php
namespace Domain\Finances;
class FakturowniaImportRepository
{
private $mdb;
private $tablesReady = false;
public function __construct( $mdb = null )
{
if ( $mdb )
$this -> mdb = $mdb;
else
{
global $mdb;
$this -> mdb = $mdb;
}
}
public function ensureTables()
{
if ( $this -> tablesReady )
return;
$this -> mdb -> query(
'CREATE TABLE IF NOT EXISTS `fakturownia_client_mappings` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`external_client_key` VARCHAR(191) NOT NULL,
`external_name` VARCHAR(255) NOT NULL,
`crm_client_id` INT UNSIGNED NOT NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_external_client_key` (`external_client_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
);
$this -> mdb -> query(
'CREATE TABLE IF NOT EXISTS `fakturownia_item_mappings` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`external_item_key` VARCHAR(191) NOT NULL,
`external_name` VARCHAR(255) NOT NULL,
`finance_category_id` INT UNSIGNED NOT NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_external_item_key` (`external_item_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
);
$this -> mdb -> query(
'CREATE TABLE IF NOT EXISTS `fakturownia_imported_documents` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`external_document_key` VARCHAR(191) NOT NULL,
`document_type` VARCHAR(32) NOT NULL,
`external_id` VARCHAR(64) NOT NULL,
`source_date` DATE NULL,
`amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`finance_operation_ids` TEXT NULL,
`meta_json` LONGTEXT NULL,
`imported_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_external_document_key` (`external_document_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
);
$this -> mdb -> query(
'CREATE TABLE IF NOT EXISTS `fakturownia_unmapped_queue` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`queue_type` VARCHAR(32) NOT NULL,
`external_key` VARCHAR(191) NOT NULL,
`external_name` VARCHAR(255) NOT NULL,
`payload_json` LONGTEXT NULL,
`hits` INT UNSIGNED NOT NULL DEFAULT 1,
`resolved` TINYINT(1) NOT NULL DEFAULT 0,
`first_seen_at` DATETIME NOT NULL,
`last_seen_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_type_key` (`queue_type`, `external_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
);
$this -> mdb -> query(
'CREATE TABLE IF NOT EXISTS `fakturownia_import_state` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`state_key` VARCHAR(191) NOT NULL,
`state_value` LONGTEXT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_state_key` (`state_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
);
$this -> tablesReady = true;
}
public function getClientMapping( $externalClientKey )
{
$this -> ensureTables();
return $this -> mdb -> get( 'fakturownia_client_mappings', '*', [
'external_client_key' => $externalClientKey
] );
}
public function saveClientMapping( $externalClientKey, $externalName, $crmClientId )
{
$this -> ensureTables();
$current = $this -> getClientMapping( $externalClientKey );
$now = date( 'Y-m-d H:i:s' );
if ( $current )
{
$this -> mdb -> update( 'fakturownia_client_mappings', [
'external_name' => $externalName,
'crm_client_id' => (int)$crmClientId,
'updated_at' => $now
], [ 'id' => (int)$current['id'] ] );
}
else
{
$this -> mdb -> insert( 'fakturownia_client_mappings', [
'external_client_key' => $externalClientKey,
'external_name' => $externalName,
'crm_client_id' => (int)$crmClientId,
'created_at' => $now,
'updated_at' => $now
] );
}
$this -> resolveQueueItem( 'client', $externalClientKey );
}
public function getItemMapping( $externalItemKey )
{
$this -> ensureTables();
return $this -> mdb -> get( 'fakturownia_item_mappings', '*', [
'external_item_key' => $externalItemKey
] );
}
public function saveItemMapping( $externalItemKey, $externalName, $financeCategoryId )
{
$this -> ensureTables();
$current = $this -> getItemMapping( $externalItemKey );
$now = date( 'Y-m-d H:i:s' );
if ( $current )
{
$this -> mdb -> update( 'fakturownia_item_mappings', [
'external_name' => $externalName,
'finance_category_id' => (int)$financeCategoryId,
'updated_at' => $now
], [ 'id' => (int)$current['id'] ] );
}
else
{
$this -> mdb -> insert( 'fakturownia_item_mappings', [
'external_item_key' => $externalItemKey,
'external_name' => $externalName,
'finance_category_id' => (int)$financeCategoryId,
'created_at' => $now,
'updated_at' => $now
] );
}
$this -> resolveQueueItem( 'item', $externalItemKey );
}
public function isDocumentImported( $externalDocumentKey )
{
$this -> ensureTables();
return (bool)$this -> mdb -> has( 'fakturownia_imported_documents', [
'external_document_key' => $externalDocumentKey
] );
}
public function markDocumentImported( $externalDocumentKey, $documentType, $externalId, $sourceDate, $amount, $operationIds, $meta )
{
$this -> ensureTables();
$now = date( 'Y-m-d H:i:s' );
$data = [
'external_document_key' => $externalDocumentKey,
'document_type' => $documentType,
'external_id' => (string)$externalId,
'source_date' => $sourceDate,
'amount' => (float)$amount,
'finance_operation_ids' => implode( ',', $operationIds ),
'meta_json' => json_encode( $meta, JSON_UNESCAPED_UNICODE ),
'imported_at' => $now
];
$existing = $this -> mdb -> get( 'fakturownia_imported_documents', 'id', [
'external_document_key' => $externalDocumentKey
] );
if ( $existing )
{
$this -> mdb -> update( 'fakturownia_imported_documents', $data, [
'id' => (int)$existing
] );
return;
}
$this -> mdb -> insert( 'fakturownia_imported_documents', $data );
}
public function queueUnmapped( $queueType, $externalKey, $externalName, $payload )
{
$this -> ensureTables();
$existing = $this -> mdb -> get( 'fakturownia_unmapped_queue', '*', [
'AND' => [
'queue_type' => $queueType,
'external_key' => $externalKey
]
] );
$now = date( 'Y-m-d H:i:s' );
$payloadJson = json_encode( $payload, JSON_UNESCAPED_UNICODE );
if ( $existing )
{
$this -> mdb -> update( 'fakturownia_unmapped_queue', [
'external_name' => $externalName,
'payload_json' => $payloadJson,
'hits[+]' => 1,
'resolved' => 0,
'last_seen_at' => $now
], [ 'id' => (int)$existing['id'] ] );
return;
}
$this -> mdb -> insert( 'fakturownia_unmapped_queue', [
'queue_type' => $queueType,
'external_key' => $externalKey,
'external_name' => $externalName,
'payload_json' => $payloadJson,
'hits' => 1,
'resolved' => 0,
'first_seen_at' => $now,
'last_seen_at' => $now
] );
}
public function pendingClientMappings()
{
$this -> ensureTables();
return $this -> mdb -> select( 'fakturownia_unmapped_queue', '*', [
'AND' => [
'queue_type' => 'client',
'resolved' => 0
],
'ORDER' => [ 'last_seen_at' => 'DESC' ]
] );
}
public function pendingItemMappings()
{
$this -> ensureTables();
return $this -> mdb -> select( 'fakturownia_unmapped_queue', '*', [
'AND' => [
'queue_type' => 'item',
'resolved' => 0,
'external_key[!]' => 'name:faktura bez pozycji'
],
'ORDER' => [ 'last_seen_at' => 'DESC' ]
] );
}
public function saveState( $stateKey, $stateValue )
{
$this -> ensureTables();
$existing = $this -> mdb -> get( 'fakturownia_import_state', '*', [
'state_key' => $stateKey
] );
$now = date( 'Y-m-d H:i:s' );
if ( $existing )
{
$this -> mdb -> update( 'fakturownia_import_state', [
'state_value' => $stateValue,
'updated_at' => $now
], [ 'id' => (int)$existing['id'] ] );
return;
}
$this -> mdb -> insert( 'fakturownia_import_state', [
'state_key' => $stateKey,
'state_value' => $stateValue,
'updated_at' => $now
] );
}
public function getState( $stateKey )
{
$this -> ensureTables();
return $this -> mdb -> get( 'fakturownia_import_state', 'state_value', [
'state_key' => $stateKey
] );
}
private function resolveQueueItem( $queueType, $externalKey )
{
$this -> mdb -> update( 'fakturownia_unmapped_queue', [
'resolved' => 1,
'last_seen_at' => date( 'Y-m-d H:i:s' )
], [
'AND' => [
'queue_type' => $queueType,
'external_key' => $externalKey
]
] );
}
}

View File

@@ -0,0 +1,461 @@
<?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'] . '.';
}
}

View File

@@ -55,6 +55,23 @@ class FinanceRepository
return $this -> mdb -> select( 'crm_client', [ 'id', 'firm' ], [ 'ORDER' => [ 'firm' => 'ASC' ] ] );
}
public function categoriesFlatList()
{
return $this -> mdb -> select( 'finance_categories', [ 'id', 'name', 'group_id', 'parent_id' ], [
'ORDER' => [ 'name' => 'ASC' ]
] );
}
public function clientExists( $clientId )
{
return (bool)$this -> mdb -> has( 'crm_client', [ 'id' => (int)$clientId ] );
}
public function categoryExists( $categoryId )
{
return (bool)$this -> mdb -> has( 'finance_categories', [ 'id' => (int)$categoryId ] );
}
public function clientsWithRevenue( $date_from, $date_to, $group_id )
{
return $this -> mdb -> query(

View File

@@ -2,6 +2,22 @@
class Cron
{
public static function import_finances_from_fakturownia()
{
try
{
$importer = new \Domain\Finances\FakturowniaInvoiceImporter();
return $importer -> import();
}
catch ( \Throwable $e )
{
return [
'status' => 'error',
'msg' => 'Import Fakturownia: ' . $e -> getMessage()
];
}
}
public static function recursive_tasks()
{
global $mdb;

64
autoload/class.Env.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
class Env
{
private static $loaded = false;
private static $values = [];
public static function load( $path = '.env' )
{
if ( self::$loaded )
return;
self::$loaded = true;
if ( !is_string( $path ) || $path === '' || !file_exists( $path ) )
return;
$lines = file( $path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
if ( !is_array( $lines ) )
return;
foreach ( $lines as $line )
{
$line = trim( $line );
if ( $line === '' || strpos( $line, '#' ) === 0 )
continue;
$parts = explode( '=', $line, 2 );
if ( count( $parts ) !== 2 )
continue;
$key = trim( $parts[0] );
$value = trim( $parts[1] );
if ( $key === '' )
continue;
if ( strlen( $value ) >= 2 )
{
$first = $value[0];
$last = $value[strlen( $value ) - 1];
if ( ( $first === '"' && $last === '"' ) || ( $first === "'" && $last === "'" ) )
$value = substr( $value, 1, -1 );
}
self::$values[ $key ] = $value;
$_ENV[ $key ] = $value;
putenv( $key . '=' . $value );
}
}
public static function get( $key, $default = null )
{
self::load();
$value = getenv( $key );
if ( $value !== false )
return $value;
if ( isset( self::$values[ $key ] ) )
return self::$values[ $key ];
return $default;
}
}