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>
223 lines
8.7 KiB
Python
223 lines
8.7 KiB
Python
"""Analytics application services — Phase 12.8.
|
|
|
|
Snapshots are computed via simple local `COUNT(*)` queries against this
|
|
service's own tables only — no external analytics engine, no cross-service
|
|
reads.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.events.publisher import InMemoryEventPublisher, get_event_publisher
|
|
from app.events.types import HospitalityEventType
|
|
from app.models.analytics import AnalyticsReportDefinition, AnalyticsSnapshot
|
|
from app.models.connectors import ConnectorDispatch, ConnectorRegistration
|
|
from app.models.foundation import Menu, MenuItem, Venue
|
|
from app.models.kitchen import KitchenTicket
|
|
from app.models.pos_lite import PosRegister, PosTicket
|
|
from app.models.table_service import Reservation
|
|
from app.models.types import AuditAction, LifecycleStatus
|
|
from app.repositories.analytics import (
|
|
AnalyticsReportDefinitionRepository,
|
|
AnalyticsSnapshotRepository,
|
|
)
|
|
from app.repositories.foundation import VenueRepository
|
|
from app.schemas.analytics import AnalyticsReportDefinitionCreate, AnalyticsSnapshotRefresh
|
|
from app.services.audit_service import AuditService
|
|
from app.validators import validate_code, validate_non_empty
|
|
from shared.exceptions import AppError, NotFoundError
|
|
from shared.security import CurrentUser
|
|
|
|
# metric_key -> ORM model counted locally (venue/tenant scoped COUNT(*) only)
|
|
METRIC_TABLE_MAP: dict[str, type] = {
|
|
"venues_count": Venue,
|
|
"menus_count": Menu,
|
|
"menu_items_count": MenuItem,
|
|
"pos_registers_count": PosRegister,
|
|
"pos_tickets_count": PosTicket,
|
|
"reservations_count": Reservation,
|
|
"kitchen_tickets_count": KitchenTicket,
|
|
"connector_registrations_count": ConnectorRegistration,
|
|
"connector_dispatches_count": ConnectorDispatch,
|
|
}
|
|
|
|
|
|
class _AnalyticsBase:
|
|
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)
|
|
|
|
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
|
|
|
|
|
|
class AnalyticsReportDefinitionService(_AnalyticsBase):
|
|
def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None:
|
|
super().__init__(session, publisher)
|
|
self.repo = AnalyticsReportDefinitionRepository(session)
|
|
|
|
async def create(
|
|
self,
|
|
tenant_id: UUID,
|
|
body: AnalyticsReportDefinitionCreate,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> AnalyticsReportDefinition:
|
|
if body.venue_id is not None:
|
|
await self._require_venue(tenant_id, body.venue_id)
|
|
code = validate_code(body.code)
|
|
if await self.repo.get_by_code(tenant_id, code) is not None:
|
|
raise AppError(
|
|
"کد گزارش تکراری است",
|
|
status_code=409,
|
|
error_code="analytics_report_code_exists",
|
|
)
|
|
if not body.metric_keys:
|
|
raise AppError(
|
|
"حداقل یک metric_key الزامی است",
|
|
status_code=422,
|
|
error_code="analytics_metric_keys_required",
|
|
)
|
|
unknown = [key for key in body.metric_keys if key not in METRIC_TABLE_MAP]
|
|
if unknown:
|
|
raise AppError(
|
|
"metric_key نامعتبر است",
|
|
status_code=422,
|
|
error_code="invalid_metric_key",
|
|
details={"unknown": unknown},
|
|
)
|
|
actor_id = actor.user_id if actor else None
|
|
entity = AnalyticsReportDefinition(
|
|
tenant_id=tenant_id,
|
|
venue_id=body.venue_id,
|
|
code=code,
|
|
name=validate_non_empty(body.name, field="name"),
|
|
metric_keys=body.metric_keys,
|
|
filters=body.filters,
|
|
status=LifecycleStatus.ACTIVE,
|
|
created_by=actor_id,
|
|
updated_by=actor_id,
|
|
)
|
|
await self.repo.add(entity)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="analytics_report_definition",
|
|
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.ANALYTICS_REPORT_DEFINITION_CREATED,
|
|
aggregate_type="analytics_report_definition",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"code": entity.code},
|
|
)
|
|
return entity
|
|
|
|
async def get(self, tenant_id: UUID, entity_id: UUID) -> AnalyticsReportDefinition:
|
|
entity = await self.repo.get(tenant_id, entity_id)
|
|
if entity is None:
|
|
raise NotFoundError(
|
|
"گزارش تحلیلی یافت نشد", error_code="analytics_report_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)
|
|
|
|
|
|
class AnalyticsSnapshotService(_AnalyticsBase):
|
|
def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None:
|
|
super().__init__(session, publisher)
|
|
self.repo = AnalyticsSnapshotRepository(session)
|
|
self.reports = AnalyticsReportDefinitionRepository(session)
|
|
|
|
async def _count(self, tenant_id: UUID, model: type) -> int:
|
|
stmt = select(func.count()).select_from(model).where(
|
|
model.tenant_id == tenant_id, # type: ignore[attr-defined]
|
|
model.is_deleted.is_(False), # type: ignore[attr-defined]
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
return int(result.scalar_one())
|
|
|
|
async def refresh(
|
|
self,
|
|
tenant_id: UUID,
|
|
body: AnalyticsSnapshotRefresh,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> AnalyticsSnapshot:
|
|
if body.venue_id is not None:
|
|
await self._require_venue(tenant_id, body.venue_id)
|
|
report = await self.reports.get(tenant_id, body.report_id)
|
|
if report is None:
|
|
raise NotFoundError(
|
|
"گزارش تحلیلی یافت نشد", error_code="analytics_report_not_found"
|
|
)
|
|
period_start = validate_non_empty(body.period_start, field="period_start")
|
|
period_end = validate_non_empty(body.period_end, field="period_end")
|
|
|
|
metrics: dict[str, int] = {}
|
|
for key in report.metric_keys:
|
|
model = METRIC_TABLE_MAP.get(key)
|
|
if model is None:
|
|
continue
|
|
metrics[key] = await self._count(tenant_id, model)
|
|
|
|
actor_id = actor.user_id if actor else None
|
|
entity = AnalyticsSnapshot(
|
|
tenant_id=tenant_id,
|
|
venue_id=body.venue_id,
|
|
report_id=report.id,
|
|
period_start=period_start,
|
|
period_end=period_end,
|
|
metrics=metrics,
|
|
status=LifecycleStatus.ACTIVE,
|
|
created_by=actor_id,
|
|
updated_by=actor_id,
|
|
)
|
|
await self.repo.add(entity)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="analytics_snapshot",
|
|
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.ANALYTICS_SNAPSHOT_REFRESHED,
|
|
aggregate_type="analytics_snapshot",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"report_id": str(entity.report_id), "metric_count": len(metrics)},
|
|
)
|
|
return entity
|
|
|
|
async def get(self, tenant_id: UUID, entity_id: UUID) -> AnalyticsSnapshot:
|
|
entity = await self.repo.get(tenant_id, entity_id)
|
|
if entity is None:
|
|
raise NotFoundError(
|
|
"عکسفوری تحلیلی یافت نشد", error_code="analytics_snapshot_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)
|