Initial commit — DLS lab-app Flutter project

This commit is contained in:
egecankomur
2026-06-10 23:22:15 +03:00
commit d1acc1d367
225 changed files with 31294 additions and 0 deletions
+418
View File
@@ -0,0 +1,418 @@
# DLS — Veritabanı Referansı (Directus)
> **Backend:** Appwrite → **Directus** migrasyonu. Kaynak gerçek bu dokümandır.
> Oluşturulma: 2026-06-05 (Directus MCP ile canlıdan inşa edildi).
## 1. Bağlantı bilgileri
| | Değer |
|---|---|
| Directus URL | (Coolify'da set edilecek) |
| Auth | Directus built-in JWT (email + şifre) |
| Storage | Directus Files (directus_files) |
| Tenant izolasyonu | `tenants` + `tenant_members` tabloları |
> **Appwrite → Directus eşlemesi:**
> - `Appwrite Team` → `tenants` koleksiyonu
> - `Appwrite Team membership` → `tenant_members` koleksiyonu
> - `Appwrite Auth` → `directus_users` (Directus built-in)
> - `Appwrite Storage buckets` → `directus_files` (tek bucket, `kind` alanıyla ayrım)
> - Row-level security → Directus policies + `$CURRENT_USER` + Flutter tarafında `tenant_id` filtresi
## 2. Uygulama özeti (DLS — Dental Lab System)
**Diş klinikleri ↔ diş laboratuvarları** arasındaki protez iş alışverişini dijitalleştirir.
- Klinik bir hasta için protez işi açar → bağlı laboratuvara yollar.
- Lab gelen kutusundan görür, durum adımlarını işler: **Ölçü → Alt Yapı Prova → Üst Yapı Prova → Cila/Bitim**.
- Tamamlanınca iş `sent` → klinik teslim alınca `delivered`.
- Her iki taraf finansal akışı kendi defterinde izler.
## 3. Multi-tenancy & yetki modeli
- **Tenant = `tenants` satırı.** Her tenant'ın bir `kind`'ı var: `clinic` veya `lab`.
- Kullanıcı`tenant_members` üzerinden bir veya birden fazla tenant'a bağlanır.
- **`member_number`** (12 hane, unique): tenant'ın **bağlantı kodu**. Login'de kullanılmaz; sadece iki tenant'ı eşlemek için.
- Flutter tarafında her sorguda `tenant_id` (veya `clinic_tenant_id`/`lab_tenant_id`) filtresi **zorunlu**.
- Cross-tenant tablolar (`connections`, `jobs`, `job_files`, `job_status_history`): hem `clinic_tenant_id` hem `lab_tenant_id` taşır — iki taraf da erişir.
## 4. Koleksiyonlar (17)
> Notasyon: `IDX`=index, `UNQ`=unique, `FK`=foreign key, `DEF`=default.
> Tüm tablolarda sistem alanları: `id` (uuid PK), `date_created`, `date_updated` (varsa).
### `tenants` — tenant profili (Appwrite Team karşılığı)
| Alan | Tip | Not |
|---|---|---|
| id | uuid | PK |
| kind | enum[lab\|clinic] | tenant türü · **IDX** |
| member_number | string(12) | bağlantı kodu · **UNQ** |
| company_name | string(255) | |
| company_tax_id | string(50) | |
| company_address | text | |
| company_email | string(255) | |
| company_phone | string(30) | |
| logo | uuid | → `directus_files` |
| default_currency | string(8) | DEF: `TRY` |
| status | enum[active\|suspended] | DEF: `active` |
### `tenant_members` — kullanıcı ↔ tenant üyeliği
| Alan | Tip | Not |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | → `tenants` CASCADE · **IDX** |
| user_id | uuid | → `directus_users` CASCADE |
| role | enum[owner\|admin\|member] | DEF: `member` |
### `profiles` — kullanıcı başına ek bilgi
| Alan | Tip | Not |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | → `tenants` |
| user_id | uuid | → `directus_users` |
| display_name | string(255) | |
| phone | string(30) | |
| title | string(100) | |
### `connections` — iki tenant arası bağlantı
| Alan | Tip | Not |
|---|---|---|
| id | uuid | PK |
| clinic_tenant_id | uuid | → `tenants` · **IDX** |
| lab_tenant_id | uuid | → `tenants` · **IDX** |
| status | enum[pending\|approved\|rejected] | DEF: `pending` · **IDX** |
| requested_by | uuid | → `directus_users` |
| requested_at | timestamp | |
| approved_at | timestamp | |
| rejected_at | timestamp | |
> **Unique constraint:** `(clinic_tenant_id, lab_tenant_id)` — aynı çift iki kez bağlanamaz. Flutter tarafında kontrol edilmeli; DB constraint Directus MCP ile eklenemez, migration SQL ile ekle.
### `patients` — klinik hasta kayıtları
| Alan | Tip | Not |
|---|---|---|
| id | uuid | PK |
| clinic_tenant_id | uuid | → `tenants` · **IDX** |
| created_by | uuid | → `directus_users` |
| patient_code | string(50) | klinik içinde unique (soft) |
| first_name | string(100) | |
| last_name | string(100) | |
| phone | string(30) | |
| date_of_birth | date | |
| notes | text | |
| archived | boolean | DEF: false |
### `jobs` — protez işi (çekirdek tablo — en ağır)
| Alan | Tip | Not |
|---|---|---|
| id | uuid | PK |
| clinic_tenant_id | uuid | → `tenants` · **IDX** |
| lab_tenant_id | uuid | → `tenants` · **IDX** |
| created_by | uuid | → `directus_users` |
| patient_id | uuid | → `patients` SET NULL · **IDX** |
| patient_code | string(50) | |
| prosthetic_id | uuid | → `prosthetics` SET NULL |
| prosthetic_type | enum[metal_porselen\|zirkonyum\|implant_ustu_zirkonyum\|gecici\|e_max\|diger] | |
| member_count | integer | üye (diş) sayısı |
| teeth | json | diş numaraları dizisi `List<String>` |
| color | string(20) | Vita renk kodu |
| description | text | |
| price | decimal(10,2) | |
| currency | string(8) | |
| status | enum[pending\|in_progress\|sent\|delivered\|cancelled] | DEF: `pending` · **IDX** |
| current_step | enum[olcu\|alt_yapi_prova\|ust_yapi_prova\|cila_bitim] | |
| location | enum[at_clinic\|at_lab] | DEF: `at_clinic` |
| due_date | timestamp | |
**Query pattern (Flutter):**
```
// Lab gelen kutusu
GET /items/jobs?filter[lab_tenant_id][_eq]=$tenantId&filter[status][_eq]=pending
&sort=-date_created&limit=50&page=1
// Klinik giden işler
GET /items/jobs?filter[clinic_tenant_id][_eq]=$tenantId
&sort=-date_created&limit=50&page=1
// Filtre + arama
&filter[status][_in]=pending,in_progress
&search=hasta_kodu
```
### `job_files` — işe bağlı dosyalar
| Alan | Tip | Not |
|---|---|---|
| id | uuid | PK |
| job_id | uuid | → `jobs` CASCADE · **IDX** |
| clinic_tenant_id | uuid | → `tenants` CASCADE · **IDX** |
| lab_tenant_id | uuid | → `tenants` CASCADE · **IDX** |
| uploaded_by | uuid | → `directus_users` |
| file_id | uuid | → `directus_files` SET NULL |
| kind | enum[scan\|image\|document] | |
| name | string(255) | |
| size | integer | bayt |
| mime_type | string(100) | |
| archived_at | timestamp | soft-delete; set → download disabled |
### `job_status_history` — stepper denetim izi
| Alan | Tip | Not |
|---|---|---|
| id | uuid | PK |
| job_id | uuid | → `jobs` CASCADE · **IDX** |
| clinic_tenant_id | uuid | → `tenants` |
| lab_tenant_id | uuid | → `tenants` |
| step | enum[olcu\|alt_yapi_prova\|ust_yapi_prova\|cila_bitim] | |
| completed_by | uuid | → `directus_users` |
| completed_at | timestamp | |
| note | text | |
### `prosthetics` — lab ürün kataloğu
| Alan | Tip | Not |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | → `tenants` · **IDX** |
| created_by | uuid | → `directus_users` |
| name | string(255) | |
| type | enum[...prosthetic_types...] | |
| unit_price | decimal(10,2) | |
| currency | string(8) | DEF: `TRY` |
| archived | boolean | DEF: false · **IDX** |
### `finance_entries` — tek-taraflı defter
| Alan | Tip | Not |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | → `tenants` · **IDX** |
| created_by | uuid | → `directus_users` |
| job_id | uuid | → `jobs` SET NULL |
| counterpart_tenant_id | uuid | → `tenants` SET NULL |
| type | enum[income\|expense\|receivable\|payable] | |
| amount | decimal(12,2) | |
| currency | string(8) | |
| status | enum[pending\|paid\|cancelled] | DEF: `pending` · **IDX** |
| date | timestamp | **IDX** |
| description | text | |
> **Cross-tenant sync (Directus Flow):** İş `sent`/`delivered` olunca:
> - Lab tarafı → `receivable/pending` satır
> - Klinik tarafı → `payable/pending` satır
> Idempotent: `(job_id, tenant_id, type)` kombinasyonu varsa atla.
### `payments` — ödeme kayıtları
| Alan | Tip | Not |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | → `tenants` |
| counterpart_tenant_id | uuid | → `tenants` |
| direction | enum[inflow\|outflow] | |
| amount | decimal(12,2) | |
| currency | string(8) | |
| payment_date | timestamp | |
| method | string(30) | |
| notes | text | |
| recorded_by | uuid | → `directus_users` |
| status | enum[pending\|confirmed\|rejected] | DEF: `confirmed` |
### `clinic_pricing` — kliniğe özel lab fiyatı
| Alan | Tip | Not |
|---|---|---|
| id | uuid | PK |
| lab_tenant_id | uuid | → `tenants` CASCADE |
| clinic_tenant_id | uuid | → `tenants` CASCADE |
| prosthetic_type | enum[...] | |
| custom_unit_price | decimal(10,2) | |
| discount_percent | decimal(5,2) | |
| currency | string(8) | |
| created_by | uuid | → `directus_users` |
> **Unique:** `(lab_tenant_id, clinic_tenant_id, prosthetic_type)` — Flutter tarafında kontrol et.
### `notifications`
| Alan | Tip | Not |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | → `tenants` · **IDX** |
| user_id | uuid | → `directus_users` CASCADE |
| job_id | uuid | → `jobs` SET NULL |
| connection_id | uuid | → `connections` SET NULL |
| message | string(500) | |
| read | boolean | DEF: false · **IDX** |
| severity | enum[info\|warning] | DEF: `info` |
### `audit_logs`
| Alan | Tip | Not |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | → `tenants` |
| user_id | uuid | → `directus_users` |
| action | enum[create\|update\|delete] | |
| entity_type | string(50) | |
| entity_id | string(36) | |
| changes | json | |
| ip_address | string(50) | |
| user_agent | string(500) | |
### `invite_links`
| Alan | Tip | Not |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | → `tenants` CASCADE |
| code | string(32) | **UNQ** |
| email | string(255) | |
| role | enum[admin\|member] | |
| status | enum[pending\|accepted\|cancelled\|expired] | DEF: `pending` |
| invited_by | uuid | → `directus_users` |
| expires_at | timestamp | |
| accepted_at | timestamp | |
| accepted_by | uuid | → `directus_users` |
### `user_preferences`
| Alan | Tip | Not |
|---|---|---|
| id | uuid | PK |
| user_id | uuid | → `directus_users` CASCADE |
| theme | enum[light\|dark\|system] | DEF: `system` |
| color_theme | string(50) | |
## 5. İş akışı (jobs lifecycle)
```
Klinik açar (pending)
→ Lab "İşleme Al" (in_progress, currentStep: alt_yapi_prova, location: at_lab)
→ Lab "Kliniğe Gönder" (location: at_clinic) — prova için
→ Klinik "Provayı Onayla" (currentStep++, location: at_lab)
→ Klinik "Düzeltme İste" (location: at_lab, step aynı)
→ [Cila/Bitim sonrası] Lab "Kliniğe Gönder" (status: sent, location: at_clinic)
→ Klinik "Teslim Al" (status: delivered)
→ finance-sync tetiklenir
→ job_files arşivlenir (archived_at set)
```
**Adım sırası:** `olcu → alt_yapi_prova → ust_yapi_prova → cila_bitim`
> Not: `acceptJob` = olcu tamamlandı anlamına gelir; `currentStep` direkt `alt_yapi_prova`'ya atlar.
## 6. Query Optimizasyon Kuralları (Flutter)
### Zorunlu filtreler — hiç ihmal etme
```dart
// Her jobs sorgusunda tenant filtresi ZORUNLU
filter: {
'lab_tenant_id': {'_eq': tenantId}, // lab tarafı
// veya
'clinic_tenant_id': {'_eq': tenantId}, // klinik tarafı
}
```
### Sayfalama — sonsuz liste / cursor
```dart
// İlk yükleme
limit: 30, page: 1
// Sonraki sayfa (offset bazlı)
limit: 30, offset: 30
// Toplam sayı için ayrı istek (pahalı — sadece gerektiğinde)
meta: 'filter_count'
```
### Field projection — sadece gerekeni çek
```dart
// Jobs listesi — detay alanlarını çekme
fields: ['id', 'patient_code', 'prosthetic_type', 'status',
'current_step', 'location', 'date_created', 'due_date',
'clinic_tenant_id.company_name', 'lab_tenant_id.company_name']
// Jobs detay — tam veri
fields: ['*', 'patient_id.*', 'job_files.*']
```
### Relation join — N+1 önleme
```dart
// Kötü: jobs çek → her iş için ayrı tenant sorgusu
// İyi: nested fields ile tek sorguda
fields: ['*', 'clinic_tenant_id.company_name', 'lab_tenant_id.company_name']
```
### Finance listesi — tarih aralığı ile kes
```dart
filter: {
'tenant_id': {'_eq': tenantId},
'date': {'_gte': '2026-01-01'}, // son 6 ay gibi
'status': {'_neq': 'cancelled'},
}
sort: ['-date']
limit: 50
```
## 7. Klinik vs Lab — Ayrı Akışlar
| Ekran | Klinik | Lab |
|---|---|---|
| Ana sayfa | Gönderilen işler özeti, bekleyen ödemeler | Gelen işler özeti, işlemdeki işler |
| İşler ana liste | Giden işler (`clinic_tenant_id`) | Gelen işler (`lab_tenant_id`) |
| İş aksiyon butonu | Provayı Onayla / Düzeltme İste / Teslim Al | İşleme Al / Kliniğe Gönder |
| Ürünler | — | Katalog CRUD |
| Hastalar | Hasta kayıtları | — |
| Bağlantı kur | Lab'ı `member_number` ile ara | Gelen talepleri onayla/reddet |
| Finans | Borçlar (payable) | Alacaklar (receivable) |
## 8. Flutter → Directus API pattern
```dart
// Auth
POST /auth/login
body: { email, password }
returns: { access_token, refresh_token, expires }
// Token yenileme
POST /auth/refresh
body: { refresh_token }
// Koleksiyon okuma
GET /items/{collection}?filter[field][_eq]=value&limit=30&sort=-date_created
// Tekil kayıt
GET /items/{collection}/{id}?fields=*,relation.*
// Oluşturma
POST /items/{collection}
body: { field: value, ... }
// Güncelleme
PATCH /items/{collection}/{id}
body: { field: value }
// Dosya yükleme
POST /files
Content-Type: multipart/form-data
field: file (binary)
returns: { id, filename_download, ... }
```
## 9. Directus Flows (server-side otomasyonlar)
Aşağıdaki iş mantıkları Flutter client'tan değil Directus Flow'dan tetiklenmeli:
| Tetikleyici | Flow | Açıklama |
|---|---|---|
| `jobs.status → sent\|delivered` | finance-sync | Lab receivable + klinik payable oluştur (idempotent) |
| `jobs.status → delivered` | archive-job-files | `job_files.archived_at` set et |
| `jobs` UPDATE | audit-log | `audit_logs` satır yaz |
| `connections.status → approved\|rejected` | notify-connection | İlgili tarafa bildirim gönder |
> Flows henüz oluşturulmadı — bir sonraki adım.
## 10. Eksik kısıtlamalar (SQL ile eklenecek)
Directus MCP composite unique constraint desteklemiyor; veritabanına direkt SQL ile eklenecek:
```sql
-- connections çifti unique
ALTER TABLE connections
ADD CONSTRAINT connections_pair_unique UNIQUE (clinic_tenant_id, lab_tenant_id);
-- clinic_pricing üçlüsü unique
ALTER TABLE clinic_pricing
ADD CONSTRAINT clinic_pricing_triple_unique
UNIQUE (lab_tenant_id, clinic_tenant_id, prosthetic_type);
```