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