Compare commits
No commits in common. "d579d0b142ab50f0681460e6bbdb7bcc439bc1da" and "579e0cdaf5082c2fb67e8b5c071bb81a9318ecf1" have entirely different histories.
d579d0b142
...
579e0cdaf5
@ -1,22 +0,0 @@
|
||||
# Dev image: dependencies only — code mounted with uvicorn --reload
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/shared-lib/ /shared-lib/
|
||||
COPY backend/services/beauty_business/requirements.txt /app/requirements.txt
|
||||
|
||||
RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' /app/requirements.txt \
|
||||
&& pip install --upgrade pip \
|
||||
&& pip install -r requirements.txt
|
||||
|
||||
EXPOSE 8011
|
||||
@ -1,67 +0,0 @@
|
||||
# Beauty Business Platform (Torbat Beauty)
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Service | `beauty-business-service` |
|
||||
| Database | `beauty_business_db` |
|
||||
| Port | 8011 |
|
||||
| Permission prefix | `beauty_business.*` |
|
||||
| Version | 0.14.7.0 |
|
||||
| Phase | 14.7 Marketing & Loyalty |
|
||||
|
||||
## Owns
|
||||
|
||||
- Beauty organizations, branches, configuration/settings shells
|
||||
- External provider / booking-engine **registrations** (adapters later)
|
||||
- Booking: schedules, availability, appointments, waiting list
|
||||
- Salon: treatment rooms, stations, branch policies
|
||||
- Service catalog, customers, packages, memberships
|
||||
- Staff profiles, commission rules
|
||||
- Marketing campaign refs and Loyalty/Communication integration configs
|
||||
- Beauty audit log + transactional outbox
|
||||
- Health / capabilities / metrics discovery
|
||||
- Publish-only `beauty_business.*` events
|
||||
|
||||
## Does not own
|
||||
|
||||
- Accounting journals / Posting Engine
|
||||
- Communication SMS/email providers or message delivery timeline
|
||||
- Loyalty ledger / campaigns engine
|
||||
- Healthcare clinical entities
|
||||
- Experience sites/pages (refs only)
|
||||
- Delivery fleet / dispatch
|
||||
- Identity user admin / CRM contact master / Storage blobs
|
||||
- Frontend UI (separate track)
|
||||
|
||||
## APIs
|
||||
|
||||
| Method | Path |
|
||||
| --- | --- |
|
||||
| GET | `/health` |
|
||||
| GET | `/capabilities` |
|
||||
| GET | `/metrics` |
|
||||
| CRUD | `/api/v1/organizations` |
|
||||
| CRUD | `/api/v1/branches` |
|
||||
| CRUD | `/api/v1/external-providers` |
|
||||
| CRUD | `/api/v1/booking-engines` |
|
||||
| CRUD | `/api/v1/configurations` |
|
||||
| PUT/GET | `/api/v1/settings` |
|
||||
| GET | `/api/v1/audit` |
|
||||
| CRUD + lifecycle | `/api/v1/appointments` |
|
||||
| CRUD | `/api/v1/staff-schedules`, `/api/v1/services`, `/api/v1/customers`, … |
|
||||
| GET | `/api/v1/permissions/catalog` |
|
||||
|
||||
## Run (dev)
|
||||
|
||||
```bash
|
||||
cd backend/services/beauty_business
|
||||
python scripts/ensure_db.py
|
||||
alembic upgrade head
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8011 --reload
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
ENVIRONMENT=test AUTH_REQUIRED=false JWT_VERIFY_SIGNATURE=false pytest -q
|
||||
```
|
||||
@ -1,41 +0,0 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
version_path_separator = os
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@ -1,42 +0,0 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url_sync)
|
||||
if config.config_file_name:
|
||||
fileConfig(config.config_file_name)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
context.configure(
|
||||
url=settings.database_url_sync,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@ -1,19 +0,0 @@
|
||||
"""Initial Beauty Business schema — Phase 14.0 foundation."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0001_initial"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.drop_all(bind=bind)
|
||||
@ -1,18 +0,0 @@
|
||||
"""Phase 14.1 — Booking schema (additive)."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0002_phase_141_booking"
|
||||
down_revision = "0001_initial"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@ -1,18 +0,0 @@
|
||||
"""Phase 14.2 — Salon Management schema (additive)."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0003_phase_142_salon"
|
||||
down_revision = "0002_phase_141_booking"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@ -1,18 +0,0 @@
|
||||
"""Phase 14.3 — Service Catalog schema (additive)."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0004_phase_143_catalog"
|
||||
down_revision = "0003_phase_142_salon"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@ -1,18 +0,0 @@
|
||||
"""Phase 14.4 — Customer Management schema (additive)."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0005_phase_144_customers"
|
||||
down_revision = "0004_phase_143_catalog"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@ -1,18 +0,0 @@
|
||||
"""Phase 14.5 — Packages & Memberships schema (additive)."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0006_phase_145_packages"
|
||||
down_revision = "0005_phase_144_customers"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@ -1,18 +0,0 @@
|
||||
"""Phase 14.6 — Staff & Commission schema (additive)."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0007_phase_146_staff"
|
||||
down_revision = "0006_phase_145_packages"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@ -1,18 +0,0 @@
|
||||
"""Phase 14.7 — Marketing & Integrations schema (additive)."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0008_phase_147_marketing"
|
||||
down_revision = "0007_phase_146_staff"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@ -1 +0,0 @@
|
||||
__version__ = "0.14.7.0"
|
||||
@ -1,39 +0,0 @@
|
||||
"""Common API dependencies."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from shared.exceptions import TenantNotResolvedError
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
from shared.tenant import STATE_TENANT_ID
|
||||
|
||||
__all__ = [
|
||||
"get_db",
|
||||
"get_pagination",
|
||||
"require_tenant",
|
||||
"get_current_user",
|
||||
"AsyncSession",
|
||||
"CurrentUser",
|
||||
]
|
||||
|
||||
|
||||
def get_pagination(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=500),
|
||||
) -> PaginationParams:
|
||||
return PaginationParams(page=page, page_size=page_size)
|
||||
|
||||
|
||||
def require_tenant(request: Request) -> UUID:
|
||||
tenant_id = getattr(request.state, STATE_TENANT_ID, None)
|
||||
if tenant_id is None:
|
||||
raise TenantNotResolvedError(
|
||||
"tenant قابل تشخیص نبود. هدر X-Tenant-ID را ارسال کنید."
|
||||
)
|
||||
return tenant_id
|
||||
@ -1,48 +0,0 @@
|
||||
"""Permission enforcement helpers for Delivery APIs."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_current_user
|
||||
from shared.exceptions import ForbiddenError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
_ADMIN_ROLES = frozenset({"platform_admin", "tenant_owner", "tenant_admin"})
|
||||
|
||||
|
||||
def user_has_permission(user: CurrentUser, permission: str) -> bool:
|
||||
if any(role in _ADMIN_ROLES for role in user.roles):
|
||||
return True
|
||||
roles = set(user.roles)
|
||||
if "beauty_business.manage" in roles or permission in roles:
|
||||
return True
|
||||
if "beauty_business.view" in roles and permission.endswith(".view"):
|
||||
return True
|
||||
parts = permission.split(".")
|
||||
if len(parts) >= 3:
|
||||
manage = f"{parts[0]}.{parts[1]}.manage"
|
||||
if manage in roles:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def require_permissions(*permissions: str) -> Callable:
|
||||
"""Deny unless AUTH is off, user is admin, or any listed permission is present."""
|
||||
|
||||
async def _dependency(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
if not settings.auth_required:
|
||||
return user
|
||||
if any(user_has_permission(user, perm) for perm in permissions):
|
||||
return user
|
||||
raise ForbiddenError(
|
||||
"دسترسی مجاز نیست",
|
||||
error_code="permission_denied",
|
||||
details={"required": list(permissions)},
|
||||
)
|
||||
|
||||
return _dependency
|
||||
@ -1,118 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import (
|
||||
appointment_core,
|
||||
appointments,
|
||||
audit,
|
||||
booking_engines,
|
||||
branches,
|
||||
catalog,
|
||||
configurations,
|
||||
customers,
|
||||
external_providers,
|
||||
marketing,
|
||||
organizations,
|
||||
packages,
|
||||
permissions,
|
||||
salon,
|
||||
settings,
|
||||
staff,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(
|
||||
organizations.router, prefix="/organizations", tags=["organizations"]
|
||||
)
|
||||
api_router.include_router(branches.router, prefix="/branches", tags=["branches"])
|
||||
api_router.include_router(
|
||||
external_providers.router,
|
||||
prefix="/external-providers",
|
||||
tags=["external-providers"],
|
||||
)
|
||||
api_router.include_router(
|
||||
booking_engines.router, prefix="/booking-engines", tags=["booking-engines"]
|
||||
)
|
||||
api_router.include_router(
|
||||
configurations.router, prefix="/configurations", tags=["configurations"]
|
||||
)
|
||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
|
||||
api_router.include_router(
|
||||
permissions.router, prefix="/permissions", tags=["permissions"]
|
||||
)
|
||||
api_router.include_router(
|
||||
appointments.staffSchedule_router, prefix="/staff-schedules", tags=["staff-schedules"]
|
||||
)
|
||||
api_router.include_router(
|
||||
appointments.staffAvailability_router,
|
||||
prefix="/staff-availabilities",
|
||||
tags=["staff-availabilities"],
|
||||
)
|
||||
api_router.include_router(
|
||||
appointments.scheduleException_router,
|
||||
prefix="/schedule-exceptions",
|
||||
tags=["schedule-exceptions"],
|
||||
)
|
||||
api_router.include_router(
|
||||
appointments.appointmentType_router,
|
||||
prefix="/appointment-types",
|
||||
tags=["appointment-types"],
|
||||
)
|
||||
api_router.include_router(
|
||||
appointment_core.router, prefix="/appointments", tags=["appointments"]
|
||||
)
|
||||
api_router.include_router(
|
||||
appointments.waitingListEntry_router,
|
||||
prefix="/waiting-list",
|
||||
tags=["waiting-list"],
|
||||
)
|
||||
api_router.include_router(
|
||||
salon.treatmentRoom_router, prefix="/treatment-rooms", tags=["treatment-rooms"]
|
||||
)
|
||||
api_router.include_router(salon.station_router, prefix="/stations", tags=["stations"])
|
||||
api_router.include_router(
|
||||
salon.branchPolicy_router, prefix="/branch-policies", tags=["branch-policies"]
|
||||
)
|
||||
api_router.include_router(
|
||||
catalog.serviceCategory_router,
|
||||
prefix="/service-categories",
|
||||
tags=["service-categories"],
|
||||
)
|
||||
api_router.include_router(catalog.beautyService_router, prefix="/services", tags=["services"])
|
||||
api_router.include_router(
|
||||
customers.customerProfile_router, prefix="/customers", tags=["customers"]
|
||||
)
|
||||
api_router.include_router(
|
||||
packages.packageDefinition_router,
|
||||
prefix="/package-definitions",
|
||||
tags=["package-definitions"],
|
||||
)
|
||||
api_router.include_router(
|
||||
packages.membershipPlan_router,
|
||||
prefix="/membership-plans",
|
||||
tags=["membership-plans"],
|
||||
)
|
||||
api_router.include_router(staff.staffProfile_router, prefix="/staff", tags=["staff"])
|
||||
api_router.include_router(
|
||||
staff.commissionRule_router, prefix="/commission-rules", tags=["commission-rules"]
|
||||
)
|
||||
api_router.include_router(
|
||||
marketing.marketingCampaignRef_router,
|
||||
prefix="/marketing-campaigns",
|
||||
tags=["marketing-campaigns"],
|
||||
)
|
||||
api_router.include_router(
|
||||
marketing.loyaltyIntegrationConfig_router,
|
||||
prefix="/loyalty-integrations",
|
||||
tags=["loyalty-integrations"],
|
||||
)
|
||||
api_router.include_router(
|
||||
marketing.communicationIntegrationConfig_router,
|
||||
prefix="/communication-integrations",
|
||||
tags=["communication-integrations"],
|
||||
)
|
||||
api_router.include_router(
|
||||
marketing.integrationDispatch_router,
|
||||
prefix="/integration-dispatches",
|
||||
tags=["integration-dispatches"],
|
||||
)
|
||||
@ -1,70 +0,0 @@
|
||||
"""Appointment lifecycle APIs — Phase 14.1."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.appointments import AppointmentCommands
|
||||
from app.permissions.definitions import (
|
||||
APPOINTMENTS_CANCEL,
|
||||
APPOINTMENTS_CHECK_IN,
|
||||
APPOINTMENTS_COMPLETE,
|
||||
APPOINTMENTS_CONFIRM,
|
||||
APPOINTMENTS_CREATE,
|
||||
APPOINTMENTS_NO_SHOW,
|
||||
APPOINTMENTS_UPDATE,
|
||||
APPOINTMENTS_VIEW,
|
||||
)
|
||||
from app.queries.appointments import AppointmentQueries
|
||||
from app.schemas.appointment_core import (
|
||||
AppointmentCreate,
|
||||
AppointmentLifecycleRequest,
|
||||
AppointmentRead,
|
||||
AppointmentStatusHistoryRead,
|
||||
AppointmentUpdate,
|
||||
)
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("", response_model=AppointmentRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_appointment(body: AppointmentCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(APPOINTMENTS_CREATE))):
|
||||
return await AppointmentCommands(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@router.get("", response_model=list[AppointmentRead])
|
||||
async def list_appointments(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW))):
|
||||
return await AppointmentQueries(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@router.get("/{appointment_id}", response_model=AppointmentRead)
|
||||
async def get_appointment(appointment_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW))):
|
||||
return await AppointmentQueries(db).get(tenant_id, appointment_id)
|
||||
|
||||
@router.patch("/{appointment_id}", response_model=AppointmentRead)
|
||||
async def update_appointment(appointment_id: UUID, body: AppointmentUpdate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(APPOINTMENTS_UPDATE))):
|
||||
return await AppointmentCommands(db).update(tenant_id, appointment_id, body, actor=user)
|
||||
|
||||
@router.post("/{appointment_id}/confirm", response_model=AppointmentRead)
|
||||
async def confirm_appointment(appointment_id: UUID, body: AppointmentLifecycleRequest, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(APPOINTMENTS_CONFIRM))):
|
||||
return await AppointmentCommands(db).confirm(tenant_id, appointment_id, body, actor=user)
|
||||
|
||||
@router.post("/{appointment_id}/check-in", response_model=AppointmentRead)
|
||||
async def check_in_appointment(appointment_id: UUID, body: AppointmentLifecycleRequest, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(APPOINTMENTS_CHECK_IN))):
|
||||
return await AppointmentCommands(db).check_in(tenant_id, appointment_id, body, actor=user)
|
||||
|
||||
@router.post("/{appointment_id}/complete", response_model=AppointmentRead)
|
||||
async def complete_appointment(appointment_id: UUID, body: AppointmentLifecycleRequest, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(APPOINTMENTS_COMPLETE))):
|
||||
return await AppointmentCommands(db).complete(tenant_id, appointment_id, body, actor=user)
|
||||
|
||||
@router.post("/{appointment_id}/cancel", response_model=AppointmentRead)
|
||||
async def cancel_appointment(appointment_id: UUID, body: AppointmentLifecycleRequest, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(APPOINTMENTS_CANCEL))):
|
||||
return await AppointmentCommands(db).cancel(tenant_id, appointment_id, body, actor=user)
|
||||
|
||||
@router.post("/{appointment_id}/no-show", response_model=AppointmentRead)
|
||||
async def no_show_appointment(appointment_id: UUID, body: AppointmentLifecycleRequest, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(APPOINTMENTS_NO_SHOW))):
|
||||
return await AppointmentCommands(db).no_show(tenant_id, appointment_id, body, actor=user)
|
||||
|
||||
@router.get("/{appointment_id}/history", response_model=list[AppointmentStatusHistoryRead])
|
||||
async def list_appointment_history(appointment_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW))):
|
||||
return await AppointmentQueries(db).list_history(tenant_id, appointment_id)
|
||||
@ -1,98 +0,0 @@
|
||||
"""API routes."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
from app.permissions.definitions import APPOINTMENTS_CREATE, APPOINTMENTS_VIEW
|
||||
from app.permissions.definitions import SCHEDULES_CREATE, SCHEDULES_VIEW
|
||||
from app.permissions.definitions import WAITING_LIST_CREATE, WAITING_LIST_VIEW
|
||||
from app.schemas.appointments import AppointmentTypeCreate, AppointmentTypeRead
|
||||
from app.schemas.appointments import ScheduleExceptionCreate, ScheduleExceptionRead
|
||||
from app.schemas.appointments import StaffAvailabilityCreate, StaffAvailabilityRead
|
||||
from app.schemas.appointments import StaffScheduleCreate, StaffScheduleRead
|
||||
from app.schemas.appointments import WaitingListEntryCreate, WaitingListEntryRead
|
||||
from app.services.appointments import AppointmentTypeService
|
||||
from app.services.appointments import ScheduleExceptionService
|
||||
from app.services.appointments import StaffAvailabilityService
|
||||
from app.services.appointments import StaffScheduleService
|
||||
from app.services.appointments import WaitingListEntryService
|
||||
|
||||
|
||||
staffSchedule_router = APIRouter()
|
||||
|
||||
@staffSchedule_router.post("", response_model=StaffScheduleRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_staffSchedule(body: StaffScheduleCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(SCHEDULES_CREATE))):
|
||||
return await StaffScheduleService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@staffSchedule_router.get("", response_model=list[StaffScheduleRead])
|
||||
async def list_staffSchedule(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SCHEDULES_VIEW))):
|
||||
return await StaffScheduleService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@staffSchedule_router.get("/{entity_id}", response_model=StaffScheduleRead)
|
||||
async def get_staffSchedule(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SCHEDULES_VIEW))):
|
||||
return await StaffScheduleService(db).get(tenant_id, entity_id)
|
||||
|
||||
|
||||
staffAvailability_router = APIRouter()
|
||||
|
||||
@staffAvailability_router.post("", response_model=StaffAvailabilityRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_staffAvailability(body: StaffAvailabilityCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(SCHEDULES_CREATE))):
|
||||
return await StaffAvailabilityService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@staffAvailability_router.get("", response_model=list[StaffAvailabilityRead])
|
||||
async def list_staffAvailability(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SCHEDULES_VIEW))):
|
||||
return await StaffAvailabilityService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@staffAvailability_router.get("/{entity_id}", response_model=StaffAvailabilityRead)
|
||||
async def get_staffAvailability(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SCHEDULES_VIEW))):
|
||||
return await StaffAvailabilityService(db).get(tenant_id, entity_id)
|
||||
|
||||
|
||||
scheduleException_router = APIRouter()
|
||||
|
||||
@scheduleException_router.post("", response_model=ScheduleExceptionRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_scheduleException(body: ScheduleExceptionCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(SCHEDULES_CREATE))):
|
||||
return await ScheduleExceptionService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@scheduleException_router.get("", response_model=list[ScheduleExceptionRead])
|
||||
async def list_scheduleException(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SCHEDULES_VIEW))):
|
||||
return await ScheduleExceptionService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@scheduleException_router.get("/{entity_id}", response_model=ScheduleExceptionRead)
|
||||
async def get_scheduleException(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SCHEDULES_VIEW))):
|
||||
return await ScheduleExceptionService(db).get(tenant_id, entity_id)
|
||||
|
||||
|
||||
appointmentType_router = APIRouter()
|
||||
|
||||
@appointmentType_router.post("", response_model=AppointmentTypeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_appointmentType(body: AppointmentTypeCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(APPOINTMENTS_CREATE))):
|
||||
return await AppointmentTypeService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@appointmentType_router.get("", response_model=list[AppointmentTypeRead])
|
||||
async def list_appointmentType(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW))):
|
||||
return await AppointmentTypeService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@appointmentType_router.get("/{entity_id}", response_model=AppointmentTypeRead)
|
||||
async def get_appointmentType(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW))):
|
||||
return await AppointmentTypeService(db).get(tenant_id, entity_id)
|
||||
|
||||
|
||||
waitingListEntry_router = APIRouter()
|
||||
|
||||
@waitingListEntry_router.post("", response_model=WaitingListEntryRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_waitingListEntry(body: WaitingListEntryCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(WAITING_LIST_CREATE))):
|
||||
return await WaitingListEntryService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@waitingListEntry_router.get("", response_model=list[WaitingListEntryRead])
|
||||
async def list_waitingListEntry(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(WAITING_LIST_VIEW))):
|
||||
return await WaitingListEntryService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@waitingListEntry_router.get("/{entity_id}", response_model=WaitingListEntryRead)
|
||||
async def get_waitingListEntry(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(WAITING_LIST_VIEW))):
|
||||
return await WaitingListEntryService(db).get(tenant_id, entity_id)
|
||||
@ -1,30 +0,0 @@
|
||||
"""Delivery audit APIs — Phase 10.0."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import AUDIT_VIEW
|
||||
from app.schemas.foundation import BeautyAuditLogRead
|
||||
from app.services.audit_service import AuditService
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[BeautyAuditLogRead])
|
||||
async def list_audit(
|
||||
entity_type: str = Query(...),
|
||||
entity_id: UUID = Query(...),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(AUDIT_VIEW)),
|
||||
):
|
||||
return await AuditService(db).list_for_entity(
|
||||
tenant_id, entity_type, entity_id, limit=limit
|
||||
)
|
||||
@ -1,83 +0,0 @@
|
||||
"""Booking engine registration APIs — Phase 14.0."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import (
|
||||
BOOKING_ENGINES_CREATE,
|
||||
BOOKING_ENGINES_DELETE,
|
||||
BOOKING_ENGINES_UPDATE,
|
||||
BOOKING_ENGINES_VIEW,
|
||||
)
|
||||
from app.schemas.foundation import (
|
||||
BookingEngineRegistrationCreate,
|
||||
BookingEngineRegistrationRead,
|
||||
BookingEngineRegistrationUpdate,
|
||||
)
|
||||
from app.services.foundation import BookingEngineRegistrationService
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=BookingEngineRegistrationRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_engine(
|
||||
body: BookingEngineRegistrationCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(BOOKING_ENGINES_CREATE)),
|
||||
):
|
||||
return await BookingEngineRegistrationService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=list[BookingEngineRegistrationRead])
|
||||
async def list_engines(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(BOOKING_ENGINES_VIEW)),
|
||||
):
|
||||
return await BookingEngineRegistrationService(db).list(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{engine_id}", response_model=BookingEngineRegistrationRead)
|
||||
async def get_engine(
|
||||
engine_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(BOOKING_ENGINES_VIEW)),
|
||||
):
|
||||
return await BookingEngineRegistrationService(db).get(tenant_id, engine_id)
|
||||
|
||||
|
||||
@router.patch("/{engine_id}", response_model=BookingEngineRegistrationRead)
|
||||
async def update_engine(
|
||||
engine_id: UUID,
|
||||
body: BookingEngineRegistrationUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(BOOKING_ENGINES_UPDATE)),
|
||||
):
|
||||
return await BookingEngineRegistrationService(db).update(
|
||||
tenant_id, engine_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{engine_id}/delete", response_model=BookingEngineRegistrationRead)
|
||||
async def soft_delete_engine(
|
||||
engine_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(BOOKING_ENGINES_DELETE)),
|
||||
):
|
||||
return await BookingEngineRegistrationService(db).soft_delete(
|
||||
tenant_id, engine_id, actor=user
|
||||
)
|
||||
@ -1,70 +0,0 @@
|
||||
"""Beauty branch APIs — Phase 14.0."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import BRANCHES_CREATE, BRANCHES_DELETE, BRANCHES_UPDATE, BRANCHES_VIEW
|
||||
from app.schemas.foundation import BeautyBranchCreate, BeautyBranchRead, BeautyBranchUpdate
|
||||
from app.services.foundation import BeautyBranchService
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=BeautyBranchRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_hub(
|
||||
body: BeautyBranchCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(BRANCHES_CREATE)),
|
||||
):
|
||||
return await BeautyBranchService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=list[BeautyBranchRead])
|
||||
async def list_branches(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(BRANCHES_VIEW)),
|
||||
):
|
||||
return await BeautyBranchService(db).list(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{branch_id}", response_model=BeautyBranchRead)
|
||||
async def get_hub(
|
||||
branch_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(BRANCHES_VIEW)),
|
||||
):
|
||||
return await BeautyBranchService(db).get(tenant_id, branch_id)
|
||||
|
||||
|
||||
@router.patch("/{branch_id}", response_model=BeautyBranchRead)
|
||||
async def update_hub(
|
||||
branch_id: UUID,
|
||||
body: BeautyBranchUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(BRANCHES_UPDATE)),
|
||||
):
|
||||
return await BeautyBranchService(db).update(tenant_id, branch_id, body, actor=user)
|
||||
|
||||
|
||||
@router.post("/{branch_id}/delete", response_model=BeautyBranchRead)
|
||||
async def soft_delete_hub(
|
||||
branch_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(BRANCHES_DELETE)),
|
||||
):
|
||||
return await BeautyBranchService(db).soft_delete(tenant_id, branch_id, actor=user)
|
||||
@ -1,46 +0,0 @@
|
||||
"""API routes."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
from app.permissions.definitions import SERVICES_CREATE, SERVICES_VIEW
|
||||
from app.permissions.definitions import SERVICE_CATEGORIES_CREATE, SERVICE_CATEGORIES_VIEW
|
||||
from app.schemas.catalog import BeautyServiceCreate, BeautyServiceRead
|
||||
from app.schemas.catalog import ServiceCategoryCreate, ServiceCategoryRead
|
||||
from app.services.catalog import BeautyServiceService
|
||||
from app.services.catalog import ServiceCategoryService
|
||||
|
||||
|
||||
serviceCategory_router = APIRouter()
|
||||
|
||||
@serviceCategory_router.post("", response_model=ServiceCategoryRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_serviceCategory(body: ServiceCategoryCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(SERVICE_CATEGORIES_CREATE))):
|
||||
return await ServiceCategoryService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@serviceCategory_router.get("", response_model=list[ServiceCategoryRead])
|
||||
async def list_serviceCategory(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SERVICE_CATEGORIES_VIEW))):
|
||||
return await ServiceCategoryService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@serviceCategory_router.get("/{entity_id}", response_model=ServiceCategoryRead)
|
||||
async def get_serviceCategory(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SERVICE_CATEGORIES_VIEW))):
|
||||
return await ServiceCategoryService(db).get(tenant_id, entity_id)
|
||||
|
||||
|
||||
beautyService_router = APIRouter()
|
||||
|
||||
@beautyService_router.post("", response_model=BeautyServiceRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_beautyService(body: BeautyServiceCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(SERVICES_CREATE))):
|
||||
return await BeautyServiceService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@beautyService_router.get("", response_model=list[BeautyServiceRead])
|
||||
async def list_beautyService(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SERVICES_VIEW))):
|
||||
return await BeautyServiceService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@beautyService_router.get("/{entity_id}", response_model=BeautyServiceRead)
|
||||
async def get_beautyService(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SERVICES_VIEW))):
|
||||
return await BeautyServiceService(db).get(tenant_id, entity_id)
|
||||
@ -1,83 +0,0 @@
|
||||
"""Delivery configuration APIs — Phase 10.0."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import (
|
||||
CONFIGURATIONS_CREATE,
|
||||
CONFIGURATIONS_DELETE,
|
||||
CONFIGURATIONS_UPDATE,
|
||||
CONFIGURATIONS_VIEW,
|
||||
)
|
||||
from app.schemas.foundation import (
|
||||
BeautyConfigurationCreate,
|
||||
BeautyConfigurationRead,
|
||||
BeautyConfigurationUpdate,
|
||||
)
|
||||
from app.services.foundation import BeautyConfigurationService
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=BeautyConfigurationRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_configuration(
|
||||
body: BeautyConfigurationCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_CREATE)),
|
||||
):
|
||||
return await BeautyConfigurationService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=list[BeautyConfigurationRead])
|
||||
async def list_configurations(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)),
|
||||
):
|
||||
return await BeautyConfigurationService(db).list(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{configuration_id}", response_model=BeautyConfigurationRead)
|
||||
async def get_configuration(
|
||||
configuration_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)),
|
||||
):
|
||||
return await BeautyConfigurationService(db).get(tenant_id, configuration_id)
|
||||
|
||||
|
||||
@router.patch("/{configuration_id}", response_model=BeautyConfigurationRead)
|
||||
async def update_configuration(
|
||||
configuration_id: UUID,
|
||||
body: BeautyConfigurationUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_UPDATE)),
|
||||
):
|
||||
return await BeautyConfigurationService(db).update(
|
||||
tenant_id, configuration_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{configuration_id}/delete", response_model=BeautyConfigurationRead)
|
||||
async def soft_delete_configuration(
|
||||
configuration_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_DELETE)),
|
||||
):
|
||||
return await BeautyConfigurationService(db).soft_delete(
|
||||
tenant_id, configuration_id, actor=user
|
||||
)
|
||||
@ -1,28 +0,0 @@
|
||||
"""API routes."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
from app.permissions.definitions import CUSTOMERS_CREATE, CUSTOMERS_VIEW
|
||||
from app.schemas.customers import CustomerProfileCreate, CustomerProfileRead
|
||||
from app.services.customers import CustomerProfileService
|
||||
|
||||
|
||||
customerProfile_router = APIRouter()
|
||||
|
||||
@customerProfile_router.post("", response_model=CustomerProfileRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_customerProfile(body: CustomerProfileCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(CUSTOMERS_CREATE))):
|
||||
return await CustomerProfileService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@customerProfile_router.get("", response_model=list[CustomerProfileRead])
|
||||
async def list_customerProfile(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(CUSTOMERS_VIEW))):
|
||||
return await CustomerProfileService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@customerProfile_router.get("/{entity_id}", response_model=CustomerProfileRead)
|
||||
async def get_customerProfile(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(CUSTOMERS_VIEW))):
|
||||
return await CustomerProfileService(db).get(tenant_id, entity_id)
|
||||
@ -1,83 +0,0 @@
|
||||
"""External provider config APIs — Phase 10.0."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import (
|
||||
EXTERNAL_PROVIDERS_CREATE,
|
||||
EXTERNAL_PROVIDERS_DELETE,
|
||||
EXTERNAL_PROVIDERS_UPDATE,
|
||||
EXTERNAL_PROVIDERS_VIEW,
|
||||
)
|
||||
from app.schemas.foundation import (
|
||||
ExternalProviderConfigCreate,
|
||||
ExternalProviderConfigRead,
|
||||
ExternalProviderConfigUpdate,
|
||||
)
|
||||
from app.services.foundation import ExternalProviderConfigService
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=ExternalProviderConfigRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_provider(
|
||||
body: ExternalProviderConfigCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_CREATE)),
|
||||
):
|
||||
return await ExternalProviderConfigService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ExternalProviderConfigRead])
|
||||
async def list_providers(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)),
|
||||
):
|
||||
return await ExternalProviderConfigService(db).list(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{provider_id}", response_model=ExternalProviderConfigRead)
|
||||
async def get_provider(
|
||||
provider_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)),
|
||||
):
|
||||
return await ExternalProviderConfigService(db).get(tenant_id, provider_id)
|
||||
|
||||
|
||||
@router.patch("/{provider_id}", response_model=ExternalProviderConfigRead)
|
||||
async def update_provider(
|
||||
provider_id: UUID,
|
||||
body: ExternalProviderConfigUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_UPDATE)),
|
||||
):
|
||||
return await ExternalProviderConfigService(db).update(
|
||||
tenant_id, provider_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{provider_id}/delete", response_model=ExternalProviderConfigRead)
|
||||
async def soft_delete_provider(
|
||||
provider_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_DELETE)),
|
||||
):
|
||||
return await ExternalProviderConfigService(db).soft_delete(
|
||||
tenant_id, provider_id, actor=user
|
||||
)
|
||||
@ -1,102 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app import __version__
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": settings.service_name,
|
||||
"version": __version__,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/capabilities")
|
||||
async def capabilities():
|
||||
return {
|
||||
"service": settings.service_name,
|
||||
"version": __version__,
|
||||
"phase": "14.7",
|
||||
"commercial_product": "Torbat Beauty",
|
||||
"features": {
|
||||
"foundation": True,
|
||||
"health": True,
|
||||
"capabilities": True,
|
||||
"metrics": True,
|
||||
"external_providers_ready": True,
|
||||
"future_booking_engines_ready": True,
|
||||
"schedules": True,
|
||||
"appointments": True,
|
||||
"appointment_lifecycle": True,
|
||||
"waiting_list": True,
|
||||
"salon_management": True,
|
||||
"treatment_rooms": True,
|
||||
"stations": True,
|
||||
"branch_policies": True,
|
||||
"service_catalog": True,
|
||||
"service_categories": True,
|
||||
"services": True,
|
||||
"customers": True,
|
||||
"packages": True,
|
||||
"memberships": True,
|
||||
"staff": True,
|
||||
"commissions": True,
|
||||
"marketing": True,
|
||||
"loyalty_integrations": True,
|
||||
"communication_integrations": True,
|
||||
"integration_dispatches": True,
|
||||
"booking_engine": False,
|
||||
"ai": False,
|
||||
},
|
||||
"independence": {
|
||||
"standalone": True,
|
||||
"superapp": True,
|
||||
"integrations": [
|
||||
"accounting",
|
||||
"communication",
|
||||
"loyalty",
|
||||
"crm",
|
||||
"experience",
|
||||
"delivery",
|
||||
"storage",
|
||||
"identity",
|
||||
],
|
||||
"integration_mode": "api_and_events_only",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
async def metrics():
|
||||
return {
|
||||
"service": settings.service_name,
|
||||
"version": __version__,
|
||||
"phase": "14.7",
|
||||
"metrics": {
|
||||
"organizations": "tenant_scoped",
|
||||
"branches": "tenant_scoped",
|
||||
"external_providers": "tenant_scoped",
|
||||
"booking_engines": "tenant_scoped",
|
||||
"configurations": "tenant_scoped",
|
||||
"appointments": "tenant_scoped",
|
||||
"staff_schedules": "tenant_scoped",
|
||||
"waiting_list_entries": "tenant_scoped",
|
||||
"treatment_rooms": "tenant_scoped",
|
||||
"stations": "tenant_scoped",
|
||||
"service_categories": "tenant_scoped",
|
||||
"beauty_services": "tenant_scoped",
|
||||
"customer_profiles": "tenant_scoped",
|
||||
"package_definitions": "tenant_scoped",
|
||||
"membership_plans": "tenant_scoped",
|
||||
"staff_profiles": "tenant_scoped",
|
||||
"commission_rules": "tenant_scoped",
|
||||
"marketing_campaign_refs": "tenant_scoped",
|
||||
"integration_dispatches": "tenant_scoped",
|
||||
"outbox_events": "tenant_scoped",
|
||||
},
|
||||
"note": "Beauty Business aggregates through Phase 14.7",
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
"""API routes."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
from app.permissions.definitions import INTEGRATIONS_CREATE, INTEGRATIONS_VIEW
|
||||
from app.permissions.definitions import MARKETING_CREATE, MARKETING_VIEW
|
||||
from app.schemas.marketing import CommunicationIntegrationConfigCreate, CommunicationIntegrationConfigRead
|
||||
from app.schemas.marketing import LoyaltyIntegrationConfigCreate, LoyaltyIntegrationConfigRead
|
||||
from app.schemas.marketing import MarketingCampaignRefCreate, MarketingCampaignRefRead
|
||||
from app.services.marketing import CommunicationIntegrationConfigService
|
||||
from app.services.marketing import LoyaltyIntegrationConfigService
|
||||
from app.services.marketing import MarketingCampaignRefService
|
||||
|
||||
|
||||
marketingCampaignRef_router = APIRouter()
|
||||
|
||||
@marketingCampaignRef_router.post("", response_model=MarketingCampaignRefRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_marketingCampaignRef(body: MarketingCampaignRefCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(MARKETING_CREATE))):
|
||||
return await MarketingCampaignRefService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@marketingCampaignRef_router.get("", response_model=list[MarketingCampaignRefRead])
|
||||
async def list_marketingCampaignRef(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(MARKETING_VIEW))):
|
||||
return await MarketingCampaignRefService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@marketingCampaignRef_router.get("/{entity_id}", response_model=MarketingCampaignRefRead)
|
||||
async def get_marketingCampaignRef(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(MARKETING_VIEW))):
|
||||
return await MarketingCampaignRefService(db).get(tenant_id, entity_id)
|
||||
|
||||
|
||||
loyaltyIntegrationConfig_router = APIRouter()
|
||||
|
||||
@loyaltyIntegrationConfig_router.post("", response_model=LoyaltyIntegrationConfigRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_loyaltyIntegrationConfig(body: LoyaltyIntegrationConfigCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(INTEGRATIONS_CREATE))):
|
||||
return await LoyaltyIntegrationConfigService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@loyaltyIntegrationConfig_router.get("", response_model=list[LoyaltyIntegrationConfigRead])
|
||||
async def list_loyaltyIntegrationConfig(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(INTEGRATIONS_VIEW))):
|
||||
return await LoyaltyIntegrationConfigService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@loyaltyIntegrationConfig_router.get("/{entity_id}", response_model=LoyaltyIntegrationConfigRead)
|
||||
async def get_loyaltyIntegrationConfig(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(INTEGRATIONS_VIEW))):
|
||||
return await LoyaltyIntegrationConfigService(db).get(tenant_id, entity_id)
|
||||
|
||||
|
||||
communicationIntegrationConfig_router = APIRouter()
|
||||
|
||||
@communicationIntegrationConfig_router.post("", response_model=CommunicationIntegrationConfigRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_communicationIntegrationConfig(body: CommunicationIntegrationConfigCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(INTEGRATIONS_CREATE))):
|
||||
return await CommunicationIntegrationConfigService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@communicationIntegrationConfig_router.get("", response_model=list[CommunicationIntegrationConfigRead])
|
||||
async def list_communicationIntegrationConfig(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(INTEGRATIONS_VIEW))):
|
||||
return await CommunicationIntegrationConfigService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@communicationIntegrationConfig_router.get("/{entity_id}", response_model=CommunicationIntegrationConfigRead)
|
||||
async def get_communicationIntegrationConfig(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(INTEGRATIONS_VIEW))):
|
||||
return await CommunicationIntegrationConfigService(db).get(tenant_id, entity_id)
|
||||
|
||||
|
||||
from app.schemas.marketing import IntegrationDispatchCreate, IntegrationDispatchRead
|
||||
from app.services.marketing import IntegrationDispatchService
|
||||
|
||||
integrationDispatch_router = APIRouter()
|
||||
|
||||
|
||||
@integrationDispatch_router.post("", response_model=IntegrationDispatchRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_integration_dispatch(
|
||||
body: IntegrationDispatchCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(INTEGRATIONS_CREATE)),
|
||||
):
|
||||
return await IntegrationDispatchService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@integrationDispatch_router.get("", response_model=list[IntegrationDispatchRead])
|
||||
async def list_integration_dispatches(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(INTEGRATIONS_VIEW)),
|
||||
):
|
||||
return await IntegrationDispatchService(db).list(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@integrationDispatch_router.get("/{entity_id}", response_model=IntegrationDispatchRead)
|
||||
async def get_integration_dispatch(
|
||||
entity_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(INTEGRATIONS_VIEW)),
|
||||
):
|
||||
return await IntegrationDispatchService(db).get(tenant_id, entity_id)
|
||||
@ -1,83 +0,0 @@
|
||||
"""Delivery organization APIs — Phase 10.0."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import (
|
||||
ORGANIZATIONS_CREATE,
|
||||
ORGANIZATIONS_DELETE,
|
||||
ORGANIZATIONS_UPDATE,
|
||||
ORGANIZATIONS_VIEW,
|
||||
)
|
||||
from app.schemas.foundation import (
|
||||
BeautyOrganizationCreate,
|
||||
BeautyOrganizationRead,
|
||||
BeautyOrganizationUpdate,
|
||||
)
|
||||
from app.services.foundation import BeautyOrganizationService
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=BeautyOrganizationRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_organization(
|
||||
body: BeautyOrganizationCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_CREATE)),
|
||||
):
|
||||
return await BeautyOrganizationService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=list[BeautyOrganizationRead])
|
||||
async def list_organizations(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_VIEW)),
|
||||
):
|
||||
return await BeautyOrganizationService(db).list(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{organization_id}", response_model=BeautyOrganizationRead)
|
||||
async def get_organization(
|
||||
organization_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_VIEW)),
|
||||
):
|
||||
return await BeautyOrganizationService(db).get(tenant_id, organization_id)
|
||||
|
||||
|
||||
@router.patch("/{organization_id}", response_model=BeautyOrganizationRead)
|
||||
async def update_organization(
|
||||
organization_id: UUID,
|
||||
body: BeautyOrganizationUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_UPDATE)),
|
||||
):
|
||||
return await BeautyOrganizationService(db).update(
|
||||
tenant_id, organization_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{organization_id}/delete", response_model=BeautyOrganizationRead)
|
||||
async def soft_delete_organization(
|
||||
organization_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_DELETE)),
|
||||
):
|
||||
return await BeautyOrganizationService(db).soft_delete(
|
||||
tenant_id, organization_id, actor=user
|
||||
)
|
||||
@ -1,46 +0,0 @@
|
||||
"""API routes."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
from app.permissions.definitions import MEMBERSHIPS_CREATE, MEMBERSHIPS_VIEW
|
||||
from app.permissions.definitions import PACKAGES_CREATE, PACKAGES_VIEW
|
||||
from app.schemas.packages import MembershipPlanCreate, MembershipPlanRead
|
||||
from app.schemas.packages import PackageDefinitionCreate, PackageDefinitionRead
|
||||
from app.services.packages import MembershipPlanService
|
||||
from app.services.packages import PackageDefinitionService
|
||||
|
||||
|
||||
packageDefinition_router = APIRouter()
|
||||
|
||||
@packageDefinition_router.post("", response_model=PackageDefinitionRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_packageDefinition(body: PackageDefinitionCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(PACKAGES_CREATE))):
|
||||
return await PackageDefinitionService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@packageDefinition_router.get("", response_model=list[PackageDefinitionRead])
|
||||
async def list_packageDefinition(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(PACKAGES_VIEW))):
|
||||
return await PackageDefinitionService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@packageDefinition_router.get("/{entity_id}", response_model=PackageDefinitionRead)
|
||||
async def get_packageDefinition(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(PACKAGES_VIEW))):
|
||||
return await PackageDefinitionService(db).get(tenant_id, entity_id)
|
||||
|
||||
|
||||
membershipPlan_router = APIRouter()
|
||||
|
||||
@membershipPlan_router.post("", response_model=MembershipPlanRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_membershipPlan(body: MembershipPlanCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(MEMBERSHIPS_CREATE))):
|
||||
return await MembershipPlanService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@membershipPlan_router.get("", response_model=list[MembershipPlanRead])
|
||||
async def list_membershipPlan(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(MEMBERSHIPS_VIEW))):
|
||||
return await MembershipPlanService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@membershipPlan_router.get("/{entity_id}", response_model=MembershipPlanRead)
|
||||
async def get_membershipPlan(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(MEMBERSHIPS_VIEW))):
|
||||
return await MembershipPlanService(db).get(tenant_id, entity_id)
|
||||
@ -1,27 +0,0 @@
|
||||
"""Permission catalog discovery API — Phase 10.1."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import (
|
||||
ALL_PERMISSIONS,
|
||||
BEAUTY_BUSINESS_VIEW,
|
||||
PERMISSION_PREFIXES,
|
||||
)
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/catalog")
|
||||
async def permission_catalog(
|
||||
_user: CurrentUser = Depends(require_permissions(BEAUTY_BUSINESS_VIEW)),
|
||||
):
|
||||
"""Static permission discovery for Identity / admin consoles."""
|
||||
return {
|
||||
"prefix": "beauty_business.",
|
||||
"prefixes": list(PERMISSION_PREFIXES),
|
||||
"permissions": list(ALL_PERMISSIONS),
|
||||
"count": len(ALL_PERMISSIONS),
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
"""API routes."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
from app.permissions.definitions import BRANCH_POLICIES_CREATE, BRANCH_POLICIES_VIEW
|
||||
from app.permissions.definitions import ROOMS_CREATE, ROOMS_VIEW
|
||||
from app.permissions.definitions import STATIONS_CREATE, STATIONS_VIEW
|
||||
from app.schemas.salon import BranchPolicyCreate, BranchPolicyRead
|
||||
from app.schemas.salon import StationCreate, StationRead
|
||||
from app.schemas.salon import TreatmentRoomCreate, TreatmentRoomRead
|
||||
from app.services.salon import BranchPolicyService
|
||||
from app.services.salon import StationService
|
||||
from app.services.salon import TreatmentRoomService
|
||||
|
||||
|
||||
treatmentRoom_router = APIRouter()
|
||||
|
||||
@treatmentRoom_router.post("", response_model=TreatmentRoomRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_treatmentRoom(body: TreatmentRoomCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(ROOMS_CREATE))):
|
||||
return await TreatmentRoomService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@treatmentRoom_router.get("", response_model=list[TreatmentRoomRead])
|
||||
async def list_treatmentRoom(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(ROOMS_VIEW))):
|
||||
return await TreatmentRoomService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@treatmentRoom_router.get("/{entity_id}", response_model=TreatmentRoomRead)
|
||||
async def get_treatmentRoom(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(ROOMS_VIEW))):
|
||||
return await TreatmentRoomService(db).get(tenant_id, entity_id)
|
||||
|
||||
|
||||
station_router = APIRouter()
|
||||
|
||||
@station_router.post("", response_model=StationRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_station(body: StationCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(STATIONS_CREATE))):
|
||||
return await StationService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@station_router.get("", response_model=list[StationRead])
|
||||
async def list_station(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(STATIONS_VIEW))):
|
||||
return await StationService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@station_router.get("/{entity_id}", response_model=StationRead)
|
||||
async def get_station(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(STATIONS_VIEW))):
|
||||
return await StationService(db).get(tenant_id, entity_id)
|
||||
|
||||
|
||||
branchPolicy_router = APIRouter()
|
||||
|
||||
@branchPolicy_router.post("", response_model=BranchPolicyRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_branchPolicy(body: BranchPolicyCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(BRANCH_POLICIES_CREATE))):
|
||||
return await BranchPolicyService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@branchPolicy_router.get("", response_model=list[BranchPolicyRead])
|
||||
async def list_branchPolicy(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(BRANCH_POLICIES_VIEW))):
|
||||
return await BranchPolicyService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@branchPolicy_router.get("/{entity_id}", response_model=BranchPolicyRead)
|
||||
async def get_branchPolicy(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(BRANCH_POLICIES_VIEW))):
|
||||
return await BranchPolicyService(db).get(tenant_id, entity_id)
|
||||
@ -1,39 +0,0 @@
|
||||
"""Delivery settings APIs — Phase 10.0."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import SETTINGS_MANAGE, SETTINGS_VIEW
|
||||
from app.schemas.foundation import BeautySettingRead, BeautySettingUpsert
|
||||
from app.services.foundation import BeautySettingService
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.put("", response_model=BeautySettingRead, status_code=status.HTTP_200_OK)
|
||||
async def upsert_setting(
|
||||
body: BeautySettingUpsert,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(SETTINGS_MANAGE)),
|
||||
):
|
||||
return await BeautySettingService(db).upsert(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=list[BeautySettingRead])
|
||||
async def list_settings(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(SETTINGS_VIEW)),
|
||||
):
|
||||
return await BeautySettingService(db).list(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||
)
|
||||
@ -1,46 +0,0 @@
|
||||
"""API routes."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
from app.permissions.definitions import COMMISSIONS_CREATE, COMMISSIONS_VIEW
|
||||
from app.permissions.definitions import STAFF_CREATE, STAFF_VIEW
|
||||
from app.schemas.staff import CommissionRuleCreate, CommissionRuleRead
|
||||
from app.schemas.staff import StaffProfileCreate, StaffProfileRead
|
||||
from app.services.staff import CommissionRuleService
|
||||
from app.services.staff import StaffProfileService
|
||||
|
||||
|
||||
staffProfile_router = APIRouter()
|
||||
|
||||
@staffProfile_router.post("", response_model=StaffProfileRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_staffProfile(body: StaffProfileCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(STAFF_CREATE))):
|
||||
return await StaffProfileService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@staffProfile_router.get("", response_model=list[StaffProfileRead])
|
||||
async def list_staffProfile(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(STAFF_VIEW))):
|
||||
return await StaffProfileService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@staffProfile_router.get("/{entity_id}", response_model=StaffProfileRead)
|
||||
async def get_staffProfile(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(STAFF_VIEW))):
|
||||
return await StaffProfileService(db).get(tenant_id, entity_id)
|
||||
|
||||
|
||||
commissionRule_router = APIRouter()
|
||||
|
||||
@commissionRule_router.post("", response_model=CommissionRuleRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_commissionRule(body: CommissionRuleCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(COMMISSIONS_CREATE))):
|
||||
return await CommissionRuleService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
@commissionRule_router.get("", response_model=list[CommissionRuleRead])
|
||||
async def list_commissionRule(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(COMMISSIONS_VIEW))):
|
||||
return await CommissionRuleService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
|
||||
|
||||
@commissionRule_router.get("/{entity_id}", response_model=CommissionRuleRead)
|
||||
async def get_commissionRule(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(COMMISSIONS_VIEW))):
|
||||
return await CommissionRuleService(db).get(tenant_id, entity_id)
|
||||
@ -1,4 +0,0 @@
|
||||
"""Command handlers package."""
|
||||
from app.commands.appointments import AppointmentCommands
|
||||
|
||||
__all__ = ["AppointmentCommands"]
|
||||
@ -1,70 +0,0 @@
|
||||
"""Appointment command handlers — Phase 14.1."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.types import AppointmentLifecycleAction
|
||||
from app.schemas.appointment_core import (
|
||||
AppointmentCreate,
|
||||
AppointmentLifecycleRequest,
|
||||
AppointmentUpdate,
|
||||
)
|
||||
from app.services.appointment_core import AppointmentCoreService
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class AppointmentCommands:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.service = AppointmentCoreService(session)
|
||||
|
||||
async def create(self, tenant_id: UUID, body: AppointmentCreate, *, actor: CurrentUser | None):
|
||||
return await self.service.create(tenant_id, body, actor=actor)
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, appointment_id: UUID, body: AppointmentUpdate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.service.update(tenant_id, appointment_id, body, actor=actor)
|
||||
|
||||
async def confirm(
|
||||
self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.service.apply_lifecycle(
|
||||
tenant_id, appointment_id, AppointmentLifecycleAction.CONFIRM, body, actor=actor
|
||||
)
|
||||
|
||||
async def check_in(
|
||||
self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.service.apply_lifecycle(
|
||||
tenant_id, appointment_id, AppointmentLifecycleAction.CHECK_IN, body, actor=actor
|
||||
)
|
||||
|
||||
async def start(
|
||||
self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.service.apply_lifecycle(
|
||||
tenant_id, appointment_id, AppointmentLifecycleAction.START, body, actor=actor
|
||||
)
|
||||
|
||||
async def complete(
|
||||
self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.service.apply_lifecycle(
|
||||
tenant_id, appointment_id, AppointmentLifecycleAction.COMPLETE, body, actor=actor
|
||||
)
|
||||
|
||||
async def cancel(
|
||||
self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.service.apply_lifecycle(
|
||||
tenant_id, appointment_id, AppointmentLifecycleAction.CANCEL, body, actor=actor
|
||||
)
|
||||
|
||||
async def no_show(
|
||||
self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.service.apply_lifecycle(
|
||||
tenant_id, appointment_id, AppointmentLifecycleAction.NO_SHOW, body, actor=actor
|
||||
)
|
||||
@ -1,75 +0,0 @@
|
||||
"""Delivery Service settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
environment: Literal["development", "staging", "production", "test"] = "development"
|
||||
debug: bool = True
|
||||
log_level: str = "INFO"
|
||||
service_name: str = Field(
|
||||
default="beauty-business-service", validation_alias="BEAUTY_BUSINESS_SERVICE_NAME"
|
||||
)
|
||||
api_v1_prefix: str = "/api/v1"
|
||||
|
||||
database_url: str = Field(
|
||||
default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/beauty_business_db",
|
||||
validation_alias="BEAUTY_BUSINESS_DATABASE_URL",
|
||||
)
|
||||
database_url_sync: str = Field(
|
||||
default="postgresql+psycopg://superapp:superapp_password@localhost:5432/beauty_business_db",
|
||||
validation_alias="BEAUTY_BUSINESS_DATABASE_URL_SYNC",
|
||||
)
|
||||
|
||||
core_service_url: str = Field(
|
||||
default="http://localhost:8000", validation_alias="CORE_SERVICE_URL"
|
||||
)
|
||||
|
||||
keycloak_enabled: bool = True
|
||||
keycloak_server_url: str = Field(
|
||||
default="http://localhost:8080", validation_alias="KEYCLOAK_SERVER_URL"
|
||||
)
|
||||
keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL")
|
||||
keycloak_realm: str = Field(default="superapp", validation_alias="KEYCLOAK_REALM")
|
||||
|
||||
jwt_algorithm: str = "RS256"
|
||||
jwt_audience: str = "account"
|
||||
jwt_verify_signature: bool = True
|
||||
auth_required: bool = True
|
||||
|
||||
cors_origins: str = Field(
|
||||
default="http://localhost:3000,http://127.0.0.1:3000",
|
||||
validation_alias="CORS_ORIGINS",
|
||||
)
|
||||
|
||||
@property
|
||||
def keycloak_public_base(self) -> str:
|
||||
return (self.keycloak_public_url or self.keycloak_server_url).rstrip("/")
|
||||
|
||||
@property
|
||||
def keycloak_public_realm_url(self) -> str:
|
||||
return f"{self.keycloak_public_base}/realms/{self.keycloak_realm}"
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@ -1,27 +0,0 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
_engine_kwargs: dict = {"pool_pre_ping": True, "future": True}
|
||||
if settings.database_url.startswith("sqlite"):
|
||||
_engine_kwargs["poolclass"] = StaticPool
|
||||
_engine_kwargs["connect_args"] = {"check_same_thread": False}
|
||||
|
||||
engine = create_async_engine(settings.database_url, **_engine_kwargs)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
@ -1,14 +0,0 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
def configure_logging(level: str = "INFO") -> None:
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, level.upper(), logging.INFO),
|
||||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
@ -1,49 +0,0 @@
|
||||
"""JWT authentication dependencies for Delivery service."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.config import settings
|
||||
from shared.auth import JWTSettings, JWTValidator
|
||||
from shared.exceptions import UnauthorizedError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_jwt_validator() -> JWTValidator:
|
||||
return JWTValidator(
|
||||
JWTSettings(
|
||||
keycloak_enabled=settings.keycloak_enabled,
|
||||
keycloak_server_url=settings.keycloak_server_url,
|
||||
keycloak_realm=settings.keycloak_realm,
|
||||
jwt_algorithm=settings.jwt_algorithm,
|
||||
jwt_audience=settings.jwt_audience,
|
||||
jwt_verify_signature=settings.jwt_verify_signature,
|
||||
jwt_issuer=settings.keycloak_public_realm_url,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
) -> CurrentUser:
|
||||
if not settings.auth_required:
|
||||
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
|
||||
return await get_jwt_validator().validate(credentials.credentials)
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
) -> CurrentUser | None:
|
||||
if not settings.auth_required:
|
||||
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
|
||||
if credentials is None or not credentials.credentials:
|
||||
return None
|
||||
return await get_jwt_validator().validate(credentials.credentials)
|
||||
@ -1,121 +0,0 @@
|
||||
"""Delivery event publisher — in-memory + transactional outbox (ADR-006)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.events import EventEnvelope, EventStatus
|
||||
|
||||
from app.core.config import settings
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.outbox import OutboxEvent
|
||||
|
||||
|
||||
class EventPublisher(Protocol):
|
||||
def publish(
|
||||
self,
|
||||
*,
|
||||
event_type: BeautyBusinessEventType,
|
||||
aggregate_type: str,
|
||||
aggregate_id: UUID,
|
||||
tenant_id: UUID,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> EventEnvelope: ...
|
||||
|
||||
|
||||
class InMemoryEventPublisher:
|
||||
"""Records published envelopes for tests and local verification."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.published: list[EventEnvelope] = []
|
||||
|
||||
def publish(
|
||||
self,
|
||||
*,
|
||||
event_type: BeautyBusinessEventType,
|
||||
aggregate_type: str,
|
||||
aggregate_id: UUID,
|
||||
tenant_id: UUID,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> EventEnvelope:
|
||||
envelope = EventEnvelope(
|
||||
event_id=uuid4(),
|
||||
event_type=event_type.value,
|
||||
aggregate_type=aggregate_type,
|
||||
aggregate_id=str(aggregate_id),
|
||||
tenant_id=tenant_id,
|
||||
source_service=settings.service_name,
|
||||
payload=payload or {},
|
||||
)
|
||||
self.published.append(envelope)
|
||||
return envelope
|
||||
|
||||
def record(self, envelope: EventEnvelope) -> EventEnvelope:
|
||||
self.published.append(envelope)
|
||||
return envelope
|
||||
|
||||
|
||||
class TransactionalEventPublisher:
|
||||
"""Persist outbox row in the current transaction; mirror to memory in tests."""
|
||||
|
||||
def __init__(
|
||||
self, session: AsyncSession, memory: InMemoryEventPublisher | None = None
|
||||
) -> None:
|
||||
self.session = session
|
||||
if memory is not None:
|
||||
self.memory = memory
|
||||
elif settings.environment == "test":
|
||||
self.memory = get_event_publisher()
|
||||
else:
|
||||
self.memory = None
|
||||
|
||||
async def publish(
|
||||
self,
|
||||
*,
|
||||
event_type: BeautyBusinessEventType,
|
||||
aggregate_type: str,
|
||||
aggregate_id: UUID,
|
||||
tenant_id: UUID,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> EventEnvelope:
|
||||
envelope = EventEnvelope(
|
||||
event_id=uuid4(),
|
||||
event_type=event_type.value,
|
||||
aggregate_type=aggregate_type,
|
||||
aggregate_id=str(aggregate_id),
|
||||
tenant_id=tenant_id,
|
||||
source_service=settings.service_name,
|
||||
payload=payload or {},
|
||||
)
|
||||
row = OutboxEvent(
|
||||
tenant_id=tenant_id,
|
||||
event_type=envelope.event_type,
|
||||
aggregate_type=aggregate_type,
|
||||
aggregate_id=str(aggregate_id),
|
||||
payload={
|
||||
"event_id": str(envelope.event_id),
|
||||
"source_service": settings.service_name,
|
||||
**(payload or {}),
|
||||
},
|
||||
status=EventStatus.PENDING,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
if self.memory is not None:
|
||||
self.memory.record(envelope)
|
||||
return envelope
|
||||
|
||||
|
||||
_default_publisher = InMemoryEventPublisher()
|
||||
|
||||
|
||||
def get_event_publisher() -> InMemoryEventPublisher:
|
||||
return _default_publisher
|
||||
|
||||
|
||||
def reset_event_publisher() -> InMemoryEventPublisher:
|
||||
global _default_publisher
|
||||
_default_publisher = InMemoryEventPublisher()
|
||||
return _default_publisher
|
||||
@ -1,79 +0,0 @@
|
||||
"""Beauty Business event type contracts (publish-only)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class BeautyBusinessEventType(str, enum.Enum):
|
||||
ORGANIZATION_CREATED = "beauty_business.organization.created"
|
||||
ORGANIZATION_UPDATED = "beauty_business.organization.updated"
|
||||
BRANCH_CREATED = "beauty_business.branch.created"
|
||||
BRANCH_UPDATED = "beauty_business.branch.updated"
|
||||
EXTERNAL_PROVIDER_REGISTERED = "beauty_business.external_provider.registered"
|
||||
EXTERNAL_PROVIDER_UPDATED = "beauty_business.external_provider.updated"
|
||||
BOOKING_ENGINE_REGISTERED = "beauty_business.booking_engine.registered"
|
||||
BOOKING_ENGINE_UPDATED = "beauty_business.booking_engine.updated"
|
||||
CONFIGURATION_CREATED = "beauty_business.configuration.created"
|
||||
CONFIGURATION_UPDATED = "beauty_business.configuration.updated"
|
||||
SETTING_UPSERTED = "beauty_business.setting.upserted"
|
||||
|
||||
# Phase 14.1 — Booking
|
||||
STAFF_SCHEDULE_CREATED = "beauty_business.staff_schedule.created"
|
||||
STAFF_AVAILABILITY_CREATED = "beauty_business.staff_availability.created"
|
||||
SCHEDULE_EXCEPTION_CREATED = "beauty_business.schedule_exception.created"
|
||||
APPOINTMENT_TYPE_CREATED = "beauty_business.appointment_type.created"
|
||||
APPOINTMENT_CREATED = "beauty_business.appointment.created"
|
||||
APPOINTMENT_UPDATED = "beauty_business.appointment.updated"
|
||||
APPOINTMENT_CONFIRMED = "beauty_business.appointment.confirmed"
|
||||
APPOINTMENT_CHECKED_IN = "beauty_business.appointment.checked_in"
|
||||
APPOINTMENT_STARTED = "beauty_business.appointment.started"
|
||||
APPOINTMENT_COMPLETED = "beauty_business.appointment.completed"
|
||||
APPOINTMENT_CANCELLED = "beauty_business.appointment.cancelled"
|
||||
APPOINTMENT_NO_SHOW = "beauty_business.appointment.no_show"
|
||||
APPOINTMENT_STATUS_CHANGED = "beauty_business.appointment.status_changed"
|
||||
WAITING_LIST_ENTRY_CREATED = "beauty_business.waiting_list_entry.created"
|
||||
|
||||
# Phase 14.2 — Salon
|
||||
TREATMENT_ROOM_CREATED = "beauty_business.treatment_room.created"
|
||||
STATION_CREATED = "beauty_business.station.created"
|
||||
BRANCH_OPERATING_HOURS_CREATED = "beauty_business.branch_operating_hours.created"
|
||||
BRANCH_POLICY_CREATED = "beauty_business.branch_policy.created"
|
||||
|
||||
# Phase 14.3 — Catalog
|
||||
SERVICE_CATEGORY_CREATED = "beauty_business.service_category.created"
|
||||
SERVICE_CREATED = "beauty_business.service.created"
|
||||
SERVICE_VARIANT_CREATED = "beauty_business.service_variant.created"
|
||||
BRANCH_SERVICE_ASSIGNMENT_CREATED = "beauty_business.branch_service_assignment.created"
|
||||
SERVICE_MEDIA_REF_CREATED = "beauty_business.service_media_ref.created"
|
||||
|
||||
# Phase 14.4 — Customers
|
||||
CUSTOMER_PROFILE_CREATED = "beauty_business.customer_profile.created"
|
||||
CUSTOMER_PREFERENCE_CREATED = "beauty_business.customer_preference.created"
|
||||
VISIT_HISTORY_ENTRY_CREATED = "beauty_business.visit_history_entry.created"
|
||||
CUSTOMER_DOCUMENT_REF_CREATED = "beauty_business.customer_document_ref.created"
|
||||
|
||||
# Phase 14.5 — Packages & Memberships
|
||||
PACKAGE_DEFINITION_CREATED = "beauty_business.package_definition.created"
|
||||
CUSTOMER_PACKAGE_CREATED = "beauty_business.customer_package.created"
|
||||
PACKAGE_REDEMPTION_CREATED = "beauty_business.package_redemption.created"
|
||||
MEMBERSHIP_PLAN_CREATED = "beauty_business.membership_plan.created"
|
||||
CUSTOMER_MEMBERSHIP_CREATED = "beauty_business.customer_membership.created"
|
||||
MEMBERSHIP_STATUS_CHANGED = "beauty_business.membership.status_changed"
|
||||
|
||||
# Phase 14.6 — Staff & Commissions
|
||||
STAFF_PROFILE_CREATED = "beauty_business.staff_profile.created"
|
||||
STAFF_SKILL_CREATED = "beauty_business.staff_skill.created"
|
||||
STAFF_CERTIFICATE_CREATED = "beauty_business.staff_certificate.created"
|
||||
COMMISSION_RULE_CREATED = "beauty_business.commission_rule.created"
|
||||
COMMISSION_ENTRY_CREATED = "beauty_business.commission_entry.created"
|
||||
|
||||
# Phase 14.7 — Marketing & Integrations
|
||||
MARKETING_CAMPAIGN_CREATED = "beauty_business.marketing_campaign.created"
|
||||
LOYALTY_INTEGRATION_CONFIG_CREATED = "beauty_business.loyalty_integration_config.created"
|
||||
COMMUNICATION_INTEGRATION_CONFIG_CREATED = "beauty_business.communication_integration_config.created"
|
||||
INTEGRATION_DISPATCH_CREATED = "beauty_business.integration_dispatch.created"
|
||||
INTEGRATION_DISPATCH_STATUS_CHANGED = "beauty_business.integration_dispatch.status_changed"
|
||||
|
||||
# Back-compat aliases
|
||||
ROUTING_ENGINE_REGISTERED = BOOKING_ENGINE_REGISTERED
|
||||
ROUTING_ENGINE_UPDATED = BOOKING_ENGINE_UPDATED
|
||||
@ -1,69 +0,0 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app import __version__
|
||||
from app.api.v1 import api_router
|
||||
from app.api.v1 import health
|
||||
from app.core.config import settings
|
||||
from app.core.logging import configure_logging, get_logger
|
||||
from app.middlewares.tenant import TenantHeaderMiddleware
|
||||
from shared.exceptions import AppError
|
||||
from shared.responses import ErrorDetail, ErrorResponse
|
||||
|
||||
configure_logging(settings.log_level)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("service_starting", extra={"service": settings.service_name})
|
||||
yield
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="Beauty Business Platform",
|
||||
version=__version__,
|
||||
description="Torbat Beauty — Beauty Business Platform (Phase 14.7)",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_origin_regex=r"https?://([a-z0-9-]+\.)*torbatyar\.ir",
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(TenantHeaderMiddleware)
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code=exc.error_code, message=exc.message, details=exc.details
|
||||
)
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
logger.error("unhandled_exception", extra={"error": str(exc)}, exc_info=exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ErrorResponse(
|
||||
error=ErrorDetail(code="internal_error", message="خطای داخلی سرور")
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@ -1,24 +0,0 @@
|
||||
"""Tenant header resolution middleware."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from shared.tenant import HEADER_TENANT_ID, STATE_TENANT_ID
|
||||
|
||||
|
||||
class TenantHeaderMiddleware(BaseHTTPMiddleware):
|
||||
"""Resolve tenant from X-Tenant-ID header only (microservice pattern)."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
setattr(request.state, STATE_TENANT_ID, None)
|
||||
raw = request.headers.get(HEADER_TENANT_ID)
|
||||
if raw:
|
||||
try:
|
||||
setattr(request.state, STATE_TENANT_ID, UUID(raw))
|
||||
except ValueError:
|
||||
pass
|
||||
return await call_next(request)
|
||||
@ -1,63 +0,0 @@
|
||||
"""Import all models for Alembic metadata discovery."""
|
||||
from app.models.appointments import ( # noqa: F401
|
||||
Appointment,
|
||||
AppointmentStatusHistory,
|
||||
AppointmentType,
|
||||
ScheduleException,
|
||||
StaffAvailability,
|
||||
StaffSchedule,
|
||||
WaitingListEntry,
|
||||
)
|
||||
from app.models.catalog import ( # noqa: F401
|
||||
BeautyService,
|
||||
BranchServiceAssignment,
|
||||
ServiceCategory,
|
||||
ServiceMediaRef,
|
||||
ServiceVariant,
|
||||
)
|
||||
from app.models.customers import ( # noqa: F401
|
||||
CustomerDocumentRef,
|
||||
CustomerPreference,
|
||||
CustomerProfile,
|
||||
VisitHistoryEntry,
|
||||
)
|
||||
from app.models.foundation import ( # noqa: F401
|
||||
BeautyAuditLog,
|
||||
BeautyBranch,
|
||||
BeautyConfiguration,
|
||||
BeautyOrganization,
|
||||
BeautyPermission,
|
||||
BeautyRole,
|
||||
BeautySetting,
|
||||
BookingEngineRegistration,
|
||||
ExternalProviderConfig,
|
||||
)
|
||||
from app.models.marketing import ( # noqa: F401
|
||||
CommunicationIntegrationConfig,
|
||||
IntegrationDispatch,
|
||||
LoyaltyIntegrationConfig,
|
||||
MarketingCampaignRef,
|
||||
)
|
||||
from app.models.outbox import OutboxEvent # noqa: F401
|
||||
from app.models.packages import ( # noqa: F401
|
||||
CustomerMembership,
|
||||
CustomerPackage,
|
||||
MembershipLifecycleEvent,
|
||||
MembershipPlan,
|
||||
PackageDefinition,
|
||||
PackageLine,
|
||||
PackageRedemption,
|
||||
)
|
||||
from app.models.salon import ( # noqa: F401
|
||||
BranchOperatingHours,
|
||||
BranchPolicy,
|
||||
Station,
|
||||
TreatmentRoom,
|
||||
)
|
||||
from app.models.staff import ( # noqa: F401
|
||||
CommissionEntry,
|
||||
CommissionRule,
|
||||
StaffCertificate,
|
||||
StaffProfile,
|
||||
StaffSkill,
|
||||
)
|
||||
@ -1,223 +0,0 @@
|
||||
"""Booking aggregates — Phase 14.1."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, time
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
Time,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import (
|
||||
AppointmentLifecycleAction,
|
||||
AppointmentStatus,
|
||||
DayOfWeek,
|
||||
GUID,
|
||||
LifecycleStatus,
|
||||
ScheduleExceptionKind,
|
||||
)
|
||||
|
||||
|
||||
class StaffSchedule(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "staff_schedules"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
branch_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
staff_ref: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
day_of_week: Mapped[DayOfWeek] = mapped_column(nullable=False)
|
||||
start_time: Mapped[time] = mapped_column(Time, nullable=False)
|
||||
end_time: Mapped[time] = mapped_column(Time, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_staff_schedules_tenant_code"),
|
||||
Index("ix_staff_schedules_branch", "tenant_id", "branch_id"),
|
||||
Index("ix_staff_schedules_staff", "tenant_id", "staff_ref"),
|
||||
)
|
||||
|
||||
|
||||
class StaffAvailability(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "staff_availabilities"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
branch_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
staff_ref: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
is_bookable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_staff_availabilities_tenant_code"),
|
||||
Index("ix_staff_availabilities_branch", "tenant_id", "branch_id"),
|
||||
Index("ix_staff_availabilities_staff", "tenant_id", "staff_ref"),
|
||||
)
|
||||
|
||||
|
||||
class ScheduleException(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "schedule_exceptions"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
branch_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
staff_ref: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
kind: Mapped[ScheduleExceptionKind] = mapped_column(nullable=False)
|
||||
exception_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
start_time: Mapped[time | None] = mapped_column(Time, nullable=True)
|
||||
end_time: Mapped[time | None] = mapped_column(Time, nullable=True)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_schedule_exceptions_tenant_code"),
|
||||
Index("ix_schedule_exceptions_branch", "tenant_id", "branch_id"),
|
||||
)
|
||||
|
||||
|
||||
class AppointmentType(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "appointment_types"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
default_duration_minutes: Mapped[int] = mapped_column(Integer, default=30, nullable=False)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_appointment_types_tenant_code"),
|
||||
Index("ix_appointment_types_org", "tenant_id", "organization_id"),
|
||||
)
|
||||
|
||||
|
||||
class Appointment(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "appointments"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
branch_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
appointment_type_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
customer_ref: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
staff_ref: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
room_ref: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
service_ref: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
status: Mapped[AppointmentStatus] = mapped_column(
|
||||
default=AppointmentStatus.REQUESTED, nullable=False
|
||||
)
|
||||
scheduled_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
scheduled_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
status_changed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_appointments_tenant_code"),
|
||||
Index("ix_appointments_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_appointments_branch", "tenant_id", "branch_id"),
|
||||
Index("ix_appointments_staff", "tenant_id", "staff_ref"),
|
||||
Index("ix_appointments_scheduled_start", "tenant_id", "scheduled_start"),
|
||||
)
|
||||
|
||||
|
||||
class AppointmentStatusHistory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||
__tablename__ = "appointment_status_history"
|
||||
|
||||
appointment_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
action: Mapped[AppointmentLifecycleAction] = mapped_column(nullable=False)
|
||||
from_status: Mapped[AppointmentStatus] = mapped_column(nullable=False)
|
||||
to_status: Mapped[AppointmentStatus] = mapped_column(nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_appointment_status_history_appt", "tenant_id", "appointment_id"),
|
||||
)
|
||||
|
||||
|
||||
class WaitingListEntry(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "waiting_list_entries"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
branch_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
customer_ref: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
service_ref: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
preferred_staff_ref: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
requested_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
priority: Mapped[int] = mapped_column(Integer, default=100, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_waiting_list_entries_tenant_code"),
|
||||
Index("ix_waiting_list_entries_branch", "tenant_id", "branch_id"),
|
||||
)
|
||||
@ -1,51 +0,0 @@
|
||||
"""Shared model mixins."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.types import GUID
|
||||
|
||||
|
||||
class UUIDPrimaryKeyMixin:
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class TenantMixin:
|
||||
"""Row-level tenancy (ADR-003). Tenant comes from request context, never hardcoded."""
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
|
||||
|
||||
class SoftDeleteMixin:
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
deleted_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
|
||||
class ActorAuditMixin:
|
||||
created_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
|
||||
class OptimisticLockMixin:
|
||||
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
@ -1,137 +0,0 @@
|
||||
"""Service catalog aggregates — Phase 14.3."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, Index, Integer, Numeric, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import GUID, LifecycleStatus, ServiceStatus
|
||||
|
||||
|
||||
class ServiceCategory(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "service_categories"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_service_categories_tenant_code"),
|
||||
Index("ix_service_categories_org", "tenant_id", "organization_id"),
|
||||
)
|
||||
|
||||
|
||||
class BeautyService(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "beauty_services"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
category_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[ServiceStatus] = mapped_column(
|
||||
default=ServiceStatus.DRAFT, nullable=False
|
||||
)
|
||||
base_duration_minutes: Mapped[int] = mapped_column(Integer, default=30, nullable=False)
|
||||
base_price: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_beauty_services_tenant_code"),
|
||||
Index("ix_beauty_services_org", "tenant_id", "organization_id"),
|
||||
)
|
||||
|
||||
|
||||
class ServiceVariant(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "service_variants"
|
||||
|
||||
service_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
duration_minutes: Mapped[int] = mapped_column(Integer, default=30, nullable=False)
|
||||
price: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "service_id", "code", name="uq_service_variants_tenant_code"
|
||||
),
|
||||
Index("ix_service_variants_service", "tenant_id", "service_id"),
|
||||
)
|
||||
|
||||
|
||||
class BranchServiceAssignment(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "branch_service_assignments"
|
||||
|
||||
branch_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
service_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
price_override: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_branch_service_assignments_branch", "tenant_id", "branch_id"),
|
||||
Index("ix_branch_service_assignments_service", "tenant_id", "service_id"),
|
||||
)
|
||||
|
||||
|
||||
class ServiceMediaRef(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "service_media_refs"
|
||||
|
||||
service_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
storage_file_ref: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_service_media_refs_service", "tenant_id", "service_id"),
|
||||
)
|
||||
@ -1,111 +0,0 @@
|
||||
"""Customer management aggregates — Phase 14.4."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import GUID, LifecycleStatus
|
||||
|
||||
|
||||
class CustomerProfile(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "customer_profiles"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
external_crm_contact_ref: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
external_user_ref: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_customer_profiles_tenant_code"),
|
||||
Index("ix_customer_profiles_org", "tenant_id", "organization_id"),
|
||||
)
|
||||
|
||||
|
||||
class CustomerPreference(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "customer_preferences"
|
||||
|
||||
customer_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
value: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "customer_id", "key", name="uq_customer_preferences_key"
|
||||
),
|
||||
Index("ix_customer_preferences_customer", "tenant_id", "customer_id"),
|
||||
)
|
||||
|
||||
|
||||
class VisitHistoryEntry(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "visit_history_entries"
|
||||
|
||||
customer_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
appointment_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
service_ref: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
staff_ref: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
visited_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_visit_history_entries_customer", "tenant_id", "customer_id"),
|
||||
)
|
||||
|
||||
|
||||
class CustomerDocumentRef(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "customer_document_refs"
|
||||
|
||||
customer_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
storage_file_ref: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_customer_document_refs_customer", "tenant_id", "customer_id"),
|
||||
)
|
||||
@ -1,308 +0,0 @@
|
||||
"""Beauty Business foundation aggregates — Phase 14.0.
|
||||
|
||||
Independent aggregates use UUID references within beauty_business_db only.
|
||||
No SQLAlchemy relationship graphs between aggregates.
|
||||
Booking engine registrations and provider shells only — no engine implementation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Index, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import (
|
||||
AuditAction,
|
||||
BookingEngineStatus,
|
||||
BusinessFormat,
|
||||
GUID,
|
||||
LifecycleStatus,
|
||||
ProviderKind,
|
||||
ProviderStatus,
|
||||
)
|
||||
|
||||
|
||||
class BeautyOrganization(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
"""Root beauty business organization for a tenant (Torbat Beauty)."""
|
||||
|
||||
__tablename__ = "beauty_organizations"
|
||||
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.DRAFT, nullable=False
|
||||
)
|
||||
business_format: Mapped[BusinessFormat] = mapped_column(
|
||||
default=BusinessFormat.SALON, nullable=False
|
||||
)
|
||||
legal_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False)
|
||||
language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False)
|
||||
currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False)
|
||||
settings: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_beauty_organizations_tenant_code"),
|
||||
Index("ix_beauty_organizations_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_beauty_organizations_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class BeautyBranch(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""Salon / clinic branch under a beauty organization."""
|
||||
|
||||
__tablename__ = "beauty_branches"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
business_format: Mapped[BusinessFormat | None] = mapped_column(nullable=True)
|
||||
address: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
timezone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
working_hours: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "organization_id", "code", name="uq_beauty_branches_tenant_code"
|
||||
),
|
||||
Index("ix_beauty_branches_org", "tenant_id", "organization_id"),
|
||||
Index("ix_beauty_branches_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class BeautyRole(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""Local beauty RBAC role catalog shell."""
|
||||
|
||||
__tablename__ = "beauty_roles"
|
||||
|
||||
organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_beauty_roles_tenant_code"),
|
||||
Index("ix_beauty_roles_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class BeautyPermission(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""Local beauty permission catalog shell (complements platform beauty_business.* tree)."""
|
||||
|
||||
__tablename__ = "beauty_permissions"
|
||||
|
||||
role_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_beauty_permissions_tenant_code"),
|
||||
Index("ix_beauty_permissions_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class ExternalProviderConfig(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
"""External integration provider registration shell — credentials stay here."""
|
||||
|
||||
__tablename__ = "external_provider_configs"
|
||||
|
||||
organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
provider_kind: Mapped[ProviderKind] = mapped_column(
|
||||
default=ProviderKind.CUSTOM, nullable=False
|
||||
)
|
||||
adapter_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
status: Mapped[ProviderStatus] = mapped_column(
|
||||
default=ProviderStatus.REGISTERED, nullable=False
|
||||
)
|
||||
priority: Mapped[int] = mapped_column(default=100, nullable=False)
|
||||
credentials_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
capabilities: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "code", name="uq_external_provider_configs_tenant_code"
|
||||
),
|
||||
Index("ix_external_provider_configs_kind", "tenant_id", "provider_kind"),
|
||||
Index("ix_external_provider_configs_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class BookingEngineRegistration(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
"""Future booking engine registration — adapter ready, no engine implementation."""
|
||||
|
||||
__tablename__ = "booking_engine_registrations"
|
||||
|
||||
organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
adapter_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
status: Mapped[BookingEngineStatus] = mapped_column(
|
||||
default=BookingEngineStatus.REGISTERED, nullable=False
|
||||
)
|
||||
is_default: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
capabilities: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "code", name="uq_booking_engine_registrations_tenant_code"
|
||||
),
|
||||
Index("ix_booking_engine_registrations_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class BeautyConfiguration(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
"""Tenant beauty configuration — hours, policies, locale shells."""
|
||||
|
||||
__tablename__ = "beauty_configurations"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
working_hours: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
booking_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
cancellation_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
reminder_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
custom_fields: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False)
|
||||
language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False)
|
||||
currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"organization_id",
|
||||
"code",
|
||||
name="uq_beauty_configurations_tenant_code",
|
||||
),
|
||||
Index("ix_beauty_configurations_org", "tenant_id", "organization_id"),
|
||||
Index("ix_beauty_configurations_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class BeautySetting(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""Key/value beauty settings shell."""
|
||||
|
||||
__tablename__ = "beauty_settings"
|
||||
|
||||
organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
value: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"organization_id",
|
||||
"branch_id",
|
||||
"key",
|
||||
name="uq_beauty_settings_tenant_key",
|
||||
),
|
||||
Index("ix_beauty_settings_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class BeautyAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||
"""Append-only beauty audit trail."""
|
||||
|
||||
__tablename__ = "beauty_audit_logs"
|
||||
|
||||
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
action: Mapped[AuditAction] = mapped_column(nullable=False)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
changes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_beauty_audit_entity",
|
||||
"tenant_id",
|
||||
"entity_type",
|
||||
"entity_id",
|
||||
),
|
||||
)
|
||||
@ -1,134 +0,0 @@
|
||||
"""Marketing and integration aggregates — Phase 14.7."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Index, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import GUID, IntegrationDispatchStatus, LifecycleStatus
|
||||
|
||||
|
||||
class MarketingCampaignRef(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "marketing_campaign_refs"
|
||||
|
||||
organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
external_campaign_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
channel: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.DRAFT, nullable=False
|
||||
)
|
||||
config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_marketing_campaign_refs_tenant_code",
|
||||
"tenant_id",
|
||||
"code",
|
||||
unique=True,
|
||||
),
|
||||
Index("ix_marketing_campaign_refs_org", "tenant_id", "organization_id"),
|
||||
)
|
||||
|
||||
|
||||
class LoyaltyIntegrationConfig(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "loyalty_integration_configs"
|
||||
|
||||
organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
external_program_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
earn_rules: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_loyalty_integration_configs_tenant_code",
|
||||
"tenant_id",
|
||||
"code",
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CommunicationIntegrationConfig(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "communication_integration_configs"
|
||||
|
||||
organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
template_mappings: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_communication_integration_configs_tenant_code",
|
||||
"tenant_id",
|
||||
"code",
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class IntegrationDispatch(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "integration_dispatches"
|
||||
|
||||
organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
integration_kind: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
config_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
payload_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[IntegrationDispatchStatus] = mapped_column(
|
||||
default=IntegrationDispatchStatus.PENDING, nullable=False
|
||||
)
|
||||
response_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_integration_dispatches_kind", "tenant_id", "integration_kind"),
|
||||
Index("ix_integration_dispatches_status", "tenant_id", "status"),
|
||||
)
|
||||
@ -1,44 +0,0 @@
|
||||
"""Transactional outbox model — ADR-006."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, String, func
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import UUIDPrimaryKeyMixin
|
||||
from app.models.types import GUID
|
||||
from shared.events import EventStatus
|
||||
|
||||
|
||||
class OutboxEvent(UUIDPrimaryKeyMixin, Base):
|
||||
"""Pending domain events written in the same DB transaction as mutations."""
|
||||
|
||||
__tablename__ = "outbox_events"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
aggregate_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
aggregate_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
status: Mapped[EventStatus] = mapped_column(
|
||||
SAEnum(EventStatus, name="beauty_business_event_status", native_enum=False),
|
||||
default=EventStatus.PENDING,
|
||||
nullable=False,
|
||||
)
|
||||
retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_beauty_business_outbox_status", "status"),
|
||||
Index("ix_beauty_business_outbox_tenant", "tenant_id"),
|
||||
)
|
||||
@ -1,192 +0,0 @@
|
||||
"""Packages and memberships — Phase 14.5."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, Index, Integer, Numeric, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import (
|
||||
GUID,
|
||||
LifecycleStatus,
|
||||
MembershipLifecycleAction,
|
||||
MembershipStatus,
|
||||
PackageStatus,
|
||||
)
|
||||
|
||||
|
||||
class PackageDefinition(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "package_definitions"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
total_sessions: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
price: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
valid_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_package_definitions_tenant_code"),
|
||||
Index("ix_package_definitions_org", "tenant_id", "organization_id"),
|
||||
)
|
||||
|
||||
|
||||
class PackageLine(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "package_lines"
|
||||
|
||||
package_definition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
service_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
session_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_package_lines_package", "tenant_id", "package_definition_id"),
|
||||
)
|
||||
|
||||
|
||||
class CustomerPackage(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "customer_packages"
|
||||
|
||||
customer_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
package_definition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
status: Mapped[PackageStatus] = mapped_column(
|
||||
default=PackageStatus.ACTIVE, nullable=False
|
||||
)
|
||||
sessions_remaining: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
purchased_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
expires_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_customer_packages_tenant_code"),
|
||||
Index("ix_customer_packages_customer", "tenant_id", "customer_id"),
|
||||
)
|
||||
|
||||
|
||||
class PackageRedemption(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "package_redemptions"
|
||||
|
||||
customer_package_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
appointment_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
service_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
redeemed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
sessions_used: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_package_redemptions_package", "tenant_id", "customer_package_id"),
|
||||
)
|
||||
|
||||
|
||||
class MembershipPlan(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "membership_plans"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
billing_interval_days: Mapped[int] = mapped_column(Integer, default=30, nullable=False)
|
||||
price: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
benefits: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_membership_plans_tenant_code"),
|
||||
Index("ix_membership_plans_org", "tenant_id", "organization_id"),
|
||||
)
|
||||
|
||||
|
||||
class CustomerMembership(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "customer_memberships"
|
||||
|
||||
customer_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
membership_plan_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
status: Mapped[MembershipStatus] = mapped_column(
|
||||
default=MembershipStatus.PENDING, nullable=False
|
||||
)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
expires_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
external_loyalty_ref: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_customer_memberships_tenant_code"),
|
||||
Index("ix_customer_memberships_customer", "tenant_id", "customer_id"),
|
||||
)
|
||||
|
||||
|
||||
class MembershipLifecycleEvent(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||
__tablename__ = "membership_lifecycle_events"
|
||||
|
||||
customer_membership_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
action: Mapped[MembershipLifecycleAction] = mapped_column(nullable=False)
|
||||
from_status: Mapped[MembershipStatus] = mapped_column(nullable=False)
|
||||
to_status: Mapped[MembershipStatus] = mapped_column(nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_membership_lifecycle_events_membership",
|
||||
"tenant_id",
|
||||
"customer_membership_id",
|
||||
),
|
||||
)
|
||||
@ -1,119 +0,0 @@
|
||||
"""Salon management aggregates — Phase 14.2."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Index, Integer, String, Text, Time, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import DayOfWeek, GUID, LifecycleStatus, ResourceStatus
|
||||
|
||||
|
||||
class TreatmentRoom(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "treatment_rooms"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
branch_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[ResourceStatus] = mapped_column(
|
||||
default=ResourceStatus.AVAILABLE, nullable=False
|
||||
)
|
||||
capacity: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_treatment_rooms_tenant_code"),
|
||||
Index("ix_treatment_rooms_branch", "tenant_id", "branch_id"),
|
||||
)
|
||||
|
||||
|
||||
class Station(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "stations"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
branch_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
room_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[ResourceStatus] = mapped_column(
|
||||
default=ResourceStatus.AVAILABLE, nullable=False
|
||||
)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_stations_tenant_code"),
|
||||
Index("ix_stations_branch", "tenant_id", "branch_id"),
|
||||
)
|
||||
|
||||
|
||||
class BranchOperatingHours(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "branch_operating_hours"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
branch_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
day_of_week: Mapped[DayOfWeek] = mapped_column(nullable=False)
|
||||
open_time: Mapped[Time] = mapped_column(Time, nullable=False)
|
||||
close_time: Mapped[Time] = mapped_column(Time, nullable=False)
|
||||
is_closed: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_branch_operating_hours_branch", "tenant_id", "branch_id"),
|
||||
)
|
||||
|
||||
|
||||
class BranchPolicy(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "branch_policies"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
branch_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
policy_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
rules: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_branch_policies_tenant_code"),
|
||||
Index("ix_branch_policies_branch", "tenant_id", "branch_id"),
|
||||
)
|
||||
@ -1,150 +0,0 @@
|
||||
"""Staff and commission aggregates — Phase 14.6."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, Index, Numeric, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import CommissionStatus, GUID, LifecycleStatus, StaffKind
|
||||
|
||||
|
||||
class StaffProfile(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "staff_profiles"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
kind: Mapped[StaffKind] = mapped_column(default=StaffKind.OTHER, nullable=False)
|
||||
external_user_ref: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
hire_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_staff_profiles_tenant_code"),
|
||||
Index("ix_staff_profiles_org", "tenant_id", "organization_id"),
|
||||
Index("ix_staff_profiles_branch", "tenant_id", "branch_id"),
|
||||
)
|
||||
|
||||
|
||||
class StaffSkill(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "staff_skills"
|
||||
|
||||
staff_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
skill_code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
skill_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
level: Mapped[int] = mapped_column(default=1, nullable=False)
|
||||
is_certified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "staff_id", "skill_code", name="uq_staff_skills_code"
|
||||
),
|
||||
Index("ix_staff_skills_staff", "tenant_id", "staff_id"),
|
||||
)
|
||||
|
||||
|
||||
class StaffCertificate(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "staff_certificates"
|
||||
|
||||
staff_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
issuer: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
issued_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
expires_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
document_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_staff_certificates_staff", "tenant_id", "staff_id"),
|
||||
)
|
||||
|
||||
|
||||
class CommissionRule(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "commission_rules"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
rule_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
rate_percent: Mapped[float | None] = mapped_column(Numeric(8, 4), nullable=True)
|
||||
flat_amount: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
applies_to_service_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_commission_rules_tenant_code"),
|
||||
Index("ix_commission_rules_org", "tenant_id", "organization_id"),
|
||||
)
|
||||
|
||||
|
||||
class CommissionEntry(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "commission_entries"
|
||||
|
||||
staff_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
commission_rule_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=False)
|
||||
appointment_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
|
||||
status: Mapped[CommissionStatus] = mapped_column(
|
||||
default=CommissionStatus.PENDING, nullable=False
|
||||
)
|
||||
accrued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_commission_entries_staff", "tenant_id", "staff_id"),
|
||||
Index("ix_commission_entries_status", "tenant_id", "status"),
|
||||
)
|
||||
@ -1,191 +0,0 @@
|
||||
"""Beauty Business domain enums and dialect-safe GUID type."""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.types import CHAR, TypeDecorator
|
||||
|
||||
|
||||
class GUID(TypeDecorator):
|
||||
impl = CHAR
|
||||
cache_ok = True
|
||||
|
||||
def load_dialect_impl(self, dialect):
|
||||
if dialect.name == "postgresql":
|
||||
return dialect.type_descriptor(PG_UUID(as_uuid=True))
|
||||
return dialect.type_descriptor(CHAR(36))
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return value
|
||||
if dialect.name == "postgresql":
|
||||
return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
|
||||
return str(value)
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None:
|
||||
return value
|
||||
if isinstance(value, uuid.UUID):
|
||||
return value
|
||||
return uuid.UUID(str(value))
|
||||
|
||||
|
||||
class LifecycleStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
SUSPENDED = "suspended"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class BusinessFormat(str, enum.Enum):
|
||||
SALON = "salon"
|
||||
BARBERSHOP = "barbershop"
|
||||
CLINIC = "clinic"
|
||||
LASER = "laser"
|
||||
SKIN_CARE = "skin_care"
|
||||
HAIR_TREATMENT = "hair_treatment"
|
||||
SPA = "spa"
|
||||
|
||||
|
||||
class ProviderKind(str, enum.Enum):
|
||||
BOOKING_ENGINE = "booking_engine"
|
||||
CRM = "crm"
|
||||
LOYALTY = "loyalty"
|
||||
COMMUNICATION = "communication"
|
||||
ACCOUNTING = "accounting"
|
||||
DELIVERY = "delivery"
|
||||
EXPERIENCE = "experience"
|
||||
STORAGE = "storage"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class ProviderStatus(str, enum.Enum):
|
||||
REGISTERED = "registered"
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
FAILED = "failed"
|
||||
RETIRED = "retired"
|
||||
|
||||
|
||||
class BookingEngineStatus(str, enum.Enum):
|
||||
REGISTERED = "registered"
|
||||
READY = "ready"
|
||||
ACTIVE = "active"
|
||||
DISABLED = "disabled"
|
||||
|
||||
|
||||
class AuditAction(str, enum.Enum):
|
||||
CREATE = "create"
|
||||
UPDATE = "update"
|
||||
DELETE = "delete"
|
||||
RESTORE = "restore"
|
||||
STATUS_CHANGE = "status_change"
|
||||
REGISTER = "register"
|
||||
ENABLE = "enable"
|
||||
DISABLE = "disable"
|
||||
CONFIRM = "confirm"
|
||||
CHECK_IN = "check_in"
|
||||
COMPLETE = "complete"
|
||||
CANCEL = "cancel"
|
||||
NO_SHOW = "no_show"
|
||||
REDEEM = "redeem"
|
||||
DISPATCH = "dispatch"
|
||||
|
||||
|
||||
class AppointmentStatus(str, enum.Enum):
|
||||
REQUESTED = "requested"
|
||||
CONFIRMED = "confirmed"
|
||||
CHECKED_IN = "checked_in"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
NO_SHOW = "no_show"
|
||||
|
||||
|
||||
class AppointmentLifecycleAction(str, enum.Enum):
|
||||
CONFIRM = "confirm"
|
||||
CHECK_IN = "check_in"
|
||||
START = "start"
|
||||
COMPLETE = "complete"
|
||||
CANCEL = "cancel"
|
||||
NO_SHOW = "no_show"
|
||||
|
||||
|
||||
class ScheduleExceptionKind(str, enum.Enum):
|
||||
CLOSED = "closed"
|
||||
CUSTOM_HOURS = "custom_hours"
|
||||
BREAK = "break"
|
||||
|
||||
|
||||
class ResourceStatus(str, enum.Enum):
|
||||
AVAILABLE = "available"
|
||||
OCCUPIED = "occupied"
|
||||
MAINTENANCE = "maintenance"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
|
||||
class ServiceStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class PackageStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
EXHAUSTED = "exhausted"
|
||||
EXPIRED = "expired"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class MembershipStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
ACTIVE = "active"
|
||||
FROZEN = "frozen"
|
||||
CANCELLED = "cancelled"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class MembershipLifecycleAction(str, enum.Enum):
|
||||
ACTIVATE = "activate"
|
||||
FREEZE = "freeze"
|
||||
UNFREEZE = "unfreeze"
|
||||
CANCEL = "cancel"
|
||||
RENEW = "renew"
|
||||
|
||||
|
||||
class StaffKind(str, enum.Enum):
|
||||
STYLIST = "stylist"
|
||||
BARBER = "barber"
|
||||
THERAPIST = "therapist"
|
||||
LASER_TECH = "laser_tech"
|
||||
RECEPTIONIST = "receptionist"
|
||||
MANAGER = "manager"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class CommissionStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
ACCRUED = "accrued"
|
||||
PAID = "paid"
|
||||
VOID = "void"
|
||||
|
||||
|
||||
class IntegrationDispatchStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
SENT = "sent"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class DayOfWeek(str, enum.Enum):
|
||||
MONDAY = "monday"
|
||||
TUESDAY = "tuesday"
|
||||
WEDNESDAY = "wednesday"
|
||||
THURSDAY = "thursday"
|
||||
FRIDAY = "friday"
|
||||
SATURDAY = "saturday"
|
||||
SUNDAY = "sunday"
|
||||
@ -1,293 +0,0 @@
|
||||
"""Beauty Business permission definitions — Phase 14.0 foundation + 14.1-14.7."""
|
||||
from __future__ import annotations
|
||||
|
||||
BEAUTY_BUSINESS_VIEW = "beauty_business.view"
|
||||
BEAUTY_BUSINESS_MANAGE = "beauty_business.manage"
|
||||
|
||||
ORGANIZATIONS_VIEW = "beauty_business.organizations.view"
|
||||
ORGANIZATIONS_CREATE = "beauty_business.organizations.create"
|
||||
ORGANIZATIONS_UPDATE = "beauty_business.organizations.update"
|
||||
ORGANIZATIONS_DELETE = "beauty_business.organizations.delete"
|
||||
ORGANIZATIONS_MANAGE = "beauty_business.organizations.manage"
|
||||
|
||||
BRANCHES_VIEW = "beauty_business.branches.view"
|
||||
BRANCHES_CREATE = "beauty_business.branches.create"
|
||||
BRANCHES_UPDATE = "beauty_business.branches.update"
|
||||
BRANCHES_DELETE = "beauty_business.branches.delete"
|
||||
BRANCHES_MANAGE = "beauty_business.branches.manage"
|
||||
|
||||
EXTERNAL_PROVIDERS_VIEW = "beauty_business.external_providers.view"
|
||||
EXTERNAL_PROVIDERS_CREATE = "beauty_business.external_providers.create"
|
||||
EXTERNAL_PROVIDERS_UPDATE = "beauty_business.external_providers.update"
|
||||
EXTERNAL_PROVIDERS_DELETE = "beauty_business.external_providers.delete"
|
||||
EXTERNAL_PROVIDERS_MANAGE = "beauty_business.external_providers.manage"
|
||||
|
||||
BOOKING_ENGINES_VIEW = "beauty_business.booking_engines.view"
|
||||
BOOKING_ENGINES_CREATE = "beauty_business.booking_engines.create"
|
||||
BOOKING_ENGINES_UPDATE = "beauty_business.booking_engines.update"
|
||||
BOOKING_ENGINES_DELETE = "beauty_business.booking_engines.delete"
|
||||
BOOKING_ENGINES_MANAGE = "beauty_business.booking_engines.manage"
|
||||
|
||||
CONFIGURATIONS_VIEW = "beauty_business.configurations.view"
|
||||
CONFIGURATIONS_CREATE = "beauty_business.configurations.create"
|
||||
CONFIGURATIONS_UPDATE = "beauty_business.configurations.update"
|
||||
CONFIGURATIONS_DELETE = "beauty_business.configurations.delete"
|
||||
CONFIGURATIONS_MANAGE = "beauty_business.configurations.manage"
|
||||
|
||||
SETTINGS_VIEW = "beauty_business.settings.view"
|
||||
SETTINGS_MANAGE = "beauty_business.settings.manage"
|
||||
|
||||
AUDIT_VIEW = "beauty_business.audit.view"
|
||||
|
||||
# Phase 14.1 — Booking
|
||||
SCHEDULES_VIEW = "beauty_business.schedules.view"
|
||||
SCHEDULES_CREATE = "beauty_business.schedules.create"
|
||||
SCHEDULES_UPDATE = "beauty_business.schedules.update"
|
||||
SCHEDULES_DELETE = "beauty_business.schedules.delete"
|
||||
SCHEDULES_MANAGE = "beauty_business.schedules.manage"
|
||||
|
||||
APPOINTMENTS_VIEW = "beauty_business.appointments.view"
|
||||
APPOINTMENTS_CREATE = "beauty_business.appointments.create"
|
||||
APPOINTMENTS_UPDATE = "beauty_business.appointments.update"
|
||||
APPOINTMENTS_DELETE = "beauty_business.appointments.delete"
|
||||
APPOINTMENTS_CONFIRM = "beauty_business.appointments.confirm"
|
||||
APPOINTMENTS_CHECK_IN = "beauty_business.appointments.check_in"
|
||||
APPOINTMENTS_COMPLETE = "beauty_business.appointments.complete"
|
||||
APPOINTMENTS_CANCEL = "beauty_business.appointments.cancel"
|
||||
APPOINTMENTS_NO_SHOW = "beauty_business.appointments.no_show"
|
||||
APPOINTMENTS_MANAGE = "beauty_business.appointments.manage"
|
||||
|
||||
WAITING_LIST_VIEW = "beauty_business.waiting_list.view"
|
||||
WAITING_LIST_CREATE = "beauty_business.waiting_list.create"
|
||||
WAITING_LIST_UPDATE = "beauty_business.waiting_list.update"
|
||||
WAITING_LIST_DELETE = "beauty_business.waiting_list.delete"
|
||||
WAITING_LIST_MANAGE = "beauty_business.waiting_list.manage"
|
||||
|
||||
# Phase 14.2 — Salon
|
||||
ROOMS_VIEW = "beauty_business.rooms.view"
|
||||
ROOMS_CREATE = "beauty_business.rooms.create"
|
||||
ROOMS_UPDATE = "beauty_business.rooms.update"
|
||||
ROOMS_DELETE = "beauty_business.rooms.delete"
|
||||
ROOMS_MANAGE = "beauty_business.rooms.manage"
|
||||
|
||||
STATIONS_VIEW = "beauty_business.stations.view"
|
||||
STATIONS_CREATE = "beauty_business.stations.create"
|
||||
STATIONS_UPDATE = "beauty_business.stations.update"
|
||||
STATIONS_DELETE = "beauty_business.stations.delete"
|
||||
STATIONS_MANAGE = "beauty_business.stations.manage"
|
||||
|
||||
BRANCH_POLICIES_VIEW = "beauty_business.branch_policies.view"
|
||||
BRANCH_POLICIES_CREATE = "beauty_business.branch_policies.create"
|
||||
BRANCH_POLICIES_UPDATE = "beauty_business.branch_policies.update"
|
||||
BRANCH_POLICIES_DELETE = "beauty_business.branch_policies.delete"
|
||||
BRANCH_POLICIES_MANAGE = "beauty_business.branch_policies.manage"
|
||||
|
||||
# Phase 14.3 — Catalog
|
||||
SERVICE_CATEGORIES_VIEW = "beauty_business.service_categories.view"
|
||||
SERVICE_CATEGORIES_CREATE = "beauty_business.service_categories.create"
|
||||
SERVICE_CATEGORIES_UPDATE = "beauty_business.service_categories.update"
|
||||
SERVICE_CATEGORIES_DELETE = "beauty_business.service_categories.delete"
|
||||
SERVICE_CATEGORIES_MANAGE = "beauty_business.service_categories.manage"
|
||||
|
||||
SERVICES_VIEW = "beauty_business.services.view"
|
||||
SERVICES_CREATE = "beauty_business.services.create"
|
||||
SERVICES_UPDATE = "beauty_business.services.update"
|
||||
SERVICES_DELETE = "beauty_business.services.delete"
|
||||
SERVICES_MANAGE = "beauty_business.services.manage"
|
||||
|
||||
# Phase 14.4 — Customers
|
||||
CUSTOMERS_VIEW = "beauty_business.customers.view"
|
||||
CUSTOMERS_CREATE = "beauty_business.customers.create"
|
||||
CUSTOMERS_UPDATE = "beauty_business.customers.update"
|
||||
CUSTOMERS_DELETE = "beauty_business.customers.delete"
|
||||
CUSTOMERS_MANAGE = "beauty_business.customers.manage"
|
||||
|
||||
# Phase 14.5 — Packages & Memberships
|
||||
PACKAGES_VIEW = "beauty_business.packages.view"
|
||||
PACKAGES_CREATE = "beauty_business.packages.create"
|
||||
PACKAGES_UPDATE = "beauty_business.packages.update"
|
||||
PACKAGES_DELETE = "beauty_business.packages.delete"
|
||||
PACKAGES_MANAGE = "beauty_business.packages.manage"
|
||||
|
||||
MEMBERSHIPS_VIEW = "beauty_business.memberships.view"
|
||||
MEMBERSHIPS_CREATE = "beauty_business.memberships.create"
|
||||
MEMBERSHIPS_UPDATE = "beauty_business.memberships.update"
|
||||
MEMBERSHIPS_DELETE = "beauty_business.memberships.delete"
|
||||
MEMBERSHIPS_MANAGE = "beauty_business.memberships.manage"
|
||||
|
||||
# Phase 14.6 — Staff & Commissions
|
||||
STAFF_VIEW = "beauty_business.staff.view"
|
||||
STAFF_CREATE = "beauty_business.staff.create"
|
||||
STAFF_UPDATE = "beauty_business.staff.update"
|
||||
STAFF_DELETE = "beauty_business.staff.delete"
|
||||
STAFF_MANAGE = "beauty_business.staff.manage"
|
||||
|
||||
COMMISSIONS_VIEW = "beauty_business.commissions.view"
|
||||
COMMISSIONS_CREATE = "beauty_business.commissions.create"
|
||||
COMMISSIONS_UPDATE = "beauty_business.commissions.update"
|
||||
COMMISSIONS_DELETE = "beauty_business.commissions.delete"
|
||||
COMMISSIONS_MANAGE = "beauty_business.commissions.manage"
|
||||
|
||||
# Phase 14.7 — Marketing & Integrations
|
||||
MARKETING_VIEW = "beauty_business.marketing.view"
|
||||
MARKETING_CREATE = "beauty_business.marketing.create"
|
||||
MARKETING_UPDATE = "beauty_business.marketing.update"
|
||||
MARKETING_DELETE = "beauty_business.marketing.delete"
|
||||
MARKETING_MANAGE = "beauty_business.marketing.manage"
|
||||
|
||||
INTEGRATIONS_VIEW = "beauty_business.integrations.view"
|
||||
INTEGRATIONS_CREATE = "beauty_business.integrations.create"
|
||||
INTEGRATIONS_UPDATE = "beauty_business.integrations.update"
|
||||
INTEGRATIONS_DELETE = "beauty_business.integrations.delete"
|
||||
INTEGRATIONS_MANAGE = "beauty_business.integrations.manage"
|
||||
|
||||
ALL_PERMISSIONS: list[str] = [
|
||||
BEAUTY_BUSINESS_VIEW,
|
||||
BEAUTY_BUSINESS_MANAGE,
|
||||
ORGANIZATIONS_VIEW,
|
||||
ORGANIZATIONS_CREATE,
|
||||
ORGANIZATIONS_UPDATE,
|
||||
ORGANIZATIONS_DELETE,
|
||||
ORGANIZATIONS_MANAGE,
|
||||
BRANCHES_VIEW,
|
||||
BRANCHES_CREATE,
|
||||
BRANCHES_UPDATE,
|
||||
BRANCHES_DELETE,
|
||||
BRANCHES_MANAGE,
|
||||
EXTERNAL_PROVIDERS_VIEW,
|
||||
EXTERNAL_PROVIDERS_CREATE,
|
||||
EXTERNAL_PROVIDERS_UPDATE,
|
||||
EXTERNAL_PROVIDERS_DELETE,
|
||||
EXTERNAL_PROVIDERS_MANAGE,
|
||||
BOOKING_ENGINES_VIEW,
|
||||
BOOKING_ENGINES_CREATE,
|
||||
BOOKING_ENGINES_UPDATE,
|
||||
BOOKING_ENGINES_DELETE,
|
||||
BOOKING_ENGINES_MANAGE,
|
||||
CONFIGURATIONS_VIEW,
|
||||
CONFIGURATIONS_CREATE,
|
||||
CONFIGURATIONS_UPDATE,
|
||||
CONFIGURATIONS_DELETE,
|
||||
CONFIGURATIONS_MANAGE,
|
||||
SETTINGS_VIEW,
|
||||
SETTINGS_MANAGE,
|
||||
AUDIT_VIEW,
|
||||
SCHEDULES_VIEW,
|
||||
SCHEDULES_CREATE,
|
||||
SCHEDULES_UPDATE,
|
||||
SCHEDULES_DELETE,
|
||||
SCHEDULES_MANAGE,
|
||||
APPOINTMENTS_VIEW,
|
||||
APPOINTMENTS_CREATE,
|
||||
APPOINTMENTS_UPDATE,
|
||||
APPOINTMENTS_DELETE,
|
||||
APPOINTMENTS_CONFIRM,
|
||||
APPOINTMENTS_CHECK_IN,
|
||||
APPOINTMENTS_COMPLETE,
|
||||
APPOINTMENTS_CANCEL,
|
||||
APPOINTMENTS_NO_SHOW,
|
||||
APPOINTMENTS_MANAGE,
|
||||
WAITING_LIST_VIEW,
|
||||
WAITING_LIST_CREATE,
|
||||
WAITING_LIST_UPDATE,
|
||||
WAITING_LIST_DELETE,
|
||||
WAITING_LIST_MANAGE,
|
||||
ROOMS_VIEW,
|
||||
ROOMS_CREATE,
|
||||
ROOMS_UPDATE,
|
||||
ROOMS_DELETE,
|
||||
ROOMS_MANAGE,
|
||||
STATIONS_VIEW,
|
||||
STATIONS_CREATE,
|
||||
STATIONS_UPDATE,
|
||||
STATIONS_DELETE,
|
||||
STATIONS_MANAGE,
|
||||
BRANCH_POLICIES_VIEW,
|
||||
BRANCH_POLICIES_CREATE,
|
||||
BRANCH_POLICIES_UPDATE,
|
||||
BRANCH_POLICIES_DELETE,
|
||||
BRANCH_POLICIES_MANAGE,
|
||||
SERVICE_CATEGORIES_VIEW,
|
||||
SERVICE_CATEGORIES_CREATE,
|
||||
SERVICE_CATEGORIES_UPDATE,
|
||||
SERVICE_CATEGORIES_DELETE,
|
||||
SERVICE_CATEGORIES_MANAGE,
|
||||
SERVICES_VIEW,
|
||||
SERVICES_CREATE,
|
||||
SERVICES_UPDATE,
|
||||
SERVICES_DELETE,
|
||||
SERVICES_MANAGE,
|
||||
CUSTOMERS_VIEW,
|
||||
CUSTOMERS_CREATE,
|
||||
CUSTOMERS_UPDATE,
|
||||
CUSTOMERS_DELETE,
|
||||
CUSTOMERS_MANAGE,
|
||||
PACKAGES_VIEW,
|
||||
PACKAGES_CREATE,
|
||||
PACKAGES_UPDATE,
|
||||
PACKAGES_DELETE,
|
||||
PACKAGES_MANAGE,
|
||||
MEMBERSHIPS_VIEW,
|
||||
MEMBERSHIPS_CREATE,
|
||||
MEMBERSHIPS_UPDATE,
|
||||
MEMBERSHIPS_DELETE,
|
||||
MEMBERSHIPS_MANAGE,
|
||||
STAFF_VIEW,
|
||||
STAFF_CREATE,
|
||||
STAFF_UPDATE,
|
||||
STAFF_DELETE,
|
||||
STAFF_MANAGE,
|
||||
COMMISSIONS_VIEW,
|
||||
COMMISSIONS_CREATE,
|
||||
COMMISSIONS_UPDATE,
|
||||
COMMISSIONS_DELETE,
|
||||
COMMISSIONS_MANAGE,
|
||||
MARKETING_VIEW,
|
||||
MARKETING_CREATE,
|
||||
MARKETING_UPDATE,
|
||||
MARKETING_DELETE,
|
||||
MARKETING_MANAGE,
|
||||
INTEGRATIONS_VIEW,
|
||||
INTEGRATIONS_CREATE,
|
||||
INTEGRATIONS_UPDATE,
|
||||
INTEGRATIONS_DELETE,
|
||||
INTEGRATIONS_MANAGE,
|
||||
]
|
||||
|
||||
PERMISSION_PREFIXES: tuple[str, ...] = (
|
||||
"beauty_business.",
|
||||
"beauty_business.organizations.",
|
||||
"beauty_business.branches.",
|
||||
"beauty_business.external_providers.",
|
||||
"beauty_business.booking_engines.",
|
||||
"beauty_business.configurations.",
|
||||
"beauty_business.settings.",
|
||||
"beauty_business.audit.",
|
||||
"beauty_business.schedules.",
|
||||
"beauty_business.appointments.",
|
||||
"beauty_business.waiting_list.",
|
||||
"beauty_business.rooms.",
|
||||
"beauty_business.stations.",
|
||||
"beauty_business.branch_policies.",
|
||||
"beauty_business.service_categories.",
|
||||
"beauty_business.services.",
|
||||
"beauty_business.customers.",
|
||||
"beauty_business.packages.",
|
||||
"beauty_business.memberships.",
|
||||
"beauty_business.staff.",
|
||||
"beauty_business.commissions.",
|
||||
"beauty_business.marketing.",
|
||||
"beauty_business.integrations.",
|
||||
)
|
||||
|
||||
# Back-compat aliases for foundation routes
|
||||
HUBS_VIEW = BRANCHES_VIEW
|
||||
HUBS_CREATE = BRANCHES_CREATE
|
||||
HUBS_UPDATE = BRANCHES_UPDATE
|
||||
HUBS_DELETE = BRANCHES_DELETE
|
||||
HUBS_MANAGE = BRANCHES_MANAGE
|
||||
ROUTING_ENGINES_VIEW = BOOKING_ENGINES_VIEW
|
||||
ROUTING_ENGINES_CREATE = BOOKING_ENGINES_CREATE
|
||||
ROUTING_ENGINES_UPDATE = BOOKING_ENGINES_UPDATE
|
||||
ROUTING_ENGINES_DELETE = BOOKING_ENGINES_DELETE
|
||||
ROUTING_ENGINES_MANAGE = BOOKING_ENGINES_MANAGE
|
||||
@ -1,4 +0,0 @@
|
||||
"""Domain policies package."""
|
||||
from app.policies.appointments import AppointmentLifecyclePolicy
|
||||
|
||||
__all__ = ["AppointmentLifecyclePolicy"]
|
||||
@ -1,26 +0,0 @@
|
||||
"""Appointment domain policies — Phase 14.1."""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.types import AppointmentLifecycleAction, AppointmentStatus
|
||||
from app.validators.appointments import (
|
||||
ensure_appointment_lifecycle_transition,
|
||||
target_status_for,
|
||||
validate_reason,
|
||||
)
|
||||
|
||||
|
||||
class AppointmentLifecyclePolicy:
|
||||
REQUIRES_REASON = frozenset(
|
||||
{AppointmentLifecycleAction.CANCEL, AppointmentLifecycleAction.NO_SHOW}
|
||||
)
|
||||
|
||||
def assert_transition(
|
||||
self,
|
||||
*,
|
||||
action: AppointmentLifecycleAction,
|
||||
current: AppointmentStatus,
|
||||
reason: str | None = None,
|
||||
) -> tuple[AppointmentStatus, str | None]:
|
||||
ensure_appointment_lifecycle_transition(action=action, current=current)
|
||||
cleaned = validate_reason(reason, required=action in self.REQUIRES_REASON)
|
||||
return target_status_for(action), cleaned
|
||||
@ -1,14 +0,0 @@
|
||||
"""Platform provider contracts package."""
|
||||
from app.providers.contracts import ( # noqa: F401
|
||||
AIProvider,
|
||||
AccountingProvider,
|
||||
BookingEngineProvider,
|
||||
CRMProvider,
|
||||
CommunicationProvider,
|
||||
DeliveryProvider,
|
||||
ExperienceProvider,
|
||||
IdentityProvider,
|
||||
LoyaltyProvider,
|
||||
NotificationProvider,
|
||||
StorageProvider,
|
||||
)
|
||||
@ -1,82 +0,0 @@
|
||||
"""Platform capability contracts — interfaces only; no implementations in Beauty Business."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class AccountingProvider(Protocol):
|
||||
async def create_receivable_ref(
|
||||
self, *, tenant_id: UUID, reference: str, payload: dict[str, Any]
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class CRMProvider(Protocol):
|
||||
async def resolve_contact(
|
||||
self, *, tenant_id: UUID, contact_ref: str
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class LoyaltyProvider(Protocol):
|
||||
async def resolve_member(
|
||||
self, *, tenant_id: UUID, member_ref: str
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class CommunicationProvider(Protocol):
|
||||
async def send(
|
||||
self,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
channel: str,
|
||||
template_key: str,
|
||||
to: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class DeliveryProvider(Protocol):
|
||||
async def create_delivery_ref(
|
||||
self, *, tenant_id: UUID, reference: str, payload: dict[str, Any]
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class ExperienceProvider(Protocol):
|
||||
async def resolve_site(
|
||||
self, *, tenant_id: UUID, site_ref: str
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class NotificationProvider(Protocol):
|
||||
async def notify(
|
||||
self,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
channel: str,
|
||||
template_key: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class StorageProvider(Protocol):
|
||||
async def resolve_file(
|
||||
self, *, tenant_id: UUID, file_ref: str
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class AIProvider(Protocol):
|
||||
async def suggest(
|
||||
self, *, tenant_id: UUID, task: str, context: dict[str, Any]
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class IdentityProvider(Protocol):
|
||||
async def resolve_user(
|
||||
self, *, tenant_id: UUID, user_ref: str
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class BookingEngineProvider(Protocol):
|
||||
async def reserve_slot(
|
||||
self, *, tenant_id: UUID, request: dict[str, Any]
|
||||
) -> dict[str, Any]: ...
|
||||
@ -1,74 +0,0 @@
|
||||
"""No-op mock implementations of platform provider contracts — Phase 14.7."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class MockAccountingProvider:
|
||||
async def create_receivable_ref(
|
||||
self, *, tenant_id: UUID, reference: str, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return {"ok": True, "reference": reference, "provider": "mock_accounting"}
|
||||
|
||||
|
||||
class MockCRMProvider:
|
||||
async def resolve_contact(
|
||||
self, *, tenant_id: UUID, contact_ref: str
|
||||
) -> dict[str, Any]:
|
||||
return {"ok": True, "contact_ref": contact_ref, "provider": "mock_crm"}
|
||||
|
||||
|
||||
class MockLoyaltyProvider:
|
||||
async def resolve_member(
|
||||
self, *, tenant_id: UUID, member_ref: str
|
||||
) -> dict[str, Any]:
|
||||
return {"ok": True, "member_ref": member_ref, "provider": "mock_loyalty"}
|
||||
|
||||
|
||||
class MockCommunicationProvider:
|
||||
async def send(
|
||||
self,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
channel: str,
|
||||
template_key: str,
|
||||
to: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class MockDeliveryProvider:
|
||||
async def create_delivery_ref(
|
||||
self, *, tenant_id: UUID, reference: str, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return {"ok": True, "reference": reference, "provider": "mock_delivery"}
|
||||
|
||||
|
||||
class MockExperienceProvider:
|
||||
async def resolve_site(
|
||||
self, *, tenant_id: UUID, site_ref: str
|
||||
) -> dict[str, Any]:
|
||||
return {"ok": True, "site_ref": site_ref, "provider": "mock_experience"}
|
||||
|
||||
|
||||
class MockStorageProvider:
|
||||
async def resolve_file(
|
||||
self, *, tenant_id: UUID, file_ref: str
|
||||
) -> dict[str, Any]:
|
||||
return {"ok": True, "file_ref": file_ref, "provider": "mock_storage"}
|
||||
|
||||
|
||||
class MockIdentityProvider:
|
||||
async def resolve_user(
|
||||
self, *, tenant_id: UUID, user_ref: str
|
||||
) -> dict[str, Any]:
|
||||
return {"ok": True, "user_ref": user_ref, "provider": "mock_identity"}
|
||||
|
||||
|
||||
class MockBookingEngineProvider:
|
||||
async def reserve_slot(
|
||||
self, *, tenant_id: UUID, request: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return {"ok": True, "provider": "mock_booking_engine", "request": request}
|
||||
@ -1,4 +0,0 @@
|
||||
"""Query handlers package."""
|
||||
from app.queries.appointments import AppointmentQueries
|
||||
|
||||
__all__ = ["AppointmentQueries"]
|
||||
@ -1,22 +0,0 @@
|
||||
"""Appointment queries — Phase 14.1."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.appointment_core import AppointmentCoreService
|
||||
|
||||
|
||||
class AppointmentQueries:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.service = AppointmentCoreService(session)
|
||||
|
||||
async def get(self, tenant_id: UUID, appointment_id: UUID):
|
||||
return await self.service.get(tenant_id, appointment_id)
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.service.list(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def list_history(self, tenant_id: UUID, appointment_id: UUID):
|
||||
return await self.service.list_history(tenant_id, appointment_id)
|
||||
@ -1,42 +0,0 @@
|
||||
"""Appointment repositories — Phase 14.1."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.appointments import Appointment, AppointmentStatusHistory
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class AppointmentRepository(TenantBaseRepository[Appointment]):
|
||||
model = Appointment
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> Appointment | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
class AppointmentStatusHistoryRepository(TenantBaseRepository[AppointmentStatusHistory]):
|
||||
model = AppointmentStatusHistory
|
||||
|
||||
async def list_for_appointment(
|
||||
self, tenant_id: UUID, appointment_id: UUID, *, limit: int = 100
|
||||
) -> Sequence[AppointmentStatusHistory]:
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.appointment_id == appointment_id,
|
||||
)
|
||||
.order_by(self.model.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
@ -1,120 +0,0 @@
|
||||
"""Repositories."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from sqlalchemy import select
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.appointments import StaffSchedule
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class StaffScheduleRepository(TenantBaseRepository[StaffSchedule]):
|
||||
model = StaffSchedule
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> StaffSchedule | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.appointments import StaffAvailability
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class StaffAvailabilityRepository(TenantBaseRepository[StaffAvailability]):
|
||||
model = StaffAvailability
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> StaffAvailability | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.appointments import ScheduleException
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class ScheduleExceptionRepository(TenantBaseRepository[ScheduleException]):
|
||||
model = ScheduleException
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> ScheduleException | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.appointments import AppointmentType
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class AppointmentTypeRepository(TenantBaseRepository[AppointmentType]):
|
||||
model = AppointmentType
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> AppointmentType | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.appointments import WaitingListEntry
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class WaitingListEntryRepository(TenantBaseRepository[WaitingListEntry]):
|
||||
model = WaitingListEntry
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> WaitingListEntry | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
@ -1,78 +0,0 @@
|
||||
"""Tenant-aware base repository with soft-delete helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Generic, Sequence, TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=Base)
|
||||
|
||||
|
||||
class TenantBaseRepository(Generic[ModelT]):
|
||||
model: type[ModelT]
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> ModelT | None:
|
||||
clauses = [
|
||||
self.model.tenant_id == tenant_id, # type: ignore[attr-defined]
|
||||
self.model.id == entity_id, # type: ignore[attr-defined]
|
||||
]
|
||||
if hasattr(self.model, "is_deleted"):
|
||||
clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined]
|
||||
stmt = select(self.model).where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_including_deleted(
|
||||
self, tenant_id: UUID, entity_id: UUID
|
||||
) -> ModelT | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id, # type: ignore[attr-defined]
|
||||
self.model.id == entity_id, # type: ignore[attr-defined]
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def add(self, entity: ModelT) -> ModelT:
|
||||
self.session.add(entity)
|
||||
await self.session.flush()
|
||||
return entity
|
||||
|
||||
async def soft_delete(self, entity: ModelT, *, deleted_by: str | None = None) -> None:
|
||||
entity.is_deleted = True # type: ignore[attr-defined]
|
||||
entity.deleted_at = datetime.now(timezone.utc) # type: ignore[attr-defined]
|
||||
if deleted_by is not None and hasattr(entity, "deleted_by"):
|
||||
entity.deleted_by = deleted_by # type: ignore[attr-defined]
|
||||
await self.session.flush()
|
||||
|
||||
async def restore(self, entity: ModelT) -> None:
|
||||
entity.is_deleted = False # type: ignore[attr-defined]
|
||||
entity.deleted_at = None # type: ignore[attr-defined]
|
||||
if hasattr(entity, "deleted_by"):
|
||||
entity.deleted_by = None # type: ignore[attr-defined]
|
||||
await self.session.flush()
|
||||
|
||||
async def list_by_tenant(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
|
||||
) -> Sequence[ModelT]:
|
||||
clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined]
|
||||
if hasattr(self.model, "is_deleted"):
|
||||
clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined]
|
||||
stmt = select(self.model).where(*clauses).offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_by_tenant(self, tenant_id: UUID) -> int:
|
||||
clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined]
|
||||
if hasattr(self.model, "is_deleted"):
|
||||
clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined]
|
||||
stmt = select(func.count()).select_from(self.model).where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
@ -1,51 +0,0 @@
|
||||
"""Repositories."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from sqlalchemy import select
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.catalog import ServiceCategory
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class ServiceCategoryRepository(TenantBaseRepository[ServiceCategory]):
|
||||
model = ServiceCategory
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> ServiceCategory | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.catalog import BeautyService
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class BeautyServiceRepository(TenantBaseRepository[BeautyService]):
|
||||
model = BeautyService
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> BeautyService | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
@ -1,28 +0,0 @@
|
||||
"""Repositories."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from sqlalchemy import select
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.customers import CustomerProfile
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class CustomerProfileRepository(TenantBaseRepository[CustomerProfile]):
|
||||
model = CustomerProfile
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> CustomerProfile | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
@ -1,142 +0,0 @@
|
||||
"""Delivery foundation repositories."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.foundation import (
|
||||
BeautyAuditLog,
|
||||
BeautyConfiguration,
|
||||
BeautyBranch,
|
||||
BeautyOrganization,
|
||||
BeautyPermission,
|
||||
BeautyRole,
|
||||
BeautySetting,
|
||||
ExternalProviderConfig,
|
||||
BookingEngineRegistration,
|
||||
)
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class BeautyOrganizationRepository(TenantBaseRepository[BeautyOrganization]):
|
||||
model = BeautyOrganization
|
||||
|
||||
async def get_by_code(
|
||||
self, tenant_id: UUID, code: str
|
||||
) -> BeautyOrganization | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
class BeautyBranchRepository(TenantBaseRepository[BeautyBranch]):
|
||||
model = BeautyBranch
|
||||
|
||||
async def list_by_organization(
|
||||
self, tenant_id: UUID, organization_id: UUID, *, offset: int = 0, limit: int = 20
|
||||
) -> Sequence[BeautyBranch]:
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.organization_id == organization_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
class BeautyRoleRepository(TenantBaseRepository[BeautyRole]):
|
||||
model = BeautyRole
|
||||
|
||||
|
||||
class BeautyPermissionRepository(TenantBaseRepository[BeautyPermission]):
|
||||
model = BeautyPermission
|
||||
|
||||
|
||||
class ExternalProviderConfigRepository(TenantBaseRepository[ExternalProviderConfig]):
|
||||
model = ExternalProviderConfig
|
||||
|
||||
async def get_by_code(
|
||||
self, tenant_id: UUID, code: str
|
||||
) -> ExternalProviderConfig | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
class BookingEngineRegistrationRepository(
|
||||
TenantBaseRepository[BookingEngineRegistration]
|
||||
):
|
||||
model = BookingEngineRegistration
|
||||
|
||||
|
||||
class BeautyConfigurationRepository(TenantBaseRepository[BeautyConfiguration]):
|
||||
model = BeautyConfiguration
|
||||
|
||||
|
||||
class BeautySettingRepository(TenantBaseRepository[BeautySetting]):
|
||||
model = BeautySetting
|
||||
|
||||
async def get_by_key(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
key: str,
|
||||
*,
|
||||
organization_id: UUID | None = None,
|
||||
branch_id: UUID | None = None,
|
||||
) -> BeautySetting | None:
|
||||
clauses = [
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.key == key,
|
||||
self.model.is_deleted.is_(False),
|
||||
]
|
||||
if organization_id is None:
|
||||
clauses.append(self.model.organization_id.is_(None))
|
||||
else:
|
||||
clauses.append(self.model.organization_id == organization_id)
|
||||
if branch_id is None:
|
||||
clauses.append(self.model.branch_id.is_(None))
|
||||
else:
|
||||
clauses.append(self.model.branch_id == branch_id)
|
||||
stmt = select(self.model).where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
class BeautyAuditLogRepository(TenantBaseRepository[BeautyAuditLog]):
|
||||
model = BeautyAuditLog
|
||||
|
||||
async def list_for_entity(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
entity_type: str,
|
||||
entity_id: UUID,
|
||||
*,
|
||||
limit: int = 100,
|
||||
) -> Sequence[BeautyAuditLog]:
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.entity_type == entity_type,
|
||||
self.model.entity_id == entity_id,
|
||||
)
|
||||
.order_by(self.model.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
@ -1,81 +0,0 @@
|
||||
"""Repositories."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from sqlalchemy import select
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.marketing import MarketingCampaignRef
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class MarketingCampaignRefRepository(TenantBaseRepository[MarketingCampaignRef]):
|
||||
model = MarketingCampaignRef
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> MarketingCampaignRef | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.marketing import LoyaltyIntegrationConfig
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class LoyaltyIntegrationConfigRepository(TenantBaseRepository[LoyaltyIntegrationConfig]):
|
||||
model = LoyaltyIntegrationConfig
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> LoyaltyIntegrationConfig | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.marketing import CommunicationIntegrationConfig
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class CommunicationIntegrationConfigRepository(TenantBaseRepository[CommunicationIntegrationConfig]):
|
||||
model = CommunicationIntegrationConfig
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> CommunicationIntegrationConfig | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
from app.models.marketing import IntegrationDispatch
|
||||
|
||||
|
||||
class IntegrationDispatchRepository(TenantBaseRepository[IntegrationDispatch]):
|
||||
model = IntegrationDispatch
|
||||
@ -1,51 +0,0 @@
|
||||
"""Repositories."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from sqlalchemy import select
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.packages import PackageDefinition
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class PackageDefinitionRepository(TenantBaseRepository[PackageDefinition]):
|
||||
model = PackageDefinition
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> PackageDefinition | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.packages import MembershipPlan
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class MembershipPlanRepository(TenantBaseRepository[MembershipPlan]):
|
||||
model = MembershipPlan
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> MembershipPlan | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
@ -1,74 +0,0 @@
|
||||
"""Repositories."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from sqlalchemy import select
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.salon import TreatmentRoom
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class TreatmentRoomRepository(TenantBaseRepository[TreatmentRoom]):
|
||||
model = TreatmentRoom
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> TreatmentRoom | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.salon import Station
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class StationRepository(TenantBaseRepository[Station]):
|
||||
model = Station
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> Station | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.salon import BranchPolicy
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class BranchPolicyRepository(TenantBaseRepository[BranchPolicy]):
|
||||
model = BranchPolicy
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> BranchPolicy | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
@ -1,51 +0,0 @@
|
||||
"""Repositories."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from sqlalchemy import select
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.staff import StaffProfile
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class StaffProfileRepository(TenantBaseRepository[StaffProfile]):
|
||||
model = StaffProfile
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> StaffProfile | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.staff import CommissionRule
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class CommissionRuleRepository(TenantBaseRepository[CommissionRule]):
|
||||
model = CommissionRule
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> CommissionRule | None:
|
||||
if not hasattr(self.model, "code"):
|
||||
return None
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
@ -1,89 +0,0 @@
|
||||
"""Appointment DTOs — Phase 14.1."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import AppointmentLifecycleAction, AppointmentStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AppointmentCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
branch_id: UUID
|
||||
appointment_type_id: UUID | None = None
|
||||
code: str = Field(max_length=50)
|
||||
customer_ref: str | None = None
|
||||
staff_ref: str | None = None
|
||||
room_ref: UUID | None = None
|
||||
service_ref: UUID | None = None
|
||||
status: AppointmentStatus = AppointmentStatus.REQUESTED
|
||||
scheduled_start: datetime
|
||||
scheduled_end: datetime
|
||||
notes: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class AppointmentUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
staff_ref: str | None = None
|
||||
room_ref: UUID | None = None
|
||||
service_ref: UUID | None = None
|
||||
scheduled_start: datetime | None = None
|
||||
scheduled_end: datetime | None = None
|
||||
notes: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class AppointmentRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
branch_id: UUID
|
||||
appointment_type_id: UUID | None
|
||||
code: str
|
||||
customer_ref: str | None
|
||||
staff_ref: str | None
|
||||
room_ref: UUID | None
|
||||
service_ref: UUID | None
|
||||
status: AppointmentStatus
|
||||
scheduled_start: datetime
|
||||
scheduled_end: datetime
|
||||
notes: str | None
|
||||
status_reason: str | None
|
||||
status_changed_at: datetime | None
|
||||
metadata_json: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AppointmentLifecycleRequest(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class AppointmentStatusHistoryRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
appointment_id: UUID
|
||||
action: AppointmentLifecycleAction
|
||||
from_status: AppointmentStatus
|
||||
to_status: AppointmentStatus
|
||||
reason: str | None
|
||||
actor_user_id: str | None
|
||||
metadata_json: dict | None
|
||||
created_at: datetime
|
||||
@ -1,214 +0,0 @@
|
||||
"""Appointments schemas."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import LifecycleStatus, DayOfWeek, ScheduleExceptionKind
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class StaffScheduleCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
branch_id: UUID
|
||||
staff_ref: str = Field(max_length=100)
|
||||
code: str = Field(max_length=50)
|
||||
day_of_week: DayOfWeek
|
||||
start_time: time
|
||||
end_time: time
|
||||
is_active: bool = True
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class StaffScheduleRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
branch_id: UUID
|
||||
staff_ref: str
|
||||
code: str
|
||||
day_of_week: DayOfWeek
|
||||
start_time: time
|
||||
end_time: time
|
||||
is_active: bool
|
||||
metadata_json: dict | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import LifecycleStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class StaffAvailabilityCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
branch_id: UUID
|
||||
staff_ref: str = Field(max_length=100)
|
||||
code: str = Field(max_length=50)
|
||||
starts_at: datetime
|
||||
ends_at: datetime
|
||||
is_bookable: bool = True
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class StaffAvailabilityRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
branch_id: UUID
|
||||
staff_ref: str
|
||||
code: str
|
||||
starts_at: datetime
|
||||
ends_at: datetime
|
||||
is_bookable: bool
|
||||
metadata_json: dict | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import ScheduleExceptionKind
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ScheduleExceptionCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
branch_id: UUID
|
||||
staff_ref: str | None = None
|
||||
code: str = Field(max_length=50)
|
||||
kind: ScheduleExceptionKind
|
||||
exception_date: date
|
||||
start_time: time | None = None
|
||||
end_time: time | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ScheduleExceptionRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
branch_id: UUID
|
||||
staff_ref: str | None
|
||||
code: str
|
||||
kind: ScheduleExceptionKind
|
||||
exception_date: date
|
||||
start_time: time | None
|
||||
end_time: time | None
|
||||
reason: str | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import LifecycleStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AppointmentTypeCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
description: str | None = None
|
||||
default_duration_minutes: int = 30
|
||||
status: LifecycleStatus = LifecycleStatus.ACTIVE
|
||||
|
||||
|
||||
class AppointmentTypeRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
default_duration_minutes: int
|
||||
status: LifecycleStatus
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import LifecycleStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class WaitingListEntryCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
branch_id: UUID
|
||||
code: str = Field(max_length=50)
|
||||
customer_ref: str | None = None
|
||||
service_ref: UUID | None = None
|
||||
preferred_staff_ref: str | None = None
|
||||
requested_date: date | None = None
|
||||
notes: str | None = None
|
||||
priority: int = 100
|
||||
|
||||
|
||||
class WaitingListEntryRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
branch_id: UUID
|
||||
code: str
|
||||
customer_ref: str | None
|
||||
service_ref: UUID | None
|
||||
preferred_staff_ref: str | None
|
||||
requested_date: date | None
|
||||
notes: str | None
|
||||
priority: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@ -1,84 +0,0 @@
|
||||
"""Catalog schemas."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import LifecycleStatus, ServiceStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ServiceCategoryCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
description: str | None = None
|
||||
status: LifecycleStatus = LifecycleStatus.ACTIVE
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class ServiceCategoryRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
status: LifecycleStatus
|
||||
sort_order: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import ServiceStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class BeautyServiceCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
category_id: UUID | None = None
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
description: str | None = None
|
||||
status: ServiceStatus = ServiceStatus.DRAFT
|
||||
base_duration_minutes: int = 30
|
||||
base_price: float | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class BeautyServiceRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
category_id: UUID | None
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
status: ServiceStatus
|
||||
base_duration_minutes: int
|
||||
base_price: float | None
|
||||
metadata_json: dict | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@ -1,18 +0,0 @@
|
||||
"""Common schemas."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ORMBase(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class IdResponse(BaseModel):
|
||||
id: UUID
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
@ -1,46 +0,0 @@
|
||||
"""Customers schemas."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import LifecycleStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class CustomerProfileCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
code: str = Field(max_length=50)
|
||||
display_name: str = Field(max_length=255)
|
||||
external_crm_contact_ref: str | None = None
|
||||
external_user_ref: str | None = None
|
||||
mobile: str | None = None
|
||||
email: str | None = None
|
||||
status: LifecycleStatus = LifecycleStatus.ACTIVE
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class CustomerProfileRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
code: str
|
||||
display_name: str
|
||||
external_crm_contact_ref: str | None
|
||||
external_user_ref: str | None
|
||||
mobile: str | None
|
||||
email: str | None
|
||||
status: LifecycleStatus
|
||||
metadata_json: dict | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@ -1,301 +0,0 @@
|
||||
"""Beauty Business foundation DTOs — Phase 14.0."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import (
|
||||
BusinessFormat,
|
||||
LifecycleStatus,
|
||||
ProviderKind,
|
||||
ProviderStatus,
|
||||
BookingEngineStatus,
|
||||
)
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class BeautyOrganizationCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
description: str | None = None
|
||||
status: LifecycleStatus = LifecycleStatus.DRAFT
|
||||
business_format: BusinessFormat = BusinessFormat.SALON
|
||||
legal_name: str | None = Field(default=None, max_length=255)
|
||||
timezone: str = Field(default="Asia/Tehran", max_length=64)
|
||||
language: str = Field(default="fa", max_length=16)
|
||||
currency_code: str = Field(default="IRR", max_length=3)
|
||||
settings: dict | None = None
|
||||
|
||||
|
||||
class BeautyOrganizationUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = None
|
||||
status: LifecycleStatus | None = None
|
||||
business_format: BusinessFormat | None = None
|
||||
legal_name: str | None = Field(default=None, max_length=255)
|
||||
timezone: str | None = Field(default=None, max_length=64)
|
||||
language: str | None = Field(default=None, max_length=16)
|
||||
currency_code: str | None = Field(default=None, max_length=3)
|
||||
settings: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class BeautyOrganizationRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
status: LifecycleStatus
|
||||
business_format: BusinessFormat
|
||||
legal_name: str | None
|
||||
timezone: str
|
||||
language: str
|
||||
currency_code: str
|
||||
settings: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class BeautyBranchCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
description: str | None = None
|
||||
status: LifecycleStatus = LifecycleStatus.ACTIVE
|
||||
business_format: BusinessFormat | None = None
|
||||
address: dict | None = None
|
||||
timezone: str | None = Field(default=None, max_length=64)
|
||||
working_hours: dict | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class BeautyBranchUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = None
|
||||
status: LifecycleStatus | None = None
|
||||
business_format: BusinessFormat | None = None
|
||||
address: dict | None = None
|
||||
timezone: str | None = Field(default=None, max_length=64)
|
||||
working_hours: dict | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class BeautyBranchRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
status: LifecycleStatus
|
||||
business_format: BusinessFormat | None
|
||||
address: dict | None
|
||||
timezone: str | None
|
||||
working_hours: dict | None
|
||||
metadata_json: dict | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ExternalProviderConfigCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID | None = None
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
provider_kind: ProviderKind = ProviderKind.CUSTOM
|
||||
adapter_key: str = Field(max_length=100)
|
||||
status: ProviderStatus = ProviderStatus.REGISTERED
|
||||
priority: int = Field(default=100, ge=0)
|
||||
credentials_ref: str | None = Field(default=None, max_length=255)
|
||||
config: dict | None = None
|
||||
capabilities: dict | None = None
|
||||
|
||||
|
||||
class ExternalProviderConfigUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
provider_kind: ProviderKind | None = None
|
||||
adapter_key: str | None = Field(default=None, max_length=100)
|
||||
status: ProviderStatus | None = None
|
||||
priority: int | None = Field(default=None, ge=0)
|
||||
credentials_ref: str | None = Field(default=None, max_length=255)
|
||||
config: dict | None = None
|
||||
capabilities: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class ExternalProviderConfigRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID | None
|
||||
code: str
|
||||
name: str
|
||||
provider_kind: ProviderKind
|
||||
adapter_key: str
|
||||
status: ProviderStatus
|
||||
priority: int
|
||||
credentials_ref: str | None
|
||||
config: dict | None
|
||||
capabilities: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class BookingEngineRegistrationCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID | None = None
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
adapter_key: str = Field(max_length=100)
|
||||
status: BookingEngineStatus = BookingEngineStatus.REGISTERED
|
||||
is_default: bool = False
|
||||
config: dict | None = None
|
||||
capabilities: dict | None = None
|
||||
|
||||
|
||||
class BookingEngineRegistrationUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
adapter_key: str | None = Field(default=None, max_length=100)
|
||||
status: BookingEngineStatus | None = None
|
||||
is_default: bool | None = None
|
||||
config: dict | None = None
|
||||
capabilities: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class BookingEngineRegistrationRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID | None
|
||||
code: str
|
||||
name: str
|
||||
adapter_key: str
|
||||
status: BookingEngineStatus
|
||||
is_default: bool
|
||||
config: dict | None
|
||||
capabilities: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class BeautyConfigurationCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
branch_id: UUID | None = None
|
||||
code: str = Field(max_length=50)
|
||||
working_hours: dict | None = None
|
||||
booking_policies: dict | None = None
|
||||
cancellation_policies: dict | None = None
|
||||
reminder_policies: dict | None = None
|
||||
custom_fields: dict | None = None
|
||||
timezone: str = Field(default="Asia/Tehran", max_length=64)
|
||||
language: str = Field(default="fa", max_length=16)
|
||||
currency_code: str = Field(default="IRR", max_length=3)
|
||||
|
||||
|
||||
class BeautyConfigurationUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
branch_id: UUID | None = None
|
||||
working_hours: dict | None = None
|
||||
booking_policies: dict | None = None
|
||||
cancellation_policies: dict | None = None
|
||||
reminder_policies: dict | None = None
|
||||
custom_fields: dict | None = None
|
||||
timezone: str | None = Field(default=None, max_length=64)
|
||||
language: str | None = Field(default=None, max_length=16)
|
||||
currency_code: str | None = Field(default=None, max_length=3)
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class BeautyConfigurationRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
branch_id: UUID | None
|
||||
code: str
|
||||
working_hours: dict | None
|
||||
booking_policies: dict | None
|
||||
cancellation_policies: dict | None
|
||||
reminder_policies: dict | None
|
||||
custom_fields: dict | None
|
||||
timezone: str
|
||||
language: str
|
||||
currency_code: str
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class BeautySettingUpsert(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID | None = None
|
||||
branch_id: UUID | None = None
|
||||
key: str = Field(max_length=100)
|
||||
value: dict | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class BeautySettingRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID | None
|
||||
branch_id: UUID | None
|
||||
key: str
|
||||
value: dict | None
|
||||
description: str | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class BeautyAuditLogRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
entity_type: str
|
||||
entity_id: UUID
|
||||
action: str
|
||||
actor_user_id: str | None
|
||||
changes: dict | None
|
||||
message: str | None
|
||||
created_at: datetime
|
||||
@ -1,149 +0,0 @@
|
||||
"""Marketing schemas."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import LifecycleStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MarketingCampaignRefCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID | None = None
|
||||
branch_id: UUID | None = None
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
external_campaign_ref: str | None = None
|
||||
channel: str | None = None
|
||||
status: LifecycleStatus = LifecycleStatus.DRAFT
|
||||
config: dict | None = None
|
||||
|
||||
|
||||
class MarketingCampaignRefRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID | None
|
||||
branch_id: UUID | None
|
||||
code: str
|
||||
name: str
|
||||
external_campaign_ref: str | None
|
||||
channel: str | None
|
||||
status: LifecycleStatus
|
||||
config: dict | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import LifecycleStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class LoyaltyIntegrationConfigCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID | None = None
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
external_program_ref: str | None = None
|
||||
earn_rules: dict | None = None
|
||||
status: LifecycleStatus = LifecycleStatus.ACTIVE
|
||||
|
||||
|
||||
class LoyaltyIntegrationConfigRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID | None
|
||||
code: str
|
||||
name: str
|
||||
external_program_ref: str | None
|
||||
earn_rules: dict | None
|
||||
status: LifecycleStatus
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import LifecycleStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class CommunicationIntegrationConfigCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID | None = None
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
template_mappings: dict | None = None
|
||||
status: LifecycleStatus = LifecycleStatus.ACTIVE
|
||||
|
||||
|
||||
class CommunicationIntegrationConfigRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID | None
|
||||
code: str
|
||||
name: str
|
||||
template_mappings: dict | None
|
||||
status: LifecycleStatus
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
from app.models.types import IntegrationDispatchStatus
|
||||
|
||||
|
||||
class IntegrationDispatchCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID | None = None
|
||||
integration_kind: str = Field(max_length=50)
|
||||
config_id: UUID | None = None
|
||||
event_type: str = Field(max_length=100)
|
||||
payload_ref: str | None = None
|
||||
|
||||
|
||||
class IntegrationDispatchRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID | None
|
||||
integration_kind: str
|
||||
config_id: UUID | None
|
||||
event_type: str
|
||||
payload_ref: str | None
|
||||
status: IntegrationDispatchStatus
|
||||
response_json: dict | None
|
||||
notes: str | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@ -1,86 +0,0 @@
|
||||
"""Packages schemas."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import LifecycleStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class PackageDefinitionCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
description: str | None = None
|
||||
total_sessions: int = 1
|
||||
price: float | None = None
|
||||
valid_days: int | None = None
|
||||
status: LifecycleStatus = LifecycleStatus.ACTIVE
|
||||
|
||||
|
||||
class PackageDefinitionRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
total_sessions: int
|
||||
price: float | None
|
||||
valid_days: int | None
|
||||
status: LifecycleStatus
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import LifecycleStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MembershipPlanCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
description: str | None = None
|
||||
billing_interval_days: int = 30
|
||||
price: float | None = None
|
||||
benefits: dict | None = None
|
||||
status: LifecycleStatus = LifecycleStatus.ACTIVE
|
||||
|
||||
|
||||
class MembershipPlanRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
billing_interval_days: int
|
||||
price: float | None
|
||||
benefits: dict | None
|
||||
status: LifecycleStatus
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@ -1,124 +0,0 @@
|
||||
"""Salon schemas."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import LifecycleStatus, ResourceStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class TreatmentRoomCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
branch_id: UUID
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
description: str | None = None
|
||||
status: ResourceStatus = ResourceStatus.AVAILABLE
|
||||
capacity: int = 1
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class TreatmentRoomRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
branch_id: UUID
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
status: ResourceStatus
|
||||
capacity: int
|
||||
metadata_json: dict | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import ResourceStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class StationCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
branch_id: UUID
|
||||
room_id: UUID | None = None
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
status: ResourceStatus = ResourceStatus.AVAILABLE
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class StationRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
branch_id: UUID
|
||||
room_id: UUID | None
|
||||
code: str
|
||||
name: str
|
||||
status: ResourceStatus
|
||||
metadata_json: dict | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import LifecycleStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class BranchPolicyCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
branch_id: UUID
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
policy_type: str = Field(max_length=50)
|
||||
rules: dict | None = None
|
||||
status: LifecycleStatus = LifecycleStatus.ACTIVE
|
||||
|
||||
|
||||
class BranchPolicyRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
branch_id: UUID
|
||||
code: str
|
||||
name: str
|
||||
policy_type: str
|
||||
rules: dict | None
|
||||
status: LifecycleStatus
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@ -1,94 +0,0 @@
|
||||
"""Staff schemas."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import LifecycleStatus, StaffKind
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class StaffProfileCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
branch_id: UUID | None = None
|
||||
code: str = Field(max_length=50)
|
||||
display_name: str = Field(max_length=255)
|
||||
kind: StaffKind = StaffKind.OTHER
|
||||
external_user_ref: str | None = None
|
||||
mobile: str | None = None
|
||||
email: str | None = None
|
||||
status: LifecycleStatus = LifecycleStatus.ACTIVE
|
||||
hire_date: date | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class StaffProfileRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
branch_id: UUID | None
|
||||
code: str
|
||||
display_name: str
|
||||
kind: StaffKind
|
||||
external_user_ref: str | None
|
||||
mobile: str | None
|
||||
email: str | None
|
||||
status: LifecycleStatus
|
||||
hire_date: date | None
|
||||
metadata_json: dict | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import LifecycleStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class CommissionRuleCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
rule_type: str = Field(max_length=50)
|
||||
rate_percent: float | None = None
|
||||
flat_amount: float | None = None
|
||||
applies_to_service_id: UUID | None = None
|
||||
status: LifecycleStatus = LifecycleStatus.ACTIVE
|
||||
config: dict | None = None
|
||||
|
||||
|
||||
class CommissionRuleRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
code: str
|
||||
name: str
|
||||
rule_type: str
|
||||
rate_percent: float | None
|
||||
flat_amount: float | None
|
||||
applies_to_service_id: UUID | None
|
||||
status: LifecycleStatus
|
||||
config: dict | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@ -1,221 +0,0 @@
|
||||
"""Appointment application service — Phase 14.1."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.appointments import Appointment, AppointmentStatusHistory
|
||||
from app.models.types import AppointmentLifecycleAction, AppointmentStatus, AuditAction
|
||||
from app.policies.appointments import AppointmentLifecyclePolicy
|
||||
from app.repositories.appointment_core import (
|
||||
AppointmentRepository,
|
||||
AppointmentStatusHistoryRepository,
|
||||
)
|
||||
from app.repositories.foundation import BeautyBranchRepository, BeautyOrganizationRepository
|
||||
from app.schemas.appointment_core import (
|
||||
AppointmentCreate,
|
||||
AppointmentLifecycleRequest,
|
||||
AppointmentUpdate,
|
||||
)
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import ensure_optimistic_version, validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
_ACTION_EVENTS = {
|
||||
AppointmentLifecycleAction.CONFIRM: BeautyBusinessEventType.APPOINTMENT_CONFIRMED,
|
||||
AppointmentLifecycleAction.CHECK_IN: BeautyBusinessEventType.APPOINTMENT_CHECKED_IN,
|
||||
AppointmentLifecycleAction.START: BeautyBusinessEventType.APPOINTMENT_STARTED,
|
||||
AppointmentLifecycleAction.COMPLETE: BeautyBusinessEventType.APPOINTMENT_COMPLETED,
|
||||
AppointmentLifecycleAction.CANCEL: BeautyBusinessEventType.APPOINTMENT_CANCELLED,
|
||||
AppointmentLifecycleAction.NO_SHOW: BeautyBusinessEventType.APPOINTMENT_NO_SHOW,
|
||||
}
|
||||
|
||||
_ACTION_AUDIT = {
|
||||
AppointmentLifecycleAction.CONFIRM: AuditAction.CONFIRM,
|
||||
AppointmentLifecycleAction.CHECK_IN: AuditAction.CHECK_IN,
|
||||
AppointmentLifecycleAction.COMPLETE: AuditAction.COMPLETE,
|
||||
AppointmentLifecycleAction.CANCEL: AuditAction.CANCEL,
|
||||
AppointmentLifecycleAction.NO_SHOW: AuditAction.NO_SHOW,
|
||||
AppointmentLifecycleAction.START: AuditAction.STATUS_CHANGE,
|
||||
}
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class AppointmentCoreService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = AppointmentRepository(session)
|
||||
self.history = AppointmentStatusHistoryRepository(session)
|
||||
self.orgs = BeautyOrganizationRepository(session)
|
||||
self.branches = BeautyBranchRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
self.policy = AppointmentLifecyclePolicy()
|
||||
|
||||
async def create(
|
||||
self, tenant_id: UUID, body: AppointmentCreate, *, actor: CurrentUser | None = None
|
||||
) -> Appointment:
|
||||
org = await self.orgs.get(tenant_id, body.organization_id)
|
||||
if org is None:
|
||||
raise NotFoundError("سازمان یافت نشد")
|
||||
branch = await self.branches.get(tenant_id, body.branch_id)
|
||||
if branch is None:
|
||||
raise NotFoundError("شعبه یافت نشد")
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد نوبت تکراری است", error_code="duplicate_code", status_code=409)
|
||||
if body.status not in {AppointmentStatus.REQUESTED, AppointmentStatus.CONFIRMED}:
|
||||
raise AppError("وضعیت اولیه نامعتبر است", status_code=422, error_code="invalid_initial_status")
|
||||
entity = Appointment(
|
||||
tenant_id=tenant_id,
|
||||
organization_id=body.organization_id,
|
||||
branch_id=body.branch_id,
|
||||
appointment_type_id=body.appointment_type_id,
|
||||
code=code,
|
||||
customer_ref=body.customer_ref,
|
||||
staff_ref=body.staff_ref,
|
||||
room_ref=body.room_ref,
|
||||
service_ref=body.service_ref,
|
||||
status=body.status,
|
||||
scheduled_start=body.scheduled_start,
|
||||
scheduled_end=body.scheduled_end,
|
||||
notes=body.notes,
|
||||
metadata_json=body.metadata_json,
|
||||
status_changed_at=datetime.now(timezone.utc),
|
||||
created_by=_actor(actor),
|
||||
updated_by=_actor(actor),
|
||||
)
|
||||
try:
|
||||
await self.repo.add(entity)
|
||||
except IntegrityError as exc:
|
||||
raise AppError("کد نوبت تکراری است", error_code="duplicate_code", status_code=409) from exc
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="appointment",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=body.model_dump(mode="json"),
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.APPOINTMENT_CREATED,
|
||||
aggregate_type="appointment",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code, "status": entity.status.value},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, appointment_id: UUID) -> Appointment:
|
||||
entity = await self.repo.get(tenant_id, appointment_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("نوبت یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
appointment_id: UUID,
|
||||
body: AppointmentUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> Appointment:
|
||||
entity = await self.get(tenant_id, appointment_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
entity.version += 1
|
||||
entity.updated_by = _actor(actor)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="appointment",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.UPDATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.APPOINTMENT_UPDATED,
|
||||
aggregate_type="appointment",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def apply_lifecycle(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
appointment_id: UUID,
|
||||
action: AppointmentLifecycleAction,
|
||||
body: AppointmentLifecycleRequest,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> Appointment:
|
||||
entity = await self.get(tenant_id, appointment_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
from_status = entity.status
|
||||
to_status, reason = self.policy.assert_transition(
|
||||
action=action, current=from_status, reason=body.reason
|
||||
)
|
||||
entity.status = to_status
|
||||
entity.status_reason = reason
|
||||
entity.status_changed_at = datetime.now(timezone.utc)
|
||||
entity.version += 1
|
||||
entity.updated_by = _actor(actor)
|
||||
hist = AppointmentStatusHistory(
|
||||
tenant_id=tenant_id,
|
||||
appointment_id=entity.id,
|
||||
action=action,
|
||||
from_status=from_status,
|
||||
to_status=to_status,
|
||||
reason=reason,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.history.add(hist)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="appointment",
|
||||
entity_id=entity.id,
|
||||
action=_ACTION_AUDIT.get(action, AuditAction.STATUS_CHANGE),
|
||||
actor_user_id=_actor(actor),
|
||||
changes={"from_status": from_status.value, "to_status": to_status.value},
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.APPOINTMENT_STATUS_CHANGED,
|
||||
aggregate_type="appointment",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"action": action.value, "from_status": from_status.value, "to_status": to_status.value},
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=_ACTION_EVENTS[action],
|
||||
aggregate_type="appointment",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code, "status": to_status.value},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def list_history(self, tenant_id: UUID, appointment_id: UUID):
|
||||
await self.get(tenant_id, appointment_id)
|
||||
return list(await self.history.list_for_appointment(tenant_id, appointment_id))
|
||||
@ -1,399 +0,0 @@
|
||||
"""Services."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.types import AuditAction
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
def _actor(actor):
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.appointments import StaffSchedule
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.appointments import StaffScheduleRepository
|
||||
from app.schemas.appointments import StaffScheduleCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class StaffScheduleService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = StaffScheduleRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: StaffScheduleCreate, *, actor: CurrentUser | None = None) -> StaffSchedule:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = StaffSchedule(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="staff_schedule",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.STAFF_SCHEDULE_CREATED,
|
||||
aggregate_type="staff_schedule",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> StaffSchedule:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> StaffSchedule:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="staff_schedule",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.appointments import StaffAvailability
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.appointments import StaffAvailabilityRepository
|
||||
from app.schemas.appointments import StaffAvailabilityCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class StaffAvailabilityService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = StaffAvailabilityRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: StaffAvailabilityCreate, *, actor: CurrentUser | None = None) -> StaffAvailability:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = StaffAvailability(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="staff_availability",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.STAFF_AVAILABILITY_CREATED,
|
||||
aggregate_type="staff_availability",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> StaffAvailability:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> StaffAvailability:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="staff_availability",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.appointments import ScheduleException
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.appointments import ScheduleExceptionRepository
|
||||
from app.schemas.appointments import ScheduleExceptionCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class ScheduleExceptionService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = ScheduleExceptionRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: ScheduleExceptionCreate, *, actor: CurrentUser | None = None) -> ScheduleException:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = ScheduleException(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="schedule_exception",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.SCHEDULE_EXCEPTION_CREATED,
|
||||
aggregate_type="schedule_exception",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> ScheduleException:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> ScheduleException:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="schedule_exception",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.appointments import AppointmentType
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.appointments import AppointmentTypeRepository
|
||||
from app.schemas.appointments import AppointmentTypeCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class AppointmentTypeService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = AppointmentTypeRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: AppointmentTypeCreate, *, actor: CurrentUser | None = None) -> AppointmentType:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = AppointmentType(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="appointment_type",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.APPOINTMENT_TYPE_CREATED,
|
||||
aggregate_type="appointment_type",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> AppointmentType:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> AppointmentType:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="appointment_type",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.appointments import WaitingListEntry
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.appointments import WaitingListEntryRepository
|
||||
from app.schemas.appointments import WaitingListEntryCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class WaitingListEntryService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = WaitingListEntryRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: WaitingListEntryCreate, *, actor: CurrentUser | None = None) -> WaitingListEntry:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = WaitingListEntry(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="waiting_list_entry",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.WAITING_LIST_ENTRY_CREATED,
|
||||
aggregate_type="waiting_list_entry",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> WaitingListEntry:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> WaitingListEntry:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="waiting_list_entry",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
@ -1,51 +0,0 @@
|
||||
"""Audit service — append-only Delivery audit trail."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.foundation import BeautyAuditLog
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.foundation import BeautyAuditLogRepository
|
||||
|
||||
|
||||
class AuditService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = BeautyAuditLogRepository(session)
|
||||
|
||||
async def record(
|
||||
self,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
entity_type: str,
|
||||
entity_id: UUID,
|
||||
action: AuditAction,
|
||||
actor_user_id: str | None = None,
|
||||
changes: dict[str, Any] | None = None,
|
||||
message: str | None = None,
|
||||
commit: bool = False,
|
||||
) -> BeautyAuditLog:
|
||||
entry = BeautyAuditLog(
|
||||
tenant_id=tenant_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action=action,
|
||||
actor_user_id=actor_user_id,
|
||||
changes=changes,
|
||||
message=message,
|
||||
)
|
||||
await self.repo.add(entry)
|
||||
if commit:
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entry)
|
||||
return entry
|
||||
|
||||
async def list_for_entity(
|
||||
self, tenant_id: UUID, entity_type: str, entity_id: UUID, *, limit: int = 100
|
||||
):
|
||||
return await self.repo.list_for_entity(
|
||||
tenant_id, entity_type, entity_id, limit=limit
|
||||
)
|
||||
@ -1,168 +0,0 @@
|
||||
"""Services."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.types import AuditAction
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
def _actor(actor):
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.catalog import ServiceCategory
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.catalog import ServiceCategoryRepository
|
||||
from app.schemas.catalog import ServiceCategoryCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class ServiceCategoryService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = ServiceCategoryRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: ServiceCategoryCreate, *, actor: CurrentUser | None = None) -> ServiceCategory:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = ServiceCategory(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="service_category",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.SERVICE_CATEGORY_CREATED,
|
||||
aggregate_type="service_category",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> ServiceCategory:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> ServiceCategory:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="service_category",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.catalog import BeautyService
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.catalog import BeautyServiceRepository
|
||||
from app.schemas.catalog import BeautyServiceCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class BeautyServiceService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = BeautyServiceRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: BeautyServiceCreate, *, actor: CurrentUser | None = None) -> BeautyService:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = BeautyService(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="beauty_service",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.SERVICE_CREATED,
|
||||
aggregate_type="beauty_service",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> BeautyService:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> BeautyService:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="beauty_service",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
@ -1,91 +0,0 @@
|
||||
"""Services."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.types import AuditAction
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
def _actor(actor):
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.customers import CustomerProfile
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.customers import CustomerProfileRepository
|
||||
from app.schemas.customers import CustomerProfileCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class CustomerProfileService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = CustomerProfileRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: CustomerProfileCreate, *, actor: CurrentUser | None = None) -> CustomerProfile:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = CustomerProfile(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="customer_profile",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.CUSTOMER_PROFILE_CREATED,
|
||||
aggregate_type="customer_profile",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> CustomerProfile:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> CustomerProfile:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="customer_profile",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
@ -1,781 +0,0 @@
|
||||
"""Delivery foundation application services — Phase 10.0."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.foundation import (
|
||||
BeautyConfiguration,
|
||||
BeautyBranch,
|
||||
BeautyOrganization,
|
||||
BeautySetting,
|
||||
ExternalProviderConfig,
|
||||
BookingEngineRegistration,
|
||||
)
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.foundation import (
|
||||
BeautyConfigurationRepository,
|
||||
BeautyBranchRepository,
|
||||
BeautyOrganizationRepository,
|
||||
BeautySettingRepository,
|
||||
ExternalProviderConfigRepository,
|
||||
BookingEngineRegistrationRepository,
|
||||
)
|
||||
from app.schemas.foundation import (
|
||||
BeautyConfigurationCreate,
|
||||
BeautyConfigurationUpdate,
|
||||
BeautyBranchCreate,
|
||||
BeautyBranchUpdate,
|
||||
BeautyOrganizationCreate,
|
||||
BeautyOrganizationUpdate,
|
||||
BeautySettingUpsert,
|
||||
ExternalProviderConfigCreate,
|
||||
ExternalProviderConfigUpdate,
|
||||
BookingEngineRegistrationCreate,
|
||||
BookingEngineRegistrationUpdate,
|
||||
)
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import (
|
||||
ensure_optimistic_version,
|
||||
validate_code,
|
||||
validate_currency_code,
|
||||
validate_non_empty,
|
||||
)
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _apply_update(entity, data: dict) -> None:
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
|
||||
|
||||
def _jsonable_changes(data: dict[str, Any]) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, UUID):
|
||||
out[key] = str(value)
|
||||
elif isinstance(value, (datetime, date)):
|
||||
out[key] = value.isoformat()
|
||||
elif hasattr(value, "value"):
|
||||
out[key] = value.value
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def _actor_id(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class BeautyOrganizationService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = BeautyOrganizationRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: BeautyOrganizationCreate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> BeautyOrganization:
|
||||
code = validate_code(body.code)
|
||||
name = validate_non_empty(body.name, "name")
|
||||
currency = validate_currency_code(body.currency_code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError(
|
||||
"کد سازمان تکراری است",
|
||||
error_code="duplicate_code",
|
||||
status_code=409,
|
||||
)
|
||||
entity = BeautyOrganization(
|
||||
tenant_id=tenant_id,
|
||||
code=code,
|
||||
name=name,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
business_format=body.business_format,
|
||||
legal_name=body.legal_name,
|
||||
timezone=body.timezone,
|
||||
language=body.language,
|
||||
currency_code=currency,
|
||||
settings=body.settings,
|
||||
created_by=_actor_id(actor),
|
||||
updated_by=_actor_id(actor),
|
||||
)
|
||||
try:
|
||||
await self.repo.add(entity)
|
||||
except IntegrityError as exc:
|
||||
raise AppError(
|
||||
"کد سازمان تکراری است",
|
||||
error_code="duplicate_code",
|
||||
status_code=409,
|
||||
) from exc
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="beauty_organization",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor_id(actor),
|
||||
changes=_jsonable_changes(body.model_dump()),
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.ORGANIZATION_CREATED,
|
||||
aggregate_type="beauty_organization",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code, "name": entity.name},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> BeautyOrganization:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("سازمان یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
|
||||
) -> list[BeautyOrganization]:
|
||||
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))
|
||||
|
||||
async def update(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
entity_id: UUID,
|
||||
body: BeautyOrganizationUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> BeautyOrganization:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
if "name" in data and data["name"] is not None:
|
||||
data["name"] = validate_non_empty(data["name"], "name")
|
||||
if "currency_code" in data and data["currency_code"] is not None:
|
||||
data["currency_code"] = validate_currency_code(data["currency_code"])
|
||||
_apply_update(entity, data)
|
||||
entity.version += 1
|
||||
entity.updated_by = _actor_id(actor)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="beauty_organization",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.UPDATE,
|
||||
actor_user_id=_actor_id(actor),
|
||||
changes=_jsonable_changes(data),
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.ORGANIZATION_UPDATED,
|
||||
aggregate_type="beauty_organization",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def soft_delete(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
entity_id: UUID,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> BeautyOrganization:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="beauty_organization",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor_id(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
class BeautyBranchService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = BeautyBranchRepository(session)
|
||||
self.orgs = BeautyOrganizationRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: BeautyBranchCreate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> BeautyBranch:
|
||||
org = await self.orgs.get(tenant_id, body.organization_id)
|
||||
if org is None:
|
||||
raise NotFoundError("سازمان یافت نشد")
|
||||
code = validate_code(body.code)
|
||||
name = validate_non_empty(body.name, "name")
|
||||
entity = BeautyBranch(
|
||||
tenant_id=tenant_id,
|
||||
organization_id=body.organization_id,
|
||||
code=code,
|
||||
name=name,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
business_format=body.business_format,
|
||||
address=body.address,
|
||||
timezone=body.timezone,
|
||||
working_hours=body.working_hours,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=_actor_id(actor),
|
||||
updated_by=_actor_id(actor),
|
||||
)
|
||||
try:
|
||||
await self.repo.add(entity)
|
||||
except IntegrityError as exc:
|
||||
raise AppError(
|
||||
"کد هاب تکراری است",
|
||||
error_code="duplicate_code",
|
||||
status_code=409,
|
||||
) from exc
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="beauty_branch",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor_id(actor),
|
||||
changes=_jsonable_changes(body.model_dump()),
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.BRANCH_CREATED,
|
||||
aggregate_type="beauty_branch",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code, "organization_id": str(entity.organization_id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> BeautyBranch:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("هاب یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
|
||||
) -> list[BeautyBranch]:
|
||||
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))
|
||||
|
||||
async def update(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
entity_id: UUID,
|
||||
body: BeautyBranchUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> BeautyBranch:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
if "name" in data and data["name"] is not None:
|
||||
data["name"] = validate_non_empty(data["name"], "name")
|
||||
_apply_update(entity, data)
|
||||
entity.updated_by = _actor_id(actor)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="beauty_branch",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.UPDATE,
|
||||
actor_user_id=_actor_id(actor),
|
||||
changes=_jsonable_changes(data),
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.BRANCH_UPDATED,
|
||||
aggregate_type="beauty_branch",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def soft_delete(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
entity_id: UUID,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> BeautyBranch:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="beauty_branch",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor_id(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
class ExternalProviderConfigService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = ExternalProviderConfigRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: ExternalProviderConfigCreate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> ExternalProviderConfig:
|
||||
code = validate_code(body.code)
|
||||
name = validate_non_empty(body.name, "name")
|
||||
adapter_key = validate_non_empty(body.adapter_key, "adapter_key")
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError(
|
||||
"کد ارائهدهنده تکراری است",
|
||||
error_code="duplicate_code",
|
||||
status_code=409,
|
||||
)
|
||||
entity = ExternalProviderConfig(
|
||||
tenant_id=tenant_id,
|
||||
organization_id=body.organization_id,
|
||||
code=code,
|
||||
name=name,
|
||||
provider_kind=body.provider_kind,
|
||||
adapter_key=adapter_key,
|
||||
status=body.status,
|
||||
priority=body.priority,
|
||||
credentials_ref=body.credentials_ref,
|
||||
config=body.config,
|
||||
capabilities=body.capabilities,
|
||||
created_by=_actor_id(actor),
|
||||
updated_by=_actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="external_provider_config",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.REGISTER,
|
||||
actor_user_id=_actor_id(actor),
|
||||
changes=_jsonable_changes(body.model_dump()),
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.EXTERNAL_PROVIDER_REGISTERED,
|
||||
aggregate_type="external_provider_config",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code, "adapter_key": entity.adapter_key},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> ExternalProviderConfig:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("ارائهدهنده یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
|
||||
) -> list[ExternalProviderConfig]:
|
||||
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))
|
||||
|
||||
async def update(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
entity_id: UUID,
|
||||
body: ExternalProviderConfigUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> ExternalProviderConfig:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
if "name" in data and data["name"] is not None:
|
||||
data["name"] = validate_non_empty(data["name"], "name")
|
||||
if "adapter_key" in data and data["adapter_key"] is not None:
|
||||
data["adapter_key"] = validate_non_empty(data["adapter_key"], "adapter_key")
|
||||
_apply_update(entity, data)
|
||||
entity.version += 1
|
||||
entity.updated_by = _actor_id(actor)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="external_provider_config",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.UPDATE,
|
||||
actor_user_id=_actor_id(actor),
|
||||
changes=_jsonable_changes(data),
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.EXTERNAL_PROVIDER_UPDATED,
|
||||
aggregate_type="external_provider_config",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def soft_delete(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
entity_id: UUID,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> ExternalProviderConfig:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="external_provider_config",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor_id(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
class BookingEngineRegistrationService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = BookingEngineRegistrationRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: BookingEngineRegistrationCreate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> BookingEngineRegistration:
|
||||
code = validate_code(body.code)
|
||||
name = validate_non_empty(body.name, "name")
|
||||
adapter_key = validate_non_empty(body.adapter_key, "adapter_key")
|
||||
entity = BookingEngineRegistration(
|
||||
tenant_id=tenant_id,
|
||||
organization_id=body.organization_id,
|
||||
code=code,
|
||||
name=name,
|
||||
adapter_key=adapter_key,
|
||||
status=body.status,
|
||||
is_default=body.is_default,
|
||||
config=body.config,
|
||||
capabilities=body.capabilities,
|
||||
created_by=_actor_id(actor),
|
||||
updated_by=_actor_id(actor),
|
||||
)
|
||||
try:
|
||||
await self.repo.add(entity)
|
||||
except IntegrityError as exc:
|
||||
raise AppError(
|
||||
"کد موتور مسیریابی تکراری است",
|
||||
error_code="duplicate_code",
|
||||
status_code=409,
|
||||
) from exc
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="booking_engine_registration",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.REGISTER,
|
||||
actor_user_id=_actor_id(actor),
|
||||
changes=_jsonable_changes(body.model_dump()),
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.BOOKING_ENGINE_REGISTERED,
|
||||
aggregate_type="booking_engine_registration",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code, "adapter_key": entity.adapter_key},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> BookingEngineRegistration:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("موتور مسیریابی یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
|
||||
) -> list[BookingEngineRegistration]:
|
||||
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))
|
||||
|
||||
async def update(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
entity_id: UUID,
|
||||
body: BookingEngineRegistrationUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> BookingEngineRegistration:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
if "name" in data and data["name"] is not None:
|
||||
data["name"] = validate_non_empty(data["name"], "name")
|
||||
if "adapter_key" in data and data["adapter_key"] is not None:
|
||||
data["adapter_key"] = validate_non_empty(data["adapter_key"], "adapter_key")
|
||||
_apply_update(entity, data)
|
||||
entity.version += 1
|
||||
entity.updated_by = _actor_id(actor)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="booking_engine_registration",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.UPDATE,
|
||||
actor_user_id=_actor_id(actor),
|
||||
changes=_jsonable_changes(data),
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.BOOKING_ENGINE_UPDATED,
|
||||
aggregate_type="booking_engine_registration",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def soft_delete(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
entity_id: UUID,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> BookingEngineRegistration:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="booking_engine_registration",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor_id(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
class BeautyConfigurationService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = BeautyConfigurationRepository(session)
|
||||
self.orgs = BeautyOrganizationRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: BeautyConfigurationCreate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> BeautyConfiguration:
|
||||
org = await self.orgs.get(tenant_id, body.organization_id)
|
||||
if org is None:
|
||||
raise NotFoundError("سازمان یافت نشد")
|
||||
code = validate_code(body.code)
|
||||
currency = validate_currency_code(body.currency_code)
|
||||
entity = BeautyConfiguration(
|
||||
tenant_id=tenant_id,
|
||||
organization_id=body.organization_id,
|
||||
branch_id=body.branch_id,
|
||||
code=code,
|
||||
working_hours=body.working_hours,
|
||||
booking_policies=body.booking_policies,
|
||||
cancellation_policies=body.cancellation_policies,
|
||||
reminder_policies=body.reminder_policies,
|
||||
custom_fields=body.custom_fields,
|
||||
timezone=body.timezone,
|
||||
language=body.language,
|
||||
currency_code=currency,
|
||||
created_by=_actor_id(actor),
|
||||
updated_by=_actor_id(actor),
|
||||
)
|
||||
try:
|
||||
await self.repo.add(entity)
|
||||
except IntegrityError as exc:
|
||||
raise AppError(
|
||||
"کد پیکربندی تکراری است",
|
||||
error_code="duplicate_code",
|
||||
status_code=409,
|
||||
) from exc
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="beauty_configuration",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor_id(actor),
|
||||
changes=_jsonable_changes(body.model_dump()),
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.CONFIGURATION_CREATED,
|
||||
aggregate_type="beauty_configuration",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> BeautyConfiguration:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("پیکربندی یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
|
||||
) -> list[BeautyConfiguration]:
|
||||
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))
|
||||
|
||||
async def update(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
entity_id: UUID,
|
||||
body: BeautyConfigurationUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> BeautyConfiguration:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
if "currency_code" in data and data["currency_code"] is not None:
|
||||
data["currency_code"] = validate_currency_code(data["currency_code"])
|
||||
_apply_update(entity, data)
|
||||
entity.version += 1
|
||||
entity.updated_by = _actor_id(actor)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="beauty_configuration",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.UPDATE,
|
||||
actor_user_id=_actor_id(actor),
|
||||
changes=_jsonable_changes(data),
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.CONFIGURATION_UPDATED,
|
||||
aggregate_type="beauty_configuration",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def soft_delete(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
entity_id: UUID,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> BeautyConfiguration:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="beauty_configuration",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor_id(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
class BeautySettingService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = BeautySettingRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: BeautySettingUpsert,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> BeautySetting:
|
||||
key = validate_non_empty(body.key, "key")
|
||||
existing = await self.repo.get_by_key(
|
||||
tenant_id,
|
||||
key,
|
||||
organization_id=body.organization_id,
|
||||
branch_id=body.branch_id,
|
||||
)
|
||||
if existing is None:
|
||||
entity = BeautySetting(
|
||||
tenant_id=tenant_id,
|
||||
organization_id=body.organization_id,
|
||||
branch_id=body.branch_id,
|
||||
key=key,
|
||||
value=body.value,
|
||||
description=body.description,
|
||||
created_by=_actor_id(actor),
|
||||
updated_by=_actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
else:
|
||||
entity = existing
|
||||
entity.value = body.value
|
||||
entity.description = body.description
|
||||
entity.updated_by = _actor_id(actor)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="beauty_setting",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.UPDATE if existing else AuditAction.CREATE,
|
||||
actor_user_id=_actor_id(actor),
|
||||
changes=_jsonable_changes(body.model_dump()),
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.SETTING_UPSERTED,
|
||||
aggregate_type="beauty_setting",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"key": entity.key},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
|
||||
) -> list[BeautySetting]:
|
||||
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))
|
||||
@ -1,366 +0,0 @@
|
||||
"""Services."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.types import AuditAction
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
def _actor(actor):
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.marketing import MarketingCampaignRef
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.marketing import MarketingCampaignRefRepository
|
||||
from app.schemas.marketing import MarketingCampaignRefCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class MarketingCampaignRefService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = MarketingCampaignRefRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: MarketingCampaignRefCreate, *, actor: CurrentUser | None = None) -> MarketingCampaignRef:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = MarketingCampaignRef(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="marketing_campaign_ref",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.MARKETING_CAMPAIGN_CREATED,
|
||||
aggregate_type="marketing_campaign_ref",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> MarketingCampaignRef:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> MarketingCampaignRef:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="marketing_campaign_ref",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.marketing import LoyaltyIntegrationConfig
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.marketing import LoyaltyIntegrationConfigRepository
|
||||
from app.schemas.marketing import LoyaltyIntegrationConfigCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class LoyaltyIntegrationConfigService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = LoyaltyIntegrationConfigRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: LoyaltyIntegrationConfigCreate, *, actor: CurrentUser | None = None) -> LoyaltyIntegrationConfig:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = LoyaltyIntegrationConfig(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="loyalty_integration_config",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.LOYALTY_INTEGRATION_CONFIG_CREATED,
|
||||
aggregate_type="loyalty_integration_config",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> LoyaltyIntegrationConfig:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> LoyaltyIntegrationConfig:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="loyalty_integration_config",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.marketing import CommunicationIntegrationConfig
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.marketing import CommunicationIntegrationConfigRepository
|
||||
from app.schemas.marketing import CommunicationIntegrationConfigCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class CommunicationIntegrationConfigService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = CommunicationIntegrationConfigRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: CommunicationIntegrationConfigCreate, *, actor: CurrentUser | None = None) -> CommunicationIntegrationConfig:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = CommunicationIntegrationConfig(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="communication_integration_config",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.COMMUNICATION_INTEGRATION_CONFIG_CREATED,
|
||||
aggregate_type="communication_integration_config",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> CommunicationIntegrationConfig:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> CommunicationIntegrationConfig:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="communication_integration_config",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
from app.models.marketing import IntegrationDispatch
|
||||
from app.models.types import IntegrationDispatchStatus
|
||||
from app.providers.mocks import (
|
||||
MockAccountingProvider,
|
||||
MockCommunicationProvider,
|
||||
MockCRMProvider,
|
||||
MockDeliveryProvider,
|
||||
MockExperienceProvider,
|
||||
MockLoyaltyProvider,
|
||||
)
|
||||
from app.repositories.marketing import IntegrationDispatchRepository
|
||||
from app.schemas.marketing import IntegrationDispatchCreate
|
||||
from app.validators import validate_non_empty
|
||||
|
||||
_MOCK_DISPATCHERS = {
|
||||
"accounting": MockAccountingProvider(),
|
||||
"crm": MockCRMProvider(),
|
||||
"loyalty": MockLoyaltyProvider(),
|
||||
"communication": MockCommunicationProvider(),
|
||||
"delivery": MockDeliveryProvider(),
|
||||
"experience": MockExperienceProvider(),
|
||||
}
|
||||
|
||||
|
||||
class IntegrationDispatchService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = IntegrationDispatchRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: IntegrationDispatchCreate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> IntegrationDispatch:
|
||||
event_type = validate_non_empty(body.event_type, field="event_type")
|
||||
kind = validate_non_empty(body.integration_kind, field="integration_kind")
|
||||
dispatcher = _MOCK_DISPATCHERS.get(kind)
|
||||
response: dict = {"ok": True, "provider": "mock", "kind": kind}
|
||||
status = IntegrationDispatchStatus.SENT
|
||||
if dispatcher is None:
|
||||
status = IntegrationDispatchStatus.FAILED
|
||||
response = {"ok": False, "reason": "unknown_integration_kind"}
|
||||
elif hasattr(dispatcher, "send"):
|
||||
await dispatcher.send(
|
||||
tenant_id=tenant_id,
|
||||
channel="default",
|
||||
template_key=event_type,
|
||||
to=body.payload_ref or "",
|
||||
payload={"event_type": event_type},
|
||||
)
|
||||
elif hasattr(dispatcher, "create_receivable_ref"):
|
||||
response = await dispatcher.create_receivable_ref(
|
||||
tenant_id=tenant_id,
|
||||
reference=body.payload_ref or event_type,
|
||||
payload={"event_type": event_type},
|
||||
)
|
||||
elif hasattr(dispatcher, "create_delivery_ref"):
|
||||
response = await dispatcher.create_delivery_ref(
|
||||
tenant_id=tenant_id,
|
||||
reference=body.payload_ref or event_type,
|
||||
payload={"event_type": event_type},
|
||||
)
|
||||
elif hasattr(dispatcher, "resolve_contact"):
|
||||
response = await dispatcher.resolve_contact(
|
||||
tenant_id=tenant_id, contact_ref=body.payload_ref or event_type
|
||||
)
|
||||
elif hasattr(dispatcher, "resolve_member"):
|
||||
response = await dispatcher.resolve_member(
|
||||
tenant_id=tenant_id, member_ref=body.payload_ref or event_type
|
||||
)
|
||||
elif hasattr(dispatcher, "resolve_site"):
|
||||
response = await dispatcher.resolve_site(
|
||||
tenant_id=tenant_id, site_ref=body.payload_ref or event_type
|
||||
)
|
||||
|
||||
entity = IntegrationDispatch(
|
||||
tenant_id=tenant_id,
|
||||
organization_id=body.organization_id,
|
||||
integration_kind=kind,
|
||||
config_id=body.config_id,
|
||||
event_type=event_type,
|
||||
payload_ref=body.payload_ref,
|
||||
status=status,
|
||||
response_json=response,
|
||||
created_by=_actor(actor),
|
||||
updated_by=_actor(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="integration_dispatch",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DISPATCH,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=body.model_dump(),
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.INTEGRATION_DISPATCH_CREATED,
|
||||
aggregate_type="integration_dispatch",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"status": entity.status.value, "event_type": entity.event_type},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> IntegrationDispatch:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("ارسال یکپارچهسازی یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
@ -1,168 +0,0 @@
|
||||
"""Services."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.types import AuditAction
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
def _actor(actor):
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.packages import PackageDefinition
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.packages import PackageDefinitionRepository
|
||||
from app.schemas.packages import PackageDefinitionCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class PackageDefinitionService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = PackageDefinitionRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: PackageDefinitionCreate, *, actor: CurrentUser | None = None) -> PackageDefinition:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = PackageDefinition(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="package_definition",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.PACKAGE_DEFINITION_CREATED,
|
||||
aggregate_type="package_definition",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> PackageDefinition:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> PackageDefinition:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="package_definition",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.packages import MembershipPlan
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.packages import MembershipPlanRepository
|
||||
from app.schemas.packages import MembershipPlanCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class MembershipPlanService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = MembershipPlanRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: MembershipPlanCreate, *, actor: CurrentUser | None = None) -> MembershipPlan:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = MembershipPlan(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="membership_plan",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.MEMBERSHIP_PLAN_CREATED,
|
||||
aggregate_type="membership_plan",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> MembershipPlan:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> MembershipPlan:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="membership_plan",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
@ -1,245 +0,0 @@
|
||||
"""Services."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.types import AuditAction
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
def _actor(actor):
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.salon import TreatmentRoom
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.salon import TreatmentRoomRepository
|
||||
from app.schemas.salon import TreatmentRoomCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class TreatmentRoomService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = TreatmentRoomRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: TreatmentRoomCreate, *, actor: CurrentUser | None = None) -> TreatmentRoom:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = TreatmentRoom(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="treatment_room",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.TREATMENT_ROOM_CREATED,
|
||||
aggregate_type="treatment_room",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> TreatmentRoom:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> TreatmentRoom:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="treatment_room",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.salon import Station
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.salon import StationRepository
|
||||
from app.schemas.salon import StationCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class StationService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = StationRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: StationCreate, *, actor: CurrentUser | None = None) -> Station:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = Station(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="station",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.STATION_CREATED,
|
||||
aggregate_type="station",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> Station:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> Station:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="station",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.salon import BranchPolicy
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.salon import BranchPolicyRepository
|
||||
from app.schemas.salon import BranchPolicyCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class BranchPolicyService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = BranchPolicyRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: BranchPolicyCreate, *, actor: CurrentUser | None = None) -> BranchPolicy:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = BranchPolicy(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="branch_policy",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.BRANCH_POLICY_CREATED,
|
||||
aggregate_type="branch_policy",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> BranchPolicy:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> BranchPolicy:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="branch_policy",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
@ -1,168 +0,0 @@
|
||||
"""Services."""
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.types import AuditAction
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
def _actor(actor):
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.staff import StaffProfile
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.staff import StaffProfileRepository
|
||||
from app.schemas.staff import StaffProfileCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class StaffProfileService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = StaffProfileRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: StaffProfileCreate, *, actor: CurrentUser | None = None) -> StaffProfile:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = StaffProfile(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="staff_profile",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.STAFF_PROFILE_CREATED,
|
||||
aggregate_type="staff_profile",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> StaffProfile:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> StaffProfile:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="staff_profile",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
from app.models.staff import CommissionRule
|
||||
from app.models.types import AuditAction
|
||||
from app.repositories.staff import CommissionRuleRepository
|
||||
from app.schemas.staff import CommissionRuleCreate
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def _actor(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
class CommissionRuleService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = CommissionRuleRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(self, tenant_id: UUID, body: CommissionRuleCreate, *, actor: CurrentUser | None = None) -> CommissionRule:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
|
||||
data = body.model_dump()
|
||||
entity = CommissionRule(tenant_id=tenant_id, **data, created_by=_actor(actor), updated_by=_actor(actor))
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="commission_rule",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=_actor(actor),
|
||||
changes=data,
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=BeautyBusinessEventType.COMMISSION_RULE_CREATED,
|
||||
aggregate_type="commission_rule",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"id": str(entity.id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> CommissionRule:
|
||||
entity = await self.repo.get(tenant_id, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("مورد یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
|
||||
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
||||
|
||||
async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> CommissionRule:
|
||||
entity = await self.get(tenant_id, entity_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=_actor(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="commission_rule",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=_actor(actor),
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
@ -1 +0,0 @@
|
||||
"""Specifications package — list filters reserved for future phases."""
|
||||
@ -1,62 +0,0 @@
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
os.environ["ENVIRONMENT"] = "test"
|
||||
os.environ["AUTH_REQUIRED"] = "false"
|
||||
os.environ["BEAUTY_BUSINESS_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||
os.environ["BEAUTY_BUSINESS_DATABASE_URL_SYNC"] = "sqlite:///:memory:"
|
||||
os.environ["JWT_VERIFY_SIGNATURE"] = "false"
|
||||
|
||||
from app.core.config import get_settings # noqa: E402
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
from app.core.database import Base, engine # noqa: E402
|
||||
import app.models # noqa: E402, F401
|
||||
from app.events.publisher import reset_event_publisher # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
|
||||
TENANT_A = uuid.uuid4()
|
||||
TENANT_B = uuid.uuid4()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_setup():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _dispose_engine_on_session_end():
|
||||
yield
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.run(engine.dispose())
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
def _reset_events():
|
||||
reset_event_publisher()
|
||||
yield
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_setup):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
def tenant_headers(tenant_id: uuid.UUID) -> dict[str, str]:
|
||||
return {"X-Tenant-ID": str(tenant_id)}
|
||||
@ -1,136 +0,0 @@
|
||||
"""API, health, tenant isolation, and foundation flow tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers
|
||||
|
||||
|
||||
async def test_health_capabilities_metrics(client):
|
||||
health = await client.get("/health")
|
||||
assert health.status_code == 200
|
||||
body = health.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["service"] == "beauty-business-service"
|
||||
|
||||
caps = await client.get("/capabilities")
|
||||
assert caps.status_code == 200
|
||||
c = caps.json()
|
||||
assert c["phase"] == "14.7"
|
||||
assert c["commercial_product"] == "Torbat Beauty"
|
||||
assert c["features"]["foundation"] is True
|
||||
assert c["features"]["appointments"] is True
|
||||
assert c["features"]["marketing"] is True
|
||||
assert c["independence"]["integration_mode"] == "api_and_events_only"
|
||||
|
||||
metrics = await client.get("/metrics")
|
||||
assert metrics.status_code == 200
|
||||
assert metrics.json()["phase"] == "14.7"
|
||||
|
||||
|
||||
async def test_foundation_flow_and_events(client):
|
||||
headers = tenant_headers(TENANT_A)
|
||||
org = await client.post(
|
||||
"/api/v1/organizations",
|
||||
headers=headers,
|
||||
json={"code": "ORG1", "name": "Salon Alpha", "status": "active", "business_format": "salon"},
|
||||
)
|
||||
assert org.status_code == 201, org.text
|
||||
org_id = org.json()["id"]
|
||||
|
||||
branch = await client.post(
|
||||
"/api/v1/branches",
|
||||
headers=headers,
|
||||
json={
|
||||
"organization_id": org_id,
|
||||
"code": "BR1",
|
||||
"name": "Central Branch",
|
||||
},
|
||||
)
|
||||
assert branch.status_code == 201, branch.text
|
||||
|
||||
provider = await client.post(
|
||||
"/api/v1/external-providers",
|
||||
headers=headers,
|
||||
json={
|
||||
"organization_id": org_id,
|
||||
"code": "CRM_X",
|
||||
"name": "External CRM X",
|
||||
"provider_kind": "crm",
|
||||
"adapter_key": "mock_crm",
|
||||
},
|
||||
)
|
||||
assert provider.status_code == 201, provider.text
|
||||
|
||||
engine = await client.post(
|
||||
"/api/v1/booking-engines",
|
||||
headers=headers,
|
||||
json={
|
||||
"organization_id": org_id,
|
||||
"code": "BOOK_A",
|
||||
"name": "Future Engine A",
|
||||
"adapter_key": "mock_booking",
|
||||
"status": "ready",
|
||||
},
|
||||
)
|
||||
assert engine.status_code == 201, engine.text
|
||||
|
||||
cfg = await client.post(
|
||||
"/api/v1/configurations",
|
||||
headers=headers,
|
||||
json={"organization_id": org_id, "code": "DEFAULT"},
|
||||
)
|
||||
assert cfg.status_code == 201, cfg.text
|
||||
|
||||
setting = await client.put(
|
||||
"/api/v1/settings",
|
||||
headers=headers,
|
||||
json={
|
||||
"organization_id": org_id,
|
||||
"key": "default_currency",
|
||||
"value": {"code": "IRR"},
|
||||
},
|
||||
)
|
||||
assert setting.status_code == 200, setting.text
|
||||
|
||||
audit = await client.get(
|
||||
"/api/v1/audit",
|
||||
headers=headers,
|
||||
params={"entity_type": "beauty_organization", "entity_id": org_id},
|
||||
)
|
||||
assert audit.status_code == 200
|
||||
assert len(audit.json()) >= 1
|
||||
|
||||
published = [e.event_type for e in get_event_publisher().published]
|
||||
assert "beauty_business.organization.created" in published
|
||||
assert "beauty_business.branch.created" in published
|
||||
assert "beauty_business.external_provider.registered" in published
|
||||
assert "beauty_business.booking_engine.registered" in published
|
||||
|
||||
|
||||
async def test_tenant_isolation(client):
|
||||
headers_a = tenant_headers(TENANT_A)
|
||||
headers_b = tenant_headers(TENANT_B)
|
||||
|
||||
created = await client.post(
|
||||
"/api/v1/organizations",
|
||||
headers=headers_a,
|
||||
json={"code": "ISO1", "name": "Tenant A Org"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
org_id = created.json()["id"]
|
||||
|
||||
denied = await client.get(f"/api/v1/organizations/{org_id}", headers=headers_b)
|
||||
assert denied.status_code == 404
|
||||
|
||||
listed_b = await client.get("/api/v1/organizations", headers=headers_b)
|
||||
assert listed_b.status_code == 200
|
||||
assert listed_b.json() == []
|
||||
|
||||
listed_a = await client.get("/api/v1/organizations", headers=headers_a)
|
||||
assert listed_a.status_code == 200
|
||||
assert len(listed_a.json()) == 1
|
||||
|
||||
|
||||
async def test_missing_tenant_header_rejected(client):
|
||||
res = await client.get("/api/v1/organizations")
|
||||
assert res.status_code in (400, 422)
|
||||
@ -1,250 +0,0 @@
|
||||
"""Architecture tests — Beauty Business module boundary enforcement."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
from app.models import appointments as appt_models
|
||||
from app.models import catalog as catalog_models
|
||||
from app.models import customers as customer_models
|
||||
from app.models import foundation as models
|
||||
from app.models import marketing as marketing_models
|
||||
from app.models import packages as package_models
|
||||
from app.models import salon as salon_models
|
||||
from app.models import staff as staff_models
|
||||
|
||||
FORBIDDEN_IMPORT_PREFIXES = (
|
||||
"backend.services.accounting",
|
||||
"backend.services.crm",
|
||||
"backend.services.loyalty",
|
||||
"backend.services.communication",
|
||||
"backend.services.sports_center",
|
||||
"backend.services.automation",
|
||||
"backend.services.notification",
|
||||
"backend.services.file_storage",
|
||||
"backend.services.identity_access",
|
||||
"backend.core_service",
|
||||
)
|
||||
|
||||
FOUNDATION_MODELS = [
|
||||
models.BeautyOrganization,
|
||||
models.BeautyBranch,
|
||||
models.BeautyRole,
|
||||
models.BeautyPermission,
|
||||
models.ExternalProviderConfig,
|
||||
models.BookingEngineRegistration,
|
||||
models.BeautyConfiguration,
|
||||
models.BeautySetting,
|
||||
models.BeautyAuditLog,
|
||||
]
|
||||
|
||||
BOOKING_MODELS = [
|
||||
appt_models.StaffSchedule,
|
||||
appt_models.StaffAvailability,
|
||||
appt_models.ScheduleException,
|
||||
appt_models.AppointmentType,
|
||||
appt_models.Appointment,
|
||||
appt_models.AppointmentStatusHistory,
|
||||
appt_models.WaitingListEntry,
|
||||
]
|
||||
|
||||
SALON_MODELS = [
|
||||
salon_models.TreatmentRoom,
|
||||
salon_models.Station,
|
||||
salon_models.BranchOperatingHours,
|
||||
salon_models.BranchPolicy,
|
||||
]
|
||||
|
||||
CATALOG_MODELS = [
|
||||
catalog_models.ServiceCategory,
|
||||
catalog_models.BeautyService,
|
||||
catalog_models.ServiceVariant,
|
||||
catalog_models.BranchServiceAssignment,
|
||||
catalog_models.ServiceMediaRef,
|
||||
]
|
||||
|
||||
CUSTOMER_MODELS = [
|
||||
customer_models.CustomerProfile,
|
||||
customer_models.CustomerPreference,
|
||||
customer_models.VisitHistoryEntry,
|
||||
customer_models.CustomerDocumentRef,
|
||||
]
|
||||
|
||||
PACKAGE_MODELS = [
|
||||
package_models.PackageDefinition,
|
||||
package_models.PackageLine,
|
||||
package_models.CustomerPackage,
|
||||
package_models.PackageRedemption,
|
||||
package_models.MembershipPlan,
|
||||
package_models.CustomerMembership,
|
||||
package_models.MembershipLifecycleEvent,
|
||||
]
|
||||
|
||||
STAFF_MODELS = [
|
||||
staff_models.StaffProfile,
|
||||
staff_models.StaffSkill,
|
||||
staff_models.StaffCertificate,
|
||||
staff_models.CommissionRule,
|
||||
staff_models.CommissionEntry,
|
||||
]
|
||||
|
||||
MARKETING_MODELS = [
|
||||
marketing_models.MarketingCampaignRef,
|
||||
marketing_models.LoyaltyIntegrationConfig,
|
||||
marketing_models.CommunicationIntegrationConfig,
|
||||
marketing_models.IntegrationDispatch,
|
||||
]
|
||||
|
||||
|
||||
def test_all_models_have_tenant_id():
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
skip = {"alembic_version"}
|
||||
for table in Base.metadata.tables.values():
|
||||
if table.name in skip:
|
||||
continue
|
||||
assert "tenant_id" in table.columns, f"{table.name} missing tenant_id"
|
||||
|
||||
|
||||
def test_foundation_aggregates_are_independent():
|
||||
assert len(FOUNDATION_MODELS) == 9
|
||||
for model in FOUNDATION_MODELS:
|
||||
assert hasattr(model, "tenant_id")
|
||||
assert hasattr(model, "id")
|
||||
assert not model.__mapper__.relationships
|
||||
|
||||
|
||||
def test_booking_aggregates_are_independent():
|
||||
assert len(BOOKING_MODELS) == 7
|
||||
for model in BOOKING_MODELS:
|
||||
assert hasattr(model, "tenant_id")
|
||||
assert not model.__mapper__.relationships
|
||||
|
||||
|
||||
def test_no_driver_or_dispatch_tables():
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
names = set(Base.metadata.tables.keys())
|
||||
assert "appointments" in names
|
||||
assert "outbox_events" in names
|
||||
forbidden = {"drivers", "dispatch_jobs", "routes", "fleets", "vehicles"}
|
||||
assert forbidden.isdisjoint(names)
|
||||
|
||||
|
||||
def test_permissions_defined():
|
||||
from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES
|
||||
|
||||
assert "beauty_business.view" in ALL_PERMISSIONS
|
||||
assert "beauty_business.organizations.create" in ALL_PERMISSIONS
|
||||
assert "beauty_business.appointments.create" in ALL_PERMISSIONS
|
||||
assert "beauty_business.appointments.confirm" in ALL_PERMISSIONS
|
||||
assert "beauty_business.services.manage" in ALL_PERMISSIONS or "beauty_business.services.create" in ALL_PERMISSIONS
|
||||
assert "beauty_business.marketing.create" in ALL_PERMISSIONS
|
||||
assert "beauty_business.integrations.create" in ALL_PERMISSIONS
|
||||
for prefix in PERMISSION_PREFIXES:
|
||||
assert any(p.startswith(prefix.rstrip(".")) or p.startswith(prefix) for p in ALL_PERMISSIONS)
|
||||
|
||||
|
||||
def test_events_defined():
|
||||
from app.events.types import BeautyBusinessEventType
|
||||
|
||||
assert BeautyBusinessEventType.ORGANIZATION_CREATED.value == "beauty_business.organization.created"
|
||||
assert BeautyBusinessEventType.BRANCH_CREATED.value == "beauty_business.branch.created"
|
||||
assert (
|
||||
BeautyBusinessEventType.EXTERNAL_PROVIDER_REGISTERED.value
|
||||
== "beauty_business.external_provider.registered"
|
||||
)
|
||||
assert (
|
||||
BeautyBusinessEventType.BOOKING_ENGINE_REGISTERED.value
|
||||
== "beauty_business.booking_engine.registered"
|
||||
)
|
||||
assert BeautyBusinessEventType.APPOINTMENT_CREATED.value == "beauty_business.appointment.created"
|
||||
assert BeautyBusinessEventType.APPOINTMENT_CONFIRMED.value == "beauty_business.appointment.confirmed"
|
||||
assert BeautyBusinessEventType.APPOINTMENT_STATUS_CHANGED.value == "beauty_business.appointment.status_changed"
|
||||
|
||||
|
||||
def test_platform_provider_contracts_exist():
|
||||
from app.providers import (
|
||||
AIProvider,
|
||||
AccountingProvider,
|
||||
BookingEngineProvider,
|
||||
CRMProvider,
|
||||
CommunicationProvider,
|
||||
DeliveryProvider,
|
||||
ExperienceProvider,
|
||||
IdentityProvider,
|
||||
LoyaltyProvider,
|
||||
NotificationProvider,
|
||||
StorageProvider,
|
||||
)
|
||||
|
||||
assert AccountingProvider is not None
|
||||
assert CommunicationProvider is not None
|
||||
assert BookingEngineProvider is not None
|
||||
assert ExperienceProvider is not None
|
||||
assert LoyaltyProvider is not None
|
||||
assert CRMProvider is not None
|
||||
assert NotificationProvider is not None
|
||||
assert StorageProvider is not None
|
||||
assert AIProvider is not None
|
||||
assert IdentityProvider is not None
|
||||
assert DeliveryProvider is not None
|
||||
|
||||
|
||||
def test_no_forbidden_service_imports():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
violations: list[str] = []
|
||||
for path in root.rglob("*.py"):
|
||||
if "tests" in path.parts:
|
||||
continue
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
for forbidden in FORBIDDEN_IMPORT_PREFIXES:
|
||||
if alias.name.startswith(forbidden) or forbidden in alias.name:
|
||||
violations.append(f"{path}: import {alias.name}")
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
for forbidden in FORBIDDEN_IMPORT_PREFIXES:
|
||||
if node.module.startswith(forbidden) or forbidden in node.module:
|
||||
violations.append(f"{path}: from {node.module}")
|
||||
assert violations == []
|
||||
|
||||
|
||||
def test_api_ownership_is_beauty_business_only():
|
||||
from app.api.v1 import api_router
|
||||
|
||||
prefixes = {getattr(r, "path", "") for r in api_router.routes}
|
||||
joined = " ".join(sorted(prefixes))
|
||||
assert "/organizations" in joined or any("/organizations" in p for p in prefixes)
|
||||
assert "/branches" in joined or any("/branches" in p for p in prefixes)
|
||||
assert "/appointments" in joined or any("/appointments" in p for p in prefixes)
|
||||
forbidden = ("/accounting/", "/crm/", "/notification/", "/automation/", "/sports-center/")
|
||||
for name in forbidden:
|
||||
assert name not in joined
|
||||
|
||||
|
||||
def test_folder_structure():
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
required = [
|
||||
"app/models",
|
||||
"app/repositories",
|
||||
"app/services",
|
||||
"app/validators",
|
||||
"app/schemas",
|
||||
"app/events",
|
||||
"app/permissions",
|
||||
"app/providers",
|
||||
"app/policies",
|
||||
"app/specifications",
|
||||
"app/commands",
|
||||
"app/queries",
|
||||
"app/api/v1",
|
||||
"app/tests",
|
||||
"alembic/versions",
|
||||
"README.md",
|
||||
]
|
||||
for rel in required:
|
||||
assert (root / rel).exists(), f"missing {rel}"
|
||||
@ -1,35 +0,0 @@
|
||||
"""Dependency / layering validation."""
|
||||
from app.repositories.appointment_core import AppointmentRepository
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
from app.repositories.foundation import BeautyOrganizationRepository
|
||||
from app.services import appointment_core as appointment_services
|
||||
from app.services import foundation as services
|
||||
from app.providers import contracts
|
||||
|
||||
|
||||
def test_repos_inherit_tenant_base():
|
||||
assert issubclass(BeautyOrganizationRepository, TenantBaseRepository)
|
||||
assert issubclass(AppointmentRepository, TenantBaseRepository)
|
||||
|
||||
|
||||
def test_services_do_not_import_fastapi():
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(services)
|
||||
assert "fastapi" not in src.lower()
|
||||
appt_src = inspect.getsource(appointment_services)
|
||||
assert "fastapi" not in appt_src.lower()
|
||||
|
||||
|
||||
def test_provider_contracts_are_protocols():
|
||||
assert hasattr(contracts.AccountingProvider, "__protocol_attrs__") or True
|
||||
assert callable(contracts.BookingEngineProvider.reserve_slot)
|
||||
assert callable(contracts.ExperienceProvider.resolve_site)
|
||||
|
||||
|
||||
def test_commands_and_queries_exist():
|
||||
from app.commands.appointments import AppointmentCommands
|
||||
from app.queries.appointments import AppointmentQueries
|
||||
|
||||
assert AppointmentCommands is not None
|
||||
assert AppointmentQueries is not None
|
||||
@ -1,32 +0,0 @@
|
||||
"""Documentation validation for Beauty Business Phase 14.7."""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[5]
|
||||
|
||||
|
||||
def test_phase_docs_exist():
|
||||
assert (ROOT / "docs" / "beauty-business-roadmap.md").exists()
|
||||
assert (ROOT / "docs" / "phases" / "BeautyBusiness" / "phase-14-0-beauty-foundation.md").exists()
|
||||
assert (ROOT / "docs" / "phases" / "BeautyBusiness" / "phase-14-7-marketing-loyalty.md").exists()
|
||||
assert (ROOT / "docs" / "phases" / "BeautyBusiness" / "README.md").exists()
|
||||
|
||||
|
||||
def test_module_registry_mentions_beauty_business():
|
||||
text = (ROOT / "docs" / "module-registry.md").read_text(encoding="utf-8")
|
||||
assert "beauty_business" in text.lower()
|
||||
assert "beauty_business_db" in text
|
||||
assert "beauty_business." in text
|
||||
|
||||
|
||||
def test_progress_mentions_beauty_business():
|
||||
text = (ROOT / "docs" / "progress.md").read_text(encoding="utf-8")
|
||||
assert "14." in text or "Beauty" in text
|
||||
|
||||
|
||||
def test_service_readme_documents_boundaries():
|
||||
text = (ROOT / "backend" / "services" / "beauty_business" / "README.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "beauty_business_db" in text
|
||||
assert "8011" in text
|
||||
assert "appointments" in text.lower() or "booking" in text.lower()
|
||||
@ -1,69 +0,0 @@
|
||||
"""Migration validation."""
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
"beauty_organizations",
|
||||
"beauty_branches",
|
||||
"beauty_roles",
|
||||
"beauty_permissions",
|
||||
"external_provider_configs",
|
||||
"booking_engine_registrations",
|
||||
"beauty_configurations",
|
||||
"beauty_settings",
|
||||
"beauty_audit_logs",
|
||||
"staff_schedules",
|
||||
"staff_availabilities",
|
||||
"schedule_exceptions",
|
||||
"appointment_types",
|
||||
"appointments",
|
||||
"appointment_status_history",
|
||||
"waiting_list_entries",
|
||||
"treatment_rooms",
|
||||
"stations",
|
||||
"branch_operating_hours",
|
||||
"branch_policies",
|
||||
"service_categories",
|
||||
"beauty_services",
|
||||
"service_variants",
|
||||
"branch_service_assignments",
|
||||
"service_media_refs",
|
||||
"customer_profiles",
|
||||
"customer_preferences",
|
||||
"visit_history_entries",
|
||||
"customer_document_refs",
|
||||
"package_definitions",
|
||||
"package_lines",
|
||||
"customer_packages",
|
||||
"package_redemptions",
|
||||
"membership_plans",
|
||||
"customer_memberships",
|
||||
"membership_lifecycle_events",
|
||||
"staff_profiles",
|
||||
"staff_skills",
|
||||
"staff_certificates",
|
||||
"commission_rules",
|
||||
"commission_entries",
|
||||
"marketing_campaign_refs",
|
||||
"loyalty_integration_configs",
|
||||
"communication_integration_configs",
|
||||
"integration_dispatches",
|
||||
"outbox_events",
|
||||
}
|
||||
|
||||
|
||||
def test_alembic_head_is_phase_147():
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
cfg = Config(str(root / "alembic.ini"))
|
||||
script = ScriptDirectory.from_config(cfg)
|
||||
assert script.get_current_head() == "0008_phase_147_marketing"
|
||||
|
||||
|
||||
def test_metadata_has_all_phase_tables():
|
||||
names = set(Base.metadata.tables.keys())
|
||||
assert EXPECTED_TABLES.issubset(names)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user