This commit is contained in:
2026-04-03 22:35:49 +02:00
parent 0e7ee957cb
commit e95c4967d2
52 changed files with 7430 additions and 631 deletions

View File

@@ -26,7 +26,8 @@ final class ReceiptController
private readonly ReceiptConfigRepository $receiptConfigs,
private readonly CompanySettingsRepository $companySettings,
private readonly OrdersRepository $orders,
private readonly AutomationService $automation
private readonly AutomationService $automation,
private readonly ReceiptService $receiptService
) {
}
@@ -48,14 +49,8 @@ final class ReceiptController
$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;
}
$totalGross = $this->receiptService->calculateTotalGross($items);
$html = $this->template->render('orders/receipt-create', [
'title' => $this->translator->get('receipts.create.title'),
@@ -67,7 +62,7 @@ final class ReceiptController
'order' => $order,
'items' => $items,
'configs' => array_values($configs),
'seller' => $seller,
'seller' => $this->companySettings->getSettings(),
'totalGross' => $totalGross,
'existingReceipts' => $existingReceipts,
], 'layouts/app');
@@ -90,98 +85,13 @@ final class ReceiptController
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 H:i:s', strtotime($issueDate));
} else {
$issueDate = date('Y-m-d H:i:s');
}
$saleDate = $this->resolveSaleDate($config, $order, $payments, $issueDate);
$orderReference = $this->resolveOrderReference($config, $order);
$user = $this->auth->user();
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([
$result = $this->receiptService->issue([
'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,
'issue_date_override' => (string) $request->input('issue_date', ''),
'created_by' => is_array($user) ? ($user['id'] ?? null) : null,
]);
@@ -189,19 +99,20 @@ final class ReceiptController
$this->orders->recordActivity(
$orderId,
'receipt_issued',
'Wystawiono paragon: ' . $receiptNumber,
['receipt_number' => $receiptNumber, 'config_id' => $configId, 'total_gross' => number_format($totalGross, 2, '.', '')],
'Wystawiono paragon: ' . $result['receipt_number'],
['receipt_number' => $result['receipt_number'], 'config_id' => $configId, 'total_gross' => $result['total_gross']],
'user',
$userName !== '' ? $userName : null
);
Flash::set('order.success', 'Paragon wystawiony: ' . $receiptNumber);
Flash::set('order.success', 'Paragon wystawiony: ' . $result['receipt_number']);
try {
$this->automation->trigger('receipt.created', $orderId);
} catch (Throwable) {
// Blad automatyzacji nie blokuje sukcesu paragonu
}
} catch (ReceiptIssueException $e) {
Flash::set('order.error', $e->getMessage());
} catch (Throwable) {
Flash::set('order.error', 'Blad wystawiania paragonu');
}
@@ -209,71 +120,6 @@ final class ReceiptController
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;
}
public function show(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
use RuntimeException;
final class ReceiptIssueException extends RuntimeException
{
}

View File

@@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\ReceiptConfigRepository;
use Throwable;
final class ReceiptService
{
public function __construct(
private readonly ReceiptRepository $receipts,
private readonly ReceiptConfigRepository $receiptConfigs,
private readonly CompanySettingsRepository $companySettings,
private readonly OrdersRepository $orders
) {
}
/**
* @param array{
* order_id: int,
* config_id: int,
* issue_date_mode?: string,
* issue_date_override?: string,
* created_by?: int|null,
* } $params
* @return array{receipt_number: string, total_gross: string}
* @throws ReceiptIssueException
*/
public function issue(array $params): array
{
$orderId = (int) $params['order_id'];
$configId = (int) $params['config_id'];
$config = $this->receiptConfigs->findById($configId);
if ($config === null || (int) ($config['is_active'] ?? 0) !== 1) {
throw new ReceiptIssueException('Nieprawidlowa lub nieaktywna konfiguracja paragonu');
}
$details = $this->orders->findDetails($orderId);
if ($details === null) {
throw new ReceiptIssueException('Zamowienie nie istnieje');
}
$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'] : [];
$issueDateOverride = trim((string) ($params['issue_date_override'] ?? ''));
if ($issueDateOverride !== '' && strtotime($issueDateOverride) !== false) {
$issueDate = date('Y-m-d H:i:s', strtotime($issueDateOverride));
} else {
$issueDateMode = (string) ($params['issue_date_mode'] ?? 'today');
$issueDate = $this->resolveIssueDate($issueDateMode, $order, $payments);
}
$saleDate = $this->resolveSaleDate($config, $order, $payments, $issueDate);
$orderReference = $this->resolveOrderReference($config, $order);
$sellerSnapshot = $this->buildSellerSnapshot();
$buyerSnapshot = $this->buildBuyerSnapshot($addresses);
['items' => $itemsSnapshot, 'total_gross' => $totalGross] = $this->buildItemsSnapshot($items);
$receiptNumber = $this->receipts->getNextNumber(
$configId,
(string) ($config['number_format'] ?? 'PAR/%N/%M/%Y'),
(string) ($config['numbering_type'] ?? 'monthly')
);
$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' => $params['created_by'] ?? null,
]);
return [
'receipt_number' => $receiptNumber,
'total_gross' => number_format($totalGross, 2, '.', ''),
];
}
/**
* @param list<array<string, mixed>> $items
*/
public function calculateTotalGross(array $items): float
{
$total = 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;
$total += $qty * $price;
}
return $total;
}
/**
* @param array<string, mixed> $order
* @param list<array<string, mixed>> $payments
*/
private function resolveIssueDate(string $mode, array $order, array $payments): string
{
if ($mode === 'order_date') {
$orderedAt = trim((string) ($order['ordered_at'] ?? ''));
if ($orderedAt !== '') {
$timestamp = strtotime($orderedAt);
if ($timestamp !== false) {
return date('Y-m-d H:i:s', $timestamp);
}
}
}
if ($mode === 'payment_date') {
$firstPayment = $payments[0] ?? [];
$paymentDate = trim((string) ($firstPayment['payment_date'] ?? ''));
if ($paymentDate !== '') {
$timestamp = strtotime($paymentDate);
if ($timestamp !== false) {
return date('Y-m-d H:i:s', $timestamp);
}
}
}
return date('Y-m-d H:i:s');
}
/**
* @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 H:i:s', $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 H:i:s', $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;
}
/**
* @return array<string, mixed>
*/
private function buildSellerSnapshot(): array
{
$seller = $this->companySettings->getSettings();
return [
'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'] ?? '',
];
}
/**
* @param list<array<string, mixed>> $addresses
* @return array<string, mixed>|null
*/
private function buildBuyerSnapshot(array $addresses): ?array
{
$buyerAddress = $this->resolveBuyerAddress($addresses);
if ($buyerAddress === null) {
return null;
}
return [
'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'] ?? '',
];
}
/**
* @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 list<array<string, mixed>> $items
* @return array{items: list<array<string, mixed>>, total_gross: float}
*/
private function buildItemsSnapshot(array $items): array
{
$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'] ?? '',
];
}
return [
'items' => $itemsSnapshot,
'total_gross' => $totalGross,
];
}
}