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;
+ }
}