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>
363 lines
15 KiB
Python
363 lines
15 KiB
Python
"""POS Pro application services — Phase 12.5.
|
||
|
||
Payments, discounts, tax lines, floor plans, and stations. Payment
|
||
records are local only — no accounting posting.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
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.pos_pro import PosDiscount, PosFloorPlan, PosPayment, PosStation, PosTaxLine
|
||
from app.models.types import AuditAction, LifecycleStatus, PosDiscountKind
|
||
from app.repositories.foundation import DiningAreaRepository, VenueRepository
|
||
from app.repositories.pos_lite import PosTicketRepository
|
||
from app.repositories.pos_pro import (
|
||
PosDiscountRepository,
|
||
PosFloorPlanRepository,
|
||
PosPaymentRepository,
|
||
PosStationRepository,
|
||
PosTaxLineRepository,
|
||
)
|
||
from app.schemas.pos_pro import (
|
||
PosDiscountCreate,
|
||
PosFloorPlanCreate,
|
||
PosPaymentCreate,
|
||
PosStationCreate,
|
||
PosTaxLineCreate,
|
||
)
|
||
from app.services.audit_service import AuditService
|
||
from app.validators import (
|
||
validate_code,
|
||
validate_currency_code,
|
||
validate_lifecycle_status,
|
||
validate_non_empty,
|
||
validate_non_negative_int,
|
||
)
|
||
from shared.exceptions import AppError, NotFoundError
|
||
from shared.security import CurrentUser
|
||
|
||
|
||
class _PosProBase:
|
||
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.tickets = PosTicketRepository(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_ticket(self, tenant_id: UUID, ticket_id: UUID):
|
||
ticket = await self.tickets.get(tenant_id, ticket_id)
|
||
if ticket is None:
|
||
raise NotFoundError("فیش یافت نشد", error_code="pos_ticket_not_found")
|
||
return ticket
|
||
|
||
|
||
class PosPaymentService(_PosProBase):
|
||
def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None:
|
||
super().__init__(session, publisher)
|
||
self.repo = PosPaymentRepository(session)
|
||
|
||
async def create(
|
||
self, tenant_id: UUID, body: PosPaymentCreate, *, actor: CurrentUser | None = None
|
||
) -> PosPayment:
|
||
await self._require_venue(tenant_id, body.venue_id)
|
||
await self._require_ticket(tenant_id, body.ticket_id)
|
||
amount = validate_non_negative_int(body.amount, field="amount")
|
||
if amount is None or amount <= 0:
|
||
raise AppError(
|
||
"مبلغ پرداخت باید بیشتر از صفر باشد",
|
||
status_code=422,
|
||
error_code="invalid_amount",
|
||
details={"field": "amount"},
|
||
)
|
||
actor_id = actor.user_id if actor else None
|
||
entity = PosPayment(
|
||
tenant_id=tenant_id,
|
||
venue_id=body.venue_id,
|
||
ticket_id=body.ticket_id,
|
||
method=body.method,
|
||
amount=amount,
|
||
currency_code=validate_currency_code(body.currency_code),
|
||
external_payment_ref=body.external_payment_ref,
|
||
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="pos_payment",
|
||
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.POS_PAYMENT_RECORDED,
|
||
aggregate_type="pos_payment",
|
||
aggregate_id=entity.id,
|
||
tenant_id=tenant_id,
|
||
payload={"ticket_id": str(entity.ticket_id), "amount": entity.amount},
|
||
)
|
||
return entity
|
||
|
||
async def get(self, tenant_id: UUID, entity_id: UUID) -> PosPayment:
|
||
entity = await self.repo.get(tenant_id, entity_id)
|
||
if entity is None:
|
||
raise NotFoundError("پرداخت یافت نشد", error_code="pos_payment_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 PosDiscountService(_PosProBase):
|
||
def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None:
|
||
super().__init__(session, publisher)
|
||
self.repo = PosDiscountRepository(session)
|
||
|
||
async def create(
|
||
self, tenant_id: UUID, body: PosDiscountCreate, *, actor: CurrentUser | None = None
|
||
) -> PosDiscount:
|
||
await self._require_venue(tenant_id, body.venue_id)
|
||
await self._require_ticket(tenant_id, body.ticket_id)
|
||
code = validate_code(body.code)
|
||
if await self.repo.get_by_code_for_ticket(tenant_id, body.ticket_id, code) is not None:
|
||
raise AppError(
|
||
"کد تخفیف تکراری است", status_code=409, error_code="pos_discount_code_exists"
|
||
)
|
||
value = validate_non_negative_int(body.value, field="value")
|
||
if value is None:
|
||
value = 0
|
||
if body.kind == PosDiscountKind.PERCENT and value > 100:
|
||
raise AppError(
|
||
"درصد تخفیف نمیتواند بیشتر از ۱۰۰ باشد",
|
||
status_code=422,
|
||
error_code="invalid_discount_percent",
|
||
)
|
||
actor_id = actor.user_id if actor else None
|
||
entity = PosDiscount(
|
||
tenant_id=tenant_id,
|
||
venue_id=body.venue_id,
|
||
ticket_id=body.ticket_id,
|
||
code=code,
|
||
kind=body.kind,
|
||
value=value,
|
||
name=body.name,
|
||
created_by=actor_id,
|
||
updated_by=actor_id,
|
||
)
|
||
await self.repo.add(entity)
|
||
await self.audit.record(
|
||
tenant_id=tenant_id,
|
||
entity_type="pos_discount",
|
||
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.POS_DISCOUNT_APPLIED,
|
||
aggregate_type="pos_discount",
|
||
aggregate_id=entity.id,
|
||
tenant_id=tenant_id,
|
||
payload={"ticket_id": str(entity.ticket_id), "code": entity.code},
|
||
)
|
||
return entity
|
||
|
||
async def get(self, tenant_id: UUID, entity_id: UUID) -> PosDiscount:
|
||
entity = await self.repo.get(tenant_id, entity_id)
|
||
if entity is None:
|
||
raise NotFoundError("تخفیف یافت نشد", error_code="pos_discount_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 PosTaxLineService(_PosProBase):
|
||
def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None:
|
||
super().__init__(session, publisher)
|
||
self.repo = PosTaxLineRepository(session)
|
||
|
||
async def create(
|
||
self, tenant_id: UUID, body: PosTaxLineCreate, *, actor: CurrentUser | None = None
|
||
) -> PosTaxLine:
|
||
await self._require_venue(tenant_id, body.venue_id)
|
||
await self._require_ticket(tenant_id, body.ticket_id)
|
||
rate_bps = validate_non_negative_int(body.rate_bps, field="rate_bps") or 0
|
||
amount = validate_non_negative_int(body.amount, field="amount") or 0
|
||
actor_id = actor.user_id if actor else None
|
||
entity = PosTaxLine(
|
||
tenant_id=tenant_id,
|
||
venue_id=body.venue_id,
|
||
ticket_id=body.ticket_id,
|
||
code=validate_code(body.code),
|
||
name=validate_non_empty(body.name, field="name"),
|
||
rate_bps=rate_bps,
|
||
amount=amount,
|
||
created_by=actor_id,
|
||
updated_by=actor_id,
|
||
)
|
||
await self.repo.add(entity)
|
||
await self.audit.record(
|
||
tenant_id=tenant_id,
|
||
entity_type="pos_tax_line",
|
||
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.POS_TAX_LINE_ADDED,
|
||
aggregate_type="pos_tax_line",
|
||
aggregate_id=entity.id,
|
||
tenant_id=tenant_id,
|
||
payload={"ticket_id": str(entity.ticket_id), "code": entity.code},
|
||
)
|
||
return entity
|
||
|
||
async def get(self, tenant_id: UUID, entity_id: UUID) -> PosTaxLine:
|
||
entity = await self.repo.get(tenant_id, entity_id)
|
||
if entity is None:
|
||
raise NotFoundError("سطر مالیات یافت نشد", error_code="pos_tax_line_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 PosFloorPlanService(_PosProBase):
|
||
def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None:
|
||
super().__init__(session, publisher)
|
||
self.repo = PosFloorPlanRepository(session)
|
||
self.dining_areas = DiningAreaRepository(session)
|
||
|
||
async def create(
|
||
self, tenant_id: UUID, body: PosFloorPlanCreate, *, actor: CurrentUser | None = None
|
||
) -> PosFloorPlan:
|
||
await self._require_venue(tenant_id, body.venue_id)
|
||
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")
|
||
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="pos_floor_plan_code_exists"
|
||
)
|
||
actor_id = actor.user_id if actor else None
|
||
entity = PosFloorPlan(
|
||
tenant_id=tenant_id,
|
||
venue_id=body.venue_id,
|
||
code=code,
|
||
name=validate_non_empty(body.name, field="name"),
|
||
dining_area_id=body.dining_area_id,
|
||
status=validate_lifecycle_status(body.status),
|
||
created_by=actor_id,
|
||
updated_by=actor_id,
|
||
)
|
||
await self.repo.add(entity)
|
||
await self.audit.record(
|
||
tenant_id=tenant_id,
|
||
entity_type="pos_floor_plan",
|
||
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.POS_FLOOR_PLAN_CREATED,
|
||
aggregate_type="pos_floor_plan",
|
||
aggregate_id=entity.id,
|
||
tenant_id=tenant_id,
|
||
payload={"code": entity.code},
|
||
)
|
||
return entity
|
||
|
||
async def get(self, tenant_id: UUID, entity_id: UUID) -> PosFloorPlan:
|
||
entity = await self.repo.get(tenant_id, entity_id)
|
||
if entity is None:
|
||
raise NotFoundError("پلان طبقه یافت نشد", error_code="pos_floor_plan_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 PosStationService(_PosProBase):
|
||
def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None:
|
||
super().__init__(session, publisher)
|
||
self.repo = PosStationRepository(session)
|
||
self.floor_plans = PosFloorPlanRepository(session)
|
||
|
||
async def create(
|
||
self, tenant_id: UUID, body: PosStationCreate, *, actor: CurrentUser | None = None
|
||
) -> PosStation:
|
||
await self._require_venue(tenant_id, body.venue_id)
|
||
if body.floor_plan_id is not None and await self.floor_plans.get(
|
||
tenant_id, body.floor_plan_id
|
||
) is None:
|
||
raise NotFoundError("پلان طبقه یافت نشد", error_code="pos_floor_plan_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="pos_station_code_exists"
|
||
)
|
||
actor_id = actor.user_id if actor else None
|
||
entity = PosStation(
|
||
tenant_id=tenant_id,
|
||
venue_id=body.venue_id,
|
||
floor_plan_id=body.floor_plan_id,
|
||
code=code,
|
||
name=validate_non_empty(body.name, field="name"),
|
||
status=validate_lifecycle_status(body.status),
|
||
created_by=actor_id,
|
||
updated_by=actor_id,
|
||
)
|
||
await self.repo.add(entity)
|
||
await self.audit.record(
|
||
tenant_id=tenant_id,
|
||
entity_type="pos_station",
|
||
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.POS_STATION_CREATED,
|
||
aggregate_type="pos_station",
|
||
aggregate_id=entity.id,
|
||
tenant_id=tenant_id,
|
||
payload={"code": entity.code},
|
||
)
|
||
return entity
|
||
|
||
async def get(self, tenant_id: UUID, entity_id: UUID) -> PosStation:
|
||
entity = await self.repo.get(tenant_id, entity_id)
|
||
if entity is None:
|
||
raise NotFoundError("ایستگاه یافت نشد", error_code="pos_station_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)
|