Перейти к содержанию

Personnel Service

Назначение

personnel обслуживает кадровый домен: оргструктуру, должности, сотрудников и шаблоны смен. Сервис построен на NestJS, хранит состояние в PostgreSQL через Prisma, принимает команды и queries через RabbitMQ RPC и публикует кадровые события в RabbitMQ.

Как сервис встроен в систему

  • Команды принимает через efko.personnel.commands.
  • Запросы принимает через efko.personnel.queries.
  • События публикует через efko.personnel.events.
  • Очереди разделены на personnel-service.commands.queue и personnel-service.queries.queue.
  • В AppModule подключён RmqEventEmitterModule с sourceService: 'personnel'; дополнительно подключён Prisma-based outbox.

Основные модули

  • DepartmentsModule: подразделения и иерархия оргструктуры.
  • PositionsModule: должности внутри подразделений.
  • EmployeesModule: сотрудники, изменение данных и увольнение.
  • ShiftTemplatesModule: шаблоны сменных графиков.
  • PrismaModule: доступ к БД и репозиториям.
  • OutboxModule: публикация событий из outbox_messages в efko.personnel.events.
  • RabbitModule: transport-конфигурация RabbitMQ.

RabbitMQ Commands и Queries

Все команды отправляются в exchange efko.personnel.commands, очередь personnel-service.commands.queue. Все запросы отправляются в exchange efko.personnel.queries, очередь personnel-service.queries.queue.

Все RabbitRPC handlers работают через ValidationPipe и personnelRpcErrorInterceptor, логируют topic и request metadata (correlationId, userId, userRole).

Commands

PersonnelCreateDepartmentCommand

Создать подразделение.

Exchange: efko.personnel.commands
Request Body:

{
  name: string;                    // Название подразделения
  code: string;                    // Уникальный код подразделения
  type: DepartmentType;            // Тип: DIVISION | DEPARTMENT | SECTION | UNIT
  parentId?: string | null;        // UUID родительского подразделения (опционально)
  headEmployeeId?: string | null;  // UUID руководителя (опционально)
  sourceSystemId?: string | null;  // ID из внешней системы (для ETL)
}

Response:

{
  id: string;
  name: string;
  code: string;
  type: DepartmentType;
  parentId: string | null;
  headEmployeeId: string | null;
  sourceSystemId: string | null;
}

Ошибки: DEPARTMENT_CODE_ALREADY_EXISTS -> 409


PersonnelUpdateDepartmentCommand

Обновить подразделение.

Exchange: efko.personnel.commands
Request Body:

{
  id: string;                      // UUID подразделения
  name?: string;
  type?: DepartmentType;
  parentId?: string | null;
  headEmployeeId?: string | null;
}

Response:

{
  id: string;
  name: string;
  code: string;
  type: DepartmentType;
  parentId: string | null;
  headEmployeeId: string | null;
}

PersonnelCreatePositionCommand

Создать должность.

Exchange: efko.personnel.commands
Request Body:

{
  title: string;            // Название должности
  code: string;             // Уникальный код должности
  departmentId: string;     // UUID подразделения
  sourceSystemId?: string;  // ID из внешней системы
}

Response:

{
  id: string;
  title: string;
  code: string;
  departmentId: string;
}

Ошибки: - POSITION_CODE_ALREADY_EXISTS -> 409 - DEPARTMENT_NOT_FOUND -> 404


PersonnelUpdatePositionCommand

Обновить должность.

Exchange: efko.personnel.commands
Request Body:

{
  id: string;
  title?: string;
  departmentId?: string;
}

Response:

{
  id: string;
  title: string;
  code: string;
  departmentId: string;
}

PersonnelCreateEmployeeCommand

Создать сотрудника.

Exchange: efko.personnel.commands
Request Body:

{
  personnelNumber: string;  // Табельный номер (уникальный)
  fullName: string;         // Полное имя (формат: Фамилия Имя Отчество)
  dateOfBirth: string;      // Дата рождения (ISO date: YYYY-MM-DD)
  departmentId: string;     // UUID подразделения
  positionId: string;       // UUID должности
  hireDate: string;         // Дата приема (ISO date)
  employmentType: EmploymentType; // MAIN | PART_TIME
  sourceSystemId?: string;  // ID из внешней системы
}

Response:

{
  id: string;
  personnelNumber: string;
  fullName: string;
  departmentId: string;
  positionId: string;
  status: EmployeeStatus;  // ACTIVE | TERMINATED | ON_LEAVE
}

Ошибки: - INVALID_FULL_NAME -> 400 - DEPARTMENT_NOT_FOUND -> 404 - POSITION_NOT_FOUND -> 404


PersonnelUpdateEmployeeCommand

Обновить данные сотрудника.

Exchange: efko.personnel.commands
Request Body:

{
  id: string;
  fullName?: string;
  departmentId?: string;
  positionId?: string;
  employmentType?: EmploymentType;
  dateOfBirth?: string;  // ISO date
}

Response:

{
  id: string;
  personnelNumber: string;
  fullName: string;
  dateOfBirth: string;
  departmentId: string;
  positionId: string;
  hireDate: string;
  terminationDate: string | null;
  employmentType: EmploymentType;
  status: EmployeeStatus;
  sourceSystemId: string | null;
}

PersonnelTerminateEmployeeCommand

Уволить сотрудника.

Exchange: efko.personnel.commands
Request Body:

{
  id: string;
  terminationDate?: string;  // ISO date, опционально (по умолчанию сегодня)
}

Response:

{
  id: string;
  status: EmployeeStatus;  // TERMINATED
  terminationDate: string;
}

Ошибки: - EMPLOYEE_ALREADY_TERMINATED -> 409 - EMPLOYEE_NOT_FOUND -> 404


PersonnelCreateShiftTemplateCommand

Создать шаблон смены.

Exchange: efko.personnel.commands
Request Body:

{
  name: string;                // Название шаблона
  shiftType: ShiftType;        // DAY_SHIFT | NIGHT_SHIFT | ROTATING
  startTime: string;           // Время начала (HH:MM)
  endTime: string;             // Время окончания (HH:MM)
  workDaysPattern: string;     // Бинарная строка (7 символов: 1111100)
}

Response:

{
  id: string;
  name: string;
  shiftType: ShiftType;
  startTime: string;
  endTime: string;
  workDaysPattern: string;
}

PersonnelUpdateShiftTemplateCommand

Обновить шаблон смены.

Exchange: efko.personnel.commands
Request Body:

{
  id: string;
  name?: string;
  shiftType?: ShiftType;
  startTime?: string;
  endTime?: string;
  workDaysPattern?: string;
}

Response:

{
  id: string;
  name: string;
  shiftType: ShiftType;
  startTime: string;
  endTime: string;
  workDaysPattern: string;
}

Queries

PersonnelGetDepartmentsQuery

Получить список подразделений.

Exchange: efko.personnel.queries
Request Body:

{
  type?: DepartmentType;  // Фильтр по типу подразделения (опционально)
  code?: string;          // Фильтр по коду (опционально)
}

Response:

{
  departments: Array<{
    id: string;
    name: string;
    code: string;
    type: DepartmentType;
    parentId: string | null;
    headEmployeeId: string | null;
    sourceSystemId: string | null;
  }>;
}

PersonnelGetPositionsQuery

Получить список должностей.

Exchange: efko.personnel.queries
Request Body:

{
  departmentId?: string;  // Фильтр по подразделению (опционально)
}

Response:

{
  positions: Array<{
    id: string;
    title: string;
    code: string;
    departmentId: string;
  }>;
}

PersonnelGetEmployeesQuery

Получить список сотрудников.

Exchange: efko.personnel.queries
Request Body:

{
  departmentId?: string;      // Фильтр по подразделению
  positionId?: string;        // Фильтр по должности
  status?: EmployeeStatus;    // ACTIVE | TERMINATED | ON_LEAVE
  employmentType?: EmploymentType;  // MAIN | PART_TIME
}

Response:

{
  employees: Array<{
    id: string;
    personnelNumber: string;
    fullName: string;
    status: EmployeeStatus;
    employmentType: EmploymentType;
    departmentId: string;
    positionId: string;
    hireDate: string;
    dateOfBirth: string;
    terminationDate: string | null;
  }>;
}

PersonnelGetShiftTemplatesQuery

Получить список шаблонов смен.

Exchange: efko.personnel.queries
Request Body:

{}

Response:

{
  templates: Array<{
    id: string;
    name: string;
    shiftType: ShiftType;
    startTime: string;
    endTime: string;
    workDaysPattern: string;
  }>;
}

Основная бизнес-логика

  • Подразделения поддерживают иерархию parent -> children; при создании/обновлении parentId и headEmployeeId можно передавать не только UUID, но и бизнес-идентификаторы, которые резолвятся через resolveEntityId(...).
  • Должность жёстко привязана к подразделению; create flow валидирует уникальность code и существование подразделения.
  • Сотрудник связан и с подразделением, и с должностью; CreateEmployeeUseCase валидирует оба reference и использует value objects PersonnelNumber и FullName.
  • Увольнение реализовано отдельным use case: меняет статус сотрудника и ставит дату увольнения.
  • Шаблоны смен содержат тип смены, время начала/окончания и workDaysPattern; время и паттерн валидируются value object-ами.
  • Для ETL-импортов create use case-ы по подразделениям, должностям и сотрудникам сначала пытаются делать upsert по sourceSystemId.

Хранение данных

PostgreSQL/Prisma, основные таблицы:

  • departments: имя, код, тип, parent_id, head_employee_id, source_system_id.
  • positions: title, code, department_id, source_system_id.
  • employees: табельный номер, ФИО, дата рождения, подразделение, должность, даты приёма/увольнения, тип занятости, статус, source_system_id.
  • shift_schedule_templates: имя шаблона, тип смены, время начала/окончания, паттерн рабочих дней.
  • outbox_messages: события кадрового домена для асинхронной публикации.

Интеграции

  • RabbitMQ RPC для всех кадровых команд и queries.
  • RabbitMQ events:
  • подразделения, должности и сотрудники при create/update часто пишут события через outbox;
  • увольнение и часть операций используют EventEmitterService напрямую.
  • ETL является важным upstream:
  • ZUP mapper направляет данные в PersonnelCreateDepartmentCommand, PersonnelCreatePositionCommand, PersonnelCreateEmployeeCommand, PersonnelCreateShiftTemplateCommand;
  • create use case-ы умеют обновлять существующие записи по sourceSystemId, что снижает дубли при повторном импорте.

Обработка ошибок

  • Доменные ошибки наследуются от PersonnelError.
  • Для RPC используется personnelRpcErrorInterceptor, который приводит доменные и HTTP ошибки к структуре { error: { code, message, statusCode } }.
  • Для HTTP-слоя подключён AllExceptionsFilter; в HTTP-контексте он возвращает JSON с statusCode и message.
  • Основные маппинги:
  • ошибки формата (INVALID_*) -> 400
  • ошибки отсутствующих сущностей -> 404
  • конфликтные состояния (*_ALREADY_EXISTS, EMPLOYEE_ALREADY_TERMINATED, EMPLOYEE_NOT_ACTIVE) -> 409

Observability и logging

  • Логирование через nestjs-pino.
  • В dev-режиме лог пишется в logs/personnel.log и очищается на старте.
  • Bootstrap включает enableShutdownHooks() и глобальный exception filter.
  • Контроллеры логируют RPC topic и request metadata.
  • В EmployeesController есть дополнительный debug/error лог для PersonnelCreateEmployeeCommand, включая сериализацию payload и ошибки выполнения.

Зависимости

  • NestJS
  • Prisma + PostgreSQL
  • @golevelup/nestjs-rabbitmq
  • nestjs-pino
  • @efko-kernel/contracts
  • @efko-kernel/interfaces
  • @efko-kernel/nest-utils

Наблюдения и пробелы по коду

  • Use case-ы используют смешанную схему публикации событий: outbox для части create/update операций и direct publish для части команд.
  • В DepartmentsModule, EmployeesModule, PositionsModule явно не импортируется OutboxModule, хотя create use case-ы зависят от OutboxMessageRepository; корректность разрешения зависимости предполагает наличие глобального провайдера в общем composition root.
  • Как и в production, внешний HTTP-сервер поднимается, но бизнес-операции экспонированы как RabbitRPC, а не REST.