From 519e4073df0fba47179bc71254ca93fb358662c0 Mon Sep 17 00:00:00 2001 From: nns Date: Wed, 14 Oct 2026 01:55:29 +0500 Subject: [PATCH] =?UTF-8?q?fix(=D0=BA=D0=B0=D0=B1=D0=B8=D0=BD=D0=B5=D1=82)?= =?UTF-8?q?:=20=D1=8D=D1=82=D0=B0=D0=BF=20=C2=AB=D0=9D=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20=D0=BA=D0=B0=D0=B1=D0=B8=D0=BD?= =?UTF-8?q?=D0=B5=D1=82=D0=B0=C2=BB=20=E2=80=94=2015=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BE=D0=BA=20(=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=2014?= =?UTF-8?q?.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Controllers/AuthSignupController.cs | 4 +- .../Catalog/RetailPointsController.cs | 12 +- .../Organizations/EmployeesController.cs | 48 ++++- .../OrganizationSettingsController.cs | 30 ++- .../Sales/RetailSalesController.cs | 21 ++- src/food-market.api/Seed/DevDataSeeder.cs | 4 +- .../Catalog/CatalogDtos.cs | 7 +- src/food-market.domain/Catalog/RetailPoint.cs | 7 + .../Organizations/Organization.cs | 5 + ...0261014010000_Phase52_CabinetSetupFixes.cs | 61 ++++++ src/food-market.web/src/components/Field.tsx | 16 +- src/food-market.web/src/components/Modal.tsx | 5 +- src/food-market.web/src/lib/types.ts | 1 + .../src/lib/useFormatCurrency.ts | 9 +- src/food-market.web/src/lib/useOrgSettings.ts | 4 + .../src/pages/EmployeeRolesPage.tsx | 24 ++- .../src/pages/EmployeesPage.tsx | 79 +++++++- src/food-market.web/src/pages/LoginPage.tsx | 12 +- .../src/pages/OrganizationSettingsPage.tsx | 40 +++- .../src/pages/RetailPointsPage.tsx | 51 ++++- .../src/pages/money/MoneyAccountsPage.tsx | 32 +++- .../CabinetSetupTests.cs | 178 ++++++++++++++++++ 22 files changed, 588 insertions(+), 62 deletions(-) create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20261014010000_Phase52_CabinetSetupFixes.cs create mode 100644 tests/food-market.IntegrationTests/CabinetSetupTests.cs diff --git a/src/food-market.api/Controllers/AuthSignupController.cs b/src/food-market.api/Controllers/AuthSignupController.cs index fd1a702..e922154 100644 --- a/src/food-market.api/Controllers/AuthSignupController.cs +++ b/src/food-market.api/Controllers/AuthSignupController.cs @@ -142,7 +142,9 @@ public async Task> 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, }); diff --git a/src/food-market.api/Controllers/Catalog/RetailPointsController.cs b/src/food-market.api/Controllers/Catalog/RetailPointsController.cs index 5ccbb50..1b3df91 100644 --- a/src/food-market.api/Controllers/Catalog/RetailPointsController.cs +++ b/src/food-market.api/Controllers/Catalog/RetailPointsController.cs @@ -44,7 +44,8 @@ public async Task>> 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 { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } @@ -54,7 +55,8 @@ public async Task> 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> 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 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(); } diff --git a/src/food-market.api/Controllers/Organizations/EmployeesController.cs b/src/food-market.api/Controllers/Organizations/EmployeesController.cs index e676e7a..7a7e6aa 100644 --- a/src/food-market.api/Controllers/Organizations/EmployeesController.cs +++ b/src/food-market.api/Controllers/Organizations/EmployeesController.cs @@ -45,7 +45,11 @@ public record EmployeeDto( // active | fired | deleted — производное от IsActive/IsDeleted, удобно для UI-бейджа string Status, IReadOnlyList 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 { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; @@ -263,6 +269,40 @@ public async Task SetPin(Guid id, [FromBody] SetPinInput input, C return Ok(new { ok = true, pinSet = true }); } + public record ResetPasswordResult(string Password); + + /// Сбросить пароль сотрудника: генерируем новый временный пароль, + /// показываем один раз (как при создании учётки). Старый пароль + /// недействителен; активные сессии сотрудника гасим (revoke токенов). + /// Работает только для сотрудника с учётной записью. + [HttpPost("{id:guid}/reset-password"), Authorize(Roles = "SuperAdmin,Admin")] + public async Task> 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 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); } diff --git a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs index 9cd00d2..d3f5e50 100644 --- a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs +++ b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs @@ -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> Get(CancellationToken ct) @@ -118,6 +127,18 @@ public async Task> 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 ReadVatRateAsync(string countryCode, CancellationTok o.PlanShops, o.PlanCashes, o.PlanWarehouses, - o.PlanEmployees); + o.PlanEmployees, + o.Bin, + o.Address, + o.IsVatPayer); } diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index e538fc8..1cb758f 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -645,19 +645,26 @@ private async Task 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); diff --git a/src/food-market.api/Seed/DevDataSeeder.cs b/src/food-market.api/Seed/DevDataSeeder.cs index 8cec32f..778e1ad 100644 --- a/src/food-market.api/Seed/DevDataSeeder.cs +++ b/src/food-market.api/Seed/DevDataSeeder.cs @@ -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); diff --git a/src/food-market.application/Catalog/CatalogDtos.cs b/src/food-market.application/Catalog/CatalogDtos.cs index 679cce1..bc69860 100644 --- a/src/food-market.application/Catalog/CatalogDtos.cs +++ b/src/food-market.application/Catalog/CatalogDtos.cs @@ -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); diff --git a/src/food-market.domain/Catalog/RetailPoint.cs b/src/food-market.domain/Catalog/RetailPoint.cs index 3081f1f..83a853e 100644 --- a/src/food-market.domain/Catalog/RetailPoint.cs +++ b/src/food-market.domain/Catalog/RetailPoint.cs @@ -14,6 +14,13 @@ public class RetailPoint : TenantEntity public string? FiscalSerial { get; set; } // серийный номер ККМ (заполнится когда подключим фискализацию) public string? FiscalRegNumber { get; set; } // регистрационный номер в ОФД public bool IsActive { get; set; } = true; + + /// Счёт (MoneyAccount, Kind=Cash), куда идёт наличная выручка смены + /// этой точки. При открытии смены подставляется по умолчанию. null — не задан. + public Guid? CashAccountId { get; set; } + /// Счёт (MoneyAccount, Kind=Acquiring/Bank), куда идёт выручка по + /// картам этой точки (эквайринг). При открытии смены подставляется. null — не задан. + public Guid? CardAccountId { get; set; } /// Id сущности в МойСкладе — для идемпотентного переноса истории. public string? MoySkladExternalId { get; set; } } diff --git a/src/food-market.domain/Organizations/Organization.cs b/src/food-market.domain/Organizations/Organization.cs index 4c96358..913760f 100644 --- a/src/food-market.domain/Organizations/Organization.cs +++ b/src/food-market.domain/Organizations/Organization.cs @@ -13,6 +13,11 @@ public class Organization : Entity public string? Email { get; set; } public bool IsActive { get; set; } = true; + /// Плательщик НДС (да/нет) — как у юрлица в МойСклад. По умолчанию + /// false. На ставку НДС товаров сейчас не влияет (та берётся из страны), + /// это реквизит организации для документов/отчётности. + public bool IsVatPayer { get; set; } + /// Архивирована ли организация. Архивные не видны пользователям /// но данные сохраняются. Из архива можно восстановить или (после 30 дней) /// удалить навсегда — этим управляет SuperAdmin. diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261014010000_Phase52_CabinetSetupFixes.cs b/src/food-market.infrastructure/Persistence/Migrations/20261014010000_Phase52_CabinetSetupFixes.cs new file mode 100644 index 0000000..16ad02e --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261014010000_Phase52_CabinetSetupFixes.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Этап «Настройка кабинета» (14.10): реквизит «Плательщик НДС», + /// счета точки продаж (наличные/карта), и точечные правки данных: + /// — п.2: чистим адрес-заглушку «Алматы, ул. Пример 1» у складов; + /// — п.4: счета KZ-организаций в BYN без движений переводим в KZT; + /// — п.11: имя владельца-сотрудника «<название> Owner» → «Владелец». + [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"";"); + } +} diff --git a/src/food-market.web/src/components/Field.tsx b/src/food-market.web/src/components/Field.tsx index fda51ed..786a463 100644 --- a/src/food-market.web/src/components/Field.tsx +++ b/src/food-market.web/src/components/Field.tsx @@ -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') { diff --git a/src/food-market.web/src/components/Modal.tsx b/src/food-market.web/src/components/Modal.tsx index 9074ba2..02cc323 100644 --- a/src/food-market.web/src/components/Modal.tsx +++ b/src/food-market.web/src/components/Modal.tsx @@ -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]) diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index eb9fd69..aa6f169 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -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 { diff --git a/src/food-market.web/src/lib/useFormatCurrency.ts b/src/food-market.web/src/lib/useFormatCurrency.ts index 0126fce..9b3cd4b 100644 --- a/src/food-market.web/src/lib/useFormatCurrency.ts +++ b/src/food-market.web/src/lib/useFormatCurrency.ts @@ -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], ) } diff --git a/src/food-market.web/src/lib/useOrgSettings.ts b/src/food-market.web/src/lib/useOrgSettings.ts index 9c88d05..867694a 100644 --- a/src/food-market.web/src/lib/useOrgSettings.ts +++ b/src/food-market.web/src/lib/useOrgSettings.ts @@ -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() { diff --git a/src/food-market.web/src/pages/EmployeeRolesPage.tsx b/src/food-market.web/src/pages/EmployeeRolesPage.tsx index f1a5c84..54dfa3d 100644 --- a/src/food-market.web/src/pages/EmployeeRolesPage.tsx +++ b/src/food-market.web/src/pages/EmployeeRolesPage.tsx @@ -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() { <> diff --git a/src/food-market.web/src/pages/EmployeesPage.tsx b/src/food-market.web/src/pages/EmployeesPage.tsx index 76d3380..60e389f 100644 --- a/src/food-market.web/src/pages/EmployeesPage.tsx +++ b/src/food-market.web/src/pages/EmployeesPage.tsx @@ -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
(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(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() { Главный администратор )} + {r.pinSet && ( + + PIN задан + + )} {r.position &&
{r.position}
} @@ -323,6 +364,9 @@ export function EmployeesPage() { {form?.id && activeEmployee?.status === 'deleted' && ( Сотрудник удалён — изменения недоступны. )} + {form?.id && activeEmployee?.hasAccount && activeEmployee?.status !== 'deleted' && ( + + )} @@ -424,6 +468,30 @@ export function EmployeesPage() {

Если ничего не выбрано — кассир работает на всех кассах.

)} + {isCashier && form.id && ( +
+
+ PIN для входа на кассе + {activeEmployee?.pinSet && PIN задан} +
+
+
+ setForm({ ...form, pin: e.target.value.replace(/\D/g, '').slice(0, 6) })} /> +
+ + {activeEmployee?.pinSet && ( + + )} +
+

+ Быстрый вход кассира на кассе по PIN (без email/пароля). Нужен для входа grant_type=pin на привязанной точке. +

+
+ )} setCreatedAccount(null)} - title="Учётная запись создана" + title={createdAccount?.reset ? 'Пароль сброшен' : 'Учётная запись создана'} footer={} > {createdAccount && (

- Передайте сотруднику данные для входа. Это окно показывается один раз — - после закрытия пароль восстановить нельзя, придётся сбросить. + {createdAccount.reset + ? 'Передайте сотруднику новый временный пароль. Старый больше не действует. Окно показывается один раз.' + : 'Передайте сотруднику данные для входа. Это окно показывается один раз — после закрытия пароль восстановить нельзя, придётся сбросить.'}

diff --git a/src/food-market.web/src/pages/LoginPage.tsx b/src/food-market.web/src/pages/LoginPage.tsx index 8dce620..02309aa 100644 --- a/src/food-market.web/src/pages/LoginPage.tsx +++ b/src/food-market.web/src/pages/LoginPage.tsx @@ -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)]'}`} diff --git a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx index 38ec893..ae14eba 100644 --- a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx +++ b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx @@ -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(null) const [nameErr, setNameErr] = useState(null) + const [binErr, setBinErr] = useState(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('Название обязательно') }} /> +
+ + { 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) }} /> + + + setForm({ ...form, address: e.target.value })} /> + +
+ setForm({ ...form, isVatPayer: v })} + /> +

+ Реквизиты организации (как у юрлица в МойСклад). Ставка НДС в карточках + товаров берётся из страны и от этого флага не зависит. +

+
setForm({ ...form, cashAccountId: e.target.value })}> + + {cashAccounts.map((a) => )} + + + + + +
+

+ Выручка кассовой смены этой точки попадёт на выбранные счета: наличные и + оплата картой. Если не выбрано — счёт подберётся автоматически. +

setForm({ ...form, isActive: v })} />
)} diff --git a/src/food-market.web/src/pages/money/MoneyAccountsPage.tsx b/src/food-market.web/src/pages/money/MoneyAccountsPage.tsx index 0697bd6..f170c86 100644 --- a/src/food-market.web/src/pages/money/MoneyAccountsPage.tsx +++ b/src/food-market.web/src/pages/money/MoneyAccountsPage.tsx @@ -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(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(null) const [err, setErr] = useState(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() { {r.name.includes('из МойСклад') && ( - Начальный остаток при переносе из МойСклад на {r.openingDate?.slice(0, 10)}: {fmt(r.openingBalance ?? 0)} + Начальный остаток при переносе из МойСклад на {r.openingDate?.slice(0, 10)}: {fmtAcc(r.openingBalance ?? 0, r.currencyCode)} )} @@ -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) => ( - {fmt(r.currentBalance)} + {fmtAcc(r.currentBalance, r.currencyCode)} )}, ]} /> @@ -198,7 +216,7 @@ export function MoneyAccountsPage() { onChange={(e) => setForm({ ...form, accountNumber: e.target.value })} />
)} - +