Основное:
* Program.cs: SuppressImplicitRequiredAttributeForNonNullableReferenceTypes=true
+ InvalidModelStateResponseFactory → ValidationProblemDetails + Serilog Warning.
Пустые необязательные строковые поля в InvoiceLine (Note, BarcodeSource и т.п.)
больше не роняют запрос в 400. humanizeError на фронте уже умеет этот формат.
* InvoiceModels.cs: Name/Source/FilePath → string?.
* Validators.cs: InvoiceSaveLinesValidator (Lines NotNull), InvoiceApplyValidator
(Guid.Empty на Store/Currency = ошибка, SupplierId опционален).
Служебный «Поставщик не указан» (адаптация sklad §11 под multi-tenant):
* Phase32b миграция добавляет Counterparty.IsPlaceholder bool + partial index
по IsPlaceholder=true.
* InvoicesController.Apply: supplierId опционален; если не указан —
ResolvePlaceholderCounterpartyAsync ищет/создаёт одного служебного per org.
Имя из накладной в комментарий документа НЕ пишется.
* CounterpartiesController: служебный всегда последним в List сортировках,
Update и Delete при IsPlaceholder → 409 Conflict.
* CounterpartyDto.IsPlaceholder + Mapster-config обновлены.
* Автоподбора/автозаведения контрагентов нигде не было — специально проверили.
Applied-накладная readOnly (§10):
* InvoiceDetailDto: добавлены supplyNumber + processedAt.
* InvoiceReviewPage: applied-режим — плашка «Черновик приёмки № … создан …»
со ссылкой на приёмку, шапка и строки readOnly, скан-блок и apply-кнопка
скрыты. После успешного apply — invalidateQueries, экран сразу readOnly.
Текст ошибки apply (§5) и список накладных (§6):
* ApplyDialog: error → detail → title → (код), __silent на axios.
* Invoices list: parseServerDate подшивает «Z» если его нет, «Загружена DD.MM.YY HH:MM»
в бейдже статуса (видимо в десктоп-таблице и в мобильной карточке — у нас общая).
Тесты + проверки:
* tests/food-market.IntegrationTests/InvoiceApplySklad0610Tests.cs — 5 кейсов:
SaveLines со всеми null, apply без поставщика с idempotency по placeholder'у,
Delete/Update placeholder → 409, applied detail содержит processedAt +
hasFiles=false, apply с Guid.Empty storeId → ValidationProblemDetails.
Все 5 ✓.
* stage retest на https://test.admin.food-market.kz: upload+SaveLines(null-fields)+
apply(без поставщика)+apply(с ИП Бегимбаев)+DELETE placeholder → весь flow
зелёный, служебный один на орг, hasFiles=false после apply.
* quality-watchdog step 9 (menu-walk): 3/3 passed (3.7m).
* Фото-накладная через Claude CLI не гонял — пайплайн apply→DeleteStored
закрыт через hasFiles=false в тестах и реальный stage smoke.
docs/supply-inventory.md — шаг 7 расписан под необязательного поставщика,
новый шаг 8 (applied → readOnly), миграция Phase32b в таблице «Файлы».
docs/sklad-0610-progress.md — финал 9/9 с подробным журналом решений.
Пред-существующие (не трогал):
* OrgAuditLogTests.Counterparty_update_diff_contains_before_after → 403 от
[RequiresAdvancedMode] на /api/admin/audit-log (commit 921bca0, s29);
* ApiReferenceDocsJobTests.Scans_endpoints_with_doubly_nested_generic_return —
регекс-тюнинг доков.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Старый idempotency-маркер — Product с Article LIKE 'DEMO-%'. После того как
«Демо-магазин» на стенде наполнили реальным каталогом из МойСклада
(артикулы не DEMO-*), маркер срабатывал false и QuickLoginDemoSeeder при
каждом рестарте стенда пытался залить фиктивный каталог заново — падая
на unique-индексе IX_product_barcodes_OrganizationId_Code (23505). В БД
ничего лишнего не попадало (транзакция откатывалась), но лог stage-api
получал 9 ERR в сутки.
Фикс: SeedAsync и PeekAsync считают «уже сидировано» при любом Product в
орге. Поведение из UI (POST /api/admin/seed-demo) не меняется для свежих
org'ов. Прочие стартовые сидеры (OpenIddictClientSeeder,
SystemReferenceSeeder, DevDataSeeder) и HangfireJobsConfigurator — уже
идемпотентны, аудит в docs/seeder-noise-progress.md.
Integration-тест:
- SeedDemo_second_call_on_populated_org_returns_already_seeded
- SeedDemo_on_org_with_imported_catalog_skips_without_duplicate_barcode
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Owner caught (повторно, после фикса 500): контейнер stage-api пытался
запустить "claude" в своём cwd (/app), а binary там нет —
"No such file or directory". Включаем RemoteHost SSH:
1. ClaudeCliInvoiceExtractor: передаёт в ssh/scp опции -i <SshKey>,
-o StrictHostKeyChecking=accept-new, -o UserKnownHostsFile=<SshKnownHosts>,
-o BatchMode=yes, -o ConnectTimeout=10. Конфиги Invoices:ClaudeCli:SshKey
и Invoices:ClaudeCli:SshKnownHosts.
2. deploy/Dockerfile.api: + openssh-client в runtime.
3. (compose + ssh-ключ на prod-vm не в репо: deploy/secrets/invoice-ocr-key,
authorized_keys на dev-vm с from="192.168.1.190",no-pty,no-forwarding;
env RemoteHost=nns@192.168.1.192, Executable=/home/nns/.local/bin/claude.)
Owner-policy: фото/сканы накладных НЕ хранить на серверах. InvoiceProcessor
удаляет StoredPath сразу после распознавания — успех и неудача; remote
tmp копия на dev-vm подчищается в finally. InvoiceWorker больше не содержит
sweeper (периодическая очистка не нужна), конфиг Invoices:KeepFilesDays
игнорируется (оставлен в комментарии для backwards compat).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Пропустил в первом коммите: OrganizationSettingsPage в простом режиме
дёргал /api/organization/telegram/status, который [RequiresAdvancedMode].
Токсичный тост силен silent-GET-403 политикой (api.ts), но menu-walk
e2e всё равно ловил 4 сетевых 403 для demo-admin → fail. Чиним у
источника: не фетчим если not advanced.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Owner caught on stage (2026-06-07 18:57):
1) POST /api/purchases/invoices/upload → 500: migration Phase31a создала
invoice_uploads.UpdatedAt как NOT NULL, но конвенция проекта — DateTime?
nullable + стамп только для Modified в AppDbContext.StampTimestamps.
EF на INSERT отправлял UpdatedAt=NULL → 23502. Фикс: Phase31b DROP NOT NULL
+ Phase31b для invoice_aliases.
2) Фоновые 403 → тост «Нет доступа» при F5 на любой странице:
- SavedPresets фетчил /api/user/presets (RequiresAdvancedMode) из любой
страницы с фильтрами в простом режиме → 403;
- useOnboardingProgress фетчил /api/organization/employees (Admin only)
для кладовщика/кассира на '/' → 403.
Фикс:
- api.ts: GET-403 silent, тост только на действия юзера (POST/PUT/DELETE);
- SavedPresets gated на advancedMode (useMe + enabled);
- useOnboardingProgress canSeeEmployees-gate.
3) tests/food-market.IntegrationTests/InvoiceUploadHttpTests.cs — HTTP-прогон
POST /upload через Testcontainers-Postgres (дыра: s31 pipeline tests
проверяли matcher, но ни разу не инсертили в invoice_uploads).
4) tests/e2e/scenarios/stage-ui-menu-walk.spec.ts — regress: каждая демо-роль,
каждый пункт меню, F5, assert 0 unexpected 4xx/5xx + 0 error-тостов +
0 console.error. В quality-watchdog step 9 с 4-часовой каденцией.
5) tests/e2e/scenarios/stage-ui-invoice-upload.spec.ts — demo-keeper
загружает реальное фото накладной (tests/fixtures/invoice-sample.jpg,
сгенерирована Playwright-ом из HTML, 1800×2400 JPEG) → ждёт recognition
→ экран проверки. При не-настроенном Claude CLI тест fail'ится с ясным
сообщением (это тоже баг).
Прогон локально 4/4 (InvoicePipelineTests 2/2 + InvoiceUploadHttpTests 2/2).
docs/hotfix-invoice500-403-progress.md — ход работы 1-4/5.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Для полной заливки каталога МойСклада в «Демо-магазин» на стейдже
переделал импорт по требованию «идемпотентный + без дублей».
Схема:
- Phase32a_MoySkladExternalId: VARCHAR(64) на products и product_groups
+ partial-unique index (OrgId, MoySkladExternalId) WHERE IS NOT NULL.
NULL разрешён — товары, созданные вручную, без MS id.
Клиент (MoySkladClient):
- Auto-detect Basic vs Bearer: МойСклад принимает оба, но сторонние
проекты (sklad на prod-vm) используют Base64(login:password). Строку
распознаём как base64 с ':' в декоде → ставим Basic, иначе Bearer.
- StreamProductsAsync принимает includeArchived (default true для
back-compat; импортный путь передаёт false).
- GetAllUomsAsync — грузит справочник единиц измерения один раз.
Сервис (MoySkladImportService):
- MoySkladImportOptions: OverwriteExisting / SkipArchived / MergeByBarcode.
- Resolution order: MS id → barcode → article. Первый найденный
апдейтится, не плодит дубль.
- Группы — upsert по MS id; pre-существующие группы без MS id с тем же
именем в том же родителе — подхватываются (MS id проставляется).
- Товары из архива MoySklad (archived=true) полностью пропускаются
(счётчик SkippedArchivedInSource в результате).
- Barcode-дубли внутри MoySklad: первый встреченный выигрывает, остальные
пропускаются с записью в Errors (счётчик SkippedDuplicateBarcodeInSource).
- UOM: MS uom.id → MS uom.code → наш локальный Code. Весовые товары
(weighed=true) всегда ставятся в «кг».
- Расширенный MoySkladImportResult: Created / MergedByBarcode /
SkippedArchivedInSource / SkippedDuplicateBarcodeInSource / GroupsUpdated.
Контроллер (MoySkladImportController):
- ImportRequest получает SkipArchived (default true), MergeByBarcode
(default true). Токен по-прежнему принимается в body и НЕ сохраняется
в Organizations.MoySkladToken, если передан в теле запроса.
- Финальный Message прогресса собирает все счётчики.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Три новых роута из Sprint 31 были достижимы только по прямому URL —
в sidebar/командной палитре их не было. Добавлено:
- AppLayout: «Накладные (по фото)» (/purchases/invoices) в разделе
«Закупки», «Инвентаризация с телефона» (/inventory/mobile) в
«Остатках» — для Admin и Storekeeper, видно в простом режиме;
i18n ru + en.
- SplitButtonMenu — переиспользуемая dropdown-кнопка «Новая…».
SuppliesPage: вручную / по фото / сканированием. InventoriesPage:
вручную / с телефона.
- CommandPalette (Cmd+K): новые static-pages invoices, supply-scan,
inventories (был пропущен), inventory-mobile.
- QuickActionsPalette (Cmd+J): invoice-new, supply-scan,
inventory-mobile.
- Параллельно всплыл сирота /admin/diagnostic — добавили ссылку
в sidebar под Admin'ом.
Playwright stage-ui-menu-links.spec.ts: demo-keeper + demo-admin,
desktop 1280×800 + mobile 375×667 — кликами с главной до всех
трёх новых экранов, скриншоты в reports/. Негатив для demo-cashier
проверяет, что ему эти пункты не видны.
Orphan-audit: tests/scripts/menu-orphans-audit.mjs парсит App.tsx и
диффит против фактических точек входа (sidebar/Cmd+K/Cmd+J/split-
кнопок). После всех правок — 0 сирот (отчёт docs/menu-orphans-report.md).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Docker web build использует 'tsc -b' (noUnusedLocals=true), который на
dev-машине проскакивал через tsc --noEmit. Три unused:
- InventoryMobilePage: countRows (из offlineCatalog)
- SupplyScanPage: AlertTriangle, Plus (lucide-react)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Unit:
- InvoiceMatcherTests (12): barcode wins, short barcode falls through,
alias by code/by name_norm, alias is supplier-scoped, по названию
НЕ матчим автоматически (sklad-урок про LD Super Slims),
NormalizeName quotes/whitespace/unit-glue/ё→е, OnlyDigits, AliasKeys
skip пустые.
- ClaudeCliInvoiceExtractorParseTests (8): plain JSON, envelope с
result+usage, markdown fence, barcode spaces strip, null-tolerant,
'empty' raises, пустое name пропускается. Parse/FromJson теперь
public (internal мешал тестам, InternalsVisibleTo избыточен).
Integration:
- Matcher_hits_product_by_barcode_from_real_catalog — реальный
Postgres + seeded org + создаём товар+штрихкод, matcher находит.
- Multi_tenant_isolation_matcher_does_not_see_other_orgs_barcodes —
две org, штрихкод orgA НЕ виден в matcher'е orgB (tenant isolation).
Все новые тесты зелёные. Pre-existing failures в full-suite не связаны.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Backend дополнения (InventoriesController):
- GET /mobile-catalog?groupId= — компактные тройки [barcode, productId,
name] для оффлайн-работы. Фильтр по группе = частичная инвентаризация.
- POST /{id}/add-line — scan-mode: +1 к существующей строке или новая,
bookQty подтягивается из текущего Stock (пункт 6: без ручного
«Пересчитать»).
- GET /{id}/book-drift — список позиций, где текущий Stock != сохранённого
BookQty. UI показывает перед Post, чтобы было видно что учётный поплыл
(продажи/приёмки с момента создания). Пункт 6 целиком.
Существующий Create уже заполняет bookQty из Stock (строки not passed →
подтягивает весь склад; строки passed → подтягивает book по каждой).
Update тоже auto-fill'ит для новых строк.
Web (lib/offlineCatalog.ts): IndexedDB wrapper (DB=fm-inventory,
STORE=catalog, META=meta). saveCatalog/findByBarcode/catalogMeta/
countRows. Порт из sklad'овского db.ts.
Web (InventoryMobilePage.tsx) — три stage'а в одном файле:
- setup: склад + опц. группа + 'Загрузить каталог на телефон'.
- scan: Камера <CameraScanner /> или аппаратный сканер; скан → +1 к
существующей строке; неизвестные → в отдельный список unknown с
предупреждением; офлайн-плашка; localStorage сохраняет rows+docId.
- review: карточки с book/факт/дельта/дельта*себестоимость + фильтр
'только расхождения'; drift-плашка если учётный поплыл;
'Провести' → POST /post.
Routes: /inventory/mobile. tsc зелёный.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CameraScanner (components/CameraScanner.tsx) — порт из sklad'a @zxing/
browser + @zxing/library. Непрерывное сканирование: окно не закрывается
между сканами, EAN13/EAN8/UPC/Code128/ITF. Подавление повтора 1.5с (в
кадре товар распознаётся десятки раз в секунду). Вибрация 40мс + flash
зелёный/красный + анимация scanPop/scanRing.
SupplyScanPage (/purchases/supplies/scan):
- Выбор поставщика (search по /counterparty-search), склада, валюты.
- Кнопка 'Сканировать камерой' → <CameraScanner /> на полный экран.
- Alt: аппаратный сканер — <input> в details-блоке, Enter = +1.
- Каждый скан: GET /product-by-barcode → если нашёлся, +1 к
существующей строке или новая сверху. Цена подставляется last-
purchase-price (из последней Posted supply-line по этому товару).
- Неизвестный штрихкод: feedback 'Нет в каталоге', вибрация ошибки.
Товар создавать — отдельный переход на /products/new (future wiring).
- Прогресс сохраняется в localStorage ('fm.supply-scan.rows') —
перезагрузка не теряет; очищается после успешного сохранения.
- Save → POST /api/purchases/supplies (существующий endpoint), статус
Draft; редирект в /purchases/supplies/:id для проверки/проведения.
InvoicesController.ProductByBarcode теперь возвращает ещё и
lastPurchasePrice (из последней Posted supply-line).
@zxing/browser@0.1.5 + @zxing/library@0.23.0 добавлены в deps.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
InvoicesPage: карточки загрузок со статусом (бейдж + иконка),
поллинг каждые 3с пока есть pending/parsing (останавливается на
needs_review/applied/failed). Две кнопки загрузки:
- 'Снять камерой' (<input capture="environment" accept="image/*">)
- 'Загрузить файл' (image/* + pdf, multi-select)
Mobile-first: кнопки большие, grid 2-col; на desktop растёт.
InvoiceReviewPage: карточками-строками (телефон читается, desktop
масштабируется). Редактируемые поля qty/price, авто-сумма.
- Шапка: supplier, №, дата, сумма (editable). Предупреждение если
сумма строк отличается от «Итого» больше чем на 1.
- Общий сканер (input Enter) → в первую несопоставленную строку.
- Для несопоставленной строки — 'Найти товар' (debounced search
по /api/catalog/products?q=) с ссылкой 'Создать товар' если
ничего не нашлось.
- Save → PUT /lines (пишет alias'ы автоматически).
- Apply → ApplyDialog: выбор поставщика (search с БИН),
склад (default main), валюта (default KZT). POST /apply →
редирект в /purchases/supplies/:id.
Routes добавлены в App.tsx: /purchases/invoices и /purchases/
invoices/:id.
tsc зелёный.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Backend-pipeline по образцу ~/ref/sklad/sklad/Invoices/, но документы
создаются в НАШЕЙ базе (Supply), не в МойСкладе.
Домен (food-market.domain/Purchases/InvoiceUpload.cs):
- InvoiceUpload: файл + статус (pending/parsing/needs_review/applied/failed)
+ распознанная шапка + ParsedJson (jsonb) + ссылка на созданный Supply.
- InvoiceAlias: запомненное соответствие (SupplierKey, SourceCode|
SourceNameNorm) → ProductId. Два частично-уникальных индекса (filter
SourceCode/SourceNameNorm IS NOT NULL).
Миграция Phase31a (идемпотентная ADD/CREATE IF NOT EXISTS; [Migration]+
[DbContext]).
Infrastructure/Invoices/:
- IInvoiceExtractor + ParsedInvoice/InvoiceLine DTO.
- ClaudeCliInvoiceExtractor — порт из sklad: 'claude -p --output-format
json', промпт один-в-один (копировать все строки, null если не читается,
barcode != supplier_code, сверка с «Итого»). Поддерживает local и
Invoices:ClaudeCli:RemoteHost (ssh+scp+cleanup).
- InvoiceMatcher — barcode → alias → none. По названию НЕ ищем
(sklad-урок: 2-5 ошибок из 10, подмена хуже пустой строки).
- InvoiceProcessor + InvoiceWorker (BackgroundService):
pending → parsing → needs_review | failed. SweepAsync раз в час
удаляет файлы старше Invoices:KeepFilesDays (default 7); строки
остаются. Multi-tenant через IgnoreQueryFilters + явный OrgId.
Controller api/purchases/invoices:
- POST /upload (SuppliesEdit, 60 MB max): файлы в uploads/invoices/{yyyy-
MM}/, запись в pending.
- GET /, GET /{id} — список + детали со строками.
- GET /{id}/file — отдать файл.
- DELETE /{id} — удалить запись + файл.
- PUT /{id}/lines — правки шапки + строк + авто-upsert InvoiceAlias
для каждой строки с MatchedBy=manual|alias (два ключа: по code и по
name_norm).
- POST /{id}/apply — создать Supply (Draft) с данным поставщиком/
складом/валютой, статус → applied, SupplyId ссылается, файл удаляется.
- GET /product-by-barcode, /counterparty-search — помощь UI.
Build зелёный. Web + тесты — следующими шагами.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Координатор отротировал мастер-пароль стенда: теперь значение живёт в
~/.fm-watchdog/stage-shared-password на dev-vm (не коммитится) и в
QUICK_LOGIN_MASTER_PASSWORD в stage-env на prod-vm. Старый дефолт '123456'
на обоих сторонах убран — он мог вводить в заблуждение и был записан в
бандл.
Клиент-серверный контракт:
- Server (AuthorizationController.IsMasterPassword) принимает ДВА пароля
как мастер-ключ: (a) QuickLoginOptions.MasterPassword (константное
сравнение, FixedTimeEquals); (b) публичный sentinel
QuickLoginOptions.SentinelPassword = '__QUICK_LOGIN_PWLESS__'. Оба
гейтованы EffectivelyEnabled — вне стенда отбиваются как любой чужой
пароль.
- SPA (LoginPage.handleQuickLogin) при клике карточки шлёт sentinel в
поле password. Реальный пароль в JS-бандл больше не попадает; ротация
секрета больше не требует rebuild'а веба.
- Форму мы НЕ наполняем sentinel'ом — поле password остаётся пустым,
чтобы ни одно значение не светилось в DOM.
Default QuickLoginOptions.MasterPassword = "" (пустая строка не
матчится в IsMasterPassword). deploy/docker-compose.yml дефолт
QUICK_LOGIN_MASTER_PASSWORD тоже пустой.
Тесты:
- integration (3/3 ok) — добавлены sentinel-кейсы: пропускает активного
при Enabled=true, отвергается при Enabled=false. Admin12345!-санити-
логин в Disabled-тесте перенесён вперёд, чтобы снять lockout-счётчик
от соседей по коллекции (Identity lockoutOnFailure=true).
- e2e stage-quick-login (4/4 ok) — ql02 шлёт sentinel вместо '123456'
(симулирует реальный SPA flow).
docs/quick-login.md:
- без упоминания реального пароля; хранилище — ~/.fm-watchdog/
stage-shared-password; ротация-рецепт через python читает файл
в-процессе (не экспонирует значение в ps/логи).
- раздел «Доступ к стенду» — host-nginx basic-auth для внешнего
интернета, 192.168.1.x бьёт без неё.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Workflow (.forgejo/workflows/docker-public.yml):
- Собирает ДВА тега: :test с PUBLIC_APP_URL=https://test.admin.food-market.kz
(для test.food-market.kz) и :latest с PUBLIC_APP_URL=https://admin.food-
market.kz (для будущего food-market.kz). deploy_prod отключён
намеренно: прод-public закрыт владельцем.
- deploy_test: SSH на prod-vm, идемпотентно устанавливает PUBLIC_TAG=test
в ~/food-market/deploy/.env, pull + up -d public. Smoke проверяет что
/signup/ ссылается на test.admin и НЕ содержит admin.food-market.kz.
nginx.conf (src/food-market.public/deploy/):
- absolute_redirect off; port_in_redirect off;
Было: curl -sI https://test.food-market.kz/signup → location: http://...
(schema терялась на 301 /signup → /signup/).
Стало: location: /signup/ (относительный, браузер держит https).
Env-пример (.env.example) — комментарий, что для test-билда ставить
test.admin и прод в коде оставить дефолтом в source-коде (SignupForm/
Header), но в билде всегда задавать явно через PUBLIC_APP_URL build-arg.
RUNBOOK — новый раздел «Публичный сайт: какой образ ↔ какой домен» с
таблицей и smoke-командой.
Smoke проверил:
- https://test.food-market.kz/signup → 301 на /signup/ (без схемы, https
сохраняется).
- https://test.food-market.kz/signup/ → ссылается на test.admin.food-
market.kz; ни одного вхождения admin.food-market.kz (без test.).
- POST /api/auth/signup с Origin test.food-market.kz → user создан в
food-market-stage-postgres-1 (STAGE), не в food-market-postgres (прод-
adjacent).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Разбил монолитный SeedAsync на 4 метода, каждый со своим DI-scope и
DbContext'ом:
1) EnsureDemoOrgAsync — org Демо-магазин;
2) EnsureTenantReferencesAsync — единицы/магазин/роли через
DevDataSeeder.SeedTenantReferencesAsync;
3) TryFillCatalogAsync — DemoTenantSeeder (товары/приёмки/продажи);
4) EnsureDemoUsersAsync — трио демо-юзеров.
Корень проблемы на стенде: DemoTenantSeeder падает с «Tenant not set»
(при старте нет HTTP-контекста → ITenantContext.OrganizationId пуст →
StockService.ApplyMovementAsync бросает). Если переиспользовать один
scope, следующий UserManager.CreateAsync возвращает
DbUpdateConcurrencyException — DbContext транзакционно сломан.
Теперь: catalog-step изолирован и его падение не отравляет создание
юзеров. Catalog нужен только для показательного UI — он
опциональный, с явным логом «ОК — создать можно через POST
/api/admin/seed-demo после логина».
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Весь StartAsync обёрнут try/catch — dev-удобство для стенда не должно
класть API. Если сид упал (DbUpdateConcurrencyException и т.п.) — Warning
в лог, SuperAdmin + уже существующие аккаунты по мастер-паролю работают.
Demo-trio можно будет досеять руками или через POST /api/admin/seed-demo
если нужно.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Критический фикс: раньше safety-override срабатывал на host.EndsWith(
".admin.food-market.kz") — это ВКЛЮЧАЛО сам стейдж (test.admin.food-market.kz
заканчивается на ".admin.food-market.kz"). Деплой на стенд это и показал:
Warning в логе "test.admin.food-market.kz — это ПРОД" и /api/dev/quick-
login/accounts возвращал 404.
Теперь проверка: host == "admin.food-market.kz" (точное совпадение). Любой
поддомен (test.admin.*, dev.admin.*) — стенд, быстрый вход включается.
Если в будущем появится второй прод-хост — дописать условие явно.
Также добавлено:
- deploy/docker-compose.yml: пробрасывает QuickLogin__Enabled и
QuickLogin__MasterPassword из env (default off).
- docs/quick-login.md: как работает, предохранители, как выключить, как
проверить прод.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
HousekeepingJobs.PruneAutoTestEntitiesAsync — Hangfire-recurring (02:45 UTC
daily, Cron:PruneAutoTestEntities). Удаляет:
- orgs по regex autotest-паттерна в имени (quality-, soak-, UI-a11y-,
SmokeOrgA/B-, PerfBaseline-, Reg-, RT-ratetest-, Probe Org, cat3*,
visual-, factory-smoke-, mt6*, doc4*, auth2*, rep5*, rt8*, wiz9*,
i18n*, twofa*, iso*, mob1*, axdet-, load-, s19-test- + любое имя
с [0-9]{10,}) старше 3 дней + все их таблицы с OrganizationId;
- orphan autotest-юзеров (без удалённой org) по regex в email и доменам
@e.kz / @test-fm.local.
Защищены: SuperAdmin (OrganizationId IS NULL), admin@food-market.local,
admin@demo-market.local, demo-*@food-market.local, market@email.com,
«Demo Market» и «Демо-магазин» — явные исключения.
deploy/stage-prune-autotest.sql — ровно та же логика как SQL-скрипт для
ручного запуска. Прогнал на stage: было 8653 user'ов / 8634 orgs →
стало 300 users / 305 orgs. SuperAdmin/demo/market остались.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
StageBanner — жёлтая полоса сверху всех страниц, когда /api/dev/
quick-login/accounts отвечает 200 (стенд). В проде эндпоинт 404 →
компонент возвращает null, никакая полоса не рисуется.
Решение «stage vs prod» кэшируется в sessionStorage, чтобы не стрелять
запрос на каждой навигации в SPA (один запрос за открытие вкладки).
На стенде динамически добавляется <meta name=robots content=noindex,
nofollow> — не хотим чтобы Google индексировал test.admin.food-market.kz.
Один web-билд ходит в оба окружения; никаких отдельных index.html.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Над обычной формой логина теперь висит жёлтый блок «Быстрый вход
(тестовый стенд)»: клик по карточке → моментальный логин через
мастер-пароль 123456 (сохранён как константа в LoginPage.tsx — должен
совпадать с server-side QuickLoginOptions.MasterPassword).
Блок сам опрашивает /api/dev/quick-login/accounts: 200 → рендерится,
404 (= прод) → возвращает null, НИЧЕГО не рисует. Так одним и тем же
web-билдом покрываются и стенд, и прод — секрета в UI нет.
Поиск: ?q= на сервере (debounced 200ms), фильтрует по email/ФИО/роли/
магазину. Карточки с badge SuperAdmin / Демо. Lazy-рендер, max-h-80
с overflow-y чтобы не ломать компоновку.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hosted-сервис, который на старте, когда EffectivelyEnabled=true, создаёт:
- организацию «Демо-магазин» (отдельная от «Demo Market» DevDataSeeder'а,
чтобы не ломать e2e, завязанные на DemoMarket);
- tenant references через существующий SeedTenantReferencesAsync (единицы,
магазин, PriceType, системные роли);
- каталог демо-товаров через DemoTenantSeeder (50 товаров + 30 продаж +
приёмки/контрагенты) — идемпотентно, маркер DEMO-*;
- три учётки с ролями Admin/Cashier/Storekeeper:
demo-admin@food-market.localdemo-cashier@food-market.localdemo-keeper@food-market.local
(пароль Admin12345!, но мастер-пароль 123456 тоже подойдёт — через
QuickLoginOptions в AuthorizationController).
На проде (EffectivelyEnabled=false) сервис тихо выходит в StartAsync —
ничего не трогает, ничего не логирует.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Анонимный эндпоинт, который ВИДЕН только когда QuickLogin эффективно
включён (EffectivelyEnabled=true). В проде отдаёт 404, чтобы по наличию
эндпоинта нельзя было вычислить среду.
Фильтрация:
- IsActive=false → не показываем;
- автотестовые (prefix cat31-/visual-/factory-/mt6*/doc4*/auth2*/rep5*/
rt8*/wiz9*/quality-/i18n*/twofa*/a11y-/mob14-/mob15-/iso*/axdet-/
smoke-/ratetest-/perf-/probe-/load-/s19-test-/admin-/cashier-/
keeper-) → не показываем;
- email@e.kz, @test-fm.local — служебные домены diag-probes, тоже скрыты;
- длинный числовой суффикс [0-9]{10,} в email → автотест.
Порядок: SuperAdmin → demo → остальные (ThenBy email для стабильности).
Лимит 50 + поиск ?q по email/full-name/org-name/role (case-insensitive).
Карточка: email, FullName, роль (SuperAdmin / EmployeeRole.Name),
название org, флаги IsSuperAdmin/IsDemo для сортировки/отображения.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AuthorizationController теперь после провала обычного CheckPasswordSignIn
даёт второй шанс через QuickLoginOptions.IsMasterPassword — FixedTimeEquals,
чтобы не светить длину. Включается только когда EffectivelyEnabled()=true
(т.е. Enabled=true И Issuer не prod).
Что сохранено:
- неактивные пользователи отшиваются до пароля (user.IsActive сверху);
- 2FA / org-check — после master-password пути ВСЁ ЕЩЁ работают;
- rate-limit (UseAuthFormUsernameKey + UseRateLimiter) отрабатывает ДО
нас, никак не ослабляется;
- собственный пароль продолжает работать — не ломает автотесты и seeded
admin'ов, которые используют Admin12345!.
Warning в лог при каждом срабатывании — чтобы в прод-логах (если кто-то
по ошибке активировал) было видно.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dev-only флаг «быстрого входа». Enabled=false по умолчанию, подхватывает
QuickLogin:Enabled и QuickLogin:MasterPassword из конфига (планируется
проброс через env в stage-compose).
EffectivelyEnabled() — жёсткий предохранитель: даже с Enabled=true, если
OpenIddict:Issuer указывает на admin.food-market.kz (прод) — считаем
выключенным. Пустой Issuer тоже отбивает — не рискуем в неизвестном окружении.
Program.cs логирует Warning на старте: включили → «QuickLogin включён для
стенда», Enabled=true но прод → «не активирован», Enabled=false — молчим.
Чтобы ошибка конфигурации всегда была видна в логах прод-инстанса.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- SuperAdminOrganizationsPage:
- OrgRow.isAdvancedMode добавлен.
- Новая колонка «Режим» — бейдж «Простой» (slate) / «Расширенный»
(violet).
- Кнопка-экшн (иконка Sparkles) открывает модалку подтверждения
с разной копией для вкл/выкл; успешный PUT → list.refetch().
- Выкл-копия напоминает что данные НЕ удаляются и переключение
обратимо; вкл-копия перечисляет открывающиеся модули.
Переключение из карточки организации — отложено, список-страница
с бейджем и кнопкой закрывает UX.
pnpm run build — ✓ 2.02s, 0 TS errors.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Везде формула: simpleMode = me && !me.advancedMode && !SuperAdmin.
Значения скрытых полей в state сохраняются и при save на сервер
отправляются как есть; данные НЕ стираются.
- ProductEditPage: скрыты Артикул (auto-gen), Описание, Страна,
Эталонная цена, Ставка НДС, «В т.ч. НДС», Валюта закупки,
«Услуга», «Маркируемый», Максимальный остаток, не-retail типы
цен в блоке «Цены продажи».
- CounterpartiesPage: скрыты Юр.название, Страна, Email, Банк,
Р.счёт, БИК, Контактное лицо. Остаются: Название, Тип лица,
БИН/ИИН, Адрес, Телефон, Заметки.
- SupplyEditPage: колонка «Валюта» всегда скрыта в простом режиме
(одна валюта по умолчанию).
«Поставщик/Покупатель»-галочки отложены — требуют новых колонок
IsSupplier/IsBuyer на Counterparty (новая функциональность).
pnpm run build — ✓ 2.36s, 0 TS errors.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- AdvancedModeGuard — render-guard: простой режим + не-SuperAdmin → страница
«Недоступно в простом режиме» с текстом + кнопкой «На главную».
SuperAdmin пропускается, чтобы можно было проверить работу после
переключения.
- useMe (+ локальные MeResponse) расширены полем advancedMode: boolean.
- AppLayout.buildNav(roles, advancedMode) — в простом режиме скрыты:
priceTypes, transfers, demands, promotions, loyaltyPrograms/Cards,
reportAbc, auditLog, LanguageSwitcher.
- App.tsx — скрытые маршруты обёрнуты в <AdvancedModeGuard>:
/catalog/price-types, /inventory/transfers*, /sales/demands*,
/loyalty/programs, /loyalty/cards, /promotions, /audit-log,
/reports/abc.
- DashboardPage — в простом режиме скрыты виджеты «Топ товаров» и
«Маржа». Остаются KPI, график продаж, «Последние продажи», «Низкий
остаток».
- Данные скрытых модулей НЕ удаляются при выключении режима; при
повторном включении всё видно.
pnpm run build — ✓ 2.6s, 0 TS errors.
Co-Authored-By: Claude Opus 4.7 <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>
- PUT /api/super-admin/organizations/{id}/advanced-mode { enabled: bool }
— только SuperAdmin, админ орги → 403 от [Authorize(Roles=...)].
- Idempotent: совпадающее состояние → 200 без записи в аудит; разное →
запись SuperAdminAuditLog (EnableAdvancedMode / DisableAdvancedMode)
с before/after + IP. Архивную org переключать нельзя (409).
- /api/me теперь отдаёт advancedMode (bool) — читается из Organization
с IgnoreQueryFilters (SuperAdmin без org → false — работает через
/api/super-admin/...).
- OrgRow / OrgDetail в SuperAdmin list / detail дополнены IsAdvancedMode
для бейджа «Простой/Расширенный» (шаг 6).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simple/Advanced mode: по умолчанию у всех организаций «простой» режим
(IsAdvancedMode=false) — виден только базовый розничный учёт. Включить
«расширенный» режим может только SuperAdmin через отдельный API; в
настройках магазина переключателя нет.
Данные скрытых модулей при переключении НЕ удаляются; при повторном
включении всё снова видно.
- Organization.IsAdvancedMode (bool, default false) + XML-док
- EF-миграция Phase29a — идемпотентный ADD COLUMN IF NOT EXISTS DEFAULT false
- AppDbContextModelSnapshot обновлён
- Применено на dev-БД: 5/5 организаций → IsAdvancedMode=false
docs/simple-mode-progress.md — чек-лист + журнал.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ASP.NET Core convention для HttpX-атрибутов: `~/path` означает
'absolute from root, ignore class [Route]'. До фикса генератор клеил
`base-route` + `~/path` → невалидный `/~/connect/token`.
Теперь tilde корректно срезается, /connect/token виден в reference.
Также добавлен unit test ApiReferenceDocsJobTests (Sprint 28) для
lock-down regex behavior на double-nested generics.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Overnight progress while 4h-soak runs in background:
1. ApiReferenceDocsJob.cs + scripts/gen-api-reference.py — return-type
regex теперь ловит nested generics любой глубины. Было 195
endpoint'ов в auto-gen reference; стало 240 (+45). EmployeesController
GET /api/organization/employees был пропущен из-за
Task<ActionResult<PagedResult<EmployeeDto>>>.
2. docs/observability.md — добавлен food_market_disk_free_bytes (Sprint 20)
+ раздел "quality-watchdog метрики" (5 метрик textfile exporter'a из
Sprint 26: run_total, step_failure_total, endpoint_p95_ms,
last_run_status, incidents_total). Готовые dashboards теперь содержат
оба JSON (food-market.json + quality-watchdog.json).
3. tests/integration/07-import-export-flows.spec.ts — POST 1C-CSV import
(semicolon-CSV cp1251) → создаются продукты с группой автоматом;
POST /api/org/export (НЕ /api/admin/org-export) → возвращает
{id, status}; orgB не видит export orgA. Прогон 8.2s.
4. tests/food-market.IntegrationTests/PruneQualityTestOrgsTests.cs —
2 [Fact]'a для метода из Sprint 25. Удаляет только quality-* старше
threshold, не трогает реальные org. Требует Testcontainers.
5. .forgejo/workflows/regression.yml — добавлен шаг integration suite
после flows+visual. Telegram: "35 flows + 60 visual + 8 integration".
Soak-real (4h @ 50 RPS) запущен в setsid-detach session, продолжается.
Итоговые числа добавлю в sprint28-progress.md после завершения.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sprint 23 (adversarial): атаковали систему как недоброжелатель.
Найдено 4 бага, все починены.
Bug #001 (Medium): NULL-byte в Product.Name вызывал 500 без тела.
Postgres TEXT не принимает \x00. Добавил NoControlChars() в
ProductInputValidator + CounterpartyInputValidator.
Bug #002 (Low): ProductInputValidator MaximumLength(200) конфликтовал
со StringLength(500) в DTO и schema HasMaxLength(500). Сделал 500
везде. Counterparty: 200 → 255 (matches HasMaxLength).
Bug #003 (CRITICAL): параллельные posting'и под Serializable выбрасывали
PostgresException 40001 → middleware → 500 empty body. Добавил
SerializationConflictMiddleware который мапит 40001 → 409 Conflict
с {error, retryable: true}. Также SerializableRetry helper для
явного retry внутри endpoint'ов с exp backoff. Применил retry-wrap
к RetailSalesController.Post (PostCoreAsync extracted).
Bug #004 (Low): цена 0.0000001 округлялась до 0 уже после прохождения
required-price check (check был ДО RoundIfNeeded). FindMissing-
RequiredPriceAsync теперь округляет перед сравнением — required
цена реально > 0 после rounding.
Bug reports: tests/e2e/reports/bugs/bug-00[1-4]-*.md (github-issue format).
Multi-tenant attacks (cat 3): clean — все cross-org GET/PUT/DELETE
дают 404, bulk-update affected=0, lists не утекают.
Auth-edge (cat 2): clean — JWT tampering 401, garbage 401, CORS evil.com
не получает allow-origin, fake refresh 400 invalid_grant.
DOS (cat 7): clean — 50MB body 413, 200 headers 431, long URL 200.
Hangfire safety (cat 8): clean — regular Admin → /hangfire 403,
seed-demo использует tenant context, body org-id игнорируется.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Parse1cCsv ловил Windows-1251 на UTF-8 bodies без BOM. Теперь
смотрит Content-Type charset первым, потом BOM, потом WIN-1251.
- Migration Phase22a_OrgExports забыл UpdatedAt колонку (Entity base
имеет её). ADD COLUMN IF NOT EXISTS внутри миграции для уже
созданных таблиц.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
При первом деплое /api/employees вернул 404 — реальный endpoint
/api/organization/employees. Также DTO содержит lastName/firstName/
middleName отдельно, не fullName — собираем строку на клиенте.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sprint 11 — каркас для интеграции с операторами фискальных данных РК.
Реальные ApiKey'и появятся у user'а позже; задача — построить такой
фрейм, чтобы подключение оператора сводилось к вписыванию кредов в UI
без правок кода/деплоя.
Что сделано:
- IFiscalProvider (Application/Common/Fiscal) + FiscalResult,
FiscalProviderKind (None/Mock/Webkassa/Kassa24/OfdSolo),
IFiscalProviderFactory, FiscalNotConfiguredException.
- 4 реализации в Infrastructure/Fiscal:
• MockFiscalProvider — фейк MOCK-<8hex> через 300мс, идемпотентный
по Sale.Id (используется dev/stage и интеграционными тестами);
• WebkassaProvider — полный HTTP-pipeline Authorize→Check, парсинг
JSON-ответа, NDS-в-ставке, retry-safe через ExternalCheckNumber;
• Kassa24Provider / OfdSoloProvider — скелет с тем же контрактом,
RegisterAsync бросает FiscalNotConfiguredException (нужны
спецификации API от user'а, NDA-only).
- Миграция Phase11a: 5 колонок в retail_sales (FiscalNumber, QrCode,
Url, ProviderTxId, ProviderKind) + 5 в organizations (FiscalProvider
NOT NULL default 0, ApiKey/Secret encrypted, CashboxUniqueNumber,
ApiBaseUrl). Default 0 = обратная совместимость, существующие чеки
и продажи без фискализации работают как раньше.
- RetailSalesController.Post — TryFiscalizeAsync после commit'а
stock-транзакции. Best-effort: сетевые/HTTP-ошибки логируются, чек
остаётся проведённым. Идемпотентность по IsNullOrEmpty(FiscalNumber).
- OrgFiscalSettingsController: GET/PUT настройки + GET /providers
(опции для select'а) + POST /test-send (фейк-чек к выбранному
провайдеру, не сохраняет в БД).
- UI: FiscalSection в OrganizationSettingsPage с password-input'ами
для ApiKey/Secret (шифруются DataProtection.purpose=foodmarket.fiscal,
в GET — только has-* флаги), спец-значение "__clear__" для снятия,
кнопка «Тестовая отправка».
- Тесты: 11 unit (Mock 5 + Webkassa payload 6) + 3 integration
(Mock сохраняет FiscalNumber, test-send даёт MOCK-номер, None
не фискализует).
- docs/ofd-integration.md — гид с архитектурой, шагами подключения
Webkassa (полный pap), TODO для Касса24/ОФД-Соло, безопасностью
кредов, retry-сценариями.
Все 68 unit + 8 integration в Fiscal/Loyalty/RetailOversell — зелёные.
Web vite build — зелёный.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
S10-3: командная палитра для быстрой навигации и поиска.
Backend GlobalSearchController:
- GET /api/search/global?q=… ищет в 3 источниках: товары (name/article/
barcode startsWith), контрагенты (name/bin contains), документы
(Supply.Number, RetailSale.Number, Demand.Number) — по ≤5 в каждой
группе. Tenant-scoped, требует ≥2 символа в q.
- Lower-cased contains; EF8 OrderBy на record-projection ломается,
поэтому проектируем в anonymous, потом маппим в DocumentHit.
Frontend CommandPalette.tsx:
- Глобальный хоткей Cmd+K / Ctrl+K (listener на document в AppLayout).
- Статический список 20 страниц для навигации без меню (даже без API).
- Дебаунс query 200мс → GET /api/search/global при q ≥ 2 символов.
- Recent items: localStorage 'fm.cmdk.recent', последние 10 выбранных
показываются когда q пустой.
- Подсветка совпадений через RegExp split + <mark>.
- Хоткеи: ↑↓ Enter Esc; группированный список (Recent / Pages /
Товары / Контрагенты / Документы).
Проверено на стэйдже: q='колбас' → 3 продукта, q='Алматы' →
2 контрагента (поставщики), q='ПР-Y1-00019' → 5 retail-sale.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
После предыдущего фикса 5/мин per-username — per-IP 30/мин всё равно
ломал stage e2e (multi-tenant специ делают 4 signup+token подряд →
накапливается за минуту). Поднял до 60/мин token, 600/час; per-username
5/мин остаётся как анти-bruteforce.
Также: playwright.config.ts добавлен locale: 'ru-RU' — без этого
Chromium шлёт en-US, i18next отдаёт английский sidebar, а тесты ищут
русские лейблы (2.2 'Главная', 6.1 'Поставщик/Склад/Дата').
Verify-spec'и V-14 (POS Sync) и V-15 (Stock race) — починены payload'ы
под актуальную схему API (/api/catalog/stores не /api/inventory/stores,
quantity не qty, unitCost не costPrice, polnyy retail-sale body с
retailPointId/currencyId/payment/isReturn). Проверено:
- V-14: 1-й POS-батч 200 (accepted=1), 2-й replayedFromCache=true с тем
же serverSaleId; detail GET показывает notes=pos:<csid-N> ✓
- V-15: 5 параллельных Post на остаток=3 → ровно 3 успешных (204), 2
конфликта (409 'Недостаточно остатка'). Stock=0 после dust settles. ✓
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Регрессия после ba54155: rate-limit 5/мин per-IP сваливал stage e2e
(75 тестов с одного IP, каждый по /connect/token при apiSignup → 22+
из них падали на 429 после 5-й попытки). Per-IP лимит был
неправильной осью защиты.
Новая стратегия в AuthRateLimiterExtensions:
- Per-username (только /connect/token): 5/мин, 20/час. Защищает от
перебора пароля к конкретному account независимо от IP атакующего.
Username вытаскивается form-body peek-middleware'ом перед UseRateLimiter
(EnableBuffering + ручной парс x-www-form-urlencoded, тело ≤4KB).
- Per-IP (token+signup): 30/мин, 200/час. Защищает от спам-регистрации
и от 1-IP-перебирает-тысячи-аккаунтов сценария.
- Back-compat: legacy RateLimiting:PerMinute/PerHour мапятся в IP-лимит.
Проверено через https://test.admin.food-market.kz:
- 6 неверных попыток на ОДНУ учётку → 6-я → 429 ✓
- 8 неверных попыток на РАЗНЫЕ учётки с того же IP → все 400 (IP-лимит 30/мин не достигнут) ✓
Также добавлены verify-spec'и stage-ui-verify-pos-sync (п.14) и
stage-ui-verify-stock-race (п.15).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Verify-Sprint баги A-D:
- A: на stage docker-compose.yml был "RateLimiting__PerMinute=200" — убран,
теперь работают дефолты (5/мин, 20/час). 6-я попытка с тем же IP/паролем → 429.
- B: web-контейнер nginx не имел location = /metrics → запрос ловился SPA
fallback'ом (index.html, 947 байт). Добавлен proxy_pass на api:8080.
- C: web-nginx не имел location /swagger/ → swagger.json возвращал SPA HTML.
Добавлены /swagger/ + редирект /swagger → /swagger/.
- D: Swagger подключался только в Development. Добавлен флаг IncludeSwagger
(env IncludeSwagger=true) — Program.cs включает UseSwagger() и в Production
если флаг выставлен. На prod admin.food-market.kz флаг не ставим.
Проверено через https://test.admin.food-market.kz:
- 6 неверных логинов подряд: 1-5 → 400, 6-7 → 429 ✓
- /metrics → 14967 байт prometheus exposition ✓
- /swagger/v1/swagger.json → 422 КБ openapi 3.0.1 ✓
- /swagger/ → swagger-ui (redirect на /swagger/index.html) ✓
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SignalR через web sockets/long-poll стримит данные, его нельзя кешировать.
В пред-версии SW не интерсептировал POST, но GET-fetch для negotiate
проходил через SW pipeline и валился TypeError'ом.
Фикс: явный return на /hubs/* перед всеми стратегиями.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Backend:
- src/food-market.api/Storage/IObjectStorage.cs — абстракция
(SaveAsync, OpenAsync, DeleteAsync, PublicUrl). Имя реализации (Kind)
для логов.
- LocalObjectStorage — ContentRoot/uploads/{key}. По умолчанию.
- MinioObjectStorage — S3-совместимый bucket, ключ-объекта совпадает с
тем что хранится в БД (products/{id:N}/{guid}.png). PutObject с явным
size (для NetworkStream копируем в MemoryStream).
- StorageOptions: Type=Local|Minio, Endpoint, AccessKey, SecretKey,
UseSsl, Bucket=food-market-uploads.
- StorageBootstrap.AddObjectStorage — DI-регистрация с runtime fallback
на Local если MinIO-config пустой; MinioBootstrap (IHostedService)
создаёт bucket на старте, ловит ошибку и не валит API.
- UploadsController: GET /uploads/{**path} → стримит из IObjectStorage
(cache-control 7 дней). Нужен когда MinIO активен — для Local nginx
раздаёт быстрее, но фолбэк работает.
- ProductImagesController отрефакторен на IObjectStorage; URL'ы в БД
остаются /uploads/products/{id}/{guid}.ext.
Тесты:
- StorageAbstractionTests (3/3 ✓): Local default, round-trip bytes,
PublicUrl pattern.
Stage-готовность:
- deploy/docker-compose.yml на стейдже обновлён (через scp): добавлен
minio container, depends_on в api, env переменные Storage__*.
Bucket автосоздаётся.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Найдено через UI-deep: после успешного создания нового сотрудника
EmployeesPage не вызывал refetch/invalidate на list-query, и список
показывал старые данные до ручного refresh страницы. Причина:
direct api.post вместо useCatalogMutations.create (нужен custom response
shape с generatedPassword для one-shot модалки).
Фикс: await qc.invalidateQueries({queryKey:[URL]}) сразу после успеха.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Найдено через UI-deep: EmployeesPage в catch'е save'a доставал
err.response.data.error || err.message и показывал в модалке. На 400-ках
с ProblemDetails (errors.{field}:[msg]) error отсутствовал и попадал
generic axios «Request failed with status code 400».
Фикс: используем общий humanizeError() (тот же что в toast'е).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Предыдущий фикс с qc.removeQueries({queryKey:['/api/catalog/products', id]})
+ invalidateQueries(exact:true) — оказался не до конца верным:
1) removeQueries на ещё-mounted ProductEditPage с активной подпиской на этот
key триггерит refetch (TanStack заполняет пустой cache на active subscriber).
2) invalidateQueries({queryKey:['/api/catalog/products'], exact:true}) на
самом деле не матчит ни list (ключ имеет 6 элементов с пагинацией), ни item
(ключ из 2 элементов) — exact=true ищет ровно [...] из 1 элемента.
Правильно: просто navigate('/catalog/products'). React Query refetchOnMount
сам обновит list при заходе на ProductsPage (staleTime=0 default).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Найдено через UI-deep: после Удалить ProductEditPage делал
qc.invalidateQueries({queryKey:['/api/catalog/products']}) до navigate'a.
React Query refetch'ил конкретно ['/api/catalog/products', id] (тот что
живёт на этой же странице) → 404 → axios interceptor показывал toast
«Не найдено» поверх редиректа на список.
Фикс: сначала navigate('/catalog/products'), потом
qc.removeQueries для item-кеша + invalidate список с exact=true чтобы не
матчить вложенный item-key.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Найдено через UI-deep тестирование (Playwright):
Баг 1: race-condition. Если юзер быстро кликает Сохранить до того как
прогрузился справочник currencies, цена-MoneyInput добавляет строку с
currencyId='' (фолбэк `?? ''`). Сервер возвращает 400 с криптичным
JSON-validation: "$.prices[0].currencyId" не парсится.
Фикс: MoneyInput для цен disabled пока !currencies.data; вместо фолбэка
'' возвращаемся из onChange (no-op). canSave дополнительно проверяет
row.currencyId — двойная страховка.
Баг 2: при ошибке сохранения page показывал "Request failed with status
code 400" — generic axios message. Toast при этом показывал
человеко-читаемый текст через humanizeError (api interceptor).
Фикс: exporting humanizeError из @/lib/api, ProductEditPage onError
использует тот же helper. Теперь form-level error == toast-сообщение.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Найдено через UI-deep тестирование: Modal не имел ARIA-роли, screen
reader не определял его как диалог. Также добавил aria-label='Закрыть'
на X-кнопку.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
'?' на US-раскладке вводится через Shift+/, поэтому при нажатии e.shiftKey=true.
Старая логика требовала wantShift === e.shiftKey и блокировала '?' (wantShift=false).
Теперь для одиночных клавиш (без '+' в spec) сравниваем только e.key — это правильно
и для '/', и для '?', и для 'n', и не ломает 'mod+s'.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Item 7 Sprint 7 — финальный пункт.
Хук: src/lib/useShortcuts.ts — поддерживает 'mod+s' (Ctrl/Cmd), Escape,
одиночные клавиши ('/', 'n', '?'). Бэр-клавиши скипают input/textarea/
contenteditable чтобы не ломать ввод. preventDefault() автоматически,
второй параметр enabled=true для конфликтов с диалогами.
Edit-страницы (9: Product + 8 doc-edit):
- mod+s = save (через canSave/canSubmit и save.isPending)
- Escape = navigate(<list-path>)
- enabled = !dialogProps.open — чтобы Esc не пересекался с ConfirmDialog
(иначе Esc бы и закрыл диалог, и навигировал на список).
List-страницы (10: Products + 9 doc-list):
- '/' = searchRef.current?.focus()
- 'n' = navigate('/<entity>/new')
(на CounterpartiesPage — открыть create-modal, т.к. там нет роута)
- SearchBar переведён на forwardRef для ref-проброса в input.
«?»-Overlay: src/components/ShortcutsOverlay.tsx — глобальный модал со
шпаргалкой. Открывается '?', закрывается Esc или кликом снаружи.
Смонтирован в AppLayout один раз.
tsc clean. На стейдже задеплоено.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Item 4 Sprint 7 — shimmer-плейсхолдеры вместо текстовых лоадеров.
Компоненты (src/components/Skeleton.tsx):
- <Skeleton variant='line'|'block'|'circle' /> — базовый pulse-блок.
- <TableSkeleton rows cols /> — 8 строк × N колонок с псевдослучайной
шириной плейсхолдеров, чтобы превью таблицы выглядело естественно.
- <FormSkeleton /> — заголовок + 2 секции по 6 полей.
DataTable: при isLoading=true теперь рендерит TableSkeleton (а не
«Загрузка…»). На list-страницах layout остаётся стабильным.
Edit-pages: добавил guard
if (!isNew && existing.isLoading) return <FormSkeleton />
на 9 doc-edit pages (ProductEdit, DemandEdit, EnterEdit, InventoryEdit,
LossEdit, SupplierReturnEdit, TransferEdit, SupplyEdit, RetailSaleEdit) +
OrganizationSettingsPage. До этого они показывали пустые поля formы или
«Загрузка…».
DashboardPage: график выручки во время загрузки теперь Skeleton block
72rem высоты (вместо текста «Загрузка…»).
tsc clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Item 3 Sprint 7 — заменил молчаливый rej в src/lib/api.ts на toast.error
для всех 4xx/5xx (кроме 401, где идёт auto-refresh). Тосты успеха —
для мутаций через meta.successMessage, чтобы избежать спама на queries.
Компоненты:
- src/lib/toast.ts — мин singleton API (toast.success/error/info), без deps.
Дедуп подряд идущих одинаковых сообщений. Autoclose через setTimeout.
- src/components/Toaster.tsx — фиксированный top-right контейнер. На мобиле
растягивается до экрана с margin. Кнопка X для ручного закрытия.
- src/lib/api.ts — interceptor 4xx/5xx: humanizeError() читает
ProblemDetails (errors.X[0] → detail → message → title); title по статусу
(«Нет доступа» / «Не найдено» / «Конфликт» / «Проверьте поля» / «Слишком
много запросов» / «Ошибка сервера»). Опт-аут через config.__silent=true.
Глобальный mutation onSuccess (App.tsx) подтягивает meta.successMessage и
показывает toast. meta.successMessage=false → опт-аут.
Применено (через meta):
- useCatalogMutations: create=«Создано», update=«Сохранено», remove=«Удалено»
(автоматически для всех list-pages: Counterparties, Stores, Countries,
PriceTypes, ProductGroups, RetailPoints, EmployeeRoles, ...)
- Doc-edit pages (Demand/Enter/Inventory/Loss/SupplierReturn/Transfer/Supply/
RetailSale): save=«Сохранено», post=«Проведено», unpost=«Снято с
проведения», remove=«Удалено».
- ProductEditPage: save=«Сохранено», remove=«Удалено».
- OrganizationSettingsPage save: «Настройки сохранены».
tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Item 1 Sprint 7 — кнопка «Заполнить демо-данными» в OrganizationSettingsPage.
Что заполняет (за одну транзакцию, ~3с на стейдже):
- 5 групп товаров (Молочные / Хлеб / Напитки / Бакалея / Снеки)
- 50 товаров с барштрихкодами EAN-13 + retail-ценой (article DEMO-NN-MM)
- 10 контрагентов (5 поставщиков + 5 покупателей-юрлиц с BIN)
- Второй склад «Резерв» (если нет) для transfer'a
- 5 приёмок (Posted) за последние 30 дней с moving-average cost
- 30 розничных продаж (Posted) за последний месяц, Cash/Card случайно
- 1 опт-отгрузка (Demand, Posted) с 15% скидкой
- 1 списание (Loss, Posted, причина Expired)
- 1 перемещение (Transfer, Posted) между складами
- 1 инвентаризация (Posted) с небольшим diff +/- 1
Идемпотентность: маркер — наличие Product с Article startsWith "DEMO-".
Повторный POST → возвращает summary без вставок.
API:
- GET /api/admin/seed-demo/status — счётчики (Admin policy)
- POST /api/admin/seed-demo — запустить (Admin policy)
UI: OrganizationSettingsPage.tsx, секция «Демо-данные» с Sparkles-иконкой,
counts grid и кнопкой (disabled когда уже заполнено).
Тесты: tests/e2e/scenarios/stage-demo-seed (5/5 ✓ локально).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
В Development swagger.json валился двумя ошибками:
1. CustomOperationIds dereferencing api.ActionDescriptor.RouteValues['action']
для минимальных API (/health, /metrics, /connect/*) кидало
KeyNotFoundException. Делаем TryGetValue + fallback на RelativePath.
2. CustomSchemaIds с FullName! падал NRE на типах без FullName
(generic-параметры). Fallback на t.Name через ??.
После фикса: /swagger/v1/swagger.json 200, 117 paths, все 19 новых
модулей (Enter/Loss/Transfer/Inventory/SupplierReturn/Demand/Reports/
AuditLog/2FA/POS/Signup) присутствуют, schemaId без дубликатов.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CRUD продукта генерирует записи create/update/delete с diff'ом
полей; фильтры по entityType/entityId/action работают; multi-tenant
строго (org B не видит логи org A).
Bonus fix: тот же DateTime Kind=Unspecified→UTC что в reports,
применён к from/to в /api/admin/audit-log.
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>
Тот же баг что в TD-6 чинили на Supplies/Demands/RetailSales и в pt 2
на Products: добавление/замена line'ов через nav-collection даёт
DbUpdateConcurrencyException «0 rows affected» при следующем UPDATE
родителя. На документах без xmin это становится 500, на InventoryDoc
(с xmin от TD-6) — 409.
Переводим Enters/Losses/Transfers/SupplierReturns.Update на
ExecuteDelete + DbSet.Add (как Supplies). InventoriesController
дополнительно: добавление новых строк через _db.InventoryLines.Add
вместо doc.Lines.Add (RemoveRange/Clear там не было — merge-in-place
по ProductId).
Воспроизведение (на Enters):
1. POST /api/inventory/enters {lines:[A]}
2. PUT … {lines:[A,B]} (одна оставлена, одна новая) → было 500
DbUpdateConcurrencyException ; стало 204.
stage-enter (10 шагов): CRUD + Post + Unpost + edge + multi-tenant +
concurrent PUT — все зелёные.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1. Products.Update: добавление нового barcode'а к существующему товару
валилось с DbUpdateConcurrencyException 'Товар изменён в другом окне',
хотя никакой конкурентной правки не было. Тот же EF8-баг, который в
TD-6 чинили на Supplies/Demands/RetailSales: nav-collection.Add +
client-side Id путает EF, UPDATE родителя получает 0 affected. Чиним
тем же паттерном: ExecuteDelete старых ProductBarcodes/ProductPrices,
DbSet.Add новых. Воспроизводится: создать товар с 1 barcode, PUT с
2 barcodes → 409. После фикса → 204.
2. IX_products_OrganizationId_Article был обычным (не уникальным), хотя
контроллер ловил нарушение по имени индекса и возвращал 'Артикул уже
занят'. Catch-блок никогда не срабатывал. Делаем индекс уникальным
миграцией Phase8d. Перед созданием — нумеруем дубликаты по существующим
данным (если есть). NULL/пустые article остаются distinct (Postgres
NULL semantics).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ASP.NET Identity AuthenticatorTokenProvider (RFC 6238 — Google
Authenticator, Authy, 1Password OTP). TwoFactorEnabled и SecurityStamp
уже были в users-таблице из Identity-схемы.
Endpoints (Bearer-auth):
- GET /api/me/2fa/status — { enabled: bool }.
- POST /api/me/2fa/enroll — генерирует SecretKey (если ещё нет),
возвращает otpauth-URI для QR + сам shared-key. Пока 2FA включён,
enroll возвращает alreadyEnabled=true без секрета.
- POST /api/me/2fa/verify { code } — валидирует и включает 2FA.
- POST /api/me/2fa/disable { code } — выключает + ResetAuthenticatorKeyAsync.
Требует текущий code как защиту от случайного отключения.
AuthorizationController.Exchange (password grant): после успеха проверки
пароля смотрит TwoFactorEnabledAsync; если true и нет otp_code в
запросе — возвращает invalid_grant с error_description="2fa_required";
если otp_code невалиден — "2fa_invalid"; иначе токен выдаётся.
Опционально для всех ролей — User самостоятельно решает включать или нет.
Для админов рекомендуется (отдельная политика — следующий шаг).
Тесты: 4 интеграционных (enroll+verify+status, неверный code → 400,
token-endpoint require otp_code, disable с code). Тесты сами генерируют
TOTP через ручную RFC 6238 имплементацию (HMAC-SHA1, 30-сек step).
Bonus: добавлены DI-заглушки UnusedSupplyWriter / UnusedRetailSalePoster
для CQRS-handler'ов из TD-1 — handler'ы пока не подключены к
контроллерам, заглушки нужны чтобы DI-validation на старте не падала.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Подключён MediatR в food-market.api с авторегистрацией из сборки
food-market.application. Цель — показать паттерн, не полный
рефакторинг контроллеров (это отдельный спринт).
Образцы handler'ов в food-market.application:
- Purchases/Commands/CreateSupplyCommand + CreateSupplyHandler — создание
Draft-приёмки с делегированием персистентности через ISupplyWriter
абстракцию (testable без EF).
- Sales/Commands/PostRetailSaleCommand + PostRetailSaleHandler —
проведение чека с валидацией платежа (переиспользует RetailPaymentValidator
из Sprint 1) и делегированием stock-операции через IRetailSalePoster.
- Sales/Queries/GetSalesReportQuery + GetSalesReportHandler — агрегация
плоских sale-строк по period:day/period:month/product. Pure-функция,
безопасно тестируется в памяти.
Контроллеры пока используют прежние flow (контроллер → EF напрямую) —
поэтапная миграция, не big-bang. Эти handler'ы — образец, на который
оглядываемся при следующих feature'ах.
Тесты: 6 unit (2 GetSalesReportHandler, 1 CreateSupplyHandler,
3 PostRetailSaleHandler). 57 unit total зелёных.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
LogEnrichmentMiddleware: после Authentication+Authorization вытягивает из
ClaimsPrincipal OrgId (claim org_id) и UserId (sub/NameIdentifier), плюс
CorrelationId из заголовка X-Correlation-ID (или генерирует Guid). Все три
кладутся в Serilog LogContext через PushProperty — каждая ILogger.Log*
внутри пайплайна автоматически получает эти поля как структурные
properties (не текст), пригодные для фильтрации в Loki/ELK без regex.
Эхо CorrelationId в response-header — клиент видит id для support.
Business-логи (структурные плейсхолдеры, не string interpolation):
- Supply.Post → "Supply posted: {SupplyNumber} supplier={SupplierId}
store={StoreId} lines={LinesCount} total={Total}".
- RetailSale.Post → "RetailSale posted: {SaleNumber} store={StoreId}
payment={Payment} lines={LinesCount} total={Total}".
docs/logging.md — паттерн, anti-pattern'ы (string interpolation, PII в
логах, токены/пароли), correlation-id workflow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Подключён FluentValidation (уже был в Directory.Packages.props, теперь
активно используется):
- AddValidatorsFromAssemblyContaining<Program>() — авторегистрация всех
IValidator<T> из сборки food-market.api.
- ValidationFilter (IAsyncActionFilter) глобально подключён через
MvcOptions: на каждый action ищет IValidator<TArg> по рантайм-типу
body-параметра, гоняет, fail → 400 ValidationProblemDetails (RFC 7807).
Не используем FluentValidation.AspNetCore — официально deprecated
(см. docs.fluentvalidation.net/aspnet); current recommendation —
DI-extensions + manual filter, как у нас.
Валидаторы (для 5 DTO):
- SupplyInputValidator — Supplier/Store/Currency ≠ Empty, Date ≤ tomorrow,
Lines non-empty, line.Quantity > 0, line.UnitPrice ≥ 0.
- RetailSaleInputValidator — Store/Currency ≠ Empty, Date ≤ tomorrow,
PaidCash/PaidCard ≥ 0, Lines non-empty с per-line проверками.
- ProductInputValidator — Name required, Vat∈[0,100], MinStock ≤ MaxStock.
- CounterpartyInputValidator — Name required, BIN/ИИН regex \d{12},
Email формат (EmailAddress).
- EmployeeInputValidator — LastName/FirstName required, RoleId ≠ Empty,
SendInvite → требует CreateAccount + Email, CreateAccount → требует Email.
Сообщения по-русски (фронт ждёт RU).
Тесты: 16 юнит-тестов на валидаторы (5 на SupplyInput, 2 на RetailSaleInput,
4 на ProductInput, 2 на CounterpartyInput, 3 на EmployeeInput). Полный
прогон unit-тестов зелёный.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Раньше прогресс фоновых импортов жил в 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>
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>
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>
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>
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>
Доступные DTO для оффлайн-кассы (food-market.pos):
• ProductSyncDto/PriceSyncDto/StockSyncDto/CounterpartySyncDto — выгрузка
изменений для последующей пробивки;
• PosSyncResponse — конверт всего sync-ответа с ServerTime (reference
time против клок-дрейфа кассы) и DeletedProductIds;
• PosSaleDto/PosSaleLineDto/PosSaleBatchDto — батч продаж от кассы.
PosSaleBatchDto несёт IdempotencyKey + каждая продажа имеет ClientSaleId
(двойная идемпотентность);
• PosSaleBatchResponse — Accepted/Failed + ReplayedFromCache флаг.
Версионирование на уровне namespace — для v2 будет рядом без
breaking changes. Required-поля везде where applicable: компилятор обяжет
заполнить новые обязательные поля при появлении v1.X добавок.
Тесты: 3 unit на сериализационный round-trip (компиляция падёт при удалении
любого поля контракта — это и есть тест public API).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>