feat(core): add sequence service with SELECT FOR UPDATE locking
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user