fix(кабинет): этап «Настройка кабинета» — 15 правок (прогон 14.10)
Some checks failed
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 Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Docker API / Build + push API (push) Has been cancelled
Docker API / Deploy API on stage (push) Has been cancelled

1. Реквизиты организации: БИН/ИИН (12 цифр, проверка), юр.адрес, «Плательщик
   НДС» — в Настройках → Организация, сохраняются и отдаются в API (Organization.
   IsVatPayer + Bin/Address в OrgSettings).
2. Склад при регистрации — адрес пустой (была заглушка «Алматы, ул. Пример 1»);
   миграция чистит заглушку у существующих складов.
3. Кассы: блок скачивания кассы скрыт, пока нет ссылок на сборки (убраны
   мёртвые кнопки и технический текст про конфигурацию сервера).
4. Счета: в списке показывается валюта счёта (а не ₸ орг.); новый счёт по
   умолчанию в валюте организации (KZT), не первой в справочнике. Миграция:
   счета KZ-организаций в BYN без движений → KZT.
5. «Дата начала учёта» берётся по локальной дате (была UTC → ночью подставляла
   вчера). todayIso() переведён на локальное время.
6. Esc в выпадающем списке закрывает только список, не весь диалог (Select гасит
   всплытие + Modal закрывается лишь при !defaultPrevented).
7. Счёт-касса: подпись «Магазин (к которому относится касса)» вместо «привязана
   к точке» — список показывает склады/магазины.
8. Точка продаж: поля «Счёт для наличных» и «Счёт для оплаты картой»; автооткрытие
   смены берёт счета из точки (наличные→Cash, карта→Acquiring/Bank).
9. PIN кассира: в карточке сотрудника задать/сменить/снять PIN; в списке «PIN
   задан»; вход grant_type=pin по точке (сервер уже поддерживал).
10. «Сбросить пароль» в карточке сотрудника → новый временный пароль один раз.
11. Владелец в «Сотрудниках» — «Владелец» вместо «<название> Owner» (signup +
    миграция существующих).
12. Роли: матрица прав дополнена недостающими 6 правами (перемещения, деньги/
    смены, маркетинг) — цифра «N/38» теперь совпадает с галочками; текст без
    жаргона «CRUD».
13. Настройки: блок «Демо-данные» — только суперадмину; «Креды per-tenant» →
    человеческий текст.
15. Форма входа: сообщения «Укажите email» / «Введите пароль» (как в регистрации).
16. Суммы счетов без копеек, если в организации выключены дробные цены
    (useFormatCurrency учитывает allowFractionalPrices).

Тесты: CabinetSetupTests (реквизиты+валидация БИН, имя владельца, счета точки,
PIN+вход grant_type=pin, сброс пароля) 5/5; регрессия Plan/Markup/RegistryCopy 7/7.
Код весовых (27+5) НЕ трогал — весы на паузе. П.14 — см. отчёт (блок продажи в
минус не ставится; поведение «разрешено с предупреждением» уже соответствует).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-14 01:55:29 +05:00
parent b3daa889f4
commit 519e4073df
22 changed files with 588 additions and 62 deletions

View file

@ -142,7 +142,9 @@ public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput inpu
_db.Employees.Add(new Employee _db.Employees.Add(new Employee
{ {
OrganizationId = org.Id, UserId = user.Id, OrganizationId = org.Id, UserId = user.Id,
LastName = input.OrganizationName.Trim(), FirstName = "Owner", // Имя владельца по умолчанию «Владелец» (редактируемо), без англ. «Owner»
// и без дублирования названия магазина (п.11).
LastName = "Владелец", FirstName = "",
Position = "Владелец", Email = input.Email.Trim(), Position = "Владелец", Email = input.Email.Trim(),
RoleId = adminRole.Id, IsActive = true, RoleId = adminRole.Id, IsActive = true,
}); });

View file

@ -44,7 +44,8 @@ public async Task<ActionResult<PagedResult<RetailPointDto>>> List([FromQuery] Pa
var items = await q var items = await q
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(r => new RetailPointDto(r.Id, r.Name, r.Code, r.StoreId, r.Store!.Name, .Select(r => new RetailPointDto(r.Id, r.Name, r.Code, r.StoreId, r.Store!.Name,
r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive)) r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive,
r.CashAccountId, r.CardAccountId))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<RetailPointDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<RetailPointDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -54,7 +55,8 @@ public async Task<ActionResult<RetailPointDto>> Get(Guid id, CancellationToken c
{ {
var r = await _db.RetailPoints.Include(x => x.Store).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); var r = await _db.RetailPoints.Include(x => x.Store).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return r is null ? NotFound() : new RetailPointDto(r.Id, r.Name, r.Code, r.StoreId, r.Store!.Name, return r is null ? NotFound() : new RetailPointDto(r.Id, r.Name, r.Code, r.StoreId, r.Store!.Name,
r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive); r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive,
r.CashAccountId, r.CardAccountId);
} }
[HttpPost, RequiresPermission("RetailPointsManage")] [HttpPost, RequiresPermission("RetailPointsManage")]
@ -69,12 +71,14 @@ public async Task<ActionResult<RetailPointDto>> Create([FromBody] RetailPointInp
Address = input.Address, Phone = input.Phone, Address = input.Address, Phone = input.Phone,
FiscalSerial = input.FiscalSerial, FiscalRegNumber = input.FiscalRegNumber, FiscalSerial = input.FiscalSerial, FiscalRegNumber = input.FiscalRegNumber,
IsActive = input.IsActive, IsActive = input.IsActive,
CashAccountId = input.CashAccountId, CardAccountId = input.CardAccountId,
}; };
_db.RetailPoints.Add(e); _db.RetailPoints.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 RetailPointDto(e.Id, e.Name, e.Code, e.StoreId, store.Name, new RetailPointDto(e.Id, e.Name, e.Code, e.StoreId, store.Name,
e.Address, e.Phone, e.FiscalSerial, e.FiscalRegNumber, e.IsActive)); e.Address, e.Phone, e.FiscalSerial, e.FiscalRegNumber, e.IsActive,
e.CashAccountId, e.CardAccountId));
} }
[HttpPut("{id:guid}"), RequiresPermission("RetailPointsManage")] [HttpPut("{id:guid}"), RequiresPermission("RetailPointsManage")]
@ -90,6 +94,8 @@ public async Task<IActionResult> Update(Guid id, [FromBody] RetailPointInput inp
e.FiscalSerial = input.FiscalSerial; e.FiscalSerial = input.FiscalSerial;
e.FiscalRegNumber = input.FiscalRegNumber; e.FiscalRegNumber = input.FiscalRegNumber;
e.IsActive = input.IsActive; e.IsActive = input.IsActive;
e.CashAccountId = input.CashAccountId;
e.CardAccountId = input.CardAccountId;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
} }

View file

@ -45,7 +45,11 @@ public record EmployeeDto(
// active | fired | deleted — производное от IsActive/IsDeleted, удобно для UI-бейджа // active | fired | deleted — производное от IsActive/IsDeleted, удобно для UI-бейджа
string Status, string Status,
IReadOnlyList<Guid> RetailPointIds, IReadOnlyList<Guid> RetailPointIds,
bool IsOwner, bool IsSelf); bool IsOwner, bool IsSelf,
// PIN кассира задан (сам PIN не отдаём — только факт).
bool PinSet,
// У сотрудника есть учётная запись (логин) — можно сбросить пароль.
bool HasAccount);
public record EmployeeInput( public record EmployeeInput(
string LastName, string FirstName, string? MiddleName, string LastName, string FirstName, string? MiddleName,
@ -112,7 +116,9 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"), e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(), e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
e.UserId != null && ownerUserId == e.UserId, e.UserId != null && ownerUserId == e.UserId,
e.UserId != null && currentUserId != null && e.UserId == currentUserId)) e.UserId != null && currentUserId != null && e.UserId == currentUserId,
e.PinHash != null,
e.UserId != null))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<EmployeeDto> return new PagedResult<EmployeeDto>
{ Items = items, Total = total, Page = req.Page, PageSize = req.Take }; { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
@ -263,6 +269,40 @@ public async Task<IActionResult> SetPin(Guid id, [FromBody] SetPinInput input, C
return Ok(new { ok = true, pinSet = true }); return Ok(new { ok = true, pinSet = true });
} }
public record ResetPasswordResult(string Password);
/// <summary>Сбросить пароль сотрудника: генерируем новый временный пароль,
/// показываем один раз (как при создании учётки). Старый пароль
/// недействителен; активные сессии сотрудника гасим (revoke токенов).
/// Работает только для сотрудника с учётной записью.</summary>
[HttpPost("{id:guid}/reset-password"), Authorize(Roles = "SuperAdmin,Admin")]
public async Task<ActionResult<ResetPasswordResult>> ResetPassword(Guid id, CancellationToken ct)
{
var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
if (e.UserId is null)
return BadRequest(new { error = "У сотрудника нет учётной записи — сбрасывать нечего." });
var user = await _userMgr.FindByIdAsync(e.UserId.Value.ToString());
if (user is null) return BadRequest(new { error = "Учётная запись не найдена." });
var newPassword = GenerateTempPassword();
// RemovePassword + AddPassword, как в реактивации при регистрации: не
// зависит от token-провайдеров (GeneratePasswordResetToken требует их).
await _userMgr.RemovePasswordAsync(user);
var res = await _userMgr.AddPasswordAsync(user, newPassword);
if (!res.Succeeded)
return BadRequest(new { error = string.Join("; ", res.Errors.Select(x => x.Description)) });
// Гасим активные сессии — старые токены с прежним паролем не должны жить.
await _db.Database.ExecuteSqlRawAsync(
"UPDATE \"OpenIddictTokens\" SET \"Status\" = 'revoked' WHERE \"Subject\" = {0} AND \"Status\" = 'valid'",
user.Id.ToString());
await _audit.LogAsync("ResetPassword", "Employee", e.Id, new { e.LastName, e.FirstName, e.Email }, ct);
return new ResetPasswordResult(newPassword);
}
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")] [HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")]
public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input, CancellationToken ct)
{ {
@ -470,7 +510,9 @@ private Task DeactivateLinkedUserAsync(Guid? userId, CancellationToken ct)
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"), e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(), e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
e.UserId != null && ownerUserId == e.UserId, e.UserId != null && ownerUserId == e.UserId,
e.UserId != null && currentUserId != null && e.UserId == currentUserId)) e.UserId != null && currentUserId != null && e.UserId == currentUserId,
e.PinHash != null,
e.UserId != null))
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
} }

View file

@ -61,7 +61,11 @@ public record OrgSettingsDto(
int? PlanShops, int? PlanShops,
int? PlanCashes, int? PlanCashes,
int? PlanWarehouses, int? PlanWarehouses,
int? PlanEmployees); int? PlanEmployees,
// Реквизиты организации (как у юрлица в МойСклад).
string? Bin,
string? Address,
bool IsVatPayer);
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId). // DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
public record OrgSettingsInput( public record OrgSettingsInput(
@ -95,7 +99,12 @@ public record OrgSettingsInput(
int? NegStockNotifyIntervalMinutes = null, int? NegStockNotifyIntervalMinutes = null,
int? NegStockQuietFromHour = null, int? NegStockQuietFromHour = null,
int? NegStockQuietToHour = null, int? NegStockQuietToHour = null,
int? NegStockEscalateHours = null); int? NegStockEscalateHours = null,
// Реквизиты — опционально (round-trip-безопасно): применяются только
// если прислан соответствующий ключ.
string? Bin = null,
string? Address = null,
bool? IsVatPayer = null);
[HttpGet("settings")] [HttpGet("settings")]
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct) public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
@ -118,6 +127,18 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
.FirstOrDefaultAsync(o => o.Id == orgId, ct); .FirstOrDefaultAsync(o => o.Id == orgId, ct);
if (o is null) return NotFound(); if (o is null) return NotFound();
// Реквизиты организации. БИН/ИИН — ровно 12 цифр (или пусто).
if (input.Bin is not null)
{
var bin = input.Bin.Trim();
if (bin.Length > 0 && !System.Text.RegularExpressions.Regex.IsMatch(bin, @"^\d{12}$"))
return BadRequest(new { error = "БИН/ИИН должен состоять из 12 цифр.", field = "bin" });
o.Bin = bin.Length == 0 ? null : bin;
}
if (input.Address is not null)
o.Address = string.IsNullOrWhiteSpace(input.Address) ? null : input.Address.Trim();
if (input.IsVatPayer is bool vp) o.IsVatPayer = vp;
o.Name = input.Name; o.Name = input.Name;
o.CountryCode = input.CountryCode; o.CountryCode = input.CountryCode;
// Валюта организации жёстко следует за страной — не принимается от клиента. // Валюта организации жёстко следует за страной — не принимается от клиента.
@ -212,5 +233,8 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
o.PlanShops, o.PlanShops,
o.PlanCashes, o.PlanCashes,
o.PlanWarehouses, o.PlanWarehouses,
o.PlanEmployees); o.PlanEmployees,
o.Bin,
o.Address,
o.IsVatPayer);
} }

View file

@ -645,8 +645,14 @@ private async Task<IActionResult> PostCoreAsync(Guid id, CancellationToken ct)
// Cash орг.) и открываем смену с нулевым разменом (размен можно // Cash орг.) и открываем смену с нулевым разменом (размен можно
// внести позже внесением). Если счёта нет — просим открыть вручную. // внести позже внесением). Если счёта нет — просим открыть вручную.
var rp = await _db.RetailPoints.AsNoTracking() var rp = await _db.RetailPoints.AsNoTracking()
.Where(x => x.Id == rpId).Select(x => new { x.StoreId }).FirstOrDefaultAsync(ct); .Where(x => x.Id == rpId)
var cashAccountId = await _db.MoneyAccounts .Select(x => new { x.StoreId, x.CashAccountId, x.CardAccountId })
.FirstOrDefaultAsync(ct);
// Счёт наличных берём из настройки точки («Счёт для наличных»),
// иначе — Cash на складе точки / первый активный Cash орг.
var cashAccountId = rp!.CashAccountId;
if (cashAccountId is null)
cashAccountId = await _db.MoneyAccounts
.Where(a => a.Kind == foodmarket.Domain.Money.MoneyAccountKind.Cash && a.IsActive .Where(a => a.Kind == foodmarket.Domain.Money.MoneyAccountKind.Cash && a.IsActive
&& (a.StoreId == rp!.StoreId || a.StoreId == null)) && (a.StoreId == rp!.StoreId || a.StoreId == null))
.OrderByDescending(a => a.StoreId == rp!.StoreId) .OrderByDescending(a => a.StoreId == rp!.StoreId)
@ -657,7 +663,8 @@ private async Task<IActionResult> PostCoreAsync(Guid id, CancellationToken ct)
error = "На этой точке продаж не открыта кассовая смена, и не найден денежный счёт для автооткрытия. Откройте смену вручную.", error = "На этой точке продаж не открыта кассовая смена, и не найден денежный счёт для автооткрытия. Откройте смену вручную.",
code = "cash-shift-required", code = "cash-shift-required",
}); });
var opened = await _money.OpenShiftAsync(rpId, cashAcc, null, 0m, CurrentUserId(), ct); // Счёт для карт — из настройки точки («Счёт для оплаты картой»).
var opened = await _money.OpenShiftAsync(rpId, cashAcc, rp!.CardAccountId, 0m, CurrentUserId(), ct);
if (opened.Status != foodmarket.Application.Money.MoneyServiceStatus.Ok || opened.ShiftId is not { } newShift) if (opened.Status != foodmarket.Application.Money.MoneyServiceStatus.Ok || opened.ShiftId is not { } newShift)
return Conflict(new { error = opened.Message ?? "Не удалось открыть смену автоматически.", code = "cash-shift-required" }); return Conflict(new { error = opened.Message ?? "Не удалось открыть смену автоматически.", code = "cash-shift-required" });
_log.LogInformation("Смена автооткрыта первой продажей: shift={ShiftId} point={PointId}", newShift, rpId); _log.LogInformation("Смена автооткрыта первой продажей: shift={ShiftId} point={PointId}", newShift, rpId);

View file

@ -308,7 +308,9 @@ public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
Name = "Основной склад", Name = "Основной склад",
Code = "MAIN", Code = "MAIN",
IsMain = true, IsMain = true,
Address = "Алматы, ул. Пример 1", // Адрес пустой — магазин заполнит сам (п.2). Раньше была заглушка
// «Алматы, ул. Пример 1», которая выглядела как реальный адрес.
Address = null,
}; };
db.Stores.Add(mainStore); db.Stores.Add(mainStore);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);

View file

@ -25,7 +25,9 @@ public record StoreDto(
public record RetailPointDto( public record RetailPointDto(
Guid Id, string Name, string? Code, Guid StoreId, string? StoreName, Guid Id, string Name, string? Code, Guid StoreId, string? StoreName,
string? Address, string? Phone, string? FiscalSerial, string? FiscalRegNumber, bool IsActive); string? Address, string? Phone, string? FiscalSerial, string? FiscalRegNumber, bool IsActive,
// Счета для денег смены этой точки (наличные/карта).
Guid? CashAccountId = null, Guid? CardAccountId = null);
public record ProductGroupDto( public record ProductGroupDto(
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, Guid Id, string Name, Guid? ParentId, string Path, int SortOrder,
@ -82,7 +84,8 @@ public record StoreInput(
public record RetailPointInput( public record RetailPointInput(
string Name, string? Code, Guid StoreId, string Name, string? Code, Guid StoreId,
string? Address = null, string? Phone = null, string? Address = null, string? Phone = null,
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true); string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true,
Guid? CashAccountId = null, Guid? CardAccountId = null);
public record ProductGroupInput( public record ProductGroupInput(
string Name, Guid? ParentId, int SortOrder = 0, string Name, Guid? ParentId, int SortOrder = 0,
[Range(0, 1000)] decimal? MarkupPercent = null); [Range(0, 1000)] decimal? MarkupPercent = null);

View file

@ -14,6 +14,13 @@ public class RetailPoint : TenantEntity
public string? FiscalSerial { get; set; } // серийный номер ККМ (заполнится когда подключим фискализацию) public string? FiscalSerial { get; set; } // серийный номер ККМ (заполнится когда подключим фискализацию)
public string? FiscalRegNumber { get; set; } // регистрационный номер в ОФД public string? FiscalRegNumber { get; set; } // регистрационный номер в ОФД
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
/// <summary>Счёт (MoneyAccount, Kind=Cash), куда идёт наличная выручка смены
/// этой точки. При открытии смены подставляется по умолчанию. null — не задан.</summary>
public Guid? CashAccountId { get; set; }
/// <summary>Счёт (MoneyAccount, Kind=Acquiring/Bank), куда идёт выручка по
/// картам этой точки (эквайринг). При открытии смены подставляется. null — не задан.</summary>
public Guid? CardAccountId { get; set; }
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary> /// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
public string? MoySkladExternalId { get; set; } public string? MoySkladExternalId { get; set; }
} }

View file

@ -13,6 +13,11 @@ public class Organization : Entity
public string? Email { get; set; } public string? Email { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
/// <summary>Плательщик НДС (да/нет) — как у юрлица в МойСклад. По умолчанию
/// false. На ставку НДС товаров сейчас не влияет (та берётся из страны),
/// это реквизит организации для документов/отчётности.</summary>
public bool IsVatPayer { get; set; }
/// <summary>Архивирована ли организация. Архивные не видны пользователям /// <summary>Архивирована ли организация. Архивные не видны пользователям
/// но данные сохраняются. Из архива можно восстановить или (после 30 дней) /// но данные сохраняются. Из архива можно восстановить или (после 30 дней)
/// удалить навсегда — этим управляет SuperAdmin.</summary> /// удалить навсегда — этим управляет SuperAdmin.</summary>

View file

@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Этап «Настройка кабинета» (14.10): реквизит «Плательщик НДС»,
/// счета точки продаж (наличные/карта), и точечные правки данных:
/// — п.2: чистим адрес-заглушку «Алматы, ул. Пример 1» у складов;
/// — п.4: счета KZ-организаций в BYN без движений переводим в KZT;
/// — п.11: имя владельца-сотрудника «&lt;название&gt; Owner» → «Владелец».</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20261014010000_Phase52_CabinetSetupFixes")]
public partial class Phase52_CabinetSetupFixes : Migration
{
protected override void Up(MigrationBuilder b)
{
// ─ Схема ─
b.Sql(@"
ALTER TABLE public.organizations
ADD COLUMN IF NOT EXISTS ""IsVatPayer"" boolean NOT NULL DEFAULT false;
ALTER TABLE public.retail_points
ADD COLUMN IF NOT EXISTS ""CashAccountId"" uuid NULL,
ADD COLUMN IF NOT EXISTS ""CardAccountId"" uuid NULL;");
// ─ п.2: адрес-заглушка склада → пусто ─
b.Sql(@"UPDATE public.stores SET ""Address"" = NULL
WHERE ""Address"" = 'Алматы, ул. Пример 1';");
// ─ п.4: BYN → KZT у счетов казахстанских организаций без движений ─
b.Sql(@"
UPDATE public.money_accounts a
SET ""CurrencyId"" = kzt.""Id"", ""UpdatedAt"" = now()
FROM public.organizations o, public.currencies byn, public.currencies kzt
WHERE a.""OrganizationId"" = o.""Id""
AND o.""CountryCode"" = 'KZ'
AND byn.""Code"" = 'BYN' AND a.""CurrencyId"" = byn.""Id""
AND kzt.""Code"" = 'KZT'
AND NOT EXISTS (SELECT 1 FROM public.money_transactions m WHERE m.""AccountId"" = a.""Id"");");
// ─ п.11: имя владельца «<название орг> Owner» → «Владелец» ─
b.Sql(@"
UPDATE public.employees e
SET ""LastName"" = 'Владелец', ""FirstName"" = ''
FROM public.organizations o
WHERE e.""OrganizationId"" = o.""Id""
AND e.""UserId"" IS NOT NULL
AND e.""UserId"" = o.""AccountOwnerUserId""
AND e.""LastName"" = o.""Name""
AND e.""FirstName"" = 'Owner';");
}
protected override void Down(MigrationBuilder b) => b.Sql(@"
ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""IsVatPayer"";
ALTER TABLE public.retail_points
DROP COLUMN IF EXISTS ""CashAccountId"",
DROP COLUMN IF EXISTS ""CardAccountId"";");
}
}

View file

@ -231,7 +231,13 @@ export function Select({
placeholder="Поиск…" placeholder="Поиск…"
className="w-full h-8 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 text-base sm:text-sm focus:outline-none focus:ring-1 focus:ring-[var(--color-brand)]" className="w-full h-8 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 text-base sm:text-sm focus:outline-none focus:ring-1 focus:ring-[var(--color-brand)]"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); setQuery('') } if (e.key === 'Escape') {
// Esc закрывает ТОЛЬКО выпадающий список, не весь диалог.
// Modal слушает keydown на document; гасим всплытие и помечаем
// событие обработанным (Modal закрывается лишь при !defaultPrevented).
e.preventDefault(); e.stopPropagation(); e.nativeEvent.stopImmediatePropagation()
setOpen(false); setQuery('')
}
else if (e.key === 'ArrowDown') { else if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
setHighlight((h) => Math.min(h + 1, Math.max(0, filtered.length - 1))) setHighlight((h) => Math.min(h + 1, Math.max(0, filtered.length - 1)))
@ -416,7 +422,13 @@ export function AsyncSelect({
placeholder="Поиск…" placeholder="Поиск…"
className="w-full h-8 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 text-base sm:text-sm focus:outline-none focus:ring-1 focus:ring-[var(--color-brand)]" className="w-full h-8 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 text-base sm:text-sm focus:outline-none focus:ring-1 focus:ring-[var(--color-brand)]"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); setQuery('') } if (e.key === 'Escape') {
// Esc закрывает ТОЛЬКО выпадающий список, не весь диалог.
// Modal слушает keydown на document; гасим всплытие и помечаем
// событие обработанным (Modal закрывается лишь при !defaultPrevented).
e.preventDefault(); e.stopPropagation(); e.nativeEvent.stopImmediatePropagation()
setOpen(false); setQuery('')
}
else if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight((h) => Math.min(h + 1, Math.max(0, options.length - 1))) } else if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight((h) => Math.min(h + 1, Math.max(0, options.length - 1))) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight((h) => Math.max(0, h - 1)) } else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight((h) => Math.max(0, h - 1)) }
else if (e.key === 'Enter') { else if (e.key === 'Enter') {

View file

@ -20,7 +20,10 @@ export function Modal({
}: ModalProps) { }: ModalProps) {
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const onEsc = (e: KeyboardEvent) => e.key === 'Escape' && onClose() // Esc закрывает диалог, НО не когда событие уже обработано (открытый
// выпадающий список внутри диалога гасит Esc и ставит defaultPrevented —
// тогда закрывается только список, а диалог остаётся). См. Select в Field.tsx.
const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape' && !e.defaultPrevented) onClose() }
document.addEventListener('keydown', onEsc) document.addEventListener('keydown', onEsc)
return () => document.removeEventListener('keydown', onEsc) return () => document.removeEventListener('keydown', onEsc)
}, [open, onClose]) }, [open, onClose])

View file

@ -40,6 +40,7 @@ export interface Store {
export interface RetailPoint { export interface RetailPoint {
id: string; name: string; code: string | null; storeId: string; storeName: string | null; id: string; name: string; code: string | null; storeId: string; storeName: string | null;
address: string | null; phone: string | null; fiscalSerial: string | null; fiscalRegNumber: string | null; isActive: boolean address: string | null; phone: string | null; fiscalSerial: string | null; fiscalRegNumber: string | null; isActive: boolean
cashAccountId: string | null; cardAccountId: string | null
} }
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; markupPercent: number | null; organizationId: string | null; isSystem?: boolean } export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; markupPercent: number | null; organizationId: string | null; isSystem?: boolean }
export interface Counterparty { export interface Counterparty {

View file

@ -34,18 +34,23 @@ export function useFormatCurrency() {
// для kz-розницы (исторически большинство экранов на русском). // для kz-розницы (исторически большинство экранов на русском).
const locale = i18n.language || 'ru-RU' const locale = i18n.language || 'ru-RU'
const symbol = data?.defaultCurrencySymbol ?? '₸' const symbol = data?.defaultCurrencySymbol ?? '₸'
// Если в организации выключены дробные цены («целые тенге, без копеек») —
// по умолчанию показываем суммы без копеек. Явный opts.decimals/compact
// всегда имеет приоритет. Пока настройки не загрузились — ведём себя как
// с дробными (2 знака), чтобы не «прыгало» при включённых копейках.
const defaultDecimals = data && data.allowFractionalPrices === false ? 0 : 2
return useCallback( return useCallback(
(value: number | null | undefined, opts?: FormatOptions): string => { (value: number | null | undefined, opts?: FormatOptions): string => {
if (value == null || isNaN(value)) return '—' if (value == null || isNaN(value)) return '—'
const decimals = opts?.decimals ?? (opts?.compact ? 0 : 2) const decimals = opts?.decimals ?? (opts?.compact ? 0 : defaultDecimals)
const formatted = value.toLocaleString(locale, { const formatted = value.toLocaleString(locale, {
minimumFractionDigits: decimals, minimumFractionDigits: decimals,
maximumFractionDigits: decimals, maximumFractionDigits: decimals,
}) })
return `${formatted} ${symbol}` return `${formatted} ${symbol}`
}, },
[locale, symbol], [locale, symbol, defaultDecimals],
) )
} }

View file

@ -32,6 +32,10 @@ export interface OrgSettings {
planCashes: number | null planCashes: number | null
planWarehouses: number | null planWarehouses: number | null
planEmployees: number | null planEmployees: number | null
// Реквизиты организации.
bin: string | null
address: string | null
isVatPayer: boolean
} }
export function useOrgSettings() { export function useOrgSettings() {

View file

@ -20,11 +20,13 @@ export interface RolePermissions {
demandsView: boolean; demandsEdit: boolean; demandsPost: boolean demandsView: boolean; demandsEdit: boolean; demandsPost: boolean
retailSalesOperate: boolean; retailSalesRefund: boolean retailSalesOperate: boolean; retailSalesRefund: boolean
counterpartiesView: boolean; counterpartiesEdit: boolean; counterpartiesDelete: boolean counterpartiesView: boolean; counterpartiesEdit: boolean; counterpartiesDelete: boolean
stocksView: boolean; inventoryEdit: boolean; lossEdit: boolean; enterEdit: boolean stocksView: boolean; inventoryEdit: boolean; lossEdit: boolean; enterEdit: boolean; transferEdit: boolean
reportsView: boolean; reportsFinanceView: boolean; reportsStockView: boolean reportsView: boolean; reportsFinanceView: boolean; reportsStockView: boolean
orgSettingsManage: boolean; employeesManage: boolean; rolesManage: boolean orgSettingsManage: boolean; employeesManage: boolean; rolesManage: boolean
storesManage: boolean; retailPointsManage: boolean; cashRegistersManage: boolean storesManage: boolean; retailPointsManage: boolean; cashRegistersManage: boolean
integrationsManage: boolean integrationsManage: boolean
loyaltyManage: boolean; promotionsManage: boolean
moneyView: boolean; moneyEdit: boolean; cashShiftOperate: boolean
} }
export interface EmployeeRoleDto { export interface EmployeeRoleDto {
@ -47,11 +49,13 @@ const blankPerms = (): RolePermissions => ({
demandsView: false, demandsEdit: false, demandsPost: false, demandsView: false, demandsEdit: false, demandsPost: false,
retailSalesOperate: false, retailSalesRefund: false, retailSalesOperate: false, retailSalesRefund: false,
counterpartiesView: false, counterpartiesEdit: false, counterpartiesDelete: false, counterpartiesView: false, counterpartiesEdit: false, counterpartiesDelete: false,
stocksView: false, inventoryEdit: false, lossEdit: false, enterEdit: false, stocksView: false, inventoryEdit: false, lossEdit: false, enterEdit: false, transferEdit: false,
reportsView: false, reportsFinanceView: false, reportsStockView: false, reportsView: false, reportsFinanceView: false, reportsStockView: false,
orgSettingsManage: false, employeesManage: false, rolesManage: false, orgSettingsManage: false, employeesManage: false, rolesManage: false,
storesManage: false, retailPointsManage: false, cashRegistersManage: false, storesManage: false, retailPointsManage: false, cashRegistersManage: false,
integrationsManage: false, integrationsManage: false,
loyaltyManage: false, promotionsManage: false,
moneyView: false, moneyEdit: false, cashShiftOperate: false,
}) })
export const allPerms = (): RolePermissions => ({ export const allPerms = (): RolePermissions => ({
@ -61,11 +65,13 @@ export const allPerms = (): RolePermissions => ({
demandsView: true, demandsEdit: true, demandsPost: true, demandsView: true, demandsEdit: true, demandsPost: true,
retailSalesOperate: true, retailSalesRefund: true, retailSalesOperate: true, retailSalesRefund: true,
counterpartiesView: true, counterpartiesEdit: true, counterpartiesDelete: true, counterpartiesView: true, counterpartiesEdit: true, counterpartiesDelete: true,
stocksView: true, inventoryEdit: true, lossEdit: true, enterEdit: true, stocksView: true, inventoryEdit: true, lossEdit: true, enterEdit: true, transferEdit: true,
reportsView: true, reportsFinanceView: true, reportsStockView: true, reportsView: true, reportsFinanceView: true, reportsStockView: true,
orgSettingsManage: true, employeesManage: true, rolesManage: true, orgSettingsManage: true, employeesManage: true, rolesManage: true,
storesManage: true, retailPointsManage: true, cashRegistersManage: true, storesManage: true, retailPointsManage: true, cashRegistersManage: true,
integrationsManage: true, integrationsManage: true,
loyaltyManage: true, promotionsManage: true,
moneyView: true, moneyEdit: true, cashShiftOperate: true,
}) })
const blankForm = (): Form => ({ name: '', description: '', isSystem: false, permissions: blankPerms() }) const blankForm = (): Form => ({ name: '', description: '', isSystem: false, permissions: blankPerms() })
@ -102,6 +108,16 @@ export const PERM_GROUPS: { title: string; perms: { key: keyof RolePermissions;
{ key: 'inventoryEdit', label: 'Инвентаризация' }, { key: 'inventoryEdit', label: 'Инвентаризация' },
{ key: 'lossEdit', label: 'Списание' }, { key: 'lossEdit', label: 'Списание' },
{ key: 'enterEdit', label: 'Оприходование' }, { key: 'enterEdit', label: 'Оприходование' },
{ key: 'transferEdit', label: 'Перемещения между складами' },
]},
{ title: 'Деньги и кассовые смены', perms: [
{ key: 'moneyView', label: 'Просмотр счетов и движений' },
{ key: 'moneyEdit', label: 'Редактирование счетов и ордеров' },
{ key: 'cashShiftOperate', label: 'Открытие/закрытие кассовой смены' },
]},
{ title: 'Маркетинг', perms: [
{ key: 'loyaltyManage', label: 'Лояльность (бонусы, карты)' },
{ key: 'promotionsManage', label: 'Акции и скидки' },
]}, ]},
{ title: 'Отчёты', perms: [ { title: 'Отчёты', perms: [
{ key: 'reportsView', label: 'Сводные отчёты' }, { key: 'reportsView', label: 'Сводные отчёты' },
@ -141,7 +157,7 @@ export function EmployeeRolesPage() {
<> <>
<ListPageShell <ListPageShell
title="Роли сотрудников" title="Роли сотрудников"
description="Системные роли — только просмотр прав. Кастомные роли — полный CRUD; можно создать на основе системной как шаблона." description="Системные роли можно только просматривать. Свои роли можно создавать, менять и удалять — в том числе на основе системной как образца."
actions={ actions={
<> <>
<SearchBar value={search} onChange={setSearch} /> <SearchBar value={search} onChange={setSearch} />

View file

@ -49,6 +49,10 @@ interface EmployeeDto {
isOwner: boolean isOwner: boolean
/** Текущий вошедший пользователь — это сам этот Employee. Нельзя удалить. */ /** Текущий вошедший пользователь — это сам этот Employee. Нельзя удалить. */
isSelf: boolean isSelf: boolean
/** Задан ли PIN кассира (быстрый вход на кассе). Сам PIN не отдаётся. */
pinSet: boolean
/** Есть ли учётная запись (логин) — можно сбросить пароль. */
hasAccount: boolean
} }
interface Form { interface Form {
@ -70,6 +74,8 @@ interface Form {
isActive: boolean isActive: boolean
retailPointIds: string[] retailPointIds: string[]
createAccount: boolean createAccount: boolean
/** Ввод нового PIN (write-only; текущий PIN не загружается). */
pin: string
} }
const blankForm = (): Form => ({ const blankForm = (): Form => ({
@ -79,6 +85,7 @@ const blankForm = (): Form => ({
roleId: '', isActive: true, roleId: '', isActive: true,
retailPointIds: [], retailPointIds: [],
createAccount: true, createAccount: true,
pin: '',
}) })
export function EmployeesPage() { export function EmployeesPage() {
@ -87,7 +94,9 @@ export function EmployeesPage() {
const [form, setForm] = useState<Form | null>(null) const [form, setForm] = useState<Form | null>(null)
// Сгенерированный пароль возвращается с сервера один раз — показываем // Сгенерированный пароль возвращается с сервера один раз — показываем
// в отдельной модалке, чтобы админ передал сотруднику. // в отдельной модалке, чтобы админ передал сотруднику.
const [createdAccount, setCreatedAccount] = useState<{ email: string; password: string } | null>(null) const [createdAccount, setCreatedAccount] = useState<{ email: string; password: string; reset?: boolean } | null>(null)
// Текущее состояние PIN в открытой форме (для статуса «PIN задан» в самой форме).
const [pinBusy, setPinBusy] = useState(false)
// Текущий открытый сотрудник — нужен чтобы знать isOwner/isSelf для футера // Текущий открытый сотрудник — нужен чтобы знать isOwner/isSelf для футера
// модалки (form содержит только редактируемые поля и не знает про маркеры). // модалки (form содержит только редактируемые поля и не знает про маркеры).
const [activeEmployee, setActiveEmployee] = useState<EmployeeDto | null>(null) const [activeEmployee, setActiveEmployee] = useState<EmployeeDto | null>(null)
@ -167,6 +176,33 @@ export function EmployeesPage() {
}) })
} }
// Сброс пароля (п.10): новый временный пароль показывается один раз.
const resetPassword = async () => {
if (!form?.id) return
try {
const res = await api.post<{ password: string }>(`${URL}/${form.id}/reset-password`)
setCreatedAccount({ email: activeEmployee?.email ?? '', password: res.data.password, reset: true })
} catch (e) {
setBlockedDelete({ title: 'Не удалось сбросить пароль', body: humanizeError(e as Error) })
}
}
// Задать/сменить/снять PIN кассира (п.9). pin=null — снять.
const applyPin = async (pin: string | null) => {
if (!form?.id) return
setPinBusy(true)
try {
await api.post(`${URL}/${form.id}/pin`, { pin })
await qc.invalidateQueries({ queryKey: [URL] })
setActiveEmployee((a) => (a ? { ...a, pinSet: !!pin } : a))
setForm((f) => (f ? { ...f, pin: '' } : f))
} catch (e) {
setBlockedDelete({ title: 'Не удалось сохранить PIN', body: humanizeError(e as Error) })
} finally {
setPinBusy(false)
}
}
// Фильтр по статусу сотрудника. По умолчанию показываем только активных // Фильтр по статусу сотрудника. По умолчанию показываем только активных
// и уволенных; «удалённые» — отдельный режим (read-only список архива). // и уволенных; «удалённые» — отдельный режим (read-only список архива).
const [statusFilter, setStatusFilter] = useState<'default' | 'active' | 'fired' | 'deleted' | 'all'>('default') const [statusFilter, setStatusFilter] = useState<'default' | 'active' | 'fired' | 'deleted' | 'all'>('default')
@ -216,7 +252,7 @@ export function EmployeesPage() {
salary: r.salary, salary: r.salary,
taxNumber: r.taxNumber ?? '', description: r.description ?? '', imageUrl: r.imageUrl ?? '', taxNumber: r.taxNumber ?? '', description: r.description ?? '', imageUrl: r.imageUrl ?? '',
roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds, roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds,
createAccount: false, createAccount: false, pin: '',
}) })
}} }}
columns={[ columns={[
@ -233,6 +269,11 @@ export function EmployeesPage() {
Главный администратор Главный администратор
</span> </span>
)} )}
{r.pinSet && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
PIN задан
</span>
)}
</div> </div>
{r.position && <div className="text-xs text-slate-500 dark:text-slate-400">{r.position}</div>} {r.position && <div className="text-xs text-slate-500 dark:text-slate-400">{r.position}</div>}
</div> </div>
@ -323,6 +364,9 @@ export function EmployeesPage() {
{form?.id && activeEmployee?.status === 'deleted' && ( {form?.id && activeEmployee?.status === 'deleted' && (
<span className="text-xs text-slate-500 dark:text-slate-400 italic">Сотрудник удалён — изменения недоступны.</span> <span className="text-xs text-slate-500 dark:text-slate-400 italic">Сотрудник удалён — изменения недоступны.</span>
)} )}
{form?.id && activeEmployee?.hasAccount && activeEmployee?.status !== 'deleted' && (
<Button variant="secondary" size="sm" onClick={resetPassword}>Сбросить пароль</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button> <Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.lastName || !form.firstName || !form.roleId}>Сохранить</Button> <Button onClick={save} disabled={!form?.lastName || !form.firstName || !form.roleId}>Сохранить</Button>
</> </>
@ -424,6 +468,30 @@ export function EmployeesPage() {
<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>
</div> </div>
)} )}
{isCashier && form.id && (
<div>
<div className="text-sm font-medium mb-1.5">
PIN для входа на кассе
{activeEmployee?.pinSet && <span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">PIN задан</span>}
</div>
<div className="flex items-end gap-2">
<div className="flex-1">
<TextInput value={form.pin} inputMode="numeric" maxLength={6} placeholder="4–6 цифр"
onChange={(e) => setForm({ ...form, pin: e.target.value.replace(/\D/g, '').slice(0, 6) })} />
</div>
<Button variant="secondary" size="sm" disabled={pinBusy || form.pin.length < 4}
onClick={() => applyPin(form.pin)}>
{activeEmployee?.pinSet ? 'Сменить PIN' : 'Задать PIN'}
</Button>
{activeEmployee?.pinSet && (
<Button variant="secondary" size="sm" disabled={pinBusy} onClick={() => applyPin(null)}>Снять</Button>
)}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
Быстрый вход кассира на кассе по PIN (без email/пароля). Нужен для входа grant_type=pin на привязанной точке.
</p>
</div>
)}
<Checkbox <Checkbox
label="Активен" label="Активен"
checked={form.isActive} checked={form.isActive}
@ -453,14 +521,15 @@ export function EmployeesPage() {
<Modal <Modal
open={!!createdAccount} open={!!createdAccount}
onClose={() => setCreatedAccount(null)} onClose={() => setCreatedAccount(null)}
title="Учётная запись создана" title={createdAccount?.reset ? 'Пароль сброшен' : 'Учётная запись создана'}
footer={<Button onClick={() => setCreatedAccount(null)}>Готово</Button>} footer={<Button onClick={() => setCreatedAccount(null)}>Готово</Button>}
> >
{createdAccount && ( {createdAccount && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-slate-600 dark:text-slate-300"> <p className="text-sm text-slate-600 dark:text-slate-300">
Передайте сотруднику данные для входа. Это окно показывается один раз — {createdAccount.reset
после закрытия пароль восстановить нельзя, придётся сбросить. ? 'Передайте сотруднику новый временный пароль. Старый больше не действует. Окно показывается один раз.'
: 'Передайте сотруднику данные для входа. Это окно показывается один раз — после закрытия пароль восстановить нельзя, придётся сбросить.'}
</p> </p>
<Field label="Логин (email)"> <Field label="Логин (email)">
<div className="flex gap-2"> <div className="flex gap-2">

View file

@ -4,7 +4,7 @@ import { login } from '@/lib/auth'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Logo } from '@/components/Logo' import { Logo } from '@/components/Logo'
import { QuickLoginBlock } from '@/components/QuickLoginBlock' import { QuickLoginBlock } from '@/components/QuickLoginBlock'
import { validateEmail, messages } from '@/lib/validation' import { validateEmail } from '@/lib/validation'
/** Публичный sentinel-«пароль» для клика по карточке быстрого входа. /** Публичный sentinel-«пароль» для клика по карточке быстрого входа.
* Должен совпадать с server-side QuickLoginOptions.SentinelPassword. * Должен совпадать с server-side QuickLoginOptions.SentinelPassword.
@ -61,8 +61,10 @@ export function LoginPage() {
function onBtnPointerCancel() { tapRef.current = null } function onBtnPointerCancel() { tapRef.current = null }
async function runSubmit() { async function runSubmit() {
const em = validateEmail(email) // Сообщения в стиле регистрации (п.15): пустые поля — «Укажите email» /
const pw = password ? null : messages.required // «Введите пароль», а не общий «Это поле обязательно…».
const em = !email.trim() ? 'Укажите email' : validateEmail(email)
const pw = password ? null : 'Введите пароль'
setEmailErr(em); setPasswordErr(pw) setEmailErr(em); setPasswordErr(pw)
if (em || pw) return if (em || pw) return
if (submittingRef.current) return if (submittingRef.current) return
@ -137,7 +139,7 @@ export function LoginPage() {
placeholder="name@example.kz" placeholder="name@example.kz"
value={email} value={email}
onChange={(e) => { setEmail(e.target.value); if (emailErr) setEmailErr(null) }} onChange={(e) => { setEmail(e.target.value); if (emailErr) setEmailErr(null) }}
onBlur={() => setEmailErr(validateEmail(email))} onBlur={() => setEmailErr(!email.trim() ? 'Укажите email' : validateEmail(email))}
aria-invalid={emailErr ? true : undefined} aria-invalid={emailErr ? true : undefined}
aria-describedby={emailErr ? 'login-email-err' : undefined} aria-describedby={emailErr ? 'login-email-err' : undefined}
className={`w-full rounded-md border bg-white dark:bg-slate-900 px-3 py-2 text-base sm:text-sm focus:outline-none focus:ring-2 ${emailErr ? 'border-red-400 focus:ring-red-300' : 'border-slate-300 dark:border-slate-600 focus:ring-[var(--color-brand)]'}`} className={`w-full rounded-md border bg-white dark:bg-slate-900 px-3 py-2 text-base sm:text-sm focus:outline-none focus:ring-2 ${emailErr ? 'border-red-400 focus:ring-red-300' : 'border-slate-300 dark:border-slate-600 focus:ring-[var(--color-brand)]'}`}
@ -152,7 +154,7 @@ export function LoginPage() {
autoComplete="current-password" autoComplete="current-password"
value={password} value={password}
onChange={(e) => { setPassword(e.target.value); if (passwordErr) setPasswordErr(null) }} onChange={(e) => { setPassword(e.target.value); if (passwordErr) setPasswordErr(null) }}
onBlur={() => setPasswordErr(password ? null : messages.required)} onBlur={() => setPasswordErr(password ? null : 'Введите пароль')}
aria-invalid={passwordErr ? true : undefined} aria-invalid={passwordErr ? true : undefined}
aria-describedby={passwordErr ? 'login-password-err' : undefined} aria-describedby={passwordErr ? 'login-password-err' : undefined}
className={`w-full rounded-md border bg-white dark:bg-slate-900 px-3 py-2 text-base sm:text-sm focus:outline-none focus:ring-2 ${passwordErr ? 'border-red-400 focus:ring-red-300' : 'border-slate-300 dark:border-slate-600 focus:ring-[var(--color-brand)]'}`} className={`w-full rounded-md border bg-white dark:bg-slate-900 px-3 py-2 text-base sm:text-sm focus:outline-none focus:ring-2 ${passwordErr ? 'border-red-400 focus:ring-red-300' : 'border-slate-300 dark:border-slate-600 focus:ring-[var(--color-brand)]'}`}

View file

@ -8,7 +8,7 @@ import { Field, TextInput, Select, Checkbox } from '@/components/Field'
import { FormSkeleton } from '@/components/Skeleton' 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, useIsSuperAdmin } from '@/lib/useMe'
import { isOnboardingHidden, setOnboardingHidden } from '@/lib/onboardingPrefs' import { isOnboardingHidden, setOnboardingHidden } from '@/lib/onboardingPrefs'
import { ListChecks } from 'lucide-react' import { ListChecks } from 'lucide-react'
@ -23,13 +23,17 @@ export function OrganizationSettingsPage() {
const qc = useQueryClient() const qc = useQueryClient()
const settings = useOrgSettings() const settings = useOrgSettings()
const countries = useCountries() const countries = useCountries()
// Блок «Демо-данные» — только для суперадмина (обычному магазину он не нужен
// и путает: «для демонстрации стейджа», «уже заполнено 30 951 товар») — п.13.
const isSuperAdmin = useIsSuperAdmin()
const [form, setForm] = useState<OrgSettings | null>(null) const [form, setForm] = useState<OrgSettings | null>(null)
const [nameErr, setNameErr] = useState<string | null>(null) const [nameErr, setNameErr] = useState<string | null>(null)
const [binErr, setBinErr] = useState<string | null>(null)
// Синхронизируем форму с актуальным снимком настроек: первый раз — после // Синхронизируем форму с актуальным снимком настроек: первый раз — после
// загрузки; и каждый раз когда сервер вернул свежую версию (например после // загрузки; и каждый раз когда сервер вернул свежую версию (например после
// refetchOnMount или после save в другой вкладке). // refetchOnMount или после save в другой вкладке).
useEffect(() => { if (settings.data) { setForm(settings.data); setNameErr(null) } }, [settings.data]) useEffect(() => { if (settings.data) { setForm(settings.data); setNameErr(null); setBinErr(null) } }, [settings.data])
// При смене страны подтягиваем её валюту и ставку НДС (оба read-only, из справочника стран). // При смене страны подтягиваем её валюту и ставку НДС (оба read-only, из справочника стран).
const onCountryChange = (countryCode: string) => { const onCountryChange = (countryCode: string) => {
@ -51,6 +55,9 @@ export function OrganizationSettingsPage() {
const payload = { const payload = {
name: form.name, name: form.name,
countryCode: form.countryCode, countryCode: form.countryCode,
bin: form.bin ?? '',
address: form.address ?? '',
isVatPayer: form.isVatPayer,
multiCurrencyEnabled: form.multiCurrencyEnabled, multiCurrencyEnabled: form.multiCurrencyEnabled,
showVatEnabledOnProduct: form.showVatEnabledOnProduct, showVatEnabledOnProduct: form.showVatEnabledOnProduct,
showServiceOnProduct: form.showServiceOnProduct, showServiceOnProduct: form.showServiceOnProduct,
@ -91,6 +98,27 @@ export function OrganizationSettingsPage() {
onBlur={() => { if (!form.name.trim()) setNameErr('Название обязательно') }} /> onBlur={() => { if (!form.name.trim()) setNameErr('Название обязательно') }} />
</Field> </Field>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="БИН / ИИН" error={binErr ?? undefined}>
<TextInput value={form.bin ?? ''} inputMode="numeric" maxLength={12} placeholder="12 цифр"
onChange={(e) => { setForm({ ...form, bin: e.target.value.replace(/\D/g, '').slice(0, 12) }); if (binErr) setBinErr(null) }}
onBlur={() => { const v = (form.bin ?? '').trim(); setBinErr(v.length > 0 && v.length !== 12 ? 'БИН/ИИН — 12 цифр' : null) }} />
</Field>
<Field label="Юридический адрес">
<TextInput value={form.address ?? ''} placeholder="Город, улица, дом"
onChange={(e) => setForm({ ...form, address: e.target.value })} />
</Field>
</div>
<Checkbox
label="Плательщик НДС"
checked={form.isVatPayer}
onChange={(v) => setForm({ ...form, isVatPayer: v })}
/>
<p className="text-xs text-slate-500 dark:text-slate-400 -mt-2">
Реквизиты организации (как у юрлица в МойСклад). Ставка НДС в карточках
товаров берётся из страны и от этого флага не зависит.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Страна"> <Field label="Страна">
<Select value={form.countryCode} onChange={(e) => onCountryChange(e.target.value)}> <Select value={form.countryCode} onChange={(e) => onCountryChange(e.target.value)}>
@ -256,7 +284,7 @@ export function OrganizationSettingsPage() {
</section> </section>
<div className="mt-4 flex gap-3 items-center"> <div className="mt-4 flex gap-3 items-center">
<Button onClick={() => save.mutate()} disabled={save.isPending}> <Button onClick={() => save.mutate()} disabled={save.isPending || !!binErr}>
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'} <Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
</Button> </Button>
{save.isSuccess && <span className="text-sm text-emerald-600">Сохранено</span>} {save.isSuccess && <span className="text-sm text-emerald-600">Сохранено</span>}
@ -266,7 +294,7 @@ export function OrganizationSettingsPage() {
<OnboardingVisibilitySection orgId={form.id} /> <OnboardingVisibilitySection orgId={form.id} />
<FiscalSection /> <FiscalSection />
<TelegramSection /> <TelegramSection />
<DemoSeedSection /> {isSuperAdmin && <DemoSeedSection />}
</div> </div>
</div> </div>
) )
@ -609,8 +637,8 @@ function FiscalSection() {
</h2> </h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1"> <p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Передавать ли чеки оператору фискальных данных (Webkassa / Касса24 / ОФД-Соло). Передавать ли чеки оператору фискальных данных (Webkassa / Касса24 / ОФД-Соло).
Креды хранятся per-tenant и шифруются на сервере. Если фискализация Ключи доступа хранятся отдельно для вашей организации и шифруются на сервере.
не нужна — оставьте «Без фискализации» (поведение по умолчанию). Если фискализация не нужна — оставьте «Без фискализации» (поведение по умолчанию).
</p> </p>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">

View file

@ -27,6 +27,10 @@ function PosDownloadCard() {
}) })
const win = data?.windowsUrl const win = data?.windowsUrl
const lin = data?.linuxUrl const lin = data?.linuxUrl
// Пока сборки кассы не опубликованы (ссылок нет) — блок не показываем вовсе:
// владельцу не нужен ни технический текст про «конфигурацию сервера», ни
// кнопки, которые никуда не ведут (п.3). Появится ссылка — появится и блок.
if (!win && !lin) return null
return ( return (
<div className="mb-4 rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50"> <div className="mb-4 rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@ -38,17 +42,12 @@ function PosDownloadCard() {
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="secondary" size="sm" disabled={!win} {win && <Button variant="secondary" size="sm"
onClick={() => win && window.open(win, '_blank')}>Скачать для Windows</Button> onClick={() => window.open(win, '_blank')}>Скачать для Windows</Button>}
<Button variant="secondary" size="sm" disabled={!lin} {lin && <Button variant="secondary" size="sm"
onClick={() => lin && window.open(lin, '_blank')}>Скачать для Linux</Button> onClick={() => window.open(lin, '_blank')}>Скачать для Linux</Button>}
</div> </div>
</div> </div>
{!win && !lin && (
<div className="mt-2 text-xs text-amber-600 dark:text-amber-400">
Ссылки на сборки не настроены (раздел <code>Pos</code> в конфигурации сервера).
</div>
)}
</div> </div>
) )
} }
@ -63,11 +62,14 @@ interface Form {
fiscalSerial: string fiscalSerial: string
fiscalRegNumber: string fiscalRegNumber: string
isActive: boolean isActive: boolean
cashAccountId: string
cardAccountId: string
} }
const blankForm = (storeId: string): Form => ({ const blankForm = (storeId: string): Form => ({
name: '', code: '', storeId, address: '', phone: '', name: '', code: '', storeId, address: '', phone: '',
fiscalSerial: '', fiscalRegNumber: '', isActive: true, fiscalSerial: '', fiscalRegNumber: '', isActive: true,
cashAccountId: '', cardAccountId: '',
}) })
export function RetailPointsPage() { export function RetailPointsPage() {
@ -82,10 +84,20 @@ export function RetailPointsPage() {
queryFn: async () => (await api.get<PagedResult<Store>>('/api/catalog/stores?pageSize=500')).data.items, queryFn: async () => (await api.get<PagedResult<Store>>('/api/catalog/stores?pageSize=500')).data.items,
staleTime: 60_000, staleTime: 60_000,
}) })
// Счета для денег смены этой кассы (п.8): наличные = Cash, карта = Acquiring/Bank.
const accounts = useQuery({
queryKey: ['money-accounts-lookup'],
queryFn: async () => (await api.get<PagedResult<{ id: string; name: string; kind: number; isActive: boolean }>>('/api/money/accounts?pageSize=200')).data.items,
staleTime: 60_000,
})
const cashAccounts = (accounts.data ?? []).filter((a) => a.kind === 1 && a.isActive) // Cash
const cardAccounts = (accounts.data ?? []).filter((a) => (a.kind === 4 || a.kind === 3) && a.isActive) // Acquiring/Bank
const save = async () => { const save = async () => {
if (!form) return if (!form) return
const { id, ...payload } = form const { id, cashAccountId, cardAccountId, ...rest } = form
// Пустой выбор → null (иначе бэкенд не свяжет пустую строку с Guid?).
const payload = { ...rest, cashAccountId: cashAccountId || null, cardAccountId: cardAccountId || null }
if (id) await update.mutateAsync({ id, input: payload }) if (id) await update.mutateAsync({ id, input: payload })
else await create.mutateAsync(payload) else await create.mutateAsync(payload)
setForm(null); setNameErr(null) setForm(null); setNameErr(null)
@ -123,6 +135,7 @@ export function RetailPointsPage() {
address: r.address ?? '', phone: r.phone ?? '', address: r.address ?? '', phone: r.phone ?? '',
fiscalSerial: r.fiscalSerial ?? '', fiscalRegNumber: r.fiscalRegNumber ?? '', fiscalSerial: r.fiscalSerial ?? '', fiscalRegNumber: r.fiscalRegNumber ?? '',
isActive: r.isActive, isActive: r.isActive,
cashAccountId: r.cashAccountId ?? '', cardAccountId: r.cardAccountId ?? '',
}) }} }) }}
columns={[ columns={[
{ header: 'Название', sortKey: 'name', cell: (r) => r.name }, { header: 'Название', sortKey: 'name', cell: (r) => r.name },
@ -193,6 +206,24 @@ export function RetailPointsPage() {
<TextInput value={form.fiscalRegNumber} onChange={(e) => setForm({ ...form, fiscalRegNumber: e.target.value })} /> <TextInput value={form.fiscalRegNumber} onChange={(e) => setForm({ ...form, fiscalRegNumber: e.target.value })} />
</Field> </Field>
</div> </div>
<div className="grid grid-cols-2 gap-3 pt-2 border-t border-slate-100 dark:border-slate-800">
<Field label="Счёт для наличных">
<Select value={form.cashAccountId} onChange={(e) => setForm({ ...form, cashAccountId: e.target.value })}>
<option value="">— по умолчанию —</option>
{cashAccounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</Select>
</Field>
<Field label="Счёт для оплаты картой">
<Select value={form.cardAccountId} onChange={(e) => setForm({ ...form, cardAccountId: e.target.value })}>
<option value="">— по умолчанию —</option>
{cardAccounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</Select>
</Field>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 -mt-1">
Выручка кассовой смены этой точки попадёт на выбранные счета: наличные и
оплата картой. Если не выбрано — счёт подберётся автоматически.
</p>
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} /> <Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div> </div>
)} )}

View file

@ -13,7 +13,7 @@ import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog' import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useStores, useCurrencies } from '@/lib/useLookups' import { useStores, useCurrencies } from '@/lib/useLookups'
import { useFormatCurrency } from '@/lib/useFormatCurrency' import { useOrgSettings } from '@/lib/useOrgSettings'
import { MoneyAccountKind, ACCOUNT_KIND_LABEL, type MoneyAccountRow } from '@/lib/money' import { MoneyAccountKind, ACCOUNT_KIND_LABEL, type MoneyAccountRow } from '@/lib/money'
const URL = '/api/money/accounts' const URL = '/api/money/accounts'
@ -33,20 +33,38 @@ interface Form {
xmin?: number xmin?: number
} }
function todayIso() { return new Date().toISOString().slice(0, 10) } // Локальная (не UTC) дата «сегодня» — иначе ночью по Алматы (UTC+5)
// подставлялся вчерашний день (п.5).
function todayIso() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export function MoneyAccountsPage() { export function MoneyAccountsPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<MoneyAccountRow>(URL) const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<MoneyAccountRow>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL) const { create, update, remove } = useCatalogMutations(URL, URL)
const stores = useStores() const stores = useStores()
const currencies = useCurrencies() const currencies = useCurrencies()
const fmt = useFormatCurrency() const org = useOrgSettings()
const qc = useQueryClient() const qc = useQueryClient()
const { confirm, dialogProps } = useConfirm() const { confirm, dialogProps } = useConfirm()
const [form, setForm] = useState<Form | null>(null) const [form, setForm] = useState<Form | null>(null)
const [err, setErr] = useState<string | null>(null) const [err, setErr] = useState<string | null>(null)
const defaultCurrency = currencies.data?.[0] // По умолчанию — валюта организации (обычно KZT), а не первая в списке
// справочника (там могла оказаться BYN) — п.4.
const defaultCurrency =
currencies.data?.find((c) => c.id === org.data?.defaultCurrencyId)
?? currencies.data?.find((c) => c.code === 'KZT')
?? currencies.data?.[0]
// Формат суммы счёта в ЕГО валюте (список показывал org-символ ₸ для всех) —
// п.4. Копейки — по настройке организации (п.16).
const fmtAcc = (value: number | null | undefined, code: string) => {
if (value == null || isNaN(value)) return '—'
const decimals = org.data?.allowFractionalPrices === false ? 0 : 2
return `${value.toLocaleString('ru-RU', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })} ${code}`
}
const openNew = () => { const openNew = () => {
setErr(null) setErr(null)
@ -128,7 +146,7 @@ export function MoneyAccountsPage() {
</span> </span>
{r.name.includes('из МойСклад') && ( {r.name.includes('из МойСклад') && (
<span className="text-xs text-slate-400"> <span className="text-xs text-slate-400">
Начальный остаток при переносе из МойСклад на {r.openingDate?.slice(0, 10)}: {fmt(r.openingBalance ?? 0)} Начальный остаток при переносе из МойСклад на {r.openingDate?.slice(0, 10)}: {fmtAcc(r.openingBalance ?? 0, r.currencyCode)}
</span> </span>
)} )}
</span> </span>
@ -136,7 +154,7 @@ export function MoneyAccountsPage() {
{ header: 'Тип', width: '150px', cell: (r) => ACCOUNT_KIND_LABEL[r.kind] ?? r.kind }, { header: 'Тип', width: '150px', cell: (r) => ACCOUNT_KIND_LABEL[r.kind] ?? r.kind },
{ header: 'Банк / магазин', cell: (r) => r.bankName ?? r.storeName ?? '—' }, { header: 'Банк / магазин', cell: (r) => r.bankName ?? r.storeName ?? '—' },
{ header: 'Остаток', width: '180px', className: 'text-right font-mono', cell: (r) => ( { header: 'Остаток', width: '180px', className: 'text-right font-mono', cell: (r) => (
<span className={r.currentBalance < 0 ? 'text-red-600' : ''}>{fmt(r.currentBalance)}</span> <span className={r.currentBalance < 0 ? 'text-red-600' : ''}>{fmtAcc(r.currentBalance, r.currencyCode)}</span>
)}, )},
]} ]}
/> />
@ -198,7 +216,7 @@ export function MoneyAccountsPage() {
onChange={(e) => setForm({ ...form, accountNumber: e.target.value })} /> onChange={(e) => setForm({ ...form, accountNumber: e.target.value })} />
</Field> </Field>
)} )}
<Field label={form.kind === MoneyAccountKind.Cash ? 'Магазин (касса привязана к точке)' : 'Магазин (необязательно)'}> <Field label={form.kind === MoneyAccountKind.Cash ? 'Магазин (к которому относится касса)' : 'Магазин (необязательно)'}>
<Select value={form.storeId} <Select value={form.storeId}
onChange={(e) => setForm({ ...form, storeId: (e.target as HTMLSelectElement).value })}> onChange={(e) => setForm({ ...form, storeId: (e.target as HTMLSelectElement).value })}>
<option value="">— не привязан —</option> <option value="">— не привязан —</option>

View file

@ -0,0 +1,178 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Этап «Настройка кабинета» (14.10): реквизиты организации (п.1),
/// счета точки продаж (п.8), PIN кассира + вход grant_type=pin (п.9), сброс
/// пароля сотрудника (п.10), имя владельца «Владелец» (п.11).</summary>
[Collection(ApiCollection.Name)]
public class CabinetSetupTests
{
private readonly ApiFactory _factory;
public CabinetSetupTests(ApiFactory factory) => _factory = factory;
private static async Task<Dictionary<string, JsonElement>> SettingsDictAsync(ApiActor api)
{
var s = await api.GetJsonAsync("/api/organization/settings");
return JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(s.GetRawText())!;
}
[Fact]
public async Task Requisites_save_and_validate_bin()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"req-{Guid.NewGuid():N}");
var dict = await SettingsDictAsync(api);
dict["bin"] = JsonSerializer.SerializeToElement("123456789012");
dict["address"] = JsonSerializer.SerializeToElement("Алматы, Абая 1");
dict["isVatPayer"] = JsonSerializer.SerializeToElement(false);
(await api.Http.PutAsJsonAsync("/api/organization/settings", dict)).EnsureSuccessStatusCode();
var after = await api.GetJsonAsync("/api/organization/settings");
after.GetProperty("bin").GetString().Should().Be("123456789012");
after.GetProperty("address").GetString().Should().Be("Алматы, Абая 1");
after.GetProperty("isVatPayer").GetBoolean().Should().BeFalse();
// БИН неверной длины — 400.
var bad = await SettingsDictAsync(api);
bad["bin"] = JsonSerializer.SerializeToElement("123");
var resp = await api.Http.PutAsJsonAsync("/api/organization/settings", bad);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Owner_employee_named_vladelec()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"own-{Guid.NewGuid():N}");
var list = await api.ListAsync("/api/organization/employees?pageSize=50");
var owner = list.FirstOrDefault(e => e.GetProperty("isOwner").GetBoolean());
owner.ValueKind.Should().Be(JsonValueKind.Object, "владелец есть в списке сотрудников");
owner.GetProperty("lastName").GetString().Should().Be("Владелец");
owner.GetProperty("firstName").GetString().Should().Be("");
}
[Fact]
public async Task RetailPoint_stores_cash_and_card_accounts()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"rp-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
// Создаём Cash и Acquiring счета.
var cash = await CreateAccountAsync(api, "Касса", kind: 1, refs.CurrencyId, storeId: refs.StoreId);
var card = await CreateAccountAsync(api, "Эквайринг", kind: 4, refs.CurrencyId, storeId: null);
var point = (await api.ListAsync("/api/catalog/retail-points?pageSize=50")).First();
var pointId = point.GetProperty("id").GetString()!;
var put = await api.Http.PutAsJsonAsync($"/api/catalog/retail-points/{pointId}", new
{
name = point.GetProperty("name").GetString(),
code = point.GetProperty("code").GetString(),
storeId = point.GetProperty("storeId").GetString(),
address = (string?)null, phone = (string?)null,
fiscalSerial = (string?)null, fiscalRegNumber = (string?)null, isActive = true,
cashAccountId = cash, cardAccountId = card,
});
put.EnsureSuccessStatusCode();
var after = await api.GetJsonAsync($"/api/catalog/retail-points/{pointId}");
after.GetProperty("cashAccountId").GetString().Should().Be(cash);
after.GetProperty("cardAccountId").GetString().Should().Be(card);
}
[Fact]
public async Task Cashier_pin_set_and_pin_login()
{
var api = new ApiActor(_factory.CreateClient());
var slug = $"pin-{Guid.NewGuid():N}";
await api.SignupAndLoginAsync(slug);
var cashierRoleId = (await api.ListAsync("/api/organization/employee-roles?pageSize=50"))
.First(r => r.GetProperty("name").GetString() == "Кассир").GetProperty("id").GetString();
var pointId = (await api.ListAsync("/api/catalog/retail-points?pageSize=50")).First().GetProperty("id").GetString()!;
var cashierEmail = $"cashier-{slug}@example.kz";
var createResp = await api.Http.PostAsJsonAsync("/api/organization/employees", new
{
lastName = "Кассирова", firstName = "Айгерим", roleId = cashierRoleId, isActive = true,
email = cashierEmail, createAccount = true,
retailPointIds = new[] { pointId },
});
createResp.EnsureSuccessStatusCode();
var empId = (await createResp.Content.ReadFromJsonAsync<JsonElement>())
.GetProperty("employee").GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/organization/employees/{empId}/pin", new { pin = "1234" }))
.EnsureSuccessStatusCode();
// Факт «PIN задан» виден в списке.
var emp = await api.GetJsonAsync($"/api/organization/employees/{empId}");
emp.GetProperty("pinSet").GetBoolean().Should().BeTrue();
// Вход grant_type=pin по точке.
using var anon = _factory.CreateClient();
var tok = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "pin",
["retail_point_id"] = pointId,
["pin"] = "1234",
["client_id"] = "food-market-web",
["scope"] = "openid profile email roles api offline_access",
}));
var body = await tok.Content.ReadAsStringAsync();
tok.IsSuccessStatusCode.Should().BeTrue(body);
(await tok.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("access_token").GetString()
.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task Reset_password_issues_working_password()
{
var api = new ApiActor(_factory.CreateClient());
var slug = $"rst-{Guid.NewGuid():N}";
await api.SignupAndLoginAsync(slug);
var roleId = (await api.ListAsync("/api/organization/employee-roles?pageSize=50"))
.First(r => r.GetProperty("name").GetString() == "Кладовщик").GetProperty("id").GetString();
var email = $"mgr-{slug}@example.kz";
var createResp = await api.Http.PostAsJsonAsync("/api/organization/employees", new
{
lastName = "Петров", firstName = "Пётр", roleId, isActive = true,
email, createAccount = true,
});
createResp.EnsureSuccessStatusCode();
var empId = (await createResp.Content.ReadFromJsonAsync<JsonElement>())
.GetProperty("employee").GetProperty("id").GetString();
var reset = await api.Http.PostAsJsonAsync($"/api/organization/employees/{empId}/reset-password", new { });
reset.IsSuccessStatusCode.Should().BeTrue(await reset.Content.ReadAsStringAsync());
var newPassword = (await reset.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("password").GetString()!;
newPassword.Should().NotBeNullOrEmpty();
// Новый пароль работает.
using var anon = _factory.CreateClient();
var login = new ApiActor(anon);
var token = await login.TokenAsync(email, newPassword);
token.Should().NotBeNullOrEmpty();
}
private static async Task<string> CreateAccountAsync(ApiActor api, string name, int kind, string currencyId, string? storeId)
{
var resp = await api.Http.PostAsJsonAsync("/api/money/accounts", new
{
name, kind, bankName = (string?)null, accountNumber = (string?)null,
storeId, currencyId, openingBalance = 0m, openingDate = DateTime.UtcNow,
isActive = true, isDefault = false,
});
resp.IsSuccessStatusCode.Should().BeTrue(await resp.Content.ReadAsStringAsync());
return (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
}
}