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
|
_db.Employees.Add(new Employee
|
||||||
{
|
{
|
||||||
OrganizationId = org.Id, UserId = user.Id,
|
OrganizationId = org.Id, UserId = user.Id,
|
||||||
LastName = input.OrganizationName.Trim(), FirstName = "Owner",
|
// Имя владельца по умолчанию «Владелец» (редактируемо), без англ. «Owner»
|
||||||
|
// и без дублирования названия магазина (п.11).
|
||||||
|
LastName = "Владелец", FirstName = "",
|
||||||
Position = "Владелец", Email = input.Email.Trim(),
|
Position = "Владелец", Email = input.Email.Trim(),
|
||||||
RoleId = adminRole.Id, IsActive = true,
|
RoleId = adminRole.Id, IsActive = true,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@ public async Task<ActionResult<PagedResult<RetailPointDto>>> List([FromQuery] Pa
|
||||||
var items = await q
|
var items = await q
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(r => new RetailPointDto(r.Id, r.Name, r.Code, r.StoreId, r.Store!.Name,
|
.Select(r => new RetailPointDto(r.Id, r.Name, r.Code, r.StoreId, r.Store!.Name,
|
||||||
r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive))
|
r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive,
|
||||||
|
r.CashAccountId, r.CardAccountId))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<RetailPointDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<RetailPointDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +55,8 @@ public async Task<ActionResult<RetailPointDto>> Get(Guid id, CancellationToken c
|
||||||
{
|
{
|
||||||
var r = await _db.RetailPoints.Include(x => x.Store).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
var r = await _db.RetailPoints.Include(x => x.Store).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
return r is null ? NotFound() : new RetailPointDto(r.Id, r.Name, r.Code, r.StoreId, r.Store!.Name,
|
return r is null ? NotFound() : new RetailPointDto(r.Id, r.Name, r.Code, r.StoreId, r.Store!.Name,
|
||||||
r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive);
|
r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive,
|
||||||
|
r.CashAccountId, r.CardAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, RequiresPermission("RetailPointsManage")]
|
[HttpPost, RequiresPermission("RetailPointsManage")]
|
||||||
|
|
@ -69,12 +71,14 @@ public async Task<ActionResult<RetailPointDto>> Create([FromBody] RetailPointInp
|
||||||
Address = input.Address, Phone = input.Phone,
|
Address = input.Address, Phone = input.Phone,
|
||||||
FiscalSerial = input.FiscalSerial, FiscalRegNumber = input.FiscalRegNumber,
|
FiscalSerial = input.FiscalSerial, FiscalRegNumber = input.FiscalRegNumber,
|
||||||
IsActive = input.IsActive,
|
IsActive = input.IsActive,
|
||||||
|
CashAccountId = input.CashAccountId, CardAccountId = input.CardAccountId,
|
||||||
};
|
};
|
||||||
_db.RetailPoints.Add(e);
|
_db.RetailPoints.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||||
new RetailPointDto(e.Id, e.Name, e.Code, e.StoreId, store.Name,
|
new RetailPointDto(e.Id, e.Name, e.Code, e.StoreId, store.Name,
|
||||||
e.Address, e.Phone, e.FiscalSerial, e.FiscalRegNumber, e.IsActive));
|
e.Address, e.Phone, e.FiscalSerial, e.FiscalRegNumber, e.IsActive,
|
||||||
|
e.CashAccountId, e.CardAccountId));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), RequiresPermission("RetailPointsManage")]
|
[HttpPut("{id:guid}"), RequiresPermission("RetailPointsManage")]
|
||||||
|
|
@ -90,6 +94,8 @@ public async Task<IActionResult> Update(Guid id, [FromBody] RetailPointInput inp
|
||||||
e.FiscalSerial = input.FiscalSerial;
|
e.FiscalSerial = input.FiscalSerial;
|
||||||
e.FiscalRegNumber = input.FiscalRegNumber;
|
e.FiscalRegNumber = input.FiscalRegNumber;
|
||||||
e.IsActive = input.IsActive;
|
e.IsActive = input.IsActive;
|
||||||
|
e.CashAccountId = input.CashAccountId;
|
||||||
|
e.CardAccountId = input.CardAccountId;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,11 @@ public record EmployeeDto(
|
||||||
// active | fired | deleted — производное от IsActive/IsDeleted, удобно для UI-бейджа
|
// active | fired | deleted — производное от IsActive/IsDeleted, удобно для UI-бейджа
|
||||||
string Status,
|
string Status,
|
||||||
IReadOnlyList<Guid> RetailPointIds,
|
IReadOnlyList<Guid> RetailPointIds,
|
||||||
bool IsOwner, bool IsSelf);
|
bool IsOwner, bool IsSelf,
|
||||||
|
// PIN кассира задан (сам PIN не отдаём — только факт).
|
||||||
|
bool PinSet,
|
||||||
|
// У сотрудника есть учётная запись (логин) — можно сбросить пароль.
|
||||||
|
bool HasAccount);
|
||||||
|
|
||||||
public record EmployeeInput(
|
public record EmployeeInput(
|
||||||
string LastName, string FirstName, string? MiddleName,
|
string LastName, string FirstName, string? MiddleName,
|
||||||
|
|
@ -112,7 +116,9 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
|
||||||
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
|
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
|
||||||
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
|
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
|
||||||
e.UserId != null && ownerUserId == e.UserId,
|
e.UserId != null && ownerUserId == e.UserId,
|
||||||
e.UserId != null && currentUserId != null && e.UserId == currentUserId))
|
e.UserId != null && currentUserId != null && e.UserId == currentUserId,
|
||||||
|
e.PinHash != null,
|
||||||
|
e.UserId != null))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<EmployeeDto>
|
return new PagedResult<EmployeeDto>
|
||||||
{ Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
{ Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
|
|
@ -263,6 +269,40 @@ public async Task<IActionResult> SetPin(Guid id, [FromBody] SetPinInput input, C
|
||||||
return Ok(new { ok = true, pinSet = true });
|
return Ok(new { ok = true, pinSet = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record ResetPasswordResult(string Password);
|
||||||
|
|
||||||
|
/// <summary>Сбросить пароль сотрудника: генерируем новый временный пароль,
|
||||||
|
/// показываем один раз (как при создании учётки). Старый пароль
|
||||||
|
/// недействителен; активные сессии сотрудника гасим (revoke токенов).
|
||||||
|
/// Работает только для сотрудника с учётной записью.</summary>
|
||||||
|
[HttpPost("{id:guid}/reset-password"), Authorize(Roles = "SuperAdmin,Admin")]
|
||||||
|
public async Task<ActionResult<ResetPasswordResult>> ResetPassword(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (e is null) return NotFound();
|
||||||
|
if (e.UserId is null)
|
||||||
|
return BadRequest(new { error = "У сотрудника нет учётной записи — сбрасывать нечего." });
|
||||||
|
|
||||||
|
var user = await _userMgr.FindByIdAsync(e.UserId.Value.ToString());
|
||||||
|
if (user is null) return BadRequest(new { error = "Учётная запись не найдена." });
|
||||||
|
|
||||||
|
var newPassword = GenerateTempPassword();
|
||||||
|
// RemovePassword + AddPassword, как в реактивации при регистрации: не
|
||||||
|
// зависит от token-провайдеров (GeneratePasswordResetToken требует их).
|
||||||
|
await _userMgr.RemovePasswordAsync(user);
|
||||||
|
var res = await _userMgr.AddPasswordAsync(user, newPassword);
|
||||||
|
if (!res.Succeeded)
|
||||||
|
return BadRequest(new { error = string.Join("; ", res.Errors.Select(x => x.Description)) });
|
||||||
|
|
||||||
|
// Гасим активные сессии — старые токены с прежним паролем не должны жить.
|
||||||
|
await _db.Database.ExecuteSqlRawAsync(
|
||||||
|
"UPDATE \"OpenIddictTokens\" SET \"Status\" = 'revoked' WHERE \"Subject\" = {0} AND \"Status\" = 'valid'",
|
||||||
|
user.Id.ToString());
|
||||||
|
|
||||||
|
await _audit.LogAsync("ResetPassword", "Employee", e.Id, new { e.LastName, e.FirstName, e.Email }, ct);
|
||||||
|
return new ResetPasswordResult(newPassword);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
@ -470,7 +510,9 @@ private Task DeactivateLinkedUserAsync(Guid? userId, CancellationToken ct)
|
||||||
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
|
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
|
||||||
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
|
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
|
||||||
e.UserId != null && ownerUserId == e.UserId,
|
e.UserId != null && ownerUserId == e.UserId,
|
||||||
e.UserId != null && currentUserId != null && e.UserId == currentUserId))
|
e.UserId != null && currentUserId != null && e.UserId == currentUserId,
|
||||||
|
e.PinHash != null,
|
||||||
|
e.UserId != null))
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,11 @@ public record OrgSettingsDto(
|
||||||
int? PlanShops,
|
int? PlanShops,
|
||||||
int? PlanCashes,
|
int? PlanCashes,
|
||||||
int? PlanWarehouses,
|
int? PlanWarehouses,
|
||||||
int? PlanEmployees);
|
int? PlanEmployees,
|
||||||
|
// Реквизиты организации (как у юрлица в МойСклад).
|
||||||
|
string? Bin,
|
||||||
|
string? Address,
|
||||||
|
bool IsVatPayer);
|
||||||
|
|
||||||
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
|
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
|
||||||
public record OrgSettingsInput(
|
public record OrgSettingsInput(
|
||||||
|
|
@ -95,7 +99,12 @@ public record OrgSettingsInput(
|
||||||
int? NegStockNotifyIntervalMinutes = null,
|
int? NegStockNotifyIntervalMinutes = null,
|
||||||
int? NegStockQuietFromHour = null,
|
int? NegStockQuietFromHour = null,
|
||||||
int? NegStockQuietToHour = null,
|
int? NegStockQuietToHour = null,
|
||||||
int? NegStockEscalateHours = null);
|
int? NegStockEscalateHours = null,
|
||||||
|
// Реквизиты — опционально (round-trip-безопасно): применяются только
|
||||||
|
// если прислан соответствующий ключ.
|
||||||
|
string? Bin = null,
|
||||||
|
string? Address = null,
|
||||||
|
bool? IsVatPayer = null);
|
||||||
|
|
||||||
[HttpGet("settings")]
|
[HttpGet("settings")]
|
||||||
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
|
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
|
||||||
|
|
@ -118,6 +127,18 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
|
||||||
.FirstOrDefaultAsync(o => o.Id == orgId, ct);
|
.FirstOrDefaultAsync(o => o.Id == orgId, ct);
|
||||||
if (o is null) return NotFound();
|
if (o is null) return NotFound();
|
||||||
|
|
||||||
|
// Реквизиты организации. БИН/ИИН — ровно 12 цифр (или пусто).
|
||||||
|
if (input.Bin is not null)
|
||||||
|
{
|
||||||
|
var bin = input.Bin.Trim();
|
||||||
|
if (bin.Length > 0 && !System.Text.RegularExpressions.Regex.IsMatch(bin, @"^\d{12}$"))
|
||||||
|
return BadRequest(new { error = "БИН/ИИН должен состоять из 12 цифр.", field = "bin" });
|
||||||
|
o.Bin = bin.Length == 0 ? null : bin;
|
||||||
|
}
|
||||||
|
if (input.Address is not null)
|
||||||
|
o.Address = string.IsNullOrWhiteSpace(input.Address) ? null : input.Address.Trim();
|
||||||
|
if (input.IsVatPayer is bool vp) o.IsVatPayer = vp;
|
||||||
|
|
||||||
o.Name = input.Name;
|
o.Name = input.Name;
|
||||||
o.CountryCode = input.CountryCode;
|
o.CountryCode = input.CountryCode;
|
||||||
// Валюта организации жёстко следует за страной — не принимается от клиента.
|
// Валюта организации жёстко следует за страной — не принимается от клиента.
|
||||||
|
|
@ -212,5 +233,8 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
|
||||||
o.PlanShops,
|
o.PlanShops,
|
||||||
o.PlanCashes,
|
o.PlanCashes,
|
||||||
o.PlanWarehouses,
|
o.PlanWarehouses,
|
||||||
o.PlanEmployees);
|
o.PlanEmployees,
|
||||||
|
o.Bin,
|
||||||
|
o.Address,
|
||||||
|
o.IsVatPayer);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -645,19 +645,26 @@ private async Task<IActionResult> PostCoreAsync(Guid id, CancellationToken ct)
|
||||||
// Cash орг.) и открываем смену с нулевым разменом (размен можно
|
// Cash орг.) и открываем смену с нулевым разменом (размен можно
|
||||||
// внести позже внесением). Если счёта нет — просим открыть вручную.
|
// внести позже внесением). Если счёта нет — просим открыть вручную.
|
||||||
var rp = await _db.RetailPoints.AsNoTracking()
|
var rp = await _db.RetailPoints.AsNoTracking()
|
||||||
.Where(x => x.Id == rpId).Select(x => new { x.StoreId }).FirstOrDefaultAsync(ct);
|
.Where(x => x.Id == rpId)
|
||||||
var cashAccountId = await _db.MoneyAccounts
|
.Select(x => new { x.StoreId, x.CashAccountId, x.CardAccountId })
|
||||||
.Where(a => a.Kind == foodmarket.Domain.Money.MoneyAccountKind.Cash && a.IsActive
|
.FirstOrDefaultAsync(ct);
|
||||||
&& (a.StoreId == rp!.StoreId || a.StoreId == null))
|
// Счёт наличных берём из настройки точки («Счёт для наличных»),
|
||||||
.OrderByDescending(a => a.StoreId == rp!.StoreId)
|
// иначе — Cash на складе точки / первый активный Cash орг.
|
||||||
.Select(a => (Guid?)a.Id).FirstOrDefaultAsync(ct);
|
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)
|
if (cashAccountId is not { } cashAcc)
|
||||||
return Conflict(new
|
return Conflict(new
|
||||||
{
|
{
|
||||||
error = "На этой точке продаж не открыта кассовая смена, и не найден денежный счёт для автооткрытия. Откройте смену вручную.",
|
error = "На этой точке продаж не открыта кассовая смена, и не найден денежный счёт для автооткрытия. Откройте смену вручную.",
|
||||||
code = "cash-shift-required",
|
code = "cash-shift-required",
|
||||||
});
|
});
|
||||||
var opened = await _money.OpenShiftAsync(rpId, cashAcc, null, 0m, CurrentUserId(), ct);
|
// Счёт для карт — из настройки точки («Счёт для оплаты картой»).
|
||||||
|
var opened = await _money.OpenShiftAsync(rpId, cashAcc, rp!.CardAccountId, 0m, CurrentUserId(), ct);
|
||||||
if (opened.Status != foodmarket.Application.Money.MoneyServiceStatus.Ok || opened.ShiftId is not { } newShift)
|
if (opened.Status != foodmarket.Application.Money.MoneyServiceStatus.Ok || opened.ShiftId is not { } newShift)
|
||||||
return Conflict(new { error = opened.Message ?? "Не удалось открыть смену автоматически.", code = "cash-shift-required" });
|
return Conflict(new { error = opened.Message ?? "Не удалось открыть смену автоматически.", code = "cash-shift-required" });
|
||||||
_log.LogInformation("Смена автооткрыта первой продажей: shift={ShiftId} point={PointId}", newShift, rpId);
|
_log.LogInformation("Смена автооткрыта первой продажей: shift={ShiftId} point={PointId}", newShift, rpId);
|
||||||
|
|
|
||||||
|
|
@ -308,7 +308,9 @@ public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
|
||||||
Name = "Основной склад",
|
Name = "Основной склад",
|
||||||
Code = "MAIN",
|
Code = "MAIN",
|
||||||
IsMain = true,
|
IsMain = true,
|
||||||
Address = "Алматы, ул. Пример 1",
|
// Адрес пустой — магазин заполнит сам (п.2). Раньше была заглушка
|
||||||
|
// «Алматы, ул. Пример 1», которая выглядела как реальный адрес.
|
||||||
|
Address = null,
|
||||||
};
|
};
|
||||||
db.Stores.Add(mainStore);
|
db.Stores.Add(mainStore);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,9 @@ public record StoreDto(
|
||||||
|
|
||||||
public record RetailPointDto(
|
public record RetailPointDto(
|
||||||
Guid Id, string Name, string? Code, Guid StoreId, string? StoreName,
|
Guid Id, string Name, string? Code, Guid StoreId, string? StoreName,
|
||||||
string? Address, string? Phone, string? FiscalSerial, string? FiscalRegNumber, bool IsActive);
|
string? Address, string? Phone, string? FiscalSerial, string? FiscalRegNumber, bool IsActive,
|
||||||
|
// Счета для денег смены этой точки (наличные/карта).
|
||||||
|
Guid? CashAccountId = null, Guid? CardAccountId = null);
|
||||||
|
|
||||||
public record ProductGroupDto(
|
public record ProductGroupDto(
|
||||||
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder,
|
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder,
|
||||||
|
|
@ -82,7 +84,8 @@ public record StoreInput(
|
||||||
public record RetailPointInput(
|
public record RetailPointInput(
|
||||||
string Name, string? Code, Guid StoreId,
|
string Name, string? Code, Guid StoreId,
|
||||||
string? Address = null, string? Phone = null,
|
string? Address = null, string? Phone = null,
|
||||||
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
|
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true,
|
||||||
|
Guid? CashAccountId = null, Guid? CardAccountId = null);
|
||||||
public record ProductGroupInput(
|
public record ProductGroupInput(
|
||||||
string Name, Guid? ParentId, int SortOrder = 0,
|
string Name, Guid? ParentId, int SortOrder = 0,
|
||||||
[Range(0, 1000)] decimal? MarkupPercent = null);
|
[Range(0, 1000)] decimal? MarkupPercent = null);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@ public class RetailPoint : TenantEntity
|
||||||
public string? FiscalSerial { get; set; } // серийный номер ККМ (заполнится когда подключим фискализацию)
|
public string? FiscalSerial { get; set; } // серийный номер ККМ (заполнится когда подключим фискализацию)
|
||||||
public string? FiscalRegNumber { get; set; } // регистрационный номер в ОФД
|
public string? FiscalRegNumber { get; set; } // регистрационный номер в ОФД
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Счёт (MoneyAccount, Kind=Cash), куда идёт наличная выручка смены
|
||||||
|
/// этой точки. При открытии смены подставляется по умолчанию. null — не задан.</summary>
|
||||||
|
public Guid? CashAccountId { get; set; }
|
||||||
|
/// <summary>Счёт (MoneyAccount, Kind=Acquiring/Bank), куда идёт выручка по
|
||||||
|
/// картам этой точки (эквайринг). При открытии смены подставляется. null — не задан.</summary>
|
||||||
|
public Guid? CardAccountId { get; set; }
|
||||||
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||||
public string? MoySkladExternalId { get; set; }
|
public string? MoySkladExternalId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ public class Organization : Entity
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Плательщик НДС (да/нет) — как у юрлица в МойСклад. По умолчанию
|
||||||
|
/// false. На ставку НДС товаров сейчас не влияет (та берётся из страны),
|
||||||
|
/// это реквизит организации для документов/отчётности.</summary>
|
||||||
|
public bool IsVatPayer { get; set; }
|
||||||
|
|
||||||
/// <summary>Архивирована ли организация. Архивные не видны пользователям
|
/// <summary>Архивирована ли организация. Архивные не видны пользователям
|
||||||
/// но данные сохраняются. Из архива можно восстановить или (после 30 дней)
|
/// но данные сохраняются. Из архива можно восстановить или (после 30 дней)
|
||||||
/// удалить навсегда — этим управляет SuperAdmin.</summary>
|
/// удалить навсегда — этим управляет SuperAdmin.</summary>
|
||||||
|
|
|
||||||
|
|
@ -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="Поиск…"
|
placeholder="Поиск…"
|
||||||
className="w-full h-8 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 text-base sm:text-sm focus:outline-none focus:ring-1 focus:ring-[var(--color-brand)]"
|
className="w-full h-8 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 text-base sm:text-sm focus:outline-none focus:ring-1 focus:ring-[var(--color-brand)]"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); setQuery('') }
|
if (e.key === 'Escape') {
|
||||||
|
// Esc закрывает ТОЛЬКО выпадающий список, не весь диалог.
|
||||||
|
// Modal слушает keydown на document; гасим всплытие и помечаем
|
||||||
|
// событие обработанным (Modal закрывается лишь при !defaultPrevented).
|
||||||
|
e.preventDefault(); e.stopPropagation(); e.nativeEvent.stopImmediatePropagation()
|
||||||
|
setOpen(false); setQuery('')
|
||||||
|
}
|
||||||
else if (e.key === 'ArrowDown') {
|
else if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setHighlight((h) => Math.min(h + 1, Math.max(0, filtered.length - 1)))
|
setHighlight((h) => Math.min(h + 1, Math.max(0, filtered.length - 1)))
|
||||||
|
|
@ -416,7 +422,13 @@ export function AsyncSelect({
|
||||||
placeholder="Поиск…"
|
placeholder="Поиск…"
|
||||||
className="w-full h-8 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 text-base sm:text-sm focus:outline-none focus:ring-1 focus:ring-[var(--color-brand)]"
|
className="w-full h-8 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 text-base sm:text-sm focus:outline-none focus:ring-1 focus:ring-[var(--color-brand)]"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); setQuery('') }
|
if (e.key === 'Escape') {
|
||||||
|
// Esc закрывает ТОЛЬКО выпадающий список, не весь диалог.
|
||||||
|
// Modal слушает keydown на document; гасим всплытие и помечаем
|
||||||
|
// событие обработанным (Modal закрывается лишь при !defaultPrevented).
|
||||||
|
e.preventDefault(); e.stopPropagation(); e.nativeEvent.stopImmediatePropagation()
|
||||||
|
setOpen(false); setQuery('')
|
||||||
|
}
|
||||||
else if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight((h) => Math.min(h + 1, Math.max(0, options.length - 1))) }
|
else if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight((h) => Math.min(h + 1, Math.max(0, options.length - 1))) }
|
||||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight((h) => Math.max(0, h - 1)) }
|
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight((h) => Math.max(0, h - 1)) }
|
||||||
else if (e.key === 'Enter') {
|
else if (e.key === 'Enter') {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,10 @@ export function Modal({
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const onEsc = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
|
// Esc закрывает диалог, НО не когда событие уже обработано (открытый
|
||||||
|
// выпадающий список внутри диалога гасит Esc и ставит defaultPrevented —
|
||||||
|
// тогда закрывается только список, а диалог остаётся). См. Select в Field.tsx.
|
||||||
|
const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape' && !e.defaultPrevented) onClose() }
|
||||||
document.addEventListener('keydown', onEsc)
|
document.addEventListener('keydown', onEsc)
|
||||||
return () => document.removeEventListener('keydown', onEsc)
|
return () => document.removeEventListener('keydown', onEsc)
|
||||||
}, [open, onClose])
|
}, [open, onClose])
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export interface Store {
|
||||||
export interface RetailPoint {
|
export interface RetailPoint {
|
||||||
id: string; name: string; code: string | null; storeId: string; storeName: string | null;
|
id: string; name: string; code: string | null; storeId: string; storeName: string | null;
|
||||||
address: string | null; phone: string | null; fiscalSerial: string | null; fiscalRegNumber: string | null; isActive: boolean
|
address: string | null; phone: string | null; fiscalSerial: string | null; fiscalRegNumber: string | null; isActive: boolean
|
||||||
|
cashAccountId: string | null; cardAccountId: string | null
|
||||||
}
|
}
|
||||||
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; markupPercent: number | null; organizationId: string | null; isSystem?: boolean }
|
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; markupPercent: number | null; organizationId: string | null; isSystem?: boolean }
|
||||||
export interface Counterparty {
|
export interface Counterparty {
|
||||||
|
|
|
||||||
|
|
@ -34,18 +34,23 @@ export function useFormatCurrency() {
|
||||||
// для kz-розницы (исторически большинство экранов на русском).
|
// для kz-розницы (исторически большинство экранов на русском).
|
||||||
const locale = i18n.language || 'ru-RU'
|
const locale = i18n.language || 'ru-RU'
|
||||||
const symbol = data?.defaultCurrencySymbol ?? '₸'
|
const symbol = data?.defaultCurrencySymbol ?? '₸'
|
||||||
|
// Если в организации выключены дробные цены («целые тенге, без копеек») —
|
||||||
|
// по умолчанию показываем суммы без копеек. Явный opts.decimals/compact
|
||||||
|
// всегда имеет приоритет. Пока настройки не загрузились — ведём себя как
|
||||||
|
// с дробными (2 знака), чтобы не «прыгало» при включённых копейках.
|
||||||
|
const defaultDecimals = data && data.allowFractionalPrices === false ? 0 : 2
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(value: number | null | undefined, opts?: FormatOptions): string => {
|
(value: number | null | undefined, opts?: FormatOptions): string => {
|
||||||
if (value == null || isNaN(value)) return '—'
|
if (value == null || isNaN(value)) return '—'
|
||||||
const decimals = opts?.decimals ?? (opts?.compact ? 0 : 2)
|
const decimals = opts?.decimals ?? (opts?.compact ? 0 : defaultDecimals)
|
||||||
const formatted = value.toLocaleString(locale, {
|
const formatted = value.toLocaleString(locale, {
|
||||||
minimumFractionDigits: decimals,
|
minimumFractionDigits: decimals,
|
||||||
maximumFractionDigits: decimals,
|
maximumFractionDigits: decimals,
|
||||||
})
|
})
|
||||||
return `${formatted} ${symbol}`
|
return `${formatted} ${symbol}`
|
||||||
},
|
},
|
||||||
[locale, symbol],
|
[locale, symbol, defaultDecimals],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ export interface OrgSettings {
|
||||||
planCashes: number | null
|
planCashes: number | null
|
||||||
planWarehouses: number | null
|
planWarehouses: number | null
|
||||||
planEmployees: number | null
|
planEmployees: number | null
|
||||||
|
// Реквизиты организации.
|
||||||
|
bin: string | null
|
||||||
|
address: string | null
|
||||||
|
isVatPayer: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOrgSettings() {
|
export function useOrgSettings() {
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,13 @@ export interface RolePermissions {
|
||||||
demandsView: boolean; demandsEdit: boolean; demandsPost: boolean
|
demandsView: boolean; demandsEdit: boolean; demandsPost: boolean
|
||||||
retailSalesOperate: boolean; retailSalesRefund: boolean
|
retailSalesOperate: boolean; retailSalesRefund: boolean
|
||||||
counterpartiesView: boolean; counterpartiesEdit: boolean; counterpartiesDelete: boolean
|
counterpartiesView: boolean; counterpartiesEdit: boolean; counterpartiesDelete: boolean
|
||||||
stocksView: boolean; inventoryEdit: boolean; lossEdit: boolean; enterEdit: boolean
|
stocksView: boolean; inventoryEdit: boolean; lossEdit: boolean; enterEdit: boolean; transferEdit: boolean
|
||||||
reportsView: boolean; reportsFinanceView: boolean; reportsStockView: boolean
|
reportsView: boolean; reportsFinanceView: boolean; reportsStockView: boolean
|
||||||
orgSettingsManage: boolean; employeesManage: boolean; rolesManage: boolean
|
orgSettingsManage: boolean; employeesManage: boolean; rolesManage: boolean
|
||||||
storesManage: boolean; retailPointsManage: boolean; cashRegistersManage: boolean
|
storesManage: boolean; retailPointsManage: boolean; cashRegistersManage: boolean
|
||||||
integrationsManage: boolean
|
integrationsManage: boolean
|
||||||
|
loyaltyManage: boolean; promotionsManage: boolean
|
||||||
|
moneyView: boolean; moneyEdit: boolean; cashShiftOperate: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmployeeRoleDto {
|
export interface EmployeeRoleDto {
|
||||||
|
|
@ -47,11 +49,13 @@ const blankPerms = (): RolePermissions => ({
|
||||||
demandsView: false, demandsEdit: false, demandsPost: false,
|
demandsView: false, demandsEdit: false, demandsPost: false,
|
||||||
retailSalesOperate: false, retailSalesRefund: false,
|
retailSalesOperate: false, retailSalesRefund: false,
|
||||||
counterpartiesView: false, counterpartiesEdit: false, counterpartiesDelete: false,
|
counterpartiesView: false, counterpartiesEdit: false, counterpartiesDelete: false,
|
||||||
stocksView: false, inventoryEdit: false, lossEdit: false, enterEdit: false,
|
stocksView: false, inventoryEdit: false, lossEdit: false, enterEdit: false, transferEdit: false,
|
||||||
reportsView: false, reportsFinanceView: false, reportsStockView: false,
|
reportsView: false, reportsFinanceView: false, reportsStockView: false,
|
||||||
orgSettingsManage: false, employeesManage: false, rolesManage: false,
|
orgSettingsManage: false, employeesManage: false, rolesManage: false,
|
||||||
storesManage: false, retailPointsManage: false, cashRegistersManage: false,
|
storesManage: false, retailPointsManage: false, cashRegistersManage: false,
|
||||||
integrationsManage: false,
|
integrationsManage: false,
|
||||||
|
loyaltyManage: false, promotionsManage: false,
|
||||||
|
moneyView: false, moneyEdit: false, cashShiftOperate: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const allPerms = (): RolePermissions => ({
|
export const allPerms = (): RolePermissions => ({
|
||||||
|
|
@ -61,11 +65,13 @@ export const allPerms = (): RolePermissions => ({
|
||||||
demandsView: true, demandsEdit: true, demandsPost: true,
|
demandsView: true, demandsEdit: true, demandsPost: true,
|
||||||
retailSalesOperate: true, retailSalesRefund: true,
|
retailSalesOperate: true, retailSalesRefund: true,
|
||||||
counterpartiesView: true, counterpartiesEdit: true, counterpartiesDelete: true,
|
counterpartiesView: true, counterpartiesEdit: true, counterpartiesDelete: true,
|
||||||
stocksView: true, inventoryEdit: true, lossEdit: true, enterEdit: true,
|
stocksView: true, inventoryEdit: true, lossEdit: true, enterEdit: true, transferEdit: true,
|
||||||
reportsView: true, reportsFinanceView: true, reportsStockView: true,
|
reportsView: true, reportsFinanceView: true, reportsStockView: true,
|
||||||
orgSettingsManage: true, employeesManage: true, rolesManage: true,
|
orgSettingsManage: true, employeesManage: true, rolesManage: true,
|
||||||
storesManage: true, retailPointsManage: true, cashRegistersManage: true,
|
storesManage: true, retailPointsManage: true, cashRegistersManage: true,
|
||||||
integrationsManage: true,
|
integrationsManage: true,
|
||||||
|
loyaltyManage: true, promotionsManage: true,
|
||||||
|
moneyView: true, moneyEdit: true, cashShiftOperate: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const blankForm = (): Form => ({ name: '', description: '', isSystem: false, permissions: blankPerms() })
|
const blankForm = (): Form => ({ name: '', description: '', isSystem: false, permissions: blankPerms() })
|
||||||
|
|
@ -102,6 +108,16 @@ export const PERM_GROUPS: { title: string; perms: { key: keyof RolePermissions;
|
||||||
{ key: 'inventoryEdit', label: 'Инвентаризация' },
|
{ key: 'inventoryEdit', label: 'Инвентаризация' },
|
||||||
{ key: 'lossEdit', label: 'Списание' },
|
{ key: 'lossEdit', label: 'Списание' },
|
||||||
{ key: 'enterEdit', label: 'Оприходование' },
|
{ key: 'enterEdit', label: 'Оприходование' },
|
||||||
|
{ key: 'transferEdit', label: 'Перемещения между складами' },
|
||||||
|
]},
|
||||||
|
{ title: 'Деньги и кассовые смены', perms: [
|
||||||
|
{ key: 'moneyView', label: 'Просмотр счетов и движений' },
|
||||||
|
{ key: 'moneyEdit', label: 'Редактирование счетов и ордеров' },
|
||||||
|
{ key: 'cashShiftOperate', label: 'Открытие/закрытие кассовой смены' },
|
||||||
|
]},
|
||||||
|
{ title: 'Маркетинг', perms: [
|
||||||
|
{ key: 'loyaltyManage', label: 'Лояльность (бонусы, карты)' },
|
||||||
|
{ key: 'promotionsManage', label: 'Акции и скидки' },
|
||||||
]},
|
]},
|
||||||
{ title: 'Отчёты', perms: [
|
{ title: 'Отчёты', perms: [
|
||||||
{ key: 'reportsView', label: 'Сводные отчёты' },
|
{ key: 'reportsView', label: 'Сводные отчёты' },
|
||||||
|
|
@ -141,7 +157,7 @@ export function EmployeeRolesPage() {
|
||||||
<>
|
<>
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
title="Роли сотрудников"
|
title="Роли сотрудников"
|
||||||
description="Системные роли — только просмотр прав. Кастомные роли — полный CRUD; можно создать на основе системной как шаблона."
|
description="Системные роли можно только просматривать. Свои роли можно создавать, менять и удалять — в том числе на основе системной как образца."
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<SearchBar value={search} onChange={setSearch} />
|
<SearchBar value={search} onChange={setSearch} />
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,10 @@ interface EmployeeDto {
|
||||||
isOwner: boolean
|
isOwner: boolean
|
||||||
/** Текущий вошедший пользователь — это сам этот Employee. Нельзя удалить. */
|
/** Текущий вошедший пользователь — это сам этот Employee. Нельзя удалить. */
|
||||||
isSelf: boolean
|
isSelf: boolean
|
||||||
|
/** Задан ли PIN кассира (быстрый вход на кассе). Сам PIN не отдаётся. */
|
||||||
|
pinSet: boolean
|
||||||
|
/** Есть ли учётная запись (логин) — можно сбросить пароль. */
|
||||||
|
hasAccount: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Form {
|
interface Form {
|
||||||
|
|
@ -70,6 +74,8 @@ interface Form {
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
retailPointIds: string[]
|
retailPointIds: string[]
|
||||||
createAccount: boolean
|
createAccount: boolean
|
||||||
|
/** Ввод нового PIN (write-only; текущий PIN не загружается). */
|
||||||
|
pin: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const blankForm = (): Form => ({
|
const blankForm = (): Form => ({
|
||||||
|
|
@ -79,6 +85,7 @@ const blankForm = (): Form => ({
|
||||||
roleId: '', isActive: true,
|
roleId: '', isActive: true,
|
||||||
retailPointIds: [],
|
retailPointIds: [],
|
||||||
createAccount: true,
|
createAccount: true,
|
||||||
|
pin: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
export function EmployeesPage() {
|
export function EmployeesPage() {
|
||||||
|
|
@ -87,7 +94,9 @@ export function EmployeesPage() {
|
||||||
const [form, setForm] = useState<Form | null>(null)
|
const [form, setForm] = useState<Form | null>(null)
|
||||||
// Сгенерированный пароль возвращается с сервера один раз — показываем
|
// Сгенерированный пароль возвращается с сервера один раз — показываем
|
||||||
// в отдельной модалке, чтобы админ передал сотруднику.
|
// в отдельной модалке, чтобы админ передал сотруднику.
|
||||||
const [createdAccount, setCreatedAccount] = useState<{ email: string; password: string } | null>(null)
|
const [createdAccount, setCreatedAccount] = useState<{ email: string; password: string; reset?: boolean } | null>(null)
|
||||||
|
// Текущее состояние PIN в открытой форме (для статуса «PIN задан» в самой форме).
|
||||||
|
const [pinBusy, setPinBusy] = useState(false)
|
||||||
// Текущий открытый сотрудник — нужен чтобы знать isOwner/isSelf для футера
|
// Текущий открытый сотрудник — нужен чтобы знать isOwner/isSelf для футера
|
||||||
// модалки (form содержит только редактируемые поля и не знает про маркеры).
|
// модалки (form содержит только редактируемые поля и не знает про маркеры).
|
||||||
const [activeEmployee, setActiveEmployee] = useState<EmployeeDto | null>(null)
|
const [activeEmployee, setActiveEmployee] = useState<EmployeeDto | null>(null)
|
||||||
|
|
@ -167,6 +176,33 @@ export function EmployeesPage() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сброс пароля (п.10): новый временный пароль показывается один раз.
|
||||||
|
const resetPassword = async () => {
|
||||||
|
if (!form?.id) return
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ password: string }>(`${URL}/${form.id}/reset-password`)
|
||||||
|
setCreatedAccount({ email: activeEmployee?.email ?? '', password: res.data.password, reset: true })
|
||||||
|
} catch (e) {
|
||||||
|
setBlockedDelete({ title: 'Не удалось сбросить пароль', body: humanizeError(e as Error) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Задать/сменить/снять PIN кассира (п.9). pin=null — снять.
|
||||||
|
const applyPin = async (pin: string | null) => {
|
||||||
|
if (!form?.id) return
|
||||||
|
setPinBusy(true)
|
||||||
|
try {
|
||||||
|
await api.post(`${URL}/${form.id}/pin`, { pin })
|
||||||
|
await qc.invalidateQueries({ queryKey: [URL] })
|
||||||
|
setActiveEmployee((a) => (a ? { ...a, pinSet: !!pin } : a))
|
||||||
|
setForm((f) => (f ? { ...f, pin: '' } : f))
|
||||||
|
} catch (e) {
|
||||||
|
setBlockedDelete({ title: 'Не удалось сохранить PIN', body: humanizeError(e as Error) })
|
||||||
|
} finally {
|
||||||
|
setPinBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Фильтр по статусу сотрудника. По умолчанию показываем только активных
|
// Фильтр по статусу сотрудника. По умолчанию показываем только активных
|
||||||
// и уволенных; «удалённые» — отдельный режим (read-only список архива).
|
// и уволенных; «удалённые» — отдельный режим (read-only список архива).
|
||||||
const [statusFilter, setStatusFilter] = useState<'default' | 'active' | 'fired' | 'deleted' | 'all'>('default')
|
const [statusFilter, setStatusFilter] = useState<'default' | 'active' | 'fired' | 'deleted' | 'all'>('default')
|
||||||
|
|
@ -216,7 +252,7 @@ export function EmployeesPage() {
|
||||||
salary: r.salary,
|
salary: r.salary,
|
||||||
taxNumber: r.taxNumber ?? '', description: r.description ?? '', imageUrl: r.imageUrl ?? '',
|
taxNumber: r.taxNumber ?? '', description: r.description ?? '', imageUrl: r.imageUrl ?? '',
|
||||||
roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds,
|
roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds,
|
||||||
createAccount: false,
|
createAccount: false, pin: '',
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
columns={[
|
columns={[
|
||||||
|
|
@ -233,6 +269,11 @@ export function EmployeesPage() {
|
||||||
Главный администратор
|
Главный администратор
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{r.pinSet && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
|
||||||
|
PIN задан
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{r.position && <div className="text-xs text-slate-500 dark:text-slate-400">{r.position}</div>}
|
{r.position && <div className="text-xs text-slate-500 dark:text-slate-400">{r.position}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -323,6 +364,9 @@ export function EmployeesPage() {
|
||||||
{form?.id && activeEmployee?.status === 'deleted' && (
|
{form?.id && activeEmployee?.status === 'deleted' && (
|
||||||
<span className="text-xs text-slate-500 dark:text-slate-400 italic">Сотрудник удалён — изменения недоступны.</span>
|
<span className="text-xs text-slate-500 dark:text-slate-400 italic">Сотрудник удалён — изменения недоступны.</span>
|
||||||
)}
|
)}
|
||||||
|
{form?.id && activeEmployee?.hasAccount && activeEmployee?.status !== 'deleted' && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={resetPassword}>Сбросить пароль</Button>
|
||||||
|
)}
|
||||||
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
|
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
|
||||||
<Button onClick={save} disabled={!form?.lastName || !form.firstName || !form.roleId}>Сохранить</Button>
|
<Button onClick={save} disabled={!form?.lastName || !form.firstName || !form.roleId}>Сохранить</Button>
|
||||||
</>
|
</>
|
||||||
|
|
@ -424,6 +468,30 @@ export function EmployeesPage() {
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">Если ничего не выбрано — кассир работает на всех кассах.</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">Если ничего не выбрано — кассир работает на всех кассах.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isCashier && form.id && (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium mb-1.5">
|
||||||
|
PIN для входа на кассе
|
||||||
|
{activeEmployee?.pinSet && <span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">PIN задан</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<TextInput value={form.pin} inputMode="numeric" maxLength={6} placeholder="4–6 цифр"
|
||||||
|
onChange={(e) => setForm({ ...form, pin: e.target.value.replace(/\D/g, '').slice(0, 6) })} />
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" size="sm" disabled={pinBusy || form.pin.length < 4}
|
||||||
|
onClick={() => applyPin(form.pin)}>
|
||||||
|
{activeEmployee?.pinSet ? 'Сменить PIN' : 'Задать PIN'}
|
||||||
|
</Button>
|
||||||
|
{activeEmployee?.pinSet && (
|
||||||
|
<Button variant="secondary" size="sm" disabled={pinBusy} onClick={() => applyPin(null)}>Снять</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
Быстрый вход кассира на кассе по PIN (без email/пароля). Нужен для входа grant_type=pin на привязанной точке.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Активен"
|
label="Активен"
|
||||||
checked={form.isActive}
|
checked={form.isActive}
|
||||||
|
|
@ -453,14 +521,15 @@ export function EmployeesPage() {
|
||||||
<Modal
|
<Modal
|
||||||
open={!!createdAccount}
|
open={!!createdAccount}
|
||||||
onClose={() => setCreatedAccount(null)}
|
onClose={() => setCreatedAccount(null)}
|
||||||
title="Учётная запись создана"
|
title={createdAccount?.reset ? 'Пароль сброшен' : 'Учётная запись создана'}
|
||||||
footer={<Button onClick={() => setCreatedAccount(null)}>Готово</Button>}
|
footer={<Button onClick={() => setCreatedAccount(null)}>Готово</Button>}
|
||||||
>
|
>
|
||||||
{createdAccount && (
|
{createdAccount && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||||
Передайте сотруднику данные для входа. Это окно показывается один раз —
|
{createdAccount.reset
|
||||||
после закрытия пароль восстановить нельзя, придётся сбросить.
|
? 'Передайте сотруднику новый временный пароль. Старый больше не действует. Окно показывается один раз.'
|
||||||
|
: 'Передайте сотруднику данные для входа. Это окно показывается один раз — после закрытия пароль восстановить нельзя, придётся сбросить.'}
|
||||||
</p>
|
</p>
|
||||||
<Field label="Логин (email)">
|
<Field label="Логин (email)">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { login } from '@/lib/auth'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Logo } from '@/components/Logo'
|
import { Logo } from '@/components/Logo'
|
||||||
import { QuickLoginBlock } from '@/components/QuickLoginBlock'
|
import { QuickLoginBlock } from '@/components/QuickLoginBlock'
|
||||||
import { validateEmail, messages } from '@/lib/validation'
|
import { validateEmail } from '@/lib/validation'
|
||||||
|
|
||||||
/** Публичный sentinel-«пароль» для клика по карточке быстрого входа.
|
/** Публичный sentinel-«пароль» для клика по карточке быстрого входа.
|
||||||
* Должен совпадать с server-side QuickLoginOptions.SentinelPassword.
|
* Должен совпадать с server-side QuickLoginOptions.SentinelPassword.
|
||||||
|
|
@ -61,8 +61,10 @@ export function LoginPage() {
|
||||||
function onBtnPointerCancel() { tapRef.current = null }
|
function onBtnPointerCancel() { tapRef.current = null }
|
||||||
|
|
||||||
async function runSubmit() {
|
async function runSubmit() {
|
||||||
const em = validateEmail(email)
|
// Сообщения в стиле регистрации (п.15): пустые поля — «Укажите email» /
|
||||||
const pw = password ? null : messages.required
|
// «Введите пароль», а не общий «Это поле обязательно…».
|
||||||
|
const em = !email.trim() ? 'Укажите email' : validateEmail(email)
|
||||||
|
const pw = password ? null : 'Введите пароль'
|
||||||
setEmailErr(em); setPasswordErr(pw)
|
setEmailErr(em); setPasswordErr(pw)
|
||||||
if (em || pw) return
|
if (em || pw) return
|
||||||
if (submittingRef.current) return
|
if (submittingRef.current) return
|
||||||
|
|
@ -137,7 +139,7 @@ export function LoginPage() {
|
||||||
placeholder="name@example.kz"
|
placeholder="name@example.kz"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => { setEmail(e.target.value); if (emailErr) setEmailErr(null) }}
|
onChange={(e) => { setEmail(e.target.value); if (emailErr) setEmailErr(null) }}
|
||||||
onBlur={() => setEmailErr(validateEmail(email))}
|
onBlur={() => setEmailErr(!email.trim() ? 'Укажите email' : validateEmail(email))}
|
||||||
aria-invalid={emailErr ? true : undefined}
|
aria-invalid={emailErr ? true : undefined}
|
||||||
aria-describedby={emailErr ? 'login-email-err' : undefined}
|
aria-describedby={emailErr ? 'login-email-err' : undefined}
|
||||||
className={`w-full rounded-md border bg-white dark:bg-slate-900 px-3 py-2 text-base sm:text-sm focus:outline-none focus:ring-2 ${emailErr ? 'border-red-400 focus:ring-red-300' : 'border-slate-300 dark:border-slate-600 focus:ring-[var(--color-brand)]'}`}
|
className={`w-full rounded-md border bg-white dark:bg-slate-900 px-3 py-2 text-base sm:text-sm focus:outline-none focus:ring-2 ${emailErr ? 'border-red-400 focus:ring-red-300' : 'border-slate-300 dark:border-slate-600 focus:ring-[var(--color-brand)]'}`}
|
||||||
|
|
@ -152,7 +154,7 @@ export function LoginPage() {
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => { setPassword(e.target.value); if (passwordErr) setPasswordErr(null) }}
|
onChange={(e) => { setPassword(e.target.value); if (passwordErr) setPasswordErr(null) }}
|
||||||
onBlur={() => setPasswordErr(password ? null : messages.required)}
|
onBlur={() => setPasswordErr(password ? null : 'Введите пароль')}
|
||||||
aria-invalid={passwordErr ? true : undefined}
|
aria-invalid={passwordErr ? true : undefined}
|
||||||
aria-describedby={passwordErr ? 'login-password-err' : undefined}
|
aria-describedby={passwordErr ? 'login-password-err' : undefined}
|
||||||
className={`w-full rounded-md border bg-white dark:bg-slate-900 px-3 py-2 text-base sm:text-sm focus:outline-none focus:ring-2 ${passwordErr ? 'border-red-400 focus:ring-red-300' : 'border-slate-300 dark:border-slate-600 focus:ring-[var(--color-brand)]'}`}
|
className={`w-full rounded-md border bg-white dark:bg-slate-900 px-3 py-2 text-base sm:text-sm focus:outline-none focus:ring-2 ${passwordErr ? 'border-red-400 focus:ring-red-300' : 'border-slate-300 dark:border-slate-600 focus:ring-[var(--color-brand)]'}`}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { Field, TextInput, Select, Checkbox } from '@/components/Field'
|
||||||
import { FormSkeleton } from '@/components/Skeleton'
|
import { FormSkeleton } from '@/components/Skeleton'
|
||||||
import { useCountries } from '@/lib/useLookups'
|
import { useCountries } from '@/lib/useLookups'
|
||||||
import { useOrgSettings, type OrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings, type OrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { useMe } from '@/lib/useMe'
|
import { useMe, useIsSuperAdmin } from '@/lib/useMe'
|
||||||
import { isOnboardingHidden, setOnboardingHidden } from '@/lib/onboardingPrefs'
|
import { isOnboardingHidden, setOnboardingHidden } from '@/lib/onboardingPrefs'
|
||||||
import { ListChecks } from 'lucide-react'
|
import { ListChecks } from 'lucide-react'
|
||||||
|
|
||||||
|
|
@ -23,13 +23,17 @@ export function OrganizationSettingsPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const settings = useOrgSettings()
|
const settings = useOrgSettings()
|
||||||
const countries = useCountries()
|
const countries = useCountries()
|
||||||
|
// Блок «Демо-данные» — только для суперадмина (обычному магазину он не нужен
|
||||||
|
// и путает: «для демонстрации стейджа», «уже заполнено 30 951 товар») — п.13.
|
||||||
|
const isSuperAdmin = useIsSuperAdmin()
|
||||||
|
|
||||||
const [form, setForm] = useState<OrgSettings | null>(null)
|
const [form, setForm] = useState<OrgSettings | null>(null)
|
||||||
const [nameErr, setNameErr] = useState<string | null>(null)
|
const [nameErr, setNameErr] = useState<string | null>(null)
|
||||||
|
const [binErr, setBinErr] = useState<string | null>(null)
|
||||||
// Синхронизируем форму с актуальным снимком настроек: первый раз — после
|
// Синхронизируем форму с актуальным снимком настроек: первый раз — после
|
||||||
// загрузки; и каждый раз когда сервер вернул свежую версию (например после
|
// загрузки; и каждый раз когда сервер вернул свежую версию (например после
|
||||||
// refetchOnMount или после save в другой вкладке).
|
// refetchOnMount или после save в другой вкладке).
|
||||||
useEffect(() => { if (settings.data) { setForm(settings.data); setNameErr(null) } }, [settings.data])
|
useEffect(() => { if (settings.data) { setForm(settings.data); setNameErr(null); setBinErr(null) } }, [settings.data])
|
||||||
|
|
||||||
// При смене страны подтягиваем её валюту и ставку НДС (оба read-only, из справочника стран).
|
// При смене страны подтягиваем её валюту и ставку НДС (оба read-only, из справочника стран).
|
||||||
const onCountryChange = (countryCode: string) => {
|
const onCountryChange = (countryCode: string) => {
|
||||||
|
|
@ -51,6 +55,9 @@ export function OrganizationSettingsPage() {
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
countryCode: form.countryCode,
|
countryCode: form.countryCode,
|
||||||
|
bin: form.bin ?? '',
|
||||||
|
address: form.address ?? '',
|
||||||
|
isVatPayer: form.isVatPayer,
|
||||||
multiCurrencyEnabled: form.multiCurrencyEnabled,
|
multiCurrencyEnabled: form.multiCurrencyEnabled,
|
||||||
showVatEnabledOnProduct: form.showVatEnabledOnProduct,
|
showVatEnabledOnProduct: form.showVatEnabledOnProduct,
|
||||||
showServiceOnProduct: form.showServiceOnProduct,
|
showServiceOnProduct: form.showServiceOnProduct,
|
||||||
|
|
@ -91,6 +98,27 @@ export function OrganizationSettingsPage() {
|
||||||
onBlur={() => { if (!form.name.trim()) setNameErr('Название обязательно') }} />
|
onBlur={() => { if (!form.name.trim()) setNameErr('Название обязательно') }} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Field label="БИН / ИИН" error={binErr ?? undefined}>
|
||||||
|
<TextInput value={form.bin ?? ''} inputMode="numeric" maxLength={12} placeholder="12 цифр"
|
||||||
|
onChange={(e) => { setForm({ ...form, bin: e.target.value.replace(/\D/g, '').slice(0, 12) }); if (binErr) setBinErr(null) }}
|
||||||
|
onBlur={() => { const v = (form.bin ?? '').trim(); setBinErr(v.length > 0 && v.length !== 12 ? 'БИН/ИИН — 12 цифр' : null) }} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Юридический адрес">
|
||||||
|
<TextInput value={form.address ?? ''} placeholder="Город, улица, дом"
|
||||||
|
onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
label="Плательщик НДС"
|
||||||
|
checked={form.isVatPayer}
|
||||||
|
onChange={(v) => setForm({ ...form, isVatPayer: v })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 -mt-2">
|
||||||
|
Реквизиты организации (как у юрлица в МойСклад). Ставка НДС в карточках
|
||||||
|
товаров берётся из страны и от этого флага не зависит.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<Field label="Страна">
|
<Field label="Страна">
|
||||||
<Select value={form.countryCode} onChange={(e) => onCountryChange(e.target.value)}>
|
<Select value={form.countryCode} onChange={(e) => onCountryChange(e.target.value)}>
|
||||||
|
|
@ -256,7 +284,7 @@ export function OrganizationSettingsPage() {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="mt-4 flex gap-3 items-center">
|
<div className="mt-4 flex gap-3 items-center">
|
||||||
<Button onClick={() => save.mutate()} disabled={save.isPending}>
|
<Button onClick={() => save.mutate()} disabled={save.isPending || !!binErr}>
|
||||||
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
|
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
|
||||||
</Button>
|
</Button>
|
||||||
{save.isSuccess && <span className="text-sm text-emerald-600">Сохранено</span>}
|
{save.isSuccess && <span className="text-sm text-emerald-600">Сохранено</span>}
|
||||||
|
|
@ -266,7 +294,7 @@ export function OrganizationSettingsPage() {
|
||||||
<OnboardingVisibilitySection orgId={form.id} />
|
<OnboardingVisibilitySection orgId={form.id} />
|
||||||
<FiscalSection />
|
<FiscalSection />
|
||||||
<TelegramSection />
|
<TelegramSection />
|
||||||
<DemoSeedSection />
|
{isSuperAdmin && <DemoSeedSection />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -609,8 +637,8 @@ function FiscalSection() {
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||||
Передавать ли чеки оператору фискальных данных (Webkassa / Касса24 / ОФД-Соло).
|
Передавать ли чеки оператору фискальных данных (Webkassa / Касса24 / ОФД-Соло).
|
||||||
Креды хранятся per-tenant и шифруются на сервере. Если фискализация
|
Ключи доступа хранятся отдельно для вашей организации и шифруются на сервере.
|
||||||
не нужна — оставьте «Без фискализации» (поведение по умолчанию).
|
Если фискализация не нужна — оставьте «Без фискализации» (поведение по умолчанию).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ function PosDownloadCard() {
|
||||||
})
|
})
|
||||||
const win = data?.windowsUrl
|
const win = data?.windowsUrl
|
||||||
const lin = data?.linuxUrl
|
const lin = data?.linuxUrl
|
||||||
|
// Пока сборки кассы не опубликованы (ссылок нет) — блок не показываем вовсе:
|
||||||
|
// владельцу не нужен ни технический текст про «конфигурацию сервера», ни
|
||||||
|
// кнопки, которые никуда не ведут (п.3). Появится ссылка — появится и блок.
|
||||||
|
if (!win && !lin) return null
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50">
|
<div className="mb-4 rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
|
@ -38,17 +42,12 @@ function PosDownloadCard() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="secondary" size="sm" disabled={!win}
|
{win && <Button variant="secondary" size="sm"
|
||||||
onClick={() => win && window.open(win, '_blank')}>Скачать для Windows</Button>
|
onClick={() => window.open(win, '_blank')}>Скачать для Windows</Button>}
|
||||||
<Button variant="secondary" size="sm" disabled={!lin}
|
{lin && <Button variant="secondary" size="sm"
|
||||||
onClick={() => lin && window.open(lin, '_blank')}>Скачать для Linux</Button>
|
onClick={() => window.open(lin, '_blank')}>Скачать для Linux</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!win && !lin && (
|
|
||||||
<div className="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
Ссылки на сборки не настроены (раздел <code>Pos</code> в конфигурации сервера).
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -63,11 +62,14 @@ interface Form {
|
||||||
fiscalSerial: string
|
fiscalSerial: string
|
||||||
fiscalRegNumber: string
|
fiscalRegNumber: string
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
|
cashAccountId: string
|
||||||
|
cardAccountId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const blankForm = (storeId: string): Form => ({
|
const blankForm = (storeId: string): Form => ({
|
||||||
name: '', code: '', storeId, address: '', phone: '',
|
name: '', code: '', storeId, address: '', phone: '',
|
||||||
fiscalSerial: '', fiscalRegNumber: '', isActive: true,
|
fiscalSerial: '', fiscalRegNumber: '', isActive: true,
|
||||||
|
cashAccountId: '', cardAccountId: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
export function RetailPointsPage() {
|
export function RetailPointsPage() {
|
||||||
|
|
@ -82,10 +84,20 @@ export function RetailPointsPage() {
|
||||||
queryFn: async () => (await api.get<PagedResult<Store>>('/api/catalog/stores?pageSize=500')).data.items,
|
queryFn: async () => (await api.get<PagedResult<Store>>('/api/catalog/stores?pageSize=500')).data.items,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
// Счета для денег смены этой кассы (п.8): наличные = Cash, карта = Acquiring/Bank.
|
||||||
|
const accounts = useQuery({
|
||||||
|
queryKey: ['money-accounts-lookup'],
|
||||||
|
queryFn: async () => (await api.get<PagedResult<{ id: string; name: string; kind: number; isActive: boolean }>>('/api/money/accounts?pageSize=200')).data.items,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
const cashAccounts = (accounts.data ?? []).filter((a) => a.kind === 1 && a.isActive) // Cash
|
||||||
|
const cardAccounts = (accounts.data ?? []).filter((a) => (a.kind === 4 || a.kind === 3) && a.isActive) // Acquiring/Bank
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!form) return
|
if (!form) return
|
||||||
const { id, ...payload } = form
|
const { id, cashAccountId, cardAccountId, ...rest } = form
|
||||||
|
// Пустой выбор → null (иначе бэкенд не свяжет пустую строку с Guid?).
|
||||||
|
const payload = { ...rest, cashAccountId: cashAccountId || null, cardAccountId: cardAccountId || null }
|
||||||
if (id) await update.mutateAsync({ id, input: payload })
|
if (id) await update.mutateAsync({ id, input: payload })
|
||||||
else await create.mutateAsync(payload)
|
else await create.mutateAsync(payload)
|
||||||
setForm(null); setNameErr(null)
|
setForm(null); setNameErr(null)
|
||||||
|
|
@ -123,6 +135,7 @@ export function RetailPointsPage() {
|
||||||
address: r.address ?? '', phone: r.phone ?? '',
|
address: r.address ?? '', phone: r.phone ?? '',
|
||||||
fiscalSerial: r.fiscalSerial ?? '', fiscalRegNumber: r.fiscalRegNumber ?? '',
|
fiscalSerial: r.fiscalSerial ?? '', fiscalRegNumber: r.fiscalRegNumber ?? '',
|
||||||
isActive: r.isActive,
|
isActive: r.isActive,
|
||||||
|
cashAccountId: r.cashAccountId ?? '', cardAccountId: r.cardAccountId ?? '',
|
||||||
}) }}
|
}) }}
|
||||||
columns={[
|
columns={[
|
||||||
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
||||||
|
|
@ -193,6 +206,24 @@ export function RetailPointsPage() {
|
||||||
<TextInput value={form.fiscalRegNumber} onChange={(e) => setForm({ ...form, fiscalRegNumber: e.target.value })} />
|
<TextInput value={form.fiscalRegNumber} onChange={(e) => setForm({ ...form, fiscalRegNumber: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 pt-2 border-t border-slate-100 dark:border-slate-800">
|
||||||
|
<Field label="Счёт для наличных">
|
||||||
|
<Select value={form.cashAccountId} onChange={(e) => setForm({ ...form, cashAccountId: e.target.value })}>
|
||||||
|
<option value="">— по умолчанию —</option>
|
||||||
|
{cashAccounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Счёт для оплаты картой">
|
||||||
|
<Select value={form.cardAccountId} onChange={(e) => setForm({ ...form, cardAccountId: e.target.value })}>
|
||||||
|
<option value="">— по умолчанию —</option>
|
||||||
|
{cardAccounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 -mt-1">
|
||||||
|
Выручка кассовой смены этой точки попадёт на выбранные счета: наличные и
|
||||||
|
оплата картой. Если не выбрано — счёт подберётся автоматически.
|
||||||
|
</p>
|
||||||
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
|
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { useConfirm } from '@/lib/useConfirm'
|
||||||
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useStores, useCurrencies } from '@/lib/useLookups'
|
import { useStores, useCurrencies } from '@/lib/useLookups'
|
||||||
import { useFormatCurrency } from '@/lib/useFormatCurrency'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { MoneyAccountKind, ACCOUNT_KIND_LABEL, type MoneyAccountRow } from '@/lib/money'
|
import { MoneyAccountKind, ACCOUNT_KIND_LABEL, type MoneyAccountRow } from '@/lib/money'
|
||||||
|
|
||||||
const URL = '/api/money/accounts'
|
const URL = '/api/money/accounts'
|
||||||
|
|
@ -33,20 +33,38 @@ interface Form {
|
||||||
xmin?: number
|
xmin?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function todayIso() { return new Date().toISOString().slice(0, 10) }
|
// Локальная (не UTC) дата «сегодня» — иначе ночью по Алматы (UTC+5)
|
||||||
|
// подставлялся вчерашний день (п.5).
|
||||||
|
function todayIso() {
|
||||||
|
const d = new Date()
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
export function MoneyAccountsPage() {
|
export function MoneyAccountsPage() {
|
||||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<MoneyAccountRow>(URL)
|
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<MoneyAccountRow>(URL)
|
||||||
const { create, update, remove } = useCatalogMutations(URL, URL)
|
const { create, update, remove } = useCatalogMutations(URL, URL)
|
||||||
const stores = useStores()
|
const stores = useStores()
|
||||||
const currencies = useCurrencies()
|
const currencies = useCurrencies()
|
||||||
const fmt = useFormatCurrency()
|
const org = useOrgSettings()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { confirm, dialogProps } = useConfirm()
|
const { confirm, dialogProps } = useConfirm()
|
||||||
const [form, setForm] = useState<Form | null>(null)
|
const [form, setForm] = useState<Form | null>(null)
|
||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
const defaultCurrency = currencies.data?.[0]
|
// По умолчанию — валюта организации (обычно KZT), а не первая в списке
|
||||||
|
// справочника (там могла оказаться BYN) — п.4.
|
||||||
|
const defaultCurrency =
|
||||||
|
currencies.data?.find((c) => c.id === org.data?.defaultCurrencyId)
|
||||||
|
?? currencies.data?.find((c) => c.code === 'KZT')
|
||||||
|
?? currencies.data?.[0]
|
||||||
|
|
||||||
|
// Формат суммы счёта в ЕГО валюте (список показывал org-символ ₸ для всех) —
|
||||||
|
// п.4. Копейки — по настройке организации (п.16).
|
||||||
|
const fmtAcc = (value: number | null | undefined, code: string) => {
|
||||||
|
if (value == null || isNaN(value)) return '—'
|
||||||
|
const decimals = org.data?.allowFractionalPrices === false ? 0 : 2
|
||||||
|
return `${value.toLocaleString('ru-RU', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })} ${code}`
|
||||||
|
}
|
||||||
|
|
||||||
const openNew = () => {
|
const openNew = () => {
|
||||||
setErr(null)
|
setErr(null)
|
||||||
|
|
@ -128,7 +146,7 @@ export function MoneyAccountsPage() {
|
||||||
</span>
|
</span>
|
||||||
{r.name.includes('из МойСклад') && (
|
{r.name.includes('из МойСклад') && (
|
||||||
<span className="text-xs text-slate-400">
|
<span className="text-xs text-slate-400">
|
||||||
Начальный остаток при переносе из МойСклад на {r.openingDate?.slice(0, 10)}: {fmt(r.openingBalance ?? 0)}
|
Начальный остаток при переносе из МойСклад на {r.openingDate?.slice(0, 10)}: {fmtAcc(r.openingBalance ?? 0, r.currencyCode)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -136,7 +154,7 @@ export function MoneyAccountsPage() {
|
||||||
{ header: 'Тип', width: '150px', cell: (r) => ACCOUNT_KIND_LABEL[r.kind] ?? r.kind },
|
{ header: 'Тип', width: '150px', cell: (r) => ACCOUNT_KIND_LABEL[r.kind] ?? r.kind },
|
||||||
{ header: 'Банк / магазин', cell: (r) => r.bankName ?? r.storeName ?? '—' },
|
{ header: 'Банк / магазин', cell: (r) => r.bankName ?? r.storeName ?? '—' },
|
||||||
{ header: 'Остаток', width: '180px', className: 'text-right font-mono', cell: (r) => (
|
{ header: 'Остаток', width: '180px', className: 'text-right font-mono', cell: (r) => (
|
||||||
<span className={r.currentBalance < 0 ? 'text-red-600' : ''}>{fmt(r.currentBalance)}</span>
|
<span className={r.currentBalance < 0 ? 'text-red-600' : ''}>{fmtAcc(r.currentBalance, r.currencyCode)}</span>
|
||||||
)},
|
)},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
@ -198,7 +216,7 @@ export function MoneyAccountsPage() {
|
||||||
onChange={(e) => setForm({ ...form, accountNumber: e.target.value })} />
|
onChange={(e) => setForm({ ...form, accountNumber: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
<Field label={form.kind === MoneyAccountKind.Cash ? 'Магазин (касса привязана к точке)' : 'Магазин (необязательно)'}>
|
<Field label={form.kind === MoneyAccountKind.Cash ? 'Магазин (к которому относится касса)' : 'Магазин (необязательно)'}>
|
||||||
<Select value={form.storeId}
|
<Select value={form.storeId}
|
||||||
onChange={(e) => setForm({ ...form, storeId: (e.target as HTMLSelectElement).value })}>
|
onChange={(e) => setForm({ ...form, storeId: (e.target as HTMLSelectElement).value })}>
|
||||||
<option value="">— не привязан —</option>
|
<option value="">— не привязан —</option>
|
||||||
|
|
|
||||||
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