Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.42% covered (success)
95.42%
146 / 153
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
InventoryCalculationService
95.39% covered (success)
95.39%
145 / 152
50.00% covered (danger)
50.00%
4 / 8
35
0.00% covered (danger)
0.00%
0 / 1
 calculateItemsSummary
100.00% covered (success)
100.00%
75 / 75
100.00% covered (success)
100.00%
1 / 1
7
 resolveExchangeRate
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 findCurrencyRate
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 processRowMutations
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
8.30
 createDefaultItemRow
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
1
 insertRowWithGroup
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 addNewGroupRow
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 reindexSortOrders
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Engine\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11/**
12 * Service for calculating inventory line items, margins, discounts, and currency totals.
13 */
14final class InventoryCalculationService
15{
16    /**
17     * Calculates line item values, margins, discounts, tax breakdown and currency totals.
18     *
19     * @param array<int, array<string, mixed>> $rawItems
20     * @param string                           $currencyCode
21     * @param float                            $exchangeRate
22     * @return array{items: array<int, array<string, mixed>>, summary: array<string, mixed>}
23     */
24    public function calculateItemsSummary(array $rawItems, string $currencyCode, float $exchangeRate): array
25    {
26        $calculatedItems = [];
27        $totalNet = 0.0;
28        $totalDiscount = 0.0;
29        $totalAfterDiscount = 0.0;
30        $totalPurchase = 0.0;
31        $totalTax = 0.0;
32        $totalGross = 0.0;
33        $taxBreakdown = [];
34
35        foreach ($rawItems as $item) {
36            $qty = (float) ($item['quantity'] ?? 1.0);
37            $unitPrice = (float) ($item['unit_price'] ?? 0.0);
38            $discountPercent = (float) ($item['discount_percent'] ?? 0.0);
39            $taxPercent = (float) ($item['tax_percent'] ?? 23.0);
40            $purchaseCost = (float) ($item['purchase_cost'] ?? 0.0);
41
42            $netAmount = round($qty * $unitPrice, 2);
43            $discountAmount = (float) ($item['discount_amount'] ?? 0.0);
44            if ($discountPercent > 0.0 && $discountAmount === 0.0) {
45                $discountAmount = round(($netAmount * $discountPercent) / 100.0, 2);
46            }
47
48            $priceAfterDiscount = max(0.0, round($netAmount - $discountAmount, 2));
49            $taxAmount = round(($priceAfterDiscount * $taxPercent) / 100.0, 2);
50            $grossAmount = round($priceAfterDiscount + $taxAmount, 2);
51
52            $totalItemPurchase = round($qty * $purchaseCost, 2);
53            $marginAmount = round($priceAfterDiscount - $totalItemPurchase, 2);
54            $marginPercent = $priceAfterDiscount > 0.0
55                ? round(($marginAmount / $priceAfterDiscount) * 100.0, 2)
56                : 0.0;
57
58            $taxRateKey = number_format($taxPercent, 0) . '%';
59            if (!isset($taxBreakdown[$taxRateKey])) {
60                $taxBreakdown[$taxRateKey] = ['net' => 0.0, 'amount' => 0.0];
61            }
62            $taxBreakdown[$taxRateKey]['net'] = round($taxBreakdown[$taxRateKey]['net'] + $priceAfterDiscount, 2);
63            $taxBreakdown[$taxRateKey]['amount'] = round($taxBreakdown[$taxRateKey]['amount'] + $taxAmount, 2);
64
65            $totalNet += $netAmount;
66            $totalDiscount += $discountAmount;
67            $totalAfterDiscount += $priceAfterDiscount;
68            $totalPurchase += $totalItemPurchase;
69            $totalTax += $taxAmount;
70            $totalGross += $grossAmount;
71
72            $calculatedItems[] = array_merge($item, [
73                'currency' => $currencyCode,
74                'exchange_rate' => $exchangeRate,
75                'quantity' => $qty,
76                'unit_price' => $unitPrice,
77                'discount_percent' => $discountPercent,
78                'discount_amount' => $discountAmount,
79                'net_amount' => $netAmount,
80                'price_after_discount' => $priceAfterDiscount,
81                'purchase_cost' => $purchaseCost,
82                'margin_amount' => $marginAmount,
83                'margin_percent' => $marginPercent,
84                'tax_percent' => $taxPercent,
85                'tax_amount' => $taxAmount,
86                'gross_amount' => $grossAmount,
87            ]);
88        }
89
90        $totalMarginAmount = round($totalAfterDiscount - $totalPurchase, 2);
91        $totalMarginPercent = $totalAfterDiscount > 0.0
92            ? round(($totalMarginAmount / $totalAfterDiscount) * 100.0, 2)
93            : 0.0;
94
95        $currencyGrossBase = round($totalGross * $exchangeRate, 2);
96
97        return [
98            'items' => $calculatedItems,
99            'summary' => [
100                'total_net' => round($totalNet, 2),
101                'total_discount' => round($totalDiscount, 2),
102                'total_after_discount' => round($totalAfterDiscount, 2),
103                'total_purchase' => round($totalPurchase, 2),
104                'total_margin_amount' => $totalMarginAmount,
105                'total_margin_percent' => $totalMarginPercent,
106                'total_tax' => round($totalTax, 2),
107                'total_gross' => round($totalGross, 2),
108                'tax_breakdown' => $taxBreakdown,
109                'currency' => $currencyCode,
110                'exchange_rate' => $exchangeRate,
111                'currency_gross_base' => $currencyGrossBase,
112            ],
113        ];
114    }
115
116    /**
117     * Resolves active exchange rate for selected currency.
118     *
119     * @param array<string, mixed> $body
120     * @param string               $currencyCode
121     * @param list<array{code: string, name: string, symbol: string, exchange_rate: float}> $currencies
122     * @return float
123     */
124    public function resolveExchangeRate(array $body, string $currencyCode, array $currencies): float
125    {
126        $previousCurrency = strtoupper((string) ($body['previous_currency'] ?? ''));
127        if ($previousCurrency !== '' && $previousCurrency !== $currencyCode) {
128            return $this->findCurrencyRate($currencyCode, $currencies);
129        }
130
131        if (isset($body['exchange_rate']) && (float) $body['exchange_rate'] > 0.0) {
132            return (float) $body['exchange_rate'];
133        }
134
135        return $this->findCurrencyRate($currencyCode, $currencies);
136    }
137
138    /**
139     * @param list<array{code: string, name: string, symbol: string, exchange_rate: float}> $currencies
140     */
141    private function findCurrencyRate(string $currencyCode, array $currencies): float
142    {
143        foreach ($currencies as $curr) {
144            if ($curr['code'] === $currencyCode) {
145                return (float) $curr['exchange_rate'];
146            }
147        }
148
149        return 1.0;
150    }
151
152    /**
153     * Handles adding new product/service/group rows.
154     *
155     * @param array<string, mixed>             $body
156     * @param array<int, array<string, mixed>> $rawItems
157     * @return array<int, array<string, mixed>>
158     */
159    public function processRowMutations(array $body, array $rawItems): array
160    {
161        if (isset($body['remove_index'])) {
162            $removeIndex = (int) $body['remove_index'];
163            unset($rawItems[$removeIndex]);
164            return $this->reindexSortOrders(array_values($rawItems));
165        }
166
167        if (isset($body['delete_row'])) {
168            $deleteIdx = (int) $body['delete_row'];
169            unset($rawItems[$deleteIdx]);
170            return $this->reindexSortOrders(array_values($rawItems));
171        }
172
173        $targetGroup = isset($body['add_to_group']) ? trim((string) $body['add_to_group']) : '';
174
175        if (isset($body['add_product']) || isset($body['add_row'])) {
176            $newRow = $this->createDefaultItemRow(0, 'product');
177            $rawItems = $this->insertRowWithGroup($rawItems, $newRow, $targetGroup);
178        } elseif (isset($body['add_service'])) {
179            $newRow = $this->createDefaultItemRow(0, 'service');
180            $rawItems = $this->insertRowWithGroup($rawItems, $newRow, $targetGroup);
181        } elseif (isset($body['add_group'])) {
182            $rawItems = $this->addNewGroupRow($rawItems);
183        }
184
185        return $rawItems;
186    }
187
188    /**
189     * Creates default line item row structure.
190     *
191     * @param int    $sortOrder
192     * @param string $type
193     * @return array<string, mixed>
194     */
195    public function createDefaultItemRow(int $sortOrder = 10, string $type = 'product'): array
196    {
197        return [
198            'id' => null,
199            'sort_order' => $sortOrder,
200            'item_type' => $type,
201            'item_name' => '',
202            'sku' => '',
203            'description' => '',
204            'unit' => 'szt',
205            'quantity' => 1.0,
206            'unit_price' => 0.0,
207            'purchase_cost' => 0.0,
208            'discount_percent' => 0.0,
209            'discount_amount' => 0.0,
210            'tax_percent' => 23.0,
211            'tax_amount' => 0.0,
212            'net_amount' => 0.0,
213            'price_after_discount' => 0.0,
214            'margin_amount' => 0.0,
215            'margin_percent' => 0.0,
216            'gross_amount' => 0.0,
217            'comment' => '',
218            'group_name' => '',
219        ];
220    }
221
222    /**
223     * Inserts new row directly after the last row of the target group, or appends to table end.
224     *
225     * @param array<int, array<string, mixed>> $rawItems
226     * @param array<string, mixed>             $newRow
227     * @param string                           $targetGroup
228     * @return array<int, array<string, mixed>>
229     */
230    public function insertRowWithGroup(array $rawItems, array $newRow, string $targetGroup): array
231    {
232        if ($targetGroup !== '') {
233            $newRow['group_name'] = $targetGroup;
234            $insertIndex = -1;
235            foreach ($rawItems as $idx => $item) {
236                if (trim((string) ($item['group_name'] ?? '')) === $targetGroup) {
237                    $insertIndex = $idx;
238                }
239            }
240            if ($insertIndex >= 0) {
241                array_splice($rawItems, $insertIndex + 1, 0, [$newRow]);
242                return $this->reindexSortOrders($rawItems);
243            }
244        }
245
246        $rawItems[] = $newRow;
247        return $this->reindexSortOrders($rawItems);
248    }
249
250    /**
251     * Generates a new auto-incremented group header row and appends it to table end.
252     *
253     * @param array<int, array<string, mixed>> $rawItems
254     * @return array<int, array<string, mixed>>
255     */
256    public function addNewGroupRow(array $rawItems): array
257    {
258        $groupNum = 1;
259        foreach ($rawItems as $item) {
260            if (!empty($item['group_name']) && preg_match('/Grupa (\d+)/', (string) $item['group_name'], $m)) {
261                $groupNum = max($groupNum, ((int) $m[1]) + 1);
262            }
263        }
264        $newRow = $this->createDefaultItemRow(0, 'product');
265        $newRow['group_name'] = 'Grupa ' . $groupNum;
266        $rawItems[] = $newRow;
267        return $this->reindexSortOrders($rawItems);
268    }
269
270    /**
271     * Reindexes sort_order sequentially by 10s.
272     *
273     * @param array<int, array<string, mixed>> $rawItems
274     * @return array<int, array<string, mixed>>
275     */
276    public function reindexSortOrders(array $rawItems): array
277    {
278        $reindexed = [];
279        $sort = 10;
280        foreach ($rawItems as $item) {
281            $item['sort_order'] = $sort;
282            $reindexed[] = $item;
283            $sort += 10;
284        }
285        return $reindexed;
286    }
287}