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:
parent
781f268089
commit
24dc7fc619
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
1885
src/food-market.infrastructure/Persistence/Migrations/20260424210000_Phase5d_ProductVatDecimal.Designer.cs
generated
Normal file
1885
src/food-market.infrastructure/Persistence/Migrations/20260424210000_Phase5d_ProductVatDecimal.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Reference in a new issue