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,44 @@
using System.Text.RegularExpressions;
using GeradoresService.Core;
using GeradoresService.Data;
namespace GeradoresService.Generators.Contactos;
/// <summary>Código postal português (NNNN-NNN). Apenas formato.</summary>
public sealed partial class CodigoPostalGenerator : GeneratorBase
{
public override string Id => "codigo-postal";
public override string Name => "Código postal";
public override string Category => Categories.Contactos;
public override string Description => "Código postal português (CP4-CP3) com localidade.";
public override bool HasChecksum => false;
public override GeneratedItem Generate(GeneratorContext context)
{
var (cp, locality, district) = Random(context);
return new GeneratedItem(cp, new Dictionary<string, string>
{
["Código postal"] = cp,
["Localidade"] = locality,
["Distrito"] = district,
});
}
public static (string Cp, string Locality, string District) Random(GeneratorContext context)
{
var l = context.Pick(PtData.Localities);
return ($"{l.Cp4}-{1 + context.Random.Next(999):D3}", l.Locality, l.District);
}
public override ValidationResult Validate(string value)
{
var m = Pattern().Match(value.Trim());
if (!m.Success)
return ValidationResult.WrongFormat("Formato esperado: NNNN-NNN (ex.: 1000-001).");
var cp = $"{m.Groups[1].Value}-{m.Groups[2].Value}";
return ValidationResult.Valid(cp, "Formato de código postal válido.");
}
[GeneratedRegex(@"^([1-9]\d{3})[-\s]?(\d{3})$")]
private static partial Regex Pattern();
}

View File

@@ -0,0 +1,56 @@
using System.Globalization;
using System.Net.Mail;
using System.Text;
using GeradoresService.Core;
using GeradoresService.Data;
namespace GeradoresService.Generators.Contactos;
public sealed class EmailGenerator : GeneratorBase
{
public override string Id => "email";
public override string Name => "Email";
public override string Category => Categories.Contactos;
public override string Description => "Endereço de email fictício. Por omissão usa domínios reservados para testes (example.com).";
public override bool HasChecksum => false;
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("dominio", "Domínio", "teste",
("teste", "Reservado para testes (example.com)"),
("realista", "Realista (gmail.com, sapo.pt…)")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var first = context.Random.Next(2) == 0 ? context.Pick(PtData.MaleNames) : context.Pick(PtData.FemaleNames);
return new GeneratedItem(Build(first, context.Pick(PtData.Surnames), context, context.Option(Options[0]) == "realista"));
}
public static string Build(string firstName, string surname, GeneratorContext context, bool realistic = false)
{
var domains = realistic ? PtData.RealisticEmailDomains : PtData.SafeEmailDomains;
var separator = context.Pick([".", "_", ""]);
var suffix = context.Random.Next(3) == 0 ? context.Random.Next(1, 100).ToString() : "";
return $"{Slug(firstName)}{separator}{Slug(surname)}{suffix}@{context.Pick(domains)}";
}
private static string Slug(string s)
{
var sb = new StringBuilder();
foreach (var c in s.Normalize(NormalizationForm.FormD))
if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark && char.IsLetterOrDigit(c))
sb.Append(char.ToLowerInvariant(c));
return sb.ToString();
}
public override ValidationResult Validate(string value)
{
var v = value.Trim();
var at = v.LastIndexOf('@');
if (at <= 0 || at == v.Length - 1 || !v[(at + 1)..].Contains('.') || v.Any(char.IsWhiteSpace)
|| !MailAddress.TryCreate(v, out var address) || address.Address != v)
return ValidationResult.WrongFormat("Não é um endereço de email válido.");
return ValidationResult.Valid(v.ToLowerInvariant(), "Formato de email válido.");
}
}

View File

@@ -0,0 +1,69 @@
using GeradoresService.Core;
using GeradoresService.Data;
using GeradoresService.Generators.Financeiro;
using GeradoresService.Generators.Identificacao;
namespace GeradoresService.Generators.Contactos;
/// <summary>Pessoa fictícia completa, com todos os identificadores válidos.</summary>
public sealed class PessoaGenerator : GeneratorBase
{
public override string Id => "pessoa";
public override string Name => "Pessoa fictícia";
public override string Category => Categories.Contactos;
public override string Description => "Nome, data de nascimento, NIF, NISS, CC, contactos, morada e IBAN — tudo consistente e válido.";
public override bool CanValidate => false;
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("genero", "Género", "qualquer", ("qualquer", "Qualquer"), ("f", "Feminino"), ("m", "Masculino")),
Option("idade", "Idade", "adulto", ("adulto", "Adulto (18–80)"), ("menor", "Menor (0–17)"), ("qualquer", "Qualquer (0–90)")),
];
public override ValidationResult Validate(string value) =>
ValidationResult.WrongFormat("Este gerador não tem validação.");
public override GeneratedItem Generate(GeneratorContext context)
{
var genero = context.Option(Options[0]);
if (genero == "qualquer")
genero = context.Random.Next(2) == 0 ? "f" : "m";
var names = genero == "f" ? PtData.FemaleNames : PtData.MaleNames;
var first = context.Pick(names);
var middle = context.Random.Next(3) == 0 ? " " + context.Pick(names.Where(n => n != first).ToArray()) : "";
var surname1 = context.Pick(PtData.Surnames);
var surname2 = context.Pick(PtData.Surnames.Where(s => s != surname1).ToArray());
var fullName = $"{first}{middle} {surname1} {surname2}";
var (minAge, maxAge) = context.Option(Options[1]) switch
{
"menor" => (0, 17),
"qualquer" => (0, 90),
_ => (18, 80),
};
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var birth = today.AddYears(-maxAge - 1).AddDays(1 + context.Random.Next((maxAge - minAge + 1) * 365));
var (cp, locality, _) = CodigoPostalGenerator.Random(context);
var street = $"{context.Pick(PtData.StreetTypes)} {context.Pick(PtData.StreetNames)}, {1 + context.Random.Next(300)}";
var nibContext = new GeneratorContext(context.Random, new Dictionary<string, string?>());
var fields = new Dictionary<string, string>
{
["Nome"] = fullName,
["Género"] = genero == "f" ? "Feminino" : "Masculino",
["Data de nascimento"] = birth.ToString("yyyy-MM-dd"),
["NIF"] = NifGenerator.Build(context.Pick(["1", "2", "3"]), context),
["NISS"] = new NissGenerator().Generate(nibContext).Value,
["Cartão de Cidadão"] = new CartaoCidadaoGenerator().Generate(nibContext).Value,
["Telemóvel"] = new TelefoneGenerator().Generate(nibContext).Value,
["Email"] = EmailGenerator.Build(first, surname2, context),
["Morada"] = street,
["Código postal"] = cp,
["Localidade"] = locality,
["IBAN"] = new IbanGenerator().Generate(nibContext).Value,
};
return new GeneratedItem(fullName, fields);
}
}

View File

@@ -0,0 +1,67 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Contactos;
/// <summary>Números de telefone portugueses (Plano Nacional de Numeração). Apenas formato.</summary>
public sealed class TelefoneGenerator : GeneratorBase
{
private static readonly string[] MobilePrefixes = ["91", "92", "93", "96"];
public override string Id => "telefone";
public override string Name => "Telefone";
public override string Category => Categories.Contactos;
public override string Description => "Telemóvel ou telefone fixo português.";
public override bool HasChecksum => false;
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("tipo", "Tipo", "movel", ("movel", "Telemóvel"), ("fixo", "Fixo"), ("qualquer", "Qualquer")),
Option("formato", "Formato", "nacional",
("nacional", "912 345 678"), ("internacional", "+351 912 345 678"), ("compacto", "912345678")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var tipo = context.Option(Options[0]);
if (tipo == "qualquer")
tipo = context.Random.Next(2) == 0 ? "movel" : "fixo";
var number = tipo == "movel"
? context.Pick(MobilePrefixes) + context.Digits(7)
: "2" + (1 + context.Random.Next(9)) + context.Digits(7);
return new GeneratedItem(Format(number, context.Option(Options[1])));
}
private static string Format(string n, string formato) => formato switch
{
"compacto" => n,
"internacional" => $"+351 {n[..3]} {n[3..6]} {n[6..]}",
_ => $"{n[..3]} {n[3..6]} {n[6..]}",
};
public override ValidationResult Validate(string value)
{
var n = Checksums.Clean(value).Replace("(", "").Replace(")", "");
if (n.StartsWith("+351")) n = n[4..];
else if (n.StartsWith("00351")) n = n[5..];
if (n.Length != 9 || !Checksums.AllDigits(n))
return ValidationResult.WrongFormat("Um número português tem 9 dígitos (opcionalmente com +351).");
var tipo = n switch
{
['9', '1' or '2' or '3' or '6', ..] => "telemóvel",
['2', ..] => "fixo",
['3', '0', ..] => "VoIP nómada",
['8', '0', '0', ..] => "número verde (gratuito)",
['8', '0', '8', ..] => "número azul (custo partilhado)",
['7', '0', '7', ..] or ['7', '6', '0', ..] => "número de valor acrescentado",
_ => null,
};
return tipo is null
? ValidationResult.WrongFormat("O prefixo não corresponde a um número português conhecido.")
: ValidationResult.Valid(Format(n, "internacional"), $"Número português válido ({tipo}).", [$"Tipo: {tipo}"]);
}
}

View File

@@ -0,0 +1,82 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Financeiro;
/// <summary>
/// Números de cartão de pagamento válidos pelo algoritmo de Luhn (apenas para testes).
/// </summary>
public sealed class CartaoCreditoGenerator : GeneratorBase
{
private sealed record Brand(string Key, string Name, int Length, Func<Random, string> Prefix, Func<string, bool> Matches);
private static readonly Brand[] Brands =
[
new("visa", "Visa", 16, _ => "4", n => n.StartsWith('4') && n.Length is 13 or 16 or 19),
new("mastercard", "Mastercard", 16,
r => r.Next(2) == 0 ? (51 + r.Next(5)).ToString() : (2221 + r.Next(500)).ToString(),
n => n.Length == 16 && (InRange(n, 2, 51, 55) || InRange(n, 4, 2221, 2720))),
new("amex", "American Express", 15, r => r.Next(2) == 0 ? "34" : "37",
n => n.Length == 15 && (n.StartsWith("34") || n.StartsWith("37"))),
];
public override string Id => "cartao-credito";
public override string Name => "Cartão de crédito";
public override string Category => Categories.Financeiro;
public override string Description => "Número de cartão válido pelo algoritmo de Luhn, com validade e CVV. Apenas para testes.";
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("rede", "Rede", "visa", [.. Brands.Select(b => (b.Key, b.Name)), ("qualquer", "Qualquer")]),
Option("formato", "Formato", "espacos", ("espacos", "4111 1111 1111 1111"), ("compacto", "4111111111111111")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var key = context.Option(Options[0]);
var brand = key == "qualquer" ? context.Pick(Brands) : Brands.Single(b => b.Key == key);
var body = brand.Prefix(context.Random);
body += context.Digits(brand.Length - 1 - body.Length);
var number = body + Checksums.LuhnCheckDigit(body);
var expiry = DateTime.UtcNow.AddMonths(context.Random.Next(6, 60));
var cvv = context.Digits(brand.Key == "amex" ? 4 : 3);
var display = context.Option(Options[1]) == "compacto" ? number : Format(number);
return new GeneratedItem(display, new Dictionary<string, string>
{
["Número"] = display,
["Rede"] = brand.Name,
["Validade"] = expiry.ToString("MM/yy"),
["CVV"] = cvv,
});
}
private static string Format(string number) => number.Length == 15
? $"{number[..4]} {number[4..10]} {number[10..]}"
: string.Join(' ', number.Chunk(4).Select(c => new string(c)));
private static bool InRange(string n, int digits, int min, int max) =>
int.TryParse(n.AsSpan(0, digits), out var p) && p >= min && p <= max;
public override ValidationResult Validate(string value)
{
var number = Checksums.Clean(value);
if (number.Length is < 12 or > 19 || !Checksums.AllDigits(number))
return ValidationResult.WrongFormat("Um número de cartão tem entre 12 e 19 dígitos.");
var brand = Brands.FirstOrDefault(b => b.Matches(number));
var details = new List<string>
{
$"Rede: {brand?.Name ?? "desconhecida"}",
"Controlo: algoritmo de Luhn",
};
if (!Checksums.LuhnValid(number))
{
var expected = Checksums.LuhnCheckDigit(number[..^1]);
return ValidationResult.BadChecksum($"Falha no algoritmo de Luhn: último dígito devia ser {expected}.", details);
}
return ValidationResult.Valid(Format(number), $"Número de cartão válido{(brand is null ? "" : $" ({brand.Name})")}.", details);
}
}

View File

@@ -0,0 +1,75 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Financeiro;
/// <summary>
/// IBAN português: PT + 2 dígitos de controlo ISO 7064 (MOD 97-10) + NIB.
/// A validação aceita IBAN de qualquer país (verifica comprimento conhecido e MOD 97).
/// </summary>
public sealed class IbanGenerator : NibGenerator
{
private static readonly Dictionary<string, int> Lengths = new()
{
["PT"] = 25, ["ES"] = 24, ["FR"] = 27, ["DE"] = 22, ["IT"] = 27, ["GB"] = 22, ["IE"] = 22,
["NL"] = 18, ["BE"] = 16, ["LU"] = 20, ["CH"] = 21, ["AT"] = 20, ["DK"] = 18, ["FI"] = 18,
["SE"] = 24, ["NO"] = 15, ["PL"] = 28, ["CZ"] = 24, ["GR"] = 27, ["HR"] = 21, ["RO"] = 24,
["MT"] = 31, ["CY"] = 28, ["SK"] = 24, ["SI"] = 19, ["HU"] = 28, ["BG"] = 22, ["EE"] = 20,
["LT"] = 20, ["LV"] = 21, ["IS"] = 26, ["LI"] = 21, ["MC"] = 27, ["SM"] = 27, ["BR"] = 29,
};
public override string Id => "iban";
public override string Name => "IBAN";
public override string Description => "IBAN português (PT50 + NIB). Valida IBAN de qualquer país.";
public override IReadOnlyList<GeneratorOption> Options =>
[
.. base.Options,
Option("formato", "Formato", "espacos", ("espacos", "PT50 0000 0000 …"), ("compacto", "PT500000…")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var iban = FromBban("PT", BuildNib(context));
return new GeneratedItem(context.Option(Options[1]) == "compacto" ? iban : Format(iban));
}
public static string FromBban(string country, string bban)
{
var check = 98 - Checksums.Mod97(Checksums.LettersToDigits(bban + country) + "00");
return $"{country}{check:D2}{bban}";
}
public static string Format(string iban) =>
string.Join(' ', iban.Chunk(4).Select(c => new string(c)));
public override ValidationResult Validate(string value)
{
var iban = Checksums.Clean(value).ToUpperInvariant();
if (iban.Length < 15 || iban.Length > 34
|| !char.IsAsciiLetter(iban[0]) || !char.IsAsciiLetter(iban[1])
|| !char.IsAsciiDigit(iban[2]) || !char.IsAsciiDigit(iban[3])
|| !iban.All(char.IsAsciiLetterOrDigit))
return ValidationResult.WrongFormat("Um IBAN tem código de país (2 letras), 2 dígitos de controlo e o BBAN.");
var country = iban[..2];
if (Lengths.TryGetValue(country, out var expectedLength) && iban.Length != expectedLength)
return ValidationResult.WrongFormat($"Um IBAN {country} tem {expectedLength} caracteres (tem {iban.Length}).");
var rearranged = Checksums.LettersToDigits(iban[4..] + iban[..4]);
if (Checksums.Mod97(rearranged) != 1)
{
var expected = FromBban(country, iban[4..])[2..4];
return ValidationResult.BadChecksum(
$"Dígitos de controlo do IBAN inválidos: esperado {expected}, encontrado {iban[2..4]}.",
["Controlo: ISO 7064 MOD 97-10"]);
}
if (country == "PT")
{
var nibResult = ValidateNib(iban[4..], "IBAN");
return nibResult with { Normalized = nibResult.IsValid ? Format(iban) : null };
}
return ValidationResult.Valid(Format(iban), $"IBAN válido ({country}).", ["Controlo: ISO 7064 MOD 97-10"]);
}
}

View File

@@ -0,0 +1,75 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Financeiro;
/// <summary>
/// NIB: banco (4) + balcão (4) + conta (11) + controlo (2). Controlo = 98 - (NIB19 × 100 mod 97).
/// </summary>
public class NibGenerator : GeneratorBase
{
public static readonly IReadOnlyList<(string Code, string Name)> Banks =
[
("0007", "Novo Banco"),
("0010", "BPI"),
("0018", "Santander Totta"),
("0023", "Banco ActivoBank"),
("0033", "Millennium BCP"),
("0035", "Caixa Geral de Depósitos"),
("0036", "Banco Montepio"),
("0045", "Crédito Agrícola"),
("0079", "Banco BIC"),
("0193", "Banco CTT"),
("0269", "Bankinter"),
];
public override string Id => "nib";
public override string Name => "NIB";
public override string Category => Categories.Financeiro;
public override string Description => "Número de Identificação Bancária (21 dígitos).";
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("banco", "Banco", "qualquer",
[("qualquer", "Qualquer"), .. Banks.Select(b => (b.Code, $"{b.Code} — {b.Name}"))]),
];
public override GeneratedItem Generate(GeneratorContext context) => new(BuildNib(context));
protected string BuildNib(GeneratorContext context)
{
var bank = context.Option(Options[0]);
if (bank == "qualquer")
bank = context.Pick(Banks).Code;
var body = bank + context.Digits(4) + context.Digits(11);
return body + NibCheck(body);
}
public static string NibCheck(string first19) =>
(98 - Checksums.Mod97(first19 + "00")).ToString("D2");
public static string? BankName(string nib) =>
Banks.FirstOrDefault(b => nib.StartsWith(b.Code)).Name;
public override ValidationResult Validate(string value)
{
var nib = Checksums.Clean(value);
if (nib.Length != 21 || !Checksums.AllDigits(nib))
return ValidationResult.WrongFormat("Um NIB tem 21 dígitos.");
return ValidateNib(nib, "NIB");
}
public static ValidationResult ValidateNib(string nib, string label)
{
var expected = NibCheck(nib[..19]);
var details = new List<string>
{
$"Banco: {nib[..4]}{(BankName(nib) is { } n ? $" ({n})" : "")}",
$"Balcão: {nib[4..8]}",
$"Conta: {nib[8..19]}",
$"Dígitos de controlo esperados: {expected} (módulo 97)",
};
return nib[19..] == expected
? ValidationResult.Valid(nib, $"{label} válido.", details)
: ValidationResult.BadChecksum($"Dígitos de controlo do NIB inválidos: esperado {expected}, encontrado {nib[19..]}.", details);
}
}

View File

@@ -0,0 +1,75 @@
using System.Globalization;
using System.Text.RegularExpressions;
using GeradoresService.Core;
namespace GeradoresService.Generators.Financeiro;
/// <summary>
/// Referência Multibanco: entidade (5) + referência (9) + montante.
/// Os 2 últimos dígitos da referência são controlo: 98 - (Σ dígito × peso mod 97),
/// calculado sobre entidade(5) + referência(7) + montante em cêntimos(8).
/// </summary>
public sealed partial class ReferenciaMultibancoGenerator : GeneratorBase
{
private static readonly int[] Weights = [51, 73, 17, 89, 38, 62, 45, 53, 15, 50, 5, 49, 34, 81, 76, 27, 90, 9, 30, 3];
public override string Id => "multibanco";
public override string Name => "Referência Multibanco";
public override string Category => Categories.Financeiro;
public override string Description => "Entidade, referência e montante com dígitos de controlo válidos.";
public override GeneratedItem Generate(GeneratorContext context)
{
var entity = (10000 + context.Random.Next(90000)).ToString();
var cents = 100 + context.Random.Next(50000);
var reference = context.Digits(7);
reference += CheckDigits(entity, reference, cents);
var amount = (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
return new GeneratedItem($"{entity} {FormatReference(reference)} {amount}", new Dictionary<string, string>
{
["Entidade"] = entity,
["Referência"] = FormatReference(reference),
["Montante"] = $"{amount} €",
});
}
public static string CheckDigits(string entity, string reference7, long cents)
{
var chk = entity + reference7 + cents.ToString("D8");
var sum = 0;
for (var i = 0; i < 20; i++)
sum += (chk[i] - '0') * Weights[i];
return (98 - sum % 97).ToString("D2");
}
private static string FormatReference(string r) => $"{r[..3]} {r[3..6]} {r[6..]}";
public override ValidationResult Validate(string value)
{
var m = Pattern().Match(value.Trim());
if (!m.Success)
return ValidationResult.WrongFormat("Formato esperado: entidade (5 dígitos), referência (9 dígitos) e montante, ex.: 12345 123 456 789 10.00");
var entity = m.Groups["ent"].Value;
var reference = Checksums.Clean(m.Groups["ref"].Value);
var amount = decimal.Parse(m.Groups["val"].Value.Replace(',', '.'), CultureInfo.InvariantCulture);
var cents = (long)Math.Round(amount * 100);
if (cents > 99_999_999)
return ValidationResult.WrongFormat("Montante demasiado elevado.");
var expected = CheckDigits(entity, reference[..7], cents);
var details = new List<string>
{
$"Entidade: {entity}",
$"Montante: {amount.ToString("0.00", CultureInfo.InvariantCulture)} €",
$"Dígitos de controlo esperados: {expected}",
};
return reference[7..] == expected
? ValidationResult.Valid($"{entity} {FormatReference(reference)} {amount.ToString("0.00", CultureInfo.InvariantCulture)}", "Referência Multibanco válida.", details)
: ValidationResult.BadChecksum($"Dígitos de controlo inválidos: esperado {expected}, encontrado {reference[7..]}.", details);
}
[GeneratedRegex(@"^(?<ent>\d{5})[\s|;]+(?<ref>\d{3}\s?\d{3}\s?\d{3})[\s|;]+(?<val>\d{1,6}(?:[.,]\d{1,2})?)\s*€?$")]
private static partial Regex Pattern();
}

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();
}

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}"]);
}
}