Initial commit: PESCO NCR system
Complete Non-Conformance Report system replacing the PowerApps/SharePoint prototype: FastAPI + SQLAlchemy 2 (async) + Alembic + MySQL 8 backend, React 18 + Vite + TypeScript + MUI frontend, Entra ID auth (MSAL / JWKS, group-gated), Microsoft Graph delegated Mail.Send notifications (OBO), six-stage workflow state machine with server-side enforcement, atomic NCR-YYYY-NNNN numbering, attachments with camera capture, immutable field-level audit trail, admin reopen, reports + CSV export, WeasyPrint PDF traveler, Power BI reporting views + read-only DB user, documented VISUAL ERP job-lookup stub, pytest suite (26 tests), docker-compose deployment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
56
backend/app/services/numbering.py
Normal file
56
backend/app/services/numbering.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Atomic NCR number allocation.
|
||||
|
||||
Format: NCR-YYYY-NNNN (zero-padded, per-calendar-year sequence).
|
||||
|
||||
Strategy: UPDATE-first on the per-year row in ncr_sequences. The UPDATE takes
|
||||
a row lock (InnoDB) / reserved write lock (SQLite) that is held until the
|
||||
enclosing transaction commits, so two concurrent submissions serialize and can
|
||||
never read the same sequence value. If the year row doesn't exist yet
|
||||
(first NCR of a new year), it is inserted inside a SAVEPOINT; a losing racer
|
||||
gets an IntegrityError, rolls back only the savepoint, and proceeds to the
|
||||
UPDATE which now finds the winner's row.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import NcrSequence
|
||||
from app.models.base import utcnow
|
||||
|
||||
|
||||
async def allocate_ncr_number(
|
||||
db: AsyncSession, now: datetime | None = None
|
||||
) -> tuple[str, int, int]:
|
||||
"""Allocate the next NCR number inside the caller's transaction.
|
||||
|
||||
Returns (ncr_number, year, seq). Must be called within the same
|
||||
transaction that inserts the NCR so the sequence row lock is held
|
||||
until commit.
|
||||
"""
|
||||
year = (now or utcnow()).year
|
||||
|
||||
result = await db.execute(
|
||||
update(NcrSequence)
|
||||
.where(NcrSequence.year == year)
|
||||
.values(last_seq=NcrSequence.last_seq + 1)
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
# First NCR of this calendar year — create the sequence row.
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
db.add(NcrSequence(year=year, last_seq=0))
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
pass # another request created it first; fall through to UPDATE
|
||||
await db.execute(
|
||||
update(NcrSequence)
|
||||
.where(NcrSequence.year == year)
|
||||
.values(last_seq=NcrSequence.last_seq + 1)
|
||||
)
|
||||
|
||||
seq = (
|
||||
await db.execute(select(NcrSequence.last_seq).where(NcrSequence.year == year))
|
||||
).scalar_one()
|
||||
return f"NCR-{year}-{seq:04d}", year, seq
|
||||
Reference in New Issue
Block a user