Projeto Estoque da Lojinha

A Ana tem uma lojinha de papelaria e controla o estoque num caderno: quantos cadernos, canetas e mochilas ainda tem, e quanto custa cada um. Toda semana ela faz as mesmas perguntas: "o que está acabando?", "quanto vale tudo o que está na prateleira?", "qual categoria dá mais dinheiro?". Neste projeto, ela troca o caderno por um programa em C# que responde a essas perguntas com LINQ, em uma linha cada, e que guarda o estoque num arquivo para não esquecer nada de um dia para o outro.

Você vai praticar

O que vamos construir

O programa pronto, na máquina da Ana. Na primeira vez, ainda não existe o arquivo, e ele começa com os produtos do caderno de papel. Ela vê o que está acabando, recebe 20 borrachas, vende 5 canetas, tenta vender 3 mochilas (só há 2) e sai:

$ dotnet run
estoque.csv não existe: começando do caderno de papel.
=== Estoque da Lojinha ===
7 produtos carregados.

1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair
Opção: 2
Cód  Produto     Qtd       Preço
  3  Borracha      3     R$ 1,75
  4  Mochila       2    R$ 89,90
  6  Papel A4      4    R$ 27,50

1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair
Opção: 3
Código: 3
Quantas unidades? 20
Borracha: agora são 23.

1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair
Opção: 4
Código: 2
Quantas unidades? 5
Caneta: agora são 35.

1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair
Opção: 4
Código: 4
Quantas unidades? 3
Só há 2 de Mochila.

1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair
Opção: 0
Salvei 7 produtos em estoque.csv. Até amanhã!

No dia seguinte, o programa lê o arquivo e continua de onde parou: a caneta tem 35, e a borracha, 23.

  1. A ficha do produto e a lista (record, List e a tabela).
  2. Perguntas ao estoque com LINQ (Where, OrderBy, Sum…).
  3. O relatório por categoria e a busca (GroupBy e FirstOrDefault).
  4. O menu: entrada e saída (TryParse, FindIndex e with).
  5. O estoque guardado num arquivo (estoque.csv).

Antes de começar

Você precisa ter visto List<T>, Records, Lambdas e as aulas de LINQ, a partir de O que é LINQ. Como o programa vai crescer, a Ana cria um projeto (aula Projetos):

$ mkdir -p projetos && cd projetos
$ dotnet new console -o Estoque
O modelo "Aplicativo do Console" foi criado com êxito.

Processando ações pós-criação...
Restaurando /home/ana/projetos/Estoque/Estoque.csproj:
  Determinando os projetos a serem restaurados...
  /home/ana/projetos/Estoque/Estoque.csproj restaurado (em 107 ms).
A restauração foi bem-sucedida.

$ cd Estoque

Em cada versão, ela troca todo o conteúdo do Program.cs pelo código novo e roda com dotnet run, dentro da pasta ~/projetos/Estoque.


Versão 1 · A ficha do produto e a lista

Pense nas fichas de papel da Ana: cada produto tem uma, com os mesmos campos na mesma ordem (código, nome, categoria, quantidade e preço). Em C#, o desenho da ficha é um record, e a caixa com todas as fichas é uma List<Produto> ("lista de produtos").

A primeira versão não pergunta nada ao usuário. Ela só monta a lista e mostra a tabela do estoque:

// Estoque da Lojinha — versão 1: a ficha e a lista
List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(2, "Caneta", "Escrita", 40, 2.50m),
    new(3, "Borracha", "Escrita", 3, 1.75m),
    new(4, "Mochila", "Bolsas", 2, 89.90m),
    new(5, "Lápis", "Escrita", 60, 1.20m),
    new(6, "Papel A4", "Papelaria", 4, 27.50m),
    new(7, "Estojo", "Bolsas", 6, 19.90m),
];

Console.WriteLine("=== Estoque da Lojinha ===");
Console.WriteLine("Cód  Produto     Categoria   Qtd       Preço");
foreach (Produto p in estoque)
{
    Console.WriteLine($"{p.Codigo,3}  {p.Nome,-10}  {p.Categoria,-10}  {p.Quantidade,3}  {p.Preco,10:C}");
}
Console.WriteLine($"{estoque.Count} produtos cadastrados.");

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
LinhaO que faz
record Produto(int Codigo, string Nome, …, decimal Preco);O desenho da ficha, na última linha do arquivo. Esta linha cria o tipo Produto, com cinco propriedades e um construtor que recebe as cinco, na ordem. Em Program.cs, os tipos ficam depois das instruções (aula Program.cs e o Main). C# 9
decimal PrecoDinheiro é sempre decimal: ele guarda 15,90 exato, e o double não (aula float, double e decimal).
List<Produto> estoque = [ … ];Cria a lista já com sete produtos. Os colchetes são uma expressão de coleção C# 12; antes dela, escrevia-se new List<Produto> { … }.
new(1, "Caderno", "Papelaria", 12, 15.90m)Cria um Produto. O C# já sabe o tipo (é uma lista de Produto), por isso basta new(…) C# 9. O m no fim de 15.90m diz "isto é um decimal". No código, o separador decimal é sempre o ponto.
foreach (Produto p in estoque)Passa por cada ficha, da primeira à última. Dentro do laço, p é o produto da vez (aula foreach).
{p.Codigo,3}O número depois da vírgula é a largura: o código ocupa 3 posições, alinhado à direita.
{p.Nome,-10}Largura negativa alinha à esquerda: o nome ocupa 10 posições, e o que sobra vira espaço.
{p.Preco,10:C}Largura 10 e o formato C ("currency", moeda). Na máquina da Ana, em pt-BR, 15.90m sai R$ 15,90.
estoque.CountQuantos itens a lista tem agora. É uma propriedade (sem parênteses).
$ dotnet run
=== Estoque da Lojinha ===
Cód  Produto     Categoria   Qtd       Preço
  1  Caderno     Papelaria    12    R$ 15,90
  2  Caneta      Escrita      40     R$ 2,50
  3  Borracha    Escrita       3     R$ 1,75
  4  Mochila     Bolsas        2    R$ 89,90
  5  Lápis       Escrita      60     R$ 1,20
  6  Papel A4    Papelaria     4    R$ 27,50
  7  Estojo      Bolsas        6    R$ 19,90
7 produtos cadastrados.

Repare nos preços: todos terminam na mesma coluna, porque cada um ocupa 10 posições. É o mesmo que a Ana faria no caderno, com uma régua.

A ficha não se apaga: records são imutáveis

A Ana vendeu um caderno e tentou diminuir a quantidade como faria numa variável comum:

CS8852: a propriedade só pode ser atribuída na criação
List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
];

estoque[0].Quantidade = 11;     // vendeu um caderno
Console.WriteLine(estoque[0]);

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
$ dotnet run
/home/ana/projetos/Estoque/Program.cs(6,1): error CS8852: A propriedade somente de inicialização ou o indexador 'Produto.Quantidade' só pode ser atribuído em um inicializador de objeto, em 'this' ou 'base' em um construtor de instância ou em um acessador 'init'. [/home/ana/projetos/Estoque/Estoque.csproj]

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

O que significa: as propriedades de um record posicional são init: recebem o valor quando a ficha é criada e depois não mudam mais (aula readonly e init). Em inglês, a mensagem começa com Init-only property or indexer 'Produto.Quantidade' can only be assigned….

Como consertar: em vez de rasurar a ficha, a Ana preenche uma ficha nova, igual à velha, com a quantidade trocada, e põe a nova no lugar. É isso que o with faz:

List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
];

estoque[0] = estoque[0] with { Quantidade = 11 };
Console.WriteLine(estoque[0]);

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
$ dotnet run
Produto { Codigo = 1, Nome = Caderno, Categoria = Papelaria, Quantidade = 11, Preco = 15,90 }

De quebra, o Console.WriteLine de um record mostra todos os campos, com nome: ótimo para conferir o que está dentro.

Por que não usar uma class com set, que deixaria mudar? Daria certo também. O record foi escolhido porque, com LINQ, a maior parte do trabalho é ler o estoque, e uma ficha que ninguém muda por engano é mais segura. Só a versão 4 vai mudar quantidades, e sempre num lugar só.

Missão (versão 1): chegou um produto novo, a cola. Acrescente na lista o produto de código 8, nome "Cola", categoria "Escrita", 10 unidades a R$ 3,50 (lembre do m), e rode. A tabela deve ganhar uma linha e o total deve dizer 8 produtos cadastrados.

// Estoque da Lojinha — versão 1: a ficha e a lista
List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(2, "Caneta", "Escrita", 40, 2.50m),
    new(3, "Borracha", "Escrita", 3, 1.75m),
    new(4, "Mochila", "Bolsas", 2, 89.90m),
    new(5, "Lápis", "Escrita", 60, 1.20m),
    new(6, "Papel A4", "Papelaria", 4, 27.50m),
    new(7, "Estojo", "Bolsas", 6, 19.90m),
];

Console.WriteLine("=== Estoque da Lojinha ===");
Console.WriteLine("Cód  Produto     Categoria   Qtd       Preço");
foreach (Produto p in estoque)
{
    Console.WriteLine($"{p.Codigo,3}  {p.Nome,-10}  {p.Categoria,-10}  {p.Quantidade,3}  {p.Preco,10:C}");
}
Console.WriteLine($"{estoque.Count} produtos cadastrados.");

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
// Estoque da Lojinha — versão 1: a ficha e a lista
List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(2, "Caneta", "Escrita", 40, 2.50m),
    new(3, "Borracha", "Escrita", 3, 1.75m),
    new(4, "Mochila", "Bolsas", 2, 89.90m),
    new(5, "Lápis", "Escrita", 60, 1.20m),
    new(6, "Papel A4", "Papelaria", 4, 27.50m),
    new(7, "Estojo", "Bolsas", 6, 19.90m),
    new(8, "Cola", "Escrita", 10, 3.50m),
];

Console.WriteLine("=== Estoque da Lojinha ===");
Console.WriteLine("Cód  Produto     Categoria   Qtd       Preço");
foreach (Produto p in estoque)
{
    Console.WriteLine($"{p.Codigo,3}  {p.Nome,-10}  {p.Categoria,-10}  {p.Quantidade,3}  {p.Preco,10:C}");
}
Console.WriteLine($"{estoque.Count} produtos cadastrados.");

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);

Versão 2 · Perguntas ao estoque com LINQ

Com a lista pronta, chegam as perguntas da Ana. Sem LINQ, cada pergunta seria um foreach com um if e uma variável para ir somando. Com LINQ, cada pergunta vira uma frase: "do estoque, onde a quantidade é menor que 5"; "do estoque, a soma de quantidade vezes preço".

// Estoque da Lojinha — versão 2: perguntas com LINQ
List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(2, "Caneta", "Escrita", 40, 2.50m),
    new(3, "Borracha", "Escrita", 3, 1.75m),
    new(4, "Mochila", "Bolsas", 2, 89.90m),
    new(5, "Lápis", "Escrita", 60, 1.20m),
    new(6, "Papel A4", "Papelaria", 4, 27.50m),
    new(7, "Estojo", "Bolsas", 6, 19.90m),
];

// 1. Quais produtos estão acabando? (menos de 5 unidades)
var acabando = estoque.Where(p => p.Quantidade < 5);
Console.WriteLine("Comprar logo:");
foreach (Produto p in acabando)
{
    Console.WriteLine($"  {p.Nome} (só {p.Quantidade})");
}

// 2. Os nomes em ordem alfabética
var nomes = estoque.OrderBy(p => p.Nome).Select(p => p.Nome);
Console.WriteLine($"Em ordem: {string.Join(", ", nomes)}");

// 3. Contas sobre o estoque inteiro
int unidades = estoque.Sum(p => p.Quantidade);
decimal valor = estoque.Sum(p => p.Quantidade * p.Preco);
int caros = estoque.Count(p => p.Preco > 10m);
Produto maisCaro = estoque.MaxBy(p => p.Preco)!;

Console.WriteLine($"Unidades: {unidades}");
Console.WriteLine($"Valor do estoque: {valor:C}");
Console.WriteLine($"Produtos acima de R$ 10: {caros}");
Console.WriteLine($"Mais caro: {maisCaro.Nome}, {maisCaro.Preco:C}");

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
LinhaO que faz
estoque.Where(p => p.Quantidade < 5)Filtra: fica só com os produtos em que a condição é verdadeira. O p => p.Quantidade < 5 é uma lambda: "dado um produto p, diga se a quantidade dele é menor que 5". Aula: Filtrar e Projetar.
var acabando = …;O resultado é um IEnumerable<Produto>, uma sequência que se percorre com foreach. O var poupa escrever esse nome comprido.
.OrderBy(p => p.Nome)Ordena pelo nome, de A a Z (aula Ordenar). A lista original continua na ordem de antes.
.Select(p => p.Nome)Projeta: troca cada produto por outra coisa, aqui só o nome. O resultado é uma sequência de string.
string.Join(", ", nomes)Junta os textos numa linha só, com ", " entre eles.
estoque.Sum(p => p.Quantidade)Soma o que a lambda devolve para cada produto: todas as unidades.
estoque.Sum(p => p.Quantidade * p.Preco)O valor do estoque: para cada produto, quantidade × preço, e a soma de tudo. int vezes decimal dá decimal, e a conta é exata. Aula: Agregar.
estoque.Count(p => p.Preco > 10m)Conta quantos produtos cumprem a condição. Sem lambda, Count() conta todos.
estoque.MaxBy(p => p.Preco)!Devolve o produto de maior preço (o Max devolveria só o preço). Numa lista vazia, ele devolveria null; o ! diz ao compilador "aqui não está vazia" e evita um aviso (aula Referências Anuláveis). .NET 6
$ dotnet run
Comprar logo:
  Borracha (só 3)
  Mochila (só 2)
  Papel A4 (só 4)
Em ordem: Borracha, Caderno, Caneta, Estojo, Lápis, Mochila, Papel A4
Unidades: 127
Valor do estoque: R$ 777,25
Produtos acima de R$ 10: 4
Mais caro: Mochila, R$ 89,90

Confira uma conta na mão: o caderno vale 12 × 15,90 = 190,80; a caneta, 40 × 2,50 = 100,00; somando os sete, dá R$ 777,25. Repare também no Lápis entre Estojo e Mochila: o OrderBy de texto segue as regras do português, e o á conta como a.

CS1061: a sequência não tem Nome

A Ana quis mostrar o nome do que está acabando direto do resultado do Where:

List<Produto> estoque =
[
    new(3, "Borracha", "Escrita", 3, 1.75m),
    new(4, "Mochila", "Bolsas", 2, 89.90m),
];

var acabando = estoque.Where(p => p.Quantidade < 5);
Console.WriteLine(acabando.Nome);

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
$ dotnet run
/home/ana/projetos/Estoque/Program.cs(8,28): error CS1061: ‘IEnumerable<Produto>’ não contém uma definição para "Nome" e não foi possível encontrar nenhum método de extensão "Nome" que aceite um primeiro argumento do tipo ‘IEnumerable<Produto>’ (você está se esquecendo de usar uma diretiva ou uma referência de assembly?) [/home/ana/projetos/Estoque/Estoque.csproj]

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

O que significa: o Where não devolve um produto, devolve uma sequência de produtos (podem ser zero, um ou vários). Uma sequência não tem nome; cada produto dentro dela, sim. A dica do fim (diretiva ou referência de assembly) não ajuda aqui: ela aparece em todo CS1061.

Como consertar: percorra com foreach, como na versão 2; ou projete os nomes: string.Join(", ", acabando.Select(p => p.Nome)); ou, se quer só o primeiro, acabando.First().Nome.

A consulta é uma receita, não uma foto

Um detalhe do LINQ que surpreende: a linha do Where não separa os produtos naquela hora. Ela guarda a "receita" do filtro, e a receita roda de novo a cada vez que alguém percorre o resultado. Veja o que acontece quando o estoque muda depois da consulta:

List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(3, "Borracha", "Escrita", 3, 1.75m),
    new(4, "Mochila", "Bolsas", 2, 89.90m),
];

var acabando = estoque.Where(p => p.Quantidade < 5);
Console.WriteLine($"Acabando: {acabando.Count()}");

estoque[1] = estoque[1] with { Quantidade = 30 };   // chegou uma caixa de borrachas
Console.WriteLine($"Acabando: {acabando.Count()}");

var foto = estoque.Where(p => p.Quantidade < 5).ToList();
estoque[2] = estoque[2] with { Quantidade = 10 };   // chegaram mochilas
Console.WriteLine($"Acabando (consulta): {acabando.Count()}");
Console.WriteLine($"Acabando (ToList):   {foto.Count}");

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
$ dotnet run
Acabando: 2
Acabando: 1
Acabando (consulta): 0
Acabando (ToList):   1

A mesma variável acabando deu 2, depois 1, depois 0, porque cada Count() refez o filtro na lista do momento. Isso se chama execução adiada. Quando você quer guardar o resultado daquele momento, termine a consulta com .ToList(): ela roda na hora e copia os produtos para uma lista nova. Aula: O que é LINQ.

Missão (versão 2): a Ana quer saber quanto vale só a Papelaria. Troque o 0m por uma consulta: primeiro um Where que fica com a categoria "Papelaria", depois um Sum de quantidade × preço. O resultado certo é Papelaria vale R$ 300,80.

List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(2, "Caneta", "Escrita", 40, 2.50m),
    new(3, "Borracha", "Escrita", 3, 1.75m),
    new(4, "Mochila", "Bolsas", 2, 89.90m),
    new(5, "Lápis", "Escrita", 60, 1.20m),
    new(6, "Papel A4", "Papelaria", 4, 27.50m),
    new(7, "Estojo", "Bolsas", 6, 19.90m),
];

decimal total = estoque.Sum(p => p.Quantidade * p.Preco);
Console.WriteLine($"Estoque inteiro: {total:C}");

// Troque o 0m por uma consulta: Where (só a Papelaria) e depois Sum
decimal papelaria = 0m;
Console.WriteLine($"Papelaria vale {papelaria:C}");

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(2, "Caneta", "Escrita", 40, 2.50m),
    new(3, "Borracha", "Escrita", 3, 1.75m),
    new(4, "Mochila", "Bolsas", 2, 89.90m),
    new(5, "Lápis", "Escrita", 60, 1.20m),
    new(6, "Papel A4", "Papelaria", 4, 27.50m),
    new(7, "Estojo", "Bolsas", 6, 19.90m),
];

decimal total = estoque.Sum(p => p.Quantidade * p.Preco);
Console.WriteLine($"Estoque inteiro: {total:C}");

// Troque o 0m por uma consulta: Where (só a Papelaria) e depois Sum
decimal papelaria = estoque.Where(p => p.Categoria == "Papelaria").Sum(p => p.Quantidade * p.Preco);
Console.WriteLine($"Papelaria vale {papelaria:C}");

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);

Versão 3 · O relatório por categoria e a busca

Agora a Ana quer o relatório do fim do mês: para cada categoria, quantos produtos, quantas unidades e quanto vale. No caderno, ela faria três pilhas de fichas (Papelaria, Escrita, Bolsas) e somaria cada pilha. O GroupBy ("agrupar por") faz exatamente as pilhas.

// Estoque da Lojinha — versão 3: relatório por categoria e busca
List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(2, "Caneta", "Escrita", 40, 2.50m),
    new(3, "Borracha", "Escrita", 3, 1.75m),
    new(4, "Mochila", "Bolsas", 2, 89.90m),
    new(5, "Lápis", "Escrita", 60, 1.20m),
    new(6, "Papel A4", "Papelaria", 4, 27.50m),
    new(7, "Estojo", "Bolsas", 6, 19.90m),
];

// Relatório: uma linha por categoria, da que vale mais para a que vale menos
var relatorio = estoque
    .GroupBy(p => p.Categoria)
    .Select(g => new
    {
        Categoria = g.Key,
        Produtos = g.Count(),
        Unidades = g.Sum(p => p.Quantidade),
        Valor = g.Sum(p => p.Quantidade * p.Preco)
    })
    .OrderByDescending(c => c.Valor);

Console.WriteLine("Categoria   Produtos  Unidades       Valor");
foreach (var c in relatorio)
{
    Console.WriteLine($"{c.Categoria,-10}  {c.Produtos,8}  {c.Unidades,8}  {c.Valor,10:C}");
}

// Busca pelo nome, sem ligar para maiúsculas
Mostrar(Buscar(estoque, "caneta"));
Mostrar(Buscar(estoque, "régua"));

bool temBolsa = estoque.Any(p => p.Categoria == "Bolsas");
bool tudoTem = estoque.All(p => p.Quantidade > 0);
Console.WriteLine($"Vende bolsas? {temBolsa}. Nada esgotado? {tudoTem}.");

static Produto? Buscar(List<Produto> lista, string nome) =>
    lista.FirstOrDefault(p => p.Nome.Equals(nome, StringComparison.OrdinalIgnoreCase));

static void Mostrar(Produto? p)
{
    if (p is null)
    {
        Console.WriteLine("Não temos esse produto.");
        return;
    }
    Console.WriteLine($"{p.Nome}: {p.Quantidade} unidades a {p.Preco:C}");
}

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
LinhaO que faz
.GroupBy(p => p.Categoria)Faz uma pilha para cada categoria diferente. Cada pilha g tem uma chave, g.Key (o nome da categoria), e é ela mesma uma sequência com os produtos daquela categoria. Aula: Agrupar.
.Select(g => new { Categoria = g.Key, … })Troca cada pilha por um resumo dela. O new { … } sem nome de tipo cria um tipo anônimo: um objeto só com esses campos, que serve só para este relatório.
g.Count(), g.Sum(p => p.Quantidade)As mesmas contas da versão 2, mas só com os produtos da pilha.
.OrderByDescending(c => c.Valor)Ordena do maior valor para o menor ("descending" = decrescente).
foreach (var c in relatorio)Aqui o var é obrigatório: o tipo anônimo não tem nome para escrever.
static Produto? Buscar(…) => lista.FirstOrDefault(…)Uma função local que devolve o primeiro produto que combina, ou null se nenhum combina (o "OrDefault"). O ? em Produto? avisa que pode vir null.
p.Nome.Equals(nome, StringComparison.OrdinalIgnoreCase)Compara ignorando maiúsculas e minúsculas: "caneta" acha "Caneta" (aula Métodos de String).
if (p is null)Antes de usar o resultado de FirstOrDefault, confira se veio alguma coisa (aula null).
estoque.Any(…), estoque.All(…)Any: "algum produto cumpre?". All: "todos cumprem?". Os dois devolvem bool, que o Console mostra como True ou False.
$ dotnet run
Categoria   Produtos  Unidades       Valor
Papelaria          2        16   R$ 300,80
Bolsas             2         8   R$ 299,20
Escrita            3       103   R$ 177,25
Caneta: 40 unidades a R$ 2,50
Não temos esse produto.
Vende bolsas? True. Nada esgotado? True.

A Escrita tem quase todas as unidades (103 de 127), mas é a que vale menos: são canetas e lápis baratos. A Papelaria, com só 16 unidades, vale mais. É o tipo de coisa que o relatório mostra e o olho não vê.

First sem resultado: InvalidOperationException

Na primeira busca, a Ana usou First em vez de FirstOrDefault, e procurou um produto que a loja não tem:

List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(2, "Caneta", "Escrita", 40, 2.50m),
];

Produto regua = estoque.First(p => p.Nome == "Régua");
Console.WriteLine(regua.Preco);

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
$ dotnet run
Unhandled exception. System.InvalidOperationException: Sequence contains no matching element
   at System.Linq.ThrowHelper.ThrowNoMatchException()
   at System.Linq.Enumerable.First[TSource](IEnumerable`1 source, Func`2 predicate)
   at Program.<Main>$(String[] args) in /home/ana/projetos/Estoque/Program.cs:line 7
$ echo $?
134

O que significa: "a sequência não contém nenhum elemento que combine". O First promete devolver um produto; quando não há nenhum, ele não tem o que devolver e lança uma exceção. O programa cai na linha 7, com o código de saída 134.

Como consertar: quando pode não haver resultado (busca pelo que o usuário digitou, por exemplo), use FirstOrDefault e confira o null, como na versão 3. Deixe o First para quando "não achar" seria mesmo um defeito do programa.

Missão (versão 3): este programa deveria mostrar uma linha por categoria, com os nomes dos produtos dela, mas mostra uma linha por produto. O defeito está na chave do GroupBy. Conserte para a saída ter Escrita: Caneta, Borracha, Lápis.

List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(2, "Caneta", "Escrita", 40, 2.50m),
    new(3, "Borracha", "Escrita", 3, 1.75m),
    new(4, "Mochila", "Bolsas", 2, 89.90m),
    new(5, "Lápis", "Escrita", 60, 1.20m),
    new(6, "Papel A4", "Papelaria", 4, 27.50m),
    new(7, "Estojo", "Bolsas", 6, 19.90m),
];

// Uma linha por categoria, com os nomes dos produtos dela
var grupos = estoque.GroupBy(p => p.Nome);
foreach (var g in grupos)
{
    Console.WriteLine($"{g.Key}: {string.Join(", ", g.Select(p => p.Nome))}");
}

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(2, "Caneta", "Escrita", 40, 2.50m),
    new(3, "Borracha", "Escrita", 3, 1.75m),
    new(4, "Mochila", "Bolsas", 2, 89.90m),
    new(5, "Lápis", "Escrita", 60, 1.20m),
    new(6, "Papel A4", "Papelaria", 4, 27.50m),
    new(7, "Estojo", "Bolsas", 6, 19.90m),
];

// Uma linha por categoria, com os nomes dos produtos dela
var grupos = estoque.GroupBy(p => p.Categoria);
foreach (var g in grupos)
{
    Console.WriteLine($"{g.Key}: {string.Join(", ", g.Select(p => p.Nome))}");
}

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);

Versão 4 · O menu: entrada e saída

Até aqui, o programa só olha. Agora ele vai mudar o estoque: quando chega mercadoria (entrada) e quando a Ana vende (saída). Um menu pergunta o que fazer, até ela digitar 0. As consultas da versão 2 continuam úteis: a opção "Acabando" é o mesmo Where, passado para a função que lista.

// Estoque da Lojinha — versão 4: o menu, a entrada e a saída
List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(2, "Caneta", "Escrita", 40, 2.50m),
    new(3, "Borracha", "Escrita", 3, 1.75m),
    new(4, "Mochila", "Bolsas", 2, 89.90m),
    new(5, "Lápis", "Escrita", 60, 1.20m),
    new(6, "Papel A4", "Papelaria", 4, 27.50m),
    new(7, "Estojo", "Bolsas", 6, 19.90m),
];

Console.WriteLine("=== Estoque da Lojinha ===");
while (true)
{
    Console.WriteLine();
    Console.WriteLine("1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair");
    string opcao = Perguntar("Opção: ");
    if (opcao == "0")
    {
        break;
    }
    switch (opcao)
    {
        case "1": Listar(estoque); break;
        case "2": Listar(estoque.Where(p => p.Quantidade < 5)); break;
        case "3": Movimentar(estoque, +1); break;
        case "4": Movimentar(estoque, -1); break;
        default: Console.WriteLine("Opção inválida."); break;
    }
}
Console.WriteLine($"Até amanhã! Valor do estoque: {estoque.Sum(p => p.Quantidade * p.Preco):C}");

static string Perguntar(string texto)
{
    Console.Write(texto);
    return Console.ReadLine()?.Trim() ?? "0";     // fim da entrada = sair
}

static void Listar(IEnumerable<Produto> produtos)
{
    Console.WriteLine("Cód  Produto     Qtd       Preço");
    foreach (Produto p in produtos)
    {
        Console.WriteLine($"{p.Codigo,3}  {p.Nome,-10}  {p.Quantidade,3}  {p.Preco,10:C}");
    }
}

// sinal = +1 (chegou mercadoria) ou -1 (vendeu)
static void Movimentar(List<Produto> lista, int sinal)
{
    if (!int.TryParse(Perguntar("Código: "), out int codigo))
    {
        Console.WriteLine("Código inválido.");
        return;
    }
    int i = lista.FindIndex(p => p.Codigo == codigo);
    if (i == -1)
    {
        Console.WriteLine($"Não há produto com o código {codigo}.");
        return;
    }
    if (!int.TryParse(Perguntar("Quantas unidades? "), out int qtd) || qtd <= 0)
    {
        Console.WriteLine("Quantidade inválida.");
        return;
    }
    Produto p = lista[i];
    if (sinal < 0 && qtd > p.Quantidade)
    {
        Console.WriteLine($"Só há {p.Quantidade} de {p.Nome}.");
        return;
    }
    lista[i] = p with { Quantidade = p.Quantidade + sinal * qtd };
    Console.WriteLine($"{p.Nome}: agora são {lista[i].Quantidade}.");
}

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
LinhaO que faz
while (true) { … break; }Repete o menu para sempre; o break sai do laço quando a opção é "0" (aulas while e break).
switch (opcao) { case "1": … }Escolhe o que fazer pelo texto digitado. O default pega tudo o que não é opção (aula switch).
Console.ReadLine()?.Trim() ?? "0"Lê uma linha. Quando a entrada acaba (o usuário apertou Ctrl+D), o ReadLine devolve null: o ?. não chama o Trim, e o ?? troca o null por "0", que sai do programa (aula Operadores ??, ?. e ??=).
static void Listar(IEnumerable<Produto> produtos)O parâmetro é IEnumerable, e não List: assim, a mesma função lista a List inteira e o resultado de um Where (aula IEnumerable e foreach).
int.TryParse(…, out int codigo)Tenta converter o texto em número. Devolve false se a Ana digitou abc, em vez de derrubar o programa como o int.Parse faria (aula Parse e TryParse).
lista.FindIndex(p => p.Codigo == codigo)A posição do produto na lista, ou -1 se não há. É um método da própria List, não do LINQ: precisamos da posição para trocar a ficha.
if (sinal < 0 && qtd > p.Quantidade)Na saída, não deixa vender mais do que há.
lista[i] = p with { Quantidade = … };Uma ficha nova, cópia da velha com a quantidade nova, no mesmo lugar da lista. sinal * qtd soma na entrada e subtrai na saída.

A Ana rodou e fez uma tarde de trabalho (os números depois de Opção:, Código: e Quantas unidades? são o que ela digitou):

$ dotnet run
=== Estoque da Lojinha ===

1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair
Opção: 2
Cód  Produto     Qtd       Preço
  3  Borracha      3     R$ 1,75
  4  Mochila       2    R$ 89,90
  6  Papel A4      4    R$ 27,50

1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair
Opção: 3
Código: 3
Quantas unidades? 20
Borracha: agora são 23.

1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair
Opção: 4
Código: 4
Quantas unidades? 3
Só há 2 de Mochila.

1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair
Opção: 4
Código: abc
Código inválido.

1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair
Opção: 4
Código: 2
Quantas unidades? 5
Caneta: agora são 35.

1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair
Opção: 1
Cód  Produto     Qtd       Preço
  1  Caderno      12    R$ 15,90
  2  Caneta       35     R$ 2,50
  3  Borracha     23     R$ 1,75
  4  Mochila       2    R$ 89,90
  5  Lápis        60     R$ 1,20
  6  Papel A4      4    R$ 27,50
  7  Estojo        6    R$ 19,90

1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair
Opção: 0
Até amanhã! Valor do estoque: R$ 799,75
Apagar dentro do foreach: "Collection was modified"

A Ana pensou numa opção para tirar da lista os produtos esgotados e escreveu o jeito mais direto:

List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(4, "Mochila", "Bolsas", 0, 89.90m),
    new(7, "Estojo", "Bolsas", 6, 19.90m),
];

// tirar da lista o que esgotou
foreach (Produto p in estoque)
{
    if (p.Quantidade == 0)
    {
        estoque.Remove(p);
    }
}
Console.WriteLine(estoque.Count);

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
$ dotnet run
Unhandled exception. System.InvalidOperationException: Collection was modified; enumeration operation may not execute.
   at System.Collections.Generic.List`1.Enumerator.MoveNext()
   at Program.<Main>$(String[] args) in /home/ana/projetos/Estoque/Program.cs:line 9

O que significa: "a coleção foi modificada; a enumeração não pode continuar". O foreach vai andando pela lista; quando ela muda no meio do caminho, ele não sabe mais onde está e para com uma exceção (na linha 9, a do foreach).

Como consertar: não mexa na lista que o foreach está percorrendo. Para apagar, a List tem um método pronto, que faz o laço por dentro: estoque.RemoveAll(p => p.Quantidade == 0);. Ou, no estilo LINQ, crie uma lista nova só com o que fica: estoque = estoque.Where(p => p.Quantidade > 0).ToList();. O mesmo vale para Add dentro do foreach.

Missão (versão 4): a função Movimentar está sem o teste da saída, e a Ana consegue vender 3 mochilas quando só tem 2 (o estoque fica em -1). Escreva o teste no lugar do comentário. A entrada já vem pronta: opção 4 (saída), código 4, 3 unidades; depois lista (1) e sai (0). Com o teste certo, aparece Só há 2 de Mochila. e o valor final continua R$ 777,25.

// Estoque da Lojinha — versão 4: o menu, a entrada e a saída
List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(2, "Caneta", "Escrita", 40, 2.50m),
    new(3, "Borracha", "Escrita", 3, 1.75m),
    new(4, "Mochila", "Bolsas", 2, 89.90m),
    new(5, "Lápis", "Escrita", 60, 1.20m),
    new(6, "Papel A4", "Papelaria", 4, 27.50m),
    new(7, "Estojo", "Bolsas", 6, 19.90m),
];

Console.WriteLine("=== Estoque da Lojinha ===");
while (true)
{
    Console.WriteLine();
    Console.WriteLine("1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair");
    string opcao = Perguntar("Opção: ");
    if (opcao == "0")
    {
        break;
    }
    switch (opcao)
    {
        case "1": Listar(estoque); break;
        case "2": Listar(estoque.Where(p => p.Quantidade < 5)); break;
        case "3": Movimentar(estoque, +1); break;
        case "4": Movimentar(estoque, -1); break;
        default: Console.WriteLine("Opção inválida."); break;
    }
}
Console.WriteLine($"Até amanhã! Valor do estoque: {estoque.Sum(p => p.Quantidade * p.Preco):C}");

static string Perguntar(string texto)
{
    Console.Write(texto);
    return Console.ReadLine()?.Trim() ?? "0";     // fim da entrada = sair
}

static void Listar(IEnumerable<Produto> produtos)
{
    Console.WriteLine("Cód  Produto     Qtd       Preço");
    foreach (Produto p in produtos)
    {
        Console.WriteLine($"{p.Codigo,3}  {p.Nome,-10}  {p.Quantidade,3}  {p.Preco,10:C}");
    }
}

// sinal = +1 (chegou mercadoria) ou -1 (vendeu)
static void Movimentar(List<Produto> lista, int sinal)
{
    if (!int.TryParse(Perguntar("Código: "), out int codigo))
    {
        Console.WriteLine("Código inválido.");
        return;
    }
    int i = lista.FindIndex(p => p.Codigo == codigo);
    if (i == -1)
    {
        Console.WriteLine($"Não há produto com o código {codigo}.");
        return;
    }
    if (!int.TryParse(Perguntar("Quantas unidades? "), out int qtd) || qtd <= 0)
    {
        Console.WriteLine("Quantidade inválida.");
        return;
    }
    Produto p = lista[i];
    // FALTA AQUI: na saída (sinal < 0), não deixe vender mais do que há.
    // Mostre "Só há 2 de Mochila." (com p.Quantidade e p.Nome) e saia com return.

    lista[i] = p with { Quantidade = p.Quantidade + sinal * qtd };
    Console.WriteLine($"{p.Nome}: agora são {lista[i].Quantidade}.");
}

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
// Estoque da Lojinha — versão 4: o menu, a entrada e a saída
List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(2, "Caneta", "Escrita", 40, 2.50m),
    new(3, "Borracha", "Escrita", 3, 1.75m),
    new(4, "Mochila", "Bolsas", 2, 89.90m),
    new(5, "Lápis", "Escrita", 60, 1.20m),
    new(6, "Papel A4", "Papelaria", 4, 27.50m),
    new(7, "Estojo", "Bolsas", 6, 19.90m),
];

Console.WriteLine("=== Estoque da Lojinha ===");
while (true)
{
    Console.WriteLine();
    Console.WriteLine("1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair");
    string opcao = Perguntar("Opção: ");
    if (opcao == "0")
    {
        break;
    }
    switch (opcao)
    {
        case "1": Listar(estoque); break;
        case "2": Listar(estoque.Where(p => p.Quantidade < 5)); break;
        case "3": Movimentar(estoque, +1); break;
        case "4": Movimentar(estoque, -1); break;
        default: Console.WriteLine("Opção inválida."); break;
    }
}
Console.WriteLine($"Até amanhã! Valor do estoque: {estoque.Sum(p => p.Quantidade * p.Preco):C}");

static string Perguntar(string texto)
{
    Console.Write(texto);
    return Console.ReadLine()?.Trim() ?? "0";     // fim da entrada = sair
}

static void Listar(IEnumerable<Produto> produtos)
{
    Console.WriteLine("Cód  Produto     Qtd       Preço");
    foreach (Produto p in produtos)
    {
        Console.WriteLine($"{p.Codigo,3}  {p.Nome,-10}  {p.Quantidade,3}  {p.Preco,10:C}");
    }
}

// sinal = +1 (chegou mercadoria) ou -1 (vendeu)
static void Movimentar(List<Produto> lista, int sinal)
{
    if (!int.TryParse(Perguntar("Código: "), out int codigo))
    {
        Console.WriteLine("Código inválido.");
        return;
    }
    int i = lista.FindIndex(p => p.Codigo == codigo);
    if (i == -1)
    {
        Console.WriteLine($"Não há produto com o código {codigo}.");
        return;
    }
    if (!int.TryParse(Perguntar("Quantas unidades? "), out int qtd) || qtd <= 0)
    {
        Console.WriteLine("Quantidade inválida.");
        return;
    }
    Produto p = lista[i];
    if (sinal < 0 && qtd > p.Quantidade)
    {
        Console.WriteLine($"Só há {p.Quantidade} de {p.Nome}.");
        return;
    }
    lista[i] = p with { Quantidade = p.Quantidade + sinal * qtd };
    Console.WriteLine($"{p.Nome}: agora são {lista[i].Quantidade}.");
}

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);

Versão 5 · O estoque guardado num arquivo

A versão 4 tem um problema grave: ao sair, tudo o que a Ana fez some, e no dia seguinte a caneta volta a ter 40. A memória do programa acaba junto com ele. Para lembrar, o estoque vai para um arquivo de texto, estoque.csv, uma linha por produto, com os campos separados por ;. O formato CSV ("valores separados por vírgula") abre até numa planilha.

Antes: o centavo que virou milhar

A primeira tentativa da Ana usou vírgula entre os campos e escreveu o preço do jeito normal, com {p.Preco}:

O preço perdeu os centavos, sem nenhuma mensagem de erro
// errado.cs — a primeira tentativa da Ana de salvar em CSV
List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(2, "Caneta", "Escrita", 40, 2.50m),
];

// salvar: uma linha por produto, campos separados por vírgula
File.WriteAllLines("estoque.csv",
    estoque.Select(p => $"{p.Codigo},{p.Nome},{p.Categoria},{p.Quantidade},{p.Preco}"));

// carregar de volta
List<Produto> lido = File.ReadLines("estoque.csv")
    .Select(linha => linha.Split(','))
    .Select(c => new Produto(int.Parse(c[0]), c[1], c[2], int.Parse(c[3]), decimal.Parse(c[4])))
    .ToList();

foreach (Produto p in lido)
{
    Console.WriteLine($"{p.Nome}: {p.Preco:C}");
}

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
$ dotnet run
Caderno: R$ 15,00
Caneta: R$ 2,00
$ cat estoque.csv
1,Caderno,Papelaria,12,15,90
2,Caneta,Escrita,40,2,50

O que aconteceu: na máquina da Ana, em pt-BR, 15.90m vira o texto 15,90, com vírgula. A linha do caderno ficou com seis pedaços, e não cinco: …,12,15,90. Na leitura, o Split(',') separou o preço em "15" e "90"; o programa leu só o "15", e os centavos sumiram. Não houve erro nenhum, e esse é o pior tipo de defeito.

Como consertar: duas mudanças, e a versão 5 usa as duas. Separe os campos com ;, que não aparece em números. E grave e leia os números sempre do mesmo jeito, independente do país do computador, com CultureInfo.InvariantCulture (a "cultura invariante", que usa ponto): o preço vai para o arquivo como 15.90 (aula Cultura).

A versão 5 é a 4 com duas funções novas, Carregar e Salvar, e duas linhas novas no começo e no fim:

// Estoque da Lojinha — versão 5: o estoque guardado em estoque.csv
using System.Globalization;

const string Arquivo = "estoque.csv";
List<Produto> estoque = Carregar();

Console.WriteLine("=== Estoque da Lojinha ===");
Console.WriteLine($"{estoque.Count} produtos carregados.");
while (true)
{
    Console.WriteLine();
    Console.WriteLine("1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair");
    string opcao = Perguntar("Opção: ");
    if (opcao == "0")
    {
        break;
    }
    switch (opcao)
    {
        case "1": Listar(estoque); break;
        case "2": Listar(estoque.Where(p => p.Quantidade < 5)); break;
        case "3": Movimentar(estoque, +1); break;
        case "4": Movimentar(estoque, -1); break;
        default: Console.WriteLine("Opção inválida."); break;
    }
}
Salvar(estoque);
Console.WriteLine($"Salvei {estoque.Count} produtos em {Arquivo}. Até amanhã!");

static string Perguntar(string texto)
{
    Console.Write(texto);
    return Console.ReadLine()?.Trim() ?? "0";     // fim da entrada = sair
}

static void Listar(IEnumerable<Produto> produtos)
{
    Console.WriteLine("Cód  Produto     Qtd       Preço");
    foreach (Produto p in produtos)
    {
        Console.WriteLine($"{p.Codigo,3}  {p.Nome,-10}  {p.Quantidade,3}  {p.Preco,10:C}");
    }
}

// sinal = +1 (chegou mercadoria) ou -1 (vendeu)
static void Movimentar(List<Produto> lista, int sinal)
{
    if (!int.TryParse(Perguntar("Código: "), out int codigo))
    {
        Console.WriteLine("Código inválido.");
        return;
    }
    int i = lista.FindIndex(p => p.Codigo == codigo);
    if (i == -1)
    {
        Console.WriteLine($"Não há produto com o código {codigo}.");
        return;
    }
    if (!int.TryParse(Perguntar("Quantas unidades? "), out int qtd) || qtd <= 0)
    {
        Console.WriteLine("Quantidade inválida.");
        return;
    }
    Produto p = lista[i];
    if (sinal < 0 && qtd > p.Quantidade)
    {
        Console.WriteLine($"Só há {p.Quantidade} de {p.Nome}.");
        return;
    }
    lista[i] = p with { Quantidade = p.Quantidade + sinal * qtd };
    Console.WriteLine($"{p.Nome}: agora são {lista[i].Quantidade}.");
}

static List<Produto> Carregar()
{
    if (!File.Exists(Arquivo))
    {
        Console.WriteLine($"{Arquivo} não existe: começando do caderno de papel.");
        return
        [
            new(1, "Caderno", "Papelaria", 12, 15.90m),
            new(2, "Caneta", "Escrita", 40, 2.50m),
            new(3, "Borracha", "Escrita", 3, 1.75m),
            new(4, "Mochila", "Bolsas", 2, 89.90m),
            new(5, "Lápis", "Escrita", 60, 1.20m),
            new(6, "Papel A4", "Papelaria", 4, 27.50m),
            new(7, "Estojo", "Bolsas", 6, 19.90m),
        ];
    }
    return File.ReadLines(Arquivo)
        .Skip(1)                                   // pula o cabeçalho
        .Where(linha => linha.Trim() != "")        // ignora linhas vazias
        .Select(LerLinha)
        .ToList();
}

static Produto LerLinha(string linha)
{
    string[] c = linha.Split(';');
    return new Produto(int.Parse(c[0]), c[1], c[2], int.Parse(c[3]),
                       decimal.Parse(c[4], CultureInfo.InvariantCulture));
}

static void Salvar(List<Produto> lista)
{
    var linhas = lista.Select(p => string.Join(";", p.Codigo, p.Nome, p.Categoria, p.Quantidade,
                                               p.Preco.ToString(CultureInfo.InvariantCulture)));
    File.WriteAllLines(Arquivo, linhas.Prepend("codigo;nome;categoria;quantidade;preco"));
}

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
LinhaO que faz
using System.Globalization;Traz o CultureInfo. Fica na primeira linha do arquivo (aula Namespaces e using).
const string Arquivo = "estoque.csv";O nome do arquivo num lugar só. Por ser const, as funções static podem usá-lo (aula Constantes). O arquivo fica na pasta em que a Ana roda o programa.
if (!File.Exists(Arquivo))Na primeira vez, o arquivo não existe, e o programa começa com os produtos do caderno de papel (aula Arquivos).
File.ReadLines(Arquivo)Lê o arquivo linha por linha, como uma sequência de string. Por ser uma sequência, aceita LINQ.
.Skip(1)Pula a primeira linha, o cabeçalho codigo;nome;….
.Where(linha => linha.Trim() != "")Ignora linhas em branco (um Enter a mais no fim do arquivo, por exemplo).
.Select(LerLinha)Transforma cada linha num Produto. Passar o nome do método é o mesmo que .Select(linha => LerLinha(linha)).
.ToList()Roda a consulta na hora e devolve a List que o menu vai mudar.
linha.Split(';')Corta a linha em pedaços no ;: "1;Caderno;Papelaria;12;15.90" vira um array de 5 textos.
decimal.Parse(c[4], CultureInfo.InvariantCulture)Lê "15.90" com ponto, do jeito que foi gravado, em qualquer computador.
string.Join(";", p.Codigo, p.Nome, …)Monta a linha com ; entre os campos.
p.Preco.ToString(CultureInfo.InvariantCulture)Escreve o preço com ponto: 15.90.
linhas.Prepend("codigo;nome;…")Põe o cabeçalho antes das linhas dos produtos.
File.WriteAllLines(Arquivo, …)Cria o arquivo (ou apaga o que havia) e grava uma linha para cada texto da sequência.

Na primeira vez, o arquivo não existe. A Ana faz a tarde de trabalho do começo da página (O que vamos construir) e sai; o programa grava o arquivo. No dia seguinte, ela olha o arquivo e roda de novo:

$ cat estoque.csv
codigo;nome;categoria;quantidade;preco
1;Caderno;Papelaria;12;15.90
2;Caneta;Escrita;35;2.50
3;Borracha;Escrita;23;1.75
4;Mochila;Bolsas;2;89.90
5;Lápis;Escrita;60;1.20
6;Papel A4;Papelaria;4;27.50
7;Estojo;Bolsas;6;19.90
$ dotnet run
=== Estoque da Lojinha ===
7 produtos carregados.

1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair
Opção: 1
Cód  Produto     Qtd       Preço
  1  Caderno      12    R$ 15,90
  2  Caneta       35     R$ 2,50          « lembrou da venda de ontem
  3  Borracha     23     R$ 1,75          « e da entrada
  4  Mochila       2    R$ 89,90
  5  Lápis        60     R$ 1,20
  6  Papel A4      4    R$ 27,50
  7  Estojo        6    R$ 19,90

1) Listar  2) Acabando  3) Entrada  4) Saída  0) Sair
Opção: 0
Salvei 7 produtos em estoque.csv. Até amanhã!

Desta vez não apareceu a linha estoque.csv não existe: os 7 produtos vieram do arquivo. No arquivo, os preços estão com ponto (15.90); na tela, com vírgula (R$ 15,90). O arquivo é para o programa; a tela é para a Ana.

Missão (versão 5): este programa grava dois produtos em estoque.csv, mostra o arquivo e lê de volta. A leitura já usa InvariantCulture, mas a gravação não: o preço vai para o arquivo como 15,90, e a leitura invariante entende a vírgula como separador de milhar (o caderno sai a R$ 1.590,00!). Conserte a gravação para o preço ir com ponto, e a saída mostrar Caderno: R$ 15,90.

using System.Globalization;

List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(2, "Caneta", "Escrita", 40, 2.50m),
];

// Salvar: o preço tem de ir com ponto, do jeito "invariante"
File.WriteAllLines("estoque.csv",
    estoque.Select(p => $"{p.Codigo};{p.Nome};{p.Categoria};{p.Quantidade};{p.Preco}"));

// Carregar: lê o preço com ponto (InvariantCulture)
List<Produto> lido = File.ReadLines("estoque.csv")
    .Select(linha => linha.Split(';'))
    .Select(c => new Produto(int.Parse(c[0]), c[1], c[2], int.Parse(c[3]),
                             decimal.Parse(c[4], CultureInfo.InvariantCulture)))
    .ToList();

Console.WriteLine(File.ReadAllText("estoque.csv"));
foreach (Produto p in lido)
{
    Console.WriteLine($"{p.Nome}: {p.Preco:C}");
}

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);
using System.Globalization;

List<Produto> estoque =
[
    new(1, "Caderno", "Papelaria", 12, 15.90m),
    new(2, "Caneta", "Escrita", 40, 2.50m),
];

// Salvar: o preço tem de ir com ponto, do jeito "invariante"
File.WriteAllLines("estoque.csv",
    estoque.Select(p => $"{p.Codigo};{p.Nome};{p.Categoria};{p.Quantidade};{p.Preco.ToString(CultureInfo.InvariantCulture)}"));

// Carregar: lê o preço com ponto (InvariantCulture)
List<Produto> lido = File.ReadLines("estoque.csv")
    .Select(linha => linha.Split(';'))
    .Select(c => new Produto(int.Parse(c[0]), c[1], c[2], int.Parse(c[3]),
                             decimal.Parse(c[4], CultureInfo.InvariantCulture)))
    .ToList();

Console.WriteLine(File.ReadAllText("estoque.csv"));
foreach (Produto p in lido)
{
    Console.WriteLine($"{p.Nome}: {p.Preco:C}");
}

record Produto(int Codigo, string Nome, string Categoria, int Quantidade, decimal Preco);

Formas diferentes

Em vez de…Daria para…Por que o projeto não fez
estoque.Where(p => p.Quantidade < 5)Sintaxe de consulta: from p in estoque where p.Quantidade < 5 select pÉ o mesmo programa depois de compilado. A forma com métodos é a mais usada e tem todos os operadores (MaxBy, Prepend… não existem na sintaxe de consulta). Veja O que é LINQ.
record Produto(…) e withclass Produto com { get; set; } e p.Quantidade -= qtdFunciona. O record impede mudanças por engano e já mostra os campos no WriteLine. O projeto Lista de Tarefas usa uma classe com set.
estoque.csv com ;estoque.json com System.Text.JsonO JSON resolve as vírgulas e a cultura sozinho; o projeto Agenda usa JSON. O CSV foi escolhido aqui porque se lê com LINQ, linha por linha, e abre numa planilha.
FindIndex + lista[i] = …Dictionary<int, Produto> com o código como chaveCom milhares de produtos, o dicionário acha o código mais rápido (aula Dictionary). Para sete, a lista é mais simples e mantém a ordem.

Desafios extras

  • Cadastrar: uma opção 5 que pede nome, categoria, quantidade e preço e acrescenta o produto com o código seguinte: estoque.Max(p => p.Codigo) + 1. Leia o preço com decimal.TryParse (em pt-BR, a Ana digita 3,50).
  • Relatório no menu: leve o GroupBy da versão 3 para uma opção 6.
  • Arquivo estragado: abra o estoque.csv num editor, troque um preço por abc e rode. O decimal.Parse lança FormatException. Troque por TryParse e pule a linha com um aviso.
  • Busca por pedaço do nome: p.Nome.Contains(texto, StringComparison.OrdinalIgnoreCase) acha "Papel A4" digitando "papel".

Exercícios

1. O que estoque.Where(p => p.Quantidade < 5) devolve?

2. A Ana procura um produto pelo nome que o cliente falou. Qual método é o certo?

3. Por que estoque[0].Quantidade = 11; não compila neste projeto?

4. Na máquina da Ana, $"{15.90m}" vira 15,90. O que isso causa num CSV separado por vírgula?

5. Complete para somar o valor da categoria Bolsas:

decimal bolsas = estoque.(p => p.Categoria == "Bolsas").(p => p.Quantidade * p.Preco);

6. Complete para trocar a ficha da posição i por uma cópia com 5 unidades a menos:

lista[i] = lista[i] { Quantidade = lista[i].Quantidade - 5 };

7. Complete para pular o cabeçalho do arquivo antes de ler os produtos:

File.ReadLines("estoque.csv").(1).Select(LerLinha).();

Resumo

  • Um record descreve a ficha; uma List<Produto> guarda todas. Para "mudar" um record, crie uma cópia com with.
  • Where filtra, OrderBy ordena, Select transforma, Count e Sum fazem contas, MaxBy acha o maior, Any e All respondem sim ou não.
  • GroupBy faz uma pilha por chave (g.Key); cada pilha aceita as mesmas contas.
  • First lança exceção se não acha; FirstOrDefault devolve null, e você confere.
  • Uma consulta LINQ roda de novo a cada uso; ToList() tira uma "foto" na hora.
  • Não mude a lista dentro do foreach que a percorre: use RemoveAll ou crie uma lista nova.
  • Em arquivos, grave e leia números com CultureInfo.InvariantCulture, e separe campos com um caractere que não aparece nos dados.