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:
nns 2026-04-25 12:21:04 +05:00
parent 38f7725593
commit 4f4a751d26
17 changed files with 2041 additions and 45 deletions

View file

@ -23,6 +23,20 @@ public ProductsController(AppDbContext db, ITenantContext 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)
// среди артикулов, которые полностью состоят из цифр, и прибавляет 1.
// Если числовых артикулов нет — возвращает "1".
@ -139,8 +153,10 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
{
if (input.Barcodes is null || input.Barcodes.Count == 0)
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
var allowFractional = await AllowFractionalAsync(ct);
var e = new Product();
Apply(e, input);
e.PurchasePrice = RoundIfNeeded(e.PurchasePrice, allowFractional);
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
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 ?? [])
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
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);
try
@ -175,8 +191,10 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
var allowFractional = await AllowFractionalAsync(ct);
var existingVat = e.Vat;
Apply(e, input);
e.PurchasePrice = RoundIfNeeded(e.PurchasePrice, allowFractional);
// Если UI не передал Vat (скрыт) — сохраняем что было, не обнуляем.
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);
e.Prices.Clear();
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
{

View file

@ -33,7 +33,8 @@ public record OrgSettingsDto(
bool ShowVatEnabledOnProduct,
bool ShowServiceOnProduct,
bool ShowMarkedOnProduct,
bool ShowMinMaxStock);
bool ShowMinMaxStock,
bool AllowFractionalPrices);
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
public record OrgSettingsInput(
@ -43,7 +44,8 @@ public record OrgSettingsInput(
bool ShowVatEnabledOnProduct,
bool ShowServiceOnProduct,
bool ShowMarkedOnProduct,
bool ShowMinMaxStock);
bool ShowMinMaxStock,
bool AllowFractionalPrices);
[HttpGet("settings")]
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.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
o.ShowMinMaxStock = input.ShowMinMaxStock;
o.AllowFractionalPrices = input.AllowFractionalPrices;
await _db.SaveChangesAsync(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.ShowServiceOnProduct,
o.ShowMarkedOnProduct,
o.ShowMinMaxStock);
o.ShowMinMaxStock,
o.AllowFractionalPrices);
}

View file

@ -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)
{
var number = await GenerateNumberAsync(input.Date, ct);
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
var supply = new Supply
{
Number = number,
@ -138,12 +139,13 @@ public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input,
var order = 0;
foreach (var l in input.Lines)
{
var unitPrice = allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero);
supply.Lines.Add(new SupplyLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice,
LineTotal = l.Quantity * l.UnitPrice,
UnitPrice = unitPrice,
LineTotal = l.Quantity * unitPrice,
SortOrder = order++,
});
}
@ -174,16 +176,18 @@ public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, C
// Replace lines wholesale (simple, idempotent).
_db.SupplyLines.RemoveRange(supply.Lines);
supply.Lines.Clear();
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
var order = 0;
foreach (var l in input.Lines)
{
var unitPrice = allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero);
supply.Lines.Add(new SupplyLine
{
SupplyId = supply.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice,
LineTotal = l.Quantity * l.UnitPrice,
UnitPrice = unitPrice,
LineTotal = l.Quantity * unitPrice,
SortOrder = order++,
});
}

View file

@ -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)
{
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
{
Number = number,
@ -208,11 +210,11 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
CustomerId = input.CustomerId,
CurrencyId = input.CurrencyId,
Payment = input.Payment,
PaidCash = input.PaidCash,
PaidCard = input.PaidCard,
PaidCash = R(input.PaidCash),
PaidCard = R(input.PaidCard),
Notes = input.Notes,
};
ApplyLines(sale, input.Lines);
ApplyLines(sale, input.Lines, allowFractional);
_db.RetailSales.Add(sale);
await _db.SaveChangesAsync(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)
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.StoreId = input.StoreId;
sale.RetailPointId = input.RetailPointId;
sale.CustomerId = input.CustomerId;
sale.CurrencyId = input.CurrencyId;
sale.Payment = input.Payment;
sale.PaidCash = input.PaidCash;
sale.PaidCard = input.PaidCard;
sale.PaidCash = R(input.PaidCash);
sale.PaidCard = R(input.PaidCard);
sale.Notes = input.Notes;
_db.RetailSaleLines.RemoveRange(sale.Lines);
sale.Lines.Clear();
ApplyLines(sale, input.Lines);
ApplyLines(sale, input.Lines, allowFractional);
await _db.SaveChangesAsync(ct);
return NoContent();
@ -313,25 +317,28 @@ public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
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;
decimal subtotal = 0, discountTotal = 0;
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
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice,
Discount = l.Discount,
UnitPrice = unitPrice,
Discount = discount,
LineTotal = lineTotal,
VatPercent = l.VatPercent,
SortOrder = order++,
});
subtotal += l.Quantity * l.UnitPrice;
discountTotal += l.Discount;
subtotal += l.Quantity * unitPrice;
discountTotal += discount;
}
sale.Subtotal = subtotal;
sale.DiscountTotal = discountTotal;

View file

@ -46,4 +46,11 @@ public class Organization : Entity
/// на карточке товара и одноимённую колонку в списке. Нужно в основном
/// торговым сетям со свободным местом на полке — по умолчанию выключено.</summary>
public bool ShowMinMaxStock { get; set; }
/// <summary>Разрешить ли цены с дробной частью (две цифры после запятой).
/// По умолчанию false — большинству KZ-магазинов хватает круглых тенге;
/// если включено, MoneyInput работает с шагом 0.01 и форматирует с .00,
/// иначе шаг 1 и значения округляются до целого даже при попытке прислать
/// дробное через API.</summary>
public bool AllowFractionalPrices { get; set; }
}

View file

@ -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");
}
}
}

View file

@ -1062,6 +1062,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<string>("Address")
.HasColumnType("text");
b.Property<bool>("AllowFractionalPrices")
.HasColumnType("boolean");
b.Property<string>("Bin")
.HasMaxLength(20)
.HasColumnType("character varying(20)");

View file

@ -38,37 +38,43 @@ interface MoneyInputProps {
onChange: (v: number | null) => void
currencyCode?: string | null
currencySymbol?: string | null
/** Разрешать ли две цифры после запятой. По умолчанию false (целые). */
allowFractional?: boolean
disabled?: boolean
placeholder?: string
className?: string
}
/** Денежное поле: только цифры + точка/запятая (запятая точка), 2 знака,
* справа символ валюты (/$/). Запятая, пробелы и буквы фильтруются.
* Пустое поле null. */
/** Денежное поле: только цифры (+ точка/запятая если allowFractional),
* справа символ валюты (/$/). При allowFractional=false дробная часть
* отбрасывается на лету и отображается целым; иначе два знака после
* запятой. Пустое поле null. */
export function MoneyInput({
value, onChange, currencyCode, currencySymbol, disabled, placeholder = '0', className,
value, onChange, currencyCode, currencySymbol, allowFractional = false,
disabled, placeholder = '0', className,
}: MoneyInputProps) {
const suffix = currencySymbol || currencyCode || '₸'
const display = value == null ? '' : String(value)
const display = value == null ? '' : (allowFractional ? String(value) : String(Math.round(value)))
return (
<div className={cn('relative', className)}>
<input
type="text"
inputMode="decimal"
inputMode={allowFractional ? 'decimal' : 'numeric'}
disabled={disabled}
value={display}
placeholder={placeholder}
onFocus={(e) => e.currentTarget.select()}
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)
// Не более одной точки.
const parts = raw.split('.')
const cleaned = parts.length > 1 ? parts[0] + '.' + parts.slice(1).join('') : raw
const n = Number(cleaned)
if (allowFractional) {
const parts = raw.split('.')
raw = parts.length > 1 ? parts[0] + '.' + parts.slice(1).join('') : raw
}
const n = Number(raw)
if (!Number.isFinite(n)) return
onChange(n)
onChange(allowFractional ? n : Math.round(n))
}}
className={cn(inputClass, 'pr-10 text-right tabular-nums')}
/>

View file

@ -14,6 +14,7 @@ export interface OrgSettings {
showServiceOnProduct: boolean
showMarkedOnProduct: boolean
showMinMaxStock: boolean
allowFractionalPrices: boolean
}
export function useOrgSettings() {

View file

@ -41,6 +41,7 @@ export function OrganizationSettingsPage() {
showServiceOnProduct: form.showServiceOnProduct,
showMarkedOnProduct: form.showMarkedOnProduct,
showMinMaxStock: form.showMinMaxStock,
allowFractionalPrices: form.allowFractionalPrices,
}
return (await api.put<OrgSettings>('/api/organization/settings', payload)).data
},
@ -136,6 +137,16 @@ export function OrganizationSettingsPage() {
Если включено на карточке товара есть поля «Минимальный / Максимальный остаток»
для автозаказа. По умолчанию скрыто.
</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>
<div className="mt-4 flex gap-3 items-center">

View file

@ -323,6 +323,7 @@ export function ProductEditPage() {
onChange={(n) => setForm({ ...form, purchasePrice: n == null ? '' : String(n) })}
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}
allowFractional={org.data?.allowFractionalPrices ?? false}
/>
</Field>
{org.data?.multiCurrencyEnabled && (
@ -378,6 +379,7 @@ export function ProductEditPage() {
onChange={(n) => updatePrice(i, { amount: n ?? 0 })}
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}
allowFractional={org.data?.allowFractionalPrices ?? false}
/>
</div>
{org.data?.multiCurrencyEnabled && (

View file

@ -122,11 +122,15 @@ export function ProductsPage() {
{ header: 'Штрихкод', width: '160px', cell: (r) => (
<span className="font-mono">{r.barcodes[0]?.code ?? '—'}</span>
)},
{ header: 'Закупочная цена', width: '160px', className: 'text-right font-mono', sortKey: 'purchasePrice', cell: (r) => (
r.purchasePrice != null
? `${r.purchasePrice.toLocaleString('ru', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${r.purchaseCurrencyCode ?? ''}`.trim()
: '—'
)},
{ header: 'Закупочная цена', width: '160px', className: 'text-right font-mono', sortKey: 'purchasePrice', cell: (r) => {
if (r.purchasePrice == null) return '—'
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) {
baseColumns.push({ header: 'НДС', width: '90px', className: 'text-right', sortKey: 'vat', cell: (r) => r.vatEnabled ? `${r.vat.toFixed(2)}%` : '—' })

View file

@ -288,13 +288,15 @@ export function RetailSaleEditPage() {
<MoneyInput value={form.paidCash} disabled={isPosted}
onChange={(n) => setForm({ ...form, paidCash: n ?? 0 })}
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 label="Получено картой">
<MoneyInput value={form.paidCard} disabled={isPosted}
onChange={(n) => setForm({ ...form, paidCard: n ?? 0 })}
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 label="Примечание" className="md:col-span-3">
<TextArea rows={2} value={form.notes} disabled={isPosted}
@ -345,14 +347,16 @@ export function RetailSaleEditPage() {
value={l.unitPrice}
onChange={(n) => updateLine(i, { unitPrice: n ?? 0 })}
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 className="py-2 px-3">
<MoneyInput disabled={isPosted}
value={l.discount}
onChange={(n) => updateLine(i, { discount: n ?? 0 })}
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 className="py-2 px-3 text-right font-mono font-semibold">
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}

View file

@ -6,6 +6,7 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { useCatalogList } from '@/lib/useCatalog'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { type RetailSaleListRow, RetailSaleStatus, PaymentMethod } from '@/lib/types'
const URL = '/api/sales/retail'
@ -21,6 +22,11 @@ const paymentLabel: Record<number, string> = {
export function RetailSalesPage() {
const navigate = useNavigate()
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 (
<ListPageShell
@ -59,7 +65,7 @@ export function RetailSalesPage() {
{ header: 'Покупатель', width: '180px', cell: (r) => r.customerName ?? <span className="text-slate-400">аноним</span> },
{ header: 'Оплата', width: '120px', cell: (r) => paymentLabel[r.payment] ?? '?' },
{ 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 здесь начнут падать продажи с касс. Сейчас можно создать вручную для теста."
/>

View file

@ -6,6 +6,7 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { useCatalogList } from '@/lib/useCatalog'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { type SupplyListRow, SupplyStatus } from '@/lib/types'
const URL = '/api/purchases/supplies'
@ -13,6 +14,11 @@ const URL = '/api/purchases/supplies'
export function SuppliesPage() {
const navigate = useNavigate()
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 (
<ListPageShell
@ -49,7 +55,7 @@ export function SuppliesPage() {
{ header: 'Поставщик', sortKey: 'supplier', cell: (r) => r.supplierName },
{ header: 'Склад', width: '180px', sortKey: 'store', cell: (r) => r.storeName },
{ 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="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения."
/>

View file

@ -321,7 +321,8 @@ export function SupplyEditPage() {
value={l.unitPrice}
onChange={(n) => updateLine(i, { unitPrice: n ?? 0 })}
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 className="py-2 px-3 text-right font-mono font-semibold">
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}