Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 94
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
CliController
0.00% covered (danger)
0.00%
0 / 94
0.00% covered (danger)
0.00%
0 / 8
3192
0.00% covered (danger)
0.00%
0 / 1
 cliParseSupplierBill
n/a
0 / 0
n/a
0 / 0
2
 detectLanguage
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 findSupplierInvoiceType
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 findBillNumber
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 findBillDue
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 findBillDate
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 findBillGross
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
72
 matchSupplier
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
272
 parseDate
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
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 */
13declare(strict_types=1);
14
15namespace Modules\Billing\Controller;
16
17use Modules\Billing\Models\BillMapper;
18use Modules\Billing\Models\BillTypeMapper;
19use Modules\Billing\Models\NullBillType;
20use Modules\Billing\Models\SettingsEnum;
21use Modules\Payment\Models\PaymentType;
22use Modules\SupplierManagement\Models\NullSupplier;
23use Modules\SupplierManagement\Models\Supplier;
24use Modules\SupplierManagement\Models\SupplierMapper;
25use phpOMS\Contract\RenderableInterface;
26use phpOMS\Localization\LanguageDetection\Language;
27use phpOMS\Message\RequestAbstract;
28use phpOMS\Message\ResponseAbstract;
29use phpOMS\Stdlib\Base\FloatInt;
30use 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 */
40final 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}