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:
jonatanritter
2026-07-22 16:25:41 -03:00
parent 1791435a8d
commit 836c267e09
10 changed files with 1237 additions and 1 deletions
@@ -0,0 +1,126 @@
"""fiscal_series + fiscal_certificates + fiscal_documents (Task 3): the
three tables that "change owner" per the design spec (decision #2) --
ported from the auto's `fiscal_document_series` (tenants), `fiscal_
certificates`/`fiscal_documents` (fiscal), with product_id/tenant_ref/
branch_ref replacing organization_id/branch_id (porte table).
Revision ID: 523235d06bd5
Revises: fd0ffe65c993
Create Date: 2026-07-22 00:00:00.000000
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "523235d06bd5"
down_revision: Union[str, Sequence[str], None] = "fd0ffe65c993"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"fiscal_series",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("product_id", sa.UUID(), nullable=False),
sa.Column("tenant_ref", sa.String(length=64), nullable=False),
sa.Column("branch_ref", sa.String(length=64), nullable=False),
sa.Column("document_model", sa.String(length=2), nullable=False),
sa.Column("serie", sa.Integer(), nullable=False),
sa.Column("next_number", sa.Integer(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["product_id"], ["products.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"product_id", "tenant_ref", "branch_ref", "document_model", "serie",
name="uq_fiscal_series_product_tenant_branch_model_serie",
),
)
op.create_table(
"fiscal_certificates",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("product_id", sa.UUID(), nullable=False),
sa.Column("tenant_ref", sa.String(length=64), nullable=False),
sa.Column("branch_ref", sa.String(length=64), nullable=False),
sa.Column("cnpj", sa.String(length=14), nullable=False),
sa.Column("pfx_encrypted", sa.LargeBinary(), nullable=False),
sa.Column("password_encrypted", sa.LargeBinary(), nullable=False),
sa.Column("subject_cn", sa.String(length=255), nullable=False),
sa.Column("cnpj_certificado", sa.String(length=14), nullable=False),
sa.Column("not_valid_before", sa.DateTime(timezone=True), nullable=False),
sa.Column("not_valid_after", sa.DateTime(timezone=True), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["product_id"], ["products.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_fiscal_certificates_product_branch_live",
"fiscal_certificates",
["product_id", "branch_ref"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
op.create_table(
"fiscal_documents",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("product_id", sa.UUID(), nullable=False),
sa.Column("tenant_ref", sa.String(length=64), nullable=False),
sa.Column("branch_ref", sa.String(length=64), nullable=False),
sa.Column("series_id", sa.UUID(), nullable=False),
sa.Column("document_model", sa.String(length=2), nullable=False),
sa.Column("serie", sa.Integer(), nullable=False),
sa.Column("numero", sa.Integer(), nullable=False),
sa.Column("chave_acesso", sa.String(length=44), nullable=False),
sa.Column("codigo_numerico", sa.String(length=8), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("ambiente", sa.String(length=12), nullable=False),
sa.Column("xml_assinado", sa.Text(), nullable=False),
sa.Column("rejeicao_codigo", sa.String(length=10), nullable=True),
sa.Column("rejeicao_motivo", sa.String(length=500), nullable=True),
sa.Column("protocolo", sa.String(length=20), nullable=True),
sa.Column("autorizada_em", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["product_id"], ["products.id"]),
sa.ForeignKeyConstraint(["series_id"], ["fiscal_series.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("chave_acesso", name="uq_fiscal_documents_chave_acesso"),
)
op.create_index(
"ix_fiscal_documents_product_tenant_branch_status",
"fiscal_documents",
["product_id", "tenant_ref", "branch_ref", "status"],
)
def downgrade() -> None:
op.drop_index("ix_fiscal_documents_product_tenant_branch_status", table_name="fiscal_documents")
op.drop_table("fiscal_documents")
op.drop_index("ix_fiscal_certificates_product_branch_live", table_name="fiscal_certificates")
op.drop_table("fiscal_certificates")
op.drop_table("fiscal_series")