|
|
|
|
@@ -14,6 +14,12 @@ Végpontok:
|
|
|
|
|
POST /admin/providers/{id}/flag — Megjelölés (approved -> flagged)
|
|
|
|
|
POST /admin/providers/{id}/restore — Visszaállítás (rejected -> approved)
|
|
|
|
|
DELETE /admin/providers/{id} — Törlés (soft delete)
|
|
|
|
|
|
|
|
|
|
P0 CRITICAL FIX (2026-07-07): Promotion Engine wiring.
|
|
|
|
|
- PATCH /admin/providers/{id} on a ServiceStaging record with status="approved"
|
|
|
|
|
now triggers the Promotion Engine: creates a ServiceProvider + ServiceProfile,
|
|
|
|
|
sets validation_score=100, then deletes the staging record.
|
|
|
|
|
- Auto-Validation: Admin approval sets validation_score=100 on the new ServiceProvider.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
@@ -27,6 +33,7 @@ from pydantic import BaseModel, Field
|
|
|
|
|
from sqlalchemy import select, func, or_, union_all, literal, cast, String, case
|
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
from sqlalchemy.orm.attributes import flag_modified
|
|
|
|
|
|
|
|
|
|
from app.api import deps
|
|
|
|
|
from app.schemas.address import AddressIn, AddressOut
|
|
|
|
|
@@ -38,12 +45,13 @@ from app.models.identity.social import (
|
|
|
|
|
SourceType,
|
|
|
|
|
ProviderValidation,
|
|
|
|
|
)
|
|
|
|
|
from app.models.marketplace.service import ServiceProfile
|
|
|
|
|
from app.models.marketplace.service import ServiceProfile, ServiceExpertise
|
|
|
|
|
from app.models.marketplace.staged_data import ServiceStaging
|
|
|
|
|
from app.models.gamification.gamification import UserContribution
|
|
|
|
|
from app.models.vehicle.history import AuditLog
|
|
|
|
|
from app.services.gamification_service import gamification_service
|
|
|
|
|
from app.services.provider_service import _sync_service_expertises
|
|
|
|
|
from app.services.address_manager import AddressManager
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger("admin-providers")
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
@@ -55,11 +63,17 @@ router = APIRouter()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProviderListItem(BaseModel):
|
|
|
|
|
"""Egy szolgáltató rövid adatai a listázáshoz."""
|
|
|
|
|
"""Egy szolgáltató rövid adatai a listázáshoz.
|
|
|
|
|
|
|
|
|
|
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
|
|
|
|
- address_detail: Optional[AddressOut] — first-class citizen.
|
|
|
|
|
- A flat mezők (address, city) [DEPRECATED] — Phase 3-ban törlendő.
|
|
|
|
|
"""
|
|
|
|
|
id: int
|
|
|
|
|
name: str
|
|
|
|
|
address: Optional[str] = None
|
|
|
|
|
city: Optional[str] = None
|
|
|
|
|
address: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.full_address_text instead")
|
|
|
|
|
city: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.city instead")
|
|
|
|
|
address_detail: Optional[AddressOut] = None
|
|
|
|
|
category: Optional[str] = None
|
|
|
|
|
status: str
|
|
|
|
|
source: str
|
|
|
|
|
@@ -72,11 +86,16 @@ class ProviderListItem(BaseModel):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProviderDetail(BaseModel):
|
|
|
|
|
"""Szolgáltató részletes adatai."""
|
|
|
|
|
"""Szolgáltató részletes adatai.
|
|
|
|
|
|
|
|
|
|
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
|
|
|
|
- address_detail: Optional[AddressOut] — first-class citizen.
|
|
|
|
|
- A flat mezők (address, city) [DEPRECATED] — Phase 3-ban törlendő.
|
|
|
|
|
"""
|
|
|
|
|
id: int
|
|
|
|
|
name: str
|
|
|
|
|
address: str
|
|
|
|
|
city: Optional[str] = None
|
|
|
|
|
address: str = Field(..., description="[DEPRECATED] Use address_detail.full_address_text instead")
|
|
|
|
|
city: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.city instead")
|
|
|
|
|
address_detail: Optional[AddressOut] = None
|
|
|
|
|
plus_code: Optional[str] = None
|
|
|
|
|
contact_phone: Optional[str] = None
|
|
|
|
|
@@ -130,10 +149,15 @@ class ModerationResponse(BaseModel):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProviderUpdateInput(BaseModel):
|
|
|
|
|
"""Provider adatok szerkesztésének bemeneti sémája."""
|
|
|
|
|
"""Provider adatok szerkesztésének bemeneti sémája.
|
|
|
|
|
|
|
|
|
|
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
|
|
|
|
- address_detail: Optional[AddressIn] — first-class citizen.
|
|
|
|
|
- A flat mezők (address, city) [DEPRECATED] — Phase 3-ban törlendő.
|
|
|
|
|
"""
|
|
|
|
|
name: Optional[str] = Field(None, min_length=1, max_length=255, description="Szolgáltató neve")
|
|
|
|
|
address: Optional[str] = Field(None, max_length=500, description="Teljes cím")
|
|
|
|
|
city: Optional[str] = Field(None, max_length=100, description="Város")
|
|
|
|
|
address: Optional[str] = Field(None, max_length=500, description="[DEPRECATED] Use address_detail.full_address_text instead")
|
|
|
|
|
city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Use address_detail.city instead")
|
|
|
|
|
address_detail: Optional[AddressIn] = Field(None, description="Cím részletes adatai (egységes AddressIn séma)")
|
|
|
|
|
plus_code: Optional[str] = Field(None, max_length=50, description="Plus Code (Google Maps)")
|
|
|
|
|
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
|
|
|
|
|
@@ -294,6 +318,10 @@ def _build_unified_providers_query(
|
|
|
|
|
from app.models.identity.address import Address, GeoPostalCode
|
|
|
|
|
|
|
|
|
|
# --- 1. ServiceProvider lekérdezés ---
|
|
|
|
|
# P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
|
|
|
|
# Outerjoin to system.addresses and system.geo_postal_codes to pull
|
|
|
|
|
# address/city from the normalized Address table instead of flat columns.
|
|
|
|
|
# Coalesce to flat columns for backward compatibility during transition.
|
|
|
|
|
provider_conditions = []
|
|
|
|
|
if status_filter:
|
|
|
|
|
provider_conditions.append(ServiceProvider.status == status_filter)
|
|
|
|
|
@@ -303,8 +331,12 @@ def _build_unified_providers_query(
|
|
|
|
|
provider_stmt = select(
|
|
|
|
|
ServiceProvider.id.label("id"),
|
|
|
|
|
ServiceProvider.name.label("name"),
|
|
|
|
|
ServiceProvider.address.label("address"),
|
|
|
|
|
ServiceProvider.city.label("city"),
|
|
|
|
|
func.coalesce(
|
|
|
|
|
Address.full_address_text, ServiceProvider.address
|
|
|
|
|
).label("address"),
|
|
|
|
|
func.coalesce(
|
|
|
|
|
GeoPostalCode.city, ServiceProvider.city
|
|
|
|
|
).label("city"),
|
|
|
|
|
ServiceProvider.category.label("category"),
|
|
|
|
|
cast(ServiceProvider.status, String).label("status"),
|
|
|
|
|
cast(ServiceProvider.source, String).label("source"),
|
|
|
|
|
@@ -312,6 +344,14 @@ def _build_unified_providers_query(
|
|
|
|
|
ServiceProvider.validation_score.label("validation_score"),
|
|
|
|
|
ServiceProvider.added_by_user_id.label("added_by_user_id"),
|
|
|
|
|
ServiceProvider.created_at.label("created_at"),
|
|
|
|
|
# P0 PHASE 2 ADDRESS UNIFICATION: Include address_id for address_detail resolution
|
|
|
|
|
ServiceProvider.address_id.label("address_id"),
|
|
|
|
|
).outerjoin(
|
|
|
|
|
Address,
|
|
|
|
|
Address.id == ServiceProvider.address_id,
|
|
|
|
|
).outerjoin(
|
|
|
|
|
GeoPostalCode,
|
|
|
|
|
GeoPostalCode.id == Address.postal_code_id,
|
|
|
|
|
)
|
|
|
|
|
if provider_conditions:
|
|
|
|
|
provider_stmt = provider_stmt.where(*provider_conditions)
|
|
|
|
|
@@ -341,6 +381,8 @@ def _build_unified_providers_query(
|
|
|
|
|
literal(0).label("validation_score"),
|
|
|
|
|
literal(None).label("added_by_user_id"),
|
|
|
|
|
ServiceStaging.created_at.label("created_at"),
|
|
|
|
|
# P0 PHASE 2: Placeholder for UNION ALL column count parity
|
|
|
|
|
literal(None).label("address_id"),
|
|
|
|
|
)
|
|
|
|
|
if staging_conditions:
|
|
|
|
|
staging_stmt = staging_stmt.where(*staging_conditions)
|
|
|
|
|
@@ -375,6 +417,8 @@ def _build_unified_providers_query(
|
|
|
|
|
literal(100).label("validation_score"),
|
|
|
|
|
literal(None).label("added_by_user_id"),
|
|
|
|
|
Organization.created_at.label("created_at"),
|
|
|
|
|
# P0 PHASE 2: Placeholder for UNION ALL column count parity
|
|
|
|
|
literal(None).label("address_id"),
|
|
|
|
|
).outerjoin(
|
|
|
|
|
Address,
|
|
|
|
|
Address.id == Organization.address_id,
|
|
|
|
|
@@ -463,11 +507,25 @@ async def list_providers(
|
|
|
|
|
|
|
|
|
|
items = []
|
|
|
|
|
for row in rows:
|
|
|
|
|
# P0 PHASE 2 ADDRESS UNIFICATION: Resolve address_detail from address_id
|
|
|
|
|
row_address_detail = None
|
|
|
|
|
row_address_id = getattr(row, "address_id", None)
|
|
|
|
|
if row_address_id:
|
|
|
|
|
try:
|
|
|
|
|
addr_data = await AddressManager.get_normalized(db, row_address_id)
|
|
|
|
|
if addr_data:
|
|
|
|
|
row_address_detail = AddressOut(**addr_data)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(
|
|
|
|
|
f"Failed to resolve address_detail for list row #{row.id}: {e}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
items.append(ProviderListItem(
|
|
|
|
|
id=row.id,
|
|
|
|
|
name=row.name,
|
|
|
|
|
address=row.address,
|
|
|
|
|
city=row.city,
|
|
|
|
|
address_detail=row_address_detail,
|
|
|
|
|
category=row.category,
|
|
|
|
|
status=row.status,
|
|
|
|
|
source=row.source,
|
|
|
|
|
@@ -548,10 +606,15 @@ async def get_provider_detail(
|
|
|
|
|
)
|
|
|
|
|
sp = provider.scalar_one_or_none()
|
|
|
|
|
if sp:
|
|
|
|
|
# Lekérdezzük a kapcsolódó kategória ID-kat a ServiceProfile-on keresztül
|
|
|
|
|
# P0 BUGFIX (2026-07-07): Resolve category_ids from ServiceProfile → ServiceExpertise.
|
|
|
|
|
# Search by BOTH service_provider_id AND organization_id.
|
|
|
|
|
# ServiceProvider ID 6 (and other legacy providers) may have their ServiceProfile
|
|
|
|
|
# linked via organization_id instead of service_provider_id. Using OR ensures
|
|
|
|
|
# we find the profile regardless of which FK is populated.
|
|
|
|
|
resolved_category_ids: Optional[List[int]] = None
|
|
|
|
|
profile_stmt = select(ServiceProfile).where(
|
|
|
|
|
ServiceProfile.service_provider_id == provider_id
|
|
|
|
|
(ServiceProfile.service_provider_id == provider_id) |
|
|
|
|
|
(ServiceProfile.organization_id == provider_id)
|
|
|
|
|
)
|
|
|
|
|
profile_result = await db.execute(profile_stmt)
|
|
|
|
|
profile = profile_result.scalar_one_or_none()
|
|
|
|
|
@@ -561,16 +624,35 @@ async def get_provider_detail(
|
|
|
|
|
)
|
|
|
|
|
expertise_result = await db.execute(expertise_stmt)
|
|
|
|
|
resolved_category_ids = [row[0] for row in expertise_result.fetchall()]
|
|
|
|
|
else:
|
|
|
|
|
# P0 FINAL FIX (2026-07-07): If no ServiceProfile exists (e.g. legacy provider #6),
|
|
|
|
|
# return empty list instead of null. The PATCH endpoint auto-creates a ServiceProfile
|
|
|
|
|
# when category_ids are saved, but GET must not crash or return null for providers
|
|
|
|
|
# that haven't been edited yet.
|
|
|
|
|
resolved_category_ids = []
|
|
|
|
|
|
|
|
|
|
# Get opening_hours from the ServiceProfile (JSONB column)
|
|
|
|
|
opening_hours = profile.opening_hours if profile else {}
|
|
|
|
|
|
|
|
|
|
# P0 BUGFIX: Resolve address_detail from address_id if present
|
|
|
|
|
live_address_detail = None
|
|
|
|
|
live_address_id = getattr(sp, "address_id", None)
|
|
|
|
|
if live_address_id:
|
|
|
|
|
try:
|
|
|
|
|
addr_data = await AddressManager.get_normalized(db, live_address_id)
|
|
|
|
|
if addr_data:
|
|
|
|
|
live_address_detail = AddressOut(**addr_data)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(
|
|
|
|
|
f"Failed to resolve address_detail for provider #{sp.id}: {e}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return ProviderDetail(
|
|
|
|
|
id=sp.id,
|
|
|
|
|
name=sp.name,
|
|
|
|
|
address=sp.address or "",
|
|
|
|
|
city=sp.city,
|
|
|
|
|
address_detail=None,
|
|
|
|
|
address_detail=live_address_detail,
|
|
|
|
|
plus_code=sp.plus_code,
|
|
|
|
|
contact_phone=sp.contact_phone,
|
|
|
|
|
contact_email=sp.contact_email,
|
|
|
|
|
@@ -586,9 +668,13 @@ async def get_provider_detail(
|
|
|
|
|
supported_vehicle_classes=sp.supported_vehicle_classes or [],
|
|
|
|
|
specializations=sp.specializations or {},
|
|
|
|
|
opening_hours=opening_hours or {},
|
|
|
|
|
# P0 BUGFIX: Include raw_data for ServiceProvider records so the frontend
|
|
|
|
|
# "Nyers adatok / Crawler" tab shows actual data instead of empty/mock state
|
|
|
|
|
raw_data=sp.raw_data or {},
|
|
|
|
|
# P0 BUGFIX: ServiceProvider model does NOT have raw_data, trust_score,
|
|
|
|
|
# rejection_reason, or audit_trail columns — those exist only on ServiceStaging.
|
|
|
|
|
# Explicitly pass None to avoid Pydantic/AttributeError 500 crash.
|
|
|
|
|
raw_data=None,
|
|
|
|
|
trust_score=None,
|
|
|
|
|
rejection_reason=None,
|
|
|
|
|
audit_trail=None,
|
|
|
|
|
created_at=sp.created_at,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@@ -598,11 +684,24 @@ async def get_provider_detail(
|
|
|
|
|
)
|
|
|
|
|
ss = staging.scalar_one_or_none()
|
|
|
|
|
if ss:
|
|
|
|
|
# P0 CRITICAL BUGFIX: Resolve address_detail from address_id if present
|
|
|
|
|
staging_address_detail = None
|
|
|
|
|
if ss.address_id:
|
|
|
|
|
try:
|
|
|
|
|
addr_data = await AddressManager.get_normalized(db, ss.address_id)
|
|
|
|
|
if addr_data:
|
|
|
|
|
staging_address_detail = AddressOut(**addr_data)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(
|
|
|
|
|
f"Failed to resolve address_detail for staging #{ss.id}: {e}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return ProviderDetail(
|
|
|
|
|
id=ss.id,
|
|
|
|
|
name=ss.name,
|
|
|
|
|
address=ss.full_address or "",
|
|
|
|
|
city=ss.city,
|
|
|
|
|
address_detail=staging_address_detail,
|
|
|
|
|
contact_phone=ss.contact_phone,
|
|
|
|
|
contact_email=ss.contact_email,
|
|
|
|
|
website=ss.website,
|
|
|
|
|
@@ -1142,64 +1241,104 @@ async def update_provider(
|
|
|
|
|
|
|
|
|
|
Minden változás auditálásra kerül az AuditLog táblában.
|
|
|
|
|
"""
|
|
|
|
|
provider = await _get_provider_or_404(db, provider_id)
|
|
|
|
|
# P0 BUGFIX: First try ServiceProvider, then fall back to ServiceStaging
|
|
|
|
|
# (same pattern as get_provider_detail endpoint)
|
|
|
|
|
provider = await db.execute(
|
|
|
|
|
select(ServiceProvider).where(ServiceProvider.id == provider_id)
|
|
|
|
|
)
|
|
|
|
|
sp = provider.scalar_one_or_none()
|
|
|
|
|
staging_record = None
|
|
|
|
|
|
|
|
|
|
if sp:
|
|
|
|
|
# Found in ServiceProvider — use it
|
|
|
|
|
provider_obj = sp
|
|
|
|
|
is_staging = False
|
|
|
|
|
else:
|
|
|
|
|
# Try ServiceStaging
|
|
|
|
|
staging = await db.execute(
|
|
|
|
|
select(ServiceStaging).where(ServiceStaging.id == provider_id)
|
|
|
|
|
)
|
|
|
|
|
ss = staging.scalar_one_or_none()
|
|
|
|
|
if not ss:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail=f"Provider with ID {provider_id} not found in any table.",
|
|
|
|
|
)
|
|
|
|
|
provider_obj = ss
|
|
|
|
|
is_staging = True
|
|
|
|
|
|
|
|
|
|
# Változások nyomon követése auditáláshoz
|
|
|
|
|
old_status = provider.status.value if hasattr(provider.status, 'value') else str(provider.status)
|
|
|
|
|
old_status = provider_obj.status.value if hasattr(provider_obj.status, 'value') else str(provider_obj.status)
|
|
|
|
|
old_data = {
|
|
|
|
|
"name": provider.name,
|
|
|
|
|
"city": provider.city,
|
|
|
|
|
"address_zip": provider.address_zip,
|
|
|
|
|
"address_street_name": provider.address_street_name,
|
|
|
|
|
"address_street_type": provider.address_street_type,
|
|
|
|
|
"address_house_number": provider.address_house_number,
|
|
|
|
|
"plus_code": provider.plus_code,
|
|
|
|
|
"contact_phone": provider.contact_phone,
|
|
|
|
|
"contact_email": provider.contact_email,
|
|
|
|
|
"website": provider.website,
|
|
|
|
|
"category": provider.category,
|
|
|
|
|
"name": provider_obj.name,
|
|
|
|
|
"city": provider_obj.city,
|
|
|
|
|
"address_zip": getattr(provider_obj, 'address_zip', None),
|
|
|
|
|
"address_street_name": getattr(provider_obj, 'address_street_name', None),
|
|
|
|
|
"address_street_type": getattr(provider_obj, 'address_street_type', None),
|
|
|
|
|
"address_house_number": getattr(provider_obj, 'address_house_number', None),
|
|
|
|
|
"plus_code": getattr(provider_obj, 'plus_code', None),
|
|
|
|
|
"contact_phone": provider_obj.contact_phone,
|
|
|
|
|
"contact_email": provider_obj.contact_email,
|
|
|
|
|
"website": provider_obj.website,
|
|
|
|
|
"category": getattr(provider_obj, 'category', None),
|
|
|
|
|
"status": old_status,
|
|
|
|
|
"validation_score": provider.validation_score,
|
|
|
|
|
"supported_vehicle_classes": provider.supported_vehicle_classes,
|
|
|
|
|
"specializations": provider.specializations,
|
|
|
|
|
"validation_score": getattr(provider_obj, 'validation_score', 0),
|
|
|
|
|
"supported_vehicle_classes": getattr(provider_obj, 'supported_vehicle_classes', None),
|
|
|
|
|
"specializations": getattr(provider_obj, 'specializations', None),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Mezők frissítése (csak a nem None értékeket)
|
|
|
|
|
update_fields = update_data.model_dump(exclude_none=True)
|
|
|
|
|
|
|
|
|
|
# =====================================================================
|
|
|
|
|
# P0 CRITICAL BUGFIX: Staging records (ServiceStaging) have a different
|
|
|
|
|
# column set than ServiceProvider. ServiceStaging does NOT have:
|
|
|
|
|
# address_zip, address_street_name, address_street_type,
|
|
|
|
|
# address_house_number, address_stairwell, address_floor, address_door,
|
|
|
|
|
# address_hrsz, latitude, longitude, category, validation_score,
|
|
|
|
|
# supported_vehicle_classes, specializations
|
|
|
|
|
# For staging records, we must:
|
|
|
|
|
# 1. Apply ONLY flat columns that exist on ServiceStaging
|
|
|
|
|
# 2. Merge complex data (address_detail, specializations, category_ids)
|
|
|
|
|
# into raw_data so they are preserved for the Promotion Engine
|
|
|
|
|
# =====================================================================
|
|
|
|
|
|
|
|
|
|
# =====================================================================
|
|
|
|
|
# STEP 0: Extract address_detail (AddressIn) and map to flat model fields
|
|
|
|
|
# =====================================================================
|
|
|
|
|
address_detail: Optional[dict] = update_fields.pop("address_detail", None)
|
|
|
|
|
if address_detail:
|
|
|
|
|
# Map AddressIn fields to ServiceProvider flat columns
|
|
|
|
|
addr_mapping = {
|
|
|
|
|
"zip": "address_zip",
|
|
|
|
|
"city": None, # city is a direct column, handled separately
|
|
|
|
|
"street_name": "address_street_name",
|
|
|
|
|
"street_type": "address_street_type",
|
|
|
|
|
"house_number": "address_house_number",
|
|
|
|
|
"stairwell": "address_stairwell",
|
|
|
|
|
"floor": "address_floor",
|
|
|
|
|
"door": "address_door",
|
|
|
|
|
"parcel_id": "address_hrsz",
|
|
|
|
|
"full_address_text": None, # maps to address field
|
|
|
|
|
"latitude": None, # maps to latitude
|
|
|
|
|
"longitude": None, # maps to longitude
|
|
|
|
|
}
|
|
|
|
|
for addr_field, model_field in addr_mapping.items():
|
|
|
|
|
if addr_field in address_detail and address_detail[addr_field] is not None:
|
|
|
|
|
if model_field:
|
|
|
|
|
update_fields[model_field] = address_detail[addr_field]
|
|
|
|
|
elif addr_field == "full_address_text":
|
|
|
|
|
update_fields["address"] = address_detail[addr_field]
|
|
|
|
|
elif addr_field == "latitude":
|
|
|
|
|
update_fields["latitude"] = address_detail[addr_field]
|
|
|
|
|
elif addr_field == "longitude":
|
|
|
|
|
update_fields["longitude"] = address_detail[addr_field]
|
|
|
|
|
elif addr_field == "city":
|
|
|
|
|
update_fields["city"] = address_detail[addr_field]
|
|
|
|
|
|
|
|
|
|
if is_staging and address_detail:
|
|
|
|
|
# P0 CRITICAL BUGFIX: Use AddressManager to create/update the Address
|
|
|
|
|
# record in system.addresses, then link it via address_id on ServiceStaging.
|
|
|
|
|
# This ensures the address_detail is NOT lost — it's stored in the
|
|
|
|
|
# normalized Address table and can be returned in the API response.
|
|
|
|
|
addr_in = AddressIn(**address_detail)
|
|
|
|
|
new_address_id = await AddressManager.create_or_update(
|
|
|
|
|
db=db,
|
|
|
|
|
address_id=getattr(provider_obj, "address_id", None),
|
|
|
|
|
data=addr_in,
|
|
|
|
|
)
|
|
|
|
|
if new_address_id:
|
|
|
|
|
provider_obj.address_id = new_address_id
|
|
|
|
|
# Also update full_address if full_address_text is provided
|
|
|
|
|
if address_detail.get("full_address_text"):
|
|
|
|
|
update_fields["full_address"] = address_detail["full_address_text"]
|
|
|
|
|
if address_detail.get("city"):
|
|
|
|
|
update_fields["city"] = address_detail["city"]
|
|
|
|
|
elif address_detail:
|
|
|
|
|
# P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
|
|
|
|
# Use AddressManager to create/update the normalized system.addresses
|
|
|
|
|
# record and link it via address_id on ServiceProvider.
|
|
|
|
|
# Flat column writes (address_zip, address_street_name, etc.) are
|
|
|
|
|
# STRICTLY REMOVED — AddressManager is the single source of truth.
|
|
|
|
|
addr_in = AddressIn(**address_detail)
|
|
|
|
|
new_address_id = await AddressManager.create_or_update(
|
|
|
|
|
db=db,
|
|
|
|
|
address_id=getattr(provider_obj, "address_id", None),
|
|
|
|
|
data=addr_in,
|
|
|
|
|
)
|
|
|
|
|
if new_address_id:
|
|
|
|
|
provider_obj.address_id = new_address_id
|
|
|
|
|
# =====================================================================
|
|
|
|
|
# STEP 1: Extract multi-dimensional arrays and JSONB from payload
|
|
|
|
|
# =====================================================================
|
|
|
|
|
@@ -1235,89 +1374,307 @@ async def update_provider(
|
|
|
|
|
new_status = update_fields.get("status")
|
|
|
|
|
if new_status and new_status != old_status:
|
|
|
|
|
# Ha státusz változik, naplózzuk külön akcióként
|
|
|
|
|
provider.status = ModerationStatus(new_status)
|
|
|
|
|
if not is_staging:
|
|
|
|
|
provider_obj.status = ModerationStatus(new_status)
|
|
|
|
|
else:
|
|
|
|
|
provider_obj.status = new_status
|
|
|
|
|
if new_status == "approved" and old_status == "rejected":
|
|
|
|
|
# Restore esetén validation_score visszaállítás
|
|
|
|
|
if "validation_score" not in update_fields:
|
|
|
|
|
provider.validation_score = 50
|
|
|
|
|
if hasattr(provider_obj, 'validation_score'):
|
|
|
|
|
provider_obj.validation_score = 50
|
|
|
|
|
elif new_status == "rejected":
|
|
|
|
|
# Reject/flag esetén validation_score csökkentés
|
|
|
|
|
if "validation_score" not in update_fields:
|
|
|
|
|
provider.validation_score = max(0, (provider.validation_score or 0) - 20)
|
|
|
|
|
if hasattr(provider_obj, 'validation_score'):
|
|
|
|
|
provider_obj.validation_score = max(0, (provider_obj.validation_score or 0) - 20)
|
|
|
|
|
# Töröljük a status-t a sima mezőkből, mert külön kezeltük
|
|
|
|
|
del update_fields["status"]
|
|
|
|
|
|
|
|
|
|
# =====================================================================
|
|
|
|
|
# STEP 2: Save supported_vehicle_classes and specializations to ServiceProvider
|
|
|
|
|
# P0 CRITICAL FIX (2026-07-07 v2): PROMOTION ENGINE
|
|
|
|
|
# When a ServiceStaging record is approved by admin, promote it to a
|
|
|
|
|
# live ServiceProvider + ServiceProfile, then delete the staging record.
|
|
|
|
|
#
|
|
|
|
|
# P0 CRITICAL BUGFIX v2 (2026-07-07):
|
|
|
|
|
# - Trigger condition now checks provider_obj.status directly (the actual
|
|
|
|
|
# DB state after update) instead of relying on new_status from update_fields.
|
|
|
|
|
# This ensures promotion fires even if status was already "approved" in DB
|
|
|
|
|
# or if status wasn't explicitly passed in the PATCH payload.
|
|
|
|
|
# - After promotion, ALL references to provider_id (AuditLog, profile lookup)
|
|
|
|
|
# now use new_provider.id, NOT the old staging provider_id.
|
|
|
|
|
# - Added logger.error("P0 PROMOTION TRIGGERED") for Docker traceability.
|
|
|
|
|
# =====================================================================
|
|
|
|
|
if supported_vehicle_classes is not None:
|
|
|
|
|
provider.supported_vehicle_classes = supported_vehicle_classes
|
|
|
|
|
if specializations is not None:
|
|
|
|
|
provider.specializations = specializations
|
|
|
|
|
|
|
|
|
|
# Többi mező frissítése
|
|
|
|
|
for field, value in update_fields.items():
|
|
|
|
|
if field != "status":
|
|
|
|
|
setattr(provider, field, value)
|
|
|
|
|
|
|
|
|
|
# =====================================================================
|
|
|
|
|
# STEP 3: Sync expertise tags and arrays to ServiceProfile
|
|
|
|
|
# =====================================================================
|
|
|
|
|
# Find the ServiceProfile linked to this ServiceProvider
|
|
|
|
|
profile_stmt = select(ServiceProfile).where(
|
|
|
|
|
ServiceProfile.service_provider_id == provider_id
|
|
|
|
|
# Determine the effective status: use the newly set status on provider_obj,
|
|
|
|
|
# or fall back to the current DB status. This is more robust than relying
|
|
|
|
|
# on new_status from update_fields, which may be None if status wasn't
|
|
|
|
|
# explicitly included in the PATCH payload.
|
|
|
|
|
effective_status = (
|
|
|
|
|
provider_obj.status.value
|
|
|
|
|
if hasattr(provider_obj.status, 'value')
|
|
|
|
|
else str(provider_obj.status)
|
|
|
|
|
)
|
|
|
|
|
profile_result = await db.execute(profile_stmt)
|
|
|
|
|
profile = profile_result.scalar_one_or_none()
|
|
|
|
|
if is_staging and effective_status == "approved":
|
|
|
|
|
logger.error(
|
|
|
|
|
f"P0 PROMOTION TRIGGERED: Staging #{provider_id} "
|
|
|
|
|
f"('{provider_obj.name}') effective_status='{effective_status}', "
|
|
|
|
|
f"new_status_from_payload='{new_status}', "
|
|
|
|
|
f"approved by admin #{current_user.id}. Promoting to live..."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if profile:
|
|
|
|
|
# Save arrays to ServiceProfile as well
|
|
|
|
|
if supported_vehicle_classes is not None:
|
|
|
|
|
profile.supported_vehicle_classes = supported_vehicle_classes
|
|
|
|
|
if specializations is not None:
|
|
|
|
|
profile.specializations = specializations
|
|
|
|
|
if opening_hours is not None:
|
|
|
|
|
profile.opening_hours = opening_hours
|
|
|
|
|
# 1. Create ServiceProvider from staging data
|
|
|
|
|
from app.models.identity.social import SourceType
|
|
|
|
|
new_provider = ServiceProvider(
|
|
|
|
|
name=provider_obj.name,
|
|
|
|
|
address=provider_obj.full_address or "",
|
|
|
|
|
city=provider_obj.city,
|
|
|
|
|
address_id=getattr(provider_obj, "address_id", None),
|
|
|
|
|
plus_code=getattr(provider_obj, "plus_code", None),
|
|
|
|
|
contact_phone=provider_obj.contact_phone,
|
|
|
|
|
contact_email=provider_obj.contact_email,
|
|
|
|
|
website=provider_obj.website,
|
|
|
|
|
category=update_fields.get("category") or getattr(provider_obj, "category", None),
|
|
|
|
|
status=ModerationStatus.approved,
|
|
|
|
|
source=SourceType.manual,
|
|
|
|
|
validation_score=100, # Auto-Validation: admin approval = 100
|
|
|
|
|
added_by_user_id=current_user.id,
|
|
|
|
|
)
|
|
|
|
|
db.add(new_provider)
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
|
|
|
|
# Sync expertise tags if category_ids provided
|
|
|
|
|
if category_ids is not None:
|
|
|
|
|
# 2. Create ServiceProfile linked to the new ServiceProvider
|
|
|
|
|
profile = ServiceProfile(
|
|
|
|
|
service_provider_id=new_provider.id,
|
|
|
|
|
fingerprint=getattr(provider_obj, "fingerprint", None),
|
|
|
|
|
status="active",
|
|
|
|
|
trust_score=100,
|
|
|
|
|
is_verified=True,
|
|
|
|
|
contact_phone=provider_obj.contact_phone,
|
|
|
|
|
contact_email=provider_obj.contact_email,
|
|
|
|
|
website=provider_obj.website,
|
|
|
|
|
opening_hours=opening_hours or {},
|
|
|
|
|
)
|
|
|
|
|
db.add(profile)
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
|
|
|
|
# 3. Sync category_ids if provided (from raw_data or direct input)
|
|
|
|
|
promoted_category_ids = category_ids
|
|
|
|
|
if not promoted_category_ids and provider_obj.raw_data:
|
|
|
|
|
promoted_category_ids = provider_obj.raw_data.get("category_ids")
|
|
|
|
|
if promoted_category_ids:
|
|
|
|
|
await _sync_service_expertises(
|
|
|
|
|
db=db,
|
|
|
|
|
service_profile_id=profile.id,
|
|
|
|
|
category_ids=category_ids,
|
|
|
|
|
category_ids=promoted_category_ids,
|
|
|
|
|
)
|
|
|
|
|
logger.info(
|
|
|
|
|
f"ServiceExpertise synced for provider #{provider_id}: "
|
|
|
|
|
f"service_profile_id={profile.id}, category_ids={category_ids}"
|
|
|
|
|
f"P0 PROMOTION: ServiceExpertise synced for new provider "
|
|
|
|
|
f"#{new_provider.id}, category_ids={promoted_category_ids}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 4. Log the promotion — use new_provider.id, NOT the old staging provider_id
|
|
|
|
|
await _log_moderation_action(
|
|
|
|
|
db=db,
|
|
|
|
|
user_id=current_user.id,
|
|
|
|
|
action="PROVIDER_PROMOTED_FROM_STAGING",
|
|
|
|
|
provider_id=new_provider.id,
|
|
|
|
|
reason=f"Promoted from ServiceStaging #{provider_id} by admin",
|
|
|
|
|
old_status=old_status,
|
|
|
|
|
new_status="approved",
|
|
|
|
|
source_table="service_staging",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 5. Delete the staging record (promotion complete)
|
|
|
|
|
await db.delete(provider_obj)
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"P0 PROMOTION COMPLETE: Staging #{provider_id} → "
|
|
|
|
|
f"ServiceProvider #{new_provider.id}, "
|
|
|
|
|
f"ServiceProfile #{profile.id}, validation_score=100"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Switch to the new provider for the response
|
|
|
|
|
provider_obj = new_provider
|
|
|
|
|
is_staging = False
|
|
|
|
|
# Reset update_fields since we've already created everything
|
|
|
|
|
update_fields.clear()
|
|
|
|
|
|
|
|
|
|
# =====================================================================
|
|
|
|
|
# STEP 2: Save supported_vehicle_classes and specializations
|
|
|
|
|
# =====================================================================
|
|
|
|
|
if is_staging:
|
|
|
|
|
# P0 BUGFIX: ServiceStaging has no supported_vehicle_classes/specializations columns.
|
|
|
|
|
# Merge them into raw_data for the Promotion Engine.
|
|
|
|
|
# CRITICAL: Use flag_modified() so SQLAlchemy detects the JSONB mutation.
|
|
|
|
|
raw_data_mutated = False
|
|
|
|
|
if supported_vehicle_classes is not None or specializations is not None or category_ids is not None or opening_hours is not None:
|
|
|
|
|
if not provider_obj.raw_data:
|
|
|
|
|
provider_obj.raw_data = {}
|
|
|
|
|
if supported_vehicle_classes is not None:
|
|
|
|
|
provider_obj.raw_data["supported_vehicle_classes"] = supported_vehicle_classes
|
|
|
|
|
raw_data_mutated = True
|
|
|
|
|
if specializations is not None:
|
|
|
|
|
provider_obj.raw_data["specializations"] = specializations
|
|
|
|
|
raw_data_mutated = True
|
|
|
|
|
if category_ids is not None:
|
|
|
|
|
provider_obj.raw_data["category_ids"] = category_ids
|
|
|
|
|
raw_data_mutated = True
|
|
|
|
|
if opening_hours is not None:
|
|
|
|
|
provider_obj.raw_data["opening_hours"] = opening_hours
|
|
|
|
|
raw_data_mutated = True
|
|
|
|
|
if raw_data_mutated:
|
|
|
|
|
flag_modified(provider_obj, "raw_data")
|
|
|
|
|
else:
|
|
|
|
|
if hasattr(provider_obj, 'supported_vehicle_classes') and supported_vehicle_classes is not None:
|
|
|
|
|
provider_obj.supported_vehicle_classes = supported_vehicle_classes
|
|
|
|
|
if hasattr(provider_obj, 'specializations') and specializations is not None:
|
|
|
|
|
provider_obj.specializations = specializations
|
|
|
|
|
|
|
|
|
|
# =====================================================================
|
|
|
|
|
# STEP 2b: For staging records, strip fields that don't exist on ServiceStaging
|
|
|
|
|
# =====================================================================
|
|
|
|
|
if is_staging:
|
|
|
|
|
# P0 BUGFIX: Only keep fields that actually exist on ServiceStaging model
|
|
|
|
|
staging_allowed_fields = {
|
|
|
|
|
"name", "city", "full_address", "contact_phone", "website",
|
|
|
|
|
"contact_email", "description", "status", "plus_code",
|
|
|
|
|
"source", "trust_score", "rejection_reason",
|
|
|
|
|
}
|
|
|
|
|
# Map 'address' from ProviderUpdateInput to 'full_address' on ServiceStaging
|
|
|
|
|
if "address" in update_fields:
|
|
|
|
|
update_fields["full_address"] = update_fields.pop("address")
|
|
|
|
|
# Remove fields that don't exist on ServiceStaging
|
|
|
|
|
raw_data_mutated = False
|
|
|
|
|
for field in list(update_fields.keys()):
|
|
|
|
|
if field not in staging_allowed_fields:
|
|
|
|
|
# Merge unknown fields into raw_data so they aren't lost
|
|
|
|
|
if field not in ("status",):
|
|
|
|
|
if not provider_obj.raw_data:
|
|
|
|
|
provider_obj.raw_data = {}
|
|
|
|
|
provider_obj.raw_data[field] = update_fields[field]
|
|
|
|
|
raw_data_mutated = True
|
|
|
|
|
del update_fields[field]
|
|
|
|
|
if raw_data_mutated:
|
|
|
|
|
flag_modified(provider_obj, "raw_data")
|
|
|
|
|
|
|
|
|
|
# Többi mező frissítése
|
|
|
|
|
for field, value in update_fields.items():
|
|
|
|
|
if field != "status" and hasattr(provider_obj, field):
|
|
|
|
|
setattr(provider_obj, field, value)
|
|
|
|
|
|
|
|
|
|
# =====================================================================
|
|
|
|
|
# STEP 3: Sync expertise tags and arrays to ServiceProfile
|
|
|
|
|
# (Only for ServiceProvider records that have a linked ServiceProfile)
|
|
|
|
|
# =====================================================================
|
|
|
|
|
profile = None
|
|
|
|
|
if not is_staging:
|
|
|
|
|
# P0 CRITICAL BUGFIX v2: Use provider_obj.id instead of provider_id.
|
|
|
|
|
# After promotion, provider_obj points to the NEW ServiceProvider,
|
|
|
|
|
# but provider_id still holds the old staging ID (e.g., 1754).
|
|
|
|
|
# Using provider_obj.id ensures we find the correct ServiceProfile.
|
|
|
|
|
# P0 BUGFIX (2026-07-07): Search by BOTH service_provider_id AND
|
|
|
|
|
# organization_id. ServiceProvider ID 6 (and other legacy providers)
|
|
|
|
|
# may have their ServiceProfile linked via organization_id instead of
|
|
|
|
|
# service_provider_id. Using OR ensures we find the profile regardless
|
|
|
|
|
# of which FK is populated.
|
|
|
|
|
profile_stmt = select(ServiceProfile).where(
|
|
|
|
|
(ServiceProfile.service_provider_id == provider_obj.id) |
|
|
|
|
|
(ServiceProfile.organization_id == provider_obj.id)
|
|
|
|
|
)
|
|
|
|
|
profile_result = await db.execute(profile_stmt)
|
|
|
|
|
profile = profile_result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
# P0 BUGFIX (2026-07-07): If no ServiceProfile exists for this Live provider,
|
|
|
|
|
# CREATE ONE automatically so that advanced fields (opening_hours,
|
|
|
|
|
# specializations, supported_vehicle_classes, category_ids) are not silently lost.
|
|
|
|
|
# P0 CRITICAL BUGFIX (2026-07-07): ServiceProfile creation is now UNCONDITIONAL.
|
|
|
|
|
# Previously, the profile was only created if has_advanced_fields was True.
|
|
|
|
|
# This caused a 500 error when a PATCH with category_ids was sent for a newly
|
|
|
|
|
# created provider that had no ServiceProfile yet, because _sync_service_expertises
|
|
|
|
|
# requires a valid profile.id. Now we ALWAYS create the profile if it doesn't exist.
|
|
|
|
|
if not profile:
|
|
|
|
|
profile = ServiceProfile(
|
|
|
|
|
service_provider_id=provider_obj.id,
|
|
|
|
|
fingerprint=f"auto-created-{provider_obj.id}",
|
|
|
|
|
status="active",
|
|
|
|
|
trust_score=30,
|
|
|
|
|
is_verified=False,
|
|
|
|
|
contact_phone=provider_obj.contact_phone,
|
|
|
|
|
contact_email=provider_obj.contact_email,
|
|
|
|
|
website=provider_obj.website,
|
|
|
|
|
opening_hours=opening_hours or {},
|
|
|
|
|
supported_vehicle_classes=supported_vehicle_classes or [],
|
|
|
|
|
specializations=specializations or {},
|
|
|
|
|
)
|
|
|
|
|
db.add(profile)
|
|
|
|
|
await db.flush()
|
|
|
|
|
logger.info(
|
|
|
|
|
f"P0 BUGFIX: Auto-created ServiceProfile #{profile.id} "
|
|
|
|
|
f"for Live provider #{provider_obj.id} ('{provider_obj.name}')"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# P0 CRITICAL BUGFIX (2026-07-07): Debug logging to trace profile.id
|
|
|
|
|
# before _sync_service_expertises call. This ensures we can identify
|
|
|
|
|
# the exact state in Docker logs if the 500 error recurs.
|
|
|
|
|
print(f"DEBUG: Syncing categories for profile_id: {profile.id}")
|
|
|
|
|
logger.info(
|
|
|
|
|
f"P0 DEBUG: ServiceProfile resolved — profile_id={profile.id}, "
|
|
|
|
|
f"provider_id={provider_obj.id}, category_ids={category_ids}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if profile:
|
|
|
|
|
# Save arrays to ServiceProfile as well
|
|
|
|
|
if supported_vehicle_classes is not None:
|
|
|
|
|
profile.supported_vehicle_classes = supported_vehicle_classes
|
|
|
|
|
if specializations is not None:
|
|
|
|
|
profile.specializations = specializations
|
|
|
|
|
if opening_hours is not None:
|
|
|
|
|
profile.opening_hours = opening_hours
|
|
|
|
|
|
|
|
|
|
# Sync expertise tags if category_ids provided
|
|
|
|
|
if category_ids is not None:
|
|
|
|
|
await _sync_service_expertises(
|
|
|
|
|
db=db,
|
|
|
|
|
service_profile_id=profile.id,
|
|
|
|
|
category_ids=category_ids,
|
|
|
|
|
)
|
|
|
|
|
logger.info(
|
|
|
|
|
f"ServiceExpertise synced for provider #{provider_id}: "
|
|
|
|
|
f"service_profile_id={profile.id}, category_ids={category_ids}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Új állapot rögzítése
|
|
|
|
|
new_status_val = provider.status.value if hasattr(provider.status, 'value') else str(provider.status)
|
|
|
|
|
new_status_val = provider_obj.status.value if hasattr(provider_obj.status, 'value') else str(provider_obj.status)
|
|
|
|
|
|
|
|
|
|
# AuditLog - változások részletes naplózása
|
|
|
|
|
new_data_snapshot = {
|
|
|
|
|
"name": provider.name,
|
|
|
|
|
"city": provider.city,
|
|
|
|
|
"address_zip": provider.address_zip,
|
|
|
|
|
"address_street_name": provider.address_street_name,
|
|
|
|
|
"address_street_type": provider.address_street_type,
|
|
|
|
|
"address_house_number": provider.address_house_number,
|
|
|
|
|
"plus_code": provider.plus_code,
|
|
|
|
|
"contact_phone": provider.contact_phone,
|
|
|
|
|
"contact_email": provider.contact_email,
|
|
|
|
|
"website": provider.website,
|
|
|
|
|
"category": provider.category,
|
|
|
|
|
"name": provider_obj.name,
|
|
|
|
|
"city": provider_obj.city,
|
|
|
|
|
"address_zip": getattr(provider_obj, 'address_zip', None),
|
|
|
|
|
"address_street_name": getattr(provider_obj, 'address_street_name', None),
|
|
|
|
|
"address_street_type": getattr(provider_obj, 'address_street_type', None),
|
|
|
|
|
"address_house_number": getattr(provider_obj, 'address_house_number', None),
|
|
|
|
|
"plus_code": getattr(provider_obj, 'plus_code', None),
|
|
|
|
|
"contact_phone": provider_obj.contact_phone,
|
|
|
|
|
"contact_email": provider_obj.contact_email,
|
|
|
|
|
"website": provider_obj.website,
|
|
|
|
|
"category": getattr(provider_obj, 'category', None),
|
|
|
|
|
"status": new_status_val,
|
|
|
|
|
"validation_score": provider.validation_score,
|
|
|
|
|
"supported_vehicle_classes": provider.supported_vehicle_classes,
|
|
|
|
|
"specializations": provider.specializations,
|
|
|
|
|
"validation_score": getattr(provider_obj, 'validation_score', 0),
|
|
|
|
|
"supported_vehicle_classes": getattr(provider_obj, 'supported_vehicle_classes', None),
|
|
|
|
|
"specializations": getattr(provider_obj, 'specializations', None),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# P0 CRITICAL BUGFIX v2: Use provider_obj.id instead of provider_id.
|
|
|
|
|
# After promotion, provider_obj points to the NEW ServiceProvider,
|
|
|
|
|
# but provider_id still holds the old staging ID (e.g., 1754).
|
|
|
|
|
# The AuditLog must reference the new provider's ID.
|
|
|
|
|
log_entry = AuditLog(
|
|
|
|
|
user_id=current_user.id,
|
|
|
|
|
action="PROVIDER_UPDATED",
|
|
|
|
|
target_type="service_provider",
|
|
|
|
|
target_id=str(provider_id),
|
|
|
|
|
target_id=str(provider_obj.id),
|
|
|
|
|
old_data=old_data,
|
|
|
|
|
new_data={
|
|
|
|
|
"reason": "Admin edit",
|
|
|
|
|
@@ -1338,33 +1695,94 @@ async def update_provider(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail="Database integrity error. Check constraints (e.g., duplicate data, invalid status value).",
|
|
|
|
|
)
|
|
|
|
|
await db.refresh(provider)
|
|
|
|
|
await db.refresh(provider_obj)
|
|
|
|
|
|
|
|
|
|
# Get opening_hours from the ServiceProfile for the response
|
|
|
|
|
opening_hours_response = {}
|
|
|
|
|
resolved_category_ids: Optional[List[int]] = None
|
|
|
|
|
if profile:
|
|
|
|
|
opening_hours_response = profile.opening_hours or {}
|
|
|
|
|
# P0 BUGFIX: Resolve category_ids from the actual DB state (ServiceExpertise)
|
|
|
|
|
# instead of relying on the payload value, which may be stale or None.
|
|
|
|
|
expertise_stmt = select(ServiceExpertise.expertise_id).where(
|
|
|
|
|
ServiceExpertise.service_id == profile.id
|
|
|
|
|
)
|
|
|
|
|
expertise_result = await db.execute(expertise_stmt)
|
|
|
|
|
resolved_category_ids = [row[0] for row in expertise_result.fetchall()]
|
|
|
|
|
|
|
|
|
|
return ProviderDetail(
|
|
|
|
|
id=provider.id,
|
|
|
|
|
name=provider.name,
|
|
|
|
|
address=provider.address or "",
|
|
|
|
|
city=provider.city,
|
|
|
|
|
address_detail=None,
|
|
|
|
|
plus_code=provider.plus_code,
|
|
|
|
|
contact_phone=provider.contact_phone,
|
|
|
|
|
contact_email=provider.contact_email,
|
|
|
|
|
website=provider.website,
|
|
|
|
|
category=provider.category,
|
|
|
|
|
status=provider.status.value if hasattr(provider.status, 'value') else str(provider.status),
|
|
|
|
|
source=provider.source.value if hasattr(provider.source, 'value') else str(provider.source),
|
|
|
|
|
source_table="service_providers",
|
|
|
|
|
validation_score=provider.validation_score or 0,
|
|
|
|
|
evidence_image_path=provider.evidence_image_path,
|
|
|
|
|
added_by_user_id=provider.added_by_user_id,
|
|
|
|
|
category_ids=category_ids,
|
|
|
|
|
supported_vehicle_classes=provider.supported_vehicle_classes or [],
|
|
|
|
|
specializations=provider.specializations or {},
|
|
|
|
|
opening_hours=opening_hours_response,
|
|
|
|
|
created_at=provider.created_at,
|
|
|
|
|
)
|
|
|
|
|
if not is_staging:
|
|
|
|
|
# P0 BUGFIX: Resolve address_detail from address_id if present
|
|
|
|
|
live_address_detail = None
|
|
|
|
|
live_address_id = getattr(provider_obj, "address_id", None)
|
|
|
|
|
if live_address_id:
|
|
|
|
|
try:
|
|
|
|
|
addr_data = await AddressManager.get_normalized(db, live_address_id)
|
|
|
|
|
if addr_data:
|
|
|
|
|
live_address_detail = AddressOut(**addr_data)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(
|
|
|
|
|
f"Failed to resolve address_detail for provider #{provider_obj.id}: {e}"
|
|
|
|
|
)
|
|
|
|
|
return ProviderDetail(
|
|
|
|
|
id=provider_obj.id,
|
|
|
|
|
name=provider_obj.name,
|
|
|
|
|
address=provider_obj.address or "",
|
|
|
|
|
city=provider_obj.city,
|
|
|
|
|
address_detail=live_address_detail,
|
|
|
|
|
plus_code=provider_obj.plus_code,
|
|
|
|
|
contact_phone=provider_obj.contact_phone,
|
|
|
|
|
contact_email=provider_obj.contact_email,
|
|
|
|
|
website=provider_obj.website,
|
|
|
|
|
category=provider_obj.category,
|
|
|
|
|
status=provider_obj.status.value if hasattr(provider_obj.status, 'value') else str(provider_obj.status),
|
|
|
|
|
source=provider_obj.source.value if hasattr(provider_obj.source, 'value') else str(provider_obj.source),
|
|
|
|
|
source_table="service_providers",
|
|
|
|
|
validation_score=getattr(provider_obj, 'validation_score', 0) or 0,
|
|
|
|
|
evidence_image_path=getattr(provider_obj, 'evidence_image_path', None),
|
|
|
|
|
added_by_user_id=getattr(provider_obj, 'added_by_user_id', None),
|
|
|
|
|
category_ids=resolved_category_ids or category_ids,
|
|
|
|
|
supported_vehicle_classes=getattr(provider_obj, 'supported_vehicle_classes', None) or [],
|
|
|
|
|
specializations=getattr(provider_obj, 'specializations', None) or {},
|
|
|
|
|
opening_hours=opening_hours_response,
|
|
|
|
|
raw_data=None,
|
|
|
|
|
trust_score=None,
|
|
|
|
|
rejection_reason=None,
|
|
|
|
|
audit_trail=None,
|
|
|
|
|
created_at=provider_obj.created_at,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# Staging record response
|
|
|
|
|
# P0 CRITICAL BUGFIX: Resolve address_detail from address_id if present
|
|
|
|
|
staging_address_detail = None
|
|
|
|
|
staging_address_id = getattr(provider_obj, "address_id", None)
|
|
|
|
|
if staging_address_id:
|
|
|
|
|
try:
|
|
|
|
|
addr_data = await AddressManager.get_normalized(db, staging_address_id)
|
|
|
|
|
if addr_data:
|
|
|
|
|
staging_address_detail = AddressOut(**addr_data)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(
|
|
|
|
|
f"Failed to resolve address_detail for staging #{provider_obj.id}: {e}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return ProviderDetail(
|
|
|
|
|
id=provider_obj.id,
|
|
|
|
|
name=provider_obj.name,
|
|
|
|
|
address=provider_obj.full_address or "",
|
|
|
|
|
city=provider_obj.city,
|
|
|
|
|
address_detail=staging_address_detail,
|
|
|
|
|
plus_code=getattr(provider_obj, 'plus_code', None),
|
|
|
|
|
contact_phone=provider_obj.contact_phone,
|
|
|
|
|
contact_email=provider_obj.contact_email,
|
|
|
|
|
website=provider_obj.website,
|
|
|
|
|
description=getattr(provider_obj, 'description', None),
|
|
|
|
|
status=provider_obj.status,
|
|
|
|
|
source=provider_obj.source or "bot",
|
|
|
|
|
source_table="service_staging",
|
|
|
|
|
validation_score=getattr(provider_obj, 'trust_score', 0) or 0,
|
|
|
|
|
trust_score=provider_obj.trust_score,
|
|
|
|
|
rejection_reason=provider_obj.rejection_reason,
|
|
|
|
|
raw_data=provider_obj.raw_data or {},
|
|
|
|
|
audit_trail=provider_obj.audit_trail or {},
|
|
|
|
|
created_at=provider_obj.created_at,
|
|
|
|
|
)
|