Files
Danil Suhomlinov 33800292aa chore: add Coolify deployment scaffolding (Dockerfiles, prod compose, git hygiene)
- apps/api/Dockerfile: build NestJS, run prisma migrate deploy on start
- apps/web/Dockerfile + nginx.conf: build Vite, serve static, proxy /api -> api
- docker-compose.coolify.yml: full prod stack (postgres, redis, minio, keycloak, api, web)
- .dockerignore / .gitignore / .gitattributes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:42:45 +03:00

18 KiB
Raw Permalink Blame History

Inventory (Vestimentație & Echipament) — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.

Goal: Заменить свободные String-поля в Benefit на FK к новой модели InventoryItem со складским учётом, добавить страницу /inventory для CRUD и логику атомарного уменьшения остатков при выдаче.

Architecture: Новая Prisma модель + NestJS модуль (inventory) + React страница + перезапись BenefitDrawer/BeneficiiTab + $transaction для stock adjust в BenefitService.

Tech Stack: NestJS 10 + Prisma 5 + React 18 + Mantine v7 + TanStack Query v5


Task 1: Prisma migration — InventoryItem model

Files:

  • Modify: apps/api/prisma/schema.prisma

  • Create: apps/api/prisma/migrations/20260512000000_add_inventory/migration.sql

  • Step 1: Добавить enum + model в schema.prisma после Benefit:

enum InventoryItemType {
  uniforma
  halat
  ciupici
  vesta
  aparat_telefon
  alte
}

model InventoryItem {
  id           String  @id @default(uuid())
  sku          String  @unique
  name         String
  type         InventoryItemType
  size         String?
  color        String?
  pricePerUnit Decimal? @db.Decimal(10, 2)
  stockQty     Int      @default(0)
  active       Boolean  @default(true)
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  uniformaBenefits  Benefit[] @relation("BenefitUniforma")
  halatBenefits     Benefit[] @relation("BenefitHalat")
  ciupiciBenefits   Benefit[] @relation("BenefitCiupici")
  vestaBenefits     Benefit[] @relation("BenefitVesta")
  aparatTelBenefits Benefit[] @relation("BenefitAparatTel")

  @@index([type, active])
  @@map("inventory_items")
}
  • Step 2: Изменить Benefit — заменить uniformaId String? и т.д. на FK:
model Benefit {
  id                  String  @id @default(uuid())
  employeeId          String  @unique
  employee            Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)

  uniformaId       String?
  uniforma         InventoryItem? @relation("BenefitUniforma", fields: [uniformaId], references: [id])
  halatId          String?
  halat            InventoryItem? @relation("BenefitHalat", fields: [halatId], references: [id])
  ciupiciId        String?
  ciupici          InventoryItem? @relation("BenefitCiupici", fields: [ciupiciId], references: [id])
  vestaId          String?
  vesta            InventoryItem? @relation("BenefitVesta", fields: [vestaId], references: [id])
  aparatTelefonId  String?
  aparatTelefon    InventoryItem? @relation("BenefitAparatTel", fields: [aparatTelefonId], references: [id])

  ticheteMasa         Boolean @default(false)
  valoareTichet       Decimal? @db.Decimal(10, 2)
  alimentatiePersonal Boolean @default(false)
  abonamentTel        Decimal? @db.Decimal(10, 2)
  cardCompanie        String?
  automobilServiciu   String?

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("benefits")
}
  • Step 3: Создать SQL миграцию вручную (т.к. migrate dev не работает в non-interactive shell):
-- CreateEnum
CREATE TYPE "InventoryItemType" AS ENUM ('uniforma', 'halat', 'ciupici', 'vesta', 'aparat_telefon', 'alte');

-- CreateTable
CREATE TABLE "inventory_items" (
    "id" TEXT NOT NULL,
    "sku" TEXT NOT NULL,
    "name" TEXT NOT NULL,
    "type" "InventoryItemType" NOT NULL,
    "size" TEXT,
    "color" TEXT,
    "pricePerUnit" DECIMAL(10,2),
    "stockQty" INTEGER NOT NULL DEFAULT 0,
    "active" BOOLEAN NOT NULL DEFAULT true,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" TIMESTAMP(3) NOT NULL,
    CONSTRAINT "inventory_items_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "inventory_items_sku_key" ON "inventory_items"("sku");
CREATE INDEX "inventory_items_type_active_idx" ON "inventory_items"("type", "active");

-- AlterTable: обнуляем старые свободные текстовые ID и добавляем FK
ALTER TABLE "benefits" ALTER COLUMN "uniformaId" DROP NOT NULL;
UPDATE "benefits" SET "uniformaId" = NULL WHERE "uniformaId" IS NOT NULL AND "uniformaId" NOT IN (SELECT "id" FROM "inventory_items");
UPDATE "benefits" SET "halatId" = NULL WHERE "halatId" IS NOT NULL AND "halatId" NOT IN (SELECT "id" FROM "inventory_items");
UPDATE "benefits" SET "ciupiciId" = NULL WHERE "ciupiciId" IS NOT NULL AND "ciupiciId" NOT IN (SELECT "id" FROM "inventory_items");
UPDATE "benefits" SET "vestaId" = NULL WHERE "vestaId" IS NOT NULL AND "vestaId" NOT IN (SELECT "id" FROM "inventory_items");
UPDATE "benefits" SET "aparatTelefonId" = NULL WHERE "aparatTelefonId" IS NOT NULL AND "aparatTelefonId" NOT IN (SELECT "id" FROM "inventory_items");

ALTER TABLE "benefits" ADD CONSTRAINT "benefits_uniformaId_fkey" FOREIGN KEY ("uniformaId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_halatId_fkey" FOREIGN KEY ("halatId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_ciupiciId_fkey" FOREIGN KEY ("ciupiciId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_vestaId_fkey" FOREIGN KEY ("vestaId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_aparatTelefonId_fkey" FOREIGN KEY ("aparatTelefonId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
  • Step 4: Применить миграцию + регенерировать клиент:
pnpm --filter api exec prisma migrate deploy
pnpm --filter api exec prisma generate
  • Step 5: Commit
git add apps/api/prisma/schema.prisma apps/api/prisma/migrations/20260512000000_add_inventory/
git commit -m "feat(inventory): add InventoryItem model + FK from Benefit"

Task 2: Inventory module (NestJS)

Files:

  • Create: apps/api/src/modules/inventory/inventory.module.ts

  • Create: apps/api/src/modules/inventory/inventory.controller.ts

  • Create: apps/api/src/modules/inventory/inventory.service.ts

  • Create: apps/api/src/modules/inventory/dto/create-inventory.dto.ts

  • Create: apps/api/src/modules/inventory/dto/update-inventory.dto.ts

  • Create: apps/api/src/modules/inventory/dto/list-query.dto.ts

  • Create: apps/api/src/modules/inventory/dto/adjust-stock.dto.ts

  • Modify: apps/api/src/app.module.ts — импортировать InventoryModule

  • Step 1: DTO с class-validator:

// create-inventory.dto.ts
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { InventoryItemType } from '@prisma/client';

export class CreateInventoryDto {
  @IsString() sku!: string;
  @IsString() name!: string;
  @IsEnum(InventoryItemType) type!: InventoryItemType;
  @IsOptional() @IsString() size?: string;
  @IsOptional() @IsString() color?: string;
  @IsOptional() @IsNumber() pricePerUnit?: number;
  @IsNumber() @Min(0) stockQty!: number;
  @IsOptional() @IsBoolean() active?: boolean;
}
// adjust-stock.dto.ts
export class AdjustStockDto {
  @IsNumber() delta!: number;        // +10, -5
  @IsString() reason!: string;
}
// list-query.dto.ts
export class InventoryQueryDto {
  @IsOptional() @IsEnum(InventoryItemType) type?: InventoryItemType;
  @IsOptional() @Type(() => Boolean) @IsBoolean() active?: boolean;
  @IsOptional() @IsString() search?: string;
  @IsOptional() @Type(() => Number) @IsInt() page?: number = 1;
  @IsOptional() @Type(() => Number) @IsInt() limit?: number = 50;
}
  • Step 2: Service:
// inventory.service.ts
@Injectable()
export class InventoryService {
  constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {}

  async list(q: InventoryQueryDto, userId: string, role: string) {
    const where: Prisma.InventoryItemWhereInput = {};
    if (q.type) where.type = q.type;
    if (q.active !== undefined) where.active = q.active;
    if (q.search) {
      where.OR = [
        { sku: { contains: q.search, mode: 'insensitive' } },
        { name: { contains: q.search, mode: 'insensitive' } },
      ];
    }
    const limit = Math.min(q.limit ?? 50, 200);
    const [total, items] = await this.prisma.$transaction([
      this.prisma.inventoryItem.count({ where }),
      this.prisma.inventoryItem.findMany({ where, orderBy: { name: 'asc' }, skip: ((q.page ?? 1) - 1) * limit, take: limit }),
    ]);
    await this.audit.logRead({ userId, userRole: role, entity: 'InventoryItem', entityId: 'LIST' });
    return { total, page: q.page ?? 1, limit, items };
  }

  async create(dto: CreateInventoryDto, userId: string, role: string) {
    const item = await this.prisma.inventoryItem.create({ data: dto });
    await this.audit.logChange({ userId, userRole: role, action: 'CREATE', entity: 'InventoryItem', entityId: item.id });
    return item;
  }

  async update(id: string, dto: UpdateInventoryDto, userId: string, role: string) {
    const item = await this.prisma.inventoryItem.update({ where: { id }, data: dto });
    await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'InventoryItem', entityId: id });
    return item;
  }

  async remove(id: string, userId: string, role: string) {
    const used = await this.prisma.benefit.count({
      where: { OR: [
        { uniformaId: id }, { halatId: id }, { ciupiciId: id }, { vestaId: id }, { aparatTelefonId: id },
      ]},
    });
    if (used > 0) {
      // soft delete
      await this.prisma.inventoryItem.update({ where: { id }, data: { active: false } });
      await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'InventoryItem', entityId: id, field: 'active', newValue: 'false' });
      return { softDeleted: true };
    }
    await this.prisma.inventoryItem.delete({ where: { id } });
    await this.audit.logChange({ userId, userRole: role, action: 'DELETE', entity: 'InventoryItem', entityId: id });
    return { deleted: true };
  }

  async adjustStock(id: string, dto: AdjustStockDto, userId: string, role: string) {
    const item = await this.prisma.inventoryItem.update({
      where: { id },
      data: { stockQty: { increment: dto.delta } },
    });
    if (item.stockQty < 0) throw new BadRequestException('Stoc negativ nu este permis');
    await this.audit.logChange({
      userId, userRole: role, action: 'UPDATE', entity: 'InventoryItem', entityId: id,
      field: 'stockQty', newValue: String(item.stockQty), reason: dto.reason,
    });
    return item;
  }
}
  • Step 3: Controller:
@Controller('inventory')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class InventoryController {
  constructor(private readonly svc: InventoryService) {}

  @Get()
  @Roles('hr_admin', 'hr_specialist')
  list(@Query() q: InventoryQueryDto, @Request() req: AuthReq) {
    return this.svc.list(q, req.user.id, req.user.role);
  }

  @Post()
  @Roles('hr_admin')
  create(@Body() dto: CreateInventoryDto, @Request() req: AuthReq) {
    return this.svc.create(dto, req.user.id, req.user.role);
  }

  @Patch(':id')
  @Roles('hr_admin')
  update(@Param('id') id: string, @Body() dto: UpdateInventoryDto, @Request() req: AuthReq) {
    return this.svc.update(id, dto, req.user.id, req.user.role);
  }

  @Delete(':id')
  @Roles('hr_admin')
  remove(@Param('id') id: string, @Request() req: AuthReq) {
    return this.svc.remove(id, req.user.id, req.user.role);
  }

  @Post(':id/adjust-stock')
  @Roles('hr_admin')
  adjust(@Param('id') id: string, @Body() dto: AdjustStockDto, @Request() req: AuthReq) {
    return this.svc.adjustStock(id, dto, req.user.id, req.user.role);
  }
}
  • Step 4: Module + регистрация в app.module.ts. Commit.

Task 3: Atomic stock adjust в BenefitService

Files:

  • Modify: apps/api/src/modules/employees/sub-resources/benefit.service.ts (или где сейчас upsert логика)

  • Step 1: Переписать upsert(employeeId, dto):

async upsert(employeeId: string, dto: UpsertBenefitDto, userId: string, role: string) {
  const old = await this.prisma.benefit.findUnique({ where: { employeeId } });

  const fields: Array<keyof Pick<UpsertBenefitDto, 'uniformaId'|'halatId'|'ciupiciId'|'vestaId'|'aparatTelefonId'>> = [
    'uniformaId', 'halatId', 'ciupiciId', 'vestaId', 'aparatTelefonId',
  ];

  return this.prisma.$transaction(async (tx) => {
    for (const f of fields) {
      const oldId = old?.[f] ?? null;
      const newId = dto[f] ?? null;
      if (oldId === newId) continue;
      if (oldId) await tx.inventoryItem.update({ where: { id: oldId }, data: { stockQty: { increment: 1 } } });
      if (newId) {
        const item = await tx.inventoryItem.update({ where: { id: newId }, data: { stockQty: { decrement: 1 } } });
        if (item.stockQty < 0) throw new BadRequestException(`Stoc epuizat pentru ${f}`);
      }
    }
    const benefit = await tx.benefit.upsert({
      where: { employeeId },
      create: { employeeId, ...dto },
      update: dto,
    });
    return benefit;
  }).then(async (b) => {
    await this.audit.logChange({ userId, userRole: role, action: old ? 'UPDATE' : 'CREATE', entity: 'Benefit', entityId: b.id });
    return b;
  });
}
  • Step 2: Тест mental: dryRun со старым null+новым null — пропустить; oldA→newB — A++, B--.
  • Step 3: Commit.

Task 4: Seed data + frontend types

Files:

  • Modify: apps/api/prisma/seed.ts — добавить ~15 предметов

  • Modify: apps/web/src/api/types.tsInventoryItem, InventoryItemType, Benefit (с relation полями)

  • Step 1: Seed (4 uniforme + 4 halate + 3 ciupici + 2 veste + 2 aparate, у каждого stockQty: 50):

const inventory = [
  { sku: 'UN-CHIR-M-AL', name: 'Uniformă chirurgie M albastru', type: 'uniforma', size: 'M', color: 'albastru', stockQty: 50 },
  { sku: 'UN-CHIR-L-AL', name: 'Uniformă chirurgie L albastru', type: 'uniforma', size: 'L', color: 'albastru', stockQty: 50 },
  // ... ещё 13
];
for (const i of inventory) {
  await prisma.inventoryItem.upsert({ where: { sku: i.sku }, create: i, update: {} });
}
  • Step 2: Frontend types:
export type InventoryItemType = 'uniforma' | 'halat' | 'ciupici' | 'vesta' | 'aparat_telefon' | 'alte';

export interface InventoryItem {
  id: string;
  sku: string;
  name: string;
  type: InventoryItemType;
  size: string | null;
  color: string | null;
  pricePerUnit: string | null;  // Decimal serialized
  stockQty: number;
  active: boolean;
}

export interface PaginatedInventory { total: number; page: number; limit: number; items: InventoryItem[]; }

export interface Benefit {
  // ... existing
  uniformaId: string | null;
  uniforma: InventoryItem | null;
  halatId: string | null;
  halat: InventoryItem | null;
  ciupiciId: string | null;
  ciupici: InventoryItem | null;
  vestaId: string | null;
  vesta: InventoryItem | null;
  aparatTelefonId: string | null;
  aparatTelefon: InventoryItem | null;
  // ...
}
  • Step 3: Run seed: pnpm --filter api prisma:seed. Commit.

Task 5: InventoryPage + Drawer

Files:

  • Create: apps/web/src/pages/inventory/InventoryPage.tsx

  • Create: apps/web/src/pages/inventory/InventoryDrawer.tsx

  • Create: apps/web/src/pages/inventory/StockAdjustModal.tsx

  • Step 1: InventoryPage — таблица + filter bar (search, type select, active switch) + кнопка «Adaugă articol». Stoc=0 → red row, <5 → amber.

  • Step 2: InventoryDrawer — react-hook-form + Zod, поля SKU/Denumire/Tip/Mărime/Culoare/Preț/Stoc inițial/Active. POST/PATCH через axios.

  • Step 3: StockAdjustModal<NumberInput delta> + <Textarea reason>, POST /inventory/:id/adjust-stock.

  • Step 4: Commit.


Task 6: Wire в App.tsx + nav

Files:

  • Modify: apps/web/src/App.tsx

  • Step 1: Импорт IconBox + InventoryPage.

  • Step 2: Добавить в NAV_ITEMS:

    { labelKey: 'nav.inventory', path: '/inventory', icon: <IconBox size={20} stroke={1.6} color="#008286" />, roles: ['hr_admin', 'hr_specialist'] }
    
  • Step 3: Route: <Route path="/inventory" element={<InventoryPage />} />

  • Step 4: i18n key nav.inventory: "Inventar".

  • Step 5: Commit.


Task 7: Перезаписать BenefitDrawer + BeneficiiTab

Files:

  • Modify: apps/web/src/pages/employees/drawers/BenefitDrawer.tsx

  • Modify: apps/web/src/pages/employees/tabs/BeneficiiTab.tsx

  • Step 1: В BenefitDrawer заменить 5 <TextInput> на <Select searchable>:

const { data: uniforme } = useQuery({
  queryKey: ['inventory', 'uniforma'],
  queryFn: () => apiClient.get('/inventory', { params: { type: 'uniforma', active: true, limit: 200 } }).then(r => r.data),
  staleTime: 60_000,
});

<Controller name="uniformaId" control={control} render={({ field }) => (
  <Select
    label="Uniformă"
    data={(uniforme?.items ?? []).map((it: InventoryItem) => ({
      value: it.id,
      label: `${it.sku}${it.name}${it.size ? ` (${it.size})` : ''} · stoc: ${it.stockQty}`,
      disabled: it.stockQty === 0 && it.id !== field.value,
    }))}
    searchable
    clearable
    {...field}
  />
)} />

Повторить для halat / ciupici / vesta / aparatTelefon.

  • Step 2: В BeneficiiTab заменить BField label="Uniformă (ID)" value={benefit.uniformaId} на:
<BField label="Uniformă" value={benefit.uniforma ? `${benefit.uniforma.sku}${benefit.uniforma.name}` : null} />
  • Step 3: На бэке в benefit.service.ts добавить include: { uniforma: true, halat: true, ciupici: true, vesta: true, aparatTelefon: true } во все find-методы.

  • Step 4: Commit.


Self-Review Checklist (после написания)

  • Все ID полей в Benefit стали FK
  • Atomic stock в $transaction, не race condition
  • Soft delete если InventoryItem используется
  • Seed создаёт ~15 предметов
  • Drawer disable=true при stockQty=0 (кроме уже выбранного)
  • Audit log для CRUD + adjust-stock
  • Все эндпоинты под /api/v1/