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