Register all active platform services and base features in core DB so admin can manage them, add delivery/hospitality/sports-center backend phases, update apps catalog and production deploy tooling. Co-authored-by: Cursor <cursoragent@cursor.com>
491 lines
19 KiB
Python
491 lines
19 KiB
Python
"""Table Service & Reservations application services — Phase 12.3.
|
|
|
|
Dine-in workflows only: reservations, table assignments, in-service
|
|
requests, and waitlist. No POS / kitchen / payment posting.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.events.publisher import InMemoryEventPublisher, get_event_publisher
|
|
from app.events.types import HospitalityEventType
|
|
from app.models.table_service import (
|
|
Reservation,
|
|
ServiceRequest,
|
|
TableAssignment,
|
|
WaitlistEntry,
|
|
)
|
|
from app.models.types import (
|
|
AuditAction,
|
|
ReservationStatus,
|
|
ServiceRequestStatus,
|
|
TableAssignmentStatus,
|
|
WaitlistStatus,
|
|
)
|
|
from app.repositories.foundation import (
|
|
BranchRepository,
|
|
DiningAreaRepository,
|
|
DiningTableRepository,
|
|
VenueRepository,
|
|
)
|
|
from app.repositories.qr import QrOrderingSessionRepository
|
|
from app.repositories.table_service import (
|
|
ReservationRepository,
|
|
ServiceRequestRepository,
|
|
TableAssignmentRepository,
|
|
WaitlistEntryRepository,
|
|
)
|
|
from app.schemas.table_service import (
|
|
ReservationCreate,
|
|
ServiceRequestCreate,
|
|
TableAssignmentCreate,
|
|
WaitlistEntryCreate,
|
|
)
|
|
from app.services.audit_service import AuditService
|
|
from app.validators import (
|
|
validate_code,
|
|
validate_non_empty,
|
|
validate_positive_int,
|
|
validate_reservation_status,
|
|
validate_reservation_window,
|
|
validate_service_request_kind,
|
|
validate_service_request_status,
|
|
validate_waitlist_status,
|
|
)
|
|
from shared.exceptions import AppError, NotFoundError
|
|
from shared.security import CurrentUser
|
|
|
|
RESERVATION_TERMINAL_STATUSES = frozenset(
|
|
{ReservationStatus.COMPLETED, ReservationStatus.CANCELLED, ReservationStatus.NO_SHOW}
|
|
)
|
|
SERVICE_REQUEST_TERMINAL_STATUSES = frozenset(
|
|
{ServiceRequestStatus.CLOSED, ServiceRequestStatus.CANCELLED}
|
|
)
|
|
WAITLIST_TERMINAL_STATUSES = frozenset(
|
|
{WaitlistStatus.SEATED, WaitlistStatus.CANCELLED, WaitlistStatus.EXPIRED}
|
|
)
|
|
|
|
|
|
class _TableServiceBase:
|
|
def __init__(
|
|
self,
|
|
session: AsyncSession,
|
|
publisher: InMemoryEventPublisher | None = None,
|
|
) -> None:
|
|
self.session = session
|
|
self.audit = AuditService(session)
|
|
self.publisher = publisher or get_event_publisher()
|
|
self.venues = VenueRepository(session)
|
|
self.tables = DiningTableRepository(session)
|
|
|
|
async def _require_venue(self, tenant_id: UUID, venue_id: UUID):
|
|
venue = await self.venues.get(tenant_id, venue_id)
|
|
if venue is None:
|
|
raise NotFoundError("واحد پذیرایی یافت نشد", error_code="venue_not_found")
|
|
return venue
|
|
|
|
async def _require_table(self, tenant_id: UUID, table_id: UUID):
|
|
table = await self.tables.get(tenant_id, table_id)
|
|
if table is None:
|
|
raise NotFoundError("میز یافت نشد", error_code="table_not_found")
|
|
return table
|
|
|
|
|
|
class ReservationService(_TableServiceBase):
|
|
def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None:
|
|
super().__init__(session, publisher)
|
|
self.repo = ReservationRepository(session)
|
|
self.branches = BranchRepository(session)
|
|
self.dining_areas = DiningAreaRepository(session)
|
|
|
|
async def create(
|
|
self, tenant_id: UUID, body: ReservationCreate, *, actor: CurrentUser | None = None
|
|
) -> Reservation:
|
|
await self._require_venue(tenant_id, body.venue_id)
|
|
if body.branch_id is not None and await self.branches.get(tenant_id, body.branch_id) is None:
|
|
raise NotFoundError("شعبه یافت نشد", error_code="branch_not_found")
|
|
if (
|
|
body.dining_area_id is not None
|
|
and await self.dining_areas.get(tenant_id, body.dining_area_id) is None
|
|
):
|
|
raise NotFoundError("فضای پذیرایی یافت نشد", error_code="dining_area_not_found")
|
|
if body.table_id is not None:
|
|
await self._require_table(tenant_id, body.table_id)
|
|
code = validate_code(body.code)
|
|
if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None:
|
|
raise AppError(
|
|
"کد رزرو تکراری است", status_code=409, error_code="reservation_code_exists"
|
|
)
|
|
party_size = validate_positive_int(body.party_size, field="party_size")
|
|
reserved_from, reserved_to = validate_reservation_window(
|
|
body.reserved_from, body.reserved_to
|
|
)
|
|
actor_id = actor.user_id if actor else None
|
|
entity = Reservation(
|
|
tenant_id=tenant_id,
|
|
venue_id=body.venue_id,
|
|
branch_id=body.branch_id,
|
|
dining_area_id=body.dining_area_id,
|
|
table_id=body.table_id,
|
|
code=code,
|
|
guest_name=validate_non_empty(body.guest_name, field="guest_name"),
|
|
guest_phone=body.guest_phone,
|
|
party_size=party_size,
|
|
status=ReservationStatus.PENDING,
|
|
reserved_from=reserved_from,
|
|
reserved_to=reserved_to,
|
|
notes=body.notes,
|
|
metadata_json=body.metadata_json,
|
|
created_by=actor_id,
|
|
updated_by=actor_id,
|
|
)
|
|
await self.repo.add(entity)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="reservation",
|
|
entity_id=entity.id,
|
|
action=AuditAction.CREATE,
|
|
actor_user_id=actor_id,
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
self.publisher.publish(
|
|
event_type=HospitalityEventType.RESERVATION_CREATED,
|
|
aggregate_type="reservation",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"code": entity.code, "status": entity.status.value},
|
|
)
|
|
return entity
|
|
|
|
async def get(self, tenant_id: UUID, entity_id: UUID) -> Reservation:
|
|
entity = await self.repo.get(tenant_id, entity_id)
|
|
if entity is None:
|
|
raise NotFoundError("رزرو یافت نشد", error_code="reservation_not_found")
|
|
return entity
|
|
|
|
async def list(self, tenant_id: UUID, *, offset: int, limit: int):
|
|
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
|
|
|
async def change_status(
|
|
self,
|
|
tenant_id: UUID,
|
|
entity_id: UUID,
|
|
new_status: ReservationStatus | str,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> Reservation:
|
|
entity = await self.get(tenant_id, entity_id)
|
|
resolved = validate_reservation_status(new_status)
|
|
if entity.status in RESERVATION_TERMINAL_STATUSES:
|
|
raise AppError(
|
|
"وضعیت رزرو نهایی شده و قابل تغییر نیست",
|
|
status_code=409,
|
|
error_code="reservation_status_final",
|
|
)
|
|
actor_id = actor.user_id if actor else None
|
|
entity.status = resolved
|
|
entity.updated_by = actor_id
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="reservation",
|
|
entity_id=entity.id,
|
|
action=AuditAction.STATUS_CHANGE,
|
|
actor_user_id=actor_id,
|
|
changes={"status": resolved},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
self.publisher.publish(
|
|
event_type=HospitalityEventType.RESERVATION_STATUS_CHANGED,
|
|
aggregate_type="reservation",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"status": entity.status.value},
|
|
)
|
|
return entity
|
|
|
|
|
|
class TableAssignmentService(_TableServiceBase):
|
|
def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None:
|
|
super().__init__(session, publisher)
|
|
self.repo = TableAssignmentRepository(session)
|
|
self.reservations = ReservationRepository(session)
|
|
self.ordering_sessions = QrOrderingSessionRepository(session)
|
|
|
|
async def create(
|
|
self, tenant_id: UUID, body: TableAssignmentCreate, *, actor: CurrentUser | None = None
|
|
) -> TableAssignment:
|
|
await self._require_venue(tenant_id, body.venue_id)
|
|
await self._require_table(tenant_id, body.table_id)
|
|
if body.reservation_id is not None:
|
|
if await self.reservations.get(tenant_id, body.reservation_id) is None:
|
|
raise NotFoundError("رزرو یافت نشد", error_code="reservation_not_found")
|
|
if body.ordering_session_id is not None:
|
|
if await self.ordering_sessions.get(tenant_id, body.ordering_session_id) is None:
|
|
raise NotFoundError(
|
|
"جلسه سفارشگیری یافت نشد", error_code="qr_ordering_session_not_found"
|
|
)
|
|
active = await self.repo.list_active_for_table(tenant_id, body.table_id)
|
|
if active:
|
|
raise AppError(
|
|
"میز در حال حاضر تخصیص فعال دارد",
|
|
status_code=409,
|
|
error_code="table_assignment_active_exists",
|
|
)
|
|
actor_id = actor.user_id if actor else None
|
|
entity = TableAssignment(
|
|
tenant_id=tenant_id,
|
|
venue_id=body.venue_id,
|
|
table_id=body.table_id,
|
|
reservation_id=body.reservation_id,
|
|
ordering_session_id=body.ordering_session_id,
|
|
status=TableAssignmentStatus.ACTIVE,
|
|
assigned_at=datetime.now(timezone.utc),
|
|
guest_label=body.guest_label,
|
|
metadata_json=body.metadata_json,
|
|
created_by=actor_id,
|
|
updated_by=actor_id,
|
|
)
|
|
await self.repo.add(entity)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="table_assignment",
|
|
entity_id=entity.id,
|
|
action=AuditAction.CREATE,
|
|
actor_user_id=actor_id,
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
self.publisher.publish(
|
|
event_type=HospitalityEventType.TABLE_ASSIGNMENT_CREATED,
|
|
aggregate_type="table_assignment",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"table_id": str(entity.table_id), "status": entity.status.value},
|
|
)
|
|
return entity
|
|
|
|
async def get(self, tenant_id: UUID, entity_id: UUID) -> TableAssignment:
|
|
entity = await self.repo.get(tenant_id, entity_id)
|
|
if entity is None:
|
|
raise NotFoundError("تخصیص میز یافت نشد", error_code="table_assignment_not_found")
|
|
return entity
|
|
|
|
async def list(self, tenant_id: UUID, *, offset: int, limit: int):
|
|
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
|
|
|
async def release(
|
|
self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None
|
|
) -> TableAssignment:
|
|
entity = await self.get(tenant_id, entity_id)
|
|
if entity.status != TableAssignmentStatus.ACTIVE:
|
|
raise AppError(
|
|
"تخصیص میز فعال نیست", status_code=409, error_code="table_assignment_not_active"
|
|
)
|
|
actor_id = actor.user_id if actor else None
|
|
entity.status = TableAssignmentStatus.RELEASED
|
|
entity.released_at = datetime.now(timezone.utc)
|
|
entity.updated_by = actor_id
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="table_assignment",
|
|
entity_id=entity.id,
|
|
action=AuditAction.STATUS_CHANGE,
|
|
actor_user_id=actor_id,
|
|
changes={"status": entity.status},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
return entity
|
|
|
|
|
|
class ServiceRequestService(_TableServiceBase):
|
|
def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None:
|
|
super().__init__(session, publisher)
|
|
self.repo = ServiceRequestRepository(session)
|
|
self.table_assignments = TableAssignmentRepository(session)
|
|
|
|
async def create(
|
|
self, tenant_id: UUID, body: ServiceRequestCreate, *, actor: CurrentUser | None = None
|
|
) -> ServiceRequest:
|
|
await self._require_venue(tenant_id, body.venue_id)
|
|
await self._require_table(tenant_id, body.table_id)
|
|
if body.table_assignment_id is not None:
|
|
if await self.table_assignments.get(tenant_id, body.table_assignment_id) is None:
|
|
raise NotFoundError(
|
|
"تخصیص میز یافت نشد", error_code="table_assignment_not_found"
|
|
)
|
|
code = validate_code(body.code)
|
|
if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None:
|
|
raise AppError(
|
|
"کد درخواست خدمت تکراری است",
|
|
status_code=409,
|
|
error_code="service_request_code_exists",
|
|
)
|
|
kind = validate_service_request_kind(body.kind)
|
|
actor_id = actor.user_id if actor else None
|
|
entity = ServiceRequest(
|
|
tenant_id=tenant_id,
|
|
venue_id=body.venue_id,
|
|
table_id=body.table_id,
|
|
table_assignment_id=body.table_assignment_id,
|
|
code=code,
|
|
kind=kind,
|
|
status=ServiceRequestStatus.OPEN,
|
|
notes=body.notes,
|
|
created_by=actor_id,
|
|
updated_by=actor_id,
|
|
)
|
|
await self.repo.add(entity)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="service_request",
|
|
entity_id=entity.id,
|
|
action=AuditAction.CREATE,
|
|
actor_user_id=actor_id,
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
self.publisher.publish(
|
|
event_type=HospitalityEventType.SERVICE_REQUEST_CREATED,
|
|
aggregate_type="service_request",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"kind": entity.kind.value, "table_id": str(entity.table_id)},
|
|
)
|
|
return entity
|
|
|
|
async def get(self, tenant_id: UUID, entity_id: UUID) -> ServiceRequest:
|
|
entity = await self.repo.get(tenant_id, entity_id)
|
|
if entity is None:
|
|
raise NotFoundError("درخواست خدمت یافت نشد", error_code="service_request_not_found")
|
|
return entity
|
|
|
|
async def list(self, tenant_id: UUID, *, offset: int, limit: int):
|
|
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
|
|
|
async def change_status(
|
|
self,
|
|
tenant_id: UUID,
|
|
entity_id: UUID,
|
|
new_status,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> ServiceRequest:
|
|
entity = await self.get(tenant_id, entity_id)
|
|
resolved = validate_service_request_status(new_status)
|
|
if entity.status in SERVICE_REQUEST_TERMINAL_STATUSES:
|
|
raise AppError(
|
|
"وضعیت درخواست خدمت نهایی شده و قابل تغییر نیست",
|
|
status_code=409,
|
|
error_code="service_request_status_final",
|
|
)
|
|
actor_id = actor.user_id if actor else None
|
|
entity.status = resolved
|
|
entity.updated_by = actor_id
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="service_request",
|
|
entity_id=entity.id,
|
|
action=AuditAction.STATUS_CHANGE,
|
|
actor_user_id=actor_id,
|
|
changes={"status": resolved},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
return entity
|
|
|
|
|
|
class WaitlistEntryService(_TableServiceBase):
|
|
def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None:
|
|
super().__init__(session, publisher)
|
|
self.repo = WaitlistEntryRepository(session)
|
|
|
|
async def create(
|
|
self, tenant_id: UUID, body: WaitlistEntryCreate, *, actor: CurrentUser | None = None
|
|
) -> WaitlistEntry:
|
|
await self._require_venue(tenant_id, body.venue_id)
|
|
code = validate_code(body.code)
|
|
if await self.repo.get_by_code(tenant_id, body.venue_id, code) is not None:
|
|
raise AppError(
|
|
"کد لیست انتظار تکراری است",
|
|
status_code=409,
|
|
error_code="waitlist_entry_code_exists",
|
|
)
|
|
party_size = validate_positive_int(body.party_size, field="party_size")
|
|
actor_id = actor.user_id if actor else None
|
|
entity = WaitlistEntry(
|
|
tenant_id=tenant_id,
|
|
venue_id=body.venue_id,
|
|
code=code,
|
|
guest_name=validate_non_empty(body.guest_name, field="guest_name"),
|
|
party_size=party_size,
|
|
status=WaitlistStatus.WAITING,
|
|
phone=body.phone,
|
|
notes=body.notes,
|
|
created_by=actor_id,
|
|
updated_by=actor_id,
|
|
)
|
|
await self.repo.add(entity)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="waitlist_entry",
|
|
entity_id=entity.id,
|
|
action=AuditAction.CREATE,
|
|
actor_user_id=actor_id,
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
self.publisher.publish(
|
|
event_type=HospitalityEventType.WAITLIST_ENTRY_CREATED,
|
|
aggregate_type="waitlist_entry",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"code": entity.code, "party_size": entity.party_size},
|
|
)
|
|
return entity
|
|
|
|
async def get(self, tenant_id: UUID, entity_id: UUID) -> WaitlistEntry:
|
|
entity = await self.repo.get(tenant_id, entity_id)
|
|
if entity is None:
|
|
raise NotFoundError("مورد لیست انتظار یافت نشد", error_code="waitlist_entry_not_found")
|
|
return entity
|
|
|
|
async def list(self, tenant_id: UUID, *, offset: int, limit: int):
|
|
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
|
|
|
async def change_status(
|
|
self,
|
|
tenant_id: UUID,
|
|
entity_id: UUID,
|
|
new_status,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> WaitlistEntry:
|
|
entity = await self.get(tenant_id, entity_id)
|
|
resolved = validate_waitlist_status(new_status)
|
|
if entity.status in WAITLIST_TERMINAL_STATUSES:
|
|
raise AppError(
|
|
"وضعیت لیست انتظار نهایی شده و قابل تغییر نیست",
|
|
status_code=409,
|
|
error_code="waitlist_entry_status_final",
|
|
)
|
|
actor_id = actor.user_id if actor else None
|
|
entity.status = resolved
|
|
entity.updated_by = actor_id
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="waitlist_entry",
|
|
entity_id=entity.id,
|
|
action=AuditAction.STATUS_CHANGE,
|
|
actor_user_id=actor_id,
|
|
changes={"status": resolved},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
return entity
|