diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f285ab..a7c3209c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,18 @@ * [PostIt] Une page d'historique des commandes billing permet maintenant d'ouvrir une commande existante. * [PostIt] Une vue "Demandes en cours" en lecture seule est disponible pour le performer, filtrée sur les statuts actifs (Inserted, Accepted, InProgress). +* [Yavsc.Org] Nouvelles entités `Country` et `PerformerCodeInputValidation` pour piloter la validation du code entreprise performer par pays. ### Changed * [PostIt] La page détail billing se préremplit depuis une commande existante (Rdv, Brush, MBrush) et passe en mode mise à jour. +* [Yavsc.Org] Le formulaire `Manage/SetActivity` inclut désormais le pays d'exercice (`fr`, `en`, `pt`) et applique la regex associée au champ `SIREN`. +* [Yavsc.Org] La vérification externe du numéro d'entreprise est conservée uniquement pour le pays `fr`. ### Fixed * [PostIt] Le flux historique n'est plus limité à une simple liste: l'action d'ouverture charge la commande cible puis navigue vers la page détail. +* [Yavsc.Org] Le champ `SIREN` n'est plus validé avec une règle unique indépendante du pays d'exercice. ## [1.0.8-rc9] - unstable diff --git a/src/Yavsc.Abstract/Workflow/Country.cs b/src/Yavsc.Abstract/Workflow/Country.cs new file mode 100644 index 00000000..d782d0aa --- /dev/null +++ b/src/Yavsc.Abstract/Workflow/Country.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace Yavsc.Models.Workflow +{ + /// + /// Supported country of exercise for performer profile validations. + /// + public class Country + { + [Key] + [MaxLength(2)] + [MinLength(2)] + public string Code { get; set; } = string.Empty; + + [Required] + [MaxLength(64)] + public string DisplayName { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/src/Yavsc.Abstract/Workflow/IPerformerProfile.cs b/src/Yavsc.Abstract/Workflow/IPerformerProfile.cs index e53583a5..4314dd37 100644 --- a/src/Yavsc.Abstract/Workflow/IPerformerProfile.cs +++ b/src/Yavsc.Abstract/Workflow/IPerformerProfile.cs @@ -3,6 +3,7 @@ namespace Yavsc.Workflow public interface IPerformerProfile { string PerformerId { get; set; } + string ExerciseCountryCode { get; set; } string SIREN { get; set; } bool AcceptNotifications { get; set; } long OrganizationAddressId { get; set; } diff --git a/src/Yavsc.Abstract/Workflow/PerformerCodeInputValidation.cs b/src/Yavsc.Abstract/Workflow/PerformerCodeInputValidation.cs new file mode 100644 index 00000000..84e9c1cc --- /dev/null +++ b/src/Yavsc.Abstract/Workflow/PerformerCodeInputValidation.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Yavsc.Models.Workflow +{ + /// + /// Validation rule for performer business code input by country. + /// + public class PerformerCodeInputValidation + { + [Key] + public long Id { get; set; } + + [Required] + [MaxLength(2)] + [MinLength(2)] + public string CountryCode { get; set; } = string.Empty; + + [Required] + [MaxLength(256)] + public string RegularExpression { get; set; } = string.Empty; + + [Required] + [MaxLength(128)] + public string ErrorMessage { get; set; } = string.Empty; + + [ForeignKey(nameof(CountryCode))] + public Country Country { get; set; } + } + + public static class PerformerCodeInputValidationCatalog + { + public static readonly IReadOnlyList Countries = new List + { + new() { Code = "fr", DisplayName = "France" }, + new() { Code = "en", DisplayName = "England" }, + new() { Code = "pt", DisplayName = "Portugal" }, + }; + + public static readonly IReadOnlyList Rules = new List + { + new() + { + Id = 1, + CountryCode = "fr", + RegularExpression = "^[0-9]{9,14}$", + ErrorMessage = "Le code FR doit contenir entre 9 et 14 chiffres." + }, + new() + { + Id = 2, + CountryCode = "en", + RegularExpression = "^[A-Za-z0-9]{8,14}$", + ErrorMessage = "Le code EN doit contenir entre 8 et 14 caracteres alphanumeriques." + }, + new() + { + Id = 3, + CountryCode = "pt", + RegularExpression = "^[0-9]{9}$", + ErrorMessage = "Le code PT doit contenir exactement 9 chiffres." + }, + }; + + public static string NormalizeCountryCode(string? countryCode) + { + return (countryCode ?? string.Empty).Trim().ToLowerInvariant(); + } + + public static PerformerCodeInputValidation? GetRule(string? countryCode) + { + var normalized = NormalizeCountryCode(countryCode); + foreach (var rule in Rules) + { + if (string.Equals(rule.CountryCode, normalized, StringComparison.Ordinal)) + { + return rule; + } + } + + return null; + } + } +} \ No newline at end of file diff --git a/src/Yavsc.Org.Tests/NonRegression/PerformerCodeInputValidationTests.cs b/src/Yavsc.Org.Tests/NonRegression/PerformerCodeInputValidationTests.cs new file mode 100644 index 00000000..c47bdff5 --- /dev/null +++ b/src/Yavsc.Org.Tests/NonRegression/PerformerCodeInputValidationTests.cs @@ -0,0 +1,76 @@ +using System.ComponentModel.DataAnnotations; +using Yavsc.Models.Relationship; +using Yavsc.Models.Workflow; + +namespace Yavsc.Tests.NonRegression; + +public class PerformerCodeInputValidationTests +{ + [Fact] + public void Validate_rejects_unknown_country_code() + { + var profile = CreateBaseProfile(); + profile.ExerciseCountryCode = "de"; + profile.SIREN = "123456789"; + + var results = Validate(profile); + + Assert.Contains(results, r => r.MemberNames.Contains(nameof(PerformerProfile.ExerciseCountryCode))); + } + + [Fact] + public void Validate_rejects_code_not_matching_country_rule() + { + var profile = CreateBaseProfile(); + profile.ExerciseCountryCode = "pt"; + profile.SIREN = "ABC123"; + + var results = Validate(profile); + + Assert.Contains(results, r => r.MemberNames.Contains(nameof(PerformerProfile.SIREN))); + } + + [Fact] + public void Validate_accepts_country_specific_valid_codes() + { + var fr = CreateBaseProfile(); + fr.ExerciseCountryCode = "fr"; + fr.SIREN = "123456789"; + + var en = CreateBaseProfile(); + en.ExerciseCountryCode = "en"; + en.SIREN = "AB12CD34"; + + var pt = CreateBaseProfile(); + pt.ExerciseCountryCode = "pt"; + pt.SIREN = "501964843"; + + Assert.Empty(Validate(fr)); + Assert.Empty(Validate(en)); + Assert.Empty(Validate(pt)); + } + + private static PerformerProfile CreateBaseProfile() + { + return new PerformerProfile + { + PerformerId = "perf-1", + SIREN = "123456789", + ExerciseCountryCode = "fr", + OrganizationAddress = new Location + { + Address = "1 rue du Test", + Latitude = 48.8566, + Longitude = 2.3522, + }, + }; + } + + private static List Validate(PerformerProfile profile) + { + var ctx = new ValidationContext(profile); + var results = new List(); + Validator.TryValidateObject(profile, ctx, results, validateAllProperties: true); + return results; + } +} \ No newline at end of file diff --git a/src/Yavsc.Org/Controllers/Accounting/ManageController.cs b/src/Yavsc.Org/Controllers/Accounting/ManageController.cs index 43e11cd2..2c8cbec9 100644 --- a/src/Yavsc.Org/Controllers/Accounting/ManageController.cs +++ b/src/Yavsc.Org/Controllers/Accounting/ManageController.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Localization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.Mvc.Rendering; using Yavsc.Models.Workflow; using Yavsc.Helpers; using Yavsc.Models.Relationship; @@ -544,15 +545,19 @@ namespace Yavsc.Controllers { var currentProfile = _dbContext.Performers.Include(x => x.OrganizationAddress) .First(x => x.PerformerId == uid); + currentProfile.ExerciseCountryCode = NormalizeCountryCodeOrDefault(currentProfile.ExerciseCountryCode); ViewBag.Activities = _dbContext.ActivityItems(existing.Activity); + SetExerciseCountries(currentProfile.ExerciseCountryCode); return View(currentProfile); } ViewBag.Activities = _dbContext.ActivityItems(new List()); + SetExerciseCountries("fr"); return View(new PerformerProfile { PerformerId = user.Id, Performer = user, + ExerciseCountryCode = "fr", OrganizationAddress = new Location() }); } @@ -563,28 +568,32 @@ namespace Yavsc.Controllers { var user = GetCurrentUserAsync().Result; var uid = user.Id; + model.ExerciseCountryCode = NormalizeCountryCodeOrDefault(model.ExerciseCountryCode); try { if (ModelState.IsValid) { - - var exSiren = await _dbContext.ExceptionsSIREN.FirstOrDefaultAsync( - ex => ex.SIREN == model.SIREN - ); - if (exSiren != null) + var isFrenchPerformerCode = string.Equals(model.ExerciseCountryCode, "fr", StringComparison.Ordinal); + if (isFrenchPerformerCode) { - _logger.LogInformation("Exception SIREN:" + exSiren); - } - else - { - var taskCheck = await _cchecker.CheckAsync(model.SIREN); - if (!taskCheck.success) + var exSiren = await _dbContext.ExceptionsSIREN.FirstOrDefaultAsync( + ex => ex.SIREN == model.SIREN + ); + if (exSiren != null) { - ModelState.AddModelError( - "SIREN", - _SR["Invalid company number"] + " (" + taskCheck.errorCode + ")" - ); - _logger.LogInformation($"Invalid company number: {model.SIREN}/{taskCheck.errorType}/{taskCheck.errorCode}/{taskCheck.errorMessage}" ); + _logger.LogInformation("Exception SIREN:" + exSiren); + } + else + { + var taskCheck = await _cchecker.CheckAsync(model.SIREN); + if (!taskCheck.success) + { + ModelState.AddModelError( + "SIREN", + _SR["Invalid company number"] + " (" + taskCheck.errorCode + ")" + ); + _logger.LogInformation($"Invalid company number: {model.SIREN}/{taskCheck.errorType}/{taskCheck.errorCode}/{taskCheck.errorMessage}" ); + } } } } @@ -622,10 +631,32 @@ namespace Yavsc.Controllers } ViewBag.Activities = _dbContext.ActivityItems(new List()); ViewBag.GoogleSettings = _googleSettings; + SetExerciseCountries(model.ExerciseCountryCode); model.Performer = _dbContext.Users.Single(u=>u.Id == model.PerformerId); return View(model); } + private static string NormalizeCountryCodeOrDefault(string? code) + { + var normalized = PerformerCodeInputValidationCatalog.NormalizeCountryCode(code); + if (string.IsNullOrWhiteSpace(normalized)) + { + return "fr"; + } + + return normalized; + } + + private void SetExerciseCountries(string? selectedCountryCode) + { + var selected = NormalizeCountryCodeOrDefault(selectedCountryCode); + ViewBag.ExerciseCountries = new SelectList( + PerformerCodeInputValidationCatalog.Countries, + nameof(Country.Code), + nameof(Country.DisplayName), + selected); + } + [HttpPost] public async Task UnsetActivity() { diff --git a/src/Yavsc.Org/Views/Manage/SetActivity.cshtml b/src/Yavsc.Org/Views/Manage/SetActivity.cshtml index b319faf4..d9d2e0ae 100644 --- a/src/Yavsc.Org/Views/Manage/SetActivity.cshtml +++ b/src/Yavsc.Org/Views/Manage/SetActivity.cshtml @@ -67,6 +67,15 @@ +
+ + +
+ + + +
+
diff --git a/src/Yavsc.Server/Models/ApplicationDbContext.cs b/src/Yavsc.Server/Models/ApplicationDbContext.cs index 7bb5ed18..cb08023a 100644 --- a/src/Yavsc.Server/Models/ApplicationDbContext.cs +++ b/src/Yavsc.Server/Models/ApplicationDbContext.cs @@ -109,6 +109,21 @@ namespace Yavsc.Models builder.Entity().Property(a => a.ParentCode).IsRequired(false); + builder.Entity().HasKey(c => c.Code); + builder.Entity() + .HasOne(v => v.Country) + .WithMany() + .HasForeignKey(v => v.CountryCode) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity().HasData( + PerformerCodeInputValidationCatalog.Countries + ); + + builder.Entity().HasData( + PerformerCodeInputValidationCatalog.Rules + ); + builder.Entity().Property("Id").UseIdentityAlwaysColumn(); builder.Entity().Property("Id").UseIdentityAlwaysColumn(); builder.Entity().Property("Id").UseIdentityAlwaysColumn(); @@ -262,6 +277,8 @@ namespace Yavsc.Models public DbSet HairMultiCutQueries { get; set; } public DbSet Performers { get; set; } + public DbSet Countries { get; set; } + public DbSet PerformerCodeInputValidations { get; set; } public DbSet Estimates { get; set; } public DbSet Signatures { get; set; } diff --git a/src/Yavsc.Server/Models/Workflow/PerformerProfile.cs b/src/Yavsc.Server/Models/Workflow/PerformerProfile.cs index 7067bc13..80622ac8 100644 --- a/src/Yavsc.Server/Models/Workflow/PerformerProfile.cs +++ b/src/Yavsc.Server/Models/Workflow/PerformerProfile.cs @@ -4,12 +4,14 @@ using System.ComponentModel.DataAnnotations.Schema; namespace Yavsc.Models.Workflow { using System; + using System.Collections.Generic; + using System.Text.RegularExpressions; using Models.Relationship; using Newtonsoft.Json; using Yavsc.Attributes.Validation; using Yavsc.Workflow; - public class PerformerProfile : IPerformerProfile { + public class PerformerProfile : IPerformerProfile, IValidatableObject { [Key] public string PerformerId { get; set; } @@ -20,8 +22,11 @@ namespace Yavsc.Models.Workflow [Display(Name="Activity"), JsonIgnore] public virtual List Activity { get; set; } - [Required,YaStringLength(14),Display(Name="SIREN"), - RegularExpression(@"^[0-9]{9,14}$", ErrorMessage = "Only numbers are allowed here")] + [Required, Display(Name = "Country of exercise")] + [MinLength(2), MaxLength(2)] + public string ExerciseCountryCode { get; set; } = "fr"; + + [Required,YaStringLength(14),Display(Name="SIREN")] public string SIREN { get; set; } public long OrganizationAddressId { get; set; } @@ -60,5 +65,40 @@ namespace Yavsc.Models.Workflow return Performer?.Posts?.Count > 0 ; } } + public IEnumerable Validate(ValidationContext validationContext) + { + var countryCode = PerformerCodeInputValidationCatalog.NormalizeCountryCode(ExerciseCountryCode); + + if (string.IsNullOrWhiteSpace(countryCode)) + { + yield return new ValidationResult( + "Le pays d'exercice est requis.", + new[] { nameof(ExerciseCountryCode) }); + yield break; + } + + var rule = PerformerCodeInputValidationCatalog.GetRule(countryCode); + if (rule is null) + { + yield return new ValidationResult( + "Le pays d'exercice doit etre l'un des suivants: fr, en, pt.", + new[] { nameof(ExerciseCountryCode) }); + yield break; + } + + if (string.IsNullOrWhiteSpace(SIREN)) + { + yield break; + } + + var normalizedCode = SIREN.Trim(); + if (!Regex.IsMatch(normalizedCode, rule.RegularExpression, RegexOptions.CultureInvariant)) + { + yield return new ValidationResult( + rule.ErrorMessage, + new[] { nameof(SIREN) }); + } + } + } }