# 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`: ```prisma 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: ```prisma 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): ```sql -- 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: Применить миграцию + регенерировать клиент: ```bash pnpm --filter api exec prisma migrate deploy pnpm --filter api exec prisma generate ``` - [ ] Step 5: Commit ```bash 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`: ```typescript // 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; } ``` ```typescript // adjust-stock.dto.ts export class AdjustStockDto { @IsNumber() delta!: number; // +10, -5 @IsString() reason!: string; } ``` ```typescript // 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: ```typescript // 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: ```typescript @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)`: ```typescript async upsert(employeeId: string, dto: UpsertBenefitDto, userId: string, role: string) { const old = await this.prisma.benefit.findUnique({ where: { employeeId } }); const fields: Array> = [ '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.ts` — `InventoryItem`, `InventoryItemType`, `Benefit` (с relation полями) - [ ] Step 1: Seed (4 uniforme + 4 halate + 3 ciupici + 2 veste + 2 aparate, у каждого `stockQty: 50`): ```typescript 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: ```typescript 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` — `` + `