admin felület különválasztva
This commit is contained in:
190
docs/p0_vendor_expense_architecture_audit_report.md
Normal file
190
docs/p0_vendor_expense_architecture_audit_report.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# P0 READ & COMPARE — Vendor and Expense Architecture Audit Report
|
||||
|
||||
**Date:** 2026-06-23
|
||||
**Author:** Fast Coder (Core Developer)
|
||||
**Scope:** Vendor creation flow + Expense creation flow
|
||||
**Status:** COMPLETE — No code modified, pure audit
|
||||
|
||||
---
|
||||
|
||||
## 1. AUDIT: Vendor Creation Flow
|
||||
|
||||
### 1.1. Endpoint: `POST /providers/quick-add`
|
||||
|
||||
**File:** [`backend/app/api/v1/endpoints/providers.py:241`](../backend/app/api/v1/endpoints/providers.py:241)
|
||||
|
||||
This is the **primary** vendor creation endpoint used by the frontend's CostEntryWizard when a user adds a new service provider (vendor) via crowdsourcing.
|
||||
|
||||
**Actual Code Flow** (in [`backend/app/services/provider_service.py:468`](../backend/app/services/provider_service.py:468)):
|
||||
|
||||
| Step | What happens | Line |
|
||||
|------|-------------|------|
|
||||
| 1 | Organization created with `org_type=OrgType.service_provider` | `:526` |
|
||||
| 2 | `owner_id=None` — explicitly set to None | `:541` |
|
||||
| 3 | `status="pending_verification"`, `is_verified=False` | `:527-528` |
|
||||
| 4 | ServiceProfile created with `status="ghost"` | `:561-570` |
|
||||
| 5 | Branch created with address data | `:618-630` |
|
||||
| 6 | **OrganizationMember created with `role=OrgUserRole.ADMIN`** | `:639-646` |
|
||||
|
||||
### 1.2. Comparison: Actual vs Expected
|
||||
|
||||
| Aspect | Expected (Business Logic) | Actual Code | Verdict |
|
||||
|--------|--------------------------|-------------|---------|
|
||||
| `org_type` | `SERVICE_PROVIDER` | `OrgType.service_provider` ✅ | **CORRECT** |
|
||||
| `owner_id` | `NULL` (ideally) | `owner_id=None` ✅ | **CORRECT** |
|
||||
| Creator in members? | **NO** — creator should NOT be a member | **BUG:** Creator IS added as `OrganizationMember` with `role=ADMIN` ❌ | **DEVIATION** |
|
||||
|
||||
### 1.3. The Member Problem
|
||||
|
||||
In [`backend/app/services/provider_service.py:639-646`](../backend/app/services/provider_service.py:639-646):
|
||||
|
||||
```python
|
||||
member = OrganizationMember(
|
||||
organization_id=org.id,
|
||||
user_id=user_id,
|
||||
role=OrgUserRole.ADMIN, # <-- ADMIN, not OWNER
|
||||
is_permanent=True,
|
||||
is_verified=True,
|
||||
)
|
||||
```
|
||||
|
||||
The code comment at line 635 says:
|
||||
> *"A létrehozó felhasználó ADMIN szerepkörű tag lesz (NEM OWNER). Crowdsourcingból felvett szolgáltatóknak nincs tulajdonosa (owner_id=NULL). Az ADMIN jogosultság elegendő a későbbi szerkesztéshez, de a cég nem jelenik meg a 'Cégeim' garázsválasztóban."*
|
||||
|
||||
**However**, the expected business logic says:
|
||||
> *"A létrehozó felhasználó NEM kerülhet be a tagok (organization_members) közé!"*
|
||||
|
||||
This is a **deliberate design decision** — the current code adds the creator as ADMIN. The expected logic says NO membership at all. This needs Architect clarification.
|
||||
|
||||
### 1.4. Secondary Endpoint: `POST /organizations/onboard`
|
||||
|
||||
**File:** [`backend/app/api/v1/endpoints/organizations.py:38`](../backend/app/api/v1/endpoints/organizations.py:38)
|
||||
|
||||
This endpoint is for **business onboarding** (not vendor creation). It creates:
|
||||
- `org_type=OrgType.business` (line 84)
|
||||
- `owner_id` is NOT set (implicitly NULL)
|
||||
- Creator is added as `OrganizationMember` with `role="OWNER"` (line 116-121)
|
||||
|
||||
This is **correct** for business onboarding — the creator SHOULD be the owner.
|
||||
|
||||
---
|
||||
|
||||
## 2. AUDIT: Expense Creation Flow
|
||||
|
||||
### 2.1. Endpoint: `POST /expenses/`
|
||||
|
||||
**File:** [`backend/app/api/v1/endpoints/expenses.py:345`](../backend/app/api/v1/endpoints/expenses.py:345)
|
||||
|
||||
### 2.2. How `organization_id` is Resolved
|
||||
|
||||
**Actual Code** (lines 398-429):
|
||||
|
||||
```python
|
||||
# CRITICAL FIX: organization_id MUST be the user's active organization context,
|
||||
# NOT blindly taken from the payload.
|
||||
#
|
||||
# Resolution strategy:
|
||||
# 1. First, try to resolve the user's active organization from their membership
|
||||
# 2. Fallback to asset.current_organization_id or asset.owner_org_id
|
||||
# 3. Ignore expense.organization_id from payload to prevent vendor-org pollution
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org_member = org_result.scalar_one_or_none()
|
||||
|
||||
if org_member:
|
||||
organization_id = org_member.organization_id # Priority 1
|
||||
elif asset.current_organization_id:
|
||||
organization_id = asset.current_organization_id # Priority 2
|
||||
elif asset.owner_org_id:
|
||||
organization_id = asset.owner_org_id # Priority 3
|
||||
else:
|
||||
# B2C fallback: any membership (even unverified)
|
||||
...
|
||||
```
|
||||
|
||||
### 2.3. How `vendor_organization_id` is Set
|
||||
|
||||
**Actual Code** (line 495):
|
||||
|
||||
```python
|
||||
vendor_organization_id=expense.vendor_organization_id,
|
||||
```
|
||||
|
||||
The payload's `vendor_organization_id` is passed **directly** to the model. This is the vendor's organization ID.
|
||||
|
||||
### 2.4. Comparison: Actual vs Expected
|
||||
|
||||
| Aspect | Expected (Business Logic) | Actual Code | Verdict |
|
||||
|--------|--------------------------|-------------|---------|
|
||||
| `organization_id` source | Asset's garage (`asset.current_organization_id`) | User's active org membership (Priority 1), then `asset.current_organization_id` (Priority 2) | **PARTIAL DEVIATION** |
|
||||
| Payload `organization_id` | Should be **IGNORED** | IS ignored — backend resolves from user context ✅ | **CORRECT** |
|
||||
| `vendor_organization_id` | The invoice issuer (vendor) | Set from `expense.vendor_organization_id` ✅ | **CORRECT** |
|
||||
|
||||
### 2.5. The organization_id Deviation Explained
|
||||
|
||||
The expected logic says:
|
||||
> *"A költség organization_id mezőjének (a költség gazdájának) a jármű saját garázsát (asset.current_organization_id) kell tükröznie."*
|
||||
|
||||
The actual code uses **Priority 1: user's active org membership**. This means if a user belongs to Organization A (their fleet manager org) but the asset is currently assigned to Organization B (a different garage), the expense will be saved under Organization A, not Organization B.
|
||||
|
||||
**This is a potential bug** in multi-org scenarios where a user manages vehicles across multiple organizations. The asset's `current_organization_id` should be the authoritative source.
|
||||
|
||||
### 2.6. Frontend Payload Analysis
|
||||
|
||||
**File:** [`frontend/src/components/cost/CostEntryWizard.vue:690-723`](../frontend/src/components/cost/CostEntryWizard.vue:690)
|
||||
|
||||
The frontend:
|
||||
1. Does **NOT** send `vendor_organization_id` in the payload (line 690-714)
|
||||
2. Sends `organization_id` = user's `active_organization_id` (line 720-723)
|
||||
3. Stores vendor info inside `data.vendor_id` and `data.vendor_name` (JSONB) (line 707-710)
|
||||
|
||||
**File:** [`frontend/src/stores/cost.ts:64-73`](../frontend/src/stores/cost.ts:64)
|
||||
|
||||
The cost store also injects `organization_id` from `authStore.user?.active_organization_id`.
|
||||
|
||||
**This means:** The frontend never sends `vendor_organization_id` to the backend! The vendor info is stored in the JSONB `data` field, but the dedicated `vendor_organization_id` column on `AssetCost` remains NULL.
|
||||
|
||||
---
|
||||
|
||||
## 3. Summary of Findings
|
||||
|
||||
### 🟢 CORRECT (Matches Expected Logic)
|
||||
|
||||
| # | Finding | Location |
|
||||
|---|---------|----------|
|
||||
| 1 | `POST /providers/quick-add` creates org with `org_type=service_provider` | [`provider_service.py:526`](../backend/app/services/provider_service.py:526) |
|
||||
| 2 | `owner_id=None` for crowdsourced providers | [`provider_service.py:541`](../backend/app/services/provider_service.py:541) |
|
||||
| 3 | Backend `POST /expenses/` ignores payload `organization_id` | [`expenses.py:398-429`](../backend/app/api/v1/endpoints/expenses.py:398) |
|
||||
| 4 | `vendor_organization_id` is correctly mapped from schema | [`expenses.py:495`](../backend/app/api/v1/endpoints/expenses.py:495) |
|
||||
|
||||
### 🟡 DEVIATION (Needs Architect Decision)
|
||||
|
||||
| # | Finding | Location | Impact |
|
||||
|---|---------|----------|--------|
|
||||
| 1 | **Vendor creator IS added as OrganizationMember (ADMIN)** — expected: NO membership | [`provider_service.py:639-646`](../backend/app/services/provider_service.py:639) | Vendor appears in user's org list? |
|
||||
| 2 | **`organization_id` resolved from user's active org (Priority 1)** — expected: `asset.current_organization_id` | [`expenses.py:406-414`](../backend/app/api/v1/endpoints/expenses.py:406) | Wrong org in multi-org scenarios |
|
||||
| 3 | **Frontend never sends `vendor_organization_id`** — vendor info only in JSONB `data` | [`CostEntryWizard.vue:690-714`](../frontend/src/components/cost/CostEntryWizard.vue:690) | `vendor_organization_id` column always NULL |
|
||||
|
||||
### 🔴 CRITICAL GAP
|
||||
|
||||
| # | Finding | Location |
|
||||
|---|---------|----------|
|
||||
| 1 | Frontend `CostEntryWizard` does NOT populate `vendor_organization_id` in the POST payload | [`CostEntryWizard.vue:707`](../frontend/src/components/cost/CostEntryWizard.vue:707) — vendor_id stored only in `data.vendor_id` |
|
||||
| 2 | The `cost.ts` store also doesn't map `vendor_organization_id` | [`cost.ts:53-73`](../frontend/src/stores/cost.ts:53) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended Actions
|
||||
|
||||
1. **Architect Decision Needed:** Should `quick_add_provider` add the creator as `OrganizationMember` (current behavior) or NOT (expected logic)? The current ADMIN role prevents the org from appearing in "My Companies" garage selector, but the user still has a membership record.
|
||||
|
||||
2. **Fix `organization_id` Resolution:** Change Priority 1 from "user's active org membership" to `asset.current_organization_id` to match the expected business logic exactly.
|
||||
|
||||
3. **Fix Frontend:** Add `vendor_organization_id` to the POST `/expenses/` payload when a vendor is selected from the dropdown, so the dedicated column is populated instead of only storing it in JSONB `data`.
|
||||
|
||||
---
|
||||
|
||||
*End of Audit Report — No code was modified during this analysis.*
|
||||
Reference in New Issue
Block a user