start numbers implementation

This commit is contained in:
2022-12-20 15:22:02 +01:00
parent 3488366d79
commit b361ac5675
10 changed files with 610 additions and 3 deletions
@@ -9,10 +9,10 @@ public partial class RavenOperations : IRavenOperations
public virtual Task<Result<T>> Create<T>(T model, Func<T, Task<ValidationResult>> validate = null, Action<IZeroDocumentSession> onAfterStore = null) where T : ZeroIdEntity, new() => Save(model, validate, onAfterStore);
/// <inheritdoc />
public virtual Task<Result<T>> Update<T>(T model, Func<T, Task<ValidationResult>> validate = null, Action<IZeroDocumentSession> onAfterStore = null) where T : ZeroIdEntity, new() => Save(model, validate, onAfterStore);
public virtual Task<Result<T>> Update<T>(T model, Func<T, Task<ValidationResult>> validate = null, Action<IZeroDocumentSession> onAfterStore = null) where T : ZeroIdEntity, new() => Save(model, validate, onAfterStore, true);
/// <inheritdoc />
protected virtual async Task<Result<T>> Save<T>(T model, Func<T, Task<ValidationResult>> validate = null, Action<IZeroDocumentSession> onAfterStore = null) where T : ZeroIdEntity, new()
protected virtual async Task<Result<T>> Save<T>(T model, Func<T, Task<ValidationResult>> validate = null, Action<IZeroDocumentSession> onAfterStore = null, bool update = false) where T : ZeroIdEntity, new()
{
if (model == null)
{
@@ -30,10 +30,14 @@ public partial class RavenOperations : IRavenOperations
previousModel = await session.LoadAsync<T>(model.Id);
}
if (previousModel == null)
if (update && previousModel == null)
{
return Result<T>.Fail("@errors.onsave.noidmatch");
}
else if (!update && previousModel != null)
{
return Result<T>.Fail("@errors.oncreate.idmismatch");
}
isUpdate = true;
}
@@ -0,0 +1,18 @@
using System.Linq.Expressions;
namespace zero.Numbers;
public interface IZeroNumberStoreDbProvider
{
Task<T> Load<T>(string id, CancellationToken ct = default) where T : ZeroEntity, new();
Task<T> Find<T>(Expression<Func<T, bool>> expression, CancellationToken ct = default) where T : ZeroEntity;
Task<IList<T>> FindAll<T>(Expression<Func<T, bool>> expression, CancellationToken ct = default) where T : ZeroEntity;
Task<Result<T>> Create<T>(T model, CancellationToken ct = default) where T : ZeroEntity, new();
Task<Result<T>> Update<T>(T model, CancellationToken ct = default) where T : ZeroEntity, new();
Task<Result<T>> Delete<T>(T model, CancellationToken ct = default) where T : ZeroEntity, new();
}
+47
View File
@@ -0,0 +1,47 @@
namespace zero.Numbers;
/// <summary>
/// Configuration of number generation
/// Template can use the following placeholders:
/// {number} for the current number
/// {random} for a random number (default length is 5)
/// {random:7} for a random number with the specified length
/// {year} for the date in yyyy
/// {date} for the date in dd-mm-yyyy
/// {date:dmy} for the date in a custom format, where dmy is a nested placeholder for the format
/// </summary>
public class Number : ZeroEntity
{
/// <summary>
/// The template to build the number.
/// The number will reset to StartNumber once a new year is reached.
/// This is only applicable if the template contains the placeholder {year}.
/// Example: {year}-{number} will result in 2020-01, 2020-02, ... 2021-01, 2021-02, ...
/// </summary>
public string Template { get; set; } = "{number}";
/// <summary>
/// Where to start, lol
/// </summary>
public long StartNumber { get; set; } = 1;
/// <summary>
/// Minimum length of the number (append 0's when it is too short)
/// </summary>
public int MinLength { get; set; } = 1;
/// <summary>
/// Store current counters
/// </summary>
public List<NumberCounter> Counters { get; set; } = new();
}
public class NumberCounter
{
public string Key { get; set; }
public string Value { get; set; }
public long Count { get; set; }
}
+42
View File
@@ -0,0 +1,42 @@
using Raven.Client.Documents;
namespace zero.Commerce.Numbers;
public class NumberInterceptor : Interceptor<Number>
{
protected IZeroStore Store { get; private set; }
public NumberInterceptor(IZeroStore store)
{
Store = store;
}
/// <inheritdoc />
public override Task<InterceptorResult<Number>> Creating(InterceptorParameters args, Number model) => Saving(args, model, false);
/// <inheritdoc />
public override Task<InterceptorResult<Number>> Updating(InterceptorParameters args, Number model) => Saving(args, model, true);
protected async Task<InterceptorResult<Number>> Saving(InterceptorParameters args, Number model, bool update)
{
string flavor = model.Flavor;
string existingId = await args.Operations.Session.Query<Number>().Where(x => x.Flavor == flavor).Select(x => x.Id).FirstOrDefaultAsync();
if (!existingId.IsNullOrEmpty() && existingId != model.Id)
{
return new()
{
Result = Result<Number>.Fail("@shop.errors.number.multiplenotallowed")
};
}
model.Alias = flavor;
model.IsActive = true;
model.Name = null;
return default;
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace zero.Numbers;
public class NumberOptions : List<NumberOptionsItem>
{
}
public class NumberOptionsItem
{
public string Alias { get; set; }
public string Template { get; set; } = "{number}";
public long StartNumber { get; set; } = 1;
public int MinLength { get; set; } = 1;
}
+45
View File
@@ -0,0 +1,45 @@
using System.Text.RegularExpressions;
using zero.Numbers;
namespace zero.Numbers;
public class NumberService : INumberService
{
protected IZeroNumberStoreDbProvider Db { get; set; }
const string DEFAULT_COUNTER = "Default";
Regex templateRegex = new("{(date|number|year|random):?([a-zA-Z0-9-_]*)}", RegexOptions.IgnoreCase);
Regex containsYearRegex = new("{(date|year|date:([^}]*y+[^}]*))}", RegexOptions.IgnoreCase);
Random random = new();
public NumberService(IZeroNumberStoreDbProvider db, IZeroOptions options)
{
Db = db;
//RegisteredTypes = options.For<FlavorOptions>().GetAll<Number>().Select(x => x as NumberType);
}
}
public interface INumberService
{
/// <summary>
/// Get the next available number for the specified template
/// </summary>
Task<string> Next(string alias, DateTimeOffset? date = null, bool store = true);
/// <summary>
/// Resets the current counter for the specified template
/// </summary>
Task Reset(string alias);
/// <summary>
/// Resets all counters for the specified template
/// </summary>
Task ResetAll(string alias);
/// <summary>
/// Renders the final output from the template and the value
/// </summary>
string Compile(string alias, long value, DateTimeOffset? date = null);
}
+19
View File
@@ -0,0 +1,19 @@
using FluentValidation;
namespace zero.Numbers;
public class NumberValidator : ZeroValidator<Number>
{
public NumberValidator()
{
//if (ctx.Context == null)
//{
// throw new ArgumentNullException("ctx.Context", "NumberValidator requires access to ");
//}
RuleFor(x => x.Template).NotEmpty().Length(2, 60);
RuleFor(x => x.Template).Must(x => x != null && x.Contains("{number}")).WithMessage("@shop.number.errors.invalid_template");
RuleFor(x => x.StartNumber).GreaterThanOrEqualTo(0);
RuleFor(x => x.MinLength).ExclusiveBetween(1, 32);
}
}
+24
View File
@@ -0,0 +1,24 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace zero.Commerce.Numbers;
internal class ZeroCommerceNumberModule : ZeroModule
{
public override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
services.AddScoped<INumberStore, NumberStore>();
services.AddScoped<INumberService, NumberService>();
services.AddSingleton<IPermissionProvider, NumberPermissions>();
services.AddScoped<IInterceptor, NumberInterceptor>();
services.AddScoped<IValidator<Number>, NumberValidator>();
services.Configure<FlavorOptions>(opts =>
{
opts.Configure<Number>(cfg =>
{
cfg.CanUseWithoutFlavors = false;
});
});
}
}
+12
View File
@@ -0,0 +1,12 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace zero.Numbers;
internal class ZeroNumberModule : ZeroModule
{
public override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<NumberOptions>().Bind(configuration.GetSection("Zero:Numbers"));
}
}
+379
View File
@@ -0,0 +1,379 @@
using Raven.Client.Documents;
using System.Text.RegularExpressions;
namespace zero.Commerce.Numbers;
public class _NumberService : I_NumberService
{
/// <inheritdoc />
public IEnumerable<NumberType> RegisteredTypes { get; private set; }
protected INumberStore Store { get; set; }
const string DEFAULT_COUNTER = "Default";
Regex templateRegex = new("{(date|number|year|random):?([a-zA-Z0-9-_]*)}", RegexOptions.IgnoreCase);
Regex containsYearRegex = new("{(date|year|date:([^}]*y+[^}]*))}", RegexOptions.IgnoreCase);
Random random = new();
public _NumberService(INumberStore store, IZeroOptions options)
{
Store = store;
RegisteredTypes = options.For<FlavorOptions>().GetAll<Number>().Select(x => x as NumberType);
}
/// <inheritdoc />
public async Task<Number> Get(string alias)
{
NumberType type = RegisteredTypes.FirstOrDefault(x => x.Alias.Equals(alias, StringComparison.InvariantCultureIgnoreCase));
return await Load(type, () => type.Construct(type) as Number);
}
/// <inheritdoc />
public async Task<T> Get<T>(string alias = null) where T : Number, new()
{
NumberType type = RegisteredTypes.FirstOrDefault(x => x.FlavorType == typeof(T) && (alias.IsNullOrEmpty() || x.Alias.Equals(alias, StringComparison.InvariantCultureIgnoreCase)));
return await Load<T>(type);
}
/// <inheritdoc />
public async Task<Paged<NumberCounter>> GetCounters(string alias) => await GetCounters(await Get(alias));
/// <inheritdoc />
public async Task<Paged<NumberCounter>> GetCounters<T>(string alias = null) where T : Number, new() => await GetCounters(await Get<T>(alias));
protected async Task<Paged<NumberCounter>> GetCounters(Number number)
{
if (number == null || number.Hash.IsNullOrEmpty())
{
return new(0, 1, 1);
}
List<NumberCounter> items = new();
Dictionary<string, long?> counters = await Store.Session.CountersFor(number).GetAllAsync();
foreach (var counter in counters)
{
long value = counter.Value ?? 0;
int.TryParse(counter.Key, out int year);
bool isPassed = year > 0 && year < DateTimeOffset.Now.Year;
items.Add(new()
{
Count = value,
Key = counter.Key,
Value = isPassed ? "@shop.number.fields.counterlist.yearpassed" : Compile(number, value + 1)
});
}
return new(items, items.Count, 1, items.Count);
}
/// <inheritdoc />
public async Task<Paged<Number>> GetAll(ListQuery<Number> query = default)
{
query ??= new();
List<Number> result = new();
List<Number> numbers = await Store.LoadAll();
foreach (NumberType type in RegisteredTypes)
{
Number number = numbers.FirstOrDefault(x => x.Flavor == type.Alias);
if (number == null)
{
number = type.Construct(type) as Number;
number.Flavor = type.Alias;
}
number.Name = type.Name;
number.IsActive = true;
result.Add(number);
}
return new(result.Paging(query.Page, query.PageSize).ToList(), result.Count, query.Page, query.PageSize);
}
/// <inheritdoc />
public async Task<string> Next<T>(string alias = null, DateTimeOffset? date = null) where T : Number, new()
{
T preset = await Get<T>(alias);
if (!IsStored(preset))
{
NumberType type = RegisteredTypes.FirstOrDefault(x => x.FlavorType == typeof(T) && (alias.IsNullOrEmpty() || x.Alias.Equals(alias, StringComparison.InvariantCultureIgnoreCase)));
preset = await Create<T>(type);
}
string counterName = GetCounterName(preset);
// get a matching counter
var counters = Store.Session.CountersFor(preset);
long? storedCount = await counters.GetAsync(counterName);
// calculate new value
bool hasValue = storedCount.HasValue;
long oldValue = hasValue ? storedCount.Value : preset.StartNumber;
long incrementBy = hasValue ? 1 : 0;
long newValue = oldValue + incrementBy;
// increment the value and store it in database
counters.Increment(counterName, hasValue ? incrementBy : newValue);
await Store.Session.SaveChangesAsync();
// compiles the template and returns the rendered result
return Compile(preset, newValue, date);
}
/// <inheritdoc />
public async Task Reset(string alias) => await Reset(await Get(alias));
/// <inheritdoc />
public async Task Reset<T>(string alias = null) where T : Number, new() => await Reset(await Get<T>(alias));
/// <inheritdoc />
protected async Task Reset(Number preset)
{
if (!IsStored(preset))
{
return;
}
string counterName = GetCounterName(preset);
// get a matching counter
var counters = Store.Session.CountersFor(preset);
// delete the counter
counters.Delete(counterName);
await Store.Session.SaveChangesAsync();
}
/// <inheritdoc />
public async Task<Result<Number>> Update(Number preset)
{
return IsStored(preset) ? await Store.Update(preset) : await Store.Create(preset);
}
/// <inheritdoc />
public async Task Delete(string alias)
{
Number preset = await Get(alias);
if (IsStored(preset))
{
await Store.Delete(preset);
}
}
/// <inheritdoc />
public async Task Delete<T>(string alias = null) where T : Number, new()
{
T preset = await Get<T>(alias);
if (IsStored(preset))
{
await Store.Delete(preset);
}
}
/// <inheritdoc />
public string Compile<T>(T number, long value, DateTimeOffset? date = null) where T : Number
{
string output = number.Template;
MatchCollection matches = templateRegex.Matches(output);
DateTimeOffset usedDate = date ?? DateTimeOffset.Now;
foreach (Match match in matches)
{
if (!match.Success)
{
continue;
}
string original = match.Value;
string type = match.Groups[1].Value;
string options = match.Groups[2].Value;
string result = String.Empty;
if (type == "year")
{
result = usedDate.Year.ToString();
}
else if (type == "date")
{
result = usedDate.ToString(options.IsNullOrWhiteSpace() ? "dd-MM-yyyy" : options);
}
else if (type == "number")
{
int minLength = number.MinLength > 0 && number.MinLength <= 16 ? number.MinLength : 1;
result = value.ToString("D" + minLength);
}
else if (type == "random")
{
int length = 5;
if (!options.IsNullOrWhiteSpace() && Int32.TryParse(options, out int oLength) && oLength > 0 && oLength <= 10)
{
length = oLength;
}
result = random.Next(0, Int32.MaxValue).ToString("D10").Substring(0, length);
}
output = output.ReplaceFirst(original, result);
}
return output;
}
/// <summary>
/// Get integration data from database
/// </summary>
protected async Task<T> Load<T>(NumberType type, Func<T> createInstance = null) where T : Number, new()
{
if (type == null)
{
return default;
}
T entity = await Store.Session.Query<T>().FirstOrDefaultAsync(x => x.Flavor == type.Alias && x.IsActive);
if (entity == null)
{
entity = createInstance != null ? createInstance() : new T();
}
entity.Name = type.Name;
entity.IsActive = true;
entity.Flavor = type.Alias;
return entity;
}
/// <summary>
/// Saves a new number to the database
/// </summary>
protected async Task<T> Create<T>(NumberType type, Action<T> modify = null) where T : Number, new()
{
T entity = new();
entity.Flavor = type.Alias;
entity.Name = null;
entity.IsActive = true;
modify?.Invoke(entity);
Result<Number> result = await Store.Create(entity);
if (!result.IsSuccess)
{
throw new Exception("Could not store number in database"); // TODO add details for logging
}
return (T)result.Model;
}
/// <summary>
/// Get the name for the current counter
/// </summary>
protected virtual string GetCounterName<T>(T number) where T : Number
{
if (!number.ResetNumberPerYear || !containsYearRegex.IsMatch(number.Template))
{
return DEFAULT_COUNTER;
}
return DateTimeOffset.Now.Year.ToString();
}
/// <summary>
/// Determines if this number comes from the database
/// </summary>
protected virtual bool IsStored<T>(T number) where T : Number
{
return number != null && !number.Hash.IsNullOrEmpty();
}
}
public interface I_NumberService
{
/// <summary>
/// Get a number config by an alias
/// </summary>
Task<Number> Get(string alias);
/// <summary>
/// Get the number config of a certain type with an optional alias
/// </summary>
Task<T> Get<T>(string alias = null) where T : Number, new();
/// <summary>
/// Get all counters for a number
/// </summary>
Task<Paged<NumberCounter>> GetCounters(string alias);
/// <summary>
/// Get all counters for a number
/// </summary>
Task<Paged<NumberCounter>> GetCounters<T>(string alias = null) where T : Number, new();
/// <summary>
/// Get all numbers
/// </summary>
Task<Paged<Number>> GetAll(ListQuery<Number> query = default);
/// <summary>
/// Get the next available number for the specified template
/// </summary>
Task<string> Next<T>(string alias = null, DateTimeOffset? date = null) where T : Number, new();
/// <summary>
/// Resets the current counter for the specified template
/// </summary>
Task Reset(string alias);
/// <summary>
/// Resets the current counter for the specified template
/// </summary>
Task Reset<T>(string alias = null) where T : Number, new();
/// <summary>
/// Updates number properties
/// </summary>
Task<Result<Number>> Update(Number preset);
/// <summary>
/// Resets the whole number configuration including changes and all attached counters
/// </summary>
Task Delete(string alias);
/// <summary>
/// Resets the whole number configuration including changes and all attached counters
/// </summary>
Task Delete<T>(string alias = null) where T : Number, new();
/// <summary>
/// Renders the final output from the template and the value
/// </summary>
string Compile<T>(T number, long value, DateTimeOffset? date = null) where T : Number;
}