Files
service-finder/docs/p0_expense_edit_modal_root_cause_analysis.md
2026-07-27 08:39:18 +00:00

14 KiB

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.idleaf/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

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

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:
    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   │
└─────────────────────────────────────────────────────────────────────┘

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