Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 94 |
|
0.00% |
0 / 8 |
CRAP | |
0.00% |
0 / 1 |
CliController | |
0.00% |
0 / 94 |
|
0.00% |
0 / 8 |
3192 | |
0.00% |
0 / 1 |
cliParseSupplierBill | n/a |
0 / 0 |
n/a |
0 / 0 |
2 | |||||
detectLanguage | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
findSupplierInvoiceType | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
42 | |||
findBillNumber | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
30 | |||
findBillDue | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
30 | |||
findBillDate | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
30 | |||
findBillGross | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
72 | |||
matchSupplier | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
272 | |||
parseDate | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
42 |
1 | <?php |
2 | /** |
3 | * Jingga |
4 | * |
5 | * PHP Version 8.1 |
6 | * |
7 | * @package Modules\Billing |
8 | * @copyright Dennis Eichhorn |
9 | * @license OMS License 2.0 |
10 | * @version 1.0.0 |
11 | * @link https://jingga.app |
12 | */ |
13 | declare(strict_types=1); |
14 | |
15 | namespace Modules\Billing\Controller; |
16 | |
17 | use Modules\Billing\Models\BillMapper; |
18 | use Modules\Billing\Models\BillTypeMapper; |
19 | use Modules\Billing\Models\NullBillType; |
20 | use Modules\Billing\Models\SettingsEnum; |
21 | use Modules\Payment\Models\PaymentType; |
22 | use Modules\SupplierManagement\Models\NullSupplier; |
23 | use Modules\SupplierManagement\Models\Supplier; |
24 | use Modules\SupplierManagement\Models\SupplierMapper; |
25 | use phpOMS\Contract\RenderableInterface; |
26 | use phpOMS\Localization\LanguageDetection\Language; |
27 | use phpOMS\Message\RequestAbstract; |
28 | use phpOMS\Message\ResponseAbstract; |
29 | use phpOMS\Stdlib\Base\FloatInt; |
30 | use phpOMS\Views\View; |
31 | |
32 | /** |
33 | * Billing controller class. |
34 | * |
35 | * @package Modules\Billing |
36 | * @license OMS License 2.0 |
37 | * @link https://jingga.app |
38 | * @since 1.0.0 |
39 | */ |
40 | final class CliController extends Controller |
41 | { |
42 | /** |
43 | * Analyse supplier bill |
44 | * |
45 | * @param RequestAbstract $request Request |
46 | * @param ResponseAbstract $response Response |
47 | * @param array $data Generic data |
48 | * |
49 | * @return RenderableInterface Response can be rendered |
50 | * |
51 | * @since 1.0.0 |
52 | * @codeCoverageIgnore |
53 | */ |
54 | public function cliParseSupplierBill(RequestAbstract $request, ResponseAbstract $response, array $data = []) : RenderableInterface |
55 | { |
56 | /** @var \Model\Setting $setting */ |
57 | $setting = $this->app->appSettings->get( |
58 | names: SettingsEnum::ORIGINAL_MEDIA_TYPE, |
59 | module: self::NAME |
60 | ); |
61 | |
62 | $originalType = $request->getDataInt('type') ?? (int) $setting->content; |
63 | |
64 | /** @var \Modules\Billing\Models\Bill $bill */ |
65 | $bill = BillMapper::get() |
66 | ->with('media') |
67 | ->with('media/types') |
68 | ->with('media/content') |
69 | ->where('id', (int) $request->getData('i')) |
70 | ->where('media/types/id', $originalType) |
71 | ->execute(); |
72 | |
73 | $old = clone $bill; |
74 | |
75 | $content = \strtolower($bill->getFileByType($originalType)->content->content ?? ''); |
76 | $lines = \explode("\n", $content); |
77 | |
78 | $language = $this->detectLanguage($content); |
79 | $bill->language = $language; |
80 | |
81 | $identifierContent = \file_get_contents(__DIR__ . '/../Models/billIdentifier.json'); |
82 | if ($identifierContent === false) { |
83 | $identifierContent = '{}'; |
84 | } |
85 | |
86 | /** @var array $identifiers */ |
87 | $identifiers = \json_decode($identifierContent, true); |
88 | |
89 | /* Supplier */ |
90 | /** @var \Modules\SupplierManagement\Models\Supplier[] $suppliers */ |
91 | $suppliers = SupplierMapper::getAll() |
92 | ->with('account') |
93 | ->with('mainAddress') |
94 | ->with('attributes/type') |
95 | ->where('attributes/type/name', ['bill_match_pattern', 'bill_date_format'], 'IN') |
96 | ->execute(); |
97 | |
98 | $supplierId = $this->matchSupplier($content, $suppliers); |
99 | $bill->supplier = new NullSupplier($supplierId); |
100 | $supplier = $suppliers[$supplierId] ?? new NullSupplier(); |
101 | |
102 | $bill->billTo = $supplier->account->name1; |
103 | $bill->billAddress = $supplier->mainAddress->address; |
104 | $bill->billCity = $supplier->mainAddress->city; |
105 | $bill->billZip = $supplier->mainAddress->postal; |
106 | $bill->billCountry = $supplier->mainAddress->getCountry(); |
107 | |
108 | /* Type */ |
109 | $type = $this->findSupplierInvoiceType($content, $identifiers['type'], $language); |
110 | |
111 | /** @var \Modules\Billing\Models\BillType $billType */ |
112 | $billType = BillTypeMapper::get() |
113 | ->where('name', $type) |
114 | ->execute(); |
115 | |
116 | $bill->type = new NullBillType($billType->id); |
117 | |
118 | /* Number */ |
119 | $billNumber = $this->findBillNumber($lines, $identifiers['bill_no'][$language]); |
120 | $bill->number = $billNumber; |
121 | |
122 | /* Date */ |
123 | $billDateTemp = $this->findBillDate($lines, $identifiers['bill_date'][$language]); |
124 | $billDate = $this->parseDate($billDateTemp, $supplier, $identifiers['date_format']); |
125 | |
126 | $bill->billDate = $billDate; |
127 | |
128 | /* Due */ |
129 | $billDueTemp = $this->findBillDue($lines, $identifiers['bill_date'][$language]); |
130 | $billDue = $this->parseDate($billDueTemp, $supplier, $identifiers['date_format']); |
131 | // @todo: implement multiple due dates for bills |
132 | |
133 | /* Total Gross */ |
134 | $totalGross = $this->findBillGross($lines, $identifiers['total_gross'][$language]); |
135 | $bill->grossCosts = new FloatInt($totalGross); |
136 | |
137 | $this->updateModel($request->header->account, $old, $bill, BillMapper::class, 'bill_parsing', $request->getOrigin()); |
138 | |
139 | $view = new View($this->app->l11nManager, $request, $response); |
140 | $view->setTemplate('/Modules/Billing/Theme/Cli/bill-parsed'); |
141 | $view->data['bill'] = $bill; |
142 | |
143 | return $view; |
144 | } |
145 | |
146 | /** |
147 | * Detect language from content |
148 | * |
149 | * @param string $content String to analyze |
150 | * |
151 | * @return string |
152 | * |
153 | * @since 1.0.0 |
154 | */ |
155 | private function detectLanguage(string $content) : string |
156 | { |
157 | $detector = new Language(); |
158 | $language = $detector->detect($content)->bestResults()->close(); |
159 | |
160 | if (!\is_array($language) || \count($language) < 1) { |
161 | return 'en'; |
162 | } |
163 | |
164 | return \substr(\array_keys($language)[0], 0, 2); |
165 | } |
166 | |
167 | /** |
168 | * Detect the supplier bill type |
169 | * |
170 | * @param string $content String to analyze |
171 | * @param array $types Possible bill types |
172 | * @param string $language Bill language |
173 | * |
174 | * @return string |
175 | * |
176 | * @since 1.0.0 |
177 | */ |
178 | private function findSupplierInvoiceType(string $content, array $types, string $language) : string |
179 | { |
180 | $bestPos = \strlen($content); |
181 | $bestMatch = ''; |
182 | |
183 | foreach ($types as $name => $type) { |
184 | foreach ($type[$language] as $l11n) { |
185 | $found = \stripos($content, \strtolower($l11n)); |
186 | |
187 | if ($found !== false && $found < $bestPos) { |
188 | $bestPos = $found; |
189 | $bestMatch = $name; |
190 | } |
191 | } |
192 | } |
193 | |
194 | return empty($bestMatch) ? 'purchase_invoice' : $bestMatch; |
195 | } |
196 | |
197 | /** |
198 | * Detect the supplier bill number |
199 | * |
200 | * @param string[] $lines Bill lines |
201 | * @param array $matches Number match patterns |
202 | * |
203 | * @return string |
204 | * |
205 | * @since 1.0.0 |
206 | */ |
207 | private function findBillNumber(array $lines, array $matches) : string |
208 | { |
209 | $bestPos = \count($lines); |
210 | $bestMatch = ''; |
211 | |
212 | $found = []; |
213 | |
214 | foreach ($matches as $match) { |
215 | foreach ($lines as $row => $line) { |
216 | if (\preg_match($match, $line, $found) === 1) { |
217 | if ($row < $bestPos) { |
218 | $bestPos = $row; |
219 | $bestMatch = \trim($found['bill_no']); |
220 | } |
221 | |
222 | break; |
223 | } |
224 | } |
225 | } |
226 | |
227 | return $bestMatch; |
228 | } |
229 | |
230 | /** |
231 | * Detect the supplier bill due date |
232 | * |
233 | * @param string[] $lines Bill lines |
234 | * @param array $matches Due match patterns |
235 | * |
236 | * @return string |
237 | * |
238 | * @since 1.0.0 |
239 | */ |
240 | private function findBillDue(array $lines, array $matches) : string |
241 | { |
242 | $bestPos = \count($lines); |
243 | $bestMatch = ''; |
244 | |
245 | $found = []; |
246 | |
247 | foreach ($matches as $match) { |
248 | foreach ($lines as $row => $line) { |
249 | if (\preg_match($match, $line, $found) === 1) { |
250 | if ($row < $bestPos) { |
251 | // @todo: don't many invoices have the due date at the bottom? bestPos doesn't make sense?! |
252 | $bestPos = $row; |
253 | $bestMatch = \trim($found['bill_due']); |
254 | } |
255 | |
256 | break; |
257 | } |
258 | } |
259 | } |
260 | |
261 | return $bestMatch; |
262 | } |
263 | |
264 | /** |
265 | * Detect the supplier bill date |
266 | * |
267 | * @param string[] $lines Bill lines |
268 | * @param array $matches Date match patterns |
269 | * |
270 | * @return string |
271 | * |
272 | * @since 1.0.0 |
273 | */ |
274 | private function findBillDate(array $lines, array $matches) : string |
275 | { |
276 | $bestPos = \count($lines); |
277 | $bestMatch = ''; |
278 | |
279 | $found = []; |
280 | |
281 | foreach ($matches as $match) { |
282 | foreach ($lines as $row => $line) { |
283 | if (\preg_match($match, $line, $found) === 1) { |
284 | if ($row < $bestPos) { |
285 | $bestPos = $row; |
286 | $bestMatch = \trim($found['bill_date']); |
287 | } |
288 | |
289 | break; |
290 | } |
291 | } |
292 | } |
293 | |
294 | return $bestMatch; |
295 | } |
296 | |
297 | /** |
298 | * Detect the supplier bill gross amount |
299 | * |
300 | * @param string[] $lines Bill lines |
301 | * @param array $matches Gross match patterns |
302 | * |
303 | * @return int |
304 | * |
305 | * @since 1.0.0 |
306 | */ |
307 | private function findBillGross(array $lines, array $matches) : int |
308 | { |
309 | $bestMatch = 0; |
310 | |
311 | $found = []; |
312 | |
313 | foreach ($matches as $match) { |
314 | foreach ($lines as $line) { |
315 | if (\preg_match($match, $line, $found) === 1) { |
316 | $temp = \trim($found['total_gross']); |
317 | |
318 | $posD = \stripos($temp, '.'); |
319 | $posK = \stripos($temp, ','); |
320 | |
321 | $hasDecimal = ($posD !== false || $posK !== false) |
322 | && \max((int) $posD, (int) $posK) + 3 >= \strlen($temp); |
323 | |
324 | $gross = ((int) \str_replace(['.', ','], ['', ''], $temp)) * ($hasDecimal |
325 | ? 100 |
326 | : 10000); |
327 | |
328 | if ($gross > $bestMatch) { |
329 | $bestMatch = $gross; |
330 | } |
331 | } |
332 | } |
333 | } |
334 | |
335 | return $bestMatch; |
336 | } |
337 | |
338 | /** |
339 | * Find possible supplier id |
340 | * |
341 | * Priorities: |
342 | * 1. bill_match_pattern |
343 | * 2. name1 + iban |
344 | * 3. name1 + city || address |
345 | * 4. name1 |
346 | * |
347 | * @param string $content Content to analyze |
348 | * @param Supplier[] $suppliers Suppliers |
349 | * |
350 | * @return int |
351 | * |
352 | * @since 1.0.0 |
353 | */ |
354 | private function matchSupplier(string $content, array $suppliers) : int |
355 | { |
356 | // bill_match_pattern |
357 | foreach ($suppliers as $supplier) { |
358 | // @todo: consider to support regex? |
359 | if ((!empty($supplier->getAttribute('bill_match_pattern')->value->valueStr) |
360 | && \stripos($content, $supplier->getAttribute('bill_match_pattern')->value->valueStr) !== false) |
361 | ) { |
362 | return $supplier->id; |
363 | } |
364 | } |
365 | |
366 | // name1 + iban |
367 | foreach ($suppliers as $supplier) { |
368 | if (\stripos($content, $supplier->account->name1) !== false) { |
369 | $ibans = $supplier->getPaymentsByType(PaymentType::SWIFT); |
370 | foreach ($ibans as $iban) { |
371 | if (\stripos($content, $iban->content2) !== false) { |
372 | return $supplier->id; |
373 | } |
374 | } |
375 | } |
376 | } |
377 | |
378 | // name1 + city || address |
379 | foreach ($suppliers as $supplier) { |
380 | if (\stripos($content, $supplier->account->name1) !== false |
381 | && ((!empty($supplier->mainAddress->city) |
382 | && \stripos($content, $supplier->mainAddress->city) !== false) |
383 | || (!empty($supplier->mainAddress->address) |
384 | && \stripos($content, $supplier->mainAddress->address) !== false) |
385 | ) |
386 | ) { |
387 | return $supplier->id; |
388 | } |
389 | } |
390 | |
391 | // name1 |
392 | foreach ($suppliers as $supplier) { |
393 | if (\stripos($content, $supplier->account->name1) !== false) { |
394 | return $supplier->id; |
395 | } |
396 | } |
397 | |
398 | return 0; |
399 | } |
400 | |
401 | /** |
402 | * Create DateTime from date string |
403 | * |
404 | * @param string $date Date string |
405 | * @param Supplier $supplier Supplier |
406 | * @param string[] $formats Date formats |
407 | * |
408 | * @return null|\DateTime |
409 | * |
410 | * @since 1.0.0 |
411 | */ |
412 | private function parseDate(string $date, Supplier $supplier, array $formats) : ?\DateTime |
413 | { |
414 | if ((!empty($supplier->getAttribute('bill_date_format')->value->valueStr))) { |
415 | $dt = \DateTime::createFromFormat( |
416 | $supplier->getAttribute('bill_date_format')->value->valueStr ?? '', |
417 | $date |
418 | ); |
419 | |
420 | return $dt === false ? new \DateTime('1970-01-01') : $dt; |
421 | } |
422 | |
423 | foreach ($formats as $format) { |
424 | if (($obj = \DateTime::createFromFormat($format, $date)) !== false) { |
425 | return $obj === false ? null : $obj; |
426 | } |
427 | } |
428 | |
429 | return null; |
430 | } |
431 | } |