mirror of
https://github.com/Karaka-Management/oms-Purchase.git
synced 2026-01-10 15:18:41 +00:00
testing order suggestion
This commit is contained in:
parent
2cdbf9dac6
commit
544758631d
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
|
|
|||
76
Controller/ApiController.php
Normal file
76
Controller/ApiController.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
}
|
||||
|
|
@ -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
|
||||
*/
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
Models/OrderSuggestion/OrderSuggestionElement.php
Normal file
65
Models/OrderSuggestion/OrderSuggestionElement.php
Normal 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();
|
||||
}
|
||||
}
|
||||
103
Models/OrderSuggestion/OrderSuggestionElementMapper.php
Normal file
103
Models/OrderSuggestion/OrderSuggestionElementMapper.php
Normal 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
32
Models/OrderSuggestion/OrderSuggestionElementStatus.php
Normal file
32
Models/OrderSuggestion/OrderSuggestionElementStatus.php
Normal 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;
|
||||
}
|
||||
104
Models/OrderSuggestion/OrderSuggestionMapper.php
Normal file
104
Models/OrderSuggestion/OrderSuggestionMapper.php
Normal 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
34
Models/OrderSuggestion/OrderSuggestionStatus.php
Normal file
34
Models/OrderSuggestion/OrderSuggestionStatus.php
Normal 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;
|
||||
}
|
||||
|
|
@ -29,4 +29,6 @@ abstract class PermissionCategory extends Enum
|
|||
public const INVOICE = 1;
|
||||
|
||||
public const ARTICLE = 2;
|
||||
|
||||
public const ORDER = 3;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
1
Theme/Backend/article-order-pending-list.tpl.php → Theme/Backend/order-pending-list.tpl.php
Executable file → Normal file
1
Theme/Backend/article-order-pending-list.tpl.php → Theme/Backend/order-pending-list.tpl.php
Executable file → Normal file
|
|
@ -16,3 +16,4 @@ declare(strict_types=1);
|
|||
* @var \phpOMS\Views\View $this
|
||||
*/
|
||||
echo $this->data['nav']->render();
|
||||
?>
|
||||
200
Theme/Backend/order-suggestion.tpl.php
Normal file
200
Theme/Backend/order-suggestion.tpl.php
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user