Commit graph

175 commits

Author SHA1 Message Date
nns ec0cff7fc4 feat(concurrency): RowVersion на документах через Postgres xmin (TD-6)
Optimistic concurrency через системную колонку Postgres xmin — никакой
дополнительной колонки и миграции не нужно, xmin есть у каждой таблицы
и автоматически обновляется при UPDATE.

Конфигурация:
- IVersionedEntity (маркер) + uint Xmin на Supply, Demand, RetailSale,
  Transfer, InventoryDoc.
- e.UseXminAsConcurrencyToken() в EF-конфиге для каждой — создаёт shadow
  property "xmin" с IsConcurrencyToken + ValueGeneratedOnAddOrUpdate.
- e.Ignore(x => x.Xmin): .NET-property живёт только для транспорта в DTO,
  не маппится в БД (xmin тащим shadow'ом).
- GetInternal в SuppliesController читает xmin через
  EF.Property<uint>(s, "xmin") в LINQ-проекции и складывает в DTO.

Wire-up:
- SuppliesController.Update принимает input.Xmin (uint?), сверяет с
  shadow xmin загруженного supply через EF.Entry().Property("xmin").
  Несовпадение → 409 с code=concurrency_conflict. null/0 от клиента →
  legacy compat, проверки нет.
- SaveOrFkErrorAsync ловит DbUpdateConcurrencyException → 409 (двойная
  защита: и явная сверка, и EF auto-check в SaveChanges).

Bonus: Supply.Update перешёл на тот же паттерн что Demand/RetailSale —
ExecuteDelete старых строк + AddRange новых напрямую в DbSet. Старый
RemoveRange-then-Add через nav-collection ломал EF concurrency check
(UPDATE supply_lines одной из старых строк падал 0 affected внутри той
же SaveChanges-транзакции).

Тесты: 2 интеграционных:
- two parallel updates with same xmin → один 204, другой 409; retry
  с новым xmin тоже 204.
- legacy clients без xmin → PUT работает без concurrency-проверки.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:33:01 +05:00
nns b963adfa2e feat(import-jobs): persisted ImportJobRegistry в БД (TD-5)
Раньше прогресс фоновых импортов жил в ConcurrentDictionary внутри
Singleton-сервиса: рестарт процесса терял всю историю, активные
джобы навсегда оставались в статусе Running.

Теперь:
- Domain.Integrations.ImportJob (TenantEntity) — таблица import_jobs,
  миграция Phase8c_ImportJobs (jsonb для ErrorsJson, индексы по
  OrgId+StartedAt / OrgId+Status / FinishedAt).
- ImportJobRegistry рефакторен: Create() пишет строку немедленно,
  SaveAsync() обновляет, Get/RecentlyFinished читают из БД. API
  совместимое со старой in-memory версией — MoySkladImportService
  и контроллеры не меняются.
- MoySkladImportController.RunInBackgroundAsync теперь:
  * Periodic flush через Timer каждые 2 секунды — UI видит
    реальный progress (Stage/Created/Total), а не Create-snapshot;
  * Финальный flush в finally — обязательный для terminal state.
- AdminCleanupController.WipeAllAsync — то же финальное сохранение.
- SkipAudit=true для import-job записей — служебные, в OrgAuditLog
  не пишем.

Tenant-isolation: query-filter работает прозрачно, B не видит джоб A.

Тесты: 3 интеграционных (survives across scope, RecentlyFinished
читает из БД, tenant-isolation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:45:08 +05:00
nns 25d92c989a feat(email): HTML-шаблоны MailKit + invite/weekly/low-stock джобы (P1-22)
Application:
- IEmailSender.SendHtmlAsync(html, textFallback) - multipart/alternative
  с plain-text для клиентов без HTML;
- EmailTemplateRenderer - минимальный mustache-light:
  {{key}} (HTML-escape), {{{raw}}} (без escape), {{#key}}...{{/key}}
  (условный блок, truthy если не-null/не-пусто/не-"0"/не-"false");
- EmailTemplates - загрузчик embedded HTML-шаблонов из
  Resources/EmailTemplates/*.html, кеш after-first-read, parse
  "Subject: <тема>" из первой строки, плюс plain-text strip для fallback.

Шаблоны (embedded в food-market.api.dll):
- invite.html - приглашение сотрудника с временным паролем и кнопкой
  «Открыть Food Market».
- weekly-summary.html - выручка/чеки/средний чек за неделю + топ-товары.
- low-stock.html - таблица товаров с stock < MinStock.

EmployeesController.Create принимает SendInvite (требует CreateAccount):
формирует payload из orgName/loginUrl/role и шлёт через SendHtmlAsync.
SMTP-ошибки логируются warning'ом, не блокируют создание (showOnce
tempPassword фронту всё равно отдаётся).

Hangfire recurring jobs (EmailNotificationJobs):
- weekly-summary: cron "0 7 * * 1" (понедельник 07:00 UTC) - по каждой
  активной орге считает revenue/tx/avgTicket/top-5, шлёт Admin'ам;
- low-stock-alert: cron "0 8 * * *" - товары с sum(stock)<MinStock,
  шлёт Admin'ам. AsyncLocal tenant override на каждую орг чтобы
  query-filter работал корректно.

Тесты: 8 unit на EmailTemplateRenderer + EmailTemplates (escape, raw,
условные блоки, invite/low-stock-шаблон-loaders). Все 35 unit
зелёные (27 + 8 новых).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:37:32 +05:00
nns a189a5dd6e feat(audit): per-tenant журнал мутаций OrgAuditLog (P1-18)
Domain OrgAuditLog (TenantEntity) - per-org журнал create/update/delete
для Supply/SupplierReturn/RetailSale/Demand/Product/ProductPrice/
ProductBarcode/Counterparty (белый список в IsTracked).

Реализация: OrgAuditInterceptor (SaveChangesInterceptor) снимает diff на
SavingChanges (до commit), пишет в тот же DbContext в той же транзакции -
атомарно с самой мутацией. ChangesJson формата
{ "field": { "before": X, "after": Y } } - служебные поля
(OrganizationId/CreatedAt/UpdatedAt) пропускаются.

ITenantContext получил UserId (sub claim) для атрибуции событий.
AppDbContext.SkipAudit - escape-hatch для сидеров/системных операций.

Tenant-isolation: query-filter обычный TenantEntity-фильтр. B не видит
audit-строки A; SuperAdmin без override видит всё.

Контроллер GET /api/admin/audit-log с фильтрами entityType / entityId /
userId / action / from / to. Permission OrgSettingsManage.
Web: /audit-log для Admin'а - таблица с раскрывающимся JSON diff'ом,
цветные плашки create/update/delete, фильтры по типу и действию.

Миграция Phase8b_OrgAuditLog: jsonb-колонка, индексы
(OrgId+CreatedAt), (OrgId+EntityType+EntityId), (OrgId+UserId+CreatedAt).

Тесты: 3 интеграционных (create Product создаёт audit-запись;
update Counterparty - diff содержит before/after; tenant-изоляция:
B не видит записи A).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:26:36 +05:00
nns 47a019dc6d feat(demands): оптовая отгрузка контрагенту-юрлицу (P1-5)
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>
2026-05-28 16:18:49 +05:00
nns 824ef8279c feat(observability): Prometheus метрики /metrics + бизнес-счётчики (P1-17)
prometheus-net.AspNetCore@8.2.1 + EF Core DbCommandInterceptor.

Endpoint: GET /metrics (text exposition, без auth — типичная практика;
на prod закроем nginx allow private-network).

Стандартные метрики (через UseHttpMetrics):
- http_requests_received_total (code/method/controller/action)
- http_request_duration_seconds (histogram, p50/p95/p99 SLO)
- process_cpu_seconds_total / dotnet_total_memory_bytes / GC counters

Кастомные бизнес-метрики (AppMetrics):
- food_market_documents_posted_total{type} — все типы документов
- food_market_sales_posted_total — alias по retail-sale (явно в SLO)
- food_market_supplies_posted_total — alias по supply
- food_market_documents_error_total{type, reason} — ошибки проведения
  с разбивкой по причине (serialization=40001, insufficient_stock,
  number_conflict, validation, other)
- food_market_db_query_duration_seconds{kind} — гистограмма SQL через
  DbMetricsInterceptor (kind=query для SELECT, command для CUD)

Tenant-меток в кастомных метриках НЕТ сознательно: на multi-tenant хосте
раздуло бы cardinality. Per-org разрез — через /api/reports/*.

Counters добавлены в:
- SuppliesController.Post (success + serialization-error)
- RetailSalesController.Post (success)
- PosController.CreateAndPostSaleAsync (success + number_conflict)

docs/observability.md — scrape-конфиг prometheus.yml, образец Grafana
dashboard (4 ряда: Health/Business/Database/Runtime), prometheus rules
с alert'ами (HighErrorRate, DbSerializationContention, NoSalesIn30Min).

Тесты: 3 интеграционных (endpoint доступен и возвращает text/plain с
встроенными метриками; sales counter инкрементится после Post; db_query
гистограмма накапливается).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:20:01 +05:00
nns 640c8d9c22 feat(pos-api): GET /sync и POST /sales с двойной идемпотентностью (P1-12b)
Endpoints:
- GET /api/pos/v1/sync?since=ISO&storeId=Guid - выгрузка изменений
  (Products / Prices / Stocks / Counterparties) после reference time;
  Stocks - всегда полный снимок на момент ответа (POS нужен актуальный
  остаток на полке).
- POST /api/pos/v1/sales - батч продаж с idempotency.

Двойная идемпотентность:
1. Batch-level: PosBatchAck (новая таблица, unique idx по OrgId+Key) -
   повтор того же батча возвращает кешированный ответ. При параллельном
   race ловим 23505 на уникальном индексе и тоже возвращаем кеш.
2. Per-sale: ClientSaleId записывается в RetailSale.Notes как prefix
   "pos:GUID32". Перед созданием продажи проверяем что такой маркер ещё
   не встречался - если есть, возвращаем существующую продажу. Это
   спасает и при разных batch-ключах с пересекающимися ClientSaleId.

Pre-flight: проверка остатка ДО создания черновика - sale, которая не
влезает в полку, попадает в Failed, остальные в батче проводятся.

Domain: PosBatchAck (TenantEntity), миграция Phase7a_PosBatchAcks
(jsonb для ResponseJson, unique idx).

Контракты v1 из food-market.shared.

Тесты: 7 интеграционных - полная sync, дельта по since, POST батч
списывает stock, replay того же батча no-duplicates, ClientSaleId через
разные batch-keys тоже no-duplicates, недостача попадает в Failed,
tenant-изоляция.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:10:17 +05:00
nns dbd08f6fd2 feat(openapi): улучшенный Swagger + TS-клиент через openapi-typescript (P1-19)
API:
• SwaggerGen с OpenAPI info (title/version/description),
  Bearer security-scheme (через OpenIddict JWT),
  стабильные operationId = Controller_VerbAction (HTTP-verb включён
  чтобы избежать коллизии когда ASP.NET стрипает Async-суффикс —
  WipeAll и WipeAllAsync ранее давали одинаковый operationId);
• CustomSchemaIds с префиксом из namespace (одноимённые nested
  record'ы в разных контроллерах больше не схлопываются — StockRow
  есть в Inventory_StockController и Reports_StockReportController).

UI:
• /swagger (UI) и /swagger/v1/swagger.json (документ) — только в Development.
  На prod не раскрываем (endpoint enumeration).

Web:
• Добавлен devDependency openapi-typescript@^7.5.2 + npm-script gen:api,
  читающий http://localhost:5081/swagger/v1/swagger.json.
• src/lib/api.generated.ts — сгенерированные типы (~7700 строк, все
  схемы и operations).
• src/lib/apiClient.ts — тонкая обёртка над axios api, использующая
  типы из generated. Подключена для пары контроллеров (Reports/Sales,
  Reports/ABC, Reports/Profit) как образец постепенной миграции.

docs/openapi.md — workflow генерации (live API или Swashbuckle CLI),
versioning, наставления для нового кода.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:39:22 +05:00
nns dcf8f60b67 feat(reports): ABC-анализ по Парето (P1-11)
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>
2026-05-28 11:24:26 +05:00
nns 3db112cbee feat(reports): отчёт «Прибыль» (выручка − COGS) (P1-10)
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>
2026-05-28 11:19:19 +05:00
nns 63c58ef6c1 feat(reports): отчёт «Остатки на дату» с реконструкцией (P1-9)
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>
2026-05-28 11:14:24 +05:00
nns ac77849901 feat(reports): отчёт «Продажи» с группировками и экспортом (P1-8)
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>
2026-05-28 11:09:52 +05:00
nns a74fa114d8 feat(hangfire): dashboard + scheduled cleanup джобы (P1-16)
Hangfire.PostgreSql storage (тот же ConnectionString:Default). Сервер
стартует только когда Hangfire:Enabled (true по умолчанию) — в
интеграционных тестах выключаем через env Hangfire__Enabled=false,
чтобы тесты не плодили служебные таблицы в одноразовом контейнере.

Dashboard на /hangfire с авторизационным фильтром SuperAdminHangfireFilter —
требует роли SuperAdmin (стандартный OpenIddict-токен валидируется
аутентификационным middleware'ом перед этим).

Recurring jobs (HangfireJobsConfigurator):
• prune-stock-movements — ежедневно 03:30 UTC, удаляет StockMovement
  старше 730 дней (Hangfire:Retention:StockMovementDays). За 30 минут
  до бэкапа, чтобы pg_dump не цеплял временные блокировки.
• prune-audit-log — ежедневно 03:45 UTC, удаляет super_admin_audit_log
  старше 90 дней (Hangfire:Retention:AuditLogDays).

Логика очистки в HousekeepingJobs (scoped, использует AppDbContext с
IgnoreQueryFilters — это межтенантная задача).

Тесты: 1 unit (PruneStockMovements удаляет только старые), 1 интеграционный
(dashboard не отвечает без Hangfire-сервера). Полный прогон:
24 unit + 32 integration = 56 зелёных.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 10:07:14 +05:00
nns 6886e1a92b feat(supplier-returns): возврат поставщику (P1-7)
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>
2026-05-28 09:58:29 +05:00
nns ea29ab63c7 feat(returns): возврат от покупателя (CustomerReturn) (P1-6)
Расширение RetailSale: IsReturn (bool) + ReferenceSaleId (Guid?, self-FK) +
RetailSaleLine.QtyReturned (агрегация для защиты от over-return).
Миграция Phase6e_RetailSaleReturns (IF NOT EXISTS, можно гонять на стейдже).

Контроллер api/sales/retail:
• Create принимает isReturn/referenceSaleId; для возврата с reference —
  валидация что reference существует, не сам возврат, и проведён.
• POST /{id}/create-return — создаёт Draft-возврат от проведённого чека,
  копируя строки с qty = (Quantity - QtyReturned).
• Post с IsReturn=true идёт через PostReturnAsync: проверяет лимит
  возврата по reference (Quantity - QtyReturned), создаёт StockMovement
  тип CustomerReturn с +Quantity, инкрементит QtyReturned на источнике.
• Unpost для возврата зеркально откатывает.
• Запрещён unpost исходного чека пока есть проведённые возвраты на него.

Web: кнопка «Создать возврат» на странице проведённой продажи. DTO
расширены полями isReturn/referenceSale*/qtyReturned.

Тесты: 3 интеграционных (полный цикл sale→create-return→post,
standalone-return без reference, блокировка unpost при активных возвратах).
Полный прогон: 27 зелёных интеграционных, 23 зелёных unit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:51:04 +05:00
nns 4285bdee91 feat(inventories): инвентаризация с CSV-импортом факта (P1-4)
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>
2026-05-28 09:39:32 +05:00
nns fa1e123327 feat(transfers): атомарное перемещение между складами (P1-3)
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>
2026-05-28 09:32:32 +05:00
nns 3172b0ea72 feat(losses): списание со склада с указанием причины (P1-2)
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>
2026-05-28 09:24:40 +05:00
nns e392bf8ae9 feat(enters): оприходование товара без поставщика (P1-1)
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>
2026-05-28 09:18:13 +05:00
nns f2dad91e05 test(integration): Testcontainers.PostgreSql + WebApplicationFactory, 10 тестов (P1-21)
ApiFactory поднимает реальный API на одноразовом postgres:16-alpine (Ryuk off —
сеть к Docker Hub нестабильна, образ закэширован; RateLimiting off через env, т.к.
лимитер читает конфиг эагерно). Program сделан public partial для фабрики.

Сценарии (10 зелёных):
- signup-flow: signup→token→/api/me с org; дубль-signup 400; слабый пароль 400.
- tenant isolation A vs B: контрагент A не виден B (список + прямой GET 404).
- permission: кастомная роль без ProductsEdit → PUT товара 403, GET 200; админ не 403.
- supply post→unpost: остаток 0→10, Cost=70 (скользящее среднее), unpost→0; двойной post 409.
- retail overselling: продажа сверх остатка → 409; недоплата → 400.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 03:14:01 +05:00
nns f3d517f257 test(unit): xUnit-проект food-market.UnitTests, 23 теста (P1-20)
Чистая логика вынесена в Application для тестируемости и используется контроллерами:
- MovingAverageCost.Compute (скользящее среднее себестоимости) ← SuppliesController.Post
- RetailPaymentValidator.IsSufficient (достаточность оплаты) ← RetailSalesController.Post

Тесты:
- MovingAverageCost: первая приёмка, средневзвешенное, округление до 4 знаков, totalQty=0.
- RetailPaymentValidator: ровно/переплата/недоплата, округление до 2 знаков.
- StockService.ApplyMovement (SQLite in-memory): материализация Stock+движение,
  инкремент, отрицательное списание, throw без tenant.
- Мультитенантный query-filter AppDbContext: tenant видит своё; чужой не видит;
  SuperAdmin без override — всё; с override — только выбранную оргу.

Все 23 зелёные. EF8 SQLite поддерживает ToJson (EmployeeRole.Permissions).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 03:01:56 +05:00
nns 422b7ad5ea feat(auth): prod X509-ключи OpenIddict с persistent self-signed (P0-1)
OpenIddictKeyConfigurator: dev — прежний RSA-ключ в App_Data (поведение не
менялось, шифрование access-token выключено); prod/stage — отдельные X509
сертификаты подписи и шифрования из конфига (OpenIddict:SigningCertPath /
EncryptionCertPath / CertPassword, можно env). Нет файла → генерируется
persistent self-signed (RSA 2048, 5 лет) и сохраняется в App_Data (volume),
а не dev-ephemeral — токены переживают рестарт.

Проверено: prod выдаёт 5-сегментный JWE, /api/me 200; рестарт → те же
сертификаты (fingerprint совпал), pre-restart токен валиден. dev — 3-сегментный
JWT, /api/me 200. docs/openiddict-keys.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:47:00 +05:00
nns 688be30226 test(e2e): roles step08 проверяет permission-enforcement + rate-limit конфигурируем
- roles.steps.ts step08: было «задокументированный gap», стало реальная
  проверка — кастомная роль без ProductsEdit → 403 на PUT товара, GET → 200.
  Сценарий roles зелёный 8/8.
- RateLimiting:* конфиг (Enabled/PerMinute/PerHour): тесты с общим loopback-IP
  поднимают/выключают лимит, чтобы повторные логины не упирались в 429.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:41:52 +05:00
nns 28010fafdb feat(authz): permission-based авторизация по флагам роли (P0-5)
PermissionAuthorizationHandler + [RequiresPermission("...")] + динамический
PermissionAuthorizationPolicyProvider (policy perm:*). Доступ определяют флаги
RolePermissions роли сотрудника (live из БД), а не зашитый список Identity-ролей.

SuperAdmin и Identity-роль Admin (= системная «Администратор» с All()) —
полный доступ шорткатом; custom-роли не маппятся на Admin, поэтому шорткат их
не задевает. Нет активного Employee/нет флага → 403 (fail-closed).

Заменены [Authorize(Roles=...)] в каталоге (Products/ProductGroups/PriceTypes/
Counterparties/Stores/RetailPoints/Units/ProductImages) и документах (Supplies/
RetailSales) на конкретные права. Currencies/Countries оставлены SuperAdmin
(глобальный справочник, не org-permission).

Проверено curl на :5091: custom-роль без ProductsEdit → PUT товара 403;
GET 200; admin/после выдачи права → 400 (не 403). Закрывает «роли — фикция» из аудита.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:37:28 +05:00
nns 82bf53bb5c feat(api): health-пробы /health/live и /health/ready (P0-4)
/health/live — liveness без зависимостей (Predicate=false).
/health/ready — readiness: DatabaseReadyHealthCheck (CanConnect + нет
неприменённых миграций), тег ready, 503 если не готово. JSON-ответ по
каждому чеку. docker-compose api healthcheck + Dockerfile.api → /health/ready,
web ждёт api service_healthy. /health сохранён для обратной совместимости.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:23:48 +05:00
nns 8048c44ee4 feat(api): rate-limit /connect/token и /api/auth/signup (P0-3)
Sliding window на IP: 5/мин + 20/час (оба окна chained, оба должны
пропустить). Отдельные бакеты на эндпоинт — регистрация не съедает лимит
логинов. Глобальный лимитер с no-op для не-auth путей: двойное окно
per-endpoint policy выразить не может. 429 + JSON-телом, X-Forwarded-For
учитывается за прокси. Проверено curl'ом: 6-я попытка/мин → 429.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:20:02 +05:00
nns 01568baf4f fix(superadmin): change-owner требует reason ≥ 10 символов
Смена владельца организации писала reason в журнал аудита, но проверяла лишь
его непустоту — короткие/мусорные причины («ok») проходили. PlatformSettings
для SMTP уже требует ≥10 символов; приводим change-owner к той же планке
(ТЗ 2.8: «Reason < 10 символов → 400»), чтобы журнал аудита оставался
осмысленным.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:53:32 +05:00
nns 5091d43f5d fix(employees): увольнение/деактивация гасит логин связанного User
Employee.Delete (увольнение и soft-delete) и Employee.Update (деактивация)
меняли только Employee.IsActive, но не трогали связанный AppUser. Логин и
refresh в AuthorizationController гейтятся на User.IsActive — поэтому
уволенный сотрудник продолжал входить и обновлять токены до 30 дней (ТЗ 0.4:
«возможность залогиниться как удалённого сотрудника» = баг, P0).

Добавлен SetLinkedUserActiveAsync: при деактивации сотрудника гасит
User.IsActive и отзывает его valid OpenIddict-токены (как при удалении орг),
при реактивации через Update — возвращает доступ. Вызывается из DELETE (оба
шага) и из Update при смене активности.

Найдено сценарием employees step07 (было: login/refresh уволенного → 200).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:47:56 +05:00
nns e78e921dd2 chore(moysklad): базовый URL MoySklad — из конфигурации (MoySklad:BaseUrl)
MoySkladClient.BaseUrl был константой api.moysklad.ru, из-за чего импорт
нельзя было протестировать без боевого токена. Регистрация HttpClient теперь
берёт BaseAddress из MoySklad:BaseUrl (дефолт — прежний боевой URL), так что
e2e/интеграционные тесты наводят клиент на mock-сервер, не трогая прод.
MoySkladClient не меняем — он уже делает BaseAddress ??= const.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:27:16 +05:00
nns 15f27fd16e fix(supplies): сериализуемое проведение приёмки против lost update остатков
Supply.Post шёл на дефолтной изоляции (Read Committed), а
StockService.ApplyMovementAsync делает read-modify-write по Stock.Quantity
без RowVersion. Под конкуренцией это ломало главный инвариант
Stock.Quantity == Σ StockMovement.Quantity:

- двойное проведение ОДНОЙ приёмки (оба запроса читают Status=Draft до
  коммита соседа) применяло остаток дважды — два StockMovement, но Stock
  рос лишь на одну партию (lost update);
- две разные приёмки одного товара могли потерять обновление остатка и
  посчитать скользящее среднее Cost от устаревшего currentQty.

Переводим проведение на IsolationLevel.Serializable (как RetailSale.Post)
и ловим конфликт сериализации (SQLSTATE 40001/40P01) → 409, чтобы клиент
повторил, а не получал 500. Найдено сценарием stock-concurrency
(step03: было stock=32/sum=39 → стало 32/32, statuses 204+409).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:16:11 +05:00
nns e13ac655e5 fix(catalog): FK-guard удаления контрагента + валидация полей товара
Найдено в catalog-edge:

- DELETE контрагента, на которого ссылаются supplies/retail-sales/products
  (DefaultSupplier), отдавал 500 (DbUpdateException 23503) вместо понятного
  409. Добавлен явный чек использования → Conflict со списком где занят.
- POST товара с пустым Name проходил до FK-проверки и падал неинформативно;
  теперь явный 400 с указанием поля. На ProductInput навешены
  [Required]/[MinLength]/[StringLength] на Name/Article/ImageUrl — отсекаем
  пустые и сверхдлинные значения на уровне модели.

catalog-edge: 12/12.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:03:37 +05:00
nns 32729e72a3 fix(auth): refresh-token rotation немедленно инвалидирует старый токен
Два бага в refresh-flow, из-за которых утёкший refresh-token давал доступ
после ротации (auth-edge step03):

1. AuthorizationController прокидывал в новый principal только
   AuthorizationId, но не TokenId. Handler RedeemTokenEntry читает TokenId
   из подписываемого principal, чтобы пометить погашаемый refresh как
   Redeemed — без него старый токен оставался valid.
2. Даже после починки редемпшна OpenIddict по умолчанию даёт 30-секундный
   reuse-leeway: погашенный refresh ещё принимается в этом окне. Обнуляем
   окно (SetRefreshTokenReuseLeeway(TimeSpan.Zero)) — ротация инвалидирует
   старый refresh сразу.

auth-edge: 10/10 (было 9/10).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:03:29 +05:00
nns 7a4b34bc2f fix(documents): защита денег и инварианта остатков на posting-операциях
Два P0-бага выявлены сценарием documents-edge:

BUG #11 (high): RetailSale.Post не проверял PaidCash+PaidCard ≥ Total.
Касса могла «провести» чек с фактической оплатой 0 — товар уходит
со склада, деньги не получены. Добавлена валидация: paid (округлённое
до 2 знаков) ≥ total, иначе 400 «Сумма оплаты меньше итога».
Сдача (PaidCash > Total) остаётся легальной.

BUG #12 (critical): Supply.Unpost не проверял, не уйдёт ли Stock в
минус после реверса. Сценарий: приёмка 10шт → продажа 5шт → unpost
приёмки ⇒ stock = -5. Это нарушение инварианта учёта. Добавлен guard:
агрегируем reverse-quantity по продукту, сравниваем с текущим
Stock.Quantity, при недостаче возвращаем 409 со списком конфликтных
строк.

Покрыто E2E documents-edge step05 (PaidCash<Total → 4xx) и step08
(unpost после sale → 409): обе проверки теперь зелёные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:33:40 +05:00
nns ab5c4c970d fix(security): SuperAdmin edit-mode override обходит [Authorize(Roles=Admin)]
Проблема: в режиме «открыть как…» (SuperAdmin + X-Org-Override) с reason ≥10
символов ReadonlyOverrideMiddleware пропускает PUT/POST/DELETE, но затем
контроллер падает 403 на атрибуте [Authorize(Roles="Admin,Storekeeper")] —
у SuperAdmin'а нет роли Admin тенанта. Результат: edit-mode фактически
не работает ни на одном tenant-эндпоинте.

Симптом, обнаруженный E2E:
  step11_superadmin_edit_override_with_reason: PUT → 403 «Forbidden»,
  super_admin_audit_log не растёт.

Фикс: новый SuperAdminOverrideClaimsTransformer (IClaimsTransformation).
При каждом запросе с заголовком X-Org-Override и ролью SuperAdmin
временно добавляет роли Admin/Storekeeper/Cashier в principal — только
для этого запроса. Изоляция и аудит остаются:
  - query filter всё равно скоупится через X-Org-Override (см.
    HttpContextTenantContext.TryGetHttpOverrideOrg).
  - SuperAdminEditAuditFilter пишет SuperAdminAuditLog с reason
    при успешном 2xx ответе.

Проверено E2E multi-tenant-isolation: 12/12 шагов проходят.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:24:52 +05:00
nns 319a91ff10 feat(bootstrap): системная ProductGroup «Все товары» при создании org
Гэп из e2e-отчёта: новая орга стартует с пустым каталогом групп, и
ProductsController.Create падает с 400 «ProductGroupId required» пока
юзер вручную не заведёт группу. Это плохой UX — особенно для quick-
create товара из чека или приёмки.

Что сделано:
- ProductGroup получил поле IsSystem (default false) + миграция Phase5e.
- DevDataSeeder.SeedTenantReferencesAsync теперь создаёт идемпотентно
  системную группу «Все товары» (IsSystem=true) при bootstrap'е новой
  org. Та же логика срабатывает в SuperAdminOrganizationsController.Create
  и AuthSignupController, потому что оба зовут SeedTenantReferencesAsync.
- ProductGroupsController.Delete: системная группа защищена от удаления
  (400 «Системную группу удалить нельзя.»). Иначе продукты могли бы
  осиротеть после ON DELETE RESTRICT.
- ProductEditPage / ProductQuickCreateModal: при создании нового товара
  автоматически выбирают «Все товары» (или единственную группу), чтобы
  пользователь мог сохранить продукт без лишнего клика.
2026-05-08 12:08:28 +05:00
nns 57168299ac fix(validation): обязательные FK-Guid проверяются на 400 + DbUpdateException → 400
Было: SupplyInput.SupplierId/StoreId/CurrencyId — non-nullable Guid. Если
JSON приходит без поля или с null, оно десериализуется в Guid.Empty, и
ошибка проявляется только на SaveChanges как PostgresException 23503
(FK violation) с HTTP 500. UI получает generic 500 и не понимает какое
поле виновато.

Что изменено:
- Добавлен helper RequiredGuid.FirstMissing(...) — возвращает имя первого
  Guid.Empty поля или null.
- SuppliesController.Create/Update, RetailSalesController.Create/Update,
  ProductsController.Create/Update — теперь начинают с проверки FK-Guid'ов
  и возвращают 400 {error, field} если какое-то пусто.
- В тех же контроллерах SaveChanges обёрнут в SaveOrFkErrorAsync, который
  ловит PostgresException SqlState=23503 (foreign_key_violation), парсит
  ConstraintName и возвращает 400 вместо 500. Защита для случая когда
  Guid не пуст, но указывает на удалённую/чужую запись.

TaskUpdate: closes step08-bug «Supply без supplierId → 500».
2026-05-08 12:05:01 +05:00
nns 9eb1a6c69a fix(retail-sale): блок overselling в Post — 409 если qty>остатка
Сценарий из e2e: остаток 10, продаём 99999 → /post возвращал 204
[Posted] и стоки уходили в минус. Это критично — кассир мог провести
чек на товар, которого нет, и в БД появлялся отрицательный остаток.

Что изменено:
- В Post перед SaveChanges собираем сумму запрошенного qty по каждому
  productId (учитываем дубль одного товара в нескольких строках чека).
- Читаем stocks.Quantity для всех затронутых productId на конкретном
  StoreId одним запросом.
- Если хоть по одной строке available < requested — возвращаем 409
  с body {error, lines:[{productId, productName, qty, available}]},
  не делаем SaveChanges.
- Всё под BeginTransactionAsync(Serializable): защищает от race condition
  между двумя одновременными post'ами на один товар (без блокировки оба
  бы прочли «5», списали по 3, получили бы −1).
2026-05-08 12:01:20 +05:00
nns bf53629092 refactor(units): drop Description, hide Code from non-SuperAdmin UI
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m24s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m33s
Docker Web / Build + push Web (push) Successful in 37s
Docker API / Deploy API on stage (push) Successful in 18s
Docker Web / Deploy Web on stage (push) Successful in 12s
Description у пяти канонических ОКЕИ-единиц никогда не заполнялось ни UI,
ни импортом, ни сидером — выкидываем поле полностью (Domain → EF-config
→ DTO → Input → frontend types → Super-Admin форма). Migration
Phase5d_DropUnitOfMeasureDescription дропает колонку.

Code оставляем в БД (нужен для интеграций МойСклад/1С), но скрываем от
org Admin'а:
- /catalog/units-of-measure — только колонки Name + кнопка toggle, без
  Code и Description; поиск/сортировка только по Name.
- /super-admin/units-of-measure — Code продолжает показываться в таблице
  и форме редактирования.

Дропдаун единиц в ProductEditPage / ProductQuickCreateModal уже отдаёт
только {u.name} в options, проверено. На SupplyEditPage/RetailSaleEditPage
в строках документа отображается unitName, Code не показывался — без
изменений.
2026-05-08 11:02:10 +05:00
nns 493ed33fd0 phase5c: единицы измерения — глобальный справочник + junction для орг
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 59s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m26s
Docker Web / Build + push Web (push) Successful in 35s
Docker API / Deploy API on stage (push) Failing after 38s
Docker Web / Deploy Web on stage (push) Successful in 12s
Было: каждая орга держала свои 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 со списком
  организаций при попытке деактивировать используемую единицу
2026-05-08 01:21:20 +05:00
nns 46877cc134 fix(phone): серверная KZ-ФЛК на всех endpoint'ах принимающих phone
Logic gap из e2e-отчёта: SuperAdmin /organizations принимал любой текст
в Phone — серверной валидации ФЛК не было (только в /api/auth/signup).
Это позволяло сохранить «abc» в Organization.Phone и невалидные номера
для контрагентов и сотрудников.

— Application/Common/PhoneNormalization.cs (новый): TryNormalizeKz +
  IsValidOrEmpty. Принимает любое форматирование, ведущая «8» → «7»;
  валидно: 11 цифр, начинается с «77» (мобильный код KZ).
— SuperAdminOrganizationsController.Create/Update: 400 если phone не
  парсится; в БД пишется нормализованная форма «+77001234567».
— CounterpartiesController.Create/Update: то же. Apply() нормализует.
— EmployeesController.Create/Update: то же.
— SuperAdminEmployeesController.Create/Update: то же.
— AuthSignupController: убран локальный NormalizeKzPhone, используется
  shared. Сообщение об ошибке унифицировано.

Defense-in-depth к фронтовой валидации (PhoneInput / validatePhone).
Незаполненный phone остаётся валидным для опциональных полей —
контроллер сам решает требовать или нет.
2026-05-08 01:05:48 +05:00
nns bcf81c57ee fix(auth): Cashier/Storekeeper больше не видят /api/organization/employees + Identity-роль маппится из orgRole
Найдено в e2e-прогоне (отчёт reports/full-cycle-2026-05-07-baseline.md):
- GET /api/organization/employees вернул 200 для Cashier (ожидалось 403).
- Cashier у созданного через POST /employees вообще не получает
  Identity-роли — серверная авторизация не работает.

Корни:
1. EmployeesController имел class-level [Authorize] без roles,
   List/Get не имели per-method [Authorize(Roles=...)] — поэтому любой
   аутентифицированный юзер мог читать список сотрудников.
2. EmployeesController.Create при createAccount=true вызывал
   _userMgr.CreateAsync, но НЕ вызывал AddToRoleAsync — у созданного
   юзера не было ни одной Identity-роли.

Фиксы:
- Class-level `[Authorize(Roles = "SuperAdmin,Admin")]` на
  EmployeesController. Теперь List/Get/Create/Update/Delete все
  требуют Admin (или SuperAdmin override). Per-method дубль убран.
- Новый helper `Api/Infrastructure/IdentityRoleMapper.cs`:
  Администратор → Admin, Кладовщик → Storekeeper, Кассир → Cashier.
  Кастомные orgRole не получают Identity-роли (это by-design — они
  дают UI-permissions внутри org, без доступа к role-locked endpoint'ам).
- EmployeesController.Create вызывает AddToRoleAsync с замапленной
  Identity-ролью если такая есть.
- SuperAdminEmployeesController.Create аналогично — вместо хардкод
  "Admin" использует mapper с fallback на "Admin" (по запросу юзера
  при создании учётки SuperAdmin'ом).

После фикса в e2e:
- Cashier → GET /api/organization/employees → 403 (было 200).
- /connect/token → /api/me содержит roles=["Cashier"].
- Cashier → /api/sales/retail-sales → 200 (рабочая авторизация).
2026-05-08 01:03:30 +05:00
nns e38a360e54 feat(auth): forgot/reset password — endpoints + UI + IP rate-limit
Some checks failed
CI / Backend (.NET 8) (push) Successful in 1m6s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 1m17s
Docker Web / Build + push Web (push) Successful in 36s
Docker API / Deploy API on stage (push) Failing after 37s
Docker Web / Deploy Web on stage (push) Successful in 12s
CI / POS (WPF, Windows) (push) Has been cancelled
Пункты 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 с новым паролем → норма.
2026-05-06 12:45:38 +05:00
nns 76e956ea6c feat(platform): IEmailSender + MailKit + PlatformSettingsController
Пункт 2 + 3 пакета SMTP-настроек.

Backend:
- IEmailSender (Application/Common/Email) — общий интерфейс отправки
  одного письма; EmailNotConfiguredException — для контроллеров чтобы
  ловить и отдавать понятный 400 вместо 500.
- MailKitEmailSender (Infrastructure/Email) — реализация:
  · регистрируется Singleton, на каждой отправке открывает scope для
    свежего AppDbContext (конфиг перечитывается без рестарта);
  · читает PlatformSettings из БД, расшифровывает пароль через
    IDataProtector("foodmarket.smtp");
  · поддержка SmtpUseSsl (implicit TLS / 465) и SmtpStartTls (587);
    оба false → открытое соединение (для dev/MailHog);
  · бросает EmailNotConfiguredException если host или from-email пусты,
    или если расшифровка пароля падает (ключ DataProtection ротировался).

API:
- PlatformSettingsController:
  GET /api/super-admin/platform-settings — все поля КРОМЕ пароля
    (только has-password флаг + updatedAt).
  PUT — принимает Reason (≥10) + все поля + опциональный
    NewSmtpPassword. Спец-значение "__clear__" снимает пароль.
    Пароль шифруется через DataProtection при записи. Audit-log.
  POST /test-send — реальная отправка через текущие настройки;
    ловит EmailNotConfiguredException → 400, остальные → 500
    с message (SuperAdmin-only, diagnostic-info нужна).

DI:
- AddSingleton<IEmailSender, MailKitEmailSender>;
- AddDataProtection (default file-system key store ASP.NET Core).

Пакеты:
- MailKit 4.10.0 (4.8 имел moderate-severity advisory).
- Microsoft.AspNetCore.DataProtection 8.0.11 (transitive в API уже
  был через OpenIddict, но Infrastructure нужен явный reference).
2026-05-06 12:39:18 +05:00
nns e8a28ba1f6 feat(employees): двухступенчатое удаление — «уволить» → «удалить»
Полное физическое удаление сотрудника невозможно — у него FK из
retail_sales и supplies. Поэтому теперь два шага:

  IsActive=true                                  → активный
  IsActive=false + FiredAt                       → уволен (кнопка «Уволить»)
  IsActive=false + IsDeleted=true + DeletedAt    → удалён (кнопка «Удалить»)

— Domain: Employee получил поля IsDeleted/DeletedAt + миграция
  Phase5a_EmployeeSoftDelete (drop column возможен через Down).
- API EmployeesController.Delete:
  · если активен — переводит в Fired;
  · если уже уволен — ставит IsDeleted=true + DeletedAt;
  · если уже удалён — 409 Conflict;
  · гарды Owner и self применяются на ОБОИХ шагах.
- API EmployeesController.List: новый query-param ?status=
  active|fired|deleted|all (default: всё кроме deleted).
- DTO дополнен полями isDeleted, deletedAt, status (active/fired/deleted) —
  фронтэнд использует для бейджа и логики кнопок.
- UI EmployeesPage:
  · фильтр статуса в actions: «Активные и уволенные» (default),
    «Только активные», «Только уволенные», «Только удалённые»,
    «Все, включая удалённых».
  · колонка «Статус» теперь с цветным бейджем (emerald/amber/rose).
  · ФИО уволенного помечается «(уволен)», удалённого — line-through
    + «(удалён)».
  · кнопка-действие в модалке: «Уволить» если active, «Удалить» если
    fired, скрыта если уже deleted (заменена на pojaснение).
  · confirm-текст обоих шагов разный — юзер понимает что произойдёт.

Существующие связанные документы (продажи, поставки) ссылаются на
employees по FK; имена для UI берутся из employee.LastName/FirstName +
status — отображение «Иванов И.И. (удалён)» работает автоматически.
2026-05-06 11:32:07 +05:00
nns b073e99ca7 feat(roles): три системные роли — Admin/Cashier/Storekeeper
Менеджер/Закупщик/Бухгалтер сидились как кастомные шаблоны вместе с
организацией, но при этом числились системными в DevDataSeeder
(IsSystem=false), что путало UI (где-то нельзя было менять, где-то
можно было). Юзер хочет: при создании новой орги только три
системные роли (Admin, Storekeeper, Cashier), все остальные роли
администратор создаёт сам.

— SystemRoles.Manager убран. Identity-роли сидируются: SuperAdmin,
  Admin, Cashier, Storekeeper.
— EmployeeRoles tenant-сидер создаёт только три записи (все IsSystem=true,
  все три не редактируются и не удаляются обычным юзером — это правило
  уже работало для Админа/Кассира, теперь покрывает Кладовщика).
— Authorize(Roles = ".. Manager ..") убрано из всех контроллеров (13 файлов):
  Sales/RetailSales, Catalog/{Products,ProductImages,ProductGroups,
  Counterparties,UnitsOfMeasure,RetailPoints,PriceTypes,Stores},
  Purchases/Supplies, Organizations/{Employees,EmployeeRoles,
  OrganizationSettings}.

Существующие организации с уже созданными «Менеджер/Закупщик/
Бухгалтер» записями НЕ затрагиваются — сидер пропускает org если в ней
уже есть роли (anyRole short-circuit). При желании админ может удалить
эти кастомные роли через UI.
2026-05-06 11:31:15 +05:00
nurdotnet fd7df631e1 feat(signup): телефон обязателен + ФЛК Казахстана (77XXXXXXXXX)
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m0s
CI / Web (React + Vite) (push) Successful in 40s
Docker API / Build + push API (push) Successful in 1m20s
Docker Public / Build + push Public (push) Successful in 28s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Public / Deploy Public on stage (push) Successful in 11s
Фронт:
- SignupForm: убрал «(необязательно)» из лейбла, добавил required + autoComplete=tel.
- validation.ts: validatePhone теперь возвращает ошибку при пустом значении и валидирует строго KZ-мобильный (^77\d{9}$); ведущая «8» нормализуется в «7». «79…» (РФ) отвергается.

Бэк:
- AuthSignupController: SignupInput.Phone теперь string (не nullable). Добавлен NormalizeKzPhone — единая нормализация на сервере, защита от обхода фронтового валидатора. На запись в Organization.Phone уходит каноничная форма «+7XXXXXXXXXX».
2026-05-03 02:52:58 +05:00
nns 79406e304e revert(domains): публичный сайт → test.food-market.kz, apex 404 до релиза
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 53s
CI / Web (React + Vite) (push) Successful in 38s
Docker API / Build + push API (push) Successful in 1m9s
Docker Public / Build + push Public (push) Successful in 28s
Docker Web / Build + push Web (push) Successful in 33s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Public / Deploy Public on stage (push) Successful in 10s
Docker Web / Deploy Web on stage (push) Successful in 11s
Публичный сайт ещё в разработке — выносим его с food-market.kz на
test.food-market.kz. На корневом домене food-market.kz пока 404.
admin.food-market.kz остаётся как есть.

— Заменены https-URL в src/** и deploy/:
  https://food-market.kz → https://test.food-market.kz
  (admin.food-market.kz, app.food-market.kz и emails @food-market.kz
  не трогаем — sed строго по https-префиксу).
— public Dockerfile ARG PUBLIC_SITE_URL → test.food-market.kz.
— SignupForm/Header/NoOrganizationPage указывают на admin.food-market.kz
  для API (без изменений с прошлого коммита).
— appsettings.json CORS: test + admin.food-market.kz.

Nginx (на сервере):
- /etc/nginx/conf.d/test.food-market.kz.conf — новый, серт LE issued.
- food-market.kz.conf — apex теперь 404 (HTTPS), серт переиспользует
  пару (food-market.kz + admin.food-market.kz).
- food-market.zat.kz и app.food-market.zat.kz — 301 на test/admin
  соответственно.

Smoke: test/, /signup/, admin/health, admin/login = 200; apex = 404;
zat → test/admin 301; sitemap.xml отдаёт https://test.food-market.kz/.
2026-05-01 18:06:31 +05:00
nns 58df887f1c feat(domains): миграция на food-market.kz / admin.food-market.kz
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 58s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m38s
Docker Public / Build + push Public (push) Successful in 49s
Docker Web / Build + push Web (push) Successful in 37s
Docker API / Deploy API on stage (push) Successful in 18s
Docker Public / Deploy Public on stage (push) Successful in 9s
Docker Web / Deploy Web on stage (push) Successful in 11s
- Заменил все хардкоды URL в src/** и deploy/:
  food-market.zat.kz       → food-market.kz       (публичный сайт)
  app.food-market.zat.kz   → admin.food-market.kz (админ-API + SPA)
- public/SignupForm и Header: дефолт PUBLIC_APP_URL теперь
  https://admin.food-market.kz (раньше указывал на сам публичный домен,
  что было багом — фронт стучался не туда после переезда зон).
- public/Dockerfile ARG PUBLIC_APP_URL → admin.food-market.kz.
- API appsettings.json CORS — оставил только два прода-origin (localhost
  для dev живёт там же).
- Program.cs: добавил opts.SetIssuer(uri) если задан OpenIddict:Issuer
  в конфиге — иначе iss вычислялся из текущего HTTP-запроса и ломался
  при nginx-прокси без X-Forwarded-Proto.
- docker-compose стейджа: env OpenIddict__Issuer=https://admin.food-market.kz/
  + Cors__AllowedOrigins[0,1].

Nginx (на сервере, не в репе):
- /etc/nginx/conf.d/food-market.kz.conf, admin.food-market.kz.conf —
  новые конфиги с certbot-выданными сертификатами на оба домена
  (LetsEncrypt --webroot, действителен до 2026-07-29).
- Старые food-market.zat.kz / app.food-market.zat.kz переведены в
  301-редирект на новые домены (HTTP+HTTPS), серты zat.kz пока
  оставлены чтобы handshake шёл нормально.
2026-04-30 13:56:51 +05:00
nns 3849cb3547 feat(super-admin): полное управление сотрудниками любой орги
Some checks failed
CI / Backend (.NET 8) (push) Successful in 47s
CI / Web (React + Vite) (push) Successful in 41s
Docker API / Build + push API (push) Successful in 1m10s
Docker Web / Build + push Web (push) Successful in 32s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 12s
CI / POS (WPF, Windows) (push) Has been cancelled
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-28 14:21:14 +05:00
nurdotnet f61d8bc178 fix(auth): SuperAdmin платформы без OrganizationId + отдельный Admin для Demo Market
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m8s
CI / Web (React + Vite) (push) Successful in 41s
Docker API / Build + push API (push) Successful in 1m8s
Docker API / Deploy API on stage (push) Successful in 18s
admin@food-market.local → SuperAdmin (OrganizationId=null, видит все орги)
admin@demo-market.local → Admin Demo Market (новый, для тестов орг-уровня)
Idempotent-фикс для существующих БД: исправляет роль и чистит OrganizationId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 10:18:21 +05:00
nns c0824518ab feat(employees): главный администратор — терминология + защита роли/активности
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 45s
CI / Web (React + Vite) (push) Successful in 40s
Docker API / Build + push API (push) Successful in 1m7s
Docker Web / Build + push Web (push) Successful in 32s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 12s
— «Владелец» переименован в «Главный администратор» — терминологически
  у нас не «собственник», а тот кто управляет организацией.
  Бейдж в таблице, тексты модалок, серверные сообщения 403 — везде
  единая формулировка.

— PUT /api/organization/employees/{id}: добавлен гард для главного
  администратора:
  · Смена RoleId на не-«Администратор» → 403 «Нельзя сменить роль…»
  · IsActive=false → 403 «Нельзя деактивировать…»
  Раньше юзер мог поменять себе роль на Кладовщик и получить бейдж
  «Владелец» с ролью кладовщика — несостыковка.

— EmployeesPage: при редактировании главного администратора
  · Селект ролей disabled + amber-плашка-объяснение «роль фиксирована»
  · Чекбокс «Активен» disabled + текст «нельзя деактивировать»
  · save() ловит ошибки и показывает их в общей модалке (раньше 403
    «тихо проваливалось» — модалка зависала).

— recovery-restore-orphan-owners.sql добавлен блок: для всех
  Organizations где главный администратор имеет роль не-«Администратор»
  или IsActive=false → восстанавливает «Администратор» и активирует.
  Идемпотентен. Применён на стейдже (0 пострадавших — текущая БД ОК).

Все изменения главного администратора (роль, ФИО, удаление, передача
управления) по архитектурному решению юзера должны идти через очередь
запросов к Супер-администратору платформы. Эта подсистема — отдельная
большая фича (RequestType / RequestQueue / SuperAdmin approval UI),
её план описан в TG-ответе.
2026-04-27 19:12:33 +05:00
nns 2691b7d78b feat(employees): бейдж «Владелец» + блокировка удаления с объяснением
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 45s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 1m8s
Docker Web / Build + push Web (push) Successful in 32s
Docker API / Deploy API on stage (push) Successful in 18s
Docker Web / Deploy Web on stage (push) Successful in 12s
Жалоба юзера: «нажимаю удалить владельца магазина — диалог "удалить
сотрудника?" — нажимаю — ничего не происходит». Раньше кнопка «Удалить»
для Owner была доступна, на сервере отвечала 403 с понятным сообщением,
но фронт ошибку не ловил — модалка зависала.

— EmployeeDto теперь возвращает isOwner (Org.AccountOwnerUserId ==
  Employee.UserId) и isSelf (UserId текущего залогиненного юзера).
  List + Get обновлены: подгружают AccountOwnerUserId и текущий sub
  из JWT, проставляют флаги в проекции.

— Таблица сотрудников: рядом с ФИО владельца — бейдж «Владелец»
  (amber-100/800).

— Кнопка «Удалить» в модалке редактирования:
  · disabled для Owner и для self с tooltip-объяснением;
  · клик по disabled-кнопке через onClick-handler показывает спец-
    модалку: «Нельзя удалить владельца магазина — сначала передайте
    управление другому пользователю. Организация не может остаться
    без владельца. Удалить или деактивировать саму организацию может
    только Супер-администратор платформы.»;
  · self-delete объясняется отдельным текстом (Настройки → Аккаунт →
    Покинуть организацию);
  · обычное удаление — confirm с именем сотрудника и пояснением что
    это soft-delete (деактивация).
  · 403/любая ошибка от сервера ловится в try/catch и показывается
    в той же модалке «Не удалось удалить» — больше не «ничего не
    происходит».

Smoke: API эмплоя возвращает isOwner=true,isSelf=false для Owner'а в
override-режиме SuperAdmin'а.
2026-04-27 18:58:08 +05:00
nns 691448201d fix: пароль/orphan signup/tenant-guard toast/dashboard счётчик
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 48s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 1m9s
Docker Public / Build + push Public (push) Successful in 41s
Docker Web / Build + push Web (push) Successful in 31s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Public / Deploy Public on stage (push) Successful in 11s
Docker Web / Deploy Web on stage (push) Successful in 12s
- validation: убрана клиентская проверка «должна быть заглавная/цифра»
  — она расходилась с серверной политикой Identity и блокировала
  валидные пароли. Серверный Identity сам валидирует и возвращает
  конкретное сообщение в общий error-bar формы. Клиент проверяет
  только длину 8.

- /api/auth/signup: если AppUser с таким email уже есть, но он
  orphan (org удалена / архивирована / IsActive=false) — реактивируем
  его и привязываем к новой org вместо отказа «уже зарегистрирован».
  SuperAdmin не реактивируется (ему сценарий неактуален).
  Пароль перезаписывается на тот, что юзер ввёл сейчас.

- TenantRouteGuard: убран alert «Откройте конкретную организацию через
  Открыть как…». На каждом редиректе SuperAdmin'а из tenant-роута
  всплывал window.alert — раздражал. Поведение редиректа сохранено.
  AppLayout чистит legacy ключ из sessionStorage.

- SuperAdmin dashboard: KPI «Пользователей» теперь показывает количество
  активных, а в подсказке — сколько деактивированных. Раньше показывал
  total (включая orphan) — юзер видел «2», но в реальных списках их не
  было, потому что orphan-юзеры уже деактивированы.
2026-04-27 18:38:56 +05:00
nns 633bdf3ef0 fix(auth): закрыть критические дыры — orphan login, self-delete, owner-delete, override-баннер
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 46s
CI / Web (React + Vite) (push) Successful in 41s
Docker API / Build + push API (push) Successful in 1m12s
Docker Web / Build + push Web (push) Successful in 31s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 12s
Аудит 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.
2026-04-27 09:28:18 +05:00
nns 4ac7dc585c feat(deploy): Phase 6 — публичный сайт на food-market.zat.kz, админка на app.
Доменная схема (по решению юзера):
  food-market.zat.kz → новый Astro public-сайт (порт 8082, контейнер food-market-public)
  app.food-market.zat.kz → существующая админка (food-market.web, порт 8081)
  API остаётся на app.* под /api/*.

Изменения:
- docker-compose: добавлен сервис public (image food-market-public:latest,
  127.0.0.1:8082:80). На стенде .env дополнен PUBLIC_TAG=latest, контейнер
  поднят, smoke на / и /pricing проходит.
- Forgejo workflow .forgejo/workflows/docker-public.yml — отдельный билд
  при изменениях в src/food-market.public/**: docker build с
  --build-arg PUBLIC_SITE_URL=https://food-market.zat.kz и
  --build-arg PUBLIC_APP_URL=https://app.food-market.zat.kz, push в
  локальный registry, deploy через docker compose pull+up. TG-пинг.
- Nginx (на стенде вручную, не через репо):
  - Новый блок food-market-app.conf для app.food-market.zat.kz —
    проксирует на :8081 (web), вместе с /api/admin/import/ и
    /tg-webhook путями. Certbot --nginx выпустил SSL.
  - Старый food-market-stage.conf переписан на public — проксирует на
    :8082, использует существующий SSL для food-market.zat.kz.
- API CORS: добавлены food-market.zat.kz, app.food-market.zat.kz,
  food-market.kz, app.food-market.kz в AllowedOrigins (publicу нужен
  food-market.zat.kz для signup-запросов, админке нужен app.*).
- JWT cookie domain не настраиваем — проект использует localStorage,
  cross-domain auth-bridge через URL fragment (см. AuthBridgePage),
  что безопаснее cookie с .food-market.zat.kz.
- Хардкодов food-market.zat.kz в food-market.web/src не нашлось —
  всё через относительные URL.

Существующие админ-сессии: токены в localStorage привязаны к
food-market.zat.kz origin. После переезда юзеры увидят на этом
домене публичный сайт без своих токенов — нужно перелогиниться
на app.food-market.zat.kz.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:17:48 +05:00
nns ad09d48a89 feat(public): Phase 6 — публичный маркетинговый сайт food-market.public на Astro
Новый пакет src/food-market.public/ — отдельный фронт для маркетинга и
самостоятельной регистрации магазинов-клиентов в SaaS Food Market.
Существующая админка food-market.web НЕ затронута.

Стек: Astro 4 + React 19 islands + Tailwind v3 (палитра идентична
food-market.web — единый бренд), TypeScript 6, content collections для
юр.документов. Static-сайт через nginx, gzip + immutable cache на assets.

Карта страниц (23):
- /                            — главная (Hero + 3 выгоды + скриншот +
                                   6 вертикалей + 6 модулей + Касса +
                                   Интеграции + 3 тарифа + соцпруф +
                                   FAQ + финальный CTA)
- /features                    — модули по сценариям
- /pricing                     — тарифы + интерактивный конструктор
                                   «Бизнес» (per-unit: 10000 база +
                                   2000/магазин + 500/касса + 500/склад,
                                   слайдеры, передача params в /signup)
- /pos                         — УТП лендинг кассы для Windows + весов
- /migration-from-other-system     — УТП лендинг миграции с сторонняя система
                                   (сравнительная таблица + 3 шага)
- /integrations                — список интеграций
- /for-grocery|pharmacy|cafe|alcohol|clothing|household — 6 вертикалей
                                   с уникальными фишками (весовой,
                                   серии/сроки, модификаторы и комбо,
                                   акцизные марки, размерные сетки,
                                   гарантийные сроки)
- /signup                      — регистрация (React-island форма)
- /about /contacts /kb /blog /status /changelog — компания + ресурсы
- /legal/{offer,privacy,consent,requisites} — реальные юр.документы
                                   из /tmp/legal/ как Astro content
                                   collection (markdown с frontmatter,
                                   динамический [slug].astro template,
                                   720px max-width, line-height 1.7,
                                   prose-legal стили)
- /sitemap.xml                 — ручной генератор (sitemap-плагин
                                   конфликтует с Astro 4.16, заменён
                                   на простой APIRoute)

React-острова (3):
- BusinessTariffBuilder — слайдеры + расчёт total + ссылка на signup
- SignupForm — email/password/orgName/phone/plan + валидация + agree
- FAQ — accordion 7 вопросов

API: новый POST /api/auth/signup создаёт Organization + AppUser
(Identity Admin role) + Owner Employee + полный bootstrap через
DevDataSeeder.SeedTenantReferencesAsync (units, price-types, store,
cassa, 6 ролей). Токены НЕ выпускает — фронт сразу делает обычный
/connect/token (password grant) и получает access/refresh без
дублирования OpenIddict-логики. На signup-форме — auth-bridge:
токены передаются через URL fragment в админку
APP_URL/auth-bridge#access=...&refresh=...&welcome=1, AuthBridgePage
кладёт в localStorage и редиректит на /?welcome=signup.

URL-домены через env-переменные (юзер ещё выбирает финальный):
- PUBLIC_SITE_URL — canonical/OG/sitemap (default https://food-market.kz)
- PUBLIC_APP_URL — admin/API endpoint (default https://food-market.zat.kz)
Nginx-конфиг для деплоя сайта — заготовка-template в
deploy/nginx/food-market-public.conf.template, не применён —
ждёт решения по домену.

Dockerfile multi-stage (node:20-alpine build → nginx:1.27 runtime),
build-args PUBLIC_SITE_URL/PUBLIC_APP_URL, deploy/nginx.conf gzip +
immutable cache + try_files для pretty URLs.

SEO: OG-теги, twitter-card, canonical, JSON-LD SoftwareApplication
схема, robots.txt → sitemap-index, lang=ru-KZ.

Admin-side: /auth-bridge route в food-market.web — принимает токены
из URL fragment, кладёт в localStorage (fm.access_token / fm.refresh_token),
редиректит на /. Fragment чтобы access_token не попадал в Referer.

23 страницы билдятся без ошибок. Контейнер собирается. Деплой на
конкретный домен — отдельным шагом после решения юзера.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:11:34 +05:00
nns fc3f63c49a feat(super-admin): настраиваемый retention period для архивных орг
Раньше «удалить орг навсегда» было захардкожено на 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>
2026-04-26 17:59:24 +05:00
nns 5c5b231157 feat(directories): двухуровневые справочники Группы и Ед.измерения (системные + tenant)
Концепция: 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>
2026-04-26 16:20:47 +05:00
nns bd2800837f feat(roles): системные роли read-only + русские имена + чистка дубликата у admin
Концепция: «Супер администратор» — платформенная Identity-роль
SuperAdmin. «Администратор» — организационная роль внутри Employee
(IsSystem=true в EmployeeRole). Они НЕ должны дублироваться у
одного юзера.

- Сидер: admin@food-market.local получает только Identity-роль
  SuperAdmin. Догоняющая ветка для существующих стендов:
  если есть Identity-роль Admin — RemoveFromRoleAsync. На стенде
  AspNetUserRoles почищен SQL'ом.
- AppLayout: translateRoles() переводит SuperAdmin → «Супер
  администратор», скрывает Identity-роль Admin (org-уровень
  показывается через Employee/Role, не через Identity).
- EmployeeRolesPage: клик по строке системной роли → alert
  «Системная роль, изменения недоступны». Edit-модалка для
  системных была частично defensive (disabled чекбоксы Phase 2c),
  теперь точка входа закрыта целиком. Кастомные роли — без изменений.

EmployeeRole.IsSystem поле уже было — миграция не нужна.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:11:14 +05:00
nns 8d72e9da2d feat(super-admin): перенести справочник Стран в системную консоль
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>
2026-04-26 16:09:02 +05:00
nns 61cb97a1b7 fix(tenancy): SuperAdmin override должен применять tenant filter выбранной орги
🔴 КРИТИЧНЫЙ БАГ ИЗОЛЯЦИИ. SuperAdmin в режиме «открыть как Demo Market»
видел товары FOOD MARKET (29540 чужих записей вместо 0 своих).

Корень проблемы — query-filter в AppDbContext:

  e => _tenant.IsSuperAdmin || e.OrganizationId == _tenant.OrganizationId

IsSuperAdmin → весь предикат становится true → все записи всех орг.
В режиме override OrganizationId уже корректно подменялся на
выбранную орг, НО bypass через IsSuperAdmin делал подмену
бессмысленной — фильтр всё равно пропускал всё.

Фикс — добавил IsTenantOverride флаг в ITenantContext и переписал:

  e => (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
       || e.OrganizationId == _tenant.OrganizationId

То есть SuperAdmin обходит фильтр ТОЛЬКО когда не в override. В
override-режиме он работает в контексте выбранной орги как обычный
юзер — фильтр применяется.

HttpContextTenantContext.IsTenantOverride возвращает true когда
текущий запрос — HTTP, юзер в роли SuperAdmin и присутствует header
X-Org-Override с валидным GUID. AsyncLocal-override (background-задачи
импорта/Hangfire) намеренно НЕ считается tenant-override — там
IsSuper=false по умолчанию и фильтр и так применяется.

Smoke-test ДО фикса (воспроизведение):
  GET /products X-Org-Override=DemoId   → total 29540 (баг: чужие)
  GET /products X-Org-Override=FoodId   → total 29540
  GET /products без header              → total 29540 (legit super)

После деплоя ожидается:
  GET /products X-Org-Override=DemoId   → 0 (Demo Market пуст)
  GET /products X-Org-Override=FoodId   → 29540 (своих)
  GET /products без header              → 29540 (legit super bypass)

Затронуты ВСЕ tenant-сущности (фильтр применяется через reflection
ко всем ITenantEntity): products, counterparties, supplies, stocks,
movements, retail-sales и т.д.

DesignTimeTenantContext получил IsTenantOverride=false (он только для
EF tooling).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:55:04 +05:00
nns 21c2ca89fe ui(super-admin): SaaS-метрики на главной системной консоли (placeholders)
Food Market — SaaS для розницы, SuperAdmin это владелец платформы,
а не сотрудник магазина. Операционные метрики магазинов («Товаров
29540», «Приёмок за месяц») для него бесполезны — это для tenant
dashboard'а конкретной орги.

KPI блок на /super-admin переработан под кабинет SaaS-провайдера:

Top row (4 карточки):
- Организаций (как было — клиентская база)
- Платящих клиентов — placeholder, accent emerald, muted
- MRR (₸ / мес) — placeholder, accent violet, muted
- Должники — placeholder, accent rose, muted

Second row (2 карточки):
- Пользователей (всего/активных)
- Регистраций за 30 дней — реальное значение
  (COUNT Organizations WHERE CreatedAt >= now-30d)

Заглушки получили проп muted=true: фон чуть серее (slate-50/60),
значение «—» более бледным slate-400, иконка остаётся полноцветной
чтобы было видно «здесь будут данные после Phase 4». В hint —
«Скоро · после внедрения биллинга».

API: DashboardStats потерял TotalProducts/TotalSuppliesThisMonth,
получил RegistrationsLast30Days.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:47:13 +05:00
nns 4dd6b16eed feat(super-admin): Phase 3 — edit-mode с reason + audit-trail
В режиме «открыть как…» SuperAdmin может временно (на 30 минут)
включить редактирование с обязательной причиной — каждая успешная
мутация пишется в SuperAdminAuditLog. Чтобы лог был полезным:

API:
- Header X-Org-Override-Reason. Если присутствует и trimmed >= 10
  символов — ReadonlyOverrideMiddleware пропускает мутации (вместо 403).
- SuperAdminEditAuditFilter — глобальный IAsyncActionFilter после
  controller'а: при наличии обоих headers + успешном статусе 2xx +
  методе POST/PUT/PATCH/DELETE пишет запись ActionType=EditEntity
  с reason, описанием «METHOD /path → 200», IP и SuperAdminUserId.
  Регистрируется как Scoped + AddService<>() в AddControllers.

Web:
- enableEditMode(reason)/disableEditMode/getEditMode в lib/api.ts —
  хранение в localStorage с expiresAt = now + 30мин. Axios interceptor
  добавляет header только пока edit активен и не истёк.
- SuperAdminAsOrgBanner расширен: цвет меняется amber→red в edit-mode,
  кнопка «Включить редактирование» открывает модалку с textarea
  reason (≥10 символов) + чекбокс согласия на аудит. После активации
  баннер показывает «EDIT-MODE (N мин)», кнопка «Снять edit» отключает
  до истечения таймера.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:07:10 +05:00
nns b2d6584f09 feat(super-admin): Phase 2 — read-only «открыть как…» context switch
SuperAdmin может зайти в данные конкретной орги в режиме просмотра
(аналог «view as customer» в SaaS). Все запросы летят с tenant'ом
выбранной орги, но любая мутация запрещена — Phase 3 (edit-mode +
audit-trail) будет ослаблять ограничение по reason'у.

API:
- HttpContextTenantContext.OrganizationId: если у юзера роль SuperAdmin
  И header X-Org-Override присутствует — возвращаем его как tenant
  (вместо org_id из JWT).
- ReadonlyOverrideMiddleware (после UseAuthorization): когда заголовок
  активен, отбивает 403 любую non-GET операцию, кроме /api/super-admin/*
  (управление орг) и /connect/* (refresh tokens).
- Безопасность: проверка SuperAdmin-роли — без неё header игнорируется,
  обычный юзер ничего подменить не может.

Web:
- api.ts: localStorage 'superAdminAsOrg' = {id, name}; axios interceptor
  добавляет X-Org-Override на каждый запрос. setOrgOverride(...) делает
  hard reload чтобы сбросить TanStack Query-кэш.
- SuperAdminAsOrgBanner — жёлтая полоса сверху main-area с названием
  орги и кнопкой «Выйти». Подключена в AppLayout перед <Outlet/>.
- В таблице /super-admin/organizations добавлена кнопка LogIn (синяя)
  в actions; клик → setOrgOverride → reload в режим просмотра.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:03:48 +05:00
nns 59983acabd fix(super-admin): новая org через UI получает полный bootstrap (как Demo)
POST /api/super-admin/organizations создавал только Store + Admin role
в inline-коде — у новой орги не было единиц измерения, типов цен,
кастомных ролей шаблонов (Менеджер/Кладовщик/Закупщик/Бухгалтер),
кассы. Юзеру приходилось бы заводить всё руками.

Решение — переиспользовать DevDataSeeder.SeedTenantReferencesAsync,
который уже умеет всё это идемпотентно:
- 5 единиц измерения (штука/кг/л/м/упаковка по ОКЕИ)
- 2 типа цен (Розничная IsSystem+IsRequired+IsRetail / Оптовая)
- «Основной склад» MAIN
- «Касса 1» POS-1
- 6 ролей через SeedEmployeeRolesAsync (2 системных + 4 шаблона)

Helper повышен с private на public static. В контроллере убран
inline Store + AdminRole, после Add(org)+SaveChanges вызывается
seed, потом находим уже созданную «Администратор» роль для линковки
с Employee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:58:23 +05:00
nns a7fcc9f9e1 fix(seed): grant SuperAdmin role to admin@food-market.local
Раздел /super-admin в UI прячется за me.roles.includes('SuperAdmin').
Сидер при создании admin'а назначал только SystemRoles.Admin —
SuperAdmin висел как Identity-роль в роле-каталоге, но никому не был
выдан. Из-за этого SuperAdmin-консоль на стенде была не видна в меню.

Фикс: при создании admin'а сразу AddToRoleAsync(SuperAdmin). Для уже
развёрнутых стендов — догоняющая ветка else if (!IsInRoleAsync(SuperAdmin))
догоняет существующую учётку при следующем рестарте API.

На стенде роль уже выдана вручную через INSERT в AspNetUserRoles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:06:42 +05:00
nns 18eb362702 feat(api): super-admin endpoints (orgs CRUD + setup-status + audit-log + dashboard)
SuperAdminOrganizationsController (/api/super-admin/organizations):
все методы используют IgnoreQueryFilters() для обхода tenant-фильтра.
- GET / — таблица с пагинацией, фильтр archived, поиск по Name/Bin,
  возвращает счётчики (employees, products) + last login по users.
- GET /{id} — детали + статистика (employees, products, counterparties,
  supplies за 30 дней) + AccountOwner данные.
- POST / — создание орга вместе с админом: Org + Store «Основной» +
  EmployeeRole «Администратор» (IsSystem) + AppUser (random temp pwd
  возвращается один раз) + Employee. Owner = созданный AppUser.
- PUT /{id} — правка базовых данных, лог EditOrg с before/after.
- POST /{id}/archive — требует ConfirmationName == Org.Name (ввод).
- POST /{id}/restore — снять архив.
- DELETE /{id} — только если в архиве >30 дней + повторное подтверждение.
- POST /{id}/change-owner — Reason обязателен, валидируем что user
  принадлежит этой орге, лог ChangeOwner с from/to.

Все мутации пишут запись в SuperAdminAuditLog с ActionType,
Description, Reason, ChangesJson, IpAddress, SuperAdminUserId.

SuperAdminController (/api/super-admin):
- GET /setup-status — нужен ли wizard? (OrgCount == 0).
- GET /dashboard — total/active/archived orgs, users, products, supplies/month.
- GET /audit-log — фильтры organizationId/actionType/from/to + paged + join
  на orgs для имени.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:54:07 +05:00
nns bf6880fe0f feat(employee): add Salary, TaxNumber, Description, ImageUrl + radio role picker
Domain Employee расширен 4 nullable-полями (по образу сторонняя система):
- Salary numeric(18,2) — оклад в валюте организации
- TaxNumber varchar(20) — ИИН/ИНН
- Description varchar(2000) — комментарий HR'а
- ImageUrl varchar(500) — аватар (на будущее: загрузка через images endpoint
  как у товаров; пока поле для прямой ссылки)

Migration Phase4c_EmployeeExtraFields добавляет 4 nullable колонки
(существующие записи не ломаются). EF config + snapshot обновлены.

API EmployeesController: DTO/Input/Create/Update пробрасывают новые
поля сквозь.

Frontend EmployeesPage:
- Поля «Оклад» и «ИИН/ИНН» рядом, ниже — «Описание» textarea.
- Селект роли заменён на radio-список с описанием каждой роли
  (системные сначала, затем кастомные). Под радио — ссылка
  «Настроить права ролей →» на /settings/employee-roles. Это
  по образу МС — пользователь сразу видит за что отвечает каждая
  роль и куда идти если нужно подкрутить.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:48:27 +05:00
nns 1d9fd7297c fix(roles): keep only Admin + Cashier as system, demote others to custom + migration
После ревью UX оказалось что 6 системных ролей — перебор. Перешли на
схему «два системных + остальные шаблоны»:

- Администратор (IsSystem=true) — RolePermissions.All().
- Кассир (IsSystem=true) — POS-only набор:
  ProductsView + StocksView + RetailSalesOperate. Без RetailSalesRefund
  (админ включит при необходимости). Это маркер для будущего POS-app —
  не имеет доступа к веб-админке.
- Менеджер / Кладовщик / Закупщик / Бухгалтер — IsSystem=false
  (кастомные). Можно удалить если не нужны или подкрутить под себя.

Сидер на чистой БД сразу создаёт роли в правильных статусах. Для
существующих установок миграция Phase4b_RolesSimplify идемпотентно
делает UPDATE: демоутит лишние и приводит permissions Кассира к
правильному набору. Down() — no-op (юзер мог переименовать).

На стенде sql применил вручную + записал в __EFMigrationsHistory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:41:13 +05:00
nns c714ec265c feat(api): EmployeesController + EmployeeRolesController + invite-with-temp-password
EmployeeRolesController (/api/organization/employee-roles):
- List/Get/Create/Update/Delete. Системные роли (IsSystem=true) — нельзя
  удалить (409), но имя/описание/permissions редактируются (чтобы можно
  было кастомизировать набор галок). Удаление 409 если роль уже
  используется сотрудниками.

EmployeesController (/api/organization/employees):
- List с поиском по фамилии/имени/email/телефону.
- Create:
  - LastName, FirstName, MiddleName, Position, Email, Phone, RoleId, IsActive
  - RetailPointIds[] — для роли Кассир привязка к нескольким кассам;
    хранится в employee_retail_point_assignments.
  - CreateAccount=true → одновременно создаём User (Identity) с email и
    случайным temp-паролем (12 символов, все классы), возвращаем в
    response.GeneratedPassword один раз — UI покажет «выдайте сотруднику».
- Update — replace assignments wholesale; IsActive false → проставляем
  FiredAt=now (восстановление обнуляет).
- Delete — без проверок на FK документов (на этом этапе нет других
  ссылок на Employee, кроме CASCADE-связи с retail-point assignments).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:03:39 +05:00
nns 0ff31d1450 feat(seed): system roles per organization + map admin → Employee
DevDataSeeder.SeedEmployeeRolesAsync — 6 системных ролей с готовыми
наборами Permissions:
- Администратор    — RolePermissions.All() (все 21 флаг)
- Менеджер         — каталог + закупки + контрагенты + отчёты + остатки
- Кладовщик        — приёмки + остатки + view товаров
- Кассир           — продажи + view товаров (привязка к кассе на UI-этапе)
- Закупщик         — закупки + контрагенты + view товаров
- Бухгалтер        — все *View, никаких edit

IsSystem=true, SortOrder сохраняет порядок отображения в селектах.
Сидируется один раз per организацию (anyRole? skip) — чтобы кастомные
правки галок админа не сбрасывались на каждый старт.

SeedAdminEmployeeAsync — после создания admin@food-market.local
(SuperAdmin Identity user) заводит Employee-запись с ролью
«Администратор» в Demo Market организации, чтобы UI «Сотрудники»
сразу показывал учётку, а не пустой список.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:01:44 +05:00
nns 7d09873be2 fix(supply-lines): show both article and barcode in line subtitle
В таблице позиций приёмки под названием товара теперь выводится
и артикул, и основной штрихкод сразу — раньше показывалось что-то
одно (артикул или ничего, без штрихкода).

Формат: «Арт: 17933 · ШК: 4870144022958». Если только одно из двух
— префикс соответствующий, без точки-разделителя. Если ни того ни
другого — subtitle не рендерится. Шрифт мелкий моно серый.

API:
- SupplyLineDto расширен полем ProductBarcode (основной по
  IsPrimary, иначе первый по порядку).
- В проекции GetInternal штрихкод подтягивается через
  p.Barcodes.OrderByDescending(IsPrimary).Select(Code).First().

Frontend:
- types.ts.SupplyLineDto, LineRow в SupplyEditPage и AddedProduct
  в SupplyLineQuickAdd получили поле productBarcode/barcode.
- При добавлении строки через ProductPicker, sticky-input или
  quick-create — primary barcode достаётся из p.barcodes одинаковой
  логикой (sort by IsPrimary desc, [0]).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:03:43 +05:00
nns 95bf2188c6 feat(product-card+list): drop supplier field, reorder sections, add cost column
Карточка товара:
- убрано поле «Основной поставщик» из секции «Классификация» (домен/DTO
  оставлены без изменений; в payload отправляется null);
- порядок секций: Основное → Цены → Классификация → Изображения →
  Штрихкоды (раньше Цены шли после Классификации). Цены — самое важное,
  должны быть ближе к названию товара.

Список товаров:
- добавлена колонка «Себестоимость» перед колонкой системной розничной
  цены. Источник — Product.Cost (скользящее среднее, обновляется при
  проведении приёмки). Cost = 0 (приёмок не было) показывается как «—»,
  чтобы визуально отличать «не накопилось» от реальной себестоимости 0.
- API: добавлен сортировочный case sort=cost,asc/desc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:13:57 +05:00
nns 48babf0d10 feat(api): products quick-search + by-barcode endpoints
GET /api/catalog/products/quick-search?search=&storeId=&limit=20 —
лёгкий поиск для inline-добавления строк в документы. Ранжирует
по приоритету: точный barcode → точный article → префикс article →
префикс name → name contains. Возвращает QuickSearchItem с stockQty
по storeId (если передан) или сумме по всем складам.

GET /api/catalog/products/by-barcode/{value}?storeId= — точный поиск
для сканера. 404 если 0 совпадений, объект QuickSearchItem если 1,
{ items: [...] } если несколько (для диалога выбора).

Why: новый UX inline-добавления строк в приёмке требует быстрого
поиска по штрихкоду/артикулу/названию с показом остатков прямо в
дропдауне; полный /products endpoint слишком тяжёлый.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:53:07 +05:00
nns b7288bac1b feat(supply): «Проведено» внутри формы + обязательная дата и ≥1 позиция
UI:
- Чекбокс «Проведено» переехал из шапки в секцию «Реквизиты документа»,
  чтобы было визуально как в сторонняя система. С хинтом «только проведённый
  документ влияет на остатки и себестоимость».
- Поле «Дата» помечено как обязательное (звёздочка + required).
- canSave требует form.lines.length > 0; пустое состояние секции
  «Позиции» теперь красное «должна быть хотя бы одна позиция».
- onError приёмки достаёт сообщение из response.data.error.

API:
- Create/Update приёмки 400-ят без позиций
  («Приёмка должна содержать хотя бы одну позицию.»).
- Post (проведение) уже валидирует это; теперь и на этапе сохранения.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:28:29 +05:00
nns 306153d128 phase3b: product card cleanup + supply form simplification
Product card:
- Barcodes moved inside «Основное», before description.
- Description hidden behind new ShowDescriptionOnProduct setting (default false).
- «Закупка» и «Цены продажи» объединены в один блок «Цены».

Supply (приёмка):
- Удалены поля «Дата накладной» и «№ накладной поставщика»
  (избыточны: дата документа уже есть, номер можно положить в Notes).
- Поле «Склад *» скрывается если в системе всего один склад
  — для большинства мелких магазинов лишний клик не нужен.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:00:06 +05:00
nns eaf5b7399b feat(product-card): drop ShelfLifeDays + recompose classification + auto-article + barcode trash hide
- Удаление поля «Срок годности (дней)»:
  • Domain.Product.ShelfLifeDays убран,
  • миграция Phase3b_DropProductShelfLifeDays — DROP COLUMN,
  • DTO/Input/UI/фильтр в списке товаров — выпилены.

- Перекомпоновка секции «Классификация» в карточке товара:
  • ряд 1 (3 col): Группа * | Единица измерения * | Фасовка,
  • ряд 2 (2 col): Основной поставщик | Страна происхождения,
  • Страна происхождения видна только если включена настройка
    Organization.ShowCountryOfOriginOnProduct (default false).
  • Та же миграция добавляет колонку, в OrganizationSettingsPage
    появляется галка «Показывать «Страну происхождения» на товаре»
    с подсказкой про импорт.

- Артикул теперь обязательное поле с авто-генерацией:
  • ProductEditPage: метка «Артикул *», required,
  • генератор generateArticle() (timestamp[-6] + 3 random) — у нового
    товара поле сразу заполнено,
  • canSave требует непустой article. Уникальность подтверждает
    сервер (он также имеет свой fallback-генератор max+1).

- Иконка корзины в секции «Штрихкоды» рендерится только при
  form.barcodes.length > 1 — для единственной строки удаления нет
  (минимум 1 штрихкод обязателен, удалять единственный нельзя).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:05 +05:00
nns 4649a624c3 chore(price-types): drop IsDefault flag + rename IsRetail label + uniqueness
PriceType: убран флаг IsDefault — он семантически дублировал IsSystem
(защищённая запись «по умолчанию»). Остаются IsSystem / IsRequired /
IsRetail.

- Domain.Catalog.PriceType: удалено поле IsDefault.
- Миграция Phase3b_DropPriceTypeIsDefault: DROP COLUMN.
- DTO/Input (PriceTypeDto, PriceTypeInput) — без IsDefault.
- PriceTypesController:
  • убрана логика uniqueness IsDefault на Create/Update,
  • IsRetail теперь enforce'ит уникальность: при установке IsRetail=true
    у других записей сбрасывается,
  • при удалении единственной IsRetail записи (если она не системная)
    IsRetail автоматически переезжает на IsSystem-запись — у организации
    всегда остаётся один POS-кандидат.
- ProductsController.RecalcRetail и SuppliesController.SetDefaultRetail —
  поиск дефолтной розничной идёт по IsSystem → IsRetail → SortOrder → Name
  (ранее ThenByDescending(IsDefault) — выпилено).
- DevDataSeeder: поле IsDefault убрано.
- web types.ts: убрано isDefault из PriceType.
- PriceTypesPage:
  • убран чекбокс «По умолчанию»,
  • лейбл «Розничная (используется на кассе)» → «Используется на кассе»,
  • Form/blankForm/onRowClick без isDefault.
- ProductsPage / ProductEditPage: фоллбэк дефолтной цены теперь
  IsSystem → IsRetail → первая.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:15:29 +05:00
nns db3be5bbca fix(price-types): correct is-system seeder + require value > 0 + system-price filter/sort
Phase3b сидер ошибочно создавал НОВУЮ запись «Розничная цена» с
IsSystem=true в каждой организации, не проверяя что фактически
системной была другая запись (с реальными ценами у товаров).
В итоге IsSystem-замок оказывался не у той записи.

Миграция Phase3b_FixPriceTypeIsSystem (идемпотентная):
- Снимает IsSystem со всех записей.
- Помечает IsSystem=true + IsRequired=true тому PriceType, у которого
  максимум связанных ProductPrice (приоритет — фактически
  использующейся цене); при равенстве — самая старая (CreatedAt ASC).
- Если у организации вообще нет PriceType — создаёт «Розничная цена»
  (IsSystem=true, IsRequired=true).

DevDataSeeder: «Розничная» переименована в «Розничная цена», добавлены
IsSystem=true / IsRequired=true; работает только если у организации
ноль PriceType — больше не шлёпает дубль.

API валидация (ProductsController.Create/Update):
- FindMissingRequiredPriceAsync: для каждого PriceType с IsRequired=true
  проверяет, что в input.Prices есть запись с Amount > 0. Иначе
  возвращает 400 «Цена «<имя>» обязательна и должна быть больше 0.».

API фильтр+сортировка по системной цене:
- ProductsController.List: query parameters systemPriceFrom / systemPriceTo
  применяют ≥ / ≤ к Prices.Where(IsSystem).Amount.
- Sort key 'systemPrice' — OrderBy / OrderByDescending по той же
  системной цене.

Web ProductsPage:
- Filters.referencePriceFrom/To → systemPriceFrom/To, бэк-параметры
  systemPriceFrom/To.
- Подпись фильтра — динамическое имя системного PriceType (имя из
  справочника, обновляется при переименовании).
- Колонка системной цены получила sortKey='systemPrice'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:31:31 +05:00
nns ba7de0b513 feat(product+filters): срок годности (shelfLifeDays) + фильтр от/до
ProductEditPage:
- В секции «Основное» добавлено поле «Срок годности (дней)» рядом с
  Артикулом — NumberInput, целое ≥ 0, hint «не обязательное поле».
- form.shelfLifeDays хранится строкой и сериализуется в payload как
  number | null.

ProductsPage filters:
- Добавлен диапазон «Срок годности (дней) — от / до».
- Удалён остаток фильтра isActive (поле выпилено в Phase3b foundation).

ProductsController.List:
- Принимает shelfLifeDaysFrom / shelfLifeDaysTo (int?) и применяет
  ≥ / ≤ к p.ShelfLifeDays.
- Также теперь принимает referencePriceFrom / referencePriceTo (новые
  имена); старые purchasePriceFrom/To работают как алиасы для
  обратной совместимости с уже отрендеренным UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:52:44 +05:00
nns b79c71591d feat(phase3b): drop IsActive, add ShelfLifeDays, restore PriceType IsSystem/IsRequired
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>
2026-04-25 22:46:34 +05:00
nns de23f5fc7a feat(api): recalc-retail endpoint + 30-day reference price refresh job
POST /api/catalog/products/{id}/recalc-retail (Admin/Manager/Storekeeper):
- Если у Group товара задан MarkupPercent — записывает в дефолтный
  розничный PriceType значение ceil(Cost * (1 + pct/100)). Округление
  под AllowFractionalPrices: до сотых при включённом, до целого иначе.
- Возвращает 400 «У группы не задана наценка. …» если MarkupPercent null.
- Возвращает 400 если нет ни одного активного PriceType.
- Использует Organization.DefaultCurrencyId как fallback при создании
  новой записи цены.

Background/ReferencePriceRefreshJob (IHostedService, PeriodicTimer 24ч):
- Раз в сутки находит товары с LastSupplyAt < now-30d и Cost > 0,
  переписывает ReferencePrice = Cost, обновляет ReferencePriceUpdatedAt.
- IgnoreQueryFilters — работает над всеми organizations.
- Стартовая задержка 5 минут чтобы не пересечься с пендинг-миграцией.
- Зарегистрирован через AddHostedService в Program.cs.
- Hangfire не подключаем как полноценный server — IHostedService даёт
  тот же эффект без отдельной schema/dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:03:44 +05:00
nns 38040b4ec7 feat(api): supply posting hook for cost & markup
При проведении приёмки (POST /api/purchases/supplies/{id}/post):

- Себестоимость товара пересчитывается по скользящему среднему по
  ВСЕМ складам организации:
  newCost = (qty_old * cost_old + qty_in * price_in) / (qty_old + qty_in).
  При qty_old = 0 или cost_old = 0 → newCost = price_in.
  Хранится с 4 знаками (Math.Round AwayFromZero).
- ReferencePrice автозаполняется UnitPrice'ом первой Posted приёмки.
- LastSupplyAt = UtcNow.
- Розничная (дефолтный PriceType, IsDefault → IsRetail → SortOrder/Name):
  • если у строки RetailPriceManuallyOverridden=true и есть
    RetailPriceOverride — пишем его как розничную (override per-line),
  • иначе если у Group задан MarkupPercent — пишем
    Math.Ceiling(cost * (1 + pct/100)) с округлением:
    при AllowFractionalPrices=true — до сотых, иначе до целого,
  • иначе — розничная не трогается.
  Если в Product.Prices ещё нет записи под дефолтный PriceType —
  создаётся (currency = supply.CurrencyId).
- Всё в одной транзакции, ApplyMovementAsync вызывается ПОСЛЕ
  расчёта Cost (currentQty снимается до приёмки).

SupplyLineInput/SupplyLineDto расширены полями RetailPriceManuallyOverridden,
RetailPriceOverride; в DTO дополнительно CurrentRetailPrice — текущая
дефолтная розничная цена товара (для отображения в UI приёмки).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:02:00 +05:00
nns 6acf6b7c03 feat(domain): pricing model rename and new fields (Phase3a)
Подготовка к новой модели цен сторонняя система-style:
- Product.PurchasePrice → ReferencePrice (справочная закупочная,
  не обязательная). + ReferencePriceUpdatedAt для 30-дневного таймера.
- Product.+ Cost numeric(18,4) — себестоимость по скользящему среднему.
- Product.+ LastSupplyAt — UTC последней Posted приёмки.
- ProductGroup.+ MarkupPercent (5,2) — % наценки на cost для авто-розничной.
- Organization.+ MultiplePriceTypesEnabled (default false) и
  ShowReferencePriceOnProduct (default true).
- SupplyLine.+ RetailPriceManuallyOverridden + RetailPriceOverride —
  отметка ручной правки розничной в строке приёмки.

Миграция Phase3a_PricingModel: RENAME + AddColumn'ы. Logic перерасчёта
себестоимости, автонаценки, recalc-endpoint и Hangfire job — следующими
коммитами.

DTO/контроллеры/OtherSystem-импорт/UI поля переименованы в referencePrice
(включая фильтры списка товаров). UI-логика следующего коммита будет
показывать Cost и кнопку «привести розничную к себестоимости»; пока
referencePrice работает как раньше.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:59:09 +05:00
nns fd2da58ad4 fix(products/update): merge barcodes/prices по ключу + 409 на concurrency
Юзер ловил 500 «DbUpdateConcurrencyException: 0 rows affected» при PUT
/api/catalog/products. RemoveRange(всех детей) + Add новых на каждом
сохранении генерирует массовый DELETE/INSERT, при котором EF ожидал N
rows affected, а реальный DELETE возвращал меньше — и весь батч падал
с 500.

Чиню по-человечески:
- Merge by stable key: barcodes по Code, prices по PriceTypeId.
  Совпавшие — обновляем поля, лишние удаляем, новые добавляем. Минимум
  записей в SaveChanges, минимум поводов для 0-affected.
- Catch DbUpdateConcurrencyException → 409 «Товар изменён в другом
  окне или сессии. Перезагрузите страницу и попробуйте снова.» вместо
  непрозрачного 500.
- Удалена мёртвая ветка `if (input.Vat is null) e.Vat = existingVat`:
  Apply уже не присваивает Vat при null, ничего восстанавливать не
  нужно.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:18:23 +05:00
nns 53fa4d2deb feat(barcode-uniqueness): pre-check на Create/Update + warnings импорта + admin endpoint
Pre-check:
- ProductsController.FindBarcodeConflictAsync ищет штрихкоды,
  принадлежащие другим товарам организации; на Create/Update при
  конфликте возвращается 400 «Штрихкод 1234 уже используется
  товаром «Кока-кола 0.5л».» вместо 500 от unique index.

OtherSystem-импорт:
- При попытке привязать уже занятый штрихкод — пишется warning
  «{товар}: штрихкод {код} уже занят, пропущен.» в errors[],
  товар остаётся, дубль не сохраняется.
- В конце импорта проходит финальный SELECT по дубликатам в БД
  (если есть исторические) — warnings типа «Внимание: штрихкод X
  привязан к нескольким товарам — почисти вручную.».

Admin-endpoint:
- GET /api/catalog/products/barcode-duplicates (Admin/Manager)
  возвращает массив { code, products: [{productId, productName,
  article}, ...] } для будущей UI-чистки.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:26:20 +05:00
nns d1a7e1e647 ui(products-list): убрать фильтр «Со штрихкодом», добавить «Закупочная цена от/до»
Поскольку штрихкод теперь обязательный (минимум 1 у каждого товара),
фильтр «Со штрихкодом» бессмыслен — убран из UI и контроллера.

Вместо него — два MoneyInput «Закупочная цена от/до» в панели
фильтров. Использует символ валюты по умолчанию из настроек
организации и уважает AllowFractionalPrices.

Backend: ProductsController.List принимает purchasePriceFrom /
purchasePriceTo (decimal?), применяет ≥ / ≤ к PurchasePrice;
параметр hasBarcode удалён.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:23:45 +05:00
nns 4f4a751d26 feat(org-settings): AllowFractionalPrices — переключатель дробных цен
Новая галка в настройках магазина «Разрешить дробные цены (с копейками)»
(default false). Когда выключено — все денежные поля принимают и
сохраняют только целые числа.

- Organization.AllowFractionalPrices + миграция Phase5h.
- OrgSettings DTO/Input + UI настроек (галка с подсказкой).
- MoneyInput получил prop allowFractional: при false запрещает ввод
  точки/запятой и форматирует целым числом, при true — две цифры
  после запятой как раньше.
- ProductEditPage / SupplyEditPage / RetailSaleEditPage передают
  org.allowFractionalPrices во все MoneyInput.
- Списки Products / Supplies / RetailSales форматируют суммы по
  настройке (с .00 или без).
- Сервер защищён от обхода UI: ProductsController / SuppliesController /
  RetailSalesController при сохранении округляют purchasePrice /
  price.amount / unitPrice / discount / paidCash / paidCard до целого
  если флаг выключен.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:21:04 +05:00
nns 38f7725593 feat(product): группа обязательна, ≥1 штрихкод, умные дефолты на новом
- Product.ProductGroupId теперь NOT NULL (Guid вместо Guid?). Миграция
  Phase5g_RequiredProductGroup делает backfill: создаёт «Продукты
  питания» в каждой организации, у которой есть товары без группы,
  переносит туда null-значения, потом ALTER COLUMN NOT NULL.
- ProductDto/ProductInput: ProductGroupId/Name без `?`.
- ProductsController.Create/Update: 400 если barcodes пустой.
- OtherSystem-импорт: при отсутствии productFolder у товара ставится
  defaultGroupId — id «Продукты питания» (создаётся при необходимости).
- DemoCatalogSeeder: «Продукты питания» добавлена в seed-набор групп.
- ProductEditPage:
  • новый товар сразу получает 1 EAN-13 в barcodes,
  • Single-select Единица измерения и Группа лишились опции «—»,
  • дефолт unitOfMeasureId — id единицы code='796' (штука),
  • дефолт productGroupId — «Продукты питания» (или первая),
  • Save disabled пока имя/единица/группа/≥1 штрихкод не заполнены,
  • если штрихкоды удалены — красная подсказка вместо нейтральной.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:24:10 +05:00
nns 23e29be21b feat(forms): MoneyInput/NumberInput + select-пагинация + Range на бэкенде
UI:
- Pagination: ввод страницы заменён на select (option 1..totalPages),
  по выбору сразу setPage. Стрелки ← → остаются.
- Field.tsx: добавлены MoneyInput (decimal + суффикс ₸/$/€) и
  NumberInput (decimal без валюты). Оба фильтруют ввод регулярно
  (только цифры + точка/запятая→точка), при focus — выделяют значение.
- ProductEditPage: purchasePrice / vat / minStock / maxStock / amount
  в ценах продаж переведены на новые компоненты; символ валюты —
  из выбранной валюты позиции/закупки или из defaultCurrencySymbol орг.
- SupplyEditPage / RetailSaleEditPage: quantity/unitPrice/discount
  в строках, paidCash/paidCard в шапке — на NumberInput/MoneyInput
  с символом из form.currencyId.
- CountriesPage: vatRate — NumberInput.

API:
- ProductInput / ProductPriceInput / SupplyLineInput /
  RetailSaleLineInput / RetailSaleInput — добавлены [Range(0,1e10)]
  на денежные/количественные поля и [Range(0,100)] на проценты.
  ASP.NET автоматически валидирует и возвращает 400 при выходе.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:17:32 +05:00
nns 6468186ed5 feat(products): авто-генерация числового артикула при создании
При POST /api/catalog/products если Article пустой — сервер берёт
max(Article::int) среди артикулов текущей организации и ставит +1.
Если числовых артикулов нет — «1». Пользователь может указать
артикул руками, тогда используется его значение.

Конфликт по unique index IX_products_OrganizationId_Article на
SaveChanges перехватывается — возвращается 400 с текстом «Артикул
«X» уже занят в этой организации.» вместо 500. Та же обработка
добавлена в Update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:04:57 +05:00
nns 9c70de9b3d ui(products-list): колонки Штрихкод/Фасовка/Закупочная цена
В таблице товаров:
- «Ед.» → «Фасовка» (packagingLabel из типа товара, sort packaging).
- «Штрихкодов» (count) → «Штрихкод» — первый код монoshrift.
- Убраны колонки «Группа» и «Активен».
- Добавлена «Закупочная цена» с форматированием «305.00 ₸»
  (purchasePrice + purchaseCurrencyCode), sort purchasePrice.

На сервере ProductsController.List принимает новые sort keys
packaging и purchasePrice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:03:45 +05:00
nns 9886b5dee1 feat(org-settings): настройка ShowMinMaxStock для мин/макс остатков
Добавлена Organization.ShowMinMaxStock (bool, default false) — флаг
видимости полей «Минимальный / Максимальный остаток» на карточке
товара. В UI настроек магазина появилась соответствующая галка
с подсказкой. По умолчанию выключено — большинству магазинов
эти поля не нужны.

Миграция Phase5f_ShowMinMaxStock добавляет колонку.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:02:53 +05:00
nns efeeb61e42 refactor(currencies): убрать IsActive и MinorUnit из UI/API
- Currency.IsActive удалён полностью (domain/DTO/API/web/миграция).
  Валюты — глобальный справочник; «архивировать» USD глобально
  бессмысленно, а per-tenant видимости у валют нет.
- MinorUnit остаётся в БД (нужен для форматирования цен), но скрыт
  из UI: убран CurrencyDto.MinorUnit, CurrencyInput.MinorUnit,
  колонка «Знаки» из списка.
- Форма валюты — 3 поля: Код / Название / Символ.
- Миграция Phase5e_DropCurrencyIsActive дропает колонку.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:01:13 +05:00
nns 24dc7fc619 refactor(vat): Product.Vat как decimal(5,2), поле видно только при VatEnabled
Единственная роль галки «В том числе НДС» на товаре — показать/скрыть
поле «Ставка НДС %». Никакой семантики «в том числе/сверху» на товаре
не живёт — это логика документа (продажи/поставки).

- Product.Vat: int → decimal (миграция Phase5d_ProductVatDecimal меняет
  тип колонки на numeric(5,2)).
- ProductDto/ProductInput: decimal? Vat.
- ResolveDefaultVatAsync, seeders, OtherSystem import — decimal.
- OtherSystem import: если vatEnabled пришёл — уважаем, иначе прежний
  fallback «vat=0 → без НДС».
- UI: вместо жёсткого Select [0,10,12,16,20] — TextInput number step=0.01;
  поле рендерится только когда form.vatEnabled=true; дефолт для нового
  товара подставляется из Country.VatRate организации.
- В таблице товаров ставка печатается с 2 знаками (16.00%).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:51:11 +05:00
nns 781f268089 feat(org-settings): галки «Услуга»/«Маркируемый» скрываются по умолчанию
Добавлены organizations.ShowServiceOnProduct и ShowMarkedOnProduct
(оба default false). В UI карточки товара чекбоксы «Услуга» и
«Маркируемый» рендерятся только если соответствующий флаг включен;
в фильтрах списка товаров Tri-фильтры тоже прячутся. В БД поля
IsService/IsMarked у Product сохраняются как обычно — просто UI их
не показывает.

Это параллель к ShowVatEnabledOnProduct: по умолчанию UI максимально
простой, а нишевые фичи включаются через настройки магазина.

Миграция Phase5c_ShowServiceMarkedOnProduct добавляет обе колонки.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:39:06 +05:00
nns 5a06e15924 feat(tables): server-side sort by column header click
Во всех таблицах можно сортировать по клику на заголовок столбца:
первый клик — по возрастанию (↑), второй — по убыванию (↓),
смена колонки сбрасывает предыдущую. Без активной сортировки —
серверный default (обычно по Name ASC).

Реализация:
- PagedRequest: добавлены Sort (ключ колонки) и Order ("asc"/"desc"),
  плюс удобное свойство Desc.
- DataTable: Column.sortKey + props sortKey/sortOrder/onSortChange,
  в заголовке появляется иконка (ArrowUpDown/ArrowUp/ArrowDown).
- useCatalogList: хранит sortKey/sortOrder, отдаёт setSort, шлёт
  ?sort=&order= в query-string.
- Все 10 List-эндпоинтов (Countries, Currencies, UnitsOfMeasure,
  PriceTypes, Stores, RetailPoints, Counterparties, ProductGroups,
  Products, Supplies, RetailSales + Stock/Movements) принимают
  параметры и применяют switch-based OrderBy по whitelisted ключам.
- Все страницы со списками прокидывают sort state и sortKey на
  колонках, где сортировка имеет смысл (тексты/числа/даты).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:24:05 +05:00
nns 31d528d5c2 refactor(countries): drop SortOrder, sort by Name, auto-width columns
- Country.SortOrder удалено из домена/DTO/API/seeder/web/UI.
- Миграция Phase5b_DropCountrySortOrder дропает колонку.
- Список стран сортируется по Name ASC.
- В форме: поле «Порядок» убрано.
- В таблице: убрана колонка «Порядок», ширины колонок сжаты по
  содержимому (Код 80px, Валюта 120px, НДС 100px, Название flex).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:14:01 +05:00
nns 5bea852b94 feat(org-settings): валюта read-only, тянется из страны (как НДС)
- Currency в настройках больше не выбирается, показывается disabled
  как "KZT (₸)", источник правды — Country.DefaultCurrencyId.
- Backend: OrgSettingsInput больше не принимает DefaultCurrencyId;
  Update синхронизирует Organization.DefaultCurrencyId со страной.
- UX: страна — единственный редактируемый вход, определяет и НДС, и валюту.
- Мульти-валютный режим (Organization.MultiCurrencyEnabled) остаётся
  галкой; выбор валюты в закупках/продажах/карточке товара по-прежнему
  скрыт когда флаг выключен.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:08:30 +05:00
nurdotnet 6979599791 feat(vat): ставка в стране + опц. переопределение на товаре
Phase5_VatAsCountryProperty:
- countries.VatRate (numeric(5,2)) — ставка страны, источник правды.
  Seed: KZ=16, RU=20, BY=20, DE=19, CN=13, TR=18, UZ=12, KG=12, KR=10,
  IT=22, PL=23, US=0.
- organizations.ShowVatEnabledOnProduct (bool, default false) — флаг
  отображения на карточке товара.
- organizations.DefaultVat удалён (заменён страной).
- products.Vat ОСТАЁТСЯ: для KZ есть льготные категории (хлеб/молоко =
  0%) и фискальный чек требует ставку на каждой позиции.

Country domain: + DefaultCurrency / VatRate (уже было DefaultCurrencyId
из Phase4, сейчас дополнено).

Organization domain: DefaultVat убран, ShowVatEnabledOnProduct добавлен.

Backend:
- ProductInput.Vat теперь int? — если UI скрывает поле и прислал null,
  ProductsController берёт дефолт из страны организации (Country.VatRate
  при создании; при update сохраняет прежнее значение).
- CountriesController.List/Get/Create/Update возвращает/принимает
  DefaultCurrency и VatRate.
- OtherSystem импорт: дефолт Vat загружается из страны организации.
- SystemReferenceSeeder: новые валюты BYN/UZS/KGS/TRY/KRW/PLN, seed
  country-currency-vat для всех 12 стран.
- OrganizationSettingsController: VatRate read-only из страны,
  ShowVatEnabledOnProduct редактируется.

Web:
- Country type + CountriesPage форма редактирования (валюта, ставка НДС).
- OrganizationSettingsPage: "Ставка НДС" read-only
  (берётся из страны, ссылка на /catalog/countries), галочка
  "Указывать ставку НДС на товаре".
- ProductEditPage: блок Ставка НДС % + галка "В том числе НДС" теперь
  показываются только если showVatEnabledOnProduct=true. В payload
  при save.mutate отправляется vat=null если скрыто.
- ProductsPage: колонка НДС показывается только при включённом флаге.

Galleries/products/settings других этапов — не задеты.
2026-04-24 11:56:28 +05:00