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;
|
||||
}
|
||||
|
||||
// Округление цен под настройку 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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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++,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
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")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AllowFractionalPrices")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Bin")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface OrgSettings {
|
|||
showServiceOnProduct: boolean
|
||||
showMarkedOnProduct: boolean
|
||||
showMinMaxStock: boolean
|
||||
allowFractionalPrices: boolean
|
||||
}
|
||||
|
||||
export function useOrgSettings() {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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)}%` : '—' })
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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 здесь начнут падать продажи с касс. Сейчас можно создать вручную для теста."
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения."
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
Loading…
Reference in a new issue