TorbatYar/backend/services/hospitality/app/services/pos_pro.py
Mortezakoohjani 5c6a2e78cf feat(platform): seed service registry, deploy all modules, and fix homepage catalog.
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>
2026-07-27 12:39:51 +03:30

363 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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