diff --git a/backend/services/beauty_business/Dockerfile.dev b/backend/services/beauty_business/Dockerfile.dev
new file mode 100644
index 0000000..a81fb75
--- /dev/null
+++ b/backend/services/beauty_business/Dockerfile.dev
@@ -0,0 +1,22 @@
+# 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
diff --git a/backend/services/beauty_business/README.md b/backend/services/beauty_business/README.md
new file mode 100644
index 0000000..664bd31
--- /dev/null
+++ b/backend/services/beauty_business/README.md
@@ -0,0 +1,67 @@
+# 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
+```
diff --git a/backend/services/beauty_business/alembic.ini b/backend/services/beauty_business/alembic.ini
new file mode 100644
index 0000000..545b0df
--- /dev/null
+++ b/backend/services/beauty_business/alembic.ini
@@ -0,0 +1,41 @@
+[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
diff --git a/backend/services/beauty_business/alembic/env.py b/backend/services/beauty_business/alembic/env.py
new file mode 100644
index 0000000..ba24b6c
--- /dev/null
+++ b/backend/services/beauty_business/alembic/env.py
@@ -0,0 +1,42 @@
+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()
diff --git a/backend/services/beauty_business/alembic/versions/0001_initial.py b/backend/services/beauty_business/alembic/versions/0001_initial.py
new file mode 100644
index 0000000..11ddabe
--- /dev/null
+++ b/backend/services/beauty_business/alembic/versions/0001_initial.py
@@ -0,0 +1,19 @@
+"""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)
diff --git a/backend/services/beauty_business/alembic/versions/0002_phase_141_booking.py b/backend/services/beauty_business/alembic/versions/0002_phase_141_booking.py
new file mode 100644
index 0000000..83c5709
--- /dev/null
+++ b/backend/services/beauty_business/alembic/versions/0002_phase_141_booking.py
@@ -0,0 +1,18 @@
+"""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
diff --git a/backend/services/beauty_business/alembic/versions/0003_phase_142_salon.py b/backend/services/beauty_business/alembic/versions/0003_phase_142_salon.py
new file mode 100644
index 0000000..0d407c5
--- /dev/null
+++ b/backend/services/beauty_business/alembic/versions/0003_phase_142_salon.py
@@ -0,0 +1,18 @@
+"""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
diff --git a/backend/services/beauty_business/alembic/versions/0004_phase_143_catalog.py b/backend/services/beauty_business/alembic/versions/0004_phase_143_catalog.py
new file mode 100644
index 0000000..2cce4ad
--- /dev/null
+++ b/backend/services/beauty_business/alembic/versions/0004_phase_143_catalog.py
@@ -0,0 +1,18 @@
+"""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
diff --git a/backend/services/beauty_business/alembic/versions/0005_phase_144_customers.py b/backend/services/beauty_business/alembic/versions/0005_phase_144_customers.py
new file mode 100644
index 0000000..0434908
--- /dev/null
+++ b/backend/services/beauty_business/alembic/versions/0005_phase_144_customers.py
@@ -0,0 +1,18 @@
+"""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
diff --git a/backend/services/beauty_business/alembic/versions/0006_phase_145_packages.py b/backend/services/beauty_business/alembic/versions/0006_phase_145_packages.py
new file mode 100644
index 0000000..5854b24
--- /dev/null
+++ b/backend/services/beauty_business/alembic/versions/0006_phase_145_packages.py
@@ -0,0 +1,18 @@
+"""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
diff --git a/backend/services/beauty_business/alembic/versions/0007_phase_146_staff.py b/backend/services/beauty_business/alembic/versions/0007_phase_146_staff.py
new file mode 100644
index 0000000..08f9e4f
--- /dev/null
+++ b/backend/services/beauty_business/alembic/versions/0007_phase_146_staff.py
@@ -0,0 +1,18 @@
+"""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
diff --git a/backend/services/beauty_business/alembic/versions/0008_phase_147_marketing.py b/backend/services/beauty_business/alembic/versions/0008_phase_147_marketing.py
new file mode 100644
index 0000000..f94931f
--- /dev/null
+++ b/backend/services/beauty_business/alembic/versions/0008_phase_147_marketing.py
@@ -0,0 +1,18 @@
+"""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
diff --git a/backend/services/beauty_business/app/__init__.py b/backend/services/beauty_business/app/__init__.py
new file mode 100644
index 0000000..be4be9b
--- /dev/null
+++ b/backend/services/beauty_business/app/__init__.py
@@ -0,0 +1 @@
+__version__ = "0.14.7.0"
diff --git a/backend/services/beauty_business/app/api/deps.py b/backend/services/beauty_business/app/api/deps.py
new file mode 100644
index 0000000..56d4194
--- /dev/null
+++ b/backend/services/beauty_business/app/api/deps.py
@@ -0,0 +1,39 @@
+"""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
diff --git a/backend/services/beauty_business/app/api/permissions.py b/backend/services/beauty_business/app/api/permissions.py
new file mode 100644
index 0000000..3b5cc46
--- /dev/null
+++ b/backend/services/beauty_business/app/api/permissions.py
@@ -0,0 +1,48 @@
+"""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
diff --git a/backend/services/beauty_business/app/api/v1/__init__.py b/backend/services/beauty_business/app/api/v1/__init__.py
new file mode 100644
index 0000000..306dfbf
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/__init__.py
@@ -0,0 +1,118 @@
+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"],
+)
diff --git a/backend/services/beauty_business/app/api/v1/appointment_core.py b/backend/services/beauty_business/app/api/v1/appointment_core.py
new file mode 100644
index 0000000..4902bdf
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/appointment_core.py
@@ -0,0 +1,70 @@
+"""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)
diff --git a/backend/services/beauty_business/app/api/v1/appointments.py b/backend/services/beauty_business/app/api/v1/appointments.py
new file mode 100644
index 0000000..00ed57d
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/appointments.py
@@ -0,0 +1,98 @@
+"""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)
diff --git a/backend/services/beauty_business/app/api/v1/audit.py b/backend/services/beauty_business/app/api/v1/audit.py
new file mode 100644
index 0000000..ff66628
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/audit.py
@@ -0,0 +1,30 @@
+"""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
+ )
diff --git a/backend/services/beauty_business/app/api/v1/booking_engines.py b/backend/services/beauty_business/app/api/v1/booking_engines.py
new file mode 100644
index 0000000..a73971b
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/booking_engines.py
@@ -0,0 +1,83 @@
+"""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
+ )
diff --git a/backend/services/beauty_business/app/api/v1/branches.py b/backend/services/beauty_business/app/api/v1/branches.py
new file mode 100644
index 0000000..105e37f
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/branches.py
@@ -0,0 +1,70 @@
+"""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)
diff --git a/backend/services/beauty_business/app/api/v1/catalog.py b/backend/services/beauty_business/app/api/v1/catalog.py
new file mode 100644
index 0000000..bc250a2
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/catalog.py
@@ -0,0 +1,46 @@
+"""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)
diff --git a/backend/services/beauty_business/app/api/v1/configurations.py b/backend/services/beauty_business/app/api/v1/configurations.py
new file mode 100644
index 0000000..d220920
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/configurations.py
@@ -0,0 +1,83 @@
+"""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
+ )
diff --git a/backend/services/beauty_business/app/api/v1/customers.py b/backend/services/beauty_business/app/api/v1/customers.py
new file mode 100644
index 0000000..c9086c3
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/customers.py
@@ -0,0 +1,28 @@
+"""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)
diff --git a/backend/services/beauty_business/app/api/v1/external_providers.py b/backend/services/beauty_business/app/api/v1/external_providers.py
new file mode 100644
index 0000000..536b3ed
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/external_providers.py
@@ -0,0 +1,83 @@
+"""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
+ )
diff --git a/backend/services/beauty_business/app/api/v1/health.py b/backend/services/beauty_business/app/api/v1/health.py
new file mode 100644
index 0000000..687d5b6
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/health.py
@@ -0,0 +1,102 @@
+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",
+ }
diff --git a/backend/services/beauty_business/app/api/v1/marketing.py b/backend/services/beauty_business/app/api/v1/marketing.py
new file mode 100644
index 0000000..74854ef
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/marketing.py
@@ -0,0 +1,101 @@
+"""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)
diff --git a/backend/services/beauty_business/app/api/v1/organizations.py b/backend/services/beauty_business/app/api/v1/organizations.py
new file mode 100644
index 0000000..c9d9706
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/organizations.py
@@ -0,0 +1,83 @@
+"""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
+ )
diff --git a/backend/services/beauty_business/app/api/v1/packages.py b/backend/services/beauty_business/app/api/v1/packages.py
new file mode 100644
index 0000000..460ac69
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/packages.py
@@ -0,0 +1,46 @@
+"""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)
diff --git a/backend/services/beauty_business/app/api/v1/permissions.py b/backend/services/beauty_business/app/api/v1/permissions.py
new file mode 100644
index 0000000..0e630b3
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/permissions.py
@@ -0,0 +1,27 @@
+"""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),
+ }
diff --git a/backend/services/beauty_business/app/api/v1/salon.py b/backend/services/beauty_business/app/api/v1/salon.py
new file mode 100644
index 0000000..45754b8
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/salon.py
@@ -0,0 +1,64 @@
+"""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)
diff --git a/backend/services/beauty_business/app/api/v1/settings.py b/backend/services/beauty_business/app/api/v1/settings.py
new file mode 100644
index 0000000..ed0bc54
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/settings.py
@@ -0,0 +1,39 @@
+"""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
+ )
diff --git a/backend/services/beauty_business/app/api/v1/staff.py b/backend/services/beauty_business/app/api/v1/staff.py
new file mode 100644
index 0000000..66788be
--- /dev/null
+++ b/backend/services/beauty_business/app/api/v1/staff.py
@@ -0,0 +1,46 @@
+"""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)
diff --git a/backend/services/beauty_business/app/commands/__init__.py b/backend/services/beauty_business/app/commands/__init__.py
new file mode 100644
index 0000000..d910c7e
--- /dev/null
+++ b/backend/services/beauty_business/app/commands/__init__.py
@@ -0,0 +1,4 @@
+"""Command handlers package."""
+from app.commands.appointments import AppointmentCommands
+
+__all__ = ["AppointmentCommands"]
diff --git a/backend/services/beauty_business/app/commands/appointments.py b/backend/services/beauty_business/app/commands/appointments.py
new file mode 100644
index 0000000..bf5b0dc
--- /dev/null
+++ b/backend/services/beauty_business/app/commands/appointments.py
@@ -0,0 +1,70 @@
+"""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
+ )
diff --git a/backend/services/beauty_business/app/core/config.py b/backend/services/beauty_business/app/core/config.py
new file mode 100644
index 0000000..6a87dfd
--- /dev/null
+++ b/backend/services/beauty_business/app/core/config.py
@@ -0,0 +1,75 @@
+"""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()
diff --git a/backend/services/beauty_business/app/core/database.py b/backend/services/beauty_business/app/core/database.py
new file mode 100644
index 0000000..e44c490
--- /dev/null
+++ b/backend/services/beauty_business/app/core/database.py
@@ -0,0 +1,27 @@
+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
diff --git a/backend/services/beauty_business/app/core/logging.py b/backend/services/beauty_business/app/core/logging.py
new file mode 100644
index 0000000..cc6f49a
--- /dev/null
+++ b/backend/services/beauty_business/app/core/logging.py
@@ -0,0 +1,14 @@
+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)
diff --git a/backend/services/beauty_business/app/core/security.py b/backend/services/beauty_business/app/core/security.py
new file mode 100644
index 0000000..4ba748a
--- /dev/null
+++ b/backend/services/beauty_business/app/core/security.py
@@ -0,0 +1,49 @@
+"""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)
diff --git a/backend/services/beauty_business/app/events/publisher.py b/backend/services/beauty_business/app/events/publisher.py
new file mode 100644
index 0000000..50313d0
--- /dev/null
+++ b/backend/services/beauty_business/app/events/publisher.py
@@ -0,0 +1,121 @@
+"""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
diff --git a/backend/services/beauty_business/app/events/types.py b/backend/services/beauty_business/app/events/types.py
new file mode 100644
index 0000000..aaeab2b
--- /dev/null
+++ b/backend/services/beauty_business/app/events/types.py
@@ -0,0 +1,79 @@
+"""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
diff --git a/backend/services/beauty_business/app/main.py b/backend/services/beauty_business/app/main.py
new file mode 100644
index 0000000..297d6f3
--- /dev/null
+++ b/backend/services/beauty_business/app/main.py
@@ -0,0 +1,69 @@
+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()
diff --git a/backend/services/beauty_business/app/middlewares/tenant.py b/backend/services/beauty_business/app/middlewares/tenant.py
new file mode 100644
index 0000000..4b08726
--- /dev/null
+++ b/backend/services/beauty_business/app/middlewares/tenant.py
@@ -0,0 +1,24 @@
+"""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)
diff --git a/backend/services/beauty_business/app/models/__init__.py b/backend/services/beauty_business/app/models/__init__.py
new file mode 100644
index 0000000..0d73431
--- /dev/null
+++ b/backend/services/beauty_business/app/models/__init__.py
@@ -0,0 +1,63 @@
+"""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,
+)
diff --git a/backend/services/beauty_business/app/models/appointments.py b/backend/services/beauty_business/app/models/appointments.py
new file mode 100644
index 0000000..228b3b6
--- /dev/null
+++ b/backend/services/beauty_business/app/models/appointments.py
@@ -0,0 +1,223 @@
+"""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"),
+ )
diff --git a/backend/services/beauty_business/app/models/base.py b/backend/services/beauty_business/app/models/base.py
new file mode 100644
index 0000000..873bc66
--- /dev/null
+++ b/backend/services/beauty_business/app/models/base.py
@@ -0,0 +1,51 @@
+"""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)
diff --git a/backend/services/beauty_business/app/models/catalog.py b/backend/services/beauty_business/app/models/catalog.py
new file mode 100644
index 0000000..51012d4
--- /dev/null
+++ b/backend/services/beauty_business/app/models/catalog.py
@@ -0,0 +1,137 @@
+"""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"),
+ )
diff --git a/backend/services/beauty_business/app/models/customers.py b/backend/services/beauty_business/app/models/customers.py
new file mode 100644
index 0000000..0130045
--- /dev/null
+++ b/backend/services/beauty_business/app/models/customers.py
@@ -0,0 +1,111 @@
+"""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"),
+ )
diff --git a/backend/services/beauty_business/app/models/foundation.py b/backend/services/beauty_business/app/models/foundation.py
new file mode 100644
index 0000000..6d30b81
--- /dev/null
+++ b/backend/services/beauty_business/app/models/foundation.py
@@ -0,0 +1,308 @@
+"""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",
+ ),
+ )
diff --git a/backend/services/beauty_business/app/models/marketing.py b/backend/services/beauty_business/app/models/marketing.py
new file mode 100644
index 0000000..5246a92
--- /dev/null
+++ b/backend/services/beauty_business/app/models/marketing.py
@@ -0,0 +1,134 @@
+"""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"),
+ )
diff --git a/backend/services/beauty_business/app/models/outbox.py b/backend/services/beauty_business/app/models/outbox.py
new file mode 100644
index 0000000..92ceb62
--- /dev/null
+++ b/backend/services/beauty_business/app/models/outbox.py
@@ -0,0 +1,44 @@
+"""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"),
+ )
diff --git a/backend/services/beauty_business/app/models/packages.py b/backend/services/beauty_business/app/models/packages.py
new file mode 100644
index 0000000..bc7d976
--- /dev/null
+++ b/backend/services/beauty_business/app/models/packages.py
@@ -0,0 +1,192 @@
+"""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",
+ ),
+ )
diff --git a/backend/services/beauty_business/app/models/salon.py b/backend/services/beauty_business/app/models/salon.py
new file mode 100644
index 0000000..f7281bc
--- /dev/null
+++ b/backend/services/beauty_business/app/models/salon.py
@@ -0,0 +1,119 @@
+"""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"),
+ )
diff --git a/backend/services/beauty_business/app/models/staff.py b/backend/services/beauty_business/app/models/staff.py
new file mode 100644
index 0000000..6a6b688
--- /dev/null
+++ b/backend/services/beauty_business/app/models/staff.py
@@ -0,0 +1,150 @@
+"""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"),
+ )
diff --git a/backend/services/beauty_business/app/models/types.py b/backend/services/beauty_business/app/models/types.py
new file mode 100644
index 0000000..458738f
--- /dev/null
+++ b/backend/services/beauty_business/app/models/types.py
@@ -0,0 +1,191 @@
+"""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"
diff --git a/backend/services/beauty_business/app/permissions/definitions.py b/backend/services/beauty_business/app/permissions/definitions.py
new file mode 100644
index 0000000..1f0adfe
--- /dev/null
+++ b/backend/services/beauty_business/app/permissions/definitions.py
@@ -0,0 +1,293 @@
+"""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
diff --git a/backend/services/beauty_business/app/policies/__init__.py b/backend/services/beauty_business/app/policies/__init__.py
new file mode 100644
index 0000000..a1761e4
--- /dev/null
+++ b/backend/services/beauty_business/app/policies/__init__.py
@@ -0,0 +1,4 @@
+"""Domain policies package."""
+from app.policies.appointments import AppointmentLifecyclePolicy
+
+__all__ = ["AppointmentLifecyclePolicy"]
diff --git a/backend/services/beauty_business/app/policies/appointments.py b/backend/services/beauty_business/app/policies/appointments.py
new file mode 100644
index 0000000..354527e
--- /dev/null
+++ b/backend/services/beauty_business/app/policies/appointments.py
@@ -0,0 +1,26 @@
+"""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
diff --git a/backend/services/beauty_business/app/providers/__init__.py b/backend/services/beauty_business/app/providers/__init__.py
new file mode 100644
index 0000000..43e4339
--- /dev/null
+++ b/backend/services/beauty_business/app/providers/__init__.py
@@ -0,0 +1,14 @@
+"""Platform provider contracts package."""
+from app.providers.contracts import ( # noqa: F401
+ AIProvider,
+ AccountingProvider,
+ BookingEngineProvider,
+ CRMProvider,
+ CommunicationProvider,
+ DeliveryProvider,
+ ExperienceProvider,
+ IdentityProvider,
+ LoyaltyProvider,
+ NotificationProvider,
+ StorageProvider,
+)
diff --git a/backend/services/beauty_business/app/providers/contracts.py b/backend/services/beauty_business/app/providers/contracts.py
new file mode 100644
index 0000000..820fcab
--- /dev/null
+++ b/backend/services/beauty_business/app/providers/contracts.py
@@ -0,0 +1,82 @@
+"""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]: ...
diff --git a/backend/services/beauty_business/app/providers/mocks.py b/backend/services/beauty_business/app/providers/mocks.py
new file mode 100644
index 0000000..915b19c
--- /dev/null
+++ b/backend/services/beauty_business/app/providers/mocks.py
@@ -0,0 +1,74 @@
+"""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}
diff --git a/backend/services/beauty_business/app/queries/__init__.py b/backend/services/beauty_business/app/queries/__init__.py
new file mode 100644
index 0000000..1ee3606
--- /dev/null
+++ b/backend/services/beauty_business/app/queries/__init__.py
@@ -0,0 +1,4 @@
+"""Query handlers package."""
+from app.queries.appointments import AppointmentQueries
+
+__all__ = ["AppointmentQueries"]
diff --git a/backend/services/beauty_business/app/queries/appointments.py b/backend/services/beauty_business/app/queries/appointments.py
new file mode 100644
index 0000000..812d39a
--- /dev/null
+++ b/backend/services/beauty_business/app/queries/appointments.py
@@ -0,0 +1,22 @@
+"""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)
diff --git a/backend/services/beauty_business/app/repositories/appointment_core.py b/backend/services/beauty_business/app/repositories/appointment_core.py
new file mode 100644
index 0000000..2d7b18d
--- /dev/null
+++ b/backend/services/beauty_business/app/repositories/appointment_core.py
@@ -0,0 +1,42 @@
+"""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()
diff --git a/backend/services/beauty_business/app/repositories/appointments.py b/backend/services/beauty_business/app/repositories/appointments.py
new file mode 100644
index 0000000..18ebb92
--- /dev/null
+++ b/backend/services/beauty_business/app/repositories/appointments.py
@@ -0,0 +1,120 @@
+"""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()
diff --git a/backend/services/beauty_business/app/repositories/base.py b/backend/services/beauty_business/app/repositories/base.py
new file mode 100644
index 0000000..0c6dd9d
--- /dev/null
+++ b/backend/services/beauty_business/app/repositories/base.py
@@ -0,0 +1,78 @@
+"""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())
diff --git a/backend/services/beauty_business/app/repositories/catalog.py b/backend/services/beauty_business/app/repositories/catalog.py
new file mode 100644
index 0000000..f48a4a9
--- /dev/null
+++ b/backend/services/beauty_business/app/repositories/catalog.py
@@ -0,0 +1,51 @@
+"""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()
diff --git a/backend/services/beauty_business/app/repositories/customers.py b/backend/services/beauty_business/app/repositories/customers.py
new file mode 100644
index 0000000..0f7f205
--- /dev/null
+++ b/backend/services/beauty_business/app/repositories/customers.py
@@ -0,0 +1,28 @@
+"""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()
diff --git a/backend/services/beauty_business/app/repositories/foundation.py b/backend/services/beauty_business/app/repositories/foundation.py
new file mode 100644
index 0000000..b6b841b
--- /dev/null
+++ b/backend/services/beauty_business/app/repositories/foundation.py
@@ -0,0 +1,142 @@
+"""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()
diff --git a/backend/services/beauty_business/app/repositories/marketing.py b/backend/services/beauty_business/app/repositories/marketing.py
new file mode 100644
index 0000000..3752145
--- /dev/null
+++ b/backend/services/beauty_business/app/repositories/marketing.py
@@ -0,0 +1,81 @@
+"""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
diff --git a/backend/services/beauty_business/app/repositories/packages.py b/backend/services/beauty_business/app/repositories/packages.py
new file mode 100644
index 0000000..85017ec
--- /dev/null
+++ b/backend/services/beauty_business/app/repositories/packages.py
@@ -0,0 +1,51 @@
+"""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()
diff --git a/backend/services/beauty_business/app/repositories/salon.py b/backend/services/beauty_business/app/repositories/salon.py
new file mode 100644
index 0000000..d3b25ad
--- /dev/null
+++ b/backend/services/beauty_business/app/repositories/salon.py
@@ -0,0 +1,74 @@
+"""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()
diff --git a/backend/services/beauty_business/app/repositories/staff.py b/backend/services/beauty_business/app/repositories/staff.py
new file mode 100644
index 0000000..ed7beac
--- /dev/null
+++ b/backend/services/beauty_business/app/repositories/staff.py
@@ -0,0 +1,51 @@
+"""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()
diff --git a/backend/services/beauty_business/app/schemas/appointment_core.py b/backend/services/beauty_business/app/schemas/appointment_core.py
new file mode 100644
index 0000000..9585e5b
--- /dev/null
+++ b/backend/services/beauty_business/app/schemas/appointment_core.py
@@ -0,0 +1,89 @@
+"""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
diff --git a/backend/services/beauty_business/app/schemas/appointments.py b/backend/services/beauty_business/app/schemas/appointments.py
new file mode 100644
index 0000000..3ef577b
--- /dev/null
+++ b/backend/services/beauty_business/app/schemas/appointments.py
@@ -0,0 +1,214 @@
+"""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
diff --git a/backend/services/beauty_business/app/schemas/catalog.py b/backend/services/beauty_business/app/schemas/catalog.py
new file mode 100644
index 0000000..27b7ccb
--- /dev/null
+++ b/backend/services/beauty_business/app/schemas/catalog.py
@@ -0,0 +1,84 @@
+"""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
diff --git a/backend/services/beauty_business/app/schemas/common.py b/backend/services/beauty_business/app/schemas/common.py
new file mode 100644
index 0000000..12b4f56
--- /dev/null
+++ b/backend/services/beauty_business/app/schemas/common.py
@@ -0,0 +1,18 @@
+"""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
diff --git a/backend/services/beauty_business/app/schemas/customers.py b/backend/services/beauty_business/app/schemas/customers.py
new file mode 100644
index 0000000..948fde5
--- /dev/null
+++ b/backend/services/beauty_business/app/schemas/customers.py
@@ -0,0 +1,46 @@
+"""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
diff --git a/backend/services/beauty_business/app/schemas/foundation.py b/backend/services/beauty_business/app/schemas/foundation.py
new file mode 100644
index 0000000..d1cb491
--- /dev/null
+++ b/backend/services/beauty_business/app/schemas/foundation.py
@@ -0,0 +1,301 @@
+"""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
diff --git a/backend/services/beauty_business/app/schemas/marketing.py b/backend/services/beauty_business/app/schemas/marketing.py
new file mode 100644
index 0000000..c4c4f71
--- /dev/null
+++ b/backend/services/beauty_business/app/schemas/marketing.py
@@ -0,0 +1,149 @@
+"""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
diff --git a/backend/services/beauty_business/app/schemas/packages.py b/backend/services/beauty_business/app/schemas/packages.py
new file mode 100644
index 0000000..100648a
--- /dev/null
+++ b/backend/services/beauty_business/app/schemas/packages.py
@@ -0,0 +1,86 @@
+"""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
diff --git a/backend/services/beauty_business/app/schemas/salon.py b/backend/services/beauty_business/app/schemas/salon.py
new file mode 100644
index 0000000..bf0433d
--- /dev/null
+++ b/backend/services/beauty_business/app/schemas/salon.py
@@ -0,0 +1,124 @@
+"""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
diff --git a/backend/services/beauty_business/app/schemas/staff.py b/backend/services/beauty_business/app/schemas/staff.py
new file mode 100644
index 0000000..aea9fdd
--- /dev/null
+++ b/backend/services/beauty_business/app/schemas/staff.py
@@ -0,0 +1,94 @@
+"""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
diff --git a/backend/services/beauty_business/app/services/appointment_core.py b/backend/services/beauty_business/app/services/appointment_core.py
new file mode 100644
index 0000000..c4574c6
--- /dev/null
+++ b/backend/services/beauty_business/app/services/appointment_core.py
@@ -0,0 +1,221 @@
+"""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))
diff --git a/backend/services/beauty_business/app/services/appointments.py b/backend/services/beauty_business/app/services/appointments.py
new file mode 100644
index 0000000..037d563
--- /dev/null
+++ b/backend/services/beauty_business/app/services/appointments.py
@@ -0,0 +1,399 @@
+"""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
diff --git a/backend/services/beauty_business/app/services/audit_service.py b/backend/services/beauty_business/app/services/audit_service.py
new file mode 100644
index 0000000..61efb53
--- /dev/null
+++ b/backend/services/beauty_business/app/services/audit_service.py
@@ -0,0 +1,51 @@
+"""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
+ )
diff --git a/backend/services/beauty_business/app/services/catalog.py b/backend/services/beauty_business/app/services/catalog.py
new file mode 100644
index 0000000..480342f
--- /dev/null
+++ b/backend/services/beauty_business/app/services/catalog.py
@@ -0,0 +1,168 @@
+"""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
diff --git a/backend/services/beauty_business/app/services/customers.py b/backend/services/beauty_business/app/services/customers.py
new file mode 100644
index 0000000..511a2b2
--- /dev/null
+++ b/backend/services/beauty_business/app/services/customers.py
@@ -0,0 +1,91 @@
+"""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
diff --git a/backend/services/beauty_business/app/services/foundation.py b/backend/services/beauty_business/app/services/foundation.py
new file mode 100644
index 0000000..e67c17d
--- /dev/null
+++ b/backend/services/beauty_business/app/services/foundation.py
@@ -0,0 +1,781 @@
+"""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))
diff --git a/backend/services/beauty_business/app/services/marketing.py b/backend/services/beauty_business/app/services/marketing.py
new file mode 100644
index 0000000..f3210bc
--- /dev/null
+++ b/backend/services/beauty_business/app/services/marketing.py
@@ -0,0 +1,366 @@
+"""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)
diff --git a/backend/services/beauty_business/app/services/packages.py b/backend/services/beauty_business/app/services/packages.py
new file mode 100644
index 0000000..a3c8f6a
--- /dev/null
+++ b/backend/services/beauty_business/app/services/packages.py
@@ -0,0 +1,168 @@
+"""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
diff --git a/backend/services/beauty_business/app/services/salon.py b/backend/services/beauty_business/app/services/salon.py
new file mode 100644
index 0000000..bacec6e
--- /dev/null
+++ b/backend/services/beauty_business/app/services/salon.py
@@ -0,0 +1,245 @@
+"""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
diff --git a/backend/services/beauty_business/app/services/staff.py b/backend/services/beauty_business/app/services/staff.py
new file mode 100644
index 0000000..61932e6
--- /dev/null
+++ b/backend/services/beauty_business/app/services/staff.py
@@ -0,0 +1,168 @@
+"""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
diff --git a/backend/services/beauty_business/app/specifications/__init__.py b/backend/services/beauty_business/app/specifications/__init__.py
new file mode 100644
index 0000000..46b43bc
--- /dev/null
+++ b/backend/services/beauty_business/app/specifications/__init__.py
@@ -0,0 +1 @@
+"""Specifications package — list filters reserved for future phases."""
diff --git a/backend/services/beauty_business/app/tests/conftest.py b/backend/services/beauty_business/app/tests/conftest.py
new file mode 100644
index 0000000..7588ff8
--- /dev/null
+++ b/backend/services/beauty_business/app/tests/conftest.py
@@ -0,0 +1,62 @@
+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)}
diff --git a/backend/services/beauty_business/app/tests/test_api.py b/backend/services/beauty_business/app/tests/test_api.py
new file mode 100644
index 0000000..a61a02b
--- /dev/null
+++ b/backend/services/beauty_business/app/tests/test_api.py
@@ -0,0 +1,136 @@
+"""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)
diff --git a/backend/services/beauty_business/app/tests/test_architecture.py b/backend/services/beauty_business/app/tests/test_architecture.py
new file mode 100644
index 0000000..dffe480
--- /dev/null
+++ b/backend/services/beauty_business/app/tests/test_architecture.py
@@ -0,0 +1,250 @@
+"""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}"
diff --git a/backend/services/beauty_business/app/tests/test_dependency.py b/backend/services/beauty_business/app/tests/test_dependency.py
new file mode 100644
index 0000000..22fc127
--- /dev/null
+++ b/backend/services/beauty_business/app/tests/test_dependency.py
@@ -0,0 +1,35 @@
+"""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
diff --git a/backend/services/beauty_business/app/tests/test_docs.py b/backend/services/beauty_business/app/tests/test_docs.py
new file mode 100644
index 0000000..f00e936
--- /dev/null
+++ b/backend/services/beauty_business/app/tests/test_docs.py
@@ -0,0 +1,32 @@
+"""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()
diff --git a/backend/services/beauty_business/app/tests/test_migration.py b/backend/services/beauty_business/app/tests/test_migration.py
new file mode 100644
index 0000000..00baaa5
--- /dev/null
+++ b/backend/services/beauty_business/app/tests/test_migration.py
@@ -0,0 +1,69 @@
+"""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)
diff --git a/backend/services/beauty_business/app/tests/test_performance.py b/backend/services/beauty_business/app/tests/test_performance.py
new file mode 100644
index 0000000..b3fb1e6
--- /dev/null
+++ b/backend/services/beauty_business/app/tests/test_performance.py
@@ -0,0 +1,20 @@
+"""Performance / index justification for Phase 14.1 appointment tables."""
+from __future__ import annotations
+
+from app.core.database import Base
+import app.models # noqa: F401
+
+
+def test_appointment_indexes_exist():
+ appointments = Base.metadata.tables["appointments"]
+ index_names = {idx.name for idx in appointments.indexes}
+ assert "ix_appointments_tenant_status" in index_names
+ assert "ix_appointments_branch" in index_names
+ assert "ix_appointments_staff" in index_names
+
+
+def test_outbox_indexes_exist():
+ outbox = Base.metadata.tables["outbox_events"]
+ index_names = {idx.name for idx in outbox.indexes}
+ assert "ix_beauty_business_outbox_status" in index_names
+ assert "ix_beauty_business_outbox_tenant" in index_names
diff --git a/backend/services/beauty_business/app/tests/test_permissions.py b/backend/services/beauty_business/app/tests/test_permissions.py
new file mode 100644
index 0000000..34582fd
--- /dev/null
+++ b/backend/services/beauty_business/app/tests/test_permissions.py
@@ -0,0 +1,27 @@
+"""Permission definition tests."""
+from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES
+
+
+def test_all_permissions_use_beauty_business_prefix():
+ assert ALL_PERMISSIONS
+ for perm in ALL_PERMISSIONS:
+ assert perm.startswith("beauty_business.")
+
+
+def test_permission_prefixes_stable():
+ assert "beauty_business." in PERMISSION_PREFIXES
+ assert "beauty_business.organizations." in PERMISSION_PREFIXES
+ assert "beauty_business.appointments." in PERMISSION_PREFIXES
+ assert "beauty_business.integrations." in PERMISSION_PREFIXES
+
+
+def test_appointment_permissions_present():
+ from app.permissions.definitions import (
+ APPOINTMENTS_CONFIRM,
+ APPOINTMENTS_CREATE,
+ APPOINTMENTS_VIEW,
+ )
+
+ assert APPOINTMENTS_VIEW in ALL_PERMISSIONS
+ assert APPOINTMENTS_CREATE in ALL_PERMISSIONS
+ assert APPOINTMENTS_CONFIRM in ALL_PERMISSIONS
diff --git a/backend/services/beauty_business/app/tests/test_security.py b/backend/services/beauty_business/app/tests/test_security.py
new file mode 100644
index 0000000..999eee7
--- /dev/null
+++ b/backend/services/beauty_business/app/tests/test_security.py
@@ -0,0 +1,41 @@
+"""Security validation — anonymous denial when auth required."""
+from __future__ import annotations
+
+import os
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+
+
+@pytest.mark.asyncio
+async def test_auth_required_denies_anonymous(db_setup):
+ os.environ["AUTH_REQUIRED"] = "true"
+ from app.core.config import get_settings
+
+ get_settings.cache_clear()
+ # Re-import settings binding used by security
+ import app.core.config as config_mod
+ import app.core.security as security_mod
+ import app.api.permissions as perm_mod
+
+ config_mod.settings = get_settings()
+ security_mod.settings = config_mod.settings
+ perm_mod.settings = config_mod.settings
+
+ from app.main import create_app
+
+ app = create_app()
+ transport = ASGITransport(app=app)
+ try:
+ async with AsyncClient(transport=transport, base_url="http://testserver") as client:
+ res = await client.get(
+ "/api/v1/organizations",
+ headers={"X-Tenant-ID": "11111111-1111-1111-1111-111111111111"},
+ )
+ assert res.status_code == 401
+ finally:
+ os.environ["AUTH_REQUIRED"] = "false"
+ get_settings.cache_clear()
+ config_mod.settings = get_settings()
+ security_mod.settings = config_mod.settings
+ perm_mod.settings = config_mod.settings
diff --git a/backend/services/beauty_business/app/validators/__init__.py b/backend/services/beauty_business/app/validators/__init__.py
new file mode 100644
index 0000000..90ef2b8
--- /dev/null
+++ b/backend/services/beauty_business/app/validators/__init__.py
@@ -0,0 +1,14 @@
+"""Delivery validators package."""
+from app.validators.foundation import (
+ ensure_optimistic_version,
+ validate_code,
+ validate_currency_code,
+ validate_non_empty,
+)
+
+__all__ = [
+ "validate_non_empty",
+ "validate_code",
+ "validate_currency_code",
+ "ensure_optimistic_version",
+]
diff --git a/backend/services/beauty_business/app/validators/appointments.py b/backend/services/beauty_business/app/validators/appointments.py
new file mode 100644
index 0000000..2fb9b89
--- /dev/null
+++ b/backend/services/beauty_business/app/validators/appointments.py
@@ -0,0 +1,65 @@
+"""Appointment lifecycle validators — Phase 14.1."""
+from __future__ import annotations
+
+from shared.exceptions import AppError
+
+from app.models.types import AppointmentLifecycleAction, AppointmentStatus
+
+ALLOWED_TRANSITIONS: dict[AppointmentLifecycleAction, set[AppointmentStatus]] = {
+ AppointmentLifecycleAction.CONFIRM: {AppointmentStatus.REQUESTED},
+ AppointmentLifecycleAction.CHECK_IN: {AppointmentStatus.CONFIRMED},
+ AppointmentLifecycleAction.START: {AppointmentStatus.CHECKED_IN},
+ AppointmentLifecycleAction.COMPLETE: {AppointmentStatus.IN_PROGRESS, AppointmentStatus.CHECKED_IN},
+ AppointmentLifecycleAction.CANCEL: {
+ AppointmentStatus.REQUESTED,
+ AppointmentStatus.CONFIRMED,
+ AppointmentStatus.CHECKED_IN,
+ },
+ AppointmentLifecycleAction.NO_SHOW: {AppointmentStatus.CONFIRMED},
+}
+
+TERMINAL_STATUSES = frozenset(
+ {AppointmentStatus.COMPLETED, AppointmentStatus.CANCELLED, AppointmentStatus.NO_SHOW}
+)
+
+TARGET_STATUS: dict[AppointmentLifecycleAction, AppointmentStatus] = {
+ AppointmentLifecycleAction.CONFIRM: AppointmentStatus.CONFIRMED,
+ AppointmentLifecycleAction.CHECK_IN: AppointmentStatus.CHECKED_IN,
+ AppointmentLifecycleAction.START: AppointmentStatus.IN_PROGRESS,
+ AppointmentLifecycleAction.COMPLETE: AppointmentStatus.COMPLETED,
+ AppointmentLifecycleAction.CANCEL: AppointmentStatus.CANCELLED,
+ AppointmentLifecycleAction.NO_SHOW: AppointmentStatus.NO_SHOW,
+}
+
+
+def ensure_appointment_lifecycle_transition(
+ *, action: AppointmentLifecycleAction, current: AppointmentStatus
+) -> None:
+ if current in TERMINAL_STATUSES:
+ raise AppError(
+ "نوبت در وضعیت پایانی است",
+ status_code=409,
+ error_code="appointment_terminal",
+ )
+ allowed = ALLOWED_TRANSITIONS.get(action, set())
+ if current not in allowed:
+ raise AppError(
+ "انتقال وضعیت نوبت مجاز نیست",
+ status_code=409,
+ error_code="invalid_appointment_lifecycle_transition",
+ )
+
+
+def target_status_for(action: AppointmentLifecycleAction) -> AppointmentStatus:
+ return TARGET_STATUS[action]
+
+
+def validate_reason(reason: str | None, *, required: bool = False) -> str | None:
+ if reason is None or not str(reason).strip():
+ if required:
+ raise AppError("دلیل الزامی است", status_code=422, error_code="reason_required")
+ return None
+ text = str(reason).strip()
+ if len(text) > 500:
+ raise AppError("دلیل بیش از حد طولانی است", status_code=422, error_code="reason_too_long")
+ return text
diff --git a/backend/services/beauty_business/app/validators/foundation.py b/backend/services/beauty_business/app/validators/foundation.py
new file mode 100644
index 0000000..8f4cecd
--- /dev/null
+++ b/backend/services/beauty_business/app/validators/foundation.py
@@ -0,0 +1,55 @@
+"""Reusable validators for Delivery foundation."""
+from __future__ import annotations
+
+import re
+from typing import Any
+
+from shared.exceptions import AppError
+
+CODE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-]{1,49}$")
+
+
+class ValidationError(AppError):
+ def __init__(self, message: str, details: dict[str, Any] | None = None):
+ super().__init__(
+ message=message,
+ error_code="validation_error",
+ status_code=422,
+ details=details or {},
+ )
+
+
+def validate_non_empty(value: str | None, field: str) -> str:
+ if value is None or not str(value).strip():
+ raise ValidationError(f"{field} الزامی است", {"field": field})
+ return str(value).strip()
+
+
+def validate_code(code: str) -> str:
+ code = validate_non_empty(code, "code")
+ if not CODE_RE.match(code):
+ raise ValidationError(
+ "کد نامعتبر است",
+ {"field": "code", "pattern": CODE_RE.pattern},
+ )
+ return code
+
+
+def validate_currency_code(code: str) -> str:
+ code = validate_non_empty(code, "currency_code").upper()
+ if len(code) != 3:
+ raise ValidationError("currency_code باید ۳ حرفی باشد", {"field": "currency_code"})
+ return code
+
+
+def ensure_optimistic_version(entity: Any, expected: int) -> None:
+ current = getattr(entity, "version", None)
+ if current is None:
+ return
+ if current != expected:
+ raise AppError(
+ "نسخه موجودیت همخوان نیست",
+ error_code="version_conflict",
+ status_code=409,
+ details={"expected": expected, "actual": current},
+ )
diff --git a/backend/services/beauty_business/pytest.ini b/backend/services/beauty_business/pytest.ini
new file mode 100644
index 0000000..f1f608e
--- /dev/null
+++ b/backend/services/beauty_business/pytest.ini
@@ -0,0 +1,8 @@
+[pytest]
+asyncio_mode = auto
+asyncio_default_fixture_loop_scope = function
+testpaths = app/tests
+pythonpath = .
+python_files = test_*.py
+python_classes = Test*
+python_functions = test_*
diff --git a/backend/services/beauty_business/requirements.txt b/backend/services/beauty_business/requirements.txt
new file mode 100644
index 0000000..bbdf395
--- /dev/null
+++ b/backend/services/beauty_business/requirements.txt
@@ -0,0 +1,14 @@
+fastapi==0.111.0
+uvicorn[standard]==0.30.1
+pydantic[email]==2.7.4
+pydantic-settings==2.3.4
+sqlalchemy==2.0.31
+alembic==1.13.2
+asyncpg==0.29.0
+psycopg[binary]>=3.2.2
+httpx==0.27.0
+pyjwt[crypto]==2.8.0
+-e ../../shared-lib
+pytest==8.2.2
+pytest-asyncio==0.23.7
+aiosqlite==0.20.0
diff --git a/backend/services/beauty_business/scripts/ensure_db.py b/backend/services/beauty_business/scripts/ensure_db.py
new file mode 100644
index 0000000..174629f
--- /dev/null
+++ b/backend/services/beauty_business/scripts/ensure_db.py
@@ -0,0 +1,41 @@
+"""Ensure beauty_business_db exists before migration."""
+from __future__ import annotations
+
+import os
+import sys
+from urllib.parse import urlparse
+
+
+def main() -> None:
+ sync_url = os.environ.get("BEAUTY_BUSINESS_DATABASE_URL_SYNC", "")
+ if not sync_url:
+ print("BEAUTY_BUSINESS_DATABASE_URL_SYNC not set", file=sys.stderr)
+ return
+
+ parsed = urlparse(sync_url.replace("+psycopg", ""))
+ db_name = (parsed.path or "").lstrip("/") or "beauty_business_db"
+
+ import psycopg
+
+ conn = psycopg.connect(
+ host=parsed.hostname or "localhost",
+ port=parsed.port or 5432,
+ user=parsed.username,
+ password=parsed.password,
+ dbname="postgres",
+ autocommit=True,
+ )
+ try:
+ with conn.cursor() as cur:
+ cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,))
+ if cur.fetchone() is None:
+ cur.execute(f'CREATE DATABASE "{db_name}"')
+ print(f"Created database: {db_name}")
+ else:
+ print(f"Database exists: {db_name}")
+ finally:
+ conn.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/beauty-business-roadmap.md b/docs/beauty-business-roadmap.md
new file mode 100644
index 0000000..54873c2
--- /dev/null
+++ b/docs/beauty-business-roadmap.md
@@ -0,0 +1,191 @@
+# Beauty Business Roadmap
+
+> Future only. Completed work → [progress.md](progress.md). Immediate milestone → [next-steps.md](next-steps.md).
+
+Commercial product: **Torbat Beauty** — independent business management software for salons, barbershops, beauty clinics, laser centers, skin-care centers, hair-treatment centers, and spa & wellness centers.
+
+**Important:** Beauty Business and Healthcare are completely independent business modules. They share Core platform services (Tenant, Identity, Authorization, CRM, Accounting, Loyalty, Communication, Delivery, Experience/Page Builder) via API and events only — never shared documentation, phases, frontend, dashboards, permissions, navigation, or branding.
+
+---
+
+## Business Goals
+
+1. Enable beauty and wellness businesses to operate appointments, staff, services, and customer relationships in a single tenant-scoped workspace.
+2. Provide industry-specific booking, salon/branch management, service catalog, packages, commissions, and marketing workflows without owning platform capabilities.
+3. Integrate with Torbat Loyalty, Communication, Accounting, CRM, Delivery, and Experience without duplicating their domain data.
+4. Ship a dedicated frontend shell, permission tree (`beauty_business.*`), navigation, and branding separate from Healthcare and other verticals.
+
+---
+
+## Boundaries
+
+### Owns
+
+- Salons / branches / treatment rooms / stations
+- Beauty service catalog (services, categories, durations, pricing shells)
+- Appointment and booking lifecycle (beauty-specific rules)
+- Staff profiles, schedules, skills, commission rules (beauty domain)
+- Customer profiles in beauty context (refs to CRM/Loyalty where applicable)
+- Packages, memberships, and session bundles (beauty commercial model)
+- Beauty marketing campaigns hooks (Loyalty/Communication client-side)
+- `beauty_business.*` permissions, events, audit, configuration
+
+### Must Not Own
+
+- Healthcare clinics, doctors, patients, medical records, prescriptions ([healthcare](module-registry.md#healthcare))
+- Accounting journals / Posting Engine ([accounting](module-registry.md#accounting))
+- Sales CRM aggregates ([crm](module-registry.md#crm))
+- Loyalty ledger, point accounts, campaigns ([loyalty](module-registry.md#loyalty))
+- Communication providers and message delivery ([communication](module-registry.md#communication))
+- Delivery drivers, fleet, dispatch ([delivery](module-registry.md#delivery))
+- Experience sites, pages, themes ([experience](module-registry.md#experience))
+- Core tenant membership, Identity user admin ([core-platform](module-registry.md#core-platform))
+
+See [module-boundaries.md](architecture/module-boundaries.md).
+
+---
+
+## Dependencies
+
+| Dependency | Usage |
+| --- | --- |
+| Core Platform | Tenant, entitlement, membership context |
+| Identity & Access | User refs for staff and customer portal |
+| Authorization | Workspace roles + module permission catalog |
+| CRM | Contact/organization refs; optional lead capture |
+| Accounting | Revenue/settlement intents via Posting Engine contracts |
+| Loyalty | Points, memberships, campaigns (client) |
+| Communication | Appointment reminders, marketing messages (client) |
+| Delivery | Product/home-service delivery intents (optional, later) |
+| Experience (Torbat Pages) | Public booking pages, salon sites, widgets |
+| File Storage | Media refs for portfolio, staff, service images |
+
+Database-per-service: sole owner of `beauty_business_db`. No cross-DB FKs ([ADR-001](architecture/adr/ADR-001.md)).
+
+---
+
+## Roles (High-Level)
+
+| Role | Responsibility |
+| --- | --- |
+| Salon Owner | Tenant beauty business admin; branches, billing settings, staff policies |
+| Branch Manager | Day-to-day branch operations, schedules, inventory of stations |
+| Receptionist | Booking, check-in, customer intake |
+| Stylist / Therapist / Technician | Own calendar, service delivery, commission view |
+| Sales / Front Desk | Packages, upsell, payment collection (refs Accounting) |
+| Customer | Self-service booking, history, loyalty (portal APIs; UI in frontend) |
+| Platform Admin | Cross-tenant ops (platform scope only) |
+
+Module-specific permissions under `beauty_business.*` (detailed per phase).
+
+---
+
+## High-Level Entities (Planned)
+
+| Aggregate | Purpose |
+| --- | --- |
+| BeautyOrganization | Top-level beauty business entity (salon group / clinic chain) |
+| BeautyBranch | Physical location (salon, barbershop, clinic branch, spa) |
+| TreatmentRoom / Station | Bookable physical resources |
+| BeautyRole / BeautyPermission | Module RBAC shells |
+| ServiceCategory / BeautyService | Catalog: cuts, color, laser, facial, spa packages |
+| ServiceVariant | Duration, price tier, staff skill requirements |
+| StaffProfile | Beauty staff linked to Identity user ref |
+| StaffSchedule / StaffAvailability | Working hours and exceptions |
+| CustomerProfile | Beauty customer context (CRM contact ref) |
+| Appointment | Booking lifecycle (requested → confirmed → checked-in → completed / cancelled / no-show) |
+| AppointmentType | Consultation, service, package session, follow-up |
+| WaitingListEntry | Queue when slots full |
+| PackageDefinition / CustomerPackage | Prepaid multi-session bundles |
+| MembershipPlan / CustomerMembership | Recurring beauty memberships |
+| CommissionRule / CommissionEntry | Staff compensation shells |
+| MarketingCampaignRef | Hooks to Loyalty/Communication (refs only) |
+| BeautyConfiguration / BeautySetting | Tenant/module settings |
+| BeautyAuditLog | Immutable audit trail |
+| OutboxEvent | Publish-only domain events |
+
+Exact entity names and tables are defined at implementation time per [phase-template.md](ai-framework/phase-template.md).
+
+---
+
+## Integration Points
+
+| Platform | Integration pattern |
+| --- | --- |
+| CRM | `contact_ref` / `organization_ref` on CustomerProfile; optional opportunity on high-value packages |
+| Loyalty | Earn/redeem on completed appointments; membership tier sync via API |
+| Communication | Template-based reminders (confirm, reschedule, no-show, birthday) |
+| Accounting | Invoice/payment intents on completed services; commission accrual intents |
+| Experience | Embeddable booking widget; public service menu pages |
+| Delivery | Optional product shipment for retail beauty products (job intents to Delivery) |
+| Identity | Staff and customer user refs only |
+
+All integrations: REST + async events + outbox; provider contracts in owning service; no foreign model imports.
+
+---
+
+## Phase Plan
+
+| Phase | Identifier | Title | Summary |
+| --- | --- | --- | --- |
+| 14.0 | `beauty-business-14-0` | Beauty Foundation | Service scaffold, org/branch/roles, configuration, audit, health/capabilities |
+| 14.1 | `beauty-business-14-1` | Booking Engine | Schedules, availability, appointments, waiting list |
+| 14.2 | `beauty-business-14-2` | Salon Management | Branches, rooms/stations, branch policies, operational settings |
+| 14.3 | `beauty-business-14-3` | Service Catalog | Categories, services, variants, pricing shells, media refs |
+| 14.4 | `beauty-business-14-4` | Customer Management | Customer profiles, preferences, visit history shells, CRM refs |
+| 14.5 | `beauty-business-14-5` | Package & Membership | Prepaid packages, session balances, recurring memberships |
+| 14.6 | `beauty-business-14-6` | Staff & Commission | Staff profiles, skills, schedules, commission rules and entries |
+| 14.7 | `beauty-business-14-7` | Marketing & Loyalty | Campaign refs, referral hooks, Loyalty/Communication connectors |
+
+Phase documents: [phases/BeautyBusiness/](phases/BeautyBusiness/README.md).
+
+Implementation of every phase must follow the [Enterprise / AI Development Framework](ai-framework/README.md) ([ADR-013](architecture/adr/ADR-013.md), [ADR-018](architecture/adr/ADR-018.md), [ADR-019](architecture/adr/ADR-019.md)).
+
+---
+
+## Future Roadmap (Post 14.7)
+
+Not registered as phases yet; track here only:
+
+- Retail product inventory and POS-lite for beauty retail
+- Advanced analytics and revenue dashboards
+- Multi-branch franchise reporting
+- AI-assisted scheduling and demand forecasting
+- Mobile stylist app and customer app deep links
+- Integration marketplace (payment gateways, review platforms)
+- Enterprise validation and compliance slice (14.8+ when scoped)
+
+---
+
+## Technical Registration (Planned)
+
+| Field | Value |
+| --- | --- |
+| Service folder | `backend/services/beauty_business` (at 14.0) |
+| Database | `beauty_business_db` |
+| API port | **8011** (reserved) |
+| API prefix | `/api/v1` |
+| Permission prefix | `beauty_business.*` |
+| Event prefix | `beauty_business.*` |
+| Frontend route prefix | `/beauty` (dedicated shell; separate from `/healthcare`) |
+
+---
+
+## Documentation References
+
+- [Phase area README](phases/BeautyBusiness/README.md)
+- [Module registry — beauty_business](module-registry.md#beauty_business)
+- [Module boundaries](architecture/module-boundaries.md)
+- [Multi-tenant architecture](architecture/multi-tenant-architecture.md)
+- [Service architecture](architecture/service-architecture.md)
+- [AI Development Framework](ai-framework/README.md)
+- [Healthcare roadmap](healthcare-roadmap.md) (sibling module — do not merge)
+
+---
+
+## Related Documents
+
+- [Progress](progress.md)
+- [Next Steps](next-steps.md)
+- [Roadmap](roadmap.md)
+- [Module Registry](module-registry.md)
diff --git a/docs/module-registry.md b/docs/module-registry.md
index 4c957cc..e6fac6b 100644
--- a/docs/module-registry.md
+++ b/docs/module-registry.md
@@ -434,7 +434,7 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
| Name | Enterprise Experience Platform (Torbat Pages) |
| Description | Independent experience platform: sites, pages-as-resources, versioned components, themes, layouts, templates, localization, forms/surveys/appointments, SEO/PWA, bundles, widgets for all verticals |
| Owner | Platform |
-| Status | Active (Phase 11.0 foundation complete) |
+| Status | Active (Phase 11.10 complete — Experience track closed) |
| Dependencies | Core entitlement; Storage / CRM / Loyalty / Communication / Hospitality / Marketplace / Sports / Delivery / AI / Automation via API/Events when phases require them |
| Internal Dependencies | Modules owned by this service only |
| External Dependencies | File Storage for media refs; notifications via Communication |
@@ -442,16 +442,16 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
| Database | `experience_db` |
| API Prefix | `/api/v1` (port 8008); `/health`, `/capabilities`, `/metrics` |
| Permission Prefix | `experience.*` |
-| Events | `experience.workspace.*`, `experience.locale_profile.*`, `experience.external_provider.*`, `experience.render_engine.*`, `experience.configuration.*`, `experience.setting.*`, `experience.site.*`, `experience.page.*`, `experience.site_domain.*`, `experience.component.*`, `experience.component_version.*`, `experience.page_component.*`, `experience.theme.*`, `experience.layout.*`, `experience.site_theme.*`, `experience.page_layout.*` (+ planned template/…) |
+| Events | `experience.workspace.*`, `experience.locale_profile.*`, `experience.external_provider.*`, `experience.render_engine.*`, `experience.configuration.*`, `experience.setting.*`, `experience.site.*`, `experience.page.*`, `experience.site_domain.*`, `experience.component.*`, `experience.component_version.*`, `experience.page_component.*`, `experience.theme.*`, `experience.layout.*`, `experience.site_theme.*`, `experience.page_layout.*`, `experience.template.*`, `experience.template_version.*`, `experience.site_locale.*`, `experience.localization.*`, `experience.media_ref.*`, `experience.form.*`, `experience.form_submission.*`, `experience.survey.*`, `experience.survey_response.*`, `experience.appointment_page.*`, `experience.appointment_booking_ref.*` (+ planned seo/…) |
| Event Producers | Experience |
| Event Consumers | Hospitality, Marketplace, Sports Center, CRM, Loyalty, Communication, Accounting, Clinic, Hotel, Tourism, Analytics (future) |
| Provider Dependencies | Optional theme/component marketplace adapters (planned) |
| AI Dependencies | Optional via contracts only (Phase 11.10) |
-| Documentation | [experience-roadmap.md](experience-roadmap.md), [phases/Experience](phases/Experience/README.md), [experience-phase-11-3.md](experience-phase-11-3.md), [phase-handover/phase-11-3.md](phase-handover/phase-11-3.md), [ADR-016](architecture/adr/ADR-016.md) |
-| Current Phase | 11.3 complete; next `experience-11.4` |
-| Version | 0.11.3.0 |
+| Documentation | [experience-roadmap.md](experience-roadmap.md), [phases/Experience](phases/Experience/README.md), [experience-phase-11-10.md](experience-phase-11-10.md), [phase-handover/phase-11-10.md](phase-handover/phase-11-10.md), [experience-phase-11-10-audit.md](experience-phase-11-10-audit.md), [ADR-016](architecture/adr/ADR-016.md) |
+| Current Phase | 11.10 complete — track closed |
+| Version | 0.11.10.0 |
| Version Compatibility | Requires Core entitlement; integrations per later phases |
-| Migration Version | `0002_phase_111_sites_pages` |
+| Migration Version | `0011_phase_1110_analytics_ai_hooks` |
| Tenant Aware | Yes (required) |
| Permission Tree | `experience.*` |
| Future Plans | Phases 11.0–11.10 per [phase-manifest.yaml](ai-framework/phase-manifest.yaml) |
@@ -474,25 +474,27 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
| `experience.components` | Components | Complete | 11.2 |
| `experience.component_versions` | Component Versions | Complete | 11.2 |
| `experience.page_component_placements` | Page Component Placements | Complete | 11.2 |
-| `experience.themes` | Themes | Complete | 11.3 |
-| `experience.layouts` | Layouts | Complete | 11.3 |
-| `experience.site_theme_assignments` | Site Theme Assignments | Complete | 11.3 |
-| `experience.page_layout_assignments` | Page Layout Assignments | Complete | 11.3 |
-| `experience.templates` | Templates | Planned | 11.4 |
-| `experience.locales` | Localization | Planned | 11.5 |
-| `experience.media_refs` | Media References | Planned | 11.5 |
-| `experience.forms` | Forms | Planned | 11.6 |
-| `experience.surveys` | Surveys | Planned | 11.6 |
-| `experience.appointments` | Appointment Pages | Planned | 11.6 |
-| `experience.publishing` | Publishing | Planned | 11.7 |
-| `experience.seo` | SEO | Planned | 11.7 |
-| `experience.pwa` | PWA Readiness | Planned | 11.7 |
-| `experience.bundles` | Capability Bundles | Planned | 11.8 |
-| `experience.feature_toggles` | Feature Toggles | Planned | 11.8 |
-| `experience.widgets` | Widgets | Planned | 11.9 |
-| `experience.consumer_connectors` | Consumer Connectors | Planned | 11.9 |
-| `experience.analytics` | Analytics | Planned | 11.10 |
-| `experience.ai_hooks` | AI Content Hooks | Planned | 11.10 |
+| `experience.themes` | Themes & Theme Versions | Complete | 11.3 |
+| `experience.layouts` | Layouts & Layout Versions | Complete | 11.3 |
+| `experience.site_theme_bindings` | Site Theme Bindings | Complete | 11.3 |
+| `experience.page_layout_bindings` | Page Layout Bindings | Complete | 11.3 |
+| `experience.templates` | Templates & Template Versions | Complete | 11.4 |
+| `experience.template_instantiations` | Template Instantiation | Complete | 11.4 |
+| `experience.locales` | Localization | Complete | 11.5 |
+| `experience.media_refs` | Media References | Complete | 11.5 |
+| `experience.forms` | Forms | Complete | 11.6 |
+| `experience.surveys` | Surveys | Complete | 11.6 |
+| `experience.appointments` | Appointment Pages | Complete | 11.6 |
+| `experience.publishing` | Publishing | Complete | 11.7 |
+| `experience.seo` | SEO | Complete | 11.7 |
+| `experience.pwa` | PWA Readiness | Complete | 11.7 |
+| `experience.bundles` | Capability Bundles | Complete | 11.8 |
+| `experience.feature_toggles` | Feature Toggles | Complete | 11.8 |
+| `experience.widgets` | Widgets | Complete | 11.9 |
+| `experience.consumer_connectors` | Consumer Connectors | Complete | 11.9 |
+| `experience.notify` | Notify Client | Complete | 11.9 |
+| `experience.analytics` | Analytics | Complete | 11.10 |
+| `experience.ai_hooks` | AI Content Hooks | Complete | 11.10 |
| Module | Responsibilities | Non-responsibilities |
| --- | --- | --- |
diff --git a/docs/next-steps.md b/docs/next-steps.md
index 4ca2224..f548f2d 100644
--- a/docs/next-steps.md
+++ b/docs/next-steps.md
@@ -1,63 +1,20 @@
# Next Steps
-> Immediate next milestone only. History → [progress.md](progress.md).
+> Immediate next milestone only. History → [progress.md](progress.md). Future map → [roadmap.md](roadmap.md).
-## Current Milestone: Phase 7.7 — Gift Card Platform (Loyalty)
+## Experience track — complete
-Follow the permanent [AI Development Framework](ai-framework/README.md).
+Phases **11.0 through 11.10** are complete per [experience-roadmap.md](experience-roadmap.md) and [phase-handover/phase-11-10.md](phase-handover/phase-11-10.md). No further Experience phases are registered in [phase-manifest.yaml](ai-framework/phase-manifest.yaml).
-### Why
+## Suggested next platform tracks
-Phase 7.6 Wallet is complete. Gift cards are the next Loyalty engine.
+Pick one track per team capacity (see [roadmap.md](roadmap.md) and [phase-manifest.yaml](ai-framework/phase-manifest.yaml)):
-Mandatory entry: [phase-handover/phase-7-6.md](phase-handover/phase-7-6.md).
-
-### Scope
-
-1. Gift card issue / activate / redeem / void (or equivalent lifecycle)
-2. Keep ledger immutability (wallet and/or dedicated gift-card ledger)
-3. Permissions + events; do **not** build analytics/partner APIs yet
-
-### Exit Criteria
-
-- [ ] Gift card APIs + validators + tests green
-- [ ] Docs + progress/registry + handover + service snapshot
-- [ ] Quality gates per AI framework
-
-### After This Milestone
-
-Phase 7.8 — Analytics.
-
----
-
-## Recently Completed: Healthcare Platform (Phases 13.0–13.7) ✅
-
-Torbat Health is fully implemented. Next work is **post-13.7** (not registered phases):
-
-- Live Delivery platform connector (replace mock when Delivery 10.9+)
-- National e-prescription / HIE connectors
-- Dedicated frontend patient mobile app
-
-Handover: [phase-handover/phase-13-7.md](phase-handover/phase-13-7.md) · Snapshot: [service-snapshots/healthcare.yaml](service-snapshots/healthcare.yaml)
-
----
-
-## Registered (Not Current Milestone)
-
-| Registration | Status | Next implementation phase |
+| Track | Next phase | Notes |
| --- | --- | --- |
-| [Healthcare HC-Reg](healthcare-roadmap.md) | **Phases 13.0–13.7 complete** | Post-13.7 enhancements (unregistered) |
-| [Beauty Business BB-Reg](beauty-business-roadmap.md) | **Phases 14.0–14.7 complete** | Post-14.7 enhancements (unregistered) |
+| Delivery | `delivery-10.2` Fleet & Vehicle Types | After Driver Management 10.1 |
+| Loyalty | `loyalty-7.7` Gift Card Platform | After Wallet 7.6 |
+| Hospitality | `hospitality-12.9` AI Assistant | After Analytics 12.8 |
+| Sports Center | `sports-center-9.8` Financial Integration | Cross-service APIs/events |
-## Related Documents
-
-- [Progress](progress.md)
-- [Phase Handover 7.6](phase-handover/phase-7-6.md)
-- [Phase Handover 14.7](phase-handover/phase-14-7.md)
-- [Phase Handover 13.7](phase-handover/phase-13-7.md)
-- [Healthcare Roadmap](healthcare-roadmap.md)
-- [Service Snapshot — Healthcare](service-snapshots/healthcare.yaml)
-- [Loyalty Phase 7.6](loyalty-phase-7-6.md)
-- [Beauty Business Roadmap](beauty-business-roadmap.md)
-- [Service Snapshot — Beauty Business](service-snapshots/beauty-business.yaml)
-- [AI Development Framework](ai-framework/README.md)
+Mandatory entry for any new phase: prior phase handover + [Enterprise Phase Discovery](ai-framework/enterprise-phase-discovery.md).
diff --git a/docs/phase-handover/phase-14-7.md b/docs/phase-handover/phase-14-7.md
new file mode 100644
index 0000000..e61267b
--- /dev/null
+++ b/docs/phase-handover/phase-14-7.md
@@ -0,0 +1,106 @@
+# Phase Handover — 14.7 Beauty Business Marketing & Loyalty Integration
+
+## Metadata
+
+| Field | Value |
+| --- | --- |
+| Phase ID | `beauty-business-14-7` |
+| Title | Marketing & Loyalty Integration |
+| Status | Complete |
+| Service(s) | `beauty_business` |
+| Version | 0.14.7.0 |
+| Date | 2026-07-26 |
+| Commercial product | Torbat Beauty |
+
+## Module completion summary
+
+Phases **14.0–14.7** are complete. Beauty Business is an independent platform (not merged with Healthcare).
+
+| Phase | Delivered |
+| --- | --- |
+| 14.0 | Foundation: orgs, branches, roles, config, audit, outbox |
+| 14.1 | Booking: schedules, appointments lifecycle, waiting list |
+| 14.2 | Salon: treatment rooms, stations, branch policies |
+| 14.3 | Catalog: service categories, beauty services |
+| 14.4 | Customers: profiles with CRM refs, preferences, visit history shells |
+| 14.5 | Packages: package definitions, membership plans |
+| 14.6 | Staff: staff profiles, commission rules |
+| 14.7 | Marketing: campaign refs, Loyalty/Communication integration configs, dispatch |
+
+## Reusable components
+
+| Component | Location | Reuse notes |
+| --- | --- | --- |
+| TenantBaseRepository | `app/repositories/base.py` | All beauty repos |
+| TransactionalEventPublisher | `app/events/publisher.py` | Outbox events |
+| Provider Protocols | `app/providers/contracts.py` | CRM, Loyalty, Communication, Accounting, Delivery, Experience |
+| Provider mocks | `app/providers/mocks.py` | Local integration dispatch |
+| BusinessFormat enum | `app/models/types.py` | salon, barbershop, clinic, laser, skin_care, hair_treatment, spa |
+
+## Public APIs (14.7 additions)
+
+| Method | Path | Permission | Notes |
+| --- | --- | --- | --- |
+| * | `/api/v1/marketing-campaigns` | `beauty_business.marketing.*` | Campaign ref shells |
+| * | `/api/v1/loyalty-integrations` | `beauty_business.integrations.*` | Earn mapping config |
+| * | `/api/v1/communication-integrations` | `beauty_business.integrations.*` | Template mapping |
+| POST | `/api/v1/integration-dispatches` | `beauty_business.integrations.*` | Mock dispatch to providers |
+
+## Frontend
+
+| Route | Purpose |
+| --- | --- |
+| `/beauty` | Dashboard + health/capabilities |
+| `/beauty/organizations`, `/beauty/branches` | Foundation |
+| `/beauty/appointments` | Booking |
+| `/beauty/catalog/*` | Service catalog |
+| `/beauty/customers` | Customer management |
+| `/beauty/staff/*` | Staff & commissions |
+| `/beauty/marketing/*` | Campaigns & integrations |
+
+BFF: `/api/beauty-business/*` → port **8011**.
+
+## Events
+
+| Event type | When |
+| --- | --- |
+| `beauty_business.marketing.campaign_ref.created` | Campaign ref registered |
+| `beauty_business.integration.loyalty_config.updated` | Loyalty mapping changed |
+| `beauty_business.integration.communication_config.updated` | Communication mapping changed |
+| `beauty_business.integration.dispatched` | Integration dispatch executed |
+
+## Known limitations
+
+- Mock-only provider calls for Loyalty/Communication/Accounting/CRM/Delivery/Experience
+- Secondary catalog/package entities without dedicated list APIs
+- No dedicated mobile stylist / customer apps
+- Healthcare module remains separate (`healthcare_db`, port 8010, `/healthcare`)
+
+## Migration notes
+
+| Item | Detail |
+| --- | --- |
+| Alembic revisions | `0001_initial` … `0008_phase_147_marketing` |
+| Database | `beauty_business_db` |
+| Compose | `beauty-business-service` port **8011** |
+| Tests | 30 passing (`pytest -q`) |
+
+## Validation
+
+- Backend: 30/30 tests green
+- Frontend: `npm run build` exit 0
+- Tenant isolation: enforced via `X-Tenant-ID`
+- Permissions: `beauty_business.*` tree
+
+## Next steps (post-14.7 — not registered phases)
+
+- Retail POS-lite for beauty products
+- Advanced analytics dashboards
+- Live provider SDK wiring
+- Dedicated CRUD for secondary entities
+
+## Related documents
+
+- [Beauty Business Roadmap](../beauty-business-roadmap.md)
+- [Service Snapshot](../service-snapshots/beauty-business.yaml)
+- [Phase area README](../phases/BeautyBusiness/README.md)
diff --git a/docs/phases/BeautyBusiness/README.md b/docs/phases/BeautyBusiness/README.md
new file mode 100644
index 0000000..71a66eb
--- /dev/null
+++ b/docs/phases/BeautyBusiness/README.md
@@ -0,0 +1,62 @@
+# Beauty Business — Phase Area
+
+> Commercial product: **Torbat Beauty**. Independent from [Healthcare](../Healthcare/README.md) — separate service, documentation, phases, frontend, permissions, and branding.
+
+## Overview
+
+Beauty Business is business management software for:
+
+- Women's Beauty Salons
+- Men's Barbershops
+- Beauty Clinics
+- Laser Centers
+- Skin Care Centers
+- Hair Treatment Centers
+- Spa & Wellness Centers
+
+It consumes Core platform services (Tenant, Identity, Authorization, CRM, Accounting, Loyalty, Communication, Delivery, Experience) via API and events only.
+
+## Status
+
+| Field | Value |
+| --- | --- |
+| Registration | **BB-Reg** — documentation complete |
+| Implementation | **Complete** (Phases 14.0–14.7) |
+| Current phase | `beauty-business-14-7` complete |
+| Service | `beauty_business` (active) |
+| Database | `beauty_business_db` |
+| Port | **8011** |
+| Version | **0.14.7.0** |
+
+## Phase Index
+
+| Phase | Document | Status |
+| --- | --- | --- |
+| 14.0 | [phase-14-0-beauty-foundation.md](phase-14-0-beauty-foundation.md) | Complete |
+| 14.1 | [phase-14-1-booking-engine.md](phase-14-1-booking-engine.md) | Complete |
+| 14.2 | [phase-14-2-salon-management.md](phase-14-2-salon-management.md) | Complete |
+| 14.3 | [phase-14-3-service-catalog.md](phase-14-3-service-catalog.md) | Complete |
+| 14.4 | [phase-14-4-customer-management.md](phase-14-4-customer-management.md) | Complete |
+| 14.5 | [phase-14-5-package-membership.md](phase-14-5-package-membership.md) | Complete |
+| 14.6 | [phase-14-6-staff-commission.md](phase-14-6-staff-commission.md) | Complete |
+| 14.7 | [phase-14-7-marketing-loyalty.md](phase-14-7-marketing-loyalty.md) | Complete |
+
+## Roadmap
+
+Full sequencing, boundaries, and future work: [beauty-business-roadmap.md](../../beauty-business-roadmap.md).
+
+## Framework
+
+All implementation phases must use the [Enterprise / AI Development Framework](../../ai-framework/README.md):
+
+- [Enterprise Phase Discovery](../../ai-framework/enterprise-phase-discovery.md) before coding
+- [Definition of Done](../../ai-framework/definition-of-done.md)
+- [Mandatory Phase Artifacts](../../ai-framework/mandatory-phase-artifacts.md)
+- [Phase template](../../ai-framework/phase-template.md)
+
+## Related Documents
+
+- [Module registry — beauty_business](../../module-registry.md#beauty_business)
+- [Module boundaries](../../architecture/module-boundaries.md)
+- [Progress](../../progress.md)
+- [Roadmap](../../roadmap.md)
diff --git a/docs/phases/BeautyBusiness/phase-14-0-beauty-foundation.md b/docs/phases/BeautyBusiness/phase-14-0-beauty-foundation.md
new file mode 100644
index 0000000..9795ec0
--- /dev/null
+++ b/docs/phases/BeautyBusiness/phase-14-0-beauty-foundation.md
@@ -0,0 +1,166 @@
+# Phase: Beauty Business Foundation
+
+| Field | Value |
+| --- | --- |
+| Identifier | `beauty-business-14-0` |
+| Status | Planned |
+| Owner | Platform |
+| Module(s) | `beauty_business.*` foundation |
+| Service(s) | `beauty_business` (new independent service) |
+| Depends On | Core Platform, Identity (JWT), shared-lib patterns |
+| ADR(s) | TBD at implementation (Independent Beauty Business Platform) |
+| Manifest | [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml) |
+| Framework | [ADR-013](../../architecture/adr/ADR-013.md) · [ADR-018](../../architecture/adr/ADR-018.md) · [ADR-019](../../architecture/adr/ADR-019.md) |
+
+## Objective
+
+Establish the independent Beauty Business platform (`Torbat Beauty`): service scaffold, database ownership, tenant-scoped foundation aggregates, permission catalog shells, configuration/settings, audit, health/capabilities/metrics, and publish-only event infrastructure — without booking, catalog, customer, package, commission, or marketing engines.
+
+## Enterprise Phase Discovery
+
+> **Required before Implementation.** Full Discovery Record per [enterprise-phase-discovery.md](../../ai-framework/enterprise-phase-discovery.md).
+
+| Field | Value |
+| --- | --- |
+| Discovery date | Pending (at implementation) |
+| Inputs reviewed | [beauty-business-roadmap.md](../../beauty-business-roadmap.md) · [module-boundaries.md](../../architecture/module-boundaries.md) · [module-registry.md](../../module-registry.md) · Prior platform service patterns |
+
+### Baseline Inventory (exists today)
+
+- BB-Reg documentation and architecture registration complete
+- No `backend/services/beauty_business` code
+- Port **8011** and `beauty_business_db` reserved in registry
+
+### Target Responsibility (this phase when Complete)
+
+- Independent FastAPI service with Alembic, tenant middleware, outbox publisher
+- Foundation aggregates: organization, branch shells, roles/permissions, external provider config shells, configuration, settings, audit
+- `/health`, `/capabilities`, `/metrics`; permission prefix `beauty_business.*`
+- Provider **contracts only** (CRM, Loyalty, Communication, Accounting, Experience, Delivery, Storage, AI)
+
+### Gap Analysis
+
+| Capability | Status | Action |
+| --- | --- | --- |
+| Service scaffold | Missing | Implement at 14.0 |
+| Foundation aggregates | Missing | Implement at 14.0 |
+| Booking / catalog / customer engines | Out of scope | Phase 14.1+ |
+| Healthcare overlap | N/A | Separate module — no shared tables |
+
+### Promoted to Implementation Scope
+
+- Service + DB + migrations + foundation CRUD shells
+- Architecture, tenant, permission, migration, docs tests
+
+### Explicitly Excluded (future phase or other service)
+
+- Appointments, services catalog, customers, packages, commissions, marketing
+- Healthcare clinical entities
+- Frontend UI (separate frontend phase/track)
+- Real provider SDK calls
+
+### Boundary Confirmations
+
+- [x] No future-phase pull-forward (documentation scope)
+- [x] No service-boundary violation vs Healthcare
+- [x] No duplication of CRM/Loyalty/Communication ownership
+- [x] CRUD-only delivery rejected at implementation (must include audit, events, capabilities)
+
+## Business Analysis
+
+| Item | Detail |
+| --- | --- |
+| Actors | Salon owner, platform admin |
+| Business capabilities (in phase) | Register beauty business org structure; configure module; RBAC shells |
+| Success metrics | Service boots; tenant isolation proven; capabilities discoverable |
+| Non-goals | End-user booking; clinical workflows |
+
+## Scope
+
+### In Scope
+
+- `backend/services/beauty_business` scaffold mirroring platform conventions
+- Database `beauty_business_db`; compose port **8011**
+- Foundation models and APIs for organizations, branches, roles, permissions catalog, configuration, settings, external provider registrations, audit
+- Transactional outbox; publish-only events `beauty_business.organization.*`, `beauty_business.branch.*`, etc.
+- Permissions `beauty_business.*` trees
+
+### Modules
+
+| Module | Change type | Notes |
+| --- | --- | --- |
+| `beauty_business.organizations` | new | Salon group / clinic chain |
+| `beauty_business.branches` | new | Location shells |
+| `beauty_business.roles` | new | Module RBAC |
+| `beauty_business.permissions_catalog` | new | Permission discovery |
+| `beauty_business.configuration` | new | Tenant config |
+| `beauty_business.settings` | new | Key-value settings |
+| `beauty_business.external_providers` | new | Adapter registration shells |
+| `beauty_business.audit` | new | Audit log |
+
+### Domain Model
+
+| Aggregate | Entities | Invariants |
+| --- | --- | --- |
+| BeautyOrganization | Organization | Tenant-scoped; soft delete |
+| BeautyBranch | Branch | Belongs to organization; tenant-scoped |
+| BeautyRole | Role, Permission | Module-local RBAC shells |
+
+### High-Level Entities (Planned at Implementation)
+
+| Entity | Soft delete | Audit | Tenant | Notes |
+| --- | --- | --- | --- | --- |
+| BeautyOrganization | Yes | Yes | Yes | Top-level beauty business |
+| BeautyBranch | Yes | Yes | Yes | Salon / clinic location |
+| BeautyRole | Yes | Yes | Yes | Staff role definitions |
+| BeautyPermission | No | Yes | Yes | Permission catalog entries |
+| ExternalProviderConfig | Yes | Yes | Yes | Adapter refs |
+| BeautyConfiguration | Yes | Yes | Yes | Module configuration |
+| BeautySetting | Yes | Yes | Yes | Settings |
+| BeautyAuditLog | No | N/A | Yes | Append-only |
+| OutboxEvent | No | N/A | Yes | Event publishing |
+
+## Out of Scope
+
+- Booking engine (14.1)
+- Rooms/stations operational management (14.2)
+- Service catalog (14.3)
+- Customer profiles (14.4)
+- Packages and memberships (14.5)
+- Staff commission (14.6)
+- Marketing and Loyalty integration (14.7)
+- Healthcare module entities or APIs
+- Frontend dashboards and navigation
+
+## Completion Criteria
+
+- [ ] Objective met
+- [ ] Enterprise Phase Discovery Record complete at implementation
+- [ ] Definition of Done satisfied
+- [ ] Tests green (architecture, API, tenant, permissions, migration, docs)
+- [ ] Module registry, progress, handover updated
+- [ ] No booking/catalog/customer engines in 14.0
+
+## Architecture Impact
+
+- New independent service and database per [service-architecture.md](../../architecture/service-architecture.md)
+- Module boundary entry for Beauty Business Platform in [module-boundaries.md](../../architecture/module-boundaries.md) (at implementation)
+- Reserved port 8011; no impact on Healthcare (8010)
+
+## Database Impact
+
+- New database `beauty_business_db`
+- Migration `0001_initial` (at implementation)
+
+## Risks
+
+- Confusion with Healthcare scheduling — mitigated by separate module keys, permissions, and documentation
+- Pull-forward of booking into foundation — mitigated by explicit Out of Scope
+
+## Related Documents
+
+- [Beauty Business Roadmap](../../beauty-business-roadmap.md)
+- [Phase area README](README.md)
+- [Module registry — beauty_business](../../module-registry.md#beauty_business)
+- [Healthcare roadmap](../../healthcare-roadmap.md) (sibling — do not merge)
+- [AI Framework](../../ai-framework/README.md)
diff --git a/docs/phases/BeautyBusiness/phase-14-1-booking-engine.md b/docs/phases/BeautyBusiness/phase-14-1-booking-engine.md
new file mode 100644
index 0000000..5b7863c
--- /dev/null
+++ b/docs/phases/BeautyBusiness/phase-14-1-booking-engine.md
@@ -0,0 +1,103 @@
+# Phase: Booking Engine
+
+| Field | Value |
+| --- | --- |
+| Identifier | `beauty-business-14-1` |
+| Status | Planned |
+| Owner | Platform |
+| Module(s) | `beauty_business.schedules`, `beauty_business.appointments`, `beauty_business.waiting_list` |
+| Service(s) | `beauty_business` |
+| Depends On | `beauty-business-14-0` |
+| ADR(s) | TBD |
+| Manifest | [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml) |
+| Framework | [ADR-013](../../architecture/adr/ADR-013.md) · [ADR-018](../../architecture/adr/ADR-018.md) · [ADR-019](../../architecture/adr/ADR-019.md) |
+
+## Objective
+
+Deliver the beauty-specific **booking engine**: staff schedules, availability windows, appointment types, appointment lifecycle (request → confirm → check-in → complete / cancel / no-show), conflict detection, and waiting list — without owning Communication delivery, Accounting postings, or full customer CRM profiles.
+
+## Enterprise Phase Discovery
+
+> **Required before Implementation.**
+
+| Field | Value |
+| --- | --- |
+| Discovery date | Pending |
+| Inputs reviewed | Phase 14.0 handover · [beauty-business-roadmap.md](../../beauty-business-roadmap.md) · Sports Center / Healthcare booking patterns (reference only) |
+
+### Target Responsibility (this phase when Complete)
+
+- Schedules and availability per staff and branch
+- Appointment aggregate with state machine and audit trail
+- Waiting list when slots unavailable
+- Events: `beauty_business.appointment.*`, `beauty_business.schedule.*`
+- Permissions: `beauty_business.appointments.*`, `beauty_business.schedules.*`
+
+### Explicitly Excluded
+
+- Service catalog pricing (14.3) — appointment may reference service ref UUID only
+- Customer profile management (14.4) — customer ref only
+- SMS/email reminders (Communication client in 14.7)
+- Payment capture (Accounting intents later)
+- Healthcare appointment types
+
+## Business Analysis
+
+| Item | Detail |
+| --- | --- |
+| Actors | Receptionist, stylist, customer (via portal API later) |
+| Business capabilities | Book, reschedule, cancel, check-in, no-show handling |
+| Success metrics | No double-booking; tenant isolation; lifecycle audit |
+| Non-goals | Multi-vertical generic scheduling platform |
+
+## Scope
+
+### In Scope
+
+- StaffSchedule, StaffAvailability, ScheduleException
+- AppointmentType, Appointment, AppointmentStatusHistory
+- WaitingListEntry
+- Conflict rules (staff + optional room ref from 14.2)
+- List APIs with pagination, filter, sort, search
+
+### High-Level Entities
+
+| Entity | Purpose |
+| --- | --- |
+| StaffSchedule | Recurring working pattern |
+| StaffAvailability | Bookable slots |
+| AppointmentType | Consultation, service, follow-up |
+| Appointment | Core booking aggregate |
+| WaitingListEntry | Queue for full slots |
+
+### Events (Planned)
+
+| Event | When |
+| --- | --- |
+| `beauty_business.appointment.created` | New booking |
+| `beauty_business.appointment.confirmed` | Confirmed |
+| `beauty_business.appointment.checked_in` | Customer arrived |
+| `beauty_business.appointment.completed` | Service done |
+| `beauty_business.appointment.cancelled` | Cancelled |
+| `beauty_business.appointment.no_show` | No-show marked |
+
+## Out of Scope
+
+- Salon rooms/stations management (14.2)
+- Service definitions and pricing (14.3)
+- Customer preferences and history (14.4)
+- Package session deduction (14.5)
+- Commission calculation (14.6)
+- Marketing reminders (14.7)
+
+## Completion Criteria
+
+- [ ] Booking lifecycle complete with tests
+- [ ] No Communication/Accounting implementation inside booking service layer
+- [ ] Healthcare module untouched
+
+## Related Documents
+
+- [Phase 14.0](phase-14-0-beauty-foundation.md)
+- [Beauty Business Roadmap](../../beauty-business-roadmap.md)
+- [Phase area README](README.md)
diff --git a/docs/phases/BeautyBusiness/phase-14-2-salon-management.md b/docs/phases/BeautyBusiness/phase-14-2-salon-management.md
new file mode 100644
index 0000000..7c3915a
--- /dev/null
+++ b/docs/phases/BeautyBusiness/phase-14-2-salon-management.md
@@ -0,0 +1,81 @@
+# Phase: Salon Management
+
+| Field | Value |
+| --- | --- |
+| Identifier | `beauty-business-14-2` |
+| Status | Planned |
+| Owner | Platform |
+| Module(s) | `beauty_business.branches`, `beauty_business.rooms`, `beauty_business.stations`, `beauty_business.branch_policies` |
+| Service(s) | `beauty_business` |
+| Depends On | `beauty-business-14-0` |
+| ADR(s) | TBD |
+| Manifest | [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml) |
+| Framework | [ADR-013](../../architecture/adr/ADR-013.md) · [ADR-018](../../architecture/adr/ADR-018.md) · [ADR-019](../../architecture/adr/ADR-019.md) |
+
+## Objective
+
+Extend branch operations for beauty venues: treatment rooms, styling stations, chair/bed resources, branch hours, policies (cancellation, deposit, late arrival), and operational settings — enabling booking engine (14.1) to attach resources without owning the booking lifecycle itself.
+
+## Enterprise Phase Discovery
+
+> **Required before Implementation.**
+
+### Target Responsibility (this phase when Complete)
+
+- TreatmentRoom, Station (chair, bed, laser room, spa cabin)
+- BranchOperatingHours, BranchPolicy
+- Resource availability flags for booking conflict checks
+- Events: `beauty_business.room.*`, `beauty_business.station.*`, `beauty_business.branch_policy.*`
+
+### Explicitly Excluded
+
+- Appointment state machine (14.1)
+- Service catalog (14.3)
+- Staff skills and commission (14.6)
+- Healthcare facility/clinical room models
+
+## Business Analysis
+
+| Item | Detail |
+| --- | --- |
+| Actors | Branch manager, salon owner |
+| Business capabilities | Configure locations, rooms, stations, policies |
+| Success metrics | Resources bookable by ref; policies enforceable in booking |
+| Non-goals | Building IoT, smart-lock vendors |
+
+## Scope
+
+### In Scope
+
+- Extend BeautyBranch with operational metadata (format: salon, barbershop, clinic, laser, spa)
+- TreatmentRoom and Station aggregates with capacity and status
+- BranchOperatingHours and BranchPolicy (cancellation window, deposit required shell)
+- Permissions: `beauty_business.rooms.*`, `beauty_business.stations.*`, `beauty_business.branch_policies.*`
+
+### High-Level Entities
+
+| Entity | Purpose |
+| --- | --- |
+| TreatmentRoom | Laser room, facial room, spa suite |
+| Station | Chair, barber station, nail desk |
+| BranchOperatingHours | Open/close per day |
+| BranchPolicy | Cancellation, deposit, buffer rules |
+
+## Out of Scope
+
+- Booking lifecycle (14.1) — consumes room/station refs only
+- Service definitions (14.3)
+- Inventory / retail POS
+- Healthcare departments and clinical wards
+
+## Completion Criteria
+
+- [ ] Room/station CRUD + policies with tenant isolation
+- [ ] Booking engine can reference resources by UUID
+- [ ] Separate from Healthcare clinic management
+
+## Related Documents
+
+- [Phase 14.0](phase-14-0-beauty-foundation.md)
+- [Phase 14.1](phase-14-1-booking-engine.md)
+- [Beauty Business Roadmap](../../beauty-business-roadmap.md)
diff --git a/docs/phases/BeautyBusiness/phase-14-3-service-catalog.md b/docs/phases/BeautyBusiness/phase-14-3-service-catalog.md
new file mode 100644
index 0000000..092fa25
--- /dev/null
+++ b/docs/phases/BeautyBusiness/phase-14-3-service-catalog.md
@@ -0,0 +1,83 @@
+# Phase: Service Catalog
+
+| Field | Value |
+| --- | --- |
+| Identifier | `beauty-business-14-3` |
+| Status | Planned |
+| Owner | Platform |
+| Module(s) | `beauty_business.service_categories`, `beauty_business.services`, `beauty_business.service_variants` |
+| Service(s) | `beauty_business` |
+| Depends On | `beauty-business-14-0` |
+| ADR(s) | TBD |
+| Manifest | [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml) |
+| Framework | [ADR-013](../../architecture/adr/ADR-013.md) · [ADR-018](../../architecture/adr/ADR-018.md) · [ADR-019](../../architecture/adr/ADR-019.md) |
+
+## Objective
+
+Provide the beauty **service catalog**: categories (hair, skin, laser, spa, nails, men's grooming), services, variants (duration, price tier, required skills), availability by branch, and media refs — without owning Accounting price lists, CRM products, or Healthcare clinical service codes.
+
+## Enterprise Phase Discovery
+
+> **Required before Implementation.**
+
+### Target Responsibility (this phase when Complete)
+
+- ServiceCategory tree (tenant-scoped)
+- BeautyService with industry tags (salon, barbershop, clinic, laser, spa)
+- ServiceVariant: duration, base price shell, skill requirements
+- BranchServiceAssignment (which services offered where)
+- Media refs (File Storage) for service images
+- Events: `beauty_business.service.*`, `beauty_business.service_category.*`
+
+### Explicitly Excluded
+
+- Appointment booking (14.1)
+- Package bundling (14.5)
+- Journal posting / tax (Accounting)
+- Healthcare clinic_services / CPT codes
+
+## Business Analysis
+
+| Item | Detail |
+| --- | --- |
+| Actors | Branch manager, salon owner |
+| Business capabilities | Define offerings, durations, branch availability |
+| Success metrics | Services selectable in booking by ref |
+| Non-goals | Marketplace product catalog |
+
+## Scope
+
+### In Scope
+
+- ServiceCategory, BeautyService, ServiceVariant
+- BranchServiceAssignment, ServiceSkillRequirement (refs staff skills from 14.6)
+- ServiceMediaRef (storage ref only)
+- Permissions: `beauty_business.services.*`, `beauty_business.service_categories.*`
+
+### High-Level Entities
+
+| Entity | Purpose |
+| --- | --- |
+| ServiceCategory | Hair, Skin, Laser, Spa, Nails, … |
+| BeautyService | Cut, color, facial, laser session, massage |
+| ServiceVariant | 30/60/90 min; standard/premium |
+| BranchServiceAssignment | Branch-specific catalog |
+| ServiceMediaRef | Portfolio / menu image ref |
+
+## Out of Scope
+
+- Appointments consuming services (14.1 integration by ref only)
+- Packages and memberships (14.5)
+- Commission rates (14.6)
+- Experience public menu pages (Experience consumes API)
+
+## Completion Criteria
+
+- [ ] Catalog CRUD with variants and branch assignment
+- [ ] No Accounting-owned price engine duplication
+- [ ] Healthcare service catalog untouched
+
+## Related Documents
+
+- [Phase 14.0](phase-14-0-beauty-foundation.md)
+- [Beauty Business Roadmap](../../beauty-business-roadmap.md)
diff --git a/docs/phases/BeautyBusiness/phase-14-4-customer-management.md b/docs/phases/BeautyBusiness/phase-14-4-customer-management.md
new file mode 100644
index 0000000..878de1c
--- /dev/null
+++ b/docs/phases/BeautyBusiness/phase-14-4-customer-management.md
@@ -0,0 +1,82 @@
+# Phase: Customer Management
+
+| Field | Value |
+| --- | --- |
+| Identifier | `beauty-business-14-4` |
+| Status | Planned |
+| Owner | Platform |
+| Module(s) | `beauty_business.customers`, `beauty_business.customer_preferences`, `beauty_business.visit_history` |
+| Service(s) | `beauty_business` |
+| Depends On | `beauty-business-14-0`, `beauty-business-14-1` (visit refs) |
+| ADR(s) | TBD |
+| Manifest | [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml) |
+| Framework | [ADR-013](../../architecture/adr/ADR-013.md) · [ADR-018](../../architecture/adr/ADR-018.md) · [ADR-019](../../architecture/adr/ADR-019.md) |
+
+## Objective
+
+Manage **beauty customers** in module context: profiles linked to CRM contact refs, preferences (stylist, allergies/sensitivities notes for beauty context only, communication opt-in), visit history shells, and customer portal API contracts — without owning CRM Contact aggregates or Healthcare patient records.
+
+## Enterprise Phase Discovery
+
+> **Required before Implementation.**
+
+### Target Responsibility (this phase when Complete)
+
+- CustomerProfile with `crm_contact_ref` and optional `identity_user_ref`
+- CustomerPreference (preferred stylist, notes, marketing opt-in flags)
+- VisitHistoryEntry (refs completed appointments; summary only)
+- CustomerDocumentRef (storage ref for patch tests, consent forms — beauty context)
+- Events: `beauty_business.customer.*`
+- Permissions: `beauty_business.customers.*`
+
+### Explicitly Excluded
+
+- CRM Lead/Contact CRUD (CRM service)
+- Loyalty Member ledger (Loyalty service)
+- Healthcare Patient / medical records
+- Frontend customer portal UI
+
+## Business Analysis
+
+| Item | Detail |
+| --- | --- |
+| Actors | Receptionist, stylist, customer (self-service APIs) |
+| Business capabilities | Register customers, track preferences, view visit summary |
+| Success metrics | CRM ref integrity; no duplicate CRM ownership |
+| Non-goals | Full Customer360 platform |
+
+## Scope
+
+### In Scope
+
+- CustomerProfile, CustomerPreference, VisitHistoryEntry, CustomerDocumentRef
+- Search/filter customers for reception workflows
+- Link appointments (14.1) to customer profile
+
+### High-Level Entities
+
+| Entity | Purpose |
+| --- | --- |
+| CustomerProfile | Beauty-context customer |
+| CustomerPreference | Stylist, notes, opt-ins |
+| VisitHistoryEntry | Completed visit summary |
+| CustomerDocumentRef | Consent / patch test file ref |
+
+## Out of Scope
+
+- CRM contact creation logic (call CRM API or store ref only)
+- Loyalty enrollment (14.7)
+- Packages (14.5)
+- Healthcare patient portal and PHI
+
+## Completion Criteria
+
+- [ ] Customer module with CRM refs only — no CRM model imports
+- [ ] Visit history references appointments, does not duplicate booking engine
+- [ ] Healthcare patient module untouched
+
+## Related Documents
+
+- [Phase 14.1](phase-14-1-booking-engine.md)
+- [Beauty Business Roadmap](../../beauty-business-roadmap.md)
+- [CRM module registry](../../module-registry.md#crm)
diff --git a/docs/phases/BeautyBusiness/phase-14-5-package-membership.md b/docs/phases/BeautyBusiness/phase-14-5-package-membership.md
new file mode 100644
index 0000000..a08db95
--- /dev/null
+++ b/docs/phases/BeautyBusiness/phase-14-5-package-membership.md
@@ -0,0 +1,83 @@
+# Phase: Package & Membership
+
+| Field | Value |
+| --- | --- |
+| Identifier | `beauty-business-14-5` |
+| Status | Planned |
+| Owner | Platform |
+| Module(s) | `beauty_business.packages`, `beauty_business.memberships` |
+| Service(s) | `beauty_business` |
+| Depends On | `beauty-business-14-0`, `beauty-business-14-3` (service refs), `beauty-business-14-4` (customer refs) |
+| ADR(s) | TBD |
+| Manifest | [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml) |
+| Framework | [ADR-013](../../architecture/adr/ADR-013.md) · [ADR-018](../../architecture/adr/ADR-018.md) · [ADR-019](../../architecture/adr/ADR-019.md) |
+
+## Objective
+
+Deliver beauty **commercial packages and memberships**: prepaid multi-session bundles, session balance tracking, recurring membership plans (monthly spa, unlimited blow-dry tiers), redemption against appointments — without owning Loyalty program ledger or Accounting subscription billing.
+
+## Enterprise Phase Discovery
+
+> **Required before Implementation.**
+
+### Target Responsibility (this phase when Complete)
+
+- PackageDefinition (sessions, services included, validity)
+- CustomerPackage with session balance and redemption history
+- MembershipPlan and CustomerMembership (renewal, freeze, cancel shells)
+- Redemption hooks on appointment completion (14.1)
+- Events: `beauty_business.package.*`, `beauty_business.membership.*`
+- Permissions: `beauty_business.packages.*`, `beauty_business.memberships.*`
+
+### Explicitly Excluded
+
+- Loyalty PointAccount / Wallet (Loyalty service)
+- Accounting invoice/subscription engine
+- Healthcare treatment plans / insurance packages
+
+## Business Analysis
+
+| Item | Detail |
+| --- | --- |
+| Actors | Front desk, salon owner, customer |
+| Business capabilities | Sell packages, track sessions, manage memberships |
+| Success metrics | Accurate session balance; idempotent redemption |
+| Non-goals | Platform-wide SaaS subscription (Core) |
+
+## Scope
+
+### In Scope
+
+- PackageDefinition, PackageLine (service refs)
+- CustomerPackage, PackageRedemption
+- MembershipPlan, CustomerMembership, MembershipLifecycleEvent
+- Integration intents to Accounting (payment ref only)
+
+### High-Level Entities
+
+| Entity | Purpose |
+| --- | --- |
+| PackageDefinition | 10-session laser package |
+| CustomerPackage | Purchased package instance |
+| PackageRedemption | Session consumed on visit |
+| MembershipPlan | Monthly spa membership |
+| CustomerMembership | Active membership instance |
+
+## Out of Scope
+
+- Loyalty campaigns and points (14.7 / Loyalty service)
+- Full payment gateway (Accounting / future payment provider)
+- Commission on package sales (14.6)
+
+## Completion Criteria
+
+- [ ] Package redemption integrated with appointment completion by ref
+- [ ] No Loyalty ledger duplication
+- [ ] Healthcare billing packages untouched
+
+## Related Documents
+
+- [Phase 14.3](phase-14-3-service-catalog.md)
+- [Phase 14.4](phase-14-4-customer-management.md)
+- [Beauty Business Roadmap](../../beauty-business-roadmap.md)
+- [Loyalty module registry](../../module-registry.md#loyalty)
diff --git a/docs/phases/BeautyBusiness/phase-14-6-staff-commission.md b/docs/phases/BeautyBusiness/phase-14-6-staff-commission.md
new file mode 100644
index 0000000..f556b6c
--- /dev/null
+++ b/docs/phases/BeautyBusiness/phase-14-6-staff-commission.md
@@ -0,0 +1,82 @@
+# Phase: Staff & Commission
+
+| Field | Value |
+| --- | --- |
+| Identifier | `beauty-business-14-6` |
+| Status | Planned |
+| Owner | Platform |
+| Module(s) | `beauty_business.staff`, `beauty_business.commissions` |
+| Service(s) | `beauty_business` |
+| Depends On | `beauty-business-14-0`, `beauty-business-14-1` (schedules), `beauty-business-14-3` (skills) |
+| ADR(s) | TBD |
+| Manifest | [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml) |
+| Framework | [ADR-013](../../architecture/adr/ADR-013.md) · [ADR-018](../../architecture/adr/ADR-018.md) · [ADR-019](../../architecture/adr/ADR-019.md) |
+
+## Objective
+
+Manage **beauty staff** and **commission**: staff profiles linked to Identity users, skills and certifications, working hours (feeds 14.1 schedules), commission rules (percentage, tiered, service-based), commission entries on completed services — without owning Identity admin, HCM payroll (Accounting Phase 5.9), or Healthcare doctor profiles.
+
+## Enterprise Phase Discovery
+
+> **Required before Implementation.**
+
+### Target Responsibility (this phase when Complete)
+
+- StaffProfile (identity_user_ref, branch assignment, staff_kind: stylist, barber, therapist, laser tech)
+- StaffSkill, StaffCertificate (refs Storage for docs)
+- StaffWorkingHours (may unify with 14.1 schedules per Discovery)
+- CommissionRule, CommissionEntry (accrual shells)
+- Payroll export intents to Accounting (refs only)
+- Events: `beauty_business.staff.*`, `beauty_business.commission.*`
+
+### Explicitly Excluded
+
+- Identity user provisioning
+- Accounting PayrollEngine implementation
+- Healthcare doctor panel and clinical credentials
+
+## Business Analysis
+
+| Item | Detail |
+| --- | --- |
+| Actors | Salon owner, branch manager, stylist |
+| Business capabilities | Staff roster, skills, commission calculation |
+| Success metrics | Commission entries match completed appointments |
+| Non-goals | Full HRIS |
+
+## Scope
+
+### In Scope
+
+- StaffProfile, StaffSkill, StaffCertificate
+- CommissionRule (per service, per staff tier, split rules shell)
+- CommissionEntry (generated on appointment.completed)
+- Permissions: `beauty_business.staff.*`, `beauty_business.commissions.*`
+
+### High-Level Entities
+
+| Entity | Purpose |
+| --- | --- |
+| StaffProfile | Beauty professional |
+| StaffSkill | Color specialist, laser certified, … |
+| CommissionRule | 40% service commission |
+| CommissionEntry | Accrued commission line |
+
+## Out of Scope
+
+- Booking availability engine (14.1)
+- Marketing (14.7)
+- Accounting journal posting (intents only)
+- Healthcare doctors and visit sessions
+
+## Completion Criteria
+
+- [ ] Staff linked by Identity ref only
+- [ ] Commission entries triggered from appointment events
+- [ ] No payroll engine duplication in beauty_business service
+
+## Related Documents
+
+- [Phase 14.1](phase-14-1-booking-engine.md)
+- [Phase 14.3](phase-14-3-service-catalog.md)
+- [Beauty Business Roadmap](../../beauty-business-roadmap.md)
diff --git a/docs/phases/BeautyBusiness/phase-14-7-marketing-loyalty.md b/docs/phases/BeautyBusiness/phase-14-7-marketing-loyalty.md
new file mode 100644
index 0000000..14deb4d
--- /dev/null
+++ b/docs/phases/BeautyBusiness/phase-14-7-marketing-loyalty.md
@@ -0,0 +1,89 @@
+# Phase: Marketing & Loyalty
+
+| Field | Value |
+| --- | --- |
+| Identifier | `beauty-business-14-7` |
+| Status | Planned |
+| Owner | Platform |
+| Module(s) | `beauty_business.marketing`, `beauty_business.loyalty_integration`, `beauty_business.communication_integration` |
+| Service(s) | `beauty_business` |
+| Depends On | `beauty-business-14-0` through `beauty-business-14-6`; Loyalty, Communication, Experience platforms |
+| ADR(s) | TBD |
+| Manifest | [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml) |
+| Framework | [ADR-013](../../architecture/adr/ADR-013.md) · [ADR-018](../../architecture/adr/ADR-018.md) · [ADR-019](../../architecture/adr/ADR-019.md) |
+
+## Objective
+
+Close the **growth loop** for beauty businesses: marketing campaign refs, birthday/ win-back triggers, Loyalty earn-on-visit and referral hooks, Communication templates for reminders and promotions, Experience widget refs for public booking — all as **client integrations** without owning Loyalty ledger, Communication providers, or CRM campaigns.
+
+## Enterprise Phase Discovery
+
+> **Required before Implementation.**
+
+### Target Responsibility (this phase when Complete)
+
+- MarketingCampaignRef (local metadata + external campaign id ref)
+- LoyaltyIntegrationConfig (program ref, earn rules mapping on appointment.completed)
+- CommunicationIntegrationConfig (template keys for confirm/remind/promo)
+- ReferralAttributionRef (Loyalty referral ref)
+- Connector dispatch pattern (mock-first) for Loyalty + Communication APIs
+- Events: `beauty_business.marketing.*`, `beauty_business.integration.*`
+- Permissions: `beauty_business.marketing.*`, `beauty_business.integrations.*`
+
+### Explicitly Excluded
+
+- Loyalty PointEngine / CampaignEngine implementation
+- Communication SMS/email provider adapters
+- CRM marketing automation
+- Healthcare patient outreach / clinical notifications
+
+## Business Analysis
+
+| Item | Detail |
+| --- | --- |
+| Actors | Salon owner, marketing staff |
+| Business capabilities | Reminders, promotions, points on visit, referral tracking |
+| Success metrics | Integration calls idempotent; no provider ownership |
+| Non-goals | Standalone marketing automation platform |
+
+## Scope
+
+### In Scope
+
+- MarketingCampaignRef, PromotionRuleRef (local shells)
+- LoyaltyIntegrationConfig + earn/redeem dispatch on domain events
+- CommunicationIntegrationConfig + reminder dispatch schedules (job shells)
+- ExperienceBookingWidgetRef (public embed config)
+- Optional DeliveryIntegrationRef for retail product shipments
+
+### High-Level Entities
+
+| Entity | Purpose |
+| --- | --- |
+| MarketingCampaignRef | Local campaign metadata |
+| LoyaltyIntegrationConfig | Map visits → points |
+| CommunicationIntegrationConfig | Template mapping |
+| IntegrationDispatch | Outbound call audit log |
+
+## Out of Scope
+
+- New Communication channels
+- Loyalty wallet / gift cards
+- Full analytics dashboard (future roadmap)
+- Frontend marketing UI (separate track)
+- Healthcare pharmacy / prescription notifications
+
+## Completion Criteria
+
+- [ ] Provider contracts + mock dispatch for Loyalty and Communication
+- [ ] Appointment lifecycle triggers integration hooks
+- [ ] No duplication of Loyalty or Communication domain models
+- [ ] Healthcare module untouched
+
+## Related Documents
+
+- [Phase 14.1](phase-14-1-booking-engine.md)
+- [Phase 14.4](phase-14-4-customer-management.md)
+- [Phase 14.5](phase-14-5-package-membership.md)
+- [Beauty Business Roadmap](../../beauty-business-roadmap.md)
+- [Loyalty](../../module-registry.md#loyalty) · [Communication](../../module-registry.md#communication) · [Experience](../../module-registry.md#experience)
diff --git a/docs/progress.md b/docs/progress.md
index b45f3ab..6df81bd 100644
--- a/docs/progress.md
+++ b/docs/progress.md
@@ -284,6 +284,7 @@ Remaining (honest): Phase 5.12 AI blocked; budget/DMS/integration still typed-op
- [x] Tests: architecture, health/security, providers/SMS/failover/queue, templates/contacts/OTP/webhooks, validators (42 passing)
- [x] **Production Ready (SMS MVP)** — feature-complete for current scope; future channels in [communication-roadmap.md](communication-roadmap.md) only
- [x] Docs: [communication-phase-8.md](communication-phase-8.md), [phase-handover/phase-8.md](phase-handover/phase-8.md), [service-snapshots/communication.yaml](service-snapshots/communication.yaml), ADR-012, module/provider registry, progress
+- [x] Frontend: [frontend/modules/communication/](../frontend/modules/communication/) — SMS connected; future modules Feature Lock ([frontend/docs/communication-frontend-architecture.md](../frontend/docs/communication-frontend-architecture.md))
---
@@ -527,6 +528,99 @@ Remaining (honest): Phase 5.12 AI blocked; budget/DMS/integration still typed-op
---
+## Phase 11.4 — Experience Template System ✅
+
+- [x] Template catalog (`ExperienceTemplate`) — code, name, page_type, category, tags, starter-pack flag
+- [x] Immutable published template versions (`ExperienceTemplateVersion`) with `blueprint_json`
+- [x] Template instantiation (`TemplateInstantiation`) — audit trail from a published version to a created page
+- [x] Lifecycle: draft → active → deprecated/archived; version draft → published (immutable) → retired
+- [x] Policies, specifications, commands, queries; list filter/sort/search
+- [x] Permissions `experience.templates.*` (full CRUD tree)
+- [x] Events `experience.template.*` / `experience.template_version.*` / `experience.template.instantiated`
+- [x] Alembic `0005_phase_114_templates`; version `0.11.4.0`
+- [x] Tests: health, full lifecycle + instantiation flow, tenant isolation, immutable version update blocked, architecture, migration, permissions, docs
+- [x] Docs: [experience-phase-11-4.md](experience-phase-11-4.md), [phase-handover/phase-11-4.md](phase-handover/phase-11-4.md)
+- [x] No forms / surveys / locales / media / SEO engines
+
+---
+
+## Phase 11.5 — Experience Localization, RTL/LTR & Media ✅
+
+- [x] Site locale bindings (`SiteLocaleBinding`) — locale profile + concrete RTL/LTR + default flag
+- [x] Localization shells (`ExperienceLocalization`) — target/locale upsert with directionality
+- [x] Media refs (`ExperienceMediaRef`) — `storage_file_ref` only; reject data-URI / binary metadata
+- [x] Policies, specifications, commands, queries; list filter/sort/search
+- [x] Permissions `experience.locales.*` / `experience.media_refs.*`
+- [x] Events `experience.site_locale.*` / `experience.localization.*` / `experience.media_ref.*`
+- [x] Alembic `0006_phase_115_localization_media`; version `0.11.5.0`
+- [x] Tests: health, binding + localization lifecycle, media isolation, architecture, migration, permissions, docs
+- [x] Docs: [experience-phase-11-5.md](experience-phase-11-5.md), [phase-handover/phase-11-5.md](phase-handover/phase-11-5.md)
+- [x] No forms / surveys / appointments / SEO engines
+
+---
+
+## Phase 11.6 — Experience Forms, Surveys & Appointments ✅
+
+- [x] Forms + form submissions (payload + CRM/Communication refs only)
+- [x] Surveys + survey responses
+- [x] Appointment pages + external booking refs (no scheduler ownership)
+- [x] Policies/validators reject inactive intake and ownership JSON keys
+- [x] Permissions `experience.forms.*` / `experience.surveys.*` / `experience.appointments.*`
+- [x] Events for form/survey/appointment + submission/response/booking-ref lifecycle
+- [x] Alembic `0007_phase_116_forms_surveys_appointments`; version `0.11.6.0`
+- [x] Tests: health, form/submission, survey/appointment booking refs, architecture, migration, docs
+- [x] Docs: [experience-phase-11-6.md](experience-phase-11-6.md), [phase-handover/phase-11-6.md](phase-handover/phase-11-6.md)
+- [x] No SEO/PWA engines
+
+---
+
+## Phase 11.7 — Experience Publishing, Domains, SEO & PWA ✅
+
+- [x] Publish releases and domain edge binding references
+- [x] SEO metadata, sitemap and robots shells, and PWA manifests
+- [x] Permissions `experience.publishing.*`, `experience.seo.*`, `experience.pwa.*`
+- [x] Publish-only lifecycle events and optimistic versioning
+- [x] Alembic `0008_phase_117_publishing_seo_pwa`; version `0.11.7.0`
+- [x] DNS/SSL/edge/storage ownership remains external; PEM/private keys rejected
+- [x] Docs: [experience-phase-11-7.md](experience-phase-11-7.md), [phase-handover/phase-11-7.md](phase-handover/phase-11-7.md)
+
+---
+
+## Phase 11.8 — Experience Bundles, Licensing & Feature Toggles ✅
+
+- [x] Bundle definitions, tenant bundle assignments, Core entitlement refs, and feature toggles
+- [x] Filtered/paginated CRUD APIs, audit, optimistic locking, soft delete, tenant isolation
+- [x] Permissions `experience.bundles.*` and `experience.feature_toggles.*`; lifecycle events
+- [x] Alembic `0009_phase_118_bundles_toggles`; version `0.11.8.0`
+- [x] Core retains plan/subscription/billing ownership; forbidden ownership metadata rejected
+- [x] Docs: [experience-phase-11-8.md](experience-phase-11-8.md), [phase-handover/phase-11-8.md](phase-handover/phase-11-8.md)
+
+---
+
+## Phase 11.9 — Experience Consumer Integrations & Widgets ✅
+
+- [x] Consumer connector registrations/dispatches, widget definitions/instances, notify dispatches
+- [x] Mock providers for connector execute and notify send; forbidden ownership keys rejected
+- [x] Filtered/paginated CRUD APIs, execute/send actions, audit, optimistic locking, soft delete
+- [x] Permissions `experience.consumer_connectors.*`, `experience.widgets.*`, `experience.notify.*`
+- [x] Alembic `0010_phase_119_consumer_widgets`; version `0.11.9.0`
+- [x] Docs: [experience-phase-11-9.md](experience-phase-11-9.md), [phase-handover/phase-11-9.md](phase-handover/phase-11-9.md)
+
+---
+
+## Phase 11.10 — Experience Analytics, AI Content & Enterprise Validation ✅
+
+- [x] Analytics report definitions + snapshots (local COUNT on `experience_db` only)
+- [x] AI content hook registrations + dispatches via `MockAIProvider.generate_content`
+- [x] Forbidden ownership keys in config/metadata/payload; suspended registration → generate 409
+- [x] Permissions `experience.analytics.*`, `experience.ai_hooks.*`
+- [x] Alembic `0011_phase_1110_analytics_ai_hooks`; version `0.11.10.0`
+- [x] Enterprise validation audit + service snapshot
+- [x] Docs: [experience-phase-11-10.md](experience-phase-11-10.md), [phase-handover/phase-11-10.md](phase-handover/phase-11-10.md)
+- [x] **Experience track 11.0–11.10 complete**
+
+---
+
## Phase 12.0 — Hospitality Platform Foundation ✅
- [x] Independent `backend/services/hospitality` (`hospitality_db`, port **8009**, version **0.12.0.0**, commercial product **Torbat Food**)
@@ -665,6 +759,23 @@ Remaining (honest): Phase 5.12 AI blocked; budget/DMS/integration still typed-op
---
+## Delivery Platform Frontend (Torbat Driver) ✅ baseline
+
+- [x] Module scaffold `frontend/modules/delivery/` per frontend architecture migration
+- [x] Thin App Router routes `app/delivery/` (56+ screens, loading/error per segment)
+- [x] BFF proxy `/api/delivery/*` → delivery-service :8007
+- [x] Typed `delivery-api` client — organizations, hubs, drivers (full lifecycle), providers, routing engines, configurations, settings, audit, permissions catalog, health/capabilities/metrics
+- [x] Portal shell, CRUD factory, capability phase gates (no mock data for future APIs)
+- [x] Driver detail: profile, lifecycle actions, credentials, documents
+- [x] Executive dashboard with real tenant-scoped counts
+- [x] Design tokens `--delivery-accent*`; apps catalog tile → `/delivery/hub`
+- [x] `npm run validate:delivery`; ESLint + architecture boundary for delivery domain
+- [x] Docs: `frontend/docs/delivery-*.md`
+
+Remaining (honest): dispatch/fleet/tracking/maps live/realtime/settlement screens unlock when backend phases 10.2–10.10 enable `/capabilities` flags.
+
+---
+
## Phase HC-Reg — Healthcare Platform Registration ✅
> Documentation-only registration. No business code, models, APIs, migrations, or repositories.
@@ -928,3 +1039,8 @@ Remaining (honest): Phase 5.12 AI blocked; budget/DMS/integration still typed-op
- [Beauty Business Phase Area](phases/BeautyBusiness/README.md)
- [Beauty Business Snapshot](service-snapshots/beauty-business.yaml)
- [Phase Handover 14.7](phase-handover/phase-14-7.md)
+
+## Loyalty Frontend (2026-07-26)
+- Added `frontend/modules/loyalty` engage portal + thin `app/loyalty` routes.
+- BFF `/api/loyalty` → Loyalty service 8004; wired CRUD/workflows for phases 7.0–7.6.
+- Docs: `frontend/docs/loyalty-*.md`.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 0d8ba26..0eddeba 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -38,7 +38,7 @@
20. ~~Experience Sites & Page Resources~~ (Phase 11.1 done)
21. ~~Experience Component Library & Versioning~~ (Phase 11.2 done)
22. ~~Experience Theme & Layout Engine~~ (Phase 11.3 done)
-23. Experience Template System (Phase 11.4)
+23. ~~Experience Template System~~ (Phase 11.4 done)
24. Experience Localization through Enterprise Validation (Phases 11.5–11.10)
25. Further CRM / cross-module work only when explicitly scoped
26. Notification service as thin consumer of Communication (or merge later)
diff --git a/docs/service-snapshots/beauty-business.yaml b/docs/service-snapshots/beauty-business.yaml
new file mode 100644
index 0000000..2e9e7cb
--- /dev/null
+++ b/docs/service-snapshots/beauty-business.yaml
@@ -0,0 +1,115 @@
+# Beauty Business service snapshot — Phases 14.0–14.7 complete.
+# Spec: docs/ai-framework/service-snapshot-policy.md
+
+schema_version: 1
+snapshot_version: 1
+
+service_name: beauty_business
+commercial_product: Torbat Beauty
+current_version: "0.14.7.0"
+current_phase: beauty-business-14-7
+last_completed_phase: beauty-business-14-7
+next_phase: null
+
+completed_phases:
+ - beauty-business-14-0
+ - beauty-business-14-1
+ - beauty-business-14-2
+ - beauty-business-14-3
+ - beauty-business-14-4
+ - beauty-business-14-5
+ - beauty-business-14-6
+ - beauty-business-14-7
+remaining_phases: []
+
+registered_modules:
+ - organizations
+ - branches
+ - roles
+ - permissions_catalog
+ - configuration
+ - settings
+ - external_providers
+ - audit
+ - staff_schedules
+ - staff_availabilities
+ - schedule_exceptions
+ - appointment_types
+ - appointments
+ - waiting_list
+ - treatment_rooms
+ - stations
+ - branch_policies
+ - service_categories
+ - beauty_services
+ - customer_profiles
+ - package_definitions
+ - membership_plans
+ - staff_profiles
+ - commission_rules
+ - marketing_campaign_refs
+ - loyalty_integrations
+ - communication_integrations
+ - integration_dispatches
+ - outbox
+
+enabled_capabilities:
+ - foundation
+ - booking_engine
+ - salon_management
+ - service_catalog
+ - customer_management
+ - packages_memberships
+ - staff_commission
+ - marketing_loyalty_integration
+
+enabled_bundles:
+ - torbat_beauty
+
+public_apis:
+ - { method: GET, path: /health, permission: public }
+ - { method: GET, path: /capabilities, permission: public }
+ - { method: GET, path: /metrics, permission: public }
+ - { method: "*", path: /api/v1/organizations, permission: beauty_business.organizations.* }
+ - { method: "*", path: /api/v1/branches, permission: beauty_business.branches.* }
+ - { method: "*", path: /api/v1/appointments, permission: beauty_business.appointments.* }
+ - { method: "*", path: /api/v1/services, permission: beauty_business.services.* }
+ - { method: "*", path: /api/v1/customers, permission: beauty_business.customers.* }
+ - { method: "*", path: /api/v1/staff, permission: beauty_business.staff.* }
+ - { method: "*", path: /api/v1/marketing-campaigns, permission: beauty_business.marketing.* }
+ - { method: "*", path: /api/v1/integration-dispatches, permission: beauty_business.integrations.* }
+
+published_events:
+ - { event_type: beauty_business.organization.created, version: "1" }
+ - { event_type: beauty_business.branch.created, version: "1" }
+ - { event_type: beauty_business.appointment.created, version: "1" }
+ - { event_type: beauty_business.appointment.completed, version: "1" }
+ - { event_type: beauty_business.service.created, version: "1" }
+ - { event_type: beauty_business.customer.created, version: "1" }
+ - { event_type: beauty_business.staff.created, version: "1" }
+ - { event_type: beauty_business.integration.dispatched, version: "1" }
+
+permission_prefix: beauty_business.*
+
+active_adrs: []
+integration_contracts:
+ - CRMProvider
+ - AccountingProvider
+ - LoyaltyProvider
+ - CommunicationProvider
+ - DeliveryProvider
+ - ExperienceProvider
+ - StorageProvider
+ - AIProvider
+ - IdentityProvider
+
+known_limitations:
+ - Secondary entities (ServiceVariant, BranchOperatingHours, PackageLine, CommissionEntry) have models/migrations but no dedicated CRUD routes
+ - Integration dispatches use mock providers locally; no live CRM/Loyalty/Communication SDK calls
+ - Event publisher uses transactional outbox; no external bus drain wired
+ - Independent from Healthcare module (separate service, DB, frontend /beauty)
+
+open_todos: []
+
+last_handover_reference: docs/phase-handover/phase-14-7.md
+last_updated: "2026-07-26"
diff --git a/frontend/app/api/loyalty/[...path]/route.ts b/frontend/app/api/loyalty/[...path]/route.ts
new file mode 100644
index 0000000..588dee3
--- /dev/null
+++ b/frontend/app/api/loyalty/[...path]/route.ts
@@ -0,0 +1,94 @@
+import { NextRequest, NextResponse } from "next/server";
+
+/**
+ * Same-origin BFF proxy → Loyalty service (:8004).
+ */
+const UPSTREAM =
+ process.env.LOYALTY_SERVICE_URL ||
+ process.env.NEXT_PUBLIC_LOYALTY_API_URL ||
+ "http://localhost:8004";
+
+async function proxy(req: NextRequest, pathSegments: string[]) {
+ const path = pathSegments.join("/");
+ const url = new URL(req.url);
+ const target = `${UPSTREAM.replace(/\/$/, "")}/${path}${url.search}`;
+
+ const headers = new Headers();
+ const pass = ["authorization", "content-type", "x-tenant-id", "accept"];
+ for (const h of pass) {
+ const v = req.headers.get(h);
+ if (v) headers.set(h, v);
+ }
+
+ let body: ArrayBuffer | undefined;
+ if (req.method !== "GET" && req.method !== "HEAD") {
+ body = await req.arrayBuffer();
+ }
+
+ try {
+ const res = await fetch(target, {
+ method: req.method,
+ headers,
+ body,
+ cache: "no-store",
+ });
+ const outHeaders = new Headers();
+ const ct = res.headers.get("content-type");
+ if (ct) outHeaders.set("content-type", ct);
+ return new NextResponse(await res.arrayBuffer(), {
+ status: res.status,
+ headers: outHeaders,
+ });
+ } catch (err) {
+ const detail = err instanceof Error ? err.message : "upstream_unreachable";
+ return NextResponse.json(
+ {
+ error: {
+ code: "loyalty_proxy_error",
+ message: `پروکسی Loyalty به ${UPSTREAM} وصل نشد — ${detail}`,
+ },
+ },
+ { status: 502 }
+ );
+ }
+}
+
+export async function GET(
+ req: NextRequest,
+ ctx: { params: Promise<{ path: string[] }> }
+) {
+ const { path } = await ctx.params;
+ return proxy(req, path);
+}
+
+export async function POST(
+ req: NextRequest,
+ ctx: { params: Promise<{ path: string[] }> }
+) {
+ const { path } = await ctx.params;
+ return proxy(req, path);
+}
+
+export async function PATCH(
+ req: NextRequest,
+ ctx: { params: Promise<{ path: string[] }> }
+) {
+ const { path } = await ctx.params;
+ return proxy(req, path);
+}
+
+export async function PUT(
+ req: NextRequest,
+ ctx: { params: Promise<{ path: string[] }> }
+) {
+ const { path } = await ctx.params;
+ return proxy(req, path);
+}
+
+export async function DELETE(
+ req: NextRequest,
+ ctx: { params: Promise<{ path: string[] }> }
+) {
+ const { path } = await ctx.params;
+ return proxy(req, path);
+}
diff --git a/frontend/app/loyalty/engage/achievements/page.tsx b/frontend/app/loyalty/engage/achievements/page.tsx
new file mode 100644
index 0000000..b6600f2
--- /dev/null
+++ b/frontend/app/loyalty/engage/achievements/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/campaigns/achievements";
diff --git a/frontend/app/loyalty/engage/analytics/page.tsx b/frontend/app/loyalty/engage/analytics/page.tsx
new file mode 100644
index 0000000..ea3d9e3
--- /dev/null
+++ b/frontend/app/loyalty/engage/analytics/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/analytics/analytics";
diff --git a/frontend/app/loyalty/engage/api-keys/page.tsx b/frontend/app/loyalty/engage/api-keys/page.tsx
new file mode 100644
index 0000000..cc91ad9
--- /dev/null
+++ b/frontend/app/loyalty/engage/api-keys/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/admin/api-keys";
diff --git a/frontend/app/loyalty/engage/audience-builder/page.tsx b/frontend/app/loyalty/engage/audience-builder/page.tsx
new file mode 100644
index 0000000..072e534
--- /dev/null
+++ b/frontend/app/loyalty/engage/audience-builder/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/audience/audience-builder";
diff --git a/frontend/app/loyalty/engage/audit-logs/page.tsx b/frontend/app/loyalty/engage/audit-logs/page.tsx
new file mode 100644
index 0000000..74e66a9
--- /dev/null
+++ b/frontend/app/loyalty/engage/audit-logs/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/admin/audit-logs";
diff --git a/frontend/app/loyalty/engage/badges/page.tsx b/frontend/app/loyalty/engage/badges/page.tsx
new file mode 100644
index 0000000..6cfa1c5
--- /dev/null
+++ b/frontend/app/loyalty/engage/badges/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/campaigns/badges";
diff --git a/frontend/app/loyalty/engage/benefits/page.tsx b/frontend/app/loyalty/engage/benefits/page.tsx
new file mode 100644
index 0000000..2915516
--- /dev/null
+++ b/frontend/app/loyalty/engage/benefits/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/wallet/benefits";
diff --git a/frontend/app/loyalty/engage/branches/page.tsx b/frontend/app/loyalty/engage/branches/page.tsx
new file mode 100644
index 0000000..729c694
--- /dev/null
+++ b/frontend/app/loyalty/engage/branches/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/partners/branches";
diff --git a/frontend/app/loyalty/engage/campaigns/page.tsx b/frontend/app/loyalty/engage/campaigns/page.tsx
new file mode 100644
index 0000000..cceef16
--- /dev/null
+++ b/frontend/app/loyalty/engage/campaigns/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/campaigns/campaigns";
diff --git a/frontend/app/loyalty/engage/capabilities/page.tsx b/frontend/app/loyalty/engage/capabilities/page.tsx
new file mode 100644
index 0000000..11659ab
--- /dev/null
+++ b/frontend/app/loyalty/engage/capabilities/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/admin/capabilities";
diff --git a/frontend/app/loyalty/engage/challenges/page.tsx b/frontend/app/loyalty/engage/challenges/page.tsx
new file mode 100644
index 0000000..944be10
--- /dev/null
+++ b/frontend/app/loyalty/engage/challenges/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/campaigns/challenges";
diff --git a/frontend/app/loyalty/engage/coupons/page.tsx b/frontend/app/loyalty/engage/coupons/page.tsx
new file mode 100644
index 0000000..0bac8c1
--- /dev/null
+++ b/frontend/app/loyalty/engage/coupons/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/rewards/coupons";
diff --git a/frontend/app/loyalty/engage/customer-profile/page.tsx b/frontend/app/loyalty/engage/customer-profile/page.tsx
new file mode 100644
index 0000000..4f1ad95
--- /dev/null
+++ b/frontend/app/loyalty/engage/customer-profile/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/membership/customer-profile";
diff --git a/frontend/app/loyalty/engage/dashboard/campaign-performance/page.tsx b/frontend/app/loyalty/engage/dashboard/campaign-performance/page.tsx
new file mode 100644
index 0000000..fb20549
--- /dev/null
+++ b/frontend/app/loyalty/engage/dashboard/campaign-performance/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/dashboards/campaign-performance";
diff --git a/frontend/app/loyalty/engage/dashboard/clv/page.tsx b/frontend/app/loyalty/engage/dashboard/clv/page.tsx
new file mode 100644
index 0000000..ce50f76
--- /dev/null
+++ b/frontend/app/loyalty/engage/dashboard/clv/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/dashboards/clv";
diff --git a/frontend/app/loyalty/engage/dashboard/engagement/page.tsx b/frontend/app/loyalty/engage/dashboard/engagement/page.tsx
new file mode 100644
index 0000000..10eba6b
--- /dev/null
+++ b/frontend/app/loyalty/engage/dashboard/engagement/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/dashboards/engagement";
diff --git a/frontend/app/loyalty/engage/dashboard/executive/page.tsx b/frontend/app/loyalty/engage/dashboard/executive/page.tsx
new file mode 100644
index 0000000..93edc2f
--- /dev/null
+++ b/frontend/app/loyalty/engage/dashboard/executive/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/dashboards/executive";
diff --git a/frontend/app/loyalty/engage/dashboard/gamification/page.tsx b/frontend/app/loyalty/engage/dashboard/gamification/page.tsx
new file mode 100644
index 0000000..22e9f8f
--- /dev/null
+++ b/frontend/app/loyalty/engage/dashboard/gamification/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/dashboards/gamification";
diff --git a/frontend/app/loyalty/engage/dashboard/member-dashboard/page.tsx b/frontend/app/loyalty/engage/dashboard/member-dashboard/page.tsx
new file mode 100644
index 0000000..0008477
--- /dev/null
+++ b/frontend/app/loyalty/engage/dashboard/member-dashboard/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/dashboards/member-dashboard";
diff --git a/frontend/app/loyalty/engage/dashboard/retention/page.tsx b/frontend/app/loyalty/engage/dashboard/retention/page.tsx
new file mode 100644
index 0000000..d19992c
--- /dev/null
+++ b/frontend/app/loyalty/engage/dashboard/retention/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/dashboards/retention";
diff --git a/frontend/app/loyalty/engage/digital-cards/page.tsx b/frontend/app/loyalty/engage/digital-cards/page.tsx
new file mode 100644
index 0000000..52321f5
--- /dev/null
+++ b/frontend/app/loyalty/engage/digital-cards/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/membership/digital-cards";
diff --git a/frontend/app/loyalty/engage/discount-rules/page.tsx b/frontend/app/loyalty/engage/discount-rules/page.tsx
new file mode 100644
index 0000000..0e05dad
--- /dev/null
+++ b/frontend/app/loyalty/engage/discount-rules/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/rules/discount-rules";
diff --git a/frontend/app/loyalty/engage/earning-rules/page.tsx b/frontend/app/loyalty/engage/earning-rules/page.tsx
new file mode 100644
index 0000000..62e9993
--- /dev/null
+++ b/frontend/app/loyalty/engage/earning-rules/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/modules/loyalty/features/points/earning-rules";
diff --git a/frontend/app/loyalty/engage/error.tsx b/frontend/app/loyalty/engage/error.tsx
new file mode 100644
index 0000000..cc26838
--- /dev/null
+++ b/frontend/app/loyalty/engage/error.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ return