fix(регистрация): 7 правок этапа «Регистрация» (прогон 12.10)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Public / Build + push Public (test + latest) (push) Waiting to run
Docker Public / Deploy :test → test.food-market.kz (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

1. Маска телефона: префикс «+7 », оператор не подставляется заранее.
   Нормализация снимает ведущую «8» и код страны «7» (на 11 цифрах),
   value хранится каноническим «+7»+national и восстанавливается снятием
   литерального «+7» (без 8/11-эвристик) — ввод больше не «скачет».
   Обе копии (public + admin). Проверено на всех вариантах + вставка.
2. Тариф и бесплатный период: Organization.Plan/FreeTrialEndsAt +
   конфигурация «Бизнес» (Phase50, существующие → «start», дата NULL).
   Signup сохраняет план и 90 дней; показ в кабинете владельца и в
   карточке/списке суперадмина. Биллинга/блокировки нет.
3. Ошибки формы: переход к первому полю с ошибкой + фокус; единые
   сообщения «Укажите email/телефон/название магазина», «Придумайте пароль».
4. «Первые шаги»: кнопка «Скрыть» (возврат из настроек организации);
   шаг каталога показывает статус копии реестра («загружается…» /
   «Каталог готов: N товаров — проверьте продажные цены»); ссылка на
   Forgejo заменена на «Базу знаний».
5. «Новые функции»: не считаем изменения до даты регистрации орг
   (новой орг баннер скрыт) + русская плюрализация улучшений/исправлений.
6. Карточка товара: «Закупочная цена» видна и в простом режиме (настройка
   ShowReferencePriceOnProduct), подпись настройки приведена к новой схеме.
7. Копия реестра: первый штрихкод помечается «Основным»; бэкфилл уже
   скопированных товаров без основного (Phase51).

Тесты: PlanSignupTests (3), RegistryCopyPrimaryBarcodeTests (1),
MarkupToggleTests (3, регрессия) — зелёные. Phone-нормализация покрыта
симуляцией ввода/вставки.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-12 21:46:15 +05:00
parent 005e5457af
commit 0ce0e0df77
22 changed files with 690 additions and 127 deletions

View file

@ -31,7 +31,10 @@ public AuthSignupController(AppDbContext db, UserManager<User> userMgr, IBackgro
_db = db; _userMgr = userMgr; _jobs = jobs; _db = db; _userMgr = userMgr; _jobs = jobs;
} }
public record SignupInput(string Email, string Password, string OrganizationName, string Phone, string? Plan); public record SignupInput(string Email, string Password, string OrganizationName, string Phone, string? Plan,
int? Shops = null, int? Cashes = null, int? Warehouses = null, int? Employees = null);
private static readonly string[] ValidPlans = { "start", "business", "network" };
public record SignupResult(Guid OrganizationId, string Email); public record SignupResult(Guid OrganizationId, string Email);
[HttpPost("signup")] [HttpPost("signup")]
@ -64,6 +67,9 @@ public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput inpu
// 1. Organization + полный bootstrap tenant-сущностей. // 1. Organization + полный bootstrap tenant-сущностей.
var kzt = await _db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct); var kzt = await _db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct);
// Тариф: нормализуем к одному из трёх; неизвестный/пустой → «start».
var plan = (input.Plan ?? "").Trim().ToLowerInvariant();
if (!ValidPlans.Contains(plan)) plan = "start";
var org = new Organization var org = new Organization
{ {
Name = input.OrganizationName.Trim(), Name = input.OrganizationName.Trim(),
@ -71,6 +77,15 @@ public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput inpu
DefaultCurrencyId = kzt?.Id, DefaultCurrencyId = kzt?.Id,
Phone = normalizedPhone, Phone = normalizedPhone,
Email = input.Email.Trim(), Email = input.Email.Trim(),
Plan = plan,
// Бесплатный период — 90 дней с момента регистрации. Только хранение
// и показ; блокировки по истечении нет.
FreeTrialEndsAt = DateTime.UtcNow.AddDays(90),
// Конфигурацию конструктора сохраняем только для «business».
PlanShops = plan == "business" ? input.Shops : null,
PlanCashes = plan == "business" ? input.Cashes : null,
PlanWarehouses = plan == "business" ? input.Warehouses : null,
PlanEmployees = plan == "business" ? input.Employees : null,
}; };
_db.Organizations.Add(org); _db.Organizations.Add(org);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);

View file

@ -54,7 +54,14 @@ public record OrgSettingsDto(
int NegStockNotifyIntervalMinutes, int NegStockNotifyIntervalMinutes,
int? NegStockQuietFromHour, int? NegStockQuietFromHour,
int? NegStockQuietToHour, int? NegStockQuietToHour,
int NegStockEscalateHours); int NegStockEscalateHours,
// Тариф и бесплатный период — read-only (выбор был при регистрации).
string Plan,
DateTime? FreeTrialEndsAt,
int? PlanShops,
int? PlanCashes,
int? PlanWarehouses,
int? PlanEmployees);
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId). // DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
public record OrgSettingsInput( public record OrgSettingsInput(
@ -199,5 +206,11 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
o.NegStockNotifyIntervalMinutes, o.NegStockNotifyIntervalMinutes,
o.NegStockQuietFromHour, o.NegStockQuietFromHour,
o.NegStockQuietToHour, o.NegStockQuietToHour,
o.NegStockEscalateHours); o.NegStockEscalateHours,
o.Plan,
o.FreeTrialEndsAt,
o.PlanShops,
o.PlanCashes,
o.PlanWarehouses,
o.PlanEmployees);
} }

View file

@ -33,7 +33,8 @@ public record OrgRow(
bool IsActive, bool IsArchived, DateTime? ArchivedAt, bool IsActive, bool IsArchived, DateTime? ArchivedAt,
DateTime CreatedAt, int EmployeeCount, int ProductCount, DateTime CreatedAt, int EmployeeCount, int ProductCount,
DateTime? LastLoginAt, DateTime? LastLoginAt,
bool IsAdvancedMode); bool IsAdvancedMode,
string Plan, DateTime? FreeTrialEndsAt);
public record OrgDetail( public record OrgDetail(
Guid Id, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email, Guid Id, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
@ -42,7 +43,10 @@ public record OrgDetail(
Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail, Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail,
DateTime CreatedAt, DateTime? UpdatedAt, DateTime CreatedAt, DateTime? UpdatedAt,
int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth, int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth,
bool IsAdvancedMode); bool IsAdvancedMode,
// Тариф и бесплатный период (выбор при регистрации).
string Plan, DateTime? FreeTrialEndsAt,
int? PlanShops, int? PlanCashes, int? PlanWarehouses, int? PlanEmployees);
public record OrgInput( public record OrgInput(
string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
@ -82,7 +86,8 @@ public record AdvancedModeRequest(bool Enabled);
_db.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id), _db.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id),
_db.Users.Where(u => u.OrganizationId == o.Id && u.LastLoginAt != null) _db.Users.Where(u => u.OrganizationId == o.Id && u.LastLoginAt != null)
.Max(u => (DateTime?)u.LastLoginAt), .Max(u => (DateTime?)u.LastLoginAt),
o.IsAdvancedMode)) o.IsAdvancedMode,
o.Plan, o.FreeTrialEndsAt))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<OrgRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<OrgRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -306,7 +311,9 @@ public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequ
o.IsActive, o.IsArchived, o.ArchivedAt, o.IsActive, o.IsArchived, o.ArchivedAt,
o.AccountOwnerUserId, owner?.FullName, owner?.Email, o.AccountOwnerUserId, owner?.FullName, owner?.Email,
o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies, o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies,
o.IsAdvancedMode); o.IsAdvancedMode,
o.Plan, o.FreeTrialEndsAt,
o.PlanShops, o.PlanCashes, o.PlanWarehouses, o.PlanEmployees);
} }
private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct) private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct)

View file

@ -1,5 +1,8 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers; namespace foodmarket.Api.Controllers;
@ -21,17 +24,19 @@ public class WhatsNewController : ControllerBase
{ {
private readonly IWebHostEnvironment _env; private readonly IWebHostEnvironment _env;
private readonly ILogger<WhatsNewController> _log; private readonly ILogger<WhatsNewController> _log;
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public WhatsNewController(IWebHostEnvironment env, ILogger<WhatsNewController> log) public WhatsNewController(IWebHostEnvironment env, ILogger<WhatsNewController> log, AppDbContext db, ITenantContext tenant)
{ {
_env = env; _log = log; _env = env; _log = log; _db = db; _tenant = tenant;
} }
public record WhatsNewItem(string Date, string Title, string Body, string Type); public record WhatsNewItem(string Date, string Title, string Body, string Type);
public record WhatsNewResponse(string BuildVersion, IReadOnlyList<WhatsNewItem> Items); public record WhatsNewResponse(string BuildVersion, IReadOnlyList<WhatsNewItem> Items);
[HttpGet] [HttpGet]
public ActionResult<WhatsNewResponse> Get() public async Task<ActionResult<WhatsNewResponse>> Get(CancellationToken ct)
{ {
// BuildVersion — short SHA / тэг билд-времени. Берём из // BuildVersion — short SHA / тэг билд-времени. Берём из
// VERSION файла (создаётся Docker-build'ом) или ассембли. // VERSION файла (создаётся Docker-build'ом) или ассембли.
@ -54,6 +59,24 @@ public ActionResult<WhatsNewResponse> Get()
var content = System.IO.File.ReadAllText(changelogPath); var content = System.IO.File.ReadAllText(changelogPath);
var items = ParseChangelog(content).ToList(); var items = ParseChangelog(content).ToList();
// Не показываем новой организации улучшения/исправления, которые были
// ДО её регистрации — для неё они не «новые». Фильтруем по дате создания
// организации (решение владельца 12.10). SuperAdmin (без org) видит всё.
var orgId = _tenant.OrganizationId;
if (orgId is not null)
{
var createdAt = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == orgId)
.Select(o => (DateTime?)o.CreatedAt)
.FirstOrDefaultAsync(ct);
if (createdAt is DateTime reg)
{
var regDate = reg.Date;
items = items.Where(i => DateTime.TryParse(i.Date, out var d) && d.Date >= regDate).ToList();
}
}
return new WhatsNewResponse(buildVersion, items); return new WhatsNewResponse(buildVersion, items);
} }

View file

@ -205,4 +205,24 @@ public class Organization : Entity
/// показывается «Каталог из реестра загружается…». Повторно не копируется.</summary> /// показывается «Каталог из реестра загружается…». Повторно не копируется.</summary>
public int RegistryCopyState { get; set; } public int RegistryCopyState { get; set; }
public DateTime? RegistryCopiedAt { get; set; } public DateTime? RegistryCopiedAt { get; set; }
// ─ Тариф и бесплатный период (выбор при регистрации) ───────────────────────
/// <summary>Выбранный при регистрации тариф: «start», «business», «network».
/// Биллинга пока нет — значение только хранится и показывается владельцу и
/// SuperAdmin. Существующие организации (миграция) → «start».</summary>
public string Plan { get; set; } = "start";
/// <summary>До какой даты действует бесплатный период (регистрация + 90 дней).
/// null — у организаций, созданных до появления тарифов (миграция даты не
/// проставляет). Блокировки по истечении нет — только отображение.</summary>
public DateTime? FreeTrialEndsAt { get; set; }
/// <summary>Конфигурация тарифа «Бизнес», выбранная в конструкторе при
/// регистрации (магазины/кассы/склады/сотрудники). null для «start»/«network»
/// и для организаций без выбранной конфигурации. Сейчас не ограничивает —
/// хранится и показывается.</summary>
public int? PlanShops { get; set; }
public int? PlanCashes { get; set; }
public int? PlanWarehouses { get; set; }
public int? PlanEmployees { get; set; }
} }

View file

@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Тариф, выбранный при регистрации («start»/«business»/«network»),
/// бесплатный период (регистрация + 90 дней) и конфигурация «Бизнес».
/// Существующие организации → тариф «start», дата бесплатного периода не
/// проставляется (NULL). Биллинга/блокировки нет — только хранение и показ.
/// Решение владельца 12.10.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20261012060000_Phase50_PlanAndTrial")]
public partial class Phase50_PlanAndTrial : Migration
{
protected override void Up(MigrationBuilder b) => b.Sql(@"
ALTER TABLE public.organizations
ADD COLUMN IF NOT EXISTS ""Plan"" text NOT NULL DEFAULT 'start',
ADD COLUMN IF NOT EXISTS ""FreeTrialEndsAt"" timestamp with time zone NULL,
ADD COLUMN IF NOT EXISTS ""PlanShops"" integer NULL,
ADD COLUMN IF NOT EXISTS ""PlanCashes"" integer NULL,
ADD COLUMN IF NOT EXISTS ""PlanWarehouses"" integer NULL,
ADD COLUMN IF NOT EXISTS ""PlanEmployees"" integer NULL;");
protected override void Down(MigrationBuilder b) => b.Sql(@"
ALTER TABLE public.organizations
DROP COLUMN IF EXISTS ""Plan"",
DROP COLUMN IF EXISTS ""FreeTrialEndsAt"",
DROP COLUMN IF EXISTS ""PlanShops"",
DROP COLUMN IF EXISTS ""PlanCashes"",
DROP COLUMN IF EXISTS ""PlanWarehouses"",
DROP COLUMN IF EXISTS ""PlanEmployees"";");
}
}

View file

@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Бэкфилл «Основного» штрихкода у товаров, у которых штрихкоды есть,
/// но ни один не помечен IsPrimary — следствие бага копии «Реестра товаров»
/// (товар с единственным штрихкодом оставался без «Основного», fix #7).
/// Выбираем по одному штрихкоду на товар — лексикографически первый по Code
/// (так же, как теперь делает копия). Идемпотентно: товары, у которых основной
/// уже есть, не трогаются.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20261012070000_Phase51_BackfillPrimaryBarcode")]
public partial class Phase51_BackfillPrimaryBarcode : Migration
{
protected override void Up(MigrationBuilder b) => b.Sql(@"
WITH to_fix AS (
SELECT DISTINCT b.""ProductId""
FROM public.product_barcodes b
WHERE NOT EXISTS (
SELECT 1 FROM public.product_barcodes p
WHERE p.""ProductId"" = b.""ProductId"" AND p.""IsPrimary"" = true
)
),
pick AS (
SELECT DISTINCT ON (b.""ProductId"") b.""Id""
FROM public.product_barcodes b
JOIN to_fix f ON f.""ProductId"" = b.""ProductId""
ORDER BY b.""ProductId"", b.""Code""
)
UPDATE public.product_barcodes
SET ""IsPrimary"" = true, ""UpdatedAt"" = now()
WHERE ""Id"" IN (SELECT ""Id"" FROM pick);");
// Откат бессмысленен и небезопасен (мы не знаем, какой штрихкод был
// «Основным» до бэкфилла) — ничего не делаем.
protected override void Down(MigrationBuilder b) { }
}
}

View file

@ -323,8 +323,21 @@ public async Task CopyRegistryToOrgAsync(Guid orgId, CancellationToken ct)
PurchasePrice = card.PurchasePrice, PurchasePrice = card.PurchasePrice,
PurchaseCurrencyId = kztId, PurchaseCurrencyId = kztId,
}; };
foreach (var b in card.Barcodes) // Первый штрихкод помечаем «Основным» (IsPrimary). Для товара
product.Barcodes.Add(new ProductBarcode { OrganizationId = orgId, Code = b.Code, Type = b.Type }); // с единственным штрихкодом это он и есть — иначе «Основной»
// оставался пустым (баг копии реестра, fix #7). Порядок
// детерминируем по Code, чтобы при повторной копии основной
// был тем же.
var primarySet = false;
foreach (var b in card.Barcodes.OrderBy(x => x.Code, StringComparer.Ordinal))
{
product.Barcodes.Add(new ProductBarcode
{
OrganizationId = orgId, Code = b.Code, Type = b.Type,
IsPrimary = !primarySet,
});
primarySet = true;
}
if (card.RetailPrice is { } rp && retailTypeId != Guid.Empty) if (card.RetailPrice is { } rp && retailTypeId != Guid.Empty)
product.Prices.Add(new ProductPrice { OrganizationId = orgId, PriceTypeId = retailTypeId, Amount = rp, CurrencyId = kztId }); product.Prices.Add(new ProductPrice { OrganizationId = orgId, PriceTypeId = retailTypeId, Amount = rp, CurrencyId = kztId });
_db.Products.Add(product); _db.Products.Add(product);

View file

@ -1,41 +1,50 @@
import { useMemo, useRef, useLayoutEffect, type InputHTMLAttributes, type FormEvent } from 'react' import { useMemo, useRef, useLayoutEffect, type InputHTMLAttributes, type FormEvent } from 'react'
const PREFIX = '+7 7' // Код страны (+7) живёт в префиксе; оператор НЕ подставляем заранее.
const PREFIX_LEN = 4 const PREFIX = '+7 '
const PREFIX_LEN = PREFIX.length // 3
function rawToUserDigits(raw: string): string { // Национальный номер KZ — 10 цифр, начинается с 7 (например 701 000 00 00).
// Из любой формы вытаскиваем до 10 цифр нац. номера: снимаем ведущую «8»
// (межгород) и, если получилось 11 цифр с ведущей «7» — это код страны, снимаем.
// Прим.: при посимвольном вводе в handleChange мы передаём сюда ТОЛЬКО часть
// после префикса, чтобы «7» из «+7 » не попала в номер.
function toNational(raw: string): string {
let d = raw.replace(/\D/g, '') let d = raw.replace(/\D/g, '')
if (d[0] === '8') d = d.slice(1) if (d[0] === '8') d = d.slice(1)
if (d[0] === '7') d = d.slice(1) if (d.length === 11 && d[0] === '7') d = d.slice(1)
if (d[0] === '7') d = d.slice(1) return d.slice(0, 10)
return d.slice(0, 9)
} }
function userDigitsToDisplay(d: string): string { // Восстановление нац. номера из каноничного value. Мы ВСЕГДА эмитим value как
// «+7»+national, поэтому снимаем ровно литеральный код страны «+7» и не
// применяем 8/11-эвристики toNational — иначе «7» кода страны при неполном
// номере сливается с номером и ввод «скачет» (+7 77, +7 777 0, …).
function valueToNational(value: string): string {
if (value.startsWith('+7')) return value.slice(2).replace(/\D/g, '').slice(0, 10)
return toNational(value)
}
function nationalToDisplay(d: string): string {
if (d.length === 0) return PREFIX if (d.length === 0) return PREFIX
if (d.length <= 2) return `${PREFIX}${d}` let s = PREFIX + d.slice(0, 3)
if (d.length <= 5) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2)}` if (d.length > 3) s += ' ' + d.slice(3, 6)
if (d.length <= 7) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5)}` if (d.length > 6) s += ' ' + d.slice(6, 8)
return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}` if (d.length > 8) s += ' ' + d.slice(8, 10)
return s
} }
function digitsBeforeCursor(value: string, cursor: number): number { // Курсор считаем в цифрах нац. номера (цифры ПОСЛЕ префикса), чтобы «7» из
let count = 0 // «+7 » не сбивала позицию.
for (let i = 0; i < cursor && i < value.length; i++) { function natDigitsBeforeCursor(value: string, cursor: number): number {
if (/\d/.test(value[i])) count++ let c = 0
} for (let i = PREFIX_LEN; i < cursor && i < value.length; i++) if (/\d/.test(value[i])) c++
return count return c
}
function cursorAfterNthDigit(value: string, n: number): number {
if (n <= 0) return 0
let count = 0
for (let i = 0; i < value.length; i++) {
if (/\d/.test(value[i])) {
count++
if (count === n) return i + 1
}
} }
function cursorAfterNthNatDigit(value: string, n: number): number {
if (n <= 0) return PREFIX_LEN
let c = 0
for (let i = PREFIX_LEN; i < value.length; i++) if (/\d/.test(value[i])) { c++; if (c === n) return i + 1 }
return value.length return value.length
} }
@ -44,35 +53,38 @@ interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'v
onChange: (value: string) => void onChange: (value: string) => void
} }
/** PhoneInput для public — см. food-market.web/components/PhoneInput.tsx. */ /** PhoneInput для public — зеркалит food-market.web/components/PhoneInput.tsx. */
export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) { export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) {
const ref = useRef<HTMLInputElement>(null) const ref = useRef<HTMLInputElement>(null)
const userDigits = useMemo(() => rawToUserDigits(value), [value]) const national = useMemo(() => valueToNational(value), [value])
const display = userDigitsToDisplay(userDigits) const display = nationalToDisplay(national)
const pendingDigitCountRef = useRef<number | null>(null) const pendingRef = useRef<number | null>(null)
useLayoutEffect(() => { useLayoutEffect(() => {
const n = pendingDigitCountRef.current const n = pendingRef.current
pendingDigitCountRef.current = null pendingRef.current = null
if (n === null) return if (n === null) return
const el = ref.current const el = ref.current
if (!el) return if (!el) return
const pos = Math.max(PREFIX_LEN, cursorAfterNthDigit(display, n)) const pos = Math.max(PREFIX_LEN, cursorAfterNthNatDigit(display, n))
el.setSelectionRange(pos, pos) el.setSelectionRange(pos, pos)
}, [display]) }, [display])
const handleBeforeInput = (e: FormEvent<HTMLInputElement>) => { const handleBeforeInput = (e: FormEvent<HTMLInputElement>) => {
const native = e.nativeEvent as InputEvent const native = e.nativeEvent as InputEvent
if (native.data && /\D/.test(native.data)) { if (native.data && /\D/.test(native.data)) e.preventDefault()
e.preventDefault()
}
} }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const cursorPos = e.target.selectionStart ?? e.target.value.length const raw = e.target.value
pendingDigitCountRef.current = digitsBeforeCursor(e.target.value, cursorPos) const cursorPos = e.target.selectionStart ?? raw.length
const newDigits = rawToUserDigits(e.target.value) pendingRef.current = natDigitsBeforeCursor(raw, cursorPos)
onChange(newDigits.length > 0 ? `+77${newDigits}` : '') // При посимвольном вводе значение начинается с «+7 » — берём часть после
// префикса, чтобы его «7» не попала в номер. При полной вставке (replace)
// значения без префикса нормализуем целиком (снимем код страны/«8»).
const src = raw.startsWith(PREFIX) ? raw.slice(PREFIX_LEN) : raw
const nat = toNational(src)
onChange(nat.length > 0 ? '+7' + nat : '')
} }
const guardCursor = (el: HTMLInputElement) => { const guardCursor = (el: HTMLInputElement) => {
@ -82,12 +94,10 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
el.setSelectionRange(Math.max(PREFIX_LEN, start), Math.max(PREFIX_LEN, end)) el.setSelectionRange(Math.max(PREFIX_LEN, start), Math.max(PREFIX_LEN, end))
} }
} }
const handleSelect = (e: React.SyntheticEvent<HTMLInputElement>) => guardCursor(e.currentTarget) const handleSelect = (e: React.SyntheticEvent<HTMLInputElement>) => guardCursor(e.currentTarget)
const handleFocus = () => { const handleFocus = () => {
const el = ref.current const el = ref.current
if (!el) return if (el) requestAnimationFrame(() => guardCursor(el))
requestAnimationFrame(() => guardCursor(el))
} }
return ( return (

View file

@ -90,17 +90,33 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
const e: FieldErrors = {} const e: FieldErrors = {}
const em = validateEmail(email); if (em) e.email = em const em = validateEmail(email); if (em) e.email = em
const pw = validatePassword(password); if (pw) e.password = pw const pw = validatePassword(password); if (pw) e.password = pw
if (!orgName.trim()) e.orgName = 'Название магазина обязательно для заполнения' if (!orgName.trim()) e.orgName = 'Укажите название магазина'
const ph = validatePhone(phone); if (ph) e.phone = ph const ph = validatePhone(phone); if (ph) e.phone = ph
if (!agree) e.agree = 'Подтвердите согласие с офертой и политикой ПДн' if (!agree) e.agree = 'Подтвердите согласие с офертой и политикой ПДн'
return e return e
} }
// Порядок полей сверху вниз — для перехода к первой ошибке. id совпадает с
// id инпутов ниже.
const FIELD_ORDER: Array<keyof FieldErrors> = ['email', 'password', 'orgName', 'phone', 'agree']
const focusFirstError = (errs: FieldErrors) => {
const firstKey = FIELD_ORDER.find((k) => errs[k])
if (!firstKey) return
// На 1280×665 верхние поля часто выше вьюпорта — сначала проскроллим к полю,
// затем сфокусируем (focus сам по себе прокручивает неплавно/неточно).
const el = document.getElementById(`signup-${firstKey}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
// Фокус после старта прокрутки — иначе браузер перебьёт плавный скролл.
setTimeout(() => el.focus({ preventScroll: true }), 150)
}
}
const submit = async (ev: React.FormEvent) => { const submit = async (ev: React.FormEvent) => {
ev.preventDefault() ev.preventDefault()
const errs = validate() const errs = validate()
setFieldErrors(errs) setFieldErrors(errs)
if (Object.keys(errs).length) return if (Object.keys(errs).length) { focusFirstError(errs); return }
setError(null); setBusy(true) setError(null); setBusy(true)
try { try {
const res = await fetch(`${API_URL}/api/auth/signup`, { const res = await fetch(`${API_URL}/api/auth/signup`, {
@ -151,6 +167,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
<Field label="Email" error={fieldErrors.email}> <Field label="Email" error={fieldErrors.email}>
<input <input
id="signup-email"
type="email" type="email"
value={email} value={email}
onChange={(e) => { onChange={(e) => {
@ -171,6 +188,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
<Field label="Пароль" hint="Минимум 8 символов, цифра и заглавная." error={fieldErrors.password}> <Field label="Пароль" hint="Минимум 8 символов, цифра и заглавная." error={fieldErrors.password}>
<input <input
id="signup-password"
type="password" type="password"
value={password} value={password}
onChange={(e) => { onChange={(e) => {
@ -188,15 +206,16 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
<Field label="Название магазина" error={fieldErrors.orgName}> <Field label="Название магазина" error={fieldErrors.orgName}>
<input <input
id="signup-orgName"
type="text" type="text"
value={orgName} value={orgName}
onChange={(e) => { onChange={(e) => {
const v = e.target.value const v = e.target.value
setOrgName(v) setOrgName(v)
setFieldErrors(p => p.orgName ? {...p, orgName: !v.trim() ? 'Название магазина обязательно для заполнения' : undefined} : p) setFieldErrors(p => p.orgName ? {...p, orgName: !v.trim() ? 'Укажите название магазина' : undefined} : p)
}} }}
onBlur={(e) => { onBlur={(e) => {
setFieldErrors(p => ({...p, orgName: !e.target.value.trim() ? 'Название магазина обязательно для заполнения' : undefined})) setFieldErrors(p => ({...p, orgName: !e.target.value.trim() ? 'Укажите название магазина' : undefined}))
}} }}
placeholder="Наименование организации" placeholder="Наименование организации"
className={inputCls(!!fieldErrors.orgName)} className={inputCls(!!fieldErrors.orgName)}
@ -205,6 +224,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
<Field label="Телефон" error={fieldErrors.phone}> <Field label="Телефон" error={fieldErrors.phone}>
<PhoneInput <PhoneInput
id="signup-phone"
value={phone} value={phone}
onChange={(v) => { onChange={(v) => {
setPhone(v) setPhone(v)
@ -266,7 +286,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
<div> <div>
<label className="flex items-start gap-2 text-sm"> <label className="flex items-start gap-2 text-sm">
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} className="mt-1" /> <input id="signup-agree" type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} className="mt-1" />
<span>Я согласен с <a href="/legal/offer" className="text-brand underline py-1">офертой</a> и <a href="/legal/privacy" className="text-brand underline py-1">политикой обработки ПДн</a>.</span> <span>Я согласен с <a href="/legal/offer" className="text-brand underline py-1">офертой</a> и <a href="/legal/privacy" className="text-brand underline py-1">политикой обработки ПДн</a>.</span>
</label> </label>
{fieldErrors.agree && <p className="text-xs text-red-600 mt-1">{fieldErrors.agree}</p>} {fieldErrors.agree && <p className="text-xs text-red-600 mt-1">{fieldErrors.agree}</p>}

View file

@ -5,7 +5,7 @@
const STRICT_EMAIL = /^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$/ const STRICT_EMAIL = /^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$/
export const emailMsg = { export const emailMsg = {
required: 'Email обязателен', required: 'Укажите email',
format: 'Некорректный формат email. Пример: user@example.kz', format: 'Некорректный формат email. Пример: user@example.kz',
noTld: 'Email должен содержать доменную зону (например, .kz, .com)', noTld: 'Email должен содержать доменную зону (например, .kz, .com)',
} }
@ -19,7 +19,7 @@ export function validateEmail(value: string): string | null {
} }
export function validatePassword(value: string): string | null { export function validatePassword(value: string): string | null {
if (!value) return 'Это поле обязательно для заполнения' if (!value) return 'Придумайте пароль'
if (value.length < 8) return 'Пароль должен быть не менее 8 символов' if (value.length < 8) return 'Пароль должен быть не менее 8 символов'
if (!/[A-Z]/.test(value)) return 'Пароль должен содержать хотя бы одну заглавную букву' if (!/[A-Z]/.test(value)) return 'Пароль должен содержать хотя бы одну заглавную букву'
if (!/[0-9]/.test(value)) return 'Пароль должен содержать хотя бы одну цифру' if (!/[0-9]/.test(value)) return 'Пароль должен содержать хотя бы одну цифру'
@ -27,7 +27,7 @@ export function validatePassword(value: string): string | null {
} }
export function validatePhone(value: string): string | null { export function validatePhone(value: string): string | null {
if (!value || value.trim() === '') return 'Телефон обязателен для заполнения' if (!value || value.trim() === '') return 'Укажите телефон'
// Казахстан: +7 7XX XXX XX XX (мобильные коды KZ начинаются с 77, // Казахстан: +7 7XX XXX XX XX (мобильные коды KZ начинаются с 77,
// 79 — это РФ, исключаем). Принимаем любое форматирование (пробелы, // 79 — это РФ, исключаем). Принимаем любое форматирование (пробелы,
// скобки, дефисы), нормализуем к 11 цифрам, ведущая «8» = «7». // скобки, дефисы), нормализуем к 11 цифрам, ведущая «8» = «7».

View file

@ -3,46 +3,59 @@ import { cn } from '@/lib/utils'
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 tabular-nums' 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 tabular-nums'
const PREFIX = '+7 7' // Залочено: «+», «7», пробел, «7» (мобильный код KZ). // Код страны (+7) живёт в префиксе; оператор НЕ подставляем заранее, иначе
const PREFIX_LEN = PREFIX.length // 4 // при вводе полного номера «7XX…» его первая цифра налезает на префиксную «7».
const PREFIX = '+7 '
const PREFIX_LEN = PREFIX.length // 3
/** Извлекает 9 пользовательских цифр из любой формы значения (canonical // Национальный номер KZ — 10 цифр, начинается с 7 (например 701 000 00 00).
* «+77XXXXXXXXX», display «+7 7XX XXX XX XX», raw paste «8 707…», и т.д.). // Из любой формы вытаскиваем до 10 цифр нац. номера: снимаем ведущую «8»
* Снимает по одной ведущей «8», «7», «7» — это код страны и мобильный // (межгород) и, если получилось 11 цифр с ведущей «7» — это код страны, снимаем.
* префикс KZ, оба зашиты в наш PREFIX. */ // Прим.: при посимвольном вводе в handleChange сюда передаётся ТОЛЬКО часть
function rawToUserDigits(raw: string): string { // после префикса, чтобы «7» из «+7 » не попала в номер.
function toNational(raw: string): string {
let d = raw.replace(/\D/g, '') let d = raw.replace(/\D/g, '')
if (d[0] === '8') d = d.slice(1) if (d[0] === '8') d = d.slice(1)
if (d[0] === '7') d = d.slice(1) if (d.length === 11 && d[0] === '7') d = d.slice(1)
if (d[0] === '7') d = d.slice(1) return d.slice(0, 10)
return d.slice(0, 9)
} }
/** Display из 9 пользовательских цифр. Формат: «+7 7XX XXX XX XX» — после // Восстановление нац. номера из каноничного value. Мы ВСЕГДА эмитим value как
* залоченного «+7 7» идут 2+3+2+2 = 9 цифр через пробелы. */ // «+7»+national, поэтому снимаем ровно литеральный код страны «+7» и не
function userDigitsToDisplay(d: string): string { // применяем 8/11-эвристики toNational — иначе «7» кода страны при неполном
// номере сливается с номером и ввод «скачет» (+7 77, +7 777 0, …).
function valueToNational(value: string): string {
if (value.startsWith('+7')) return value.slice(2).replace(/\D/g, '').slice(0, 10)
return toNational(value)
}
// Display из нац. номера. Формат: «+7 7XX XXX XX XX» — после «+7 » идут
// 3+3+2+2 = 10 цифр через пробелы.
function nationalToDisplay(d: string): string {
if (d.length === 0) return PREFIX if (d.length === 0) return PREFIX
if (d.length <= 2) return `${PREFIX}${d}` let s = PREFIX + d.slice(0, 3)
if (d.length <= 5) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2)}` if (d.length > 3) s += ' ' + d.slice(3, 6)
if (d.length <= 7) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5)}` if (d.length > 6) s += ' ' + d.slice(6, 8)
return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}` if (d.length > 8) s += ' ' + d.slice(8, 10)
return s
} }
/** Сколько цифровых символов стоит ДО позиции cursor в строке value. */ // Курсор считаем в цифрах нац. номера (цифры ПОСЛЕ префикса), чтобы «7» из
function digitsBeforeCursor(value: string, cursor: number): number { // «+7 » не сбивала позицию.
function natDigitsBeforeCursor(value: string, cursor: number): number {
let count = 0 let count = 0
for (let i = 0; i < cursor && i < value.length; i++) { for (let i = PREFIX_LEN; i < cursor && i < value.length; i++) {
if (/\d/.test(value[i])) count++ if (/\d/.test(value[i])) count++
} }
return count return count
} }
/** Позиция в value сразу ПОСЛЕ n-й цифры (1-indexed). Если n больше чем // Позиция в value сразу ПОСЛЕ n-й цифры нац. номера (1-indexed). Если n<=0 —
* есть цифр — возвращает конец строки. */ // начало digit-зоны (сразу за префиксом); если больше чем есть — конец строки.
function cursorAfterNthDigit(value: string, n: number): number { function cursorAfterNthNatDigit(value: string, n: number): number {
if (n <= 0) return 0 if (n <= 0) return PREFIX_LEN
let count = 0 let count = 0
for (let i = 0; i < value.length; i++) { for (let i = PREFIX_LEN; i < value.length; i++) {
if (/\d/.test(value[i])) { if (/\d/.test(value[i])) {
count++ count++
if (count === n) return i + 1 if (count === n) return i + 1
@ -57,19 +70,19 @@ interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'v
onChange: (value: string) => void onChange: (value: string) => void
} }
/** Поле ввода телефона Казахстана. Префикс «+7 7» (4 символа) залочен — /** Поле ввода телефона Казахстана. Префикс «+7 » (3 символа) залочен — курсор
* курсор не пускается в позиции 0..3, выделение клампится на digit-зону. * не пускается в позиции 0..2, выделение клампится на digit-зону. В остальном
* В остальном работает как обычный input: native selection, backspace, * работает как обычный input: native selection, backspace, click, Cmd+A,
* click, Cmd+A, paste, drag — всё нативное. Единственное ограничение — * paste, drag. Не-цифры блокируются через onBeforeInput. Нормализация снимает
* не-цифры блокируются через onBeforeInput до того как попасть в value. */ * ведущую «8» и код страны «7», поэтому «8701…», «+7701…», «7701…» и «701…»
* дают один и тот же номер «+7 701 …». */
export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) { export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) {
const ref = useRef<HTMLInputElement>(null) const ref = useRef<HTMLInputElement>(null)
const userDigits = useMemo(() => rawToUserDigits(value), [value]) const national = useMemo(() => valueToNational(value), [value])
const display = userDigitsToDisplay(userDigits) const display = nationalToDisplay(national)
// Запоминаем сколько цифр стояло до курсора в момент редактирования — // Запоминаем сколько цифр нац. номера стояло до курсора в момент
// после React-нормализации (которая может сдвинуть пробелы формата) // редактирования — после React-нормализации возвращаем курсор после той же
// ставим курсор обратно после той же по счёту цифры. Без этого React // по счёту цифры, иначе React сбрасывал бы его в конец на каждом изменении.
// сбрасывал бы курсор в конец на каждом изменении value.
const pendingDigitCountRef = useRef<number | null>(null) const pendingDigitCountRef = useRef<number | null>(null)
useLayoutEffect(() => { useLayoutEffect(() => {
@ -78,7 +91,7 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
if (n === null) return if (n === null) return
const el = ref.current const el = ref.current
if (!el) return if (!el) return
const pos = Math.max(PREFIX_LEN, cursorAfterNthDigit(display, n)) const pos = Math.max(PREFIX_LEN, cursorAfterNthNatDigit(display, n))
el.setSelectionRange(pos, pos) el.setSelectionRange(pos, pos)
}, [display]) }, [display])
@ -95,10 +108,15 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
// нормализуем значение. Запоминаем позицию курсора в digit-индексах, // нормализуем значение. Запоминаем позицию курсора в digit-индексах,
// чтобы useLayoutEffect восстановил её после ре-рендера. // чтобы useLayoutEffect восстановил её после ре-рендера.
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const cursorPos = e.target.selectionStart ?? e.target.value.length const raw = e.target.value
pendingDigitCountRef.current = digitsBeforeCursor(e.target.value, cursorPos) const cursorPos = e.target.selectionStart ?? raw.length
const newDigits = rawToUserDigits(e.target.value) pendingDigitCountRef.current = natDigitsBeforeCursor(raw, cursorPos)
onChange(newDigits.length > 0 ? `+77${newDigits}` : '') // При посимвольном вводе значение начинается с «+7 » — берём часть после
// префикса, чтобы его «7» не попала в номер. При полной вставке значения
// без префикса нормализуем целиком (снимем код страны/«8»).
const src = raw.startsWith(PREFIX) ? raw.slice(PREFIX_LEN) : raw
const nat = toNational(src)
onChange(nat.length > 0 ? '+7' + nat : '')
} }
// Не пускаем курсор/selection в префикс. onSelect ловит любые попытки // Не пускаем курсор/selection в префикс. onSelect ловит любые попытки

View file

@ -21,6 +21,18 @@ interface WhatsNewResponse {
const STORAGE_KEY = 'fm.lastSeenBuildVersion' const STORAGE_KEY = 'fm.lastSeenBuildVersion'
/** Русская плюрализация: forms = [одна, две-четыре, пять-и-более].
* Напр. pluralRu(21, ['улучшение','улучшения','улучшений']) → «улучшение»,
* pluralRu(34, [...]) → «исправления», pluralRu(50, [...]) → «улучшений». */
function pluralRu(n: number, forms: [string, string, string]): string {
const mod100 = n % 100
const mod10 = n % 10
if (mod100 >= 11 && mod100 <= 14) return forms[2]
if (mod10 === 1) return forms[0]
if (mod10 >= 2 && mod10 <= 4) return forms[1]
return forms[2]
}
export function WhatsNewBanner() { export function WhatsNewBanner() {
const [dismissed, setDismissed] = useState(false) const [dismissed, setDismissed] = useState(false)
const { data } = useQuery<WhatsNewResponse>({ const { data } = useQuery<WhatsNewResponse>({
@ -58,8 +70,8 @@ export function WhatsNewBanner() {
<Sparkles className="w-4 h-4 text-emerald-600 shrink-0" aria-hidden="true" /> <Sparkles className="w-4 h-4 text-emerald-600 shrink-0" aria-hidden="true" />
<span className="truncate text-emerald-900 dark:text-emerald-200"> <span className="truncate text-emerald-900 dark:text-emerald-200">
Появились новые функции —{' '} Появились новые функции —{' '}
<strong>{featCount}</strong> {featCount === 1 ? 'улучшение' : 'улучшений'} <strong>{featCount}</strong> {pluralRu(featCount, ['улучшение', 'улучшения', 'улучшений'])}
{fixCount > 0 && <> и <strong>{fixCount}</strong> {fixCount === 1 ? 'исправление' : 'исправлений'}</>}{' '} {fixCount > 0 && <> и <strong>{fixCount}</strong> {pluralRu(fixCount, ['исправление', 'исправления', 'исправлений'])}</>}{' '}
за последние 30 дней.{' '} за последние 30 дней.{' '}
<Link to="/whats-new" onClick={dismiss} className="underline font-medium hover:text-emerald-700 dark:hover:text-emerald-100"> <Link to="/whats-new" onClick={dismiss} className="underline font-medium hover:text-emerald-700 dark:hover:text-emerald-100">
Посмотреть Посмотреть

View file

@ -0,0 +1,28 @@
// Per-org флаг «скрыть первые шаги» на главной. Хранится в localStorage
// (это UI-предпочтение браузера, не серверное состояние). Ключ привязан к
// orgId, чтобы в одном браузере у разных организаций он был независим.
// Все обращения в try/catch — localStorage может быть недоступен (приватное
// окно, отключённые site-data).
const KEY_PREFIX = 'allmarket:onboarding-hidden:'
function key(orgId: string | null): string {
return KEY_PREFIX + (orgId ?? 'default')
}
export function isOnboardingHidden(orgId: string | null): boolean {
try {
return localStorage.getItem(key(orgId)) === '1'
} catch {
return false
}
}
export function setOnboardingHidden(orgId: string | null, hidden: boolean): void {
try {
if (hidden) localStorage.setItem(key(orgId), '1')
else localStorage.removeItem(key(orgId))
} catch {
/* localStorage недоступен — предпочтение просто не сохранится */
}
}

View file

@ -33,7 +33,16 @@ export function useOnboardingProgress() {
queryFn: async () => (await api.get<PagedResult<unknown>>('/api/purchases/supplies?pageSize=1')).data.total, queryFn: async () => (await api.get<PagedResult<unknown>>('/api/purchases/supplies?pageSize=1')).data.total,
staleTime: 60_000, staleTime: 60_000,
}) })
// Статус копии «Реестра товаров» в каталог: 0=не было, 1=идёт, 2=готово, 3=ошибка.
// Пока идёт — опрашиваем чаще, чтобы шаг каталога сам обновился по готовности.
const registry = useQuery({
queryKey: ['onboarding:registry-copy-status'],
queryFn: async () => (await api.get<{ state: number; copiedAt: string | null }>('/api/catalog/registry-copy-status')).data,
refetchInterval: (q) => (q.state.data?.state === 1 ? 4000 : false),
})
const productCount = products.data ?? 0
const registryCopyState = registry.data?.state ?? 0
const orgConfigured = !!org.data?.defaultCurrencyId && !!org.data?.countryCode const orgConfigured = !!org.data?.defaultCurrencyId && !!org.data?.countryCode
// Если роль не видит сотрудников — считаем шаг «добавить команду» как // Если роль не видит сотрудников — считаем шаг «добавить команду» как
// завершённый (это не задача кассира/кладовщика), иначе прогресс никогда // завершённый (это не задача кассира/кладовщика), иначе прогресс никогда
@ -51,6 +60,9 @@ export function useOnboardingProgress() {
|| products.isLoading || products.isLoading
|| supplies.isLoading, || supplies.isLoading,
...steps, ...steps,
productCount,
registryCopyState,
orgId: me.data?.orgId ?? null,
doneCount: done, doneCount: done,
totalCount: total, totalCount: total,
overall: total === 0 ? 0 : Math.round((done / total) * 100), overall: total === 0 ? 0 : Math.round((done / total) * 100),

View file

@ -25,6 +25,13 @@ export interface OrgSettings {
negStockQuietFromHour: number | null negStockQuietFromHour: number | null
negStockQuietToHour: number | null negStockQuietToHour: number | null
negStockEscalateHours: number negStockEscalateHours: number
// Тариф и бесплатный период — read-only (выбор был при регистрации).
plan: string
freeTrialEndsAt: string | null
planShops: number | null
planCashes: number | null
planWarehouses: number | null
planEmployees: number | null
} }
export function useOrgSettings() { export function useOrgSettings() {

View file

@ -1,7 +1,8 @@
import { Link } from 'react-router-dom' import { Link, useNavigate, Navigate } from 'react-router-dom'
import { Settings, UserCog, Package, ShoppingCart, ArrowRight, CheckCircle2, BarChart3 } from 'lucide-react' import { Settings, UserCog, Package, ShoppingCart, ArrowRight, CheckCircle2, BarChart3, EyeOff } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { useOnboardingProgress } from '@/lib/useOnboardingProgress' import { useOnboardingProgress } from '@/lib/useOnboardingProgress'
import { isOnboardingHidden, setOnboardingHidden } from '@/lib/onboardingPrefs'
interface StepCard { interface StepCard {
done: boolean done: boolean
@ -16,6 +17,53 @@ interface StepCard {
export function OnboardingPage() { export function OnboardingPage() {
const p = useOnboardingProgress() const p = useOnboardingProgress()
const navigate = useNavigate()
// «Скрыть первые шаги»: если владелец спрятал виджет, главная ведёт сразу на
// аналитику-дашборд. Вернуть можно из настроек организации («Показать первые
// шаги на главной»). Ждём orgId, чтобы не мигать до загрузки /api/me.
if (p.orgId && isOnboardingHidden(p.orgId)) {
return <Navigate to="/dashboard" replace />
}
const copying = p.registryCopyState === 1
const copied = p.registryCopyState === 2
const catalogCard: StepCard = copying
? {
done: false,
badge: 'Каталог',
title: 'Каталог загружается из реестра ALL MARKET',
description: 'Каталог загружается… Товары появятся через минуту-другую — можно продолжать настройку, обновлять страницу не нужно.',
ctaLabel: 'Каталог загружается…',
ctaTo: '/catalog/products',
ctaDisabled: true,
icon: Package,
}
: copied && p.productCount > 0
? {
done: true,
badge: 'Каталог',
title: `Каталог готов: ${p.productCount} товаров из реестра ALL MARKET`,
description: 'Товары скопированы из реестра ALL MARKET. Проверьте продажные цены — закупочные и себестоимость появятся после первой приёмки.',
ctaLabel: 'Проверить продажные цены',
ctaTo: '/catalog/products',
icon: Package,
}
: {
done: p.hasProducts,
badge: 'Каталог',
title: 'Заведите товары вручную или импортируйте из МойСклад',
description: 'Товары — основа всего. Импорт подтянет наименования, штрихкоды, цены и остатки одним нажатием.',
ctaLabel: p.hasProducts ? 'Перейти в каталог' : 'Добавить товары',
ctaTo: p.hasProducts ? '/catalog/products' : '/admin/import/moysklad',
icon: Package,
}
const hide = () => {
setOnboardingHidden(p.orgId, true)
navigate('/dashboard')
}
const cards: StepCard[] = [ const cards: StepCard[] = [
{ {
@ -36,15 +84,7 @@ export function OnboardingPage() {
ctaTo: '/settings/employees', ctaTo: '/settings/employees',
icon: UserCog, icon: UserCog,
}, },
{ catalogCard,
done: p.hasProducts,
badge: 'Каталог',
title: 'Заведите товары вручную или импортируйте из МойСклад',
description: 'Товары — основа всего. Импорт подтянет наименования, штрихкоды, цены и остатки одним нажатием.',
ctaLabel: p.hasProducts ? 'Перейти в каталог' : 'Добавить товары',
ctaTo: p.hasProducts ? '/catalog/products' : '/admin/import/moysklad',
icon: Package,
},
{ {
done: p.hasSupplies, done: p.hasSupplies,
badge: 'Закупки', badge: 'Закупки',
@ -59,10 +99,19 @@ export function OnboardingPage() {
return ( return (
<div className="h-full overflow-auto"> <div className="h-full overflow-auto">
<div className="max-w-5xl mx-auto p-4 sm:p-6 space-y-5 sm:space-y-6"> <div className="max-w-5xl mx-auto p-4 sm:p-6 space-y-5 sm:space-y-6">
<div className="flex items-start justify-between gap-3">
<PageHeader <PageHeader
title="Первые шаги в ALL MARKET" title="Первые шаги в ALL MARKET"
description="Складской учёт, закупки и продажи в одной системе. Пройдите 4 шага — и можно работать." description="Складской учёт, закупки и продажи в одной системе. Пройдите 4 шага — и можно работать."
/> />
<button
onClick={hide}
title="Скрыть первые шаги (вернуть можно в настройках организации)"
className="flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-slate-200 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800"
>
<EyeOff className="w-4 h-4" /> Скрыть первые шаги
</button>
</div>
{/* Прогресс-плашка */} {/* Прогресс-плашка */}
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5"> <section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5">
@ -134,7 +183,7 @@ export function OnboardingPage() {
<h3 className="text-sm font-semibold mb-1">Помощь и поддержка</h3> <h3 className="text-sm font-semibold mb-1">Помощь и поддержка</h3>
<p className="text-sm text-slate-600 dark:text-slate-400"> <p className="text-sm text-slate-600 dark:text-slate-400">
Возникли вопросы по настройке или работе системы? Напишите администратору Возникли вопросы по настройке или работе системы? Напишите администратору
или загляните в документацию проекта на Forgejo. или загляните в <Link to="/help" className="text-[var(--color-brand)] underline">Базу знаний</Link>.
</p> </p>
</section> </section>
</div> </div>

View file

@ -9,6 +9,8 @@ import { FormSkeleton } from '@/components/Skeleton'
import { useCountries } from '@/lib/useLookups' import { useCountries } from '@/lib/useLookups'
import { useOrgSettings, type OrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings, type OrgSettings } from '@/lib/useOrgSettings'
import { useMe } from '@/lib/useMe' import { useMe } from '@/lib/useMe'
import { isOnboardingHidden, setOnboardingHidden } from '@/lib/onboardingPrefs'
import { ListChecks } from 'lucide-react'
interface DemoSeedSummary { interface DemoSeedSummary {
alreadySeeded: boolean alreadySeeded: boolean
@ -82,6 +84,7 @@ export function OrganizationSettingsPage() {
<PageHeader title="Настройки организации" description="Страна, валюта, ставка НДС по умолчанию." /> <PageHeader title="Настройки организации" description="Страна, валюта, ставка НДС по умолчанию." />
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4"> <section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
<PlanBanner settings={form} />
<Field label="Название организации" error={nameErr ?? undefined}> <Field label="Название организации" error={nameErr ?? undefined}>
<TextInput value={form.name} <TextInput value={form.name}
onChange={(e) => { setForm({ ...form, name: e.target.value }); if (nameErr) setNameErr(null) }} onChange={(e) => { setForm({ ...form, name: e.target.value }); if (nameErr) setNameErr(null) }}
@ -189,8 +192,9 @@ export function OrganizationSettingsPage() {
onChange={(v) => setForm({ ...form, showReferencePriceOnProduct: v })} onChange={(v) => setForm({ ...form, showReferencePriceOnProduct: v })}
/> />
<p className="text-xs text-slate-500 dark:text-slate-400 -mt-2"> <p className="text-xs text-slate-500 dark:text-slate-400 -mt-2">
Справочная цена закупа — необязательное поле. Авто-заполняется первой Если включено — в карточке товара есть поле «Закупочная цена» (цена последней
проведённой приёмкой и через 30 дней без приёмок переписывается на текущую себестоимость. закупки). Её обновляет каждая проведённая приёмка; отмена проведения возвращает
предыдущее значение. Себестоимость (скользящее среднее) считается отдельно.
</p> </p>
<Checkbox <Checkbox
@ -259,6 +263,7 @@ export function OrganizationSettingsPage() {
{save.error && <span className="text-sm text-red-600">{(save.error as Error).message}</span>} {save.error && <span className="text-sm text-red-600">{(save.error as Error).message}</span>}
</div> </div>
<OnboardingVisibilitySection orgId={form.id} />
<FiscalSection /> <FiscalSection />
<TelegramSection /> <TelegramSection />
<DemoSeedSection /> <DemoSeedSection />
@ -267,6 +272,68 @@ export function OrganizationSettingsPage() {
) )
} }
/** Восстановление виджета «Первые шаги» на главной (скрывается кнопкой на самой
* странице первых шагов). Предпочтение хранится в localStorage per-org. */
function OnboardingVisibilitySection({ orgId }: { orgId: string }) {
const [hidden, setHidden] = useState(() => isOnboardingHidden(orgId))
return (
<section className="border-t border-slate-200 dark:border-slate-800 pt-6 mt-6">
<h2 className="text-base font-semibold flex items-center gap-2">
<ListChecks className="w-4 h-4 text-emerald-600" /> Первые шаги
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
{hidden
? 'Сейчас виджет «Первые шаги» скрыт — на главной открывается дашборд.'
: 'Виджет «Первые шаги» показывается на главной странице. Его можно скрыть кнопкой «Скрыть первые шаги» на самой странице.'}
</p>
<div className="mt-3">
<Button
variant="secondary"
onClick={() => { setOnboardingHidden(orgId, !hidden); setHidden(!hidden) }}
>
{hidden ? 'Показать первые шаги на главной' : 'Скрыть первые шаги'}
</Button>
</div>
</section>
)
}
const PLAN_LABELS: Record<string, string> = { start: 'Старт', business: 'Бизнес', network: 'Сеть' }
/** Дата ISO → ДД.ММ.ГГГГ (Asia/Almaty не важна — дата без времени). */
function fmtDate(iso: string | null): string | null {
if (!iso) return null
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return null
const dd = String(d.getDate()).padStart(2, '0')
const mm = String(d.getMonth() + 1).padStart(2, '0')
return `${dd}.${mm}.${d.getFullYear()}`
}
/** Read-only плашка тарифа и бесплатного периода. Выбор был при регистрации;
* биллинга/смены тарифа в кабинете пока нет — только показ. */
function PlanBanner({ settings }: { settings: OrgSettings }) {
const label = PLAN_LABELS[settings.plan] ?? 'Старт'
const until = fmtDate(settings.freeTrialEndsAt)
const isBusiness = settings.plan === 'business'
return (
<div className="rounded-lg border border-emerald-200 dark:border-emerald-900/50 bg-emerald-50 dark:bg-emerald-900/20 px-4 py-3">
<div className="text-sm text-slate-800 dark:text-slate-100">
<span className="font-semibold">Тариф {label}</span>
{until && <span className="text-slate-600 dark:text-slate-300"> · бесплатно до {until}</span>}
</div>
{isBusiness && (settings.planShops || settings.planCashes || settings.planWarehouses || settings.planEmployees) && (
<div className="mt-1.5 text-xs text-slate-600 dark:text-slate-300 flex flex-wrap gap-x-4 gap-y-0.5">
{settings.planShops != null && <span>Магазинов: <b>{settings.planShops}</b></span>}
{settings.planCashes != null && <span>Касс: <b>{settings.planCashes}</b></span>}
{settings.planWarehouses != null && <span>Складов: <b>{settings.planWarehouses}</b></span>}
{settings.planEmployees != null && <span>Сотрудников: <b>{settings.planEmployees}</b></span>}
</div>
)}
</div>
)
}
interface TelegramStatus { interface TelegramStatus {
botEnabled: boolean botEnabled: boolean
botUsername: string | null botUsername: string | null

View file

@ -424,7 +424,10 @@ export function ProductEditPage() {
<div> <div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-3">Закупка</h3> <h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-3">Закупка</h3>
<div className="space-y-3"> <div className="space-y-3">
{!simpleMode && org.data?.showReferencePriceOnProduct && ( {/* Закупочная цена — базовое поле схемы цен (каждая приёмка её
пишет), показываем и в простом режиме. Скрыть можно только
настройкой организации ShowReferencePriceOnProduct. */}
{org.data?.showReferencePriceOnProduct && (
<Field label="Закупочная цена"> <Field label="Закупочная цена">
<MoneyInput <MoneyInput
value={form.referencePrice === '' ? null : Number(form.referencePrice)} value={form.referencePrice === '' ? null : Number(form.referencePrice)}
@ -432,7 +435,7 @@ export function ProductEditPage() {
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}
/> />
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">не обязательное поле</p> <p className="text-xs text-slate-500 dark:text-slate-400 mt-1">цена последней закупки; необязательное поле</p>
</Field> </Field>
)} )}
<Field label="Себестоимость"> <Field label="Себестоимость">

View file

@ -19,8 +19,11 @@ interface OrgRow {
isActive: boolean; isArchived: boolean; archivedAt: string | null isActive: boolean; isArchived: boolean; archivedAt: string | null
createdAt: string; employeeCount: number; productCount: number; lastLoginAt: string | null createdAt: string; employeeCount: number; productCount: number; lastLoginAt: string | null
isAdvancedMode: boolean isAdvancedMode: boolean
plan: string; freeTrialEndsAt: string | null
} }
const PLAN_LABELS: Record<string, string> = { start: 'Старт', business: 'Бизнес', network: 'Сеть' }
export function SuperAdminOrganizationsPage() { export function SuperAdminOrganizationsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [archived, setArchived] = useState<boolean | null>(false) const [archived, setArchived] = useState<boolean | null>(false)
@ -118,6 +121,16 @@ export function SuperAdminOrganizationsPage() {
</div> </div>
)}, )},
{ header: 'Создана', width: '140px', cell: (r) => new Date(r.createdAt).toLocaleDateString('ru') }, { header: 'Создана', width: '140px', cell: (r) => new Date(r.createdAt).toLocaleDateString('ru') },
{ header: 'Тариф', width: '150px', cell: (r) => (
<div>
<div className="text-xs font-medium">{PLAN_LABELS[r.plan] ?? 'Старт'}</div>
{r.freeTrialEndsAt && (
<div className="text-[11px] text-slate-500 dark:text-slate-400">
беспл. до {new Date(r.freeTrialEndsAt).toLocaleDateString('ru')}
</div>
)}
</div>
)},
{ header: 'Сотрудники', width: '110px', className: 'text-right', cell: (r) => r.employeeCount }, { header: 'Сотрудники', width: '110px', className: 'text-right', cell: (r) => r.employeeCount },
{ header: 'Товары', width: '110px', className: 'text-right', cell: (r) => r.productCount }, { header: 'Товары', width: '110px', className: 'text-right', cell: (r) => r.productCount },
{ header: 'Last login', width: '140px', cell: (r) => r.lastLoginAt ? new Date(r.lastLoginAt).toLocaleDateString('ru') : '—' }, { header: 'Last login', width: '140px', cell: (r) => r.lastLoginAt ? new Date(r.lastLoginAt).toLocaleDateString('ru') : '—' },

View file

@ -0,0 +1,81 @@
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Регистрация сохраняет выбранный тариф и бесплатный период (90 дней),
/// а для «Бизнес» — конфигурацию конструктора. Settings-эндпоинт отдаёт их
/// read-only для показа в кабинете владельца. Биллинга/блокировки нет.
/// Решение владельца 12.10.</summary>
[Collection(ApiCollection.Name)]
public class PlanSignupTests
{
private readonly ApiFactory _factory;
public PlanSignupTests(ApiFactory factory) => _factory = factory;
private async Task<JsonElement> SignupWithPlanAndGetSettingsAsync(ApiActor api, string slug, object body)
{
var resp = await api.Http.PostAsJsonAsync("/api/auth/signup", body);
resp.IsSuccessStatusCode.Should().BeTrue(await resp.Content.ReadAsStringAsync());
api.UseToken(await api.TokenAsync($"{slug}@example.kz", "Passw0rd!"));
return await api.GetJsonAsync("/api/organization/settings");
}
[Fact]
public async Task Business_signup_saves_plan_trial_and_config()
{
var api = new ApiActor(_factory.CreateClient());
var slug = $"plan-biz-{Guid.NewGuid():N}";
var s = await SignupWithPlanAndGetSettingsAsync(api, slug, new
{
email = $"{slug}@example.kz", password = "Passw0rd!", organizationName = $"Org {slug}",
phone = "+77010000000", plan = "business",
shops = 3, cashes = 4, warehouses = 2, employees = 25,
});
s.GetProperty("plan").GetString().Should().Be("business");
s.GetProperty("planShops").GetInt32().Should().Be(3);
s.GetProperty("planCashes").GetInt32().Should().Be(4);
s.GetProperty("planWarehouses").GetInt32().Should().Be(2);
s.GetProperty("planEmployees").GetInt32().Should().Be(25);
var trial = s.GetProperty("freeTrialEndsAt").GetDateTime();
var expected = DateTime.UtcNow.AddDays(90);
trial.Should().BeCloseTo(expected, TimeSpan.FromHours(1), "бесплатный период = регистрация + 90 дней");
}
[Fact]
public async Task Start_signup_saves_plan_without_config()
{
var api = new ApiActor(_factory.CreateClient());
var slug = $"plan-start-{Guid.NewGuid():N}";
var s = await SignupWithPlanAndGetSettingsAsync(api, slug, new
{
email = $"{slug}@example.kz", password = "Passw0rd!", organizationName = $"Org {slug}",
phone = "+77010000000", plan = "start",
});
s.GetProperty("plan").GetString().Should().Be("start");
s.GetProperty("freeTrialEndsAt").ValueKind.Should().Be(JsonValueKind.String, "дата бесплатного периода проставлена");
// Конфигурация «Бизнес» не относится к «Старт» — поля null.
s.GetProperty("planShops").ValueKind.Should().Be(JsonValueKind.Null);
s.GetProperty("planEmployees").ValueKind.Should().Be(JsonValueKind.Null);
}
[Fact]
public async Task Unknown_plan_falls_back_to_start()
{
var api = new ApiActor(_factory.CreateClient());
var slug = $"plan-unk-{Guid.NewGuid():N}";
var s = await SignupWithPlanAndGetSettingsAsync(api, slug, new
{
email = $"{slug}@example.kz", password = "Passw0rd!", organizationName = $"Org {slug}",
phone = "+77010000000", plan = "enterprise-xxl",
});
s.GetProperty("plan").GetString().Should().Be("start", "неизвестный тариф нормализуется к «start»");
}
}

View file

@ -0,0 +1,74 @@
using System.Text.Json;
using FluentAssertions;
using foodmarket.Domain.Registry;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Infrastructure.Registry;
using foodmarket.IntegrationTests.Support;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Копия «Реестра товаров» в каталог магазина должна помечать первый
/// штрихкод «Основным» (IsPrimary). Товар с единственным штрихкодом раньше
/// оставался без «Основного» (баг, fix #7).</summary>
[Collection(ApiCollection.Name)]
public class RegistryCopyPrimaryBarcodeTests
{
private readonly ApiFactory _factory;
public RegistryCopyPrimaryBarcodeTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
[Fact]
public async Task Copy_sets_single_barcode_as_primary()
{
var api = new ApiActor(_factory.CreateClient());
var slug = $"regcopy-{Guid.NewGuid():N}";
await api.SignupAndLoginAsync(slug);
var settings = await api.GetJsonAsync("/api/organization/settings");
var orgId = Guid.Parse(settings.GetProperty("id").GetString()!);
var barcode = RandomBarcode();
var cardName = $"REG-{slug}";
// Готовим карточку реестра с ОДНИМ штрихкодом и запускаем копию напрямую
// (Hangfire в тестах выключен, поэтому зовём сервис сами).
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var unitId = await db.UnitsOfMeasure.AsNoTracking().Select(u => u.Id).FirstAsync();
db.ProductRegistryCards.Add(new ProductRegistryCard
{
Name = cardName,
UnitOfMeasureId = unitId,
Cost = 100m,
PurchasePrice = 100m,
RetailPrice = 150m,
Source = RegistryCardSource.Shop,
Barcodes = new List<ProductRegistryBarcode> { new() { Code = barcode } },
});
await db.SaveChangesAsync();
// Регистрация выставила RegistryCopyState=1; копия отработает.
var svc = scope.ServiceProvider.GetRequiredService<ProductRegistryService>();
await svc.CopyRegistryToOrgAsync(orgId, default);
}
// Находим скопированный товар по штрихкоду и проверяем «Основной».
var found = await api.GetJsonAsync($"/api/catalog/products?pageSize=50&search={cardName}");
var items = found.TryGetProperty("items", out var it) ? it : found;
var product = items.EnumerateArray()
.FirstOrDefault(p => p.GetProperty("name").GetString() == cardName);
product.ValueKind.Should().Be(JsonValueKind.Object, "товар из реестра скопирован в каталог");
var detail = await api.GetJsonAsync($"/api/catalog/products/{product.GetProperty("id").GetString()}");
var barcodes = detail.GetProperty("barcodes").EnumerateArray().ToList();
barcodes.Should().ContainSingle(b => b.GetProperty("code").GetString() == barcode);
barcodes.Count(b => b.GetProperty("isPrimary").GetBoolean()).Should().Be(1, "ровно один штрихкод — «Основной»");
barcodes.Single(b => b.GetProperty("code").GetString() == barcode)
.GetProperty("isPrimary").GetBoolean().Should().BeTrue("единственный штрихкод должен быть «Основным»");
}
}