feat(11-12-accounting): phases 11-12 complete — milestone v0.3 done

Phase 11: Receipt preview, print & PDF via dompdf.
Phase 12: Accounting section with receipt list, filters, pagination,
selectable checkboxes and XLSX export via PhpSpreadsheet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 21:00:29 +01:00
parent fb60b6d5d7
commit 22fc330055
12 changed files with 1007 additions and 18 deletions

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\View\Template;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Modules\Auth\AuthService;
use App\Modules\Settings\ReceiptConfigRepository;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
final class AccountingController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly ReceiptRepository $receipts,
private readonly ReceiptConfigRepository $receiptConfigs
) {
}
public function index(Request $request): Response
{
$filters = $this->parseFilters($request);
$result = $this->receipts->paginate($filters);
$totalPages = max(1, (int) ceil(((int) $result['total']) / max(1, (int) $result['per_page'])));
$configs = array_filter(
$this->receiptConfigs->listAll(),
static fn(array $c): bool => (int) ($c['is_active'] ?? 0) === 1
);
$configOptions = ['' => $this->translator->get('accounting.filters.any')];
foreach ($configs as $cfg) {
$configOptions[(string) ($cfg['id'] ?? '')] = (string) ($cfg['name'] ?? '');
}
$tableRows = array_map(fn(array $row): array => $this->toTableRow($row), (array) ($result['items'] ?? []));
$html = $this->template->render('accounting/index', [
'title' => $this->translator->get('accounting.title'),
'activeMenu' => 'accounting',
'user' => $this->auth->user(),
'tableList' => [
'list_key' => 'accounting',
'base_path' => '/accounting',
'query' => $filters,
'filters' => [
[
'key' => 'search',
'label' => $this->translator->get('accounting.filters.search'),
'type' => 'text',
'value' => $filters['search'],
],
[
'key' => 'config_id',
'label' => $this->translator->get('accounting.filters.config'),
'type' => 'select',
'value' => (string) $filters['config_id'],
'options' => $configOptions,
],
[
'key' => 'date_from',
'label' => $this->translator->get('accounting.filters.date_from'),
'type' => 'date',
'value' => $filters['date_from'],
],
[
'key' => 'date_to',
'label' => $this->translator->get('accounting.filters.date_to'),
'type' => 'date',
'value' => $filters['date_to'],
],
],
'columns' => [
['key' => 'receipt_number', 'label' => $this->translator->get('accounting.columns.number'), 'sortable' => true, 'sort_key' => 'receipt_number', 'raw' => true],
['key' => 'issue_date', 'label' => $this->translator->get('accounting.columns.issue_date'), 'sortable' => true, 'sort_key' => 'issue_date'],
['key' => 'sale_date', 'label' => $this->translator->get('accounting.columns.sale_date')],
['key' => 'total_gross', 'label' => $this->translator->get('accounting.columns.total_gross'), 'sortable' => true, 'sort_key' => 'total_gross'],
['key' => 'config_name', 'label' => $this->translator->get('accounting.columns.config')],
['key' => 'order_ref', 'label' => $this->translator->get('accounting.columns.order'), 'raw' => true],
],
'rows' => $tableRows,
'pagination' => [
'page' => (int) ($result['page'] ?? 1),
'total_pages' => $totalPages,
'total' => (int) ($result['total'] ?? 0),
'per_page' => (int) ($result['per_page'] ?? 20),
],
'per_page_options' => [20, 50, 100],
'empty_message' => $this->translator->get('accounting.empty'),
'show_actions' => false,
'selectable' => true,
'select_name' => 'selected_ids[]',
'select_value_key' => 'id',
],
'exportQuery' => $this->buildExportQuery($filters),
'csrfToken' => Csrf::token(),
], 'layouts/app');
return Response::html($html);
}
public function export(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
return Response::redirect('/accounting');
}
$exportAll = (string) $request->input('export_all', '0') === '1';
$selectedIds = (array) $request->input('selected_ids', []);
$selectedIds = array_filter(array_map('intval', $selectedIds), static fn(int $id): bool => $id > 0);
$filters = $this->parseFilters($request);
if ($exportAll) {
$rows = $this->receipts->exportData($filters);
} elseif ($selectedIds !== []) {
$rows = $this->receipts->findByIds($selectedIds);
} else {
return Response::redirect('/accounting');
}
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Paragony');
$headers = ['Numer', 'Data wystawienia', 'Data sprzedazy', 'Kwota brutto', 'Konfiguracja', 'Nr zamowienia', 'Nr referencyjny'];
foreach ($headers as $col => $header) {
$sheet->setCellValue([$col + 1, 1], $header);
}
$sheet->getStyle('1:1')->getFont()->setBold(true);
$rowNum = 2;
foreach ($rows as $row) {
$sheet->setCellValue([1, $rowNum], (string) ($row['receipt_number'] ?? ''));
$sheet->setCellValue([2, $rowNum], (string) ($row['issue_date'] ?? ''));
$sheet->setCellValue([3, $rowNum], (string) ($row['sale_date'] ?? ''));
$sheet->setCellValue([4, $rowNum], (float) ($row['total_gross'] ?? 0));
$sheet->setCellValue([5, $rowNum], (string) ($row['config_name'] ?? ''));
$sheet->setCellValue([6, $rowNum], (string) ($row['internal_order_number'] ?? $row['external_order_id'] ?? ''));
$sheet->setCellValue([7, $rowNum], (string) ($row['order_reference_value'] ?? ''));
$rowNum++;
}
foreach (range(1, 7) as $col) {
$sheet->getColumnDimensionByColumn($col)->setAutoSize(true);
}
$writer = new Xlsx($spreadsheet);
ob_start();
$writer->save('php://output');
$content = ob_get_clean();
$filename = 'paragony_' . date('Y-m-d') . '.xlsx';
return new Response($content ?: '', 200, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
}
/**
* @return array<string, mixed>
*/
private function parseFilters(Request $request): array
{
return [
'search' => trim((string) $request->input('search', '')),
'config_id' => (int) $request->input('config_id', 0),
'date_from' => trim((string) $request->input('date_from', '')),
'date_to' => trim((string) $request->input('date_to', '')),
'sort' => (string) $request->input('sort', 'issue_date'),
'sort_dir' => (string) $request->input('sort_dir', 'DESC'),
'page' => max(1, (int) $request->input('page', 1)),
'per_page' => max(1, min(100, (int) $request->input('per_page', 20))),
];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function toTableRow(array $row): array
{
$orderId = (int) ($row['order_id'] ?? 0);
$receiptId = (int) ($row['id'] ?? 0);
$receiptNumber = htmlspecialchars((string) ($row['receipt_number'] ?? ''), ENT_QUOTES, 'UTF-8');
$orderNumber = htmlspecialchars(
(string) ($row['internal_order_number'] ?? $row['external_order_id'] ?? '#' . $orderId),
ENT_QUOTES,
'UTF-8'
);
return [
'id' => (string) $receiptId,
'receipt_number' => '<a href="/orders/' . $orderId . '/receipt/' . $receiptId . '">' . $receiptNumber . '</a>',
'issue_date' => (string) ($row['issue_date'] ?? ''),
'sale_date' => (string) ($row['sale_date'] ?? ''),
'total_gross' => $row['total_gross'] !== null
? number_format((float) $row['total_gross'], 2, '.', ' ')
: '-',
'config_name' => (string) ($row['config_name'] ?? '-'),
'order_ref' => '<a href="/orders/' . $orderId . '">' . $orderNumber . '</a>',
];
}
/**
* @param array<string, mixed> $filters
*/
private function buildExportQuery(array $filters): string
{
$params = [];
if (($filters['search'] ?? '') !== '') {
$params['search'] = (string) $filters['search'];
}
if (($filters['config_id'] ?? 0) > 0) {
$params['config_id'] = (string) $filters['config_id'];
}
if (($filters['date_from'] ?? '') !== '') {
$params['date_from'] = (string) $filters['date_from'];
}
if (($filters['date_to'] ?? '') !== '') {
$params['date_to'] = (string) $filters['date_to'];
}
return $params !== [] ? '?' . http_build_query($params) : '';
}
}

View File

@@ -42,6 +42,32 @@ final class ReceiptRepository
return is_array($row) ? $row : null;
}
/**
* @param list<int> $ids
* @return list<array<string, mixed>>
*/
public function findByIds(array $ids): array
{
if ($ids === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$statement = $this->pdo->prepare(
"SELECT r.*, rc.name AS config_name,
o.internal_order_number, o.external_order_id
FROM receipts r
LEFT JOIN receipt_configs rc ON rc.id = r.config_id
LEFT JOIN orders o ON o.id = r.order_id
WHERE r.id IN ({$placeholders})
ORDER BY r.issue_date DESC"
);
$statement->execute(array_values($ids));
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @param array<string, mixed> $data
*/
@@ -118,4 +144,132 @@ final class ReceiptRepository
return $number;
}
/**
* @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 = [];
$search = trim((string) ($filters['search'] ?? ''));
if ($search !== '') {
$where[] = '(r.receipt_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 . '%';
}
$configId = (int) ($filters['config_id'] ?? 0);
if ($configId > 0) {
$where[] = 'r.config_id = :config_id';
$params['config_id'] = $configId;
}
$dateFrom = trim((string) ($filters['date_from'] ?? ''));
if ($dateFrom !== '' && strtotime($dateFrom) !== false) {
$where[] = 'r.issue_date >= :date_from';
$params['date_from'] = $dateFrom;
}
$dateTo = trim((string) ($filters['date_to'] ?? ''));
if ($dateTo !== '' && strtotime($dateTo) !== false) {
$where[] = 'r.issue_date <= :date_to';
$params['date_to'] = $dateTo;
}
$whereClause = $where !== [] ? 'WHERE ' . implode(' AND ', $where) : '';
$allowedSort = ['receipt_number', 'issue_date', 'total_gross'];
$sort = in_array((string) ($filters['sort'] ?? ''), $allowedSort, true)
? (string) $filters['sort']
: 'issue_date';
$sortDir = strtoupper((string) ($filters['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$countStmt = $this->pdo->prepare(
"SELECT COUNT(*) FROM receipts r
LEFT JOIN orders o ON o.id = r.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'] ?? 20)));
$offset = ($page - 1) * $perPage;
$stmt = $this->pdo->prepare(
"SELECT r.*, rc.name AS config_name,
o.internal_order_number, o.external_order_id
FROM receipts r
LEFT JOIN receipt_configs rc ON rc.id = r.config_id
LEFT JOIN orders o ON o.id = r.order_id
{$whereClause}
ORDER BY r.{$sort} {$sortDir}
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,
];
}
/**
* @param array<string, mixed> $filters
* @return list<array<string, mixed>>
*/
public function exportData(array $filters): array
{
$where = [];
$params = [];
$search = trim((string) ($filters['search'] ?? ''));
if ($search !== '') {
$where[] = '(r.receipt_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 . '%';
}
$configId = (int) ($filters['config_id'] ?? 0);
if ($configId > 0) {
$where[] = 'r.config_id = :config_id';
$params['config_id'] = $configId;
}
$dateFrom = trim((string) ($filters['date_from'] ?? ''));
if ($dateFrom !== '' && strtotime($dateFrom) !== false) {
$where[] = 'r.issue_date >= :date_from';
$params['date_from'] = $dateFrom;
}
$dateTo = trim((string) ($filters['date_to'] ?? ''));
if ($dateTo !== '' && strtotime($dateTo) !== false) {
$where[] = 'r.issue_date <= :date_to';
$params['date_to'] = $dateTo;
}
$whereClause = $where !== [] ? 'WHERE ' . implode(' AND ', $where) : '';
$stmt = $this->pdo->prepare(
"SELECT r.*, rc.name AS config_name,
o.internal_order_number, o.external_order_id
FROM receipts r
LEFT JOIN receipt_configs rc ON rc.id = r.config_id
LEFT JOIN orders o ON o.id = r.order_id
{$whereClause}
ORDER BY r.issue_date DESC"
);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
}