Resposta rápida
- O que muda
- o modelo ganha um segundo formulário, de várias linhas, criado por
AddGridem vez deAddFields. - A armadilha central
- em
AddGrid, os blocos de linha vêm antes dos de formulário — o oposto deAddFields. - Quem liga cabeçalho e item
SetRelation, chamado no modelo pai, com o ID do filho como primeiro parâmetro.- Onde some o campo de vínculo
- da estrutura da VIEW; na do modelo ele fica, senão para de gravar.
- Como se varre o grid
Length()e, por índice,GetValue(campo, nLinha)eIsDeleted(nLinha)— semGoLine.
Nesta página8 seções
Um cadastro com grid começa igual a qualquer outro MVC: ModelDef monta o modelo, ViewDef monta a tela. A diferença é o método do segundo componente — cabeçalho usa AddFields, item usa AddGrid, e AddGrid não é AddFields com um parâmetro a mais: é uma assinatura com a ordem dos blocos trocada. Quem copia o hábito de posição do formulário simples para o grid erra de um jeito que o compilador nunca aponta.
PGV020.prw é um contrato de exemplo; duas tabelas no dicionário antes de qualquer código:
| Campo | Tipo | Tamanho | O que é |
|---|---|---|---|
Z51_FILIAL | Caractere | 8 | Filial do cabeçalho |
Z51_NUM | Caractere | 6 | Número do contrato — a outra metade da chave |
Z51_CLIENTE | Caractere | 6 | Código do cliente |
Z51_VALOR | Numérico | 14,2 | Total do contrato, somado dos itens no commit |
Z52_FILIAL | Caractere | 8 | Filial do item — preenchida pelo SetRelation |
Z52_NUM | Caractere | 6 | Contrato a que o item pertence — idem |
Z52_ITEM | Caractere | 4 | Sequencial, preenchido pela view |
Z52_PROD | Caractere | 15 | Produto |
Z52_QUANT | Numérico | 12,2 | Quantidade |
Z52_VALOR | Numérico | 14,2 | Valor do item |
Os índices são Z51_FILIAL+Z51_NUM e Z52_FILIAL+Z52_NUM+Z52_ITEM — o segundo é o que SetRelation usa como ordenação, e precisa existir no Configurador antes de a tela abrir. O modelo é um MPFormModel só, com dois submodelos: um de formulário (Z51MASTER), um de grid (Z52DETAIL).
A ordem que a assinatura inverte
Em AddFields, a dupla de validação de formulário — bPre e bPost — é o quarto e o quinto parâmetro, depois de cId, cOwner e a estrutura. Em AddGrid, ela desce para a sexta e a sétima posição, porque dois blocos de LINHA entram antes:
| posição | AddFields | AddGrid |
|---|---|---|
| 1 | cId | cId |
| 2 | cOwner | cOwner |
| 3 | oModelStruct | oModelStruct |
| 4 | bPre | bLinePre |
| 5 | bPost | bLinePost |
| 6 | bLoad | bPre |
| 7 | — | bPost |
| 8 | — | bLoad |
O corpo do método confirma: AddGrid cria um FWFormGridModel():New e chama, em sequência, SetLPre(bLinePre), SetLPost(bLinePost), SetPre(bPre), SetPost(bPost) e SetLoad(bLoad) — os nomes dos setters seguem a ordem dos parâmetros, LINHA primeiro, formulário depois. Passar a pós-validação de linha na posição errada compila sem erro e simplesmente nunca é chamada.
É por isso que a chamada de PGV020.prw marca as posições vazias com comentário, em vez de contar vírgulas no olho:
oModel:AddGrid("Z52DETAIL", "Z51MASTER", oStruItem, ;
/*bLinePre*/, {|oGrid| PGV020Lin(oGrid)}, ;
/*bPre*/, /*bPost*/, /*bLoad*/)A pós-validação de linha está na quinta posição, onde bLinePost mora. Uma casa adiante, viraria bPre de formulário — e nunca se comportaria como validação de linha.
SetRelation: quem aponta para quem
SetRelation é chamado no modelo pai; o ID do formulário FILHO é o primeiro parâmetro — não o do pai. Em PGV020.prw, função ModelDef, o desenho é este:
oModel:SetRelation("Z52DETAIL", { ;
{"Z52_FILIAL", 'xFilial("Z52")'}, ;
{"Z52_NUM", "Z51_NUM"} ;
}, Z52->(IndexKey(1)))O cOrderBy não é string escrita à mão: é IndexKey(1) lido da própria tabela filha. E o lado direito de cada par de aRelation tem dois comportamentos: string de expressão, como 'xFilial("Z52")', o framework AVALIA; nome de campo, como "Z51_NUM", lê o VALOR no modelo PAI. Mesma sintaxe, duas semânticas.
RemoveField mora na view, não no modelo
Os campos que SetRelation preenche precisam continuar na estrutura do MODELO — senão não seriam gravados. O que muda é a VIEW: PGV020.prw tira dela os DOIS campos do relacionamento, com oStruItem:RemoveField("Z52_FILIAL") e oStruItem:RemoveField("Z52_NUM"). O campo some da tela, mas continua recebendo o valor do pai — na estrutura de modelo, RemoveField apagaria o dado; na de view, só esconde.
A filial merece atenção à parte: FWFormStruct(2,...) não a descarta sozinha. Uma coluna “Filial” repetida em toda linha do grid, sempre igual, é sintoma de quem esqueceu dela.
A numeração do item é da tela
AddIncrementField(cIDView, cField) é método de FWFormView, não de modelo — a numeração do item (Z52_ITEM) é da camada de apresentação.
Dois comportamentos vistos no uso, sem assinatura documentada
SetOptional(.F.), no submodelo do grid, torna o grid obrigatório — o registro não fecha sem ao menos uma linha. É o caso sem rede: nenhuma classe do framework MVC o declara. As poucas declarações desse nome que existem pertencem a classes de outros módulos, com outra assinatura, recebendo um alias — não são a que esse uso chama. Trate-o como comportamento observado, não como assinatura garantida entre versões.
SetUniqueLine({"Z52_PROD"}) faz o framework recusar sozinho uma segunda linha com o mesmo produto, sem validação escrita por quem programa. É caso diferente: FWFormGridModel declara e implementa SetUniqueLine(aUnique, bUnique) — array de campos que identifica a linha e bloco opcional de comparação. Os dois métodos costumam ser citados juntos como “não documentados”; só um deles é.
Varrer o grid por índice, sem mexer na posição
O commit de PGV020.prw soma o valor de cada item no total do cabeçalho antes de gravar, e para isso precisa percorrer TODAS as linhas do grid:
For nLinha := 1 To nLinhas
If !oGrid:IsDeleted(nLinha)
nTotal += oGrid:GetValue("Z52_VALOR", nLinha)
EndIf
Next nLinhaRepare no que esse laço não tem: nenhum GoLine. É a parte que mais se escreve errado. GetValue e IsDeleted aceitam o número da linha como argumento — omitido, valem para a linha corrente, mas o parâmetro existe. Ler por índice não move o grid, então não há posição de usuário a guardar nem restaurar. A versão com GoLine no início do laço e outro no fim para devolver o cursor funciona, e circula em exemplo de curso — mas faz trabalho que o método já resolve.
Length() merece a mesma honestidade: não está declarado na classe do grid, e ainda assim é o método que se usa na prática para saber quantas linhas existem, enquanto GetMaxLine(), esse sim declarado, praticamente não aparece em código real. O que sustenta Length() é o uso, não um contrato documentado.
A chave primária pode apontar para o filho
SetPrimaryKey também pode apontar para os campos da tabela FILHA, não para os da master — desenho deliberado quando o cabeçalho existe só para mostrar um dado já gravado em outro lugar, como um cliente: logo depois de fechar a chave pelo filho, SetOnlyView(.T.) no submodelo do cabeçalho marca aquele lado como só-consulta. A tela existe para mostrar o dado do pai, não gravá-lo; a chave primária de verdade, nesse desenho, é a do grid.
As caixas na tela
PGV020.prw divide a tela com CreateHorizontalBox("SUPERIOR", 35) e CreateHorizontalBox("INFERIOR", 65). Não há regra fixa para essa proporção; ela segue quantos campos e linhas cada bloco precisa mostrar — um cabeçalho enxuto cede mais espaço ao grid, um cabeçalho cheio de campos pede o contrário.
Nenhum dos pontos acima é exclusivo de contrato: o mesmo par — AddFields no cabeçalho, AddGrid nos itens, ligados por SetRelation — é a base de pedido de compra, pedido de venda, nota fiscal de entrada e qualquer cadastro em que itens dependem de uma tabela mestre. A ordem invertida dos blocos, onde mora o campo de vínculo e como varrer o grid sem perder a posição do usuário resolvem o mesmo problema nos quatro.
Se o grid de um cadastro seu não grava os itens, fale com a gente.
Anexos
#Include "Protheus.ch"
#Include "FWMVCDef.ch"
/*/{Protheus.doc} PGV020
Cadastro de contratos em MVC com cabecalho e itens -- o "modelo 3" do ERP.
Cabecalho na tabela ficticia Z51, itens na Z52. Alem do que o PGV010 ja
mostra, este fonte tem os quatro pontos que so aparecem quando existe grid:
AddGrid, SetRelation, a pos-validacao de linha e um commit proprio que soma
os itens no cabecalho antes de gravar.
Exemplo didatico do site. As tabelas Z51 e Z52 nao existem no dicionario
padrao; crie-as no Configurador antes de compilar.
@type user function
@author Pragiva
@since 20/09/2026
@version 1.0
@return nil
@example U_PGV020()
/*/
User Function PGV020()
Local oBrowse := Nil
oBrowse := FWMBrowse():New()
oBrowse:SetAlias("Z51")
oBrowse:SetDescription("Contratos")
oBrowse:SetMenuDef("PGV020")
oBrowse:Activate()
Return Nil
/*/{Protheus.doc} MenuDef
As opcoes do browse. Identicas as do modelo 1: ter grid nao muda o menu.
@type static function
@return array, o aRotina do browse
/*/
Static Function MenuDef()
Local aRotina := {}
ADD OPTION aRotina TITLE "Pesquisar" ACTION "AxPesqui" OPERATION 1 ACCESS 0
ADD OPTION aRotina TITLE "Visualizar" ACTION "VIEWDEF.PGV020" OPERATION 2 ACCESS 0
ADD OPTION aRotina TITLE "Incluir" ACTION "VIEWDEF.PGV020" OPERATION 3 ACCESS 0
ADD OPTION aRotina TITLE "Alterar" ACTION "VIEWDEF.PGV020" OPERATION 4 ACCESS 0
ADD OPTION aRotina TITLE "Excluir" ACTION "VIEWDEF.PGV020" OPERATION 5 ACCESS 0
Return aRotina
/*/{Protheus.doc} ModelDef
O modelo pai-filho.
A ordem dos blocos de AddGrid nao e a mesma de AddFields: aqui os dois
primeiros sao os de LINHA (bLinePre, bLinePost) e so depois vem os do
formulario (bPre, bPost) e a carga. Passar a pos-validacao de linha na
posicao errada compila e nao roda nunca.
@type static function
@return object, o modelo com cabecalho e itens
/*/
Static Function ModelDef()
Local oModel := Nil
Local oStruCab := FWFormStruct(1, "Z51")
Local oStruItem := FWFormStruct(1, "Z52")
// Posicao 3: pos-validacao do modelo. Preenche-la SUBSTITUI o default
// do MPFormModel, que e FWFormCanDel -- dai PGV020Pos devolver
// FWFormCanDel na exclusao, e nao .T.
oModel := MPFormModel():New("PGV020M", /*bPre*/, {|oMdl| PGV020Pos(oMdl)}, ;
{|oMdl| PGV020Com(oMdl)}, /*bCancel*/)
oModel:AddFields("Z51MASTER", /*cOwner*/, oStruCab)
oModel:AddGrid("Z52DETAIL", "Z51MASTER", oStruItem, ;
/*bLinePre*/, {|oGrid| PGV020Lin(oGrid)}, ;
/*bPre*/, /*bPost*/, /*bLoad*/)
// O lado direito de cada par e campo do PAI quando e nome de campo, e
// expressao avaliada quando e string de expressao. A ordenacao e a chave
// de indice da tabela filha, lida dela mesma.
oModel:SetRelation("Z52DETAIL", { ;
{"Z52_FILIAL", 'xFilial("Z52")'}, ;
{"Z52_NUM" , "Z51_NUM"} ;
}, Z52->(IndexKey(1)))
oModel:SetDescription("Contratos")
oModel:GetModel("Z51MASTER"):SetDescription("Cabecalho do contrato")
oModel:GetModel("Z52DETAIL"):SetDescription("Itens do contrato")
oModel:SetPrimaryKey({"Z51_FILIAL", "Z51_NUM"})
// Comportamento observado: .F. torna o grid obrigatorio -- o
// contrato nao fecha sem pelo menos um item.
oModel:GetModel("Z52DETAIL"):SetOptional(.F.)
// Duas linhas do mesmo produto no mesmo contrato viram erro do
// framework, sem precisar de codigo.
oModel:GetModel("Z52DETAIL"):SetUniqueLine({"Z52_PROD"})
Return oModel
/*/{Protheus.doc} ViewDef
A tela do pai-filho: cabecalho em cima, itens embaixo.
Os campos de vinculo saem da ESTRUTURA DA VIEW, nao da do modelo. Tirados do
modelo, eles deixariam de ser gravados; tirados so da view, somem da tela e
continuam sendo preenchidos pelo SetRelation.
@type static function
@return object, a view ligada ao modelo
/*/
Static Function ViewDef()
Local oView := Nil
Local oModel := FWLoadModel("PGV020")
Local oStruCab := FWFormStruct(2, "Z51")
Local oStruItem := FWFormStruct(2, "Z52")
// Os DOIS campos do SetRelation saem da tela. FWFormStruct(2,...) nao
// descarta a filial sozinho.
oStruItem:RemoveField("Z52_FILIAL")
oStruItem:RemoveField("Z52_NUM")
oView := FWFormView():New()
oView:SetModel(oModel)
oView:AddField("VIEW_CAB" , oStruCab , "Z51MASTER")
oView:AddGrid ("VIEW_ITEM", oStruItem, "Z52DETAIL")
// Numeracao automatica do item. E da VIEW, nao do modelo.
oView:AddIncrementField("VIEW_ITEM", "Z52_ITEM")
oView:CreateHorizontalBox("SUPERIOR", 35)
oView:CreateHorizontalBox("INFERIOR", 65)
oView:SetOwnerView("VIEW_CAB" , "SUPERIOR")
oView:SetOwnerView("VIEW_ITEM", "INFERIOR")
Return oView
/*/{Protheus.doc} PGV020Lin
Pos-validacao de LINHA do grid: roda uma vez por linha, na confirmacao.
Linha marcada para exclusao nao se valida -- senao o usuario nao consegue
apagar justamente a linha errada que o fez abrir a tela.
FWAlertError e MsgAlert aqui fazem o usuario ver DUAS mensagens: a sua e a
que o framework exibe em seguida.
@type static function
@param oGrid, object, o submodelo de grid, ja posicionado na linha
@return logical, .F. barra a confirmacao
/*/
Static Function PGV020Lin(oGrid)
Local lOk := .T.
If oGrid:IsDeleted()
Return .T.
EndIf
// O submodelo de grid nao implementa SetErrorMessage; quem tem o metodo
// e o modelo principal, alcancado por oGrid:GetModel(). E as posicoes 3
// e 4 sao as do erro -- e a 3a que o framework le.
If oGrid:GetValue("Z52_QUANT") <= 0
oGrid:GetModel():SetErrorMessage( , , oGrid:GetId(), "Z52_QUANT", ;
"PGV020", "Quantidade invalida", ;
"Informe uma quantidade maior que zero.")
lOk := .F.
EndIf
Return lOk
/*/{Protheus.doc} PGV020Pos
Pos-validacao do modelo inteiro.
Ela roda na inclusao, na alteracao E na exclusao. Sem filtrar pela operacao,
uma regra escrita para a inclusao passa a impedir que alguem apague o
registro.
@type static function
@param oModel, object, o modelo principal
@return logical, .F. barra a confirmacao
/*/
Static Function PGV020Pos(oModel)
Local lOk := .T.
// FWFormCanDel, e nao .T.: um bPost proprio SUBSTITUI esse default do
// MPFormModel, que e quem confere chave estrangeira antes de apagar.
If oModel:GetOperation() == MODEL_OPERATION_DELETE
Return FWFormCanDel(oModel)
EndIf
// Posicoes 3 e 4 sao as do erro, e e a 3a que o framework le. Preencher
// 1 e 2 e deixar 3 e 4 vazios faz a mensagem se perder.
If Empty(oModel:GetValue("Z51MASTER", "Z51_CLIENTE"))
oModel:SetErrorMessage( , , "Z51MASTER", "Z51_CLIENTE", ;
"PGV020", "Cliente obrigatorio", ;
"Informe o cliente do contrato.")
lOk := .F.
EndIf
Return lOk
/*/{Protheus.doc} PGV020Com
Commit proprio: soma os itens no total do cabecalho e grava.
Duas coisas que o nome nao entrega. Primeira: GetValue e IsDeleted do grid
aceitam o NUMERO DA LINHA, entao se varre por indice sem mover o grid --
nao e preciso GoLine. Segunda: o retorno diz GRAVOU, nao PODE GRAVAR;
retornar .F. aqui deixa os dados no banco e a tela aberta. Quem barra e a
pos-validacao, que roda antes.
@type static function
@param oModel, object, o modelo principal
@return logical, resultado da gravacao
/*/
Static Function PGV020Com(oModel)
Local lCommit := .F.
Local oGrid := oModel:GetModel("Z52DETAIL")
Local nTotal := 0
Local nLinha := 0
Local nLinhas := oGrid:Length()
For nLinha := 1 To nLinhas
If !oGrid:IsDeleted(nLinha)
nTotal += oGrid:GetValue("Z52_VALOR", nLinha)
EndIf
Next nLinha
oModel:GetModel("Z51MASTER"):SetValue("Z51_VALOR", nTotal)
lCommit := FWFormCommit(oModel)
Return lCommitReferências
TDNtdn.totvs.com
TDN — MPFormModel
Continue por aqui
- Vídeo
ProtheusReferência técnica
ViewDef: a tela de uma rotina MVC
ViewDef não valida nem grava: só recebe o modelo pronto e diz onde cada componente aparece. O que acontece sem SetOwnerView e quando o box não é preciso.