Compare commits

..

No commits in common. "bed30f68bd0c1e9c033a172e0d608c2538c73e24" and "3ed6fe25be3d4a604071b7e0f2c02bb55c82c1be" have entirely different histories.

20 changed files with 57 additions and 4011 deletions

View file

@ -33,12 +33,14 @@ public async Task<ActionResult<PagedResult<CurrencyDto>>> List([FromQuery] Paged
("name", true) => q.OrderByDescending(c => c.Name), ("name", true) => q.OrderByDescending(c => c.Name),
("symbol", false) => q.OrderBy(c => c.Symbol), ("symbol", false) => q.OrderBy(c => c.Symbol),
("symbol", true) => q.OrderByDescending(c => c.Symbol), ("symbol", true) => q.OrderByDescending(c => c.Symbol),
("isActive", false) => q.OrderBy(c => c.IsActive).ThenBy(c => c.Code),
("isActive", true) => q.OrderByDescending(c => c.IsActive).ThenBy(c => c.Code),
("code", true) => q.OrderByDescending(c => c.Code), ("code", true) => q.OrderByDescending(c => c.Code),
_ => q.OrderBy(c => c.Code), _ => q.OrderBy(c => c.Code),
}; };
var items = await q var items = await q
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(c => new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol)) .Select(c => new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol, c.MinorUnit, c.IsActive))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<CurrencyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<CurrencyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -47,7 +49,7 @@ public async Task<ActionResult<PagedResult<CurrencyDto>>> List([FromQuery] Paged
public async Task<ActionResult<CurrencyDto>> Get(Guid id, CancellationToken ct) public async Task<ActionResult<CurrencyDto>> Get(Guid id, CancellationToken ct)
{ {
var c = await _db.Currencies.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); var c = await _db.Currencies.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return c is null ? NotFound() : new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol); return c is null ? NotFound() : new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol, c.MinorUnit, c.IsActive);
} }
[HttpPost, Authorize(Roles = "SuperAdmin")] [HttpPost, Authorize(Roles = "SuperAdmin")]
@ -58,11 +60,13 @@ public async Task<ActionResult<CurrencyDto>> Create([FromBody] CurrencyInput inp
Code = input.Code.Trim().ToUpper(), Code = input.Code.Trim().ToUpper(),
Name = input.Name, Name = input.Name,
Symbol = input.Symbol, Symbol = input.Symbol,
MinorUnit = input.MinorUnit,
IsActive = input.IsActive,
}; };
_db.Currencies.Add(e); _db.Currencies.Add(e);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id }, return CreatedAtAction(nameof(Get), new { id = e.Id },
new CurrencyDto(e.Id, e.Code, e.Name, e.Symbol)); new CurrencyDto(e.Id, e.Code, e.Name, e.Symbol, e.MinorUnit, e.IsActive));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin")] [HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin")]
@ -73,6 +77,8 @@ public async Task<IActionResult> Update(Guid id, [FromBody] CurrencyInput input,
e.Code = input.Code.Trim().ToUpper(); e.Code = input.Code.Trim().ToUpper();
e.Name = input.Name; e.Name = input.Name;
e.Symbol = input.Symbol; e.Symbol = input.Symbol;
e.MinorUnit = input.MinorUnit;
e.IsActive = input.IsActive;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
} }

View file

@ -23,23 +23,6 @@ public ProductsController(AppDbContext db, ITenantContext tenant)
_tenant = tenant; _tenant = tenant;
} }
// Следующий числовой артикул для организации. Находит max(Article::int)
// среди артикулов, которые полностью состоят из цифр, и прибавляет 1.
// Если числовых артикулов нет — возвращает "1".
private async Task<string> GenerateNextArticleAsync(CancellationToken ct)
{
var articles = await _db.Products
.Where(p => p.Article != null && p.Article != "")
.Select(p => p.Article!)
.ToListAsync(ct);
var next = 1;
foreach (var a in articles)
{
if (int.TryParse(a, out var n) && n >= next) next = n + 1;
}
return next.ToString();
}
// Дефолт Vat для нового товара — из страны организации (Country.VatRate). // Дефолт Vat для нового товара — из страны организации (Country.VatRate).
private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct) private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
{ {
@ -109,10 +92,6 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
("group", true) => q.OrderByDescending(p => p.ProductGroup != null ? p.ProductGroup.Name : null).ThenBy(p => p.Name), ("group", true) => q.OrderByDescending(p => p.ProductGroup != null ? p.ProductGroup.Name : null).ThenBy(p => p.Name),
("unit", false) => q.OrderBy(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name), ("unit", false) => q.OrderBy(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name),
("unit", true) => q.OrderByDescending(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name), ("unit", true) => q.OrderByDescending(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name),
("packaging", false) => q.OrderBy(p => p.Packaging).ThenBy(p => p.Name),
("packaging", true) => q.OrderByDescending(p => p.Packaging).ThenBy(p => p.Name),
("purchasePrice", false) => q.OrderBy(p => p.PurchasePrice).ThenBy(p => p.Name),
("purchasePrice", true) => q.OrderByDescending(p => p.PurchasePrice).ThenBy(p => p.Name),
("vat", false) => q.OrderBy(p => p.Vat).ThenBy(p => p.Name), ("vat", false) => q.OrderBy(p => p.Vat).ThenBy(p => p.Name),
("vat", true) => q.OrderByDescending(p => p.Vat).ThenBy(p => p.Name), ("vat", true) => q.OrderByDescending(p => p.Vat).ThenBy(p => p.Name),
("isActive", false) => q.OrderBy(p => p.IsActive).ThenBy(p => p.Name), ("isActive", false) => q.OrderBy(p => p.IsActive).ThenBy(p => p.Name),
@ -141,8 +120,6 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
Apply(e, input); Apply(e, input);
// Если 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);
// Авто-артикул: если пользователь не указал — генерируем числовой.
if (string.IsNullOrWhiteSpace(e.Article)) e.Article = await GenerateNextArticleAsync(ct);
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 });
@ -150,14 +127,7 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId }); e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId });
_db.Products.Add(e); _db.Products.Add(e);
try await _db.SaveChangesAsync(ct);
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_products_OrganizationId_Article") == true)
{
return BadRequest(new { error = $"Артикул «{e.Article}» уже занят в этой организации." });
}
var dto = await GetInternalAsync(e.Id, ct); var dto = await GetInternalAsync(e.Id, ct);
return CreatedAtAction(nameof(Get), new { id = e.Id }, dto); return CreatedAtAction(nameof(Get), new { id = e.Id }, dto);
} }
@ -186,14 +156,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
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 = pr.Amount, CurrencyId = pr.CurrencyId });
try await _db.SaveChangesAsync(ct);
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_products_OrganizationId_Article") == true)
{
return BadRequest(new { error = $"Артикул «{e.Article}» уже занят в этой организации." });
}
return NoContent(); return NoContent();
} }

View file

@ -32,8 +32,7 @@ public record OrgSettingsDto(
decimal VatRate, decimal VatRate,
bool ShowVatEnabledOnProduct, bool ShowVatEnabledOnProduct,
bool ShowServiceOnProduct, bool ShowServiceOnProduct,
bool ShowMarkedOnProduct, bool ShowMarkedOnProduct);
bool ShowMinMaxStock);
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId). // DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
public record OrgSettingsInput( public record OrgSettingsInput(
@ -42,8 +41,7 @@ public record OrgSettingsInput(
bool MultiCurrencyEnabled, bool MultiCurrencyEnabled,
bool ShowVatEnabledOnProduct, bool ShowVatEnabledOnProduct,
bool ShowServiceOnProduct, bool ShowServiceOnProduct,
bool ShowMarkedOnProduct, bool ShowMarkedOnProduct);
bool ShowMinMaxStock);
[HttpGet("settings")] [HttpGet("settings")]
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct) public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
@ -77,7 +75,6 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
o.ShowVatEnabledOnProduct = input.ShowVatEnabledOnProduct; o.ShowVatEnabledOnProduct = input.ShowVatEnabledOnProduct;
o.ShowServiceOnProduct = input.ShowServiceOnProduct; o.ShowServiceOnProduct = input.ShowServiceOnProduct;
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct; o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
o.ShowMinMaxStock = input.ShowMinMaxStock;
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);
@ -103,6 +100,5 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
vat, vat,
o.ShowVatEnabledOnProduct, o.ShowVatEnabledOnProduct,
o.ShowServiceOnProduct, o.ShowServiceOnProduct,
o.ShowMarkedOnProduct, o.ShowMarkedOnProduct);
o.ShowMinMaxStock);
} }

View file

@ -8,7 +8,7 @@ public record CountryDto(
decimal VatRate); decimal VatRate);
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol); public record CurrencyDto(Guid Id, string Code, string Name, string Symbol, int MinorUnit, bool IsActive);
public record UnitOfMeasureDto( public record UnitOfMeasureDto(
Guid Id, string Code, string Name, string? Description, bool IsActive); Guid Id, string Code, string Name, string? Description, bool IsActive);
@ -55,7 +55,7 @@ public record ProductDto(
public record CountryInput( public record CountryInput(
string Code, string Name, string Code, string Name,
Guid? DefaultCurrencyId = null, decimal VatRate = 0m); Guid? DefaultCurrencyId = null, decimal VatRate = 0m);
public record CurrencyInput(string Code, string Name, string Symbol); public record CurrencyInput(string Code, string Name, string Symbol, int MinorUnit = 2, bool IsActive = true);
public record UnitOfMeasureInput(string Code, string Name, string? Description = null, bool IsActive = true); public record UnitOfMeasureInput(string Code, string Name, string? Description = null, bool IsActive = true);
public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true); public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true);
public record StoreInput( public record StoreInput(

View file

@ -8,7 +8,6 @@ public class Currency : Entity
public string Code { get; set; } = null!; // ISO 4217, e.g. "KZT" public string Code { get; set; } = null!; // ISO 4217, e.g. "KZT"
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
public string Symbol { get; set; } = null!; // "₸" public string Symbol { get; set; } = null!; // "₸"
// Количество знаков после запятой для форматирования цен. Не редактируется public int MinorUnit { get; set; } = 2; // 2 = two decimal places
// в UI — задаётся сидером/миграцией по ISO 4217. public bool IsActive { get; set; } = true;
public int MinorUnit { get; set; } = 2;
} }

View file

@ -41,9 +41,4 @@ public class Organization : Entity
/// Маркировка требуется только в нишевых категориях (алкоголь, лекарства, /// Маркировка требуется только в нишевых категориях (алкоголь, лекарства,
/// табак) — по умолчанию выключено.</summary> /// табак) — по умолчанию выключено.</summary>
public bool ShowMarkedOnProduct { get; set; } public bool ShowMarkedOnProduct { get; set; }
/// <summary>Показывать ли поля «Минимальный остаток» / «Максимальный остаток»
/// на карточке товара и одноимённую колонку в списке. Нужно в основном
/// торговым сетям со свободным местом на полке — по умолчанию выключено.</summary>
public bool ShowMinMaxStock { get; set; }
} }

View file

@ -1,24 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Удаляет currencies.IsActive. Валюты — глобальный справочник,
/// «архивировать» USD не имеет смысла. Если какая-то валюта не нужна
/// конкретному магазину — пользователь её просто не выбирает.</summary>
public partial class Phase5e_DropCurrencyIsActive : Migration
{
protected override void Up(MigrationBuilder b)
{
b.DropColumn(name: "IsActive", schema: "public", table: "currencies");
}
protected override void Down(MigrationBuilder b)
{
b.AddColumn<bool>(
name: "IsActive", schema: "public", table: "currencies",
type: "boolean", nullable: false, defaultValue: true);
}
}
}

View file

@ -1,24 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Добавляет organizations.ShowMinMaxStock — флаг видимости
/// полей мин/макс остатка на карточке товара и одноимённой колонки
/// в списке. По умолчанию false.</summary>
public partial class Phase5f_ShowMinMaxStock : Migration
{
protected override void Up(MigrationBuilder b)
{
b.AddColumn<bool>(
name: "ShowMinMaxStock", schema: "public", table: "organizations",
type: "boolean", nullable: false, defaultValue: false);
}
protected override void Down(MigrationBuilder b)
{
b.DropColumn(name: "ShowMinMaxStock", schema: "public", table: "organizations");
}
}
}

View file

@ -473,6 +473,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<int>("MinorUnit") b.Property<int>("MinorUnit")
.HasColumnType("integer"); .HasColumnType("integer");
@ -1093,9 +1096,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("ShowMarkedOnProduct") b.Property<bool>("ShowMarkedOnProduct")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("ShowMinMaxStock")
.HasColumnType("boolean");
b.Property<bool>("ShowServiceOnProduct") b.Property<bool>("ShowServiceOnProduct")
.HasColumnType("boolean"); .HasColumnType("boolean");

View file

@ -18,15 +18,14 @@ export function Field({ label, error, children, className }: FieldProps) {
) )
} }
const inputClass = 'w-full h-10 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 text-sm leading-none focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] disabled:opacity-60 disabled:bg-slate-50 dark:disabled:bg-slate-800/60' const inputClass = 'w-full rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] disabled:opacity-60'
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) { export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
return <input {...props} className={cn(inputClass, props.className)} /> return <input {...props} className={cn(inputClass, props.className)} />
} }
export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) { export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
// TextArea — multi-line, высоту не фиксируем. return <textarea {...props} className={cn(inputClass, 'font-[inherit]', props.className)} />
return <textarea {...props} className={cn(inputClass, 'h-auto py-2 font-[inherit] leading-normal', props.className)} />
} }
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) { export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {

View file

@ -1,5 +1,3 @@
import { useEffect, useState } from 'react'
interface PaginationProps { interface PaginationProps {
page: number page: number
pageSize: number pageSize: number
@ -11,20 +9,8 @@ export function Pagination({ page, pageSize, total, onPageChange }: PaginationPr
const totalPages = Math.max(1, Math.ceil(total / pageSize)) const totalPages = Math.max(1, Math.ceil(total / pageSize))
const from = (page - 1) * pageSize + 1 const from = (page - 1) * pageSize + 1
const to = Math.min(page * pageSize, total) const to = Math.min(page * pageSize, total)
const [jumpValue, setJumpValue] = useState<string>(String(page))
useEffect(() => { setJumpValue(String(page)) }, [page])
if (total === 0) return null if (total === 0) return null
const commitJump = () => {
const n = parseInt(jumpValue, 10)
if (!Number.isFinite(n)) { setJumpValue(String(page)); return }
const clamped = Math.min(Math.max(1, n), totalPages)
if (clamped !== page) onPageChange(clamped)
setJumpValue(String(clamped))
}
return ( return (
<div className="flex items-center justify-between mt-3 text-sm text-slate-500"> <div className="flex items-center justify-between mt-3 text-sm text-slate-500">
<span>{from}{to} из {total}</span> <span>{from}{to} из {total}</span>
@ -36,20 +22,7 @@ export function Pagination({ page, pageSize, total, onPageChange }: PaginationPr
> >
</button> </button>
<span className="flex items-center gap-1"> <span>{page} / {totalPages}</span>
<span>Страница</span>
<input
type="number"
min={1}
max={totalPages}
value={jumpValue}
onChange={(e) => setJumpValue(e.target.value)}
onBlur={commitJump}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitJump() } }}
className="w-14 px-1.5 py-0.5 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-center tabular-nums"
/>
<span>из {totalPages}</span>
</span>
<button <button
disabled={page >= totalPages} disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)} onClick={() => onPageChange(page + 1)}

View file

@ -1,73 +1,21 @@
// Штрихкод-утилиты: генерация валидных кодов под разные форматы. // EAN-13 утилиты.
// //
// Внутренние EAN-13 магазина начинаются с "2" — зарезервированный префикс // Внутренние штрихкоды магазина начинаются с "2" — это зарезервированный
// для in-store use, не пересекается с GTIN реальных товаров. // префикс для in-store use, он не пересекается с GTIN реальных товаров
// от производителей.
import { BarcodeType } from '@/lib/types' function ean13Checksum(first12: string): number {
function digitsChecksum(first: string, weightAtOdd: number): number {
// Общая EAN/UPC-подобная формула: сумма с чередующимися весами, остаток от 10.
// Нечётные позиции (с индекса 0) — weightAtOdd, чётные — 1.
let sum = 0 let sum = 0
for (let i = 0; i < first.length; i++) { for (let i = 0; i < 12; i++) {
const d = first.charCodeAt(i) - 48 const d = first12.charCodeAt(i) - 48
sum += i % 2 === 0 ? d * weightAtOdd : d sum += i % 2 === 0 ? d : d * 3
} }
return (10 - (sum % 10)) % 10 return (10 - (sum % 10)) % 10
} }
function randomDigits(n: number): string {
let s = ''
for (let i = 0; i < n; i++) s += Math.floor(Math.random() * 10).toString()
return s
}
function randomAlnum(n: number): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
let s = ''
for (let i = 0; i < n; i++) s += alphabet[Math.floor(Math.random() * alphabet.length)]
return s
}
function ean13(): string {
const body = '2' + randomDigits(11)
return body + digitsChecksum(body, 3).toString()
}
function ean8(): string {
// EAN-8: 7 цифр + checksum. Веса: нечётные×3, чётные×1 (с индекса 0).
const body = randomDigits(7)
return body + digitsChecksum(body, 3).toString()
}
function upca(): string {
// UPC-A: 11 цифр + checksum. Та же формула что у EAN-13.
const body = randomDigits(11)
return body + digitsChecksum(body, 3).toString()
}
function upce(): string {
// Упрощённая генерация: 8 случайных цифр (реальный UPC-E строится через
// сжатие UPC-A по спецправилам; для внутренних нужд достаточно числовой
// последовательности нужной длины).
return randomDigits(8)
}
/** Сгенерировать внутренний EAN-13 с префиксом "2" и случайной серединой. */ /** Сгенерировать внутренний EAN-13 с префиксом "2" и случайной серединой. */
export function generateEan13InternalPrefix2(): string { export function generateEan13InternalPrefix2(): string {
return ean13() let body = '2'
} for (let i = 0; i < 11; i++) body += Math.floor(Math.random() * 10).toString()
return body + ean13Checksum(body).toString()
/** Сгенерировать штрихкод под указанный формат. */
export function generateBarcode(type: BarcodeType): string {
switch (type) {
case BarcodeType.Ean13: return ean13()
case BarcodeType.Ean8: return ean8()
case BarcodeType.Upca: return upca()
case BarcodeType.Upce: return upce()
case BarcodeType.Code128:
case BarcodeType.Code39: return randomAlnum(12)
case BarcodeType.Other:
default: return ean13()
}
} }

View file

@ -27,7 +27,7 @@ export interface Country {
defaultCurrencySymbol: string | null defaultCurrencySymbol: string | null
vatRate: number vatRate: number
} }
export interface Currency { id: string; code: string; name: string; symbol: string } export interface Currency { id: string; code: string; name: string; symbol: string; minorUnit: number; isActive: boolean }
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; isActive: boolean } export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; isActive: boolean }
export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean } export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }
export interface Store { export interface Store {

View file

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

View file

@ -28,6 +28,8 @@ export function CurrenciesPage() {
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> }, { header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> },
{ header: 'Название', sortKey: 'name', cell: (r) => r.name }, { header: 'Название', sortKey: 'name', cell: (r) => r.name },
{ header: 'Символ', width: '100px', sortKey: 'symbol', cell: (r) => <span className="text-lg">{r.symbol}</span> }, { header: 'Символ', width: '100px', sortKey: 'symbol', cell: (r) => <span className="text-lg">{r.symbol}</span> },
{ header: 'Знаки', width: '100px', className: 'text-right', cell: (r) => r.minorUnit },
{ header: 'Активна', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' },
]} ]}
/> />
</ListPageShell> </ListPageShell>

View file

@ -40,7 +40,6 @@ export function OrganizationSettingsPage() {
showVatEnabledOnProduct: form.showVatEnabledOnProduct, showVatEnabledOnProduct: form.showVatEnabledOnProduct,
showServiceOnProduct: form.showServiceOnProduct, showServiceOnProduct: form.showServiceOnProduct,
showMarkedOnProduct: form.showMarkedOnProduct, showMarkedOnProduct: form.showMarkedOnProduct,
showMinMaxStock: form.showMinMaxStock,
} }
return (await api.put<OrgSettings>('/api/organization/settings', payload)).data return (await api.put<OrgSettings>('/api/organization/settings', payload)).data
}, },
@ -126,16 +125,6 @@ export function OrganizationSettingsPage() {
Включать, только если продаётся маркируемая категория (алкоголь, табак, лекарства). Включать, только если продаётся маркируемая категория (алкоголь, табак, лекарства).
По умолчанию галка скрыта. По умолчанию галка скрыта.
</p> </p>
<Checkbox
label='Показывать мин/макс остатки на товаре'
checked={form.showMinMaxStock}
onChange={(v) => setForm({ ...form, showMinMaxStock: v })}
/>
<p className="text-xs text-slate-500 -mt-2">
Если включено на карточке товара есть поля «Минимальный / Максимальный остаток»
для автозаказа. По умолчанию скрыто.
</p>
</section> </section>
<div className="mt-4 flex gap-3 items-center"> <div className="mt-4 flex gap-3 items-center">

View file

@ -11,7 +11,7 @@ import {
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
import { BarcodeType, Packaging, type Product } from '@/lib/types' import { BarcodeType, Packaging, type Product } from '@/lib/types'
import { ProductImageGallery } from '@/components/ProductImageGallery' import { ProductImageGallery } from '@/components/ProductImageGallery'
import { generateEan13InternalPrefix2, generateBarcode } from '@/lib/barcode' import { generateEan13InternalPrefix2 } from '@/lib/barcode'
interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string } interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string }
interface BarcodeRow { id?: string; code: string; type: BarcodeType; isPrimary: boolean } interface BarcodeRow { id?: string; code: string; type: BarcodeType; isPrimary: boolean }
@ -316,18 +316,16 @@ export function ProductEditPage() {
</Grid> </Grid>
</Section> </Section>
{org.data?.showMinMaxStock && ( <AdvancedSection>
<AdvancedSection> <Grid cols={4}>
<Grid cols={4}> <Field label="Минимальный остаток (для уведомления)">
<Field label="Минимальный остаток (для уведомления)"> <TextInput type="number" step="0.001" value={form.minStock} onChange={(e) => setForm({ ...form, minStock: e.target.value })} placeholder="—" />
<TextInput type="number" step="0.001" value={form.minStock} onChange={(e) => setForm({ ...form, minStock: e.target.value })} placeholder="—" /> </Field>
</Field> <Field label="Максимальный остаток (для автозаказа)">
<Field label="Максимальный остаток (для автозаказа)"> <TextInput type="number" step="0.001" value={form.maxStock} onChange={(e) => setForm({ ...form, maxStock: e.target.value })} placeholder="—" />
<TextInput type="number" step="0.001" value={form.maxStock} onChange={(e) => setForm({ ...form, maxStock: e.target.value })} placeholder="—" /> </Field>
</Field> </Grid>
</Grid> </AdvancedSection>
</AdvancedSection>
)}
<Section <Section
title="Цены продажи" title="Цены продажи"
@ -391,10 +389,7 @@ export function ProductEditPage() {
<TextInput placeholder="Код" value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} /> <TextInput placeholder="Код" value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} />
</div> </div>
<div className="col-span-3"> <div className="col-span-3">
<Select value={b.type} onChange={(e) => { <Select value={b.type} onChange={(e) => updateBarcode(i, { type: Number(e.target.value) as BarcodeType })}>
const newType = Number(e.target.value) as BarcodeType
updateBarcode(i, { type: newType, code: generateBarcode(newType) })
}}>
<option value={BarcodeType.Ean13}>EAN-13</option> <option value={BarcodeType.Ean13}>EAN-13</option>
<option value={BarcodeType.Ean8}>EAN-8</option> <option value={BarcodeType.Ean8}>EAN-8</option>
<option value={BarcodeType.Code128}>CODE 128</option> <option value={BarcodeType.Code128}>CODE 128</option>

View file

@ -8,7 +8,7 @@ import { Plus, Filter, X } from 'lucide-react'
import { useCatalogList } from '@/lib/useCatalog' import { useCatalogList } from '@/lib/useCatalog'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
import { ProductGroupTree } from '@/components/ProductGroupTree' import { ProductGroupTree } from '@/components/ProductGroupTree'
import { packagingLabel, type Product } from '@/lib/types' import type { Product } from '@/lib/types'
const URL = '/api/catalog/products' const URL = '/api/catalog/products'
@ -117,19 +117,16 @@ export function ProductsPage() {
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>} {r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
</div> </div>
)}, )},
{ header: 'Фасовка', width: '110px', sortKey: 'packaging', cell: (r) => packagingLabel[r.packaging] ?? '—' }, { header: 'Группа', width: '200px', sortKey: 'group', cell: (r) => r.productGroupName ?? '—' },
{ header: 'Штрихкод', width: '160px', cell: (r) => ( { header: 'Ед.', width: '70px', sortKey: 'unit', cell: (r) => r.unitName },
<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()
: '—'
)},
] ]
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)}%` : '—' })
} }
baseColumns.push(
{ header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length },
{ header: 'Активен', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' },
)
return ( return (
<div className="flex h-full min-h-0"> <div className="flex h-full min-h-0">