Files
orderPRO/.paul/codebase/architecture.md
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

23 KiB

Architecture

Request Flow

HTTP Request
  → public/index.php
  → bootstrap/app.php         (loads config, registers PDO, services)
  → Application::boot()        (loads routes/web.php)
  → Router::dispatch(Request)  (matches URL, runs middleware pipeline)
  → [Middleware]               (AuthMiddleware, ApiKeyMiddleware)
  → Controller::method()       (parse input → call repository/service → render)
  → Template::render()         (PHP native, layout composition)
  → Response::send()

Layer Map

Layer Location Responsibility
Entry public/index.php Bootstrap only
Routes routes/web.php (581 lines) All ~80 routes; manual DI wiring
Core src/Core/ (25 files) Framework infrastructure
Controllers src/Modules/*/Controller.php Request parsing → response
Services src/Modules/*/Service.php Business logic
Repositories src/Modules/*/Repository.php PDO data access (34+ repos)
Views resources/views/ PHP templates with $e() / $t()
Components resources/views/components/ Reusable UI blocks
Frontend modules public/assets/js/modules/ Small vanilla JS enhancements loaded by layout

Module Inventory (src/Modules/)

Module Files Key Classes Purpose
Auth 3 AuthController, AuthMiddleware, AuthService Login/logout, session
Users 2 UserController, UserRepository User CRUD
Orders 3 OrdersController (1187 LOC), OrdersRepository (1221 LOC) Order list, detail, status, payment, correlated subquery for return-risk
Shipments 17 ShipmentController, provider services + tracking services Shipment creation, label download, tracking polling
Accounting 5 AccountingController, ReceiptService, ReceiptRepository Receipts, invoices, PDF, Excel export
Email 3 EmailSendingService, VariableResolver, AttachmentGenerator Template-based email with PDF attachments
Automation 6 AutomationService (834 LOC), AutomationRepository, AutomationExecutionLogRepository Event→condition→action rules, email triggers
Settings 54+ Integration controllers, OAuth clients, API clients (Fakturownia incl.), mappers Allegro/shopPRO/Apaczka/InPost/Fakturownia config, status mappings
Cron 12 CronRepository, CronHandlerFactory, handler classes Scheduled imports, syncs, token refresh
Printing 4 PrintApiController, PrintJobRepository, ApiKeyMiddleware REST API for Windows print client
Statistics 3 OrdersStatisticsController, OrdersStatisticsRepository, statistics-summary-charts.js Daily order statistics and monthly summary charts
Info 1 InfoController Health check

Frontend Enhancement Modules

Checkbox Multiselect (public/assets/js/modules/checkbox-multiselect.js)

  • Loaded globally from resources/views/layouts/app.php.
  • Enhances native <select multiple data-checkbox-multiselect> controls after DOMContentLoaded.
  • Keeps the original select in the form, synchronizes option selected state, and preserves native GET/POST names such as channels[] and status_groups[].
  • Used by /statistics/orders and /statistics/summary filters to display a compact trigger, checkbox dropdown, "Wszystkie" bulk toggle, and selected count.
  • Progressive enhancement: if JavaScript fails, the native multi-select remains visible.

Statistics Summary Charts (public/assets/js/modules/statistics-summary-charts.js)

  • Loaded globally from resources/views/layouts/app.php after Chart.js 4.4.8 CDN; activates only when #js-statistics-summary-data exists.
  • Reads JSON produced by OrdersStatisticsController::summary() and renders two interactive Chart.js line charts on /statistics/summary.
  • Chart 1 displays monthly order counts per selected integration plus a Razem line.
  • Chart 2 displays monthly gross order values per selected integration plus a Razem line.
  • The PHP view keeps table fallbacks under both charts, so the data remains visible if JavaScript fails.

Key Data Flows

Order Lifecycle

  1. Import — Cron handler → API client → OrderImportServiceOrdersRepository::insertOrder()AutomationService::executeForNewOrder()
  2. Re-import (Phase 111 + 112)OrderImportRepository::upsertOrderAggregate wykrywa tranzycje payment_status z 0/1 na 2 i zwraca payment_transition=true. AllegroOrderImportService i ShopproOrdersSyncService na tej fladze emituja payment.status_changed, co przez chain reguly automatyzacji #7 zmienia status_code na w_realizacji. Logika preservacji status_code z Phase 62 pozostaje rozdzielona (statusOverwriteAllowed = currentStatus='nieoplacone' && newPaymentStatus===2). Phase 112-01 (delta-only re-import): przy created=false repo nie wywoluje replaceAddresses/replaceItems/replaceNotes/replaceShipments/replaceStatusHistoryorder_items.id i flagi lokalne (np. project_generated z Phase 97) pozostaja stabilne. updateOrderDelta() aktualizuje wylacznie status_code (warunkowo, z propagacja anulowania), payment_status, total_paid, is_canceled_by_buyer, source_updated_at, payload_json, fetched_at, updated_at. Anulowanie ze zrodla (is_canceled_by_buyer=1 lub zmapowany pull status_code='anulowane') nadpisuje preservacje statusu. Identical-payload guard (normalizePayloadJson) pomija UPDATE gdy znormalizowany payload nie rozni sie od DB i brak innych tranzycji.
  3. Status updateOrdersController::updateStatus()OrdersRepository::updateStatus() → automation check
  4. Status sync — Cron → AllegroStatusSyncService / ShopproStatusSyncService → carrier API

Statistics Summary

  1. Request/statistics/summaryOrdersStatisticsController::summary()
  2. Filters — controller reuses statistics filter semantics: date range, channels[], status_groups[], default status groups excluding cancelled; default history starts at 2026-04-01.
  3. AggregationOrdersStatisticsRepository::aggregateByMonth() groups existing orders rows by YYYY-MM and channel key, using the same effective date/channel/status/gross amount SQL helpers as the daily report.
  4. View model — controller builds per-integration series and total series for order count and gross value charts.
  5. Renderresources/views/statistics/summary.php renders filters, chart JSON, two canvas targets, and table fallbacks.

Shipment Flow

  1. CreateShipmentController::create()ShipmentProviderRegistry → carrier ShipmentService::createShipment()ShipmentPackageRepository::insert()
  2. Track — Cron ShipmentTrackingHandlerShipmentTrackingRegistry → carrier tracking API → ShipmentPackageRepository::updateDeliveryStatus()

Receipt / Invoice

  1. GenerateReceiptController::store()ReceiptService::generateReceipt()ReceiptRepository::insert() + Dompdf PDF
  2. EmailEmailSendingService::send()VariableResolver::resolve()AttachmentGenerator::generatePdf() → PHPMailer SMTP

Automation Rules

  1. SetupAutomationControllerAutomationRepository::insertRule()
  2. TriggerAutomationService::executeForOrder() → evaluates trigger (order_status_changed, order_status_aged) → runs action (send email, update status)
  3. LogAutomationExecutionLogRepository tracks every run

Cron Jobs

Handler Task
AllegroOrdersImportHandler Fetch new Allegro orders
AllegroStatusSyncHandler Push status changes to Allegro
AllegroTokenRefreshHandler OAuth token refresh (24h expiry)
ShopproOrdersImportHandler Fetch new shopPRO orders
ShopproStatusSyncHandler Push status to shopPRO
ShopproPaymentStatusSyncHandler Sync payment statuses
ShipmentTrackingHandler Poll carrier tracking APIs
OrderStatusAgedHandler Trigger automation for stuck statuses
AutomationHistoryCleanupHandler Purge old automation logs

Dependency Injection

Manual constructor injection in routes/web.php — no DI container library. Example:

$ordersController = new OrdersController(
    $template, $translator, $auth,
    $app->orders(), $shipmentPackageRepository,
    $receiptRepository, $receiptConfigRepository, ...
);

All production classes are final — prevents accidental inheritance.

Directory Structure

bootstrap/         app.php (service wiring, config loading)
bin/               migrate.php, cron.php (CLI entry points)
config/            app.php, database.php
database/
  migrations/      84 SQL files (YYYYMMDD_NNNNNN_description.sql)
  drafts/          WIP migrations
public/
  index.php        HTTP entry point
  .htaccess        Apache rewrite rules
  assets/css/      Compiled CSS (app.css, login.css, modules/)
  assets/js/       jquery-alerts.js, global-search.js, automation-form.js
resources/
  views/           PHP templates by module + components/ layouts/
  scss/            SCSS sources (app.scss, login.scss, modules/_*.scss)
  modules/         jquery-alerts JS+SCSS source
  lang/pl/         Polish translations
routes/
  web.php          All routes (581 lines)
src/
  Core/            Framework (25 files)
  Modules/         13 feature modules (~200+ PHP files)
storage/
  logs/            app.log
  sessions/        PHP session files
  cache/           PHPUnit cache, etc.
tests/
  Unit/            PHPUnit tests (7+ service test files)
  bootstrap.php    PSR-4 autoloader for tests

Phase 108 — Delivery Status Management

DeliveryStatusRepository (src/Modules/Shipments/DeliveryStatusRepository.php)

  • CRUD dla tabeli delivery_statuses
  • Per-request static cache (private static ?array $cache)
  • Blokuje edycję/usunięcie statusów systemowych (is_system=1)
  • Blokuje usunięcie statusów używanych w delivery_status_mappings lub shipment_packages

DeliveryStatusesController (src/Modules/Settings/DeliveryStatusesController.php)

  • Panel /settings/delivery-statuses
  • Dwie zakładki via ?tab= param: statuses (CRUD) i mapping (embed mapowania)
  • Wstrzykuje DeliveryStatusRepository i DeliveryStatusMappingRepository

DeliveryStatus::setRepository() (dynamic loading)

  • Wywoływane raz w routes/web.php po bootstrap
  • label(), getAllOptions(), getAllStatuses(), getColor() ładują z DB gdy repo ustawione
  • Fallback na hardcoded stałe gdy repo niedostępne

AutomationController + AutomationService (Phase 108 Plan 02)

  • AutomationController::buildShipmentStatusOptions() — buduje listę opcji [key => ['label' => ...]] z DeliveryStatus::getAllOptions() (DB-driven)
  • Walidacja shipment_status warunku i update_shipment_status akcji w parseConditionValue()/parseActionConfig() używa DeliveryStatus::getAllStatuses()
  • AutomationService::evaluateShipmentStatusCondition() — bezpośrednie porównanie kluczy DB (usunięto mapping grupowy SHIPMENT_STATUS_OPTION_MAP)
  • AutomationService::resolveStatusFromActionKey() — bezpośredni klucz statusu z DB jako target
  • BREAKING: stare reguły z grupowymi kluczami (registered, courier_pickup, dropped_at_point, unclaimed, picked_up_return) nie matchują się — operator musi je odtworzyć przy użyciu nowych kluczy DB

Phase 113 — Fakturownia Integration Foundation

Schema (Plan 113-01)

  • Tabele invoice_configs, invoices, invoice_number_counters (mirror receipt_configs/receipts/receipt_number_counters plus delegation fields: invoice_configs.integration_id, is_delegated; invoices.external_invoice_id, external_pdf_url).
  • Tabela fakturownia_integration_settings (multi-account: integration_id INT UNSIGNED NOT NULL UNIQUE FK -> integrations(id)).
  • orders.invoice_requested TINYINT(1) NOT NULL DEFAULT 0 z indexem idx_orders_invoice_requested.

FakturowniaIntegrationRepository (src/Modules/Settings/FakturowniaIntegrationRepository.php)

  • findAll() JOIN integrations + fakturownia_integration_settings zwraca listę kont Fakturowni.
  • findByIntegrationId(int) zwraca jedno konto (z resolved api_token_encrypted z integrations.api_key_encrypted z fallbackiem na settings).
  • save(?int $integrationId, array $payload) - upsert (insert do integrations przez IntegrationsRepository::ensureIntegration gdy $integrationId=null; w przeciwnym razie update name/is_active). Token szyfrowany przez IntegrationSecretCipher i zapisywany do integrations.api_key_encrypted (źródło prawdy) oraz settings.api_token_encrypted (cache).
  • delete(int $integrationId) — blokuje usunięcie gdy invoice_configs.integration_id = X (FK SET NULL chroniony aplikacyjnie przez IntegrationConfigException).
  • getDecryptedToken(int $integrationId) — dla użycia w przyszłych planach (createInvoice/downloadPdf).

FakturowniaApiClient (src/Modules/Settings/FakturowniaApiClient.php)

  • testConnection(string $prefix, string $apiToken): array — GET https://{prefix}.fakturownia.pl/account.json?api_token=... z cURL + SslCertificateResolver::resolve(). Zwraca ['ok' => bool, 'http_code' => int, 'message' => string].
  • createInvoice() i downloadPdf() — STUB-y rzucające RuntimeException do implementacji w kolejnym planie.

IntegrationsRepository::updateTestResult()

  • Nowa metoda zapisująca last_test_status / last_test_http_code / last_test_message / last_test_at po wywołaniu API test. Używana przez FakturowniaIntegrationController::test() (i będzie reuse'owana w przyszłych integracjach).

FakturowniaIntegrationController (src/Modules/Settings/FakturowniaIntegrationController.php)

  • Routy /settings/integrations/fakturownia (lista), .../edit, .../save, .../test, .../delete (POST z _token CSRF).
  • Wykorzystuje Flash::set('fakturownia.save'|'fakturownia.test'|'fakturownia.error') i RedirectPathResolver.

IntegrationsHubController

  • Nowy parametr konstruktora FakturowniaIntegrationRepository $fakturownia i nowa metoda buildFakturowniaRow() agregująca status wszystkich kont (count instancji, configured/active counts, ostatni test).

Phase 115 — Wystawianie faktury z zamowienia

InvoiceService (src/Modules/Accounting/InvoiceService.php)

  • issue(array $params): array — orchestrator. Walidacja config (active), order details fetch, build snapshots (seller z company_settings, buyer merged z payload_json+addresses+manual override, items z VAT-aware netto/brutto split), routing do issueLocal() lub issueDelegated() zaleznie od invoice_configs.is_delegated.
  • issueLocal()InvoiceRepository::nextLocalNumber() (atomowy counter z invoice_number_counters) -> insertLocal() -> zwraca {invoice_id, invoice_number, total_gross, mode='local'}.
  • issueDelegated()FakturowniaApiClient::createInvoice() PRZED INSERT lokalnym; on success zapis external_invoice_id/external_pdf_url/invoice_number z odpowiedzi API; on failure rzuca InvoiceIssueException (zaden wiersz w invoices). invoice_number_counters NIE jest dotykany dla delegated.
  • Static extractBuyerTaxNumber($order, $buyerAddress) — parsuje NIP z payload_json sciezki: invoice.address.taxId (Allegro), invoice.taxId/nip, buyer.tax_number/nip, client.nip/tax_number, top-level nip/tax_number. Fallback na order_addresses.company_tax_number.

InvoiceRepository (src/Modules/Accounting/InvoiceRepository.php)

  • findByOrderId/findById — JOIN invoices + invoice_configs + integrations (type='fakturownia') + fakturownia_integration_settings (LEFT JOIN dla account_prefix).
  • insertLocal/insertDelegated — wspolny prywatny insert() z roznymi NULL-amizacjami external_* pol.
  • nextLocalNumber()INSERT ... ON DUPLICATE KEY UPDATE last_number = last_number + 1 na invoice_number_counters, mirror ReceiptRepository::getNextNumber.
  • paginate() — filtry: search (numer/order ref), config_id, mode (local/delegated rozroznia po external_invoice_id IS NULL), date_from/date_to.

FakturowniaApiClient (rozszerzony)

  • createInvoice(array $settings, array $invoice) — POST https://{prefix}.fakturownia.pl/invoices.json z body {api_token, invoice}. cURL z SslCertificateResolver, timeout $timeoutSeconds. On 2xx parsuje JSON na {id, number, view_url, pdf_url, raw}. On non-2xx rzuca RuntimeException("HTTP {code}: {error}").
  • buildPdfUrl(prefix, invoiceId, apiToken) — string-builder dla https://{prefix}.fakturownia.pl/invoices/{id}.pdf?api_token=.... Bez fetcha; uzywany w redirect 302.
  • Dodany httpPostJson() (private) odpowiednik istniejacego httpGet().

InvoiceController (src/Modules/Accounting/InvoiceController.php)

  • create(Request) — GET /orders/{id}/invoice/create. Walidacja orders.invoice_requested=1 (przekierowanie z flash error gdy 0). Active configs (filter is_active=1). NIP auto-prefill via InvoiceService::extractBuyerTaxNumber(). Renderuje accounting/invoice_form.
  • store(Request) — POST /orders/{id}/invoice/store. CSRF, config_id walidacja. Wywoluje InvoiceService::issue() z buyer overrides z formularza. On success: OrdersRepository::recordActivity('invoice_issued'), flash success, redirect na /orders/{id}/invoice/{invoiceId}. On InvoiceIssueException: flash do invoice.error, redirect z powrotem na form.
  • show(Request) — GET /orders/{id}/invoice/{invoiceId}. HTML preview z snapshotow.
  • pdf(Request) — GET /orders/{id}/invoice/{invoiceId}/pdf. Gdy external_pdf_url istnieje -> redirect 302; inaczej Dompdf inline z templatu accounting/invoice_pdf.
  • issuedList(Request) — GET /settings/accounting/invoices/issued. Filtry GET, paginacja 50/strona.

orders.invoice_requested toggle

  • OrdersRepository::setInvoiceRequested(int, bool) — UPDATE z updated_at = NOW().
  • OrdersController::toggleInvoiceRequested — POST /orders/{id}/invoice-requested/toggle. CSRF, JSON response {success, invoice_requested}. Loguje order_activity_log z event_type='invoice_requested_changed'.
  • public/assets/js/modules/invoice-requested-toggle.js — vanilla JS, idempotent guard dataset.bound='1'. AJAX POST przy change, optimistic show/hide [data-invoice-button-wrap]. Rollback checkbox przy HTTP/network blad.

Auto-import flagi invoice_requested

  • AllegroOrderImportService::importSingleOrder — przy wasCreated=true jezeli payload.invoice.required truthy -> setInvoiceRequested(true). Tylko pierwszy import (delta-only re-import nie nadpisuje manualnej zmiany).
  • ShopproOrdersSyncService::shouldRequestInvoice($rawOrder) — flexible parser sprawdzajacy wants_invoice, invoice_required, invoice.required, buyer.wants_invoice, buyer.invoice (akceptuje true/1/'1'/'true'/'yes'/'tak'). Wywolany tylko przy wasCreated=true.

View hierarchy

  • accounting/invoice_form.php — formularz wystawiania.
  • accounting/invoice_preview.php — HTML preview po wystawieniu.
  • accounting/invoice_pdf.php — template Dompdf, mirror receipts/print.php z dodatkowymi polami faktury VAT (parties, netto/VAT/brutto per stawka, termin platnosci).
  • accounting/invoices_issued_list.php — lista pod /settings/accounting/invoices/issued.
  • orders/show.php — checkbox toggle + warunkowy przycisk "Wystaw fakture" + sekcja "Faktury" w tabie documents.

DI wiring (routes/web.php)

  • $invoiceRepository = new InvoiceRepository($app->db()); (po InvoiceConfigRepository).
  • $invoiceService = new InvoiceService($invoiceRepository, $invoiceConfigRepository, $companySettingsRepository, new OrdersRepository(...), $fakturowniaIntegrationRepository, $fakturowniaApiClient);
  • $invoiceController = new InvoiceController($template, $translator, $auth, $invoiceRepository, $invoiceConfigRepository, $companySettingsRepository, new OrdersRepository(...), $invoiceService);
  • $ordersController rozszerzony o 2 trailing params: $invoiceRepository, $invoiceConfigRepository.

BREAKING / migration

  • Zadnych nowych migracji — Phase 113-01 dostarczyla orders.invoice_requested, invoice_configs/invoices/invoice_number_counters i fakturownia_integration_settings.
  • OrdersController ctor dostal 2 NEW optional params (default null) — backwards compatible.

Edge cases / known limits

  • INVOICE-IDEMP-115 (.paul/codebase/todo.md) — brak idempotencji przy double-POST do Fakturowni gdy odpowiedz nie dotrze; operator musi recznie zweryfikowac w panelu.
  • Brak invoice.created event automatyzacji (per Phase 113 decision).
  • Brak download+cache PDF z Fakturowni — tylko redirect 302 (kazdy klik na PDF dla delegated faktury fetchuje PDF z Fakturowni).

Phase 114 — Accounting Configs Refactor

Sekcja Ksiegowosc — struktura URL

  • /settings/accounting — hub-rozdroze z 2 kartami: "Paragony" i "Faktury". ReceiptConfigController::hub().
  • /settings/accounting/receipts — lista konfiguracji paragonow. ReceiptConfigController::list().
  • /settings/accounting/receipts/new, /edit?id=N — formularz na osobnej podstronie. ReceiptConfigController::edit().
  • /settings/accounting/receipts/save|toggle|delete — POST actions.
  • Legacy aliasy: /settings/accounting/save|toggle|delete (POST) zostaja jako duplicate routes (wsteczna kompatybilnosc z <form action> w starszych szablonach/bookmarkach).
  • /settings/accounting/invoices + /new, /edit, /save, /toggle, /delete — analogicznie dla invoice_configs. InvoiceConfigController.

InvoiceConfigRepository (src/Modules/Settings/InvoiceConfigRepository.php)

  • listAll() JOIN invoice_configs LEFT JOIN integrations (type='fakturownia') — zwraca integration_name gdy is_delegated=1.
  • save(array $data): int — walidacja serwerowa wszystkich pol. Krytyczna regula: gdy is_delegated=1 musi byc integration_id > 0 wskazujacy na integrations.type='fakturownia', inaczej rzuca IntegrationConfigException. Gdy is_delegated=0, ignoruje integration_id (NULL).
  • toggleStatus(int $id) przez ToggleableRepositoryTrait::toggleActive().
  • delete(int $id) — pre-check SELECT 1 FROM invoices WHERE config_id zeby zwrocic czytelny PL komunikat zamiast brzydkiego SQLSTATE z FK RESTRICT.

Seed

  • Migracja 20260511_000107_seed_default_invoice_config.sql — idempotentny insert Domyslny VAT (NOT EXISTS guard, invoice_configs.name nie jest UNIQUE).

invoice-config-form.js (public/assets/js/modules/invoice-config-form.js)

  • Vanilla JS modul ladowany globalnie przez layouts/app.php.
  • Toggle widocznosci [data-invoice-delegation] wrappera w zaleznosci od stanu [data-invoice-delegated] checkboxa.
  • Ustawia select[name=integration_id].required zgodnie ze stanem checkboxa; przy unchecked czysci value.

Ujednolicony wyglad list paragonow/faktur

  • Tabela table.table w table-wrap, badge badge--{success,muted} na statusy.
  • Edycja przez <a href=".../edit?id=N">, toggle/delete przez <form> z _token i js-confirm-delete.
  • Wspolny pattern miedzy accounting-receipts.php i accounting-invoices.php (faktury maja dodatkowe kolumny: Tryb, Konto Fakturowni).