# Florashop.market — texnik spetsifikatsiya

**Versiya:** 0.1 · **Sana:** 2026-10-29 · **Holat:** MVP ishlab chiqarishda, to'lov va kuryer sinov rejimida

Florashop — gul do'konlari uchun multi-vendor marketplace (Flowwow / Uber Eats modeli). Xaridor yaqin do'kondan guldasta tanlaydi, pul **escrow** (ushlab turish) holatiga o'tadi, do'kon 10 daqiqada tasdiqlaydi, guldastani yasab **haqiqiy rasmini** yuklaydi, xaridor tasdiqlagach Yandex Delivery kuryeri avtomatik chaqiriladi, yetkazilgach komissiya ushlanib qolgan pul do'kon balansiga tushadi.

Asosiy muammolar va ularning yechimi:

| Muammo | Yechim |
|---|---|
| Gul tez so'liydi, stokni kuzatish qiyin | Ko'p mahsulot `is_made_to_order` (buyurtma bo'yicha yasaladi); tayyor zaxira bo'lsa `stock_qty` tranzaksiya ichida `UPDATE … WHERE stock_qty >= qty` bilan kamayadi, bekor qilinsa qaytadi |
| Do'kon javob bermaydi | 10 daqiqalik BullMQ taymeri → eng yaqin o'xshash do'konga avtomatik yo'naltirish (2 martagacha) → bo'lmasa bekor + to'liq qaytarish |
| Xaridor rasmdagi sifatni talab qiladi | Majburiy foto-nazorat: kamida 800 px, SHA-256 bo'yicha qayta ishlatish taqiqlangan, 3 urinish, 20 daqiqada avto-tasdiq |
| Nozik logistika | Kuryer faqat rasm tasdiqlangach chaqiriladi; Yandex `courier` klassi + `thermobag`, izoh "vertikal tashing"; kuryer topilmasa 3 martagacha qayta chaqirish |
| Ishonch | Pul yetkazilguncha ushlab turiladi; do'konga tushum 24 soat `balance_pending` da (nizo oynasi) |

---

## 1. Arxitektura

```mermaid
flowchart LR
  subgraph Clients
    C[Xaridor<br/>florashop.market]
    TGC[Telegram Mini App<br/>xaridor]
    S[Do'kon paneli<br/>/store — Web + Telegram WebApp]
    BOT[Telegram bot<br/>do'kon bildirishnomalari]
  end
  CF[Cloudflare] --> NG[Nginx aaPanel<br/>statik + /api proxy + /uploads]
  C & TGC & S --> CF
  NG --> API[API — Fastify / Node 20<br/>:3480]
  API --> PG[(PostgreSQL 16<br/>+ PostGIS 3.4)]
  API --> R[(Redis 7<br/>BullMQ + rate-limit)]
  W[Worker — BullMQ<br/>orders · delivery · notify] --> PG
  W --> R
  W --> YD[Yandex Delivery<br/>B2B Cargo API v2]
  YD -- callback --> NG
  W --> TG[Telegram Bot API]
  TG -- webhook --> NG
  TG --> BOT
  API --> PAY[To'lov provayderi<br/>hold / capture / void]
```

**Komponentlar**

| Komponent | Texnologiya | Vazifa |
|---|---|---|
| `api` | Node 20, Fastify 4, `pg`, JSON-schema validatsiya (AJV) | REST API, auth, upload, webhooklar |
| `worker` | BullMQ 5 | Taymerlar, kuryer chaqirish, Yandex sinxroni, Telegram navbati (25 msg/s limit) |
| `db` | PostgreSQL 16 + PostGIS 3.4 | Barcha ma'lumot, `geography(Point,4326)` + GiST indeks |
| `redis` | Redis 7, AOF, `noeviction` | Navbatlar (yo'qolmasligi shart), rate-limit hisoblagichlari |
| `web` | Vanilla ES modules, Leaflet + OSM | Xaridor SPA, do'kon paneli, shu hujjat |

**Nima uchun Fastify, NestJS emas:** MVP uchun kam qatlam, JSON-schema validatsiya va yuqori tezlik o'rnatilgan holda keladi. Domen mantiq (`orders.js`, `fsm.js`, `escrow.js`, `delivery.js`) HTTP qatlamidan ajratilgan — kerak bo'lsa NestJS modullariga ko'chirish mexanik ish.

**Tranzaksiya qoidasi:** har bir holat o'zgarishi `SELECT … FOR UPDATE` bilan buyurtmani qulflaydi, `order_events` ga yozadi va bitta tranzaksiyada bajariladi. Tashqi ta'sirlar (navbatga qo'yish, Telegram) faqat `COMMIT` dan keyin ishga tushadi — tranzaksiya qaytsa yolg'on bildirishnoma ketmaydi.

**Idempotentlik:** `POST /orders` `Idempotency-Key` sarlavhasi bilan; escrow yozuvlari `idempotency_key UNIQUE` (`hold:<order>`, `capture:<order>` …); webhooklar `webhook_inbox (source, dedup_key) UNIQUE`; BullMQ job ID'lari deterministik (`accept-<order>-<reroute>`).

---

## 2. Ma'lumotlar bazasi (PostgreSQL + PostGIS)

To'liq DDL: [`schema.sql`](schema.sql) (`api/migrations/001_init.sql`). Pul summalari `bigint` — valyutaning platformada ishlatiladigan eng kichik butun birligida (UZS uchun so'm). Har jadvalda `created_at/updated_at`, `updated_at` trigger orqali.

```mermaid
erDiagram
  users ||--o{ stores : owns
  users ||--o{ store_members : ""
  stores ||--o{ store_members : ""
  stores ||--o{ products : sells
  categories ||--o{ products : ""
  users ||--o{ orders : places
  stores ||--o{ orders : fulfils
  orders ||--o{ order_events : history
  orders ||--o{ order_photos : "foto-nazorat"
  orders ||--o{ escrow_transactions : ledger
  stores ||--o{ escrow_transactions : ""
  orders ||--o{ delivery_claims : "Yandex claim"
  orders ||--o| reviews : ""
```

### 2.1 Asosiy jadvallar

**`users`** — mijoz, do'kon egasi/xodimi, kuryer, admin.

| Ustun | Tip | Izoh |
|---|---|---|
| id | uuid PK | `gen_random_uuid()` |
| role | `user_role` enum | customer, store_owner, store_staff, courier, admin |
| phone / email / telegram_id | varchar / citext / bigint, har biri UNIQUE | kamida bittasi majburiy (CHECK) |
| password_hash | text | scrypt (N=16384) |
| token_version | int | "hamma qurilmadan chiqish" uchun JWT bekor qilish |

**`stores`** — do'kon profili.

| Ustun | Tip | Izoh |
|---|---|---|
| owner_id | uuid FK → users | RESTRICT |
| location | `geography(Point,4326)` | GiST indeks `stores_location_gix` |
| delivery_radius_m | int 1000–50000 | do'kon o'zi yetkazadigan maksimal radius |
| working_hours | jsonb | `{"mon":["09:00","21:00"], "sun":null}` — tungi slot (`22:00–04:00`) ham qo'llanadi |
| timezone, currency, country_code | | xalqaro: har do'kon o'z vaqt zonasi va valyutasida |
| commission_rate | numeric(5,4) | standart 0.15, admin do'kon bo'yicha o'zgartiradi |
| balance / balance_pending | bigint | yechib olinadigan / 24 soat nizo oynasidagi |
| rating, rating_count, acceptance_rate | | qayta yo'naltirishda saralash uchun |
| telegram_chat_id | bigint | bot orqali ulanadi (`/start link_<code>`) |
| status | `store_status` | pending_review → active / paused / blocked |

Indekslar: `GiST(location)`, `(status, is_online) WHERE status='active'`, `(owner_id)`.

**`products`**

| Ustun | Tip | Izoh |
|---|---|---|
| store_id | FK → stores CASCADE | |
| category_id | FK → categories | |
| price / old_price | bigint, CHECK old_price > price | |
| images, composition | jsonb | `[{"name":"Atirgul","qty":25}]` |
| is_made_to_order | bool | true — stok cheklanmaydi |
| stock_qty | int ≥ 0 | tayyor guldastalar |
| freshness_until | timestamptz | tayyor guldasta yaroqlilik muddati |
| status | `product_status` | draft, active, out_of_stock, archived |
| search_tsv | tsvector GENERATED | GIN indeks, `plainto_tsquery` |

**`orders`**

| Ustun | Tip | Izoh |
|---|---|---|
| number | bigint UNIQUE (sequence 100001…) | foydalanuvchiga ko'rinadigan raqam |
| customer_id, store_id, original_store_id | FK | yo'naltirilganda store_id o'zgaradi |
| status | `order_status` | 16 holat, §3 |
| items | jsonb snapshot | narx/nom buyurtma paytidagidek saqlanadi |
| delivery_location | geography(Point) | GiST |
| items_total, delivery_fee, total_amount | bigint | CHECK `total = items + delivery` |
| commission_rate, commission_amount, store_payout | | yaratilganda (va yo'naltirilganda) hisoblanadi |
| accept_deadline_at | timestamptz | 10 daqiqalik taymer |
| reroute_count, tried_store_ids uuid[] | | qayta urinish nazorati |
| photo_attempts | smallint | ≤ 3 |
| idempotency_key | UNIQUE | |
| version | int | har o'tishda +1 (optimistik tekshiruv uchun) |

Indekslar: `(customer_id, created_at DESC)`, `(store_id, status, created_at DESC)`, qisman `(status) WHERE status NOT IN (final)`, `GiST(delivery_location)`.

**`order_photos`** — foto-nazorat.

| Ustun | Izoh |
|---|---|
| order_id, store_id, uploaded_by, attempt | |
| url, sha256, width, height, size_bytes, mime | magic-byte bo'yicha aniqlanadi (JPEG/PNG/WEBP), o'lcham sarlavhadan o'qiladi |
| status | pending / approved / rejected / auto_approved |
| reject_reason, reviewed_at | |
| **UNIQUE (store_id, sha256)** | bir rasmni boshqa buyurtmaga qayta yuklab bo'lmaydi |

**`escrow_transactions`** — o'zgarmas (append-only) pul daftari.

| type | Qachon | Summa |
|---|---|---|
| HOLD | buyurtma yaratilganda | total_amount |
| VOID | bekor qilinganda (hold bo'shatiladi) | total_amount |
| CAPTURE | COMPLETED | total_amount |
| COMMISSION | COMPLETED | commission + delivery_fee (platformaga) |
| STORE_CREDIT | COMPLETED, `meta.pending=true` → 24 soatdan keyin `false` | store_payout |
| REFUND / ADJUSTMENT | nizo hal qilinganda | qaytarilgan summa / do'kondan yechilgan |
| PAYOUT | do'kon pul yechganda (`pending` → bank) | |

`idempotency_key UNIQUE` — takroriy job yoki webhook ikki marta pul yozmaydi. Balans o'zgarishi va daftar yozuvi bitta tranzaksiyada.

**Yordamchi:** `order_events` (to'liq audit), `delivery_claims` (Yandex claim holati, kuryer, narx, raw JSON), `reviews`, `webhook_inbox` (dedup), `telegram_links` (30 daqiqalik ulash kodi), `store_members` (do'konga bir nechta xodim).

### 2.2 Geo-so'rov (5–10 km radius)

```sql
SELECT p.*, s.name, round(ST_Distance(s.location, $1::geography)) AS distance_m
FROM products p
JOIN stores s ON s.id = p.store_id
WHERE s.status = 'active' AND s.is_online AND p.status = 'active'
  AND ST_DWithin(s.location, $1::geography, LEAST($2, s.delivery_radius_m))
ORDER BY distance_m, s.rating DESC
LIMIT 24 OFFSET 0;
```

`ST_DWithin` geography ustida GiST indeksdan foydalanadi; `LEAST` — xaridor tanlagan radius (1–10 km) va do'konning o'z radiusidan kichigi. Buyurtma yaratishda masofa yana bir bor tekshiriladi (`out_of_delivery_zone`).

---

## 3. Buyurtma hayot sikli (State Machine)

```mermaid
stateDiagram-v2
  [*] --> CREATED
  CREATED --> PAYMENT_HELD: provider.hold()
  PAYMENT_HELD --> AWAITING_STORE: taymer 10 daq
  AWAITING_STORE --> ACCEPTED: do'kon qabul qildi
  AWAITING_STORE --> AWAITING_STORE: timeout / rad → yaqin do'konga (≤2)
  AWAITING_STORE --> CANCELLED: muqobil do'kon yo'q → VOID
  ACCEPTED --> ASSEMBLING
  ASSEMBLING --> PHOTO_REVIEW: rasm yuklandi
  PHOTO_REVIEW --> PHOTO_APPROVED: mijoz ✓ yoki 20 daq avto
  PHOTO_REVIEW --> PHOTO_REJECTED: mijoz ✗
  PHOTO_REJECTED --> PHOTO_REVIEW: yangi rasm (≤3)
  PHOTO_REVIEW --> CANCELLED: 3-rad → VOID
  PHOTO_APPROVED --> COURIER_SEARCH: Yandex claim
  COURIER_SEARCH --> COURIER_ASSIGNED: performer_found
  COURIER_SEARCH --> COURIER_SEARCH: topilmadi → qayta (≤3)
  COURIER_ASSIGNED --> PICKED_UP: pickuped
  PICKED_UP --> DELIVERED: delivered
  DELIVERED --> COMPLETED: CAPTURE + komissiya + STORE_CREDIT
  COURIER_SEARCH --> DISPUTED: 3 marta topilmadi
  PICKED_UP --> DISPUTED: returning / failed
  DISPUTED --> COMPLETED: admin
  DISPUTED --> REFUNDED: admin
  CANCELLED --> [*]
  COMPLETED --> [*]
  REFUNDED --> [*]
```

Ruxsat etilgan o'tishlar `api/src/fsm.js` dagi `TRANSITIONS` jadvalida; boshqasi `409 invalid_transition`. Joriy jadval `GET /api/v1/meta` da ham qaytadi.

### 3.1 Biznes qoidalari

| # | Qoida | Amalga oshirish |
|---|---|---|
| 1 | Buyurtmada pul darhol yechilmaydi, ushlab turiladi | `holdPayment` → `HOLD` yozuvi, `PAYMENT_HELD` |
| 2 | Do'konga 10 daqiqa | `accept_deadline_at`, BullMQ delayed job `accept-<id>-<reroute>`; Telegram tugmalari ✅/❌ |
| 2a | Javob yo'q yoki rad | `findAlternativeStore`: faol, onlayn, yetkazish radiusida, avval urinilmagan, shu kategoriyada narxi `items_total` ning 70–115% oralig'idagi mahsulot bor; masofa → reyting → acceptance_rate bo'yicha. Narx xaridor uchun o'zgarmaydi |
| 2b | 2 marta yo'naltirildi yoki muqobil yo'q | `CANCELLED` + `VOID`, stok qaytadi |
| 2c | Do'kon ishonchliligi | timeout/rad — `acceptance_rate × 0.95`, qabul — `+0.05` (EMA) |
| 3 | Foto-nazorat majburiy | faqat ACCEPTED/ASSEMBLING/PHOTO_REJECTED da yuklanadi; ≥ 800 px; `UNIQUE(store_id, sha256)` |
| 3a | Mijoz javob bermasa | 20 daqiqadan keyin `auto_approved` |
| 3b | 3 marta rad | `CANCELLED (photo_rejected_limit)`, pul to'liq qaytadi |
| 4 | Kuryer faqat tasdiqdan keyin | `PHOTO_APPROVED` → `dispatch-courier` job → Yandex `claims/create` → `ready_for_approval` da `claims/accept` |
| 4a | Kuryer topilmadi | 30 s kechikish bilan qayta claim, jami 3 urinish, keyin `DISPUTED` |
| 5 | Yetkazildi → COMPLETED | `CAPTURE`, `COMMISSION` (komissiya + yetkazish haqi platformaga), `STORE_CREDIT` → `balance_pending` |
| 5a | Nizo oynasi | 24 soatdan keyin `release-payout` job pulni `balance` ga o'tkazadi |
| 6 | Mijoz bekor qilishi | faqat `ACCEPTED` gacha (gul hali kesilmagan) |

### 3.2 Pul hisobi (misol)

Guldasta 854 000 + yetkazish 19 000 = **873 000** ushlab turiladi. Komissiya 15% × 854 000 = **128 100**. Do'konga **725 900** (`store_payout = items_total − commission`). Platforma: 128 100 + 19 000 (Yandex'ga to'lanadigan yetkazish).

### 3.3 Taymerlar va navbatlar

| Navbat / job | Kechikish | Ish |
|---|---|---|
| `orders` / `store-accept-timeout` | 600 s | qayta yo'naltirish yoki bekor |
| `orders` / `photo-auto-approve` | 1200 s | rasmni avto-tasdiqlash |
| `orders` / `complete-order` | darhol | escrow taqsimoti |
| `orders` / `release-payout` | 86 400 s | pending → balance |
| `delivery` / `dispatch-courier` | darhol / 30 s | Yandex claim yaratish |
| `delivery` / `sync-claim` | callback'da darhol, aks holda har 60 s | `claims/info` → holat |
| `notify` / `telegram` | darhol, 25/s limit, 8 urinish | bildirishnoma |

Barcha job'lar 5 marta eksponensial backoff bilan qayta uriniladi; handler'lar idempotent (holat tekshiriladi, eskirgan job jim o'tadi).

---

## 4. Yandex Delivery integratsiyasi

```mermaid
sequenceDiagram
  participant W as Worker
  participant Y as Yandex B2B Cargo
  participant N as /webhooks/yandex-delivery
  W->>Y: POST /b2b/cargo/integration/v2/claims/create?request_id=uuid
  Y-->>W: {id, status: new, version}
  Y->>N: POST ?secret=…&claim_id=…&updated_ts=…
  N->>W: sync-claim job (dedup webhook_inbox)
  W->>Y: POST /claims/info?claim_id
  Y-->>W: status=ready_for_approval
  W->>Y: POST /claims/accept {version}
  Y->>N: performer_found → pickuped → delivered
  W->>W: COURIER_ASSIGNED → PICKED_UP → DELIVERED → COMPLETED
```

- Claim: 2 nuqta (source = do'kon, destination = qabul qiluvchi), `taxi_class: courier`, `cargo_options: [thermobag]`, `callback_properties.callback_url` maxfiy kalit bilan.
- Callback faqat signal: haqiqiy holat har doim `claims/info` dan olinadi (soxta callback holatni o'zgartira olmaydi).
- Holatlar xaritasi: `new…performer_draft` → COURIER_SEARCH; `performer_found, pickup_arrived` → COURIER_ASSIGNED; `pickuped, delivery_arrived` → PICKED_UP; `delivered(_finish)` → DELIVERED; `failed, cancelled*, performer_not_found` → qayta chaqirish; `returning…` → DISPUTED.
- Narx: `POST /check-price` (token bo'lsa), aks holda tarif `15 000 + 2 500 × km`, 1 000 ga yaxlitlanadi.
- **Simulyator:** `YANDEX_DELIVERY_TOKEN` bo'sh bo'lsa `sim-…` claim yaratiladi va worker xuddi shu `syncClaim` yo'li orqali holatlarni 20 s qadam bilan o'tkazadi. Token qo'shilgach kod o'zgarmaydi.

---

## 5. REST API (v1)

Bazaviy URL: `https://api.florashop.market/v1` (muqobil: `https://florashop.market/api/v1`). Hamkor paneli: `https://partner.florashop.market`. Format JSON, vaqt ISO-8601 UTC. Auth: `Authorization: Bearer <JWT>` (HS256, 30 kun). Xatolar:

```json
{ "error": "out_of_delivery_zone", "details": { "distance_m": 13120, "radius_m": 12000 } }
```

| HTTP | Ma'no |
|---|---|
| 401 | `auth_required`, `invalid_token`, `invalid_credentials` |
| 403 | `forbidden`, `not_store_member` |
| 404 | `*_not_found` |
| 409 | `invalid_transition`, `invalid_state`, `photo_reused`, `out_of_stock`, `insufficient_balance` |
| 422 | `validation_error` (AJV), `out_of_delivery_zone` |
| 429 | `rate_limited` (global 300/daq/IP, auth 10/daq, buyurtma 20/daq) |

### 5.1 Auth va onboarding

| Metod | Yo'l | Tavsif |
|---|---|---|
| POST | `/auth/register` | `{phone:"+998…", password(≥8), full_name}` → `201 {token, user}` |
| POST | `/auth/login` | `{phone, password}` → `{token, user}` |
| POST | `/auth/telegram` | `{init_data}` — Telegram WebApp `initData`, HMAC-SHA256 (`WebAppData`) tekshiruvi, 24 soat |
| POST | `/auth/logout-all` | `token_version++` |
| GET | `/me` | `{user, stores:[{id,name,status,role}]}` |
| POST | `/stores` | do'kon yaratish (onboarding), egasi `store_owner` bo'ladi |
| GET | `/stores/my` · `/stores/:id` | profil + statistika (yangi, faol, 30 kunlik daromad) |
| PATCH | `/stores/:id` | ish vaqti, radius, onlayn, rekvizitlar… |
| POST | `/stores/:id/telegram-link` | `{code, url:"https://t.me/<bot>?start=link_<code>"}` |

```http
POST /api/v1/stores
{ "name": "Lola Gullari", "phone": "+998901234567", "city": "Toshkent",
  "address": "Amir Temur 15", "lat": 41.3111, "lng": 69.2797,
  "delivery_radius_m": 10000, "prep_time_min": 45,
  "working_hours": { "mon": ["09:00","21:00"], "sun": null } }
→ 201 { "id": "…", "slug": "lola-gullari-3fa2", "status": "pending_review", "commission_rate": 0.15, … }
```

### 5.2 Katalog (PostGIS)

| Metod | Yo'l | Tavsif |
|---|---|---|
| GET | `/categories` | |
| GET | `/catalog?lat&lng&radius_km=7&category&q&min_price&max_price&sort&limit&offset` | radius 1–10 km; sort: `distance, popular, rating, price_asc, price_desc` |
| GET | `/stores/nearby?lat&lng&radius_km` | xarita uchun do'konlar |
| GET | `/products/:id` | |

```json
GET /api/v1/catalog?lat=41.30&lng=69.27&radius_km=5&limit=1
{ "items": [{ "id": "…", "name": "25 ta qizil atirgul", "price": 450000, "currency": "UZS",
  "images": ["/uploads/products/…jpg"], "category": "roses", "store_id": "…",
  "store_name": "Lola Gullari", "store_rating": 4.9, "distance_m": 981,
  "is_open_now": true, "eta_min": 53 }], "next_offset": 1, "radius_km": 5 }
```

### 5.3 Buyurtmalar va foto-nazorat

| Metod | Yo'l | Kim | Tavsif |
|---|---|---|---|
| POST | `/delivery/quote` | hamma | `{store_id, lat, lng}` → `{price, distance_km, source}` |
| POST | `/orders` | mijoz | `Idempotency-Key` sarlavhasi; javob — to'liq buyurtma |
| GET | `/orders` · `/orders/:id` | mijoz | tafsilot: `events[]`, `photos[]`, `delivery` |
| POST | `/orders/:id/cancel` | mijoz | faqat ACCEPTED gacha |
| POST | `/orders/:id/photos/:photoId/approve` | mijoz | → PHOTO_APPROVED, kuryer chaqiriladi |
| POST | `/orders/:id/photos/:photoId/reject` | mijoz | `{reason}` (≥3 belgi) |
| POST | `/orders/:id/review` | mijoz | `{rating 1–5, text}` — faqat COMPLETED |
| GET | `/stores/:id/orders?scope=new\|active\|done\|all` | do'kon | |
| GET | `/store-orders/:id` | do'kon | anonim buyurtmada mijoz ma'lumoti yashiriladi |
| POST | `/store-orders/:id/accept` · `/reject` · `/start` | do'kon | |
| POST | `/store-orders/:id/photos` | do'kon | `multipart/form-data`, `file` (JPEG/PNG/WEBP, ≤ 8 MB, ≥ 800 px) |

```http
POST /api/v1/orders
Idempotency-Key: 7c0e…
{ "items": [{ "product_id": "…", "qty": 1 }],
  "recipient_name": "Malika", "recipient_phone": "+998907776655",
  "address": "Shayxontohur 12", "lat": 41.32, "lng": 69.29,
  "card_text": "Tug'ilgan kuning bilan!", "deliver_at": "2026-10-30T10:00:00Z" }
→ 201 { "id": "…", "number": 100002, "status": "AWAITING_STORE",
        "items_total": 854000, "delivery_fee": 19000, "total_amount": 873000,
        "commission_amount": 128100, "store_payout": 725900,
        "accept_deadline_at": "2026-10-29T17:58:37Z",
        "events": [{"to_status":"CREATED"},{"to_status":"PAYMENT_HELD"},{"to_status":"AWAITING_STORE"}], … }
```

```http
POST /api/v1/store-orders/{id}/photos   (multipart: file=@bouquet.jpg)
→ 201 { "photo": { "id": "…", "attempt": 1, "width": 1200, "height": 1200, "status": "pending" },
        "order": { "status": "PHOTO_REVIEW", … } }
409 { "error": "photo_reused" }   — shu rasm avval yuklangan
```

### 5.4 Mahsulotlar, balans

| Metod | Yo'l | Tavsif |
|---|---|---|
| GET/POST | `/stores/:id/products` | |
| PATCH/DELETE | `/products/:id` | DELETE = arxivlash |
| POST | `/uploads/product-image` | ≥ 400 px |
| GET | `/stores/:id/balance` | `{balance, balance_pending, ledger[]}` |
| POST | `/stores/:id/payouts` | `{amount ≥ 10 000}`, faqat egasi, rekvizit majburiy |

### 5.5 Webhooklar

| Metod | Yo'l | Himoya |
|---|---|---|
| POST | `https://api.florashop.market/v1/webhooks/yandex-delivery?secret=…&claim_id=…` | maxfiy kalit (timing-safe), noma'lum claim e'tiborsiz, `webhook_inbox` dedup, holat `claims/info` dan qayta o'qiladi |
| POST | `/webhooks/telegram/:secret` | yo'ldagi kalit + `X-Telegram-Bot-Api-Secret-Token`, `update_id` dedup |

Telegram bot: `/start` — panel tugmasi; `/start link_<code>` — do'kon chatini ulash; inline `acc:<order>` / `rej:<order>` — 10 daqiqalik buyurtmani to'g'ridan bot ichida qabul/rad.

### 5.6 Admin

`GET /admin/overview` (holatlar, 30 kunlik komissiya, navbatlar), `GET /admin/stores?status`, `POST /admin/stores/:id/status {status, commission_rate}`, `GET /admin/orders`, `POST /admin/orders/:id/resolve {action: complete|refund, amount, reason}`.

---

## 6. Xavfsizlik

- Parol: scrypt + salt, `timingSafeEqual`. JWT HS256, `token_version` bilan bekor qilinadi.
- Har yo'lda AJV sxema, `additionalProperties: false`; SQL faqat parametrlar bilan (dinamik ustun nomlari faqat sxema ro'yxatidan).
- Rate-limit Redis'da (ko'p instansiyada ham ishlaydi), Cloudflare real IP (`CF-Connecting-IP`).
- Upload: magic-byte tekshiruvi (kengaytma emas), 8 MB, tasodifiy UUID nom, `/uploads` faqat statik (PHP/skript bajarilmaydi).
- Do'kon ma'lumotiga kirish — `store_members` orqali; mijoz faqat o'z buyurtmasini ko'radi.
- Webhooklar maxfiy kalit bilan, Yandex holati doim server tomonidan qayta so'raladi.
- Konteynerlar `node` foydalanuvchisi, xotira cheklovi, portlar faqat `127.0.0.1`.

## 7. Deploy

```
~/florashop
├── docker-compose.yml        db (PostGIS) · redis · migrate · api · worker
├── .env                      (600) — kalitlar ~/.secrets/.env dan
├── api/                      Fastify API + worker + migrations + seed + testlar
├── web/                      statik frontend → /var/www/florashop.market/public
└── docs/SPEC.md              shu hujjat → /docs/
```

Nginx (aaPanel): `/` statik, `/api/` → `127.0.0.1:3480`, `/uploads/` → `/var/www/florashop.market/uploads` (30 kun kesh). Migratsiya `pg_advisory_lock` bilan, `schema_migrations` jadvali.

## 8. Ishga tushirish uchun qolgan tashqi qadamlar

| # | Nima | Kim | Holat |
|---|---|---|---|
| 1 | Yandex Delivery B2B shartnoma + OAuth token → `FLORASHOP_YANDEX_DELIVERY_TOKEN` | ega | kutilmoqda (hozir simulyator) |
| 2 | Hold qo'llaydigan to'lov provayderi (Payme Subscribe API `receipts.create/pay hold` yoki Click/Uzcard hold) → `escrow.js` ga provayder qo'shish | ega + dev | kutilmoqda (hozir `mock`) |
| 3 | BotFather'da do'kon boti → `FLORASHOP_BOT_TOKEN`, `FLORASHOP_BOT_USERNAME`, webhook o'rnatish | ega | kutilmoqda |
| 4 | Cloudflare Origin Certificate → `/etc/ssl/florashop/` | ega | kutilmoqda |
| 5 | Oferta, maxfiylik siyosati, do'kon shartnomasi | yurist | — |

## 9. Keyingi bosqich (roadmap)

1. Real to'lov provayderi (hold/capture/void) va do'konlarga avtomatik payout.
2. SSE/WebSocket bilan jonli holat (hozir 7–15 s polling).
3. ru/en interfeys (backend `locale` va `categories.name` allaqachon ko'p tilli).
4. Rasmni avtomatik tekshirish: EXIF vaqti, perceptual hash (o'xshash rasmlar), AI sifat bahosi.
5. Kuryer o'rnini xaritada jonli ko'rsatish (`claims/performer-position`).
6. Promo-kodlar, sevimlilar, takroriy buyurtma (8-mart, 14-fevral).
7. Read-replica + PgBouncer, API gorizontal kengaytirish (holat to'liq PG/Redis'da, API stateless).
