init
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log
|
||||
pnpm-debug.log
|
||||
.env
|
||||
.DS_Store
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
node_modules
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Используем Node.js 20
|
||||
FROM node:20-alpine
|
||||
|
||||
# Устанавливаем рабочую директорию внутри контейнера
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем package.json и pnpm-lock.yaml
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Устанавливаем pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Устанавливаем зависимости
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Копируем весь проект
|
||||
COPY . .
|
||||
|
||||
# Собираем приложение
|
||||
RUN pnpm run build
|
||||
|
||||
# Указываем переменные окружения (можно переопределять при запуске)
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Запускаем приложение
|
||||
CMD ["pnpm", "run", "start"]
|
||||
@@ -0,0 +1,34 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: tracker_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASS}
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
ports:
|
||||
- "${DB_PORT}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
app:
|
||||
build: .
|
||||
container_name: tracker_app
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASS: ${DB_PASS}
|
||||
DB_NAME: ${DB_NAME}
|
||||
NODE_ENV: production
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "tracker",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "npx nodemon --exec esno src/main.ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@hcengineering/api-client": "^0.6.466",
|
||||
"@hcengineering/client": "^0.6.466",
|
||||
"@hcengineering/contact": "^0.6.466",
|
||||
"@hcengineering/core": "^0.6.466",
|
||||
"@hcengineering/document": "^0.6.466",
|
||||
"@hcengineering/rank": "^0.6.466",
|
||||
"@hcengineering/tracker": "^0.6.466",
|
||||
"@nestjs/common": "^11.0.11",
|
||||
"@nestjs/config": "^4.0.1",
|
||||
"@nestjs/core": "^11.0.11",
|
||||
"@nestjs/platform-express": "^11.0.11",
|
||||
"@nestjs/schedule": "^5.0.1",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"esno": "^4.8.0",
|
||||
"pg": "^8.14.0",
|
||||
"typeorm": "^0.3.21",
|
||||
"ws": "^8.18.1",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/ws": "^8.18.0",
|
||||
"nodemon": "^3.1.9"
|
||||
}
|
||||
}
|
||||
Generated
+3565
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { SyncModule } from './sync/sync.module';
|
||||
import { ReportsModule } from './reports/reports.module';
|
||||
import { TasksModule } from './tasks/tasks.module';
|
||||
|
||||
// Импорт ваших Entities
|
||||
import { Person } from './entities/person.entity';
|
||||
import { TaskStatus } from './entities/task-status.entity';
|
||||
import { Task } from './entities/task.entity';
|
||||
import { TrackerTransaction } from './entities/transaction-tracker.entity';
|
||||
import { TimeSpentReport } from './entities/type-spent-report.entity';
|
||||
import { config } from './config/config';
|
||||
import { TaskClosure } from './entities/task-closure.entity';
|
||||
import { SyncState } from './entities/sync-state.entity';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ReportsModule,
|
||||
|
||||
ScheduleModule.forRoot(),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'postgres',
|
||||
host: config.db.hostname,
|
||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||
username: config.db.username,
|
||||
password: config.db.password,
|
||||
database: config.db.database,
|
||||
entities: [Person, TaskStatus, Task, TrackerTransaction, TimeSpentReport, TaskClosure, SyncState],
|
||||
synchronize: true, // В продакшене лучше выключить и использовать миграции
|
||||
}),
|
||||
SyncModule,
|
||||
TasksModule
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,41 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { z } from 'zod';
|
||||
import path from 'path';
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, '../../.env') });
|
||||
|
||||
const envVarsSchema = z.object({
|
||||
DB_HOST: z.string().default('localhost'),
|
||||
DB_PORT: z.string().default('5432').transform(Number),
|
||||
DB_USER: z.string().default('postgres'),
|
||||
DB_PASS: z.string().default('postgres'),
|
||||
DB_NAME: z.string().default('tracker_db'),
|
||||
|
||||
HULY_URL: z.string().url(),
|
||||
HULY_EMAIL: z.string().email(),
|
||||
HULY_PASSWORD: z.string(),
|
||||
HULY_WORKSPACE: z.string().default('-'),
|
||||
});
|
||||
|
||||
const envVars = envVarsSchema.safeParse(process.env);
|
||||
|
||||
if (!envVars.success) {
|
||||
console.error('❌ Ошибка конфигурации:\n', envVars.error.format());
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
db: {
|
||||
hostname: envVars.data.DB_HOST,
|
||||
port: envVars.data.DB_PORT,
|
||||
username: envVars.data.DB_USER,
|
||||
password: envVars.data.DB_PASS,
|
||||
database: envVars.data.DB_NAME,
|
||||
},
|
||||
huly: {
|
||||
url: envVars.data.HULY_URL,
|
||||
email: envVars.data.HULY_EMAIL,
|
||||
password: envVars.data.HULY_PASSWORD,
|
||||
workspace: envVars.data.HULY_WORKSPACE,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ClientSocket, ClientSocketFactory } from "@hcengineering/client";
|
||||
import { config } from "./config";
|
||||
import { WebSocket } from 'ws'
|
||||
|
||||
// Настраиваем ws
|
||||
const socketFactory: ClientSocketFactory = (url: string) => {
|
||||
const ws = new WebSocket(url);
|
||||
return ws as ClientSocket;
|
||||
};
|
||||
|
||||
export const options = {
|
||||
email: config.huly.email,
|
||||
password: config.huly.password,
|
||||
workspace: config.huly.workspace,
|
||||
socketFactory,
|
||||
connectionTimeout: 60000,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
|
||||
|
||||
@Entity('persons')
|
||||
export class Person {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
// externalId — поле, где храним _id из трекера
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
externalId!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
city!: string;
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
createdOn!: Date;
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
modifiedOn!: Date;
|
||||
|
||||
// Могут быть и другие поля — github-url и т.д.
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// src/entities/sync-state.entity.ts
|
||||
|
||||
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
|
||||
|
||||
@Entity('sync_state')
|
||||
export class SyncState {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
// Тип транзакции, например "TxCreateDoc" или "TxUpdateDoc"
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
txType!: string;
|
||||
|
||||
// Последняя учтённая дата (timestamp)
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
lastSynced!: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// src/entities/task-closure.entity.ts
|
||||
|
||||
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
|
||||
|
||||
@Entity('task_closures')
|
||||
export class TaskClosure {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
// Уникальный ID задачи в трекере
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
externalTaskId!: string;
|
||||
|
||||
// Когда задача была закрыта (из транзакции createdOn)
|
||||
@Column({ type: 'timestamp' })
|
||||
closedOn!: Date;
|
||||
|
||||
// Если задача "переоткрыта" – ставим false
|
||||
@Column({ default: true, type: 'bool' })
|
||||
active!: boolean;
|
||||
|
||||
// --- новые поля для "заголовка" и т.д.
|
||||
@Column({ type: 'varchar', length: 1024, nullable: true })
|
||||
title!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
identifier!: string | null; // TSK-123, к примеру
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
number!: number | null; // если нужно
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
|
||||
|
||||
@Entity('task_statuses')
|
||||
export class TaskStatus {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
externalId!: string; // _id из трекера
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
category!: string | null; // task:statusCategory:Won и т.п.
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Person } from './person.entity';
|
||||
import { TaskStatus } from './task-status.entity';
|
||||
|
||||
@Entity('tasks')
|
||||
export class Task {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
externalId!: string; // objectId из трекера
|
||||
|
||||
@Column({ type: 'text' })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
// Прогнозное время (estimation)
|
||||
@Column({ type: 'float', default: 0 })
|
||||
estimation!: number;
|
||||
|
||||
@ManyToOne(() => TaskStatus, { eager: true })
|
||||
@JoinColumn()
|
||||
status!: TaskStatus; // связь с таблицей статусов
|
||||
|
||||
@ManyToOne(() => Person, { nullable: true, eager: true })
|
||||
@JoinColumn()
|
||||
assignee!: Person | null; // текущий исполнитель
|
||||
|
||||
// Флаг, показывающий, что задача закрыта
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isDone!: boolean;
|
||||
|
||||
// Точное время закрытия задачи (по транзакции), если есть
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
closedOn!: Date | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
createdOn!: Date; // из транзакции или атрибутов
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
modifiedOn!: Date; // последнее изменение в задаче
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
|
||||
|
||||
@Entity('tracker_transactions')
|
||||
export class TrackerTransaction {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
// Уникальный ID транзакции из трекера
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
externalId!: string; // _id
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
type!: string; // TxCreateDoc / TxUpdateDoc
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
objectId!: string; // для задач — objectId = externalId задачи
|
||||
|
||||
// payload будет хранить либо attributes (для create), либо operations (для update)
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
payload!: Record<string, any> | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
createdOn!: Date; // время создания транзакции (из трекера)
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
modifiedOn!: Date; // время изменения транзакции (из трекера)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn
|
||||
} from 'typeorm';
|
||||
|
||||
import { Task } from './task.entity';
|
||||
import { Person } from './person.entity';
|
||||
import type { DataTypeDefaults } from 'typeorm/driver/types/DataTypeDefaults';
|
||||
|
||||
@Entity('time_spent_reports')
|
||||
export class TimeSpentReport {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
externalId!: string; // _id из трекера
|
||||
|
||||
@ManyToOne(() => Task, { eager: true })
|
||||
@JoinColumn()
|
||||
task!: Task;
|
||||
|
||||
// исполнитель, если указан
|
||||
@ManyToOne(() => Person, { nullable: true, eager: true })
|
||||
@JoinColumn()
|
||||
employee!: Person | null;
|
||||
|
||||
// фактически потраченное время
|
||||
@Column({ type: 'float' })
|
||||
value!: number; // часы/условные единицы
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
date!: Date;
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
createdOn!: Date;
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
modifiedOn!: Date;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { TrackerSyncService } from './sync/sync.service';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(3000);
|
||||
console.log(`App listening on port 3000`);
|
||||
|
||||
// Получаем экземпляр TrackerSyncService и вызываем syncAllData
|
||||
const trackerSyncService = app.get(TrackerSyncService);
|
||||
await trackerSyncService.syncAllData();
|
||||
console.log(`Initial data sync completed.`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,35 @@
|
||||
// src/reports/reports.controller.ts
|
||||
|
||||
import { Controller, Get, Inject, Query } from '@nestjs/common';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@Controller('reports')
|
||||
export class ReportsController {
|
||||
constructor(@Inject(ReportsService) private readonly reportsService: ReportsService) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Пример:
|
||||
* GET /reports/completed?start=1741900000000&end=1741959999999
|
||||
*/
|
||||
@Get('completed')
|
||||
async getCompleted(
|
||||
@Query('start') start: string,
|
||||
@Query('end') end: string
|
||||
) {
|
||||
|
||||
if (!start || !end) {
|
||||
return {
|
||||
error: 'Invalid start/end timestamps',
|
||||
};
|
||||
}
|
||||
|
||||
const data = await this.reportsService.getClosedReport(start, end);
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
data,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// src/reports/reports.module.ts
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ReportsController } from './reports.controller';
|
||||
import { ReportsService } from './reports.service';
|
||||
import { Task } from '../entities/task.entity';
|
||||
import { TimeSpentReport } from '../entities/type-spent-report.entity';
|
||||
import { Person } from '../entities/person.entity';
|
||||
import { TaskClosure } from '../entities/task-closure.entity';
|
||||
import { SyncModule } from '../sync/sync.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Task, TimeSpentReport, Person, TaskClosure]),
|
||||
SyncModule
|
||||
],
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService],
|
||||
exports: [ReportsService],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
@@ -0,0 +1,214 @@
|
||||
// src/reports/reports.service.ts
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Between, Repository } from 'typeorm';
|
||||
|
||||
import { TaskClosure } from '../entities/task-closure.entity';
|
||||
import { connect } from '@hcengineering/api-client';
|
||||
import tracker from '@hcengineering/tracker';
|
||||
import contact from '@hcengineering/contact';
|
||||
import { options } from '../config/options';
|
||||
import { config } from '../config/config';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
private client: any;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(TaskClosure)
|
||||
private readonly closureRepo: Repository<TaskClosure>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Инициализация клиента
|
||||
*/
|
||||
async initClient() {
|
||||
if (!this.client) {
|
||||
this.client = await connect(config.huly.url, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Формируем отчёт по закрытым задачам (TaskClosure) в [startIso, endIso].
|
||||
* Для каждой задачи (Issue) добавляем title, description (markdown), estimation, identifier.
|
||||
* Сгруппированные по сотрудникам (employee) записи TimeSpendReport дают spentTime и descriptions.
|
||||
*
|
||||
* Итоговая структура (упрощённо):
|
||||
* [
|
||||
* {
|
||||
* person: { id, name, email },
|
||||
* tasks: [
|
||||
* {
|
||||
* taskId,
|
||||
* identifier,
|
||||
* title,
|
||||
* taskDescription,
|
||||
* estimation,
|
||||
* spentTime,
|
||||
* descriptions: [...]
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
* ]
|
||||
*/
|
||||
async getClosedReport(startIso: string, endIso: string) {
|
||||
await this.initClient();
|
||||
|
||||
const startDate = new Date(startIso);
|
||||
const endDate = new Date(endIso);
|
||||
|
||||
// 1) Из локальной БД берем только те задачи, которые были закрыты (active) в заданном интервале
|
||||
const closures = await this.closureRepo.find({
|
||||
where: {
|
||||
active: true,
|
||||
closedOn: Between(startDate, endDate),
|
||||
},
|
||||
});
|
||||
if (!closures.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2) Собираем уникальные ID задач
|
||||
const taskIds = closures.map((c) => c.externalTaskId);
|
||||
|
||||
// 3) Загружаем задачи (Issue) из трекера
|
||||
const issues = await this.client.findAll(
|
||||
tracker.class.Issue,
|
||||
{ _id: { $in: taskIds } },
|
||||
{ limit: 2000 },
|
||||
);
|
||||
|
||||
// Пример: подгружаем markdown-разметку описания
|
||||
for (const issue of issues) {
|
||||
if (issue.description) {
|
||||
issue.description = await this.client.fetchMarkup(
|
||||
issue._class,
|
||||
issue._id,
|
||||
'description',
|
||||
issue.description,
|
||||
'markdown'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем карту для быстрого доступа: taskId => issueData
|
||||
const issueMap = new Map<string, any>();
|
||||
for (const iss of issues) {
|
||||
issueMap.set(iss._id, iss);
|
||||
}
|
||||
|
||||
// 4) Загружаем TimeSpendReport для этих же задач
|
||||
const timeReports = await this.client.findAll(
|
||||
tracker.class.TimeSpendReport,
|
||||
{ attachedTo: { $in: taskIds } },
|
||||
{ limit: 9999 },
|
||||
);
|
||||
|
||||
// 5) Группируем timeReports по сотрудникам
|
||||
const byPerson = new Map<string, any[]>();
|
||||
for (const rep of timeReports) {
|
||||
if (!rep.employee) continue; // если нет employee, пропустим
|
||||
const empId = rep.employee;
|
||||
if (!byPerson.has(empId)) {
|
||||
byPerson.set(empId, []);
|
||||
}
|
||||
byPerson.get(empId)!.push(rep);
|
||||
}
|
||||
|
||||
// 6) Формируем итог, итерируясь по сотрудникам
|
||||
const result = [];
|
||||
for (const [employeeId, reportsOfPerson] of byPerson.entries()) {
|
||||
// Сведения о сотруднике
|
||||
const personInfo = await this.loadPersonInfo(employeeId);
|
||||
|
||||
// Группируем отчёты сотрудника по задачам
|
||||
const byTask = new Map<string, any[]>();
|
||||
for (const r of reportsOfPerson) {
|
||||
const taskId = r.attachedTo;
|
||||
// используем только закрытые задачи, которые мы собрали в closures
|
||||
if (!taskIds.includes(taskId)) continue;
|
||||
|
||||
if (!byTask.has(taskId)) {
|
||||
byTask.set(taskId, []);
|
||||
}
|
||||
byTask.get(taskId)!.push(r);
|
||||
}
|
||||
|
||||
// Собираем список задач
|
||||
const tasksList = [];
|
||||
for (const [taskId, list] of byTask.entries()) {
|
||||
// Сумма фактического времени
|
||||
const spentTime = list.reduce((acc, x) => acc + (x.value || 0), 0);
|
||||
// descriptions из всех timeReports
|
||||
const descriptions = list
|
||||
.map((x) => x.description || '')
|
||||
.filter((d) => d.length > 0);
|
||||
|
||||
// Берем данные задачи (title, description, estimation, identifier)
|
||||
const issueData = issueMap.get(taskId);
|
||||
const title = issueData?.title || 'Untitled';
|
||||
const taskDescription = issueData?.description || '';
|
||||
const estimation = issueData?.estimation || 0;
|
||||
const identifier = issueData?.identifier || '';
|
||||
|
||||
tasksList.push({
|
||||
taskId,
|
||||
identifier,
|
||||
title,
|
||||
taskDescription,
|
||||
estimation,
|
||||
spentTime,
|
||||
descriptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (!tasksList.length) continue;
|
||||
|
||||
// Добавляем в общий результат объект по сотруднику
|
||||
result.push({
|
||||
person: {
|
||||
id: employeeId,
|
||||
name: personInfo.name,
|
||||
email: personInfo.email,
|
||||
},
|
||||
tasks: tasksList,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузить имя, email сотрудника
|
||||
*/
|
||||
private async loadPersonInfo(employeeId: string): Promise<{ name: string; email: string }> {
|
||||
let name = 'Unknown Person';
|
||||
let email = '';
|
||||
|
||||
// 1) findOne(contact.class.Person)
|
||||
const person = await this.client.findOne(
|
||||
contact.class.Person,
|
||||
{ _id: employeeId },
|
||||
{}
|
||||
);
|
||||
if (person) {
|
||||
name = person.name || `Person-${employeeId}`;
|
||||
}
|
||||
|
||||
// 2) findAll(contact.class.Channel, provider: 'contact:channelProvider:Email', attachedTo=employeeId)
|
||||
const channels = await this.client.findAll(
|
||||
contact.class.Channel,
|
||||
{
|
||||
provider: 'contact:channelProvider:Email',
|
||||
attachedTo: employeeId,
|
||||
},
|
||||
{}
|
||||
);
|
||||
if (channels?.length) {
|
||||
email = channels[0].value || '';
|
||||
}
|
||||
|
||||
return { name, email };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TrackerSyncService } from './sync.service';
|
||||
import { TaskClosure } from '../entities/task-closure.entity';
|
||||
import { SyncState } from '../entities/sync-state.entity';
|
||||
import { TaskStatus } from '../entities/task-status.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
TaskClosure,
|
||||
SyncState,
|
||||
TaskStatus,
|
||||
]),
|
||||
],
|
||||
providers: [TrackerSyncService], // Регистрируем сервис
|
||||
exports: [TrackerSyncService], // Экспортируем, чтобы использовать в AppModule
|
||||
})
|
||||
|
||||
export class SyncModule {}
|
||||
@@ -0,0 +1,298 @@
|
||||
// src/sync/tracker-sync.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { TaskClosure } from '../entities/task-closure.entity';
|
||||
import { TaskStatus } from '../entities/task-status.entity';
|
||||
import { SyncState } from '../entities/sync-state.entity';
|
||||
|
||||
import { connect, type ConnectOptions } from '@hcengineering/api-client';
|
||||
import { ClientSocket, ClientSocketFactory } from '@hcengineering/client';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import tracker from '@hcengineering/tracker';
|
||||
import tx from '@hcengineering/core';
|
||||
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { config } from '../config/config';
|
||||
import { options } from '../config/options';
|
||||
|
||||
@Injectable()
|
||||
export class TrackerSyncService {
|
||||
private readonly logger = new Logger(TrackerSyncService.name);
|
||||
|
||||
private client: any;
|
||||
private readonly options: ConnectOptions;
|
||||
private readonly url: string;
|
||||
|
||||
// размер батча
|
||||
private readonly BATCH_SIZE = 1;
|
||||
|
||||
/** Флаг, указывающий, что синхронизация идёт прямо сейчас */
|
||||
private isSyncing = false;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(TaskClosure)
|
||||
private readonly closureRepo: Repository<TaskClosure>,
|
||||
|
||||
@InjectRepository(TaskStatus)
|
||||
private readonly statusRepo: Repository<TaskStatus>,
|
||||
|
||||
@InjectRepository(SyncState)
|
||||
private readonly syncStateRepo: Repository<SyncState>,
|
||||
) {
|
||||
this.url = config.huly.url;
|
||||
this.options = options;
|
||||
this.logger.log(
|
||||
`TrackerSyncService inited with workspace: ${this.options.workspace}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Запускаем cron каждые 10 секунд (пример).
|
||||
* Если sync идёт — пропускаем запуск.
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_10_SECONDS)
|
||||
async scheduledSync() {
|
||||
// проверяем, не идёт ли синхронизация
|
||||
if (this.isSyncing) {
|
||||
this.logger.log('scheduledSync called but a sync is already in progress - skipping.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.syncAllData();
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Sync error: ${err.message}`, err.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запуск полной синхронизации (можно вызвать вручную при старте приложения).
|
||||
*/
|
||||
async syncAllData() {
|
||||
// Проверяем флаг
|
||||
if (this.isSyncing) {
|
||||
this.logger.log('syncAllData called but a sync is already in progress - skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
try {
|
||||
await this.initClient();
|
||||
|
||||
this.logger.log('Starting statuses sync...');
|
||||
await this.syncStatuses(); // 1) сначала стягиваем статусы
|
||||
|
||||
this.logger.log('Starting tasks sync...');
|
||||
// 2) транзакции TxCreateDoc
|
||||
await this.syncTasksBatches('TxCreateDoc');
|
||||
// 3) транзакции TxUpdateDoc
|
||||
await this.syncTasksBatches('TxUpdateDoc');
|
||||
|
||||
this.logger.log('Sync finished!');
|
||||
} finally {
|
||||
// В любом случае, даже если ошибка — сбросим флаг
|
||||
this.isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация клиента
|
||||
*/
|
||||
async initClient() {
|
||||
if (!this.client) {
|
||||
this.client = await connect(this.url, this.options);
|
||||
this.logger.log(`Client connected to ${this.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизация статусов. Сохраняем/обновляем их в локальной таблице "task_statuses".
|
||||
*/
|
||||
private async syncStatuses() {
|
||||
// Грузим статусы
|
||||
const allStatuses = await this.client.findAll(
|
||||
tracker.class.IssueStatus,
|
||||
{},
|
||||
{ limit: 2000 },
|
||||
);
|
||||
|
||||
for (const s of allStatuses) {
|
||||
let existing = await this.statusRepo.findOne({ where: { externalId: s._id } });
|
||||
if (!existing) {
|
||||
existing = this.statusRepo.create({
|
||||
externalId: s._id,
|
||||
name: s.name,
|
||||
category: s.category,
|
||||
description: s.description ?? '',
|
||||
});
|
||||
} else {
|
||||
existing.name = s.name;
|
||||
existing.category = s.category;
|
||||
existing.description = s.description ?? '';
|
||||
}
|
||||
await this.statusRepo.save(existing);
|
||||
}
|
||||
this.logger.log(`Statuses synced, count=${allStatuses.length}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Основной метод для TxCreateDoc/TxUpdateDoc
|
||||
*/
|
||||
private async syncTasksBatches(type: 'TxCreateDoc' | 'TxUpdateDoc') {
|
||||
let syncState = await this.syncStateRepo.findOne({ where: { txType: type } });
|
||||
if (!syncState) {
|
||||
syncState = this.syncStateRepo.create({
|
||||
txType: type,
|
||||
lastSynced: new Date(0),
|
||||
});
|
||||
await this.syncStateRepo.save(syncState);
|
||||
}
|
||||
let lastSyncedMs = syncState.lastSynced ? syncState.lastSynced.getTime() : 0;
|
||||
|
||||
let page = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
page++;
|
||||
this.logger.log(`Loading ${type} batch #${page} from modifiedOn > ${new Date(lastSyncedMs).toISOString()}`);
|
||||
|
||||
const chunk = await this.client.findAll(
|
||||
type === 'TxCreateDoc' ? tx.class.TxCreateDoc : tx.class.TxUpdateDoc,
|
||||
{ objectClass: 'tracker:class:Issue', modifiedOn: { $gt: lastSyncedMs } },
|
||||
{ sort: { modifiedOn: 1 }, limit: this.BATCH_SIZE },
|
||||
);
|
||||
if (!chunk.length) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
let maxModifiedOn = lastSyncedMs;
|
||||
|
||||
for (const t of chunk) {
|
||||
if (t.modifiedOn > maxModifiedOn) {
|
||||
maxModifiedOn = t.modifiedOn;
|
||||
}
|
||||
|
||||
// Смотрим, новый статус => done?
|
||||
const newStatusId = this.getNewStatusId(t);
|
||||
if (newStatusId) {
|
||||
// Находим в локальном репозитории статусов
|
||||
const status = await this.statusRepo.findOne({ where: { externalId: newStatusId } });
|
||||
if (status) {
|
||||
if (this.isDoneStatus(status)) {
|
||||
// => done
|
||||
await this.activateTaskClosure(t.objectId, t.modifiedOn);
|
||||
} else {
|
||||
// => reopen
|
||||
await this.deactivateTaskClosure(t.objectId);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(`Status ${newStatusId} not found in local db, skip`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем syncState
|
||||
syncState.lastSynced = new Date(maxModifiedOn);
|
||||
await this.syncStateRepo.save(syncState);
|
||||
|
||||
if (chunk.length < this.BATCH_SIZE) {
|
||||
hasMore = false;
|
||||
}
|
||||
lastSyncedMs = maxModifiedOn;
|
||||
}
|
||||
}
|
||||
|
||||
private getNewStatusId(txObj: any): string | null {
|
||||
if (txObj._class === 'core:class:TxCreateDoc') {
|
||||
return txObj.attributes?.status || null;
|
||||
} else if (txObj._class === 'core:class:TxUpdateDoc') {
|
||||
return txObj.operations?.status || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private isDoneStatus(status: TaskStatus): boolean {
|
||||
if (!status) return false;
|
||||
if (status.category === 'task:statusCategory:Won') return true;
|
||||
if (status.name?.toLowerCase().includes('выполн')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Активируем (или создаём) запись в task_closures.
|
||||
* + дополнительно тянем поля задачи (title/description/...) из трекера,
|
||||
* чтобы сразу сохранить их локально.
|
||||
*/
|
||||
private async activateTaskClosure(externalTaskId: string, txModifiedOn: number) {
|
||||
// 1) Запросим у трекера поля задачи (title, description, ...)
|
||||
const taskInfo = await this.client.findOne(
|
||||
tracker.class.Issue,
|
||||
{ _id: externalTaskId },
|
||||
{}
|
||||
);
|
||||
|
||||
// taskInfo может быть undefined, если задачи нет
|
||||
|
||||
// 2) Ищем локальную запись
|
||||
let existing = await this.closureRepo.findOne({
|
||||
where: { externalTaskId },
|
||||
});
|
||||
|
||||
// 3) Заполняем поля
|
||||
const closedOn = new Date(txModifiedOn);
|
||||
const title = taskInfo?.title ?? null;
|
||||
const description = taskInfo?.description
|
||||
? await this.client.fetchMarkup(
|
||||
taskInfo._class,
|
||||
taskInfo._id,
|
||||
'description',
|
||||
taskInfo.description,
|
||||
'markdown'
|
||||
)
|
||||
: '';
|
||||
|
||||
const identifier = taskInfo?.identifier ?? null;
|
||||
const number = taskInfo?.number ?? null;
|
||||
|
||||
if (!existing) {
|
||||
// создаём новую
|
||||
existing = this.closureRepo.create({
|
||||
externalTaskId,
|
||||
closedOn,
|
||||
active: true,
|
||||
title,
|
||||
description,
|
||||
identifier,
|
||||
number,
|
||||
});
|
||||
await this.closureRepo.save(existing);
|
||||
this.logger.log(`Task ${externalTaskId} => DONE at ${txModifiedOn}. Title="${title}"`);
|
||||
} else {
|
||||
// если запись была, возможно, active=false
|
||||
existing.active = true;
|
||||
existing.closedOn = closedOn;
|
||||
existing.title = title;
|
||||
existing.description = description;
|
||||
existing.identifier = identifier;
|
||||
existing.number = number;
|
||||
|
||||
await this.closureRepo.save(existing);
|
||||
this.logger.log(`Task ${externalTaskId} => re-DONE at ${txModifiedOn}. Title="${title}"`);
|
||||
}
|
||||
}
|
||||
|
||||
private async deactivateTaskClosure(externalTaskId: string) {
|
||||
const allActive = await this.closureRepo.find({
|
||||
where: { externalTaskId, active: true },
|
||||
});
|
||||
for (const c of allActive) {
|
||||
c.active = false;
|
||||
await this.closureRepo.save(c);
|
||||
this.logger.log(`Task ${externalTaskId} => reopened => closure deactivated`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// src/tasks/tasks.controller.ts
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { TasksService } from './tasks.service';
|
||||
|
||||
@Controller('tasks')
|
||||
export class TasksController {
|
||||
constructor(private readonly tasksService: TasksService) {}
|
||||
|
||||
@Get()
|
||||
async getAll() {
|
||||
return this.tasksService.findAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// src/tasks/tasks.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TasksController } from './tasks.controller';
|
||||
import { TasksService } from './tasks.service';
|
||||
import { Task } from '../entities/task.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Task])],
|
||||
controllers: [TasksController],
|
||||
providers: [TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
@@ -0,0 +1,17 @@
|
||||
// src/tasks/tasks.service.ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Task } from '../entities/task.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TasksService {
|
||||
constructor(
|
||||
@InjectRepository(Task)
|
||||
private readonly taskRepo: Repository<Task>
|
||||
) {}
|
||||
|
||||
findAll() {
|
||||
return this.taskRepo.find({ relations: ['assignee', 'status'] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"strict": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user