diff --git a/zero.Raven/Operations/RavenOperations.Write.cs b/zero.Raven/Operations/RavenOperations.Write.cs index 78ebe4dd..c4e32afe 100644 --- a/zero.Raven/Operations/RavenOperations.Write.cs +++ b/zero.Raven/Operations/RavenOperations.Write.cs @@ -9,10 +9,10 @@ public partial class RavenOperations : IRavenOperations public virtual Task> Create(T model, Func> validate = null, Action onAfterStore = null) where T : ZeroIdEntity, new() => Save(model, validate, onAfterStore); /// - public virtual Task> Update(T model, Func> validate = null, Action onAfterStore = null) where T : ZeroIdEntity, new() => Save(model, validate, onAfterStore); + public virtual Task> Update(T model, Func> validate = null, Action onAfterStore = null) where T : ZeroIdEntity, new() => Save(model, validate, onAfterStore, true); /// - protected virtual async Task> Save(T model, Func> validate = null, Action onAfterStore = null) where T : ZeroIdEntity, new() + protected virtual async Task> Save(T model, Func> validate = null, Action 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(model.Id); } - if (previousModel == null) + if (update && previousModel == null) { return Result.Fail("@errors.onsave.noidmatch"); } + else if (!update && previousModel != null) + { + return Result.Fail("@errors.oncreate.idmismatch"); + } isUpdate = true; } diff --git a/zero/Numbers/IZeroNumberStoreDbProvider.cs b/zero/Numbers/IZeroNumberStoreDbProvider.cs new file mode 100644 index 00000000..b717e47f --- /dev/null +++ b/zero/Numbers/IZeroNumberStoreDbProvider.cs @@ -0,0 +1,18 @@ +using System.Linq.Expressions; + +namespace zero.Numbers; + +public interface IZeroNumberStoreDbProvider +{ + Task Load(string id, CancellationToken ct = default) where T : ZeroEntity, new(); + + Task Find(Expression> expression, CancellationToken ct = default) where T : ZeroEntity; + + Task> FindAll(Expression> expression, CancellationToken ct = default) where T : ZeroEntity; + + Task> Create(T model, CancellationToken ct = default) where T : ZeroEntity, new(); + + Task> Update(T model, CancellationToken ct = default) where T : ZeroEntity, new(); + + Task> Delete(T model, CancellationToken ct = default) where T : ZeroEntity, new(); +} \ No newline at end of file diff --git a/zero/Numbers/Number.cs b/zero/Numbers/Number.cs new file mode 100644 index 00000000..f84c2837 --- /dev/null +++ b/zero/Numbers/Number.cs @@ -0,0 +1,47 @@ +namespace zero.Numbers; + +/// +/// 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 +/// +public class Number : ZeroEntity +{ + /// + /// 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, ... + /// + public string Template { get; set; } = "{number}"; + + /// + /// Where to start, lol + /// + public long StartNumber { get; set; } = 1; + + /// + /// Minimum length of the number (append 0's when it is too short) + /// + public int MinLength { get; set; } = 1; + + /// + /// Store current counters + /// + public List Counters { get; set; } = new(); +} + + +public class NumberCounter +{ + public string Key { get; set; } + + public string Value { get; set; } + + public long Count { get; set; } +} \ No newline at end of file diff --git a/zero/Numbers/NumberInterceptor.cs b/zero/Numbers/NumberInterceptor.cs new file mode 100644 index 00000000..fa7ee314 --- /dev/null +++ b/zero/Numbers/NumberInterceptor.cs @@ -0,0 +1,42 @@ +using Raven.Client.Documents; + +namespace zero.Commerce.Numbers; + +public class NumberInterceptor : Interceptor +{ + protected IZeroStore Store { get; private set; } + + + public NumberInterceptor(IZeroStore store) + { + Store = store; + } + + + /// + public override Task> Creating(InterceptorParameters args, Number model) => Saving(args, model, false); + + /// + public override Task> Updating(InterceptorParameters args, Number model) => Saving(args, model, true); + + + protected async Task> Saving(InterceptorParameters args, Number model, bool update) + { + string flavor = model.Flavor; + string existingId = await args.Operations.Session.Query().Where(x => x.Flavor == flavor).Select(x => x.Id).FirstOrDefaultAsync(); + + if (!existingId.IsNullOrEmpty() && existingId != model.Id) + { + return new() + { + Result = Result.Fail("@shop.errors.number.multiplenotallowed") + }; + } + + model.Alias = flavor; + model.IsActive = true; + model.Name = null; + + return default; + } +} \ No newline at end of file diff --git a/zero/Numbers/NumberOptions.cs b/zero/Numbers/NumberOptions.cs new file mode 100644 index 00000000..ead86c64 --- /dev/null +++ b/zero/Numbers/NumberOptions.cs @@ -0,0 +1,17 @@ +namespace zero.Numbers; + +public class NumberOptions : List +{ +} + + +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; +} diff --git a/zero/Numbers/NumberService.cs b/zero/Numbers/NumberService.cs new file mode 100644 index 00000000..925f9f6a --- /dev/null +++ b/zero/Numbers/NumberService.cs @@ -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().GetAll().Select(x => x as NumberType); + } +} + + +public interface INumberService +{ + /// + /// Get the next available number for the specified template + /// + Task Next(string alias, DateTimeOffset? date = null, bool store = true); + + /// + /// Resets the current counter for the specified template + /// + Task Reset(string alias); + + /// + /// Resets all counters for the specified template + /// + Task ResetAll(string alias); + + /// + /// Renders the final output from the template and the value + /// + string Compile(string alias, long value, DateTimeOffset? date = null); +} \ No newline at end of file diff --git a/zero/Numbers/NumberValidator.cs b/zero/Numbers/NumberValidator.cs new file mode 100644 index 00000000..9553b9ff --- /dev/null +++ b/zero/Numbers/NumberValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +namespace zero.Numbers; + +public class NumberValidator : ZeroValidator +{ + 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); + } +} \ No newline at end of file diff --git a/zero/Numbers/ZeroCommerceNumberModule.cs b/zero/Numbers/ZeroCommerceNumberModule.cs new file mode 100644 index 00000000..9515fcb9 --- /dev/null +++ b/zero/Numbers/ZeroCommerceNumberModule.cs @@ -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(); + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped, NumberValidator>(); + + services.Configure(opts => + { + opts.Configure(cfg => + { + cfg.CanUseWithoutFlavors = false; + }); + }); + } +} \ No newline at end of file diff --git a/zero/Numbers/ZeroNumberModule.cs b/zero/Numbers/ZeroNumberModule.cs new file mode 100644 index 00000000..54f9beb6 --- /dev/null +++ b/zero/Numbers/ZeroNumberModule.cs @@ -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().Bind(configuration.GetSection("Zero:Numbers")); + } +} \ No newline at end of file diff --git a/zero/Numbers/_NumberService.cs b/zero/Numbers/_NumberService.cs new file mode 100644 index 00000000..5520d4ed --- /dev/null +++ b/zero/Numbers/_NumberService.cs @@ -0,0 +1,379 @@ +using Raven.Client.Documents; +using System.Text.RegularExpressions; + +namespace zero.Commerce.Numbers; + +public class _NumberService : I_NumberService +{ + /// + public IEnumerable 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().GetAll().Select(x => x as NumberType); + } + + + /// + public async Task Get(string alias) + { + NumberType type = RegisteredTypes.FirstOrDefault(x => x.Alias.Equals(alias, StringComparison.InvariantCultureIgnoreCase)); + return await Load(type, () => type.Construct(type) as Number); + } + + + /// + public async Task Get(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(type); + } + + + /// + public async Task> GetCounters(string alias) => await GetCounters(await Get(alias)); + + + /// + public async Task> GetCounters(string alias = null) where T : Number, new() => await GetCounters(await Get(alias)); + + + protected async Task> GetCounters(Number number) + { + if (number == null || number.Hash.IsNullOrEmpty()) + { + return new(0, 1, 1); + } + + List items = new(); + Dictionary 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); + } + + + /// + public async Task> GetAll(ListQuery query = default) + { + query ??= new(); + + List result = new(); + List 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); + } + + + /// + public async Task Next(string alias = null, DateTimeOffset? date = null) where T : Number, new() + { + T preset = await Get(alias); + + if (!IsStored(preset)) + { + NumberType type = RegisteredTypes.FirstOrDefault(x => x.FlavorType == typeof(T) && (alias.IsNullOrEmpty() || x.Alias.Equals(alias, StringComparison.InvariantCultureIgnoreCase))); + preset = await Create(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); + } + + + /// + public async Task Reset(string alias) => await Reset(await Get(alias)); + + + /// + public async Task Reset(string alias = null) where T : Number, new() => await Reset(await Get(alias)); + + + /// + 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(); + } + + + /// + public async Task> Update(Number preset) + { + return IsStored(preset) ? await Store.Update(preset) : await Store.Create(preset); + } + + + /// + public async Task Delete(string alias) + { + Number preset = await Get(alias); + + if (IsStored(preset)) + { + await Store.Delete(preset); + } + } + + + /// + public async Task Delete(string alias = null) where T : Number, new() + { + T preset = await Get(alias); + + if (IsStored(preset)) + { + await Store.Delete(preset); + } + } + + + /// + public string Compile(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; + } + + + /// + /// Get integration data from database + /// + protected async Task Load(NumberType type, Func createInstance = null) where T : Number, new() + { + if (type == null) + { + return default; + } + + T entity = await Store.Session.Query().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; + } + + + /// + /// Saves a new number to the database + /// + protected async Task Create(NumberType type, Action modify = null) where T : Number, new() + { + T entity = new(); + entity.Flavor = type.Alias; + entity.Name = null; + entity.IsActive = true; + + modify?.Invoke(entity); + + Result 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; + } + + + /// + /// Get the name for the current counter + /// + protected virtual string GetCounterName(T number) where T : Number + { + if (!number.ResetNumberPerYear || !containsYearRegex.IsMatch(number.Template)) + { + return DEFAULT_COUNTER; + } + + return DateTimeOffset.Now.Year.ToString(); + } + + + /// + /// Determines if this number comes from the database + /// + protected virtual bool IsStored(T number) where T : Number + { + return number != null && !number.Hash.IsNullOrEmpty(); + } +} + + +public interface I_NumberService +{ + /// + /// Get a number config by an alias + /// + Task Get(string alias); + + /// + /// Get the number config of a certain type with an optional alias + /// + Task Get(string alias = null) where T : Number, new(); + + /// + /// Get all counters for a number + /// + Task> GetCounters(string alias); + + /// + /// Get all counters for a number + /// + Task> GetCounters(string alias = null) where T : Number, new(); + + /// + /// Get all numbers + /// + Task> GetAll(ListQuery query = default); + + /// + /// Get the next available number for the specified template + /// + Task Next(string alias = null, DateTimeOffset? date = null) where T : Number, new(); + + /// + /// Resets the current counter for the specified template + /// + Task Reset(string alias); + + /// + /// Resets the current counter for the specified template + /// + Task Reset(string alias = null) where T : Number, new(); + + /// + /// Updates number properties + /// + Task> Update(Number preset); + + /// + /// Resets the whole number configuration including changes and all attached counters + /// + Task Delete(string alias); + + /// + /// Resets the whole number configuration including changes and all attached counters + /// + Task Delete(string alias = null) where T : Number, new(); + + /// + /// Renders the final output from the template and the value + /// + string Compile(T number, long value, DateTimeOffset? date = null) where T : Number; +} \ No newline at end of file