feat(registry+pricing): «Реестр товаров» + схема цен (закупочная/себестоимость/продажная)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

РЕЕСТР ТОВАРОВ (глобальный системный шаблон карточек):
- Домен 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 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-12 19:19:47 +05:00
parent 42e4f630b1
commit e39e4fe1d5
43 changed files with 1477 additions and 124 deletions

View file

@ -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<foodmarket.Infrastructure.Registry.ProductRegistryService>(
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<DbSchemaDocsJob>(

View file

@ -1,60 +0,0 @@
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Background;
/// <summary>Раз в сутки переписывает ReferencePrice = Cost для товаров,
/// у которых LastSupplyAt старше 30 дней и Cost > 0. Цель: устаревшая
/// «эталонная» цена не остаётся годами — её сравнивают с актуальной
/// себестоимостью. Если пользователь редактировал ReferencePrice вручную
/// (через PUT /api/catalog/products/...), ReferencePriceUpdatedAt уходит
/// в now, и таймер начинает 30 дней заново.</summary>
public class ReferencePriceRefreshJob : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<ReferencePriceRefreshJob> _log;
// Запускаем каждые 24 часа. Промах между сутками не критичен — цена
// не падает, а лишь догоняет текущую Cost.
private static readonly TimeSpan Period = TimeSpan.FromHours(24);
public ReferencePriceRefreshJob(IServiceProvider services, ILogger<ReferencePriceRefreshJob> 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<AppDbContext>();
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);
}
}

View file

@ -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<User> _userMgr;
private readonly IBackgroundJobClient _jobs;
public AuthSignupController(AppDbContext db, UserManager<User> userMgr)
public AuthSignupController(AppDbContext db, UserManager<User> 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<ActionResult<SignupResult>> Signup([FromBody] SignupInput inpu
RoleId = adminRole.Id, IsActive = true,
});
org.AccountOwnerUserId = user.Id;
// Копия «Реестра товаров» в каталог магазина — фоновым заданием, регистрация
// и первый вход не ждут. Пока идёт — админка показывает «Каталог загружается…».
org.RegistryCopyState = 1; // InProgress
await _db.SaveChangesAsync(ct);
_jobs.Enqueue<ProductRegistryService>(s => s.CopyRegistryToOrgAsync(org.Id, CancellationToken.None));
return new SignupResult(org.Id, user.Email);
}

View file

@ -166,11 +166,11 @@ private async Task<decimal> 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<decimal> 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<decimal> 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<ActionResult<ProductDto>> 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<IActionResult> 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<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicat
public record QuickSearchItem(
Guid Id, string Name, string? Article, string? DefaultBarcode,
decimal? ReferencePrice, decimal? StockQty);
decimal? PurchasePrice, decimal? StockQty);
/// <summary>Лёгкий поиск для 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<string> 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<List<QuickSearchItem>> 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<List<QuickSearchItem>> 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;

View file

@ -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;
/// <summary>Статус копии «Реестра товаров» в каталог этого магазина. Пока state=1
/// админка показывает «Каталог из реестра загружается…».</summary>
[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<ActionResult<StatusDto>> 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);
}
}

View file

@ -396,12 +396,10 @@ public async Task<IActionResult> 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<IActionResult> 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);

View file

@ -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;
/// <summary>SuperAdmin: «Реестр товаров» — системный шаблон карточек. Поиск/
/// просмотр/правка/удаление карточек, переключатель фонового пополнения
/// (с причиной в аудит), наполнение «из МойСклад заново», флаг участия у орг.</summary>
[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<ActionResult<CardListDto>> 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<ActionResult<CardDto>> 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<IActionResult> 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<IActionResult> 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<ActionResult<EnrichDto>> GetEnrich(CancellationToken ct)
{
var s = await GetSettingsAsync(ct);
return new EnrichDto(s.ProductRegistryEnrichEnabled, s.ProductRegistryEnrichLastRunStartedAt,
s.ProductRegistryEnrichLastRunFinishedAt, s.ProductRegistryEnrichLastAdded);
}
[HttpPut("enrich")]
public async Task<IActionResult> 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<ActionResult<object>> 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<IActionResult> 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<ProductRegistryService>(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<ActionResult<OrgFlagDto[]>> 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<IActionResult> 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<Domain.Platform.PlatformSettings> 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);
}
}

View file

@ -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);

View file

@ -442,6 +442,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladDocumentImportService>();
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
builder.Services.AddScoped<foodmarket.Infrastructure.Registry.ProductRegistryService>();
// Inventory
builder.Services.AddScoped<foodmarket.Application.Inventory.INegativeStockAlertService, foodmarket.Infrastructure.Inventory.NegativeStockAlertService>();
@ -538,7 +539,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// Витринный сидер для скриншотов сайта (POST /api/admin/seed-demo?showcase=1).
builder.Services.AddScoped<foodmarket.Api.Seed.ShowcaseSeeder>();
builder.Services.AddScoped<foodmarket.Api.Seed.BulkCatalogSeeder>();
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
// 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.

View file

@ -44,7 +44,7 @@ public async Task<object> 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<ProductPrice>
{
new() { OrganizationId = orgId, PriceTypeId = priceType.Id, Amount = 100m + (i % 900), CurrencyId = currency.Id },

View file

@ -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 =
[

View file

@ -118,7 +118,7 @@ public async Task<SeedSummary> 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<ProductPrice>
{
new ProductPrice
@ -199,7 +199,7 @@ public async Task<SeedSummary> 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<SeedSummary> 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<SeedSummary> 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
{

View file

@ -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 }
);
}

View file

@ -110,7 +110,7 @@ public async Task<SeedSummary> 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<ProductPrice>
{
@ -241,7 +241,7 @@ void AddSupply(DateTime date, IReadOnlyList<Product> picked, Func<int> 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<Product> picked, Func<int> 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
{

View file

@ -117,7 +117,7 @@ public async Task<SeedSummary> 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<ProductPrice>
{
@ -197,7 +197,7 @@ public async Task<SeedSummary> 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<SeedSummary> 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<SeedSummary> 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
{

View file

@ -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<ProductPriceInput>? Prices = null,

View file

@ -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,

View file

@ -36,11 +36,13 @@ public class Product : TenantEntity
public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений)
public decimal? MaxStock { get; set; } // максимальный остаток (для автозаказа)
/// <summary>«Эталонная» (справочная) цена закупа. Не обязательная.
/// Автоматически заполняется UnitPrice'ом первой проведённой приёмки.
/// Через 30 дней без новых приёмок Hangfire-job переписывает на текущую Cost.</summary>
public decimal? ReferencePrice { get; set; }
public DateTime? ReferencePriceUpdatedAt { get; set; }
/// <summary>Закупочная цена (последняя). Подставляется в строку приёмки;
/// после проведения приёмки = цена из строки (решение владельца 12.10).
/// Можно менять вручную в карточке. Стартовое значение — buyPrice МойСклад /
/// цена из «Реестра товаров». Раньше называлась «эталонная» (ReferencePrice).</summary>
public decimal? PurchasePrice { get; set; }
/// <summary>Когда закупочную цену последний раз меняли вручную (PUT товара).</summary>
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.</summary>
public decimal Cost { get; set; }
/// <summary>UTC-метка последней проведённой приёмки. Используется
/// 30-дневной job для перезаписи ReferencePrice на текущую Cost.</summary>
/// <summary>UTC-метка последней проведённой приёмки (информационная).</summary>
public DateTime? LastSupplyAt { get; set; }
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)

View file

@ -186,4 +186,16 @@ public class Organization : Entity
/// контура / sandbox'а). null → используется production URL из
/// реализации провайдера.</summary>
public string? FiscalApiBaseUrl { get; set; }
// ─ Реестр товаров ─────────────────────────────────────────────────────────
/// <summary>Пополняет ли каталог этого магазина общий «Реестр товаров»
/// (фоновое задание берёт новые штрихкоды в реестр). По умолчанию true для
/// зарегистрированных магазинов; демо/автотестовые выключаются.</summary>
public bool ContributesToRegistry { get; set; } = true;
/// <summary>Статус копии реестра в каталог магазина при регистрации:
/// 0=не было, 1=идёт загрузка, 2=готово, 3=ошибка. Пока 1 — в админке
/// показывается «Каталог из реестра загружается…». Повторно не копируется.</summary>
public int RegistryCopyState { get; set; }
public DateTime? RegistryCopiedAt { get; set; }
}

View file

@ -63,4 +63,18 @@ public class PlatformSettings : Entity
public int? MoySkladDeltaLastRunMigrated { get; set; }
/// <summary>Текст ошибки последнего прогона (если был сбой).</summary>
public string? MoySkladDeltaLastRunError { get; set; }
// ── Реестр товаров (системный шаблон карточек) ────────────────────────────
/// <summary>Фоновое пополнение реестра из каталогов магазинов-участников.
/// По умолчанию ВЫКЛ (включает SuperAdmin с причиной в аудит).</summary>
public bool ProductRegistryEnrichEnabled { get; set; }
public DateTime? ProductRegistryEnrichLastRunStartedAt { get; set; }
public DateTime? ProductRegistryEnrichLastRunFinishedAt { get; set; }
/// <summary>Сколько карточек добавлено в последнем фоновом прогоне.</summary>
public int? ProductRegistryEnrichLastAdded { get; set; }
/// <summary>Итог последнего наполнения реестра «из МойСклад заново» (JSON с
/// числами: товаров, взято, пропущено и т.п.) + когда выполнено.</summary>
public string? ProductRegistryFillLastResult { get; set; }
public DateTime? ProductRegistryFillLastAt { get; set; }
}

View file

@ -0,0 +1,71 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Registry;
/// <summary>Откуда пришла карточка реестра. MoySklad — наполнение «из МойСклад
/// заново» (пересоздаётся командой суперадмина). Shop — автопополнение из каталога
/// магазина-участника (не перезаписывается наполнением из МойСклад).</summary>
public enum RegistryCardSource
{
MoySklad = 1,
Shop = 2,
}
/// <summary>Группа «Реестра товаров» — глобальное дерево (Entity, без
/// OrganizationId, как страны/валюты). Путь денормализован как в источнике.</summary>
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; }
/// <summary>Id группы (productfolder) в МойСклад — для повторного наполнения.</summary>
public string? SourceExternalId { get; set; }
}
/// <summary>Карточка «Реестра товаров» — системный шаблон, которым наполняется
/// каталог нового магазина при регистрации. Глобальная (Entity, без
/// OrganizationId). Ключ — штрихкод (уникален в реестре, см. ProductRegistryBarcode).
/// Остатки/движения/документы/поставщики/мин-макс в реестр НЕ входят.</summary>
public class ProductRegistryCard : Entity
{
public string Name { get; set; } = null!;
/// <summary>Системная единица измерения ALL MARKET (глобальный справочник).</summary>
public Guid UnitOfMeasureId { get; set; }
public UnitOfMeasure? UnitOfMeasure { get; set; }
/// <summary>Тип товара — как в карточке товара (штучный/весовой/разливной).</summary>
public Packaging Packaging { get; set; } = Packaging.Piece;
public Guid? ProductRegistryGroupId { get; set; }
public ProductRegistryGroup? ProductRegistryGroup { get; set; }
/// <summary>Себестоимость (на старте = закупочная; далее в магазине —
/// скользящее среднее). Только чтение в карточке товара.</summary>
public decimal Cost { get; set; }
/// <summary>Закупочная цена (buyPrice МойСклад / закупочная магазина).</summary>
public decimal? PurchasePrice { get; set; }
/// <summary>Розничная (продажная) цена.</summary>
public decimal? RetailPrice { get; set; }
public RegistryCardSource Source { get; set; } = RegistryCardSource.Shop;
/// <summary>Организация-первооткрыватель (для карточек из магазинов).</summary>
public Guid? SourceOrganizationId { get; set; }
/// <summary>Id товара (product) в МойСклад — для наполнения из МойСклад.</summary>
public string? SourceExternalId { get; set; }
public ICollection<ProductRegistryBarcode> Barcodes { get; set; } = [];
}
/// <summary>Штрихкод карточки реестра. Код уникален в пределах всего реестра
/// (уникальный индекс) — именно штрихкод является ключом «новизны» товара.</summary>
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;
}

View file

@ -123,6 +123,22 @@ public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, C
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: true, ct)) yield return p;
}
/// <summary>Сколько сущностей данного типа в МойСклад (meta.size). Для
/// отчёта «сколько модификаций/комплектов/услуг пропущено» (мы их не берём).
/// Только чтение; null при ошибке/запрете.</summary>
public async Task<int?> 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<List<MsUom>> GetAllUomsAsync(string token, CancellationToken ct)
{
var all = new List<MsUom>();

View file

@ -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,
};

View file

@ -104,6 +104,9 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public bool SkipAudit { get; set; }
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
public DbSet<foodmarket.Domain.Platform.PlatformSettings> PlatformSettings => Set<foodmarket.Domain.Platform.PlatformSettings>();
public DbSet<foodmarket.Domain.Registry.ProductRegistryGroup> ProductRegistryGroups => Set<foodmarket.Domain.Registry.ProductRegistryGroup>();
public DbSet<foodmarket.Domain.Registry.ProductRegistryCard> ProductRegistryCards => Set<foodmarket.Domain.Registry.ProductRegistryCard>();
public DbSet<foodmarket.Domain.Registry.ProductRegistryBarcode> ProductRegistryBarcodes => Set<foodmarket.Domain.Registry.ProductRegistryBarcode>();
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<foodmarket.Domain.Registry.ProductRegistryGroup>(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<foodmarket.Domain.Registry.ProductRegistryCard>(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<int>();
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<foodmarket.Domain.Registry.ProductRegistryBarcode>(b =>
{
b.ToTable("product_registry_barcodes");
b.Property(x => x.Code).HasMaxLength(64).IsRequired();
b.Property(x => x.Type).HasConversion<int>();
// Штрихкод уникален во всём реестре — это ключ «новизны» товара.
b.HasIndex(x => x.Code).IsUnique();
});
builder.Entity<SuperAdminAuditLog>(b =>

View file

@ -142,7 +142,7 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> 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);

View file

@ -0,0 +1,103 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>«Реестр товаров» — глобальный системный шаблон карточек (без
/// OrganizationId). Три таблицы + флаги в platform_settings и organizations.
/// Идемпотентно (IF NOT EXISTS).</summary>
[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"";");
}
}
}

View file

@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Схема цен владельца 12.10: «эталонная» цена становится
/// «закупочной». products.ReferencePrice → PurchasePrice (данные сохраняются),
/// ReferencePriceUpdatedAt → PurchasePriceUpdatedAt. Системный тип цены
/// «Розничная цена» → «Продажная цена» (только если имя не меняли вручную).</summary>
[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;");
}
}
}

View file

@ -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;
/// <summary>«Реестр товаров» — системный шаблон карточек. Три операции:
/// наполнение из МойСклад (команда суперадмина, пересоздаёт MoySklad-карточки),
/// фоновое пополнение из каталогов магазинов-участников (новые штрихкоды),
/// копия всего реестра в каталог нового магазина при регистрации.
/// Глобальные таблицы, без tenant-фильтра. МойСклад — только чтение.</summary>
public class ProductRegistryService
{
private readonly AppDbContext _db;
private readonly MoySkladClient _ms;
private readonly ILogger<ProductRegistryService> _log;
public ProductRegistryService(AppDbContext db, MoySkladClient ms, ILogger<ProductRegistryService> 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);
/// <summary>Фоновая обёртка для Hangfire: выполняет наполнение из МойСклад и
/// пишет итог (JSON с числами) в PlatformSettings, чтобы суперадмин его видел.</summary>
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<FillResult> 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<string, ProductRegistryGroup>();
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<string>(
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<int> 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<string>(
await _db.ProductRegistryBarcodes.Select(b => b.Code).ToListAsync(ct),
StringComparer.OrdinalIgnoreCase);
var retailTypeByOrg = new Dictionary<Guid, Guid>();
var shopGroupByPath = new Dictionary<string, Guid>(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<Guid, Guid>();
var orgGroupIdByPath = new Dictionary<string, Guid>(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<string, BarcodeType> 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<string>(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;
}
/// <summary>Фильтр мусора для фонового пополнения: EAN-13/EAN-8 с неверной
/// контрольной цифрой — не берём; прочие типы (code128 и т.п.) — берём.</summary>
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]);
}

View file

@ -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() {
<Route path="units" element={lz(SuperAdminUnitsOfMeasurePage)} />
<Route path="settings" element={lz(SuperAdminSettingsPage)} />
<Route path="platform-settings" element={lz(SuperAdminPlatformSettingsPage)} />
<Route path="product-registry" element={lz(SuperAdminProductRegistryPage)} />
</Route>
{/* Tenant-роуты — обычный AppLayout, но с TenantRouteGuard:

View file

@ -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() {
<main className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
<SuperAdminAsOrgBanner />
<RegistryCopyBanner />
<NegativeStockBanner />
<WhatsNewBanner />
<Outlet />

View file

@ -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<Status>('/api/catalog/registry-copy-status')).data,
// Пока идёт загрузка — опрашиваем чаще, чтобы баннер сам исчез по готовности.
refetchInterval: (q) => (q.state.data?.state === 1 ? 4000 : false),
})
if (data?.state !== 1) return null
return (
<div className="flex items-center gap-2 px-4 py-2 text-sm bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200">
<Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
<span>Каталог из реестра загружается… Товары появятся через минуту-другую — можно продолжать работать.</span>
</div>
)
}

View file

@ -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: 'Журнал действий' },

View file

@ -138,7 +138,7 @@
"fieldPackaging": "Фасовка",
"fieldBarcode": "Штрихкод",
"fieldCost": "Себестоимость",
"fieldRetailPrice": "Розничная цена",
"fieldRetailPrice": "Продажная цена",
"deleteConfirmTitle": "Удалить товар?",
"deleteConfirmBody": "Удалить «{{name}}»? Действие необратимо.",
"groupsTitle": "Группы товаров"

View file

@ -198,7 +198,7 @@ function StepProduct({ onNext, onSkip }: { onNext: () => void; onSkip: () => voi
<TextInput value={name} onChange={(e) => setName(e.target.value)} placeholder="например, Молоко 3.2%" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Розничная цена">
<Field label="Продажная цена">
<TextInput type="number" inputMode="numeric" value={price}
onChange={(e) => setPrice(e.target.value)} placeholder="100" />
</Field>

View file

@ -173,7 +173,7 @@ export function OrganizationSettingsPage() {
</p>
<Checkbox
label='Показывать «Эталонную цену» на товаре'
label='Показывать «Закупочную цену» на товаре'
checked={form.showReferencePriceOnProduct}
onChange={(v) => setForm({ ...form, showReferencePriceOnProduct: v })}
/>

View file

@ -51,7 +51,7 @@ export function PriceTypesPage() {
<>
<ListPageShell
title="Типы цен"
description="Розничная, оптовая и другие ценовые группы."
description="Продажная, оптовая и другие ценовые группы."
actions={
<>
<SearchBar value={search} onChange={setSearch} />

View file

@ -425,7 +425,7 @@ export function ProductEditPage() {
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-3">Закупка</h3>
<div className="space-y-3">
{!simpleMode && org.data?.showReferencePriceOnProduct && (
<Field label="Эталонная цена">
<Field label="Закупочная цена">
<MoneyInput
value={form.referencePrice === '' ? null : Number(form.referencePrice)}
onChange={(n) => setForm({ ...form, referencePrice: n == null ? '' : String(n) })}

View file

@ -144,14 +144,14 @@ export function ProductGroupsPage() {
onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })}
/>
</Field>
<Field label="Наценка % (для авто-розничной по себестоимости)">
<Field label="Наценка % (для авто-продажной по себестоимости)">
<PercentInput
value={form.markupPercent}
onChange={(n) => setForm({ ...form, markupPercent: n })}
placeholder="нет автонаценки"
/>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
При проведении приёмки розничная цена товара = ⌈Себестоимость × (1 + наценка/100)⌉.
При проведении приёмки продажная цена товара = ⌈Себестоимость × (1 + наценка/100)⌉.
Пусто — автонаценка отключена.
</p>
</Field>

View file

@ -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',

View file

@ -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<CardList>('/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<FillStatus>('/api/super-admin/product-registry/fill-status')).data,
refetchInterval: 5000,
})
const { data: enrich } = useQuery({
queryKey: ['registry-enrich'],
queryFn: async () => (await api.get<EnrichDto>('/api/super-admin/product-registry/enrich')).data,
})
const { data: orgs } = useQuery({
queryKey: ['registry-orgs'],
queryFn: async () => (await api.get<OrgFlag[]>('/api/super-admin/product-registry/orgs')).data,
})
const fillResult = useMemo(() => {
try { return fill?.lastResult ? JSON.parse(fill.lastResult) as Record<string, unknown> : null } catch { return null }
}, [fill?.lastResult])
return (
<div className="h-full overflow-auto">
<div className="max-w-5xl mx-auto p-4 sm:p-6 space-y-5">
<PageHeader title="Реестр товаров"
description="Системный шаблон карточек: новый магазин при регистрации получает его копию. Наполняется из МойСклад и из каталогов магазинов-участников." />
{/* Сводка + наполнение из МойСклад */}
<FillSection fill={fill} fillResult={fillResult} orgs={orgs ?? []} onDone={() => qc.invalidateQueries({ queryKey: ['registry-fill-status'] })} />
{/* Фоновое пополнение */}
<EnrichSection enrich={enrich} onChange={() => qc.invalidateQueries({ queryKey: ['registry-enrich'] })} />
{/* Поиск карточек */}
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="font-semibold flex items-center gap-2"><Package className="w-4 h-4" /> Карточки реестра
<span className="text-sm font-normal text-slate-500">(всего {list?.total ?? '…'})</span></h3>
<div className="flex gap-2">
<TextInput value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Поиск по наименованию или штрихкоду"
onKeyDown={(e) => { if (e.key === 'Enter') { setApplied(search.trim()); setPage(1) } }} />
<Button variant="secondary" onClick={() => { setApplied(search.trim()); setPage(1) }}><Search className="w-4 h-4" /> Найти</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="text-left text-slate-500 border-b border-slate-200 dark:border-slate-700">
<th className="py-1.5 pr-2">Наименование</th><th className="pr-2">Штрихкоды</th><th className="pr-2">Группа</th>
<th className="pr-2">Тип</th><th className="pr-2 text-right">Себест.</th><th className="pr-2 text-right">Закуп.</th>
<th className="pr-2 text-right">Розн.</th><th className="pr-2">Источник</th><th></th>
</tr></thead>
<tbody>
{(list?.items ?? []).map(c => <CardRow key={c.id} card={c} onChanged={() => qc.invalidateQueries({ queryKey: ['registry-cards'] })} />)}
{list && list.items.length === 0 && <tr><td colSpan={9} className="py-4 text-center text-slate-500">Ничего не найдено.</td></tr>}
</tbody>
</table>
</div>
{list && list.total > pageSize && (
<div className="flex items-center gap-3 text-sm">
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>← Назад</Button>
<span>стр. {page} из {Math.ceil(list.total / pageSize)}</span>
<Button variant="secondary" disabled={page >= Math.ceil(list.total / pageSize)} onClick={() => setPage(p => p + 1)}>Вперёд →</Button>
</div>
)}
</section>
{/* Участники пополнения */}
<OrgsSection orgs={orgs ?? []} onChange={() => qc.invalidateQueries({ queryKey: ['registry-orgs'] })} />
</div>
</div>
)
}
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 (
<tr className="border-b border-slate-100 dark:border-slate-800 align-top">
<td className="py-1.5 pr-2"><TextInput value={name} onChange={e => setName(e.target.value)} /></td>
<td className="pr-2 text-xs">{card.barcodes.join(', ')}</td>
<td className="pr-2 text-xs">{card.groupPath ?? '—'}</td>
<td className="pr-2">{PKG[card.packaging]}</td>
<td className="pr-2"><TextInput value={cost} onChange={e => setCost(e.target.value)} className="w-20 text-right" /></td>
<td className="pr-2"><TextInput value={ref} onChange={e => setRef(e.target.value)} className="w-20 text-right" /></td>
<td className="pr-2"><TextInput value={retail} onChange={e => setRetail(e.target.value)} className="w-20 text-right" /></td>
<td className="pr-2">{SRC[card.source]}</td>
<td className="pr-2 whitespace-nowrap">
<button className="text-brand mr-2" disabled={busy} onClick={save}><Save className="w-4 h-4 inline" /></button>
<button className="text-slate-500" onClick={() => setEdit(false)}>×</button>
</td>
</tr>
)
return (
<tr className="border-b border-slate-100 dark:border-slate-800">
<td className="py-1.5 pr-2">{card.name}</td>
<td className="pr-2 text-xs text-slate-500">{card.barcodes.join(', ')}</td>
<td className="pr-2 text-xs text-slate-500">{card.groupPath ?? '—'}</td>
<td className="pr-2">{PKG[card.packaging]}</td>
<td className="pr-2 text-right tabular-nums">{money(card.cost)}</td>
<td className="pr-2 text-right tabular-nums">{money(card.purchasePrice)}</td>
<td className="pr-2 text-right tabular-nums">{money(card.retailPrice)}</td>
<td className="pr-2 text-xs">{SRC[card.source]}</td>
<td className="pr-2 whitespace-nowrap">
<button className="text-slate-500 hover:text-brand mr-2" onClick={() => setEdit(true)}>✎</button>
<button className="text-slate-400 hover:text-red-600" onClick={del}><Trash2 className="w-4 h-4 inline" /></button>
</td>
</tr>
)
}
function FillSection({ fill, fillResult, orgs, onDone }: { fill: FillStatus | undefined; fillResult: Record<string, unknown> | 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<string | null>(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 (
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="font-semibold flex items-center gap-2"><Database className="w-4 h-4" /> Наполнение из МойСклад</h3>
<div className="grid grid-cols-3 gap-3 text-sm">
<Stat label="Карточек в реестре" value={fill?.cards} />
<Stat label="Групп" value={fill?.groups} />
<Stat label="Штрихкодов" value={fill?.barcodes} />
</div>
{r && (
<div className="text-xs text-slate-600 dark:text-slate-400 rounded-md bg-slate-50 dark:bg-slate-800 p-3 space-y-0.5">
<div className="font-medium text-slate-700 dark:text-slate-300">Последнее наполнение {fill?.lastAt ? '· ' + new Date(fill.lastAt).toLocaleString('ru-RU') : ''}</div>
{r.Error ? <div className="text-red-600">Ошибка: {String(r.Error)}</div> : <>
<div>Товаров в МойСклад: {String(r.ProductsSeen)} · взято: {String(r.Taken)}</div>
<div>Пропущено: без штрихкода {String(r.SkippedNoBarcode)}, архивных {String(r.SkippedArchived)}, дубль-штрихкод {String(r.SkippedDuplicateBarcode)}</div>
<div>Не берём: модификаций {String(r.Variants)}, комплектов {String(r.Bundles)}, услуг {String(r.Services)}</div>
<div>Групп: {String(r.Groups)} · штрихкодов: {String(r.Barcodes)}</div>
</>}
</div>
)}
<div className="flex flex-wrap items-end gap-3">
<Field label="Источник (магазин с токеном МойСклад)">
<select value={srcId} onChange={e => setSrcId(e.target.value)} className="h-10 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 text-sm">
{withToken.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
{withToken.length === 0 && <option value="">нет магазинов с токеном</option>}
</select>
</Field>
<Field label="Причина (≥10, в журнал)"><TextInput value={reason} onChange={e => setReason(e.target.value)} placeholder="Наполняю реестр из МойСклад" /></Field>
<Button onClick={run} disabled={busy || !srcId || reason.trim().length < 10}><RefreshCw className="w-4 h-4" /> Наполнить из МойСклад заново</Button>
</div>
{msg && <p className="text-sm text-slate-600 dark:text-slate-400">{msg}</p>}
</section>
)
}
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<string | null>(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 (
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="font-semibold">Фоновое пополнение из магазинов</h3>
<p className="text-xs text-slate-500">Периодически берёт в реестр штрихкоды, которых в нём ещё не было, из каталогов магазинов-участников. Существующие карточки не перезаписываются.</p>
<div className="rounded-md border border-slate-200 dark:border-slate-700 px-3 py-2">
<Checkbox label={pending ? 'Включено' : 'Выключено'} checked={pending} onChange={setPending} />
</div>
<div className="text-sm text-slate-600 dark:text-slate-400">
Последний прогон: {enrich?.lastRunFinishedAt ? new Date(enrich.lastRunFinishedAt).toLocaleString('ru-RU') : '—'}
{enrich?.lastAdded != null && <> · добавлено {enrich.lastAdded}</>}
</div>
{err && <div className="text-sm text-red-600">{err}</div>}
<Field label="Причина (≥10, в журнал)"><TextInput value={reason} onChange={e => setReason(e.target.value)} /></Field>
<Button onClick={save} disabled={busy || reason.trim().length < 10 || !enrich || pending === enrich.enabled}>
<Save className="w-4 h-4" /> {pending ? 'Включить пополнение' : 'Выключить пополнение'}
</Button>
</section>
)
}
function OrgsSection({ orgs, onChange }: { orgs: OrgFlag[]; onChange: () => void }) {
return (
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="font-semibold">Участники пополнения</h3>
<p className="text-xs text-slate-500">«Пополняет реестр» — магазины, из каталогов которых берутся новые штрихкоды.</p>
<div className="divide-y divide-slate-100 dark:divide-slate-800">
{orgs.filter(o => !o.isArchived).map(o => <OrgRow key={o.id} org={o} onChange={onChange} />)}
</div>
</section>
)
}
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 (
<div className="flex items-center justify-between py-2 text-sm">
<span>{org.name}{org.hasMoySkladToken && <span className="ml-2 text-xs text-slate-400">(есть токен МойСклад)</span>}</span>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={org.contributesToRegistry} disabled={busy} onChange={toggle} className="w-4 h-4 accent-[var(--color-brand)]" />
<span className={org.contributesToRegistry ? 'text-emerald-600' : 'text-slate-400'}>{org.contributesToRegistry ? 'Вкл' : 'Выкл'}</span>
</label>
</div>
)
}
function Stat({ label, value }: { label: string; value: number | undefined }) {
return (
<div className="rounded-md bg-slate-50 dark:bg-slate-800 p-3">
<div className="text-xs text-slate-500">{label}</div>
<div className="text-xl font-bold tabular-nums">{value == null ? '…' : new Intl.NumberFormat('ru').format(value)}</div>
</div>
)
}

View file

@ -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<Form>(emptyForm)
const [pickerOpen, setPickerOpen] = useState(false)

View file

@ -0,0 +1,97 @@
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Схема цен владельца 12.10: закупочная (purchasePrice) = цена строки
/// последней проведённой приёмки; себестоимость — скользящее среднее; продажная
/// меняется приёмкой только при ручной правке в строке. Unpost возвращает
/// закупочную из предыдущей проведённой приёмки.</summary>
[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<JsonElement>()).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, "после отмены проведения закупочная вернулась из предыдущей приёмки");
}
}

View file

@ -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",
};