diff --git a/Logistics.Documents.Model/DocumentUrls.cs b/Logistics.Documents.Model/DocumentUrls.cs new file mode 100644 index 0000000..8399bd6 --- /dev/null +++ b/Logistics.Documents.Model/DocumentUrls.cs @@ -0,0 +1,5 @@ +namespace Logistics.Documents; +public static class DocumentUrls +{ + public const string Receives = "/logistics/documents/receives"; +} diff --git a/Logistics.Documents.Model/Logistics - Backup.Documents.Model.csproj b/Logistics.Documents.Model/Logistics - Backup.Documents.Model.csproj new file mode 100644 index 0000000..dfee375 --- /dev/null +++ b/Logistics.Documents.Model/Logistics - Backup.Documents.Model.csproj @@ -0,0 +1,13 @@ + + + + net7.0 + enable + enable + + + + + + + diff --git a/Logistics.Documents.Model/Logistics.Documents.Model.csproj b/Logistics.Documents.Model/Logistics.Documents.Model.csproj new file mode 100644 index 0000000..b5ed163 --- /dev/null +++ b/Logistics.Documents.Model/Logistics.Documents.Model.csproj @@ -0,0 +1,15 @@ + + + + net7.0 + enable + enable + Logistics.Documents + + + + + + + + diff --git a/Logistics.Documents.Model/Receive/IReceiveDocument.cs b/Logistics.Documents.Model/Receive/IReceiveDocument.cs new file mode 100644 index 0000000..505ccd5 --- /dev/null +++ b/Logistics.Documents.Model/Receive/IReceiveDocument.cs @@ -0,0 +1,12 @@ +using Common.Documents; + +namespace Logistics.Documents.Receive; + +public interface IReceiveDocument : IDocument +{ + int? Supplier { get; init; } + DateTimeOffset? ReceiveDate { get; init; } + + int ItemCount { get; init; } + int OpenItemCount { get; init; } +} diff --git a/Logistics.Documents.Model/Receive/IReceiveDocumentService.cs b/Logistics.Documents.Model/Receive/IReceiveDocumentService.cs new file mode 100644 index 0000000..51769fc --- /dev/null +++ b/Logistics.Documents.Model/Receive/IReceiveDocumentService.cs @@ -0,0 +1,84 @@ +using System.Collections.Immutable; +using Common.Documents; +using Connected.Annotations; +using Connected.ServiceModel; + +namespace Logistics.Documents.Receive; +/// +/// Represents service for the document. +/// +[Service] +[ServiceUrl(DocumentUrls.Receives)] +public interface IReceiveDocumentService : IDocumentService +{ + /// + /// Inserts a new . + /// + /// The arguments containing the properties of the new document. + /// The id of the newly inserted document. + Task Insert(InsertReceiveDocumentArgs args); + /// + /// Updates document. + /// + /// The arguments containing changed properties of the document. + Task Update(UpdateReceiveDocumentArgs args); + /// + /// Performs partial update on the for the properties specified + /// in arguments. + /// + /// The arguments containing properties that need to be updated. + Task Patch(PatchArgs args); + /// + /// Deletes from the storage. + /// + /// The arguments containing the id of the document that is about to be deleted. + Task Delete(PrimaryKeyArgs args); + /// + /// Selects for the specified id. + /// + /// The arguments containing the id. + /// if found, null otherwise. + Task Select(PrimaryKeyArgs args); + /// + /// Searches documents for the specified criteria. + /// + /// The arguments containing the query criteria. + /// The list of documents that matches the search criteria. + Task> Query(QueryArgs? args); + /// + /// Inserts a new into the document. + /// + /// The arguments containing the properties of the new item. + /// The id of the newly inserted item. + Task InsertItem(InsertReceiveItemArgs args); + /// + /// Updates . + /// + /// The arguments containing the properties to be updated. + Task UpdateItem(UpdateReceiveItemArgs args); + /// + /// Permanently deleted the . + /// + /// The arguments containing the id of the item to be deleted. + Task DeleteItem(PrimaryKeyArgs args); + /// + /// Queries the items for the specified . + /// + /// The arguments containing the id of the document for which the items to be + /// queried. + /// The list of items that belong to the specified document. + Task> QueryItems(PrimaryKeyArgs args); + /// + /// Selects the item for the specified id. + /// + /// The arguments containing the id of the item. + /// The if found, null otherwise. + Task SelectItem(PrimaryKeyArgs args); + /// + /// Select the for the specified entity and entity id from the + /// specified document. + /// + /// The arguments containing criteria values. + /// A first that matches the criteria, null otherwise. + Task SelectItem(SelectReceiveItemArgs args); +} diff --git a/Logistics.Documents.Model/Receive/IReceiveItem.cs b/Logistics.Documents.Model/Receive/IReceiveItem.cs new file mode 100644 index 0000000..ac65f35 --- /dev/null +++ b/Logistics.Documents.Model/Receive/IReceiveItem.cs @@ -0,0 +1,10 @@ +using Connected.Data; + +namespace Logistics.Documents.Receive; + +public interface IReceiveItem : IEntityContainer +{ + int Document { get; init; } + float Quantity { get; init; } + float PostedQuantity { get; init; } +} diff --git a/Logistics.Documents.Model/Receive/IReceivePlannedItem.cs b/Logistics.Documents.Model/Receive/IReceivePlannedItem.cs new file mode 100644 index 0000000..1062895 --- /dev/null +++ b/Logistics.Documents.Model/Receive/IReceivePlannedItem.cs @@ -0,0 +1,41 @@ +using Connected.Data; + +namespace Logistics.Documents.Receive; +/// +/// Represents connected (many-to-many) entity between +/// and . +/// +/// +/// Master receive document contains one or more items. Receive document +/// is then divided into one or more documents which contain +/// two lists of items: +/// +/// +/// +/// +/// This entity represents planned items which represents the plan of how what kind of item and how +/// much should be posted to each . This acts only as a guide to the user +/// not the actual items and quantities that arrived into warehouse. +/// +public interface IReceivePlannedItem : IPrimaryKey +{ + /// + /// The id of the to which + /// this planned entity belongs. + /// + int Document { get; init; } + /// + /// The id of the item to which + /// this planned entity belongs. + /// + int Item { get; init; } + /// + /// The planned entity which should be posted into this + /// item. + /// + float Quantity { get; init; } + /// + /// The actual posted quantity for this item. + /// + float PostedQuantity { get; init; } +} diff --git a/Logistics.Documents.Model/Receive/IReceivePostingDocument.cs b/Logistics.Documents.Model/Receive/IReceivePostingDocument.cs new file mode 100644 index 0000000..7c9ee6a --- /dev/null +++ b/Logistics.Documents.Model/Receive/IReceivePostingDocument.cs @@ -0,0 +1,10 @@ +using Common.Documents; + +namespace Logistics.Documents.Receive; + +public interface IReceivePostingDocument : IDocument +{ + int Document { get; init; } + int OpenItemCount { get; init; } + int ItemCount { get; init; } +} diff --git a/Logistics.Documents.Model/Receive/IReceivePostingDocumentService.cs b/Logistics.Documents.Model/Receive/IReceivePostingDocumentService.cs new file mode 100644 index 0000000..4570da2 --- /dev/null +++ b/Logistics.Documents.Model/Receive/IReceivePostingDocumentService.cs @@ -0,0 +1,84 @@ +using System.Collections.Immutable; +using Common.Documents; +using Connected.Annotations; +using Connected.Notifications; +using Connected.ServiceModel; + +namespace Logistics.Documents.Receive; + +/// +/// Represents service for the document. +/// +[Service] +[ServiceUrl(DocumentUrls.Receives)] +public interface IReceivePostingDocumentService : IDocumentService +{ + event ServiceEventHandler> PlannedItemUpdated; + /// + /// Inserts a new . + /// + /// The arguments containing the properties of the new document. + /// The id of the newly inserted document. + Task Insert(InsertReceivePostingDocumentArgs args); + /// + /// Updates document. + /// + /// The arguments containing changed properties of the document. + Task Update(UpdateReceivePostingDocumentArgs args); + /// + /// Performs partial update on the for the properties specified + /// in arguments. + /// + /// The arguments containing properties that need to be updated. + Task Patch(PatchArgs args); + /// + /// Deletes from the storage. + /// + /// The arguments containing the id of the document that is about to be deleted. + Task Delete(PrimaryKeyArgs args); + /// + /// Selects for the specified id. + /// + /// The arguments containing the id. + /// is found, null otherwise. + Task Select(PrimaryKeyArgs args); + /// + /// Queries for the specified document. + /// + /// The arguments containing the id of the parent receive document. + /// if found, null otherwise. + Task> Query(PrimaryKeyArgs args); + /// + /// Inserts a new into the document. + /// + /// The arguments containing the properties of the new item. + /// The id of the newly inserted item. + Task InsertItem(InsertReceivePostingItemArgs args); + + Task PatchPlanedItem(PatchArgs args); + + /// + /// Queries the items for the specified . + /// + /// The arguments containing the id of the document for which the items to be + /// queried. + /// The list of items that belong to the specified document. + Task> QueryItems(PrimaryKeyArgs args); + /// + /// Selects the item for the specified id. + /// + /// The arguments containing the id of the item. + /// The if found, null otherwise. + Task SelectItem(PrimaryKeyArgs args); + + /// + /// Updates . + /// + /// The arguments containing the changed properties of the item. + Task UpdatePlannedItem(UpdateReceivePlannedItemArgs args); + + Task SelectPlannedItem(PrimaryKeyArgs args); + Task SelectPlannedItem(SelectReceivePlannedItemArgs args); + Task> QueryPlannedItems(PrimaryKeyArgs args); + Task> QueryPlannedItems(PrimaryKeyArgs args); +} diff --git a/Logistics.Documents.Model/Receive/IReceivePostingItem.cs b/Logistics.Documents.Model/Receive/IReceivePostingItem.cs new file mode 100644 index 0000000..2208b1b --- /dev/null +++ b/Logistics.Documents.Model/Receive/IReceivePostingItem.cs @@ -0,0 +1,10 @@ +using Connected.Data; + +namespace Logistics.Documents.Receive; +public interface IReceivePostingItem : IPrimaryKey +{ + int Document { get; init; } + long Serial { get; init; } + float Quantity { get; init; } + int Location { get; init; } +} diff --git a/Logistics.Documents.Model/Receive/ReceiveDocumentArgs.cs b/Logistics.Documents.Model/Receive/ReceiveDocumentArgs.cs new file mode 100644 index 0000000..3ba22da --- /dev/null +++ b/Logistics.Documents.Model/Receive/ReceiveDocumentArgs.cs @@ -0,0 +1,58 @@ +using System.ComponentModel.DataAnnotations; +using Common.Documents; +using Connected.Annotations; +using Connected.ServiceModel; + +namespace Logistics.Documents.Receive; +/// +/// The arguments used when inserting a new item +/// via service. +/// +public class InsertReceiveItemArgs : Dto +{ + /// + /// The id of the document. + /// Must exists in the storage. + /// + [Range(1, int.MaxValue)] + public int Document { get; set; } + + [Required, MaxLength(128)] + public string EntityType { get; set; } = default!; + + [Required, MaxLength(128)] + public string EntityId { get; set; } = default!; + + [Range(0, float.MaxValue)] + public float Quantity { get; set; } +} + +public sealed class UpdateReceiveItemArgs : PrimaryKeyArgs +{ + [MinValue(0)] + public float PostedQuantity { get; set; } +} + +public sealed class InsertReceiveDocumentArgs : InsertDocumentArgs +{ + public int? Warehouse { get; set; } + + public int? Supplier { get; set; } +} + +public sealed class UpdateReceiveDocumentArgs : UpdateDocumentArgs +{ + public int? Warehouse { get; set; } + + public int? Supplier { get; set; } +} + +public sealed class SelectReceiveItemArgs : Dto +{ + [MinValue(1)] + public int Document { get; set; } + [Required, MaxLength(128)] + public string Entity { get; set; } = default!; + [Required, MaxLength(128)] + public string EntityId { get; set; } = default!; +} \ No newline at end of file diff --git a/Logistics.Documents.Model/Receive/ReceivePostingDocumentArgs.cs b/Logistics.Documents.Model/Receive/ReceivePostingDocumentArgs.cs new file mode 100644 index 0000000..caf6ff2 --- /dev/null +++ b/Logistics.Documents.Model/Receive/ReceivePostingDocumentArgs.cs @@ -0,0 +1,72 @@ +using System.ComponentModel.DataAnnotations; +using Common.Documents; +using Connected.Annotations; +using Connected.ServiceModel; + +namespace Logistics.Documents.Receive; +public sealed class InsertReceivePostingDocumentArgs : InsertDocumentArgs +{ + [MinValue(1)] + public int Document { get; set; } +} + +public sealed class UpdateReceivePostingDocumentArgs : UpdateDocumentArgs +{ + [MinValue(0)] + public int ItemCount { get; set; } + [MinValue(0)] + public int OpenItemCount { get; set; } +} + +/// +/// The arguments used when inserting a new item +/// via service. +/// +public class InsertReceivePostingItemArgs : Dto +{ + /// + /// The id of the document. + /// Must exists in the storage. + /// + [MinValue(1)] + public int Document { get; set; } + + [MinValue(1)] + public int Location { get; set; } + + [MinValue(0)] + public float Quantity { get; set; } + + [MinValue(1)] + public long? Serial { get; set; } +} + +public class InsertReceivePlannedItemArgs : Dto +{ + [MinValue(1)] + public int Document { get; set; } + + [MinValue(0)] + public float Quantity { get; set; } + + public string Entity { get; set; } + public string EntityId { get; set; } +} + +public class UpdateReceivePlannedItemArgs : PrimaryKeyArgs +{ + [MinValue(0)] + public float PostedQuantity { get; set; } +} + +public class SelectReceivePlannedItemArgs : Dto +{ + [MinValue(1)] + public int Document { get; set; } + [Required, MaxLength(128)] + public string Entity { get; set; } = default!; + + [Required, MaxLength(128)] + public string EntityId { get; set; } = default!; +} + diff --git a/Logistics.Documents/Bootstrapper.cs b/Logistics.Documents/Bootstrapper.cs new file mode 100644 index 0000000..a6ee44b --- /dev/null +++ b/Logistics.Documents/Bootstrapper.cs @@ -0,0 +1,9 @@ +using Connected; +using Connected.Annotations; + +[assembly: MicroService(MicroServiceType.Service)] + +namespace Logistics.Documents; +internal sealed class Bootstrapper : Startup +{ +} diff --git a/Logistics.Documents/Logistics.Documents.csproj b/Logistics.Documents/Logistics.Documents.csproj new file mode 100644 index 0000000..5d23c40 --- /dev/null +++ b/Logistics.Documents/Logistics.Documents.csproj @@ -0,0 +1,32 @@ + + + + net7.0 + enable + enable + + + + + + + + + + + + + True + True + SR.resx + + + + + + ResXFileCodeGenerator + SR.Designer.cs + + + + diff --git a/Logistics.Documents/Receive/ReceiveDocument.cs b/Logistics.Documents/Receive/ReceiveDocument.cs new file mode 100644 index 0000000..166aa55 --- /dev/null +++ b/Logistics.Documents/Receive/ReceiveDocument.cs @@ -0,0 +1,24 @@ +using Common.Documents; +using Connected.Annotations; +using Connected.Entities.Annotations; +using Logistics.Types; + +namespace Logistics.Documents.Receive; + +/// +[Table(Schema = Domain.Code)] +internal sealed record ReceiveDocument : Document, IReceiveDocument +{ + public const string EntityKey = $"{Domain.Code}.{nameof(ReceiveDocument)}"; + + /// + [Ordinal(1), Nullable] + public int? Supplier { get; init; } + /// + [Ordinal(2), Nullable] + public DateTimeOffset? ReceiveDate { get; init; } + /// + public int ItemCount { get; init; } + /// + public int OpenItemCount { get; init; } +} diff --git a/Logistics.Documents/Receive/ReceiveDocumentItemOps.cs b/Logistics.Documents/Receive/ReceiveDocumentItemOps.cs new file mode 100644 index 0000000..8880089 --- /dev/null +++ b/Logistics.Documents/Receive/ReceiveDocumentItemOps.cs @@ -0,0 +1,162 @@ +using System.Collections.Immutable; +using Connected.Caching; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.Services; + +namespace Logistics.Documents.Receive; +internal sealed class ReceiveDocumentItemOps +{ + public sealed class Insert : ServiceFunction + { + public Insert(IStorageProvider storage, IEventService events) + { + Storage = storage; + Events = events; + } + + private IStorageProvider Storage { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + var result = await Storage.Open().Update(Arguments.AsEntity(State.New)); + + return result.Id; + } + + protected override async Task OnCommitted() + { + await Events.Enqueue(this, typeof(ReceiveDocumentService), nameof(IReceiveDocumentService.ItemInserted), new PrimaryKeyArgs { Id = Result }); + } + } + + public sealed class Delete : ServiceAction> + { + public Delete(IStorageProvider storage, IEventService events, ICacheContext cache, IReceiveDocumentService documents) + { + Storage = storage; + Events = events; + Cache = cache; + Documents = documents; + } + + private IStorageProvider Storage { get; } + private IEventService Events { get; } + private ICacheContext Cache { get; } + private IReceiveDocumentService Documents { get; } + + protected override async Task OnInvoke() + { + if (SetState(await Documents.SelectItem(Arguments)) is not IReceiveItem entity) + return; + + await Storage.Open().Update(Arguments.AsEntity(State.Deleted)); + } + + protected override async Task OnCommitted() + { + await Cache.Remove(ReceiveItem.EntityKey, Arguments.Id); + await Events.Enqueue(this, Documents, nameof(IReceiveDocumentService.ItemDeleted), Arguments); + } + } + + public sealed class Query : ServiceFunction, ImmutableList> + { + public Query(IStorageProvider storage) + { + Storage = storage; + } + + private IStorageProvider Storage { get; } + + protected override async Task> OnInvoke() + { + return await (from e in Storage.Open() where e.Document == Arguments.Id select e).AsEntities(); + } + } + + public sealed class Select : NullableServiceFunction, IReceiveItem?> + { + public Select(IStorageProvider storage, ICacheContext cache) + { + Storage = storage; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + + protected override async Task OnInvoke() + { + return await Cache.Get(ReceiveItem.EntityKey, Arguments.Id, async (f) => + { + return await (from e in Storage.Open() where e.Id == Arguments.Id select e).AsEntity(); + }); + } + } + + public sealed class SelectByEntity : NullableServiceFunction + { + public SelectByEntity(IStorageProvider storage, ICacheContext cache) + { + Storage = storage; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + + protected override async Task OnInvoke() + { + return await Cache.Get(ReceiveItem.EntityKey, + f => f.Document == Arguments.Document + && string.Equals(f.Entity, Arguments.Entity, StringComparison.OrdinalIgnoreCase) + && string.Equals(f.EntityId, Arguments.EntityId, StringComparison.OrdinalIgnoreCase), async (f) => + { + return await (from e in Storage.Open() + where e.Document == Arguments.Document + && string.Equals(e.Entity, Arguments.Entity, StringComparison.OrdinalIgnoreCase) + && string.Equals(e.EntityId, Arguments.EntityId, StringComparison.OrdinalIgnoreCase) + select e).AsEntity(); + }); + } + } + + public sealed class Update : ServiceAction + { + public Update(IStorageProvider storage, ICacheContext cache, IEventService events, IReceiveDocumentService documents) + { + Storage = storage; + Cache = cache; + Events = events; + Documents = documents; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + private IEventService Events { get; } + private IReceiveDocumentService Documents { get; } + + protected override async Task OnInvoke() + { + if (await Documents.SelectItem(Arguments.Id) is not ReceiveItem entity) + return; + + await Storage.Open().Update(entity, Arguments, async () => + { + await Cache.Remove(ReceiveItem.EntityKey, Arguments.Id); + + return (await Documents.SelectItem(Arguments.Id)) as ReceiveItem; + }); + } + + protected override async Task OnCommitted() + { + await Cache.Remove(ReceiveItem.EntityKey, Arguments.Id); + await Events.Enqueue(this, Documents, nameof(IReceiveDocumentService.ItemUpdated), Arguments); + } + } +} diff --git a/Logistics.Documents/Receive/ReceiveDocumentOps.cs b/Logistics.Documents/Receive/ReceiveDocumentOps.cs new file mode 100644 index 0000000..f2e7d19 --- /dev/null +++ b/Logistics.Documents/Receive/ReceiveDocumentOps.cs @@ -0,0 +1,144 @@ +using System.Collections.Immutable; +using Connected.Caching; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.Services; + +namespace Logistics.Documents.Receive; +internal sealed class ReceiveDocumentOps +{ + public sealed class Delete : ServiceAction> + { + public Delete(IReceiveDocumentService documents, IStorageProvider storage, ICacheContext cache, IEventService events) + { + Documents = documents; + Storage = storage; + Cache = cache; + Events = events; + } + + private IReceiveDocumentService Documents { get; } + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + if (SetState(await Documents.Select(Arguments)) is not IReceiveDocument document) + return; + + /* + * Delete all items + */ + foreach (var item in await Documents.QueryItems(document.Id)) + await Documents.DeleteItem(item.Id); + /* + * Delete document + */ + await Storage.Open().Update(Arguments.AsEntity(State.Deleted)); + } + + protected override async Task OnCommitted() + { + await Cache.Remove(ReceiveDocument.EntityKey, Arguments.Id); + await Events.Enqueue(this, Documents, nameof(IReceiveDocumentService.Deleted), Arguments); + } + } + + public sealed class Insert : ServiceFunction + { + public Insert(IStorageProvider storage, IEventService events, IReceiveDocumentService documents) + { + Storage = storage; + Events = events; + Documents = documents; + } + + private IStorageProvider Storage { get; } + private IEventService Events { get; } + private IReceiveDocumentService Documents { get; } + + protected override async Task OnInvoke() + { + return (await Storage.Open().Update(Arguments.AsEntity(State.New))).Id; + } + + protected override async Task OnCommitted() + { + await Events.Enqueue(this, Documents, nameof(IReceiveDocumentService.Inserted), new PrimaryKeyArgs { Id = Result }); + } + } + + public sealed class Query : ServiceFunction> + { + public Query(IStorageProvider storage) + { + Storage = storage; + } + + public IStorageProvider Storage { get; } + + protected override async Task> OnInvoke() + { + return await (from e in Storage.Open() select e).WithArguments(Arguments).AsEntities(); + } + } + + public sealed class Select : NullableServiceFunction, IReceiveDocument?> + { + public Select(IStorageProvider storage, ICacheContext cache) + { + Storage = storage; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + + protected override async Task OnInvoke() + { + return await Cache.Get(ReceiveDocument.EntityKey, Arguments.Id, async (f) => + { + return await (from e in Storage.Open() where e.Id == Arguments.Id select e).AsEntity(); + }); + } + } + + public sealed class Update : ServiceAction + { + public Update(IStorageProvider storage, ICacheContext cache, IEventService events, IReceiveDocumentService documents) + { + Storage = storage; + Cache = cache; + Events = events; + Documents = documents; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + private IEventService Events { get; } + private IReceiveDocumentService Documents { get; } + + protected override async Task OnInvoke() + { + if (await Documents.Select(Arguments.Id) is not ReceiveDocument entity) + return; + + await Storage.Open().Update(entity, Arguments, async () => + { + await Cache.Remove(ReceiveDocument.EntityKey, Arguments.Id); + + return (await Documents.Select(Arguments.Id)) as ReceiveDocument; + }); + } + + protected override async Task OnCommitted() + { + await Cache.Remove(ReceiveDocument.EntityKey, Arguments.Id); + await Events.Enqueue(this, Documents, nameof(IReceiveDocumentService.Updated), Arguments); + } + } +} + diff --git a/Logistics.Documents/Receive/ReceiveDocumentService.cs b/Logistics.Documents/Receive/ReceiveDocumentService.cs new file mode 100644 index 0000000..d91e410 --- /dev/null +++ b/Logistics.Documents/Receive/ReceiveDocumentService.cs @@ -0,0 +1,82 @@ +using System.Collections.Immutable; +using Common.Documents; +using Connected.Entities; +using Connected.ServiceModel; +using ItemOps = Logistics.Documents.Receive.ReceiveDocumentItemOps; +using Ops = Logistics.Documents.Receive.ReceiveDocumentOps; + +namespace Logistics.Documents.Receive; +/// +internal sealed class ReceiveDocumentService : DocumentService, IReceiveDocumentService +{ + /// + /// Create a new instance + /// + /// The DI scope used by this instance. + public ReceiveDocumentService(IContext context) : base(context) + { + } + /// + public async Task Delete(PrimaryKeyArgs args) + { + await Invoke(GetOperation(), args); + } + /// + public async Task DeleteItem(PrimaryKeyArgs args) + { + await Invoke(GetOperation(), args); + } + /// + public async Task Insert(InsertReceiveDocumentArgs args) + { + return await Invoke(GetOperation(), args); + } + /// + public async Task InsertItem(InsertReceiveItemArgs args) + { + return await Invoke(GetOperation(), args); + } + /// + public async Task Patch(PatchArgs args) + { + if (await Select(args.Id) is not ReceiveDocument entity) + return; + + await Update(args.Patch(entity)); + } + /// + public async Task> Query(QueryArgs? args) + { + return await Invoke(GetOperation(), args ?? QueryArgs.Default); + } + /// + public async Task> QueryItems(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + /// + public async Task Select(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + /// + public async Task SelectItem(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + /// + public async Task SelectItem(SelectReceiveItemArgs args) + { + return await Invoke(GetOperation(), args); + } + /// + public async Task Update(UpdateReceiveDocumentArgs args) + { + await Invoke(GetOperation(), args); + } + /// + public async Task UpdateItem(UpdateReceiveItemArgs args) + { + await Invoke(GetOperation(), args); + } +} diff --git a/Logistics.Documents/Receive/ReceiveItem.cs b/Logistics.Documents/Receive/ReceiveItem.cs new file mode 100644 index 0000000..93b0bc5 --- /dev/null +++ b/Logistics.Documents/Receive/ReceiveItem.cs @@ -0,0 +1,23 @@ +using Common; +using Connected.Annotations; +using Connected.Entities; +using Connected.Entities.Annotations; +using Logistics.Types; + +namespace Logistics.Documents.Receive; + +/// +[Table(Schema = CommonSchemas.DocumentSchema)] +internal sealed record ReceiveItem : EntityContainer, IReceiveItem +{ + public const string EntityKey = $"{Domain.Code}.{nameof(ReceiveItem)}"; + /// + [Ordinal(0), Index] + public int Document { get; init; } + /// + [Ordinal(1)] + public float Quantity { get; init; } + /// + [Ordinal(4)] + public float PostedQuantity { get; init; } +} diff --git a/Logistics.Documents/Receive/ReceivePlannedItem.cs b/Logistics.Documents/Receive/ReceivePlannedItem.cs new file mode 100644 index 0000000..0f72ea3 --- /dev/null +++ b/Logistics.Documents/Receive/ReceivePlannedItem.cs @@ -0,0 +1,25 @@ +using Connected.Annotations; +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; +using Logistics.Types; + +namespace Logistics.Documents.Receive; +/// +[Table(Schema = Domain.Code)] +internal sealed record ReceivePlannedItem : ConsistentEntity, IReceivePlannedItem +{ + public const string EntityKey = $"{Domain.Code}.{nameof(ReceivePlannedItem)}"; + + /// + [Ordinal(0), Index] + public int Document { get; init; } + /// + [Ordinal(1), Index] + public int Item { get; init; } + /// + [Ordinal(2)] + public float Quantity { get; init; } + /// + [Ordinal(3)] + public float PostedQuantity { get; init; } +} diff --git a/Logistics.Documents/Receive/ReceivePlannedItemsOps.cs b/Logistics.Documents/Receive/ReceivePlannedItemsOps.cs new file mode 100644 index 0000000..bc01e6e --- /dev/null +++ b/Logistics.Documents/Receive/ReceivePlannedItemsOps.cs @@ -0,0 +1,128 @@ +using System.Collections.Immutable; +using Connected.Caching; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Interop; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.Services; + +namespace Logistics.Documents.Receive; +internal sealed class ReceivePlannedItemsOps +{ + public sealed class Query : ServiceFunction, ImmutableList> + { + public Query(IStorageProvider storage) + { + Storage = storage; + } + + private IStorageProvider Storage { get; } + + protected override async Task> OnInvoke() + { + return await (from e in Storage.Open() where e.Document == Arguments.Id select e).AsEntities(); + } + } + + public sealed class QueryByItem : ServiceFunction, ImmutableList> + { + public QueryByItem(IStorageProvider storage) + { + Storage = storage; + } + + private IStorageProvider Storage { get; } + + protected override async Task> OnInvoke() + { + return await (from e in Storage.Open() where e.Item == Arguments.Id select e).AsEntities(); + } + } + + public sealed class Select : NullableServiceFunction, IReceivePlannedItem> + { + public Select(IStorageProvider storage, ICacheContext cache) + { + Storage = storage; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + + protected override async Task OnInvoke() + { + return await Cache.Get(ReceivePlannedItem.EntityKey, Arguments.Id, async (f) => + { + return await (from e in Storage.Open() where e.Id == Arguments.Id select e).AsEntity(); + }); + } + } + + public sealed class SelectByEntity : NullableServiceFunction + { + public SelectByEntity(IStorageProvider storage, ICacheContext cache, IReceiveDocumentService documents, IReceivePostingDocumentService postingDocuments) + { + Storage = storage; + Cache = cache; + Documents = documents; + PostingDocuments = postingDocuments; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + private IReceiveDocumentService Documents { get; } + private IReceivePostingDocumentService PostingDocuments { get; } + + protected override async Task OnInvoke() + { + if (await PostingDocuments.Select(Arguments.Document) is not IReceivePostingDocument postingDocument) + return null; + + if (await Documents.SelectItem(Arguments.AsArguments(new { postingDocument.Document })) is not IReceiveItem item) + return null; + + return await Cache.Get(ReceivePlannedItem.EntityKey, f => f.Item == item.Id, async (f) => + { + return await (from e in Storage.Open() where e.Item == item.Id select e).AsEntity(); + }); + } + } + + public sealed class Update : ServiceAction + { + public Update(IStorageProvider storage, ICacheContext cache, IEventService events, IReceivePostingDocumentService documents) + { + Storage = storage; + Cache = cache; + Events = events; + Documents = documents; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + private IEventService Events { get; } + private IReceivePostingDocumentService Documents { get; } + + protected override async Task OnInvoke() + { + if (await Documents.SelectPlannedItem(Arguments.Id) is not ReceivePlannedItem entity) + return; + + await Storage.Open().Update(entity, Arguments, async () => + { + await Cache.Remove(ReceivePlannedItem.EntityKey, Arguments.Id); + + return (await Documents.SelectPlannedItem(Arguments.Id)) as ReceivePlannedItem; + }); + + await Cache.Remove(ReceivePlannedItem.EntityKey, Arguments.Id); + } + + protected override async Task OnCommitted() + { + await Events.Enqueue(this, Documents, nameof(IReceivePostingDocumentService.PlannedItemUpdated), Arguments); + } + } +} diff --git a/Logistics.Documents/Receive/ReceivePostingDocument.cs b/Logistics.Documents/Receive/ReceivePostingDocument.cs new file mode 100644 index 0000000..879e4e6 --- /dev/null +++ b/Logistics.Documents/Receive/ReceivePostingDocument.cs @@ -0,0 +1,22 @@ +using Common.Documents; +using Connected.Annotations; +using Connected.Entities.Annotations; +using Logistics.Types; + +namespace Logistics.Documents.Receive; +/// +[Table(Schema = Domain.Code)] +internal sealed record ReceivePostingDocument : Document, IReceivePostingDocument +{ + public const string EntityKey = $"{Domain.Code}.{nameof(ReceivePostingDocument)}"; + + /// + [Ordinal(0)] + public int Document { get; init; } + /// + [Ordinal(1)] + public int OpenItemCount { get; init; } + /// + [Ordinal(2)] + public int ItemCount { get; init; } +} diff --git a/Logistics.Documents/Receive/ReceivePostingDocumentOps.cs b/Logistics.Documents/Receive/ReceivePostingDocumentOps.cs new file mode 100644 index 0000000..edfbc4d --- /dev/null +++ b/Logistics.Documents/Receive/ReceivePostingDocumentOps.cs @@ -0,0 +1,133 @@ +using System.Collections.Immutable; +using Connected.Caching; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.Services; + +namespace Logistics.Documents.Receive; +internal sealed class ReceivePostingDocumentOps +{ + public sealed class Delete : ServiceAction> + { + public Delete(IStorageProvider storage, ICacheContext cache, IEventService events, IReceivePostingDocumentService documents) + { + Storage = storage; + Cache = cache; + Events = events; + Documents = documents; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + private IEventService Events { get; } + private IReceivePostingDocumentService Documents { get; } + + protected override async Task OnInvoke() + { + await Storage.Open().Update(Arguments.AsEntity(State.Deleted)); + await Cache.Remove(ReceivePostingDocument.EntityKey, Arguments.Id); + } + + protected override async Task OnCommitted() + { + await Events.Enqueue(this, Documents, nameof(IReceivePostingDocumentService.Deleted), Arguments); + } + } + + public sealed class Insert : ServiceFunction + { + public Insert(IStorageProvider storage, IEventService events, IReceivePostingDocumentService documents) + { + Storage = storage; + Events = events; + Documents = documents; + } + + private IStorageProvider Storage { get; } + private IEventService Events { get; } + private IReceivePostingDocumentService Documents { get; } + + protected override async Task OnInvoke() + { + return (await Storage.Open().Update(Arguments.AsEntity(State.New))).Id; + } + + protected override async Task OnCommitted() + { + await Events.Enqueue(this, Documents, nameof(IReceivePostingDocumentService.Inserted), new PrimaryKeyArgs { Id = Result }); + } + } + + public sealed class Query : ServiceFunction, ImmutableList> + { + public Query(IStorageProvider storage) + { + Storage = storage; + } + + private IStorageProvider Storage { get; } + + protected override async Task> OnInvoke() + { + return await (from e in Storage.Open() where e.Document == Arguments.Id select e).AsEntities(); + } + } + + public sealed class Select : NullableServiceFunction, IReceivePostingDocument> + { + public Select(IStorageProvider storage, ICacheContext cache) + { + Storage = storage; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + + protected override async Task OnInvoke() + { + return await Cache.Get(ReceivePostingDocument.EntityKey, Arguments.Id, async (f) => + { + return await (from e in Storage.Open() where e.Id == Arguments.Id select e).AsEntity(); + }); + } + } + + public sealed class Update : ServiceAction + { + public Update(IStorageProvider storage, ICacheContext cache, IEventService events, IReceivePostingDocumentService documents) + { + Storage = storage; + Cache = cache; + Events = events; + Documents = documents; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + private IEventService Events { get; } + private IReceivePostingDocumentService Documents { get; } + + protected override async Task OnInvoke() + { + if (await Documents.Select(Arguments.Id) is not ReceivePostingDocument entity) + return; + + await Storage.Open().Update(entity, Arguments, async () => + { + await Cache.Remove(ReceivePostingDocument.EntityKey, Arguments.Id); + + return (await Documents.Select(Arguments.Id)) as ReceivePostingDocument; + }); + + await Cache.Remove(ReceivePostingDocument.EntityKey, Arguments.Id); + } + + protected override async Task OnCommitted() + { + await Events.Enqueue(this, Documents, nameof(IReceivePostingDocumentService.Updated), Arguments); + } + } +} diff --git a/Logistics.Documents/Receive/ReceivePostingDocumentService.cs b/Logistics.Documents/Receive/ReceivePostingDocumentService.cs new file mode 100644 index 0000000..23602dd --- /dev/null +++ b/Logistics.Documents/Receive/ReceivePostingDocumentService.cs @@ -0,0 +1,98 @@ +using System.Collections.Immutable; +using Common.Documents; +using Connected.Entities; +using Connected.Notifications; +using Connected.ServiceModel; +using ItemOps = Logistics.Documents.Receive.ReceivePostingItemOps; +using Ops = Logistics.Documents.Receive.ReceivePostingDocumentOps; +using PlannedOps = Logistics.Documents.Receive.ReceivePlannedItemsOps; + +namespace Logistics.Documents.Receive; +internal sealed class ReceivePostingDocumentService : DocumentService, IReceivePostingDocumentService +{ + public event ServiceEventHandler> PlannedItemUpdated; + public ReceivePostingDocumentService(IContext context) : base(context) + { + } + + public async Task Delete(PrimaryKeyArgs args) + { + await Invoke(GetOperation(), args); + } + + public async Task Insert(InsertReceivePostingDocumentArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task InsertItem(InsertReceivePostingItemArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task Patch(PatchArgs args) + { + if (await Select(args.Id) is not ReceivePostingDocument entity) + return; + + await Update(entity.Merge(args, State.Default).AsArguments()); + } + + public async Task PatchPlanedItem(PatchArgs args) + { + if (await SelectPlannedItem(args.Id) is not ReceivePlannedItem entity) + return; + + await UpdatePlannedItem(entity.Merge(args, State.Default).AsArguments()); + } + + public async Task> Query(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task> QueryItems(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task> QueryPlannedItems(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task> QueryPlannedItems(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task Select(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task SelectItem(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task SelectPlannedItem(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task SelectPlannedItem(SelectReceivePlannedItemArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task Update(UpdateReceivePostingDocumentArgs args) + { + await Invoke(GetOperation(), args); + } + + public Task UpdatePlannedItem(UpdateReceivePlannedItemArgs args) + { + throw new NotImplementedException(); + } +} diff --git a/Logistics.Documents/Receive/ReceivePostingItem.cs b/Logistics.Documents/Receive/ReceivePostingItem.cs new file mode 100644 index 0000000..91070b1 --- /dev/null +++ b/Logistics.Documents/Receive/ReceivePostingItem.cs @@ -0,0 +1,24 @@ +using Connected.Annotations; +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; +using Logistics.Types; + +namespace Logistics.Documents.Receive; +/// +[Table(Schema = Domain.Code)] +internal sealed record ReceivePostingItem : ConsistentEntity, IReceivePostingItem +{ + public const string EntityKey = $"{Domain.Code}.{nameof(ReceivePostingItem)}"; + /// + [Ordinal(0)] + public int Document { get; init; } + /// + [Ordinal(1)] + public long Serial { get; init; } + /// + [Ordinal(2)] + public float Quantity { get; init; } + /// + [Ordinal(3)] + public int Location { get; init; } +} diff --git a/Logistics.Documents/Receive/ReceivePostingItemOps.cs b/Logistics.Documents/Receive/ReceivePostingItemOps.cs new file mode 100644 index 0000000..33bbc57 --- /dev/null +++ b/Logistics.Documents/Receive/ReceivePostingItemOps.cs @@ -0,0 +1,70 @@ +using System.Collections.Immutable; +using Connected.Caching; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.Services; + +namespace Logistics.Documents.Receive; +internal sealed class ReceivePostingItemOps +{ + public sealed class Insert : ServiceFunction + { + public Insert(IStorageProvider storage, IEventService events, IReceivePostingDocumentService documents) + { + Storage = storage; + Events = events; + Documents = documents; + } + + private IStorageProvider Storage { get; } + private IEventService Events { get; } + private IReceivePostingDocumentService Documents { get; } + + protected override async Task OnInvoke() + { + return (await Storage.Open().Update(Arguments.AsEntity(State.New))).Id; + } + + protected override async Task OnCommitted() + { + await Events.Enqueue(this, Documents, nameof(IReceivePostingDocumentService.ItemInserted), new PrimaryKeyArgs { Id = Result }); + } + } + + public sealed class Query : ServiceFunction, ImmutableList> + { + public Query(IStorageProvider storage) + { + Storage = storage; + } + + private IStorageProvider Storage { get; } + + protected override async Task> OnInvoke() + { + return await (from e in Storage.Open() where e.Document == Arguments.Id select e).AsEntities(); + } + } + + public sealed class Select : NullableServiceFunction, IReceivePostingItem> + { + public Select(IStorageProvider storage, ICacheContext cache) + { + Storage = storage; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + + protected override async Task OnInvoke() + { + return await Cache.Get(ReceivePostingItem.EntityKey, Arguments.Id, async (f) => + { + return await (from e in Storage.Open() where e.Id == Arguments.Id select e).AsEntity(); + }); + } + } +} diff --git a/Logistics.Documents/Receive/Validators.cs b/Logistics.Documents/Receive/Validators.cs new file mode 100644 index 0000000..c905217 --- /dev/null +++ b/Logistics.Documents/Receive/Validators.cs @@ -0,0 +1,64 @@ +using Common.Documents; +using Connected.Annotations; +using Connected.Data; +using Connected.Security.Identity; +using Connected.Validation; +using Contacts.Types; +using Logistics.Types.Warehouses; +using System.ComponentModel.DataAnnotations; + +namespace Logistics.Documents.Receive; + +[Priority(0)] +internal sealed class InsertReceiveDocumentValidator : InsertDocumentValidator +{ + public InsertReceiveDocumentValidator(IUserService users, IBusinessPartnerService businessPartners, IWarehouseService warehouses) : base(users) + { + BusinessPartners = businessPartners; + Warehouses = warehouses; + } + + private IBusinessPartnerService BusinessPartners { get; } + private IWarehouseService Warehouses { get; } + + protected override async Task OnValidating() + { + await ValidateSupplier(); + await ValidateWarehouse(); + } + + private async Task ValidateSupplier() + { + /* + * If supplier is not set there is no need for a validation. + */ + if (Arguments.Supplier is null) + return; + /* + * Check is business partner exists. + */ + if (await BusinessPartners.Select((int)Arguments.Supplier) is not IBusinessPartner supplier) + throw ValidationExceptions.NotFound(nameof(Arguments.Supplier), Arguments.Supplier); + /* + * Check if business partner has Supplier role which means it's actually a supplier. + */ + if (!supplier.Roles.HasFlag(CustomerRoles.Supplier)) + throw new ValidationException($"{SR.ValPartnerNotSupplier} ({Arguments.Supplier})"); + } + + private async Task ValidateWarehouse() + { + if (Arguments.Warehouse is null) + return; + /* + * Check is warehouse exists. + */ + if (await Warehouses.Select(Arguments.Warehouse) is not IWarehouse warehouse) + throw ValidationExceptions.NotFound(nameof(Arguments.Warehouse), Arguments.Warehouse); + /* + * Only Enabled warehouses can be used. + */ + if (warehouse.Status == Status.Disabled) + throw ValidationExceptions.Disabled(nameof(Arguments.Warehouse)); + } +} diff --git a/Logistics.Documents/SR.Designer.cs b/Logistics.Documents/SR.Designer.cs new file mode 100644 index 0000000..4dfda94 --- /dev/null +++ b/Logistics.Documents/SR.Designer.cs @@ -0,0 +1,72 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Logistics.Documents { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class SR { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal SR() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Logistics.Documents.SR", typeof(SR).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Business partner is not a supplier.. + /// + internal static string ValPartnerNotSupplier { + get { + return ResourceManager.GetString("ValPartnerNotSupplier", resourceCulture); + } + } + } +} diff --git a/Logistics.Documents/SR.resx b/Logistics.Documents/SR.resx new file mode 100644 index 0000000..02de3ea --- /dev/null +++ b/Logistics.Documents/SR.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Business partner is not a supplier. + + \ No newline at end of file diff --git a/Logistics.Processes.Receive/Bootstrapper.cs b/Logistics.Processes.Receive/Bootstrapper.cs new file mode 100644 index 0000000..d0d9837 --- /dev/null +++ b/Logistics.Processes.Receive/Bootstrapper.cs @@ -0,0 +1,11 @@ +using Connected; +using Connected.Annotations; + +[assembly: MicroService(MicroServiceType.Process)] + +namespace Logistics.Documents; + +internal sealed class Bootstrapper : Startup +{ + +} diff --git a/Logistics.Processes.Receive/Listeners/PlannedItemListener.cs b/Logistics.Processes.Receive/Listeners/PlannedItemListener.cs new file mode 100644 index 0000000..ecd04ca --- /dev/null +++ b/Logistics.Processes.Receive/Listeners/PlannedItemListener.cs @@ -0,0 +1,74 @@ +using Connected.Middleware.Annotations; +using Connected.Notifications; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Logistics.Documents.Receive; +using Microsoft.Extensions.Logging; + +namespace Logistics.Documents.Listeners; +[Middleware(nameof(IReceivePostingDocumentService.PlannedItemUpdated))] +internal sealed class PlannedItemListener : EventListener> +{ + public PlannedItemListener(ILogger logger, IReceivePostingDocumentService documents, IReceiveDocumentService receiveDocuments) + { + Logger = logger; + Documents = documents; + ReceiveDocuments = receiveDocuments; + } + + private ILogger Logger { get; } + private IReceivePostingDocumentService Documents { get; } + private IReceiveDocumentService ReceiveDocuments { get; } + + protected override async Task OnInvoke() + { + if (await Documents.SelectPlannedItem(Arguments.Id) is not IReceivePlannedItem item) + { + Logger.LogWarning("The IReceivePlannedItem not found ({id}}.", Arguments.Id); + return; + } + + if (await ReceiveDocuments.SelectItem(item.Item) is not IReceiveItem receiveItem) + { + Logger.LogWarning("The IReceiveItem not found ({id}}.", item.Item); + return; + } + + if (await Documents.Select(item.Document) is not IReceivePostingDocument document) + { + Logger.LogWarning("The IReceivePostingDocument not found ({id}}.", item.Document); + return; + } + + await UpdateOpenItems(document); + await UpdatePostedQuantity(receiveItem); + } + + private async Task UpdateOpenItems(IReceivePostingDocument document) + { + var items = await Documents.QueryPlannedItems(new PrimaryKeyArgs { Id = document.Id }); + + await Documents.Patch(new PatchArgs + { + Id = document.Id, + Properties = new Dictionary + { + {nameof(IReceivePostingDocument.OpenItemCount), items.Count(f => f.PostedQuantity < f.Quantity) } + } + }); + } + + private async Task UpdatePostedQuantity(IReceiveItem item) + { + var items = await Documents.QueryPlannedItems(new PrimaryKeyArgs { Id = item.Id }); + + await Documents.PatchPlanedItem(new PatchArgs + { + Id = item.Id, + Properties = new Dictionary + { + {nameof(IReceiveItem.PostedQuantity), items.Sum(f => f.PostedQuantity) } + } + }); + } +} diff --git a/Logistics.Processes.Receive/Listeners/PostingItemListener.cs b/Logistics.Processes.Receive/Listeners/PostingItemListener.cs new file mode 100644 index 0000000..764a242 --- /dev/null +++ b/Logistics.Processes.Receive/Listeners/PostingItemListener.cs @@ -0,0 +1,89 @@ +using Connected.Middleware.Annotations; +using Connected.Notifications; +using Connected.Notifications.Events; +using Logistics.Documents.Receive; +using Logistics.Stock; +using Logistics.Types.Serials; +using Microsoft.Extensions.Logging; + +namespace Logistics.Documents.Listeners; +/// +/// Represents the event listener to the Updated event. +/// +/// +/// This middleware reacts when the item is inserted and updates the . +/// +[Middleware(nameof(IReceivePostingDocumentService.ItemInserted))] +internal sealed class PostingItemListener : EventListener> +{ + /// + /// Creates a new instance of the + /// + public PostingItemListener(ILogger logger, IStockService stock, IReceivePostingDocumentService documents, ISerialService serials) + { + Logger = logger; + Stock = stock; + Documents = documents; + Serials = serials; + } + + private ILogger Logger { get; } + private IStockService Stock { get; } + private IReceivePostingDocumentService Documents { get; } + private ISerialService Serials { get; } + + protected override async Task OnInvoke() + { + /* + * Stage 1 is to prepare all data neede to perform operation + * + * Load posting item + */ + if (await Documents.SelectItem(Arguments.Id) is not IReceivePostingItem item) + { + Logger.LogWarning("The IReceivePostingItem not found ({id}}.", Arguments.Id); + return; + } + /* + * Now load the serial number + */ + if (await Serials.Select(item.Serial) is not ISerial serial) + { + Logger.LogWarning("The ISerial not found ({id}}.", item.Serial); + return; + } + /* + * Now load the serial number + */ + if (await Documents.SelectPlannedItem(new SelectReceivePlannedItemArgs + { + Document = item.Document, + Entity = serial.Entity, + EntityId = serial.EntityId + }) is not ISerial plannedItem) + { + Logger.LogWarning("The IReceivePlannedItem not found ({entity}, {entityId}).", serial.Entity, serial.EntityId); + return; + } + /* + * The idea here is simple: + * update (increase) the stock for the specified item + * and posted quantity and update the statictics for + * the immediate parents. + */ + await Stock.Update(new UpdateStockArgs + { + Location = item.Location, + Quantity = item.Quantity, + Serial = item.Serial + }); + /* + * Now update the planned item with posted quantity + */ + await Documents.UpdatePlannedItem(new UpdateReceivePlannedItemArgs + { + Id = plannedItem.Id, + PostedQuantity = item.Quantity + }); + } +} diff --git a/Logistics.Processes.Receive/Listeners/ReceiveItemListener.cs b/Logistics.Processes.Receive/Listeners/ReceiveItemListener.cs new file mode 100644 index 0000000..8bcd5fa --- /dev/null +++ b/Logistics.Processes.Receive/Listeners/ReceiveItemListener.cs @@ -0,0 +1,40 @@ +using Connected.Middleware.Annotations; +using Connected.Notifications; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Logistics.Documents.Receive; +using Microsoft.Extensions.Logging; + +namespace Logistics.Documents.Listeners; +[Middleware(nameof(IReceiveDocumentService.ItemUpdated))] +internal sealed class ReceiveItemListener : EventListener> +{ + public ReceiveItemListener(ILogger logger, IReceiveDocumentService documents) + { + Logger = logger; + Documents = documents; + } + + private ILogger Logger { get; } + private IReceiveDocumentService Documents { get; } + + protected override async Task OnInvoke() + { + if (await Documents.SelectItem(Arguments.Id) is not IReceiveItem item) + { + Logger.LogWarning("The IReceiveItem not found ({id}}.", Arguments.Id); + return; + } + + var items = await Documents.QueryItems(item.Document); + + await Documents.Patch(new PatchArgs + { + Id = item.Document, + Properties = new Dictionary + { + {nameof(IReceiveDocument.OpenItemCount), items.Count(f=>f.PostedQuantity + + + net7.0 + enable + enable + Logistics.Documents + + + + + + + + + + + + + + + + diff --git a/Logistics.Processes.Receive/Protection/ReceiveProtector.cs b/Logistics.Processes.Receive/Protection/ReceiveProtector.cs new file mode 100644 index 0000000..a0fa3c2 --- /dev/null +++ b/Logistics.Processes.Receive/Protection/ReceiveProtector.cs @@ -0,0 +1,24 @@ +using Connected.Data.DataProtection; +using Connected.Data.EntityProtection; +using Connected.Middleware; +using Connected.ServiceModel; +using Logistics.Documents.Receive; + +namespace Logistics.Documents.Protection; +internal sealed class ReceiveProtector : MiddlewareComponent, IEntityProtector +{ + public ReceiveProtector(IReceiveDocumentService documents) + { + Documents = documents; + } + + public IReceiveDocumentService Documents { get; } + + public async Task Invoke(EntityProtectionArgs args) + { + if (await Documents.Select(new PrimaryKeyArgs { Id = args.Entity.Id }) is not IReceiveDocument document) + return; + + throw new NotImplementedException(); + } +} diff --git a/Logistics.Resources.Model/Logistics.Resources.Model.csproj b/Logistics.Resources.Model/Logistics.Resources.Model.csproj new file mode 100644 index 0000000..cfadb03 --- /dev/null +++ b/Logistics.Resources.Model/Logistics.Resources.Model.csproj @@ -0,0 +1,9 @@ + + + + net7.0 + enable + enable + + + diff --git a/Logistics.Stock.Model/Aggregations/IStockAggregation.cs b/Logistics.Stock.Model/Aggregations/IStockAggregation.cs new file mode 100644 index 0000000..e8897a5 --- /dev/null +++ b/Logistics.Stock.Model/Aggregations/IStockAggregation.cs @@ -0,0 +1,9 @@ +using Connected.Data; + +namespace Logistics.Stock.Aggregations; +public interface IStockAggregation : IPrimaryKey +{ + long Stock { get; init; } + DateTimeOffset Date { get; init; } + float Quantity { get; init; } +} diff --git a/Logistics.Stock.Model/IStock.cs b/Logistics.Stock.Model/IStock.cs new file mode 100644 index 0000000..ed13e9b --- /dev/null +++ b/Logistics.Stock.Model/IStock.cs @@ -0,0 +1,33 @@ +using Connected.Data; + +namespace Logistics.Stock; +/// +/// The stock descriptor which describes what kind of entity it +/// represents. The entity could be Product, Semi product or any +/// other type of entity. +/// +public interface IStock : IPrimaryKey +{ + /// + /// The type of the entity. + /// + string Entity { get; init; } + /// + /// The primary key of the entity. + /// + string EntityId { get; init; } + /// + /// The total quantity currently available. + /// + float Quantity { get; init; } + /// + /// The minimum quantity that should be always available + /// in the stock. + /// + float? Min { get; init; } + /// + /// The maximum quantity that should be stored in + /// the stock. + /// + float? Max { get; init; } +} diff --git a/Logistics.Stock.Model/IStockItem.cs b/Logistics.Stock.Model/IStockItem.cs new file mode 100644 index 0000000..e261f0c --- /dev/null +++ b/Logistics.Stock.Model/IStockItem.cs @@ -0,0 +1,40 @@ +using Connected.Data; + +namespace Logistics.Stock; +/// +/// Represents a single stock item. +/// +/// +/// Goods are typically stored in the warehouse. Warehouse is +/// organized into locations or storage bins and each location contains +/// zero or more goods. +/// +public interface IStockItem : IPrimaryKey +{ + /// + /// The to which the item belong. + /// + /// + /// Stock contains information about the type of the entity whereas + /// the stock item contains information about actual storage. + /// + long Stock { get; init; } + /// + /// The location where the goods are stored. + /// + int Location { get; init; } + /// + /// The serial number of the goods. + /// + /// + /// Each item has a serial number which uniquely identifies + /// the items even from the same type but from + /// different documents. + /// + long Serial { get; init; } + /// + /// The quantity left in this location. Once the quantity reaches zero + /// the item gets deleted from the location. + /// + float Quantity { get; init; } +} diff --git a/Logistics.Stock.Model/IStockService.cs b/Logistics.Stock.Model/IStockService.cs new file mode 100644 index 0000000..c1c81af --- /dev/null +++ b/Logistics.Stock.Model/IStockService.cs @@ -0,0 +1,38 @@ +using System.Collections.Immutable; +using Connected.Annotations; +using Connected.Notifications; +using Connected.ServiceModel; + +namespace Logistics.Stock; +/// +/// Represents the service which manipulates with stock items. +/// +[Service] +[ServiceUrl(StockUrls.Stock)] +public interface IStockService : IServiceNotifications +{ + /// + /// Updates the stock items at the specified location. + /// + /// + Task Update(UpdateStockArgs args); + + Task Select(PrimaryKeyArgs args); + Task Select(EntityArgs args); + /// + /// Queries all stock items for the specified stock. + /// + /// The arguments containing the id of the stock + /// The list of stock items that belong to the specified stock id. + Task> QueryItems(PrimaryKeyArgs args); + /// + /// Queries stock items for the specified stock that are present in the specified + /// warehouse location. + /// + /// The arguments containing the crieria used by query. + /// The list of stock items that are present in the specified warehouse location and + /// belong to the specified stock id. + Task> QueryItems(QueryStockItemsArgs args); + + Task SelectItem(PrimaryKeyArgs args); +} diff --git a/Logistics.Stock.Model/Logistics.Stock.Model.csproj b/Logistics.Stock.Model/Logistics.Stock.Model.csproj new file mode 100644 index 0000000..2ec6125 --- /dev/null +++ b/Logistics.Stock.Model/Logistics.Stock.Model.csproj @@ -0,0 +1,14 @@ + + + + net7.0 + enable + enable + Logistics.Stock + + + + + + + diff --git a/Logistics.Stock.Model/StockArgs.cs b/Logistics.Stock.Model/StockArgs.cs new file mode 100644 index 0000000..5511b5e --- /dev/null +++ b/Logistics.Stock.Model/StockArgs.cs @@ -0,0 +1,36 @@ +using Connected.Annotations; +using Connected.ServiceModel; + +namespace Logistics.Stock; +/// +/// Represents the arguments when updating the stock items. +/// +public sealed class UpdateStockArgs : Dto +{ + /// + /// The serial number of the item. + /// + [MinValue(1)] + public long Serial { get; set; } + /// + /// The warehouse location where the items are stored. + /// + + [MinValue(1)] + public int Location { get; set; } + /// + /// The changed quantity. Can be a positive or negative + /// value. + /// + public float Quantity { get; set; } +} + +public sealed class QueryStockItemsArgs : PrimaryKeyArgs +{ + [MinValue(1)] + public int Location { get; set; } + /// + /// The optional serial number. + /// + public long? Serial { get; set; } +} \ No newline at end of file diff --git a/Logistics.Stock.Model/StockUrls.cs b/Logistics.Stock.Model/StockUrls.cs new file mode 100644 index 0000000..8dd7a61 --- /dev/null +++ b/Logistics.Stock.Model/StockUrls.cs @@ -0,0 +1,5 @@ +namespace Logistics.Stock; +public static class StockUrls +{ + public const string Stock = "/logistics/stock"; +} diff --git a/Logistics.Stock/Bootstrapper.cs b/Logistics.Stock/Bootstrapper.cs new file mode 100644 index 0000000..1f1a08f --- /dev/null +++ b/Logistics.Stock/Bootstrapper.cs @@ -0,0 +1,9 @@ +using Connected; +using Connected.Annotations; + +[assembly: MicroService(MicroServiceType.Service)] + +namespace Logistics.Stock; +internal sealed class Bootstrapper : Startup +{ +} diff --git a/Logistics.Stock/Logistics.Stock.csproj b/Logistics.Stock/Logistics.Stock.csproj new file mode 100644 index 0000000..a93d2af --- /dev/null +++ b/Logistics.Stock/Logistics.Stock.csproj @@ -0,0 +1,17 @@ + + + + net7.0 + enable + enable + + + + + + + + + + + diff --git a/Logistics.Stock/Services/StockAggregator.cs b/Logistics.Stock/Services/StockAggregator.cs new file mode 100644 index 0000000..a97a184 --- /dev/null +++ b/Logistics.Stock/Services/StockAggregator.cs @@ -0,0 +1,60 @@ +using Connected.Collections.Queues; +using Connected.Middleware; +using Logistics.Types.WarehouseLocations; +using Microsoft.Extensions.Logging; + +namespace Logistics.Stock.Services; +internal sealed class StockAggregator : MiddlewareComponent, IQueueClient> +{ + public StockAggregator(ILogger logger, IWarehouseLocationService locations, IStockService stock) + { + Logger = logger; + Locations = locations; + Stock = stock; + } + + private ILogger Logger { get; } + private IWarehouseLocationService Locations { get; } + private IStockService Stock { get; } + + public async Task Invoke(IQueueMessage message, PrimaryKeyQueueArgs args) + { + if (await Stock.SelectItem(args.Id) is not IStockItem stock) + { + Logger.LogWarning("IStockItem not found {id}", args.Id); + return; + } + + await Calculate(stock, stock.Location); + } + + private async Task Calculate(IStockItem stock, int locationId) + { + if (await Locations.Select(locationId) is not IWarehouseLocation location) + { + Logger.LogWarning("IWarehouseLocation not found {id}", locationId); + return; + } + + if (location.Parent is null) + return; + + var parent = (int)location.Parent; + + var sum = (await Stock.QueryItems(new QueryStockItemsArgs + { + Id = stock.Id, + Location = parent, + Serial = stock.Serial + })).Sum(f => f.Quantity); + + await Stock.Update(new UpdateStockArgs + { + Location = parent, + Quantity = sum, + Serial = stock.Serial + }); + + await Calculate(stock, parent); + } +} diff --git a/Logistics.Stock/Stock.cs b/Logistics.Stock/Stock.cs new file mode 100644 index 0000000..3416c3a --- /dev/null +++ b/Logistics.Stock/Stock.cs @@ -0,0 +1,24 @@ +using Connected.Annotations; +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; +using Logistics.Types; + +namespace Logistics.Stock; +[Table(Schema = Domain.Code)] +internal sealed record Stock : ConsistentEntity, IStock +{ + [Ordinal(0), Length(128), Index(Name = $"ix_{Domain.Code}_{nameof(Entity)}_{nameof(EntityId)}", Unique = true)] + public string Entity { get; init; } = default!; + + [Ordinal(1), Length(128), Index(Name = $"ix_{Domain.Code}_{nameof(Entity)}_{nameof(EntityId)}", Unique = true)] + public string EntityId { get; init; } = default!; + + [Ordinal(2)] + public float Quantity { get; init; } + + [Ordinal(3)] + public float? Min { get; init; } + + [Ordinal(4)] + public float? Max { get; init; } +} diff --git a/Logistics.Stock/StockItem.cs b/Logistics.Stock/StockItem.cs new file mode 100644 index 0000000..22a7d0e --- /dev/null +++ b/Logistics.Stock/StockItem.cs @@ -0,0 +1,24 @@ +using Connected.Annotations; +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; +using Logistics.Types; + +namespace Logistics.Stock; +/// +[Table(Schema = Domain.Code)] +internal sealed record StockItem : ConsistentEntity, IStockItem +{ + public const string EntityKey = $"{Domain.Code}.{nameof(StockItem)}"; + /// + [Ordinal(0)] + public long Stock { get; init; } + /// + [Ordinal(1)] + public int Location { get; init; } + /// + [Ordinal(2)] + public long Serial { get; init; } + /// + [Ordinal(3)] + public float Quantity { get; init; } +} diff --git a/Logistics.Stock/StockOps.cs b/Logistics.Stock/StockOps.cs new file mode 100644 index 0000000..1c3937f --- /dev/null +++ b/Logistics.Stock/StockOps.cs @@ -0,0 +1,187 @@ +using Connected.Caching; +using Connected.Collections.Queues; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.Services; +using Connected.Threading; +using Logistics.Stock.Services; +using Logistics.Types.Serials; +using Logistics.Types.WarehouseLocations; + +namespace Logistics.Stock; +internal sealed class StockOps +{ + public const string StockQueue = "Stock"; + static StockOps() + { + Locker = new(); + } + + private static AsyncLockerSlim Locker { get; } + /// + /// This method ensures that a stock (parent) record exists. + /// + /// + /// The stock record is not created explicitly since this would introduce unnecessary complexity. It is + /// instead created on the fly when the first request is made. The tricky part is it must be thread safe + /// so we need an async locker since lock statement does not support async calls. + /// + private static async Task Ensure(IStorageProvider storage, IStockService stock, EntityArgs args) + { + /* + * First check for existence so we don't need to perform a lock if the record is found. + */ + if (await stock.Select(args) is IStock existing) + return existing; + /* + * Doesn't exist. + * Perform an async lock to ensure no one else is trying to insert the item. + */ + return await Locker.LockAsync(async () => + { + /* + * Read again if two or more threads were competing for the insert. The thing is this + * is happening quite frequently even in semi loaded warehouse systems. + */ + if (await stock.Select(args) is IStock existing2) + return existing2; + /* + * Still nothing. We are safe to insert a new stock descriptor. Note that in scalable environments + * there is still a possibillity that two requests would made it here but from different processes. + * Thus we should have a unique constraint on the entity ensuring only one request will win, all the others + * lose. This also means the provider owning the entity must support unique constraints. + */ + var entity = await storage.Open().Update(args.AsEntity(State.New)); + var result = await stock.Select(entity.Id); + /* + * This should not happen anyway but we'll do it for the sake of sompiler warning. + */ + if (result is null) + throw new NullReferenceException(nameof(IStock)); + + return result; + }); + } + + public sealed class Update : ServiceFunction + { + public Update(IStorageProvider storage, ICacheContext cache, IEventService events, IStockService stock, + ISerialService serials, IQueueService queue, IWarehouseLocationService locations) + { + Storage = storage; + Cache = cache; + Events = events; + Stock = stock; + Serials = serials; + Queue = queue; + Locations = locations; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + private IEventService Events { get; } + private IStockService Stock { get; } + private ISerialService Serials { get; } + private IQueueService Queue { get; } + private IWarehouseLocationService Locations { get; } + private bool IsLeaf { get; set; } + + protected override async Task OnInvoke() + { + /* + * We need this info for queueing aggregations. + */ + IsLeaf = (await Locations.Select(Arguments.Location)).ItemCount == 0; + /* + * Validators should validate the existence. Serials don't get deleted. + */ + if (await Serials.Select(Arguments.Serial) is not ISerial serial) + return 0; + /* + * Ensure the stock record exists. + */ + var stock = await Ensure(Storage, Stock, serial.AsArguments()); + /* + * Now we must check if the stock item exists for the specified serial and + * warehouse location. If so we'll only update the quantity. + */ + if (await FindExisting(stock) is not StockItem existing) + return await InsertItem(stock); + else + return await UpdateItem(existing); + } + + private async Task InsertItem(IStock stock) + { + return await Locker.LockAsync(async () => + { + /* + * Query again if someone overtook us. + */ + if (await FindExisting(stock) is not StockItem existing) + { + /* + * Still doesn't exist, it's safe to insert it since we are in the locked area. + */ + return (await Storage.Open().Update(Arguments.AsEntity(State.New))).Id; + } + else + { + /* + * Indeed, there was a record inserted in the meantime. + */ + return await UpdateItem(existing); + } + }); + } + /// + /// Performs the update on the existing stock item. + /// + /// The stock item to be updated. + private async Task UpdateItem(StockItem item) + { + await Storage.Open().Update(item, Arguments, async () => + { + await Cache.Remove(StockItem.EntityKey, item.Id); + + return SetState((await Stock.SelectItem(item.Id)) as StockItem); + }, + async (e) => + { + var quantity = item.Quantity + Arguments.Quantity; + + await Task.CompletedTask; + + return e.Merge(Arguments, State.Default, new { Quantity = quantity }); + }); + + return item.Id; + } + + private async Task FindExisting(IStock stock) + { + var items = await Stock.QueryItems(new QueryStockItemsArgs + { + Id = stock.Id, + Location = Arguments.Location, + Serial = Arguments.Serial + }); + + if (items.IsEmpty || items[0] is not StockItem existing) + return null; + + return existing; + } + + protected override async Task OnCommitted() + { + await Cache.Remove(StockItem.EntityKey, Result); + await Events.Enqueue(this, Stock, nameof(IStockService.Updated), new PrimaryKeyArgs { Id = Result }); + + if (IsLeaf) + await Queue.Enqueue>(new PrimaryKeyQueueArgs { Id = Result }); + } + } +} diff --git a/Logistics.Stock/StockService.cs b/Logistics.Stock/StockService.cs new file mode 100644 index 0000000..c817ca2 --- /dev/null +++ b/Logistics.Stock/StockService.cs @@ -0,0 +1,41 @@ +using System.Collections.Immutable; +using Connected.ServiceModel; +using Connected.Services; + +namespace Logistics.Stock; +internal sealed class StockService : EntityService, IStockService +{ + public StockService(IContext context) : base(context) + { + } + + public Task> QueryItems(PrimaryKeyArgs args) + { + throw new NotImplementedException(); + } + + public Task> QueryItems(QueryStockItemsArgs args) + { + throw new NotImplementedException(); + } + + public Task Select(PrimaryKeyArgs args) + { + throw new NotImplementedException(); + } + + public Task Select(EntityArgs args) + { + throw new NotImplementedException(); + } + + public Task SelectItem(PrimaryKeyArgs args) + { + throw new NotImplementedException(); + } + + public Task Update(UpdateStockArgs args) + { + throw new NotImplementedException(); + } +} diff --git a/Logistics.Types.Model/Domain.cs b/Logistics.Types.Model/Domain.cs new file mode 100644 index 0000000..ef026af --- /dev/null +++ b/Logistics.Types.Model/Domain.cs @@ -0,0 +1,6 @@ +namespace Logistics.Types; +public static class Domain +{ + public const string Name = "Logistics"; + public const string Code = "lgs"; +} diff --git a/Logistics.Types.Model/Logistics.Types.Model.csproj b/Logistics.Types.Model/Logistics.Types.Model.csproj new file mode 100644 index 0000000..f1c3e5f --- /dev/null +++ b/Logistics.Types.Model/Logistics.Types.Model.csproj @@ -0,0 +1,14 @@ + + + + net7.0 + enable + enable + Logistics.Types + + + + + + + diff --git a/Logistics.Types.Model/LogisticsUrls.cs b/Logistics.Types.Model/LogisticsUrls.cs new file mode 100644 index 0000000..fabc472 --- /dev/null +++ b/Logistics.Types.Model/LogisticsUrls.cs @@ -0,0 +1,8 @@ +namespace Logistics.Types; +public static class LogisticsUrls +{ + public const string Warehouses = "/logistics/types/warehouses"; + public const string WarehouseLocations = "/logistics/types/warehouseLocations"; + public const string Packing = "/logistics/types/packing"; + public const string Serials = "/logistics/types/serials"; +} diff --git a/Logistics.Types.Model/Packaging/IPacking.cs b/Logistics.Types.Model/Packaging/IPacking.cs new file mode 100644 index 0000000..455d458 --- /dev/null +++ b/Logistics.Types.Model/Packaging/IPacking.cs @@ -0,0 +1,21 @@ +using Connected.Data; + +namespace Logistics.Types.Packaging; +public interface IPacking : IPrimaryKey +{ + string Ean { get; init; } + string Entity { get; init; } + string EntityId { get; init; } + + float? Quantity { get; init; } + float? NetWeight { get; init; } + float? GrossWeight { get; init; } + + int? Width { get; init; } + int? Height { get; init; } + int? Depth { get; init; } + + int? ItemCount { get; init; } + + Status Status { get; init; } +} diff --git a/Logistics.Types.Model/Packaging/IPackingService.cs b/Logistics.Types.Model/Packaging/IPackingService.cs new file mode 100644 index 0000000..b493413 --- /dev/null +++ b/Logistics.Types.Model/Packaging/IPackingService.cs @@ -0,0 +1,34 @@ +using System.Collections.Immutable; +using Connected.Annotations; +using Connected.Notifications; +using Connected.ServiceModel; + +namespace Logistics.Types.Packaging; + +[Service] +[ServiceUrl(LogisticsUrls.Packing)] +public interface IPackingService : IServiceNotifications +{ + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task> Query(QueryArgs? args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task> Query(PrimaryKeyListArgs args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(PrimaryKeyArgs args); + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(SelectPackingArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Put)] + Task Insert(InsertPackingArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Patch)] + Task Update(UpdatePackingArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Patch)] + Task Patch(PatchArgs args); + + [ServiceMethod(ServiceMethodVerbs.Delete | ServiceMethodVerbs.Post)] + Task Delete(PrimaryKeyArgs args); +} diff --git a/Logistics.Types.Model/Packaging/PackingArgs.cs b/Logistics.Types.Model/Packaging/PackingArgs.cs new file mode 100644 index 0000000..8d4fc53 --- /dev/null +++ b/Logistics.Types.Model/Packaging/PackingArgs.cs @@ -0,0 +1,54 @@ +using System.ComponentModel.DataAnnotations; +using Connected.Data; +using Connected.ServiceModel; + +namespace Logistics.Types.Packaging; +public sealed class InsertPackingArgs : EntityArgs +{ + [Required, MaxLength(32)] + public string Ean { get; set; } = default!; + + public float? Quantity { get; set; } + + public float? NetWeight { get; set; } + + public float? GrossWeight { get; set; } + + public int? Width { get; set; } + + public int? Height { get; set; } + + public int? Depth { get; set; } + + public int? ItemCount { get; set; } + + public Status Status { get; set; } = Status.Disabled; +} + +public sealed class UpdatePackingArgs : PrimaryKeyArgs +{ + [Required, MaxLength(32)] + public string Ean { get; set; } = default!; + + public float? Quantity { get; set; } + + public float? NetWeight { get; set; } + + public float? GrossWeight { get; set; } + + public int? Width { get; set; } + + public int? Height { get; set; } + + public int? Depth { get; set; } + + public int? ItemCount { get; set; } + + public Status Status { get; set; } = Status.Disabled; +} + +public sealed class SelectPackingArgs : Dto +{ + [Required, MaxLength(32)] + public string Ean { get; set; } = default!; +} \ No newline at end of file diff --git a/Logistics.Types.Model/Serials/ISerial.cs b/Logistics.Types.Model/Serials/ISerial.cs new file mode 100644 index 0000000..d234688 --- /dev/null +++ b/Logistics.Types.Model/Serials/ISerial.cs @@ -0,0 +1,54 @@ +using Connected.Data; + +namespace Logistics.Types.Serials; +/// +/// Represents a serial number in the logistic environment. +/// +/// +/// The primary usage of the Serial is in warehouse management +/// system, where every item in the stock is labeled with serial number. +/// Once the item is received and before it is put in the stock locations, +/// it receives a unique serial number. If the item is moved between stock +/// locations and even warehouses, its serial value remains the same. Serial +/// number plays a key role in traceability. +/// +public interface ISerial : IPrimaryKey +{ + /// + /// The entity which owns the serial number. This could + /// be Product, Semi product, Raw or any other type of + /// entity which needs some kind of labeling. + /// + string Entity { get; init; } + /// + /// The primary key of the entity. This points to the exact record of + /// the Entity type. + /// + string EntityId { get; init; } + /// + /// The actual Serial number. System can use different middleware techniques + /// to obtain this value because it's very common to be have project specific + /// implementation to calculate this value. + /// + string Value { get; init; } + /// + /// The remaining quantity in the stock for this serial. This value can increase + /// or decrease depending on the warehouse implementation. Some systems do reuse + /// the same serial between different receives. + /// + float Quantity { get; init; } + /// + /// The date serial was created. + /// + DateTimeOffset Created { get; init; } + /// + /// If the item has limited shelf life, this value should hold the date when + /// the shelf life expires. + /// + DateTimeOffset? BestBefore { get; init; } + /// + /// The status of the serial number. If the status is the + /// processes using the serial number should not allow the entity to be used in documents. + /// + Status Status { get; init; } +} diff --git a/Logistics.Types.Model/Serials/ISerialService.cs b/Logistics.Types.Model/Serials/ISerialService.cs new file mode 100644 index 0000000..0a89efb --- /dev/null +++ b/Logistics.Types.Model/Serials/ISerialService.cs @@ -0,0 +1,71 @@ +using System.Collections.Immutable; +using Connected.Annotations; +using Connected.Notifications; +using Connected.ServiceModel; + +namespace Logistics.Types.Serials; +/// +/// The service for manipulating with serials. A is a fundamental +/// entity used by labeling and traceability systems. +/// +[Service] +[ServiceUrl(LogisticsUrls.Serials)] +public interface ISerialService : IServiceNotifications +{ + /// + /// Queries all serial numbers. + /// + /// The optional arguments specifiying the + /// behavior of the result set. + /// A list of entities. + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task> Query(QueryArgs? args); + /// + /// Performs a lookup on the serials for the specified set of ids. + /// + /// The arguments containing the list of ids for + /// which the entities will be returned. + /// A list of entities that matches the specified ids. + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task> Query(PrimaryKeyListArgs args); + /// + /// Returns the first serial that matches the specified id. + /// + /// The arguments containing the id of the entity. + /// The if found, null otherwise. + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(PrimaryKeyArgs args); + /// + /// Returns the first serial with the specified value. + /// + /// The arguments containing the value for which serial + /// entity will be returned. + /// The if found, null otherwise. + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(SelectSerialArgs args); + /// + /// Inserts a new serial number. + /// + /// The arguments containing the properties of the new serial. + /// The id of the newly inserted serial. + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Put)] + Task Insert(InsertSerialArgs args); + /// + /// Updates an existing serial. + /// + /// The arguments containing properties which will change the entity. + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Patch)] + Task Update(UpdateSerialArgs args); + /// + /// Performs a partial update on the serial. + /// + /// The arguments containing properties which has to be updated. + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Patch)] + Task Patch(PatchArgs args); + /// + /// Peranently deletes the serial from the storage. + /// + /// The arguments containing the id of the entity to be deleted. + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Delete)] + Task Delete(PrimaryKeyArgs args); +} diff --git a/Logistics.Types.Model/Serials/SerialArgs.cs b/Logistics.Types.Model/Serials/SerialArgs.cs new file mode 100644 index 0000000..38b402b --- /dev/null +++ b/Logistics.Types.Model/Serials/SerialArgs.cs @@ -0,0 +1,59 @@ +using System.ComponentModel.DataAnnotations; +using Connected.Annotations; +using Connected.Data; +using Connected.ServiceModel; + +namespace Logistics.Types.Serials; +/// +/// Arguments used when inserting a new serial number. +/// +public sealed class InsertSerialArgs : Dto +{ + /// + [Required, MaxLength(128)] + public string Entity { get; set; } = default!; + /// + [Required, MaxLength(128)] + public string EntityId { get; set; } = default!; + /// + [MinValue(0)] + public float Quantity { get; set; } + /// + /// + /// If this property is null, the process will most likely + /// set the value of the current date (DateTime.UtcNow). + /// + public DateTimeOffset? Created { get; set; } + /// + public DateTimeOffset? BestBefore { get; set; } + /// + public Status Status { get; set; } = Status.Disabled; + /// + [Required, MaxLength(128)] + public string Value { get; set; } = default!; +} +/// +/// The arguments used when updating the existing serial entity. +/// +public sealed class UpdateSerialArgs : PrimaryKeyArgs +{ + /// + /// The new quantity. This is an absolute value, do not provide + /// delta values. + /// + [MinValue(0)] + public float Quantity { get; set; } + /// + public DateTimeOffset? BestBefore { get; set; } + /// + public Status Status { get; set; } = Status.Disabled; +} +/// +/// The arguments used for selecting serial by its value. +/// +public sealed class SelectSerialArgs : Dto +{ + /// + [Required, MaxLength(128)] + public string Value { get; set; } = default!; +} \ No newline at end of file diff --git a/Logistics.Types.Model/WarehouseLocations/IWarehouseLocation.cs b/Logistics.Types.Model/WarehouseLocations/IWarehouseLocation.cs new file mode 100644 index 0000000..690d8b0 --- /dev/null +++ b/Logistics.Types.Model/WarehouseLocations/IWarehouseLocation.cs @@ -0,0 +1,27 @@ +using Connected.Data; +using Logistics.Types.Warehouses; + +namespace Logistics.Types.WarehouseLocations; +/// +/// Represents a physical or logical location inside a . +/// +/// +/// Each contains zero or more locations. +/// Location can be a container, which means it contains child locations, or leaf, which doesn't contain +/// child locations. Items can be put only in leaf locations, whereas containers acts only as aggregators +/// which means they provide calculated values for items contained in the child locations. +/// +public interface IWarehouseLocation : IPrimaryKey +{ + int? Parent { get; init; } + int Warehouse { get; init; } + string Name { get; init; } + string Code { get; init; } + Status Status { get; init; } + /// + /// The number of direct child items that belong to this + /// location. If this value is 0 it means the location + /// is leaf. If not, it's a container. + /// + int ItemCount { get; init; } +} diff --git a/Logistics.Types.Model/WarehouseLocations/IWarehouseLocationService.cs b/Logistics.Types.Model/WarehouseLocations/IWarehouseLocationService.cs new file mode 100644 index 0000000..2e2d3c6 --- /dev/null +++ b/Logistics.Types.Model/WarehouseLocations/IWarehouseLocationService.cs @@ -0,0 +1,29 @@ +using System.Collections.Immutable; +using Connected.Annotations; +using Connected.Notifications; +using Connected.ServiceModel; +using Logistics.Types.Warehouses; + +namespace Logistics.Types.WarehouseLocations; +[Service] +[ServiceUrl(LogisticsUrls.WarehouseLocations)] +public interface IWarehouseLocationService : IServiceNotifications +{ + Task> Query(QueryArgs? args); + /// + /// Queries warehouse locations for the specified . + /// + /// The arguments containing the id of the warehouse. + /// The list of warehouse locations that belong to the specified warehouse. + Task> Query(QueryWarehouseLocationArgs args); + Task> Query(PrimaryKeyListArgs args); + Task> QueryChildren(QueryWarehouseLocationChildrenArgs args); + + Task Select(PrimaryKeyArgs args); + Task Select(SelectWarehouseLocationArgs args); + + Task Insert(InsertWarehouseLocationArgs args); + Task Update(UpdateWarehouseLocationArgs args); + Task Patch(PatchArgs args); + Task Delete(PrimaryKeyArgs args); +} diff --git a/Logistics.Types.Model/WarehouseLocations/WarehouseLocationArgs.cs b/Logistics.Types.Model/WarehouseLocations/WarehouseLocationArgs.cs new file mode 100644 index 0000000..ac6681b --- /dev/null +++ b/Logistics.Types.Model/WarehouseLocations/WarehouseLocationArgs.cs @@ -0,0 +1,54 @@ +using System.ComponentModel.DataAnnotations; +using Connected.Annotations; +using Connected.Data; +using Connected.ServiceModel; + +namespace Logistics.Types.WarehouseLocations; +public sealed class InsertWarehouseLocationArgs : Dto +{ + public int? Parent { get; set; } + + [MinValue(1)] + public int Warehouse { get; set; } + + [Required, MaxLength(128)] + public string Name { get; set; } = default!; + + [Required, MaxLength(32)] + public string Code { get; set; } = default!; + + public Status Status { get; set; } = Status.Disabled; +} + +public sealed class UpdateWarehouseLocationArgs : PrimaryKeyArgs +{ + public int? Parent { get; set; } + + [Required, MaxLength(128)] + public string Name { get; set; } = default!; + + [Required, MaxLength(32)] + public string Code { get; set; } = default!; + + public Status Status { get; set; } = Status.Disabled; +} + +public sealed class SelectWarehouseLocationArgs : Dto +{ + [Required, MaxLength(32)] + public string Code { get; set; } = default!; +} + +public sealed class QueryWarehouseLocationArgs : QueryArgs +{ + [MinValue(1)] + public int Warehouse { get; set; } +} + +public sealed class QueryWarehouseLocationChildrenArgs : QueryArgs +{ + [MinValue(1)] + public int Warehouse { get; set; } + + public int? Parent { get; set; } +} \ No newline at end of file diff --git a/Logistics.Types.Model/Warehouses/IWarehouse.cs b/Logistics.Types.Model/Warehouses/IWarehouse.cs new file mode 100644 index 0000000..5fdfb7b --- /dev/null +++ b/Logistics.Types.Model/Warehouses/IWarehouse.cs @@ -0,0 +1,10 @@ +using Connected.Data; + +namespace Logistics.Types.Warehouses; + +public interface IWarehouse : IPrimaryKey +{ + string Name { get; init; } + string Code { get; init; } + Status Status { get; init; } +} diff --git a/Logistics.Types.Model/Warehouses/IWarehouseService.cs b/Logistics.Types.Model/Warehouses/IWarehouseService.cs new file mode 100644 index 0000000..9bb8cea --- /dev/null +++ b/Logistics.Types.Model/Warehouses/IWarehouseService.cs @@ -0,0 +1,32 @@ +using System.Collections.Immutable; +using Connected.Annotations; +using Connected.Notifications; +using Connected.ServiceModel; + +namespace Logistics.Types.Warehouses; + +[Service] +[ServiceUrl(LogisticsUrls.Warehouses)] +public interface IWarehouseService : IServiceNotifications +{ + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task> Query(QueryArgs? args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task> Query(PrimaryKeyListArgs args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(PrimaryKeyArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Put)] + Task Insert(InsertWarehouseArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Patch)] + Task Update(UpdateWarehouseArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Patch)] + Task Patch(PatchArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Delete)] + Task Delete(PrimaryKeyArgs args); +} diff --git a/Logistics.Types.Model/Warehouses/WarehouseArgs.cs b/Logistics.Types.Model/Warehouses/WarehouseArgs.cs new file mode 100644 index 0000000..79acb79 --- /dev/null +++ b/Logistics.Types.Model/Warehouses/WarehouseArgs.cs @@ -0,0 +1,26 @@ +using Connected.Data; +using Connected.ServiceModel; +using System.ComponentModel.DataAnnotations; + +namespace Logistics.Types.Warehouses; +public class InsertWarehouseArgs : Dto +{ + [Required, MaxLength(128)] + public string Name { get; set; } = default!; + + [Required, MaxLength(32)] + public string Code { get; set; } = default!; + + public Status Status { get; set; } = Status.Disabled; +} + +public class UpdateWarehouseArgs : PrimaryKeyArgs +{ + [Required, MaxLength(128)] + public string Name { get; set; } = default!; + + [Required, MaxLength(32)] + public string Code { get; set; } = default!; + + public Status Status { get; set; } = Status.Disabled; +} diff --git a/Logistics.Types/Bootstrapper.cs b/Logistics.Types/Bootstrapper.cs new file mode 100644 index 0000000..3b4624f --- /dev/null +++ b/Logistics.Types/Bootstrapper.cs @@ -0,0 +1,9 @@ +using Connected; +using Connected.Annotations; + +[assembly: MicroService(MicroServiceType.Service)] + +namespace Logistics.Types; +internal sealed class Bootstrapper : Startup +{ +} diff --git a/Logistics.Types/Logistics.Types.csproj b/Logistics.Types/Logistics.Types.csproj new file mode 100644 index 0000000..c196cdb --- /dev/null +++ b/Logistics.Types/Logistics.Types.csproj @@ -0,0 +1,15 @@ + + + + net7.0 + enable + enable + + + + + + + + + diff --git a/Logistics.Types/Packaging/Packing.cs b/Logistics.Types/Packaging/Packing.cs new file mode 100644 index 0000000..63a70fc --- /dev/null +++ b/Logistics.Types/Packaging/Packing.cs @@ -0,0 +1,46 @@ +using Connected.Annotations; +using Connected.Data; +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; + +namespace Logistics.Types.Packaging; + +[Table(Schema = Domain.Code)] +internal sealed record Packing : ConsistentEntity, IPacking +{ + public const string EntityKey = $"{Domain.Code}.{nameof(Packing)}"; + + [Ordinal(0), Length(32)] + [Index(Unique = true)] + public string Ean { get; init; } = default!; + + [Ordinal(1), Length(128)] + public string Entity { get; init; } = default!; + + [Ordinal(2), Length(128)] + public string EntityId { get; init; } = default!; + + [Ordinal(3)] + public float? Quantity { get; init; } + + [Ordinal(4)] + public float? NetWeight { get; init; } + + [Ordinal(5)] + public float? GrossWeight { get; init; } + + [Ordinal(6)] + public int? Width { get; init; } + + [Ordinal(7)] + public int? Height { get; init; } + + [Ordinal(8)] + public int? Depth { get; init; } + + [Ordinal(9)] + public int? ItemCount { get; init; } + + [Ordinal(10)] + public Status Status { get; init; } = Status.Disabled; +} diff --git a/Logistics.Types/Packaging/PackingOps.cs b/Logistics.Types/Packaging/PackingOps.cs new file mode 100644 index 0000000..6c6f170 --- /dev/null +++ b/Logistics.Types/Packaging/PackingOps.cs @@ -0,0 +1,177 @@ +using System.Collections.Immutable; +using Connected.Caching; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.Services; + +namespace Logistics.Types.Packaging; +internal sealed class PackingOps +{ + public sealed class Delete : ServiceAction> + { + public Delete(IStorageProvider storage, IPackingService packingService, IEventService events, ICachingService cache) + { + Storage = storage; + PackingService = packingService; + Events = events; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private IPackingService PackingService { get; } + private IEventService Events { get; } + private ICachingService Cache { get; } + + protected override async Task OnInvoke() + { + if (await PackingService.Select(Arguments.Id) is not IPacking packing) + return; + + SetState(packing); + + await Storage.Open().Update(Arguments.AsEntity(State.Deleted)); + } + + protected override async Task OnCommitted() + { + await Cache.Remove(Packing.EntityKey, Arguments.Id); + await Events.Enqueue(this, Events, nameof(IPackingService.Deleted), Arguments); + } + } + + public sealed class Insert : ServiceFunction + { + public Insert(IStorageProvider storage, IEventService events) + { + Storage = storage; + Events = events; + } + + private IStorageProvider Storage { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + return (await Storage.Open().Update(Arguments.AsEntity(State.New))).Id; + } + + protected override async Task OnCommitted() + { + await Events.Enqueue(this, Events, nameof(IPackingService.Inserted), Arguments); + } + } + + public sealed class Query : ServiceFunction> + { + public Query(IStorageProvider storage) + { + Storage = storage; + } + + private IStorageProvider Storage { get; } + + protected override async Task?> OnInvoke() + { + return await (from e in Storage.Open() + select e).WithArguments(Arguments).AsEntities(); + } + } + + public sealed class Lookup : ServiceFunction, ImmutableList> + { + public Lookup(IStorageProvider storage) + { + Storage = storage; + } + + private IStorageProvider Storage { get; } + + protected override async Task> OnInvoke() + { + return await (from e in Storage.Open() + where Arguments.IdList.Any(f => f == e.Id) + select e).AsEntities(); + } + } + + public sealed class Select : NullableServiceFunction, IPacking> + { + public Select(IStorageProvider storage, ICachingService cache) + { + Storage = storage; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private ICachingService Cache { get; } + + protected override async Task OnInvoke() + { + return await Cache.Get(Packing.EntityKey, Arguments.Id, async (f) => + { + return await (from e in Storage.Open() + where e.Id == Arguments.Id + select e).AsEntity(); + }); + } + } + + public sealed class SelectByEan : NullableServiceFunction + { + public SelectByEan(IStorageProvider storage, ICachingService cache) + { + Storage = storage; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private ICachingService Cache { get; } + + protected override async Task OnInvoke() + { + return await Cache.Get(Packing.EntityKey, f => string.Equals(f.Ean, Arguments.Ean, StringComparison.OrdinalIgnoreCase), async (f) => + { + return await (from e in Storage.Open() + where string.Equals(e.Ean, Arguments.Ean, StringComparison.OrdinalIgnoreCase) + select e).AsEntity(); + }); + } + } + + public sealed class Update : ServiceAction + { + public Update(IStorageProvider storage, ICachingService cache, IPackingService packingService, IEventService events) + { + Storage = storage; + Cache = cache; + PackingService = packingService; + Events = events; + } + + private IStorageProvider Storage { get; } + private ICachingService Cache { get; } + private IPackingService PackingService { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + if (SetState(await PackingService.Select(Arguments.Id)) is not Packing entity) + return; + + await Storage.Open().Update(entity.Merge(Arguments, State.Default), Arguments, async () => + { + await Cache.Remove(Packing.EntityKey, Arguments.Id); + + return SetState(await PackingService.Select(Arguments.Id)) as Packing; + }); + } + + protected override async Task OnCommitted() + { + await Cache.Remove(Packing.EntityKey, Arguments.Id); + await Events.Enqueue(this, PackingService, nameof(PackingService.Updated), Arguments); + } + } +} diff --git a/Logistics.Types/Packaging/PackingService.cs b/Logistics.Types/Packaging/PackingService.cs new file mode 100644 index 0000000..355d077 --- /dev/null +++ b/Logistics.Types/Packaging/PackingService.cs @@ -0,0 +1,56 @@ +using System.Collections.Immutable; +using Connected.Entities; +using Connected.ServiceModel; +using Connected.Services; +using Ops = Logistics.Types.Packaging.PackingOps; + +namespace Logistics.Types.Packaging; +internal sealed class PackingService : EntityService, IPackingService +{ + public PackingService(IContext context) : base(context) + { + } + + public async Task Delete(PrimaryKeyArgs args) + { + await Invoke(GetOperation(), args); + } + + public async Task Insert(InsertPackingArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task Patch(PatchArgs args) + { + if (await Select(args.Id) is not Packing entity) + return; + + await Update(args.Patch(entity)); + } + + public async Task> Query(QueryArgs? args) + { + return await Invoke(GetOperation(), args ?? QueryArgs.Default); + } + + public async Task> Query(PrimaryKeyListArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task Select(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task Select(SelectPackingArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task Update(UpdatePackingArgs args) + { + await Invoke(GetOperation(), args); + } +} diff --git a/Logistics.Types/Packaging/PackingValidation.cs b/Logistics.Types/Packaging/PackingValidation.cs new file mode 100644 index 0000000..bd68ad4 --- /dev/null +++ b/Logistics.Types/Packaging/PackingValidation.cs @@ -0,0 +1,45 @@ +using Connected.Middleware; +using Connected.Validation; + +namespace Logistics.Types.Packaging; +internal sealed class InsertPackingValidator : MiddlewareComponent, IValidator +{ + public InsertPackingValidator(IPackingService packingService) + { + PackingService = packingService; + } + + public IPackingService PackingService { get; } + + public async Task Validate(InsertPackingArgs args) + { + if (await PackingService.Select(new SelectPackingArgs + { + Ean = args.Ean + }) is not null) + { + throw ValidationExceptions.ValueExists(nameof(args.Ean), args.Ean); + } + } +} + +internal sealed class UpdatePackingValidator : MiddlewareComponent, IValidator +{ + public UpdatePackingValidator(IPackingService packingService) + { + PackingService = packingService; + } + + public IPackingService PackingService { get; } + + public async Task Validate(UpdatePackingArgs args) + { + if (await PackingService.Select(new SelectPackingArgs + { + Ean = args.Ean + }) is Packing entity && entity.Id != args.Id) + { + throw ValidationExceptions.ValueExists(nameof(args.Ean), args.Ean); + } + } +} diff --git a/Logistics.Types/Serials/Serial.cs b/Logistics.Types/Serials/Serial.cs new file mode 100644 index 0000000..2484de3 --- /dev/null +++ b/Logistics.Types/Serials/Serial.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using Connected.Annotations; +using Connected.Data; +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; + +namespace Logistics.Types.Serials; +/// +[Table(Schema = Domain.Code)] +internal sealed record Serial : ConsistentEntity, ISerial +{ + /// + /// The entity identifier which can be used in caching keys for example. + /// + public const string EntityKey = $"{Domain.Code}.{nameof(Serial)}"; + /// + [Ordinal(0), MaxLength(128)] + public string Entity { get; init; } = default!; + /// + [Ordinal(1), MaxLength(128)] + public string EntityId { get; init; } = default!; + /// + [Ordinal(2), MaxLength(128), Index(Unique = true)] + public string Value { get; init; } = default!; + /// + [Ordinal(3)] + public float Quantity { get; init; } + /// + [Ordinal(4)] + public DateTimeOffset Created { get; init; } + /// + [Ordinal(5)] + public DateTimeOffset? BestBefore { get; init; } + /// + [Ordinal(6)] + public Status Status { get; init; } = Status.Disabled; +} diff --git a/Logistics.Types/Serials/SerialOps.cs b/Logistics.Types/Serials/SerialOps.cs new file mode 100644 index 0000000..67e54c4 --- /dev/null +++ b/Logistics.Types/Serials/SerialOps.cs @@ -0,0 +1,220 @@ +using System.Collections.Immutable; +using Connected.Caching; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.Services; + +namespace Logistics.Types.Serials; +internal sealed class SerialOps +{ + /// + public sealed class Delete : ServiceAction> + { + public Delete(IStorageProvider storage, ISerialService serials, IEventService events, ICachingService cache) + { + Storage = storage; + Serials = serials; + Events = events; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private ISerialService Serials { get; } + private IEventService Events { get; } + private ICachingService Cache { get; } + + protected override async Task OnInvoke() + { + if (await Serials.Select(Arguments.Id) is not ISerial serial) + return; + /* + * Setting state of the entity enable other middleware to use the entity even after it + * is deleted. For example, IEventListener will receive the state of this operation. + */ + SetState(serial); + /* + * Perform delete. + */ + await Storage.Open().Update(Arguments.AsEntity(State.Deleted)); + } + + protected override async Task OnCommitted() + { + /* + * Remove entity from the cache. + */ + await Cache.Remove(Serial.EntityKey, Arguments.Id); + /* + * Enqueue event so event listeners can respond to the transaction. + */ + await Events.Enqueue(this, Events, nameof(ISerialService.Deleted), Arguments); + } + } + + /// + public sealed class Insert : ServiceFunction + { + public Insert(IStorageProvider storage, IEventService events) + { + Storage = storage; + Events = events; + } + + private IStorageProvider Storage { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + /* + * Perform insert and return the newly inserted id. + */ + return (await Storage.Open().Update(Arguments.AsEntity(State.New))).Id; + } + + protected override async Task OnCommitted() + { + await Events.Enqueue(this, Events, nameof(ISerialService.Inserted), Arguments); + } + } + + /// + public sealed class Query : ServiceFunction> + { + public Query(IStorageProvider storage) + { + Storage = storage; + } + + private IStorageProvider Storage { get; } + + protected override async Task> OnInvoke() + { + /* + * For non cached entities query always hits the storage. + */ + return await (from e in Storage.Open() + select e).WithArguments(Arguments).AsEntities(); + } + } + /// + public sealed class Lookup : ServiceFunction, ImmutableList> + { + public Lookup(IStorageProvider storage) + { + Storage = storage; + } + + private IStorageProvider Storage { get; } + + protected override async Task> OnInvoke() + { + return await (from e in Storage.Open() + where Arguments.IdList.Any(f => f == e.Id) + select e).AsEntities(); + } + } + /// + public sealed class Select : NullableServiceFunction, ISerial> + { + public Select(IStorageProvider storage, ICachingService cache) + { + Storage = storage; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private ICachingService Cache { get; } + + protected override async Task OnInvoke() + { + /* + * First, try to receive entity from the cache. If it doesn't exist in the cache + * load it from storage. If storage returns non null value, store it in the cache + * for subsequent calls. The entity gets remove either because of inactivity or when + * updating or deleting it. + */ + return await Cache.Get(Serial.EntityKey, Arguments.Id, async (f) => + { + /* + * Doesn't exist in the cache. Let's do the storage action. + */ + return await (from e in Storage.Open() + where e.Id == Arguments.Id + select e).AsEntity(); + }); + } + } + /// + public sealed class SelectByValue : NullableServiceFunction + { + public SelectByValue(IStorageProvider storage, ICachingService cache) + { + Storage = storage; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private ICachingService Cache { get; } + + protected override async Task OnInvoke() + { + return await Cache.Get(Serial.EntityKey, f => string.Equals(f.Value, Arguments.Value, StringComparison.OrdinalIgnoreCase), async (f) => + { + return await (from e in Storage.Open() + where string.Equals(e.Value, Arguments.Value, StringComparison.OrdinalIgnoreCase) + select e).AsEntity(); + }); + } + } + /// + public sealed class Update : ServiceAction + { + public Update(IStorageProvider storage, ICachingService cache, ISerialService packingService, IEventService events) + { + Storage = storage; + Cache = cache; + Serials = packingService; + Events = events; + } + + private IStorageProvider Storage { get; } + private ICachingService Cache { get; } + private ISerialService Serials { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + /* + * Set the state of the unchanged entity. This enable other middleware to + * calculate quantity delta, for example because they receive the state of + * this operation. Not all middleware supports this, the primary example of + * such state client is IEventListener. + */ + if (SetState(await Serials.Select(Arguments.Id)) is not Serial entity) + return; + /* + * Sinc ethis is concurrent entity we must perform retry if the concurrency fails. + */ + await Storage.Open().Update(entity.Merge(Arguments, State.Default), Arguments, async () => + { + /* + * The update failed because of concurrency. Remove the entity from the cache to ensure + * it gets loaded from the storage next time with fresh values and try again. + */ + await Cache.Remove(Serial.EntityKey, Arguments.Id); + /* + * Since the entity reloaded we must overwrite its state. + */ + return SetState(await Serials.Select(Arguments.Id)) as Serial; + }); + } + + protected override async Task OnCommitted() + { + await Cache.Remove(Serial.EntityKey, Arguments.Id); + await Events.Enqueue(this, Serials, nameof(Serials.Updated), Arguments); + } + } +} diff --git a/Logistics.Types/Serials/SerialService.cs b/Logistics.Types/Serials/SerialService.cs new file mode 100644 index 0000000..ccae6e5 --- /dev/null +++ b/Logistics.Types/Serials/SerialService.cs @@ -0,0 +1,61 @@ +using System.Collections.Immutable; +using Connected.Entities; +using Connected.ServiceModel; +using Connected.Services; +using Ops = Logistics.Types.Serials.SerialOps; + +namespace Logistics.Types.Serials; +/// +internal sealed class SerialService : EntityService, ISerialService +{ + /// + /// Create a new service. + /// + /// The context which serves as a DI scope. + public SerialService(IContext context) : base(context) + { + } + /// + public async Task Delete(PrimaryKeyArgs args) + { + await Invoke(GetOperation(), args); + } + /// + public async Task Insert(InsertSerialArgs args) + { + return await Invoke(GetOperation(), args); + } + /// + public async Task> Query(QueryArgs? args) + { + return await Invoke(GetOperation(), args ?? QueryArgs.Default); + } + /// + public async Task> Query(PrimaryKeyListArgs args) + { + return await Invoke(GetOperation(), args); + } + /// + public async Task Select(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + /// + public async Task Select(SelectSerialArgs args) + { + return await Invoke(GetOperation(), args); + } + /// + public async Task Update(UpdateSerialArgs args) + { + await Invoke(GetOperation(), args); + } + /// + public async Task Patch(PatchArgs args) + { + if (await Select(args.Id) is not Serial entity) + return; + + await Update(args.Patch(entity)); + } +} diff --git a/Logistics.Types/Serials/SerialValidation.cs b/Logistics.Types/Serials/SerialValidation.cs new file mode 100644 index 0000000..b60b44c --- /dev/null +++ b/Logistics.Types/Serials/SerialValidation.cs @@ -0,0 +1,24 @@ +using Connected.Middleware; +using Connected.Validation; + +namespace Logistics.Types.Serials; +internal sealed class InsertSerialValidator : MiddlewareComponent, IValidator +{ + public InsertSerialValidator(ISerialService serials) + { + Serials = serials; + } + + public ISerialService Serials { get; } + + public async Task Validate(InsertSerialArgs args) + { + if (await Serials.Select(new SelectSerialArgs + { + Value = args.Value + }) is not null) + { + throw ValidationExceptions.ValueExists(nameof(args.Value), args.Value); + } + } +} \ No newline at end of file diff --git a/Logistics.Types/WarehouseLocations/WarehouseLocation.cs b/Logistics.Types/WarehouseLocations/WarehouseLocation.cs new file mode 100644 index 0000000..98275c2 --- /dev/null +++ b/Logistics.Types/WarehouseLocations/WarehouseLocation.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using Connected.Annotations; +using Connected.Data; +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; + +namespace Logistics.Types.WarehouseLocations; +/// +internal sealed record WarehouseLocation : ConsistentEntity, IWarehouseLocation +{ + public const string EntityKey = $"{Domain.Code}.{nameof(WarehouseLocation)}"; + + /// + [Ordinal(0)] + public int? Parent { get; init; } + /// + [Ordinal(1)] + public int Warehouse { get; init; } + /// + [Ordinal(2), MaxLength(128)] + public string Name { get; init; } = default!; + /// + [Ordinal(3), MaxLength(32), Index(Unique = true)] + public string Code { get; init; } = default!; + /// + [Ordinal(4)] + public Status Status { get; init; } + /// + [Ordinal(5)] + public int ItemCount { get; init; } +} diff --git a/Logistics.Types/WarehouseLocations/WarehouseLocationCache.cs b/Logistics.Types/WarehouseLocations/WarehouseLocationCache.cs new file mode 100644 index 0000000..75c254b --- /dev/null +++ b/Logistics.Types/WarehouseLocations/WarehouseLocationCache.cs @@ -0,0 +1,14 @@ +using Connected.Entities.Caching; +using Logistics.Types.Warehouses; + +namespace Logistics.Types.WarehouseLocations; +internal interface IWarehouseLocationCache : IEntityCacheClient { } +/// +/// Cache for the entity. +/// +internal sealed class WarehouseLocationCache : EntityCacheClient, IWarehouseLocationCache +{ + public WarehouseLocationCache(IEntityCacheContext context) : base(context, Warehouse.EntityKey) + { + } +} diff --git a/Logistics.Types/WarehouseLocations/WarehouseLocationOps.cs b/Logistics.Types/WarehouseLocations/WarehouseLocationOps.cs new file mode 100644 index 0000000..897845b --- /dev/null +++ b/Logistics.Types/WarehouseLocations/WarehouseLocationOps.cs @@ -0,0 +1,202 @@ +using System.Collections.Immutable; +using Connected.Caching; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.Services; + +namespace Logistics.Types.WarehouseLocations; +internal sealed class WarehouseLocationOps +{ + public sealed class Delete : ServiceAction> + { + public Delete(IStorageProvider storage, IWarehouseLocationService locations, IEventService events, ICachingService cache) + { + Storage = storage; + Locations = locations; + Events = events; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private IWarehouseLocationService Locations { get; } + private IEventService Events { get; } + private ICachingService Cache { get; } + + protected override async Task OnInvoke() + { + if (await Locations.Select(Arguments.Id) is not IWarehouseLocation entity) + return; + + SetState(entity); + + await Storage.Open().Update(Arguments.AsEntity(State.Deleted)); + } + + protected override async Task OnCommitted() + { + await Cache.Remove(WarehouseLocation.EntityKey, Arguments.Id); + await Events.Enqueue(this, Events, nameof(IWarehouseLocationService.Deleted), Arguments); + } + } + + public sealed class Insert : ServiceFunction + { + public Insert(IStorageProvider storage, IEventService events) + { + Storage = storage; + Events = events; + } + + private IStorageProvider Storage { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + return (await Storage.Open().Update(Arguments.AsEntity(State.New))).Id; + } + + protected override async Task OnCommitted() + { + await Events.Enqueue(this, Events, nameof(IWarehouseLocationService.Inserted), Arguments); + } + } + + public sealed class Query : ServiceFunction> + { + public Query(IWarehouseLocationCache locations) + { + Locations = locations; + } + + public IWarehouseLocationCache Locations { get; } + + protected override async Task> OnInvoke() + { + return await (from e in Locations + select e).WithArguments(Arguments).AsEntities(); + } + } + + public sealed class QueryByWarehouse : ServiceFunction> + { + public QueryByWarehouse(IWarehouseLocationCache locations) + { + Locations = locations; + } + + public IWarehouseLocationCache Locations { get; } + + protected override async Task> OnInvoke() + { + return await (from e in Locations + where e.Warehouse == Arguments.Warehouse + select e).WithArguments(Arguments).AsEntities(); + } + } + + public sealed class QueryChildren : ServiceFunction> + { + public QueryChildren(IWarehouseLocationCache locations) + { + Locations = locations; + } + + public IWarehouseLocationCache Locations { get; } + + protected override async Task> OnInvoke() + { + return await (from e in Locations + where e.Warehouse == Arguments.Warehouse + && (Arguments.Parent is null || e.Parent == Arguments.Parent) + select e).WithArguments(Arguments).AsEntities(); + } + } + + public sealed class Lookup : ServiceFunction, ImmutableList> + { + public Lookup(IWarehouseLocationCache locations) + { + Locations = locations; + } + + public IWarehouseLocationCache Locations { get; } + + protected override async Task> OnInvoke() + { + return await (from e in Locations + where Arguments.IdList.Any(f => f == e.Id) + select e).AsEntities(); + } + } + + public sealed class Select : NullableServiceFunction, IWarehouseLocation> + { + public Select(IWarehouseLocationCache locations) + { + Locations = locations; + } + + private IWarehouseLocationCache Locations { get; } + + protected override async Task OnInvoke() + { + return await (from e in Locations + where e.Id == Arguments.Id + select e).AsEntity(); + } + } + + public sealed class SelectByCode : NullableServiceFunction + { + public SelectByCode(IWarehouseLocationCache locations) + { + Locations = locations; + } + + private IWarehouseLocationCache Locations { get; } + + protected override async Task OnInvoke() + { + return await (from e in Locations + where string.Equals(e.Code, Arguments.Code, StringComparison.OrdinalIgnoreCase) + select e).AsEntity(); + } + } + + public sealed class Update : ServiceAction + { + public Update(IStorageProvider storage, IWarehouseLocationCache cache, IWarehouseLocationService locations, IEventService events) + { + Storage = storage; + Cache = cache; + Locations = locations; + Events = events; + } + + private IStorageProvider Storage { get; } + private IWarehouseLocationCache Cache { get; } + private IWarehouseLocationService Locations { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + if (SetState(await Locations.Select(Arguments.Id)) is not WarehouseLocation entity) + return; + + await Storage.Open().Update(entity.Merge(Arguments, State.Default), Arguments, async () => + { + await Cache.Refresh(Arguments.Id); + + return SetState(await Locations.Select(Arguments.Id)) as WarehouseLocation; + }); + } + + protected override async Task OnCommitted() + { + await Cache.Refresh(Arguments.Id); + await Events.Enqueue(this, Locations, nameof(Locations.Updated), Arguments); + } + } +} diff --git a/Logistics.Types/WarehouseLocations/WarehouseLocationProtection.cs b/Logistics.Types/WarehouseLocations/WarehouseLocationProtection.cs new file mode 100644 index 0000000..99fa5b3 --- /dev/null +++ b/Logistics.Types/WarehouseLocations/WarehouseLocationProtection.cs @@ -0,0 +1,29 @@ +using Connected.Data.DataProtection; +using Connected.Data.EntityProtection; +using Connected.Entities; +using Connected.Middleware; +using Connected.Validation; + +namespace Logistics.Types.WarehouseLocations; +internal class WarehouseLocationProtection : MiddlewareComponent, IEntityProtector +{ + public WarehouseLocationProtection(IWarehouseLocationCache cache) + { + Cache = cache; + } + + public IWarehouseLocationCache Cache { get; } + + public async Task Invoke(EntityProtectionArgs args) + { + if (args.State != State.Deleted) + return; + /* + * We are protecting the children because warehouse locations support nesting entities. + */ + if (args.Entity.ItemCount > 0) + throw ValidationExceptions.ReferenceExists(args.Entity.GetType(), args.Entity.Id); + + await Task.CompletedTask; + } +} diff --git a/Logistics.Types/WarehouseLocations/WarehouseLocationService.cs b/Logistics.Types/WarehouseLocations/WarehouseLocationService.cs new file mode 100644 index 0000000..d0412e6 --- /dev/null +++ b/Logistics.Types/WarehouseLocations/WarehouseLocationService.cs @@ -0,0 +1,66 @@ +using System.Collections.Immutable; +using Connected.Entities; +using Connected.ServiceModel; +using Connected.Services; +using Ops = Logistics.Types.WarehouseLocations.WarehouseLocationOps; + +namespace Logistics.Types.WarehouseLocations; +internal sealed class WarehouseLocationService : EntityService, IWarehouseLocationService +{ + public WarehouseLocationService(IContext context) : base(context) + { + } + + public async Task Delete(PrimaryKeyArgs args) + { + await Invoke(GetOperation(), args); + } + + public Task Insert(InsertWarehouseLocationArgs args) + { + return Invoke(GetOperation(), args); + } + + public async Task Patch(PatchArgs args) + { + if (await Select(args.Id) is not WarehouseLocation entity) + return; + + await Update(args.Patch(entity)); + } + + public async Task> Query(QueryArgs? args) + { + return await Invoke(GetOperation(), args ?? QueryArgs.Default); + } + + public async Task> Query(QueryWarehouseLocationArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task> Query(PrimaryKeyListArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task> QueryChildren(QueryWarehouseLocationChildrenArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task Select(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task Select(SelectWarehouseLocationArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task Update(UpdateWarehouseLocationArgs args) + { + await Invoke(GetOperation(), args); + } +} diff --git a/Logistics.Types/WarehouseLocations/WarehouseLocationValidation.cs b/Logistics.Types/WarehouseLocations/WarehouseLocationValidation.cs new file mode 100644 index 0000000..1eb5274 --- /dev/null +++ b/Logistics.Types/WarehouseLocations/WarehouseLocationValidation.cs @@ -0,0 +1,98 @@ +using Connected.Entities; +using Connected.Middleware; +using Connected.Validation; +using Logistics.Types.Warehouses; + +namespace Logistics.Types.WarehouseLocations; +internal sealed class InsertWarehouseLocationValidation : MiddlewareComponent, IValidator +{ + public InsertWarehouseLocationValidation(IWarehouseLocationCache cache, IWarehouseService warehouses) + { + Cache = cache; + Warehouses = warehouses; + } + + private IWarehouseLocationCache Cache { get; } + private IWarehouseService Warehouses { get; } + + public async Task Validate(InsertWarehouseLocationArgs args) + { + /* + * Warehouse existence + */ + if (await Warehouses.Select(args.Warehouse) is null) + throw ValidationExceptions.NotFound(nameof(args.Warehouse), args.Warehouse); + /* + * Code is unique + */ + if (await (from e in Cache where string.Equals(e.Code, args.Code, StringComparison.OrdinalIgnoreCase) select e).AsEntity() is not null) + throw ValidationExceptions.ValueExists(nameof(args.Code), args.Code); + /* + * Check parent + */ + if (args.Parent is not null) + { + var parent = await (from e in Cache where e.Id == args.Parent select e).AsEntity(); + + if (parent is null) + throw ValidationExceptions.NotFound(nameof(args.Parent), args.Parent); + /* + * Parent and entity must be in the same warehouse. + */ + if (parent.Warehouse != args.Warehouse) + throw ValidationExceptions.Mismatch(nameof(args.Warehouse), args.Warehouse); + } + } +} + +internal sealed class UpdateWarehouseLocationValidation : MiddlewareComponent, IValidator +{ + public UpdateWarehouseLocationValidation(IWarehouseLocationCache cache) + { + Cache = cache; + } + + private IWarehouseLocationCache Cache { get; } + + public async Task Validate(UpdateWarehouseLocationArgs args) + { + if (await (from e in Cache where e.Id == args.Id select e).AsEntity() is not WarehouseLocation entity) + return; + /* + * Code is unique + */ + if (await (from e in Cache where string.Equals(e.Code, args.Code, StringComparison.OrdinalIgnoreCase) && e.Id != args.Id select e).AsEntity() is not null) + throw ValidationExceptions.ValueExists(nameof(args.Code), args.Code); + + /* + * Check parent + */ + if (args.Parent is not null) + { + var parent = await (from e in Cache where e.Id == args.Parent select e).AsEntity(); + + if (parent is null) + throw ValidationExceptions.NotFound(nameof(args.Parent), args.Parent); + /* + * Parent and entity must be in the same warehouse. + */ + if (parent.Warehouse != entity.Warehouse) + throw ValidationExceptions.Mismatch(nameof(args.Parent), args.Parent); + /* + * nesting under self + */ + if (parent.Id == args.Id) + throw ValidationExceptions.Mismatch(nameof(args.Parent), args.Parent); + + var currentParent = await (from e in Cache where e.Id == parent.Parent select e).AsEntity(); + + while (currentParent is not null) + { + if (currentParent.Id == args.Id) + throw ValidationExceptions.Mismatch(nameof(args.Parent), args.Parent); + + currentParent = await (from e in Cache where e.Id == currentParent.Parent select e).AsEntity(); + } + } + } +} diff --git a/Logistics.Types/Warehouses/Warehouse.cs b/Logistics.Types/Warehouses/Warehouse.cs new file mode 100644 index 0000000..0fcaed5 --- /dev/null +++ b/Logistics.Types/Warehouses/Warehouse.cs @@ -0,0 +1,22 @@ +using Connected.Annotations; +using Connected.Data; +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; + +namespace Logistics.Types.Warehouses; +/// +[Table(Schema = Domain.Code)] +internal sealed record Warehouse : ConsistentEntity, IWarehouse +{ + public const string EntityKey = $"{Domain.Code}.{nameof(Warehouse)}"; + + /// + [Ordinal(0), Length(128)] + public string Name { get; init; } = default!; + /// + [Ordinal(1), Length(32), Index(Unique = true)] + public string Code { get; init; } = default!; + /// + [Ordinal(2)] + public Status Status { get; init; } +} diff --git a/Logistics.Types/Warehouses/WarehouseCache.cs b/Logistics.Types/Warehouses/WarehouseCache.cs new file mode 100644 index 0000000..d260ef0 --- /dev/null +++ b/Logistics.Types/Warehouses/WarehouseCache.cs @@ -0,0 +1,14 @@ +using Connected.Entities.Caching; + +namespace Logistics.Types.Warehouses; + +internal interface IWarehouseCache : IEntityCacheClient { } +/// +/// Cache for the entity. +/// +internal sealed class WarehouseCache : EntityCacheClient, IWarehouseCache +{ + public WarehouseCache(IEntityCacheContext context) : base(context, Warehouse.EntityKey) + { + } +} diff --git a/Logistics.Types/Warehouses/WarehouseOps.cs b/Logistics.Types/Warehouses/WarehouseOps.cs new file mode 100644 index 0000000..c06b6f6 --- /dev/null +++ b/Logistics.Types/Warehouses/WarehouseOps.cs @@ -0,0 +1,150 @@ +using System.Collections.Immutable; +using Connected.Caching; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.Services; + +namespace Logistics.Types.Warehouses; +internal sealed class WarehouseOps +{ + public sealed class Delete : ServiceAction> + { + public Delete(IStorageProvider storage, IWarehouseService warehouses, IEventService events, ICachingService cache) + { + Storage = storage; + Warehouses = warehouses; + Events = events; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private IWarehouseService Warehouses { get; } + private IEventService Events { get; } + private ICachingService Cache { get; } + + protected override async Task OnInvoke() + { + if (await Warehouses.Select(Arguments.Id) is not IWarehouse warehouse) + return; + + SetState(warehouse); + + await Storage.Open().Update(Arguments.AsEntity(State.Deleted)); + } + + protected override async Task OnCommitted() + { + await Cache.Remove(Warehouse.EntityKey, Arguments.Id); + await Events.Enqueue(this, Events, nameof(IWarehouseService.Deleted), Arguments); + } + } + + public sealed class Insert : ServiceFunction + { + public Insert(IStorageProvider storage, IEventService events) + { + Storage = storage; + Events = events; + } + + private IStorageProvider Storage { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + return (await Storage.Open().Update(Arguments.AsEntity(State.New))).Id; + } + + protected override async Task OnCommitted() + { + await Events.Enqueue(this, Events, nameof(IWarehouseService.Inserted), Arguments); + } + } + + public sealed class Query : ServiceFunction> + { + public Query(IWarehouseCache warehouses) + { + Warehouses = warehouses; + } + + public IWarehouseCache Warehouses { get; } + + protected override async Task> OnInvoke() + { + return await (from e in Warehouses + select e).WithArguments(Arguments).AsEntities(); + } + } + + public sealed class Lookup : ServiceFunction, ImmutableList> + { + public Lookup(IWarehouseCache warehouses) + { + Warehouses = warehouses; + } + + public IWarehouseCache Warehouses { get; } + + protected override async Task> OnInvoke() + { + return await (from e in Warehouses + where Arguments.IdList.Any(f => f == e.Id) + select e).AsEntities(); + } + } + + public sealed class Select : NullableServiceFunction, IWarehouse> + { + public Select(IWarehouseCache warehouses) + { + Warehouses = warehouses; + } + + private IWarehouseCache Warehouses { get; } + + protected override async Task OnInvoke() + { + return await (from e in Warehouses + where e.Id == Arguments.Id + select e).AsEntity(); + } + } + + public sealed class Update : ServiceAction + { + public Update(IStorageProvider storage, IWarehouseCache cache, IWarehouseService warehouses, IEventService events) + { + Storage = storage; + Cache = cache; + Warehouses = warehouses; + Events = events; + } + + private IStorageProvider Storage { get; } + private IWarehouseCache Cache { get; } + private IWarehouseService Warehouses { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + if (SetState(await Warehouses.Select(Arguments.Id)) is not Warehouse entity) + return; + + await Storage.Open().Update(entity.Merge(Arguments, State.Default), Arguments, async () => + { + await Cache.Refresh(Arguments.Id); + + return SetState(await Warehouses.Select(Arguments.Id)) as Warehouse; + }); + } + + protected override async Task OnCommitted() + { + await Cache.Refresh(Arguments.Id); + await Events.Enqueue(this, Warehouses, nameof(Warehouses.Updated), Arguments); + } + } +} diff --git a/Logistics.Types/Warehouses/WarehouseService.cs b/Logistics.Types/Warehouses/WarehouseService.cs new file mode 100644 index 0000000..b27c711 --- /dev/null +++ b/Logistics.Types/Warehouses/WarehouseService.cs @@ -0,0 +1,56 @@ +using System.Collections.Immutable; +using Connected.Entities; +using Connected.ServiceModel; +using Connected.Services; +using Ops = Logistics.Types.Warehouses.WarehouseOps; + +namespace Logistics.Types.Warehouses; +/// +internal sealed class WarehouseService : EntityService, IWarehouseService +{ + /// + /// Creates a new instance. + /// + /// The context acting as a DI scope. + public WarehouseService(IContext context) : base(context) + { + } + /// + public async Task Delete(PrimaryKeyArgs args) + { + await Invoke(GetOperation(), args); + } + /// + public async Task Insert(InsertWarehouseArgs args) + { + return await Invoke(GetOperation(), args); + } + /// + public async Task> Query(QueryArgs? args) + { + return await Invoke(GetOperation(), args ?? QueryArgs.Default); + } + /// + public async Task> Query(PrimaryKeyListArgs args) + { + return await Invoke(GetOperation(), args); + } + /// + public async Task Select(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + /// + public async Task Update(UpdateWarehouseArgs args) + { + await Invoke(GetOperation(), args); + } + /// + public async Task Patch(PatchArgs args) + { + if (await Select(args.Id) is not Warehouse entity) + return; + + await Update(args.Patch(entity)); + } +} diff --git a/Logistics.Types/Warehouses/WarehouseValidation.cs b/Logistics.Types/Warehouses/WarehouseValidation.cs new file mode 100644 index 0000000..00c9d41 --- /dev/null +++ b/Logistics.Types/Warehouses/WarehouseValidation.cs @@ -0,0 +1,36 @@ +using Connected.Entities; +using Connected.Middleware; +using Connected.Validation; + +namespace Logistics.Types.Warehouses; +internal sealed class InsertWarehouseValidator : MiddlewareComponent, IValidator +{ + public InsertWarehouseValidator(IWarehouseCache cache) + { + Cache = cache; + } + + private IWarehouseCache Cache { get; } + + public async Task Validate(InsertWarehouseArgs args) + { + if (await (from e in Cache where string.Equals(e.Code, args.Code, StringComparison.OrdinalIgnoreCase) select e).AsEntity() is not null) + throw ValidationExceptions.ValueExists(nameof(args.Code), args.Code); + } +} + +internal sealed class UpdateWarehouseValidator : MiddlewareComponent, IValidator +{ + public UpdateWarehouseValidator(IWarehouseCache cache) + { + Cache = cache; + } + + private IWarehouseCache Cache { get; } + + public async Task Validate(UpdateWarehouseArgs args) + { + if (await (from e in Cache where string.Equals(e.Code, args.Code, StringComparison.OrdinalIgnoreCase) && e.Id == args.Id select e).AsEntity() is not null) + throw ValidationExceptions.ValueExists(nameof(args.Code), args.Code); + } +} diff --git a/Logistics.sln b/Logistics.sln new file mode 100644 index 0000000..029d5dd --- /dev/null +++ b/Logistics.sln @@ -0,0 +1,149 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.32916.344 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logistics.Types.Model", "Logistics.Types.Model\Logistics.Types.Model.csproj", "{A673CACA-8A88-4AE1-B6C4-E31CED477981}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logistics.Documents.Model", "Logistics.Documents.Model\Logistics.Documents.Model.csproj", "{BCB56C1C-253F-4BB8-88A2-06653ED3E232}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Dependencies", "Dependencies", "{04C7CB0E-A6E1-4CCC-AF76-B199137278B7}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logistics.Processes.Receive", "Logistics.Processes.Receive\Logistics.Processes.Receive.csproj", "{9EB45FF3-4910-4FEA-9553-97410C350AB9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common", "..\Common\Common\Common.csproj", "{A4BF05CA-F790-4296-8647-18CEB1801637}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Model", "..\Common\Common.Model\Common.Model.csproj", "{652D8B33-1485-43DD-9BDA-EE8103C2E0C8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected", "..\Connected\Connected\Connected.csproj", "{6D40EEF9-9DB8-4755-B307-485523B1E3EF}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logistics.Documents", "Logistics.Documents\Logistics.Documents.csproj", "{D808FC0D-355C-41D9-8560-DF99A963CA6A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Hosting", "..\Framework\Connected.Hosting\Connected.Hosting.csproj", "{1F47C7C9-45F7-469B-9B96-442DE2B3B0C5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Runtime", "..\Framework\Connected.Runtime\Connected.Runtime.csproj", "{AB5E17FA-335D-4495-B7C7-4E4F1D6E2AE4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Data", "..\Framework\Connected.Data\Connected.Data.csproj", "{BF03B6D3-849F-4095-9AB1-3D493075A8AC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Services", "..\Framework\Connected.Services\Connected.Services.csproj", "{F0E8C988-991B-4E2C-B1E1-0917685B7F70}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Middleware", "..\Framework\Connected.Middleware\Connected.Middleware.csproj", "{B8008838-C74C-4CD9-9248-B04005037EC9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Validation", "..\Framework\Connected.Validation\Connected.Validation.csproj", "{7BECE4B3-14A8-4CC1-BD96-E830EB453941}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Contacts.Types.Model", "..\Customers\Contacts.Types.Model\Contacts.Types.Model.csproj", "{67EF282C-7CDE-4D85-A628-001306629762}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.ServiceModel", "..\Framework.ServiceModel\Connected.ServiceModel\Connected.ServiceModel.csproj", "{6332E9A7-9EE8-4979-94AF-D297E4BBEA26}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logistics.Stock.Model", "Logistics.Stock.Model\Logistics.Stock.Model.csproj", "{415704FB-3DCA-41F9-A2D1-0FB72D346532}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Entities", "..\Framework\Connected.Entities\Connected.Entities.csproj", "{0EA01172-88E4-4B29-B553-B71F4EAEF844}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logistics.Stock", "Logistics.Stock\Logistics.Stock.csproj", "{8C4B2009-1DF6-4358-ABED-64E1F6076CCD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logistics.Types", "Logistics.Types\Logistics.Types.csproj", "{A7B4FE4C-9D04-41FC-B036-78FAB6B15899}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A673CACA-8A88-4AE1-B6C4-E31CED477981}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A673CACA-8A88-4AE1-B6C4-E31CED477981}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A673CACA-8A88-4AE1-B6C4-E31CED477981}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A673CACA-8A88-4AE1-B6C4-E31CED477981}.Release|Any CPU.Build.0 = Release|Any CPU + {BCB56C1C-253F-4BB8-88A2-06653ED3E232}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BCB56C1C-253F-4BB8-88A2-06653ED3E232}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BCB56C1C-253F-4BB8-88A2-06653ED3E232}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BCB56C1C-253F-4BB8-88A2-06653ED3E232}.Release|Any CPU.Build.0 = Release|Any CPU + {9EB45FF3-4910-4FEA-9553-97410C350AB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9EB45FF3-4910-4FEA-9553-97410C350AB9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9EB45FF3-4910-4FEA-9553-97410C350AB9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9EB45FF3-4910-4FEA-9553-97410C350AB9}.Release|Any CPU.Build.0 = Release|Any CPU + {A4BF05CA-F790-4296-8647-18CEB1801637}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4BF05CA-F790-4296-8647-18CEB1801637}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4BF05CA-F790-4296-8647-18CEB1801637}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4BF05CA-F790-4296-8647-18CEB1801637}.Release|Any CPU.Build.0 = Release|Any CPU + {652D8B33-1485-43DD-9BDA-EE8103C2E0C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {652D8B33-1485-43DD-9BDA-EE8103C2E0C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {652D8B33-1485-43DD-9BDA-EE8103C2E0C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {652D8B33-1485-43DD-9BDA-EE8103C2E0C8}.Release|Any CPU.Build.0 = Release|Any CPU + {6D40EEF9-9DB8-4755-B307-485523B1E3EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6D40EEF9-9DB8-4755-B307-485523B1E3EF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6D40EEF9-9DB8-4755-B307-485523B1E3EF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6D40EEF9-9DB8-4755-B307-485523B1E3EF}.Release|Any CPU.Build.0 = Release|Any CPU + {D808FC0D-355C-41D9-8560-DF99A963CA6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D808FC0D-355C-41D9-8560-DF99A963CA6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D808FC0D-355C-41D9-8560-DF99A963CA6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D808FC0D-355C-41D9-8560-DF99A963CA6A}.Release|Any CPU.Build.0 = Release|Any CPU + {1F47C7C9-45F7-469B-9B96-442DE2B3B0C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F47C7C9-45F7-469B-9B96-442DE2B3B0C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F47C7C9-45F7-469B-9B96-442DE2B3B0C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F47C7C9-45F7-469B-9B96-442DE2B3B0C5}.Release|Any CPU.Build.0 = Release|Any CPU + {AB5E17FA-335D-4495-B7C7-4E4F1D6E2AE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB5E17FA-335D-4495-B7C7-4E4F1D6E2AE4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB5E17FA-335D-4495-B7C7-4E4F1D6E2AE4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB5E17FA-335D-4495-B7C7-4E4F1D6E2AE4}.Release|Any CPU.Build.0 = Release|Any CPU + {BF03B6D3-849F-4095-9AB1-3D493075A8AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF03B6D3-849F-4095-9AB1-3D493075A8AC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF03B6D3-849F-4095-9AB1-3D493075A8AC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF03B6D3-849F-4095-9AB1-3D493075A8AC}.Release|Any CPU.Build.0 = Release|Any CPU + {F0E8C988-991B-4E2C-B1E1-0917685B7F70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0E8C988-991B-4E2C-B1E1-0917685B7F70}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0E8C988-991B-4E2C-B1E1-0917685B7F70}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0E8C988-991B-4E2C-B1E1-0917685B7F70}.Release|Any CPU.Build.0 = Release|Any CPU + {B8008838-C74C-4CD9-9248-B04005037EC9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8008838-C74C-4CD9-9248-B04005037EC9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8008838-C74C-4CD9-9248-B04005037EC9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8008838-C74C-4CD9-9248-B04005037EC9}.Release|Any CPU.Build.0 = Release|Any CPU + {7BECE4B3-14A8-4CC1-BD96-E830EB453941}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7BECE4B3-14A8-4CC1-BD96-E830EB453941}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7BECE4B3-14A8-4CC1-BD96-E830EB453941}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7BECE4B3-14A8-4CC1-BD96-E830EB453941}.Release|Any CPU.Build.0 = Release|Any CPU + {67EF282C-7CDE-4D85-A628-001306629762}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {67EF282C-7CDE-4D85-A628-001306629762}.Debug|Any CPU.Build.0 = Debug|Any CPU + {67EF282C-7CDE-4D85-A628-001306629762}.Release|Any CPU.ActiveCfg = Release|Any CPU + {67EF282C-7CDE-4D85-A628-001306629762}.Release|Any CPU.Build.0 = Release|Any CPU + {6332E9A7-9EE8-4979-94AF-D297E4BBEA26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6332E9A7-9EE8-4979-94AF-D297E4BBEA26}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6332E9A7-9EE8-4979-94AF-D297E4BBEA26}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6332E9A7-9EE8-4979-94AF-D297E4BBEA26}.Release|Any CPU.Build.0 = Release|Any CPU + {415704FB-3DCA-41F9-A2D1-0FB72D346532}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {415704FB-3DCA-41F9-A2D1-0FB72D346532}.Debug|Any CPU.Build.0 = Debug|Any CPU + {415704FB-3DCA-41F9-A2D1-0FB72D346532}.Release|Any CPU.ActiveCfg = Release|Any CPU + {415704FB-3DCA-41F9-A2D1-0FB72D346532}.Release|Any CPU.Build.0 = Release|Any CPU + {0EA01172-88E4-4B29-B553-B71F4EAEF844}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0EA01172-88E4-4B29-B553-B71F4EAEF844}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0EA01172-88E4-4B29-B553-B71F4EAEF844}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0EA01172-88E4-4B29-B553-B71F4EAEF844}.Release|Any CPU.Build.0 = Release|Any CPU + {8C4B2009-1DF6-4358-ABED-64E1F6076CCD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C4B2009-1DF6-4358-ABED-64E1F6076CCD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C4B2009-1DF6-4358-ABED-64E1F6076CCD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C4B2009-1DF6-4358-ABED-64E1F6076CCD}.Release|Any CPU.Build.0 = Release|Any CPU + {A7B4FE4C-9D04-41FC-B036-78FAB6B15899}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7B4FE4C-9D04-41FC-B036-78FAB6B15899}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7B4FE4C-9D04-41FC-B036-78FAB6B15899}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7B4FE4C-9D04-41FC-B036-78FAB6B15899}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {A4BF05CA-F790-4296-8647-18CEB1801637} = {04C7CB0E-A6E1-4CCC-AF76-B199137278B7} + {652D8B33-1485-43DD-9BDA-EE8103C2E0C8} = {04C7CB0E-A6E1-4CCC-AF76-B199137278B7} + {6D40EEF9-9DB8-4755-B307-485523B1E3EF} = {04C7CB0E-A6E1-4CCC-AF76-B199137278B7} + {1F47C7C9-45F7-469B-9B96-442DE2B3B0C5} = {04C7CB0E-A6E1-4CCC-AF76-B199137278B7} + {AB5E17FA-335D-4495-B7C7-4E4F1D6E2AE4} = {04C7CB0E-A6E1-4CCC-AF76-B199137278B7} + {BF03B6D3-849F-4095-9AB1-3D493075A8AC} = {04C7CB0E-A6E1-4CCC-AF76-B199137278B7} + {F0E8C988-991B-4E2C-B1E1-0917685B7F70} = {04C7CB0E-A6E1-4CCC-AF76-B199137278B7} + {B8008838-C74C-4CD9-9248-B04005037EC9} = {04C7CB0E-A6E1-4CCC-AF76-B199137278B7} + {7BECE4B3-14A8-4CC1-BD96-E830EB453941} = {04C7CB0E-A6E1-4CCC-AF76-B199137278B7} + {67EF282C-7CDE-4D85-A628-001306629762} = {04C7CB0E-A6E1-4CCC-AF76-B199137278B7} + {6332E9A7-9EE8-4979-94AF-D297E4BBEA26} = {04C7CB0E-A6E1-4CCC-AF76-B199137278B7} + {0EA01172-88E4-4B29-B553-B71F4EAEF844} = {04C7CB0E-A6E1-4CCC-AF76-B199137278B7} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {FC558A1B-D7D9-4869-9589-1877A5E64720} + EndGlobalSection +EndGlobal