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>
237 lines
8.9 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|