feat(resellers): campos de representante legal (preparação p/ contrato)

Usuário: o cadastro de revenda precisa ficar completo porque essa
mesma base vai alimentar a criação de contrato com a revenda dentro
do ERP. resellers ganha rep_full_name/rep_cpf/rep_role/rep_email/
rep_mobile_phone/rep_authorization_ack, mesmo shape de
client_registrations.pj_rep_* — um contrato precisa de um signatário
nomeado e autorizado, não só a lista geral de sócios.

Divergência deliberada: ao contrário de pj_rep_cpf (bloqueado do PATCH
genérico em client_registrations, esperando um fluxo de submissão
pública que o EDEN ainda não construiu — hoje uma lacuna conhecida,
nunca preenchido de fato), rep_cpf aqui fica livremente editável —
revenda não tem esse fluxo de submissão pública pra travar atrás.

Nova tela: card "Representante legal" entre "Dados da revenda" e
"Sócios". Suíte: 107/107 (106→107). pnpm turbo run typecheck build:
28/28. Testado ao vivo em navegador real.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 15:41:16 -03:00
parent ec13dccee6
commit d81553fcfe
8 changed files with 240 additions and 4 deletions

View File

@@ -35,4 +35,15 @@ export class CreateResellerDto {
@IsOptional() @IsString() contactTechnicalName?: string;
@IsOptional() @IsEmail() contactTechnicalEmail?: string;
@IsOptional() @IsString() contactTechnicalPhone?: string;
// Representante legal — usado depois para confecção de contrato com a
// revenda (não bloqueado no PATCH genérico, ao contrário do equivalente
// em client_registrations: ver o cabeçalho da migration
// 1789700100000_resellers-legal-representative.cjs para o porquê.
@IsOptional() @IsString() repFullName?: string;
@IsOptional() @IsString() repCpf?: string;
@IsOptional() @IsString() repRole?: string;
@IsOptional() @IsEmail() repEmail?: string;
@IsOptional() @IsString() repMobilePhone?: string;
@IsOptional() @IsBoolean() repAuthorizationAck?: boolean;
}

View File

@@ -70,6 +70,12 @@ export class ResellersService {
contactTechnicalName: dto.contactTechnicalName,
contactTechnicalEmail: dto.contactTechnicalEmail,
contactTechnicalPhone: dto.contactTechnicalPhone,
repFullName: dto.repFullName,
repCpf: dto.repCpf,
repRole: dto.repRole,
repEmail: dto.repEmail,
repMobilePhone: dto.repMobilePhone,
repAuthorizationAck: dto.repAuthorizationAck,
});
await auditRepo.appendAuditEvent({
@@ -116,6 +122,12 @@ export class ResellersService {
contactTechnicalName: dto.contactTechnicalName,
contactTechnicalEmail: dto.contactTechnicalEmail,
contactTechnicalPhone: dto.contactTechnicalPhone,
repFullName: dto.repFullName,
repCpf: dto.repCpf,
repRole: dto.repRole,
repEmail: dto.repEmail,
repMobilePhone: dto.repMobilePhone,
repAuthorizationAck: dto.repAuthorizationAck,
});
if (!reseller) throw new NotFoundException("Revenda não encontrada");

View File

@@ -211,4 +211,32 @@ describe("resellers — CNPJ lookup autofill", () => {
.send({ willSign: true })
.expect(404);
});
it("sets and updates the legal representative fields, unlocked (unlike client_registrations' pj_rep_cpf)", async () => {
const id = await createReseller("qa-test- Revenda Representante");
const created = await agent(app)
.patch(`/api/v1/resellers/${id}`)
.set(...bearer(admin.accessToken))
.send({
repFullName: "qa-test- Representante Um",
repCpf: "52998224725",
repRole: "Diretor",
repEmail: "representante@example.com",
repMobilePhone: "11999990000",
repAuthorizationAck: true,
})
.expect(200);
expect(created.body.rep_full_name).toBe("qa-test- Representante Um");
expect(created.body.rep_cpf).toBe("52998224725");
expect(created.body.rep_authorization_ack).toBe(true);
const updated = await agent(app)
.patch(`/api/v1/resellers/${id}`)
.set(...bearer(admin.accessToken))
.send({ repCpf: "11144477735" })
.expect(200);
expect(updated.body.rep_cpf).toBe("11144477735");
expect(updated.body.rep_full_name).toBe("qa-test- Representante Um"); // untouched field survives COALESCE
});
});

View File

@@ -39,6 +39,12 @@ export interface Reseller {
contact_technical_name: string | null;
contact_technical_email: string | null;
contact_technical_phone: string | null;
rep_full_name: string | null;
rep_cpf: string | null;
rep_role: string | null;
rep_email: string | null;
rep_mobile_phone: string | null;
rep_authorization_ack: boolean;
}
/** Request body — camelCase, matches `CreateResellerDto` exactly. */
@@ -74,6 +80,13 @@ export interface CreateResellerInput {
contactTechnicalName?: string;
contactTechnicalEmail?: string;
contactTechnicalPhone?: string;
repFullName?: string;
repCpf?: string;
repRole?: string;
repEmail?: string;
repMobilePhone?: string;
repAuthorizationAck?: boolean;
}
/** Matches `UpdateResellerDto`: everything optional. */

View File

@@ -23,7 +23,15 @@ import { ResellerPartnersSection } from "./ResellerPartnersSection.js";
*
* Section order when editing (per product direction, same order as
* `ClientRegistrationDetailPage`): Dados da revenda (incl. CNPJ lookup) ->
* Sócios -> Endereço -> Contatos -> Status.
* Representante legal -> Sócios -> Endereço -> Contatos -> Status.
*
* "Representante legal" (`rep_*`) mirrors `client_registrations.pj_rep_*`
* — added because reseller registration data will feed reseller contract
* creation later; a contract needs a named, authorized signer, not just
* the general Sócios list. Unlike `pj_rep_cpf` there, `repCpf` here is a
* plain editable field — see the migration
* `1789700100000_resellers-legal-representative.cjs` for why the same
* structural lock doesn't apply to resellers.
*/
interface FormState {
name: string;
@@ -39,6 +47,13 @@ interface FormState {
cnpjSituation: string;
mainActivity: string;
repFullName: string;
repCpf: string;
repRole: string;
repEmail: string;
repMobilePhone: string;
repAuthorizationAck: boolean;
addressZip: string;
addressStreet: string;
addressNumber: string;
@@ -73,6 +88,13 @@ const EMPTY_FORM: FormState = {
cnpjSituation: "",
mainActivity: "",
repFullName: "",
repCpf: "",
repRole: "",
repEmail: "",
repMobilePhone: "",
repAuthorizationAck: false,
addressZip: "",
addressStreet: "",
addressNumber: "",
@@ -112,6 +134,13 @@ function toFormState(reseller: Reseller): FormState {
cnpjSituation: reseller.cnpj_situation ?? "",
mainActivity: reseller.main_activity ?? "",
repFullName: reseller.rep_full_name ?? "",
repCpf: reseller.rep_cpf ?? "",
repRole: reseller.rep_role ?? "",
repEmail: reseller.rep_email ?? "",
repMobilePhone: reseller.rep_mobile_phone ?? "",
repAuthorizationAck: reseller.rep_authorization_ack,
addressZip: reseller.address_zip ?? "",
addressStreet: reseller.address_street ?? "",
addressNumber: reseller.address_number ?? "",
@@ -206,6 +235,13 @@ export function ResellerFormPage() {
cnpjSituation: toOptional(form.cnpjSituation),
mainActivity: toOptional(form.mainActivity),
repFullName: toOptional(form.repFullName),
repCpf: toOptional(form.repCpf),
repRole: toOptional(form.repRole),
repEmail: toOptional(form.repEmail),
repMobilePhone: toOptional(form.repMobilePhone),
repAuthorizationAck: form.repAuthorizationAck,
addressZip: toOptional(form.addressZip),
addressStreet: toOptional(form.addressStreet),
addressNumber: toOptional(form.addressNumber),
@@ -418,6 +454,43 @@ export function ResellerFormPage() {
) : null}
</div>
</Card>
{isEditing ? (
<Card title="Representante legal">
<p className="mb-3 text-xs text-default">
Quem assina pela revenda — usado depois na confecção de contrato com a revenda.
</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Input
label="Nome completo"
value={form.repFullName}
onChange={(e) => updateField("repFullName", e.target.value)}
/>
<Input label="CPF" value={form.repCpf} onChange={(e) => updateField("repCpf", e.target.value)} />
<Input label="Cargo" value={form.repRole} onChange={(e) => updateField("repRole", e.target.value)} />
<Input
label="E-mail"
type="email"
value={form.repEmail}
onChange={(e) => updateField("repEmail", e.target.value)}
/>
<Input
label="Celular"
value={form.repMobilePhone}
onChange={(e) => updateField("repMobilePhone", e.target.value)}
/>
<label className="flex items-center gap-2 py-2.5 text-sm text-title">
<input
type="checkbox"
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
checked={form.repAuthorizationAck}
onChange={(e) => updateField("repAuthorizationAck", e.target.checked)}
/>
Representante confirmou autorização
</label>
</div>
</Card>
) : null}
</form>
{isEditing && id ? (

View File

@@ -775,3 +775,21 @@ Data: 2026-09-18. Frontend da leva anterior (agente `eden-frontend`), construíd
### Verificação
`pnpm turbo run typecheck build` 28/28 (antes e depois da correção). Suíte 106/106 (inalterada, mudança de frontend). Testado ao vivo em navegador real via Playwright, incluindo o cenário exato do bug (digitar 14 dígitos antes de qualquer busca — botão permanece "Buscar CNPJ" corretamente agora) e o ciclo completo com CNPJ real (Petrobras, nome fantasia e sócio confirmados). Revendas de teste desativadas ao final.
---
## Representante legal da Revenda — preparação pra contrato com revenda
Data: 2026-09-18. Usuário: "o cadastro da revenda tem que ser completo pq depois vamos usar esses dados para fazer o contrato com a revenda tb por dentro do erp". Implementado diretamente (mudança pequena e bem entendida, mesmo padrão já estabelecido).
Investigando antes de mudar: o módulo de Assinatura Eletrônica já aceita `resellerId` como origem de um envelope (`CreateEnvelopeDto`, alternativa a `clientRegistrationId`), mas os `contratante.signers` de um envelope hoje vêm sempre digitados manualmente no corpo da requisição — não existe ainda nenhuma derivação automática a partir de `client_registration_partners`/`reseller_partners` (achado que vale registrar para quando essa integração for construída de verdade, não resolvido aqui — fora do pedido desta leva). O que faltava de concreto e imediato: `resellers`, ao contrário de `client_registrations`, não tinha um bloco de **representante legal** dedicado (`pj_rep_*` em client_registrations) — só a lista geral de sócios. Um contrato precisa de um signatário nomeado e autorizado, não só uma lista de sócios.
Migration `packages/database/migrations/1789700100000_resellers-legal-representative.cjs`: `resellers` ganha `rep_full_name`, `rep_cpf`, `rep_role`, `rep_email`, `rep_mobile_phone`, `rep_authorization_ack` — mesmo shape de `client_registrations.pj_rep_*`. **Divergência deliberada**: lá `pj_rep_cpf` é estruturalmente bloqueado do PATCH genérico (só seria setável pelo fluxo de submissão pública original do legado, que o EDEN ainda não construiu — hoje é uma lacuna conhecida, o campo nunca é preenchido de fato). Revenda não tem esse fluxo de submissão pública (é sempre cadastro de backoffice), então não existe "momento de submissão original" pra travar atrás — `rep_cpf` aqui é um campo comum, livremente editável via `PATCH`, sem repetir uma trava que só faria sentido lá.
Backend (`CreateResellerDto`, `resellers.service.ts`, repositório) e frontend (`ResellerFormPage.tsx`, novo card "Representante legal" entre "Dados da revenda" e "Sócios") seguindo o mesmo padrão já usado no resto do módulo.
### Verificação
`pnpm db:migrate`/`down`/`migrate` de novo — limpo. `pnpm turbo run typecheck build` 28/28. Suíte 107/107 (106→107, 1 teste novo cobrindo set+update dos campos). Testado ao vivo em navegador real: ordem confirmada (`Dados da revenda → Representante legal → Sócios → Endereço → Contatos → Status`), Nome/CPF do representante preenchidos e confirmados persistentes após reload da página. Revenda de teste desativada ao final.
### Pendências
A derivação automática de signatários de contrato a partir de `client_registration_partners`/`reseller_partners`/representante legal (pra realmente "usar na confecção do contrato") ainda não existe — hoje `CreateEnvelopeDto.signers` continua sendo preenchido manualmente independente da origem. Esse é o trabalho real que fecha o ciclo que o usuário descreveu; os dados já ficam prontos e completos esperando por ele.

View File

@@ -0,0 +1,49 @@
/**
* Representante legal da revenda — mirrors `client_registrations.pj_rep_*`
* (packages/database/migrations/1788447739229_client-registrations.cjs).
* Motivation (user, 2026-09-18): reseller registration data will later
* feed reseller contract creation inside the ERP, same as
* client_registrations already does for client contracts — a contract
* needs a named, authorized signer for the legal entity, not just the
* general Sócios list (`reseller_partners`, added in
* 1789700000000_resellers-expansion.cjs).
*
* Deliberate deviation from `client_registrations.pj_rep_cpf`: there it's
* structurally excluded from the generic admin PATCH (`UpdateClientRegistrationDto`)
* because in the legacy it's only ever set during the public self-service
* submission flow, which EDEN hasn't built yet — so it stays permanently
* unset there today, a known pre-existing gap, not something to replicate.
* Resellers have no such public submission flow (always backoffice-created)
* and no "original submission" moment to lock behind, so `rep_cpf` here is
* a plain, freely-PATCH-able column like every other reseller field — see
* `resellers.service.ts`, no new DTO exclusion added for it.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
ALTER TABLE resellers
ADD COLUMN rep_full_name TEXT,
ADD COLUMN rep_cpf TEXT,
ADD COLUMN rep_role TEXT,
ADD COLUMN rep_email TEXT,
ADD COLUMN rep_mobile_phone TEXT,
ADD COLUMN rep_authorization_ack BOOLEAN NOT NULL DEFAULT false;
COMMENT ON COLUMN resellers.rep_authorization_ack IS
'Confirmação de que o representante está autorizado a agir pela revenda — mesmo campo de intenção que client_registrations.pj_rep_authorization_ack, sem o mesmo bloqueio estrutural de edição (ver cabeçalho da migration).';
`);
};
exports.down = (pgm) => {
pgm.sql(`
ALTER TABLE resellers
DROP COLUMN IF EXISTS rep_full_name,
DROP COLUMN IF EXISTS rep_cpf,
DROP COLUMN IF EXISTS rep_role,
DROP COLUMN IF EXISTS rep_email,
DROP COLUMN IF EXISTS rep_mobile_phone,
DROP COLUMN IF EXISTS rep_authorization_ack;
`);
};

View File

@@ -33,6 +33,12 @@ export interface Reseller {
contact_technical_name: string | null;
contact_technical_email: string | null;
contact_technical_phone: string | null;
rep_full_name: string | null;
rep_cpf: string | null;
rep_role: string | null;
rep_email: string | null;
rep_mobile_phone: string | null;
rep_authorization_ack: boolean;
}
export async function listResellers(includeInactive = false): Promise<Reseller[]> {
@@ -79,6 +85,12 @@ export interface CreateResellerInput {
contactTechnicalName?: string | null;
contactTechnicalEmail?: string | null;
contactTechnicalPhone?: string | null;
repFullName?: string | null;
repCpf?: string | null;
repRole?: string | null;
repEmail?: string | null;
repMobilePhone?: string | null;
repAuthorizationAck?: boolean;
}
export async function createReseller(input: CreateResellerInput): Promise<Reseller> {
@@ -91,7 +103,8 @@ export async function createReseller(input: CreateResellerInput): Promise<Resell
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
contact_technical_name, contact_technical_email, contact_technical_phone,
rep_full_name, rep_cpf, rep_role, rep_email, rep_mobile_phone, rep_authorization_ack
)
VALUES (
$1, $2, $3, COALESCE($4, true), $5,
@@ -101,7 +114,8 @@ export async function createReseller(input: CreateResellerInput): Promise<Resell
$17, $18, $19, COALESCE($20, 'Brasil'),
$21, $22, $23,
$24, $25, $26,
$27, $28, $29
$27, $28, $29,
$30, $31, $32, $33, $34, COALESCE($35, false)
)
RETURNING *`,
[
@@ -134,6 +148,12 @@ export async function createReseller(input: CreateResellerInput): Promise<Resell
input.contactTechnicalName ?? null,
input.contactTechnicalEmail ?? null,
input.contactTechnicalPhone ?? null,
input.repFullName ?? null,
input.repCpf ?? null,
input.repRole ?? null,
input.repEmail ?? null,
input.repMobilePhone ?? null,
input.repAuthorizationAck ?? null,
],
);
const reseller = rows[0];
@@ -176,7 +196,13 @@ export async function updateReseller(
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)
contact_technical_phone = COALESCE($29, contact_technical_phone),
rep_full_name = COALESCE($30, rep_full_name),
rep_cpf = COALESCE($31, rep_cpf),
rep_role = COALESCE($32, rep_role),
rep_email = COALESCE($33, rep_email),
rep_mobile_phone = COALESCE($34, rep_mobile_phone),
rep_authorization_ack = COALESCE($35, rep_authorization_ack)
WHERE id = $1
RETURNING *`,
[
@@ -209,6 +235,12 @@ export async function updateReseller(
input.contactTechnicalName ?? null,
input.contactTechnicalEmail ?? null,
input.contactTechnicalPhone ?? null,
input.repFullName ?? null,
input.repCpf ?? null,
input.repRole ?? null,
input.repEmail ?? null,
input.repMobilePhone ?? null,
input.repAuthorizationAck ?? null,
],
);
return rows[0] ?? null;