first commit

This commit is contained in:
exercict
2026-07-14 08:10:11 +04:00
commit fdfb0dd62e
60 changed files with 9930 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Services\Auth;
class LdapService
{
public function authenticate(string $login, string $password): array|false
{
$config = require __DIR__ . '/../../../config/ldap.php';
$host = $config['host'];
$port = $config['port'];
$baseDn = $config['base_dn'];
$domain = $config['domain'];
$adminGroupName = $config['admin_group_name'] ?? 'ИТ-Отдел';
$connection = ldap_connect("ldap://{$host}:{$port}");
if (!$connection) {
return false;
}
ldap_set_option($connection, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_set_option($connection, LDAP_OPT_REFERRALS, 0);
$bindRdn = $login . '@' . $domain;
$bind = @ldap_bind($connection, $bindRdn, $password);
if (!$bind) {
ldap_unbind($connection);
return false;
}
$filter = sprintf('(sAMAccountName=%s)', ldap_escape($login, '', LDAP_ESCAPE_FILTER));
$attributes = ['cn', 'displayName', 'mail', 'sAMAccountName', 'memberOf'];
$search = @ldap_search($connection, $baseDn, $filter, $attributes);
if (!$search) {
ldap_unbind($connection);
return [
'login' => $login,
'display_name' => $login,
'email' => null,
'groups' => [],
'is_admin' => false,
];
}
$entries = ldap_get_entries($connection, $search);
ldap_unbind($connection);
if (($entries['count'] ?? 0) < 1) {
return [
'login' => $login,
'display_name' => $login,
'email' => null,
'groups' => [],
'is_admin' => false,
];
}
$entry = $entries[0];
$groups = [];
if (!empty($entry['memberof']) && is_array($entry['memberof'])) {
for ($i = 0; $i < ($entry['memberof']['count'] ?? 0); $i++) {
$dn = $entry['memberof'][$i];
if (preg_match('/CN=([^,]+)/u', $dn, $matches)) {
$groups[] = $matches[1];
}
}
}
$isAdmin = false;
foreach ($groups as $group) {
if (mb_strtolower(trim($group)) === mb_strtolower(trim($adminGroupName))) {
$isAdmin = true;
break;
}
}
return [
'login' => $entry['samaccountname'][0] ?? $login,
'display_name' => $entry['displayname'][0] ?? $entry['cn'][0] ?? $login,
'email' => $entry['mail'][0] ?? null,
'groups' => $groups,
'is_admin' => $isAdmin,
];
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Services\Google;
use App\Core\DB;
use PDO;
class GoogleExportService
{
public function exportTaskById(int $taskId): bool
{
$pdo = DB::connection();
$stmt = $pdo->prepare("
SELECT t.*, b.code AS board_code
FROM tasks t
LEFT JOIN boards b ON b.id = t.board_id
WHERE t.id = ?
LIMIT 1
");
$stmt->execute([$taskId]);
$task = $stmt->fetch();
if (!$task) {
return false;
}
// Пока экспортируем только задачи доски снабжения
if (($task['board_code'] ?? '') !== 'supply') {
return false;
}
$sheets = new GoogleSheetsService();
$row = [
$task['name'] ?? '', // A Наименование
$task['quantity'] ?? '', // B кол-во, шт
$task['balance_tn'] ?? '', // C Остаток, тн
$task['order_number'] ?? '', // D Заказ, №
$task['order_amount'] ?? '', // E Сумма заказа
$task['applicant'] ?? '', // F Фамилия
$task['request_date'] ?? '', // G дата заявки
$task['transport_company'] ?? '', // H ТК
$task['supplier'] ?? '', // I Поставщик
$task['invoice_number'] ?? '', // J № счета
$task['payment_date'] ?? '', // K Дата оплаты
$task['delivery_date'] ?? '', // L дата поставки
$task['received_by'] ?? '', // M Груз получил
((int)($task['completed_flag'] ?? 0) === 1) ? 'TRUE' : 'FALSE', // N Выполнено
];
$rowNumber = $sheets->findFirstEmptyRow();
$sheets->updateRow($rowNumber, $row);
if ($rowNumber > 0) {
$stmt = $pdo->prepare("
UPDATE tasks
SET
google_row_id = ?,
source_type = 'google',
updated_at = NOW()
WHERE id = ?
");
$stmt->execute([(string)$rowNumber, $taskId]);
}
return true;
}
}
+389
View File
@@ -0,0 +1,389 @@
<?php
declare(strict_types=1);
namespace App\Services\Google;
use App\Core\DB;
use DateTime;
use PDO;
class GoogleImportService
{
public function import(): array
{
$pdo = DB::connection();
$stmt = $pdo->query("
SELECT *
FROM board_sources
WHERE source_type = 'google_sheet'
AND is_active = 1
AND sync_mode IN ('import', 'import_export')
");
$sources = $stmt->fetchAll();
$totalCreated = 0;
$totalUpdated = 0;
$totalSkipped = 0;
$totalRows = 0;
foreach ($sources as $source) {
$result = $this->importBoard($pdo, $source);
$totalCreated += $result['created'];
$totalUpdated += $result['updated'];
$totalSkipped += $result['skipped'];
$totalRows += $result['total'];
}
return [
'created' => $totalCreated,
'updated' => $totalUpdated,
'skipped' => $totalSkipped,
'total' => $totalRows,
];
}
private function importBoard(PDO $pdo, array $source): array
{
$boardId = (int)$source['board_id'];
$spreadsheetId = (string)$source['source_key'];
$sheetName = (string)$source['sheet_name'];
$notificationService = new \App\Services\NotificationService();
$userIds = $this->getAllUserIds($pdo);
$stmt = $pdo->prepare("
SELECT target_type, target_key, source_column_name
FROM board_source_mappings
WHERE board_source_id = ?
AND is_active = 1
");
$stmt->execute([$source['id']]);
$mappings = $stmt->fetchAll();
if (empty($mappings)) {
return ['created' => 0, 'updated' => 0, 'skipped' => 0, 'total' => 0];
}
$sheets = new GoogleSheetsService($spreadsheetId, $sheetName);
$rows = $sheets->getRowsRaw();
$created = 0;
$updated = 0;
$skipped = 0;
$publisher = new \App\Services\WebSocket\EventPublisher();
$stmt = $pdo->prepare("SELECT code FROM boards WHERE id = ? LIMIT 1");
$stmt->execute([$boardId]);
$boardCode = (string)($stmt->fetchColumn() ?: '');
foreach ($rows as $row) {
$rowNumber = (int)$row['_row_number'];
$base = [
'name' => '',
'creator_name' => null,
'assignee_name' => null,
'description' => null,
'status' => 'NEW',
'priority' => 'MEDIUM',
'crm_id' => null,
'completed_flag' => 0,
'task_created_at' => date('Y-m-d H:i:s'),
'planned_at' => null,
'completed_at' => null,
];
$custom = [];
foreach ($mappings as $map) {
$col = strtoupper((string)$map['source_column_name']);
$value = $row[$col] ?? null;
if ($map['target_type'] === 'base') {
$this->applyBase($base, (string)$map['target_key'], $value);
}
if ($map['target_type'] === 'custom') {
$custom[(string)$map['target_key']] = trim((string)$value);
}
}
$normalizedName = mb_strtolower(trim((string)$base['name']));
if (
$normalizedName === '' ||
$normalizedName === 'наименование детали' ||
$normalizedName === 'наименование'
) {
$skipped++;
continue;
}
$base['status'] = ((int)$base['completed_flag'] === 1) ? 'DONE' : 'IN_PROGRESS';
$existing = $this->findByRow($pdo, $boardId, $rowNumber);
// if (!$existing) {
// $existing = $this->findByName($pdo, $boardId, $base['name']);
// }
if ($existing) {
$taskId = (int)$existing['id'];
$stmt = $pdo->prepare("
UPDATE tasks SET
google_row_id = ?,
name = ?,
description = ?,
creator_name = ?,
assignee_name = ?,
priority = ?,
completed_flag = ?,
task_created_at = ?,
planned_at = ?,
completed_at = ?,
updated_at = NOW()
WHERE id = ?
");
$stmt->execute([
$rowNumber,
$base['name'],
$base['description'],
$base['creator_name'],
$base['assignee_name'],
$base['status'],
$base['priority'],
$base['completed_flag'],
$base['task_created_at'],
$base['planned_at'],
$base['completed_at'],
$taskId
]);
$this->saveCustom($pdo, $taskId, $boardId, $custom);
$updated++;
$publisher->publish([
'type' => 'task_updated',
'task_id' => $taskId,
'board_id' => $boardId,
'board_code' => $boardCode,
'name' => $base['name'],
'status' => $base['status'],
'updated_at' => date('Y-m-d H:i:s'),
]);
} else {
$crmId = !empty($base['crm_id'])
? (string)$base['crm_id']
: $this->generateCrmId();
$stmt = $pdo->prepare("
INSERT INTO tasks (
crm_id, board_id, google_row_id, source_type,
name, description, creator_name, assignee_name,
status, priority, completed_flag,
task_created_at, planned_at, completed_at,
created_at, updated_at
) VALUES (?, ?, ?, 'google', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
");
$stmt->execute([
$crmId,
$boardId,
$rowNumber,
$base['name'],
$base['description'],
$base['creator_name'],
$base['assignee_name'],
$base['status'],
$base['priority'],
$base['completed_flag'],
$base['task_created_at'],
$base['planned_at'],
$base['completed_at'],
]);
$taskId = (int)$pdo->lastInsertId();
$this->saveCustom($pdo, $taskId, $boardId, $custom);
$created++;
$notificationService->createForUsers(
$userIds,
'task_created',
'Новая задача',
$base['name'],
[
'task_id' => $taskId,
'board_id' => $boardId,
'board_code' => $boardCode,
'crm_id' => $crmId,
]
);
$publisher->publish([
'type' => 'task_created',
'task_id' => $taskId,
'board_id' => $boardId,
'board_code' => $boardCode,
'name' => $base['name'],
'crm_id' => $crmId,
'created_at' => date('Y-m-d H:i:s'),
]);
}
}
$publisher->publish([
'type' => 'import_finished',
'board_id' => $boardId,
'board_code' => $boardCode,
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'total' => count($rows),
'finished_at' => date('Y-m-d H:i:s'),
]);
// if ($created > 0 || $updated > 0) {
// $notificationService->createForUsers(
// $userIds,
// 'import_finished',
// 'Импорт завершен',
// 'Новых: ' . $created . ', обновлено: ' . $updated,
// [
// 'board_id' => $boardId,
// 'board_code' => $boardCode,
// 'created' => $created,
// 'updated' => $updated,
// ]
// );
// }
return [
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'total' => count($rows)
];
}
private function applyBase(array &$base, string $key, mixed $value): void
{
$value = trim((string)$value);
switch ($key) {
case 'name': $base['name'] = $value; break;
case 'description': $base['description'] = $value ?: null; break;
case 'creator_name': $base['creator_name'] = $value ?: null; break;
case 'assignee_name': $base['assignee_name'] = $value ?: null; break;
case 'crm_id': $base['crm_id'] = $value ?: null; break;
case 'completed_flag': $base['completed_flag'] = $this->toBool($value); break;
case 'task_created_at':
$base['task_created_at'] = $this->normalizeImportDateTime($value, true);
break;
case 'planned_at':
$base['planned_at'] = $this->normalizeImportDateTime($value, false);
break;
case 'completed_at':
$base['completed_at'] = $this->normalizeImportDateTime($value, false);
break;
}
}
private function saveCustom(PDO $pdo, int $taskId, int $boardId, array $data): void
{
if (!$data) return;
$stmt = $pdo->prepare("
SELECT id, code FROM board_fields WHERE board_id = ?
");
$stmt->execute([$boardId]);
$fields = $stmt->fetchAll();
$map = [];
foreach ($fields as $f) $map[$f['code']] = $f['id'];
foreach ($data as $code=>$val) {
if (!isset($map[$code])) continue;
$stmt = $pdo->prepare("
INSERT INTO task_field_values (task_id, field_id, value_text)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE value_text = VALUES(value_text)
");
$stmt->execute([$taskId, $map[$code], $val]);
}
}
private function findByRow(PDO $pdo, int $boardId, int $row): array|false
{
$stmt = $pdo->prepare("
SELECT id FROM tasks WHERE board_id=? AND google_row_id=?
");
$stmt->execute([$boardId, $row]);
return $stmt->fetch();
}
private function findByName(PDO $pdo, int $boardId, string $name): array|false
{
$stmt = $pdo->prepare("
SELECT id FROM tasks WHERE board_id=? AND name=? LIMIT 1
");
$stmt->execute([$boardId, $name]);
return $stmt->fetch();
}
private function generateCrmId(): string
{
return 'CRM-' . date('Ymd-His') . '-' . bin2hex(random_bytes(2));
}
private function toBool($v): int
{
$v = mb_strtolower(trim((string)$v));
return in_array($v,['1','true','да']) ? 1 : 0;
}
private function getAllUserIds(PDO $pdo): array
{
$stmt = $pdo->query("SELECT id FROM users");
return array_map('intval', array_column($stmt->fetchAll(), 'id'));
}
private function normalizeImportDateTime(mixed $value, bool $useNowIfEmpty = false): ?string
{
$value = trim((string)$value);
if ($value === '') {
return $useNowIfEmpty ? date('Y-m-d H:i:s') : null;
}
$formats = [
'd.m.Y H:i:s',
'd.m.Y H:i',
'Y-m-d H:i:s',
'Y-m-d H:i',
'd.m.Y',
'Y-m-d',
];
foreach ($formats as $format) {
$date = \DateTime::createFromFormat($format, $value);
if ($date instanceof \DateTime) {
if ($format === 'd.m.Y' || $format === 'Y-m-d') {
return $date->format('Y-m-d') . ' 00:00:00';
}
return $date->format('Y-m-d H:i:s');
}
}
return $useNowIfEmpty ? date('Y-m-d H:i:s') : null;
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Services\Google;
use Google\Client;
use Google\Service\Sheets;
use GuzzleHttp\Client as GuzzleClient;
class GoogleSheetsService
{
private Sheets $service;
private string $spreadsheetId;
private string $sheetName;
public function __construct(string $spreadsheetId, string $sheetName)
{
$guzzle = new GuzzleClient([
'curl' => [
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
]
]);
$client = new Client();
$client->setHttpClient($guzzle);
$client->setAuthConfig(__DIR__ . '/../../../storage/google-service-account.json');
$client->setScopes([Sheets::SPREADSHEETS]);
$this->service = new Sheets($client);
$this->spreadsheetId = $spreadsheetId;
$this->sheetName = $sheetName;
}
public function getRowsRaw(): array
{
$range = "'{$this->sheetName}'!A:Z";
$response = $this->service->spreadsheets_values->get(
$this->spreadsheetId,
$range
);
$values = $response->getValues() ?? [];
$rows = [];
foreach ($values as $i => $row) {
$rowNum = $i + 1;
if ($rowNum === 1) continue;
$item = ['_row_number' => $rowNum];
foreach (range('A','Z') as $idx=>$col) {
$item[$col] = $row[$idx] ?? null;
}
$rows[] = $item;
}
return $rows;
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Core\DB;
class NotificationService
{
public function createForUsers(array $userIds, string $type, string $title, ?string $message = null, ?array $payload = null): void
{
$userIds = array_values(array_unique(array_map('intval', $userIds)));
$userIds = array_filter($userIds, fn($id) => $id > 0);
if (empty($userIds)) {
return;
}
$pdo = DB::connection();
$stmt = $pdo->prepare("
INSERT INTO notifications (
user_id,
type,
title,
message,
payload_json,
is_read,
created_at
) VALUES (?, ?, ?, ?, ?, 0, NOW())
");
$payloadJson = $payload ? json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null;
foreach ($userIds as $userId) {
$stmt->execute([
$userId,
$type,
$title,
$message,
$payloadJson,
]);
}
}
public function getUnreadCount(int $userId): int
{
$pdo = DB::connection();
$stmt = $pdo->prepare("
SELECT COUNT(*)
FROM notifications
WHERE user_id = ?
AND is_read = 0
");
$stmt->execute([$userId]);
return (int)$stmt->fetchColumn();
}
public function getLatest(int $userId, int $limit = 20): array
{
$pdo = DB::connection();
$stmt = $pdo->prepare("
SELECT *
FROM notifications
WHERE user_id = ?
ORDER BY id DESC
LIMIT ?
");
$stmt->bindValue(1, $userId, \PDO::PARAM_INT);
$stmt->bindValue(2, $limit, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
public function markAllRead(int $userId): void
{
$pdo = DB::connection();
$stmt = $pdo->prepare("
UPDATE notifications
SET is_read = 1,
read_at = NOW()
WHERE user_id = ?
AND is_read = 0
");
$stmt->execute([$userId]);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Services\WebSocket;
class EventPublisher
{
private string $queueFile;
public function __construct()
{
$this->queueFile = __DIR__ . '/../../../storage/ws/events.log';
}
public function publish(array $payload): void
{
$dir = dirname($this->queueFile);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$line = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($line === false) {
return;
}
file_put_contents($this->queueFile, $line . PHP_EOL, FILE_APPEND | LOCK_EX);
}
}