feat(114): accounting configs refactor + invoice configs CRUD

Phase 114 complete (v3.7 Invoices):
- /settings/accounting jako hub-rozdroze (Paragony / Faktury)
- /settings/accounting/receipts + /invoices osobne podstrony list i edycji
- InvoiceConfigRepository + Controller (CRUD z walidacja delegacji)
- Seed Domyslny VAT (NOT EXISTS idempotent)
- invoice-config-form.js (toggle is_delegated -> integration_id)
- confirm-delete.js (globalny modul OrderProAlerts.confirm)
- Legacy aliasy starych endpointow /settings/accounting/save|toggle|delete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 22:32:29 +02:00
parent 2382018739
commit 6129042ff6
22 changed files with 1663 additions and 192 deletions

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
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 Throwable;
final class InvoiceConfigController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly InvoiceConfigRepository $repository,
private readonly FakturowniaIntegrationRepository $fakturownia
) {
}
public function index(Request $request): Response
{
$configs = $this->repository->listAll();
$accounts = $this->fakturownia->findAll();
$html = $this->template->render('settings/accounting-invoices', [
'title' => 'Konfiguracje faktur',
'activeMenu' => 'settings',
'activeSettings' => 'accounting',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'configs' => $configs,
'fakturowniaAccounts' => $accounts,
'successMessage' => (string) Flash::get('accounting.invoices.save', ''),
'errorMessage' => (string) Flash::get('accounting.invoices.error', ''),
], 'layouts/app');
return Response::html($html);
}
public function edit(Request $request): Response
{
$id = (int) $request->input('id', 0);
$config = $id > 0 ? $this->repository->findById($id) : null;
if ($id > 0 && $config === null) {
Flash::set('accounting.invoices.error', 'Nie znaleziono konfiguracji faktury ID ' . $id . '.');
return Response::redirect('/settings/accounting/invoices');
}
$accounts = array_values(array_filter(
$this->fakturownia->findAll(),
static fn (array $row) => !empty($row['is_active'])
));
$html = $this->template->render('settings/accounting-invoice-edit', [
'title' => $config === null ? 'Nowa konfiguracja faktury' : 'Edycja konfiguracji faktury',
'activeMenu' => 'settings',
'activeSettings' => 'accounting',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'config' => $config,
'fakturowniaAccounts' => $accounts,
'successMessage' => (string) Flash::get('accounting.invoices.save', ''),
'errorMessage' => (string) Flash::get('accounting.invoices.error', ''),
], 'layouts/app');
return Response::html($html);
}
public function save(Request $request): Response
{
$id = (int) $request->input('id', 0);
$redirectFail = $id > 0
? '/settings/accounting/invoices/edit?id=' . $id
: '/settings/accounting/invoices/new';
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('accounting.invoices.error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectFail);
}
try {
$this->repository->save([
'id' => $id > 0 ? $id : '',
'name' => (string) $request->input('name', ''),
'number_format' => (string) $request->input('number_format', ''),
'numbering_type' => (string) $request->input('numbering_type', 'monthly'),
'sale_date_source' => (string) $request->input('sale_date_source', 'issue_date'),
'order_reference' => (string) $request->input('order_reference', 'none'),
'payment_to_days' => (int) $request->input('payment_to_days', 7),
'default_kind' => (string) $request->input('default_kind', 'vat'),
'is_delegated' => $request->input('is_delegated', ''),
'integration_id' => $request->input('integration_id', ''),
'is_active' => $request->input('is_active', ''),
]);
Flash::set('accounting.invoices.save', 'Zapisano konfiguracje faktury.');
return Response::redirect('/settings/accounting/invoices');
} catch (Throwable $exception) {
Flash::set('accounting.invoices.error', $exception->getMessage());
return Response::redirect($redirectFail);
}
}
public function toggleStatus(Request $request): Response
{
$redirect = '/settings/accounting/invoices';
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('accounting.invoices.error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirect);
}
$id = (int) $request->input('id', 0);
if ($id <= 0) {
Flash::set('accounting.invoices.error', 'Brak identyfikatora konfiguracji.');
return Response::redirect($redirect);
}
try {
$this->repository->toggleStatus($id);
Flash::set('accounting.invoices.save', 'Zmieniono status konfiguracji.');
} catch (Throwable $exception) {
Flash::set('accounting.invoices.error', $exception->getMessage());
}
return Response::redirect($redirect);
}
public function delete(Request $request): Response
{
$redirect = '/settings/accounting/invoices';
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('accounting.invoices.error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirect);
}
$id = (int) $request->input('id', 0);
if ($id <= 0) {
Flash::set('accounting.invoices.error', 'Brak identyfikatora konfiguracji.');
return Response::redirect($redirect);
}
try {
$this->repository->delete($id);
Flash::set('accounting.invoices.save', 'Usunieto konfiguracje faktury.');
} catch (Throwable $exception) {
Flash::set('accounting.invoices.error', $exception->getMessage());
}
return Response::redirect($redirect);
}
}

View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\Http\ToggleableRepositoryTrait;
use PDO;
use RuntimeException;
use Throwable;
final class InvoiceConfigRepository
{
use ToggleableRepositoryTrait;
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return list<array<string, mixed>>
*/
public function listAll(): array
{
$statement = $this->pdo->prepare(
'SELECT ic.*, i.name AS integration_name
FROM invoice_configs ic
LEFT JOIN integrations i ON i.id = ic.integration_id AND i.type = :type
ORDER BY ic.is_active DESC, ic.name ASC'
);
$statement->execute(['type' => 'fakturownia']);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return array<string, mixed>|null
*/
public function findById(int $id): ?array
{
if ($id <= 0) {
return null;
}
$statement = $this->pdo->prepare(
'SELECT ic.*, i.name AS integration_name
FROM invoice_configs ic
LEFT JOIN integrations i ON i.id = ic.integration_id AND i.type = :type
WHERE ic.id = :id
LIMIT 1'
);
$statement->execute(['id' => $id, 'type' => 'fakturownia']);
$row = $statement->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
/**
* @param array<string, mixed> $data
* @return int Saved config id
*/
public function save(array $data): int
{
$name = trim((string) ($data['name'] ?? ''));
if ($name === '') {
throw new IntegrationConfigException('Nazwa konfiguracji faktury jest wymagana.');
}
if (mb_strlen($name) > 128) {
throw new IntegrationConfigException('Nazwa konfiguracji jest za dluga (max 128 znakow).');
}
$numberFormat = trim((string) ($data['number_format'] ?? ''));
if ($numberFormat === '') {
throw new IntegrationConfigException('Format numeracji jest wymagany.');
}
if (mb_strlen($numberFormat) > 64) {
throw new IntegrationConfigException('Format numeracji jest za dlugi (max 64 znakow).');
}
$numberingType = (string) ($data['numbering_type'] ?? 'monthly');
if (!in_array($numberingType, ['monthly', 'yearly'], true)) {
$numberingType = 'monthly';
}
$saleDateSource = (string) ($data['sale_date_source'] ?? 'issue_date');
if (!in_array($saleDateSource, ['order_date', 'payment_date', 'issue_date'], true)) {
$saleDateSource = 'issue_date';
}
$orderReference = (string) ($data['order_reference'] ?? 'none');
if (!in_array($orderReference, ['none', 'orderpro', 'integration'], true)) {
$orderReference = 'none';
}
$paymentToDays = (int) ($data['payment_to_days'] ?? 7);
if ($paymentToDays < 0) {
$paymentToDays = 0;
}
if ($paymentToDays > 365) {
$paymentToDays = 365;
}
$defaultKind = trim((string) ($data['default_kind'] ?? 'vat'));
if ($defaultKind === '') {
$defaultKind = 'vat';
}
if (mb_strlen($defaultKind) > 32) {
throw new IntegrationConfigException('Typ dokumentu jest za dlugi (max 32 znakow).');
}
$isDelegated = !empty($data['is_delegated']) ? 1 : 0;
$isActive = !empty($data['is_active']) ? 1 : 0;
$integrationId = isset($data['integration_id']) && $data['integration_id'] !== ''
? (int) $data['integration_id']
: null;
if ($isDelegated === 1) {
if ($integrationId === null || $integrationId <= 0) {
throw new IntegrationConfigException(
'Przy delegacji wystawiania do Fakturowni musisz wskazac konto Fakturowni.'
);
}
if (!$this->isFakturowniaIntegration($integrationId)) {
throw new IntegrationConfigException(
'Wybrana integracja nie jest kontem Fakturowni - sprawdz konfiguracje.'
);
}
} else {
$integrationId = null;
}
$params = [
'name' => $name,
'integration_id' => $integrationId,
'is_delegated' => $isDelegated,
'is_active' => $isActive,
'number_format' => $numberFormat,
'numbering_type' => $numberingType,
'sale_date_source' => $saleDateSource,
'order_reference' => $orderReference,
'payment_to_days' => $paymentToDays,
'default_kind' => $defaultKind,
];
$id = isset($data['id']) && $data['id'] !== '' ? (int) $data['id'] : 0;
if ($id > 0) {
$statement = $this->pdo->prepare(
'UPDATE invoice_configs SET
name = :name,
integration_id = :integration_id,
is_delegated = :is_delegated,
is_active = :is_active,
number_format = :number_format,
numbering_type = :numbering_type,
sale_date_source = :sale_date_source,
order_reference = :order_reference,
payment_to_days = :payment_to_days,
default_kind = :default_kind,
updated_at = NOW()
WHERE id = :id'
);
$params['id'] = $id;
$statement->execute($params);
return $id;
}
$statement = $this->pdo->prepare(
'INSERT INTO invoice_configs
(name, integration_id, is_delegated, is_active, number_format, numbering_type,
sale_date_source, order_reference, payment_to_days, default_kind)
VALUES
(:name, :integration_id, :is_delegated, :is_active, :number_format, :numbering_type,
:sale_date_source, :order_reference, :payment_to_days, :default_kind)'
);
$statement->execute($params);
return (int) $this->pdo->lastInsertId();
}
public function toggleStatus(int $id): void
{
$this->toggleActive('invoice_configs', $id);
}
public function delete(int $id): void
{
if ($id <= 0) {
throw new IntegrationConfigException('Nieprawidlowy identyfikator konfiguracji.');
}
if ($this->hasInvoices($id)) {
throw new IntegrationConfigException(
'Nie mozna usunac konfiguracji - istnieja juz wystawione faktury powiazane z ta konfiguracja.'
);
}
$statement = $this->pdo->prepare('DELETE FROM invoice_configs WHERE id = :id');
$statement->execute(['id' => $id]);
}
private function isFakturowniaIntegration(int $integrationId): bool
{
try {
$statement = $this->pdo->prepare(
'SELECT 1 FROM integrations WHERE id = :id AND type = :type LIMIT 1'
);
$statement->execute(['id' => $integrationId, 'type' => 'fakturownia']);
return $statement->fetchColumn() !== false;
} catch (Throwable) {
return false;
}
}
private function hasInvoices(int $configId): bool
{
try {
$statement = $this->pdo->prepare(
'SELECT 1 FROM invoices WHERE config_id = :id LIMIT 1'
);
$statement->execute(['id' => $configId]);
return $statement->fetchColumn() !== false;
} catch (Throwable) {
return false;
}
}
}

View File

@@ -22,27 +22,58 @@ final class ReceiptConfigController
) {
}
public function index(Request $request): Response
public function hub(Request $request): Response
{
$html = $this->template->render('settings/accounting', [
'title' => $this->translator->get('settings.accounting.title'),
'activeMenu' => 'settings',
'activeSettings' => 'accounting',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'successMessage' => (string) Flash::get('settings.accounting.success', ''),
'errorMessage' => (string) Flash::get('settings.accounting.error', ''),
], 'layouts/app');
return Response::html($html);
}
public function list(Request $request): Response
{
$t = $this->translator;
$configs = $this->repository->listAll();
$editConfig = null;
$editId = (int) $request->input('edit', '0');
if ($editId > 0) {
$editConfig = $this->repository->findById($editId);
}
$html = $this->template->render('settings/accounting', [
'title' => $t->get('settings.accounting.title'),
$html = $this->template->render('settings/accounting-receipts', [
'title' => 'Konfiguracje paragonow',
'activeMenu' => 'settings',
'activeSettings' => 'accounting',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'configs' => $configs,
'editConfig' => $editConfig,
'successMessage' => Flash::get('settings.accounting.success', ''),
'errorMessage' => Flash::get('settings.accounting.error', ''),
'successMessage' => (string) Flash::get('settings.accounting.success', ''),
'errorMessage' => (string) Flash::get('settings.accounting.error', ''),
], 'layouts/app');
return Response::html($html);
}
public function edit(Request $request): Response
{
$id = (int) $request->input('id', 0);
$config = $id > 0 ? $this->repository->findById($id) : null;
if ($id > 0 && $config === null) {
Flash::set('settings.accounting.error', 'Nie znaleziono konfiguracji paragonu ID ' . $id . '.');
return Response::redirect('/settings/accounting/receipts');
}
$html = $this->template->render('settings/accounting-receipt-edit', [
'title' => $config === null ? 'Nowa konfiguracja paragonu' : 'Edycja konfiguracji paragonu',
'activeMenu' => 'settings',
'activeSettings' => 'accounting',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'config' => $config,
'successMessage' => (string) Flash::get('settings.accounting.success', ''),
'errorMessage' => (string) Flash::get('settings.accounting.error', ''),
], 'layouts/app');
return Response::html($html);
@@ -50,9 +81,15 @@ final class ReceiptConfigController
public function save(Request $request): Response
{
$id = (int) $request->input('id', 0);
$redirectFail = $id > 0
? '/settings/accounting/receipts/edit?id=' . $id
: '/settings/accounting/receipts/new';
$redirectOk = '/settings/accounting/receipts';
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings.accounting.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/accounting');
return Response::redirect($redirectFail);
}
$name = trim((string) $request->input('name', ''));
@@ -60,17 +97,17 @@ final class ReceiptConfigController
if ($name === '') {
Flash::set('settings.accounting.error', 'Nazwa konfiguracji jest wymagana');
return Response::redirect('/settings/accounting');
return Response::redirect($redirectFail);
}
if ($numberFormat === '' || strpos($numberFormat, '%N') === false) {
Flash::set('settings.accounting.error', 'Format numeracji jest wymagany i musi zawierac %N');
return Response::redirect('/settings/accounting');
return Response::redirect($redirectFail);
}
try {
$this->repository->save([
'id' => $request->input('id', ''),
'id' => $id > 0 ? $id : '',
'name' => $name,
'is_active' => $request->input('is_active', null),
'number_format' => $numberFormat,
@@ -81,24 +118,25 @@ final class ReceiptConfigController
]);
Flash::set('settings.accounting.success', $this->translator->get('settings.accounting.flash.saved'));
return Response::redirect($redirectOk);
} catch (Throwable) {
Flash::set('settings.accounting.error', $this->translator->get('settings.accounting.flash.save_failed'));
return Response::redirect($redirectFail);
}
return Response::redirect('/settings/accounting');
}
public function toggleStatus(Request $request): Response
{
$redirect = '/settings/accounting/receipts';
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings.accounting.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/accounting');
return Response::redirect($redirect);
}
$id = (int) $request->input('id', '0');
if ($id <= 0) {
Flash::set('settings.accounting.error', 'Nieprawidlowy identyfikator konfiguracji');
return Response::redirect('/settings/accounting');
return Response::redirect($redirect);
}
try {
@@ -108,20 +146,21 @@ final class ReceiptConfigController
Flash::set('settings.accounting.error', 'Blad zmiany statusu');
}
return Response::redirect('/settings/accounting');
return Response::redirect($redirect);
}
public function delete(Request $request): Response
{
$redirect = '/settings/accounting/receipts';
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings.accounting.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/accounting');
return Response::redirect($redirect);
}
$id = (int) $request->input('id', '0');
if ($id <= 0) {
Flash::set('settings.accounting.error', 'Nieprawidlowy identyfikator konfiguracji');
return Response::redirect('/settings/accounting');
return Response::redirect($redirect);
}
try {
@@ -131,6 +170,6 @@ final class ReceiptConfigController
Flash::set('settings.accounting.error', $this->translator->get('settings.accounting.flash.delete_failed'));
}
return Response::redirect('/settings/accounting');
return Response::redirect($redirect);
}
}