felhasználói felületre pü

This commit is contained in:
Roo
2026-07-27 08:39:18 +00:00
parent c9118bf52f
commit 6f28d3e70d
72 changed files with 6311 additions and 749 deletions

View File

@@ -0,0 +1,248 @@
# P0 Root Cause Analysis: Expense Edit Modal Data Binding Failure
**Date:** 2026-07-26
**Scope:** Frontend `CostEntryWizard.vue` + Backend `expenses.py`
**Symptom:** Edit modal defaults category to "Finanszírozás", fails to bind vehicle/provider, missing specific details (fuel amount).
---
## 1. Data Storage: How Expenses & Specific Details Are Persisted
### DB Table: `fleet_finance.asset_costs`
| Column | Type | Stores |
|--------|------|--------|
| `category_id` | `int4` | FK → `fleet_finance.cost_categories.id`**leaf/subcategory ID**, not parent |
| `service_provider_id` | `int4` | FK → `marketplace.service_providers.id` — the selected provider |
| `vendor_organization_id` | `int4` | FK → `fleet.organizations.id` — B2B vendor org |
| `external_vendor_name` | `varchar(200)` | Free-text vendor name |
| `data` | `JSONB` | **All specific details:** `liters`, `net_amount`, `vat_rate`, `invoice_number`, `invoice_date`, `payment_method`, `payment_deadline`, `mileage_at_cost`, `vendor_id`, `vendor_name`, `vendor_source` |
| `amount_gross` | `numeric(18,2)` | Gross (Bruttó) amount |
| `amount_net` | `numeric(18,2)` | Net amount |
| `invoice_number` | `varchar(100)` | Dedicated column (duplicated in `data`) |
**Key Finding:** Specific details like fuel liters, payment method, invoice date, etc. are stored in the `data` JSONB column. There is **no separate related table** for fuel-specific details.
---
## 2. Backend API Analysis
### 2A. No Single-Expense Read Endpoint Exists
The backend has:
- `GET /expenses/` — lists all expenses (paginated)
- `GET /expenses/{asset_id}` — lists expenses for a specific asset
- `PUT /expenses/{expense_id}` — updates an expense
**There is NO `GET /expenses/by-id/{expense_id}` endpoint** that returns a single enriched expense with all resolved relationships.
### 2B. List Endpoints Return Raw ORM Objects
Both list endpoints return `result.scalars().all()` — raw SQLAlchemy `AssetCost` objects serialized by FastAPI's default JSON encoder. The `AssetCostResponse` Pydantic schema (which includes `category_name`, `category_code`, `vendor_name`, `description`, `mileage_at_cost`) is **defined but never used** by the list endpoints.
This means:
- `data` (JSONB) is serialized as a raw dict — this part actually works
- But `category_name`, `category_code`, `vendor_name` are **not resolved** (they require JOINs or relationship loading)
- The response lacks the parent category ID (only the leaf `category_id` is present)
### 2C. PUT Endpoint Has Partial Data Loss Risk
The `update_expense` function handles `mileage_at_cost` and `description` inside `data` JSONB correctly, but the frontend's `updateExpense()` in `cost.ts` sends `service_provider_id` only if it's in the payload. The `PUT` endpoint's `AssetCostUpdate` schema does include `service_provider_id: Optional[int]`, so if the frontend sends it, it would work.
---
## 3. Frontend Data Flow: The Edit Path
### Step 1: User clicks Edit → `CostsView.vue` line 527
```typescript
function openEditWizard(cost: any) {
const vehicleId = cost.asset_id || cost.vehicle_id
editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null
editCostData.value = cost // ← passes raw list-response object directly
showEditWizard.value = true
}
```
The `cost` object comes from the `GET /expenses` list response — a raw SQLAlchemy object with fields: `id`, `asset_id`, `organization_id`, `category_id`, `amount_net`, `amount_gross`, `vat_rate`, `currency`, `date`, `invoice_number`, `status`, `data`, `service_provider_id`, `vendor_organization_id`, `external_vendor_name`, `invoice_date`, `fulfillment_date`.
### Step 2: Hydration watcher → `CostEntryWizard.vue` lines 972-988
```typescript
watch(() => props.editCost, (cost) => {
if (!cost) return
form.date = cost.date ? getLocalDateString(new Date(cost.date)) : getLocalDateString()
form.mainCategory = String(cost.category_id || '') // ← BUG #1
form.subCategory = String(cost.sub_category_id || '') // ← BUG #1 (never exists)
form.netAmount = cost.data?.net_amount || cost.amount_net || 0
form.vatRate = cost.data?.vat_rate || 27
form.grossAmount = cost.amount_gross || 0
form.currency = cost.currency || 'HUF'
form.invoiceNumber = cost.data?.invoice_number || ''
form.invoiceDate = cost.data?.invoice_date || ''
form.paymentDeadline = cost.data?.payment_deadline || ''
form.paymentMethod = cost.data?.payment_method || 'TRANSFER'
form.mileageAtCost = cost.mileage_at_cost || cost.data?.mileage_at_cost || null
form.liters = cost.data?.liters || null // ← BUG #2 area
// NOTE: selectedProvider is NEVER set from cost.service_provider_id! ← BUG #3
// NOTE: selectedVehicleId is NEVER set from cost.asset_id! ← BUG #4
})
```
---
## 4. Root Cause Map: 5 Specific Bugs
### 🔴 BUG #1 — Category Hierarchy Collapse (CRITICAL)
**Location:** `CostEntryWizard.vue:976`
**What happens:**
- `category_id` in the DB is the **leaf/subcategory** ID (e.g., `id=4` for "Üzemanyag / Benzin")
- The wizard has a **two-level** selector: `mainCategory` (parent) → `subCategory` (leaf)
- The hydration sets `form.mainCategory = String(cost.category_id || '')` — puts the leaf ID into the parent dropdown
- `form.subCategory = String(cost.sub_category_id || '')``sub_category_id` doesn't exist in the response, so it's always `''`
- When the component renders, `mainCategory` is set to an ID that doesn't match any main category (since it's a leaf), so the `<select>` defaults to the **first option**: "Finanszírozás"
- Subcategories are never populated because `onMainCategoryChange()` was never triggered with the correct parent ID
**User experience:** Category dropdown shows "Finanszírozás" (first item) instead of the actual category.
**Fix approach:**
1. Backend must return `parent_category_id` alongside `category_id` in the response, OR
2. Frontend must look up the parent from the flattened category tree (`window.__allCostCategories`) and split `category_id` into main+sub correctly
### 🔴 BUG #2 — Vehicle Not Bound When Not in Store (HIGH)
**Location:** `CostsView.vue:530` + `CostEntryWizard.vue:93`
**What happens:**
- `openEditWizard` finds the vehicle by `cost.asset_id` in `vehicleStore.vehicles`
- If the vehicle is not in the store (e.g., filtered by another org, or not loaded), `editVehicle` is `null`
- In `CostEntryWizard.vue:77`, when `editVehicle` is null AND `userVehicles.length > 0`, the global vehicle dropdown is shown
- But `selectedVehicleId` is **never set** from `cost.asset_id` in the hydration watcher
- Result: vehicle dropdown shows "Select Vehicle..." with nothing selected
**Fix approach:** In the hydration watcher, set `selectedVehicleId.value = cost.asset_id` when `props.vehicle` is null.
### 🔴 BUG #3 — Provider/ServiceProvider Not Hydrated (HIGH)
**Location:** `CostEntryWizard.vue:972-988` (absence of provider hydration)
**What happens:**
- The DB stores `service_provider_id` (int) and `external_vendor_name` (string) directly on the AssetCost row
- The list response includes these fields
- But the hydration watcher **never reads** `cost.service_provider_id` or `cost.external_vendor_name`
- `selectedProvider` ref is never populated, so `ProviderAutocomplete` shows empty
- The user sees no provider selected even though one was saved
**Fix approach:** In the hydration watcher, if `cost.service_provider_id` exists, construct a `ProviderSearchResult` object and set `selectedProvider.value`. If only `external_vendor_name` exists, set it as the provider name.
### 🟡 BUG #4 — `data.liters` May Be Lost in List Serialization (MEDIUM)
**Location:** `CostEntryWizard.vue:987`
**What happens:**
- The hydration reads `cost.data?.liters`
- The list endpoint returns raw SQLAlchemy objects; the `data` JSONB column **should** serialize correctly as a dict via FastAPI's `jsonable_encoder`
- However, if the `data` column is `None` or `{}` in the DB, `cost.data?.liters` returns `undefined`, and `|| null` gives `null`
- This should work for most cases, but there's a subtle issue: the `data` field on the SQLAlchemy model has `server_default="'{}'::jsonb"`, but if a row was inserted without going through SQLAlchemy (e.g., raw SQL), `data` could be `None`
**Fix approach:** Add defensive null-check: `(cost.data && cost.data.liters) ? cost.data.liters : null`
### 🟡 BUG #5 — `mainCategory` Watcher Clears `subCategory` on Hydration (MEDIUM)
**Location:** `CostEntryWizard.vue:818-819`
**What happens:**
- The hydration watcher runs and sets `form.mainCategory = String(cost.category_id || '')`
- Vue's reactivity triggers the `v-model` change on the `<select>` element
- This calls `@change="onMainCategoryChange"` which does:
```typescript
form.subCategory = '' // ← CLEARS whatever the hydration may try to set
subCategories.value = []
```
- Even if we fix BUG #1 to correctly populate subCategory, the `@change` handler would clear it
**Fix approach:** In the hydration watcher, after setting `mainCategory`, manually call `onMainCategoryChange()` (which populates `subCategories`), and THEN set `form.subCategory` to the correct leaf ID. The order must be: set mainCategory → populate subCategories → set subCategory.
---
## 5. Data Flow Diagram
```
┌─────────────────────────────────────────────────────────────────────┐
│ BACKEND │
│ │
│ GET /expenses?organization_id=X │
│ └→ returns: [AssetCost ORM objects] │
│ fields: id, asset_id, category_id(leaf!), service_provider_id, │
│ amount_gross, date, data{liters, net_amount, ...}, │
│ external_vendor_name, invoice_date, ... │
│ │
│ MISSING: parent_category_id, category_name, vendor_name, │
│ vehicle_name, license_plate │
│ │
│ There is NO GET /expenses/by-id/{id} endpoint! │
└──────────────────────┬──────────────────────────────────────────────┘
│ raw ORM objects (FastAPI jsonable_encoder)
┌─────────────────────────────────────────────────────────────────────┐
│ FRONTEND │
│ │
│ CostsView.vue │
│ └→ openEditWizard(cost) │
│ editCostData.value = cost ← raw list item │
│ editVehicle.value = store.find(v => v.id === cost.asset_id) │
│ │
│ CostEntryWizard.vue │
│ └→ watch(editCost): │
│ form.mainCategory = String(cost.category_id) ← BUG: leaf ID │
│ form.subCategory = String(cost.sub_category_id) ← BUG: undef │
│ form.liters = cost.data?.liters │
│ selectedProvider = ??? ← BUG: never │
│ selectedVehicleId = ??? ← BUG: never │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 6. Recommended Fix Plan
### Phase 1: Backend (Minimal — No Schema Changes)
1. **Create `GET /expenses/by-id/{expense_id}` endpoint** in `expenses.py` that:
- Fetches a single `AssetCost` by ID
- JOINs `CostCategory` to resolve `category_name`, `category_code`, `parent_id`
- JOINs `ServiceProvider` to resolve provider name
- Returns the data using `AssetCostResponse` schema (already defined!)
- Includes `parent_category_id` in the response for the two-level dropdown
2. **Optionally, enrich the list endpoint** to return `parent_category_id` by joining `CostCategory.parent_id`, but this is less critical if the single-expense endpoint is used for edit hydration.
### Phase 2: Frontend (Edit Path)
1. **In `CostsView.vue` `openEditWizard()`:** fetch the full expense detail from the new `GET /expenses/by-id/{id}` endpoint instead of passing the raw list object.
2. **In `CostEntryWizard.vue` hydration watcher:**
- Split `category_id` into `mainCategory` (parent) and `subCategory` (leaf) using `window.__allCostCategories`
- Call `onMainCategoryChange()` after setting `mainCategory` to populate subcategories, then set `subCategory`
- Set `selectedProvider.value` from `cost.service_provider_id` + resolved provider name
- Set `selectedVehicleId.value` from `cost.asset_id` when vehicle prop is null
- Defensive null-check on `cost.data?.liters`
### Phase 3: Edge Cases
1. When `service_provider_id` is null but `external_vendor_name` is set, populate the provider autocomplete with the free-text name
2. Handle the case where `vendor_organization_id` points to a B2B org (different from `service_provider_id`)
---
## 7. Files That Need Changes
| File | Change Type | Priority |
|------|-------------|----------|
| `backend/app/api/v1/endpoints/expenses.py` | Add `GET /expenses/by-id/{id}` endpoint | P0 |
| `backend/app/schemas/asset_cost.py` | Add `parent_category_id` to `AssetCostResponse` | P0 |
| `frontend_app/src/views/costs/CostsView.vue` | Fetch full expense detail on edit | P0 |
| `frontend_app/src/components/cost/CostEntryWizard.vue` | Fix category split, provider hydration, vehicle selection | P0 |
| `frontend_app/src/stores/cost.ts` | Add `fetchExpenseById()` action (optional, if store-based) | P1 |