From: Vladimir Fomichev Date: Thu, 13 Nov 2025 14:32:57 +0000 (+0300) Subject: Штатное расписание X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=4fd75c1f57b694684aa1afd15dc5555428127e7d;p=erp24_rep%2Fyii-erp24%2F.git Штатное расписание --- diff --git a/erp24/api3/core/services/ReportService.php b/erp24/api3/core/services/ReportService.php index e6188d0a..5bda96d7 100644 --- a/erp24/api3/core/services/ReportService.php +++ b/erp24/api3/core/services/ReportService.php @@ -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 index 00000000..13074714 --- /dev/null +++ b/erp24/controllers/StoreStaffingController.php @@ -0,0 +1,515 @@ + [ + '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 index 00000000..23dff7c8 --- /dev/null +++ b/erp24/records/StoreStaffing.php @@ -0,0 +1,139 @@ + 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 index 00000000..6fa1f1aa --- /dev/null +++ b/erp24/records/StoreStaffingLog.php @@ -0,0 +1,119 @@ + 'Создание', + 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 index 00000000..f9e05dff --- /dev/null +++ b/erp24/records/StoreStaffingSearch.php @@ -0,0 +1,85 @@ + $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 index 00000000..169d119f --- /dev/null +++ b/erp24/views/store-staffing/_form.php @@ -0,0 +1,76 @@ + + +
+ + + + field($model, 'store_id')->dropDownList($stores, ['prompt' => 'Выберите магазин']) ?> + + field($model, 'employee_position_id')->dropDownList($positions, [ + 'prompt' => 'Выберите должность', + 'id' => 'position-id', + ]) ?> + +
+ Грейд (посит) должности: - +
+ + field($model, 'count')->textInput(['type' => 'number', 'min' => 0.1, 'step' => 0.1]) ?> + +
+ 'btn btn-success']) ?> + 'btn btn-secondary']) ?> +
+ + + +
+ +registerJs(<< 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 index 00000000..54118092 --- /dev/null +++ b/erp24/views/store-staffing/create.php @@ -0,0 +1,25 @@ +title = 'Создать запись штатного расписания'; +$this->params['breadcrumbs'][] = ['label' => 'Штатное расписание', 'url' => ['index']]; +$this->params['breadcrumbs'][] = $this->title; +?> +
+ +

title) ?>

+ + render('_form', [ + 'model' => $model, + 'stores' => $stores, + 'positions' => $positions, + ]) ?> + +
+ diff --git a/erp24/views/store-staffing/import.php b/erp24/views/store-staffing/import.php new file mode 100644 index 00000000..2e9150bc --- /dev/null +++ b/erp24/views/store-staffing/import.php @@ -0,0 +1,42 @@ +title = 'Импорт штатного расписания из Excel'; +$this->params['breadcrumbs'][] = ['label' => 'Штатное расписание', 'url' => ['index']]; +$this->params['breadcrumbs'][] = $this->title; +?> +
+ +

title) ?>

+ +
+

Инструкция

+

Загрузите файл Excel с расширением .xls или .xlsx

+

Формат файла:

+
    +
  • Колонка A (store_name): Название магазина (должно совпадать с названием в системе)
  • +
  • Колонка B (position_name): Название должности (должно совпадать с названием в системе)
  • +
  • Колонка C (posit): Грейд должности (уровень обученности)
  • +
  • Колонка D (count): Количество сотрудников в этой позиции
  • +
+

'btn btn-sm btn-info']) ?>

+
+ + ['enctype' => 'multipart/form-data']]); ?> + + field($model, 'excelFile')->fileInput() ?> + +
+ 'btn btn-success']) ?> + 'btn btn-secondary']) ?> +
+ + + +
+ diff --git a/erp24/views/store-staffing/index.php b/erp24/views/store-staffing/index.php new file mode 100644 index 00000000..0d1388c3 --- /dev/null +++ b/erp24/views/store-staffing/index.php @@ -0,0 +1,125 @@ +title = 'Штатное расписание по магазинам'; +$this->params['breadcrumbs'][] = $this->title; +?> +
+ +

title) ?>

+ +

+ 'btn btn-success']) ?> + 'btn btn-primary']) ?> + 'btn btn-info', 'target' => '_blank']) ?> + 'btn btn-warning']) ?> +

+ + render('_search', ['model' => $searchModel]); ?> + + $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}', + ], + ], + ]); ?> + +

Средний уровень обученности по магазинам

+ + + + + + + + + + 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 ''; + echo ''; + echo ''; + echo ''; + } + ?> + +
МагазинСредний уровень (посит)
' . Html::a($data['storeName'], ['logs', 'store_id' => $storeId]) . '' . $avgScore . '
+ +
+ diff --git a/erp24/views/store-staffing/logs.php b/erp24/views/store-staffing/logs.php new file mode 100644 index 00000000..9ef6cf4c --- /dev/null +++ b/erp24/views/store-staffing/logs.php @@ -0,0 +1,94 @@ +title = 'Логи изменений штатного расписания'; +$this->params['breadcrumbs'][] = ['label' => 'Штатное расписание', 'url' => ['index']]; +$this->params['breadcrumbs'][] = $this->title; +?> +
+ +

title) ?>

+ +

+ 'btn btn-secondary']) ?> +

+ +
+
+
+
+ 'form-control', + 'prompt' => 'Все магазины', + ]) ?> +
+
+ 'btn btn-primary']) ?> +
+
+
+
+ + $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 : 'Система'; + }, + ], + ], + ]); ?> + +
+ diff --git a/erp24/views/store-staffing/update.php b/erp24/views/store-staffing/update.php new file mode 100644 index 00000000..1c50ea50 --- /dev/null +++ b/erp24/views/store-staffing/update.php @@ -0,0 +1,26 @@ +title = 'Редактировать запись: ' . $model->id; +$this->params['breadcrumbs'][] = ['label' => 'Штатное расписание', 'url' => ['index']]; +$this->params['breadcrumbs'][] = ['label' => $model->id, 'url' => ['view', 'id' => $model->id]]; +$this->params['breadcrumbs'][] = 'Редактировать'; +?> +
+ +

title) ?>

+ + render('_form', [ + 'model' => $model, + 'stores' => $stores, + 'positions' => $positions, + ]) ?> + +
+ diff --git a/erp24/views/store-staffing/view.php b/erp24/views/store-staffing/view.php new file mode 100644 index 00000000..fe2a2785 --- /dev/null +++ b/erp24/views/store-staffing/view.php @@ -0,0 +1,59 @@ +title = $model->id; +$this->params['breadcrumbs'][] = ['label' => 'Штатное расписание', 'url' => ['index']]; +$this->params['breadcrumbs'][] = $this->title; +\yii\web\YiiAsset::register($this); +?> +
+ +

title) ?>

+ +

+ $model->id], ['class' => 'btn btn-primary']) ?> + $model->id], [ + 'class' => 'btn btn-danger', + 'data' => [ + 'confirm' => 'Вы уверены?', + 'method' => 'post', + ], + ]) ?> + 'btn btn-secondary']) ?> +

+ + $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', + ], + ]) ?> + +
+