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,61 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Identificacao;
/// <summary>
/// Número de documento do Cartão de Cidadão: NNNNNNNN C VV T
/// - NNNNNNNN: número de identificação civil (NIC)
/// - C: dígito de controlo do NIC (módulo 11)
/// - VV: versão (duas letras/dígitos)
/// - T: dígito de controlo do documento completo (Luhn com A=10..Z=35), conforme especificação da AMA.
/// </summary>
public sealed class CartaoCidadaoGenerator : GeneratorBase
{
public override string Id => "cc";
public override string Name => "Cartão de Cidadão";
public override string Category => Categories.Identificacao;
public override string Description => "Número de documento do Cartão de Cidadão.";
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("formato", "Formato", "espacos", ("espacos", "12345678 9 ZZ1"), ("compacto", "123456789ZZ1")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var nic = context.Digits(8);
var c = Checksums.Mod11(nic);
// As versões começam em ZZ e vão decrementando.
var version = "Z" + (char)('A' + context.Random.Next(26));
var payload = $"{nic}{c}{version}";
var t = Checksums.LuhnCheckDigit(payload);
var compact = payload + t;
return new GeneratedItem(context.Option(Options[0]) == "compacto" ? compact : Format(compact));
}
private static string Format(string compact) => $"{compact[..8]} {compact[8]} {compact[9..]}";
public override ValidationResult Validate(string value)
{
var cc = Checksums.Clean(value).ToUpperInvariant();
if (cc.Length != 12
|| !Checksums.AllDigits(cc[..9])
|| !cc[9..11].All(char.IsAsciiLetterOrDigit)
|| !char.IsAsciiDigit(cc[11]))
return ValidationResult.WrongFormat("Formato esperado: 8 dígitos, 1 dígito, 2 letras/dígitos de versão e 1 dígito (ex.: 00000000 0 ZZ4).");
var details = new List<string>
{
$"NIC: {cc[..8]}",
$"Versão: {cc[9..11]}",
"Controlo: algoritmo Luhn sobre os 12 caracteres (A=10 … Z=35)",
};
if (!Checksums.LuhnValid(cc))
{
var expected = Checksums.LuhnCheckDigit(cc[..11]);
return ValidationResult.BadChecksum($"Dígito de controlo inválido: esperado {expected}, encontrado {cc[11]}.", details);
}
return ValidationResult.Valid(Format(cc), "Número de documento do CC válido.", details);
}
}

View File

@@ -0,0 +1,119 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Identificacao;
/// <summary>
/// Número de Identificação Fiscal. 9 dígitos; o último é módulo 11 com pesos 9..2.
/// </summary>
public class NifGenerator : GeneratorBase
{
public sealed record NifPrefix(string Prefix, string Description, bool Colectiva);
public static readonly IReadOnlyList<NifPrefix> Prefixes =
[
new("1", "Pessoa singular", false),
new("2", "Pessoa singular", false),
new("3", "Pessoa singular", false),
new("45", "Pessoa singular não residente", false),
new("5", "Pessoa colectiva", true),
new("6", "Administração pública", true),
new("70", "Herança indivisa", false),
new("71", "Pessoa colectiva não residente", true),
new("72", "Fundo de investimento", true),
new("74", "Herança indivisa", false),
new("75", "Herança indivisa", false),
new("77", "Atribuição oficiosa", false),
new("78", "Não residente (reembolso de IVA)", false),
new("79", "Regime excepcional (Expo 98)", false),
new("8", "Empresário em nome individual (extinto)", false),
new("90", "Condomínio / sociedade irregular", true),
new("91", "Condomínio / sociedade irregular", true),
new("98", "Não residente sem estabelecimento estável", true),
new("99", "Sociedade civil sem personalidade jurídica", true),
];
private const string AnySingular = "singular";
private const string Any = "qualquer";
public override string Id => "nif";
public override string Name => "NIF";
public override string Category => Categories.Identificacao;
public override string Description => "Número de Identificação Fiscal (contribuinte).";
protected virtual IEnumerable<NifPrefix> AllowedPrefixes => Prefixes;
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("tipo", "Tipo", DefaultChoice,
[.. ExtraChoices, .. AllowedPrefixes.Select(p => (p.Prefix, $"{p.Prefix} — {p.Description}"))]),
];
protected virtual string DefaultChoice => AnySingular;
protected virtual (string, string)[] ExtraChoices =>
[
(AnySingular, "Pessoa singular (1, 2 ou 3)"),
(Any, "Qualquer"),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var choice = context.Option(Options[0]);
var prefix = choice switch
{
AnySingular => context.Pick(["1", "2", "3"]),
Any => context.Pick(AllowedPrefixes.ToArray()).Prefix,
_ => choice,
};
return new GeneratedItem(Build(prefix, context));
}
public static string Build(string prefix, GeneratorContext context)
{
var body = prefix + context.Digits(8 - prefix.Length);
return body + Checksums.Mod11(body);
}
public override ValidationResult Validate(string value)
{
var nif = Checksums.Clean(value);
if (nif.StartsWith("PT", StringComparison.OrdinalIgnoreCase))
nif = nif[2..];
if (nif.Length != 9 || !Checksums.AllDigits(nif))
return ValidationResult.WrongFormat("Um NIF tem 9 dígitos.");
var prefix = FindPrefix(nif);
if (prefix is null || !AllowedPrefixes.Contains(prefix))
return ValidationResult.WrongFormat($"Prefixo '{nif[..2]}' não corresponde a um {Name} válido.");
var expected = Checksums.Mod11(nif.AsSpan(0, 8));
var details = new List<string>
{
$"Prefixo {prefix.Prefix}: {prefix.Description}",
$"Dígito de controlo esperado: {expected} (módulo 11, pesos 9..2)",
};
return expected == nif[8] - '0'
? ValidationResult.Valid(nif, $"{Name} válido — {prefix.Description}.", details)
: ValidationResult.BadChecksum($"Dígito de controlo inválido: esperado {expected}, encontrado {nif[8]}.", details);
}
public static NifPrefix? FindPrefix(string nif) =>
Prefixes.FirstOrDefault(p => p.Prefix.Length == 2 && nif.StartsWith(p.Prefix))
?? Prefixes.FirstOrDefault(p => p.Prefix.Length == 1 && nif.StartsWith(p.Prefix));
}
/// <summary>NIPC: NIF de pessoas colectivas. Mesmo algoritmo, prefixos restritos.</summary>
public sealed class NipcGenerator : NifGenerator
{
public override string Id => "nipc";
public override string Name => "NIPC";
public override string Description => "Número de Identificação de Pessoa Colectiva (empresas e entidades).";
protected override IEnumerable<NifPrefix> AllowedPrefixes => Prefixes.Where(p => p.Colectiva);
protected override string DefaultChoice => "5";
protected override (string, string)[] ExtraChoices => [("qualquer", "Qualquer")];
}

View File

@@ -0,0 +1,57 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Identificacao;
/// <summary>
/// Número de Identificação da Segurança Social. 11 dígitos, começa por 1 (singular) ou 2 (colectiva).
/// Controlo: 9 - (Σ dígito × peso) mod 10, pesos 29, 23, 19, 17, 13, 11, 7, 5, 3, 2.
/// </summary>
public sealed class NissGenerator : GeneratorBase
{
private static readonly int[] Weights = [29, 23, 19, 17, 13, 11, 7, 5, 3, 2];
public override string Id => "niss";
public override string Name => "NISS";
public override string Category => Categories.Identificacao;
public override string Description => "Número de Identificação da Segurança Social.";
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("tipo", "Tipo", "1", ("1", "1 — Pessoa singular"), ("2", "2 — Pessoa colectiva")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var body = context.Option(Options[0]) + context.Digits(9);
return new GeneratedItem(body + CheckDigit(body));
}
public static int CheckDigit(ReadOnlySpan<char> first10)
{
var sum = 0;
for (var i = 0; i < 10; i++)
sum += (first10[i] - '0') * Weights[i];
return 9 - sum % 10;
}
public override ValidationResult Validate(string value)
{
var niss = Checksums.Clean(value);
if (niss.Length != 11 || !Checksums.AllDigits(niss))
return ValidationResult.WrongFormat("Um NISS tem 11 dígitos.");
if (niss[0] is not ('1' or '2'))
return ValidationResult.WrongFormat("Um NISS começa por 1 (singular) ou 2 (colectiva).");
var expected = CheckDigit(niss);
var tipo = niss[0] == '1' ? "pessoa singular" : "pessoa colectiva";
var details = new List<string>
{
$"Tipo: {tipo}",
$"Dígito de controlo esperado: {expected}",
};
return expected == niss[10] - '0'
? ValidationResult.Valid(niss, $"NISS válido — {tipo}.", details)
: ValidationResult.BadChecksum($"Dígito de controlo inválido: esperado {expected}, encontrado {niss[10]}.", details);
}
}

View File

@@ -0,0 +1,31 @@
using System.Text.RegularExpressions;
using GeradoresService.Core;
namespace GeradoresService.Generators.Identificacao;
/// <summary>
/// Número de passaporte português: uma letra seguida de 6 dígitos.
/// Não existe dígito de controlo público no número em si; valida-se apenas o formato.
/// </summary>
public sealed partial class PassaporteGenerator : GeneratorBase
{
public override string Id => "passaporte";
public override string Name => "Passaporte";
public override string Category => Categories.Identificacao;
public override string Description => "Número de passaporte português (letra + 6 dígitos). Apenas formato.";
public override bool HasChecksum => false;
public override GeneratedItem Generate(GeneratorContext context) =>
new($"{(char)('A' + context.Random.Next(26))}{context.Digits(6)}");
public override ValidationResult Validate(string value)
{
var p = Checksums.Clean(value).ToUpperInvariant();
return Pattern().IsMatch(p)
? ValidationResult.Valid(p, "Formato de passaporte português válido (sem dígito de controlo).")
: ValidationResult.WrongFormat("Formato esperado: uma letra seguida de 6 dígitos (ex.: C123456).");
}
[GeneratedRegex("^[A-Z][0-9]{6}$")]
private static partial Regex Pattern();
}