felhasználói felületre pü
This commit is contained in:
248
docs/p0_expense_edit_modal_root_cause_analysis.md
Normal file
248
docs/p0_expense_edit_modal_root_cause_analysis.md
Normal 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 |
|
||||
Reference in New Issue
Block a user