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;
|
||||
|
||||
214
apps/api/test/resellers-cnpj-lookup.spec.ts
Normal file
214
apps/api/test/resellers-cnpj-lookup.spec.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type AuthedTokens } from "./setup.js";
|
||||
import type { CnpjLookupResult } from "@eden/integrations";
|
||||
|
||||
/**
|
||||
* POST /v1/resellers/:id/cnpj-lookup — mirrors
|
||||
* client-registrations-cnpj-lookup.spec.ts. `lookupCnpj` is mocked
|
||||
* throughout (no network calls to cnpj.ws/BrasilAPI in this suite); a real
|
||||
* call against the internet is done manually, once, outside of vitest.
|
||||
*/
|
||||
const lookupCnpjMock = vi.fn<(cnpj: string) => Promise<CnpjLookupResult | null>>();
|
||||
|
||||
vi.mock("@eden/integrations", async () => {
|
||||
const actual = await vi.importActual<typeof import("@eden/integrations")>("@eden/integrations");
|
||||
return {
|
||||
...actual,
|
||||
lookupCnpj: (cnpj: string) => lookupCnpjMock(cnpj),
|
||||
};
|
||||
});
|
||||
|
||||
const FOUND_RESULT: CnpjLookupResult = {
|
||||
companyName: "qa-test- Revenda Lookup LTDA",
|
||||
tradeName: "qa-test- Revenda Lookup",
|
||||
openingDate: "2012-07-19",
|
||||
legalNature: "Sociedade Empresária Limitada",
|
||||
cnpjSituation: "ATIVA",
|
||||
mainActivity: "Comércio varejista de equipamentos de telecomunicação",
|
||||
address: {
|
||||
zip: "04547-000",
|
||||
street: "Av. Brigadeiro Faria Lima",
|
||||
number: "2000",
|
||||
complement: "Conjunto 20",
|
||||
neighborhood: "Itaim Bibi",
|
||||
city: "São Paulo",
|
||||
state: "SP",
|
||||
},
|
||||
stateRegistration: "987.654.321.114",
|
||||
partners: [
|
||||
{ name: "Ciclana de Tal", document: "***111222**", qualification: "Sócio-Administrador" },
|
||||
{ name: "Sicrano da Silva", document: "***333444**", qualification: "Sócio" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("resellers — CNPJ lookup autofill", () => {
|
||||
let app: INestApplication;
|
||||
let admin: AuthedTokens;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
admin = await loginSuperAdmin(app);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
lookupCnpjMock.mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeTestApp(app);
|
||||
});
|
||||
|
||||
async function createReseller(name: string) {
|
||||
const res = await agent(app)
|
||||
.post("/api/v1/resellers")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ name })
|
||||
.expect(201);
|
||||
return res.body.id as string;
|
||||
}
|
||||
|
||||
it("applies company data and creates partners when the CNPJ is found", async () => {
|
||||
lookupCnpjMock.mockResolvedValue(FOUND_RESULT);
|
||||
const id = await createReseller("qa-test- Revenda A");
|
||||
|
||||
const res = await agent(app)
|
||||
.post(`/api/v1/resellers/${id}/cnpj-lookup`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ cnpj: "11.222.333/0001-81" })
|
||||
.expect(201);
|
||||
|
||||
expect(lookupCnpjMock).toHaveBeenCalledWith("11.222.333/0001-81");
|
||||
expect(res.body.reseller.document).toBe("11222333000181");
|
||||
expect(res.body.reseller.trade_name).toBe(FOUND_RESULT.tradeName);
|
||||
expect(res.body.reseller.state_registration).toBe(FOUND_RESULT.stateRegistration);
|
||||
expect(res.body.reseller.legal_nature).toBe(FOUND_RESULT.legalNature);
|
||||
expect(res.body.reseller.cnpj_situation).toBe(FOUND_RESULT.cnpjSituation);
|
||||
expect(res.body.reseller.main_activity).toBe(FOUND_RESULT.mainActivity);
|
||||
expect(res.body.reseller.address_zip).toBe(FOUND_RESULT.address.zip);
|
||||
expect(res.body.reseller.address_street).toBe(FOUND_RESULT.address.street);
|
||||
expect(res.body.reseller.address_city).toBe(FOUND_RESULT.address.city);
|
||||
expect(res.body.reseller.address_state).toBe(FOUND_RESULT.address.state);
|
||||
expect(res.body.partners).toHaveLength(2);
|
||||
expect(res.body.partners.map((p: { name: string }) => p.name).sort()).toEqual(
|
||||
["Ciclana de Tal", "Sicrano da Silva"].sort(),
|
||||
);
|
||||
expect(res.body.partners.every((p: { source: string }) => p.source === "cnpj_lookup")).toBe(true);
|
||||
expect(res.body.partners.every((p: { will_sign: boolean }) => p.will_sign === false)).toBe(true);
|
||||
|
||||
const getRes = await agent(app)
|
||||
.get(`/api/v1/resellers/${id}`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(200);
|
||||
expect(getRes.body.document).toBe("11222333000181");
|
||||
|
||||
const partnersRes = await agent(app)
|
||||
.get(`/api/v1/resellers/${id}/partners`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(200);
|
||||
expect(partnersRes.body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("running the lookup twice does not duplicate partners (dedup by document)", async () => {
|
||||
lookupCnpjMock.mockResolvedValue(FOUND_RESULT);
|
||||
const id = await createReseller("qa-test- Revenda B");
|
||||
|
||||
await agent(app)
|
||||
.post(`/api/v1/resellers/${id}/cnpj-lookup`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ cnpj: "11222333000181" })
|
||||
.expect(201);
|
||||
|
||||
const second = await agent(app)
|
||||
.post(`/api/v1/resellers/${id}/cnpj-lookup`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ cnpj: "11222333000181" })
|
||||
.expect(201);
|
||||
|
||||
expect(second.body.partners).toHaveLength(2);
|
||||
|
||||
const partnersRes = await agent(app)
|
||||
.get(`/api/v1/resellers/${id}/partners`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(200);
|
||||
expect(partnersRes.body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns 404 and applies no changes when the CNPJ is not found in either source", async () => {
|
||||
lookupCnpjMock.mockResolvedValue(null);
|
||||
const id = await createReseller("qa-test- Revenda C");
|
||||
|
||||
await agent(app)
|
||||
.post(`/api/v1/resellers/${id}/cnpj-lookup`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ cnpj: "99999999000199" })
|
||||
.expect(404);
|
||||
|
||||
const getRes = await agent(app)
|
||||
.get(`/api/v1/resellers/${id}`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(200);
|
||||
expect(getRes.body.document).toBeNull();
|
||||
expect(getRes.body.trade_name).toBeNull();
|
||||
|
||||
const partnersRes = await agent(app)
|
||||
.get(`/api/v1/resellers/${id}/partners`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(200);
|
||||
expect(partnersRes.body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 404 for a reseller id that doesn't exist", async () => {
|
||||
await agent(app)
|
||||
.post(`/api/v1/resellers/${randomUUID()}/cnpj-lookup`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ cnpj: "11222333000181" })
|
||||
.expect(404);
|
||||
expect(lookupCnpjMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports manual partner CRUD: create, list, update", async () => {
|
||||
const id = await createReseller("qa-test- Revenda D");
|
||||
|
||||
const created = await agent(app)
|
||||
.post(`/api/v1/resellers/${id}/partners`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ name: "qa-test- Sócio Manual", document: "12345678909", qualification: "Sócio" })
|
||||
.expect(201);
|
||||
expect(created.body.source).toBe("manual");
|
||||
expect(created.body.will_sign).toBe(false);
|
||||
|
||||
const listRes = await agent(app)
|
||||
.get(`/api/v1/resellers/${id}/partners`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(200);
|
||||
expect(listRes.body).toHaveLength(1);
|
||||
|
||||
const updated = await agent(app)
|
||||
.patch(`/api/v1/resellers/${id}/partners/${created.body.id}`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ cpf: "12345678909", willSign: true, signatureEmail: "socio@example.com" })
|
||||
.expect(200);
|
||||
expect(updated.body.cpf).toBe("12345678909");
|
||||
expect(updated.body.will_sign).toBe(true);
|
||||
expect(updated.body.signature_email).toBe("socio@example.com");
|
||||
});
|
||||
|
||||
it("returns 404 for a partner update targeting a partner that doesn't belong to the reseller", async () => {
|
||||
const idA = await createReseller("qa-test- Revenda E1");
|
||||
const idB = await createReseller("qa-test- Revenda E2");
|
||||
|
||||
const created = await agent(app)
|
||||
.post(`/api/v1/resellers/${idA}/partners`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ name: "qa-test- Sócio Cruzado" })
|
||||
.expect(201);
|
||||
|
||||
await agent(app)
|
||||
.patch(`/api/v1/resellers/${idB}/partners/${created.body.id}`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ willSign: true })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
`);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
107
packages/database/src/repositories/reseller-partners.ts
Normal file
107
packages/database/src/repositories/reseller-partners.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user