refactor(vat): Product.Vat как decimal(5,2), поле видно только при VatEnabled
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 29s
CI / Web (React + Vite) (push) Successful in 22s
Docker Images / API image (push) Successful in 38s
Docker Images / Web image (push) Successful in 23s
Docker Images / Deploy stage (push) Successful in 17s
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 29s
CI / Web (React + Vite) (push) Successful in 22s
Docker Images / API image (push) Successful in 38s
Docker Images / Web image (push) Successful in 23s
Docker Images / Deploy stage (push) Successful in 17s
Единственная роль галки «В том числе НДС» на товаре — показать/скрыть поле «Ставка НДС %». Никакой семантики «в том числе/сверху» на товаре не живёт — это логика документа (продажи/поставки). - Product.Vat: int → decimal (миграция Phase5d_ProductVatDecimal меняет тип колонки на numeric(5,2)). - ProductDto/ProductInput: decimal? Vat. - ResolveDefaultVatAsync, seeders, MoySklad import — decimal. - MoySklad 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
143f9d5330
commit
3ed6fe25be
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
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")
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue