Хелпер BusinessTime (UTC+5). Дашборд (RevenueToday/Week/Month + график) и
группировка period:day в отчётах продаж/прибыли теперь по местной дате.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- RequiresAdvancedModeAttribute (TypeFilterAttribute → IAsyncAuthorizationFilter):
читает Organization.IsAdvancedMode по org_id-claim через AppDbContext,
кэш 30 с, инвалидация после переключения. SuperAdmin bypass. На отказ
возвращает 403 ProblemDetails с code=advanced-mode-required.
- Атрибут навешен на контроллеры:
Sales/DemandsController, Inventory/TransfersController,
Loyalty/LoyaltyCardsController + LoyaltyProgramsController,
Promotions/PromotionsController, Reports/AbcReportController,
Admin/OrgAuditLogController, Telegram/TelegramBindingController,
Common/UserPresetsController.
- И на отдельные действия:
Catalog/PriceTypesController POST/PUT/DELETE (GET доступен — товары
используют retail-прайс),
Catalog/ProductsController POST /bulk-update.
- SuperAdmin toggle (шаг 2) инвалидирует кэш фильтра после SaveChangesAsync.
- SSO (Auth/ExternalAuthController) гейтится UI, а не атрибутом: endpoint
инициируется анонимом без org_id в токене.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1. **DateTime Kind=Unspecified → UTC** в ResolveRange / AsUtc.
ASP.NET парсит 'from=2026-05-29' с Kind=Unspecified, Npgsql 8
отказывается слать такие в timestamp with time zone (500).
Принудительно конвертим Unspecified→UTC (трактуем как полночь
UTC), Local→ToUniversalTime. Применено к Sales/Profit/ABC/Stock.
2. **Enter.Post теперь пересчитывает Product.Cost** по той же
формуле скользящего среднего что Supply.Post. Без этого товары,
попавшие в систему через Оприходование (а не через Supply),
имели Cost=0 — Profit/ABC-отчёты показывали cost=0 и неверную
маржу. Воспроизведение: Enter 100@30 + RetailSale 10@500 →
Profit-отчёт показывал revenue=5000, cost=0 (должно cost=300).
3. **ABC report: Парето-граница по cumBefore (а не cumAfter).**
Единственный товар с cumShare=100% валился в класс C, хотя
полностью покрывает Парето — должен быть A. Чиним: товар
принадлежит классу A если он нужен чтобы пересечь порог
80% (cumBefore < 80%). Стандартный Парето-алгоритм.
stage-reports (8 шагов): Sales/Stock/Profit/ABC + CSV/XLSX
export + edge — все зелёные.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET /api/reports/abc — топ-товары по выбранной метрике с распределением
A/B/C по Парето (A=80%, B=15%, C=5% накопительной метрики).
Параметр metric:
• revenue (по умолчанию) — выручка;
• profit — прибыль (выручка − Quantity·Product.Cost);
• margin — alias для profit (отдельная кнопка в UI).
Граничные случаи: пустой период → пустой набор; товары с net-неположительной
метрикой исключаются (некоторые отдают только возвраты — для ABC не
интересны).
Возвраты учтены со знаком (net-метрика). storeId / productGroupId
фильтры. Export CSV/XLSX.
Web: /reports/abc с цветными плашками класса (A green / B yellow / C red)
и визуальной полосой накопительной доли.
Тесты: 4 интеграционных (Парето на 3 товара 800/150/50 → A/B/C; пустой
период; profit-метрика меняет порядок против revenue; tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET /api/reports/profit с группировками period:day/week/month, product,
group (по группе товаров). Cost-snapshot — Product.Cost (скользящее
среднее, документировано как приближение; точный FIFO требует партий).
Маржа = profit/revenue·100. Защита от деления на ноль при нулевой
выручке (пустой период → margin = 0, не NaN).
Возвраты вычитаются и из выручки, и из COGS (returned line делает
−Quantity·UnitPrice выручки и −Quantity·Cost себестоимости).
Export CSV/XLSX через тот же ReportExport.
Web: /reports/profit с KPI-плашками (общая выручка/себестоимость/прибыль
+ маржа) — прибыль зелёным/красным в зависимости от знака.
Тесты: 3 интеграционных (simple profit calc 4×100−4×50=200=50%; empty
period → пустой набор без 500/NaN; tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET /api/reports/stock?date=… — восстанавливает остатки (Product, Store)
агрегацией журнала StockMovement до указанной даты. На «сейчас» совпадает
с материализованным Stock (инвариант учёта); на прошлую дату — реальная
реконструкция через Σ движений.
Edge-cases:
• дата в будущем → текущий остаток (движений из будущего нет);
• дата раньше первой операции → пусто (пара не существовала);
• операция с future-датой исключена снимком на «сегодня».
Стоимость: последний UnitCost движения до даты на пару (Product, Store);
fallback на Product.Cost если в журнале не было ни одной партии. Это
приближённая оценка — точный FIFO требует партий.
Параметры: storeId, productGroupId, includeZero (вернуть и нулевые
позиции). Export CSV/XLSX через тот же ReportExport.
Web: /reports/stock — дата, фильтры, экспорт, итоговая стоимость.
Тесты: 5 интеграционных (today=current, before-first-mov→empty, future=current,
date-before-op-excludes-it, tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET /api/reports/sales — агрегаты по period:day/week/month, product,
cashier, register, payment. Фильтры: from/to (по умолчанию last 30 days),
storeId, productGroupId. Возвраты включаются с минусом (netto-выручка
для фискальной отчётности).
GET /api/reports/sales/export?format=csv|xlsx — выгрузка через CsvHelper
(BOM UTF-8 + ; разделитель для Excel-RU) и ClosedXML.
Реализация: плоский набор строк проектируется на сервере БД (Join+Where,
EF переводит), агрегация в C#. Сознательный компромисс — EF8 не
переводит «distinct count» внутри group-проекции с join'ами по
nullable-ключам; объёмы отчётов (~десятки тысяч строк/месяц) держатся
в RAM спокойно.
Web: /reports/sales — выбор периода, табы группировки, фильтры, экспорт.
Sidebar: «Отчёты → Продажи» для Admin/Storekeeper.
Bonus: попутно вылечен баг RetailSalesController.Update — DbUpdateConcurrency
«0 affected» воспроизводился при PUT на свеже-созданный возврат
(create-return + immediate edit). Исправлено двумя изменениями:
• Update не делает Include(Lines) — старые строки удаляются ExecuteDelete'ом;
• ApplyLines добавляет новые строки напрямую в DbSet (а не через nav-collection
sale.Lines.Add) — иначе EF8 путается со state'ом из-за client-side Id (Guid).
Тесты: 5 интеграционных (group by product, group by payment, returns reduce
revenue signed, tenant isolation, CSV export). 37 интеграционных всего зелёные.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>