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.
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.
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. Criar ou convidar contato comercial não é alteração protegida: só o contato Commercial designado no Fornecedor está na cadeia de reserva do aviso bancário, e os demais nunca são canal do aviso nem fonte de telefone do callback.
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.
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.
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.
O token nunca viaja no caminho, e abrir o link não muda nada. O link leva o token no fragmento (…/portal/access#token), que o navegador não envia ao servidor e que não cai em log de proxy, WAF ou Referer. A página que abre é estática e só oferece "abrir a cotação"; o clique faz POST /portal/sessions com o token no corpo, que resolve o convite, marca a conta Active e abre sessão curta. Robô de segurança de e-mail que abre o link não ativa conta nem falseia "quem abriu". O fornecedor volta quantas vezes quiser dentro da validade.
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.
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.
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.
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.
| Saída | O que dá | O que custa | Estado |
|---|---|---|---|
| Usuário do tenant com permissões podadas | Autenticação, papéis e trilha prontos, sem construir nada | Cria 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 comprador | Recusada pelo Anexo, nominalmente |
| Portal com conta e senha | Identidade forte, sessão longa, MFA | Senha, 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 tela | Adiada · gancho declarado |
| Conta sem credencial + link por convite | Autor nomeado em todo acesso, anexo e envio; revogação por pessoa e por convite; zero atrito de adoção | O e-mail 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ão | Proposta |
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 e clica em abrir a cotação. A página não muda estado; o clique envia o token no corpo de POST /portal/sessions, que resolve o convite, marca a conta como Active, abre sessão curta e leva à 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. Com a conta nascendo do convite, o campo opcional fica 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
SupplierContact e carrega supplierId congelado. Estado de conta, idioma e último acesso. Nenhum campo de credencial na v1.
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.
roles[] (com o papel PortalAdmin), supplierEstablishmentId?, e-mail e celular. É daqui que sai o convidado, por e-mail — consentimento de canal só entra com o WhatsApp, na v2.
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.
| Campo | Tipo | Regra | |
|---|---|---|---|
| supplierContactId | Guid | obrig | → SupplierContact, 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. |
| supplierId | Guid | gerado | Resolvido 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. |
| login | string(320) | gerado | O e-mail do contato, normalizado e congelado no nascimento. Não é credencial — é rótulo de identidade e endereço padrão de convite. Único por fornecedor entre contas não revogadas — (supplierId, login) —, para que o representante de dois fornecedores tenha uma conta em cada. Não acompanha o e-mail do contato: mudá-lo revoga a conta e o próximo convite cria outra (RN-PRT-24). |
| accountStatus | enum | obrig | Invited · 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. |
| mfaEnabled | bool | gerado | Derivado 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. |
| locale | string(5) | opc | Idioma 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. |
| lastAccessOn | datetimeoffset? | gerado | Último acesso bem-sucedido. Serve à tela do comprador ("convidado há seis dias, nunca abriu") e é o insumo do lembrete de prazo. |
| dataAccessScope | enum | gerado | Supplier · 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.
Domínio
Campos · PortalAccessLink
| Campo | Tipo | Regra | |
|---|---|---|---|
| portalUserId | Guid | obrig | → PortalUser. Não existe link anônimo: o convite é sempre para uma pessoa, ainda que ela nunca tenha aberto nada antes. |
| targetType | enum | obrig | Enum fechado. Na v1 tem um valor: QuotationRequest. Valor sem descritor registrado derruba a inicialização. PurchaseOrder e SupplierRegistration estão previstos e não implementados. |
| targetId | Guid | obrig | A chave do alvo no mundo dele. Sem chave estrangeira — é outro bounded context, e o precedente é o dono polimórfico do Anexo. |
| round | int | obrig | A rodada que este link abre. Rodada nova é link novo, e o link da rodada anterior é revogado na virada — sem isso, a proposta da rodada 1 continuaria editável depois de o comparativo ter rodado. |
| tokenHash | char(64) | gerado | SHA-256 do token opaco de 128 bits. O texto claro nunca é gravado e existe uma única vez, no envio. Comparação em tempo constante. Índice único. |
| expiresAt | datetimeoffset | obrig | Encerramento da rodada, resolvido pelo descritor do alvo. Prorrogar a rodada empurra a data dos links vivos; não cria link novo. |
| consumedAt | datetimeoffset? | gerado | O envio da resposta. Preenchido, o link não abre mais. É o outro lado da regra "vale até o envio ou o encerramento, o que vier primeiro". |
| revokedAt · revokedBy · reasonCodeId | datetimeoffset? · Guid? · Guid? | cond | Revogação explícita. Motivo pelo catálogo do Motivo, em uso novo PortalLinkRevoke — texto livre aqui seria a sexta reincidência da dívida que aquela spec fechou. |
| supersededByLinkId | Guid? | gerado | Reemissão. O link antigo aponta para o novo e é revogado na mesma unidade de trabalho. É o histórico que três colunas no convite não teriam. |
| sentChannels | enum[] | obrig | Email na v1. Por onde o link saiu de fato — não o que foi pedido. WhatsApp é v2 (§ Canais). |
| accessCount · lastAccessOn | int · datetimeoffset? | gerado | Quantas vezes o token foi trocado por sessão. Não limita nada — serve à auditoria e à conversa difícil ("o link foi aberto de três lugares em dez minutos"). |
Um ativo por (conta × alvo × rodada), e o índice é que garante. Índice único parcial sobre (portalUserId, targetType, targetId, round) onde revokedAt e consumedAt são nulos. A forma exata do índice parcial depende do banco, que ainda não foi escolhido — a invariante é do domínio e está na RN-PRT-04; a implementação é da decisão de arquitetura que continua pendente.
Entrega
O convite e os canais
Na v1 o link sai só por e-mail. WhatsApp é v2: é canal que a plataforma não entrega e um dos mais atacados — clonagem de número e SIM swap —, e o produto não abre canal de fraude para ganhar taxa de abertura. A análise abaixo fica para quando ele entrar.
| Canal | Endereço | Condição | Estado |
|---|---|---|---|
| SupplierContact.email | Obrigatório para convidar. Contato sem e-mail é recusado antes de gerar token | Disponível | |
| SupplierContact.mobile | Consentimento registrado e número verificado. Sem consentimento, o canal é silenciosamente omitido e a tela do comprador diz por quê | v2 |
v2 · O que WhatsApp obrigará, e que e-mail não obriga
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 SupplierContact — whatsappConsent 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.
Três avisos no catálogo, e um que a v1 não tem
O catálogo de slugs da Notificação hoje fala com quem está fora do tenant em um caso só, o controle bancário. Os três avisos do portal estão reservados lá pelo nome; a audiência, o canal e o conteúdo são definidos aqui, e o resolvedor do convidado entra na Notificação quando esta proposta for validada. A cadeia do aviso bancário (SupplierFinanceContact) não serve: o convidado é o contato do convite, não o financeiro.
nexio.portal-invitation.sent · o convite com o link, só por e-mail na v1 e com o token no fragmento da URL (RN-PRT-21, RN-PRT-23) — ao contato convidado, e ao PortalUser dele quando há conta · nexio.portal-access-link.reissued · reemissão, e o aviso diz que o anterior deixou de valer · nexio.portal-round.closing · lembrete de prazo a quem foi convidado e não enviou. O aviso de resposta submetida não existe na v1: não há slug para ele no catálogo, e o comprador acompanha a rodada na tela da cotação — se vier, vem com a Cotação.
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.
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.
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.
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 NotQuoted — valor 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.
| Valor | Quando | O que o comparativo faz | Quem resolve |
|---|---|---|---|
| Comparable | Há fator vigente na hierarquia ItemSupplier → Item → global intraclasse | Entra na comparação, com o fator em snapshot na linha | Ninguém |
| PendingConversion | A unidade cotada não tem fator para a unidade pedida, e as duas são da mesma classe | Mostra a linha fora da comparação, com o preço na unidade do fornecedor à vista | O comprador, cadastrando o fator. A linha recalcula e tira o snapshot naquele momento |
| NotComparable | Classe Serviço, que por decisão não converte | Mostra lado a lado sem número derivado. É trabalho de leitura humana, e sempre foi | Ningué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. Os do próprio supplierId voltam qualquer que seja a visibilidade — Internal protege do resto do mundo, não do autor. E anexo de QuotationResponse nunca é Supplier: nessa visibilidade a proposta iria aos concorrentes, e o Anexo recusa a marcação. É comparação de coluna, não regra especial no código de autorização.
| Quem | Enxerga | Mecanismo |
|---|---|---|
| Fornecedor A, no portal | A cotação, as linhas, os anexos Supplier do documento, a própria resposta e os próprios anexos | Sessão derivada do link → supplierId, filtro em toda consulta |
| Fornecedor A sobre o fornecedor B | Nada. Nem que B foi convidado, nem quantos foram | A lista de convidados nunca sai pelas rotas do portal |
| Fornecedor A sobre a própria proposta enviada | Só depois de reemissão do link, e em leitura | consumedAt preenchido fecha o link; a reemissão é ato do comprador |
| Comprador | Com proposta fechada (padrão): antes do encerramento da rodada, só quem enviou e quando — nunca preço, linha, condição ou anexo; depois, tudo que foi enviado. Com proposta aberta, o enviado na hora. Rascunho de fornecedor, nunca | Estado da resposta e sealedBid da cotação, não permissão |
Uma exigência ao Anexo, e só uma. A rota de anexo do portal é POST /portal/quotations/{id}/attachments, por sessão (decisão 6): o supplierId vem da sessão, nunca do path, e nenhum token viaja na rota. O conteúdo da RN-ATT-21 é o mesmo — supplierId obrigatório, nasce Internal, defaultVisibility do tipo ignorado. Muda o formato da rota e a origem do dado, que continua sendo o vínculo. O acesso do fornecedor é "link por convite", nunca "link de uso único".
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:quotations como permissão de tenant, semeia por migration, e a saída recusada pelo Anexo volta pela janela.
| Superfície | Autoriza por | Falha responde |
|---|---|---|
/portal/* | Sessão derivada do link → portalUserId + supplierId + alvo + rodada. Nenhuma permissão do catálogo é consultada. POST /portal/bank-change-contests autoriza pelo token de contestação do Fornecedor, sem sessão e sem vínculo de cotação | 401 sempre que o vínculo não se sustenta; 403 quando o vínculo é válido e o fornecedor está bloqueado |
| Rotas do comprador | Permissão do catálogo, uma por rota, como todo o resto do produto | 403 · 404 pelo escopo de empresa |
Seis permissões novas do lado comprador, todas em verbo:recurso, com o recurso no plural:
| Permissão | Cobre |
|---|---|
| read:portal-users | Ver as contas do portal de um fornecedor, último acesso e estado |
| update:portal-users | Reativar conta suspensa |
| delete:portal-users | Suspender e revogar conta. Não cria — quem cria é o convite |
| read:portal-accesses | Ver o histórico de acesso de uma cotação: emissões, reemissões, canais e aberturas |
| create:portal-accesses | Emitir e reemitir link. Reemitir para resposta já enviada dá leitura, nunca edição; reabrir proposta é ato da Cotação, com permissão própria (RN-PRT-17) |
| delete:portal-accesses | Revogar link, com ou sem emitir outro |
Uma quarta é exigida e não nasce aqui: enter-on-behalf:quotation-responses, 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.
| Onde | O que muda | Por quê |
|---|---|---|
| Anexo | Nada a aplicar lá: o Anexo não fixa rota, sessão nem autenticação do portal e cita esta página; fixa só a autoria (supplierId, uploadedByKind, portalUserId) e o isolamento. A rota por sessão e o convite são daqui | Decisões 2 e 6 |
| Fornecedor | PortalUser sai de "existe no modelo" e vira entidade especificada; o locale do fornecedor deixa de ser gancho | Decisões 2 e 3 · § Canais |
| Notificação | Os três slugs já reservados ganham o resolvedor do contato convidado, com a audiência escrita em § Canais; cascata de idioma passando pelo PortalUser | § Canais |
| Motivo | Dois usos novos em appliesTo: PortalLinkRevoke e PortalUserSuspend | Motivo é dado, e ato unilateral do comprador carrega motivo |
| Modelo de Classes | Duas caixas novas na banda do fornecimento; PortalUser deixa de ser condicional; enums PortalAccountStatus, PortalTargetType, PortalChannel, ComparabilityStatus, QuoteEntrySource | Consequência das duas anteriores |
| Cotação · a especificar | Lista de convidados por (fornecedor × contato); round e closesAt na rodada; enteredBy + enteredByUserId e NotQuoted na resposta; sealedBid na cotação, travado depois do primeiro convite; reabertura de resposta com motivo e permissão própria, guardando a versão enviada; 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álise | Gate 13 fecha; o M7 sai da lista de achados abertos; a fronteira "Portal do fornecedor — a decidir" vira "decidido, v1 restrita" | O portal bloqueia a Cotação, não a Requisição |
O Fornecedor protege os contatos do aviso bancário, e isso vale para cá. O e-mail e o telefone do contato Finance mudam por proposta decidida no motor; os dos contatos da cadeia de reserva — PortalAdmin, o Commercial designado e o contato designado do estabelecimento principal — por alteração protegida, com segunda pessoa, aviso ao endereço anterior e callback; sem telefone confiável a troca vai à instância com a controladoria, pedido assinado do fornecedor anexado, gravado com quem anexou e quem decidiu, data e hora — o sistema exige o anexo e não confere o documento —, e 7 dias de carência; a mudança aplicada e contestada volta ao valor anterior, ou, quando veio de canal já comprometido ou de pedido assinado, fica suspensa até a controladoria decidir. O portal convive com a cadeia assim: convidar vendedor — criar ou convidar contato comercial — não é ChainContact, porque os contatos Commercial não designados são livres e nunca são canal do aviso nem fonte de telefone; dar ou tirar o papel PortalAdmin e designar o Commercial da cadeia são ChainContact. A conta nasce de contato comercial e o login é rótulo, não credencial. E como o login não acompanha o e-mail do contato (RN-PRT-24), mudar o e-mail de um contato nunca redireciona uma conta existente para outra caixa de entrada.
O portal bloqueia a Cotação, e só ela. A Requisição e o Pedido não passam por aqui. O que o portal mudava em modelo já publicado — a autoria do anexo (supplierId, uploadedByKind, portalUserId) e o PortalUser ligado ao contato — já está fixado no Anexo e no Fornecedor; por isso esta proposta pode ser validada junto com a Cotação sem voltar em anexo já gravado nem em contato já cadastrado. Rota, sessão, convite e avisos do portal são desta página: as páginas da base citam, não repetem.
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.
| Item | Estado na plataforma | O que esta proposta faz |
|---|---|---|
| Identidade externa | Não existe. ExternalIdentity é vínculo de usuário interno; sem UserType, IsExternal ou Guest | Contorna. A identidade do portal é do Nexio e não toca o GrydAuth |
| Canal de mensageria | Não existe. O módulo entrega e-mail e push; não há WhatsApp nem SMS | Não usa na v1: o convite sai por e-mail, e WhatsApp e SMS são v2 |
| Armazenamento de arquivo com antivírus | Zero no repositório — é o épico do GrydFiles | Depende. 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 trilha | Treze colunas, nenhuma é ação; hoje vai em AdditionalData | Convive. As ações do portal seguem a convenção <entidade>.<verbo-no-passado> como o resto |
Interface
Telas
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.
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.
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.
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.
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.
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
| Rota | Autoriza | Devolve | Erros |
|---|---|---|---|
| POST /portal/sessions | o token, no corpo | Troca o token por sessão curta, marca a conta Active, incrementa accessCount e leva à cotação. A página do link é estática e não chama nada sozinha (RN-PRT-23) | 401 · 403 |
| POST /portal/bank-change-contests | o token de contestação, no corpo | Contesta a troca de conta bancária ou de contato do aviso pelo link do Fornecedor (RN-FOR-30, RN-FOR-38): sem login, de uso único, sem sessão. Não mostra a conta nem o contato propostos | 401 |
| GET /portal/quotations/{id} | sessão | Cabeçalho, condições, linhas com quantidade e unidade pedida, anexos Supplier do documento e o rascunho do próprio fornecedor | 401 · 403 · 404 |
| GET /portal/quotations/{id}/template | sessão | A planilha modelo gerada para esta rodada, com item, quantidade e unidade travados | 401 · 404 · 409 |
| PUT /portal/quotations/{id}/response | sessão | Salva o rascunho e devolve o recurso. Invisível ao comprador. Recusado depois do envio e depois do encerramento | 400 · 401 · 409 · 422 |
| POST /portal/quotations/{id}/response:import | sessão | Importa a planilha para o rascunho. dryRun=true devolve o que entraria e o que falharia, sem gravar. Tudo ou nada | 400 · 401 · 409 · 422 |
| POST /portal/quotations/{id}/response:submit | sessão | Congela a proposta, resolve NotQuoted, congela taxa e fatores, consome o link e avisa o comprador. Exige código quando o tenant ligou o MFA | 401 · 409 · 422 |
| POST /portal/quotations/{id}/attachments | sessão | O caminho do fornecedor, com upload-intent e confirmação embutidos. supplierId vem da sessão; nasce Internal. É a rota do fornecedor que o Anexo cita; autoria e reserva seguem as regras de lá (RN-ATT-21, RN-ATT-36) | 401 · 409 · 422 |
| GET /portal/quotations/{id}/attachments | sessão | Os anexos do próprio supplierId mais os Supplier do documento. Comparação de coluna | 401 · 404 |
Comprador · uma permissão por rota
| Rota | Permissão | Devolve | Erros |
|---|---|---|---|
| GET /portal-users | read:portal-users | As contas de um fornecedor, com estado e último acesso | 400 · 404 |
| PATCH /portal-users/{id}/activate | update:portal-users | Reativa conta suspensa. Devolve o recurso | 404 · 409 |
| PATCH /portal-users/{id}/deactivate | delete:portal-users | Suspende, com motivo. Links vivos param de abrir na hora, sem serem revogados um a um | 404 · 409 · 422 |
| PATCH /portal-users/{id}/revoke | delete:portal-users | Encerra a conta, com motivo. Terminal: reconvidar cria conta nova | 404 · 409 · 422 |
| POST /portal-access-links:issue | create:portal-accesses | Emite ou reemite. Revoga o ativo, aponta supersededByLinkId e envia pelos canais pedidos, na mesma unidade de trabalho | 400 · 404 · 409 · 422 |
| POST /portal-access-links/{id}:revoke | delete:portal-accesses | Mata um link sem emitir outro, com motivo | 404 · 409 · 422 |
| GET /portal-access-links | read:portal-accesses | O histórico de uma cotação: emissões, reemissões, canais, aberturas. Exige read:portal-accesses, que não vem junto com read:portal-users: read e lookup não se implicam, e ver contas não é ver o histórico de acesso | 400 · 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 dez sufixos — _NOT_FOUND → 404 · _ALREADY_EXISTS, _ALREADY_ASSIGNED e _CONFLICT → 409 · _FORBIDDEN, _ACCESS_DENIED e _BLOCKED → 403 · _UNAUTHORIZED → 401 · _UNPROCESSABLE → 422 · _UNAVAILABLE → 503 · 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ódigo | HTTP | Quando |
|---|---|---|
| PORTAL_TOKEN_UNAUTHORIZED | 401 | O token não casa nenhum hash. Resposta única e genérica, em tempo constante |
| PORTAL_LINK_EXPIRED_UNAUTHORIZED | 401 | Casou, e a rodada já encerrou. A tela oferece pedir novo link |
| PORTAL_LINK_REVOKED_UNAUTHORIZED | 401 | Casou, e foi revogado — inclusive por reemissão, que revoga o anterior |
| PORTAL_LINK_ALREADY_CONSUMED_UNAUTHORIZED | 401 | Casou, e a resposta já foi enviada. Rever o enviado exige reemissão |
| PORTAL_MFA_CODE_INVALID_UNAUTHORIZED | 401 | Código errado ou vencido no envio, com o MFA ligado pelo tenant |
| PORTAL_USER_SUSPENDED_FORBIDDEN | 403 | Vínculo válido, conta suspensa ou revogada. A pessoa perdeu o acesso, o fornecedor não |
| PORTAL_SUPPLIER_BLOCKED_FORBIDDEN | 403 | Fornecedor 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_FOUND | 404 | Não existe neste tenant — ou o fornecedor está fora do escopo do usuário. Os dois respondem igual |
| PORTAL_LINK_NOT_FOUND | 404 | Idem, nas rotas do comprador |
| PORTAL_TARGET_TYPE_UNKNOWN | 400 | targetType sem descritor registrado. Em produção não chega: a inicialização já teria falhado |
| PORTAL_TARGET_NOT_ALLOWED_UNPROCESSABLE | 422 | Alvo fora do que a v1 aceita. Existe para o dia em que PurchaseOrder entrar pela metade |
| PORTAL_CONTACT_EMAIL_MISSING_UNPROCESSABLE | 422 | Convidar contato sem e-mail. Recusado antes de gerar token — token emitido e não entregue é o pior dos dois mundos |
| PORTAL_ACTIVE_LINK_CONFLICT | 409 | Já existe link ativo para (conta × alvo × rodada) e a emissão não pediu reemissão. Corrida de duas telas |
| PORTAL_ROUND_CLOSED_CONFLICT | 409 | Qualquer escrita depois do encerramento da rodada |
| PORTAL_RESPONSE_ALREADY_SUBMITTED_CONFLICT | 409 | Segundo envio. A proposta enviada é imutável; correção é rodada nova ou reabertura com motivo |
| PORTAL_IMPORT_STRUCTURE_UNPROCESSABLE | 422 | Planilha fora do modelo — coluna faltando, aba trocada, cabeçalho alterado. Recusa o arquivo inteiro |
| PORTAL_IMPORT_ITEM_UNKNOWN_UNPROCESSABLE | 422 | Linha que não corresponde a nenhum item da rodada. Recusa o arquivo inteiro, porque importação é tudo ou nada |
| PORTAL_IMPORT_QUANTITY_CHANGED_UNPROCESSABLE | 422 | Quantidade ou unidade pedida alteradas na planilha. São campos travados, e mexer neles é o que quebra o comparativo |
| PORTAL_PRIMARY_ALTERNATIVE_MISSING_UNPROCESSABLE | 422 | Mais 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
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.
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.
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.
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.
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.
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.
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.
Token desconhecido tem uma resposta só. Expirado, revogado e consumido são distinguidos, porque quem chegou até ali teve o token verdadeiro.
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.
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.
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. Com proposta fechada, também o enviado fica fechado ao comprador até o encerramento da rodada (RN-PRT-26).
NotQuoted explícitoValor gravado, não nulo. É o que impede o comparativo de confundir recusa com omissão, e "não cotado" com zero.
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.
NotComparable não é pendênciaClasse 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.
Todas apontam para a mesma linha da cotação. Sem isPrimary única, o total da proposta é ambíguo e o envio é recusado.
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.
Correção é rodada nova, ou reabertura explícita — ato da Cotação, com permissão própria e motivo, só antes do encerramento da rodada —, que guarda a versão enviada e devolve o rascunho ao fornecedor com link novo. Reemitir link é outra coisa: para resposta enviada, dá leitura e nunca edição. Editar em cima apagaria o que já entrou no comparativo de alguém.
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.
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.
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.
E-mail é obrigatório para convidar e é o único canal da v1. WhatsApp e SMS são v2; quando entrarem, exigirão aceite registrado com data e origem no contato (§ Canais).
PortalUserAções no padrão <entidade>.<verbo-no-passado>: portal-access-link.issued, .revoked, portal-session.opened, quotation-response.submitted, quotation-response.entered-on-behalf.
O token viaja no fragmento do link e só é enviado no corpo de POST /portal/sessions — ou de POST /portal/bank-change-contests —, por clique explícito na página estática que o link abre. Nenhum GET do portal muda estado. Nenhuma rota aceita token em caminho ou query string; se chegar, o middleware responde PORTAL_TOKEN_UNAUTHORIZED e o registro de acesso mascara o valor. Por quê: robô de segurança de e-mail abre links; com GET ativando conta, a trilha diria que o fornecedor abriu quando quem abriu foi um antivírus, e o token ficaria no log do balanceador.
login é único por (supplierId, login) entre contas não revogadas: o representante de dois fornecedores tem duas contas, uma por vínculo. O login é congelado no nascimento. Mudar o e-mail do contato — pelo caminho protegido do Fornecedor, quando o contato é da cadeia do aviso — revoga a conta ligada a ele e os links vivos dela, na mesma transação, e o próximo convite cria conta nova no endereço novo. Identidade que muda de caixa de entrada sozinha é redirecionamento de acesso.
Inativar o contato — inclusive por PUT /suppliers/{id}/contacts que o retire do conjunto — revoga a conta dele e todos os links vivos, na mesma transação, com motivo de sistema. Inativar o fornecedor suspende todas as contas dele e derruba as sessões abertas; reativar o fornecedor não reativa conta, que volta por ato do comprador (PATCH /portal-users/{id}/activate). Bloqueio do fornecedor continua barrando o acesso sem mexer em conta (RN-PRT-09). O efeito é chamada síncrona na transação do Fornecedor, nunca reação a evento.
Cada cotação carrega sealedBid, com padrão do tenant true, mudável só antes do primeiro convite. Com sealedBid = true, nenhuma rota do comprador devolve preço, linha, condição ou anexo de resposta enviada antes do encerramento da rodada — só fornecedor, contato e hora do envio —, e a proposta lançada pelo comprador (RN-PRT-18) fica fechada aos demais compradores do mesmo jeito. Prorrogar a rodada prorroga o fechamento. Com false, o comprador vê o enviado na hora, e a tela diz que a cotação é aberta. Por quê: com propostas visíveis antes do prazo, um comprador pode passar o menor preço ao fornecedor preferido para ele cobrir — é o controle que Ariba e Coupa oferecem, e aqui é o padrão.
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.
| Produto | Como o fornecedor entra | O que se aprende |
|---|---|---|
| SAP Ariba Network | Conta obrigatória na rede, com cobrança por volume transacionado em algumas faixas | O 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 Notifications | E-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 Portal | Portal gratuito com conta, mais o mesmo caminho de resposta por e-mail para quem não quer conta | Os dois convivem. A conta é conveniência para quem transaciona muito, não pedágio para quem transaciona uma vez |
| Mercado Eletrônico · Nimbi | Rede com cadastro do fornecedor como produto em si | Modelo 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
- 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.
- Dado bancário, contato do aviso e identidade. Conta bancária, e-mail e telefone dos contatos que recebem o aviso ou atendem o callback, e
cpf,ownerCpfouentityTypedo fornecedor não entram pelo portal — por nenhuma tela e por nenhuma rota. Mudam do lado do comprador, por proposta decidida no motor, com aviso ao canal anterior, callback ou pedido assinado anexado, e a identidade por correção com a controladoria (Fornecedor). A única coisa que o fornecedor faz por aqui nesse assunto é contestar, pelo link de uso único. O portal é justamente o canal que o fraudador tentaria usar.
- Autocadastro público. O
targetTypejá 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.
PurchaseOrdercomo 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.
localeestá 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 — pré-condição da Cotação, não 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.
Registro
Histórico de revisões
| Versão | Data | O que mudou |
|---|---|---|
| v1.10 | 30/10/2026 | Permissões padronizadas em read/lookup/create/update/delete (decisão do Roger de 30/10/2026), ação a ação. De → para: manage:portal-users → update:portal-users (reativar) e delete:portal-users (suspender e revogar conta); manage:portal-accesses → create:portal-accesses (emitir e reemitir link), delete:portal-accesses (revogar link) e read:portal-accesses (histórico de acesso, GET /portal-access-links). A tabela passa de três para seis permissões. |
| v1.9 | 20/09/2026 | Recuos da validação de 18/09 (achado 11): o que é do portal passa a morar só aqui. Avisos: a audiência, o canal e o conteúdo dos três slugs são definidos nesta página — o contato convidado, não a cadeia do aviso bancário —, e a Notificação os mantém reservados pelo nome; o cabeçalho passa de “4 slugs a acrescentar” para três reservados. Anexo: a exigência de rota está atendida pelo recuo — o Anexo não fixa mais rota nem autenticação do portal e cita esta página. Fornecedor: o acesso do convidado bloqueado é regra daqui (PORTAL_SUPPLIER_BLOCKED_FORBIDDEN). Gate: o portal bloqueia a Cotação, não a Requisição. Continua proposta à espera da decisão D8. |
| v1.8 | 12/09/2026 | Correção da validação R6: os avisos do portal passam a citar os slugs do catálogo da Notificação — nexio.portal-invitation.sent, nexio.portal-access-link.reissued e nexio.portal-round.closing —, e fica escrito que o aviso de resposta submetida não existe na v1; a ação de trilha do lançamento pelo comprador passa a quotation-response.entered-on-behalf, em kebab. |
| v1.7 | 12/09/2026 | Correção da validação R5: o pedido assinado da troca de canal passa a ser exigido como anexo com rastro, sem conferência do documento pelo sistema, e a mudança contestada vinda de canal comprometido fica suspensa até a controladoria decidir (Fornecedor, RN-FOR-38, RN-FOR-40). Fora de escopo passa a declarar que dado bancário, contato do aviso e identidade não entram pelo portal — só a contestação pelo link. |
| v1.6 | 11/09/2026 | Correção da validação R4: troca de canal sem telefone confiável com a assinatura do pedido conferida pela controladoria (Fornecedor, RN-FOR-40); narrativa de desenho anterior retirada do corpo na conta do portal e na exigência ao Anexo (B18). |
| v1.5 | 11/09/2026 | Correção da validação R3: criar ou convidar vendedor não é ChainContact — só o Commercial designado está na cadeia do aviso (Fornecedor, EV-19); troca de canal sem telefone confiável citada como instância com pedido assinado e carência (EV-18); consentimento de canal só com o WhatsApp, na v2. |
| v1.4 | 11/09/2026 | Correção da validação R2: a contestação pelo link vale também para mudança de contato do aviso (Fornecedor RN-FOR-38); perímetro da conta sem credencial é só o e-mail; PortalAdmin é papel do contato, sem campo à parte; rodapé dos contatos protegidos com callback e reversão. |
| v1.3 | 11/09/2026 | Validação de 10/09. Abrir o link não muda estado: token no fragmento e POST /portal/sessions por clique, nunca em caminho (RN-PRT-23). login único por fornecedor, congelado e sem acompanhar o e-mail do contato (RN-PRT-24). Inativar contato ou fornecedor revoga e suspende em cascata (RN-PRT-25). Proposta fechada por cotação, padrão do tenant (RN-PRT-26); reabrir não é reemitir (RN-PRT-17). Anexos do próprio fornecedor em qualquer visibilidade; anexo de resposta nunca Supplier. Rota de contestação bancária declarada. Convite só por e-mail na v1 — WhatsApp é v2 (RN-PRT-21), sai PORTAL_CHANNEL_NOT_CONSENTED_UNPROCESSABLE. Dez sufixos de erro. |
| v1.1 – v1.2 | 09/09/2026 | Permissões no plural; contato Finance protegido pelo Fornecedor refletido no login. |
| v1.0 | 08/09/2026 | Primeira versão: link por convite e conta opcional, tela linha a linha e planilha, lançamento pelo comprador marcado. |