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).
private async Task<int> ResolveDefaultVatAsync(CancellationToken ct)
private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
{
var orgId = _tenant.OrganizationId;
if (orgId is null) return 0;
if (orgId is null) return 0m;
var countryCode = await _db.Organizations
.Where(o => o.Id == orgId)
.Select(o => o.CountryCode)
.FirstOrDefaultAsync(ct);
if (string.IsNullOrEmpty(countryCode)) return 0;
if (string.IsNullOrEmpty(countryCode)) return 0m;
var rate = await _db.Countries
.Where(c => c.Code == countryCode)
.Select(c => (decimal?)c.VatRate)
.FirstOrDefaultAsync(ct);
return (int)Math.Round(rate ?? 0m);
return rate ?? 0m;
}
[HttpGet]
@ -204,7 +204,7 @@ private static void Apply(Product e, ProductInput i)
e.Article = i.Article;
e.Description = i.Description;
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.ProductGroupId = i.ProductGroupId;
e.DefaultSupplierId = i.DefaultSupplierId;

View file

@ -33,8 +33,8 @@ public async Task StartAsync(CancellationToken ct)
if (hasProducts) return;
// KZ дефолт — 16% (из Country.VatRate), льготные категории (хлеб) — 0%.
const int vatDefault = 16;
const int vat0 = 0;
const decimal vatDefault = 16m;
const decimal vat0 = 0m;
var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters()
.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(
Guid Id, string Name, string? Article, string? Description,
Guid UnitOfMeasureId, string UnitName,
int Vat, bool VatEnabled,
decimal Vat, bool VatEnabled,
Guid? ProductGroupId, string? ProductGroupName,
Guid? DefaultSupplierId, string? DefaultSupplierName,
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 ProductInput(
string Name, string? Article, string? Description,
Guid UnitOfMeasureId, int? Vat, bool VatEnabled,
Guid UnitOfMeasureId, decimal? Vat, bool VatEnabled,
Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false,
decimal? MinStock = null, decimal? MaxStock = null,

View file

@ -11,12 +11,13 @@ public class Product : TenantEntity
public Guid UnitOfMeasureId { get; set; }
public UnitOfMeasure? UnitOfMeasure { get; set; }
// Ставка НДС в процентах (0/10/12/16/20). Дефолт при создании берётся из
// Country.VatRate организации, пользователь может менять для исключений
// (хлеб = 0% и т.п.) — но только если в настройках организации включена
// галка «Указывать ставку НДС на товаре». VatEnabled — «в том числе НДС»:
// применяется ли ставка на позицию в документах.
public int Vat { get; set; }
// Ставка НДС в процентах, decimal(5,2) — например 16.00 или 0.00. Дефолт
// при создании берётся из Country.VatRate организации, пользователь может
// менять для исключений (хлеб = 0.00 и т.п.) — но только если в настройках
// включена галка «Указывать ставку НДС на товаре». VatEnabled управляет
// лишь видимостью поля Vat в UI: снята — поле скрыто, включена — поле
// с текущей ставкой. Семантика «в том числе/сверху» — на уровне документа.
public decimal Vat { get; set; }
public bool VatEnabled { get; set; } = true;
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
?? throw new InvalidOperationException("No tenant organization in context.");
// Дефолт VAT — из страны организации (Country.VatRate).
var defaultVat = (int)Math.Round(await _db.Countries
// Дефолт VAT — из страны организации (Country.VatRate), decimal(5,2).
var defaultVat = await _db.Countries
.Where(c => c.Code == (_db.Organizations
.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)
?? await _db.UnitsOfMeasure.FirstAsync(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
{
// Vat товара: из MoySklad.vat если задано, иначе дефолт страны.
// VatEnabled: если p.Vat явно 0 — «без НДС» (льготная категория).
var vat = p.Vat ?? defaultVat;
var vatEnabled = (p.Vat ?? -1) != 0;
// VatEnabled: приоритет p.VatEnabled, fallback — «без НДС» если p.Vat=0.
var vat = p.Vat.HasValue ? (decimal)p.Vat.Value : defaultVat;
var vatEnabled = p.VatEnabled ?? (p.Vat is null || p.Vat.Value != 0);
Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId
&& localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : 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")
.HasColumnType("timestamp with time zone");
b.Property<int>("Vat")
.HasColumnType("integer");
b.Property<decimal>("Vat")
.HasPrecision(5, 2)
.HasColumnType("numeric(5,2)");
b.Property<bool>("VatEnabled")
.ValueGeneratedOnAdd()

View file

@ -39,7 +39,6 @@ interface Form {
barcodes: BarcodeRow[]
}
const vatChoices = [0, 10, 12, 16, 20]
const emptyForm: Form = {
name: '', article: '', description: '',
@ -107,7 +106,11 @@ export function ProductEditPage() {
: currencies.data?.find(c => c.code === 'KZT')
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({
mutationFn: async () => {
@ -272,12 +275,14 @@ export function ProductEditPage() {
</Select>
</Field>
</Grid>
{org.data?.showVatEnabledOnProduct && (
{org.data?.showVatEnabledOnProduct && form.vatEnabled && (
<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>
<TextInput
type="number" step="0.01" min="0"
value={form.vat}
onChange={(e) => setForm({ ...form, vat: Number(e.target.value) })}
/>
</Field>
</Grid>
)}

View file

@ -121,7 +121,7 @@ export function ProductsPage() {
{ header: 'Ед.', width: '70px', sortKey: 'unit', cell: (r) => r.unitName },
]
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(
{ header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length },