```
PharmacySolution/
├── Pharmacy.Core/ (Ядро системы)
├── Pharmacy.Data/ (Доступ к данным)
├── Pharmacy.Services/ (Бизнес-логика)
├── Pharmacy.Web/ (Веб-интерфейс)
└── Pharmacy.Tests/ (Тесты)
```
## 1. Pharmacy.Core (Основные модели и интерфейсы)
```csharp
// Pharmacy.Core/Models/Product.cs
namespace Pharmacy.Core.Models
{
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public string ActiveSubstance { get; set; }
public string Manufacturer { get; set; }
public string Barcode { get; set; }
public ProductCategory Category { get; set; }
public bool IsPrescriptionRequired { get; set; }
public decimal Price { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public ICollection<StockItem> StockItems { get; set; }
public ICollection<ProductImage> Images { get; set; }
}
public enum ProductCategory
{
Medicines,
Vitamins,
MedicalDevices,
Cosmetics,
Hygiene,
BabyCare,
Other
}
}
// Pharmacy.Core/Models/StockItem.cs
namespace Pharmacy.Core.Models
{
public class StockItem
{
public int Id { get; set; }
public int ProductId { get; set; }
public Product Product { get; set; }
public int Quantity { get; set; }
public int WarehouseId { get; set; }
public Warehouse Warehouse { get; set; }
public DateTime ExpirationDate { get; set; }
public string BatchNumber { get; set; }
public DateTime ArrivalDate { get; set; }
public decimal PurchasePrice { get; set; }
}
}
// Pharmacy.Core/Models/Warehouse.cs
namespace Pharmacy.Core.Models
{
public class Warehouse
{
public int Id { get; set; }
public string Name { get; set; }
public string Address { get; set; }
public decimal Capacity { get; set; } // в куб. метрах
public decimal CurrentOccupancy { get; set; }
public bool IsActive { get; set; } = true;
public ICollection<StockItem> StockItems { get; set; }
}
}
// Pharmacy.Core/Interfaces/IRepository.cs
namespace Pharmacy.Core.Interfaces
{
public interface IRepository<T> where T : class
{
Task<T> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);
Task<bool> ExistsAsync(int id);
}
}
// Pharmacy.Core/Interfaces/IProductService.cs
namespace Pharmacy.Core.Interfaces
{
public interface IProductService
{
Task<Product> GetProductByIdAsync(int id);
Task<IEnumerable<Product>> GetAllProductsAsync();
Task<IEnumerable<Product>> SearchProductsAsync(string searchTerm, ProductCategory? category);
Task AddProductAsync(Product product);
Task UpdateProductAsync(Product product);
Task DeleteProductAsync(int id);
Task<StockInfo> GetStockInfoAsync(int productId);
Task AdjustStockAsync(int productId, int warehouseId, int quantityChange, string batchNumber, DateTime? expirationDate);
}
public class StockInfo
{
public int TotalQuantity { get; set; }
public IEnumerable<StockItem> Items { get; set; }
}
}
```
## 2. Pharmacy.Data (Доступ к данным)
```csharp
// Pharmacy.Data/Repositories/Repository.cs
namespace Pharmacy.Data.Repositories
{
public class Repository<T> : IRepository<T> where T : class
{
protected readonly PharmacyDbContext _context;
protected readonly DbSet<T> _dbSet;
public Repository(PharmacyDbContext context)
{
_context = context;
_dbSet = context.Set<T>();
}
public async Task<T> GetByIdAsync(int id) => await _dbSet.FindAsync(id);
public async Task<IEnumerable<T>> GetAllAsync() => await _dbSet.ToListAsync();
public async Task AddAsync(T entity) => await _dbSet.AddAsync(entity);
public async Task UpdateAsync(T entity) => _context.Entry(entity).State = EntityState.Modified;
public async Task DeleteAsync(T entity) => _dbSet.Remove(entity);
public async Task<bool> ExistsAsync(int id) => await _dbSet.AnyAsync(e => EF.Property<int>(e, "Id") == id);
}
}
// Pharmacy.Data/Repositories/ProductRepository.cs
namespace Pharmacy.Data.Repositories
{
public interface IProductRepository : IRepository<Product>
{
Task<IEnumerable<Product>> GetProductsByCategoryAsync(ProductCategory category);
Task<IEnumerable<Product>> SearchProductsAsync(string searchTerm, ProductCategory? category);
Task<StockInfo> GetStockInfoAsync(int productId);
}
public class ProductRepository : Repository<Product>, IProductRepository
{
public ProductRepository(PharmacyDbContext context) : base(context) { }
public async Task<IEnumerable<Product>> GetProductsByCategoryAsync(ProductCategory category)
=> await _context.Products.Where(p => p.Category == category).ToListAsync();
public async Task<IEnumerable<Product>> SearchProductsAsync(string searchTerm, ProductCategory? category)
{
var query = _context.Products.AsQueryable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
query = query.Where(p =>
p.Name.Contains(searchTerm) ||
p.Description.Contains(searchTerm) ||
p.ActiveSubstance.Contains(searchTerm) ||
p.Barcode == searchTerm);
}
if (category.HasValue)
{
query = query.Where(p => p.Category == category.Value);
}
return await query.ToListAsync();
}
public async Task<StockInfo> GetStockInfoAsync(int productId)
{
var stockItems = await _context.StockItems
.Include(si => si.Warehouse)
.Where(si => si.ProductId == productId)
.ToListAsync();
return new StockInfo
{
TotalQuantity = stockItems.Sum(si => si.Quantity),
Items = stockItems
};
}
}
}
// Pharmacy.Data/PharmacyDbContext.cs
namespace Pharmacy.Data
{
public class PharmacyDbContext : DbContext
{
public PharmacyDbContext(DbContextOptions<PharmacyDbContext> options) : base(options) { }
public DbSet<Product> Products { get; set; }
public DbSet<StockItem> StockItems { get; set; }
public DbSet<Warehouse> Warehouses { get; set; }
public DbSet<ProductImage> ProductImages { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.HasIndex(p => p.Barcode).IsUnique();
entity.Property(p => p.Price).HasColumnType("decimal(18,2)");
});
modelBuilder.Entity<StockItem>(entity =>
{
entity.Property(si => si.PurchasePrice).HasColumnType("decimal(18,2)");
entity.HasIndex(si => new { si.ProductId, si.WarehouseId, si.BatchNumber }).IsUnique();
});
modelBuilder.Entity<Warehouse>(entity =>
{
entity.Property(w => w.Capacity).HasColumnType("decimal(18,2)");
entity.Property(w => w.CurrentOccupancy).HasColumnType("decimal(18,2)");
});
}
}
}
```
## 3. Pharmacy.Services (Бизнес-логика)
```csharp
// Pharmacy.Services/ProductService.cs
namespace Pharmacy.Services
{
public class ProductService : IProductService
{
private readonly IProductRepository _productRepository;
private readonly IRepository<StockItem> _stockItemRepository;
private readonly IRepository<Warehouse> _warehouseRepository;
private readonly ILogger<ProductService> _logger;
public ProductService(
IProductRepository productRepository,
IRepository<StockItem> stockItemRepository,
IRepository<Warehouse> warehouseRepository,
ILogger<ProductService> logger)
{
_productRepository = productRepository;
_stockItemRepository = stockItemRepository;
_warehouseRepository = warehouseRepository;
_logger = logger;
}
public async Task<Product> GetProductByIdAsync(int id)
{
var product = await _productRepository.GetByIdAsync(id);
if (product == null)
{
_logger.LogWarning("Product with id {ProductId} not found", id);
throw new KeyNotFoundException($"Product with id {id} not found");
}
return product;
}
public async Task<IEnumerable<Product>> GetAllProductsAsync()
=> await _productRepository.GetAllAsync();
public async Task<IEnumerable<Product>> SearchProductsAsync(string searchTerm, ProductCategory? category)
=> await _productRepository.SearchProductsAsync(searchTerm, category);
public async Task AddProductAsync(Product product)
{
if (product == null)
throw new ArgumentNullException(nameof(product));
await _productRepository.AddAsync(product);
_logger.LogInformation("Product {ProductName} added with id {ProductId}", product.Name, product.Id);
}
public async Task UpdateProductAsync(Product product)
{
if (product == null)
throw new ArgumentNullException(nameof(product));
if (!await _productRepository.ExistsAsync(product.Id))
throw new KeyNotFoundException($"Product with id {product.Id} not found");
product.UpdatedAt = DateTime.UtcNow;
await _productRepository.UpdateAsync(product);
_logger.LogInformation("Product with id {ProductId} updated", product.Id);
}
public async Task DeleteProductAsync(int id)
{
var product = await _productRepository.GetByIdAsync(id);
if (product == null)
throw new KeyNotFoundException($"Product with id {id} not found");
await _productRepository.DeleteAsync(product);
_logger.LogInformation("Product with id {ProductId} deleted", id);
}
public async Task<StockInfo> GetStockInfoAsync(int productId)
{
if (!await _productRepository.ExistsAsync(productId))
throw new KeyNotFoundException($"Product with id {productId} not found");
return await _productRepository.GetStockInfoAsync(productId);
}
public async Task AdjustStockAsync(int productId, int warehouseId, int quantityChange, string batchNumber, DateTime? expirationDate)
{
if (quantityChange == 0)
return;
var product = await _productRepository.GetByIdAsync(productId);
if (product == null)
throw new KeyNotFoundException($"Product with id {productId} not found");
var warehouse = await _warehouseRepository.GetByIdAsync(warehouseId);
if (warehouse == null)
throw new KeyNotFoundException($"Warehouse with id {warehouseId} not found");
var stockItem = await _context.StockItems
.FirstOrDefaultAsync(si => si.ProductId == productId &&
si.WarehouseId == warehouseId &&
si.BatchNumber == batchNumber);
if (stockItem == null)
{
if (quantityChange < 0)
throw new InvalidOperationException("Cannot reduce stock for non-existent batch");
if (!expirationDate.HasValue)
throw new ArgumentException("Expiration date is required for new batch");
stockItem = new StockItem
{
ProductId = productId,
WarehouseId = warehouseId,
Quantity = quantityChange,
BatchNumber = batchNumber,
ExpirationDate = expirationDate.Value,
ArrivalDate = DateTime.UtcNow,
PurchasePrice = 0 // Можно добавить логику для установки цены
};
await _stockItemRepository.AddAsync(stockItem);
}
else
{
var newQuantity = stockItem.Quantity + quantityChange;
if (newQuantity < 0)
throw new InvalidOperationException("Insufficient stock");
stockItem.Quantity = newQuantity;
await _stockItemRepository.UpdateAsync(stockItem);
}
_logger.LogInformation("Stock adjusted for product {ProductId} in warehouse {WarehouseId}. Change: {QuantityChange}",
productId, warehouseId, quantityChange);
}
}
}
```
## 4. Pharmacy.Web (Веб-интерфейс)
```csharp
// Pharmacy.Web/Controllers/ProductsController.cs
namespace Pharmacy.Web.Controllers
{
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
private readonly IMapper _mapper;
public ProductsController(IProductService productService, IMapper mapper)
{
_productService = productService;
_mapper = mapper;
}
[HttpGet]
[AllowAnonymous]
public async Task<ActionResult<IEnumerable<ProductDto>>> GetProducts(
[FromQuery] string searchTerm = "",
[FromQuery] ProductCategory? category = null)
{
var products = await _productService.SearchProductsAsync(searchTerm, category);
return Ok(_mapper.Map<IEnumerable<ProductDto>>(products));
}
[HttpGet("{id}")]
[AllowAnonymous]
public async Task<ActionResult<ProductDetailsDto>> GetProduct(int id)
{
var product = await _productService.GetProductByIdAsync(id);
var stockInfo = await _productService.GetStockInfoAsync(id);
var productDetails = _mapper.Map<ProductDetailsDto>(product);
productDetails.StockInfo = _mapper.Map<StockInfoDto>(stockInfo);
return Ok(productDetails);
}
[HttpPost]
[Authorize(Roles = "Admin,Manager")]
public async Task<ActionResult<ProductDto>> CreateProduct(ProductCreateDto productDto)
{
var product = _mapper.Map<Product>(productDto);
await _productService.AddProductAsync(product);
return CreatedAtAction(nameof(GetProduct),
new { id = product.Id },
_mapper.Map<ProductDto>(product));
}
[HttpPut("{id}")]
[Authorize(Roles = "Admin,Manager")]
public async Task<IActionResult> UpdateProduct(int id, ProductUpdateDto productDto)
{
if (id != productDto.Id)
return BadRequest();
var product = await _productService.GetProductByIdAsync(id);
_mapper.Map(productDto, product);
await _productService.UpdateProductAsync(product);
return NoContent();
}
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteProduct(int id)
{
await _productService.DeleteProductAsync(id);
return NoContent();
}
[HttpPost("{id}/stock")]
[Authorize(Roles = "Admin,Manager")]
public async Task<IActionResult> AdjustStock(int id, StockAdjustmentDto adjustment)
{
await _productService.AdjustStockAsync(
id,
adjustment.WarehouseId,
adjustment.Quantity,
adjustment.BatchNumber,
adjustment.ExpirationDate);
return NoContent();
}
}
}
// Pharmacy.Web/DTOs/ProductDto.cs
namespace Pharmacy.Web.DTOs
{
public class ProductDto
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public string ActiveSubstance { get; set; }
public string Manufacturer { get; set; }
public string Barcode { get; set; }
public ProductCategory Category { get; set; }
public bool IsPrescriptionRequired { get; set; }
public decimal Price { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
public class ProductDetailsDto : ProductDto
{
public StockInfoDto StockInfo { get; set; }
public IEnumerable<ProductImageDto> Images { get; set; }
}
public class ProductCreateDto
{
[Required, MaxLength(100)]
public string Name { get; set; }
[MaxLength(500)]
public string Description { get; set; }
[Required, MaxLength(100)]
public string ActiveSubstance { get; set; }
[Required, MaxLength(100)]
public string Manufacturer { get; set; }
[Required, MaxLength(50)]
public string Barcode { get; set; }
[Required]
public ProductCategory Category { get; set; }
public bool IsPrescriptionRequired { get; set; }
[Range(0, 100000)]
public decimal Price { get; set; }
}
public class ProductUpdateDto
{
public int Id { get; set; }
[Required, MaxLength(100)]
public string Name { get; set; }
[MaxLength(500)]
public string Description { get; set; }
public bool IsPrescriptionRequired { get; set; }
[Range(0, 100000)]
public decimal Price { get; set; }
}
public class StockInfoDto
{
public int TotalQuantity { get; set; }
public IEnumerable<StockItemDto> Items { get; set; }
}
public class StockItemDto
{
public int WarehouseId { get; set; }
public string WarehouseName { get; set; }
public int Quantity { get; set; }
public string BatchNumber { get; set; }
public DateTime ExpirationDate { get; set; }
}
public class StockAdjustmentDto
{
[Required]
public int WarehouseId { get; set; }
[Required]
public int Quantity { get; set; }
[Required]
public string BatchNumber { get; set; }
public DateTime? ExpirationDate { get; set; }
}
public class ProductImageDto
{
public int Id { get; set; }
public string Url { get; set; }
public bool IsMain { get; set; }
}
}
```
## 5. Startup и конфигурация
```csharp
// Pharmacy.Web/Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add services to the container
builder.Services.AddDbContext<PharmacyDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddAutoMapper(typeof(MappingProfile));
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Pharmacy API", Version = "v1" });
});
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8
.GetBytes(builder.Configuration.GetSection("AppSettings:Token").Value)),
ValidateIssuer = false,
ValidateAudience = false
};
});
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
// Seed database
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
try
{
var context = services.GetRequiredService<PharmacyDbContext>();
context.Database.Migrate();
await Seed.SeedData(context);
}
catch (Exception ex)
{
var logger = services.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "An error occurred during migration");
}
}
app.Run();
```
## 6. Дополнительные компоненты
```csharp
// Pharmacy.Core/Mapping/MappingProfile.cs
namespace Pharmacy.Core.Mapping
{
public class MappingProfile : Profile
{
public MappingProfile()
{
CreateMap<Product, ProductDto>();
CreateMap<ProductCreateDto, Product>();
CreateMap<ProductUpdateDto, Product>();
CreateMap<StockItem, StockItemDto>()
.ForMember(dest => dest.WarehouseName, opt => opt.MapFrom(src => src.Warehouse.Name));
CreateMap<StockInfo, StockInfoDto>();
}
}
}
// Pharmacy.Data/Seed.cs
namespace Pharmacy.Data
{
public static class Seed
{
public static async Task SeedData(PharmacyDbContext context)
{
if (!await context.Warehouses.AnyAsync())
{
var warehouses = new List<Warehouse>
{
new Warehouse { Name = "Основной склад", Address = "ул. Складская, 1", Capacity = 1000 },
new Warehouse { Name = "Дополнительный склад", Address = "ул. Запасная, 5", Capacity = 500 }
};
await context.Warehouses.AddRangeAsync(warehouses);
await context.SaveChangesAsync();
}
if (!await context.Products.AnyAsync())
{
var products = new List<Product>
{
new Product {
Name = "Аспирин",
Description = "Обезболивающее и жаропонижающее средство",
ActiveSubstance = "Ацетилсалициловая кислота",
Manufacturer = "Bayer",
Barcode = "123456789012",
Category = ProductCategory.Medicines,
IsPrescriptionRequired = false,
Price = 150.50m
},
new Product {
Name = "Нурофен",
Description = "Обезболивающее, жаропонижающее и противовоспалительное средство",
ActiveSubstance = "Ибупрофен",
Manufacturer = "Reckitt Benckiser",
Barcode = "987654321098",
Category = ProductCategory.Medicines,
IsPrescriptionRequired = false,
Price = 220.75m
}
};
await context.Products.AddRangeAsync(products);
await context.SaveChangesAsync();
var stockItems = new List<StockItem>
{
new StockItem {
ProductId = 1,
WarehouseId = 1,
Quantity = 100,
BatchNumber = "ASP202301",
ExpirationDate = DateTime.UtcNow.AddYears(2),
ArrivalDate = DateTime.UtcNow.AddMonths(-1),
PurchasePrice = 120.00m
},
new StockItem {
ProductId = 2,
WarehouseId = 1,
Quantity = 50,
BatchNumber = "NUR202302",
ExpirationDate = DateTime.UtcNow.AddYears(3),
ArrivalDate = DateTime.UtcNow.AddMonths(-2),
PurchasePrice = 180.00m
}
};
await context.StockItems.AddRangeAsync(stockItems);
await context.SaveChangesAsync();
}
}
}
}
```
## 7. Pharmacy.Tests (Тесты)
```csharp
// Pharmacy.Tests/Services/ProductServiceTests.cs
namespace Pharmacy.Tests.Services
{
public class ProductServiceTests
{
private readonly Mock<IProductRepository> _mockProductRepo;
private readonly Mock<IRepository<StockItem>> _mockStockItemRepo;
private readonly Mock<IRepository<Warehouse>> _mockWarehouseRepo;
private readonly Mock<ILogger<ProductService>> _mockLogger;
private readonly ProductService _service;
public ProductServiceTests()
{
_mockProductRepo = new Mock<IProductRepository>();
_mockStockItemRepo = new Mock<IRepository<StockItem>>();
_mockWarehouseRepo = new Mock<IRepository<Warehouse>>();
_mockLogger = new Mock<ILogger<ProductService>>();
_service = new ProductService(
_mockProductRepo.Object,
_mockStockItemRepo.Object,
_mockWarehouseRepo.Object,
_mockLogger.Object);
}
[Fact]
public async Task GetProductByIdAsync_ProductExists_ReturnsProduct()
{
// Arrange
var productId = 1;
var expectedProduct = new Product { Id = productId, Name = "Test Product" };
_mockProductRepo.Setup(x => x.GetByIdAsync(productId))
.ReturnsAsync(expectedProduct);
// Act
var result = await _service.GetProductByIdAsync(productId);
// Assert
result.Should().NotBeNull();
result.Id.Should().Be(productId);
result.Name.Should().Be(expectedProduct.Name);
}
[Fact]
public async Task AdjustStockAsync_NewBatch_AddsStockItem()
{
// Arrange
var productId = 1;
var warehouseId = 1;
var quantity = 10;
var batchNumber = "BATCH001";
var expirationDate = DateTime.UtcNow.AddYears(1);
_mockProductRepo.Setup(x => x.GetByIdAsync(productId))
.ReturnsAsync(new Product { Id = productId });
_mockWarehouseRepo.Setup(x => x.GetByIdAsync(warehouseId))
.ReturnsAsync(new Warehouse { Id = warehouseId });
_mockStockItemRepo.Setup(x => x.GetAllAsync())
.ReturnsAsync(new List<StockItem>());
// Act
await _service.AdjustStockAsync(productId, warehouseId, quantity, batchNumber, expirationDate);
// Assert
_mockStockItemRepo.Verify(x => x.AddAsync(It.Is<StockItem>(si =>
si.ProductId == productId &&
si.WarehouseId == warehouseId &&
si.Quantity == quantity &&
si.BatchNumber == batchNumber)),
Times.Once);
}
}
}
```