chore: remove plans and uploads from git tracking
@@ -9,3 +9,9 @@ server/prisma/dev.db-journal
|
||||
|
||||
# Image resize cache
|
||||
uploads/.cache/
|
||||
|
||||
# Server uploads directory (images)
|
||||
server/uploads/
|
||||
|
||||
# Plans and design docs
|
||||
.opencode/plans/
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
# Design: Доработка товара — удаление «под заказ», обязательные quantity и категория
|
||||
|
||||
**Дата:** 2026-05-15
|
||||
**Статус:** На согласовании
|
||||
|
||||
## Цель
|
||||
|
||||
Упростить модель товара: убрать концепцию «под заказ», сделать количество и категорию обязательными полями. Категория «Не указано» остаётся технической заглушкой для переноса товаров при удалении категории, но не видна в каталоге и не выбирается при редактировании.
|
||||
|
||||
## Архитектура изменений
|
||||
|
||||
### 1. База данных (Prisma)
|
||||
|
||||
**Миграция:**
|
||||
- Перед удалением полей: все товары с `inStock = false` получают `quantity = 0`
|
||||
- Удалить поля `inStock` и `leadTimeDays` из модели `Product`
|
||||
- Статус наличия определяется исключительно по `quantity`:
|
||||
- `quantity > 0` → «В наличии»
|
||||
- `quantity = 0` → «Нет в наличии»
|
||||
|
||||
**`server/prisma/schema.prisma`:**
|
||||
```prisma
|
||||
model Product {
|
||||
// ... остальные поля без изменений ...
|
||||
quantity Int @default(0)
|
||||
// УДАЛЕНО: inStock Boolean @default(true)
|
||||
// УДАЛЕНО: leadTimeDays Int?
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
|
||||
categoryId String
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Сервер — валидация и CRUD
|
||||
|
||||
**`server/src/routes/api/admin-products.js`:**
|
||||
|
||||
**CREATE (POST):**
|
||||
- `quantity` — required, `Int >= 0` (было nullable)
|
||||
- `categoryId` — required (было: при пустом → авто-назначение «Не указано»)
|
||||
- Удалить валидацию `leadTimeDays` при `!inStock`
|
||||
- Удалить принудительную установку `quantity = 1` для «под заказ»
|
||||
- Вернуть 400: `'Укажите категорию'` если `categoryId` отсутствует
|
||||
|
||||
**UPDATE (PATCH):**
|
||||
- `quantity` — required, `Int >= 0` (было nullable)
|
||||
- `categoryId` — required (было: при пустом → «Не указано»)
|
||||
- Удалить логику очистки `leadTimeDays` при `inStock = true`
|
||||
- Удалить принудительную установку `quantity = 1`
|
||||
- Вернуть 400 при отсутствии `categoryId`
|
||||
|
||||
**JSON Schema:**
|
||||
- `CREATE_PRODUCT_SCHEMA`: убрать `leadTimeDays`, сделать `quantity` required (убрать `nullable`)
|
||||
- `PATCH_PRODUCT_SCHEMA`: убрать `leadTimeDays`, `quantity` — если передан, то `>= 0`
|
||||
|
||||
**`server/src/routes/api/public-catalog.js`:**
|
||||
- Удалить ветку `availability === 'in_stock'` и `availability === 'made_to_order'`
|
||||
- Фильтрация «в наличии» больше не нужна — все товары в каталоге
|
||||
|
||||
### 3. Клиент — админка (две страницы)
|
||||
|
||||
**`client/src/pages/admin/ui/AdminPage.tsx`** и **`client/src/pages/admin-products/ui/AdminProductsPage.tsx`:**
|
||||
|
||||
**FormState:**
|
||||
- Удалить `inStock: boolean` и `leadTimeDays: string`
|
||||
- `quantity: string` — без nullable-семантики
|
||||
|
||||
**UI:**
|
||||
- Удалить Switch «В наличии / Под заказ»
|
||||
- Удалить TextField «Срок исполнения, дней»
|
||||
- TextField «Количество»:
|
||||
- Без helper «Оставьте пустым...»
|
||||
- Новый helper: «0 = нет в наличии»
|
||||
- Валидация: не может быть пустым, `parseInt >= 0`
|
||||
- Select «Категория»:
|
||||
- Удалить `<MenuItem value="">` с «Не указано»
|
||||
- Валидация: не даёт сохранить без выбранной категории
|
||||
- Показать ошибку при попытке сохранить без категории
|
||||
|
||||
**Submit-валидация:**
|
||||
- Удалить проверку `leadTimeDays` при `!inStock`
|
||||
- Добавить проверку: `categoryId` не пустой → blocking error
|
||||
- Добавить проверку: `quantity` не пустой → blocking error
|
||||
|
||||
### 4. Клиент — каталог
|
||||
|
||||
**`client/src/entities/product/ui/ProductCard.tsx`:**
|
||||
- Удалить логику `'Под заказ · {leadTimeDays} дн.'`
|
||||
- Новый статус:
|
||||
- `quantity > 0` → «В наличии» (зелёный)
|
||||
- `quantity === 0` → «Нет в наличии» (серый/red)
|
||||
|
||||
**`client/src/pages/product/ui/ProductPage.tsx`:**
|
||||
- Удалить chip `'Под заказ · {leadTimeDays} дн.'`
|
||||
- Удалить alert `'Этот товар изготавливается под заказ...'`
|
||||
- Статус определяется по `quantity`
|
||||
|
||||
**`client/src/pages/checkout/ui/CheckoutPage.tsx`:**
|
||||
- Удалить определение made-to-order товаров в корзине
|
||||
- Удалить info alert о доставке после изготовления
|
||||
|
||||
### 5. Клиент — фильтры
|
||||
|
||||
**`client/src/pages/home/lib/use-product-filters.ts`:**
|
||||
- Удалить `availability: 'all' | 'in_stock' | 'made_to_order'` из state
|
||||
- Удалить `availability` из параметров `fetchPublicProducts()`
|
||||
|
||||
**`client/src/pages/home/ui/ProductFilters.tsx`:**
|
||||
- Удалить `ToggleButtonGroup` с `'all'`, `'in_stock'`, `'made_to_order'`
|
||||
- Удалить отображение категории «Не указано» из списка чипов (фильтр `cat.slug !== 'ne-ukazano'`)
|
||||
|
||||
### 6. Категория «Не указано» — что остаётся
|
||||
|
||||
| Где | Что происходит |
|
||||
|---|---|
|
||||
| `server/src/lib/default-category.js` | **Остаётся** — функция `getOrCreateUnspecifiedCategory()` |
|
||||
| `server/src/index.js` | **Остаётся** — вызов при старте |
|
||||
| `server/src/routes/api/admin-categories.js` | **Остаётся** — нельзя удалить/переименовать; при удалении категории товары переезжают в «Не указано» |
|
||||
| Админка категорий | **Остаётся** — кнопка удаления заблокирована |
|
||||
| Фильтры каталога | **Скрыта** — не показывается в чипах |
|
||||
| Форма товара | **Скрыта** — не выбирается в Select |
|
||||
|
||||
## Статус товара — новая логика
|
||||
|
||||
```
|
||||
quantity > 0 → «В наличии» (зелёный chip/badge)
|
||||
quantity = 0 → «Нет в наличии» (серый chip/badge)
|
||||
```
|
||||
|
||||
Никаких других статусов. Поле `inStock` больше не существует.
|
||||
|
||||
## Файлы для изменения
|
||||
|
||||
### Сервер
|
||||
| Файл | Изменения |
|
||||
|---|---|
|
||||
| `server/prisma/schema.prisma` | Удалить `inStock`, `leadTimeDays` |
|
||||
| `server/src/routes/api/admin-products.js` | Валидация, schema, убрать логику под заказ |
|
||||
| `server/src/routes/api/public-catalog.js` | Убрать фильтр availability |
|
||||
|
||||
### Клиент
|
||||
| Файл | Изменения |
|
||||
|---|---|
|
||||
| `client/src/pages/admin/ui/AdminPage.tsx` | FormState, UI, валидация |
|
||||
| `client/src/pages/admin-products/ui/AdminProductsPage.tsx` | FormState, UI, валидация |
|
||||
| `client/src/entities/product/ui/ProductCard.tsx` | Статус по quantity |
|
||||
| `client/src/pages/product/ui/ProductPage.tsx` | Убрать под заказ UI |
|
||||
| `client/src/pages/checkout/ui/CheckoutPage.tsx` | Убрать made-to-order detection |
|
||||
| `client/src/pages/home/ui/ProductFilters.tsx` | Убрать availability toggle, скрыть «Не указано» |
|
||||
| `client/src/pages/home/lib/use-product-filters.ts` | Убрать `availability` |
|
||||
|
||||
## Миграция данных
|
||||
|
||||
```javascript
|
||||
// В Prisma migration:
|
||||
// 1. UPDATE Product SET quantity = 0 WHERE inStock = false
|
||||
// 2. ALTER TABLE Product DROP COLUMN inStock
|
||||
// 3. ALTER TABLE Product DROP COLUMN leadTimeDays
|
||||
```
|
||||
|
||||
## Тестирование
|
||||
|
||||
**Сервер:**
|
||||
- CREATE без categoryId → 400
|
||||
- CREATE без quantity → 400
|
||||
- CREATE с quantity = 0 → OK
|
||||
- PATCH без categoryId → 400
|
||||
- PATCH с quantity = 0 → OK
|
||||
|
||||
**Клиент:**
|
||||
- Форма не сохраняется без категории
|
||||
- Форма не сохраняется без количества
|
||||
- Фильтры не содержат «Под заказ» и «Не указано»
|
||||
- Карточка товара показывает «Нет в наличии» при quantity = 0
|
||||
@@ -14,19 +14,18 @@ describe('persistMultipartImages with eager mode', () => {
|
||||
await fs.promises.unlink(path.join(UPLOADS_DIR, file)).catch(() => {})
|
||||
}
|
||||
}
|
||||
const cacheDir = path.join(UPLOADS_DIR, '.cache')
|
||||
await fs.promises.rm(cacheDir, { recursive: true, force: true }).catch(() => {})
|
||||
})
|
||||
|
||||
it('returns WebP URLs when eager=true', async () => {
|
||||
const sharp = (await import('sharp')).default
|
||||
const testImagePath = path.join(UPLOADS_DIR, `${TEST_PREFIX}original.png`)
|
||||
|
||||
const filesBefore = await fs.promises.readdir(UPLOADS_DIR)
|
||||
|
||||
await sharp({ create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } } })
|
||||
.png()
|
||||
.toFile(testImagePath)
|
||||
|
||||
const filesBefore = await fs.promises.readdir(UPLOADS_DIR)
|
||||
|
||||
const mockRequest = {
|
||||
isMultipart: () => true,
|
||||
parts: async function* () {
|
||||
@@ -51,7 +50,13 @@ describe('persistMultipartImages with eager mode', () => {
|
||||
|
||||
// Verify the intermediate PNG file written by persistMultipartImages was deleted
|
||||
const filesAfter = await fs.promises.readdir(UPLOADS_DIR)
|
||||
const newPngFiles = filesAfter.filter((f) => !filesBefore.includes(f) && f.endsWith('.png'))
|
||||
const newPngFiles = filesAfter.filter(
|
||||
(f) =>
|
||||
!filesBefore.includes(f) &&
|
||||
f.endsWith('.png') &&
|
||||
f !== path.basename(testImagePath) &&
|
||||
!f.startsWith('test-eager-uuid-'),
|
||||
)
|
||||
expect(newPngFiles).toHaveLength(0)
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 13 MiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 15 MiB |
|
Before Width: | Height: | Size: 13 MiB |
|
Before Width: | Height: | Size: 13 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |