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