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

494 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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`):
```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``<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`:
```ts
{ 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>`:
```typescript
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}` на:
```tsx
<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/`