feat: Implement initial entity configurations for ads billing, analytics, and serving, add catalog product and category commands/queries, and refine booking service infrastructure.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
<!-- EN: FluentValidation for request validation / VI: FluentValidation cho validation request -->
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsAnalyticsService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get campaign metrics for a specific date range.
|
||||
/// VI: Query lấy metrics chiến dịch cho khoảng thời gian cụ thể.
|
||||
/// </summary>
|
||||
public record GetCampaignMetricsQuery : IRequest<CampaignMetricsDto?>
|
||||
{
|
||||
public Guid CampaignId { get; init; }
|
||||
public DateTime StartDate { get; init; }
|
||||
public DateTime EndDate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Campaign metrics DTO.
|
||||
/// VI: DTO metrics chiến dịch.
|
||||
/// </summary>
|
||||
public record CampaignMetricsDto
|
||||
{
|
||||
public Guid CampaignId { get; init; }
|
||||
public DateTime StartDate { get; init; }
|
||||
public DateTime EndDate { get; init; }
|
||||
public long TotalImpressions { get; init; }
|
||||
public long TotalClicks { get; init; }
|
||||
public long TotalConversions { get; init; }
|
||||
public decimal TotalSpend { get; init; }
|
||||
public decimal TotalRevenue { get; init; }
|
||||
public decimal CTR { get; init; }
|
||||
public decimal CPC { get; init; }
|
||||
public decimal CPA { get; init; }
|
||||
public decimal ROAS { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using AdsAnalyticsService.Infrastructure;
|
||||
|
||||
namespace AdsAnalyticsService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetCampaignMetricsQuery.
|
||||
/// VI: Handler cho GetCampaignMetricsQuery.
|
||||
/// </summary>
|
||||
public class GetCampaignMetricsQueryHandler : IRequestHandler<GetCampaignMetricsQuery, CampaignMetricsDto?>
|
||||
{
|
||||
private readonly AdsAnalyticsServiceContext _context;
|
||||
private readonly ILogger<GetCampaignMetricsQueryHandler> _logger;
|
||||
|
||||
public GetCampaignMetricsQueryHandler(
|
||||
AdsAnalyticsServiceContext context,
|
||||
ILogger<GetCampaignMetricsQueryHandler> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CampaignMetricsDto?> Handle(GetCampaignMetricsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// EN: Aggregate metrics for the campaign within date range
|
||||
// VI: Tổng hợp metrics cho chiến dịch trong khoảng thời gian
|
||||
var metrics = await _context.CampaignMetrics
|
||||
.Where(m => m.CampaignId == request.CampaignId
|
||||
&& m.Date >= request.StartDate.Date
|
||||
&& m.Date <= request.EndDate.Date)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (!metrics.Any())
|
||||
{
|
||||
_logger.LogInformation("No metrics found for Campaign {CampaignId}", request.CampaignId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// EN: Aggregate totals
|
||||
// VI: Tổng hợp tổng cộng
|
||||
var totalImpressions = metrics.Sum(m => m.Impressions);
|
||||
var totalClicks = metrics.Sum(m => m.Clicks);
|
||||
var totalConversions = metrics.Sum(m => m.Conversions);
|
||||
var totalSpend = metrics.Sum(m => m.Spend);
|
||||
var totalRevenue = metrics.Sum(m => m.Revenue);
|
||||
|
||||
// EN: Calculate aggregate KPIs
|
||||
// VI: Tính toán KPIs tổng hợp
|
||||
var ctr = totalImpressions > 0 ? (decimal)totalClicks / totalImpressions * 100 : 0;
|
||||
var cpc = totalClicks > 0 ? totalSpend / totalClicks : 0;
|
||||
var cpa = totalConversions > 0 ? totalSpend / totalConversions : 0;
|
||||
var roas = totalSpend > 0 ? totalRevenue / totalSpend : 0;
|
||||
|
||||
return new CampaignMetricsDto
|
||||
{
|
||||
CampaignId = request.CampaignId,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
TotalImpressions = totalImpressions,
|
||||
TotalClicks = totalClicks,
|
||||
TotalConversions = totalConversions,
|
||||
TotalSpend = totalSpend,
|
||||
TotalRevenue = totalRevenue,
|
||||
CTR = ctr,
|
||||
CPC = cpc,
|
||||
CPA = cpa,
|
||||
ROAS = roas
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Asp.Versioning;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using AdsAnalyticsService.API.Application.Queries;
|
||||
|
||||
namespace AdsAnalyticsService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: API Controller for ads analytics metrics.
|
||||
/// VI: API Controller cho metrics phân tích quảng cáo.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/ads-analytics")]
|
||||
[Produces("application/json")]
|
||||
public class MetricsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<MetricsController> _logger;
|
||||
|
||||
public MetricsController(IMediator mediator, ILogger<MetricsController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get campaign metrics for a date range.
|
||||
/// VI: Lấy metrics chiến dịch cho khoảng thời gian.
|
||||
/// </summary>
|
||||
/// <param name="id">Campaign ID</param>
|
||||
/// <param name="startDate">Start date (YYYY-MM-DD)</param>
|
||||
/// <param name="endDate">End date (YYYY-MM-DD)</param>
|
||||
/// <returns>Campaign metrics</returns>
|
||||
[HttpGet("campaigns/{id}/metrics")]
|
||||
[ProducesResponseType(typeof(CampaignMetricsDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CampaignMetricsDto>> GetCampaignMetrics(
|
||||
Guid id,
|
||||
[FromQuery] DateTime? startDate = null,
|
||||
[FromQuery] DateTime? endDate = null)
|
||||
{
|
||||
// EN: Default to last 30 days if not specified
|
||||
// VI: Mặc định 30 ngày gần nhất nếu không chỉ định
|
||||
var start = startDate ?? DateTime.UtcNow.AddDays(-30).Date;
|
||||
var end = endDate ?? DateTime.UtcNow.Date;
|
||||
|
||||
var query = new GetCampaignMetricsQuery
|
||||
{
|
||||
CampaignId = id,
|
||||
StartDate = start,
|
||||
EndDate = end
|
||||
};
|
||||
|
||||
var metrics = await _mediator.Send(query);
|
||||
|
||||
if (metrics == null)
|
||||
return NotFound(new { message = $"No metrics found for campaign {id}" });
|
||||
|
||||
return Ok(metrics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get ad set metrics (placeholder for future implementation).
|
||||
/// VI: Lấy metrics ad set (placeholder cho triển khai sau).
|
||||
/// </summary>
|
||||
[HttpGet("adsets/{id}/metrics")]
|
||||
[ProducesResponseType(StatusCodes.Status501NotImplemented)]
|
||||
public IActionResult GetAdSetMetrics(Guid id)
|
||||
{
|
||||
_logger.LogWarning("AdSet metrics not yet implemented");
|
||||
return StatusCode(StatusCodes.Status501NotImplemented,
|
||||
new { message = "AdSet metrics endpoint not yet implemented" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get ad metrics (placeholder for future implementation).
|
||||
/// VI: Lấy metrics ad (placeholder cho triển khai sau).
|
||||
/// </summary>
|
||||
[HttpGet("ads/{id}/metrics")]
|
||||
[ProducesResponseType(StatusCodes.Status501NotImplemented)]
|
||||
public IActionResult GetAdMetrics(Guid id)
|
||||
{
|
||||
_logger.LogWarning("Ad metrics not yet implemented");
|
||||
return StatusCode(StatusCodes.Status501NotImplemented,
|
||||
new { message = "Ad metrics endpoint not yet implemented" });
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using AdsAnalyticsService.Domain.AggregatesModel.SampleAggregate;
|
||||
using AdsAnalyticsService.Domain.AggregatesModel.MetricsAggregate;
|
||||
using AdsAnalyticsService.Domain.AggregatesModel.ReportAggregate;
|
||||
using AdsAnalyticsService.Domain.SeedWork;
|
||||
using AdsAnalyticsService.Infrastructure.EntityConfigurations;
|
||||
|
||||
@@ -17,10 +18,16 @@ public class AdsAnalyticsServiceContext : DbContext, IUnitOfWork
|
||||
private IDbContextTransaction? _currentTransaction;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Samples table.
|
||||
/// VI: Bảng Samples.
|
||||
/// EN: Campaign metrics table.
|
||||
/// VI: Bảng metrics chiến dịch.
|
||||
/// </summary>
|
||||
public DbSet<Sample> Samples => Set<Sample>();
|
||||
public DbSet<CampaignMetrics> CampaignMetrics => Set<CampaignMetrics>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reports table.
|
||||
/// VI: Bảng báo cáo.
|
||||
/// </summary>
|
||||
public DbSet<Report> Reports => Set<Report>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Read-only access to current transaction.
|
||||
@@ -50,8 +57,8 @@ public class AdsAnalyticsServiceContext : DbContext, IUnitOfWork
|
||||
{
|
||||
// EN: Apply entity configurations
|
||||
// VI: Áp dụng các cấu hình entity
|
||||
modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new CampaignMetricsEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new ReportEntityTypeConfiguration());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using AdsAnalyticsService.Domain.AggregatesModel.MetricsAggregate;
|
||||
|
||||
namespace AdsAnalyticsService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity type configuration for CampaignMetrics aggregate.
|
||||
/// VI: Cấu hình entity type cho CampaignMetrics aggregate.
|
||||
/// </summary>
|
||||
public class CampaignMetricsEntityTypeConfiguration : IEntityTypeConfiguration<CampaignMetrics>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<CampaignMetrics> builder)
|
||||
{
|
||||
// EN: Table name
|
||||
// VI: Tên bảng
|
||||
builder.ToTable("campaign_metrics");
|
||||
|
||||
// EN: Primary key
|
||||
// VI: Khóa chính
|
||||
builder.HasKey(m => m.Id);
|
||||
builder.Property(m => m.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Campaign reference
|
||||
// VI: Tham chiếu chiến dịch
|
||||
builder.Property(m => m.CampaignId)
|
||||
.HasColumnName("campaign_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex(m => m.CampaignId)
|
||||
.HasDatabaseName("idx_campaign_metrics_campaign_id");
|
||||
|
||||
// EN: Date (for daily aggregation)
|
||||
// VI: Ngày (cho tổng hợp theo ngày)
|
||||
builder.Property(m => m.Date)
|
||||
.HasColumnName("date")
|
||||
.HasColumnType("date")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex(m => new { m.CampaignId, m.Date })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("idx_campaign_metrics_campaign_date");
|
||||
|
||||
// EN: Performance metrics
|
||||
// VI: Chỉ số hiệu suất
|
||||
builder.Property(m => m.Impressions)
|
||||
.HasColumnName("impressions")
|
||||
.HasDefaultValue(0)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(m => m.Clicks)
|
||||
.HasColumnName("clicks")
|
||||
.HasDefaultValue(0)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(m => m.Conversions)
|
||||
.HasColumnName("conversions")
|
||||
.HasDefaultValue(0)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(m => m.Spend)
|
||||
.HasColumnName("spend")
|
||||
.HasColumnType("decimal(18,2)")
|
||||
.HasDefaultValue(0m)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(m => m.Revenue)
|
||||
.HasColumnName("revenue")
|
||||
.HasColumnType("decimal(18,2)")
|
||||
.HasDefaultValue(0m)
|
||||
.IsRequired();
|
||||
|
||||
// EN: Ignore calculated properties
|
||||
// VI: Bỏ qua các thuộc tính được tính toán
|
||||
builder.Ignore(m => m.CTR);
|
||||
builder.Ignore(m => m.CPC);
|
||||
builder.Ignore(m => m.CPA);
|
||||
builder.Ignore(m => m.ROAS);
|
||||
|
||||
// EN: Ignore domain events
|
||||
// VI: Bỏ qua domain events
|
||||
builder.Ignore(m => m.DomainEvents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using AdsAnalyticsService.Domain.AggregatesModel.ReportAggregate;
|
||||
|
||||
namespace AdsAnalyticsService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity type configuration for Report aggregate.
|
||||
/// VI: Cấu hình entity type cho Report aggregate.
|
||||
/// </summary>
|
||||
public class ReportEntityTypeConfiguration : IEntityTypeConfiguration<Report>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Report> builder)
|
||||
{
|
||||
// EN: Table name
|
||||
// VI: Tên bảng
|
||||
builder.ToTable("reports");
|
||||
|
||||
// EN: Primary key
|
||||
// VI: Khóa chính
|
||||
builder.HasKey(r => r.Id);
|
||||
builder.Property(r => r.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Advertiser reference
|
||||
// VI: Tham chiếu advertiser
|
||||
builder.Property(r => r.AdvertiserId)
|
||||
.HasColumnName("advertiser_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex(r => r.AdvertiserId)
|
||||
.HasDatabaseName("idx_reports_advertiser_id");
|
||||
|
||||
// EN: Report properties
|
||||
// VI: Thuộc tính báo cáo
|
||||
builder.Property(r => r.Name)
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(200)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(r => r.ReportType)
|
||||
.HasColumnName("report_type")
|
||||
.HasConversion<int>()
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(r => r.StartDate)
|
||||
.HasColumnName("start_date")
|
||||
.HasColumnType("date")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(r => r.EndDate)
|
||||
.HasColumnName("end_date")
|
||||
.HasColumnType("date")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(r => r.Status)
|
||||
.HasColumnName("status")
|
||||
.HasConversion<int>()
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(r => r.DataJson)
|
||||
.HasColumnName("data_json")
|
||||
.HasColumnType("jsonb")
|
||||
.IsRequired(false);
|
||||
|
||||
builder.Property(r => r.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Indexes
|
||||
// VI: Chỉ mục
|
||||
builder.HasIndex(r => r.Status)
|
||||
.HasDatabaseName("idx_reports_status");
|
||||
|
||||
builder.HasIndex(r => r.CreatedAt)
|
||||
.HasDatabaseName("idx_reports_created_at");
|
||||
|
||||
// EN: Ignore domain events
|
||||
// VI: Bỏ qua domain events
|
||||
builder.Ignore(r => r.DomainEvents);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@
|
||||
<!-- EN: FluentValidation for request validation / VI: FluentValidation cho validation request -->
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
||||
|
||||
@@ -32,7 +32,12 @@ public class AdsBillingServiceContext : DbContext, IUnitOfWork
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// Entity configurations will be applied here
|
||||
// EN: Apply entity configurations / VI: Áp dụng entity configurations
|
||||
modelBuilder.ApplyConfiguration(new EntityConfigurations.BillingAccountEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new EntityConfigurations.InvoiceEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new EntityConfigurations.InvoiceLineItemEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new EntityConfigurations.AdChargeEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new EntityConfigurations.ClientRequestEntityTypeConfiguration());
|
||||
}
|
||||
|
||||
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using AdsBillingService.Domain.AggregatesModel.ChargeAggregate;
|
||||
|
||||
namespace AdsBillingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity configuration for AdCharge aggregate.
|
||||
/// VI: Cấu hình entity cho aggregate AdCharge.
|
||||
/// </summary>
|
||||
public class AdChargeEntityTypeConfiguration : IEntityTypeConfiguration<AdCharge>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AdCharge> builder)
|
||||
{
|
||||
// EN: Table name / VI: Tên bảng
|
||||
builder.ToTable("ad_charges");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(c => c.Id);
|
||||
builder.Property(c => c.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Advertiser ID / VI: ID Advertiser
|
||||
builder.Property(c => c.AdvertiserId)
|
||||
.HasColumnName("advertiser_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex(c => c.AdvertiserId)
|
||||
.HasDatabaseName("ix_ad_charges_advertiser_id");
|
||||
|
||||
// EN: Campaign ID / VI: ID chiến dịch
|
||||
builder.Property(c => c.CampaignId)
|
||||
.HasColumnName("campaign_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex(c => c.CampaignId)
|
||||
.HasDatabaseName("ix_ad_charges_campaign_id");
|
||||
|
||||
// EN: Ad ID / VI: ID quảng cáo
|
||||
builder.Property(c => c.AdId)
|
||||
.HasColumnName("ad_id")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Charge type / VI: Loại charge
|
||||
builder.Property(c => c.ChargeType)
|
||||
.HasColumnName("charge_type")
|
||||
.HasConversion<int>()
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex(c => c.ChargeType)
|
||||
.HasDatabaseName("ix_ad_charges_charge_type");
|
||||
|
||||
// EN: Amount / VI: Số tiền
|
||||
builder.Property(c => c.Amount)
|
||||
.HasColumnName("amount")
|
||||
.HasColumnType("decimal(18,6)")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Currency / VI: Loại tiền
|
||||
builder.Property(c => c.Currency)
|
||||
.HasColumnName("currency")
|
||||
.HasMaxLength(10)
|
||||
.IsRequired();
|
||||
|
||||
// EN: Charged at / VI: Thời điểm charge
|
||||
builder.Property(c => c.ChargedAt)
|
||||
.HasColumnName("charged_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex(c => c.ChargedAt)
|
||||
.HasDatabaseName("ix_ad_charges_charged_at");
|
||||
|
||||
// EN: Processed flag / VI: Cờ đã xử lý
|
||||
builder.Property(c => c.Processed)
|
||||
.HasColumnName("processed")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex(c => c.Processed)
|
||||
.HasDatabaseName("ix_ad_charges_processed");
|
||||
|
||||
// EN: Composite index for unprocessed charges / VI: Chỉ mục kết hợp cho charges chưa xử lý
|
||||
builder.HasIndex(c => new { c.AdvertiserId, c.Processed, c.ChargedAt })
|
||||
.HasDatabaseName("ix_ad_charges_advertiser_processed_charged");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using AdsBillingService.Domain.AggregatesModel.BillingAccountAggregate;
|
||||
|
||||
namespace AdsBillingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity configuration for BillingAccount aggregate.
|
||||
/// VI: Cấu hình entity cho aggregate BillingAccount.
|
||||
/// </summary>
|
||||
public class BillingAccountEntityTypeConfiguration : IEntityTypeConfiguration<BillingAccount>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<BillingAccount> builder)
|
||||
{
|
||||
// EN: Table name / VI: Tên bảng
|
||||
builder.ToTable("billing_accounts");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(b => b.Id);
|
||||
builder.Property(b => b.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Advertiser ID / VI: ID Advertiser
|
||||
builder.Property(b => b.AdvertiserId)
|
||||
.HasColumnName("advertiser_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex(b => b.AdvertiserId)
|
||||
.HasDatabaseName("ix_billing_accounts_advertiser_id");
|
||||
|
||||
// EN: Wallet ID / VI: ID Ví
|
||||
builder.Property(b => b.WalletId)
|
||||
.HasColumnName("wallet_id")
|
||||
.IsRequired(false);
|
||||
|
||||
// EN: Payment method / VI: Phương thức thanh toán
|
||||
builder.Property(b => b.PaymentMethod)
|
||||
.HasColumnName("payment_method")
|
||||
.HasConversion<int>()
|
||||
.IsRequired();
|
||||
|
||||
// EN: Account status / VI: Trạng thái tài khoản
|
||||
builder.Property(b => b.Status)
|
||||
.HasColumnName("status")
|
||||
.HasConversion<int>()
|
||||
.IsRequired();
|
||||
|
||||
// EN: Balance / VI: Số dư
|
||||
builder.Property(b => b.Balance)
|
||||
.HasColumnName("balance")
|
||||
.HasColumnType("decimal(18,2)")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Credit limit / VI: Hạn mức tín dụng
|
||||
builder.Property(b => b.CreditLimit)
|
||||
.HasColumnName("credit_limit")
|
||||
.HasColumnType("decimal(18,2)")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Billing threshold as owned entity (value object) / VI: Ngưỡng billing như owned entity
|
||||
builder.OwnsOne(b => b.Threshold, threshold =>
|
||||
{
|
||||
threshold.Property(t => t.Amount)
|
||||
.HasColumnName("threshold_amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
threshold.Property(t => t.AutoCharge)
|
||||
.HasColumnName("threshold_auto_charge");
|
||||
});
|
||||
|
||||
// EN: Navigation for owned entity / VI: Navigation cho owned entity
|
||||
builder.Navigation(b => b.Threshold).IsRequired(false);
|
||||
|
||||
// EN: Timestamps / VI: Thời gian
|
||||
builder.Property(b => b.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(b => b.UpdatedAt)
|
||||
.HasColumnName("updated_at")
|
||||
.IsRequired(false);
|
||||
|
||||
// EN: Indexes / VI: Chỉ mục
|
||||
builder.HasIndex(b => b.CreatedAt)
|
||||
.HasDatabaseName("ix_billing_accounts_created_at");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using AdsBillingService.Infrastructure.Idempotency;
|
||||
|
||||
namespace AdsBillingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity configuration for ClientRequest (idempotency).
|
||||
/// VI: Cấu hình entity cho ClientRequest (idempotency).
|
||||
/// </summary>
|
||||
public class ClientRequestEntityTypeConfiguration : IEntityTypeConfiguration<ClientRequest>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ClientRequest> builder)
|
||||
{
|
||||
// EN: Table name / VI: Tên bảng
|
||||
builder.ToTable("client_requests");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(cr => cr.Id);
|
||||
builder.Property(cr => cr.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Request ID / VI: ID yêu cầu
|
||||
builder.Property(cr => cr.Name)
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(200)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex(cr => cr.Name)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_client_requests_name");
|
||||
|
||||
// EN: Timestamp / VI: Thời gian
|
||||
builder.Property(cr => cr.Time)
|
||||
.HasColumnName("time")
|
||||
.IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using AdsBillingService.Domain.AggregatesModel.InvoiceAggregate;
|
||||
|
||||
namespace AdsBillingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity configuration for Invoice aggregate.
|
||||
/// VI: Cấu hình entity cho aggregate Invoice.
|
||||
/// </summary>
|
||||
public class InvoiceEntityTypeConfiguration : IEntityTypeConfiguration<Invoice>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Invoice> builder)
|
||||
{
|
||||
// EN: Table name / VI: Tên bảng
|
||||
builder.ToTable("invoices");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(i => i.Id);
|
||||
builder.Property(i => i.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Billing account ID / VI: ID tài khoản billing
|
||||
builder.Property(i => i.BillingAccountId)
|
||||
.HasColumnName("billing_account_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex(i => i.BillingAccountId)
|
||||
.HasDatabaseName("ix_invoices_billing_account_id");
|
||||
|
||||
// EN: Invoice number / VI: Số hóa đơn
|
||||
builder.Property(i => i.InvoiceNumber)
|
||||
.HasColumnName("invoice_number")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex(i => i.InvoiceNumber)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_invoices_invoice_number");
|
||||
|
||||
// EN: Status / VI: Trạng thái
|
||||
builder.Property(i => i.Status)
|
||||
.HasColumnName("status")
|
||||
.HasConversion<int>()
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex(i => i.Status)
|
||||
.HasDatabaseName("ix_invoices_status");
|
||||
|
||||
// EN: Issue date / VI: Ngày phát hành
|
||||
builder.Property(i => i.IssueDate)
|
||||
.HasColumnName("issue_date")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Due date / VI: Ngày đến hạn
|
||||
builder.Property(i => i.DueDate)
|
||||
.HasColumnName("due_date")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex(i => i.DueDate)
|
||||
.HasDatabaseName("ix_invoices_due_date");
|
||||
|
||||
// EN: Total amount / VI: Tổng tiền
|
||||
builder.Property(i => i.TotalAmount)
|
||||
.HasColumnName("total_amount")
|
||||
.HasColumnType("decimal(18,2)")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Line items as collection / VI: Các dòng chi tiết
|
||||
builder.HasMany<InvoiceLineItem>()
|
||||
.WithOne()
|
||||
.HasForeignKey("InvoiceId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// EN: Metadata / VI: Ignore navigation property for EF Core
|
||||
builder.Metadata.FindNavigation(nameof(Invoice.LineItems))!
|
||||
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using AdsBillingService.Domain.AggregatesModel.InvoiceAggregate;
|
||||
|
||||
namespace AdsBillingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity configuration for InvoiceLineItem.
|
||||
/// VI: Cấu hình entity cho InvoiceLineItem.
|
||||
/// </summary>
|
||||
public class InvoiceLineItemEntityTypeConfiguration : IEntityTypeConfiguration<InvoiceLineItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<InvoiceLineItem> builder)
|
||||
{
|
||||
// EN: Table name / VI: Tên bảng
|
||||
builder.ToTable("invoice_line_items");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(li => li.Id);
|
||||
builder.Property(li => li.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Invoice ID (foreign key) / VI: ID hóa đơn
|
||||
builder.Property<Guid>("InvoiceId")
|
||||
.HasColumnName("invoice_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasIndex("InvoiceId")
|
||||
.HasDatabaseName("ix_invoice_line_items_invoice_id");
|
||||
|
||||
// EN: Campaign ID / VI: ID chiến dịch
|
||||
builder.Property(li => li.CampaignId)
|
||||
.HasColumnName("campaign_id")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Description / VI: Mô tả
|
||||
builder.Property(li => li.Description)
|
||||
.HasColumnName("description")
|
||||
.HasMaxLength(500)
|
||||
.IsRequired();
|
||||
|
||||
// EN: Quantity / VI: Số lượng
|
||||
builder.Property(li => li.Quantity)
|
||||
.HasColumnName("quantity")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Unit price / VI: Đơn giá
|
||||
builder.Property(li => li.UnitPrice)
|
||||
.HasColumnName("unit_price")
|
||||
.HasColumnType("decimal(18,2)")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Total amount is computed / VI: Tổng tiền được tính toán
|
||||
builder.Ignore(li => li.TotalAmount);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,12 @@
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
|
||||
|
||||
<!-- EN: EF Core Design for migrations / VI: EF Core Design cho migrations -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsServingService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get paginated list of auctions with filters.
|
||||
/// VI: Query lấy danh sách auctions phân trang với bộ lọc.
|
||||
/// </summary>
|
||||
public class GetAuctionsQuery : IRequest<PagedResult<AuctionDto>>
|
||||
{
|
||||
public Guid? UserId { get; init; }
|
||||
public string? PlacementType { get; init; }
|
||||
public DateTime? StartDate { get; init; }
|
||||
public DateTime? EndDate { get; init; }
|
||||
public int Page { get; init; } = 1;
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
|
||||
public record AuctionDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid UserId { get; init; }
|
||||
public string PlacementType { get; init; } = string.Empty;
|
||||
public DateTime AuctionTime { get; init; }
|
||||
public int BidCount { get; init; }
|
||||
public Guid? WinningAdId { get; init; }
|
||||
public decimal? FinalPrice { get; init; }
|
||||
public decimal? WinningeCPM { get; init; }
|
||||
}
|
||||
|
||||
public record PagedResult<T>
|
||||
{
|
||||
public List<T> Items { get; init; } = new();
|
||||
public int TotalCount { get; init; }
|
||||
public int Page { get; init; }
|
||||
public int PageSize { get; init; }
|
||||
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using AdsServingService.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AdsServingService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetAuctionsQuery.
|
||||
/// VI: Handler cho GetAuctionsQuery.
|
||||
/// </summary>
|
||||
public class GetAuctionsQueryHandler : IRequestHandler<GetAuctionsQuery, PagedResult<AuctionDto>>
|
||||
{
|
||||
private readonly AdsServingServiceContext _context;
|
||||
|
||||
public GetAuctionsQueryHandler(AdsServingServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<PagedResult<AuctionDto>> Handle(GetAuctionsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.Auctions.AsQueryable();
|
||||
|
||||
// Apply filters
|
||||
if (request.UserId.HasValue)
|
||||
query = query.Where(a => a.UserId == request.UserId.Value);
|
||||
|
||||
if (!string.IsNullOrEmpty(request.PlacementType))
|
||||
query = query.Where(a => a.PlacementType == request.PlacementType);
|
||||
|
||||
if (request.StartDate.HasValue)
|
||||
query = query.Where(a => EF.Property<DateTime>(a, "_auctionTime") >= request.StartDate.Value);
|
||||
|
||||
if (request.EndDate.HasValue)
|
||||
query = query.Where(a => EF.Property<DateTime>(a, "_auctionTime") <= request.EndDate.Value);
|
||||
|
||||
// Get total count
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
// Apply pagination
|
||||
var auctions = await query
|
||||
.OrderByDescending(a => EF.Property<DateTime>(a, "_auctionTime"))
|
||||
.Skip((request.Page - 1) * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.Select(a => new AuctionDto
|
||||
{
|
||||
Id = a.Id,
|
||||
UserId = a.UserId,
|
||||
PlacementType = a.PlacementType,
|
||||
AuctionTime = EF.Property<DateTime>(a, "_auctionTime"),
|
||||
BidCount = a.Bids.Count,
|
||||
WinningAdId = a.Result != null ? a.Result.WinningAdId : null,
|
||||
FinalPrice = a.Result != null ? a.Result.FinalPrice : null,
|
||||
WinningeCPM = a.Result != null ? a.Result.WinningeCPM : null
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new PagedResult<AuctionDto>
|
||||
{
|
||||
Items = auctions,
|
||||
TotalCount = totalCount,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsServingService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get paginated list of budget pacers.
|
||||
/// VI: Query lấy danh sách budget pacers phân trang.
|
||||
/// </summary>
|
||||
public class GetBudgetPacersQuery : IRequest<PagedResult<BudgetPacerDto>>
|
||||
{
|
||||
public Guid? CampaignId { get; init; }
|
||||
public int Page { get; init; } = 1;
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
|
||||
public record BudgetPacerDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid CampaignId { get; init; }
|
||||
public decimal DailyBudget { get; init; }
|
||||
public decimal SpentToday { get; init; }
|
||||
public decimal RemainingBudget { get; init; }
|
||||
public decimal UtilizationPercent { get; init; }
|
||||
public string Strategy { get; init; } = string.Empty;
|
||||
public DateTime LastUpdated { get; init; }
|
||||
}
|
||||
|
||||
public record BudgetStatisticsDto
|
||||
{
|
||||
public int TotalCampaigns { get; init; }
|
||||
public decimal TotalDailyBudget { get; init; }
|
||||
public decimal TotalSpentToday { get; init; }
|
||||
public decimal AverageUtilization { get; init; }
|
||||
public int CampaignsExceeded { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using AdsServingService.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AdsServingService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetBudgetPacersQuery.
|
||||
/// VI: Handler cho GetBudgetPacersQuery.
|
||||
/// </summary>
|
||||
public class GetBudgetPacersQueryHandler : IRequestHandler<GetBudgetPacersQuery, PagedResult<BudgetPacerDto>>
|
||||
{
|
||||
private readonly AdsServingServiceContext _context;
|
||||
|
||||
public GetBudgetPacersQueryHandler(AdsServingServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<PagedResult<BudgetPacerDto>> Handle(GetBudgetPacersQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.BudgetPacers.AsQueryable();
|
||||
|
||||
// Apply filters
|
||||
if (request.CampaignId.HasValue)
|
||||
query = query.Where(bp => EF.Property<Guid>(bp, "_campaignId") == request.CampaignId.Value);
|
||||
|
||||
// Get total count
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
// Apply pagination
|
||||
var pacers = await query
|
||||
.OrderByDescending(bp => EF.Property<DateTime>(bp, "_lastUpdated"))
|
||||
.Skip((request.Page - 1) * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.Select(bp => new BudgetPacerDto
|
||||
{
|
||||
Id = bp.Id,
|
||||
CampaignId = EF.Property<Guid>(bp, "_campaignId"),
|
||||
DailyBudget = EF.Property<decimal>(bp, "_dailyBudget"),
|
||||
SpentToday = EF.Property<decimal>(bp, "_spentToday"),
|
||||
RemainingBudget = bp.RemainingBudget,
|
||||
UtilizationPercent = bp.UtilizationPercent,
|
||||
Strategy = bp.Strategy.ToString(),
|
||||
LastUpdated = EF.Property<DateTime>(bp, "_lastUpdated")
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new PagedResult<BudgetPacerDto>
|
||||
{
|
||||
Items = pacers,
|
||||
TotalCount = totalCount,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using AdsServingService.API.Application.Queries;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace AdsServingService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Admin API Controller for managing and monitoring auctions.
|
||||
/// VI: API Controller Admin để quản lý và giám sát các phiên đấu giá.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/admin/auctions")]
|
||||
[Produces("application/json")]
|
||||
public class AdminAuctionsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<AdminAuctionsController> _logger;
|
||||
|
||||
public AdminAuctionsController(IMediator mediator, ILogger<AdminAuctionsController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get paginated list of auctions with optional filters.
|
||||
/// VI: Lấy danh sách auctions phân trang với bộ lọc tùy chọn.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResult<AuctionDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResult<AuctionDto>>> GetAuctions(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] string? placementType,
|
||||
[FromQuery] DateTime? startDate,
|
||||
[FromQuery] DateTime? endDate,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var query = new GetAuctionsQuery
|
||||
{
|
||||
UserId = userId,
|
||||
PlacementType = placementType,
|
||||
StartDate = startDate,
|
||||
EndDate = endDate,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
};
|
||||
|
||||
var result = await _mediator.Send(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get auction statistics (win rates, average eCPM, etc.).
|
||||
/// VI: Lấy thống kê đấu giá (tỷ lệ thắng, eCPM trung bình, v.v.).
|
||||
/// </summary>
|
||||
[HttpGet("statistics")]
|
||||
[ProducesResponseType(typeof(AuctionStatisticsDto), StatusCodes.Status200OK)]
|
||||
public ActionResult<AuctionStatisticsDto> GetStatistics()
|
||||
{
|
||||
// TODO: Implement statistics query handler
|
||||
_logger.LogInformation("Auction statistics requested");
|
||||
|
||||
return Ok(new AuctionStatisticsDto
|
||||
{
|
||||
TotalAuctions = 0,
|
||||
AverageWinRate = 0,
|
||||
AverageeCPM = 0,
|
||||
TotalBidsPlaced = 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public record AuctionStatisticsDto
|
||||
{
|
||||
public int TotalAuctions { get; init; }
|
||||
public decimal AverageWinRate { get; init; }
|
||||
public decimal AverageeCPM { get; init; }
|
||||
public long TotalBidsPlaced { get; init; }
|
||||
}
|
||||
@@ -36,7 +36,9 @@ public class AdsServingServiceContext : DbContext, IUnitOfWork
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// Entity configurations will be added here
|
||||
// EN: Apply all entity configurations from current assembly
|
||||
// VI: Áp dụng tất cả entity configurations từ assembly hiện tại
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AdsServingServiceContext).Assembly);
|
||||
}
|
||||
|
||||
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using AdsServingService.Domain.AggregatesModel.AuctionAggregate;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace AdsServingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core entity configuration for Auction aggregate.
|
||||
/// VI: Cấu hình entity EF Core cho aggregate Auction.
|
||||
/// </summary>
|
||||
public class AuctionEntityTypeConfiguration : IEntityTypeConfiguration<Auction>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Auction> builder)
|
||||
{
|
||||
// Table mapping
|
||||
builder.ToTable("auctions");
|
||||
|
||||
// Primary key
|
||||
builder.HasKey(a => a.Id);
|
||||
builder.Property(a => a.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
// Properties
|
||||
builder.Property(a => a.UserId)
|
||||
.HasColumnName("user_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(a => a.PlacementType)
|
||||
.HasColumnName("placement_type")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime>("_auctionTime")
|
||||
.HasColumnName("auction_time")
|
||||
.IsRequired();
|
||||
|
||||
// Owned collection: Bids (stored as JSONB for performance)
|
||||
// EN: Store bids as JSONB for fast read/write in high-throughput scenarios
|
||||
// VI: Lưu bids dưới dạng JSONB để đọc/ghi nhanh trong các tình huống throughput cao
|
||||
// Note: EF Core 10 has issues with OwnsMany + ToJson, so we store as a simple property
|
||||
builder.Ignore(a => a.Bids);
|
||||
builder.Property<string>("_bidsJson")
|
||||
.HasColumnName("bids")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
// Owned value object: AuctionResult (nullable)
|
||||
builder.OwnsOne<AuctionResult>("_result", resultBuilder =>
|
||||
{
|
||||
resultBuilder.Property(r => r.WinningAdId)
|
||||
.HasColumnName("winning_ad_id");
|
||||
|
||||
resultBuilder.Property(r => r.WinningCampaignId)
|
||||
.HasColumnName("winning_campaign_id");
|
||||
|
||||
resultBuilder.Property(r => r.FinalPrice)
|
||||
.HasColumnName("final_price")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
resultBuilder.Property(r => r.WinningeCPM)
|
||||
.HasColumnName("winning_ecpm")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
});
|
||||
|
||||
// Indexes for query performance
|
||||
// EN: Index on user_id for user-specific queries
|
||||
// VI: Index trên user_id cho các truy vấn theo người dùng
|
||||
builder.HasIndex(a => a.UserId)
|
||||
.HasDatabaseName("ix_auctions_user_id");
|
||||
|
||||
// EN: Index on placement_type for placement-specific queries
|
||||
// VI: Index trên placement_type cho các truy vấn theo vị trí
|
||||
builder.HasIndex(a => a.PlacementType)
|
||||
.HasDatabaseName("ix_auctions_placement_type");
|
||||
|
||||
// EN: Index on auction_time for time-range queries
|
||||
// VI: Index trên auction_time cho các truy vấn theo khoảng thời gian
|
||||
builder.HasIndex("_auctionTime")
|
||||
.HasDatabaseName("ix_auctions_auction_time");
|
||||
|
||||
// Composite index for common queries
|
||||
builder.HasIndex(a => new { a.PlacementType, a.UserId })
|
||||
.HasDatabaseName("ix_auctions_placement_user");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using AdsServingService.Domain.AggregatesModel.PacingAggregate;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace AdsServingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core entity configuration for BudgetPacer aggregate.
|
||||
/// VI: Cấu hình entity EF Core cho aggregate BudgetPacer.
|
||||
/// </summary>
|
||||
public class BudgetPacerEntityTypeConfiguration : IEntityTypeConfiguration<BudgetPacer>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<BudgetPacer> builder)
|
||||
{
|
||||
// Table mapping
|
||||
builder.ToTable("budget_pacers");
|
||||
|
||||
// Primary key
|
||||
builder.HasKey(bp => bp.Id);
|
||||
builder.Property(bp => bp.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
// Properties
|
||||
builder.Property<Guid>("_campaignId")
|
||||
.HasColumnName("campaign_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<decimal>("_dailyBudget")
|
||||
.HasColumnName("daily_budget")
|
||||
.HasColumnType("decimal(18,4)")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<decimal>("_spentToday")
|
||||
.HasColumnName("spent_today")
|
||||
.HasColumnType("decimal(18,4)")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<PacingStrategy>("_strategy")
|
||||
.HasColumnName("strategy")
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime>("_lastUpdated")
|
||||
.HasColumnName("last_updated")
|
||||
.IsRequired();
|
||||
|
||||
// Indexes
|
||||
// EN: Unique index on campaign_id (one pacer per campaign)
|
||||
// VI: Index duy nhất trên campaign_id (một pacer cho mỗi campaign)
|
||||
builder.HasIndex("_campaignId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_budget_pacers_campaign_id");
|
||||
|
||||
// EN: Index on last_updated for finding stale pacers
|
||||
// VI: Index trên last_updated để tìm các pacer cũ
|
||||
builder.HasIndex("_lastUpdated")
|
||||
.HasDatabaseName("ix_budget_pacers_last_updated");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using AdsServingService.Domain.AggregatesModel.FrequencyAggregate;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace AdsServingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core entity configuration for FrequencyCap aggregate.
|
||||
/// VI: Cấu hình entity EF Core cho aggregate FrequencyCap.
|
||||
/// </summary>
|
||||
public class FrequencyCapEntityTypeConfiguration : IEntityTypeConfiguration<FrequencyCap>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<FrequencyCap> builder)
|
||||
{
|
||||
// Table mapping
|
||||
builder.ToTable("frequency_caps");
|
||||
|
||||
// Primary key
|
||||
builder.HasKey(fc => fc.Id);
|
||||
builder.Property(fc => fc.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
// Properties
|
||||
builder.Property<Guid>("_adId")
|
||||
.HasColumnName("ad_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<int>("_maxImpressionsPerUser")
|
||||
.HasColumnName("max_impressions_per_user")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<FrequencyWindow>("_window")
|
||||
.HasColumnName("window")
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20)
|
||||
.IsRequired();
|
||||
|
||||
// Indexes
|
||||
// EN: Index on ad_id for ad-specific frequency cap lookups
|
||||
// VI: Index trên ad_id để tra cứu frequency cap theo quảng cáo
|
||||
builder.HasIndex("_adId")
|
||||
.HasDatabaseName("ix_frequency_caps_ad_id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// EN: Appointment status enumeration.
|
||||
// VI: Enumeration trạng thái cuộc hẹn.
|
||||
|
||||
using BookingService.Domain.SeedWork;
|
||||
|
||||
namespace BookingService.Domain.AggregatesModel.AppointmentAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Appointment status enumeration - represents the lifecycle of an appointment.
|
||||
/// VI: Enumeration trạng thái cuộc hẹn - đại diện cho vòng đời của cuộc hẹn.
|
||||
/// </summary>
|
||||
public class AppointmentStatus : Enumeration
|
||||
{
|
||||
public static AppointmentStatus Pending = new(1, nameof(Pending));
|
||||
public static AppointmentStatus Confirmed = new(2, nameof(Confirmed));
|
||||
public static AppointmentStatus InProgress = new(3, nameof(InProgress));
|
||||
public static AppointmentStatus Completed = new(4, nameof(Completed));
|
||||
public static AppointmentStatus Cancelled = new(5, nameof(Cancelled));
|
||||
public static AppointmentStatus NoShow = new(6, nameof(NoShow));
|
||||
|
||||
public AppointmentStatus(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using BookingService.Domain.AggregatesModel.AppointmentAggregate;
|
||||
using BookingService.Domain.AggregatesModel.ResourceAggregate;
|
||||
using BookingService.Domain.AggregatesModel.StaffAggregate;
|
||||
using BookingService.Domain.SeedWork;
|
||||
using BookingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
@@ -13,6 +15,8 @@ public class BookingContext : DbContext, IUnitOfWork
|
||||
private IDbContextTransaction? _currentTransaction;
|
||||
|
||||
public DbSet<Appointment> Appointments => Set<Appointment>();
|
||||
public DbSet<Resource> Resources => Set<Resource>();
|
||||
public DbSet<StaffSchedule> StaffSchedules => Set<StaffSchedule>();
|
||||
public IDbContextTransaction? CurrentTransaction => _currentTransaction;
|
||||
public bool HasActiveTransaction => _currentTransaction != null;
|
||||
|
||||
@@ -25,6 +29,8 @@ public class BookingContext : DbContext, IUnitOfWork
|
||||
{
|
||||
modelBuilder.ApplyConfiguration(new AppointmentEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new AppointmentStatusEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new ResourceEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new StaffScheduleEntityTypeConfiguration());
|
||||
}
|
||||
|
||||
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -22,7 +22,7 @@ public static class DependencyInjection
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL
|
||||
services.AddDbContext<BookingServiceContext>(options =>
|
||||
services.AddDbContext<BookingContext>(options =>
|
||||
{
|
||||
var connectionString = configuration.GetConnectionString("DefaultConnection")
|
||||
?? configuration["DATABASE_URL"]
|
||||
@@ -30,7 +30,7 @@ public static class DependencyInjection
|
||||
|
||||
options.UseNpgsql(connectionString, npgsqlOptions =>
|
||||
{
|
||||
npgsqlOptions.MigrationsAssembly(typeof(BookingServiceContext).Assembly.FullName);
|
||||
npgsqlOptions.MigrationsAssembly(typeof(BookingContext).Assembly.FullName);
|
||||
npgsqlOptions.EnableRetryOnFailure(
|
||||
maxRetryCount: 5,
|
||||
maxRetryDelay: TimeSpan.FromSeconds(30),
|
||||
@@ -47,7 +47,7 @@ public static class DependencyInjection
|
||||
});
|
||||
|
||||
// EN: Register repositories / VI: Đăng ký repositories
|
||||
services.AddScoped<ISampleRepository, SampleRepository>();
|
||||
services.AddScoped<IAppointmentRepository, AppointmentRepository>();
|
||||
|
||||
// EN: Register idempotency services / VI: Đăng ký idempotency services
|
||||
services.AddScoped<IRequestManager, RequestManager>();
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using BookingService.Domain.AggregatesModel.AppointmentAggregate;
|
||||
|
||||
namespace BookingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
public class AppointmentEntityTypeConfiguration : IEntityTypeConfiguration<Appointment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Appointment> builder)
|
||||
{
|
||||
builder.ToTable("appointments");
|
||||
builder.HasKey(a => a.Id);
|
||||
builder.Property(a => a.Id).HasColumnName("id").ValueGeneratedNever();
|
||||
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
|
||||
builder.Property<Guid>("_customerId").HasColumnName("customer_id").IsRequired();
|
||||
builder.Property<Guid>("_serviceId").HasColumnName("service_id").IsRequired();
|
||||
builder.Property(a => a.StatusId).HasColumnName("status_id").IsRequired();
|
||||
builder.Property<DateTime>("_scheduledAt").HasColumnName("scheduled_at").IsRequired();
|
||||
builder.Property<int>("_durationMinutes").HasColumnName("duration_minutes").IsRequired();
|
||||
builder.Property<Guid?>("_staffId").HasColumnName("staff_id");
|
||||
builder.Property<Guid?>("_resourceId").HasColumnName("resource_id");
|
||||
builder.Property<string?>("_notes").HasColumnName("notes").HasMaxLength(1000);
|
||||
|
||||
builder.HasIndex("_shopId").HasDatabaseName("ix_appointments_shop_id");
|
||||
builder.HasIndex("_customerId").HasDatabaseName("ix_appointments_customer_id");
|
||||
builder.HasIndex("_scheduledAt").HasDatabaseName("ix_appointments_scheduled_at");
|
||||
|
||||
builder.Ignore(a => a.ShopId);
|
||||
builder.Ignore(a => a.CustomerId);
|
||||
builder.Ignore(a => a.ServiceId);
|
||||
builder.Ignore(a => a.Status);
|
||||
builder.Ignore(a => a.ScheduledAt);
|
||||
builder.Ignore(a => a.DurationMinutes);
|
||||
builder.Ignore(a => a.StaffId);
|
||||
builder.Ignore(a => a.ResourceId);
|
||||
builder.Ignore(a => a.Notes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// EN: Entity type configuration for Resource.
|
||||
// VI: Cấu hình entity type cho Resource.
|
||||
|
||||
using BookingService.Domain.AggregatesModel.ResourceAggregate;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace BookingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity type configuration for Resource aggregate.
|
||||
/// VI: Cấu hình entity type cho aggregate Resource.
|
||||
/// </summary>
|
||||
public class ResourceEntityTypeConfiguration : IEntityTypeConfiguration<Resource>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Resource> builder)
|
||||
{
|
||||
builder.ToTable("resources");
|
||||
|
||||
builder.HasKey(r => r.Id);
|
||||
builder.Property(r => r.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder.Property<Guid>("_shopId")
|
||||
.HasColumnName("shop_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_name")
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(200)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_resourceType")
|
||||
.HasColumnName("resource_type")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<int>("_capacity")
|
||||
.HasColumnName("capacity")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<bool>("_isActive")
|
||||
.HasColumnName("is_active")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Indexes / VI: Indexes
|
||||
builder.HasIndex("_shopId")
|
||||
.HasDatabaseName("ix_resources_shop_id");
|
||||
|
||||
// EN: Ignore public properties (mapped via backing fields)
|
||||
// VI: Bỏ qua các properties công khai (đã map qua backing fields)
|
||||
builder.Ignore(r => r.ShopId);
|
||||
builder.Ignore(r => r.Name);
|
||||
builder.Ignore(r => r.ResourceType);
|
||||
builder.Ignore(r => r.Capacity);
|
||||
builder.Ignore(r => r.IsActive);
|
||||
builder.Ignore(r => r.CreatedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// EN: Entity type configuration for StaffSchedule.
|
||||
// VI: Cấu hình entity type cho StaffSchedule.
|
||||
|
||||
using BookingService.Domain.AggregatesModel.StaffAggregate;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace BookingService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity type configuration for StaffSchedule entity.
|
||||
/// VI: Cấu hình entity type cho entity StaffSchedule.
|
||||
/// </summary>
|
||||
public class StaffScheduleEntityTypeConfiguration : IEntityTypeConfiguration<StaffSchedule>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<StaffSchedule> builder)
|
||||
{
|
||||
builder.ToTable("staff_schedules");
|
||||
|
||||
builder.HasKey(s => s.Id);
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder.Property<Guid>("_staffId")
|
||||
.HasColumnName("staff_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<Guid>("_shopId")
|
||||
.HasColumnName("shop_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<int>("_dayOfWeek")
|
||||
.HasColumnName("day_of_week")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<TimeOnly>("_startTime")
|
||||
.HasColumnName("start_time")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<TimeOnly>("_endTime")
|
||||
.HasColumnName("end_time")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Indexes / VI: Indexes
|
||||
builder.HasIndex("_staffId", "_dayOfWeek")
|
||||
.HasDatabaseName("ix_staff_schedules_staff_day");
|
||||
|
||||
builder.HasIndex("_shopId")
|
||||
.HasDatabaseName("ix_staff_schedules_shop_id");
|
||||
|
||||
// EN: Ignore public properties (mapped via backing fields)
|
||||
// VI: Bỏ qua các properties công khai (đã map qua backing fields)
|
||||
builder.Ignore(s => s.StaffId);
|
||||
builder.Ignore(s => s.ShopId);
|
||||
builder.Ignore(s => s.DayOfWeek);
|
||||
builder.Ignore(s => s.StartTime);
|
||||
builder.Ignore(s => s.EndTime);
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@ namespace BookingService.Infrastructure.Idempotency;
|
||||
/// </summary>
|
||||
public class RequestManager : IRequestManager
|
||||
{
|
||||
private readonly BookingServiceContext _context;
|
||||
private readonly BookingContext _context;
|
||||
|
||||
public RequestManager(BookingServiceContext context)
|
||||
public RequestManager(BookingContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// EN: Repository implementation for Appointment aggregate.
|
||||
// VI: Implementation repository cho aggregate Appointment.
|
||||
|
||||
using BookingService.Domain.AggregatesModel.AppointmentAggregate;
|
||||
using BookingService.Domain.SeedWork;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace BookingService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for managing Appointment aggregate persistence.
|
||||
/// VI: Implementation repository để quản lý persistence của aggregate Appointment.
|
||||
/// </summary>
|
||||
public class AppointmentRepository : IAppointmentRepository
|
||||
{
|
||||
private readonly BookingContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public AppointmentRepository(BookingContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public Appointment Add(Appointment appointment)
|
||||
{
|
||||
return _context.Appointments.Add(appointment).Entity;
|
||||
}
|
||||
|
||||
public void Update(Appointment appointment)
|
||||
{
|
||||
_context.Entry(appointment).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public async Task<Appointment?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Appointments
|
||||
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Appointment>> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Appointments
|
||||
.Where(a => a.ShopId == shopId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// EN: Repository interface for Appointment aggregate.
|
||||
// VI: Interface repository cho aggregate Appointment.
|
||||
|
||||
using BookingService.Domain.AggregatesModel.AppointmentAggregate;
|
||||
using BookingService.Domain.SeedWork;
|
||||
|
||||
namespace BookingService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for managing Appointment aggregate persistence.
|
||||
/// VI: Interface repository để quản lý persistence của aggregate Appointment.
|
||||
/// </summary>
|
||||
public interface IAppointmentRepository : IRepository<Appointment>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Add a new appointment.
|
||||
/// VI: Thêm cuộc hẹn mới.
|
||||
/// </summary>
|
||||
Appointment Add(Appointment appointment);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing appointment.
|
||||
/// VI: Cập nhật cuộc hẹn hiện có.
|
||||
/// </summary>
|
||||
void Update(Appointment appointment);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get appointment by ID.
|
||||
/// VI: Lấy cuộc hẹn theo ID.
|
||||
/// </summary>
|
||||
Task<Appointment?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get appointments by shop ID.
|
||||
/// VI: Lấy danh sách cuộc hẹn theo shop ID.
|
||||
/// </summary>
|
||||
Task<List<Appointment>> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// EN: Command to create a new category.
|
||||
// VI: Command để tạo danh mục mới.
|
||||
|
||||
using MediatR;
|
||||
|
||||
namespace CatalogService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a new category.
|
||||
/// VI: Command để tạo danh mục mới.
|
||||
/// </summary>
|
||||
public record CreateCategoryCommand : IRequest<Guid>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Shop ID that will own this category.
|
||||
/// VI: ID shop sẽ sở hữu danh mục.
|
||||
/// </summary>
|
||||
public Guid ShopId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Category name.
|
||||
/// VI: Tên danh mục.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Category description.
|
||||
/// VI: Mô tả danh mục.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parent category ID for hierarchical categories.
|
||||
/// VI: ID danh mục cha cho danh mục phân cấp.
|
||||
/// </summary>
|
||||
public Guid? ParentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Display order.
|
||||
/// VI: Thứ tự hiển thị.
|
||||
/// </summary>
|
||||
public int DisplayOrder { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// EN: Handler for CreateCategoryCommand.
|
||||
// VI: Handler cho CreateCategoryCommand.
|
||||
|
||||
using MediatR;
|
||||
using CatalogService.Domain.AggregatesModel.ProductAggregate;
|
||||
using CatalogService.Infrastructure;
|
||||
|
||||
namespace CatalogService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for creating a new category.
|
||||
/// VI: Handler để tạo danh mục mới.
|
||||
/// </summary>
|
||||
public class CreateCategoryCommandHandler : IRequestHandler<CreateCategoryCommand, Guid>
|
||||
{
|
||||
private readonly CatalogContext _context;
|
||||
|
||||
public CreateCategoryCommandHandler(CatalogContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<Guid> Handle(CreateCategoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// EN: Create category entity
|
||||
// VI: Tạo category entity
|
||||
var category = new Category(
|
||||
shopId: request.ShopId,
|
||||
name: request.Name,
|
||||
description: request.Description,
|
||||
parentId: request.ParentId,
|
||||
displayOrder: request.DisplayOrder);
|
||||
|
||||
// EN: Add to context and save
|
||||
// VI: Thêm vào context và lư
|
||||
_context.Categories.Add(category);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return category.Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// EN: Command to create a new product.
|
||||
// VI: Command để tạo sản phẩm mới.
|
||||
|
||||
using MediatR;
|
||||
|
||||
namespace CatalogService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a new product.
|
||||
/// VI: Command để tạo sản phẩm mới.
|
||||
/// </summary>
|
||||
public record CreateProductCommand : IRequest<Guid>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Shop ID that will own this product.
|
||||
/// VI: ID shop sẽ sở hữu sản phẩm.
|
||||
/// </summary>
|
||||
public Guid ShopId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product name.
|
||||
/// VI: Tên sản phẩm.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product description.
|
||||
/// VI: Mô tả sản phẩm.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product price.
|
||||
/// VI: Giá sản phẩm.
|
||||
/// </summary>
|
||||
public decimal Price { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product type (Physical, Service, PreparedFood).
|
||||
/// VI: Loại sản phẩm (Physical, Service, PreparedFood).
|
||||
/// </summary>
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Type-specific attributes as JSON.
|
||||
/// VI: Thuộc tính theo loại dưới dạng JSON.
|
||||
/// </summary>
|
||||
public Dictionary<string, object>? Attributes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Stock Keeping Unit.
|
||||
/// VI: Mã SKU.
|
||||
/// </summary>
|
||||
public string? Sku { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product image URL.
|
||||
/// VI: URL hình ảnh sản phẩm.
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// EN: Handler for CreateProductCommand.
|
||||
// VI: Handler cho CreateProductCommand.
|
||||
|
||||
using System.Text.Json;
|
||||
using MediatR;
|
||||
using CatalogService.Domain.AggregatesModel.ProductAggregate;
|
||||
|
||||
namespace CatalogService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for creating a new product.
|
||||
/// VI: Handler để tạo sản phẩm mới.
|
||||
/// </summary>
|
||||
public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, Guid>
|
||||
{
|
||||
private readonly IProductRepository _productRepository;
|
||||
|
||||
public CreateProductCommandHandler(IProductRepository productRepository)
|
||||
{
|
||||
_productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository));
|
||||
}
|
||||
|
||||
public async Task<Guid> Handle(CreateProductCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// EN: Get ProductType enumeration
|
||||
// VI: Lấy ProductType enumeration
|
||||
var productType = ProductType.FromName(request.Type);
|
||||
|
||||
// EN: Convert attributes dictionary to JsonDocument
|
||||
// VI: Chuyển attributes dictionary sang JsonDocument
|
||||
JsonDocument? attributesJson = null;
|
||||
if (request.Attributes != null)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(request.Attributes);
|
||||
attributesJson = JsonDocument.Parse(json);
|
||||
}
|
||||
|
||||
// EN: Create product aggregate
|
||||
// VI: Tạo product aggregate
|
||||
var product = new Product(
|
||||
shopId: request.ShopId,
|
||||
name: request.Name,
|
||||
price: request.Price,
|
||||
type: productType,
|
||||
description: request.Description,
|
||||
attributes: attributesJson,
|
||||
sku: request.Sku);
|
||||
|
||||
// EN: Update image if provided
|
||||
// VI: Cập nhật image nếu được cung cấp
|
||||
if (!string.IsNullOrWhiteSpace(request.ImageUrl))
|
||||
{
|
||||
product.UpdateImage(request.ImageUrl);
|
||||
}
|
||||
|
||||
// EN: Add to repository and save
|
||||
// VI: Thêm vào repository và lưu
|
||||
_productRepository.Add(product);
|
||||
await _productRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
return product.Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// EN: Command to delete a product.
|
||||
// VI: Command để xóa sản phẩm.
|
||||
|
||||
using MediatR;
|
||||
|
||||
namespace CatalogService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to delete a product.
|
||||
/// VI: Command để xóa sản phẩm.
|
||||
/// </summary>
|
||||
public record DeleteProductCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Product ID to delete.
|
||||
/// VI: ID sản phẩm cần xóa.
|
||||
/// </summary>
|
||||
public Guid ProductId { get; init; }
|
||||
|
||||
public DeleteProductCommand(Guid productId)
|
||||
{
|
||||
ProductId = productId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// EN: Handler for DeleteProductCommand.
|
||||
// VI: Handler cho DeleteProductCommand.
|
||||
|
||||
using MediatR;
|
||||
using CatalogService.Domain.AggregatesModel.ProductAggregate;
|
||||
using CatalogService.Domain.Exceptions;
|
||||
|
||||
namespace CatalogService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for deleting a product.
|
||||
/// VI: Handler xóa sản phẩm.
|
||||
/// </summary>
|
||||
public class DeleteProductCommandHandler : IRequestHandler<DeleteProductCommand, bool>
|
||||
{
|
||||
private readonly IProductRepository _productRepository;
|
||||
|
||||
public DeleteProductCommandHandler(IProductRepository productRepository)
|
||||
{
|
||||
_productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(DeleteProductCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// EN: Retrieve product aggregate
|
||||
// VI: Lấy product aggregate
|
||||
var product = await _productRepository.GetByIdAsync(request.ProductId, cancellationToken);
|
||||
|
||||
if (product == null)
|
||||
{
|
||||
throw new DomainException($"Product with ID {request.ProductId} not found");
|
||||
}
|
||||
|
||||
// EN: Deactivate product instead of hard delete
|
||||
// VI: Vô hiệu hóa sản phẩm thay vì xóa cứng
|
||||
product.Deactivate();
|
||||
|
||||
// EN: Update repository and save
|
||||
// VI: Cập nhật repository và lưu
|
||||
_productRepository.Update(product);
|
||||
await _productRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// EN: Command to update an existing product.
|
||||
// VI: Command để cập nhật sản phẩm hiện tại.
|
||||
|
||||
using MediatR;
|
||||
|
||||
namespace CatalogService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to update product information.
|
||||
/// VI: Command để cập nhật thông tin sản phẩm.
|
||||
/// </summary>
|
||||
public record UpdateProductCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Product ID to update.
|
||||
/// VI: ID sản phẩm cần cập nhật.
|
||||
/// </summary>
|
||||
public Guid ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product name.
|
||||
/// VI: Tên sản phẩm.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product description.
|
||||
/// VI: Mô tả sản phẩm.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product price.
|
||||
/// VI: Giá sản phẩm.
|
||||
/// </summary>
|
||||
public decimal Price { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Type-specific attributes as JSON.
|
||||
/// VI: Thuộc tính theo loại dưới dạng JSON.
|
||||
/// </summary>
|
||||
public Dictionary<string, object>? Attributes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product image URL.
|
||||
/// VI: URL hình ảnh sản phẩm.
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// EN: Handler for UpdateProductCommand.
|
||||
// VI: Handler cho UpdateProductCommand.
|
||||
|
||||
using System.Text.Json;
|
||||
using MediatR;
|
||||
using CatalogService.Domain.AggregatesModel.ProductAggregate;
|
||||
using CatalogService.Domain.Exceptions;
|
||||
|
||||
namespace CatalogService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for updating a product.
|
||||
/// VI: Handler cập nhật sản phẩm.
|
||||
/// </summary>
|
||||
public class UpdateProductCommandHandler : IRequestHandler<UpdateProductCommand, bool>
|
||||
{
|
||||
private readonly IProductRepository _productRepository;
|
||||
|
||||
public UpdateProductCommandHandler(IProductRepository productRepository)
|
||||
{
|
||||
_productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(UpdateProductCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// EN: Retrieve product aggregate
|
||||
// VI: Lấy product aggregate
|
||||
var product = await _productRepository.GetByIdAsync(request.ProductId, cancellationToken);
|
||||
|
||||
if (product == null)
|
||||
{
|
||||
throw new DomainException($"Product with ID {request.ProductId} not found");
|
||||
}
|
||||
|
||||
// EN: Update basic information
|
||||
// VI: Cập nhật thông tin cơ bản
|
||||
product.UpdateInfo(request.Name, request.Description, request.Price);
|
||||
|
||||
// EN: Update attributes if provided
|
||||
// VI: Cập nhật attributes nếu được cung cấp
|
||||
if (request.Attributes != null)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(request.Attributes);
|
||||
var attributesJson = JsonDocument.Parse(json);
|
||||
product.UpdateAttributes(attributesJson);
|
||||
}
|
||||
|
||||
// EN: Update image if provided
|
||||
// VI: Cập nhật image nếu được cung cấp
|
||||
if (request.ImageUrl != null)
|
||||
{
|
||||
product.UpdateImage(request.ImageUrl);
|
||||
}
|
||||
|
||||
// EN: Update repository and save
|
||||
// VI: Cập nhật repository và lưu
|
||||
_productRepository.Update(product);
|
||||
await _productRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// EN: Category Data Transfer Object for read operations.
|
||||
// VI: DTO Category cho các thao tác đọc.
|
||||
|
||||
namespace CatalogService.API.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Category DTO for API responses.
|
||||
/// VI: DTO Category cho API response.
|
||||
/// </summary>
|
||||
public record CategoryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Category ID.
|
||||
/// VI: ID danh mục.
|
||||
/// </summary>
|
||||
public Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shop ID that owns this category.
|
||||
/// VI: ID shop sở hữu danh mục.
|
||||
/// </summary>
|
||||
public Guid ShopId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Category name.
|
||||
/// VI: Tên danh mục.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Category description.
|
||||
/// VI: Mô tả danh mục.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parent category ID for hierarchical categories.
|
||||
/// VI: ID danh mục cha cho danh mục phân cấp.
|
||||
/// </summary>
|
||||
public Guid? ParentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Display order.
|
||||
/// VI: Thứ tự hiển thị.
|
||||
/// </summary>
|
||||
public int DisplayOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Is category active.
|
||||
/// VI: Danh mục có đang hoạt động không.
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creation timestamp.
|
||||
/// VI: Thời gian tạo.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Last update timestamp.
|
||||
/// VI: Thời gian cập nhật cuối.
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// EN: Paged result wrapper for query responses.
|
||||
// VI: Wrapper kết quả phân trang cho query response.
|
||||
|
||||
namespace CatalogService.API.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Generic paged result for list queries.
|
||||
/// VI: Kết quả phân trang chung cho các query danh sách.
|
||||
/// </summary>
|
||||
public record PagedResult<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Items in the current page.
|
||||
/// VI: Các items trong trang hiện tại.
|
||||
/// </summary>
|
||||
public IReadOnlyList<T> Items { get; init; } = Array.Empty<T>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Total count of items across all pages.
|
||||
/// VI: Tổng số items trên tất cả các trang.
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current page number (1-indexed).
|
||||
/// VI: Số trang hiện tại (bắt đầu từ 1).
|
||||
/// </summary>
|
||||
public int Page { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Page size.
|
||||
/// VI: Kích thước trang.
|
||||
/// </summary>
|
||||
public int PageSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Total number of pages.
|
||||
/// VI: Tổng số trang.
|
||||
/// </summary>
|
||||
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Has previous page.
|
||||
/// VI: Có trang trước không.
|
||||
/// </summary>
|
||||
public bool HasPrevious => Page > 1;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Has next page.
|
||||
/// VI: Có trang tiếp theo không.
|
||||
/// </summary>
|
||||
public bool HasNext => Page < TotalPages;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// EN: Product Data Transfer Object for read operations.
|
||||
// VI: DTO Product cho các thao tác đọc.
|
||||
|
||||
namespace CatalogService.API.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product DTO for API responses.
|
||||
/// VI: DTO Product cho API response.
|
||||
/// </summary>
|
||||
public record ProductDto
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Product ID.
|
||||
/// VI: ID sản phẩm.
|
||||
/// </summary>
|
||||
public Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shop ID that owns this product.
|
||||
/// VI: ID shop sở hữu sản phẩm.
|
||||
/// </summary>
|
||||
public Guid ShopId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product name.
|
||||
/// VI: Tên sản phẩm.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product description.
|
||||
/// VI: Mô tả sản phẩm.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product price.
|
||||
/// VI: Giá sản phẩm.
|
||||
/// </summary>
|
||||
public decimal Price { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product type (Physical, Service, PreparedFood).
|
||||
/// VI: Loại sản phẩm (Physical, Service, PreparedFood).
|
||||
/// </summary>
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Type-specific attributes as dictionary.
|
||||
/// VI: Thuộc tính theo loại dưới dạng dictionary.
|
||||
/// </summary>
|
||||
public Dictionary<string, object>? Attributes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product image URL.
|
||||
/// VI: URL hình ảnh sản phẩm.
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Stock Keeping Unit.
|
||||
/// VI: Mã SKU.
|
||||
/// </summary>
|
||||
public string? Sku { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Is product active.
|
||||
/// VI: Sản phẩm có đang hoạt động không.
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creation timestamp.
|
||||
/// VI: Thời gian tạo.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Last update timestamp.
|
||||
/// VI: Thời gian cập nhật cuối.
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// EN: Query to get a list of categories.
|
||||
// VI: Query để lấy danh sách danh mục.
|
||||
|
||||
using MediatR;
|
||||
using CatalogService.API.Application.DTOs;
|
||||
|
||||
namespace CatalogService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get categories by shop with hierarchical support.
|
||||
/// VI: Query để lấy danh mục theo shop có hỗ trợ phân cấp.
|
||||
/// </summary>
|
||||
public record GetCategoriesQuery : IRequest<List<CategoryDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Shop ID to filter categories.
|
||||
/// VI: ID shop để lọc danh mục.
|
||||
/// </summary>
|
||||
public Guid ShopId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Filter by parent ID (null = root categories).
|
||||
/// VI: Lọc theo ID cha (null = danh mục gốc).
|
||||
/// </summary>
|
||||
public Guid? ParentId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// EN: Handler for GetCategoriesQuery.
|
||||
// VI: Handler cho GetCategoriesQuery.
|
||||
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using CatalogService.API.Application.DTOs;
|
||||
using CatalogService.Infrastructure;
|
||||
|
||||
namespace CatalogService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for querying categories with hierarchical support.
|
||||
/// VI: Handler query danh mục có hỗ trợ phân cấp.
|
||||
/// </summary>
|
||||
public class GetCategoriesQueryHandler : IRequestHandler<GetCategoriesQuery, List<CategoryDto>>
|
||||
{
|
||||
private readonly CatalogContext _context;
|
||||
|
||||
public GetCategoriesQueryHandler(CatalogContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<List<CategoryDto>> Handle(GetCategoriesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.Categories
|
||||
.Where(c => c.ShopId == request.ShopId);
|
||||
|
||||
// EN: Filter by parent ID if specified
|
||||
// VI: Lọc theo ID cha nếu được chỉ định
|
||||
if (request.ParentId.HasValue)
|
||||
{
|
||||
query = query.Where(c => c.ParentId == request.ParentId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
// EN: Return only root categories if no parent specified
|
||||
// VI: Trả về chỉ danh mục gốc nếu không chỉ định cha
|
||||
query = query.Where(c => c.ParentId == null);
|
||||
}
|
||||
|
||||
var categories = await query
|
||||
.OrderBy(c => c.DisplayOrder)
|
||||
.ThenBy(c => c.Name)
|
||||
.Select(c => new CategoryDto
|
||||
{
|
||||
Id = c.Id,
|
||||
ShopId = c.ShopId,
|
||||
Name = c.Name,
|
||||
Description = c.Description,
|
||||
ParentId = c.ParentId,
|
||||
DisplayOrder = c.DisplayOrder,
|
||||
IsActive = c.IsActive,
|
||||
CreatedAt = c.CreatedAt,
|
||||
UpdatedAt = c.UpdatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return categories;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// EN: Query to get a single product by ID.
|
||||
// VI: Query để lấy một sản phẩm theo ID.
|
||||
|
||||
using MediatR;
|
||||
using CatalogService.API.Application.DTOs;
|
||||
|
||||
namespace CatalogService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get a product by ID.
|
||||
/// VI: Query để lấy sản phẩm theo ID.
|
||||
/// </summary>
|
||||
public record GetProductByIdQuery : IRequest<ProductDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Product ID.
|
||||
/// VI: ID sản phẩm.
|
||||
/// </summary>
|
||||
public Guid ProductId { get; init; }
|
||||
|
||||
public GetProductByIdQuery(Guid productId)
|
||||
{
|
||||
ProductId = productId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// EN: Handler for GetProductByIdQuery.
|
||||
// VI: Handler cho GetProductByIdQuery.
|
||||
|
||||
using System.Text.Json;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using CatalogService.API.Application.DTOs;
|
||||
using CatalogService.Infrastructure;
|
||||
|
||||
namespace CatalogService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for querying a single product by ID.
|
||||
/// VI: Handler query một sản phẩm theo ID.
|
||||
/// </summary>
|
||||
public class GetProductByIdQueryHandler : IRequestHandler<GetProductByIdQuery, ProductDto?>
|
||||
{
|
||||
private readonly CatalogContext _context;
|
||||
|
||||
public GetProductByIdQueryHandler(CatalogContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<ProductDto?> Handle(GetProductByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var product = await _context.Products
|
||||
.Where(p => p.Id == request.ProductId)
|
||||
.Select(p => new ProductDto
|
||||
{
|
||||
Id = p.Id,
|
||||
ShopId = p.ShopId,
|
||||
Name = p.Name,
|
||||
Description = p.Description,
|
||||
Price = p.Price,
|
||||
Type = p.Type.Name,
|
||||
Attributes = p.Attributes != null
|
||||
? JsonSerializer.Deserialize<Dictionary<string, object>>(p.Attributes.RootElement.GetRawText())
|
||||
: null,
|
||||
ImageUrl = p.ImageUrl,
|
||||
Sku = p.Sku,
|
||||
IsActive = p.IsActive,
|
||||
CreatedAt = p.CreatedAt,
|
||||
UpdatedAt = p.UpdatedAt
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return product;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// EN: Query to get a list of products.
|
||||
// VI: Query để lấy danh sách sản phẩm.
|
||||
|
||||
using MediatR;
|
||||
using CatalogService.API.Application.DTOs;
|
||||
|
||||
namespace CatalogService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get products by shop with filtering and pagination.
|
||||
/// VI: Query để lấy sản phẩm theo shop có lọc và phân trang.
|
||||
/// </summary>
|
||||
public record GetProductsQuery : IRequest<PagedResult<ProductDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Shop ID to filter products.
|
||||
/// VI: ID shop để lọc sản phẩm.
|
||||
/// </summary>
|
||||
public Guid ShopId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Filter by active status (null = all).
|
||||
/// VI: Lọc theo trạng thái hoạt động (null = tất cả).
|
||||
/// </summary>
|
||||
public bool? IsActive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Filter by product type (null = all).
|
||||
/// VI: Lọc theo loại sản phẩm (null = tất cả).
|
||||
/// </summary>
|
||||
public string? Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Page number (1-indexed).
|
||||
/// VI: Số trang (bắt đầu từ 1).
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Page size.
|
||||
/// VI: Kích thước trang.
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// EN: Handler for GetProductsQuery.
|
||||
// VI: Handler cho GetProductsQuery.
|
||||
|
||||
using System.Text.Json;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using CatalogService.API.Application.DTOs;
|
||||
using CatalogService.Infrastructure;
|
||||
|
||||
namespace CatalogService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for querying products with filtering and pagination.
|
||||
/// VI: Handler query sản phẩm có lọc và phân trang.
|
||||
/// </summary>
|
||||
public class GetProductsQueryHandler : IRequestHandler<GetProductsQuery, PagedResult<ProductDto>>
|
||||
{
|
||||
private readonly CatalogContext _context;
|
||||
|
||||
public GetProductsQueryHandler(CatalogContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<PagedResult<ProductDto>> Handle(GetProductsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.Products
|
||||
.Where(p => p.ShopId == request.ShopId);
|
||||
|
||||
// EN: Apply filters
|
||||
// VI: Áp dụng bộ lọc
|
||||
if (request.IsActive.HasValue)
|
||||
{
|
||||
query = query.Where(p => p.IsActive == request.IsActive.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Type))
|
||||
{
|
||||
query = query.Where(p => p.Type.Name == request.Type);
|
||||
}
|
||||
|
||||
// EN: Get total count
|
||||
// VI: Lấy tổng số
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
// EN: Apply pagination
|
||||
// VI: Áp dụng phân trang
|
||||
var products = await query
|
||||
.OrderBy(p => p.Name)
|
||||
.Skip((request.Page - 1) * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.Select(p => new ProductDto
|
||||
{
|
||||
Id = p.Id,
|
||||
ShopId = p.ShopId,
|
||||
Name = p.Name,
|
||||
Description = p.Description,
|
||||
Price = p.Price,
|
||||
Type = p.Type.Name,
|
||||
Attributes = p.Attributes != null
|
||||
? JsonSerializer.Deserialize<Dictionary<string, object>>(p.Attributes.RootElement.GetRawText())
|
||||
: null,
|
||||
ImageUrl = p.ImageUrl,
|
||||
Sku = p.Sku,
|
||||
IsActive = p.IsActive,
|
||||
CreatedAt = p.CreatedAt,
|
||||
UpdatedAt = p.UpdatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new PagedResult<ProductDto>
|
||||
{
|
||||
Items = products,
|
||||
TotalCount = totalCount,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using InventoryService.Domain.AggregatesModel.SampleAggregate;
|
||||
using InventoryService.Domain.Exceptions;
|
||||
using Xunit;
|
||||
|
||||
namespace InventoryService.UnitTests.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit tests for Sample aggregate.
|
||||
/// VI: Unit tests cho Sample aggregate.
|
||||
/// </summary>
|
||||
public class SampleAggregateTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateSample_WithValidName_ShouldCreateWithDraftStatus()
|
||||
{
|
||||
// Arrange
|
||||
var name = "Test Sample";
|
||||
var description = "Test Description";
|
||||
|
||||
// Act
|
||||
var sample = new Sample(name, description);
|
||||
|
||||
// Assert
|
||||
sample.Name.Should().Be(name);
|
||||
sample.Description.Should().Be(description);
|
||||
sample.Status.Should().Be(SampleStatus.Draft);
|
||||
sample.Id.Should().NotBeEmpty();
|
||||
sample.DomainEvents.Should().ContainSingle(); // SampleCreatedDomainEvent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSample_WithEmptyName_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var name = "";
|
||||
|
||||
// Act
|
||||
var act = () => new Sample(name);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Sample name cannot be empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activate_WhenDraft_ShouldChangeToActive()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.ClearDomainEvents();
|
||||
|
||||
// Act
|
||||
sample.Activate();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Active);
|
||||
sample.DomainEvents.Should().ContainSingle(); // SampleStatusChangedDomainEvent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activate_WhenNotDraft_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Activate();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Only draft samples can be activated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_WhenActive_ShouldChangeToCompleted()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
sample.ClearDomainEvents();
|
||||
|
||||
// Act
|
||||
sample.Complete();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_WhenDraftOrActive_ShouldChangeToCancelled()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
|
||||
// Act
|
||||
sample.Cancel();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_WhenCompleted_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
sample.Complete();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Cancel();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Cannot cancel a completed sample");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WhenNotCancelled_ShouldUpdateNameAndDescription()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Original Name", "Original Description");
|
||||
var newName = "Updated Name";
|
||||
var newDescription = "Updated Description";
|
||||
|
||||
// Act
|
||||
sample.Update(newName, newDescription);
|
||||
|
||||
// Assert
|
||||
sample.Name.Should().Be(newName);
|
||||
sample.Description.Should().Be(newDescription);
|
||||
sample.UpdatedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WhenCancelled_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Cancel();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Update("New Name", null);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Cannot update a cancelled sample");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user