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:
parent
e16375ccf6
commit
6979599791
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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("Лепёшка")),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
1881
src/food-market.infrastructure/Persistence/Migrations/20260424062000_Phase5_VatAsCountryProperty.Designer.cs
generated
Normal file
1881
src/food-market.infrastructure/Persistence/Migrations/20260424062000_Phase5_VatAsCountryProperty.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 })} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue