admin frontend elkezdése, járművek tisztázása, frontend fejleszts
This commit is contained in:
445
plans/cost_billing_analysis_premium_breakdown_v1.md
Normal file
445
plans/cost_billing_analysis_premium_breakdown_v1.md
Normal file
@@ -0,0 +1,445 @@
|
||||
# Költség/Billing Rendszer Elemzés & Módosítási Javaslat (v2)
|
||||
## Premium szintű költségbontás — Subscription-alapú kategória láthatóság + Km tracking
|
||||
|
||||
**Készült:** 2026.06.12.
|
||||
**Verzió:** v2.0 (User feedback alapján átdolgozva)
|
||||
**Státusz:** Jóváhagyásra vár
|
||||
|
||||
---
|
||||
|
||||
## 1. Jelenlegi Állapot Elemzés
|
||||
|
||||
### 1.1 Előfizetési Architektúra
|
||||
|
||||
| Komponens | Tábla | Mező | Státusz |
|
||||
|-----------|-------|------|---------|
|
||||
| [`User`](backend/app/models/identity/identity.py:122) | `identity.users` | `subscription_plan: str` (default="FREE") | ✅ Megvan |
|
||||
| [`User`](backend/app/models/identity/identity.py:122) | `identity.users` | `subscription_expires_at: datetime` | ✅ Megvan |
|
||||
| [`User`](backend/app/models/identity/identity.py:122) | `identity.users` | `ui_mode: str` ("personal"/"business") | ✅ Megvan |
|
||||
| [`Organization`](backend/app/models/marketplace/organization.py:35) | `marketplace.organizations` | `subscription_plan: str` (default="FREE") | ✅ Megvan |
|
||||
| [`SubscriptionTier`](backend/app/models/core_logic.py:12) | `system.subscription_tiers` | `name: str`, `rules: JSONB` | ✅ Megvan |
|
||||
| [`subscription_service.py`](backend/app/services/) | **Hiányzó fájl** | Gitea #239 | ❌ **Nincs meg** |
|
||||
| [`subscription_worker.py`](backend/app/workers/system/subscription_worker.py:32) | Cron robot | Auto-downgrade expired → FREE | ✅ Megvan |
|
||||
|
||||
### 1.2 Költség Modellek
|
||||
|
||||
#### [`AssetCost`](backend/app/models/vehicle/asset.py:239) (`vehicle.asset_costs`) — **Modern/Aktív tábla**
|
||||
- **Használja:** [`POST /expenses/`](backend/app/api/v1/endpoints/expenses.py:12), [`CostService.record_cost()`](backend/app/services/cost_service.py:23)
|
||||
- **Mezői:** `id` (UUID), `asset_id`, `organization_id`, `category_id`, `amount_net`, `currency`, `date`, `invoice_number`, `data` (JSONB)
|
||||
- **`data` JSONB tárol:** `mileage_at_cost`, `description`
|
||||
- **⚠️ Nincs subscription check** — bármely felhasználó bármilyen adatot rögzíthet
|
||||
|
||||
#### [`CostCategory`](backend/app/models/vehicle/vehicle.py:19) (`vehicle.cost_categories`)
|
||||
- **Meglévő mezők:** `id`, `parent_id` (hierarchia), `code`, `name`, `description`, `is_system`, `visibility` ("both"/"b2b"), `accounting_code`
|
||||
- **Jelenlegi visibility map:** FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, PARKING, TOLL, OTHER → `"both"` ; FINANCE, ADMIN → `"b2b"`
|
||||
- **❌ Nincs `subscription_tier` mező** — nem lehet megkülönböztetni FREE és PREMIUM kategóriákat
|
||||
|
||||
#### [`Asset.current_mileage`](backend/app/models/vehicle/asset.py:111) — **Meglévő km mező**
|
||||
- `current_mileage: Mapped[int] = mapped_column(Integer, default=0, index=True)` — Az Asset modellen
|
||||
- **❌ Probléma:** Nincs automatizmus, ami frissítené amikor költséget rögzítenek
|
||||
- **❌ Probléma:** Nincs history tábla a km állás változásainak követésére
|
||||
|
||||
#### [`AssetTelemetry`](backend/app/models/vehicle/asset.py:336) (`vehicle.asset_telemetry`)
|
||||
- 1:1 kapcsolat Asset-tel, `current_mileage: Mapped[int]` mezővel
|
||||
- **Technikai adósság:** Duplikálja az `Asset.current_mileage` mezőt
|
||||
|
||||
#### [`VehicleOdometerState`](backend/app/models/vehicle/vehicle.py:111) (`vehicle.vehicle_odometer_states`)
|
||||
- Legacy tábla — `vehicle_model_definitions.id`-hez kötődik (nem az Asset UUID-hez)
|
||||
- Tárol: `last_recorded_odometer`, `daily_avg_distance`, `estimated_current_odometer`
|
||||
- **Nem használható** az új Asset-alapú architektúrában
|
||||
|
||||
### 1.3 API Végpontok Állapota
|
||||
|
||||
| Végpont | Fájl | Subscription Check | Megjegyzés |
|
||||
|---------|------|-------------------|------------|
|
||||
| `POST /expenses/` | [`expenses.py:12`](backend/app/api/v1/endpoints/expenses.py:12) | ❌ | Csak DRAFT limit check van |
|
||||
| `GET /{vehicle_id}/summary` | [`analytics.py:66`](backend/app/api/v1/endpoints/analytics.py:66) | ❌ | TCO summary |
|
||||
| `GET /dashboard` | [`analytics.py:199`](backend/app/api/v1/endpoints/analytics.py:199) | ❌ | Mock data |
|
||||
| `POST /upgrade` | [`billing.py:20`](backend/app/api/v1/endpoints/billing.py:20) | ✅ | Megvan |
|
||||
|
||||
### 1.4 Hiányosságok (Gap Analysis)
|
||||
|
||||
| # | Hiányosság | Leírás | Súlyosság |
|
||||
|---|-----------|--------|-----------|
|
||||
| 1 | **Nincs subscription gating** | A `POST /expenses/` nem ellenőrzi a subscription_plan-t | 🔴 Kritikus |
|
||||
| 2 | **Hiányzó `subscription_service.py`** | Gitea #239 — entitlement mátrix hiányzik | 🔴 Blokkoló |
|
||||
| 3 | **Nincs `CostCategory.min_tier` mező** | Nem lehet megkülönböztetni FREE/PREMIUM kategóriákat | 🟡 Magas |
|
||||
| 4 | **Km tracking nincs centralizálva** | `Asset.current_mileage` létezik, de nincs automatikus frissítés | 🟡 Magas |
|
||||
| 5 | **Nincs OdometerLog history** | Nincs audit trail a km állás változásokról | 🟡 Közepes |
|
||||
| 6 | **Párhuzamos telemetry tábla** | `AssetTelemetry` duplikálja `Asset.current_mileage`-t | 🟠 Alacsony |
|
||||
|
||||
---
|
||||
|
||||
## 2. Módosítási Javaslat (Logic Spec v2)
|
||||
|
||||
### 2.1 CostCategory Bővítés — `min_tier` mező
|
||||
|
||||
**Nem kell új `UserCostCategory` modell!** A meglévő [`CostCategory`](backend/app/models/vehicle/vehicle.py:19) kap egy új mezőt:
|
||||
|
||||
```python
|
||||
class CostCategory(Base):
|
||||
# ... meglévő mezők ...
|
||||
|
||||
# ÚJ: Minimum előfizetési szint a kategória használatához
|
||||
min_tier: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="free",
|
||||
server_default="'free'"
|
||||
)
|
||||
```
|
||||
|
||||
**CostCategory seed adat — kategória szintek:**
|
||||
|
||||
| Kategória CSoport | `code` értékek | `visibility` | `min_tier` | Leírás |
|
||||
|-------------------|---------------|-------------|-----------|--------|
|
||||
| **Alap (FREE)** | FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, OTHER | `both` | `free` | Mindenki számára elérhető |
|
||||
| **Kiterjesztett (PREMIUM)** | PARKING, TOLL, FINANCE, ADMIN | `both`/`b2b` | `premium` | Csak PREMIUM+ tagoknak |
|
||||
| **Vállalati (VIP)** | *(speciális account kódok)* | `b2b` | `vip` | Csak VIP ügyfeleknek |
|
||||
|
||||
### 2.2 Odometer Rendszer — Centralizált Km Tracking
|
||||
|
||||
#### 2.2.1 Új modell: [`OdometerReading`](backend/app/models/vehicle/asset.py)
|
||||
|
||||
```python
|
||||
class OdometerReading(Base):
|
||||
"""
|
||||
Kilométeróra állások history audit trail.
|
||||
Minden esemény (költség, szerviz, telemetria) itt rögzíti a km állást.
|
||||
Csak APPEND-only — soha nem törlődik, csak új rekord kerül hozzá.
|
||||
"""
|
||||
__tablename__ = "odometer_readings"
|
||||
__table_args__ = {"schema": "vehicle"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
asset_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PG_UUID(as_uuid=True),
|
||||
ForeignKey("vehicle.assets.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False
|
||||
)
|
||||
mileage: Mapped[int] = mapped_column(Integer, nullable=False) # km állás
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False
|
||||
)
|
||||
source: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
# source lehet: 'cost_entry', 'manual', 'telemetry', 'import', 'service_event'
|
||||
source_id: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
# source_id lehet: a kapcsolódó AssetCost UUID-ja, vagy esemény ID
|
||||
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="odometer_readings")
|
||||
```
|
||||
|
||||
#### 2.2.2 [`Asset`](backend/app/models/vehicle/asset.py:69) — Bővítés
|
||||
|
||||
```python
|
||||
class Asset(Base):
|
||||
# ... meglévő mezők ...
|
||||
|
||||
# MEGLÉVŐ — marad, de automatikusan frissül:
|
||||
current_mileage: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||
|
||||
# ÚJ kapcsolat:
|
||||
odometer_readings: Mapped[List["OdometerReading"]] = relationship(
|
||||
"OdometerReading", back_populates="asset"
|
||||
)
|
||||
```
|
||||
|
||||
#### 2.2.3 [`CostService.record_cost()`](backend/app/services/cost_service.py:23) — Km frissítés
|
||||
|
||||
```python
|
||||
class CostService:
|
||||
async def record_cost(self, db: AsyncSession, cost_in: AssetCostCreate, user_id: int):
|
||||
# 1. Meglévő logika (OCR, XP, currency conversion) ...
|
||||
mileage = cost_in.data.get("mileage_at_cost") if cost_in.data else None
|
||||
|
||||
# 2. NEW: OdometerReading log + Asset.current_mileage frissítés
|
||||
if mileage is not None:
|
||||
# a) Logold a km állást
|
||||
reading = OdometerReading(
|
||||
asset_id=cost_in.asset_id,
|
||||
mileage=mileage,
|
||||
source="cost_entry",
|
||||
source_id=str(new_cost.id)
|
||||
)
|
||||
db.add(reading)
|
||||
|
||||
# b) Frissítsd az Asset.current_mileage mezőt (ha nagyobb)
|
||||
asset = await db.get(Asset, cost_in.asset_id)
|
||||
if asset and mileage > asset.current_mileage:
|
||||
asset.current_mileage = mileage
|
||||
|
||||
# 3. Meglévő logika folytatása ...
|
||||
```
|
||||
|
||||
### 2.3 `subscription_service.py` — Entitlement Mátrix (Gitea #239)
|
||||
|
||||
```python
|
||||
"""
|
||||
subscription_service.py — Gitea #239
|
||||
Feature entitlement mátrix subscription szintek szerint.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
class Feature(str, Enum):
|
||||
EXTENDED_CATEGORIES = "extended_categories" # PARKING, TOLL, FINANCE, ADMIN
|
||||
DOCUMENT_ATTACHMENT = "document_attachment"
|
||||
VAT_TRACKING = "vat_tracking"
|
||||
TCO_BREAKDOWN = "tco_breakdown"
|
||||
EXPORT_CSV = "export_csv"
|
||||
ANALYTICS_DASHBOARD = "analytics_dashboard"
|
||||
|
||||
FEATURE_ENTITLEMENTS = {
|
||||
"FREE": [
|
||||
Feature.TCO_BREAKDOWN, # Basic összesített TCO
|
||||
],
|
||||
"PREMIUM": [
|
||||
Feature.EXTENDED_CATEGORIES, # Kiterjesztett kategóriák
|
||||
Feature.DOCUMENT_ATTACHMENT,
|
||||
Feature.VAT_TRACKING,
|
||||
Feature.TCO_BREAKDOWN, # Részletes bontás
|
||||
Feature.EXPORT_CSV,
|
||||
Feature.ANALYTICS_DASHBOARD,
|
||||
],
|
||||
"VIP": [
|
||||
Feature.EXTENDED_CATEGORIES,
|
||||
Feature.DOCUMENT_ATTACHMENT,
|
||||
Feature.VAT_TRACKING,
|
||||
Feature.TCO_BREAKDOWN,
|
||||
Feature.EXPORT_CSV,
|
||||
Feature.ANALYTICS_DASHBOARD,
|
||||
],
|
||||
}
|
||||
|
||||
async def check_feature_access(db, user_id, feature):
|
||||
"""Ellenőrzi a feature elérést a user subscription alapján."""
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
return False
|
||||
return feature in FEATURE_ENTITLEMENTS.get(user.subscription_plan, [])
|
||||
|
||||
async def require_feature(db, user_id, feature):
|
||||
"""403-as hibát dob ha nincs hozzáférés."""
|
||||
if not await check_feature_access(db, user_id, feature):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Feature '{feature.value}' requires PREMIUM subscription"
|
||||
)
|
||||
|
||||
async def get_available_categories(db, user_id):
|
||||
"""Visszaadja a user számára elérhető CostCategory listát."""
|
||||
user = await db.get(User, user_id)
|
||||
plan = user.subscription_plan if user else "FREE"
|
||||
|
||||
query = """
|
||||
SELECT * FROM vehicle.cost_categories
|
||||
WHERE min_tier IN ('free', :plan)
|
||||
ORDER BY parent_id NULLS FIRST, sort_order
|
||||
"""
|
||||
result = await db.execute(text(query), {"plan": plan.lower()})
|
||||
return result.mappings().all()
|
||||
```
|
||||
|
||||
### 2.4 Végpont Módosítások
|
||||
|
||||
#### 2.4.1 [`POST /expenses/`](backend/app/api/v1/endpoints/expenses.py:12)
|
||||
|
||||
```python
|
||||
@router.post("/", status_code=201)
|
||||
async def create_expense(
|
||||
expense: AssetCostCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# 1. Ellenőrizd, hogy a kategória elérhető-e a user számára
|
||||
category = await db.get(CostCategory, expense.category_id)
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
|
||||
if category.min_tier != "free" and current_user.subscription_plan == "FREE":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Category '{category.name}' requires PREMIUM subscription"
|
||||
)
|
||||
|
||||
# 2. PREMIUM+ dokumentum csatolás check
|
||||
if expense.document_id:
|
||||
await require_feature(db, current_user.id, Feature.DOCUMENT_ATTACHMENT)
|
||||
|
||||
# 3. Létrehozás
|
||||
new_cost = AssetCost(
|
||||
asset_id=expense.asset_id,
|
||||
organization_id=organization_id,
|
||||
category_id=expense.category_id,
|
||||
amount_net=expense.amount_net,
|
||||
currency=expense.currency,
|
||||
date=expense.date,
|
||||
invoice_number=expense.invoice_number,
|
||||
data=expense.data
|
||||
)
|
||||
db.add(new_cost)
|
||||
await db.flush() # Kell az ID-hoz
|
||||
|
||||
# 4. Odometer frissítés (ha van mileage_at_cost)
|
||||
if expense.data and expense.data.get("mileage_at_cost"):
|
||||
mileage = int(expense.data["mileage_at_cost"])
|
||||
reading = OdometerReading(
|
||||
asset_id=expense.asset_id,
|
||||
mileage=mileage,
|
||||
source="cost_entry",
|
||||
source_id=str(new_cost.id)
|
||||
)
|
||||
db.add(reading)
|
||||
|
||||
asset = await db.get(Asset, expense.asset_id)
|
||||
if asset and mileage > asset.current_mileage:
|
||||
asset.current_mileage = mileage
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_cost)
|
||||
```
|
||||
|
||||
#### 2.4.2 `GET /cost-categories/` — ÚJ végpont
|
||||
|
||||
```python
|
||||
@router.get("/cost-categories/")
|
||||
async def list_cost_categories(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Visszaadja a user számára elérhető költségkategóriákat."""
|
||||
categories = await get_available_categories(db, current_user.id)
|
||||
return categories
|
||||
```
|
||||
|
||||
### 2.5 [`AssetCost`](backend/app/models/vehicle/asset.py:239) — Bővítés (minimális)
|
||||
|
||||
Csak a `document_id` mező kerül hozzáadásra (az Evidence Storeba mutató hivatkozás).
|
||||
A többi funkció (VAT, notes) a meglévő `data` JSONB-ben tárolható, és csak PREMIUM-nak elérhető a frontenden.
|
||||
|
||||
```python
|
||||
class AssetCost(Base):
|
||||
# ... meglévő mezők ...
|
||||
|
||||
# ÚJ (opcionális, PREMIUM feature):
|
||||
document_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True))
|
||||
```
|
||||
|
||||
### 2.6 Admin Kontroll (SystemParameter)
|
||||
|
||||
| Paraméter | Scope | Default | Leírás |
|
||||
|-----------|-------|---------|--------|
|
||||
| `COST_EXTENDED_CATEGORIES_ENABLED` | GLOBAL | `true` | Kiterjesztett kategóriák bekapcsolása |
|
||||
| `COST_VAT_TRACKING_ENABLED` | GLOBAL | `false` | ÁFA követés bekapcsolása |
|
||||
| `COST_ODOMETER_REQUIRED` | GLOBAL | `true` | Km állás kötelező legyen-e |
|
||||
|
||||
---
|
||||
|
||||
## 3. Folyamatábra
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph REQUEST["POST /expenses/"]
|
||||
A[Bejövő kérés] --> B{Van category_id?}
|
||||
B -->|Igen| C[CostCategory lekérése]
|
||||
C --> D{min_tier == free?}
|
||||
D -->|Igen| E[Engedélyezve]
|
||||
D -->|Nem| F{User subscription?}
|
||||
F -->|FREE| G[403 - Premium feature]
|
||||
F -->|PREMIUM+| H[Engedélyezve]
|
||||
E --> I[AssetCost létrehozása]
|
||||
H --> I
|
||||
I --> J{Van mileage_at_cost?}
|
||||
J -->|Igen| K[OdometerReading log]
|
||||
K --> L{ mileage > Asset.current_mileage ?}
|
||||
L -->|Igen| M[Asset.current_mileage frissítés]
|
||||
L -->|Nem| N[Nincs frissítés]
|
||||
M --> O[Commit]
|
||||
J -->|Nem| O
|
||||
N --> O
|
||||
end
|
||||
|
||||
subgraph READ["GET /cost-categories/"]
|
||||
P[User subscription] --> Q{subscription_plan}
|
||||
Q -->|FREE| R[Csak free kategóriák]
|
||||
Q -->|PREMIUM+| S[Free + premium kategóriák]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementációs Terv
|
||||
|
||||
### 4.1 Lépések
|
||||
|
||||
| # | Lépés | Érintett fájlok | Függőség |
|
||||
|---|-------|-----------------|----------|
|
||||
| 1 | **`subscription_service.py`** létrehozása | `backend/app/services/subscription_service.py` | — |
|
||||
| 2 | **`CostCategory.min_tier`** mező hozzáadása | [`backend/app/models/vehicle/vehicle.py:19`](backend/app/models/vehicle/vehicle.py:19) | #1 |
|
||||
| 3 | **`OdometerReading`** modell létrehozása | [`backend/app/models/vehicle/asset.py`](backend/app/models/vehicle/asset.py) | — |
|
||||
| 4 | **`Asset`** kapcsolat + `AssetCost.document_id` | [`backend/app/models/vehicle/asset.py:69`](backend/app/models/vehicle/asset.py:69) | #3 |
|
||||
| 5 | **Pydantic sémák bővítése** | [`backend/app/schemas/asset_cost.py`](backend/app/schemas/asset_cost.py) | #2, #4 |
|
||||
| 6 | **`CostService.record_cost()`** — km frissítés | [`backend/app/services/cost_service.py:23`](backend/app/services/cost_service.py:23) | #3 |
|
||||
| 7 | **`POST /expenses/`** — subscription gating + km log | [`backend/app/api/v1/endpoints/expenses.py:12`](backend/app/api/v1/endpoints/expenses.py:12) | #1, #2, #6 |
|
||||
| 8 | **`GET /cost-categories/`** — új végpont | [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py) | #2 |
|
||||
| 9 | **Seed script** — `min_tier` értékek beállítása | `backend/scripts/seed_cost_category_tiers.py` | #2 |
|
||||
| 10 | **Sync engine + tesztek** | `backend/app/scripts/sync_engine.py` | #2-#5 |
|
||||
|
||||
### 4.2 Adatbázis Szinkron
|
||||
|
||||
```bash
|
||||
# Modellek módosítása után:
|
||||
docker compose exec sf_api python3 /app/backend/app/scripts/sync_engine.py
|
||||
```
|
||||
|
||||
### 4.3 Seed Script — Kategória szintek beállítása
|
||||
|
||||
```python
|
||||
# backend/scripts/seed_cost_category_tiers.py
|
||||
"""CostCategory min_tier mező seedelése."""
|
||||
|
||||
TIER_MAP = {
|
||||
# FREE kategóriák (mindenki számára)
|
||||
"FUEL": "free",
|
||||
"MAINTENANCE": "free",
|
||||
"SERVICE": "free",
|
||||
"TIRE": "free",
|
||||
"INSURANCE": "free",
|
||||
"OTHER": "free",
|
||||
|
||||
# PREMIUM kategóriák (csak előfizetőknek)
|
||||
"PARKING": "premium",
|
||||
"TOLL": "premium",
|
||||
"FINANCE": "premium",
|
||||
"ADMIN": "premium",
|
||||
|
||||
# VIP kategóriák (speciális)
|
||||
# ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Összefoglalás
|
||||
|
||||
### Mit változtat a terv:
|
||||
|
||||
1. **FREE felhasználók:** Csak az alábbi kategóriákba rögzíthetnek költséget: FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, OTHER. A km állást továbbra is megadhatják, és az automatikusan frissíti az `Asset.current_mileage` mezőt.
|
||||
|
||||
2. **PREMIUM+ felhasználók:** Minden kategória elérhető (FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, PARKING, TOLL, FINANCE, ADMIN, OTHER). Plusz: dokumentum csatolás, ÁFA követés, részletes TCO bontás.
|
||||
|
||||
3. **Km tracking:** Az `OdometerReading` tábla minden költségrögzítéskor naplózza a km állást. Az `Asset.current_mileage` automatikusan frissül (ha az új érték nagyobb). Ez folyamatos, megbízható audit trail-et biztosít.
|
||||
|
||||
### Amit NEM kell megvalósítani (v2 törlések):
|
||||
- ❌ `UserCostCategory` modell — a felhasználók nem hoznak létre saját kategóriákat
|
||||
- ❌ `CostTag` / `AssetCostTag` — nem kell külön címke rendszer
|
||||
- ❌ `AssetCost.user_category_id` — kategória kiválasztás nem szükséges
|
||||
|
||||
### Blokkoló:
|
||||
- **Gitea #239 (`subscription_service.py`)** — ezt mindenképp létre kell hozni az entitlement mátrixhoz
|
||||
|
||||
---
|
||||
|
||||
**Jóváhagyásra vár:** Kérem a fenti v2 terv áttekintését. Ha elfogadásra kerül, létrehozom a Gitea kártyákat és megkezdjük az implementációt.
|
||||
179
plans/logic_spec_identity_preserving_soft_delete.md
Normal file
179
plans/logic_spec_identity_preserving_soft_delete.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# 🏗️ Logic Spec: Identity-Preserving Soft Delete & Asset Validation
|
||||
|
||||
## 1. Modul Célja és Masterbook 2 Illeszkedés
|
||||
|
||||
**Mérföldkő:** Identity & Security Hardening
|
||||
**Masterbook 2 Referencia:** [`docs/v201/05_AUTH_AND_IDENTITY_SPEC.md`](docs/v201/05_AUTH_AND_IDENTITY_SPEC.md) (Soft Delete / Anonymization szekció)
|
||||
**Masterbook 2 Referencia:** [`docs/v201/18_ASSET_AND_FLEET_SPECIFICATION.md`](docs/v201/18_ASSET_AND_FLEET_SPECIFICATION.md) (Asset specifikáció)
|
||||
|
||||
Jelenlegi állapot a Masterbook szerint:
|
||||
- A Soft Delete már létezik, de **hiányos**: nincs `deleted_at` timestamp, nincs token invalidáció, nem védi a Person rekordot.
|
||||
- Az Asset modellben nincs `CheckConstraint` a VIN/Rendszám "Vagy-Vagy" szabályhoz.
|
||||
- A Pydantic sémákban nincs `model_validator` a kötelező azonosító validációhoz.
|
||||
|
||||
---
|
||||
|
||||
## 2. Adatmodell változások
|
||||
|
||||
### 2.1 Asset CheckConstraint (`backend/app/models/vehicle/asset.py`)
|
||||
|
||||
**Jelenlegi állapot:** A [`Asset`](backend/app/models/vehicle/asset.py:69) osztály `__table_args__`-ja csak a sémát adja meg:
|
||||
```python
|
||||
__table_args__ = {"schema": "vehicle"}
|
||||
```
|
||||
|
||||
**Módosítás:** Bővítsük ki a `__table_args__`-t egy `CheckConstraint`-tel:
|
||||
|
||||
```python
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"vin IS NOT NULL OR license_plate IS NOT NULL",
|
||||
name="ck_asset_vin_or_plate_required"
|
||||
),
|
||||
{"schema": "vehicle"}
|
||||
)
|
||||
```
|
||||
|
||||
**Import:** A [`CheckConstraint`](backend/app/models/vehicle/asset.py:7) már elérhető az SQLAlchemy importok között? Ellenőrizendő: jelenleg csak `UniqueConstraint` van importálva. Ki kell egészíteni:
|
||||
```python
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Numeric, text, Text, UniqueConstraint, CheckConstraint, BigInteger, Integer, Float
|
||||
```
|
||||
|
||||
### 2.2 deleted_at oszlop a User modellhez (`backend/app/models/identity/identity.py`)
|
||||
|
||||
**Jelenlegi állapot:** A [`User`](backend/app/models/identity/identity.py:122) osztályban már van `is_deleted: Mapped[bool]` (149. sor), de hiányzik a `deleted_at`.
|
||||
|
||||
**Módosítás:** Add hozzá a `deleted_at` mezőt a `User` osztályhoz a `created_at` után:
|
||||
|
||||
```python
|
||||
# === SOFT DELETE ===
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
```
|
||||
|
||||
### 2.3 deleted_at oszlop a Person modellhez (opcionális, struktúra miatt)
|
||||
|
||||
**Jelenlegi állapot:** A [`Person`](backend/app/models/identity/identity.py:36) osztályban nincs `deleted_at`.
|
||||
|
||||
**Módosítás:** Add hozzá a struktúra konzisztencia miatt:
|
||||
|
||||
```python
|
||||
# === SOFT DELETE (structure only - never delete Person data) ===
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Pydantic Séma Módosítások
|
||||
|
||||
### 3.1 AssetCreate séma (`backend/app/schemas/asset.py`)
|
||||
|
||||
**Jelenlegi állapot:** A [`license_plate`](backend/app/schemas/asset.py:116) kötelező (`Field(...)`), a `vin` opcionális.
|
||||
|
||||
**Módosítás:** Mindkét mező legyen `Optional[str] = None`:
|
||||
|
||||
```python
|
||||
license_plate: Optional[str] = Field(None, min_length=2, max_length=20, description="Rendszám")
|
||||
vin: Optional[str] = Field(None, min_length=1, max_length=50, description="VIN szám (opcionális)")
|
||||
```
|
||||
|
||||
**Validátor:** Adj hozzá egy `@model_validator` (Pydantic V2 mód) vagy `@root_validator` (Pydantic V1 mód) metódust, ami:
|
||||
1. Üres string (`""`) átalakítása `None`-ra mindkét mezőnél
|
||||
2. Ha mindkettő `None`, dobjon `ValueError`-t (422-es HTTP válasz)
|
||||
|
||||
### 3.2 AssetUpdate séma (`backend/app/schemas/asset.py`)
|
||||
|
||||
**Jelenlegi állapot:** A [`license_plate`](backend/app/schemas/asset.py:203) és `vin` már `Optional[str] = None`.
|
||||
|
||||
**Módosítás:** Adj hozzá egy `@model_validator`-t vagy `@root_validator`-t, ami csak akkor dob hibát, ha a felhasználó explicitly `None`-ra akarja állítani mindkettőt.
|
||||
|
||||
---
|
||||
|
||||
## 4. Backend Service Logika: Person-Preserving Soft Delete
|
||||
|
||||
### 4.1 AuthService.soft_delete_user módosítása (`backend/app/services/auth_service.py`)
|
||||
|
||||
**Jelenlegi kód:** [`soft_delete_user`](backend/app/services/auth_service.py:486-503)
|
||||
|
||||
**Módosítások sorrendben:**
|
||||
|
||||
1. **`deleted_at` beállítás:**
|
||||
```python
|
||||
user.deleted_at = datetime.now(timezone.utc)
|
||||
```
|
||||
|
||||
2. **E-mail átírás** (már létezik, de pontosítva):
|
||||
```python
|
||||
old_email = user.email
|
||||
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
|
||||
user.email = f"deleted_{user.id}_{timestamp}_{old_email}"
|
||||
```
|
||||
|
||||
3. **Person rekord érintetlenül hagyása** (CRITICAL - maradjon ahogy van, NE módosítsuk)
|
||||
|
||||
4. **Token invalidáció:** Töröljük az összes aktív Refresh Token-t
|
||||
|
||||
5. **Naplózás** bővítése `deleted_at`-tal
|
||||
|
||||
---
|
||||
|
||||
## 5. API Végpont: DELETE /api/v1/users/me
|
||||
|
||||
### 5.1 Új endpoint (`backend/app/api/v1/endpoints/users.py`)
|
||||
|
||||
```python
|
||||
@router.delete("/me", status_code=200)
|
||||
async def delete_my_account(
|
||||
reason: Optional[str] = Body(None, description="Törlés oka (opcionális)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Saját fiók soft-delete.
|
||||
- Anonimizálja az e-mail címet
|
||||
- is_active = False, is_deleted = True, deleted_at = timestamp
|
||||
- Person rekordot NEM bántja
|
||||
- Érvényteleníti az összes refresh token-t
|
||||
- Wallet és Gamification adatokat megőrzi (inaktív user miatt freeze)
|
||||
"""
|
||||
success = await AuthService.soft_delete_user(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
reason=reason or "user_requested_self_delete",
|
||||
actor_id=current_user.id
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A felhasználó már törölve van."
|
||||
)
|
||||
|
||||
return {"status": "ok", "message": "Fiók sikeresen törölve."}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Adatbázis Szinkronizáció
|
||||
|
||||
A módosítások után futtatni kell a sync_engine-t:
|
||||
|
||||
```bash
|
||||
docker exec -it sf_api python -m app.scripts.sync_engine
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Tesztterv
|
||||
|
||||
### 7.1 Asset validáció tesztelése
|
||||
1. Hozz létre járművet **csak VIN-nel** → sikeres
|
||||
2. Hozz létre járművet **csak rendszámmal** → sikeres
|
||||
3. Hozz létre járművet **mindkettővel** → sikeres
|
||||
4. Hozz létre járművet **egyik nélkül sem** → 422-es hiba
|
||||
|
||||
### 7.2 Soft Delete tesztelése
|
||||
1. Hozz létre usert, authentikálj
|
||||
2. DELETE `/api/v1/users/me` hívás
|
||||
3. Ellenőrizd: `User.is_deleted = True`, `User.is_active = False`, `User.deleted_at` beállítva
|
||||
4. Ellenőrizd: `Person` rekord adatai változatlanok, `Person.is_active` változatlan
|
||||
5. Ellenőrizd: Refresh token törölve (régi session nem működik)
|
||||
Reference in New Issue
Block a user