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
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:
parent
42e4f630b1
commit
e39e4fe1d5
|
|
@ -98,6 +98,16 @@ public Task StartAsync(CancellationToken ct)
|
||||||
cronExpression: cronDisk,
|
cronExpression: cronDisk,
|
||||||
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
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.
|
// Sprint 22: weekly DB schema documentation generation.
|
||||||
var cronSchema = _cfg["Hangfire:Cron:DbSchemaDocs"] ?? "0 5 * * 0"; // Воскресенье 05:00 UTC
|
var cronSchema = _cfg["Hangfire:Cron:DbSchemaDocs"] ?? "0 5 * * 0"; // Воскресенье 05:00 UTC
|
||||||
_jobs.AddOrUpdate<DbSchemaDocsJob>(
|
_jobs.AddOrUpdate<DbSchemaDocsJob>(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
using foodmarket.Domain.Organizations;
|
using foodmarket.Domain.Organizations;
|
||||||
using foodmarket.Infrastructure.Identity;
|
using foodmarket.Infrastructure.Identity;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using foodmarket.Infrastructure.Registry;
|
||||||
|
using Hangfire;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -22,10 +24,11 @@ public class AuthSignupController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly UserManager<User> _userMgr;
|
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);
|
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,
|
RoleId = adminRole.Id, IsActive = true,
|
||||||
});
|
});
|
||||||
org.AccountOwnerUserId = user.Id;
|
org.AccountOwnerUserId = user.Id;
|
||||||
|
// Копия «Реестра товаров» в каталог магазина — фоновым заданием, регистрация
|
||||||
|
// и первый вход не ждут. Пока идёт — админка показывает «Каталог загружается…».
|
||||||
|
org.RegistryCopyState = 1; // InProgress
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
_jobs.Enqueue<ProductRegistryService>(s => s.CopyRegistryToOrgAsync(org.Id, CancellationToken.None));
|
||||||
|
|
||||||
return new SignupResult(org.Id, user.Email);
|
return new SignupResult(org.Id, user.Email);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,11 +166,11 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
||||||
if (packaging is not null) q = q.Where(p => p.Packaging == packaging);
|
if (packaging is not null) q = q.Where(p => p.Packaging == packaging);
|
||||||
if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked);
|
if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked);
|
||||||
// referencePriceFrom/To — новый, актуальный параметр; purchasePriceFrom/To
|
// referencePriceFrom/To — новый, актуальный параметр; purchasePriceFrom/To
|
||||||
// — обратная совместимость c прежним UI (тоже по ReferencePrice).
|
// purchasePriceFrom/To — обратная совместимость c прежним UI (закупочная).
|
||||||
var refFrom = referencePriceFrom ?? purchasePriceFrom;
|
var refFrom = referencePriceFrom ?? purchasePriceFrom;
|
||||||
var refTo = referencePriceTo ?? purchasePriceTo;
|
var refTo = referencePriceTo ?? purchasePriceTo;
|
||||||
if (refFrom is not null) q = q.Where(p => p.ReferencePrice >= refFrom);
|
if (refFrom is not null) q = q.Where(p => p.PurchasePrice >= refFrom);
|
||||||
if (refTo is not null) q = q.Where(p => p.ReferencePrice <= refTo);
|
if (refTo is not null) q = q.Where(p => p.PurchasePrice <= refTo);
|
||||||
// Фильтр по системной (главной розничной) цене — берём Prices c PriceType.IsSystem=true.
|
// Фильтр по системной (главной розничной) цене — берём Prices c PriceType.IsSystem=true.
|
||||||
if (systemPriceFrom is not null)
|
if (systemPriceFrom is not null)
|
||||||
q = q.Where(p => p.Prices.Any(pr => pr.PriceType!.IsSystem && pr.Amount >= systemPriceFrom));
|
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),
|
("unit", true) => q.OrderByDescending(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name),
|
||||||
("packaging", false) => q.OrderBy(p => p.Packaging).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),
|
("packaging", true) => q.OrderByDescending(p => p.Packaging).ThenBy(p => p.Name),
|
||||||
("purchasePrice", false) => q.OrderBy(p => p.ReferencePrice).ThenBy(p => p.Name),
|
("purchasePrice", false) => q.OrderBy(p => p.PurchasePrice).ThenBy(p => p.Name),
|
||||||
("purchasePrice", true) => q.OrderByDescending(p => p.ReferencePrice).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", false) => q.OrderBy(p => p.Cost).ThenBy(p => p.Name),
|
||||||
("cost", true) => q.OrderByDescending(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),
|
("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);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
var fmt = (format ?? "csv").ToLowerInvariant();
|
var fmt = (format ?? "csv").ToLowerInvariant();
|
||||||
var headers = new[] { "Название", "Артикул", "Ед.", "Группа", "Штрихкод", "Розн. цена", "Себестоимость", "Статус" };
|
var headers = new[] { "Название", "Артикул", "Ед.", "Группа", "Штрихкод", "Прод. цена", "Себестоимость", "Статус" };
|
||||||
return fmt == "xlsx"
|
return fmt == "xlsx"
|
||||||
? ReportExport.Xlsx(rows, "products", "Товары", headers)
|
? ReportExport.Xlsx(rows, "products", "Товары", headers)
|
||||||
: ReportExport.Csv(rows, "products", headers);
|
: ReportExport.Csv(rows, "products", headers);
|
||||||
|
|
@ -309,7 +309,7 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
|
||||||
Apply(e, input);
|
Apply(e, input);
|
||||||
e.ProductGroupId = groupId.Value;
|
e.ProductGroupId = groupId.Value;
|
||||||
e.UnitOfMeasureId = unitId.Value;
|
e.UnitOfMeasureId = unitId.Value;
|
||||||
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
|
e.PurchasePrice = RoundIfNeeded(e.PurchasePrice, allowFractional);
|
||||||
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
|
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
|
||||||
if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct);
|
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);
|
Apply(e, input);
|
||||||
e.ProductGroupId = groupId.Value;
|
e.ProductGroupId = groupId.Value;
|
||||||
e.UnitOfMeasureId = unitId.Value;
|
e.UnitOfMeasureId = unitId.Value;
|
||||||
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
|
e.PurchasePrice = RoundIfNeeded(e.PurchasePrice, allowFractional);
|
||||||
|
|
||||||
// Children-collections правим через ExecuteDelete (минует трекер) +
|
// Children-collections правим через ExecuteDelete (минует трекер) +
|
||||||
// DbSet.Add — иначе EF8 на nav-collection путается и UPDATE на родителя
|
// DbSet.Add — иначе EF8 на nav-collection путается и UPDATE на родителя
|
||||||
|
|
@ -1110,7 +1110,7 @@ public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicat
|
||||||
|
|
||||||
public record QuickSearchItem(
|
public record QuickSearchItem(
|
||||||
Guid Id, string Name, string? Article, string? DefaultBarcode,
|
Guid Id, string Name, string? Article, string? DefaultBarcode,
|
||||||
decimal? ReferencePrice, decimal? StockQty);
|
decimal? PurchasePrice, decimal? StockQty);
|
||||||
|
|
||||||
/// <summary>Лёгкий поиск для inline-добавления строк в документы (приёмка,
|
/// <summary>Лёгкий поиск для inline-добавления строк в документы (приёмка,
|
||||||
/// продажа). Ранжирует точное совпадение штрихкода → точное артикула →
|
/// продажа). Ранжирует точное совпадение штрихкода → точное артикула →
|
||||||
|
|
@ -1138,7 +1138,7 @@ public record QuickSearchItem(
|
||||||
// делает накладные расходы пренебрежимыми.
|
// делает накладные расходы пренебрежимыми.
|
||||||
var raw = await q.Select(p => new
|
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(),
|
Barcodes = p.Barcodes.Select(b => new { b.Code, b.IsPrimary }).ToList(),
|
||||||
StockQty = storeId == null
|
StockQty = storeId == null
|
||||||
? (decimal?)_db.Stocks.Where(st => st.ProductId == p.Id).Sum(st => (decimal?)st.Quantity)
|
? (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(
|
Item = new QuickSearchItem(
|
||||||
r.Id, r.Name, r.Article,
|
r.Id, r.Name, r.Article,
|
||||||
r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault(),
|
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)),
|
Rank = Rank(r.Name, r.Article, r.Barcodes.Select(b => b.Code)),
|
||||||
})
|
})
|
||||||
.OrderBy(x => x.Rank).ThenBy(x => x.Item.Name)
|
.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))
|
.Where(p => p.Barcodes.Any(b => b.Code == code))
|
||||||
.Select(p => new
|
.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(),
|
Barcodes = p.Barcodes.Select(b => new { b.Code, b.IsPrimary }).ToList(),
|
||||||
StockQty = storeId == null
|
StockQty = storeId == null
|
||||||
? (decimal?)_db.Stocks.Where(st => st.ProductId == p.Id).Sum(st => (decimal?)st.Quantity)
|
? (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(
|
return matches.Select(r => new QuickSearchItem(
|
||||||
r.Id, r.Name, r.Article,
|
r.Id, r.Name, r.Article,
|
||||||
r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? code,
|
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(
|
public record WeightedBarcodeHit(
|
||||||
|
|
@ -1485,13 +1485,14 @@ private static void Apply(Product e, ProductInput i)
|
||||||
e.IsMarked = i.IsMarked;
|
e.IsMarked = i.IsMarked;
|
||||||
e.MinStock = i.MinStock;
|
e.MinStock = i.MinStock;
|
||||||
e.MaxStock = i.MaxStock;
|
e.MaxStock = i.MaxStock;
|
||||||
// ReferencePriceUpdatedAt подбиваем только при реальной смене цены
|
// Закупочная цена: вход — purchasePrice (новый) либо referencePrice
|
||||||
// (включая переход с null на значение и обратно). Этим помечаем,
|
// (временный алиас для старых клиентов/iOS). PurchasePriceUpdatedAt
|
||||||
// что цена «свежая», 30-дневный таймер автоперезаписи отсчитывается заново.
|
// подбиваем только при реальной смене (пометка «менялась вручную»).
|
||||||
if (e.ReferencePrice != i.ReferencePrice)
|
var inPurchase = i.PurchasePrice ?? i.ReferencePrice;
|
||||||
|
if (e.PurchasePrice != inPurchase)
|
||||||
{
|
{
|
||||||
e.ReferencePrice = i.ReferencePrice;
|
e.PurchasePrice = inPurchase;
|
||||||
e.ReferencePriceUpdatedAt = i.ReferencePrice.HasValue ? DateTime.UtcNow : null;
|
e.PurchasePriceUpdatedAt = inPurchase.HasValue ? DateTime.UtcNow : null;
|
||||||
}
|
}
|
||||||
e.PurchaseCurrencyId = i.PurchaseCurrencyId;
|
e.PurchaseCurrencyId = i.PurchaseCurrencyId;
|
||||||
e.ImageUrl = i.ImageUrl;
|
e.ImageUrl = i.ImageUrl;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -396,12 +396,10 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
product.Cost = foodmarket.Application.Inventory.MovingAverageCost.Compute(
|
product.Cost = foodmarket.Application.Inventory.MovingAverageCost.Compute(
|
||||||
currentQty, product.Cost, line.Quantity, line.UnitPrice);
|
currentQty, product.Cost, line.Quantity, line.UnitPrice);
|
||||||
|
|
||||||
// 2. ReferencePrice — автозаполнение при первой приёмке.
|
// 2. Закупочная цена (последняя) = цена из строки приёмки — каждая
|
||||||
if (product.ReferencePrice is null)
|
// проведённая приёмка пишет её (решение владельца 12.10).
|
||||||
{
|
product.PurchasePrice = line.UnitPrice;
|
||||||
product.ReferencePrice = line.UnitPrice;
|
product.PurchasePriceUpdatedAt = now;
|
||||||
product.ReferencePriceUpdatedAt = now;
|
|
||||||
}
|
|
||||||
product.LastSupplyAt = now;
|
product.LastSupplyAt = now;
|
||||||
|
|
||||||
// 3. Розничная: либо явный override строки, либо автонаценка по группе.
|
// 3. Розничная: либо явный override строки, либо автонаценка по группе.
|
||||||
|
|
@ -542,6 +540,24 @@ public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||||||
Notes: $"Отмена проведения документа {supply.Number}"), 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.Status = SupplyStatus.Draft;
|
||||||
supply.PostedAt = null;
|
supply.PostedAt = null;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -99,6 +99,7 @@ public ProductInputValidator()
|
||||||
// контроллер генерирует внутренний EAN-13.
|
// контроллер генерирует внутренний EAN-13.
|
||||||
RuleFor(x => x.Vat).InclusiveBetween(0m, 100m).When(x => x.Vat.HasValue)
|
RuleFor(x => x.Vat).InclusiveBetween(0m, 100m).When(x => x.Vat.HasValue)
|
||||||
.WithMessage("Ставка НДС должна быть от 0 до 100%.");
|
.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.ReferencePrice).GreaterThanOrEqualTo(0).When(x => x.ReferencePrice.HasValue);
|
||||||
RuleFor(x => x.MinStock).GreaterThanOrEqualTo(0).When(x => x.MinStock.HasValue);
|
RuleFor(x => x.MinStock).GreaterThanOrEqualTo(0).When(x => x.MinStock.HasValue);
|
||||||
RuleFor(x => x.MaxStock).GreaterThanOrEqualTo(0).When(x => x.MaxStock.HasValue);
|
RuleFor(x => x.MaxStock).GreaterThanOrEqualTo(0).When(x => x.MaxStock.HasValue);
|
||||||
|
|
|
||||||
|
|
@ -442,6 +442,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
|
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
|
||||||
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladDocumentImportService>();
|
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladDocumentImportService>();
|
||||||
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
|
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
|
||||||
|
builder.Services.AddScoped<foodmarket.Infrastructure.Registry.ProductRegistryService>();
|
||||||
|
|
||||||
// Inventory
|
// Inventory
|
||||||
builder.Services.AddScoped<foodmarket.Application.Inventory.INegativeStockAlertService, foodmarket.Infrastructure.Inventory.NegativeStockAlertService>();
|
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).
|
// Витринный сидер для скриншотов сайта (POST /api/admin/seed-demo?showcase=1).
|
||||||
builder.Services.AddScoped<foodmarket.Api.Seed.ShowcaseSeeder>();
|
builder.Services.AddScoped<foodmarket.Api.Seed.ShowcaseSeeder>();
|
||||||
builder.Services.AddScoped<foodmarket.Api.Seed.BulkCatalogSeeder>();
|
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.
|
// DemoCatalogSeeder disabled: real catalog is imported from MoySklad.
|
||||||
// Keep the file as reference for anyone starting without MoySklad access —
|
// Keep the file as reference for anyone starting without MoySklad access —
|
||||||
// just re-register here to turn demo data back on.
|
// just re-register here to turn demo data back on.
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ public async Task<object> SeedAsync(Guid orgId, int target, CancellationToken ct
|
||||||
Name = $"Товар демонстрационный №{i + 1:D5}",
|
Name = $"Товар демонстрационный №{i + 1:D5}",
|
||||||
Article = $"BULK-{i + 1:D6}",
|
Article = $"BULK-{i + 1:D6}",
|
||||||
UnitOfMeasureId = unit.Id, Vat = 12m, VatEnabled = true,
|
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>
|
Prices = new List<ProductPrice>
|
||||||
{
|
{
|
||||||
new() { OrganizationId = orgId, PriceTypeId = priceType.Id, Amount = 100m + (i % 900), CurrencyId = currency.Id },
|
new() { OrganizationId = orgId, PriceTypeId = priceType.Id, Amount = 100m + (i % 900), CurrencyId = currency.Id },
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,7 @@ Guid AddGroup(string name, Guid? parentId)
|
||||||
ProductGroupId = d.Group,
|
ProductGroupId = d.Group,
|
||||||
CountryOfOriginId = d.Country,
|
CountryOfOriginId = d.Country,
|
||||||
Packaging = d.IsWeighed ? Packaging.Weight : Packaging.Piece,
|
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,
|
PurchaseCurrencyId = kzt.Id,
|
||||||
Prices =
|
Prices =
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
|
||||||
Vat = 16m, VatEnabled = true,
|
Vat = 16m, VatEnabled = true,
|
||||||
ProductGroupId = groups[g].Id,
|
ProductGroupId = groups[g].Id,
|
||||||
Packaging = packaging,
|
Packaging = packaging,
|
||||||
ReferencePrice = t.RetailPrice,
|
PurchasePrice = t.RetailPrice,
|
||||||
Prices = new List<ProductPrice>
|
Prices = new List<ProductPrice>
|
||||||
{
|
{
|
||||||
new ProductPrice
|
new ProductPrice
|
||||||
|
|
@ -199,7 +199,7 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
|
||||||
foreach (var p in picked)
|
foreach (var p in picked)
|
||||||
{
|
{
|
||||||
var qty = _rng.Next(20, 100);
|
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;
|
var lt = qty * price;
|
||||||
supply.Lines.Add(new SupplyLine
|
supply.Lines.Add(new SupplyLine
|
||||||
{
|
{
|
||||||
|
|
@ -253,7 +253,7 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
|
||||||
foreach (var p in picked)
|
foreach (var p in picked)
|
||||||
{
|
{
|
||||||
var qty = _rng.Next(1, 4);
|
var qty = _rng.Next(1, 4);
|
||||||
var price = p.ReferencePrice!.Value;
|
var price = p.PurchasePrice!.Value;
|
||||||
var lt = qty * price;
|
var lt = qty * price;
|
||||||
sale.Lines.Add(new RetailSaleLine
|
sale.Lines.Add(new RetailSaleLine
|
||||||
{
|
{
|
||||||
|
|
@ -294,7 +294,7 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
|
||||||
foreach (var p in demandPicked)
|
foreach (var p in demandPicked)
|
||||||
{
|
{
|
||||||
var qty = _rng.Next(5, 15);
|
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;
|
var lt = qty * price;
|
||||||
demand.Lines.Add(new DemandLine
|
demand.Lines.Add(new DemandLine
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
|
||||||
if (!anyPriceType)
|
if (!anyPriceType)
|
||||||
{
|
{
|
||||||
db.PriceTypes.AddRange(
|
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 }
|
new PriceType { OrganizationId = orgId, Name = "Оптовая", SortOrder = 1 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
|
||||||
Vat = 12m, VatEnabled = true,
|
Vat = 12m, VatEnabled = true,
|
||||||
ProductGroupId = groups[g].Id,
|
ProductGroupId = groups[g].Id,
|
||||||
Packaging = packaging,
|
Packaging = packaging,
|
||||||
ReferencePrice = price,
|
PurchasePrice = price,
|
||||||
Cost = Math.Round(price * 0.6m, 2),
|
Cost = Math.Round(price * 0.6m, 2),
|
||||||
Prices = new List<ProductPrice>
|
Prices = new List<ProductPrice>
|
||||||
{
|
{
|
||||||
|
|
@ -241,7 +241,7 @@ void AddSupply(DateTime date, IReadOnlyList<Product> picked, Func<int> qtyFn)
|
||||||
foreach (var p in picked)
|
foreach (var p in picked)
|
||||||
{
|
{
|
||||||
var qty = qtyFn();
|
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;
|
var lt = qty * price;
|
||||||
supply.Lines.Add(new SupplyLine
|
supply.Lines.Add(new SupplyLine
|
||||||
{
|
{
|
||||||
|
|
@ -301,7 +301,7 @@ void AddSupply(DateTime date, IReadOnlyList<Product> picked, Func<int> qtyFn)
|
||||||
foreach (var p in picked)
|
foreach (var p in picked)
|
||||||
{
|
{
|
||||||
var qty = _rng.Next(1, 4);
|
var qty = _rng.Next(1, 4);
|
||||||
var price = p.ReferencePrice!.Value;
|
var price = p.PurchasePrice!.Value;
|
||||||
var lt = qty * price;
|
var lt = qty * price;
|
||||||
sale.Lines.Add(new RetailSaleLine
|
sale.Lines.Add(new RetailSaleLine
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
|
||||||
Vat = 12m, VatEnabled = true,
|
Vat = 12m, VatEnabled = true,
|
||||||
ProductGroupId = groups[g].Id,
|
ProductGroupId = groups[g].Id,
|
||||||
Packaging = packaging,
|
Packaging = packaging,
|
||||||
ReferencePrice = price,
|
PurchasePrice = price,
|
||||||
Cost = Math.Round(price * 0.6m, 2),
|
Cost = Math.Round(price * 0.6m, 2),
|
||||||
Prices = new List<ProductPrice>
|
Prices = new List<ProductPrice>
|
||||||
{
|
{
|
||||||
|
|
@ -197,7 +197,7 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
|
||||||
foreach (var p in picked)
|
foreach (var p in picked)
|
||||||
{
|
{
|
||||||
var qty = _rng.Next(30, 120);
|
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;
|
var lt = qty * price;
|
||||||
supply.Lines.Add(new SupplyLine
|
supply.Lines.Add(new SupplyLine
|
||||||
{
|
{
|
||||||
|
|
@ -271,7 +271,7 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
|
||||||
foreach (var p in picked)
|
foreach (var p in picked)
|
||||||
{
|
{
|
||||||
var qty = _rng.Next(1, 4);
|
var qty = _rng.Next(1, 4);
|
||||||
var price = p.ReferencePrice!.Value;
|
var price = p.PurchasePrice!.Value;
|
||||||
var lt = qty * price;
|
var lt = qty * price;
|
||||||
sale.Lines.Add(new RetailSaleLine
|
sale.Lines.Add(new RetailSaleLine
|
||||||
{
|
{
|
||||||
|
|
@ -359,7 +359,7 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
|
||||||
foreach (var p in picked)
|
foreach (var p in picked)
|
||||||
{
|
{
|
||||||
var qty = _rng.Next(10, 30);
|
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;
|
var lt = qty * price;
|
||||||
d.Lines.Add(new DemandLine
|
d.Lines.Add(new DemandLine
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,10 @@ public record ProductDto(
|
||||||
Guid? CountryOfOriginId, string? CountryOfOriginName,
|
Guid? CountryOfOriginId, string? CountryOfOriginName,
|
||||||
bool IsService, Packaging Packaging, bool IsMarked,
|
bool IsService, Packaging Packaging, bool IsMarked,
|
||||||
decimal? MinStock, decimal? MaxStock,
|
decimal? MinStock, decimal? MaxStock,
|
||||||
|
decimal? PurchasePrice, DateTime? PurchasePriceUpdatedAt,
|
||||||
|
// referencePrice/referencePriceUpdatedAt — ВРЕМЕННЫЕ алиасы закупочной цены
|
||||||
|
// для iOS-приложения ALL MARKET Staff (читает referencePrice). После перевода
|
||||||
|
// приложения на purchasePrice алиасы убрать.
|
||||||
decimal? ReferencePrice, DateTime? ReferencePriceUpdatedAt,
|
decimal? ReferencePrice, DateTime? ReferencePriceUpdatedAt,
|
||||||
Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
|
Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
|
||||||
decimal Cost, DateTime? LastSupplyAt,
|
decimal Cost, DateTime? LastSupplyAt,
|
||||||
|
|
@ -97,6 +101,8 @@ public record ProductInput(
|
||||||
Guid ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
|
Guid ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
|
||||||
bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false,
|
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? 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,
|
[Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null,
|
||||||
[StringLength(1000)] string? ImageUrl = null,
|
[StringLength(1000)] string? ImageUrl = null,
|
||||||
IReadOnlyList<ProductPriceInput>? Prices = null,
|
IReadOnlyList<ProductPriceInput>? Prices = null,
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,8 @@ public static TypeAdapterConfig Build()
|
||||||
src.CountryOfOrigin != null ? src.CountryOfOrigin.Name : null,
|
src.CountryOfOrigin != null ? src.CountryOfOrigin.Name : null,
|
||||||
src.IsService, src.Packaging, src.IsMarked,
|
src.IsService, src.Packaging, src.IsMarked,
|
||||||
src.MinStock, src.MaxStock,
|
src.MinStock, src.MaxStock,
|
||||||
src.ReferencePrice, src.ReferencePriceUpdatedAt,
|
src.PurchasePrice, src.PurchasePriceUpdatedAt,
|
||||||
|
src.PurchasePrice, src.PurchasePriceUpdatedAt, // алиасы referencePrice* = закупочная (для iOS)
|
||||||
src.PurchaseCurrencyId,
|
src.PurchaseCurrencyId,
|
||||||
src.PurchaseCurrency != null ? src.PurchaseCurrency.Code : null,
|
src.PurchaseCurrency != null ? src.PurchaseCurrency.Code : null,
|
||||||
src.Cost, src.LastSupplyAt,
|
src.Cost, src.LastSupplyAt,
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,13 @@ public class Product : TenantEntity
|
||||||
public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений)
|
public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений)
|
||||||
public decimal? MaxStock { get; set; } // максимальный остаток (для автозаказа)
|
public decimal? MaxStock { get; set; } // максимальный остаток (для автозаказа)
|
||||||
|
|
||||||
/// <summary>«Эталонная» (справочная) цена закупа. Не обязательная.
|
/// <summary>Закупочная цена (последняя). Подставляется в строку приёмки;
|
||||||
/// Автоматически заполняется UnitPrice'ом первой проведённой приёмки.
|
/// после проведения приёмки = цена из строки (решение владельца 12.10).
|
||||||
/// Через 30 дней без новых приёмок Hangfire-job переписывает на текущую Cost.</summary>
|
/// Можно менять вручную в карточке. Стартовое значение — buyPrice МойСклад /
|
||||||
public decimal? ReferencePrice { get; set; }
|
/// цена из «Реестра товаров». Раньше называлась «эталонная» (ReferencePrice).</summary>
|
||||||
public DateTime? ReferencePriceUpdatedAt { get; set; }
|
public decimal? PurchasePrice { get; set; }
|
||||||
|
/// <summary>Когда закупочную цену последний раз меняли вручную (PUT товара).</summary>
|
||||||
|
public DateTime? PurchasePriceUpdatedAt { get; set; }
|
||||||
public Guid? PurchaseCurrencyId { get; set; }
|
public Guid? PurchaseCurrencyId { get; set; }
|
||||||
public Currency? PurchaseCurrency { get; set; }
|
public Currency? PurchaseCurrency { get; set; }
|
||||||
|
|
||||||
|
|
@ -49,8 +51,7 @@ public class Product : TenantEntity
|
||||||
/// Хранится с 4 знаками для точности; UI показывает 2.</summary>
|
/// Хранится с 4 знаками для точности; UI показывает 2.</summary>
|
||||||
public decimal Cost { get; set; }
|
public decimal Cost { get; set; }
|
||||||
|
|
||||||
/// <summary>UTC-метка последней проведённой приёмки. Используется
|
/// <summary>UTC-метка последней проведённой приёмки (информационная).</summary>
|
||||||
/// 30-дневной job для перезаписи ReferencePrice на текущую Cost.</summary>
|
|
||||||
public DateTime? LastSupplyAt { get; set; }
|
public DateTime? LastSupplyAt { get; set; }
|
||||||
|
|
||||||
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)
|
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)
|
||||||
|
|
|
||||||
|
|
@ -186,4 +186,16 @@ public class Organization : Entity
|
||||||
/// контура / sandbox'а). null → используется production URL из
|
/// контура / sandbox'а). null → используется production URL из
|
||||||
/// реализации провайдера.</summary>
|
/// реализации провайдера.</summary>
|
||||||
public string? FiscalApiBaseUrl { get; set; }
|
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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,4 +63,18 @@ public class PlatformSettings : Entity
|
||||||
public int? MoySkladDeltaLastRunMigrated { get; set; }
|
public int? MoySkladDeltaLastRunMigrated { get; set; }
|
||||||
/// <summary>Текст ошибки последнего прогона (если был сбой).</summary>
|
/// <summary>Текст ошибки последнего прогона (если был сбой).</summary>
|
||||||
public string? MoySkladDeltaLastRunError { get; set; }
|
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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
src/food-market.domain/Registry/ProductRegistry.cs
Normal file
71
src/food-market.domain/Registry/ProductRegistry.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
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)
|
public async Task<List<MsUom>> GetAllUomsAsync(string token, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var all = new List<MsUom>();
|
var all = new List<MsUom>();
|
||||||
|
|
|
||||||
|
|
@ -429,7 +429,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct, includeArchived:
|
||||||
existing.CountryOfOriginId = countryId ?? existing.CountryOfOriginId;
|
existing.CountryOfOriginId = countryId ?? existing.CountryOfOriginId;
|
||||||
existing.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece;
|
existing.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece;
|
||||||
existing.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED";
|
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;
|
if (existing.PurchaseCurrencyId is null) existing.PurchaseCurrencyId = kzt.Id;
|
||||||
// Привязываем MS id в любом случае — это ключ будущих upsert'ов.
|
// Привязываем MS id в любом случае — это ключ будущих upsert'ов.
|
||||||
if (string.IsNullOrEmpty(existing.MoySkladExternalId))
|
if (string.IsNullOrEmpty(existing.MoySkladExternalId))
|
||||||
|
|
@ -500,7 +500,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct, includeArchived:
|
||||||
CountryOfOriginId = countryId,
|
CountryOfOriginId = countryId,
|
||||||
Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece,
|
Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece,
|
||||||
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
|
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,
|
PurchaseCurrencyId = kzt.Id,
|
||||||
MoySkladExternalId = p.Id,
|
MoySkladExternalId = p.Id,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,9 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
||||||
public bool SkipAudit { get; set; }
|
public bool SkipAudit { get; set; }
|
||||||
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
|
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
|
||||||
public DbSet<foodmarket.Domain.Platform.PlatformSettings> PlatformSettings => Set<foodmarket.Domain.Platform.PlatformSettings>();
|
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)
|
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.FromEmail).HasMaxLength(200);
|
||||||
b.Property(x => x.FromName).HasMaxLength(200);
|
b.Property(x => x.FromName).HasMaxLength(200);
|
||||||
b.Property(x => x.MoySkladDeltaLastRunError).HasMaxLength(2000);
|
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 =>
|
builder.Entity<SuperAdminAuditLog>(b =>
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
|
||||||
b.Property(x => x.Article).HasMaxLength(500);
|
b.Property(x => x.Article).HasMaxLength(500);
|
||||||
b.Property(x => x.MinStock).HasPrecision(18, 4);
|
b.Property(x => x.MinStock).HasPrecision(18, 4);
|
||||||
b.Property(x => x.MaxStock).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.Cost).HasPrecision(18, 4);
|
||||||
b.Property(x => x.ImageUrl).HasMaxLength(1000);
|
b.Property(x => x.ImageUrl).HasMaxLength(1000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"";");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
|
@ -99,6 +99,7 @@ const SuperAdminSettingsPage = lazy(() => import('@/pages/SuperAdminSettingsPage
|
||||||
const SuperAdminUnitsOfMeasurePage = lazy(() => import('@/pages/SuperAdminUnitsOfMeasurePage').then(m => ({ default: m.SuperAdminUnitsOfMeasurePage })))
|
const SuperAdminUnitsOfMeasurePage = lazy(() => import('@/pages/SuperAdminUnitsOfMeasurePage').then(m => ({ default: m.SuperAdminUnitsOfMeasurePage })))
|
||||||
const SuperAdminOrgEmployeesPage = lazy(() => import('@/pages/SuperAdminOrgEmployeesPage').then(m => ({ default: m.SuperAdminOrgEmployeesPage })))
|
const SuperAdminOrgEmployeesPage = lazy(() => import('@/pages/SuperAdminOrgEmployeesPage').then(m => ({ default: m.SuperAdminOrgEmployeesPage })))
|
||||||
const SuperAdminPlatformSettingsPage = lazy(() => import('@/pages/SuperAdminPlatformSettingsPage').then(m => ({ default: m.SuperAdminPlatformSettingsPage })))
|
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-страниц. Возвращает компонент,
|
/** Suspense-обёртка с form-скелетом для lazy-страниц. Возвращает компонент,
|
||||||
* пригодный к использованию как element={...}. */
|
* пригодный к использованию как element={...}. */
|
||||||
|
|
@ -158,6 +159,7 @@ export default function App() {
|
||||||
<Route path="units" element={lz(SuperAdminUnitsOfMeasurePage)} />
|
<Route path="units" element={lz(SuperAdminUnitsOfMeasurePage)} />
|
||||||
<Route path="settings" element={lz(SuperAdminSettingsPage)} />
|
<Route path="settings" element={lz(SuperAdminSettingsPage)} />
|
||||||
<Route path="platform-settings" element={lz(SuperAdminPlatformSettingsPage)} />
|
<Route path="platform-settings" element={lz(SuperAdminPlatformSettingsPage)} />
|
||||||
|
<Route path="product-registry" element={lz(SuperAdminProductRegistryPage)} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Tenant-роуты — обычный AppLayout, но с TenantRouteGuard:
|
{/* Tenant-роуты — обычный AppLayout, но с TenantRouteGuard:
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { FeedbackWidget } from './FeedbackWidget'
|
||||||
import { WhatsNewBanner } from './WhatsNewBanner'
|
import { WhatsNewBanner } from './WhatsNewBanner'
|
||||||
import { NotificationCenter } from './NotificationCenter'
|
import { NotificationCenter } from './NotificationCenter'
|
||||||
import { NegativeStockBanner } from './NegativeStockBanner'
|
import { NegativeStockBanner } from './NegativeStockBanner'
|
||||||
|
import { RegistryCopyBanner } from './RegistryCopyBanner'
|
||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
sub: string
|
sub: string
|
||||||
|
|
@ -369,6 +370,7 @@ export function AppLayout() {
|
||||||
|
|
||||||
<main className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
|
<main className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
|
||||||
<SuperAdminAsOrgBanner />
|
<SuperAdminAsOrgBanner />
|
||||||
|
<RegistryCopyBanner />
|
||||||
<NegativeStockBanner />
|
<NegativeStockBanner />
|
||||||
<WhatsNewBanner />
|
<WhatsNewBanner />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
|
||||||
26
src/food-market.web/src/components/RegistryCopyBanner.tsx
Normal file
26
src/food-market.web/src/components/RegistryCopyBanner.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
ShieldCheck, Building, FileClock, HeartPulse, HardDriveDownload,
|
ShieldCheck, Building, FileClock, HeartPulse, HardDriveDownload,
|
||||||
Settings, Users, LayoutDashboard, LogOut, Menu, X, ChevronDown, Globe,
|
Settings, Users, LayoutDashboard, LogOut, Menu, X, ChevronDown, Globe,
|
||||||
FolderTree, Ruler,
|
FolderTree, Ruler, Package,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { api, setOrgOverride } from '@/lib/api'
|
import { api, setOrgOverride } from '@/lib/api'
|
||||||
import { logout } from '@/lib/auth'
|
import { logout } from '@/lib/auth'
|
||||||
|
|
@ -25,6 +25,7 @@ const NAV: NavSection[] = [
|
||||||
{ to: '/super-admin/countries', icon: Globe, label: 'Страны' },
|
{ to: '/super-admin/countries', icon: Globe, label: 'Страны' },
|
||||||
{ to: '/super-admin/groups', icon: FolderTree, label: 'Группы (эталон)' },
|
{ to: '/super-admin/groups', icon: FolderTree, label: 'Группы (эталон)' },
|
||||||
{ to: '/super-admin/units', icon: Ruler, label: 'Ед. измерения (эталон)' },
|
{ to: '/super-admin/units', icon: Ruler, label: 'Ед. измерения (эталон)' },
|
||||||
|
{ to: '/super-admin/product-registry', icon: Package, label: 'Реестр товаров' },
|
||||||
]},
|
]},
|
||||||
{ group: 'Аудит', items: [
|
{ group: 'Аудит', items: [
|
||||||
{ to: '/super-admin/audit-log', icon: FileClock, label: 'Журнал действий' },
|
{ to: '/super-admin/audit-log', icon: FileClock, label: 'Журнал действий' },
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@
|
||||||
"fieldPackaging": "Фасовка",
|
"fieldPackaging": "Фасовка",
|
||||||
"fieldBarcode": "Штрихкод",
|
"fieldBarcode": "Штрихкод",
|
||||||
"fieldCost": "Себестоимость",
|
"fieldCost": "Себестоимость",
|
||||||
"fieldRetailPrice": "Розничная цена",
|
"fieldRetailPrice": "Продажная цена",
|
||||||
"deleteConfirmTitle": "Удалить товар?",
|
"deleteConfirmTitle": "Удалить товар?",
|
||||||
"deleteConfirmBody": "Удалить «{{name}}»? Действие необратимо.",
|
"deleteConfirmBody": "Удалить «{{name}}»? Действие необратимо.",
|
||||||
"groupsTitle": "Группы товаров"
|
"groupsTitle": "Группы товаров"
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ function StepProduct({ onNext, onSkip }: { onNext: () => void; onSkip: () => voi
|
||||||
<TextInput value={name} onChange={(e) => setName(e.target.value)} placeholder="например, Молоко 3.2%" />
|
<TextInput value={name} onChange={(e) => setName(e.target.value)} placeholder="например, Молоко 3.2%" />
|
||||||
</Field>
|
</Field>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Розничная цена">
|
<Field label="Продажная цена">
|
||||||
<TextInput type="number" inputMode="numeric" value={price}
|
<TextInput type="number" inputMode="numeric" value={price}
|
||||||
onChange={(e) => setPrice(e.target.value)} placeholder="100" />
|
onChange={(e) => setPrice(e.target.value)} placeholder="100" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ export function OrganizationSettingsPage() {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label='Показывать «Эталонную цену» на товаре'
|
label='Показывать «Закупочную цену» на товаре'
|
||||||
checked={form.showReferencePriceOnProduct}
|
checked={form.showReferencePriceOnProduct}
|
||||||
onChange={(v) => setForm({ ...form, showReferencePriceOnProduct: v })}
|
onChange={(v) => setForm({ ...form, showReferencePriceOnProduct: v })}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export function PriceTypesPage() {
|
||||||
<>
|
<>
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
title="Типы цен"
|
title="Типы цен"
|
||||||
description="Розничная, оптовая и другие ценовые группы."
|
description="Продажная, оптовая и другие ценовые группы."
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<SearchBar value={search} onChange={setSearch} />
|
<SearchBar value={search} onChange={setSearch} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-3">Закупка</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{!simpleMode && org.data?.showReferencePriceOnProduct && (
|
{!simpleMode && org.data?.showReferencePriceOnProduct && (
|
||||||
<Field label="Эталонная цена">
|
<Field label="Закупочная цена">
|
||||||
<MoneyInput
|
<MoneyInput
|
||||||
value={form.referencePrice === '' ? null : Number(form.referencePrice)}
|
value={form.referencePrice === '' ? null : Number(form.referencePrice)}
|
||||||
onChange={(n) => setForm({ ...form, referencePrice: n == null ? '' : String(n) })}
|
onChange={(n) => setForm({ ...form, referencePrice: n == null ? '' : String(n) })}
|
||||||
|
|
|
||||||
|
|
@ -144,14 +144,14 @@ export function ProductGroupsPage() {
|
||||||
onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })}
|
onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Наценка % (для авто-розничной по себестоимости)">
|
<Field label="Наценка % (для авто-продажной по себестоимости)">
|
||||||
<PercentInput
|
<PercentInput
|
||||||
value={form.markupPercent}
|
value={form.markupPercent}
|
||||||
onChange={(n) => setForm({ ...form, markupPercent: n })}
|
onChange={(n) => setForm({ ...form, markupPercent: n })}
|
||||||
placeholder="нет автонаценки"
|
placeholder="нет автонаценки"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||||
При проведении приёмки розничная цена товара = ⌈Себестоимость × (1 + наценка/100)⌉.
|
При проведении приёмки продажная цена товара = ⌈Себестоимость × (1 + наценка/100)⌉.
|
||||||
Пусто — автонаценка отключена.
|
Пусто — автонаценка отключена.
|
||||||
</p>
|
</p>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ export function ProductsPage() {
|
||||||
// Колонка системной розничной цены: заголовок = PriceType.Name той записи
|
// Колонка системной розничной цены: заголовок = PriceType.Name той записи
|
||||||
// что помечена IsSystem (если пользователь её переименовал — заголовок меняется).
|
// что помечена IsSystem (если пользователь её переименовал — заголовок меняется).
|
||||||
{
|
{
|
||||||
header: systemPriceType?.name ?? 'Розничная цена',
|
header: systemPriceType?.name ?? 'Продажная цена',
|
||||||
width: '170px',
|
width: '170px',
|
||||||
className: 'text-right font-mono',
|
className: 'text-right font-mono',
|
||||||
sortKey: 'systemPrice',
|
sortKey: 'systemPrice',
|
||||||
|
|
|
||||||
291
src/food-market.web/src/pages/SuperAdminProductRegistryPage.tsx
Normal file
291
src/food-market.web/src/pages/SuperAdminProductRegistryPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -83,7 +83,7 @@ export function SupplyEditPage() {
|
||||||
// тому, что увидит пользователь в карточке товара и в справочнике типов цен.
|
// тому, что увидит пользователь в карточке товара и в справочнике типов цен.
|
||||||
const systemPriceTypeName = priceTypes.data?.find((pt) => pt.isSystem)?.name
|
const systemPriceTypeName = priceTypes.data?.find((pt) => pt.isSystem)?.name
|
||||||
?? priceTypes.data?.find((pt) => pt.isRetail)?.name
|
?? priceTypes.data?.find((pt) => pt.isRetail)?.name
|
||||||
?? 'Розничная'
|
?? 'Продажная'
|
||||||
|
|
||||||
const [form, setForm] = useState<Form>(emptyForm)
|
const [form, setForm] = useState<Form>(emptyForm)
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
|
|
|
||||||
|
|
@ -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, "после отмены проведения закупочная вернулась из предыдущей приёмки");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -99,7 +99,7 @@ public void Product_all_properties()
|
||||||
DefaultSupplierId = Guid.NewGuid(), CountryOfOriginId = Guid.NewGuid(),
|
DefaultSupplierId = Guid.NewGuid(), CountryOfOriginId = Guid.NewGuid(),
|
||||||
IsService = false, Packaging = Packaging.Piece, IsMarked = false,
|
IsService = false, Packaging = Packaging.Piece, IsMarked = false,
|
||||||
MinStock = 10m, MaxStock = 100m,
|
MinStock = 10m, MaxStock = 100m,
|
||||||
ReferencePrice = 50m, ReferencePriceUpdatedAt = DateTime.UtcNow,
|
PurchasePrice = 50m, PurchasePriceUpdatedAt = DateTime.UtcNow,
|
||||||
PurchaseCurrencyId = Guid.NewGuid(), Cost = 40m, LastSupplyAt = DateTime.UtcNow,
|
PurchaseCurrencyId = Guid.NewGuid(), Cost = 40m, LastSupplyAt = DateTime.UtcNow,
|
||||||
ImageUrl = "/u.png",
|
ImageUrl = "/u.png",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue