]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
Штатное расписание
authorVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Thu, 13 Nov 2025 14:32:57 +0000 (17:32 +0300)
committerVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Thu, 13 Nov 2025 14:32:57 +0000 (17:32 +0300)
12 files changed:
erp24/api3/core/services/ReportService.php
erp24/controllers/StoreStaffingController.php [new file with mode: 0644]
erp24/records/StoreStaffing.php [new file with mode: 0644]
erp24/records/StoreStaffingLog.php [new file with mode: 0644]
erp24/records/StoreStaffingSearch.php [new file with mode: 0644]
erp24/views/store-staffing/_form.php [new file with mode: 0644]
erp24/views/store-staffing/create.php [new file with mode: 0644]
erp24/views/store-staffing/import.php [new file with mode: 0644]
erp24/views/store-staffing/index.php [new file with mode: 0644]
erp24/views/store-staffing/logs.php [new file with mode: 0644]
erp24/views/store-staffing/update.php [new file with mode: 0644]
erp24/views/store-staffing/view.php [new file with mode: 0644]

index e6188d0a81da58ab13f73f62bc8162b2c0462d2a..5bda96d7b4d794e5b493dee060fbfddb0ad748d4 100644 (file)
@@ -22,6 +22,78 @@ use yii_app\records\WriteOffsErp;
 
 class ReportService
 {
+    /**
+     * Получает средний уровень обученности по штатному расписанию магазина
+     * 
+     * @param int $storeId ID магазина
+     * @return float|null Средний уровень (посит) или null если нет данных
+     */
+    private function getStoreStaffingSkillScore($storeId)
+    {
+        $staffing = \yii_app\records\StoreStaffing::find()
+            ->alias('ss')
+            ->innerJoin('employee_position ep', 'ep.id = ss.employee_position_id')
+            ->where(['ss.store_id' => $storeId])
+            ->select([
+                'total_weighted' => new Expression('SUM(ep.posit * ss.count)'),
+                'total_count' => new Expression('SUM(ss.count)')
+            ])
+            ->asArray()
+            ->one();
+
+        if (!$staffing || $staffing['total_count'] == 0) {
+            return null;
+        }
+
+        return round((float)$staffing['total_weighted'] / (float)$staffing['total_count'], 1);
+    }
+
+    /**
+     * Подсчитывает средний уровень обученности на смене
+     * Учитывает сотрудников с и без назначенной должности
+     * 
+     * @param array $employees Массив сотрудников с admin_id
+     * @param array $adminSkillMap Карта admin_id => ['posit' => грейд, 'is_estimated' => был ли определен по группе]
+     * @return array ['score' => средний уровень, 'has_estimated' => есть ли предварительные значения] или null если нет данных
+     */
+    private function calculateShiftSkillScore(array $employees, array $adminSkillMap)
+    {
+        if (empty($employees)) {
+            return null;
+        }
+
+        $totalWeighted = 0;
+        $totalCount = 0;
+        $hasEstimated = false;
+
+        foreach ($employees as $employee) {
+            $adminId = $employee['admin_id'];
+            
+            if (!isset($adminSkillMap[$adminId])) {
+                continue;
+            }
+
+            $skillData = $adminSkillMap[$adminId];
+            $posit = $skillData['posit'] ?? 0;
+            
+            if ($skillData['is_estimated'] ?? false) {
+                $hasEstimated = true;
+            }
+
+            $totalWeighted += $posit;
+            $totalCount++;
+        }
+
+        if ($totalCount == 0) {
+            return null;
+        }
+
+        return [
+            'score' => round((float)$totalWeighted / (float)$totalCount, 1),
+            'has_estimated' => $hasEstimated
+        ];
+    }
+
     /**
      * Создает карту должностей для массива admin_id
      * 
@@ -74,6 +146,129 @@ class ReportService
         return $positionMap;
     }
 
+    /**
+     * Создает карту скилла для массива admin_id (для расчета skills)
+     * 
+     * Для каждого админа:
+     * 1. Если есть employee_position_id - берет грейд напрямую
+     * 2. Если нет - ищет должность по group_name с учетом вхождений
+     * 3. Если не найдена - ставит грейд 0
+     * 
+     * @param array $adminIds Массив ID администраторов
+     * @return array Карта вида [admin_id => ['posit' => грейд, 'is_estimated' => был ли определен по группе]]
+     */
+    private function buildAdminSkillMap(array $adminIds): array 
+    {
+        if (empty($adminIds)) {
+            return [];
+        }
+        
+        // Получаем admin с их employee_position_id и group_id
+        $admins = Admin::find()
+            ->select(['id', 'employee_position_id', 'group_id'])
+            ->where(['id' => $adminIds])
+            ->indexBy('id')
+            ->asArray()
+            ->all();
+
+        // Получаем все должности с грейдами
+        $employeePositions = EmployeePosition::find()
+            ->select(['id', 'name', 'posit'])
+            ->indexBy('id')
+            ->asArray()
+            ->all();
+
+        // Получаем все группы для поиска по имени
+        $groupIds = array_filter(array_column($admins, 'group_id'));
+        $adminGroups = AdminGroup::find()
+            ->select(['id', 'name'])
+            ->where(['id' => $groupIds])
+            ->indexBy('id')
+            ->asArray()
+            ->all();
+
+        $skillMap = [];
+        
+        foreach ($admins as $adminId => $admin) {
+            $posit = 0;
+            $isEstimated = false;
+
+            // Сценарий 1: есть employee_position_id
+            if (!empty($admin['employee_position_id']) && isset($employeePositions[$admin['employee_position_id']])) {
+                $posit = $employeePositions[$admin['employee_position_id']]['posit'];
+            } 
+            // Сценарий 2: нет employee_position_id, но есть group_id
+            elseif (!empty($admin['group_id']) && isset($adminGroups[$admin['group_id']])) {
+                $groupName = $adminGroups[$admin['group_id']]['name'];
+                
+                // Ищем должность по вхождению с логикой из контроллера
+                $foundPositit = $this->findPosititByGroupName($groupName, $employeePositions);
+                
+                if ($foundPositit !== null) {
+                    $posit = $foundPositit;
+                    $isEstimated = true;
+                }
+                // Иначе posit остается 0
+            }
+
+            $skillMap[$adminId] = [
+                'posit' => $posit,
+                'is_estimated' => $isEstimated
+            ];
+        }
+
+        return $skillMap;
+    }
+
+    /**
+     * Ищет грейд должности по имени группы с учетом вхождений
+     * 
+     * @param string $groupName Имя группы администратора
+     * @param array $employeePositions Массив всех должностей
+     * @return int|null Грейд (posit) найденной должности или null
+     */
+    private function findPosititByGroupName($groupName, $employeePositions)
+    {
+        $groupName = trim($groupName);
+
+        // 1. Сначала ищем точное совпадение
+        foreach ($employeePositions as $position) {
+            if (strcasecmp($position['name'], $groupName) === 0) {
+                return (int)$position['posit'];
+            }
+        }
+
+        // 2. Ищем по вхождению с учетом логики
+        $candidates = [];
+        foreach ($employeePositions as $position) {
+            $positionName = $position['name'];
+
+            // Проверяем вхождение в обоих направлениях
+            $groupContainsPosition = stripos($groupName, $positionName) !== false;
+            $positionContainsGroup = stripos($positionName, $groupName) !== false;
+
+            if ($groupContainsPosition || $positionContainsGroup) {
+                $candidates[] = [
+                    'posit' => (int)$position['posit'],
+                    'name' => $positionName,
+                    'similarity' => similar_text($groupName, $positionName, $percent),
+                    'percent' => $percent
+                ];
+            }
+        }
+
+        if (empty($candidates)) {
+            return null;
+        }
+
+        // Сортируем по проценту схожести и выбираем лучший
+        usort($candidates, function($a, $b) {
+            return $b['percent'] <=> $a['percent'];
+        });
+
+        return $candidates[0]['posit'];
+    }
+
     /**
      * Подсчитывает количество сотрудников по должностям
      * 
@@ -122,6 +317,7 @@ class ReportService
         $currentDate = $data->date_start;
         $reports = [];
         $totalEmployeePositionsOnShift = [];
+        $totalEmployeeSkillsScores = [];
 
         $employees = Sales::find()->select(["COUNT(*) as cnt", "admin_id"])
             ->where([
@@ -146,6 +342,7 @@ class ReportService
         $adminIdsInPeriod = ArrayHelper::getColumn($allAdminsInPeriod, 'admin_id');
 
         $positionMap = $this->buildPositionMap($adminIdsInPeriod);
+        $adminSkillMap = $this->buildAdminSkillMap($adminIdsInPeriod);
 
 
         while ($currentDate <= $data->date_end) {
@@ -501,11 +698,34 @@ class ReportService
                     }
                 }
 
+                // Расчет уровня обученности на смене
+                $staffingScore = $this->getStoreStaffingSkillScore($store->id);
+                $storeAdmins = isset($adminNames[$store->id]) ? $adminNames[$store->id] : [];
+                $storeAdminIds = array_column($storeAdmins, 'id');
+                $storeAdminSkillMap = [];
+                foreach ($storeAdminIds as $adminId) {
+                    if (isset($adminSkillMap[$adminId])) {
+                        $storeAdminSkillMap[$adminId] = $adminSkillMap[$adminId];
+                    }
+                }
+                $shiftScoreData = $this->calculateShiftSkillScore($timetables, $storeAdminSkillMap);
+
+                $employeeSkillsScore = null;
+                $skillsScoreIsEstimated = false;
+                if ($staffingScore !== null && $shiftScoreData !== null && $staffingScore > 0) {
+                    $shiftScore = $shiftScoreData['score'];
+                    $skillsScoreIsEstimated = $shiftScoreData['has_estimated'];
+                    $employeeSkillsScore = round(($shiftScore / $staffingScore) * 100, 1);
+                    $totalEmployeeSkillsScores[] = $employeeSkillsScore;
+                }
+
                 $reportStore = [
                     "name" => $store->name,
                     "id" => $store->id,
                     "admins" => $adminNames[$store->id] ?? [],
                     "employee_positions_on_shift" => $storeEmployeePositionsOnShift,
+                    "employee_skills_score" => $employeeSkillsScore,
+                    "employee_skills_score_is_estimated" => $skillsScoreIsEstimated,
                     "visitors_quantity" => $storeVisitorsQuantity,
                     "sale_quantity" => $storeSaleQuantity,
                     "sale_total" => $storeSaleTotal,
@@ -530,6 +750,12 @@ class ReportService
                 $report["stores"][] = $reportStore;
             }
 
+            // Расчет среднего уровня обученности
+            $totalEmployeeSkillsScore = null;
+            if (!empty($totalEmployeeSkillsScores)) {
+                $totalEmployeeSkillsScore = round(array_sum($totalEmployeeSkillsScores) / count($totalEmployeeSkillsScores), 1);
+            }
+
             $report['total'] = [
                 "sale_total" => $storeSaleTotalTotal,
                 "sale_quantity" => $storeSaleQuantityTotal,
@@ -557,6 +783,7 @@ class ReportService
                 "total_services_per_day" => $totalServicePerDayTotal,
                 "total_potted_per_day" => $totalPottedPerDayTotal,
                 "employee_positions_on_shift" => $employeePositionsOnShift,
+                "employee_skills_score" => $totalEmployeeSkillsScore,
             ];
 
             // Создаем итоговый массив для дня с правильным порядком полей
@@ -583,6 +810,9 @@ class ReportService
 
         $reports = [];
 
+        // Получаем карту для расчета skills
+        $adminPositionIdMap = [];
+
         $cityStoreNames = ArrayHelper::map(CityStore::find()->where(['visible' => '1'])->all(), 'id', 'name');
 
         $eitStores = ExportImportTable::find()->where(['export_val' => $data->stores])
@@ -612,6 +842,7 @@ class ReportService
         $adminIdsInPeriod = ArrayHelper::getColumn($employeesTotal, 'admin_id');
 
         $positionMap = $this->buildPositionMap($adminIdsInPeriod);
+        $adminSkillMap = $this->buildAdminSkillMap($adminIdsInPeriod);
 
         foreach ($data->date as $ind => $dateStartEnd) {
             $currentDate = $dateStartEnd[0];
@@ -913,6 +1144,26 @@ class ReportService
                     }
                 }
 
+                // Расчет уровня обученности на неделю
+                $staffingScore = $this->getStoreStaffingSkillScore($store_id);
+                $storeWeekAdmins = $storeWeekEmployees[$store_id] ?? [];
+                $storeWeekAdminIds = array_column($storeWeekAdmins, 'admin_id');
+                $storeAdminSkillMap = [];
+                foreach ($storeWeekAdminIds as $adminId) {
+                    if (isset($adminSkillMap[$adminId])) {
+                        $storeAdminSkillMap[$adminId] = $adminSkillMap[$adminId];
+                    }
+                }
+                $shiftScoreData = $this->calculateShiftSkillScore($storeWeekAdmins, $storeAdminSkillMap);
+
+                $employeeSkillsScore = null;
+                $skillsScoreIsEstimated = false;
+                if ($staffingScore !== null && $shiftScoreData !== null && $staffingScore > 0) {
+                    $shiftScore = $shiftScoreData['score'];
+                    $skillsScoreIsEstimated = $shiftScoreData['has_estimated'];
+                    $employeeSkillsScore = round(($shiftScore / $staffingScore) * 100, 1);
+                }
+
                 $store = [
                     "sale_month_total" => ($salesMonth[$store_guids[$store_id]]["total"] ?? 0),
                     "sale_total" => $storeSaleTotalTotal[$store_id] ?? 0,
@@ -943,6 +1194,8 @@ class ReportService
                     "total_services_per_day" => $totalServicePerDayTotal[$store_id] ?? 0,
                     "total_potted_per_day" => $totalPottedPerDayTotal[$store_id] ?? 0,
                     "employee_positions_on_shift" => $storeEmployeePositionsOnShift,
+                    "employee_skills_score" => $employeeSkillsScore,
+                    "employee_skills_score_is_estimated" => $skillsScoreIsEstimated,
                 ];
                 $stores []= ['id' => $store_id, 'guid' => $eitStores[$store_id]['export_val'],
                     'name' => $cityStoreNames[$store_id], 'data' => $store];
@@ -1054,6 +1307,7 @@ class ReportService
         $adminIdsInPeriod = ArrayHelper::getColumn($employeesTotal, 'admin_id');
 
         $positionMap = $this->buildPositionMap($adminIdsInPeriod);
+        $adminSkillMap = $this->buildAdminSkillMap($adminIdsInPeriod);
 
 
         foreach ($days as $ind => $day) {
@@ -1367,6 +1621,26 @@ class ReportService
                         }
                     }
 
+                    // Расчет уровня обученности за день
+                    $staffingScore = $this->getStoreStaffingSkillScore($store_id);
+                    $storeDayAdmins = $storeEmployeesData[$store_id] ?? [];
+                    $storeDayAdminIds = array_column($storeDayAdmins, 'admin_id');
+                    $storeAdminSkillMap = [];
+                    foreach ($storeDayAdminIds as $adminId) {
+                        if (isset($adminSkillMap[$adminId])) {
+                            $storeAdminSkillMap[$adminId] = $adminSkillMap[$adminId];
+                        }
+                    }
+                    $shiftScoreData = $this->calculateShiftSkillScore($storeDayAdmins, $storeAdminSkillMap);
+
+                    $employeeSkillsScore = null;
+                    $skillsScoreIsEstimated = false;
+                    if ($staffingScore !== null && $shiftScoreData !== null && $staffingScore > 0) {
+                        $shiftScore = $shiftScoreData['score'];
+                        $skillsScoreIsEstimated = $shiftScoreData['has_estimated'];
+                        $employeeSkillsScore = round(($shiftScore / $staffingScore) * 100, 1);
+                    }
+
                     $store = [
                         "sale_month_total" => ($salesMonth[$store_guids[$store_id]]["total"] ?? 0),
                         "sale_total" => $storeSaleTotalTotal[$store_id] ?? 0,
@@ -1417,6 +1691,8 @@ class ReportService
                         "total_services_per_day" => $totalServicePerDayTotal[$store_id] ?? 0,
                         "total_potted_per_day" => $totalPottedPerDayTotal[$store_id] ?? 0,
                         "employee_positions_on_shift" => $storeEmployeePositionsOnShift,
+                        "employee_skills_score" => $employeeSkillsScore,
+                        "employee_skills_score_is_estimated" => $skillsScoreIsEstimated,
                     ];
                     $stores [] = [
                         'id' => $store_id,
diff --git a/erp24/controllers/StoreStaffingController.php b/erp24/controllers/StoreStaffingController.php
new file mode 100644 (file)
index 0000000..1307471
--- /dev/null
@@ -0,0 +1,515 @@
+<?php
+
+namespace app\controllers;
+
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use Yii;
+use yii\base\DynamicModel;
+use yii\data\ActiveDataProvider;
+use yii\db\Expression;
+use yii\helpers\ArrayHelper;
+use yii\web\Controller;
+use yii\web\NotFoundHttpException;
+use yii\filters\VerbFilter;
+use yii\web\UploadedFile;
+use yii_app\records\CityStore;
+use yii_app\records\EmployeePosition;
+use yii_app\records\StoreStaffing;
+use yii_app\records\StoreStaffingLog;
+use yii_app\records\StoreStaffingSearch;
+
+/**
+ * StoreStaffingController implements the CRUD actions for StoreStaffing model.
+ */
+class StoreStaffingController extends Controller
+{
+    /**
+     * @inheritDoc
+     */
+    public function behaviors()
+    {
+        return array_merge(
+            parent::behaviors(),
+            [
+                'verbs' => [
+                    'class' => VerbFilter::className(),
+                    'actions' => [
+                        'delete' => ['POST'],
+                        'import' => ['POST', 'GET'],
+                    ],
+                ],
+            ]
+        );
+    }
+
+    /**
+     * Lists all StoreStaffing models.
+     *
+     * @return string
+     */
+    public function actionIndex()
+    {
+        $searchModel = new StoreStaffingSearch();
+        $dataProvider = $searchModel->search($this->request->queryParams);
+
+        // Получаем уникальные магазины
+        $stores = ArrayHelper::map(CityStore::find()->select(['id', 'name'])->asArray()->all(), 'id', 'name');
+
+        return $this->render('index', [
+            'searchModel' => $searchModel,
+            'dataProvider' => $dataProvider,
+            'stores' => $stores,
+        ]);
+    }
+
+    /**
+     * Displays a single StoreStaffing model.
+     * @param int $id ID
+     * @return string
+     * @throws NotFoundHttpException if the model cannot be found
+     */
+    public function actionView($id)
+    {
+        return $this->render('view', [
+            'model' => $this->findModel($id),
+        ]);
+    }
+
+    /**
+     * Creates a new StoreStaffing model.
+     * If creation is successful, the browser will be redirected to the 'view' page.
+     * @return string|\yii\web\Response
+     */
+    public function actionCreate()
+    {
+        $model = new StoreStaffing();
+
+        if ($this->request->isPost) {
+            if ($model->load($this->request->post()) && $model->save()) {
+                Yii::$app->session->setFlash('success', 'Запись штатного расписания успешно создана.');
+                return $this->redirect(['view', 'id' => $model->id]);
+            }
+        }
+
+        $stores = ArrayHelper::map(CityStore::find()->select(['id', 'name'])->asArray()->all(), 'id', 'name');
+        $positions = ArrayHelper::map(EmployeePosition::find()->select(['id', 'name', 'posit'])->asArray()->all(), 'id', 'name');
+
+        return $this->render('create', [
+            'model' => $model,
+            'stores' => $stores,
+            'positions' => $positions,
+        ]);
+    }
+
+    /**
+     * Updates an existing StoreStaffing model.
+     * If update is successful, the browser will be redirected to the 'view' page.
+     * @param int $id ID
+     * @return string|\yii\web\Response
+     * @throws NotFoundHttpException if the model cannot be found
+     */
+    public function actionUpdate($id)
+    {
+        $model = $this->findModel($id);
+
+        if ($this->request->isPost && $model->load($this->request->post()) && $model->save()) {
+            Yii::$app->session->setFlash('success', 'Запись штатного расписания успешно обновлена.');
+            return $this->redirect(['view', 'id' => $model->id]);
+        }
+
+        $stores = ArrayHelper::map(CityStore::find()->select(['id', 'name'])->asArray()->all(), 'id', 'name');
+        $positions = ArrayHelper::map(EmployeePosition::find()->select(['id', 'name', 'posit'])->asArray()->all(), 'id', 'name');
+
+        return $this->render('update', [
+            'model' => $model,
+            'stores' => $stores,
+            'positions' => $positions,
+        ]);
+    }
+
+    /**
+     * Deletes an existing StoreStaffing model.
+     * If deletion is successful, the browser will be redirected to the 'index' page.
+     * @param int $id ID
+     * @return \yii\web\Response
+     * @throws NotFoundHttpException if the model cannot be found
+     */
+    public function actionDelete($id)
+    {
+        $this->findModel($id)->delete();
+        Yii::$app->session->setFlash('success', 'Запись штатного расписания успешно удалена.');
+
+        return $this->redirect(['index']);
+    }
+
+    /**
+     * Импорт штатного расписания из файла Excel
+     * Формат: store_name | position_name | posit | count
+     *
+     * @return string|\yii\web\Response
+     */
+    public function actionImport()
+    {
+        $model = new DynamicModel(['excelFile']);
+        $model->addRule('excelFile', 'file', ['extensions' => ['xls', 'xlsx'], 'skipOnEmpty' => false]);
+
+        $results = [
+            'created' => 0,
+            'updated' => 0,
+            'errors' => [],
+        ];
+
+        if (Yii::$app->request->isPost) {
+            $model->excelFile = UploadedFile::getInstance($model, 'excelFile');
+
+            if ($model->validate()) {
+                $filePath = Yii::getAlias('@runtime') . '/import_' . uniqid() . '.' . $model->excelFile->extension;
+                $model->excelFile->saveAs($filePath);
+
+                try {
+                    $results = $this->processImportFile($filePath);
+                    Yii::$app->session->setFlash('success', 
+                        "Импорт завершен. Создано: {$results['created']}, обновлено: {$results['updated']}. " . 
+                        (count($results['errors']) > 0 ? "Ошибок: " . count($results['errors']) : "")
+                    );
+                    if (count($results['errors']) > 0) {
+                        Yii::$app->session->setFlash('warning', "Обнаружены ошибки: " . implode('; ', array_slice($results['errors'], 0, 5)));
+                    }
+                } catch (\Exception $e) {
+                    Yii::$app->session->setFlash('error', 'Ошибка при обработке файла: ' . $e->getMessage());
+                }
+
+                return $this->redirect(['index']);
+            }
+        }
+
+        return $this->render('import', [
+            'model' => $model,
+        ]);
+    }
+
+    /**
+     * Скачать шаблон Excel для импорта
+     *
+     * @return \yii\web\Response
+     */
+    public function actionExportTemplate()
+    {
+        $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
+
+        // Лист 1: Инструкция
+        $sheet1 = $spreadsheet->getActiveSheet();
+        $sheet1->setTitle('Инструкция');
+        
+        $sheet1->setCellValue('A1', 'ИНСТРУКЦИЯ ПО ИМПОРТУ ШТАТНОГО РАСПИСАНИЯ');
+        $sheet1->mergeCells('A1:D1');
+        $sheet1->getStyle('A1')->getFont()->setBold(true)->setSize(14);
+
+        $row = 3;
+        $sheet1->setCellValue('A' . $row, 'Формат файла для импорта:');
+        $sheet1->getStyle('A' . $row)->getFont()->setBold(true);
+
+        $row += 2;
+        $sheet1->setCellValue('A' . $row, 'Колонка A (store_name):');
+        $sheet1->getStyle('A' . $row)->getFont()->setBold(true);
+        $sheet1->setCellValue('B' . $row, 'Название магазина (должно совпадать с названием в системе)');
+
+        $row++;
+        $sheet1->setCellValue('A' . $row, 'Колонка B (position_name):');
+        $sheet1->getStyle('A' . $row)->getFont()->setBold(true);
+        $sheet1->setCellValue('B' . $row, 'Название должности (должно совпадать с названием в системе)');
+
+        $row++;
+        $sheet1->setCellValue('A' . $row, 'Колонка C (posit):');
+        $sheet1->getStyle('A' . $row)->getFont()->setBold(true);
+        $sheet1->setCellValue('B' . $row, 'Грейд должности (уровень обученности)');
+
+        $row++;
+        $sheet1->setCellValue('A' . $row, 'Колонка D (count):');
+        $sheet1->getStyle('A' . $row)->getFont()->setBold(true);
+        $sheet1->setCellValue('B' . $row, 'Количество сотрудников в этой позиции');
+
+        // Лист 2: Данные
+        $sheet2 = $spreadsheet->createSheet();
+        $sheet2->setTitle('Данные');
+
+        // Заголовки
+        $sheet2->setCellValue('A1', 'store_name');
+        $sheet2->setCellValue('B1', 'position_name');
+        $sheet2->setCellValue('C1', 'posit');
+        $sheet2->setCellValue('D1', 'count');
+
+        // Форматирование заголовков
+        $sheet2->getStyle('A1:D1')->getFont()->setBold(true)->setColor(new \PhpOffice\PhpSpreadsheet\Style\Color('FFFFFF'));
+        $sheet2->getStyle('A1:D1')->getFill()->setFillType('solid')->getStartColor()->setARGB('FF366092');
+
+        // Добавляем примеры данных из БД (все магазины и позиции)
+        $stores = CityStore::find()->where(['visible' => CityStore::IS_VISIBLE])->orderBy('name')->all();
+        $positions = EmployeePosition::find()->orderBy('name')->all();
+
+        if (count($stores) > 0 && count($positions) > 0) {
+            $row = 2;
+            foreach ($stores as $store) {
+                foreach ($positions as $position) {
+                    $sheet2->setCellValue('A' . $row, $store->name);
+                    $sheet2->setCellValue('B' . $row, $position->name);
+                    $sheet2->setCellValue('C' . $row, $position->posit);
+                    $sheet2->setCellValue('D' . $row, ''); // Пустая колонка count для заполнения
+                    $row++;
+                }
+            }
+        }
+
+        // Установка ширины колонок
+        $sheet2->getColumnDimension('A')->setWidth(30);
+        $sheet2->getColumnDimension('B')->setWidth(30);
+        $sheet2->getColumnDimension('C')->setWidth(10);
+        $sheet2->getColumnDimension('D')->setWidth(10);
+
+        // Отправляем файл
+        $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
+        $tempFile = Yii::getAlias('@runtime') . '/store_staffing_template_' . time() . '.xlsx';
+        $writer->save($tempFile);
+
+        $response = Yii::$app->response->sendFile($tempFile, 'store_staffing_template.xlsx');
+        
+        // Удаляем временный файл после отправки
+        @unlink($tempFile);
+        
+        return $response;
+    }
+
+    /**
+     * Просмотр логов изменений
+     *
+     * @param int|null $store_id
+     * @return string
+     */
+    public function actionLogs($store_id = null)
+    {
+        $query = StoreStaffingLog::find();
+
+        if ($store_id !== null) {
+            $query->where(['store_id' => $store_id]);
+        }
+
+        $dataProvider = new ActiveDataProvider([
+            'query' => $query,
+            'sort' => [
+                'defaultOrder' => [
+                    'created_at' => SORT_DESC,
+                ]
+            ],
+            'pagination' => [
+                'pageSize' => 100,
+            ]
+        ]);
+
+        $stores = ArrayHelper::map(CityStore::find()->select(['id', 'name'])->asArray()->all(), 'id', 'name');
+
+        return $this->render('logs', [
+            'dataProvider' => $dataProvider,
+            'stores' => $stores,
+            'selectedStore' => $store_id,
+        ]);
+    }
+
+    /**
+     * Получить грейд должности (используется для AJAX)
+     *
+     * @return mixed
+     */
+    public function actionGetPositionPosit($position_id)
+    {
+        $position = EmployeePosition::findOne(['id' => $position_id]);
+
+        if ($position) {
+            return $this->asJson(['posit' => $position->posit]);
+        }
+
+        return $this->asJson(['posit' => null]);
+    }
+
+    /**
+     * Умный поиск должности по названию с учетом вхождения
+     *
+     * @param string $positionName Название должности из файла
+     * @param array $allPositions Массив всех должностей
+     * @return int|null ID найденной должности или null
+     */
+    protected function findPositionByName($positionName, $allPositions)
+    {
+        $positionName = trim($positionName);
+
+        // 1. Сначала ищем точное совпадение
+        foreach ($allPositions as $position) {
+            if (strcasecmp($position['name'], $positionName) === 0) {
+                return $position['id'];
+            }
+        }
+
+        // 2. Ищем по вхождению с учетом логики
+        $candidates = [];
+        foreach ($allPositions as $position) {
+            $dbName = $position['name'];
+
+            // Проверяем вхождение в обоих направлениях
+            $fileContainsDb = stripos($positionName, $dbName) !== false;
+            $dbContainsFile = stripos($dbName, $positionName) !== false;
+
+            if ($dbContainsFile || $fileContainsDb) {
+                // Избегаем путаницы: если в файле "старший флорист", а в БД есть и "флорист" и "старший флорист",
+                // то выбираем наиболее подходящий (старший флорист)
+                $candidates[] = [
+                    'id' => $position['id'],
+                    'name' => $dbName,
+                    'match_type' => $dbContainsFile ? 'contains' : 'contained',
+                    'similarity' => similar_text($positionName, $dbName, $percent),
+                    'percent' => $percent
+                ];
+            }
+        }
+
+        if (empty($candidates)) {
+            return null;
+        }
+
+        // Сортируем по проценту схожести и выбираем лучший
+        usort($candidates, function($a, $b) {
+            return $b['percent'] <=> $a['percent'];
+        });
+
+        return $candidates[0]['id'];
+    }
+
+    /**
+     * Обработка файла импорта
+     *
+     * @param string $filePath
+     * @return array
+     */
+    protected function processImportFile($filePath)
+    {
+        $results = [
+            'created' => 0,
+            'updated' => 0,
+            'errors' => [],
+        ];
+
+        $spreadsheet = IOFactory::load($filePath);
+        $rows = $spreadsheet->getActiveSheet()->toArray(null, true, true, true);
+
+        // Пропускаем заголовок
+        $header = array_shift($rows);
+
+        // Кэшируем магазины и должности
+        $storesMap = ArrayHelper::map(CityStore::find()->select(['id', 'name'])->asArray()->all(), 'name', 'id');
+        $allPositions = EmployeePosition::find()->select(['id', 'name', 'posit'])->asArray()->all();
+        $positionsMap = ArrayHelper::map($allPositions, 'name', 'id');
+        $positionDetails = ArrayHelper::map($allPositions, 'id', 'posit');
+
+        $rowNum = 2;
+        foreach ($rows as $row) {
+            if (empty($row['A']) && empty($row['B']) && empty($row['C']) && empty($row['D'])) {
+                continue;
+            }
+
+            $storeName = trim($row['A'] ?? '');
+            $positionName = trim($row['B'] ?? '');
+            $posit = (int)($row['C'] ?? 0);
+            $count = (float)($row['D'] ?? 0);
+
+            // Валидация
+            if (empty($storeName)) {
+                $results['errors'][] = "Строка $rowNum: название магазина пусто.";
+                $rowNum++;
+                continue;
+            }
+
+            if (empty($positionName)) {
+                $results['errors'][] = "Строка $rowNum: название должности пусто.";
+                $rowNum++;
+                continue;
+            }
+
+            if ($count <= 0) {
+                $results['errors'][] = "Строка $rowNum: количество должно быть больше 0.";
+                $rowNum++;
+                continue;
+            }
+
+            // Поиск магазина
+            if (!isset($storesMap[$storeName])) {
+                $results['errors'][] = "Строка $rowNum: магазин '$storeName' не найден.";
+                $rowNum++;
+                continue;
+            }
+            $storeId = $storesMap[$storeName];
+
+            // Поиск должности с умным поиском по вхождению
+            $positionId = $this->findPositionByName($positionName, $allPositions);
+            if ($positionId === null) {
+                $results['errors'][] = "Строка $rowNum: должность '$positionName' не найдена.";
+                $rowNum++;
+                continue;
+            }
+            $actualPosit = $positionDetails[$positionId];
+
+            // Проверка соответствия posit
+            if ($posit != $actualPosit) {
+                $results['errors'][] = "Строка $rowNum: грейд должности '$positionName' должен быть $actualPosit, а не $posit.";
+            }
+
+            // Поиск или создание записи
+            $staffing = StoreStaffing::findOne([
+                'store_id' => $storeId,
+                'employee_position_id' => $positionId,
+            ]);
+
+            if ($staffing) {
+                if ($staffing->count != $count) {
+                    $staffing->count = $count;
+                    if ($staffing->save()) {
+                        $results['updated']++;
+                    } else {
+                        $results['errors'][] = "Строка $rowNum: ошибка при обновлении записи.";
+                    }
+                }
+            } else {
+                $staffing = new StoreStaffing();
+                $staffing->store_id = $storeId;
+                $staffing->employee_position_id = $positionId;
+                $staffing->count = $count;
+
+                if ($staffing->save()) {
+                    $results['created']++;
+                } else {
+                    $results['errors'][] = "Строка $rowNum: ошибка при создании записи.";
+                }
+            }
+
+            $rowNum++;
+        }
+
+        @unlink($filePath);
+
+        return $results;
+    }
+
+    /**
+     * Finds the StoreStaffing model based on its primary key value.
+     * If the model is not found, a 404 HTTP exception will be thrown.
+     * @param int $id ID
+     * @return StoreStaffing the loaded model
+     * @throws NotFoundHttpException if the model cannot be found
+     */
+    protected function findModel($id)
+    {
+        if (($model = StoreStaffing::findOne(['id' => $id])) !== null) {
+            return $model;
+        }
+
+        throw new NotFoundHttpException('The requested page does not exist.');
+    }
+}
+
diff --git a/erp24/records/StoreStaffing.php b/erp24/records/StoreStaffing.php
new file mode 100644 (file)
index 0000000..23dff7c
--- /dev/null
@@ -0,0 +1,139 @@
+<?php
+
+namespace yii_app\records;
+
+use Yii;
+use yii\behaviors\TimestampBehavior;
+use yii\db\ActiveRecord;
+use yii\db\Expression;
+
+/**
+ * This is the model class for table "store_staffing".
+ *
+ * @property int $id
+ * @property int $store_id ID магазина
+ * @property int $employee_position_id ID должности
+ * @property int $count Количество сотрудников
+ * @property string $created_at Дата создания
+ * @property string $updated_at Дата обновления
+ *
+ * @property CityStore $store
+ * @property EmployeePosition $employeePosition
+ */
+class StoreStaffing extends ActiveRecord
+{
+    /**
+     * {@inheritdoc}
+     */
+    public static function tableName()
+    {
+        return 'store_staffing';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function behaviors()
+    {
+        return [
+            [
+                'class' => TimestampBehavior::class,
+                'createdAtAttribute' => 'created_at',
+                'updatedAtAttribute' => 'updated_at',
+                'value' => function () {
+                    return date('Y-m-d H:i:s');
+                },
+            ],
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function rules()
+    {
+        return [
+            [['store_id', 'employee_position_id', 'count'], 'required'],
+            [['store_id', 'employee_position_id'], 'integer'],
+            [['count'], 'number', 'min' => 0.1, 'message' => 'Количество должно быть не менее 0.1'],
+            [['store_id', 'employee_position_id'], 'unique', 'targetAttribute' => ['store_id', 'employee_position_id'], 'message' => 'Для этого магазина и должности уже определено штатное расписание.'],
+            [['store_id'], 'exist', 'skipOnError' => true, 'targetClass' => CityStore::class, 'targetAttribute' => ['store_id' => 'id']],
+            [['employee_position_id'], 'exist', 'skipOnError' => true, 'targetClass' => EmployeePosition::class, 'targetAttribute' => ['employee_position_id' => 'id']],
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function attributeLabels()
+    {
+        return [
+            'id' => 'ID',
+            'store_id' => 'Магазин',
+            'employee_position_id' => 'Должность',
+            'count' => 'Количество',
+            'created_at' => 'Дата создания',
+            'updated_at' => 'Дата обновления',
+        ];
+    }
+
+    /**
+     * Gets query for [[Store]].
+     */
+    public function getStore()
+    {
+        return $this->hasOne(CityStore::class, ['id' => 'store_id']);
+    }
+
+    /**
+     * Gets query for [[EmployeePosition]].
+     */
+    public function getEmployeePosition()
+    {
+        return $this->hasOne(EmployeePosition::class, ['id' => 'employee_position_id']);
+    }
+
+    /**
+     * После сохранения логируем изменения
+     */
+    public function afterSave($insert, $changedAttributes)
+    {
+        parent::afterSave($insert, $changedAttributes);
+
+        $log = new StoreStaffingLog();
+        $log->store_staffing_id = $this->id;
+        $log->store_id = $this->store_id;
+        $log->employee_position_id = $this->employee_position_id;
+        $log->changed_by = Yii::$app->user->id ?? null;
+
+        if ($insert) {
+            $log->action = StoreStaffingLog::ACTION_CREATE;
+            $log->new_count = $this->count;
+        } else {
+            $log->action = StoreStaffingLog::ACTION_UPDATE;
+            $log->old_count = $changedAttributes['count'] ?? null;
+            $log->new_count = $this->count;
+        }
+
+        $log->save(false);
+    }
+
+    /**
+     * После удаления логируем удаление
+     */
+    public function afterDelete()
+    {
+        parent::afterDelete();
+
+        $log = new StoreStaffingLog();
+        $log->store_staffing_id = null;
+        $log->store_id = $this->store_id;
+        $log->employee_position_id = $this->employee_position_id;
+        $log->action = StoreStaffingLog::ACTION_DELETE;
+        $log->old_count = $this->count;
+        $log->changed_by = Yii::$app->user->id ?? null;
+
+        $log->save(false);
+    }
+}
+
diff --git a/erp24/records/StoreStaffingLog.php b/erp24/records/StoreStaffingLog.php
new file mode 100644 (file)
index 0000000..6fa1f1a
--- /dev/null
@@ -0,0 +1,119 @@
+<?php
+
+namespace yii_app\records;
+
+use yii\db\ActiveRecord;
+
+/**
+ * This is the model class for table "store_staffing_log".
+ *
+ * @property int $id
+ * @property int|null $store_staffing_id ID записи штатного расписания (NULL для удалений)
+ * @property int $store_id ID магазина
+ * @property int $employee_position_id ID должности
+ * @property string $action Действие (create, update, delete)
+ * @property int|null $old_count Старое количество
+ * @property int|null $new_count Новое количество
+ * @property int|null $changed_by ID администратора, внесшего изменение
+ * @property string $created_at Дата создания
+ *
+ * @property StoreStaffing $storeStaffing
+ * @property CityStore $store
+ * @property EmployeePosition $employeePosition
+ * @property Admin $changedByUser
+ */
+class StoreStaffingLog extends ActiveRecord
+{
+    public const ACTION_CREATE = 'create';
+    public const ACTION_UPDATE = 'update';
+    public const ACTION_DELETE = 'delete';
+
+    public static array $actions = [
+        self::ACTION_CREATE => 'Создание',
+        self::ACTION_UPDATE => 'Обновление',
+        self::ACTION_DELETE => 'Удаление',
+    ];
+
+    /**
+     * {@inheritdoc}
+     */
+    public static function tableName()
+    {
+        return 'store_staffing_log';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function rules()
+    {
+        return [
+            [['store_id', 'employee_position_id', 'action'], 'required'],
+            [['store_staffing_id', 'store_id', 'employee_position_id', 'old_count', 'new_count', 'changed_by'], 'integer'],
+            [['action'], 'string', 'max' => 10],
+            [['store_staffing_id'], 'exist', 'skipOnError' => true, 'targetClass' => StoreStaffing::class, 'targetAttribute' => ['store_staffing_id' => 'id']],
+            [['store_id'], 'exist', 'skipOnError' => true, 'targetClass' => CityStore::class, 'targetAttribute' => ['store_id' => 'id']],
+            [['employee_position_id'], 'exist', 'skipOnError' => true, 'targetClass' => EmployeePosition::class, 'targetAttribute' => ['employee_position_id' => 'id']],
+            [['changed_by'], 'exist', 'skipOnError' => true, 'targetClass' => Admin::class, 'targetAttribute' => ['changed_by' => 'id']],
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function attributeLabels()
+    {
+        return [
+            'id' => 'ID',
+            'store_staffing_id' => 'ID Записи',
+            'store_id' => 'Магазин',
+            'employee_position_id' => 'Должность',
+            'action' => 'Действие',
+            'old_count' => 'Старое количество',
+            'new_count' => 'Новое количество',
+            'changed_by' => 'Изменено',
+            'created_at' => 'Дата',
+        ];
+    }
+
+    /**
+     * Gets query for [[StoreStaffing]].
+     */
+    public function getStoreStaffing()
+    {
+        return $this->hasOne(StoreStaffing::class, ['id' => 'store_staffing_id']);
+    }
+
+    /**
+     * Gets query for [[Store]].
+     */
+    public function getStore()
+    {
+        return $this->hasOne(CityStore::class, ['id' => 'store_id']);
+    }
+
+    /**
+     * Gets query for [[EmployeePosition]].
+     */
+    public function getEmployeePosition()
+    {
+        return $this->hasOne(EmployeePosition::class, ['id' => 'employee_position_id']);
+    }
+
+    /**
+     * Gets query for [[ChangedByUser]].
+     */
+    public function getChangedByUser()
+    {
+        return $this->hasOne(Admin::class, ['id' => 'changed_by']);
+    }
+
+    /**
+     * Получить название действия
+     */
+    public function getActionName()
+    {
+        return self::$actions[$this->action] ?? $this->action;
+    }
+}
+
diff --git a/erp24/records/StoreStaffingSearch.php b/erp24/records/StoreStaffingSearch.php
new file mode 100644 (file)
index 0000000..f9e05df
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+
+namespace yii_app\records;
+
+use yii\base\Model;
+use yii\data\ActiveDataProvider;
+
+/**
+ * StoreStaffingSearch represents the model behind the search form of `yii_app\records\StoreStaffing`.
+ */
+class StoreStaffingSearch extends Model
+{
+    public $id;
+    public $store_id;
+    public $employee_position_id;
+    public $count;
+    public $created_at;
+    public $updated_at;
+
+    /**
+     * {@inheritdoc}
+     */
+    public function rules()
+    {
+        return [
+            [['id', 'store_id', 'employee_position_id', 'count'], 'integer'],
+            [['created_at', 'updated_at'], 'safe'],
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function scenarios()
+    {
+        // bypass scenarios() implementation in the parent class
+        return Model::scenarios();
+    }
+
+    /**
+     * Creates data provider instance with search query applied
+     *
+     * @param array $params
+     *
+     * @return ActiveDataProvider
+     */
+    public function search($params)
+    {
+        $query = StoreStaffing::find();
+
+        // add conditions that should always apply here
+
+        $dataProvider = new ActiveDataProvider([
+            'query' => $query,
+            'sort' => [
+                'defaultOrder' => [
+                    'store_id' => SORT_ASC,
+                    'employee_position_id' => SORT_ASC,
+                ]
+            ]
+        ]);
+
+        $this->load($params);
+
+        if (!$this->validate()) {
+            // uncomment the following line if you do not want to return any records when validation fails
+            // $query->where('0=1');
+            return $dataProvider;
+        }
+
+        // grid filtering conditions
+        $query->andFilterWhere([
+            'id' => $this->id,
+            'store_id' => $this->store_id,
+            'employee_position_id' => $this->employee_position_id,
+            'count' => $this->count,
+        ]);
+
+        $query->andFilterWhere(['like', 'created_at', $this->created_at])
+            ->andFilterWhere(['like', 'updated_at', $this->updated_at]);
+
+        return $dataProvider;
+    }
+}
+
diff --git a/erp24/views/store-staffing/_form.php b/erp24/views/store-staffing/_form.php
new file mode 100644 (file)
index 0000000..169d119
--- /dev/null
@@ -0,0 +1,76 @@
+<?php
+
+use yii\helpers\Html;
+use yii\widgets\ActiveForm;
+use yii\helpers\ArrayHelper;
+use yii_app\records\CityStore;
+use yii_app\records\EmployeePosition;
+
+/** @var yii\web\View $this */
+/** @var yii_app\records\StoreStaffing $model */
+/** @var yii\bootstrap5\ActiveForm $form */
+/** @var array $stores */
+/** @var array $positions */
+?>
+
+<div class="store-staffing-form">
+
+    <?php $form = ActiveForm::begin(); ?>
+
+    <?= $form->field($model, 'store_id')->dropDownList($stores, ['prompt' => 'Выберите магазин']) ?>
+
+    <?= $form->field($model, 'employee_position_id')->dropDownList($positions, [
+        'prompt' => 'Выберите должность',
+        'id' => 'position-id',
+    ]) ?>
+
+    <div class="alert alert-info">
+        <strong>Грейд (посит) должности:</strong> <span id="position-posit">-</span>
+    </div>
+
+    <?= $form->field($model, 'count')->textInput(['type' => 'number', 'min' => 0.1, 'step' => 0.1]) ?>
+
+    <div class="form-group">
+        <?= Html::submitButton('Сохранить', ['class' => 'btn btn-success']) ?>
+        <?= Html::a('Отмена', ['index'], ['class' => 'btn btn-secondary']) ?>
+    </div>
+
+    <?php ActiveForm::end(); ?>
+
+</div>
+
+<?php
+$this->registerJs(<<<JS
+    // Объект с информацией о должностях
+    let positionsInfo = {};
+    
+    // Получаем информацию о позициях
+    let allPositions = $positions;
+    Object.values(allPositions).forEach(function(name, id) {
+        // Запросим информацию с сервера
+    });
+
+    // При изменении должности обновляем грейд
+    document.getElementById('position-id').addEventListener('change', function() {
+        let positionId = this.value;
+        
+        if (positionId) {
+            // Найдем информацию о должности через AJAX
+            fetch('index.php?r=crud/store-staffing/get-position-posit&position_id=' + positionId)
+                .then(response => response.json())
+                .then(data => {
+                    document.getElementById('position-posit').textContent = data.posit || '-';
+                });
+        } else {
+            document.getElementById('position-posit').textContent = '-';
+        }
+    });
+
+    // Инициализируем при загрузке страницы
+    if (document.getElementById('position-id').value) {
+        document.getElementById('position-id').dispatchEvent(new Event('change'));
+    }
+JS
+);
+?>
+
diff --git a/erp24/views/store-staffing/create.php b/erp24/views/store-staffing/create.php
new file mode 100644 (file)
index 0000000..5411809
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+
+use yii\helpers\Html;
+
+/** @var yii\web\View $this */
+/** @var yii_app\records\StoreStaffing $model */
+/** @var array $stores */
+/** @var array $positions */
+
+$this->title = 'Создать запись штатного расписания';
+$this->params['breadcrumbs'][] = ['label' => 'Штатное расписание', 'url' => ['index']];
+$this->params['breadcrumbs'][] = $this->title;
+?>
+<div class="store-staffing-create p-4">
+
+    <h1><?= Html::encode($this->title) ?></h1>
+
+    <?= $this->render('_form', [
+        'model' => $model,
+        'stores' => $stores,
+        'positions' => $positions,
+    ]) ?>
+
+</div>
+
diff --git a/erp24/views/store-staffing/import.php b/erp24/views/store-staffing/import.php
new file mode 100644 (file)
index 0000000..2e9150b
--- /dev/null
@@ -0,0 +1,42 @@
+<?php
+
+use yii\helpers\Html;
+use yii\widgets\ActiveForm;
+
+/** @var yii\web\View $this */
+/** @var yii\base\DynamicModel $model */
+
+$this->title = 'Импорт штатного расписания из Excel';
+$this->params['breadcrumbs'][] = ['label' => 'Штатное расписание', 'url' => ['index']];
+$this->params['breadcrumbs'][] = $this->title;
+?>
+<div class="store-staffing-import p-4">
+
+    <h1><?= Html::encode($this->title) ?></h1>
+
+    <div class="alert alert-info">
+        <h4>Инструкция</h4>
+        <p>Загрузите файл Excel с расширением .xls или .xlsx</p>
+        <p><strong>Формат файла:</strong></p>
+        <ul>
+            <li><strong>Колонка A (store_name):</strong> Название магазина (должно совпадать с названием в системе)</li>
+            <li><strong>Колонка B (position_name):</strong> Название должности (должно совпадать с названием в системе)</li>
+            <li><strong>Колонка C (posit):</strong> Грейд должности (уровень обученности)</li>
+            <li><strong>Колонка D (count):</strong> Количество сотрудников в этой позиции</li>
+        </ul>
+        <p><?= Html::a('Скачать шаблон', ['export-template'], ['class' => 'btn btn-sm btn-info']) ?></p>
+    </div>
+
+    <?php $form = ActiveForm::begin(['options' => ['enctype' => 'multipart/form-data']]); ?>
+
+        <?= $form->field($model, 'excelFile')->fileInput() ?>
+
+        <div class="form-group">
+            <?= Html::submitButton('Загрузить', ['class' => 'btn btn-success']) ?>
+            <?= Html::a('Отмена', ['index'], ['class' => 'btn btn-secondary']) ?>
+        </div>
+
+    <?php ActiveForm::end(); ?>
+
+</div>
+
diff --git a/erp24/views/store-staffing/index.php b/erp24/views/store-staffing/index.php
new file mode 100644 (file)
index 0000000..0d1388c
--- /dev/null
@@ -0,0 +1,125 @@
+<?php
+
+use yii\helpers\ArrayHelper;
+use yii\helpers\Html;
+use yii\helpers\Url;
+use yii\grid\GridView;
+use yii_app\records\StoreStaffing;
+
+/** @var yii\web\View $this */
+/** @var yii_app\records\StoreStaffingSearch $searchModel */
+/** @var yii\data\ActiveDataProvider $dataProvider */
+/** @var array $stores */
+
+$this->title = 'Штатное расписание по магазинам';
+$this->params['breadcrumbs'][] = $this->title;
+?>
+<div class="store-staffing-index p-4">
+
+    <h1><?= Html::encode($this->title) ?></h1>
+
+    <p>
+        <?= Html::a('Создать запись', ['create'], ['class' => 'btn btn-success']) ?>
+        <?= Html::a('Импорт из Excel', ['import'], ['class' => 'btn btn-primary']) ?>
+        <?= Html::a('Скачать шаблон', ['export-template'], ['class' => 'btn btn-info', 'target' => '_blank']) ?>
+        <?= Html::a('Просмотр логов', ['logs'], ['class' => 'btn btn-warning']) ?>
+    </p>
+
+    <?php // echo $this->render('_search', ['model' => $searchModel]); ?>
+
+    <?= GridView::widget([
+        'dataProvider' => $dataProvider,
+        'filterModel' => $searchModel,
+        'columns' => [
+            ['class' => 'yii\grid\SerialColumn'],
+
+            [
+                'attribute' => 'store_id',
+                'label' => 'Магазин',
+                'value' => function ($model) {
+                    return $model->store ? $model->store->name : 'N/A';
+                },
+                'filter' => Html::activeDropDownList($searchModel, 'store_id', $stores, ['class' => 'form-control', 'prompt' => 'Все магазины']),
+            ],
+            [
+                'attribute' => 'employee_position_id',
+                'label' => 'Должность',
+                'value' => function ($model) {
+                    return $model->employeePosition ? $model->employeePosition->name : 'N/A';
+                },
+                'filter' => Html::activeDropDownList($searchModel, 'employee_position_id',
+                    ArrayHelper::map(\yii_app\records\EmployeePosition::find()
+                        ->select(['id', 'name', 'posit'])
+                        ->orderBy('posit')
+                        ->asArray()
+                        ->all(),
+                        'id',
+                        function($position) {
+                            return $position['name'] . ' (грейд: ' . $position['posit'] . ')';
+                        }
+                    ),
+                    ['class' => 'form-control', 'prompt' => 'Все должности']
+                ),
+            ],
+            [
+                'label' => 'Грейд (posit)',
+                'value' => function ($model) {
+                    return $model->employeePosition ? $model->employeePosition->posit : 'N/A';
+                },
+            ],
+            [
+                'attribute' => 'count',
+                'label' => 'Количество',
+            ],
+            [
+                'class' => 'yii\grid\ActionColumn',
+                'template' => '{view} {update} {delete}',
+            ],
+        ],
+    ]); ?>
+
+    <h3>Средний уровень обученности по магазинам</h3>
+    
+    <table class="table table-striped table-bordered">
+        <thead>
+            <tr>
+                <th>Магазин</th>
+                <th>Средний уровень (посит)</th>
+            </tr>
+        </thead>
+        <tbody>
+            <?php
+            $staffingByStore = StoreStaffing::find()
+                ->with('store', 'employeePosition')
+                ->orderBy('store_id')
+                ->all();
+
+            $storeSkills = [];
+            foreach ($staffingByStore as $staffing) {
+                if (!isset($storeSkills[$staffing->store_id])) {
+                    $storeSkills[$staffing->store_id] = [
+                        'storeName' => $staffing->store ? $staffing->store->name : 'N/A',
+                        'totalWeighted' => 0,
+                        'totalCount' => 0,
+                    ];
+                }
+
+                if ($staffing->employeePosition) {
+                    $storeSkills[$staffing->store_id]['totalWeighted'] += $staffing->employeePosition->posit * $staffing->count;
+                    $storeSkills[$staffing->store_id]['totalCount'] += $staffing->count;
+                }
+            }
+
+            foreach ($storeSkills as $storeId => $data) {
+                $avgScore = $data['totalCount'] > 0 ? round($data['totalWeighted'] / $data['totalCount'], 1) : 0;
+                echo '<tr>';
+                echo '<td>' . Html::a($data['storeName'], ['logs', 'store_id' => $storeId]) . '</td>';
+                echo '<td><strong>' . $avgScore . '</strong></td>';
+                echo '</tr>';
+            }
+            ?>
+        </tbody>
+    </table>
+
+</div>
+
diff --git a/erp24/views/store-staffing/logs.php b/erp24/views/store-staffing/logs.php
new file mode 100644 (file)
index 0000000..9ef6cf4
--- /dev/null
@@ -0,0 +1,94 @@
+<?php
+
+use yii\helpers\Html;
+use yii\grid\GridView;
+use yii_app\records\StoreStaffingLog;
+
+/** @var yii\web\View $this */
+/** @var yii\data\ActiveDataProvider $dataProvider */
+/** @var array $stores */
+/** @var int|null $selectedStore */
+
+$this->title = 'Логи изменений штатного расписания';
+$this->params['breadcrumbs'][] = ['label' => 'Штатное расписание', 'url' => ['index']];
+$this->params['breadcrumbs'][] = $this->title;
+?>
+<div class="store-staffing-logs p-4">
+
+    <h1><?= Html::encode($this->title) ?></h1>
+
+    <p>
+        <?= Html::a('Назад к штатному расписанию', ['index'], ['class' => 'btn btn-secondary']) ?>
+    </p>
+
+    <div class="card mb-3">
+        <div class="card-body">
+            <form method="get" class="row g-3">
+                <div class="col-md-4">
+                    <?= Html::dropDownList('store_id', $selectedStore, $stores, [
+                        'class' => 'form-control',
+                        'prompt' => 'Все магазины',
+                    ]) ?>
+                </div>
+                <div class="col-md-2">
+                    <?= Html::submitButton('Фильтр', ['class' => 'btn btn-primary']) ?>
+                </div>
+            </form>
+        </div>
+    </div>
+
+    <?= GridView::widget([
+        'dataProvider' => $dataProvider,
+        'columns' => [
+            ['class' => 'yii\grid\SerialColumn'],
+
+            [
+                'attribute' => 'created_at',
+                'label' => 'Дата',
+                'format' => ['date', 'php:d.m.Y H:i:s'],
+            ],
+            [
+                'attribute' => 'store_id',
+                'label' => 'Магазин',
+                'value' => function ($model) {
+                    return $model->store ? $model->store->name : 'N/A';
+                },
+            ],
+            [
+                'attribute' => 'employee_position_id',
+                'label' => 'Должность',
+                'value' => function ($model) {
+                    return $model->employeePosition ? $model->employeePosition->name : 'N/A';
+                },
+            ],
+            [
+                'attribute' => 'action',
+                'label' => 'Действие',
+                'value' => function ($model) {
+                    return $model->getActionName();
+                },
+                'filter' => Html::dropDownList('action', null, StoreStaffingLog::$actions, [
+                    'class' => 'form-control',
+                    'prompt' => 'Все',
+                ]),
+            ],
+            [
+                'attribute' => 'old_count',
+                'label' => 'Старое значение',
+            ],
+            [
+                'attribute' => 'new_count',
+                'label' => 'Новое значение',
+            ],
+            [
+                'attribute' => 'changed_by',
+                'label' => 'Изменено',
+                'value' => function ($model) {
+                    return $model->changedByUser ? $model->changedByUser->name : 'Система';
+                },
+            ],
+        ],
+    ]); ?>
+
+</div>
+
diff --git a/erp24/views/store-staffing/update.php b/erp24/views/store-staffing/update.php
new file mode 100644 (file)
index 0000000..1c50ea5
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+use yii\helpers\Html;
+
+/** @var yii\web\View $this */
+/** @var yii_app\records\StoreStaffing $model */
+/** @var array $stores */
+/** @var array $positions */
+
+$this->title = 'Редактировать запись: ' . $model->id;
+$this->params['breadcrumbs'][] = ['label' => 'Штатное расписание', 'url' => ['index']];
+$this->params['breadcrumbs'][] = ['label' => $model->id, 'url' => ['view', 'id' => $model->id]];
+$this->params['breadcrumbs'][] = 'Редактировать';
+?>
+<div class="store-staffing-update p-4">
+
+    <h1><?= Html::encode($this->title) ?></h1>
+
+    <?= $this->render('_form', [
+        'model' => $model,
+        'stores' => $stores,
+        'positions' => $positions,
+    ]) ?>
+
+</div>
+
diff --git a/erp24/views/store-staffing/view.php b/erp24/views/store-staffing/view.php
new file mode 100644 (file)
index 0000000..fe2a278
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+
+use yii\helpers\Html;
+use yii\widgets\DetailView;
+
+/** @var yii\web\View $this */
+/** @var yii_app\records\StoreStaffing $model */
+
+$this->title = $model->id;
+$this->params['breadcrumbs'][] = ['label' => 'Штатное расписание', 'url' => ['index']];
+$this->params['breadcrumbs'][] = $this->title;
+\yii\web\YiiAsset::register($this);
+?>
+<div class="store-staffing-view p-4">
+
+    <h1><?= Html::encode($this->title) ?></h1>
+
+    <p>
+        <?= Html::a('Редактировать', ['update', 'id' => $model->id], ['class' => 'btn btn-primary']) ?>
+        <?= Html::a('Удалить', ['delete', 'id' => $model->id], [
+            'class' => 'btn btn-danger',
+            'data' => [
+                'confirm' => 'Вы уверены?',
+                'method' => 'post',
+            ],
+        ]) ?>
+        <?= Html::a('Назад', ['index'], ['class' => 'btn btn-secondary']) ?>
+    </p>
+
+    <?= DetailView::widget([
+        'model' => $model,
+        'attributes' => [
+            'id',
+            [
+                'attribute' => 'store_id',
+                'value' => function ($model) {
+                    return $model->store ? $model->store->name : 'N/A';
+                }
+            ],
+            [
+                'attribute' => 'employee_position_id',
+                'value' => function ($model) {
+                    return $model->employeePosition ? $model->employeePosition->name : 'N/A';
+                }
+            ],
+            [
+                'label' => 'Грейд (posit)',
+                'value' => function ($model) {
+                    return $model->employeePosition ? $model->employeePosition->posit : 'N/A';
+                }
+            ],
+            'count',
+            'created_at',
+            'updated_at',
+        ],
+    ]) ?>
+
+</div>
+