Таблица токенов устройств (device_tokens, Phase40) + POST/DELETE /api/me/device-tokens
для регистрации iOS/Android/Web push-токенов. Контракт IPushSender.SendAsync; в DI
включён StubPushSender (пишет в лог) — реальный APNs/FCM/VAPID подменит заглушку, когда
появятся ключи, без правок вызывающего кода. Описано в docs/mobile-api.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Решение владельца 08.10: продавать/списывать в минус РАЗРЕШЕНО, но с постоянным
предупреждением и требованием провести инвентаризацию.
- Снята блокировка «недостаточно остатка» (409 oversell): розница, касса (POS-синк),
опт-отгрузка, списание, перемещение, возврат поставщику, отмена приёмки. Деньги
(минус счёта) — по-прежнему запрещены (другое правило).
- NegativeStockAlert (сущность + миграция Phase39): создаётся/обновляется при уходе
остатка < 0 (единая точка — StockService), авто-закрытие при возврате ≥ 0. Частичный
уникальный индекс: один открытый алерт на (org, store, product). Причина: тип/номер
документа/автор/время.
- API /api/negative-stock: список (с подсказкой «возможно, не проведена приёмка П-…»),
/count (для баннеров), /recount (черновик инвентаризации по минусам склада). Отчёт
«Остатки» — фильтр negativeOnly.
- Веб: постоянный баннер «N товаров в минусе — нужна инвентаризация» (сворачивается,
крестиком не закрыть) + «Пересчитать эти товары»; в остатках — красная пометка и
фильтр «Только минусы».
- Тесты: integration 2/2 (минус разрешён → алерт → recount → авто-закрытие; фильтр);
full-chain шаг 9/13 переписаны (oversell/отмена приёмки → проведено + предупреждение).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Замечание владельца: инвентаризация стояла «Проведено» без оприходования/списания.
Переделано по МойСкладу (docs/inventory-moysklad-reference.md):
- InventoryStatus Draft→Completed (было Posted; значение 1 то же). Инвентаризация
остатки НЕ двигает: endpoint complete замораживает строки без движений.
- create-enter (излишки diff>0) / create-loss (недостача diff<0) — создают черновики
Оприходования/Списания со строками разницы + ссылка InventoryDocId. Повторно нельзя (409).
- Кнопки «Применить результаты» НЕТ (поправка Mac-Claude — не одобрено владельцем).
- unpost («вернуть в черновик») отменяет проведение и удаляет связанные документы.
- post/unpost — временная совместимость для мобильного «FM Склад» (post = завершить +
создать и провести оба документа); помечено в docs/mobile-api.md.
- Enter/Loss: поле InventoryDocId (+ миграция Phase38) + в DTO «на основании инвентаризации №…».
- InventoryResult (application) — чистая разбивка diff→излишки/недостача (unit-тесты:
разница/сумма/только посчитанные строки/округление).
- InventoryMigrationController (dry-run/apply): старые Posted+InventoryAdjustment →
связанные проведённые документы переметкой движений (не задваивает), с проверкой
неизменности остатков. Скрипт deploy/migrate-inventory-to-docs.sh (backup + запуск).
- Демо-сидеры: инвентаризации теперь Completed без прямых движений.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Избранное: Product.IsFavorite/FavoriteOrder (миграция Phase37), в POS-синке
(ProductSyncDto) и в карточке товара (ProductInput). Касса: PosStore хранит
флаг (+ALTER для старых БД) и GetFavorites(); на экране продажи — сворачиваемая
сетка плиток «быстрых товаров» (товары без штрихкода: хлеб, овощи на вес),
тап добавляет в чек. Unit-тест GetFavorites (порядок/фильтр). 44 POS unit зелёные.
- Тёмная тема — опция: кнопка-переключатель в шапке (ToggleTheme), выбор
сохраняется на устройстве (kv device.theme), применяется при старте. На тех же
токенах (ThemeDictionaries Light/Dark). По умолчанию — светлая. Кадр «тёмная тема» в галерее.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Сервер: Employee.PinHash (PBKDF2, миграция Phase36); POST
/api/organization/employees/{id}/pin — админ задаёт/снимает PIN (4–6 цифр,
уникален среди активных сотрудников на пересекающихся точках). Кастомный
OpenIddict-грант grant_type=pin (retail_point_id + pin): находит кассира,
привязанного к точке, по PIN и выдаёт токены. Брутфорс-защита — in-memory по
точке (5 неверных → блок 5 мин). Клиент-сидер досоздаёт gt:pin существующим
клиентам (без пересоздания). Пароль остаётся запасным.
- Касса: PIN-пад на зарегистрированном устройстве (маскированный ввод, 0–9/⌫/
Войти), переключение на вход по логину; AuthService.PinLoginAsync.
- Тесты: unit PinHasher (9), integration (3): вход по PIN выдаёт рабочий токен;
5 неверных → блок; дубль PIN на точке → 409. + скриншот PIN-пада в галерее.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- RolePermissions.Effective(role, isSuperAdmin, isAdmin, advancedMode) — флаги с
учётом роли и простого/расширенного режима; AdvancedOnly — единый список
advanced-only прав (зеркалит [RequiresAdvancedMode]).
- /api/me теперь отдаёт permissions (имя→bool). Мобильное и веб скрывают одно и то же.
- Веб: хук useCan() читает me.permissions (раньше гейтил только по именам ролей).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- POST /api/catalog/products/{id}/barcodes {code, kind}: добавить штрихкод к
товару, уникальность в организации, 409 «штрихкод уже у товара X» (+название),
идемпотентно при повторе тому же товару, автоопределение типа по длине.
- Весовые этикетки (внутримагазинные EAN-13, как в МойСкладе): общий парсер
food-market.shared/Barcodes/WeightedBarcode (настраиваемые префиксы веса/цены,
длина PLU); касса pos.core делегирует туда. Настройки организации
(WeightBarcode*: enabled/префиксы/длина кода) + миграция Phase35.
- GET /api/catalog/products/by-barcode/{value} разбирает весовую этикетку →
товар + количество (вес) / встроенная цена; обычный ШК — как прежде.
- GET/PUT /api/organization/settings отдаёт/принимает настройки (блок весовых
применяется только если передан — round-trip-safe для текущей веб-формы).
- Офлайн-каталог ProductSyncDto: явный isWeighed (packaging==2) для мобильного/кассы.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Раньше продажи/возвраты/внесения/выплаты/расхождение смены не влияли на остаток
кассы (касса уходила в −, эквайринг оставался 0). Теперь при закрытии смены
MoneyService.MaterializeShiftMovementsAsync создаёт Posted-движения money_transactions
по типам (Category): продажи нал/карта, возвраты нал/карта, внесение, выплата,
излишек/недостача — привязанные к смене, идемпотентно. Остаток кассы/эквайринга и
отчёт «Деньги» теперь верны автоматически (всё читает money_transactions).
Отрицательный остаток: расход (CashOut/PaymentOut/TransferOut) больше остатка счёта
и выплата из смены больше наличных смены → 409 insufficient-funds; настройка
Organization.AllowNegativeBalance (по умолч. выкл., меняет только Admin).
Миграция Phase34 (idempotent): money_transactions.Category/.CashShiftId,
organizations.AllowNegativeBalance.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Основное:
* 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>
Для полной заливки каталога МойСклада в «Демо-магазин» на стейдже
переделал импорт по требованию «идемпотентный + без дублей».
Схема:
- 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>
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>
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>
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>
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>
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>
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>
Расширение 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>
Domain InventoryDoc+InventoryLine (productId, bookQty, actualQty, diff).
EF, миграция Phase6d_Inventories. Контроллер api/inventory/inventories:
Create без строк автоматически подгружает все товары склада с текущим
Stock в bookQty (actual=0); Update пишет actualQty по строкам, пересчитывая
diff. Post создаёт корректирующие движения InventoryAdjustment на diff
(положительный — приход излишка, отрицательный — списание недостачи).
Unpost атомарно откатывает; проверка «излишек уже расходован» → 409.
Web: /inventory/inventories (list с разделением излишек/недостача) +
edit с импортом CSV (productId|article;actualQty). Сайдбар «Инвентаризации».
Тесты: 3 интеграционных (create-подгрузка bookQty + apply diff;
post 400 если diff=0; tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Domain Transfer+TransferLine (FromStoreId → ToStoreId, обязательны и различны).
EF, миграция Phase6c_Transfers. Контроллер api/inventory/transfers: CRUD +
Post/Unpost. Post создаёт ПАРУ движений TransferOut(-) + TransferIn(+) в
одной Serializable-транзакции; Unpost — обратная пара тоже атомарно.
Защита от ухода в минус: post (на FromStore), unpost (на ToStore — товар
мог быть уже расходован).
Web: /inventory/transfers (list+edit) с двумя селекторами складов и
визуализацией «From → To». Пункт «Перемещения» в сайдбаре. Permission
TransferEdit добавлен в RolePermissions.
Тесты: 4 интеграционных (post создаёт пару движений, unpost оставляет
ровно 4 движения и обнуляет stock-диффы; same-store → 400; short-stock
на FromStore → 409 без побочных эффектов; tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Domain Loss+LossLine + enum LossReason (Defect/Expired/Damage/Shortage/Other).
EF, миграция Phase6b_Losses. Контроллер api/inventory/losses: CRUD +
Post/Unpost. Post создаёт StockMovement тип WriteOff с -Quantity; защита
от ухода в минус (409 со списком конфликтов). Unpost возвращает товар.
Web: /inventory/losses (list+edit) с фильтром по причине и колонкой
текущего остатка в строке. Сайдбар: «Списания» (Admin/Storekeeper).
Тесты: 3 интеграционных (post→stock падает, unpost восстанавливает;
списание сверх остатка → 409; tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Domain Enter+EnterLine (мирорит Supply, но без SupplierId и без cost rollup).
EF-конфигурация, миграция Phase6a_Enters (idempotent CREATE TABLE).
Контроллер api/inventory/enters: CRUD + Post/Unpost. Post создаёт
StockMovement тип Enter; Unpost блокируется, если остаток ушёл бы в минус.
Web: /inventory/enters (list + edit), пункт «Оприходования» в сайдбаре
Admin/Storekeeper.
Тесты: 4 интеграционных (post раздаёт stock, unpost откатывает, double
post→409, tenant-изоляция A/B, unpost блокируется при минусе после продажи).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Гэп из 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: при создании нового товара
автоматически выбирают «Все товары» (или единственную группу), чтобы
пользователь мог сохранить продукт без лишнего клика.
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 не показывался — без
изменений.
Было: каждая орга держала свои 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 со списком
организаций при попытке деактивировать используемую единицу
Платформенные настройки: один row, не tenant-scoped, видны и меняются
только Супер-администратором. Хранят SMTP-креды для исходящей почты
(forgot-password, нотификации). IMAP к этому отношения не имеет — IMAP
для чтения входящей, нам нужен SMTP.
Поля:
- SmtpHost, SmtpPort
- SmtpUseSsl (implicit TLS / 465) и SmtpStartTls (587, по дефолту true)
- SmtpUsername, SmtpPasswordEncrypted (хранится зашифрованным через
DataProtection API; в API-ответах не выходит, только has-password флаг)
- FromEmail, FromName
Миграция Phase5b_PlatformSettings создаёт таблицу public.platform_settings.
Конфиг EF в AppDbContext: HasMaxLength для строк.
Полное физическое удаление сотрудника невозможно — у него 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 — отображение «Иванов И.И. (удалён)» работает автоматически.
ИНН — российское поле. В Казахстане физлица используют ИИН (12 цифр),
юрлица — БИН (12 цифр). Колонок «inn» в БД у нас нет (есть Bin для
юрлица + TaxNumber для ИИН физлица), миграция drop column не нужна —
только лейблы и комментарии.
Поправлено:
- EmployeesPage: «ИИН/ИНН» → «ИИН» (12 цифр, inputMode=numeric).
- CounterpartiesPage: убрано поле «ИНН / другой» — оставлены БИН и ИИН
с правильными ограничениями (12 цифр, numeric).
- SuperAdminOrgCreatePage / SuperAdminSetupPage: «БИН/ИНН» → «БИН».
- MoySkladImportPage: «(ИНН ...)» в ответе test → «(идентификатор ...)».
- Domain comments в Employee.cs / Counterparty.cs обновлены.
Внутренний MoySklad-DTO `Inn` оставлен — это входящий JSON из российского
API МойСклад, поле там действительно так называется. Маппится в Bin
при импорте контрагента (как и было).
Раньше «удалить орг навсегда» было захардкожено на 30 дней архива.
Теперь — глобальная системная настройка SuperAdmin'а.
Domain/DB:
- SystemSettings : Entity (single-row table system_settings).
Поле ArchiveRetentionDays (int, default 30). Структура расширяется
именованными полями по мере необходимости — без key-value generic'а.
- Migration Phase4e_SystemSettings создаёт таблицу с default 30.
- DevDataSeeder: при первом старте создаёт single-row дефолт.
API:
- GET /api/super-admin/settings — текущие настройки.
- PUT /api/super-admin/settings — обновить с валидацией [0..3650].
Audit-log запись ActionType=EditSystemSettings с before/after.
- SuperAdminOrganizationsController.Delete: хардкод 30 заменён
чтением SystemSettings.ArchiveRetentionDays. При retention=0 —
удаление доступно сразу после архивации.
UI:
- /super-admin/settings — страница «Системные настройки».
Select из 6 опций (0/1/3/7/14/30), warning-баннер при выборе
«Немедленно». Кнопка «Сохранить» disabled пока нет изменений.
- В SuperAdminLayout убрана пометка «скоро» с пункта «Системные
настройки» — раздел активен.
- SuperAdminOrganizationsPage: кнопка «Удалить навсегда» теперь
читает retentionDays из API; tooltip показывает оставшиеся дни
«Доступно через X дн. (retention N)»; при retention=0 — всегда
active для архивных орг.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Концепция: ProductGroup и UnitOfMeasure становятся двухуровневыми
справочниками. Системные эталонные записи (OrganizationId=NULL,
управляются SuperAdmin'ом) видны всем tenant'ам как «Эталон»
и read-only. Tenant'овские (OrganizationId=<orgId>) — обычная изоляция,
полный CRUD у админа орги.
Архитектура:
- IOptionalTenantEntity { Guid? OrganizationId } — новый интерфейс
в Domain/Common. ProductGroup и UnitOfMeasure отнаследованы от
Entity и реализуют его.
- AppDbContext.ApplyOptionalTenantFilter<T>: query-filter для
IOptionalTenantEntity пропускает запись с OrganizationId=NULL для
всех tenant'ов + tenant'овские по выбранной orgId. SuperAdmin без
override видит всё, в override — только NULL+своё.
- StampTenant: при Add для IOptionalTenantEntity — null оставляется
если SuperAdmin без override (системная), иначе подставляется
tenant.OrganizationId.
- Миграция Phase4d_OptionalTenantOnDirectories: ALTER COLUMN
OrganizationId DROP NOT NULL на product_groups и units_of_measure.
Existing данные FOOD MARKET (11 групп, 5 единиц) сохраняются как
tenant'овские — additive change, ничего не теряется.
- DTO: UnitOfMeasureDto и ProductGroupDto получили nullable
OrganizationId; фронт читает его для показа badge «Эталон».
- Защита мутаций: PUT/DELETE контроллеры теперь возвращают Forbid()
если запись OrganizationId=null и юзер не SuperAdmin (только
суперадмин может править/удалять системные).
Frontend:
- Badge «Эталон» (indigo) рядом с именем системной записи в обеих
страницах.
- Клик по строке системной записи → alert «Изменения недоступны…».
- SuperAdmin sidebar: новые пункты «Группы (эталон)» (FolderTree)
и «Ед. измерения (эталон)» (Ruler) под «Справочники». Страницы
реиспользуют существующие компоненты — для SuperAdmin без override
фильтр возвращает все записи, что в Phase 4+ можно ужесточить
отдельным эндпоинтом «только системные» (?orgId=null).
Decision (нонстоп-выбор по ТЗ): nullable OrganizationId через
IOptionalTenantEntity, не sentinel Guid.Empty — чище, безопаснее,
ясная семантика. Существующие группы FOOD MARKET НЕ мигрированы в
системные (как просил юзер) — пусть SuperAdmin сам создаст эталоны.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Базовый domain-каркас для SuperAdmin console (Phase 1):
Organization:
- IsArchived bool + ArchivedAt DateTime? — архивная орга не видна
юзерам, но данные сохраняются. Удалить навсегда можно только из
архива >30 дней (логика в API на следующем коммите).
- AccountOwnerUserId Guid? — главный владелец, не путать с админами
per-org. SuperAdmin может сменить через action c reason в audit-log.
- HasIndex(IsArchived) для быстрой фильтрации.
SuperAdminAuditLog (новая таблица super_admin_audit_log):
- Не tenant-scoped — лог общий по всей системе.
- ActionType (CreateOrg/EditOrg/ArchiveOrg/RestoreOrg/DeleteOrg/
ChangeOwner/EditEntity), OrganizationId, EntityType+EntityId,
Description, Reason, ChangesJson (jsonb), IpAddress.
- Индексы: CreatedAt, (SuperAdminUserId, CreatedAt),
(OrganizationId, CreatedAt) — типовые запросы фильтра.
Migration Phase4_SuperAdminConsole добавляет 3 колонки в organizations
+ создаёт super_admin_audit_log с тремя композитными индексами.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
RolePermissions расширен с 21 до 32 флагов: добавлены UnitsManage,
DemandsView/Edit/Post (отгрузка контрагенту, не путать с RetailSales),
CounterpartiesDelete, InventoryEdit, LossEdit, EnterEdit (склад-операции),
ReportsFinanceView, ReportsStockView (тонкие отчётные права),
CashRegistersManage и IntegrationsManage (отдельно от OrgSettingsManage).
UI EmployeeRolesPage:
- 7 групп вместо 6: Каталог / Закупки / Продажи / Контрагенты /
Склад-Остатки / Отчёты / Настройки. Все секции аккордеоном внутри
модалки (как было — flex-col), но с правильным грануляр-списком.
- Системные роли — чекбоксы disabled (только просмотр; имя/описание
редактируются).
- При [+ Добавить роль] — сначала открывается модалка выбора шаблона:
Пустой / Копия Администратора / Копия любой существующей. Дальше
открывается основная модалка с предзаполненной матрицей.
allPerms() помощник на фронте — зеркало RolePermissions.All() с бэка,
для шаблона «Копия Администратора» в clone-flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Базовый каркас модуля «Сотрудники и Роли» (по образу сторонняя система):
Domain:
- Employee — сотрудник организации (UserId nullable: запись может
существовать без логина), ФИО + Position + Email/Phone + Role + IsActive
+ FiredAt + RetailPointAssignments.
- EmployeeRole — роль с IsSystem флагом и owned RolePermissions.
- RolePermissions — 21 булев флаг по группам (Каталог/Закупки/Продажи/
Контрагенты/Отчёты/Настройки) + helper All() для админа.
- EmployeeRetailPointAssignment — ассоциация сотрудника с RetailPoint
(для роли Кассир — к каким кассам привязан).
Infrastructure:
- OrganizationsHrConfigurations с OwnsOne(...).ToJson("permissions")
для permissions — JSONB-колонка вместо отдельной таблицы.
- DbSet<EmployeeRole/Employee/EmployeeRetailPointAssignment>.
- Уникальные индексы: (OrgId, RoleName), (OrgId, UserId) с filter
WHERE UserId IS NOT NULL, (EmployeeId, RetailPointId).
Migration Phase4_EmployeesAndRoles создаёт три таблицы. Сидер
системных ролей и привязка существующего admin'а к Employee —
следующим коммитом, контроллеры и UI — далее.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
- Удаление поля «Срок годности (дней)»:
• 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>
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>
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>
Подготовка к новой модели цен сторонняя система-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>
Новая галка в настройках магазина «Разрешить дробные цены (с копейками)»
(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>
- 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>
Добавлена Organization.ShowMinMaxStock (bool, default false) — флаг
видимости полей «Минимальный / Максимальный остаток» на карточке
товара. В UI настроек магазина появилась соответствующая галка
с подсказкой. По умолчанию выключено — большинству магазинов
эти поля не нужны.
Миграция Phase5f_ShowMinMaxStock добавляет колонку.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>