Compare commits

..

No commits in common. "d579d0b142ab50f0681460e6bbdb7bcc439bc1da" and "579e0cdaf5082c2fb67e8b5c071bb81a9318ecf1" have entirely different histories.

384 changed files with 4772 additions and 25189 deletions

View File

@ -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

View File

@ -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
```

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1 +0,0 @@
__version__ = "0.14.7.0"

View File

@ -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

View File

@ -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

View File

@ -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"],
)

View File

@ -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)

View File

@ -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)

View File

@ -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
)

View File

@ -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
)

View File

@ -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)

View File

@ -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)

View File

@ -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
)

View File

@ -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)

View File

@ -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
)

View File

@ -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",
}

View File

@ -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)

View File

@ -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
)

View File

@ -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)

View File

@ -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),
}

View File

@ -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)

View File

@ -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
)

View File

@ -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)

View File

@ -1,4 +0,0 @@
"""Command handlers package."""
from app.commands.appointments import AppointmentCommands
__all__ = ["AppointmentCommands"]

View File

@ -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
)

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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,
)

View File

@ -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"),
)

View File

@ -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)

View File

@ -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"),
)

View File

@ -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"),
)

View File

@ -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",
),
)

View File

@ -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"),
)

View File

@ -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"),
)

View File

@ -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",
),
)

View File

@ -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"),
)

View File

@ -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"),
)

View File

@ -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"

View File

@ -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

View File

@ -1,4 +0,0 @@
"""Domain policies package."""
from app.policies.appointments import AppointmentLifecyclePolicy
__all__ = ["AppointmentLifecyclePolicy"]

View File

@ -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

View File

@ -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,
)

View File

@ -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]: ...

View File

@ -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}

View File

@ -1,4 +0,0 @@
"""Query handlers package."""
from app.queries.appointments import AppointmentQueries
__all__ = ["AppointmentQueries"]

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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())

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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
)

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1 +0,0 @@
"""Specifications package — list filters reserved for future phases."""

View File

@ -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)}

View File

@ -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)

View File

@ -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}"

View File

@ -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

View File

@ -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()

View File

@ -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