food-market/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs
nns b79c71591d feat(phase3b): drop IsActive, add ShelfLifeDays, restore PriceType IsSystem/IsRequired
Domain + миграция Phase3b_PricingCleanup:
- DROP IsActive у products / product_groups / units_of_measure /
  counterparties / price_types (включая индекс
  IX_products_OrganizationId_IsActive). В этих сущностях концепт
  деактивации не оправдан — если товар/группа/единица/контрагент
  не нужны, их физически удаляют.
- DROP organizations.MultiplePriceTypesEnabled — раздел «Типы цен»
  всегда виден, отдельной настройки больше не нужно.
- ADD price_types.IsRequired bool default false — обязательность
  заполнения для каждого товара.
- ADD price_types.IsSystem bool default false — защищённая запись,
  не удаляется и IsRequired всегда true; имя редактируется.
  В каждой организации гарантируется одна системная запись
  «Розничная цена» (создаётся миграцией если её нет).
- ADD products.ShelfLifeDays integer NULL — срок годности.

API:
- ProductsController/UnitsOfMeasureController/ProductGroupsController/
  CounterpartiesController/PriceTypesController: убраны параметры
  isActive в фильтрах, sort-keys, DTO, Apply, Создании.
- Products проекция: вместо IsActive теперь ShelfLifeDays.
- PriceTypesController: 400 при попытке удалить системную запись;
  IsRequired у системной — всегда true, не меняется через PUT.
- recalc-retail / supply posting: дефолтный PriceType ищется по
  IsSystem → IsDefault → IsRetail → SortOrder → Name (без IsActive).
- OrgSettingsDto/Input — без MultiplePriceTypesEnabled.

Web:
- types.ts: убраны isActive у Product/ProductGroup/UnitOfMeasure/
  Counterparty/PriceType. PriceType пополнен isRequired/isSystem.
  Product получил shelfLifeDays.
- useOrgSettings: убрано multiplePriceTypesEnabled.
- AppLayout: меню «Типы цен» всегда видно.
- Pages (Counterparties/Units/ProductGroups/PriceTypes/ProductEdit/
  OrganizationSettings): сняты колонки/чекбоксы/поля «Активен»;
  удалён GroupMarkupsPage; в PriceTypesPage добавлен Lock-индикатор
  системной записи и блок-подсказка, кнопка удаления скрыта.
- DemoCatalogSeeder и OtherSystem-импортёр: больше не пишут IsActive.

UI-перекомпоновка карточки товара (Phase3b пп.6/9), Supply Posted-toggle,
PercentInput, ShelfLifeDays-фильтр и редизайн прайс-секции — отдельными
коммитами далее по плану.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:46:34 +05:00

117 lines
4.4 KiB
C#

using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Organizations;
[ApiController]
[Authorize]
[Route("api/organization")]
public class OrganizationSettingsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public OrganizationSettingsController(AppDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
public record OrgSettingsDto(
Guid Id,
string Name,
string CountryCode,
Guid? DefaultCurrencyId,
string? DefaultCurrencyCode,
string? DefaultCurrencySymbol,
bool MultiCurrencyEnabled,
// VAT read-only: из страны организации (countries.VatRate). Источник правды — справочник стран.
decimal VatRate,
bool ShowVatEnabledOnProduct,
bool ShowServiceOnProduct,
bool ShowMarkedOnProduct,
bool ShowMinMaxStock,
bool AllowFractionalPrices,
bool ShowReferencePriceOnProduct);
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
public record OrgSettingsInput(
string Name,
string CountryCode,
bool MultiCurrencyEnabled,
bool ShowVatEnabledOnProduct,
bool ShowServiceOnProduct,
bool ShowMarkedOnProduct,
bool ShowMinMaxStock,
bool AllowFractionalPrices,
bool ShowReferencePriceOnProduct);
[HttpGet("settings")]
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var o = await _db.Organizations
.Include(o => o.DefaultCurrency)
.FirstOrDefaultAsync(o => o.Id == orgId, ct);
if (o is null) return NotFound();
var vat = await ReadVatRateAsync(o.CountryCode, ct);
return Project(o, vat);
}
[HttpPut("settings"), Authorize(Roles = "Admin,Manager")]
public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var o = await _db.Organizations
.Include(o => o.DefaultCurrency)
.FirstOrDefaultAsync(o => o.Id == orgId, ct);
if (o is null) return NotFound();
o.Name = input.Name;
o.CountryCode = input.CountryCode;
// Валюта организации жёстко следует за страной — не принимается от клиента.
o.DefaultCurrencyId = await _db.Countries
.Where(c => c.Code == input.CountryCode)
.Select(c => c.DefaultCurrencyId)
.FirstOrDefaultAsync(ct);
o.MultiCurrencyEnabled = input.MultiCurrencyEnabled;
o.ShowVatEnabledOnProduct = input.ShowVatEnabledOnProduct;
o.ShowServiceOnProduct = input.ShowServiceOnProduct;
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
o.ShowMinMaxStock = input.ShowMinMaxStock;
o.AllowFractionalPrices = input.AllowFractionalPrices;
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
await _db.SaveChangesAsync(ct);
await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct);
var vat = await ReadVatRateAsync(o.CountryCode, ct);
return Project(o, vat);
}
private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationToken ct)
{
var rate = await _db.Countries
.Where(c => c.Code == countryCode)
.Select(c => (decimal?)c.VatRate)
.FirstOrDefaultAsync(ct);
return rate ?? 0m;
}
private static OrgSettingsDto Project(foodmarket.Domain.Organizations.Organization o, decimal vat) => new(
o.Id, o.Name, o.CountryCode,
o.DefaultCurrencyId,
o.DefaultCurrency?.Code,
o.DefaultCurrency?.Symbol,
o.MultiCurrencyEnabled,
vat,
o.ShowVatEnabledOnProduct,
o.ShowServiceOnProduct,
o.ShowMarkedOnProduct,
o.ShowMinMaxStock,
o.AllowFractionalPrices,
o.ShowReferencePriceOnProduct);
}