feat(08-10-receipt-module): phases 08-10 complete — receipt issuing from orders
Phase 08 — DB Foundation: - 3 new tables: receipt_configs, receipts, receipt_number_counters - company_settings extended with BDO, REGON, KRS, logo fields Phase 09 — Receipt Config: - CRUD for receipt configurations (Settings > Accounting) - ReceiptConfigController + ReceiptConfigRepository Phase 10 — Receipt Issuing: - ReceiptRepository with atomic numbering (INSERT ON DUPLICATE KEY UPDATE) - ReceiptController with snapshot pattern (seller/buyer/items as JSON) - "Wystaw paragon" button in order view - Documents tab showing both receipts and marketplace documents - Activity log entry on receipt creation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
263
src/Modules/Accounting/ReceiptController.php
Normal file
263
src/Modules/Accounting/ReceiptController.php
Normal file
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Accounting;
|
||||
|
||||
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 App\Modules\Orders\OrdersRepository;
|
||||
use App\Modules\Settings\CompanySettingsRepository;
|
||||
use App\Modules\Settings\ReceiptConfigRepository;
|
||||
use Throwable;
|
||||
|
||||
final class ReceiptController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly ReceiptRepository $receipts,
|
||||
private readonly ReceiptConfigRepository $receiptConfigs,
|
||||
private readonly CompanySettingsRepository $companySettings,
|
||||
private readonly OrdersRepository $orders
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
$details = $this->orders->findDetails($orderId);
|
||||
if ($details === null) {
|
||||
return Response::html('Not found', 404);
|
||||
}
|
||||
|
||||
$configs = array_filter($this->receiptConfigs->listAll(), static fn(array $c): bool => (int) ($c['is_active'] ?? 0) === 1);
|
||||
if ($configs === []) {
|
||||
Flash::set('order.error', $this->translator->get('receipts.create.no_configs'));
|
||||
return Response::redirect('/orders/' . $orderId);
|
||||
}
|
||||
|
||||
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
|
||||
$items = is_array($details['items'] ?? null) ? $details['items'] : [];
|
||||
$seller = $this->companySettings->getSettings();
|
||||
|
||||
$totalGross = 0.0;
|
||||
foreach ($items as $item) {
|
||||
$qty = (float) ($item['quantity'] ?? 0);
|
||||
$price = $item['original_price_with_tax'] !== null ? (float) $item['original_price_with_tax'] : 0.0;
|
||||
$totalGross += $qty * $price;
|
||||
}
|
||||
|
||||
$html = $this->template->render('orders/receipt-create', [
|
||||
'title' => $this->translator->get('receipts.create.title'),
|
||||
'activeMenu' => 'orders',
|
||||
'activeOrders' => 'list',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'orderId' => $orderId,
|
||||
'order' => $order,
|
||||
'items' => $items,
|
||||
'configs' => array_values($configs),
|
||||
'seller' => $seller,
|
||||
'totalGross' => $totalGross,
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
public function store(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
|
||||
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
||||
Flash::set('order.error', 'Nieprawidlowy token CSRF');
|
||||
return Response::redirect('/orders/' . $orderId);
|
||||
}
|
||||
|
||||
$configId = (int) $request->input('config_id', '0');
|
||||
if ($configId <= 0) {
|
||||
Flash::set('order.error', $this->translator->get('receipts.create.no_config_selected'));
|
||||
return Response::redirect('/orders/' . $orderId . '/receipt/create');
|
||||
}
|
||||
|
||||
$config = $this->receiptConfigs->findById($configId);
|
||||
if ($config === null || (int) ($config['is_active'] ?? 0) !== 1) {
|
||||
Flash::set('order.error', $this->translator->get('receipts.create.invalid_config'));
|
||||
return Response::redirect('/orders/' . $orderId . '/receipt/create');
|
||||
}
|
||||
|
||||
$details = $this->orders->findDetails($orderId);
|
||||
if ($details === null) {
|
||||
return Response::html('Not found', 404);
|
||||
}
|
||||
|
||||
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
|
||||
$items = is_array($details['items'] ?? null) ? $details['items'] : [];
|
||||
$addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
|
||||
$payments = is_array($details['payments'] ?? null) ? $details['payments'] : [];
|
||||
|
||||
$seller = $this->companySettings->getSettings();
|
||||
$sellerSnapshot = [
|
||||
'company_name' => $seller['company_name'] ?? '',
|
||||
'tax_number' => $seller['tax_number'] ?? '',
|
||||
'street' => $seller['street'] ?? '',
|
||||
'city' => $seller['city'] ?? '',
|
||||
'postal_code' => $seller['postal_code'] ?? '',
|
||||
'phone' => $seller['phone'] ?? '',
|
||||
'email' => $seller['email'] ?? '',
|
||||
'bank_account' => $seller['bank_account'] ?? '',
|
||||
'bdo_number' => $seller['bdo_number'] ?? '',
|
||||
'regon' => $seller['regon'] ?? '',
|
||||
'court_register' => $seller['court_register'] ?? '',
|
||||
];
|
||||
|
||||
$buyerAddress = $this->resolveBuyerAddress($addresses);
|
||||
$buyerSnapshot = $buyerAddress !== null ? [
|
||||
'name' => $buyerAddress['name'] ?? '',
|
||||
'company_name' => $buyerAddress['company_name'] ?? '',
|
||||
'tax_number' => $buyerAddress['company_tax_number'] ?? '',
|
||||
'street' => trim(($buyerAddress['street_name'] ?? '') . ' ' . ($buyerAddress['street_number'] ?? '')),
|
||||
'city' => $buyerAddress['city'] ?? '',
|
||||
'postal_code' => $buyerAddress['zip_code'] ?? '',
|
||||
'phone' => $buyerAddress['phone'] ?? '',
|
||||
'email' => $buyerAddress['email'] ?? '',
|
||||
] : null;
|
||||
|
||||
$itemsSnapshot = [];
|
||||
$totalGross = 0.0;
|
||||
foreach ($items as $item) {
|
||||
$qty = (float) ($item['quantity'] ?? 0);
|
||||
$price = $item['original_price_with_tax'] !== null ? (float) $item['original_price_with_tax'] : 0.0;
|
||||
$lineTotal = $qty * $price;
|
||||
$totalGross += $lineTotal;
|
||||
$itemsSnapshot[] = [
|
||||
'name' => $item['original_name'] ?? '',
|
||||
'quantity' => $qty,
|
||||
'price' => $price,
|
||||
'total' => $lineTotal,
|
||||
'sku' => $item['sku'] ?? '',
|
||||
'ean' => $item['ean'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$issueDate = trim((string) $request->input('issue_date', ''));
|
||||
if ($issueDate === '' || strtotime($issueDate) === false) {
|
||||
$issueDate = date('Y-m-d');
|
||||
}
|
||||
|
||||
$saleDate = $this->resolveSaleDate($config, $order, $payments, $issueDate);
|
||||
|
||||
$orderReference = $this->resolveOrderReference($config, $order);
|
||||
|
||||
try {
|
||||
$receiptNumber = $this->receipts->getNextNumber(
|
||||
$configId,
|
||||
(string) ($config['number_format'] ?? 'PAR/%N/%M/%Y'),
|
||||
(string) ($config['numbering_type'] ?? 'monthly')
|
||||
);
|
||||
|
||||
$user = $this->auth->user();
|
||||
|
||||
$this->receipts->create([
|
||||
'order_id' => $orderId,
|
||||
'config_id' => $configId,
|
||||
'receipt_number' => $receiptNumber,
|
||||
'issue_date' => $issueDate,
|
||||
'sale_date' => $saleDate,
|
||||
'seller_data_json' => json_encode($sellerSnapshot, JSON_UNESCAPED_UNICODE),
|
||||
'buyer_data_json' => $buyerSnapshot !== null ? json_encode($buyerSnapshot, JSON_UNESCAPED_UNICODE) : null,
|
||||
'items_json' => json_encode($itemsSnapshot, JSON_UNESCAPED_UNICODE),
|
||||
'total_net' => number_format($totalGross, 2, '.', ''),
|
||||
'total_gross' => number_format($totalGross, 2, '.', ''),
|
||||
'order_reference_value' => $orderReference,
|
||||
'created_by' => is_array($user) ? ($user['id'] ?? null) : null,
|
||||
]);
|
||||
|
||||
$userName = is_array($user) ? (string) ($user['username'] ?? $user['email'] ?? '') : '';
|
||||
$this->orders->recordActivity(
|
||||
$orderId,
|
||||
'receipt_issued',
|
||||
'Wystawiono paragon: ' . $receiptNumber,
|
||||
['receipt_number' => $receiptNumber, 'config_id' => $configId, 'total_gross' => number_format($totalGross, 2, '.', '')],
|
||||
'user',
|
||||
$userName !== '' ? $userName : null
|
||||
);
|
||||
|
||||
Flash::set('order.success', 'Paragon wystawiony: ' . $receiptNumber);
|
||||
} catch (Throwable) {
|
||||
Flash::set('order.error', 'Blad wystawiania paragonu');
|
||||
}
|
||||
|
||||
return Response::redirect('/orders/' . $orderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $addresses
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function resolveBuyerAddress(array $addresses): ?array
|
||||
{
|
||||
$byType = [];
|
||||
foreach ($addresses as $addr) {
|
||||
$type = (string) ($addr['address_type'] ?? '');
|
||||
if ($type !== '' && !isset($byType[$type])) {
|
||||
$byType[$type] = $addr;
|
||||
}
|
||||
}
|
||||
|
||||
return $byType['invoice'] ?? $byType['customer'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
* @param array<string, mixed> $order
|
||||
* @param list<array<string, mixed>> $payments
|
||||
*/
|
||||
private function resolveSaleDate(array $config, array $order, array $payments, string $issueDate): string
|
||||
{
|
||||
$source = (string) ($config['sale_date_source'] ?? 'issue_date');
|
||||
|
||||
if ($source === 'order_date') {
|
||||
$ordered = (string) ($order['ordered_at'] ?? '');
|
||||
if ($ordered !== '') {
|
||||
$ts = strtotime($ordered);
|
||||
return $ts !== false ? date('Y-m-d', $ts) : $issueDate;
|
||||
}
|
||||
}
|
||||
|
||||
if ($source === 'payment_date' && $payments !== []) {
|
||||
$lastPayment = $payments[0] ?? [];
|
||||
$payDate = (string) ($lastPayment['payment_date'] ?? '');
|
||||
if ($payDate !== '') {
|
||||
$ts = strtotime($payDate);
|
||||
return $ts !== false ? date('Y-m-d', $ts) : $issueDate;
|
||||
}
|
||||
}
|
||||
|
||||
return $issueDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
* @param array<string, mixed> $order
|
||||
*/
|
||||
private function resolveOrderReference(array $config, array $order): ?string
|
||||
{
|
||||
$ref = (string) ($config['order_reference'] ?? 'none');
|
||||
|
||||
if ($ref === 'orderpro') {
|
||||
return (string) ($order['internal_order_number'] ?? '');
|
||||
}
|
||||
|
||||
if ($ref === 'integration') {
|
||||
return (string) ($order['external_order_id'] ?? '');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
121
src/Modules/Accounting/ReceiptRepository.php
Normal file
121
src/Modules/Accounting/ReceiptRepository.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Accounting;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class ReceiptRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function findByOrderId(int $orderId): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT r.*, rc.name AS config_name
|
||||
FROM receipts r
|
||||
LEFT JOIN receipt_configs rc ON rc.id = r.config_id
|
||||
WHERE r.order_id = :order_id
|
||||
ORDER BY r.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 * FROM receipts WHERE 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 create(array $data): int
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO receipts (
|
||||
order_id, config_id, receipt_number, issue_date, sale_date,
|
||||
seller_data_json, buyer_data_json, items_json,
|
||||
total_net, total_gross, order_reference_value, created_by
|
||||
) VALUES (
|
||||
:order_id, :config_id, :receipt_number, :issue_date, :sale_date,
|
||||
:seller_data_json, :buyer_data_json, :items_json,
|
||||
:total_net, :total_gross, :order_reference_value, :created_by
|
||||
)'
|
||||
);
|
||||
|
||||
$statement->execute([
|
||||
'order_id' => (int) $data['order_id'],
|
||||
'config_id' => (int) $data['config_id'],
|
||||
'receipt_number' => (string) $data['receipt_number'],
|
||||
'issue_date' => (string) $data['issue_date'],
|
||||
'sale_date' => (string) $data['sale_date'],
|
||||
'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,
|
||||
'created_by' => $data['created_by'] ?? null,
|
||||
]);
|
||||
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
public function getNextNumber(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 receipt_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 receipt_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 receipt_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 receipt_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();
|
||||
|
||||
$number = 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
|
||||
);
|
||||
|
||||
return $number;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user