feat(org-settings): AllowFractionalPrices — переключатель дробных цен
Новая галка в настройках магазина «Разрешить дробные цены (с копейками)» (default false). Когда выключено — все денежные поля принимают и сохраняют только целые числа. - Organization.AllowFractionalPrices + миграция Phase5h. - OrgSettings DTO/Input + UI настроек (галка с подсказкой). - MoneyInput получил prop allowFractional: при false запрещает ввод точки/запятой и форматирует целым числом, при true — две цифры после запятой как раньше. - ProductEditPage / SupplyEditPage / RetailSaleEditPage передают org.allowFractionalPrices во все MoneyInput. - Списки Products / Supplies / RetailSales форматируют суммы по настройке (с .00 или без). - Сервер защищён от обхода UI: ProductsController / SuppliesController / RetailSalesController при сохранении округляют purchasePrice / price.amount / unitPrice / discount / paidCash / paidCard до целого если флаг выключен. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
38f7725593
commit
4f4a751d26
|
|
@ -23,6 +23,20 @@ public ProductsController(AppDbContext db, ITenantContext tenant)
|
||||||
_tenant = tenant;
|
_tenant = tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Округление цен под настройку AllowFractionalPrices.
|
||||||
|
// Возвращает true если орг разрешает дробные цены.
|
||||||
|
private async Task<bool> AllowFractionalAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId;
|
||||||
|
if (orgId is null) return true;
|
||||||
|
return await _db.Organizations.Where(o => o.Id == orgId)
|
||||||
|
.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
|
}
|
||||||
|
private static decimal RoundIfNeeded(decimal value, bool allowFractional) =>
|
||||||
|
allowFractional ? value : Math.Round(value, 0, MidpointRounding.AwayFromZero);
|
||||||
|
private static decimal? RoundIfNeeded(decimal? value, bool allowFractional) =>
|
||||||
|
value is null ? null : RoundIfNeeded(value.Value, allowFractional);
|
||||||
|
|
||||||
// Следующий числовой артикул для организации. Находит max(Article::int)
|
// Следующий числовой артикул для организации. Находит max(Article::int)
|
||||||
// среди артикулов, которые полностью состоят из цифр, и прибавляет 1.
|
// среди артикулов, которые полностью состоят из цифр, и прибавляет 1.
|
||||||
// Если числовых артикулов нет — возвращает "1".
|
// Если числовых артикулов нет — возвращает "1".
|
||||||
|
|
@ -139,8 +153,10 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
|
||||||
{
|
{
|
||||||
if (input.Barcodes is null || input.Barcodes.Count == 0)
|
if (input.Barcodes is null || input.Barcodes.Count == 0)
|
||||||
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
|
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
|
||||||
|
var allowFractional = await AllowFractionalAsync(ct);
|
||||||
var e = new Product();
|
var e = new Product();
|
||||||
Apply(e, input);
|
Apply(e, input);
|
||||||
|
e.PurchasePrice = RoundIfNeeded(e.PurchasePrice, allowFractional);
|
||||||
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
|
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
|
||||||
if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct);
|
if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct);
|
||||||
// Авто-артикул: если пользователь не указал — генерируем числовой.
|
// Авто-артикул: если пользователь не указал — генерируем числовой.
|
||||||
|
|
@ -149,7 +165,7 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
|
||||||
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 });
|
||||||
foreach (var pr in input.Prices ?? [])
|
foreach (var pr in input.Prices ?? [])
|
||||||
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId });
|
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = RoundIfNeeded(pr.Amount, allowFractional), CurrencyId = pr.CurrencyId });
|
||||||
|
|
||||||
_db.Products.Add(e);
|
_db.Products.Add(e);
|
||||||
try
|
try
|
||||||
|
|
@ -175,8 +191,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 allowFractional = await AllowFractionalAsync(ct);
|
||||||
var existingVat = e.Vat;
|
var existingVat = e.Vat;
|
||||||
Apply(e, input);
|
Apply(e, input);
|
||||||
|
e.PurchasePrice = RoundIfNeeded(e.PurchasePrice, allowFractional);
|
||||||
// Если UI не передал Vat (скрыт) — сохраняем что было, не обнуляем.
|
// Если UI не передал Vat (скрыт) — сохраняем что было, не обнуляем.
|
||||||
if (input.Vat is null) e.Vat = existingVat;
|
if (input.Vat is null) e.Vat = existingVat;
|
||||||
|
|
||||||
|
|
@ -188,7 +206,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
|
||||||
_db.ProductPrices.RemoveRange(e.Prices);
|
_db.ProductPrices.RemoveRange(e.Prices);
|
||||||
e.Prices.Clear();
|
e.Prices.Clear();
|
||||||
foreach (var pr in input.Prices ?? [])
|
foreach (var pr in input.Prices ?? [])
|
||||||
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId });
|
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = RoundIfNeeded(pr.Amount, allowFractional), CurrencyId = pr.CurrencyId });
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ public record OrgSettingsDto(
|
||||||
bool ShowVatEnabledOnProduct,
|
bool ShowVatEnabledOnProduct,
|
||||||
bool ShowServiceOnProduct,
|
bool ShowServiceOnProduct,
|
||||||
bool ShowMarkedOnProduct,
|
bool ShowMarkedOnProduct,
|
||||||
bool ShowMinMaxStock);
|
bool ShowMinMaxStock,
|
||||||
|
bool AllowFractionalPrices);
|
||||||
|
|
||||||
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
|
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
|
||||||
public record OrgSettingsInput(
|
public record OrgSettingsInput(
|
||||||
|
|
@ -43,7 +44,8 @@ public record OrgSettingsInput(
|
||||||
bool ShowVatEnabledOnProduct,
|
bool ShowVatEnabledOnProduct,
|
||||||
bool ShowServiceOnProduct,
|
bool ShowServiceOnProduct,
|
||||||
bool ShowMarkedOnProduct,
|
bool ShowMarkedOnProduct,
|
||||||
bool ShowMinMaxStock);
|
bool ShowMinMaxStock,
|
||||||
|
bool AllowFractionalPrices);
|
||||||
|
|
||||||
[HttpGet("settings")]
|
[HttpGet("settings")]
|
||||||
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
|
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
|
||||||
|
|
@ -78,6 +80,7 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
|
||||||
o.ShowServiceOnProduct = input.ShowServiceOnProduct;
|
o.ShowServiceOnProduct = input.ShowServiceOnProduct;
|
||||||
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
|
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
|
||||||
o.ShowMinMaxStock = input.ShowMinMaxStock;
|
o.ShowMinMaxStock = input.ShowMinMaxStock;
|
||||||
|
o.AllowFractionalPrices = input.AllowFractionalPrices;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct);
|
await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct);
|
||||||
|
|
@ -104,5 +107,6 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
|
||||||
o.ShowVatEnabledOnProduct,
|
o.ShowVatEnabledOnProduct,
|
||||||
o.ShowServiceOnProduct,
|
o.ShowServiceOnProduct,
|
||||||
o.ShowMarkedOnProduct,
|
o.ShowMarkedOnProduct,
|
||||||
o.ShowMinMaxStock);
|
o.ShowMinMaxStock,
|
||||||
|
o.AllowFractionalPrices);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
|
||||||
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
|
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var number = await GenerateNumberAsync(input.Date, ct);
|
var number = await GenerateNumberAsync(input.Date, ct);
|
||||||
|
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
var supply = new Supply
|
var supply = new Supply
|
||||||
{
|
{
|
||||||
Number = number,
|
Number = number,
|
||||||
|
|
@ -138,12 +139,13 @@ public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input,
|
||||||
var order = 0;
|
var order = 0;
|
||||||
foreach (var l in input.Lines)
|
foreach (var l in input.Lines)
|
||||||
{
|
{
|
||||||
|
var unitPrice = allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero);
|
||||||
supply.Lines.Add(new SupplyLine
|
supply.Lines.Add(new SupplyLine
|
||||||
{
|
{
|
||||||
ProductId = l.ProductId,
|
ProductId = l.ProductId,
|
||||||
Quantity = l.Quantity,
|
Quantity = l.Quantity,
|
||||||
UnitPrice = l.UnitPrice,
|
UnitPrice = unitPrice,
|
||||||
LineTotal = l.Quantity * l.UnitPrice,
|
LineTotal = l.Quantity * unitPrice,
|
||||||
SortOrder = order++,
|
SortOrder = order++,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -174,16 +176,18 @@ public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, C
|
||||||
// Replace lines wholesale (simple, idempotent).
|
// Replace lines wholesale (simple, idempotent).
|
||||||
_db.SupplyLines.RemoveRange(supply.Lines);
|
_db.SupplyLines.RemoveRange(supply.Lines);
|
||||||
supply.Lines.Clear();
|
supply.Lines.Clear();
|
||||||
|
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
var order = 0;
|
var order = 0;
|
||||||
foreach (var l in input.Lines)
|
foreach (var l in input.Lines)
|
||||||
{
|
{
|
||||||
|
var unitPrice = allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero);
|
||||||
supply.Lines.Add(new SupplyLine
|
supply.Lines.Add(new SupplyLine
|
||||||
{
|
{
|
||||||
SupplyId = supply.Id,
|
SupplyId = supply.Id,
|
||||||
ProductId = l.ProductId,
|
ProductId = l.ProductId,
|
||||||
Quantity = l.Quantity,
|
Quantity = l.Quantity,
|
||||||
UnitPrice = l.UnitPrice,
|
UnitPrice = unitPrice,
|
||||||
LineTotal = l.Quantity * l.UnitPrice,
|
LineTotal = l.Quantity * unitPrice,
|
||||||
SortOrder = order++,
|
SortOrder = order++,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,8 @@ public async Task<ActionResult<RetailSaleDto>> Get(Guid id, CancellationToken ct
|
||||||
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
|
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var number = await GenerateNumberAsync(input.Date, ct);
|
var number = await GenerateNumberAsync(input.Date, ct);
|
||||||
|
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
|
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
||||||
var sale = new RetailSale
|
var sale = new RetailSale
|
||||||
{
|
{
|
||||||
Number = number,
|
Number = number,
|
||||||
|
|
@ -208,11 +210,11 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
|
||||||
CustomerId = input.CustomerId,
|
CustomerId = input.CustomerId,
|
||||||
CurrencyId = input.CurrencyId,
|
CurrencyId = input.CurrencyId,
|
||||||
Payment = input.Payment,
|
Payment = input.Payment,
|
||||||
PaidCash = input.PaidCash,
|
PaidCash = R(input.PaidCash),
|
||||||
PaidCard = input.PaidCard,
|
PaidCard = R(input.PaidCard),
|
||||||
Notes = input.Notes,
|
Notes = input.Notes,
|
||||||
};
|
};
|
||||||
ApplyLines(sale, input.Lines);
|
ApplyLines(sale, input.Lines, allowFractional);
|
||||||
_db.RetailSales.Add(sale);
|
_db.RetailSales.Add(sale);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
var dto = await GetInternal(sale.Id, ct);
|
var dto = await GetInternal(sale.Id, ct);
|
||||||
|
|
@ -227,19 +229,21 @@ public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput inpu
|
||||||
if (sale.Status != RetailSaleStatus.Draft)
|
if (sale.Status != RetailSaleStatus.Draft)
|
||||||
return Conflict(new { error = "Только черновик может быть изменён." });
|
return Conflict(new { error = "Только черновик может быть изменён." });
|
||||||
|
|
||||||
|
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
|
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
||||||
sale.Date = input.Date;
|
sale.Date = input.Date;
|
||||||
sale.StoreId = input.StoreId;
|
sale.StoreId = input.StoreId;
|
||||||
sale.RetailPointId = input.RetailPointId;
|
sale.RetailPointId = input.RetailPointId;
|
||||||
sale.CustomerId = input.CustomerId;
|
sale.CustomerId = input.CustomerId;
|
||||||
sale.CurrencyId = input.CurrencyId;
|
sale.CurrencyId = input.CurrencyId;
|
||||||
sale.Payment = input.Payment;
|
sale.Payment = input.Payment;
|
||||||
sale.PaidCash = input.PaidCash;
|
sale.PaidCash = R(input.PaidCash);
|
||||||
sale.PaidCard = input.PaidCard;
|
sale.PaidCard = R(input.PaidCard);
|
||||||
sale.Notes = input.Notes;
|
sale.Notes = input.Notes;
|
||||||
|
|
||||||
_db.RetailSaleLines.RemoveRange(sale.Lines);
|
_db.RetailSaleLines.RemoveRange(sale.Lines);
|
||||||
sale.Lines.Clear();
|
sale.Lines.Clear();
|
||||||
ApplyLines(sale, input.Lines);
|
ApplyLines(sale, input.Lines, allowFractional);
|
||||||
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
|
|
@ -313,25 +317,28 @@ public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ApplyLines(RetailSale sale, IReadOnlyList<RetailSaleLineInput> input)
|
private static void ApplyLines(RetailSale sale, IReadOnlyList<RetailSaleLineInput> input, bool allowFractional)
|
||||||
{
|
{
|
||||||
|
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
||||||
var order = 0;
|
var order = 0;
|
||||||
decimal subtotal = 0, discountTotal = 0;
|
decimal subtotal = 0, discountTotal = 0;
|
||||||
foreach (var l in input)
|
foreach (var l in input)
|
||||||
{
|
{
|
||||||
var lineTotal = l.Quantity * l.UnitPrice - l.Discount;
|
var unitPrice = R(l.UnitPrice);
|
||||||
|
var discount = R(l.Discount);
|
||||||
|
var lineTotal = l.Quantity * unitPrice - discount;
|
||||||
sale.Lines.Add(new RetailSaleLine
|
sale.Lines.Add(new RetailSaleLine
|
||||||
{
|
{
|
||||||
ProductId = l.ProductId,
|
ProductId = l.ProductId,
|
||||||
Quantity = l.Quantity,
|
Quantity = l.Quantity,
|
||||||
UnitPrice = l.UnitPrice,
|
UnitPrice = unitPrice,
|
||||||
Discount = l.Discount,
|
Discount = discount,
|
||||||
LineTotal = lineTotal,
|
LineTotal = lineTotal,
|
||||||
VatPercent = l.VatPercent,
|
VatPercent = l.VatPercent,
|
||||||
SortOrder = order++,
|
SortOrder = order++,
|
||||||
});
|
});
|
||||||
subtotal += l.Quantity * l.UnitPrice;
|
subtotal += l.Quantity * unitPrice;
|
||||||
discountTotal += l.Discount;
|
discountTotal += discount;
|
||||||
}
|
}
|
||||||
sale.Subtotal = subtotal;
|
sale.Subtotal = subtotal;
|
||||||
sale.DiscountTotal = discountTotal;
|
sale.DiscountTotal = discountTotal;
|
||||||
|
|
|
||||||
|
|
@ -46,4 +46,11 @@ public class Organization : Entity
|
||||||
/// на карточке товара и одноимённую колонку в списке. Нужно в основном
|
/// на карточке товара и одноимённую колонку в списке. Нужно в основном
|
||||||
/// торговым сетям со свободным местом на полке — по умолчанию выключено.</summary>
|
/// торговым сетям со свободным местом на полке — по умолчанию выключено.</summary>
|
||||||
public bool ShowMinMaxStock { get; set; }
|
public bool ShowMinMaxStock { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Разрешить ли цены с дробной частью (две цифры после запятой).
|
||||||
|
/// По умолчанию false — большинству KZ-магазинов хватает круглых тенге;
|
||||||
|
/// если включено, MoneyInput работает с шагом 0.01 и форматирует с .00,
|
||||||
|
/// иначе шаг 1 и значения округляются до целого даже при попытке прислать
|
||||||
|
/// дробное через API.</summary>
|
||||||
|
public bool AllowFractionalPrices { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,24 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>Добавляет organizations.AllowFractionalPrices — флаг
|
||||||
|
/// разрешения цен с дробной частью (две цифры после запятой).
|
||||||
|
/// По умолчанию false: KZ-ритейл обычно работает целыми тенге.</summary>
|
||||||
|
public partial class Phase5h_AllowFractionalPrices : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.AddColumn<bool>(
|
||||||
|
name: "AllowFractionalPrices", schema: "public", table: "organizations",
|
||||||
|
type: "boolean", nullable: false, defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.DropColumn(name: "AllowFractionalPrices", schema: "public", table: "organizations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1062,6 +1062,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Property<string>("Address")
|
b.Property<string>("Address")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowFractionalPrices")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<string>("Bin")
|
b.Property<string>("Bin")
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
.HasColumnType("character varying(20)");
|
.HasColumnType("character varying(20)");
|
||||||
|
|
|
||||||
|
|
@ -38,37 +38,43 @@ interface MoneyInputProps {
|
||||||
onChange: (v: number | null) => void
|
onChange: (v: number | null) => void
|
||||||
currencyCode?: string | null
|
currencyCode?: string | null
|
||||||
currencySymbol?: string | null
|
currencySymbol?: string | null
|
||||||
|
/** Разрешать ли две цифры после запятой. По умолчанию false (целые). */
|
||||||
|
allowFractional?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Денежное поле: только цифры + точка/запятая (запятая → точка), 2 знака,
|
/** Денежное поле: только цифры (+ точка/запятая если allowFractional),
|
||||||
* справа — символ валюты (₸/$/€). Запятая, пробелы и буквы фильтруются.
|
* справа — символ валюты (₸/$/€). При allowFractional=false дробная часть
|
||||||
* Пустое поле → null. */
|
* отбрасывается на лету и отображается целым; иначе — два знака после
|
||||||
|
* запятой. Пустое поле → null. */
|
||||||
export function MoneyInput({
|
export function MoneyInput({
|
||||||
value, onChange, currencyCode, currencySymbol, disabled, placeholder = '0', className,
|
value, onChange, currencyCode, currencySymbol, allowFractional = false,
|
||||||
|
disabled, placeholder = '0', className,
|
||||||
}: MoneyInputProps) {
|
}: MoneyInputProps) {
|
||||||
const suffix = currencySymbol || currencyCode || '₸'
|
const suffix = currencySymbol || currencyCode || '₸'
|
||||||
const display = value == null ? '' : String(value)
|
const display = value == null ? '' : (allowFractional ? String(value) : String(Math.round(value)))
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode={allowFractional ? 'decimal' : 'numeric'}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={display}
|
value={display}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const raw = e.target.value.replace(',', '.').replace(/[^\d.]/g, '')
|
let raw = e.target.value.replace(',', '.')
|
||||||
|
raw = allowFractional ? raw.replace(/[^\d.]/g, '') : raw.replace(/[^\d]/g, '')
|
||||||
if (raw === '') return onChange(null)
|
if (raw === '') return onChange(null)
|
||||||
// Не более одной точки.
|
if (allowFractional) {
|
||||||
const parts = raw.split('.')
|
const parts = raw.split('.')
|
||||||
const cleaned = parts.length > 1 ? parts[0] + '.' + parts.slice(1).join('') : raw
|
raw = parts.length > 1 ? parts[0] + '.' + parts.slice(1).join('') : raw
|
||||||
const n = Number(cleaned)
|
}
|
||||||
|
const n = Number(raw)
|
||||||
if (!Number.isFinite(n)) return
|
if (!Number.isFinite(n)) return
|
||||||
onChange(n)
|
onChange(allowFractional ? n : Math.round(n))
|
||||||
}}
|
}}
|
||||||
className={cn(inputClass, 'pr-10 text-right tabular-nums')}
|
className={cn(inputClass, 'pr-10 text-right tabular-nums')}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export interface OrgSettings {
|
||||||
showServiceOnProduct: boolean
|
showServiceOnProduct: boolean
|
||||||
showMarkedOnProduct: boolean
|
showMarkedOnProduct: boolean
|
||||||
showMinMaxStock: boolean
|
showMinMaxStock: boolean
|
||||||
|
allowFractionalPrices: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOrgSettings() {
|
export function useOrgSettings() {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export function OrganizationSettingsPage() {
|
||||||
showServiceOnProduct: form.showServiceOnProduct,
|
showServiceOnProduct: form.showServiceOnProduct,
|
||||||
showMarkedOnProduct: form.showMarkedOnProduct,
|
showMarkedOnProduct: form.showMarkedOnProduct,
|
||||||
showMinMaxStock: form.showMinMaxStock,
|
showMinMaxStock: form.showMinMaxStock,
|
||||||
|
allowFractionalPrices: form.allowFractionalPrices,
|
||||||
}
|
}
|
||||||
return (await api.put<OrgSettings>('/api/organization/settings', payload)).data
|
return (await api.put<OrgSettings>('/api/organization/settings', payload)).data
|
||||||
},
|
},
|
||||||
|
|
@ -136,6 +137,16 @@ export function OrganizationSettingsPage() {
|
||||||
Если включено — на карточке товара есть поля «Минимальный / Максимальный остаток»
|
Если включено — на карточке товара есть поля «Минимальный / Максимальный остаток»
|
||||||
для автозаказа. По умолчанию скрыто.
|
для автозаказа. По умолчанию скрыто.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
label='Разрешить дробные цены (с копейками)'
|
||||||
|
checked={form.allowFractionalPrices}
|
||||||
|
onChange={(v) => setForm({ ...form, allowFractionalPrices: v })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 -mt-2">
|
||||||
|
Включи если хочешь использовать цены с двумя знаками после запятой (1500.50 ₸).
|
||||||
|
По умолчанию — целые тенге, без копеек.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="mt-4 flex gap-3 items-center">
|
<div className="mt-4 flex gap-3 items-center">
|
||||||
|
|
|
||||||
|
|
@ -323,6 +323,7 @@ export function ProductEditPage() {
|
||||||
onChange={(n) => setForm({ ...form, purchasePrice: n == null ? '' : String(n) })}
|
onChange={(n) => setForm({ ...form, purchasePrice: n == null ? '' : String(n) })}
|
||||||
currencyCode={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.code ?? org.data?.defaultCurrencyCode ?? undefined}
|
currencyCode={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.code ?? org.data?.defaultCurrencyCode ?? undefined}
|
||||||
currencySymbol={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.symbol ?? org.data?.defaultCurrencySymbol ?? undefined}
|
currencySymbol={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.symbol ?? org.data?.defaultCurrencySymbol ?? undefined}
|
||||||
|
allowFractional={org.data?.allowFractionalPrices ?? false}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
{org.data?.multiCurrencyEnabled && (
|
{org.data?.multiCurrencyEnabled && (
|
||||||
|
|
@ -378,6 +379,7 @@ export function ProductEditPage() {
|
||||||
onChange={(n) => updatePrice(i, { amount: n ?? 0 })}
|
onChange={(n) => updatePrice(i, { amount: n ?? 0 })}
|
||||||
currencyCode={currencies.data?.find((c) => c.id === p.currencyId)?.code ?? org.data?.defaultCurrencyCode ?? undefined}
|
currencyCode={currencies.data?.find((c) => c.id === p.currencyId)?.code ?? org.data?.defaultCurrencyCode ?? undefined}
|
||||||
currencySymbol={currencies.data?.find((c) => c.id === p.currencyId)?.symbol ?? org.data?.defaultCurrencySymbol ?? undefined}
|
currencySymbol={currencies.data?.find((c) => c.id === p.currencyId)?.symbol ?? org.data?.defaultCurrencySymbol ?? undefined}
|
||||||
|
allowFractional={org.data?.allowFractionalPrices ?? false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{org.data?.multiCurrencyEnabled && (
|
{org.data?.multiCurrencyEnabled && (
|
||||||
|
|
|
||||||
|
|
@ -122,11 +122,15 @@ export function ProductsPage() {
|
||||||
{ header: 'Штрихкод', width: '160px', cell: (r) => (
|
{ header: 'Штрихкод', width: '160px', cell: (r) => (
|
||||||
<span className="font-mono">{r.barcodes[0]?.code ?? '—'}</span>
|
<span className="font-mono">{r.barcodes[0]?.code ?? '—'}</span>
|
||||||
)},
|
)},
|
||||||
{ header: 'Закупочная цена', width: '160px', className: 'text-right font-mono', sortKey: 'purchasePrice', cell: (r) => (
|
{ header: 'Закупочная цена', width: '160px', className: 'text-right font-mono', sortKey: 'purchasePrice', cell: (r) => {
|
||||||
r.purchasePrice != null
|
if (r.purchasePrice == null) return '—'
|
||||||
? `${r.purchasePrice.toLocaleString('ru', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${r.purchaseCurrencyCode ?? ''}`.trim()
|
const fractional = org.data?.allowFractionalPrices ?? false
|
||||||
: '—'
|
const num = r.purchasePrice.toLocaleString('ru',
|
||||||
)},
|
fractional
|
||||||
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
|
: { maximumFractionDigits: 0 })
|
||||||
|
return `${num} ${r.purchaseCurrencyCode ?? ''}`.trim()
|
||||||
|
}},
|
||||||
]
|
]
|
||||||
if (showVat) {
|
if (showVat) {
|
||||||
baseColumns.push({ header: 'НДС', width: '90px', className: 'text-right', sortKey: 'vat', cell: (r) => r.vatEnabled ? `${r.vat.toFixed(2)}%` : '—' })
|
baseColumns.push({ header: 'НДС', width: '90px', className: 'text-right', sortKey: 'vat', cell: (r) => r.vatEnabled ? `${r.vat.toFixed(2)}%` : '—' })
|
||||||
|
|
|
||||||
|
|
@ -288,13 +288,15 @@ export function RetailSaleEditPage() {
|
||||||
<MoneyInput value={form.paidCash} disabled={isPosted}
|
<MoneyInput value={form.paidCash} disabled={isPosted}
|
||||||
onChange={(n) => setForm({ ...form, paidCash: n ?? 0 })}
|
onChange={(n) => setForm({ ...form, paidCash: n ?? 0 })}
|
||||||
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code}
|
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code}
|
||||||
currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol} />
|
currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol}
|
||||||
|
allowFractional={org.data?.allowFractionalPrices ?? false} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Получено картой">
|
<Field label="Получено картой">
|
||||||
<MoneyInput value={form.paidCard} disabled={isPosted}
|
<MoneyInput value={form.paidCard} disabled={isPosted}
|
||||||
onChange={(n) => setForm({ ...form, paidCard: n ?? 0 })}
|
onChange={(n) => setForm({ ...form, paidCard: n ?? 0 })}
|
||||||
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code}
|
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code}
|
||||||
currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol} />
|
currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol}
|
||||||
|
allowFractional={org.data?.allowFractionalPrices ?? false} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Примечание" className="md:col-span-3">
|
<Field label="Примечание" className="md:col-span-3">
|
||||||
<TextArea rows={2} value={form.notes} disabled={isPosted}
|
<TextArea rows={2} value={form.notes} disabled={isPosted}
|
||||||
|
|
@ -345,14 +347,16 @@ export function RetailSaleEditPage() {
|
||||||
value={l.unitPrice}
|
value={l.unitPrice}
|
||||||
onChange={(n) => updateLine(i, { unitPrice: n ?? 0 })}
|
onChange={(n) => updateLine(i, { unitPrice: n ?? 0 })}
|
||||||
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code}
|
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code}
|
||||||
currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol} />
|
currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol}
|
||||||
|
allowFractional={org.data?.allowFractionalPrices ?? false} />
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<MoneyInput disabled={isPosted}
|
<MoneyInput disabled={isPosted}
|
||||||
value={l.discount}
|
value={l.discount}
|
||||||
onChange={(n) => updateLine(i, { discount: n ?? 0 })}
|
onChange={(n) => updateLine(i, { discount: n ?? 0 })}
|
||||||
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code}
|
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code}
|
||||||
currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol} />
|
currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol}
|
||||||
|
allowFractional={org.data?.allowFractionalPrices ?? false} />
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 text-right font-mono font-semibold">
|
<td className="py-2 px-3 text-right font-mono font-semibold">
|
||||||
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}
|
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { type RetailSaleListRow, RetailSaleStatus, PaymentMethod } from '@/lib/types'
|
import { type RetailSaleListRow, RetailSaleStatus, PaymentMethod } from '@/lib/types'
|
||||||
|
|
||||||
const URL = '/api/sales/retail'
|
const URL = '/api/sales/retail'
|
||||||
|
|
@ -21,6 +22,11 @@ const paymentLabel: Record<number, string> = {
|
||||||
export function RetailSalesPage() {
|
export function RetailSalesPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<RetailSaleListRow>(URL)
|
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<RetailSaleListRow>(URL)
|
||||||
|
const org = useOrgSettings()
|
||||||
|
const fractional = org.data?.allowFractionalPrices ?? false
|
||||||
|
const moneyFmt = fractional
|
||||||
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
|
: { maximumFractionDigits: 0 }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
|
|
@ -59,7 +65,7 @@ export function RetailSalesPage() {
|
||||||
{ header: 'Покупатель', width: '180px', cell: (r) => r.customerName ?? <span className="text-slate-400">аноним</span> },
|
{ header: 'Покупатель', width: '180px', cell: (r) => r.customerName ?? <span className="text-slate-400">аноним</span> },
|
||||||
{ header: 'Оплата', width: '120px', cell: (r) => paymentLabel[r.payment] ?? '?' },
|
{ header: 'Оплата', width: '120px', cell: (r) => paymentLabel[r.payment] ?? '?' },
|
||||||
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount },
|
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount },
|
||||||
{ header: 'Сумма', width: '160px', className: 'text-right font-mono font-semibold', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', { maximumFractionDigits: 2 })} ${r.currencyCode}` },
|
{ header: 'Сумма', width: '160px', className: 'text-right font-mono font-semibold', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` },
|
||||||
]}
|
]}
|
||||||
empty="Чеков пока нет. На Phase 5 здесь начнут падать продажи с касс. Сейчас можно создать вручную для теста."
|
empty="Чеков пока нет. На Phase 5 здесь начнут падать продажи с касс. Сейчас можно создать вручную для теста."
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { type SupplyListRow, SupplyStatus } from '@/lib/types'
|
import { type SupplyListRow, SupplyStatus } from '@/lib/types'
|
||||||
|
|
||||||
const URL = '/api/purchases/supplies'
|
const URL = '/api/purchases/supplies'
|
||||||
|
|
@ -13,6 +14,11 @@ const URL = '/api/purchases/supplies'
|
||||||
export function SuppliesPage() {
|
export function SuppliesPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<SupplyListRow>(URL)
|
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<SupplyListRow>(URL)
|
||||||
|
const org = useOrgSettings()
|
||||||
|
const fractional = org.data?.allowFractionalPrices ?? false
|
||||||
|
const moneyFmt = fractional
|
||||||
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
|
: { maximumFractionDigits: 0 }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
|
|
@ -49,7 +55,7 @@ export function SuppliesPage() {
|
||||||
{ header: 'Поставщик', sortKey: 'supplier', cell: (r) => r.supplierName },
|
{ header: 'Поставщик', sortKey: 'supplier', cell: (r) => r.supplierName },
|
||||||
{ header: 'Склад', width: '180px', sortKey: 'store', cell: (r) => r.storeName },
|
{ header: 'Склад', width: '180px', sortKey: 'store', cell: (r) => r.storeName },
|
||||||
{ header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount },
|
{ header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount },
|
||||||
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', { maximumFractionDigits: 2 })} ${r.currencyCode}` },
|
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` },
|
||||||
]}
|
]}
|
||||||
empty="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения."
|
empty="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения."
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -321,7 +321,8 @@ export function SupplyEditPage() {
|
||||||
value={l.unitPrice}
|
value={l.unitPrice}
|
||||||
onChange={(n) => updateLine(i, { unitPrice: n ?? 0 })}
|
onChange={(n) => updateLine(i, { unitPrice: n ?? 0 })}
|
||||||
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code}
|
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code}
|
||||||
currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol} />
|
currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol}
|
||||||
|
allowFractional={org.data?.allowFractionalPrices ?? false} />
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 text-right font-mono font-semibold">
|
<td className="py-2 px-3 text-right font-mono font-semibold">
|
||||||
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}
|
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue