feat(vat): ставка в стране + опц. переопределение на товаре

Phase5_VatAsCountryProperty:
- countries.VatRate (numeric(5,2)) — ставка страны, источник правды.
  Seed: KZ=16, RU=20, BY=20, DE=19, CN=13, TR=18, UZ=12, KG=12, KR=10,
  IT=22, PL=23, US=0.
- organizations.ShowVatEnabledOnProduct (bool, default false) — флаг
  отображения на карточке товара.
- organizations.DefaultVat удалён (заменён страной).
- products.Vat ОСТАЁТСЯ: для KZ есть льготные категории (хлеб/молоко =
  0%) и фискальный чек требует ставку на каждой позиции.

Country domain: + DefaultCurrency / VatRate (уже было DefaultCurrencyId
из Phase4, сейчас дополнено).

Organization domain: DefaultVat убран, ShowVatEnabledOnProduct добавлен.

Backend:
- ProductInput.Vat теперь int? — если UI скрывает поле и прислал null,
  ProductsController берёт дефолт из страны организации (Country.VatRate
  при создании; при update сохраняет прежнее значение).
- CountriesController.List/Get/Create/Update возвращает/принимает
  DefaultCurrency и VatRate.
- OtherSystem импорт: дефолт Vat загружается из страны организации.
- SystemReferenceSeeder: новые валюты BYN/UZS/KGS/TRY/KRW/PLN, seed
  country-currency-vat для всех 12 стран.
- OrganizationSettingsController: VatRate read-only из страны,
  ShowVatEnabledOnProduct редактируется.

Web:
- Country type + CountriesPage форма редактирования (валюта, ставка НДС).
- OrganizationSettingsPage: "Ставка НДС" read-only
  (берётся из страны, ссылка на /catalog/countries), галочка
  "Указывать ставку НДС на товаре".
- ProductEditPage: блок Ставка НДС % + галка "В том числе НДС" теперь
  показываются только если showVatEnabledOnProduct=true. В payload
  при save.mutate отправляется vat=null если скрыто.
- ProductsPage: колонка НДС показывается только при включённом флаге.

Galleries/products/settings других этапов — не задеты.
This commit is contained in:
nurdotnet 2026-04-24 11:56:28 +05:00
parent e16375ccf6
commit 6979599791
21 changed files with 2344 additions and 145 deletions

View file

@ -20,7 +20,7 @@ public class CountriesController : ControllerBase
[HttpGet] [HttpGet]
public async Task<ActionResult<PagedResult<CountryDto>>> List([FromQuery] PagedRequest req, CancellationToken ct) public async Task<ActionResult<PagedResult<CountryDto>>> List([FromQuery] PagedRequest req, CancellationToken ct)
{ {
var q = _db.Countries.AsNoTracking().AsQueryable(); var q = _db.Countries.Include(c => c.DefaultCurrency).AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Search)) if (!string.IsNullOrWhiteSpace(req.Search))
{ {
var s = req.Search.Trim().ToLower(); var s = req.Search.Trim().ToLower();
@ -30,7 +30,12 @@ public async Task<ActionResult<PagedResult<CountryDto>>> List([FromQuery] PagedR
var items = await q var items = await q
.OrderBy(c => c.SortOrder).ThenBy(c => c.Name) .OrderBy(c => c.SortOrder).ThenBy(c => c.Name)
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(c => new CountryDto(c.Id, c.Code, c.Name, c.SortOrder)) .Select(c => new CountryDto(
c.Id, c.Code, c.Name, c.SortOrder,
c.DefaultCurrencyId,
c.DefaultCurrency != null ? c.DefaultCurrency.Code : null,
c.DefaultCurrency != null ? c.DefaultCurrency.Symbol : null,
c.VatRate))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<CountryDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<CountryDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -38,20 +43,28 @@ public async Task<ActionResult<PagedResult<CountryDto>>> List([FromQuery] PagedR
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
public async Task<ActionResult<CountryDto>> Get(Guid id, CancellationToken ct) public async Task<ActionResult<CountryDto>> Get(Guid id, CancellationToken ct)
{ {
var c = await _db.Countries.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); var c = await _db.Countries.Include(x => x.DefaultCurrency).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return c is null ? NotFound() : new CountryDto(c.Id, c.Code, c.Name, c.SortOrder); return c is null ? NotFound() : Project(c);
} }
[HttpPost, Authorize(Roles = "SuperAdmin")] [HttpPost, Authorize(Roles = "SuperAdmin,Admin")]
public async Task<ActionResult<CountryDto>> Create([FromBody] CountryInput input, CancellationToken ct) public async Task<ActionResult<CountryDto>> Create([FromBody] CountryInput input, CancellationToken ct)
{ {
var e = new Country { Code = input.Code.Trim().ToUpper(), Name = input.Name, SortOrder = input.SortOrder }; var e = new Country
{
Code = input.Code.Trim().ToUpper(),
Name = input.Name,
SortOrder = input.SortOrder,
DefaultCurrencyId = input.DefaultCurrencyId,
VatRate = input.VatRate,
};
_db.Countries.Add(e); _db.Countries.Add(e);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id }, new CountryDto(e.Id, e.Code, e.Name, e.SortOrder)); await _db.Entry(e).Reference(x => x.DefaultCurrency).LoadAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id }, Project(e));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin")] [HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")]
public async Task<IActionResult> Update(Guid id, [FromBody] CountryInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] CountryInput input, CancellationToken ct)
{ {
var e = await _db.Countries.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.Countries.FirstOrDefaultAsync(x => x.Id == id, ct);
@ -59,6 +72,8 @@ public async Task<IActionResult> Update(Guid id, [FromBody] CountryInput input,
e.Code = input.Code.Trim().ToUpper(); e.Code = input.Code.Trim().ToUpper();
e.Name = input.Name; e.Name = input.Name;
e.SortOrder = input.SortOrder; e.SortOrder = input.SortOrder;
e.DefaultCurrencyId = input.DefaultCurrencyId;
e.VatRate = input.VatRate;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
} }
@ -72,4 +87,8 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
} }
private static CountryDto Project(Country c) => new(
c.Id, c.Code, c.Name, c.SortOrder,
c.DefaultCurrencyId, c.DefaultCurrency?.Code, c.DefaultCurrency?.Symbol, c.VatRate);
} }

View file

@ -1,5 +1,6 @@
using foodmarket.Application.Catalog; using foodmarket.Application.Catalog;
using foodmarket.Application.Common; using foodmarket.Application.Common;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog; using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -14,8 +15,30 @@ namespace foodmarket.Api.Controllers.Catalog;
public class ProductsController : ControllerBase public class ProductsController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public ProductsController(AppDbContext db) => _db = db; public ProductsController(AppDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
// Дефолт Vat для нового товара — из страны организации (Country.VatRate).
private async Task<int> ResolveDefaultVatAsync(CancellationToken ct)
{
var orgId = _tenant.OrganizationId;
if (orgId is null) return 0;
var countryCode = await _db.Organizations
.Where(o => o.Id == orgId)
.Select(o => o.CountryCode)
.FirstOrDefaultAsync(ct);
if (string.IsNullOrEmpty(countryCode)) return 0;
var rate = await _db.Countries
.Where(c => c.Code == countryCode)
.Select(c => (decimal?)c.VatRate)
.FirstOrDefaultAsync(ct);
return (int)Math.Round(rate ?? 0m);
}
[HttpGet] [HttpGet]
public async Task<ActionResult<PagedResult<ProductDto>>> List( public async Task<ActionResult<PagedResult<ProductDto>>> List(
@ -81,6 +104,8 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
{ {
var e = new Product(); var e = new Product();
Apply(e, input); Apply(e, input);
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct);
foreach (var b in input.Barcodes ?? []) foreach (var b in input.Barcodes ?? [])
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary }); e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
@ -102,7 +127,10 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
.FirstOrDefaultAsync(x => x.Id == id, ct); .FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); if (e is null) return NotFound();
var existingVat = e.Vat;
Apply(e, input); Apply(e, input);
// Если UI не передал Vat (скрыт) — сохраняем что было, не обнуляем.
if (input.Vat is null) e.Vat = existingVat;
_db.ProductBarcodes.RemoveRange(e.Barcodes); _db.ProductBarcodes.RemoveRange(e.Barcodes);
e.Barcodes.Clear(); e.Barcodes.Clear();
@ -162,7 +190,7 @@ private static void Apply(Product e, ProductInput i)
e.Article = i.Article; e.Article = i.Article;
e.Description = i.Description; e.Description = i.Description;
e.UnitOfMeasureId = i.UnitOfMeasureId; e.UnitOfMeasureId = i.UnitOfMeasureId;
e.Vat = i.Vat; if (i.Vat is int v) e.Vat = v;
e.VatEnabled = i.VatEnabled; e.VatEnabled = i.VatEnabled;
e.ProductGroupId = i.ProductGroupId; e.ProductGroupId = i.ProductGroupId;
e.DefaultSupplierId = i.DefaultSupplierId; e.DefaultSupplierId = i.DefaultSupplierId;

View file

@ -28,14 +28,16 @@ public record OrgSettingsDto(
string? DefaultCurrencyCode, string? DefaultCurrencyCode,
string? DefaultCurrencySymbol, string? DefaultCurrencySymbol,
bool MultiCurrencyEnabled, bool MultiCurrencyEnabled,
int DefaultVat); // VAT read-only: из страны организации (countries.VatRate). Источник правды — справочник стран.
decimal VatRate,
bool ShowVatEnabledOnProduct);
public record OrgSettingsInput( public record OrgSettingsInput(
string Name, string Name,
string CountryCode, string CountryCode,
Guid? DefaultCurrencyId, Guid? DefaultCurrencyId,
bool MultiCurrencyEnabled, bool MultiCurrencyEnabled,
int DefaultVat); bool ShowVatEnabledOnProduct);
[HttpGet("settings")] [HttpGet("settings")]
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct) public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
@ -45,7 +47,8 @@ public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
.Include(o => o.DefaultCurrency) .Include(o => o.DefaultCurrency)
.FirstOrDefaultAsync(o => o.Id == orgId, ct); .FirstOrDefaultAsync(o => o.Id == orgId, ct);
if (o is null) return NotFound(); if (o is null) return NotFound();
return Project(o); var vat = await ReadVatRateAsync(o.CountryCode, ct);
return Project(o, vat);
} }
[HttpPut("settings"), Authorize(Roles = "Admin,Manager")] [HttpPut("settings"), Authorize(Roles = "Admin,Manager")]
@ -61,19 +64,29 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
o.CountryCode = input.CountryCode; o.CountryCode = input.CountryCode;
o.DefaultCurrencyId = input.DefaultCurrencyId; o.DefaultCurrencyId = input.DefaultCurrencyId;
o.MultiCurrencyEnabled = input.MultiCurrencyEnabled; o.MultiCurrencyEnabled = input.MultiCurrencyEnabled;
o.DefaultVat = input.DefaultVat; o.ShowVatEnabledOnProduct = input.ShowVatEnabledOnProduct;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
// Re-read чтобы подтянуть DefaultCurrency.
await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct); await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct);
return Project(o); var vat = await ReadVatRateAsync(o.CountryCode, ct);
return Project(o, vat);
} }
private static OrgSettingsDto Project(foodmarket.Domain.Organizations.Organization o) => new( 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.Id, o.Name, o.CountryCode,
o.DefaultCurrencyId, o.DefaultCurrencyId,
o.DefaultCurrency?.Code, o.DefaultCurrency?.Code,
o.DefaultCurrency?.Symbol, o.DefaultCurrency?.Symbol,
o.MultiCurrencyEnabled, o.MultiCurrencyEnabled,
o.DefaultVat); vat,
o.ShowVatEnabledOnProduct);
} }

View file

@ -32,7 +32,7 @@ public async Task StartAsync(CancellationToken ct)
var hasProducts = await db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct); var hasProducts = await db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
if (hasProducts) return; if (hasProducts) return;
// KZ default VAT is 16% (applies as int on Product). // KZ дефолт — 16% (из Country.VatRate), льготные категории (хлеб) — 0%.
const int vatDefault = 16; const int vatDefault = 16;
const int vat0 = 0; const int vat0 = 0;
@ -156,6 +156,7 @@ Guid AddGroup(string name, Guid? parentId)
Name = d.Name, Name = d.Name,
Article = d.Article, Article = d.Article,
UnitOfMeasureId = d.Unit, UnitOfMeasureId = d.Unit,
// Хлеб/батон/лепёшка — 0% (льготная категория в KZ).
Vat = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка") Vat = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
? vat0 : vatDefault, ? vat0 : vatDefault,
VatEnabled = !(d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")), VatEnabled = !(d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")),

View file

@ -51,16 +51,13 @@ public async Task StartAsync(CancellationToken ct)
Phone = "+7 (777) 000-00-00", Phone = "+7 (777) 000-00-00",
Email = "demo@food-market.local", Email = "demo@food-market.local",
DefaultCurrencyId = kzt?.Id, DefaultCurrencyId = kzt?.Id,
DefaultVat = 16,
}; };
db.Organizations.Add(demoOrg); db.Organizations.Add(demoOrg);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
else if (demoOrg.DefaultCurrencyId is null && kzt is not null) else if (demoOrg.DefaultCurrencyId is null && kzt is not null)
{ {
// backfill для существующей организации на стенде
demoOrg.DefaultCurrencyId = kzt.Id; demoOrg.DefaultCurrencyId = kzt.Id;
if (demoOrg.DefaultVat == 0) demoOrg.DefaultVat = 16;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }

View file

@ -19,55 +19,55 @@ public async Task StartAsync(CancellationToken ct)
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await SeedCurrenciesAsync(db, ct); // первыми — на них ссылаются страны
await db.SaveChangesAsync(ct);
await SeedCountriesAsync(db, ct); await SeedCountriesAsync(db, ct);
await SeedCurrenciesAsync(db, ct); await db.SaveChangesAsync(ct);
await BackfillCountryDefaultsAsync(db, ct);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
public Task StopAsync(CancellationToken ct) => Task.CompletedTask; public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
private static async Task SeedCountriesAsync(AppDbContext db, CancellationToken ct) private record CountrySeed(string Code, string Name, int SortOrder, string CurrencyCode, decimal VatRate);
private static readonly CountrySeed[] CountrySeeds =
{ {
// Kazakhstan first, then common trade partners. new("KZ", "Казахстан", 1, "KZT", 16m),
var wanted = new[] new("RU", "Россия", 2, "RUB", 20m),
{ new("CN", "Китай", 3, "CNY", 13m),
new Country { Code = "KZ", Name = "Казахстан", SortOrder = 1 }, new("TR", "Турция", 4, "TRY", 18m),
new Country { Code = "RU", Name = "Россия", SortOrder = 2 }, new("BY", "Беларусь", 5, "BYN", 20m),
new Country { Code = "CN", Name = "Китай", SortOrder = 3 }, new("UZ", "Узбекистан", 6, "UZS", 12m),
new Country { Code = "TR", Name = "Турция", SortOrder = 4 }, new("KG", "Кыргызстан", 7, "KGS", 12m),
new Country { Code = "BY", Name = "Беларусь", SortOrder = 5 }, new("DE", "Германия", 10, "EUR", 19m),
new Country { Code = "UZ", Name = "Узбекистан", SortOrder = 6 }, new("US", "США", 11, "USD", 0m),
new Country { Code = "KG", Name = "Кыргызстан", SortOrder = 7 }, new("KR", "Южная Корея", 12, "KRW", 10m),
new Country { Code = "DE", Name = "Германия", SortOrder = 10 }, new("IT", "Италия", 13, "EUR", 22m),
new Country { Code = "US", Name = "США", SortOrder = 11 }, new("PL", "Польша", 14, "PLN", 23m),
new Country { Code = "KR", Name = "Южная Корея", SortOrder = 12 },
new Country { Code = "IT", Name = "Италия", SortOrder = 13 },
new Country { Code = "PL", Name = "Польша", SortOrder = 14 },
}; };
var existingCodes = await db.Countries.Select(c => c.Code).ToListAsync(ct); private static readonly Currency[] CurrencySeeds =
foreach (var c in wanted)
{ {
if (!existingCodes.Contains(c.Code)) new() { Code = "KZT", Name = "Тенге", Symbol = "₸", MinorUnit = 2 },
{ new() { Code = "RUB", Name = "Российский рубль", Symbol = "₽", MinorUnit = 2 },
db.Countries.Add(c); new() { Code = "USD", Name = "Доллар США", Symbol = "$", MinorUnit = 2 },
} new() { Code = "EUR", Name = "Евро", Symbol = "€", MinorUnit = 2 },
} new() { Code = "CNY", Name = "Китайский юань", Symbol = "¥", MinorUnit = 2 },
} new() { Code = "BYN", Name = "Белорусский рубль", Symbol = "Br", MinorUnit = 2 },
new() { Code = "UZS", Name = "Узбекский сум", Symbol = "сум", MinorUnit = 2 },
new() { Code = "KGS", Name = "Кыргызский сом", Symbol = "сом", MinorUnit = 2 },
new() { Code = "TRY", Name = "Турецкая лира", Symbol = "₺", MinorUnit = 2 },
new() { Code = "KRW", Name = "Южнокорейская вона", Symbol = "₩", MinorUnit = 0 },
new() { Code = "PLN", Name = "Польский злотый", Symbol = "zł", MinorUnit = 2 },
};
private static async Task SeedCurrenciesAsync(AppDbContext db, CancellationToken ct) private static async Task SeedCurrenciesAsync(AppDbContext db, CancellationToken ct)
{ {
var wanted = new[]
{
new Currency { Code = "KZT", Name = "Тенге", Symbol = "₸", MinorUnit = 2 },
new Currency { Code = "RUB", Name = "Российский рубль", Symbol = "₽", MinorUnit = 2 },
new Currency { Code = "USD", Name = "Доллар США", Symbol = "$", MinorUnit = 2 },
new Currency { Code = "EUR", Name = "Евро", Symbol = "€", MinorUnit = 2 },
new Currency { Code = "CNY", Name = "Китайский юань", Symbol = "¥", MinorUnit = 2 },
};
var existingCodes = await db.Currencies.Select(c => c.Code).ToListAsync(ct); var existingCodes = await db.Currencies.Select(c => c.Code).ToListAsync(ct);
foreach (var c in wanted) foreach (var c in CurrencySeeds)
{ {
if (!existingCodes.Contains(c.Code)) if (!existingCodes.Contains(c.Code))
{ {
@ -75,4 +75,36 @@ private static async Task SeedCurrenciesAsync(AppDbContext db, CancellationToken
} }
} }
} }
private static async Task SeedCountriesAsync(AppDbContext db, CancellationToken ct)
{
var existingCodes = await db.Countries.Select(c => c.Code).ToListAsync(ct);
foreach (var s in CountrySeeds)
{
if (!existingCodes.Contains(s.Code))
{
db.Countries.Add(new Country { Code = s.Code, Name = s.Name, SortOrder = s.SortOrder, VatRate = s.VatRate });
}
}
}
private static async Task BackfillCountryDefaultsAsync(AppDbContext db, CancellationToken ct)
{
// Привязываем валюту и НДС к странам (обновит и новосозданные и существующие).
var currenciesByCode = await db.Currencies.ToDictionaryAsync(c => c.Code, ct);
var countries = await db.Countries.ToListAsync(ct);
foreach (var country in countries)
{
var seed = Array.Find(CountrySeeds, s => s.Code == country.Code);
if (seed is null) continue;
if (country.DefaultCurrencyId is null && currenciesByCode.TryGetValue(seed.CurrencyCode, out var cur))
{
country.DefaultCurrencyId = cur.Id;
}
if (country.VatRate == 0m && seed.VatRate > 0m)
{
country.VatRate = seed.VatRate;
}
}
}
} }

View file

@ -2,7 +2,11 @@
namespace foodmarket.Application.Catalog; namespace foodmarket.Application.Catalog;
public record CountryDto(Guid Id, string Code, string Name, int SortOrder); public record CountryDto(
Guid Id, string Code, string Name, int SortOrder,
Guid? DefaultCurrencyId, string? DefaultCurrencyCode, string? DefaultCurrencySymbol,
decimal VatRate);
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol, int MinorUnit, bool IsActive); public record CurrencyDto(Guid Id, string Code, string Name, string Symbol, int MinorUnit, bool IsActive);
@ -48,7 +52,9 @@ public record ProductDto(
IReadOnlyList<ProductBarcodeDto> Barcodes); IReadOnlyList<ProductBarcodeDto> Barcodes);
// Upsert payloads (input) // Upsert payloads (input)
public record CountryInput(string Code, string Name, int SortOrder = 0); public record CountryInput(
string Code, string Name, int SortOrder = 0,
Guid? DefaultCurrencyId = null, decimal VatRate = 0m);
public record CurrencyInput(string Code, string Name, string Symbol, int MinorUnit = 2, bool IsActive = true); public record CurrencyInput(string Code, string Name, string Symbol, int MinorUnit = 2, bool IsActive = true);
public record UnitOfMeasureInput(string Code, string Name, string? Description = null, bool IsActive = true); public record UnitOfMeasureInput(string Code, string Name, string? Description = null, bool IsActive = true);
public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true); public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true);
@ -70,7 +76,7 @@ public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ea
public record ProductPriceInput(Guid PriceTypeId, decimal Amount, Guid CurrencyId); public record ProductPriceInput(Guid PriceTypeId, decimal Amount, Guid CurrencyId);
public record ProductInput( public record ProductInput(
string Name, string? Article, string? Description, string Name, string? Article, string? Description,
Guid UnitOfMeasureId, int Vat, bool VatEnabled, Guid UnitOfMeasureId, int? Vat, bool VatEnabled,
Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId, Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false, bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false,
decimal? MinStock = null, decimal? MaxStock = null, decimal? MinStock = null, decimal? MaxStock = null,

View file

@ -8,8 +8,13 @@ public class Country : Entity
public string Code { get; set; } = null!; // ISO 3166-1 alpha-2, e.g. "KZ" public string Code { get; set; } = null!; // ISO 3166-1 alpha-2, e.g. "KZ"
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
public int SortOrder { get; set; } public int SortOrder { get; set; }
/// <summary>Валюта по умолчанию для этой страны — при выборе страны в настройках /// <summary>Валюта страны — при выборе страны в настройках организации
/// организации её валюта подтягивается автоматически.</summary> /// она становится валютой по умолчанию для этой организации.</summary>
public Guid? DefaultCurrencyId { get; set; } public Guid? DefaultCurrencyId { get; set; }
public Currency? DefaultCurrency { get; set; } public Currency? DefaultCurrency { get; set; }
/// <summary>Ставка НДС этой страны, в процентах (например 16.00 для KZ,
/// 20.00 для RU). Единственный источник правды для ставки НДС —
/// Product.Vat на товаре не хранится.</summary>
public decimal VatRate { get; set; }
} }

View file

@ -11,8 +11,11 @@ public class Product : TenantEntity
public Guid UnitOfMeasureId { get; set; } public Guid UnitOfMeasureId { get; set; }
public UnitOfMeasure? UnitOfMeasure { get; set; } public UnitOfMeasure? UnitOfMeasure { get; set; }
// Ставка НДС на товаре — число 0/10/12/16/20 как в MoySklad. // Ставка НДС в процентах (0/10/12/16/20). Дефолт при создании берётся из
// VatEnabled=true → НДС применяется, false → без НДС. // Country.VatRate организации, пользователь может менять для исключений
// (хлеб = 0% и т.п.) — но только если в настройках организации включена
// галка «Указывать ставку НДС на товаре». VatEnabled — «в том числе НДС»:
// применяется ли ставка на позицию в документах.
public int Vat { get; set; } public int Vat { get; set; }
public bool VatEnabled { get; set; } = true; public bool VatEnabled { get; set; } = true;

View file

@ -26,7 +26,9 @@ public class Organization : Entity
/// false — тогда UI не предлагает выбор валюты, всё в DefaultCurrency.</summary> /// false — тогда UI не предлагает выбор валюты, всё в DefaultCurrency.</summary>
public bool MultiCurrencyEnabled { get; set; } public bool MultiCurrencyEnabled { get; set; }
/// <summary>Ставка НДС по умолчанию для новых товаров (KZ=16%, RU=20%). /// <summary>Показывать ли пользователю галку «В том числе НДС» на форме товара.
/// Само значение применяется к товару при создании; пользователь может менять.</summary> /// Если false (по умолчанию) — магазин работает с одной ставкой НДС и галка
public int DefaultVat { get; set; } = 16; /// скрыта, все товары считаются с НДС. Если true — можно для отдельных товаров
/// (хлеб, медикаменты) снимать галку.</summary>
public bool ShowVatEnabledOnProduct { get; set; }
} }

View file

@ -144,9 +144,11 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
var orgId = organizationIdOverride ?? _tenant.OrganizationId var orgId = organizationIdOverride ?? _tenant.OrganizationId
?? throw new InvalidOperationException("No tenant organization in context."); ?? throw new InvalidOperationException("No tenant organization in context.");
// Pre-load tenant defaults. KZ default VAT is 16% — applied when product didn't // Дефолт VAT — из страны организации (Country.VatRate).
// carry its own vat from MoySklad. var defaultVat = (int)Math.Round(await _db.Countries
const int kzDefaultVat = 16; .Where(c => c.Code == (_db.Organizations
.Where(o => o.Id == orgId).Select(o => o.CountryCode).First()))
.Select(c => (decimal?)c.VatRate).FirstOrDefaultAsync(ct) ?? 16m);
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", ct) var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", ct)
?? await _db.UnitsOfMeasure.FirstAsync(ct); ?? await _db.UnitsOfMeasure.FirstAsync(ct);
var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct) var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct)
@ -218,8 +220,10 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
try try
{ {
var vat = p.Vat ?? kzDefaultVat; // Vat товара: из MoySklad.vat если задано, иначе дефолт страны.
var vatEnabled = (p.Vat ?? kzDefaultVat) > 0; // VatEnabled: если p.Vat явно 0 — «без НДС» (льготная категория).
var vat = p.Vat ?? defaultVat;
var vatEnabled = (p.Vat ?? -1) != 0;
Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId
&& localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null; && localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null;
Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null; Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null;

View file

@ -27,6 +27,7 @@ private static void ConfigureCountry(EntityTypeBuilder<Country> b)
b.ToTable("countries"); b.ToTable("countries");
b.Property(x => x.Code).HasMaxLength(2).IsRequired(); b.Property(x => x.Code).HasMaxLength(2).IsRequired();
b.Property(x => x.Name).HasMaxLength(100).IsRequired(); b.Property(x => x.Name).HasMaxLength(100).IsRequired();
b.Property(x => x.VatRate).HasPrecision(5, 2);
b.HasOne(x => x.DefaultCurrency).WithMany().HasForeignKey(x => x.DefaultCurrencyId).OnDelete(DeleteBehavior.Restrict); b.HasOne(x => x.DefaultCurrency).WithMany().HasForeignKey(x => x.DefaultCurrencyId).OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => x.Code).IsUnique(); b.HasIndex(x => x.Code).IsUnique();
} }
@ -120,6 +121,8 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
b.Property(x => x.PurchasePrice).HasPrecision(18, 4); b.Property(x => x.PurchasePrice).HasPrecision(18, 4);
b.Property(x => x.ImageUrl).HasMaxLength(1000); b.Property(x => x.ImageUrl).HasMaxLength(1000);
// VatEnabled defaults to true в БД — при миграции existing rows сохраняют true.
b.Property(x => x.VatEnabled).HasDefaultValue(true);
b.HasOne(x => x.UnitOfMeasure).WithMany().HasForeignKey(x => x.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict); b.HasOne(x => x.UnitOfMeasure).WithMany().HasForeignKey(x => x.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.ProductGroup).WithMany().HasForeignKey(x => x.ProductGroupId).OnDelete(DeleteBehavior.Restrict); b.HasOne(x => x.ProductGroup).WithMany().HasForeignKey(x => x.ProductGroupId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.DefaultSupplier).WithMany().HasForeignKey(x => x.DefaultSupplierId).OnDelete(DeleteBehavior.Restrict); b.HasOne(x => x.DefaultSupplier).WithMany().HasForeignKey(x => x.DefaultSupplierId).OnDelete(DeleteBehavior.Restrict);

View file

@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>VAT теперь основывается на стране, но хранится и на товаре:
/// - countries.VatRate (numeric(5,2), default 0) — ставка страны, источник правды.
/// - organizations.ShowVatEnabledOnProduct (bool, default false) — показывать ли
/// поля Vat/VatEnabled на карточке товара в UI; внутренне они есть всегда.
/// - organizations.DefaultVat (int) удаляется (заменён на Country.VatRate).
/// - products.Vat ОСТАЁТСЯ: для KZ есть льготные категории (хлеб/молоко/
/// лекарства = 0%) и фискальный чек требует ставку на каждой позиции.
/// Seed: KZ=16%, RU=20%, BY=20%, US=0%, DE=19%, CN=13%, TR=18%, UZ=12%, KG=12%,
/// KR=10%, IT=22%, PL=23%.</summary>
public partial class Phase5_VatAsCountryProperty : Migration
{
protected override void Up(MigrationBuilder b)
{
b.AddColumn<decimal>(
name: "VatRate", schema: "public", table: "countries",
type: "numeric(5,2)", precision: 5, scale: 2, nullable: false, defaultValue: 0m);
b.AddColumn<bool>(
name: "ShowVatEnabledOnProduct", schema: "public", table: "organizations",
type: "boolean", nullable: false, defaultValue: false);
b.DropColumn(name: "DefaultVat", schema: "public", table: "organizations");
b.Sql("""
UPDATE public.countries SET "VatRate" = CASE "Code"
WHEN 'KZ' THEN 16
WHEN 'RU' THEN 20
WHEN 'BY' THEN 20
WHEN 'DE' THEN 19
WHEN 'CN' THEN 13
WHEN 'TR' THEN 18
WHEN 'UZ' THEN 12
WHEN 'KG' THEN 12
WHEN 'KR' THEN 10
WHEN 'IT' THEN 22
WHEN 'PL' THEN 23
ELSE 0
END;
""");
}
protected override void Down(MigrationBuilder b)
{
b.AddColumn<int>(
name: "DefaultVat", schema: "public", table: "organizations",
type: "integer", nullable: false, defaultValue: 16);
b.DropColumn(name: "ShowVatEnabledOnProduct", schema: "public", table: "organizations");
b.DropColumn(name: "VatRate", schema: "public", table: "countries");
}
}
}

View file

@ -448,6 +448,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<DateTime?>("UpdatedAt") b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<decimal>("VatRate")
.HasPrecision(5, 2)
.HasColumnType("numeric(5,2)");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Code") b.HasIndex("Code")
@ -613,7 +617,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<bool>("VatEnabled") b.Property<bool>("VatEnabled")
.HasColumnType("boolean"); .ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.HasKey("Id"); b.HasKey("Id");
@ -1079,9 +1085,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<Guid?>("DefaultCurrencyId") b.Property<Guid?>("DefaultCurrencyId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<int>("DefaultVat")
.HasColumnType("integer");
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("boolean"); .HasColumnType("boolean");
@ -1092,6 +1095,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("MultiCurrencyEnabled") b.Property<bool>("MultiCurrencyEnabled")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("ShowVatEnabledOnProduct")
.HasColumnType("boolean");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)

View file

@ -20,7 +20,13 @@ export const packagingLabel: Record<Packaging, string> = {
[Packaging.Liquid]: 'Разливной', [Packaging.Liquid]: 'Разливной',
} }
export interface Country { id: string; code: string; name: string; sortOrder: number } export interface Country {
id: string; code: string; name: string; sortOrder: number
defaultCurrencyId: string | null
defaultCurrencyCode: string | null
defaultCurrencySymbol: string | null
vatRate: number
}
export interface Currency { id: string; code: string; name: string; symbol: string; minorUnit: number; isActive: boolean } export interface Currency { id: string; code: string; name: string; symbol: string; minorUnit: number; isActive: boolean }
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; isActive: boolean } export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; isActive: boolean }
export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean } export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }

View file

@ -9,7 +9,8 @@ export interface OrgSettings {
defaultCurrencyCode: string | null defaultCurrencyCode: string | null
defaultCurrencySymbol: string | null defaultCurrencySymbol: string | null
multiCurrencyEnabled: boolean multiCurrencyEnabled: boolean
defaultVat: number vatRate: number
showVatEnabledOnProduct: boolean
} }
export function useOrgSettings() { export function useOrgSettings() {

View file

@ -1,18 +1,54 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { useCatalogList } from '@/lib/useCatalog' import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Select } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { useCurrencies } from '@/lib/useLookups'
import type { Country } from '@/lib/types' import type { Country } from '@/lib/types'
const URL = '/api/catalog/countries'
interface Form {
id?: string
code: string
name: string
sortOrder: number
defaultCurrencyId: string | null
vatRate: number
}
const blank: Form = { code: '', name: '', sortOrder: 0, defaultCurrencyId: null, vatRate: 0 }
export function CountriesPage() { export function CountriesPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Country>('/api/catalog/countries') const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Country>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const currencies = useCurrencies()
const [form, setForm] = useState<Form | null>(null)
const save = async () => {
if (!form) return
const { id, ...payload } = form
if (id) await update.mutateAsync({ id, input: payload })
else await create.mutateAsync(payload)
setForm(null)
}
return ( return (
<>
<ListPageShell <ListPageShell
title="Страны" title="Страны"
description="Глобальный справочник. По умолчанию Казахстан." description="Глобальный справочник. Валюта и ставка НДС страны используются как дефолты для организации."
actions={<SearchBar value={search} onChange={setSearch} />} actions={
<>
<SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blank)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
footer={data && data.total > 0 && ( footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} /> <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)} )}
@ -21,12 +57,76 @@ export function CountriesPage() {
rows={data?.items ?? []} rows={data?.items ?? []}
isLoading={isLoading} isLoading={isLoading}
rowKey={(r) => r.id} rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, code: r.code, name: r.name, sortOrder: r.sortOrder,
defaultCurrencyId: r.defaultCurrencyId, vatRate: r.vatRate,
})}
columns={[ columns={[
{ header: 'Код', width: '90px', cell: (r) => <span className="font-mono">{r.code}</span> }, { header: 'Код', width: '90px', cell: (r) => <span className="font-mono">{r.code}</span> },
{ header: 'Название', cell: (r) => r.name }, { header: 'Название', cell: (r) => r.name },
{ header: 'Валюта', width: '130px', cell: (r) => r.defaultCurrencyCode ? `${r.defaultCurrencyCode} ${r.defaultCurrencySymbol ?? ''}` : '—' },
{ header: 'НДС', width: '100px', className: 'text-right', cell: (r) => `${r.vatRate.toFixed(2)}%` },
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder }, { header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
]} ]}
/> />
</ListPageShell> </ListPageShell>
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? 'Редактировать страну' : 'Новая страна'}
footer={
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить страну?')) {
await remove.mutateAsync(form.id!)
setForm(null)
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.code || !form?.name}>Сохранить</Button>
</>
}
>
{form && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="ISO-код (2 буквы)">
<TextInput value={form.code} maxLength={2} onChange={(e) => setForm({ ...form, code: e.target.value.toUpperCase() })} />
</Field>
<Field label="Порядок">
<TextInput type="number" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} />
</Field>
</div>
<Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Валюта">
<Select
value={form.defaultCurrencyId ?? ''}
onChange={(e) => setForm({ ...form, defaultCurrencyId: e.target.value || null })}
>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code} ({c.symbol})</option>)}
</Select>
</Field>
<Field label="Ставка НДС, %">
<TextInput
type="number"
step="0.01"
value={form.vatRate}
onChange={(e) => setForm({ ...form, vatRate: Number(e.target.value) })}
/>
</Field>
</div>
</div>
)}
</Modal>
</>
) )
} }

View file

@ -8,8 +8,6 @@ import { Field, TextInput, Select, Checkbox } from '@/components/Field'
import { useCurrencies, useCountries } from '@/lib/useLookups' import { useCurrencies, useCountries } from '@/lib/useLookups'
import { useOrgSettings, type OrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings, type OrgSettings } from '@/lib/useOrgSettings'
const vatChoices = [0, 10, 12, 16, 20]
export function OrganizationSettingsPage() { export function OrganizationSettingsPage() {
const qc = useQueryClient() const qc = useQueryClient()
const settings = useOrgSettings() const settings = useOrgSettings()
@ -19,21 +17,21 @@ export function OrganizationSettingsPage() {
const [form, setForm] = useState<OrgSettings | null>(null) const [form, setForm] = useState<OrgSettings | null>(null)
useEffect(() => { if (settings.data && !form) setForm(settings.data) }, [settings.data, form]) useEffect(() => { if (settings.data && !form) setForm(settings.data) }, [settings.data, form])
// При смене страны подтягиваем её дефолтную валюту. // При смене страны подтягиваем её валюту и ставку НДС (оба из справочника стран).
const onCountryChange = (countryCode: string) => { const onCountryChange = (countryCode: string) => {
if (!form) return if (!form) return
const country = countries.data?.find((c) => c.code === countryCode) const country = countries.data?.find((c) => c.code === countryCode)
const fallbackByCode: Record<string, string | undefined> = { KZ: 'KZT', RU: 'RUB', BY: 'BYN', US: 'USD' } const currency = country?.defaultCurrencyId
const targetCode = fallbackByCode[countryCode] ? currencies.data?.find((c) => c.id === country.defaultCurrencyId)
const currency = targetCode ? currencies.data?.find((c) => c.code === targetCode) : undefined : undefined
setForm({ setForm({
...form, ...form,
countryCode, countryCode,
defaultCurrencyId: currency?.id ?? form.defaultCurrencyId, defaultCurrencyId: currency?.id ?? country?.defaultCurrencyId ?? null,
defaultCurrencyCode: currency?.code ?? form.defaultCurrencyCode, defaultCurrencyCode: currency?.code ?? country?.defaultCurrencyCode ?? null,
defaultCurrencySymbol: currency?.symbol ?? form.defaultCurrencySymbol, defaultCurrencySymbol: currency?.symbol ?? country?.defaultCurrencySymbol ?? null,
vatRate: country?.vatRate ?? 0,
}) })
void country // reserved for future use (sortOrder etc.)
} }
const save = useMutation({ const save = useMutation({
@ -44,7 +42,7 @@ export function OrganizationSettingsPage() {
countryCode: form.countryCode, countryCode: form.countryCode,
defaultCurrencyId: form.defaultCurrencyId, defaultCurrencyId: form.defaultCurrencyId,
multiCurrencyEnabled: form.multiCurrencyEnabled, multiCurrencyEnabled: form.multiCurrencyEnabled,
defaultVat: form.defaultVat, showVatEnabledOnProduct: form.showVatEnabledOnProduct,
} }
return (await api.put<OrgSettings>('/api/organization/settings', payload)).data return (await api.put<OrgSettings>('/api/organization/settings', payload)).data
}, },
@ -96,11 +94,30 @@ export function OrganizationSettingsPage() {
Если выключено в формах продаж и закупок выбор валюты не показывается, всё считается в валюте по умолчанию. Если выключено в формах продаж и закупок выбор валюты не показывается, всё считается в валюте по умолчанию.
</p> </p>
<Field label="Ставка НДС по умолчанию для новых товаров"> <Field label="Ставка НДС">
<Select value={form.defaultVat} onChange={(e) => setForm({ ...form, defaultVat: Number(e.target.value) })}> <div className="flex items-center gap-3">
{vatChoices.map((v) => <option key={v} value={v}>{v}%</option>)} <TextInput
</Select> value={`${form.vatRate.toFixed(2)}%`}
disabled
className="max-w-[120px]"
/>
<span className="text-xs text-slate-500">
берётся из страны (<strong>{form.countryCode}</strong>) чтобы изменить, откройте справочник{' '}
<a className="underline" href="/catalog/countries">Страны</a>
</span>
</div>
</Field> </Field>
<Checkbox
label='Указывать ставку НДС на товаре'
checked={form.showVatEnabledOnProduct}
onChange={(v) => setForm({ ...form, showVatEnabledOnProduct: v })}
/>
<p className="text-xs text-slate-500 -mt-2">
Если выключено поля «НДС %» и «В том числе НДС» на карточке товара скрыты,
все новые товары получают ставку из страны организации. Если включено у каждого
товара можно задать ставку вручную (хлеб = 0%, лекарства = 0% и т.п.).
</p>
</section> </section>
<div className="mt-4 flex gap-3 items-center"> <div className="mt-4 flex gap-3 items-center">

View file

@ -38,13 +38,11 @@ interface Form {
barcodes: BarcodeRow[] barcodes: BarcodeRow[]
} }
// KZ default VAT rate.
const defaultVat = 16
const vatChoices = [0, 10, 12, 16, 20] const vatChoices = [0, 10, 12, 16, 20]
const emptyForm: Form = { const emptyForm: Form = {
name: '', article: '', description: '', name: '', article: '', description: '',
unitOfMeasureId: '', vat: defaultVat, vatEnabled: true, unitOfMeasureId: '', vat: 16, vatEnabled: true,
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '', productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
isService: false, packaging: Packaging.Piece, isMarked: false, isActive: true, isService: false, packaging: Packaging.Piece, isMarked: false, isActive: true,
minStock: '', maxStock: '', minStock: '', maxStock: '',
@ -108,11 +106,7 @@ export function ProductEditPage() {
: currencies.data?.find(c => c.code === 'KZT') : currencies.data?.find(c => c.code === 'KZT')
setForm((f) => ({ ...f, purchaseCurrencyId: def?.id ?? currencies.data?.[0]?.id ?? '' })) setForm((f) => ({ ...f, purchaseCurrencyId: def?.id ?? currencies.data?.[0]?.id ?? '' }))
} }
// Default VAT для нового товара берём из настроек организации. }, [isNew, units.data, currencies.data, org.data?.defaultCurrencyId, form.unitOfMeasureId, form.purchaseCurrencyId])
if (isNew && org.data?.defaultVat !== undefined && form.vat === 16 && org.data.defaultVat !== 16) {
setForm((f) => ({ ...f, vat: org.data!.defaultVat }))
}
}, [isNew, units.data, currencies.data, org.data?.defaultCurrencyId, org.data?.defaultVat, form.unitOfMeasureId, form.purchaseCurrencyId, form.vat])
const save = useMutation({ const save = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -121,7 +115,9 @@ export function ProductEditPage() {
article: form.article || null, article: form.article || null,
description: form.description || null, description: form.description || null,
unitOfMeasureId: form.unitOfMeasureId, unitOfMeasureId: form.unitOfMeasureId,
vat: form.vat, // Если UI скрывает Vat (showVatEnabledOnProduct=false), посылаем null,
// бэкенд заполнит дефолтом из страны при создании / сохранит прежнее при update.
vat: org.data?.showVatEnabledOnProduct ? form.vat : null,
vatEnabled: form.vatEnabled, vatEnabled: form.vatEnabled,
productGroupId: form.productGroupId || null, productGroupId: form.productGroupId || null,
defaultSupplierId: form.defaultSupplierId || null, defaultSupplierId: form.defaultSupplierId || null,
@ -247,11 +243,6 @@ export function ProductEditPage() {
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)} {units.data?.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</Select> </Select>
</Field> </Field>
<Field label="Ставка НДС, %">
<Select value={form.vat} onChange={(e) => setForm({ ...form, vat: Number(e.target.value) })}>
{vatChoices.map((v) => <option key={v} value={v}>{v}%</option>)}
</Select>
</Field>
<Field label="Группа"> <Field label="Группа">
<Select value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}> <Select value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}>
<option value=""></option> <option value=""></option>
@ -280,8 +271,19 @@ export function ProductEditPage() {
</Select> </Select>
</Field> </Field>
</Grid> </Grid>
{org.data?.showVatEnabledOnProduct && (
<Grid cols={3}>
<Field label="Ставка НДС, %">
<Select value={form.vat} onChange={(e) => setForm({ ...form, vat: Number(e.target.value) })}>
{vatChoices.map((v) => <option key={v} value={v}>{v}%</option>)}
</Select>
</Field>
</Grid>
)}
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2"> <div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2">
<Checkbox label="НДС применяется (ставка выше)" checked={form.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} /> {org.data?.showVatEnabledOnProduct && (
<Checkbox label="В том числе НДС" checked={form.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} />
)}
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} /> <Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
<Checkbox label="Маркируемый (Честный знак / Datamatrix)" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} /> <Checkbox label="Маркируемый (Честный знак / Datamatrix)" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} /> <Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />

View file

@ -6,6 +6,7 @@ import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Plus, Filter, X } from 'lucide-react' import { Plus, Filter, X } from 'lucide-react'
import { useCatalogList } from '@/lib/useCatalog' import { useCatalogList } from '@/lib/useCatalog'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { ProductGroupTree } from '@/components/ProductGroupTree' import { ProductGroupTree } from '@/components/ProductGroupTree'
import type { Product } from '@/lib/types' import type { Product } from '@/lib/types'
@ -96,8 +97,34 @@ export function ProductsPage() {
const [filters, setFilters] = useState<Filters>(defaultFilters) const [filters, setFilters] = useState<Filters>(defaultFilters)
const [filtersOpen, setFiltersOpen] = useState(false) const [filtersOpen, setFiltersOpen] = useState(false)
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL, toExtra(filters)) const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL, toExtra(filters))
const org = useOrgSettings()
const showVat = org.data?.showVatEnabledOnProduct ?? false
const activeCount = activeFilterCount(filters) const activeCount = activeFilterCount(filters)
type Col = {
header: string
width?: string
className?: string
cell: (r: Product) => React.ReactNode
}
const baseColumns: Col[] = [
{ header: 'Название', cell: (r) => (
<div>
<div className="font-medium">{r.name}</div>
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
</div>
)},
{ header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' },
{ header: 'Ед.', width: '70px', cell: (r) => r.unitName },
]
if (showVat) {
baseColumns.push({ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => r.vatEnabled ? `${r.vat}%` : '—' })
}
baseColumns.push(
{ header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
)
return ( return (
<div className="flex h-full min-h-0"> <div className="flex h-full min-h-0">
{/* Left: groups tree */} {/* Left: groups tree */}
@ -171,19 +198,7 @@ export function ProductsPage() {
isLoading={isLoading} isLoading={isLoading}
rowKey={(r) => r.id} rowKey={(r) => r.id}
onRowClick={(r) => navigate(`/catalog/products/${r.id}`)} onRowClick={(r) => navigate(`/catalog/products/${r.id}`)}
columns={[ columns={baseColumns}
{ header: 'Название', cell: (r) => (
<div>
<div className="font-medium">{r.name}</div>
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
</div>
)},
{ header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' },
{ header: 'Ед.', width: '70px', cell: (r) => r.unitName },
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => r.vatEnabled ? `${r.vat}%` : '—' },
{ header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
empty="Товаров ещё нет. Они появятся после приёмки или через API." empty="Товаров ещё нет. Они появятся после приёмки или через API."
/> />
</div> </div>