Release 1.0.8-rc10
All checks were successful
Forgejo Release / release (push) Successful in 15m55s

This commit is contained in:
Paul Schneider 2026-08-31 04:45:01 +01:00
commit 17735eada3
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
9 changed files with 302 additions and 19 deletions

View file

@ -6,14 +6,18 @@
* [PostIt] Une page d'historique des commandes billing permet maintenant d'ouvrir une commande existante. * [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). * [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 ### Changed
* [PostIt] La page détail billing se préremplit depuis une commande existante (Rdv, Brush, MBrush) et passe en mode mise à jour. * [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 ### 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. * [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 ## [1.0.8-rc9] - unstable

View file

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace Yavsc.Models.Workflow
{
/// <summary>
/// Supported country of exercise for performer profile validations.
/// </summary>
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;
}
}

View file

@ -3,6 +3,7 @@ namespace Yavsc.Workflow
public interface IPerformerProfile public interface IPerformerProfile
{ {
string PerformerId { get; set; } string PerformerId { get; set; }
string ExerciseCountryCode { get; set; }
string SIREN { get; set; } string SIREN { get; set; }
bool AcceptNotifications { get; set; } bool AcceptNotifications { get; set; }
long OrganizationAddressId { get; set; } long OrganizationAddressId { get; set; }

View file

@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Yavsc.Models.Workflow
{
/// <summary>
/// Validation rule for performer business code input by country.
/// </summary>
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<Country> Countries = new List<Country>
{
new() { Code = "fr", DisplayName = "France" },
new() { Code = "en", DisplayName = "England" },
new() { Code = "pt", DisplayName = "Portugal" },
};
public static readonly IReadOnlyList<PerformerCodeInputValidation> Rules = new List<PerformerCodeInputValidation>
{
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;
}
}
}

View file

@ -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<ValidationResult> Validate(PerformerProfile profile)
{
var ctx = new ValidationContext(profile);
var results = new List<ValidationResult>();
Validator.TryValidateObject(profile, ctx, results, validateAllProperties: true);
return results;
}
}

View file

@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Mvc.Rendering;
using Yavsc.Models.Workflow; using Yavsc.Models.Workflow;
using Yavsc.Helpers; using Yavsc.Helpers;
using Yavsc.Models.Relationship; using Yavsc.Models.Relationship;
@ -544,15 +545,19 @@ namespace Yavsc.Controllers
{ {
var currentProfile = _dbContext.Performers.Include(x => x.OrganizationAddress) var currentProfile = _dbContext.Performers.Include(x => x.OrganizationAddress)
.First(x => x.PerformerId == uid); .First(x => x.PerformerId == uid);
currentProfile.ExerciseCountryCode = NormalizeCountryCodeOrDefault(currentProfile.ExerciseCountryCode);
ViewBag.Activities = _dbContext.ActivityItems(existing.Activity); ViewBag.Activities = _dbContext.ActivityItems(existing.Activity);
SetExerciseCountries(currentProfile.ExerciseCountryCode);
return View(currentProfile); return View(currentProfile);
} }
ViewBag.Activities = _dbContext.ActivityItems(new List<UserActivity>()); ViewBag.Activities = _dbContext.ActivityItems(new List<UserActivity>());
SetExerciseCountries("fr");
return View(new PerformerProfile return View(new PerformerProfile
{ {
PerformerId = user.Id, PerformerId = user.Id,
Performer = user, Performer = user,
ExerciseCountryCode = "fr",
OrganizationAddress = new Location() OrganizationAddress = new Location()
}); });
} }
@ -563,28 +568,32 @@ namespace Yavsc.Controllers
{ {
var user = GetCurrentUserAsync().Result; var user = GetCurrentUserAsync().Result;
var uid = user.Id; var uid = user.Id;
model.ExerciseCountryCode = NormalizeCountryCodeOrDefault(model.ExerciseCountryCode);
try try
{ {
if (ModelState.IsValid) if (ModelState.IsValid)
{ {
var isFrenchPerformerCode = string.Equals(model.ExerciseCountryCode, "fr", StringComparison.Ordinal);
var exSiren = await _dbContext.ExceptionsSIREN.FirstOrDefaultAsync( if (isFrenchPerformerCode)
ex => ex.SIREN == model.SIREN
);
if (exSiren != null)
{ {
_logger.LogInformation("Exception SIREN:" + exSiren); var exSiren = await _dbContext.ExceptionsSIREN.FirstOrDefaultAsync(
} ex => ex.SIREN == model.SIREN
else );
{ if (exSiren != null)
var taskCheck = await _cchecker.CheckAsync(model.SIREN);
if (!taskCheck.success)
{ {
ModelState.AddModelError( _logger.LogInformation("Exception SIREN:" + exSiren);
"SIREN", }
_SR["Invalid company number"] + " (" + taskCheck.errorCode + ")" else
); {
_logger.LogInformation($"Invalid company number: {model.SIREN}/{taskCheck.errorType}/{taskCheck.errorCode}/{taskCheck.errorMessage}" ); 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<UserActivity>()); ViewBag.Activities = _dbContext.ActivityItems(new List<UserActivity>());
ViewBag.GoogleSettings = _googleSettings; ViewBag.GoogleSettings = _googleSettings;
SetExerciseCountries(model.ExerciseCountryCode);
model.Performer = _dbContext.Users.Single(u=>u.Id == model.PerformerId); model.Performer = _dbContext.Users.Single(u=>u.Id == model.PerformerId);
return View(model); 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] [HttpPost]
public async Task<IActionResult> UnsetActivity() public async Task<IActionResult> UnsetActivity()
{ {

View file

@ -67,6 +67,15 @@
<span asp-validation-for="Active" class="text-danger"></span> <span asp-validation-for="Active" class="text-danger"></span>
</div> </div>
</div> </div>
<div class="form-group">
<label asp-for="ExerciseCountryCode" class="col-md-2 control-label">Pays d'exercice</label>
<div class="col-md-10">
<select asp-for="ExerciseCountryCode" asp-items="ViewBag.ExerciseCountries" class="form-control"></select>
<span asp-validation-for="ExerciseCountryCode" class="text-danger"></span>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label asp-for="SIREN" class="col-md-2 control-label">SIREN</label> <label asp-for="SIREN" class="col-md-2 control-label">SIREN</label>

View file

@ -109,6 +109,21 @@ namespace Yavsc.Models
builder.Entity<Activity>().Property(a => a.ParentCode).IsRequired(false); builder.Entity<Activity>().Property(a => a.ParentCode).IsRequired(false);
builder.Entity<Country>().HasKey(c => c.Code);
builder.Entity<PerformerCodeInputValidation>()
.HasOne(v => v.Country)
.WithMany()
.HasForeignKey(v => v.CountryCode)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<Country>().HasData(
PerformerCodeInputValidationCatalog.Countries
);
builder.Entity<PerformerCodeInputValidation>().HasData(
PerformerCodeInputValidationCatalog.Rules
);
builder.Entity<Client>().Property("Id").UseIdentityAlwaysColumn(); builder.Entity<Client>().Property("Id").UseIdentityAlwaysColumn();
builder.Entity<ClientSecret>().Property("Id").UseIdentityAlwaysColumn(); builder.Entity<ClientSecret>().Property("Id").UseIdentityAlwaysColumn();
builder.Entity<ClientScope>().Property("Id").UseIdentityAlwaysColumn(); builder.Entity<ClientScope>().Property("Id").UseIdentityAlwaysColumn();
@ -262,6 +277,8 @@ namespace Yavsc.Models
public DbSet<HairMultiCutQuery> HairMultiCutQueries { get; set; } public DbSet<HairMultiCutQuery> HairMultiCutQueries { get; set; }
public DbSet<PerformerProfile> Performers { get; set; } public DbSet<PerformerProfile> Performers { get; set; }
public DbSet<Country> Countries { get; set; }
public DbSet<PerformerCodeInputValidation> PerformerCodeInputValidations { get; set; }
public DbSet<Estimate> Estimates { get; set; } public DbSet<Estimate> Estimates { get; set; }
public DbSet<Signature> Signatures { get; set; } public DbSet<Signature> Signatures { get; set; }

View file

@ -4,12 +4,14 @@ using System.ComponentModel.DataAnnotations.Schema;
namespace Yavsc.Models.Workflow namespace Yavsc.Models.Workflow
{ {
using System; using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Models.Relationship; using Models.Relationship;
using Newtonsoft.Json; using Newtonsoft.Json;
using Yavsc.Attributes.Validation; using Yavsc.Attributes.Validation;
using Yavsc.Workflow; using Yavsc.Workflow;
public class PerformerProfile : IPerformerProfile { public class PerformerProfile : IPerformerProfile, IValidatableObject {
[Key] [Key]
public string PerformerId { get; set; } public string PerformerId { get; set; }
@ -20,8 +22,11 @@ namespace Yavsc.Models.Workflow
[Display(Name="Activity"), JsonIgnore] [Display(Name="Activity"), JsonIgnore]
public virtual List<UserActivity> Activity { get; set; } public virtual List<UserActivity> Activity { get; set; }
[Required,YaStringLength(14),Display(Name="SIREN"), [Required, Display(Name = "Country of exercise")]
RegularExpression(@"^[0-9]{9,14}$", ErrorMessage = "Only numbers are allowed here")] [MinLength(2), MaxLength(2)]
public string ExerciseCountryCode { get; set; } = "fr";
[Required,YaStringLength(14),Display(Name="SIREN")]
public string SIREN { get; set; } public string SIREN { get; set; }
public long OrganizationAddressId { get; set; } public long OrganizationAddressId { get; set; }
@ -60,5 +65,40 @@ namespace Yavsc.Models.Workflow
return Performer?.Posts?.Count > 0 ; return Performer?.Posts?.Count > 0 ;
} } } }
public IEnumerable<ValidationResult> 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) });
}
}
} }
} }