feat(115): wystawianie faktury z zamowienia (lokalne + delegowane Fakturownia)
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>
This commit is contained in:
166
src/Core/Http/MfWhitelistApiClient.php
Normal file
166
src/Core/Http/MfWhitelistApiClient.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core\Http;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Klient publicznego API "Biala lista podatnikow VAT" Ministerstwa Finansow.
|
||||
* Endpoint: https://wl-api.mf.gov.pl/api/search/nip/{nip}?date=YYYY-MM-DD
|
||||
* Bez rejestracji, bez klucza. Limit ~10 req/s per IP.
|
||||
*/
|
||||
final class MfWhitelistApiClient
|
||||
{
|
||||
private const BASE_URL = 'https://wl-api.mf.gov.pl';
|
||||
|
||||
public function __construct(private readonly int $timeoutSeconds = 10)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{name: string, tax_no: string, regon: string, street: string, postal_code: string, city: string, country: string, status_vat: string, raw: array<string, mixed>}
|
||||
*/
|
||||
public function lookupByNip(string $nip, ?string $date = null): array
|
||||
{
|
||||
$cleanNip = preg_replace('/[\s\-]/', '', trim($nip)) ?? '';
|
||||
if (!preg_match('/^\d{10}$/', $cleanNip)) {
|
||||
throw new RuntimeException('NIP musi miec 10 cyfr.');
|
||||
}
|
||||
|
||||
$dateParam = $date ?? date('Y-m-d');
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateParam)) {
|
||||
$dateParam = date('Y-m-d');
|
||||
}
|
||||
|
||||
$url = self::BASE_URL . '/api/search/nip/' . rawurlencode($cleanNip)
|
||||
. '?date=' . rawurlencode($dateParam);
|
||||
|
||||
[$body, $httpCode, $curlError] = $this->httpGet($url);
|
||||
|
||||
if ($curlError !== null) {
|
||||
throw new RuntimeException('Blad polaczenia z MF Biala Lista: ' . $curlError);
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$msg = $this->resolveErrorMessage($body);
|
||||
throw new RuntimeException('MF Biala Lista HTTP ' . $httpCode . ($msg !== '' ? ': ' . $msg : ''));
|
||||
}
|
||||
|
||||
$decoded = json_decode($body, true);
|
||||
if (!is_array($decoded) || !is_array($decoded['result'] ?? null)) {
|
||||
throw new RuntimeException('Niepoprawna odpowiedz JSON z MF Biala Lista.');
|
||||
}
|
||||
|
||||
$subject = is_array($decoded['result']['subject'] ?? null) ? $decoded['result']['subject'] : null;
|
||||
if ($subject === null) {
|
||||
throw new RuntimeException('MF Biala Lista nie zwrocila danych dla NIP ' . $cleanNip . ' (brak podmiotu).');
|
||||
}
|
||||
|
||||
$name = trim((string) ($subject['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
throw new RuntimeException('MF Biala Lista nie zwrocila nazwy dla NIP ' . $cleanNip . '.');
|
||||
}
|
||||
|
||||
$address = trim((string) ($subject['workingAddress'] ?? $subject['residenceAddress'] ?? ''));
|
||||
['street' => $street, 'postal_code' => $postalCode, 'city' => $city] = $this->parseAddress($address);
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'tax_no' => trim((string) ($subject['nip'] ?? $cleanNip)),
|
||||
'regon' => trim((string) ($subject['regon'] ?? '')),
|
||||
'street' => $street,
|
||||
'postal_code' => $postalCode,
|
||||
'city' => $city,
|
||||
'country' => 'PL',
|
||||
'status_vat' => trim((string) ($subject['statusVat'] ?? '')),
|
||||
'raw' => $subject,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse address from MF Biala Lista format: "ul. Krakowska 1, 00-001 Warszawa".
|
||||
*
|
||||
* @return array{street: string, postal_code: string, city: string}
|
||||
*/
|
||||
private function parseAddress(string $address): array
|
||||
{
|
||||
if ($address === '') {
|
||||
return ['street' => '', 'postal_code' => '', 'city' => ''];
|
||||
}
|
||||
|
||||
$lastComma = strrpos($address, ',');
|
||||
if ($lastComma === false) {
|
||||
return ['street' => $address, 'postal_code' => '', 'city' => ''];
|
||||
}
|
||||
|
||||
$street = trim(substr($address, 0, $lastComma));
|
||||
$tail = trim(substr($address, $lastComma + 1));
|
||||
|
||||
if (preg_match('/^(\d{2}-\d{3})\s+(.+)$/u', $tail, $m)) {
|
||||
return ['street' => $street, 'postal_code' => $m[1], 'city' => trim($m[2])];
|
||||
}
|
||||
|
||||
return ['street' => $street, 'postal_code' => '', 'city' => $tail];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: int, 2: ?string}
|
||||
*/
|
||||
private function httpGet(string $url): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return ['', 0, 'Nie udalo sie zainicjowac cURL.'];
|
||||
}
|
||||
|
||||
$opts = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPGET => true,
|
||||
CURLOPT_TIMEOUT => $this->timeoutSeconds,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'User-Agent: orderPRO/1.0',
|
||||
],
|
||||
];
|
||||
|
||||
$caPath = SslCertificateResolver::resolve();
|
||||
if ($caPath !== null) {
|
||||
$opts[CURLOPT_CAINFO] = $caPath;
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, $opts);
|
||||
$rawBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
|
||||
if ($rawBody === false) {
|
||||
return ['', $httpCode, $curlError !== '' ? $curlError : 'Brak odpowiedzi z API.'];
|
||||
}
|
||||
|
||||
return [(string) $rawBody, $httpCode, null];
|
||||
}
|
||||
|
||||
private function resolveErrorMessage(string $body): string
|
||||
{
|
||||
$trimmed = ltrim($body, "\xEF\xBB\xBF \t\n\r\0\x0B");
|
||||
if ($trimmed === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$decoded = json_decode($trimmed, true);
|
||||
if (is_array($decoded)) {
|
||||
foreach (['message', 'code', 'error'] as $key) {
|
||||
if (isset($decoded[$key]) && is_string($decoded[$key]) && trim($decoded[$key]) !== '') {
|
||||
return trim($decoded[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$snippet = trim(strip_tags($trimmed));
|
||||
return substr($snippet, 0, 200);
|
||||
}
|
||||
}
|
||||
292
src/Modules/Accounting/InvoiceController.php
Normal file
292
src/Modules/Accounting/InvoiceController.php
Normal file
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Accounting;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Core\Security\Csrf;
|
||||
use App\Core\Support\Flash;
|
||||
use App\Core\View\Template;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Core\Http\MfWhitelistApiClient;
|
||||
use App\Modules\Settings\CompanySettingsRepository;
|
||||
use App\Modules\Settings\InvoiceConfigRepository;
|
||||
use Throwable;
|
||||
|
||||
final class InvoiceController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly InvoiceRepository $invoices,
|
||||
private readonly InvoiceConfigRepository $invoiceConfigs,
|
||||
private readonly CompanySettingsRepository $companySettings,
|
||||
private readonly OrdersRepository $orders,
|
||||
private readonly InvoiceService $invoiceService,
|
||||
private readonly MfWhitelistApiClient $mfWhitelist
|
||||
) {
|
||||
}
|
||||
|
||||
public function nipLookup(Request $request): Response
|
||||
{
|
||||
$nip = preg_replace('/[\s\-]/', '', (string) $request->input('nip', '')) ?? '';
|
||||
if (!preg_match('/^\d{10}$/', $nip)) {
|
||||
return Response::json(['success' => false, 'error' => 'Niepoprawny NIP (musi miec 10 cyfr).'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$data = $this->mfWhitelist->lookupByNip($nip);
|
||||
} catch (Throwable $e) {
|
||||
return Response::json(['success' => false, 'error' => $e->getMessage()], 502);
|
||||
}
|
||||
|
||||
return Response::json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'company_name' => $data['name'],
|
||||
'tax_number' => $data['tax_no'],
|
||||
'street' => $data['street'],
|
||||
'postal_code' => $data['postal_code'],
|
||||
'city' => $data['city'],
|
||||
'country' => $data['country'],
|
||||
'regon' => $data['regon'],
|
||||
'status_vat' => $data['status_vat'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
$details = $this->orders->findDetails($orderId);
|
||||
if ($details === null) {
|
||||
return Response::html('Not found', 404);
|
||||
}
|
||||
|
||||
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
|
||||
if ((int) ($order['invoice_requested'] ?? 0) !== 1) {
|
||||
Flash::set('order.error', 'Faktura nie zostala zazadana dla tego zamowienia.');
|
||||
return Response::redirect('/orders/' . $orderId);
|
||||
}
|
||||
|
||||
$configs = array_values(array_filter(
|
||||
$this->invoiceConfigs->listAll(),
|
||||
static fn (array $c): bool => (int) ($c['is_active'] ?? 0) === 1
|
||||
));
|
||||
if ($configs === []) {
|
||||
Flash::set('order.error', 'Brak aktywnych konfiguracji faktur. Skonfiguruj w Ustawienia > Ksiegowosc > Faktury.');
|
||||
return Response::redirect('/orders/' . $orderId);
|
||||
}
|
||||
|
||||
$items = is_array($details['items'] ?? null) ? $details['items'] : [];
|
||||
$addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
|
||||
|
||||
$byType = [];
|
||||
foreach ($addresses as $addr) {
|
||||
$type = (string) ($addr['address_type'] ?? '');
|
||||
if ($type !== '' && !isset($byType[$type])) {
|
||||
$byType[$type] = $addr;
|
||||
}
|
||||
}
|
||||
$buyerAddress = $byType['invoice'] ?? $byType['customer'] ?? null;
|
||||
$autoTaxNumber = InvoiceService::extractBuyerTaxNumber($order, $buyerAddress);
|
||||
|
||||
$existingInvoices = $this->invoices->findByOrderId($orderId);
|
||||
|
||||
$html = $this->template->render('accounting/invoice_form', [
|
||||
'title' => 'Wystaw fakture',
|
||||
'activeMenu' => 'orders',
|
||||
'activeOrders' => 'list',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'orderId' => $orderId,
|
||||
'order' => $order,
|
||||
'items' => $items,
|
||||
'configs' => $configs,
|
||||
'seller' => $this->companySettings->getSettings(),
|
||||
'buyerAddress' => $buyerAddress,
|
||||
'autoTaxNumber' => $autoTaxNumber,
|
||||
'existingInvoices' => $existingInvoices,
|
||||
'errorMessage' => (string) Flash::get('invoice.error', ''),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
public function store(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
|
||||
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
||||
Flash::set('order.error', 'Nieprawidlowy token CSRF.');
|
||||
return Response::redirect('/orders/' . $orderId);
|
||||
}
|
||||
|
||||
$configId = (int) $request->input('config_id', 0);
|
||||
if ($configId <= 0) {
|
||||
Flash::set('invoice.error', 'Wybierz konfiguracje faktury.');
|
||||
return Response::redirect('/orders/' . $orderId . '/invoice/create');
|
||||
}
|
||||
|
||||
$user = $this->auth->user();
|
||||
|
||||
try {
|
||||
$result = $this->invoiceService->issue([
|
||||
'order_id' => $orderId,
|
||||
'config_id' => $configId,
|
||||
'buyer_tax_number' => (string) $request->input('buyer_tax_number', ''),
|
||||
'buyer_name' => (string) $request->input('buyer_name', ''),
|
||||
'buyer_company_name' => (string) $request->input('buyer_company_name', ''),
|
||||
'buyer_street' => (string) $request->input('buyer_street', ''),
|
||||
'buyer_city' => (string) $request->input('buyer_city', ''),
|
||||
'buyer_postal_code' => (string) $request->input('buyer_postal_code', ''),
|
||||
'buyer_email' => (string) $request->input('buyer_email', ''),
|
||||
'issue_date_override' => (string) $request->input('issue_date', ''),
|
||||
'created_by' => is_array($user) ? ($user['id'] ?? null) : null,
|
||||
]);
|
||||
|
||||
$userName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : '';
|
||||
$this->orders->recordActivity(
|
||||
$orderId,
|
||||
'invoice_issued',
|
||||
($result['mode'] === 'delegated' ? 'Wystawiono fakture (Fakturownia): ' : 'Wystawiono fakture: ') . $result['invoice_number'],
|
||||
[
|
||||
'invoice_number' => $result['invoice_number'],
|
||||
'config_id' => $configId,
|
||||
'mode' => $result['mode'],
|
||||
'total_gross' => $result['total_gross'],
|
||||
],
|
||||
'user',
|
||||
$userName !== '' ? $userName : null
|
||||
);
|
||||
|
||||
Flash::set('order.success', 'Faktura wystawiona: ' . $result['invoice_number']);
|
||||
return Response::redirect('/orders/' . $orderId . '/invoice/' . $result['invoice_id']);
|
||||
} catch (InvoiceIssueException $e) {
|
||||
Flash::set('invoice.error', $e->getMessage());
|
||||
} catch (Throwable $e) {
|
||||
Flash::set('invoice.error', 'Blad wystawiania faktury: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return Response::redirect('/orders/' . $orderId . '/invoice/create');
|
||||
}
|
||||
|
||||
public function show(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
$invoiceId = max(0, (int) $request->input('invoiceId', 0));
|
||||
|
||||
$invoice = $this->invoices->findById($invoiceId);
|
||||
if ($invoice === null || (int) ($invoice['order_id'] ?? 0) !== $orderId) {
|
||||
return Response::html('Not found', 404);
|
||||
}
|
||||
|
||||
$data = $this->buildInvoiceViewData($invoice, $orderId);
|
||||
$html = $this->template->render('accounting/invoice_preview', $data, 'layouts/app');
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
public function pdf(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
$invoiceId = max(0, (int) $request->input('invoiceId', 0));
|
||||
|
||||
$invoice = $this->invoices->findById($invoiceId);
|
||||
if ($invoice === null || (int) ($invoice['order_id'] ?? 0) !== $orderId) {
|
||||
return Response::html('Not found', 404);
|
||||
}
|
||||
|
||||
$externalPdfUrl = trim((string) ($invoice['external_pdf_url'] ?? ''));
|
||||
if ($externalPdfUrl !== '') {
|
||||
return Response::redirect($externalPdfUrl);
|
||||
}
|
||||
|
||||
$data = $this->buildInvoiceViewData($invoice, $orderId);
|
||||
$html = $this->template->render('accounting/invoice_pdf', $data);
|
||||
|
||||
$dompdf = new \Dompdf\Dompdf();
|
||||
$dompdf->loadHtml($html);
|
||||
$dompdf->setPaper('A4');
|
||||
$dompdf->render();
|
||||
|
||||
$filename = str_replace(['/', '\\'], '_', (string) ($invoice['invoice_number'] ?? 'invoice')) . '.pdf';
|
||||
|
||||
return new Response($dompdf->output() ?: '', 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
]);
|
||||
}
|
||||
|
||||
public function issuedList(Request $request): Response
|
||||
{
|
||||
$filters = [
|
||||
'search' => trim((string) $request->input('search', '')),
|
||||
'config_id' => (int) $request->input('config_id', 0),
|
||||
'mode' => (string) $request->input('mode', ''),
|
||||
'date_from' => trim((string) $request->input('date_from', '')),
|
||||
'date_to' => trim((string) $request->input('date_to', '')),
|
||||
'page' => max(1, (int) $request->input('page', 1)),
|
||||
'per_page' => 50,
|
||||
];
|
||||
|
||||
$result = $this->invoices->paginate($filters);
|
||||
$totalPages = max(1, (int) ceil(((int) $result['total']) / max(1, (int) $result['per_page'])));
|
||||
|
||||
$configs = $this->invoiceConfigs->listAll();
|
||||
|
||||
$html = $this->template->render('accounting/invoices_issued_list', [
|
||||
'title' => 'Wystawione faktury',
|
||||
'activeMenu' => 'settings',
|
||||
'activeSettings' => 'accounting',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'filters' => $filters,
|
||||
'invoices' => $result['items'],
|
||||
'total' => $result['total'],
|
||||
'page' => $result['page'],
|
||||
'perPage' => $result['per_page'],
|
||||
'totalPages' => $totalPages,
|
||||
'configs' => $configs,
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $invoice
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildInvoiceViewData(array $invoice, int $orderId): array
|
||||
{
|
||||
$seller = json_decode((string) ($invoice['seller_data_json'] ?? '{}'), true);
|
||||
$buyer = ($invoice['buyer_data_json'] ?? null) !== null
|
||||
? json_decode((string) $invoice['buyer_data_json'], true)
|
||||
: null;
|
||||
$itemsJson = json_decode((string) ($invoice['items_json'] ?? '{"items":[]}'), true);
|
||||
$items = is_array($itemsJson['items'] ?? null) ? $itemsJson['items'] : (is_array($itemsJson) ? $itemsJson : []);
|
||||
|
||||
$configName = trim((string) ($invoice['config_name'] ?? ''));
|
||||
$isDelegated = (int) ($invoice['config_is_delegated'] ?? 0) === 1
|
||||
|| trim((string) ($invoice['external_invoice_id'] ?? '')) !== '';
|
||||
|
||||
return [
|
||||
'title' => 'Faktura ' . ($invoice['invoice_number'] ?? ''),
|
||||
'activeMenu' => 'orders',
|
||||
'activeOrders' => 'list',
|
||||
'user' => $this->auth->user(),
|
||||
'orderId' => $orderId,
|
||||
'invoice' => $invoice,
|
||||
'seller' => is_array($seller) ? $seller : [],
|
||||
'buyer' => is_array($buyer) ? $buyer : null,
|
||||
'items' => $items,
|
||||
'configName' => $configName,
|
||||
'isDelegated' => $isDelegated,
|
||||
'integrationName' => trim((string) ($invoice['integration_name'] ?? '')),
|
||||
'accountPrefix' => trim((string) ($invoice['account_prefix'] ?? '')),
|
||||
];
|
||||
}
|
||||
}
|
||||
10
src/Modules/Accounting/InvoiceIssueException.php
Normal file
10
src/Modules/Accounting/InvoiceIssueException.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Accounting;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class InvoiceIssueException extends RuntimeException
|
||||
{
|
||||
}
|
||||
236
src/Modules/Accounting/InvoiceRepository.php
Normal file
236
src/Modules/Accounting/InvoiceRepository.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Accounting;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class InvoiceRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function findByOrderId(int $orderId): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT i.*, ic.name AS config_name, ic.is_delegated AS config_is_delegated,
|
||||
ig.name AS integration_name, fis.account_prefix
|
||||
FROM invoices i
|
||||
LEFT JOIN invoice_configs ic ON ic.id = i.config_id
|
||||
LEFT JOIN integrations ig ON ig.id = ic.integration_id AND ig.type = "fakturownia"
|
||||
LEFT JOIN fakturownia_integration_settings fis ON fis.integration_id = ig.id
|
||||
WHERE i.order_id = :order_id
|
||||
ORDER BY i.created_at DESC'
|
||||
);
|
||||
$statement->execute(['order_id' => $orderId]);
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT i.*, ic.name AS config_name, ic.is_delegated AS config_is_delegated,
|
||||
ig.name AS integration_name, fis.account_prefix
|
||||
FROM invoices i
|
||||
LEFT JOIN invoice_configs ic ON ic.id = i.config_id
|
||||
LEFT JOIN integrations ig ON ig.id = ic.integration_id AND ig.type = "fakturownia"
|
||||
LEFT JOIN fakturownia_integration_settings fis ON fis.integration_id = ig.id
|
||||
WHERE i.id = :id LIMIT 1'
|
||||
);
|
||||
$statement->execute(['id' => $id]);
|
||||
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function insertLocal(array $data): int
|
||||
{
|
||||
return $this->insert($data, isDelegated: false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function insertDelegated(array $data): int
|
||||
{
|
||||
return $this->insert($data, isDelegated: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function insert(array $data, bool $isDelegated): int
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO invoices (
|
||||
order_id, config_id, invoice_number, issue_date, sale_date, payment_due_date,
|
||||
seller_data_json, buyer_data_json, items_json,
|
||||
total_net, total_gross, order_reference_value,
|
||||
external_invoice_id, external_pdf_url, kind, created_by
|
||||
) VALUES (
|
||||
:order_id, :config_id, :invoice_number, :issue_date, :sale_date, :payment_due_date,
|
||||
:seller_data_json, :buyer_data_json, :items_json,
|
||||
:total_net, :total_gross, :order_reference_value,
|
||||
:external_invoice_id, :external_pdf_url, :kind, :created_by
|
||||
)'
|
||||
);
|
||||
|
||||
$statement->execute([
|
||||
'order_id' => (int) $data['order_id'],
|
||||
'config_id' => (int) $data['config_id'],
|
||||
'invoice_number' => (string) $data['invoice_number'],
|
||||
'issue_date' => (string) $data['issue_date'],
|
||||
'sale_date' => (string) $data['sale_date'],
|
||||
'payment_due_date' => $data['payment_due_date'] ?? null,
|
||||
'seller_data_json' => (string) $data['seller_data_json'],
|
||||
'buyer_data_json' => $data['buyer_data_json'],
|
||||
'items_json' => (string) $data['items_json'],
|
||||
'total_net' => (string) $data['total_net'],
|
||||
'total_gross' => (string) $data['total_gross'],
|
||||
'order_reference_value' => $data['order_reference_value'] ?? null,
|
||||
'external_invoice_id' => $isDelegated ? ($data['external_invoice_id'] ?? null) : null,
|
||||
'external_pdf_url' => $isDelegated ? ($data['external_pdf_url'] ?? null) : null,
|
||||
'kind' => (string) ($data['kind'] ?? 'vat'),
|
||||
'created_by' => $data['created_by'] ?? null,
|
||||
]);
|
||||
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
public function nextLocalNumber(int $configId, string $numberFormat, string $numberingType): string
|
||||
{
|
||||
$year = (int) date('Y');
|
||||
$month = $numberingType === 'yearly' ? null : (int) date('n');
|
||||
|
||||
if ($month === null) {
|
||||
$this->pdo->prepare(
|
||||
'INSERT INTO invoice_number_counters (config_id, year, month, last_number)
|
||||
VALUES (:config_id, :year, NULL, 1)
|
||||
ON DUPLICATE KEY UPDATE last_number = last_number + 1'
|
||||
)->execute(['config_id' => $configId, 'year' => $year]);
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT last_number FROM invoice_number_counters
|
||||
WHERE config_id = :config_id AND year = :year AND month IS NULL'
|
||||
);
|
||||
$stmt->execute(['config_id' => $configId, 'year' => $year]);
|
||||
} else {
|
||||
$this->pdo->prepare(
|
||||
'INSERT INTO invoice_number_counters (config_id, year, month, last_number)
|
||||
VALUES (:config_id, :year, :month, 1)
|
||||
ON DUPLICATE KEY UPDATE last_number = last_number + 1'
|
||||
)->execute(['config_id' => $configId, 'year' => $year, 'month' => $month]);
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT last_number FROM invoice_number_counters
|
||||
WHERE config_id = :config_id AND year = :year AND month = :month'
|
||||
);
|
||||
$stmt->execute(['config_id' => $configId, 'year' => $year, 'month' => $month]);
|
||||
}
|
||||
|
||||
$lastNumber = (int) $stmt->fetchColumn();
|
||||
|
||||
return str_replace(
|
||||
['%N', '%M', '%Y'],
|
||||
[
|
||||
str_pad((string) $lastNumber, 3, '0', STR_PAD_LEFT),
|
||||
str_pad((string) ($month ?? 1), 2, '0', STR_PAD_LEFT),
|
||||
(string) $year,
|
||||
],
|
||||
$numberFormat
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
|
||||
*/
|
||||
public function paginate(array $filters): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
$configId = (int) ($filters['config_id'] ?? 0);
|
||||
if ($configId > 0) {
|
||||
$where[] = 'i.config_id = :config_id';
|
||||
$params['config_id'] = $configId;
|
||||
}
|
||||
|
||||
$mode = (string) ($filters['mode'] ?? '');
|
||||
if ($mode === 'local') {
|
||||
$where[] = 'i.external_invoice_id IS NULL';
|
||||
} elseif ($mode === 'delegated') {
|
||||
$where[] = 'i.external_invoice_id IS NOT NULL';
|
||||
}
|
||||
|
||||
$dateFrom = trim((string) ($filters['date_from'] ?? ''));
|
||||
if ($dateFrom !== '' && strtotime($dateFrom) !== false) {
|
||||
$where[] = 'i.issue_date >= :date_from';
|
||||
$params['date_from'] = $dateFrom;
|
||||
}
|
||||
|
||||
$dateTo = trim((string) ($filters['date_to'] ?? ''));
|
||||
if ($dateTo !== '' && strtotime($dateTo) !== false) {
|
||||
$where[] = 'i.issue_date <= :date_to';
|
||||
$params['date_to'] = $dateTo . ' 23:59:59';
|
||||
}
|
||||
|
||||
$search = trim((string) ($filters['search'] ?? ''));
|
||||
if ($search !== '') {
|
||||
$where[] = '(i.invoice_number LIKE :search OR o.internal_order_number LIKE :search2 OR o.external_order_id LIKE :search3)';
|
||||
$params['search'] = '%' . $search . '%';
|
||||
$params['search2'] = '%' . $search . '%';
|
||||
$params['search3'] = '%' . $search . '%';
|
||||
}
|
||||
|
||||
$whereClause = $where !== [] ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$countStmt = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM invoices i
|
||||
LEFT JOIN orders o ON o.id = i.order_id
|
||||
{$whereClause}"
|
||||
);
|
||||
$countStmt->execute($params);
|
||||
$total = (int) $countStmt->fetchColumn();
|
||||
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$perPage = max(1, min(100, (int) ($filters['per_page'] ?? 50)));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT i.*, ic.name AS config_name, ic.is_delegated AS config_is_delegated,
|
||||
ig.name AS integration_name, fis.account_prefix,
|
||||
o.internal_order_number, o.external_order_id
|
||||
FROM invoices i
|
||||
LEFT JOIN invoice_configs ic ON ic.id = i.config_id
|
||||
LEFT JOIN integrations ig ON ig.id = ic.integration_id AND ig.type = 'fakturownia'
|
||||
LEFT JOIN fakturownia_integration_settings fis ON fis.integration_id = ig.id
|
||||
LEFT JOIN orders o ON o.id = i.order_id
|
||||
{$whereClause}
|
||||
ORDER BY i.issue_date DESC, i.id DESC
|
||||
LIMIT {$perPage} OFFSET {$offset}"
|
||||
);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return [
|
||||
'items' => is_array($rows) ? $rows : [],
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
];
|
||||
}
|
||||
}
|
||||
611
src/Modules/Accounting/InvoiceService.php
Normal file
611
src/Modules/Accounting/InvoiceService.php
Normal file
@@ -0,0 +1,611 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Accounting;
|
||||
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Modules\Settings\CompanySettingsRepository;
|
||||
use App\Modules\Settings\FakturowniaApiClient;
|
||||
use App\Modules\Settings\FakturowniaIntegrationRepository;
|
||||
use App\Modules\Settings\InvoiceConfigRepository;
|
||||
use Throwable;
|
||||
|
||||
final class InvoiceService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InvoiceRepository $invoices,
|
||||
private readonly InvoiceConfigRepository $invoiceConfigs,
|
||||
private readonly CompanySettingsRepository $companySettings,
|
||||
private readonly OrdersRepository $orders,
|
||||
private readonly FakturowniaIntegrationRepository $fakturownia,
|
||||
private readonly FakturowniaApiClient $fakturowniaApi
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* order_id: int,
|
||||
* config_id: int,
|
||||
* buyer_tax_number?: string,
|
||||
* buyer_name?: string,
|
||||
* buyer_company_name?: string,
|
||||
* buyer_street?: string,
|
||||
* buyer_city?: string,
|
||||
* buyer_postal_code?: string,
|
||||
* buyer_email?: string,
|
||||
* issue_date_override?: string,
|
||||
* created_by?: int|null,
|
||||
* } $params
|
||||
* @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
|
||||
* @throws InvoiceIssueException
|
||||
*/
|
||||
public function issue(array $params): array
|
||||
{
|
||||
$orderId = (int) $params['order_id'];
|
||||
$configId = (int) $params['config_id'];
|
||||
|
||||
$config = $this->invoiceConfigs->findById($configId);
|
||||
if ($config === null || (int) ($config['is_active'] ?? 0) !== 1) {
|
||||
throw new InvoiceIssueException('Nieprawidlowa lub nieaktywna konfiguracja faktury.');
|
||||
}
|
||||
|
||||
$details = $this->orders->findDetails($orderId);
|
||||
if ($details === null) {
|
||||
throw new InvoiceIssueException('Zamowienie nie istnieje.');
|
||||
}
|
||||
|
||||
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
|
||||
$items = is_array($details['items'] ?? null) ? $details['items'] : [];
|
||||
$addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
|
||||
$payments = is_array($details['payments'] ?? null) ? $details['payments'] : [];
|
||||
|
||||
$issueDate = $this->resolveIssueDate((string) ($params['issue_date_override'] ?? ''));
|
||||
$saleDate = $this->resolveSaleDate($config, $order, $payments, $issueDate);
|
||||
$paymentDueDate = $this->resolvePaymentDueDate($issueDate, (int) ($config['payment_to_days'] ?? 7));
|
||||
$orderReference = $this->resolveOrderReference($config, $order);
|
||||
|
||||
$sellerSnapshot = $this->buildSellerSnapshot();
|
||||
$buyerSnapshot = $this->buildBuyerSnapshot($order, $addresses, $params);
|
||||
['items' => $itemsSnapshot, 'total_gross' => $totalGross, 'total_net' => $totalNet] = $this->buildItemsSnapshot($items, $order);
|
||||
|
||||
$kind = trim((string) ($config['default_kind'] ?? 'vat')) ?: 'vat';
|
||||
$isDelegated = (int) ($config['is_delegated'] ?? 0) === 1;
|
||||
|
||||
if ($isDelegated) {
|
||||
return $this->issueDelegated(
|
||||
$orderId,
|
||||
$configId,
|
||||
$config,
|
||||
$issueDate,
|
||||
$saleDate,
|
||||
$paymentDueDate,
|
||||
$sellerSnapshot,
|
||||
$buyerSnapshot,
|
||||
$itemsSnapshot,
|
||||
$totalNet,
|
||||
$totalGross,
|
||||
$orderReference,
|
||||
$kind,
|
||||
$params['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
return $this->issueLocal(
|
||||
$orderId,
|
||||
$configId,
|
||||
$config,
|
||||
$issueDate,
|
||||
$saleDate,
|
||||
$paymentDueDate,
|
||||
$sellerSnapshot,
|
||||
$buyerSnapshot,
|
||||
$itemsSnapshot,
|
||||
$totalNet,
|
||||
$totalGross,
|
||||
$orderReference,
|
||||
$kind,
|
||||
$params['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
* @param array<string, mixed> $sellerSnapshot
|
||||
* @param array<string, mixed>|null $buyerSnapshot
|
||||
* @param list<array<string, mixed>> $itemsSnapshot
|
||||
* @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
|
||||
*/
|
||||
private function issueLocal(
|
||||
int $orderId,
|
||||
int $configId,
|
||||
array $config,
|
||||
string $issueDate,
|
||||
string $saleDate,
|
||||
string $paymentDueDate,
|
||||
array $sellerSnapshot,
|
||||
?array $buyerSnapshot,
|
||||
array $itemsSnapshot,
|
||||
float $totalNet,
|
||||
float $totalGross,
|
||||
string $orderReference,
|
||||
string $kind,
|
||||
?int $createdBy
|
||||
): array {
|
||||
$invoiceNumber = $this->invoices->nextLocalNumber(
|
||||
$configId,
|
||||
(string) ($config['number_format'] ?? 'FV/%N/%M/%Y'),
|
||||
(string) ($config['numbering_type'] ?? 'monthly')
|
||||
);
|
||||
|
||||
$invoiceId = $this->invoices->insertLocal([
|
||||
'order_id' => $orderId,
|
||||
'config_id' => $configId,
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'issue_date' => $issueDate,
|
||||
'sale_date' => $saleDate,
|
||||
'payment_due_date' => $paymentDueDate,
|
||||
'seller_data_json' => json_encode($sellerSnapshot, JSON_UNESCAPED_UNICODE),
|
||||
'buyer_data_json' => $buyerSnapshot !== null ? json_encode($buyerSnapshot, JSON_UNESCAPED_UNICODE) : null,
|
||||
'items_json' => json_encode($itemsSnapshot, JSON_UNESCAPED_UNICODE),
|
||||
'total_net' => number_format($totalNet, 2, '.', ''),
|
||||
'total_gross' => number_format($totalGross, 2, '.', ''),
|
||||
'order_reference_value' => $orderReference !== '' ? $orderReference : null,
|
||||
'kind' => $kind,
|
||||
'created_by' => $createdBy,
|
||||
]);
|
||||
|
||||
return [
|
||||
'invoice_id' => $invoiceId,
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'total_gross' => number_format($totalGross, 2, '.', ''),
|
||||
'mode' => 'local',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
* @param array<string, mixed> $sellerSnapshot
|
||||
* @param array<string, mixed>|null $buyerSnapshot
|
||||
* @param list<array<string, mixed>> $itemsSnapshot
|
||||
* @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int}
|
||||
*/
|
||||
private function issueDelegated(
|
||||
int $orderId,
|
||||
int $configId,
|
||||
array $config,
|
||||
string $issueDate,
|
||||
string $saleDate,
|
||||
string $paymentDueDate,
|
||||
array $sellerSnapshot,
|
||||
?array $buyerSnapshot,
|
||||
array $itemsSnapshot,
|
||||
float $totalNet,
|
||||
float $totalGross,
|
||||
string $orderReference,
|
||||
string $kind,
|
||||
?int $createdBy
|
||||
): array {
|
||||
$integrationId = (int) ($config['integration_id'] ?? 0);
|
||||
if ($integrationId <= 0) {
|
||||
throw new InvoiceIssueException('Konfiguracja delegowana nie wskazuje konta Fakturowni.');
|
||||
}
|
||||
|
||||
$account = $this->fakturownia->findByIntegrationId($integrationId);
|
||||
if ($account === null) {
|
||||
throw new InvoiceIssueException('Konto Fakturownia nie istnieje (id=' . $integrationId . ').');
|
||||
}
|
||||
|
||||
$prefix = trim((string) ($account['account_prefix'] ?? ''));
|
||||
if ($prefix === '') {
|
||||
throw new InvoiceIssueException('Konto Fakturownia nie ma ustawionego prefiksu (subdomeny).');
|
||||
}
|
||||
|
||||
$apiToken = $this->fakturownia->getDecryptedToken($integrationId);
|
||||
if ($apiToken === null || $apiToken === '') {
|
||||
throw new InvoiceIssueException('Brak tokenu API dla konta Fakturownia.');
|
||||
}
|
||||
|
||||
$payload = $this->buildFakturowniaPayload(
|
||||
$kind,
|
||||
$issueDate,
|
||||
$saleDate,
|
||||
$paymentDueDate,
|
||||
$sellerSnapshot,
|
||||
$buyerSnapshot,
|
||||
$itemsSnapshot,
|
||||
$orderReference,
|
||||
(int) ($config['payment_to_days'] ?? 7),
|
||||
(string) ($account['department_id'] ?? '')
|
||||
);
|
||||
|
||||
try {
|
||||
$response = $this->fakturowniaApi->createInvoice([
|
||||
'account_prefix' => $prefix,
|
||||
'api_token' => $apiToken,
|
||||
], $payload);
|
||||
} catch (Throwable $e) {
|
||||
throw new InvoiceIssueException('Fakturownia: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$externalId = trim((string) ($response['id'] ?? ''));
|
||||
$externalNumber = trim((string) ($response['number'] ?? ''));
|
||||
$externalPdfUrl = trim((string) ($response['pdf_url'] ?? $response['view_url'] ?? ''));
|
||||
|
||||
if ($externalId === '' || $externalNumber === '') {
|
||||
throw new InvoiceIssueException('Fakturownia zwrocila niekompletna odpowiedz (brak id/number).');
|
||||
}
|
||||
|
||||
$invoiceId = $this->invoices->insertDelegated([
|
||||
'order_id' => $orderId,
|
||||
'config_id' => $configId,
|
||||
'invoice_number' => $externalNumber,
|
||||
'issue_date' => $issueDate,
|
||||
'sale_date' => $saleDate,
|
||||
'payment_due_date' => $paymentDueDate,
|
||||
'seller_data_json' => json_encode($sellerSnapshot, JSON_UNESCAPED_UNICODE),
|
||||
'buyer_data_json' => $buyerSnapshot !== null ? json_encode($buyerSnapshot, JSON_UNESCAPED_UNICODE) : null,
|
||||
'items_json' => json_encode($itemsSnapshot, JSON_UNESCAPED_UNICODE),
|
||||
'total_net' => number_format($totalNet, 2, '.', ''),
|
||||
'total_gross' => number_format($totalGross, 2, '.', ''),
|
||||
'order_reference_value' => $orderReference !== '' ? $orderReference : null,
|
||||
'external_invoice_id' => $externalId,
|
||||
'external_pdf_url' => $externalPdfUrl !== '' ? $externalPdfUrl : null,
|
||||
'kind' => $kind,
|
||||
'created_by' => $createdBy,
|
||||
]);
|
||||
|
||||
return [
|
||||
'invoice_id' => $invoiceId,
|
||||
'invoice_number' => $externalNumber,
|
||||
'total_gross' => number_format($totalGross, 2, '.', ''),
|
||||
'mode' => 'delegated',
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveIssueDate(string $override): string
|
||||
{
|
||||
$override = trim($override);
|
||||
if ($override !== '' && strtotime($override) !== false) {
|
||||
return date('Y-m-d H:i:s', (int) strtotime($override));
|
||||
}
|
||||
return date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
* @param array<string, mixed> $order
|
||||
* @param list<array<string, mixed>> $payments
|
||||
*/
|
||||
private function resolveSaleDate(array $config, array $order, array $payments, string $issueDate): string
|
||||
{
|
||||
$source = (string) ($config['sale_date_source'] ?? 'issue_date');
|
||||
|
||||
if ($source === 'order_date') {
|
||||
$ordered = trim((string) ($order['external_created_at'] ?? $order['ordered_at'] ?? ''));
|
||||
if ($ordered !== '') {
|
||||
$ts = strtotime($ordered);
|
||||
return $ts !== false ? date('Y-m-d H:i:s', $ts) : $issueDate;
|
||||
}
|
||||
}
|
||||
|
||||
if ($source === 'payment_date' && $payments !== []) {
|
||||
$payment = $payments[0] ?? [];
|
||||
$payDate = trim((string) ($payment['payment_date'] ?? ''));
|
||||
if ($payDate !== '') {
|
||||
$ts = strtotime($payDate);
|
||||
return $ts !== false ? date('Y-m-d H:i:s', $ts) : $issueDate;
|
||||
}
|
||||
}
|
||||
|
||||
return $issueDate;
|
||||
}
|
||||
|
||||
private function resolvePaymentDueDate(string $issueDate, int $paymentToDays): string
|
||||
{
|
||||
$ts = strtotime($issueDate);
|
||||
if ($ts === false) {
|
||||
$ts = time();
|
||||
}
|
||||
return date('Y-m-d 00:00:00', $ts + max(0, $paymentToDays) * 86400);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
* @param array<string, mixed> $order
|
||||
*/
|
||||
private function resolveOrderReference(array $config, array $order): string
|
||||
{
|
||||
$ref = (string) ($config['order_reference'] ?? 'none');
|
||||
|
||||
if ($ref === 'orderpro') {
|
||||
return (string) ($order['internal_order_number'] ?? '');
|
||||
}
|
||||
if ($ref === 'integration') {
|
||||
return (string) ($order['external_order_number'] ?? $order['external_order_id'] ?? '');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildSellerSnapshot(): array
|
||||
{
|
||||
$seller = $this->companySettings->getSettings();
|
||||
|
||||
return [
|
||||
'company_name' => $seller['company_name'] ?? '',
|
||||
'tax_number' => $seller['tax_number'] ?? '',
|
||||
'street' => $seller['street'] ?? '',
|
||||
'city' => $seller['city'] ?? '',
|
||||
'postal_code' => $seller['postal_code'] ?? '',
|
||||
'phone' => $seller['phone'] ?? '',
|
||||
'email' => $seller['email'] ?? '',
|
||||
'bank_account' => $seller['bank_account'] ?? '',
|
||||
'bdo_number' => $seller['bdo_number'] ?? '',
|
||||
'regon' => $seller['regon'] ?? '',
|
||||
'court_register' => $seller['court_register'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @param list<array<string, mixed>> $addresses
|
||||
* @param array<string, mixed> $params
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function buildBuyerSnapshot(array $order, array $addresses, array $params): ?array
|
||||
{
|
||||
$byType = [];
|
||||
foreach ($addresses as $addr) {
|
||||
$type = (string) ($addr['address_type'] ?? '');
|
||||
if ($type !== '' && !isset($byType[$type])) {
|
||||
$byType[$type] = $addr;
|
||||
}
|
||||
}
|
||||
$buyerAddress = $byType['invoice'] ?? $byType['customer'] ?? null;
|
||||
|
||||
$autoTaxNumber = self::extractBuyerTaxNumber($order, $buyerAddress);
|
||||
|
||||
$manualTax = trim((string) ($params['buyer_tax_number'] ?? ''));
|
||||
$manualName = trim((string) ($params['buyer_name'] ?? ''));
|
||||
$manualCompany = trim((string) ($params['buyer_company_name'] ?? ''));
|
||||
$manualStreet = trim((string) ($params['buyer_street'] ?? ''));
|
||||
$manualCity = trim((string) ($params['buyer_city'] ?? ''));
|
||||
$manualPostal = trim((string) ($params['buyer_postal_code'] ?? ''));
|
||||
$manualEmail = trim((string) ($params['buyer_email'] ?? ''));
|
||||
|
||||
$name = $manualName !== ''
|
||||
? $manualName
|
||||
: trim((string) ($buyerAddress['name'] ?? ''));
|
||||
$company = $manualCompany !== ''
|
||||
? $manualCompany
|
||||
: trim((string) ($buyerAddress['company_name'] ?? ''));
|
||||
$street = $manualStreet !== ''
|
||||
? $manualStreet
|
||||
: trim(((string) ($buyerAddress['street_name'] ?? '')) . ' ' . ((string) ($buyerAddress['street_number'] ?? '')));
|
||||
$city = $manualCity !== ''
|
||||
? $manualCity
|
||||
: trim((string) ($buyerAddress['city'] ?? ''));
|
||||
$postal = $manualPostal !== ''
|
||||
? $manualPostal
|
||||
: trim((string) ($buyerAddress['zip_code'] ?? ''));
|
||||
$email = $manualEmail !== ''
|
||||
? $manualEmail
|
||||
: trim((string) ($buyerAddress['email'] ?? $order['buyer_email'] ?? ''));
|
||||
$taxNumber = $manualTax !== '' ? $manualTax : $autoTaxNumber;
|
||||
|
||||
if ($name === '' && $company === '' && $taxNumber === '' && $street === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'company_name' => $company,
|
||||
'tax_number' => $taxNumber,
|
||||
'street' => $street,
|
||||
'city' => $city,
|
||||
'postal_code' => $postal,
|
||||
'phone' => trim((string) ($buyerAddress['phone'] ?? '')),
|
||||
'email' => $email,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract NIP from various payload locations (Allegro, shopPRO).
|
||||
*
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<string, mixed>|null $buyerAddress
|
||||
*/
|
||||
public static function extractBuyerTaxNumber(array $order, ?array $buyerAddress): string
|
||||
{
|
||||
if ($buyerAddress !== null) {
|
||||
$candidate = trim((string) ($buyerAddress['company_tax_number'] ?? ''));
|
||||
if ($candidate !== '') {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
$payload = $order['payload_json'] ?? null;
|
||||
if (is_string($payload) && $payload !== '') {
|
||||
$decoded = json_decode($payload, true);
|
||||
if (is_array($decoded)) {
|
||||
foreach (self::taxNumberPaths() as $path) {
|
||||
$value = self::digValue($decoded, $path);
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<list<string>>
|
||||
*/
|
||||
private static function taxNumberPaths(): array
|
||||
{
|
||||
return [
|
||||
['invoice', 'address', 'taxId'],
|
||||
['invoice', 'taxId'],
|
||||
['invoice', 'nip'],
|
||||
['buyer', 'tax_number'],
|
||||
['buyer', 'nip'],
|
||||
['client', 'nip'],
|
||||
['client', 'tax_number'],
|
||||
['nip'],
|
||||
['tax_number'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arr
|
||||
* @param list<string> $path
|
||||
*/
|
||||
private static function digValue(array $arr, array $path): mixed
|
||||
{
|
||||
$cur = $arr;
|
||||
foreach ($path as $key) {
|
||||
if (!is_array($cur) || !array_key_exists($key, $cur)) {
|
||||
return null;
|
||||
}
|
||||
$cur = $cur[$key];
|
||||
}
|
||||
return $cur;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
* @param array<string, mixed> $order
|
||||
* @return array{items: list<array<string, mixed>>, total_gross: float, total_net: float}
|
||||
*/
|
||||
private function buildItemsSnapshot(array $items, array $order): array
|
||||
{
|
||||
$itemsSnapshot = [];
|
||||
$totalGross = 0.0;
|
||||
$totalNet = 0.0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
$qty = (float) ($item['quantity'] ?? 0);
|
||||
$price = $item['original_price_with_tax'] !== null ? (float) $item['original_price_with_tax'] : (float) ($item['price_gross'] ?? 0);
|
||||
$vat = (float) ($item['vat'] ?? 23);
|
||||
$lineGross = $qty * $price;
|
||||
$lineNet = $vat > 0 ? round($lineGross / (1 + $vat / 100), 2) : $lineGross;
|
||||
$totalGross += $lineGross;
|
||||
$totalNet += $lineNet;
|
||||
|
||||
$itemsSnapshot[] = [
|
||||
'name' => $item['original_name'] ?? $item['name'] ?? '',
|
||||
'quantity' => $qty,
|
||||
'price_gross' => $price,
|
||||
'price_net' => $vat > 0 ? round($price / (1 + $vat / 100), 2) : $price,
|
||||
'vat' => $vat,
|
||||
'total_gross' => round($lineGross, 2),
|
||||
'total_net' => round($lineNet, 2),
|
||||
'sku' => $item['sku'] ?? '',
|
||||
'ean' => $item['ean'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$deliveryPrice = (float) ($order['delivery_price'] ?? 0);
|
||||
if ($deliveryPrice > 0) {
|
||||
$deliveryVat = 23.0;
|
||||
$deliveryNet = round($deliveryPrice / (1 + $deliveryVat / 100), 2);
|
||||
$totalGross += $deliveryPrice;
|
||||
$totalNet += $deliveryNet;
|
||||
$itemsSnapshot[] = [
|
||||
'name' => 'Koszt wysylki',
|
||||
'quantity' => 1.0,
|
||||
'price_gross' => $deliveryPrice,
|
||||
'price_net' => $deliveryNet,
|
||||
'vat' => $deliveryVat,
|
||||
'total_gross' => round($deliveryPrice, 2),
|
||||
'total_net' => round($deliveryNet, 2),
|
||||
'sku' => '',
|
||||
'ean' => '',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $itemsSnapshot,
|
||||
'total_gross' => round($totalGross, 2),
|
||||
'total_net' => round($totalNet, 2),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Fakturownia API payload (https://app.fakturownia.pl/api).
|
||||
*
|
||||
* @param array<string, mixed> $sellerSnapshot
|
||||
* @param array<string, mixed>|null $buyerSnapshot
|
||||
* @param list<array<string, mixed>> $itemsSnapshot
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildFakturowniaPayload(
|
||||
string $kind,
|
||||
string $issueDate,
|
||||
string $saleDate,
|
||||
string $paymentDueDate,
|
||||
array $sellerSnapshot,
|
||||
?array $buyerSnapshot,
|
||||
array $itemsSnapshot,
|
||||
string $orderReference,
|
||||
int $paymentToDays,
|
||||
string $departmentId
|
||||
): array {
|
||||
$issueDay = substr($issueDate, 0, 10);
|
||||
$saleDay = substr($saleDate, 0, 10);
|
||||
$dueDay = substr($paymentDueDate, 0, 10);
|
||||
|
||||
// UWAGA: seller_* pola CELOWO pominiete. Konta Fakturowni z podwyzszonym
|
||||
// poziomem zabezpieczen interpretuja roznice w seller_name/tax_no/bank
|
||||
// jako proba "utworzenia nowego dzialu" i odrzucaja request HTTP 422
|
||||
// ("Poziom zabezpieczenia przed zmiana konta bankowego nie pozwala na
|
||||
// utworzenie dzialu"). Fakturownia uzywa wtedy danych sprzedawcy
|
||||
// zarejestrowanych na koncie (uzytkownik IS sprzedawca w Fakturowni).
|
||||
// Lokalny snapshot `seller_data_json` w tabeli `invoices` zachowuje
|
||||
// dane orderPRO dla audytu — niezalezne od tego co poszlo do Fakturowni.
|
||||
$invoice = [
|
||||
'kind' => $kind !== '' ? $kind : 'vat',
|
||||
'issue_date' => $issueDay,
|
||||
'sell_date' => $saleDay,
|
||||
'payment_to' => $dueDay,
|
||||
'positions' => array_map(static function (array $item): array {
|
||||
return [
|
||||
'name' => (string) ($item['name'] ?? ''),
|
||||
'tax' => (float) ($item['vat'] ?? 23),
|
||||
'total_price_gross' => number_format((float) ($item['total_gross'] ?? 0), 2, '.', ''),
|
||||
'quantity' => (float) ($item['quantity'] ?? 1),
|
||||
];
|
||||
}, $itemsSnapshot),
|
||||
];
|
||||
unset($paymentToDays, $sellerSnapshot);
|
||||
|
||||
if ($buyerSnapshot !== null) {
|
||||
$buyerName = trim((string) ($buyerSnapshot['company_name'] ?? ''));
|
||||
if ($buyerName === '') {
|
||||
$buyerName = trim((string) ($buyerSnapshot['name'] ?? ''));
|
||||
}
|
||||
$invoice['buyer_name'] = $buyerName;
|
||||
$invoice['buyer_tax_no'] = (string) ($buyerSnapshot['tax_number'] ?? '');
|
||||
$invoice['buyer_street'] = (string) ($buyerSnapshot['street'] ?? '');
|
||||
$invoice['buyer_post_code'] = (string) ($buyerSnapshot['postal_code'] ?? '');
|
||||
$invoice['buyer_city'] = (string) ($buyerSnapshot['city'] ?? '');
|
||||
$invoice['buyer_email'] = (string) ($buyerSnapshot['email'] ?? '');
|
||||
}
|
||||
|
||||
if ($orderReference !== '') {
|
||||
$invoice['additional_info_desc'] = 'Zamowienie: ' . $orderReference;
|
||||
}
|
||||
|
||||
// department_id celowo pominiete — konta Fakturowni z podwyzszonym
|
||||
// poziomem zabezpieczen odrzucaja ten parametr przez API (HTTP 422
|
||||
// "Poziom zabezpieczenia ... nie pozwala na utworzenie dzialu").
|
||||
// Fakturownia uzywa wtedy domyslnego dzialu konta.
|
||||
unset($departmentId);
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ use App\Core\Security\Csrf;
|
||||
use App\Core\View\Template;
|
||||
use App\Core\Support\Flash;
|
||||
use App\Core\Support\StringHelper;
|
||||
use App\Modules\Accounting\InvoiceRepository;
|
||||
use App\Modules\Accounting\ReceiptRepository;
|
||||
use App\Modules\Settings\InvoiceConfigRepository;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use App\Modules\Email\EmailSendingService;
|
||||
use App\Modules\Settings\EmailMailboxRepository;
|
||||
@@ -37,7 +39,9 @@ final class OrdersController
|
||||
private readonly string $storagePath = '',
|
||||
private readonly ?\App\Modules\Printing\PrintJobRepository $printJobRepo = null,
|
||||
private readonly ?ShopproIntegrationsRepository $shopproIntegrations = null,
|
||||
private readonly ?AutomationService $automation = null
|
||||
private readonly ?AutomationService $automation = null,
|
||||
private readonly ?InvoiceRepository $invoiceRepo = null,
|
||||
private readonly ?InvoiceConfigRepository $invoiceConfigRepo = null
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -228,6 +232,17 @@ final class OrdersController
|
||||
$emailTemplates = $this->emailTemplateRepo !== null ? $this->emailTemplateRepo->listActive() : [];
|
||||
$emailMailboxes = $this->emailMailboxRepo !== null ? $this->emailMailboxRepo->listActive() : [];
|
||||
|
||||
$invoices = $this->invoiceRepo !== null
|
||||
? $this->invoiceRepo->findByOrderId($orderId)
|
||||
: [];
|
||||
$activeInvoiceConfigs = [];
|
||||
if ($this->invoiceConfigRepo !== null) {
|
||||
$activeInvoiceConfigs = array_values(array_filter(
|
||||
$this->invoiceConfigRepo->listAll(),
|
||||
static fn (array $c): bool => (int) ($c['is_active'] ?? 0) === 1
|
||||
));
|
||||
}
|
||||
|
||||
$flashSuccess = (string) Flash::get('order.success', '');
|
||||
$flashError = (string) Flash::get('order.error', '');
|
||||
|
||||
@@ -259,6 +274,8 @@ final class OrdersController
|
||||
'flashError' => $flashError,
|
||||
'receipts' => $receipts,
|
||||
'receiptConfigs' => $activeReceiptConfigs,
|
||||
'invoices' => $invoices,
|
||||
'invoiceConfigs' => $activeInvoiceConfigs,
|
||||
'emailTemplates' => $emailTemplates,
|
||||
'emailMailboxes' => $emailMailboxes,
|
||||
'customerRiskInfo' => $customerRiskInfo,
|
||||
@@ -460,6 +477,34 @@ final class OrdersController
|
||||
return Response::redirect('/orders/' . $orderId);
|
||||
}
|
||||
|
||||
public function toggleInvoiceRequested(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
if ($orderId <= 0) {
|
||||
return Response::json(['success' => false, 'error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
||||
return Response::json(['success' => false, 'error' => $this->translator->get('auth.errors.csrf_expired')], 403);
|
||||
}
|
||||
|
||||
$value = (int) $request->input('invoice_requested', 0) === 1;
|
||||
$this->orders->setInvoiceRequested($orderId, $value);
|
||||
|
||||
$user = $this->auth->user();
|
||||
$actorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : null;
|
||||
$this->orders->recordActivity(
|
||||
$orderId,
|
||||
'invoice_requested_changed',
|
||||
'Klient prosi o fakture: ' . ($value ? 'tak' : 'nie'),
|
||||
['invoice_requested' => $value ? 1 : 0],
|
||||
'user',
|
||||
$actorName !== '' ? $actorName : null
|
||||
);
|
||||
|
||||
return Response::json(['success' => true, 'invoice_requested' => $value ? 1 : 0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
|
||||
@@ -911,6 +911,14 @@ final class OrdersRepository
|
||||
return true;
|
||||
}
|
||||
|
||||
public function setInvoiceRequested(int $orderId, bool $value): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE orders SET invoice_requested = :v, updated_at = NOW() WHERE id = :id'
|
||||
);
|
||||
$stmt->execute(['v' => $value ? 1 : 0, 'id' => $orderId]);
|
||||
}
|
||||
|
||||
public function recordActivity(
|
||||
int $orderId,
|
||||
string $eventType,
|
||||
|
||||
@@ -96,6 +96,13 @@ final class AllegroOrderImportService
|
||||
);
|
||||
}
|
||||
|
||||
if ($wasCreated) {
|
||||
$invoiceFlag = is_array($payload['invoice'] ?? null) ? $payload['invoice'] : [];
|
||||
if (!empty($invoiceFlag['required'])) {
|
||||
$this->ordersRepository->setInvoiceRequested($savedOrderId, true);
|
||||
}
|
||||
}
|
||||
|
||||
if ($wasCreated && $this->automationService !== null) {
|
||||
$this->automationService->trigger('order.imported', $savedOrderId, [
|
||||
'source' => IntegrationSources::ALLEGRO,
|
||||
|
||||
@@ -60,27 +60,78 @@ final class FakturowniaApiClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation in a follow-up plan (Phase 113-02+).
|
||||
* POST /invoices.json — creates invoice in Fakturownia and returns parsed response.
|
||||
*
|
||||
* @param array<string, mixed> $settings
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
* @param array{account_prefix: string, api_token: string} $settings
|
||||
* @param array<string, mixed> $invoiceFields
|
||||
* @return array{id: string, number: string, view_url: string, pdf_url: string, raw: array<string, mixed>}
|
||||
*/
|
||||
public function createInvoice(array $settings, array $payload): array
|
||||
public function createInvoice(array $settings, array $invoiceFields): array
|
||||
{
|
||||
unset($settings, $payload);
|
||||
throw new RuntimeException('FakturowniaApiClient::createInvoice not implemented in Phase 113-01.');
|
||||
$prefix = strtolower(trim((string) ($settings['account_prefix'] ?? '')));
|
||||
$apiToken = trim((string) ($settings['api_token'] ?? ''));
|
||||
|
||||
if ($prefix === '' || $apiToken === '') {
|
||||
throw new RuntimeException('Brak prefiksu konta lub tokenu API.');
|
||||
}
|
||||
|
||||
$url = $this->buildUrl($prefix, '/invoices.json');
|
||||
$body = json_encode([
|
||||
'api_token' => $apiToken,
|
||||
'invoice' => $invoiceFields,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if ($body === false) {
|
||||
throw new RuntimeException('Nie udalo sie zakodowac payloadu (json_encode).');
|
||||
}
|
||||
|
||||
[$rawBody, $httpCode, $curlError] = $this->httpPostJson($url, $body);
|
||||
|
||||
if ($curlError !== null) {
|
||||
throw new RuntimeException('Blad polaczenia: ' . $curlError);
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$msg = $this->resolveErrorMessage($rawBody);
|
||||
if ($msg === '' || strtolower($msg) === 'error') {
|
||||
$msg = 'raw body: ' . substr($rawBody, 0, 500);
|
||||
}
|
||||
error_log('[Fakturownia] createInvoice HTTP ' . $httpCode . ' | body=' . substr($rawBody, 0, 1000));
|
||||
throw new RuntimeException('HTTP ' . $httpCode . ': ' . $msg);
|
||||
}
|
||||
|
||||
$decoded = json_decode($rawBody, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new RuntimeException('Niepoprawna odpowiedz JSON od Fakturowni.');
|
||||
}
|
||||
|
||||
$id = $decoded['id'] ?? null;
|
||||
$number = $decoded['number'] ?? $decoded['full_number'] ?? null;
|
||||
|
||||
if ($id === null || $number === null) {
|
||||
throw new RuntimeException('Odpowiedz Fakturowni nie zawiera id/number.');
|
||||
}
|
||||
|
||||
$viewUrl = (string) ($decoded['view_url'] ?? '');
|
||||
$pdfUrl = $this->buildPdfUrl($prefix, (string) $id, $apiToken);
|
||||
|
||||
return [
|
||||
'id' => (string) $id,
|
||||
'number' => (string) $number,
|
||||
'view_url' => $viewUrl,
|
||||
'pdf_url' => $pdfUrl,
|
||||
'raw' => $decoded,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation in a follow-up plan (Phase 113-02+).
|
||||
*
|
||||
* @param array<string, mixed> $settings
|
||||
* Build URL for direct PDF download/redirect (no fetch — used in 302 redirect).
|
||||
*/
|
||||
public function downloadPdf(array $settings, string $invoiceId): string
|
||||
public function buildPdfUrl(string $accountPrefix, string $invoiceId, string $apiToken): string
|
||||
{
|
||||
unset($settings, $invoiceId);
|
||||
throw new RuntimeException('FakturowniaApiClient::downloadPdf not implemented in Phase 113-01.');
|
||||
$prefix = strtolower(trim($accountPrefix));
|
||||
return $this->buildUrl($prefix, '/invoices/' . rawurlencode($invoiceId) . '.pdf')
|
||||
. '?api_token=' . rawurlencode($apiToken);
|
||||
}
|
||||
|
||||
private function buildUrl(string $prefix, string $path): string
|
||||
@@ -120,7 +171,48 @@ final class FakturowniaApiClient
|
||||
$rawBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($rawBody === false) {
|
||||
return ['', $httpCode, $curlError !== '' ? $curlError : 'Brak odpowiedzi z API.'];
|
||||
}
|
||||
|
||||
return [(string) $rawBody, $httpCode, null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: int, 2: ?string}
|
||||
*/
|
||||
private function httpPostJson(string $url, string $body): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return ['', 0, 'Nie udalo sie zainicjowac cURL.'];
|
||||
}
|
||||
|
||||
$opts = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_TIMEOUT => $this->timeoutSeconds,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
'User-Agent: orderPRO/1.0',
|
||||
],
|
||||
];
|
||||
|
||||
$caPath = SslCertificateResolver::resolve();
|
||||
if ($caPath !== null) {
|
||||
$opts[CURLOPT_CAINFO] = $caPath;
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, $opts);
|
||||
$rawBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
|
||||
if ($rawBody === false) {
|
||||
return ['', $httpCode, $curlError !== '' ? $curlError : 'Brak odpowiedzi z API.'];
|
||||
@@ -138,21 +230,61 @@ final class FakturowniaApiClient
|
||||
|
||||
$decoded = json_decode($trimmed, true);
|
||||
if (is_array($decoded)) {
|
||||
$candidates = ['message', 'error', 'code'];
|
||||
foreach ($candidates as $key) {
|
||||
if (isset($decoded[$key]) && is_string($decoded[$key]) && trim($decoded[$key]) !== '') {
|
||||
$fieldErrors = $this->flattenFieldErrors($decoded['errors'] ?? null);
|
||||
if ($fieldErrors !== '') {
|
||||
$code = isset($decoded['code']) && is_string($decoded['code']) ? trim($decoded['code']) : '';
|
||||
return $code !== '' && strtolower($code) !== 'error'
|
||||
? $code . ' — ' . $fieldErrors
|
||||
: $fieldErrors;
|
||||
}
|
||||
|
||||
foreach (['message', 'error'] as $key) {
|
||||
if (isset($decoded[$key]) && is_string($decoded[$key]) && trim($decoded[$key]) !== '' && trim($decoded[$key]) !== 'error') {
|
||||
return trim($decoded[$key]);
|
||||
}
|
||||
}
|
||||
if (isset($decoded['errors']) && is_array($decoded['errors'])) {
|
||||
$first = reset($decoded['errors']);
|
||||
if (is_string($first) && trim($first) !== '') {
|
||||
return trim($first);
|
||||
}
|
||||
|
||||
if (isset($decoded['code']) && is_string($decoded['code']) && trim($decoded['code']) !== '') {
|
||||
return trim($decoded['code']) . ' (body: ' . substr($trimmed, 0, 300) . ')';
|
||||
}
|
||||
}
|
||||
|
||||
$snippet = trim(strip_tags($trimmed));
|
||||
return substr($snippet, 0, 200);
|
||||
return substr($snippet, 0, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten Fakturownia per-field errors structure like:
|
||||
* {"buyer_name": ["can't be blank"], "positions": ["are invalid"]}
|
||||
* Into: "buyer_name: can't be blank; positions: are invalid"
|
||||
*/
|
||||
private function flattenFieldErrors(mixed $errors): string
|
||||
{
|
||||
if (!is_array($errors) || $errors === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
foreach ($errors as $field => $value) {
|
||||
$msgs = [];
|
||||
if (is_string($value)) {
|
||||
$msgs[] = trim($value);
|
||||
} elseif (is_array($value)) {
|
||||
foreach ($value as $v) {
|
||||
if (is_string($v)) {
|
||||
$msgs[] = trim($v);
|
||||
} elseif (is_array($v)) {
|
||||
$msgs[] = json_encode($v, JSON_UNESCAPED_UNICODE) ?: '';
|
||||
}
|
||||
}
|
||||
}
|
||||
$msgs = array_filter($msgs, static fn (string $m): bool => $m !== '');
|
||||
if ($msgs === []) {
|
||||
continue;
|
||||
}
|
||||
$parts[] = (is_string($field) ? $field . ': ' : '') . implode(', ', $msgs);
|
||||
}
|
||||
|
||||
return implode('; ', $parts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +270,12 @@ final class ShopproOrdersSyncService
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -301,6 +307,36 @@ final class ShopproOrdersSyncService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
|
||||
Reference in New Issue
Block a user