Files
orderPRO/src/Modules/Accounting/InvoiceRepository.php
Jacek Pyziak 33ee1a1cf5 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>
2026-05-10 23:34:50 +02:00

237 lines
8.9 KiB
PHP

<?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,
];
}
}