refactor: update EF Core backing field mapping and ignore DDD enumeration types

This commit is contained in:
Ho Ngoc Hai
2026-03-04 12:36:19 +07:00
parent 2d74f53f0d
commit 64e7b4e00d
11 changed files with 132 additions and 118 deletions

View File

@@ -57,7 +57,7 @@ public class OrderController : ControllerBase
/// </summary>
[HttpPost("pos/orders")]
public Task<IActionResult> CreatePosOrder([FromBody] JsonElement body) =>
_order.PostAsJsonAsync("/api/v1/pos/orders", body).ProxyAsync();
_order.PostAsJsonAsync("/api/v1/orders", body).ProxyAsync();
/// <summary>
/// EN: Get POS dashboard data — daily revenue, order count, popular items.

View File

@@ -5,6 +5,8 @@ using System.Text.Json;
using MediatR;
using Microsoft.EntityFrameworkCore;
using CatalogService.API.Application.DTOs;
using CatalogService.Domain.AggregatesModel.ProductAggregate;
using CatalogService.Domain.SeedWork;
using CatalogService.Infrastructure;
namespace CatalogService.API.Application.Queries;
@@ -24,27 +26,29 @@ public class GetProductByIdQueryHandler : IRequestHandler<GetProductByIdQuery, P
public async Task<ProductDto?> Handle(GetProductByIdQuery request, CancellationToken cancellationToken)
{
var product = await _context.Products
.Where(p => p.Id == request.ProductId)
.Select(p => new ProductDto
{
Id = p.Id,
ShopId = p.ShopId,
Name = p.Name,
Description = p.Description,
Price = p.Price,
Type = p.Type.Name,
Attributes = p.Attributes != null
? JsonSerializer.Deserialize<Dictionary<string, object>>(p.Attributes.RootElement.GetRawText())
: null,
ImageUrl = p.ImageUrl,
Sku = p.Sku,
IsActive = p.IsActive,
CreatedAt = p.CreatedAt,
UpdatedAt = p.UpdatedAt
})
.FirstOrDefaultAsync(cancellationToken);
var typeMap = Enumeration.GetAll<ProductType>().ToDictionary(t => t.Id, t => t.Name);
return product;
var entity = await _context.Products
.FirstOrDefaultAsync(p => p.Id == request.ProductId, cancellationToken);
if (entity == null) return null;
return new ProductDto
{
Id = entity.Id,
ShopId = entity.ShopId,
Name = entity.Name,
Description = entity.Description,
Price = entity.Price,
Type = typeMap.GetValueOrDefault(entity.TypeId, "Unknown"),
Attributes = entity.Attributes != null
? JsonSerializer.Deserialize<Dictionary<string, object>>(entity.Attributes.RootElement.GetRawText())
: null,
ImageUrl = entity.ImageUrl,
Sku = entity.Sku,
IsActive = entity.IsActive,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt
};
}
}

View File

@@ -5,6 +5,8 @@ using System.Text.Json;
using MediatR;
using Microsoft.EntityFrameworkCore;
using CatalogService.API.Application.DTOs;
using CatalogService.Domain.AggregatesModel.ProductAggregate;
using CatalogService.Domain.SeedWork;
using CatalogService.Infrastructure;
namespace CatalogService.API.Application.Queries;
@@ -36,24 +38,30 @@ public class GetProductsQueryHandler : IRequestHandler<GetProductsQuery, PagedRe
if (!string.IsNullOrWhiteSpace(request.Type))
{
query = query.Where(p => p.Type.Name == request.Type);
// EN: Resolve TypeId from enumeration name for server-side filtering.
// VI: Resolve TypeId từ tên enumeration để lọc phía server.
var typeEnum = Enumeration.FromDisplayName<ProductType>(request.Type);
query = query.Where(p => p.TypeId == typeEnum.Id);
}
// EN: Get total count
// VI: Lấy tổng số
var totalCount = await query.CountAsync(cancellationToken);
// EN: Apply pagination and load entities, then project in memory
// (JsonDocument cannot be projected server-side by EF Core).
// VI: Phân trang và load entities, rồi project trong bộ nhớ
// (JsonDocument không thể project server-side bởi EF Core).
// EN: Apply pagination and load entities, then project in memory.
// Type navigation is ignored by EF Core, so resolve name from Enumeration.
// VI: Phân trang và load entities, rồi project trong bộ nhớ.
// Type navigation bị ignore bởi EF Core, nên resolve tên từ Enumeration.
var entities = await query
.Include(p => p.Type)
.OrderBy(p => p.Name)
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToListAsync(cancellationToken);
// EN: Build TypeId → Name lookup from Enumeration.
// VI: Build lookup TypeId → Name từ Enumeration.
var typeMap = Enumeration.GetAll<ProductType>().ToDictionary(t => t.Id, t => t.Name);
var products = entities.Select(p => new ProductDto
{
Id = p.Id,
@@ -61,7 +69,7 @@ public class GetProductsQueryHandler : IRequestHandler<GetProductsQuery, PagedRe
Name = p.Name,
Description = p.Description,
Price = p.Price,
Type = p.Type?.Name ?? "Unknown",
Type = typeMap.GetValueOrDefault(p.TypeId, "Unknown"),
Attributes = p.Attributes != null
? JsonSerializer.Deserialize<Dictionary<string, object>>(p.Attributes.RootElement.GetRawText())
: null,

View File

@@ -18,7 +18,6 @@ public class Product : Entity, IAggregateRoot
private string _name = null!;
private string? _description;
private decimal _price;
private ProductType _type = null!;
private JsonDocument? _attributes; // Type-specific attributes in JSONB
private string? _imageUrl;
private string? _sku;
@@ -50,12 +49,6 @@ public class Product : Entity, IAggregateRoot
/// </summary>
public decimal Price => _price;
/// <summary>
/// EN: Product type.
/// VI: Loại sản phẩm.
/// </summary>
public ProductType Type => _type;
/// <summary>
/// EN: Type ID for EF Core mapping.
/// VI: Type ID cho EF Core mapping.
@@ -132,7 +125,6 @@ public class Product : Entity, IAggregateRoot
_name = name.Trim();
_description = description?.Trim();
_price = price;
_type = type;
TypeId = type.Id;
_attributes = attributes;
_sku = sku?.Trim();

View File

@@ -53,7 +53,14 @@ public class CatalogContext : DbContext, IUnitOfWork
// VI: Áp dụng các cấu hình entity
modelBuilder.ApplyConfiguration(new ProductEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new CategoryEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new ProductTypeEntityTypeConfiguration());
// EN: Ignore ProductType so EF Core does NOT auto-discover Product.TypeId as a FK.
// ProductType is a DDD Enumeration resolved in-memory; the product_types table
// is seeded by the initial migration and not managed at runtime.
// VI: Ignore ProductType để EF Core KHÔNG tự phát hiện Product.TypeId là FK.
// ProductType là DDD Enumeration được resolve trong bộ nhớ; bảng product_types
// được seed bởi migration khởi tạo và không được quản lý lúc runtime.
modelBuilder.Ignore<ProductType>();
}
/// <summary>

View File

@@ -98,11 +98,9 @@ public class ProductEntityTypeConfiguration : IEntityTypeConfiguration<Product>
builder.HasIndex(p => p.Sku).HasDatabaseName("ix_products_sku");
builder.HasIndex(p => p.IsActive).HasDatabaseName("ix_products_is_active");
// EN: Configure navigation to ProductType via TypeId foreign key.
// VI: Cấu hình navigation đến ProductType qua TypeId foreign key.
builder.HasOne(p => p.Type)
.WithMany()
.HasForeignKey(p => p.TypeId)
.OnDelete(DeleteBehavior.Restrict);
// EN: TypeId is a plain FK column — no navigation property to ProductType.
// Type name is resolved from Enumeration in query handlers.
// VI: TypeId là cột FK thuần — không có navigation property đến ProductType.
// Tên Type được resolve từ Enumeration trong query handlers.
}
}

View File

@@ -16,12 +16,14 @@ public class InventoryItemEntityTypeConfiguration : IEntityTypeConfiguration<Inv
builder.HasKey(i => i.Id);
builder.Property(i => i.Id).HasColumnName("id").ValueGeneratedNever();
builder.Property<Guid>("_productId").HasColumnName("product_id").IsRequired();
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
builder.Property<int>("_quantity").HasColumnName("quantity").IsRequired();
builder.Property<int>("_reservedQuantity").HasColumnName("reserved_quantity").IsRequired();
builder.Property<int>("_reorderLevel").HasColumnName("reorder_level").HasDefaultValue(10);
builder.Property<DateTime?>("_updatedAt").HasColumnName("updated_at");
builder.Property(i => i.ProductId).HasField("_productId").HasColumnName("product_id").IsRequired();
builder.Property(i => i.ShopId).HasField("_shopId").HasColumnName("shop_id").IsRequired();
builder.Property(i => i.Quantity).HasField("_quantity").HasColumnName("quantity").IsRequired();
builder.Property(i => i.ReservedQuantity).HasField("_reservedQuantity").HasColumnName("reserved_quantity").IsRequired();
builder.Property(i => i.ReorderLevel).HasField("_reorderLevel").HasColumnName("reorder_level").HasDefaultValue(10);
builder.Property(i => i.UpdatedAt).HasField("_updatedAt").HasColumnName("updated_at");
builder.Ignore(i => i.CreatedAt);
// Owned InventoryTransaction collection
builder.OwnsMany(i => i.Transactions, txn =>
@@ -44,15 +46,11 @@ public class InventoryItemEntityTypeConfiguration : IEntityTypeConfiguration<Inv
txn.Ignore(t => t.CreatedAt);
});
builder.HasIndex("_productId").HasDatabaseName("ix_inventory_product_id");
builder.HasIndex("_shopId").HasDatabaseName("ix_inventory_shop_id");
builder.HasIndex(i => i.ProductId).HasDatabaseName("ix_inventory_product_id");
builder.HasIndex(i => i.ShopId).HasDatabaseName("ix_inventory_shop_id");
builder.Ignore(i => i.ProductId);
builder.Ignore(i => i.ShopId);
builder.Ignore(i => i.Quantity);
builder.Ignore(i => i.ReservedQuantity);
// EN: AvailableQuantity is computed (Quantity - ReservedQuantity), not stored.
// VI: AvailableQuantity được tính toán (Quantity - ReservedQuantity), không lưu.
builder.Ignore(i => i.AvailableQuantity);
builder.Ignore(i => i.ReorderLevel);
builder.Ignore(i => i.UpdatedAt);
}
}

View File

@@ -43,7 +43,11 @@ public class InventoryContext : DbContext, IUnitOfWork
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new InventoryItemEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new TransactionTypeEntityTypeConfiguration());
// EN: Ignore TransactionType so EF Core does NOT auto-discover TypeId as a FK.
// TransactionType is a DDD Enumeration resolved in-memory.
// VI: Ignore TransactionType để EF Core KHÔNG tự phát hiện TypeId là FK.
modelBuilder.Ignore<TransactionType>();
}
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)

View File

@@ -95,14 +95,14 @@ public class InventoryRepository : IInventoryRepository
CancellationToken cancellationToken = default)
{
var query = _context.InventoryItems
.Where(i => i.AvailableQuantity <= i.ReorderLevel);
.Where(i => (i.Quantity - i.ReservedQuantity) <= i.ReorderLevel);
if (shopId.HasValue)
query = query.Where(i => i.ShopId == shopId.Value);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderBy(i => i.AvailableQuantity)
.OrderBy(i => i.Quantity - i.ReservedQuantity)
.Skip(skip)
.Take(take)
.ToListAsync(cancellationToken);

View File

@@ -23,14 +23,17 @@ public class MerchantStaffEntityTypeConfiguration : IEntityTypeConfiguration<Mer
.HasColumnName("id")
.ValueGeneratedNever();
builder.Property<Guid>("_userId")
builder.Property(s => s.UserId)
.HasField("_userId")
.HasColumnName("user_id");
builder.Property<Guid>("_merchantId")
builder.Property(s => s.MerchantId)
.HasField("_merchantId")
.HasColumnName("merchant_id")
.IsRequired();
builder.Property<string?>("_employeeCode")
builder.Property(s => s.EmployeeCode)
.HasField("_employeeCode")
.HasColumnName("employee_code")
.HasMaxLength(20);
@@ -42,33 +45,41 @@ public class MerchantStaffEntityTypeConfiguration : IEntityTypeConfiguration<Mer
.HasColumnName("status_id")
.IsRequired();
builder.Property<StaffPermissions>("_permissions")
builder.Property(s => s.Permissions)
.HasField("_permissions")
.HasColumnName("permissions")
.HasConversion<int>();
builder.Property<string?>("_phone")
builder.Property(s => s.Phone)
.HasField("_phone")
.HasColumnName("phone")
.HasMaxLength(20);
builder.Property<string?>("_email")
builder.Property(s => s.Email)
.HasField("_email")
.HasColumnName("email")
.HasMaxLength(100);
builder.Property<string?>("_pinCodeHash")
builder.Property(s => s.PinCodeHash)
.HasField("_pinCodeHash")
.HasColumnName("pin_code_hash")
.HasMaxLength(100);
builder.Property<DateTime>("_joinedAt")
builder.Property(s => s.JoinedAt)
.HasField("_joinedAt")
.HasColumnName("joined_at");
builder.Property<DateTime?>("_terminatedAt")
builder.Property(s => s.TerminatedAt)
.HasField("_terminatedAt")
.HasColumnName("terminated_at");
builder.Property<DateTime>("_createdAt")
builder.Property(s => s.CreatedAt)
.HasField("_createdAt")
.HasColumnName("created_at")
.IsRequired();
builder.Property<DateTime?>("_updatedAt")
builder.Property(s => s.UpdatedAt)
.HasField("_updatedAt")
.HasColumnName("updated_at");
// EN: Configure navigation to device tokens
@@ -87,26 +98,14 @@ public class MerchantStaffEntityTypeConfiguration : IEntityTypeConfiguration<Mer
// EN: Indexes
// VI: Indexes
builder.HasIndex("_userId").HasDatabaseName("ix_merchant_staff_user_id");
builder.HasIndex("_merchantId").HasDatabaseName("ix_merchant_staff_merchant_id");
builder.HasIndex("_email").HasDatabaseName("ix_merchant_staff_email");
builder.HasIndex(s => s.UserId).HasDatabaseName("ix_merchant_staff_user_id");
builder.HasIndex(s => s.MerchantId).HasDatabaseName("ix_merchant_staff_merchant_id");
builder.HasIndex(s => s.Email).HasDatabaseName("ix_merchant_staff_email");
// EN: Ignore expression-bodied properties (EF Core can't map them)
// EN: Repository queries use EF.Property<T>() to access backing fields directly.
// VI: Ignore expression-bodied properties (EF Core không thể map chúng)
// EN: Ignore Enumeration navigation properties (resolved in-memory).
// VI: Ignore Enumeration navigation properties (resolve trong bộ nhớ).
builder.Ignore(s => s.Role);
builder.Ignore(s => s.Status);
builder.Ignore(s => s.Permissions);
builder.Ignore(s => s.UserId);
builder.Ignore(s => s.MerchantId);
builder.Ignore(s => s.EmployeeCode);
builder.Ignore(s => s.Phone);
builder.Ignore(s => s.Email);
builder.Ignore(s => s.PinCodeHash);
builder.Ignore(s => s.JoinedAt);
builder.Ignore(s => s.TerminatedAt);
builder.Ignore(s => s.CreatedAt);
builder.Ignore(s => s.UpdatedAt);
}
}
@@ -130,44 +129,41 @@ public class DeviceTokenEntityTypeConfiguration : IEntityTypeConfiguration<Devic
.HasColumnName("staff_id")
.IsRequired();
builder.Property<string>("_deviceId")
builder.Property(d => d.DeviceId)
.HasField("_deviceId")
.HasColumnName("device_id")
.HasMaxLength(100)
.IsRequired();
builder.Property<string?>("_deviceName")
builder.Property(d => d.DeviceName)
.HasField("_deviceName")
.HasColumnName("device_name")
.HasMaxLength(100);
builder.Property<string?>("_fcmToken")
builder.Property(d => d.FcmToken)
.HasField("_fcmToken")
.HasColumnName("fcm_token")
.HasMaxLength(500);
builder.Property<string>("_platform")
builder.Property(d => d.Platform)
.HasField("_platform")
.HasColumnName("platform")
.HasMaxLength(20)
.IsRequired();
builder.Property<DateTime?>("_lastUsedAt")
builder.Property(d => d.LastUsedAt)
.HasField("_lastUsedAt")
.HasColumnName("last_used_at");
builder.Property<DateTime>("_createdAt")
builder.Property(d => d.CreatedAt)
.HasField("_createdAt")
.HasColumnName("created_at")
.IsRequired();
// EN: Indexes
// VI: Indexes
builder.HasIndex(d => d.StaffId).HasDatabaseName("ix_device_tokens_staff_id");
builder.HasIndex("_deviceId").HasDatabaseName("ix_device_tokens_device_id");
// EN: Ignore expression-bodied properties
// VI: Ignore expression-bodied properties
builder.Ignore(d => d.DeviceId);
builder.Ignore(d => d.DeviceName);
builder.Ignore(d => d.FcmToken);
builder.Ignore(d => d.Platform);
builder.Ignore(d => d.LastUsedAt);
builder.Ignore(d => d.CreatedAt);
builder.HasIndex(d => d.DeviceId).HasDatabaseName("ix_device_tokens_device_id");
}
}
@@ -191,42 +187,42 @@ public class ShopMemberEntityTypeConfiguration : IEntityTypeConfiguration<ShopMe
.HasColumnName("staff_id")
.IsRequired();
builder.Property<Guid>("_shopId")
builder.Property(m => m.ShopId)
.HasField("_shopId")
.HasColumnName("shop_id")
.IsRequired();
builder.Property<Guid?>("_branchId")
builder.Property(m => m.BranchId)
.HasField("_branchId")
.HasColumnName("branch_id");
builder.Property(m => m.RoleId)
.HasColumnName("role_id")
.IsRequired();
builder.Property<StaffPermissions?>("_customPermissions")
builder.Property(m => m.CustomPermissions)
.HasField("_customPermissions")
.HasColumnName("custom_permissions")
.HasConversion<int?>();
builder.Property<bool>("_isPrimary")
builder.Property(m => m.IsPrimary)
.HasField("_isPrimary")
.HasColumnName("is_primary")
.HasDefaultValue(false);
builder.Property<DateTime>("_assignedAt")
builder.Property(m => m.AssignedAt)
.HasField("_assignedAt")
.HasColumnName("assigned_at")
.IsRequired();
// EN: Indexes
// VI: Indexes
builder.HasIndex(m => m.StaffId).HasDatabaseName("ix_shop_members_staff_id");
builder.HasIndex("_shopId").HasDatabaseName("ix_shop_members_shop_id");
builder.HasIndex(m => m.ShopId).HasDatabaseName("ix_shop_members_shop_id");
// EN: Ignore expression-bodied properties
// VI: Ignore expression-bodied properties
// EN: Ignore Enumeration navigation property (resolved in-memory).
// VI: Ignore Enumeration navigation property (resolve trong bộ nhớ).
builder.Ignore(m => m.Role);
builder.Ignore(m => m.CustomPermissions);
builder.Ignore(m => m.ShopId);
builder.Ignore(m => m.BranchId);
builder.Ignore(m => m.IsPrimary);
builder.Ignore(m => m.AssignedAt);
}
}

View File

@@ -86,6 +86,13 @@ public class MerchantServiceContext : DbContext, IUnitOfWork
// EN: Apply entity configurations from this assembly
// VI: Áp dụng các cấu hình entity từ assembly này
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MerchantServiceContext).Assembly);
// EN: Ignore Enumeration types so EF Core does NOT auto-discover FKs.
// These are DDD Enumerations resolved in-memory; their tables are seeded by migrations.
// VI: Ignore Enumeration types để EF Core KHÔNG tự phát hiện FK.
modelBuilder.Ignore<StaffRole>();
modelBuilder.Ignore<StaffStatus>();
modelBuilder.Ignore<ShopRole>();
}
/// <summary>