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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>>;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <= reorder level).
|
||||
/// VI: Lấy items stock thấp (quantity <= 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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user