This commit is contained in:
Dennis Eichhorn 2024-02-04 20:34:11 +00:00
parent 9f400de255
commit 47651186c8
16 changed files with 209 additions and 321 deletions

View File

@ -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'
);

View File

@ -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',

View File

@ -9,12 +9,12 @@
"l11n": {
"en": {
"subject": "Billing",
"body": "<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Billing</title></head><body style=\"font-family: Arial, sans-serif; font-size: 16px; line-height: 1.5;\"><table cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\"><tr><td><table align=\"center\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"600\" style=\"margin: 0 auto; background-color: #ffffff;\"><tr><td style=\"padding: 40px 0; text-align: center;\"><h1 style=\"margin-top: 0; color: #000000; font-size: 24px;\">Billing</h1><p style=\"margin-bottom: 20px;\">Dear {user_name},</p><p style=\"margin-bottom: 20px;\">Thank you for for doing business with us.</p><p style=\"margin-bottom: 20px;\">Attached kindly find your bill.</p><p style=\"margin-top: 40px;\">Jingga e.K. - www.jingga.app - CEO Dennis Eichhorn - Amtsgericht Friedberg HRA 5058</p></td></tr></table></td></tr></table></body></html>",
"body": "<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Billing</title></head><body style=\"font-family: Arial, sans-serif; font-size: 14px; line-height: 1.5;\"><table cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\"><tr><td><table cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"600\" style=\"margin: 0 auto; background-color: #ffffff;\"><tr><td style=\"padding: 40px 0;\"><h1 style=\"margin-top: 0; color: #000000; font-size: 24px; text-align: center;\">Billing</h1><p style=\"margin-bottom: 20px;\">Dear {user_name},</p><p style=\"margin-bottom: 20px;\">Thank you for for doing business with us.</p><p style=\"margin-bottom: 20px;\">Attached kindly find your bill.</p><p style=\"margin-top: 40px;\">Jingga e.K. - www.jingga.app - CEO Dennis Eichhorn - Amtsgericht Friedberg HRA 5058</p></td></tr></table></td></tr></table></body></html>",
"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": "<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Abrechnung</title></head><body style=\"font-family: Arial, sans-serif; font-size: 16px; line-height: 1.5;\"><table cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\"><tr><td><table align=\"center\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"600\" style=\"margin: 0 auto; background-color: #ffffff;\"><tr><td style=\"padding: 40px 0; text-align: center;\"><h1 style=\"margin-top: 0; color: #000000; font-size: 24px;\">Abrechnung</h1><p style=\"margin-bottom: 20px;\">Sehr geehrte/r {user_name},</p><p style=\"margin-bottom: 20px;\">Vielen Dank für Ihre Geschäftsbeziehung mit uns.</p><p style=\"margin-bottom: 20px;\">Im Anhang finden Sie Ihre Rechnung.</p><p style=\"margin-top: 40px;\">Jingga e.K. - www.jingga.app - CEO Dennis Eichhorn - Amtsgericht Friedberg HRA 5058</p></td></tr></table></td></tr></table></body></html>",
"body": "<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Abrechnung</title></head><body style=\"font-family: Arial, sans-serif; font-size: 14px; line-height: 1.5;\"><table cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\"><tr><td><table cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"600\" style=\"margin: 0 auto; background-color: #ffffff;\"><tr><td style=\"padding: 40px 0;\"><h1 style=\"margin-top: 0; color: #000000; font-size: 24px; text-align: center;\">Abrechnung</h1><p style=\"margin-bottom: 20px;\">Sehr geehrte/r {user_name},</p><p style=\"margin-bottom: 20px;\">Vielen Dank für Ihre Geschäftsbeziehung mit uns.</p><p style=\"margin-bottom: 20px;\">Im Anhang finden Sie Ihre Rechnung.</p><p style=\"margin-top: 40px;\">Jingga e.K. - www.jingga.app - CEO Dennis Eichhorn - Amtsgericht Friedberg HRA 5058</p></td></tr></table></td></tr></table></body></html>",
"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"
}
},

View File

@ -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();

View File

@ -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<string, bool>
*
* @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

View File

@ -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<string, bool>
*
* @todo implement
* @todo consider to block 'default' name
*
* @since 1.0.0
*/
private function validatePriceUpdate(RequestAbstract $request) : array

View File

@ -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);
}
}
}

View File

@ -465,8 +465,6 @@ final class ApiTaxController extends Controller
*
* @return array<string, bool>
*
* @todo Implement API validation function
*
* @since 1.0.0
*/
private function validateTaxCombinationDelete(RequestAbstract $request) : array

View File

@ -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
{

View File

@ -247,7 +247,7 @@ class Bill implements \JsonSerializable
*/
public Account $referral;
public string $referralName = '';
public string $externalReferral = '';
/**
* Net amount.

View File

@ -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;

View File

@ -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'],

View File

@ -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
{

View File

@ -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 = <<<SQL
SELECT
billing_bill_element_item
SUM(billing_bill_element_total_netsalesprice) as net_sales,
SUM(billing_bill_element_total_netpurchaseprice) as net_costs,
YEAR(billing_bill_performance_date) as year,
@ -373,11 +374,35 @@ final class SalesBillMapper extends BillMapper
FROM billing_bill_element
JOIN billing_bill ON billing_bill_element.billing_bill_element_bill = billing_bill.billing_bill_id
WHERE
billing_bill_element_item = {$item}
billing_bill_element_item IN ({$item})
AND billing_bill_performance_date >= '{$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 = <<<SQL
SELECT
billing_bill_element_item
SUM(billing_bill_element_quantity) as quantity,
YEAR(billing_bill_performance_date) as year,
MONTH(billing_bill_performance_date) as month
FROM billing_bill_element
JOIN billing_bill ON billing_bill_element.billing_bill_element_bill = billing_bill.billing_bill_id
WHERE
billing_bill_element_item IN ({$item})
AND billing_bill_performance_date >= '{$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);

View File

@ -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"
]

View File

@ -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')));
}
/**