# Корпоративная HR-система мотивации «ПиАр Грэм» Полноценный одностраничный интерфейс с дашбордом, каталогом наградных боксов, админкой и встроенной документацией по схеме БД и API. ```html ``` ПиАр Грэм — Корпоративная система мотивации
А. Соколов получил Мажор Грэм — 12 500 ПГ Е. Никитина вскрыла Крипто-Бокс — 4 800 ПГ М. Дегтярёв активировал Бонус Плюс — 1 200 ПГ О. Зайцева получила Сборка «Оазис» — 18 700 ПГ Д. Гаврилов вскрыл ПиАр Дроп — 3 250 ПГ Т. Беляева активировала Бронзовый пак — 480 ПГ А. Соколов получил Мажор Грэм — 12 500 ПГ Е. Никитина вскрыла Крипто-Бокс — 4 800 ПГ М. Дегтярёв активировал Бонус Плюс — 1 200 ПГ О. Зайцева получила Сборка «Оазис» — 18 700 ПГ Д. Гаврилов вскрыл ПиАр Дроп — 3 250 ПГ Т. Беляева активировала Бронзовый пак — 480 ПГ
Квартальное HR-событие · Q4

Турнир «Зимний Спринт»

Командный челлендж между департаментами с распределением призового фонда в 2 500 000 ПиАр Грэм. Срок завершения — 31 декабря.

Общий прогресс плана 67% · 1 675 000 / 2 500 000 ПГ
Призовой фонд
2 500 000 ПГ
Команд
18
Участников
412
Дней осталось
23

Лимитированные наборы

Ограниченное число генераций в этом месяце

Лимит
Мажор Грэм
Премиум · Лимитированный
9 500 ПГ 12 000 ПГ
Осталось: 42Открыто: 158/200
Хит
Крипто-Бокс
Премиум · Лимитированный
4 800 ПГ 6 200 ПГ
Осталось: 88Открыто: 312/400
Новинка
ПиАр Дроп
Сезонный · Лимитированный
3 250 ПГ 4 100 ПГ
Осталось: 121Открыто: 79/200
Бонус Плюс
Стандарт · Лимитированный
1 200 ПГ
Осталось: 240Открыто: 560/800

Премиум-сборки

Комплекты с максимальной номинальной ценностью

Хит
Сборка «Оазис»
Премиум-комплект
15 200 ПГ 19 500 ПГ
5 карточек в набореОткрыто: 824
Сборка «Сафари»
Премиум-комплект
11 800 ПГ 14 200 ПГ
4 карточки в набореОткрыто: 1 142
Топ
Сборка «Царь Добычи»
Премиум-комплект
22 000 ПГ 28 500 ПГ
7 карточек в набореОткрыто: 318
Сборка «Атлант»
Премиум-комплект
8 900 ПГ
3 карточки в набореОткрыто: 1 880

Фарм · базовые

Низкая стоимость активации, быстрая проверка механики

Бронзовый пак
Стартер
480 ПГ
Без лимитаОткрыто: 18 420
Стартовый дроп
Стартер
250 ПГ
Без лимитаОткрыто: 26 110
Ежедневно
Дневной чек-ин
Стартер
120 ПГ
1 раз в суткиОткрыто: 41 200

Пятьдесят на пятьдесят

Равное математическое распределение между двумя наградами

Дуэт · Мини
50/50 · Базовый
800 ПГ
2 исходаОткрыто: 5 240
Дуэт · Профи
50/50 · Премиум
3 600 ПГ 4 500 ПГ
2 исходаОткрыто: 980

Панель администратора

Конструктор наградных боксов и журнал верификации списания баллов

Создание нового набора

Заполните параметры коробки и распределение шансов выпадения карточек

Содержимое набора

Сумма всех вероятностей должна равняться 100%

Карточка Уровень Номинал, ПГ Шанс, %
Бронзовый пакБазовый480
Бонус ПлюсСтандарт1 200
ПиАр ДропЭпик3 250
Крипто-БоксЭпик4 800
Мажор ГрэмЛегенда9 500
Суммарная вероятность: 100.0%

Журнал верификации списаний

Внутренние запросы сотрудников на обмен ПиАр Грэм на корпоративный мерч

ID Сотрудник Сумма (ПГ) Адрес назначения Дата Статус Действия
#10842 А. Соколов 12 500 Деп. Маркетинга, каб. 412 04.12.2024 В очереди
#10841 Е. Никитина 4 800 HR-департамент, ресепшн 04.12.2024 В очереди
#10840 М. Дегтярёв 1 200 Отдел разработки, 7 этаж 03.12.2024 Подтверждено
#10839 О. Зайцева 18 700 Финансовый деп., каб. 205 03.12.2024 В очереди
#10838 Д. Гаврилов 3 250 Отдел продаж, ресепшн 02.12.2024 Отклонено
#10837 Т. Беляева 480 Деп. дизайна, 5 этаж 02.12.2024 Подтверждено

Техническое задание

Схема БД на Prisma ORM и REST API эндпоинты

Prisma Schema · PostgreSQL

Полная схема реляционной базы данных корпоративной системы мотивации.

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum Role {
  EMPLOYEE
  ADMIN
}

enum BoxCategory {
  LIMITED
  BUNDLE
  FARM
  FIFTY_FIFTY
}

enum ItemTier {
  BASIC
  STANDARD
  EPIC
  LEGEND
}

enum AccountActionStatus {
  QUEUED
  APPROVED
  REJECTED
}

enum InventoryStatus {
  IN_INVENTORY
  RETURNED
}

model User {
  id         Int      @id @default(autoincrement())
  name       String
  email      String   @unique
  balance    Int      @default(0)  // баланс в ПиАр Грэм
  role       Role     @default(EMPLOYEE)
  createdAt  DateTime @default(now())

  inventory       EmployeeInventory[]
  accountActions  AccountAction[]
}

model Box {
  id           Int         @id @default(autoincrement())
  name         String
  slug         String      @unique
  price        Int                       // стоимость активации, ПГ
  oldPrice     Int?                      // базовая цена для отображения скидки
  cycleLimit   Int?                      // null = без лимита
  cyclesDone   Int         @default(0)
  category     BoxCategory
  imageUrl     String?
  isArchived   Boolean     @default(false)
  createdAt    DateTime    @default(now())

  items        BoxItem[]
}

model Item {
  id        Int      @id @default(autoincrement())
  name      String
  value     Int                          // номинал в ПГ
  tier      ItemTier
  imageUrl  String?

  boxes     BoxItem[]
  inventory EmployeeInventory[]
}

model BoxItem {
  id          Int    @id @default(autoincrement())
  boxId       Int
  itemId      Int
  dropChance  Float                      // % вероятности 0..100

  box   Box   @relation(fields: [boxId], references: [id], onDelete: Cascade)
  item  Item  @relation(fields: [itemId], references: [id])

  @@unique([boxId, itemId])
}

model AccountAction {
  id         Int                  @id @default(autoincrement())
  userId     Int
  amount     Int                                // сумма ПГ к списанию
  address    String                             // текстовый адрес/реквизиты
  status     AccountActionStatus @default(QUEUED)
  rejectReason String?
  createdAt  DateTime             @default(now())
  updatedAt  DateTime             @updatedAt

  user       User @relation(fields: [userId], references: [id])

  @@index([status])
  @@index([userId])
}

model EmployeeInventory {
  id        Int             @id @default(autoincrement())
  userId    Int
  itemId    Int
  status    InventoryStatus @default(IN_INVENTORY)
  obtainedAt DateTime      @default(now())

  user User @relation(fields: [userId], references: [id])
  item Item @relation(fields: [itemId], references: [id])

  @@index([userId, status])
}

REST API · Эндпоинты

GET /api/boxes
GET /api/boxes/:slug
POST /api/admin/boxes  — создать набор
PATCH /api/admin/boxes/:id  — обновить параметры
GET /api/admin/account-actions
PATCH /api/admin/account-actions/:id/status  — подтвердить/отклонить

Express + Prisma · Реализация

// src/server.js
import express from 'express';
import { PrismaClient } from '@prisma/client';
import { z } from 'zod';

const prisma = new PrismaClient();
const app = express();
app.use(express.json());

// === Валидация входных данных ===
const CreateBoxSchema = z.object({
  name:       z.string().min(2).max(80),
  slug:       z.string().regex(/^[a-z0-9-]+$/),
  price:      z.number().int().positive(),
  oldPrice:   z.number().int().positive().optional(),
  cycleLimit: z.number().int().positive().nullable().optional(),
  category:   z.enum(['LIMITED','BUNDLE','FARM','FIFTY_FIFTY']),
  imageUrl:   z.string().url().optional(),
  items:      z.array(z.object({
    itemId:     z.number().int().positive(),
    dropChance: z.number().min(0).max(100)
  })).min(1)
});

// === POST /api/admin/boxes — создание набора ===
app.post('/api/admin/boxes', async (req, res) => {
  try {
    const data = CreateBoxSchema.parse(req.body);

    // Сумма шансов должна строго равняться 100%
    const sum = data.items.reduce((s, it) => s + it.dropChance, 0);
    if (Math.abs(sum - 100) > 0.001) {
      return res.status(400).json({
        error: 'INVALID_CHANCES',
        message: `Сумма вероятностей = ${sum}%, должна быть 100%`
      });
    }

    const box = await prisma.box.create({
      data: {
        name:       data.name,
        slug:       data.slug,
        price:      data.price,
        oldPrice:   data.oldPrice,
        cycleLimit: data.cycleLimit,
        category:   data.category,
        imageUrl:   data.imageUrl,
        items: {
          create: data.items.map(it => ({
            itemId:     it.itemId,
            dropChance: it.dropChance
          }))
        }
      },
      include: { items: { include: { item: true } } }
    });

    res.status(201).json(box);
  } catch (e) {
    if (e.code === 'P2002') return res.status(409).json({ error: 'SLUG_TAKEN' });
    res.status(400).json({ error: 'BAD_REQUEST', details: e.message });
  }
});

// === PATCH /api/admin/account-actions/:id/status ===
// Подтверждение или отклонение запроса на списание ПГ
const UpdateStatusSchema = z.object({
  status:       z.enum(['APPROVED', 'REJECTED']),
  rejectReason: z.string().max(500).optional()
});

app.patch('/api/admin/account-actions/:id/status', async (req, res) => {
  try {
    const id = Number(req.params.id);
    const { status, rejectReason } = UpdateStatusSchema.parse(req.body);

    const action = await prisma.accountAction.findUnique({
      where: { id }, include: { user: true }
    });
    if (!action) return res.status(404).json({ error: 'NOT_FOUND' });
    if (action.status !== 'QUEUED') {
      return res.status(409).json({ error: 'ALREADY_PROCESSED' });
    }

    // Атомарная транзакция: меняем статус + двигаем баланс
    const result = await prisma.$transaction(async (tx) => {
      if (status === 'APPROVED') {
        // Подтверждение — окончательное списание баллов
        if (action.user.balance < action.amount) {
          throw new Error('INSUFFICIENT_BALANCE');
        }
        await tx.user.update({
          where: { id: action.userId },
          data:  { balance: { decrement: action.amount } }
        });
      } else {
        // Отклонение — возврат суммы на баланс сотрудника
        await tx.user.update({
          where: { id: action.userId },
          data:  { balance: { increment: action.amount } }
        });
        // TODO: запушить системное уведомление с rejectReason
      }
      return tx.accountAction.update({
        where: { id },
        data:  { status, rejectReason: status === 'REJECTED' ? rejectReason : null }
      });
    });

    res.json(result);
  } catch (e) {
    if (e.message === 'INSUFFICIENT_BALANCE')
      return res.status(409).json({ error: 'INSUFFICIENT_BALANCE' });
    res.status(400).json({ error: 'BAD_REQUEST', details: e.message });
  }
});

app.listen(3000, () => console.log('API on :3000'));
Готово