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.
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 é 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.
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 (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ã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. 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
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[], isPortalAdmin, supplierEstablishmentId?, e-mail e celular. É daqui que sai o convidado, e é aqui que mora o consentimento de canal.
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. Não é credencial — é rótulo de identidade e endereço padrão de convite. Único por tenant entre contas não revogadas. |
| 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 · WhatsApp. Por onde o link saiu de fato — não o que foi pedido. Canal recusado por falta de consentimento não entra na lista, e a tela do comprador mostra a diferença. |
| 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
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.
| 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ê | 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 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.
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.
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. É 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 | Tudo que foi enviado. Rascunho de fornecedor, nunca | Estado 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 muda — 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. 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ície | Autoriza por | Falha responde |
|---|---|---|
/portal/* | Sessão derivada do link → portalUserId + supplierId + alvo + rodada. Nenhuma permissão do catálogo é consultada | 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 |
Três permissões novas do lado comprador, todas em verbo:recurso singular:
| Permissão | Cobre |
|---|---|
| read:portal-user | Ver as contas do portal de um fornecedor, último acesso e estado |
| manage:portal-user | Suspender, reativar e revogar conta. Não cria — quem cria é o convite |
| manage:portal-access | Emitir, 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.
| Onde | O que muda | Por quê |
|---|---|---|
| Anexo | Rota 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 preenche | Decisões 2 e 6 |
| Fornecedor | PortalUser sai de "existe no modelo" e vira entidade especificada; SupplierContact ganha whatsappConsent e whatsappConsentOn; o locale do fornecedor deixa de ser gancho | Decisões 2 e 3 · § Canais |
| Notificação | Quatro slugs novos com resolvedor de destinatário externo; canal WhatsApp declarado; 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; 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" | 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.
| 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 | Depende. O modelo já carrega canal e consentimento; a entrega espera a plataforma |
| 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 |
|---|---|---|---|
| GET /portal/access/{token} | o próprio token | Troca o token por sessão curta, marca a conta Active, incrementa accessCount e redireciona para a cotação sem o token na URL | 401 · 403 |
| 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. Substitui a rota por token escrita no Anexo | 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-user | As contas de um fornecedor, com estado, canais consentidos e último acesso | 400 · 404 |
| PATCH /portal-users/{id}/activate | manage:portal-user | Reativa conta suspensa. Devolve o recurso | 404 · 409 |
| PATCH /portal-users/{id}/deactivate | manage:portal-user | Suspende, com motivo. Links vivos param de abrir na hora, sem serem revogados um a um | 404 · 409 · 422 |
| PATCH /portal-users/{id}/revoke | manage:portal-user | Encerra a conta, com motivo. Terminal: reconvidar cria conta nova | 404 · 409 · 422 |
| POST /portal-access-links:issue | manage:portal-access | 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 | manage:portal-access | Mata um link sem emitir outro, com motivo | 404 · 409 · 422 |
| GET /portal-access-links | manage:portal-access | O 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 nele | 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 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ó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_CHANNEL_NOT_CONSENTED_UNPROCESSABLE | 422 | WhatsApp pedido sem consentimento registrado, quando é o único canal pedido. Havendo e-mail junto, o canal é omitido e o convite sai |
| 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.
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 do comprador com motivo e reemissão de link. 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 é 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.
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.
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.
- 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 — 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.