feat: Implement initial entity configurations for ads billing, analytics, and serving, add catalog product and category commands/queries, and refine booking service infrastructure.

This commit is contained in:
Ho Ngoc Hai
2026-01-18 01:15:51 +07:00
parent 4abd842c0d
commit b1931be440
52 changed files with 2384 additions and 202 deletions

View File

@@ -846,6 +846,57 @@ services:
- "traefik.http.services.booking-service.loadbalancer.healthcheck.interval=10s"
# Ads Manager Service .NET - Campaign & Ad Management
ads-manager-service-net:
build:
context: ../../services/ads-manager-service-net
dockerfile: Dockerfile
image: goodgo/ads-manager-service-net:latest
container_name: ads-manager-service-net-local
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ASPNETCORE_URLS=http://+:8080
# EN: Database - Neon PostgreSQL
# VI: Cơ sở dữ liệu - Neon PostgreSQL
- ConnectionStrings__DefaultConnection=Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Port=5432;Database=ads_manager_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require
# EN: IAM Service Communication
# VI: Giao tiếp IAM Service
- IamService__BaseUrl=http://iam-service-net:8080
- IamService__ServiceName=ads-manager-service
# EN: JWT Configuration
# VI: Cấu hình JWT
- Jwt__Authority=http://iam-service-net:8080
- Jwt__Audience=goodgo-api
- Jwt__RequireHttpsMetadata=false
# EN: Redis Cache
# VI: Cache Redis
- Redis__Host=167.114.174.113
- Redis__Port=6379
- Redis__Password=Velik@2026
ports:
- "5021:8080"
depends_on:
iam-service-net:
condition: service_healthy
traefik:
condition: service_started
networks:
- microservices-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
labels:
- "traefik.enable=true"
- "traefik.http.routers.ads-manager-service.rule=PathPrefix(`/api/v1/ads-manager`)"
- "traefik.http.routers.ads-manager-service.entrypoints=web"
- "traefik.http.services.ads-manager-service.loadbalancer.server.port=8080"
- "traefik.http.services.ads-manager-service.loadbalancer.healthcheck.path=/health/live"
- "traefik.http.services.ads-manager-service.loadbalancer.healthcheck.interval=10s"
# Jaeger - Distributed Tracing
# jaeger:
# image: jaegertracing/all-in-one:1.47

View File

@@ -14,6 +14,10 @@
<!-- EN: FluentValidation for request validation / VI: FluentValidation cho validation request -->
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />

View File

@@ -0,0 +1,34 @@
using MediatR;
namespace AdsAnalyticsService.API.Application.Queries;
/// <summary>
/// EN: Query to get campaign metrics for a specific date range.
/// VI: Query lấy metrics chiến dịch cho khoảng thời gian cụ thể.
/// </summary>
public record GetCampaignMetricsQuery : IRequest<CampaignMetricsDto?>
{
public Guid CampaignId { get; init; }
public DateTime StartDate { get; init; }
public DateTime EndDate { get; init; }
}
/// <summary>
/// EN: Campaign metrics DTO.
/// VI: DTO metrics chiến dịch.
/// </summary>
public record CampaignMetricsDto
{
public Guid CampaignId { get; init; }
public DateTime StartDate { get; init; }
public DateTime EndDate { get; init; }
public long TotalImpressions { get; init; }
public long TotalClicks { get; init; }
public long TotalConversions { get; init; }
public decimal TotalSpend { get; init; }
public decimal TotalRevenue { get; init; }
public decimal CTR { get; init; }
public decimal CPC { get; init; }
public decimal CPA { get; init; }
public decimal ROAS { get; init; }
}

View File

@@ -0,0 +1,71 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using AdsAnalyticsService.Infrastructure;
namespace AdsAnalyticsService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetCampaignMetricsQuery.
/// VI: Handler cho GetCampaignMetricsQuery.
/// </summary>
public class GetCampaignMetricsQueryHandler : IRequestHandler<GetCampaignMetricsQuery, CampaignMetricsDto?>
{
private readonly AdsAnalyticsServiceContext _context;
private readonly ILogger<GetCampaignMetricsQueryHandler> _logger;
public GetCampaignMetricsQueryHandler(
AdsAnalyticsServiceContext context,
ILogger<GetCampaignMetricsQueryHandler> logger)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<CampaignMetricsDto?> Handle(GetCampaignMetricsQuery request, CancellationToken cancellationToken)
{
// EN: Aggregate metrics for the campaign within date range
// VI: Tổng hợp metrics cho chiến dịch trong khoảng thời gian
var metrics = await _context.CampaignMetrics
.Where(m => m.CampaignId == request.CampaignId
&& m.Date >= request.StartDate.Date
&& m.Date <= request.EndDate.Date)
.ToListAsync(cancellationToken);
if (!metrics.Any())
{
_logger.LogInformation("No metrics found for Campaign {CampaignId}", request.CampaignId);
return null;
}
// EN: Aggregate totals
// VI: Tổng hợp tổng cộng
var totalImpressions = metrics.Sum(m => m.Impressions);
var totalClicks = metrics.Sum(m => m.Clicks);
var totalConversions = metrics.Sum(m => m.Conversions);
var totalSpend = metrics.Sum(m => m.Spend);
var totalRevenue = metrics.Sum(m => m.Revenue);
// EN: Calculate aggregate KPIs
// VI: Tính toán KPIs tổng hợp
var ctr = totalImpressions > 0 ? (decimal)totalClicks / totalImpressions * 100 : 0;
var cpc = totalClicks > 0 ? totalSpend / totalClicks : 0;
var cpa = totalConversions > 0 ? totalSpend / totalConversions : 0;
var roas = totalSpend > 0 ? totalRevenue / totalSpend : 0;
return new CampaignMetricsDto
{
CampaignId = request.CampaignId,
StartDate = request.StartDate,
EndDate = request.EndDate,
TotalImpressions = totalImpressions,
TotalClicks = totalClicks,
TotalConversions = totalConversions,
TotalSpend = totalSpend,
TotalRevenue = totalRevenue,
CTR = ctr,
CPC = cpc,
CPA = cpa,
ROAS = roas
};
}
}

View File

@@ -0,0 +1,88 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using AdsAnalyticsService.API.Application.Queries;
namespace AdsAnalyticsService.API.Controllers;
/// <summary>
/// EN: API Controller for ads analytics metrics.
/// VI: API Controller cho metrics phân tích quảng cáo.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/ads-analytics")]
[Produces("application/json")]
public class MetricsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<MetricsController> _logger;
public MetricsController(IMediator mediator, ILogger<MetricsController> logger)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: Get campaign metrics for a date range.
/// VI: Lấy metrics chiến dịch cho khoảng thời gian.
/// </summary>
/// <param name="id">Campaign ID</param>
/// <param name="startDate">Start date (YYYY-MM-DD)</param>
/// <param name="endDate">End date (YYYY-MM-DD)</param>
/// <returns>Campaign metrics</returns>
[HttpGet("campaigns/{id}/metrics")]
[ProducesResponseType(typeof(CampaignMetricsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CampaignMetricsDto>> GetCampaignMetrics(
Guid id,
[FromQuery] DateTime? startDate = null,
[FromQuery] DateTime? endDate = null)
{
// EN: Default to last 30 days if not specified
// VI: Mặc định 30 ngày gần nhất nếu không chỉ định
var start = startDate ?? DateTime.UtcNow.AddDays(-30).Date;
var end = endDate ?? DateTime.UtcNow.Date;
var query = new GetCampaignMetricsQuery
{
CampaignId = id,
StartDate = start,
EndDate = end
};
var metrics = await _mediator.Send(query);
if (metrics == null)
return NotFound(new { message = $"No metrics found for campaign {id}" });
return Ok(metrics);
}
/// <summary>
/// EN: Get ad set metrics (placeholder for future implementation).
/// VI: Lấy metrics ad set (placeholder cho triển khai sau).
/// </summary>
[HttpGet("adsets/{id}/metrics")]
[ProducesResponseType(StatusCodes.Status501NotImplemented)]
public IActionResult GetAdSetMetrics(Guid id)
{
_logger.LogWarning("AdSet metrics not yet implemented");
return StatusCode(StatusCodes.Status501NotImplemented,
new { message = "AdSet metrics endpoint not yet implemented" });
}
/// <summary>
/// EN: Get ad metrics (placeholder for future implementation).
/// VI: Lấy metrics ad (placeholder cho triển khai sau).
/// </summary>
[HttpGet("ads/{id}/metrics")]
[ProducesResponseType(StatusCodes.Status501NotImplemented)]
public IActionResult GetAdMetrics(Guid id)
{
_logger.LogWarning("Ad metrics not yet implemented");
return StatusCode(StatusCodes.Status501NotImplemented,
new { message = "Ad metrics endpoint not yet implemented" });
}
}

View File

@@ -1,7 +1,8 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using AdsAnalyticsService.Domain.AggregatesModel.SampleAggregate;
using AdsAnalyticsService.Domain.AggregatesModel.MetricsAggregate;
using AdsAnalyticsService.Domain.AggregatesModel.ReportAggregate;
using AdsAnalyticsService.Domain.SeedWork;
using AdsAnalyticsService.Infrastructure.EntityConfigurations;
@@ -17,10 +18,16 @@ public class AdsAnalyticsServiceContext : DbContext, IUnitOfWork
private IDbContextTransaction? _currentTransaction;
/// <summary>
/// EN: Samples table.
/// VI: Bảng Samples.
/// EN: Campaign metrics table.
/// VI: Bảng metrics chiến dịch.
/// </summary>
public DbSet<Sample> Samples => Set<Sample>();
public DbSet<CampaignMetrics> CampaignMetrics => Set<CampaignMetrics>();
/// <summary>
/// EN: Reports table.
/// VI: Bảng báo cáo.
/// </summary>
public DbSet<Report> Reports => Set<Report>();
/// <summary>
/// EN: Read-only access to current transaction.
@@ -50,8 +57,8 @@ public class AdsAnalyticsServiceContext : DbContext, IUnitOfWork
{
// EN: Apply entity configurations
// VI: Áp dụng các cấu hình entity
modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new CampaignMetricsEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new ReportEntityTypeConfiguration());
}
/// <summary>

View File

@@ -0,0 +1,86 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using AdsAnalyticsService.Domain.AggregatesModel.MetricsAggregate;
namespace AdsAnalyticsService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity type configuration for CampaignMetrics aggregate.
/// VI: Cấu hình entity type cho CampaignMetrics aggregate.
/// </summary>
public class CampaignMetricsEntityTypeConfiguration : IEntityTypeConfiguration<CampaignMetrics>
{
public void Configure(EntityTypeBuilder<CampaignMetrics> builder)
{
// EN: Table name
// VI: Tên bảng
builder.ToTable("campaign_metrics");
// EN: Primary key
// VI: Khóa chính
builder.HasKey(m => m.Id);
builder.Property(m => m.Id)
.HasColumnName("id")
.IsRequired();
// EN: Campaign reference
// VI: Tham chiếu chiến dịch
builder.Property(m => m.CampaignId)
.HasColumnName("campaign_id")
.IsRequired();
builder.HasIndex(m => m.CampaignId)
.HasDatabaseName("idx_campaign_metrics_campaign_id");
// EN: Date (for daily aggregation)
// VI: Ngày (cho tổng hợp theo ngày)
builder.Property(m => m.Date)
.HasColumnName("date")
.HasColumnType("date")
.IsRequired();
builder.HasIndex(m => new { m.CampaignId, m.Date })
.IsUnique()
.HasDatabaseName("idx_campaign_metrics_campaign_date");
// EN: Performance metrics
// VI: Chỉ số hiệu suất
builder.Property(m => m.Impressions)
.HasColumnName("impressions")
.HasDefaultValue(0)
.IsRequired();
builder.Property(m => m.Clicks)
.HasColumnName("clicks")
.HasDefaultValue(0)
.IsRequired();
builder.Property(m => m.Conversions)
.HasColumnName("conversions")
.HasDefaultValue(0)
.IsRequired();
builder.Property(m => m.Spend)
.HasColumnName("spend")
.HasColumnType("decimal(18,2)")
.HasDefaultValue(0m)
.IsRequired();
builder.Property(m => m.Revenue)
.HasColumnName("revenue")
.HasColumnType("decimal(18,2)")
.HasDefaultValue(0m)
.IsRequired();
// EN: Ignore calculated properties
// VI: Bỏ qua các thuộc tính được tính toán
builder.Ignore(m => m.CTR);
builder.Ignore(m => m.CPC);
builder.Ignore(m => m.CPA);
builder.Ignore(m => m.ROAS);
// EN: Ignore domain events
// VI: Bỏ qua domain events
builder.Ignore(m => m.DomainEvents);
}
}

View File

@@ -0,0 +1,83 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using AdsAnalyticsService.Domain.AggregatesModel.ReportAggregate;
namespace AdsAnalyticsService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity type configuration for Report aggregate.
/// VI: Cấu hình entity type cho Report aggregate.
/// </summary>
public class ReportEntityTypeConfiguration : IEntityTypeConfiguration<Report>
{
public void Configure(EntityTypeBuilder<Report> builder)
{
// EN: Table name
// VI: Tên bảng
builder.ToTable("reports");
// EN: Primary key
// VI: Khóa chính
builder.HasKey(r => r.Id);
builder.Property(r => r.Id)
.HasColumnName("id")
.IsRequired();
// EN: Advertiser reference
// VI: Tham chiếu advertiser
builder.Property(r => r.AdvertiserId)
.HasColumnName("advertiser_id")
.IsRequired();
builder.HasIndex(r => r.AdvertiserId)
.HasDatabaseName("idx_reports_advertiser_id");
// EN: Report properties
// VI: Thuộc tính báo cáo
builder.Property(r => r.Name)
.HasColumnName("name")
.HasMaxLength(200)
.IsRequired();
builder.Property(r => r.ReportType)
.HasColumnName("report_type")
.HasConversion<int>()
.IsRequired();
builder.Property(r => r.StartDate)
.HasColumnName("start_date")
.HasColumnType("date")
.IsRequired();
builder.Property(r => r.EndDate)
.HasColumnName("end_date")
.HasColumnType("date")
.IsRequired();
builder.Property(r => r.Status)
.HasColumnName("status")
.HasConversion<int>()
.IsRequired();
builder.Property(r => r.DataJson)
.HasColumnName("data_json")
.HasColumnType("jsonb")
.IsRequired(false);
builder.Property(r => r.CreatedAt)
.HasColumnName("created_at")
.IsRequired();
// EN: Indexes
// VI: Chỉ mục
builder.HasIndex(r => r.Status)
.HasDatabaseName("idx_reports_status");
builder.HasIndex(r => r.CreatedAt)
.HasDatabaseName("idx_reports_created_at");
// EN: Ignore domain events
// VI: Bỏ qua domain events
builder.Ignore(r => r.DomainEvents);
}
}

View File

@@ -14,6 +14,10 @@
<!-- EN: FluentValidation for request validation / VI: FluentValidation cho validation request -->
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />

View File

@@ -32,7 +32,12 @@ public class AdsBillingServiceContext : DbContext, IUnitOfWork
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Entity configurations will be applied here
// EN: Apply entity configurations / VI: Áp dụng entity configurations
modelBuilder.ApplyConfiguration(new EntityConfigurations.BillingAccountEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new EntityConfigurations.InvoiceEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new EntityConfigurations.InvoiceLineItemEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new EntityConfigurations.AdChargeEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new EntityConfigurations.ClientRequestEntityTypeConfiguration());
}
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)

View File

@@ -0,0 +1,86 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using AdsBillingService.Domain.AggregatesModel.ChargeAggregate;
namespace AdsBillingService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity configuration for AdCharge aggregate.
/// VI: Cấu hình entity cho aggregate AdCharge.
/// </summary>
public class AdChargeEntityTypeConfiguration : IEntityTypeConfiguration<AdCharge>
{
public void Configure(EntityTypeBuilder<AdCharge> builder)
{
// EN: Table name / VI: Tên bảng
builder.ToTable("ad_charges");
// EN: Primary key / VI: Khóa chính
builder.HasKey(c => c.Id);
builder.Property(c => c.Id)
.HasColumnName("id")
.IsRequired();
// EN: Advertiser ID / VI: ID Advertiser
builder.Property(c => c.AdvertiserId)
.HasColumnName("advertiser_id")
.IsRequired();
builder.HasIndex(c => c.AdvertiserId)
.HasDatabaseName("ix_ad_charges_advertiser_id");
// EN: Campaign ID / VI: ID chiến dịch
builder.Property(c => c.CampaignId)
.HasColumnName("campaign_id")
.IsRequired();
builder.HasIndex(c => c.CampaignId)
.HasDatabaseName("ix_ad_charges_campaign_id");
// EN: Ad ID / VI: ID quảng cáo
builder.Property(c => c.AdId)
.HasColumnName("ad_id")
.IsRequired();
// EN: Charge type / VI: Loại charge
builder.Property(c => c.ChargeType)
.HasColumnName("charge_type")
.HasConversion<int>()
.IsRequired();
builder.HasIndex(c => c.ChargeType)
.HasDatabaseName("ix_ad_charges_charge_type");
// EN: Amount / VI: Số tiền
builder.Property(c => c.Amount)
.HasColumnName("amount")
.HasColumnType("decimal(18,6)")
.IsRequired();
// EN: Currency / VI: Loại tiền
builder.Property(c => c.Currency)
.HasColumnName("currency")
.HasMaxLength(10)
.IsRequired();
// EN: Charged at / VI: Thời điểm charge
builder.Property(c => c.ChargedAt)
.HasColumnName("charged_at")
.IsRequired();
builder.HasIndex(c => c.ChargedAt)
.HasDatabaseName("ix_ad_charges_charged_at");
// EN: Processed flag / VI: Cờ đã xử lý
builder.Property(c => c.Processed)
.HasColumnName("processed")
.IsRequired();
builder.HasIndex(c => c.Processed)
.HasDatabaseName("ix_ad_charges_processed");
// EN: Composite index for unprocessed charges / VI: Chỉ mục kết hợp cho charges chưa xử lý
builder.HasIndex(c => new { c.AdvertiserId, c.Processed, c.ChargedAt })
.HasDatabaseName("ix_ad_charges_advertiser_processed_charged");
}
}

View File

@@ -0,0 +1,88 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using AdsBillingService.Domain.AggregatesModel.BillingAccountAggregate;
namespace AdsBillingService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity configuration for BillingAccount aggregate.
/// VI: Cấu hình entity cho aggregate BillingAccount.
/// </summary>
public class BillingAccountEntityTypeConfiguration : IEntityTypeConfiguration<BillingAccount>
{
public void Configure(EntityTypeBuilder<BillingAccount> builder)
{
// EN: Table name / VI: Tên bảng
builder.ToTable("billing_accounts");
// EN: Primary key / VI: Khóa chính
builder.HasKey(b => b.Id);
builder.Property(b => b.Id)
.HasColumnName("id")
.IsRequired();
// EN: Advertiser ID / VI: ID Advertiser
builder.Property(b => b.AdvertiserId)
.HasColumnName("advertiser_id")
.IsRequired();
builder.HasIndex(b => b.AdvertiserId)
.HasDatabaseName("ix_billing_accounts_advertiser_id");
// EN: Wallet ID / VI: ID Ví
builder.Property(b => b.WalletId)
.HasColumnName("wallet_id")
.IsRequired(false);
// EN: Payment method / VI: Phương thức thanh toán
builder.Property(b => b.PaymentMethod)
.HasColumnName("payment_method")
.HasConversion<int>()
.IsRequired();
// EN: Account status / VI: Trạng thái tài khoản
builder.Property(b => b.Status)
.HasColumnName("status")
.HasConversion<int>()
.IsRequired();
// EN: Balance / VI: Số dư
builder.Property(b => b.Balance)
.HasColumnName("balance")
.HasColumnType("decimal(18,2)")
.IsRequired();
// EN: Credit limit / VI: Hạn mức tín dụng
builder.Property(b => b.CreditLimit)
.HasColumnName("credit_limit")
.HasColumnType("decimal(18,2)")
.IsRequired();
// EN: Billing threshold as owned entity (value object) / VI: Ngưỡng billing như owned entity
builder.OwnsOne(b => b.Threshold, threshold =>
{
threshold.Property(t => t.Amount)
.HasColumnName("threshold_amount")
.HasColumnType("decimal(18,2)");
threshold.Property(t => t.AutoCharge)
.HasColumnName("threshold_auto_charge");
});
// EN: Navigation for owned entity / VI: Navigation cho owned entity
builder.Navigation(b => b.Threshold).IsRequired(false);
// EN: Timestamps / VI: Thời gian
builder.Property(b => b.CreatedAt)
.HasColumnName("created_at")
.IsRequired();
builder.Property(b => b.UpdatedAt)
.HasColumnName("updated_at")
.IsRequired(false);
// EN: Indexes / VI: Chỉ mục
builder.HasIndex(b => b.CreatedAt)
.HasDatabaseName("ix_billing_accounts_created_at");
}
}

View File

@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using AdsBillingService.Infrastructure.Idempotency;
namespace AdsBillingService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity configuration for ClientRequest (idempotency).
/// VI: Cấu hình entity cho ClientRequest (idempotency).
/// </summary>
public class ClientRequestEntityTypeConfiguration : IEntityTypeConfiguration<ClientRequest>
{
public void Configure(EntityTypeBuilder<ClientRequest> builder)
{
// EN: Table name / VI: Tên bảng
builder.ToTable("client_requests");
// EN: Primary key / VI: Khóa chính
builder.HasKey(cr => cr.Id);
builder.Property(cr => cr.Id)
.HasColumnName("id")
.IsRequired();
// EN: Request ID / VI: ID yêu cầu
builder.Property(cr => cr.Name)
.HasColumnName("name")
.HasMaxLength(200)
.IsRequired();
builder.HasIndex(cr => cr.Name)
.IsUnique()
.HasDatabaseName("ix_client_requests_name");
// EN: Timestamp / VI: Thời gian
builder.Property(cr => cr.Time)
.HasColumnName("time")
.IsRequired();
}
}

View File

@@ -0,0 +1,80 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using AdsBillingService.Domain.AggregatesModel.InvoiceAggregate;
namespace AdsBillingService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity configuration for Invoice aggregate.
/// VI: Cấu hình entity cho aggregate Invoice.
/// </summary>
public class InvoiceEntityTypeConfiguration : IEntityTypeConfiguration<Invoice>
{
public void Configure(EntityTypeBuilder<Invoice> builder)
{
// EN: Table name / VI: Tên bảng
builder.ToTable("invoices");
// EN: Primary key / VI: Khóa chính
builder.HasKey(i => i.Id);
builder.Property(i => i.Id)
.HasColumnName("id")
.IsRequired();
// EN: Billing account ID / VI: ID tài khoản billing
builder.Property(i => i.BillingAccountId)
.HasColumnName("billing_account_id")
.IsRequired();
builder.HasIndex(i => i.BillingAccountId)
.HasDatabaseName("ix_invoices_billing_account_id");
// EN: Invoice number / VI: Số hóa đơn
builder.Property(i => i.InvoiceNumber)
.HasColumnName("invoice_number")
.HasMaxLength(50)
.IsRequired();
builder.HasIndex(i => i.InvoiceNumber)
.IsUnique()
.HasDatabaseName("ix_invoices_invoice_number");
// EN: Status / VI: Trạng thái
builder.Property(i => i.Status)
.HasColumnName("status")
.HasConversion<int>()
.IsRequired();
builder.HasIndex(i => i.Status)
.HasDatabaseName("ix_invoices_status");
// EN: Issue date / VI: Ngày phát hành
builder.Property(i => i.IssueDate)
.HasColumnName("issue_date")
.IsRequired();
// EN: Due date / VI: Ngày đến hạn
builder.Property(i => i.DueDate)
.HasColumnName("due_date")
.IsRequired();
builder.HasIndex(i => i.DueDate)
.HasDatabaseName("ix_invoices_due_date");
// EN: Total amount / VI: Tổng tiền
builder.Property(i => i.TotalAmount)
.HasColumnName("total_amount")
.HasColumnType("decimal(18,2)")
.IsRequired();
// EN: Line items as collection / VI: Các dòng chi tiết
builder.HasMany<InvoiceLineItem>()
.WithOne()
.HasForeignKey("InvoiceId")
.OnDelete(DeleteBehavior.Cascade);
// EN: Metadata / VI: Ignore navigation property for EF Core
builder.Metadata.FindNavigation(nameof(Invoice.LineItems))!
.SetPropertyAccessMode(PropertyAccessMode.Field);
}
}

View File

@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using AdsBillingService.Domain.AggregatesModel.InvoiceAggregate;
namespace AdsBillingService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity configuration for InvoiceLineItem.
/// VI: Cấu hình entity cho InvoiceLineItem.
/// </summary>
public class InvoiceLineItemEntityTypeConfiguration : IEntityTypeConfiguration<InvoiceLineItem>
{
public void Configure(EntityTypeBuilder<InvoiceLineItem> builder)
{
// EN: Table name / VI: Tên bảng
builder.ToTable("invoice_line_items");
// EN: Primary key / VI: Khóa chính
builder.HasKey(li => li.Id);
builder.Property(li => li.Id)
.HasColumnName("id")
.IsRequired();
// EN: Invoice ID (foreign key) / VI: ID hóa đơn
builder.Property<Guid>("InvoiceId")
.HasColumnName("invoice_id")
.IsRequired();
builder.HasIndex("InvoiceId")
.HasDatabaseName("ix_invoice_line_items_invoice_id");
// EN: Campaign ID / VI: ID chiến dịch
builder.Property(li => li.CampaignId)
.HasColumnName("campaign_id")
.IsRequired();
// EN: Description / VI: Mô tả
builder.Property(li => li.Description)
.HasColumnName("description")
.HasMaxLength(500)
.IsRequired();
// EN: Quantity / VI: Số lượng
builder.Property(li => li.Quantity)
.HasColumnName("quantity")
.IsRequired();
// EN: Unit price / VI: Đơn giá
builder.Property(li => li.UnitPrice)
.HasColumnName("unit_price")
.HasColumnType("decimal(18,2)")
.IsRequired();
// EN: Total amount is computed / VI: Tổng tiền được tính toán
builder.Ignore(li => li.TotalAmount);
}
}

View File

@@ -33,6 +33,12 @@
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
<!-- EN: EF Core Design for migrations / VI: EF Core Design cho migrations -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,38 @@
using MediatR;
namespace AdsServingService.API.Application.Queries;
/// <summary>
/// EN: Query to get paginated list of auctions with filters.
/// VI: Query lấy danh sách auctions phân trang với bộ lọc.
/// </summary>
public class GetAuctionsQuery : IRequest<PagedResult<AuctionDto>>
{
public Guid? UserId { get; init; }
public string? PlacementType { get; init; }
public DateTime? StartDate { get; init; }
public DateTime? EndDate { get; init; }
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 20;
}
public record AuctionDto
{
public Guid Id { get; init; }
public Guid UserId { get; init; }
public string PlacementType { get; init; } = string.Empty;
public DateTime AuctionTime { get; init; }
public int BidCount { get; init; }
public Guid? WinningAdId { get; init; }
public decimal? FinalPrice { get; init; }
public decimal? WinningeCPM { get; init; }
}
public record PagedResult<T>
{
public List<T> Items { get; init; } = new();
public int TotalCount { get; init; }
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
}

View File

@@ -0,0 +1,66 @@
using AdsServingService.Infrastructure;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace AdsServingService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetAuctionsQuery.
/// VI: Handler cho GetAuctionsQuery.
/// </summary>
public class GetAuctionsQueryHandler : IRequestHandler<GetAuctionsQuery, PagedResult<AuctionDto>>
{
private readonly AdsServingServiceContext _context;
public GetAuctionsQueryHandler(AdsServingServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<PagedResult<AuctionDto>> Handle(GetAuctionsQuery request, CancellationToken cancellationToken)
{
var query = _context.Auctions.AsQueryable();
// Apply filters
if (request.UserId.HasValue)
query = query.Where(a => a.UserId == request.UserId.Value);
if (!string.IsNullOrEmpty(request.PlacementType))
query = query.Where(a => a.PlacementType == request.PlacementType);
if (request.StartDate.HasValue)
query = query.Where(a => EF.Property<DateTime>(a, "_auctionTime") >= request.StartDate.Value);
if (request.EndDate.HasValue)
query = query.Where(a => EF.Property<DateTime>(a, "_auctionTime") <= request.EndDate.Value);
// Get total count
var totalCount = await query.CountAsync(cancellationToken);
// Apply pagination
var auctions = await query
.OrderByDescending(a => EF.Property<DateTime>(a, "_auctionTime"))
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.Select(a => new AuctionDto
{
Id = a.Id,
UserId = a.UserId,
PlacementType = a.PlacementType,
AuctionTime = EF.Property<DateTime>(a, "_auctionTime"),
BidCount = a.Bids.Count,
WinningAdId = a.Result != null ? a.Result.WinningAdId : null,
FinalPrice = a.Result != null ? a.Result.FinalPrice : null,
WinningeCPM = a.Result != null ? a.Result.WinningeCPM : null
})
.ToListAsync(cancellationToken);
return new PagedResult<AuctionDto>
{
Items = auctions,
TotalCount = totalCount,
Page = request.Page,
PageSize = request.PageSize
};
}
}

View File

@@ -0,0 +1,35 @@
using MediatR;
namespace AdsServingService.API.Application.Queries;
/// <summary>
/// EN: Query to get paginated list of budget pacers.
/// VI: Query lấy danh sách budget pacers phân trang.
/// </summary>
public class GetBudgetPacersQuery : IRequest<PagedResult<BudgetPacerDto>>
{
public Guid? CampaignId { get; init; }
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 20;
}
public record BudgetPacerDto
{
public Guid Id { get; init; }
public Guid CampaignId { get; init; }
public decimal DailyBudget { get; init; }
public decimal SpentToday { get; init; }
public decimal RemainingBudget { get; init; }
public decimal UtilizationPercent { get; init; }
public string Strategy { get; init; } = string.Empty;
public DateTime LastUpdated { get; init; }
}
public record BudgetStatisticsDto
{
public int TotalCampaigns { get; init; }
public decimal TotalDailyBudget { get; init; }
public decimal TotalSpentToday { get; init; }
public decimal AverageUtilization { get; init; }
public int CampaignsExceeded { get; init; }
}

View File

@@ -0,0 +1,57 @@
using AdsServingService.Infrastructure;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace AdsServingService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetBudgetPacersQuery.
/// VI: Handler cho GetBudgetPacersQuery.
/// </summary>
public class GetBudgetPacersQueryHandler : IRequestHandler<GetBudgetPacersQuery, PagedResult<BudgetPacerDto>>
{
private readonly AdsServingServiceContext _context;
public GetBudgetPacersQueryHandler(AdsServingServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<PagedResult<BudgetPacerDto>> Handle(GetBudgetPacersQuery request, CancellationToken cancellationToken)
{
var query = _context.BudgetPacers.AsQueryable();
// Apply filters
if (request.CampaignId.HasValue)
query = query.Where(bp => EF.Property<Guid>(bp, "_campaignId") == request.CampaignId.Value);
// Get total count
var totalCount = await query.CountAsync(cancellationToken);
// Apply pagination
var pacers = await query
.OrderByDescending(bp => EF.Property<DateTime>(bp, "_lastUpdated"))
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.Select(bp => new BudgetPacerDto
{
Id = bp.Id,
CampaignId = EF.Property<Guid>(bp, "_campaignId"),
DailyBudget = EF.Property<decimal>(bp, "_dailyBudget"),
SpentToday = EF.Property<decimal>(bp, "_spentToday"),
RemainingBudget = bp.RemainingBudget,
UtilizationPercent = bp.UtilizationPercent,
Strategy = bp.Strategy.ToString(),
LastUpdated = EF.Property<DateTime>(bp, "_lastUpdated")
})
.ToListAsync(cancellationToken);
return new PagedResult<BudgetPacerDto>
{
Items = pacers,
TotalCount = totalCount,
Page = request.Page,
PageSize = request.PageSize
};
}
}

View File

@@ -0,0 +1,80 @@
using AdsServingService.API.Application.Queries;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace AdsServingService.API.Controllers;
/// <summary>
/// EN: Admin API Controller for managing and monitoring auctions.
/// VI: API Controller Admin để quản lý và giám sát các phiên đấu giá.
/// </summary>
[ApiController]
[Route("api/v1/admin/auctions")]
[Produces("application/json")]
public class AdminAuctionsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<AdminAuctionsController> _logger;
public AdminAuctionsController(IMediator mediator, ILogger<AdminAuctionsController> logger)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: Get paginated list of auctions with optional filters.
/// VI: Lấy danh sách auctions phân trang với bộ lọc tùy chọn.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResult<AuctionDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResult<AuctionDto>>> GetAuctions(
[FromQuery] Guid? userId,
[FromQuery] string? placementType,
[FromQuery] DateTime? startDate,
[FromQuery] DateTime? endDate,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var query = new GetAuctionsQuery
{
UserId = userId,
PlacementType = placementType,
StartDate = startDate,
EndDate = endDate,
Page = page,
PageSize = pageSize
};
var result = await _mediator.Send(query);
return Ok(result);
}
/// <summary>
/// EN: Get auction statistics (win rates, average eCPM, etc.).
/// VI: Lấy thống kê đấu giá (tỷ lệ thắng, eCPM trung bình, v.v.).
/// </summary>
[HttpGet("statistics")]
[ProducesResponseType(typeof(AuctionStatisticsDto), StatusCodes.Status200OK)]
public ActionResult<AuctionStatisticsDto> GetStatistics()
{
// TODO: Implement statistics query handler
_logger.LogInformation("Auction statistics requested");
return Ok(new AuctionStatisticsDto
{
TotalAuctions = 0,
AverageWinRate = 0,
AverageeCPM = 0,
TotalBidsPlaced = 0
});
}
}
public record AuctionStatisticsDto
{
public int TotalAuctions { get; init; }
public decimal AverageWinRate { get; init; }
public decimal AverageeCPM { get; init; }
public long TotalBidsPlaced { get; init; }
}

View File

@@ -36,7 +36,9 @@ public class AdsServingServiceContext : DbContext, IUnitOfWork
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Entity configurations will be added here
// EN: Apply all entity configurations from current assembly
// VI: Áp dụng tất cả entity configurations từ assembly hiện tại
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AdsServingServiceContext).Assembly);
}
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)

View File

@@ -0,0 +1,85 @@
using AdsServingService.Domain.AggregatesModel.AuctionAggregate;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace AdsServingService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core entity configuration for Auction aggregate.
/// VI: Cấu hình entity EF Core cho aggregate Auction.
/// </summary>
public class AuctionEntityTypeConfiguration : IEntityTypeConfiguration<Auction>
{
public void Configure(EntityTypeBuilder<Auction> builder)
{
// Table mapping
builder.ToTable("auctions");
// Primary key
builder.HasKey(a => a.Id);
builder.Property(a => a.Id)
.HasColumnName("id")
.ValueGeneratedNever();
// Properties
builder.Property(a => a.UserId)
.HasColumnName("user_id")
.IsRequired();
builder.Property(a => a.PlacementType)
.HasColumnName("placement_type")
.HasMaxLength(50)
.IsRequired();
builder.Property<DateTime>("_auctionTime")
.HasColumnName("auction_time")
.IsRequired();
// Owned collection: Bids (stored as JSONB for performance)
// EN: Store bids as JSONB for fast read/write in high-throughput scenarios
// VI: Lưu bids dưới dạng JSONB để đọc/ghi nhanh trong các tình huống throughput cao
// Note: EF Core 10 has issues with OwnsMany + ToJson, so we store as a simple property
builder.Ignore(a => a.Bids);
builder.Property<string>("_bidsJson")
.HasColumnName("bids")
.HasColumnType("jsonb");
// Owned value object: AuctionResult (nullable)
builder.OwnsOne<AuctionResult>("_result", resultBuilder =>
{
resultBuilder.Property(r => r.WinningAdId)
.HasColumnName("winning_ad_id");
resultBuilder.Property(r => r.WinningCampaignId)
.HasColumnName("winning_campaign_id");
resultBuilder.Property(r => r.FinalPrice)
.HasColumnName("final_price")
.HasColumnType("decimal(18,4)");
resultBuilder.Property(r => r.WinningeCPM)
.HasColumnName("winning_ecpm")
.HasColumnType("decimal(18,4)");
});
// Indexes for query performance
// EN: Index on user_id for user-specific queries
// VI: Index trên user_id cho các truy vấn theo người dùng
builder.HasIndex(a => a.UserId)
.HasDatabaseName("ix_auctions_user_id");
// EN: Index on placement_type for placement-specific queries
// VI: Index trên placement_type cho các truy vấn theo vị trí
builder.HasIndex(a => a.PlacementType)
.HasDatabaseName("ix_auctions_placement_type");
// EN: Index on auction_time for time-range queries
// VI: Index trên auction_time cho các truy vấn theo khoảng thời gian
builder.HasIndex("_auctionTime")
.HasDatabaseName("ix_auctions_auction_time");
// Composite index for common queries
builder.HasIndex(a => new { a.PlacementType, a.UserId })
.HasDatabaseName("ix_auctions_placement_user");
}
}

View File

@@ -0,0 +1,61 @@
using AdsServingService.Domain.AggregatesModel.PacingAggregate;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace AdsServingService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core entity configuration for BudgetPacer aggregate.
/// VI: Cấu hình entity EF Core cho aggregate BudgetPacer.
/// </summary>
public class BudgetPacerEntityTypeConfiguration : IEntityTypeConfiguration<BudgetPacer>
{
public void Configure(EntityTypeBuilder<BudgetPacer> builder)
{
// Table mapping
builder.ToTable("budget_pacers");
// Primary key
builder.HasKey(bp => bp.Id);
builder.Property(bp => bp.Id)
.HasColumnName("id")
.ValueGeneratedNever();
// Properties
builder.Property<Guid>("_campaignId")
.HasColumnName("campaign_id")
.IsRequired();
builder.Property<decimal>("_dailyBudget")
.HasColumnName("daily_budget")
.HasColumnType("decimal(18,4)")
.IsRequired();
builder.Property<decimal>("_spentToday")
.HasColumnName("spent_today")
.HasColumnType("decimal(18,4)")
.IsRequired();
builder.Property<PacingStrategy>("_strategy")
.HasColumnName("strategy")
.HasConversion<string>()
.HasMaxLength(20)
.IsRequired();
builder.Property<DateTime>("_lastUpdated")
.HasColumnName("last_updated")
.IsRequired();
// Indexes
// EN: Unique index on campaign_id (one pacer per campaign)
// VI: Index duy nhất trên campaign_id (một pacer cho mỗi campaign)
builder.HasIndex("_campaignId")
.IsUnique()
.HasDatabaseName("ix_budget_pacers_campaign_id");
// EN: Index on last_updated for finding stale pacers
// VI: Index trên last_updated để tìm các pacer cũ
builder.HasIndex("_lastUpdated")
.HasDatabaseName("ix_budget_pacers_last_updated");
}
}

View File

@@ -0,0 +1,45 @@
using AdsServingService.Domain.AggregatesModel.FrequencyAggregate;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace AdsServingService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core entity configuration for FrequencyCap aggregate.
/// VI: Cấu hình entity EF Core cho aggregate FrequencyCap.
/// </summary>
public class FrequencyCapEntityTypeConfiguration : IEntityTypeConfiguration<FrequencyCap>
{
public void Configure(EntityTypeBuilder<FrequencyCap> builder)
{
// Table mapping
builder.ToTable("frequency_caps");
// Primary key
builder.HasKey(fc => fc.Id);
builder.Property(fc => fc.Id)
.HasColumnName("id")
.ValueGeneratedNever();
// Properties
builder.Property<Guid>("_adId")
.HasColumnName("ad_id")
.IsRequired();
builder.Property<int>("_maxImpressionsPerUser")
.HasColumnName("max_impressions_per_user")
.IsRequired();
builder.Property<FrequencyWindow>("_window")
.HasColumnName("window")
.HasConversion<string>()
.HasMaxLength(20)
.IsRequired();
// Indexes
// EN: Index on ad_id for ad-specific frequency cap lookups
// VI: Index trên ad_id để tra cứu frequency cap theo quảng cáo
builder.HasIndex("_adId")
.HasDatabaseName("ix_frequency_caps_ad_id");
}
}

View File

@@ -0,0 +1,24 @@
// EN: Appointment status enumeration.
// VI: Enumeration trạng thái cuộc hẹn.
using BookingService.Domain.SeedWork;
namespace BookingService.Domain.AggregatesModel.AppointmentAggregate;
/// <summary>
/// EN: Appointment status enumeration - represents the lifecycle of an appointment.
/// VI: Enumeration trạng thái cuộc hẹn - đại diện cho vòng đời của cuộc hẹn.
/// </summary>
public class AppointmentStatus : Enumeration
{
public static AppointmentStatus Pending = new(1, nameof(Pending));
public static AppointmentStatus Confirmed = new(2, nameof(Confirmed));
public static AppointmentStatus InProgress = new(3, nameof(InProgress));
public static AppointmentStatus Completed = new(4, nameof(Completed));
public static AppointmentStatus Cancelled = new(5, nameof(Cancelled));
public static AppointmentStatus NoShow = new(6, nameof(NoShow));
public AppointmentStatus(int id, string name) : base(id, name)
{
}
}

View File

@@ -2,6 +2,8 @@ using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using BookingService.Domain.AggregatesModel.AppointmentAggregate;
using BookingService.Domain.AggregatesModel.ResourceAggregate;
using BookingService.Domain.AggregatesModel.StaffAggregate;
using BookingService.Domain.SeedWork;
using BookingService.Infrastructure.EntityConfigurations;
@@ -13,6 +15,8 @@ public class BookingContext : DbContext, IUnitOfWork
private IDbContextTransaction? _currentTransaction;
public DbSet<Appointment> Appointments => Set<Appointment>();
public DbSet<Resource> Resources => Set<Resource>();
public DbSet<StaffSchedule> StaffSchedules => Set<StaffSchedule>();
public IDbContextTransaction? CurrentTransaction => _currentTransaction;
public bool HasActiveTransaction => _currentTransaction != null;
@@ -25,6 +29,8 @@ public class BookingContext : DbContext, IUnitOfWork
{
modelBuilder.ApplyConfiguration(new AppointmentEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new AppointmentStatusEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new ResourceEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new StaffScheduleEntityTypeConfiguration());
}
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)

View File

@@ -22,7 +22,7 @@ public static class DependencyInjection
IConfiguration configuration)
{
// EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL
services.AddDbContext<BookingServiceContext>(options =>
services.AddDbContext<BookingContext>(options =>
{
var connectionString = configuration.GetConnectionString("DefaultConnection")
?? configuration["DATABASE_URL"]
@@ -30,7 +30,7 @@ public static class DependencyInjection
options.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.MigrationsAssembly(typeof(BookingServiceContext).Assembly.FullName);
npgsqlOptions.MigrationsAssembly(typeof(BookingContext).Assembly.FullName);
npgsqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
@@ -47,7 +47,7 @@ public static class DependencyInjection
});
// EN: Register repositories / VI: Đăng ký repositories
services.AddScoped<ISampleRepository, SampleRepository>();
services.AddScoped<IAppointmentRepository, AppointmentRepository>();
// EN: Register idempotency services / VI: Đăng ký idempotency services
services.AddScoped<IRequestManager, RequestManager>();

View File

@@ -1,38 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using BookingService.Domain.AggregatesModel.AppointmentAggregate;
namespace BookingService.Infrastructure.EntityConfigurations;
public class AppointmentEntityTypeConfiguration : IEntityTypeConfiguration<Appointment>
{
public void Configure(EntityTypeBuilder<Appointment> builder)
{
builder.ToTable("appointments");
builder.HasKey(a => a.Id);
builder.Property(a => a.Id).HasColumnName("id").ValueGeneratedNever();
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
builder.Property<Guid>("_customerId").HasColumnName("customer_id").IsRequired();
builder.Property<Guid>("_serviceId").HasColumnName("service_id").IsRequired();
builder.Property(a => a.StatusId).HasColumnName("status_id").IsRequired();
builder.Property<DateTime>("_scheduledAt").HasColumnName("scheduled_at").IsRequired();
builder.Property<int>("_durationMinutes").HasColumnName("duration_minutes").IsRequired();
builder.Property<Guid?>("_staffId").HasColumnName("staff_id");
builder.Property<Guid?>("_resourceId").HasColumnName("resource_id");
builder.Property<string?>("_notes").HasColumnName("notes").HasMaxLength(1000);
builder.HasIndex("_shopId").HasDatabaseName("ix_appointments_shop_id");
builder.HasIndex("_customerId").HasDatabaseName("ix_appointments_customer_id");
builder.HasIndex("_scheduledAt").HasDatabaseName("ix_appointments_scheduled_at");
builder.Ignore(a => a.ShopId);
builder.Ignore(a => a.CustomerId);
builder.Ignore(a => a.ServiceId);
builder.Ignore(a => a.Status);
builder.Ignore(a => a.ScheduledAt);
builder.Ignore(a => a.DurationMinutes);
builder.Ignore(a => a.StaffId);
builder.Ignore(a => a.ResourceId);
builder.Ignore(a => a.Notes);
}
}

View File

@@ -0,0 +1,64 @@
// EN: Entity type configuration for Resource.
// VI: Cấu hình entity type cho Resource.
using BookingService.Domain.AggregatesModel.ResourceAggregate;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace BookingService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity type configuration for Resource aggregate.
/// VI: Cấu hình entity type cho aggregate Resource.
/// </summary>
public class ResourceEntityTypeConfiguration : IEntityTypeConfiguration<Resource>
{
public void Configure(EntityTypeBuilder<Resource> builder)
{
builder.ToTable("resources");
builder.HasKey(r => r.Id);
builder.Property(r => r.Id)
.HasColumnName("id")
.ValueGeneratedNever();
builder.Property<Guid>("_shopId")
.HasColumnName("shop_id")
.IsRequired();
builder.Property<string>("_name")
.HasColumnName("name")
.HasMaxLength(200)
.IsRequired();
builder.Property<string>("_resourceType")
.HasColumnName("resource_type")
.HasMaxLength(50)
.IsRequired();
builder.Property<int>("_capacity")
.HasColumnName("capacity")
.IsRequired();
builder.Property<bool>("_isActive")
.HasColumnName("is_active")
.IsRequired();
builder.Property<DateTime>("_createdAt")
.HasColumnName("created_at")
.IsRequired();
// EN: Indexes / VI: Indexes
builder.HasIndex("_shopId")
.HasDatabaseName("ix_resources_shop_id");
// EN: Ignore public properties (mapped via backing fields)
// VI: Bỏ qua các properties công khai (đã map qua backing fields)
builder.Ignore(r => r.ShopId);
builder.Ignore(r => r.Name);
builder.Ignore(r => r.ResourceType);
builder.Ignore(r => r.Capacity);
builder.Ignore(r => r.IsActive);
builder.Ignore(r => r.CreatedAt);
}
}

View File

@@ -0,0 +1,60 @@
// EN: Entity type configuration for StaffSchedule.
// VI: Cấu hình entity type cho StaffSchedule.
using BookingService.Domain.AggregatesModel.StaffAggregate;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace BookingService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity type configuration for StaffSchedule entity.
/// VI: Cấu hình entity type cho entity StaffSchedule.
/// </summary>
public class StaffScheduleEntityTypeConfiguration : IEntityTypeConfiguration<StaffSchedule>
{
public void Configure(EntityTypeBuilder<StaffSchedule> builder)
{
builder.ToTable("staff_schedules");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id)
.HasColumnName("id")
.ValueGeneratedNever();
builder.Property<Guid>("_staffId")
.HasColumnName("staff_id")
.IsRequired();
builder.Property<Guid>("_shopId")
.HasColumnName("shop_id")
.IsRequired();
builder.Property<int>("_dayOfWeek")
.HasColumnName("day_of_week")
.IsRequired();
builder.Property<TimeOnly>("_startTime")
.HasColumnName("start_time")
.IsRequired();
builder.Property<TimeOnly>("_endTime")
.HasColumnName("end_time")
.IsRequired();
// EN: Indexes / VI: Indexes
builder.HasIndex("_staffId", "_dayOfWeek")
.HasDatabaseName("ix_staff_schedules_staff_day");
builder.HasIndex("_shopId")
.HasDatabaseName("ix_staff_schedules_shop_id");
// EN: Ignore public properties (mapped via backing fields)
// VI: Bỏ qua các properties công khai (đã map qua backing fields)
builder.Ignore(s => s.StaffId);
builder.Ignore(s => s.ShopId);
builder.Ignore(s => s.DayOfWeek);
builder.Ignore(s => s.StartTime);
builder.Ignore(s => s.EndTime);
}
}

View File

@@ -8,9 +8,9 @@ namespace BookingService.Infrastructure.Idempotency;
/// </summary>
public class RequestManager : IRequestManager
{
private readonly BookingServiceContext _context;
private readonly BookingContext _context;
public RequestManager(BookingServiceContext context)
public RequestManager(BookingContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}

View File

@@ -0,0 +1,47 @@
// EN: Repository implementation for Appointment aggregate.
// VI: Implementation repository cho aggregate Appointment.
using BookingService.Domain.AggregatesModel.AppointmentAggregate;
using BookingService.Domain.SeedWork;
using Microsoft.EntityFrameworkCore;
namespace BookingService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for managing Appointment aggregate persistence.
/// VI: Implementation repository để quản lý persistence của aggregate Appointment.
/// </summary>
public class AppointmentRepository : IAppointmentRepository
{
private readonly BookingContext _context;
public IUnitOfWork UnitOfWork => _context;
public AppointmentRepository(BookingContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public Appointment Add(Appointment appointment)
{
return _context.Appointments.Add(appointment).Entity;
}
public void Update(Appointment appointment)
{
_context.Entry(appointment).State = EntityState.Modified;
}
public async Task<Appointment?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Appointments
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
}
public async Task<List<Appointment>> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default)
{
return await _context.Appointments
.Where(a => a.ShopId == shopId)
.ToListAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,38 @@
// EN: Repository interface for Appointment aggregate.
// VI: Interface repository cho aggregate Appointment.
using BookingService.Domain.AggregatesModel.AppointmentAggregate;
using BookingService.Domain.SeedWork;
namespace BookingService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository interface for managing Appointment aggregate persistence.
/// VI: Interface repository để quản lý persistence của aggregate Appointment.
/// </summary>
public interface IAppointmentRepository : IRepository<Appointment>
{
/// <summary>
/// EN: Add a new appointment.
/// VI: Thêm cuộc hẹn mới.
/// </summary>
Appointment Add(Appointment appointment);
/// <summary>
/// EN: Update an existing appointment.
/// VI: Cập nhật cuộc hẹn hiện có.
/// </summary>
void Update(Appointment appointment);
/// <summary>
/// EN: Get appointment by ID.
/// VI: Lấy cuộc hẹn theo ID.
/// </summary>
Task<Appointment?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get appointments by shop ID.
/// VI: Lấy danh sách cuộc hẹn theo shop ID.
/// </summary>
Task<List<Appointment>> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,43 @@
// EN: Command to create a new category.
// VI: Command để tạo danh mục mới.
using MediatR;
namespace CatalogService.API.Application.Commands;
/// <summary>
/// EN: Command to create a new category.
/// VI: Command để tạo danh mục mới.
/// </summary>
public record CreateCategoryCommand : IRequest<Guid>
{
/// <summary>
/// EN: Shop ID that will own this category.
/// VI: ID shop sẽ sở hữu danh mục.
/// </summary>
public Guid ShopId { get; init; }
/// <summary>
/// EN: Category name.
/// VI: Tên danh mục.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// EN: Category description.
/// VI: Mô tả danh mục.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// EN: Parent category ID for hierarchical categories.
/// VI: ID danh mục cha cho danh mục phân cấp.
/// </summary>
public Guid? ParentId { get; init; }
/// <summary>
/// EN: Display order.
/// VI: Thứ tự hiển thị.
/// </summary>
public int DisplayOrder { get; init; }
}

View File

@@ -0,0 +1,41 @@
// EN: Handler for CreateCategoryCommand.
// VI: Handler cho CreateCategoryCommand.
using MediatR;
using CatalogService.Domain.AggregatesModel.ProductAggregate;
using CatalogService.Infrastructure;
namespace CatalogService.API.Application.Commands;
/// <summary>
/// EN: Handler for creating a new category.
/// VI: Handler để tạo danh mục mới.
/// </summary>
public class CreateCategoryCommandHandler : IRequestHandler<CreateCategoryCommand, Guid>
{
private readonly CatalogContext _context;
public CreateCategoryCommandHandler(CatalogContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<Guid> Handle(CreateCategoryCommand request, CancellationToken cancellationToken)
{
// EN: Create category entity
// VI: Tạo category entity
var category = new Category(
shopId: request.ShopId,
name: request.Name,
description: request.Description,
parentId: request.ParentId,
displayOrder: request.DisplayOrder);
// EN: Add to context and save
// VI: Thêm vào context và lư
_context.Categories.Add(category);
await _context.SaveChangesAsync(cancellationToken);
return category.Id;
}
}

View File

@@ -0,0 +1,61 @@
// EN: Command to create a new product.
// VI: Command để tạo sản phẩm mới.
using MediatR;
namespace CatalogService.API.Application.Commands;
/// <summary>
/// EN: Command to create a new product.
/// VI: Command để tạo sản phẩm mới.
/// </summary>
public record CreateProductCommand : IRequest<Guid>
{
/// <summary>
/// EN: Shop ID that will own this product.
/// VI: ID shop sẽ sở hữu sản phẩm.
/// </summary>
public Guid ShopId { get; init; }
/// <summary>
/// EN: Product name.
/// VI: Tên sản phẩm.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// EN: Product description.
/// VI: Mô tả sản phẩm.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// EN: Product price.
/// VI: Giá sản phẩm.
/// </summary>
public decimal Price { get; init; }
/// <summary>
/// EN: Product type (Physical, Service, PreparedFood).
/// VI: Loại sản phẩm (Physical, Service, PreparedFood).
/// </summary>
public string Type { get; init; } = string.Empty;
/// <summary>
/// EN: Type-specific attributes as JSON.
/// VI: Thuộc tính theo loại dưới dạng JSON.
/// </summary>
public Dictionary<string, object>? Attributes { get; init; }
/// <summary>
/// EN: Stock Keeping Unit.
/// VI: Mã SKU.
/// </summary>
public string? Sku { get; init; }
/// <summary>
/// EN: Product image URL.
/// VI: URL hình ảnh sản phẩm.
/// </summary>
public string? ImageUrl { get; init; }
}

View File

@@ -0,0 +1,63 @@
// EN: Handler for CreateProductCommand.
// VI: Handler cho CreateProductCommand.
using System.Text.Json;
using MediatR;
using CatalogService.Domain.AggregatesModel.ProductAggregate;
namespace CatalogService.API.Application.Commands;
/// <summary>
/// EN: Handler for creating a new product.
/// VI: Handler để tạo sản phẩm mới.
/// </summary>
public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, Guid>
{
private readonly IProductRepository _productRepository;
public CreateProductCommandHandler(IProductRepository productRepository)
{
_productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository));
}
public async Task<Guid> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
// EN: Get ProductType enumeration
// VI: Lấy ProductType enumeration
var productType = ProductType.FromName(request.Type);
// EN: Convert attributes dictionary to JsonDocument
// VI: Chuyển attributes dictionary sang JsonDocument
JsonDocument? attributesJson = null;
if (request.Attributes != null)
{
var json = JsonSerializer.Serialize(request.Attributes);
attributesJson = JsonDocument.Parse(json);
}
// EN: Create product aggregate
// VI: Tạo product aggregate
var product = new Product(
shopId: request.ShopId,
name: request.Name,
price: request.Price,
type: productType,
description: request.Description,
attributes: attributesJson,
sku: request.Sku);
// EN: Update image if provided
// VI: Cập nhật image nếu được cung cấp
if (!string.IsNullOrWhiteSpace(request.ImageUrl))
{
product.UpdateImage(request.ImageUrl);
}
// EN: Add to repository and save
// VI: Thêm vào repository và lưu
_productRepository.Add(product);
await _productRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return product.Id;
}
}

View File

@@ -0,0 +1,24 @@
// EN: Command to delete a product.
// VI: Command để xóa sản phẩm.
using MediatR;
namespace CatalogService.API.Application.Commands;
/// <summary>
/// EN: Command to delete a product.
/// VI: Command để xóa sản phẩm.
/// </summary>
public record DeleteProductCommand : IRequest<bool>
{
/// <summary>
/// EN: Product ID to delete.
/// VI: ID sản phẩm cần xóa.
/// </summary>
public Guid ProductId { get; init; }
public DeleteProductCommand(Guid productId)
{
ProductId = productId;
}
}

View File

@@ -0,0 +1,45 @@
// EN: Handler for DeleteProductCommand.
// VI: Handler cho DeleteProductCommand.
using MediatR;
using CatalogService.Domain.AggregatesModel.ProductAggregate;
using CatalogService.Domain.Exceptions;
namespace CatalogService.API.Application.Commands;
/// <summary>
/// EN: Handler for deleting a product.
/// VI: Handler xóa sản phẩm.
/// </summary>
public class DeleteProductCommandHandler : IRequestHandler<DeleteProductCommand, bool>
{
private readonly IProductRepository _productRepository;
public DeleteProductCommandHandler(IProductRepository productRepository)
{
_productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository));
}
public async Task<bool> Handle(DeleteProductCommand request, CancellationToken cancellationToken)
{
// EN: Retrieve product aggregate
// VI: Lấy product aggregate
var product = await _productRepository.GetByIdAsync(request.ProductId, cancellationToken);
if (product == null)
{
throw new DomainException($"Product with ID {request.ProductId} not found");
}
// EN: Deactivate product instead of hard delete
// VI: Vô hiệu hóa sản phẩm thay vì xóa cứng
product.Deactivate();
// EN: Update repository and save
// VI: Cập nhật repository và lưu
_productRepository.Update(product);
await _productRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return true;
}
}

View File

@@ -0,0 +1,49 @@
// EN: Command to update an existing product.
// VI: Command để cập nhật sản phẩm hiện tại.
using MediatR;
namespace CatalogService.API.Application.Commands;
/// <summary>
/// EN: Command to update product information.
/// VI: Command để cập nhật thông tin sản phẩm.
/// </summary>
public record UpdateProductCommand : IRequest<bool>
{
/// <summary>
/// EN: Product ID to update.
/// VI: ID sản phẩm cần cập nhật.
/// </summary>
public Guid ProductId { get; init; }
/// <summary>
/// EN: Product name.
/// VI: Tên sản phẩm.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// EN: Product description.
/// VI: Mô tả sản phẩm.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// EN: Product price.
/// VI: Giá sản phẩm.
/// </summary>
public decimal Price { get; init; }
/// <summary>
/// EN: Type-specific attributes as JSON.
/// VI: Thuộc tính theo loại dưới dạng JSON.
/// </summary>
public Dictionary<string, object>? Attributes { get; init; }
/// <summary>
/// EN: Product image URL.
/// VI: URL hình ảnh sản phẩm.
/// </summary>
public string? ImageUrl { get; init; }
}

View File

@@ -0,0 +1,62 @@
// EN: Handler for UpdateProductCommand.
// VI: Handler cho UpdateProductCommand.
using System.Text.Json;
using MediatR;
using CatalogService.Domain.AggregatesModel.ProductAggregate;
using CatalogService.Domain.Exceptions;
namespace CatalogService.API.Application.Commands;
/// <summary>
/// EN: Handler for updating a product.
/// VI: Handler cập nhật sản phẩm.
/// </summary>
public class UpdateProductCommandHandler : IRequestHandler<UpdateProductCommand, bool>
{
private readonly IProductRepository _productRepository;
public UpdateProductCommandHandler(IProductRepository productRepository)
{
_productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository));
}
public async Task<bool> Handle(UpdateProductCommand request, CancellationToken cancellationToken)
{
// EN: Retrieve product aggregate
// VI: Lấy product aggregate
var product = await _productRepository.GetByIdAsync(request.ProductId, cancellationToken);
if (product == null)
{
throw new DomainException($"Product with ID {request.ProductId} not found");
}
// EN: Update basic information
// VI: Cập nhật thông tin cơ bản
product.UpdateInfo(request.Name, request.Description, request.Price);
// EN: Update attributes if provided
// VI: Cập nhật attributes nếu được cung cấp
if (request.Attributes != null)
{
var json = JsonSerializer.Serialize(request.Attributes);
var attributesJson = JsonDocument.Parse(json);
product.UpdateAttributes(attributesJson);
}
// EN: Update image if provided
// VI: Cập nhật image nếu được cung cấp
if (request.ImageUrl != null)
{
product.UpdateImage(request.ImageUrl);
}
// EN: Update repository and save
// VI: Cập nhật repository và lưu
_productRepository.Update(product);
await _productRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return true;
}
}

View File

@@ -0,0 +1,65 @@
// EN: Category Data Transfer Object for read operations.
// VI: DTO Category cho các thao tác đọc.
namespace CatalogService.API.Application.DTOs;
/// <summary>
/// EN: Category DTO for API responses.
/// VI: DTO Category cho API response.
/// </summary>
public record CategoryDto
{
/// <summary>
/// EN: Category ID.
/// VI: ID danh mục.
/// </summary>
public Guid Id { get; init; }
/// <summary>
/// EN: Shop ID that owns this category.
/// VI: ID shop sở hữu danh mục.
/// </summary>
public Guid ShopId { get; init; }
/// <summary>
/// EN: Category name.
/// VI: Tên danh mục.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// EN: Category description.
/// VI: Mô tả danh mục.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// EN: Parent category ID for hierarchical categories.
/// VI: ID danh mục cha cho danh mục phân cấp.
/// </summary>
public Guid? ParentId { get; init; }
/// <summary>
/// EN: Display order.
/// VI: Thứ tự hiển thị.
/// </summary>
public int DisplayOrder { get; init; }
/// <summary>
/// EN: Is category active.
/// VI: Danh mục có đang hoạt động không.
/// </summary>
public bool IsActive { get; init; }
/// <summary>
/// EN: Creation timestamp.
/// VI: Thời gian tạo.
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// EN: Last update timestamp.
/// VI: Thời gian cập nhật cuối.
/// </summary>
public DateTime? UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,53 @@
// EN: Paged result wrapper for query responses.
// VI: Wrapper kết quả phân trang cho query response.
namespace CatalogService.API.Application.DTOs;
/// <summary>
/// EN: Generic paged result for list queries.
/// VI: Kết quả phân trang chung cho các query danh sách.
/// </summary>
public record PagedResult<T>
{
/// <summary>
/// EN: Items in the current page.
/// VI: Các items trong trang hiện tại.
/// </summary>
public IReadOnlyList<T> Items { get; init; } = Array.Empty<T>();
/// <summary>
/// EN: Total count of items across all pages.
/// VI: Tổng số items trên tất cả các trang.
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// EN: Current page number (1-indexed).
/// VI: Số trang hiện tại (bắt đầu từ 1).
/// </summary>
public int Page { get; init; }
/// <summary>
/// EN: Page size.
/// VI: Kích thước trang.
/// </summary>
public int PageSize { get; init; }
/// <summary>
/// EN: Total number of pages.
/// VI: Tổng số trang.
/// </summary>
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
/// <summary>
/// EN: Has previous page.
/// VI: Có trang trước không.
/// </summary>
public bool HasPrevious => Page > 1;
/// <summary>
/// EN: Has next page.
/// VI: Có trang tiếp theo không.
/// </summary>
public bool HasNext => Page < TotalPages;
}

View File

@@ -0,0 +1,83 @@
// EN: Product Data Transfer Object for read operations.
// VI: DTO Product cho các thao tác đọc.
namespace CatalogService.API.Application.DTOs;
/// <summary>
/// EN: Product DTO for API responses.
/// VI: DTO Product cho API response.
/// </summary>
public record ProductDto
{
/// <summary>
/// EN: Product ID.
/// VI: ID sản phẩm.
/// </summary>
public Guid Id { get; init; }
/// <summary>
/// EN: Shop ID that owns this product.
/// VI: ID shop sở hữu sản phẩm.
/// </summary>
public Guid ShopId { get; init; }
/// <summary>
/// EN: Product name.
/// VI: Tên sản phẩm.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// EN: Product description.
/// VI: Mô tả sản phẩm.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// EN: Product price.
/// VI: Giá sản phẩm.
/// </summary>
public decimal Price { get; init; }
/// <summary>
/// EN: Product type (Physical, Service, PreparedFood).
/// VI: Loại sản phẩm (Physical, Service, PreparedFood).
/// </summary>
public string Type { get; init; } = string.Empty;
/// <summary>
/// EN: Type-specific attributes as dictionary.
/// VI: Thuộc tính theo loại dưới dạng dictionary.
/// </summary>
public Dictionary<string, object>? Attributes { get; init; }
/// <summary>
/// EN: Product image URL.
/// VI: URL hình ảnh sản phẩm.
/// </summary>
public string? ImageUrl { get; init; }
/// <summary>
/// EN: Stock Keeping Unit.
/// VI: Mã SKU.
/// </summary>
public string? Sku { get; init; }
/// <summary>
/// EN: Is product active.
/// VI: Sản phẩm có đang hoạt động không.
/// </summary>
public bool IsActive { get; init; }
/// <summary>
/// EN: Creation timestamp.
/// VI: Thời gian tạo.
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// EN: Last update timestamp.
/// VI: Thời gian cập nhật cuối.
/// </summary>
public DateTime? UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,26 @@
// EN: Query to get a list of categories.
// VI: Query để lấy danh sách danh mục.
using MediatR;
using CatalogService.API.Application.DTOs;
namespace CatalogService.API.Application.Queries;
/// <summary>
/// EN: Query to get categories by shop with hierarchical support.
/// VI: Query để lấy danh mục theo shop có hỗ trợ phân cấp.
/// </summary>
public record GetCategoriesQuery : IRequest<List<CategoryDto>>
{
/// <summary>
/// EN: Shop ID to filter categories.
/// VI: ID shop để lọc danh mục.
/// </summary>
public Guid ShopId { get; init; }
/// <summary>
/// EN: Filter by parent ID (null = root categories).
/// VI: Lọc theo ID cha (null = danh mục gốc).
/// </summary>
public Guid? ParentId { get; init; }
}

View File

@@ -0,0 +1,61 @@
// EN: Handler for GetCategoriesQuery.
// VI: Handler cho GetCategoriesQuery.
using MediatR;
using Microsoft.EntityFrameworkCore;
using CatalogService.API.Application.DTOs;
using CatalogService.Infrastructure;
namespace CatalogService.API.Application.Queries;
/// <summary>
/// EN: Handler for querying categories with hierarchical support.
/// VI: Handler query danh mục có hỗ trợ phân cấp.
/// </summary>
public class GetCategoriesQueryHandler : IRequestHandler<GetCategoriesQuery, List<CategoryDto>>
{
private readonly CatalogContext _context;
public GetCategoriesQueryHandler(CatalogContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<List<CategoryDto>> Handle(GetCategoriesQuery request, CancellationToken cancellationToken)
{
var query = _context.Categories
.Where(c => c.ShopId == request.ShopId);
// EN: Filter by parent ID if specified
// VI: Lọc theo ID cha nếu được chỉ định
if (request.ParentId.HasValue)
{
query = query.Where(c => c.ParentId == request.ParentId.Value);
}
else
{
// EN: Return only root categories if no parent specified
// VI: Trả về chỉ danh mục gốc nếu không chỉ định cha
query = query.Where(c => c.ParentId == null);
}
var categories = await query
.OrderBy(c => c.DisplayOrder)
.ThenBy(c => c.Name)
.Select(c => new CategoryDto
{
Id = c.Id,
ShopId = c.ShopId,
Name = c.Name,
Description = c.Description,
ParentId = c.ParentId,
DisplayOrder = c.DisplayOrder,
IsActive = c.IsActive,
CreatedAt = c.CreatedAt,
UpdatedAt = c.UpdatedAt
})
.ToListAsync(cancellationToken);
return categories;
}
}

View File

@@ -0,0 +1,25 @@
// EN: Query to get a single product by ID.
// VI: Query để lấy một sản phẩm theo ID.
using MediatR;
using CatalogService.API.Application.DTOs;
namespace CatalogService.API.Application.Queries;
/// <summary>
/// EN: Query to get a product by ID.
/// VI: Query để lấy sản phẩm theo ID.
/// </summary>
public record GetProductByIdQuery : IRequest<ProductDto?>
{
/// <summary>
/// EN: Product ID.
/// VI: ID sản phẩm.
/// </summary>
public Guid ProductId { get; init; }
public GetProductByIdQuery(Guid productId)
{
ProductId = productId;
}
}

View File

@@ -0,0 +1,50 @@
// EN: Handler for GetProductByIdQuery.
// VI: Handler cho GetProductByIdQuery.
using System.Text.Json;
using MediatR;
using Microsoft.EntityFrameworkCore;
using CatalogService.API.Application.DTOs;
using CatalogService.Infrastructure;
namespace CatalogService.API.Application.Queries;
/// <summary>
/// EN: Handler for querying a single product by ID.
/// VI: Handler query một sản phẩm theo ID.
/// </summary>
public class GetProductByIdQueryHandler : IRequestHandler<GetProductByIdQuery, ProductDto?>
{
private readonly CatalogContext _context;
public GetProductByIdQueryHandler(CatalogContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
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);
return product;
}
}

View File

@@ -0,0 +1,44 @@
// EN: Query to get a list of products.
// VI: Query để lấy danh sách sản phẩm.
using MediatR;
using CatalogService.API.Application.DTOs;
namespace CatalogService.API.Application.Queries;
/// <summary>
/// EN: Query to get products by shop with filtering and pagination.
/// VI: Query để lấy sản phẩm theo shop có lọc và phân trang.
/// </summary>
public record GetProductsQuery : IRequest<PagedResult<ProductDto>>
{
/// <summary>
/// EN: Shop ID to filter products.
/// VI: ID shop để lọc sản phẩm.
/// </summary>
public Guid ShopId { get; init; }
/// <summary>
/// EN: Filter by active status (null = all).
/// VI: Lọc theo trạng thái hoạt động (null = tất cả).
/// </summary>
public bool? IsActive { get; init; }
/// <summary>
/// EN: Filter by product type (null = all).
/// VI: Lọc theo loại sản phẩm (null = tất cả).
/// </summary>
public string? Type { get; init; }
/// <summary>
/// EN: Page number (1-indexed).
/// VI: Số trang (bắt đầu từ 1).
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// EN: Page size.
/// VI: Kích thước trang.
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -0,0 +1,79 @@
// EN: Handler for GetProductsQuery.
// VI: Handler cho GetProductsQuery.
using System.Text.Json;
using MediatR;
using Microsoft.EntityFrameworkCore;
using CatalogService.API.Application.DTOs;
using CatalogService.Infrastructure;
namespace CatalogService.API.Application.Queries;
/// <summary>
/// EN: Handler for querying products with filtering and pagination.
/// VI: Handler query sản phẩm có lọc và phân trang.
/// </summary>
public class GetProductsQueryHandler : IRequestHandler<GetProductsQuery, PagedResult<ProductDto>>
{
private readonly CatalogContext _context;
public GetProductsQueryHandler(CatalogContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<PagedResult<ProductDto>> Handle(GetProductsQuery request, CancellationToken cancellationToken)
{
var query = _context.Products
.Where(p => p.ShopId == request.ShopId);
// EN: Apply filters
// VI: Áp dụng bộ lọc
if (request.IsActive.HasValue)
{
query = query.Where(p => p.IsActive == request.IsActive.Value);
}
if (!string.IsNullOrWhiteSpace(request.Type))
{
query = query.Where(p => p.Type.Name == request.Type);
}
// EN: Get total count
// VI: Lấy tổng số
var totalCount = await query.CountAsync(cancellationToken);
// EN: Apply pagination
// VI: Áp dụng phân trang
var products = await query
.OrderBy(p => p.Name)
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.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
})
.ToListAsync(cancellationToken);
return new PagedResult<ProductDto>
{
Items = products,
TotalCount = totalCount,
Page = request.Page,
PageSize = request.PageSize
};
}
}

View File

@@ -1,151 +0,0 @@
using FluentAssertions;
using InventoryService.Domain.AggregatesModel.SampleAggregate;
using InventoryService.Domain.Exceptions;
using Xunit;
namespace InventoryService.UnitTests.Domain;
/// <summary>
/// EN: Unit tests for Sample aggregate.
/// VI: Unit tests cho Sample aggregate.
/// </summary>
public class SampleAggregateTests
{
[Fact]
public void CreateSample_WithValidName_ShouldCreateWithDraftStatus()
{
// Arrange
var name = "Test Sample";
var description = "Test Description";
// Act
var sample = new Sample(name, description);
// Assert
sample.Name.Should().Be(name);
sample.Description.Should().Be(description);
sample.Status.Should().Be(SampleStatus.Draft);
sample.Id.Should().NotBeEmpty();
sample.DomainEvents.Should().ContainSingle(); // SampleCreatedDomainEvent
}
[Fact]
public void CreateSample_WithEmptyName_ShouldThrowException()
{
// Arrange
var name = "";
// Act
var act = () => new Sample(name);
// Assert
act.Should().Throw<SampleDomainException>()
.WithMessage("Sample name cannot be empty");
}
[Fact]
public void Activate_WhenDraft_ShouldChangeToActive()
{
// Arrange
var sample = new Sample("Test Sample");
sample.ClearDomainEvents();
// Act
sample.Activate();
// Assert
sample.Status.Should().Be(SampleStatus.Active);
sample.DomainEvents.Should().ContainSingle(); // SampleStatusChangedDomainEvent
}
[Fact]
public void Activate_WhenNotDraft_ShouldThrowException()
{
// Arrange
var sample = new Sample("Test Sample");
sample.Activate();
// Act
var act = () => sample.Activate();
// Assert
act.Should().Throw<SampleDomainException>()
.WithMessage("Only draft samples can be activated");
}
[Fact]
public void Complete_WhenActive_ShouldChangeToCompleted()
{
// Arrange
var sample = new Sample("Test Sample");
sample.Activate();
sample.ClearDomainEvents();
// Act
sample.Complete();
// Assert
sample.Status.Should().Be(SampleStatus.Completed);
}
[Fact]
public void Cancel_WhenDraftOrActive_ShouldChangeToCancelled()
{
// Arrange
var sample = new Sample("Test Sample");
// Act
sample.Cancel();
// Assert
sample.Status.Should().Be(SampleStatus.Cancelled);
}
[Fact]
public void Cancel_WhenCompleted_ShouldThrowException()
{
// Arrange
var sample = new Sample("Test Sample");
sample.Activate();
sample.Complete();
// Act
var act = () => sample.Cancel();
// Assert
act.Should().Throw<SampleDomainException>()
.WithMessage("Cannot cancel a completed sample");
}
[Fact]
public void Update_WhenNotCancelled_ShouldUpdateNameAndDescription()
{
// Arrange
var sample = new Sample("Original Name", "Original Description");
var newName = "Updated Name";
var newDescription = "Updated Description";
// Act
sample.Update(newName, newDescription);
// Assert
sample.Name.Should().Be(newName);
sample.Description.Should().Be(newDescription);
sample.UpdatedAt.Should().NotBeNull();
}
[Fact]
public void Update_WhenCancelled_ShouldThrowException()
{
// Arrange
var sample = new Sample("Test Sample");
sample.Cancel();
// Act
var act = () => sample.Update("New Name", null);
// Assert
act.Should().Throw<SampleDomainException>()
.WithMessage("Cannot update a cancelled sample");
}
}