diff --git a/.github/dev_bug_report.md b/.github/dev_bug_report.md deleted file mode 100755 index ef93e56..0000000 --- a/.github/dev_bug_report.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: Dev Bug Report -about: Create a report to help us improve -title: '' -labels: stat_backlog, type_bug -assignees: '' - ---- - -# Bug Description -A clear and concise description of what the bug is. - -# How to Reproduce - -Steps to reproduce the behavior: - -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -## Minimal Code Example - -``` -// your code ... -``` - -# Expected Behavior -A clear and concise description of what you expected to happen. - -# Screenshots -If applicable, add screenshots to help explain your problem. - -# Additional Information -Add any other context about the problem here. diff --git a/.github/dev_feature_request.md b/.github/dev_feature_request.md deleted file mode 100755 index 9573c35..0000000 --- a/.github/dev_feature_request.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: Dev Feature Request -about: Suggest an idea for this project -title: '' -labels: stat_backlog, type_feature -assignees: '' - ---- - -# What is the feature you request -* A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -* A clear and concise description of what you want to happen. - -# Alternatives -A clear and concise description of any alternative solutions or features you've considered. - -# Additional Information -Add any other context or screenshots about the feature request here. diff --git a/Admin/Install/Admin.install.json b/Admin/Install/Admin.install.json deleted file mode 100755 index 1b8d939..0000000 --- a/Admin/Install/Admin.install.json +++ /dev/null @@ -1,9 +0,0 @@ -[ - { - "type": "setting", - "name": "1005100003", - "content": "[\"en\", \"de\"]", - "pattern": "", - "module": "Billing" - } -] \ No newline at end of file diff --git a/Admin/Install/Admin.install.php b/Admin/Install/Admin.install.php new file mode 100644 index 0000000..0fd52fb --- /dev/null +++ b/Admin/Install/Admin.install.php @@ -0,0 +1,26 @@ + 'setting', + 'name' => SettingsEnum::VALID_BILL_LANGUAGES, + 'content' => '["en","de"]', + 'pattern' => '', + 'module' => ApiController::NAME, + ], +]; diff --git a/Admin/Install/Admin.php b/Admin/Install/Admin.php index ea8cd1f..a21cb13 100755 --- a/Admin/Install/Admin.php +++ b/Admin/Install/Admin.php @@ -38,6 +38,6 @@ class Admin */ public static function install(ApplicationAbstract $app, string $path) : void { - \Modules\Admin\Admin\Installer::installExternal($app, ['path' => __DIR__ . '/Admin.install.json']); + \Modules\Admin\Admin\Installer::installExternal($app, ['path' => __DIR__ . '/Admin.install.php']); } } diff --git a/Admin/Install/Media.install.json b/Admin/Install/Media.install.json index 2f42254..9f59e9c 100755 --- a/Admin/Install/Media.install.json +++ b/Admin/Install/Media.install.json @@ -36,14 +36,28 @@ }, { "type": "type", - "name": "original", + "name": "internal", "l11n": [ { - "title": "Original", + "title": "Internal", "lang": "en" }, { - "title": "Original", + "title": "Intern", + "lang": "de" + } + ] + }, + { + "type": "type", + "name": "external", + "l11n": [ + { + "title": "External", + "lang": "en" + }, + { + "title": "Extern", "lang": "de" } ] diff --git a/Admin/Install/Media.php b/Admin/Install/Media.php index 274c55a..3dae6c2 100755 --- a/Admin/Install/Media.php +++ b/Admin/Install/Media.php @@ -54,11 +54,18 @@ class Media ], [ 'type' => 'setting', - 'name' => SettingsEnum::ORIGINAL_MEDIA_TYPE, + 'name' => SettingsEnum::INTERNAL_MEDIA_TYPE, 'content' => (string) $media['type'][1]['id'], 'pattern' => '\\d+', 'module' => 'Billing', ], + [ + 'type' => 'setting', + 'name' => SettingsEnum::EXTERNAL_MEDIA_TYPE, + 'content' => (string) $media['type'][2]['id'], + 'pattern' => '\\d+', + 'module' => 'Billing', + ], ], ] ); diff --git a/Admin/Install/Media/bill.pdf.php b/Admin/Install/Media/bill.pdf.php index b78e14c..0027d7d 100755 --- a/Admin/Install/Media/bill.pdf.php +++ b/Admin/Install/Media/bill.pdf.php @@ -15,6 +15,7 @@ declare(strict_types=1); use Modules\Billing\Models\NullBill; use phpOMS\Localization\ISO3166NameEnum; use phpOMS\Localization\Money; +use phpOMS\Stdlib\Base\FloatInt; /** @var \phpOMS\Views\View $this */ @@ -118,7 +119,7 @@ $pdf->setTextColor(255, 162, 7); $pdf->setXY($rightPos, $tempY = $pdf->getY() + 10, true); $pdf->MultiCell( - 26, 30, + 29, 30, $lang[$pdf->language]['InvoiceNo'] . "\n" . $lang[$pdf->language]['InvoiceDate'] . "\n" . $lang[$pdf->language]['ServiceDate'] . "\n" @@ -131,20 +132,22 @@ $pdf->MultiCell( //$pdf->setFont('helvetica', '', 10); $pdf->setTextColor(0, 0, 0); -$pdf->setXY($rightPos + 26 + 2, $tempY, true); +$pdf->setXY($rightPos + 29 + 2, $tempY, true); $pdf->MultiCell( 25, 30, $bill->number . "\n" . ($bill->billDate?->format('Y-m-d') ?? '0') . "\n" . ($bill->performanceDate?->format('Y-m-d') ?? '0') . "\n" . $bill->accountNumber . "\n" - . $bill->externalReferral . "\n" + . $bill->external . "\n" . ($bill->billDate?->format('Y-m-d') ?? '0'), /* Consider to add dueDate in addition */ 0, 'L' ); $pdf->Ln(); -$pdf->setY($pdf->getY() - 20); +$tempY = $pdf->getY(); +$height = 0; +$pdf->setY($tempY - 20); $header = [ $lang[$pdf->language]['Item'], @@ -153,8 +156,6 @@ $header = [ $lang[$pdf->language]['Total'], ]; -$lines = $bill->elements; - // Header $headerCount = \count($header); $w = [$pageWidth - 20 - 20 - 20 - 2 * 15, 20, 20, 20]; @@ -166,7 +167,7 @@ $first = true; // Data $fill = false; -foreach($lines as $line) { +foreach($bill->elements as $line) { // @todo depending on amount of lines, there is a solution (html, or use backtracking of tcpdf) if ($first || $pdf->getY() > $pageHeight - 40) { $pdf->setFillColor(255, 162, 7); @@ -218,87 +219,93 @@ foreach($lines as $line) { $fill = !$fill; // get taxes - if (!isset($taxes[$line->taxR->value / 10000])) { - $taxes[$line->taxR->value / 10000] = $line->taxP; + if (!isset($taxes[$line->taxR->value / FloatInt::DIVISOR])) { + $taxes[$line->taxR->value / FloatInt::DIVISOR] = $line->taxP; } else { - $taxes[$line->taxR->value / 10000]->add($line->taxP); + $taxes[$line->taxR->value / FloatInt::DIVISOR]->add($line->taxP); } } -$pdf->Cell(\array_sum($w), 0, '', 'T'); -$pdf->Ln(); +// We have to do the following because in some cases it doesn't set the Y position correctly after the table +// I assume it is related to if/else above. A html multicell might not correctly set the y position. +if (!empty($bill->elements)) { + $pdf->setY($tempY + $height); -if ($pdf->getY() > $pageHeight - 40) { - $pdf->AddPage(); -} + $pdf->Cell(\array_sum($w), 0, '', 'T'); + $pdf->Ln(); -$pdf->setFillColor(240, 240, 240); -$pdf->setTextColor(0); -$pdf->setDrawColor(240, 240, 240); -$pdf->setFont('helvetica', 'B', 10); + if ($pdf->getY() > $pageHeight - 40) { + $pdf->AddPage(); + } -$tempY = $pdf->getY(); + $pdf->setFillColor(240, 240, 240); + $pdf->setTextColor(0); + $pdf->setDrawColor(240, 240, 240); + $pdf->setFont('helvetica', 'B', 10); -$netSales = Money::fromFloatInt($bill->netSales); + $tempY = $pdf->getY(); -$pdf->setX($w[0] + $w[1] + 12); -$pdf->Cell($w[2], 7, $lang[$pdf->language]['Subtotal'], 0, 0, 'L', false); -$pdf->Cell($w[3], 7, $netSales->getCurrency(2, symbol: ''), 0, 0, 'L', false); -$pdf->Ln(); - -foreach ($taxes as $rate => $tax) { - $tax = Money::fromFloatInt($tax); + $netSales = Money::fromFloatInt($bill->netSales); $pdf->setX($w[0] + $w[1] + 12); - $pdf->Cell($w[2], 7, $lang[$pdf->language]['Taxes'] . ' (' . $rate . '%)', 0, 0, 'L', false); - $pdf->Cell($w[3], 7, $tax->getCurrency(2, symbol: ''), 0, 0, 'L', false); + $pdf->Cell($w[2], 7, $lang[$pdf->language]['Subtotal'], 0, 0, 'L', false); + $pdf->Cell($w[3], 7, $netSales->getCurrency(2, symbol: ''), 0, 0, 'L', false); $pdf->Ln(); + + foreach ($taxes as $rate => $tax) { + $tax = Money::fromFloatInt($tax); + + $pdf->setX($w[0] + $w[1] + 12); + $pdf->Cell($w[2], 7, $lang[$pdf->language]['Taxes'] . ' (' . $rate . '%)', 0, 0, 'L', false); + $pdf->Cell($w[3], 7, $tax->getCurrency(2, symbol: ''), 0, 0, 'L', false); + $pdf->Ln(); + } + + $pdf->setFillColor(255, 162, 7); + $pdf->setTextColor(255); + $pdf->setDrawColor(255, 162, 7); + //$pdf->setFont('helvetica', 'B', 10); + + $grossSales = Money::fromFloatInt($bill->grossSales); + + $pdf->setX($w[0] + $w[1] + 12); + $pdf->Cell($w[2], 7, \strtoupper($lang[$pdf->language]['Total']), 1, 0, 'L', true); + $pdf->Cell($w[3] + 3, 7, $grossSales->getCurrency(2, symbol: ''), 1, 0, 'L', true); + $pdf->Ln(); + + $tempY2 = $pdf->getY(); + + // @todo fix payment terms + $pdf->setTextColor(0); + $pdf->setFont('helvetica', 'B', 8); + $pdf->setY($tempY); + $pdf->Write(0, $lang[$pdf->language]['PaymentTerms'] . ': CreditCard', '', false, 'L', false, 0, false, false, 0); + + $pdf->setFont('helvetica', '', 8); + $pdf->Write(0, $bill->paymentText, '', false, 'L', false, 0, false, false, 0); + $pdf->Ln(); + + // @todo fix terms + $pdf->setFont('helvetica', 'B', 8); + $pdf->Write(0, $lang[$pdf->language]['Terms'] . ': ' . $pdf->attributes['terms'], '', false, 'L', false, 0, false, false, 0); + $pdf->Ln(); + + //$pdf->setFont('helvetica', 'B', 8); + $pdf->Write(0, $lang[$pdf->language]['Currency'] . ': ' . $bill->currency, '', false, 'L', false, 0, false, false, 0); + $pdf->Ln(); + + //$pdf->setFont('helvetica', 'B', 8); + $pdf->Write(0, $lang[$pdf->language]['TaxRemark'], '', false, 'L', false, 0, false, false, 0); + $pdf->Ln(); + + $pdf->setFont('helvetica', '', 8); + $pdf->Write(0, $bill->termsText, '', false, 'L', false, 0, false, false, 0); + //$pdf->Ln(); + + //$pdf->setY($tempY2); + //$pdf->Ln(); } -$pdf->setFillColor(255, 162, 7); -$pdf->setTextColor(255); -$pdf->setDrawColor(255, 162, 7); -//$pdf->setFont('helvetica', 'B', 10); - -$grossSales = Money::fromFloatInt($bill->grossSales); - -$pdf->setX($w[0] + $w[1] + 12); -$pdf->Cell($w[2], 7, \strtoupper($lang[$pdf->language]['Total']), 1, 0, 'L', true); -$pdf->Cell($w[3] + 3, 7, $grossSales->getCurrency(2, symbol: ''), 1, 0, 'L', true); -$pdf->Ln(); - -$tempY2 = $pdf->getY(); - -// @todo fix payment terms -$pdf->setTextColor(0); -$pdf->setFont('helvetica', 'B', 8); -$pdf->setY($tempY); -$pdf->Write(0, $lang[$pdf->language]['PaymentTerms'] . ': CreditCard', '', false, 'L', false, 0, false, false, 0); - -$pdf->setFont('helvetica', '', 8); -$pdf->Write(0, $bill->paymentText, '', false, 'L', false, 0, false, false, 0); -$pdf->Ln(); - -// @todo fix terms -$pdf->setFont('helvetica', 'B', 8); -$pdf->Write(0, $lang[$pdf->language]['Terms'] . ': ' . $pdf->attributes['terms'], '', false, 'L', false, 0, false, false, 0); -$pdf->Ln(); - -//$pdf->setFont('helvetica', 'B', 8); -$pdf->Write(0, $lang[$pdf->language]['Currency'] . ': ' . $bill->currency, '', false, 'L', false, 0, false, false, 0); -$pdf->Ln(); - -//$pdf->setFont('helvetica', 'B', 8); -$pdf->Write(0, $lang[$pdf->language]['TaxRemark'], '', false, 'L', false, 0, false, false, 0); -$pdf->Ln(); - -$pdf->setFont('helvetica', '', 8); -$pdf->Write(0, $bill->termsText, '', false, 'L', false, 0, false, false, 0); -//$pdf->Ln(); - -//$pdf->setY($tempY2); -//$pdf->Ln(); - //Close and output PDF document $path = (string) ($this->data['path'] ?? (($bill->billDate?->format('Y-m-d') ?? '0') . '_' . $bill->number . '.pdf')); $pdf->Output($path, 'I'); diff --git a/Admin/Install/Messages.install.json b/Admin/Install/Messages.install.json index 4fb63f1..2a50782 100755 --- a/Admin/Install/Messages.install.json +++ b/Admin/Install/Messages.install.json @@ -15,7 +15,28 @@ "de": { "subject": "Rechnungsstellung", "body": "Abrechnung

Abrechnung

Sehr geehrte/r {user_name},

Vielen Dank für Ihre Geschäftsbeziehung mit uns.

Im Anhang finden Sie Ihre Rechnung.

Jingga e.K. - www.jingga.app - CEO Dennis Eichhorn - Amtsgericht Friedberg HRA 5058

", - "bodyalt": "Sehr geehrte/r {user_name},\n\nvielen Dank für Ihre Geschäftsbeziehung mit uns.\n\nIm Anhang finden Sie freundlicherweise Ihre Rechnung.\n\n\nJingga e.K. - www.jingga.app - CEO Dennis Eichhorn - Amtsgericht Friedberg HRA 5058" + "bodyalt": "Sehr geehrte/r {user_name},\n\nVielen Dank für Ihre Geschäftsbeziehung mit uns.\n\nIm Anhang finden Sie freundlicherweise Ihre Rechnung.\n\n\nJingga e.K. - www.jingga.app - CEO Dennis Eichhorn - Amtsgericht Friedberg HRA 5058" + } + }, + "send": false + }, + { + "type": "email_template", + "from": "", + "to": "", + "cc": "", + "bcc": "", + "ishtml": true, + "l11n": { + "en": { + "subject": "Order", + "body": "Order

Order

Dear {user_name},

We are looking forward to doing business with you.

Attached kindly find our order.

Jingga e.K. - www.jingga.app - CEO Dennis Eichhorn - Amtsgericht Friedberg HRA 5058

", + "bodyalt": "Dear {user_name},\n\nWe are looking forward to doing business with you.\n\nAttached kindly find our order.\n\n\nJingga e.K. - www.jingga.app - CEO Dennis Eichhorn - Amtsgericht Friedberg HRA 5058" + }, + "de": { + "subject": "Bestellung", + "body": "Bestellung

Bestellung

Sehr geehrte/r {user_name},

Wir freuen uns eine Bestellung bei Ihnen aufgeben zu können.

Im Anhang finden Sie unsere Bestellung.

Jingga e.K. - www.jingga.app - CEO Dennis Eichhorn - Amtsgericht Friedberg HRA 5058

", + "bodyalt": "Sehr geehrte/r {user_name},\n\nWir freuen uns eine Bestellung bei Ihnen aufgeben zu können.\n\nIm Anhang finden Sie unsere Bestellung.\n\n\nJingga e.K. - www.jingga.app - CEO Dennis Eichhorn - Amtsgericht Friedberg HRA 5058" } }, "send": false diff --git a/Admin/Install/Messages.php b/Admin/Install/Messages.php index 36ed926..670cff7 100755 --- a/Admin/Install/Messages.php +++ b/Admin/Install/Messages.php @@ -53,6 +53,12 @@ class Messages 'content' => (string) $messages['email_template'][0]['id'], 'module' => 'Billing', ], + [ + 'id' => null, + 'name' => SettingsEnum::BILLING_SUPPLIER_EMAIL_TEMPLATE, + 'content' => (string) $messages['email_template'][1]['id'], + 'module' => 'Billing', + ], ]; $response = new HttpResponse(); diff --git a/Admin/Install/Navigation.install.json b/Admin/Install/Navigation.install.json index 234e57b..61df907 100755 --- a/Admin/Install/Navigation.install.json +++ b/Admin/Install/Navigation.install.json @@ -5,7 +5,7 @@ "type": 2, "subtype": 1, "name": "Billing", - "uri": "{/base}/sales/bill/list", + "uri": "{/base}/sales/bill/list?{?}", "target": "self", "icon": null, "order": 10, @@ -19,7 +19,7 @@ "type": 3, "subtype": 1, "name": "Open", - "uri": "{/base}/sales/bill/list", + "uri": "{/base}/sales/bill/list?{?}", "target": "self", "icon": null, "order": 1, @@ -34,7 +34,7 @@ "type": 3, "subtype": 1, "name": "Archive", - "uri": "{/base}/sales/bill/archive", + "uri": "{/base}/sales/bill/archive?{?}", "target": "self", "icon": null, "order": 5, @@ -96,7 +96,7 @@ "type": 2, "subtype": 1, "name": "Billing", - "uri": "{/base}/purchase/bill/list", + "uri": "{/base}/purchase/bill/list?{?}", "target": "self", "icon": null, "order": 10, @@ -110,7 +110,7 @@ "type": 3, "subtype": 1, "name": "Open", - "uri": "{/base}/purchase/bill/list", + "uri": "{/base}/purchase/bill/list?{?}", "target": "self", "icon": null, "order": 1, @@ -125,7 +125,7 @@ "type": 3, "subtype": 1, "name": "Archive", - "uri": "{/base}/purchase/bill/archive", + "uri": "{/base}/purchase/bill/archive?{?}", "target": "self", "icon": null, "order": 5, @@ -172,7 +172,7 @@ "type": 2, "subtype": 1, "name": "Billing", - "uri": "{/base}/warehouse/bill/list", + "uri": "{/base}/warehouse/bill/list?{?}", "target": "self", "icon": null, "order": 5, @@ -186,7 +186,7 @@ "type": 3, "subtype": 1, "name": "List", - "uri": "{/base}/warehouse/bill/list", + "uri": "{/base}/warehouse/bill/list?{?}", "target": "self", "icon": null, "order": 1, @@ -201,7 +201,7 @@ "type": 3, "subtype": 1, "name": "Archive", - "uri": "{/base}/warehouse/bill/archive", + "uri": "{/base}/warehouse/bill/archive?{?}", "target": "self", "icon": null, "order": 1, @@ -233,7 +233,7 @@ "type": 3, "subtype": 1, "name": "Bill", - "uri": "{/base}/purchase/analysis/bill", + "uri": "{/base}/purchase/analysis/bill?{?}", "target": "self", "icon": null, "order": 15, diff --git a/Admin/Install/db.json b/Admin/Install/db.json index f47abf1..54e44ca 100755 --- a/Admin/Install/db.json +++ b/Admin/Install/db.json @@ -242,6 +242,11 @@ "type": "TINYINT(1)", "null": false }, + "billing_price_status": { + "name": "billing_price_status", + "type": "TINYINT(1)", + "null": false + }, "billing_price_quantity": { "name": "billing_price_quantity", "type": "BIGINT", @@ -440,6 +445,12 @@ "type": "TINYINT(1)", "null": false }, + "billing_type_email": { + "description": "send email on archive", + "name": "billing_type_email", + "type": "TINYINT(1)", + "null": false + }, "billing_type_account_format": { "name": "billing_type_account_format", "type": "VARCHAR(255)", @@ -535,6 +546,11 @@ "type": "VARCHAR(255)", "null": false }, + "billing_bill_external": { + "name": "billing_bill_external", + "type": "VARCHAR(255)", + "null": false + }, "billing_bill_info": { "name": "billing_bill_info", "type": "TEXT", @@ -715,21 +731,11 @@ "type": "BIGINT", "null": false }, - "billing_bill_grossprofit": { - "name": "billing_bill_grossprofit", - "type": "BIGINT", - "null": false - }, "billing_bill_netcosts": { "name": "billing_bill_netcosts", "type": "BIGINT", "null": false }, - "billing_bill_grosscosts": { - "name": "billing_bill_grosscosts", - "type": "BIGINT", - "null": false - }, "billing_bill_netsales": { "name": "billing_bill_netsales", "type": "BIGINT", @@ -745,11 +751,6 @@ "type": "BIGINT", "null": false }, - "billing_bill_grossdiscount": { - "name": "billing_bill_grossdiscount", - "type": "BIGINT", - "null": false - }, "billing_bill_taxp": { "name": "billing_bill_taxp", "type": "BIGINT", @@ -781,12 +782,6 @@ "foreignTable": "account", "foreignKey": "account_id" }, - "billing_bill_referral_name": { - "name": "billing_bill_referral_name", - "type": "VARCHAR(255)", - "default": null, - "null": true - }, "billing_bill_reference": { "name": "billing_bill_reference", "type": "INT", @@ -1061,48 +1056,24 @@ "null": true, "default": null }, - "billing_bill_element_single_grosspurchaseprice": { - "name": "billing_bill_element_single_grosspurchaseprice", - "type": "BIGINT", - "null": true, - "default": null - }, "billing_bill_element_total_netpurchaseprice": { "name": "billing_bill_element_total_netpurchaseprice", "type": "BIGINT", "null": true, "default": null }, - "billing_bill_element_total_grosspurchaseprice": { - "name": "billing_bill_element_total_grosspurchaseprice", - "type": "BIGINT", - "null": true, - "default": null - }, "billing_bill_element_single_netprofit": { "name": "billing_bill_element_single_netprofit", "type": "BIGINT", "null": true, "default": null }, - "billing_bill_element_single_grossprofit": { - "name": "billing_bill_element_single_grossprofit", - "type": "BIGINT", - "null": true, - "default": null - }, "billing_bill_element_total_netprofit": { "name": "billing_bill_element_total_netprofit", "type": "BIGINT", "null": true, "default": null }, - "billing_bill_element_total_grossprofit": { - "name": "billing_bill_element_total_grossprofit", - "type": "BIGINT", - "null": true, - "default": null - }, "billing_bill_element_price_discount_single": { "name": "billing_bill_element_price_discount_single", "type": "BIGINT", diff --git a/Admin/Install/types.json b/Admin/Install/types.json index 0cf81ef..575b93b 100755 --- a/Admin/Install/types.json +++ b/Admin/Install/types.json @@ -4,6 +4,7 @@ "numberFormat": "{y}{type}-{m}{sequence}", "transferType": 1, "sign": 1, + "email": true, "isAccounting": false, "transferStock": false, "isTemplate": false, @@ -17,6 +18,7 @@ "numberFormat": "{y}{type}-{m}{sequence}", "transferType": 1, "sign": 1, + "email": true, "isAccounting": false, "transferStock": false, "isTemplate": false, @@ -30,6 +32,7 @@ "numberFormat": "{y}{type}-{m}{sequence}", "transferType": 1, "sign": 1, + "email": false, "isAccounting": false, "transferStock": true, "isTemplate": false, @@ -43,6 +46,7 @@ "numberFormat": "{y}{type}-{m}{sequence}", "transferType": 1, "sign": 1, + "email": true, "isAccounting": true, "transferStock": false, "isTemplate": false, @@ -56,6 +60,7 @@ "numberFormat": "{y}{type}-{m}{sequence}", "transferType": 1, "sign": 1, + "email": true, "isAccounting": false, "transferStock": false, "isTemplate": false, @@ -69,6 +74,7 @@ "numberFormat": "{y}{type}-{m}{sequence}", "transferType": 1, "sign": -1, + "email": true, "isAccounting": true, "transferStock": false, "isTemplate": false, @@ -82,6 +88,7 @@ "numberFormat": "{y}{type}-{m}{sequence}", "transferType": 1, "sign": -1, + "email": true, "isAccounting": true, "transferStock": false, "isTemplate": false, @@ -95,6 +102,7 @@ "numberFormat": "{y}{type}-{m}{sequence}", "transferType": 2, "sign": -1, + "email": false, "isAccounting": false, "transferStock": false, "isTemplate": false, @@ -108,6 +116,7 @@ "numberFormat": "{y}{type}-{m}{sequence}", "transferType": 2, "sign": -1, + "email": true, "isAccounting": false, "transferStock": false, "isTemplate": false, @@ -121,6 +130,7 @@ "numberFormat": "{y}{type}-{m}{sequence}", "transferType": 2, "sign": -1, + "email": false, "isAccounting": false, "transferStock": false, "isTemplate": false, @@ -134,6 +144,7 @@ "numberFormat": "{y}{type}-{m}{sequence}", "transferType": 2, "sign": -1, + "email": false, "isAccounting": false, "transferStock": true, "isTemplate": false, @@ -147,6 +158,7 @@ "numberFormat": "{y}{type}-{m}{sequence}", "transferType": 2, "sign": -1, + "email": false, "isAccounting": true, "transferStock": false, "isTemplate": false, @@ -160,6 +172,7 @@ "numberFormat": "{y}{type}-{m}{sequence}", "transferType": 2, "sign": -1, + "email": false, "isAccounting": false, "transferStock": false, "isTemplate": false, @@ -173,6 +186,7 @@ "numberFormat": "{y}{type}-{m}{sequence}", "transferType": 2, "sign": 1, + "email": false, "isAccounting": true, "transferStock": false, "isTemplate": false, @@ -186,6 +200,7 @@ "numberFormat": "{y}{type}-{m}{sequence}", "transferType": 2, "sign": 1, + "email": false, "isAccounting": true, "transferStock": false, "isTemplate": false, diff --git a/Admin/Installer.php b/Admin/Installer.php index a58a032..02239ec 100755 --- a/Admin/Installer.php +++ b/Admin/Installer.php @@ -136,7 +136,7 @@ final class Installer extends InstallerAbstract $billAttrType = []; /** @var \Modules\Billing\Controller\ApiAttributeController $module */ - $module = $app->moduleManager->getModuleInstance('Billing', 'ApiAttribute'); + $module = $app->moduleManager->get('Billing', 'ApiAttribute'); /** @var array $attribute */ foreach ($attributes as $attribute) { @@ -205,7 +205,7 @@ final class Installer extends InstallerAbstract $billAttrValue = []; /** @var \Modules\Billing\Controller\ApiAttributeController $module */ - $module = $app->moduleManager->getModuleInstance('Billing', 'ApiAttribute'); + $module = $app->moduleManager->get('Billing', 'ApiAttribute'); foreach ($attributes as $attribute) { $billAttrValue[$attribute['name']] = []; @@ -277,7 +277,7 @@ final class Installer extends InstallerAbstract $result = []; /** @var \Modules\Billing\Controller\ApiTaxController $module */ - $module = $app->moduleManager->getModuleInstance('Billing', 'ApiTax'); + $module = $app->moduleManager->get('Billing', 'ApiTax'); /** @var \Modules\Attribute\Models\AttributeType $itemAttributeSales */ $itemAttributeSales = ItemAttributeTypeMapper::get() @@ -345,7 +345,7 @@ final class Installer extends InstallerAbstract $billTypes = []; /** @var \Modules\Billing\Controller\ApiBillTypeController $module */ - $module = $app->moduleManager->getModuleInstance('Billing', 'ApiBillType'); + $module = $app->moduleManager->get('Billing', 'ApiBillType'); foreach ($types as $type) { $response = new HttpResponse(); @@ -357,6 +357,7 @@ final class Installer extends InstallerAbstract $request->setData('language', \array_keys($type['l11n'])[0] ?? 'en'); $request->setData('number_format', $type['numberFormat'] ?? '{id}'); $request->setData('sign', $type['sign'] ?? 1); + $request->setData('email', $type['email'] ?? false); $request->setData('transfer_stock', $type['transferStock'] ?? false); $request->setData('is_template', $type['isTemplate'] ?? false); $request->setData('is_accounting', $type['isAccounting'] ?? false); @@ -414,7 +415,7 @@ final class Installer extends InstallerAbstract $paymentTerms = []; /** @var \Modules\Billing\Controller\ApiController $module */ - $module = $app->moduleManager->getModuleInstance('Billing', 'Api'); + $module = $app->moduleManager->get('Billing', 'Api'); /** @var array $type */ foreach ($types as $type) { @@ -474,7 +475,7 @@ final class Installer extends InstallerAbstract $shippingTerms = []; /** @var \Modules\Billing\Controller\ApiController $module */ - $module = $app->moduleManager->getModuleInstance('Billing', 'Api'); + $module = $app->moduleManager->get('Billing', 'Api'); /** @var array $type */ foreach ($types as $type) { diff --git a/Admin/Routes/Cli.php b/Admin/Routes/Cli.php index cc9476b..3b81fd9 100755 --- a/Admin/Routes/Cli.php +++ b/Admin/Routes/Cli.php @@ -4,7 +4,7 @@ declare(strict_types=1); use phpOMS\Router\RouteVerb; return [ - '^/billing/bill/purchase/parse(\?.*$|$)' => [ + '^/billing/bill/purchase/parse( .*$|$)' => [ [ 'dest' => '\Modules\Billing\Controller\CliController:cliParseSupplierBill', 'verb' => RouteVerb::ANY, diff --git a/Admin/Routes/Web/Api.php b/Admin/Routes/Web/Api.php index 2c168d3..44996e2 100755 --- a/Admin/Routes/Web/Api.php +++ b/Admin/Routes/Web/Api.php @@ -51,4 +51,15 @@ return [ ], ], ], + '^.*/bill/parse(\?.*$|$)' => [ + [ + 'dest' => '\Modules\Billing\Controller\ApiPurchaseController:apiInvoiceParse', + 'verb' => RouteVerb::SET, + 'permission' => [ + 'module' => BackendController::NAME, + 'type' => PermissionType::MODIFY, + 'state' => PermissionCategory::PURCHASE_INVOICE, + ], + ], + ], ]; diff --git a/Controller/ApiBillController.php b/Controller/ApiBillController.php index 53f9b64..140a27f 100755 --- a/Controller/ApiBillController.php +++ b/Controller/ApiBillController.php @@ -55,7 +55,6 @@ use phpOMS\Localization\ISO4217CharEnum; use phpOMS\Localization\ISO639x1Enum; use phpOMS\Message\Http\HttpRequest; use phpOMS\Message\Http\RequestStatusCode; -use phpOMS\Message\Mail\Email; use phpOMS\Message\NotificationLevel; use phpOMS\Message\RequestAbstract; use phpOMS\Message\ResponseAbstract; @@ -94,6 +93,105 @@ final class ApiBillController extends Controller } } + public function apiBillEmail(RequestAbstract $request, ResponseAbstract $response, array $data = []) : void + { + $bill = $data['bill'] ?? BillMapper::get() + ->with('type') + ->with('files') + ->with('files/types') + ->where('id', $request->getDataInt('bill') ?? 0) + ->execute(); + + $media = $data['media'] ?? $bill->getFileByTypeName('internal');; + + if ($bill->status === BillStatus::ARCHIVED + && $bill->type->email + ) { + $email = $request->getDataString('email'); + $billingTemplate = null; + + if (!empty($email)) { + /** @var \Model\Setting $billingTemplate */ + $billingTemplate = $this->app->appSettings->get( + names: SettingsEnum::BILLING_CUSTOMER_EMAIL_TEMPLATE, + module: 'Billing' + ); + } elseif (($bill->client?->id ?? 0) !== 0) { + $client = ClientMapper::get() + ->with('account') + ->with('attributes') + ->with('attributes/type') + ->with('attributes/value') + ->where('id', $bill->client?->id ?? 0) + ->where('attributes/type/name', ['bill_emails', 'bill_email_address'], 'IN') + ->execute(); + + /** @var \Model\Setting $billingTemplate */ + $billingTemplate = $this->app->appSettings->get( + names: SettingsEnum::BILLING_CUSTOMER_EMAIL_TEMPLATE, + module: 'Billing' + ); + + if ($client->getAttribute('bill_emails')->value->getValue() === 1) { + // @todo should this really be a string or an ID for a contact element? + $email ??= empty($tmp = $client->getAttribute('bill_email_address')->value->getValue()) + ? $client->account->email + : (string) $tmp; + } + } elseif (($bill->supplier?->id ?? 0) !== 0) { + $supplier = SupplierMapper::get() + ->with('account') + ->with('attributes') + ->with('attributes/type') + ->with('attributes/value') + ->where('id', $bill->supplier?->id ?? 0) + ->where('attributes/type/name', ['bill_emails', 'bill_email_address'], 'IN') + ->execute(); + + /** @var \Model\Setting $billingTemplate */ + $billingTemplate = $this->app->appSettings->get( + names: SettingsEnum::BILLING_SUPPLIER_EMAIL_TEMPLATE, + module: 'Billing' + ); + + if ($supplier->getAttribute('bill_emails')->value->getValue() === 1) { + // @todo should this really be a string or an ID for a contact element? + $email ??= empty($tmp = $supplier->getAttribute('bill_email_address')->value->getValue()) + ? $supplier->account->email + : (string) $tmp; + } + } + + if (!empty($email)) { + $this->sendBillEmail($media, $email, (int) $billingTemplate->content, $bill->language); + } + } + } + + public function apiBillFinalize(RequestAbstract $request, ResponseAbstract $response, array $data = []) : void + { + // Archive bill + /** @var \Modules\Billing\Models\Bill $bill */ + $old = BillMapper::get() + ->with('type') + ->where('id', $request->getDataInt('bill') ?? 0) + ->execute(); + + $new = clone $old; + $new->status = BillStatus::ARCHIVED; + + $this->updateModel($request->header->account, $old, $new, BillMapper::class, 'bill', $request->getOrigin()); + + // Create final pdf + $this->apiBillPdfArchiveCreate($request, $response, $data); + $media = $response->getDataArray($request->uri->__toString())['response']; + + // Send bill via email + $this->apiBillEmail($request, $response, ['bill' => $old, 'media' => $media]); + + $this->createStandardUpdateResponse($request, $response, $new); + } + /** * Api method to update a bill * @@ -244,11 +342,8 @@ final class ApiBillController extends Controller * Maybe needs to add a status when last validated, we don't want to validate every time * https://github.com/Karaka-Management/oms-Billing/issues/44 * - * @todo Use bill and shipping address instead of main address if available - * - * @todo Implement allowed invoice languages and a default invoice language if none match - * - * @todo Implement client invoice language (allowing for different invoice languages than invoice address) + * @todo Add custom tax id for bill to manually overwrite the client_sales_tax_code + * https://github.com/Karaka-Management/oms-Billing/issues/65 * * @since 1.0.0 */ @@ -260,7 +355,7 @@ final class ApiBillController extends Controller $bill->billDate = $request->getDataDateTime('bill_date') ?? new \DateTime('now'); $bill->performanceDate = $request->getDataDateTime('performancedate') ?? new \DateTime('now'); $bill->accountNumber = $account->number; - $bill->externalReferral = $request->getDataString('externalreferral') ?? ''; + $bill->external = $request->getDataString('externalreferral') ?? ''; $bill->status = BillStatus::tryFromValue($request->getDataInt('status')) ?? BillStatus::DRAFT; $bill->shippingTerms = null; @@ -272,6 +367,7 @@ final class ApiBillController extends Controller // @todo Handle payment due // Careful, there can be multiple due dates // Example: payment plan or discounted and none-discounted date + // https://github.com/Karaka-Management/oms-Billing/issues/53 if ($account instanceof Client) { $bill->client = $account; @@ -286,6 +382,7 @@ final class ApiBillController extends Controller } // @todo use bill and shipping address instead of main address if available + // https://github.com/Karaka-Management/oms-Billing/issues/45 $bill->billTo = $request->getDataString('billto') ?? $account->account->name1; $bill->billAddress = $request->getDataString('billaddress') ?? $account->mainAddress->address; $bill->billCity = $request->getDataString('billtocity') ?? $account->mainAddress->city; @@ -294,6 +391,26 @@ final class ApiBillController extends Controller $bill->currency = ISO4217CharEnum::_EUR; + $bill->language = $this->findBillLanguage($account); + + $typeMapper = BillTypeMapper::get() + ->with('l11n') + ->where('l11n/language', $bill->language) + ->limit(1); + + if ($request->hasData('type')) { + $typeMapper->where('id', $request->getDataInt('type')); + } else { + $typeMapper->where('name', 'sales_invoice'); + } + + $bill->type = $typeMapper->execute(); + + return $bill; + } + + private function findBillLanguage(Client|Supplier $account) : string + { /** @var \Model\Setting $settings */ $settings = $this->app->appSettings->get(null, SettingsEnum::VALID_BILL_LANGUAGES, @@ -328,29 +445,18 @@ final class ApiBillController extends Controller $billLanguage = $accountBillLanguage; } else { $accountLanguages = ISO639x1Enum::languageFromCountry($account->mainAddress->country); - $accountLanguage = empty($accountLanguages) ? '' : $accountLanguages[0]; + $accountLanguage = ''; - if (\in_array($accountLanguage, $validLanguages)) { - $billLanguage = $accountLanguage; + foreach ($accountLanguages as $accountLanguage) { + if (\in_array($accountLanguage, $validLanguages)) { + $billLanguage = $accountLanguage; + + break; + } } } - $bill->language = $billLanguage; - - $typeMapper = BillTypeMapper::get() - ->with('l11n') - ->where('l11n/language', $billLanguage) - ->limit(1); - - if ($request->hasData('type')) { - $typeMapper->where('id', $request->getDataInt('type')); - } else { - $typeMapper->where('name', 'sales_invoice'); - } - - $bill->type = $typeMapper->execute(); - - return $bill; + return $billLanguage; } /** @@ -424,7 +530,7 @@ final class ApiBillController extends Controller $element = BillElement::fromItem( $item, $taxCombination, - FloatInt::toInt($request->getDataString('quantity') ?? 1), + FloatInt::toInt($request->getDataString('quantity') ?? '1'), $bill, $container ); @@ -856,7 +962,7 @@ final class ApiBillController extends Controller 'sales_tax_code', 'purchase_tax_code', 'costcenter', 'costobject', 'default_purchase_container', 'default_sales_container', ], 'IN') - ->where('l11n/type/title', ['name1', 'name2', 'name3'], 'IN') + ->where('l11n/type/title', ['name1', 'name2'], 'IN') ->where('l11n/language', $bill->language) ->execute(); @@ -904,7 +1010,27 @@ final class ApiBillController extends Controller */ public function apiMediaRender(RequestAbstract $request, ResponseAbstract $response, array $data = []) : void { - // @todo check if has permission + if (!$this->app->accountManager->get($request->header->account)->hasPermission( + PermissionType::READ, + $this->app->unitId, + null, + self::NAME, + PermissionCategory::SALES_INVOICE + ) + && !$this->app->accountManager->get($request->header->account)->hasPermission( + PermissionType::READ, + $this->app->unitId, + null, + self::NAME, + PermissionCategory::PURCHASE_INVOICE + ) + ) { + $this->fillJsonResponse($request, $response, NotificationLevel::HIDDEN, '', '', []); + $response->header->status = RequestStatusCode::R_403; + + return; + } + $this->app->moduleManager->get('Media', 'Api')->apiMediaExport($request, $response, ['ignorePermission' => true]); } @@ -923,6 +1049,27 @@ final class ApiBillController extends Controller */ public function apiPreviewRender(RequestAbstract $request, ResponseAbstract $response, array $data = []) : void { + if (!$this->app->accountManager->get($request->header->account)->hasPermission( + PermissionType::READ, + $this->app->unitId, + null, + self::NAME, + PermissionCategory::SALES_INVOICE + ) + && !$this->app->accountManager->get($request->header->account)->hasPermission( + PermissionType::READ, + $this->app->unitId, + null, + self::NAME, + PermissionCategory::PURCHASE_INVOICE + ) + ) { + $this->fillJsonResponse($request, $response, NotificationLevel::HIDDEN, '', '', []); + $response->header->status = RequestStatusCode::R_403; + + return; + } + Autoloader::addPath(__DIR__ . '/../../../Resources/'); /** @var \Modules\Billing\Models\Bill $bill */ @@ -1068,10 +1215,33 @@ final class ApiBillController extends Controller */ public function apiBillPdfArchiveCreate(RequestAbstract $request, ResponseAbstract $response, array $data = []) : void { + if (!$this->app->accountManager->get($request->header->account)->hasPermission( + PermissionType::READ, + $this->app->unitId, + null, + self::NAME, + PermissionCategory::SALES_INVOICE + ) + && !$this->app->accountManager->get($request->header->account)->hasPermission( + PermissionType::READ, + $this->app->unitId, + null, + self::NAME, + PermissionCategory::PURCHASE_INVOICE + ) + ) { + $this->fillJsonResponse($request, $response, NotificationLevel::HIDDEN, '', '', []); + $response->header->status = RequestStatusCode::R_403; + + return; + } + Autoloader::addPath(__DIR__ . '/../../../Resources/'); /** @var \Modules\Billing\Models\Bill $bill */ $bill = BillMapper::get() + ->with('files') + ->with('files/types') ->with('elements') ->with('elements/container') ->with('type') @@ -1168,6 +1338,15 @@ final class ApiBillController extends Controller // @codeCoverageIgnoreEnd } + /** @var \Model\Setting $internalType */ + $internalType = $this->app->appSettings->get( + names: SettingsEnum::INTERNAL_MEDIA_TYPE, + module: self::NAME + ); + + // @todo Check if old file exists -> update media + $oldFile = $bill->getFileByType((int) $internalType->content); + $billFileName = ($bill->billDate?->format('Y-m-d') ?? '0') . '_' . $bill->number . '.pdf'; \file_put_contents($pdfDir . '/' . $billFileName, $pdf); @@ -1177,73 +1356,60 @@ final class ApiBillController extends Controller return; } - $media = $this->app->moduleManager->get('Media', 'Api')->createDbEntry( - status: [ - 'status' => UploadStatus::OK, - 'name' => $billFileName, - 'path' => $pdfDir, - 'filename' => $billFileName, - 'size' => \filesize($pdfDir . '/' . $billFileName), - 'extension' => 'pdf', - ], - account: $request->header->account, - virtualPath: $path, - ip: $request->getOrigin(), - app: $this->app, - readContent: true, - unit: $this->app->unitId - ); + $media = null; + if ($oldFile->id === 0) { + $media = $this->app->moduleManager->get('Media', 'Api')->createDbEntry( + status: [ + 'status' => UploadStatus::OK, + 'name' => $billFileName, + 'path' => $pdfDir, + 'filename' => $billFileName, + 'size' => \filesize($pdfDir . '/' . $billFileName), + 'extension' => 'pdf', + ], + account: $request->header->account, + virtualPath: $path, + ip: $request->getOrigin(), + app: $this->app, + readContent: true, + unit: $this->app->unitId + ); - // Send bill via email - // @bug Not all bills should be sent as email - // Depends on bill type and status (i.e. draft, deleted) - // https://github.com/Karaka-Management/oms-Billing/issues/50 - $client = ClientMapper::get() - ->with('account') - ->with('attributes') - ->with('attributes/type') - ->with('attributes/value') - ->where('id', $bill->client?->id ?? 0) - ->where('attributes/type/name', ['bill_emails', 'bill_email_address'], 'IN') - ->execute(); + // Add type to media + $this->createModelRelation( + $request->header->account, + $media->id, + (int) $internalType->content, + MediaMapper::class, + 'types', + '', + $request->getOrigin() + ); - if ($client->getAttribute('bill_emails')->value->getValue() === 1) { - // @todo should this really be a string or an ID for a contact element? - $email = empty($tmp = $client->getAttribute('bill_email_address')->value->getValue()) - ? $client->account->email - : (string) $tmp; + // Add media to bill + $this->createModelRelation( + $request->header->account, + $bill->id, + $media->id, + BillMapper::class, + 'files', + '', + $request->getOrigin() + ); + } else { + $media = clone $oldFile; + if (\realpath($pdfDir . '/' . $billFileName) !== \realpath($oldFile->getAbsolutePath())) { + \unlink($oldFile->getAbsolutePath()); + } - $this->sendBillEmail($media, $email, $response->header->l11n->language); + + $media->setPath(\Modules\Media\Controller\ApiController::normalizeDbPath($pdfDir . '/' . $billFileName)); + $media->setVirtualPath($path); + $media->size = \filesize($media->getAbsolutePath()); + + $this->updateModel($request->header->account, $oldFile, $media, MediaMapper::class, 'media', $request->getOrigin()); } - // Add type to media - /** @var \Model\Setting $originalType */ - $originalType = $this->app->appSettings->get( - names: SettingsEnum::ORIGINAL_MEDIA_TYPE, - module: self::NAME - ); - - $this->createModelRelation( - $request->header->account, - $media->id, - (int) $originalType->content, - MediaMapper::class, - 'types', - '', - $request->getOrigin() - ); - - // Add media to bill - $this->createModelRelation( - $request->header->account, - $bill->id, - $media->id, - BillMapper::class, - 'files', - '', - $request->getOrigin() - ); - $this->createStandardCreateResponse($request, $response, $media); } @@ -1252,44 +1418,47 @@ final class ApiBillController extends Controller * * @param Media $media Media to send * @param string $email Email address + * @param string $template Email template * @param string $language Message language * * @return void * + * @question Maybe we should move this entire function to the Messages module + * There is nothing bill specific in here. + * * @since 1.0.0 */ - public function sendBillEmail(Media $media, string $email, string $language = 'en') : void + public function sendBillEmail(Media $media, string $email, int $template, string $language = 'en') : void { $handler = $this->app->moduleManager->get('Admin', 'Api')->setUpServerMailHandler(); - /** @var \Model\Setting $emailFrom */ - $emailFrom = $this->app->appSettings->get( - names: AdminSettingsEnum::MAIL_SERVER_ADDR, - module: 'Admin' - ); - - if (empty($emailFrom->content)) { - return; - } - - /** @var \Model\Setting $billingTemplate */ - $billingTemplate = $this->app->appSettings->get( - names: SettingsEnum::BILLING_CUSTOMER_EMAIL_TEMPLATE, - module: 'Billing' - ); - $mail = EmailMapper::get() ->with('l11n') - ->where('id', (int) $billingTemplate->content) + ->where('id', $template) ->where('l11n/language', $language) ->execute(); - $mail = new Email(); - $mail->setFrom($emailFrom->content); + $status = false; + if ($mail->id !== 0) { + $status = $this->app->moduleManager->get('Admin', 'Api')->setupEmailDefaults($mail, $language); + } + $mail->addTo($email); $mail->addAttachment($media->getAbsolutePath(), $media->name); - $handler->send($mail); + if ($status) { + $status = $handler->send($mail); + } + + if (!$status) { + \phpOMS\Log\FileLogger::getInstance()->error( + \phpOMS\Log\FileLogger::MSG_FULL, [ + 'message' => 'Couldn\'t send bill media: ' . $media->id, + 'line' => __LINE__, + 'file' => self::class, + ] + ); + } } /** diff --git a/Controller/ApiBillTypeController.php b/Controller/ApiBillTypeController.php index c7b53e9..c754c62 100755 --- a/Controller/ApiBillTypeController.php +++ b/Controller/ApiBillTypeController.php @@ -81,6 +81,7 @@ final class ApiBillTypeController extends Controller ); $billType->numberFormat = $request->getDataString('number_format') ?? '{id}'; $billType->sign = $request->getDataInt('sign') ?? 1; + $billType->email = $request->getDataBool('email') ?? false; $billType->transferStock = $request->getDataBool('transfer_stock') ?? false; $billType->isTemplate = $request->getDataBool('is_template') ?? false; $billType->isAccounting = $request->getDataBool('is_accounting') ?? false; diff --git a/Controller/ApiPriceController.php b/Controller/ApiPriceController.php index 61318d1..f27176b 100755 --- a/Controller/ApiPriceController.php +++ b/Controller/ApiPriceController.php @@ -19,6 +19,7 @@ use Modules\Attribute\Models\NullAttributeValue; use Modules\Billing\Models\Price\NullPrice; use Modules\Billing\Models\Price\Price; use Modules\Billing\Models\Price\PriceMapper; +use Modules\Billing\Models\Price\PriceStatus; use Modules\Billing\Models\Price\PriceType; use Modules\ClientManagement\Models\Client; use Modules\ClientManagement\Models\ClientMapper; @@ -28,6 +29,7 @@ use Modules\ItemManagement\Models\ItemMapper; use Modules\ItemManagement\Models\NullItem; use Modules\SupplierManagement\Models\NullSupplier; use Modules\SupplierManagement\Models\Supplier; +use Modules\SupplierManagement\Models\SupplierMapper; use phpOMS\Localization\ISO4217CharEnum; use phpOMS\Message\Http\RequestStatusCode; use phpOMS\Message\RequestAbstract; @@ -46,8 +48,12 @@ final class ApiPriceController extends Controller { public function findBestPrice(RequestAbstract $request, ?Item $item = null, ?Client $client = null, ?Supplier $supplier = null) { + $item ??= new NullItem(); + $client ??= new NullClient(); + $supplier ??= new NullSupplier(); + // Get item - if ($item === null && $request->hasData('price_item')) { + if ($item->id === 0 && $request->hasData('price_item')) { /** @var null|\Modules\ItemManagement\Models\Item $item */ $item = ItemMapper::get() ->with('attributes') @@ -59,9 +65,10 @@ final class ApiPriceController extends Controller } // Get client - if ($client === null && $request->hasData('client')) { + if ($client->id === 0 && $request->hasData('client')) { /** @var \Modules\ClientManagement\Models\Client $client */ $client = ClientMapper::get() + ->with('mainAddress') ->with('attributes') ->with('attributes/type') ->with('attributes/value') @@ -71,39 +78,125 @@ final class ApiPriceController extends Controller } // Get supplier - if ($supplier === null && $request->hasData('supplier')) { - $supplier = new NullSupplier($request->getDataInt('supplier')); + if ($supplier->id === 0 && $request->hasData('supplier')) { + $supplier = SupplierMapper::get() + ->where('id', $request->getDataInt('supplier')) + ->execute(); } - $quantity = new FloatInt($request->getDataString('price_quantity') ?? 10000); + $quantity = new FloatInt($request->getDataString('price_quantity') ?? FloatInt::DIVISOR); + $quantity->value = $quantity->value === 0 ? FloatInt::DIVISOR : $quantity->value; // Get all relevant prices - $queryMapper = PriceMapper::getAll(); + $queryMapper = PriceMapper::getAll() + ->where('status', PriceStatus::ACTIVE); if ($request->hasData('price_name')) { $queryMapper->where('name', $request->getData('name')); } - $queryMapper->where('promocode', \array_unique([$request->getData('promocode'), null]), 'IN'); + $queryMapper->where('promocode', \array_unique([$request->getDataString('promocode') ?? '', '']), 'IN'); - $queryMapper->where('item', \array_unique([$request->getDataInt('item'), $item?->id, null]), 'IN'); - $queryMapper->where('itemsalesgroup', \array_unique([$request->getDataInt('sales_group'), $item?->getAttribute('sales_group')->value->getValue(), null]), 'IN'); - $queryMapper->where('itemproductgroup', \array_unique([$request->getDataInt('product_group'), $item?->getAttribute('product_group')->value->getValue(), null]), 'IN'); - $queryMapper->where('itemsegment', \array_unique([$request->getDataInt('item_segment'), $item?->getAttribute('segment')->value->getValue(), null]), 'IN'); - $queryMapper->where('itemsection', \array_unique([$request->getDataInt('item_section'), $item?->getAttribute('section')->value->getValue(), null]), 'IN'); - $queryMapper->where('itemtype', \array_unique([$request->getDataInt('product_type'), $item?->getAttribute('product_type')->value->getValue(), null]), 'IN'); + // Item + if ($item->id !== 0) { + $queryMapper->where('item', $item->id); + } elseif ($request->hasData('item')) { + $queryMapper->where('item', $request->getDataInt('item')); + } - $queryMapper->where('client', \array_unique([$request->getDataInt('client'), $client?->id, null]), 'IN'); - $queryMapper->where('clientgroup', \array_unique([$request->getDataInt('client_group'), $client?->getAttribute('client_group')->value->getValue(), null]), 'IN'); - $queryMapper->where('clientsegment', \array_unique([$request->getDataInt('client_segment'), $client?->getAttribute('segment')->value->getValue(), null]), 'IN'); - $queryMapper->where('clientsection', \array_unique([$request->getDataInt('client_section'), $client?->getAttribute('section')->value->getValue(), null]), 'IN'); - $queryMapper->where('clienttype', \array_unique([$request->getDataInt('client_type'), $client?->getAttribute('client_type')->value->getValue(), null]), 'IN'); - $queryMapper->where('clientcountry', \array_unique([$request->getData('client_region'), $client?->mainAddress->country, null]), 'IN'); + // Item segment + $itemSegment = [null, $request->getDataInt('item_segment')]; + if ($item->getAttribute('segment')->value->id !== 0) { + $itemSegment[] = $item->getAttribute('segment')->value->getValue(); + } + $queryMapper->where('itemsegment', \array_unique($itemSegment), 'IN'); - $queryMapper->where('supplier', \array_unique([$supplier?->id, null]), 'IN'); - $queryMapper->where('unit', \array_unique([$request->getDataInt('price_unit'), null]), 'IN'); - $queryMapper->where('type', $request->getDataInt('price_type') ?? (($supplier?->id ?? 0) === 0 ? PriceType::SALES : PriceType::PURCHASE)); - $queryMapper->where('currency', \array_unique([$request->getDataString('currency'), null]), 'IN'); + // Item section + $itemSection = [null, $request->getDataInt('item_section')]; + if ($item->getAttribute('section')->value->id !== 0) { + $itemSection[] = $item->getAttribute('section')->value->getValue(); + } + $queryMapper->where('itemsection', \array_unique($itemSection), 'IN'); + + // Item sales group + $itemSalesGroups = [null, $request->getDataInt('sales_group')]; + if ($item->getAttribute('sales_group')->value->id !== 0) { + $itemSalesGroups[] = $item->getAttribute('sales_group')->value->getValue(); + } + $queryMapper->where('itemsalesgroup', \array_unique($itemSalesGroups), 'IN'); + + // Item product group + $itemProductGroups = [null, $request->getDataInt('product_group')]; + if ($item->getAttribute('product_group')->value->id !== 0) { + $itemProductGroups[] = $item->getAttribute('product_group')->value->getValue(); + } + $queryMapper->where('itemproductgroup', \array_unique($itemProductGroups), 'IN'); + + // Item product type + $itemProductType = [null, $request->getDataInt('product_type')]; + if ($item->getAttribute('product_type')->value->id !== 0) { + $itemProductType[] = $item->getAttribute('product_type')->value->getValue(); + } + $queryMapper->where('itemtype', \array_unique($itemProductType), 'IN'); + + // Client + if ($client->id !== 0) { + $queryMapper->where('client', $client->id); + } elseif ($request->hasData('client')) { + $queryMapper->where('client', $request->getDataInt('client')); + } + + // Client segment + $clientSegment = [null, $request->getDataInt('client_segment')]; + if ($client->getAttribute('segment')->value->id !== 0) { + $clientSegment[] = $client->getAttribute('segment')->value->getValue(); + } + $queryMapper->where('clientsegment', \array_unique($clientSegment), 'IN'); + + // Client section + $clientSection = [null, $request->getDataInt('client_section')]; + if ($client->getAttribute('section')->value->id !== 0) { + $clientSection[] = $client->getAttribute('section')->value->getValue(); + } + $queryMapper->where('clientsection', \array_unique($clientSection), 'IN'); + + // Client group + $clientGroup = [null, $request->getDataInt('client_group')]; + if ($client->getAttribute('client_group')->value->id !== 0) { + $clientGroup[] = $client->getAttribute('client_group')->value->getValue(); + } + $queryMapper->where('clientgroup', \array_unique($clientGroup), 'IN'); + + // Client type + $clientType = [null, $request->getDataInt('client_type')]; + if ($client->getAttribute('client_type')->value->id !== 0) { + $clientType[] = $client->getAttribute('client_type')->value->getValue(); + } + $queryMapper->where('clienttype', \array_unique($clientType), 'IN'); + + // Client type + $clientCountry = [null, $request->getDataInt('client_region')]; + if ($client->mainAddress->id !== 0) { + $clientCountry[] = $client->mainAddress->country; + } + $queryMapper->where('clientcountry', \array_unique($clientCountry), 'IN'); + + // Supplier + if ($supplier->id !== 0) { + $queryMapper->where('supplier', $supplier->id); + } elseif ($request->hasData('supplier')) { + $queryMapper->where('supplier', $request->getDataInt('supplier')); + } + + if ($request->hasData('price_unit')) { + $queryMapper->where('unit', $request->getDataInt('price_unit')); + } + + $queryMapper->where('type', $request->getDataInt('price_type') ?? ($supplier->id === 0 ? PriceType::SALES : PriceType::PURCHASE)); + + if ($request->hasData('currency')) { + $queryMapper->where('currency', $request->getData('currency')); + } // @todo implement start and end @@ -124,7 +217,8 @@ final class ApiPriceController extends Controller // Find base price $basePrice = null; foreach ($prices as $price) { - if ($price->priceNew > 0 + if (/*$price->priceNew->value > 0 */ // Price could be 0 + $price->id !== 0 && $price->item->id !== 0 && $price->itemsalesgroup->id === 0 && $price->itemproductgroup->id === 0 @@ -177,8 +271,8 @@ final class ApiPriceController extends Controller // 3. subtract bonus effect $newPrice -= $price->discount->value; - $newPrice = (int) ($newPrice - $price->bonus->value / 10000 * $price->priceNew->value / $quantity->value); - $newPrice = (int) ((1000000 - $price->discountPercentage->value) / 1000000 * $newPrice); + $newPrice = (int) ($newPrice - $price->bonus->value / FloatInt::DIVISOR * $price->priceNew->value / $quantity->value); + $newPrice = (int) (((FloatInt::DIVISOR * 100) - $price->discountPercentage->value) / (FloatInt::DIVISOR * 100) * $newPrice); // @todo If a customer receives 1+1 but purchases 2, then he gets 2+2 (if multiply === true) which is better than 1+1 with multiply false. // Same goes for amount discounts? @@ -189,13 +283,13 @@ final class ApiPriceController extends Controller } } - if ($bestPrice->price->value === 0) { + if ($bestPrice->priceNew->value === 0) { $discounts[] = clone $bestPrice; $bestPrice = $basePrice; } // Actual price calculation - $bestActualPriceValue = $bestPrice?->price->value ?? \PHP_INT_MAX; + $bestActualPriceValue = $bestPrice?->priceNew->value ?? \PHP_INT_MAX; $discountAmount = $bestPrice->discount->value; $discountPercentage = $bestPrice->discountPercentage->value; @@ -210,11 +304,12 @@ final class ApiPriceController extends Controller } $bestActualPriceValue -= $discountAmount; - $bestActualPriceValue = (int) \round((1000000 - $discountPercentage) / 1000000 * $bestActualPriceValue, 0); + $bestActualPriceValue = (int) \round(((FloatInt::DIVISOR * 100) - $discountPercentage) / (FloatInt::DIVISOR * 100) * $bestActualPriceValue, 0); return [ - 'basePrice' => $basePrice->price, - 'bestPrice' => $bestPrice->price, + 'basePrice' => $basePrice->priceNew, + 'bestPrice' => $bestPrice->priceNew, + 'supplier' => $bestPrice->supplier->id, 'bestActualPrice' => new FloatInt($bestActualPriceValue), 'discounts' => $discounts, 'discountPercent' => new FloatInt($discountPercentage), @@ -379,6 +474,8 @@ final class ApiPriceController extends Controller ? ($request->getDataString('name') ?? $new->name) : $new->name; + $new->status = PriceStatus::tryFromValue($request->getDataInt('type')) ?? $new->status; + $new->promocode = $request->getDataString('promocode') ?? $new->promocode; $new->itemsalesgroup = $request->hasData('itemsalesgroup') ? new NullAttributeValue((int) $request->getData('itemsalesgroup')) : $new->itemsalesgroup; diff --git a/Controller/ApiPurchaseController.php b/Controller/ApiPurchaseController.php index 7f58dec..3ea7d2a 100755 --- a/Controller/ApiPurchaseController.php +++ b/Controller/ApiPurchaseController.php @@ -1,5 +1,4 @@ apiBillCreate + * -> [createBill] + * -> apiMediaAddToBill + * -> apiInvoiceParse + * -> cliParseSupplierBill + * -> [updateBill] + * -> apiBillPdfArchiveCreate + * -> eventBillArchive + * * @since 1.0.0 */ public function apiSupplierBillUpload(RequestAbstract $request, ResponseAbstract $response, array $data = []) : void + { + if (!empty($val = $this->validateSupplierBillUpload($request))) { + $response->header->status = RequestStatusCode::R_400; + $this->createInvalidCreateResponse($request, $response, $val); + + return; + } + + $bills = $this->createSupplierBillUploadFromRequest($request, $response, $data); + if (empty($bills)) { + $response->header->status = RequestStatusCode::R_400; + $this->createInvalidCreateResponse($request, $response, $val); + } + + $this->createStandardCreateResponse($request, $response, $bills); + } + + /** + * Method to create item attribute from request. + * + * @param RequestAbstract $request Request + * + * @return array + * + * @since 1.0.0 + */ + private function createSupplierBillUploadFromRequest(RequestAbstract $request, ResponseAbstract $response, $data) : array { /** @var \Model\Setting $setting */ $setting = $this->app->appSettings->get( - names: SettingsEnum::ORIGINAL_MEDIA_TYPE, + names: SettingsEnum::EXTERNAL_MEDIA_TYPE, module: self::NAME ); - $originalType = $request->getDataInt('type') ?? ((int) $setting->content); + $internalType = $request->getDataInt('type') ?? ((int) $setting->content); /** @var \Modules\Billing\Models\BillType $purchaseTransferType */ $purchaseTransferType = BillTypeMapper::get() @@ -105,7 +142,7 @@ final class ApiPurchaseController extends Controller } $mediaRequest->setData('bill', $billId); - $mediaRequest->setData('type', $originalType); + $mediaRequest->setData('type', $internalType); $mediaRequest->setData('parse_content', true, true); $this->app->moduleManager->get('Billing', 'ApiBill')->apiMediaAddToBill($mediaRequest, $mediaResponse, $data); @@ -113,46 +150,121 @@ final class ApiPurchaseController extends Controller /** @var \Modules\Media\Models\Media[] $uploaded */ $uploaded = $mediaResponse->getDataArray('')['response']['upload']; if (empty($uploaded)) { - $response->header->status = RequestStatusCode::R_400; - throw new \Exception(); + return []; } $in = \reset($uploaded)->getAbsolutePath(); if (!\is_file($in)) { - $response->header->status = RequestStatusCode::R_400; - throw new \Exception(); + return []; } } - // Create internal document - $billResponse = new HttpResponse(); - $billRequest = new HttpRequest(); + $request->setData('id', $billId, true); + $request->setData('bill', $billId, true); - $billRequest->header->account = $request->header->account; - $billRequest->setData('bill', $billId); - - $this->app->moduleManager->get('Billing', 'ApiBill')->apiBillPdfArchiveCreate($billRequest, $billResponse); - - // Offload bill parsing to cli - $cliPath = \realpath(__DIR__ . '/../../../cli.php'); - if ($cliPath === false) { - return; - } - - try { - SystemUtils::runProc( - OperatingSystem::getSystem() === SystemType::WIN ? 'php.exe' : 'php', - \escapeshellarg($cliPath) - . ' /billing/bill/purchase/parse ' - . '-i ' . \escapeshellarg((string) $billId), - $request->getDataBool('async') ?? true - ); - } catch (\Throwable $t) { - $response->header->status = RequestStatusCode::R_400; - $this->app->logger->error($t->getMessage()); - } - - $this->createStandardCreateResponse($request, $response, $bills); + $this->apiInvoiceParse($request, $response, $data); } + + return $bills; + } + + /** + * Validate item attribute create request + * + * @param RequestAbstract $request Request + * + * @return array + * + * @since 1.0.0 + */ + private function validateSupplierBillUpload(RequestAbstract $request) : array + { + $val = []; + if (($val['files'] = empty($request->files))) { + return $val; + } + + return []; + } + + /** + * Api method to create bill files + * + * @param RequestAbstract $request Request + * @param ResponseAbstract $response Response + * @param array $data Generic data + * + * @return void + * + * @api + * + * @throws \Exception + * + * @since 1.0.0 + */ + public function apiInvoiceParse(RequestAbstract $request, ResponseAbstract $response, array $data = []) : void + { + if (!empty($val = $this->validateInvoiceParse($request))) { + $response->header->status = RequestStatusCode::R_400; + $this->createInvalidCreateResponse($request, $response, $val); + + return; + } + + $bill = BillMapper::get() + ->where('id', (int) $request->getData('id')) + ->execute(); + + // After a bill is "closed" its values shouldn't change + if ($bill->status !== BillStatus::DRAFT + && $bill->status !== BillStatus::UNPARSED + && $bill->status !== BillStatus::ACTIVE + ) { + $response->header->status = RequestStatusCode::R_423; + $this->createInvalidCreateResponse($request, $response, $val); + + return; + } + + // Offload bill parsing to cli + $cliPath = \realpath(__DIR__ . '/../../../cli.php'); + if ($cliPath === false) { + return; + } + + try { + SystemUtils::runProc( + OperatingSystem::getSystem() === SystemType::WIN ? 'php.exe' : 'php', + '-dxdebug.remote_enable=1 -dxdebug.start_with_request=yes -dxdebug.mode=coverage,develop,debug ' . + \escapeshellarg($cliPath) + . ' /billing/bill/purchase/parse ' + . '-i ' . \escapeshellarg((string) $bill->id), + $request->getDataBool('async') ?? true + ); + } catch (\Throwable $t) { + $response->header->status = RequestStatusCode::R_400; + $this->app->logger->error($t->getMessage()); + } + + $this->createStandardUpdateResponse($request, $response, $bill); + } + + /** + * Validate item attribute create request + * + * @param RequestAbstract $request Request + * + * @return array + * + * @since 1.0.0 + */ + private function validateInvoiceParse(RequestAbstract $request) : array + { + $val = []; + if (($val['id'] = !$request->hasData('id'))) { + return $val; + } + + return []; } } diff --git a/Controller/BackendController.php b/Controller/BackendController.php index a6bf073..0107a04 100755 --- a/Controller/BackendController.php +++ b/Controller/BackendController.php @@ -22,12 +22,14 @@ use Modules\Billing\Models\BillTransferType; use Modules\Billing\Models\BillTypeMapper; use Modules\Billing\Models\PaymentTermL11nMapper; use Modules\Billing\Models\PaymentTermMapper; +use Modules\Billing\Models\PermissionCategory; use Modules\Billing\Models\PurchaseBillMapper; use Modules\Billing\Models\SalesBillMapper; use Modules\Billing\Models\SettingsEnum; use Modules\Billing\Models\ShippingTermL11nMapper; use Modules\Billing\Models\ShippingTermMapper; use Modules\Billing\Models\StockBillMapper; +use phpOMS\Account\PermissionType; use phpOMS\Contract\RenderableInterface; use phpOMS\DataStorage\Database\Query\OrderType; use phpOMS\Message\RequestAbstract; @@ -71,6 +73,7 @@ final class BackendController extends Controller ->where('type/transferType', BillTransferType::SALES) ->where('type/l11n/language', $response->header->l11n->language) ->sort('id', OrderType::DESC) + ->where('unit', $this->app->unitId) ->limit(25); if ($request->getData('ptype') === 'p') { @@ -131,26 +134,38 @@ final class BackendController extends Controller $view->data['billtypes'] = $billTypes; - /** @var \Modules\Auditor\Models\Audit[] $logsBill */ - $logs = AuditMapper::getAll() - ->with('createdBy') - ->where('module', 'Billing') - ->where('type', StringUtils::intHash(BillMapper::class)) - ->where('ref', $bill->id) - ->execute(); - - if (!empty($bill->elements)) { - /** @var \Modules\Auditor\Models\Audit[] $logsElements */ - $logsElements = AuditMapper::getAll() + $logs = []; + if ($this->app->accountManager->get($request->header->account)->hasPermission( + PermissionType::READ, + $this->app->unitId, + null, + self::NAME, + PermissionCategory::BILL_LOG, + ) + ) { + /** @var \Modules\Auditor\Models\Audit[] $logsBill */ + $logs = AuditMapper::getAll() ->with('createdBy') ->where('module', 'Billing') - ->where('type', StringUtils::intHash(BillElementMapper::class)) - ->where('ref', \array_keys($bill->elements), 'IN') + ->where('type', StringUtils::intHash(BillMapper::class)) + ->where('ref', $bill->id) ->execute(); - $logs = \array_merge($logs, $logsElements); + if (!empty($bill->elements)) { + /** @var \Modules\Auditor\Models\Audit[] $logsElements */ + $logsElements = AuditMapper::getAll() + ->with('createdBy') + ->where('module', 'Billing') + ->where('type', StringUtils::intHash(BillElementMapper::class)) + ->where('ref', \array_keys($bill->elements), 'IN') + ->execute(); + + $logs = \array_merge($logs, $logsElements); + } } + + $view->data['logs'] = $logs; $view->data['media-upload'] = new \Modules\Media\Theme\Backend\Components\Upload\BaseView($this->app->l11nManager, $request, $response); @@ -255,6 +270,7 @@ final class BackendController extends Controller ->with('supplier') ->where('type/transferType', BillTransferType::PURCHASE) ->sort('id', OrderType::DESC) + ->where('unit', $this->app->unitId) ->limit(25); if ($request->getData('ptype') === 'p') { @@ -313,32 +329,34 @@ final class BackendController extends Controller ->where('l11n/language', $request->header->l11n->language) ->execute(); - /** @var \Model\Setting $originalType */ - $originalType = $this->app->appSettings->get( - names: SettingsEnum::ORIGINAL_MEDIA_TYPE, - module: self::NAME - ); - - $view->data['originalType'] = (int) $originalType->content; - - /** @var \Modules\Auditor\Models\Audit[] $logs */ - $logs = AuditMapper::getAll() - ->with('createdBy') - ->where('module', 'Billing') - ->where('type', StringUtils::intHash(BillMapper::class)) - ->where('ref', $view->data['bill']->id) - ->execute(); - - if (!empty($view->data['bill']->elements)) { - /** @var \Modules\Auditor\Models\Audit[] $logsElements */ - $logsElements = AuditMapper::getAll() + $logs = []; + if ($this->app->accountManager->get($request->header->account)->hasPermission( + PermissionType::READ, + $this->app->unitId, + null, + self::NAME, + PermissionCategory::BILL_LOG, + ) + ) { + /** @var \Modules\Auditor\Models\Audit[] $logs */ + $logs = AuditMapper::getAll() ->with('createdBy') ->where('module', 'Billing') - ->where('type', StringUtils::intHash(BillElementMapper::class)) - ->where('ref', \array_keys($view->data['bill']->elements), 'IN') + ->where('type', StringUtils::intHash(BillMapper::class)) + ->where('ref', $view->data['bill']->id) ->execute(); - $logs = \array_merge($logs, $logsElements); + if (!empty($view->data['bill']->elements)) { + /** @var \Modules\Auditor\Models\Audit[] $logsElements */ + $logsElements = AuditMapper::getAll() + ->with('createdBy') + ->where('module', 'Billing') + ->where('type', StringUtils::intHash(BillElementMapper::class)) + ->where('ref', \array_keys($view->data['bill']->elements), 'IN') + ->execute(); + + $logs = \array_merge($logs, $logsElements); + } } $view->data['logs'] = $logs; @@ -366,11 +384,11 @@ final class BackendController extends Controller $view->data['nav'] = $this->app->moduleManager->get('Navigation')->createNavigationMid(1005106001, $request, $response); if ($request->getData('ptype') === 'p') { - $view->data['bills'] = StockBillMapper::getAll()->where('id', $request->getDataInt('id') ?? 0, '<')->limit(25)->execute(); + $view->data['bills'] = StockBillMapper::getAll()->where('id', $request->getDataInt('id') ?? 0, '<')->where('unit', $this->app->unitId)->limit(25)->execute(); } elseif ($request->getData('ptype') === 'n') { - $view->data['bills'] = StockBillMapper::getAll()->where('id', $request->getDataInt('id') ?? 0, '>')->limit(25)->execute(); + $view->data['bills'] = StockBillMapper::getAll()->where('id', $request->getDataInt('id') ?? 0, '>')->where('unit', $this->app->unitId)->limit(25)->execute(); } else { - $view->data['bills'] = StockBillMapper::getAll()->where('id', 0, '>')->limit(25)->execute(); + $view->data['bills'] = StockBillMapper::getAll()->where('id', 0, '>')->where('unit', $this->app->unitId)->limit(25)->execute(); } return $view; @@ -459,7 +477,7 @@ final class BackendController extends Controller public function viewPrivatePurchaseBillDashboard(RequestAbstract $request, ResponseAbstract $response, array $data = []) : RenderableInterface { $view = new View($this->app->l11nManager, $request, $response); - $view->setTemplate('/Modules/Billing/Theme/Backend/user-purchase-bill-dashboard'); + $view->setTemplate('/Modules/Billing/Theme/Backend/purchase-bill-list'); $view->data['nav'] = $this->app->moduleManager->get('Navigation')->createNavigationMid(1005109001, $request, $response); $mapperQuery = PurchaseBillMapper::getAll() @@ -469,6 +487,7 @@ final class BackendController extends Controller ->where('type/transferType', BillTransferType::PURCHASE) ->where('status', BillStatus::UNPARSED) ->sort('id', OrderType::DESC) + ->where('unit', $this->app->unitId) ->limit(25); if ($request->getData('ptype') === 'p') { @@ -504,7 +523,7 @@ final class BackendController extends Controller public function viewPrivateBillingPurchaseInvoice(RequestAbstract $request, ResponseAbstract $response, array $data = []) : RenderableInterface { $view = new View($this->app->l11nManager, $request, $response); - $view->setTemplate('/Modules/Billing/Theme/Backend/user-purchase-bill'); + $view->setTemplate('/Modules/Billing/Theme/Backend/purchase-bill'); $view->data['nav'] = $this->app->moduleManager->get('Navigation')->createNavigationMid(1005109001, $request, $response); $bill = PurchaseBillMapper::get() @@ -525,13 +544,13 @@ final class BackendController extends Controller $view->data['previewType'] = (int) $previewType->content; - /** @var \Model\Setting $originalType */ - $originalType = $this->app->appSettings->get( - names: SettingsEnum::ORIGINAL_MEDIA_TYPE, + /** @var \Model\Setting $externalType */ + $externalType = $this->app->appSettings->get( + names: SettingsEnum::EXTERNAL_MEDIA_TYPE, module: self::NAME ); - $view->data['originalType'] = (int) $originalType->content; + $view->data['externalType'] = (int) $externalType->content; $view->data['media-upload'] = new \Modules\Media\Theme\Backend\Components\Upload\BaseView($this->app->l11nManager, $request, $response); return $view; diff --git a/Controller/CliController.php b/Controller/CliController.php index 31797a4..9f1c050 100755 --- a/Controller/CliController.php +++ b/Controller/CliController.php @@ -14,8 +14,11 @@ declare(strict_types=1); namespace Modules\Billing\Controller; +use Modules\Billing\Models\BillElement; +use Modules\Billing\Models\BillElementMapper; use Modules\Billing\Models\BillMapper; use Modules\Billing\Models\BillTypeMapper; +use Modules\Billing\Models\InvoiceRecognition; use Modules\Billing\Models\NullBillType; use Modules\Billing\Models\SettingsEnum; use Modules\Payment\Models\PaymentType; @@ -23,7 +26,11 @@ use Modules\SupplierManagement\Models\NullSupplier; use Modules\SupplierManagement\Models\Supplier; use Modules\SupplierManagement\Models\SupplierMapper; use phpOMS\Contract\RenderableInterface; -use phpOMS\Localization\LanguageDetection\Language; +use phpOMS\Localization\ISO4217CharEnum; +use phpOMS\Localization\ISO4217DecimalEnum; +use phpOMS\Localization\Localization; +use phpOMS\Message\Http\HttpRequest; +use phpOMS\Message\Http\HttpResponse; use phpOMS\Message\RequestAbstract; use phpOMS\Message\ResponseAbstract; use phpOMS\Stdlib\Base\FloatInt; @@ -53,31 +60,53 @@ final class CliController extends Controller */ public function cliParseSupplierBill(RequestAbstract $request, ResponseAbstract $response, array $data = []) : RenderableInterface { + $view = new View($this->app->l11nManager, $request, $response); + $view->setTemplate('/Modules/Billing/Theme/Cli/bill-parsed'); + /** @var \Model\Setting $setting */ $setting = $this->app->appSettings->get( - names: SettingsEnum::ORIGINAL_MEDIA_TYPE, + names: SettingsEnum::EXTERNAL_MEDIA_TYPE, module: self::NAME ); - $originalType = $request->getDataInt('type') ?? (int) $setting->content; + $externalType = $request->getDataInt('-t') ?? (int) $setting->content; /** @var \Modules\Billing\Models\Bill $bill */ $bill = BillMapper::get() + ->with('elements') ->with('files') - ->with('media/types') - ->with('media/content') - ->where('id', (int) $request->getData('i')) - ->where('media/types/id', $originalType) + ->with('files/types') + ->with('files/content') + ->where('id', (int) $request->getData('-i')) + ->where('files/types', $externalType) ->execute(); + if ($bill->id === 0) { + return $view; + } + $old = clone $bill; - $content = \strtolower($bill->getFileByType($originalType)->content->content ?? ''); + $content = \strtolower($bill->getFileByType($externalType)->content->content ?? ''); $lines = \explode("\n", $content); + foreach ($lines as $line => $value) { + if (empty(\trim($value))) { + unset($lines[$line]); + } + } + + $lines = \array_values($lines); + + $language = InvoiceRecognition::detectLanguage($content); + + if (!\in_array($language, ['en', 'de'])) { + $language = 'en'; + } - $language = $this->detectLanguage($content); $bill->language = $language; + $l11n = Localization::fromLanguage($language); + $identifierContent = \file_get_contents(__DIR__ . '/../Models/bill_identifier.json'); if ($identifierContent === false) { $identifierContent = '{}'; @@ -99,11 +128,32 @@ final class CliController extends Controller $bill->supplier = new NullSupplier($supplierId); $supplier = $suppliers[$supplierId] ?? new NullSupplier(); - $bill->billTo = $supplier->account->name1; - $bill->billAddress = $supplier->mainAddress->address; - $bill->billCity = $supplier->mainAddress->city; - $bill->billZip = $supplier->mainAddress->postal; - $bill->billCountry = $supplier->mainAddress->country; + if ($supplier->id !== 0) { + $bill->billTo = $supplier->account->name1; + $bill->billAddress = $supplier->mainAddress->address; + $bill->billCity = $supplier->mainAddress->city; + $bill->billZip = $supplier->mainAddress->postal; + $bill->billCountry = $supplier->mainAddress->country; + } else { + $bill->billCountry = InvoiceRecognition::findCountry($lines, $identifiers, $language); + } + + $currency = InvoiceRecognition::findCurrency($lines); + $countryCurrency = ISO4217CharEnum::currencyFromCountry($bill->billCountry); + + // Identified currency has to be country currency or one of the top globally used currencies + if ($currency !== \in_array($currency, [ + $countryCurrency, ISO4217CharEnum::_USD, ISO4217CharEnum::_EUR, ISO4217CharEnum::_JPY, + ISO4217CharEnum::_GBP, ISO4217CharEnum::_AUD, ISO4217CharEnum::_CAD, ISO4217CharEnum::_CHF, + ISO4217CharEnum::_CNH, ISO4217CharEnum::_CNY + ]) + ) { + $currency = $countryCurrency; + } + + $bill->currency = $currency; + + $rd = -FloatInt::MAX_DECIMALS + ISO4217DecimalEnum::getByName('_' . $bill->currency); /* Type */ $type = $this->findSupplierInvoiceType($content, $identifiers['type'], $language); @@ -116,65 +166,282 @@ final class CliController extends Controller $bill->type = new NullBillType($billType->id); /* Number */ - $billNumber = $this->findBillNumber($lines, $identifiers['bill_no'][$language]); - $bill->number = $billNumber; + $billNumber = InvoiceRecognition::findBillNumber($lines, $identifiers['bill_no'][$language]); + $bill->external = $billNumber; + + /* Reference / PO */ + // @todo implement /* Date */ - $billDateTemp = $this->findBillDate($lines, $identifiers['bill_date'][$language]); - $billDate = $this->parseDate($billDateTemp, $supplier, $identifiers['date_format']); + $billDateTemp = InvoiceRecognition::findBillDate($lines, $identifiers['bill_date'][$language]); + $billDate = InvoiceRecognition::parseDate($billDateTemp, $identifiers['date_format'], $supplier->getAttribute('bill_date_format')->value->valueStr ?? ''); $bill->billDate = $billDate; /* Due */ - $billDueTemp = $this->findBillDue($lines, $identifiers['bill_date'][$language]); - $billDue = $this->parseDate($billDueTemp, $supplier, $identifiers['date_format']); + $billDueTemp = InvoiceRecognition::findBillDue($lines, $identifiers['bill_due'][$language]); + $billDue = InvoiceRecognition::parseDate($billDueTemp, $identifiers['date_format'], $supplier->getAttribute('bill_date_format')->value->valueStr ?? ''); // @todo implement multiple due dates for bills - /* Total Net */ - $totalNet = $this->findBillNet($lines, $identifiers['total_net'][$language]); - $bill->netCosts = new FloatInt($totalNet); + /* Total */ + $totalGross = InvoiceRecognition::findBillGross($lines, $identifiers['total_gross'][$language]); + $totalNet = InvoiceRecognition::findBillNet($lines, $identifiers['total_net'][$language]); + + // The number format needs to be corrected: + // Languages don't always respect the l11n number format + // Sometimes parsing errors can happen + $format = FloatInt::identifyNumericFormat($totalGross); + + if ($format !== null) { + $l11n->thousands = $format['thousands']; + $l11n->decimal = $format['decimal']; + } + + $bill->grossSales = new FloatInt($totalGross, $l11n->thousands, $l11n->decimal); + $bill->netSales = new FloatInt($totalNet, $l11n->thousands, $l11n->decimal); /* Total Tax */ - $totalTaxAmount = $this->findBillTaxAmount($lines, $identifiers['total_net'][$language]); + // @todo taxes depend on local tax id (if company in Germany but invoice from US -> only gross amount important, there is no net) + $totalTaxAmount = InvoiceRecognition::findBillTaxAmount($lines, $identifiers['total_tax'][$language]); + $taxRates = InvoiceRecognition::findBillTaxRates($lines, $identifiers['tax_rate'][$language]); - /* Total Gross */ - $totalGross = $this->findBillGross($lines, $identifiers['total_gross'][$language]); - $bill->grossCosts = new FloatInt($totalGross); + if ($bill->netSales->value === 0) { + $bill->netSales->value = $taxRates === 0 + ? $bill->grossSales->value + : (int) \round($bill->grossSales->value / (1.0 + $taxRates / (FloatInt::DIVISOR * 100)), $rd); + } + + if ($bill->grossSales->value === 0) { + $bill->grossSales->value = $taxRates === 0 + ? $bill->netSales->value + : $bill->netSales->value + ((int) \round($bill->netSales->value * $taxRates / (FloatInt::DIVISOR * 100), $rd)); + } + + // We just assume that finding the net sales value is more likely + // If this turns out to be false, we need to recalculate the netSales from the grossSales instead + if ($bill->grossSales->value === $bill->netSales->value) { + $bill->grossSales->value = $bill->netSales->value + ((int) \round($bill->netSales->value * $taxRates / (FloatInt::DIVISOR * 100), $rd)); + } + + if ($taxRates === 0 && $bill->netSales->value !== $bill->grossSales->value) { + $taxRates = ((int) ($bill->grossSales->value / ($bill->grossSales->value / FloatInt::DIVISOR))) - FloatInt::DIVISOR; + } /* Item lines */ - $itemLines = $this->findBillItemLines($lines, $identifiers['item_table'][$language]); + $itemLines = InvoiceRecognition::findBillItemLines($lines, $identifiers['item_table'][$language]); + + // @todo Try to find item from item database + // @todo Some of the element value setting is unnecessary as it happens also in the recalculatePrices() + // Same goes for the bill element creations further down below + if (empty($bill->elements)) { + $itemLineEnd = 0; + foreach ($itemLines as $line => $itemLine) { + $itemLineEnd = $line; + + $billElement = new BillElement(); + $billElement->bill = $bill; + + $billElement->taxR->value = $taxRates; + + if (isset($itemLine['description'])) { + $billElement->itemName = \trim($itemLine['description']); + } + + if (isset($itemLine['quantity'])) { + $billElement->quantity = new FloatInt($itemLine['quantity'], $l11n->thousands, $l11n->decimal); + } + + // Unit + if (isset($itemLine['price'])) { + $billElement->singleListPriceNet = new FloatInt($itemLine['price'], $l11n->thousands, $l11n->decimal); + + $billElement->singleSalesPriceNet = $billElement->singleListPriceNet; + $billElement->singlePurchasePriceNet = $billElement->singleSalesPriceNet; + + if ($billElement->taxR->value > 0) { + $billElement->singleListPriceGross->value = $billElement->singleListPriceNet->value + ((int) \round($billElement->singleSalesPriceNet->value * $billElement->taxR->value / (FloatInt::DIVISOR * 100), $rd)); + $billElement->singleSalesPriceGross = $billElement->singleListPriceGross; + } else { + $billElement->singleListPriceGross = $billElement->singleListPriceNet; + $billElement->singleSalesPriceGross = $billElement->singleListPriceGross; + } + } + + // Total + if (isset($itemLine['total'])) { + $billElement->totalListPriceNet = new FloatInt($itemLine['total'], $l11n->thousands, $l11n->decimal); + + $billElement->totalSalesPriceNet = $billElement->totalListPriceNet; + $billElement->totalPurchasePriceNet = $billElement->totalSalesPriceNet; + + if ($billElement->taxR->value > 0) { + $billElement->totalListPriceGross->value = $billElement->totalListPriceNet->value + ((int) \round($billElement->totalSalesPriceNet->value * $billElement->taxR->value / (FloatInt::DIVISOR * 100), $rd)); + $billElement->totalSalesPriceGross = $billElement->totalListPriceGross; + } else { + $billElement->totalListPriceGross = $billElement->totalListPriceNet; + $billElement->totalSalesPriceGross = $billElement->totalListPriceGross; + } + } + + $billElement->taxP->value = $billElement->totalSalesPriceGross->value - $billElement->totalSalesPriceNet->value; + + $billElement->recalculatePrices(); + $bill->elements[] = $billElement; + + $this->createModel($request->header->account, $billElement, BillElementMapper::class, 'bill_element', $request->getOrigin()); + } + + /* Total Special */ + // @question How do we want to apply total discounts? + // Option 1: Apply in relation to the amount per line item (this would be correct for stock evaluation) + // Option 2: Additional element (For correct stock evaluation we could do a internal/backend correction in the lot price calculation) + // + // Option 2 seems nicer from a user perspective! + $totalSpecial = InvoiceRecognition::findBillSpecial($lines, $identifiers, $language, $itemLineEnd); + foreach ($totalSpecial as $key => $amount) { + if ($amount === 0) { + continue; + } + + $key = \str_replace('total_', '', $key); + + $billElement = new BillElement(); + $billElement->bill = $bill; + + $billElement->taxR->value = $taxRates; + + $internalRequest = new HttpRequest(); + $internalResponse = new HttpResponse(); + + $internalRequest->header->account = $request->header->account; + $internalRequest->header->l11n = $request->header->l11n; + + $internalRequest->setData('search', $key); + $internalRequest->setData('limit', 1); + + $internalResponse->header->l11n = clone $response->header->l11n; + $internalResponse->header->l11n->language = $bill->language; + + $this->app->moduleManager->get('ItemManagement', 'Api')->apiItemFind($internalRequest, $internalResponse); + $item = $internalResponse->getDataArray('')[0]; + + $billElement->itemName = $key; + + if ($item->id !== 0) { + $billElement->item = $item; + $billElement->itemNumber = $item->number; + $billElement->itemName = $item->getL11n('name1')->content; + } + + $billElement->quantity->value = FloatInt::DIVISOR; + + // Unit + $billElement->singleListPriceNet = new FloatInt($amount, $l11n->thousands, $l11n->decimal); + + $billElement->singleSalesPriceNet = $billElement->singleListPriceNet; + $billElement->singlePurchasePriceNet = $billElement->singleSalesPriceNet; + + if ($billElement->taxR->value > 0) { + $billElement->singleListPriceGross->value = $billElement->singleListPriceNet->value + ((int) \round($billElement->singleSalesPriceNet->value * $billElement->taxR->value / (FloatInt::DIVISOR * 100), $rd)); + $billElement->singleSalesPriceGross = $billElement->singleListPriceGross; + } else { + $billElement->singleListPriceGross = $billElement->singleListPriceNet; + $billElement->singleSalesPriceGross = $billElement->singleListPriceGross; + } + + // Total + $billElement->totalListPriceNet = $billElement->singleListPriceNet; + $billElement->totalSalesPriceNet = $billElement->singleSalesPriceNet; + $billElement->totalPurchasePriceNet = $billElement->singlePurchasePriceNet; + $billElement->totalListPriceGross = $billElement->singleListPriceGross; + $billElement->totalSalesPriceGross = $billElement->singleSalesPriceGross; + + $billElement->taxP->value = $billElement->totalSalesPriceGross->value - $billElement->totalSalesPriceNet->value; + + $billElement->recalculatePrices(); + $bill->elements[] = $billElement; + + $this->createModel($request->header->account, $billElement, BillElementMapper::class, 'bill_element', $request->getOrigin()); + } + } + + if (!empty($bill->elements)) { + // Calculate totals from elements + $totalNet = 0; + $totalGross = 0; + foreach ($bill->elements as $element) { + $totalNet += $element->totalSalesPriceNet->value; + $totalGross += $element->totalSalesPriceGross->value; + } + + $bill->grossSales = new FloatInt($totalGross); + $bill->netCosts = new FloatInt($totalNet); + $bill->netSales = $bill->netCosts; + } + + $bill->taxP->value = $bill->grossSales->value - $bill->netSales->value; + + // No elements could be identified -> make total a bill element + if (empty($itemLines) && empty($bill->elements)) { + $billElement = new BillElement(); + $billElement->bill = $bill; + + // List price + $billElement->singleListPriceNet->value = $bill->netSales->value; + $billElement->totalListPriceNet->value = $bill->netSales->value; + + $billElement->singleListPriceGross->value = $bill->grossSales->value; + $billElement->totalListPriceGross->value = $bill->grossSales->value; + + // Unit price + $billElement->singleSalesPriceNet->value = $bill->netSales->value; + $billElement->singlePurchasePriceNet->value = $bill->netSales->value; + + $billElement->singleSalesPriceGross->value = $bill->grossSales->value; + + // Total + $billElement->totalSalesPriceNet->value = $bill->netSales->value; + $billElement->totalPurchasePriceNet->value = $bill->netSales->value; + + $billElement->totalSalesPriceGross->value = $bill->grossSales->value; + + $billElement->taxP->value = $bill->taxP->value; + $billElement->taxR->value = $taxRates; + + $billElement->recalculatePrices(); + $bill->elements[] = $billElement; + + $this->createModel($request->header->account, $billElement, BillElementMapper::class, 'bill_element', $request->getOrigin()); + } + + // Re-calculate totals from elements due to change + $totalNet = 0; + $totalGross = 0; + foreach ($bill->elements as $element) { + $totalNet += $element->totalSalesPriceNet->value; + $totalGross += $element->totalSalesPriceGross->value; + } + + $bill->grossSales = new FloatInt($totalGross); + $bill->netCosts = new FloatInt($totalNet); + $bill->netSales = $bill->netCosts; + + $bill->taxP->value = $bill->grossSales->value - $bill->netSales->value; $this->updateModel($request->header->account, $old, $bill, BillMapper::class, 'bill_parsing', $request->getOrigin()); - $view = new View($this->app->l11nManager, $request, $response); - $view->setTemplate('/Modules/Billing/Theme/Cli/bill-parsed'); + // @todo change tax code during/after bill parsing + $view->data['bill'] = $bill; - // @todo change tax code during/after bill parsing + // Fix internal document + $request->setData('bill', $bill->id, true); + $billResponse = new HttpResponse(); + $this->app->moduleManager->get('Billing', 'ApiBill')->apiBillPdfArchiveCreate($request, $billResponse); return $view; } - /** - * Detect language from content - * - * @param string $content String to analyze - * - * @return string - * - * @since 1.0.0 - */ - private function detectLanguage(string $content) : string - { - $detector = new Language(); - $language = $detector->detect($content)->bestResults()->close(); - - if (!\is_array($language) || \count($language) < 1) { - return 'en'; - } - - return \substr(\array_keys($language)[0], 0, 2); - } /** * Detect the supplier bill type @@ -206,291 +473,6 @@ final class CliController extends Controller return empty($bestMatch) ? 'purchase_invoice' : $bestMatch; } - /** - * Detect the supplier bill number - * - * @param string[] $lines Bill lines - * @param array $matches Number match patterns - * - * @return string - * - * @since 1.0.0 - */ - private function findBillNumber(array $lines, array $matches) : string - { - $bestPos = \count($lines); - $bestMatch = ''; - - $found = []; - - foreach ($matches as $match) { - foreach ($lines as $row => $line) { - if (\preg_match($match, $line, $found) === 1) { - if ($row < $bestPos) { - $bestPos = $row; - $bestMatch = \trim($found['bill_no']); - } - - break; - } - } - } - - return $bestMatch; - } - - /** - * Detect the supplier bill due date - * - * @param string[] $lines Bill lines - * @param array $matches Due match patterns - * - * @return string - * - * @since 1.0.0 - */ - private function findBillDue(array $lines, array $matches) : string - { - $bestPos = \count($lines); - $bestMatch = ''; - - $found = []; - - foreach ($matches as $match) { - foreach ($lines as $row => $line) { - if (\preg_match($match, $line, $found) === 1) { - if ($row < $bestPos) { - // @todo don't many invoices have the due date at the bottom? bestPos doesn't make sense?! - $bestPos = $row; - $bestMatch = \trim($found['bill_due']); - } - - break; - } - } - } - - return $bestMatch; - } - - /** - * Detect the supplier bill date - * - * @param string[] $lines Bill lines - * @param array $matches Date match patterns - * - * @return string - * - * @since 1.0.0 - */ - private function findBillDate(array $lines, array $matches) : string - { - $bestPos = \count($lines); - $bestMatch = ''; - - $found = []; - - foreach ($matches as $match) { - foreach ($lines as $row => $line) { - if (\preg_match($match, $line, $found) === 1) { - if ($row < $bestPos) { - $bestPos = $row; - $bestMatch = \trim($found['bill_date']); - } - - break; - } - } - } - - return $bestMatch; - } - - /** - * Detect the supplier bill gross amount - * - * @param string[] $lines Bill lines - * @param array $matches Tax match patterns - * - * @return int - * - * @since 1.0.0 - * @todo Handle multiple tax lines - * Example: 19% and 7% - */ - private function findBillTaxAmount(array $lines, array $matches) : int - { - $bestMatch = 0; - - $found = []; - - foreach ($matches as $match) { - foreach ($lines as $line) { - if (\preg_match($match, $line, $found) === 1) { - $temp = \trim($found['total_tax']); - - $posD = \stripos($temp, '.'); - $posK = \stripos($temp, ','); - - $hasDecimal = ($posD !== false || $posK !== false) - && \max((int) $posD, (int) $posK) + 3 >= \strlen($temp); - - $gross = ((int) \str_replace(['.', ','], ['', ''], $temp)) * ($hasDecimal - ? 100 - : 10000); - - if ($gross > $bestMatch) { - $bestMatch = $gross; - } - } - } - } - - return $bestMatch; - } - - /** - * Detect the supplier bill gross amount - * - * @param string[] $lines Bill lines - * @param array $matches Net match patterns - * - * @return int - * - * @since 1.0.0 - * @todo maybe check with taxes - * @todo maybe make sure text position is before total_gross - */ - private function findBillNet(array $lines, array $matches) : int - { - $bestMatch = 0; - - $found = []; - - foreach ($matches as $match) { - foreach ($lines as $line) { - if (\preg_match($match, $line, $found) === 1) { - $temp = \trim($found['total_net']); - - $posD = \stripos($temp, '.'); - $posK = \stripos($temp, ','); - - $hasDecimal = ($posD !== false || $posK !== false) - && \max((int) $posD, (int) $posK) + 3 >= \strlen($temp); - - $gross = ((int) \str_replace(['.', ','], ['', ''], $temp)) * ($hasDecimal - ? 100 - : 10000); - - if ($gross > $bestMatch) { - $bestMatch = $gross; - } - } - } - } - - return $bestMatch; - } - - /** - * Detect the supplier bill gross amount - * - * @param string[] $lines Bill lines - * @param array $matches Gross match patterns - * - * @return int - * - * @since 1.0.0 - */ - private function findBillGross(array $lines, array $matches) : int - { - $bestMatch = 0; - - $found = []; - - foreach ($matches as $match) { - foreach ($lines as $line) { - if (\preg_match($match, $line, $found) === 1) { - $temp = \trim($found['total_gross']); - - $posD = \stripos($temp, '.'); - $posK = \stripos($temp, ','); - - $hasDecimal = ($posD !== false || $posK !== false) - && \max((int) $posD, (int) $posK) + 3 >= \strlen($temp); - - $gross = ((int) \str_replace(['.', ','], ['', ''], $temp)) * ($hasDecimal - ? 100 - : 10000); - - if ($gross > $bestMatch) { - $bestMatch = $gross; - } - } - } - } - - return $bestMatch; - } - - /** - * Detect the supplier bill gross amount - * - * @param string[] $lines Bill lines - * @param array $matches Item lines match patterns - * - * @return array - * - * @since 1.0.0 - */ - private function findBillItemLines(array $lines, array $matches) : array - { - // Find start for item list (should be a headline containing certain words) - $startLine = 0; - $bestMatch = 0; - - foreach ($lines as $idx => $line) { - $headlineMatches = 0; - - foreach ($matches['headline'] as $match) { - foreach ($match as $headline) { - if (\stripos($line, $headline) !== false) { - ++$headlineMatches; - - continue; - } - } - } - - if ($headlineMatches > $bestMatch && $headlineMatches > 1) { - $bestMatch = $headlineMatches; - $startLine = $idx; - } - } - - if ($startLine === 0) { - return []; - } - - // Get headline structure = item list structure - $headlineStructure = []; - foreach ($matches['headline'] as $type => $match) { - foreach ($match as $headline) { - if (($pos = \stripos($line, $headline)) !== false) { - $headlineStructure[$type] = $pos; - - continue; - } - } - } - - \asort($headlineStructure); - - // Get item list until end of item list/table is reached - - return []; - } - /** * Find possible supplier id * @@ -498,7 +480,6 @@ final class CliController extends Controller * 1. bill_match_pattern * 2. name1 + IBAN * 3. name1 + city || address - * 4. name1 * * @param string $content Content to analyze * @param Supplier[] $suppliers Suppliers @@ -544,44 +525,6 @@ final class CliController extends Controller } } - // name1 - foreach ($suppliers as $supplier) { - if (\stripos($content, $supplier->account->name1) !== false) { - return $supplier->id; - } - } - return 0; } - - /** - * Create DateTime from date string - * - * @param string $date Date string - * @param Supplier $supplier Supplier - * @param string[] $formats Date formats - * - * @return null|\DateTime - * - * @since 1.0.0 - */ - private function parseDate(string $date, Supplier $supplier, array $formats) : ?\DateTime - { - if ((!empty($supplier->getAttribute('bill_date_format')->value->valueStr))) { - $dt = \DateTime::createFromFormat( - $supplier->getAttribute('bill_date_format')->value->valueStr ?? '', - $date - ); - - return $dt === false ? new \DateTime('1970-01-01') : $dt; - } - - foreach ($formats as $format) { - if (($obj = \DateTime::createFromFormat($format, $date)) !== false) { - return $obj === false ? null : $obj; - } - } - - return null; - } } diff --git a/Models/Bill.php b/Models/Bill.php index d366729..64197df 100755 --- a/Models/Bill.php +++ b/Models/Bill.php @@ -65,6 +65,8 @@ class Bill implements \JsonSerializable */ public string $number = ''; + public string $external = ''; + /** * Bill type. * @@ -247,8 +249,6 @@ class Bill implements \JsonSerializable */ public Account $referral; - public string $externalReferral = ''; - /** * Net amount. * @@ -257,14 +257,6 @@ class Bill implements \JsonSerializable */ public FloatInt $netProfit; - /** - * Gross amount. - * - * @var FloatInt - * @since 1.0.0 - */ - public FloatInt $grossProfit; - /** * Costs in net. * @@ -273,14 +265,6 @@ class Bill implements \JsonSerializable */ public FloatInt $netCosts; - /** - * Profit in net. - * - * @var FloatInt - * @since 1.0.0 - */ - public FloatInt $grossCosts; - /** * Costs in net. * @@ -305,14 +289,6 @@ class Bill implements \JsonSerializable */ public FloatInt $netDiscount; - /** - * Profit in net. - * - * @var FloatInt - * @since 1.0.0 - */ - public FloatInt $grossDiscount; - /** * Tax amount * @@ -443,13 +419,10 @@ class Bill implements \JsonSerializable public function __construct() { $this->netProfit = new FloatInt(0); - $this->grossProfit = new FloatInt(0); $this->netCosts = new FloatInt(0); - $this->grossCosts = new FloatInt(0); $this->netSales = new FloatInt(0); $this->grossSales = new FloatInt(0); $this->netDiscount = new FloatInt(0); - $this->grossDiscount = new FloatInt(0); $this->taxP = new FloatInt(0); $this->billDate = new \DateTime('now'); @@ -551,15 +524,92 @@ class Bill implements \JsonSerializable $this->elements[] = $element; $this->netProfit->value += $element->totalProfitNet->value; - $this->grossProfit->value += $element->totalProfitGross->value; $this->netCosts->value += $element->totalPurchasePriceNet->value; - $this->grossCosts->value += $element->totalPurchasePriceGross->value; $this->netSales->value += $element->totalSalesPriceNet->value; $this->grossSales->value += $element->totalSalesPriceGross->value; $this->netDiscount->value += $element->totalDiscountP->value; + } - // @todo Discount might be in quantities - $this->grossDiscount->value += (int) ($element->taxR->value * $element->totalDiscountP->value / 10000); + // @todo also consider rounding similarly to recalculatePrices in elements + public function isValid() : bool + { + return $this->validateTaxAmountElements() + && $this->validateProfit() + && $this->validateGrossElements() + && $this->validatePriceQuantityElements() + && $this->validateNetElements() + && $this->validateNetGross() + && $this->areElementsValid(); + } + + public function areElementsValid() : bool + { + foreach ($this->elements as $element) { + if (!$element->isValid()) { + return false; + } + } + + return true; + } + + public function validateNetGross() : bool + { + return $this->netSales->value <= $this->grossSales->value; + } + + public function validateProfit() : bool + { + return $this->netSales->value - $this->netCosts->value === $this->netProfit->value; + } + + public function validateTax() : bool + { + return \abs($this->netSales->value + $this->taxP->value - $this->grossSales->value) === 0; + } + + public function validateTaxAmountElements() : bool + { + $taxes = 0; + foreach ($this->elements as $element) { + $taxes += $element->taxP->value; + } + + return $taxes === $this->taxP->value; + } + + public function validateNetElements() : bool + { + $net = 0; + foreach ($this->elements as $element) { + $net += $element->totalSalesPriceNet->value; + } + + return $net === $this->netSales->value; + } + + public function validateGrossElements() + { + $gross = 0; + foreach ($this->elements as $element) { + $gross += $element->totalSalesPriceGross->value; + } + + return $gross === $this->grossSales->value; + } + + public function validatePriceQuantityElements() + { + foreach ($this->elements as $element) { + if ($element->discountQ->value === 0 + && $element->totalDiscountP->value === 0 + && ($element->quantity->value / FloatInt::DIVISOR) * $element->singleSalesPriceNet->value - $element->totalSalesPriceNet->value > 1.0 + ) { + return false; + } + } + + return true; } /** @@ -570,6 +620,7 @@ class Bill implements \JsonSerializable return [ 'id' => $this->id, 'number' => $this->number, + 'external' => $this->external, 'type' => $this->type, 'shipTo' => $this->shipTo, 'shipFAO' => $this->shipFAO, diff --git a/Models/BillElement.php b/Models/BillElement.php index 3091223..6041f00 100755 --- a/Models/BillElement.php +++ b/Models/BillElement.php @@ -19,6 +19,7 @@ use Modules\Finance\Models\TaxCode; use Modules\ItemManagement\Models\Container; use Modules\ItemManagement\Models\Item; use Modules\ItemManagement\Models\NullItem; +use phpOMS\Localization\ISO4217DecimalEnum; use phpOMS\Stdlib\Base\FloatInt; use phpOMS\Stdlib\Base\SmartDateTime; @@ -100,20 +101,12 @@ class BillElement implements \JsonSerializable public FloatInt $singlePurchasePriceNet; - public FloatInt $singlePurchasePriceGross; - public FloatInt $totalPurchasePriceNet; - public FloatInt $totalPurchasePriceGross; - public FloatInt $singleProfitNet; - public FloatInt $singleProfitGross; - public FloatInt $totalProfitNet; - public FloatInt $totalProfitGross; - public ?int $itemSegment = null; public ?int $itemSection = null; @@ -178,7 +171,7 @@ class BillElement implements \JsonSerializable { $this->bill = new NullBill(); - $this->quantity = new FloatInt(); + $this->quantity = new FloatInt(FloatInt::DIVISOR); $this->singleListPriceNet = new FloatInt(); $this->singleListPriceGross = new FloatInt(); @@ -195,16 +188,10 @@ class BillElement implements \JsonSerializable $this->totalSalesPriceGross = new FloatInt(); $this->singlePurchasePriceNet = new FloatInt(); - $this->singlePurchasePriceGross = new FloatInt(); - $this->totalPurchasePriceNet = new FloatInt(); - $this->totalPurchasePriceGross = new FloatInt(); $this->singleProfitNet = new FloatInt(); - $this->singleProfitGross = new FloatInt(); - $this->totalProfitNet = new FloatInt(); - $this->totalProfitGross = new FloatInt(); $this->singleDiscountP = new FloatInt(); $this->totalDiscountP = new FloatInt(); @@ -237,30 +224,95 @@ class BillElement implements \JsonSerializable public function recalculatePrices() : void { - $this->totalListPriceNet->value = (int) \round(($this->quantity->getNormalizedValue() - $this->discountQ->getNormalizedValue()) * $this->singleListPriceNet->value, 0); - $this->totalSalesPriceNet->value = (int) \round(($this->quantity->getNormalizedValue() - $this->discountQ->getNormalizedValue()) * $this->singleListPriceNet->value, 0); + $rd = -FloatInt::MAX_DECIMALS + ISO4217DecimalEnum::getByName('_' . $this->bill->currency); + + $this->totalListPriceNet->value = (int) \round($this->quantity->getNormalizedValue() * $this->singleListPriceNet->value, $rd); + $this->totalSalesPriceNet->value = (int) \round(($this->quantity->getNormalizedValue() - $this->discountQ->getNormalizedValue()) * $this->singleListPriceNet->value, $rd); // @todo Check if this is correct, this should maybe happen after applying the discounts?! // This depends on if the single price is already discounted or not $this->singleProfitNet->value = $this->singleSalesPriceNet->value - $this->singlePurchasePriceNet->value; $this->totalProfitNet->value = $this->totalSalesPriceNet->value - $this->totalPurchasePriceNet->value; - $this->taxP->value = (int) \round($this->taxR->value / 1000000 * $this->totalSalesPriceNet->value, 0); + $this->taxP->value = (int) \round($this->taxR->value / (FloatInt::DIVISOR * 100) * $this->totalSalesPriceNet->value, $rd); - $this->singleListPriceGross->value = (int) \round($this->singleListPriceNet->value + $this->singleListPriceNet->value * $this->taxR->value / 10000, 0); - $this->totalListPriceGross->value = (int) \round($this->totalListPriceNet->value + $this->totalListPriceNet->value * $this->taxR->value / 10000, 0); - $this->singleSalesPriceGross->value = (int) \round($this->singleSalesPriceNet->value + $this->singleSalesPriceNet->value * $this->taxR->value / 10000, 0); - $this->totalSalesPriceGross->value = (int) \round($this->totalSalesPriceNet->value + $this->totalSalesPriceNet->value * $this->taxR->value / 10000, 0); - - $this->singleProfitGross->value = $this->singleSalesPriceGross->value - $this->singlePurchasePriceGross->value; - $this->totalProfitGross->value = (int) \round(($this->quantity->getNormalizedValue() - $this->discountQ->getNormalizedValue()) * ($this->totalSalesPriceGross->value - $this->totalPurchasePriceGross->value), 0); + $this->singleListPriceGross->value = (int) \round($this->singleListPriceNet->value + $this->singleListPriceNet->value * $this->taxR->value / (FloatInt::DIVISOR * 100), $rd); + $this->totalListPriceGross->value = (int) \round($this->totalListPriceNet->value + $this->totalListPriceNet->value * $this->taxR->value / (FloatInt::DIVISOR * 100), $rd); + $this->singleSalesPriceGross->value = (int) \round($this->singleSalesPriceNet->value + $this->singleSalesPriceNet->value * $this->taxR->value / (FloatInt::DIVISOR * 100), $rd); + $this->totalSalesPriceGross->value = (int) \round($this->totalSalesPriceNet->value + $this->totalSalesPriceNet->value * $this->taxR->value / (FloatInt::DIVISOR * 100), $rd); $this->singleDiscountP->value = $this->quantity->value - $this->discountQ->value === 0 ? 0 : (int) \round($this->totalDiscountP->value / ($this->quantity->getNormalizedValue() - $this->discountQ->getNormalizedValue())); // important because the quantity includes $discountQ - $this->effectiveSingleSalesPriceNet->value = (int) \round($this->totalSalesPriceNet->value / ($this->quantity->value / 10000)); + $this->effectiveSingleSalesPriceNet->value = (int) \round($this->totalSalesPriceNet->value / ($this->quantity->value / FloatInt::DIVISOR), $rd); + } + + // @todo also consider rounding similarly to recalculatePrices + public function isValid() : bool + { + return $this->validateNetGross() + && $this->validateProfit() + && $this->validateTax() + && $this->validateTaxRate() + && $this->validateSingleTotal() + && $this->validateEffectiveSinglePrice() + && $this->validateTotalPrice(); + } + + public function validateNetGross() : bool + { + return $this->singleListPriceNet->value <= $this->singleListPriceGross->value + && $this->singleSalesPriceNet->value <= $this->singleSalesPriceGross->value + && $this->totalListPriceNet->value <= $this->totalListPriceGross->value + && $this->totalSalesPriceNet->value <= $this->totalSalesPriceGross->value; + } + + public function validateProfit() : bool + { + return $this->totalSalesPriceNet->value - $this->totalPurchasePriceNet->value === $this->totalProfitNet->value; + } + + public function validateTax() : bool + { + $paidQuantity = $this->quantity->value - $this->discountQ->value; + + return \abs($this->singleListPriceNet->value + ((int) \round($this->taxP->value / ($paidQuantity / FloatInt::DIVISOR), 0)) - $this->singleListPriceGross->value) === 0 + && \abs($this->singleSalesPriceNet->value + ((int) \round($this->taxP->value / ($paidQuantity / FloatInt::DIVISOR), 0)) - $this->singleSalesPriceGross->value) === 0 + && \abs($this->totalListPriceNet->value +$this->taxP->value - $this->totalListPriceGross->value) === 0 + && \abs($this->totalSalesPriceNet->value + $this->taxP->value - $this->totalSalesPriceGross->value) === 0; + } + + public function validateTaxRate() : bool + { + return (($this->taxP->value === 0 && $this->taxR->value === 0) + || (\abs($this->taxP->value / $this->totalSalesPriceNet->value - $this->taxR->value / (FloatInt::DIVISOR * 100)) < 0.001) + && \abs($this->totalSalesPriceGross->value / $this->totalSalesPriceNet->value - 1.0 - $this->taxR->value / (FloatInt::DIVISOR * 100)) < 0.001); + } + + public function validateSingleTotal() : bool + { + $paidQuantity = $this->quantity->value - $this->discountQ->value; + + // Only possible for sales, costs may be different for different lots + return ((int) \round($this->singleListPriceNet->value * ($this->quantity->value / FloatInt::DIVISOR), 0)) === $this->totalListPriceNet->value + && ((int) \round($this->singleSalesPriceNet->value * ($paidQuantity / FloatInt::DIVISOR), 0)) === $this->totalSalesPriceNet->value + && ((int) \round($this->singleDiscountP->value * ($this->quantity->value / FloatInt::DIVISOR), 0)) === $this->totalDiscountP->value; + } + + public function validateEffectiveSinglePrice() : bool + { + return $this->effectiveSingleSalesPriceNet->value === (int) \round($this->totalSalesPriceNet->value / ($this->quantity->value / FloatInt::DIVISOR)); + } + + public function validateTotalPrice() : bool + { + return ((int) \round($this->singleListPriceNet->value * ($this->quantity->value / FloatInt::DIVISOR) + - $this->singleListPriceNet->value * ($this->quantity->value / FloatInt::DIVISOR) * $this->singleDiscountR->value / (FloatInt::DIVISOR * 100) + - $this->totalDiscountP->value * ($this->quantity->value / FloatInt::DIVISOR) + - $this->singleListPriceNet->value * ($this->discountQ->value / FloatInt::DIVISOR), 0)) + === $this->totalSalesPriceNet->value; } /** @@ -292,7 +344,7 @@ class BillElement implements \JsonSerializable public static function fromItem( Item $item, TaxCombination $taxCombination, - int $quantity = 10000, + int $quantity = FloatInt::DIVISOR, Bill $bill = null, ?Container $container = null ) : self @@ -314,9 +366,6 @@ class BillElement implements \JsonSerializable $element->singlePurchasePriceNet->value = $item->purchasePrice->value; $element->totalPurchasePriceNet->value = (int) ($element->quantity->getNormalizedValue() * $item->purchasePrice->value); - $element->singlePurchasePriceGross->value = (int) \round($element->singlePurchasePriceNet->value + $element->singlePurchasePriceNet->value * $element->taxR->value / 10000, 0); - $element->totalPurchasePriceGross->value = (int) \round($element->totalPurchasePriceNet->value + $element->totalPurchasePriceNet->value * $element->taxR->value / 10000, 0); - if ($element->bill->id !== 0 && $item->getAttribute('subscription')->value->getValue() === 1 && $element->item !== null diff --git a/Models/BillElementMapper.php b/Models/BillElementMapper.php index 2abe925..8966399 100755 --- a/Models/BillElementMapper.php +++ b/Models/BillElementMapper.php @@ -59,14 +59,10 @@ final class BillElementMapper extends DataMapperFactory 'billing_bill_element_total_grosssalesprice' => ['name' => 'billing_bill_element_total_grosssalesprice', 'type' => 'Serializable', 'internal' => 'totalSalesPriceGross'], 'billing_bill_element_single_netprofit' => ['name' => 'billing_bill_element_single_netprofit', 'type' => 'Serializable', 'internal' => 'singleProfitNet'], - 'billing_bill_element_single_grossprofit' => ['name' => 'billing_bill_element_single_grossprofit', 'type' => 'Serializable', 'internal' => 'singleProfitGross'], 'billing_bill_element_total_netprofit' => ['name' => 'billing_bill_element_total_netprofit', 'type' => 'Serializable', 'internal' => 'totalProfitNet'], - 'billing_bill_element_total_grossprofit' => ['name' => 'billing_bill_element_total_grossprofit', 'type' => 'Serializable', 'internal' => 'totalProfitGross'], 'billing_bill_element_single_netpurchaseprice' => ['name' => 'billing_bill_element_single_netpurchaseprice', 'type' => 'Serializable', 'internal' => 'singlePurchasePriceNet'], - 'billing_bill_element_single_grosspurchaseprice' => ['name' => 'billing_bill_element_single_grosspurchaseprice', 'type' => 'Serializable', 'internal' => 'singlePurchasePriceGross'], 'billing_bill_element_total_netpurchaseprice' => ['name' => 'billing_bill_element_total_netpurchaseprice', 'type' => 'Serializable', 'internal' => 'totalPurchasePriceNet'], - 'billing_bill_element_total_grosspurchaseprice' => ['name' => 'billing_bill_element_total_grosspurchaseprice', 'type' => 'Serializable', 'internal' => 'totalPurchasePriceGross'], 'billing_bill_element_bill' => ['name' => 'billing_bill_element_bill', 'type' => 'int', 'internal' => 'bill'], 'billing_bill_element_tax_type' => ['name' => 'billing_bill_element_tax_type', 'type' => 'string', 'internal' => 'taxCode'], diff --git a/Models/BillMapper.php b/Models/BillMapper.php index 6073d59..eac195c 100755 --- a/Models/BillMapper.php +++ b/Models/BillMapper.php @@ -48,6 +48,7 @@ class BillMapper extends DataMapperFactory 'billing_bill_id' => ['name' => 'billing_bill_id', 'type' => 'int', 'internal' => 'id'], 'billing_bill_sequence' => ['name' => 'billing_bill_sequence', 'type' => 'int', 'internal' => 'sequence'], 'billing_bill_number' => ['name' => 'billing_bill_number', 'type' => 'string', 'internal' => 'number'], + 'billing_bill_external' => ['name' => 'billing_bill_external', 'type' => 'string', 'internal' => 'external'], 'billing_bill_type' => ['name' => 'billing_bill_type', 'type' => 'int', 'internal' => 'type'], 'billing_bill_template' => ['name' => 'billing_bill_template', 'type' => 'bool', 'internal' => 'isTemplate'], 'billing_bill_archived' => ['name' => 'billing_bill_archived', 'type' => 'bool', 'internal' => 'isArchived'], @@ -69,19 +70,15 @@ class BillMapper extends DataMapperFactory 'billing_bill_billZip' => ['name' => 'billing_bill_billZip', 'type' => 'string', 'internal' => 'billZip'], 'billing_bill_billCountry' => ['name' => 'billing_bill_billCountry', 'type' => 'string', 'internal' => 'billCountry'], 'billing_bill_netprofit' => ['name' => 'billing_bill_netprofit', 'type' => 'Serializable', 'internal' => 'netProfit'], - 'billing_bill_grossprofit' => ['name' => 'billing_bill_grossprofit', 'type' => 'Serializable', 'internal' => 'grossProfit'], 'billing_bill_netcosts' => ['name' => 'billing_bill_netcosts', 'type' => 'Serializable', 'internal' => 'netCosts'], - 'billing_bill_grosscosts' => ['name' => 'billing_bill_grosscosts', 'type' => 'Serializable', 'internal' => 'grossCosts'], 'billing_bill_netsales' => ['name' => 'billing_bill_netsales', 'type' => 'Serializable', 'internal' => 'netSales'], 'billing_bill_grosssales' => ['name' => 'billing_bill_grosssales', 'type' => 'Serializable', 'internal' => 'grossSales'], 'billing_bill_netdiscount' => ['name' => 'billing_bill_netdiscount', 'type' => 'Serializable', 'internal' => 'netDiscount'], - 'billing_bill_grossdiscount' => ['name' => 'billing_bill_grossdiscount', 'type' => 'Serializable', 'internal' => 'grossDiscount'], 'billing_bill_taxp' => ['name' => 'billing_bill_taxp', 'type' => 'Serializable', 'internal' => 'taxP'], 'billing_bill_fiaccount' => ['name' => 'billing_bill_fiaccount', 'type' => 'string', 'internal' => 'fiAccount'], 'billing_bill_currency' => ['name' => 'billing_bill_currency', 'type' => 'string', 'internal' => 'currency'], 'billing_bill_language' => ['name' => 'billing_bill_language', 'type' => 'string', 'internal' => 'language'], 'billing_bill_referral' => ['name' => 'billing_bill_referral', 'type' => 'int', 'internal' => 'referral'], - 'billing_bill_referral_name' => ['name' => 'billing_bill_referral_name', 'type' => 'string', 'internal' => 'externalReferral'], 'billing_bill_reference' => ['name' => 'billing_bill_reference', 'type' => 'int', 'internal' => 'reference'], 'billing_bill_accsegment' => ['name' => 'billing_bill_accsegment', 'type' => 'int', 'internal' => 'accSegment'], 'billing_bill_accsection' => ['name' => 'billing_bill_accsection', 'type' => 'int', 'internal' => 'accSection'], diff --git a/Models/BillType.php b/Models/BillType.php index 93a4484..8bfab10 100755 --- a/Models/BillType.php +++ b/Models/BillType.php @@ -54,6 +54,8 @@ class BillType implements \JsonSerializable public int $sign = 1; + public bool $email = false; + /** * Localization * diff --git a/Models/BillTypeMapper.php b/Models/BillTypeMapper.php index a3ec65c..c5452a0 100755 --- a/Models/BillTypeMapper.php +++ b/Models/BillTypeMapper.php @@ -46,6 +46,7 @@ final class BillTypeMapper extends DataMapperFactory 'billing_type_transfer_stock' => ['name' => 'billing_type_transfer_stock', 'type' => 'bool', 'internal' => 'transferStock'], 'billing_type_accounting' => ['name' => 'billing_type_accounting', 'type' => 'bool', 'internal' => 'isAccounting'], 'billing_type_transfer_sign' => ['name' => 'billing_type_transfer_sign', 'type' => 'int', 'internal' => 'sign'], + 'billing_type_email' => ['name' => 'billing_type_email', 'type' => 'bool', 'internal' => 'email'], 'billing_type_is_template' => ['name' => 'billing_type_is_template', 'type' => 'bool', 'internal' => 'isTemplate'], ]; diff --git a/Models/InvoiceRecognition.php b/Models/InvoiceRecognition.php new file mode 100644 index 0000000..18ed979 --- /dev/null +++ b/Models/InvoiceRecognition.php @@ -0,0 +1,1328 @@ + $value) { + if (empty(\trim($value))) { + unset($lines[$line]); + } + } + + $lines = \array_values($lines); + + $language = self::detectLanguage($content); + if (!\in_array($language, ['en', 'de'])) { + $language = 'en'; + } + + $bill->language = $language; + + $l11n = Localization::fromLanguage($language); + + $identifierContent = \file_get_contents(__DIR__ . '/bill_identifier.json'); + if ($identifierContent === false) { + $identifierContent = '{}'; + } + + /** @var array $identifiers */ + $identifiers = \json_decode($identifierContent, true); + + $bill->billCountry = InvoiceRecognition::findCountry($lines, $identifiers, $language); + + $currency = self::findCurrency($lines); + $countryCurrency = ISO4217CharEnum::currencyFromCountry($bill->billCountry); + + // Identified currency has to be country currency or one of the top globally used currencies + if ($currency !== \in_array($currency, [ + $countryCurrency, ISO4217CharEnum::_USD, ISO4217CharEnum::_EUR, ISO4217CharEnum::_JPY, + ISO4217CharEnum::_GBP, ISO4217CharEnum::_AUD, ISO4217CharEnum::_CAD, ISO4217CharEnum::_CHF, + ISO4217CharEnum::_CNH, ISO4217CharEnum::_CNY + ]) + ) { + $currency = $countryCurrency; + } + + $bill->currency = $currency; + + $rd = -FloatInt::MAX_DECIMALS + ISO4217DecimalEnum::getByName('_' . $bill->currency); + + /* Type */ + $type = self::findSupplierInvoiceType($content, $identifiers['type'], $language); + + /** @var \Modules\Billing\Models\BillType $billType */ + /* + $billType = BillTypeMapper::get() + ->where('name', $type) + ->execute(); + + $bill->type = new NullBillType($billType->id); + */ + + /* Number */ + $billNumber = self::findBillNumber($lines, $identifiers['bill_no'][$language]); + $bill->external = $billNumber; + + /* Reference / PO */ + // @todo implement + + /* Date */ + $billDateTemp = self::findBillDate($lines, $identifiers['bill_date'][$language]); + $billDate = self::parseDate($billDateTemp, $identifiers['date_format']); + + $bill->billDate = $billDate; + + /* Due */ + $billDueTemp = self::findBillDue($lines, $identifiers['bill_due'][$language]); + $billDue = self::parseDate($billDueTemp, $identifiers['date_format']); + // @todo implement multiple due dates for bills + + /* Total */ + $totalGross = self::findBillGross($lines, $identifiers['total_gross'][$language]); + $totalNet = self::findBillNet($lines, $identifiers['total_net'][$language]); + + // The number format needs to be corrected: + // Languages don't always respect the l11n number format + // Sometimes parsing errors can happen + $format = FloatInt::identifyNumericFormat($totalGross); + + if ($format !== null) { + $l11n->thousands = $format['thousands']; + $l11n->decimal = $format['decimal']; + } + + $bill->grossSales = new FloatInt($totalGross, $l11n->thousands, $l11n->decimal); + $bill->netSales = new FloatInt($totalNet, $l11n->thousands, $l11n->decimal); + + /* Total Tax */ + // @todo taxes depend on local tax id (if company in Germany but invoice from US -> only gross amount important, there is no net) + $totalTaxAmount = self::findBillTaxAmount($lines, $identifiers['total_tax'][$language]); + $taxRates = self::findBillTaxRates($lines, $identifiers['tax_rate'][$language]); + + if ($bill->netSales->value === 0) { + $bill->netSales->value = $taxRates === 0 + ? $bill->grossSales->value + : (int) \round($bill->grossSales->value / (1.0 + $taxRates / (FloatInt::DIVISOR * 100)), $rd); + } + + if ($bill->grossSales->value === 0) { + $bill->grossSales->value = $taxRates === 0 + ? $bill->netSales->value + : $bill->netSales->value + ((int) \round($bill->netSales->value * $taxRates / (FloatInt::DIVISOR * 100), $rd)); + } + + // We just assume that finding the net sales value is more likely + // If this turns out to be false, we need to recalculate the netSales from the grossSales instead + if ($bill->grossSales->value === $bill->netSales->value) { + $bill->grossSales->value = $bill->netSales->value + ((int) \round($bill->netSales->value * $taxRates / (FloatInt::DIVISOR * 100), $rd)); + } + + if ($taxRates === 0 && $bill->netSales->value !== $bill->grossSales->value) { + $taxRates = ((int) ($bill->grossSales->value / ($bill->grossSales->value / FloatInt::DIVISOR))) - FloatInt::DIVISOR; + } + + /* Item lines */ + $itemLines = self::findBillItemLines($lines, $identifiers['item_table'][$language]); + + // @todo Try to find item from item database + if (empty($bill->elements)) { + $itemLineEnd = 0; + foreach ($itemLines as $line => $itemLine) { + $itemLineEnd = $line; + + $billElement = new BillElement(); + $billElement->bill = $bill; + + $billElement->taxR->value = $taxRates; + + if (isset($itemLine['description'])) { + $billElement->itemName = \trim($itemLine['description']); + } + + if (isset($itemLine['quantity'])) { + $billElement->quantity = new FloatInt($itemLine['quantity'], $l11n->thousands, $l11n->decimal); + } + + // Unit + if (isset($itemLine['price'])) { + $billElement->singleListPriceNet = new FloatInt($itemLine['price'], $l11n->thousands, $l11n->decimal); + + $billElement->singleSalesPriceNet = $billElement->singleListPriceNet; + $billElement->singlePurchasePriceNet = $billElement->singleSalesPriceNet; + + if ($billElement->taxR->value > 0) { + $billElement->singleListPriceGross->value = $billElement->singleListPriceNet->value + ((int) \round($billElement->singleSalesPriceNet->value * $billElement->taxR->value / (FloatInt::DIVISOR * 100), $rd)); + $billElement->singleSalesPriceGross = $billElement->singleListPriceGross; + } else { + $billElement->singleListPriceGross = $billElement->singleListPriceNet; + $billElement->singleSalesPriceGross = $billElement->singleListPriceGross; + } + } + + // Total + if (isset($itemLine['total'])) { + $billElement->totalListPriceNet = new FloatInt($itemLine['total'], $l11n->thousands, $l11n->decimal); + + $billElement->totalSalesPriceNet = $billElement->totalListPriceNet; + $billElement->totalPurchasePriceNet = $billElement->totalSalesPriceNet; + + if ($billElement->taxR->value > 0) { + $billElement->totalListPriceGross->value = $billElement->totalListPriceNet->value + ((int) \round($billElement->totalSalesPriceNet->value * $billElement->taxR->value / (FloatInt::DIVISOR * 100), $rd)); + $billElement->totalSalesPriceGross = $billElement->totalListPriceGross; + } else { + $billElement->totalListPriceGross = $billElement->totalListPriceNet; + $billElement->totalSalesPriceGross = $billElement->totalListPriceGross; + } + } + + $billElement->taxP->value = $billElement->totalSalesPriceGross->value - $billElement->totalSalesPriceNet->value; + + $billElement->recalculatePrices(); + $bill->elements[] = $billElement; + } + + /* Total Special */ + // @question How do we want to apply total discounts? + // Option 1: Apply in relation to the amount per line item (this would be correct for stock evaluation) + // Option 2: Additional element (For correct stock evaluation we could do a internal/backend correction in the lot price calculation) + // + // Option 2 seems nicer from a user perspective! + $totalSpecial = self::findBillSpecial($lines, $identifiers, $language, $itemLineEnd); + foreach ($totalSpecial as $key => $amount) { + if ($amount === 0) { + continue; + } + + $key = \str_replace('total_', '', $key); + + $billElement = new BillElement(); + $billElement->bill = $bill; + + $billElement->taxR->value = $taxRates; + + $billElement->quantity->value = FloatInt::DIVISOR; + + // Unit + $billElement->singleListPriceNet = new FloatInt($amount, $l11n->thousands, $l11n->decimal); + + $billElement->singleSalesPriceNet = $billElement->singleListPriceNet; + $billElement->singlePurchasePriceNet = $billElement->singleSalesPriceNet; + + if ($billElement->taxR->value > 0) { + $billElement->singleListPriceGross->value = $billElement->singleListPriceNet->value + ((int) \round($billElement->singleSalesPriceNet->value * $billElement->taxR->value / (FloatInt::DIVISOR * 100), $rd)); + $billElement->singleSalesPriceGross = $billElement->singleListPriceGross; + } else { + $billElement->singleListPriceGross = $billElement->singleListPriceNet; + $billElement->singleSalesPriceGross = $billElement->singleListPriceGross; + } + + // Total + $billElement->totalListPriceNet = $billElement->singleListPriceNet; + $billElement->totalSalesPriceNet = $billElement->singleSalesPriceNet; + $billElement->totalPurchasePriceNet = $billElement->singlePurchasePriceNet; + $billElement->totalListPriceGross = $billElement->singleListPriceGross; + $billElement->totalSalesPriceGross = $billElement->singleSalesPriceGross; + + $billElement->taxP->value = $billElement->totalSalesPriceGross->value - $billElement->totalSalesPriceNet->value; + + $billElement->recalculatePrices(); + $bill->elements[] = $billElement; + } + } + + if (!empty($bill->elements)) { + // Calculate totals from elements + $totalNet = 0; + $totalGross = 0; + foreach ($bill->elements as $element) { + $totalNet += $element->totalSalesPriceNet->value; + $totalGross += $element->totalSalesPriceGross->value; + } + + + $bill->grossSales = new FloatInt($totalGross); + $bill->netCosts = new FloatInt($totalNet); + $bill->netSales = $bill->netCosts; + } + + $bill->taxP->value = $bill->grossSales->value - $bill->netSales->value; + + // No elements could be identified -> make total a bill element + if (empty($bill->elements)) { + $billElement = new BillElement(); + $billElement->bill = $bill; + + // List price + $billElement->singleListPriceNet->value = $bill->netSales->value; + $billElement->totalListPriceNet->value = $bill->netSales->value; + + $billElement->singleListPriceGross->value = $bill->grossSales->value; + $billElement->totalListPriceGross->value = $bill->grossSales->value; + + // Unit price + $billElement->singleSalesPriceNet->value = $bill->netSales->value; + $billElement->singlePurchasePriceNet->value = $bill->netSales->value; + + $billElement->singleSalesPriceGross->value = $bill->grossSales->value; + + // Total + $billElement->totalSalesPriceNet->value = $bill->netSales->value; + $billElement->totalPurchasePriceNet->value = $bill->netSales->value; + + $billElement->totalSalesPriceGross->value = $bill->grossSales->value; + + $billElement->taxP->value = $bill->taxP->value; + $billElement->taxR->value = $taxRates; + + $billElement->recalculatePrices(); + $bill->elements[] = $billElement; + } + + // Re-calculate totals from elements due to change + $totalNet = 0; + $totalGross = 0; + foreach ($bill->elements as $element) { + $totalNet += $element->totalSalesPriceNet->value; + $totalGross += $element->totalSalesPriceGross->value; + } + + $bill->grossSales = new FloatInt($totalGross); + $bill->netCosts = new FloatInt($totalNet); + $bill->netSales = $bill->netCosts; + + $bill->taxP->value = $bill->grossSales->value - $bill->netSales->value; + } + + /** + * Detect language from content + * + * @param string $content String to analyze + * + * @return string + * + * @since 1.0.0 + */ + public static function detectLanguage(string $content) : string + { + $detector = new Language(); + $language = $detector->detect($content)->bestResults()->close(); + + if (!\is_array($language) || empty($language)) { + return 'en'; + } + + return \substr(\array_keys($language)[0], 0, 2); + } + + /** + * Detect the supplier bill type + * + * @param string $content String to analyze + * @param array $types Possible bill types + * @param string $language Bill language + * + * @return string + * + * @since 1.0.0 + */ + public static function findSupplierInvoiceType(string $content, array $types, string $language) : string + { + $bestPos = \strlen($content); + $bestMatch = ''; + + foreach ($types as $name => $type) { + foreach ($type[$language] as $l11n) { + $found = \stripos($content, \strtolower($l11n)); + + if ($found !== false && $found < $bestPos) { + $bestPos = $found; + $bestMatch = $name; + } + } + } + + return empty($bestMatch) ? 'purchase_invoice' : $bestMatch; + } + + /** + * Detect the supplier bill number + * + * @param string[] $lines Bill lines + * @param array $matches Number match patterns + * + * @return string + * + * @since 1.0.0 + */ + public static function findBillNumber(array $lines, array $matches) : string + { + $bestPos = \count($lines); + $bestMatch = ''; + + $found = []; + + foreach ($matches as $match) { + foreach ($lines as $row => $line) { + if (\preg_match($match, $line, $found) === 1) { + if ($row < $bestPos) { + $bestPos = $row; + $bestMatch = $found['bill_no']; + } + + break; + } + } + } + + return \trim($bestMatch); + } + + /** + * Detect the supplier bill due date + * + * @param string[] $lines Bill lines + * @param array $matches Due match patterns + * + * @return string + * + * @since 1.0.0 + */ + public static function findBillDue(array $lines, array $matches) : string + { + $bestPos = \count($lines); + $bestMatch = ''; + + $found = []; + + foreach ($matches as $match) { + foreach ($lines as $row => $line) { + if (\preg_match($match, $line, $found) === 1) { + if ($row < $bestPos) { + // @todo don't many invoices have the due date at the bottom? bestPos doesn't make sense?! + $bestPos = $row; + $bestMatch = $found['bill_due']; + } + + break; + } + } + } + + return \trim($bestMatch); + } + + /** + * Detect the supplier bill date + * + * @param string[] $lines Bill lines + * @param array $matches Date match patterns + * + * @return string + * + * @since 1.0.0 + */ + public static function findBillDate(array $lines, array $matches) : string + { + $bestPos = \count($lines); + $bestMatch = ''; + + $found = []; + + foreach ($matches as $match) { + foreach ($lines as $row => $line) { + if (\preg_match($match, $line, $found) === 1) { + if ($row < $bestPos) { + $bestPos = $row; + $bestMatch = $found['bill_date']; + } + + break; + } + } + } + + return \trim($bestMatch); + } + + /** + * Detect the supplier bill gross amount + * + * @param string[] $lines Bill lines + * @param array $matches Tax match patterns + * + * @return int + * + * @since 1.0.0 + * @todo Handle multiple tax lines + * Example: 19% and 7% + */ + public static function findBillTaxAmount(array $lines, array $matches) : int + { + $bestMatch = 0; + + $found = []; + + foreach ($matches as $match) { + foreach ($lines as $line) { + if (\preg_match($match, $line, $found) === 1) { + $temp = \trim($found['total_tax']); + + $posD = \stripos($temp, '.'); + $posK = \stripos($temp, ','); + + $hasDecimal = ($posD !== false || $posK !== false) + && \max((int) $posD, (int) $posK) + 3 >= \strlen($temp); + + $gross = ((int) \str_replace(['.', ','], ['', ''], $temp)) * ($hasDecimal + ? 100 + : FloatInt::DIVISOR); + + if ($gross > $bestMatch) { + $bestMatch = $gross; + } + } + } + } + + return $bestMatch; + } + + /** + * Detect the supplier bill gross amount + * + * @param string[] $lines Bill lines + * @param array $matches Tax match patterns + * + * @return int + * + * @since 1.0.0 + * @todo Handle multiple tax lines + * Example: 19% and 7% + */ + public static function findBillTaxRates(array $lines, array $matches) : int + { + $bestMatch = 0; + + $found = []; + + foreach ($matches as $match) { + foreach ($lines as $line) { + if (\preg_match($match, $line, $found) === 1) { + $temp = \trim($found['tax_rate']); + + $posD = \stripos($temp, '.'); + $posK = \stripos($temp, ','); + + $hasDecimal = ($posD !== false || $posK !== false) + && \max((int) $posD, (int) $posK) + 3 >= \strlen($temp); + + $rate = ((int) \str_replace(['.', ','], ['', ''], $temp)) * ($hasDecimal + ? 100 + : FloatInt::DIVISOR); + + if ($rate > $bestMatch) { + $bestMatch = $rate; + } + } + } + } + + return $bestMatch; + } + + /** + * Detect the supplier bill gross amount + * + * @param string[] $lines Bill lines + * @param array $matches Net match patterns + * + * @return int + * + * @bug Issue with net/discount/gross in one line + * + * @since 1.0.0 + * @todo maybe check with taxes + * @todo maybe make sure text position is before total_gross + */ + public static function findBillNet(array $lines, array $matches) : string + { + $bestMatch = 0; + $bestMatchStr = ''; + + $found = []; + + foreach ($matches as $match) { + foreach ($lines as $line) { + if (\preg_match($match, $line, $found) === 1 + && \preg_match('/[,.]{1,1}[\d]{4}$/', $found['total_net']) !== 1 + ) { + $temp = \trim($found['total_net']); + + $posD = \stripos($temp, '.'); + $posK = \stripos($temp, ','); + + $hasDecimal = ($posD !== false || $posK !== false) + && \max((int) $posD, (int) $posK) + 3 >= \strlen($temp); + + $net = ((int) \str_replace(['.', ','], ['', ''], $temp)) * ($hasDecimal + ? 100 + : FloatInt::DIVISOR); + + if ($net > $bestMatch) { + $bestMatch = $net; + $bestMatchStr = $temp; + } + } + } + } + + return $bestMatchStr; + } + + /** + * Detect the supplier bill gross amount + * + * @param string[] $lines Bill lines + * @param array $matches Gross match patterns + * + * @return string + * + * @bug Issue with net/discount/gross in one line + * + * @since 1.0.0 + */ + public static function findBillGross(array $lines, array $matches) : string + { + $bestMatch = 0; + $bestMatchStr = ''; + + $found = []; + + foreach ($matches as $match) { + foreach ($lines as $line) { + if (\preg_match($match, $line, $found) === 1 + && \preg_match('/[,.]{1,1}[\d]{4}$/', $found['total_gross']) !== 1 + ) { + $temp = \trim($found['total_gross']); + + $posD = \stripos($temp, '.'); + $posK = \stripos($temp, ','); + + $hasDecimal = ($posD !== false || $posK !== false) + && \max((int) $posD, (int) $posK) + 3 >= \strlen($temp); + + $gross = ((int) \str_replace(['.', ','], ['', ''], $temp)) * ($hasDecimal + ? 100 + : FloatInt::DIVISOR); + + if ($gross > $bestMatch) { + $bestMatch = $gross; + $bestMatchStr = $temp; + } + } + } + } + + return $bestMatchStr; + } + + /** + * Detect the supplier bill gross amount + * + * @param string[] $lines Bill lines + * @param array $matches Gross match patterns + * + * @return array + * + * @bug Issue with net/discount/gross in one line + * + * @since 1.0.0 + */ + public static function findBillSpecial(array $lines, array $matches, string $language, int $lineStart) : array + { + // Find discounts + $bestDiscount = 0; + $found = []; + + $discountLine = 0; + + foreach ($matches['total_discount'][$language] as $match) { + foreach ($lines as $idx => $line) { + if ($idx < $lineStart) { + continue; + } + + if (\preg_match($match, $line, $found) === 1) { + $temp = \trim($found['total_discount']); + + $posD = \stripos($temp, '.'); + $posK = \stripos($temp, ','); + + $hasDecimal = ($posD !== false || $posK !== false) + && \max((int) $posD, (int) $posK) + 3 >= \strlen($temp); + + $discount = ((int) \str_replace(['.', ','], ['', ''], $temp)) * ($hasDecimal + ? 100 + : FloatInt::DIVISOR); + + $discount = \abs($discount); + + if ($discount > $bestDiscount) { + $bestDiscount = $discount; + $discountLine = $idx; + + break; + } + } + } + } + + // Find shipping + $bestShipping = 0; + $found = []; + + $shippingLine = 0; + + foreach ($matches['total_shipping'][$language] as $match) { + foreach ($lines as $idx => $line) { + if ($idx < $lineStart) { + continue; + } + + if (\preg_match($match, $line, $found) === 1) { + $temp = \trim($found['total_shipping']); + + $posD = \stripos($temp, '.'); + $posK = \stripos($temp, ','); + + $hasDecimal = ($posD !== false || $posK !== false) + && \max((int) $posD, (int) $posK) + 3 >= \strlen($temp); + + $shipping = ((int) \str_replace(['.', ','], ['', ''], $temp)) * ($hasDecimal + ? 100 + : FloatInt::DIVISOR); + + if ($shipping > $bestShipping) { + $bestShipping = $shipping; + $shippingLine = $idx; + + break; + } + } + } + } + + // Find customs + $bestCustoms = 0; + $found = []; + + $customsLine = 0; + + foreach ($matches['total_customs'][$language] as $match) { + foreach ($lines as $idx => $line) { + if ($idx < $lineStart) { + continue; + } + + if (\preg_match($match, $line, $found) === 1) { + $temp = \trim($found['total_customs']); + + $posD = \stripos($temp, '.'); + $posK = \stripos($temp, ','); + + $hasDecimal = ($posD !== false || $posK !== false) + && \max((int) $posD, (int) $posK) + 3 >= \strlen($temp); + + $customs = ((int) \str_replace(['.', ','], ['', ''], $temp)) * ($hasDecimal + ? 100 + : FloatInt::DIVISOR); + + if ($customs > $bestCustoms) { + $bestCustoms = $customs; + $customsLine = $idx; + + break; + } + } + } + } + + // Find insurance + $bestInsurance = 0; + $found = []; + + $insuranceLine = 0; + + foreach ($matches['total_insurance'][$language] as $match) { + foreach ($lines as $idx => $line) { + if ($idx < $lineStart) { + continue; + } + + if (\preg_match($match, $line, $found) === 1) { + $temp = \trim($found['total_insurance']); + + $posD = \stripos($temp, '.'); + $posK = \stripos($temp, ','); + + $hasDecimal = ($posD !== false || $posK !== false) + && \max((int) $posD, (int) $posK) + 3 >= \strlen($temp); + + $insurance = ((int) \str_replace(['.', ','], ['', ''], $temp)) * ($hasDecimal + ? 100 + : FloatInt::DIVISOR); + + if ($insurance > $bestInsurance) { + $bestInsurance = $insurance; + $insuranceLine = $idx; + + break; + } + } + } + } + + // Find surcharge + $bestSurcharge = 0; + $found = []; + + $surchargeLine = 0; + + foreach ($matches['total_surcharge'][$language] as $match) { + foreach ($lines as $idx => $line) { + if ($idx < $lineStart) { + continue; + } + + if (\preg_match($match, $line, $found) === 1 + && $idx !== $shippingLine + && $idx !== $customsLine + && $idx !== $insuranceLine + ) { + $temp = \trim($found['total_surcharge']); + + $posD = \stripos($temp, '.'); + $posK = \stripos($temp, ','); + + $hasDecimal = ($posD !== false || $posK !== false) + && \max((int) $posD, (int) $posK) + 3 >= \strlen($temp); + + $surcharge = ((int) \str_replace(['.', ','], ['', ''], $temp)) * ($hasDecimal + ? 100 + : FloatInt::DIVISOR); + + if ($surcharge > $bestSurcharge) { + $bestSurcharge = $surcharge; + $surchargeLine = $idx; + + break; + } + } + } + } + + return [ + 'total_discount' => -1 * $bestDiscount, + 'total_shipping' => $bestShipping, + 'total_customs' => $bestCustoms, + 'total_insurance' => $bestInsurance, + 'total_surcharge' => $bestSurcharge, + ]; + } + + /** + * Detect the supplier bill gross amount + * + * @param string[] $lines Bill lines + * @param array $matches Item lines match patterns + * + * @return array + * + * @since 1.0.0 + */ + public static function findBillItemLines(array $lines, array $matches) : array + { + // Find start for item list (should be a headline containing certain words) + $startLine = 0; + $bestMatch = 0; + + foreach ($lines as $idx => $line) { + $headlineMatches = 0; + + foreach ($matches['headline'] as $match) { + foreach ($match as $headline) { + if (\stripos($line, $headline) !== false) { + ++$headlineMatches; + + continue; + } + } + } + + if ($headlineMatches > $bestMatch && $headlineMatches > 1) { + $bestMatch = $headlineMatches; + $startLine = $idx; + } + } + + if ($startLine === 0) { + return []; + } + + // Find end of item lines + $line = $lines[$startLine]; + + // Get headline structure = item list structure + $headlineStructure = []; + foreach ($matches['headline'] as $type => $match) { + foreach ($match as $headline) { + // We have to make sure that there are + if (\preg_match('/(\s{1,}' . $headline . '|' . $headline . '\s{1,})/', $line) === 1) { + $headlineStructure[$type] = true; + + continue; + } + } + } + + \asort($headlineStructure); + + $rows = []; + + // Get item list until end of item list/table is reached + $found = []; + $structureCount = \count($headlineStructure); + $linesSkipped = 0; + + foreach ($lines as $l => $line) { + // @todo find better way to identify end of item table + // @bug find way to handle multiple pages + // @bug find way to handle multi-line item description + if ($l <= $startLine) { + continue; + } + + if ($linesSkipped > 2) { + break; + } + + if (\preg_match_all($matches['parts'], $line, $found) !== $structureCount) { + ++$linesSkipped; + continue; + } + + $linesSkipped = 0; + + $temp = []; + $c = 0; + foreach ($headlineStructure as $idx => $_) { + $subFound = []; + + $temp[$idx] = \preg_match($matches['row'][$idx], $found[2][$c], $subFound) === 1 + ? $subFound[0] + : ''; + + ++$c; + } + + $rows[$l] = $temp; + } + + return $rows; + } + + /** + * Create DateTime from date string + * + * @param string $date Date string + * @param string[] $formats Date formats + * + * @return null|\DateTime + * + * @since 1.0.0 + */ + public static function parseDate(string $date, array $formats, string $supplierFormat = '') : ?\DateTime + { + if ((!empty($supplierFormat))) { + $dt = \DateTime::createFromFormat( + $supplierFormat ?? '', + $date + ); + + return $dt === false ? new \DateTime('1970-01-01') : $dt; + } + + $now = new \DateTime('now'); + $bestMatch = null; + + foreach ($formats as $format) { + if (($obj = \DateTime::createFromFormat($format, $date)) !== false) { + if (\abs($obj->getTimestamp() - $now->getTimestamp()) < 60 * 60 * 24 * 365 * 10) { + // The estimated date should be within 10 years + return $obj; + } + + $bestMatch = $obj; + } + } + + return $bestMatch; + } + + /** + * Detect the supplier bill number + * + * @param string[] $lines Bill lines + * @param array $matches Number match patterns + * + * @return string + * + * @since 1.0.0 + */ + public static function findEmail(array $lines, array $matches) : string + { + $bestPos = \count($lines); + $bestMatch = ''; + + $found = []; + + foreach ($matches as $match) { + foreach ($lines as $row => $line) { + if (\preg_match($match, $line, $found) === 1) { + if ($row < $bestPos) { + $bestPos = $row; + $bestMatch = $found['email']; + } + + break; + } + } + } + + return \trim($bestMatch); + } + + /** + * Detect the supplier bill number + * + * @param string[] $lines Bill lines + * @param array $matches Number match patterns + * + * @return string + * + * @since 1.0.0 + */ + public static function findPhone(array $lines, array $matches) : string + { + $bestPos = \count($lines); + $bestMatch = ''; + + $found = []; + + foreach ($matches as $match) { + foreach ($lines as $row => $line) { + if (\preg_match($match, $line, $found) === 1) { + if ($row < $bestPos) { + $bestPos = $row; + $bestMatch = $found['phone']; + } + + break; + } + } + } + + return \trim($bestMatch); + } + + /** + * Detect the supplier bill number + * + * @param string[] $lines Bill lines + * @param array $matches Number match patterns + * + * @return string + * + * @since 1.0.0 + */ + public static function findWebsite(array $lines, array $matches) : string + { + $bestPos = \count($lines); + $bestMatch = ''; + + $found = []; + + foreach ($matches as $match) { + foreach ($lines as $row => $line) { + if (\preg_match($match, $line, $found) === 1) { + if ($row < $bestPos) { + $bestPos = $row; + $bestMatch = $found['website']; + } + + break; + } + } + } + + return \trim($bestMatch); + } + + /** + * Detect the supplier bill number + * + * @param string[] $lines Bill lines + * @param array $matches Number match patterns + * + * @return string + * + * @since 1.0.0 + */ + public static function findVat(array $lines, array $matches) : string + { + $bestPos = \count($lines); + $bestMatch = ''; + + $found = []; + + foreach ($matches as $match) { + foreach ($lines as $row => $line) { + if (\preg_match($match, $line, $found) === 1) { + if ($row < $bestPos) { + $bestPos = $row; + $bestMatch = $found['vat_id']; + } + + break; + } + } + } + + if (\stripos($bestMatch, 'S') > 1 + || \stripos($bestMatch, 'O') > 1 + ) { + $subIban = \substr($bestMatch, 2); + $subIban = \str_replace(['S', 'O'], ['5', '0'], $subIban); + $bestMatch = \substr($bestMatch, 0, 2) . $subIban; + } + + return \str_replace([' ', '-'], '', \strtoupper(\trim($bestMatch))); + } + + /** + * Detect the supplier bill number + * + * @param string[] $lines Bill lines + * @param array $matches Number match patterns + * + * @return string + * + * @since 1.0.0 + */ + public static function findTaxId(array $lines, array $matches) : string + { + $bestPos = \count($lines); + $bestMatch = ''; + + $found = []; + + // @performance A lot of these loops (see other functions as well) can be optimized + // Go over the lines first this way we stop the loop much earlier. + foreach ($matches as $match) { + foreach ($lines as $row => $line) { + if (\preg_match($match, $line, $found) === 1) { + if ($row < $bestPos) { + $bestPos = $row; + $bestMatch = $found['tax_id']; + } + + // Break 2 is required because here we also support searching for VAT ID. + // We do this because some software may use the identifiers for VAT and Tax id interchangeably + // The highest priority $match use the actual identifier and afterwards the other identifiers follow. + break 2; + } + } + } + + return \trim($bestMatch); + } + + /** + * Detect the supplier bill number + * + * @param string[] $lines Bill lines + * @param array $matches Number match patterns + * + * @return string + * + * @since 1.0.0 + */ + public static function findIban(array $lines, array $matches) : string + { + $bestPos = \count($lines); + $bestMatch = ''; + + $found = []; + + foreach ($matches as $match) { + foreach ($lines as $row => $line) { + if (\preg_match($match, $line, $found) === 1) { + if ($row < $bestPos) { + $bestPos = $row; + $bestMatch = $found['iban']; + } + + break; + } + } + } + + $bestMatch = \trim(\strtoupper($bestMatch)); + $bestMatch = \str_replace(' ', '', $bestMatch); + $bestMatch = \wordwrap($bestMatch, 4, ' ', true); + + // Trying to fix bad parsing + if (\stripos($bestMatch, 'S') > 1 + || \stripos($bestMatch, 'O') > 1 + ) { + $format = IbanEnum::getByName('_' . \substr($bestMatch, 0, 2)); + + $len = \strlen($bestMatch); + $formatLen = \strlen($format); + + for ($i = 0; $i < $len; ++$i) { + if ($i >= $formatLen) { + break; + } + + if ($format[$i] !== 'k' && $format[$i] !== 'n') { + continue; + } + + if ($bestMatch[$i] === 'O' + || $bestMatch[$i] === 'o' + ) { + $bestMatch[$i] = '0'; + } elseif ($bestMatch[$i] === 'S' + || $bestMatch[$i] === 's' + ) { + $bestMatch[$i] = '5'; + } + } + + } + + return \trim($bestMatch); + } + + public static function findCountry(array $lines, array $matches, string $language) : string + { + $iban = self::findIban($lines, $matches['iban']); + if (\phpOMS\Validation\Finance\Iban::isValid($iban)) { + $obj = new Iban($iban); + + if (ISO3166TwoEnum::isValidValue($obj->getCountry())) { + return \strtoupper($obj->getCountry()); + } + } + + $vatId = self::findVat($lines, $matches['vat_id'][$language]); + if (EUVat::isValid($vatId)) { + return \strtoupper(\substr($vatId, 0, 2)); + } + + $email = self::findEmail($lines, $matches['email']); + $country = \strtoupper(\substr($email, \strrpos($email, '.') + 1)); + + if (ISO3166TwoEnum::isValidValue($country)) { + return \strtoupper($country); + } + + $website = self::findWebsite($lines, $matches['website']); + $country = \strtoupper(\substr($website, \strrpos($website, '.') + 1)); + + if (ISO3166TwoEnum::isValidValue($country)) { + return \strtoupper($country); + } + + $countries = ISO3166TwoEnum::countryFromLanguage($language); + + return empty($countries) ? 'US' : \reset($countries); + } + + public static function findCurrency(array $lines) : string + { + $symbols = ISO4217SymbolEnum::getConstants(); + $currency = ''; + + foreach ($lines as $line) { + foreach ($symbols as $symbol) { + $match = $symbol; + if (\preg_match('/[\x20-\x7e]/', $symbol) === 1) { + $match = ' ' . $symbol . ' '; + } + + if (\strpos($line, $match) !== false) { + $currency = ISO4217SymbolEnum::getName($symbol); + $currency = ISO4217CharEnum::getByName($currency); + + break; + } + } + } + + if (!empty($currency)) { + return $currency; + } + + $symbols = ISO4217CharEnum::getConstants(); + + foreach ($lines as $line) { + foreach ($symbols as $symbol) { + if (\strpos($line, ' ' . $symbol . ' ') !== false) { + $currency = $symbol; + + break; + } + } + } + + return $currency; + } +} diff --git a/Models/PermissionCategory.php b/Models/PermissionCategory.php index ffe47a6..3e1458c 100755 --- a/Models/PermissionCategory.php +++ b/Models/PermissionCategory.php @@ -43,4 +43,6 @@ abstract class PermissionCategory extends Enum public const PAYMENT_TERM = 9; public const SHIPPING_TERM = 10; + + public const BILL_LOG = 101; } diff --git a/Models/Price/Price.php b/Models/Price/Price.php index 74eab01..023a524 100755 --- a/Models/Price/Price.php +++ b/Models/Price/Price.php @@ -55,6 +55,8 @@ class Price implements \JsonSerializable public Item $item; + public int $status = PriceStatus::ACTIVE; + public AttributeValue $itemsalesgroup; public AttributeValue $itemproductgroup; diff --git a/Models/Price/PriceMapper.php b/Models/Price/PriceMapper.php index 4c7dfc8..9fa9a26 100755 --- a/Models/Price/PriceMapper.php +++ b/Models/Price/PriceMapper.php @@ -60,6 +60,7 @@ final class PriceMapper extends DataMapperFactory 'billing_price_supplier' => ['name' => 'billing_price_supplier', 'type' => 'int', 'internal' => 'supplier'], 'billing_price_unit' => ['name' => 'billing_price_unit', 'type' => 'int', 'internal' => 'unit'], 'billing_price_type' => ['name' => 'billing_price_type', 'type' => 'int', 'internal' => 'type'], + 'billing_price_status' => ['name' => 'billing_price_status', 'type' => 'int', 'internal' => 'status'], 'billing_price_quantity' => ['name' => 'billing_price_quantity', 'type' => 'Serializable', 'internal' => 'quantity'], 'billing_price_price' => ['name' => 'billing_price_price', 'type' => 'Serializable', 'internal' => 'price'], 'billing_price_price_new' => ['name' => 'billing_price_price_new', 'type' => 'Serializable', 'internal' => 'priceNew'], diff --git a/Models/Price/PriceStatus.php b/Models/Price/PriceStatus.php new file mode 100644 index 0000000..74e3a21 --- /dev/null +++ b/Models/Price/PriceStatus.php @@ -0,0 +1,32 @@ += '{$start->format('Y-m-d H:i:s')}' AND billing_bill_performance_date <= '{$end->format('Y-m-d H:i:s')}' - GROUP BY billing_bill_element_item, year, month - ORDER BY billing_bill_element_item, year ASC, month ASC; + GROUP BY item, year, month + ORDER BY item, year ASC, month ASC; SQL; $query = new Builder(self::$db); diff --git a/Models/SettingsEnum.php b/Models/SettingsEnum.php index 038c6bd..9edcade 100755 --- a/Models/SettingsEnum.php +++ b/Models/SettingsEnum.php @@ -28,9 +28,13 @@ abstract class SettingsEnum extends Enum { public const PREVIEW_MEDIA_TYPE = '1005100001'; // internally generated preview - public const ORIGINAL_MEDIA_TYPE = '1005100002'; // original document (client = invoice sent to client, supplier = invoice from supplier) + public const INTERNAL_MEDIA_TYPE = '1005100002'; // original document (client = invoice sent to client, supplier = invoice from supplier) + + public const EXTERNAL_MEDIA_TYPE = '1005100006'; // original document (client = invoice sent to client, supplier = invoice from supplier) public const VALID_BILL_LANGUAGES = '1005100003'; // List of valid languages for bills public const BILLING_CUSTOMER_EMAIL_TEMPLATE = '1005100004'; // Email template for customer billing + + public const BILLING_SUPPLIER_EMAIL_TEMPLATE = '1005100005'; // Email template for supplier billing } diff --git a/Models/bill_identifier.json b/Models/bill_identifier.json index ee607ab..285b32a 100644 --- a/Models/bill_identifier.json +++ b/Models/bill_identifier.json @@ -35,130 +35,217 @@ "Auftragsbestätigung" ] }, + "purchase_offer": { + "en": [ + "Offer" + ], + "de": [ + "Angebot" + ] + }, "purchase_reverse_invoice": { "en": [], "de": [ "Gutschrift" ] + }, + "purchase_proforma_invoice": { + "en": [ + "Proforma" + ], + "de": [ + "Proforma" + ] } }, + "tax_id": { + "en": [ + "/(tax[\\. \\-]*id|tin|ssn|ein)(.*? {1,})(?(.{6,15}))( |$)/i", + "/(vat[\\. \\-]*id)(.*? {1,})(?([^a-zA-Z]{6,15}))( |$)/i" + ], + "de": [ + "/(Steuern|Steuer[\\. \\-]*Nr|St[\\. \\-]*Nr)(.*? {1,})(?(.{8,15}))( |$)/i", + "/(USt[\\. \\-]*Id|Umsatzst.*?Id)(.*? {1,})(?([^a-zA-Z]{8,15}))( |$)/i" + ] + }, + "vat_id": { + "en": [ + "/(vat[\\. \\-]*id)(.*? {1,})(?([a-zA-Z]{2})(.*?){7,13})( |$)/i" + ], + "de": [ + "/(USt[\\. \\-]*Id|Umsatzst.*?Id)(.*? {1,})(?([a-zA-Z]{2})(.*?){7,13})( |$)/i" + ] + }, + "iban": ["/(IBAN)(.*? {1,})(?([a-zA-Z]{2,}[ 0-9]{14,}))( |$)/i"], + "email": ["/(^| )(?([a-zA-Z0-9\\-]+@[a-zA-Z0-9\\-]+\\.[a-zA-Z]{2,}))( |$)/i"], + "website": ["/(^| )(?(https:\\/\\/|www\\.)([a-zA-Z0-9\\-]+\\.[a-zA-Z]{2,}))( |$)/i"], + "phone": { + "en": [ + "/(phone)(.*? {1,})(?([+0-9 \\/\\-\\(\\)]*[0-9]+[+0-9 \\/\\-\\(\\)]*){4,})( |[^ 0-9\\/\\(\\)+\\-]|$)/i" + ], + "de": [ + "/(Tel|Rufn)(.*? {1,})(?([+0-9 \\/\\-\\(\\)]*[0-9]+[+0-9 \\/\\-\\(\\)]*){4,})( |[^ 0-9\\/\\(\\)+\\-]|$)/i" + ] + }, "date_format": [ - "Y-m-d", - "Y.m.d", - "Y/m/d", - "d-m-Y", - "d.m.Y", - "d/m/Y", - "m-d-Y", - "m.d.Y", - "m/d/Y", - "M. d.Y", - "M. d. Y", - "M. d Y", - "M. d,Y", - "M. d, Y", - "M d.Y", - "M d. Y", - "M d Y", - "M d,Y", - "M d, Y", - "M, d.Y", - "M, d. Y", - "M, d Y", - "M, d,Y", - "M, d, Y" + "Y-m-d", "Y.m.d", "Y/m/d", + "d-m-Y", "d.m.Y", "d/m/Y", + "m-d-Y", "m.d.Y", "m/d/Y", + "M. d.Y", "M. d. Y", "M. d Y", + "M. d,Y", "M. d, Y", "M d.Y", + "M d. Y", "M d Y", "M d,Y", + "M d, Y", "M, d.Y", "M, d. Y", + "M, d Y", "M, d,Y", "M, d, Y", + "d-m-y", "d.m.y", "d/m/y", + "m-d-y", "m.d.y", "m/d/y" ], "bill_no": { "en": [ - "/(inv.*?)(no|\\s|,|:|\\.|#)+(?.*?)( |$)/i", - "/(#)(?.*?)( |$)/i" + "/(invoice|inv.*? no)(.*? {1,})(?\\S*?)( |$)/i", + "/(#)(?\\S*?)( |$)/i" ], "de": [ - "/(rechnungsn.*?|beleg.*?)(?.*?)( |$)/i" + "/(rechnungsn|rechnung n|belegn|beleg n)(.*? {1,})(?\\S*?)( |$)/i" ] }, "bill_date": { "en": [ - "/(inv.*?)(date.*?)(\\s|,|:|\\.)+(?.*?)( |$)/i", - "/(date.*?)(\\s|,|:|\\.)+(?.*?)( |$)/i" + "/(inv.*? )(date.*? )(?.{8,}?)( |$)/i", + "/(date.*? )(?.{8,}?)( |$)/i" ], "de": [ - "/(rechnungsdat.*?|belegdat.*?)(\\s|,|:|\\.)+(?.*?)( |$)/i" + "/(rechnungsdat|belegdat|datum)(.*? )(?\\S{8,}?)( |$)/i" ] }, "bill_due": { "en": [ - "/(due date.*?)(\\s|,|:|\\.)+(?.*?)( |$)/i", - "/(due.*?)(\\s|,|:|\\.)+(?.*?)( |$)/i" + "/(due date.*? )(?\\S{8,}?)( |$)/i", + "/(due.*? )(?\\S{8,}?)( |$)/i" ], "de": [ - "/(fällig.*?)(\\s|,|:|\\.)+(?.*?)( |$)/i" + "/(fällig.*? )(?\\S{8,}?)( |$)/i" ] }, "total_net": { "en": [ - "/(subtotal.*?|net.*?)(?([0-9]+,*\\.*)+)/i" + "/(subtotal|net)(.*? {1,})(?([0-9]+,*\\.*)+)(?! *%)/i" ], "de": [ - "/(netto.*?|zwischensumme.*?)(?([0-9]+,*\\.*)+)/i" + "/(netto|zwischensumme|betrag exk)(.*? {1,})(?([0-9]+,*\\.*)+)(?! *%)/i" + ] + }, + "total_discount": { + "en": [ + "/(discount)(.*? {1,})(?([0-9]+,*\\.*)+)(?! *%)/i" + ], + "de": [ + "/(rabatt)(.*? {1,})(?([0-9]+,*\\.*)+)(?! *%)/i" + ] + }, + "total_shipping": { + "en": [ + "/(fuel|handling|fright|shipping)(.*? {1,})(?([0-9]+,*\\.*)+)(?! *%)/i" + ], + "de": [ + "/(versand|transport|fracht)(.*? {1,})(?([0-9]+,*\\.*)+)(?! *%)/i" + ] + }, + "total_customs": { + "en": [ + "/(customs)(.*? {1,})(?([0-9]+,*\\.*)+)(?! *%)/i" + ], + "de": [ + "/(Einfuhr)(\\S* )(?([0-9]+,*\\.*)+)(?! *%)/i" + ] + }, + "total_insurance": { + "en": [ + "/(insurance)(.*? {1,})(?([0-9]+,*\\.*)+)(?! *%)/i" + ], + "de": [ + "/(versicherung)(.*? {1,})(?([0-9]+,*\\.*)+)(?! *%)/i" + ] + }, + "total_surcharge": { + "en": [ + "/(fee|surcharge)(.*? {1,})(?([0-9]+,*\\.*)+)(?! *%)/i" + ], + "de": [ + "/(gebühr)(.*? {1,})(?([0-9]+,*\\.*)+)(?! *%)/i" ] }, "total_tax": { "en": [ - "/(tax.*?)(?([0-9]+,*\\.*)+)/i" + "/(VAT|tax)(.*? {1,})(?([0-9]+,*\\.*)+)(?! *%)/i" ], "de": [ - "/(USt.*?|Mwst.*?|Umsatzsteuer.*?|Mehrwehrtsteuer.*?)(?([0-9]+,*\\.*)+)/i" + "/(USt|Mwst|Umsatzst|Mehrwertst)(.*? {1,})(?([0-9]+,*\\.*)+)(?! *%)/i", + "/( {1,})(USt|Mwst|Umsatzst|Mehrwertst)(?([0-9]+,*\\.*)+)(?! *%)/i" + ] + }, + "tax_rate": { + "en": [ + "/(VAT|tax)(.*? {1,})(?([0-9]+,*\\.*)+)(?= *%)/i" + ], + "de": [ + "/(USt|Mwst|Umsatzst|Mehrwertst)(.*? {1,})(?([0-9]+,*\\.*)+)(?= *%)/i", + "/( {1,})(?([0-9]+,*\\.*)+)(?= *%)(.*?)(USt|Mwst|Umsatzst|Mehrwertst)/i" ] }, "total_gross": { "en": [ - "/(total.*?|gross.*?)(?([0-9]+,*\\.*)+)/i" + "/(total|gross)(.*? {1,})(?([0-9]+,*\\.*)+)(?! *%)/i" ], "de": [ - "/(betrag.*?|gesamt.*?|brutto|rechnungsbetrag.*?|summe.*?)(?([0-9]+,*\\.*)+)/i" + "/(betrag|gesamt|brutto|summe)(.*? {1,})(?([0-9]+,*\\.*)+)(?! *%)/i" ] }, "item_table": { "en": { "headline": { - "order": ["no.", "#"], - "description": ["description", "name", "service", "product"], - "quantity": ["qty", "quantity"], - "price": ["price", "net", "gross"], + "order": ["no.", "#", "pos"], + "number": ["number"], + "description": ["description", "name", "service", "product", "item"], + "quantity": ["qty", "quantity", "hours"], + "price": ["price", "rate"], "unit": ["unit"], - "total": ["amount", "total", "price", "net", "gross"], + "total": ["amount", "total", "gross"], "tax": ["tax"] }, + "parts": "/( *)(.+?)(\\s{3,}|$)/i", "row": { - "order": "\\d+", - "description": ".*?", - "quantity": "[+-]?([0-9]{1,3}([,\\.][0-9]{3})*(,\\.[0-9]+)?|\\d*[,\\.]\\d+|\\d+)", - "price": "[+-]?([0-9]{1,3}([,\\.][0-9]{3})*(,\\.[0-9]+)?|\\d*[,\\.]\\d+|\\d+)", - "unit": ".*?", - "total": "[+-]?([0-9]{1,3}([,\\.][0-9]{3})*(,\\.[0-9]+)?|\\d*[,\\.]\\d+|\\d+)", - "tax": "[+-]?([0-9]{1,3}([,\\.][0-9]{3})*(,\\.[0-9]+)?|\\d*[,\\.]\\d+|\\d+)" + "order": "/\\d+/i", + "number": "/.*/i", + "description": "/.*/i", + "quantity": "/[+-]?([0-9]+,*\\.*)+/i", + "price": "/[+-]?([0-9]+,*\\.*)+/i", + "unit": "/.*/i", + "total": "/[+-]?([0-9]+,*\\.*)+/i", + "tax": "/[+-]?([0-9]+,*\\.*)+/i" } }, "de": { "headline": { "order": ["Pos", "#", "Position"], - "description": ["Beschreibung", "Bez", "Bezeichnung", "Leistung", "Produkt"], - "quantity": ["Menge", "Anzahl"], - "price": ["Einzel", "Preis", "Netto", "Net", "Brutto"], + "number": ["Nr.", "Nummer"], + "description": ["Beschreibung", "Bez", "Bezeichnung", "Leistung", "Produkt", "Artikel", "Name"], + "quantity": ["Menge", "Anzahl", "Stunden"], + "price": ["Einzel", "Preis", "Preis pro"], "unit": ["Einheit", "Einh"], - "total": ["Gesamt", "Preis", "Netto", "Net", "Brutto"], - "tax": ["MwSt", "USt"] + "total": ["Gesamt", "Brutto", "Summe"], + "tax": ["MwSt", "USt", "Steuer"] }, + "parts": "/( *)(.+?)(\\s{2,}|$)/i", "row": { - "order": "\\d+", - "description": ".*?", - "quantity": "[+-]?([0-9]{1,3}([,\\.][0-9]{3})*(,\\.[0-9]+)?|\\d*[,\\.]\\d+|\\d+)", - "price": "[+-]?([0-9]{1,3}([,\\.][0-9]{3})*(,\\.[0-9]+)?|\\d*[,\\.]\\d+|\\d+)", - "unit": ".*?", - "total": "[+-]?([0-9]{1,3}([,\\.][0-9]{3})*(,\\.[0-9]+)?|\\d*[,\\.]\\d+|\\d+)", - "tax": "[+-]?([0-9]{1,3}([,\\.][0-9]{3})*(,\\.[0-9]+)?|\\d*[,\\.]\\d+|\\d+)" + "order": "/\\d+/i", + "number": "/.*/i", + "description": "/.*/i", + "quantity": "/[+-]?([0-9]+,*\\.*)+/i", + "price": "/[+-]?([0-9]+,*\\.*)+/i", + "unit": "/.*/i", + "total": "/[+-]?([0-9]+,*\\.*)+/i", + "tax": "/[+-]?([0-9]+,*\\.*)+/i" } } } -} \ No newline at end of file +} diff --git a/Theme/Backend/Lang/de.lang.php b/Theme/Backend/Lang/de.lang.php index 9be6a93..3fd4ab0 100755 --- a/Theme/Backend/Lang/de.lang.php +++ b/Theme/Backend/Lang/de.lang.php @@ -18,7 +18,10 @@ return ['Billing' => [ 'AlreadyPaid' => 'Bereits bezahlt', 'Amount' => 'Betrag', 'Archive' => 'Archiev', + 'Internal' => 'Intern', + 'Error' => 'Fehler', 'Billing' => 'Rechnungsstellung', + 'External' => 'Extern', 'Bills' => 'Rechnungen', 'Bonus' => 'Bonus', 'Cashback' => 'Kennzeichnen', @@ -51,6 +54,7 @@ return ['Billing' => [ 'MoneyTransfer' => 'Überweisung', 'Name' => 'Name', 'Net' => 'Netz', + 'Parse' => 'Rechnungserkennung', 'Offer' => 'Angebot', 'Original' => 'Original', 'Payment' => 'Zahlung', @@ -81,4 +85,9 @@ return ['Billing' => [ 'ShippingTerms' => 'Lieferbedingungen', 'PaymentTerm' => 'Zahlungsbedingung', 'ShippingTerm' => 'Lieferbedingung', + 'E_bill_items' => 'Es gibt ein Problem mit Ihren Artikeln.', + 'E_bill_taxes' => 'Der Gesamtsteuerbetrag stimmt nicht mit dem Steuerbetrag der Elemente überein. Möglicherweise gibt es ein Problem mit den Steuersätzen oder zusätzlichen Posten.', + 'E_bill_net' => 'Der Gesamtnettobetrag stimmt nicht mit dem Nettobetrag der Elemente überein. Mögliches Problem mit Steuersätzen oder zusätzlichen Positionen.', + 'E_bill_gross' => 'Der Gesamtbruttobetrag stimmt nicht mit dem Bruttobetrag der Elemente überein. Mögliches Problem mit Steuersätzen oder zusätzlichen Positionen.', + 'E_bill_unit' => 'Stückpreis stimmt nicht mit Gesamtpreis überein. Mögliche Probleme mit Preis, Menge oder Rabatten.', ]]; diff --git a/Theme/Backend/Lang/en.lang.php b/Theme/Backend/Lang/en.lang.php index e12411e..cf8f496 100755 --- a/Theme/Backend/Lang/en.lang.php +++ b/Theme/Backend/Lang/en.lang.php @@ -18,7 +18,10 @@ return ['Billing' => [ 'AlreadyPaid' => 'Already Paid', 'Amount' => 'Amount', 'Archive' => 'Archive', + 'Internal' => 'Internal', + 'Error' => 'Error', 'Billing' => 'Billing', + 'External' => 'External', 'Bills' => 'Bills', 'Bonus' => 'Bonus', 'Cashback' => 'Cash Back', @@ -51,6 +54,7 @@ return ['Billing' => [ 'MoneyTransfer' => 'Money Transfer', 'Name' => 'Name', 'Net' => 'Net', + 'Parse' => 'Invoice recognition', 'Offer' => 'Offer', 'Original' => 'Original', 'Payment' => 'Payment', @@ -81,4 +85,10 @@ return ['Billing' => [ 'ShippingTerms' => 'Shipping Terms', 'PaymentTerm' => 'Payment Term', 'ShippingTerm' => 'Shipping Term', + 'ShippingTerm' => 'Shipping Term', + 'E_bill_items' => 'There is an issue with your bill items.', + 'E_bill_taxes' => 'The total tax amount doesn\'t match the tax amount of the elements. Potential issue with tax rates or additional items.', + 'E_bill_net' => 'The total net amount doesn\'t match the net amount of the elements. Potential issue with tax rates or additional items.', + 'E_bill_gross' => 'The total gross amount doesn\'t match the gross amount of the elements. Potential issue with tax rates or additional items.', + 'E_bill_unit' => 'Unit price doesn\'t match total price. Potential issues with price, quantity or discounts.', ]]; diff --git a/Theme/Backend/bill-create.tpl.php b/Theme/Backend/bill-create.tpl.php index 34841d6..7869866 100755 --- a/Theme/Backend/bill-create.tpl.php +++ b/Theme/Backend/bill-create.tpl.php @@ -36,7 +36,7 @@ $elements = $bill->elements; $billTypes = $this->data['billtypes'] ?? []; -$archive = $bill->getFileByTypeName('original'); +$archive = $bill->getFileByTypeName('internal'); /** @var \Modules\Auditor\Models\Audit */ $logs = $this->data['logs'] ?? []; @@ -47,6 +47,33 @@ $disabled = $editable ? '' : ' disabled'; $isNew = $archive->id === 0; echo $this->data['nav']->render(); ?> +isValid()) : ?> +
+
+
+
+
    + areElementsValid()) : ?> +
  • getHtml('E_bill_items'); ?>
  • + + validateTaxAmountElements()) : ?> +
  • getHtml('E_bill_taxes'); ?>
  • + + validateNetElements()) : ?> +
  • getHtml('E_bill_net'); ?>
  • + + validateGrossElements()) : ?> +
  • getHtml('E_bill_gross'); ?>
  • + + validatePriceQuantityElements()) : ?> +
  • getHtml('E_bill_unit'); ?>
  • + +
+
+
+
+
+
@@ -55,9 +82,9 @@ echo $this->data['nav']->render(); ?>
  • -
  • +
  • -
  • +
  • @@ -101,7 +128,7 @@ echo $this->data['nav']->render(); ?>
    @@ -356,7 +383,7 @@ echo $this->data['nav']->render(); ?> - + getHtml('Total'); ?> netDiscount->getAmount(2); ?> @@ -385,7 +412,7 @@ echo $this->data['nav']->render(); ?>
    @@ -417,6 +444,7 @@ echo $this->data['nav']->render(); ?> + +
    data['media-upload']->render('bill-file', 'files', '', $media); ?>
    - +
    diff --git a/Theme/Backend/purchase-bill-list.tpl.php b/Theme/Backend/purchase-bill-list.tpl.php index 5db7e5c..ad3f775 100755 --- a/Theme/Backend/purchase-bill-list.tpl.php +++ b/Theme/Backend/purchase-bill-list.tpl.php @@ -42,6 +42,18 @@ echo $this->data['nav']->render(); ?> + getHtml('External'); ?> + + + getHtml('Type'); ?> getNumber(); ?> + external; ?> type->getL11n(); ?> supplier->number; ?> printHtml($value->billTo); ?> @@ -170,7 +183,7 @@ echo $this->data['nav']->render(); ?> billZip; ?> billCity; ?> billCountry; ?> - getCurrency($value->netSales); ?> + netSales->getAmount(); ?> createdAt->format('Y-m-d'); ?> diff --git a/Theme/Backend/purchase-bill.tpl.php b/Theme/Backend/purchase-bill.tpl.php index 5bd67ed..e9731e6 100755 --- a/Theme/Backend/purchase-bill.tpl.php +++ b/Theme/Backend/purchase-bill.tpl.php @@ -12,10 +12,17 @@ */ declare(strict_types=1); +use Modules\Billing\Models\BillStatus; +use phpOMS\Localization\ISO3166NameEnum; +use phpOMS\Localization\ISO3166TwoEnum; +use phpOMS\Localization\ISO4217Enum; +use phpOMS\Localization\ISO639Enum; use phpOMS\Uri\UriFactory; -// Media helper functions (e.g. file icon generator) -include __DIR__ . '/../../../Media/Theme/Backend/template-functions.php'; +$countryCodes = ISO3166TwoEnum::getConstants(); +$countries = ISO3166NameEnum::getConstants(); +$languages = ISO639Enum::getConstants(); +$currencies = ISO4217Enum::getConstants(); /** * @var \phpOMS\Views\View $this @@ -27,24 +34,55 @@ $elements = $bill->elements; $billTypes = $this->data['billtypes'] ?? []; -$originalType = $this->data['originalType']; -$original = $bill->getFileByType($originalType); +$archive = $bill->getFileByTypeName('external'); /** @var \Modules\Auditor\Models\Audit */ $logs = $this->data['logs'] ?? []; +$editable = $bill->id === 0 || \in_array($bill->status, [BillStatus::DRAFT, BillStatus::UNPARSED]); +$disabled = $editable ? '' : ' disabled'; + +$isNew = $archive->id === 0; + echo $this->data['nav']->render(); ?> +isValid()) : ?> +
    +
    +
    +
    +
      + areElementsValid()) : ?> +
    • getHtml('E_bill_items'); ?>
    • + + validateTaxAmountElements()) : ?> +
    • getHtml('E_bill_taxes'); ?>
    • + + validateNetElements()) : ?> +
    • getHtml('E_bill_net'); ?>
    • + + validateGrossElements()) : ?> +
    • getHtml('E_bill_gross'); ?>
    • + + validatePriceQuantityElements()) : ?> +
    • getHtml('E_bill_unit'); ?>
    • + +
    +
    +
    +
    +
    +
    @@ -56,61 +94,148 @@ echo $this->data['nav']->render(); ?>
    getHtml('Invoice'); ?>
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    +
    + + +
    + +
    + + +
    + +
    + + + + > + +
    + +
    + + +
    + +
    + +
    +
    + + + > + +
    + client?->id ?? 0) > 0) : ?> + + +
    +
    + +
    + + > +
    + +
    + + > +
    + +
    + + > +
    + +
    + + +
    + +
    + + +
    -
    + +
    + +
    +
    -
    getHtml('Invoice'); ?>
    +
    getHtml('Billing'); ?>
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    +
    + + +
    + +
    + + > +
    + +
    + + > +
    + +
    + + > +
    + +
    + + > +
    + +
    + + +
    + +
    + + > +
    @@ -121,22 +246,42 @@ echo $this->data['nav']->render(); ?>
    getHtml('Delivery'); ?>
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    +
    + + +
    + +
    + + > +
    + +
    + + > +
    + +
    + + > +
    + +
    + + > +
    + +
    + + +
    @@ -147,56 +292,114 @@ echo $this->data['nav']->render(); ?>
    -
    +
    getHtml('Invoice'); ?>download
    - +
    - - - - + + + + + + + + +
    getHtml('Item'); ?> getHtml('Name'); ?> - getHtml('Quantity'); ?> - getHtml('Price'); ?> + getHtml('Quantity'); ?> getHtml('Discount'); ?> getHtml('DiscountP'); ?> - getHtml('Bonus'); ?> - getHtml('Tax'); ?> - getHtml('Net'); ?> -
    add expand_less expand_more - - - - - - - - - getCurrency($element->totalSalesPriceNet); ?> - -
    add expand_less expand_more - - - - - - - - - + getHtml('Bonus'); ?> + getHtml('Price'); ?> + getHtml('TaxP'); ?> + getHtml('Net'); ?> + getHtml('Margin'); ?> +
    + expand_less + expand_more + close + + + > + + > + > + > + > + > + > + getCurrency($element->totalSalesPriceNet, symbol: ''); ?> + totalSalesPriceNet->value === 0 ? 0 : (1 - $element->totalPurchasePriceNet->value / $element->totalSalesPriceNet->value) * 100, 2); ?>% + + +
    expand_less + expand_more + close + + + + + + + + + + + + +
    getHtml('Total'); ?> + + netDiscount->getAmount(2); ?> + netDiscount->value === 0 ? 0 : ($bill->netDiscount->value / ($bill->netSales->value + $bill->netDiscount->value)) * 100, 2); ?>% + + + taxP->getAmount(2); ?> + netSales->getAmount(2); ?> + netSales->value === 0 ? 0 : (1 - $bill->netCosts->value / $bill->netSales->value) * 100, 2); ?>%
    -
    - getHtml('Freightage'); ?>: 0.00 - - getHtml('Net'); ?>: getCurrency($bill->netSales); ?> - - getHtml('Tax'); ?>: 0.00 - - getHtml('Total'); ?>: getCurrency($bill->grossSales); ?> -
    +
    + + +
    +
    +
    @@ -208,7 +411,7 @@ echo $this->data['nav']->render(); ?>
    @@ -225,66 +428,147 @@ echo $this->data['nav']->render(); ?>
    +
    -
    + status === BillStatus::DRAFT + || $bill->status === BillStatus::UNPARSED + || $bill->status === BillStatus::ACTIVE + ) : ?> +
    +
    +
    + +
    +
    +
    + + +
    - id > 0) : ?> - - +
    + +
    data['media-upload']->render('bill-file', 'files', '', $bill->files); ?>
    +
    @@ -295,8 +579,8 @@ echo $this->data['nav']->render(); ?> getHtml('ID', '0', '0'); ?> - getHtml('Trigger', 'Auditor', 'Backend'); ?> getHtml('Action', 'Auditor', 'Backend'); ?> + getHtml('Trigger', 'Auditor', 'Backend'); ?> getHtml('CreatedBy', 'Auditor', 'Backend'); ?> getHtml('CreatedAt', 'Auditor', 'Backend'); ?> @@ -306,22 +590,24 @@ echo $this->data['nav']->render(); ?> ?> id; ?> - trigger; ?> old === null) : echo $this->getHtml('CREATE', 'Auditor', 'Backend'); ?> old !== null && $audit->new !== null) : echo $this->getHtml('UPDATE', 'Auditor', 'Backend'); ?> new === null) : echo $this->getHtml('DELETE', 'Auditor', 'Backend'); ?> getHtml('UNKNOWN', 'Auditor', 'Backend'); ?> + trigger; ?> printHtml( $this->renderUserName('%3$s %2$s %1$s', [$audit->createdBy->name1, $audit->createdBy->name2, $audit->createdBy->name3, $audit->createdBy->login]) ); ?> - createdAt->format('Y-m-d'); ?> + createdAt->format('Y-m-d H:i'); ?>
    + +
    diff --git a/Theme/Backend/user-purchase-bill-dashboard.tpl.php b/Theme/Backend/user-purchase-bill-dashboard.tpl.php index 2bc9062..176c8b3 100755 --- a/Theme/Backend/user-purchase-bill-dashboard.tpl.php +++ b/Theme/Backend/user-purchase-bill-dashboard.tpl.php @@ -44,6 +44,18 @@ echo $this->data['nav']->render(); ?> + getHtml('External'); ?> + + + getHtml('Type'); ?> getNumber(); ?> + external; ?> type->getL11n(); ?> supplier->number; ?> printHtml($value->billTo); ?> @@ -176,7 +189,7 @@ echo $this->data['nav']->render(); ?> : ISO3166NameEnum::getByName( ISO3166TwoEnum::getName($value->billCountry) ); ?> - grossCosts->getAmount(); ?> + grossSales->getAmount(); ?> billDate?->format('Y-m-d'); ?> diff --git a/Theme/Backend/user-purchase-bill.tpl.php b/Theme/Backend/user-purchase-bill.tpl.php deleted file mode 100755 index c32abe0..0000000 --- a/Theme/Backend/user-purchase-bill.tpl.php +++ /dev/null @@ -1,350 +0,0 @@ -data['bill']; -$elements = $bill->elements; - -$previewType = $this->data['previewType']; -$originalType = $this->data['originalType']; -$billPdf = $bill->getFileByType($previewType); -$original = $bill->getFileByType($originalType); -$media = $bill->files; - -echo $this->data['nav']->render(); ?> - -
    -
    - -
    -
    - -
    -
    -
    -
    -
    -
    getHtml('Invoice'); ?>
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    getHtml('Invoice'); ?>
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    getHtml('Delivery'); ?>
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    getHtml('Invoice'); ?>download
    - - - - - - - -
    - getHtml('Item'); ?> - getHtml('Name'); ?> - getHtml('Quantity'); ?> - getHtml('Price'); ?> - getHtml('Discount'); ?> - getHtml('DiscountP'); ?> - getHtml('Bonus'); ?> - getHtml('Tax'); ?> - getHtml('Net'); ?> -
    add expand_less expand_more - - - - - - - - - getCurrency($element->totalSalesPriceNet); ?> - -
    add expand_less expand_more - - - - - - - - - -
    -
    - getHtml('Freightage'); ?>: 0.00 - - getHtml('Net'); ?>: getCurrency($bill->netSales); ?> - - getHtml('Tax'); ?>: 0.00 - - getHtml('Total'); ?>: getCurrency($bill->grossSales); ?> -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    - id > 0) : ?> - - -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    - id > 0) : ?> - - -
    -
    -
    -
    -
    - -
    -
    -
    -
    -

    getHtml('Payment'); ?>

    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -

    getHtml('Media'); ?>

    - -
    -
    - - -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    getHtml('Media'); ?>download
    - - - - - extension === 'collection' - ? UriFactory::build('{/base}/media/list?path=' . \rtrim($file->getVirtualPath(), '/') . '/' . $file->name) - : UriFactory::build('{/base}/media/view?id=' . $file->id - . '&path={?path}' . ( - $file->id === 0 - ? '/' . $file->name - : '' - ) - ); - - $icon = $fileIconFunction(FileUtils::getExtensionType($file->extension)); - ?> - -
    - - getHtml('Name'); ?> - getHtml('Type'); ?> -
    - printHtml($icon); ?> - name; ?> - extension; ?> - -
    -
    -
    -
    -
    - -
    -
    -
    -
    - - - - - - -
    getHtml('Logs'); ?>download
    IP - getHtml('ID', '0', '0'); ?> - getHtml('Name'); ?> - getHtml('Log'); ?> - getHtml('Date'); ?> -
    printHtml($this->request->getOrigin()); ?> - printHtml((string) $this->request->header->account); ?> - printHtml((string) $this->request->header->account); ?> - Create Invoice - printHtml((new \DateTime('now'))->format('Y-m-d H:i:s')); ?> -
    -
    -
    -
    -
    -
    -
    - diff --git a/tests/Autoloader.php b/tests/Autoloader.php index 561d764..28b8056 100755 --- a/tests/Autoloader.php +++ b/tests/Autoloader.php @@ -36,6 +36,7 @@ final class Autoloader __DIR__ . '/../', __DIR__ . '/../MainRepository/', __DIR__ . '/../../', + __DIR__ . '/../../../', ]; /** diff --git a/tests/Models/InvoiceRecognitionTest.php b/tests/Models/InvoiceRecognitionTest.php new file mode 100644 index 0000000..c593c45 --- /dev/null +++ b/tests/Models/InvoiceRecognitionTest.php @@ -0,0 +1,352 @@ +netSales->value); + } + + /** + * @dataProvider billList + */ + public function testTaxRate($json, $content) : void + { + $billObj = new Bill(); + InvoiceRecognition::detect($billObj, $content); + + $test = \json_decode(\file_get_contents($json), true); + + self::assertEquals($test['tax_rate'], \reset($billObj->elements)->taxR->value); + } + + /** + * @dataProvider billList + */ + public function testGrossSales($json, $content) : void + { + $billObj = new Bill(); + InvoiceRecognition::detect($billObj, $content); + + $test = \json_decode(\file_get_contents($json), true); + + self::assertEquals($test['grossSales'], $billObj->grossSales->value); + } + + /** + * @dataProvider billList + */ + public function testTaxAmount($json, $content) : void + { + $billObj = new Bill(); + InvoiceRecognition::detect($billObj, $content); + + $test = \json_decode(\file_get_contents($json), true); + + self::assertEquals($test['tax_amount'], $billObj->taxP->value); + } + + /** + * @dataProvider billList + */ + public function testBillDate($json, $content) : void + { + $billObj = new Bill(); + InvoiceRecognition::detect($billObj, $content); + + $test = \json_decode(\file_get_contents($json), true); + + self::assertEquals($test['bill_date'], $billObj->billDate?->format('Y-m-d')); + } + + /** + * @dataProvider billList + */ + public function testBillLanguage($json, $content) : void + { + $billObj = new Bill(); + InvoiceRecognition::detect($billObj, $content); + + $test = \json_decode(\file_get_contents($json), true); + + self::assertEquals($test['language'], $billObj->language); + } + + /** + * @dataProvider billList + */ + public function testBillCurrency($json, $content) : void + { + $billObj = new Bill(); + InvoiceRecognition::detect($billObj, $content); + + $test = \json_decode(\file_get_contents($json), true); + + self::assertEquals($test['currency'], $billObj->currency); + } + + /** + * @dataProvider billList + */ + public function testIban($json, $content) : void + { + $identifierContent = \file_get_contents(__DIR__ . '/../../Models/bill_identifier.json'); + if ($identifierContent === false) { + $identifierContent = '{}'; + } + + /** @var array $identifiers */ + $identifiers = \json_decode($identifierContent, true); + + $test = \json_decode(\file_get_contents($json), true); + + $lines = \explode("\n", $content); + foreach ($lines as $line => $value) { + if (empty(\trim($value))) { + unset($lines[$line]); + } + } + $lines = \array_values($lines); + + self::assertEquals( + \str_replace(' ', '', $test['iban']), + \str_replace(' ', '', InvoiceRecognition::findIban($lines, $identifiers['iban'])) + ); + } + + /** + * @dataProvider billList + */ + public function testVATId($json, $content) : void + { + $identifierContent = \file_get_contents(__DIR__ . '/../../Models/bill_identifier.json'); + if ($identifierContent === false) { + $identifierContent = '{}'; + } + + /** @var array $identifiers */ + $identifiers = \json_decode($identifierContent, true); + + $test = \json_decode(\file_get_contents($json), true); + + $lines = \explode("\n", $content); + foreach ($lines as $line => $value) { + if (empty(\trim($value))) { + unset($lines[$line]); + } + } + $lines = \array_values($lines); + + self::assertEquals( + $test['vat_id'], + InvoiceRecognition::findVat($lines, $identifiers['vat_id'][$test['language']]) + ); + } + + /** + * @dataProvider billList + */ + public function testTaxId($json, $content) : void + { + $identifierContent = \file_get_contents(__DIR__ . '/../../Models/bill_identifier.json'); + if ($identifierContent === false) { + $identifierContent = '{}'; + } + + /** @var array $identifiers */ + $identifiers = \json_decode($identifierContent, true); + + $test = \json_decode(\file_get_contents($json), true); + + $lines = \explode("\n", $content); + foreach ($lines as $line => $value) { + if (empty(\trim($value))) { + unset($lines[$line]); + } + } + $lines = \array_values($lines); + + self::assertEquals( + $test['tax_id'], + InvoiceRecognition::findTaxId($lines, $identifiers['tax_id'][$test['language']]) + ); + } + + /** + * @dataProvider billList + */ + public function testWebsite($json, $content) : void + { + $identifierContent = \file_get_contents(__DIR__ . '/../../Models/bill_identifier.json'); + if ($identifierContent === false) { + $identifierContent = '{}'; + } + + /** @var array $identifiers */ + $identifiers = \json_decode($identifierContent, true); + + $test = \json_decode(\file_get_contents($json), true); + + $lines = \explode("\n", $content); + foreach ($lines as $line => $value) { + if (empty(\trim($value))) { + unset($lines[$line]); + } + } + $lines = \array_values($lines); + + self::assertEquals( + $test['website'], + InvoiceRecognition::findWebsite($lines, $identifiers['website']) + ); + } + + /** + * @dataProvider billList + */ + public function testEmail($json, $content) : void + { + $identifierContent = \file_get_contents(__DIR__ . '/../../Models/bill_identifier.json'); + if ($identifierContent === false) { + $identifierContent = '{}'; + } + + /** @var array $identifiers */ + $identifiers = \json_decode($identifierContent, true); + + $test = \json_decode(\file_get_contents($json), true); + + $lines = \explode("\n", $content); + foreach ($lines as $line => $value) { + if (empty(\trim($value))) { + unset($lines[$line]); + } + } + $lines = \array_values($lines); + + self::assertEquals( + $test['email'], + InvoiceRecognition::findEmail($lines, $identifiers['email']) + ); + } + + /** + * @dataProvider billList + */ + public function testPhone($json, $content) : void + { + $identifierContent = \file_get_contents(__DIR__ . '/../../Models/bill_identifier.json'); + if ($identifierContent === false) { + $identifierContent = '{}'; + } + + /** @var array $identifiers */ + $identifiers = \json_decode($identifierContent, true); + + $test = \json_decode(\file_get_contents($json), true); + + $lines = \explode("\n", $content); + foreach ($lines as $line => $value) { + if (empty(\trim($value))) { + unset($lines[$line]); + } + } + $lines = \array_values($lines); + + self::assertEquals( + \str_replace(' ', '', $test['phone']), + \str_replace(' ', '', InvoiceRecognition::findPhone($lines, $identifiers['phone'][$test['language']])) + ); + } + + public static array $billList = []; + + public function billList() + { + /* + if (\str_ends_with(__DIR__ . '/bills/12.png', 'pdf')) { + $content = PdfParser::pdf2text(__DIR__ . '/bills/12.png'); + } else { + $ocr = new TesseractOcr(); + + $content = $ocr->parseImage(__DIR__ . '/bills/12.png'); + } + + return [ + [ + __DIR__ . '/bills/12.json', + $content + ] + ]; + */ + + if (!empty(self::$billList)) { + return self::$billList; + } + + $files = \scandir(__DIR__ . '/bills/'); + foreach ($files as $bill) { + if ($bill === '.' || $bill === '..' || \str_ends_with($bill, '.json')) { + continue; + } + + $parts = \explode('.', $bill); + $count = \count($parts); + unset($parts[$count - 1]); + + if (\str_ends_with(__DIR__ . '/bills/' . $bill, 'pdf')) { + $content = PdfParser::pdf2text(__DIR__ . '/bills/' . $bill); + } else { + $ocr = new TesseractOcr(); + + $content = $ocr->parseImage(__DIR__ . '/bills/' . $bill); + } + + $element = [ + __DIR__ . '/bills/' . \implode('', $parts) . '.json', + $content + ]; + + self::$billList[] = $element; + } + + return self::$billList; + } + + public static function tearDownAfterClass() : void + { + self::$billList = []; + } +} diff --git a/tests/Models/bills/1.json b/tests/Models/bills/1.json new file mode 100644 index 0000000..9ef8af5 --- /dev/null +++ b/tests/Models/bills/1.json @@ -0,0 +1,34 @@ +{ + "invoice_number": "20191242", + "grossSales": 2800000, + "netSales": 2800000, + "tax_amount": 0, + "tax_rate": 0, + "bill_date": "2019-09-12", + "delivery_date": "", + "due_date": "2019-09-12", + "vat_id": "DE12345678", + "tax_id": "12/112233/44221", + "address": { + "name": "fortytools gmbh", + "address": "Georgsplatz 10", + "city": "20099 Hamburg", + "country": "" + }, + "iban": "", + "elements": [ + { + "position": 1, + "description": "", + "quantity": 10000, + "unit": "pro Woche", + "price": 280000, + "total": 280000 + } + ], + "language": "de", + "currency": "EUR", + "email": "info@fortytools-cleaning.com", + "website": "", + "phone": "+49-40-609 407 89 - 0" +} \ No newline at end of file diff --git a/tests/Models/bills/1.png b/tests/Models/bills/1.png new file mode 100644 index 0000000..954323b Binary files /dev/null and b/tests/Models/bills/1.png differ diff --git a/tests/Models/bills/10.json b/tests/Models/bills/10.json new file mode 100644 index 0000000..7791aff --- /dev/null +++ b/tests/Models/bills/10.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "12345", + "grossSales": 11200000, + "netSales": 11200000, + "tax_amount": 0, + "tax_rate": 0, + "bill_date": "", + "delivery_date": "", + "service_date": "", + "due_date": "", + "vat_id": "", + "tax_id": "", + "address": { + "name": "", + "address": "", + "city": "", + "country": "" + }, + "iban": "DE34233004333401", + "elements": [], + "language": "de", + "currency": "EUR", + "email": "mail@musterfirma.com", + "website": "www.musterfirma.com", + "phone": "(+49) 1234/98 76 54" +} \ No newline at end of file diff --git a/tests/Models/bills/10.png b/tests/Models/bills/10.png new file mode 100644 index 0000000..ae903d6 Binary files /dev/null and b/tests/Models/bills/10.png differ diff --git a/tests/Models/bills/11.jpg b/tests/Models/bills/11.jpg new file mode 100644 index 0000000..79ab0a9 Binary files /dev/null and b/tests/Models/bills/11.jpg differ diff --git a/tests/Models/bills/11.json b/tests/Models/bills/11.json new file mode 100644 index 0000000..b99ea4a --- /dev/null +++ b/tests/Models/bills/11.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "", + "grossSales": 20440000, + "netSales": 0, + "tax_amount": 0, + "tax_rate": 0, + "bill_date": "", + "delivery_date": "", + "service_date": "", + "due_date": "", + "vat_id": "", + "tax_id": "", + "address": { + "name": "", + "address": "", + "city": "", + "country": "" + }, + "iban": "", + "elements": [], + "language": "de", + "currency": "EUR", + "email": "", + "website": "", + "phone": "" +} \ No newline at end of file diff --git a/tests/Models/bills/12.json b/tests/Models/bills/12.json new file mode 100644 index 0000000..7525ebd --- /dev/null +++ b/tests/Models/bills/12.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "10005", + "grossSales": 1160000, + "netSales": 1000000, + "tax_amount": 160000, + "tax_rate": 160000, + "bill_date": "2020-09-22", + "delivery_date": "", + "service_date": "", + "due_date": "", + "vat_id": "DE814878557", + "tax_id": "122/5719/4368", + "address": { + "name": "easzbill GmbH", + "address": "Düsselstr. 21", + "city": "41564 Kaarst", + "country": "" + }, + "iban": "DE58300700240509944500", + "elements": [], + "language": "de", + "currency": "EUR", + "email": "support@easybill.de", + "website": "www.easybil.de", + "phone": "+49 2154 897 01 - 20" +} \ No newline at end of file diff --git a/tests/Models/bills/12.png b/tests/Models/bills/12.png new file mode 100644 index 0000000..e231802 Binary files /dev/null and b/tests/Models/bills/12.png differ diff --git a/tests/Models/bills/14.json b/tests/Models/bills/14.json new file mode 100644 index 0000000..91d1784 --- /dev/null +++ b/tests/Models/bills/14.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "2021011", + "grossSales": 15969800, + "netSales": 13420000, + "tax_amount": 2549800, + "tax_rate": 190000, + "bill_date": "2021-03-29", + "delivery_date": "", + "service_date": "", + "due_date": "2021-04-28", + "vat_id": "DE234567891", + "tax_id": "", + "address": { + "name": "Invoice Office GmbH", + "address": "Inge-Meysel Str. 8a", + "city": "85053 Ingolstadt", + "country": "" + }, + "iban": "DE20000111122223332103", + "elements": [], + "language": "de", + "currency": "EUR", + "email": "demo@invoiceoffice.de", + "website": "", + "phone": "0049123456789" +} \ No newline at end of file diff --git a/tests/Models/bills/14.png b/tests/Models/bills/14.png new file mode 100644 index 0000000..571dcc6 Binary files /dev/null and b/tests/Models/bills/14.png differ diff --git a/tests/Models/bills/15.json b/tests/Models/bills/15.json new file mode 100644 index 0000000..f504363 --- /dev/null +++ b/tests/Models/bills/15.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "2021012", + "grossSales": 92391600, + "netSales": 77640000, + "tax_amount": 14751600, + "tax_rate": 190000, + "bill_date": "2021-03-29", + "delivery_date": "", + "service_date": "", + "due_date": "2021-04-28", + "vat_id": "DE123456789", + "tax_id": "", + "address": { + "name": "Invoice Office GmbH", + "address": "Inge-Meysel Str. 8a", + "city": "85053 Ingolstadt", + "country": "" + }, + "iban": "DE20000111122223332103", + "elements": [], + "language": "de", + "currency": "EUR", + "email": "demo@invoiceoffice.de", + "website": "", + "phone": "0049123456789" +} \ No newline at end of file diff --git a/tests/Models/bills/15.png b/tests/Models/bills/15.png new file mode 100644 index 0000000..f07c49d Binary files /dev/null and b/tests/Models/bills/15.png differ diff --git a/tests/Models/bills/16.json b/tests/Models/bills/16.json new file mode 100644 index 0000000..9bfc568 --- /dev/null +++ b/tests/Models/bills/16.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "1001", + "grossSales": 4658900, + "netSales": 3915000, + "tax_amount": 743900, + "tax_rate": 190000, + "bill_date": "2021-02-17", + "delivery_date": "", + "service_date": "", + "due_date": "", + "vat_id": "", + "tax_id": "", + "address": { + "name": "Ihre Firma", + "address": "Musterstraße X", + "city": "12345 Musterstadt", + "country": "" + }, + "iban": "DE85123456789012345678", + "elements": [], + "language": "de", + "currency": "EUR", + "email": "firmaxyz@gmail.de", + "website": "www.firmaxyz.de", + "phone": "+49 1234/12 34 56" +} \ No newline at end of file diff --git a/tests/Models/bills/16.png b/tests/Models/bills/16.png new file mode 100644 index 0000000..c234834 Binary files /dev/null and b/tests/Models/bills/16.png differ diff --git a/tests/Models/bills/17.json b/tests/Models/bills/17.json new file mode 100644 index 0000000..3cc29b2 --- /dev/null +++ b/tests/Models/bills/17.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "DE-001", + "grossSales": 1654100, + "netSales": 1390000, + "tax_amount": 264100, + "tax_rate": 190000, + "bill_date": "2019-01-29", + "delivery_date": "", + "service_date": "", + "due_date": "2019-01-29", + "vat_id": "", + "tax_id": "", + "address": { + "name": "European Auto Parts", + "address": "Schillingbrücke 58", + "city": "73111 Berlin", + "country": "" + }, + "iban": "DE9112345678", + "elements": [], + "language": "de", + "currency": "EUR", + "email": "", + "website": "", + "phone": "" +} \ No newline at end of file diff --git a/tests/Models/bills/17.png b/tests/Models/bills/17.png new file mode 100644 index 0000000..dd8bd25 Binary files /dev/null and b/tests/Models/bills/17.png differ diff --git a/tests/Models/bills/18.jpg b/tests/Models/bills/18.jpg new file mode 100644 index 0000000..6419779 Binary files /dev/null and b/tests/Models/bills/18.jpg differ diff --git a/tests/Models/bills/18.json b/tests/Models/bills/18.json new file mode 100644 index 0000000..2fadccc --- /dev/null +++ b/tests/Models/bills/18.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "1234", + "grossSales": 36533000, + "netSales": 30700000, + "tax_amount": 5833000, + "tax_rate": 190000, + "bill_date": "2020-01-01", + "delivery_date": "", + "service_date": "", + "due_date": "", + "vat_id": "DE123456789", + "tax_id": "", + "address": { + "name": "pixa", + "address": "Musterstraße 1", + "city": "12345 Berlin", + "country": "" + }, + "iban": "DE07123412341234123412", + "elements": [], + "language": "de", + "currency": "EUR", + "email": "mail@example.com", + "website": "", + "phone": "+49 123 456789" +} \ No newline at end of file diff --git a/tests/Models/bills/19.json b/tests/Models/bills/19.json new file mode 100644 index 0000000..a841dbf --- /dev/null +++ b/tests/Models/bills/19.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "2019-1001", + "grossSales": 357000, + "netSales": 300000, + "tax_amount": 57000, + "tax_rate": 190000, + "bill_date": "2019-01-01", + "delivery_date": "", + "service_date": "", + "due_date": "", + "vat_id": "DE24324567", + "tax_id": "", + "address": { + "name": "Musterfirma GmbH", + "address": "Musterstraße 23", + "city": "12345 Musterhausen", + "country": "" + }, + "iban": "DE3423456234356765", + "elements": [], + "language": "de", + "currency": "EUR", + "email": "info@muster.de", + "website": "www.firma.de", + "phone": "+40 (0)30 12345678" +} \ No newline at end of file diff --git a/tests/Models/bills/19.png b/tests/Models/bills/19.png new file mode 100644 index 0000000..a620d69 Binary files /dev/null and b/tests/Models/bills/19.png differ diff --git a/tests/Models/bills/2.json b/tests/Models/bills/2.json new file mode 100644 index 0000000..93d22cd --- /dev/null +++ b/tests/Models/bills/2.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "12345", + "grossSales": 16838500, + "netSales": 14150000, + "tax_amount": 2688500, + "tax_rate": 190000, + "bill_date": "2022-01-01", + "delivery_date": "", + "service_date": "", + "due_date": "2022-01-15", + "vat_id": "", + "tax_id": "123456789", + "address": { + "name": "Vorname Name", + "address": "Musterstraße 123", + "city": "12345 Musterstadt", + "country": "" + }, + "iban": "", + "elements": [], + "language": "de", + "currency": "EUR", + "email": "hallo@superduperseite.de", + "website": "www.superduperseite.de", + "phone": "" +} \ No newline at end of file diff --git a/tests/Models/bills/2.png b/tests/Models/bills/2.png new file mode 100644 index 0000000..d68b7ff Binary files /dev/null and b/tests/Models/bills/2.png differ diff --git a/tests/Models/bills/3.json b/tests/Models/bills/3.json new file mode 100644 index 0000000..70eb5b1 --- /dev/null +++ b/tests/Models/bills/3.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "01234", + "grossSales": 21420000, + "netSales": 18000000, + "tax_amount": 3420000, + "tax_rate": 190000, + "bill_date": "2024-08-13", + "delivery_date": "", + "service_date": "", + "due_date": "2024-09-15", + "vat_id": "", + "tax_id": "0123 4567 8901", + "address": { + "name": "Werbeagentur Kluger", + "address": "Jede Straße 123", + "city": "12345 Jede Stadt", + "country": "" + }, + "iban": "", + "elements": [], + "language": "de", + "currency": "EUR", + "email": "hallo@superduperseite.de", + "website": "", + "phone": "(0221) 1234-56" +} \ No newline at end of file diff --git a/tests/Models/bills/3.png b/tests/Models/bills/3.png new file mode 100644 index 0000000..5a5abd0 Binary files /dev/null and b/tests/Models/bills/3.png differ diff --git a/tests/Models/bills/4.json b/tests/Models/bills/4.json new file mode 100644 index 0000000..fe80f85 --- /dev/null +++ b/tests/Models/bills/4.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "2021021", + "grossSales": 15113000, + "netSales": 12700000, + "tax_amount": 2413000, + "tax_rate": 190000, + "bill_date": "2021-04-01", + "delivery_date": "", + "service_date": "", + "due_date": "2021-05-01", + "vat_id": "DE123456789", + "tax_id": "", + "address": { + "name": "Invoice Office GmbH", + "address": "Inge-Mezsel Str. 8a", + "city": "85053 Ingolstadt", + "country": "" + }, + "iban": "DE20000111122223332103", + "elements": [], + "language": "de", + "currency": "EUR", + "email": "demo@invoiceoffice.de", + "website": "", + "phone": "0049123456789" +} \ No newline at end of file diff --git a/tests/Models/bills/4.png b/tests/Models/bills/4.png new file mode 100644 index 0000000..44abfa9 Binary files /dev/null and b/tests/Models/bills/4.png differ diff --git a/tests/Models/bills/5.json b/tests/Models/bills/5.json new file mode 100644 index 0000000..67205eb --- /dev/null +++ b/tests/Models/bills/5.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "323525", + "grossSales": 7809300, + "netSales": 6562400, + "tax_amount": 1246900, + "tax_rate": 190000, + "bill_date": "2022-08-10", + "delivery_date": "", + "service_date": "", + "due_date": "", + "vat_id": "DE101778899", + "tax_id": "200/100/10001", + "address": { + "name": "Handwerker Muster", + "address": "Musterstraße 1", + "city": "123456 Musterstadt", + "country": "" + }, + "iban": "DE02345611110000100020", + "elements": [], + "language": "de", + "currency": "EUR", + "email": "", + "website": "", + "phone": "+49 123 456 789-0" +} \ No newline at end of file diff --git a/tests/Models/bills/5.png b/tests/Models/bills/5.png new file mode 100644 index 0000000..e45d2b6 Binary files /dev/null and b/tests/Models/bills/5.png differ diff --git a/tests/Models/bills/6.json b/tests/Models/bills/6.json new file mode 100644 index 0000000..e5974c6 --- /dev/null +++ b/tests/Models/bills/6.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "2021010", + "grossSales": 22848000, + "netSales": 19200000, + "tax_amount": 3648000, + "tax_rate": 190000, + "bill_date": "2021-03-29", + "delivery_date": "", + "service_date": "", + "due_date": "2021-04-28", + "vat_id": "DE123456789", + "tax_id": "", + "address": { + "name": "Invoice Office GmbH", + "address": "Inge-Meysel Str. 8a", + "city": "85053 Ingolstadt", + "country": "" + }, + "iban": "DE20000111122223332103", + "elements": [], + "language": "de", + "currency": "EUR", + "email": "demo@invoiceoffice.de", + "website": "", + "phone": "0049123456789" +} \ No newline at end of file diff --git a/tests/Models/bills/6.png b/tests/Models/bills/6.png new file mode 100644 index 0000000..880b739 Binary files /dev/null and b/tests/Models/bills/6.png differ diff --git a/tests/Models/bills/7.json b/tests/Models/bills/7.json new file mode 100644 index 0000000..80f9d0c --- /dev/null +++ b/tests/Models/bills/7.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "220002", + "grossSales": 6021400, + "netSales": 5060000, + "tax_amount": 961400, + "tax_rate": 190000, + "bill_date": "2022-11-04", + "delivery_date": "", + "service_date": "", + "due_date": "2022-11-18", + "vat_id": "", + "tax_id": "45/123/12345", + "address": { + "name": "Handwerksmeister Frity Blau", + "address": "Musterstraße 123", + "city": "12345 Berlin", + "country": "" + }, + "iban": "DE100023145678", + "elements": [], + "language": "de", + "currency": "EUR", + "email": "info@mustermann.de", + "website": "www.mustermann.de", + "phone": "+49 (30) 12 34 56 01" +} \ No newline at end of file diff --git a/tests/Models/bills/7.png b/tests/Models/bills/7.png new file mode 100644 index 0000000..41ae689 Binary files /dev/null and b/tests/Models/bills/7.png differ diff --git a/tests/Models/bills/8.json b/tests/Models/bills/8.json new file mode 100644 index 0000000..98be9ba --- /dev/null +++ b/tests/Models/bills/8.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "INV0041-1", + "grossSales": 56283100, + "netSales": 52601000, + "tax_amount": 3939800, + "tax_rate": 70000, + "bill_date": "2022-02-20", + "delivery_date": "", + "service_date": "", + "due_date": "2022-02-27", + "vat_id": "", + "tax_id": "", + "address": { + "name": "Company ABC", + "address": "Some Street 4", + "city": "12345 Rome", + "country": "" + }, + "iban": "", + "elements": [], + "language": "en", + "currency": "USD", + "email": "", + "website": "", + "phone": "" +} \ No newline at end of file diff --git a/tests/Models/bills/8.pdf b/tests/Models/bills/8.pdf new file mode 100644 index 0000000..10a3a94 Binary files /dev/null and b/tests/Models/bills/8.pdf differ diff --git a/tests/Models/bills/9.json b/tests/Models/bills/9.json new file mode 100644 index 0000000..98be9ba --- /dev/null +++ b/tests/Models/bills/9.json @@ -0,0 +1,26 @@ +{ + "invoice_number": "INV0041-1", + "grossSales": 56283100, + "netSales": 52601000, + "tax_amount": 3939800, + "tax_rate": 70000, + "bill_date": "2022-02-20", + "delivery_date": "", + "service_date": "", + "due_date": "2022-02-27", + "vat_id": "", + "tax_id": "", + "address": { + "name": "Company ABC", + "address": "Some Street 4", + "city": "12345 Rome", + "country": "" + }, + "iban": "", + "elements": [], + "language": "en", + "currency": "USD", + "email": "", + "website": "", + "phone": "" +} \ No newline at end of file diff --git a/tests/Models/bills/9.pdf b/tests/Models/bills/9.pdf new file mode 100644 index 0000000..99506f7 Binary files /dev/null and b/tests/Models/bills/9.pdf differ