Files
sowai-fiscal-svc/src/fiscal_svc/series/service.py
T
jonatanritter c903a9ce0e 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.
2026-07-22 16:51:22 -03:00

216 lines
9.2 KiB
Python

"""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