diff --git a/app/db/mock_seed.py b/app/db/mock_seed.py index 833c8c0..4133271 100644 --- a/app/db/mock_seed.py +++ b/app/db/mock_seed.py @@ -60,9 +60,18 @@ VEHICLE_PRICE_BANDS = ( ) +def _cpf_check_digit(base_digits: str) -> str: + total = sum(int(digit) * weight for digit, weight in zip(base_digits, range(len(base_digits) + 1, 1, -1))) + remainder = 11 - (total % 11) + return "0" if remainder >= 10 else str(remainder) + + def _cpf_from_index(index: int) -> str: - """Gera um CPF numerico deterministico de 11 digitos a partir do indice.""" - return str(10_000_000_000 + index).zfill(11) + """Gera um CPF valido e deterministico de 11 digitos a partir do indice.""" + base_digits = f"{100_000_000 + index:09d}"[-9:] + first_digit = _cpf_check_digit(base_digits) + second_digit = _cpf_check_digit(base_digits + first_digit) + return f"{base_digits}{first_digit}{second_digit}" def _vehicle_price_from_index(index: int, rng: random.Random) -> float: @@ -155,3 +164,4 @@ def seed_mock_data() -> None: db.commit() finally: db.close() + diff --git a/app/services/domain/order_service.py b/app/services/domain/order_service.py index 2b8bc55..886c381 100644 --- a/app/services/domain/order_service.py +++ b/app/services/domain/order_service.py @@ -226,7 +226,25 @@ async def realizar_pedido( valor_veiculo = float(vehicle.preco) modelo_veiculo = str(vehicle.modelo) - await hydrate_mock_customer_from_cpf(cpf=cpf_norm, user_id=user_id) + try: + await hydrate_mock_customer_from_cpf(cpf=cpf_norm, user_id=user_id) + except ValueError as exc: + if str(exc) == "cpf_already_linked": + raise_tool_http_error( + status_code=409, + code="cpf_already_linked", + message="Este CPF ja esta vinculado a outro usuario.", + retryable=True, + field="cpf", + ) + raise_tool_http_error( + status_code=400, + code="invalid_cpf", + message="CPF invalido para realizar o pedido.", + retryable=True, + field="cpf", + ) + avaliacao = await validar_cliente_venda(cpf=cpf_norm, valor_veiculo=valor_veiculo) if not avaliacao.get("aprovado"): raise_tool_http_error( @@ -276,10 +294,6 @@ async def realizar_pedido( ) numero_pedido = f"PED-{utc_now().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6].upper()}" - if user_id is not None: - user = db.query(User).filter(User.id == user_id).first() - if user and user.cpf != cpf_norm: - user.cpf = cpf_norm pedido = Order( numero_pedido=numero_pedido, @@ -338,3 +352,5 @@ async def realizar_pedido( finally: _release_vehicle_reservation_lock(db, reservation_lock_name) db.close() + + diff --git a/app/services/flows/order_flow.py b/app/services/flows/order_flow.py index facc964..3975037 100644 --- a/app/services/flows/order_flow.py +++ b/app/services/flows/order_flow.py @@ -992,7 +992,7 @@ class OrderFlowMixin: cpf=str(cpf_value), user_id=user_id, ) - except ValueError: + except ValueError as exc: draft["payload"].pop("cpf", None) self._set_order_flow_entry( "pending_order_drafts", @@ -1001,6 +1001,8 @@ class OrderFlowMixin: draft, active_task="order_create", ) + if str(exc) == "cpf_already_linked": + return "Este CPF ja esta vinculado a outro usuario. Pode me informar um CPF diferente?" return "Para seguir com o pedido, preciso de um CPF valido. Pode me informar novamente?" draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES) @@ -1189,3 +1191,4 @@ class OrderFlowMixin: return self._fallback_format_tool_result("cancelar_pedido", tool_result) + diff --git a/app/services/user/mock_customer_service.py b/app/services/user/mock_customer_service.py index 082393e..1adba70 100644 --- a/app/services/user/mock_customer_service.py +++ b/app/services/user/mock_customer_service.py @@ -2,6 +2,7 @@ import hashlib from app.db.mock_database import SessionMockLocal from app.db.mock_models import Customer, User +from app.services.orchestration.technical_normalizer import is_valid_cpf, normalize_cpf # Helpers para criar clientes ficticios deterministas a partir do CPF. @@ -19,8 +20,6 @@ MOCK_CUSTOMER_NAMES = [ ] -def _normalize_cpf(value: str) -> str: - return "".join(char for char in str(value or "") if char.isdigit()) def _stable_int(seed_text: str) -> int: @@ -50,8 +49,8 @@ async def hydrate_mock_customer_from_cpf( Esta funcao existe apenas para a fase local/mock. Quando a API real entrar, ela deve ser substituida/removida. """ - cpf_norm = _normalize_cpf(cpf) - if len(cpf_norm) != 11: + cpf_norm = normalize_cpf(cpf) + if not cpf_norm or not is_valid_cpf(cpf_norm): raise ValueError("CPF invalido para hidratacao mock.") db = SessionMockLocal() @@ -66,6 +65,14 @@ async def hydrate_mock_customer_from_cpf( linked_user = False user = None if user_id is not None: + conflicting_user = ( + db.query(User) + .filter(User.cpf == cpf_norm, User.id != user_id) + .first() + ) + if conflicting_user: + raise ValueError("cpf_already_linked") + user = db.query(User).filter(User.id == user_id).first() if user and user.cpf != cpf_norm: user.cpf = cpf_norm @@ -87,3 +94,4 @@ async def hydrate_mock_customer_from_cpf( } finally: db.close() + diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index 0b58906..8ce8e74 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -703,6 +703,49 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(registry.calls, []) self.assertIsNotNone(state.get_entry("pending_order_drafts", 10)) + async def test_order_flow_rejects_cpf_linked_to_other_user_without_resetting_vehicle_choice(self): + state = FakeState( + entries={ + "pending_order_drafts": { + 10: { + "payload": {"vehicle_id": 1}, + "expires_at": utc_now() + timedelta(minutes=30), + } + } + }, + contexts={ + 10: { + "generic_memory": {}, + "shared_memory": {}, + "last_stock_results": [ + {"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 48500.0} + ], + "selected_vehicle": {"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 48500.0}, + } + } + ) + registry = FakeRegistry() + flow = OrderFlowHarness(state=state, registry=registry) + + async def fake_hydrate_mock_customer_from_cpf(cpf: str, user_id: int | None = None): + raise ValueError("cpf_already_linked") + + with patch( + "app.services.flows.order_flow.hydrate_mock_customer_from_cpf", + new=fake_hydrate_mock_customer_from_cpf, + ): + response = await flow._try_collect_and_create_order( + message="12345678909", + user_id=10, + extracted_fields={}, + intents={}, + turn_decision={"intent": "order_create", "domain": "sales", "action": "collect_order_create"}, + ) + + self.assertEqual(response, "Este CPF ja esta vinculado a outro usuario. Pode me informar um CPF diferente?") + self.assertEqual(registry.calls, []) + self.assertIsNotNone(state.get_entry("pending_order_drafts", 10)) + self.assertEqual(state.get_user_context(10)["selected_vehicle"]["id"], 1) async def test_order_flow_auto_lists_stock_on_first_purchase_message_when_budget_exists(self): state = FakeState( contexts={ @@ -2876,3 +2919,4 @@ class ToolRegistryExecutionTests(unittest.IsolatedAsyncioTestCase): if __name__ == "__main__": unittest.main() + diff --git a/tests/test_mock_seed.py b/tests/test_mock_seed.py index c7186eb..615c454 100644 --- a/tests/test_mock_seed.py +++ b/tests/test_mock_seed.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import sessionmaker from app.db import mock_seed as mock_seed_module from app.db.mock_database import MockBase from app.db.mock_models import Customer, Order, Vehicle +from app.services.orchestration.technical_normalizer import is_valid_cpf class MockSeedDataTests(unittest.TestCase): @@ -36,6 +37,9 @@ class MockSeedDataTests(unittest.TestCase): db.query(Vehicle).filter(Vehicle.preco <= 70000).count(), 50, ) + sample_cpfs = [row.cpf for row in db.query(Customer).order_by(Customer.id).limit(5).all()] + self.assertTrue(sample_cpfs) + self.assertTrue(all(is_valid_cpf(cpf) for cpf in sample_cpfs)) finally: db.close() @@ -86,3 +90,4 @@ class MockSeedDataTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + diff --git a/tests/test_order_service.py b/tests/test_order_service.py index 18e79a3..2576329 100644 --- a/tests/test_order_service.py +++ b/tests/test_order_service.py @@ -120,6 +120,31 @@ class OrderServiceReservationTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(session.added, []) self.assertTrue(session.closed) + async def test_realizar_pedido_rejects_cpf_linked_to_other_user(self): + vehicle = Vehicle(id=8, modelo="Toyota Corolla 2024", categoria="suv", preco=76087.0) + session = FakeSession(vehicle=vehicle) + + async def fake_hydrate_mock_customer_from_cpf(cpf: str, user_id: int | None = None): + raise ValueError("cpf_already_linked") + + async def fake_validar_cliente_venda(cpf: str, valor_veiculo: float): + raise AssertionError("nao deveria consultar credito quando o CPF ja pertence a outro usuario") + + with patch.object(order_service, "SessionMockLocal", return_value=session), patch.object( + order_service, + "hydrate_mock_customer_from_cpf", + new=fake_hydrate_mock_customer_from_cpf, + ), patch.object( + order_service, + "validar_cliente_venda", + new=fake_validar_cliente_venda, + ): + with self.assertRaises(HTTPException) as ctx: + await order_service.realizar_pedido(cpf="123.456.789-09", vehicle_id=8, user_id=99) + + self.assertEqual(ctx.exception.status_code, 409) + self.assertEqual(ctx.exception.detail["code"], "cpf_already_linked") + self.assertTrue(session.closed) async def test_realizar_pedido_uses_locked_vehicle_before_persisting_order(self): vehicle = Vehicle(id=8, modelo="Toyota Corolla 2024", categoria="suv", preco=76087.0) session = FakeSession(vehicle=vehicle) @@ -161,3 +186,4 @@ class OrderServiceReservationTests(unittest.IsolatedAsyncioTestCase): if __name__ == "__main__": unittest.main() +