feat: certificates + series API v1 (Task 4)
Port certificate lifecycle (parse/encrypt/upload/deactivate) and FiscalSeries CRUD from the auto, adapted to (product_id, tenant_ref, branch_ref) tenancy. Closes the "guard retroativo" PATCH /v1/series next_number regression check that Task 3 deferred to this task. Routes: POST/GET/DELETE /v1/certificados, POST/GET/PATCH /v1/series.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"""Task 4: `FiscalSeries` CRUD (create/list/update) -- ported from the
|
||||
auto's `app/modules/tenants/service.py` FiscalDocumentSeries CRUD, porte
|
||||
table applied (`organization_id`/`branch_id` -> `product_id`/`tenant_ref`/
|
||||
`branch_ref`). `allocate_fiscal_number` itself (the atomic number-granting
|
||||
function) already lives in `documents.service` (Task 3, ported first since
|
||||
`FiscalSeries` is its home table) -- this module owns everything ELSE about
|
||||
a series: creating one, listing them, and the ADMIN edit path (`PATCH`)
|
||||
with the "guard retroativo" (`next_number` can never regress below what
|
||||
this série has already emitted) that Task 3's `FiscalSeries` docstring
|
||||
explicitly deferred to this task.
|
||||
|
||||
Deliberately its OWN `FiscalSeriesNotFoundError` (distinct class from
|
||||
`documents.service.FiscalSeriesNotFoundError`, same name, different
|
||||
module): that one is raised by a `(product_id, tenant_ref, branch_ref,
|
||||
document_model, serie)` TUPLE lookup (`allocate_fiscal_number`'s exact
|
||||
lookup shape); this one is raised by a bare `series_id` lookup (`PATCH
|
||||
/v1/series/{series_id}`'s shape) -- the auto's own `tenants.service.
|
||||
FiscalSeriesNotFoundError` supported BOTH shapes via optional constructor
|
||||
args in one class; this port keeps the shapes SEPARATE instead of carrying
|
||||
that same either/or constructor across two different modules-by-porte."""
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fiscal_svc.documents.models import FiscalDocument, FiscalSeries
|
||||
from fiscal_svc.series.schemas import FiscalSeriesCreate, FiscalSeriesPatch
|
||||
|
||||
|
||||
class FiscalSeriesNotFoundError(Exception):
|
||||
"""`series_id` does not resolve to a live row for this `product_id` --
|
||||
same anti-oracle 404 either way (does not exist vs. belongs to another
|
||||
product) as the rest of this service."""
|
||||
|
||||
def __init__(self, series_id: uuid.UUID):
|
||||
self.series_id = series_id
|
||||
super().__init__(f"Série fiscal {series_id} não encontrada")
|
||||
|
||||
|
||||
class DuplicateFiscalSeriesError(Exception):
|
||||
"""Raised when a `(product_id, tenant_ref, branch_ref, document_model,
|
||||
serie)` tuple collides with an existing `FiscalSeries` row -- live OR
|
||||
soft-deleted (the DB constraint, Task 3's
|
||||
`uq_fiscal_series_product_tenant_branch_model_serie`, has no `WHERE
|
||||
deleted_at IS NULL`, so a soft-deleted series still blocks recreation
|
||||
with the same tuple)."""
|
||||
|
||||
def __init__(self, document_model: str, serie: int):
|
||||
self.document_model = document_model
|
||||
self.serie = serie
|
||||
super().__init__(
|
||||
f"Já existe uma série fiscal para o modelo {document_model} e série {serie} "
|
||||
"neste branch_ref"
|
||||
)
|
||||
|
||||
|
||||
class FiscalSeriesNumberRegressionError(Exception):
|
||||
"""The "requisito herdado" guard from Task 3's `FiscalSeries` docstring,
|
||||
finally closed now that `FiscalDocument` exists to check against:
|
||||
`PATCH /v1/series/{id}` setting `next_number` to a value that is NOT
|
||||
strictly greater than the highest `numero` this series has already
|
||||
emitted (a LIVE, i.e. non-soft-deleted, `FiscalDocument`) would let the
|
||||
NEXT allocation hand out a number that was already used -- either an
|
||||
outright repeat (SEFAZ duplicate-key rejection) or, worse, a silent
|
||||
re-use if the earlier document was never transmitted. Blocked
|
||||
unconditionally whenever the series has emitted at least one document,
|
||||
regardless of whether the new `next_number` is higher or lower than the
|
||||
CURRENT `next_number` -- "regression" here means "against what SEFAZ has
|
||||
already seen for this série", not "against the previous column value"."""
|
||||
|
||||
def __init__(self, series_id: uuid.UUID, next_number: int, max_numero: int):
|
||||
self.series_id = series_id
|
||||
self.next_number = next_number
|
||||
self.max_numero = max_numero
|
||||
super().__init__(
|
||||
f"série {series_id} já emitiu até o número {max_numero}; "
|
||||
f"next_number ({next_number}) deve ser maior que {max_numero}"
|
||||
)
|
||||
|
||||
|
||||
def _is_fiscal_series_constraint_violation(exc: IntegrityError) -> bool:
|
||||
"""True iff `exc` violates `uq_fiscal_series_product_tenant_branch_
|
||||
model_serie` -- Task 3's migration names this constraint EXPLICITLY
|
||||
(unlike the auto's un-named equivalent), so a plain substring match on
|
||||
the name suffices; no need for the auto's "serie" + "unique constraint"
|
||||
double-marker workaround (that existed there only because the
|
||||
auto-generated constraint name there collided with the table's own
|
||||
name)."""
|
||||
detail = str(getattr(exc.orig, "args", [""])[0]) if exc.orig else str(exc)
|
||||
return "uq_fiscal_series_product_tenant_branch_model_serie" in detail
|
||||
|
||||
|
||||
async def _check_duplicate_fiscal_series(
|
||||
session: AsyncSession,
|
||||
product_id: uuid.UUID,
|
||||
tenant_ref: str,
|
||||
branch_ref: str,
|
||||
document_model: str,
|
||||
serie: int,
|
||||
) -> None:
|
||||
"""Pre-flight check against LIVE series only -- fast, friendlier-error
|
||||
happy path. Does NOT see soft-deleted rows; the DB constraint plus
|
||||
`create_fiscal_series`'s `except IntegrityError` still block those,
|
||||
same two-layer pattern as the rest of this codebase's uniqueness
|
||||
guards."""
|
||||
result = await session.execute(
|
||||
select(FiscalSeries.id).where(
|
||||
FiscalSeries.product_id == product_id,
|
||||
FiscalSeries.tenant_ref == tenant_ref,
|
||||
FiscalSeries.branch_ref == branch_ref,
|
||||
FiscalSeries.document_model == document_model,
|
||||
FiscalSeries.serie == serie,
|
||||
FiscalSeries.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none() is not None:
|
||||
raise DuplicateFiscalSeriesError(document_model, serie)
|
||||
|
||||
|
||||
async def create_fiscal_series(
|
||||
session: AsyncSession, product_id: uuid.UUID, data: FiscalSeriesCreate
|
||||
) -> FiscalSeries:
|
||||
await _check_duplicate_fiscal_series(
|
||||
session, product_id, data.tenant_ref, data.branch_ref, data.document_model, data.serie
|
||||
)
|
||||
|
||||
series = FiscalSeries(
|
||||
product_id=product_id,
|
||||
tenant_ref=data.tenant_ref,
|
||||
branch_ref=data.branch_ref,
|
||||
document_model=data.document_model,
|
||||
serie=data.serie,
|
||||
next_number=data.next_number,
|
||||
)
|
||||
session.add(series)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
if _is_fiscal_series_constraint_violation(exc):
|
||||
raise DuplicateFiscalSeriesError(data.document_model, data.serie) from exc
|
||||
raise
|
||||
await session.refresh(series)
|
||||
return series
|
||||
|
||||
|
||||
async def list_fiscal_series(
|
||||
session: AsyncSession, product_id: uuid.UUID, tenant_ref: str, branch_ref: str
|
||||
) -> list[FiscalSeries]:
|
||||
"""`(product_id, tenant_ref, branch_ref)`-scoped, non-soft-deleted
|
||||
list, ordered by `(document_model, serie)` -- small per-branch catalog,
|
||||
unpaginated (mirrors the auto's `list_fiscal_series`)."""
|
||||
result = await session.execute(
|
||||
select(FiscalSeries)
|
||||
.where(
|
||||
FiscalSeries.product_id == product_id,
|
||||
FiscalSeries.tenant_ref == tenant_ref,
|
||||
FiscalSeries.branch_ref == branch_ref,
|
||||
FiscalSeries.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(FiscalSeries.document_model, FiscalSeries.serie)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_fiscal_series(
|
||||
session: AsyncSession, product_id: uuid.UUID, series_id: uuid.UUID, data: FiscalSeriesPatch
|
||||
) -> FiscalSeries:
|
||||
"""Applies the allowlisted partial edit -- ONLY `next_number`. Uses
|
||||
`.with_for_update()` + `.execution_options(populate_existing=True)` --
|
||||
the SAME two-part fix as `documents.service.allocate_fiscal_number` (see
|
||||
its docstring): this PATCH can race a concurrent `allocate_fiscal_
|
||||
number` call against the SAME row, and the regression guard below needs
|
||||
a freshly-locked read to be meaningful."""
|
||||
result = await session.execute(
|
||||
select(FiscalSeries)
|
||||
.where(
|
||||
FiscalSeries.id == series_id,
|
||||
FiscalSeries.product_id == product_id,
|
||||
FiscalSeries.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
series = result.scalar_one_or_none()
|
||||
if series is None:
|
||||
raise FiscalSeriesNotFoundError(series_id=series_id)
|
||||
|
||||
changes = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Guard retroativo (Task 3's `FiscalSeries` docstring, closed here):
|
||||
# roda DEPOIS do `.with_for_update()` acima (a mesma linha travada
|
||||
# serializa esta checagem contra `allocate_fiscal_number`) e ANTES de
|
||||
# aplicar qualquer mudança -- um `next_number` que regride é rejeitado
|
||||
# inteiro, nenhum campo do patch é aplicado.
|
||||
if "next_number" in changes:
|
||||
max_result = await session.execute(
|
||||
select(func.max(FiscalDocument.numero)).where(
|
||||
FiscalDocument.series_id == series.id,
|
||||
FiscalDocument.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
max_numero = max_result.scalar_one_or_none()
|
||||
if max_numero is not None and changes["next_number"] <= max_numero:
|
||||
raise FiscalSeriesNumberRegressionError(
|
||||
series_id=series.id, next_number=changes["next_number"], max_numero=max_numero
|
||||
)
|
||||
|
||||
for field, value in changes.items():
|
||||
setattr(series, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(series)
|
||||
return series
|
||||
Reference in New Issue
Block a user