33800292aa
- 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>
153 lines
7.1 KiB
Markdown
153 lines
7.1 KiB
Markdown
# Design: Inventory (Vestimentație & Echipament)
|
||
|
||
**Date:** 2026-05-12
|
||
**Scope:** Полноценный модуль склада инвентаря для HR Medpark — заменяет свободные текстовые ID в `Benefit` на FK к таблице `InventoryItem`.
|
||
|
||
---
|
||
|
||
## 1. Goal
|
||
|
||
В разделе «Beneficii» сотрудника поля «Uniformă / Halat / Ciupici / Vestă / Aparat telefon» сейчас хранятся как свободный текст (`uniformaId String?`). По ТЗ (Rubrici necesare, B121-B131 — «info de la depozit») это должна быть выдача со склада с учётом остатков.
|
||
|
||
Модуль `Inventory`:
|
||
- Реестр товаров склада (SKU, наименование, тип, размер, цвет, цена, остаток).
|
||
- CRUD для `hr_admin`, read для `hr_specialist`.
|
||
- В `BenefitDrawer` — `<Select>` с поиском по складу вместо `<TextInput>`.
|
||
- При выдаче `Benefit` уменьшается `stockQty` (атомарно в транзакции).
|
||
- При снятии выдачи — возвращается на склад.
|
||
|
||
## 2. Out of scope (для первой итерации)
|
||
|
||
- Учёт серий/партий с истекающим сроком годности
|
||
- Списание (write-off) с причиной — пока только повышение/понижение `stockQty` через CRUD
|
||
- Резервирование за сотрудником без выдачи
|
||
- Импорт остатков из Excel
|
||
- История движений (audit идёт через общий `AuditLog`, специальной таблицы движений нет)
|
||
|
||
## 3. Data model
|
||
|
||
```prisma
|
||
enum InventoryItemType {
|
||
uniforma
|
||
halat
|
||
ciupici
|
||
vesta
|
||
aparat_telefon
|
||
alte
|
||
}
|
||
|
||
model InventoryItem {
|
||
id String @id @default(uuid())
|
||
sku String @unique // артикул
|
||
name String // "Uniformă chirurgie M albastru"
|
||
type InventoryItemType
|
||
size String? // "M", "42"
|
||
color String?
|
||
pricePerUnit Decimal? @db.Decimal(10, 2)
|
||
stockQty Int @default(0)
|
||
active Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
// Обратные связи на Benefit (по типам)
|
||
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")
|
||
}
|
||
|
||
// Изменения в Benefit:
|
||
// - uniformaId String? → FK к InventoryItem (relation "BenefitUniforma")
|
||
// - halatId, ciupiciId, vestaId, aparatTelefonId — аналогично
|
||
```
|
||
|
||
### Миграция данных
|
||
|
||
Существующие свободные String значения в `Benefit.uniformaId/...` теряют связь — поле обнуляется (NULL). Сообщить HR-у в release notes, что прошлые «текстовые» значения нужно перевыбрать из Inventory.
|
||
|
||
## 4. API endpoints
|
||
|
||
Модуль `apps/api/src/modules/inventory/`:
|
||
|
||
| Method | Path | Role | Purpose |
|
||
|--------|------|------|---------|
|
||
| GET | `/api/v1/inventory` | hr_admin, hr_specialist | List + filter (type, search, активные) + pagination |
|
||
| GET | `/api/v1/inventory/:id` | hr_admin, hr_specialist | Один товар |
|
||
| POST | `/api/v1/inventory` | hr_admin | Создать |
|
||
| PATCH | `/api/v1/inventory/:id` | hr_admin | Обновить |
|
||
| DELETE | `/api/v1/inventory/:id` | hr_admin | Soft delete (`active = false` если есть выдачи, hard delete если их нет) |
|
||
| POST | `/api/v1/inventory/:id/adjust-stock` | hr_admin | `{ delta: number, reason: string }` — приход / списание |
|
||
|
||
Read-методы — `audit.logRead({ entity: 'InventoryItem' })`. Write — `logChange`.
|
||
|
||
## 5. Frontend
|
||
|
||
### `/inventory` — список
|
||
- Таблица: SKU / Denumire / Tip / Mărime / Culoare / Stoc / Preț / Acțiuni
|
||
- Filter bar: `<Select tip>`, `<Switch active only>`, search (SKU+name)
|
||
- Кнопка «Adaugă articol» → drawer
|
||
- Highlight: `stockQty === 0` → red row, `stockQty < 5` → amber
|
||
- Click row → drawer для редактирования
|
||
|
||
### Drawer (CRUD)
|
||
- Поля: SKU, Denumire, Tip (select 6), Mărime, Culoare, Preț unitar (MDL), Stoc inițial, Active toggle
|
||
- Валидация: SKU unique (handled на бэке), name required
|
||
|
||
### `/inventory/:id` — Stock adjust modal
|
||
- Открывается из строки таблицы или drawer
|
||
- Inputs: delta (число с +/-), reason (textarea)
|
||
- Audit log пишется обязательно
|
||
|
||
### Изменения в `BenefitDrawer.tsx`
|
||
- Заменить 5 `<TextInput>` (uniformaId, halatId, ciupiciId, vestaId, aparatTelefonId) на `<Select searchable>` с фильтром по `type`:
|
||
```ts
|
||
const { data: uniforme } = useQuery(['inventory', 'uniforma'],
|
||
() => apiClient.get('/inventory', { params: { type: 'uniforma', active: true } }));
|
||
```
|
||
- Подпись опции: `${item.sku} — ${item.name} (${item.size}, stoc: ${item.stockQty})`
|
||
- Disable опции с `stockQty === 0` (за исключением уже выбранной у текущего сотрудника)
|
||
|
||
### Изменения в `BeneficiiTab.tsx`
|
||
- Резолвить `benefit.uniforma?.name` (через `include` на бэке) вместо ID
|
||
- Показ: «Uniformă: Uniformă chirurgie M albastru (SKU-001)»
|
||
|
||
### Nav
|
||
- В `App.tsx` → `NAV_ITEMS`: `{ labelKey: 'nav.inventory', path: '/inventory', icon: <IconBox />, roles: ['hr_admin', 'hr_specialist'] }`
|
||
|
||
## 6. Бизнес-логика выдачи (atomic stock adjustment)
|
||
|
||
В `BenefitService.upsert(employeeId, dto)`:
|
||
1. Загрузить текущий `Benefit` (если есть) → diff `oldUniformaId vs newUniformaId`
|
||
2. В транзакции `prisma.$transaction`:
|
||
- Если `oldUniformaId` сменился: `stockQty++` для старого
|
||
- Если `newUniformaId` указан: проверить `stockQty > 0`, иначе `BadRequest('Stoc epuizat')`. Затем `stockQty--`
|
||
3. Upsert Benefit
|
||
4. Audit log
|
||
|
||
Аналогично для всех 5 полей.
|
||
|
||
## 7. Seed data
|
||
|
||
В `apps/api/prisma/seed.ts`:
|
||
- 4 модели Uniforme (M/L/XL chirurgie, M/L/XL ATI)
|
||
- 4 модели Halate
|
||
- 3 размера Ciupici (38-42, 43-46)
|
||
- 2 модели Veste (S, M)
|
||
- 2 модели Aparate telefon (Samsung A15, iPhone SE)
|
||
|
||
Каждая со `stockQty: 50`.
|
||
|
||
## 8. Implementation order
|
||
|
||
1. **Prisma migration** — модель `InventoryItem`, FK в `Benefit`, seed
|
||
2. **API module** — controller + service + DTOs
|
||
3. **Frontend types** + `/inventory` page + drawer
|
||
4. **BenefitDrawer rewrite** — селекты вместо TextInput
|
||
5. **BeneficiiTab** — показ резолвленных имён
|
||
6. **Atomic stock logic** в BenefitService
|
||
7. **Stock adjust** modal + endpoint
|