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": "
BillingBillingDear {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": "BillingBillingDear {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": "AbrechnungAbrechnungSehr 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": "AbrechnungAbrechnungSehr 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')));
}
/**