First implementation API for PIP (Proyecto: Pharmacy Integration Platform)
Run Tests on Branches / Backend Tests (push) Successful in 3m32s
Run Tests on Branches / Frontend Tests (push) Successful in 3m27s

This commit is contained in:
Antoni Nuñez Romeu
2026-07-01 16:46:52 +02:00
parent d935db4b3e
commit c5df0ec0b2
121 changed files with 9575 additions and 0 deletions
@@ -0,0 +1,210 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from src.core.exceptions import ConflictException, NotFoundException, ValidationException
from src.core.logging import get_logger
from src.domain.entities import (
InventoryHistoryEntity,
InventoryRecordEntity,
ReservationEntity,
ReservationStatus,
)
from src.domain.interfaces import IInventoryRepository, IReservationRepository
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
logger = get_logger("inventory_service")
RESERVATION_DEFAULT_TTL_MINUTES = 30
class InventoryService:
def __init__(
self,
inventory_repo: IInventoryRepository,
reservation_repo: IReservationRepository,
) -> None:
self._inventory_repo = inventory_repo
self._reservation_repo = reservation_repo
async def get_inventory_record(self, pharmacy_id: UUID, medication_id: UUID) -> InventoryRecordEntity | None:
return await self._inventory_repo.get_record(pharmacy_id, medication_id)
async def list_inventory(self, pharmacy_id: UUID, *, offset: int = 0, limit: int = 50) -> tuple[list[InventoryRecordEntity], int]:
return await self._inventory_repo.list_by_pharmacy(pharmacy_id, offset=offset, limit=limit)
async def upsert_inventory_record(self, entity: InventoryRecordEntity) -> InventoryRecordEntity:
existing = await self._inventory_repo.get_record(entity.pharmacy_id, entity.medication_id)
previous_stock = existing.stock if existing else 0
upserted = await self._inventory_repo.upsert(entity)
delta = entity.stock - previous_stock
if delta != 0:
history = InventoryHistoryEntity(
inventory_record_id=upserted.id,
pharmacy_id=entity.pharmacy_id,
medication_id=entity.medication_id,
previous_stock=previous_stock,
new_stock=entity.stock,
delta=delta,
reason=entity.source or "upsert",
source=entity.source,
)
await self._inventory_repo.add_history(history)
await self._publish_event("inventory.updated", upserted.id, {
"pharmacy_id": str(entity.pharmacy_id),
"medication_id": str(entity.medication_id),
"previous_stock": previous_stock,
"new_stock": entity.stock,
"delta": delta,
})
return upserted
async def get_inventory_history(self, pharmacy_id: UUID, medication_id: UUID, *, offset: int = 0, limit: int = 50) -> list[InventoryHistoryEntity]:
records = await self._inventory_repo.get_history(pharmacy_id, medication_id, offset=offset, limit=limit)
return list(records)
async def create_reservation(
self,
pharmacy_id: UUID,
medication_id: UUID,
quantity: int,
*,
user_id: UUID | None = None,
external_reference: str | None = None,
idempotency_key: str | None = None,
ttl_minutes: int = RESERVATION_DEFAULT_TTL_MINUTES,
) -> ReservationEntity:
if idempotency_key:
existing = await self._reservation_repo.get_by_idempotency_key(idempotency_key)
if existing:
return existing
record = await self._inventory_repo.get_record(pharmacy_id, medication_id)
if not record:
raise NotFoundException("InventoryRecord", f"{pharmacy_id}/{medication_id}")
if record.available_stock < quantity:
raise ValidationException(
f"Insufficient available stock. Requested: {quantity}, Available: {record.available_stock}",
details={"available_stock": record.available_stock, "requested": quantity},
)
record.reserved_stock += quantity
record.available_stock = record.stock - record.reserved_stock
await self._inventory_repo.upsert(record)
now = datetime.now(timezone.utc)
entity = ReservationEntity(
pharmacy_id=pharmacy_id,
medication_id=medication_id,
user_id=user_id,
external_reference=external_reference,
quantity=quantity,
status=ReservationStatus.PENDING,
expires_at=now + __import__("datetime").timedelta(minutes=ttl_minutes),
idempotency_key=idempotency_key,
)
created = await self._reservation_repo.create(entity)
await self._publish_event("reservation.created", created.id, {
"pharmacy_id": str(pharmacy_id),
"medication_id": str(medication_id),
"quantity": quantity,
})
return created
async def confirm_reservation(self, reservation_id: UUID) -> ReservationEntity:
entity = await self._reservation_repo.get_by_id(reservation_id)
if not entity:
raise NotFoundException("Reservation", str(reservation_id))
if entity.status != ReservationStatus.PENDING:
raise ValidationException(f"Reservation cannot be confirmed. Current status: {entity.status}")
now = datetime.now(timezone.utc)
if entity.expires_at and entity.expires_at < now:
entity.status = ReservationStatus.EXPIRED
await self._reservation_repo.update(entity)
await self._release_reserved_stock(entity)
await self._publish_event("reservation.expired", entity.id, {"pharmacy_id": str(entity.pharmacy_id)})
raise ValidationException("Reservation has expired")
entity.status = ReservationStatus.CONFIRMED
entity.confirmed_at = now
updated = await self._reservation_repo.update(entity)
await self._publish_event("reservation.confirmed", updated.id, {
"pharmacy_id": str(entity.pharmacy_id),
"medication_id": str(entity.medication_id),
"quantity": entity.quantity,
})
return updated
async def cancel_reservation(self, reservation_id: UUID) -> ReservationEntity:
entity = await self._reservation_repo.get_by_id(reservation_id)
if not entity:
raise NotFoundException("Reservation", str(reservation_id))
if entity.status not in (ReservationStatus.PENDING, ReservationStatus.CONFIRMED):
raise ValidationException(f"Reservation cannot be cancelled. Current status: {entity.status}")
entity.status = ReservationStatus.CANCELLED
entity.cancelled_at = datetime.now(timezone.utc)
updated = await self._reservation_repo.update(entity)
await self._release_reserved_stock(entity)
await self._publish_event("reservation.cancelled", updated.id, {
"pharmacy_id": str(entity.pharmacy_id),
"medication_id": str(entity.medication_id),
"quantity": entity.quantity,
})
return updated
async def get_reservation(self, reservation_id: UUID) -> ReservationEntity | None:
return await self._reservation_repo.get_by_id(reservation_id)
async def list_reservations(self, pharmacy_id: UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[list[ReservationEntity], int]:
return await self._reservation_repo.list_by_pharmacy(pharmacy_id, status=status, offset=offset, limit=limit)
async def expire_pending_reservations(self) -> int:
pass
async def _release_reserved_stock(self, reservation: ReservationEntity) -> None:
record = await self._inventory_repo.get_record(reservation.pharmacy_id, reservation.medication_id)
if not record:
return
record.reserved_stock = max(0, record.reserved_stock - reservation.quantity)
record.available_stock = record.stock - record.reserved_stock
updated = await self._inventory_repo.upsert(record)
history = InventoryHistoryEntity(
inventory_record_id=updated.id,
pharmacy_id=reservation.pharmacy_id,
medication_id=reservation.medication_id,
previous_stock=record.stock + reservation.quantity if reservation.status == ReservationStatus.PENDING else record.stock,
new_stock=record.stock,
delta=-reservation.quantity,
reason=f"reservation_{reservation.status}",
source="inventory_service",
)
await self._inventory_repo.add_history(history)
async def _publish_event(self, event_type: str, resource_id: UUID, payload: dict[str, Any]) -> None:
try:
routing_key = event_type.replace(".", "_")
full_payload = {
"event_type": event_type,
"resource_id": str(resource_id),
"timestamp": datetime.now(timezone.utc).isoformat(),
**payload,
}
await rabbitmq_client.publish(routing_key, full_payload)
except Exception as exc:
logger.warning("inventory_event_publish_failed", extra={"event_type": event_type, "error": str(exc)})