diff --git a/api-collections/core-api/7-partners/suppliers/03-create-supplier.bru b/api-collections/core-api/7-partners/suppliers/03-create-supplier.bru index bf5109f03..1a2440113 100644 --- a/api-collections/core-api/7-partners/suppliers/03-create-supplier.bru +++ b/api-collections/core-api/7-partners/suppliers/03-create-supplier.bru @@ -18,7 +18,7 @@ body:json { { "name": "Forn E2E Bruno", "email": "forn-e2e-bruno@example.com", - "cnpj": "11222333000181", + "document": "11222333000181", "corporateName": "Forn E2E Bruno LTDA", "fantasyName": "Forn E2E", "serviceCategory": "INFORMATICA", diff --git a/api-collections/core-api/7-partners/suppliers/04-get-supplier-by-id.bru b/api-collections/core-api/7-partners/suppliers/04-get-supplier-by-id.bru index 9d2768558..44d999ef7 100644 --- a/api-collections/core-api/7-partners/suppliers/04-get-supplier-by-id.bru +++ b/api-collections/core-api/7-partners/suppliers/04-get-supplier-by-id.bru @@ -22,8 +22,13 @@ tests { expect(res.getBody().id).to.equal(bru.getVar("supplierCreatedId")); }); - test("CA3 — cnpj esta correto", function () { - expect(res.getBody().cnpj).to.equal("11222333000181"); + test("CA3 — document esta correto e o fornecedor e PJ (#1022)", function () { + expect(res.getBody().document).to.equal("11222333000181"); + expect(res.getBody().personType).to.equal("PJ"); + }); + + test("#1022 — alias deprecated cnpj espelha document", function () { + expect(res.getBody().cnpj).to.equal(res.getBody().document); }); test("CA3 — supplier esta ativo apos criacao", function () { diff --git a/api-collections/core-api/7-partners/suppliers/08-update-supplier.bru b/api-collections/core-api/7-partners/suppliers/08-update-supplier.bru index 2d71b777f..578c3a498 100644 --- a/api-collections/core-api/7-partners/suppliers/08-update-supplier.bru +++ b/api-collections/core-api/7-partners/suppliers/08-update-supplier.bru @@ -18,7 +18,7 @@ body:json { { "name": "Forn E2E Bruno Atualizado", "email": "forn-e2e-bruno-updated@example.com", - "cnpj": "11222333000181", + "document": "11222333000181", "corporateName": "Forn E2E Bruno LTDA Atualizado", "fantasyName": "Forn E2E Updated", "serviceCategory": "CONSULTORIA", diff --git a/handbook/architecture/adr/0070-supplier-individual-cpf-supersedes-0031-partial.md b/handbook/architecture/adr/0070-supplier-individual-cpf-supersedes-0031-partial.md new file mode 100644 index 000000000..399ab36f3 --- /dev/null +++ b/handbook/architecture/adr/0070-supplier-individual-cpf-supersedes-0031-partial.md @@ -0,0 +1,126 @@ +[← Voltar para ADRs](./README.md) + +# ADR-0070: Fornecedor pessoa física — o documento do Fornecedor passa a ser CPF **ou** CNPJ, e a PF não tem razão social nem nome fantasia + +- **Status:** Accepted +- **Date:** 2026-10-01 +- **Deciders:** Tech Lead (Gabriel) + P.O do cliente (decisão de produto na issue e na conversa de 2026-10-01) +- **Supersedes (parcial):** [ADR-0031](./0031-partners-registry-module.md) — na linha de `par_suppliers` da tabela de schemas (`cnpj unique`): a chave natural do Fornecedor deixa de ser só CNPJ e o campo sensível da edição passa de `cnpj` a `document`. `Financier` e `Act` **não mudam** — continuam com o `Cnpj` do kernel e recusam CPF. O resto do ADR-0031 segue vigente. +- **Relates:** [#1022](https://github.com/ERP-Bem-Comum/core-api/issues/1022) · [ADR-0043](./0043-partners-supplier-integration-events.md) (payload de integração já usa `document`) · [ADR-0044](./0044-cnpj-alphanumeric-kernel.md) (CNPJ alfanumérico) + +## Contexto + +O cliente tem fornecedores pessoa física, pagos por RPA. O cálculo do RPA já existe no Contas a Pagar; +faltava o cadastro: `Supplier.cnpj` era o VO `Cnpj` do kernel, a coluna `par_suppliers.cnpj` era +`varchar(14)` UNIQUE e o mapper reidratava com `Cnpj.parse` — uma linha com CPF quebraria na leitura. + +Pessoa física não tem razão social nem nome fantasia. A pergunta de modelagem é **como representar +um campo que, para um dos dois tipos de pessoa, não existe**. + +## Decisão + +### 1. Identidade PF × PJ é uma union discriminada no domínio + +```ts +type SupplierIdentity = + | { personType: 'individual'; document: { kind: 'cpf'; value: Cpf } } + | { personType: 'company'; document: { kind: 'cnpj'; value: Cnpj }; corporateName: string; fantasyName: string }; +``` + +Na PF, `corporateName` e `fantasyName` **não existem no tipo** — não é que existam vazios. O +compilador impede ler razão social de quem não a tem sem antes perguntar o `personType`, e o +`switch` exaustivo obriga cada consumidor (DTO, CSV, busca, mapper) a decidir o que mostra para PF. + +O `SupplierDocument` vive em `partners/domain/supplier/`, **não no kernel**: só o Fornecedor aceita os +dois documentos. O `Cnpj` do kernel não muda. + +### 2. O tipo de pessoa é derivado do documento, nunca gravado + +Um campo `personType` gravado permitiria o estado "PF com CNPJ". Derivado do documento (11 dígitos → +CPF/PF; letra ou 14 caracteres → CNPJ/PJ), esse estado não é representável. A borda HTTP expõe +`personType: 'PF' | 'PJ'` calculado. + +### 3. `NULL` só nas bordas; nenhum valor sentinela + +Na coluna MySQL e no JSON, a ausência de razão social/nome fantasia da PF é `NULL`/`null`, e o banco +amarra isso ao documento: + +```sql +CHECK ((CHAR_LENGTH(document) = 11) = (corporate_name IS NULL)) -- idem fantasy_name +``` + +**Alternativa recusada: valor sentinela** (`-1`, `00`, string reservada) no lugar de `null`. Foi +proposta para evitar que um `null` "corrompido" virasse `undefined` ou `NaN` no caminho. A premissa +não se sustenta, e o sentinela traria defeito concreto: + +- Em JS, `null` é um valor primitivo fixo, não um ponteiro para memória não inicializada; o `NULL` do + MySQL é estado definido pelo padrão SQL e o driver `mysql2` o entrega como `null`; no JSON, `null` + faz ida e volta exata. Quem some na serialização é `undefined`, não `null`. +- Corrupção de memória ou de disco atinge qualquer valor por igual — um `-1` corrompido também vira + outra coisa. O sentinela não protege contra ela. +- Os dois campos são **texto**: o sentinela seria a string `"-1"`, que passaria pelo `CHECK` + (`IS NULL` falso) e chegaria à busca, ao CSV, à resposta HTTP e à tela se algum consumidor esquecer + de filtrá-lo — um magic value com outro nome. +- O objetivo real ("o código nunca fica em dúvida se o valor existe") é entregue melhor pela union do + §1: dentro do domínio não há nem `null` nem sentinela. + +O resto do módulo já representa ausência com `null` na borda (`collaborator/types.ts:35-50`). + +### 4. PF com razão social/nome fantasia preenchido é **recusado**, não ignorado + +`supplier-corporate-name-not-allowed-for-pf` / `supplier-fantasy-name-not-allowed-for-pf` (422). +Descartar em silêncio algo que o usuário digitou esconde um erro de cadastro (CPF digitado no lugar +do CNPJ, por exemplo). Branco (`""`, espaços) conta como ausente, porque a tela desativa os campos e +pode enviá-los vazios. Para PJ, os dois continuam obrigatórios. + +### 5. Trocar PF ↔ PJ é trocar o campo sensível + +O campo vital da edição passa de `cnpj` a `document`. Trocar PF ↔ PJ necessariamente troca o +documento, então exige `supplier:edit-sensitive` sem regra adicional. Ao virar PJ, razão social e +nome fantasia passam a ser obrigatórios. + +### 6. Contrato HTTP v1: aditivo, com alias por um ciclo + +O recurso `/api/v1/suppliers` é contrato congelado ([ADR-0033](./0033-api-versioning-v1-legacy-mirror.md)), +então a mudança é **aditiva** e o nome antigo sobrevive por um ciclo, para o backend subir antes do +front sem quebrar a tela atual: + +- **Entrada (POST/PUT):** `document` (11 ou 14 caracteres). `cnpj` aceito como alias deprecated; + exige-se um dos dois, iguais se vierem ambos. +- **Resposta:** `document` + `personType`; `cnpj` segue como alias deprecated de `document`; + `corporateName`/`fantasyName` passam a `string | null`. +- **Códigos de erro renomeados:** `invalid-cnpj` → `invalid-supplier-document`; + `*-cnpj-duplicate` → `*-document-duplicate`. + +A remoção do alias `cnpj` é decisão do próximo ciclo, quando o front (`web-app` spec +`117-fornecedor-pessoa-fisica`) estiver em produção consumindo `document`/`personType`. + +## Consequências + +- **Migration `0020`** (partners): `RENAME COLUMN cnpj TO document` (continua `varchar(14)`), índice + `par_suppliers_document_idx`, `corporate_name`/`fantasy_name` nullable e os dois CHECKs. Sem + backfill: toda linha existente tem CNPJ de 14 caracteres e os dois nomes preenchidos. +- **Integração `partners → financial`:** nenhuma mudança de contrato. O payload já se chamava + `document` desde o ADR-0043, e o evento de domínio `SupplierRegistered` nunca é serializado — o + payload sai do snapshot do agregado. Não há evento antigo no outbox com `cnpj` a aceitar. +- **CNAB/VAN:** sem mudança de código. `financial/domain/payout/inscription.ts` já deriva o tipo de + inscrição do tamanho (11 → `1`). +- **Leitura de nota (OCR):** `findSupplierIdByCnpj` vira `findSupplierIdByDocument` e resolve CPF + também — o emitente de um RPA é pessoa física. ⚠️ Risco aceito: a cascata do leitor de PDF tem + fallbacks sobre o texto inteiro, e um CPF que não é do emitente (tomador, representante) e que + coincida com o de um fornecedor PF passa a **pré-selecioná-lo**. Antes, `Cnpj.parse` descartava + todo valor de 11 posições. É pré-seleção, revisável na tela, não lançamento; se aparecer na + prática, a correção é restringir o resolver ao campo do emitente, não recusar CPF. +- **ETL legada:** a coluna legada `cnpj` pode trazer CPF; antes ia para quarentena (`CnpjInvalid`), + agora entra como PF. O legado tinha razão social/nome fantasia `NOT NULL`, então a PF chega com os + dois preenchidos à força; o ACL os **descarta** para PF, porque ali eles são enchimento de + formulário, não dado da pessoa. **Exceção: CPF ambíguo vai para quarentena** (`ExcludedByDecision`, + ADR-0070) — se as 11 posições completadas com zeros formam um CNPJ válido, pode ser um CNPJ + legado que perdeu os zeros à esquerda (pelos pesos do módulo 11, só acontece com documento + iniciado em `00`), e importá-lo como PF descartaria a razão social real. Se a ETL já rodou e pôs fornecedores PF em quarentena, eles + precisam ser recarregados depois desta entrega (#1022 §6). +- **CSV:** a coluna do documento passa a se chamar `CPF/CNPJ`; razão social e nome fantasia saem + vazios na PF. +- ⚠️ **Mudança de status perceptível:** POST/PUT de PJ **sem** `corporateName` passa de 400 (Zod) a + 422 (`supplier-corporate-name-required`, domínio), porque o shape não pode mais exigir o campo — quem + decide é o documento. diff --git a/handbook/architecture/adr/README.md b/handbook/architecture/adr/README.md index 0796b13f8..d2307ad50 100644 --- a/handbook/architecture/adr/README.md +++ b/handbook/architecture/adr/README.md @@ -166,6 +166,7 @@ Um **ADR (Architecture Decision Record)** é um documento curto que captura uma | [0067](./0067-typescript-7-side-by-side-supersedes-0009-language.md) | **TypeScript 7 nativo compila; o TS 6 fica só para o `typescript-eslint`** — `supersedes` **parcial** de [0009](./0009-node-24-typescript-6-with-7-roadmap.md), seções "Linguagem" (`:32-34`) e "Plano de migração" (`:36-42`). O gatilho literal do 0009 (`:90-96`) disparou: **TS 7.0 é GA desde 08/07/2026**. Mas duas previsões dele estavam erradas e ficam ditas: "ajustes mínimos" não descreve o port — **nenhuma** versão do `typescript-eslint` aceita TS 7 (peer `>=4.8.4 <6.1.0` no `latest` 8.68.0 **e** no canary; sem v9 publicada; issue [#10940](https://github.com/typescript-eslint/typescript-eslint/issues/10940) OPEN, parada desde 09/07) —, e o "CI roda `tsc` e `tsgo` em paralelo" (`:39`) **nunca foi implementado** em 4 meses, então é **revogado**, não repetido. Side-by-side por alias: `typescript` → `npm:@typescript/typescript6`, `@typescript/native` → `npm:typescript@^7.0.2`. **`@typescript/native-preview` sai** (descontinuado pela Microsoft). ✅ Nenhum breaking do TS 7 atinge o `tsconfig` (medido campo a campo: `ES2024`, `NodeNext`, sem `baseUrl`, `types`/`strict` explícitos) — dividendo do "strict total desde o dia 1". ⚠️ Risco declarado: template literal com emoji muda tipo inferido **sem erro** (TS 6 conta 4, TS 7 conta 3) — exposição **medida hoje: nula**, o repo não tem template literal type algum. ⚠️ Custo consciente: o alias só existe até `6.0.2` contra `6.0.3` instalado. Instalação **respeita a quarentena** de 24h (`minimumReleaseAgeStrict`) | Accepted | 2026-08-25 | | [0068](./0068-env-fail-fast-every-environment.md) | **Toda variável de ambiente lida é obrigatória, em TODO ambiente** — ausente ou recusada, o processo não sobe; e `X_DRIVER=memory` deixa de ser valor aceito. Fecha a [#799](https://github.com/ERP-Bem-Comum/core-api/issues/799) e **revoga o FR-007** do [#456](https://github.com/ERP-Bem-Comum/core-api/issues/456), que fazia `memory` declarado subir em produção "sem falhar". O fato que decide não está no código: **as envs de homologação e produção são postas à mão, na console da AWS, por um funcionário da Codebit** — degradar ali não protege ninguém, porque o sinal não chega a quem poderia corrigir; e local que degrada deixa de espelhar hml/prod, que é como um defeito de configuração sobrevive até o deploy. Rejeita a variante mais estrita (remover `driver: 'memory'` também dos seis tipos por módulo) por ser **cara e inútil**: custo medido de **179** arquivos de teste migrando para integração e `pnpm test` passando a exigir Docker, para trancar uma porta já trancada — em hml/prod a única entrada do in-memory é a env, e não existe caller que passe `{ driver: 'memory' }` fora do `server.ts` e dos testes ([Inquiry-0034](../../inquiries/0034-in-memory-fora-de-local-custo-na-piramide.md)). O adapter em memória **sobrevive como double de teste injetado** — outra fronteira, outra coisa. ⚠️ Custo declarado: todo dev passa a precisar declarar as envs, e um ambiente sem elas para de subir | Accepted | 2026-08-31 | | [0069](./0069-approval-policy-follows-rbac-bypass-supersedes-0052-partial.md) | **A alçada de aprovação segue o `AUTH_RBAC_MODE=bypass`** — `supersedes` **parcial** de [0052](./0052-rbac-bypass-flag.md): o bypass deixa de parar na borda HTTP e alcança a permissão `payable:approve` lida pela `approval-policy` do domínio. O bypass vinha sendo aplicado pela metade, por **três** camadas que decidem sobre permissão e das quais só duas o honravam: `authorize` vira no-op ✅, o `GET /me` anuncia o **catálogo inteiro** (`list-user-permissions.ts:35`) ✅, e a `approval-policy` (`approval-policy.ts:27`) lia `canApprove` do banco cru e recusava ❌. Medido em 09/09 no ambiente local: o `/me` de um usuário do ETL legado devolvia **47 permissões, incluindo `payable:approve`**; o banco lhe dava **uma**, e não era essa — ele tomava uma recusa que **contradizia o que o `/me` acabara de lhe dizer**, depois de o `authorize` no-op o deixar entrar até o fundo. Aplicado como **decorator** (`withRbacBypass`) composto num ponto só, no composition root do `financial`; o `server.ts` traduz o modo num booleano e o `financial` não passa a conhecer `RbacMode`. ⚠️ Alcança **só o ato de aprovar** (`depsForApprove`): o mesmo port responde a outra pergunta em `saveDocument`/`submitDraft` — se o `approverRef` **indicado** tem alçada —, e essa é **roteamento**, segue enforçada. Compor no `deps` compartilhado, que é o caminho óbvio, faria a indicação **gravar** `approverRef` de quem não é aprovador, e a linha **sobrevive ao religar** — dano que o #634 não desfaz. Continuam valendo sob bypass: `approver-not-found` para usuário inexistente (o bypass afrouxa **permissão**, nunca a existência do sujeito), o **teto** de quem tem papel com alçada (#299/#609) e o `list` de candidatos da cascata (`escalate` é **roteamento de negócio**, não controle de acesso). ⚠️ **Custo aceito, e é o principal: sob bypass todo autenticado aprova qualquer valor** — quem não tem papel aprovador tem teto `null`, e `null` é **SEM TETO** pela regra binária do #299 (`user-read.drizzle.ts:42` + `approval-policy.ts:28-31`), então "o teto continua valendo" **não protege** a população que esta decisão libera. Reabre, enquanto o bypass durar, o buraco que o #609 fechou — e **vale em produção** (linha marcada `← religar` no `server.ts`, risco assumido por escrito no #634). **Gatilho de reversão: o #634 — e ele custa DUAS linhas, não zero:** são duas marcas `← religar`, o `rbacMode` fixado e o `rbacBypass: true`; apagar só a primeira religa a rota e o `/me` e **deixa a policy do domínio afrouxada**, sem que erro de tipo, teste ou lint acusem. **Alternativa recusada** pelo dono: consertar o `/me` subtraindo `payable:approve` do catálogo — sob bypass a promessa é *"todo autenticado é super-usuário"*, e uma regra de domínio que segue cobrando permissão é exceção à promessa, não correção dela. ⚠️ **Não alcança** outras policies de domínio que leiam permissão do banco; hoje esta é a única identificada | Accepted | 2026-09-09 | +| [0070](./0070-supplier-individual-cpf-supersedes-0031-partial.md) | **Fornecedor pessoa física: o documento do Fornecedor passa a ser CPF ou CNPJ** — `supersedes` **parcial** de [0031](./0031-partners-registry-module.md) na chave de `par_suppliers` (`cnpj` → `document`) e no campo sensível da edição; `Financier` e `Act` seguem só com CNPJ. A identidade é **union discriminada** no domínio (`individual` × `company`): na PF, razão social e nome fantasia **não existem no tipo**. O tipo de pessoa é **derivado** do documento, nunca gravado — um campo próprio permitiria "PF com CNPJ". `NULL` só nas bordas (coluna e JSON), amarrado ao documento por `CHECK ((CHAR_LENGTH(document) = 11) = (corporate_name IS NULL))`. **Alternativa recusada: valor sentinela** (`-1`, `00`) no lugar de `null` — `null` em JS é primitivo fixo e faz ida e volta exata no `mysql2` e no JSON; o sentinela seria a string `"-1"`, passaria pelo `CHECK` e vazaria para busca, CSV e tela. PF com razão social preenchida é **recusada** (422), não ignorada; branco conta como ausente. HTTP v1 **aditivo**: `document` + `personType` (`PF`/`PJ`), `cnpj` mantido como alias deprecated por um ciclo na entrada e na resposta. ⚠️ PJ sem `corporateName` passa de 400 (Zod) a 422 (domínio). ETL: CPF na coluna legada `cnpj` deixa de ir para quarentena e entra como PF, com os nomes forçados pelo `NOT NULL` legado descartados | Accepted | 2026-10-01 | | [0071](./0071-s3-dev-silo-fork-supersedes-0019-partial.md) | **O S3 de dev/teste passa a ser o fork Silo** — `supersedes` **parcial** de [0019](./0019-document-storage-s3-with-minio-dev.md), só na **fonte da imagem**: a MinIO apagou `minio/minio` e `minio/mc` do Docker Hub em 2026-09-11, o espelho `quay.io/minio` exige login, e o `integration` falha todo dia na `main` desde 2026-09-12 ([#1026](https://github.com/ERP-Bem-Comum/core-api/issues/1026)). O compose passa a `pgsty/silo` + `pgsty/mc`, pinados por digest — fork **mantido**, **AGPLv3** como o MinIO, e **drop-in**: mesmas envs `MINIO_*`, rotas `/minio/*`, API S3. Escolhido entre seis hipóteses testadas em container isolado contra as quatro suítes reais do CI: Bitnami legacy (congelada, sem patches, não drop-in), MinIO compilado do fonte (upstream arquivado — viraríamos mantenedores de segurança), RustFS e SeaweedFS (Apache-2.0, passam no S3, mas exigem refazer serviço e bootstrap). ⚠️ Custo aceito: **bus factor baixo** (um mantenedor principal). Gatilho de saída: RustFS, com ADR próprio. Produção (AWS S3) não muda | Accepted | 2026-10-01 | ### Notas de numeração diff --git a/scripts/etl/diagnostics/check-duplicates.ts b/scripts/etl/diagnostics/check-duplicates.ts index 0727d2e2d..abc65ee46 100644 --- a/scripts/etl/diagnostics/check-duplicates.ts +++ b/scripts/etl/diagnostics/check-duplicates.ts @@ -31,7 +31,13 @@ type DuplicateCheck = Readonly<{ // As chaves que o destino impõe como UNIQUE (confirmadas na integração partners/auth). const CHECKS: readonly DuplicateCheck[] = [ - { table: 'suppliers', column: 'cnpj', normalize: 'digits', targetUnique: 'par_suppliers.cnpj' }, + // Coluna legada `cnpj` (pode trazer CPF) → `par_suppliers.document` desde a migration 0020 (#1022). + { + table: 'suppliers', + column: 'cnpj', + normalize: 'digits', + targetUnique: 'par_suppliers.document', + }, { table: 'financiers', column: 'cnpj', normalize: 'digits', targetUnique: 'par_financiers.cnpj' }, { table: 'collaborators', diff --git a/scripts/etl/mappers/supplier.mapper.ts b/scripts/etl/mappers/supplier.mapper.ts index c8a35fc87..84ea2c8bc 100644 --- a/scripts/etl/mappers/supplier.mapper.ts +++ b/scripts/etl/mappers/supplier.mapper.ts @@ -4,10 +4,16 @@ * Renomeia `bancaryInfo*`→bankAccount e `pixInfo*`→pixKey; `serviceCategory` literal * (ADR-0031 §D2 — quarentena se fora do Set); valida e-mail por formato. Sem destino de * pagamento → quarentena (`Supplier.rehydrate` exige ao menos um). D10 para inativos. + * + * Documento (#1022): a coluna legada `cnpj` pode trazer CPF — fornecedor pessoa física, pago por + * RPA. Antes ia para quarentena como `CnpjInvalid`; agora entra como PF. O legado tinha + * `corporateName`/`fantasyName` NOT NULL, então a PF chega com os dois preenchidos à força; o + * core não os tem na PF, e o ACL os descarta (ver `identityFieldsFor`). */ import { type Result, ok, err, combine } from '#src/shared/primitives/result.ts'; -import type { Cnpj } from '#src/shared/kernel/cnpj.ts'; +import { isValidCnpj } from '#src/shared/kernel/cnpj.ts'; +import * as SupplierDocument from '#src/modules/partners/domain/supplier/supplier-document.ts'; import * as SupplierId from '#src/modules/partners/domain/supplier/supplier-id.ts'; import * as Supplier from '#src/modules/partners/domain/supplier/supplier.ts'; import * as ServiceCategory from '#src/modules/partners/domain/supplier/service-category.ts'; @@ -22,13 +28,7 @@ import type { } from '#src/modules/partners/domain/shared/payment-target.ts'; import type { LegacySupplierRow } from '../legacy/rows.ts'; import type { QuarantineReason } from '../quarantine/reason.ts'; -import { - type MapperResult, - requireField, - requireEmail, - parseCnpjField, - statusFromActive, -} from './shared.ts'; +import { type MapperResult, requireField, requireEmail, statusFromActive } from './shared.ts'; // ACL translator: legacy pix_key_type vocabulary → core PixKeyType (#275, Evans DDD p.226). const LEGACY_PIX_KEY_TYPE_MAP: Readonly> = { @@ -121,33 +121,87 @@ const resolvePaymentTargets = ( return errors.length > 0 ? err(errors) : ok({ bankAccount, pixKey }); }; +// CPF ou CNPJ na coluna legada `cnpj`. Inválido → quarentena com a tag de sempre (o campo de +// origem segue sendo `cnpj`). +// +// ⚠️ CPF AMBÍGUO vai para quarentena, não para PF. Um CNPJ legado que perdeu os zeros à esquerda +// (`000…` → 11 posições) e por acaso passa no DV de CPF seria migrado como pessoa física, com a +// razão social real descartada e sem rastro. Se as 11 posições completadas com zeros formam um CNPJ +// válido, o documento é ambíguo e a decisão volta para um humano (ADR-0070). +const parseDocumentField = ( + raw: string, +): Result => { + const r = SupplierDocument.parse(raw); + if (!r.ok) return err({ tag: 'CnpjInvalid', field: 'cnpj', attempted: raw }); + if ( + r.value.kind === 'cpf' && + isValidCnpj(r.value.value.padStart(SupplierDocument.CNPJ_LENGTH, '0')) + ) { + return err({ tag: 'ExcludedByDecision', field: 'cnpj', decisionRef: 'ADR-0070' }); + } + return ok(r.value); +}; + +// Nomes da PJ, obrigatórios. Usado também quando o documento é inválido: o legado é PJ por +// padrão (coluna `cnpj`), e checar os nomes mesmo assim mantém a regra de juntar TODOS os erros +// da linha numa quarentena só — senão quem corrige o documento descobre o nome numa 2ª rodada. +const requireCompanyNames = ( + row: LegacySupplierRow, +): Result => { + const names = combine([ + requireField(row.corporateName, 'corporate_name'), + requireField(row.fantasyName, 'fantasy_name'), + ]); + return names.ok ? ok(names.value) : err(names.error); +}; + +// Razão social/nome fantasia que o domínio recebe. PF: `null` — o legado obrigava a preenchê-los +// (NOT NULL), então o que vem ali é enchimento de formulário, não dado da pessoa física; o domínio +// recusaria a PF com eles. PJ: obrigatórios, como sempre (branco → quarentena). +const identityFieldsFor = ( + document: SupplierDocument.SupplierDocument, + row: LegacySupplierRow, +): Result => { + switch (document.kind) { + case 'cpf': + return ok([null, null]); + case 'cnpj': + return requireCompanyNames(row); + } +}; + export const mapLegacySupplierRow = (row: LegacySupplierRow): MapperResult => { + const documentField = parseDocumentField(row.cnpj); const fields = combine< - readonly [string, string, string, string, Cnpj, ServiceCategoryType], + readonly [string, string, SupplierDocument.SupplierDocument, ServiceCategoryType], QuarantineReason >([ requireField(row.name, 'name'), requireEmail(row.email, 'email'), - requireField(row.corporateName, 'corporate_name'), - requireField(row.fantasyName, 'fantasy_name'), - parseCnpjField(row.cnpj, 'cnpj'), + documentField, parseServiceCategory(row.serviceCategory), ]); + // Os nomes dependem SÓ do documento: PF não os tem; PJ — ou documento inválido — os exige. + const names = documentField.ok + ? identityFieldsFor(documentField.value, row) + : requireCompanyNames(row); const targets = resolvePaymentTargets(row); const rating = translateServiceRating(row.serviceEvaluation); // Acumula erros de campos escalares + destino de pagamento + avaliação numa única quarentena. - if (!fields.ok || !targets.ok || !rating.ok) { + if (!fields.ok || !names.ok || !targets.ok || !rating.ok) { const all = [ ...(fields.ok ? [] : fields.error), + ...(names.ok ? [] : names.error), ...(targets.ok ? [] : targets.error), ...(rating.ok ? [] : [rating.error]), ]; return err(all); } - const [name, email, corporateName, fantasyName, cnpj, serviceCategory] = fields.value; + const [name, email, document, serviceCategory] = fields.value; + const [corporateName, fantasyName] = names.value; const status = statusFromActive(row.active); const deactivatedAt = status === 'Inactive' ? row.updatedAt : null; @@ -155,7 +209,7 @@ export const mapLegacySupplierRow = (row: LegacySupplierRow): MapperResult => { id: randomUUID(), name: 'Fornecedor Alfa Informática S/A', email: 'contato@alfainfo.com.br', - cnpj: generateValidCnpj('200000000001'), + document: generateValidCnpj('200000000001'), corporateName: 'ALFA INFORMATICA E SERVICOS S/A', fantasyName: 'Alfa Tech', serviceCategory: 'INFORMATICA', @@ -93,7 +93,7 @@ const main = async (): Promise => { id: randomUUID(), name: 'Restaurante Sabor Caseiro Ltda', email: 'financeiro@saborcaseiro.com.br', - cnpj: generateValidCnpj('200000000002'), + document: generateValidCnpj('200000000002'), corporateName: 'SABOR CASEIRO PROVEDORES DE ALIMENTOS LTDA', fantasyName: 'Restaurante Sabor Caseiro', serviceCategory: 'ALIMENTACAO', @@ -107,7 +107,7 @@ const main = async (): Promise => { id: randomUUID(), name: 'Gama Organização de Eventos EIRELI', email: 'eventos@gama.com.br', - cnpj: generateValidCnpj('200000000003'), + document: generateValidCnpj('200000000003'), corporateName: 'GAMA ORGANIZACAO DE EVENTOS LTDA', fantasyName: 'Gama Eventos', serviceCategory: 'ONGANIZACAO_DE_EVENTOS', @@ -123,7 +123,7 @@ const main = async (): Promise => { id: randomUUID(), name: 'Delta Conservação e Limpeza', email: 'delta@deltalimpeza.com.br', - cnpj: generateValidCnpj('200000000004'), + document: generateValidCnpj('200000000004'), corporateName: 'DELTA SERVICOS DE PORTARIA E LIMPEZA LTDA', fantasyName: 'Delta Limpeza', serviceCategory: 'LIMPEZA', diff --git a/src/modules/financial/adapters/http/composition.ts b/src/modules/financial/adapters/http/composition.ts index 804b89d22..036e4c421 100644 --- a/src/modules/financial/adapters/http/composition.ts +++ b/src/modules/financial/adapters/http/composition.ts @@ -1034,7 +1034,7 @@ const makeDeps = ( // const p/ narrow no closure do resolveSupplierByCnpj (método opcional no port). #FIN-OCR-AUTOFILL: // só quando o partners está disponível (driver mysql); memory → sem resolução (seleção manual). Um // closure compartilhado por ingest (#560) e parse (#580). - const resolveSupplierId = pools.contractorReadPort?.findSupplierIdByCnpj; + const resolveSupplierId = pools.contractorReadPort?.findSupplierIdByDocument; const resolveSupplierByCnpj = resolveSupplierId !== undefined ? async (taxId: string) => { diff --git a/src/modules/partners/adapters/export/supplier-csv.ts b/src/modules/partners/adapters/export/supplier-csv.ts index bc9ee7e04..2ddc097ab 100644 --- a/src/modules/partners/adapters/export/supplier-csv.ts +++ b/src/modules/partners/adapters/export/supplier-csv.ts @@ -7,13 +7,15 @@ */ import { toCsv } from '#src/shared/utils/csv.ts'; +import { companyNamesOf, documentOf } from '../../domain/supplier/supplier.ts'; import type { Supplier } from '../../domain/supplier/types.ts'; const HEADER: readonly string[] = [ 'id', 'name', 'email', - 'cnpj', + // Rótulo ao humano (#1022): a coluna traz CPF (PF) ou CNPJ (PJ). + 'CPF/CNPJ', 'corporateName', 'fantasyName', 'serviceCategory', @@ -29,14 +31,16 @@ const HEADER: readonly string[] = [ // Achata o agregado em 15 células (ordem do HEADER). Destino de pagamento é discriminado: // colunas bancárias vazias quando `bankAccount` é null; idem pix. `deactivatedAt` só nos Inactive. +// Razão social e nome fantasia só existem na PJ; na PF as células ficam vazias. const supplierToCells = (s: Supplier): readonly string[] => { + const names = companyNamesOf(s.identity); const identity = [ s.id, s.name, s.email, - String(s.cnpj), - s.corporateName, - s.fantasyName, + documentOf(s), + names?.corporateName ?? '', + names?.fantasyName ?? '', s.serviceCategory, s.status, ]; diff --git a/src/modules/partners/adapters/http/partner-aggregate-query.ts b/src/modules/partners/adapters/http/partner-aggregate-query.ts index 349c0d68f..cc88dfaed 100644 --- a/src/modules/partners/adapters/http/partner-aggregate-query.ts +++ b/src/modules/partners/adapters/http/partner-aggregate-query.ts @@ -10,6 +10,7 @@ */ import { ok, err, type Result } from '#src/shared/primitives/result.ts'; +import { documentOf } from '#src/modules/partners/domain/supplier/supplier.ts'; import type { SupplierReadRecord } from '#src/modules/partners/application/ports/supplier-reader.ts'; import type { FinancierReadRecord } from '#src/modules/partners/application/ports/financier-reader.ts'; import type { CollaboratorReadRecord } from '#src/modules/partners/application/ports/collaborator-reader.ts'; @@ -59,7 +60,7 @@ const supplierItem = (r: SupplierReadRecord): PartnerListItem => ({ type: 'supplier', id: r.supplier.id as unknown as string, name: r.supplier.name, - document: r.supplier.cnpj as unknown as string, + document: documentOf(r.supplier), active: r.supplier.status === 'Active', }); diff --git a/src/modules/partners/adapters/http/supplier-dto.ts b/src/modules/partners/adapters/http/supplier-dto.ts index c17de871a..4f16fe75f 100644 --- a/src/modules/partners/adapters/http/supplier-dto.ts +++ b/src/modules/partners/adapters/http/supplier-dto.ts @@ -4,6 +4,7 @@ */ import type { SupplierReadRecord } from '#src/modules/partners/application/ports/supplier-reader.ts'; +import { companyNamesOf, documentOf } from '#src/modules/partners/domain/supplier/supplier.ts'; import type { SupplierDetailDto } from './supplier-schemas.ts'; export const supplierToDetailDto = ( @@ -11,14 +12,21 @@ export const supplierToDetailDto = ( contractCount: number, ): SupplierDetailDto => { const s = record.supplier; + const document = documentOf(s); + // Identidade PF × PJ (#1022): na PF razão social e nome fantasia não existem, e o JSON diz + // "ausente" com `null`. `personType` é derivado do documento, nunca gravado. + const names = companyNamesOf(s.identity); return { id: String(s.id), legacyId: record.legacyId, name: s.name, email: s.email, - cnpj: String(s.cnpj), - corporateName: s.corporateName, - fantasyName: s.fantasyName, + document, + // DEPRECATED (#1022): alias de `document` por um ciclo, até o front migrar. + cnpj: document, + personType: s.identity.personType === 'individual' ? 'PF' : 'PJ', + corporateName: names?.corporateName ?? null, + fantasyName: names?.fantasyName ?? null, serviceCategory: s.serviceCategory, bankAccount: s.bankAccount, pixKey: s.pixKey, diff --git a/src/modules/partners/adapters/http/supplier-plugin.ts b/src/modules/partners/adapters/http/supplier-plugin.ts index 34836c1fa..0939e19d6 100644 --- a/src/modules/partners/adapters/http/supplier-plugin.ts +++ b/src/modules/partners/adapters/http/supplier-plugin.ts @@ -52,9 +52,9 @@ const SENSITIVE_PERMISSION = 'supplier:edit-sensitive'; // Conflito de estado/unicidade → 409. const CONFLICT_CODES: ReadonlySet = new Set([ - 'register-supplier-cnpj-duplicate', - 'supplier-cnpj-duplicate', - 'edit-supplier-cnpj-duplicate', + 'register-supplier-document-duplicate', + 'supplier-document-duplicate', + 'edit-supplier-document-duplicate', 'supplier-already-inactive', 'supplier-already-active', ]); @@ -71,7 +71,8 @@ const BAD_REQUEST_CODES: ReadonlySet = new Set([ const FORBIDDEN_CODES: ReadonlySet = new Set(['edit-supplier-sensitive-forbidden']); const REPO_UNAVAILABLE_CODES: ReadonlySet = new Set(['supplier-repo-unavailable']); -// Erro de escrita → status. Default 422 (invariante de domínio: email/cnpj/categoria/payment-target). +// Erro de escrita → status. Default 422 (invariante de domínio: email/documento/identidade PF × PJ/ +// categoria/payment-target). const writeErrorStatus = (code: string): number => { if (FORBIDDEN_CODES.has(code)) return 403; if (CONFLICT_CODES.has(code)) return 409; @@ -262,7 +263,8 @@ const suppliersRoutes = }); // Edição (PUT total). `supplier:write` edita campos não-vitais (incl. payment target); - // mudar o CNPJ (vital) exige `supplier:edit-sensitive` (regra no use case). + // mudar o documento (vital) — inclusive trocar PF ↔ PJ — exige `supplier:edit-sensitive` + // (regra no use case). scope.route({ method: 'PUT', url: '/suppliers/:id', diff --git a/src/modules/partners/adapters/http/supplier-schemas.ts b/src/modules/partners/adapters/http/supplier-schemas.ts index a3528041c..7088a3309 100644 --- a/src/modules/partners/adapters/http/supplier-schemas.ts +++ b/src/modules/partners/adapters/http/supplier-schemas.ts @@ -8,6 +8,8 @@ import * as z from 'zod/v4'; +import * as SupplierDocument from '#src/modules/partners/domain/supplier/supplier-document.ts'; + const LIST_LIMIT_MAX = 100; const LIST_LIMIT_DEFAULT = 5; @@ -61,17 +63,32 @@ const pixKeySchema = z.object({ key: z.string(), }); -/** Detalhe — espelha o schema `Supplier` legado. `id` UUID do core; `legacyId` int antigo. */ +/** + * Detalhe — espelha o schema `Supplier` legado. `id` UUID do core; `legacyId` int antigo. + * + * Fornecedor pessoa física (#1022): `document` + `personType` são os campos novos (aditivos). + * `cnpj` segue na resposta como alias DEPRECATED de `document` por um ciclo, para o front atual + * não quebrar enquanto migra. Na PF, `corporateName`/`fantasyName` vêm `null` — o campo não + * existe para pessoa física, e `null` é como o JSON diz "ausente" (nunca sentinela). + */ export const supplierDetailSchema = z.object({ id: z.uuid(), legacyId: z.number().int().nullable(), name: z.string(), email: z.string(), - cnpj: z - .string() - .meta({ description: 'CNPJ — 14 caracteres sem máscara, alfanumérico (ADR-0044)' }), - corporateName: z.string(), - fantasyName: z.string(), + document: z.string().meta({ + description: + 'CPF (11 caracteres numéricos, PF) ou CNPJ (14 caracteres alfanuméricos, PJ — ADR-0044), sem máscara', + }), + personType: z.enum(['PF', 'PJ']).meta({ + description: 'Tipo de pessoa, derivado do documento (não é gravado): CPF → PF, CNPJ → PJ', + }), + cnpj: z.string().meta({ + deprecated: true, + description: 'DEPRECATED — alias de `document` por um ciclo (#1022). Usar `document`.', + }), + corporateName: z.string().nullable().meta({ description: 'Razão social — `null` na PF' }), + fantasyName: z.string().nullable().meta({ description: 'Nome fantasia — `null` na PF' }), serviceCategory: z.string(), bankAccount: bankAccountSchema.nullable(), pixKey: pixKeySchema.nullable(), @@ -120,26 +137,73 @@ const pixKeyInputSchema = z.object({ key: z.string(), }); +// Shape do documento na borda: 11 (CPF) ou 14 (CNPJ) caracteres alfanuméricos — SEM máscara. O +// alfanumérico não é enfeite: só com o tamanho, um CPF mascarado (`123.456.789-09`, 14 caracteres) +// passava como se fosse CNPJ sem máscara, enquanto um CNPJ mascarado (18) caía em 400 — máscara +// aceita para um documento e recusada para o outro. O DV e a escolha CPF × CNPJ são do domínio +// (`SupplierDocument.parse` → 422 `invalid-supplier-document`). +const supplierDocumentInputSchema = z + .string() + .refine( + (v) => + /^[0-9A-Za-z]+$/.test(v) && + (v.length === SupplierDocument.CPF_LENGTH || v.length === SupplierDocument.CNPJ_LENGTH), + { message: 'documento deve ter 11 (CPF) ou 14 (CNPJ) caracteres alfanuméricos, sem máscara' }, + ); + /** * Body do POST /suppliers. Espelha `CreateSupplier` legado. A invariante "ao menos um * payment target" é do domínio (ausência de ambos → 422), não do Zod. `serviceCategory` * e `pixKey.keyType` como string (validados no domínio). + * + * Fornecedor pessoa física (#1022): `document` aceita CPF ou CNPJ. `cnpj` continua aceito como + * alias DEPRECATED por um ciclo, para o backend subir antes do front sem quebrar a tela atual — + * exige-se um dos dois, e iguais se vierem ambos. `corporateName`/`fantasyName` deixam de ser + * obrigatórios no shape: quem decide é o documento, no domínio (PF preenchido → 422; PJ sem → 422). */ -export const createSupplierBodySchema = z.object({ - name: z.string().min(1), - email: z.string().min(1), - cnpj: z.string().length(14).meta({ - description: 'CNPJ — 14 caracteres sem máscara, alfanumérico; DV validado no domínio', - }), - corporateName: z.string().min(1), - fantasyName: z.string().min(1), - serviceCategory: z.string().min(1), - bankAccount: bankAccountInputSchema.nullable().default(null), - pixKey: pixKeyInputSchema.nullable().default(null), - // Avaliação opcional. Domínio é autoridade do conjunto (rating inválido → 422). - serviceRating: z.string().nullable().default(null), - ratingComment: z.string().nullable().default(null), -}); +export const createSupplierBodySchema = z + .object({ + name: z.string().min(1), + email: z.string().min(1), + document: supplierDocumentInputSchema.optional().meta({ + description: + 'CPF (11 caracteres numéricos) ou CNPJ (14 caracteres alfanuméricos), sem máscara', + }), + cnpj: supplierDocumentInputSchema.optional().meta({ + deprecated: true, + description: 'DEPRECATED — alias de `document` por um ciclo (#1022). Usar `document`.', + }), + corporateName: z.string().nullable().default(null), + fantasyName: z.string().nullable().default(null), + serviceCategory: z.string().min(1), + bankAccount: bankAccountInputSchema.nullable().default(null), + pixKey: pixKeyInputSchema.nullable().default(null), + // Avaliação opcional. Domínio é autoridade do conjunto (rating inválido → 422). + serviceRating: z.string().nullable().default(null), + ratingComment: z.string().nullable().default(null), + }) + .superRefine((body, ctx) => { + if (body.document === undefined && body.cnpj === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['document'], + message: 'informe `document` (ou o alias deprecated `cnpj`)', + }); + } else if ( + body.document !== undefined && + body.cnpj !== undefined && + body.document !== body.cnpj + ) { + ctx.addIssue({ + code: 'custom', + path: ['cnpj'], + message: '`cnpj` é alias de `document`: os dois, se vierem, precisam ser iguais', + }); + } + }) + // Resolve o alias: daqui para dentro só existe `document`. O `?? ''` é inalcançável (o + // superRefine acima exige um dos dois) e, se um dia não for, o domínio o recusa com 422. + .transform(({ cnpj, document, ...rest }) => ({ ...rest, document: document ?? cnpj ?? '' })); export type CreateSupplierBody = z.infer; diff --git a/src/modules/partners/adapters/persistence/mappers/supplier-outbox.mapper.ts b/src/modules/partners/adapters/persistence/mappers/supplier-outbox.mapper.ts index 91c20afc9..1183bbb17 100644 --- a/src/modules/partners/adapters/persistence/mappers/supplier-outbox.mapper.ts +++ b/src/modules/partners/adapters/persistence/mappers/supplier-outbox.mapper.ts @@ -13,6 +13,7 @@ import { newUuid } from '#src/shared/utils/id.ts'; import type { SupplierEvent } from '#src/modules/partners/domain/supplier/events.ts'; +import { documentOf } from '#src/modules/partners/domain/supplier/supplier.ts'; import type { Supplier } from '#src/modules/partners/domain/supplier/types.ts'; import type { OutboxMessage } from '#src/modules/partners/application/ports/outbox.ts'; @@ -43,7 +44,8 @@ const buildPayload = ( ): SupplierIntegrationPayload => ({ supplierRef: String(supplier.id), name: supplier.name, - document: String(supplier.cnpj), + // CPF ou CNPJ (#1022). O campo já se chamava `document` (ADR-0043): o consumidor não muda. + document: documentOf(supplier), occurredAt: event.occurredAt.toISOString(), }); diff --git a/src/modules/partners/adapters/persistence/mappers/supplier.mapper.ts b/src/modules/partners/adapters/persistence/mappers/supplier.mapper.ts index 66e97e327..361f0b93e 100644 --- a/src/modules/partners/adapters/persistence/mappers/supplier.mapper.ts +++ b/src/modules/partners/adapters/persistence/mappers/supplier.mapper.ts @@ -2,31 +2,48 @@ // // - supplierToInsert(supplier, now): NewSupplierRow — payment target achatado em // colunas (bank_account_*/pix_*); active/deactivated_at derivados; `now` injetado. -// - supplierFromRow(row): Result — reidrata id/cnpj/ -// serviceCategory e reconstrói os VOs de payment target na borda, delega a -// Supplier.rehydrate (reaplica invariantes). +// - supplierFromRow(row): Result — reidrata id/documento +// (CPF ou CNPJ)/serviceCategory e reconstrói os VOs de payment target na borda, delega a +// Supplier.rehydrate (reaplica invariantes, inclusive a identidade PF × PJ). +// +// Identidade (#1022): o agregado não tem razão social/nome fantasia na PF — o campo não existe. +// Na coluna, essa ausência é NULL; o `switch` abaixo é o único ponto que traduz um no outro. // // ADR-0020: sem JSON (payment target achatado em colunas). ADR-0014: só par_*. Zero throw na borda. import { type Result, ok, err } from '#src/shared/primitives/result.ts'; -import * as Cnpj from '#src/shared/kernel/cnpj.ts'; +import * as SupplierDocument from '#src/modules/partners/domain/supplier/supplier-document.ts'; import * as SupplierId from '#src/modules/partners/domain/supplier/supplier-id.ts'; import * as ServiceCategory from '#src/modules/partners/domain/supplier/service-category.ts'; import * as ServiceRating from '#src/modules/partners/domain/supplier/service-rating.ts'; import * as PaymentTarget from '#src/modules/partners/domain/shared/payment-target.ts'; import * as Supplier from '#src/modules/partners/domain/supplier/supplier.ts'; import type { BankAccount, PixKey } from '#src/modules/partners/domain/shared/payment-target.ts'; -import type { Supplier as SupplierEntity } from '#src/modules/partners/domain/supplier/types.ts'; +import type { + Supplier as SupplierEntity, + SupplierIdentity, +} from '#src/modules/partners/domain/supplier/types.ts'; import type { SupplierRow, NewSupplierRow } from '../schemas/mysql.ts'; export type SupplierMapperError = | 'supplier-mapper-invalid-id' - | 'supplier-mapper-invalid-cnpj' + | 'supplier-mapper-invalid-document' | 'supplier-mapper-invalid-service-category' | 'supplier-mapper-invalid-service-rating' | 'supplier-mapper-invalid-payment-target' | 'supplier-mapper-invalid-state'; +const identityColumns = ( + identity: SupplierIdentity, +): Pick => { + const names = Supplier.companyNamesOf(identity); + return { + document: SupplierDocument.toRaw(identity.document), + corporateName: names?.corporateName ?? null, + fantasyName: names?.fantasyName ?? null, + }; +}; + export const supplierToInsert = (supplier: SupplierEntity, now: Date): NewSupplierRow => { const bank = supplier.bankAccount; const pix = supplier.pixKey; @@ -34,9 +51,7 @@ export const supplierToInsert = (supplier: SupplierEntity, now: Date): NewSuppli id: supplier.id as unknown as string, name: supplier.name, email: supplier.email, - cnpj: supplier.cnpj as unknown as string, - corporateName: supplier.corporateName, - fantasyName: supplier.fantasyName, + ...identityColumns(supplier.identity), serviceCategory: supplier.serviceCategory, active: supplier.status === 'Active', deactivatedAt: supplier.status === 'Inactive' ? supplier.deactivatedAt : null, @@ -81,8 +96,8 @@ export const supplierFromRow = ( const id = SupplierId.rehydrate(row.id); if (!id.ok) return err('supplier-mapper-invalid-id'); - const cnpj = Cnpj.parse(row.cnpj); - if (!cnpj.ok) return err('supplier-mapper-invalid-cnpj'); + const document = SupplierDocument.parse(row.document); + if (!document.ok) return err('supplier-mapper-invalid-document'); const category = ServiceCategory.parse(row.serviceCategory); if (!category.ok) return err('supplier-mapper-invalid-service-category'); @@ -104,7 +119,7 @@ export const supplierFromRow = ( id: id.value, name: row.name, email: row.email, - cnpj: cnpj.value, + document: document.value, corporateName: row.corporateName, fantasyName: row.fantasyName, serviceCategory: category.value, diff --git a/src/modules/partners/adapters/persistence/migrations/mysql/0020_mixed_randall.sql b/src/modules/partners/adapters/persistence/migrations/mysql/0020_mixed_randall.sql new file mode 100644 index 000000000..a2b48b342 --- /dev/null +++ b/src/modules/partners/adapters/persistence/migrations/mysql/0020_mixed_randall.sql @@ -0,0 +1,17 @@ +-- Fornecedor pessoa física (#1022): `cnpj` vira `document` (CPF ou CNPJ) e a razão social e o nome +-- fantasia passam a aceitar NULL — que só a PF usa, amarrado ao documento pelos dois CHECKs. +-- +-- Gerada por `pnpm run db:generate:partners`, respondendo "rename" ao prompt do drizzle-kit +-- (`cnpj › document`); só este cabeçalho foi escrito à mão. +-- +-- Sem backfill: toda linha existente tem CNPJ de 14 caracteres e razão social/nome fantasia +-- NOT NULL, então os dois CHECKs valem para ela desde já. A janela entre o DROP INDEX e o novo +-- UNIQUE fica sem unicidade; é aceitável porque a migration roda sozinha, antes de o app subir, +-- e o ADD UNIQUE falharia em vez de deixar passar duplicata. +ALTER TABLE `par_suppliers` RENAME COLUMN `cnpj` TO `document`;--> statement-breakpoint +ALTER TABLE `par_suppliers` DROP INDEX `par_suppliers_cnpj_idx`;--> statement-breakpoint +ALTER TABLE `par_suppliers` MODIFY COLUMN `corporate_name` varchar(255);--> statement-breakpoint +ALTER TABLE `par_suppliers` MODIFY COLUMN `fantasy_name` varchar(255);--> statement-breakpoint +ALTER TABLE `par_suppliers` ADD CONSTRAINT `par_suppliers_document_idx` UNIQUE(`document`);--> statement-breakpoint +ALTER TABLE `par_suppliers` ADD CONSTRAINT `par_suppliers_pf_corporate_name_chk` CHECK ((CHAR_LENGTH(`par_suppliers`.`document`) = 11) = (`par_suppliers`.`corporate_name` IS NULL));--> statement-breakpoint +ALTER TABLE `par_suppliers` ADD CONSTRAINT `par_suppliers_pf_fantasy_name_chk` CHECK ((CHAR_LENGTH(`par_suppliers`.`document`) = 11) = (`par_suppliers`.`fantasy_name` IS NULL)); \ No newline at end of file diff --git a/src/modules/partners/adapters/persistence/migrations/mysql/meta/0020_snapshot.json b/src/modules/partners/adapters/persistence/migrations/mysql/meta/0020_snapshot.json new file mode 100644 index 000000000..8b2f52fd2 --- /dev/null +++ b/src/modules/partners/adapters/persistence/migrations/mysql/meta/0020_snapshot.json @@ -0,0 +1,1722 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "611f37f0-a610-4ccb-bad4-58186291a93c", + "prevId": "242c7bad-eb3a-452a-84a3-4252af0047fc", + "tables": { + "par_acts": { + "name": "par_acts", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "act_number": { + "name": "act_number", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cnpj": { + "name": "cnpj", + "type": "varchar(14) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "corporate_name": { + "name": "corporate_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fantasy_name": { + "name": "fantasy_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occupation_area": { + "name": "occupation_area", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legal_representative": { + "name": "legal_representative", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validity_start": { + "name": "validity_start", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validity_end": { + "name": "validity_end", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_financial_transfer": { + "name": "has_financial_transfer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bank_account_bank": { + "name": "bank_account_bank", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_agency": { + "name": "bank_account_agency", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_number": { + "name": "bank_account_number", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_check_digit": { + "name": "bank_account_check_digit", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pix_key_type": { + "name": "pix_key_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pix_key": { + "name": "pix_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "deactivated_at": { + "name": "deactivated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_id": { + "name": "legacy_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "par_acts_act_number_idx": { + "name": "par_acts_act_number_idx", + "columns": ["act_number"], + "isUnique": true + }, + "par_acts_legacy_id_idx": { + "name": "par_acts_legacy_id_idx", + "columns": ["legacy_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "par_acts_id": { + "name": "par_acts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "par_acts_active_consistency_chk": { + "name": "par_acts_active_consistency_chk", + "value": "(`par_acts`.`active` = FALSE) = (`par_acts`.`deactivated_at` IS NOT NULL)" + }, + "par_acts_payment_target_chk": { + "name": "par_acts_payment_target_chk", + "value": "(`par_acts`.`has_financial_transfer` = FALSE) OR (`par_acts`.`bank_account_bank` IS NOT NULL) OR (`par_acts`.`pix_key` IS NOT NULL)" + }, + "par_acts_bank_block_chk": { + "name": "par_acts_bank_block_chk", + "value": "(`par_acts`.`bank_account_bank` IS NULL) = (`par_acts`.`bank_account_agency` IS NULL)\n AND (`par_acts`.`bank_account_bank` IS NULL) = (`par_acts`.`bank_account_number` IS NULL)\n AND (`par_acts`.`bank_account_bank` IS NULL) = (`par_acts`.`bank_account_check_digit` IS NULL)" + }, + "par_acts_pix_block_chk": { + "name": "par_acts_pix_block_chk", + "value": "(`par_acts`.`pix_key_type` IS NULL) = (`par_acts`.`pix_key` IS NULL)" + } + } + }, + "par_collaborator_history": { + "name": "par_collaborator_history", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collaborator_id": { + "name": "collaborator_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "field_name": { + "name": "field_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "field_label": { + "name": "field_label", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_before": { + "name": "value_before", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_after": { + "name": "value_after", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "par_collaborator_history_collab_date_idx": { + "name": "par_collaborator_history_collab_date_idx", + "columns": ["collaborator_id", "occurred_at"], + "isUnique": false + }, + "par_collaborator_history_idem_idx": { + "name": "par_collaborator_history_idem_idx", + "columns": ["collaborator_id", "occurred_at", "field_name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "par_collaborator_history_id": { + "name": "par_collaborator_history_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "par_collaborators": { + "name": "par_collaborators", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cpf": { + "name": "cpf", + "type": "varchar(11) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occupation_area": { + "name": "occupation_area", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_of_contract": { + "name": "start_of_contract", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "employment_relationship": { + "name": "employment_relationship", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registration_status": { + "name": "registration_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rg": { + "name": "rg", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gender_identity": { + "name": "gender_identity", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "race": { + "name": "race", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "education": { + "name": "education", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "food_category": { + "name": "food_category", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "food_category_description": { + "name": "food_category_description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "complete_address": { + "name": "complete_address", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telephone": { + "name": "telephone", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emergency_contact_name": { + "name": "emergency_contact_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emergency_contact_telephone": { + "name": "emergency_contact_telephone", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allergies": { + "name": "allergies", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "biography": { + "name": "biography", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "experience_in_the_public_sector": { + "name": "experience_in_the_public_sector", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sex": { + "name": "sex", + "type": "varchar(1)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "marital_status": { + "name": "marital_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "has_children": { + "name": "has_children", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "children_count": { + "name": "children_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "children_ages": { + "name": "children_ages", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_pwd": { + "name": "is_pwd", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pwd_description": { + "name": "pwd_description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_on_leave": { + "name": "is_on_leave", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "leave_duration": { + "name": "leave_duration", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "leave_renewable": { + "name": "leave_renewable", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "leave_renewal_duration": { + "name": "leave_renewal_duration", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_sector_experience_duration": { + "name": "public_sector_experience_duration", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "territory_uf": { + "name": "territory_uf", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "territory_municipality": { + "name": "territory_municipality", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_bank": { + "name": "bank_account_bank", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_agency": { + "name": "bank_account_agency", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_number": { + "name": "bank_account_number", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_check_digit": { + "name": "bank_account_check_digit", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pix_key_type": { + "name": "pix_key_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pix_key": { + "name": "pix_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "disable_by": { + "name": "disable_by", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deactivated_at": { + "name": "deactivated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_id": { + "name": "legacy_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "par_collaborators_cpf_idx": { + "name": "par_collaborators_cpf_idx", + "columns": ["cpf"], + "isUnique": true + }, + "par_collaborators_email_idx": { + "name": "par_collaborators_email_idx", + "columns": ["email"], + "isUnique": true + }, + "par_collaborators_legacy_id_idx": { + "name": "par_collaborators_legacy_id_idx", + "columns": ["legacy_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "par_collaborators_id": { + "name": "par_collaborators_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "par_collaborators_soft_delete_chk": { + "name": "par_collaborators_soft_delete_chk", + "value": "((`par_collaborators`.`active` = FALSE) = (`par_collaborators`.`deactivated_at` IS NOT NULL))\n AND ((`par_collaborators`.`active` = FALSE) = (`par_collaborators`.`disable_by` IS NOT NULL))" + }, + "par_collaborators_bank_block_chk": { + "name": "par_collaborators_bank_block_chk", + "value": "(`par_collaborators`.`bank_account_bank` IS NULL) = (`par_collaborators`.`bank_account_agency` IS NULL)\n AND (`par_collaborators`.`bank_account_bank` IS NULL) = (`par_collaborators`.`bank_account_number` IS NULL)\n AND (`par_collaborators`.`bank_account_bank` IS NULL) = (`par_collaborators`.`bank_account_check_digit` IS NULL)" + }, + "par_collaborators_pix_block_chk": { + "name": "par_collaborators_pix_block_chk", + "value": "(`par_collaborators`.`pix_key_type` IS NULL) = (`par_collaborators`.`pix_key` IS NULL)" + } + } + }, + "par_contract_count_processed": { + "name": "par_contract_count_processed", + "columns": { + "event_id": { + "name": "event_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "par_contract_count_processed_event_id": { + "name": "par_contract_count_processed_event_id", + "columns": ["event_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "par_contract_count_view": { + "name": "par_contract_count_view", + "columns": { + "contractor_ref": { + "name": "contractor_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_count": { + "name": "active_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "par_contract_count_view_contractor_ref": { + "name": "par_contract_count_view_contractor_ref", + "columns": ["contractor_ref"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "par_email_outbox": { + "name": "par_email_outbox", + "columns": { + "event_id": { + "name": "event_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_id": { + "name": "aggregate_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_type": { + "name": "aggregate_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "payload": { + "name": "payload", + "type": "varchar(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "par_email_outbox_processed_at_occurred_at_idx": { + "name": "par_email_outbox_processed_at_occurred_at_idx", + "columns": ["processed_at", "occurred_at"], + "isUnique": false + }, + "par_email_outbox_aggregate_id_idx": { + "name": "par_email_outbox_aggregate_id_idx", + "columns": ["aggregate_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "par_email_outbox_event_id": { + "name": "par_email_outbox_event_id", + "columns": ["event_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "par_email_outbox_attempts_nonneg_chk": { + "name": "par_email_outbox_attempts_nonneg_chk", + "value": "`par_email_outbox`.`attempts` >= 0" + }, + "par_email_outbox_event_type_nonempty_chk": { + "name": "par_email_outbox_event_type_nonempty_chk", + "value": "CHAR_LENGTH(`par_email_outbox`.`event_type`) > 0" + }, + "par_email_outbox_aggregate_type_chk": { + "name": "par_email_outbox_aggregate_type_chk", + "value": "`par_email_outbox`.`aggregate_type` IN ('Collaborator')" + } + } + }, + "par_financiers": { + "name": "par_financiers", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "corporate_name": { + "name": "corporate_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legal_representative": { + "name": "legal_representative", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cnpj": { + "name": "cnpj", + "type": "varchar(14) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "telephone": { + "name": "telephone", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bank_account_bank": { + "name": "bank_account_bank", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_agency": { + "name": "bank_account_agency", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_number": { + "name": "bank_account_number", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_check_digit": { + "name": "bank_account_check_digit", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pix_key_type": { + "name": "pix_key_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pix_key": { + "name": "pix_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "deactivated_at": { + "name": "deactivated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_id": { + "name": "legacy_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "par_financiers_cnpj_idx": { + "name": "par_financiers_cnpj_idx", + "columns": ["cnpj"], + "isUnique": true + }, + "par_financiers_legacy_id_idx": { + "name": "par_financiers_legacy_id_idx", + "columns": ["legacy_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "par_financiers_id": { + "name": "par_financiers_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "par_financiers_active_consistency_chk": { + "name": "par_financiers_active_consistency_chk", + "value": "(`par_financiers`.`active` = FALSE) = (`par_financiers`.`deactivated_at` IS NOT NULL)" + }, + "par_financiers_bank_block_chk": { + "name": "par_financiers_bank_block_chk", + "value": "(`par_financiers`.`bank_account_bank` IS NULL) = (`par_financiers`.`bank_account_agency` IS NULL)\n AND (`par_financiers`.`bank_account_bank` IS NULL) = (`par_financiers`.`bank_account_number` IS NULL)\n AND (`par_financiers`.`bank_account_bank` IS NULL) = (`par_financiers`.`bank_account_check_digit` IS NULL)" + }, + "par_financiers_pix_block_chk": { + "name": "par_financiers_pix_block_chk", + "value": "(`par_financiers`.`pix_key_type` IS NULL) = (`par_financiers`.`pix_key` IS NULL)" + } + } + }, + "par_invite_tokens": { + "name": "par_invite_tokens", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collaborator_id": { + "name": "collaborator_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issued_at": { + "name": "issued_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "par_invite_tokens_token_hash_idx": { + "name": "par_invite_tokens_token_hash_idx", + "columns": ["token_hash"], + "isUnique": true + }, + "par_invite_tokens_collaborator_idx": { + "name": "par_invite_tokens_collaborator_idx", + "columns": ["collaborator_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "par_invite_tokens_id": { + "name": "par_invite_tokens_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "par_municipalities": { + "name": "par_municipalities", + "columns": { + "ibge_code": { + "name": "ibge_code", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uf": { + "name": "uf", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "deactivated_at": { + "name": "deactivated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "par_municipalities_ibge_code": { + "name": "par_municipalities_ibge_code", + "columns": ["ibge_code"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "par_municipalities_active_consistency_chk": { + "name": "par_municipalities_active_consistency_chk", + "value": "(`par_municipalities`.`active` = FALSE) = (`par_municipalities`.`deactivated_at` IS NOT NULL)" + } + } + }, + "par_outbox": { + "name": "par_outbox", + "columns": { + "event_id": { + "name": "event_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_id": { + "name": "aggregate_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_type": { + "name": "aggregate_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "payload": { + "name": "payload", + "type": "varchar(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "par_outbox_processed_at_occurred_at_idx": { + "name": "par_outbox_processed_at_occurred_at_idx", + "columns": ["processed_at", "occurred_at"], + "isUnique": false + }, + "par_outbox_aggregate_id_idx": { + "name": "par_outbox_aggregate_id_idx", + "columns": ["aggregate_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "par_outbox_event_id": { + "name": "par_outbox_event_id", + "columns": ["event_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "par_outbox_attempts_nonneg_chk": { + "name": "par_outbox_attempts_nonneg_chk", + "value": "`par_outbox`.`attempts` >= 0" + }, + "par_outbox_event_type_nonempty_chk": { + "name": "par_outbox_event_type_nonempty_chk", + "value": "CHAR_LENGTH(`par_outbox`.`event_type`) > 0" + }, + "par_outbox_aggregate_type_chk": { + "name": "par_outbox_aggregate_type_chk", + "value": "`par_outbox`.`aggregate_type` IN ('Supplier')" + } + } + }, + "par_outbox_dead_letter": { + "name": "par_outbox_dead_letter", + "columns": { + "consumer_id": { + "name": "consumer_id", + "type": "varchar(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_id": { + "name": "aggregate_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_type": { + "name": "aggregate_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "failed_at": { + "name": "failed_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "varchar(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "par_outbox_dlq_failed_at_idx": { + "name": "par_outbox_dlq_failed_at_idx", + "columns": ["failed_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "par_outbox_dead_letter_consumer_id_event_id_pk": { + "name": "par_outbox_dead_letter_consumer_id_event_id_pk", + "columns": ["consumer_id", "event_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "par_outbox_dlq_aggregate_type_chk": { + "name": "par_outbox_dlq_aggregate_type_chk", + "value": "`par_outbox_dead_letter`.`aggregate_type` IN ('Supplier')" + } + } + }, + "par_states": { + "name": "par_states", + "columns": { + "uf": { + "name": "uf", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "deactivated_at": { + "name": "deactivated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "par_states_uf": { + "name": "par_states_uf", + "columns": ["uf"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "par_states_active_consistency_chk": { + "name": "par_states_active_consistency_chk", + "value": "(`par_states`.`active` = FALSE) = (`par_states`.`deactivated_at` IS NOT NULL)" + } + } + }, + "par_suppliers": { + "name": "par_suppliers", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "varchar(14) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "corporate_name": { + "name": "corporate_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fantasy_name": { + "name": "fantasy_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service_category": { + "name": "service_category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "deactivated_at": { + "name": "deactivated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_bank": { + "name": "bank_account_bank", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_agency": { + "name": "bank_account_agency", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_number": { + "name": "bank_account_number", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_check_digit": { + "name": "bank_account_check_digit", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pix_key_type": { + "name": "pix_key_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pix_key": { + "name": "pix_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service_rating": { + "name": "service_rating", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating_comment": { + "name": "rating_comment", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_id": { + "name": "legacy_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "par_suppliers_document_idx": { + "name": "par_suppliers_document_idx", + "columns": ["document"], + "isUnique": true + }, + "par_suppliers_legacy_id_idx": { + "name": "par_suppliers_legacy_id_idx", + "columns": ["legacy_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "par_suppliers_id": { + "name": "par_suppliers_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "par_suppliers_active_consistency_chk": { + "name": "par_suppliers_active_consistency_chk", + "value": "(`par_suppliers`.`active` = FALSE) = (`par_suppliers`.`deactivated_at` IS NOT NULL)" + }, + "par_suppliers_payment_target_chk": { + "name": "par_suppliers_payment_target_chk", + "value": "(`par_suppliers`.`bank_account_bank` IS NOT NULL) OR (`par_suppliers`.`pix_key` IS NOT NULL)" + }, + "par_suppliers_bank_block_chk": { + "name": "par_suppliers_bank_block_chk", + "value": "(`par_suppliers`.`bank_account_bank` IS NULL) = (`par_suppliers`.`bank_account_agency` IS NULL)\n AND (`par_suppliers`.`bank_account_bank` IS NULL) = (`par_suppliers`.`bank_account_number` IS NULL)\n AND (`par_suppliers`.`bank_account_bank` IS NULL) = (`par_suppliers`.`bank_account_check_digit` IS NULL)" + }, + "par_suppliers_pix_block_chk": { + "name": "par_suppliers_pix_block_chk", + "value": "(`par_suppliers`.`pix_key_type` IS NULL) = (`par_suppliers`.`pix_key` IS NULL)" + }, + "par_suppliers_service_rating_chk": { + "name": "par_suppliers_service_rating_chk", + "value": "`par_suppliers`.`service_rating` IS NULL OR `par_suppliers`.`service_rating` IN ('RUIM','REGULAR','BOM','OTIMO')" + }, + "par_suppliers_pf_corporate_name_chk": { + "name": "par_suppliers_pf_corporate_name_chk", + "value": "(CHAR_LENGTH(`par_suppliers`.`document`) = 11) = (`par_suppliers`.`corporate_name` IS NULL)" + }, + "par_suppliers_pf_fantasy_name_chk": { + "name": "par_suppliers_pf_fantasy_name_chk", + "value": "(CHAR_LENGTH(`par_suppliers`.`document`) = 11) = (`par_suppliers`.`fantasy_name` IS NULL)" + } + } + }, + "par_user_profiles": { + "name": "par_user_profiles", + "columns": { + "user_ref": { + "name": "user_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cpf": { + "name": "cpf", + "type": "varchar(11) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "telephone": { + "name": "telephone", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "collaborator_ref": { + "name": "collaborator_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_id": { + "name": "legacy_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "par_user_profiles_cpf_idx": { + "name": "par_user_profiles_cpf_idx", + "columns": ["cpf"], + "isUnique": true + }, + "par_user_profiles_legacy_id_idx": { + "name": "par_user_profiles_legacy_id_idx", + "columns": ["legacy_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "par_user_profiles_user_ref": { + "name": "par_user_profiles_user_ref", + "columns": ["user_ref"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": { + "\"par_suppliers\".\"cnpj\"": "\"par_suppliers\".\"document\"" + } + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/src/modules/partners/adapters/persistence/migrations/mysql/meta/_journal.json b/src/modules/partners/adapters/persistence/migrations/mysql/meta/_journal.json index 19929ffd5..db726b2a6 100644 --- a/src/modules/partners/adapters/persistence/migrations/mysql/meta/_journal.json +++ b/src/modules/partners/adapters/persistence/migrations/mysql/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1787363790665, "tag": "0019_sturdy_inhumans", "breakpoints": true + }, + { + "idx": 20, + "version": "5", + "when": 1790873687663, + "tag": "0020_mixed_randall", + "breakpoints": true } ] } diff --git a/src/modules/partners/adapters/persistence/repos/contractor-read.drizzle.ts b/src/modules/partners/adapters/persistence/repos/contractor-read.drizzle.ts index e374ac1ec..9f1105bd4 100644 --- a/src/modules/partners/adapters/persistence/repos/contractor-read.drizzle.ts +++ b/src/modules/partners/adapters/persistence/repos/contractor-read.drizzle.ts @@ -12,7 +12,7 @@ import { eq } from 'drizzle-orm'; import process from 'node:process'; import { type Result, ok, err } from '#src/shared/primitives/result.ts'; -import * as Cnpj from '#src/shared/kernel/cnpj.ts'; +import * as SupplierDocument from '#src/modules/partners/domain/supplier/supplier-document.ts'; import type { ContractorReadPort, ContractorReadError, @@ -128,22 +128,22 @@ export const createDrizzleContractorReadStore = ( } }; - // #FIN-OCR-AUTOFILL-SUPPLIER: normaliza o CNPJ (VO) e resolve pelo índice único `par_suppliers_cnpj_idx`. - // CNPJ inválido → ok(null) (não é erro de infra). Sem match → ok(null). - const findSupplierIdByCnpj = async ( - cnpj: string, + // #FIN-OCR-AUTOFILL-SUPPLIER: normaliza o documento (CPF ou CNPJ — #1022) e resolve pelo índice + // único `par_suppliers_document_idx`. Documento inválido → ok(null) (não é erro de infra). Sem match → ok(null). + const findSupplierIdByDocument = async ( + document: string, ): Promise> => { - const parsed = Cnpj.parse(cnpj); + const parsed = SupplierDocument.parse(document); if (!parsed.ok) return ok(null); try { const rows = await db .select({ id: schema.parSuppliers.id }) .from(schema.parSuppliers) - .where(eq(schema.parSuppliers.cnpj, parsed.value as unknown as string)) + .where(eq(schema.parSuppliers.document, SupplierDocument.toRaw(parsed.value))) .limit(1); return ok(rows[0]?.id ?? null); } catch (cause) { - logRead('findSupplierIdByCnpj', cause); + logRead('findSupplierIdByDocument', cause); return err('contractor-read-unavailable'); } }; @@ -153,6 +153,6 @@ export const createDrizzleContractorReadStore = ( getFinancierView, getCollaboratorView, getActView, - findSupplierIdByCnpj, + findSupplierIdByDocument, }; }; diff --git a/src/modules/partners/adapters/persistence/repos/supplier-repository.drizzle.ts b/src/modules/partners/adapters/persistence/repos/supplier-repository.drizzle.ts index f1913b493..ef18d4ba4 100644 --- a/src/modules/partners/adapters/persistence/repos/supplier-repository.drizzle.ts +++ b/src/modules/partners/adapters/persistence/repos/supplier-repository.drizzle.ts @@ -1,10 +1,10 @@ // Adapter Drizzle de SupplierRepository (módulo partners). // -// - findById/findByCnpj/list: SELECT + mapper. +// - findById/findByDocument/list: SELECT + mapper. // - save: UMA transação — SELECT-then-UPDATE-or-INSERT do supplier (ADR-0020 — sem ON // DUPLICATE KEY) + appendOutboxInTx dos eventos publicáveis, na MESMA tx (atomicidade -// estado+outbox — ADR-0015/0043). UNIQUE `par_suppliers_cnpj_idx` → ER_DUP_ENTRY (1062) -// → rollback total → supplier-cnpj-duplicate. +// estado+outbox — ADR-0015/0043). UNIQUE `par_suppliers_document_idx` → ER_DUP_ENTRY (1062) +// → rollback total → supplier-document-duplicate. // // ADR-0020: sem ODKU. ADR-0014: só par_*. Boundary: try/catch → Result (zero throw). @@ -13,7 +13,7 @@ import process from 'node:process'; import { type Result, ok, err } from '#src/shared/primitives/result.ts'; import type { Clock } from '#src/shared/ports/clock.ts'; -import type { Cnpj } from '#src/shared/kernel/cnpj.ts'; +import * as SupplierDocument from '#src/modules/partners/domain/supplier/supplier-document.ts'; import type { SupplierRepository, SupplierRepositoryError, @@ -31,7 +31,7 @@ import { supplierEventsToOutboxMessages } from '../mappers/supplier-outbox.mappe import { appendOutboxInTx } from './outbox-repository.drizzle.ts'; import type { SupplierRow } from '../schemas/mysql.ts'; -const isCnpjDupEntry = (e: unknown): boolean => { +const isDocumentDupEntry = (e: unknown): boolean => { const candidates: unknown[] = [e]; if (e instanceof Error && e.cause !== undefined) candidates.push(e.cause); for (const c of candidates) { @@ -40,7 +40,7 @@ const isCnpjDupEntry = (e: unknown): boolean => { if ( obj['errno'] === 1062 && typeof obj['sqlMessage'] === 'string' && - obj['sqlMessage'].includes('par_suppliers_cnpj_idx') + obj['sqlMessage'].includes('par_suppliers_document_idx') ) { return true; } @@ -84,17 +84,17 @@ export const createDrizzleSupplierStore = ( } }, - findByCnpj: async (cnpj: Cnpj) => { + findByDocument: async (document: SupplierDocument.SupplierDocument) => { try { const rows = await db .select() .from(table) - .where(eq(table.cnpj, cnpj as unknown as string)) + .where(eq(table.document, SupplierDocument.toRaw(document))) .limit(1); const row = rows[0]; return row === undefined ? ok(null) : reconstruct(row); } catch (cause) { - logRepo('findByCnpj', cause); + logRepo('findByDocument', cause); return err('supplier-repo-unavailable'); } }, @@ -122,7 +122,7 @@ export const createDrizzleSupplierStore = ( const messages = supplierEventsToOutboxMessages(events, supplier); try { // UMA transação: persist do supplier + append do outbox. Se qualquer passo - // lançar (ex.: ER_DUP_ENTRY no CNPJ), o Drizzle faz rollback de tudo — + // lançar (ex.: ER_DUP_ENTRY no documento), o Drizzle faz rollback de tudo — // estado e outbox somem juntos (atomicidade — ADR-0015/0043). await db.transaction(async (tx) => { const existing = await tx @@ -145,7 +145,7 @@ export const createDrizzleSupplierStore = ( }); return ok(undefined); } catch (cause) { - if (isCnpjDupEntry(cause)) return err('supplier-cnpj-duplicate'); + if (isDocumentDupEntry(cause)) return err('supplier-document-duplicate'); logRepo('save', cause); return err('supplier-repo-unavailable'); } diff --git a/src/modules/partners/adapters/persistence/repos/supplier-repository.in-memory.ts b/src/modules/partners/adapters/persistence/repos/supplier-repository.in-memory.ts index 64d9607f5..f3ac41609 100644 --- a/src/modules/partners/adapters/persistence/repos/supplier-repository.in-memory.ts +++ b/src/modules/partners/adapters/persistence/repos/supplier-repository.in-memory.ts @@ -1,9 +1,9 @@ /** * Adapter InMemory do `SupplierRepository` (módulo partners). Para teste/CLI. * - * `Map`. `save` recusa CNPJ duplicado com id diferente - * (espelha o UNIQUE de `par_suppliers.cnpj` que o adapter Drizzle terá). - * `findByCnpj` por varredura (cardinalidade modesta — ADR-0031). + * `Map`. `save` recusa documento (CPF/CNPJ) duplicado com id diferente + * (espelha o UNIQUE de `par_suppliers.document` do adapter Drizzle). + * `findByDocument` por varredura (cardinalidade modesta — ADR-0031). * * Outbox (PAR-SUPPLIER-EVENTS / ADR-0043): `save(supplier, events)` publica os * eventos publicáveis (Registered/Edited) num `InMemoryOutbox` opcionalmente @@ -21,6 +21,7 @@ import { ok, err } from '#src/shared/primitives/result.ts'; import type { SupplierRepository } from '#src/modules/partners/domain/supplier/repository.ts'; +import * as SupplierDocument from '#src/modules/partners/domain/supplier/supplier-document.ts'; import type { SupplierId } from '#src/modules/partners/domain/supplier/supplier-id.ts'; import type { Supplier } from '#src/modules/partners/domain/supplier/types.ts'; import type { InMemoryOutbox } from '#src/modules/partners/adapters/outbox/outbox.in-memory.ts'; @@ -34,17 +35,22 @@ export type InMemorySupplierStore = Readonly<{ /** Outbox in-memory injetado (para inspeção em testes). `append` é a única operação usada. */ type InjectableOutbox = Readonly<{ port: ReturnType['port'] }>; +// Igualdade pela chave do UNIQUE (`toRaw`), como no MySQL: CPF e CNPJ nunca colidem. +const sameDocument = (s: Supplier, document: SupplierDocument.SupplierDocument): boolean => + SupplierDocument.toRaw(s.identity.document) === SupplierDocument.toRaw(document); + export const makeInMemorySupplierStore = (outbox?: InjectableOutbox): InMemorySupplierStore => { const map = new Map(); const repository: SupplierRepository = { findById: async (id) => ok(map.get(id) ?? null), - findByCnpj: async (cnpj) => ok([...map.values()].find((s) => s.cnpj === cnpj) ?? null), + findByDocument: async (document) => + ok([...map.values()].find((s) => sameDocument(s, document)) ?? null), list: async () => ok([...map.values()]), save: async (supplier, events) => { for (const existing of map.values()) { - if (existing.cnpj === supplier.cnpj && existing.id !== supplier.id) { - return err('supplier-cnpj-duplicate'); + if (sameDocument(existing, supplier.identity.document) && existing.id !== supplier.id) { + return err('supplier-document-duplicate'); } } map.set(supplier.id, supplier); diff --git a/src/modules/partners/adapters/persistence/repos/suppliers-batch-reader.drizzle.ts b/src/modules/partners/adapters/persistence/repos/suppliers-batch-reader.drizzle.ts index 84d812f9b..09d933e72 100644 --- a/src/modules/partners/adapters/persistence/repos/suppliers-batch-reader.drizzle.ts +++ b/src/modules/partners/adapters/persistence/repos/suppliers-batch-reader.drizzle.ts @@ -1,7 +1,7 @@ // Adapter Drizzle do SuppliersBatchReadPort (#356) — resolução em lote por ref. // // - 1 query com `inArray` (anti-N+1, CA7 validado no W3 contra MySQL real) — seleciona -// só as colunas da identidade mínima (id/name/cnpj/service_category); bancário/PIX +// só as colunas da identidade mínima (id/name/document/service_category); bancário/PIX // nem chegam a ser lidos (minimização em profundidade, CA5). // - refs sem row correspondente → `missing`. Categoria corrompida (dado legado // inválido) → trata como infra: `err('suppliers-batch-read-unavailable')`. @@ -39,7 +39,7 @@ export const createDrizzleSuppliersBatchReader = ( .select({ id: schema.parSuppliers.id, name: schema.parSuppliers.name, - cnpj: schema.parSuppliers.cnpj, + document: schema.parSuppliers.document, serviceCategory: schema.parSuppliers.serviceCategory, }) .from(schema.parSuppliers) @@ -56,7 +56,7 @@ export const createDrizzleSuppliersBatchReader = ( items.push({ ref: row.id, name: row.name, - taxId: row.cnpj, + taxId: row.document, serviceCategory: category.value, }); found.add(row.id); diff --git a/src/modules/partners/adapters/persistence/repos/suppliers-batch-reader.in-memory.ts b/src/modules/partners/adapters/persistence/repos/suppliers-batch-reader.in-memory.ts index 5fe741e4b..8c3cec6fd 100644 --- a/src/modules/partners/adapters/persistence/repos/suppliers-batch-reader.in-memory.ts +++ b/src/modules/partners/adapters/persistence/repos/suppliers-batch-reader.in-memory.ts @@ -5,6 +5,7 @@ */ import { ok } from '#src/shared/primitives/result.ts'; +import { documentOf } from '#src/modules/partners/domain/supplier/supplier.ts'; import type { SuppliersBatchReadPort, SupplierBatchView, @@ -29,7 +30,7 @@ export const makeInMemorySuppliersBatchReader = ( items.push({ ref, name: supplier.name, - taxId: String(supplier.cnpj), + taxId: documentOf(supplier), serviceCategory: supplier.serviceCategory, }); } diff --git a/src/modules/partners/adapters/persistence/schemas/mysql.ts b/src/modules/partners/adapters/persistence/schemas/mysql.ts index 45cad416b..c11069623 100644 --- a/src/modules/partners/adapters/persistence/schemas/mysql.ts +++ b/src/modules/partners/adapters/persistence/schemas/mysql.ts @@ -91,7 +91,7 @@ export type FinancierRow = typeof parFinanciers.$inferSelect; export type NewFinancierRow = typeof parFinanciers.$inferInsert; // ─── par_suppliers ────────────────────────────────────────────────────────── -// Fornecedor (legado `suppliers`, database.dbml:153-176). `cnpj` UNIQUE. Soft-delete +// Fornecedor (legado `suppliers`, database.dbml:153-176). `document` (CPF ou CNPJ) UNIQUE. Soft-delete // via `active` + `deactivated_at`. Destino de pagamento embedded (`bancaryInfo`/`pixInfo` // do legado) ACHATADO em colunas nullable — invariante "ao menos um destino" via CHECK. export const parSuppliers = mysqlTable( @@ -101,10 +101,14 @@ export const parSuppliers = mysqlTable( id: uuidKey('id').primaryKey().notNull(), name: varchar('name', { length: 255 }).notNull(), email: varchar('email', { length: 255 }).notNull(), - // 14 caracteres alfanuméricos, sem máscara (ADR-0044). UNIQUE; COLLATE utf8mb4_bin pelo tipo. - cnpj: cnpjKey('cnpj').notNull(), - corporateName: varchar('corporate_name', { length: 255 }).notNull(), - fantasyName: varchar('fantasy_name', { length: 255 }).notNull(), + // CPF (11 caracteres numéricos, PF) ou CNPJ (14 caracteres alfanuméricos, PJ — ADR-0044), sem máscara (#1022). + // `cnpjKey` é o varchar(14) COLLATE utf8mb4_bin: cabe os dois. O tipo de pessoa NÃO é coluna — + // deriva do tamanho, e uma coluna própria permitiria gravar PF com um CNPJ. UNIQUE: CPF e CNPJ + // nunca colidem, os tamanhos diferem. + document: cnpjKey('document').notNull(), + // Razão social e nome fantasia: só a PJ tem. NULL ⟺ PF, amarrado ao documento pelos CHECKs (e). + corporateName: varchar('corporate_name', { length: 255 }), + fantasyName: varchar('fantasy_name', { length: 255 }), // Literal legado (ex.: INFORMATICA). varchar, NÃO ENUM (ADR-0020). serviceCategory: varchar('service_category', { length: 50 }).notNull(), active: boolean('active').notNull().default(true), @@ -150,7 +154,18 @@ export const parSuppliers = mysqlTable( 'par_suppliers_service_rating_chk', sql`${t.serviceRating} IS NULL OR ${t.serviceRating} IN ('RUIM','REGULAR','BOM','OTIMO')`, ), - uniqueIndex('par_suppliers_cnpj_idx').on(t.cnpj), + // (e) identidade PF × PJ (#1022): PF (CPF, 11) ⟺ sem razão social e sem nome fantasia. + // NULL aqui é a AUSÊNCIA do campo, não valor desconhecido — sentinela ("-1", "") passaria + // pelo CHECK e chegaria à tela, ao CSV e à busca. + check( + 'par_suppliers_pf_corporate_name_chk', + sql`(CHAR_LENGTH(${t.document}) = 11) = (${t.corporateName} IS NULL)`, + ), + check( + 'par_suppliers_pf_fantasy_name_chk', + sql`(CHAR_LENGTH(${t.document}) = 11) = (${t.fantasyName} IS NULL)`, + ), + uniqueIndex('par_suppliers_document_idx').on(t.document), // UNIQUE(legacy_id) — idempotência da ETL (múltiplos NULL convivem no InnoDB). uniqueIndex('par_suppliers_legacy_id_idx').on(t.legacyId), ], diff --git a/src/modules/partners/application/ports/contractor-read.ts b/src/modules/partners/application/ports/contractor-read.ts index 9a80e14a6..653083f64 100644 --- a/src/modules/partners/application/ports/contractor-read.ts +++ b/src/modules/partners/application/ports/contractor-read.ts @@ -24,8 +24,11 @@ export type ContractorReadPort = Readonly<{ id: string, ) => Promise>; getActView: (id: string) => Promise>; - // #FIN-OCR-AUTOFILL-SUPPLIER: resolve um CNPJ (com ou sem máscara) → id do fornecedor CADASTRADO, ou - // null se o CNPJ é inválido ou não há fornecedor com ele (o financial usa p/ pré-selecionar no OCR). + // #FIN-OCR-AUTOFILL-SUPPLIER: resolve um documento — CPF ou CNPJ, com ou sem máscara (#1022) — → + // id do fornecedor CADASTRADO, ou null se o documento é inválido ou não há fornecedor com ele (o + // financial usa p/ pré-selecionar no OCR; o emitente de um RPA é pessoa física). // OPCIONAL (aditivo, precedente do AuthUserReadPort): o adapter real provê; doubles de teste podem omitir. - findSupplierIdByCnpj?: (cnpj: string) => Promise>; + findSupplierIdByDocument?: ( + document: string, + ) => Promise>; }>; diff --git a/src/modules/partners/application/use-cases/edit-supplier.ts b/src/modules/partners/application/use-cases/edit-supplier.ts index 91af54bc0..13389d20e 100644 --- a/src/modules/partners/application/use-cases/edit-supplier.ts +++ b/src/modules/partners/application/use-cases/edit-supplier.ts @@ -1,5 +1,6 @@ /** - * Use case `editSupplier` — edição cadastral (PUT total) com RBAC do campo vital (CNPJ). + * Use case `editSupplier` — edição cadastral (PUT total) com RBAC do campo vital (documento + * CPF/CNPJ — trocar PF ↔ PJ é trocar o documento, logo exige a mesma permissão). * Espelha `editFinancier`. Regra do vital no use case (usa o writer; evita inconsistência * reader/writer do driver memory). Payment-target é não-vital (editável via supplier:write). */ @@ -8,6 +9,7 @@ import { type Result, ok, err } from '#src/shared/index.ts'; import type { Clock } from '#src/shared/ports/clock.ts'; import * as SupplierId from '#src/modules/partners/domain/supplier/supplier-id.ts'; import * as Supplier from '#src/modules/partners/domain/supplier/supplier.ts'; +import * as SupplierDocument from '#src/modules/partners/domain/supplier/supplier-document.ts'; import type { Supplier as SupplierAggregate } from '#src/modules/partners/domain/supplier/types.ts'; import type { BankAccountInput, @@ -25,9 +27,9 @@ export type EditSupplierCommand = Readonly<{ canEditSensitive: boolean; name: string; email: string; - cnpj: string; - corporateName: string; - fantasyName: string; + document: string; + corporateName: string | null; + fantasyName: string | null; serviceCategory: string; bankAccount: BankAccountInput | null; pixKey: PixKeyInput | null; @@ -38,7 +40,7 @@ export type EditSupplierCommand = Readonly<{ export type EditSupplierError = | 'edit-supplier-invalid-id' | 'edit-supplier-not-found' - | 'edit-supplier-cnpj-duplicate' + | 'edit-supplier-document-duplicate' | 'edit-supplier-sensitive-forbidden' | SupplierError | SupplierRepositoryError; @@ -66,7 +68,7 @@ export const editSupplier = { name: cmd.name, email: cmd.email, - cnpj: cmd.cnpj, + document: cmd.document, corporateName: cmd.corporateName, fantasyName: cmd.fantasyName, serviceCategory: cmd.serviceCategory, @@ -79,13 +81,14 @@ export const editSupplier = ); if (!edited.ok) return edited; - const cnpjChanged = String(current.cnpj) !== String(edited.value.supplier.cnpj); - if (cnpjChanged) { + const nextDocument = edited.value.supplier.identity.document; + const documentChanged = !SupplierDocument.equals(current.identity.document, nextDocument); + if (documentChanged) { if (!cmd.canEditSensitive) return err('edit-supplier-sensitive-forbidden'); - const byCnpj = await deps.supplierRepo.findByCnpj(edited.value.supplier.cnpj); - if (!byCnpj.ok) return byCnpj; - if (byCnpj.value !== null && String(byCnpj.value.id) !== String(id.value)) { - return err('edit-supplier-cnpj-duplicate'); + const byDocument = await deps.supplierRepo.findByDocument(nextDocument); + if (!byDocument.ok) return byDocument; + if (byDocument.value !== null && String(byDocument.value.id) !== String(id.value)) { + return err('edit-supplier-document-duplicate'); } } diff --git a/src/modules/partners/application/use-cases/find-supplier-by-cnpj.ts b/src/modules/partners/application/use-cases/find-supplier-by-cnpj.ts deleted file mode 100644 index d67afd35e..000000000 --- a/src/modules/partners/application/use-cases/find-supplier-by-cnpj.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Query `findSupplierByCnpj` — busca single por CNPJ. Valida o CNPJ na borda - * (`Cnpj.parse`); `null` é o "não encontrado" canônico (não-erro). - */ - -import type { Result } from '#src/shared/index.ts'; -import * as Cnpj from '#src/shared/kernel/cnpj.ts'; -import type { CnpjError } from '#src/shared/kernel/cnpj.ts'; -import type { Supplier } from '#src/modules/partners/domain/supplier/types.ts'; -import type { - SupplierRepository, - SupplierRepositoryError, -} from '#src/modules/partners/domain/supplier/repository.ts'; - -export type FindSupplierByCnpjCommand = Readonly<{ cnpj: string }>; - -export type FindSupplierByCnpjError = CnpjError | SupplierRepositoryError; - -type Deps = Readonly<{ supplierRepo: SupplierRepository }>; - -export const findSupplierByCnpj = - (deps: Deps) => - async ( - cmd: FindSupplierByCnpjCommand, - ): Promise> => { - const cnpj = Cnpj.parse(cmd.cnpj); - if (!cnpj.ok) return cnpj; - return deps.supplierRepo.findByCnpj(cnpj.value); - }; diff --git a/src/modules/partners/application/use-cases/find-supplier-by-document.ts b/src/modules/partners/application/use-cases/find-supplier-by-document.ts new file mode 100644 index 000000000..ddb74d462 --- /dev/null +++ b/src/modules/partners/application/use-cases/find-supplier-by-document.ts @@ -0,0 +1,29 @@ +/** + * Query `findSupplierByDocument` — busca single por documento (CPF ou CNPJ — #1022). Valida o + * documento na borda (`SupplierDocument.parse`); `null` é o "não encontrado" canônico (não-erro). + */ + +import type { Result } from '#src/shared/index.ts'; +import * as SupplierDocument from '#src/modules/partners/domain/supplier/supplier-document.ts'; +import type { SupplierDocumentError } from '#src/modules/partners/domain/supplier/supplier-document.ts'; +import type { Supplier } from '#src/modules/partners/domain/supplier/types.ts'; +import type { + SupplierRepository, + SupplierRepositoryError, +} from '#src/modules/partners/domain/supplier/repository.ts'; + +export type FindSupplierByDocumentCommand = Readonly<{ document: string }>; + +export type FindSupplierByDocumentError = SupplierDocumentError | SupplierRepositoryError; + +type Deps = Readonly<{ supplierRepo: SupplierRepository }>; + +export const findSupplierByDocument = + (deps: Deps) => + async ( + cmd: FindSupplierByDocumentCommand, + ): Promise> => { + const document = SupplierDocument.parse(cmd.document); + if (!document.ok) return document; + return deps.supplierRepo.findByDocument(document.value); + }; diff --git a/src/modules/partners/application/use-cases/list-suppliers.ts b/src/modules/partners/application/use-cases/list-suppliers.ts index bce2f6457..971c47486 100644 --- a/src/modules/partners/application/use-cases/list-suppliers.ts +++ b/src/modules/partners/application/use-cases/list-suppliers.ts @@ -7,6 +7,7 @@ */ import { type Result, ok } from '#src/shared/index.ts'; +import { companyNamesOf, documentOf } from '#src/modules/partners/domain/supplier/supplier.ts'; import type { Supplier } from '#src/modules/partners/domain/supplier/types.ts'; import type { SupplierRepository, @@ -21,23 +22,23 @@ export type SupplierListFilter = Readonly<{ categories?: readonly string[]; }>; -// `search` casa name / fantasyName / corporateName (substring case-insensitive) OU cnpj. -// #288: apelido = fantasyName; razão social = corporateName. +// Nomes pesquisáveis do fornecedor. #288: apelido = fantasyName; razão social = corporateName — +// que só a PJ tem (#1022). +const searchableNames = (s: Supplier): readonly string[] => { + const names = companyNamesOf(s.identity); + return names === null ? [s.name] : [s.name, names.fantasyName, names.corporateName]; +}; + +// `search` casa os nomes (substring case-insensitive) OU o documento (CPF ou CNPJ). const matchesSearch = (s: Supplier, search: string | undefined): boolean => { const q = search?.trim() ?? ''; if (q === '') return true; const term = q.toLowerCase(); - if ( - s.name.toLowerCase().includes(term) || - s.fantasyName.toLowerCase().includes(term) || - s.corporateName.toLowerCase().includes(term) - ) { - return true; - } + if (searchableNames(s).some((name) => name.toLowerCase().includes(term))) return true; // ADR-0044: o CNPJ é alfanumérico. Remover só a MÁSCARA — tirar letras faria a busca pelo - // CNPJ real não encontrar o cadastro. O VO guarda uppercase sem máscara. - const cnpjTerm = q.replace(/[^0-9A-Za-z]/g, '').toUpperCase(); - return cnpjTerm.length > 0 && String(s.cnpj).toUpperCase().includes(cnpjTerm); + // CNPJ real não encontrar o cadastro. O VO guarda uppercase sem máscara; o do CPF é numérico. + const documentTerm = q.replace(/[^0-9A-Za-z]/g, '').toUpperCase(); + return documentTerm.length > 0 && documentOf(s).includes(documentTerm); }; export const supplierMatchesFilter = (s: Supplier, filter: SupplierListFilter): boolean => { diff --git a/src/modules/partners/application/use-cases/register-supplier.ts b/src/modules/partners/application/use-cases/register-supplier.ts index 2cca4504f..d96f766e6 100644 --- a/src/modules/partners/application/use-cases/register-supplier.ts +++ b/src/modules/partners/application/use-cases/register-supplier.ts @@ -1,8 +1,9 @@ /** * Use case `registerSupplier` — cria um fornecedor (nasce Active). * - * Sequência: `Supplier.register` (valida campos texto, email, CNPJ, serviceCategory - * e a invariante de payment target) → guard de CNPJ duplicado via `findByCnpj` → + * Sequência: `Supplier.register` (valida campos texto, email, documento CPF/CNPJ com a + * identidade PF × PJ, serviceCategory e a invariante de payment target) → guard de + * documento duplicado via `findByDocument` → * `save`. Tempo injetado via `Clock`. Curried `(deps) => (cmd)` (padrão `approvePayable`). */ @@ -25,9 +26,9 @@ import type { export type RegisterSupplierCommand = Readonly<{ name: string; email: string; - cnpj: string; - corporateName: string; - fantasyName: string; + document: string; + corporateName: string | null; + fantasyName: string | null; serviceCategory: string; bankAccount: BankAccountInput | null; pixKey: PixKeyInput | null; @@ -36,7 +37,7 @@ export type RegisterSupplierCommand = Readonly<{ }>; export type RegisterSupplierError = - | 'register-supplier-cnpj-duplicate' + | 'register-supplier-document-duplicate' | SupplierError | SupplierRepositoryError; @@ -56,7 +57,7 @@ export const registerSupplier = id: SupplierId.generate(), name: cmd.name, email: cmd.email, - cnpj: cmd.cnpj, + document: cmd.document, corporateName: cmd.corporateName, fantasyName: cmd.fantasyName, serviceCategory: cmd.serviceCategory, @@ -68,9 +69,11 @@ export const registerSupplier = }); if (!registered.ok) return registered; - const existing = await deps.supplierRepo.findByCnpj(registered.value.supplier.cnpj); + const existing = await deps.supplierRepo.findByDocument( + registered.value.supplier.identity.document, + ); if (!existing.ok) return existing; - if (existing.value !== null) return err('register-supplier-cnpj-duplicate'); + if (existing.value !== null) return err('register-supplier-document-duplicate'); const saved = await deps.supplierRepo.save(registered.value.supplier, [registered.value.event]); if (!saved.ok) return saved; diff --git a/src/modules/partners/domain/supplier/errors.ts b/src/modules/partners/domain/supplier/errors.ts index f229b613e..efdff93b3 100644 --- a/src/modules/partners/domain/supplier/errors.ts +++ b/src/modules/partners/domain/supplier/errors.ts @@ -1,7 +1,9 @@ import type { PaymentTargetError } from '../shared/payment-target.ts'; +import type { SupplierDocumentError } from './supplier-document.ts'; // Erros do agregado `Supplier` — string union kebab EN. Compõe os erros dos VOs -// de payment target (`invalid-bank-account`/`invalid-pix-key`). +// de payment target (`invalid-bank-account`/`invalid-pix-key`) e do documento +// (`invalid-supplier-document`). export type SupplierError = | 'supplier-name-required' @@ -9,7 +11,10 @@ export type SupplierError = | 'supplier-email-invalid' | 'supplier-corporate-name-required' | 'supplier-fantasy-name-required' - | 'invalid-cnpj' + // PF (CPF) não tem razão social nem nome fantasia: preenchidos são recusados, não ignorados (#1022). + | 'supplier-corporate-name-not-allowed-for-pf' + | 'supplier-fantasy-name-not-allowed-for-pf' + | SupplierDocumentError | 'invalid-service-category' | 'invalid-service-rating' | 'supplier-payment-target-required' diff --git a/src/modules/partners/domain/supplier/events.ts b/src/modules/partners/domain/supplier/events.ts index 7809f1e04..ef7cc662a 100644 --- a/src/modules/partners/domain/supplier/events.ts +++ b/src/modules/partners/domain/supplier/events.ts @@ -1,10 +1,18 @@ -import type { Cnpj } from '#src/shared/kernel/cnpj.ts'; import type { SupplierId } from './supplier-id.ts'; +import type { SupplierDocument } from './supplier-document.ts'; // Eventos do agregado `Supplier`. PascalCase passado; `occurredAt` injetado. +// `SupplierRegistered.document` é CPF ou CNPJ (#1022). Evento de DOMÍNIO, nunca serializado: +// o contrato de integração com o `financial` é montado do snapshot do agregado e já se chama +// `document` desde o ADR-0043 (`supplier-outbox.mapper.ts`). export type SupplierEvent = Readonly< - | { type: 'SupplierRegistered'; supplierId: SupplierId; cnpj: Cnpj; occurredAt: Date } + | { + type: 'SupplierRegistered'; + supplierId: SupplierId; + document: SupplierDocument; + occurredAt: Date; + } | { type: 'SupplierDeactivated'; supplierId: SupplierId; occurredAt: Date } | { type: 'SupplierReactivated'; supplierId: SupplierId; occurredAt: Date } | { type: 'SupplierEdited'; supplierId: SupplierId; occurredAt: Date } diff --git a/src/modules/partners/domain/supplier/repository.ts b/src/modules/partners/domain/supplier/repository.ts index d8300e018..cdc378a86 100644 --- a/src/modules/partners/domain/supplier/repository.ts +++ b/src/modules/partners/domain/supplier/repository.ts @@ -1,9 +1,9 @@ /** * Port `SupplierRepository` — contrato de persistência do agregado `Supplier`. * - * Posicionado em `domain/supplier/`: a unicidade de CNPJ é invariante do agregado - * (legado `suppliers.cnpj` UNIQUE) e entra na superfície do port como erro - * `supplier-cnpj-duplicate`. Espelha `FinancierRepository`. + * Posicionado em `domain/supplier/`: a unicidade do documento (CPF ou CNPJ — #1022) é + * invariante do agregado (legado `suppliers.cnpj` UNIQUE) e entra na superfície do port + * como erro `supplier-document-duplicate`. Espelha `FinancierRepository`. * * Outbox (PAR-SUPPLIER-EVENTS / ADR-0043): `save` recebe os eventos de domínio * emitidos pela operação e os publica no outbox `par_outbox` na MESMA transação da @@ -13,22 +13,24 @@ * * Adapters esperados: * - `InMemorySupplierStore` (este ticket) — teste/CLI. - * - `DrizzleSupplierRepository` (futuro) — MySQL `par_suppliers`, UNIQUE em `cnpj` (ADR-0020). + * - `DrizzleSupplierRepository` — MySQL `par_suppliers`, UNIQUE em `document` (ADR-0020). */ import type { Result } from '#src/shared/primitives/result.ts'; -import type { Cnpj } from '#src/shared/kernel/cnpj.ts'; import type { SupplierId } from './supplier-id.ts'; import type { Supplier } from './types.ts'; +import type { SupplierDocument } from './supplier-document.ts'; import type { SupplierEvent } from './events.ts'; export type SupplierRepositoryError = | 'supplier-repo-unavailable' // transient (timeout/conexão) no adapter real - | 'supplier-cnpj-duplicate'; // CNPJ já usado por outro Supplier + | 'supplier-document-duplicate'; // CPF/CNPJ já usado por outro Supplier export type SupplierRepository = Readonly<{ findById: (id: SupplierId) => Promise>; - findByCnpj: (cnpj: Cnpj) => Promise>; + findByDocument: ( + document: SupplierDocument, + ) => Promise>; list: () => Promise>; save: ( supplier: Supplier, diff --git a/src/modules/partners/domain/supplier/supplier-document.ts b/src/modules/partners/domain/supplier/supplier-document.ts new file mode 100644 index 000000000..f5384cb24 --- /dev/null +++ b/src/modules/partners/domain/supplier/supplier-document.ts @@ -0,0 +1,53 @@ +/** + * `SupplierDocument` — documento do Fornecedor: CPF (pessoa física, pago por RPA) ou CNPJ + * (pessoa jurídica). Consumir via `import * as SupplierDocument from './supplier-document.ts'`. + * + * Vive no módulo `partners`, NÃO no kernel (#1022): só o Fornecedor aceita os dois documentos. + * `Financier` e `Act` continuam com o `Cnpj` do kernel e recusam CPF. + * + * O tipo de pessoa (PF/PJ) não é campo próprio: é o `kind` do documento. Um campo separado + * permitiria gravar PF com um CNPJ — aqui o estado inválido não é representável. + */ + +import { type Result, ok, err } from '#src/shared/primitives/result.ts'; +import * as Cpf from '#src/shared/kernel/cpf.ts'; +import * as Cnpj from '#src/shared/kernel/cnpj.ts'; + +export type CpfDocument = Readonly<{ kind: 'cpf'; value: Cpf.Cpf }>; +export type CnpjDocument = Readonly<{ kind: 'cnpj'; value: Cnpj.Cnpj }>; + +export type SupplierDocument = CpfDocument | CnpjDocument; + +export type SupplierDocumentError = 'invalid-supplier-document'; + +/** Tamanhos da forma canônica, sem máscara — fonte única, reusada pela borda HTTP. */ +export const CPF_LENGTH = 11; +export const CNPJ_LENGTH = 14; + +// Mesma máscara que os VOs do kernel removem (`.` `-` `/` espaços). +const stripMask = (raw: string): string => raw.replace(/[.\-/\s]/g, ''); + +/** + * Decide o documento pelo formato e delega a validação ao VO do kernel: + * - qualquer letra ou 14 caracteres → `Cnpj.parse` (o CNPJ pode ser alfanumérico — ADR-0044); + * - 11 dígitos → `Cpf.parse`; + * - outro tamanho → `invalid-supplier-document`. + */ +export const parse = (raw: string): Result => { + const bare = stripMask(raw); + if (/[A-Za-z]/.test(bare) || bare.length === CNPJ_LENGTH) { + const cnpj = Cnpj.parse(bare); + return cnpj.ok ? ok({ kind: 'cnpj', value: cnpj.value }) : err('invalid-supplier-document'); + } + if (bare.length === CPF_LENGTH) { + const cpf = Cpf.parse(bare); + return cpf.ok ? ok({ kind: 'cpf', value: cpf.value }) : err('invalid-supplier-document'); + } + return err('invalid-supplier-document'); +}; + +/** Forma canônica sem máscara (11 dígitos ou 14 caracteres uppercase) — chave de unicidade. */ +export const toRaw = (document: SupplierDocument): string => document.value; + +export const equals = (a: SupplierDocument, b: SupplierDocument): boolean => + a.kind === b.kind && a.value === b.value; diff --git a/src/modules/partners/domain/supplier/supplier.ts b/src/modules/partners/domain/supplier/supplier.ts index a50cc1e06..61e2286db 100644 --- a/src/modules/partners/domain/supplier/supplier.ts +++ b/src/modules/partners/domain/supplier/supplier.ts @@ -2,14 +2,14 @@ * Operações do agregado `Supplier` (Fornecedor). Consumir via * `import * as Supplier from './supplier.ts'`. IDs/instantes injetados. * - * - `register` — smart constructor: nasce Active. Invariante "destino de - * pagamento" (bankAccount OU pixKey). + * - `register` — smart constructor: nasce Active. Invariantes "identidade PF × PJ" + * (`identityFrom`) e "destino de pagamento" (bankAccount OU pixKey). * - `deactivate`/`reactivate` — ciclo de vida (soft-delete). */ import { type Result, ok, err } from '#src/shared/primitives/result.ts'; import { immutable } from '#src/shared/primitives/immutable.ts'; -import * as Cnpj from '#src/shared/kernel/cnpj.ts'; +import * as SupplierDocument from './supplier-document.ts'; import * as ServiceCategory from './service-category.ts'; import * as ServiceRating from './service-rating.ts'; import * as PaymentTarget from '../shared/payment-target.ts'; @@ -22,6 +22,7 @@ import type { RegisterSupplierInput, RehydrateSupplierInput, Supplier, + SupplierIdentity, } from './types.ts'; import type { SupplierEvent } from './events.ts'; import type { SupplierError } from './errors.ts'; @@ -39,24 +40,101 @@ const parseRating = (raw: string | null): Result { +// Texto opcional da entrada: trim; ausente (`null`) ou só espaços → `null` ("não informado", +// sem ruído no banco). Vale para o comentário da avaliação e para razão social/nome fantasia. +const presentText = (raw: string | null): string | null => { if (raw === null) return null; const trimmed = raw.trim(); return trimmed.length === 0 ? null : trimmed; }; +/** + * Identidade PF × PJ (#1022) — o documento decide quais campos existem: + * - CPF: razão social e nome fantasia NÃO existem. Preenchidos → recusa explícita + * (`*-not-allowed-for-pf`), nunca descarte silencioso de dado que o usuário digitou. + * - CNPJ: os dois são obrigatórios, como sempre foram. + * Branco conta como ausente nos dois lados — a tela desativa os campos e pode mandar `""`. + */ +const identityFrom = ( + document: SupplierDocument.SupplierDocument, + corporateNameRaw: string | null, + fantasyNameRaw: string | null, +): Result => { + const corporateName = presentText(corporateNameRaw); + const fantasyName = presentText(fantasyNameRaw); + switch (document.kind) { + case 'cpf': + if (corporateName !== null) return err('supplier-corporate-name-not-allowed-for-pf'); + if (fantasyName !== null) return err('supplier-fantasy-name-not-allowed-for-pf'); + return ok({ personType: 'individual', document }); + case 'cnpj': + if (corporateName === null) return err('supplier-corporate-name-required'); + if (fantasyName === null) return err('supplier-fantasy-name-required'); + return ok({ personType: 'company', document, corporateName, fantasyName }); + } +}; + +const parseIdentity = ( + rawDocument: string, + corporateName: string | null, + fantasyName: string | null, +): Result => { + const document = SupplierDocument.parse(rawDocument); + if (!document.ok) return document; + return identityFrom(document.value, corporateName, fantasyName); +}; + +/** + * Identidade de dado JÁ PERSISTIDO — a regra do CHECK do banco, e só ela: PF ⟺ os dois nomes + * `null`. NÃO aplica a exigência de "não-branco" da entrada (`identityFrom`): uma PJ gravada com + * razão social `''` por fora do domínio sempre reidratou, e passar a recusá-la derrubaria o + * `list()` inteiro por uma linha só. Validar entrada é papel de `register`/`edit`. + */ +const persistedIdentity = ( + document: SupplierDocument.SupplierDocument, + corporateName: string | null, + fantasyName: string | null, +): Result => { + switch (document.kind) { + case 'cpf': + if (corporateName !== null) return err('supplier-corporate-name-not-allowed-for-pf'); + if (fantasyName !== null) return err('supplier-fantasy-name-not-allowed-for-pf'); + return ok({ personType: 'individual', document }); + case 'cnpj': + if (corporateName === null) return err('supplier-corporate-name-required'); + if (fantasyName === null) return err('supplier-fantasy-name-required'); + return ok({ personType: 'company', document, corporateName, fantasyName }); + } +}; + +/** Documento canônico do fornecedor (CPF ou CNPJ, sem máscara). */ +export const documentOf = (supplier: Supplier): string => + SupplierDocument.toRaw(supplier.identity.document); + +/** + * Razão social e nome fantasia — só a PJ os tem; `null` na PF. Ponto único para os adapters + * (DTO, CSV, mapper, busca) traduzirem a ausência no que cada um usa (`null`, `''`, nada). + */ +export const companyNamesOf = ( + identity: SupplierIdentity, +): Readonly<{ corporateName: string; fantasyName: string }> | null => { + switch (identity.personType) { + case 'individual': + return null; + case 'company': + return { corporateName: identity.corporateName, fantasyName: identity.fantasyName }; + } +}; + export const register = ( input: RegisterSupplierInput, ): Result<{ supplier: ActiveSupplier; event: SupplierEvent }, SupplierError> => { if (isBlank(input.name)) return err('supplier-name-required'); if (isBlank(input.email)) return err('supplier-email-required'); if (!EMAIL_RE.test(input.email.trim())) return err('supplier-email-invalid'); - if (isBlank(input.corporateName)) return err('supplier-corporate-name-required'); - if (isBlank(input.fantasyName)) return err('supplier-fantasy-name-required'); - const cnpj = Cnpj.parse(input.cnpj); - if (!cnpj.ok) return err('invalid-cnpj'); + const identity = parseIdentity(input.document, input.corporateName, input.fantasyName); + if (!identity.ok) return identity; const category = ServiceCategory.parse(input.serviceCategory); if (!category.ok) return err('invalid-service-category'); @@ -84,14 +162,12 @@ export const register = ( id: input.id, name: input.name.trim(), email: input.email.trim(), - cnpj: cnpj.value, - corporateName: input.corporateName.trim(), - fantasyName: input.fantasyName.trim(), + identity: identity.value, serviceCategory: category.value, bankAccount, pixKey, serviceRating: rating.value, - ratingComment: normalizeComment(input.ratingComment ?? null), + ratingComment: presentText(input.ratingComment ?? null), status: 'Active', }); @@ -100,16 +176,17 @@ export const register = ( event: { type: 'SupplierRegistered', supplierId: supplier.id, - cnpj: supplier.cnpj, + document: supplier.identity.document, occurredAt: input.registeredAt, }, }); }; /** - * Edição cadastral (PUT total): revalida campos/email/CNPJ/categoria + invariante de payment - * target, reconstrói o agregado preservando `id` e o estado (Active/Inactive + deactivatedAt). - * RBAC do campo vital (CNPJ) é decidido fora (use case/borda). Emite `SupplierEdited`. + * Edição cadastral (PUT total): revalida campos/email/documento/categoria + invariantes de + * identidade PF × PJ e de payment target, reconstrói o agregado preservando `id` e o estado + * (Active/Inactive + deactivatedAt). Trocar PF ↔ PJ é trocar o documento: RBAC do campo vital + * é decidido fora (use case/borda). Emite `SupplierEdited`. */ export const edit = ( supplier: Supplier, @@ -119,11 +196,9 @@ export const edit = ( if (isBlank(input.name)) return err('supplier-name-required'); if (isBlank(input.email)) return err('supplier-email-required'); if (!EMAIL_RE.test(input.email.trim())) return err('supplier-email-invalid'); - if (isBlank(input.corporateName)) return err('supplier-corporate-name-required'); - if (isBlank(input.fantasyName)) return err('supplier-fantasy-name-required'); - const cnpj = Cnpj.parse(input.cnpj); - if (!cnpj.ok) return err('invalid-cnpj'); + const identity = parseIdentity(input.document, input.corporateName, input.fantasyName); + if (!identity.ok) return identity; const category = ServiceCategory.parse(input.serviceCategory); if (!category.ok) return err('invalid-service-category'); @@ -151,14 +226,12 @@ export const edit = ( id: supplier.id, name: input.name.trim(), email: input.email.trim(), - cnpj: cnpj.value, - corporateName: input.corporateName.trim(), - fantasyName: input.fantasyName.trim(), + identity: identity.value, serviceCategory: category.value, bankAccount, pixKey, serviceRating: rating.value, - ratingComment: normalizeComment(input.ratingComment ?? null), + ratingComment: presentText(input.ratingComment ?? null), }; const edited: Supplier = @@ -202,8 +275,11 @@ export const reactivate = ( }; // Reconstrói o agregado a partir de dados persistidos (sem emitir evento). Reaplica -// as invariantes: ao menos um destino de pagamento; Inactive exige deactivatedAt. +// as invariantes: identidade PF × PJ; ao menos um destino de pagamento; Inactive exige deactivatedAt. export const rehydrate = (input: RehydrateSupplierInput): Result => { + const identity = persistedIdentity(input.document, input.corporateName, input.fantasyName); + if (!identity.ok) return identity; + if (input.bankAccount === null && input.pixKey === null) { return err('supplier-payment-target-required'); } @@ -212,9 +288,7 @@ export const rehydrate = (input: RehydrateSupplierInput): Result; + +/** Pessoa jurídica — identificada por CNPJ; razão social e nome fantasia obrigatórios. */ +export type CompanyIdentity = Readonly<{ + personType: 'company'; + document: CnpjDocument; + corporateName: string; + fantasyName: string; +}>; + +export type SupplierIdentity = IndividualIdentity | CompanyIdentity; + type SupplierCore = Readonly<{ id: SupplierId; name: string; email: string; - cnpj: Cnpj; - corporateName: string; - fantasyName: string; + identity: SupplierIdentity; serviceCategory: ServiceCategory; bankAccount: BankAccount | null; pixKey: PixKey | null; @@ -42,13 +55,15 @@ export type InactiveSupplier = SupplierCore & Readonly<{ status: 'Inactive'; dea export type Supplier = ActiveSupplier | InactiveSupplier; +// Na entrada, `corporateName`/`fantasyName` chegam crus e opcionais: quem decide se podem +// ou devem existir é o documento (PF recusa preenchidos; PJ exige). Ausência é `null`. export type RegisterSupplierInput = Readonly<{ id: SupplierId; name: string; email: string; - cnpj: string; - corporateName: string; - fantasyName: string; + document: string; + corporateName: string | null; + fantasyName: string | null; serviceCategory: string; bankAccount: BankAccountInput | null; pixKey: PixKeyInput | null; @@ -61,9 +76,9 @@ export type RegisterSupplierInput = Readonly<{ export type EditSupplierInput = Readonly<{ name: string; email: string; - cnpj: string; - corporateName: string; - fantasyName: string; + document: string; + corporateName: string | null; + fantasyName: string | null; serviceCategory: string; bankAccount: BankAccountInput | null; pixKey: PixKeyInput | null; @@ -71,16 +86,16 @@ export type EditSupplierInput = Readonly<{ ratingComment?: string | null; }>; -// Reidratação pela borda (mapper): `id`/`cnpj`/`serviceCategory`/payment target já +// Reidratação pela borda (mapper): `id`/`document`/`serviceCategory`/payment target já // chegam tipados (revalidados no mapper). `rehydrate` só reconstrói o estado e -// reaplica as invariantes (destino de pagamento; Inactive exige deactivatedAt). +// reaplica as invariantes (identidade PF × PJ; destino de pagamento; Inactive exige deactivatedAt). export type RehydrateSupplierInput = Readonly<{ id: SupplierId; name: string; email: string; - cnpj: Cnpj; - corporateName: string; - fantasyName: string; + document: SupplierDocument; + corporateName: string | null; + fantasyName: string | null; serviceCategory: ServiceCategory; bankAccount: BankAccount | null; pixKey: PixKey | null; diff --git a/src/modules/partners/public-api/contractor-view.mapper.ts b/src/modules/partners/public-api/contractor-view.mapper.ts index 31b5a3161..5def4d102 100644 --- a/src/modules/partners/public-api/contractor-view.mapper.ts +++ b/src/modules/partners/public-api/contractor-view.mapper.ts @@ -14,6 +14,7 @@ * domínio mudou. A View reflete o domínio — agora de fato. */ +import { documentOf } from '../domain/supplier/supplier.ts'; import type { Supplier } from '../domain/supplier/types.ts'; import type { Financier } from '../domain/financier/types.ts'; import type { Collaborator } from '../domain/collaborator/types.ts'; @@ -85,7 +86,7 @@ export const supplierToView = (supplier: Supplier, updatedAt: Date): SupplierVie id: supplier.id as unknown as string, name: supplier.name, email: supplier.email, - document: supplier.cnpj as unknown as string, + document: documentOf(supplier), // CPF (PF) ou CNPJ (PJ) — #1022 serviceCategory: supplier.serviceCategory, bankAccount: supplier.bankAccount, pixKey: supplier.pixKey, diff --git a/src/modules/partners/public-api/supplier-projection.ts b/src/modules/partners/public-api/supplier-projection.ts index 1d3120d38..39fd90396 100644 --- a/src/modules/partners/public-api/supplier-projection.ts +++ b/src/modules/partners/public-api/supplier-projection.ts @@ -9,6 +9,7 @@ import { type Result, ok, err } from '#src/shared/primitives/result.ts'; import { ClockReal } from '#src/shared/adapters/clock-real.ts'; import { openPartnersMysql } from '../adapters/persistence/drivers/mysql-driver.ts'; import { createDrizzleSupplierStore } from '../adapters/persistence/repos/supplier-repository.drizzle.ts'; +import { documentOf } from '../domain/supplier/supplier.ts'; export type SupplierProjectionRecord = Readonly<{ supplierRef: string; @@ -30,7 +31,7 @@ export const listSuppliersForProjection = async ( listed.value.map((supplier) => ({ supplierRef: String(supplier.id), name: supplier.name, - document: String(supplier.cnpj), + document: documentOf(supplier), })), ); } finally { diff --git a/tests/etl/fixtures/legacy-mini.sql b/tests/etl/fixtures/legacy-mini.sql index 6669de661..e526364bc 100644 --- a/tests/etl/fixtures/legacy-mini.sql +++ b/tests/etl/fixtures/legacy-mini.sql @@ -41,8 +41,8 @@ CREATE TABLE `suppliers` ( `updatedAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; --- CNPJs DEVEM ser distintos entre suppliers: par_suppliers tem UNIQUE(cnpj) no destino --- (par_suppliers_cnpj_idx). Reusar o mesmo CNPJ faria o 2º supplier cair em quarentena +-- CNPJs DEVEM ser distintos entre suppliers: par_suppliers tem UNIQUE(document) no destino +-- (par_suppliers_document_idx). Reusar o mesmo CNPJ faria o 2º supplier cair em quarentena -- por ER_DUP_ENTRY, quebrando a reconciliação/idempotência da ETL. -- Avaliação (ETL-SUPPLIER-RATING-MAPPING): supplier 1 exercita 5+comentário; supplier 2 NULL/NULL. INSERT INTO `suppliers` VALUES diff --git a/tests/etl/mappers/supplier.mapper.test.ts b/tests/etl/mappers/supplier.mapper.test.ts index cc63a41a8..f17f0f5dc 100644 --- a/tests/etl/mappers/supplier.mapper.test.ts +++ b/tests/etl/mappers/supplier.mapper.test.ts @@ -225,3 +225,48 @@ describe('mapLegacySupplierRow — avaliação de serviço (ETL-SUPPLIER-RATING- assert.ok(r.error.some((e) => e.tag === 'EnumUnknown' && e.field === 'service_evaluation')); }); }); + +// #1022 — a coluna legada `cnpj` pode trazer CPF (fornecedor pessoa física). Documentos sintéticos. +describe('mapLegacySupplierRow — fornecedor pessoa física (#1022)', () => { + const CPF = '12345678909'; + // CPF válido cujos 11 dígitos, completados com zeros, também formam um CNPJ válido — a forma de + // um CNPJ legado que perdeu os zeros à esquerda. Calculado com os VOs do kernel. + const AMBIGUOUS = '00123456797'; + + it('CPF → PF, descartando razão social/nome fantasia forçados pelo NOT NULL legado', () => { + const r = mapLegacySupplierRow(base({ cnpj: CPF })); + assert.ok(r.ok); + assert.deepEqual(r.value.aggregate.identity, { + personType: 'individual', + document: { kind: 'cpf', value: CPF }, + }); + }); + + it('CPF que também é CNPJ sem os zeros à esquerda → quarentena, não PF (decisão humana)', () => { + const r = mapLegacySupplierRow(base({ cnpj: AMBIGUOUS })); + assert.ok(!r.ok); + assert.ok( + r.error.some( + (e) => e.tag === 'ExcludedByDecision' && e.field === 'cnpj' && e.decisionRef === 'ADR-0070', + ), + ); + }); + + it('documento inválido + razão social vazia → os DOIS reasons na mesma quarentena', () => { + const r = mapLegacySupplierRow(base({ cnpj: '123', corporateName: '' })); + assert.ok(!r.ok); + assert.ok(r.error.some((e) => e.tag === 'CnpjInvalid')); + assert.ok( + r.error.some((e) => e.tag === 'RequiredFieldMissing' && e.field === 'corporate_name'), + ); + }); + + it('CPF válido + e-mail inválido → só o e-mail (PF não exige razão social)', () => { + const r = mapLegacySupplierRow(base({ cnpj: CPF, email: 'sem-arroba', corporateName: '' })); + assert.ok(!r.ok); + assert.deepEqual( + r.error.map((e) => e.field), + ['email'], + ); + }); +}); diff --git a/tests/modules/financial/adapters/cnab/remittance-file.test.ts b/tests/modules/financial/adapters/cnab/remittance-file.test.ts index d99b63213..0b32f05ee 100644 --- a/tests/modules/financial/adapters/cnab/remittance-file.test.ts +++ b/tests/modules/financial/adapters/cnab/remittance-file.test.ts @@ -2,6 +2,8 @@ import { describe, it } from 'node:test'; import { strict as assert } from 'node:assert'; import { isErr, isOk } from '#src/shared/index.ts'; +import { inscriptionType } from '#src/modules/financial/domain/payout/inscription.ts'; +import { inspectRemittanceFile } from '#src/modules/financial/adapters/cnab/remittance-inspector.ts'; import type { CedenteHeaderData } from '#src/modules/financial/adapters/cnab/multipag-records.ts'; import type { Payee } from '#src/modules/financial/adapters/cnab/multipag-segments.ts'; // W0 RED: o montador do arquivo de remessa ainda não existe. @@ -1022,3 +1024,163 @@ describe('Remessa Multipag — o arquivo de Pix por chave (#838)', () => { assert.equal(linesOf(r.value.content).length, 6); }); }); + +/* + * Favorecido PESSOA FÍSICA nas três rotas emitidas (#1022). + * + * O cadastro de fornecedor passou a aceitar CPF, e a issue afirma que o CNAB já estava pronto: o + * reader deriva o `G005` do comprimento (`inscriptionType`) e o emissor escreve a inscrição por + * `inscription()`, que alinha à direita com zeros. Até aqui toda fixture deste arquivo era CNPJ — a + * afirmação era verdadeira e nada a MEDIA. Estes casos medem, rota por rota. + * + * O tipo NÃO é escrito literal: sai de `inscriptionType`, a mesma régua que o reader aplica ao + * cadastro (`remittance-payment-reader.drizzle.ts`). É o que amarra "11 posições → `1`" ao que a + * linha grava, em vez de testar só que o emissor copia um `'1'` que alguém afirmou. + * + * Os alinhamentos esperados vêm do layout v08: `G006` é `Num` (zeros à esquerda), com 14 posições no + * Segmento B (019-032) e na Informação 2 do Pix (`G031`, p. 101), e 15 no J-52 (p. 33). + */ +describe('Remessa Multipag — favorecido pessoa física (CPF) nas três rotas (#1022)', () => { + // CPF sintético com DV válido — o exemplo canônico, sem correspondência a cadastro real. DV + // inválido seria recusa `AT` (G059) que o inspetor não pega, e o teste aprovaria arquivo recusável. + const CPF = '12345678909'; + const CPF_TYPE = inscriptionType(CPF); + const PERSON_NAME = 'MARIA DA SILVA'; + + const segmentOf = (line: string): string => at(line, 14, 14); + const detailsOf = (content: string): readonly string[] => + linesOf(content).filter((l) => at(l, 8, 8) === '3'); + + // Zero defeitos de FORMA — o que o inspetor promete, e só isso. A coerência tipo × número (`AT`) + // é o que os asserts de posição abaixo cobrem; o inspetor não a enxerga. + const assertWellFormed = (content: string): void => { + assert.deepEqual(inspectRemittanceFile(content), []); + }; + + it('a régua do reader classifica 11 posições como pessoa física', () => { + assert.equal(CPF_TYPE, '1'); + }); + + it('transferência: o Segmento B grava tipo `1` e o CPF com três zeros à esquerda', () => { + const cpfPayee: Payee = { + ...payee(1), + name: PERSON_NAME, + documentType: CPF_TYPE, + document: CPF, + }; + const file = build([ + { route: 'transfer', payee: cpfPayee, paymentDate: PAYMENT_DATE, valueCents: 1_500_00 }, + ]); + const b = detailsOf(file.content).find((l) => segmentOf(l) === 'B') ?? ''; + + assert.equal(at(b, 18, 18), '1', 'G005 — tipo de inscrição do favorecido'); + assert.equal(at(b, 19, 32), '00012345678909', 'G006 — 14 posições, Num, zeros à esquerda'); + assertWellFormed(file.content); + }); + + // O tipo é do PAGAMENTO, não do lote: dois favorecidos do mesmo banco caem no mesmo lote, e cada + // Segmento B tem de declarar o seu. Um tipo herdado do primeiro registro classificaria errado o + // segundo, num arquivo que o banco aceita. + it('transferência: CPF e CNPJ no mesmo lote, cada Segmento B com o tipo do seu favorecido', () => { + const cpfPayee: Payee = { + ...payee(1), + name: PERSON_NAME, + documentType: CPF_TYPE, + document: CPF, + }; + const file = build([ + { route: 'transfer', payee: cpfPayee, paymentDate: PAYMENT_DATE, valueCents: 100_00 }, + payment(2, 200_00), + ]); + const bs = batchDetailsOf(file.content); + + assert.equal(bs.length, 1, 'mesmo banco de favorecido — um lote só'); + const segmentsB = (bs[0] ?? []).filter((l) => segmentOf(l) === 'B'); + assert.deepEqual( + segmentsB.map((l) => at(l, 18, 32)), + ['1' + '00012345678909', '2' + payee(2).document], + ); + assertWellFormed(file.content); + }); + + // Literal, e não `{ ...billet(), … }`: `billet` devolve a união `RemittancePayment`, e o spread + // sobre ela não estreita para a rota de boleto. + const cpfBillet: RemittancePayment = { + route: 'billet', + barcode: barcodeOf('341', 1), + beneficiaryName: PERSON_NAME, + beneficiaryDocumentType: CPF_TYPE, + beneficiaryDocument: CPF, + dueDate: new Date(Date.UTC(2026, 7, 20)), + paymentDate: PAYMENT_DATE, + valueCents: 5_000, + }; + + it('boleto: o J-52 grava o CEDENTE com tipo `1` e o CPF com quatro zeros à esquerda', () => { + const file = build([cpfBillet]); + const j52 = detailsOf(file.content).find((l) => at(l, 18, 19) === '52') ?? ''; + + assert.equal(at(j52, 76, 76), '1', 'G005 — tipo de inscrição do cedente (quem recebe)'); + assert.equal(at(j52, 77, 91), '000012345678909', 'G006 — 15 posições, Num, zeros à esquerda'); + assert.equal(at(j52, 92, 131).trim(), PERSON_NAME); + assertWellFormed(file.content); + }); + + // ⚠️ "CEDENTE" no J-52 é quem RECEBE; quem paga é o SACADO, e ele vem do header do arquivo. O + // favorecido pessoa física não pode reclassificar a empresa pagadora — os dois blocos são + // independentes, e é o que se mede aqui. + it('boleto: o favorecido CPF não reclassifica o SACADO, que segue sendo a empresa do header', () => { + const file = build([cpfBillet]); + const j52 = detailsOf(file.content).find((l) => at(l, 18, 19) === '52') ?? ''; + + assert.equal(at(j52, 20, 20), CEDENTE.documentType); + assert.equal(at(j52, 21, 35), CEDENTE.document.padStart(15, '0')); + }); + + // O favorecido PF típico tem o próprio CPF como chave. A forma de iniciação NÃO distingue PF de PJ + // (`cpf` e `cnpj` colapsam em `03`, `pix-initiation.ts`) — quem distingue é o `G005` do Segmento B. + // Por isso o caso cobra os dois juntos: a iniciação sozinha não diria nada sobre o tipo. + it('Pix: o Segmento B grava tipo `1`, o CPF alinhado e a chave CPF com iniciação `03`', () => { + const file = build([ + { + route: 'pix', + payee: { name: PERSON_NAME, documentType: CPF_TYPE, document: CPF }, + pixKey: CPF, + pixKeyType: 'cpf', + paymentDate: PAYMENT_DATE, + valueCents: 100_00, + }, + ]); + const b = detailsOf(file.content).find((l) => segmentOf(l) === 'B') ?? ''; + + assert.equal(at(b, 15, 17), '03 ', 'G100 — chave CPF/CNPJ, Alfa alinhado à esquerda'); + assert.equal(at(b, 18, 18), '1', 'G005 — tipo de inscrição do favorecido'); + assert.equal(at(b, 19, 32), '00012345678909', 'G006 — 14 posições, Num, zeros à esquerda'); + assert.equal(at(b, 128, 226).trimEnd(), CPF, 'G101 — a chave'); + assertWellFormed(file.content); + }); + + // A Informação 2 do Segmento A repete a inscrição do Segmento B — p. 101 manda o CPF "com zeros à + // esquerda" até 14. Alinhar à esquerda deixaria 3 dígitos do ISPB colados no CPF, e a leitura + // posicional do banco veria outra inscrição E outro ISPB. + it('Pix: a Informação 2 do Segmento A leva o CPF em 14 posições, igual ao Segmento B', () => { + const file = build([ + { + route: 'pix', + payee: { name: PERSON_NAME, documentType: CPF_TYPE, document: CPF }, + pixKey: CPF, + pixKeyType: 'cpf', + paymentDate: PAYMENT_DATE, + valueCents: 100_00, + }, + ]); + const details = detailsOf(file.content); + const a = details.find((l) => segmentOf(l) === 'A') ?? ''; + const b = details.find((l) => segmentOf(l) === 'B') ?? ''; + + assert.equal(at(a, 178, 191), '00012345678909', 'inscrição do favorecido'); + assert.equal(at(a, 192, 199), '00000000', 'ISPB — intacto depois da inscrição'); + assert.equal(at(a, 200, 201), '01', 'tipo de conta'); + assert.equal(at(a, 178, 191), at(b, 19, 32), 'A e B declaram a mesma inscrição'); + }); +}); diff --git a/tests/modules/partners/adapters/export/supplier-csv.test.ts b/tests/modules/partners/adapters/export/supplier-csv.test.ts index 023ce4c1c..e683a6871 100644 --- a/tests/modules/partners/adapters/export/supplier-csv.test.ts +++ b/tests/modules/partners/adapters/export/supplier-csv.test.ts @@ -14,7 +14,7 @@ const EXPECTED_HEADER = [ 'id', 'name', 'email', - 'cnpj', + 'CPF/CNPJ', 'corporateName', 'fantasyName', 'serviceCategory', @@ -40,9 +40,9 @@ const baseInput = () => ({ id: SupplierId.generate(), name: 'Fornecedor X', email: 'contato@fornecedor.com.br', - cnpj: '11.222.333/0001-81', - corporateName: 'Fornecedor X LTDA', - fantasyName: 'FX', + document: '11.222.333/0001-81', + corporateName: 'Fornecedor X LTDA' as string | null, + fantasyName: 'FX' as string | null, serviceCategory: 'INFORMATICA', bankAccount: bankInput() as ReturnType | null, pixKey: null as ReturnType | null, @@ -98,10 +98,10 @@ describe('suppliersToCsv — Active com bankAccount', () => { assert.equal(cells[14], ''); // deactivatedAt }); - it('cnpj é o valor normalizado do VO', () => { + it('documento é o valor normalizado do VO', () => { const s = makeActive(); const cells = dataLines(suppliersToCsv([s]))[0]?.split(',') ?? []; - assert.equal(cells[3], String(s.cnpj)); + assert.equal(cells[3], Supplier.documentOf(s)); }); }); diff --git a/tests/modules/partners/adapters/http/grids-contract-count.routes.test.ts b/tests/modules/partners/adapters/http/grids-contract-count.routes.test.ts index 5231e3f38..77ad8d4df 100644 --- a/tests/modules/partners/adapters/http/grids-contract-count.routes.test.ts +++ b/tests/modules/partners/adapters/http/grids-contract-count.routes.test.ts @@ -84,7 +84,7 @@ const mkSupplier = (name: string, cnpj: string): Seeded => { id, name, email: `${name.toLowerCase()}@fornecedor.com.br`, - cnpj, + document: cnpj, corporateName: `${name} LTDA`, fantasyName: name, serviceCategory: 'INFORMATICA', diff --git a/tests/modules/partners/adapters/http/partner-aggregate-query.test.ts b/tests/modules/partners/adapters/http/partner-aggregate-query.test.ts index 050f5fbba..e4b3d61ca 100644 --- a/tests/modules/partners/adapters/http/partner-aggregate-query.test.ts +++ b/tests/modules/partners/adapters/http/partner-aggregate-query.test.ts @@ -35,7 +35,7 @@ const aSupplier = (name: string, cnpj: string) => id: SupplierId.generate(), name, email: `${name.toLowerCase()}@f.com`, - cnpj, + document: cnpj, corporateName: `${name} LTDA`, fantasyName: name, serviceCategory: 'INFORMATICA', diff --git a/tests/modules/partners/adapters/http/partners-aggregate-contract-count.routes.test.ts b/tests/modules/partners/adapters/http/partners-aggregate-contract-count.routes.test.ts index 3052a8fc8..2846a52b5 100644 --- a/tests/modules/partners/adapters/http/partners-aggregate-contract-count.routes.test.ts +++ b/tests/modules/partners/adapters/http/partners-aggregate-contract-count.routes.test.ts @@ -59,7 +59,7 @@ const seed = () => { id: SupplierId.generate(), name: 'Alpha', email: 'alpha@f.com', - cnpj: '11.222.333/0001-81', + document: '11.222.333/0001-81', corporateName: 'Alpha LTDA', fantasyName: 'Alpha', serviceCategory: 'INFORMATICA', diff --git a/tests/modules/partners/adapters/http/partners-aggregate.routes.test.ts b/tests/modules/partners/adapters/http/partners-aggregate.routes.test.ts index 26310fd16..775d85de8 100644 --- a/tests/modules/partners/adapters/http/partners-aggregate.routes.test.ts +++ b/tests/modules/partners/adapters/http/partners-aggregate.routes.test.ts @@ -59,7 +59,7 @@ const seed = () => { id: SupplierId.generate(), name: 'Alpha', email: 'alpha@f.com', - cnpj: '11.222.333/0001-81', + document: '11.222.333/0001-81', corporateName: 'Alpha LTDA', fantasyName: 'Alpha', serviceCategory: 'INFORMATICA', diff --git a/tests/modules/partners/adapters/http/suppliers-batch.routes.test.ts b/tests/modules/partners/adapters/http/suppliers-batch.routes.test.ts index 30bf50ace..0f329e6d6 100644 --- a/tests/modules/partners/adapters/http/suppliers-batch.routes.test.ts +++ b/tests/modules/partners/adapters/http/suppliers-batch.routes.test.ts @@ -36,7 +36,7 @@ const mk = (name: string, cnpj: string, cat: string): ReturnType = {}) => ({ name: 'Fornecedor X', email: 'contato@fornecedor.com.br', - cnpj: CNPJ_A, + document: CNPJ_A, corporateName: 'Fornecedor X LTDA', fantasyName: 'FX', serviceCategory: 'INFORMATICA', @@ -108,7 +108,7 @@ const create = async ( method: 'POST', url: '/api/v1/suppliers', headers: { authorization: `Bearer ${token}` }, - payload: body({ cnpj, email: `c${cnpj}@fornecedor.com.br` }), + payload: body({ document: cnpj, email: `c${cnpj}@fornecedor.com.br` }), }); return (res.headers['location'] ?? '').slice('/api/v1/suppliers/'.length); }; @@ -157,7 +157,7 @@ describe('SUPPLIERS-HTTP-EDIT — PUT /api/v1/suppliers/:id', () => { await teardown(); }); - it('CA: write, sem mudar cnpj (muda name + troca payment p/ pix) -> 200', async () => { + it('CA: write, sem mudar documento (muda name + troca payment p/ pix) -> 200', async () => { const { app, teardown } = await makeApp(); const token = await login(app, WRITER_EMAIL); const id = await create(app, token, CNPJ_A); @@ -175,28 +175,34 @@ describe('SUPPLIERS-HTTP-EDIT — PUT /api/v1/suppliers/:id', () => { await teardown(); }); - it('CA: write, mudando cnpj -> 403 (sensitive-forbidden)', async () => { + it('CA: write, mudando documento -> 403 (sensitive-forbidden)', async () => { const { app, teardown } = await makeApp(); const token = await login(app, WRITER_EMAIL); const id = await create(app, token, CNPJ_A); - assert.equal(await put(app, token, id, body({ cnpj: CNPJ_B })).then((r) => r.statusCode), 403); + assert.equal( + await put(app, token, id, body({ document: CNPJ_B })).then((r) => r.statusCode), + 403, + ); await teardown(); }); - it('CA: director, mudando cnpj -> 200; cnpj novo já usado -> 409', async () => { + it('CA: director, mudando documento -> 200; documento novo já usado -> 409', async () => { const { app, teardown } = await makeApp(); const token = await login(app, DIRECTOR_EMAIL); const id = await create(app, token, CNPJ_A); - assert.equal(await put(app, token, id, body({ cnpj: CNPJ_B })).then((r) => r.statusCode), 200); + assert.equal( + await put(app, token, id, body({ document: CNPJ_B })).then((r) => r.statusCode), + 200, + ); const other = await create(app, token, CNPJ_A); // CNPJ_A livre de novo (id mudou p/ B) assert.equal( - await put(app, token, other, body({ cnpj: CNPJ_B })).then((r) => r.statusCode), + await put(app, token, other, body({ document: CNPJ_B })).then((r) => r.statusCode), 409, ); await teardown(); }); - it('CA: sem payment target -> 422; email inválido -> 422; cnpj curto -> 400', async () => { + it('CA: sem payment target -> 422; email inválido -> 422; documento curto -> 400', async () => { const { app, teardown } = await makeApp(); const token = await login(app, WRITER_EMAIL); const id = await create(app, token, CNPJ_A); @@ -207,7 +213,10 @@ describe('SUPPLIERS-HTTP-EDIT — PUT /api/v1/suppliers/:id', () => { 422, ); assert.equal(await put(app, token, id, body({ email: 'nope' })).then((r) => r.statusCode), 422); - assert.equal(await put(app, token, id, body({ cnpj: '123' })).then((r) => r.statusCode), 400); + assert.equal( + await put(app, token, id, body({ document: '123' })).then((r) => r.statusCode), + 400, + ); await teardown(); }); }); diff --git a/tests/modules/partners/adapters/http/suppliers-export.routes.test.ts b/tests/modules/partners/adapters/http/suppliers-export.routes.test.ts index f275003ca..ad571e048 100644 --- a/tests/modules/partners/adapters/http/suppliers-export.routes.test.ts +++ b/tests/modules/partners/adapters/http/suppliers-export.routes.test.ts @@ -34,7 +34,7 @@ const mk = (over: { name: string; cnpj: string; serviceCategory: string }) => id: SupplierId.generate(), name: over.name, email: `${over.name.toLowerCase().replace(/[^a-z]/g, '')}@fornecedor.com.br`, - cnpj: over.cnpj, + document: over.cnpj, corporateName: `${over.name} LTDA`, fantasyName: over.name, serviceCategory: over.serviceCategory, diff --git a/tests/modules/partners/adapters/http/suppliers-individual.routes.test.ts b/tests/modules/partners/adapters/http/suppliers-individual.routes.test.ts new file mode 100644 index 000000000..c85f1911c --- /dev/null +++ b/tests/modules/partners/adapters/http/suppliers-individual.routes.test.ts @@ -0,0 +1,408 @@ +/** + * #1022 — Fornecedor pessoa física pela borda HTTP: os critérios de aceite da issue. + * + * POST/PUT aceitam `document` (CPF ou CNPJ) e o alias deprecated `cnpj`; GET devolve `document`, + * `personType` e o alias `cnpj`; na PF, `corporateName`/`fantasyName` são `null`. Documentos + * sintéticos (DV calculado), nunca dado real de cadastro. + * + * No driver memory o leitor (GET) só vê o `seed` — não as escritas do POST. Por isso as escritas + * são verificadas pelo status, e as leituras (detalhe, busca) partem de fornecedores semeados. + */ + +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { buildApp } from '#src/shared/http/app.ts'; +import { + authHttpPlugin, + buildAuthHttpDeps, + makeRequireAuth, +} from '#src/modules/auth/public-api/http.ts'; +import { + suppliersHttpPlugin, + buildPartnersHttpDeps, +} from '#src/modules/partners/public-api/http.ts'; +import { SUPPLIER_PERMISSION } from '#src/modules/partners/public-api/permissions.ts'; +import * as Supplier from '#src/modules/partners/domain/supplier/supplier.ts'; +import * as SupplierId from '#src/modules/partners/domain/supplier/supplier-id.ts'; +import type { SupplierReadRecord } from '#src/modules/partners/application/ports/supplier-reader.ts'; + +const STRONG = 'Str0ng-Passphrase-2026!'; +const WRITER_EMAIL = 'compras.editor@example.com'; +const DIRECTOR_EMAIL = 'compras.diretor@example.com'; +const CPF = '12345678909'; +const CNPJ = '11222333000181'; + +type App = Awaited>; + +const individualBody = (over: Record = {}) => ({ + name: 'Maria da Silva', + email: 'maria@example.com', + document: CPF, + serviceCategory: 'INFORMATICA', + bankAccount: { bank: '001', agency: '0001-2', accountNumber: '123456', checkDigit: '7' }, + pixKey: null, + ...over, +}); + +const companyBody = (over: Record = {}) => ({ + ...individualBody({ document: CNPJ, email: 'contato@fornecedor.com.br' }), + name: 'Fornecedor X', + corporateName: 'Fornecedor X LTDA', + fantasyName: 'FX', + ...over, +}); + +const NOW = new Date('2026-10-01T12:00:00.000Z'); + +type RegisterInput = Parameters[0]; + +// Mesmo payload do POST, passado direto ao domínio: os bodies acima são `Record` +// (aceitam campos inválidos de propósito), daí a conversão explícita para o input do `register`. +const seedRecord = (body: Record): SupplierReadRecord => { + const input = { + corporateName: null, + fantasyName: null, + ...body, + id: SupplierId.generate(), + registeredAt: NOW, + } as unknown as RegisterInput; + const r = Supplier.register(input); + assert.ok(r.ok, `seed inválido: ${r.ok ? '' : r.error}`); + return { supplier: r.value.supplier, legacyId: null, createdAt: NOW, updatedAt: NOW }; +}; + +const makeApp = async (suppliers: readonly SupplierReadRecord[] = []) => { + const authDeps = await buildAuthHttpDeps({ + driver: 'memory', + seed: { + users: [ + { + email: WRITER_EMAIL, + password: STRONG, + permissions: [SUPPLIER_PERMISSION.read, SUPPLIER_PERMISSION.write], + }, + { + email: DIRECTOR_EMAIL, + password: STRONG, + permissions: [ + SUPPLIER_PERMISSION.read, + SUPPLIER_PERMISSION.write, + 'supplier:edit-sensitive', + ], + }, + ], + }, + }); + const partnersDeps = await buildPartnersHttpDeps({ driver: 'memory', seed: { suppliers } }); + const requireAuth = makeRequireAuth(authDeps.verifyAccessToken); + const app = await buildApp({ + routes: [ + authHttpPlugin(authDeps), + { + plugin: suppliersHttpPlugin(partnersDeps, { + requireAuth, + authorize: authDeps.authorize, + hasPermission: authDeps.hasPermission, + }), + prefix: '/api/v1', + }, + ], + }); + const login = async (email: string): Promise => { + const res = await app.inject({ + method: 'POST', + url: '/api/v2/auth/login', + payload: { email, password: STRONG }, + }); + return (res.json() as { accessToken: string }).accessToken; + }; + const teardown = async (): Promise => { + await app.close(); + await partnersDeps.shutdown(); + await authDeps.shutdown(); + }; + return { app, login, teardown }; +}; + +const post = (app: App, token: string, payload: Record) => + app.inject({ + method: 'POST', + url: '/api/v1/suppliers', + headers: { authorization: `Bearer ${token}` }, + payload, + }); + +const put = (app: App, token: string, id: string, payload: Record) => + app.inject({ + method: 'PUT', + url: `/api/v1/suppliers/${id}`, + headers: { authorization: `Bearer ${token}` }, + payload, + }); + +const getDetail = async (app: App, token: string, id: string) => { + const res = await app.inject({ + method: 'GET', + url: `/api/v1/suppliers/${id}`, + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(res.statusCode, 200, res.body); + return res.json() as Record; +}; + +const idFrom = (res: Awaited>): string => + (res.headers['location'] ?? '').slice('/api/v1/suppliers/'.length); + +const errorCode = (res: Awaited>): string => + (res.json() as { error: { code: string } }).error.code; + +describe('#1022 — fornecedor pessoa física (POST/GET/PUT /api/v1/suppliers)', () => { + it('POST com CPF válido e sem razão social/nome fantasia → 201', async () => { + const { app, login, teardown } = await makeApp(); + try { + const res = await post(app, await login(WRITER_EMAIL), individualBody()); + assert.equal(res.statusCode, 201, res.body); + assert.notEqual(idFrom(res), ''); + } finally { + await teardown(); + } + }); + + it('GET de um fornecedor PF devolve document, personType PF e razão social/fantasia null', async () => { + const pf = seedRecord(individualBody()); + const { app, login, teardown } = await makeApp([pf]); + try { + const detail = await getDetail(app, await login(WRITER_EMAIL), String(pf.supplier.id)); + assert.equal(detail['document'], CPF); + assert.equal(detail['personType'], 'PF'); + assert.equal(detail['cnpj'], CPF, 'alias deprecated espelha `document` por um ciclo'); + assert.equal(detail['corporateName'], null); + assert.equal(detail['fantasyName'], null); + } finally { + await teardown(); + } + }); + + it('POST com CPF de DV errado → 422 invalid-supplier-document', async () => { + const { app, login, teardown } = await makeApp(); + try { + const res = await post( + app, + await login(WRITER_EMAIL), + individualBody({ document: '12345678900' }), + ); + assert.equal(res.statusCode, 422); + assert.equal(errorCode(res), 'invalid-supplier-document'); + } finally { + await teardown(); + } + }); + + it('POST com CPF e razão social → 422 supplier-corporate-name-not-allowed-for-pf', async () => { + const { app, login, teardown } = await makeApp(); + try { + const res = await post( + app, + await login(WRITER_EMAIL), + individualBody({ corporateName: 'Maria LTDA' }), + ); + assert.equal(res.statusCode, 422); + assert.equal(errorCode(res), 'supplier-corporate-name-not-allowed-for-pf'); + } finally { + await teardown(); + } + }); + + it('POST com CPF e nome fantasia → 422 supplier-fantasy-name-not-allowed-for-pf', async () => { + const { app, login, teardown } = await makeApp(); + try { + const res = await post( + app, + await login(WRITER_EMAIL), + individualBody({ fantasyName: 'Maria' }), + ); + assert.equal(res.statusCode, 422); + assert.equal(errorCode(res), 'supplier-fantasy-name-not-allowed-for-pf'); + } finally { + await teardown(); + } + }); + + it('POST com CNPJ sem razão social → 422 supplier-corporate-name-required', async () => { + const { app, login, teardown } = await makeApp(); + try { + const res = await post( + app, + await login(WRITER_EMAIL), + companyBody({ corporateName: undefined }), + ); + assert.equal(res.statusCode, 422); + assert.equal(errorCode(res), 'supplier-corporate-name-required'); + } finally { + await teardown(); + } + }); + + it('POST com o alias `cnpj` continua funcionando (PJ)', async () => { + const { app, login, teardown } = await makeApp(); + try { + const token = await login(WRITER_EMAIL); + const { document: _document, ...withoutDocument } = companyBody(); + const res = await post(app, token, { ...withoutDocument, cnpj: CNPJ }); + assert.equal(res.statusCode, 201, res.body); + } finally { + await teardown(); + } + }); + + it('GET de um fornecedor PJ devolve personType PJ e o alias `cnpj`', async () => { + const pj = seedRecord(companyBody()); + const { app, login, teardown } = await makeApp([pj]); + try { + const detail = await getDetail(app, await login(WRITER_EMAIL), String(pj.supplier.id)); + assert.equal(detail['document'], CNPJ); + assert.equal(detail['cnpj'], CNPJ); + assert.equal(detail['personType'], 'PJ'); + assert.equal(detail['corporateName'], 'Fornecedor X LTDA'); + } finally { + await teardown(); + } + }); + + it('POST sem `document` e sem `cnpj` → 400', async () => { + const { app, login, teardown } = await makeApp(); + try { + const { document: _document, ...withoutDocument } = individualBody(); + const res = await post(app, await login(WRITER_EMAIL), withoutDocument); + assert.equal(res.statusCode, 400); + } finally { + await teardown(); + } + }); + + it('POST com `document` e `cnpj` divergentes → 400', async () => { + const { app, login, teardown } = await makeApp(); + try { + const res = await post( + app, + await login(WRITER_EMAIL), + companyBody({ cnpj: '11444777000161' }), + ); + assert.equal(res.statusCode, 400); + } finally { + await teardown(); + } + }); + + it('POST com documento de 12 caracteres → 400 (shape: 11 ou 14)', async () => { + const { app, login, teardown } = await makeApp(); + try { + const res = await post( + app, + await login(WRITER_EMAIL), + individualBody({ document: '123456789012' }), + ); + assert.equal(res.statusCode, 400); + } finally { + await teardown(); + } + }); + + it('POST com documento mascarado → 400 para CPF e para CNPJ (a borda é sem máscara)', async () => { + // Um CPF mascarado tem 14 caracteres — o mesmo tamanho de um CNPJ sem máscara. Só o tamanho + // aceitaria a máscara para um documento e a recusaria para o outro. + const { app, login, teardown } = await makeApp(); + try { + const token = await login(WRITER_EMAIL); + for (const document of ['123.456.789-09', '11.222.333/0001-81']) { + const res = await post(app, token, individualBody({ document })); + assert.equal(res.statusCode, 400, `document=${document}`); + } + } finally { + await teardown(); + } + }); + + it('CPF duplicado → 409 register-supplier-document-duplicate', async () => { + const { app, login, teardown } = await makeApp(); + try { + const token = await login(WRITER_EMAIL); + assert.equal((await post(app, token, individualBody())).statusCode, 201); + const dup = await post(app, token, individualBody({ email: 'outra@example.com' })); + assert.equal(dup.statusCode, 409); + assert.equal(errorCode(dup), 'register-supplier-document-duplicate'); + } finally { + await teardown(); + } + }); + + it('busca da lista encontra o fornecedor PF pelo CPF (com ou sem máscara)', async () => { + const { app, login, teardown } = await makeApp([ + seedRecord(individualBody()), + seedRecord(companyBody()), + ]); + try { + const token = await login(WRITER_EMAIL); + for (const search of [CPF, '123.456.789-09']) { + const res = await app.inject({ + method: 'GET', + url: `/api/v1/suppliers?search=${encodeURIComponent(search)}`, + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(res.statusCode, 200, res.body); + const items = (res.json() as { items: { document: string }[] }).items; + assert.deepEqual( + items.map((i) => i.document), + [CPF], + `search=${search}`, + ); + } + } finally { + await teardown(); + } + }); + + it('PUT trocando PF → PJ sem supplier:edit-sensitive → 403', async () => { + const { app, login, teardown } = await makeApp(); + try { + const token = await login(WRITER_EMAIL); + const id = idFrom(await post(app, token, individualBody())); + const res = await put(app, token, id, companyBody({ email: 'maria@example.com' })); + assert.equal(res.statusCode, 403); + assert.equal(errorCode(res), 'edit-supplier-sensitive-forbidden'); + } finally { + await teardown(); + } + }); + + it('PUT trocando PF → PJ com supplier:edit-sensitive → 200 e passa a exigir razão social', async () => { + const { app, login, teardown } = await makeApp(); + try { + const writer = await login(WRITER_EMAIL); + const director = await login(DIRECTOR_EMAIL); + const id = idFrom(await post(app, writer, individualBody())); + + const missing = await put(app, director, id, companyBody({ corporateName: null })); + assert.equal(missing.statusCode, 422); + assert.equal(errorCode(missing), 'supplier-corporate-name-required'); + + const res = await put(app, director, id, companyBody()); + assert.equal(res.statusCode, 200, res.body); + } finally { + await teardown(); + } + }); + + it('PUT de PF sem trocar o documento não exige supplier:edit-sensitive', async () => { + const { app, login, teardown } = await makeApp(); + try { + const token = await login(WRITER_EMAIL); + const id = idFrom(await post(app, token, individualBody())); + const res = await put(app, token, id, individualBody({ name: 'Maria S. Souza' })); + assert.equal(res.statusCode, 200, res.body); + } finally { + await teardown(); + } + }); +}); diff --git a/tests/modules/partners/adapters/http/suppliers-lifecycle.routes.test.ts b/tests/modules/partners/adapters/http/suppliers-lifecycle.routes.test.ts index 607e4e048..e663ac5ab 100644 --- a/tests/modules/partners/adapters/http/suppliers-lifecycle.routes.test.ts +++ b/tests/modules/partners/adapters/http/suppliers-lifecycle.routes.test.ts @@ -31,7 +31,7 @@ const UUID_INEXISTENTE = '00000000-0000-4000-8000-000000000000'; const VALID_BODY = { name: 'Fornecedor X', email: 'contato@fornecedor.com.br', - cnpj: '11222333000181', + document: '11222333000181', corporateName: 'Fornecedor X LTDA', fantasyName: 'FX', serviceCategory: 'INFORMATICA', diff --git a/tests/modules/partners/adapters/http/suppliers-rating.routes.test.ts b/tests/modules/partners/adapters/http/suppliers-rating.routes.test.ts index 12e197bc1..948c8cc0d 100644 --- a/tests/modules/partners/adapters/http/suppliers-rating.routes.test.ts +++ b/tests/modules/partners/adapters/http/suppliers-rating.routes.test.ts @@ -34,7 +34,7 @@ const mkSupplier = (serviceRating: string | null, ratingComment: string | null) id: SupplierId.generate(), name: 'Alpha', email: 'alpha@x.com', - cnpj: '11.222.333/0001-81', + document: '11.222.333/0001-81', corporateName: 'Alpha LTDA', fantasyName: 'Alpha', serviceCategory: 'INFORMATICA', @@ -163,7 +163,7 @@ describe('SUPPLIERS-RATING — POST validação', () => { payload: { name: 'Novo', email: 'novo@x.com', - cnpj: '11222333000181', + document: '11222333000181', corporateName: 'Novo LTDA', fantasyName: 'Novo', serviceCategory: 'INFORMATICA', diff --git a/tests/modules/partners/adapters/http/suppliers-reads.routes.test.ts b/tests/modules/partners/adapters/http/suppliers-reads.routes.test.ts index ca76811f8..6f37c8011 100644 --- a/tests/modules/partners/adapters/http/suppliers-reads.routes.test.ts +++ b/tests/modules/partners/adapters/http/suppliers-reads.routes.test.ts @@ -41,7 +41,7 @@ const mk = (over: { id: SupplierId.generate(), name: over.name, email: `${over.name.toLowerCase()}@fornecedor.com.br`, - cnpj: over.cnpj, + document: over.cnpj, corporateName: `${over.name} LTDA`, fantasyName: over.name, serviceCategory: over.serviceCategory, @@ -169,7 +169,9 @@ describe('SUPPLIERS-HTTP-READS (S1) — GET /api/v1/suppliers', () => { assert.equal(body.meta.totalItems, 2); const first = body.items[0]!; assert.ok('legacyId' in first); - assert.ok('cnpj' in first); + assert.ok('document' in first); + assert.ok('personType' in first); + assert.ok('cnpj' in first); // alias deprecated por um ciclo (#1022) assert.ok('serviceCategory' in first); assert.ok('active' in first); await teardown(); @@ -200,11 +202,15 @@ describe('SUPPLIERS-HTTP-READS (S1) — GET /api/v1/suppliers/:id', () => { assert.equal(res.statusCode, 200); const dto = res.json() as { id: string; + document: string; + personType: string; cnpj: string; serviceCategory: string; active: boolean; }; assert.equal(dto.id, id); + assert.equal(dto.document, '11222333000181'); + assert.equal(dto.personType, 'PJ'); assert.equal(dto.cnpj, '11222333000181'); assert.equal(dto.serviceCategory, 'INFORMATICA'); assert.equal(dto.active, true); diff --git a/tests/modules/partners/adapters/http/suppliers-register.routes.test.ts b/tests/modules/partners/adapters/http/suppliers-register.routes.test.ts index 82a322282..4f5634255 100644 --- a/tests/modules/partners/adapters/http/suppliers-register.routes.test.ts +++ b/tests/modules/partners/adapters/http/suppliers-register.routes.test.ts @@ -28,7 +28,7 @@ const NOPERM_EMAIL = 'sem.permissao@example.com'; const VALID_BODY = { name: 'Fornecedor X', email: 'contato@fornecedor.com.br', - cnpj: '11222333000181', + document: '11222333000181', corporateName: 'Fornecedor X LTDA', fantasyName: 'FX', serviceCategory: 'INFORMATICA', @@ -141,10 +141,19 @@ describe('SUPPLIERS-HTTP-REGISTER (S2) — POST /api/v1/suppliers', () => { await teardown(); }); - it('CA: shape inválido (cnpj curto) -> 400 (Zod)', async () => { + it('CA: alias deprecated `cnpj` no lugar de `document` -> 201 (#1022, um ciclo)', async () => { const { app, teardown } = await makeApp(); const token = await login(app, WRITER_EMAIL); - const res = await postSupplier(app, token, { ...VALID_BODY, cnpj: '123' }); + const { document, ...rest } = VALID_BODY; + const res = await postSupplier(app, token, { ...rest, cnpj: document }); + assert.equal(res.statusCode, 201); + await teardown(); + }); + + it('CA: shape inválido (documento curto) -> 400 (Zod)', async () => { + const { app, teardown } = await makeApp(); + const token = await login(app, WRITER_EMAIL); + const res = await postSupplier(app, token, { ...VALID_BODY, document: '123' }); assert.equal(res.statusCode, 400); await teardown(); }); @@ -152,7 +161,7 @@ describe('SUPPLIERS-HTTP-REGISTER (S2) — POST /api/v1/suppliers', () => { it('CA: CNPJ 14-díg DV inválido -> 422 (domínio)', async () => { const { app, teardown } = await makeApp(); const token = await login(app, WRITER_EMAIL); - const res = await postSupplier(app, token, { ...VALID_BODY, cnpj: '11111111111111' }); + const res = await postSupplier(app, token, { ...VALID_BODY, document: '11111111111111' }); assert.equal(res.statusCode, 422); await teardown(); }); diff --git a/tests/modules/partners/adapters/persistence/mappers/supplier-outbox.mapper.test.ts b/tests/modules/partners/adapters/persistence/mappers/supplier-outbox.mapper.test.ts index c28123c8a..aa8b0aa4d 100644 --- a/tests/modules/partners/adapters/persistence/mappers/supplier-outbox.mapper.test.ts +++ b/tests/modules/partners/adapters/persistence/mappers/supplier-outbox.mapper.test.ts @@ -25,7 +25,7 @@ const buildActive = (cnpjRaw = '11222333000181'): SupplierAggregate => { id: '7f3a1234-5678-4abc-9def-fedcba987654' as never, name: 'Fornecedor X LTDA', email: 'contato@fornecedor.com.br', - cnpj: cnpjRaw, + document: cnpjRaw, corporateName: 'Fornecedor X LTDA', fantasyName: 'FX', serviceCategory: 'INFORMATICA', @@ -43,7 +43,7 @@ describe('supplierEventsToOutboxMessages', () => { const event: SupplierEvent = { type: 'SupplierRegistered', supplierId: supplier.id, - cnpj: supplier.cnpj, + document: supplier.identity.document, occurredAt: REGISTERED_AT, }; @@ -85,7 +85,7 @@ describe('supplierEventsToOutboxMessages', () => { const payload = JSON.parse(m.payload) as Record; assert.equal(payload['supplierRef'], String(supplier.id)); assert.equal(payload['name'], supplier.name); - assert.equal(payload['document'], String(supplier.cnpj)); + assert.equal(payload['document'], Supplier.documentOf(supplier)); assert.equal(payload['occurredAt'], editedAt.toISOString()); }); @@ -115,7 +115,7 @@ describe('supplierEventsToOutboxMessages', () => { { type: 'SupplierRegistered', supplierId: supplier.id, - cnpj: supplier.cnpj, + document: supplier.identity.document, occurredAt: REGISTERED_AT, }, { type: 'SupplierDeactivated', supplierId: supplier.id, occurredAt: REGISTERED_AT }, @@ -137,7 +137,7 @@ describe('supplierEventsToOutboxMessages', () => { { type: 'SupplierRegistered', supplierId: supplier.id, - cnpj: supplier.cnpj, + document: supplier.identity.document, occurredAt: REGISTERED_AT, }, { type: 'SupplierEdited', supplierId: supplier.id, occurredAt: REGISTERED_AT }, diff --git a/tests/modules/partners/adapters/persistence/repos/legacy-entity-store.in-memory.test.ts b/tests/modules/partners/adapters/persistence/repos/legacy-entity-store.in-memory.test.ts index 1bd3d8f06..4c86378a7 100644 --- a/tests/modules/partners/adapters/persistence/repos/legacy-entity-store.in-memory.test.ts +++ b/tests/modules/partners/adapters/persistence/repos/legacy-entity-store.in-memory.test.ts @@ -19,7 +19,7 @@ const buildSupplier = (cnpj: string): SupplierEntity => { id: SupplierId.generate(), name: 'Fornecedor X', email: 'contato@fornecedor.com.br', - cnpj, + document: cnpj, corporateName: 'Fornecedor X LTDA', fantasyName: 'FX', serviceCategory: 'INFORMATICA', diff --git a/tests/modules/partners/adapters/persistence/repos/partners-etl-store-classify.test.ts b/tests/modules/partners/adapters/persistence/repos/partners-etl-store-classify.test.ts index 38e658cf9..47f7481c2 100644 --- a/tests/modules/partners/adapters/persistence/repos/partners-etl-store-classify.test.ts +++ b/tests/modules/partners/adapters/persistence/repos/partners-etl-store-classify.test.ts @@ -65,9 +65,9 @@ describe('classifyProvisionError (PARTNERS-ETL-STORE-INTEGRITY-ERROR)', () => { }); it('1062 numa UNIQUE secundaria de CNPJ -> integrity-violation (dado do legado, NAO infra)', () => { - // Arrange — mesma entidade (suppliers), mas o 1062 caiu no cnpj_idx, nao no legacy_id_idx + // Arrange — mesma entidade (suppliers), mas o 1062 caiu no document_idx, nao no legacy_id_idx const cause = mysql2DupError( - "Duplicate entry '11222333000181' for key 'par_suppliers_cnpj_idx'", + "Duplicate entry '11222333000181' for key 'par_suppliers_document_idx'", ); // Act const klass = classifyProvisionError(cause, LEGACY_IDX); @@ -132,7 +132,7 @@ describe('classifyProvisionError (PARTNERS-ETL-STORE-INTEGRITY-ERROR)', () => { it('o reason que cruza a public-api e PII-free: classe nao carrega o valor duplicado', () => { // Arrange — sqlMessage contem o CNPJ (PII) const pii = '11222333000181'; - const cause = mysql2DupError(`Duplicate entry '${pii}' for key 'par_suppliers_cnpj_idx'`); + const cause = mysql2DupError(`Duplicate entry '${pii}' for key 'par_suppliers_document_idx'`); // Act const klass = classifyProvisionError(cause, LEGACY_IDX); // Assert — a classe e um literal kebab-case fixo, jamais o valor diff --git a/tests/modules/partners/adapters/persistence/repos/supplier-repository.drizzle.test.ts b/tests/modules/partners/adapters/persistence/repos/supplier-repository.drizzle.test.ts index 47bea2e47..e06eb3b17 100644 --- a/tests/modules/partners/adapters/persistence/repos/supplier-repository.drizzle.test.ts +++ b/tests/modules/partners/adapters/persistence/repos/supplier-repository.drizzle.test.ts @@ -10,7 +10,7 @@ import { strict as assert } from 'node:assert'; import { isErr, isOk } from '#src/shared/index.ts'; import { ClockFixed } from '#src/shared/adapters/clock-fixed.ts'; -import * as Cnpj from '#src/shared/kernel/cnpj.ts'; +import * as SupplierDocument from '#src/modules/partners/domain/supplier/supplier-document.ts'; import * as SupplierId from '#src/modules/partners/domain/supplier/supplier-id.ts'; import * as Supplier from '#src/modules/partners/domain/supplier/supplier.ts'; import { @@ -43,7 +43,7 @@ const buildActive = ( id: SupplierId.generate(), name: 'Fornecedor X', email: 'contato@fornecedor.com.br', - cnpj: cnpjRaw, + document: cnpjRaw, corporateName: 'Fornecedor X LTDA', fantasyName: 'FX', serviceCategory: 'INFORMATICA', @@ -86,7 +86,7 @@ if (integrationEnabled()) { const found = await repo.findById(s.id); assert.equal(isOk(found), true); if (found.ok && found.value !== null) { - assert.equal(found.value.cnpj, s.cnpj); + assert.deepEqual(found.value.identity, s.identity); assert.equal(found.value.status, 'Active'); assert.equal(found.value.bankAccount?.accountNumber, '123456'); } @@ -104,13 +104,13 @@ if (integrationEnabled()) { } }); - it('findByCnpj acha o persistido', async () => { + it('findByDocument acha o persistido', async () => { if (handle === null) return; const repo = createDrizzleSupplierStore(handle, clock); await repo.save(buildActive('11222333000181'), []); - const c = Cnpj.parse('11222333000181'); + const c = SupplierDocument.parse('11222333000181'); if (c.ok) { - const found = await repo.findByCnpj(c.value); + const found = await repo.findByDocument(c.value); assert.equal(isOk(found), true); if (found.ok) assert.notEqual(found.value, null); } @@ -125,13 +125,13 @@ if (integrationEnabled()) { if (listed.ok) assert.equal(listed.value.length, 1); }); - it('CNPJ duplicado (id distinto) → supplier-cnpj-duplicate', async () => { + it('CNPJ duplicado (id distinto) → supplier-document-duplicate', async () => { if (handle === null) return; const repo = createDrizzleSupplierStore(handle, clock); await repo.save(buildActive('11222333000181'), []); const dup = await repo.save(buildActive('11.222.333/0001-81'), []); assert.equal(isErr(dup), true); - if (!dup.ok) assert.equal(dup.error, 'supplier-cnpj-duplicate'); + if (!dup.ok) assert.equal(dup.error, 'supplier-document-duplicate'); }); }); @@ -140,7 +140,7 @@ if (integrationEnabled()) { const registeredEvent = (s: ReturnType): SupplierEvent => ({ type: 'SupplierRegistered', supplierId: s.id, - cnpj: s.cnpj, + document: s.identity.document, occurredAt: clock.now(), }); @@ -165,7 +165,7 @@ if (integrationEnabled()) { const payload = JSON.parse(row.payload) as Record; assert.equal(payload['supplierRef'], String(s.id)); assert.equal(payload['name'], s.name); - assert.equal(payload['document'], String(s.cnpj)); + assert.equal(payload['document'], Supplier.documentOf(s)); }); it('rollback da escrita → 0 rows de outbox (CNPJ duplicado aborta a tx)', async () => { @@ -180,7 +180,7 @@ if (integrationEnabled()) { const dupSupplier = buildActive('11.222.333/0001-81'); const dup = await repo.save(dupSupplier, [registeredEvent(dupSupplier)]); assert.equal(isErr(dup), true); - if (!dup.ok) assert.equal(dup.error, 'supplier-cnpj-duplicate'); + if (!dup.ok) assert.equal(dup.error, 'supplier-document-duplicate'); const outboxAfter = await handle.db.select().from(handle.schema.parOutbox); // Nenhuma row nova de outbox para o supplier duplicado (atomicidade). diff --git a/tests/modules/partners/adapters/persistence/repos/suppliers-batch-reader.drizzle.test.ts b/tests/modules/partners/adapters/persistence/repos/suppliers-batch-reader.drizzle.test.ts index 45dded2e4..294515eea 100644 --- a/tests/modules/partners/adapters/persistence/repos/suppliers-batch-reader.drizzle.test.ts +++ b/tests/modules/partners/adapters/persistence/repos/suppliers-batch-reader.drizzle.test.ts @@ -1,6 +1,6 @@ // Integração e2e do batch reader (#356, CA7): par_suppliers → createDrizzleSuppliersBatchReader → // getSuppliersView(refs) resolve N refs em 1 query (`WHERE id IN (...)`, anti-N+1) contra MySQL real. -// items = refs existentes (só id/name/cnpj/service_category — minimização); missing = ausentes. +// items = refs existentes (só id/name/document/service_category — minimização); missing = ausentes. // GATE: só com MYSQL_INTEGRATION=1. import { describe, before, after, it } from 'node:test'; @@ -32,13 +32,13 @@ if (!process.env['MYSQL_INTEGRATION']) { const t = handle.schema.parSuppliers; // Limpa na ENTRADA por tabela, não por id: um irmão da suíte (supplier-repository, sem afterEach) // deixa resíduo com o MESMO CNPJ e id distinto — delete por id não o pega e colide na UNIQUE - // par_suppliers_cnpj_idx (#521). Contrato de isolamento intra-suíte do #535. + // par_suppliers_document_idx (#521). Contrato de isolamento intra-suíte do #535. await handle.db.delete(t); const row = (id: string, name: string, cnpj: string, cat: string) => ({ id, name, email: `${name.toLowerCase()}@fornecedor.com.br`, - cnpj, + document: cnpj, corporateName: `${name} LTDA`, fantasyName: name, serviceCategory: cat, diff --git a/tests/modules/partners/adapters/persistence/supplier-individual.mapper.test.ts b/tests/modules/partners/adapters/persistence/supplier-individual.mapper.test.ts new file mode 100644 index 000000000..0d7cd988b --- /dev/null +++ b/tests/modules/partners/adapters/persistence/supplier-individual.mapper.test.ts @@ -0,0 +1,93 @@ +/** + * #1022 — Mapper row ↔ agregado para o fornecedor pessoa física. + * + * A ausência de razão social/nome fantasia na PF vira NULL na coluna (nunca sentinela), e uma + * linha com CPF reidrata sem erro — antes do #1022 ela quebrava na leitura (`Cnpj.parse`). + */ + +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import * as Supplier from '#src/modules/partners/domain/supplier/supplier.ts'; +import * as SupplierId from '#src/modules/partners/domain/supplier/supplier-id.ts'; +import { + supplierFromRow, + supplierToInsert, +} from '#src/modules/partners/adapters/persistence/mappers/supplier.mapper.ts'; +import type { SupplierRow } from '#src/modules/partners/adapters/persistence/schemas/mysql.ts'; + +const NOW = new Date('2026-10-01T12:00:00.000Z'); +const CPF = '12345678909'; + +const individual = () => { + const r = Supplier.register({ + id: SupplierId.generate(), + name: 'Maria da Silva', + email: 'maria@example.com', + document: CPF, + corporateName: null, + fantasyName: null, + serviceCategory: 'INFORMATICA', + bankAccount: null, + pixKey: { keyType: 'cpf', key: CPF }, + registeredAt: NOW, + }); + assert.ok(r.ok); + return r.value.supplier; +}; + +// A linha como o SELECT a devolveria: o insert com os opcionais resolvidos (`?? null`), que é o +// que o MySQL guarda para coluna nullable omitida. +const rowOf = (supplier: ReturnType): SupplierRow => { + const ins = supplierToInsert(supplier, NOW); + return { + id: ins.id, + name: ins.name, + email: ins.email, + document: ins.document, + corporateName: ins.corporateName ?? null, + fantasyName: ins.fantasyName ?? null, + serviceCategory: ins.serviceCategory, + active: ins.active ?? true, + deactivatedAt: ins.deactivatedAt ?? null, + bankAccountBank: ins.bankAccountBank ?? null, + bankAccountAgency: ins.bankAccountAgency ?? null, + bankAccountNumber: ins.bankAccountNumber ?? null, + bankAccountCheckDigit: ins.bankAccountCheckDigit ?? null, + pixKeyType: ins.pixKeyType ?? null, + pixKey: ins.pixKey ?? null, + serviceRating: ins.serviceRating ?? null, + ratingComment: ins.ratingComment ?? null, + createdAt: ins.createdAt, + updatedAt: ins.updatedAt, + legacyId: ins.legacyId ?? null, + }; +}; + +describe('supplier.mapper — pessoa física (#1022)', () => { + it('supplierToInsert grava o CPF em `document` e NULL em razão social/nome fantasia', () => { + const row = supplierToInsert(individual(), NOW); + assert.equal(row.document, CPF); + assert.equal(row.corporateName, null); + assert.equal(row.fantasyName, null); + }); + + it('supplierFromRow reidrata a linha com CPF como PF (round-trip)', () => { + const supplier = individual(); + const r = supplierFromRow(rowOf(supplier)); + assert.ok(r.ok, r.ok ? '' : r.error); + assert.deepEqual(r.value.identity, supplier.identity); + }); + + it('linha PF com razão social (estado que o CHECK proíbe) → supplier-mapper-invalid-state', () => { + const r = supplierFromRow({ ...rowOf(individual()), corporateName: 'Maria LTDA' }); + assert.ok(!r.ok); + assert.equal(r.error, 'supplier-mapper-invalid-state'); + }); + + it('documento de tamanho inválido na linha → supplier-mapper-invalid-document', () => { + const r = supplierFromRow({ ...rowOf(individual()), document: '1234567890' }); + assert.ok(!r.ok); + assert.equal(r.error, 'supplier-mapper-invalid-document'); + }); +}); diff --git a/tests/modules/partners/adapters/persistence/supplier.mapper.test.ts b/tests/modules/partners/adapters/persistence/supplier.mapper.test.ts index ddf59f21d..aef828a37 100644 --- a/tests/modules/partners/adapters/persistence/supplier.mapper.test.ts +++ b/tests/modules/partners/adapters/persistence/supplier.mapper.test.ts @@ -25,9 +25,9 @@ const baseInput = () => ({ id: SupplierId.generate(), name: 'Fornecedor X', email: 'contato@fornecedor.com.br', - cnpj: '11.222.333/0001-81', - corporateName: 'Fornecedor X LTDA', - fantasyName: 'FX', + document: '11.222.333/0001-81', + corporateName: 'Fornecedor X LTDA' as string | null, + fantasyName: 'FX' as string | null, serviceCategory: 'INFORMATICA', bankAccount: bankInput() as ReturnType | null, pixKey: null as ReturnType | null, @@ -45,7 +45,9 @@ describe('supplier.mapper — supplierToInsert', () => { const row = supplierToInsert(makeActive({ bankAccount: bankInput(), pixKey: null }), NOW); assert.equal(row.active, true); assert.equal(row.deactivatedAt, null); - assert.equal(row.cnpj, '11222333000181'); + assert.equal(row.document, '11222333000181'); + assert.equal(row.corporateName, 'Fornecedor X LTDA'); + assert.equal(row.fantasyName, 'FX'); assert.equal(row.bankAccountBank, '001'); assert.equal(row.bankAccountAgency, '0001-2'); assert.equal(row.bankAccountNumber, '123456'); @@ -78,7 +80,7 @@ describe('supplier.mapper — supplierFromRow', () => { id: '7f3a1234-5678-4abc-9def-fedcba987654', name: 'Fornecedor X', email: 'contato@fornecedor.com.br', - cnpj: '11222333000181', + document: '11222333000181', corporateName: 'Fornecedor X LTDA', fantasyName: 'FX', serviceCategory: 'INFORMATICA', @@ -141,7 +143,7 @@ describe('supplier.mapper — supplierFromRow', () => { assert.equal(isOk(back), true); if (back.ok) { assert.equal(back.value.id, s.id); - assert.equal(back.value.cnpj, s.cnpj); + assert.deepEqual(back.value.identity, s.identity); assert.equal(back.value.bankAccount?.accountNumber, '123456'); } }); @@ -161,8 +163,10 @@ describe('supplier.mapper — supplierFromRow', () => { assert.equal(isErr(supplierFromRow({ ...bankRow, id: 'not-a-uuid' })), true); }); - it('rejeita cnpj inválido na row', () => { - assert.equal(isErr(supplierFromRow({ ...bankRow, cnpj: '11222333000180' })), true); + it('rejeita documento inválido na row', () => { + const r = supplierFromRow({ ...bankRow, document: '11222333000180' }); + assert.equal(isErr(r), true); + if (!r.ok) assert.equal(r.error, 'supplier-mapper-invalid-document'); }); it('rejeita row sem destino de pagamento (bank e pix nulos)', () => { diff --git a/tests/modules/partners/application/supplier-usecases.test.ts b/tests/modules/partners/application/supplier-usecases.test.ts index 225f54f04..8c3281646 100644 --- a/tests/modules/partners/application/supplier-usecases.test.ts +++ b/tests/modules/partners/application/supplier-usecases.test.ts @@ -10,7 +10,7 @@ import { registerSupplier } from '#src/modules/partners/application/use-cases/re import { deactivateSupplier } from '#src/modules/partners/application/use-cases/deactivate-supplier.ts'; import { reactivateSupplier } from '#src/modules/partners/application/use-cases/reactivate-supplier.ts'; import { listSuppliers } from '#src/modules/partners/application/use-cases/list-suppliers.ts'; -import { findSupplierByCnpj } from '#src/modules/partners/application/use-cases/find-supplier-by-cnpj.ts'; +import { findSupplierByDocument } from '#src/modules/partners/application/use-cases/find-supplier-by-document.ts'; const NOW = new Date('2026-06-01T12:00:00.000Z'); const clock: Clock = { now: () => NOW, today: () => PlainDate.fromDate(NOW) }; @@ -18,12 +18,12 @@ const clock: Clock = { now: () => NOW, today: () => PlainDate.fromDate(NOW) }; let repo: SupplierRepository; let store: ReturnType; -const validCmd = (cnpj = '11.222.333/0001-81') => ({ +const validCmd = (document = '11.222.333/0001-81') => ({ name: 'Gráfica Boa Impressão', email: 'contato@boaimpressao.com.br', - cnpj, - corporateName: 'Boa Impressão Gráfica LTDA', - fantasyName: 'Boa Impressão', + document, + corporateName: 'Boa Impressão Gráfica LTDA' as string | null, + fantasyName: 'Boa Impressão' as string | null, serviceCategory: 'GRAFICA', bankAccount: { bank: '001', @@ -65,13 +65,13 @@ describe('registerSupplier', () => { await registerSupplier({ supplierRepo: repo, clock })(validCmd()); const dup = await registerSupplier({ supplierRepo: repo, clock })(validCmd()); assert.equal(isErr(dup), true); - if (!dup.ok) assert.equal(dup.error, 'register-supplier-cnpj-duplicate'); + if (!dup.ok) assert.equal(dup.error, 'register-supplier-document-duplicate'); }); it('rejeita CNPJ inválido', async () => { const r = await registerSupplier({ supplierRepo: repo, clock })(validCmd('11222333000180')); assert.equal(isErr(r), true); - if (!r.ok) assert.equal(r.error, 'invalid-cnpj'); + if (!r.ok) assert.equal(r.error, 'invalid-supplier-document'); }); it('rejeita sem destino de pagamento (nem bankAccount nem pixKey)', async () => { @@ -158,14 +158,16 @@ describe('queries', () => { if (r.ok) assert.equal(r.value.length, 1); }); - it('findSupplierByCnpj acha por CNPJ e retorna null quando ausente', async () => { + it('findSupplierByDocument acha por CNPJ e retorna null quando ausente', async () => { await registerSupplier({ supplierRepo: repo, clock })(validCmd()); - const found = await findSupplierByCnpj({ supplierRepo: repo })({ cnpj: '11222333000181' }); + const found = await findSupplierByDocument({ supplierRepo: repo })({ + document: '11222333000181', + }); assert.equal(isOk(found), true); if (found.ok) assert.notEqual(found.value, null); - const missing = await findSupplierByCnpj({ supplierRepo: repo })({ - cnpj: '04.252.011/0001-10', + const missing = await findSupplierByDocument({ supplierRepo: repo })({ + document: '04.252.011/0001-10', }); assert.equal(isOk(missing), true); if (missing.ok) assert.equal(missing.value, null); @@ -179,6 +181,6 @@ describe('adapter InMemory', () => { const clone = { ...reg.value.supplier, id: '00000000-0000-4000-8000-000000000000' }; const saved = await repo.save(clone as typeof reg.value.supplier, []); assert.equal(isErr(saved), true); - if (!saved.ok) assert.equal(saved.error, 'supplier-cnpj-duplicate'); + if (!saved.ok) assert.equal(saved.error, 'supplier-document-duplicate'); }); }); diff --git a/tests/modules/partners/application/use-cases/cnpj-search-alphanumeric.test.ts b/tests/modules/partners/application/use-cases/cnpj-search-alphanumeric.test.ts index 212907299..9005bbf42 100644 --- a/tests/modules/partners/application/use-cases/cnpj-search-alphanumeric.test.ts +++ b/tests/modules/partners/application/use-cases/cnpj-search-alphanumeric.test.ts @@ -35,7 +35,7 @@ const supplier = (cnpj: string) => { id: SupplierId.generate(), name: 'ACME Alimentos', email: 'contato@acme.com.br', - cnpj, + document: cnpj, corporateName: 'ACME Alimentos LTDA', fantasyName: 'ACME', serviceCategory: 'INFORMATICA', diff --git a/tests/modules/partners/application/use-cases/list-suppliers-search.test.ts b/tests/modules/partners/application/use-cases/list-suppliers-search.test.ts index d739518cf..ab43f36ef 100644 --- a/tests/modules/partners/application/use-cases/list-suppliers-search.test.ts +++ b/tests/modules/partners/application/use-cases/list-suppliers-search.test.ts @@ -9,7 +9,7 @@ const baseInput = () => ({ id: SupplierId.generate(), name: 'ACME Alimentos', email: 'contato@acme.com.br', - cnpj: '11.222.333/0001-81', + document: '11.222.333/0001-81', corporateName: 'ACME Alimentos LTDA', fantasyName: 'ACME', serviceCategory: 'INFORMATICA', diff --git a/tests/modules/partners/application/use-cases/supplier-outbox.test.ts b/tests/modules/partners/application/use-cases/supplier-outbox.test.ts index 049c483c1..3a86e65db 100644 --- a/tests/modules/partners/application/use-cases/supplier-outbox.test.ts +++ b/tests/modules/partners/application/use-cases/supplier-outbox.test.ts @@ -30,12 +30,12 @@ const clock: Clock = { now: () => NOW, today: () => PlainDate.fromDate(NOW) }; let outbox: ReturnType; let store: ReturnType; -const validCmd = (cnpj = '11.222.333/0001-81') => ({ +const validCmd = (document = '11.222.333/0001-81') => ({ name: 'Gráfica Boa Impressão', email: 'contato@boaimpressao.com.br', - cnpj, - corporateName: 'Boa Impressão Gráfica LTDA', - fantasyName: 'Boa Impressão', + document, + corporateName: 'Boa Impressão Gráfica LTDA' as string | null, + fantasyName: 'Boa Impressão' as string | null, serviceCategory: 'GRAFICA', bankAccount: { bank: '001', agency: '1234', accountNumber: '56789', checkDigit: '0' }, pixKey: null, diff --git a/tests/modules/partners/domain/supplier/supplier-document.test.ts b/tests/modules/partners/domain/supplier/supplier-document.test.ts new file mode 100644 index 000000000..b512871b5 --- /dev/null +++ b/tests/modules/partners/domain/supplier/supplier-document.test.ts @@ -0,0 +1,74 @@ +/** + * #1022 — `SupplierDocument`: CPF (PF) ou CNPJ (PJ), decidido pelo formato e validado pelo VO do + * kernel. Os documentos são sintéticos (DV calculado), nunca dado real de cadastro. + */ + +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import * as SupplierDocument from '#src/modules/partners/domain/supplier/supplier-document.ts'; + +const CPF = '12345678909'; +const CNPJ = '11222333000181'; +const CNPJ_ALPHANUMERIC = '12ABC34501DE35'; + +describe('SupplierDocument.parse (#1022)', () => { + it('11 dígitos com DV válido → CPF', () => { + const r = SupplierDocument.parse(CPF); + assert.ok(r.ok); + assert.deepEqual(r.value, { kind: 'cpf', value: CPF }); + }); + + it('CPF com máscara → normaliza para 11 dígitos', () => { + const r = SupplierDocument.parse('123.456.789-09'); + assert.ok(r.ok); + assert.equal(SupplierDocument.toRaw(r.value), CPF); + }); + + it('14 caracteres → CNPJ', () => { + const r = SupplierDocument.parse(CNPJ); + assert.ok(r.ok); + assert.deepEqual(r.value, { kind: 'cnpj', value: CNPJ }); + }); + + it('CNPJ alfanumérico (ADR-0044) → CNPJ, nunca tentado como CPF', () => { + const r = SupplierDocument.parse(CNPJ_ALPHANUMERIC.toLowerCase()); + assert.ok(r.ok); + assert.deepEqual(r.value, { kind: 'cnpj', value: CNPJ_ALPHANUMERIC }); + }); + + it('CPF com DV errado → invalid-supplier-document', () => { + const r = SupplierDocument.parse('12345678900'); + assert.ok(!r.ok); + assert.equal(r.error, 'invalid-supplier-document'); + }); + + it('CNPJ com DV errado → invalid-supplier-document', () => { + const r = SupplierDocument.parse('11222333000180'); + assert.ok(!r.ok); + assert.equal(r.error, 'invalid-supplier-document'); + }); + + it('letra em documento de 11 caracteres vai para o CNPJ e é recusada (não vira CPF)', () => { + const r = SupplierDocument.parse('1234567890A'); + assert.ok(!r.ok); + assert.equal(r.error, 'invalid-supplier-document'); + }); + + for (const raw of ['', '1234567890', '123456789012', '1122233300018']) { + it(`tamanho fora de 11/14 ("${raw}") → invalid-supplier-document`, () => { + const r = SupplierDocument.parse(raw); + assert.ok(!r.ok); + assert.equal(r.error, 'invalid-supplier-document'); + }); + } + + it('equals compara tipo e valor', () => { + const a = SupplierDocument.parse(CPF); + const b = SupplierDocument.parse('123.456.789-09'); + const c = SupplierDocument.parse(CNPJ); + assert.ok(a.ok && b.ok && c.ok); + assert.equal(SupplierDocument.equals(a.value, b.value), true); + assert.equal(SupplierDocument.equals(a.value, c.value), false); + }); +}); diff --git a/tests/modules/partners/domain/supplier/supplier-individual.test.ts b/tests/modules/partners/domain/supplier/supplier-individual.test.ts new file mode 100644 index 000000000..0b5079d85 --- /dev/null +++ b/tests/modules/partners/domain/supplier/supplier-individual.test.ts @@ -0,0 +1,211 @@ +/** + * #1022 — Fornecedor pessoa física: invariante PF × PJ no agregado `Supplier`. + * + * PF (CPF) não tem razão social nem nome fantasia — o campo não existe no tipo. Preenchidos são + * RECUSADOS (não ignorados); branco conta como ausente. PJ (CNPJ) continua exigindo os dois. + */ + +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import * as SupplierId from '#src/modules/partners/domain/supplier/supplier-id.ts'; +import * as Supplier from '#src/modules/partners/domain/supplier/supplier.ts'; +import * as SupplierDocument from '#src/modules/partners/domain/supplier/supplier-document.ts'; +import * as PaymentTarget from '#src/modules/partners/domain/shared/payment-target.ts'; +import type { ServiceCategory } from '#src/modules/partners/domain/supplier/service-category.ts'; + +const NOW = new Date('2026-10-01T12:00:00.000Z'); +const LATER = new Date('2026-10-02T12:00:00.000Z'); +const CPF = '12345678909'; +const CNPJ = '11222333000181'; + +const individualInput = (over: Record = {}) => ({ + id: SupplierId.generate(), + name: 'Maria da Silva', + email: 'maria@example.com', + document: CPF, + corporateName: null as string | null, + fantasyName: null as string | null, + serviceCategory: 'INFORMATICA', + bankAccount: { bank: '001', agency: '0001-2', accountNumber: '123456', checkDigit: '7' }, + pixKey: null, + registeredAt: NOW, + ...over, +}); + +const companyEdit = (over: Record = {}) => ({ + name: 'Maria da Silva', + email: 'maria@example.com', + document: CNPJ, + corporateName: 'Maria da Silva Serviços LTDA' as string | null, + fantasyName: 'MS Serviços' as string | null, + serviceCategory: 'INFORMATICA', + bankAccount: { bank: '001', agency: '0001-2', accountNumber: '123456', checkDigit: '7' }, + pixKey: null, + ...over, +}); + +const registerIndividual = () => { + const r = Supplier.register(individualInput()); + assert.ok(r.ok); + return r.value.supplier; +}; + +describe('Supplier.register — pessoa física (#1022)', () => { + it('CPF válido sem razão social/nome fantasia → PF, sem os campos', () => { + const supplier = registerIndividual(); + assert.equal(supplier.identity.personType, 'individual'); + assert.deepEqual(supplier.identity, { + personType: 'individual', + document: { kind: 'cpf', value: CPF }, + }); + assert.equal(Supplier.documentOf(supplier), CPF); + }); + + it('branco conta como ausente (a tela desativa os campos e pode mandar "")', () => { + const r = Supplier.register(individualInput({ corporateName: '', fantasyName: ' ' })); + assert.ok(r.ok); + assert.equal(r.value.supplier.identity.personType, 'individual'); + }); + + it('SupplierRegistered carrega o documento CPF', () => { + const r = Supplier.register(individualInput()); + assert.ok(r.ok); + assert.equal(r.value.event.type, 'SupplierRegistered'); + if (r.value.event.type === 'SupplierRegistered') { + assert.deepEqual(r.value.event.document, { kind: 'cpf', value: CPF }); + } + }); + + it('CPF com razão social → supplier-corporate-name-not-allowed-for-pf', () => { + const r = Supplier.register(individualInput({ corporateName: 'Maria LTDA' })); + assert.ok(!r.ok); + assert.equal(r.error, 'supplier-corporate-name-not-allowed-for-pf'); + }); + + it('CPF com nome fantasia → supplier-fantasy-name-not-allowed-for-pf', () => { + const r = Supplier.register(individualInput({ fantasyName: 'Maria' })); + assert.ok(!r.ok); + assert.equal(r.error, 'supplier-fantasy-name-not-allowed-for-pf'); + }); + + it('CPF com DV errado → invalid-supplier-document', () => { + const r = Supplier.register(individualInput({ document: '12345678900' })); + assert.ok(!r.ok); + assert.equal(r.error, 'invalid-supplier-document'); + }); + + it('CNPJ sem razão social → supplier-corporate-name-required (PJ como sempre)', () => { + const r = Supplier.register(individualInput({ document: CNPJ, fantasyName: 'FX' })); + assert.ok(!r.ok); + assert.equal(r.error, 'supplier-corporate-name-required'); + }); + + it('CNPJ sem nome fantasia → supplier-fantasy-name-required', () => { + const r = Supplier.register(individualInput({ document: CNPJ, corporateName: 'FX LTDA' })); + assert.ok(!r.ok); + assert.equal(r.error, 'supplier-fantasy-name-required'); + }); +}); + +describe('Supplier.edit — troca PF ↔ PJ (#1022)', () => { + it('PF → PJ: passa a exigir razão social e nome fantasia', () => { + const pf = registerIndividual(); + const missing = Supplier.edit(pf, companyEdit({ corporateName: null }), LATER); + assert.ok(!missing.ok); + assert.equal(missing.error, 'supplier-corporate-name-required'); + + const r = Supplier.edit(pf, companyEdit(), LATER); + assert.ok(r.ok); + assert.equal(r.value.supplier.id, pf.id); + assert.deepEqual(r.value.supplier.identity, { + personType: 'company', + document: { kind: 'cnpj', value: CNPJ }, + corporateName: 'Maria da Silva Serviços LTDA', + fantasyName: 'MS Serviços', + }); + }); + + it('PJ → PF: razão social que sobrou do cadastro PJ é recusada, não descartada', () => { + const pf = registerIndividual(); + const pj = Supplier.edit(pf, companyEdit(), LATER); + assert.ok(pj.ok); + const back = Supplier.edit( + pj.value.supplier, + companyEdit({ document: CPF, fantasyName: null }), + LATER, + ); + assert.ok(!back.ok); + assert.equal(back.error, 'supplier-corporate-name-not-allowed-for-pf'); + + const ok = Supplier.edit( + pj.value.supplier, + companyEdit({ document: CPF, corporateName: null, fantasyName: null }), + LATER, + ); + assert.ok(ok.ok); + assert.equal(ok.value.supplier.identity.personType, 'individual'); + }); +}); + +describe('Supplier.rehydrate — identidade persistida (#1022)', () => { + const cpfDocument = () => { + const d = SupplierDocument.parse(CPF); + assert.ok(d.ok); + return d.value; + }; + const pixKey = () => { + const p = PaymentTarget.createPixKey({ keyType: 'email', key: 'maria@example.com' }); + assert.ok(p.ok); + return p.value; + }; + const base = () => ({ + id: SupplierId.generate(), + name: 'Maria da Silva', + email: 'maria@example.com', + serviceCategory: 'INFORMATICA' as ServiceCategory, + bankAccount: null, + pixKey: pixKey(), + serviceRating: null, + ratingComment: null, + status: 'Active' as const, + deactivatedAt: null, + }); + + it('PF sem razão social/nome fantasia reidrata', () => { + const r = Supplier.rehydrate({ + ...base(), + document: cpfDocument(), + corporateName: null, + fantasyName: null, + }); + assert.ok(r.ok); + assert.equal(r.value.identity.personType, 'individual'); + }); + + it('PF com razão social persistida → recusa (estado que o CHECK do banco proíbe)', () => { + const r = Supplier.rehydrate({ + ...base(), + document: cpfDocument(), + corporateName: 'Maria LTDA', + fantasyName: null, + }); + assert.ok(!r.ok); + assert.equal(r.error, 'supplier-corporate-name-not-allowed-for-pf'); + }); + + it('PJ persistida com razão social em branco reidrata (o CHECK só exige NOT NULL)', () => { + // Recusar aqui derrubaria o `list()` inteiro por uma linha gravada por fora do domínio. + // Exigir "não-branco" é da ENTRADA (register/edit), não da leitura. + const cnpj = SupplierDocument.parse('11222333000181'); + assert.ok(cnpj.ok); + const r = Supplier.rehydrate({ + ...base(), + document: cnpj.value, + corporateName: '', + fantasyName: ' ', + }); + assert.ok(r.ok, r.ok ? '' : r.error); + assert.equal(r.value.identity.personType, 'company'); + }); +}); diff --git a/tests/modules/partners/domain/supplier/supplier-rating.test.ts b/tests/modules/partners/domain/supplier/supplier-rating.test.ts index 01ce570c2..ada93cea0 100644 --- a/tests/modules/partners/domain/supplier/supplier-rating.test.ts +++ b/tests/modules/partners/domain/supplier/supplier-rating.test.ts @@ -22,9 +22,9 @@ const baseInput = () => ({ id: SupplierId.generate(), name: 'Fornecedor X', email: 'contato@fornecedor.com.br', - cnpj: '11.222.333/0001-81', - corporateName: 'Fornecedor X LTDA', - fantasyName: 'FX', + document: '11.222.333/0001-81', + corporateName: 'Fornecedor X LTDA' as string | null, + fantasyName: 'FX' as string | null, serviceCategory: 'INFORMATICA', bankAccount: { bank: '001', agency: '0001-2', accountNumber: '123456', checkDigit: '7' }, pixKey: null, @@ -75,9 +75,9 @@ describe('Supplier.edit — avaliação', () => { { name: s.name, email: s.email, - cnpj: '11.222.333/0001-81', - corporateName: s.corporateName, - fantasyName: s.fantasyName, + document: '11.222.333/0001-81', + corporateName: 'Fornecedor X LTDA', + fantasyName: 'FX', serviceCategory: s.serviceCategory, bankAccount: { bank: '001', agency: '0001-2', accountNumber: '123456', checkDigit: '7' }, pixKey: null, @@ -100,7 +100,7 @@ describe('Supplier.rehydrate — avaliação', () => { id: SupplierId.generate(), name: 'F', email: 'a@b.com', - cnpj: { value: '11222333000181' } as never, + document: { kind: 'cnpj', value: '11222333000181' } as never, corporateName: 'F LTDA', fantasyName: 'F', serviceCategory: 'INFORMATICA' as never, diff --git a/tests/modules/partners/domain/supplier/supplier.test.ts b/tests/modules/partners/domain/supplier/supplier.test.ts index b47275c0f..86db436f4 100644 --- a/tests/modules/partners/domain/supplier/supplier.test.ts +++ b/tests/modules/partners/domain/supplier/supplier.test.ts @@ -23,9 +23,9 @@ const baseInput = () => ({ id: SupplierId.generate(), name: 'Fornecedor X', email: 'contato@fornecedor.com.br', - cnpj: '11.222.333/0001-81', - corporateName: 'Fornecedor X LTDA', - fantasyName: 'FX', + document: '11.222.333/0001-81', + corporateName: 'Fornecedor X LTDA' as string | null, + fantasyName: 'FX' as string | null, serviceCategory: 'INFORMATICA', bankAccount: bankInput() as ReturnType | null, pixKey: null as ReturnType | null, @@ -68,12 +68,13 @@ describe('PaymentTarget VOs', () => { }); describe('Supplier.register', () => { - it('cria Active com banco e emite SupplierRegistered (cnpj normalizado)', () => { + it('cria Active com banco e emite SupplierRegistered (documento normalizado)', () => { const r = Supplier.register(validInput()); assert.equal(isOk(r), true); if (r.ok) { assert.equal(r.value.supplier.status, 'Active'); - assert.equal(r.value.supplier.cnpj as unknown as string, '11222333000181'); + assert.equal(Supplier.documentOf(r.value.supplier), '11222333000181'); + assert.equal(r.value.supplier.identity.personType, 'company'); assert.notEqual(r.value.supplier.bankAccount, null); assert.equal(r.value.supplier.pixKey, null); assert.equal(r.value.event.type, 'SupplierRegistered'); @@ -99,7 +100,9 @@ describe('Supplier.register', () => { }); it('rejeita CNPJ inválido e serviceCategory desconhecida', () => { - assert.equal(isErr(Supplier.register(validInput({ cnpj: '11222333000180' }))), true); + const doc = Supplier.register(validInput({ document: '11222333000180' })); + assert.equal(isErr(doc), true); + if (!doc.ok) assert.equal(doc.error, 'invalid-supplier-document'); const sc = Supplier.register(validInput({ serviceCategory: 'NAO_EXISTE' })); assert.equal(isErr(sc), true); if (!sc.ok) assert.equal(sc.error, 'invalid-service-category'); diff --git a/tests/modules/partners/public-api/contractor-view.mapper.test.ts b/tests/modules/partners/public-api/contractor-view.mapper.test.ts index 6f730bbb7..b3334f902 100644 --- a/tests/modules/partners/public-api/contractor-view.mapper.test.ts +++ b/tests/modules/partners/public-api/contractor-view.mapper.test.ts @@ -35,7 +35,7 @@ const aSupplier = () => { id: SupplierId.generate(), name: 'Fornecedor X', email: 'contato@fornecedor.com.br', - cnpj: '11222333000181', + document: '11222333000181', corporateName: 'Fornecedor X LTDA', fantasyName: 'FX', serviceCategory: 'INFORMATICA', @@ -101,7 +101,7 @@ const anAct = () => { }; describe('supplierToView', () => { - it('projeta nome, documento (cnpj), email, categoria + updatedAt', () => { + it('projeta nome, documento (CNPJ), email, categoria + updatedAt', () => { const s = aSupplier(); const view = supplierToView(s, UPDATED_AT); @@ -109,7 +109,7 @@ describe('supplierToView', () => { assert.equal(view.id, s.id as unknown as string); assert.equal(view.name, 'Fornecedor X'); assert.equal(view.email, 'contato@fornecedor.com.br'); - assert.equal(view.document, s.cnpj as unknown as string); + assert.equal(view.document, '11222333000181'); assert.equal(view.serviceCategory, 'INFORMATICA'); assert.deepEqual(view.updatedAt, UPDATED_AT); }); @@ -132,7 +132,7 @@ describe('supplierToView', () => { id: SupplierId.generate(), name: 'Só PIX', email: 'sopix@x.com', - cnpj: '11444777000161', + document: '11444777000161', corporateName: 'Só PIX LTDA', fantasyName: 'SP', serviceCategory: 'INFORMATICA', diff --git a/tests/modules/partners/public-api/partners-etl-port.integration.test.ts b/tests/modules/partners/public-api/partners-etl-port.integration.test.ts index 218cc280e..6d9a489fe 100644 --- a/tests/modules/partners/public-api/partners-etl-port.integration.test.ts +++ b/tests/modules/partners/public-api/partners-etl-port.integration.test.ts @@ -35,7 +35,7 @@ const aSupplier = (cnpj: string) => { id: SupplierId.generate(), name: 'Fornecedor X', email: 'contato@fornecedor.com.br', - cnpj, + document: cnpj, corporateName: 'Fornecedor X LTDA', fantasyName: 'FX', serviceCategory: 'INFORMATICA', diff --git a/tests/modules/partners/public-api/partners-etl-store-integrity.integration.test.ts b/tests/modules/partners/public-api/partners-etl-store-integrity.integration.test.ts index 7ffde886d..ef0ee78d8 100644 --- a/tests/modules/partners/public-api/partners-etl-store-integrity.integration.test.ts +++ b/tests/modules/partners/public-api/partners-etl-store-integrity.integration.test.ts @@ -38,7 +38,7 @@ const aSupplier = (cnpj: string) => { id: SupplierId.generate(), name: 'Fornecedor X', email: 'contato@fornecedor.com.br', - cnpj, + document: cnpj, corporateName: 'Fornecedor X LTDA', fantasyName: 'FX', serviceCategory: 'INFORMATICA', @@ -103,7 +103,7 @@ if (integrationEnabled()) { const first = await port.suppliers.provision(aSupplier(cnpj), 100); assert.equal(first.ok && first.value === 'created', true); - // legacy_id 200 (distinto) com o MESMO cnpj -> colide em par_suppliers_cnpj_idx + // legacy_id 200 (distinto) com o MESMO documento -> colide em par_suppliers_document_idx const second = await port.suppliers.provision(aSupplier(cnpj), 200); assert.equal(second.ok, false); if (!second.ok) { diff --git a/tests/modules/partners/public-api/partners-read-port.integration.test.ts b/tests/modules/partners/public-api/partners-read-port.integration.test.ts index aa301b9c5..90d8cd55c 100644 --- a/tests/modules/partners/public-api/partners-read-port.integration.test.ts +++ b/tests/modules/partners/public-api/partners-read-port.integration.test.ts @@ -41,7 +41,7 @@ const aSupplier = () => { id: SupplierId.generate(), name: 'Fornecedor X', email: 'contato@fornecedor.com.br', - cnpj: '11222333000181', + document: '11222333000181', corporateName: 'Fornecedor X LTDA', fantasyName: 'FX', serviceCategory: 'INFORMATICA',