diff --git a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs
index 67b9abb..32877d2 100644
--- a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs
+++ b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs
@@ -36,6 +36,7 @@ public record OrgSettingsDto(
bool ShowMinMaxStock,
bool AllowFractionalPrices,
bool AllowNegativeBalance,
+ bool RecalcRetailOnSupplyByGroupMarkup,
bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct,
bool ShowDescriptionOnProduct,
@@ -66,6 +67,7 @@ public record OrgSettingsInput(
bool ShowMinMaxStock,
bool AllowFractionalPrices,
bool AllowNegativeBalance,
+ bool RecalcRetailOnSupplyByGroupMarkup,
bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct,
bool ShowDescriptionOnProduct,
@@ -123,6 +125,7 @@ public async Task> Update([FromBody] OrgSettingsInp
o.ShowMinMaxStock = input.ShowMinMaxStock;
o.AllowFractionalPrices = input.AllowFractionalPrices;
o.AllowNegativeBalance = input.AllowNegativeBalance;
+ o.RecalcRetailOnSupplyByGroupMarkup = input.RecalcRetailOnSupplyByGroupMarkup;
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct;
@@ -181,6 +184,7 @@ private async Task ReadVatRateAsync(string countryCode, CancellationTok
o.ShowMinMaxStock,
o.AllowFractionalPrices,
o.AllowNegativeBalance,
+ o.RecalcRetailOnSupplyByGroupMarkup,
o.ShowReferencePriceOnProduct,
o.ShowCountryOfOriginOnProduct,
o.ShowDescriptionOnProduct,
diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs
index b211236..ecfe117 100644
--- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs
+++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs
@@ -366,7 +366,12 @@ public async Task Post(Guid id, CancellationToken ct)
if (supply.Status == SupplyStatus.Posted) return Conflict(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 по
// Stock.Quantity без RowVersion. На дефолтной изоляции два
@@ -407,7 +412,7 @@ public async Task Post(Guid id, CancellationToken ct)
{
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 newRetail = allowFractional
diff --git a/src/food-market.domain/Organizations/Organization.cs b/src/food-market.domain/Organizations/Organization.cs
index 6796dee..600cc50 100644
--- a/src/food-market.domain/Organizations/Organization.cs
+++ b/src/food-market.domain/Organizations/Organization.cs
@@ -73,6 +73,13 @@ public class Organization : Entity
/// дробное через API.
public bool AllowFractionalPrices { get; set; }
+ /// Пересчитывать ли продажную цену при проведении приёмки по наценке
+ /// группы товара (Cost × (1 + MarkupPercent), округление вверх). По умолчанию
+ /// ВЫКЛ (решение владельца 12.10): приёмка меняет продажную только если она
+ /// задана вручную в строке. Ручная кнопка «Пересчитать» и наценка группы —
+ /// остаются (это ручные действия).
+ public bool RecalcRetailOnSupplyByGroupMarkup { get; set; }
+
/// Разрешить ли уходить счетам/кассам в минус. По умолчанию выкл.:
/// расход больше остатка (оплата, выплата из смены) отклоняется с 409.
/// Менять может только администратор организации.
diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261012050000_Phase49_RecalcRetailToggle.cs b/src/food-market.infrastructure/Persistence/Migrations/20261012050000_Phase49_RecalcRetailToggle.cs
new file mode 100644
index 0000000..def84a2
--- /dev/null
+++ b/src/food-market.infrastructure/Persistence/Migrations/20261012050000_Phase49_RecalcRetailToggle.cs
@@ -0,0 +1,24 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using foodmarket.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace foodmarket.Infrastructure.Persistence.Migrations
+{
+ /// Настройка магазина «Пересчитывать продажную цену при проведении
+ /// приёмки по наценке группы» — по умолчанию ВЫКЛ (для всех существующих орг
+ /// тоже ВЫКЛ). Решение владельца 12.10.
+ [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"";");
+ }
+}
diff --git a/src/food-market.web/src/lib/useOrgSettings.ts b/src/food-market.web/src/lib/useOrgSettings.ts
index f7e7d50..57750a4 100644
--- a/src/food-market.web/src/lib/useOrgSettings.ts
+++ b/src/food-market.web/src/lib/useOrgSettings.ts
@@ -15,6 +15,7 @@ export interface OrgSettings {
showMarkedOnProduct: boolean
showMinMaxStock: boolean
allowFractionalPrices: boolean
+ recalcRetailOnSupplyByGroupMarkup: boolean
showReferencePriceOnProduct: boolean
showCountryOfOriginOnProduct: boolean
showDescriptionOnProduct: boolean
diff --git a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx
index 85b753c..75fd7be 100644
--- a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx
+++ b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx
@@ -55,6 +55,7 @@ export function OrganizationSettingsPage() {
showMarkedOnProduct: form.showMarkedOnProduct,
showMinMaxStock: form.showMinMaxStock,
allowFractionalPrices: form.allowFractionalPrices,
+ recalcRetailOnSupplyByGroupMarkup: form.recalcRetailOnSupplyByGroupMarkup,
showReferencePriceOnProduct: form.showReferencePriceOnProduct,
showCountryOfOriginOnProduct: form.showCountryOfOriginOnProduct,
showDescriptionOnProduct: form.showDescriptionOnProduct,
@@ -172,6 +173,16 @@ export function OrganizationSettingsPage() {
По умолчанию — целые тенге, без копеек.
+ setForm({ ...form, recalcRetailOnSupplyByGroupMarkup: v })}
+ />
+
+ Если включено: при проведении приёмки продажная цена товара = себестоимость + наценка
+ его группы (округление вверх). Если выключено: приёмка продажную цену не меняет.
+
+
Автонаценка при приёмке — только по переключателю магазина
+/// RecalcRetailOnSupplyByGroupMarkup (по умолчанию ВЫКЛ). ВЫКЛ: приёмка без
+/// ручной правки продажной её не меняет, даже если у группы задана наценка.
+/// ВКЛ: пересчитывает по наценке. Ручная правка в строке работает в обоих режимах.
+[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 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()).GetProperty("id").GetString()!;
+ }
+
+ private async Task 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>(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 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()).GetProperty("id").GetString();
+ (await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { })).EnsureSuccessStatusCode();
+
+ (await RetailAsync(api, productId, refs.PriceTypeId)).Should().Be(333m,
+ "ручная правка продажной в строке применяется независимо от переключателя");
+ }
+}