refactor(vat): Product.Vat как decimal(5,2), поле видно только при VatEnabled

Единственная роль галки «В том числе НДС» на товаре — показать/скрыть
поле «Ставка НДС %». Никакой семантики «в том числе/сверху» на товаре
не живёт — это логика документа (продажи/поставки).

- Product.Vat: int → decimal (миграция Phase5d_ProductVatDecimal меняет
  тип колонки на numeric(5,2)).
- ProductDto/ProductInput: decimal? Vat.
- ResolveDefaultVatAsync, seeders, OtherSystem import — decimal.
- OtherSystem import: если vatEnabled пришёл — уважаем, иначе прежний
  fallback «vat=0 → без НДС».
- UI: вместо жёсткого Select [0,10,12,16,20] — TextInput number step=0.01;
  поле рендерится только когда form.vatEnabled=true; дефолт для нового
  товара подставляется из Country.VatRate организации.
- В таблице товаров ставка печатается с 2 знаками (16.00%).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-24 16:51:11 +05:00
parent 781f268089
commit 24dc7fc619
10 changed files with 1950 additions and 30 deletions

View file

@ -24,20 +24,20 @@ public ProductsController(AppDbContext db, ITenantContext tenant)
} }
// Дефолт Vat для нового товара — из страны организации (Country.VatRate). // Дефолт Vat для нового товара — из страны организации (Country.VatRate).
private async Task<int> ResolveDefaultVatAsync(CancellationToken ct) private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
{ {
var orgId = _tenant.OrganizationId; var orgId = _tenant.OrganizationId;
if (orgId is null) return 0; if (orgId is null) return 0m;
var countryCode = await _db.Organizations var countryCode = await _db.Organizations
.Where(o => o.Id == orgId) .Where(o => o.Id == orgId)
.Select(o => o.CountryCode) .Select(o => o.CountryCode)
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
if (string.IsNullOrEmpty(countryCode)) return 0; if (string.IsNullOrEmpty(countryCode)) return 0m;
var rate = await _db.Countries var rate = await _db.Countries
.Where(c => c.Code == countryCode) .Where(c => c.Code == countryCode)
.Select(c => (decimal?)c.VatRate) .Select(c => (decimal?)c.VatRate)
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
return (int)Math.Round(rate ?? 0m); return rate ?? 0m;
} }
[HttpGet] [HttpGet]
@ -204,7 +204,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;
if (i.Vat is int v) e.Vat = v; if (i.Vat is decimal 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

@ -33,8 +33,8 @@ public async Task StartAsync(CancellationToken ct)
if (hasProducts) return; if (hasProducts) return;
// KZ дефолт — 16% (из Country.VatRate), льготные категории (хлеб) — 0%. // KZ дефолт — 16% (из Country.VatRate), льготные категории (хлеб) — 0%.
const int vatDefault = 16; const decimal vatDefault = 16m;
const int vat0 = 0; const decimal vat0 = 0m;
var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters() var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "796", ct); .FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "796", ct);

View file

@ -40,7 +40,7 @@ public record ProductPriceDto(Guid Id, Guid PriceTypeId, string PriceTypeName, d
public record ProductDto( public record ProductDto(
Guid Id, string Name, string? Article, string? Description, Guid Id, string Name, string? Article, string? Description,
Guid UnitOfMeasureId, string UnitName, Guid UnitOfMeasureId, string UnitName,
int Vat, bool VatEnabled, decimal Vat, bool VatEnabled,
Guid? ProductGroupId, string? ProductGroupName, Guid? ProductGroupId, string? ProductGroupName,
Guid? DefaultSupplierId, string? DefaultSupplierName, Guid? DefaultSupplierId, string? DefaultSupplierName,
Guid? CountryOfOriginId, string? CountryOfOriginName, Guid? CountryOfOriginId, string? CountryOfOriginName,
@ -76,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, decimal? 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

@ -11,12 +11,13 @@ 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). Дефолт при создании берётся из // Ставка НДС в процентах, decimal(5,2) — например 16.00 или 0.00. Дефолт
// Country.VatRate организации, пользователь может менять для исключений // при создании берётся из Country.VatRate организации, пользователь может
// (хлеб = 0% и т.п.) — но только если в настройках организации включена // менять для исключений (хлеб = 0.00 и т.п.) — но только если в настройках
// галка «Указывать ставку НДС на товаре». VatEnabled — «в том числе НДС»: // включена галка «Указывать ставку НДС на товаре». VatEnabled управляет
// применяется ли ставка на позицию в документах. // лишь видимостью поля Vat в UI: снята — поле скрыто, включена — поле
public int Vat { get; set; } // с текущей ставкой. Семантика «в том числе/сверху» — на уровне документа.
public decimal Vat { get; set; }
public bool VatEnabled { get; set; } = true; public bool VatEnabled { get; set; } = true;
public Guid? ProductGroupId { get; set; } public Guid? ProductGroupId { get; set; }

View file

@ -144,11 +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.");
// Дефолт VAT — из страны организации (Country.VatRate). // Дефолт VAT — из страны организации (Country.VatRate), decimal(5,2).
var defaultVat = (int)Math.Round(await _db.Countries var defaultVat = await _db.Countries
.Where(c => c.Code == (_db.Organizations .Where(c => c.Code == (_db.Organizations
.Where(o => o.Id == orgId).Select(o => o.CountryCode).First())) .Where(o => o.Id == orgId).Select(o => o.CountryCode).First()))
.Select(c => (decimal?)c.VatRate).FirstOrDefaultAsync(ct) ?? 16m); .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)
@ -221,9 +221,9 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
try try
{ {
// Vat товара: из MoySklad.vat если задано, иначе дефолт страны. // Vat товара: из MoySklad.vat если задано, иначе дефолт страны.
// VatEnabled: если p.Vat явно 0 — «без НДС» (льготная категория). // VatEnabled: приоритет p.VatEnabled, fallback — «без НДС» если p.Vat=0.
var vat = p.Vat ?? defaultVat; var vat = p.Vat.HasValue ? (decimal)p.Vat.Value : defaultVat;
var vatEnabled = (p.Vat ?? -1) != 0; var vatEnabled = p.VatEnabled ?? (p.Vat is null || p.Vat.Value != 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

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Переводит products.Vat в numeric(5,2) — чтобы ставки могли быть
/// нецелыми (например 16.00, 10.50). Семантика галки VatEnabled остаётся
/// прежней: управляет видимостью поля Vat в UI карточки товара.</summary>
public partial class Phase5d_ProductVatDecimal : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql("""
ALTER TABLE public.products
ALTER COLUMN "Vat" TYPE numeric(5,2) USING "Vat"::numeric(5,2);
""");
}
protected override void Down(MigrationBuilder b)
{
b.Sql("""
ALTER TABLE public.products
ALTER COLUMN "Vat" TYPE integer USING ROUND("Vat")::integer;
""");
}
}
}

View file

@ -610,8 +610,9 @@ 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<int>("Vat") b.Property<decimal>("Vat")
.HasColumnType("integer"); .HasPrecision(5, 2)
.HasColumnType("numeric(5,2)");
b.Property<bool>("VatEnabled") b.Property<bool>("VatEnabled")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()

View file

@ -39,7 +39,6 @@ interface Form {
barcodes: BarcodeRow[] barcodes: BarcodeRow[]
} }
const vatChoices = [0, 10, 12, 16, 20]
const emptyForm: Form = { const emptyForm: Form = {
name: '', article: '', description: '', name: '', article: '', description: '',
@ -107,7 +106,11 @@ 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 ?? '' }))
} }
}, [isNew, units.data, currencies.data, org.data?.defaultCurrencyId, form.unitOfMeasureId, form.purchaseCurrencyId]) // Дефолт Vat для нового товара — из Country.VatRate организации.
if (isNew && org.data && form.vat === 16 && org.data.vatRate !== 16) {
setForm((f) => ({ ...f, vat: org.data!.vatRate }))
}
}, [isNew, units.data, currencies.data, org.data, form.unitOfMeasureId, form.purchaseCurrencyId, form.vat])
const save = useMutation({ const save = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -272,12 +275,14 @@ export function ProductEditPage() {
</Select> </Select>
</Field> </Field>
</Grid> </Grid>
{org.data?.showVatEnabledOnProduct && ( {org.data?.showVatEnabledOnProduct && form.vatEnabled && (
<Grid cols={3}> <Grid cols={3}>
<Field label="Ставка НДС, %"> <Field label="Ставка НДС, %">
<Select value={form.vat} onChange={(e) => setForm({ ...form, vat: Number(e.target.value) })}> <TextInput
{vatChoices.map((v) => <option key={v} value={v}>{v}%</option>)} type="number" step="0.01" min="0"
</Select> value={form.vat}
onChange={(e) => setForm({ ...form, vat: Number(e.target.value) })}
/>
</Field> </Field>
</Grid> </Grid>
)} )}

View file

@ -121,7 +121,7 @@ export function ProductsPage() {
{ header: 'Ед.', width: '70px', sortKey: 'unit', cell: (r) => r.unitName }, { header: 'Ед.', width: '70px', sortKey: 'unit', cell: (r) => r.unitName },
] ]
if (showVat) { if (showVat) {
baseColumns.push({ header: 'НДС', width: '80px', className: 'text-right', sortKey: 'vat', cell: (r) => r.vatEnabled ? `${r.vat}%` : '—' }) baseColumns.push({ header: 'НДС', width: '90px', className: 'text-right', sortKey: 'vat', cell: (r) => r.vatEnabled ? `${r.vat.toFixed(2)}%` : '—' })
} }
baseColumns.push( baseColumns.push(
{ header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length }, { header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length },