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>
293 lines
12 KiB
PHP
293 lines
12 KiB
PHP
<?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'] ?? '')),
|
|
];
|
|
}
|
|
}
|