feat(resellers): expande cadastro (dados PJ, endereço, contatos, sócios) + busca de CNPJ

Mesma expansão feita no Cadastro de Cliente, aplicada a Revenda:
razão social/nome fantasia, inscrição estadual, natureza jurídica,
endereço, contatos e sócios — tabela reseller_partners nova. Endpoint
POST /v1/resellers/:id/cnpj-lookup reaproveita o adapter lookupCnpj já
existente (@eden/integrations), sem duplicar a lógica de consulta.

Decisão deliberada: revenda não ganhou person_type (sempre tratada
como PJ) e document continua livremente editável via PATCH genérico
(diferente de client_registrations, que bloqueia pj_cnpj de propósito)
— a busca de CNPJ aqui é só conveniência adicional, não uma trava nova.

Suíte: 106/106 (100→106). pnpm turbo run typecheck build: 28/28.
Testado ao vivo com CNPJ real (Petrobras, 10 sócios).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 14:47:23 -03:00
parent 9e629bb394
commit 2ae61dba22
11 changed files with 973 additions and 8 deletions

View File

@@ -0,0 +1,104 @@
/**
* Expands the minimalist operational `resellers` cadastro (eden.md §1.7)
* with the same PJ profile fields, address block, three contact roles, and
* sócios/QSA table already shipped for `client_registrations` in this
* session — enabling the same CNPJ-lookup autofill
* (`packages/integrations/src/cnpj-lookup/lookup.ts`, `lookupCnpj`) for
* Revenda. Mirrors `client_registrations`' `pj_*`/`address_*`/`contact_*`
* shape, but WITHOUT the `pj_` prefix — there's no PF/PJ ambiguity on this
* table (a reseller is always a company; `resellers.name` already plays
* the role `pj_company_name` plays there, so only `trade_name` is new for
* the "nome" side). No `person_type` column is added — deliberate, see
* task notes; a reseller cadastro doesn't bifurcate PF/PJ.
*
* Deliberate divergence from `client_registrations`: `document` here stays
* freely editable via the existing generic `PATCH /v1/resellers/:id` —
* unlike `pj_cnpj`/`pf_cpf` there, which are locked out of the generic
* admin PATCH. The CNPJ-lookup endpoint added alongside this migration
* (`POST /v1/resellers/:id/cnpj-lookup`) is purely an additional
* convenience action, not a replacement for manual editing.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
ALTER TABLE resellers
ADD COLUMN trade_name TEXT,
ADD COLUMN state_registration TEXT,
ADD COLUMN municipal_registration TEXT,
ADD COLUMN opening_date DATE,
ADD COLUMN legal_nature TEXT,
ADD COLUMN cnpj_situation TEXT,
ADD COLUMN main_activity TEXT,
ADD COLUMN address_zip TEXT,
ADD COLUMN address_street TEXT,
ADD COLUMN address_number TEXT,
ADD COLUMN address_complement TEXT,
ADD COLUMN address_neighborhood TEXT,
ADD COLUMN address_city TEXT,
ADD COLUMN address_state TEXT,
ADD COLUMN address_country TEXT NOT NULL DEFAULT 'Brasil',
ADD COLUMN contact_principal_name TEXT,
ADD COLUMN contact_principal_email TEXT,
ADD COLUMN contact_principal_phone TEXT,
ADD COLUMN contact_financial_name TEXT,
ADD COLUMN contact_financial_email TEXT,
ADD COLUMN contact_financial_phone TEXT,
ADD COLUMN contact_technical_name TEXT,
ADD COLUMN contact_technical_email TEXT,
ADD COLUMN contact_technical_phone TEXT;
COMMENT ON COLUMN resellers.document IS
'CPF/CNPJ livre (sem CHECK de dígito no banco, validado na camada de aplicação) — ao contrário de client_registrations.pj_cnpj/pf_cpf, continua editável via PATCH genérico (decisão deliberada, não replicar a trava de lá aqui).';
CREATE TABLE reseller_partners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reseller_id UUID NOT NULL REFERENCES resellers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
document TEXT,
qualification TEXT,
source TEXT NOT NULL DEFAULT 'manual',
cpf TEXT,
will_sign BOOLEAN NOT NULL DEFAULT false,
signature_email TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE reseller_partners IS
'Sócios/QSA da revenda — mesmo shape de client_registration_partners. source default "manual"; a rota de CNPJ-lookup grava "cnpj_lookup" para os sócios que vierem da API pública.';
CREATE INDEX idx_reseller_partners_reseller ON reseller_partners(reseller_id);
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS reseller_partners;
ALTER TABLE resellers
DROP COLUMN IF EXISTS trade_name,
DROP COLUMN IF EXISTS state_registration,
DROP COLUMN IF EXISTS municipal_registration,
DROP COLUMN IF EXISTS opening_date,
DROP COLUMN IF EXISTS legal_nature,
DROP COLUMN IF EXISTS cnpj_situation,
DROP COLUMN IF EXISTS main_activity,
DROP COLUMN IF EXISTS address_zip,
DROP COLUMN IF EXISTS address_street,
DROP COLUMN IF EXISTS address_number,
DROP COLUMN IF EXISTS address_complement,
DROP COLUMN IF EXISTS address_neighborhood,
DROP COLUMN IF EXISTS address_city,
DROP COLUMN IF EXISTS address_state,
DROP COLUMN IF EXISTS address_country,
DROP COLUMN IF EXISTS contact_principal_name,
DROP COLUMN IF EXISTS contact_principal_email,
DROP COLUMN IF EXISTS contact_principal_phone,
DROP COLUMN IF EXISTS contact_financial_name,
DROP COLUMN IF EXISTS contact_financial_email,
DROP COLUMN IF EXISTS contact_financial_phone,
DROP COLUMN IF EXISTS contact_technical_name,
DROP COLUMN IF EXISTS contact_technical_email,
DROP COLUMN IF EXISTS contact_technical_phone;
`);
};

View File

@@ -8,6 +8,7 @@ export * as companiesRepo from "./repositories/companies.js";
export * as productsRepo from "./repositories/products.js";
export * as productCatalogsRepo from "./repositories/product-catalogs.js";
export * as resellersRepo from "./repositories/resellers.js";
export * as resellerPartnersRepo from "./repositories/reseller-partners.js";
export * as quotesRepo from "./repositories/quotes.js";
export * as contractsRepo from "./repositories/contracts.js";
export * as clientRegistrationsRepo from "./repositories/client-registrations.js";
@@ -55,7 +56,17 @@ export type {
CreateUnitInput,
UpdateUnitInput,
} from "./repositories/product-catalogs.js";
export type { Reseller, CreateResellerInput, UpdateResellerInput } from "./repositories/resellers.js";
export type {
Reseller,
CreateResellerInput,
UpdateResellerInput,
ApplyCnpjLookupInput as ApplyResellerCnpjLookupInput,
} from "./repositories/resellers.js";
export type {
ResellerPartner,
CreatePartnerInput as CreateResellerPartnerInput,
UpdatePartnerInput as UpdateResellerPartnerInput,
} from "./repositories/reseller-partners.js";
export type {
Lead,
ListLeadsFilter,

View File

@@ -0,0 +1,107 @@
import { query } from "../pool.js";
/**
* `reseller_partners` — sócios/QSA da revenda, mesmo shape de
* `client_registration_partners`. `source` defaults to `'manual'` (DB
* default) for hand-entered partners; rows created by the CNPJ-lookup flow
* (`ResellersService.applyCnpjLookup`, `POST /v1/resellers/:id/cnpj-lookup`)
* carry `source = 'cnpj_lookup'`.
*/
export interface ResellerPartner {
id: string;
reseller_id: string;
name: string;
document: string | null;
qualification: string | null;
source: string;
cpf: string | null;
will_sign: boolean;
signature_email: string | null;
created_at: Date;
}
export async function listPartnersForReseller(resellerId: string): Promise<ResellerPartner[]> {
const { rows } = await query<ResellerPartner>(
"SELECT * FROM reseller_partners WHERE reseller_id = $1 ORDER BY created_at ASC",
[resellerId],
);
return rows;
}
export async function getPartnerById(id: string): Promise<ResellerPartner | null> {
const { rows } = await query<ResellerPartner>("SELECT * FROM reseller_partners WHERE id = $1", [id]);
return rows[0] ?? null;
}
export interface CreatePartnerInput {
resellerId: string;
name: string;
document?: string | null;
qualification?: string | null;
cpf?: string | null;
willSign?: boolean;
signatureEmail?: string | null;
/**
* Server-only — never accepted from `CreatePartnerDto`. Defaults to
* `'manual'` (DB default) when omitted; the CNPJ-lookup flow
* (`ResellersService.applyCnpjLookup`) is the only caller that passes a
* non-default value.
*/
source?: string;
}
export async function createPartner(input: CreatePartnerInput): Promise<ResellerPartner> {
const { rows } = await query<ResellerPartner>(
`INSERT INTO reseller_partners
(reseller_id, name, document, qualification, source, cpf, will_sign, signature_email)
VALUES ($1, $2, $3, $4, COALESCE($5, 'manual'), $6, COALESCE($7, false), $8)
RETURNING *`,
[
input.resellerId,
input.name,
input.document ?? null,
input.qualification ?? null,
input.source ?? null,
input.cpf ?? null,
input.willSign ?? null,
input.signatureEmail ?? null,
],
);
const partner = rows[0];
if (!partner) throw new Error("failed to create reseller partner");
return partner;
}
/** Dedup key for the CNPJ-lookup flow: don't recreate a partner already recorded for this reseller+document. */
export async function findPartnerByDocument(
resellerId: string,
document: string,
): Promise<ResellerPartner | null> {
const { rows } = await query<ResellerPartner>(
"SELECT * FROM reseller_partners WHERE reseller_id = $1 AND document = $2 LIMIT 1",
[resellerId, document],
);
return rows[0] ?? null;
}
export interface UpdatePartnerInput {
cpf?: string | null;
willSign?: boolean;
signatureEmail?: string | null;
}
export async function updatePartner(
id: string,
input: UpdatePartnerInput,
): Promise<ResellerPartner | null> {
const { rows } = await query<ResellerPartner>(
`UPDATE reseller_partners SET
cpf = COALESCE($2, cpf),
will_sign = COALESCE($3, will_sign),
signature_email = COALESCE($4, signature_email)
WHERE id = $1
RETURNING *`,
[id, input.cpf ?? null, input.willSign ?? null, input.signatureEmail ?? null],
);
return rows[0] ?? null;
}

View File

@@ -9,6 +9,30 @@ export interface Reseller {
document: string | null;
contact_email: string | null;
active: boolean;
trade_name: string | null;
state_registration: string | null;
municipal_registration: string | null;
opening_date: Date | null;
legal_nature: string | null;
cnpj_situation: string | null;
main_activity: string | null;
address_zip: string | null;
address_street: string | null;
address_number: string | null;
address_complement: string | null;
address_neighborhood: string | null;
address_city: string | null;
address_state: string | null;
address_country: string;
contact_principal_name: string | null;
contact_principal_email: string | null;
contact_principal_phone: string | null;
contact_financial_name: string | null;
contact_financial_email: string | null;
contact_financial_phone: string | null;
contact_technical_name: string | null;
contact_technical_email: string | null;
contact_technical_phone: string | null;
}
export async function listResellers(includeInactive = false): Promise<Reseller[]> {
@@ -31,12 +55,54 @@ export interface CreateResellerInput {
contactEmail?: string | null;
active?: boolean;
createdBy?: string | null;
tradeName?: string | null;
stateRegistration?: string | null;
municipalRegistration?: string | null;
openingDate?: string | null;
legalNature?: string | null;
cnpjSituation?: string | null;
mainActivity?: string | null;
addressZip?: string | null;
addressStreet?: string | null;
addressNumber?: string | null;
addressComplement?: string | null;
addressNeighborhood?: string | null;
addressCity?: string | null;
addressState?: string | null;
addressCountry?: string | null;
contactPrincipalName?: string | null;
contactPrincipalEmail?: string | null;
contactPrincipalPhone?: string | null;
contactFinancialName?: string | null;
contactFinancialEmail?: string | null;
contactFinancialPhone?: string | null;
contactTechnicalName?: string | null;
contactTechnicalEmail?: string | null;
contactTechnicalPhone?: string | null;
}
export async function createReseller(input: CreateResellerInput): Promise<Reseller> {
const { rows } = await query<Reseller>(
`INSERT INTO resellers (name, document, contact_email, active, created_by)
VALUES ($1, $2, $3, COALESCE($4, true), $5)
`INSERT INTO resellers (
name, document, contact_email, active, created_by,
trade_name, state_registration, municipal_registration, opening_date,
legal_nature, cnpj_situation, main_activity,
address_zip, address_street, address_number, address_complement,
address_neighborhood, address_city, address_state, address_country,
contact_principal_name, contact_principal_email, contact_principal_phone,
contact_financial_name, contact_financial_email, contact_financial_phone,
contact_technical_name, contact_technical_email, contact_technical_phone
)
VALUES (
$1, $2, $3, COALESCE($4, true), $5,
$6, $7, $8, $9::date,
$10, $11, $12,
$13, $14, $15, $16,
$17, $18, $19, COALESCE($20, 'Brasil'),
$21, $22, $23,
$24, $25, $26,
$27, $28, $29
)
RETURNING *`,
[
input.name,
@@ -44,6 +110,30 @@ export async function createReseller(input: CreateResellerInput): Promise<Resell
input.contactEmail ?? null,
input.active ?? null,
input.createdBy ?? null,
input.tradeName ?? null,
input.stateRegistration ?? null,
input.municipalRegistration ?? null,
input.openingDate ?? null,
input.legalNature ?? null,
input.cnpjSituation ?? null,
input.mainActivity ?? null,
input.addressZip ?? null,
input.addressStreet ?? null,
input.addressNumber ?? null,
input.addressComplement ?? null,
input.addressNeighborhood ?? null,
input.addressCity ?? null,
input.addressState ?? null,
input.addressCountry ?? null,
input.contactPrincipalName ?? null,
input.contactPrincipalEmail ?? null,
input.contactPrincipalPhone ?? null,
input.contactFinancialName ?? null,
input.contactFinancialEmail ?? null,
input.contactFinancialPhone ?? null,
input.contactTechnicalName ?? null,
input.contactTechnicalEmail ?? null,
input.contactTechnicalPhone ?? null,
],
);
const reseller = rows[0];
@@ -62,10 +152,139 @@ export async function updateReseller(
name = COALESCE($2, name),
document = COALESCE($3, document),
contact_email = COALESCE($4, contact_email),
active = COALESCE($5, active)
active = COALESCE($5, active),
trade_name = COALESCE($6, trade_name),
state_registration = COALESCE($7, state_registration),
municipal_registration = COALESCE($8, municipal_registration),
opening_date = COALESCE($9::date, opening_date),
legal_nature = COALESCE($10, legal_nature),
cnpj_situation = COALESCE($11, cnpj_situation),
main_activity = COALESCE($12, main_activity),
address_zip = COALESCE($13, address_zip),
address_street = COALESCE($14, address_street),
address_number = COALESCE($15, address_number),
address_complement = COALESCE($16, address_complement),
address_neighborhood = COALESCE($17, address_neighborhood),
address_city = COALESCE($18, address_city),
address_state = COALESCE($19, address_state),
address_country = COALESCE($20, address_country),
contact_principal_name = COALESCE($21, contact_principal_name),
contact_principal_email = COALESCE($22, contact_principal_email),
contact_principal_phone = COALESCE($23, contact_principal_phone),
contact_financial_name = COALESCE($24, contact_financial_name),
contact_financial_email = COALESCE($25, contact_financial_email),
contact_financial_phone = COALESCE($26, contact_financial_phone),
contact_technical_name = COALESCE($27, contact_technical_name),
contact_technical_email = COALESCE($28, contact_technical_email),
contact_technical_phone = COALESCE($29, contact_technical_phone)
WHERE id = $1
RETURNING *`,
[id, input.name ?? null, input.document ?? null, input.contactEmail ?? null, input.active ?? null],
[
id,
input.name ?? null,
input.document ?? null,
input.contactEmail ?? null,
input.active ?? null,
input.tradeName ?? null,
input.stateRegistration ?? null,
input.municipalRegistration ?? null,
input.openingDate ?? null,
input.legalNature ?? null,
input.cnpjSituation ?? null,
input.mainActivity ?? null,
input.addressZip ?? null,
input.addressStreet ?? null,
input.addressNumber ?? null,
input.addressComplement ?? null,
input.addressNeighborhood ?? null,
input.addressCity ?? null,
input.addressState ?? null,
input.addressCountry ?? null,
input.contactPrincipalName ?? null,
input.contactPrincipalEmail ?? null,
input.contactPrincipalPhone ?? null,
input.contactFinancialName ?? null,
input.contactFinancialEmail ?? null,
input.contactFinancialPhone ?? null,
input.contactTechnicalName ?? null,
input.contactTechnicalEmail ?? null,
input.contactTechnicalPhone ?? null,
],
);
return rows[0] ?? null;
}
/**
* Dedicated CNPJ-lookup apply — mirrors
* `client-registrations.ts`'s `applyCnpjLookup`, but unlike there
* `document` is NOT structurally locked out of the generic update above;
* this function is purely a convenience path that also happens to set
* `document`, always re-resolved from the authoritative lookup itself
* (`ResellersService.applyCnpjLookup` never trusts a client-submitted
* "already looked up" payload). Every other field is `COALESCE`d against
* the existing column, same discipline as the client-registrations path:
* a field the lookup source didn't bring back never clobbers a value
* already on file.
*/
export interface ApplyCnpjLookupInput {
document: string;
companyName: string | null;
tradeName: string | null;
stateRegistration: string | null;
openingDate: string | null;
legalNature: string | null;
cnpjSituation: string | null;
mainActivity: string | null;
addressZip: string | null;
addressStreet: string | null;
addressNumber: string | null;
addressComplement: string | null;
addressNeighborhood: string | null;
addressCity: string | null;
addressState: string | null;
}
export async function applyCnpjLookup(
id: string,
input: ApplyCnpjLookupInput,
): Promise<Reseller | null> {
const { rows } = await query<Reseller>(
`UPDATE resellers SET
document = $2,
name = COALESCE($3, name),
trade_name = COALESCE($4, trade_name),
state_registration = COALESCE($5, state_registration),
opening_date = COALESCE($6::date, opening_date),
legal_nature = COALESCE($7, legal_nature),
cnpj_situation = COALESCE($8, cnpj_situation),
main_activity = COALESCE($9, main_activity),
address_zip = COALESCE($10, address_zip),
address_street = COALESCE($11, address_street),
address_number = COALESCE($12, address_number),
address_complement = COALESCE($13, address_complement),
address_neighborhood = COALESCE($14, address_neighborhood),
address_city = COALESCE($15, address_city),
address_state = COALESCE($16, address_state)
WHERE id = $1
RETURNING *`,
[
id,
input.document,
input.companyName,
input.tradeName,
input.stateRegistration,
input.openingDate,
input.legalNature,
input.cnpjSituation,
input.mainActivity,
input.addressZip,
input.addressStreet,
input.addressNumber,
input.addressComplement,
input.addressNeighborhood,
input.addressCity,
input.addressState,
],
);
return rows[0] ?? null;
}