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:
13
apps/api/src/resellers/dto/apply-reseller-cnpj-lookup.dto.ts
Normal file
13
apps/api/src/resellers/dto/apply-reseller-cnpj-lookup.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* Body for `POST /v1/resellers/:id/cnpj-lookup`. The server re-resolves
|
||||
* this CNPJ itself against the authoritative lookup APIs
|
||||
* (`@eden/integrations`'s `lookupCnpj`) — this DTO carries only the raw
|
||||
* input, never any "already looked up" data from the client.
|
||||
*/
|
||||
export class ApplyResellerCnpjLookupDto {
|
||||
@IsString()
|
||||
@MinLength(11)
|
||||
cnpj!: string;
|
||||
}
|
||||
20
apps/api/src/resellers/dto/create-reseller-partner.dto.ts
Normal file
20
apps/api/src/resellers/dto/create-reseller-partner.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* `source` is not client-settable — always defaults to `'manual'` (DB
|
||||
* default, the `reseller_partners` migration) for partners entered through
|
||||
* this endpoint. Partners populated by the CNPJ-lookup flow
|
||||
* (`POST :id/cnpj-lookup`) go through a different, server-only path and
|
||||
* are tagged `'cnpj_lookup'` instead — see `resellers.service.ts`.
|
||||
*/
|
||||
export class CreateResellerPartnerDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsOptional() @IsString() document?: string;
|
||||
@IsOptional() @IsString() qualification?: string;
|
||||
@IsOptional() @IsString() cpf?: string;
|
||||
@IsOptional() @IsBoolean() willSign?: boolean;
|
||||
@IsOptional() @IsEmail() signatureEmail?: string;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from "class-validator";
|
||||
import { IsBoolean, IsDateString, IsEmail, IsOptional, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class CreateResellerDto {
|
||||
@IsString()
|
||||
@@ -8,4 +8,31 @@ export class CreateResellerDto {
|
||||
@IsOptional() @IsString() document?: string;
|
||||
@IsOptional() @IsEmail() contactEmail?: string;
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
|
||||
@IsOptional() @IsString() tradeName?: string;
|
||||
@IsOptional() @IsString() stateRegistration?: string;
|
||||
@IsOptional() @IsString() municipalRegistration?: string;
|
||||
@IsOptional() @IsDateString() openingDate?: string;
|
||||
@IsOptional() @IsString() legalNature?: string;
|
||||
@IsOptional() @IsString() cnpjSituation?: string;
|
||||
@IsOptional() @IsString() mainActivity?: string;
|
||||
|
||||
@IsOptional() @IsString() addressZip?: string;
|
||||
@IsOptional() @IsString() addressStreet?: string;
|
||||
@IsOptional() @IsString() addressNumber?: string;
|
||||
@IsOptional() @IsString() addressComplement?: string;
|
||||
@IsOptional() @IsString() addressNeighborhood?: string;
|
||||
@IsOptional() @IsString() addressCity?: string;
|
||||
@IsOptional() @IsString() addressState?: string;
|
||||
@IsOptional() @IsString() addressCountry?: string;
|
||||
|
||||
@IsOptional() @IsString() contactPrincipalName?: string;
|
||||
@IsOptional() @IsEmail() contactPrincipalEmail?: string;
|
||||
@IsOptional() @IsString() contactPrincipalPhone?: string;
|
||||
@IsOptional() @IsString() contactFinancialName?: string;
|
||||
@IsOptional() @IsEmail() contactFinancialEmail?: string;
|
||||
@IsOptional() @IsString() contactFinancialPhone?: string;
|
||||
@IsOptional() @IsString() contactTechnicalName?: string;
|
||||
@IsOptional() @IsEmail() contactTechnicalEmail?: string;
|
||||
@IsOptional() @IsString() contactTechnicalPhone?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsBoolean, IsEmail, IsOptional, IsString } from "class-validator";
|
||||
|
||||
/** Same corrigible fields as client-registration partners: cpf/will_sign/signature_email. */
|
||||
export class UpdateResellerPartnerDto {
|
||||
@IsOptional() @IsString() cpf?: string;
|
||||
@IsOptional() @IsBoolean() willSign?: boolean;
|
||||
@IsOptional() @IsEmail() signatureEmail?: string;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { ResellersService } from "./resellers.service.js";
|
||||
import { CreateResellerDto } from "./dto/create-reseller.dto.js";
|
||||
import { UpdateResellerDto } from "./dto/update-reseller.dto.js";
|
||||
import { AssignUserToResellerDto } from "./dto/assign-user-to-reseller.dto.js";
|
||||
import { CreateResellerPartnerDto } from "./dto/create-reseller-partner.dto.js";
|
||||
import { UpdateResellerPartnerDto } from "./dto/update-reseller-partner.dto.js";
|
||||
import { ApplyResellerCnpjLookupDto } from "./dto/apply-reseller-cnpj-lookup.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
@@ -87,4 +90,40 @@ export class ResellersController {
|
||||
unassignUser(@Body() dto: AssignUserToResellerDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.resellers.unassignUser(dto.userId, user.id);
|
||||
}
|
||||
|
||||
@Get(":id/partners")
|
||||
listPartners(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.resellers.listPartners(id);
|
||||
}
|
||||
|
||||
@Post(":id/partners")
|
||||
@RequirePermission("resellers", "edit")
|
||||
createPartner(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateResellerPartnerDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.resellers.createPartner(id, dto, user.id);
|
||||
}
|
||||
|
||||
@Patch(":id/partners/:partnerId")
|
||||
@RequirePermission("resellers", "edit")
|
||||
updatePartner(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("partnerId", ParseUUIDPipe) partnerId: string,
|
||||
@Body() dto: UpdateResellerPartnerDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.resellers.updatePartner(id, partnerId, dto, user.id);
|
||||
}
|
||||
|
||||
@Post(":id/cnpj-lookup")
|
||||
@RequirePermission("resellers", "edit")
|
||||
applyCnpjLookup(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ApplyResellerCnpjLookupDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.resellers.applyCnpjLookup(id, dto, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { resellersRepo, usersRepo, auditRepo, type Reseller } from "@eden/database";
|
||||
import {
|
||||
resellersRepo,
|
||||
resellerPartnersRepo,
|
||||
usersRepo,
|
||||
auditRepo,
|
||||
type Reseller,
|
||||
type ResellerPartner,
|
||||
} from "@eden/database";
|
||||
import { onlyDigits, isValidCPF, isValidCNPJ } from "@eden/domain-shared";
|
||||
import { lookupCnpj } from "@eden/integrations";
|
||||
import type { CreateResellerDto } from "./dto/create-reseller.dto.js";
|
||||
import type { UpdateResellerDto } from "./dto/update-reseller.dto.js";
|
||||
import type { AssignUserToResellerDto } from "./dto/assign-user-to-reseller.dto.js";
|
||||
import type { CreateResellerPartnerDto } from "./dto/create-reseller-partner.dto.js";
|
||||
import type { UpdateResellerPartnerDto } from "./dto/update-reseller-partner.dto.js";
|
||||
import type { ApplyResellerCnpjLookupDto } from "./dto/apply-reseller-cnpj-lookup.dto.js";
|
||||
|
||||
/** Same tag used by client-registrations' CNPJ-lookup flow — cnpj.ws is the primary source in this port, not BrasilAPI. */
|
||||
const CNPJ_LOOKUP_PARTNER_SOURCE = "cnpj_lookup";
|
||||
|
||||
/** `undefined`/blank → `null`, so a field the lookup didn't bring back never clobbers an existing value (COALESCE in the repo). */
|
||||
function orNull(value: string | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ResellersService {
|
||||
@@ -26,6 +46,30 @@ export class ResellersService {
|
||||
contactEmail: dto.contactEmail,
|
||||
active: dto.active,
|
||||
createdBy: actorId,
|
||||
tradeName: dto.tradeName,
|
||||
stateRegistration: dto.stateRegistration,
|
||||
municipalRegistration: dto.municipalRegistration,
|
||||
openingDate: dto.openingDate,
|
||||
legalNature: dto.legalNature,
|
||||
cnpjSituation: dto.cnpjSituation,
|
||||
mainActivity: dto.mainActivity,
|
||||
addressZip: dto.addressZip,
|
||||
addressStreet: dto.addressStreet,
|
||||
addressNumber: dto.addressNumber,
|
||||
addressComplement: dto.addressComplement,
|
||||
addressNeighborhood: dto.addressNeighborhood,
|
||||
addressCity: dto.addressCity,
|
||||
addressState: dto.addressState,
|
||||
addressCountry: dto.addressCountry,
|
||||
contactPrincipalName: dto.contactPrincipalName,
|
||||
contactPrincipalEmail: dto.contactPrincipalEmail,
|
||||
contactPrincipalPhone: dto.contactPrincipalPhone,
|
||||
contactFinancialName: dto.contactFinancialName,
|
||||
contactFinancialEmail: dto.contactFinancialEmail,
|
||||
contactFinancialPhone: dto.contactFinancialPhone,
|
||||
contactTechnicalName: dto.contactTechnicalName,
|
||||
contactTechnicalEmail: dto.contactTechnicalEmail,
|
||||
contactTechnicalPhone: dto.contactTechnicalPhone,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
@@ -48,6 +92,30 @@ export class ResellersService {
|
||||
document: dto.document ? onlyDigits(dto.document) : undefined,
|
||||
contactEmail: dto.contactEmail,
|
||||
active: dto.active,
|
||||
tradeName: dto.tradeName,
|
||||
stateRegistration: dto.stateRegistration,
|
||||
municipalRegistration: dto.municipalRegistration,
|
||||
openingDate: dto.openingDate,
|
||||
legalNature: dto.legalNature,
|
||||
cnpjSituation: dto.cnpjSituation,
|
||||
mainActivity: dto.mainActivity,
|
||||
addressZip: dto.addressZip,
|
||||
addressStreet: dto.addressStreet,
|
||||
addressNumber: dto.addressNumber,
|
||||
addressComplement: dto.addressComplement,
|
||||
addressNeighborhood: dto.addressNeighborhood,
|
||||
addressCity: dto.addressCity,
|
||||
addressState: dto.addressState,
|
||||
addressCountry: dto.addressCountry,
|
||||
contactPrincipalName: dto.contactPrincipalName,
|
||||
contactPrincipalEmail: dto.contactPrincipalEmail,
|
||||
contactPrincipalPhone: dto.contactPrincipalPhone,
|
||||
contactFinancialName: dto.contactFinancialName,
|
||||
contactFinancialEmail: dto.contactFinancialEmail,
|
||||
contactFinancialPhone: dto.contactFinancialPhone,
|
||||
contactTechnicalName: dto.contactTechnicalName,
|
||||
contactTechnicalEmail: dto.contactTechnicalEmail,
|
||||
contactTechnicalPhone: dto.contactTechnicalPhone,
|
||||
});
|
||||
if (!reseller) throw new NotFoundException("Revenda não encontrada");
|
||||
|
||||
@@ -102,6 +170,141 @@ export class ResellersService {
|
||||
return { userId, resellerId: null };
|
||||
}
|
||||
|
||||
async listPartners(id: string): Promise<ResellerPartner[]> {
|
||||
await this.get(id); // 404 if missing
|
||||
return resellerPartnersRepo.listPartnersForReseller(id);
|
||||
}
|
||||
|
||||
async createPartner(
|
||||
id: string,
|
||||
dto: CreateResellerPartnerDto,
|
||||
actorId: string,
|
||||
): Promise<ResellerPartner> {
|
||||
await this.get(id);
|
||||
|
||||
const partner = await resellerPartnersRepo.createPartner({
|
||||
resellerId: id,
|
||||
name: dto.name,
|
||||
document: dto.document,
|
||||
qualification: dto.qualification,
|
||||
cpf: dto.cpf,
|
||||
willSign: dto.willSign,
|
||||
signatureEmail: dto.signatureEmail,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId,
|
||||
action: "reseller.partner_created",
|
||||
resourceType: "reseller_partner",
|
||||
resourceId: partner.id,
|
||||
metadata: { resellerId: id },
|
||||
});
|
||||
|
||||
return partner;
|
||||
}
|
||||
|
||||
async updatePartner(
|
||||
id: string,
|
||||
partnerId: string,
|
||||
dto: UpdateResellerPartnerDto,
|
||||
actorId: string,
|
||||
): Promise<ResellerPartner> {
|
||||
await this.get(id);
|
||||
|
||||
const existing = await resellerPartnersRepo.getPartnerById(partnerId);
|
||||
if (!existing || existing.reseller_id !== id) {
|
||||
throw new NotFoundException("Sócio não encontrado");
|
||||
}
|
||||
|
||||
const partner = await resellerPartnersRepo.updatePartner(partnerId, {
|
||||
cpf: dto.cpf,
|
||||
willSign: dto.willSign,
|
||||
signatureEmail: dto.signatureEmail,
|
||||
});
|
||||
if (!partner) throw new NotFoundException("Sócio não encontrado");
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId,
|
||||
action: "reseller.partner_updated",
|
||||
resourceType: "reseller_partner",
|
||||
resourceId: partnerId,
|
||||
metadata: { resellerId: id, fields: Object.keys(dto) },
|
||||
});
|
||||
|
||||
return partner;
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /:id/cnpj-lookup` — same mechanics as
|
||||
* `ClientRegistrationsService.applyCnpjLookup`: the server always
|
||||
* re-runs the authoritative lookup itself from `dto.cnpj` — never trusts
|
||||
* a client-submitted "already found" payload for persistence.
|
||||
*/
|
||||
async applyCnpjLookup(
|
||||
id: string,
|
||||
dto: ApplyResellerCnpjLookupDto,
|
||||
actorId: string,
|
||||
): Promise<{ reseller: Reseller; partners: ResellerPartner[] }> {
|
||||
await this.get(id); // 404 if missing
|
||||
|
||||
const result = await lookupCnpj(dto.cnpj);
|
||||
if (!result) {
|
||||
throw new NotFoundException("CNPJ não encontrado — preencha os dados manualmente.");
|
||||
}
|
||||
|
||||
const normalizedCnpj = dto.cnpj.replace(/\D/g, "");
|
||||
|
||||
const updated = await resellersRepo.applyCnpjLookup(id, {
|
||||
document: normalizedCnpj,
|
||||
companyName: orNull(result.companyName),
|
||||
tradeName: orNull(result.tradeName),
|
||||
stateRegistration: orNull(result.stateRegistration),
|
||||
openingDate: orNull(result.openingDate),
|
||||
legalNature: orNull(result.legalNature),
|
||||
cnpjSituation: orNull(result.cnpjSituation),
|
||||
mainActivity: orNull(result.mainActivity),
|
||||
addressZip: orNull(result.address.zip),
|
||||
addressStreet: orNull(result.address.street),
|
||||
addressNumber: orNull(result.address.number),
|
||||
addressComplement: orNull(result.address.complement),
|
||||
addressNeighborhood: orNull(result.address.neighborhood),
|
||||
addressCity: orNull(result.address.city),
|
||||
addressState: orNull(result.address.state),
|
||||
});
|
||||
if (!updated) throw new NotFoundException("Revenda não encontrada");
|
||||
|
||||
let partnersCreated = 0;
|
||||
for (const partner of result.partners) {
|
||||
const document = orNull(partner.document);
|
||||
if (document) {
|
||||
const existing = await resellerPartnersRepo.findPartnerByDocument(id, document);
|
||||
if (existing) continue;
|
||||
}
|
||||
await resellerPartnersRepo.createPartner({
|
||||
resellerId: id,
|
||||
name: partner.name,
|
||||
document,
|
||||
qualification: orNull(partner.qualification),
|
||||
source: CNPJ_LOOKUP_PARTNER_SOURCE,
|
||||
});
|
||||
partnersCreated += 1;
|
||||
}
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId,
|
||||
action: "reseller.cnpj_lookup_applied",
|
||||
resourceType: "reseller",
|
||||
resourceId: id,
|
||||
metadata: { cnpj: normalizedCnpj, partnersFound: result.partners.length, partnersCreated },
|
||||
});
|
||||
|
||||
const partners = await resellerPartnersRepo.listPartnersForReseller(id);
|
||||
return { reseller: updated, partners };
|
||||
}
|
||||
|
||||
/** Mirrors Companies' assertValidCnpjIfPresent — document is CPF or CNPJ here. */
|
||||
private assertValidDocumentIfPresent(document: string | undefined): void {
|
||||
if (!document) return;
|
||||
|
||||
Reference in New Issue
Block a user