feat(supplies): автонаценка при приёмке — только по переключателю магазина
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

Настройка организации 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>
This commit is contained in:
nns 2026-10-12 20:07:31 +05:00
parent 81bf4c7b23
commit 8540be032e
7 changed files with 182 additions and 2 deletions

View file

@ -36,6 +36,7 @@ public record OrgSettingsDto(
bool ShowMinMaxStock, bool ShowMinMaxStock,
bool AllowFractionalPrices, bool AllowFractionalPrices,
bool AllowNegativeBalance, bool AllowNegativeBalance,
bool RecalcRetailOnSupplyByGroupMarkup,
bool ShowReferencePriceOnProduct, bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct, bool ShowCountryOfOriginOnProduct,
bool ShowDescriptionOnProduct, bool ShowDescriptionOnProduct,
@ -66,6 +67,7 @@ public record OrgSettingsInput(
bool ShowMinMaxStock, bool ShowMinMaxStock,
bool AllowFractionalPrices, bool AllowFractionalPrices,
bool AllowNegativeBalance, bool AllowNegativeBalance,
bool RecalcRetailOnSupplyByGroupMarkup,
bool ShowReferencePriceOnProduct, bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct, bool ShowCountryOfOriginOnProduct,
bool ShowDescriptionOnProduct, bool ShowDescriptionOnProduct,
@ -123,6 +125,7 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
o.ShowMinMaxStock = input.ShowMinMaxStock; o.ShowMinMaxStock = input.ShowMinMaxStock;
o.AllowFractionalPrices = input.AllowFractionalPrices; o.AllowFractionalPrices = input.AllowFractionalPrices;
o.AllowNegativeBalance = input.AllowNegativeBalance; o.AllowNegativeBalance = input.AllowNegativeBalance;
o.RecalcRetailOnSupplyByGroupMarkup = input.RecalcRetailOnSupplyByGroupMarkup;
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct; o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct; o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct; o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct;
@ -181,6 +184,7 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
o.ShowMinMaxStock, o.ShowMinMaxStock,
o.AllowFractionalPrices, o.AllowFractionalPrices,
o.AllowNegativeBalance, o.AllowNegativeBalance,
o.RecalcRetailOnSupplyByGroupMarkup,
o.ShowReferencePriceOnProduct, o.ShowReferencePriceOnProduct,
o.ShowCountryOfOriginOnProduct, o.ShowCountryOfOriginOnProduct,
o.ShowDescriptionOnProduct, o.ShowDescriptionOnProduct,

View file

@ -366,7 +366,12 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
if (supply.Status == SupplyStatus.Posted) return Conflict(new { error = "Документ уже проведён." }); if (supply.Status == SupplyStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (supply.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." }); if (supply.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); // Флаги берём ИМЕННО у организации этой приёмки (таблица organizations не
// tenant-фильтруется — без Where вернулась бы произвольная орг).
var orgFlags = await _db.Organizations.Where(o => o.Id == supply.OrganizationId)
.Select(o => new { o.AllowFractionalPrices, o.RecalcRetailOnSupplyByGroupMarkup }).FirstAsync(ct);
var allowFractional = orgFlags.AllowFractionalPrices;
var recalcRetailByMarkup = orgFlags.RecalcRetailOnSupplyByGroupMarkup;
// Serializable: ApplyMovementAsync делает read-modify-write по // Serializable: ApplyMovementAsync делает read-modify-write по
// Stock.Quantity без RowVersion. На дефолтной изоляции два // Stock.Quantity без RowVersion. На дефолтной изоляции два
@ -407,7 +412,7 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{ {
SetDefaultRetail(product, line.RetailPriceOverride.Value, supply.CurrencyId); SetDefaultRetail(product, line.RetailPriceOverride.Value, supply.CurrencyId);
} }
else if (product.ProductGroup?.MarkupPercent is decimal pct) else if (recalcRetailByMarkup && product.ProductGroup?.MarkupPercent is decimal pct)
{ {
var raw = product.Cost * (1m + pct / 100m); var raw = product.Cost * (1m + pct / 100m);
var newRetail = allowFractional var newRetail = allowFractional

View file

@ -73,6 +73,13 @@ public class Organization : Entity
/// дробное через API.</summary> /// дробное через API.</summary>
public bool AllowFractionalPrices { get; set; } public bool AllowFractionalPrices { get; set; }
/// <summary>Пересчитывать ли продажную цену при проведении приёмки по наценке
/// группы товара (Cost × (1 + MarkupPercent), округление вверх). По умолчанию
/// ВЫКЛ (решение владельца 12.10): приёмка меняет продажную только если она
/// задана вручную в строке. Ручная кнопка «Пересчитать» и наценка группы —
/// остаются (это ручные действия).</summary>
public bool RecalcRetailOnSupplyByGroupMarkup { get; set; }
/// <summary>Разрешить ли уходить счетам/кассам в минус. По умолчанию выкл.: /// <summary>Разрешить ли уходить счетам/кассам в минус. По умолчанию выкл.:
/// расход больше остатка (оплата, выплата из смены) отклоняется с 409. /// расход больше остатка (оплата, выплата из смены) отклоняется с 409.
/// Менять может только администратор организации.</summary> /// Менять может только администратор организации.</summary>

View file

@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Настройка магазина «Пересчитывать продажную цену при проведении
/// приёмки по наценке группы» — по умолчанию ВЫКЛ (для всех существующих орг
/// тоже ВЫКЛ). Решение владельца 12.10.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20261012050000_Phase49_RecalcRetailToggle")]
public partial class Phase49_RecalcRetailToggle : Migration
{
protected override void Up(MigrationBuilder b) => b.Sql(@"
ALTER TABLE public.organizations
ADD COLUMN IF NOT EXISTS ""RecalcRetailOnSupplyByGroupMarkup"" boolean NOT NULL DEFAULT false;");
protected override void Down(MigrationBuilder b) => b.Sql(@"
ALTER TABLE public.organizations
DROP COLUMN IF EXISTS ""RecalcRetailOnSupplyByGroupMarkup"";");
}
}

View file

@ -15,6 +15,7 @@ export interface OrgSettings {
showMarkedOnProduct: boolean showMarkedOnProduct: boolean
showMinMaxStock: boolean showMinMaxStock: boolean
allowFractionalPrices: boolean allowFractionalPrices: boolean
recalcRetailOnSupplyByGroupMarkup: boolean
showReferencePriceOnProduct: boolean showReferencePriceOnProduct: boolean
showCountryOfOriginOnProduct: boolean showCountryOfOriginOnProduct: boolean
showDescriptionOnProduct: boolean showDescriptionOnProduct: boolean

View file

@ -55,6 +55,7 @@ export function OrganizationSettingsPage() {
showMarkedOnProduct: form.showMarkedOnProduct, showMarkedOnProduct: form.showMarkedOnProduct,
showMinMaxStock: form.showMinMaxStock, showMinMaxStock: form.showMinMaxStock,
allowFractionalPrices: form.allowFractionalPrices, allowFractionalPrices: form.allowFractionalPrices,
recalcRetailOnSupplyByGroupMarkup: form.recalcRetailOnSupplyByGroupMarkup,
showReferencePriceOnProduct: form.showReferencePriceOnProduct, showReferencePriceOnProduct: form.showReferencePriceOnProduct,
showCountryOfOriginOnProduct: form.showCountryOfOriginOnProduct, showCountryOfOriginOnProduct: form.showCountryOfOriginOnProduct,
showDescriptionOnProduct: form.showDescriptionOnProduct, showDescriptionOnProduct: form.showDescriptionOnProduct,
@ -172,6 +173,16 @@ export function OrganizationSettingsPage() {
По умолчанию — целые тенге, без копеек. По умолчанию — целые тенге, без копеек.
</p> </p>
<Checkbox
label='Пересчитывать продажную цену при проведении приёмки по наценке группы'
checked={form.recalcRetailOnSupplyByGroupMarkup}
onChange={(v) => setForm({ ...form, recalcRetailOnSupplyByGroupMarkup: v })}
/>
<p className="text-xs text-slate-500 dark:text-slate-400 -mt-2">
Если включено: при проведении приёмки продажная цена товара = себестоимость + наценка
его группы (округление вверх). Если выключено: приёмка продажную цену не меняет.
</p>
<Checkbox <Checkbox
label='Показывать «Закупочную цену» на товаре' label='Показывать «Закупочную цену» на товаре'
checked={form.showReferencePriceOnProduct} checked={form.showReferencePriceOnProduct}

View file

@ -0,0 +1,128 @@
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,
"ручная правка продажной в строке применяется независимо от переключателя");
}
}