Improve docs, naming, and reference handling for Toon/DTOs

- Use nameof(Product) for key group consistency and refactor safety
- Expand ToonDescription attributes with business context and legacy notes
- Add and update property-level documentation for measurement logic
- Add tests for AcToonSerializer reference handling (@ref markers)
- Introduce FullProcessModel and TestContainerWithSharedRefs for tests
- Add DomainDescription constant for plugin documentation
- Refine test output and assertions for metadata and navigation completeness
- Minor property description and naming improvements throughout
This commit is contained in:
Loretta 2026-01-16 09:28:17 +01:00
parent dd3c1c58c0
commit 9a3817dff0
10 changed files with 94 additions and 21 deletions

View File

@ -5,6 +5,8 @@ using Mango.Nop.Core.Dtos;
using Mango.Nop.Core.Extensions; using Mango.Nop.Core.Extensions;
using Mango.Nop.Core.Interfaces.ForeignKeys; using Mango.Nop.Core.Interfaces.ForeignKeys;
using Newtonsoft.Json; using Newtonsoft.Json;
using Nop.Core.Domain.Catalog;
//using Nop.Core.Domain.Catalog; //using Nop.Core.Domain.Catalog;
using Nop.Core.Domain.Common; using Nop.Core.Domain.Common;
using Nop.Core.Domain.Orders; using Nop.Core.Domain.Orders;
@ -13,14 +15,14 @@ using System.Linq.Expressions;
namespace FruitBank.Common.Dtos; namespace FruitBank.Common.Dtos;
[LinqToDB.Mapping.Table(Name = "Product")] [LinqToDB.Mapping.Table(Name = nameof(Product))]
[System.ComponentModel.DataAnnotations.Schema.Table("Product")] [System.ComponentModel.DataAnnotations.Schema.Table(nameof(Product))]
[ToonDescription("Product data with measurements and generic attributes", TypeRelation = ToonTypeRelation.DtoOf)] [ToonDescription("Product data with measurements and generic attributes", TypeRelation = ToonTypeRelation.DtoOf, RelatedTypes = [typeof(Product)])]
public class ProductDto : MgProductDto, IProductDto public class ProductDto : MgProductDto, IProductDto
{ {
[NotColumn, NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore] [NotColumn, NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
private static Expression<Func<ProductDto, GenericAttributeDto, bool>> RelationWithGenericAttribute => (orderItemDto, genericAttributeDto) => private static Expression<Func<ProductDto, GenericAttributeDto, bool>> RelationWithGenericAttribute => (orderItemDto, genericAttributeDto) =>
orderItemDto.Id == genericAttributeDto.EntityId && genericAttributeDto.KeyGroup == "Product";// nameof(Product); orderItemDto.Id == genericAttributeDto.EntityId && genericAttributeDto.KeyGroup == nameof(Product);// nameof(Product);
[Association(ThisKey = nameof(Id), OtherKey = nameof(GenericAttributeDto.EntityId), ExpressionPredicate = nameof(RelationWithGenericAttribute), CanBeNull = false)] [Association(ThisKey = nameof(Id), OtherKey = nameof(GenericAttributeDto.EntityId), ExpressionPredicate = nameof(RelationWithGenericAttribute), CanBeNull = false)]
@ -34,7 +36,7 @@ public class ProductDto : MgProductDto, IProductDto
//{ } //{ }
[NotColumn, NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore] [NotColumn, NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
[ToonDescription(Purpose = "Status flag", BusinessRule = "get => GenericAttributes.GetValueOrDefault<bool>('IsMeasurable')")] [ToonDescription(Purpose = "Master flag: if false, the system bypasses weight validation but still creates one Measurement Record (PalletItem) with TrayQuantity.", BusinessRule = "get => GenericAttributes.GetValueOrDefault<bool>('IsMeasurable')")]
public bool IsMeasurable public bool IsMeasurable
{ {
get => GenericAttributes.GetValueOrDefault<bool>(nameof(IMeasurable.IsMeasurable)); get => GenericAttributes.GetValueOrDefault<bool>(nameof(IMeasurable.IsMeasurable));
@ -74,7 +76,7 @@ public class ProductDto : MgProductDto, IProductDto
//set //set
//{ //{
// var ga = GenericAttributes.FirstOrDefault(ga => ga.Key == nameof(IIncomingQuantity.IncomingQuantity)) ?? // var ga = GenericAttributes.FirstOrDefault(ga => ga.Key == nameof(IIncomingQuantity.IncomingQuantity)) ??
// GenericAttributes.AddNewGenericAttribute("Product", nameof(IIncomingQuantity.IncomingQuantity), value.ToString(), Id); // GenericAttributes.AddNewGenericAttribute(nameof(Product), nameof(IIncomingQuantity.IncomingQuantity), value.ToString(), Id);
// ga.Value = value.ToString(); // ga.Value = value.ToString();
//} //}

View File

@ -9,7 +9,8 @@ using Newtonsoft.Json;
namespace FruitBank.Common.Entities; namespace FruitBank.Common.Entities;
[ToonDescription("Base class for pallet measurements with net weight calculation")] [ToonDescription("Base class for pallet measurements with net weight calculation",
Purpose = "Technically named 'Pallet' for legacy reasons, but represents a General Measurement Record. It is ALWAYS created for every item. If the product is not measurable, weights are 0 and only TrayQuantity is used.")]
public abstract class MeasuringItemPalletBase : MgEntityBase, IMeasuringItemPalletBase public abstract class MeasuringItemPalletBase : MgEntityBase, IMeasuringItemPalletBase
{ {
private double _palletWeight; private double _palletWeight;
@ -20,9 +21,10 @@ public abstract class MeasuringItemPalletBase : MgEntityBase, IMeasuringItemPall
protected int ForeignItemId; protected int ForeignItemId;
[NotColumn] [NotColumn]
[ToonDescription(BusinessRule = "get => ForeignItemId")] [ToonDescription(BusinessRule = "get => ForeignItemId", Constraints = "[#SmartTypeConstraints]")]
public int ForeignKey => ForeignItemId; public int ForeignKey => ForeignItemId;
[ToonDescription(Purpose = "Always recorded, regardless of measurability")]
public int TrayQuantity { get; set; } public int TrayQuantity { get; set; }
[Column(DataType = DataType.DecFloat)] [Column(DataType = DataType.DecFloat)]
@ -33,6 +35,7 @@ public abstract class MeasuringItemPalletBase : MgEntityBase, IMeasuringItemPall
} }
[Column(DataType = DataType.DecFloat)] [Column(DataType = DataType.DecFloat)]
[ToonDescription(Purpose = "Weight of the physical pallet if used; 0.0 if goods arrive without a pallet")]
public double PalletWeight public double PalletWeight
{ {
get => _palletWeight; get => _palletWeight;
@ -40,7 +43,7 @@ public abstract class MeasuringItemPalletBase : MgEntityBase, IMeasuringItemPall
} }
[NotColumn, System.ComponentModel.DataAnnotations.Schema.NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore] [NotColumn, System.ComponentModel.DataAnnotations.Schema.NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
[ToonDescription(BusinessRule = "get => CalculateNetWeight()", Constraints = "[#SmartTypeConstraints], readonly")] [ToonDescription(BusinessRule = "get => GrossWeight - PalletWeight - (TrayQuantity * TareWeight)", Constraints = "[#SmartTypeConstraints], readonly")]
public double NetWeight public double NetWeight
{ {
get => CalculateNetWeight(); get => CalculateNetWeight();
@ -48,6 +51,7 @@ public abstract class MeasuringItemPalletBase : MgEntityBase, IMeasuringItemPall
} }
[Column(DataType = DataType.DecFloat, CanBeNull = false)] [Column(DataType = DataType.DecFloat, CanBeNull = false)]
[ToonDescription(Purpose = "Measured gross weight; 0.0 if product is not measurable")]
public double GrossWeight public double GrossWeight
{ {
get => _grossWeight; get => _grossWeight;

View File

@ -11,7 +11,7 @@ using Table = LinqToDB.Mapping.TableAttribute;
namespace FruitBank.Common.Entities; namespace FruitBank.Common.Entities;
[ToonDescription("Pallet measurements for order items with audit tracking", Purpose = "A measurement record for outgoing goods, used to verify that the net weight being sent to the customer is accurate and audited")] [ToonDescription("Pallet measurements for order items with audit tracking", Purpose = "A measurement record for outgoing goods, used to verify that the net weight being sent to the customer is accurate and audited. NOTE: Despite the 'Pallet' name, this is a general measurement record that is ALWAYS created for every item. If the product is not measurable (IsMeasurable=false), weights are recorded as 0.0 and only TrayQuantity is stored.")]
[Table(Name = FruitBankConstClient.OrderItemPalletDbTableName)] [Table(Name = FruitBankConstClient.OrderItemPalletDbTableName)]
[System.ComponentModel.DataAnnotations.Schema.Table(FruitBankConstClient.OrderItemPalletDbTableName)] [System.ComponentModel.DataAnnotations.Schema.Table(FruitBankConstClient.OrderItemPalletDbTableName)]
public class OrderItemPallet : MeasuringItemPalletBase, IOrderItemPallet public class OrderItemPallet : MeasuringItemPalletBase, IOrderItemPallet
@ -22,6 +22,7 @@ public class OrderItemPallet : MeasuringItemPalletBase, IOrderItemPallet
set => ForeignItemId = value; set => ForeignItemId = value;
} }
[ToonDescription(Purpose = "User/Customer ID of the quality auditor")]
public int RevisorId { get; set; } public int RevisorId { get; set; }
[NotColumn, NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore] [NotColumn, NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]

View File

@ -13,8 +13,10 @@ namespace FruitBank.Common.Entities;
public class ShippingDocumentToFiles : MgEntityBase, IShippingDocumentToFiles public class ShippingDocumentToFiles : MgEntityBase, IShippingDocumentToFiles
{ {
public int FilesId { get; set; } public int FilesId { get; set; }
public int ShippingDocumentId { get; set; } public int ShippingDocumentId { get; set; }
[ToonDescription(Constraints = "enum-reference: DocumentType")]
public int DocumentTypeId { get; set; } public int DocumentTypeId { get; set; }
[NotColumn, NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore] [NotColumn, NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]

View File

@ -7,7 +7,7 @@ using System.Security.Cryptography.X509Certificates;
namespace FruitBank.Common.Entities; namespace FruitBank.Common.Entities;
[ToonDescription("Pallet measurements for shipping items", Purpose = "The smallest unit of measurement tracking, representing a single physical pallet of a shipping item, used for precise gross-to-net weight calculation and quality audit")] [ToonDescription("Pallet measurements for shipping items", Purpose = "The smallest unit of measurement tracking, representing a single physical measurement event. NOTE: Technically named 'Pallet' for legacy reasons, but it is ALWAYS created even if goods arrive without a physical pallet. For non-measurable products, weights are 0.0 and only TrayQuantity is tracked for tare-weight calculations.")]
[LinqToDB.Mapping.Table(Name = FruitBankConstClient.ShippingItemPalletDbTableName)] [LinqToDB.Mapping.Table(Name = FruitBankConstClient.ShippingItemPalletDbTableName)]
[System.ComponentModel.DataAnnotations.Schema.Table(FruitBankConstClient.ShippingItemPalletDbTableName)] [System.ComponentModel.DataAnnotations.Schema.Table(FruitBankConstClient.ShippingItemPalletDbTableName)]
public class ShippingItemPallet : MeasuringItemPalletBase, IShippingItemPallet public class ShippingItemPallet : MeasuringItemPalletBase, IShippingItemPallet

View File

@ -23,11 +23,11 @@ public class StockTakingItem : MgStockTakingItem<StockTaking, ProductDto>
[Column(DataType = DataType.DecFloat, CanBeNull = false)] [Column(DataType = DataType.DecFloat, CanBeNull = false)]
public double MeasuredNetWeight { get; set; } public double MeasuredNetWeight { get; set; }
[ToonDescription(Purpose = "Reserved stock buffer to prevent double-deduction during closing")] [ToonDescription(Purpose = "Reserved stock buffer (not yet shipped) to prevent double-deduction during closing")]
public int InProcessOrdersQuantity { get; set; } public int InProcessOrdersQuantity { get; set; }
[NotColumn, NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore] [NotColumn, NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
[ToonDescription(BusinessRule = "get => OriginalStockQuantity + InProcessOrdersQuantity")] [ToonDescription(BusinessRule = "get => OriginalStockQuantity + InProcessOrdersQuantity", Purpose = "Snapshot of total logical stock at session start")]
public int TotalOriginalQuantity => OriginalStockQuantity + InProcessOrdersQuantity; public int TotalOriginalQuantity => OriginalStockQuantity + InProcessOrdersQuantity;
[NotColumn, NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore] [NotColumn, NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]

View File

@ -13,7 +13,7 @@ public interface IStockTakingItemPallet : IMeasuringItemPalletBase
public StockTakingItem? StockTakingItem{ get; set; } public StockTakingItem? StockTakingItem{ get; set; }
} }
[ToonDescription("Weight record for inventory item", Purpose = "Granular weight-based evidence for a stock taking line item")] [ToonDescription("Weight record for inventory item", Purpose = "Granular weight-based evidence for a stock taking line item. NOTE: This record is mandatory for every inventory item. If weighing is skipped (non-measurable), it serves as a container for TrayQuantity with zeroed weight fields. The term 'Pallet' is a legacy naming convention.")]
[LinqToDB.Mapping.Table(Name = FruitBankConstClient.StockTakingItemPalletDbTableName)] [LinqToDB.Mapping.Table(Name = FruitBankConstClient.StockTakingItemPalletDbTableName)]
[System.ComponentModel.DataAnnotations.Schema.Table(FruitBankConstClient.StockTakingItemPalletDbTableName)] [System.ComponentModel.DataAnnotations.Schema.Table(FruitBankConstClient.StockTakingItemPalletDbTableName)]
public class StockTakingItemPallet : MeasuringItemPalletBase, IStockTakingItemPallet public class StockTakingItemPallet : MeasuringItemPalletBase, IStockTakingItemPallet

View File

@ -43,8 +43,8 @@ public static class FruitBankConstClient
public const string StockTakingDbTableName = "fbStockTaking"; public const string StockTakingDbTableName = "fbStockTaking";
public const string StockTakingItemDbTableName = "fbStockTakingItem"; public const string StockTakingItemDbTableName = "fbStockTakingItem";
public const string StockTakingItemPalletDbTableName = "fbStockTakingItemPallet"; public const string StockTakingItemPalletDbTableName = "fbStockTakingItemPallet";
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") }; //public static Guid[] DevAdminIds = new Guid[2] { Guid.Parse("dcf451d2-cc4c-4ac2-8c1f-da00041be1fd"), Guid.Parse("4cbaed43-2465-4d99-84f1-c8bc6b7025f7") };
//public static Guid[] SysAdmins = new Guid[3] //public static Guid[] SysAdmins = new Guid[3]

View File

@ -18,6 +18,14 @@ using System.Runtime.Serialization;
namespace FruitBankHybrid.Shared.Tests; namespace FruitBankHybrid.Shared.Tests;
//[ToonIgnore][ToonDataIgnore]
[ToonDescription(Purpose = "Container model for Shipping, Order")]
public class FullProcessModel
{
public List<Shipping> Shippings { get; set; }
public List<OrderDto> Orders { get; set; }
}
[TestClass] [TestClass]
public sealed class ToonTests public sealed class ToonTests
{ {
@ -41,11 +49,57 @@ public sealed class ToonTests
[TestMethod] [TestMethod]
public async Task OrderDtoToToon() public async Task OrderDtoToToon()
{ {
var orderDtos = await _signalRClient.GetAllOrderDtos(); var a = new FullProcessModel();
var toon = AcToonSerializer.Serialize(orderDtos, AcToonSerializerOptions.Default); a.Orders = (await _signalRClient.GetAllOrderDtos())!;
a.Shippings = (await _signalRClient.GetShippings())!;
var toon = AcToonSerializer.Serialize(a, FruitBankConstClient.DomainDescription, AcToonSerializerOptions.Default);
Console.WriteLine(toon); Console.WriteLine(toon);
Assert.IsNotEmpty(toon); Assert.IsNotEmpty(toon);
// Note: @ref: only appears when the same object instance is referenced multiple times.
// Data from separate API calls typically don't share object instances.
}
[TestMethod]
public void ReferenceHandling_WithSharedReferences_ShouldOutputRefMarkers()
{
// Create a simple test container with shared references
var sharedProduct = new ProductDto { Id = 1, Name = "Shared Product" };
// Create a container that references the same ProductDto twice
var container = new TestContainerWithSharedRefs
{
Product1 = sharedProduct,
Product2 = sharedProduct // Same instance, should create @ref
};
var toon = AcToonSerializer.Serialize(container, FruitBankConstClient.DomainDescription, AcToonSerializerOptions.Default);
Console.WriteLine(toon);
Assert.IsNotEmpty(toon);
Assert.IsTrue(toon.Contains("@ref:"), "ReferenceHandling should detect shared Product instance");
}
[TestMethod]
public void ReferenceHandling_WithSharedIIdReferences_ShouldOutputRefMarkers()
{
// Create a simple test container with shared references
var sharedProduct = new ProductDto { Id = 1, Name = "Shared Product" };
var sharedProduct2 = new ProductDto { Id = 1, Name = "Shared Product" };
// Create a container that references the same ProductDto twice
var container = new TestContainerWithSharedRefs
{
Product1 = sharedProduct,
Product2 = sharedProduct2 // Same instance, should create @ref
};
var toon = AcToonSerializer.Serialize(container, FruitBankConstClient.DomainDescription, AcToonSerializerOptions.Default);
Console.WriteLine(toon);
Assert.IsNotEmpty(toon);
Assert.IsTrue(toon.Contains("@ref:"), "ReferenceHandling should detect shared Product instance");
} }
[TestMethod] [TestMethod]
@ -94,7 +148,7 @@ public sealed class ToonTests
[TestMethod] [TestMethod]
public void ToonTypes_PropertyDescriptions_ShouldNotBeRedundantOrMisleading() public void ToonTypes_PropertyDescriptions_ShouldNotBeRedundantOrMisleading()
{ {
var toon = AcToonSerializer.SerializeTypeMetadata<OrderDto>(); var toon = AcToonSerializer.SerializeTypeMetadata<OrderDto>(FruitBankConstClient.DomainDescription);
var lines = toon.Split('\n'); var lines = toon.Split('\n');
foreach (var line in lines) foreach (var line in lines)
{ {
@ -108,7 +162,7 @@ public sealed class ToonTests
[TestMethod] [TestMethod]
public void ToonTypes_NavigationMetadata_ShouldBeComplete() public void ToonTypes_NavigationMetadata_ShouldBeComplete()
{ {
var toon = AcToonSerializer.SerializeMetadata([typeof(Shipping), typeof(OrderDto), typeof(StockTaking), typeof(StockQuantityHistory), typeof(StockQuantityHistoryExt)]); var toon = AcToonSerializer.SerializeMetadata(FruitBankConstClient.DomainDescription, [typeof(Shipping), typeof(OrderDto), typeof(StockTaking), typeof(StockQuantityHistory), typeof(StockQuantityHistoryExt)]);
Console.WriteLine(toon); Console.WriteLine(toon);
Console.WriteLine("\n=== NAVIGATION METADATA ELLENŐRZÉS ===\n"); Console.WriteLine("\n=== NAVIGATION METADATA ELLENŐRZÉS ===\n");
@ -308,4 +362,13 @@ public sealed class ToonTests
Console.WriteLine("✓ Minden navigation property tartalmazza a szükséges metadatokat"); Console.WriteLine("✓ Minden navigation property tartalmazza a szükséges metadatokat");
} }
}
/// <summary>
/// Test helper class to verify reference handling with shared object instances.
/// </summary>
public class TestContainerWithSharedRefs
{
public ProductDto? Product1 { get; set; }
public ProductDto? Product2 { get; set; }
} }

View File

@ -7,6 +7,7 @@ using FruitBank.Common.Interfaces;
using FruitBank.Common.SignalRs; using FruitBank.Common.SignalRs;
using FruitBankHybrid.Shared.Pages; using FruitBankHybrid.Shared.Pages;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Nop.Core.Domain.Catalog;
using Nop.Core.Domain.Common; using Nop.Core.Domain.Common;
using Nop.Core.Domain.Orders; using Nop.Core.Domain.Orders;
@ -51,7 +52,7 @@ public class GridGenericAttributeBase: FruitBankGridBase<GenericAttributeDto>, I
switch (ParentDataItem) switch (ParentDataItem)
{ {
case IProductDto: case IProductDto:
if (!hasContextIdParameter) ContextIds![ContextKeyGroupIndex] = "Product"; if (!hasContextIdParameter) ContextIds![ContextKeyGroupIndex] = nameof(Product);
break; break;
case IOrderDto: case IOrderDto: