diff --git a/services/ads-manager-service-net/Dockerfile b/services/ads-manager-service-net/Dockerfile index c7569e86..b228ddaa 100644 --- a/services/ads-manager-service-net/Dockerfile +++ b/services/ads-manager-service-net/Dockerfile @@ -20,11 +20,11 @@ COPY src/ ./src/ # EN: Build the application # VI: Build ứng dụng WORKDIR "/src/src/AdsManagerService.API" -RUN dotnet build "AdsManagerService.API.csproj" -c Release -o /app/build --no-restore +RUN dotnet build "AdsManagerService.API.csproj" -c Release -o /app/build # Publish stage / Giai đoạn publish FROM build AS publish -RUN dotnet publish "AdsManagerService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore +RUN dotnet publish "AdsManagerService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false # Runtime stage / Giai đoạn runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final diff --git a/services/ads-serving-service-net/docs/en/README.md b/services/ads-serving-service-net/docs/en/README.md index a8275f9d..50de2526 100644 --- a/services/ads-serving-service-net/docs/en/README.md +++ b/services/ads-serving-service-net/docs/en/README.md @@ -91,3 +91,76 @@ eCPM = (Bid × Predicted CTR × 1000) + Quality Score ## License Proprietary - GoodGo Platform + +## Admin Backoffice APIs + +### Auction Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/admin/auctions` | List auctions (paginated, with filters) | +| `GET` | `/api/v1/admin/auctions/statistics` | Auction statistics (win rate, eCPM, etc.) | + +### Budget Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/admin/budget/pacers` | List budget pacers | +| `GET` | `/api/v1/admin/budget/campaigns/{id}` | Get campaign budget status | +| `PUT` | `/api/v1/admin/budget/campaigns/{id}/reset` | Reset daily spend (manual) | +| `GET` | `/api/v1/admin/budget/statistics` | Overall budget statistics | + +### Frequency Cap Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/admin/frequency/caps` | List frequency caps | +| `GET` | `/api/v1/admin/frequency/caps/{id}` | Get frequency cap details | +| `POST` | `/api/v1/admin/frequency/caps` | Create frequency cap | +| `DELETE` | `/api/v1/admin/frequency/caps/{id}` | Delete frequency cap | + +## Database Schema + +```sql +-- Auctions table +CREATE TABLE auctions ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + placement_type VARCHAR(50) NOT NULL, + auction_time TIMESTAMP NOT NULL, + bids JSONB, + winning_ad_id UUID, + winning_campaign_id UUID, + final_price DECIMAL(18,4), + winning_ecpm DECIMAL(18,4) +); + +-- Budget Pacers table +CREATE TABLE budget_pacers ( + id UUID PRIMARY KEY, + campaign_id UUID NOT NULL UNIQUE, + daily_budget DECIMAL(18,4) NOT NULL, + spent_today DECIMAL(18,4) NOT NULL, + strategy VARCHAR(20) NOT NULL, + last_updated TIMESTAMP NOT NULL +); + +-- Frequency Caps table +CREATE TABLE frequency_caps ( + id UUID PRIMARY KEY, + ad_id UUID NOT NULL, + max_impressions_per_user INT NOT NULL, + window VARCHAR(20) NOT NULL +); +``` + +## Migrations + +```bash +# Create migration +dotnet ef migrations add MigrationName --project src/AdsServingService.Infrastructure --startup-project src/AdsServingService.API + +# Apply migrations +dotnet ef database update --project src/AdsServingService.Infrastructure --startup-project src/AdsServingService.API +``` + diff --git a/services/ads-serving-service-net/docs/vi/README.md b/services/ads-serving-service-net/docs/vi/README.md index e0062b44..62b52738 100644 --- a/services/ads-serving-service-net/docs/vi/README.md +++ b/services/ads-serving-service-net/docs/vi/README.md @@ -99,6 +99,78 @@ eCPM = (Bid × Predicted CTR × 1000) + Quality Score } ``` +## Admin Backoffice APIs + +### Auction Management + +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `GET` | `/api/v1/admin/auctions` | Danh sách auctions (phân trang, có bộ lọc) | +| `GET` | `/api/v1/admin/auctions/statistics` | Thống kê đấu giá (win rate, eCPM, etc.) | + +### Budget Management + +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `GET` | `/api/v1/admin/budget/pacers` | Danh sách budget pacers | +| `GET` | `/api/v1/admin/budget/campaigns/{id}` | Trạng thái ngân sách campaign | +| `PUT` | `/api/v1/admin/budget/campaigns/{id}/reset` | Reset chi tiêu hàng ngày (manual) | +| `GET` | `/api/v1/admin/budget/statistics` | Thống kê ngân sách tổng thể | + +### Frequency Cap Management + +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `GET` | `/api/v1/admin/frequency/caps` | Danh sách frequency caps | +| `GET` | `/api/v1/admin/frequency/caps/{id}` | Chi tiết frequency cap | +| `POST` | `/api/v1/admin/frequency/caps` | Tạo frequency cap | +| `DELETE` | `/api/v1/admin/frequency/caps/{id}` | Xóa frequency cap | + +## Database Schema + +```sql +-- Auctions table +CREATE TABLE auctions ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + placement_type VARCHAR(50) NOT NULL, + auction_time TIMESTAMP NOT NULL, + bids JSONB, + winning_ad_id UUID, + winning_campaign_id UUID, + final_price DECIMAL(18,4), + winning_ecpm DECIMAL(18,4) +); + +-- Budget Pacers table +CREATE TABLE budget_pacers ( + id UUID PRIMARY KEY, + campaign_id UUID NOT NULL UNIQUE, + daily_budget DECIMAL(18,4) NOT NULL, + spent_today DECIMAL(18,4) NOT NULL, + strategy VARCHAR(20) NOT NULL, + last_updated TIMESTAMP NOT NULL +); + +-- Frequency Caps table +CREATE TABLE frequency_caps ( + id UUID PRIMARY KEY, + ad_id UUID NOT NULL, + max_impressions_per_user INT NOT NULL, + window VARCHAR(20) NOT NULL +); +``` + +## Migrations + +```bash +# Tạo migration +dotnet ef migrations add MigrationName --project src/AdsServingService.Infrastructure --startup-project src/AdsServingService.API + +# Apply migrations +dotnet ef database update --project src/AdsServingService.Infrastructure --startup-project src/AdsServingService.API + + ## Redis Caching Strategy | Key Pattern | TTL | Mục đích | diff --git a/services/ads-serving-service-net/src/AdsServingService.API/Controllers/AdminBudgetController.cs b/services/ads-serving-service-net/src/AdsServingService.API/Controllers/AdminBudgetController.cs new file mode 100644 index 00000000..f4d7e297 --- /dev/null +++ b/services/ads-serving-service-net/src/AdsServingService.API/Controllers/AdminBudgetController.cs @@ -0,0 +1,135 @@ +using AdsServingService.API.Application.Queries; +using AdsServingService.Infrastructure; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace AdsServingService.API.Controllers; + +/// +/// EN: Admin API Controller for managing budget pacing. +/// VI: API Controller Admin để quản lý điều tiết ngân sách. +/// +[ApiController] +[Route("api/v1/admin/budget")] +[Produces("application/json")] +public class AdminBudgetController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly AdsServingServiceContext _context; + private readonly ILogger _logger; + + public AdminBudgetController( + IMediator mediator, + AdsServingServiceContext context, + ILogger logger) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Get paginated list of budget pacers. + /// VI: Lấy danh sách budget pacers phân trang. + /// + [HttpGet("pacers")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + public async Task>> GetBudgetPacers( + [FromQuery] Guid? campaignId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + var query = new GetBudgetPacersQuery + { + CampaignId = campaignId, + Page = page, + PageSize = pageSize + }; + + var result = await _mediator.Send(query); + return Ok(result); + } + + /// + /// EN: Get budget status for a specific campaign. + /// VI: Lấy trạng thái ngân sách cho một chiến dịch cụ thể. + /// + [HttpGet("campaigns/{campaignId}")] + [ProducesResponseType(typeof(BudgetPacerDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetCampaignBudget(Guid campaignId) + { + var pacer = await _context.BudgetPacers + .Where(bp => EF.Property(bp, "_campaignId") == campaignId) + .Select(bp => new BudgetPacerDto + { + Id = bp.Id, + CampaignId = EF.Property(bp, "_campaignId"), + DailyBudget = EF.Property(bp, "_dailyBudget"), + SpentToday = EF.Property(bp, "_spentToday"), + RemainingBudget = bp.RemainingBudget, + UtilizationPercent = bp.UtilizationPercent, + Strategy = bp.Strategy.ToString(), + LastUpdated = EF.Property(bp, "_lastUpdated") + }) + .FirstOrDefaultAsync(); + + if (pacer == null) + return NotFound(); + + return Ok(pacer); + } + + /// + /// EN: Reset daily spend for a campaign (manual intervention). + /// VI: Reset chi tiêu hàng ngày cho một chiến dịch (can thiệp thủ công). + /// + [HttpPut("campaigns/{campaignId}/reset")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ResetCampaignBudget(Guid campaignId) + { + var pacer = await _context.BudgetPacers + .Where(bp => EF.Property(bp, "_campaignId") == campaignId) + .FirstOrDefaultAsync(); + + if (pacer == null) + return NotFound(); + + pacer.ResetDailySpend(); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Manual budget reset for Campaign {CampaignId}", campaignId); + return Ok(); + } + + /// + /// EN: Get budget utilization statistics. + /// VI: Lấy thống kê sử dụng ngân sách. + /// + [HttpGet("statistics")] + [ProducesResponseType(typeof(BudgetStatisticsDto), StatusCodes.Status200OK)] + public async Task> GetStatistics() + { + var stats = await _context.BudgetPacers + .Select(bp => new + { + DailyBudget = EF.Property(bp, "_dailyBudget"), + SpentToday = EF.Property(bp, "_spentToday"), + Utilization = bp.UtilizationPercent + }) + .ToListAsync(); + + var result = new BudgetStatisticsDto + { + TotalCampaigns = stats.Count, + TotalDailyBudget = stats.Sum(s => s.DailyBudget), + TotalSpentToday = stats.Sum(s => s.SpentToday), + AverageUtilization = stats.Any() ? stats.Average(s => s.Utilization) : 0, + CampaignsExceeded = stats.Count(s => s.Utilization >= 100) + }; + + return Ok(result); + } +} diff --git a/services/ads-serving-service-net/src/AdsServingService.API/Controllers/AdminFrequencyController.cs b/services/ads-serving-service-net/src/AdsServingService.API/Controllers/AdminFrequencyController.cs new file mode 100644 index 00000000..0c62295b --- /dev/null +++ b/services/ads-serving-service-net/src/AdsServingService.API/Controllers/AdminFrequencyController.cs @@ -0,0 +1,144 @@ +using AdsServingService.Domain.AggregatesModel.FrequencyAggregate; +using AdsServingService.Infrastructure; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace AdsServingService.API.Controllers; + +/// +/// EN: Admin API Controller for managing frequency caps. +/// VI: API Controller Admin để quản lý frequency caps. +/// +[ApiController] +[Route("api/v1/admin/frequency")] +[Produces("application/json")] +public class AdminFrequencyController : ControllerBase +{ + private readonly AdsServingServiceContext _context; + private readonly ILogger _logger; + + public AdminFrequencyController(AdsServingServiceContext context, ILogger logger) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Get list of frequency caps with optional ad filter. + /// VI: Lấy danh sách frequency caps với bộ lọc ad tùy chọn. + /// + [HttpGet("caps")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetFrequencyCaps([FromQuery] Guid? adId) + { + var query = _context.FrequencyCaps.AsQueryable(); + + if (adId.HasValue) + query = query.Where(fc => EF.Property(fc, "_adId") == adId.Value); + + var caps = await query + .Select(fc => new FrequencyCapDto + { + Id = fc.Id, + AdId = EF.Property(fc, "_adId"), + MaxImpressionsPerUser = EF.Property(fc, "_maxImpressionsPerUser"), + Window = fc.Window.ToString() + }) + .ToListAsync(); + + return Ok(caps); + } + + /// + /// EN: Get frequency cap by ID. + /// VI: Lấy frequency cap theo ID. + /// + [HttpGet("caps/{id}")] + [ProducesResponseType(typeof(FrequencyCapDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetFrequencyCap(Guid id) + { + var cap = await _context.FrequencyCaps + .Where(fc => fc.Id == id) + .Select(fc => new FrequencyCapDto + { + Id = fc.Id, + AdId = EF.Property(fc, "_adId"), + MaxImpressionsPerUser = EF.Property(fc, "_maxImpressionsPerUser"), + Window = fc.Window.ToString() + }) + .FirstOrDefaultAsync(); + + if (cap == null) + return NotFound(); + + return Ok(cap); + } + + /// + /// EN: Create a new frequency cap. + /// VI: Tạo frequency cap mới. + /// + [HttpPost("caps")] + [ProducesResponseType(typeof(FrequencyCapDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> CreateFrequencyCap([FromBody] CreateFrequencyCapRequest request) + { + if (!Enum.TryParse(request.Window, out var window)) + return BadRequest("Invalid frequency window"); + + var cap = new FrequencyCap(request.AdId, request.MaxImpressionsPerUser, window); + + _context.FrequencyCaps.Add(cap); + await _context.SaveChangesAsync(); + + var dto = new FrequencyCapDto + { + Id = cap.Id, + AdId = request.AdId, + MaxImpressionsPerUser = request.MaxImpressionsPerUser, + Window = window.ToString() + }; + + _logger.LogInformation("Created frequency cap {Id} for Ad {AdId}", cap.Id, request.AdId); + + return CreatedAtAction(nameof(GetFrequencyCap), new { id = cap.Id }, dto); + } + + /// + /// EN: Delete a frequency cap. + /// VI: Xóa frequency cap. + /// + [HttpDelete("caps/{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteFrequencyCap(Guid id) + { + var cap = await _context.FrequencyCaps.FindAsync(id); + + if (cap == null) + return NotFound(); + + _context.FrequencyCaps.Remove(cap); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Deleted frequency cap {Id}", id); + + return NoContent(); + } +} + +public record FrequencyCapDto +{ + public Guid Id { get; init; } + public Guid AdId { get; init; } + public int MaxImpressionsPerUser { get; init; } + public string Window { get; init; } = string.Empty; +} + +public record CreateFrequencyCapRequest +{ + public Guid AdId { get; init; } + public int MaxImpressionsPerUser { get; init; } + public string Window { get; init; } = "Day"; +} diff --git a/services/booking-service-net/src/BookingService.API/Application/Behaviors/TransactionBehavior.cs b/services/booking-service-net/src/BookingService.API/Application/Behaviors/TransactionBehavior.cs index b9fae97f..d59933ed 100644 --- a/services/booking-service-net/src/BookingService.API/Application/Behaviors/TransactionBehavior.cs +++ b/services/booking-service-net/src/BookingService.API/Application/Behaviors/TransactionBehavior.cs @@ -13,11 +13,11 @@ namespace BookingService.API.Application.Behaviors; public class TransactionBehavior : IPipelineBehavior where TRequest : IRequest { - private readonly BookingServiceContext _dbContext; + private readonly BookingContext _dbContext; private readonly ILogger> _logger; public TransactionBehavior( - BookingServiceContext dbContext, + BookingContext dbContext, ILogger> logger) { _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); diff --git a/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/AppointmentEntityTypeConfiguration.cs b/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/AppointmentEntityTypeConfiguration.cs new file mode 100644 index 00000000..62e28aea --- /dev/null +++ b/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/AppointmentEntityTypeConfiguration.cs @@ -0,0 +1,84 @@ +// EN: Entity type configuration for Appointment. +// VI: Cấu hình entity type cho Appointment. + +using BookingService.Domain.AggregatesModel.AppointmentAggregate; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BookingService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity type configuration for Appointment aggregate root. +/// VI: Cấu hình entity type cho aggregate root Appointment. +/// +public class AppointmentEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("appointments"); + + builder.HasKey(a => a.Id); + builder.Property(a => a.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property("_shopId") + .HasColumnName("shop_id") + .IsRequired(); + + builder.Property("_customerId") + .HasColumnName("customer_id"); + + builder.Property("_staffId") + .HasColumnName("staff_id"); + + builder.Property("_resourceId") + .HasColumnName("resource_id"); + + builder.Property("_serviceId") + .HasColumnName("service_id") + .IsRequired(); + + builder.Property("_startTime") + .HasColumnName("start_time") + .IsRequired(); + + builder.Property("_endTime") + .HasColumnName("end_time") + .IsRequired(); + + builder.Property("_status") + .HasColumnName("status") + .HasMaxLength(50) + .IsRequired(); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + // EN: Indexes / VI: Indexes + builder.HasIndex("_shopId") + .HasDatabaseName("ix_appointments_shop_id"); + + builder.HasIndex("_customerId") + .HasDatabaseName("ix_appointments_customer_id"); + + builder.HasIndex("_staffId") + .HasDatabaseName("ix_appointments_staff_id"); + + builder.HasIndex("_startTime") + .HasDatabaseName("ix_appointments_start_time"); + + // EN: Ignore public properties (mapped via backing fields) + // VI: Bỏ qua các properties công khai (đã map qua backing fields) + builder.Ignore(a => a.ShopId); + builder.Ignore(a => a.CustomerId); + builder.Ignore(a => a.StaffId); + builder.Ignore(a => a.ResourceId); + builder.Ignore(a => a.ServiceId); + builder.Ignore(a => a.StartTime); + builder.Ignore(a => a.EndTime); + builder.Ignore(a => a.Status); + builder.Ignore(a => a.CreatedAt); + } +} diff --git a/services/booking-service-net/tests/BookingService.FunctionalTests/CustomWebApplicationFactory.cs b/services/booking-service-net/tests/BookingService.FunctionalTests/CustomWebApplicationFactory.cs index 0515c6d7..2d9f4ad1 100644 --- a/services/booking-service-net/tests/BookingService.FunctionalTests/CustomWebApplicationFactory.cs +++ b/services/booking-service-net/tests/BookingService.FunctionalTests/CustomWebApplicationFactory.cs @@ -21,7 +21,7 @@ public class CustomWebApplicationFactory : WebApplicationFactory // EN: Remove the existing DbContext registration // VI: Xóa đăng ký DbContext hiện tại var descriptor = services.SingleOrDefault( - d => d.ServiceType == typeof(DbContextOptions)); + d => d.ServiceType == typeof(DbContextOptions)); if (descriptor != null) { @@ -31,7 +31,7 @@ public class CustomWebApplicationFactory : WebApplicationFactory // EN: Remove DbContext service // VI: Xóa DbContext service var dbContextDescriptor = services.SingleOrDefault( - d => d.ServiceType == typeof(BookingServiceContext)); + d => d.ServiceType == typeof(BookingContext)); if (dbContextDescriptor != null) { @@ -40,7 +40,7 @@ public class CustomWebApplicationFactory : WebApplicationFactory // EN: Add in-memory database for testing // VI: Thêm in-memory database để test - services.AddDbContext(options => + services.AddDbContext(options => { options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString()); }); @@ -49,7 +49,7 @@ public class CustomWebApplicationFactory : WebApplicationFactory // VI: Đảm bảo database được tạo với seed data var sp = services.BuildServiceProvider(); using var scope = sp.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); db.Database.EnsureCreated(); }); } diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommandHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommandHandler.cs index 2729cb16..9aa06677 100644 --- a/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommandHandler.cs +++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommandHandler.cs @@ -4,6 +4,7 @@ using System.Text.Json; using MediatR; using CatalogService.Domain.AggregatesModel.ProductAggregate; +using CatalogService.Domain.SeedWork; namespace CatalogService.API.Application.Commands; @@ -24,7 +25,7 @@ public class CreateProductCommandHandler : IRequestHandler(request.Type); // EN: Convert attributes dictionary to JsonDocument // VI: Chuyển attributes dictionary sang JsonDocument diff --git a/services/catalog-service-net/src/CatalogService.API/Controllers/CategoriesController.cs b/services/catalog-service-net/src/CatalogService.API/Controllers/CategoriesController.cs new file mode 100644 index 00000000..de630ee4 --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Controllers/CategoriesController.cs @@ -0,0 +1,71 @@ +// EN: Categories REST API Controller. +// VI: Controller REST API cho Categories. + +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using CatalogService.API.Application.Commands; +using CatalogService.API.Application.DTOs; +using CatalogService.API.Application.Queries; + +namespace CatalogService.API.Controllers; + +/// +/// EN: Categories API Controller. +/// VI: Controller API Categories. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/categories")] +public class CategoriesController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public CategoriesController( + IMediator mediator, + ILogger logger) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Get categories by shop with hierarchical support. + /// VI: Lấy danh mục theo shop có hỗ trợ phân cấp. + /// + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetCategories( + [FromQuery] Guid shopId, + [FromQuery] Guid? parentId = null, + CancellationToken cancellationToken = default) + { + var query = new GetCategoriesQuery + { + ShopId = shopId, + ParentId = parentId + }; + + var result = await _mediator.Send(query, cancellationToken); + return Ok(result); + } + + /// + /// EN: Create a new category. + /// VI: Tạo danh mục mới. + /// + [HttpPost] + [ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> CreateCategory( + [FromBody] CreateCategoryCommand command, + CancellationToken cancellationToken = default) + { + var categoryId = await _mediator.Send(command, cancellationToken); + return CreatedAtAction( + nameof(GetCategories), + new { shopId = command.ShopId }, + categoryId); + } +} diff --git a/services/catalog-service-net/src/CatalogService.API/Controllers/ProductsController.cs b/services/catalog-service-net/src/CatalogService.API/Controllers/ProductsController.cs new file mode 100644 index 00000000..2cde640e --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Controllers/ProductsController.cs @@ -0,0 +1,139 @@ +// EN: Products REST API Controller. +// VI: Controller REST API cho Products. + +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using CatalogService.API.Application.Commands; +using CatalogService.API.Application.DTOs; +using CatalogService.API.Application.Queries; + +namespace CatalogService.API.Controllers; + +/// +/// EN: Products API Controller. +/// VI: Controller API Products. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/products")] +public class ProductsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public ProductsController( + IMediator mediator, + ILogger logger) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Get products by shop with filtering and pagination. + /// VI: Lấy sản phẩm theo shop có lọc và phân trang. + /// + [HttpGet] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + public async Task>> GetProducts( + [FromQuery] Guid shopId, + [FromQuery] bool? isActive = null, + [FromQuery] string? type = null, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + var query = new GetProductsQuery + { + ShopId = shopId, + IsActive = isActive, + Type = type, + Page = page, + PageSize = pageSize + }; + + var result = await _mediator.Send(query, cancellationToken); + return Ok(result); + } + + /// + /// EN: Get product by ID. + /// VI: Lấy sản phẩm theo ID. + /// + [HttpGet("{id}")] + [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetProduct( + Guid id, + CancellationToken cancellationToken = default) + { + var query = new GetProductByIdQuery(id); + var result = await _mediator.Send(query, cancellationToken); + + if (result == null) + { + return NotFound(new { Message = $"Product with ID {id} not found" }); + } + + return Ok(result); + } + + /// + /// EN: Create a new product. + /// VI: Tạo sản phẩm mới.Catalog + /// + [HttpPost] + [ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> CreateProduct( + [FromBody] CreateProductCommand command, + CancellationToken cancellationToken = default) + { + var productId = await _mediator.Send(command, cancellationToken); + return CreatedAtAction( + nameof(GetProduct), + new { id = productId }, + productId); + } + + /// + /// EN: Update an existing product. + /// VI: Cập nhật sản phẩm hiện tại. + /// + [HttpPut("{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task UpdateProduct( + Guid id, + [FromBody] UpdateProductCommand command, + CancellationToken cancellationToken = default) + { + // EN: Ensure ID in route matches command + // VI: Đảm bảo ID trong route khớp với command + if (id != command.ProductId) + { + return BadRequest(new { Message = "Product ID mismatch" }); + } + + var result = await _mediator.Send(command, cancellationToken); + return NoContent(); + } + + /// + /// EN: Delete (deactivate) a product. + /// VI: Xóa (vô hiệu hóa) sản phẩm. + /// + [HttpDelete("{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteProduct( + Guid id, + CancellationToken cancellationToken = default) + { + var command = new DeleteProductCommand(id); + await _mediator.Send(command, cancellationToken); + return NoContent(); + } +} diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommandHandlers.cs b/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommandHandlers.cs new file mode 100644 index 00000000..36a957da --- /dev/null +++ b/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommandHandlers.cs @@ -0,0 +1,210 @@ +// EN: Command handlers for Inventory Service. +// VI: Command handlers cho Inventory Service. + +using InventoryService.Domain.AggregatesModel.InventoryAggregate; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace InventoryService.API.Application.Commands; + +/// +/// EN: Handler for StockInCommand. +/// VI: Handler cho StockInCommand. +/// +public class StockInCommandHandler : IRequestHandler +{ + private readonly IInventoryRepository _repository; + private readonly ILogger _logger; + + public StockInCommandHandler( + IInventoryRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public async Task Handle(StockInCommand request, CancellationToken ct) + { + // EN: Get or create inventory item + // VI: Lấy hoặc tạo inventory item + var item = await _repository.GetByProductAndShopAsync( + request.ProductId, + request.ShopId, + ct); + + if (item == null) + { + item = new InventoryItem(request.ProductId, request.ShopId); + await _repository.AddAsync(item, ct); + } + + // EN: Perform stock in operation + // VI: Thực hiện nhập kho + item.StockIn(request.Amount, request.Notes, request.ReferenceId); + + await _repository.UnitOfWork.SaveChangesAsync(ct); + + _logger.LogInformation( + "EN: Stock in completed / VI: Nhập kho hoàn thành - Product: {ProductId}, Shop: {ShopId}, Amount: {Amount}", + request.ProductId, request.ShopId, request.Amount); + + return item.Id; + } +} + +/// +/// EN: Handler for StockOutCommand. +/// VI: Handler cho StockOutCommand. +/// +public class StockOutCommandHandler : IRequestHandler +{ + private readonly IInventoryRepository _repository; + private readonly ILogger _logger; + + public StockOutCommandHandler( + IInventoryRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public async Task Handle(StockOutCommand request, CancellationToken ct) + { + var item = await _repository.GetByProductAndShopAsync( + request.ProductId, + request.ShopId, + ct); + + if (item == null) + return false; + + item.StockOut(request.Amount, request.Notes, request.ReferenceId); + + await _repository.UnitOfWork.SaveChangesAsync(ct); + + _logger.LogInformation( + "EN: Stock out completed / VI: Xuất kho hoàn thành - Product: {ProductId}, Shop: {ShopId}, Amount: {Amount}", + request.ProductId, request.ShopId, request.Amount); + + return true; + } +} + +/// +/// EN: Handler for ReserveStockCommand. +/// VI: Handler cho ReserveStockCommand. +/// +public class ReserveStockCommandHandler : IRequestHandler +{ + private readonly IInventoryRepository _repository; + private readonly ILogger _logger; + + public ReserveStockCommandHandler( + IInventoryRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public async Task Handle(ReserveStockCommand request, CancellationToken ct) + { + var item = await _repository.GetByProductAndShopAsync( + request.ProductId, + request.ShopId, + ct); + + if (item == null) + return false; + + item.Reserve(request.Amount, request.OrderId); + + await _repository.UnitOfWork.SaveChangesAsync(ct); + + _logger.LogInformation( + "EN: Stock reserved / VI: Stock đã đặt trước - Product: {ProductId}, Shop: {ShopId}, Amount: {Amount}, Order: {OrderId}", + request.ProductId, request.ShopId, request.Amount, request.OrderId); + + return true; + } +} + +/// +/// EN: Handler for ReleaseReservationCommand. +/// VI: Handler cho ReleaseReservationCommand. +/// +public class ReleaseReservationCommandHandler : IRequestHandler +{ + private readonly IInventoryRepository _repository; + private readonly ILogger _logger; + + public ReleaseReservationCommandHandler( + IInventoryRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public async Task Handle(ReleaseReservationCommand request, CancellationToken ct) + { + var item = await _repository.GetByProductAndShopAsync( + request.ProductId, + request.ShopId, + ct); + + if (item == null) + return false; + + item.ReleaseReservation(request.Amount, request.OrderId); + + await _repository.UnitOfWork.SaveChangesAsync(ct); + + _logger.LogInformation( + "EN: Reservation released / VI: Đặt trước đã giải phóng - Product: {ProductId}, Shop: {ShopId}, Amount: {Amount}, Order: {OrderId}", + request.ProductId, request.ShopId, request.Amount, request.OrderId); + + return true; + } +} + +/// +/// EN: Handler for AdjustStockCommand. +/// VI: Handler cho AdjustStockCommand. +/// +public class AdjustStockCommandHandler : IRequestHandler +{ + private readonly IInventoryRepository _repository; + private readonly ILogger _logger; + + public AdjustStockCommandHandler( + IInventoryRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public async Task Handle(AdjustStockCommand request, CancellationToken ct) + { + var item = await _repository.GetByProductAndShopAsync( + request.ProductId, + request.ShopId, + ct); + + if (item == null) + return false; + + item.Adjust(request.NewQuantity, request.Notes); + + await _repository.UnitOfWork.SaveChangesAsync(ct); + + _logger.LogInformation( + "EN: Stock adjusted / VI: Stock đã điều chỉnh - Product: {ProductId}, Shop: {ShopId}, NewQuantity: {NewQuantity}", + request.ProductId, request.ShopId, request.NewQuantity); + + return true; + } +} diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommands.cs b/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommands.cs new file mode 100644 index 00000000..f8c8a0ac --- /dev/null +++ b/services/inventory-service-net/src/InventoryService.API/Application/Commands/InventoryCommands.cs @@ -0,0 +1,58 @@ +// EN: Commands for Inventory Service. +// VI: Commands cho Inventory Service. + +using MediatR; + +namespace InventoryService.API.Application.Commands; + +/// +/// EN: Command to perform stock in operation. +/// VI: Command để thực hiện nhập kho. +/// +public record StockInCommand( + Guid ProductId, + Guid ShopId, + int Amount, + string? Notes, + Guid? ReferenceId) : IRequest; + +/// +/// EN: Command to perform stock out operation. +/// VI: Command để thực hiện xuất kho. +/// +public record StockOutCommand( + Guid ProductId, + Guid ShopId, + int Amount, + string? Notes, + Guid? ReferenceId) : IRequest; + +/// +/// EN: Command to reserve stock for order. +/// VI: Command để đặt trước stock cho order. +/// +public record ReserveStockCommand( + Guid ProductId, + Guid ShopId, + int Amount, + Guid OrderId) : IRequest; + +/// +/// EN: Command to release stock reservation. +/// VI: Command để giải phóng đặt trước stock. +/// +public record ReleaseReservationCommand( + Guid ProductId, + Guid ShopId, + int Amount, + Guid OrderId) : IRequest; + +/// +/// EN: Command to adjust stock (manual correction). +/// VI: Command để điều chỉnh stock (sửa thủ công). +/// +public record AdjustStockCommand( + Guid ProductId, + Guid ShopId, + int NewQuantity, + string Notes) : IRequest; diff --git a/services/inventory-service-net/src/InventoryService.API/Application/DTOs/InventoryDtos.cs b/services/inventory-service-net/src/InventoryService.API/Application/DTOs/InventoryDtos.cs new file mode 100644 index 00000000..d731abf4 --- /dev/null +++ b/services/inventory-service-net/src/InventoryService.API/Application/DTOs/InventoryDtos.cs @@ -0,0 +1,105 @@ +// EN: DTOs for Inventory Service API. +// VI: DTOs cho Inventory Service API. + +namespace InventoryService.API.Application.DTOs; + +/// +/// EN: DTO for inventory item. +/// VI: DTO cho inventory item. +/// +public record InventoryItemDto( + Guid Id, + Guid ProductId, + Guid ShopId, + int Quantity, + int ReservedQuantity, + int AvailableQuantity, + int ReorderLevel, + DateTime? UpdatedAt); + +/// +/// EN: DTO for inventory transaction. +/// VI: DTO cho inventory transaction. +/// +public record InventoryTransactionDto( + Guid Id, + Guid InventoryItemId, + string TransactionType, + int Quantity, + Guid? ReferenceId, + string? Notes, + DateTime CreatedAt); + +/// +/// EN: Request for stock in operation. +/// VI: Request cho thao tác nhập kho. +/// +public record StockInRequest( + Guid ProductId, + Guid ShopId, + int Amount, + string? Notes, + Guid? ReferenceId); + +/// +/// EN: Request for stock out operation. +/// VI: Request cho thao tác xuất kho. +/// +public record StockOutRequest( + Guid ProductId, + Guid ShopId, + int Amount, + string? Notes, + Guid? ReferenceId); + +/// +/// EN: Request for stock reservation. +/// VI: Request cho đặt trước stock. +/// +public record ReserveStockRequest( + Guid ProductId, + Guid ShopId, + int Amount, + Guid OrderId); + +/// +/// EN: Request for releasing reservation. +/// VI: Request cho giải phóng đặt trước. +/// +public record ReleaseReservationRequest( + Guid ProductId, + Guid ShopId, + int Amount, + Guid OrderId); + +/// +/// EN: Request for stock adjustment. +/// VI: Request cho điều chỉnh stock. +/// +public record AdjustStockRequest( + Guid ProductId, + Guid ShopId, + int NewQuantity, + string Notes); + +/// +/// EN: Standard API response wrapper. +/// VI: Wrapper response API chuẩn. +/// +public class ApiResponse +{ + public bool Success { get; set; } + public T? Data { get; set; } + public string? Error { get; set; } + + public static ApiResponse Ok(T data) => new() { Success = true, Data = data }; + public static ApiResponse Fail(string error) => new() { Success = false, Error = error }; +} + +/// +/// EN: Paged result for queries. +/// VI: Kết quả phân trang cho queries. +/// +public record PagedResult( + IReadOnlyList Items, + int TotalCount); diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Mappers/InventoryMapper.cs b/services/inventory-service-net/src/InventoryService.API/Application/Mappers/InventoryMapper.cs new file mode 100644 index 00000000..3a214f74 --- /dev/null +++ b/services/inventory-service-net/src/InventoryService.API/Application/Mappers/InventoryMapper.cs @@ -0,0 +1,41 @@ +// EN: Mapper for Inventory domain entities to DTOs. +// VI: Mapper từ domain entities sang DTOs. + +using InventoryService.Domain.AggregatesModel.InventoryAggregate; +using InventoryService.API.Application.DTOs; + +namespace InventoryService.API.Application.Mappers; + +/// +/// EN: Mapper from domain entities to DTOs. +/// VI: Mapper từ domain entities sang DTOs. +/// +public static class InventoryMapper +{ + /// + /// EN: Convert InventoryItem to DTO. + /// VI: Chuyển InventoryItem sang DTO. + /// + public static InventoryItemDto ToDto(this InventoryItem item) => new( + item.Id, + item.ProductId, + item.ShopId, + item.Quantity, + item.ReservedQuantity, + item.AvailableQuantity, + item.ReorderLevel, + item.UpdatedAt); + + /// + /// EN: Convert InventoryTransaction to DTO. + /// VI: Chuyển InventoryTransaction sang DTO. + /// + public static InventoryTransactionDto ToDto(this InventoryTransaction transaction) => new( + transaction.Id, + transaction.InventoryItemId, + transaction.Type.Name, + transaction.Quantity, + transaction.ReferenceId, + transaction.Notes, + transaction.CreatedAt); +} diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Queries/InventoryQueries.cs b/services/inventory-service-net/src/InventoryService.API/Application/Queries/InventoryQueries.cs new file mode 100644 index 00000000..500610ca --- /dev/null +++ b/services/inventory-service-net/src/InventoryService.API/Application/Queries/InventoryQueries.cs @@ -0,0 +1,42 @@ +// EN: Queries for Inventory Service. +// VI: Queries cho Inventory Service. + +using InventoryService.API.Application.DTOs; +using MediatR; + +namespace InventoryService.API.Application.Queries; + +/// +/// EN: Query to get inventory by shop. +/// VI: Query lấy inventory theo shop. +/// +public record GetInventoryByShopQuery( + Guid ShopId, + int Skip = 0, + int Take = 50) : IRequest>; + +/// +/// EN: Query to get stock level for specific product and shop. +/// VI: Query lấy mức tồn kho cho product và shop cụ thể. +/// +public record GetStockLevelQuery( + Guid ProductId, + Guid ShopId) : IRequest; + +/// +/// EN: Query to get transactions for inventory item. +/// VI: Query lấy transactions cho inventory item. +/// +public record GetTransactionsQuery( + Guid InventoryItemId, + int Skip = 0, + int Take = 50) : IRequest>; + +/// +/// EN: Query to get low stock items. +/// VI: Query lấy các items stock thấp. +/// +public record GetLowStockItemsQuery( + Guid? ShopId = null, + int Skip = 0, + int Take = 50) : IRequest>; diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Queries/InventoryQueryHandlers.cs b/services/inventory-service-net/src/InventoryService.API/Application/Queries/InventoryQueryHandlers.cs new file mode 100644 index 00000000..98150b66 --- /dev/null +++ b/services/inventory-service-net/src/InventoryService.API/Application/Queries/InventoryQueryHandlers.cs @@ -0,0 +1,126 @@ +// EN: Query handlers for Inventory Service. +// VI: Query handlers cho Inventory Service. + +using InventoryService.API.Application.DTOs; +using InventoryService.API.Application.Mappers; +using InventoryService.Domain.AggregatesModel.InventoryAggregate; +using MediatR; + +namespace InventoryService.API.Application.Queries; + +/// +/// EN: Handler for GetInventoryByShopQuery. +/// VI: Handler cho GetInventoryByShopQuery. +/// +public class GetInventoryByShopQueryHandler + : IRequestHandler> +{ + private readonly IInventoryRepository _repository; + + public GetInventoryByShopQueryHandler(IInventoryRepository repository) + { + _repository = repository; + } + + public async Task> Handle( + GetInventoryByShopQuery request, + CancellationToken ct) + { + var (items, total) = await _repository.GetByShopAsync( + request.ShopId, + request.Skip, + request.Take, + ct); + + var dtos = items.Select(i => i.ToDto()).ToList(); + + return new PagedResult(dtos, total); + } +} + +/// +/// EN: Handler for GetStockLevelQuery. +/// VI: Handler cho GetStockLevelQuery. +/// +public class GetStockLevelQueryHandler + : IRequestHandler +{ + private readonly IInventoryRepository _repository; + + public GetStockLevelQueryHandler(IInventoryRepository repository) + { + _repository = repository; + } + + public async Task Handle( + GetStockLevelQuery request, + CancellationToken ct) + { + var item = await _repository.GetByProductAndShopAsync( + request.ProductId, + request.ShopId, + ct); + + return item?.ToDto(); + } +} + +/// +/// EN: Handler for GetTransactionsQuery. +/// VI: Handler cho GetTransactionsQuery. +/// +public class GetTransactionsQueryHandler + : IRequestHandler> +{ + private readonly IInventoryRepository _repository; + + public GetTransactionsQueryHandler(IInventoryRepository repository) + { + _repository = repository; + } + + public async Task> Handle( + GetTransactionsQuery request, + CancellationToken ct) + { + var (transactions, total) = await _repository.GetTransactionsAsync( + request.InventoryItemId, + request.Skip, + request.Take, + ct); + + var dtos = transactions.Select(t => t.ToDto()).ToList(); + + return new PagedResult(dtos, total); + } +} + +/// +/// EN: Handler for GetLowStockItemsQuery. +/// VI: Handler cho GetLowStockItemsQuery. +/// +public class GetLowStockItemsQueryHandler + : IRequestHandler> +{ + private readonly IInventoryRepository _repository; + + public GetLowStockItemsQueryHandler(IInventoryRepository repository) + { + _repository = repository; + } + + public async Task> Handle( + GetLowStockItemsQuery request, + CancellationToken ct) + { + var (items, total) = await _repository.GetLowStockItemsAsync( + request.ShopId, + request.Skip, + request.Take, + ct); + + var dtos = items.Select(i => i.ToDto()).ToList(); + + return new PagedResult(dtos, total); + } +} diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Validations/InventoryValidators.cs b/services/inventory-service-net/src/InventoryService.API/Application/Validations/InventoryValidators.cs new file mode 100644 index 00000000..40000017 --- /dev/null +++ b/services/inventory-service-net/src/InventoryService.API/Application/Validations/InventoryValidators.cs @@ -0,0 +1,129 @@ +// EN: FluentValidation validators for inventory commands. +// VI: FluentValidation validators cho inventory commands. + +using FluentValidation; +using InventoryService.API.Application.Commands; + +namespace InventoryService.API.Application.Validations; + +/// +/// EN: Validator for StockInCommand. +/// VI: Validator cho StockInCommand. +/// +public class StockInCommandValidator : AbstractValidator +{ + public StockInCommandValidator() + { + RuleFor(x => x.ProductId) + .NotEmpty() + .WithMessage("Product ID is required / Product ID bắt buộc"); + + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID bắt buộc"); + + RuleFor(x => x.Amount) + .GreaterThan(0) + .WithMessage("Amount must be greater than 0 / Số lượng phải lớn hơn 0"); + } +} + +/// +/// EN: Validator for StockOutCommand. +/// VI: Validator cho StockOutCommand. +/// +public class StockOutCommandValidator : AbstractValidator +{ + public StockOutCommandValidator() + { + RuleFor(x => x.ProductId) + .NotEmpty() + .WithMessage("Product ID is required / Product ID bắt buộc"); + + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID bắt buộc"); + + RuleFor(x => x.Amount) + .GreaterThan(0) + .WithMessage("Amount must be greater than 0 / Số lượng phải lớn hơn 0"); + } +} + +/// +/// EN: Validator for ReserveStockCommand. +/// VI: Validator cho ReserveStockCommand. +/// +public class ReserveStockCommandValidator : AbstractValidator +{ + public ReserveStockCommandValidator() + { + RuleFor(x => x.ProductId) + .NotEmpty() + .WithMessage("Product ID is required / Product ID bắt buộc"); + + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID bắt buộc"); + + RuleFor(x => x.Amount) + .GreaterThan(0) + .WithMessage("Amount must be greater than 0 / Số lượng phải lớn hơn 0"); + + RuleFor(x => x.OrderId) + .NotEmpty() + .WithMessage("Order ID is required / Order ID bắt buộc"); + } +} + +/// +/// EN: Validator for ReleaseReservationCommand. +/// VI: Validator cho ReleaseReservationCommand. +/// +public class ReleaseReservationCommandValidator : AbstractValidator +{ + public ReleaseReservationCommandValidator() + { + RuleFor(x => x.ProductId) + .NotEmpty() + .WithMessage("Product ID is required / Product ID bắt buộc"); + + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID bắt buộc"); + + RuleFor(x => x.Amount) + .GreaterThan(0) + .WithMessage("Amount must be greater than 0 / Số lượng phải lớn hơn 0"); + + RuleFor(x => x.OrderId) + .NotEmpty() + .WithMessage("Order ID is required / Order ID bắt buộc"); + } +} + +/// +/// EN: Validator for AdjustStockCommand. +/// VI: Validator cho AdjustStockCommand. +/// +public class AdjustStockCommandValidator : AbstractValidator +{ + public AdjustStockCommandValidator() + { + RuleFor(x => x.ProductId) + .NotEmpty() + .WithMessage("Product ID is required / Product ID bắt buộc"); + + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID bắt buộc"); + + RuleFor(x => x.NewQuantity) + .GreaterThanOrEqualTo(0) + .WithMessage("New quantity must be >= 0 / Số lượng mới phải >= 0"); + + RuleFor(x => x.Notes) + .NotEmpty() + .WithMessage("Notes are required for adjustment / Ghi chú bắt buộc cho điều chỉnh"); + } +} diff --git a/services/inventory-service-net/src/InventoryService.API/Controllers/InventoryController.cs b/services/inventory-service-net/src/InventoryService.API/Controllers/InventoryController.cs new file mode 100644 index 00000000..2a3b2daf --- /dev/null +++ b/services/inventory-service-net/src/InventoryService.API/Controllers/InventoryController.cs @@ -0,0 +1,294 @@ +// EN: Main controller for Inventory operations. +// VI: Controller chính cho các thao tác Inventory. + +using Asp.Versioning; +using InventoryService.API.Application.Commands; +using InventoryService.API.Application.DTOs; +using InventoryService.API.Application.Queries; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; + +namespace InventoryService.API.Controllers; + +/// +/// EN: Controller for inventory operations. +/// VI: Controller cho các thao tác inventory. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/inventory")] +[SwaggerTag("Inventory Management - Stock operations, reservations, and tracking")] +public class InventoryController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public InventoryController( + IMediator mediator, + ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get inventory by shop. + /// VI: Lấy inventory theo shop. + /// + [HttpGet] + [SwaggerOperation(Summary = "Get inventory by shop with pagination")] + [SwaggerResponse(200, "Inventory items retrieved successfully")] + [SwaggerResponse(400, "Invalid request")] + public async Task>>> GetInventory( + [FromQuery] Guid shopId, + [FromQuery] int skip = 0, + [FromQuery] int take = 50, + CancellationToken ct = default) + { + if (shopId == Guid.Empty) + return BadRequest(ApiResponse>.Fail("Shop ID is required")); + + var query = new GetInventoryByShopQuery(shopId, skip, take); + var result = await _mediator.Send(query, ct); + + return Ok(ApiResponse>.Ok(result)); + } + + /// + /// EN: Get stock level for specific product and shop. + /// VI: Lấy mức tồn kho cho product và shop cụ thể. + /// + [HttpGet("{productId:guid}")] + [SwaggerOperation(Summary = "Get stock level by product ID and shop ID")] + [SwaggerResponse(200, "Stock level retrieved successfully")] + [SwaggerResponse(404, "Inventory item not found")] + public async Task>> GetStockLevel( + Guid productId, + [FromQuery] Guid shopId, + CancellationToken ct = default) + { + var query = new GetStockLevelQuery(productId, shopId); + var result = await _mediator.Send(query, ct); + + if (result == null) + return NotFound(ApiResponse.Fail("Inventory item not found")); + + return Ok(ApiResponse.Ok(result)); + } + + /// + /// EN: Stock in operation (add inventory). + /// VI: Thao tác nhập kho (thêm inventory). + /// + [HttpPost("stock-in")] + [SwaggerOperation(Summary = "Add stock to inventory")] + [SwaggerResponse(200, "Stock added successfully")] + [SwaggerResponse(400, "Invalid request")] + public async Task>> StockIn( + [FromBody] StockInRequest request, + CancellationToken ct = default) + { + try + { + var command = new StockInCommand( + request.ProductId, + request.ShopId, + request.Amount, + request.Notes, + request.ReferenceId); + + var inventoryItemId = await _mediator.Send(command, ct); + + return Ok(ApiResponse.Ok(inventoryItemId)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error performing stock in"); + return BadRequest(ApiResponse.Fail(ex.Message)); + } + } + + /// + /// EN: Stock out operation (remove inventory). + /// VI: Thao tác xuất kho (giảm inventory). + /// + [HttpPost("stock-out")] + [SwaggerOperation(Summary = "Remove stock from inventory")] + [SwaggerResponse(200, "Stock removed successfully")] + [SwaggerResponse(400, "Invalid request or insufficient stock")] + [SwaggerResponse(404, "Inventory item not found")] + public async Task>> StockOut( + [FromBody] StockOutRequest request, + CancellationToken ct = default) + { + try + { + var command = new StockOutCommand( + request.ProductId, + request.ShopId, + request.Amount, + request.Notes, + request.ReferenceId); + + var result = await _mediator.Send(command, ct); + + if (!result) + return NotFound(ApiResponse.Fail("Inventory item not found")); + + return Ok(ApiResponse.Ok(result)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error performing stock out"); + return BadRequest(ApiResponse.Fail(ex.Message)); + } + } + + /// + /// EN: Reserve stock for order. + /// VI: Đặt trước stock cho order. + /// + [HttpPost("reserve")] + [SwaggerOperation(Summary = "Reserve stock for order")] + [SwaggerResponse(200, "Stock reserved successfully")] + [SwaggerResponse(400, "Invalid request or insufficient available stock")] + [SwaggerResponse(404, "Inventory item not found")] + public async Task>> ReserveStock( + [FromBody] ReserveStockRequest request, + CancellationToken ct = default) + { + try + { + var command = new ReserveStockCommand( + request.ProductId, + request.ShopId, + request.Amount, + request.OrderId); + + var result = await _mediator.Send(command, ct); + + if (!result) + return NotFound(ApiResponse.Fail("Inventory item not found")); + + return Ok(ApiResponse.Ok(result)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reserving stock"); + return BadRequest(ApiResponse.Fail(ex.Message)); + } + } + + /// + /// EN: Release stock reservation. + /// VI: Giải phóng đặt trước stock. + /// + [HttpPost("release")] + [SwaggerOperation(Summary = "Release stock reservation")] + [SwaggerResponse(200, "Reservation released successfully")] + [SwaggerResponse(400, "Invalid request")] + [SwaggerResponse(404, "Inventory item not found")] + public async Task>> ReleaseReservation( + [FromBody] ReleaseReservationRequest request, + CancellationToken ct = default) + { + try + { + var command = new ReleaseReservationCommand( + request.ProductId, + request.ShopId, + request.Amount, + request.OrderId); + + var result = await _mediator.Send(command, ct); + + if (!result) + return NotFound(ApiResponse.Fail("Inventory item not found")); + + return Ok(ApiResponse.Ok(result)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error releasing reservation"); + return BadRequest(ApiResponse.Fail(ex.Message)); + } + } + + /// + /// EN: Adjust stock (manual correction). + /// VI: Điều chỉnh stock (sửa thủ công). + /// + [HttpPost("adjust")] + [SwaggerOperation(Summary = "Manually adjust stock quantity")] + [SwaggerResponse(200, "Stock adjusted successfully")] + [SwaggerResponse(400, "Invalid request")] + [SwaggerResponse(404, "Inventory item not found")] + public async Task>> AdjustStock( + [FromBody] AdjustStockRequest request, + CancellationToken ct = default) + { + try + { + var command = new AdjustStockCommand( + request.ProductId, + request.ShopId, + request.NewQuantity, + request.Notes); + + var result = await _mediator.Send(command, ct); + + if (!result) + return NotFound(ApiResponse.Fail("Inventory item not found")); + + return Ok(ApiResponse.Ok(result)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adjusting stock"); + return BadRequest(ApiResponse.Fail(ex.Message)); + } + } + + /// + /// EN: Get transaction history for inventory item. + /// VI: Lấy lịch sử transactions cho inventory item. + /// + [HttpGet("transactions")] + [SwaggerOperation(Summary = "Get transaction history")] + [SwaggerResponse(200, "Transactions retrieved successfully")] + [SwaggerResponse(400, "Invalid request")] + public async Task>>> GetTransactions( + [FromQuery] Guid inventoryItemId, + [FromQuery] int skip = 0, + [FromQuery] int take = 50, + CancellationToken ct = default) + { + if (inventoryItemId == Guid.Empty) + return BadRequest(ApiResponse>.Fail("Inventory item ID is required")); + + var query = new GetTransactionsQuery(inventoryItemId, skip, take); + var result = await _mediator.Send(query, ct); + + return Ok(ApiResponse>.Ok(result)); + } + + /// + /// EN: Get low stock items. + /// VI: Lấy các items stock thấp. + /// + [HttpGet("low-stock")] + [SwaggerOperation(Summary = "Get items with stock at or below reorder level")] + [SwaggerResponse(200, "Low stock items retrieved successfully")] + public async Task>>> GetLowStockItems( + [FromQuery] Guid? shopId = null, + [FromQuery] int skip = 0, + [FromQuery] int take = 50, + CancellationToken ct = default) + { + var query = new GetLowStockItemsQuery(shopId, skip, take); + var result = await _mediator.Send(query, ct); + + return Ok(ApiResponse>.Ok(result)); + } +} diff --git a/services/inventory-service-net/src/InventoryService.API/InventoryService.API.csproj b/services/inventory-service-net/src/InventoryService.API/InventoryService.API.csproj index a7e87fd5..c7ca418a 100644 --- a/services/inventory-service-net/src/InventoryService.API/InventoryService.API.csproj +++ b/services/inventory-service-net/src/InventoryService.API/InventoryService.API.csproj @@ -21,6 +21,7 @@ + diff --git a/services/inventory-service-net/src/InventoryService.API/Program.cs b/services/inventory-service-net/src/InventoryService.API/Program.cs index f67ab510..8a6c5583 100644 --- a/services/inventory-service-net/src/InventoryService.API/Program.cs +++ b/services/inventory-service-net/src/InventoryService.API/Program.cs @@ -74,6 +74,9 @@ try Version = "v1", Description = "InventoryService microservice API / API microservice InventoryService" }); + + // EN: Enable annotations / VI: Bật annotations + options.EnableAnnotations(); }); // EN: Add health checks / VI: Thêm health checks diff --git a/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/IInventoryRepository.cs b/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/IInventoryRepository.cs index e8dce754..1f4dcba6 100644 --- a/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/IInventoryRepository.cs +++ b/services/inventory-service-net/src/InventoryService.Domain/AggregatesModel/InventoryAggregate/IInventoryRepository.cs @@ -33,11 +33,53 @@ public interface IInventoryRepository : IRepository /// EN: Get inventory item by product ID and shop ID. /// VI: Lấy inventory item theo product ID và shop ID. /// + Task GetByProductAndShopAsync(Guid productId, Guid shopId, CancellationToken cancellationToken = default); + + /// + /// EN: Get inventory item by product ID (old method for compatibility). + /// VI: Lấy inventory item theo product ID (phương thức cũ cho tương thích). + /// Task GetByProductIdAsync(Guid productId, Guid shopId, CancellationToken cancellationToken = default); /// - /// EN: Get all inventory items for a shop. - /// VI: Lấy tất cả inventory items cho một shop. + /// EN: Get all inventory items for a shop with pagination. + /// VI: Lấy tất cả inventory items cho một shop với phân trang. + /// + Task<(IReadOnlyList Items, int Total)> GetByShopAsync( + Guid shopId, + int skip = 0, + int take = 50, + CancellationToken cancellationToken = default); + + /// + /// EN: Get all inventory items for a shop (old method for compatibility). + /// VI: Lấy tất cả inventory items cho một shop (phương thức cũ cho tương thích). /// Task> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default); + + /// + /// EN: Get transactions for inventory item with pagination. + /// VI: Lấy transactions cho inventory item với phân trang. + /// + Task<(IReadOnlyList Transactions, int Total)> GetTransactionsAsync( + Guid inventoryItemId, + int skip = 0, + int take = 50, + CancellationToken cancellationToken = default); + + /// + /// EN: Get low stock items (quantity <= reorder level). + /// VI: Lấy items stock thấp (quantity <= reorder level). + /// + Task<(IReadOnlyList Items, int Total)> GetLowStockItemsAsync( + Guid? shopId = null, + int skip = 0, + int take = 50, + CancellationToken cancellationToken = default); + + /// + /// EN: Add inventory item asynchronously. + /// VI: Thêm inventory item bất đồng bộ. + /// + Task AddAsync(InventoryItem item, CancellationToken cancellationToken = default); } diff --git a/services/inventory-service-net/src/InventoryService.Infrastructure/Repositories/InventoryRepository.cs b/services/inventory-service-net/src/InventoryService.Infrastructure/Repositories/InventoryRepository.cs index cc9d19cf..b098bfb9 100644 --- a/services/inventory-service-net/src/InventoryService.Infrastructure/Repositories/InventoryRepository.cs +++ b/services/inventory-service-net/src/InventoryService.Infrastructure/Repositories/InventoryRepository.cs @@ -29,16 +29,90 @@ public class InventoryRepository : IInventoryRepository return await _context.InventoryItems.FirstOrDefaultAsync(i => i.Id == id, cancellationToken); } - public async Task GetByProductIdAsync(Guid productId, Guid shopId, CancellationToken cancellationToken = default) + public async Task GetByProductAndShopAsync(Guid productId, Guid shopId, CancellationToken cancellationToken = default) { return await _context.InventoryItems .FirstOrDefaultAsync(i => i.ProductId == productId && i.ShopId == shopId, cancellationToken); } + public async Task GetByProductIdAsync(Guid productId, Guid shopId, CancellationToken cancellationToken = default) + { + return await GetByProductAndShopAsync(productId, shopId, cancellationToken); + } + + public async Task<(IReadOnlyList Items, int Total)> GetByShopAsync( + Guid shopId, + int skip = 0, + int take = 50, + CancellationToken cancellationToken = default) + { + var query = _context.InventoryItems.Where(i => i.ShopId == shopId); + + var total = await query.CountAsync(cancellationToken); + var items = await query + .OrderBy(i => i.ProductId) + .Skip(skip) + .Take(take) + .ToListAsync(cancellationToken); + + return (items, total); + } + public async Task> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default) { return await _context.InventoryItems .Where(i => i.ShopId == shopId) .ToListAsync(cancellationToken); } + + public async Task<(IReadOnlyList Transactions, int Total)> GetTransactionsAsync( + Guid inventoryItemId, + int skip = 0, + int take = 50, + CancellationToken cancellationToken = default) + { + var item = await _context.InventoryItems + .Include(i => i.Transactions) + .FirstOrDefaultAsync(i => i.Id == inventoryItemId, cancellationToken); + + if (item == null) + return (new List(), 0); + + var total = item.Transactions.Count; + var transactions = item.Transactions + .OrderByDescending(t => t.CreatedAt) + .Skip(skip) + .Take(take) + .ToList(); + + return (transactions, total); + } + + public async Task<(IReadOnlyList Items, int Total)> GetLowStockItemsAsync( + Guid? shopId = null, + int skip = 0, + int take = 50, + CancellationToken cancellationToken = default) + { + var query = _context.InventoryItems + .Where(i => i.AvailableQuantity <= i.ReorderLevel); + + if (shopId.HasValue) + query = query.Where(i => i.ShopId == shopId.Value); + + var total = await query.CountAsync(cancellationToken); + var items = await query + .OrderBy(i => i.AvailableQuantity) + .Skip(skip) + .Take(take) + .ToListAsync(cancellationToken); + + return (items, total); + } + + public async Task AddAsync(InventoryItem item, CancellationToken cancellationToken = default) + { + var entity = await _context.InventoryItems.AddAsync(item, cancellationToken); + return entity.Entity; + } }