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:
235
src/Modules/Accounting/AccountingController.php
Normal file
235
src/Modules/Accounting/AccountingController.php
Normal 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) : '';
|
||||
}
|
||||
}
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user