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>
98 lines
5.6 KiB
C#
98 lines
5.6 KiB
C#
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, "после отмены проведения закупочная вернулась из предыдущей приёмки");
|
|
}
|
|
}
|