- wallet-service: IPaymentGateway abstraction + VN Pay implementation (HMAC-SHA512, sandbox), Payment aggregate root, PaymentsController with create/callback/query endpoints - order-service: PosHub SignalR hub with Redis backplane + MessagePack, strongly-typed clients, 3 group types (shop/kds/pos), integrated into Create/Pay/Complete/Cancel order handlers - fnb-engine + inventory-service: Kitchen→Inventory auto-deduction via domain events, HTTP with Polly retry + circuit breaker, idempotency check, graceful degradation on insufficient stock - order-service: Enhanced PayOrderCommand with 3 flows (cash/card/online), PaymentPending status, WalletServiceClient, CompleteOrderPaymentCommand for gateway callbacks - POS frontend: Cash/Card/QR payment components wired to real backend, BFF proxy updated - infra: Traefik routes for fnb-engine, inventory-service, and SignalR WebSocket hub - ROADMAP.md: Updated with Phase 1 progress tracking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
496 lines
18 KiB
C#
496 lines
18 KiB
C#
// EN: Main controller for Inventory operations.
|
|
// VI: Controller chính cho các thao tác Inventory.
|
|
|
|
using InventoryService.API.Application.Commands;
|
|
using InventoryService.API.Application.Commands.DeductInventory;
|
|
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]
|
|
[Route("api/v1/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: Create a new inventory item (raw material, consumable, etc.).
|
|
/// VI: Tạo mặt hàng tồn kho mới (nguyên liệu, vật tư tiêu hao, v.v.).
|
|
/// </summary>
|
|
[HttpPost("items")]
|
|
[SwaggerOperation(Summary = "Create a new inventory item")]
|
|
[SwaggerResponse(201, "Item created successfully")]
|
|
[SwaggerResponse(400, "Invalid request")]
|
|
public async Task<ActionResult<ApiResponse<Guid>>> CreateItem(
|
|
[FromBody] CreateInventoryItemRequest request,
|
|
CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
var command = new CreateInventoryItemCommand(
|
|
request.ShopId,
|
|
request.Name,
|
|
request.ItemTypeId,
|
|
request.Unit,
|
|
request.CostPerUnit,
|
|
request.InitialQuantity,
|
|
request.ReorderLevel,
|
|
request.SupplierName,
|
|
request.ExpiryDate);
|
|
|
|
var itemId = await _mediator.Send(command, ct);
|
|
return Created($"/api/v1/inventory/{itemId}", ApiResponse<Guid>.Ok(itemId));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error creating inventory item");
|
|
return BadRequest(ApiResponse<Guid>.Fail(ex.Message));
|
|
}
|
|
}
|
|
|
|
/// <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,
|
|
request.InvoiceImageUrl,
|
|
request.UnitCost);
|
|
|
|
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: Stock out by inventory item ID (for recipe ingredient deduction).
|
|
/// VI: Xuất kho theo inventory item ID (cho trừ nguyên liệu công thức).
|
|
/// </summary>
|
|
[HttpPost("stock-out-by-id")]
|
|
[SwaggerOperation(Summary = "Deduct stock by inventory item ID")]
|
|
[SwaggerResponse(200, "Stock deducted successfully")]
|
|
[SwaggerResponse(400, "Invalid request or insufficient stock")]
|
|
[SwaggerResponse(404, "Inventory item not found")]
|
|
public async Task<ActionResult<ApiResponse<bool>>> StockOutById(
|
|
[FromBody] StockOutByIdRequest request,
|
|
CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
var command = new StockOutByIdCommand(
|
|
request.InventoryItemId,
|
|
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 by ID");
|
|
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: Record wastage/shrinkage.
|
|
/// VI: Ghi nhận hao hụt.
|
|
/// </summary>
|
|
[HttpPost("wastage")]
|
|
[SwaggerOperation(Summary = "Record wastage/shrinkage for an inventory item")]
|
|
[SwaggerResponse(200, "Wastage recorded successfully")]
|
|
[SwaggerResponse(400, "Invalid request")]
|
|
[SwaggerResponse(404, "Inventory item not found")]
|
|
public async Task<ActionResult<ApiResponse<bool>>> RecordWastage(
|
|
[FromBody] RecordWastageRequest request,
|
|
CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
var command = new RecordWastageCommand(
|
|
request.InventoryItemId,
|
|
request.Amount,
|
|
request.Reason,
|
|
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 recording wastage");
|
|
return BadRequest(ApiResponse<bool>.Fail(ex.Message));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Perform stocktake (inventory count).
|
|
/// VI: Thực hiện kiểm kê (đếm tồn kho).
|
|
/// </summary>
|
|
[HttpPost("stocktake")]
|
|
[SwaggerOperation(Summary = "Perform stocktake and return discrepancies")]
|
|
[SwaggerResponse(200, "Stocktake completed successfully")]
|
|
[SwaggerResponse(400, "Invalid request")]
|
|
public async Task<ActionResult<ApiResponse<StocktakeResult>>> Stocktake(
|
|
[FromBody] StocktakeRequest request,
|
|
CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
var command = new StocktakeCommand(
|
|
request.ShopId,
|
|
request.Items.Select(i => new StocktakeItem(i.InventoryItemId, i.CountedQuantity)).ToList());
|
|
|
|
var result = await _mediator.Send(command, ct);
|
|
|
|
return Ok(ApiResponse<StocktakeResult>.Ok(result));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error performing stocktake");
|
|
return BadRequest(ApiResponse<StocktakeResult>.Fail(ex.Message));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Delete an inventory item.
|
|
/// VI: Xóa nguyên liệu khỏi tồn kho.
|
|
/// </summary>
|
|
[HttpDelete("items/{id:guid}")]
|
|
[SwaggerOperation(Summary = "Delete an inventory item")]
|
|
[SwaggerResponse(200, "Item deleted successfully")]
|
|
[SwaggerResponse(404, "Item not found")]
|
|
public async Task<ActionResult<ApiResponse<bool>>> DeleteItem(
|
|
Guid id,
|
|
CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
var result = await _mediator.Send(new DeleteInventoryItemCommand(id), ct);
|
|
if (!result)
|
|
return NotFound(ApiResponse<bool>.Fail("Inventory item not found"));
|
|
return Ok(ApiResponse<bool>.Ok(true));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error deleting inventory item");
|
|
return BadRequest(ApiResponse<bool>.Fail(ex.Message));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Get transaction history by inventory item ID or shop ID.
|
|
/// VI: Lấy lịch sử transactions theo inventory item ID hoặc shop ID.
|
|
/// </summary>
|
|
[HttpGet("transactions")]
|
|
[SwaggerOperation(Summary = "Get transaction history by item or shop")]
|
|
[SwaggerResponse(200, "Transactions retrieved successfully")]
|
|
[SwaggerResponse(400, "Invalid request")]
|
|
public async Task<ActionResult<ApiResponse<PagedResult<InventoryTransactionDto>>>> GetTransactions(
|
|
[FromQuery] Guid? inventoryItemId = null,
|
|
[FromQuery] Guid? shopId = null,
|
|
[FromQuery] int skip = 0,
|
|
[FromQuery] int take = 50,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (shopId.HasValue && shopId.Value != Guid.Empty)
|
|
{
|
|
var shopQuery = new GetTransactionsByShopQuery(shopId.Value, skip, take);
|
|
var shopResult = await _mediator.Send(shopQuery, ct);
|
|
return Ok(ApiResponse<PagedResult<InventoryTransactionDto>>.Ok(shopResult));
|
|
}
|
|
|
|
if (!inventoryItemId.HasValue || inventoryItemId.Value == Guid.Empty)
|
|
return BadRequest(ApiResponse<PagedResult<InventoryTransactionDto>>.Fail("Either shopId or inventoryItemId is required"));
|
|
|
|
var query = new GetTransactionsQuery(inventoryItemId.Value, skip, take);
|
|
var result = await _mediator.Send(query, ct);
|
|
|
|
return Ok(ApiResponse<PagedResult<InventoryTransactionDto>>.Ok(result));
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Bulk deduct inventory items (called by fnb-engine when kitchen ticket is served).
|
|
/// VI: Trừ kho hàng loạt (được gọi bởi fnb-engine khi phiếu bếp đã phục vụ).
|
|
/// </summary>
|
|
[HttpPost("deduct")]
|
|
[SwaggerOperation(Summary = "Bulk deduct inventory items (cross-service: kitchen ticket served)")]
|
|
[SwaggerResponse(200, "Deduction processed")]
|
|
[SwaggerResponse(400, "Invalid request")]
|
|
public async Task<ActionResult<ApiResponse<DeductInventoryResult>>> DeductInventory(
|
|
[FromBody] DeductInventoryRequest request,
|
|
CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
var command = new DeductInventoryCommand(
|
|
request.ShopId,
|
|
request.ReferenceId,
|
|
request.ReferenceType,
|
|
request.Reason ?? "Kitchen ticket deduction",
|
|
request.Items.Select(i => new DeductionItem(
|
|
i.InventoryItemId, i.Amount, i.Unit, i.IngredientName)).ToList());
|
|
|
|
var result = await _mediator.Send(command, ct);
|
|
|
|
return Ok(ApiResponse<DeductInventoryResult>.Ok(result));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "EN: Error processing inventory deduction / VI: Lỗi xử lý trừ kho");
|
|
return BadRequest(ApiResponse<DeductInventoryResult>.Fail(ex.Message));
|
|
}
|
|
}
|
|
|
|
/// <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));
|
|
}
|
|
}
|