Skip to content

Commit 51e3c6b

Browse files
devin-ai-integration[bot]bot_apk
andcommitted
feat: add PJ (Pessoa Jurídica) basic registration
- Add Company model with all 34 required fields (SQLModel ORM + Pydantic schemas) - Add Alembic migration for company table with unique CNPJ index - Add CRUD functions (create_company, get_company_by_cnpj) - Add POST /api/v1/companies/ endpoint (authenticated) - Regenerate OpenAPI client with CompaniesService - Add frontend registration form page at /companies with field validation - Add 'Cadastro PJ' link to sidebar navigation - Add backend tests for create, missing field (422), and duplicate CNPJ (400) Co-Authored-By: bot_apk <apk@cognition.ai>
1 parent 625667c commit 51e3c6b

12 files changed

Lines changed: 1264 additions & 6 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Create company table
2+
3+
Revision ID: a1b2c3d4e5f6
4+
Revises: fe56fa70289e
5+
Create Date: 2026-03-23 18:20:00.000000
6+
7+
"""
8+
import sqlalchemy as sa
9+
import sqlmodel.sql.sqltypes
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "a1b2c3d4e5f6"
14+
down_revision = "fe56fa70289e"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
op.create_table(
21+
"company",
22+
sa.Column("cnpj", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
23+
sa.Column("razao_social", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
24+
sa.Column("representante_legal", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
25+
sa.Column("data_abertura", sa.Date(), nullable=False),
26+
sa.Column("nome_fantasia", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
27+
sa.Column("porte", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
28+
sa.Column("atividade_economica_principal", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
29+
sa.Column("atividade_economica_secundaria", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
30+
sa.Column("natureza_juridica", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
31+
sa.Column("logradouro", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
32+
sa.Column("numero", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
33+
sa.Column("complemento", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
34+
sa.Column("cep", sqlmodel.sql.sqltypes.AutoString(length=10), nullable=False),
35+
sa.Column("bairro", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
36+
sa.Column("municipio", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
37+
sa.Column("uf", sqlmodel.sql.sqltypes.AutoString(length=2), nullable=False),
38+
sa.Column("endereco_eletronico", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
39+
sa.Column("telefone_comercial", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
40+
sa.Column("situacao_cadastral", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
41+
sa.Column("data_situacao_cadastral", sa.Date(), nullable=False),
42+
sa.Column("cpf_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=14), nullable=False),
43+
sa.Column("identidade_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
44+
sa.Column("logradouro_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
45+
sa.Column("numero_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
46+
sa.Column("complemento_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
47+
sa.Column("cep_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=10), nullable=False),
48+
sa.Column("bairro_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
49+
sa.Column("municipio_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
50+
sa.Column("uf_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=2), nullable=False),
51+
sa.Column("endereco_eletronico_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
52+
sa.Column("telefones_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=40), nullable=False),
53+
sa.Column("data_nascimento_representante_legal", sa.Date(), nullable=False),
54+
sa.Column("banco_cc_cnpj", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
55+
sa.Column("agencia_cc_cnpj", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
56+
sa.Column("id", sa.Uuid(), nullable=False),
57+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
58+
sa.PrimaryKeyConstraint("id"),
59+
)
60+
op.create_index(op.f("ix_company_cnpj"), "company", ["cnpj"], unique=True)
61+
62+
63+
def downgrade():
64+
op.drop_index(op.f("ix_company_cnpj"), table_name="company")
65+
op.drop_table("company")

backend/app/api/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from fastapi import APIRouter
22

3-
from app.api.routes import items, login, private, users, utils
3+
from app.api.routes import companies, items, login, private, users, utils
44
from app.core.config import settings
55

66
api_router = APIRouter()
77
api_router.include_router(login.router)
88
api_router.include_router(users.router)
99
api_router.include_router(utils.router)
1010
api_router.include_router(items.router)
11+
api_router.include_router(companies.router)
1112

1213

1314
if settings.ENVIRONMENT == "local":
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from typing import Any
2+
3+
from fastapi import APIRouter, HTTPException
4+
5+
from app.api.deps import CurrentUser, SessionDep
6+
from app.crud import create_company, get_company_by_cnpj
7+
from app.models import CompanyCreate, CompanyPublic
8+
9+
router = APIRouter(prefix="/companies", tags=["companies"])
10+
11+
12+
@router.post("/", response_model=CompanyPublic)
13+
def create_company_route(
14+
*, session: SessionDep, current_user: CurrentUser, company_in: CompanyCreate # noqa: ARG001
15+
) -> Any:
16+
"""
17+
Create new company (PJ).
18+
"""
19+
existing_company = get_company_by_cnpj(session=session, cnpj=company_in.cnpj)
20+
if existing_company:
21+
raise HTTPException(
22+
status_code=400,
23+
detail="A company with this CNPJ already exists.",
24+
)
25+
company = create_company(session=session, company_in=company_in)
26+
return company

backend/app/crud.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,15 @@
44
from sqlmodel import Session, select
55

66
from app.core.security import get_password_hash, verify_password
7-
from app.models import Item, ItemCreate, User, UserCreate, UserUpdate
7+
from app.models import (
8+
Company,
9+
CompanyCreate,
10+
Item,
11+
ItemCreate,
12+
User,
13+
UserCreate,
14+
UserUpdate,
15+
)
816

917

1018
def create_user(*, session: Session, user_create: UserCreate) -> User:
@@ -66,3 +74,16 @@ def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -
6674
session.commit()
6775
session.refresh(db_item)
6876
return db_item
77+
78+
79+
def get_company_by_cnpj(*, session: Session, cnpj: str) -> Company | None:
80+
statement = select(Company).where(Company.cnpj == cnpj)
81+
return session.exec(statement).first()
82+
83+
84+
def create_company(*, session: Session, company_in: CompanyCreate) -> Company:
85+
db_company = Company.model_validate(company_in)
86+
session.add(db_company)
87+
session.commit()
88+
session.refresh(db_company)
89+
return db_company

backend/app/models.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import uuid
2-
from datetime import datetime, timezone
2+
from datetime import date, datetime, timezone
33

44
from pydantic import EmailStr
55
from sqlalchemy import DateTime
@@ -108,6 +108,65 @@ class ItemsPublic(SQLModel):
108108
count: int
109109

110110

111+
# Shared properties for Company (PJ)
112+
class CompanyBase(SQLModel):
113+
cnpj: str = Field(min_length=1, max_length=20)
114+
razao_social: str = Field(min_length=1, max_length=255)
115+
representante_legal: str = Field(min_length=1, max_length=255)
116+
data_abertura: date
117+
nome_fantasia: str = Field(min_length=1, max_length=255)
118+
porte: str = Field(min_length=1, max_length=100)
119+
atividade_economica_principal: str = Field(min_length=1, max_length=255)
120+
atividade_economica_secundaria: str = Field(min_length=1, max_length=255)
121+
natureza_juridica: str = Field(min_length=1, max_length=255)
122+
logradouro: str = Field(min_length=1, max_length=255)
123+
numero: str = Field(min_length=1, max_length=20)
124+
complemento: str = Field(min_length=1, max_length=255)
125+
cep: str = Field(min_length=1, max_length=10)
126+
bairro: str = Field(min_length=1, max_length=255)
127+
municipio: str = Field(min_length=1, max_length=255)
128+
uf: str = Field(min_length=1, max_length=2)
129+
endereco_eletronico: str = Field(min_length=1, max_length=255)
130+
telefone_comercial: str = Field(min_length=1, max_length=20)
131+
situacao_cadastral: str = Field(min_length=1, max_length=100)
132+
data_situacao_cadastral: date
133+
cpf_representante_legal: str = Field(min_length=1, max_length=14)
134+
identidade_representante_legal: str = Field(min_length=1, max_length=20)
135+
logradouro_representante_legal: str = Field(min_length=1, max_length=255)
136+
numero_representante_legal: str = Field(min_length=1, max_length=20)
137+
complemento_representante_legal: str = Field(min_length=1, max_length=255)
138+
cep_representante_legal: str = Field(min_length=1, max_length=10)
139+
bairro_representante_legal: str = Field(min_length=1, max_length=255)
140+
municipio_representante_legal: str = Field(min_length=1, max_length=255)
141+
uf_representante_legal: str = Field(min_length=1, max_length=2)
142+
endereco_eletronico_representante_legal: str = Field(min_length=1, max_length=255)
143+
telefones_representante_legal: str = Field(min_length=1, max_length=40)
144+
data_nascimento_representante_legal: date
145+
banco_cc_cnpj: str = Field(min_length=1, max_length=100)
146+
agencia_cc_cnpj: str = Field(min_length=1, max_length=20)
147+
148+
149+
# Properties to receive on company creation
150+
class CompanyCreate(CompanyBase):
151+
pass
152+
153+
154+
# Database model, database table inferred from class name
155+
class Company(CompanyBase, table=True):
156+
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
157+
cnpj: str = Field(unique=True, index=True, min_length=1, max_length=20)
158+
created_at: datetime | None = Field(
159+
default_factory=get_datetime_utc,
160+
sa_type=DateTime(timezone=True), # type: ignore
161+
)
162+
163+
164+
# Properties to return via API, id is always required
165+
class CompanyPublic(CompanyBase):
166+
id: uuid.UUID
167+
created_at: datetime | None = None
168+
169+
111170
# Generic message
112171
class Message(SQLModel):
113172
message: str
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from fastapi.testclient import TestClient
2+
3+
from app.core.config import settings
4+
5+
VALID_COMPANY_DATA = {
6+
"cnpj": "12345678000199",
7+
"razao_social": "Empresa Teste LTDA",
8+
"representante_legal": "João da Silva",
9+
"data_abertura": "2020-01-15",
10+
"nome_fantasia": "Empresa Teste",
11+
"porte": "ME",
12+
"atividade_economica_principal": "62.01-5-01",
13+
"atividade_economica_secundaria": "62.02-3-00",
14+
"natureza_juridica": "206-2",
15+
"logradouro": "Rua das Flores",
16+
"numero": "100",
17+
"complemento": "Sala 201",
18+
"cep": "01001000",
19+
"bairro": "Centro",
20+
"municipio": "São Paulo",
21+
"uf": "SP",
22+
"endereco_eletronico": "contato@empresa.com.br",
23+
"telefone_comercial": "1133334444",
24+
"situacao_cadastral": "Ativa",
25+
"data_situacao_cadastral": "2020-01-15",
26+
"cpf_representante_legal": "12345678901",
27+
"identidade_representante_legal": "123456789",
28+
"logradouro_representante_legal": "Av. Paulista",
29+
"numero_representante_legal": "500",
30+
"complemento_representante_legal": "Apto 10",
31+
"cep_representante_legal": "01310100",
32+
"bairro_representante_legal": "Bela Vista",
33+
"municipio_representante_legal": "São Paulo",
34+
"uf_representante_legal": "SP",
35+
"endereco_eletronico_representante_legal": "joao@email.com",
36+
"telefones_representante_legal": "11999998888",
37+
"data_nascimento_representante_legal": "1985-06-20",
38+
"banco_cc_cnpj": "Banco do Brasil",
39+
"agencia_cc_cnpj": "1234-5",
40+
}
41+
42+
43+
def test_create_company(
44+
client: TestClient, superuser_token_headers: dict[str, str]
45+
) -> None:
46+
response = client.post(
47+
f"{settings.API_V1_STR}/companies/",
48+
headers=superuser_token_headers,
49+
json=VALID_COMPANY_DATA,
50+
)
51+
assert response.status_code == 200
52+
content = response.json()
53+
assert content["cnpj"] == VALID_COMPANY_DATA["cnpj"]
54+
assert content["razao_social"] == VALID_COMPANY_DATA["razao_social"]
55+
assert content["representante_legal"] == VALID_COMPANY_DATA["representante_legal"]
56+
assert content["nome_fantasia"] == VALID_COMPANY_DATA["nome_fantasia"]
57+
assert content["porte"] == VALID_COMPANY_DATA["porte"]
58+
assert content["logradouro"] == VALID_COMPANY_DATA["logradouro"]
59+
assert content["cpf_representante_legal"] == VALID_COMPANY_DATA["cpf_representante_legal"]
60+
assert content["banco_cc_cnpj"] == VALID_COMPANY_DATA["banco_cc_cnpj"]
61+
assert content["agencia_cc_cnpj"] == VALID_COMPANY_DATA["agencia_cc_cnpj"]
62+
assert "id" in content
63+
assert "created_at" in content
64+
65+
66+
def test_create_company_missing_field(
67+
client: TestClient, superuser_token_headers: dict[str, str]
68+
) -> None:
69+
incomplete_data = VALID_COMPANY_DATA.copy()
70+
del incomplete_data["cnpj"]
71+
response = client.post(
72+
f"{settings.API_V1_STR}/companies/",
73+
headers=superuser_token_headers,
74+
json=incomplete_data,
75+
)
76+
assert response.status_code == 422
77+
78+
79+
def test_create_company_duplicate_cnpj(
80+
client: TestClient, superuser_token_headers: dict[str, str]
81+
) -> None:
82+
data = VALID_COMPANY_DATA.copy()
83+
data["cnpj"] = "99999999000100"
84+
response = client.post(
85+
f"{settings.API_V1_STR}/companies/",
86+
headers=superuser_token_headers,
87+
json=data,
88+
)
89+
assert response.status_code == 200
90+
91+
response = client.post(
92+
f"{settings.API_V1_STR}/companies/",
93+
headers=superuser_token_headers,
94+
json=data,
95+
)
96+
assert response.status_code == 400
97+
content = response.json()
98+
assert content["detail"] == "A company with this CNPJ already exists."

backend/tests/conftest.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from app.core.config import settings
88
from app.core.db import engine, init_db
99
from app.main import app
10-
from app.models import Item, User
10+
from app.models import Company, Item, User
1111
from tests.utils.user import authentication_token_from_email
1212
from tests.utils.utils import get_superuser_token_headers
1313

@@ -17,6 +17,8 @@ def db() -> Generator[Session, None, None]:
1717
with Session(engine) as session:
1818
init_db(session)
1919
yield session
20+
statement = delete(Company)
21+
session.execute(statement)
2022
statement = delete(Item)
2123
session.execute(statement)
2224
statement = delete(User)

0 commit comments

Comments
 (0)