testing order suggestion

This commit is contained in:
Dennis Eichhorn 2024-02-07 15:48:47 +00:00
parent 2cdbf9dac6
commit 544758631d
22 changed files with 943 additions and 174 deletions

View File

@ -19,14 +19,45 @@
"type": 2,
"subtype": 1,
"name": "OrderSuggestions",
"uri": "{/base}/purchase/order/suggestion?{?}",
"uri": "{/base}/purchase/order/suggestion/list?{?}",
"target": "self",
"icon": null,
"order": 15,
"from": "Purchase",
"permission": { "permission": 2, "category": null, "element": null },
"parent": 1002101001,
"children": []
"children": [
{
"id": 1002105101,
"pid": "/purchase/order/suggestion",
"type": 3,
"subtype": 1,
"name": "List",
"uri": "{/base}/purchase/order/suggestion/list?{?}",
"target": "self",
"icon": null,
"order": 1,
"from": "Purchase",
"permission": { "permission": 2, "category": null, "element": null },
"parent": 1002105001,
"children": []
},
{
"id": 1002105201,
"pid": "/purchase/order/suggestion",
"type": 3,
"subtype": 1,
"name": "Create",
"uri": "{/base}/purchase/order/suggestion/create?{?}",
"target": "self",
"icon": null,
"order": 5,
"from": "Purchase",
"permission": { "permission": 2, "category": null, "element": null },
"parent": 1002105001,
"children": []
}
]
},
{
"id": 1002106001,

View File

@ -1,66 +1,97 @@
{
"purchase_item": {
"name": "purchase_item",
"purchase_order_suggestion": {
"name": "purchase_order_suggestion",
"fields": {
"purchase_item_id": {
"name": "purchase_item_id",
"purchase_order_suggestion_id": {
"name": "purchase_order_suggestion_id",
"type": "INT",
"null": false,
"primary": true,
"autoincrement": true
},
"purchase_item_item": {
"name": "purchase_item_item",
"purchase_order_suggestion_status": {
"name": "purchase_order_suggestion_status",
"type": "TINYINT(1)",
"null": false
},
"purchase_order_suggestion_created_by": {
"name": "purchase_order_suggestion_created_by",
"type": "INT(11)",
"null": true,
"default": null,
"null": false,
"foreignTable": "account",
"foreignKey": "account_id"
},
"purchase_order_suggestion_created_at": {
"name": "purchase_order_suggestion_created_at",
"type": "DATETIME",
"null": false
}
}
},
"purchase_order_suggestion_element": {
"name": "purchase_order_suggestion_element",
"fields": {
"purchase_order_suggestion_element_id": {
"name": "purchase_order_suggestion_element_id",
"type": "INT",
"null": false,
"primary": true,
"autoincrement": true
},
"purchase_order_suggestion_element_status": {
"name": "purchase_order_suggestion_element_status",
"type": "TINYINT(1)",
"null": false
},
"purchase_order_suggestion_element_updated_by": {
"name": "purchase_order_suggestion_element_updated_by",
"type": "INT(11)",
"null": false,
"foreignTable": "account",
"foreignKey": "account_id"
},
"purchase_order_suggestion_element_updated_at": {
"name": "purchase_order_suggestion_element_updated_at",
"type": "DATETIME",
"null": false
},
"purchase_order_suggestion_suggestion": {
"name": "purchase_order_suggestion_suggestion",
"type": "INT(11)",
"null": false,
"foreignTable": "purchase_order_suggestion",
"foreignKey": "purchase_order_suggestion_id"
},
"purchase_order_suggestion_item": {
"name": "purchase_order_suggestion_item",
"type": "INT(11)",
"null": false,
"foreignTable": "itemmgmt_item",
"foreignKey": "itemmgmt_item_id"
},
"purchase_item_supplier": {
"name": "purchase_item_supplier",
"purchase_order_suggestion_bill": {
"name": "purchase_order_suggestion_bill",
"type": "INT(11)",
"null": true,
"default": null,
"foreignTable": "billing_bill",
"foreignKey": "billing_bill_id"
},
"purchase_order_suggestion_supplier": {
"name": "purchase_order_suggestion_supplier",
"type": "INT(11)",
"null": false,
"foreignTable": "suppliermgmt_supplier",
"foreignKey": "suppliermgmt_supplier_id"
},
"purchase_item_stock": {
"description": "@todo: create foreign key if stockmanagement/warehouse exists",
"name": "purchase_item_stock",
"type": "int(11)",
"null": true,
"default": null
},
"purchase_item_limit_order_quantity": {
"name": "purchase_item_limit_order_quantity",
"type": "INT(11)",
"purchase_order_suggestion_quantity": {
"name": "purchase_order_suggestion_quantity",
"type": "BIGINT",
"null": false
},
"purchase_item_minimum_order_quantity": {
"description": "Minimum quantity we want to order",
"name": "purchase_item_minimum_order_quantity",
"type": "INT(11)",
"null": false
},
"purchase_item_maximum_order_quantity": {
"name": "purchase_item_maximum_order_quantity",
"type": "INT(11)",
"null": false
},
"purchase_item_maximum_stock_quantity": {
"name": "purchase_item_maximum_stock_quantity",
"type": "INT(11)",
"null": false
},
"purchase_item_limit_order_range": {
"name": "purchase_item_limit_order_range",
"type": "INT(11)",
"null": false
},
"purchase_item_maximum_stock_range": {
"name": "purchase_item_maximum_stock_range",
"type": "INT(11)",
"purchase_order_suggestion_costs": {
"name": "purchase_order_suggestion_costs",
"type": "BIGINT",
"null": false
}
}

View File

@ -4,7 +4,7 @@ declare(strict_types=1);
use phpOMS\Router\RouteVerb;
return [
'^/purchase/order/suggestion(\?.*$|$)' => [
'^/purchase/order/suggestion/create(\?.*$|$)' => [
[
'dest' => '\Modules\Purchase\Controller\CliController:cliGenerateOrderSuggestion',
'verb' => RouteVerb::ANY,

View File

@ -73,14 +73,36 @@ return [
],
],
],
'^.*/purchase/order/suggestion(\?.*$|$)' => [
'^.*/purchase/order/suggestion/view(\?.*$|$)' => [
[
'dest' => '\Modules\Purchase\Controller\BackendController:viewPurchaseOrderSuggestion',
'verb' => RouteVerb::GET,
'permission' => [
'module' => BackendController::NAME,
'type' => PermissionType::READ,
'state' => PermissionCategory::ARTICLE,
'state' => PermissionCategory::ORDER,
],
],
],
'^.*/purchase/order/suggestion/create(\?.*$|$)' => [
[
'dest' => '\Modules\Purchase\Controller\BackendController:viewPurchaseOrderSuggestionCreate',
'verb' => RouteVerb::GET,
'permission' => [
'module' => BackendController::NAME,
'type' => PermissionType::CREATE,
'state' => PermissionCategory::ORDER,
],
],
],
'^.*/purchase/order/suggestion/list(\?.*$|$)' => [
[
'dest' => '\Modules\Purchase\Controller\BackendController:viewPurchaseOrderSuggestionList',
'verb' => RouteVerb::GET,
'permission' => [
'module' => BackendController::NAME,
'type' => PermissionType::READ,
'state' => PermissionCategory::ORDER,
],
],
],

View File

@ -0,0 +1,76 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package Modules\HumanResourceManagement
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace Modules\HumanResourceManagement\Controller;
use phpOMS\Message\Http\RequestStatusCode;
use phpOMS\Message\NotificationLevel;
use phpOMS\Message\RequestAbstract;
use phpOMS\Message\ResponseAbstract;
use phpOMS\System\OperatingSystem;
use phpOMS\System\SystemType;
use phpOMS\System\SystemUtils;
/**
* HumanResourceManagement controller class.
*
* @package Modules\HumanResourceManagement
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
final class ApiController extends Controller
{
/**
* Api method to create an employee from an existing account
*
* @param RequestAbstract $request Request
* @param ResponseAbstract $response Response
* @param array $data Generic data
*
* @return void
*
* @api
*
* @since 1.0.0
*/
public function apiOrderSuggestionSimulate(RequestAbstract $request, ResponseAbstract $response, array $data = []) : void
{
// Offload bill parsing to cli
$cliPath = \realpath(__DIR__ . '/../../../cli.php');
if ($cliPath === false) {
return;
}
$supplier = $request->getDataString('supplier');
$productGroup = $request->getDataInt('product_group');
$showIrrelevant = !($request->getDataBool('hide_irrelevant') ?? true);
try {
SystemUtils::runProc(
OperatingSystem::getSystem() === SystemType::WIN ? 'php.exe' : 'php',
\escapeshellarg($cliPath)
. ' /purchase/order/suggestion/create'
. ($supplier === null ? '' : ' -supplier ' . \escapeshellarg($supplier))
. ($productGroup === null ? '' : ' -pgroup ' . \escapeshellarg((string) $productGroup))
. ($showIrrelevant === null ? '' : ' -irrelevant ' . \escapeshellarg((string) $showIrrelevant))
. ' -user ' . ((int) $request->header->account),
$request->getDataBool('async') ?? true
);
} catch (\Throwable $t) {
$response->header->status = RequestStatusCode::R_400;
$this->app->logger->error($t->getMessage());
}
}
}

View File

@ -14,7 +14,9 @@ declare(strict_types=1);
namespace Modules\Purchase\Controller;
use Modules\Purchase\Models\OrderSuggestionMapper;
use phpOMS\Contract\RenderableInterface;
use phpOMS\DataStorage\Database\Query\OrderType;
use phpOMS\Message\RequestAbstract;
use phpOMS\Message\ResponseAbstract;
use phpOMS\Views\View;
@ -150,10 +152,67 @@ final class BackendController extends Controller
public function viewPurchaseOrderSuggestion(RequestAbstract $request, ResponseAbstract $response, array $data = []) : RenderableInterface
{
$view = new View($this->app->l11nManager, $request, $response);
$view->setTemplate('/Modules/Purchase/Theme/Backend/article-order-recommendation');
$view->data['nav'] = $this->app->moduleManager->get('Navigation')->createNavigationMid(1003001001, $request, $response);
$view->setTemplate('/Modules/Purchase/Theme/Backend/article-order-suggestion');
$view->data['nav'] = $this->app->moduleManager->get('Navigation')->createNavigationMid(1002105001, $request, $response);
$this->app->moduleManager->get('Purchase', 'Cli')->cliGenerateOrderSuggestion($request, $response);
$view->data['suggestions'] = OrderSuggestionMapper::get()
->with('createdBy')
->with('elements')
->with('elements/supplier')
->with('elements/item')
->with('elements/bill')
->with('elements/item/l11n')
->with('elements/item/l11n/type')
->where('id', (int) $request->getData('id'))
->where('elements/item/l11n/language', $response->header->l11n->language)
->where('elements/item/l11n/type/title', ['name1', 'name2'], 'IN')
->sort('elements/supplier', OrderType::ASC)
->execute();
return $view;
}
/**
* Routing end-point for application behavior.
*
* @param RequestAbstract $request Request
* @param ResponseAbstract $response Response
* @param array $data Generic data
*
* @return RenderableInterface
*
* @since 1.0.0
* @codeCoverageIgnore
*/
public function viewPurchaseOrderSuggestionCreate(RequestAbstract $request, ResponseAbstract $response, array $data = []) : RenderableInterface
{
$view = new View($this->app->l11nManager, $request, $response);
$view->setTemplate('/Modules/Purchase/Theme/Backend/order-suggestion-create');
$view->data['nav'] = $this->app->moduleManager->get('Navigation')->createNavigationMid(1002105001, $request, $response);
return $view;
}
/**
* Routing end-point for application behavior.
*
* @param RequestAbstract $request Request
* @param ResponseAbstract $response Response
* @param array $data Generic data
*
* @return RenderableInterface
*
* @since 1.0.0
* @codeCoverageIgnore
*/
public function viewPurchaseOrderSuggestionList(RequestAbstract $request, ResponseAbstract $response, array $data = []) : RenderableInterface
{
$view = new View($this->app->l11nManager, $request, $response);
$view->setTemplate('/Modules/Purchase/Theme/Backend/order-suggestion-list');
$view->data['nav'] = $this->app->moduleManager->get('Navigation')->createNavigationMid(1002105001, $request, $response);
$view->data['suggestions'] = $this->app->moduleManager->get('Purchase', 'Cli')
->calculateSuggestions($response->header->l11n->language);
return $view;
}

View File

@ -14,16 +14,22 @@ declare(strict_types=1);
namespace Modules\Purchase\Controller;
use Modules\Admin\Models\NullAccount;
use Modules\Billing\Models\Price\PriceType;
use Modules\Billing\Models\SalesBillMapper;
use Modules\ItemManagement\Models\Item;
use Modules\ItemManagement\Models\ItemMapper;
use Modules\ItemManagement\Models\ItemStatus;
use Modules\ItemManagement\Models\StockIdentifierType;
use Modules\Organization\Models\Attribute\UnitAttributeMapper;
use Modules\SupplierManagement\Models\NullSupplier;
use Modules\Purchase\Models\OrderSuggestion\OrderSuggestion;
use Modules\SupplierManagement\Models\SupplierMapper;
use phpOMS\Contract\RenderableInterface;
use phpOMS\Math\Functions\Functions;
use phpOMS\Message\Http\HttpRequest;
use phpOMS\Message\RequestAbstract;
use phpOMS\Message\ResponseAbstract;
use phpOMS\Stdlib\Base\FloatInt;
use phpOMS\Stdlib\Base\SmartDateTime;
use phpOMS\Views\View;
@ -35,6 +41,9 @@ use phpOMS\Views\View;
* @link https://jingga.app
* @since 1.0.0
* @codeCoverageIgnore
*
* @feature Create feature which re-calculates some of the item number (minimum_stock_range, lead_time, ...) based on history numbers
* https://github.com/Karaka-Management/oms-Purchase/issues/3
*/
final class CliController extends Controller
{
@ -66,6 +75,19 @@ final class CliController extends Controller
return $view;
}
$orderSuggestion = $this->createSuggestionFromRequest($request);
return $view;
}
public function createSuggestionFromRequest(RequestAbstract $request) : array
{
$showIrrelevant = $request->getDataBool('-irrelevant') ?? false;
$now = new \DateTime('now');
$suggestion = new OrderSuggestion();
$suggestion->createdBy = new NullAccount($request->getDataInt('-user') ?? 1);
// @todo define order details per item+stock
// @question Consider to adjust range algorithm from months to weeks
@ -74,20 +96,33 @@ final class CliController extends Controller
// This would allow users to work on it for a longer time
// It would also allow for an easier approval process
$items = ItemMapper::getAll()
->with('container')
->with('l11n')
->with('l11n/type')
->with('attributes')
->with('attributes/type')
->where('status', ItemStatus::ACTIVE)
->where('stockIdentifier', StockIdentifierType::NONE, '!=')
->where('attributes/type/name', [
'primary_supplier', 'lead_time', 'qc_time',
'lead_time', 'admin_time',
'maximum_stock_quantity', 'minimum_stock_quantity',
'minimum_order_quantity',
'minimum_stock_range', 'order_quantity_steps',
'order_suggestion_type', 'order_suggestion_optimization_type',
'order_suggestion_history_duration', 'order_suggestion_averaging_method',
'order_suggestion_comparison_duration_type',
'segment', 'section', 'sales_group', 'product_group', 'product_type',
], 'IN')
->execute();
// @todo Implement item dependencies (i.e. for production)
// @todo Consider production requests
// Exclude items only created through production (finished + semi-finished)
// Calculate raw material requirement based on
// Finished products sales
// Current productions = reserved
// Not based on sales of the raw material itself = 0 (unless it is also directly sold)
$itemIds = \array_map(function (Item $item) {
return $item->id;
}, $items);
@ -122,7 +157,7 @@ final class CliController extends Controller
// If item is new, the history start is shifted
$tempHistoryStart = ((int) $item->createdAt->format('Y')) >= ((int) $start->format('Y'))
&& ((int) $item->createdAt->format('m')) >= ((int) $start->format('m'))
? (int) $item->createdAt('m') // @todo Bad if created at end of month (+1 also not possible because of year change)
? (int) $item->createdAt->format('m') // @todo Bad if created at end of month (+1 also not possible because of year change)
: $historyStart;
$actualHistoricDuration = \min(
@ -157,7 +192,7 @@ final class CliController extends Controller
// get minimum range
// Either directly from attribute minimum_stock_range
// Or from minimum_stock_quantity
// calculate quantity needed incl. lead_time and qc_time for minimum range
// calculate quantity needed incl. lead_time and admin_time for minimum range
// make sure that the quantity is rounded to the next closest quantity step
// make sure that at least the minimum order quantity is fulfilled
// make sure that the maximum order quantity is not exceeded
@ -165,7 +200,7 @@ final class CliController extends Controller
// Calculate current range using historic sales + other current stats
$totalHistoricSales = \array_sum($salesForecast);
$avgMonthlySales = $totalHistoricSales / $actualHistoricDuration;
$avgMonthlySales = (int) \round($totalHistoricSales / $actualHistoricDuration);
$totalStockQuantity = 0;
foreach ($distributions['dists'][$item->id] ?? [] as $dist) {
@ -175,64 +210,106 @@ final class CliController extends Controller
$totalReservedQuantity = $distributions['reserved'][$item->id] ?? 0;
$totalOrderedQuantity = $distributions['ordered'][$item->id] ?? 0;
$currentRangeStock = ($totalStockQuantity + $totalOrderedQuantity) / $avgMonthlySales;
$currentRangeReserved = ($totalStockQuantity + $totalOrderedQuantity - $totalReservedQuantity) / $avgMonthlySales;
$currentRangeStock = $avgMonthlySales == 0 ? \PHP_INT_MAX : ($totalStockQuantity + $totalOrderedQuantity) / $avgMonthlySales;
$currentRangeReserved = $avgMonthlySales == 0 ? \PHP_INT_MAX : ($totalStockQuantity + $totalOrderedQuantity - $totalReservedQuantity) / $avgMonthlySales;
// @todo Sometimes the reserved range is misleading since the company may not be able to deliver that fast anyway
// -> the reserved quantity is always a constant (even if we have stock, we wouldn't ship)
// -> see SD HTS (depending on other shipments -> not delivered even if available)
// -> maybe it's possible to consider the expected delivery time?
// Get minimum range we want
$minimumStockRange = $item->getAttribute('minimum_stock_range')->value->getValue() ?? 0;
$wantedStockRange = $item->getAttribute('minimum_stock_range')->value->getValue() ?? 1;
$minimumStockQuantity = $item->getAttribute('minimum_stock_quantity')->value->getValue() ?? 0;
$minimumStockRange = \max($minimumStockQuantity / $avgMonthlySales, $minimumStockRange);
$minimumStockQuantity = (int) \round($minimumStockQuantity * 1000);
$minimumStockRange = $avgMonthlySales === 0 ? 0 : $minimumStockQuantity / $avgMonthlySales;
$minimumStockQuantity = (int) \round($minimumStockRange * $avgMonthlySales);
$minimumOrderQuantity = $item->getAttribute('minimum_order_quantity')->value->getValue() ?? 0;
$minimumOrderQuantity = (int) \round($minimumOrderQuantity * 10000);
$orderQuantityStep = $item->getAttribute('order_quantity_steps')->value->getValue() ?? 1;
$orderQuantityStep = (int) \round($orderQuantityStep * 10000);
$leadTime = $item->getAttribute('lead_time')->value->getValue() ?? 3; // in days
// @todo Business hours don't have to be 8 hours
$qcTime = ($item->getAttribute('qc_time')->value->getValue() ?? 0) / (8 * 60); // from minutes -> days
// we assume 10 seconds per item if nothing is defined for (invoice handling, stock handling)
$adminTime = ($item->getAttribute('admin_time')->value->getValue() ?? 10) / (8 * 60 * 60); // from minutes -> days
// Overhead time in days by estimating at least 1 week worth of order quantity
$estimatedOverheadTime = $leadTime + $qcTime * \max($minimumOrderQuantity, $avgMonthlySales / 4);
$estimatedOverheadTime = $leadTime + $adminTime * \max($minimumOrderQuantity, $avgMonthlySales / 4) / 10000;
$orderQuantity = 0;
$rangeDiff = $minimumStockRange - ($currentRangeReserved - $estimatedOverheadTime / 30);
if ($rangeDiff > 0) {
$orderQuantity = $rangeDiff * $avgMonthlySales;
$orderQuantity = \max($minimumOrderQuantity, $orderQuantity);
$orderRange = 0;
if ($orderQuantity !== $minimumOrderQuantity
&& ($orderQuantity - $minimumOrderQuantity) % $orderQuantityStep !== 0
) {
$orderQuantity = ($orderQuantity - $minimumOrderQuantity) % $orderQuantityStep;
if ($minimumStockRange - ($currentRangeReserved - $estimatedOverheadTime / 30) > 0) {
// Iteratively approaching overhead time
for ($i = 0; $i < 3; ++$i) {
$orderRange = $minimumStockRange + $wantedStockRange - ($currentRangeReserved - $estimatedOverheadTime / 30);
// @todo Instead of using $orderRange * $avgMonthlySales use the actual forecast sales from above.
// Of course based on the $orderRange = array sum for the orderRange
// Starting at what index? Now? or do we need to shift by reserved?
$orderQuantity = $orderRange * $avgMonthlySales;
$orderQuantity = \max($minimumOrderQuantity, $orderQuantity);
if ($orderQuantity !== $minimumOrderQuantity
&& \abs($mod = Functions::modFloat($orderQuantity - $minimumOrderQuantity, $orderQuantityStep)) >= 0.01
) {
// The if in the brackets rounds the orderQuantity up or down based on closest proximity
$orderQuantity = $orderQuantity - $mod + ($orderQuantityStep - $mod < $mod ? $orderQuantityStep : 0) + $minimumOrderQuantity;
}
$estimatedOverheadTime = $leadTime + $adminTime * $orderQuantity / 10000;
}
}
$orderRange = $orderQuantity / $avgMonthlySales;
$orderQuantity = (int) \round($orderQuantity);
$supplier = new NullSupplier($item->getAttribute('primary_supplier')->value->getValue());
$isNew = $now->getTimestamp() - $item->createdAt->getTimestamp() < 60 * 60 * 24 * 60;
if (!$showIrrelevant
&& $orderQuantity === 0
&& !$isNew
&& ($currentRangeReserved > 1.0 || $avgMonthlySales === 0)
&& $minimumOrderQuantity * 1.2 <= $currentRangeReserved * $avgMonthlySales
) {
continue;
}
$internalRequest = new HttpRequest();
$internalRequest->setData('price_item', $item->id);
$internalRequest->setData('price_quantity', $orderQuantity);
$internalRequest->setData('price_type', PriceType::PURCHASE);
$price = $this->app->moduleManager->get('Billing', 'ApiPrice')->findBestPrice(
$internalRequest,
null,
null,
$supplier
);
$price = $this->app->moduleManager->get('Billing', 'ApiPrice')->findBestPrice($internalRequest, $item);
$supplier = SupplierMapper::get()
->with('account')
->where('id', (int) $price['supplier'])
->execute();
// @question Consider to add gross price
$suggestions[$item->id] = [
'supplier' => $supplier->id,
'quantity' => $orderQuantity,
'item' => $item,
'supplier' => $supplier,
'quantity' => new FloatInt($orderQuantity),
'singlePrice' => $price['bestActualPrice'],
'totalPrice' => (int) ($price['bestActualPrice'] * $orderQuantity / 10000),
'totalPrice' => new FloatInt((int) ($price['bestActualPrice']->value * $orderQuantity / 10000)),
'stock' => new FloatInt($totalStockQuantity),
'reserved' => new FloatInt($totalReservedQuantity),
'ordered' => new FloatInt($totalOrderedQuantity),
'minquantity' => new FloatInt($minimumOrderQuantity),
'minstock' => new FloatInt($minimumStockQuantity),
'quantitystep' => new FloatInt($orderQuantityStep),
'avgsales' => new FloatInt($avgMonthlySales),
'range_stock' => $currentRangeStock, // range only considering stock + ordered
'range_reserved' => $currentRangeReserved, // range considering stock - reserved + ordered
'range_ordered' => $orderRange, // range ADDED with suggested new order quantity
];
}
return $view;
return $suggestions;
}
}

View File

@ -1,27 +0,0 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package Modules\Purchase\Models
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace Modules\Purchase\Models;
/**
* Article class.
*
* @package Modules\Purchase\Models
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
class Article
{
}

View File

@ -1,27 +0,0 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package Modules\Purchase\Models
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace Modules\Purchase\Models;
/**
* Invoice class.
*
* @package Modules\Purchase\Models
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
class Invoice
{
}

View File

@ -62,7 +62,7 @@ final class DefaultOrderSuggestion implements OrderSuggestionInterface
* @var int
* @since 1.0.0
*/
public int $optimizationType = OrderSuggestionOptimizationType::PRICE;
public int $optimizationType = OrderSuggestionOptimizationType::AVAILABILITY;
/**
* How greedy should the algorithm be?
@ -102,19 +102,4 @@ final class DefaultOrderSuggestion implements OrderSuggestionInterface
* @since 1.0.0
*/
public int $maxAvailableBudget = 0;
/**
* item data for algorithm:
* number
* name
* purchase price per volume
* minimum quantity per volume
* delivery time / lead time
* in-house processing time (e.g. qs time, labelling, packaging)
* ordered (order confirmations)
* stock available
* offers
* avg. profit
* demand in period N
*/
}

View File

@ -14,6 +14,9 @@ declare(strict_types=1);
namespace Modules\Purchase\Models\OrderSuggestion;
use Modules\Admin\Models\Account;
use Modules\Admin\Models\NullAccount;
/**
* OrderSuggestion class.
*
@ -24,4 +27,19 @@ namespace Modules\Purchase\Models\OrderSuggestion;
*/
class OrderSuggestion
{
public int $id = 0;
public int $status = OrderSuggestionStatus::DRAFT;
public Account $createdBy;
public \DateTimeImmutable $createdAt;
public array $elements = [];
public function __construct()
{
$this->createdBy = new NullAccount();
$this->createdAt = new \DateTimeImmutable('now');
}
}

View File

@ -0,0 +1,65 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package Modules\Purchase\Models\OrderSuggestion
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace Modules\Purchase\Models\OrderSuggestion;
use Modules\Admin\Models\Account;
use Modules\Admin\Models\NullAccount;
use Modules\Billing\Models\Bill;
use Modules\ItemManagement\Models\Item;
use Modules\ItemManagement\Models\NullItem;
use Modules\SupplierManagement\Models\NullSupplier;
use Modules\SupplierManagement\Models\Supplier;
use phpOMS\Stdlib\Base\FloatInt;
/**
* OrderSuggestion class.
*
* @package Modules\Purchase\Models\OrderSuggestion
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
class OrderSuggestionElement
{
public int $id = 0;
public int $status = OrderSuggestionElementStatus::CALCULATED;
public Account $modifiedBy;
public \DateTimeImmutable $modifiedAt;
public int $suggestion = 0;
public Item $item;
public ?Bill $bill = null;
public Supplier $supplier;
public FloatInt $quantity;
public FloatInt $costs;
public function __construct()
{
$this->modifiedBy = new NullAccount();
$this->modifiedAt = new \DateTimeImmutable('now');
$this->item = new NullItem();
$this->supplier = new NullSupplier();
$this->quantity = new FloatInt();
$this->costs = new FloatInt();
}
}

View File

@ -0,0 +1,103 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package Modules\Purchase\Models
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace Modules\Purchase\Models;
use Modules\Admin\Models\AccountMapper;
use Modules\Billing\Models\BillMapper;
use Modules\ItemManagement\Models\ItemMapper;
use Modules\SupplierManagement\Models\SupplierMapper;
use phpOMS\DataStorage\Database\Mapper\DataMapperFactory;
/**
* Client mapper class.
*
* @package Modules\Purchase\Models
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*
* @template T of Client
* @extends DataMapperFactory<T>
*/
final class OrderSuggestionElementMapper extends DataMapperFactory
{
/**
* Columns.
*
* @var array<string, array{name:string, type:string, internal:string, autocomplete?:bool, readonly?:bool, writeonly?:bool, annotations?:array}>
* @since 1.0.0
*/
public const COLUMNS = [
'purchase_order_suggestion_element_id' => ['name' => 'purchase_order_suggestion_element_id', 'type' => 'int', 'internal' => 'id'],
'purchase_order_suggestion_element_status' => ['name' => 'purchase_order_suggestion_element_status', 'type' => 'int', 'internal' => 'status'],
'purchase_order_suggestion_element_modified_by' => ['name' => 'purchase_order_suggestion_element_modified_by', 'type' => 'int', 'internal' => 'modifiedBy'],
'purchase_order_suggestion_element_modified_at' => ['name' => 'purchase_order_suggestion_element_modified_at', 'type' => 'DateTimeImmutable', 'internal' => 'modifiedAt'],
'purchase_order_suggestion_element_suggestion' => ['name' => 'purchase_order_suggestion_element_suggestion', 'type' => 'int', 'internal' => 'suggestion'],
'purchase_order_suggestion_element_item' => ['name' => 'purchase_order_suggestion_element_item', 'type' => 'int', 'internal' => 'item'],
'purchase_order_suggestion_element_bill' => ['name' => 'purchase_order_suggestion_element_bill', 'type' => 'int', 'internal' => 'bill'],
'purchase_order_suggestion_element_supplier' => ['name' => 'purchase_order_suggestion_element_supplier', 'type' => 'int', 'internal' => 'supplier'],
'purchase_order_suggestion_element_quantity' => ['name' => 'purchase_order_suggestion_element_quantity', 'type' => 'int', 'internal' => 'quantity'],
'purchase_order_suggestion_element_costs' => ['name' => 'purchase_order_suggestion_element_costs', 'type' => 'int', 'internal' => 'costs'],
];
/**
* Primary table.
*
* @var string
* @since 1.0.0
*/
public const TABLE = 'purchase_order_suggestion_element';
/**
* Primary field name.
*
* @var string
* @since 1.0.0
*/
public const PRIMARYFIELD = 'purchase_order_suggestion_element_id';
/**
* Created at column
*
* @var string
* @since 1.0.0
*/
public const CREATED_AT = 'purchase_order_suggestion_element_updated_at';
/**
* Has one relation.
*
* @var array<string, array{mapper:class-string, external:string, by?:string, column?:string, conditional?:bool}>
* @since 1.0.0
*/
public const OWNS_ONE = [
'modifiedBy' => [
'mapper' => AccountMapper::class,
'external' => 'purchase_order_suggestion_element_modified_by',
],
'supplier' => [
'mapper' => SupplierMapper::class,
'external' => 'purchase_order_suggestion_element_supplier',
],
'item' => [
'mapper' => ItemMapper::class,
'external' => 'purchase_order_suggestion_element_item',
],
'bill' => [
'mapper' => BillMapper::class,
'external' => 'purchase_order_suggestion_element_bill',
],
];
}

View File

@ -0,0 +1,32 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package Modules\Purchase\Models\OrderSuggestion
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace Modules\Purchase\Models\OrderSuggestion;
use phpOMS\Stdlib\Base\Enum;
/**
* Suggestion type enum.
*
* @package Modules\Purchase\Models\OrderSuggestion
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
abstract class OrderSuggestionElementStatus extends Enum
{
public const MODIFIED = 1;
public const CALCULATED = 2;
}

View File

@ -0,0 +1,104 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package Modules\Purchase\Models
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace Modules\Purchase\Models;
use Modules\Admin\Models\AccountMapper;
use Modules\Admin\Models\AddressMapper;
use Modules\Purchase\Models\Attribute\ClientAttributeMapper;
use Modules\Editor\Models\EditorDocMapper;
use Modules\ItemManagement\Models\ItemMapper;
use Modules\Media\Models\MediaMapper;
use Modules\Payment\Models\PaymentMapper;
use Modules\SupplierManagement\Models\SupplierMapper;
use phpOMS\DataStorage\Database\Mapper\DataMapperFactory;
/**
* Client mapper class.
*
* @package Modules\Purchase\Models
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*
* @template T of Client
* @extends DataMapperFactory<T>
*/
final class OrderSuggestionMapper extends DataMapperFactory
{
/**
* Columns.
*
* @var array<string, array{name:string, type:string, internal:string, autocomplete?:bool, readonly?:bool, writeonly?:bool, annotations?:array}>
* @since 1.0.0
*/
public const COLUMNS = [
'purchase_order_suggestion_id' => ['name' => 'purchase_order_suggestion_id', 'type' => 'int', 'internal' => 'id'],
'purchase_order_suggestion_status' => ['name' => 'purchase_order_suggestion_status', 'type' => 'int', 'internal' => 'status'],
'purchase_order_suggestion_created_by' => ['name' => 'purchase_order_suggestion_created_by', 'type' => 'int', 'internal' => 'createdBy'],
'purchase_order_suggestion_created_at' => ['name' => 'purchase_order_suggestion_created_at', 'type' => 'DateTimeImmutable', 'internal' => 'modifiedAt'],
];
/**
* Primary table.
*
* @var string
* @since 1.0.0
*/
public const TABLE = 'purchase_order_suggestion';
/**
* Primary field name.
*
* @var string
* @since 1.0.0
*/
public const PRIMARYFIELD = 'purchase_order_suggestion_id';
/**
* Created at column
*
* @var string
* @since 1.0.0
*/
public const CREATED_AT = 'purchase_order_suggestion_created_at';
/**
* Has one relation.
*
* @var array<string, array{mapper:class-string, external:string, by?:string, column?:string, conditional?:bool}>
* @since 1.0.0
*/
public const OWNS_ONE = [
'createdBy' => [
'mapper' => AccountMapper::class,
'external' => 'purchase_order_suggestion_created_by',
],
];
/**
* Has many relation.
*
* @var array<string, array{mapper:class-string, table:string, self?:?string, external?:?string, column?:string}>
* @since 1.0.0
*/
public const HAS_MANY = [
'elements' => [
'mapper' => OrderSuggestionElementMapper::class, /* mapper of the related object */
'table' => 'purchase_order_suggestion_element', /* table of the related object, null if no relation table is used (many->1) */
'external' => null,
'self' => 'purchase_order_suggestion_element_suggestion',
],
];
}

View File

@ -26,7 +26,7 @@ use phpOMS\Stdlib\Base\Enum;
*/
abstract class OrderSuggestionOptimizationType extends Enum
{
public const PRICE = 1; // Suggestion focuses on creating better prices if volume discounts exist.
public const COST = 1; // Suggestion focuses on creating better prices if volume discounts exist.
public const JUST_IN_TIME = 2; // Suggestion focuses on calculating minimum stock quantities.

View File

@ -0,0 +1,34 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package Modules\Purchase\Models\OrderSuggestion
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace Modules\Purchase\Models\OrderSuggestion;
use phpOMS\Stdlib\Base\Enum;
/**
* Suggestion type enum.
*
* @package Modules\Purchase\Models\OrderSuggestion
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
abstract class OrderSuggestionStatus extends Enum
{
public const DRAFT = 1;
public const DELETED = 2;
public const ORDERED = 3;
}

View File

@ -29,4 +29,6 @@ abstract class PermissionCategory extends Enum
public const INVOICE = 1;
public const ARTICLE = 2;
public const ORDER = 3;
}

View File

@ -1,18 +0,0 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package Modules\Purchase
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
/**
* @var \phpOMS\Views\View $this
*/
echo $this->data['nav']->render();

View File

@ -16,3 +16,4 @@ declare(strict_types=1);
* @var \phpOMS\Views\View $this
*/
echo $this->data['nav']->render();
?>

View File

@ -0,0 +1,200 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package Modules\Purchase
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
use phpOMS\Stdlib\Base\FloatInt;
use phpOMS\Stdlib\Base\SmartDateTime;
/**
* @var \phpOMS\Views\View $this
*/
echo $this->data['nav']->render();
?>
<div class="row">
<div class="col-xs-12 col-sm-6 col-md-4 col-lg-3">
<div class="portlet">
<div class="portlet-body">
<div class="form-group">
<label>Supplier</label>
<input type="text">
</div>
<div class="form-group">
<label>Product Group</label>
<input type="text">
</div>
</div>
</div>
</div>
<div class="col-xs-12 col-sm-6 col-md-4 col-lg-3">
<div class="portlet">
<div class="portlet-body">
<div class="form-group">
<label>Algorithm</label>
<select>
<option>Availability Optimization
<option>Cost Optimization
</select>
</div>
<div class="form-group">
<label>Min. range</label>
<input type="text">
</div>
</div>
</div>
</div>
<div class="col-xs-12 col-sm-6 col-md-4 col-lg-3">
<div class="portlet">
<div class="portlet-body">
<div class="form-group">
<label class="checkbox" for="iIrrelevantItems">
<input id="iIrrelevantItems" name="hide_irrelevant" type="checkbox" value="1" checked>
<span class="checkmark"></span>
Hide irrelevant
</label>
</div>
</div>
<div class="portlet-foot">
<!-- @todo Adjust button visibility -->
<!-- Save if not created ?> -->
<!-- Order if saved ?> -->
<!-- None if already order created -->
<input type="submit" value="Save">
<input type="submit" value="Order">
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="portlet">
<div class="portlet-head">Suggestions<i class="g-icon download btn end-xs">download</i></div>
<div class="slider">
<table id="billList" class="default sticky">
<thead>
<tr>
<td>Item
<td class="wf-100">
<td>Supplier
<td>Stock
<td>Reserved
<td>Ordered
<td>Ø Sales
<td>Range 1
<td>Range 2
<td>Min. stock
<td>Min. order
<td>Steps
<td>Ordering
<td>Adding
<td>New range
<td>Price
<td>Costs
<tbody>
<?php
$now = new SmartDateTime('now');
$total = new FloatInt();
$subtotal = new FloatInt();
$lastSupplier = 0;
$supplier = $this->request->getDataString('supplier');
$hasSupplierSwitch = false;
$isFirst = true;
foreach ($this->data['suggestions'] as $item => $suggestion) :
$isNew = $now->getTimestamp() - $suggestion['item']->createdAt->getTimestamp() < 60 * 60 * 24 * 60;
// Skip irrelevant items
// No purchase suggestion
// Not new (new = item created in the last 60 days)
// At least 1 month in stock
// At least 20% above min. stock
if ($suggestion['quantity']->value === 0
&& !$isNew
&& ($suggestion['range_reserved'] > 1.0 || $suggestion['avgsales']->value === 0)
&& $suggestion['minquantity']->value * 1.2 <= $suggestion['range_reserved'] * $suggestion['avgsales']->value
) {
continue;
}
$total->add($suggestion['totalPrice']);
$subtotal->add($suggestion['totalPrice']);
$container = \reset($suggestion['item']->container);
$class = '';
if ($suggestion['quantity']->value !== 0) {
$class = ' class="highlight-2"';
}
?>
<?php
if (empty($supplier) && $lastSupplier !== $suggestion['supplier']->id && !$isFirst) :
$hasSupplierSwitch = true;
$lastSupplier = $suggestion['supplier']->id;
?>
<tr class="highlight-7">
<td colspan="16"><?= $this->printHtml($suggestion['supplier']->account->name1); ?> <?= $this->printHtml($suggestion['supplier']->account->name2); ?>
<td><?= $total->getAmount(); ?>
<?php
$subtotal = new FloatInt();
endif;
$isFirst = false;
?>
<tr>
<td><?= $this->printHtml($suggestion['item']->number); ?>
<td><?= $this->printHtml($suggestion['item']->getL11n('name1')->content); ?> <?= $this->printHtml($suggestion['item']->getL11n('name1')->content); ?>
<td><?= $this->printHtml($suggestion['supplier']->number); ?>
<td><?= $suggestion['stock']->getAmount($container->quantityDecimals); ?>
<td><?= $suggestion['reserved']->getAmount($container->quantityDecimals); ?>
<td><?= $suggestion['ordered']->getAmount($container->quantityDecimals); ?>
<td><?= $suggestion['avgsales']->getAmount(1); ?>
<td><?= $suggestion['range_stock'] === \PHP_INT_MAX ? '' : \number_format($suggestion['range_stock'], 1); ?>
<td><?= $suggestion['range_reserved'] === \PHP_INT_MAX ? '' : \number_format($suggestion['range_reserved'], 1); ?>
<td><?= $suggestion['minstock']->getAmount($container->quantityDecimals); ?>
<td><?= $suggestion['minquantity']->getAmount($container->quantityDecimals); ?>
<td><?= $suggestion['quantitystep']->getAmount($container->quantityDecimals); ?>
<td<?= $class; ?>><input step="<?= $suggestion['quantitystep']->getAmount($container->quantityDecimals); ?>" type="number" value="<?= $suggestion['quantity']->getFloat($container->quantityDecimals); ?>">
<td><?= \number_format($suggestion['range_ordered'], 1); ?>
<td><?= $suggestion['range_reserved'] === \PHP_INT_MAX ? '' : $now->createModify(0, (int) \ceil($suggestion['range_ordered'] + $suggestion['range_reserved']))->format('Y-m-d') ?>
<td><?= $suggestion['singlePrice']->getAmount(); ?>
<td><?= $suggestion['totalPrice']->getAmount(); ?>
<?php endforeach; ?>
<?php if ($hasSupplierSwitch) : ?>
<tr class="highlight-7">
<td colspan="16"><?= $this->printHtml($suggestion['supplier']->account->name1); ?> <?= $this->printHtml($suggestion['supplier']->account->name2); ?>
<td><?= $subtotal->getAmount(); ?>
<?php endif; ?>
<tfoot>
<tr class="highlight-3">
<td colspan="16">Total
<td><?= $total->getAmount(); ?>
</table>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12 col-sm-4 col-md-3 col-lg-2">
<div class="portlet highlight-2">
<div class="portlet-body">Ordering</div>
</div>
</div>
</div>

View File

@ -19,7 +19,8 @@
"dependencies": {
"Admin": "1.0.0",
"ItemManagement": "1.0.0",
"SupplierManagement": "1.0.0"
"SupplierManagement": "1.0.0",
"Billing": "1.0.0"
},
"providing": {
"Navigation": "*"