feat(api): Quotes, Client Registration e esqueleto de Contratos
Fecha o núcleo comercial da Fase 2/3: motor de precificação de Ofertas (feito diretamente, dado o risco de erro de arredondamento/regra de negócio), Cadastro de Cliente e esqueleto de Contratos (via 2 subagentes em paralelo, salvos manualmente após ambos baterem no limite de gasto mensal da conta — trabalho não commitado nos worktrees foi revisado arquivo a arquivo e copiado para main, com as peças faltantes completadas diretamente). - Quotes: pricing.ts porta as fórmulas do legado (eden.md §7/§8) — needsApproval, faixas de condição de pagamento, efeito legal da fidelidade condicionado a approval_status==='approved'. Fluxo criar→decidir aprovação→fechar negócio (vinculado a um cadastro de cliente, com guarda contra fechamento duplo). - Client Registration: cadastro direto (eden.md §3), MVP autenticado-only (sem token público de autoatendimento, upload ou lookup CNPJ/CEP nesta fase — documentado em comentário). - Contracts: máquina de estados do ADR-0006 imposta via SELECT...FOR UPDATE + tabela de transições, com signed_at/activated_at e histórico de status; 4 das 11 tabelas do ADR-0006 (as demais adiadas). 2 bugs próprios corrigidos antes do commit: UpdateQuoteInput usava COALESCE para campos financeiros (undefined do JS apagaria valor em update parcial) — campos tornados obrigatórios; User.reseller_id/ can_approve_discount faltavam em types.ts apesar das colunas já existirem no banco desde migrations anteriores. Verificado: pnpm turbo run typecheck build (28/28), pnpm test (15/15), e todo o fluxo testado ao vivo via curl contra a stack Docker real, incluindo cenários de erro (transição de status inválida, FK de produto inexistente, fechamento duplo, 401 sem token) e trilha de auditoria conferida no Postgres. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
13
apps/api/src/quotes/dto/close-deal.dto.ts
Normal file
13
apps/api/src/quotes/dto/close-deal.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsUUID } from "class-validator";
|
||||
|
||||
/**
|
||||
* MVP scope: closing a deal requires an already-existing client
|
||||
* registration id (Customer 360 module lands in parallel — once merged,
|
||||
* a richer "create+link in one step" flow can be added, mirroring
|
||||
* eden.md §6.1's two-path close-deal flow). For now this just performs
|
||||
* the lock (deal_status='fechado' + client_registration_id set).
|
||||
*/
|
||||
export class CloseDealDto {
|
||||
@IsUUID()
|
||||
clientRegistrationId!: string;
|
||||
}
|
||||
53
apps/api/src/quotes/dto/create-quote.dto.ts
Normal file
53
apps/api/src/quotes/dto/create-quote.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { QuoteItemInputDto } from "./quote-item-input.dto.js";
|
||||
|
||||
export class CreateQuoteDto {
|
||||
@IsUUID()
|
||||
resellerId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
clientName!: string;
|
||||
|
||||
@IsOptional() @IsString() clientCompany?: string;
|
||||
@IsOptional() @IsEmail() clientEmail?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
clientPhone!: string;
|
||||
|
||||
@IsIn([0, 12, 24, 36, 48])
|
||||
contractPeriod!: number;
|
||||
|
||||
/** Only meaningful if strictly less than contractPeriod — see pricing.ts. */
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
fidelityPeriod?: number;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => QuoteItemInputDto)
|
||||
items!: QuoteItemInputDto[];
|
||||
|
||||
@IsOptional() @IsNumber() @Min(0) implGeral?: number;
|
||||
@IsOptional() @IsNumber() @Min(0.01) proposedMonthlyTotal?: number;
|
||||
|
||||
@IsOptional() @IsIn(["À vista", "1+1", "1+2"]) implPaymentCondition?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
10
apps/api/src/quotes/dto/decide-approval.dto.ts
Normal file
10
apps/api/src/quotes/dto/decide-approval.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class DecideApprovalDto {
|
||||
@IsIn(["approved", "rejected"])
|
||||
decision!: "approved" | "rejected";
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
19
apps/api/src/quotes/dto/quote-item-input.dto.ts
Normal file
19
apps/api/src/quotes/dto/quote-item-input.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { IsNumber, IsUUID, Min } from "class-validator";
|
||||
|
||||
/**
|
||||
* Deliberately minimal — only what the client can legitimately choose
|
||||
* (which product, how many). Unit price, product name, and the 5 price-
|
||||
* tier snapshots are always derived server-side from the live product
|
||||
* record + the quote's contract_period (see QuotesService.buildItems) —
|
||||
* a client-supplied price is never trusted, strengthening the legacy's
|
||||
* own principle (which snapshotted at add-time but still took unit_price
|
||||
* from client state at that moment).
|
||||
*/
|
||||
export class QuoteItemInputDto {
|
||||
@IsUUID()
|
||||
productId!: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
quantity!: number;
|
||||
}
|
||||
51
apps/api/src/quotes/dto/update-quote.dto.ts
Normal file
51
apps/api/src/quotes/dto/update-quote.dto.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { QuoteItemInputDto } from "./quote-item-input.dto.js";
|
||||
|
||||
/**
|
||||
* Every edit resubmits the full commercial state (contractPeriod, items,
|
||||
* etc.) rather than a sparse patch — the service always recomputes the
|
||||
* entire pricing picture together (pricing.ts), so a partial edit that
|
||||
* only touched `notes` would still need the rest of the state to
|
||||
* recompute against; simplest and safest is to require it every time,
|
||||
* matching how the legacy's own edit screen always resubmits the whole
|
||||
* form. `resellerId` is intentionally absent — not changeable post-creation.
|
||||
*/
|
||||
export class UpdateQuoteDto {
|
||||
@IsOptional() @IsString() clientName?: string;
|
||||
@IsOptional() @IsString() clientCompany?: string;
|
||||
@IsOptional() @IsEmail() clientEmail?: string;
|
||||
@IsOptional() @IsString() clientPhone?: string;
|
||||
|
||||
@IsIn([0, 12, 24, 36, 48])
|
||||
contractPeriod!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
fidelityPeriod?: number;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => QuoteItemInputDto)
|
||||
items!: QuoteItemInputDto[];
|
||||
|
||||
@IsOptional() @IsNumber() @Min(0) implGeral?: number;
|
||||
@IsOptional() @IsNumber() @Min(0.01) proposedMonthlyTotal?: number;
|
||||
|
||||
@IsOptional() @IsIn(["À vista", "1+1", "1+2"]) implPaymentCondition?: string;
|
||||
@IsOptional() @IsString() @MinLength(0) notes?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user