app->l11nManager, $request, $response); $view->setTemplate('/Modules/Billing/Theme/Cli/bill-parsed'); $tag = null; if (!$request->hasData('-t')) { $tag = TagMapper::get() ->where('name', 'external_bill') ->execute(); } $externalType = $request->getDataInt('-t') ?? (int) ($tag?->id); /** @var \Modules\Billing\Models\Bill $bill */ $bill = BillMapper::get() ->with('elements') ->with('files') ->with('files/tags') ->with('files/content') ->where('id', (int) $request->getData('-i')) ->where('files/tags', $externalType) ->execute(); if ($bill->id === 0) { return $view; } $old = clone $bill; $content = \strtolower($bill->getFileByTag($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'; } $bill->language = $language; $l11n = Localization::fromLanguage($language); $identifierContent = \file_get_contents(__DIR__ . '/../Models/bill_identifier.json'); if ($identifierContent === false) { $identifierContent = '{}'; } /** @var array $identifiers */ $identifiers = \json_decode($identifierContent, true); /* Supplier */ // @performance Do we really want to select all the attributes below or only after we have found a suitable supplier // We don't need these attributes initially, only once we found a matching supplier // @performance We can't select all suppliers in one go, we probably need to iterate in chunks // @bug We are missing the payment information here used in the matchSupplier() function // @performance Could it be better to first perform some parsing of the bill to get the payment information and find the supplier based on that first? // If we find a supplier this would be much faster, if it doesn't we can still do the brute force below /** @var \Modules\SupplierManagement\Models\Supplier[] $suppliers */ $suppliers = SupplierMapper::getAll() ->with('account') ->with('mainAddress') ->with('attributes/type') ->where('attributes/type/name', ['bill_match_pattern', 'bill_date_format', 'bill_approval'], 'IN') ->executeGetArray(); $bill->supplier = $this->matchSupplier($content, $suppliers); if ($bill->supplier->id !== 0) { $bill->billTo = $bill->supplier->account->name1; $bill->billAddress = $bill->supplier->mainAddress->address; $bill->billCity = $bill->supplier->mainAddress->city; $bill->billZip = $bill->supplier->mainAddress->postal; $bill->billCountry = $bill->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 = InvoiceRecognition::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 = InvoiceRecognition::findBillNumber($lines, $identifiers['bill_no'][$language]); $bill->external = $billNumber; /* Reference / PO */ // @todo implement /* Date */ $billDateTemp = InvoiceRecognition::findBillDate($lines, $identifiers['bill_date'][$language]); $billDate = InvoiceRecognition::parseDate($billDateTemp, $identifiers['date_format'], $bill->supplier->getAttribute('bill_date_format')->value->valueStr ?? ''); $bill->billDate = $billDate; /* Due */ $billDueTemp = InvoiceRecognition::findBillDue($lines, $identifiers['bill_due'][$language]); $billDue = InvoiceRecognition::parseDate($billDueTemp, $identifiers['date_format'], $bill->supplier->getAttribute('bill_date_format')->value->valueStr ?? ''); // @todo implement multiple due dates for bills /* 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 */ // @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]); 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 = InvoiceRecognition::findBillItemLines($lines, $identifiers['item_table'][$language]); // @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'])) { $description = \trim($itemLine['description']); $item = new NullItem(); if ($bill->supplier->id !== 0) { $prices = PriceMapper::getAll() ->with('item') ->with('item/l11n') ->with('item/l11n/type') ->with('item/attributes') ->with('item/attributes/type') ->where('supplier', $bill->supplier->id) ->where('item/l11n/type/title', ['name1', 'name2', 'internal_matchcodes', 'description_short']) ->where('item/attributes/type/name', ['bill_match_pattern']) ->executeGetArray(); $possibleItems = \array_map( function (Price $price) { return $price->item; }, $prices ); $item = $this->matchItem($content, $possibleItems); } if ($item->id !== 0) { $billElement->item = $item; $billElement->itemNumber = $item->number; $billElement->itemName = $item->getL11n('name1')->content; if (!empty($item->getL11n('description_short')->content)) { $billElement->itemDescription = $item->getL11n('description_short')->content; } elseif (!empty($description)) { $billElement->itemDescription = $description; } } else { $billElement->itemName = \trim($itemLine['description']); if (!empty($description)) { $billElement->itemDescription = $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] ?? new NullItem(); $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()); // @todo change tax code during/after bill parsing $view->data['bill'] = $bill; // Fix internal document $request->setData('bill', $bill->id, true); $billResponse = new HttpResponse(); $this->app->moduleManager->get('Billing', 'ApiBill')->apiBillPdfArchiveCreate($request, $billResponse); if ($bill->supplier->id !== 0) { // @question Do we want to also create a notification for the people in the default group /* $approvalAccounts = AccountMapper::getAll() ->with('groups') ->where('status', AccountStatus::ACTIVE) ->where('groups/name', $bill->supplier->getAttribute('bill_approval')->value->valueStr) ->executeGetArray(); */ } return $view; } /** * Find possible supplier id * * Priorities: * 1. bill_match_pattern * 2. name1 + IBAN * 3. name1 + city || address * * @param string $content Content to analyze * @param Supplier[] $suppliers Suppliers * * @return Supplier * * @since 1.0.0 */ private function matchSupplier(string $content, array $suppliers) : Supplier { // bill_match_pattern foreach ($suppliers as $supplier) { // @question consider to support regex? if ((!empty($supplier->getAttribute('bill_match_pattern')->value->valueStr) && \stripos($content, $supplier->getAttribute('bill_match_pattern')->value->valueStr) !== false) ) { return $supplier; } } // name1 + IBAN foreach ($suppliers as $supplier) { if (\stripos($content, $supplier->account->name1) !== false) { $ibans = $supplier->getPaymentsByType(PaymentType::SWIFT); foreach ($ibans as $iban) { if (\stripos($content, $iban->content2) !== false) { return $supplier; } } } } // name1 + city || address foreach ($suppliers as $supplier) { if (\stripos($content, $supplier->account->name1) !== false && ((!empty($supplier->mainAddress->city) && \stripos($content, $supplier->mainAddress->city) !== false) || (!empty($supplier->mainAddress->address) && \stripos($content, $supplier->mainAddress->address) !== false) ) ) { return $supplier; } } return new NullSupplier(); } /** * Find possible item id * * Priorities: * 1. bill_match_pattern * 2. name1 + name2 * * @param string $content Content to analyze * @param Item[] $items Items * * @return Item * * @since 1.0.0 */ private function matchItem(string $content, array $items) : Item { // bill_match_pattern foreach ($items as $item) { // @question consider to support regex? if ((!empty($item->getAttribute('bill_match_pattern')->value->valueStr) && \stripos($content, $item->getAttribute('bill_match_pattern')->value->valueStr) !== false) ) { return $item; } } // name1 + name2 foreach ($items as $item) { if (\stripos($content, $item->getL11n('name1')->content) !== false && \stripos($content, $item->getL11n('name2')->content) !== false ) { return $item; } } return new NullItem(); } }