feat: Implement a new Inventory Service API, add admin budget and frequency controllers to Ads Serving, and introduce product and category controllers to Catalog Service.

This commit is contained in:
Ho Ngoc Hai
2026-01-18 01:21:09 +07:00
parent b1931be440
commit 4c9e12e99c
23 changed files with 1856 additions and 12 deletions

View File

@@ -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

View File

@@ -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
```

View File

@@ -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 |

View File

@@ -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;
/// <summary>
/// EN: Admin API Controller for managing budget pacing.
/// VI: API Controller Admin để quản lý điều tiết ngân sách.
/// </summary>
[ApiController]
[Route("api/v1/admin/budget")]
[Produces("application/json")]
public class AdminBudgetController : ControllerBase
{
private readonly IMediator _mediator;
private readonly AdsServingServiceContext _context;
private readonly ILogger<AdminBudgetController> _logger;
public AdminBudgetController(
IMediator mediator,
AdsServingServiceContext context,
ILogger<AdminBudgetController> logger)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: Get paginated list of budget pacers.
/// VI: Lấy danh sách budget pacers phân trang.
/// </summary>
[HttpGet("pacers")]
[ProducesResponseType(typeof(PagedResult<BudgetPacerDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResult<BudgetPacerDto>>> 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);
}
/// <summary>
/// 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ể.
/// </summary>
[HttpGet("campaigns/{campaignId}")]
[ProducesResponseType(typeof(BudgetPacerDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BudgetPacerDto>> GetCampaignBudget(Guid campaignId)
{
var pacer = await _context.BudgetPacers
.Where(bp => EF.Property<Guid>(bp, "_campaignId") == campaignId)
.Select(bp => new BudgetPacerDto
{
Id = bp.Id,
CampaignId = EF.Property<Guid>(bp, "_campaignId"),
DailyBudget = EF.Property<decimal>(bp, "_dailyBudget"),
SpentToday = EF.Property<decimal>(bp, "_spentToday"),
RemainingBudget = bp.RemainingBudget,
UtilizationPercent = bp.UtilizationPercent,
Strategy = bp.Strategy.ToString(),
LastUpdated = EF.Property<DateTime>(bp, "_lastUpdated")
})
.FirstOrDefaultAsync();
if (pacer == null)
return NotFound();
return Ok(pacer);
}
/// <summary>
/// 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).
/// </summary>
[HttpPut("campaigns/{campaignId}/reset")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ResetCampaignBudget(Guid campaignId)
{
var pacer = await _context.BudgetPacers
.Where(bp => EF.Property<Guid>(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();
}
/// <summary>
/// EN: Get budget utilization statistics.
/// VI: Lấy thống kê sử dụng ngân sách.
/// </summary>
[HttpGet("statistics")]
[ProducesResponseType(typeof(BudgetStatisticsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BudgetStatisticsDto>> GetStatistics()
{
var stats = await _context.BudgetPacers
.Select(bp => new
{
DailyBudget = EF.Property<decimal>(bp, "_dailyBudget"),
SpentToday = EF.Property<decimal>(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);
}
}

View File

@@ -0,0 +1,144 @@
using AdsServingService.Domain.AggregatesModel.FrequencyAggregate;
using AdsServingService.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace AdsServingService.API.Controllers;
/// <summary>
/// EN: Admin API Controller for managing frequency caps.
/// VI: API Controller Admin để quản lý frequency caps.
/// </summary>
[ApiController]
[Route("api/v1/admin/frequency")]
[Produces("application/json")]
public class AdminFrequencyController : ControllerBase
{
private readonly AdsServingServiceContext _context;
private readonly ILogger<AdminFrequencyController> _logger;
public AdminFrequencyController(AdsServingServiceContext context, ILogger<AdminFrequencyController> logger)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("caps")]
[ProducesResponseType(typeof(List<FrequencyCapDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<FrequencyCapDto>>> GetFrequencyCaps([FromQuery] Guid? adId)
{
var query = _context.FrequencyCaps.AsQueryable();
if (adId.HasValue)
query = query.Where(fc => EF.Property<Guid>(fc, "_adId") == adId.Value);
var caps = await query
.Select(fc => new FrequencyCapDto
{
Id = fc.Id,
AdId = EF.Property<Guid>(fc, "_adId"),
MaxImpressionsPerUser = EF.Property<int>(fc, "_maxImpressionsPerUser"),
Window = fc.Window.ToString()
})
.ToListAsync();
return Ok(caps);
}
/// <summary>
/// EN: Get frequency cap by ID.
/// VI: Lấy frequency cap theo ID.
/// </summary>
[HttpGet("caps/{id}")]
[ProducesResponseType(typeof(FrequencyCapDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<FrequencyCapDto>> GetFrequencyCap(Guid id)
{
var cap = await _context.FrequencyCaps
.Where(fc => fc.Id == id)
.Select(fc => new FrequencyCapDto
{
Id = fc.Id,
AdId = EF.Property<Guid>(fc, "_adId"),
MaxImpressionsPerUser = EF.Property<int>(fc, "_maxImpressionsPerUser"),
Window = fc.Window.ToString()
})
.FirstOrDefaultAsync();
if (cap == null)
return NotFound();
return Ok(cap);
}
/// <summary>
/// EN: Create a new frequency cap.
/// VI: Tạo frequency cap mới.
/// </summary>
[HttpPost("caps")]
[ProducesResponseType(typeof(FrequencyCapDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<FrequencyCapDto>> CreateFrequencyCap([FromBody] CreateFrequencyCapRequest request)
{
if (!Enum.TryParse<FrequencyWindow>(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);
}
/// <summary>
/// EN: Delete a frequency cap.
/// VI: Xóa frequency cap.
/// </summary>
[HttpDelete("caps/{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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";
}

View File

@@ -13,11 +13,11 @@ namespace BookingService.API.Application.Behaviors;
public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly BookingServiceContext _dbContext;
private readonly BookingContext _dbContext;
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;
public TransactionBehavior(
BookingServiceContext dbContext,
BookingContext dbContext,
ILogger<TransactionBehavior<TRequest, TResponse>> logger)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));

View File

@@ -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;
/// <summary>
/// EN: Entity type configuration for Appointment aggregate root.
/// VI: Cấu hình entity type cho aggregate root Appointment.
/// </summary>
public class AppointmentEntityTypeConfiguration : IEntityTypeConfiguration<Appointment>
{
public void Configure(EntityTypeBuilder<Appointment> builder)
{
builder.ToTable("appointments");
builder.HasKey(a => a.Id);
builder.Property(a => a.Id)
.HasColumnName("id")
.ValueGeneratedNever();
builder.Property<Guid>("_shopId")
.HasColumnName("shop_id")
.IsRequired();
builder.Property<Guid?>("_customerId")
.HasColumnName("customer_id");
builder.Property<Guid?>("_staffId")
.HasColumnName("staff_id");
builder.Property<Guid?>("_resourceId")
.HasColumnName("resource_id");
builder.Property<Guid>("_serviceId")
.HasColumnName("service_id")
.IsRequired();
builder.Property<DateTime>("_startTime")
.HasColumnName("start_time")
.IsRequired();
builder.Property<DateTime>("_endTime")
.HasColumnName("end_time")
.IsRequired();
builder.Property<string>("_status")
.HasColumnName("status")
.HasMaxLength(50)
.IsRequired();
builder.Property<DateTime>("_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);
}
}

View File

@@ -21,7 +21,7 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
// 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<BookingServiceContext>));
d => d.ServiceType == typeof(DbContextOptions<BookingContext>));
if (descriptor != null)
{
@@ -31,7 +31,7 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
// 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<Program>
// EN: Add in-memory database for testing
// VI: Thêm in-memory database để test
services.AddDbContext<BookingServiceContext>(options =>
services.AddDbContext<BookingContext>(options =>
{
options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString());
});
@@ -49,7 +49,7 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
// 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<BookingServiceContext>();
var db = scope.ServiceProvider.GetRequiredService<BookingContext>();
db.Database.EnsureCreated();
});
}

View File

@@ -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<CreateProductCommand,
{
// EN: Get ProductType enumeration
// VI: Lấy ProductType enumeration
var productType = ProductType.FromName(request.Type);
var productType = Enumeration.FromDisplayName<ProductType>(request.Type);
// EN: Convert attributes dictionary to JsonDocument
// VI: Chuyển attributes dictionary sang JsonDocument

View File

@@ -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;
/// <summary>
/// EN: Categories API Controller.
/// VI: Controller API Categories.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/categories")]
public class CategoriesController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<CategoriesController> _logger;
public CategoriesController(
IMediator mediator,
ILogger<CategoriesController> logger)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: Get categories by shop with hierarchical support.
/// VI: Lấy danh mục theo shop có hỗ trợ phân cấp.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(List<CategoryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<CategoryDto>>> 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);
}
/// <summary>
/// EN: Create a new category.
/// VI: Tạo danh mục mới.
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<Guid>> CreateCategory(
[FromBody] CreateCategoryCommand command,
CancellationToken cancellationToken = default)
{
var categoryId = await _mediator.Send(command, cancellationToken);
return CreatedAtAction(
nameof(GetCategories),
new { shopId = command.ShopId },
categoryId);
}
}

View File

@@ -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;
/// <summary>
/// EN: Products API Controller.
/// VI: Controller API Products.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/products")]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<ProductsController> _logger;
public ProductsController(
IMediator mediator,
ILogger<ProductsController> logger)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// 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.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResult<ProductDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResult<ProductDto>>> 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);
}
/// <summary>
/// EN: Get product by ID.
/// VI: Lấy sản phẩm theo ID.
/// </summary>
[HttpGet("{id}")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductDto>> 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);
}
/// <summary>
/// EN: Create a new product.
/// VI: Tạo sản phẩm mới.Catalog
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<Guid>> CreateProduct(
[FromBody] CreateProductCommand command,
CancellationToken cancellationToken = default)
{
var productId = await _mediator.Send(command, cancellationToken);
return CreatedAtAction(
nameof(GetProduct),
new { id = productId },
productId);
}
/// <summary>
/// EN: Update an existing product.
/// VI: Cập nhật sản phẩm hiện tại.
/// </summary>
[HttpPut("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> 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();
}
/// <summary>
/// EN: Delete (deactivate) a product.
/// VI: Xóa (vô hiệu hóa) sản phẩm.
/// </summary>
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteProduct(
Guid id,
CancellationToken cancellationToken = default)
{
var command = new DeleteProductCommand(id);
await _mediator.Send(command, cancellationToken);
return NoContent();
}
}

View File

@@ -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;
/// <summary>
/// EN: Handler for StockInCommand.
/// VI: Handler cho StockInCommand.
/// </summary>
public class StockInCommandHandler : IRequestHandler<StockInCommand, Guid>
{
private readonly IInventoryRepository _repository;
private readonly ILogger<StockInCommandHandler> _logger;
public StockInCommandHandler(
IInventoryRepository repository,
ILogger<StockInCommandHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<Guid> 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;
}
}
/// <summary>
/// EN: Handler for StockOutCommand.
/// VI: Handler cho StockOutCommand.
/// </summary>
public class StockOutCommandHandler : IRequestHandler<StockOutCommand, bool>
{
private readonly IInventoryRepository _repository;
private readonly ILogger<StockOutCommandHandler> _logger;
public StockOutCommandHandler(
IInventoryRepository repository,
ILogger<StockOutCommandHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<bool> 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;
}
}
/// <summary>
/// EN: Handler for ReserveStockCommand.
/// VI: Handler cho ReserveStockCommand.
/// </summary>
public class ReserveStockCommandHandler : IRequestHandler<ReserveStockCommand, bool>
{
private readonly IInventoryRepository _repository;
private readonly ILogger<ReserveStockCommandHandler> _logger;
public ReserveStockCommandHandler(
IInventoryRepository repository,
ILogger<ReserveStockCommandHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<bool> 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;
}
}
/// <summary>
/// EN: Handler for ReleaseReservationCommand.
/// VI: Handler cho ReleaseReservationCommand.
/// </summary>
public class ReleaseReservationCommandHandler : IRequestHandler<ReleaseReservationCommand, bool>
{
private readonly IInventoryRepository _repository;
private readonly ILogger<ReleaseReservationCommandHandler> _logger;
public ReleaseReservationCommandHandler(
IInventoryRepository repository,
ILogger<ReleaseReservationCommandHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<bool> 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;
}
}
/// <summary>
/// EN: Handler for AdjustStockCommand.
/// VI: Handler cho AdjustStockCommand.
/// </summary>
public class AdjustStockCommandHandler : IRequestHandler<AdjustStockCommand, bool>
{
private readonly IInventoryRepository _repository;
private readonly ILogger<AdjustStockCommandHandler> _logger;
public AdjustStockCommandHandler(
IInventoryRepository repository,
ILogger<AdjustStockCommandHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<bool> 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;
}
}

View File

@@ -0,0 +1,58 @@
// EN: Commands for Inventory Service.
// VI: Commands cho Inventory Service.
using MediatR;
namespace InventoryService.API.Application.Commands;
/// <summary>
/// EN: Command to perform stock in operation.
/// VI: Command để thực hiện nhập kho.
/// </summary>
public record StockInCommand(
Guid ProductId,
Guid ShopId,
int Amount,
string? Notes,
Guid? ReferenceId) : IRequest<Guid>;
/// <summary>
/// EN: Command to perform stock out operation.
/// VI: Command để thực hiện xuất kho.
/// </summary>
public record StockOutCommand(
Guid ProductId,
Guid ShopId,
int Amount,
string? Notes,
Guid? ReferenceId) : IRequest<bool>;
/// <summary>
/// EN: Command to reserve stock for order.
/// VI: Command để đặt trước stock cho order.
/// </summary>
public record ReserveStockCommand(
Guid ProductId,
Guid ShopId,
int Amount,
Guid OrderId) : IRequest<bool>;
/// <summary>
/// EN: Command to release stock reservation.
/// VI: Command để giải phóng đặt trước stock.
/// </summary>
public record ReleaseReservationCommand(
Guid ProductId,
Guid ShopId,
int Amount,
Guid OrderId) : IRequest<bool>;
/// <summary>
/// EN: Command to adjust stock (manual correction).
/// VI: Command để điều chỉnh stock (sửa thủ công).
/// </summary>
public record AdjustStockCommand(
Guid ProductId,
Guid ShopId,
int NewQuantity,
string Notes) : IRequest<bool>;

View File

@@ -0,0 +1,105 @@
// EN: DTOs for Inventory Service API.
// VI: DTOs cho Inventory Service API.
namespace InventoryService.API.Application.DTOs;
/// <summary>
/// EN: DTO for inventory item.
/// VI: DTO cho inventory item.
/// </summary>
public record InventoryItemDto(
Guid Id,
Guid ProductId,
Guid ShopId,
int Quantity,
int ReservedQuantity,
int AvailableQuantity,
int ReorderLevel,
DateTime? UpdatedAt);
/// <summary>
/// EN: DTO for inventory transaction.
/// VI: DTO cho inventory transaction.
/// </summary>
public record InventoryTransactionDto(
Guid Id,
Guid InventoryItemId,
string TransactionType,
int Quantity,
Guid? ReferenceId,
string? Notes,
DateTime CreatedAt);
/// <summary>
/// EN: Request for stock in operation.
/// VI: Request cho thao tác nhập kho.
/// </summary>
public record StockInRequest(
Guid ProductId,
Guid ShopId,
int Amount,
string? Notes,
Guid? ReferenceId);
/// <summary>
/// EN: Request for stock out operation.
/// VI: Request cho thao tác xuất kho.
/// </summary>
public record StockOutRequest(
Guid ProductId,
Guid ShopId,
int Amount,
string? Notes,
Guid? ReferenceId);
/// <summary>
/// EN: Request for stock reservation.
/// VI: Request cho đặt trước stock.
/// </summary>
public record ReserveStockRequest(
Guid ProductId,
Guid ShopId,
int Amount,
Guid OrderId);
/// <summary>
/// EN: Request for releasing reservation.
/// VI: Request cho giải phóng đặt trước.
/// </summary>
public record ReleaseReservationRequest(
Guid ProductId,
Guid ShopId,
int Amount,
Guid OrderId);
/// <summary>
/// EN: Request for stock adjustment.
/// VI: Request cho điều chỉnh stock.
/// </summary>
public record AdjustStockRequest(
Guid ProductId,
Guid ShopId,
int NewQuantity,
string Notes);
/// <summary>
/// EN: Standard API response wrapper.
/// VI: Wrapper response API chuẩn.
/// </summary>
public class ApiResponse<T>
{
public bool Success { get; set; }
public T? Data { get; set; }
public string? Error { get; set; }
public static ApiResponse<T> Ok(T data) => new() { Success = true, Data = data };
public static ApiResponse<T> Fail(string error) => new() { Success = false, Error = error };
}
/// <summary>
/// EN: Paged result for queries.
/// VI: Kết quả phân trang cho queries.
/// </summary>
public record PagedResult<T>(
IReadOnlyList<T> Items,
int TotalCount);

View File

@@ -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;
/// <summary>
/// EN: Mapper from domain entities to DTOs.
/// VI: Mapper từ domain entities sang DTOs.
/// </summary>
public static class InventoryMapper
{
/// <summary>
/// EN: Convert InventoryItem to DTO.
/// VI: Chuyển InventoryItem sang DTO.
/// </summary>
public static InventoryItemDto ToDto(this InventoryItem item) => new(
item.Id,
item.ProductId,
item.ShopId,
item.Quantity,
item.ReservedQuantity,
item.AvailableQuantity,
item.ReorderLevel,
item.UpdatedAt);
/// <summary>
/// EN: Convert InventoryTransaction to DTO.
/// VI: Chuyển InventoryTransaction sang DTO.
/// </summary>
public static InventoryTransactionDto ToDto(this InventoryTransaction transaction) => new(
transaction.Id,
transaction.InventoryItemId,
transaction.Type.Name,
transaction.Quantity,
transaction.ReferenceId,
transaction.Notes,
transaction.CreatedAt);
}

View File

@@ -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;
/// <summary>
/// EN: Query to get inventory by shop.
/// VI: Query lấy inventory theo shop.
/// </summary>
public record GetInventoryByShopQuery(
Guid ShopId,
int Skip = 0,
int Take = 50) : IRequest<PagedResult<InventoryItemDto>>;
/// <summary>
/// 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ể.
/// </summary>
public record GetStockLevelQuery(
Guid ProductId,
Guid ShopId) : IRequest<InventoryItemDto?>;
/// <summary>
/// EN: Query to get transactions for inventory item.
/// VI: Query lấy transactions cho inventory item.
/// </summary>
public record GetTransactionsQuery(
Guid InventoryItemId,
int Skip = 0,
int Take = 50) : IRequest<PagedResult<InventoryTransactionDto>>;
/// <summary>
/// EN: Query to get low stock items.
/// VI: Query lấy các items stock thấp.
/// </summary>
public record GetLowStockItemsQuery(
Guid? ShopId = null,
int Skip = 0,
int Take = 50) : IRequest<PagedResult<InventoryItemDto>>;

View File

@@ -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;
/// <summary>
/// EN: Handler for GetInventoryByShopQuery.
/// VI: Handler cho GetInventoryByShopQuery.
/// </summary>
public class GetInventoryByShopQueryHandler
: IRequestHandler<GetInventoryByShopQuery, PagedResult<InventoryItemDto>>
{
private readonly IInventoryRepository _repository;
public GetInventoryByShopQueryHandler(IInventoryRepository repository)
{
_repository = repository;
}
public async Task<PagedResult<InventoryItemDto>> 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<InventoryItemDto>(dtos, total);
}
}
/// <summary>
/// EN: Handler for GetStockLevelQuery.
/// VI: Handler cho GetStockLevelQuery.
/// </summary>
public class GetStockLevelQueryHandler
: IRequestHandler<GetStockLevelQuery, InventoryItemDto?>
{
private readonly IInventoryRepository _repository;
public GetStockLevelQueryHandler(IInventoryRepository repository)
{
_repository = repository;
}
public async Task<InventoryItemDto?> Handle(
GetStockLevelQuery request,
CancellationToken ct)
{
var item = await _repository.GetByProductAndShopAsync(
request.ProductId,
request.ShopId,
ct);
return item?.ToDto();
}
}
/// <summary>
/// EN: Handler for GetTransactionsQuery.
/// VI: Handler cho GetTransactionsQuery.
/// </summary>
public class GetTransactionsQueryHandler
: IRequestHandler<GetTransactionsQuery, PagedResult<InventoryTransactionDto>>
{
private readonly IInventoryRepository _repository;
public GetTransactionsQueryHandler(IInventoryRepository repository)
{
_repository = repository;
}
public async Task<PagedResult<InventoryTransactionDto>> 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<InventoryTransactionDto>(dtos, total);
}
}
/// <summary>
/// EN: Handler for GetLowStockItemsQuery.
/// VI: Handler cho GetLowStockItemsQuery.
/// </summary>
public class GetLowStockItemsQueryHandler
: IRequestHandler<GetLowStockItemsQuery, PagedResult<InventoryItemDto>>
{
private readonly IInventoryRepository _repository;
public GetLowStockItemsQueryHandler(IInventoryRepository repository)
{
_repository = repository;
}
public async Task<PagedResult<InventoryItemDto>> 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<InventoryItemDto>(dtos, total);
}
}

View File

@@ -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;
/// <summary>
/// EN: Validator for StockInCommand.
/// VI: Validator cho StockInCommand.
/// </summary>
public class StockInCommandValidator : AbstractValidator<StockInCommand>
{
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");
}
}
/// <summary>
/// EN: Validator for StockOutCommand.
/// VI: Validator cho StockOutCommand.
/// </summary>
public class StockOutCommandValidator : AbstractValidator<StockOutCommand>
{
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");
}
}
/// <summary>
/// EN: Validator for ReserveStockCommand.
/// VI: Validator cho ReserveStockCommand.
/// </summary>
public class ReserveStockCommandValidator : AbstractValidator<ReserveStockCommand>
{
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");
}
}
/// <summary>
/// EN: Validator for ReleaseReservationCommand.
/// VI: Validator cho ReleaseReservationCommand.
/// </summary>
public class ReleaseReservationCommandValidator : AbstractValidator<ReleaseReservationCommand>
{
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");
}
}
/// <summary>
/// EN: Validator for AdjustStockCommand.
/// VI: Validator cho AdjustStockCommand.
/// </summary>
public class AdjustStockCommandValidator : AbstractValidator<AdjustStockCommand>
{
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");
}
}

View File

@@ -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;
/// <summary>
/// EN: Controller for inventory operations.
/// VI: Controller cho các thao tác inventory.
/// </summary>
[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<InventoryController> _logger;
public InventoryController(
IMediator mediator,
ILogger<InventoryController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get inventory by shop.
/// VI: Lấy inventory theo shop.
/// </summary>
[HttpGet]
[SwaggerOperation(Summary = "Get inventory by shop with pagination")]
[SwaggerResponse(200, "Inventory items retrieved successfully")]
[SwaggerResponse(400, "Invalid request")]
public async Task<ActionResult<ApiResponse<PagedResult<InventoryItemDto>>>> GetInventory(
[FromQuery] Guid shopId,
[FromQuery] int skip = 0,
[FromQuery] int take = 50,
CancellationToken ct = default)
{
if (shopId == Guid.Empty)
return BadRequest(ApiResponse<PagedResult<InventoryItemDto>>.Fail("Shop ID is required"));
var query = new GetInventoryByShopQuery(shopId, skip, take);
var result = await _mediator.Send(query, ct);
return Ok(ApiResponse<PagedResult<InventoryItemDto>>.Ok(result));
}
/// <summary>
/// EN: Get stock level for specific product and shop.
/// VI: Lấy mức tồn kho cho product và shop cụ thể.
/// </summary>
[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<ActionResult<ApiResponse<InventoryItemDto>>> 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<InventoryItemDto>.Fail("Inventory item not found"));
return Ok(ApiResponse<InventoryItemDto>.Ok(result));
}
/// <summary>
/// EN: Stock in operation (add inventory).
/// VI: Thao tác nhập kho (thêm inventory).
/// </summary>
[HttpPost("stock-in")]
[SwaggerOperation(Summary = "Add stock to inventory")]
[SwaggerResponse(200, "Stock added successfully")]
[SwaggerResponse(400, "Invalid request")]
public async Task<ActionResult<ApiResponse<Guid>>> 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<Guid>.Ok(inventoryItemId));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error performing stock in");
return BadRequest(ApiResponse<Guid>.Fail(ex.Message));
}
}
/// <summary>
/// EN: Stock out operation (remove inventory).
/// VI: Thao tác xuất kho (giảm inventory).
/// </summary>
[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<ActionResult<ApiResponse<bool>>> 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<bool>.Fail("Inventory item not found"));
return Ok(ApiResponse<bool>.Ok(result));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error performing stock out");
return BadRequest(ApiResponse<bool>.Fail(ex.Message));
}
}
/// <summary>
/// EN: Reserve stock for order.
/// VI: Đặt trước stock cho order.
/// </summary>
[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<ActionResult<ApiResponse<bool>>> 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<bool>.Fail("Inventory item not found"));
return Ok(ApiResponse<bool>.Ok(result));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reserving stock");
return BadRequest(ApiResponse<bool>.Fail(ex.Message));
}
}
/// <summary>
/// EN: Release stock reservation.
/// VI: Giải phóng đặt trước stock.
/// </summary>
[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<ActionResult<ApiResponse<bool>>> 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<bool>.Fail("Inventory item not found"));
return Ok(ApiResponse<bool>.Ok(result));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error releasing reservation");
return BadRequest(ApiResponse<bool>.Fail(ex.Message));
}
}
/// <summary>
/// EN: Adjust stock (manual correction).
/// VI: Điều chỉnh stock (sửa thủ công).
/// </summary>
[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<ActionResult<ApiResponse<bool>>> 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<bool>.Fail("Inventory item not found"));
return Ok(ApiResponse<bool>.Ok(result));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adjusting stock");
return BadRequest(ApiResponse<bool>.Fail(ex.Message));
}
}
/// <summary>
/// EN: Get transaction history for inventory item.
/// VI: Lấy lịch sử transactions cho inventory item.
/// </summary>
[HttpGet("transactions")]
[SwaggerOperation(Summary = "Get transaction history")]
[SwaggerResponse(200, "Transactions retrieved successfully")]
[SwaggerResponse(400, "Invalid request")]
public async Task<ActionResult<ApiResponse<PagedResult<InventoryTransactionDto>>>> GetTransactions(
[FromQuery] Guid inventoryItemId,
[FromQuery] int skip = 0,
[FromQuery] int take = 50,
CancellationToken ct = default)
{
if (inventoryItemId == Guid.Empty)
return BadRequest(ApiResponse<PagedResult<InventoryTransactionDto>>.Fail("Inventory item ID is required"));
var query = new GetTransactionsQuery(inventoryItemId, skip, take);
var result = await _mediator.Send(query, ct);
return Ok(ApiResponse<PagedResult<InventoryTransactionDto>>.Ok(result));
}
/// <summary>
/// EN: Get low stock items.
/// VI: Lấy các items stock thấp.
/// </summary>
[HttpGet("low-stock")]
[SwaggerOperation(Summary = "Get items with stock at or below reorder level")]
[SwaggerResponse(200, "Low stock items retrieved successfully")]
public async Task<ActionResult<ApiResponse<PagedResult<InventoryItemDto>>>> 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<PagedResult<InventoryItemDto>>.Ok(result));
}
}

View File

@@ -21,6 +21,7 @@
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="7.2.0" />
<!-- EN: API Versioning / VI: API Versioning -->
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />

View File

@@ -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

View File

@@ -33,11 +33,53 @@ public interface IInventoryRepository : IRepository<InventoryItem>
/// EN: Get inventory item by product ID and shop ID.
/// VI: Lấy inventory item theo product ID và shop ID.
/// </summary>
Task<InventoryItem?> GetByProductAndShopAsync(Guid productId, Guid shopId, CancellationToken cancellationToken = default);
/// <summary>
/// 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).
/// </summary>
Task<InventoryItem?> GetByProductIdAsync(Guid productId, Guid shopId, CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
Task<(IReadOnlyList<InventoryItem> Items, int Total)> GetByShopAsync(
Guid shopId,
int skip = 0,
int take = 50,
CancellationToken cancellationToken = default);
/// <summary>
/// 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).
/// </summary>
Task<IEnumerable<InventoryItem>> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get transactions for inventory item with pagination.
/// VI: Lấy transactions cho inventory item với phân trang.
/// </summary>
Task<(IReadOnlyList<InventoryTransaction> Transactions, int Total)> GetTransactionsAsync(
Guid inventoryItemId,
int skip = 0,
int take = 50,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get low stock items (quantity &lt;= reorder level).
/// VI: Lấy items stock thấp (quantity &lt;= reorder level).
/// </summary>
Task<(IReadOnlyList<InventoryItem> Items, int Total)> GetLowStockItemsAsync(
Guid? shopId = null,
int skip = 0,
int take = 50,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Add inventory item asynchronously.
/// VI: Thêm inventory item bất đồng bộ.
/// </summary>
Task<InventoryItem> AddAsync(InventoryItem item, CancellationToken cancellationToken = default);
}

View File

@@ -29,16 +29,90 @@ public class InventoryRepository : IInventoryRepository
return await _context.InventoryItems.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
}
public async Task<InventoryItem?> GetByProductIdAsync(Guid productId, Guid shopId, CancellationToken cancellationToken = default)
public async Task<InventoryItem?> GetByProductAndShopAsync(Guid productId, Guid shopId, CancellationToken cancellationToken = default)
{
return await _context.InventoryItems
.FirstOrDefaultAsync(i => i.ProductId == productId && i.ShopId == shopId, cancellationToken);
}
public async Task<InventoryItem?> GetByProductIdAsync(Guid productId, Guid shopId, CancellationToken cancellationToken = default)
{
return await GetByProductAndShopAsync(productId, shopId, cancellationToken);
}
public async Task<(IReadOnlyList<InventoryItem> 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<IEnumerable<InventoryItem>> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default)
{
return await _context.InventoryItems
.Where(i => i.ShopId == shopId)
.ToListAsync(cancellationToken);
}
public async Task<(IReadOnlyList<InventoryTransaction> 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<InventoryTransaction>(), 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<InventoryItem> 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<InventoryItem> AddAsync(InventoryItem item, CancellationToken cancellationToken = default)
{
var entity = await _context.InventoryItems.AddAsync(item, cancellationToken);
return entity.Entity;
}
}