Domain Demand+DemandLine - зеркалит RetailSale, но всегда с CustomerId
(обязателен, не nullable), способ оплаты DemandPayment с Credit
(постоплата = дебиторка), без RetailPoint/Cashier.
EF + миграция Phase8a_Demands (idempotent CREATE TABLE).
Контроллер api/sales/demands - CRUD + Post/Unpost. Post создаёт
StockMovement тип WholesaleSale с -Quantity; защита от ухода в минус
(409 со списком конфликтов). Unpost возвращает товар.
ApplyLines пишет в DbSet напрямую (не через nav-collection) и Update
использует ExecuteDelete для старых строк - тот же fix-паттерн что в
RetailSalesController (избегает DbUpdateConcurrency на client-side Id).
Permissions переиспользуют DemandsEdit/DemandsPost (уже в RolePermissions).
Метрики observability: food_market_documents_posted_total{type="demand"}
и documents_error_total{type="demand", reason="serialization"}.
Web: /sales/demands (list+edit) с AsyncSelect контрагентов, способом
оплаты включая Credit, PaidAmount-полем для дебиторки. Сайдбар:
"Оптовые отгрузки" в группе Продажи для Admin.
Тесты: 3 интеграционных (post снижает stock + unpost восстанавливает,
over-stock posting -> 409 без побочных эффектов, tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET /api/reports/abc — топ-товары по выбранной метрике с распределением
A/B/C по Парето (A=80%, B=15%, C=5% накопительной метрики).
Параметр metric:
• revenue (по умолчанию) — выручка;
• profit — прибыль (выручка − Quantity·Product.Cost);
• margin — alias для profit (отдельная кнопка в UI).
Граничные случаи: пустой период → пустой набор; товары с net-неположительной
метрикой исключаются (некоторые отдают только возвраты — для ABC не
интересны).
Возвраты учтены со знаком (net-метрика). storeId / productGroupId
фильтры. Export CSV/XLSX.
Web: /reports/abc с цветными плашками класса (A green / B yellow / C red)
и визуальной полосой накопительной доли.
Тесты: 4 интеграционных (Парето на 3 товара 800/150/50 → A/B/C; пустой
период; profit-метрика меняет порядок против revenue; tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET /api/reports/profit с группировками period:day/week/month, product,
group (по группе товаров). Cost-snapshot — Product.Cost (скользящее
среднее, документировано как приближение; точный FIFO требует партий).
Маржа = profit/revenue·100. Защита от деления на ноль при нулевой
выручке (пустой период → margin = 0, не NaN).
Возвраты вычитаются и из выручки, и из COGS (returned line делает
−Quantity·UnitPrice выручки и −Quantity·Cost себестоимости).
Export CSV/XLSX через тот же ReportExport.
Web: /reports/profit с KPI-плашками (общая выручка/себестоимость/прибыль
+ маржа) — прибыль зелёным/красным в зависимости от знака.
Тесты: 3 интеграционных (simple profit calc 4×100−4×50=200=50%; empty
period → пустой набор без 500/NaN; tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET /api/reports/stock?date=… — восстанавливает остатки (Product, Store)
агрегацией журнала StockMovement до указанной даты. На «сейчас» совпадает
с материализованным Stock (инвариант учёта); на прошлую дату — реальная
реконструкция через Σ движений.
Edge-cases:
• дата в будущем → текущий остаток (движений из будущего нет);
• дата раньше первой операции → пусто (пара не существовала);
• операция с future-датой исключена снимком на «сегодня».
Стоимость: последний UnitCost движения до даты на пару (Product, Store);
fallback на Product.Cost если в журнале не было ни одной партии. Это
приближённая оценка — точный FIFO требует партий.
Параметры: storeId, productGroupId, includeZero (вернуть и нулевые
позиции). Export CSV/XLSX через тот же ReportExport.
Web: /reports/stock — дата, фильтры, экспорт, итоговая стоимость.
Тесты: 5 интеграционных (today=current, before-first-mov→empty, future=current,
date-before-op-excludes-it, tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET /api/reports/sales — агрегаты по period:day/week/month, product,
cashier, register, payment. Фильтры: from/to (по умолчанию last 30 days),
storeId, productGroupId. Возвраты включаются с минусом (netto-выручка
для фискальной отчётности).
GET /api/reports/sales/export?format=csv|xlsx — выгрузка через CsvHelper
(BOM UTF-8 + ; разделитель для Excel-RU) и ClosedXML.
Реализация: плоский набор строк проектируется на сервере БД (Join+Where,
EF переводит), агрегация в C#. Сознательный компромисс — EF8 не
переводит «distinct count» внутри group-проекции с join'ами по
nullable-ключам; объёмы отчётов (~десятки тысяч строк/месяц) держатся
в RAM спокойно.
Web: /reports/sales — выбор периода, табы группировки, фильтры, экспорт.
Sidebar: «Отчёты → Продажи» для Admin/Storekeeper.
Bonus: попутно вылечен баг RetailSalesController.Update — DbUpdateConcurrency
«0 affected» воспроизводился при PUT на свеже-созданный возврат
(create-return + immediate edit). Исправлено двумя изменениями:
• Update не делает Include(Lines) — старые строки удаляются ExecuteDelete'ом;
• ApplyLines добавляет новые строки напрямую в DbSet (а не через nav-collection
sale.Lines.Add) — иначе EF8 путается со state'ом из-за client-side Id (Guid).
Тесты: 5 интеграционных (group by product, group by payment, returns reduce
revenue signed, tenant isolation, CSV export). 37 интеграционных всего зелёные.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Domain SupplierReturn+SupplierReturnLine (по аналогии с Supply). Опциональная
ссылка ReferenceSupplyId на исходную приёмку — при наличии валидируется
совпадение поставщика и status=Posted источника.
EF, миграция Phase6f_SupplierReturns. Контроллер
api/purchases/supplier-returns: CRUD + Post/Unpost. Post создаёт
StockMovement тип SupplierReturn с -Quantity; защита от ухода в минус
(409 со списком конфликтов). Unpost возвращает товар обратно.
Web: /purchases/supplier-returns (list+edit). Пункт «Возвраты поставщикам»
в сайдбаре Закупки. Permissions переиспользуют SuppliesEdit/SuppliesPost/Delete.
Тесты: 4 интеграционных (post→stock, over-return→409, mismatch supplier
по reference→400, tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Domain InventoryDoc+InventoryLine (productId, bookQty, actualQty, diff).
EF, миграция Phase6d_Inventories. Контроллер api/inventory/inventories:
Create без строк автоматически подгружает все товары склада с текущим
Stock в bookQty (actual=0); Update пишет actualQty по строкам, пересчитывая
diff. Post создаёт корректирующие движения InventoryAdjustment на diff
(положительный — приход излишка, отрицательный — списание недостачи).
Unpost атомарно откатывает; проверка «излишек уже расходован» → 409.
Web: /inventory/inventories (list с разделением излишек/недостача) +
edit с импортом CSV (productId|article;actualQty). Сайдбар «Инвентаризации».
Тесты: 3 интеграционных (create-подгрузка bookQty + apply diff;
post 400 если diff=0; tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Domain Transfer+TransferLine (FromStoreId → ToStoreId, обязательны и различны).
EF, миграция Phase6c_Transfers. Контроллер api/inventory/transfers: CRUD +
Post/Unpost. Post создаёт ПАРУ движений TransferOut(-) + TransferIn(+) в
одной Serializable-транзакции; Unpost — обратная пара тоже атомарно.
Защита от ухода в минус: post (на FromStore), unpost (на ToStore — товар
мог быть уже расходован).
Web: /inventory/transfers (list+edit) с двумя селекторами складов и
визуализацией «From → To». Пункт «Перемещения» в сайдбаре. Permission
TransferEdit добавлен в RolePermissions.
Тесты: 4 интеграционных (post создаёт пару движений, unpost оставляет
ровно 4 движения и обнуляет stock-диффы; same-store → 400; short-stock
на FromStore → 409 без побочных эффектов; tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Domain Loss+LossLine + enum LossReason (Defect/Expired/Damage/Shortage/Other).
EF, миграция Phase6b_Losses. Контроллер api/inventory/losses: CRUD +
Post/Unpost. Post создаёт StockMovement тип WriteOff с -Quantity; защита
от ухода в минус (409 со списком конфликтов). Unpost возвращает товар.
Web: /inventory/losses (list+edit) с фильтром по причине и колонкой
текущего остатка в строке. Сайдбар: «Списания» (Admin/Storekeeper).
Тесты: 3 интеграционных (post→stock падает, unpost восстанавливает;
списание сверх остатка → 409; tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Domain Enter+EnterLine (мирорит Supply, но без SupplierId и без cost rollup).
EF-конфигурация, миграция Phase6a_Enters (idempotent CREATE TABLE).
Контроллер api/inventory/enters: CRUD + Post/Unpost. Post создаёт
StockMovement тип Enter; Unpost блокируется, если остаток ушёл бы в минус.
Web: /inventory/enters (list + edit), пункт «Оприходования» в сайдбаре
Admin/Storekeeper.
Тесты: 4 интеграционных (post раздаёт stock, unpost откатывает, double
post→409, tenant-изоляция A/B, unpost блокируется при минусе после продажи).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Было: каждая орга держала свои 5 копий («штука», «кг», ...). 95 строк
в БД на 19 орг — duplication, любой Admin мог их редактировать.
Стало: 5 globals (OrganizationId=NULL), CRUD только у SuperAdmin. Орга
включает нужные единицы у себя через junction org_units_of_measure.
Backend:
- UnitOfMeasure: добавлен IsActive (для soft-delete с filtered unique index)
- Новый OrgUnitOfMeasure (junction PK Organization+Unit, FK Restrict)
- Migration Phase5c_UnitsOfMeasureGlobal: безопасная для prod —
поднимает по одной строке на (Code, Name) до global, remap'ит
products.UnitOfMeasureId, наполняет junction по факту существующих
привязок, удаляет дубликаты.
- /api/catalog/units-of-measure для org Admin: read-only список
enabled-globals + POST/DELETE /enable для toggle
- /api/super-admin/units-of-measure: full CRUD; DELETE soft (IsActive=false)
с 409 если есть products или active org-junction (со списком орг)
- DevDataSeeder.SeedTenantReferencesAsync вместо создания per-tenant
юнитов — auto-enable всех active globals через junction
Frontend:
- /catalog/units — checkbox-список (включить/выключить); CTA на платформу
для SuperAdmin
- /super-admin/units — full CRUD над глобалами, 409 со списком
организаций при попытке деактивировать используемую единицу
Пункты 5 + 6 пакета SMTP-настроек.
API (AuthForgotPasswordController, anonymous):
- POST /api/auth/forgot-password { email }
· IP rate-limit: 3 попытки в час (in-memory ConcurrentDictionary
с per-IP списком timestamps; для одного API-инстанса хватает,
при scale-out — Redis).
· ВСЕГДА возвращает 200 (анти-юзер-энумерация). Реально шлёт письмо
только если юзер найден И активен И имеет email; иначе тихо
логирует и отдаёт 200.
· Использует UserManager.GeneratePasswordResetTokenAsync (Identity
AddDefaultTokenProviders уже подключён в Program.cs).
· Письмо: ссылка вида https://admin.food-market.kz/reset-password
?email=...&token=... (1 час валидна).
· Если SMTP не настроен — ловит EmailNotConfiguredException,
логирует и всё равно отдаёт 200 (UX-friendly).
- POST /api/auth/reset-password { email, token, newPassword }
· UserManager.ResetPasswordAsync. На InvalidToken — понятный
«Ссылка недействительна или истекла».
· После успеха revoke всех valid-OpenIddict tokens юзера
(UPDATE OpenIddictTokens SET Status='revoked' WHERE Subject=...).
UI:
- /forgot-password — anonymous, форма с email; submit → 200 «проверьте
почту» (одинаковый текст независимо от существования email).
- /reset-password — anonymous, читает email/token из query-string;
поля «новый пароль» + «повторите»; после успеха — auto-redirect
через 2.5 секунды на /login.
- LoginPage: добавлена ссылка «Забыли пароль?» под кнопкой «Войти».
Smoke-флоу:
1. SuperAdmin → /super-admin/platform-settings → SMTP creds + test-send.
2. Юзер → /login → «Забыли пароль?» → /forgot-password → email.
3. Письмо с ссылкой → /reset-password?email&token → новый пароль.
4. Login со старым паролем — отказ (revoked refresh + новый pwd).
5. Login с новым паролем → норма.
Страница SuperAdminPlatformSettingsPage в SuperAdmin консоли:
- Форма SMTP: Host, Port, выбор шифрования (STARTTLS / Implicit TLS / без),
Username, Password (placeholder показывает «(без изменений)» если уже
сохранён), FromEmail (обязательно), FromName.
- Поле «Причина изменения» (≥10 символов) — обязательно для PUT, пишется
в SuperAdminAuditLog.
- Блок «Тестовая отправка»: To/Subject/Body, кнопка реально шлёт через
текущие сохранённые настройки, ответ сервера показывается зелёной/
красной плашкой с message (для SuperAdmin диагностический текст MailKit
виден целиком).
Добавлен пункт меню в SuperAdminLayout «SMTP / Email» в группе «Тех.
обслуживание». Роут /super-admin/platform-settings зарегистрирован в App.tsx.
Раньше Sidebar строился только по флагу isSuperAdmin, и Кассир/
Кладовщик видели весь меню (включая «Сотрудники», «Контрагенты»,
«Настройки»), хотя серверные [Authorize(Roles = "Admin")] возвращали
бы 403 на каждом запросе.
Теперь:
- AppLayout.buildNav берёт roles[] из /api/me и собирает меню per-role:
· Каталог: Кассиру/Кладовщику только Товары; Admin — всё.
· Контрагенты: только Admin.
· Остатки: видят все три tenant-роли. Движения — Admin/Storekeeper.
· Закупки: Admin/Storekeeper.
· Продажи: Admin/Cashier.
· Импорт МойСклад, Настройки организации, Сотрудники, Роли,
Склады, Кассы — только Admin.
· Системная консоль — только SuperAdmin.
- Новый компонент RoleGuard (web/components/RoleGuard.tsx). Показывает
«Нет доступа» вместо страницы если у юзера нет нужной роли. Применён
в App.tsx для всех admin-only роутов: /settings/*, /catalog/{stores,
retail-points,counterparties}, /admin/import/moysklad. Защищает на
случай прямого ввода URL — sidebar их уже не показывает, но без
guard юзер увидел бы крутящееся колесо и 403.
Серверная авторизация ([Authorize(Roles=...)]) — основной слой защиты;
sidebar+RoleGuard — UX-слой.
API: SuperAdminEmployeesController на /api/super-admin/organizations/{orgId}/employees
- GET (с пагинацией, поиском, includeInactive=true по умолчанию)
- GET /{id} — детали + флаги isOwner/hasAccount/accountActive
- POST — создать сотрудника, опционально с учёткой (генерация temp password)
- PUT /{id} — изменить ФИО/контакты/роль/активность БЕЗ tenant-гардов
(можно править главного администратора)
- DELETE /{id}?reason=… — soft-delete; если удаляем главного администратора —
снимаем org.AccountOwnerUserId
- POST /{id}/toggle-active — активировать/деактивировать запись Employee
- POST /{id}/account/toggle-active — заблокировать/разблокировать AppUser
(revoke valid OpenIddictTokens при блокировке)
- POST /{id}/reset-password — сгенерировать новый temp password,
revoke все active токены, вернуть one-shot
Все мутации требуют reason ≥ 10 символов и пишутся в SuperAdminAuditLog
(actionType: SA_CreateEmployee / SA_EditEmployee / SA_ActivateEmployee /
SA_DeactivateEmployee / SA_ActivateAccount / SA_DeactivateAccount /
SA_ResetPassword / SA_DeleteEmployee). Эндпойнты [Authorize(Roles=SuperAdmin)],
обходят tenant-фильтр через IgnoreQueryFilters().
Web: новая страница SuperAdminOrgEmployeesPage по
`/super-admin/organizations/:id/employees`. Таблица сотрудников орги
(включая неактивных), бейдж «Главный администратор», статус учётки
(активна/заблокирована/нет). Иконки: редактировать, сбросить пароль,
блокировка учётки, активность сотрудника. Каждое действие открывает
модалку с обязательным полем «Причина» (≥10 символов) — она уходит
в audit-log. Сгенерированный пароль показывается one-shot с copy-кнопкой.
Кнопка «Сотрудники» (Users-icon) добавлена в actions колонку
SuperAdminOrganizationsPage — переход на страницу прямо из списка орг.
Аудит 2026-04-27. Полный отчёт — docs/audit-2026-04-27.md.
Что закрыто:
— /connect/token (AuthorizationController) теперь отказывает в login если
AppUser привязан к удалённой/архивной Organization. SuperAdmin обходит
проверку (ему org не нужна). Жалоба: nurnetps@gmail.com мог логиниться
после удаления своей org из SuperAdmin консоли.
— SuperAdminOrganizationsController.Delete (DELETE org) каскадно
деактивирует всех AppUser привязанных к этой org (IsActive=false,
OrganizationId=null) и помечает Status='revoked' для всех их
OpenIddictTokens. Раньше Org удалялась, а юзеры оставались валидными
с активными refresh-tokens на 30 дней.
— EmployeesController.Delete теперь soft-delete (IsActive=false,
FiredAt). Запрещены: 403 если попытка удалить себя; 403 если
попытка удалить Owner (Organization.AccountOwnerUserId ==
employee.UserId). Сообщения с инструкцией («передайте права»,
«покинуть через настройки»).
— /api/me возвращает hasLiveOrg и hasActiveEmployee — frontend
использует это для редиректа на /no-organization вместо белого экрана.
— Новая страница /no-organization (NoOrganizationPage) — fallback для
orphan AppUser. CTA: создать новую org через публичный /signup
или попросить инвайт. Кнопка «выйти». TenantRouteGuard редиректит
orphan юзеров туда.
— SuperAdminAsOrgBanner: добавлена проверка через useMe — баннер
рендерится только если у текущего юзера есть Identity-роль
SuperAdmin. Lingering localStorage override от прошлой сессии
(другой юзер логинился до этого) автоматически чистится.
— auth.ts: clearTokens() теперь сбрасывает superAdminAsOrg и
superAdminEditMode. login() вызывает clearTokens() ПЕРЕД запросом
чтобы новый юзер не унаследовал override-состояние от предыдущего.
— deploy/recovery-restore-orphan-owners.sql — идемпотентный скрипт
деактивирующий уже накопленных orphan AppUser (как nurnetps) и
revoke их токены. Применён на стейдже: 1 user деактивирован,
9 токенов revoked.
— deploy/Dockerfile.api: убран `--no-restore` из publish — два
раздельных шага роняли build с NETSDK1064 на свежих analyzer-
зависимостях, теперь restore идёт внутри publish.
Smoke (стейдж):
- nurnetps@gmail.com /connect/token → invalid_grant.
- admin@food-market.local /connect/token → access_token выдан.
- food-market.zat.kz/, /signup/, app.../login, /health → 200.
Раньше «удалить орг навсегда» было захардкожено на 30 дней архива.
Теперь — глобальная системная настройка SuperAdmin'а.
Domain/DB:
- SystemSettings : Entity (single-row table system_settings).
Поле ArchiveRetentionDays (int, default 30). Структура расширяется
именованными полями по мере необходимости — без key-value generic'а.
- Migration Phase4e_SystemSettings создаёт таблицу с default 30.
- DevDataSeeder: при первом старте создаёт single-row дефолт.
API:
- GET /api/super-admin/settings — текущие настройки.
- PUT /api/super-admin/settings — обновить с валидацией [0..3650].
Audit-log запись ActionType=EditSystemSettings с before/after.
- SuperAdminOrganizationsController.Delete: хардкод 30 заменён
чтением SystemSettings.ArchiveRetentionDays. При retention=0 —
удаление доступно сразу после архивации.
UI:
- /super-admin/settings — страница «Системные настройки».
Select из 6 опций (0/1/3/7/14/30), warning-баннер при выборе
«Немедленно». Кнопка «Сохранить» disabled пока нет изменений.
- В SuperAdminLayout убрана пометка «скоро» с пункта «Системные
настройки» — раздел активен.
- SuperAdminOrganizationsPage: кнопка «Удалить навсегда» теперь
читает retentionDays из API; tooltip показывает оставшиеся дни
«Доступно через X дн. (retention N)»; при retention=0 — всегда
active для архивных орг.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Концепция: ProductGroup и UnitOfMeasure становятся двухуровневыми
справочниками. Системные эталонные записи (OrganizationId=NULL,
управляются SuperAdmin'ом) видны всем tenant'ам как «Эталон»
и read-only. Tenant'овские (OrganizationId=<orgId>) — обычная изоляция,
полный CRUD у админа орги.
Архитектура:
- IOptionalTenantEntity { Guid? OrganizationId } — новый интерфейс
в Domain/Common. ProductGroup и UnitOfMeasure отнаследованы от
Entity и реализуют его.
- AppDbContext.ApplyOptionalTenantFilter<T>: query-filter для
IOptionalTenantEntity пропускает запись с OrganizationId=NULL для
всех tenant'ов + tenant'овские по выбранной orgId. SuperAdmin без
override видит всё, в override — только NULL+своё.
- StampTenant: при Add для IOptionalTenantEntity — null оставляется
если SuperAdmin без override (системная), иначе подставляется
tenant.OrganizationId.
- Миграция Phase4d_OptionalTenantOnDirectories: ALTER COLUMN
OrganizationId DROP NOT NULL на product_groups и units_of_measure.
Existing данные FOOD MARKET (11 групп, 5 единиц) сохраняются как
tenant'овские — additive change, ничего не теряется.
- DTO: UnitOfMeasureDto и ProductGroupDto получили nullable
OrganizationId; фронт читает его для показа badge «Эталон».
- Защита мутаций: PUT/DELETE контроллеры теперь возвращают Forbid()
если запись OrganizationId=null и юзер не SuperAdmin (только
суперадмин может править/удалять системные).
Frontend:
- Badge «Эталон» (indigo) рядом с именем системной записи в обеих
страницах.
- Клик по строке системной записи → alert «Изменения недоступны…».
- SuperAdmin sidebar: новые пункты «Группы (эталон)» (FolderTree)
и «Ед. измерения (эталон)» (Ruler) под «Справочники». Страницы
реиспользуют существующие компоненты — для SuperAdmin без override
фильтр возвращает все записи, что в Phase 4+ можно ужесточить
отдельным эндпоинтом «только системные» (?orgId=null).
Decision (нонстоп-выбор по ТЗ): nullable OrganizationId через
IOptionalTenantEntity, не sentinel Guid.Empty — чище, безопаснее,
ясная семантика. Существующие группы FOOD MARKET НЕ мигрированы в
системные (как просил юзер) — пусть SuperAdmin сам создаст эталоны.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Country — глобальный справочник (Entity, не TenantEntity), магазины-
клиенты выбирают страны из готового списка но не управляют ими.
Управление переносится в SuperAdmin консоль.
Изменения:
- API: POST/PUT /api/catalog/countries теперь Authorize(Roles=SuperAdmin)
(раньше был SuperAdmin,Admin). DELETE и так был SuperAdmin.
- GET остаётся [Authorize] без роли — нужен tenant'у для селектов в
формах создания орги/контрагентов/товаров.
- Tenant AppLayout: убран блок «Справочники» с пунктом «Страны».
Иконка Globe больше не импортируется в tenant-меню.
- Tenant роут /catalog/countries удалён из App.tsx.
- В OrganizationSettingsPage ссылка «откройте справочник Страны»
заменена на текст «обратитесь к администратору платформы».
- SuperAdminLayout: новый блок «Справочники» с пунктом «Страны»
(/super-admin/countries). Иконка Globe.
- Роут /super-admin/countries использует существующий CountriesPage —
компонент unchanged, страница теперь рендерится в SuperAdminLayout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SuperAdmin при логине больше не попадает на tenant Dashboard
(Каталог/Товары/Контрагенты/Выручка) — он стоит над всеми органами,
у него отдельная Системная консоль с системным sidebar и системным
дашбордом. Каждый из четырёх кейсов теперь визуально различим:
1. SuperAdmin → /super-admin (индиго-сайдбар, системные разделы:
Главная / Организации / Пользователи (скоро) / Журнал /
Здоровье/Бэкапы/Настройки (скоро)). В topbar — компактный
«Открыть организацию ▼» dropdown для быстрого override.
Title-suffix « · Super Admin» в browser-tab.
2. Обычный tenant-админ → /dashboard (как раньше).
3. SuperAdmin в режиме «открыть как…» → /dashboard с tenant
sidebar'ом + amber-баннер сверху + слабая жёлтая тонировка фона
body чтобы периферийным зрением было ясно «не моя админка».
4. SuperAdmin без override руками вбивает /products → TenantRouteGuard
редиректит на /super-admin/organizations + alert «Откройте
конкретную организацию через "Открыть как…"».
Изменения:
- SuperAdminLayout.tsx: индиго-палитра, Logo + badge «Super» + подпись
«Системная консоль», 7 пунктов меню (5 active + 4 «скоро»),
user-карточка с «Super Admin», topbar с org-picker dropdown,
redirect на /dashboard если активен override.
- TenantRouteGuard.tsx: в useEffect проверяет (SuperAdmin && !override)
и редиректит. Сообщение в sessionStorage для AppLayout, alert один раз.
- App.tsx: setup wizard вне layout'а; /super-admin/* через
SuperAdminLayout (nested routes); остальное через TenantRouteGuard +
AppLayout.
- AppLayout: убрана 3-пунктовая группа «Супер-админ» — оставил один
пункт «Системная консоль» как точку возврата. Тонировка фона
amber-50/60 при override.
- LoginPage: после успешного login — если me.roles.SuperAdmin и target
это /, /dashboard → redirect на /super-admin.
- SuperAdminDashboardPage: блок «Последние события» — 10 записей
audit-log + ссылка на полный журнал.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Раздел /super-admin/* доступный только пользователю с ролью SuperAdmin.
В сайдбаре отдельная группа «Супер-админ» появляется только если
me.roles содержит SuperAdmin (UI-guard, серверная авторизация
параллельно проверяется через [Authorize(Roles=\"SuperAdmin\")]).
Страницы:
- /super-admin — KPI-дашборд (orgs/users/products/supplies)
- /super-admin/organizations — таблица всех org с фильтром Активные/
Архив/Все, поиском по Name/BIN. Действия в строке: Архивировать
(модалка с вводом названия), Восстановить, Удалить навсегда (только
если в архиве >30 дней + повторное подтверждение).
- /super-admin/organizations/new — двух-секционная форма создания
(реквизиты + первый админ); в response получаем generatedPassword
и показываем в одноразовой модалке с copy-кнопками.
- /super-admin/audit-log — таблица журнала с экспортом в CSV.
- /super-admin/setup — 3-шаговый wizard (welcome → org → admin →
done с временным паролем). Авто-редирект на этот URL для SuperAdmin
если /api/super-admin/setup-status возвращает needsSetup=true (в
системе ноль организаций) — нельзя пропустить пока не создадут.
useMe()/useIsSuperAdmin() хуки в lib/useMe.ts — единая точка чтения
текущего юзера и его ролей для UI-гейтов.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Дефолтная страница после логина (/) — OnboardingPage по образу
сторонняя система «Первые шаги». Старый DashboardPage с KPI и графиком
переехал на /dashboard, в меню «Главная» теперь два пункта:
«Главная» (онбординг) и «Аналитика» (KPI/графики).
useOnboardingProgress() — хук, считает 4 шага:
- orgConfigured: country + defaultCurrency установлены
- hasEmployees: > 1 сотрудник (помимо админа)
- hasProducts: > 0 товаров
- hasSupplies: > 0 приёмок
OnboardingPage:
- Прогресс-бар «N из 4 шагов» с процентом
- 4 карточки задач: Настройки → Сотрудники → Каталог/Импорт → Приёмка
- Каждая показывает иконку (CheckCircle2 если done) + бэйдж
категории + заголовок + описание + CTA-кнопка с ArrowRight,
меняющая текст и ссылку в зависимости от done.
- Когда все 4 шага сделаны — плашка «🎉 Готово!» + переход на
/dashboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EmployeesPage (/settings/employees):
- Таблица: ФИО + должность, Роль, Email, Телефон, Учётка (есть/нет),
Статус (Активен/Уволен).
- Модалка добавления: ФИО + Position + Email + Phone + Role.
Если выбрана роль «Кассир» — появляется блок «Кассы» с чекбоксами
привязки к RetailPoint'ам (multi-select).
- Чекбокс «Создать учётную запись» (по умолчанию ✓): сервер
возвращает generatedPassword один раз, показываем в отдельной
модалке с copy-кнопками логина и временного пароля.
- Update/Delete как обычно. Снять Активен → серверная установка FiredAt.
EmployeeRolesPage (/settings/employee-roles):
- Таблица системных + кастомных ролей с счётчиком активных прав
(N/21). Системные помечены бэйджем «Системная».
- Модалка edit: имя, описание, матрица прав сгруппированная по 6
блокам (Каталог/Закупки/Продажи/Контрагенты/Отчёты/Настройки).
Удаление кнопка только для кастомных.
Меню «Настройки организации» дополнено пунктами «Сотрудники»
(иконка UserCog) и «Роли» (иконка Shield).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Валюты — закрытый сидируемый список (KZT, USD, EUR, RUB), пользователь
их не создаёт. Currency остаётся в БД как FK для Country и Organization;
GET /api/catalog/currencies продолжает работать для дропдаунов в форме
страны и настройках организации. Отдельная страница в меню избыточна.
- удалена pages/CurrenciesPage.tsx
- из роутера убран /catalog/currencies
- из сайдбара пропал пункт «Валюты», иконка Coins больше не импортируется
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Domain + миграция Phase3b_PricingCleanup:
- DROP IsActive у products / product_groups / units_of_measure /
counterparties / price_types (включая индекс
IX_products_OrganizationId_IsActive). В этих сущностях концепт
деактивации не оправдан — если товар/группа/единица/контрагент
не нужны, их физически удаляют.
- DROP organizations.MultiplePriceTypesEnabled — раздел «Типы цен»
всегда виден, отдельной настройки больше не нужно.
- ADD price_types.IsRequired bool default false — обязательность
заполнения для каждого товара.
- ADD price_types.IsSystem bool default false — защищённая запись,
не удаляется и IsRequired всегда true; имя редактируется.
В каждой организации гарантируется одна системная запись
«Розничная цена» (создаётся миграцией если её нет).
- ADD products.ShelfLifeDays integer NULL — срок годности.
API:
- ProductsController/UnitsOfMeasureController/ProductGroupsController/
CounterpartiesController/PriceTypesController: убраны параметры
isActive в фильтрах, sort-keys, DTO, Apply, Создании.
- Products проекция: вместо IsActive теперь ShelfLifeDays.
- PriceTypesController: 400 при попытке удалить системную запись;
IsRequired у системной — всегда true, не меняется через PUT.
- recalc-retail / supply posting: дефолтный PriceType ищется по
IsSystem → IsDefault → IsRetail → SortOrder → Name (без IsActive).
- OrgSettingsDto/Input — без MultiplePriceTypesEnabled.
Web:
- types.ts: убраны isActive у Product/ProductGroup/UnitOfMeasure/
Counterparty/PriceType. PriceType пополнен isRequired/isSystem.
Product получил shelfLifeDays.
- useOrgSettings: убрано multiplePriceTypesEnabled.
- AppLayout: меню «Типы цен» всегда видно.
- Pages (Counterparties/Units/ProductGroups/PriceTypes/ProductEdit/
OrganizationSettings): сняты колонки/чекбоксы/поля «Активен»;
удалён GroupMarkupsPage; в PriceTypesPage добавлен Lock-индикатор
системной записи и блок-подсказка, кнопка удаления скрыта.
- DemoCatalogSeeder и OtherSystem-импортёр: больше не пишут IsActive.
UI-перекомпоновка карточки товара (Phase3b пп.6/9), Supply Posted-toggle,
PercentInput, ShelfLifeDays-фильтр и редизайн прайс-секции — отдельными
коммитами далее по плану.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ProductGroupsPage: колонка «Наценка %» в таблице, поле «Наценка %»
в модалке редактирования с подсказкой про формулу
⌈Cost × (1 + наценка/100)⌉.
- НОВАЯ страница /settings/group-markups (GroupMarkupsPage) — массовая
правка % наценки по группам. Inline TextInput, считается diff,
кнопка «Сохранить (N)» делает PUT по каждой изменённой группе.
- AppLayout: меню «Типы цен» прячется когда multiplePriceTypesEnabled=false.
Добавлен пункт «Настройки → Наценки по группам».
- App.tsx: новый маршрут /settings/group-markups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Миграция Phase4_CountryCurrencyOrgDefaults:
- countries.DefaultCurrencyId (FK → currencies)
- organizations.DefaultCurrencyId, MultiCurrencyEnabled, DefaultVat
- Seed: KZ→KZT, RU→RUB, BY→BYN, US→USD, DE→EUR, CN→CNY, TR→TRY
- Default для org: KZT, vat=16
Backend:
- Organization сущность получила DefaultCurrency/MultiCurrencyEnabled/DefaultVat.
- OrganizationSettingsController: GET/PUT /api/organization/settings.
- DevDataSeeder при создании/backfill орга выставляет KZT + vat=16.
Web:
- /settings/organization: форма с выбором страны (авто-подтягивает валюту),
чекбоксом multi-currency, ставкой НДС по умолчанию.
- useOrgSettings() хук.
- SupplyEditPage / RetailSaleEditPage / ProductEditPage: select валюты
показывается только если multiCurrencyEnabled=true, иначе
подтягивается DefaultCurrency организации и рисуется символ валюты
справа от цены.
- ProductEditPage при создании нового товара берёт VAT из org.DefaultVat.
- В sidebar добавлен раздел 'Настройки → Организация', убран
Ставки НДС (сущность удалена раньше).
Main расходился с БД стейджа (Phase2c3_MsStrict в history, но код ещё ссылался на VatRate etc.) — деплой ломался. Реплицирую удаление сущностей вручную, чтобы код совпадал с таблицами.
Убрано (нет в OtherSystem — не выдумываем):
- Domain: VatRate сущность целиком; Counterparty.Kind + enum CounterpartyKind; Store.Kind + enum StoreKind; Product.IsAlcohol; UnitOfMeasure.Symbol/DecimalPlaces/IsBase.
- EF: DbSet<VatRate>, ConfigureVatRate, Product.VatRate navigation, индекс Counterparty.Kind.
- DTO/Input: соответствующие поля и VatRateDto/Input.
- API: VatRatesController удалён; references в Products/Counterparties/Stores/UoM/Supplies/Retail/Stock.
Добавлено как в OtherSystem:
- Product.Vat (int) + Product.VatEnabled — OtherSystem держит НДС числом на товаре.
- KZ default VAT 16% — applied в сидерах и в OtherSystemImportService когда товар не принёс свой vat.
OtherSystemImportService:
- ResolveKind убран; CompanyType=entrepreneur→Individual (как и было).
- VatRates lookup → прямой p.Vat ?? 16 + p.Vat > 0 для VatEnabled.
- baseUnit ищется по code="796" вместо IsBase.
Web:
- types.ts: убраны CounterpartyKind/StoreKind/VatRate/Product.vatRateId/vatPercent/isAlcohol/UoM.symbol/decimalPlaces/isBase; добавлено Product.vat/vatEnabled; унифицировано unitSymbol→unitName.
- VatRatesPage удалён, роут из App.tsx тоже.
- CounterpartiesPage/StoresPage/UnitsOfMeasurePage: убраны соответствующие поля в формах.
- ProductEditPage: select "Ставка НДС" теперь с фиксированными 0/10/12/16/20 + чекбокс VatEnabled.
- Stock/RetailSale/Supply pages: unitSymbol → unitName.
deploy-stage unguarded — теперь код соответствует DB, авто-deploy безопасен.
Domain (foodmarket.Domain.Sales):
- RetailSale: Number "ПР-{yyyy}-{NNNNNN}", Date, Status (Draft/Posted),
Store/RetailPoint/Customer/Currency, Subtotal/DiscountTotal/Total,
Payment (Cash/Card/BankTransfer/Bonus/Mixed) + PaidCash/PaidCard split,
CashierUserId, Notes, Lines.
- RetailSaleLine: ProductId, Quantity, UnitPrice, Discount, LineTotal,
VatPercent (snapshot), SortOrder.
- PaymentMethod enum.
EF: retail_sales + retail_sale_lines, unique index (tenant,Number),
indexes by date/status/cashier. Migration Phase2c_RetailSale.
API /api/sales/retail (Authorize):
- GET list with filters status/store/from/to/search.
- GET {id} with lines joined to products + units, customer/retail-point
names resolved.
- POST create draft (lines optional, totals computed server-side).
- PUT update — replaces lines wholesale; rejected if Posted.
- DELETE — drafts only.
- POST {id}/post — creates -qty StockMovements via IStockService for each
line (decreasing stock), Type=RetailSale; flips to Posted, stamps PostedAt.
- POST {id}/unpost — reverses with +qty movements tagged "retail-sale-reversal".
- Auto-numbering scoped per tenant + year.
Web:
- types: RetailSaleStatus, PaymentMethod, RetailSaleListRow, RetailSaleLineDto,
RetailSaleDto.
- /sales/retail list (number, date+time, status badge, store, cashier point,
customer (or "аноним"), payment method, line count, total).
- /sales/retail/new + /:id edit page mirrors Supply edit page UX:
sticky top bar (Back / Save / Post / Unpost / Delete), reqs grid with
date/store/customer/currency/payment/paid-cash/paid-card, lines table
with inline qty/price/discount + Subtotal/Discount/К оплате footer.
- ProductPicker reused. On line add, picks retail price from product's
prices list (matches "розн" in priceTypeName) or first.
- Sidebar new group "Продажи" → "Розничные чеки" (ShoppingCart).
Posting cycle ready: Supply (+stock) → ... → RetailSale (-stock).
В Stock и Движения видно текущее состояние и историю.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Domain (foodmarket.Domain.Purchases):
- Supply: Number (auto "П-{yyyy}-{000001}" per tenant), Date, Status
(Draft/Posted), Supplier (Counterparty), Store, Currency, invoice refs,
Notes, Total, PostedAt/PostedByUserId, Lines.
- SupplyLine: ProductId, Quantity, UnitPrice, LineTotal, SortOrder.
EF: supplies + supply_lines tables, unique index (tenant,Number), indexes
by date/status/supplier/product. Migration Phase2b_Supply applied.
API (/api/purchases/supplies, roles Admin/Manager/Storekeeper for mutations):
- GET list with filters (status, storeId, supplierId, search by number/name),
projected columns.
- GET {id} with full line list joined to products + units.
- POST create draft (lines optional at creation, grand total computed).
- PUT update — replaces all lines; rejected if already Posted.
- DELETE — drafts only.
- POST {id}/post — creates +qty StockMovements via IStockService.ApplyMovementAsync
for each line, flips to Posted, stamps PostedAt. Atomic (one SaveChanges).
- POST {id}/unpost — reverses with -qty movements tagged "supply-reversal",
returns to Draft so edits can resume.
- Auto-numbering scans existing numbers matching prefix per year+tenant.
Web:
- types: SupplyStatus, SupplyListRow, SupplyLineDto, SupplyDto.
- /purchases/supplies list (number, date, status badge, supplier, store,
line count, total in currency).
- /purchases/supplies/new + /:id edit page (sticky top bar with
Back / Save / Post / Unpost / Delete; reqisites grid; lines table with
inline qty/price and running total + grand total in bottom row).
- ProductPicker modal: full-text search over products (name/article/barcode),
shows purchase price for quick reference, click to add line.
- Sidebar new group "Закупки" → "Приёмки" (TruckIcon).
Flow: create draft → add lines via picker → edit qty/price → Save → Post.
Posting writes StockMovement rows (visible on Движения) and updates Stock
aggregate (visible on Остатки). Unpost reverses in place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Domain:
- foodmarket.Domain.Inventory.Stock — materialized aggregate per (Product, Store)
with Quantity, ReservedQuantity, computed Available. Unique index on tenant+
product+store.
- foodmarket.Domain.Inventory.StockMovement — append-only journal with signed
quantity, optional UnitCost, MovementType enum (Initial, Supply, RetailSale,
WholesaleSale, CustomerReturn, SupplierReturn, TransferOut, TransferIn,
WriteOff, Enter, InventoryAdjustment), document linkage (type, id, number),
OccurredAt, CreatedBy, Notes.
Application:
- IStockService.ApplyMovementAsync draft — appends movement row + upserts
materialized Stock row in the same unit of work. Callers control SaveChanges
so a posting doc can bundle all lines atomically.
Infrastructure:
- StockService implementation over AppDbContext.
- InventoryConfigurations EF mapping (precision 18,4 on quantities/costs;
indexes for product+time, store+time, document lookup).
- Migration Phase2a_Stock applied to dev DB (tables stocks, stock_movements).
API (GET, read-only for now):
- /api/inventory/stock — filter by store, product, includeZero; joins product +
unit + store names; server-side pagination.
- /api/inventory/movements — journal filtered by store/product/date range;
movement type as string enum for UI labels.
- Both [Authorize] (any authenticated user).
OtherSystem:
- MsCounterparty DTO (name, legalTitle, inn, kpp, companyType, tags...).
- OtherSystemClient.StreamCounterpartiesAsync — paginated like products.
- OtherSystemImportService.ImportCounterpartiesAsync — maps tags → Kind (supplier /
customer / both), companyType → LegalEntity/Individual; dedup by Name;
defensive trim on all string fields; per-item try/catch; batches of 500.
- /api/admin/other-system/import-counterparties endpoint (Admin policy).
Web:
- /inventory/stock list page (store filter, include-zero toggle, search; shows
quantity/reserved/available with red-on-negative, grey-on-zero accents).
- /inventory/movements list page (store filter; colored quantity +/-, Russian
labels for each movement type).
- OtherSystem import page restructured: single token test + two import buttons
(Товары, Контрагенты) + reusable ImportResult panel that handles both.
- Sidebar: new "Остатки" group with Остатки + Движения; icons Boxes + History.
Uses the ListPageShell pattern introduced in 447ac65 — sticky top bar, sticky
table header, only the body scrolls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Infrastructure (foodmarket.Infrastructure.Integrations.OtherSystem):
- OtherSystemDtos: minimal shapes of products, folders, uom, prices, barcodes from JSON-API 1.2
- OtherSystemClient: HttpClient wrapper with Bearer auth per call
- WhoAmIAsync (GET entity/organization) for connection test
- StreamProductsAsync (paginated 1000/page, IAsyncEnumerable)
- GetAllFoldersAsync (all product folders in one go)
- OtherSystemImportService: orchestrates the full import
- Creates missing product folders with Path preserved
- Maps OtherSystem VAT percent → local VatRate (fallback to default)
- Maps barcodes: ean13/ean8/code128/gtin/upca/upce → our BarcodeType enum
- Extracts retail price from salePrices (prefers "Розничная"), divides kopeck→major
- Extracts buyPrice → PurchasePrice
- Skips existing products by article OR primary barcode (unless overwrite flag set)
- Batch SaveChanges every 500 items to keep EF tracker light
- Returns counts + per-item error list
API: POST /api/admin/other-system/test — returns org name if token valid
API: POST /api/admin/other-system/import-products { token, overwriteExisting }
— Authorize(Roles = "Admin,SuperAdmin")
Web: /admin/import/other-system page
- Amber notice: token is not persisted (request-scope only), how to create
a service token in other-system.ru with read-only rights
- Test connection button + result banner
- Import button with "overwrite existing" checkbox
- Result panel with 4 counters + collapsible error list
Sidebar adds "Импорт" section with OtherSystem link.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Starter experience so the system is usable immediately after git clone → migrate → run.
DemoCatalogSeeder (Development only, runs once — skips if tenant has any products):
- 8 product groups: Напитки (Безалкогольные, Алкогольные), Молочка, Хлеб, Кондитерские,
Бакалея, Снеки — hierarchical Path computed
- 2 demo suppliers: ТОО «Продтрейд» (legal entity, KZ BIN, bank details), ИП Иванов (individual)
- 35 realistic KZ-market products with:
- Demo barcodes in 2xxx internal range (won't collide with real products)
- Retail price + purchase price at 72% of retail
- Country of origin (KZ / RU)
- Хлеб marked as 0% VAT (socially important goods in KZ)
- Сыр «Российский» marked as весовой
- Articles in kebab-case: DR-SOD-001, DAI-MLK-002, SW-CHO-001 etc.
Product form (full page /catalog/products/new and /:id, not modal):
- 5 sections: Основное / Классификация / Остатки и закупка / Цены / Штрихкоды
- Dropdowns for unit, VAT, group, country, supplier, currency via useLookups hooks
- Defaults pre-filled for new product (default VAT, base unit, KZT)
- Prices table: add/remove rows, pick price type + amount + currency
- Barcodes table: EAN-13/8/CODE128/UPC options, "primary" enforces single
- Server-side atomic save (existing Prices+Barcodes replaced on PUT)
Products page: row click → edit page, Add button → new page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop ReactQueryDevtools floating button (the "palm tree" in corner)
- Dashboard now shows: greeting with user name, stat cards, user profile
(name/email/roles/orgId), and roadmap
- Add amber banner when API calls fail (typical cause: API not restarted
after pulling new catalog code) with explicit fix instructions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>