349 lines
17 KiB
Python
Executable File
349 lines
17 KiB
Python
Executable File
# /opt/docker/dev/service_finder/backend/app/models/marketplace/organization.py
|
|
import enum
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Any, List, Optional, TYPE_CHECKING
|
|
import sqlalchemy as sa
|
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, JSON, text, Numeric, BigInteger, Float
|
|
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID as PG_UUID, JSONB
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship, foreign
|
|
from sqlalchemy.sql import func
|
|
from geoalchemy2 import Geometry
|
|
|
|
# MB 2.0: A központi aszinkron adatbázis motorból húzzuk be a Base-t
|
|
from app.database import Base
|
|
|
|
if TYPE_CHECKING:
|
|
from .service_request import ServiceRequest
|
|
|
|
class OrgType(str, enum.Enum):
|
|
individual = "individual"
|
|
service = "service"
|
|
service_provider = "service_provider"
|
|
fleet_owner = "fleet_owner"
|
|
club = "club"
|
|
business = "business"
|
|
|
|
class OrgUserRole(str, enum.Enum):
|
|
"""Szervezeti (Organization-level) szerepkörök.
|
|
|
|
Ezek a szerepkörök a szervezeten/flottán belüli jogosultságokat határozzák meg.
|
|
Minden szerepkörhöz egyedi JSON permissions objektum tartozik a fleet.org_roles táblában.
|
|
"""
|
|
OWNER = "OWNER"
|
|
ADMIN = "ADMIN"
|
|
ACCOUNTANT = "ACCOUNTANT"
|
|
DRIVER = "DRIVER"
|
|
VIEWER = "VIEWER"
|
|
|
|
class OrgRole(Base):
|
|
"""
|
|
Dinamikus szerepkör modell (RBAC Phase 2).
|
|
A szervezeti szerepkörök adatbázis-vezéreltek, nem hardkódolt Enum-ok.
|
|
Alapértelmezett szerepkörök: OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER.
|
|
|
|
permissions: JSONB oszlop a capability-alapú jogosultságok tárolására.
|
|
Példa: {"can_add_expense": True, "can_approve_expense": False}
|
|
"""
|
|
__tablename__ = "org_roles"
|
|
__table_args__ = {"schema": "fleet"}
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
name_key: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
|
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
description: Mapped[Optional[str]] = mapped_column(String(255))
|
|
is_system: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
priority: Mapped[int] = mapped_column(Integer, default=0)
|
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
# ── RBAC Phase 2: JSONB permissions column ──
|
|
# Stores capability flags for this role, e.g.:
|
|
# {"can_add_expense": True, "can_approve_expense": True, "can_view_financials": True}
|
|
permissions: Mapped[dict] = mapped_column(
|
|
JSONB,
|
|
nullable=False,
|
|
default=dict,
|
|
server_default=text("'{}'::jsonb")
|
|
)
|
|
|
|
class Organization(Base):
|
|
"""
|
|
Szervezet entitás (MB 2.0).
|
|
Támogatja a 'Digital Twin' logikát: a cég törölhető, de a statisztika és
|
|
a jármű-életút adatok megmaradnak az eredeti Person-höz kötve.
|
|
"""
|
|
__tablename__ = "organizations"
|
|
__table_args__ = {"schema": "fleet"}
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
|
|
# --- 🛡️ BIZTONSÁGI ÉS ÉLETÚT KIEGÉSZÍTÉSEK ---
|
|
|
|
# A Jogi képviselő/Tulajdonos (A Person örök DNS-e az identity sémában)
|
|
# Ez segít felismerni, ha ugyanaz az ember új céggel akar 'tiszta lapot' nyitni.
|
|
legal_owner_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"), index=True)
|
|
|
|
# ÉLETÚT DÁTUMOK (A kért logika alapján)
|
|
# 1. A legelső regisztráció dátuma (Soha nem változik)
|
|
first_registered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
# 2. Az AKTUÁLIS életciklus kezdete. Újraregisztrációkor ez frissül.
|
|
# Az API ezt használja szűrőnek: a cég csak az ezutáni adatokat látja a Dashboardon.
|
|
current_lifecycle_started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
# 3. Az utolsó deaktiválás/törlés időpontja
|
|
last_deactivated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
|
|
# Hányszor regisztrált újra ez a cég/adószám (Reinkarnációs index)
|
|
lifecycle_index: Mapped[int] = mapped_column(Integer, default=1, server_default=text("1"))
|
|
|
|
# --- 🏢 ALAPADATOK (MEGŐRIZVE) ---
|
|
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
|
|
|
|
is_anonymized: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text("false"))
|
|
anonymized_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
|
|
full_name: Mapped[str] = mapped_column(String, nullable=False)
|
|
name: Mapped[str] = mapped_column(String, nullable=False)
|
|
display_name: Mapped[Optional[str]] = mapped_column(String(50))
|
|
folder_slug: Mapped[str] = mapped_column(String(24), unique=True, index=True)
|
|
|
|
default_currency: Mapped[str] = mapped_column(String(3), default="HUF")
|
|
country_code: Mapped[str] = mapped_column(String(2), default="HU")
|
|
language: Mapped[str] = mapped_column(String(5), default="hu")
|
|
|
|
address_zip: Mapped[Optional[str]] = mapped_column(String(10))
|
|
address_city: Mapped[Optional[str]] = mapped_column(String(100))
|
|
address_street_name: Mapped[Optional[str]] = mapped_column(String(150))
|
|
address_street_type: Mapped[Optional[str]] = mapped_column(String(50))
|
|
address_house_number: Mapped[Optional[str]] = mapped_column(String(20))
|
|
address_hrsz: Mapped[Optional[str]] = mapped_column(String(50))
|
|
plus_code: Mapped[Optional[str]] = mapped_column(String(20))
|
|
|
|
tax_number: Mapped[Optional[str]] = mapped_column(String(20), unique=True, index=True)
|
|
reg_number: Mapped[Optional[str]] = mapped_column(String(50))
|
|
|
|
org_type: Mapped[OrgType] = mapped_column(
|
|
PG_ENUM(OrgType, name="orgtype", schema="fleet"),
|
|
default=OrgType.individual
|
|
)
|
|
|
|
is_affiliate_partner: Mapped[bool] = mapped_column(Boolean, server_default=text('false'), default=False)
|
|
|
|
status: Mapped[str] = mapped_column(String(30), default="pending_verification")
|
|
|
|
# Soft delete: is_active=False és is_deleted=True esetén a cég 'törölt'
|
|
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
|
|
# ── 📦 ELŐFIZETÉS / SUBSCRIPTION ──
|
|
subscription_plan: Mapped[str] = mapped_column(String(30), server_default=text("'FREE'"), index=True)
|
|
subscription_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
base_asset_limit: Mapped[int] = mapped_column(Integer, server_default=text("1"))
|
|
purchased_extra_slots: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
|
|
|
# P0 FEATURE: Subscription Tier FK — connects this org to a system.subscription_tiers record
|
|
subscription_tier_id: Mapped[Optional[int]] = mapped_column(
|
|
Integer,
|
|
ForeignKey("system.subscription_tiers.id"),
|
|
nullable=True,
|
|
index=True
|
|
)
|
|
|
|
# ── P0 3D CAPABILITY MATRIX: custom_features JSONB ──
|
|
# Organization-specific feature overrides on top of the subscription tier defaults.
|
|
# These values MERGE with (and override) tier.feature_capabilities at runtime.
|
|
# Example: {"can_export_data": True, "can_use_analytics": False}
|
|
custom_features: Mapped[dict] = mapped_column(
|
|
JSONB,
|
|
nullable=False,
|
|
default=dict,
|
|
server_default=text("'{}'::jsonb")
|
|
)
|
|
|
|
notification_settings: Mapped[Any] = mapped_column(JSON, server_default=text("'{\"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1]}'::jsonb"))
|
|
external_integration_config: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
|
|
|
visual_settings: Mapped[dict] = mapped_column(
|
|
JSONB,
|
|
nullable=False,
|
|
default=lambda: {"theme": "default", "primary_color": None, "wall_logo_url": None},
|
|
server_default=text("'{\"theme\": \"default\", \"primary_color\": null, \"wall_logo_url\": null}'::jsonb")
|
|
)
|
|
|
|
# Dinamikus szervezeti beállítások (pl. invite_expiry_days, role_hierarchy)
|
|
settings: Mapped[dict] = mapped_column(
|
|
JSONB,
|
|
nullable=False,
|
|
default=lambda: {"invite_expiry_days": 7, "max_members": 50, "allow_public_join": False},
|
|
server_default=text("'{\"invite_expiry_days\": 7, \"max_members\": 50, \"allow_public_join\": false}'::jsonb")
|
|
)
|
|
|
|
# --- 🔍 CROWDSOURCED SEARCH FIELDS ---
|
|
# Provider nicknames / alternative names for matching (e.g. ["MOL", "MOL LUB", "MOL Magyarország"])
|
|
aliases: Mapped[list] = mapped_column(
|
|
JSONB,
|
|
nullable=False,
|
|
default=list,
|
|
server_default=text("'[]'::jsonb")
|
|
)
|
|
# Crowdsourced evaluation characteristics / tags (e.g. ["gyors", "megbízható", "kedvező ár"])
|
|
tags: Mapped[list] = mapped_column(
|
|
JSONB,
|
|
nullable=False,
|
|
default=list,
|
|
server_default=text("'[]'::jsonb")
|
|
)
|
|
|
|
# A technikai tulajdonos (User fiók - törölhető)
|
|
owner_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
|
|
|
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
|
|
# Időbélyegek az aktuális állapothoz
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
|
is_ownership_transferable: Mapped[bool] = mapped_column(Boolean, server_default=text("true"))
|
|
|
|
# --- 🔗 KAPCSOLATOK (RELATIONSHIPS) ---
|
|
assets: Mapped[List["AssetAssignment"]] = relationship("AssetAssignment", back_populates="organization", cascade="all, delete-orphan")
|
|
members: Mapped[List["OrganizationMember"]] = relationship("OrganizationMember", back_populates="organization", cascade="all, delete-orphan")
|
|
owner: Mapped[Optional["User"]] = relationship("User", back_populates="owned_organizations")
|
|
financials: Mapped[List["OrganizationFinancials"]] = relationship("OrganizationFinancials", back_populates="organization", cascade="all, delete-orphan")
|
|
|
|
# JAVÍTVA: Ha az Organization törlődik, a ServiceProfile megmarad 'Ghost'-ként (ondelete="SET NULL")
|
|
service_profile: Mapped[Optional["ServiceProfile"]] = relationship("ServiceProfile", back_populates="organization", uselist=False)
|
|
|
|
branches: Mapped[List["Branch"]] = relationship("Branch", back_populates="organization", cascade="all, delete-orphan")
|
|
|
|
# Kapcsolat az örök személy rekordhoz
|
|
legal_owner: Mapped[Optional["Person"]] = relationship("Person", back_populates="owned_business_entities")
|
|
|
|
# ── P0 3D CAPABILITY MATRIX: SubscriptionTier relationship ──
|
|
subscription_tier: Mapped[Optional["SubscriptionTier"]] = relationship(
|
|
"SubscriptionTier",
|
|
foreign_keys=[subscription_tier_id],
|
|
backref="organizations",
|
|
lazy="selectin"
|
|
)
|
|
|
|
|
|
class OrganizationFinancials(Base):
|
|
__tablename__ = "organization_financials"
|
|
__table_args__ = {"schema": "fleet"}
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("fleet.organizations.id"), nullable=False)
|
|
year: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
turnover: Mapped[Optional[float]] = mapped_column(Numeric(18, 2))
|
|
profit: Mapped[Optional[float]] = mapped_column(Numeric(18, 2))
|
|
employee_count: Mapped[Optional[int]] = mapped_column(Integer)
|
|
source: Mapped[Optional[str]] = mapped_column(String(50))
|
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
|
|
|
organization: Mapped["Organization"] = relationship("Organization", back_populates="financials")
|
|
|
|
class OrganizationMember(Base):
|
|
__tablename__ = "organization_members"
|
|
__table_args__ = {"schema": "fleet"}
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("fleet.organizations.id"), nullable=False)
|
|
|
|
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
|
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
|
|
|
|
# Meghívott e-mail címe (ha a meghívottnak még nincs user fiókja)
|
|
invited_email: Mapped[Optional[str]] = mapped_column(String(255))
|
|
|
|
# Meghívó lejárati ideje
|
|
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
|
|
# Dinamikus szerepkör - PostgreSQL ENUM típus (fleet.orguserrole)
|
|
# A PG_ENUM használata biztosítja, hogy az SQLAlchemy megfelelő típuskényszerítést
|
|
# (type cast) alkalmazzon a lekérdezéseknél, elkerülve az
|
|
# "operator does not exist: fleet.orguserrole = character varying" hibát.
|
|
role: Mapped[str] = mapped_column(
|
|
PG_ENUM(OrgUserRole, name="orguserrole", schema="fleet", create_type=False),
|
|
default="MEMBER",
|
|
server_default=text("'MEMBER'")
|
|
)
|
|
permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
|
is_permanent: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
|
|
# 🔴 HIÁNYZÓ OSZLOPOK - Pótolva a logic_spec alapján
|
|
status: Mapped[str] = mapped_column(String(20), default="active", server_default=text("'active'"))
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
|
|
|
organization: Mapped["Organization"] = relationship("Organization", back_populates="members")
|
|
user: Mapped[Optional["User"]] = relationship("User")
|
|
person: Mapped[Optional["Person"]] = relationship("Person", back_populates="memberships")
|
|
|
|
class OrganizationSalesAssignment(Base):
|
|
__tablename__ = "org_sales_assignments"
|
|
__table_args__ = {"schema": "fleet"}
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
organization_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("fleet.organizations.id"))
|
|
agent_user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
|
|
|
assigned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
|
|
class Branch(Base):
|
|
"""
|
|
Telephely entitás. A fizikai helyszín, ahol a szolgáltatás vagy flotta-kezelés zajlik.
|
|
"""
|
|
__tablename__ = "branches"
|
|
__table_args__ = {"schema": "fleet"}
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("fleet.organizations.id"), nullable=False)
|
|
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
|
|
|
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
is_main: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
|
|
# Denormalizált adatok a gyors lekérdezéshez
|
|
postal_code: Mapped[Optional[str]] = mapped_column(String(10), index=True)
|
|
city: Mapped[Optional[str]] = mapped_column(String(100), index=True)
|
|
street_name: Mapped[Optional[str]] = mapped_column(String(150))
|
|
street_type: Mapped[Optional[str]] = mapped_column(String(50))
|
|
house_number: Mapped[Optional[str]] = mapped_column(String(20))
|
|
stairwell: Mapped[Optional[str]] = mapped_column(String(20))
|
|
floor: Mapped[Optional[str]] = mapped_column(String(20))
|
|
door: Mapped[Optional[str]] = mapped_column(String(20))
|
|
hrsz: Mapped[Optional[str]] = mapped_column(String(50))
|
|
|
|
# PostGIS location field for geographic queries
|
|
location: Mapped[Optional[Any]] = mapped_column(
|
|
Geometry(geometry_type='POINT', srid=4326),
|
|
nullable=True
|
|
)
|
|
|
|
opening_hours: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
|
branch_rating: Mapped[float] = mapped_column(Float, default=0.0)
|
|
|
|
status: Mapped[str] = mapped_column(String(30), default="active")
|
|
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
# Kapcsolatok
|
|
organization: Mapped["Organization"] = relationship("Organization", back_populates="branches")
|
|
address: Mapped[Optional["Address"]] = relationship("Address")
|
|
|
|
# Kapcsolatok (Primaryjoin tartva a rating rendszerhez)
|
|
reviews: Mapped[List["Rating"]] = relationship(
|
|
"Rating",
|
|
primaryjoin="and_(Branch.id==foreign(Rating.target_branch_id))"
|
|
)
|
|
|
|
# Kapcsolat a ServiceRequest modellel
|
|
service_requests: Mapped[List["ServiceRequest"]] = relationship(
|
|
"ServiceRequest",
|
|
back_populates="branch",
|
|
cascade="all, delete-orphan"
|
|
) |