OrderDraft feature

This commit is contained in:
Adam 2026-06-23 23:33:18 +02:00
parent bad80dd660
commit 10f975ce72
11 changed files with 186 additions and 67 deletions

View File

@ -0,0 +1,24 @@
using AyCode.Core.Serializers.Attributes;
using AyCode.Core.Serializers.Toons;
using LinqToDB.Mapping;
using Mango.Nop.Core.Entities;
namespace FruitBank.Common.Entities;
[AcBinarySerializable(false, true, false, true, false, false)]
[Table(Name = FruitBankConstClient.ExternalUserPartnerMapDbTableName)]
[System.ComponentModel.DataAnnotations.Schema.Table(FruitBankConstClient.ExternalUserPartnerMapDbTableName)]
[ToonDescription("Maps an external sender identity to a NopCommerce customer (partner)", Purpose = "One record per (ExternalUsername, CustomerId) pair. A single partner may have multiple mappings (e.g. one for Viber, one for email). Lookup is by ExternalUsername; first match wins.")]
public sealed class ExternalUserPartnerMapping : MgEntityBase
{
[ToonDescription(Purpose = "The external identifier as it arrives in the API call (Viber user ID, email address, etc.). Indexed for fast lookup.")]
public string ExternalUsername { get; set; } = string.Empty;
[ToonDescription(Purpose = "FK to the NopCommerce Customer this external identity maps to.")]
public int CustomerId { get; set; }
[ToonDescription(Purpose = "Admin who created this mapping.")]
public int CreatedByAdminId { get; set; }
public DateTime CreatedOnUtc { get; set; }
}

View File

@ -0,0 +1,41 @@
using AyCode.Core.Serializers.Attributes;
using AyCode.Core.Serializers.Toons;
using FruitBank.Common.Enums;
using LinqToDB.Mapping;
using Mango.Nop.Core.Entities;
namespace FruitBank.Common.Entities;
[AcBinarySerializable(false, true, false, true, false, false)]
[Table(Name = FruitBankConstClient.OrderDraftDbTableName)]
[System.ComponentModel.DataAnnotations.Schema.Table(FruitBankConstClient.OrderDraftDbTableName)]
[ToonDescription("AI-parsed order draft created from an incoming text message", Purpose = "Temporary order intent parsed from a free-text message (Viber, email, dictation). Contains the original raw message and AI-resolved line items. An admin must review and approve within 24 hours; drafts are auto-expired after 14 days. Approval converts the draft into a PreOrder or Order depending on ParsedDeliveryDate.")]
public sealed class OrderDraft : MgEntityBase
{
[ToonDescription(Purpose = "The external identifier of the sender (e.g. Viber user ID, email address). Used to look up the mapped NopCommerce CustomerId via ExternalUserPartnerMap.")]
public string ExternalUsername { get; set; } = string.Empty;
[ToonDescription(Purpose = "FK to the NopCommerce Customer resolved from ExternalUsername. Null if no mapping exists yet — admin must assign before approval.")]
public int? CustomerId { get; set; }
[ToonDescription(Purpose = "The full original message text as received. Stored verbatim and shown to the admin during review.")]
public string RawMessageText { get; set; } = string.Empty;
[ToonDescription(Purpose = "Delivery date parsed from the message by AI. Drives IsPreorder logic on each item (>4 days ahead = preorder).")]
public DateTime ParsedDeliveryDate { get; set; }
[ToonDescription(Purpose = "Draft lifecycle: Pending / Approved / Rejected / Expired.",
BusinessRule = "Pending on creation. Admin sets Approved or Rejected. Background job sets Expired after 14 days if still Pending.")]
public OrderDraftStatus Status { get; set; }
[ToonDescription(Purpose = "Admin who resolved (approved or rejected) this draft. Null until resolved.")]
public int? ResolvedByAdminId { get; set; }
[ToonDescription(Purpose = "Optional admin note on rejection.")]
public string? AdminNote { get; set; }
public DateTime CreatedOnUtc { get; set; }
public DateTime? ResolvedOnUtc { get; set; }
public List<OrderDraftItem> Items { get; set; } = [];
}

View File

@ -0,0 +1,38 @@
using AyCode.Core.Serializers.Attributes;
using AyCode.Core.Serializers.Toons;
using LinqToDB.Mapping;
using Mango.Nop.Core.Entities;
namespace FruitBank.Common.Entities;
[AcBinarySerializable(false, true, false, true, false, false)]
[Table(Name = FruitBankConstClient.OrderDraftItemDbTableName)]
[System.ComponentModel.DataAnnotations.Schema.Table(FruitBankConstClient.OrderDraftItemDbTableName)]
[ToonDescription("Single product line of an AI-parsed order draft", Purpose = "One requested product line within an OrderDraft. The AI resolves the raw product text to an ordered list of candidate NopCommerce products; the admin selects one before approval.")]
public sealed class OrderDraftItem : MgEntityBase
{
[ToonDescription(Purpose = "FK to the parent OrderDraft.")]
public int DraftId { get; set; }
[ToonDescription(Purpose = "The raw product text extracted from the message, as the sender wrote it (e.g. 'piros kalif', '20 rekesz citrom').")]
public string RawProductText { get; set; } = string.Empty;
[ToonDescription(Purpose = "The canonical product name as resolved by AI from RawProductText (e.g. 'Kaliforniai paprika piros'). For display purposes.")]
public string AiResolvedProductName { get; set; } = string.Empty;
[ToonDescription(Purpose = "Ordered JSON array of candidate NopCommerce Product IDs, best match first. Populated by AI + historical ProductTextMapping weighting.")]
public string SuggestedProductIdsJson { get; set; } = "[]";
[ToonDescription(Purpose = "The Product ID selected by the admin during review. Null until admin resolves this item.")]
public int? SelectedProductId { get; set; }
[ToonDescription(Purpose = "Quantity as parsed from the message text.", Constraints = "positive")]
public int RequestedQuantity { get; set; }
[ToonDescription(Purpose = "Whether this item should become a preorder item (true) or a direct order item (false). Computed from ParsedDeliveryDate of the parent draft: >4 days ahead = true.",
BusinessRule = "Set at creation time from parent draft's ParsedDeliveryDate. Not editable by admin.")]
public bool IsPreorder { get; set; }
[ToonDescription(Purpose = "Display order within the draft, matching the original message sequence.")]
public int SortOrder { get; set; }
}

View File

@ -0,0 +1,27 @@
using AyCode.Core.Serializers.Attributes;
using AyCode.Core.Serializers.Toons;
using LinqToDB.Mapping;
using Mango.Nop.Core.Entities;
namespace FruitBank.Common.Entities;
[AcBinarySerializable(false, true, false, true, false, false)]
[Table(Name = FruitBankConstClient.ProductTextMappingDbTableName)]
[System.ComponentModel.DataAnnotations.Schema.Table(FruitBankConstClient.ProductTextMappingDbTableName)]
[ToonDescription("Historical record of admin product selections from free-text inputs", Purpose = "Every time an admin approves an OrderDraftItem and selects a product, a record is written here. Used to weight future AI suggestions: if the same raw text recently resolved to a given product, that product ranks first in SuggestedProductIds.")]
public sealed class ProductTextMapping : MgEntityBase
{
[ToonDescription(Purpose = "The raw text as it came from the message (e.g. 'piros kalif'). Stored lowercase-trimmed for consistent matching.")]
public string RawText { get; set; } = string.Empty;
[ToonDescription(Purpose = "FK to the NopCommerce Product the admin selected for this raw text.")]
public int ResolvedProductId { get; set; }
[ToonDescription(Purpose = "Whether the selection was in a preorder context (true) or direct order context (false). Allows separate weighting per context.")]
public bool IsPreorder { get; set; }
[ToonDescription(Purpose = "The admin who made this selection.")]
public int SelectedByAdminId { get; set; }
public DateTime CreatedOnUtc { get; set; }
}

View File

@ -0,0 +1,7 @@
public enum OrderDraftStatus
{
Pending = 0,
Approved = 1,
Rejected = 2,
Expired = 3
}

View File

@ -52,6 +52,11 @@ public static class FruitBankConstClient
public const string CargoPartnerDbTableName = "fbCargoPartner";
public const string CargoTruckDbTableName = "fbCargoTruck";
public const string OrderDraftDbTableName = "fbOrderDraft";
public const string OrderDraftItemDbTableName = "fbOrderDraftItem";
public const string ExternalUserPartnerMapDbTableName = "fbExternalUserPartnerMapping";
public const string ProductTextMappingDbTableName = "fbProductTextMapping";
public const string DomainDescription = "This is a nopCommerce plugin developed for FruitBank, a fruit and vegetable wholesaler. The plugin manages supplier inbound delivery (receiving), warehouse weighing (net/gross/pallet/tare weights), and inventory stocktaking. The business logic is centered around FruitBank's requirement for precise physical measurement and quantity tracking.";
//public static Guid[] DevAdminIds = new Guid[2] { Guid.Parse("dcf451d2-cc4c-4ac2-8c1f-da00041be1fd"), Guid.Parse("4cbaed43-2465-4d99-84f1-c8bc6b7025f7") };

View File

@ -1,7 +0,0 @@
# Loggers
SignalR client-to-server log writer.
## Key Files
- **`SignaRClientLogItemWriter.cs`** — Routes client logs to `{BaseUrl}/loggerHub` via SignalR. Configurable by AppType and LogLevel.

View File

@ -1,17 +0,0 @@
using AyCode.Core.Enums;
using AyCode.Core.Loggers;
using AyCode.Services.Loggers;
namespace FruitBank.Common.Loggers
{
public class SignaRClientLogItemWriter : AcSignaRClientLogItemWriter
{
public SignaRClientLogItemWriter() : this(AppType.Web, LogLevel.Detail, null)
{
}
public SignaRClientLogItemWriter(AppType appType, LogLevel logLevel, string? categoryName = null) : base($"{FruitBankConstClient.BaseUrl}/{FruitBankConstClient.LoggerHubName}", appType, logLevel, categoryName)
{
}
}
}

View File

@ -25,22 +25,23 @@
}
},
"AcHubConnection": {
"Url": "https://shop.fruitbank.hu/fbHub",
"TransportMaxBufferSize": 30000000,
"ApplicationMaxBufferSize": 30000000,
"CloseTimeout": "00:00:10",
"KeepAliveInterval": "00:01:00",
"ServerTimeout": "00:03:00",
"SkipNegotiation": true,
"Transports": "WebSockets",
"UseAutomaticReconnect": true,
"UseStatefulReconnect": true
},
"AcBinaryHubProtocol": {
"ProtocolMode": "AsyncSegment",
"BufferSize": 4096,
"FlushPolicy": "Coalesced",
"FlushTimeout": "00:00:10"
}
"AcHubConnection": {
//"Url": "https://localhost:59579/fbHub",
"Url": "https://shop.fruitbank.hu/fbHub",
"TransportMaxBufferSize": 30000000,
"ApplicationMaxBufferSize": 30000000,
"CloseTimeout": "00:00:10",
"KeepAliveInterval": "00:01:00",
"ServerTimeout": "00:03:00",
"SkipNegotiation": true,
"Transports": "WebSockets",
"UseAutomaticReconnect": true,
"UseStatefulReconnect": true
},
"AcBinaryHubProtocol": {
"ProtocolMode": "Bytes",
"BufferSize": 4096,
"FlushPolicy": "Coalesced",
"FlushTimeout": "00:00:10"
}
}

View File

@ -8,10 +8,10 @@
"AyCode": {
"ProjectId": "aad53443-2ee2-4650-8a99-97e907265e4e",
"Urls": {
"BaseUrl": "https://localhost:59579",
"ApiBaseUrl": "https://localhost:59579"
//"BaseUrl": "https://shop.fruitbank.hu",
//"ApiBaseUrl": "https://shop.fruitbank.hu"
//"BaseUrl": "https://localhost:59579",
//"ApiBaseUrl": "https://localhost:59579"
"BaseUrl": "https://shop.fruitbank.hu",
"ApiBaseUrl": "https://shop.fruitbank.hu"
},
"Logger": {
"AppType": "Server",
@ -26,8 +26,8 @@
},
"AcHubConnection": {
"Url": "https://localhost:59579/fbHub",
//"Url": "https://shop.fruitbank.hu/fbHub",
//"Url": "https://localhost:59579/fbHub",
"Url": "https://shop.fruitbank.hu/fbHub",
"TransportMaxBufferSize": 30000000,
"ApplicationMaxBufferSize": 30000000,
"CloseTimeout": "00:00:10",
@ -38,10 +38,10 @@
"UseAutomaticReconnect": true,
"UseStatefulReconnect": true
},
"AcBinaryHubProtocol": {
"ProtocolMode": "AsyncSegment",
"BufferSize": 4096,
"FlushPolicy": "Coalesced",
"FlushTimeout": "00:00:10"
}
"AcBinaryHubProtocol": {
"ProtocolMode": "Bytes",
"BufferSize": 4096,
"FlushPolicy": "Coalesced",
"FlushTimeout": "00:00:10"
}
}

View File

@ -8,10 +8,10 @@
"AyCode": {
"ProjectId": "aad53443-2ee2-4650-8a99-97e907265e4e",
"Urls": {
"BaseUrl": "https://localhost:59579",
"ApiBaseUrl": "https://localhost:59579"
//"BaseUrl": "https://shop.fruitbank.hu",
//"ApiBaseUrl": "https://shop.fruitbank.hu"
//"BaseUrl": "https://localhost:59579",
//"ApiBaseUrl": "https://localhost:59579"
"BaseUrl": "https://shop.fruitbank.hu",
"ApiBaseUrl": "https://shop.fruitbank.hu"
},
"Logger": {
"AppType": "Server",
@ -26,8 +26,8 @@
},
"AcHubConnection": {
"Url": "https://localhost:59579/fbHub",
//"Url": "https://shop.fruitbank.hu/fbHub",
//"Url": "https://localhost:59579/fbHub",
"Url": "https://shop.fruitbank.hu/fbHub",
"TransportMaxBufferSize": 30000000,
"ApplicationMaxBufferSize": 30000000,
"CloseTimeout": "00:00:10",
@ -38,10 +38,10 @@
"UseAutomaticReconnect": true,
"UseStatefulReconnect": true
},
"AcBinaryHubProtocol": {
"ProtocolMode": "AsyncSegment",
"BufferSize": 4096,
"FlushPolicy": "Coalesced",
"FlushTimeout": "00:00:10"
}
"AcBinaryHubProtocol": {
"ProtocolMode": "Bytes",
"BufferSize": 4096,
"FlushPolicy": "Coalesced",
"FlushTimeout": "00:00:10"
}
}