From ec85a41a5481422bbf619674314ee8fa52e4263c Mon Sep 17 00:00:00 2001 From: fomichev Date: Mon, 30 Jun 2025 18:03:30 +0300 Subject: [PATCH] =?utf8?q?=D0=9D=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA?= =?utf8?q?=D0=B0=20=D0=B2=20=D0=BA=D1=80=D0=BE=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- erp24/commands/CronController.php | 199 +++++++++++++++++++ erp24/controllers/CategoryPlanController.php | 60 +++++- erp24/jobs/RebuildAutoplannogramJob.php | 2 +- erp24/services/AutoPlannogrammaService.php | 8 +- erp24/views/category-plan/new.php | 14 ++ erp24/web/js/category-plan/index.js | 105 ++++++++++ 6 files changed, 384 insertions(+), 4 deletions(-) diff --git a/erp24/commands/CronController.php b/erp24/commands/CronController.php index f8a88280..dcf56880 100644 --- a/erp24/commands/CronController.php +++ b/erp24/commands/CronController.php @@ -18,9 +18,14 @@ use yii\helpers\BaseConsole; use yii\helpers\Json; use yii_app\helpers\ClientHelper; use yii_app\helpers\DataHelper; +use yii_app\helpers\HtmlHelper; +use yii_app\records\Admin; +use yii_app\records\AdminGroup; +use yii_app\records\AdminPayroll; use yii_app\records\Autoplannogramma; use yii_app\records\BonusLevels; use yii_app\records\CityStore; +use yii_app\records\EmployeePosition; use yii_app\records\EqualizationRemains; use yii_app\records\KogortStopList; use yii_app\records\LPTrackerApi; @@ -31,6 +36,7 @@ use yii_app\records\ReplacementInvoice; use yii_app\records\ReplacementInvoiceProducts; use yii_app\records\MarketplaceOrder1cStatuses; use yii_app\records\Sales; +use yii_app\records\ScriptLauncherLog; use yii_app\records\SentKogort; use yii_app\records\Users; use yii_app\records\UsersBonus; @@ -39,6 +45,10 @@ use yii_app\records\UsersMessageManagement; use yii_app\records\UsersTelegram; use yii_app\records\UsersTelegramLog; use yii_app\services\AutoPlannogrammaService; +use yii_app\services\CabinetService; +use yii_app\services\ExportImportService; +use yii_app\services\LogService; +use yii_app\services\RatingService; use yii_app\services\StorePlanService; use yii_app\services\TelegramService; use yii_app\services\WhatsAppService; @@ -51,6 +61,11 @@ class CronController extends Controller public $time; public $stepsBack; public $test; + public $year; + public $month; + public $storeId; + + public function actions() { @@ -1193,6 +1208,9 @@ class CronController extends Controller $options[] = 'time'; $options[] = 'stepsBack'; $options[] = 'test'; + $options[] = 'year'; + $options[] = 'month'; + $options[] = 'storeId'; return $options; } @@ -1720,4 +1738,185 @@ class CronController extends Controller $this->stdout("Расчет и сохранение автопланограммы завершены\n", BaseConsole::FG_GREEN); } + + + public function actionAutoplannogrammaRecalculate(): int + { + $cacheKey = 'apRecalculateTask'; + $cache = Yii::$app->cache; + $task = $cache->get($cacheKey); + + + if (!is_array($task) || empty($task['taskName']) || $task['taskName'] !== 'apRecalculate') { + $this->stdout("No pending apRecalculateTask\n"); + return ExitCode::OK; + } + if (!isset($task['status'])) { + $task['status'] = 'pending'; + } + if ($task['status'] === 'running') { + $this->stdout("Task already running since {$task['startTime']}\n"); + return ExitCode::OK; + } + + + foreach (['year','month','storeId','startTime'] as $key) { + if (empty($task[$key])) { + $this->stdout("Invalid task payload: missing {$key}\n"); + $task['status'] = 'error'; + $task['error'] = "Missing {$key}"; + $cache->set($cacheKey, $task); + return ExitCode::UNSPECIFIED_ERROR; + } + } + + if (abs(time() - $task['startTime']) > 86400) { + $this->stdout("Task startTime is out of allowed window\n"); + $task['status'] = 'error'; + $task['error'] = 'Invalid startTime'; + $cache->set($cacheKey, $task); + return ExitCode::UNSPECIFIED_ERROR; + } + + $task['status'] = 'running'; + $cache->set($cacheKey, $task); + + // 4) Логируем старт + $log = new ScriptLauncherLog(); + $log->source = 'CronController'; + $log->category = 'autoplannogramma'; + $log->prefix = 'actionAutoplannogrammaRecalculate'; + $log->name = 'taskApRecalculate'; + $log->context = json_encode($task, JSON_UNESCAPED_UNICODE); + $log->year = (int)$task['year']; + $log->month = (int)$task['month']; + $log->active = 1; + $log->date_start = date('Y-m-d H:i:s'); + $log->save(false); + + try { + + $year = (int)$task['year']; + $month = (int)$task['month']; + $storeId = (int)$task['storeId']; + + $date = (new DateTime())->setDate($year, $month, 1); + $planDate = $date->format('Y-m-01'); + + $service = new AutoPlannogrammaService(); + + + $forecast = $service->calculateFullForecastForWeek([ + 'year' => $year, + 'month' => $month, + 'type' => AutoPlannogrammaService::TYPE_SALES, + 'store_id' => $storeId, + 'plan_date'=> $planDate, + ]); + $writeOffsF = $service->getWeeklyProductsWriteoffsForecast($month, $year, $forecast, $storeId); + $salesForecast = $service->getWeeklyBouquetProductsSalesForecast($month, $year, $storeId); + + + $existing = Autoplannogramma::find() + ->where([ + 'store_id' => $storeId, + 'year' => $year, + 'month' => $month, + 'week' => array_unique(ArrayHelper::getColumn($forecast, 'week')), + ]) + ->indexBy(fn($m) => $m->week . '_' . $m->product_id) + ->all(); + + foreach ($forecast as $item) { + $key = $item['week'] . '_' . $item['product_id']; + $model = $existing[$key] ?? new Autoplannogramma(); + $quantity = (float)($item['forecast_week_pieces'] ?? 0); + $details = []; + $total = $quantity; + + + if (!empty($writeOffsF[$item['product_id']][$item['week']]['writeOffs'])) { + $w = $writeOffsF[$item['product_id']][$item['week']]['writeOffs']; + $details['writeOffs']['quantity'] = $w; + $total += is_array($w) ? array_sum($w) : (float)$w; + } + + + foreach (['offline','online','marketplace'] as $t) { + $block = ['share'=>0,'quantity'=>0,'groups'=>[]]; + if (!empty($salesForecast[$storeId][$item['product_id']][$t])) { + $share = $salesForecast[$storeId][$t]['share'] ?? 0; + $block['share'] = $share; + $block['quantity'] = round($quantity * $share, 2); + $total += $block['quantity']; + foreach ($salesForecast[$storeId][$item['product_id']][$t] as $k=>$v) { + $block['groups'][$k] = (float)$v; + $total += (float)$v; + } + } + $details[$t] = $block; + } + + $details['forecast'] = ['quantity' => $quantity]; + $total = (float) sprintf('%.2f', $total); + + $needsUpdate = $model->isNewRecord + || $model->calculate != $quantity + || ceil($model->total) != ceil($total) + || json_encode($model->details, JSON_UNESCAPED_UNICODE) + !== json_encode($details, JSON_UNESCAPED_UNICODE); + + if ($needsUpdate) { + $model->setAttributes([ + 'year' => $year, + 'month' => $month, + 'week' => $item['week'], + 'product_id' => $item['product_id'], + 'store_id' => $storeId, + 'is_archive' => false, + 'capacity_type' => 1, + 'details' => json_encode($details, JSON_UNESCAPED_UNICODE), + 'calculate' => $quantity, + 'modify' => ceil($total), + 'total' => ceil($total), + ]); + if (!$model->save()) { + throw new \RuntimeException('Save failed: ' . json_encode($model->getErrors(), JSON_UNESCAPED_UNICODE)); + } + } + } + + $task['status'] = 'done'; + $task['progress'] = 100; + $cache->set($cacheKey, $task); + + $log->message = 'Finished successfully'; + $log->progress = 100; + $log->active = 0; + $log->date_finish = date('Y-m-d H:i:s'); + $log->status = 1; + $log->save(false); + + $this->stdout("Recalculate complete for store {$storeId}\n"); + return ExitCode::OK; + + } catch (\Throwable $e) { + $task['status'] = 'error'; + $task['error'] = $e->getMessage(); + $cache->set($cacheKey, $task); + + $log->message = 'Error: ' . $e->getMessage(); + $log->error_count = 1; + $log->error_message= $e->getMessage(); + $log->active = 0; + $log->date_finish = date('Y-m-d H:i:s'); + $log->status = 2; + $log->save(false); + + $this->stderr("Error during recalc: {$e->getMessage()}\n"); + return ExitCode::UNSPECIFIED_ERROR; + } + } + + } diff --git a/erp24/controllers/CategoryPlanController.php b/erp24/controllers/CategoryPlanController.php index 86c608b1..916cfbc1 100644 --- a/erp24/controllers/CategoryPlanController.php +++ b/erp24/controllers/CategoryPlanController.php @@ -502,11 +502,31 @@ class CategoryPlanController extends Controller { if (Yii::$app->request->get('rebuild') === '1' ) { - Yii::$app->queue->push(new RebuildAutoplannogramJob([ + /*Yii::$app->queue->push(new RebuildAutoplannogramJob([ 'year' => (int)$model->year, 'month' => (int)$model->month, 'storeId' => (int)$model->store_id, - ])); + ]));*/ + $yii = escapeshellarg(Yii::getAlias('@app/yii')); + $year = (int)$model->year; + $month = (int)$model->month; + $store = (int)$model->store_id; + + + $cmd = "php {$yii} cron/autoplannogramma-recalculate " + . " --year={$year} --month={$month} --storeId={$store} " + . "> /dev/null 2>&1 &"; + + exec($cmd, $output, $exitCode); + + if ($exitCode === 0) { + Yii::$app->session->setFlash('success','Задача запущена в фоне.'); + Yii::info('Задача запущена в фоне.'); + } else { + Yii::$app->session->setFlash('error','Не удалось запустить задачу.'); + Yii::error('Не удалось запустить задачу.'); + } + $params = [ 'DynamicModel' => $model->attributes, ]; @@ -990,5 +1010,41 @@ class CategoryPlanController extends Controller { return $offlinePlannedSales; } + public function actionRebuild() + { + $year = (int)Yii::$app->request->get('year'); + $month = (int)Yii::$app->request->get('month'); + $store = (int)Yii::$app->request->get('store_id'); + $taskName = "apRecalculateTask"; + + $cacheValue = [ + 'taskName' => $taskName, + 'year' => $year, + 'month' => $month, + 'storeId' => $store, + 'status' => 'running', + 'startTime' => date('Y-m-d H:i:s'), + 'progress' => 0, + 'error' => null + ]; + Yii::$app->cache->set($taskName, $cacheValue, 3600); + + return $this->asJson(['status' => 'started' ]); + } + + public function actionCheckTask() + { + $task = Yii::$app->cache->get('apRecalculateTask'); + + if (!$task) { + return $this->asJson(['status' => 'not_found']); + } + + return $this->asJson([ + 'status' => $task['status'], + 'progress' => $task['progress'] ?? 0, + 'error' => $task['error'] ?? null, + ]); + } } diff --git a/erp24/jobs/RebuildAutoplannogramJob.php b/erp24/jobs/RebuildAutoplannogramJob.php index afa66d45..651d999e 100644 --- a/erp24/jobs/RebuildAutoplannogramJob.php +++ b/erp24/jobs/RebuildAutoplannogramJob.php @@ -37,7 +37,7 @@ class RebuildAutoplannogramJob extends BaseObject implements JobInterface 'year' => $this->year, 'type' => AutoPlannogrammaService::TYPE_SALES, 'store_id' => $this->storeId, - 'category' => null, + 'category' => 'Срезка', 'subcategory' => null, 'species' => null, 'plan_date' => $planDate, diff --git a/erp24/services/AutoPlannogrammaService.php b/erp24/services/AutoPlannogrammaService.php index b44fc7ff..b6c3ebee 100644 --- a/erp24/services/AutoPlannogrammaService.php +++ b/erp24/services/AutoPlannogrammaService.php @@ -852,7 +852,13 @@ class AutoPlannogrammaService //$monthCategoryGoal = $this->getMonthCategoryGoal($monthCategoryShare, $datePlan, $filters['type']); $monthCategoryGoal = []; - $categoryPlan = CategoryPlan::find()->where(['year' => date('Y', strtotime($datePlan)), 'month' => date('m', strtotime($datePlan)), 'store_id' => $filters['store_id']])->indexBy('category')->asArray()->all(); + $categoryPlan = CategoryPlan::find() + ->where( + ['year' => date('Y', strtotime($datePlan)), + 'month' => date('m', strtotime($datePlan)), + 'store_id' => $filters['store_id']])->indexBy('category') + ->asArray() + ->all(); foreach ($categoryPlan as $category) { if ($category['category'] === 'Матрица') { continue; diff --git a/erp24/views/category-plan/new.php b/erp24/views/category-plan/new.php index 4b58e8d2..884f54ef 100644 --- a/erp24/views/category-plan/new.php +++ b/erp24/views/category-plan/new.php @@ -163,6 +163,7 @@ input[readonly] { 'btn btn-danger ms-2', 'name' => 'delete', + 'id' => 'delete', 'value' => 1, 'data' => [ 'confirm' => 'Вы уверены, что хотите вернуть автоплан за ' @@ -170,6 +171,17 @@ input[readonly] { ], ]) ?> + $model->year, + 'month' => $model->month, + 'store_id' => $model->store_id, + ], + [ + 'class'=>'btn btn-success', + 'data-confirm'=>'Запустить пересчет?' + ] + ) */?> 'btn btn-success ms-2', 'disabled' => true, @@ -183,6 +195,8 @@ input[readonly] { ]) ?> +
+ diff --git a/erp24/web/js/category-plan/index.js b/erp24/web/js/category-plan/index.js index 72274c70..5aeb723b 100644 --- a/erp24/web/js/category-plan/index.js +++ b/erp24/web/js/category-plan/index.js @@ -67,6 +67,7 @@ function editProcent(zis) { } }); + $.ajax({ method: "POST", url: '/category-plan/save-fields', @@ -83,6 +84,17 @@ function editProcent(zis) { dataType: "text", success: function(response) { // включаем кнопку при успешном сохранении + let changes = JSON.parse(localStorage.getItem('planChanges') || '{}'); + if (!changes[store_id]) { + changes[store_id] = {}; + } + changes[store_id][type] = { + offline: offlineVal, + internet_shop: internetVal, + write_offs: writeoffsVal + }; + localStorage.setItem('planChanges', JSON.stringify(changes, null, 2)); + updateChangesLog(store_id); $('#rebuild').prop('disabled', false); }, error: function() { @@ -92,6 +104,35 @@ function editProcent(zis) { } +function updateChangesLog(store_id) { + const changes = JSON.parse(localStorage.getItem('planChanges') || '{}'); + const changesBox = $('#changes'); + const changesCount = $('#changes-count'); + + if (!changes[store_id]) { + changes[store_id] = {}; + } + + const count = Object.keys(changes[store_id]).length; + let listItems = ''; + Object.entries(changes[store_id]).forEach(([category, values]) => { + if (values.offline !== undefined) + listItems += `
  • ${category} offline
  • `; + if (values.internet_shop !== undefined) + listItems += `
  • ${category} internet_shop
  • `; + if (values.write_offs !== undefined) + listItems += `
  • ${category} write_offs
  • `; + }); + + changesBox.html(``).hide(); + changesCount.html(`Изменения - ${count} `); + + $('#show-changes').on('click', (event) => { + event.preventDefault(); + $('#changes').toggle(); + }); +} + $(document).ready(() => { $('#categoryPlan').DataTable({ sorting: false, @@ -100,4 +141,68 @@ $(document).ready(() => { searching: true, language: data_table_language }); + const store_id = $('#selected-store').val(); + const changes = JSON.parse(localStorage.getItem('planChanges') || '{}'); + + if (store_id && changes[store_id]) { + updateChangesLog(store_id); + $('#rebuild').prop('disabled', false); + $('#changes-hint').remove(); + $('#changes').after('
    Пересчитайте планограмму после внесения изменений
    '); + } + + $('#delete').on('click', function () { + localStorage.removeItem('planChanges'); + }); + + $('#rebuild').on('click', function (event) { + event.preventDefault(); + localStorage.removeItem('planChanges'); + $('#rebuild').prop('disabled', true).text('Пересчёт запущен...'); + + $.ajax({ + url: '/category-plan/rebuild', + type: 'GET', + data: { + year: $('#dynamicmodel-year').val(), + month: $('#dynamicmodel-month').val(), + store_id: store_id + }, + success: function () { + startTaskPolling(); + $('#rebuild').prop('disabled', true); + }, + error: function () { + alert('Ошибка запуска пересчёта!'); + $('#rebuild').prop('disabled', false).text('Пересчитать автопланограмму'); + } + }); + }); }); + +let taskPollInterval = null; + +function startTaskPolling() { + if (taskPollInterval) return; + + taskPollInterval = setInterval(() => { + $.ajax({ + url: '/category-plan/check-task', + type: 'GET', + dataType: 'json', + success: function (data) { + if (data.status === 'done' || data.status === 'error') { + clearInterval(taskPollInterval); + taskPollInterval = null; + $('#rebuild').prop('disabled', false).text('Пересчитать автопланограмму'); + + if (data.status === 'done') { + alert('Задача успешно завершена'); + } else { + alert('Задача завершилась с ошибкой: ' + (data.error || '')); + } + } + } + }); + }, 3000); +} \ No newline at end of file -- 2.39.5