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
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:
parent
b3daa889f4
commit
519e4073df
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -645,19 +645,26 @@ 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(a => a.Kind == foodmarket.Domain.Money.MoneyAccountKind.Cash && a.IsActive
|
||||
&& (a.StoreId == rp!.StoreId || a.StoreId == null))
|
||||
.OrderByDescending(a => a.StoreId == rp!.StoreId)
|
||||
.Select(a => (Guid?)a.Id).FirstOrDefaultAsync(ct);
|
||||
.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)
|
||||
.Select(a => (Guid?)a.Id).FirstOrDefaultAsync(ct);
|
||||
if (cashAccountId is not { } cashAcc)
|
||||
return Conflict(new
|
||||
{
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: имя владельца-сотрудника «<название> 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"";");
|
||||
}
|
||||
}
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)]'}`}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
178
tests/food-market.IntegrationTests/CabinetSetupTests.cs
Normal file
178
tests/food-market.IntegrationTests/CabinetSetupTests.cs
Normal 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()!;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue