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
{
OrganizationId = org.Id, UserId = user.Id,
LastName = input.OrganizationName.Trim(), FirstName = "Owner",
// Имя владельца по умолчанию «Владелец» (редактируемо), без англ. «Owner»
// и без дублирования названия магазина (п.11).
LastName = "Владелец", FirstName = "",
Position = "Владелец", Email = input.Email.Trim(),
RoleId = adminRole.Id, IsActive = true,
});

View file

@ -44,7 +44,8 @@ public async Task<ActionResult<PagedResult<RetailPointDto>>> List([FromQuery] Pa
var items = await q
.Skip(req.Skip).Take(req.Take)
.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);
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);
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")]
@ -69,12 +71,14 @@ public async Task<ActionResult<RetailPointDto>> Create([FromBody] RetailPointInp
Address = input.Address, Phone = input.Phone,
FiscalSerial = input.FiscalSerial, FiscalRegNumber = input.FiscalRegNumber,
IsActive = input.IsActive,
CashAccountId = input.CashAccountId, CardAccountId = input.CardAccountId,
};
_db.RetailPoints.Add(e);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id },
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")]
@ -90,6 +94,8 @@ public async Task<IActionResult> Update(Guid id, [FromBody] RetailPointInput inp
e.FiscalSerial = input.FiscalSerial;
e.FiscalRegNumber = input.FiscalRegNumber;
e.IsActive = input.IsActive;
e.CashAccountId = input.CashAccountId;
e.CardAccountId = input.CardAccountId;
await _db.SaveChangesAsync(ct);
return NoContent();
}

View file

@ -45,7 +45,11 @@ public record EmployeeDto(
// active | fired | deleted — производное от IsActive/IsDeleted, удобно для UI-бейджа
string Status,
IReadOnlyList<Guid> RetailPointIds,
bool IsOwner, bool IsSelf);
bool IsOwner, bool IsSelf,
// PIN кассира задан (сам PIN не отдаём — только факт).
bool PinSet,
// У сотрудника есть учётная запись (логин) — можно сбросить пароль.
bool HasAccount);
public record EmployeeInput(
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.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
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);
return new PagedResult<EmployeeDto>
{ 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 });
}
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")]
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.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
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);
}

View file

@ -61,7 +61,11 @@ public record OrgSettingsDto(
int? PlanShops,
int? PlanCashes,
int? PlanWarehouses,
int? PlanEmployees);
int? PlanEmployees,
// Реквизиты организации (как у юрлица в МойСклад).
string? Bin,
string? Address,
bool IsVatPayer);
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
public record OrgSettingsInput(
@ -95,7 +99,12 @@ public record OrgSettingsInput(
int? NegStockNotifyIntervalMinutes = null,
int? NegStockQuietFromHour = null,
int? NegStockQuietToHour = null,
int? NegStockEscalateHours = null);
int? NegStockEscalateHours = null,
// Реквизиты — опционально (round-trip-безопасно): применяются только
// если прислан соответствующий ключ.
string? Bin = null,
string? Address = null,
bool? IsVatPayer = null);
[HttpGet("settings")]
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);
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.CountryCode = input.CountryCode;
// Валюта организации жёстко следует за страной — не принимается от клиента.
@ -212,5 +233,8 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
o.PlanShops,
o.PlanCashes,
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 орг.) и открываем смену с нулевым разменом (размен можно
// внести позже внесением). Если счёта нет — просим открыть вручную.
var rp = await _db.RetailPoints.AsNoTracking()
.Where(x => x.Id == rpId).Select(x => new { x.StoreId }).FirstOrDefaultAsync(ct);
var cashAccountId = await _db.MoneyAccounts
.Where(x => x.Id == rpId)
.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
&& (a.StoreId == rp!.StoreId || a.StoreId == null))
.OrderByDescending(a => a.StoreId == rp!.StoreId)
@ -657,7 +663,8 @@ private async Task<IActionResult> PostCoreAsync(Guid id, CancellationToken ct)
error = "На этой точке продаж не открыта кассовая смена, и не найден денежный счёт для автооткрытия. Откройте смену вручную.",
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)
return Conflict(new { error = opened.Message ?? "Не удалось открыть смену автоматически.", code = "cash-shift-required" });
_log.LogInformation("Смена автооткрыта первой продажей: shift={ShiftId} point={PointId}", newShift, rpId);

View file

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

View file

@ -25,7 +25,9 @@ public record StoreDto(
public record RetailPointDto(
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(
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder,
@ -82,7 +84,8 @@ public record StoreInput(
public record RetailPointInput(
string Name, string? Code, Guid StoreId,
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(
string Name, Guid? ParentId, int SortOrder = 0,
[Range(0, 1000)] decimal? MarkupPercent = null);

View file

@ -14,6 +14,13 @@ public class RetailPoint : TenantEntity
public string? FiscalSerial { get; set; } // серийный номер ККМ (заполнится когда подключим фискализацию)
public string? FiscalRegNumber { get; set; } // регистрационный номер в ОФД
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>
public string? MoySkladExternalId { get; set; }
}

View file

@ -13,6 +13,11 @@ public class Organization : Entity
public string? Email { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>Плательщик НДС (да/нет) — как у юрлица в МойСклад. По умолчанию
/// false. На ставку НДС товаров сейчас не влияет (та берётся из страны),
/// это реквизит организации для документов/отчётности.</summary>
public bool IsVatPayer { get; set; }
/// <summary>Архивирована ли организация. Архивные не видны пользователям
/// но данные сохраняются. Из архива можно восстановить или (после 30 дней)
/// удалить навсегда — этим управляет 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="Поиск…"
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) => {
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, filtered.length - 1)))
@ -416,7 +422,13 @@ export function AsyncSelect({
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)]"
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 === 'ArrowUp') { e.preventDefault(); setHighlight((h) => Math.max(0, h - 1)) }
else if (e.key === 'Enter') {

View file

@ -20,7 +20,10 @@ export function Modal({
}: ModalProps) {
useEffect(() => {
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)
return () => document.removeEventListener('keydown', onEsc)
}, [open, onClose])

View file

@ -40,6 +40,7 @@ export interface Store {
export interface RetailPoint {
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
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 Counterparty {

View file

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

View file

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

View file

@ -20,11 +20,13 @@ export interface RolePermissions {
demandsView: boolean; demandsEdit: boolean; demandsPost: boolean
retailSalesOperate: boolean; retailSalesRefund: 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
orgSettingsManage: boolean; employeesManage: boolean; rolesManage: boolean
storesManage: boolean; retailPointsManage: boolean; cashRegistersManage: boolean
integrationsManage: boolean
loyaltyManage: boolean; promotionsManage: boolean
moneyView: boolean; moneyEdit: boolean; cashShiftOperate: boolean
}
export interface EmployeeRoleDto {
@ -47,11 +49,13 @@ const blankPerms = (): RolePermissions => ({
demandsView: false, demandsEdit: false, demandsPost: false,
retailSalesOperate: false, retailSalesRefund: 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,
orgSettingsManage: false, employeesManage: false, rolesManage: false,
storesManage: false, retailPointsManage: false, cashRegistersManage: false,
integrationsManage: false,
loyaltyManage: false, promotionsManage: false,
moneyView: false, moneyEdit: false, cashShiftOperate: false,
})
export const allPerms = (): RolePermissions => ({
@ -61,11 +65,13 @@ export const allPerms = (): RolePermissions => ({
demandsView: true, demandsEdit: true, demandsPost: true,
retailSalesOperate: true, retailSalesRefund: 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,
orgSettingsManage: true, employeesManage: true, rolesManage: true,
storesManage: true, retailPointsManage: true, cashRegistersManage: true,
integrationsManage: true,
loyaltyManage: true, promotionsManage: true,
moneyView: true, moneyEdit: true, cashShiftOperate: true,
})
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: 'lossEdit', 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: [
{ key: 'reportsView', label: 'Сводные отчёты' },
@ -141,7 +157,7 @@ export function EmployeeRolesPage() {
<>
<ListPageShell
title="Роли сотрудников"
description="Системные роли — только просмотр прав. Кастомные роли — полный CRUD; можно создать на основе системной как шаблона."
description="Системные роли можно только просматривать. Свои роли можно создавать, менять и удалять — в том числе на основе системной как образца."
actions={
<>
<SearchBar value={search} onChange={setSearch} />

View file

@ -49,6 +49,10 @@ interface EmployeeDto {
isOwner: boolean
/** Текущий вошедший пользователь — это сам этот Employee. Нельзя удалить. */
isSelf: boolean
/** Задан ли PIN кассира (быстрый вход на кассе). Сам PIN не отдаётся. */
pinSet: boolean
/** Есть ли учётная запись (логин) — можно сбросить пароль. */
hasAccount: boolean
}
interface Form {
@ -70,6 +74,8 @@ interface Form {
isActive: boolean
retailPointIds: string[]
createAccount: boolean
/** Ввод нового PIN (write-only; текущий PIN не загружается). */
pin: string
}
const blankForm = (): Form => ({
@ -79,6 +85,7 @@ const blankForm = (): Form => ({
roleId: '', isActive: true,
retailPointIds: [],
createAccount: true,
pin: '',
})
export function EmployeesPage() {
@ -87,7 +94,9 @@ export function EmployeesPage() {
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 для футера
// модалки (form содержит только редактируемые поля и не знает про маркеры).
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 список архива).
const [statusFilter, setStatusFilter] = useState<'default' | 'active' | 'fired' | 'deleted' | 'all'>('default')
@ -216,7 +252,7 @@ export function EmployeesPage() {
salary: r.salary,
taxNumber: r.taxNumber ?? '', description: r.description ?? '', imageUrl: r.imageUrl ?? '',
roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds,
createAccount: false,
createAccount: false, pin: '',
})
}}
columns={[
@ -233,6 +269,11 @@ export function EmployeesPage() {
Главный администратор
</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>
{r.position && <div className="text-xs text-slate-500 dark:text-slate-400">{r.position}</div>}
</div>
@ -323,6 +364,9 @@ export function EmployeesPage() {
{form?.id && activeEmployee?.status === 'deleted' && (
<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 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>
</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
label="Активен"
checked={form.isActive}
@ -453,14 +521,15 @@ export function EmployeesPage() {
<Modal
open={!!createdAccount}
onClose={() => setCreatedAccount(null)}
title="Учётная запись создана"
title={createdAccount?.reset ? 'Пароль сброшен' : 'Учётная запись создана'}
footer={<Button onClick={() => setCreatedAccount(null)}>Готово</Button>}
>
{createdAccount && (
<div className="space-y-3">
<p className="text-sm text-slate-600 dark:text-slate-300">
Передайте сотруднику данные для входа. Это окно показывается один раз —
после закрытия пароль восстановить нельзя, придётся сбросить.
{createdAccount.reset
? 'Передайте сотруднику новый временный пароль. Старый больше не действует. Окно показывается один раз.'
: 'Передайте сотруднику данные для входа. Это окно показывается один раз — после закрытия пароль восстановить нельзя, придётся сбросить.'}
</p>
<Field label="Логин (email)">
<div className="flex gap-2">

View file

@ -4,7 +4,7 @@ import { login } from '@/lib/auth'
import { api } from '@/lib/api'
import { Logo } from '@/components/Logo'
import { QuickLoginBlock } from '@/components/QuickLoginBlock'
import { validateEmail, messages } from '@/lib/validation'
import { validateEmail } from '@/lib/validation'
/** Публичный sentinel-«пароль» для клика по карточке быстрого входа.
* Должен совпадать с server-side QuickLoginOptions.SentinelPassword.
@ -61,8 +61,10 @@ export function LoginPage() {
function onBtnPointerCancel() { tapRef.current = null }
async function runSubmit() {
const em = validateEmail(email)
const pw = password ? null : messages.required
// Сообщения в стиле регистрации (п.15): пустые поля — «Укажите email» /
// «Введите пароль», а не общий «Это поле обязательно…».
const em = !email.trim() ? 'Укажите email' : validateEmail(email)
const pw = password ? null : 'Введите пароль'
setEmailErr(em); setPasswordErr(pw)
if (em || pw) return
if (submittingRef.current) return
@ -137,7 +139,7 @@ export function LoginPage() {
placeholder="name@example.kz"
value={email}
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-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)]'}`}
@ -152,7 +154,7 @@ export function LoginPage() {
autoComplete="current-password"
value={password}
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-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)]'}`}

View file

@ -8,7 +8,7 @@ import { Field, TextInput, Select, Checkbox } from '@/components/Field'
import { FormSkeleton } from '@/components/Skeleton'
import { useCountries } from '@/lib/useLookups'
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 { ListChecks } from 'lucide-react'
@ -23,13 +23,17 @@ export function OrganizationSettingsPage() {
const qc = useQueryClient()
const settings = useOrgSettings()
const countries = useCountries()
// Блок «Демо-данные» — только для суперадмина (обычному магазину он не нужен
// и путает: «для демонстрации стейджа», «уже заполнено 30 951 товар») — п.13.
const isSuperAdmin = useIsSuperAdmin()
const [form, setForm] = useState<OrgSettings | null>(null)
const [nameErr, setNameErr] = useState<string | null>(null)
const [binErr, setBinErr] = useState<string | null>(null)
// Синхронизируем форму с актуальным снимком настроек: первый раз — после
// загрузки; и каждый раз когда сервер вернул свежую версию (например после
// 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, из справочника стран).
const onCountryChange = (countryCode: string) => {
@ -51,6 +55,9 @@ export function OrganizationSettingsPage() {
const payload = {
name: form.name,
countryCode: form.countryCode,
bin: form.bin ?? '',
address: form.address ?? '',
isVatPayer: form.isVatPayer,
multiCurrencyEnabled: form.multiCurrencyEnabled,
showVatEnabledOnProduct: form.showVatEnabledOnProduct,
showServiceOnProduct: form.showServiceOnProduct,
@ -91,6 +98,27 @@ export function OrganizationSettingsPage() {
onBlur={() => { if (!form.name.trim()) setNameErr('Название обязательно') }} />
</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">
<Field label="Страна">
<Select value={form.countryCode} onChange={(e) => onCountryChange(e.target.value)}>
@ -256,7 +284,7 @@ export function OrganizationSettingsPage() {
</section>
<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 ? 'Сохраняю…' : 'Сохранить'}
</Button>
{save.isSuccess && <span className="text-sm text-emerald-600">Сохранено</span>}
@ -266,7 +294,7 @@ export function OrganizationSettingsPage() {
<OnboardingVisibilitySection orgId={form.id} />
<FiscalSection />
<TelegramSection />
<DemoSeedSection />
{isSuperAdmin && <DemoSeedSection />}
</div>
</div>
)
@ -609,8 +637,8 @@ function FiscalSection() {
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Передавать ли чеки оператору фискальных данных (Webkassa / Касса24 / ОФД-Соло).
Креды хранятся per-tenant и шифруются на сервере. Если фискализация
не нужна — оставьте «Без фискализации» (поведение по умолчанию).
Ключи доступа хранятся отдельно для вашей организации и шифруются на сервере.
Если фискализация не нужна — оставьте «Без фискализации» (поведение по умолчанию).
</p>
<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 lin = data?.linuxUrl
// Пока сборки кассы не опубликованы (ссылок нет) — блок не показываем вовсе:
// владельцу не нужен ни технический текст про «конфигурацию сервера», ни
// кнопки, которые никуда не ведут (п.3). Появится ссылка — появится и блок.
if (!win && !lin) return null
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="flex flex-wrap items-center gap-3">
@ -38,17 +42,12 @@ function PosDownloadCard() {
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" disabled={!win}
onClick={() => win && window.open(win, '_blank')}>Скачать для Windows</Button>
<Button variant="secondary" size="sm" disabled={!lin}
onClick={() => lin && window.open(lin, '_blank')}>Скачать для Linux</Button>
{win && <Button variant="secondary" size="sm"
onClick={() => window.open(win, '_blank')}>Скачать для Windows</Button>}
{lin && <Button variant="secondary" size="sm"
onClick={() => window.open(lin, '_blank')}>Скачать для Linux</Button>}
</div>
</div>
{!win && !lin && (
<div className="mt-2 text-xs text-amber-600 dark:text-amber-400">
Ссылки на сборки не настроены (раздел <code>Pos</code> в конфигурации сервера).
</div>
)}
</div>
)
}
@ -63,11 +62,14 @@ interface Form {
fiscalSerial: string
fiscalRegNumber: string
isActive: boolean
cashAccountId: string
cardAccountId: string
}
const blankForm = (storeId: string): Form => ({
name: '', code: '', storeId, address: '', phone: '',
fiscalSerial: '', fiscalRegNumber: '', isActive: true,
cashAccountId: '', cardAccountId: '',
})
export function RetailPointsPage() {
@ -82,10 +84,20 @@ export function RetailPointsPage() {
queryFn: async () => (await api.get<PagedResult<Store>>('/api/catalog/stores?pageSize=500')).data.items,
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 () => {
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 })
else await create.mutateAsync(payload)
setForm(null); setNameErr(null)
@ -123,6 +135,7 @@ export function RetailPointsPage() {
address: r.address ?? '', phone: r.phone ?? '',
fiscalSerial: r.fiscalSerial ?? '', fiscalRegNumber: r.fiscalRegNumber ?? '',
isActive: r.isActive,
cashAccountId: r.cashAccountId ?? '', cardAccountId: r.cardAccountId ?? '',
}) }}
columns={[
{ 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 })} />
</Field>
</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 })} />
</div>
)}

View file

@ -13,7 +13,7 @@ import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { useQueryClient } from '@tanstack/react-query'
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'
const URL = '/api/money/accounts'
@ -33,20 +33,38 @@ interface Form {
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() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<MoneyAccountRow>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const stores = useStores()
const currencies = useCurrencies()
const fmt = useFormatCurrency()
const org = useOrgSettings()
const qc = useQueryClient()
const { confirm, dialogProps } = useConfirm()
const [form, setForm] = useState<Form | 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 = () => {
setErr(null)
@ -128,7 +146,7 @@ export function MoneyAccountsPage() {
</span>
{r.name.includes('из МойСклад') && (
<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>
@ -136,7 +154,7 @@ export function MoneyAccountsPage() {
{ header: 'Тип', width: '150px', cell: (r) => ACCOUNT_KIND_LABEL[r.kind] ?? r.kind },
{ header: 'Банк / магазин', cell: (r) => r.bankName ?? r.storeName ?? '—' },
{ 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 })} />
</Field>
)}
<Field label={form.kind === MoneyAccountKind.Cash ? 'Магазин (касса привязана к точке)' : 'Магазин (необязательно)'}>
<Field label={form.kind === MoneyAccountKind.Cash ? 'Магазин (к которому относится касса)' : 'Магазин (необязательно)'}>
<Select value={form.storeId}
onChange={(e) => setForm({ ...form, storeId: (e.target as HTMLSelectElement).value })}>
<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()!;
}
}