diff --git a/deploy/nginx.conf b/deploy/nginx.conf index ffc581a..96a2e03 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -3,6 +3,21 @@ server { root /usr/share/nginx/html; index index.html; + # Long-running admin imports (MoySklad etc.) read from upstream for tens of + # minutes. Bump timeouts only on that path so normal API stays snappy. + location /api/admin/import/ { + proxy_pass http://api:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60m; + proxy_send_timeout 60m; + proxy_request_buffering off; + proxy_buffering off; + } + # API reverse-proxy — upstream name "api" resolves in the compose network. location /api/ { proxy_pass http://api:8080; diff --git a/src/food-market.api/Controllers/Admin/AdminCleanupController.cs b/src/food-market.api/Controllers/Admin/AdminCleanupController.cs index 600819a..076b509 100644 --- a/src/food-market.api/Controllers/Admin/AdminCleanupController.cs +++ b/src/food-market.api/Controllers/Admin/AdminCleanupController.cs @@ -1,3 +1,6 @@ +using foodmarket.Api.Infrastructure.Tenancy; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Infrastructure.Integrations.MoySklad; using foodmarket.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,8 +16,21 @@ namespace foodmarket.Api.Controllers.Admin; public class AdminCleanupController : ControllerBase { private readonly AppDbContext _db; + private readonly IServiceScopeFactory _scopes; + private readonly ImportJobRegistry _jobs; + private readonly ITenantContext _tenant; - public AdminCleanupController(AppDbContext db) => _db = db; + public AdminCleanupController( + AppDbContext db, + IServiceScopeFactory scopes, + ImportJobRegistry jobs, + ITenantContext tenant) + { + _db = db; + _scopes = scopes; + _jobs = jobs; + _tenant = tenant; + } public record CleanupStats( int Counterparties, @@ -72,6 +88,61 @@ await _db.StockMovements return new CleanupResult("counterparties", Diff(before, after)); } + // Async-версия полной очистки: возвращает jobId, реальные DELETE в фоне, прогресс в Stage+Deleted. + [HttpPost("all/async")] + public ActionResult WipeAllAsync() + { + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); + var job = _jobs.Create("cleanup-all"); + job.Stage = "Подготовка…"; + _ = Task.Run(async () => + { + try + { + using var tenantScope = HttpContextTenantContext.UseOverride(orgId); + using var scope = _scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var steps = new (string Stage, Func> Run)[] + { + ("Движения склада", () => db.StockMovements.ExecuteDeleteAsync()), + ("Остатки", () => db.Stocks.ExecuteDeleteAsync()), + ("Строки поставок", () => db.SupplyLines.ExecuteDeleteAsync()), + ("Поставки", () => db.Supplies.ExecuteDeleteAsync()), + ("Строки продаж", () => db.RetailSaleLines.ExecuteDeleteAsync()), + ("Продажи", () => db.RetailSales.ExecuteDeleteAsync()), + ("Изображения товаров", () => db.ProductImages.ExecuteDeleteAsync()), + ("Цены товаров", () => db.ProductPrices.ExecuteDeleteAsync()), + ("Штрихкоды", () => db.ProductBarcodes.ExecuteDeleteAsync()), + ("Товары", () => db.Products.ExecuteDeleteAsync()), + ("Группы товаров", () => db.ProductGroups.ExecuteDeleteAsync()), + ("Контрагенты", () => db.Counterparties.ExecuteDeleteAsync()), + }; + + foreach (var (stage, run) in steps) + { + job.Stage = $"Удаление: {stage}…"; + job.Deleted += await run(); + } + + job.Stage = "Готово"; + job.Message = $"Удалено записей: {job.Deleted}."; + job.Status = ImportJobStatus.Succeeded; + } + catch (Exception ex) + { + job.Status = ImportJobStatus.Failed; + job.Message = ex.Message; + job.Errors.Add(ex.ToString()); + } + finally + { + job.FinishedAt = DateTime.UtcNow; + } + }); + return Ok(new { jobId = job.Id }); + } + /// Полная очистка данных текущей организации — всё кроме настроек: /// остаются Organization, пользователи, справочники (Country, Currency, UnitOfMeasure, /// PriceType), Store и RetailPoint. Удаляются: Product*, ProductGroup, Counterparty, diff --git a/src/food-market.api/Controllers/Admin/AdminJobsController.cs b/src/food-market.api/Controllers/Admin/AdminJobsController.cs new file mode 100644 index 0000000..c92203f --- /dev/null +++ b/src/food-market.api/Controllers/Admin/AdminJobsController.cs @@ -0,0 +1,41 @@ +using foodmarket.Infrastructure.Integrations.MoySklad; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace foodmarket.Api.Controllers.Admin; + +[ApiController] +[Authorize(Policy = "AdminAccess")] +[Route("api/admin/jobs")] +public class AdminJobsController : ControllerBase +{ + private readonly ImportJobRegistry _jobs; + public AdminJobsController(ImportJobRegistry jobs) => _jobs = jobs; + + public record JobView( + Guid Id, + string Kind, + string Status, + string? Stage, + DateTime StartedAt, + DateTime? FinishedAt, + int Total, int Created, int Updated, int Skipped, int Deleted, int GroupsCreated, + string? Message, + IReadOnlyList Errors); + + private static JobView Project(ImportJobProgress j) => new( + j.Id, j.Kind, j.Status.ToString(), j.Stage, j.StartedAt, j.FinishedAt, + j.Total, j.Created, j.Updated, j.Skipped, j.Deleted, j.GroupsCreated, + j.Message, j.Errors.TakeLast(20).ToList()); + + [HttpGet("{id:guid}")] + public ActionResult Get(Guid id) + { + var j = _jobs.Get(id); + return j is null ? NotFound() : Project(j); + } + + [HttpGet("recent")] + public IReadOnlyList Recent([FromQuery] int take = 10) + => _jobs.RecentlyFinished(take).Select(Project).ToList(); +} diff --git a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs index 591b214..75567ca 100644 --- a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs +++ b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs @@ -1,6 +1,10 @@ +using foodmarket.Api.Infrastructure.Tenancy; +using foodmarket.Application.Common.Tenancy; using foodmarket.Infrastructure.Integrations.MoySklad; +using foodmarket.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; namespace foodmarket.Api.Controllers.Admin; @@ -9,20 +13,57 @@ namespace foodmarket.Api.Controllers.Admin; [Route("api/admin/moysklad")] public class MoySkladImportController : ControllerBase { + private readonly IServiceScopeFactory _scopes; private readonly MoySkladImportService _svc; + private readonly ImportJobRegistry _jobs; + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; - public MoySkladImportController(MoySkladImportService svc) => _svc = svc; + public MoySkladImportController( + IServiceScopeFactory scopes, + MoySkladImportService svc, + ImportJobRegistry jobs, + AppDbContext db, + ITenantContext tenant) + { + _scopes = scopes; + _svc = svc; + _jobs = jobs; + _db = db; + _tenant = tenant; + } public record TestRequest(string Token); - public record ImportRequest(string Token, bool OverwriteExisting = false); + public record ImportRequest(string? Token = null, bool OverwriteExisting = false); + public record SettingsDto(bool HasToken, string? Masked); + public record SettingsInput(string Token); + + [HttpGet("settings")] + public async Task> GetSettings(CancellationToken ct) + { + var token = await ReadTokenFromOrgAsync(ct); + return new SettingsDto(!string.IsNullOrEmpty(token), Mask(token)); + } + + [HttpPut("settings")] + public async Task> SetSettings([FromBody] SettingsInput input, CancellationToken ct) + { + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); + var org = await _db.Organizations.FirstOrDefaultAsync(o => o.Id == orgId, ct); + if (org is null) return NotFound(); + org.MoySkladToken = string.IsNullOrWhiteSpace(input.Token) ? null : input.Token.Trim(); + await _db.SaveChangesAsync(ct); + return new SettingsDto(!string.IsNullOrEmpty(org.MoySkladToken), Mask(org.MoySkladToken)); + } [HttpPost("test")] public async Task TestConnection([FromBody] TestRequest req, CancellationToken ct) { - if (string.IsNullOrWhiteSpace(req.Token)) + var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token; + if (string.IsNullOrWhiteSpace(token)) return BadRequest(new { error = "Token is required." }); - var result = await _svc.TestConnectionAsync(req.Token, ct); + var result = await _svc.TestConnectionAsync(token, ct); if (!result.Success) { var msg = result.StatusCode switch @@ -36,26 +77,91 @@ public async Task TestConnection([FromBody] TestRequest req, Canc return Ok(new { organization = result.Value!.Name, inn = result.Value.Inn }); } - private static string? Truncate(string? s, int max) - => string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max] + "…"); + // Async launch — возвращает jobId, реальная работа в фоне. Клиент полит /jobs/{id}. [HttpPost("import-products")] - public async Task> ImportProducts([FromBody] ImportRequest req, CancellationToken ct) + public async Task> ImportProducts([FromBody] ImportRequest req, CancellationToken ct) { - if (string.IsNullOrWhiteSpace(req.Token)) - return BadRequest(new { error = "Token is required." }); + var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token; + if (string.IsNullOrWhiteSpace(token)) + return BadRequest(new { error = "Токен не настроен. Сохраните его в /admin/moysklad/settings." }); + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); - var result = await _svc.ImportProductsAsync(req.Token, req.OverwriteExisting, ct); - return result; + var job = _jobs.Create("products"); + job.Stage = "Подключение к MoySklad…"; + _ = RunInBackgroundAsync(job, async (svc, progress, ctInner) => + { + progress.Stage = "Импорт товаров…"; + var result = await svc.ImportProductsAsync(token, req.OverwriteExisting, ctInner, progress); + progress.Message = $"Готово: {result.Created} записей (создано/обновлено), {result.Skipped} пропущено, {result.GroupsCreated} групп."; + }, orgId); + return Ok(new { jobId = job.Id }); } [HttpPost("import-counterparties")] - public async Task> ImportCounterparties([FromBody] ImportRequest req, CancellationToken ct) + public async Task> ImportCounterparties([FromBody] ImportRequest req, CancellationToken ct) { - if (string.IsNullOrWhiteSpace(req.Token)) - return BadRequest(new { error = "Token is required." }); + var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token; + if (string.IsNullOrWhiteSpace(token)) + return BadRequest(new { error = "Токен не настроен. Сохраните его в /admin/moysklad/settings." }); + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); - var result = await _svc.ImportCounterpartiesAsync(req.Token, req.OverwriteExisting, ct); - return result; + var job = _jobs.Create("counterparties"); + job.Stage = "Подключение к MoySklad…"; + _ = RunInBackgroundAsync(job, async (svc, progress, ctInner) => + { + progress.Stage = "Импорт контрагентов…"; + var result = await svc.ImportCounterpartiesAsync(token, req.OverwriteExisting, ctInner, progress); + progress.Message = $"Готово: {result.Created} записей, {result.Skipped} пропущено."; + }, orgId); + return Ok(new { jobId = job.Id }); } + + private Task RunInBackgroundAsync( + ImportJobProgress job, + Func work, + Guid orgId) + { + return Task.Run(async () => + { + try + { + using var tenantScope = HttpContextTenantContext.UseOverride(orgId); + using var scope = _scopes.CreateScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + await work(svc, job, CancellationToken.None); + job.Status = ImportJobStatus.Succeeded; + } + catch (Exception ex) + { + job.Status = ImportJobStatus.Failed; + job.Message = ex.Message; + job.Errors.Add(ex.ToString()); + } + finally + { + job.FinishedAt = DateTime.UtcNow; + } + }); + } + + private async Task ReadTokenFromOrgAsync(CancellationToken ct) + { + var orgId = _tenant.OrganizationId; + if (orgId is null) return null; + return await _db.Organizations + .Where(o => o.Id == orgId) + .Select(o => o.MoySkladToken) + .FirstOrDefaultAsync(ct); + } + + private static string? Mask(string? token) + { + if (string.IsNullOrEmpty(token)) return null; + if (token.Length <= 8) return new string('•', token.Length); + return token[..4] + new string('•', 8) + token[^4..]; + } + + private static string? Truncate(string? s, int max) + => string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max] + "…"); } diff --git a/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs b/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs index 9f47c82..6380156 100644 --- a/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs +++ b/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs @@ -8,6 +8,22 @@ public class HttpContextTenantContext : ITenantContext public const string OrganizationClaim = "org_id"; public const string SuperAdminRole = "SuperAdmin"; + // Override для background задач (например, импорт из MoySklad): сохраняем tenant + // в AsyncLocal на время выполнения фонового Task. HttpContext там отсутствует, + // но query-filter'у по-прежнему нужен orgId — вот его и берём из override. + private static readonly AsyncLocal<(Guid? OrgId, bool IsSuper)?> _override = new(); + + public static IDisposable UseOverride(Guid orgId, bool isSuperAdmin = false) + { + _override.Value = (orgId, isSuperAdmin); + return new OverrideScope(); + } + + private sealed class OverrideScope : IDisposable + { + public void Dispose() => _override.Value = null; + } + private readonly IHttpContextAccessor _accessor; public HttpContextTenantContext(IHttpContextAccessor accessor) @@ -15,14 +31,29 @@ public HttpContextTenantContext(IHttpContextAccessor accessor) _accessor = accessor; } - public bool IsAuthenticated => _accessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false; + public bool IsAuthenticated + { + get + { + if (_override.Value is not null) return true; + return _accessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false; + } + } - public bool IsSuperAdmin => _accessor.HttpContext?.User?.IsInRole(SuperAdminRole) ?? false; + public bool IsSuperAdmin + { + get + { + if (_override.Value is { IsSuper: var s }) return s; + return _accessor.HttpContext?.User?.IsInRole(SuperAdminRole) ?? false; + } + } public Guid? OrganizationId { get { + if (_override.Value is { OrgId: var o }) return o; var claim = _accessor.HttpContext?.User?.FindFirst(OrganizationClaim)?.Value; return Guid.TryParse(claim, out var id) ? id : null; } diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index f152ff2..3c4f757 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -130,6 +130,7 @@ AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate, }); builder.Services.AddScoped(); + builder.Services.AddSingleton(); // Inventory builder.Services.AddScoped(); diff --git a/src/food-market.domain/Organizations/Organization.cs b/src/food-market.domain/Organizations/Organization.cs index 7d4b830..0f6a023 100644 --- a/src/food-market.domain/Organizations/Organization.cs +++ b/src/food-market.domain/Organizations/Organization.cs @@ -11,4 +11,8 @@ public class Organization : Entity public string? Phone { get; set; } public string? Email { get; set; } public bool IsActive { get; set; } = true; + + /// Персональный API-токен MoySklad. Храним per-organization чтобы + /// пользователю не нужно было вводить его каждый раз при импорте. + public string? MoySkladToken { get; set; } } diff --git a/src/food-market.infrastructure/Integrations/MoySklad/ImportJobRegistry.cs b/src/food-market.infrastructure/Integrations/MoySklad/ImportJobRegistry.cs new file mode 100644 index 0000000..ca27e98 --- /dev/null +++ b/src/food-market.infrastructure/Integrations/MoySklad/ImportJobRegistry.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; + +namespace foodmarket.Infrastructure.Integrations.MoySklad; + +public enum ImportJobStatus { Running, Succeeded, Failed, Cancelled } + +public class ImportJobProgress +{ + public Guid Id { get; init; } = Guid.NewGuid(); + public string Kind { get; init; } = ""; // "products" | "counterparties" + public DateTime StartedAt { get; init; } = DateTime.UtcNow; + public DateTime? FinishedAt { get; set; } + public ImportJobStatus Status { get; set; } = ImportJobStatus.Running; + public string? Stage { get; set; } // человекочитаемое описание текущего шага + public int Total { get; set; } // входящих записей от MS (растёт по мере пейджинга) + public int Created { get; set; } + public int Updated { get; set; } + public int Skipped { get; set; } + public int Deleted { get; set; } // для cleanup + public int GroupsCreated { get; set; } + public string? Message { get; set; } // последняя ошибка / финальное сообщение + public List Errors { get; set; } = []; +} + +// Process-memory реестр прогресса фоновых импортов. Один процесс API — одно DI singleton. +// При рестарте контейнера история импортов теряется — для просмотра «вчерашнего» надо +// смотреть логи. На MVP достаточно. +public class ImportJobRegistry +{ + private readonly ConcurrentDictionary _jobs = new(); + + public ImportJobProgress Create(string kind) + { + var job = new ImportJobProgress { Kind = kind }; + _jobs[job.Id] = job; + return job; + } + + public ImportJobProgress? Get(Guid id) => _jobs.TryGetValue(id, out var j) ? j : null; + + public IReadOnlyList RecentlyFinished(int take = 10) => + _jobs.Values + .Where(j => j.FinishedAt is not null) + .OrderByDescending(j => j.FinishedAt) + .Take(take) + .ToList(); +} diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs index 0f9b8ed..70ee8bb 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs @@ -35,9 +35,15 @@ public class MoySkladImportService public Task> TestConnectionAsync(string token, CancellationToken ct) => _client.WhoAmIAsync(token, ct); - public async Task ImportCounterpartiesAsync(string token, bool overwriteExisting, CancellationToken ct) + public async Task ImportCounterpartiesAsync( + string token, + bool overwriteExisting, + CancellationToken ct, + ImportJobProgress? progress = null, + Guid? organizationIdOverride = null) { - var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context."); + var orgId = organizationIdOverride ?? _tenant.OrganizationId + ?? throw new InvalidOperationException("No tenant organization in context."); // MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще — // counterparty entity содержит только group (группа доступа), tags @@ -69,15 +75,17 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) { total++; + if (progress is not null) progress.Total = total; // Архивных не пропускаем — импортируем как IsActive=false (см. ApplyCounterparty). try { if (existingByName.TryGetValue(c.Name, out var existing)) { - if (!overwriteExisting) { skipped++; continue; } + if (!overwriteExisting) { skipped++; if (progress is not null) progress.Skipped = skipped; continue; } ApplyCounterparty(existing, c, ResolveType); updated++; + if (progress is not null) progress.Updated = updated; } else { @@ -86,10 +94,11 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) _db.Counterparties.Add(entity); existingByName[c.Name] = entity; created++; + if (progress is not null) progress.Created = created; } batch++; - if (batch >= 500) + if (batch >= 100) { await _db.SaveChangesAsync(ct); batch = 0; @@ -99,6 +108,7 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) { _log.LogWarning(ex, "Failed to import counterparty {Name}", c.Name); errors.Add($"{c.Name}: {ex.Message}"); + if (progress is not null) progress.Errors = errors; } } @@ -127,9 +137,12 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) public async Task ImportProductsAsync( string token, bool overwriteExisting, - CancellationToken ct) + CancellationToken ct, + ImportJobProgress? progress = null, + Guid? organizationIdOverride = null) { - var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context."); + var orgId = organizationIdOverride ?? _tenant.OrganizationId + ?? throw new InvalidOperationException("No tenant organization in context."); // Pre-load tenant defaults. KZ default VAT is 16% — applied when product didn't // carry its own vat from MoySklad. @@ -171,6 +184,7 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) groupsCreated++; } if (groupsCreated > 0) await _db.SaveChangesAsync(ct); + if (progress is not null) progress.GroupsCreated = groupsCreated; // Import products var errors = new List(); @@ -189,6 +203,7 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) await foreach (var p in _client.StreamProductsAsync(token, ct)) { total++; + if (progress is not null) progress.Total = total; // Архивных не пропускаем — импортируем как IsActive=false. var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article; @@ -197,6 +212,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct)) if (alreadyByArticle && !overwriteExisting) { skipped++; + if (progress is not null) progress.Skipped = skipped; continue; } @@ -229,6 +245,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct)) product.IsActive = !p.Archived; product.PurchasePrice = p.BuyPrice is null ? product.PurchasePrice : p.BuyPrice.Value / 100m; updated++; + if (progress is not null) progress.Updated = updated; } else { @@ -271,6 +288,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct)) _db.Products.Add(product); if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product; created++; + if (progress is not null) progress.Created = created; } // Flush чаще (каждые 100) чтобы при сетевом обрыве на следующей странице @@ -281,6 +299,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct)) { _log.LogWarning(ex, "Failed to import MoySklad product {Name}", p.Name); errors.Add($"{p.Name}: {ex.Message}"); + if (progress is not null) progress.Errors = errors; } } diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index cef1341..f573264 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -69,6 +69,7 @@ protected override void OnModelCreating(ModelBuilder builder) b.Property(o => o.Name).HasMaxLength(200).IsRequired(); b.Property(o => o.CountryCode).HasMaxLength(2).IsRequired(); b.Property(o => o.Bin).HasMaxLength(20); + b.Property(o => o.MoySkladToken).HasMaxLength(200); b.HasIndex(o => o.Name); }); diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260423234500_Phase3_OrganizationMoySkladToken.Designer.cs b/src/food-market.infrastructure/Persistence/Migrations/20260423234500_Phase3_OrganizationMoySkladToken.Designer.cs new file mode 100644 index 0000000..8294946 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260423234500_Phase3_OrganizationMoySkladToken.Designer.cs @@ -0,0 +1,1859 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260423234500_Phase3_OrganizationMoySkladToken")] + partial class Phase3_OrganizationMoySkladToken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("public") + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Counterparty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("BankAccount") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BankName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Bik") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Bin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ContactPerson") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CountryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Iin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LegalName") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TaxNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CountryId"); + + b.HasIndex("OrganizationId", "Bin"); + + b.HasIndex("OrganizationId", "Name"); + + b.ToTable("counterparties", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Country", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("countries", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Currency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MinorUnit") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("currencies", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.PriceType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("IsRetail") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("price_types", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Article") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CountryOfOriginId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultSupplierId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMarked") + .HasColumnType("boolean"); + + b.Property("IsService") + .HasColumnType("boolean"); + + b.Property("IsWeighed") + .HasColumnType("boolean"); + + b.Property("MaxStock") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("MinStock") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductGroupId") + .HasColumnType("uuid"); + + b.Property("PurchaseCurrencyId") + .HasColumnType("uuid"); + + b.Property("PurchasePrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UnitOfMeasureId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Vat") + .HasColumnType("integer"); + + b.Property("VatEnabled") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("CountryOfOriginId"); + + b.HasIndex("DefaultSupplierId"); + + b.HasIndex("ProductGroupId"); + + b.HasIndex("PurchaseCurrencyId"); + + b.HasIndex("UnitOfMeasureId"); + + b.HasIndex("OrganizationId", "Article"); + + b.HasIndex("OrganizationId", "IsActive"); + + b.HasIndex("OrganizationId", "Name"); + + b.HasIndex("OrganizationId", "ProductGroupId"); + + b.ToTable("products", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductBarcode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("OrganizationId", "Code") + .IsUnique(); + + b.ToTable("product_barcodes", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("OrganizationId", "ParentId"); + + b.HasIndex("OrganizationId", "Path"); + + b.ToTable("product_groups", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsMain") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("product_images", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PriceTypeId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyId"); + + b.HasIndex("PriceTypeId"); + + b.HasIndex("ProductId", "PriceTypeId") + .IsUnique(); + + b.ToTable("product_prices", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.RetailPoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FiscalRegNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("FiscalSerial") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("StoreId"); + + b.HasIndex("OrganizationId", "Name"); + + b.ToTable("retail_points", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMain") + .HasColumnType("boolean"); + + b.Property("ManagerName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name"); + + b.ToTable("stores", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.UnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Code") + .IsUnique(); + + b.ToTable("units_of_measure", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReservedQuantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("StoreId"); + + b.HasIndex("OrganizationId", "StoreId"); + + b.HasIndex("OrganizationId", "ProductId", "StoreId") + .IsUnique(); + + b.ToTable("stocks", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.StockMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("DocumentId") + .HasColumnType("uuid"); + + b.Property("DocumentNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DocumentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("StoreId"); + + b.HasIndex("DocumentType", "DocumentId"); + + b.HasIndex("OrganizationId", "ProductId", "OccurredAt"); + + b.HasIndex("OrganizationId", "StoreId", "OccurredAt"); + + b.ToTable("stock_movements", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Bin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CountryCode") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MoySkladToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("organizations", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PostedByUserId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("SupplierInvoiceDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupplierInvoiceNumber") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Total") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyId"); + + b.HasIndex("StoreId"); + + b.HasIndex("SupplierId"); + + b.HasIndex("OrganizationId", "Date"); + + b.HasIndex("OrganizationId", "Number") + .IsUnique(); + + b.HasIndex("OrganizationId", "Status"); + + b.HasIndex("OrganizationId", "SupplierId"); + + b.ToTable("supplies", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.SupplyLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("SupplyId") + .HasColumnType("uuid"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("SupplyId"); + + b.HasIndex("OrganizationId", "ProductId"); + + b.ToTable("supply_lines", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CashierUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyId") + .HasColumnType("uuid"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaidCard") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("PaidCash") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Payment") + .HasColumnType("integer"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PostedByUserId") + .HasColumnType("uuid"); + + b.Property("RetailPointId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Total") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("RetailPointId"); + + b.HasIndex("StoreId"); + + b.HasIndex("OrganizationId", "CashierUserId"); + + b.HasIndex("OrganizationId", "Date"); + + b.HasIndex("OrganizationId", "Number") + .IsUnique(); + + b.HasIndex("OrganizationId", "Status"); + + b.ToTable("retail_sales", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Discount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RetailSaleId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VatPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("RetailSaleId"); + + b.HasIndex("OrganizationId", "ProductId"); + + b.ToTable("retail_sale_lines", "public"); + }); + + modelBuilder.Entity("foodmarket.Infrastructure.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", "public"); + }); + + modelBuilder.Entity("foodmarket.Infrastructure.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Counterparty", b => + { + b.HasOne("foodmarket.Domain.Catalog.Country", "Country") + .WithMany() + .HasForeignKey("CountryId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Country"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Product", b => + { + b.HasOne("foodmarket.Domain.Catalog.Country", "CountryOfOrigin") + .WithMany() + .HasForeignKey("CountryOfOriginId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.Counterparty", "DefaultSupplier") + .WithMany() + .HasForeignKey("DefaultSupplierId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.ProductGroup", "ProductGroup") + .WithMany() + .HasForeignKey("ProductGroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.Currency", "PurchaseCurrency") + .WithMany() + .HasForeignKey("PurchaseCurrencyId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.UnitOfMeasure", "UnitOfMeasure") + .WithMany() + .HasForeignKey("UnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CountryOfOrigin"); + + b.Navigation("DefaultSupplier"); + + b.Navigation("ProductGroup"); + + b.Navigation("PurchaseCurrency"); + + b.Navigation("UnitOfMeasure"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductBarcode", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany("Barcodes") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductGroup", b => + { + b.HasOne("foodmarket.Domain.Catalog.ProductGroup", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductImage", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany("Images") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductPrice", b => + { + b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency") + .WithMany() + .HasForeignKey("CurrencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.PriceType", "PriceType") + .WithMany() + .HasForeignKey("PriceTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany("Prices") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Currency"); + + b.Navigation("PriceType"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.RetailPoint", b => + { + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.StockMovement", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b => + { + b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency") + .WithMany() + .HasForeignKey("CurrencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Supplier") + .WithMany() + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Currency"); + + b.Navigation("Store"); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.SupplyLine", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Purchases.Supply", "Supply") + .WithMany("Lines") + .HasForeignKey("SupplyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Supply"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency") + .WithMany() + .HasForeignKey("CurrencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.RetailPoint", "RetailPoint") + .WithMany() + .HasForeignKey("RetailPointId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Currency"); + + b.Navigation("Customer"); + + b.Navigation("RetailPoint"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Sales.RetailSale", "RetailSale") + .WithMany("Lines") + .HasForeignKey("RetailSaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("RetailSale"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Product", b => + { + b.Navigation("Barcodes"); + + b.Navigation("Images"); + + b.Navigation("Prices"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductGroup", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.Navigation("Lines"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260423234500_Phase3_OrganizationMoySkladToken.cs b/src/food-market.infrastructure/Persistence/Migrations/20260423234500_Phase3_OrganizationMoySkladToken.cs new file mode 100644 index 0000000..78b32a1 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260423234500_Phase3_OrganizationMoySkladToken.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Добавляем колонку organizations.MoySkladToken — per-tenant API-токен + /// MoySklad. Хранится, чтобы не вводить вручную при каждом импорте. + public partial class Phase3_OrganizationMoySkladToken : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MoySkladToken", + schema: "public", + table: "organizations", + type: "character varying(200)", + maxLength: 200, + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MoySkladToken", + schema: "public", + table: "organizations"); + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 8fe2f64..f187ff2 100644 --- a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -1074,6 +1074,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("boolean"); + b.Property("MoySkladToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + b.Property("Name") .IsRequired() .HasMaxLength(200) diff --git a/src/food-market.web/src/pages/MoySkladImportPage.tsx b/src/food-market.web/src/pages/MoySkladImportPage.tsx index 777975a..8d8ebaf 100644 --- a/src/food-market.web/src/pages/MoySkladImportPage.tsx +++ b/src/food-market.web/src/pages/MoySkladImportPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' -import { useMutation, useQuery, useQueryClient, type UseMutationResult } from '@tanstack/react-query' -import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package, Trash2, AlertTriangle } from 'lucide-react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { AlertCircle, CheckCircle, KeyRound, Users, Package, Trash2, AlertTriangle, Save } from 'lucide-react' import { AxiosError } from 'axios' import { api } from '@/lib/api' import { PageHeader } from '@/components/PageHeader' @@ -12,116 +12,165 @@ function formatError(err: unknown): string { const status = err.response?.status const body = err.response?.data as { error?: string; error_description?: string; title?: string } | undefined const detail = body?.error ?? body?.error_description ?? body?.title - if (status === 404) { - return '404 Not Found — эндпоинт не существует. Вероятно, API не перезапущен после git pull.' - } - if (status === 401) return '401 Unauthorized — сессия истекла, перелогинься.' - if (status === 403) return '403 Forbidden — нужна роль Admin или SuperAdmin.' - if (status === 502 || status === 503) return `${status} — МойСклад недоступен, попробуй позже.` + if (status === 404) return '404 — эндпоинт не существует. API обновлён?' + if (status === 401) return '401 — сессия истекла, перелогинься.' + if (status === 403) return '403 — нужна роль Admin.' + if (status === 502 || status === 503) return `${status} — МойСклад недоступен.` return detail ? `${status ?? ''} ${detail}` : err.message } if (err instanceof Error) return err.message return String(err) } -interface TestResponse { organization: string; inn?: string | null } -interface ImportResponse { - total: number; created: number; skipped: number; groupsCreated: number; errors: string[] +interface SettingsDto { hasToken: boolean; masked: string | null } +interface JobView { + id: string + kind: string + status: 'Running' | 'Succeeded' | 'Failed' | 'Cancelled' + stage: string | null + startedAt: string + finishedAt: string | null + total: number; created: number; updated: number; skipped: number; deleted: number; groupsCreated: number + message: string | null + errors: string[] +} + +function useJob(jobId: string | null) { + return useQuery({ + queryKey: ['admin-job', jobId], + enabled: !!jobId, + queryFn: async () => (await api.get(`/api/admin/jobs/${jobId}`)).data, + refetchInterval: (q) => { + const status = q.state.data?.status + return status === 'Succeeded' || status === 'Failed' ? false : 1500 + }, + }) } export function MoySkladImportPage() { const qc = useQueryClient() - const [token, setToken] = useState('') - const [overwrite, setOverwrite] = useState(false) + + const settings = useQuery({ + queryKey: ['/api/admin/moysklad/settings'], + queryFn: async () => (await api.get('/api/admin/moysklad/settings')).data, + }) + + const [tokenInput, setTokenInput] = useState('') + const [overwrite, setOverwrite] = useState(true) + + const saveToken = useMutation({ + mutationFn: async () => + (await api.put('/api/admin/moysklad/settings', { token: tokenInput })).data, + onSuccess: () => { + setTokenInput('') + qc.invalidateQueries({ queryKey: ['/api/admin/moysklad/settings'] }) + }, + }) const test = useMutation({ - mutationFn: async () => (await api.post('/api/admin/moysklad/test', { token })).data, + mutationFn: async () => + (await api.post<{ organization: string; inn?: string }>('/api/admin/moysklad/test', {})).data, }) - const products = useMutation({ - mutationFn: async () => (await api.post('/api/admin/moysklad/import-products', { token, overwriteExisting: overwrite })).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }), + const [productsJobId, setProductsJobId] = useState(null) + const [counterpartiesJobId, setCounterpartiesJobId] = useState(null) + + const startProducts = useMutation({ + mutationFn: async () => + (await api.post<{ jobId: string }>('/api/admin/moysklad/import-products', { overwriteExisting: overwrite })).data, + onSuccess: (d) => setProductsJobId(d.jobId), + }) + const startCounterparties = useMutation({ + mutationFn: async () => + (await api.post<{ jobId: string }>('/api/admin/moysklad/import-counterparties', { overwriteExisting: overwrite })).data, + onSuccess: (d) => setCounterpartiesJobId(d.jobId), }) - const counterparties = useMutation({ - mutationFn: async () => (await api.post('/api/admin/moysklad/import-counterparties', { token, overwriteExisting: overwrite })).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/counterparties'] }), - }) + const productsJob = useJob(productsJobId) + const counterpartiesJob = useJob(counterpartiesJobId) + + const hasToken = settings.data?.hasToken ?? false return (
-
-
- -
-

Токен не сохраняется — передаётся только в текущий запрос и не пишется ни в БД, ни в логи.

-

Получить токен: online.moysklad.ru/app → Настройки аккаунта → Доступ к API → создать токен.

-

Рекомендуется отдельный сервисный аккаунт с правом только на чтение.

-
-
-
-
- +

Токен API

+ {settings.data && ( +
+ {hasToken ? ( +
+ сохранён:{' '} + {settings.data.masked} +
+ ) : ( +
Ещё не задан — импорт не сработает.
+ )} +
+ )} + setToken(e.target.value)} - placeholder="персональный токен или токен сервисного аккаунта" + value={tokenInput} + onChange={(e) => setTokenInput(e.target.value)} + placeholder="персональный или сервисный токен" autoComplete="off" spellCheck={false} /> - -
+
+ - {test.data && ( -
- - Подключено: {test.data.organization} +
+ Подключено: {test.data.organization} {test.data.inn && (ИНН {test.data.inn})}
)} {test.error &&
{formatError(test.error)}
} + {saveToken.error &&
{formatError(saveToken.error)}
}
-

Операции импорта

+

Операции импорта

- -
- - + {productsJob.data && } + {counterpartiesJob.data && }
@@ -129,19 +178,61 @@ export function MoySkladImportPage() { ) } -interface CleanupStats { - counterparties: number - products: number - productGroups: number - productBarcodes: number - productPrices: number - supplies: number - retailSales: number - stocks: number - stockMovements: number +function JobCard({ title, job }: { title: string; job: JobView }) { + const done = job.status === 'Succeeded' || job.status === 'Failed' + const color = job.status === 'Succeeded' ? 'text-emerald-600' + : job.status === 'Failed' ? 'text-red-600' : 'text-slate-500' + return ( +
+

+ {job.status === 'Succeeded' + ? + : job.status === 'Failed' + ? + : } + {title} + + {job.status === 'Running' ? (job.stage ?? 'идёт…') : job.status} + +

+
+ + + + + +
+ {done && job.message && ( +
{job.message}
+ )} + {job.errors.length > 0 && ( +
+ Ошибок: {job.errors.length} +
    + {job.errors.map((e, i) =>
  • {e}
  • )} +
+
+ )} +
+ ) } -interface CleanupResult { scope: string; deleted: CleanupStats } +function Stat({ label, value, accent }: { label: string; value: number; accent?: 'green' }) { + const bg = accent === 'green' ? 'bg-emerald-50 dark:bg-emerald-950/30' : 'bg-slate-50 dark:bg-slate-800/50' + const fg = accent === 'green' ? 'text-emerald-700 dark:text-emerald-400' : '' + return ( +
+
{label}
+
{value.toLocaleString('ru')}
+
+ ) +} + +interface CleanupStats { + counterparties: number; products: number; productGroups: number + productBarcodes: number; productPrices: number; supplies: number + retailSales: number; stocks: number; stockMovements: number +} function DangerZone() { const qc = useQueryClient() @@ -152,17 +243,14 @@ function DangerZone() { refetchOnMount: 'always', }) - const wipeCounterparties = useMutation({ - mutationFn: async () => (await api.delete('/api/admin/cleanup/counterparties')).data, - onSuccess: () => { - qc.invalidateQueries() - }, - }) + const [wipeJobId, setWipeJobId] = useState(null) + const wipeJob = useJob(wipeJobId) - const wipeAll = useMutation({ - mutationFn: async () => (await api.delete('/api/admin/cleanup/all')).data, - onSuccess: () => { - qc.invalidateQueries() + const startWipe = useMutation({ + mutationFn: async () => (await api.post<{ jobId: string }>('/api/admin/cleanup/all/async')).data, + onSuccess: (d) => { + setWipeJobId(d.jobId) + qc.invalidateQueries({ queryKey: ['/api/admin/cleanup/stats'] }) }, }) @@ -176,79 +264,52 @@ function DangerZone() { return (

- Опасная зона — временные инструменты очистки + Опасная зона — полная очистка данных

- Удаляет данные текущей организации. Справочники (ед. измерения, страны, валюты, типы цен, склады, точки продаж), пользователи и сама организация сохраняются. + Удаляет товары, группы, контрагентов, документы и остатки. Справочники, пользователи, склады и организация сохраняются.

- {s && (
- - - - - - - - - + + + + + + + + +
)} - -
- - -
- - {wipeCounterparties.data && ( -
- Удалено: {wipeCounterparties.data.deleted.counterparties} контрагентов, - {' '}{wipeCounterparties.data.deleted.supplies} поставок, - {' '}{wipeCounterparties.data.deleted.stockMovements} движений. + + {wipeJob.data && ( +
+
+ {wipeJob.data.status === 'Succeeded' && } + {wipeJob.data.status === 'Failed' && } + {wipeJob.data.status === 'Running' && } + {wipeJob.data.stage ?? wipeJob.data.status} + удалено записей: {wipeJob.data.deleted.toLocaleString('ru')} +
+ {wipeJob.data.message &&
{wipeJob.data.message}
}
)} - {wipeAll.data && ( -
- Удалено: {wipeAll.data.deleted.counterparties} контрагентов, - {' '}{wipeAll.data.deleted.products} товаров, - {' '}{wipeAll.data.deleted.productGroups} групп, - {' '}{wipeAll.data.deleted.supplies} поставок, - {' '}{wipeAll.data.deleted.retailSales} чеков, - {' '}{wipeAll.data.deleted.stockMovements} движений. -
- )} - {wipeCounterparties.error && ( -
{formatError(wipeCounterparties.error)}
- )} - {wipeAll.error && ( -
{formatError(wipeAll.error)}
- )}
) } -function Stat({ label, value }: { label: string; value: number }) { +function Tile({ label, value }: { label: string; value: number }) { return (
{label}
@@ -256,50 +317,3 @@ function Stat({ label, value }: { label: string; value: number }) {
) } - -function ImportResult({ title, result }: { title: string; result: UseMutationResult }) { - if (!result.data && !result.error) return null - return ( -
-

- {result.data - ? <> {title} — импорт завершён - : <> {title} — ошибка} -

- {result.data && ( - <> -
- - - - -
- {result.data.errors.length > 0 && ( -
- - Ошибок: {result.data.errors.length} (развернуть) - -
    - {result.data.errors.map((e, i) =>
  • {e}
  • )} -
-
- )} - - )} - {result.error && ( -
{formatError(result.error)}
- )} -
- ) -} - -function StatBox({ label, value, accent }: { label: string; value: number; accent?: 'green' }) { - const bg = accent === 'green' ? 'bg-green-50 dark:bg-green-950/30' : 'bg-slate-50 dark:bg-slate-800/50' - const fg = accent === 'green' ? 'text-green-700 dark:text-green-400' : '' - return ( -
-
{label}
-
{value.toLocaleString('ru')}
-
- ) -}