diff --git a/deployments/local/docker-compose.yml b/deployments/local/docker-compose.yml
index f37ad155..5590a390 100644
--- a/deployments/local/docker-compose.yml
+++ b/deployments/local/docker-compose.yml
@@ -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
diff --git a/services/ads-analytics-service-net/src/AdsAnalyticsService.API/AdsAnalyticsService.API.csproj b/services/ads-analytics-service-net/src/AdsAnalyticsService.API/AdsAnalyticsService.API.csproj
index 65a1e43e..8fa0d8e5 100644
--- a/services/ads-analytics-service-net/src/AdsAnalyticsService.API/AdsAnalyticsService.API.csproj
+++ b/services/ads-analytics-service-net/src/AdsAnalyticsService.API/AdsAnalyticsService.API.csproj
@@ -14,6 +14,10 @@
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
diff --git a/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Application/Queries/GetCampaignMetricsQuery.cs b/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Application/Queries/GetCampaignMetricsQuery.cs
new file mode 100644
index 00000000..fc4b67c0
--- /dev/null
+++ b/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Application/Queries/GetCampaignMetricsQuery.cs
@@ -0,0 +1,34 @@
+using MediatR;
+
+namespace AdsAnalyticsService.API.Application.Queries;
+
+///
+/// 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ể.
+///
+public record GetCampaignMetricsQuery : IRequest
+{
+ public Guid CampaignId { get; init; }
+ public DateTime StartDate { get; init; }
+ public DateTime EndDate { get; init; }
+}
+
+///
+/// EN: Campaign metrics DTO.
+/// VI: DTO metrics chiến dịch.
+///
+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; }
+}
diff --git a/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Application/Queries/GetCampaignMetricsQueryHandler.cs b/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Application/Queries/GetCampaignMetricsQueryHandler.cs
new file mode 100644
index 00000000..7937b5ae
--- /dev/null
+++ b/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Application/Queries/GetCampaignMetricsQueryHandler.cs
@@ -0,0 +1,71 @@
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+using AdsAnalyticsService.Infrastructure;
+
+namespace AdsAnalyticsService.API.Application.Queries;
+
+///
+/// EN: Handler for GetCampaignMetricsQuery.
+/// VI: Handler cho GetCampaignMetricsQuery.
+///
+public class GetCampaignMetricsQueryHandler : IRequestHandler
+{
+ private readonly AdsAnalyticsServiceContext _context;
+ private readonly ILogger _logger;
+
+ public GetCampaignMetricsQueryHandler(
+ AdsAnalyticsServiceContext context,
+ ILogger logger)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task 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
+ };
+ }
+}
diff --git a/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Controllers/MetricsController.cs b/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Controllers/MetricsController.cs
new file mode 100644
index 00000000..9e9ee6b9
--- /dev/null
+++ b/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Controllers/MetricsController.cs
@@ -0,0 +1,88 @@
+using Asp.Versioning;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using AdsAnalyticsService.API.Application.Queries;
+
+namespace AdsAnalyticsService.API.Controllers;
+
+///
+/// EN: API Controller for ads analytics metrics.
+/// VI: API Controller cho metrics phân tích quảng cáo.
+///
+[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 _logger;
+
+ public MetricsController(IMediator mediator, ILogger logger)
+ {
+ _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// EN: Get campaign metrics for a date range.
+ /// VI: Lấy metrics chiến dịch cho khoảng thời gian.
+ ///
+ /// Campaign ID
+ /// Start date (YYYY-MM-DD)
+ /// End date (YYYY-MM-DD)
+ /// Campaign metrics
+ [HttpGet("campaigns/{id}/metrics")]
+ [ProducesResponseType(typeof(CampaignMetricsDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> 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);
+ }
+
+ ///
+ /// EN: Get ad set metrics (placeholder for future implementation).
+ /// VI: Lấy metrics ad set (placeholder cho triển khai sau).
+ ///
+ [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" });
+ }
+
+ ///
+ /// EN: Get ad metrics (placeholder for future implementation).
+ /// VI: Lấy metrics ad (placeholder cho triển khai sau).
+ ///
+ [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" });
+ }
+}
diff --git a/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/AdsAnalyticsServiceContext.cs b/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/AdsAnalyticsServiceContext.cs
index 70f20927..6a229a96 100644
--- a/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/AdsAnalyticsServiceContext.cs
+++ b/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/AdsAnalyticsServiceContext.cs
@@ -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;
///
- /// EN: Samples table.
- /// VI: Bảng Samples.
+ /// EN: Campaign metrics table.
+ /// VI: Bảng metrics chiến dịch.
///
- public DbSet Samples => Set();
+ public DbSet CampaignMetrics => Set();
+
+ ///
+ /// EN: Reports table.
+ /// VI: Bảng báo cáo.
+ ///
+ public DbSet Reports => Set();
///
/// 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());
}
///
diff --git a/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/EntityConfigurations/CampaignMetricsEntityTypeConfiguration.cs b/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/EntityConfigurations/CampaignMetricsEntityTypeConfiguration.cs
new file mode 100644
index 00000000..caf9772c
--- /dev/null
+++ b/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/EntityConfigurations/CampaignMetricsEntityTypeConfiguration.cs
@@ -0,0 +1,86 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using AdsAnalyticsService.Domain.AggregatesModel.MetricsAggregate;
+
+namespace AdsAnalyticsService.Infrastructure.EntityConfigurations;
+
+///
+/// EN: Entity type configuration for CampaignMetrics aggregate.
+/// VI: Cấu hình entity type cho CampaignMetrics aggregate.
+///
+public class CampaignMetricsEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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);
+ }
+}
diff --git a/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/EntityConfigurations/ReportEntityTypeConfiguration.cs b/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/EntityConfigurations/ReportEntityTypeConfiguration.cs
new file mode 100644
index 00000000..808f030e
--- /dev/null
+++ b/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/EntityConfigurations/ReportEntityTypeConfiguration.cs
@@ -0,0 +1,83 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using AdsAnalyticsService.Domain.AggregatesModel.ReportAggregate;
+
+namespace AdsAnalyticsService.Infrastructure.EntityConfigurations;
+
+///
+/// EN: Entity type configuration for Report aggregate.
+/// VI: Cấu hình entity type cho Report aggregate.
+///
+public class ReportEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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()
+ .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()
+ .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);
+ }
+}
diff --git a/services/ads-billing-service-net/src/AdsBillingService.API/AdsBillingService.API.csproj b/services/ads-billing-service-net/src/AdsBillingService.API/AdsBillingService.API.csproj
index 360418fc..17da9c40 100644
--- a/services/ads-billing-service-net/src/AdsBillingService.API/AdsBillingService.API.csproj
+++ b/services/ads-billing-service-net/src/AdsBillingService.API/AdsBillingService.API.csproj
@@ -14,6 +14,10 @@
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
diff --git a/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/AdsBillingServiceContext.cs b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/AdsBillingServiceContext.cs
index 2c148f93..f08fd9d5 100644
--- a/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/AdsBillingServiceContext.cs
+++ b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/AdsBillingServiceContext.cs
@@ -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 SaveEntitiesAsync(CancellationToken cancellationToken = default)
diff --git a/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/AdChargeEntityTypeConfiguration.cs b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/AdChargeEntityTypeConfiguration.cs
new file mode 100644
index 00000000..93a393cd
--- /dev/null
+++ b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/AdChargeEntityTypeConfiguration.cs
@@ -0,0 +1,86 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using AdsBillingService.Domain.AggregatesModel.ChargeAggregate;
+
+namespace AdsBillingService.Infrastructure.EntityConfigurations;
+
+///
+/// EN: Entity configuration for AdCharge aggregate.
+/// VI: Cấu hình entity cho aggregate AdCharge.
+///
+public class AdChargeEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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()
+ .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");
+ }
+}
diff --git a/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/BillingAccountEntityTypeConfiguration.cs b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/BillingAccountEntityTypeConfiguration.cs
new file mode 100644
index 00000000..4b8ccac2
--- /dev/null
+++ b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/BillingAccountEntityTypeConfiguration.cs
@@ -0,0 +1,88 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using AdsBillingService.Domain.AggregatesModel.BillingAccountAggregate;
+
+namespace AdsBillingService.Infrastructure.EntityConfigurations;
+
+///
+/// EN: Entity configuration for BillingAccount aggregate.
+/// VI: Cấu hình entity cho aggregate BillingAccount.
+///
+public class BillingAccountEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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()
+ .IsRequired();
+
+ // EN: Account status / VI: Trạng thái tài khoản
+ builder.Property(b => b.Status)
+ .HasColumnName("status")
+ .HasConversion()
+ .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");
+ }
+}
diff --git a/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/ClientRequestEntityTypeConfiguration.cs b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/ClientRequestEntityTypeConfiguration.cs
new file mode 100644
index 00000000..c71e8024
--- /dev/null
+++ b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/ClientRequestEntityTypeConfiguration.cs
@@ -0,0 +1,39 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using AdsBillingService.Infrastructure.Idempotency;
+
+namespace AdsBillingService.Infrastructure.EntityConfigurations;
+
+///
+/// EN: Entity configuration for ClientRequest (idempotency).
+/// VI: Cấu hình entity cho ClientRequest (idempotency).
+///
+public class ClientRequestEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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();
+ }
+}
diff --git a/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/InvoiceEntityTypeConfiguration.cs b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/InvoiceEntityTypeConfiguration.cs
new file mode 100644
index 00000000..b0635e5a
--- /dev/null
+++ b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/InvoiceEntityTypeConfiguration.cs
@@ -0,0 +1,80 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using AdsBillingService.Domain.AggregatesModel.InvoiceAggregate;
+
+namespace AdsBillingService.Infrastructure.EntityConfigurations;
+
+///
+/// EN: Entity configuration for Invoice aggregate.
+/// VI: Cấu hình entity cho aggregate Invoice.
+///
+public class InvoiceEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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()
+ .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()
+ .WithOne()
+ .HasForeignKey("InvoiceId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ // EN: Metadata / VI: Ignore navigation property for EF Core
+ builder.Metadata.FindNavigation(nameof(Invoice.LineItems))!
+ .SetPropertyAccessMode(PropertyAccessMode.Field);
+ }
+}
diff --git a/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/InvoiceLineItemEntityTypeConfiguration.cs b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/InvoiceLineItemEntityTypeConfiguration.cs
new file mode 100644
index 00000000..6b2bbee5
--- /dev/null
+++ b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/InvoiceLineItemEntityTypeConfiguration.cs
@@ -0,0 +1,57 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using AdsBillingService.Domain.AggregatesModel.InvoiceAggregate;
+
+namespace AdsBillingService.Infrastructure.EntityConfigurations;
+
+///
+/// EN: Entity configuration for InvoiceLineItem.
+/// VI: Cấu hình entity cho InvoiceLineItem.
+///
+public class InvoiceLineItemEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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("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);
+ }
+}
diff --git a/services/ads-serving-service-net/src/AdsServingService.API/AdsServingService.API.csproj b/services/ads-serving-service-net/src/AdsServingService.API/AdsServingService.API.csproj
index 9a6f39e4..f3dc6957 100644
--- a/services/ads-serving-service-net/src/AdsServingService.API/AdsServingService.API.csproj
+++ b/services/ads-serving-service-net/src/AdsServingService.API/AdsServingService.API.csproj
@@ -33,6 +33,12 @@
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
diff --git a/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetAuctionsQuery.cs b/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetAuctionsQuery.cs
new file mode 100644
index 00000000..3f00206c
--- /dev/null
+++ b/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetAuctionsQuery.cs
@@ -0,0 +1,38 @@
+using MediatR;
+
+namespace AdsServingService.API.Application.Queries;
+
+///
+/// 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.
+///
+public class GetAuctionsQuery : IRequest>
+{
+ 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
+{
+ public List 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);
+}
diff --git a/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetAuctionsQueryHandler.cs b/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetAuctionsQueryHandler.cs
new file mode 100644
index 00000000..1602e908
--- /dev/null
+++ b/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetAuctionsQueryHandler.cs
@@ -0,0 +1,66 @@
+using AdsServingService.Infrastructure;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace AdsServingService.API.Application.Queries;
+
+///
+/// EN: Handler for GetAuctionsQuery.
+/// VI: Handler cho GetAuctionsQuery.
+///
+public class GetAuctionsQueryHandler : IRequestHandler>
+{
+ private readonly AdsServingServiceContext _context;
+
+ public GetAuctionsQueryHandler(AdsServingServiceContext context)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ public async Task> 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(a, "_auctionTime") >= request.StartDate.Value);
+
+ if (request.EndDate.HasValue)
+ query = query.Where(a => EF.Property(a, "_auctionTime") <= request.EndDate.Value);
+
+ // Get total count
+ var totalCount = await query.CountAsync(cancellationToken);
+
+ // Apply pagination
+ var auctions = await query
+ .OrderByDescending(a => EF.Property(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(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
+ {
+ Items = auctions,
+ TotalCount = totalCount,
+ Page = request.Page,
+ PageSize = request.PageSize
+ };
+ }
+}
diff --git a/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetBudgetPacersQuery.cs b/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetBudgetPacersQuery.cs
new file mode 100644
index 00000000..6422cf49
--- /dev/null
+++ b/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetBudgetPacersQuery.cs
@@ -0,0 +1,35 @@
+using MediatR;
+
+namespace AdsServingService.API.Application.Queries;
+
+///
+/// EN: Query to get paginated list of budget pacers.
+/// VI: Query lấy danh sách budget pacers phân trang.
+///
+public class GetBudgetPacersQuery : IRequest>
+{
+ 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; }
+}
diff --git a/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetBudgetPacersQueryHandler.cs b/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetBudgetPacersQueryHandler.cs
new file mode 100644
index 00000000..d2b6570f
--- /dev/null
+++ b/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetBudgetPacersQueryHandler.cs
@@ -0,0 +1,57 @@
+using AdsServingService.Infrastructure;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace AdsServingService.API.Application.Queries;
+
+///
+/// EN: Handler for GetBudgetPacersQuery.
+/// VI: Handler cho GetBudgetPacersQuery.
+///
+public class GetBudgetPacersQueryHandler : IRequestHandler>
+{
+ private readonly AdsServingServiceContext _context;
+
+ public GetBudgetPacersQueryHandler(AdsServingServiceContext context)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ public async Task> Handle(GetBudgetPacersQuery request, CancellationToken cancellationToken)
+ {
+ var query = _context.BudgetPacers.AsQueryable();
+
+ // Apply filters
+ if (request.CampaignId.HasValue)
+ query = query.Where(bp => EF.Property(bp, "_campaignId") == request.CampaignId.Value);
+
+ // Get total count
+ var totalCount = await query.CountAsync(cancellationToken);
+
+ // Apply pagination
+ var pacers = await query
+ .OrderByDescending(bp => EF.Property(bp, "_lastUpdated"))
+ .Skip((request.Page - 1) * request.PageSize)
+ .Take(request.PageSize)
+ .Select(bp => new BudgetPacerDto
+ {
+ Id = bp.Id,
+ CampaignId = EF.Property(bp, "_campaignId"),
+ DailyBudget = EF.Property(bp, "_dailyBudget"),
+ SpentToday = EF.Property(bp, "_spentToday"),
+ RemainingBudget = bp.RemainingBudget,
+ UtilizationPercent = bp.UtilizationPercent,
+ Strategy = bp.Strategy.ToString(),
+ LastUpdated = EF.Property(bp, "_lastUpdated")
+ })
+ .ToListAsync(cancellationToken);
+
+ return new PagedResult
+ {
+ Items = pacers,
+ TotalCount = totalCount,
+ Page = request.Page,
+ PageSize = request.PageSize
+ };
+ }
+}
diff --git a/services/ads-serving-service-net/src/AdsServingService.API/Controllers/AdminAuctionsController.cs b/services/ads-serving-service-net/src/AdsServingService.API/Controllers/AdminAuctionsController.cs
new file mode 100644
index 00000000..2bf41c21
--- /dev/null
+++ b/services/ads-serving-service-net/src/AdsServingService.API/Controllers/AdminAuctionsController.cs
@@ -0,0 +1,80 @@
+using AdsServingService.API.Application.Queries;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+
+namespace AdsServingService.API.Controllers;
+
+///
+/// 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á.
+///
+[ApiController]
+[Route("api/v1/admin/auctions")]
+[Produces("application/json")]
+public class AdminAuctionsController : ControllerBase
+{
+ private readonly IMediator _mediator;
+ private readonly ILogger _logger;
+
+ public AdminAuctionsController(IMediator mediator, ILogger logger)
+ {
+ _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// 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.
+ ///
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)]
+ public async Task>> 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);
+ }
+
+ ///
+ /// 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.).
+ ///
+ [HttpGet("statistics")]
+ [ProducesResponseType(typeof(AuctionStatisticsDto), StatusCodes.Status200OK)]
+ public ActionResult 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; }
+}
diff --git a/services/ads-serving-service-net/src/AdsServingService.Infrastructure/AdsServingServiceContext.cs b/services/ads-serving-service-net/src/AdsServingService.Infrastructure/AdsServingServiceContext.cs
index 627e1d35..54fde8ec 100644
--- a/services/ads-serving-service-net/src/AdsServingService.Infrastructure/AdsServingServiceContext.cs
+++ b/services/ads-serving-service-net/src/AdsServingService.Infrastructure/AdsServingServiceContext.cs
@@ -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 SaveEntitiesAsync(CancellationToken cancellationToken = default)
diff --git a/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/AuctionEntityTypeConfiguration.cs b/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/AuctionEntityTypeConfiguration.cs
new file mode 100644
index 00000000..f5c0235d
--- /dev/null
+++ b/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/AuctionEntityTypeConfiguration.cs
@@ -0,0 +1,85 @@
+using AdsServingService.Domain.AggregatesModel.AuctionAggregate;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AdsServingService.Infrastructure.EntityConfigurations;
+
+///
+/// EN: EF Core entity configuration for Auction aggregate.
+/// VI: Cấu hình entity EF Core cho aggregate Auction.
+///
+public class AuctionEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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("_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("_bidsJson")
+ .HasColumnName("bids")
+ .HasColumnType("jsonb");
+
+ // Owned value object: AuctionResult (nullable)
+ builder.OwnsOne("_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");
+ }
+}
diff --git a/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/BudgetPacerEntityTypeConfiguration.cs b/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/BudgetPacerEntityTypeConfiguration.cs
new file mode 100644
index 00000000..a46588cf
--- /dev/null
+++ b/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/BudgetPacerEntityTypeConfiguration.cs
@@ -0,0 +1,61 @@
+using AdsServingService.Domain.AggregatesModel.PacingAggregate;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AdsServingService.Infrastructure.EntityConfigurations;
+
+///
+/// EN: EF Core entity configuration for BudgetPacer aggregate.
+/// VI: Cấu hình entity EF Core cho aggregate BudgetPacer.
+///
+public class BudgetPacerEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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("_campaignId")
+ .HasColumnName("campaign_id")
+ .IsRequired();
+
+ builder.Property("_dailyBudget")
+ .HasColumnName("daily_budget")
+ .HasColumnType("decimal(18,4)")
+ .IsRequired();
+
+ builder.Property("_spentToday")
+ .HasColumnName("spent_today")
+ .HasColumnType("decimal(18,4)")
+ .IsRequired();
+
+ builder.Property("_strategy")
+ .HasColumnName("strategy")
+ .HasConversion()
+ .HasMaxLength(20)
+ .IsRequired();
+
+ builder.Property("_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");
+ }
+}
diff --git a/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/FrequencyCapEntityTypeConfiguration.cs b/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/FrequencyCapEntityTypeConfiguration.cs
new file mode 100644
index 00000000..0168c98a
--- /dev/null
+++ b/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/FrequencyCapEntityTypeConfiguration.cs
@@ -0,0 +1,45 @@
+using AdsServingService.Domain.AggregatesModel.FrequencyAggregate;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AdsServingService.Infrastructure.EntityConfigurations;
+
+///
+/// EN: EF Core entity configuration for FrequencyCap aggregate.
+/// VI: Cấu hình entity EF Core cho aggregate FrequencyCap.
+///
+public class FrequencyCapEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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("_adId")
+ .HasColumnName("ad_id")
+ .IsRequired();
+
+ builder.Property("_maxImpressionsPerUser")
+ .HasColumnName("max_impressions_per_user")
+ .IsRequired();
+
+ builder.Property("_window")
+ .HasColumnName("window")
+ .HasConversion()
+ .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");
+ }
+}
diff --git a/services/booking-service-net/src/BookingService.Domain/AggregatesModel/AppointmentAggregate/AppointmentStatus.cs b/services/booking-service-net/src/BookingService.Domain/AggregatesModel/AppointmentAggregate/AppointmentStatus.cs
new file mode 100644
index 00000000..5443dbbd
--- /dev/null
+++ b/services/booking-service-net/src/BookingService.Domain/AggregatesModel/AppointmentAggregate/AppointmentStatus.cs
@@ -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;
+
+///
+/// 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.
+///
+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)
+ {
+ }
+}
diff --git a/services/booking-service-net/src/BookingService.Infrastructure/BookingContext.cs b/services/booking-service-net/src/BookingService.Infrastructure/BookingContext.cs
index 5cb2fb43..6f2df09a 100644
--- a/services/booking-service-net/src/BookingService.Infrastructure/BookingContext.cs
+++ b/services/booking-service-net/src/BookingService.Infrastructure/BookingContext.cs
@@ -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 Appointments => Set();
+ public DbSet Resources => Set();
+ public DbSet StaffSchedules => Set();
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 SaveEntitiesAsync(CancellationToken cancellationToken = default)
diff --git a/services/booking-service-net/src/BookingService.Infrastructure/DependencyInjection.cs b/services/booking-service-net/src/BookingService.Infrastructure/DependencyInjection.cs
index 2fcf7edb..ba5c88d4 100644
--- a/services/booking-service-net/src/BookingService.Infrastructure/DependencyInjection.cs
+++ b/services/booking-service-net/src/BookingService.Infrastructure/DependencyInjection.cs
@@ -22,7 +22,7 @@ public static class DependencyInjection
IConfiguration configuration)
{
// EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL
- services.AddDbContext(options =>
+ services.AddDbContext(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();
+ services.AddScoped();
// EN: Register idempotency services / VI: Đăng ký idempotency services
services.AddScoped();
diff --git a/services/booking-service-net/src/BookingService.Infrastructure/Entity Configurations/AppointmentEntityTypeConfiguration.cs b/services/booking-service-net/src/BookingService.Infrastructure/Entity Configurations/AppointmentEntityTypeConfiguration.cs
deleted file mode 100644
index b4bf2c30..00000000
--- a/services/booking-service-net/src/BookingService.Infrastructure/Entity Configurations/AppointmentEntityTypeConfiguration.cs
+++ /dev/null
@@ -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
-{
- public void Configure(EntityTypeBuilder builder)
- {
- builder.ToTable("appointments");
- builder.HasKey(a => a.Id);
- builder.Property(a => a.Id).HasColumnName("id").ValueGeneratedNever();
- builder.Property("_shopId").HasColumnName("shop_id").IsRequired();
- builder.Property("_customerId").HasColumnName("customer_id").IsRequired();
- builder.Property("_serviceId").HasColumnName("service_id").IsRequired();
- builder.Property(a => a.StatusId).HasColumnName("status_id").IsRequired();
- builder.Property("_scheduledAt").HasColumnName("scheduled_at").IsRequired();
- builder.Property("_durationMinutes").HasColumnName("duration_minutes").IsRequired();
- builder.Property("_staffId").HasColumnName("staff_id");
- builder.Property("_resourceId").HasColumnName("resource_id");
- builder.Property("_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);
- }
-}
diff --git a/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/ResourceEntityTypeConfiguration.cs b/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/ResourceEntityTypeConfiguration.cs
new file mode 100644
index 00000000..60161e69
--- /dev/null
+++ b/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/ResourceEntityTypeConfiguration.cs
@@ -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;
+
+///
+/// EN: Entity type configuration for Resource aggregate.
+/// VI: Cấu hình entity type cho aggregate Resource.
+///
+public class ResourceEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("resources");
+
+ builder.HasKey(r => r.Id);
+ builder.Property(r => r.Id)
+ .HasColumnName("id")
+ .ValueGeneratedNever();
+
+ builder.Property("_shopId")
+ .HasColumnName("shop_id")
+ .IsRequired();
+
+ builder.Property("_name")
+ .HasColumnName("name")
+ .HasMaxLength(200)
+ .IsRequired();
+
+ builder.Property("_resourceType")
+ .HasColumnName("resource_type")
+ .HasMaxLength(50)
+ .IsRequired();
+
+ builder.Property("_capacity")
+ .HasColumnName("capacity")
+ .IsRequired();
+
+ builder.Property("_isActive")
+ .HasColumnName("is_active")
+ .IsRequired();
+
+ builder.Property("_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);
+ }
+}
diff --git a/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/StaffScheduleEntityTypeConfiguration.cs b/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/StaffScheduleEntityTypeConfiguration.cs
new file mode 100644
index 00000000..55a8cdf9
--- /dev/null
+++ b/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/StaffScheduleEntityTypeConfiguration.cs
@@ -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;
+
+///
+/// EN: Entity type configuration for StaffSchedule entity.
+/// VI: Cấu hình entity type cho entity StaffSchedule.
+///
+public class StaffScheduleEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("staff_schedules");
+
+ builder.HasKey(s => s.Id);
+ builder.Property(s => s.Id)
+ .HasColumnName("id")
+ .ValueGeneratedNever();
+
+ builder.Property("_staffId")
+ .HasColumnName("staff_id")
+ .IsRequired();
+
+ builder.Property("_shopId")
+ .HasColumnName("shop_id")
+ .IsRequired();
+
+ builder.Property("_dayOfWeek")
+ .HasColumnName("day_of_week")
+ .IsRequired();
+
+ builder.Property("_startTime")
+ .HasColumnName("start_time")
+ .IsRequired();
+
+ builder.Property("_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);
+ }
+}
diff --git a/services/booking-service-net/src/BookingService.Infrastructure/Idempotency/RequestManager.cs b/services/booking-service-net/src/BookingService.Infrastructure/Idempotency/RequestManager.cs
index 302a4984..945d5d99 100644
--- a/services/booking-service-net/src/BookingService.Infrastructure/Idempotency/RequestManager.cs
+++ b/services/booking-service-net/src/BookingService.Infrastructure/Idempotency/RequestManager.cs
@@ -8,9 +8,9 @@ namespace BookingService.Infrastructure.Idempotency;
///
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));
}
diff --git a/services/booking-service-net/src/BookingService.Infrastructure/Repositories/AppointmentRepository.cs b/services/booking-service-net/src/BookingService.Infrastructure/Repositories/AppointmentRepository.cs
new file mode 100644
index 00000000..138234e8
--- /dev/null
+++ b/services/booking-service-net/src/BookingService.Infrastructure/Repositories/AppointmentRepository.cs
@@ -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;
+
+///
+/// EN: Repository implementation for managing Appointment aggregate persistence.
+/// VI: Implementation repository để quản lý persistence của aggregate Appointment.
+///
+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 GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ return await _context.Appointments
+ .FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
+ }
+
+ public async Task> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default)
+ {
+ return await _context.Appointments
+ .Where(a => a.ShopId == shopId)
+ .ToListAsync(cancellationToken);
+ }
+}
diff --git a/services/booking-service-net/src/BookingService.Infrastructure/Repositories/IAppointmentRepository.cs b/services/booking-service-net/src/BookingService.Infrastructure/Repositories/IAppointmentRepository.cs
new file mode 100644
index 00000000..15fa32fa
--- /dev/null
+++ b/services/booking-service-net/src/BookingService.Infrastructure/Repositories/IAppointmentRepository.cs
@@ -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;
+
+///
+/// EN: Repository interface for managing Appointment aggregate persistence.
+/// VI: Interface repository để quản lý persistence của aggregate Appointment.
+///
+public interface IAppointmentRepository : IRepository
+{
+ ///
+ /// EN: Add a new appointment.
+ /// VI: Thêm cuộc hẹn mới.
+ ///
+ Appointment Add(Appointment appointment);
+
+ ///
+ /// EN: Update an existing appointment.
+ /// VI: Cập nhật cuộc hẹn hiện có.
+ ///
+ void Update(Appointment appointment);
+
+ ///
+ /// EN: Get appointment by ID.
+ /// VI: Lấy cuộc hẹn theo ID.
+ ///
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+
+ ///
+ /// EN: Get appointments by shop ID.
+ /// VI: Lấy danh sách cuộc hẹn theo shop ID.
+ ///
+ Task> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default);
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateCategoryCommand.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateCategoryCommand.cs
new file mode 100644
index 00000000..709d00e5
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateCategoryCommand.cs
@@ -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;
+
+///
+/// EN: Command to create a new category.
+/// VI: Command để tạo danh mục mới.
+///
+public record CreateCategoryCommand : IRequest
+{
+ ///
+ /// EN: Shop ID that will own this category.
+ /// VI: ID shop sẽ sở hữu danh mục.
+ ///
+ public Guid ShopId { get; init; }
+
+ ///
+ /// EN: Category name.
+ /// VI: Tên danh mục.
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// EN: Category description.
+ /// VI: Mô tả danh mục.
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// EN: Parent category ID for hierarchical categories.
+ /// VI: ID danh mục cha cho danh mục phân cấp.
+ ///
+ public Guid? ParentId { get; init; }
+
+ ///
+ /// EN: Display order.
+ /// VI: Thứ tự hiển thị.
+ ///
+ public int DisplayOrder { get; init; }
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateCategoryCommandHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateCategoryCommandHandler.cs
new file mode 100644
index 00000000..03bc124c
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateCategoryCommandHandler.cs
@@ -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;
+
+///
+/// EN: Handler for creating a new category.
+/// VI: Handler để tạo danh mục mới.
+///
+public class CreateCategoryCommandHandler : IRequestHandler
+{
+ private readonly CatalogContext _context;
+
+ public CreateCategoryCommandHandler(CatalogContext context)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ public async Task 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;
+ }
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommand.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommand.cs
new file mode 100644
index 00000000..d55dad7c
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommand.cs
@@ -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;
+
+///
+/// EN: Command to create a new product.
+/// VI: Command để tạo sản phẩm mới.
+///
+public record CreateProductCommand : IRequest
+{
+ ///
+ /// EN: Shop ID that will own this product.
+ /// VI: ID shop sẽ sở hữu sản phẩm.
+ ///
+ public Guid ShopId { get; init; }
+
+ ///
+ /// EN: Product name.
+ /// VI: Tên sản phẩm.
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// EN: Product description.
+ /// VI: Mô tả sản phẩm.
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// EN: Product price.
+ /// VI: Giá sản phẩm.
+ ///
+ public decimal Price { get; init; }
+
+ ///
+ /// EN: Product type (Physical, Service, PreparedFood).
+ /// VI: Loại sản phẩm (Physical, Service, PreparedFood).
+ ///
+ public string Type { get; init; } = string.Empty;
+
+ ///
+ /// EN: Type-specific attributes as JSON.
+ /// VI: Thuộc tính theo loại dưới dạng JSON.
+ ///
+ public Dictionary? Attributes { get; init; }
+
+ ///
+ /// EN: Stock Keeping Unit.
+ /// VI: Mã SKU.
+ ///
+ public string? Sku { get; init; }
+
+ ///
+ /// EN: Product image URL.
+ /// VI: URL hình ảnh sản phẩm.
+ ///
+ public string? ImageUrl { get; init; }
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommandHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommandHandler.cs
new file mode 100644
index 00000000..2729cb16
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommandHandler.cs
@@ -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;
+
+///
+/// EN: Handler for creating a new product.
+/// VI: Handler để tạo sản phẩm mới.
+///
+public class CreateProductCommandHandler : IRequestHandler
+{
+ private readonly IProductRepository _productRepository;
+
+ public CreateProductCommandHandler(IProductRepository productRepository)
+ {
+ _productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository));
+ }
+
+ public async Task 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;
+ }
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/DeleteProductCommand.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/DeleteProductCommand.cs
new file mode 100644
index 00000000..be53fd02
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/DeleteProductCommand.cs
@@ -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;
+
+///
+/// EN: Command to delete a product.
+/// VI: Command để xóa sản phẩm.
+///
+public record DeleteProductCommand : IRequest
+{
+ ///
+ /// EN: Product ID to delete.
+ /// VI: ID sản phẩm cần xóa.
+ ///
+ public Guid ProductId { get; init; }
+
+ public DeleteProductCommand(Guid productId)
+ {
+ ProductId = productId;
+ }
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/DeleteProductCommandHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/DeleteProductCommandHandler.cs
new file mode 100644
index 00000000..909fe9d2
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/DeleteProductCommandHandler.cs
@@ -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;
+
+///
+/// EN: Handler for deleting a product.
+/// VI: Handler xóa sản phẩm.
+///
+public class DeleteProductCommandHandler : IRequestHandler
+{
+ private readonly IProductRepository _productRepository;
+
+ public DeleteProductCommandHandler(IProductRepository productRepository)
+ {
+ _productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository));
+ }
+
+ public async Task 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;
+ }
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/UpdateProductCommand.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/UpdateProductCommand.cs
new file mode 100644
index 00000000..fb7db3ca
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/UpdateProductCommand.cs
@@ -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;
+
+///
+/// EN: Command to update product information.
+/// VI: Command để cập nhật thông tin sản phẩm.
+///
+public record UpdateProductCommand : IRequest
+{
+ ///
+ /// EN: Product ID to update.
+ /// VI: ID sản phẩm cần cập nhật.
+ ///
+ public Guid ProductId { get; init; }
+
+ ///
+ /// EN: Product name.
+ /// VI: Tên sản phẩm.
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// EN: Product description.
+ /// VI: Mô tả sản phẩm.
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// EN: Product price.
+ /// VI: Giá sản phẩm.
+ ///
+ public decimal Price { get; init; }
+
+ ///
+ /// EN: Type-specific attributes as JSON.
+ /// VI: Thuộc tính theo loại dưới dạng JSON.
+ ///
+ public Dictionary? Attributes { get; init; }
+
+ ///
+ /// EN: Product image URL.
+ /// VI: URL hình ảnh sản phẩm.
+ ///
+ public string? ImageUrl { get; init; }
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/UpdateProductCommandHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/UpdateProductCommandHandler.cs
new file mode 100644
index 00000000..58cf8458
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/UpdateProductCommandHandler.cs
@@ -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;
+
+///
+/// EN: Handler for updating a product.
+/// VI: Handler cập nhật sản phẩm.
+///
+public class UpdateProductCommandHandler : IRequestHandler
+{
+ private readonly IProductRepository _productRepository;
+
+ public UpdateProductCommandHandler(IProductRepository productRepository)
+ {
+ _productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository));
+ }
+
+ public async Task 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;
+ }
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/DTOs/CategoryDto.cs b/services/catalog-service-net/src/CatalogService.API/Application/DTOs/CategoryDto.cs
new file mode 100644
index 00000000..ed0143a2
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/DTOs/CategoryDto.cs
@@ -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;
+
+///
+/// EN: Category DTO for API responses.
+/// VI: DTO Category cho API response.
+///
+public record CategoryDto
+{
+ ///
+ /// EN: Category ID.
+ /// VI: ID danh mục.
+ ///
+ public Guid Id { get; init; }
+
+ ///
+ /// EN: Shop ID that owns this category.
+ /// VI: ID shop sở hữu danh mục.
+ ///
+ public Guid ShopId { get; init; }
+
+ ///
+ /// EN: Category name.
+ /// VI: Tên danh mục.
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// EN: Category description.
+ /// VI: Mô tả danh mục.
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// EN: Parent category ID for hierarchical categories.
+ /// VI: ID danh mục cha cho danh mục phân cấp.
+ ///
+ public Guid? ParentId { get; init; }
+
+ ///
+ /// EN: Display order.
+ /// VI: Thứ tự hiển thị.
+ ///
+ public int DisplayOrder { get; init; }
+
+ ///
+ /// EN: Is category active.
+ /// VI: Danh mục có đang hoạt động không.
+ ///
+ public bool IsActive { get; init; }
+
+ ///
+ /// EN: Creation timestamp.
+ /// VI: Thời gian tạo.
+ ///
+ public DateTime CreatedAt { get; init; }
+
+ ///
+ /// EN: Last update timestamp.
+ /// VI: Thời gian cập nhật cuối.
+ ///
+ public DateTime? UpdatedAt { get; init; }
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/DTOs/PagedResult.cs b/services/catalog-service-net/src/CatalogService.API/Application/DTOs/PagedResult.cs
new file mode 100644
index 00000000..3e9c8f3e
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/DTOs/PagedResult.cs
@@ -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;
+
+///
+/// EN: Generic paged result for list queries.
+/// VI: Kết quả phân trang chung cho các query danh sách.
+///
+public record PagedResult
+{
+ ///
+ /// EN: Items in the current page.
+ /// VI: Các items trong trang hiện tại.
+ ///
+ public IReadOnlyList Items { get; init; } = Array.Empty();
+
+ ///
+ /// EN: Total count of items across all pages.
+ /// VI: Tổng số items trên tất cả các trang.
+ ///
+ public int TotalCount { get; init; }
+
+ ///
+ /// EN: Current page number (1-indexed).
+ /// VI: Số trang hiện tại (bắt đầu từ 1).
+ ///
+ public int Page { get; init; }
+
+ ///
+ /// EN: Page size.
+ /// VI: Kích thước trang.
+ ///
+ public int PageSize { get; init; }
+
+ ///
+ /// EN: Total number of pages.
+ /// VI: Tổng số trang.
+ ///
+ public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
+
+ ///
+ /// EN: Has previous page.
+ /// VI: Có trang trước không.
+ ///
+ public bool HasPrevious => Page > 1;
+
+ ///
+ /// EN: Has next page.
+ /// VI: Có trang tiếp theo không.
+ ///
+ public bool HasNext => Page < TotalPages;
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/DTOs/ProductDto.cs b/services/catalog-service-net/src/CatalogService.API/Application/DTOs/ProductDto.cs
new file mode 100644
index 00000000..ab9636db
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/DTOs/ProductDto.cs
@@ -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;
+
+///
+/// EN: Product DTO for API responses.
+/// VI: DTO Product cho API response.
+///
+public record ProductDto
+{
+ ///
+ /// EN: Product ID.
+ /// VI: ID sản phẩm.
+ ///
+ public Guid Id { get; init; }
+
+ ///
+ /// EN: Shop ID that owns this product.
+ /// VI: ID shop sở hữu sản phẩm.
+ ///
+ public Guid ShopId { get; init; }
+
+ ///
+ /// EN: Product name.
+ /// VI: Tên sản phẩm.
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// EN: Product description.
+ /// VI: Mô tả sản phẩm.
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// EN: Product price.
+ /// VI: Giá sản phẩm.
+ ///
+ public decimal Price { get; init; }
+
+ ///
+ /// EN: Product type (Physical, Service, PreparedFood).
+ /// VI: Loại sản phẩm (Physical, Service, PreparedFood).
+ ///
+ public string Type { get; init; } = string.Empty;
+
+ ///
+ /// EN: Type-specific attributes as dictionary.
+ /// VI: Thuộc tính theo loại dưới dạng dictionary.
+ ///
+ public Dictionary? Attributes { get; init; }
+
+ ///
+ /// EN: Product image URL.
+ /// VI: URL hình ảnh sản phẩm.
+ ///
+ public string? ImageUrl { get; init; }
+
+ ///
+ /// EN: Stock Keeping Unit.
+ /// VI: Mã SKU.
+ ///
+ public string? Sku { get; init; }
+
+ ///
+ /// EN: Is product active.
+ /// VI: Sản phẩm có đang hoạt động không.
+ ///
+ public bool IsActive { get; init; }
+
+ ///
+ /// EN: Creation timestamp.
+ /// VI: Thời gian tạo.
+ ///
+ public DateTime CreatedAt { get; init; }
+
+ ///
+ /// EN: Last update timestamp.
+ /// VI: Thời gian cập nhật cuối.
+ ///
+ public DateTime? UpdatedAt { get; init; }
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetCategoriesQuery.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetCategoriesQuery.cs
new file mode 100644
index 00000000..30676b42
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetCategoriesQuery.cs
@@ -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;
+
+///
+/// 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.
+///
+public record GetCategoriesQuery : IRequest>
+{
+ ///
+ /// EN: Shop ID to filter categories.
+ /// VI: ID shop để lọc danh mục.
+ ///
+ public Guid ShopId { get; init; }
+
+ ///
+ /// EN: Filter by parent ID (null = root categories).
+ /// VI: Lọc theo ID cha (null = danh mục gốc).
+ ///
+ public Guid? ParentId { get; init; }
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetCategoriesQueryHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetCategoriesQueryHandler.cs
new file mode 100644
index 00000000..ba956f34
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetCategoriesQueryHandler.cs
@@ -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;
+
+///
+/// EN: Handler for querying categories with hierarchical support.
+/// VI: Handler query danh mục có hỗ trợ phân cấp.
+///
+public class GetCategoriesQueryHandler : IRequestHandler>
+{
+ private readonly CatalogContext _context;
+
+ public GetCategoriesQueryHandler(CatalogContext context)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ public async Task> 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;
+ }
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQuery.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQuery.cs
new file mode 100644
index 00000000..59fdd15e
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQuery.cs
@@ -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;
+
+///
+/// EN: Query to get a product by ID.
+/// VI: Query để lấy sản phẩm theo ID.
+///
+public record GetProductByIdQuery : IRequest
+{
+ ///
+ /// EN: Product ID.
+ /// VI: ID sản phẩm.
+ ///
+ public Guid ProductId { get; init; }
+
+ public GetProductByIdQuery(Guid productId)
+ {
+ ProductId = productId;
+ }
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQueryHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQueryHandler.cs
new file mode 100644
index 00000000..770a2a88
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQueryHandler.cs
@@ -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;
+
+///
+/// EN: Handler for querying a single product by ID.
+/// VI: Handler query một sản phẩm theo ID.
+///
+public class GetProductByIdQueryHandler : IRequestHandler
+{
+ private readonly CatalogContext _context;
+
+ public GetProductByIdQueryHandler(CatalogContext context)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ public async Task 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>(p.Attributes.RootElement.GetRawText())
+ : null,
+ ImageUrl = p.ImageUrl,
+ Sku = p.Sku,
+ IsActive = p.IsActive,
+ CreatedAt = p.CreatedAt,
+ UpdatedAt = p.UpdatedAt
+ })
+ .FirstOrDefaultAsync(cancellationToken);
+
+ return product;
+ }
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductsQuery.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductsQuery.cs
new file mode 100644
index 00000000..cf2c85ac
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductsQuery.cs
@@ -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;
+
+///
+/// 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.
+///
+public record GetProductsQuery : IRequest>
+{
+ ///
+ /// EN: Shop ID to filter products.
+ /// VI: ID shop để lọc sản phẩm.
+ ///
+ public Guid ShopId { get; init; }
+
+ ///
+ /// EN: Filter by active status (null = all).
+ /// VI: Lọc theo trạng thái hoạt động (null = tất cả).
+ ///
+ public bool? IsActive { get; init; }
+
+ ///
+ /// EN: Filter by product type (null = all).
+ /// VI: Lọc theo loại sản phẩm (null = tất cả).
+ ///
+ public string? Type { get; init; }
+
+ ///
+ /// EN: Page number (1-indexed).
+ /// VI: Số trang (bắt đầu từ 1).
+ ///
+ public int Page { get; init; } = 1;
+
+ ///
+ /// EN: Page size.
+ /// VI: Kích thước trang.
+ ///
+ public int PageSize { get; init; } = 20;
+}
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductsQueryHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductsQueryHandler.cs
new file mode 100644
index 00000000..e48229b5
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductsQueryHandler.cs
@@ -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;
+
+///
+/// EN: Handler for querying products with filtering and pagination.
+/// VI: Handler query sản phẩm có lọc và phân trang.
+///
+public class GetProductsQueryHandler : IRequestHandler>
+{
+ private readonly CatalogContext _context;
+
+ public GetProductsQueryHandler(CatalogContext context)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ public async Task> 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>(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
+ {
+ Items = products,
+ TotalCount = totalCount,
+ Page = request.Page,
+ PageSize = request.PageSize
+ };
+ }
+}
diff --git a/services/inventory-service-net/tests/InventoryService.UnitTests/Domain/SampleAggregateTests.cs b/services/inventory-service-net/tests/InventoryService.UnitTests/Domain/SampleAggregateTests.cs
deleted file mode 100644
index 52e1da5b..00000000
--- a/services/inventory-service-net/tests/InventoryService.UnitTests/Domain/SampleAggregateTests.cs
+++ /dev/null
@@ -1,151 +0,0 @@
-using FluentAssertions;
-using InventoryService.Domain.AggregatesModel.SampleAggregate;
-using InventoryService.Domain.Exceptions;
-using Xunit;
-
-namespace InventoryService.UnitTests.Domain;
-
-///
-/// EN: Unit tests for Sample aggregate.
-/// VI: Unit tests cho Sample aggregate.
-///
-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()
- .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()
- .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()
- .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()
- .WithMessage("Cannot update a cancelled sample");
- }
-}