diff --git a/app/services/sequence_service.py b/app/services/sequence_service.py new file mode 100644 index 0000000..924abc4 --- /dev/null +++ b/app/services/sequence_service.py @@ -0,0 +1,133 @@ +"""Sequence service — get_next_number with SELECT FOR UPDATE locking, CRUD, create.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.audit import log_audit +from app.models.sequence import Sequence + + +def _sequence_to_dict(s: Sequence) -> dict[str, Any]: + """Serialize a Sequence ORM object to dict.""" + return { + "id": str(s.id), + "name": s.name, + "prefix": s.prefix, + "next_number": s.next_number, + "padding": s.padding, + "created_at": s.created_at.isoformat() if s.created_at else None, + "updated_at": s.updated_at.isoformat() if s.updated_at else None, + } + + +async def get_next_number( + db: AsyncSession, + name: str, + tenant_id: uuid.UUID, +) -> str: + """Get the next formatted number for a sequence. + + Uses SELECT FOR UPDATE to lock the row, increment next_number, + and return the formatted string (prefix + zero-padded number). + + Raises ValueError if the sequence does not exist. + """ + # SELECT FOR UPDATE to lock the row + q = ( + select(Sequence) + .where( + Sequence.name == name, + Sequence.tenant_id == tenant_id, + Sequence.deleted_at.is_(None), + ) + .with_for_update() + ) + result = await db.execute(q) + seq = result.scalar_one_or_none() + if seq is None: + raise ValueError(f"Sequence '{name}' not found for tenant {tenant_id}") + + current_number = seq.next_number + formatted = f"{seq.prefix}{str(current_number).zfill(seq.padding)}" + seq.next_number = current_number + 1 + + await db.flush() + return formatted + + +async def create_sequence( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + data: dict[str, Any], +) -> dict[str, Any]: + """Create a new sequence.""" + sequence = Sequence( + tenant_id=tenant_id, + name=data["name"], + prefix=data.get("prefix", ""), + next_number=1, + padding=data.get("padding", 4), + ) + db.add(sequence) + await db.flush() + await db.refresh(sequence) + await log_audit( + db, tenant_id, user_id, "create", "sequence", sequence.id, + changes={"name": sequence.name, "prefix": sequence.prefix}, + ) + return _sequence_to_dict(sequence) + + +async def list_sequences( + db: AsyncSession, + tenant_id: uuid.UUID, +) -> dict[str, Any]: + """List all sequences for a tenant.""" + q = select(Sequence).where( + Sequence.tenant_id == tenant_id, + Sequence.deleted_at.is_(None), + ).order_by(Sequence.name.asc()) + result = await db.execute(q) + sequences = result.scalars().all() + return { + "items": [_sequence_to_dict(s) for s in sequences], + "total": len(sequences), + } + + +async def update_sequence( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + sequence_id: uuid.UUID, + data: dict[str, Any], +) -> dict[str, Any] | None: + """Update a sequence (name, prefix, padding only — NOT next_number).""" + q = select(Sequence).where( + Sequence.id == sequence_id, + Sequence.tenant_id == tenant_id, + Sequence.deleted_at.is_(None), + ) + result = await db.execute(q) + sequence = result.scalar_one_or_none() + if sequence is None: + return None + + changes: dict[str, Any] = {} + for field in ("name", "prefix", "padding"): + if field in data and data[field] is not None: + old_val = getattr(sequence, field) + changes[field] = {"old": old_val, "new": data[field]} + setattr(sequence, field, data[field]) + + await db.flush() + await db.refresh(sequence) + await log_audit(db, tenant_id, user_id, "update", "sequence", sequence_id, changes=changes) + return _sequence_to_dict(sequence)