From e39e4fe1d5d8fc80669e950643a12583844e28eb Mon Sep 17 00:00:00 2001 From: nns Date: Mon, 12 Oct 2026 19:19:47 +0500 Subject: [PATCH] =?UTF-8?q?feat(registry+pricing):=20=C2=AB=D0=A0=D0=B5?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=80=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=C2=BB=20+=20=D1=81=D1=85=D0=B5=D0=BC=D0=B0=20=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=20(=D0=B7=D0=B0=D0=BA=D1=83=D0=BF=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F/=D1=81=D0=B5=D0=B1=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D1=8C/=D0=BF=D1=80=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=D0=B6=D0=BD=D0=B0=D1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit РЕЕСТР ТОВАРОВ (глобальный системный шаблон карточек): - Домен ProductRegistry{Group,Card,Barcode} (Entity, без OrganizationId), миграция Phase47; штрихкод уникален в реестре (ключ новизны). - Наполнение «из МойСклад заново» (команда суперадмина, повторяемая): читает entity/product (архивные/модификации/комплекты/услуги — считает и пропускает), группы из productFolder (путь), штрихкоды всех типов; buyPrice→закупочная+ себестоимость, salePrices→продажная; пересоздаёт только MoySklad-карточки, Shop-карточки не трогает. МойСклад только чтение (пагинация 1000, 429-бэкофф). - Фоновое пополнение из каталогов магазинов-участников (recurring, флаг в PlatformSettings, по умолчанию ВЫКЛ): новые штрихкоды → карточки; фильтр мусора (неверная контр. цифра EAN-13/8, пустое имя); существующие не трогает. - Копия реестра в каталог нового магазина при регистрации — фоновым заданием (Hangfire) сразу после signup; баннер «Каталог загружается…», повторно не копирует. Остатков нет (0). - Флаг организации ContributesToRegistry (демо/автотест — ВЫКЛ миграцией). - SuperAdmin раздел «Реестр товаров»: поиск/правка/удаление карточек, тоггл фонового пополнения (причина+аудит), кнопка наполнения из МойСклад, флаги участия орг. Только SuperAdmin. СХЕМА ЦЕН (решение владельца 12.10): три цены в карточке. - Закупочная: ReferencePrice→PurchasePrice (миграция Phase48 RENAME COLUMN, данные сохранены). Каждая проведённая приёмка пишет закупочную = цена строки; unpost возвращает из предыдущей проведённой приёмки. API отдаёт purchasePrice и временно referencePrice (алиас для iOS). - Себестоимость (Cost): только чтение, скользящее среднее (как было). - Продажная (бывш. «розничная»): меняется приёмкой только при ручной правке в строке. Системный тип цены «Розничная цена»→«Продажная цена» (миграция+сидер), подписи в UI переименованы. - Убраны: автозаполнение эталонной при первой приёмке, ReferencePriceRefreshJob (30-дневная перезапись), понятие «эталонная» в UI. Тесты: SupplyPricingSchemeTests (приёмка без правок → продажная не меняется, закупочная = цена строки, себестоимость средняя; правка продажной в строке; unpost → закупочная вернулась) — 3/3. Co-Authored-By: Claude Opus 4.8 --- .../Background/HangfireJobsConfigurator.cs | 10 + .../Background/ReferencePriceRefreshJob.cs | 60 --- .../Controllers/AuthSignupController.cs | 11 +- .../Controllers/Catalog/ProductsController.cs | 39 +- .../Catalog/RegistryCopyStatusController.cs | 34 ++ .../Purchases/SuppliesController.cs | 28 +- .../SuperAdmin/ProductRegistryController.cs | 199 +++++++++ .../Infrastructure/Validation/Validators.cs | 1 + src/food-market.api/Program.cs | 4 +- src/food-market.api/Seed/BulkCatalogSeeder.cs | 2 +- src/food-market.api/Seed/DemoCatalogSeeder.cs | 2 +- src/food-market.api/Seed/DemoTenantSeeder.cs | 8 +- src/food-market.api/Seed/DevDataSeeder.cs | 2 +- src/food-market.api/Seed/ShowcaseSeeder.cs | 6 +- src/food-market.api/Seed/YearDemoSeeder.cs | 8 +- .../Catalog/CatalogDtos.cs | 6 + .../Mapping/MapsterConfig.cs | 3 +- src/food-market.domain/Catalog/Product.cs | 15 +- .../Organizations/Organization.cs | 12 + .../Platform/PlatformSettings.cs | 14 + .../Registry/ProductRegistry.cs | 71 +++ .../Integrations/MoySklad/MoySkladClient.cs | 16 + .../MoySklad/MoySkladImportService.cs | 4 +- .../Persistence/AppDbContext.cs | 38 ++ .../Configurations/CatalogConfigurations.cs | 2 +- .../20261012030000_Phase47_ProductRegistry.cs | 103 +++++ ...61012040000_Phase48_PurchasePriceRename.cs | 53 +++ .../Registry/ProductRegistryService.cs | 409 ++++++++++++++++++ src/food-market.web/src/App.tsx | 2 + .../src/components/AppLayout.tsx | 2 + .../src/components/RegistryCopyBanner.tsx | 26 ++ .../src/components/SuperAdminLayout.tsx | 3 +- src/food-market.web/src/locales/ru.json | 2 +- .../src/pages/OnboardingWizardPage.tsx | 2 +- .../src/pages/OrganizationSettingsPage.tsx | 2 +- .../src/pages/PriceTypesPage.tsx | 2 +- .../src/pages/ProductEditPage.tsx | 2 +- .../src/pages/ProductGroupsPage.tsx | 4 +- .../src/pages/ProductsPage.tsx | 2 +- .../pages/SuperAdminProductRegistryPage.tsx | 291 +++++++++++++ .../src/pages/SupplyEditPage.tsx | 2 +- .../SupplyPricingSchemeTests.cs | 97 +++++ .../DomainFullPropertyTouchTests.cs | 2 +- 43 files changed, 1477 insertions(+), 124 deletions(-) delete mode 100644 src/food-market.api/Background/ReferencePriceRefreshJob.cs create mode 100644 src/food-market.api/Controllers/Catalog/RegistryCopyStatusController.cs create mode 100644 src/food-market.api/Controllers/SuperAdmin/ProductRegistryController.cs create mode 100644 src/food-market.domain/Registry/ProductRegistry.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20261012030000_Phase47_ProductRegistry.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20261012040000_Phase48_PurchasePriceRename.cs create mode 100644 src/food-market.infrastructure/Registry/ProductRegistryService.cs create mode 100644 src/food-market.web/src/components/RegistryCopyBanner.tsx create mode 100644 src/food-market.web/src/pages/SuperAdminProductRegistryPage.tsx create mode 100644 tests/food-market.IntegrationTests/SupplyPricingSchemeTests.cs diff --git a/src/food-market.api/Background/HangfireJobsConfigurator.cs b/src/food-market.api/Background/HangfireJobsConfigurator.cs index 725c49b..2d6ce6e 100644 --- a/src/food-market.api/Background/HangfireJobsConfigurator.cs +++ b/src/food-market.api/Background/HangfireJobsConfigurator.cs @@ -98,6 +98,16 @@ public Task StartAsync(CancellationToken ct) cronExpression: cronDisk, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + // Реестр товаров: фоновое пополнение из каталогов магазинов-участников. + // Джоб сам проверяет флаг ProductRegistryEnrichEnabled (по умолчанию ВЫКЛ) и + // при выключенном ничего не делает. Каждые 30 минут. + var cronRegistryEnrich = _cfg["Hangfire:Cron:ProductRegistryEnrich"] ?? "*/30 * * * *"; + _jobs.AddOrUpdate( + recurringJobId: "product-registry-enrich", + methodCall: j => j.EnrichFromParticipantsAsync(CancellationToken.None), + cronExpression: cronRegistryEnrich, + options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + // Sprint 22: weekly DB schema documentation generation. var cronSchema = _cfg["Hangfire:Cron:DbSchemaDocs"] ?? "0 5 * * 0"; // Воскресенье 05:00 UTC _jobs.AddOrUpdate( diff --git a/src/food-market.api/Background/ReferencePriceRefreshJob.cs b/src/food-market.api/Background/ReferencePriceRefreshJob.cs deleted file mode 100644 index 505d523..0000000 --- a/src/food-market.api/Background/ReferencePriceRefreshJob.cs +++ /dev/null @@ -1,60 +0,0 @@ -using foodmarket.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace foodmarket.Api.Background; - -/// Раз в сутки переписывает ReferencePrice = Cost для товаров, -/// у которых LastSupplyAt старше 30 дней и Cost > 0. Цель: устаревшая -/// «эталонная» цена не остаётся годами — её сравнивают с актуальной -/// себестоимостью. Если пользователь редактировал ReferencePrice вручную -/// (через PUT /api/catalog/products/...), ReferencePriceUpdatedAt уходит -/// в now, и таймер начинает 30 дней заново. -public class ReferencePriceRefreshJob : BackgroundService -{ - private readonly IServiceProvider _services; - private readonly ILogger _log; - // Запускаем каждые 24 часа. Промах между сутками не критичен — цена - // не падает, а лишь догоняет текущую Cost. - private static readonly TimeSpan Period = TimeSpan.FromHours(24); - - public ReferencePriceRefreshJob(IServiceProvider services, ILogger log) - { - _services = services; - _log = log; - } - - protected override async Task ExecuteAsync(CancellationToken ct) - { - // Ждём 5 минут после старта чтобы не упасть на ещё не применённой миграции. - try { await Task.Delay(TimeSpan.FromMinutes(5), ct); } catch (TaskCanceledException) { return; } - - using var timer = new PeriodicTimer(Period); - do - { - try { await RunOnceAsync(ct); } - catch (Exception ex) { _log.LogError(ex, "ReferencePriceRefreshJob: iteration failed"); } - } - while (await timer.WaitForNextTickAsync(ct)); - } - - public async Task RunOnceAsync(CancellationToken ct) - { - using var scope = _services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var threshold = DateTime.UtcNow.AddDays(-30); - // IgnoreQueryFilters — фоновый job работает над всеми organizations. - var stale = await db.Products - .IgnoreQueryFilters() - .Where(p => p.LastSupplyAt != null && p.LastSupplyAt < threshold && p.Cost > 0m) - .ToListAsync(ct); - if (stale.Count == 0) return; - var now = DateTime.UtcNow; - foreach (var p in stale) - { - p.ReferencePrice = p.Cost; - p.ReferencePriceUpdatedAt = now; - } - await db.SaveChangesAsync(ct); - _log.LogInformation("ReferencePriceRefreshJob: refreshed {Count} stale products", stale.Count); - } -} diff --git a/src/food-market.api/Controllers/AuthSignupController.cs b/src/food-market.api/Controllers/AuthSignupController.cs index 8fec3cf..7de8640 100644 --- a/src/food-market.api/Controllers/AuthSignupController.cs +++ b/src/food-market.api/Controllers/AuthSignupController.cs @@ -2,6 +2,8 @@ using foodmarket.Domain.Organizations; using foodmarket.Infrastructure.Identity; using foodmarket.Infrastructure.Persistence; +using foodmarket.Infrastructure.Registry; +using Hangfire; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -22,10 +24,11 @@ public class AuthSignupController : ControllerBase { private readonly AppDbContext _db; private readonly UserManager _userMgr; + private readonly IBackgroundJobClient _jobs; - public AuthSignupController(AppDbContext db, UserManager userMgr) + public AuthSignupController(AppDbContext db, UserManager userMgr, IBackgroundJobClient jobs) { - _db = db; _userMgr = userMgr; + _db = db; _userMgr = userMgr; _jobs = jobs; } public record SignupInput(string Email, string Password, string OrganizationName, string Phone, string? Plan); @@ -129,7 +132,11 @@ public async Task> Signup([FromBody] SignupInput inpu RoleId = adminRole.Id, IsActive = true, }); org.AccountOwnerUserId = user.Id; + // Копия «Реестра товаров» в каталог магазина — фоновым заданием, регистрация + // и первый вход не ждут. Пока идёт — админка показывает «Каталог загружается…». + org.RegistryCopyState = 1; // InProgress await _db.SaveChangesAsync(ct); + _jobs.Enqueue(s => s.CopyRegistryToOrgAsync(org.Id, CancellationToken.None)); return new SignupResult(org.Id, user.Email); } diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index 5c9ff9e..9d03ae7 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -166,11 +166,11 @@ private async Task ResolveDefaultVatAsync(CancellationToken ct) if (packaging is not null) q = q.Where(p => p.Packaging == packaging); if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked); // referencePriceFrom/To — новый, актуальный параметр; purchasePriceFrom/To - // — обратная совместимость c прежним UI (тоже по ReferencePrice). + // purchasePriceFrom/To — обратная совместимость c прежним UI (закупочная). var refFrom = referencePriceFrom ?? purchasePriceFrom; var refTo = referencePriceTo ?? purchasePriceTo; - if (refFrom is not null) q = q.Where(p => p.ReferencePrice >= refFrom); - if (refTo is not null) q = q.Where(p => p.ReferencePrice <= refTo); + if (refFrom is not null) q = q.Where(p => p.PurchasePrice >= refFrom); + if (refTo is not null) q = q.Where(p => p.PurchasePrice <= refTo); // Фильтр по системной (главной розничной) цене — берём Prices c PriceType.IsSystem=true. if (systemPriceFrom is not null) q = q.Where(p => p.Prices.Any(pr => pr.PriceType!.IsSystem && pr.Amount >= systemPriceFrom)); @@ -197,8 +197,8 @@ private async Task ResolveDefaultVatAsync(CancellationToken ct) ("unit", true) => q.OrderByDescending(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name), ("packaging", false) => q.OrderBy(p => p.Packaging).ThenBy(p => p.Name), ("packaging", true) => q.OrderByDescending(p => p.Packaging).ThenBy(p => p.Name), - ("purchasePrice", false) => q.OrderBy(p => p.ReferencePrice).ThenBy(p => p.Name), - ("purchasePrice", true) => q.OrderByDescending(p => p.ReferencePrice).ThenBy(p => p.Name), + ("purchasePrice", false) => q.OrderBy(p => p.PurchasePrice).ThenBy(p => p.Name), + ("purchasePrice", true) => q.OrderByDescending(p => p.PurchasePrice).ThenBy(p => p.Name), ("cost", false) => q.OrderBy(p => p.Cost).ThenBy(p => p.Name), ("cost", true) => q.OrderByDescending(p => p.Cost).ThenBy(p => p.Name), ("systemPrice", false) => q.OrderBy(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name), @@ -268,7 +268,7 @@ private async Task ResolveDefaultVatAsync(CancellationToken ct) .ToListAsync(ct); var fmt = (format ?? "csv").ToLowerInvariant(); - var headers = new[] { "Название", "Артикул", "Ед.", "Группа", "Штрихкод", "Розн. цена", "Себестоимость", "Статус" }; + var headers = new[] { "Название", "Артикул", "Ед.", "Группа", "Штрихкод", "Прод. цена", "Себестоимость", "Статус" }; return fmt == "xlsx" ? ReportExport.Xlsx(rows, "products", "Товары", headers) : ReportExport.Csv(rows, "products", headers); @@ -309,7 +309,7 @@ public async Task> Create([FromBody] ProductInput input Apply(e, input); e.ProductGroupId = groupId.Value; e.UnitOfMeasureId = unitId.Value; - e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional); + e.PurchasePrice = RoundIfNeeded(e.PurchasePrice, allowFractional); // Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны. if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct); // Авто-артикул: если пользователь не указал — генерируем числовой. @@ -405,7 +405,7 @@ public async Task Update(Guid id, [FromBody] ProductInput input, Apply(e, input); e.ProductGroupId = groupId.Value; e.UnitOfMeasureId = unitId.Value; - e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional); + e.PurchasePrice = RoundIfNeeded(e.PurchasePrice, allowFractional); // Children-collections правим через ExecuteDelete (минует трекер) + // DbSet.Add — иначе EF8 на nav-collection путается и UPDATE на родителя @@ -1110,7 +1110,7 @@ public async Task>> BarcodeDuplicat public record QuickSearchItem( Guid Id, string Name, string? Article, string? DefaultBarcode, - decimal? ReferencePrice, decimal? StockQty); + decimal? PurchasePrice, decimal? StockQty); /// Лёгкий поиск для inline-добавления строк в документы (приёмка, /// продажа). Ранжирует точное совпадение штрихкода → точное артикула → @@ -1138,7 +1138,7 @@ public record QuickSearchItem( // делает накладные расходы пренебрежимыми. var raw = await q.Select(p => new { - p.Id, p.Name, p.Article, p.ReferencePrice, + p.Id, p.Name, p.Article, p.PurchasePrice, Barcodes = p.Barcodes.Select(b => new { b.Code, b.IsPrimary }).ToList(), StockQty = storeId == null ? (decimal?)_db.Stocks.Where(st => st.ProductId == p.Id).Sum(st => (decimal?)st.Quantity) @@ -1161,7 +1161,7 @@ int Rank(string name, string? article, IEnumerable codes) Item = new QuickSearchItem( r.Id, r.Name, r.Article, r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault(), - r.ReferencePrice, r.StockQty), + r.PurchasePrice, r.StockQty), Rank = Rank(r.Name, r.Article, r.Barcodes.Select(b => b.Code)), }) .OrderBy(x => x.Rank).ThenBy(x => x.Item.Name) @@ -1214,7 +1214,7 @@ private async Task> FindByExactBarcodeAsync(string code, G .Where(p => p.Barcodes.Any(b => b.Code == code)) .Select(p => new { - p.Id, p.Name, p.Article, p.ReferencePrice, + p.Id, p.Name, p.Article, p.PurchasePrice, Barcodes = p.Barcodes.Select(b => new { b.Code, b.IsPrimary }).ToList(), StockQty = storeId == null ? (decimal?)_db.Stocks.Where(st => st.ProductId == p.Id).Sum(st => (decimal?)st.Quantity) @@ -1225,7 +1225,7 @@ private async Task> FindByExactBarcodeAsync(string code, G return matches.Select(r => new QuickSearchItem( r.Id, r.Name, r.Article, r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? code, - r.ReferencePrice, r.StockQty)).ToList(); + r.PurchasePrice, r.StockQty)).ToList(); } public record WeightedBarcodeHit( @@ -1485,13 +1485,14 @@ private static void Apply(Product e, ProductInput i) e.IsMarked = i.IsMarked; e.MinStock = i.MinStock; e.MaxStock = i.MaxStock; - // ReferencePriceUpdatedAt подбиваем только при реальной смене цены - // (включая переход с null на значение и обратно). Этим помечаем, - // что цена «свежая», 30-дневный таймер автоперезаписи отсчитывается заново. - if (e.ReferencePrice != i.ReferencePrice) + // Закупочная цена: вход — purchasePrice (новый) либо referencePrice + // (временный алиас для старых клиентов/iOS). PurchasePriceUpdatedAt + // подбиваем только при реальной смене (пометка «менялась вручную»). + var inPurchase = i.PurchasePrice ?? i.ReferencePrice; + if (e.PurchasePrice != inPurchase) { - e.ReferencePrice = i.ReferencePrice; - e.ReferencePriceUpdatedAt = i.ReferencePrice.HasValue ? DateTime.UtcNow : null; + e.PurchasePrice = inPurchase; + e.PurchasePriceUpdatedAt = inPurchase.HasValue ? DateTime.UtcNow : null; } e.PurchaseCurrencyId = i.PurchaseCurrencyId; e.ImageUrl = i.ImageUrl; diff --git a/src/food-market.api/Controllers/Catalog/RegistryCopyStatusController.cs b/src/food-market.api/Controllers/Catalog/RegistryCopyStatusController.cs new file mode 100644 index 0000000..32a4643 --- /dev/null +++ b/src/food-market.api/Controllers/Catalog/RegistryCopyStatusController.cs @@ -0,0 +1,34 @@ +using foodmarket.Application.Common.Tenancy; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Catalog; + +/// Статус копии «Реестра товаров» в каталог этого магазина. Пока state=1 +/// админка показывает «Каталог из реестра загружается…». +[ApiController] +[Authorize(Policy = "AdminAccess")] +[Route("api/catalog/registry-copy-status")] +public class RegistryCopyStatusController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + + public RegistryCopyStatusController(AppDbContext db, ITenantContext tenant) { _db = db; _tenant = tenant; } + + public record StatusDto(int State, DateTime? CopiedAt); + + [HttpGet] + public async Task> Get(CancellationToken ct) + { + var orgId = _tenant.OrganizationId; + if (orgId is null) return new StatusDto(0, null); + var row = await _db.Organizations.IgnoreQueryFilters() + .Where(o => o.Id == orgId) + .Select(o => new StatusDto(o.RegistryCopyState, o.RegistryCopiedAt)) + .FirstOrDefaultAsync(ct); + return row ?? new StatusDto(0, null); + } +} diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs index 1be8803..b211236 100644 --- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs +++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs @@ -396,12 +396,10 @@ public async Task Post(Guid id, CancellationToken ct) product.Cost = foodmarket.Application.Inventory.MovingAverageCost.Compute( currentQty, product.Cost, line.Quantity, line.UnitPrice); - // 2. ReferencePrice — автозаполнение при первой приёмке. - if (product.ReferencePrice is null) - { - product.ReferencePrice = line.UnitPrice; - product.ReferencePriceUpdatedAt = now; - } + // 2. Закупочная цена (последняя) = цена из строки приёмки — каждая + // проведённая приёмка пишет её (решение владельца 12.10). + product.PurchasePrice = line.UnitPrice; + product.PurchasePriceUpdatedAt = now; product.LastSupplyAt = now; // 3. Розничная: либо явный override строки, либо автонаценка по группе. @@ -542,6 +540,24 @@ public async Task Unpost(Guid id, CancellationToken ct) Notes: $"Отмена проведения документа {supply.Number}"), ct); } + // Закупочная цена: вернуть из предыдущей проведённой приёмки этого товара + // (решение владельца 12.10), чтобы не оставлять цену отменённого документа. + // Если других проведённых приёмок нет — закупочная очищается. + foreach (var pid in supply.Lines.Select(l => l.ProductId).Distinct()) + { + var prevPrice = await _db.SupplyLines + .Where(l => l.ProductId == pid && l.SupplyId != supply.Id && l.Supply.Status == SupplyStatus.Posted) + .OrderByDescending(l => l.Supply.PostedAt) + .Select(l => (decimal?)l.UnitPrice) + .FirstOrDefaultAsync(ct); + var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == pid, ct); + if (product is not null) + { + product.PurchasePrice = prevPrice; + product.PurchasePriceUpdatedAt = DateTime.UtcNow; + } + } + supply.Status = SupplyStatus.Draft; supply.PostedAt = null; await _db.SaveChangesAsync(ct); diff --git a/src/food-market.api/Controllers/SuperAdmin/ProductRegistryController.cs b/src/food-market.api/Controllers/SuperAdmin/ProductRegistryController.cs new file mode 100644 index 0000000..86cd23a --- /dev/null +++ b/src/food-market.api/Controllers/SuperAdmin/ProductRegistryController.cs @@ -0,0 +1,199 @@ +using System.Security.Claims; +using foodmarket.Domain.Organizations; +using foodmarket.Domain.Registry; +using foodmarket.Infrastructure.Persistence; +using foodmarket.Infrastructure.Registry; +using Hangfire; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.SuperAdmin; + +/// SuperAdmin: «Реестр товаров» — системный шаблон карточек. Поиск/ +/// просмотр/правка/удаление карточек, переключатель фонового пополнения +/// (с причиной в аудит), наполнение «из МойСклад заново», флаг участия у орг. +[ApiController] +[Authorize(Roles = "SuperAdmin")] +[Route("api/super-admin/product-registry")] +public class ProductRegistryController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly IBackgroundJobClient _jobs; + + public ProductRegistryController(AppDbContext db, IBackgroundJobClient jobs) { _db = db; _jobs = jobs; } + + public record CardDto(Guid Id, string Name, Guid UnitOfMeasureId, string? UnitName, int Packaging, + Guid? GroupId, string? GroupPath, decimal Cost, decimal? PurchasePrice, decimal? RetailPrice, + int Source, Guid? SourceOrganizationId, string[] Barcodes, DateTime CreatedAt, DateTime? UpdatedAt); + public record CardListDto(int Total, int Count, CardDto[] Items); + public record CardEditInput(string Name, Guid? GroupId, int Packaging, decimal Cost, decimal? PurchasePrice, decimal? RetailPrice, Guid? UnitOfMeasureId); + public record EnrichDto(bool Enabled, DateTime? LastRunStartedAt, DateTime? LastRunFinishedAt, int? LastAdded); + public record EnrichInput(bool Enabled, string Reason); + public record FillInput(Guid SourceOrganizationId, string Reason); + public record OrgFlagDto(Guid Id, string Name, bool ContributesToRegistry, bool IsArchived, bool HasMoySkladToken); + public record OrgFlagInput(bool Enabled, string Reason); + + [HttpGet] + public async Task> List([FromQuery] string? search, [FromQuery] Guid? groupId, + [FromQuery] int? source, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default) + { + pageSize = Math.Clamp(pageSize, 1, 200); + page = Math.Max(1, page); + var q = _db.ProductRegistryCards.AsNoTracking().AsQueryable(); + if (groupId is not null) q = q.Where(c => c.ProductRegistryGroupId == groupId); + if (source is not null) q = q.Where(c => (int)c.Source == source); + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim(); + q = q.Where(c => EF.Functions.ILike(c.Name, $"%{s}%") + || c.Barcodes.Any(b => b.Code == s || EF.Functions.ILike(b.Code, $"%{s}%"))); + } + var total = await q.CountAsync(ct); + var items = await q.OrderBy(c => c.Name).Skip((page - 1) * pageSize).Take(pageSize) + .Select(c => new CardDto(c.Id, c.Name, c.UnitOfMeasureId, c.UnitOfMeasure!.Name, (int)c.Packaging, + c.ProductRegistryGroupId, c.ProductRegistryGroup!.Path, c.Cost, c.PurchasePrice, c.RetailPrice, + (int)c.Source, c.SourceOrganizationId, c.Barcodes.Select(b => b.Code).ToArray(), c.CreatedAt, c.UpdatedAt)) + .ToListAsync(ct); + return new CardListDto(total, items.Count, items.ToArray()); + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + var c = await _db.ProductRegistryCards.AsNoTracking() + .Where(x => x.Id == id) + .Select(c => new CardDto(c.Id, c.Name, c.UnitOfMeasureId, c.UnitOfMeasure!.Name, (int)c.Packaging, + c.ProductRegistryGroupId, c.ProductRegistryGroup!.Path, c.Cost, c.PurchasePrice, c.RetailPrice, + (int)c.Source, c.SourceOrganizationId, c.Barcodes.Select(b => b.Code).ToArray(), c.CreatedAt, c.UpdatedAt)) + .FirstOrDefaultAsync(ct); + return c is null ? NotFound() : c; + } + + [HttpPut("{id:guid}")] + public async Task Edit(Guid id, [FromBody] CardEditInput input, CancellationToken ct) + { + var c = await _db.ProductRegistryCards.FirstOrDefaultAsync(x => x.Id == id, ct); + if (c is null) return NotFound(); + if (string.IsNullOrWhiteSpace(input.Name)) return BadRequest(new { error = "Наименование обязательно." }); + c.Name = input.Name.Trim(); + c.ProductRegistryGroupId = input.GroupId; + c.Packaging = (Domain.Catalog.Packaging)input.Packaging; + c.Cost = input.Cost; + c.PurchasePrice = input.PurchasePrice; + c.RetailPrice = input.RetailPrice; + if (input.UnitOfMeasureId is { } uid) c.UnitOfMeasureId = uid; + c.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) + { + var affected = await _db.ProductRegistryCards.Where(x => x.Id == id).ExecuteDeleteAsync(ct); + return affected == 0 ? NotFound() : NoContent(); + } + + // ── Фоновое пополнение ──────────────────────────────────────────────────── + [HttpGet("enrich")] + public async Task> GetEnrich(CancellationToken ct) + { + var s = await GetSettingsAsync(ct); + return new EnrichDto(s.ProductRegistryEnrichEnabled, s.ProductRegistryEnrichLastRunStartedAt, + s.ProductRegistryEnrichLastRunFinishedAt, s.ProductRegistryEnrichLastAdded); + } + + [HttpPut("enrich")] + public async Task SetEnrich([FromBody] EnrichInput input, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(input.Reason) || input.Reason.Trim().Length < 10) + return BadRequest(new { error = "Причина изменения обязательна (≥ 10 символов) — она пишется в журнал." }); + var s = await GetSettingsAsync(ct); + if (s.ProductRegistryEnrichEnabled == input.Enabled) return NoContent(); + var prev = s.ProductRegistryEnrichEnabled; + s.ProductRegistryEnrichEnabled = input.Enabled; + s.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + await AuditAsync("ProductRegistryEnrichToggle", + $"Фоновое пополнение реестра: {(input.Enabled ? "ВКЛ" : "ВЫКЛ")}", input.Reason, + System.Text.Json.JsonSerializer.Serialize(new { prev, next = input.Enabled }), ct); + return NoContent(); + } + + // ── Наполнение «из МойСклад заново» ────────────────────────────────────── + [HttpGet("fill-status")] + public async Task> FillStatus(CancellationToken ct) + { + var s = await GetSettingsAsync(ct); + return Ok(new + { + cards = await _db.ProductRegistryCards.CountAsync(ct), + groups = await _db.ProductRegistryGroups.CountAsync(ct), + barcodes = await _db.ProductRegistryBarcodes.CountAsync(ct), + lastResult = s.ProductRegistryFillLastResult, + lastAt = s.ProductRegistryFillLastAt, + }); + } + + [HttpPost("fill-from-moysklad")] + public async Task FillFromMoySklad([FromBody] FillInput input, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(input.Reason) || input.Reason.Trim().Length < 10) + return BadRequest(new { error = "Причина обязательна (≥ 10 символов) — пишется в журнал." }); + var org = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(o => o.Id == input.SourceOrganizationId, ct); + if (org is null) return NotFound(new { error = "Организация-источник не найдена." }); + if (string.IsNullOrWhiteSpace(org.MoySkladToken)) + return BadRequest(new { error = "У организации-источника не настроен токен МойСклад." }); + + _jobs.Enqueue(s => s.RunFillFromMoySkladAsync(input.SourceOrganizationId, CancellationToken.None)); + await AuditAsync("ProductRegistryFillFromMoySklad", + $"Наполнение реестра из МойСклад (источник: {org.Name})", input.Reason, + System.Text.Json.JsonSerializer.Serialize(new { sourceOrgId = input.SourceOrganizationId }), ct); + return Accepted(new { enqueued = true }); + } + + // ── Флаг участия организаций ────────────────────────────────────────────── + [HttpGet("orgs")] + public async Task> Orgs(CancellationToken ct) + => await _db.Organizations.IgnoreQueryFilters().OrderBy(o => o.Name) + .Select(o => new OrgFlagDto(o.Id, o.Name, o.ContributesToRegistry, o.IsArchived, o.MoySkladToken != null)) + .ToArrayAsync(ct); + + [HttpPut("orgs/{id:guid}/contributes")] + public async Task SetOrgFlag(Guid id, [FromBody] OrgFlagInput input, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(input.Reason) || input.Reason.Trim().Length < 10) + return BadRequest(new { error = "Причина обязательна (≥ 10 символов)." }); + var org = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(o => o.Id == id, ct); + if (org is null) return NotFound(); + if (org.ContributesToRegistry == input.Enabled) return NoContent(); + var prev = org.ContributesToRegistry; + org.ContributesToRegistry = input.Enabled; + await _db.SaveChangesAsync(ct); + await AuditAsync("ProductRegistryOrgContributes", + $"«Пополняет реестр» для «{org.Name}»: {(input.Enabled ? "ВКЛ" : "ВЫКЛ")}", input.Reason, + System.Text.Json.JsonSerializer.Serialize(new { orgId = id, prev, next = input.Enabled }), ct); + return NoContent(); + } + + private async Task GetSettingsAsync(CancellationToken ct) + { + var s = await _db.PlatformSettings.FirstOrDefaultAsync(ct); + if (s is null) { s = new Domain.Platform.PlatformSettings(); _db.PlatformSettings.Add(s); await _db.SaveChangesAsync(ct); } + return s; + } + + private async Task AuditAsync(string actionType, string description, string? reason, string changesJson, CancellationToken ct) + { + var userIdRaw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); + Guid.TryParse(userIdRaw, out var uid); + _db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog + { + SuperAdminUserId = uid, ActionType = actionType, OrganizationId = null, + Description = description, Reason = reason, ChangesJson = changesJson, + IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "", + }); + await _db.SaveChangesAsync(ct); + } +} diff --git a/src/food-market.api/Infrastructure/Validation/Validators.cs b/src/food-market.api/Infrastructure/Validation/Validators.cs index 634fe58..060c8e5 100644 --- a/src/food-market.api/Infrastructure/Validation/Validators.cs +++ b/src/food-market.api/Infrastructure/Validation/Validators.cs @@ -99,6 +99,7 @@ public ProductInputValidator() // контроллер генерирует внутренний EAN-13. RuleFor(x => x.Vat).InclusiveBetween(0m, 100m).When(x => x.Vat.HasValue) .WithMessage("Ставка НДС должна быть от 0 до 100%."); + RuleFor(x => x.PurchasePrice).GreaterThanOrEqualTo(0).When(x => x.PurchasePrice.HasValue); RuleFor(x => x.ReferencePrice).GreaterThanOrEqualTo(0).When(x => x.ReferencePrice.HasValue); RuleFor(x => x.MinStock).GreaterThanOrEqualTo(0).When(x => x.MinStock.HasValue); RuleFor(x => x.MaxStock).GreaterThanOrEqualTo(0).When(x => x.MaxStock.HasValue); diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index bc3cedf..a3f9e85 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -442,6 +442,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); + builder.Services.AddScoped(); // Inventory builder.Services.AddScoped(); @@ -538,7 +539,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme // Витринный сидер для скриншотов сайта (POST /api/admin/seed-demo?showcase=1). builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddHostedService(); + // ReferencePriceRefreshJob ОТКЛЮЧЁН (решение владельца 12.10): эталонная цена + // меняется только вручную, 30-дневная перезапись на Cost больше не выполняется. // DemoCatalogSeeder disabled: real catalog is imported from MoySklad. // Keep the file as reference for anyone starting without MoySklad access — // just re-register here to turn demo data back on. diff --git a/src/food-market.api/Seed/BulkCatalogSeeder.cs b/src/food-market.api/Seed/BulkCatalogSeeder.cs index b50823e..03ed3c6 100644 --- a/src/food-market.api/Seed/BulkCatalogSeeder.cs +++ b/src/food-market.api/Seed/BulkCatalogSeeder.cs @@ -44,7 +44,7 @@ public async Task SeedAsync(Guid orgId, int target, CancellationToken ct Name = $"Товар демонстрационный №{i + 1:D5}", Article = $"BULK-{i + 1:D6}", UnitOfMeasureId = unit.Id, Vat = 12m, VatEnabled = true, - Packaging = Packaging.Piece, ReferencePrice = 100m + (i % 900), + Packaging = Packaging.Piece, PurchasePrice = 100m + (i % 900), Prices = new List { new() { OrganizationId = orgId, PriceTypeId = priceType.Id, Amount = 100m + (i % 900), CurrencyId = currency.Id }, diff --git a/src/food-market.api/Seed/DemoCatalogSeeder.cs b/src/food-market.api/Seed/DemoCatalogSeeder.cs index c442ad8..1685127 100644 --- a/src/food-market.api/Seed/DemoCatalogSeeder.cs +++ b/src/food-market.api/Seed/DemoCatalogSeeder.cs @@ -164,7 +164,7 @@ Guid AddGroup(string name, Guid? parentId) ProductGroupId = d.Group, CountryOfOriginId = d.Country, Packaging = d.IsWeighed ? Packaging.Weight : Packaging.Piece, - ReferencePrice = Math.Round(d.RetailPrice * 0.72m, 2), + PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2), PurchaseCurrencyId = kzt.Id, Prices = [ diff --git a/src/food-market.api/Seed/DemoTenantSeeder.cs b/src/food-market.api/Seed/DemoTenantSeeder.cs index 7071fcc..5b0a6cf 100644 --- a/src/food-market.api/Seed/DemoTenantSeeder.cs +++ b/src/food-market.api/Seed/DemoTenantSeeder.cs @@ -118,7 +118,7 @@ public async Task SeedAsync(Guid orgId, CancellationToken ct = defa Vat = 16m, VatEnabled = true, ProductGroupId = groups[g].Id, Packaging = packaging, - ReferencePrice = t.RetailPrice, + PurchasePrice = t.RetailPrice, Prices = new List { new ProductPrice @@ -199,7 +199,7 @@ public async Task SeedAsync(Guid orgId, CancellationToken ct = defa foreach (var p in picked) { var qty = _rng.Next(20, 100); - var price = Math.Round(p.ReferencePrice!.Value * 0.6m, 2); // закупка ~60% от розницы + var price = Math.Round(p.PurchasePrice!.Value * 0.6m, 2); // закупка ~60% от розницы var lt = qty * price; supply.Lines.Add(new SupplyLine { @@ -253,7 +253,7 @@ public async Task SeedAsync(Guid orgId, CancellationToken ct = defa foreach (var p in picked) { var qty = _rng.Next(1, 4); - var price = p.ReferencePrice!.Value; + var price = p.PurchasePrice!.Value; var lt = qty * price; sale.Lines.Add(new RetailSaleLine { @@ -294,7 +294,7 @@ public async Task SeedAsync(Guid orgId, CancellationToken ct = defa foreach (var p in demandPicked) { var qty = _rng.Next(5, 15); - var price = Math.Round(p.ReferencePrice!.Value * 0.85m, 2); // опт-скидка 15% + var price = Math.Round(p.PurchasePrice!.Value * 0.85m, 2); // опт-скидка 15% var lt = qty * price; demand.Lines.Add(new DemandLine { diff --git a/src/food-market.api/Seed/DevDataSeeder.cs b/src/food-market.api/Seed/DevDataSeeder.cs index f5eb877..8cec32f 100644 --- a/src/food-market.api/Seed/DevDataSeeder.cs +++ b/src/food-market.api/Seed/DevDataSeeder.cs @@ -294,7 +294,7 @@ public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, if (!anyPriceType) { db.PriceTypes.AddRange( - new PriceType { OrganizationId = orgId, Name = "Розничная цена", IsSystem = true, IsRequired = true, IsRetail = true, SortOrder = 0 }, + new PriceType { OrganizationId = orgId, Name = "Продажная цена", IsSystem = true, IsRequired = true, IsRetail = true, SortOrder = 0 }, new PriceType { OrganizationId = orgId, Name = "Оптовая", SortOrder = 1 } ); } diff --git a/src/food-market.api/Seed/ShowcaseSeeder.cs b/src/food-market.api/Seed/ShowcaseSeeder.cs index a1075e6..49b802c 100644 --- a/src/food-market.api/Seed/ShowcaseSeeder.cs +++ b/src/food-market.api/Seed/ShowcaseSeeder.cs @@ -110,7 +110,7 @@ public async Task SeedAsync(Guid orgId, CancellationToken ct = defa Vat = 12m, VatEnabled = true, ProductGroupId = groups[g].Id, Packaging = packaging, - ReferencePrice = price, + PurchasePrice = price, Cost = Math.Round(price * 0.6m, 2), Prices = new List { @@ -241,7 +241,7 @@ void AddSupply(DateTime date, IReadOnlyList picked, Func qtyFn) foreach (var p in picked) { var qty = qtyFn(); - var price = Math.Round(p.ReferencePrice!.Value * 0.6m, 2); + var price = Math.Round(p.PurchasePrice!.Value * 0.6m, 2); var lt = qty * price; supply.Lines.Add(new SupplyLine { @@ -301,7 +301,7 @@ void AddSupply(DateTime date, IReadOnlyList picked, Func qtyFn) foreach (var p in picked) { var qty = _rng.Next(1, 4); - var price = p.ReferencePrice!.Value; + var price = p.PurchasePrice!.Value; var lt = qty * price; sale.Lines.Add(new RetailSaleLine { diff --git a/src/food-market.api/Seed/YearDemoSeeder.cs b/src/food-market.api/Seed/YearDemoSeeder.cs index 813dd80..01bc77a 100644 --- a/src/food-market.api/Seed/YearDemoSeeder.cs +++ b/src/food-market.api/Seed/YearDemoSeeder.cs @@ -117,7 +117,7 @@ public async Task SeedAsync(Guid orgId, CancellationToken ct = defa Vat = 12m, VatEnabled = true, ProductGroupId = groups[g].Id, Packaging = packaging, - ReferencePrice = price, + PurchasePrice = price, Cost = Math.Round(price * 0.6m, 2), Prices = new List { @@ -197,7 +197,7 @@ public async Task SeedAsync(Guid orgId, CancellationToken ct = defa foreach (var p in picked) { var qty = _rng.Next(30, 120); - var price = Math.Round(p.ReferencePrice!.Value * 0.6m, 2); + var price = Math.Round(p.PurchasePrice!.Value * 0.6m, 2); var lt = qty * price; supply.Lines.Add(new SupplyLine { @@ -271,7 +271,7 @@ public async Task SeedAsync(Guid orgId, CancellationToken ct = defa foreach (var p in picked) { var qty = _rng.Next(1, 4); - var price = p.ReferencePrice!.Value; + var price = p.PurchasePrice!.Value; var lt = qty * price; sale.Lines.Add(new RetailSaleLine { @@ -359,7 +359,7 @@ public async Task SeedAsync(Guid orgId, CancellationToken ct = defa foreach (var p in picked) { var qty = _rng.Next(10, 30); - var price = Math.Round(p.ReferencePrice!.Value * 0.85m, 2); + var price = Math.Round(p.PurchasePrice!.Value * 0.85m, 2); var lt = qty * price; d.Lines.Add(new DemandLine { diff --git a/src/food-market.application/Catalog/CatalogDtos.cs b/src/food-market.application/Catalog/CatalogDtos.cs index e997828..679cce1 100644 --- a/src/food-market.application/Catalog/CatalogDtos.cs +++ b/src/food-market.application/Catalog/CatalogDtos.cs @@ -53,6 +53,10 @@ public record ProductDto( Guid? CountryOfOriginId, string? CountryOfOriginName, bool IsService, Packaging Packaging, bool IsMarked, decimal? MinStock, decimal? MaxStock, + decimal? PurchasePrice, DateTime? PurchasePriceUpdatedAt, + // referencePrice/referencePriceUpdatedAt — ВРЕМЕННЫЕ алиасы закупочной цены + // для iOS-приложения ALL MARKET Staff (читает referencePrice). После перевода + // приложения на purchasePrice алиасы убрать. decimal? ReferencePrice, DateTime? ReferencePriceUpdatedAt, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode, decimal Cost, DateTime? LastSupplyAt, @@ -97,6 +101,8 @@ public record ProductInput( Guid ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId, bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false, [Range(0, 1e10)] decimal? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null, + [Range(0, 1e10)] decimal? PurchasePrice = null, + // ReferencePrice — временный входной алиас закупочной (старые клиенты/iOS). [Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null, [StringLength(1000)] string? ImageUrl = null, IReadOnlyList? Prices = null, diff --git a/src/food-market.application/Mapping/MapsterConfig.cs b/src/food-market.application/Mapping/MapsterConfig.cs index 529598a..f981f96 100644 --- a/src/food-market.application/Mapping/MapsterConfig.cs +++ b/src/food-market.application/Mapping/MapsterConfig.cs @@ -57,7 +57,8 @@ public static TypeAdapterConfig Build() src.CountryOfOrigin != null ? src.CountryOfOrigin.Name : null, src.IsService, src.Packaging, src.IsMarked, src.MinStock, src.MaxStock, - src.ReferencePrice, src.ReferencePriceUpdatedAt, + src.PurchasePrice, src.PurchasePriceUpdatedAt, + src.PurchasePrice, src.PurchasePriceUpdatedAt, // алиасы referencePrice* = закупочная (для iOS) src.PurchaseCurrencyId, src.PurchaseCurrency != null ? src.PurchaseCurrency.Code : null, src.Cost, src.LastSupplyAt, diff --git a/src/food-market.domain/Catalog/Product.cs b/src/food-market.domain/Catalog/Product.cs index 6e3d25c..cbaaed6 100644 --- a/src/food-market.domain/Catalog/Product.cs +++ b/src/food-market.domain/Catalog/Product.cs @@ -36,11 +36,13 @@ public class Product : TenantEntity public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений) public decimal? MaxStock { get; set; } // максимальный остаток (для автозаказа) - /// «Эталонная» (справочная) цена закупа. Не обязательная. - /// Автоматически заполняется UnitPrice'ом первой проведённой приёмки. - /// Через 30 дней без новых приёмок Hangfire-job переписывает на текущую Cost. - public decimal? ReferencePrice { get; set; } - public DateTime? ReferencePriceUpdatedAt { get; set; } + /// Закупочная цена (последняя). Подставляется в строку приёмки; + /// после проведения приёмки = цена из строки (решение владельца 12.10). + /// Можно менять вручную в карточке. Стартовое значение — buyPrice МойСклад / + /// цена из «Реестра товаров». Раньше называлась «эталонная» (ReferencePrice). + public decimal? PurchasePrice { get; set; } + /// Когда закупочную цену последний раз меняли вручную (PUT товара). + public DateTime? PurchasePriceUpdatedAt { get; set; } public Guid? PurchaseCurrencyId { get; set; } public Currency? PurchaseCurrency { get; set; } @@ -49,8 +51,7 @@ public class Product : TenantEntity /// Хранится с 4 знаками для точности; UI показывает 2. public decimal Cost { get; set; } - /// UTC-метка последней проведённой приёмки. Используется - /// 30-дневной job для перезаписи ReferencePrice на текущую Cost. + /// UTC-метка последней проведённой приёмки (информационная). public DateTime? LastSupplyAt { get; set; } public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage) diff --git a/src/food-market.domain/Organizations/Organization.cs b/src/food-market.domain/Organizations/Organization.cs index d99b1e0..6796dee 100644 --- a/src/food-market.domain/Organizations/Organization.cs +++ b/src/food-market.domain/Organizations/Organization.cs @@ -186,4 +186,16 @@ public class Organization : Entity /// контура / sandbox'а). null → используется production URL из /// реализации провайдера. public string? FiscalApiBaseUrl { get; set; } + + // ─ Реестр товаров ───────────────────────────────────────────────────────── + /// Пополняет ли каталог этого магазина общий «Реестр товаров» + /// (фоновое задание берёт новые штрихкоды в реестр). По умолчанию true для + /// зарегистрированных магазинов; демо/автотестовые выключаются. + public bool ContributesToRegistry { get; set; } = true; + + /// Статус копии реестра в каталог магазина при регистрации: + /// 0=не было, 1=идёт загрузка, 2=готово, 3=ошибка. Пока 1 — в админке + /// показывается «Каталог из реестра загружается…». Повторно не копируется. + public int RegistryCopyState { get; set; } + public DateTime? RegistryCopiedAt { get; set; } } diff --git a/src/food-market.domain/Platform/PlatformSettings.cs b/src/food-market.domain/Platform/PlatformSettings.cs index e42de2c..c3e3dbb 100644 --- a/src/food-market.domain/Platform/PlatformSettings.cs +++ b/src/food-market.domain/Platform/PlatformSettings.cs @@ -63,4 +63,18 @@ public class PlatformSettings : Entity public int? MoySkladDeltaLastRunMigrated { get; set; } /// Текст ошибки последнего прогона (если был сбой). public string? MoySkladDeltaLastRunError { get; set; } + + // ── Реестр товаров (системный шаблон карточек) ──────────────────────────── + /// Фоновое пополнение реестра из каталогов магазинов-участников. + /// По умолчанию ВЫКЛ (включает SuperAdmin с причиной в аудит). + public bool ProductRegistryEnrichEnabled { get; set; } + public DateTime? ProductRegistryEnrichLastRunStartedAt { get; set; } + public DateTime? ProductRegistryEnrichLastRunFinishedAt { get; set; } + /// Сколько карточек добавлено в последнем фоновом прогоне. + public int? ProductRegistryEnrichLastAdded { get; set; } + + /// Итог последнего наполнения реестра «из МойСклад заново» (JSON с + /// числами: товаров, взято, пропущено и т.п.) + когда выполнено. + public string? ProductRegistryFillLastResult { get; set; } + public DateTime? ProductRegistryFillLastAt { get; set; } } diff --git a/src/food-market.domain/Registry/ProductRegistry.cs b/src/food-market.domain/Registry/ProductRegistry.cs new file mode 100644 index 0000000..7bcad3d --- /dev/null +++ b/src/food-market.domain/Registry/ProductRegistry.cs @@ -0,0 +1,71 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Registry; + +/// Откуда пришла карточка реестра. MoySklad — наполнение «из МойСклад +/// заново» (пересоздаётся командой суперадмина). Shop — автопополнение из каталога +/// магазина-участника (не перезаписывается наполнением из МойСклад). +public enum RegistryCardSource +{ + MoySklad = 1, + Shop = 2, +} + +/// Группа «Реестра товаров» — глобальное дерево (Entity, без +/// OrganizationId, как страны/валюты). Путь денормализован как в источнике. +public class ProductRegistryGroup : Entity +{ + public string Name { get; set; } = null!; + public Guid? ParentId { get; set; } + public ProductRegistryGroup? Parent { get; set; } + public string Path { get; set; } = ""; // "Бакалея/Крупы/Рис" + public int SortOrder { get; set; } + /// Id группы (productfolder) в МойСклад — для повторного наполнения. + public string? SourceExternalId { get; set; } +} + +/// Карточка «Реестра товаров» — системный шаблон, которым наполняется +/// каталог нового магазина при регистрации. Глобальная (Entity, без +/// OrganizationId). Ключ — штрихкод (уникален в реестре, см. ProductRegistryBarcode). +/// Остатки/движения/документы/поставщики/мин-макс в реестр НЕ входят. +public class ProductRegistryCard : Entity +{ + public string Name { get; set; } = null!; + + /// Системная единица измерения ALL MARKET (глобальный справочник). + public Guid UnitOfMeasureId { get; set; } + public UnitOfMeasure? UnitOfMeasure { get; set; } + + /// Тип товара — как в карточке товара (штучный/весовой/разливной). + public Packaging Packaging { get; set; } = Packaging.Piece; + + public Guid? ProductRegistryGroupId { get; set; } + public ProductRegistryGroup? ProductRegistryGroup { get; set; } + + /// Себестоимость (на старте = закупочная; далее в магазине — + /// скользящее среднее). Только чтение в карточке товара. + public decimal Cost { get; set; } + /// Закупочная цена (buyPrice МойСклад / закупочная магазина). + public decimal? PurchasePrice { get; set; } + /// Розничная (продажная) цена. + public decimal? RetailPrice { get; set; } + + public RegistryCardSource Source { get; set; } = RegistryCardSource.Shop; + /// Организация-первооткрыватель (для карточек из магазинов). + public Guid? SourceOrganizationId { get; set; } + /// Id товара (product) в МойСклад — для наполнения из МойСклад. + public string? SourceExternalId { get; set; } + + public ICollection Barcodes { get; set; } = []; +} + +/// Штрихкод карточки реестра. Код уникален в пределах всего реестра +/// (уникальный индекс) — именно штрихкод является ключом «новизны» товара. +public class ProductRegistryBarcode : Entity +{ + public Guid ProductRegistryCardId { get; set; } + public ProductRegistryCard? Card { get; set; } + public string Code { get; set; } = null!; + public BarcodeType Type { get; set; } = BarcodeType.Ean13; +} diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs index 9b29965..dbd8a4d 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs @@ -123,6 +123,22 @@ public async Task> WhoAmIAsync(string token, C await foreach (var p in StreamPagedAsync(token, "entity/product", archivedOnly: true, ct)) yield return p; } + /// Сколько сущностей данного типа в МойСклад (meta.size). Для + /// отчёта «сколько модификаций/комплектов/услуг пропущено» (мы их не берём). + /// Только чтение; null при ошибке/запрете. + public async Task CountEntityAsync(string token, string entity, CancellationToken ct) + { + using var req = Build(HttpMethod.Get, $"entity/{entity}?limit=1", token); + using var res = await _http.SendAsync(req, ct); + if (!res.IsSuccessStatusCode) return null; + await using var stream = await res.Content.ReadAsStreamAsync(ct); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct); + if (doc.RootElement.TryGetProperty("meta", out var meta) + && meta.TryGetProperty("size", out var size) && size.TryGetInt32(out var n)) + return n; + return null; + } + public async Task> GetAllUomsAsync(string token, CancellationToken ct) { var all = new List(); diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs index e12e79f..6241b37 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs @@ -429,7 +429,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct, includeArchived: existing.CountryOfOriginId = countryId ?? existing.CountryOfOriginId; existing.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece; existing.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED"; - existing.ReferencePrice = p.BuyPrice is null ? existing.ReferencePrice : p.BuyPrice.Value / 100m; + existing.PurchasePrice = p.BuyPrice is null ? existing.PurchasePrice : p.BuyPrice.Value / 100m; if (existing.PurchaseCurrencyId is null) existing.PurchaseCurrencyId = kzt.Id; // Привязываем MS id в любом случае — это ключ будущих upsert'ов. if (string.IsNullOrEmpty(existing.MoySkladExternalId)) @@ -500,7 +500,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct, includeArchived: CountryOfOriginId = countryId, Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece, IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED", - ReferencePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m, + PurchasePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m, PurchaseCurrencyId = kzt.Id, MoySkladExternalId = p.Id, }; diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index eab52c2..2215ee3 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -104,6 +104,9 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public bool SkipAudit { get; set; } public DbSet SystemSettings => Set(); public DbSet PlatformSettings => Set(); + public DbSet ProductRegistryGroups => Set(); + public DbSet ProductRegistryCards => Set(); + public DbSet ProductRegistryBarcodes => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -151,6 +154,41 @@ protected override void OnModelCreating(ModelBuilder builder) b.Property(x => x.FromEmail).HasMaxLength(200); b.Property(x => x.FromName).HasMaxLength(200); b.Property(x => x.MoySkladDeltaLastRunError).HasMaxLength(2000); + b.Property(x => x.ProductRegistryFillLastResult).HasColumnType("jsonb"); + }); + + // ── Реестр товаров (глобальный, без tenant-фильтра) ────────────────── + builder.Entity(b => + { + b.ToTable("product_registry_groups"); + b.Property(x => x.Name).HasMaxLength(500).IsRequired(); + b.Property(x => x.Path).HasMaxLength(2000); + b.Property(x => x.SourceExternalId).HasMaxLength(64); + b.HasIndex(x => x.ParentId); + b.HasIndex(x => x.SourceExternalId); + }); + builder.Entity(b => + { + b.ToTable("product_registry_cards"); + b.Property(x => x.Name).HasMaxLength(500).IsRequired(); + b.Property(x => x.Cost).HasColumnType("numeric(18,4)"); + b.Property(x => x.PurchasePrice).HasColumnType("numeric(18,4)"); + b.Property(x => x.RetailPrice).HasColumnType("numeric(18,4)"); + b.Property(x => x.Source).HasConversion(); + b.Property(x => x.SourceExternalId).HasMaxLength(64); + b.HasIndex(x => x.Source); + b.HasIndex(x => x.ProductRegistryGroupId); + b.HasIndex(x => x.SourceExternalId); + b.HasMany(x => x.Barcodes).WithOne(x => x.Card) + .HasForeignKey(x => x.ProductRegistryCardId).OnDelete(DeleteBehavior.Cascade); + }); + builder.Entity(b => + { + b.ToTable("product_registry_barcodes"); + b.Property(x => x.Code).HasMaxLength(64).IsRequired(); + b.Property(x => x.Type).HasConversion(); + // Штрихкод уникален во всём реестре — это ключ «новизны» товара. + b.HasIndex(x => x.Code).IsUnique(); }); builder.Entity(b => diff --git a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs index c20d64c..d6e9e0d 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs @@ -142,7 +142,7 @@ private static void ConfigureProduct(EntityTypeBuilder b) b.Property(x => x.Article).HasMaxLength(500); b.Property(x => x.MinStock).HasPrecision(18, 4); b.Property(x => x.MaxStock).HasPrecision(18, 4); - b.Property(x => x.ReferencePrice).HasPrecision(18, 4); + b.Property(x => x.PurchasePrice).HasPrecision(18, 4); b.Property(x => x.Cost).HasPrecision(18, 4); b.Property(x => x.ImageUrl).HasMaxLength(1000); diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261012030000_Phase47_ProductRegistry.cs b/src/food-market.infrastructure/Persistence/Migrations/20261012030000_Phase47_ProductRegistry.cs new file mode 100644 index 0000000..57b6a08 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261012030000_Phase47_ProductRegistry.cs @@ -0,0 +1,103 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// «Реестр товаров» — глобальный системный шаблон карточек (без + /// OrganizationId). Три таблицы + флаги в platform_settings и organizations. + /// Идемпотентно (IF NOT EXISTS). + [DbContext(typeof(AppDbContext))] + [Migration("20261012030000_Phase47_ProductRegistry")] + public partial class Phase47_ProductRegistry : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + CREATE TABLE IF NOT EXISTS public.product_registry_groups ( + ""Id"" uuid PRIMARY KEY, + ""Name"" varchar(500) NOT NULL, + ""ParentId"" uuid NULL, + ""Path"" varchar(2000) NULL, + ""SortOrder"" integer NOT NULL DEFAULT 0, + ""SourceExternalId"" varchar(64) NULL, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone NULL + ); + CREATE INDEX IF NOT EXISTS ix_prg_parent ON public.product_registry_groups (""ParentId""); + CREATE INDEX IF NOT EXISTS ix_prg_srcext ON public.product_registry_groups (""SourceExternalId""); + + CREATE TABLE IF NOT EXISTS public.product_registry_cards ( + ""Id"" uuid PRIMARY KEY, + ""Name"" varchar(500) NOT NULL, + ""UnitOfMeasureId"" uuid NOT NULL, + ""Packaging"" integer NOT NULL DEFAULT 1, + ""ProductRegistryGroupId"" uuid NULL, + ""Cost"" numeric(18,4) NOT NULL DEFAULT 0, + ""PurchasePrice"" numeric(18,4) NULL, + ""RetailPrice"" numeric(18,4) NULL, + ""Source"" integer NOT NULL DEFAULT 2, + ""SourceOrganizationId"" uuid NULL, + ""SourceExternalId"" varchar(64) NULL, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone NULL + ); + CREATE INDEX IF NOT EXISTS ix_prc_source ON public.product_registry_cards (""Source""); + CREATE INDEX IF NOT EXISTS ix_prc_group ON public.product_registry_cards (""ProductRegistryGroupId""); + CREATE INDEX IF NOT EXISTS ix_prc_srcext ON public.product_registry_cards (""SourceExternalId""); + + CREATE TABLE IF NOT EXISTS public.product_registry_barcodes ( + ""Id"" uuid PRIMARY KEY, + ""ProductRegistryCardId"" uuid NOT NULL REFERENCES public.product_registry_cards(""Id"") ON DELETE CASCADE, + ""Code"" varchar(64) NOT NULL, + ""Type"" integer NOT NULL DEFAULT 1, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone NULL + ); + CREATE INDEX IF NOT EXISTS ix_prb_card ON public.product_registry_barcodes (""ProductRegistryCardId""); + CREATE UNIQUE INDEX IF NOT EXISTS ux_prb_code ON public.product_registry_barcodes (""Code""); + + ALTER TABLE public.platform_settings + ADD COLUMN IF NOT EXISTS ""ProductRegistryEnrichEnabled"" boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS ""ProductRegistryEnrichLastRunStartedAt"" timestamp with time zone NULL, + ADD COLUMN IF NOT EXISTS ""ProductRegistryEnrichLastRunFinishedAt"" timestamp with time zone NULL, + ADD COLUMN IF NOT EXISTS ""ProductRegistryEnrichLastAdded"" integer NULL, + ADD COLUMN IF NOT EXISTS ""ProductRegistryFillLastResult"" jsonb NULL, + ADD COLUMN IF NOT EXISTS ""ProductRegistryFillLastAt"" timestamp with time zone NULL; + + ALTER TABLE public.organizations + ADD COLUMN IF NOT EXISTS ""ContributesToRegistry"" boolean NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS ""RegistryCopyState"" integer NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS ""RegistryCopiedAt"" timestamp with time zone NULL; + + -- Демо/автотестовые магазины реестр не пополняют (FOOD MARKET и + -- обычные магазины остаются ВКЛ по умолчанию). + UPDATE public.organizations SET ""ContributesToRegistry"" = false + WHERE lower(""Name"") LIKE '%демо%' OR lower(""Name"") LIKE '%demo%' + OR lower(""Name"") LIKE 'quality-%' OR lower(""Name"") LIKE '%e2e%' + OR lower(""Name"") LIKE '%autotest%' OR lower(""Name"") LIKE '%smoke%' + OR lower(""Name"") LIKE '%perf%' OR lower(""Name"") LIKE '%ratetest%';"); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@" + DROP TABLE IF EXISTS public.product_registry_barcodes; + DROP TABLE IF EXISTS public.product_registry_cards; + DROP TABLE IF EXISTS public.product_registry_groups; + ALTER TABLE public.platform_settings + DROP COLUMN IF EXISTS ""ProductRegistryEnrichEnabled"", + DROP COLUMN IF EXISTS ""ProductRegistryEnrichLastRunStartedAt"", + DROP COLUMN IF EXISTS ""ProductRegistryEnrichLastRunFinishedAt"", + DROP COLUMN IF EXISTS ""ProductRegistryEnrichLastAdded"", + DROP COLUMN IF EXISTS ""ProductRegistryFillLastResult"", + DROP COLUMN IF EXISTS ""ProductRegistryFillLastAt""; + ALTER TABLE public.organizations + DROP COLUMN IF EXISTS ""ContributesToRegistry"", + DROP COLUMN IF EXISTS ""RegistryCopyState"", + DROP COLUMN IF EXISTS ""RegistryCopiedAt"";"); + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261012040000_Phase48_PurchasePriceRename.cs b/src/food-market.infrastructure/Persistence/Migrations/20261012040000_Phase48_PurchasePriceRename.cs new file mode 100644 index 0000000..1b3d9d1 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261012040000_Phase48_PurchasePriceRename.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Схема цен владельца 12.10: «эталонная» цена становится + /// «закупочной». products.ReferencePrice → PurchasePrice (данные сохраняются), + /// ReferencePriceUpdatedAt → PurchasePriceUpdatedAt. Системный тип цены + /// «Розничная цена» → «Продажная цена» (только если имя не меняли вручную). + [DbContext(typeof(AppDbContext))] + [Migration("20261012040000_Phase48_PurchasePriceRename")] + public partial class Phase48_PurchasePriceRename : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='public' AND table_name='products' AND column_name='ReferencePrice') THEN + ALTER TABLE public.products RENAME COLUMN ""ReferencePrice"" TO ""PurchasePrice""; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='public' AND table_name='products' AND column_name='ReferencePriceUpdatedAt') THEN + ALTER TABLE public.products RENAME COLUMN ""ReferencePriceUpdatedAt"" TO ""PurchasePriceUpdatedAt""; + END IF; + END $$; + + UPDATE public.price_types SET ""Name"" = 'Продажная цена' + WHERE ""Name"" = 'Розничная цена' AND ""IsSystem"" = true;"); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@" + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='public' AND table_name='products' AND column_name='PurchasePrice') THEN + ALTER TABLE public.products RENAME COLUMN ""PurchasePrice"" TO ""ReferencePrice""; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='public' AND table_name='products' AND column_name='PurchasePriceUpdatedAt') THEN + ALTER TABLE public.products RENAME COLUMN ""PurchasePriceUpdatedAt"" TO ""ReferencePriceUpdatedAt""; + END IF; + END $$; + + UPDATE public.price_types SET ""Name"" = 'Розничная цена' + WHERE ""Name"" = 'Продажная цена' AND ""IsSystem"" = true;"); + } + } +} diff --git a/src/food-market.infrastructure/Registry/ProductRegistryService.cs b/src/food-market.infrastructure/Registry/ProductRegistryService.cs new file mode 100644 index 0000000..6a08e01 --- /dev/null +++ b/src/food-market.infrastructure/Registry/ProductRegistryService.cs @@ -0,0 +1,409 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Registry; +using foodmarket.Infrastructure.Integrations.MoySklad; +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace foodmarket.Infrastructure.Registry; + +/// «Реестр товаров» — системный шаблон карточек. Три операции: +/// наполнение из МойСклад (команда суперадмина, пересоздаёт MoySklad-карточки), +/// фоновое пополнение из каталогов магазинов-участников (новые штрихкоды), +/// копия всего реестра в каталог нового магазина при регистрации. +/// Глобальные таблицы, без tenant-фильтра. МойСклад — только чтение. +public class ProductRegistryService +{ + private readonly AppDbContext _db; + private readonly MoySkladClient _ms; + private readonly ILogger _log; + + public ProductRegistryService(AppDbContext db, MoySkladClient ms, ILogger log) + { + _db = db; _ms = ms; _log = log; + } + + public record FillResult( + int ProductsSeen, int Taken, int SkippedNoBarcode, int SkippedArchived, + int SkippedDuplicateBarcode, int Variants, int Bundles, int Services, + int Groups, int Barcodes, string? Error = null); + + /// Фоновая обёртка для Hangfire: выполняет наполнение из МойСклад и + /// пишет итог (JSON с числами) в PlatformSettings, чтобы суперадмин его видел. + public async Task RunFillFromMoySkladAsync(Guid sourceOrgId, CancellationToken ct) + { + FillResult r; + try { r = await FillFromMoySkladAsync(sourceOrgId, ct); } + catch (Exception ex) + { + _log.LogError(ex, "ProductRegistry fill-from-MoySklad failed"); + r = new FillResult(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ex.Message); + } + _db.ChangeTracker.AutoDetectChangesEnabled = true; + var s = await _db.PlatformSettings.FirstOrDefaultAsync(ct); + if (s is null) { s = new Domain.Platform.PlatformSettings(); _db.PlatformSettings.Add(s); } + s.ProductRegistryFillLastResult = System.Text.Json.JsonSerializer.Serialize(r); + s.ProductRegistryFillLastAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + } + + // ── Наполнение «из МойСклад заново» ────────────────────────────────────── + // Пересоздаёт карточки Source=MoySklad; карточки Source=Shop не трогает. + public async Task FillFromMoySkladAsync(Guid sourceOrgId, CancellationToken ct) + { + var org = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(o => o.Id == sourceOrgId, ct) + ?? throw new InvalidOperationException("Организация-источник не найдена."); + var token = org.MoySkladToken; + if (string.IsNullOrWhiteSpace(token)) + throw new InvalidOperationException("У организации-источника не настроен токен МойСклад."); + + // Счётчики пропускаемых сущностей (мы берём только entity/product). + var variants = await _ms.CountEntityAsync(token, "variant", ct) ?? 0; + var bundles = await _ms.CountEntityAsync(token, "bundle", ct) ?? 0; + var services = await _ms.CountEntityAsync(token, "service", ct) ?? 0; + + // Справочники: MS uom id → code; глобальные единицы ALL MARKET по code. + var msUomCodeById = (await _ms.GetAllUomsAsync(token, ct)) + .Where(u => !string.IsNullOrEmpty(u.Id) && !string.IsNullOrEmpty(u.Code)) + .ToDictionary(u => u.Id!, u => u.Code!); + var globalUnits = await _db.UnitsOfMeasure.IgnoreQueryFilters() + .Where(u => u.OrganizationId == null) + .ToListAsync(ct); + var unitByCode = globalUnits.Where(u => u.IsActive) + .GroupBy(u => u.Code).ToDictionary(g => g.Key, g => g.First().Id); + var pieceUnitId = unitByCode.TryGetValue("796", out var pu) ? pu + : (globalUnits.FirstOrDefault()?.Id ?? throw new InvalidOperationException("Нет ни одной системной единицы измерения.")); + + _db.ChangeTracker.AutoDetectChangesEnabled = false; + _db.SkipAudit = true; + + // 1) Удаляем прежние MoySklad-карточки (их штрихкоды уйдут каскадом) и + // MoySklad-группы (SourceExternalId != null). Shop-карточки/группы живут. + await _db.ProductRegistryCards.Where(c => c.Source == RegistryCardSource.MoySklad).ExecuteDeleteAsync(ct); + await _db.ProductRegistryGroups.Where(g => g.SourceExternalId != null).ExecuteDeleteAsync(ct); + + // 2) Группы из productfolder (путь pathName/name, дерево по ParentFolder). + var folders = await _ms.GetAllFoldersAsync(token, ct); + var groupByMsId = new Dictionary(); + foreach (var f in folders.OrderBy(f => f.PathName?.Length ?? 0)) + { + if (f.Id is null) continue; + var parentMsId = ExtractId(f.ParentFolder?.Meta?.Href); + var parentId = parentMsId != null && groupByMsId.TryGetValue(parentMsId, out var pg) ? pg.Id : (Guid?)null; + var g = new ProductRegistryGroup + { + Name = Trim(f.Name, 500) ?? "(без имени)", + ParentId = parentId, + Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}", + SourceExternalId = f.Id, + }; + _db.ProductRegistryGroups.Add(g); + groupByMsId[f.Id] = g; + } + await _db.SaveChangesAsync(ct); + var groupIdByMsId = groupByMsId.ToDictionary(kv => kv.Key, kv => (Guid?)kv.Value.Id); + + // 3) Штрихкоды, уже занятые в реестре (оставшимися Shop-карточками) — не дублируем. + var usedBarcodes = new HashSet( + await _db.ProductRegistryBarcodes.Select(b => b.Code).ToListAsync(ct), + StringComparer.OrdinalIgnoreCase); + + int seen = 0, taken = 0, noBc = 0, archived = 0, dupBc = 0, bcCount = 0, pending = 0; + await foreach (var p in _ms.StreamProductsAsync(token, ct, includeArchived: true)) + { + seen++; + if (p.Archived) { archived++; continue; } + if (string.IsNullOrWhiteSpace(p.Name)) { continue; } + + var barcodes = ExtractBarcodes(p); + if (barcodes.Count == 0) { noBc++; continue; } + // Берём только НОВЫЕ коды (глобально уникальные в реестре). + var newCodes = barcodes.Where(b => usedBarcodes.Add(b.code)).ToList(); + if (newCodes.Count == 0) { dupBc++; continue; } + + var uomCode = msUomCodeById.GetValueOrDefault(ExtractId(p.Uom?.Meta?.Href) ?? ""); + var unitId = uomCode != null && unitByCode.TryGetValue(uomCode, out var uid) ? uid : pieceUnitId; + var cost = p.BuyPrice is null ? 0m : p.BuyPrice.Value / 100m; + var retail = (p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true) + ?? p.SalePrices?.FirstOrDefault())?.Value; + var groupId = groupIdByMsId.GetValueOrDefault(ExtractId(p.ProductFolder?.Meta?.Href) ?? ""); + + var card = new ProductRegistryCard + { + Name = Trim(p.Name, 500)!, + UnitOfMeasureId = unitId, + Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece, + ProductRegistryGroupId = groupId, + Cost = cost, + PurchasePrice = cost, // закупочная = buyPrice; себестоимость = та же на старте + RetailPrice = retail is null ? null : retail.Value / 100m, + Source = RegistryCardSource.MoySklad, + SourceExternalId = p.Id, + }; + foreach (var (code, type) in newCodes) + { + card.Barcodes.Add(new ProductRegistryBarcode { Code = code, Type = type }); + bcCount++; + } + _db.ProductRegistryCards.Add(card); + taken++; + if (++pending >= 1000) { await _db.SaveChangesAsync(ct); _db.ChangeTracker.Clear(); pending = 0; } + } + if (pending > 0) await _db.SaveChangesAsync(ct); + _db.ChangeTracker.AutoDetectChangesEnabled = true; + + return new FillResult(seen, taken, noBc, archived, dupBc, variants, bundles, services, groupByMsId.Count, bcCount); + } + + // ── Фоновое пополнение из каталогов магазинов-участников ────────────────── + public async Task EnrichFromParticipantsAsync(CancellationToken ct) + { + var settings = await _db.PlatformSettings.FirstOrDefaultAsync(ct); + if (settings is null || !settings.ProductRegistryEnrichEnabled) return 0; + + settings.ProductRegistryEnrichLastRunStartedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + + var used = new HashSet( + await _db.ProductRegistryBarcodes.Select(b => b.Code).ToListAsync(ct), + StringComparer.OrdinalIgnoreCase); + + var retailTypeByOrg = new Dictionary(); + var shopGroupByPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var g in await _db.ProductRegistryGroups.Where(g => g.SourceExternalId == null).ToListAsync(ct)) + shopGroupByPath.TryAdd(g.Path, g.Id); + + var participants = await _db.Organizations.IgnoreQueryFilters() + .Where(o => o.ContributesToRegistry && !o.IsArchived) + .Select(o => o.Id).ToListAsync(ct); + + int added = 0; + _db.ChangeTracker.AutoDetectChangesEnabled = false; + _db.SkipAudit = true; + foreach (var orgId in participants) + { + var retailTypeId = retailTypeByOrg.TryGetValue(orgId, out var rt) ? rt + : (retailTypeByOrg[orgId] = await _db.PriceTypes.IgnoreQueryFilters() + .Where(t => t.OrganizationId == orgId && t.IsRetail) + .Select(t => t.Id).FirstOrDefaultAsync(ct)); + + var products = await _db.Products.IgnoreQueryFilters() + .Where(p => p.OrganizationId == orgId && !p.IsArchived) + .Select(p => new + { + p.Id, p.Name, p.UnitOfMeasureId, p.Packaging, p.Cost, p.PurchasePrice, + GroupPath = p.ProductGroup!.Path, + Barcodes = p.Barcodes.Select(b => new { b.Code, b.Type }).ToList(), + Retail = p.Prices.Where(pp => pp.PriceTypeId == retailTypeId).Select(pp => (decimal?)pp.Amount).FirstOrDefault(), + }) + .ToListAsync(ct); + + int pending = 0; + foreach (var p in products) + { + if (string.IsNullOrWhiteSpace(p.Name)) continue; + var newCodes = p.Barcodes + .Where(b => IsAcceptableBarcode(b.Code, b.Type) && used.Add(b.Code)) + .Select(b => (b.Code, b.Type)).ToList(); + if (newCodes.Count == 0) continue; + + Guid? groupId = null; + if (!string.IsNullOrEmpty(p.GroupPath)) + { + if (!shopGroupByPath.TryGetValue(p.GroupPath, out var gid)) + { + var ng = new ProductRegistryGroup { Name = p.GroupPath.Split('/').Last(), Path = p.GroupPath }; + _db.ProductRegistryGroups.Add(ng); + await _db.SaveChangesAsync(ct); + gid = ng.Id; shopGroupByPath[p.GroupPath] = gid; + } + groupId = gid; + } + + var cost = p.Cost > 0m ? p.Cost : (p.PurchasePrice ?? 0m); + var card = new ProductRegistryCard + { + Name = Trim(p.Name, 500)!, + UnitOfMeasureId = p.UnitOfMeasureId, + Packaging = p.Packaging, + ProductRegistryGroupId = groupId, + Cost = cost, + PurchasePrice = p.PurchasePrice ?? cost, + RetailPrice = p.Retail, + Source = RegistryCardSource.Shop, + SourceOrganizationId = orgId, + }; + foreach (var (code, type) in newCodes) + card.Barcodes.Add(new ProductRegistryBarcode { Code = code, Type = type }); + _db.ProductRegistryCards.Add(card); + added++; + if (++pending >= 1000) { await _db.SaveChangesAsync(ct); pending = 0; } + } + if (pending > 0) await _db.SaveChangesAsync(ct); + } + _db.ChangeTracker.AutoDetectChangesEnabled = true; + + settings = await _db.PlatformSettings.FirstAsync(ct); + settings.ProductRegistryEnrichLastRunFinishedAt = DateTime.UtcNow; + settings.ProductRegistryEnrichLastAdded = added; + await _db.SaveChangesAsync(ct); + _log.LogInformation("ProductRegistry enrich: added {Added} cards from {Orgs} participants", added, participants.Count); + return added; + } + + // ── Копия реестра в каталог нового магазина ─────────────────────────────── + public async Task CopyRegistryToOrgAsync(Guid orgId, CancellationToken ct) + { + var org = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(o => o.Id == orgId, ct); + if (org is null) return; + if (org.RegistryCopyState == 2) return; // уже скопировано — повторно не копируем + + try + { + var cardCount = await _db.ProductRegistryCards.CountAsync(ct); + if (cardCount == 0) { org.RegistryCopyState = 2; org.RegistryCopiedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); return; } + + var kztId = await _db.Currencies.Where(c => c.Code == "KZT").Select(c => c.Id).FirstAsync(ct); + var retailTypeId = await _db.PriceTypes.IgnoreQueryFilters() + .Where(t => t.OrganizationId == orgId && t.IsRetail).Select(t => t.Id).FirstOrDefaultAsync(ct); + var systemGroupId = await _db.ProductGroups.IgnoreQueryFilters() + .Where(g => g.OrganizationId == orgId && g.IsSystem).Select(g => g.Id).FirstOrDefaultAsync(ct); + var defaultVat = await _db.Countries.IgnoreQueryFilters() + .Where(c => c.Code == org.CountryCode).Select(c => (decimal?)c.VatRate).FirstOrDefaultAsync(ct) ?? 0m; + + _db.ChangeTracker.AutoDetectChangesEnabled = false; + _db.SkipAudit = true; + + // 1) Копируем дерево групп реестра в группы магазина (по Path, родители раньше детей). + var regGroups = await _db.ProductRegistryGroups.AsNoTracking().OrderBy(g => g.Path.Length).ToListAsync(ct); + var orgGroupIdByRegId = new Dictionary(); + var orgGroupIdByPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var rg in regGroups) + { + var path = string.IsNullOrEmpty(rg.Path) ? rg.Name : rg.Path; + if (orgGroupIdByPath.TryGetValue(path, out var existing)) { orgGroupIdByRegId[rg.Id] = existing; continue; } + Guid? parentId = null; + var slash = path.LastIndexOf('/'); + if (slash > 0 && orgGroupIdByPath.TryGetValue(path[..slash], out var pid)) parentId = pid; + var og = new ProductGroup + { + OrganizationId = orgId, Name = rg.Name, Path = path, ParentId = parentId, SortOrder = rg.SortOrder, + }; + _db.ProductGroups.Add(og); + orgGroupIdByPath[path] = og.Id; + orgGroupIdByRegId[rg.Id] = og.Id; + } + await _db.SaveChangesAsync(ct); + _db.ChangeTracker.Clear(); + + // 2) Копируем карточки → товары + штрихкоды + розничная цена. Остатков нет. + // Keyset-пагинация по Id: каждый читающий запрос завершается ДО SaveChanges + // (на одном DbContext нельзя одновременно открытый reader и запись). + const int batch = 1000; + int offset = 0; + while (true) + { + var page = await _db.ProductRegistryCards.AsNoTracking().Include(c => c.Barcodes) + .OrderBy(c => c.Id).Skip(offset).Take(batch).ToListAsync(ct); + if (page.Count == 0) break; + foreach (var card in page) + { + var groupId = card.ProductRegistryGroupId is { } rgid && orgGroupIdByRegId.TryGetValue(rgid, out var og) + ? og : systemGroupId; + var product = new Product + { + OrganizationId = orgId, + Name = card.Name, + UnitOfMeasureId = card.UnitOfMeasureId, + ProductGroupId = groupId, + Packaging = card.Packaging, + Vat = defaultVat, + VatEnabled = true, + Cost = card.Cost, + PurchasePrice = card.PurchasePrice, + PurchaseCurrencyId = kztId, + }; + foreach (var b in card.Barcodes) + product.Barcodes.Add(new ProductBarcode { OrganizationId = orgId, Code = b.Code, Type = b.Type }); + if (card.RetailPrice is { } rp && retailTypeId != Guid.Empty) + product.Prices.Add(new ProductPrice { OrganizationId = orgId, PriceTypeId = retailTypeId, Amount = rp, CurrencyId = kztId }); + _db.Products.Add(product); + } + await _db.SaveChangesAsync(ct); + _db.ChangeTracker.Clear(); + offset += page.Count; + } + _db.ChangeTracker.AutoDetectChangesEnabled = true; + + org = await _db.Organizations.IgnoreQueryFilters().FirstAsync(o => o.Id == orgId, ct); + org.RegistryCopyState = 2; + org.RegistryCopiedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + _log.LogInformation("ProductRegistry copy → org {Org}: {Cards} cards", orgId, cardCount); + } + catch (Exception ex) + { + _log.LogError(ex, "ProductRegistry copy → org {Org} failed", orgId); + try + { + _db.ChangeTracker.Clear(); + var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == orgId, ct); + if (o is not null) { o.RegistryCopyState = 3; await _db.SaveChangesAsync(ct); } + } + catch { /* best effort */ } + throw; + } + } + + // ── helpers ─────────────────────────────────────────────────────────────── + private static readonly Dictionary BcTypes = new(StringComparer.OrdinalIgnoreCase) + { + ["ean13"] = BarcodeType.Ean13, ["gtin"] = BarcodeType.Ean13, ["ean8"] = BarcodeType.Ean8, + ["code128"] = BarcodeType.Code128, ["upca"] = BarcodeType.Upca, ["upce"] = BarcodeType.Upce, + }; + + private static List<(string code, BarcodeType type)> ExtractBarcodes(MsProduct p) + { + var res = new List<(string, BarcodeType)>(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + if (p.Barcodes is null) return res; + foreach (var dict in p.Barcodes) + foreach (var kv in dict) + { + var code = kv.Value?.Trim(); + if (string.IsNullOrEmpty(code) || code.Length > 64 || !seen.Add(code)) continue; + res.Add((code, BcTypes.GetValueOrDefault(kv.Key, BarcodeType.Other))); + } + return res; + } + + /// Фильтр мусора для фонового пополнения: EAN-13/EAN-8 с неверной + /// контрольной цифрой — не берём; прочие типы (code128 и т.п.) — берём. + private static bool IsAcceptableBarcode(string code, BarcodeType type) + { + if (string.IsNullOrWhiteSpace(code) || code.Length > 64) return false; + bool digits = code.All(char.IsDigit); + if (type == BarcodeType.Ean13 || (digits && code.Length == 13)) return ValidEan(code, 13); + if (type == BarcodeType.Ean8 || (digits && code.Length == 8)) return ValidEan(code, 8); + return true; + } + + private static bool ValidEan(string c, int len) + { + if (c.Length != len || !c.All(char.IsDigit)) return false; + int sum = 0; + for (int i = 0; i < len - 1; i++) + { + int d = c[len - 2 - i] - '0'; + sum += (i % 2 == 0) ? d * 3 : d; + } + int check = (10 - sum % 10) % 10; + return check == c[len - 1] - '0'; + } + + private static string? ExtractId(string? href) + => string.IsNullOrEmpty(href) ? null : href.Split('?')[0].TrimEnd('/').Split('/').LastOrDefault(); + + private static string? Trim(string? s, int max) + => string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max]); +} diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index edc337d..f4ebfbe 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -99,6 +99,7 @@ const SuperAdminSettingsPage = lazy(() => import('@/pages/SuperAdminSettingsPage const SuperAdminUnitsOfMeasurePage = lazy(() => import('@/pages/SuperAdminUnitsOfMeasurePage').then(m => ({ default: m.SuperAdminUnitsOfMeasurePage }))) const SuperAdminOrgEmployeesPage = lazy(() => import('@/pages/SuperAdminOrgEmployeesPage').then(m => ({ default: m.SuperAdminOrgEmployeesPage }))) const SuperAdminPlatformSettingsPage = lazy(() => import('@/pages/SuperAdminPlatformSettingsPage').then(m => ({ default: m.SuperAdminPlatformSettingsPage }))) +const SuperAdminProductRegistryPage = lazy(() => import('@/pages/SuperAdminProductRegistryPage').then(m => ({ default: m.SuperAdminProductRegistryPage }))) /** Suspense-обёртка с form-скелетом для lazy-страниц. Возвращает компонент, * пригодный к использованию как element={...}. */ @@ -158,6 +159,7 @@ export default function App() { + {/* Tenant-роуты — обычный AppLayout, но с TenantRouteGuard: diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index 06ac0d7..999d612 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -22,6 +22,7 @@ import { FeedbackWidget } from './FeedbackWidget' import { WhatsNewBanner } from './WhatsNewBanner' import { NotificationCenter } from './NotificationCenter' import { NegativeStockBanner } from './NegativeStockBanner' +import { RegistryCopyBanner } from './RegistryCopyBanner' interface MeResponse { sub: string @@ -369,6 +370,7 @@ export function AppLayout() {
+ diff --git a/src/food-market.web/src/components/RegistryCopyBanner.tsx b/src/food-market.web/src/components/RegistryCopyBanner.tsx new file mode 100644 index 0000000..073c927 --- /dev/null +++ b/src/food-market.web/src/components/RegistryCopyBanner.tsx @@ -0,0 +1,26 @@ +import { useQuery } from '@tanstack/react-query' +import { Loader2 } from 'lucide-react' +import { api } from '@/lib/api' + +interface Status { state: number; copiedAt: string | null } + +/** + * Баннер «Каталог из реестра загружается…». Показывается, пока фоновая копия + * «Реестра товаров» в каталог нового магазина не завершилась (state=1). + * Регистрация и первый вход не ждут копию — она идёт в фоне. + */ +export function RegistryCopyBanner() { + const { data } = useQuery({ + queryKey: ['/api/catalog/registry-copy-status'], + queryFn: async () => (await api.get('/api/catalog/registry-copy-status')).data, + // Пока идёт загрузка — опрашиваем чаще, чтобы баннер сам исчез по готовности. + refetchInterval: (q) => (q.state.data?.state === 1 ? 4000 : false), + }) + if (data?.state !== 1) return null + return ( +
+ + Каталог из реестра загружается… Товары появятся через минуту-другую — можно продолжать работать. +
+ ) +} diff --git a/src/food-market.web/src/components/SuperAdminLayout.tsx b/src/food-market.web/src/components/SuperAdminLayout.tsx index a02b21d..c839b11 100644 --- a/src/food-market.web/src/components/SuperAdminLayout.tsx +++ b/src/food-market.web/src/components/SuperAdminLayout.tsx @@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query' import { ShieldCheck, Building, FileClock, HeartPulse, HardDriveDownload, Settings, Users, LayoutDashboard, LogOut, Menu, X, ChevronDown, Globe, - FolderTree, Ruler, + FolderTree, Ruler, Package, } from 'lucide-react' import { api, setOrgOverride } from '@/lib/api' import { logout } from '@/lib/auth' @@ -25,6 +25,7 @@ const NAV: NavSection[] = [ { to: '/super-admin/countries', icon: Globe, label: 'Страны' }, { to: '/super-admin/groups', icon: FolderTree, label: 'Группы (эталон)' }, { to: '/super-admin/units', icon: Ruler, label: 'Ед. измерения (эталон)' }, + { to: '/super-admin/product-registry', icon: Package, label: 'Реестр товаров' }, ]}, { group: 'Аудит', items: [ { to: '/super-admin/audit-log', icon: FileClock, label: 'Журнал действий' }, diff --git a/src/food-market.web/src/locales/ru.json b/src/food-market.web/src/locales/ru.json index fd76873..387f316 100644 --- a/src/food-market.web/src/locales/ru.json +++ b/src/food-market.web/src/locales/ru.json @@ -138,7 +138,7 @@ "fieldPackaging": "Фасовка", "fieldBarcode": "Штрихкод", "fieldCost": "Себестоимость", - "fieldRetailPrice": "Розничная цена", + "fieldRetailPrice": "Продажная цена", "deleteConfirmTitle": "Удалить товар?", "deleteConfirmBody": "Удалить «{{name}}»? Действие необратимо.", "groupsTitle": "Группы товаров" diff --git a/src/food-market.web/src/pages/OnboardingWizardPage.tsx b/src/food-market.web/src/pages/OnboardingWizardPage.tsx index 0695786..72a1552 100644 --- a/src/food-market.web/src/pages/OnboardingWizardPage.tsx +++ b/src/food-market.web/src/pages/OnboardingWizardPage.tsx @@ -198,7 +198,7 @@ function StepProduct({ onNext, onSkip }: { onNext: () => void; onSkip: () => voi setName(e.target.value)} placeholder="например, Молоко 3.2%" />
- + setPrice(e.target.value)} placeholder="100" /> diff --git a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx index 88c3880..85b753c 100644 --- a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx +++ b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx @@ -173,7 +173,7 @@ export function OrganizationSettingsPage() {

setForm({ ...form, showReferencePriceOnProduct: v })} /> diff --git a/src/food-market.web/src/pages/PriceTypesPage.tsx b/src/food-market.web/src/pages/PriceTypesPage.tsx index b175421..cf83c8f 100644 --- a/src/food-market.web/src/pages/PriceTypesPage.tsx +++ b/src/food-market.web/src/pages/PriceTypesPage.tsx @@ -51,7 +51,7 @@ export function PriceTypesPage() { <> diff --git a/src/food-market.web/src/pages/ProductEditPage.tsx b/src/food-market.web/src/pages/ProductEditPage.tsx index 511ddb5..4dd11b5 100644 --- a/src/food-market.web/src/pages/ProductEditPage.tsx +++ b/src/food-market.web/src/pages/ProductEditPage.tsx @@ -425,7 +425,7 @@ export function ProductEditPage() {

Закупка

{!simpleMode && org.data?.showReferencePriceOnProduct && ( - + setForm({ ...form, referencePrice: n == null ? '' : String(n) })} diff --git a/src/food-market.web/src/pages/ProductGroupsPage.tsx b/src/food-market.web/src/pages/ProductGroupsPage.tsx index 5e0223c..ca21021 100644 --- a/src/food-market.web/src/pages/ProductGroupsPage.tsx +++ b/src/food-market.web/src/pages/ProductGroupsPage.tsx @@ -144,14 +144,14 @@ export function ProductGroupsPage() { onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} /> - + setForm({ ...form, markupPercent: n })} placeholder="нет автонаценки" />

- При проведении приёмки розничная цена товара = ⌈Себестоимость × (1 + наценка/100)⌉. + При проведении приёмки продажная цена товара = ⌈Себестоимость × (1 + наценка/100)⌉. Пусто — автонаценка отключена.

diff --git a/src/food-market.web/src/pages/ProductsPage.tsx b/src/food-market.web/src/pages/ProductsPage.tsx index 3b26588..3dbdfd5 100644 --- a/src/food-market.web/src/pages/ProductsPage.tsx +++ b/src/food-market.web/src/pages/ProductsPage.tsx @@ -218,7 +218,7 @@ export function ProductsPage() { // Колонка системной розничной цены: заголовок = PriceType.Name той записи // что помечена IsSystem (если пользователь её переименовал — заголовок меняется). { - header: systemPriceType?.name ?? 'Розничная цена', + header: systemPriceType?.name ?? 'Продажная цена', width: '170px', className: 'text-right font-mono', sortKey: 'systemPrice', diff --git a/src/food-market.web/src/pages/SuperAdminProductRegistryPage.tsx b/src/food-market.web/src/pages/SuperAdminProductRegistryPage.tsx new file mode 100644 index 0000000..cdba91b --- /dev/null +++ b/src/food-market.web/src/pages/SuperAdminProductRegistryPage.tsx @@ -0,0 +1,291 @@ +import { useEffect, useMemo, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Package, Search, Trash2, Save, RefreshCw, Database } from 'lucide-react' +import { api } from '@/lib/api' +import { PageHeader } from '@/components/PageHeader' +import { Field, TextInput, Checkbox } from '@/components/Field' +import { Button } from '@/components/Button' + +interface Card { + id: string; name: string; unitOfMeasureId: string; unitName: string | null; packaging: number + groupId: string | null; groupPath: string | null; cost: number; purchasePrice: number | null + retailPrice: number | null; source: number; sourceOrganizationId: string | null + barcodes: string[]; createdAt: string; updatedAt: string | null +} +interface CardList { total: number; count: number; items: Card[] } +interface EnrichDto { enabled: boolean; lastRunStartedAt: string | null; lastRunFinishedAt: string | null; lastAdded: number | null } +interface FillStatus { cards: number; groups: number; barcodes: number; lastResult: string | null; lastAt: string | null } +interface OrgFlag { id: string; name: string; contributesToRegistry: boolean; isArchived: boolean; hasMoySkladToken: boolean } + +const PKG = ['', 'Штучный', 'Весовой', 'Разливной'] +const SRC = ['', 'МойСклад', 'Магазин'] +const money = (n: number | null | undefined) => n == null ? '—' : new Intl.NumberFormat('ru').format(n) + +export function SuperAdminProductRegistryPage() { + const qc = useQueryClient() + const [search, setSearch] = useState('') + const [applied, setApplied] = useState('') + const [page, setPage] = useState(1) + const pageSize = 50 + + const { data: list } = useQuery({ + queryKey: ['registry-cards', applied, page], + queryFn: async () => (await api.get('/api/super-admin/product-registry', { params: { search: applied || undefined, page, pageSize } })).data, + }) + const { data: fill } = useQuery({ + queryKey: ['registry-fill-status'], + queryFn: async () => (await api.get('/api/super-admin/product-registry/fill-status')).data, + refetchInterval: 5000, + }) + const { data: enrich } = useQuery({ + queryKey: ['registry-enrich'], + queryFn: async () => (await api.get('/api/super-admin/product-registry/enrich')).data, + }) + const { data: orgs } = useQuery({ + queryKey: ['registry-orgs'], + queryFn: async () => (await api.get('/api/super-admin/product-registry/orgs')).data, + }) + + const fillResult = useMemo(() => { + try { return fill?.lastResult ? JSON.parse(fill.lastResult) as Record : null } catch { return null } + }, [fill?.lastResult]) + + return ( +
+
+ + + {/* Сводка + наполнение из МойСклад */} + qc.invalidateQueries({ queryKey: ['registry-fill-status'] })} /> + + {/* Фоновое пополнение */} + qc.invalidateQueries({ queryKey: ['registry-enrich'] })} /> + + {/* Поиск карточек */} +
+

Карточки реестра + (всего {list?.total ?? '…'})

+
+ setSearch(e.target.value)} placeholder="Поиск по наименованию или штрихкоду" + onKeyDown={(e) => { if (e.key === 'Enter') { setApplied(search.trim()); setPage(1) } }} /> + +
+
+ + + + + + + + {(list?.items ?? []).map(c => qc.invalidateQueries({ queryKey: ['registry-cards'] })} />)} + {list && list.items.length === 0 && } + +
НаименованиеШтрихкодыГруппаТипСебест.Закуп.Розн.Источник
Ничего не найдено.
+
+ {list && list.total > pageSize && ( +
+ + стр. {page} из {Math.ceil(list.total / pageSize)} + +
+ )} +
+ + {/* Участники пополнения */} + qc.invalidateQueries({ queryKey: ['registry-orgs'] })} /> +
+
+ ) +} + +function CardRow({ card, onChanged }: { card: Card; onChanged: () => void }) { + const [edit, setEdit] = useState(false) + const [name, setName] = useState(card.name) + const [cost, setCost] = useState(String(card.cost)) + const [ref, setRef] = useState(card.purchasePrice == null ? '' : String(card.purchasePrice)) + const [retail, setRetail] = useState(card.retailPrice == null ? '' : String(card.retailPrice)) + const [busy, setBusy] = useState(false) + + const save = async () => { + setBusy(true) + try { + await api.put(`/api/super-admin/product-registry/${card.id}`, { + name, groupId: card.groupId, packaging: card.packaging, + cost: Number(cost) || 0, purchasePrice: ref === '' ? null : Number(ref), + retailPrice: retail === '' ? null : Number(retail), unitOfMeasureId: card.unitOfMeasureId, + }) + setEdit(false); onChanged() + } finally { setBusy(false) } + } + const del = async () => { + if (!confirm(`Удалить карточку «${card.name}» из реестра?`)) return + await api.delete(`/api/super-admin/product-registry/${card.id}`); onChanged() + } + + if (edit) return ( + + setName(e.target.value)} /> + {card.barcodes.join(', ')} + {card.groupPath ?? '—'} + {PKG[card.packaging]} + setCost(e.target.value)} className="w-20 text-right" /> + setRef(e.target.value)} className="w-20 text-right" /> + setRetail(e.target.value)} className="w-20 text-right" /> + {SRC[card.source]} + + + + + + ) + return ( + + {card.name} + {card.barcodes.join(', ')} + {card.groupPath ?? '—'} + {PKG[card.packaging]} + {money(card.cost)} + {money(card.purchasePrice)} + {money(card.retailPrice)} + {SRC[card.source]} + + + + + + ) +} + +function FillSection({ fill, fillResult, orgs, onDone }: { fill: FillStatus | undefined; fillResult: Record | null; orgs: OrgFlag[]; onDone: () => void }) { + const withToken = orgs.filter(o => o.hasMoySkladToken && !o.isArchived) + const [srcId, setSrcId] = useState('') + const [reason, setReason] = useState('') + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState(null) + useEffect(() => { if (!srcId && withToken.length) setSrcId(withToken[0].id) }, [withToken, srcId]) + + const run = async () => { + if (!confirm('Пересоздать карточки реестра, пришедшие из МойСклад? Карточки из магазинов не затрагиваются.')) return + setBusy(true); setMsg(null) + try { + await api.post('/api/super-admin/product-registry/fill-from-moysklad', { sourceOrganizationId: srcId, reason }) + setMsg('Запущено. Результат появится ниже через минуту-другую (обновляется автоматически).') + setReason(''); onDone() + } catch (e) { + const err = e as { response?: { data?: { error?: string } } } + setMsg(err.response?.data?.error ?? 'Не удалось запустить') + } finally { setBusy(false) } + } + + const r = fillResult + return ( +
+

Наполнение из МойСклад

+
+ + + +
+ {r && ( +
+
Последнее наполнение {fill?.lastAt ? '· ' + new Date(fill.lastAt).toLocaleString('ru-RU') : ''}
+ {r.Error ?
Ошибка: {String(r.Error)}
: <> +
Товаров в МойСклад: {String(r.ProductsSeen)} · взято: {String(r.Taken)}
+
Пропущено: без штрихкода {String(r.SkippedNoBarcode)}, архивных {String(r.SkippedArchived)}, дубль-штрихкод {String(r.SkippedDuplicateBarcode)}
+
Не берём: модификаций {String(r.Variants)}, комплектов {String(r.Bundles)}, услуг {String(r.Services)}
+
Групп: {String(r.Groups)} · штрихкодов: {String(r.Barcodes)}
+ } +
+ )} +
+ + + + setReason(e.target.value)} placeholder="Наполняю реестр из МойСклад" /> + +
+ {msg &&

{msg}

} +
+ ) +} + +function EnrichSection({ enrich, onChange }: { enrich: EnrichDto | undefined; onChange: () => void }) { + const [pending, setPending] = useState(false) + const [loaded, setLoaded] = useState(false) + const [reason, setReason] = useState('') + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + useEffect(() => { if (enrich && !loaded) { setPending(enrich.enabled); setLoaded(true) } }, [enrich, loaded]) + + const save = async () => { + setBusy(true); setErr(null) + try { await api.put('/api/super-admin/product-registry/enrich', { enabled: pending, reason }); setReason(''); onChange() } + catch (e) { const x = e as { response?: { data?: { error?: string } } }; setErr(x.response?.data?.error ?? 'Ошибка') } + finally { setBusy(false) } + } + return ( +
+

Фоновое пополнение из магазинов

+

Периодически берёт в реестр штрихкоды, которых в нём ещё не было, из каталогов магазинов-участников. Существующие карточки не перезаписываются.

+
+ +
+
+ Последний прогон: {enrich?.lastRunFinishedAt ? new Date(enrich.lastRunFinishedAt).toLocaleString('ru-RU') : '—'} + {enrich?.lastAdded != null && <> · добавлено {enrich.lastAdded}} +
+ {err &&
{err}
} + setReason(e.target.value)} /> + +
+ ) +} + +function OrgsSection({ orgs, onChange }: { orgs: OrgFlag[]; onChange: () => void }) { + return ( +
+

Участники пополнения

+

«Пополняет реестр» — магазины, из каталогов которых берутся новые штрихкоды.

+
+ {orgs.filter(o => !o.isArchived).map(o => )} +
+
+ ) +} + +function OrgRow({ org, onChange }: { org: OrgFlag; onChange: () => void }) { + const [busy, setBusy] = useState(false) + const toggle = async () => { + const next = !org.contributesToRegistry + const reason = prompt(`Причина изменения «Пополняет реестр» для «${org.name}» (≥10 символов):`, '') + if (!reason || reason.trim().length < 10) return + setBusy(true) + try { await api.put(`/api/super-admin/product-registry/orgs/${org.id}/contributes`, { enabled: next, reason }); onChange() } + finally { setBusy(false) } + } + return ( +
+ {org.name}{org.hasMoySkladToken && (есть токен МойСклад)} + +
+ ) +} + +function Stat({ label, value }: { label: string; value: number | undefined }) { + return ( +
+
{label}
+
{value == null ? '…' : new Intl.NumberFormat('ru').format(value)}
+
+ ) +} diff --git a/src/food-market.web/src/pages/SupplyEditPage.tsx b/src/food-market.web/src/pages/SupplyEditPage.tsx index cdce480..bd4ce90 100644 --- a/src/food-market.web/src/pages/SupplyEditPage.tsx +++ b/src/food-market.web/src/pages/SupplyEditPage.tsx @@ -83,7 +83,7 @@ export function SupplyEditPage() { // тому, что увидит пользователь в карточке товара и в справочнике типов цен. const systemPriceTypeName = priceTypes.data?.find((pt) => pt.isSystem)?.name ?? priceTypes.data?.find((pt) => pt.isRetail)?.name - ?? 'Розничная' + ?? 'Продажная' const [form, setForm] = useState
(emptyForm) const [pickerOpen, setPickerOpen] = useState(false) diff --git a/tests/food-market.IntegrationTests/SupplyPricingSchemeTests.cs b/tests/food-market.IntegrationTests/SupplyPricingSchemeTests.cs new file mode 100644 index 0000000..cc216a3 --- /dev/null +++ b/tests/food-market.IntegrationTests/SupplyPricingSchemeTests.cs @@ -0,0 +1,97 @@ +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +/// Схема цен владельца 12.10: закупочная (purchasePrice) = цена строки +/// последней проведённой приёмки; себестоимость — скользящее среднее; продажная +/// меняется приёмкой только при ручной правке в строке. Unpost возвращает +/// закупочную из предыдущей проведённой приёмки. +[Collection(ApiCollection.Name)] +public class SupplyPricingSchemeTests +{ + private readonly ApiFactory _factory; + public SupplyPricingSchemeTests(ApiFactory factory) => _factory = factory; + + private static string RandomBarcode() + => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); + + private async Task<(decimal? purchase, decimal cost, decimal? retail)> ReadPricesAsync(ApiActor api, string productId, string retailTypeId) + { + var p = await api.GetJsonAsync($"/api/catalog/products/{productId}"); + decimal? purchase = p.TryGetProperty("purchasePrice", out var pp) && pp.ValueKind == JsonValueKind.Number ? pp.GetDecimal() : null; + decimal cost = p.GetProperty("cost").GetDecimal(); + decimal? retail = null; + foreach (var row in p.GetProperty("prices").EnumerateArray()) + if (row.GetProperty("priceTypeId").GetString() == retailTypeId) + retail = row.GetProperty("amount").GetDecimal(); + return (purchase, cost, retail); + } + + [Fact] + public async Task Supply_without_overrides_sets_purchase_and_cost_keeps_sale_price() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"pp-plain-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); + var productId = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 200m, RandomBarcode()); + + // Приёмка 10 шт по 100, без правки продажной цены в строке. + await api.CreatePostedSupplyAsync(refs, supplier, productId, 10m, 100m); + + var (purchase, cost, retail) = await ReadPricesAsync(api, productId, refs.PriceTypeId); + purchase.Should().Be(100m, "закупочная = цена строки приёмки"); + cost.Should().Be(100m, "себестоимость от нулевого остатка = цена приёмки (скользящее среднее)"); + retail.Should().Be(200m, "продажная не меняется, если её не правили в строке"); + } + + [Fact] + public async Task Supply_with_line_sale_override_updates_card_sale_price() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"pp-override-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); + var productId = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 200m, RandomBarcode()); + + var draft = await api.Http.PostAsJsonAsync("/api/purchases/supplies", new + { + date = DateTime.UtcNow, supplierId = supplier, storeId = refs.StoreId, currencyId = refs.CurrencyId, + notes = (string?)null, + lines = new[] { new { productId, quantity = 5m, unitPrice = 90m, retailPriceManuallyOverridden = true, retailPriceOverride = 250m } }, + }); + draft.EnsureSuccessStatusCode(); + var supplyId = (await draft.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { })).EnsureSuccessStatusCode(); + + var (purchase, _, retail) = await ReadPricesAsync(api, productId, refs.PriceTypeId); + purchase.Should().Be(90m); + retail.Should().Be(250m, "продажная обновилась из ручной правки в строке приёмки"); + } + + [Fact] + public async Task Unpost_restores_purchase_price_from_previous_supply() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"pp-unpost-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); + var productId = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 200m, RandomBarcode()); + + await api.CreatePostedSupplyAsync(refs, supplier, productId, 10m, 100m); // закупочная = 100 + var (_, dummyB) = await api.CreatePostedSupplyAsync(refs, supplier, productId, 5m, 120m); // закупочная = 120 + _ = dummyB; + + // Находим вторую приёмку и отменяем проведение. + var supplies = await api.ListAsync("/api/purchases/supplies?pageSize=200"); + var second = supplies.First(s => s.GetProperty("total").GetDecimal() == 600m).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{second}/unpost", new { })).EnsureSuccessStatusCode(); + + var (purchase, _, _) = await ReadPricesAsync(api, productId, refs.PriceTypeId); + purchase.Should().Be(100m, "после отмены проведения закупочная вернулась из предыдущей приёмки"); + } +} diff --git a/tests/food-market.UnitTests/DomainFullPropertyTouchTests.cs b/tests/food-market.UnitTests/DomainFullPropertyTouchTests.cs index c1aed45..afc34dc 100644 --- a/tests/food-market.UnitTests/DomainFullPropertyTouchTests.cs +++ b/tests/food-market.UnitTests/DomainFullPropertyTouchTests.cs @@ -99,7 +99,7 @@ public void Product_all_properties() DefaultSupplierId = Guid.NewGuid(), CountryOfOriginId = Guid.NewGuid(), IsService = false, Packaging = Packaging.Piece, IsMarked = false, MinStock = 10m, MaxStock = 100m, - ReferencePrice = 50m, ReferencePriceUpdatedAt = DateTime.UtcNow, + PurchasePrice = 50m, PurchasePriceUpdatedAt = DateTime.UtcNow, PurchaseCurrencyId = Guid.NewGuid(), Cost = 40m, LastSupplyAt = DateTime.UtcNow, ImageUrl = "/u.png", };