Proposta · Fronteira do produto

Portal do Fornecedor

O mapa de domínio chama isto de maior decisão de escopo em aberto, e metade dela já estava escrita: o Anexo decidiu que o portal não passa pelo GrydAuth, e o Fornecedor decidiu que ele é projeto próprio. Faltava a outra metade — como o fornecedor entra, onde ele coloca o preço, e o que impede um concorrente de ler a proposta do outro. Esta página propõe as três respostas e nada além delas: o portal da v1 serve a um ato só, responder a uma cotação.

fecha o gate 13 · M7 da contra-análise desbloqueia QuotationRequest · QuotationResponse 2 agregados · 3 permissões · 4 slugs a acrescentar v1.0 · 08/09/2026

Ponto de partida

Dez decisões

Isto é proposta, não decisão fechada. As dez decisões abaixo saíram da conversa de 08/09/2026 e estão escritas para serem lidas contra o acervo, não para serem implementadas ainda. Três pontos continuam abertos e estão marcados no texto: o formato do questionário técnico da cotação, quem escolhe a alternativa vencedora quando o fornecedor manda três, e se o portal ganha idioma próprio antes de existir fornecedor estrangeiro de verdade.

Decisão 1 · Sem GrydAuth

O portal não cria usuário de tenant. A plataforma não tem identidade externa — ExternalIdentity é vínculo de usuário interno, e não existe UserType, IsExternal nem Guest. Podar permissões de um usuário de tenant para acomodar quem não é do tenant abre uma porta que ninguém fecha depois. A identidade do fornecedor é do Nexio, e o uploadedBy do arquivo continua sendo o serviço, como o Anexo já escreveu.

Decisão 2 · A conta nasce do convite

Ninguém se cadastra para cotar. O primeiro convite a um contato com papel comercial e e-mail válido cria o PortalUser em Invited. Vários por fornecedor é o normal, não a exceção: contato é por pessoa e pode estar amarrado a um estabelecimento, então filial e comprador diferentes já são contatos diferentes no cadastro que existe.

Decisão 3 · Sem credencial na v1

O link é o único segredo, e ele é curto. Não há senha, reset, bloqueio por tentativa nem cofre de credencial — nada disso existiria na plataforma e teria que nascer aqui, virando a maior superfície de segurança do produto. mfaEnabled continua no modelo como opção do tenant, desligada na v1: quando ligada, o envio pede um código enviado ao mesmo canal do convite.

Decisão 4 · Alcance vem do vínculo

A conta diz quem é; ela não concede nada. Todo alcance vem do link de acesso, que é por convite. A conta só agrega os convites vivos daquele fornecedor. Sem esta regra, dataAccessScope vira uma segunda fonte de verdade de autorização — exatamente o defeito que o ADR 0007 do Gryd.IO eliminou quando apagou allowCrossTenant.

Decisão 5 · Um link, com hora para morrer

Um link ativo por (PortalUser × cotação × rodada), válido até o envio ou o encerramento da rodada — o que vier primeiro. Reemitir invalida o anterior. Rodada nova é link novo. "Uso único" seria pior: impediria o fornecedor de revisar antes do prazo, que é o comportamento normal de quem cota.

Decisão 6 · Token vira sessão

O token é trocado por sessão no primeiro acesso e some da URL. Token em caminho de URL vaza em log de proxy, em histórico e no Referer. O primeiro GET resolve o convite, abre sessão curta e redireciona para um endereço sem segredo nenhum. O fornecedor volta quantas vezes quiser dentro da validade.

Decisão 7 · Onde o preço entra

Tela linha a linha é a fonte da verdade; planilha é importador para a mesma tela; PDF é anexo, nunca dado. Aceitar preço em PDF acaba com o comparativo e transforma o módulo de cotação em gaveta de arquivo. A planilha obedece à convenção de importação — tudo ou nada, com dryRun obrigatório —, e sai gerada por cotação, com item, quantidade e unidade pedida travados.

Decisão 8 · O fornecedor nunca paga a lacuna do comprador

Falta de cadastro do lado de cá não recusa envio de lá. Se o fornecedor cota numa unidade sem fator de conversão, a resposta entra como veio e a linha nasce marcada PendingConversion; quem resolve é o comprador, cadastrando o fator — e a linha recalcula. Recusar a submissão trancaria o fornecedor fora do prazo por um problema que ele não pode resolver.

Decisão 9 · Uma taxa para todos

O registro congela a taxa da submissão de cada um; o comparativo converte tudo na data de encerramento da rodada. Cinco propostas em dólar submetidas em cinco dias diferentes, comparadas com cinco taxas, comparam câmbio e não preço. A regra da Moeda continua valendo para o registro; o que esta página acrescenta é a data única da comparação.

Decisão 10 · O comprador pode digitar, e fica marcado

enteredBy = Portal | Buyer no cabeçalho da resposta. Fornecedor pequeno responde por e-mail, e recusar isso trava a cotação. Mas comprador digitando proposta de fornecedor é o vetor clássico de fraude em compras: exige permissão própria, aparece no comparativo e entra na trilha. Marca, não trava — a mesma postura das três marcas do motor de aprovação.

A decisão central

Como o fornecedor entra

Havia três saídas, e duas já tinham sido recusadas por escrito antes desta página existir. A terceira é a que está proposta aqui, e ela não é meio-termo: é identidade nomeada sem segredo guardado.

As três saídas, e por que sobra uma
SaídaO que dáO que custaEstado
Usuário do tenant com permissões podadasAutenticação, papéis e trilha prontos, sem construir nadaCria usuário de tenant para quem não é do tenant. Um erro de papel, uma consulta sem filtro ou uma permissão nova semeada por migration e o fornecedor enxerga o compradorRecusada pelo Anexo, nominalmente
Portal com conta e senhaIdentidade forte, sessão longa, MFASenha, reset, bloqueio por tentativa, política de expiração e cofre — nada disso existe na plataforma e tudo nasceria dentro do Nexio. Vira módulo, não telaAdiada · gancho declarado
Conta sem credencial + link por conviteAutor nomeado em todo acesso, anexo e envio; revogação por pessoa e por convite; zero atrito de adoçãoO e-mail (ou o número) do contato passa a ser o perímetro. É um perímetro conhecido — é o mesmo do "esqueci minha senha" de qualquer produto —, mas precisa estar escrito como decisãoProposta

O acesso, ponta a ponta, em cinco passos

1. O comprador convida um contato do fornecedor na cotação. Se aquele contato ainda não tem PortalUser, ele é criado em Invited — sem formulário, sem espera, sem confirmação do outro lado.

2. O Nexio gera um token opaco de 128 bits, guarda só o hash e envia o link pelos canais escolhidos. O texto claro existe uma vez, no envio, e nunca é gravado.

3. O fornecedor abre o link. O GET resolve o convite, marca a conta como Active, abre sessão curta e redireciona para a cotação num endereço sem token. A partir daí o segredo não circula mais.

4. Ele preenche, salva rascunho, sobe anexo, importa planilha e volta quantas vezes quiser — sempre pelo mesmo link, até o prazo.

5. Ele envia. O link morre no envio. Se precisar rever o que mandou, o comprador reemite — e a reemissão é um ato registrado, não um efeito colateral.

O que o link não protege, dito na cara

Quem lê a caixa de entrada do contato responde a cotação no lugar dele. Isso é verdade e é aceito: é o mesmo perímetro de qualquer fluxo de recuperação de senha do mercado, e trocar por senha só mudaria o perímetro de lugar — passaria a ser o mesmo e-mail, no dia em que ele esquecesse a senha.

O que se faz é encolher a janela e o alcance: o link abre uma cotação, de um fornecedor, numa rodada, até uma data. Ele não abre o cadastro, não abre outra cotação, não abre a rodada seguinte e não sobrevive ao envio. Encaminhar o link a um colega é possível, e nesse caso a trilha registra o PortalUser convidado — o fornecedor está agindo dentro da própria casa. Quando isso incomodar, a saída é convidar o colega, que cria outro PortalUser do mesmo fornecedor.

MFA — opção do tenant, desligada na v1

Ligada, ela não vira autenticação de sessão: vira degrau no envio. Um código de seis dígitos enviado ao mesmo canal do convite, exigido apenas no ato que congela preço. É o ponto caro do fluxo e o único que justifica atrito. Fica declarada como configuração por tenant, com padrão false, para que ligá-la depois não mexa em modelo nem em rota.

Por que a conta existe, se o link já autoriza. Porque autor não é o mesmo que autorização. O Anexo exige supplierId obrigatório e portalUserId opcional — desenhado quando a hipótese era link anônimo. Com a conta nascendo do convite, o campo opcional passa a estar preenchido no caminho normal, e o Nexio ganha de graça o que o link sozinho não dá: quem exatamente abriu, quem exatamente enviou, e como cortar o acesso de uma pessoa sem cancelar a cotação inteira. O campo continua anulável no modelo — mudar isso obrigaria a reclassificar anexo já gravado, e não há motivo.

Modelo

Mapa de entidades

PortalUser aggregate root · Nexio A pessoa do lado de fora. Nasce do convite, aponta para SupplierContact e carrega supplierId congelado. Estado de conta, idioma e último acesso. Nenhum campo de credencial na v1.
PortalAccessLink aggregate root · Nexio O convite que autoriza. Hash do token, alvo polimórfico, validade, canais por onde saiu, revogação e o rastro da reemissão. É a única fonte de alcance do portal.
PortalTargetRegistry registro em código · não é tabela Um descritor por targetType: resolve o fornecedor, a empresa, o prazo da rodada e o que a sessão pode ler e escrever ali. Mesmo padrão do registro de donos do Anexo — tipo sem descritor derruba a inicialização.
SupplierContact Fornecedor · já existe roles[], isPortalAdmin, supplierEstablishmentId?, e-mail e celular. É daqui que sai o convidado, e é aqui que mora o consentimento de canal.
QuotationRequest · QuotationResponse Cotação · a especificar O único alvo da v1. A lista de convidados é da cotação; o link pendura nela. Os campos que esta proposta exige estão em § O que exige das outras specs.
Attachment Anexo · já especificado uploadedByKind = PortalUser, supplierId obrigatório, nasce Internal. O isolamento entre concorrentes já está resolvido lá e não é redecidido aqui.

Nem tudo o que tem nome é classe. PortalSession não é entidade: é um cookie assinado de vida curta, derivado do link, e a tentativa de guardá-lo em tabela só produziria uma segunda cópia do alcance para sair de sincronia com a primeira. QuotationRequestInvitation também não nasce aqui — a cotação precisa da lista de convidados de qualquer jeito, e o PortalAccessLink aponta para ela.

Por que o link é entidade e não três colunas no convite. A regra de contar consumidores diria "um consumidor, vira campo" — e ela foi aplicada, com o resultado invertido pelo segundo critério, que é a consequência do erro. Errar aqui é concorrente lendo proposta alheia, a consequência mais cara do acervo inteiro. E um convite não segura o que um segredo precisa ter: emissão, expiração, revogação, reemissão com histórico e canal de entrega. Três colunas guardariam o estado atual e perderiam a pergunta que a auditoria faz — quantas vezes este link foi reemitido, e para onde foi cada um.

Domínio

Campos · PortalUser

Herda de TenantScopedAggregateRoot. Id, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy e o bloco de soft delete vêm do BaseEntity e não estão repetidos aqui.

Legenda: obrig sempre · cond conforme a operação · opc opcional · gerado calculado pelo sistema ou pelo banco.

CampoTipoRegra
supplierContactIdGuidobrigSupplierContact, um para um. Imutável: o contato mudou de empresa, a conta não vai junto — cria-se outra. É o que garante que o cadastro continue sendo o dono do dado da pessoa e a conta, só do acesso.
supplierIdGuidgeradoResolvido do contato no nascimento e congelado. É o que o token devolve, é o que o Anexo exige gravado no anexo, e é o que faz o isolamento entre concorrentes ser comparação de coluna.
loginstring(320)geradoO e-mail do contato, normalizado. Não é credencial — é rótulo de identidade e endereço padrão de convite. Único por tenant entre contas não revogadas.
accountStatusenumobrigInvited · Active · Suspended · Revoked. Nasce Invited; vira Active no primeiro acesso; Suspended e Revoked são atos do comprador, por rota própria, com motivo. Conta não ativa não abre link nenhum, mesmo válido.
mfaEnabledboolgeradoDerivado da configuração do tenant, não editável por conta na v1. Padrão false. Quando ligado, exige código no envio — nunca no acesso.
localestring(5)opcIdioma do convite e da tela. Nulo cai no idioma do tenant, como a Notificação já resolve. É aqui que o gancho de idioma do fornecedor deixa de ser hipotético — ver § Canais.
lastAccessOndatetimeoffset?geradoÚltimo acesso bem-sucedido. Serve à tela do comprador ("convidado há seis dias, nunca abriu") e é o insumo do lembrete de prazo.
dataAccessScopeenumgeradoSupplier · Establishment. Filtra, nunca concede — ver decisão 4. Derivado de supplierEstablishmentId do contato: contato amarrado a uma filial só vê os convites daquela filial.

Não existem, e a ausência é a decisão: passwordHash, passwordChangedAt, failedAttempts, lockoutUntil, refreshToken. Nenhum segredo de longa duração é guardado do lado de fora.

Entrega

O convite e os canais

O link sai por e-mail e WhatsApp, os dois escolhidos por convite e registrados no que foi enviado de fato. E-mail é o padrão e existe hoje; WhatsApp é decisão de produto tomada e dependência de plataforma que ainda não existe — o módulo de notificações da plataforma entrega e-mail e push, e não tem canal de mensageria.

CanalEndereçoCondiçãoEstado
E-mailSupplierContact.emailObrigatório para convidar. Contato sem e-mail é recusado antes de gerar tokenDisponível
WhatsAppSupplierContact.mobileConsentimento registrado e número verificado. Sem consentimento, o canal é silenciosamente omitido e a tela do comprador diz por quêDepende da plataforma

O que WhatsApp obriga, e que e-mail não obrigava

Consentimento é dado do cadastro, não do envio. Mensagem de negócio para número de pessoa física exige aceite registrado, com data e origem, e o canal só pode ser usado enquanto ele valer. Isso são dois campos no SupplierContactwhatsappConsent e whatsappConsentOn — e uma tela de cadastro que os mostra. Sem eles o produto não tem como provar o aceite, e é o tipo de prova que só falta quando já é tarde.

O texto não é livre. Mensagem iniciada pela empresa passa por modelo aprovado pelo provedor, com variáveis fixas. Isso combina bem com o que a Notificação já decidiu — o Nexio não modela template, semeia por slug e dispara com variáveis —, mas acrescenta um ciclo de aprovação externo que não existe no e-mail.

E o link continua sendo um segredo em trânsito. Mandar por dois canais dobra as chances de chegar e dobra a superfície. A mitigação é a mesma da decisão 5: janela curta, alcance de uma cotação, morte no envio.

Quatro avisos novos

O catálogo de slugs da Notificação hoje fala com quem está fora do tenant em um caso só, o controle bancário. Esta proposta acrescenta quatro, e eles precisam entrar lá com resolvedor de destinatário próprio:

portal.invitation_sent · o convite com o link — ao PortalUser · portal.link_reissued · reemissão, e o aviso diz que o anterior deixou de valer · portal.round_closing · lembrete de prazo a quem foi convidado e não enviou · portal.response_submitted · ao comprador, quando uma resposta entra.

E é aqui que o idioma do fornecedor deixa de ser gancho. A Notificação declarou locale no fornecedor como previsto e não implementado, "porque só o portal do fornecedor torna isso frequente". O portal chegou. Aviso em português para fornecedor estrangeiro deixa de ser incômodo e passa a ser instrução de trabalho que ninguém lê.

O ato

A resposta · rascunho, planilha e envio

Três estados e uma fronteira. Rascunho é do fornecedor e ninguém do lado comprador o enxerga; envio é o ato que congela; e o que estiver em branco no envio deixa de ser omissão para virar resposta.

Rascunho

Salvo a cada alteração, invisível ao comprador, editável até o prazo. Campo vazio aqui significa "ainda não preenchi". Anexos já valem, e já nascem isolados.

Envio

Congela preço, taxa de câmbio da submissão e os fatores de conversão conhecidos. Consome o link. Exige código de confirmação se o tenant tiver ligado o MFA. A partir daqui o comprador enxerga.

Enviada

Imutável. Correção é rodada nova ou reabertura explícita pelo comprador, com motivo — nunca edição em cima, porque a proposta já entrou no comparativo de alguém.

Linha em branco é resposta, e o envio é que a torna explícita

No envio, toda linha sem preço vira NotQuotedvalor gravado, não nulo. A diferença importa: nulo obriga o comparativo a adivinhar se o fornecedor recusou o item ou se esqueceu dele, e "não cotado" lido como zero é o defeito mais caro que um comparativo pode ter. Com o valor explícito, a tela do comprador escreve a frase certa e o mapa de cotação sabe que aquele fornecedor não disputa aquela linha.

O fornecedor pode marcar NotQuoted de propósito na tela, antes do envio, e nesse caso o campo de preço fecha. É a mesma informação por dois caminhos, e o segundo é o que permite enviar uma proposta parcial sem parecer incompleta.

A planilha é a mesma tela, por outro caminho

Sai gerada por cotação, nunca um modelo genérico: uma linha por item da rodada, com item, descrição, quantidade e unidade pedida travadas. O fornecedor preenche preço unitário, unidade em que cotou, prazo, marca e observação. Se ele pudesse alterar quantidade, o comparativo deixaria de comparar.

Tudo ou nada, com dryRun obrigatório, como manda a convenção de importação — a exceção assíncrona e parcial é do catálogo e não vale aqui. O dry-run devolve o que entraria e o que falharia, linha a linha, antes de qualquer gravação. Falha de estrutura recusa o arquivo inteiro; linha em branco não é falha, é NotQuoted.

Importar não envia. O arquivo cai no rascunho, o fornecedor confere na tela e envia de lá. Nenhum ato que congela preço acontece por upload.

Quando o comprador digita

O fornecedor que responde por e-mail existe e vai continuar existindo. A resposta entra pela tela do comprador, na mesma entidade, com enteredBy = Buyer e enteredByUserId preenchidos. Três consequências que não são da Cotação e por isso estão aqui:

1. Não passa pelo caminho do portal, então a regra de anexo do portal não se aplica: não há token, não há supplierId vindo dele, e o anexo entra pela rota interna com a visibilidade do tipo. 2. Exige permissão própria — não é o mesmo ato que registrar uma cotação recebida. 3. Aparece no comparativo como origem, e entra na trilha com ação própria. Sem as três, o campo vira decoração.

Comparabilidade

Unidade, comparabilidade e alternativa

O fornecedor cota na unidade dele. Pedimos em UN e ele vende em CX12; pedimos em KG e ele fatura em SC50. Isso é o normal, não a exceção — e a Unidade de Medida já resolveu a conversão. O que faltava era decidir o que acontece quando o fator não existe.

comparabilityStatus — a marca fica na linha da resposta
ValorQuandoO que o comparativo fazQuem resolve
ComparableHá fator vigente na hierarquia ItemSupplierItem → global intraclasseEntra na comparação, com o fator em snapshot na linhaNinguém
PendingConversionA unidade cotada não tem fator para a unidade pedida, e as duas são da mesma classeMostra a linha fora da comparação, com o preço na unidade do fornecedor à vistaO comprador, cadastrando o fator. A linha recalcula e tira o snapshot naquele momento
NotComparableClasse Serviço, que por decisão não converteMostra lado a lado sem número derivado. É trabalho de leitura humana, e sempre foiNinguém — e é essa a diferença

As duas marcas não podem cair na mesma fila. PendingConversion é pendência: existe uma ação que a resolve, ela tem dono e some quando é feita. NotComparable é estrutura: não há fator a cadastrar, porque serviço não converte. Juntar as duas numa "fila de pendências de cotação" cria uma lista que nunca zera — e lista que nunca zera é lista que ninguém abre.

Cadastrar o fator é curadoria de catálogo, e o mecanismo já existe. É o processo ITEM do motor de aprovação, com a política que o tenant já configurou para o catálogo. Nada novo: a cotação avisa, o catálogo decide, e a linha recalcula quando o fator passa a existir.

Não existe erro para unidade sem conversão — e a ausência é a decisão

Seria fácil devolver UOM_CONVERSION_NOT_CONFIGURED_UNPROCESSABLE no envio e mandar o fornecedor "cotar direito". O código existe no acervo e não é usado neste caminho: ele recusaria a submissão de quem não pode consertar o cadastro do comprador, na véspera do prazo, com a proposta pronta. O custo de aceitar é uma linha marcada num comparativo; o custo de recusar é uma cotação a menos.

Alternativa é linha extra na mesma resposta

Marca diferente, embalagem diferente, prazo diferente — tudo isso é outra linha apontando para a mesma linha da cotação, e não outra resposta. Resposta separada obrigaria o comprador a comparar dois documentos do mesmo fornecedor e a decidir qual deles "conta" no total.

Exatamente uma alternativa por linha da cotação é isPrimary, marcada pelo fornecedor. Sem isso o total da proposta é ambíguo — três marcas cotadas viram três somas possíveis. O comprador enxerga todas e pode escolher a que não é a principal; quando escolhe, a escolha carrega motivo, no mesmo uso de "quem não é o mais barato" que o Motivo já catalogou. Em aberto: se a escolha de alternativa vira uma decisão registrada na cotação ou só um campo do mapa comparativo.

Dinheiro

Câmbio e comparativo

A Moeda fixou que se converte uma vez, na submissão. Num documento interno isso é uma data só. Numa cotação são n submissões, uma por fornecedor, em dias diferentes — e comparar as n com as n taxas compara câmbio, não preço.

Duas conversões, com propósitos diferentes

No registro: cada QuotationResponse congela a taxa da própria submissão, na moeda funcional da empresa do documento, fatia a fatia, com o resíduo pela regra única do Centro de Custo. Isso não muda nada do que já está escrito, e é o valor que vira empenho se aquela proposta virar pedido.

Na comparação: o mapa comparativo converte todas as propostas na taxa da data de encerramento da rodada. Uma taxa, todos os concorrentes, a mesma régua. A tela mostra a data e a taxa usadas, porque comparativo cuja régua não está à vista é comparativo que ninguém defende numa auditoria.

Rodada nova converte na data de encerramento dela. Prorrogar o prazo muda a régua da comparação e não muda nenhum registro — o que é o comportamento certo, e precisa estar escrito para não parecer defeito.

Por que não a data de abertura. Seria estável desde o início e conhecida por todos — e premiaria quem enviou tarde numa moeda em queda, porque o preço dele foi formado com a informação de câmbio que os outros não tinham. A data de encerramento é a única em que todo mundo já jogou.

Segurança

Isolamento entre concorrentes

Numa cotação com cinco convidados, nenhum pode ver nada do outro. Isso já foi resolvido pelo Anexo e não é redecidido aqui: o portal devolve os anexos cujo supplierId é o do vínculo, mais os anexos Supplier da própria cotação, que são para todos por definição. É comparação de coluna, não regra especial no código de autorização.

O que cada um enxerga, e por qual mecanismo
QuemEnxergaMecanismo
Fornecedor A, no portalA cotação, as linhas, os anexos Supplier do documento, a própria resposta e os próprios anexosSessão derivada do link → supplierId, filtro em toda consulta
Fornecedor A sobre o fornecedor BNada. Nem que B foi convidado, nem quantos foramA lista de convidados nunca sai pelas rotas do portal
Fornecedor A sobre a própria proposta enviadaSó depois de reemissão do link, e em leituraconsumedAt preenchido fecha o link; a reemissão é ato do comprador
CompradorTudo que foi enviado. Rascunho de fornecedor, nuncaEstado da resposta, não permissão

Uma mudança no Anexo, e só uma. A rota lá escrita é POST /portal/rfq/{token}/attachments, desenhada quando o token viajava em toda chamada. Com a troca por sessão da decisão 6, ela passa a ser POST /portal/quotations/{id}/attachments, e o supplierId vem da sessão em vez de vir do path. O conteúdo da RN-ATT-21 não mudasupplierId obrigatório, nasce Internal, defaultVisibility do tipo ignorado. Muda o formato da rota e a origem do dado, que continua sendo o vínculo. Junto vai a correção de "link de uso único" para "link por convite", em três lugares daquela página.

Contrato

Autorização · a exceção declarada

A convenção do produto diz que um endpoint declara exatamente uma permissão, no formato verbo:recurso, e que nada é implicado por outra. As rotas /portal/* não declaram nenhuma, e isso precisa estar escrito aqui — senão a primeira pessoa a implementar cria respond:quotation como permissão de tenant, semeia por migration, e a saída recusada pelo Anexo volta pela janela.

SuperfícieAutoriza porFalha responde
/portal/*Sessão derivada do linkportalUserId + supplierId + alvo + rodada. Nenhuma permissão do catálogo é consultada401 sempre que o vínculo não se sustenta; 403 quando o vínculo é válido e o fornecedor está bloqueado
Rotas do compradorPermissão do catálogo, uma por rota, como todo o resto do produto403 · 404 pelo escopo de empresa

Três permissões novas do lado comprador, todas em verbo:recurso singular:

PermissãoCobre
read:portal-userVer as contas do portal de um fornecedor, último acesso e estado
manage:portal-userSuspender, reativar e revogar conta. Não cria — quem cria é o convite
manage:portal-accessEmitir, reemitir e revogar link. É a permissão que reabre uma proposta já enviada, e por isso não anda junto com a de convidar

Uma quarta é exigida e não nasce aqui: enter-on-behalf:quotation-response, do lado da Cotação, para o lançamento pelo comprador. Fica declarada em § O que exige das outras specs — permissão sem endpoint na própria página é como gancho órfão vira dívida. Cada permissão nova precisa de migration com backfill idempotente, porque a linha de permissão é por tenant e não há caminho de replay.

Fronteiras

O que exige das outras specs

Esta proposta não escreve a Cotação. Ela declara o que a Cotação precisa carregar para o portal existir, e o que muda nas páginas já publicadas — com a regra de que nada aqui é aplicado lá antes de esta proposta ser validada.

OndeO que mudaPor quê
AnexoRota do portal passa a ser por sessão; "link de uso único" vira "link por convite"; a assimetria de portalUserId ganha a nota de que o caminho normal preencheDecisões 2 e 6
FornecedorPortalUser sai de "existe no modelo" e vira entidade especificada; SupplierContact ganha whatsappConsent e whatsappConsentOn; o locale do fornecedor deixa de ser ganchoDecisões 2 e 3 · § Canais
NotificaçãoQuatro slugs novos com resolvedor de destinatário externo; canal WhatsApp declarado; cascata de idioma passando pelo PortalUser§ Canais
MotivoDois usos novos em appliesTo: PortalLinkRevoke e PortalUserSuspendMotivo é dado, e ato unilateral do comprador carrega motivo
Modelo de ClassesDuas caixas novas na banda do fornecimento; PortalUser deixa de ser condicional; enums PortalAccountStatus, PortalTargetType, PortalChannel, ComparabilityStatus, QuoteEntrySourceConsequência das duas anteriores
Cotação · a especificarLista de convidados por (fornecedor × contato); round e closesAt na rodada; enteredBy + enteredByUserId e NotQuoted na resposta; quotedUomId, comparabilityStatus, isPrimary e answersRequestItemId na linha; taxa da submissão no cabeçalho e taxa da comparação no mapaÉ o alvo único da v1 — sem esses campos o portal não tem onde escrever
Mapa de domínio e contra-análiseGate 13 fecha; o M7 sai da lista de achados abertos; a fronteira "Portal do fornecedor — a decidir" vira "decidido, v1 restrita"Era a última decisão do gate antes da Requisição

A ordem importa e é contraintuitiva. A Requisição vem antes da Cotação no sequenciamento, e mesmo assim o portal precisava ser decidido agora: ele é a única decisão do gate que muda modelo já publicado. Decidi-lo depois da Requisição significaria voltar em anexo já gravado e em contato já cadastrado sem saber de quem eles são — que é exatamente o argumento com que o Anexo justificou ter modelado o caminho do portal antes de o portal existir.

Dependências

O que falta na plataforma

Esta proposta foi desenhada para não depender de nada que não exista — é por isso que não há credencial na v1. Ainda assim, três itens da pauta com o Gryd.IO passam por aqui, e um deles é novo. O levantamento completo é assunto de documento próprio, no molde do GrydFiles: o que precisaria existir na plataforma para o portal ser um módulo dela, e não um canto do Nexio.

ItemEstado na plataformaO que esta proposta faz
Identidade externaNão existe. ExternalIdentity é vínculo de usuário interno; sem UserType, IsExternal ou GuestContorna. A identidade do portal é do Nexio e não toca o GrydAuth
Canal de mensageriaNão existe. O módulo entrega e-mail e push; não há WhatsApp nem SMSDepende. O modelo já carrega canal e consentimento; a entrega espera a plataforma
Armazenamento de arquivo com antivírusZero no repositório — é o épico do GrydFilesDepende. O anexo do portal é o caso que mais precisa dele: é o único em que alguém de fora escreve bytes na nossa infraestrutura
Coluna de ação na trilhaTreze colunas, nenhuma é ação; hoje vai em AdditionalDataConvive. As ações do portal seguem a convenção <entidade>.<verbo-no-passado> como o resto

Interface

Telas

Portal · entrada

Não é tela de login. O link abre direto na cotação, com o nome do fornecedor e do contato à vista — é assim que ele confere que o link é dele. Link morto mostra uma página que diz o que aconteceu e oferece "pedir novo link", que abre um aviso ao comprador; nunca um formulário.

Portal · a cotação

Cabeçalho com prazo em destaque, condições, anexos Supplier do comprador. Grade de linhas com quantidade e unidade pedida travadas, preço, unidade cotada, prazo, marca. Botão de não cotar por linha. Salvamento contínuo, com "salvo às 14:32" à vista — a ansiedade de perder o preenchimento é o que faz o fornecedor voltar para o e-mail.

Portal · planilha

Baixar modelo, subir, ver o resultado do dry-run em duas listas — o que entra e o que falha, com o motivo em cada linha. Confirmar joga no rascunho, nunca envia. Falha de estrutura mostra o arquivo inteiro recusado e explica o que a planilha tinha de errado.

Portal · envio

Revisão em uma página: total, linhas não cotadas contadas, alternativas, anexos. Se o tenant ligou o MFA, o código entra aqui. Depois do envio, uma tela de comprovante com data, hora e o que foi enviado — e o aviso de que o link se encerrou.

Comprador · convidados

Quem foi convidado, por quais canais o link saiu de fato, quem abriu, quem não abriu, quem enviou. Reemitir e revogar por linha, com motivo. É a tela que responde "por que o fornecedor X não respondeu" antes de alguém ligar para ele.

Comprador · comparativo

Origem de cada proposta à vista — portal ou digitada, e por quem. Linhas PendingConversion destacadas com o atalho de cadastrar o fator; linhas NotComparable agrupadas à parte, sem promessa de número. A taxa e a data usadas na comparação aparecem no cabeçalho, não num rodapé.

API

Endpoints

Portal · sem permissão de catálogo

RotaAutorizaDevolveErros
GET /portal/access/{token}o próprio tokenTroca o token por sessão curta, marca a conta Active, incrementa accessCount e redireciona para a cotação sem o token na URL401 · 403
GET /portal/quotations/{id}sessãoCabeçalho, condições, linhas com quantidade e unidade pedida, anexos Supplier do documento e o rascunho do próprio fornecedor401 · 403 · 404
GET /portal/quotations/{id}/templatesessãoA planilha modelo gerada para esta rodada, com item, quantidade e unidade travados401 · 404 · 409
PUT /portal/quotations/{id}/responsesessãoSalva o rascunho e devolve o recurso. Invisível ao comprador. Recusado depois do envio e depois do encerramento400 · 401 · 409 · 422
POST /portal/quotations/{id}/response:importsessãoImporta a planilha para o rascunho. dryRun=true devolve o que entraria e o que falharia, sem gravar. Tudo ou nada400 · 401 · 409 · 422
POST /portal/quotations/{id}/response:submitsessãoCongela a proposta, resolve NotQuoted, congela taxa e fatores, consome o link e avisa o comprador. Exige código quando o tenant ligou o MFA401 · 409 · 422
POST /portal/quotations/{id}/attachmentssessãoO caminho do fornecedor, com upload-intent e confirmação embutidos. supplierId vem da sessão; nasce Internal. Substitui a rota por token escrita no Anexo401 · 409 · 422
GET /portal/quotations/{id}/attachmentssessãoOs anexos do próprio supplierId mais os Supplier do documento. Comparação de coluna401 · 404

Comprador · uma permissão por rota

RotaPermissãoDevolveErros
GET /portal-usersread:portal-userAs contas de um fornecedor, com estado, canais consentidos e último acesso400 · 404
PATCH /portal-users/{id}/activatemanage:portal-userReativa conta suspensa. Devolve o recurso404 · 409
PATCH /portal-users/{id}/deactivatemanage:portal-userSuspende, com motivo. Links vivos param de abrir na hora, sem serem revogados um a um404 · 409 · 422
PATCH /portal-users/{id}/revokemanage:portal-userEncerra a conta, com motivo. Terminal: reconvidar cria conta nova404 · 409 · 422
POST /portal-access-links:issuemanage:portal-accessEmite ou reemite. Revoga o ativo, aponta supersededByLinkId e envia pelos canais pedidos, na mesma unidade de trabalho400 · 404 · 409 · 422
POST /portal-access-links/{id}:revokemanage:portal-accessMata um link sem emitir outro, com motivo404 · 409 · 422
GET /portal-access-linksmanage:portal-accessO histórico de uma cotação: emissões, reemissões, canais, aberturas. Não é read: — quem vê o histórico de acesso é quem pode mexer nele400 · 404

O ato sobre a resposta usa a forma POST /<recurso>:<ato> da convenção, aplicada a um recurso singular em vez de a uma coleção — a resposta de um fornecedor a uma cotação é uma só. Convidar não está aqui: é ato da cotação, com a permissão dela, e o link nasce como efeito na mesma transação.

Contrato

Contrato de erros

O status HTTP é derivado do sufixo do código, nunca da mensagem: o mapeador do Core reconhece nove sufixos — _NOT_FOUND → 404 · _ALREADY_EXISTS, _ALREADY_ASSIGNED e _CONFLICT → 409 · _FORBIDDEN, _ACCESS_DENIED e _BLOCKED → 403 · _UNAUTHORIZED → 401 · _UNPROCESSABLE → 422 · sem sufixo → 400.

Quando é seguro dizer o motivo, e quando não é. Token que não casa nenhum hash responde PORTAL_TOKEN_UNAUTHORIZED e nada mais — distinguir "não existe" de "expirou" para quem está adivinhando entregaria a existência de convites. Mas quando o hash casa, quem está do outro lado teve o token verdadeiro na mão, e aí dizer "expirou" ou "foi substituído" não revela nada que ele já não soubesse — e é a diferença entre uma tela que resolve o problema e uma que manda ligar para o comprador.

CódigoHTTPQuando
PORTAL_TOKEN_UNAUTHORIZED401O token não casa nenhum hash. Resposta única e genérica, em tempo constante
PORTAL_LINK_EXPIRED_UNAUTHORIZED401Casou, e a rodada já encerrou. A tela oferece pedir novo link
PORTAL_LINK_REVOKED_UNAUTHORIZED401Casou, e foi revogado — inclusive por reemissão, que revoga o anterior
PORTAL_LINK_ALREADY_CONSUMED_UNAUTHORIZED401Casou, e a resposta já foi enviada. Rever o enviado exige reemissão
PORTAL_MFA_CODE_INVALID_UNAUTHORIZED401Código errado ou vencido no envio, com o MFA ligado pelo tenant
PORTAL_USER_SUSPENDED_FORBIDDEN403Vínculo válido, conta suspensa ou revogada. A pessoa perdeu o acesso, o fornecedor não
PORTAL_SUPPLIER_BLOCKED_FORBIDDEN403Fornecedor bloqueado, ou em estado que não permite cotar. O eixo de bloqueio é do Fornecedor e vale aqui sem cópia de regra
PORTAL_USER_NOT_FOUND404Não existe neste tenant — ou o fornecedor está fora do escopo do usuário. Os dois respondem igual
PORTAL_LINK_NOT_FOUND404Idem, nas rotas do comprador
PORTAL_TARGET_TYPE_UNKNOWN400targetType sem descritor registrado. Em produção não chega: a inicialização já teria falhado
PORTAL_TARGET_NOT_ALLOWED_UNPROCESSABLE422Alvo fora do que a v1 aceita. Existe para o dia em que PurchaseOrder entrar pela metade
PORTAL_CONTACT_EMAIL_MISSING_UNPROCESSABLE422Convidar contato sem e-mail. Recusado antes de gerar token — token emitido e não entregue é o pior dos dois mundos
PORTAL_CHANNEL_NOT_CONSENTED_UNPROCESSABLE422WhatsApp pedido sem consentimento registrado, quando é o único canal pedido. Havendo e-mail junto, o canal é omitido e o convite sai
PORTAL_ACTIVE_LINK_CONFLICT409Já existe link ativo para (conta × alvo × rodada) e a emissão não pediu reemissão. Corrida de duas telas
PORTAL_ROUND_CLOSED_CONFLICT409Qualquer escrita depois do encerramento da rodada
PORTAL_RESPONSE_ALREADY_SUBMITTED_CONFLICT409Segundo envio. A proposta enviada é imutável; correção é rodada nova ou reabertura com motivo
PORTAL_IMPORT_STRUCTURE_UNPROCESSABLE422Planilha fora do modelo — coluna faltando, aba trocada, cabeçalho alterado. Recusa o arquivo inteiro
PORTAL_IMPORT_ITEM_UNKNOWN_UNPROCESSABLE422Linha que não corresponde a nenhum item da rodada. Recusa o arquivo inteiro, porque importação é tudo ou nada
PORTAL_IMPORT_QUANTITY_CHANGED_UNPROCESSABLE422Quantidade ou unidade pedida alteradas na planilha. São campos travados, e mexer neles é o que quebra o comparativo
PORTAL_PRIMARY_ALTERNATIVE_MISSING_UNPROCESSABLE422Mais de uma alternativa para a mesma linha e nenhuma marcada como principal. Sem ela o total da proposta é ambíguo

Não há código para unidade sem fator de conversão, e a ausência é deliberada — ver § Unidade. Limite de tentativas por token e por origem é middleware, não erro de domínio: não vira código de negócio nem entra nesta tabela.

Invariantes

Regras de negócio

RN-PRT-01Nenhuma identidade do portal existe no GrydAuth

O portal não cria, não altera e não consome usuário de tenant. O uploadedBy do arquivo continua sendo o serviço que subiu, como o Anexo já escreveu; a autoria de negócio é do Nexio.

RN-PRT-02A conta nasce do convite, nunca de cadastro

Não existe autocadastro na v1 e não existe tela de registro. O primeiro convite a um contato cria o PortalUser em Invited, e o primeiro acesso o promove a Active.

RN-PRT-03O alcance vem do vínculo, nunca da conta

Nenhuma consulta do portal parte do PortalUser: parte do PortalAccessLink vivo. dataAccessScope filtra o que já foi concedido e não concede nada — é o que impede uma segunda fonte de verdade de autorização.

RN-PRT-04Um link ativo por conta, alvo e rodada

Emitir com um ativo é conflito; reemitir revoga o anterior, aponta supersededByLinkId e envia o novo na mesma unidade de trabalho. Dois links vivos para o mesmo convite tornam a revogação inútil.

RN-PRT-05O token existe uma vez, e nunca é gravado

Opaco, 128 bits, guardado em sha256, comparado em tempo constante. Não aparece em log, em resposta de API nem em tela do comprador. Reemitir é a única forma de "recuperar" um link.

RN-PRT-06O link vale até o envio ou o encerramento da rodada, o que vier primeiro

Prorrogar a rodada empurra a validade dos links vivos. Rodada nova é link novo, e a virada revoga os da anterior — sem isso a proposta da rodada 1 continuaria editável depois do comparativo.

RN-PRT-07O token é trocado por sessão no primeiro acesso

Depois da troca, nenhuma rota do portal recebe token. A sessão é curta, amarrada ao PortalAccessLink, e morre com ele — revogar o link derruba a sessão aberta, não só o próximo acesso.

RN-PRT-08O motivo da recusa só é revelado quando o hash casou

Token desconhecido tem uma resposta só. Expirado, revogado e consumido são distinguidos, porque quem chegou até ali teve o token verdadeiro.

RN-PRT-09Conta suspensa e fornecedor bloqueado barram o acesso, e são coisas diferentes

Suspender a conta tira uma pessoa; bloquear o fornecedor tira a empresa, pelos eixos de estado do Fornecedor, e vale para todos os contatos dela sem que nenhum link precise ser revogado.

RN-PRT-10MFA é opção do tenant, desligada na v1, e só no envio

Ligada, exige código no ato que congela preço — nunca no acesso. Fora dela, o portal não tem segundo fator, e isso está declarado em vez de ser descoberto na primeira auditoria.

RN-PRT-11Rascunho é do fornecedor e invisível ao comprador

Nenhuma rota do lado comprador devolve resposta não enviada, nem contagem, nem indício de que existe. O que separa os dois lados é o estado da resposta, não permissão.

RN-PRT-12No envio, linha sem preço vira NotQuoted explícito

Valor gravado, não nulo. É o que impede o comparativo de confundir recusa com omissão, e "não cotado" com zero.

RN-PRT-13A unidade do fornecedor é aceita como veio

Sem fator de conversão, a linha nasce PendingConversion e o envio não é recusado. Falta de cadastro do lado comprador nunca tranca o fornecedor fora do prazo.

RN-PRT-14NotComparable não é pendência

Classe Serviço não converte por decisão da Unidade de Medida. Nada há a cadastrar, e a linha não entra em fila nenhuma.

RN-PRT-15Alternativa é linha extra, e exatamente uma é principal

Todas apontam para a mesma linha da cotação. Sem isPrimary única, o total da proposta é ambíguo e o envio é recusado.

RN-PRT-16A planilha é tudo ou nada, com dry-run obrigatório

Convenção de importação do produto, sem a exceção assíncrona do catálogo. Quantidade e unidade pedida vêm travadas, e alterá-las recusa o arquivo. Importar carrega o rascunho e nunca envia.

RN-PRT-17Proposta enviada é imutável

Correção é rodada nova, ou reabertura explícita do comprador com motivo e reemissão de link. Editar em cima apagaria o que já entrou no comparativo de alguém.

RN-PRT-18O lançamento pelo comprador é marcado, permissionado e auditado

enteredBy = Buyer com o usuário, permissão própria e ação de trilha. Não passa pelo caminho do portal, então a regra de anexo do portal não se aplica àquela resposta.

RN-PRT-19O comparativo converte numa data só

Taxa da data de encerramento da rodada, igual para todos, com data e taxa à vista na tela. O registro de cada resposta continua congelando a taxa da própria submissão.

RN-PRT-20As rotas do portal não declaram permissão de catálogo

Exceção declarada à convenção do produto. Nenhuma permissão de tenant é criada para o fornecedor, em nenhuma hipótese — é a forma de a saída recusada não voltar pela janela.

RN-PRT-21Canal só é usado com consentimento registrado

E-mail é o padrão e é obrigatório para convidar. WhatsApp exige aceite com data e origem no contato, e some do envio quando não há — sem erro, quando há e-mail junto.

RN-PRT-22Todo acesso, envio e anexo entram na trilha com o PortalUser

Ações no padrão <entidade>.<verbo-no-passado>: portal-access-link.issued, .revoked, portal-session.opened, quotation-response.submitted, quotation-response.entered_on_behalf.

Referência

Referência de mercado

O desenho proposto não é invenção: é o caminho que os dois maiores do mundo abriram depois de descobrir que a conta obrigatória custava caro.

ProdutoComo o fornecedor entraO que se aprende
SAP Ariba NetworkConta obrigatória na rede, com cobrança por volume transacionado em algumas faixasO atrito é real e tem nome. A queixa recorrente do fornecedor pequeno não é a tela — é ter que existir na rede para vender
SAP · Supplier Actionable NotificationsE-mail com link que abre o documento e permite responder sem contaÉ o precedente direto desta proposta, e existe justamente porque a conta obrigatória barrava a cauda longa de fornecedores
Coupa Supplier PortalPortal gratuito com conta, mais o mesmo caminho de resposta por e-mail para quem não quer contaOs dois convivem. A conta é conveniência para quem transaciona muito, não pedágio para quem transaciona uma vez
Mercado Eletrônico · NimbiRede com cadastro do fornecedor como produto em siModelo de rede, não de portal do comprador. Muda o produto — e é a fronteira que esta proposta não atravessa

A conclusão que vem de fora e vale aqui: quem começou por conta obrigatória construiu depois o caminho sem conta; ninguém fez o contrário. Começar pelo link e deixar a conta crescer por cima é a ordem que o mercado descobriu na marra.

Limites

Fora de escopo

Fora — muda o produto
  • Rede de fornecedores entre tenants. Um cadastro que serve a vários compradores é outro produto, com decisão jurídica antes da de produto. A spec de Fornecedor já registra a pergunta.
  • Leilão reverso e lance ao vivo. Tempo real, desempate e trava de lance são um módulo, não uma tela a mais na cotação.
  • Punchout e cXML. Catálogo do fornecedor dentro da tela do comprador é integração, e a spec de Fornecedor já a declarou projeto próprio.
  • Faturamento pelo portal. Nota entra pela integração fiscal. O portal não é canal de documento fiscal, e virar isso arrastaria o Nexio para dentro do financeiro que ele decidiu não fazer.
Fora agora, gancho previsto
  • Autocadastro público. O targetType já prevê SupplierRegistration; falta decidir quem aprova a entrada de quem ninguém convidou, e isso é uma instância do motor, não uma tela.
  • Confirmação de pedido. PurchaseOrder como alvo é o segundo consumidor natural do link, e chega com o módulo de pedido — não antes.
  • Upload de documento com validade. Certidão pelo portal é SupplierDocument, que tem número, órgão e vencimento — não é anexo, pela regra de corte do Anexo.
  • Conta com senha e MFA de sessão. Declarada, com campo no modelo, e adiada até haver fornecedor que transacione o bastante para querer uma.
  • Questionário técnico da cotação. Perguntas além de preço e prazo — prazo de garantia, certificação, condição de pagamento — são formulário configurável. Em aberto: se nasce na Cotação ou reaproveita o questionário de homologação.
  • Idioma do portal. locale está no modelo; traduzir a tela só se paga quando houver fornecedor estrangeiro cotando de verdade. Em aberto.

O que esta proposta fecha, e o que ela deixa aberto de propósito. Fecha o item 13 do gate — a última decisão antes da Requisição — respondendo às três perguntas que o mapa de domínio deixou: o portal é próprio, o fornecedor entra por convite nomeado sem credencial, e a cotação é o único ato da v1. Deixa aberto, e marcado no texto, o formato do questionário técnico, quem registra a escolha de alternativa, e o idioma. Nenhum dos três muda o modelo — é o que os torna adiáveis sem virar dívida.