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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user