feat: fiscal_series, fiscal_certificates, fiscal_documents models + migrations
Ports the three tables that change owner per the design spec (decision #2): FiscalSeries (was tenants.FiscalDocumentSeries), FiscalCertificate, FiscalDocument (both from fiscal.models) — organization_id/branch_id replaced by product_id (FK products) + tenant_ref/branch_ref (opaque strings) per the porte table. sale_id/service_order_id dropped (no Sale concept here). document_model is now a plain String(2) + Python enum, never a Postgres enum (Global Constraints: zero enum PG — the auto's own version of this column was a real PG enum, a documented debt not repeated here). Constraints preserved: UNIQUE chave_acesso, partial-unique cert-vivo-per-(product_id, branch_ref) (product-scoped in addition to the auto's branch_id, since branch_ref is an opaque string two different products could coincidentally share), UNIQUE (product_id, tenant_ref, branch_ref, document_model, serie) on the series. documents.service.allocate_fiscal_number ported verbatim (mechanism + contract): SELECT ... FOR UPDATE + populate_existing=True, no-commit contract (caller commits together with the FiscalDocument insert, Task 5). The next_number regression guard is deliberately deferred to Task 4's PATCH /v1/series endpoint (needs FiscalDocument, which now exists). AST guard (tests/shared/test_for_update_populate_existing.py) ported and adapted to scan src/fiscal_svc/, plus two new self-tests proving the detection logic itself in both directions (flags a missing fix, does not false-positive on a correctly fixed multi-line chain) — the ported guard alone only proves "currently green", not "actually detects". 33 tests green via `make k8s-test` (real-migration round trips + unique constraint violations, N=10 concurrency, identity-map staleness repro, tenancy-scoping not-found across product/tenant_ref/branch_ref).
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
"""Task 3: `documents.service.allocate_fiscal_number` -- ported from the
|
||||
auto's `tenants.service.allocate_fiscal_number`
|
||||
(`tests/modules/tenants/test_fiscal_series.py`), same mechanism/contract,
|
||||
tenancy adapted to `(product_id, tenant_ref, branch_ref)`. Covers: sequential
|
||||
allocation, the no-commit contract (Fix 1's two directions), the genuine
|
||||
N=10 concurrency guarantee, the identity-map staleness repro
|
||||
`populate_existing` exists to fix, and not-found (missing/soft-deleted/
|
||||
wrong product/wrong tenant/wrong branch -- every dimension of the new
|
||||
tenancy, not just product)."""
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from fiscal_svc.documents.models import FiscalDocumentModel, FiscalSeries
|
||||
from fiscal_svc.documents.service import FiscalSeriesNotFoundError, allocate_fiscal_number
|
||||
from fiscal_svc.tenancy.service import create_product
|
||||
|
||||
|
||||
async def _product(db_session, name="auto"):
|
||||
return await create_product(db_session, name=name, api_key=f"k-{uuid.uuid4().hex}")
|
||||
|
||||
|
||||
async def _series(db_session, product, *, tenant_ref="tenant-1", branch_ref="branch-1", next_number=1014):
|
||||
series = FiscalSeries(
|
||||
product_id=product.id,
|
||||
tenant_ref=tenant_ref,
|
||||
branch_ref=branch_ref,
|
||||
document_model=FiscalDocumentModel.NFE_55.value,
|
||||
serie=1,
|
||||
next_number=next_number,
|
||||
)
|
||||
db_session.add(series)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(series)
|
||||
return series
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allocate_fiscal_number_sequential_calls_increment(db_session):
|
||||
product = await _product(db_session)
|
||||
series = await _series(db_session, product)
|
||||
|
||||
first = await allocate_fiscal_number(
|
||||
db_session, product.id, series.tenant_ref, series.branch_ref, FiscalDocumentModel.NFE_55, 1
|
||||
)
|
||||
await db_session.commit()
|
||||
second = await allocate_fiscal_number(
|
||||
db_session, product.id, series.tenant_ref, series.branch_ref, FiscalDocumentModel.NFE_55, 1
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
assert first == 1014
|
||||
assert second == 1015
|
||||
await db_session.refresh(series)
|
||||
assert series.next_number == 1016
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allocate_fiscal_number_accepts_the_raw_string_document_model(db_session):
|
||||
"""`document_model` is a plain `String(2)` column (zero enum PG) -- the
|
||||
function must accept either the `FiscalDocumentModel` enum member or
|
||||
its raw `.value` string, since callers (Task 4/5) may hold either."""
|
||||
product = await _product(db_session)
|
||||
series = await _series(db_session, product)
|
||||
|
||||
allocated = await allocate_fiscal_number(
|
||||
db_session, product.id, series.tenant_ref, series.branch_ref, "55", 1
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
assert allocated == 1014
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allocate_fiscal_number_without_commit_does_not_persist(test_engine, db_session):
|
||||
"""Fix 1's contract, safe direction: a caller that allocates and then
|
||||
rolls back (or never commits) leaves NO durable trace -- the next
|
||||
allocation must hand out the SAME number again."""
|
||||
product = await _product(db_session)
|
||||
series = await _series(db_session, product, next_number=100)
|
||||
|
||||
session_maker = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
|
||||
rollback_session = session_maker()
|
||||
try:
|
||||
allocated = await allocate_fiscal_number(
|
||||
rollback_session, product.id, series.tenant_ref, series.branch_ref,
|
||||
FiscalDocumentModel.NFE_55, 1,
|
||||
)
|
||||
assert allocated == 100
|
||||
await rollback_session.rollback()
|
||||
finally:
|
||||
await rollback_session.close()
|
||||
|
||||
await db_session.refresh(series)
|
||||
assert series.next_number == 100, (
|
||||
f"expected next_number to revert to 100 after rollback, got "
|
||||
f"{series.next_number} -- the un-committed allocation leaked a "
|
||||
"durable side effect"
|
||||
)
|
||||
|
||||
reallocated = await allocate_fiscal_number(
|
||||
db_session, product.id, series.tenant_ref, series.branch_ref, FiscalDocumentModel.NFE_55, 1
|
||||
)
|
||||
await db_session.commit()
|
||||
assert reallocated == 100
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allocate_fiscal_number_missing_series_raises(db_session):
|
||||
product = await _product(db_session)
|
||||
|
||||
with pytest.raises(FiscalSeriesNotFoundError):
|
||||
await allocate_fiscal_number(
|
||||
db_session, product.id, "tenant-1", "branch-1", FiscalDocumentModel.NFE_55, 1
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allocate_fiscal_number_soft_deleted_series_raises(db_session):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
product = await _product(db_session)
|
||||
series = await _series(db_session, product)
|
||||
series.deleted_at = datetime.now(timezone.utc)
|
||||
await db_session.commit()
|
||||
|
||||
with pytest.raises(FiscalSeriesNotFoundError):
|
||||
await allocate_fiscal_number(
|
||||
db_session, product.id, series.tenant_ref, series.branch_ref, FiscalDocumentModel.NFE_55, 1
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allocate_fiscal_number_other_product_raises(db_session):
|
||||
"""Tenancy-scoping guard (porte adaptation): the SAME
|
||||
tenant_ref/branch_ref/document_model/serie under a DIFFERENT product_id
|
||||
must never allocate from a series that belongs to a different product --
|
||||
the anti-oracle boundary is (product_id, tenant_ref), so this exercises
|
||||
the product_id half of it."""
|
||||
product_a = await _product(db_session, name="auto")
|
||||
series = await _series(db_session, product_a)
|
||||
product_b = await _product(db_session, name="crm")
|
||||
|
||||
with pytest.raises(FiscalSeriesNotFoundError):
|
||||
await allocate_fiscal_number(
|
||||
db_session, product_b.id, series.tenant_ref, series.branch_ref,
|
||||
FiscalDocumentModel.NFE_55, 1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allocate_fiscal_number_other_tenant_ref_raises(db_session):
|
||||
"""Same tenancy boundary, tenant_ref half: two tenants of the SAME
|
||||
product must not share a series just because branch_ref/model/serie
|
||||
coincide."""
|
||||
product = await _product(db_session)
|
||||
series = await _series(db_session, product, tenant_ref="tenant-a")
|
||||
|
||||
with pytest.raises(FiscalSeriesNotFoundError):
|
||||
await allocate_fiscal_number(
|
||||
db_session, product.id, "tenant-b", series.branch_ref, FiscalDocumentModel.NFE_55, 1
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allocate_fiscal_number_other_branch_ref_raises(db_session):
|
||||
"""Same tenancy boundary, branch_ref half -- the auto's own guard test
|
||||
(`test_allocate_fiscal_number_other_org_raises`) had no branch-level
|
||||
equivalent since `branch_id` there is a real FK scoped by
|
||||
`organization_id`; here `branch_ref` is its own opaque dimension."""
|
||||
product = await _product(db_session)
|
||||
series = await _series(db_session, product, branch_ref="branch-a")
|
||||
|
||||
with pytest.raises(FiscalSeriesNotFoundError):
|
||||
await allocate_fiscal_number(
|
||||
db_session, product.id, series.tenant_ref, "branch-b", FiscalDocumentModel.NFE_55, 1
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allocate_fiscal_number_concurrent_calls_produce_distinct_sequential_numbers(
|
||||
test_engine, db_session
|
||||
):
|
||||
"""The regression guard this whole feature exists for, ported verbatim
|
||||
from the auto's own concurrency test: N=10 GENUINELY concurrent
|
||||
`allocate_fiscal_number` calls against the SAME series must produce 10
|
||||
DISTINCT, SEQUENTIAL numbers. Each call uses its OWN
|
||||
`AsyncSession(test_engine)` (a real, separate DB connection) so the
|
||||
event loop can genuinely interleave their network round-trips."""
|
||||
product = await _product(db_session)
|
||||
series = await _series(db_session, product, next_number=100)
|
||||
|
||||
async def _allocate_and_commit(session):
|
||||
allocated = await allocate_fiscal_number(
|
||||
session, product.id, series.tenant_ref, series.branch_ref,
|
||||
FiscalDocumentModel.NFE_55, 1,
|
||||
)
|
||||
await session.commit()
|
||||
return allocated
|
||||
|
||||
session_maker = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
sessions = [session_maker() for _ in range(10)]
|
||||
try:
|
||||
results = await asyncio.gather(
|
||||
*(_allocate_and_commit(session) for session in sessions),
|
||||
return_exceptions=True,
|
||||
)
|
||||
finally:
|
||||
for session in sessions:
|
||||
await session.close()
|
||||
|
||||
errors = [r for r in results if isinstance(r, BaseException)]
|
||||
assert not errors, f"expected all 10 concurrent allocations to succeed, got errors={errors!r}"
|
||||
|
||||
allocated = sorted(results)
|
||||
assert allocated == list(range(100, 110)), (
|
||||
f"expected 10 distinct sequential numbers 100..109, got {allocated!r} "
|
||||
"(a repeat means two concurrent callers received the same NF-e "
|
||||
"number; a gap means one was skipped)"
|
||||
)
|
||||
|
||||
await db_session.refresh(series)
|
||||
assert series.next_number == 110
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allocate_fiscal_number_repopulates_identity_mapped_object(test_engine, db_session):
|
||||
"""`SELECT ... FOR UPDATE` locks the ROW in Postgres, but if the calling
|
||||
session already has that row's Python object in its IDENTITY MAP, plain
|
||||
SQLAlchemy does NOT repopulate that object's attributes from the newly
|
||||
fetched row by default. Reproduced with ordinary sequential awaits
|
||||
across two sessions (no `asyncio.gather` needed) -- ported from the
|
||||
auto's own reproduction."""
|
||||
from fiscal_svc.documents.models import FiscalSeries as _FiscalSeries
|
||||
from sqlalchemy import select
|
||||
|
||||
product = await _product(db_session)
|
||||
series = await _series(db_session, product, next_number=100)
|
||||
|
||||
# Step 1: put the series into S1 (db_session)'s identity map with
|
||||
# next_number=100.
|
||||
loaded = (
|
||||
await db_session.execute(select(_FiscalSeries).where(_FiscalSeries.id == series.id))
|
||||
).scalar_one()
|
||||
assert loaded.next_number == 100
|
||||
|
||||
# Step 2: a genuinely separate session allocates 100 and commits -- the
|
||||
# DB row now holds 101.
|
||||
session_maker = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
other_session = session_maker()
|
||||
try:
|
||||
other_allocated = await allocate_fiscal_number(
|
||||
other_session, product.id, series.tenant_ref, series.branch_ref,
|
||||
FiscalDocumentModel.NFE_55, 1,
|
||||
)
|
||||
await other_session.commit()
|
||||
finally:
|
||||
await other_session.close()
|
||||
assert other_allocated == 100
|
||||
|
||||
# Step 3: S1 allocates next -- MUST be 101 (the real, post-S2 value),
|
||||
# never 100 again (that would be a duplicate NF-e number).
|
||||
allocated = await allocate_fiscal_number(
|
||||
db_session, product.id, series.tenant_ref, series.branch_ref, FiscalDocumentModel.NFE_55, 1
|
||||
)
|
||||
await db_session.commit()
|
||||
assert allocated == 101, (
|
||||
f"expected 101 (the value S2 already committed), got {allocated} -- "
|
||||
"S1's allocate_fiscal_number returned a STALE identity-map-cached "
|
||||
"next_number instead of repopulating from the FOR UPDATE-locked row"
|
||||
)
|
||||
Reference in New Issue
Block a user