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
*
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'];
+ }
+
/**
* Подсчитывает количество сотрудников по должностям
*
$currentDate = $data->date_start;
$reports = [];
$totalEmployeePositionsOnShift = [];
+ $totalEmployeeSkillsScores = [];
$employees = Sales::find()->select(["COUNT(*) as cnt", "admin_id"])
->where([
$adminIdsInPeriod = ArrayHelper::getColumn($allAdminsInPeriod, 'admin_id');
$positionMap = $this->buildPositionMap($adminIdsInPeriod);
+ $adminSkillMap = $this->buildAdminSkillMap($adminIdsInPeriod);
while ($currentDate <= $data->date_end) {
}
}
+ // Расчет уровня обученности на смене
+ $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,
$report["stores"][] = $reportStore;
}
+ // Расчет среднего уровня обученности
+ $totalEmployeeSkillsScore = null;
+ if (!empty($totalEmployeeSkillsScores)) {
+ $totalEmployeeSkillsScore = round(array_sum($totalEmployeeSkillsScores) / count($totalEmployeeSkillsScores), 1);
+ }
+
$report['total'] = [
"sale_total" => $storeSaleTotalTotal,
"sale_quantity" => $storeSaleQuantityTotal,
"total_services_per_day" => $totalServicePerDayTotal,
"total_potted_per_day" => $totalPottedPerDayTotal,
"employee_positions_on_shift" => $employeePositionsOnShift,
+ "employee_skills_score" => $totalEmployeeSkillsScore,
];
// Создаем итоговый массив для дня с правильным порядком полей
$reports = [];
+ // Получаем карту для расчета skills
+ $adminPositionIdMap = [];
+
$cityStoreNames = ArrayHelper::map(CityStore::find()->where(['visible' => '1'])->all(), 'id', 'name');
$eitStores = ExportImportTable::find()->where(['export_val' => $data->stores])
$adminIdsInPeriod = ArrayHelper::getColumn($employeesTotal, 'admin_id');
$positionMap = $this->buildPositionMap($adminIdsInPeriod);
+ $adminSkillMap = $this->buildAdminSkillMap($adminIdsInPeriod);
foreach ($data->date as $ind => $dateStartEnd) {
$currentDate = $dateStartEnd[0];
}
}
+ // Расчет уровня обученности на неделю
+ $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,
"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];
$adminIdsInPeriod = ArrayHelper::getColumn($employeesTotal, 'admin_id');
$positionMap = $this->buildPositionMap($adminIdsInPeriod);
+ $adminSkillMap = $this->buildAdminSkillMap($adminIdsInPeriod);
foreach ($days as $ind => $day) {
}
}
+ // Расчет уровня обученности за день
+ $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,
"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,
--- /dev/null
+<?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.');
+ }
+}
+
--- /dev/null
+<?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);
+ }
+}
+
--- /dev/null
+<?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;
+ }
+}
+
--- /dev/null
+<?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;
+ }
+}
+
--- /dev/null
+<?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
+);
+?>
+
--- /dev/null
+<?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>
+
--- /dev/null
+<?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>
+
--- /dev/null
+<?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>
+
--- /dev/null
+<?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>
+
--- /dev/null
+<?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>
+
--- /dev/null
+<?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>
+