- 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>
18 KiB
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.ts—InventoryItem,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/