From 47651186c844d16ea1ca3ca0d469f461b576ba3a Mon Sep 17 00:00:00 2001 From: Dennis Eichhorn Date: Sun, 4 Feb 2024 20:34:11 +0000 Subject: [PATCH] update --- Admin/Install/Media/bill.pdf.php | 4 +- Admin/Install/Media/lang.php | 4 +- Admin/Install/Messages.install.json | 4 +- Admin/Installer.php | 3 - Controller/ApiBillController.php | 151 ++++++++++------- Controller/ApiPriceController.php | 238 ++++----------------------- Controller/ApiPurchaseController.php | 45 +++-- Controller/ApiTaxController.php | 2 - Controller/CliController.php | 3 +- Models/Bill.php | 2 +- Models/BillElement.php | 8 +- Models/BillMapper.php | 2 +- Models/Price/Price.php | 6 + Models/SalesBillMapper.php | 37 ++++- Models/bill_identifier.json | 10 +- tests/Models/SalesBillMapperTest.php | 11 +- 16 files changed, 209 insertions(+), 321 deletions(-) diff --git a/Admin/Install/Media/bill.pdf.php b/Admin/Install/Media/bill.pdf.php index c42bd52..b78e14c 100755 --- a/Admin/Install/Media/bill.pdf.php +++ b/Admin/Install/Media/bill.pdf.php @@ -123,7 +123,7 @@ $pdf->MultiCell( . $lang[$pdf->language]['InvoiceDate'] . "\n" . $lang[$pdf->language]['ServiceDate'] . "\n" . $lang[$pdf->language]['CustomerNo'] . "\n" - . $lang[$pdf->language]['PO'] . "\n" + . $lang[$pdf->language]['REF'] . "\n" . $lang[$pdf->language]['DueDate'], 0, 'L' ); @@ -138,7 +138,7 @@ $pdf->MultiCell( . ($bill->billDate?->format('Y-m-d') ?? '0') . "\n" . ($bill->performanceDate?->format('Y-m-d') ?? '0') . "\n" . $bill->accountNumber . "\n" - . '' . "\n" /* @todo implement customer / supplier reference as string */ + . $bill->externalReferral . "\n" . ($bill->billDate?->format('Y-m-d') ?? '0'), /* Consider to add dueDate in addition */ 0, 'L' ); diff --git a/Admin/Install/Media/lang.php b/Admin/Install/Media/lang.php index 38f26e0..7c11b32 100755 --- a/Admin/Install/Media/lang.php +++ b/Admin/Install/Media/lang.php @@ -20,7 +20,7 @@ return [ 'InvoiceDate' => 'Invoice Date', 'ServiceDate' => 'Service Date', 'CustomerNo' => 'Customer No.', - 'PO' => 'PO', + 'REF' => 'REF', 'DueDate' => 'Due Date', 'Item' => 'Item', 'Currency' => 'Currency', @@ -40,7 +40,7 @@ return [ 'InvoiceDate' => 'Belegdatum', 'ServiceDate' => 'Leistungsdatum', 'CustomerNo' => 'Kundennummer', - 'PO' => 'Kundenreferenz', + 'REF' => 'REF', 'DueDate' => 'Fälligkeitsdatum', 'Item' => 'Artikel', 'Currency' => 'Währung', diff --git a/Admin/Install/Messages.install.json b/Admin/Install/Messages.install.json index 7608fba..4fb63f1 100755 --- a/Admin/Install/Messages.install.json +++ b/Admin/Install/Messages.install.json @@ -9,12 +9,12 @@ "l11n": { "en": { "subject": "Billing", - "body": "Billing

Billing

Dear {user_name},

Thank you for for doing business with us.

Attached kindly find your bill.

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

", + "body": "Billing

Billing

Dear {user_name},

Thank you for for doing business with us.

Attached kindly find your bill.

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

", "bodyalt": "Dear {user_name},\n\nThank you for doing business with us.\n\nAttached kindly find your bill.\n\n\nJingga e.K. - www.jingga.app - CEO Dennis Eichhorn - Amtsgericht Friedberg HRA 5058" }, "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

", + "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" } }, diff --git a/Admin/Installer.php b/Admin/Installer.php index a38d2c2..a58a032 100755 --- a/Admin/Installer.php +++ b/Admin/Installer.php @@ -347,9 +347,6 @@ final class Installer extends InstallerAbstract /** @var \Modules\Billing\Controller\ApiBillTypeController $module */ $module = $app->moduleManager->getModuleInstance('Billing', 'ApiBillType'); - // @todo allow multiple alternative bill templates - // @todo implement ordering of templates - foreach ($types as $type) { $response = new HttpResponse(); $request = new HttpRequest(); diff --git a/Controller/ApiBillController.php b/Controller/ApiBillController.php index 87c6bcf..53f9b64 100755 --- a/Controller/ApiBillController.php +++ b/Controller/ApiBillController.php @@ -38,6 +38,7 @@ use Modules\ItemManagement\Models\ItemMapper; use Modules\ItemManagement\Models\NullContainer; use Modules\Media\Models\CollectionMapper; use Modules\Media\Models\Media; +use Modules\Media\Models\MediaClass; use Modules\Media\Models\MediaMapper; use Modules\Media\Models\NullCollection; use Modules\Media\Models\PathSettings; @@ -117,6 +118,16 @@ final class ApiBillController extends Controller /** @var \Modules\Billing\Models\Bill $old */ $old = BillMapper::get()->where('id', (int) $request->getData('bill'))->execute(); + + // @feature Allow to update internal statistical fields + // Example: Referral account + if ($old->status === BillStatus::ARCHIVED) { + $response->header->status = RequestStatusCode::R_423; + $this->createInvalidUpdateResponse($request, $response, $val); + + return; + } + $new = $this->updateBillFromRequest($request, clone $old); $this->updateModel($request->header->account, $old, $new, BillMapper::class, 'bill', $request->getOrigin()); @@ -229,23 +240,27 @@ final class ApiBillController extends Controller * * @return Bill The new Bill object with default values * - * @todo Validate VAT before creation (maybe need to add a status when last validated, we don't want to validate every time) - * @todo Set the correct date of payment + * @todo Validate VAT before creation + * 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) * * @since 1.0.0 */ public function createBaseBill(Client | Supplier $account, RequestAbstract $request) : Bill { - // @todo validate vat before creation for clients $bill = new Bill(); $bill->createdBy = new NullAccount($request->header->account); $bill->unit = $account->unit ?? $this->app->unitId; - $bill->billDate = new \DateTime('now'); // @todo Date of payment - $bill->performanceDate = $request->getDataDateTime('performancedate') ?? new \DateTime('now'); // @todo Date of payment + $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->status = BillStatus::tryFromValue($request->getDataInt('status')) ?? BillStatus::DRAFT; $bill->shippingTerms = null; @@ -254,6 +269,10 @@ final class ApiBillController extends Controller $bill->paymentTerms = null; $bill->paymentText = ''; + // @todo Handle payment due + // Careful, there can be multiple due dates + // Example: payment plan or discounted and none-discounted date + if ($account instanceof Client) { $bill->client = $account; $bill->accTaxCode = empty($temp = $bill->client->getAttribute('sales_tax_code')->value->id) ? null : $temp; @@ -406,7 +425,7 @@ final class ApiBillController extends Controller $item, $taxCombination, FloatInt::toInt($request->getDataString('quantity') ?? 1), - $bill->id, + $bill, $container ); @@ -540,7 +559,7 @@ final class ApiBillController extends Controller $uploaded = []; if (!empty($uploadedFiles = $request->files)) { - $uploaded = $this->app->moduleManager->get('Media')->uploadFiles( + $uploaded = $this->app->moduleManager->get('Media', 'Api')->uploadFiles( names: [], fileNames: [], files: $uploadedFiles, @@ -603,20 +622,20 @@ final class ApiBillController extends Controller } } - if (!empty($mediaFiles = $request->getDataJson('media'))) { - foreach ($mediaFiles as $media) { - $this->createModelRelation( - $request->header->account, - $bill->id, - (int) $media, - BillMapper::class, - 'files', - '', - $request->getOrigin() - ); - } + $mediaFiles = $request->getDataJson('media'); + foreach ($mediaFiles as $media) { + $this->createModelRelation( + $request->header->account, + $bill->id, + (int) $media, + BillMapper::class, + 'files', + '', + $request->getOrigin() + ); } + // @todo media should be an array of NullMedia elements $this->fillJsonResponse($request, $response, NotificationLevel::OK, 'Media', 'Media added to bill.', [ 'upload' => $uploaded, 'media' => $mediaFiles, @@ -638,7 +657,6 @@ final class ApiBillController extends Controller */ public function apiMediaRemoveFromBill(RequestAbstract $request, ResponseAbstract $response, array $data = []) : void { - // @todo check that it is not system generated media! if (!empty($val = $this->validateMediaRemoveFromBill($request))) { $response->header->status = RequestStatusCode::R_400; $this->createInvalidDeleteResponse($request, $response, $val); @@ -652,20 +670,35 @@ final class ApiBillController extends Controller /** @var \Modules\Billing\Models\Bill $bill */ $bill = BillMapper::get()->where('id', (int) $request->getData('bill'))->execute(); - $path = $this->createBillDir($bill); + // Cannot delete system generated bill + if (\stripos($media->name, $bill->number) !== false) { + $response->header->status = RequestStatusCode::R_423; + $this->createInvalidDeleteResponse($request, $response, $media); - /** @var \Modules\Media\Models\Collection[] */ - $billCollection = CollectionMapper::getAll() - ->where('virtual', $path) - ->execute(); - - if (\count($billCollection) !== 1) { - // @todo For some reason there are multiple collections with the same virtual path? - // @todo check if this is the correct way to handle it or if we need to make sure that it is a collection return; } - $collection = \reset($billCollection); + $path = \dirname($this->createBillDir($bill)); + + /** @var \Modules\Media\Models\Collection $collection */ + $collection = CollectionMapper::get() + ->where('name', (string) $bill->id) + ->where('virtual', $path) + ->where('class', MediaClass::COLLECTION) + ->limit(1) + ->execute(); + + if ($collection->id !== 0) { + $this->deleteModelRelation( + $request->header->account, + $collection->id, + $media->id, + CollectionMapper::class, + 'sources', + '', + $request->getOrigin() + ); + } $this->deleteModelRelation( $request->header->account, @@ -677,23 +710,9 @@ final class ApiBillController extends Controller $request->getOrigin() ); - $this->deleteModelRelation( - $request->header->account, - $collection->id, - $media->id, - CollectionMapper::class, - 'sources', - '', - $request->getOrigin() - ); - + // Check if media referenced by other media except the parent collection $referenceCount = MediaMapper::countInternalReferences($media->id); - - if ($referenceCount === 0) { - // Is not used anywhere else -> remove from db and file system - - // @todo remove media types from media - + if ($referenceCount === 1) { $this->deleteModel($request->header->account, $media, MediaMapper::class, 'bill_media', $request->getOrigin()); if (\is_dir($media->getAbsolutePath())) { @@ -797,11 +816,9 @@ final class ApiBillController extends Controller $element = $this->createBillElementFromRequest($request, $response, $old, $data); $this->createModel($request->header->account, $element, BillElementMapper::class, 'bill_element', $request->getOrigin()); - // @todo handle stock transaction here - // @todo if transaction fails don't update below and send warning to user - // @todo however mark transaction as reserved and only update when bill is finalized!!! - - // @todo in BillElementUpdate do the same + // @todo Handle stock transaction here + // If the transaction fails don't perform the update below + // The same goes for BillElementUpdate $new = clone $old; $new->addElement($element); @@ -1076,6 +1093,9 @@ final class ApiBillController extends Controller // Handle PDF generation $templateId = $request->getDataInt('bill_template') ?? $bill->type->defaultTemplate?->id ?? 0; + // @todo It would be nice if we could somehow make the two settings calls below in one go. + // Maybe always make with unit if defined AND with null (maybe also with app?) + // Then return none-empty strictest match /** @var \Model\Setting[] $settings */ $settings = $this->app->appSettings->get(null, [ @@ -1175,7 +1195,9 @@ final class ApiBillController extends Controller ); // Send bill via email - // @todo maybe not all bill types, and bill status (e.g. deleted should not be sent) + // @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') @@ -1186,9 +1208,10 @@ final class ApiBillController extends Controller ->execute(); 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()) - ? (string) $tmp - : $client->account->getEmail(); + ? $client->account->email + : (string) $tmp; $this->sendBillEmail($media, $email, $response->header->l11n->language); } @@ -1245,6 +1268,10 @@ final class ApiBillController extends Controller module: 'Admin' ); + if (empty($emailFrom->content)) { + return; + } + /** @var \Model\Setting $billingTemplate */ $billingTemplate = $this->app->appSettings->get( names: SettingsEnum::BILLING_CUSTOMER_EMAIL_TEMPLATE, @@ -1263,8 +1290,6 @@ final class ApiBillController extends Controller $mail->addAttachment($media->getAbsolutePath(), $media->name); $handler->send($mail); - - $this->app->moduleManager->get('Billing', 'Api')->sendMail($mail); } /** @@ -1380,8 +1405,6 @@ final class ApiBillController extends Controller * * @return array * - * @todo Implement API validation function - * * @since 1.0.0 */ private function validateBillDelete(RequestAbstract $request) : array @@ -1417,7 +1440,17 @@ final class ApiBillController extends Controller } /** @var BillElement $old */ - $old = BillElementMapper::get()->where('id', (int) $request->getData('id'))->execute(); + $old = BillElementMapper::get() + ->with('bill') + ->where('id', (int) $request->getData('id')) + ->execute(); + + if ($old->bill->status === BillStatus::ARCHIVED) { + $response->header->status = RequestStatusCode::R_423; + $this->createInvalidUpdateResponse($request, $response, $old); + + return; + } // @todo can be edited? // @todo adjust transfer protocols diff --git a/Controller/ApiPriceController.php b/Controller/ApiPriceController.php index 6773af8..61318d1 100755 --- a/Controller/ApiPriceController.php +++ b/Controller/ApiPriceController.php @@ -20,23 +20,19 @@ use Modules\Billing\Models\Price\NullPrice; use Modules\Billing\Models\Price\Price; use Modules\Billing\Models\Price\PriceMapper; use Modules\Billing\Models\Price\PriceType; -use Modules\Billing\Models\Tax\TaxCombinationMapper; use Modules\ClientManagement\Models\Client; use Modules\ClientManagement\Models\ClientMapper; use Modules\ClientManagement\Models\NullClient; -use Modules\Finance\Models\TaxCodeMapper; use Modules\ItemManagement\Models\Item; 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; use phpOMS\Message\ResponseAbstract; use phpOMS\Stdlib\Base\FloatInt; -use phpOMS\System\MimeType; /** * Billing class. @@ -74,6 +70,11 @@ final class ApiPriceController extends Controller ->execute(); } + // Get supplier + if ($supplier === null && $request->hasData('supplier')) { + $supplier = new NullSupplier($request->getDataInt('supplier')); + } + $quantity = new FloatInt($request->getDataString('price_quantity') ?? 10000); // Get all relevant prices @@ -99,9 +100,9 @@ final class ApiPriceController extends Controller $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'); - $queryMapper->where('supplier', \array_unique([$request->getDataInt('supplier'), $supplier?->id, null]), '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') ?? PriceType::SALES); + $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'); // @todo implement start and end @@ -120,7 +121,7 @@ final class ApiPriceController extends Controller /** @var \Modules\Billing\Models\Price\Price[] $prices */ $prices = $queryMapper->execute(); - // Find base price (@todo probably not a good solution) + // Find base price $basePrice = null; foreach ($prices as $price) { if ($price->priceNew > 0 @@ -144,8 +145,11 @@ final class ApiPriceController extends Controller $basePrice ??= new NullPrice(); - // @todo implement prices which cannot be improved even if there are better prices available (i.e. some customer groups may not get better prices, Dentagen Beispiel) - // alternatively set prices as 'improvable' => which whitelists a price as can be improved or 'alwaysimproces' which always overwrites other prices + // @todo implement prices which cannot be improved even if there are better prices available + // (i.e. some customer groups may not get better prices, Dentagen Beispiel) + // alternatively set prices as 'improvable' => which whitelists a price as can be improved + // or 'always_improves' which always overwrites other prices + // Find best price $bestPrice = $basePrice; $bestPriceValue = \PHP_INT_MAX; @@ -219,197 +223,6 @@ final class ApiPriceController extends Controller ]; } - /** - * Api method to find items - * - * @param RequestAbstract $request Request - * @param ResponseAbstract $response Response - * @param array $data Generic data - * - * @return void - * - * @api - * - * @since 1.0.0 - */ - public function apiPricingFind(RequestAbstract $request, ResponseAbstract $response, array $data = []) : void - { - // Get item - /** @var null|\Modules\ItemManagement\Models\Item $item */ - $item = null; - if ($request->hasData('price_item')) { - /** @var null|\Modules\ItemManagement\Models\Item $item */ - $item = ItemMapper::get() - ->with('attributes') - ->with('attributes/type') - ->with('attributes/value') - ->where('id', (int) $request->getData('price_item')) - ->execute(); - } - - // Get account - /** @var null|\Modules\ClientManagement\Models\Client|\Modules\SupplierManagement\Models\Supplier $account */ - $account = null; - if ($request->hasData('client')) { - /** @var \Modules\ClientManagement\Models\Client $client */ - $client = ClientMapper::get() - ->with('attributes') - ->with('attributes/type') - ->with('attributes/value') - ->where('id', (int) $request->getData('client')) - ->execute(); - - /** @var \Modules\ClientManagement\Models\Client */ - $account = $client; - } else { - /** @var \Modules\SupplierManagement\Models\Supplier $supplier */ - $supplier = SupplierMapper::get() - ->with('attributes') - ->with('attributes/type') - ->with('attributes/value') - ->where('id', (int) $request->getData('supplier')) - ->execute(); - - /** @var \Modules\SupplierManagement\Models\Supplier $account */ - $account = $supplier; - } - - $quantity = new FloatInt($request->getDataString('price_quantity') ?? 10000); - - // Get all relevant prices - $queryMapper = PriceMapper::getAll(); - - if ($request->hasData('price_name')) { - $queryMapper->where('name', $request->getData('name')); - } - - $queryMapper->where('promocode', \array_unique([$request->getData('promocode'), null]), 'IN'); - - $queryMapper->where('item', \array_unique([$request->getData('item', 'int'), null]), 'IN'); - $queryMapper->where('itemsalesgroup', \array_unique([$request->getData('sales_group', 'int'), $item?->getAttribute('sales_group')->id, null]), 'IN'); - $queryMapper->where('itemproductgroup', \array_unique([$request->getData('product_group', 'int'), $item?->getAttribute('product_group')->id, null]), 'IN'); - $queryMapper->where('itemsegment', \array_unique([$request->getData('item_segment', 'int'), $item?->getAttribute('segment')->id, null]), 'IN'); - $queryMapper->where('itemsection', \array_unique([$request->getData('item_section', 'int'), $item?->getAttribute('section')->id, null]), 'IN'); - $queryMapper->where('itemtype', \array_unique([$request->getData('product_type', 'int'), $item?->getAttribute('product_type')->id, null]), 'IN'); - - $queryMapper->where('client', \array_unique([$request->getData('client', 'int'), null]), 'IN'); - $queryMapper->where('clientgroup', \array_unique([$request->getData('client_group', 'int'), $client?->getAttribute('client_group')->id, null]), 'IN'); - $queryMapper->where('clientsegment', \array_unique([$request->getData('client_segment', 'int'), $client?->getAttribute('segment')->id, null]), 'IN'); - $queryMapper->where('clientsection', \array_unique([$request->getData('client_section', 'int'), $client?->getAttribute('section')->id, null]), 'IN'); - $queryMapper->where('clienttype', \array_unique([$request->getData('client_type', 'int'), $client?->getAttribute('client_type')->id, null]), 'IN'); - $queryMapper->where('clientcountry', \array_unique([$request->getData('client_region'), $client?->mainAddress->country, null]), 'IN'); - - $queryMapper->where('supplier', \array_unique([$request->getData('supplier', 'int'), null]), 'IN'); - $queryMapper->where('unit', \array_unique([$request->getData('price_unit', 'int'), null]), 'IN'); - $queryMapper->where('type', $request->getData('price_type', 'int') ?? PriceType::SALES); - $queryMapper->where('currency', \array_unique([$request->getData('currency', 'int'), null]), 'IN'); - - // @todo implement start and end - - /* - @todo implement quantity - if ($request->hasData('price_quantity')) { - $whereQuery = new Where(); - $whereQuery->where('quantity', (int) $request->getData('price_quantity'), '<=') - ->where('quantity', null, '=', 'OR') - - $queryMapper->where('quantity', $whereQuery); - } - */ - - /** @var \Modules\Billing\Models\Price\Price[] $prices */ - $prices = $queryMapper->execute(); - - // Find base price (@todo probably not a good solution) - $bestBasePrice = null; - foreach ($prices as $price) { - if ($price->priceNew->value !== 0 && $price->priceNew === 0 - && $price->item->id !== 0 - && $price->itemsalesgroup->id === 0 - && $price->itemproductgroup->id === 0 - && $price->itemsegment->id === 0 - && $price->itemsection->id === 0 - && $price->itemtype->id === 0 - && $price->client->id === 0 - && $price->clientgroup->id === 0 - && $price->clientsegment->id === 0 - && $price->clientsection->id === 0 - && $price->clienttype->id === 0 - && $price->promocode === '' - && $price->priceNew->value < ($bestBasePrice?->price->value ?? \PHP_INT_MAX) - ) { - $bestBasePrice = $price; - } - } - - // @todo implement prices which cannot be improved even if there are better prices available (i.e. some customer groups may not get better prices, Dentagen Beispiel) - // alternatively set prices as 'improvable' => which whitelists a price as can be improved or 'alwaysimproces' which always overwrites other prices - // Find best price - $bestPrice = null; - $bestPriceValue = \PHP_INT_MAX; - - foreach ($prices as $price) { - $newPrice = $bestBasePrice?->price->value ?? \PHP_INT_MAX; - - if ($price->priceNew->value < $newPrice) { - $newPrice = $price->priceNew->value; - } - - // Calculate the price EFFECT (this is the theoretical unit price) - // 1. subtract discount value - // 2. subtract discount percentage - // 3. subtract bonus effect - - $newPrice -= $price->discount->value; - $newPrice = (int) ((1000000 - $price->discountPercentage->value) / 1000000 * $newPrice); - $newPrice = (int) ($newPrice - $price->bonus->value / 10000 * $price->priceNew->value / $quantity->value); - - // @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? - - if ($newPrice < $bestPriceValue) { - $bestPriceValue = $newPrice; - $bestPrice = $price; - } - } - - // Actual price calculation - $bestActualPrice = $bestBasePrice?->price->value ?? \PHP_INT_MAX; - $bestActualPrice -= $bestPrice->discount->value; - - // @todo now perform subtractive improvements (e.g. promocodes are often subtractive) - - // Get tax definition - /** @var \Modules\Billing\Models\Tax\TaxCombination $tax */ - $tax = ($request->getDataInt('price_type') ?? PriceType::SALES) === PriceType::SALES - ? TaxCombinationMapper::get() - ->where('itemCode', $request->getDataInt('price_item')) - ->where('clientCode', $account->getAttribute('client_code')->value->id) - ->execute() - : TaxCombinationMapper::get() - ->where('itemCode', $request->getDataInt('price_item')) - ->where('supplierCode', $account->getAttribute('supplier_code')->value->id) - ->execute(); - - $taxCode = TaxCodeMapper::get() - ->where('abbr', $tax->taxCode) - ->execute(); - - $result = [ - 'taxcode' => $taxCode->abbr, - 'grossPercentage' => $taxCode->percentageInvoice, - 'net' => $bestActualPrice, - 'taxes' => $bestActualPrice * $taxCode->percentageInvoice / 1000000, - 'gross' => $bestActualPrice + $bestActualPrice * $taxCode->percentageInvoice / 1000000, - ]; - - $response->header->set('Content-Type', MimeType::M_JSON, true); - $response->set( - $request->uri->__toString(), - $result - ); - } - /** * Api method to create item bill type * @@ -530,6 +343,22 @@ final class ApiPriceController extends Controller $old = PriceMapper::get()->where('id', (int) $request->getData('id'))->execute(); $new = $this->updatePriceFromRequest($request, clone $old); + if ($new->name === 'default' + && $old->priceNew->value !== $new->priceNew->value + ) { + /** @var \Modules\ItemManagement\Models\Item $item */ + $item = ItemMapper::get()->where('id', $new->item)->execute(); + $itemNew = clone $item; + + if ($new->type === PriceType::SALES) { + $itemNew->salesPrice = $new->priceNew; + } else { + $itemNew->purchasePrice = $new->priceNew; + } + + $this->updateModel($request->header->account, $item, $itemNew, ItemMapper::class, 'price', $request->getOrigin()); + } + $this->updateModel($request->header->account, $old, $new, PriceMapper::class, 'price', $request->getOrigin()); $this->createStandardUpdateResponse($request, $response, $new); } @@ -552,7 +381,6 @@ final class ApiPriceController extends Controller $new->promocode = $request->getDataString('promocode') ?? $new->promocode; - $new->item = $request->hasData('item') ? new NullItem((int) $request->getData('item')) : $new->item; $new->itemsalesgroup = $request->hasData('itemsalesgroup') ? new NullAttributeValue((int) $request->getData('itemsalesgroup')) : $new->itemsalesgroup; $new->itemproductgroup = $request->hasData('itemproductgroup') ? new NullAttributeValue((int) $request->getData('itemproductgroup')) : $new->itemproductgroup; $new->itemsegment = $request->hasData('itemsegment') ? new NullAttributeValue((int) $request->getData('itemsegment')) : $new->itemsegment; @@ -567,10 +395,9 @@ final class ApiPriceController extends Controller $new->supplier = $request->hasData('supplier') ? new NullSupplier((int) $request->getData('supplier')) : $new->supplier; $new->unit = $request->getDataInt('unit') ?? $new->unit; - $new->type = PriceType::tryFromValue($request->getDataInt('type')) ?? $new->type; $new->quantity = $request->getDataInt('quantity') ?? $new->quantity; - $new->price = $request->hasData('price') ? new FloatInt((int) $request->getData('price')) : $new->price; - $new->priceNew = $request->getDataInt('price_new') ?? $new->priceNew; + $new->price = $new->priceNew; + $new->priceNew = $request->hasData('price_new') ? new FloatInt((int) $request->getData('price_new')) : $new->discount = $request->getDataInt('discount') ?? $new->discount; $new->discountPercentage = $request->getDataInt('discountPercentage') ?? $new->discountPercentage; $new->bonus = $request->getDataInt('bonus') ?? $new->bonus; @@ -589,9 +416,6 @@ final class ApiPriceController extends Controller * * @return array * - * @todo implement - * @todo consider to block 'default' name - * * @since 1.0.0 */ private function validatePriceUpdate(RequestAbstract $request) : array diff --git a/Controller/ApiPurchaseController.php b/Controller/ApiPurchaseController.php index 2fb5c43..7f58dec 100755 --- a/Controller/ApiPurchaseController.php +++ b/Controller/ApiPurchaseController.php @@ -69,7 +69,9 @@ final class ApiPurchaseController extends Controller ->limit(1) ->execute(); - $files = $request->files; + $bills = []; + + $files = \array_merge($request->files, $request->getDataJson('media')); foreach ($files as $file) { // Create default bill $billRequest = new HttpRequest(); @@ -85,32 +87,41 @@ final class ApiPurchaseController extends Controller $this->app->moduleManager->get('Billing', 'ApiBill')->apiBillCreate($billRequest, $billResponse, $data); $billId = $billResponse->getDataArray('')['response']->id; + $bills[] = $billId; // Upload and assign document to bill - $mediaRequest = new HttpRequest(); + $mediaResponse = new HttpResponse(); + $mediaRequest = new HttpRequest(); + + $mediaResponse->header->l11n = $response->header->l11n; + $mediaRequest->header->account = $request->header->account; $mediaRequest->header->l11n = $request->header->l11n; - $mediaRequest->addFile($file); - $mediaResponse = new HttpResponse(); - $mediaResponse->header->l11n = $response->header->l11n; + if (\is_array($file)) { + $mediaRequest->addFile($file); + } else { + $mediaRequest->setData('media', \json_encode($file)); + } $mediaRequest->setData('bill', $billId); $mediaRequest->setData('type', $originalType); $mediaRequest->setData('parse_content', true, true); $this->app->moduleManager->get('Billing', 'ApiBill')->apiMediaAddToBill($mediaRequest, $mediaResponse, $data); - /** @var \Modules\Media\Models\Media[] $uploaded */ - $uploaded = $mediaResponse->getDataArray('')['response']['upload']; - if (empty($uploaded)) { - $response->header->status = RequestStatusCode::R_400; - throw new \Exception(); - } + if (\is_array($file)) { + /** @var \Modules\Media\Models\Media[] $uploaded */ + $uploaded = $mediaResponse->getDataArray('')['response']['upload']; + if (empty($uploaded)) { + $response->header->status = RequestStatusCode::R_400; + throw new \Exception(); + } - $in = \reset($uploaded)->getAbsolutePath(); // pdf parsed content is available in $in->content - if (!\is_file($in)) { - $response->header->status = RequestStatusCode::R_400; - throw new \Exception(); + $in = \reset($uploaded)->getAbsolutePath(); + if (!\is_file($in)) { + $response->header->status = RequestStatusCode::R_400; + throw new \Exception(); + } } // Create internal document @@ -134,12 +145,14 @@ final class ApiPurchaseController extends Controller \escapeshellarg($cliPath) . ' /billing/bill/purchase/parse ' . '-i ' . \escapeshellarg((string) $billId), - true + $request->getDataBool('async') ?? true ); } catch (\Throwable $t) { $response->header->status = RequestStatusCode::R_400; $this->app->logger->error($t->getMessage()); } + + $this->createStandardCreateResponse($request, $response, $bills); } } } diff --git a/Controller/ApiTaxController.php b/Controller/ApiTaxController.php index 0806aae..215b249 100755 --- a/Controller/ApiTaxController.php +++ b/Controller/ApiTaxController.php @@ -465,8 +465,6 @@ final class ApiTaxController extends Controller * * @return array * - * @todo Implement API validation function - * * @since 1.0.0 */ private function validateTaxCombinationDelete(RequestAbstract $request) : array diff --git a/Controller/CliController.php b/Controller/CliController.php index 2d1ff63..31797a4 100755 --- a/Controller/CliController.php +++ b/Controller/CliController.php @@ -315,7 +315,8 @@ final class CliController extends Controller * @return int * * @since 1.0.0 - * @todo What about multiple tax lines? + * @todo Handle multiple tax lines + * Example: 19% and 7% */ private function findBillTaxAmount(array $lines, array $matches) : int { diff --git a/Models/Bill.php b/Models/Bill.php index 75ff7d6..d366729 100755 --- a/Models/Bill.php +++ b/Models/Bill.php @@ -247,7 +247,7 @@ class Bill implements \JsonSerializable */ public Account $referral; - public string $referralName = ''; + public string $externalReferral = ''; /** * Net amount. diff --git a/Models/BillElement.php b/Models/BillElement.php index 1d63a23..3091223 100755 --- a/Models/BillElement.php +++ b/Models/BillElement.php @@ -283,7 +283,7 @@ class BillElement implements \JsonSerializable * @param Item $item Item * @param TaxCode $taxCode Tax code used for gross amount calculation * @param int $quantity Quantity - * @param int $bill Bill + * @param Bill $bill Bill * * @return self * @@ -293,12 +293,12 @@ class BillElement implements \JsonSerializable Item $item, TaxCombination $taxCombination, int $quantity = 10000, - int $bill = 0, + Bill $bill = null, ?Container $container = null ) : self { $element = new self(); - $element->bill = new NullBill($bill); + $element->bill = $bill; $element->item = empty($item->id) ? null : $item; $element->container = empty($container->id) ? null : $container; $element->itemNumber = $item->number; @@ -324,7 +324,7 @@ class BillElement implements \JsonSerializable $element->subscription = new Subscription(); $element->subscription->bill = $element->bill->id; $element->subscription->item = $element->item->id; - $element->subscription->start = new \DateTime('now'); // @todo change to bill performanceDate + $element->subscription->start = $bill?->performanceDate ?? new \DateTime('now'); $element->subscription->end = (new SmartDateTime('now'))->smartModify(m: 1); // @todo depends on subscription type $element->subscription->quantity = $element->quantity; diff --git a/Models/BillMapper.php b/Models/BillMapper.php index 84fb55f..6073d59 100755 --- a/Models/BillMapper.php +++ b/Models/BillMapper.php @@ -81,7 +81,7 @@ class BillMapper extends DataMapperFactory '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' => 'referralName'], + '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/Price/Price.php b/Models/Price/Price.php index 472bd0e..74eab01 100755 --- a/Models/Price/Price.php +++ b/Models/Price/Price.php @@ -32,6 +32,12 @@ use phpOMS\Stdlib\Base\FloatInt; * @license OMS License 2.0 * @link https://jingga.app * @since 1.0.0 + * + * @todo Find a way to handle references to the total invoice amount and other items + * Example: If total invoice > $X no shipping expenses + * Maybe additional column referencing total value + * Example: If item Y quantity > Z no costs for item A (e.g. service fee) + * Maybe by referencing another price (i.e. if other price triggered than this is triggered as well) */ class Price implements \JsonSerializable { diff --git a/Models/SalesBillMapper.php b/Models/SalesBillMapper.php index 1271c80..6033eff 100755 --- a/Models/SalesBillMapper.php +++ b/Models/SalesBillMapper.php @@ -309,13 +309,13 @@ final class SalesBillMapper extends BillMapper /** * Placeholder */ - public static function getClientBills(int $id, \DateTime $start, \DateTime $end) : array + public static function getClientBills(int $id, string $language, \DateTime $start, \DateTime $end) : array { return self::getAll() ->with('type') ->with('type/l11n') ->where('client', $id) - ->where('type/l11n/language', 'en') // @todo fix localization + ->where('type/l11n/language', $language) ->where('billDate', $start, '>=') ->where('billDate', $end, '<=') ->execute(); @@ -362,10 +362,11 @@ final class SalesBillMapper extends BillMapper /** * Placeholder */ - public static function getItemMonthlySalesCosts(int $item, \DateTime $start, \DateTime $end) : array + public static function getItemMonthlySalesCosts(array $item, \DateTime $start, \DateTime $end) : array { $sql = <<= '{$start->format('Y-m-d H:i:s')}' AND billing_bill_performance_date <= '{$end->format('Y-m-d H:i:s')}' - GROUP BY year, month - ORDER BY year ASC, month ASC; + GROUP BY billing_bill_element_item, year, month + ORDER BY billing_bill_element_item, year ASC, month ASC; + SQL; + + $query = new Builder(self::$db); + $result = $query->raw($sql)->execute()->fetchAll(\PDO::FETCH_ASSOC); + + return $result ?? []; + } + + public static function getItemMonthlySalesQuantity(array $item, \DateTime $start, \DateTime $end) : array + { + $sql = <<= '{$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; SQL; $query = new Builder(self::$db); diff --git a/Models/bill_identifier.json b/Models/bill_identifier.json index fe912d5..ee607ab 100644 --- a/Models/bill_identifier.json +++ b/Models/bill_identifier.json @@ -2,10 +2,12 @@ "type": { "purchase_invoice": { "en": [ - "Invoice" + "Invoice", + "Receipt" ], "de": [ - "Rechnung" + "Rechnung", + "Quittung" ] }, "purchase_credit_note": { @@ -34,9 +36,7 @@ ] }, "purchase_reverse_invoice": { - "en": [ - "Credit Note" - ], + "en": [], "de": [ "Gutschrift" ] diff --git a/tests/Models/SalesBillMapperTest.php b/tests/Models/SalesBillMapperTest.php index 9798b77..fa0f343 100755 --- a/tests/Models/SalesBillMapperTest.php +++ b/tests/Models/SalesBillMapperTest.php @@ -59,15 +59,6 @@ final class SalesBillMapperTest extends \PHPUnit\Framework\TestCase self::assertEquals(0, SalesBillMapper::getSalesByClientId(99999, new \DateTime('now'), new \DateTime('now'))->getInt()); } - /** - * @covers Modules\Billing\Models\SalesBillMapper - * @group module - */ - public function testGetAvgSalesPriceByItemIdInvalid() : void - { - self::assertEquals(0, SalesBillMapper::getAvgSalesPriceByItemId(99999, new \DateTime('now'), new \DateTime('now'))->getInt()); - } - /** * @covers Modules\Billing\Models\SalesBillMapper * @group module @@ -146,7 +137,7 @@ final class SalesBillMapperTest extends \PHPUnit\Framework\TestCase */ public function testGetItemMonthlySalesCostsInvalid() : void { - self::assertEquals([], SalesBillMapper::getItemMonthlySalesCosts(99999, new \DateTime('now'), new \DateTime('now'))); + self::assertEquals([], SalesBillMapper::getItemMonthlySalesCosts([99999], new \DateTime('now'), new \DateTime('now'))); } /**