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.
216 lines
9.2 KiB
Python
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
|