food-market/tests/food-market.IntegrationTests/MarkupToggleTests.cs
nns 8540be032e
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
feat(supplies): автонаценка при приёмке — только по переключателю магазина
Настройка организации RecalcRetailOnSupplyByGroupMarkup (по умолчанию ВЫКЛ,
миграция Phase49 для всех существующих орг тоже ВЫКЛ). При ВЫКЛ проведение
приёмки меняет продажную цену ТОЛЬКО если она задана вручную в строке; наценка
группы не применяется. При ВКЛ — прежнее поведение (продажная = себестоимость +
наценка группы, округление вверх). Ручная кнопка «Пересчитать» и наценка группы
остаются. Настройка — в разделе товаров/цен настроек магазина.

Побочно исправлен мультитенантный баг: SuppliesController.Post читал флаги
(AllowFractionalPrices и новый) через `_db.Organizations...FirstOrDefaultAsync`
без скоупа — таблица organizations не tenant-фильтруется, возвращалась
произвольная орг. Теперь берём флаги у организации самой приёмки.

Тесты MarkupToggleTests: ВЫКЛ (наценка есть, приёмка проведена → продажная не
изменилась), ВКЛ (пересчиталась), ручная цена в строке работает в обоих режимах
— 3/3; SupplyPricingSchemeTests 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 20:07:31 +05:00

129 lines
7.2 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Автонаценка при приёмке — только по переключателю магазина
/// RecalcRetailOnSupplyByGroupMarkup (по умолчанию ВЫКЛ). ВЫКЛ: приёмка без
/// ручной правки продажной её не меняет, даже если у группы задана наценка.
/// ВКЛ: пересчитывает по наценке. Ручная правка в строке работает в обоих режимах.</summary>
[Collection(ApiCollection.Name)]
public class MarkupToggleTests
{
private readonly ApiFactory _factory;
public MarkupToggleTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
private async Task<string> CreateGroupWithMarkupAsync(ApiActor api, decimal markup)
{
var resp = await api.Http.PostAsJsonAsync("/api/catalog/product-groups",
new { name = $"MG-{Guid.NewGuid():N}", parentId = (string?)null, sortOrder = 0, markupPercent = markup });
resp.EnsureSuccessStatusCode();
return (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
}
private async Task<string> CreateProductInGroupAsync(ApiActor api, ApiActor.Refs refs, string groupId, decimal retail, string barcode)
{
var resp = await api.Http.PostAsJsonAsync("/api/catalog/products", new
{
name = $"P-{Guid.NewGuid():N}", unitOfMeasureId = refs.UnitId, vat = 12, vatEnabled = true,
productGroupId = groupId, packaging = 0, purchasePrice = (decimal?)null, purchaseCurrencyId = refs.CurrencyId,
prices = new[] { new { priceTypeId = refs.PriceTypeId, amount = retail, currencyId = refs.CurrencyId } },
barcodes = new[] { new { code = barcode, type = 0, isPrimary = true } },
});
var body = await resp.Content.ReadAsStringAsync();
resp.IsSuccessStatusCode.Should().BeTrue(body);
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
private async Task SetRecalcFlagAsync(ApiActor api, bool enabled)
{
var s = await api.GetJsonAsync("/api/organization/settings");
var dict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(s.GetRawText())!;
dict["recalcRetailOnSupplyByGroupMarkup"] = JsonSerializer.SerializeToElement(enabled);
var resp = await api.Http.PutAsJsonAsync("/api/organization/settings", dict);
resp.EnsureSuccessStatusCode();
var after = await api.GetJsonAsync("/api/organization/settings");
after.GetProperty("recalcRetailOnSupplyByGroupMarkup").GetBoolean().Should().Be(enabled, "флаг должен сохраниться");
}
private async Task<decimal?> RetailAsync(ApiActor api, string productId, string retailTypeId)
{
var p = await api.GetJsonAsync($"/api/catalog/products/{productId}");
foreach (var row in p.GetProperty("prices").EnumerateArray())
if (row.GetProperty("priceTypeId").GetString() == retailTypeId)
return row.GetProperty("amount").GetDecimal();
return null;
}
[Fact]
public async Task Markup_off_by_default_supply_keeps_sale_price()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mk-off-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
var group = await CreateGroupWithMarkupAsync(api, 50m);
var productId = await CreateProductInGroupAsync(api, refs, group, 200m, RandomBarcode());
// Приёмка 10 шт по 100, без правки продажной. Флаг ВЫКЛ (по умолчанию).
await api.CreatePostedSupplyAsync(refs, supplier, productId, 10m, 100m);
(await RetailAsync(api, productId, refs.PriceTypeId)).Should().Be(200m,
"при выключенном переключателе наценка группы не применяется — продажная не меняется");
}
[Fact]
public async Task Markup_on_supply_recomputes_sale_price()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mk-on-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
var group = await CreateGroupWithMarkupAsync(api, 50m);
var gj = await api.GetJsonAsync($"/api/catalog/product-groups/{group}");
gj.GetProperty("markupPercent").GetDecimal().Should().Be(50m, "наценка группы сохранилась");
var productId = await CreateProductInGroupAsync(api, refs, group, 200m, RandomBarcode());
var pj = await api.GetJsonAsync($"/api/catalog/products/{productId}");
pj.GetProperty("productGroupId").GetString().Should().Be(group, "товар в группе с наценкой");
await SetRecalcFlagAsync(api, true);
// cost от нулевого остатка = 100 → продажная = ceil(100 × 1.5) = 150.
await api.CreatePostedSupplyAsync(refs, supplier, productId, 10m, 100m);
(await RetailAsync(api, productId, refs.PriceTypeId)).Should().Be(150m,
"при включённом переключателе продажная пересчитывается по наценке группы");
}
[Fact]
public async Task Line_override_wins_regardless_of_toggle()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mk-ovr-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
var group = await CreateGroupWithMarkupAsync(api, 50m);
var productId = await CreateProductInGroupAsync(api, refs, group, 200m, RandomBarcode());
// Флаг ВЫКЛ, но в строке задана продажная вручную → применяется override.
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 = 3m, unitPrice = 100m, retailPriceManuallyOverridden = true, retailPriceOverride = 333m } },
});
draft.EnsureSuccessStatusCode();
var supplyId = (await draft.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { })).EnsureSuccessStatusCode();
(await RetailAsync(api, productId, refs.PriceTypeId)).Should().Be(333m,
"ручная правка продажной в строке применяется независимо от переключателя");
}
}