From 544758631d1d9dcf0293fd3c11926722cd7e9f91 Mon Sep 17 00:00:00 2001 From: Dennis Eichhorn Date: Wed, 7 Feb 2024 15:48:47 +0000 Subject: [PATCH] testing order suggestion --- Admin/Install/Navigation.install.json | 35 ++- Admin/Install/db.json | 119 +++++++---- Admin/Routes/Cli.php | 2 +- Admin/Routes/Web/Backend.php | 26 ++- Controller/ApiController.php | 76 +++++++ Controller/BackendController.php | 65 +++++- Controller/CliController.php | 141 +++++++++--- Models/Article.php | 27 --- Models/Invoice.php | 27 --- .../DefaultOrderSuggestion.php | 17 +- Models/OrderSuggestion/OrderSuggestion.php | 18 ++ .../OrderSuggestionElement.php | 65 ++++++ .../OrderSuggestionElementMapper.php | 103 +++++++++ .../OrderSuggestionElementStatus.php | 32 +++ .../OrderSuggestion/OrderSuggestionMapper.php | 104 +++++++++ .../OrderSuggestionOptimizationType.php | 2 +- .../OrderSuggestion/OrderSuggestionStatus.php | 34 +++ Models/PermissionCategory.php | 2 + .../Backend/article-order-suggestion.tpl.php | 18 -- ...ist.tpl.php => order-pending-list.tpl.php} | 1 + Theme/Backend/order-suggestion.tpl.php | 200 ++++++++++++++++++ info.json | 3 +- 22 files changed, 943 insertions(+), 174 deletions(-) create mode 100644 Controller/ApiController.php delete mode 100755 Models/Article.php delete mode 100755 Models/Invoice.php create mode 100644 Models/OrderSuggestion/OrderSuggestionElement.php create mode 100644 Models/OrderSuggestion/OrderSuggestionElementMapper.php create mode 100644 Models/OrderSuggestion/OrderSuggestionElementStatus.php create mode 100644 Models/OrderSuggestion/OrderSuggestionMapper.php create mode 100644 Models/OrderSuggestion/OrderSuggestionStatus.php delete mode 100755 Theme/Backend/article-order-suggestion.tpl.php rename Theme/Backend/{article-order-pending-list.tpl.php => order-pending-list.tpl.php} (98%) mode change 100755 => 100644 create mode 100644 Theme/Backend/order-suggestion.tpl.php diff --git a/Admin/Install/Navigation.install.json b/Admin/Install/Navigation.install.json index 358bcca..e6273e7 100755 --- a/Admin/Install/Navigation.install.json +++ b/Admin/Install/Navigation.install.json @@ -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, diff --git a/Admin/Install/db.json b/Admin/Install/db.json index 2351e05..619b35f 100755 --- a/Admin/Install/db.json +++ b/Admin/Install/db.json @@ -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 } } diff --git a/Admin/Routes/Cli.php b/Admin/Routes/Cli.php index f635b38..0f9cb60 100644 --- a/Admin/Routes/Cli.php +++ b/Admin/Routes/Cli.php @@ -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, diff --git a/Admin/Routes/Web/Backend.php b/Admin/Routes/Web/Backend.php index 992d6ce..19fd01c 100755 --- a/Admin/Routes/Web/Backend.php +++ b/Admin/Routes/Web/Backend.php @@ -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, ], ], ], diff --git a/Controller/ApiController.php b/Controller/ApiController.php new file mode 100644 index 0000000..f63d781 --- /dev/null +++ b/Controller/ApiController.php @@ -0,0 +1,76 @@ +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()); + } + } +} diff --git a/Controller/BackendController.php b/Controller/BackendController.php index 4ca4ca1..95189bf 100755 --- a/Controller/BackendController.php +++ b/Controller/BackendController.php @@ -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; } diff --git a/Controller/CliController.php b/Controller/CliController.php index 26844fe..d3f18f8 100644 --- a/Controller/CliController.php +++ b/Controller/CliController.php @@ -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; } } diff --git a/Models/Article.php b/Models/Article.php deleted file mode 100755 index 5b80dd5..0000000 --- a/Models/Article.php +++ /dev/null @@ -1,27 +0,0 @@ -createdBy = new NullAccount(); + $this->createdAt = new \DateTimeImmutable('now'); + } } diff --git a/Models/OrderSuggestion/OrderSuggestionElement.php b/Models/OrderSuggestion/OrderSuggestionElement.php new file mode 100644 index 0000000..87f8d6b --- /dev/null +++ b/Models/OrderSuggestion/OrderSuggestionElement.php @@ -0,0 +1,65 @@ +modifiedBy = new NullAccount(); + $this->modifiedAt = new \DateTimeImmutable('now'); + $this->item = new NullItem(); + $this->supplier = new NullSupplier(); + $this->quantity = new FloatInt(); + $this->costs = new FloatInt(); + } +} diff --git a/Models/OrderSuggestion/OrderSuggestionElementMapper.php b/Models/OrderSuggestion/OrderSuggestionElementMapper.php new file mode 100644 index 0000000..f5f1be9 --- /dev/null +++ b/Models/OrderSuggestion/OrderSuggestionElementMapper.php @@ -0,0 +1,103 @@ + + */ +final class OrderSuggestionElementMapper extends DataMapperFactory +{ + /** + * Columns. + * + * @var 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 + * @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', + ], + ]; +} diff --git a/Models/OrderSuggestion/OrderSuggestionElementStatus.php b/Models/OrderSuggestion/OrderSuggestionElementStatus.php new file mode 100644 index 0000000..5cfa525 --- /dev/null +++ b/Models/OrderSuggestion/OrderSuggestionElementStatus.php @@ -0,0 +1,32 @@ + + */ +final class OrderSuggestionMapper extends DataMapperFactory +{ + /** + * Columns. + * + * @var 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 + * @since 1.0.0 + */ + public const OWNS_ONE = [ + 'createdBy' => [ + 'mapper' => AccountMapper::class, + 'external' => 'purchase_order_suggestion_created_by', + ], + ]; + + /** + * Has many relation. + * + * @var array + * @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', + ], + ]; +} diff --git a/Models/OrderSuggestion/OrderSuggestionOptimizationType.php b/Models/OrderSuggestion/OrderSuggestionOptimizationType.php index 0a70c5e..50c823a 100644 --- a/Models/OrderSuggestion/OrderSuggestionOptimizationType.php +++ b/Models/OrderSuggestion/OrderSuggestionOptimizationType.php @@ -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. diff --git a/Models/OrderSuggestion/OrderSuggestionStatus.php b/Models/OrderSuggestion/OrderSuggestionStatus.php new file mode 100644 index 0000000..c331921 --- /dev/null +++ b/Models/OrderSuggestion/OrderSuggestionStatus.php @@ -0,0 +1,34 @@ +data['nav']->render(); diff --git a/Theme/Backend/article-order-pending-list.tpl.php b/Theme/Backend/order-pending-list.tpl.php old mode 100755 new mode 100644 similarity index 98% rename from Theme/Backend/article-order-pending-list.tpl.php rename to Theme/Backend/order-pending-list.tpl.php index e1dd202..bb3c150 --- a/Theme/Backend/article-order-pending-list.tpl.php +++ b/Theme/Backend/order-pending-list.tpl.php @@ -16,3 +16,4 @@ declare(strict_types=1); * @var \phpOMS\Views\View $this */ echo $this->data['nav']->render(); +?> diff --git a/Theme/Backend/order-suggestion.tpl.php b/Theme/Backend/order-suggestion.tpl.php new file mode 100644 index 0000000..eae3ccd --- /dev/null +++ b/Theme/Backend/order-suggestion.tpl.php @@ -0,0 +1,200 @@ +data['nav']->render(); +?> +
+
+
+
+
+ + +
+ +
+ + +
+
+
+
+ + + +
+
+
+
+ + +
+ +
+ + +
+
+
+
+ +
+
+
+
+ +
+
+
+ + + + + + +
+
+
+
+ +
+
+
+
Suggestionsdownload
+
+ + + + + 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"'; + } + ?> + id && !$isFirst) : + $hasSupplierSwitch = true; + $lastSupplier = $suggestion['supplier']->id; + ?> + + + + + +
Item + + Supplier + Stock + Reserved + Ordered + Ø Sales + Range 1 + Range 2 + Min. stock + Min. order + Steps + Ordering + Adding + New range + Price + Costs +
printHtml($suggestion['supplier']->account->name1); ?> printHtml($suggestion['supplier']->account->name2); ?> + getAmount(); ?> + +
printHtml($suggestion['item']->number); ?> + printHtml($suggestion['item']->getL11n('name1')->content); ?> printHtml($suggestion['item']->getL11n('name1')->content); ?> + printHtml($suggestion['supplier']->number); ?> + getAmount($container->quantityDecimals); ?> + getAmount($container->quantityDecimals); ?> + getAmount($container->quantityDecimals); ?> + getAmount(1); ?> + + + getAmount($container->quantityDecimals); ?> + getAmount($container->quantityDecimals); ?> + getAmount($container->quantityDecimals); ?> + > + + createModify(0, (int) \ceil($suggestion['range_ordered'] + $suggestion['range_reserved']))->format('Y-m-d') ?> + getAmount(); ?> + getAmount(); ?> + + +
printHtml($suggestion['supplier']->account->name1); ?> printHtml($suggestion['supplier']->account->name2); ?> + getAmount(); ?> + +
Total + getAmount(); ?> +
+
+
+
+
+ +
+
+
+
Ordering
+
+
+
\ No newline at end of file diff --git a/info.json b/info.json index 58ba328..fd8a481 100755 --- a/info.json +++ b/info.json @@ -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": "*"