Saltar para o conteúdo
Voltar a todos os artigos
Architecture Development WordPress 29 Apr 2026 · 12 min de leitura

Como construí uma calculadora de avaliação que valida contra o Excel de um professor real

Francisco Silva

Francisco Silva

Parceiro Sénior de Engenharia WordPress.

Como construí uma calculadora de avaliação que valida contra o Excel de um professor real

Uma abordagem de Domain-Driven Design ao cálculo de notas em PHP, com testes de validação contra dados reais de 23 alunos × 2 períodos × 8 instrumentos. Sem “nós”, apenas um programador, uma professora e a disciplina de fazer a matemática bater certo.

Estou a construir um SaaS para professores portugueses. Não um sistema de gestão de aprendizagem, não um portal para pais, não mais uma plataforma EdTech — apenas a ferramenta específica e focada que um professor usa para acompanhar as notas dos alunos em várias turmas e períodos, com os cálculos exigidos pelo sistema de ensino básico português (medidas de inclusão do DL 54/2018, avaliação por domínios, ponderação por período, conversão de níveis).

O mercado é dominado pelo Excel. Os professores constroem folhas de cálculo que calculam médias por período, notas finais ponderadas e conversões de nível. Estas folhas de cálculo são frágeis, inconsistentes entre colegas e propensas a erros silenciosos nas fórmulas. Uma referência de célula errada pode propagar erros para 28 alunos antes de alguém se aperceber.

A minha beta tester é uma professora de Matemática que tem precisamente esse Excel. Real, usado em produção, aperfeiçoado ao longo de anos. Vinte e três alunos do 5.º ano, dois períodos de dados, oito instrumentos de avaliação distribuídos por dois domínios. O meu trabalho é replicar exatamente o que o Excel dela faz — mas como uma aplicação web mais rápida, mais fiável e escalável para outros professores.

Este artigo é sobre a disciplina de engenharia por trás desse “exatamente”. Mais concretamente: como construí um motor de cálculo em que confio, validado contra dados de referência reais provenientes da folha de cálculo de uma professora.

O problema de cálculo, em linguagem simples

O cálculo de notas de um professor português funciona, de forma resumida, assim:

Para cada período:

  • A turma tem um modelo de critérios de avaliação, organizado em domínios (por exemplo, “Conhecimentos” 60%, “Atitudes” 40%)
  • Cada domínio tem instrumentos com ponderações explícitas em pontos (Teste = 40 pts, Trabalhos = 10 pts, Participação = 10 pts, etc.)
  • Os pontos dos instrumentos em todos os domínios somam 100
  • A nota de período de um aluno é a soma dos pontos obtidos em cada instrumento
  • Essa percentagem é depois convertida num nível (de 1 a 5) com recurso a limiares — mas com uma particularidade: os limiares situam-se em .45% (por exemplo, 49,45%, 69,45%) em vez de .5%, pelo que um aluno com 49,44% fica no nível 2, enquanto 49,45% já corresponde ao nível 3

Para a nota final ao longo de vários períodos:

  • Período 1: direto (a própria nota do período)
  • Período 2: 35% × P1 + 65% × P2
  • Período 3: 33% × P1 + 34% × P2 + 33% × P3

Simples em teoria. Brutal nos detalhes.

Por que isto requer design orientado ao domínio

Se já passou algum tempo no mundo dos plugins WordPress, reconhecerá a tentação: enfiar tudo numa gigante class GradeCalculator com uma dúzia de métodos públicos, incorporar consultas $wpdb diretamente, misturar a lógica de apresentação com a lógica de cálculo, e publicar.

Recusei esse caminho por uma razão: tenho de ser capaz de olhar o professor nos olhos e dizer que os cálculos estão corretos. Isso exige três coisas:

  1. A lógica de cálculo tem de ser testável de forma isolada, sem arrancar o WordPress
  2. Os dados que fluem através dela têm de ser explícitos e imutáveis
  3. Cada regra de negócio (os limiares de 0,45%, a ponderação por período, o tratamento de pontuações em falta) tem de residir num único lugar óbvio

A forma como estruturei o código reflete isto:

includes/Domain/
├── Calculator/
│   ├── Calculator.php              # Orchestrator
│   ├── InstrumentAggregator.php    # Aggregates scores within an instrument
│   ├── PeriodAggregator.php        # Applies the inter-term formula
│   ├── LevelConverter.php          # Percentage → level mapping
│   ├── StudentScoreset.php         # Immutable input data
│   └── GradeBreakdown.php          # Immutable output data
├── Models/
│   ├── CriteriaTemplate.php        # The grading template (domains, instruments)
│   └── Domain/Instrument/...       # Sub-models
└── ValueObjects/
    ├── LevelThresholds.php         # The .45% values, encapsulated
    └── PeriodAggregationFormula.php # The 35/65 weights, encapsulated

A pasta Domain/ inteira não tem nenhuma instrução use a apontar para o WordPress. Sem $wpdb, sem chamadas wp_*(), sem get_option(). O Calculator pode ser testado com PHPUnit simples e migraria intacto para Laravel, Symfony, ou uma ferramenta de linha de comandos.

A classe Calculator em si

Aqui está o orquestrador, na íntegra. Leia-o uma vez antes de eu explicar — a própria estrutura diz-lhe o que está a acontecer.

declare(strict_types=1);

namespace AvaliarDomainCalculator;

use AvaliarDomainModelsCriteriaTemplate;
use AvaliarDomainValueObjectsLevelThresholds;
use AvaliarDomainValueObjectsPeriodAggregationFormula;

final readonly class Calculator
{
    public function __construct(
        private InstrumentAggregator $instrumentAggregator = new InstrumentAggregator(),
        private PeriodAggregator $periodAggregator = new PeriodAggregator(),
        private LevelConverter $levelConverter = new LevelConverter(),
    ) {}

    public function periodGrade(
        CriteriaTemplate $template,
        StudentScoreset $scores
    ): GradeBreakdown {
        $pointsByInstrument = [];
        $subtotalsByDomain = [];
        $total = 0.0;
        $maxTotal = 0.0;

        foreach ($template->domains as $domain) {
            $domainSubtotal = 0.0;
            foreach ($domain->instruments as $instrument) {
                $points = $this->instrumentAggregator->pointsFor($instrument, $scores);
                $pointsByInstrument[$instrument->id] = $points;
                $domainSubtotal += $points;
                $maxTotal += $instrument->maxScore;
            }
            $subtotalsByDomain[$domain->id] = $domainSubtotal;
            $total += $domainSubtotal;
        }

        return new GradeBreakdown($total, $maxTotal, $pointsByInstrument, $subtotalsByDomain);
    }

    public function finalGrade(
        PeriodAggregationFormula $formula,
        array $periodGradesByOrder
    ): float {
        return $this->periodAggregator->finalGrade($formula, $periodGradesByOrder);
    }

    public function level(float $gradePercentage, LevelThresholds $thresholds): int
    {
        return $this->levelConverter->toLevel($gradePercentage, $thresholds);
    }
}

Repare em três aspetos:

final readonly class — sem herança, sem mutação. A Calculator é construída uma vez e invocada muitas vezes com entradas diferentes. Não existe estado interno passível de corrupção.

Construtor com dependências instanciadas por omissão — nos testes posso injetar agregadores simulados; em produção os valores por omissão funcionam na perfeição. As expressões padrão em construtores do PHP 8.1+ são excelentes para este propósito.

Três métodos públicos, cada um com uma única responsabilidadeperiodGrade(), finalGrade(), level(). Sem um ponto de entrada que «faz tudo». O chamador compõe-os pela ordem que a lógica de negócio exige.

Objetos de valor: onde vive o 0,45%

Um erro de design frequente é deixar «números mágicos» dispersos pelo código. Os limiares de nível portugueses são 19,45%, 49,45%, 69,45%, 89,45% — e um erro de 0,05% move silenciosamente os alunos entre níveis.

Em vez de os codificar diretamente na Calculator, transformei-os num objeto de valor:

declare(strict_types=1);

namespace AvaliarDomainValueObjects;

final readonly class LevelThresholds
{
    /**
     * @param float[] $values Strictly increasing thresholds.
     *                        For 5 levels (Portuguese system): [19.45, 49.45, 69.45, 89.45]
     */
    public function __construct(public array $values)
    {
        // Validate: strictly increasing, all in [0, 100]
        $previous = -INF;
        foreach ($values as $v) {
            if ($v < 0 || $v > 100) {
                throw new InvalidArgumentException("Threshold {$v} out of range");
            }
            if ($v <= $previous) {
                throw new InvalidArgumentException("Thresholds must be strictly increasing");
            }
            $previous = $v;
        }
    }

    public function maxLevel(): int
    {
        return count($this->values) + 1;
    }
}

A validação no construtor apanha toda a classe de erros onde alguém configura acidentalmente [19.45, 49.45, 49.45, 89.45] (duplicado) ou [19.45, 89.45, 49.45] (fora de ordem). É preferível falhar de forma clara na criação do modelo do que calcular silenciosamente de forma errada o nível de um aluno seis meses depois.

O LevelConverter torna-se então embaraçosamente simples:

declare(strict_types=1);

namespace AvaliarDomainCalculator;

use AvaliarDomainValueObjectsLevelThresholds;

final readonly class LevelConverter
{
    public function toLevel(float $gradePercentage, LevelThresholds $thresholds): int
    {
        foreach ($thresholds->values as $i => $threshold) {
            if ($gradePercentage < $threshold) {
                return $i + 1;
            }
        }
        return $thresholds->maxLevel();
    }
}

Note o menor-que estrito (<). Um aluno com exatamente 49,45% não está abaixo do limiar — pertence ao nível seguinte. Isto corresponde ao que a folha Excel do professor faz, e é o tipo de detalhe que um sinal-igual errado torna impossível de detetar sem valores de referência.

Fórmula entre períodos: onde o Excel e o PHP se encontram

A parte mais complexa é a fórmula de agregação de períodos. O 2.º período não é apenas «a nota do 2.º período» — é 0.35 × T1 + 0.65 × T2. O 3.º período é 0.33 × T1 + 0.34 × T2 + 0.33 × T3. E, de forma determinante, antes de existirem dados do terceiro período, a fórmula tem de produzir igualmente um valor — usando 0 para o período em falta.

Aqui está o processador da fórmula:

declare(strict_types=1);

namespace AvaliarDomainCalculator;

use AvaliarDomainValueObjectsPeriodAggregationFormula;

final readonly class PeriodAggregator
{
    public function finalGrade(
        PeriodAggregationFormula $formula,
        array $periodGradesByOrder
    ): float {
        if ($formula->isDirect()) {
            if ($periodGradesByOrder === []) {
                return 0.0;
            }
            // Direct mode: use the highest-order period (the "current" one)
            $maxOrder = max(array_keys($periodGradesByOrder));
            return (float) $periodGradesByOrder[$maxOrder];
        }

        $sum = 0.0;
        foreach ($formula->weights as $order => $weight) {
            if (!isset($periodGradesByOrder[$order])) {
                throw new InvalidArgumentException(
                    sprintf('Missing grade for period order %d', $order)
                );
            }
            $sum += $periodGradesByOrder[$order] * ($weight / 100.0);
        }
        return $sum;
    }
}

A decisão interessante aqui é o throw em períodos em falta. O Excel do professor, quando se depara com um terceiro trimestre em falta, trata-o como 0 e calcula o parcial. Portanto, se T1 é 70%, T2 é 85% e T3 ainda não começou:

Final T3 = 0.33 × 70 + 0.34 × 85 + 0.33 × 0 = 52.0%

Esse número não tem significado por si só — um aluno não tem realmente 52%. Mas é o que o Excel produz, e é o que o professor usa para acompanhar «para onde este aluno está a caminhar». Reproduzo esse comportamento exigindo que o código chamador passe explicitamente 0 para os períodos em falta, em vez de o substituir silenciosamente. A excepção força o código chamador a reflectir sobre o que «em falta» significa em cada contexto.

Verdade absoluta: o teste que importa

É aqui que este projecto difere da maioria dos motores de cálculo que já vi: tenho o Excel real do meu beta tester, anonimizado, como uma fixture JSON. Vinte e três alunos, dois trimestres de dados, com as suas percentagens finais e níveis tal como o Excel os calcula.

A fixture reside em tests/fixtures/ground-truth/:

ground-truth-complete.json   # everything: template + students + assessments
template.json                # just the criteria configuration
students-summary.json        # students with final percent + level per term
assessments.json             # tests and assignments with per-question scores

E o teste que fecha o ciclo:

class GroundTruthTest extends TestCase
{
    private const FIXTURES_DIR = __DIR__ . '/../../fixtures/ground-truth/';
    private const DELTA = 0.001; // floating-point tolerance

    private array $groundTruth;

    protected function setUp(): void
    {
        $this->groundTruth = json_decode(
            file_get_contents(self::FIXTURES_DIR . 'ground-truth-complete.json'),
            true
        );
    }

    public function testAllStudentsMatchTerm1GroundTruth(): void
    {
        $template = $this->buildTemplateFromFixture();
        $calculator = new Calculator();

        foreach ($this->groundTruth['students'] as $student) {
            $term1 = $student['periods']['1P'];
            $scoreset = $this->buildScoresetFor1P($student);

            $breakdown = $calculator->periodGrade($template, $scoreset);
            $level = $calculator->level(
                $breakdown->percentage(),
                $template->levelThresholds
            );

            $this->assertEqualsWithDelta(
                $term1['final_percent'],
                $breakdown->percentage(),
                self::DELTA,
                "Student {$student['number']}: Term 1 final percent divergence"
            );

            $this->assertEquals(
                $term1['level'],
                $level,
                "Student {$student['number']}: Term 1 level divergence"
            );
        }
    }
}

O critério de validação é binário: a saída do meu Calculator corresponde à do Excel, com uma tolerância de 0,001? Se sim para todos os 23 alunos nos dois períodos, publico. Se um único aluno divergir, existe um erro.

O assertEqualsWithDelta é importante. O Excel produz valores como 0.39999999999999997 quando as suas fórmulas encadeiam operações. A aritmética de vírgula flutuante do PHP produzirá resíduos de vírgula flutuante diferentes. A tolerância de 0,001 apanha erros reais (divergência de dois pontos percentuais na fronteira de nível) sem ser ruidosa relativamente aos fantasmas do IEEE 754.

O que aprendi

A verdade de referência muda a conversa. Quando a professora pergunta «tem a certeza de que os cálculos estão corretos», não digo «sim, testei». Digo «aqui está o teste que compara todos os seus 23 alunos com o seu Excel; passa». É um tipo de confiança diferente.

O isolamento do domínio paga-se a si próprio na primeira vez que se muda o WordPress. O Calculator não tem qualquer conhecimento da existência do WordPress. Quando eventualmente substituir o $wpdb por outra coisa, ou o cálculo correr num processo batch em CLI, o código do Calculator não se move. Apenas os repositórios que obtêm os dados e os fornecem ao Calculator mudam.

Os objetos de valor apanham erros na fronteira. O construtor de LevelThresholds valida valores estritamente crescentes e dentro do intervalo. Se um futuro formulário de administração permitir que alguém configure [20, 50, 50, 90], o sistema rejeita-o no momento de guardar o modelo, e não no momento de mostrar a nota do aluno. O raio de impacto do erro é uma única submissão do formulário, e não 28 alunos multiplicados por dois períodos.

A aritmética de vírgula flutuante é um requisito funcional, não uma nota de rodapé. Os limiares .45% não existem por capricho — existem porque os valores arredondados a .5% criam fronteiras ambíguas. O Excel da professora usa .45% deliberadamente. A minha Calculadora em PHP também tem de os usar. Errar em 0.05 muda quais os alunos que ficam no nível 3 versus nível 4, o que determina se passam de ano.

O que vem a seguir

A Calculadora está na v1.0 — estável, testada contra a fonte de verdade, em uso em produção por uma professora. As próximas camadas que estou a construir por cima:

  • Substituições de configuração: um professor pode desviar-se do modelo do departamento (pesos diferentes para uma turma específica), mas a lógica central da Calculadora não muda
  • Medidas de inclusão do DL 54/2018: alunos com adaptações curriculares precisam dos seus próprios modelos; a Calculadora recebe um CriteriaTemplate diferente por aluno quando necessário
  • Modelos multi-escola: quando uma escola adota o sistema, o seu coordenador define modelos que todos os professores do departamento herdam

Nada disto requer tocar na Calculadora. Requer novos repositórios, novos serviços, novos value objects. A Calculadora mantém-se como um pequeno, simples e bem testado pedaço de código que faz uma única coisa.

Esse é o objetivo do design orientado ao domínio, resumido: tornar as partes que importam pequenas o suficiente para raciocinar sobre elas, e pequenas o suficiente para provar que estão corretas.

Se está a construir algo em que os cálculos têm de corresponder ao modelo mental de um especialista de domínio — processamento salarial, classificações, faturação, instrumentos científicos — e não tem a certeza de como estruturar o código de forma a conseguir defender a matemática, fale comigo. Tenho opiniões, e custam menos do que descobrir um erro de cálculo seis meses após o lançamento.

#case-study #domain-driven-design #education #php #software-architecture #testing

Partilhar artigo

Últimos Insights