Reescrita dos geradores: biblioteca IGenerator, minimal API e novo frontend

- GeradoresService: cada tipo passa a ser um IGenerator (NIF, NIPC, NISS, CC,
  passaporte, IBAN, NIB, cartões, referência MB, pessoa, telefone, código postal,
  email, matrícula, EAN, UUID) registado em GeneratorRegistry; remove DAL/EF
- GeradoresWS: minimal APIs em /api/v1 com OpenAPI + Scalar e health check
- UnitTest: testes de ida e volta, valores conhecidos, detecção e API
- geradoresfe: UI construída a partir dos metadados da API, validador livre,
  exportação CSV/JSON/TXT, nginx com API_UPSTREAM configurável
- Docker, docker-compose e Jenkinsfile actualizados

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 23:51:39 +01:00
parent 0f764b1239
commit d7ea8a9b9e
119 changed files with 7764 additions and 2502 deletions

View File

@@ -0,0 +1,53 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Outros;
/// <summary>Códigos de barras EAN-13 / EAN-8 (GS1). Controlo: pesos 3,1,3,1… a partir da direita.</summary>
public sealed class EanGenerator : GeneratorBase
{
public override string Id => "ean";
public override string Name => "EAN / Código de barras";
public override string Category => Categories.Outros;
public override string Description => "Códigos EAN-13 e EAN-8 com dígito de controlo GS1.";
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("tipo", "Tipo", "ean13", ("ean13", "EAN-13"), ("ean8", "EAN-8")),
Option("prefixo", "Prefixo GS1", "560", ("560", "560 — Portugal"), ("qualquer", "Qualquer")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var length = context.Option(Options[0]) == "ean8" ? 8 : 13;
var prefix = context.Option(Options[1]) == "560" ? "560" : context.Digits(3);
var body = prefix + context.Digits(length - 1 - prefix.Length);
return new GeneratedItem(body + CheckDigit(body));
}
public static int CheckDigit(string payload)
{
var sum = 0;
for (var i = 0; i < payload.Length; i++)
{
var weight = (payload.Length - i) % 2 == 1 ? 3 : 1;
sum += (payload[i] - '0') * weight;
}
return (10 - sum % 10) % 10;
}
public override ValidationResult Validate(string value)
{
var ean = Checksums.Clean(value);
if (ean.Length is not (8 or 13) || !Checksums.AllDigits(ean))
return ValidationResult.WrongFormat("Um EAN tem 8 ou 13 dígitos.");
var expected = CheckDigit(ean[..^1]);
var details = new List<string> { $"Tipo: EAN-{ean.Length}" };
if (ean.Length == 13 && ean.StartsWith("560"))
details.Add("Prefixo 560: Portugal");
return expected == ean[^1] - '0'
? ValidationResult.Valid(ean, $"EAN-{ean.Length} válido.", details)
: ValidationResult.BadChecksum($"Dígito de controlo inválido: esperado {expected}, encontrado {ean[^1]}.", details);
}
}

View File

@@ -0,0 +1,64 @@
using System.Text.RegularExpressions;
using GeradoresService.Core;
namespace GeradoresService.Generators.Outros;
/// <summary>Matrículas portuguesas em todos os formatos desde 1937. Apenas formato.</summary>
public sealed partial class MatriculaGenerator : GeneratorBase
{
// K, W e Y não eram usadas nas matrículas portuguesas.
private const string Letters = "ABCDEFGHIJLMNOPQRSTUVXZ";
private sealed record Format(string Key, string Label, string Mask, string Period);
private static readonly Format[] Formats =
[
new("2020", "AA-00-AA (desde 2020)", "LL-DD-LL", "2020 – actualidade"),
new("2005", "00-AA-00 (2005–2020)", "DD-LL-DD", "2005 – 2020"),
new("1992", "00-00-AA (1992–2005)", "DD-DD-LL", "1992 – 2005"),
new("1937", "AA-00-00 (1937–1992)", "LL-DD-DD", "1937 – 1992"),
];
public override string Id => "matricula";
public override string Name => "Matrícula";
public override string Category => Categories.Outros;
public override string Description => "Matrícula automóvel portuguesa em qualquer um dos formatos.";
public override bool HasChecksum => false;
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("formato", "Formato", "2020", [.. Formats.Select(f => (f.Key, f.Label)), ("qualquer", "Qualquer")]),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var key = context.Option(Options[0]);
var format = key == "qualquer" ? context.Pick(Formats) : Formats.Single(f => f.Key == key);
var plate = string.Concat(format.Mask.Select(c => c switch
{
'L' => Letters[context.Random.Next(Letters.Length)],
'D' => (char)('0' + context.Random.Next(10)),
_ => c,
}));
return new GeneratedItem(plate);
}
public override ValidationResult Validate(string value)
{
var plate = Checksums.Clean(value).ToUpperInvariant();
if (plate.Length != 6 || !plate.All(char.IsAsciiLetterOrDigit))
return ValidationResult.WrongFormat("Uma matrícula tem 6 caracteres em 3 pares (ex.: AA-00-AA).");
var mask = string.Concat(plate.Chunk(2).Select(p => p.All(char.IsAsciiDigit) ? "DD" : p.All(char.IsAsciiLetter) ? "LL" : "??"));
var masked = $"{mask[..2]}-{mask[2..4]}-{mask[4..]}";
var format = Formats.FirstOrDefault(f => f.Mask == masked);
if (format is null)
return ValidationResult.WrongFormat("Não corresponde a nenhum formato de matrícula portuguesa.");
var formatted = $"{plate[..2]}-{plate[2..4]}-{plate[4..]}";
if (plate.Any(c => c is 'K' or 'W' or 'Y'))
return ValidationResult.BadChecksum("As letras K, W e Y não são usadas em matrículas portuguesas.");
return ValidationResult.Valid(formatted, $"Matrícula válida (formato {format.Period}).", [$"Formato: {format.Label}"]);
}
}

View File

@@ -0,0 +1,50 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Outros;
public sealed class UuidGenerator : GeneratorBase
{
public override string Id => "uuid";
public override string Name => "UUID / GUID";
public override string Category => Categories.Outros;
public override string Description => "Identificadores únicos UUID v4 (aleatório) ou v7 (ordenado por tempo).";
public override bool HasChecksum => false;
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("versao", "Versão", "v4", ("v4", "v4 — aleatório"), ("v7", "v7 — ordenado por tempo")),
Option("formato", "Formato", "minusculas",
("minusculas", "minúsculas com hífens"), ("maiusculas", "MAIÚSCULAS com hífens"), ("compacto", "sem hífens")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var guid = context.Option(Options[0]) == "v7" ? Guid.CreateVersion7() : NewV4(context.Random);
var text = context.Option(Options[1]) switch
{
"maiusculas" => guid.ToString("D").ToUpperInvariant(),
"compacto" => guid.ToString("N"),
_ => guid.ToString("D"),
};
return new GeneratedItem(text);
}
/// <summary>UUID v4 a partir de <paramref name="random"/> (permite resultados reprodutíveis com seed).</summary>
private static Guid NewV4(Random random)
{
Span<byte> bytes = stackalloc byte[16];
random.NextBytes(bytes);
// Versão 4 (nibble alto do byte 7 no layout do System.Guid) e variante RFC 9562.
bytes[7] = (byte)((bytes[7] & 0x0F) | 0x40);
bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80);
return new Guid(bytes);
}
public override ValidationResult Validate(string value)
{
var v = value.Trim().Trim('{', '}');
if (v.Length is not (32 or 36) || !Guid.TryParse(v, out var guid))
return ValidationResult.WrongFormat("Não é um UUID válido.");
return ValidationResult.Valid(guid.ToString("D"), $"UUID válido (versão {guid.Version}).", [$"Versão: {guid.Version}", $"Variante: {guid.Variant}"]);
}
}