Compare commits

...

173 commits

Author SHA1 Message Date
nns dcc3f9d61c content(public): живое наполнение — скриншоты, фото, OG, баннеры
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m5s
CI / Web (React + Vite) (push) Successful in 39s
Docker Public / Build + push Public (push) Successful in 46s
Docker Public / Deploy Public on stage (push) Successful in 10s
— 6 реальных скриншотов админки в режиме tenant override (FOOD MARKET):
  dashboard, аналитика, каталог 29 540 SKU, карточка товара, контрагенты,
  форма приёмки. Снято Playwright через app.food-market.zat.kz.

— 14 stock-фото с Unsplash (Unsplash License) для Hero-секций и CTA:
  pos, import, about, 6 вертикалей, blog covers, integrations, cta-banner.
  CREDITS.md с авторами/profile-ссылками.

— 11 OG-картинок 1200x630 через satori + @resvg/resvg-js: home, pos,
  pricing, import, about, 6 вертикалей. Inter Bold/Regular, emerald
  градиент, лого + заголовок + sub + CTA-кнопка.

— Hero-секции переделаны: full-width фото с dark-overlay-градиентом и
  белая card поверх; вертикали /for-* с одинаковым layout-баннером;
  /pricing с зелёным gradient hero; финальный CTA с emerald overlay 75%.

— BaseLayout: ogImage передаётся пропом, twitter:image добавлен.
  Каждая страница указывает свой /og/<slug>.png.

— Контентная зачистка: «торговые весы» вместо конкретных брендов,
  модели весов убраны со всех материалов. Блог-пост cash-with-scales
  переписан без брендов.

Build: 30 страниц, smoke 20 URL — 200.
2026-04-26 21:08:05 +05:00
nns 5f7cfa0d5b content(public): нейтральный тон, без упоминаний сторонних систем
— Все материалы (главная, /pos, /about, FAQ, kb, blog) переведены на
  нейтральные формулировки: «другие системы учёта», без имён.
— Новая страница /import — единая точка входа по миграции каталога;
  описывает Excel/CSV, REST API и выгрузку 1С.
— Удалены публичные kb/blog-статьи, целиком построенные вокруг
  миграции с конкретного продукта.
— /migration-from-other-system убран из sitemap; nginx делает 301 на /import.
— blog schema расширена optional-полями (author, category, cover_image),
  чтобы новый frontmatter валидировался content collection.
— Грамматические правки: «Импорт из других систем».

Финальный grep по пакету по списку имён конкурирующих SaaS-учётных
продуктов — пусто. Smoke по 9 ключевым URL — 200; старый URL — 301.
2026-04-26 20:19:57 +05:00
nns f5a232a32e content(public): naполнить блог + KB + about/contacts; убрать упоминания сторонних систем
Phase 6 контентная часть — частично:

Блог (3 поста из /tmp/content/blog-and-kb.md, content collection):
- launch — запуск Food Market
- import-other-system — пошаговый гид миграции (внутренняя инструкция, frontmatter)
- cash-with-scales — почему касса с весами Масса-К из коробки

База знаний (5 статей, content collection с category + order):
- quickstart, import-other-system, pos-setup, billing, faq

Страницы /blog и /kb перерисованы — рендерят список из коллекций
с группировкой по категориям, отдельные [slug].astro template'ы
рендерят markdown с типографикой prose-md.

/about и /contacts наполнены реальным контентом из
/tmp/content/about-contacts-pricing-features.md (без заглушек).
Контакты — 4 канала (email/phone/чат/реквизиты) с placeholder-ами
для будущих контактов после регистрации юр.лица.

Очистка от упоминаний сторонних систем (по правилу: не сравнивать с
конкретными системами и не выводить чужие цены публично):
- Hero/FAQ на главной — заменено «Импорт из сторонняя система» на «импорт
  каталога одной кнопкой», убран FAQ-вопрос «Чем отличаетесь от...»,
  заменён на общий «Чем хорош Food Market?».
- Footer: пункт «Импорт из сторонняя система» → «Импорт каталога».
- /migration-from-other-system удалён (содержал сравнительную таблицу
  с ценами сторонняя система). Возможен возврат как KB-инструкция позже.
- features/pos/pricing/integrations/changelog/for-grocery/for-pharmacy
  — sed-замена «сторонняя система» → «другие системы», ссылки на
  /migration-from-other-system → /features.
- В content-collections блог/KB остались упоминания (юзер пришлёт
  PATCH-версии текстов отдельно).

Контейнер пересобран и задеплоен на стенд: 30 страниц, smoke на /,
/blog, /kb, /about, /blog/launch — все 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:53:41 +05:00
nns 4ac7dc585c feat(deploy): Phase 6 — публичный сайт на food-market.zat.kz, админка на app.
Доменная схема (по решению юзера):
  food-market.zat.kz → новый Astro public-сайт (порт 8082, контейнер food-market-public)
  app.food-market.zat.kz → существующая админка (food-market.web, порт 8081)
  API остаётся на app.* под /api/*.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:11:34 +05:00
nns fc3f63c49a feat(super-admin): настраиваемый retention period для архивных орг
Раньше «удалить орг навсегда» было захардкожено на 30 дней архива.
Теперь — глобальная системная настройка SuperAdmin'а.

Domain/DB:
- SystemSettings : Entity (single-row table system_settings).
  Поле ArchiveRetentionDays (int, default 30). Структура расширяется
  именованными полями по мере необходимости — без key-value generic'а.
- Migration Phase4e_SystemSettings создаёт таблицу с default 30.
- DevDataSeeder: при первом старте создаёт single-row дефолт.

API:
- GET /api/super-admin/settings — текущие настройки.
- PUT /api/super-admin/settings — обновить с валидацией [0..3650].
  Audit-log запись ActionType=EditSystemSettings с before/after.
- SuperAdminOrganizationsController.Delete: хардкод 30 заменён
  чтением SystemSettings.ArchiveRetentionDays. При retention=0 —
  удаление доступно сразу после архивации.

UI:
- /super-admin/settings — страница «Системные настройки».
  Select из 6 опций (0/1/3/7/14/30), warning-баннер при выборе
  «Немедленно». Кнопка «Сохранить» disabled пока нет изменений.
- В SuperAdminLayout убрана пометка «скоро» с пункта «Системные
  настройки» — раздел активен.
- SuperAdminOrganizationsPage: кнопка «Удалить навсегда» теперь
  читает retentionDays из API; tooltip показывает оставшиеся дни
  «Доступно через X дн. (retention N)»; при retention=0 — всегда
  active для архивных орг.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:59:24 +05:00
nns 2cadb6e5d9 fix(super-admin): убрать цикл редиректа, регресс override после пакета задач
КОРНЕВАЯ ПРИЧИНА: SuperAdminLayout имел useEffect

  if (getOrgOverride()) navigate('/dashboard', { replace: true })

который автоматически выкидывал в tenant-режим при любом заходе на
/super-admin/* если в localStorage остался override (например с прошлой
сессии или после нечистого выхода). Это создавало цикл: SuperAdmin
кликает «Системная консоль» в меню → попадает на /super-admin/... →
useEffect видит override → navigate /dashboard → tenant TenantRouteGuard
проверяет: override есть → пропускает, но юзер видит tenant с баннером
вместо системной консоли.

Если override содержал «зависший» orgId с прошлой сессии и юзер ожидал
вернуться в SuperAdmin консоль — он попадал в tenant-режим, а попытки
вернуться через ссылки sidebar лечились этим же useEffect снова в /
dashboard. Юзер видел тост от TenantRouteGuard в редких случаях когда
localStorage не успевал прочитаться (race с rAF).

Фикс:
- Удалён useEffect (+ unused useNavigate импорт). SuperAdmin может
  свободно перемещаться между системной консолью и tenant-режимом
  при активном override; снимает override только баннером «Выйти из
  режима» либо переключает другую орг через picker.
- TenantRouteGuard: повторная проверка getOrgOverride() через
  requestAnimationFrame (защита от любых race с localStorage), и
  разовый guard-флаг (useRef) чтобы не редиректить дважды на одной
  странице. Console.warn перед редиректом — облегчает диагностику
  если регресс повторится.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:54:37 +05:00
nns 79016579c2 fix(migration): Phase4d таблица называется units_of_measure, не units
Опечатка: в Up() миграции стояло table:\"units\" — реальное имя в БД
units_of_measure (по EF snapshot). API падал при старте на стенде:
«relation public.units does not exist», 502 на login.

На стенде SQL ALTER COLUMN накачен вручную + запись в
__EFMigrationsHistory; миграция fix только для будущих чистых баз.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:32:52 +05:00
nns 5c5b231157 feat(directories): двухуровневые справочники Группы и Ед.измерения (системные + tenant)
Концепция: ProductGroup и UnitOfMeasure становятся двухуровневыми
справочниками. Системные эталонные записи (OrganizationId=NULL,
управляются SuperAdmin'ом) видны всем tenant'ам как «Эталон»
и read-only. Tenant'овские (OrganizationId=<orgId>) — обычная изоляция,
полный CRUD у админа орги.

Архитектура:
- IOptionalTenantEntity { Guid? OrganizationId } — новый интерфейс
  в Domain/Common. ProductGroup и UnitOfMeasure отнаследованы от
  Entity и реализуют его.
- AppDbContext.ApplyOptionalTenantFilter<T>: query-filter для
  IOptionalTenantEntity пропускает запись с OrganizationId=NULL для
  всех tenant'ов + tenant'овские по выбранной orgId. SuperAdmin без
  override видит всё, в override — только NULL+своё.
- StampTenant: при Add для IOptionalTenantEntity — null оставляется
  если SuperAdmin без override (системная), иначе подставляется
  tenant.OrganizationId.
- Миграция Phase4d_OptionalTenantOnDirectories: ALTER COLUMN
  OrganizationId DROP NOT NULL на product_groups и units_of_measure.
  Existing данные FOOD MARKET (11 групп, 5 единиц) сохраняются как
  tenant'овские — additive change, ничего не теряется.
- DTO: UnitOfMeasureDto и ProductGroupDto получили nullable
  OrganizationId; фронт читает его для показа badge «Эталон».
- Защита мутаций: PUT/DELETE контроллеры теперь возвращают Forbid()
  если запись OrganizationId=null и юзер не SuperAdmin (только
  суперадмин может править/удалять системные).

Frontend:
- Badge «Эталон» (indigo) рядом с именем системной записи в обеих
  страницах.
- Клик по строке системной записи → alert «Изменения недоступны…».
- SuperAdmin sidebar: новые пункты «Группы (эталон)» (FolderTree)
  и «Ед. измерения (эталон)» (Ruler) под «Справочники». Страницы
  реиспользуют существующие компоненты — для SuperAdmin без override
  фильтр возвращает все записи, что в Phase 4+ можно ужесточить
  отдельным эндпоинтом «только системные» (?orgId=null).

Decision (нонстоп-выбор по ТЗ): nullable OrganizationId через
IOptionalTenantEntity, не sentinel Guid.Empty — чище, безопаснее,
ясная семантика. Существующие группы FOOD MARKET НЕ мигрированы в
системные (как просил юзер) — пусть SuperAdmin сам создаст эталоны.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:20:47 +05:00
nns bd2800837f feat(roles): системные роли read-only + русские имена + чистка дубликата у admin
Концепция: «Супер администратор» — платформенная Identity-роль
SuperAdmin. «Администратор» — организационная роль внутри Employee
(IsSystem=true в EmployeeRole). Они НЕ должны дублироваться у
одного юзера.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:11:14 +05:00
nns 8d72e9da2d feat(super-admin): перенести справочник Стран в системную консоль
Country — глобальный справочник (Entity, не TenantEntity), магазины-
клиенты выбирают страны из готового списка но не управляют ими.
Управление переносится в SuperAdmin консоль.

Изменения:
- API: POST/PUT /api/catalog/countries теперь Authorize(Roles=SuperAdmin)
  (раньше был SuperAdmin,Admin). DELETE и так был SuperAdmin.
- GET остаётся [Authorize] без роли — нужен tenant'у для селектов в
  формах создания орги/контрагентов/товаров.
- Tenant AppLayout: убран блок «Справочники» с пунктом «Страны».
  Иконка Globe больше не импортируется в tenant-меню.
- Tenant роут /catalog/countries удалён из App.tsx.
- В OrganizationSettingsPage ссылка «откройте справочник Страны»
  заменена на текст «обратитесь к администратору платформы».
- SuperAdminLayout: новый блок «Справочники» с пунктом «Страны»
  (/super-admin/countries). Иконка Globe.
- Роут /super-admin/countries использует существующий CountriesPage —
  компонент unchanged, страница теперь рендерится в SuperAdminLayout.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:47:13 +05:00
nns 7c161a138b feat(super-admin): рабочий quick-switch + UI-блокировка мутаций в read-only
БАГ: dropdown «Открыть организацию» в topbar системной консоли вёл
сначала на reload текущего /super-admin, а оттуда внутренний редирект
конкурировал с TenantRouteGuard'ом, и юзер видел alert «Откройте
через "Открыть как…"». Фикс — setOrgOverride получил опциональный
{ redirectTo }, делает window.location.assign(target) вместо reload.
Точки вызова обновлены:
- dropdown в SuperAdminLayout topbar → redirectTo='/dashboard'
- кнопка «Открыть как» в строке таблицы орг → redirectTo='/dashboard'
- кнопка «Выйти из режима» в баннере → redirectTo='/super-admin/organizations'

Хук useReadOnly() централизует «override активен И edit-mode не
включён» в один { readOnly, reason }. Button по умолчанию считает
variant='primary' и variant='danger' мутирующими (опт-аут через
mutating={false} для редких primary-без-мутаций) — в read-only они
автоматически disabled с tooltip «Только просмотр. Включите
редактирование в баннере…». Variant='secondary' и 'ghost' не
блокируются — Cancel/Назад/Закрыть остаются кликабельны. Серверный
403 (ReadonlyOverrideMiddleware) остаётся как safety-net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:32:29 +05:00
nns 9d8fd2cb53 ui(super-admin): hero-блок и KPI карточки на главной + три полезных секции
/super-admin переработан:
- Hero на indigo-градиенте: badge «Super Admin Console» + крупный H1
  «Системная консоль» + подзаголовок + env+build справа.
- 4 KPI карточки с цветным круглым фоном иконки (indigo/sky/
  emerald/amber), label uppercase tracking, значение крупно,
  hint мелко серым; hover-shadow.
- Три карточки в ряд (lg:3 cols) вместо дублирующих ссылок-CTA:
  «Здоровье системы» (заглушки /Activity для скорых проверок),
  «Активные организации» (топ-3 по объёму, ссылка «все»),
  «Свежие события» (6 последних audit-записей или empty state
  с Inbox-иконкой и текстом «Журнал пуст. Здесь появятся события»).
- Кнопка «Создать организацию» убрана с главной — оставлена
  только на /super-admin/organizations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:32:29 +05:00
nns 4d4a3a4786 ui(super-admin): читаемый логотип на тёмном sidebar
<Logo> получил пропс variant="dark": в нём «FOOD» рендерится белым
(slate-50) вместо чёрного, «MARKET» — emerald-400 (вместо var brand)
для контраста на indigo-950 фоне SuperAdminLayout. SuperAdminLayout
прокидывает variant="dark" в обоих местах (desktop sidebar + mobile
header). Светлый вариант остался по умолчанию для tenant AppLayout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:32:29 +05:00
nns 8ae9f68119 feat(super-admin): Phase 2b — отдельный SuperAdminLayout и разделение от tenant-админки
SuperAdmin при логине больше не попадает на tenant Dashboard
(Каталог/Товары/Контрагенты/Выручка) — он стоит над всеми органами,
у него отдельная Системная консоль с системным sidebar и системным
дашбордом. Каждый из четырёх кейсов теперь визуально различим:

1. SuperAdmin → /super-admin (индиго-сайдбар, системные разделы:
   Главная / Организации / Пользователи (скоро) / Журнал /
   Здоровье/Бэкапы/Настройки (скоро)). В topbar — компактный
   «Открыть организацию ▼» dropdown для быстрого override.
   Title-suffix « · Super Admin» в browser-tab.
2. Обычный tenant-админ → /dashboard (как раньше).
3. SuperAdmin в режиме «открыть как…» → /dashboard с tenant
   sidebar'ом + amber-баннер сверху + слабая жёлтая тонировка фона
   body чтобы периферийным зрением было ясно «не моя админка».
4. SuperAdmin без override руками вбивает /products → TenantRouteGuard
   редиректит на /super-admin/organizations + alert «Откройте
   конкретную организацию через "Открыть как…"».

Изменения:
- SuperAdminLayout.tsx: индиго-палитра, Logo + badge «Super» + подпись
  «Системная консоль», 7 пунктов меню (5 active + 4 «скоро»),
  user-карточка с «Super Admin», topbar с org-picker dropdown,
  redirect на /dashboard если активен override.
- TenantRouteGuard.tsx: в useEffect проверяет (SuperAdmin && !override)
  и редиректит. Сообщение в sessionStorage для AppLayout, alert один раз.
- App.tsx: setup wizard вне layout'а; /super-admin/* через
  SuperAdminLayout (nested routes); остальное через TenantRouteGuard +
  AppLayout.
- AppLayout: убрана 3-пунктовая группа «Супер-админ» — оставил один
  пункт «Системная консоль» как точку возврата. Тонировка фона
  amber-50/60 при override.
- LoginPage: после успешного login — если me.roles.SuperAdmin и target
  это /, /dashboard → redirect на /super-admin.
- SuperAdminDashboardPage: блок «Последние события» — 10 записей
  audit-log + ссылка на полный журнал.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:13:47 +05:00
nns 6f61bbd974 ui(roles): warning «изменения применятся ко всем сотрудникам» при edit
Edit модалка для кастомной роли (не системной, существующей) теперь
показывает amber-плашку перед матрицей прав — напоминает что галка
тут касается N людей сразу. Системные роли — без плашки (там
disabled чекбоксы).

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:03:48 +05:00
nns 8ff0a56144 feat(bridge): /quiet и /loud команды для управления PreToolUse прогресс-лентой
- /quiet → создаёт /tmp/cc-tg-quiet, pretool-hook сразу выходит,
  Stop hook продолжает работать (финальные ответы летят).
- /loud  → удаляет flag, прогресс-лента возобновляется.
- /ping без изменений.

На стенде bridge перезапущен, setWebhook 200.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:58:23 +05:00
nns 92d2eb0432 feat(infra): PreToolUse hook for Telegram progress feed + rate-limited batching
Hook /usr/local/bin/cc-tg-notify-pretool читает JSON через stdin
(tool_name + tool_input) и шлёт короткую строку прогресса в Telegram
перед каждым tool-вызовом — чтобы юзер видел «он работает» а не
просто ждал финального ответа от Stop hook.

Форматы (по требованию ТЗ):
- Bash       → 🔨 ${description} либо 🔨 Bash: ${command[:80]}
- Edit/Write/Read → ✏️/📝/📖 + basename(file_path)
- Grep/Glob  → 🔍/🌐 + pattern[:30/50]
- WebFetch/Search → 🌍/🔎 + url|query[:60]
- Task       → 🎯 + description[:60]
- TodoWrite  → skip (шумно)
- прочие     → 🔧 ${tool_name}

Дебаунс через flock + фоновый sleep:
- Каждый вызов append'ит строку в /tmp/cc-tg-pretool-buffer.txt и
  бампит /tmp/cc-tg-pretool-last (epoch ms) под flock'ом.
- Спавнит фоновый sleep 1.5s; после пробуждения проверяет,
  чей timestamp в LAST — если не его, выходит молча. Только
  «последний» hook реально шлёт батч одним сообщением.
- Это даёт пачке tool-вызовов один Telegram-апдейт, а не 5–10.
- Если буфер длиннее 20 строк — режется хвостом (свежие важнее).

Off-switch: touch /tmp/cc-tg-quiet — pretool-hook сразу выходит.
Stop hook продолжает работать.

Hook регистрируется в ~/.claude/settings.json под PreToolUse с
matcher="" (все tools без фильтра); фильтр TodoWrite — внутри
скрипта.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:06:42 +05:00
nns e3bc5cacfc feat(web): super-admin section + setup wizard + auto-redirect
Раздел /super-admin/* доступный только пользователю с ролью SuperAdmin.
В сайдбаре отдельная группа «Супер-админ» появляется только если
me.roles содержит SuperAdmin (UI-guard, серверная авторизация
параллельно проверяется через [Authorize(Roles=\"SuperAdmin\")]).

Страницы:
- /super-admin            — KPI-дашборд (orgs/users/products/supplies)
- /super-admin/organizations — таблица всех org с фильтром Активные/
  Архив/Все, поиском по Name/BIN. Действия в строке: Архивировать
  (модалка с вводом названия), Восстановить, Удалить навсегда (только
  если в архиве >30 дней + повторное подтверждение).
- /super-admin/organizations/new — двух-секционная форма создания
  (реквизиты + первый админ); в response получаем generatedPassword
  и показываем в одноразовой модалке с copy-кнопками.
- /super-admin/audit-log  — таблица журнала с экспортом в CSV.
- /super-admin/setup      — 3-шаговый wizard (welcome → org → admin →
  done с временным паролем). Авто-редирект на этот URL для SuperAdmin
  если /api/super-admin/setup-status возвращает needsSetup=true (в
  системе ноль организаций) — нельзя пропустить пока не создадут.

useMe()/useIsSuperAdmin() хуки в lib/useMe.ts — единая точка чтения
текущего юзера и его ролей для UI-гейтов.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:48:27 +05:00
nns 372ca9ec16 feat(roles): permissions matrix grouped by section + clone-from-template flow
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>
2026-04-26 12:44:53 +05:00
nns 1d9fd7297c fix(roles): keep only Admin + Cashier as system, demote others to custom + migration
После ревью UX оказалось что 6 системных ролей — перебор. Перешли на
схему «два системных + остальные шаблоны»:

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:41:13 +05:00
nns d2305b7d40 feat(infra): event-driven Telegram bridge — webhook + Stop hook
Полный отказ от 2-секундного polling tmux'а в пользу реактивной схемы:

OUTBOUND (server-Claude → Telegram) через Stop hook:
- /usr/local/bin/cc-tg-notify-stop (Bash) читает transcript из stdin
  (Claude Code передаёт {transcript_path}), достаёт последнюю
  assistant-запись с непустым text-блоком (jq), чанкует ≤4000 символов
  с префиксом «🤖 [food-market]», POST'ит в Telegram через curl.
  Логи /var/log/cc-tg-notify.log. Если turn без текстового ответа
  (только tool calls) — выходит молча.
- Зарегистрирован в ~/.claude/settings.json под Stop event с пустым
  matcher (все turns).

INBOUND (Telegram → bridge → tmux) через webhook:
- bridge.py переписан с run_polling на run_webhook listening
  127.0.0.1:8765 на /tg-webhook. python-telegram-bot[webhooks]
  (tornado) ставится через pip.
- При старте сам делает setWebhook к Telegram API с secret_token
  из TELEGRAM_WEBHOOK_SECRET (osprandom 24 hex), Telegram присылает
  его обратно в X-Telegram-Bot-Api-Secret-Token — PTB валидирует
  до вызова handler'ов.
- Сохранены: whitelist по chat_id, paste-в-tmux через
  send-keys -l + Enter, /ping команда. Удалён poll_and_forward,
  diff/clean логика, recently_sent_lines дедуп — больше не нужны.

Nginx: новый location = /tg-webhook на food-market-stage.conf,
проксирует на 127.0.0.1:8765 с прокидыванием X-Telegram-Bot-Api-
Secret-Token. Smoke-test: curl с неверным секретом → 403.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:38:44 +05:00
nns 5737c65215 fix(migrations): drop Employee.Navigation(RetailPointAssignments) to fix snapshot order
В snapshot/Designer я вручную добавил b.Navigation(\"RetailPointAssignments\")
в блоке Employee — но эта обратная навигация регистрируется через
WithMany(\"RetailPointAssignments\") у EmployeeRetailPointAssignment.HasOne(...),
который выполняется ПОЗЖЕ. Из-за этого BuildTargetModel падал с
«Navigation … was not found», и API на стенде не мог применить миграцию.

Убрал лишнюю строку в обоих местах. Свойство Employee.RetailPointAssignments
никуда не делось — обратную навигацию EF создаёт автоматически из конфига
EmployeeRetailPointAssignment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:11:13 +05:00
nns d3bcbee8b9 feat(onboarding): welcome dashboard with first-steps cards
Дефолтная страница после логина (/) — OnboardingPage по образу
сторонняя система «Первые шаги». Старый DashboardPage с KPI и графиком
переехал на /dashboard, в меню «Главная» теперь два пункта:
«Главная» (онбординг) и «Аналитика» (KPI/графики).

useOnboardingProgress() — хук, считает 4 шага:
- orgConfigured: country + defaultCurrency установлены
- hasEmployees: > 1 сотрудник (помимо админа)
- hasProducts: > 0 товаров
- hasSupplies: > 0 приёмок

OnboardingPage:
- Прогресс-бар «N из 4 шагов» с процентом
- 4 карточки задач: Настройки → Сотрудники → Каталог/Импорт → Приёмка
- Каждая показывает иконку (CheckCircle2 если done) + бэйдж
  категории + заголовок + описание + CTA-кнопка с ArrowRight,
  меняющая текст и ссылку в зависимости от done.
- Когда все 4 шага сделаны — плашка «🎉 Готово!» + переход на
  /dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:08:03 +05:00
nns f5cccb6f10 feat(web): Employees + Roles pages with permissions matrix
EmployeesPage (/settings/employees):
- Таблица: ФИО + должность, Роль, Email, Телефон, Учётка (есть/нет),
  Статус (Активен/Уволен).
- Модалка добавления: ФИО + Position + Email + Phone + Role.
  Если выбрана роль «Кассир» — появляется блок «Кассы» с чекбоксами
  привязки к RetailPoint'ам (multi-select).
- Чекбокс «Создать учётную запись» (по умолчанию ✓): сервер
  возвращает generatedPassword один раз, показываем в отдельной
  модалке с copy-кнопками логина и временного пароля.
- Update/Delete как обычно. Снять Активен → серверная установка FiredAt.

EmployeeRolesPage (/settings/employee-roles):
- Таблица системных + кастомных ролей с счётчиком активных прав
  (N/21). Системные помечены бэйджем «Системная».
- Модалка edit: имя, описание, матрица прав сгруппированная по 6
  блокам (Каталог/Закупки/Продажи/Контрагенты/Отчёты/Настройки).
  Удаление кнопка только для кастомных.

Меню «Настройки организации» дополнено пунктами «Сотрудники»
(иконка UserCog) и «Роли» (иконка Shield).

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:01:44 +05:00
nns f38d34f42d feat(domain): Employee, EmployeeRole, RolePermissions entities + migration
Базовый каркас модуля «Сотрудники и Роли» (по образу сторонняя система):

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>
2026-04-26 12:00:30 +05:00
nns cec76ecaaf refactor(retail-points): rename «Точка продаж» → «Касса» + перенос
складов и касс в раздел «Настройки организации»; useOrgInfra хук

UI-переименование:
- RetailPointsPage: title «Кассы», description обновлён, лейблы
  «Новая касса» / «Удалить кассу?»; доменная сущность RetailPoint
  и URL /api/catalog/retail-points сохранены — DTO/БД не трогаем.
- В сайдбаре пункты «Склады» и «Кассы» перенесены из бывшей
  группы «Склады» в группу «Настройки организации» (рядом с
  «Общие»). Старые пункты верхнего уровня убраны.

useOrgInfra() — общий хук:
- возвращает stores, cashRegisters, defaultStoreId, defaultCashId
- showStorePicker / showCashPicker = length > 1 (умное скрытие
  селекторов в формах документов когда инфра одна).
В SupplyEditPage скрытие склада уже работало через
(stores.data?.length ?? 0) > 1 — оставил как есть, новый хук
для будущих документов (продажи, инвентаризации).

Сидер default Store + RetailPoint per Organization уже есть в
DevDataSeeder.cs (Основной склад MAIN + Касса 1 POS-1) —
дополнять не нужно.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:53:49 +05:00
nns dc4b5360b9 fix(searchable-select): dropdown opens as floating overlay (Portal + absolute)
Старый dropdown использовал position:absolute внутри Section (а у того
overflow-hidden для скруглений), из-за чего он клипался границами
карточки. На некоторых страницах визуально это смотрелось так, будто
список «раздвигает» layout.

Решение — тот же Portal-паттерн что у SupplyLineQuickAdd и DateField:
- dropdown рендерится через createPortal в document.body
- position: fixed, координаты по getBoundingClientRect() trigger'а
- z-[100], sticky-headers/секции не перекроют
- Auto-flip: если внизу <240px и сверху больше — открываем вверх
  (anchor через bottom: window.innerHeight - rect.top)
- Outside-click учитывает обе ноды (wrap + dropdown в portal)
- На window.scroll/resize dropdown закрывается чтобы не «уплывать»
  относительно trigger'а

Фиксит сразу все Select'ы в проекте (тип штрихкода, валюта, страна,
группа товара, тип цены и т.д.) — компонент один.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:56:03 +05:00
nns e96f1cdc86 fix(date-field): theme styles + default to today for new docs
Тема react-datepicker подогнана под Tailwind/slate-стиль остального UI:
- font-family inherit, шрифт системный (не Helvetica дефолт);
- header bg-slate-50, border slate-200, скругления 0.5rem;
- выбранный день — заливка var(--color-brand) (фирменный зелёный);
- сегодня — bold + brand color;
- день под клавиатурой — slate-100 без заливки brand;
- стрелки навигации серые slate-500 (без bootstrap-синего);
- footer «Сегодня» — приглушённый slate-50 с brand-цветом текста;
- треугольник-указатель скрыт;
- крестик clear — slate-400 с hover slate-500;
- весь dark-вариант через prefers-color-scheme — slate-800/900.

todayIso() в SupplyEditPage переписан с toISOString() (UTC) на
локальный YYYY-MM-DD — иначе в часовом поясе KZ (UTC+5) с 03:00
до 05:00 утра новая приёмка получала бы вчерашнюю дату.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:51:19 +05:00
nns 39da12edec feat(date-field): replace native input with react-datepicker — polished UX
Нативный <input type=\"date\"> рендерил американский MM/DD/YYYY и
тонкий браузерный popup, выглядел криво рядом с другими полями.

Используем готовый react-datepicker (10M downloads/week) — никакой
кастомизации, всё из коробки:
- dateFormat=\"dd.MM.yyyy\" + locale=\"ru\" → «25.04.2026», русские
  Январь/Понедельник
- showMonthDropdown + showYearDropdown + dropdownMode=\"select\" →
  быстрый прыжок на любой месяц/год
- todayButton=\"Сегодня\" → кнопка под календарём
- isClearable → крестик в input для очистки
- popperClassName z-[100] чтобы попап не резался z-стеком

ISO YYYY-MM-DD внутрь/наружу собираем вручную из локальных Y/M/D —
не toISOString(), чтобы вечерние даты в часовом поясе KZ (UTC+5)
не сдвигались на день назад.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:39:51 +05:00
nns dc162a6c06 revert(date-field): drop custom react-day-picker — use native input type=date
Кастомный DateField на react-day-picker оказался хрупким (стрелки
навигации не реагировали, dropdown лет вылазил за popover). В проекте
нет shadcn/ui-обёртки над day-picker, а пилить её с нуля под одно
поле — overkill.

Откатил на нативный <input type=\"date\"> с max-w-[180px], чтобы
поле не растягивалось на всю колонку. Браузер сам подтягивает
локаль из ОС/настроек — у пользователя с RU-локалью календарь
будет на русском, формат DD.MM.YYYY (как в его референс-скриншоте).

- Удалён components/DateField.tsx.
- В SupplyEditPage возвращён <TextInput type=\"date\"> с
  className=\"max-w-[180px]\".
- Сняты зависимости react-day-picker и date-fns.

Если когда-нибудь вернёмся к кастомному picker'у — будем ставить
shadcn/ui calendar+popover целиком, не вручную.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:32:41 +05:00
nns c54c26cf2b fix(date-field): polish calendar UX — dropdown nav, today/clear footer, ru weekdays
Календарь приведён к виду нативного date-picker macOS:

- captionLayout="dropdown" + startMonth/endMonth — в шапке
  селекты «Апрель» и «2026» (можно прыгнуть на любой месяц/год
  без 12 кликов next).
- Стрелки навигации справа в шапке (absolute right-1 top-1),
  компактные 28×28, hover-bg, без ярко-синего акцента.
- footer prop — две inline-ссылки «Очистить» (сбрасывает) и
  «Сегодня» (ставит сегодняшнюю дату); border-t над ними.
- Сегодня = синяя заливка bg-brand text-white (как на референсе);
  выбранная дата = ring-2 ring-brand; если сегодня = выбранный —
  применяются обе (сначала заливка, потом ring).
- Ширина popover'а w-[340px], ячейки 36×36, weekday и dropdown
  capitalize → «Пн Вт Ср …», «Апрель 2026».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:22:48 +05:00
nns 6a673e536e fix(date-field): compact calendar popup — shadcn-style sizing
Календарь react-day-picker раздувался на ~700px из-за дефолтных
стилей. Теперь plотный shadcn-style ~280–290px:

- CSS-переменные v9 на враппере: --rdp-day-height/width=2rem,
  --rdp-day_button-height/width=2rem, --rdp-weekday-padding=0,
  --rdp-accent-color=brand. Контейнер p-3 text-sm.
- classNames переписаны: ячейка дня h-8 w-8 (32×32) с rounded-md,
  weekday w-8 capitalize, caption_label text-sm font-medium.
  Кнопки навигации 24×24 с hover-bg, chevron 16×16.
- Сегодня: bg-slate-100 + font-semibold (без жирной обводки).
  Выбранная дата: bg-brand text-white. Outside-дни выцветшие.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:19:13 +05:00
nns c31611d6c4 fix(date-fields): cap width + ru locale + DD.MM.YYYY format
Новый компонент <DateField/>:
- Ширина зафиксирована (по умолчанию w-40 = 160px) — раньше нативный
  <input type="date"> растягивался на всю колонку, хотя содержимое
  всегда 10 символов.
- Ввод в формате DD.MM.YYYY с авто-вставкой точек после dd и mm,
  inputMode=numeric для мобилы. Хранит/отдаёт ISO YYYY-MM-DD —
  API-контракт не меняется.
- Иконка календаря справа открывает попап (через portal в body,
  position fixed) с react-day-picker: locale=ru, weekStartsOn=1,
  ISOWeek; caption_label/weekday с capitalize CSS — «Апр 2026»,
  «Пн Вт Ср …». Outside-click закрывает.

Подключено в SupplyEditPage (поле «Дата»). Ставка на единый
компонент DateField — все будущие даты в системе через него.

Зависимости: + react-day-picker ^9, + date-fns ^4.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:03:43 +05:00
nns 970a9baec3 fix(supply-quick-add): sticky input at viewport bottom + auto-scroll on add
Quick-add bar теперь не sticky-внутри-Section, а отдельный flex-sibling
формы — всегда прибит к нижнему краю viewport независимо от высоты
содержимого и overflow-hidden у Section'а:

  <form flex flex-col h-full>
    <topbar />
    <body flex-1 overflow-auto>     ← скроллится
    <quick-add bar flex-shrink-0>   ← всегда виден
  </form>

После каждого добавления строки скролл-контейнер тела документа
автоскроллится к низу (smooth, через requestAnimationFrame чтобы
дождаться рендера новой строки) — новая строка всегда появляется
прямо над input'ом и пользователь видит подтверждение скана.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:42:24 +05:00
nns b56c499b45 fix(supply-quick-add): dropdown opens upward + show only N results + create-new at bottom
UX как в сторонняя системае:
- Dropdown открывается ВВЕРХ от input'а (anchor по input.top, fixed
  bottom). Max-height 60vh — не перекрывает шапку, внутри overflow-y.
  Раньше выпадал вниз и при добавлении строк визуально не оставалось
  места.
- Показываем первые 10 матчей (VISIBLE_LIMIT=10), под ними ссылка
  «Ещё N товаров» которая раскрывает полный список (limit=50 на
  сервере). На запрос приходит до 50 — этого достаточно для
  подавляющего большинства поисков.
- Под списком (через тонкий разделитель) — пункт «+ Создать новый
  товар: «{q}»» / «Создать товар со штрихкодом «…»». Всегда последний
  визуально, ближайший к input'у — самый удобный для быстрого клика.

Стрелки ↑↓ работают по видимой части (1..10 или весь список после
expand). Enter подбирает подсвеченный из видимой части.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:13:57 +05:00
nns 94d3c4687b fix(supply-quick-add): keep input focused after scan / clear on add
Сценарий — приёмщик подряд сканирует 50 штрихкодов без клика мышью:

- Sticky-bar с input'ом теперь ВНЕ Section'а (overflow-hidden родителя
  ломал sticky), прибит к низу скроллируемого тела документа. После
  любого добавления строки — input всегда виден.
- Очистка query и refocus вызываются СРАЗУ после клика/Enter, до
  await на /products/{id}: пока сеть в полёте, юзер уже может начать
  следующий скан. После завершения запроса — повторный refocus
  (двойной guarded focus + requestAnimationFrame), чтобы перебить
  любые ререндеры родителя, которые могут увести фокус.
- Поиск по quick-search теперь через AbortController — устаревший
  ответ при быстром вводе подряд не подменяет свежий список.
- Закрытие ProductQuickCreateModal тоже возвращает фокус в input
  (и при «Создать», и при «Отмена»).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:08:49 +05:00
nns f7deecc41c fix(supply-quick-add): dropdown not rendering — Portal + fixed position
Корень проблемы: Section рендерится с класcом overflow-hidden (нужен
для скруглений углов) — absolute-позиционированный dropdown
обрезался границей карточки и был не виден совсем.

Решение: dropdown через React createPortal вынесен в document.body,
позиция вычисляется по getBoundingClientRect() input'а на каждый
open + window scroll/resize. position: fixed, z-[100] — выше любого
sticky-header. Outside-click handler теперь учитывает оба контейнера
(input wrap + portal-узел) — клик по элементу dropdown'а больше
не закрывает его как «снаружи».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:01:49 +05:00
nns 9008939249 chore(web): remove standalone Currencies page (managed via Country FK)
Валюты — закрытый сидируемый список (KZT, USD, EUR, RUB), пользователь
их не создаёт. Currency остаётся в БД как FK для Country и Organization;
GET /api/catalog/currencies продолжает работать для дропдаунов в форме
страны и настройках организации. Отдельная страница в меню избыточна.

- удалена pages/CurrenciesPage.tsx
- из роутера убран /catalog/currencies
- из сайдбара пропал пункт «Валюты», иконка Coins больше не импортируется

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:01:49 +05:00
nns 532c1aaf24 feat(supply): inline line quick-add — scanner + autocomplete + create-on-fly
UX как в сторонняя система: под таблицей строк единый input full-width с
автофокусом, на каждый ввод — debounce 200ms и quick-search в API.
Dropdown показывает артикул + название (с подсветкой матча) +
бэйдж текущего остатка по складу документа (зелёный/красный/серый).

Сценарии:
- Сканер (цифры 8/12/13/14 + Enter) → точный barcode lookup,
  единственный матч добавляется мгновенно. Несколько → пользователь
  выбирает из dropdown.
- Текст + Enter → берёт подсвеченный пункт автокомплита.
- Дубль того же товара → Quantity += 1 у существующей строки,
  всплывает «Кол-во увеличено на 1».
- Ничего не нашлось → пункт «Создать новый товар: «{q}»» в дропдауне
  и при Enter, открывает ProductQuickCreateModal с pre-fill в зависимости
  от типа запроса (barcode/article/name).
- ↑↓ навигация, Esc/Tab закрывают, после добавления input очищается
  и возвращает фокус — для сканирования партии подряд.

Кнопка «+ Добавить из справочника» (правый верх секции) — без изменений,
открывает ProductPicker с фильтрами и multi-select для bulk-добавления.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:53:07 +05:00
nns 28b264f43b ui(product-card): «Закупка» и «Цены продажи» в две колонки на десктопе
Внутри секции «Цены» теперь двухколоночная сетка (lg:grid-cols-2):
закупка слева, цены продажи справа, с вертикальным разделителем.
На узких экранах (<lg) колонки складываются вертикально, как раньше.

В правой колонке цены продажи переведены на стандартный <Field>
с label сверху, чтобы выравниваться с полями закупки.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:35:29 +05:00
nns 168b12345d fix(supply): колонка «Розничная» использует имя системного PriceType
В таблице позиций приёмки заголовок «Розничная (карточка)» теперь берёт
имя из справочника типов цен (priceTypes.find(isSystem)) — так чтобы
название совпадало с тем, что отображается в карточке товара и в
самом справочнике. Если системного типа нет — фолбэк на IsRetail,
а затем «Розничная».

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:28:29 +05:00
nns 72e602f4ca fix(migrations): catch-up Phase3b_AddShowDescriptionOnProduct
Колонку organizations.ShowDescriptionOnProduct логически вводила миграция
Phase3b_DropProductShelfLifeDays, но я дописал её туда задним числом
после того как миграция уже применилась на стенде. EF проверяет только
__EFMigrationsHistory.MigrationId, не содержимое — поэтому колонка
не создалась, а после деплоя API падал в DevDataSeeder с
«column o.ShowDescriptionOnProduct does not exist».

Правильное лекарство — отдельная догоняющая миграция с idempotent
ALTER TABLE … ADD COLUMN IF NOT EXISTS. На стенде колонка уже добавлена
вручную и запись в истории миграций есть; пайплайн пройдёт мимо неё.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:10:36 +05:00
nns 2321010608 feat(ui): inline-create option in searchable Select
Опциональный onCreate(label) пропс — если задан и пользователь набрал
текст, не совпадающий ни с одним пунктом списка, в дропдауне появляется
кнопка «Создать «query»». По клику колбэк создаёт сущность на сервере
и возвращает id, который сразу подставляется как выбранное значение.
Enter в пустом результате тоже триггерит создание.

Подключено в приёмке для поля «Поставщик» — POST в counterparties с
дефолтами (Type=LegalEntity, остальные поля null), затем invalidate
лукапа. Полные реквизиты редактируются позже в справочнике.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:04:42 +05:00
nns 196658e548 feat(ui): searchable Select component (drop-in)
Заменили нативный <select> на кастомный комбобокс с поисковой строкой:
кликабельная кнопка-триггер, dropdown с input «Поиск…» и фильтрацией
по подстроке, навигация ↑/↓/Enter/Esc. API совместим — onChange получает
synthetic event с e.target.value, поэтому все 22 существующих <Select>
работают без правок call site. Дочерние <option> парсятся в options
автоматически (поддержка <optgroup>).

Why: справочники быстро растут (поставщики, страны, типы цен) — выбор
из длинного списка через нативный select утомителен. Поиск нужен везде.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:05 +05:00
nns fea3498b8b fix(price-types): IsRequired применяется сразу, без перезагрузки страницы
Баг: переключение «Обязательная» в /catalog/price-types не приводило
к валидации в карточке товара — Save шёл и проходил, не требуя цену.

Корневая причина — два разных queryKey:
- useCatalogMutations в PriceTypesPage инвалидирует
  ['/api/catalog/price-types'] (для самой List-страницы),
- но usePriceTypes-хук, которым пользуется ProductEditPage и
  ProductsPage, живёт под ключом ['lookup:price-types'].
В итоге свежий снапшот справочника не доходит до потребителей.

Фикс:
- useLookups: staleTime=0 + refetchOnMount: 'always' +
  refetchOnWindowFocus=true. Любой переход на ProductEditPage
  делает свежий GET и видит актуальный IsRequired/IsRetail.
- PriceTypesPage save/delete: дополнительно вызывают
  qc.invalidateQueries({ queryKey: ['lookup:price-types'] }) —
  потребители тут же перерендериваются, без необходимости F5.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:15:29 +05:00
nns a8717897b7 fix(product-edit): человечная ошибка 400 + блок Save при незаполненных IsRequired ценах
Сервер 400-нул сохранение товара когда обязательная цена пуста, а UI
показывал «Request failed with status code 400» без указания причины.

- onError save mutation: достаём response.data.error из axios-ответа
  и кладём в setError; общий generic message остаётся как fallback.
- canSave дополнен проверкой что у каждого PriceType с IsRequired=true
  есть строка в form.prices с amount > 0 (та же что делает бэкенд,
  чтобы кнопка не отправляла запрос обречённый на 400).
- Под секцией цен — красная подсказка-список незаполненных обязательных
  типов цен («Заполни обязательные цены: «Розничная2» (значение должно
  быть больше 0).»).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:58:49 +05:00
nns c257ee7e88 chore: remove one-shot ping workflow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:43:18 +05:00
nns ebdc70bd58 ci(pricetype-fix-ping): одноразовый Telegram-пинг
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:40:35 +05:00
nns db3be5bbca fix(price-types): correct is-system seeder + require value > 0 + system-price filter/sort
Phase3b сидер ошибочно создавал НОВУЮ запись «Розничная цена» с
IsSystem=true в каждой организации, не проверяя что фактически
системной была другая запись (с реальными ценами у товаров).
В итоге IsSystem-замок оказывался не у той записи.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:31:31 +05:00
nns d1ebbef671 chore: remove one-shot ping workflow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:09:18 +05:00
nns 126ff97a11 ci(phase3b-ping): одноразовый Telegram-пинг
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:06:40 +05:00
nns c7a498cf9a feat(product-prices): inputs по справочнику PriceType — без dropdown'a
Раньше каждая цена в карточке товара рендерилась как dropdown «выбор
PriceType» + поле ввода + кнопка удаления. Это было избыточно:
типы цен и так фиксированы справочником, выбирать нечего.

Теперь:
- Идём по справочнику PriceType (отсортирован по SortOrder→Name).
- На каждый PriceType — одна строка: label = pt.Name, поле MoneyInput.
- IsRequired запись помечается красной звёздочкой * после имени.
- Стерев значение — строка убирается из form.prices (UI пусто).
- Введя значение — создаётся новая запись (currency = KZT
  fallback из справочника), либо обновляется существующая.
- Кнопка «+ Добавить» и иконка удаления убраны — управление набором
  типов цен теперь только через «Настройки → Типы цен».

addPrice/removePrice вспомогательные функции удалены за ненадобностью.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:55:56 +05:00
nns d2160f8910 feat(percent-input): компонент + inline-наценка в таблице групп
- Field.tsx: новый компонент PercentInput (брат MoneyInput) — только
  цифры + точка/запятая, до 2 знаков после, суффикс «%». На onBlur
  нормализуется в Math.round(n * 100) / 100. null = пусто.
  Использует тот же draft-pattern что MoneyInput, чтобы при наборе
  «12.» точка не пропадала.
- ProductGroupsPage:
  • поле «Наценка %» в модалке заменено на PercentInput,
  • в колонке «Наценка» таблицы теперь inline-PercentInput с
    автосохранением через PUT /api/catalog/product-groups/{id} и
    инвалидацией листинга после ответа сервера. Click stopped — клик
    в инпут не открывает модалку.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:52:44 +05:00
nns b257ea528d feat(supply+products-list): чекбокс «Проведено» с confirm + системная розничная в списке
Supply edit page:
- Кнопки «Провести / Отменить проведение» заменены на чекбокс «Проведено»
  у заголовка. При попытке отметки — confirm «После проведения товары
  будут оприходованы на склад. Продолжить?», при снятии — confirm
  «Снять проведение? Остатки откатятся, себестоимость останется
  (пересчитать вручную при необходимости).».
- Чекбокс disabled пока хотя бы одна строка пустая (Quantity ≤ 0
  или UnitPrice ≤ 0) или вообще нет строк.
- Backend (post/unpost) уже корректно делает StockMovement и
  пересчёт цен из Phase3a — UI просто переключает status.

Products list:
- Колонка «Эталонная цена» заменена на колонку системной розничной.
  Заголовок = Name той PriceType что IsSystem=true (если пользователь
  переименовал «Розничная цена» → «Продажная цена», заголовок
  колонки автоматически становится «Продажная цена»).
- Значение = Product.Prices[ системного PriceType ].Amount.
  Если у товара нет такой записи — «—» (тире).
- Подпись фильтра «Закупочная цена» → «Эталонная цена» (поведение
  фильтра по диапазону цены остаётся прежним).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:49:42 +05:00
nns b79c71591d feat(phase3b): drop IsActive, add ShelfLifeDays, restore PriceType IsSystem/IsRequired
Domain + миграция Phase3b_PricingCleanup:
- DROP IsActive у products / product_groups / units_of_measure /
  counterparties / price_types (включая индекс
  IX_products_OrganizationId_IsActive). В этих сущностях концепт
  деактивации не оправдан — если товар/группа/единица/контрагент
  не нужны, их физически удаляют.
- DROP organizations.MultiplePriceTypesEnabled — раздел «Типы цен»
  всегда виден, отдельной настройки больше не нужно.
- ADD price_types.IsRequired bool default false — обязательность
  заполнения для каждого товара.
- ADD price_types.IsSystem bool default false — защищённая запись,
  не удаляется и IsRequired всегда true; имя редактируется.
  В каждой организации гарантируется одна системная запись
  «Розничная цена» (создаётся миграцией если её нет).
- ADD products.ShelfLifeDays integer NULL — срок годности.

API:
- ProductsController/UnitsOfMeasureController/ProductGroupsController/
  CounterpartiesController/PriceTypesController: убраны параметры
  isActive в фильтрах, sort-keys, DTO, Apply, Создании.
- Products проекция: вместо IsActive теперь ShelfLifeDays.
- PriceTypesController: 400 при попытке удалить системную запись;
  IsRequired у системной — всегда true, не меняется через PUT.
- recalc-retail / supply posting: дефолтный PriceType ищется по
  IsSystem → IsDefault → IsRetail → SortOrder → Name (без IsActive).
- OrgSettingsDto/Input — без MultiplePriceTypesEnabled.

Web:
- types.ts: убраны isActive у Product/ProductGroup/UnitOfMeasure/
  Counterparty/PriceType. PriceType пополнен isRequired/isSystem.
  Product получил shelfLifeDays.
- useOrgSettings: убрано multiplePriceTypesEnabled.
- AppLayout: меню «Типы цен» всегда видно.
- Pages (Counterparties/Units/ProductGroups/PriceTypes/ProductEdit/
  OrganizationSettings): сняты колонки/чекбоксы/поля «Активен»;
  удалён GroupMarkupsPage; в PriceTypesPage добавлен Lock-индикатор
  системной записи и блок-подсказка, кнопка удаления скрыта.
- DemoCatalogSeeder и OtherSystem-импортёр: больше не пишут IsActive.

UI-перекомпоновка карточки товара (Phase3b пп.6/9), Supply Posted-toggle,
PercentInput, ShelfLifeDays-фильтр и редизайн прайс-секции — отдельными
коммитами далее по плану.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:46:34 +05:00
nns 9c0e9494f3 chore: remove one-shot ping workflow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:24:20 +05:00
nns 6729a390bf ci(pricing-model-ping): одноразовый Telegram-пинг что Phase3a задеплоен
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:21:21 +05:00
nns 7fe30bd98d feat(web): supply line retail override column
В таблице строк приёмки добавлена колонка «Розничная (карточка)».
- Значение по умолчанию — текущая дефолтная розничная цена товара
  (берётся из ProductDto.prices при подборе или из SupplyLineDto.
  currentRetailPrice при загрузке существующего документа).
- Любая ручная правка ставит retailPriceManuallyOverridden=true и
  записывает retailPriceOverride. При проведении документа этот
  override применяется к Product.Prices[default] вместо автонаценки.
- В payload PUT/POST шлём retailPriceManuallyOverridden и
  retailPriceOverride (либо null если override снят).
- types.SupplyLineDto расширен полями currentRetailPrice /
  retailPriceManuallyOverridden / retailPriceOverride.
- В addLineFromProduct unitPrice fallbacks теперь учитывают cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:12:12 +05:00
nns a5f0fb83d8 feat(web): price types CRUD visibility + group markup table
- ProductGroupsPage: колонка «Наценка %» в таблице, поле «Наценка %»
  в модалке редактирования с подсказкой про формулу
  ⌈Cost × (1 + наценка/100)⌉.
- НОВАЯ страница /settings/group-markups (GroupMarkupsPage) — массовая
  правка % наценки по группам. Inline TextInput, считается diff,
  кнопка «Сохранить (N)» делает PUT по каждой изменённой группе.
- AppLayout: меню «Типы цен» прячется когда multiplePriceTypesEnabled=false.
  Добавлен пункт «Настройки → Наценки по группам».
- App.tsx: новый маршрут /settings/group-markups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:09:10 +05:00
nns e74bec3964 feat(web): product card pricing UI + settings toggles
- OrganizationSettingsPage: 2 новые галки —
  «Несколько типов цен (Опт, VIP и т.п.)» (multiplePriceTypesEnabled)
  и «Показывать «Эталонную цену» на товаре» (showReferencePriceOnProduct).
- ProductEditPage:
  • «Эталонная цена» с подписью «не обязательное поле»; рендерится только
    при showReferencePriceOnProduct=true.
  • «Себестоимость» — readonly MoneyInput, всегда виден; подпись
    «расчётная (скользящее среднее)».
  • Заголовок секции цен меняется: «Цены продажи» при multipleEnabled,
    иначе «Розничная цена».
  • Кнопка «Привести к себестоимости» (только для existing товара) —
    POST /api/catalog/products/{id}/recalc-retail. После 200 — обновляем
    дефолтный PriceType в form.prices, инвалидируем кэш. После 400 —
    показываем сообщение в общий error.
- useOrgSettings.ts: добавлены поля multiplePriceTypesEnabled и
  showReferencePriceOnProduct.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:18:23 +05:00
nns ad05f9fe30 chore: remove one-shot ping workflow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:47:15 +05:00
nns aec5ca5591 ci(runner-fix-ping): одноразовый Telegram-пинг что стенд догнал
Триггер только на изменение самого файла. Уйдёт следующим коммитом.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:45:04 +05:00
nns 3c576934c7 ci(docker): откатить buildx → docker build (registry connect refused внутри builder)
buildx --driver docker-container запускает builder в изолированном
сетевом namespace, откуда 127.0.0.1:5001 (host registry) недоступен:
ошибка «dial tcp 127.0.0.1:5001: connect: connection refused» в шаге
FROM ${LOCAL_REGISTRY}/mirror/dotnet-aspnet:8.0.

Откатываю на классический `docker build` + `docker push`. У host
docker daemon уже есть 127.0.0.1:5001 в insecure-registries, layer-cache
демона между сборками сохраняет dotnet restore / pnpm install при
стабильных манифестах. Path-фильтры (api vs web) остаются — это
основной выигрыш по времени.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:39:29 +05:00
nns 8d9cd201b4 ci(docker): split into docker-api.yml + docker-web.yml — независимые pipeline'ы
В предыдущей попытке runner v6.2.2 не подружился с cross-job outputs —
job changes падал «'runs-on' key not defined in Docker Images/changes»
и оба следующих job уходили в skipped. Откатываю на надёжный путь:
два отдельных workflow с paths-фильтром на уровне триггера.

- docker-api.yml: триггерится на src/food-market.api/**,
  application/**, domain/**, infrastructure/**, shared/**, sln,
  Dockerfile.api, compose. Билдит buildx с registry-cache, пуллит
  и пересоздаёт ТОЛЬКО api (`docker compose up -d --no-deps api`).
- docker-web.yml: триггерится на src/food-market.web/**, Dockerfile.web,
  nginx.conf, compose. Делает то же для web.
- .env стенда теперь идемпотентный — оба тэга всегда :latest, после
  push образа buildx обновляет latest, pull тянет свежий.
- Telegram отдельные сообщения «stage api deployed» / «stage web
  deployed» с SHA — понятно что именно прилетело.

Push, который не задевает ни api/ ни web/ исходники (только md/docs),
вообще не запускает workflow — экономия очевидна.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:22:31 +05:00
nns 9077c07584 fix(money-input): toFixed(2) при allowFractional=true для правильного отображения
formatFromValue использовал String(v) для дробного режима, поэтому
целые значения в БД (1317, 1860) показывались как «1317» / «1860»
даже при включённой галке «Разрешить дробные цены». Теперь:
- fractional=true → v.toFixed(2): 1317 → «1317.00», 1317.5 → «1317.50»;
- fractional=false → String(Math.round(v)): 1317.5 → «1318».

Хелпер используется в init useState, useEffect-синке и commitDraft —
таким образом при onBlur поле всегда возвращается к корректному
формату «1317.00».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:55:33 +05:00
nns 71b749fb35 ci: path filters + buildkit cache для ускорения сборки
Цель: типичный push должен катиться за 30-60 сек вместо 3-5 мин.

docker.yml — две оптимизации:
1. Job changes детектит что изменилось (api/web) через `git diff
   HEAD~1 HEAD`. Образ пересобирается только если затронуты его
   директории; `paths-ignore` отсекает docs/*.md/.github/**.
2. Сборка через `docker buildx build` с registry-cache:
   --cache-from / --cache-to type=registry,ref=...:buildcache,mode=max.
   Локальный 127.0.0.1:5001 уже разрешает DELETE, так что mutable
   buildcache работает. dotnet restore / pnpm install теперь почти
   мгновенные при отсутствии изменений в *.csproj / pnpm-lock.yaml.
3. deploy-stage запускается только если api или web реально
   пересобирался; для пропущенного образа в .env пишется :latest,
   compose pull тянет последний успешный slim. Telegram-сообщение
   указывает что именно деплоилось ([api web] / [web] / только compose).

ci.yml — actions/cache для NuGet (~/.nuget/packages по hash *.csproj)
и pnpm store (по hash pnpm-lock.yaml). paths-ignore такое же.

POS-job (windows-latest) не трогаем — он и так fires только на
тег v* / workflow_dispatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:54:57 +05:00
nns 87271281b0 fix(money-input): сохранять промежуточный ввод точки в draft
Реальная причина бага: classic problem controlled numeric input.
Когда юзер печатал «100.» (хотел «100.50»), цикл value→Number("100.")→100
→ display=String(100)=«100» съедал точку, и продолжать ввод дроби
становилось невозможно.

Фикс: MoneyInput хранит локальный draft string, который и показывается
в input. Снаружи value всё равно прокидывается числом, но draft не
синхронизируется с ним пока поле в фокусе. Промежуточные состояния
типа «100.» теперь живут в draft и не теряются.

- Добавлены useState<draft> и useState<focused>.
- onChange: пишем в draft as-is (только фильтр символов и одна точка),
  наружу onChange(number) отдаём сразу когда draft парсится в число
  (включая случай «100.» → отдаём 100, но draft оставляем «100.»).
- onBlur: commitDraft нормализует draft и в number, и обратно в draft.
- useEffect синхронизирует draft с value только когда !focused.
- Округление при !fractional не выполняется во время focus — иначе
  перебивает ввод пользователя.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:44:18 +05:00
nns 7d86b7ed73 fix(money-input): корректное обновление allowFractionalPrices без перелогина
Главная причина бага: useOrgSettings имел staleTime=5 минут. Когда
пользователь переключал галку в /settings/organization, инвалидация
ключа `/api/organization/settings` помечала кэш stale, но MoneyInput
в открытой форме товара продолжал получать старое значение из кэша
до момента, пока сабскрайбер не делал перефетч. На свежем монтировании
(переход с /settings обратно на /catalog/products/...) перефетча тоже
не происходило, т.к. данные считались всё ещё валидными.

Фикс:
- useOrgSettings: staleTime=0 + refetchOnMount: 'always' +
  refetchOnWindowFocus=true. Каждое монтирование компонента — свежие
  настройки одним лёгким GET'ом. Цена нулевая, цикл «изменил настройку
  — открыл форму — увидел эффект» теперь работает без перезагрузки.
- OrganizationSettingsPage: useEffect синхронизирует form со settings.data
  без условия `!form` — при свежем рефетче форма тоже подтягивает актуальное.

Все вызовы MoneyInput в формах уже не передают allowFractional —
компонент сам читает useOrgSettings, что вместе с фиксом выше даёт
корректное поведение.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:39:25 +05:00
nns 8a0f8c20f9 fix(money-input): уважать AllowFractionalPrices в формах редактирования
MoneyInput теперь сам читает useOrgSettings().allowFractionalPrices,
а не только полагается на prop из вызова. Это закрывает два бага:

1. Когда настройка известна и запрещает дробное, но в state товара
   лежит дробная цена (например исторические данные из OtherSystem) —
   useEffect синхронизирует округлённое значение наружу, чтобы
   при сохранении не уходило значение, которого юзер не видит.

2. Пока org.data ещё не загружено, MoneyInput не режет дробь
   (fractional трактуется как true до приезда настройки), иначе
   в момент гидрации формы из ProductDto с дробной ценой компонент
   успевал её обрезать до того как настройка приходила.

Все вызовы MoneyInput в ProductEdit / SupplyEdit / RetailSaleEdit /
ProductsPage filters очищены от избыточного prop allowFractional —
компонент берёт настройку сам. Override через prop остаётся доступным.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:17:32 +05:00
nns adf2c90904 chore: remove one-shot pack8-ping workflow
pack8-ping.yml #64 отработал (Telegram уведомление отправлено),
одноразовый workflow больше не нужен.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:18:21 +05:00
nns 68ccc9fa1d ui(mobile): адаптация под смартфоны — drawer-меню, grid, модалка
Система теперь корректно работает на узких экранах (<768px):

- AppLayout: на мобиле фиксированный sidebar заменён hamburger-меню
  (Menu icon) + off-canvas drawer с overlay. На md+ — прежний sidebar.
- ProductsPage: дерево групп тоже превращается в drawer на мобиле,
  кнопка «Группы» рядом с заголовком; фильтры flex-wrap.
- Modal: на мобиле (<sm) разворачивается на весь экран (items-stretch,
  min-h-full, убраны скругления и верхний отступ).
- DataTable: мин-ширина 640px + whitespace-nowrap в заголовках, уменьшен
  горизонтальный padding на мобиле. Родительский overflow-auto даёт
  плавный горизонтальный скролл.
- PageHeader/ListPageShell: flex-wrap, меньший padding на мобиле.
- SearchBar: flex-1 на узких (занимает доступное место), фикс 256px на sm+.
- ProductEditPage Grid helper: 3/4 колонки теперь grid-cols-1 sm: 2
  md: 3/4 — поля не слипаются на телефоне.
- ProductEditPage/Supply/RetailSale/Dashboard/OrganizationSettings:
  отступы p-3 sm:p-6, grid grid-cols-2 на страну/валюту → 1 col на мобиле.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:17:56 +05:00
nns 38f117b2a4 ci(pack8-ping): одноразовый workflow для Telegram-пинга
Триггерится только при изменении самого файла — после выполнения
будет удалён следующим коммитом.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:02:53 +05:00
nns 195ca2e2bb ui: barcode регенерация / h-10 поля / прыжок на страницу / min-max скрытие
- lib/barcode.ts: новая утилита generateBarcode(type) — валидные коды
  под все форматы (EAN-13 с префиксом 2, EAN-8 с checksum, UPC-A
  с checksum, UPC-E 8 цифр, Code128/Code39 12 буквенно-цифровых).
- ProductEditPage: при смене типа штрихкода в dropdown поле кода
  регенерируется под новый формат.
- Field.tsx: единая высота h-10 и leading-none для TextInput/Select
  чтобы Страна/Валюта/НДС в настройках были одного размера.
  TextArea оставлен с h-auto для multiline.
- Pagination.tsx: рядом с ← → добавлен input «Страница [N] из M»
  для прыжка на произвольную страницу (Enter / blur применяют).
- ProductEditPage: блок мин/макс остатков теперь показывается только
  при org.showMinMaxStock (сама настройка добавится следующим коммитом).

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:39:06 +05:00
nns 42a3d2aa50 feat(barcode): авто-генерация EAN-13 при добавлении штрихкода
Новый штрихкод в товаре сразу получает валидный EAN-13 с префиксом
"2" (зарезервирован под внутренние штрихкоды магазина, не конфликтует
с GTIN производителей). Пользователь может заменить на реальный
считанный — поле остаётся редактируемым.

Утилита lib/barcode.ts::generateEan13InternalPrefix2() генерирует
11 случайных цифр после "2" и дописывает контрольную сумму EAN-13.

Уникальность штрихкода в организации уже обеспечивает
IX_product_barcodes_OrganizationId_Code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:35:40 +05:00
nns 1227fbdfa5 ui(org-settings): симметричный layout — Страна/Валюта/НДС одной сеткой
Все read-only поля (Валюта, Ставка НДС) теперь единого вида: disabled
TextInput на всю ширину ячейки в grid-cols-2 gap-4, та же высота/padding
что и у editable Select "Страна". Единая подсказка про источник правды
страны вынесена одним параграфом под сеткой.

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

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

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

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

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

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

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

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

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

Galleries/products/settings других этапов — не задеты.
2026-04-24 11:56:28 +05:00
nurdotnet e16375ccf6 feat(product-images): загрузка на диск сервера + галерея с лайтбоксом
Backend:
- ProductImagesController: GET list / POST multipart upload /
  DELETE / POST set-main.
- Файлы лежат в $ContentRoot/uploads/products/{productId}/{guid}.{ext}
  (volume /opt/food-market-data/uploads:/app/uploads в compose).
- В БД хранится относительный URL /uploads/products/{id}/{file}.
- UseStaticFiles на /uploads — публичная раздача (без auth).
- Допустимые расширения: jpg/jpeg/png/webp/gif, до 10 МБ.
- При первой загрузке картинка становится основной; Product.ImageUrl
  синхронизируется с "основной".
- Удаление основной переводит "основной" флаг на следующую оставшуюся.

Web-nginx: /uploads/ проксируется на api:8080.

Web UI:
- Компонент <ProductImageGallery>: превьюшки 80×80 в грид,
  при наведении — кнопки "сделать основным" и "удалить",
  клик на превью → fullscreen lightbox с навигацией ←→ и счётчиком.
- В ProductEditPage убран инпут "URL изображения" (был технической
  строкой для копипаста), вместо него блок "Изображения" с галереей.
  Показывается только для уже сохранённого товара (есть id).

Docker compose: добавлен bind-mount /opt/food-market-data/uploads.
2026-04-24 11:12:27 +05:00
nurdotnet d93edcae2c feat(product): enum Packaging (штучный/весовой/разливной) вместо IsWeighed
Миграция Phase4b_ProductPackaging:
  products.IsWeighed (bool) → products.Packaging (int enum)
  1=Piece (default), 2=Weight, 3=Liquid
Backfill: прежние весовые товары → Weight.

Domain/DTO/Input/Controller/Seeder/OtherSystemImport — всё обновлено.

Web:
- Packaging enum в types.ts.
- ProductEditPage: select "Фасовка" вместо checkbox "Весовой".
- Подпись чекбокса НДС уточнена: "НДС применяется (ставка выше)" —
  ссылается на поле Vat на товаре.
- Удалён IsMarked checkbox текст → "Маркируемый (Честный знак / Datamatrix)".
- ProductsPage фильтр: select Packaging вместо Tri(IsWeighed).
2026-04-24 11:08:43 +05:00
nurdotnet 337e790eab feat(org-settings): Country↔Currency, Organization.DefaultCurrency/MultiCurrency/DefaultVat + UI настроек
Миграция Phase4_CountryCurrencyOrgDefaults:
- countries.DefaultCurrencyId (FK → currencies)
- organizations.DefaultCurrencyId, MultiCurrencyEnabled, DefaultVat
- Seed: KZ→KZT, RU→RUB, BY→BYN, US→USD, DE→EUR, CN→CNY, TR→TRY
- Default для org: KZT, vat=16

Backend:
- Organization сущность получила DefaultCurrency/MultiCurrencyEnabled/DefaultVat.
- OrganizationSettingsController: GET/PUT /api/organization/settings.
- DevDataSeeder при создании/backfill орга выставляет KZT + vat=16.

Web:
- /settings/organization: форма с выбором страны (авто-подтягивает валюту),
  чекбоксом multi-currency, ставкой НДС по умолчанию.
- useOrgSettings() хук.
- SupplyEditPage / RetailSaleEditPage / ProductEditPage: select валюты
  показывается только если multiCurrencyEnabled=true, иначе
  подтягивается DefaultCurrency организации и рисуется символ валюты
  справа от цены.
- ProductEditPage при создании нового товара берёт VAT из org.DefaultVat.
- В sidebar добавлен раздел 'Настройки → Организация', убран
  Ставки НДС (сущность удалена раньше).
2026-04-24 11:03:25 +05:00
nurdotnet a8b3ef40ce ui(products): убрать колонку "Тип" из списка, спрятать min/max stock в "Расширенные"
В списке товаров колонка с бейджами Услуга/Весовой/Маркируемый только
занимает место и ничем не помогает — в UX OtherSystem такого нет, убираю.
В форме редактирования минимальный/максимальный остаток (для
уведомлений о пополнении и автозаказа) — второстепенные поля, ушли в
раскрывающийся блок "Расширенные параметры" сразу после блока закупки.
Закупочная цена осталась основной.

НДС-колонка теперь рисует "—" если VatEnabled=false.
2026-04-24 10:53:32 +05:00
nurdotnet a3b3caa2d3 fix(other-system/test): сделать Token опциональным
UI перестал отправлять токен в теле /test (он теперь из настроек),
а TestRequest был с non-null string — ASP.NET model validation отдавал
400 'One or more validation errors occurred'. Сделал nullable.
2026-04-24 00:25:24 +05:00
nurdotnet ce0c3acdd6 feat(other-system-import): async jobs с прогрессом + токен в настройках
Pain points:
1. Импорт на ~30k товарах проходит 15-30 мин, nginx рвал на 60s → 504.
2. При импорте/очистке ничего не видно — ни счётчика, ни прогресса.
3. Токен приходилось вводить каждый раз вручную.

Фиксы:
- Async-job pattern: POST /api/admin/other-system/import-products и
  /api/admin/cleanup/all/async возвращают jobId, реальная работа
  в Task.Run. GET /api/admin/jobs/{id} — статус +
  Total/Created/Updated/Skipped/Deleted/Stage/Message.
- ImportJobRegistry (singleton, in-memory) — хранит job-progress.
- OtherSystemImportService обновляет progress по мере пейджинга
  (в т.ч. счётчик Created/Updated/Skipped).
- Cleanup разбит на именованные шаги, Stage меняется по мере
  "Товары…" → "Группы…" → "Контрагенты…".
- Токен per-organization: Organization.OtherSystemToken + миграция
  Phase3_OrganizationOtherSystemToken. Endpoints:
  GET/PUT /api/admin/other-system/settings.
- Импорт-endpoints больше не требуют token в теле — берут из org.
- HttpContextTenantContext.UseOverride(orgId) — AsyncLocal-scope
  для background tasks (HttpContext там нет, а query-filter'у нужен
  orgId — ставим через override).

Nginx (host + web-container) получил 60-минутный timeout на
/api/admin/import/ чтобы старый sync-путь тоже не ронять (на
случай если кто-то вернёт sync call).

Web:
- OtherSystemImportPage переработан: блок "Токен API" (save/test
  mask), блок импорта с кнопками без поля токена.
- JobCard с polling каждые 1.5s отображает живые счётчики и stage.
- DangerZone тоже теперь async с live-прогрессом.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:49:11 +05:00
nurdotnet 9ce22dee26 fix(other-system/import): per-page retry + чаще SaveChanges
Почему импорт раньше обрывался на ~9500/29500 товаров:
- StreamPagedAsync бросал исключение при любом сетевом глюке или
  таймауте HttpClient (90s) на одной из страниц и весь цикл сыпался.
- Флаш делался раз в 500 товаров, так что при обрыве на 9500-м можно
  было потерять последние 499.

Фиксы:
- Per-page retry до 5 раз с exp-backoff (2,4,8,16с) — обрабатываем
  только сетевые ошибки (HttpRequestException / TaskCanceledException /
  IOException). API-ошибки типа 4xx проходят наверх как есть.
- SaveChangesAsync теперь каждые 100 товаров вместо 500 — меньше
  вероятность потерять при внезапном обрыве на границе.
- При исчерпании retries — бросаем осмысленное исключение с offset'ом.

Пользователь сейчас имеет 9500 из 29509 товаров (группа "Алкоголь" — 20
из 518). Нужно перезапустить импорт в UI с overwriteExisting=true —
существующие товары обновит, недостающие подтянет.
2026-04-23 23:30:37 +05:00
nurdotnet 15ea4b9e86 feat(catalog/products): tree-of-groups + фильтры как в OtherSystem
Что добавлено:
- Слева дерево товарных групп (рекурсивное, с раскрытием), клик
  переключает фильтр ProductsPage. Клик на "Все товары" — показать весь
  каталог. Выбор группы включает её поддерево (матчинг по Path prefix
  на бэкенде, чтобы сабгруппы тоже попадали в выборку).
- Кнопка "Фильтры" разворачивает верхнюю панель с тумблерами
  (all/да/нет): Активные, Услуга, Весовой, Маркируемый, Со штрихкодом.
  Счётчик в кнопке показывает количество активных не-дефолтных фильтров.
- "Сбросить" очищает всё, кроме группы.

API:
- ProductsController.List: добавлены параметры `isMarked`, `hasBarcode`.
  `groupId` теперь фильтрует по Path-prefix (вся ветка вместо одной
  группы) — это ближе к UX OtherSystem.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:18:50 +05:00
nurdotnet c172cfda5e feat(other-system): import archived entities too (as IsActive=false)
Раньше архивных контрагентов/товаров OtherSystem API по умолчанию не
возвращает (default filter = active only). Для полной синхронизации
OtherSystem → food-market теперь делаем 2 прохода: сначала активных
(default), затем filter=archived=true — отдаём всё одним потоком.

В service убран skip-if-archived; archived записи импортируются
c IsActive=false (уже было в ApplyCounterparty / ApplyProduct;
продублировал для product folders: IsActive = !f.Archived).

Клиент: рефакторинг — один generic StreamPagedAsync<T>(path, archivedOnly)
вместо трёх копий постраничного цикла.

Теперь пользовательский OtherSystem-каталог мапится в food-market 1:1
включая архив. Счётчик "Пропущено" отныне значит только "уже существует
и галка Перезаписать не стоит".
2026-04-23 21:35:44 +05:00
nurdotnet 188114d193 fix(db): reconcile stage schema — drop TrackingType, add IsMarked
Phase2c2_OtherSystemAlignment и Phase2c3_MsStrict остались в
__EFMigrationsHistory на стейдже, но .cs-файлы были удалены при откате
кода (654a8ba). В результате:
- снапшот не соответствовал актуальной БД
- колонка TrackingType висела в БД, а код ждал IsMarked
- /api/admin/other-system/import-products валился с 42703

Эта миграция:
1. Добавляет IsMarked bool NOT NULL DEFAULT false
2. Если TrackingType есть — бэкфиллит IsMarked = (TrackingType <> 0)
   и удаляет колонку (idempotent через information_schema check)
3. Auto-scaffold также синхронизировал snapshot (был устаревшим —
   содержал VatRate/IsAlcohol/Kind/Symbol и пр., которых в коде давно нет).

Локально применилось без ошибок.
2026-04-23 21:23:45 +05:00
nurdotnet 405fe62475 feat(admin): temp cleanup buttons + fix OtherSystem import duplicates
Проблема: при импорте контрагентов/товаров с галкой «перезаписать» код
ставил Add() новой сущности вместо Update() существующей, порождая
дубликаты. Исправил оба потока — теперь по ключу (Name для контрагентов,
Article для товаров) ищем существующую запись и обновляем её на месте.
Коллекции (цены/штрихкоды товара) при апдейте не трогаем, чтобы не
затереть ручные правки пользователя.

Временные админские кнопки для разбора последствий прошлых импортов:
- DELETE /api/admin/cleanup/counterparties — сносит контрагентов + зависимые поставки + их stock-movements (RetailSale.CustomerId обнуляется, Product.DefaultSupplierId обнуляется)
- DELETE /api/admin/cleanup/all — сносит всё tenant-scoped (товары/группы/контрагенты/поставки/чеки/остатки/движения). Организация, пользователи, справочники (единицы, страны, валюты, типы цен, склады, точки продаж) остаются.
- GET /api/admin/cleanup/stats — превью с количеством записей.

UI: секция «Опасная зона» внизу страницы /admin/import/other-system с двумя
красными кнопками + подтверждение словом «УДАЛИТЬ». Показываются счётчики
до и что удалилось после.
2026-04-23 20:58:59 +05:00
nurdotnet 71a2d46bf2 deploy: mirror all base images into local registry — builds no longer need internet
Any block on mcr.microsoft.com or docker.io from KZ would stall our
builds. Mirror docker base images into 127.0.0.1:5001 under mirror/*
via daily systemd timer, and point Dockerfiles + compose + CI at the
local copies.

Mirror:
  node:20-alpine                    → 127.0.0.1:5001/mirror/node:20-alpine
  nginx:1.27-alpine                 → 127.0.0.1:5001/mirror/nginx:1.27-alpine
  postgres:16-alpine                → 127.0.0.1:5001/mirror/postgres:16-alpine
  mcr.microsoft.com/dotnet/sdk:8.0  → 127.0.0.1:5001/mirror/dotnet-sdk:8.0
  mcr.microsoft.com/dotnet/aspnet:8.0 → 127.0.0.1:5001/mirror/dotnet-aspnet:8.0

Infra (committed for reproducibility):
- deploy/mirror-base-images.sh — pull/tag/push (idempotent)
- deploy/food-market-mirror-base-images.{service,timer} — daily refresh,
  installed on stage server

Usage in build-time:
- Dockerfile.api/web take ARG LOCAL_REGISTRY=127.0.0.1:5001 with the local
  copy as default, so the same Dockerfile still builds from docker.io if
  you pass --build-arg LOCAL_REGISTRY=docker.io (well, almost).
- docker-compose.yml postgres: image via ${REGISTRY}/mirror/postgres.
- ci.yml postgres service container: local mirror.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:42:48 +05:00
nurdotnet 654a8ba87d feat: strict OtherSystem schema — реплика потерянного f7087e9
Main расходился с БД стейджа (Phase2c3_MsStrict в history, но код ещё ссылался на VatRate etc.) — деплой ломался. Реплицирую удаление сущностей вручную, чтобы код совпадал с таблицами.

Убрано (нет в OtherSystem — не выдумываем):
- Domain: VatRate сущность целиком; Counterparty.Kind + enum CounterpartyKind; Store.Kind + enum StoreKind; Product.IsAlcohol; UnitOfMeasure.Symbol/DecimalPlaces/IsBase.
- EF: DbSet<VatRate>, ConfigureVatRate, Product.VatRate navigation, индекс Counterparty.Kind.
- DTO/Input: соответствующие поля и VatRateDto/Input.
- API: VatRatesController удалён; references в Products/Counterparties/Stores/UoM/Supplies/Retail/Stock.

Добавлено как в OtherSystem:
- Product.Vat (int) + Product.VatEnabled — OtherSystem держит НДС числом на товаре.
- KZ default VAT 16% — applied в сидерах и в OtherSystemImportService когда товар не принёс свой vat.

OtherSystemImportService:
- ResolveKind убран; CompanyType=entrepreneur→Individual (как и было).
- VatRates lookup → прямой p.Vat ?? 16 + p.Vat > 0 для VatEnabled.
- baseUnit ищется по code="796" вместо IsBase.

Web:
- types.ts: убраны CounterpartyKind/StoreKind/VatRate/Product.vatRateId/vatPercent/isAlcohol/UoM.symbol/decimalPlaces/isBase; добавлено Product.vat/vatEnabled; унифицировано unitSymbol→unitName.
- VatRatesPage удалён, роут из App.tsx тоже.
- CounterpartiesPage/StoresPage/UnitsOfMeasurePage: убраны соответствующие поля в формах.
- ProductEditPage: select "Ставка НДС" теперь с фиксированными 0/10/12/16/20 + чекбокс VatEnabled.
- Stock/RetailSale/Supply pages: unitSymbol → unitName.

deploy-stage unguarded — теперь код соответствует DB, авто-deploy безопасен.
2026-04-23 17:32:02 +05:00
nurdotnet d31ecec759 ci: disable .github/workflows — Forgejo Actions is the primary CI now
All CI/CD runs on the Forgejo instance at git.zat.kz (self-hosted
runner on the stage server, same box as docker registry + stage
compose). GitHub stays as a read-only mirror via
food-market-forgejo-mirror.timer.

Re-enabling: `git mv .github/workflows.disabled .github/workflows`.
2026-04-23 17:10:22 +05:00
nurdotnet 720765ee77 ci(forgejo): guard deploy-stage — main code ≠ stage DB schema
The stage DB is on Phase2c3_MsStrict (vat_rates dropped, etc.), but
main still has Product.VatRateId / DbSet<VatRate> and friends — that
code would blow up at startup against the current DB. The user's
'feat strict OtherSystem schema' commit (f7087e9) isn't present in main
any more (looks like a rebase dropped it); the pre-built image with
that SHA is still in the registry and that's what the stage is pinned
to right now.

Until main gets the matching code, deploy-stage should only fire on
tag or manual dispatch — push-to-main still builds images, just
doesn't roll the stage forward.
2026-04-23 17:10:04 +05:00
nurdotnet d7ee6d12f8 ci(forgejo): fold deploy-stage into docker workflow via needs
Forgejo Actions doesn't reliably trigger a separate workflow on
`workflow_run: Docker Images succeeded` (at least on 7.0.16), so the
stage deploy would never fire. Merging the deploy step into the same
docker workflow as a dependent job keeps it atomic: build api, build
web, then (needs: [api, web]) deploy + smoke + telegram ping.
2026-04-23 16:47:28 +05:00
nurdotnet d5f53e1a00 ci(forgejo/docker): drop ghcr push — Forgejo GITHUB_TOKEN can't auth to ghcr.io
Forgejo Actions synthesizes a GITHUB_TOKEN for the Forgejo API, not
github.com. Using it to docker-login to ghcr.io always fails (401).
Forgejo side is the new primary — push to the local registry only.
ghcr.io mirroring, if ever wanted, will go through a separate job with
an explicit GitHub PAT in GHCR_TOKEN secret.
2026-04-23 16:45:28 +05:00
nurdotnet d4dea84502 ci(forgejo): trigger Docker Images workflow for first Forgejo run 2026-04-23 16:43:13 +05:00
nurdotnet e01e6bfd7b ci(forgejo): retrigger — dotnet 8.0.420 now on default path 2026-04-23 16:41:44 +05:00
nurdotnet 5a47c6ae26 ci(forgejo): retrigger after dotnet sdk 8.0.420 available system-wide 2026-04-23 16:25:08 +05:00
nurdotnet 8772a8826d ci(forgejo): drop setup-dotnet/setup-node/pnpm actions on Forgejo
code.forgejo.org does not mirror actions/setup-dotnet — the Forgejo
runner was failing at 'workflow prepared' with 'Unauthorized' trying
to clone it. Rather than fork a bunch of actions, install the tooling
directly on the runner host (apt dotnet-sdk-8.0, nodesource node 20,
npm -g pnpm) and call dotnet/node/pnpm inline. This keeps CI fully
independent from external action registries.

GitHub Actions copy in .github/workflows still uses the stock actions
(and the cloud-backed setup-dotnet); it will be disabled in a follow-up
once the Forgejo run is green end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:23:29 +05:00
nurdotnet 7bf4a8233e ci(forgejo): mirror .github/workflows to .forgejo/workflows
Forgejo Actions runner on the stage server picks up these jobs. Runs on
the same labels `[self-hosted, linux]` — same self-hosted box as the
Docker registry and the stage itself.

deploy-stage is simplified: no SSH round-trip (runner and stage are the
same host), just `cp` + `docker compose pull/up`.

POS job kept as-is; it's gated on tag/dispatch and a Windows runner, so
on Forgejo it'll simply not match any runner and stay queued — that's
fine, POS ships from tags only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:19:24 +05:00
nurdotnet 55a63a6446 docs: audit of our domain entities vs. live OtherSystem API
Cross-checked every entity (Product, Counterparty, Supply, RetailSale,
Stock, Store, RetailPoint, Organization, ProductGroup, Barcode, Price,
PriceType, Country, Currency, VatRate, UoM) against real responses from
OtherSystem's API — a flat list of:
 - fields we have and MS doesn't (to justify or drop)
 - fields MS has and we don't (to add)
 - semantic mismatches (e.g. MS holds prices in kopecks, our decimal)

Report only, no code changes — to be discussed with the user before
touching models/migrations. Priorities are split into P1 (import
parity: ExternalCode, Code, TrackingType enum, PaymentItemType, KZ
entrepreneur type), P2 (semantic fixes: RetailSale payment sums,
Overhead on supply, legal fields on Organization), P3 (nice-to-have),
and a list of deliberate divergences (why our VatRate/StockMovement
exist even though MS doesn't model them that way).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:57:06 +05:00
nurdotnet 9facd4845d ops: Forgejo on git.zat.kz as primary, GitHub as mirror
Pushing straight to GitHub from KZ is a lottery — TCP to github.com
times out often enough that git push becomes a flake. Fix: Forgejo runs
on the stage server (sqlite, single container), all pushes go there
first (local network, always reliable), a systemd timer mirrors the
whole repo into GitHub every 10 minutes so GitHub stays up-to-date as
a backup + CI source.

What's committed here is the infra-as-code side:
- deploy/forgejo/docker-compose.yml — Forgejo 7 on :3000 (HTTP) and :2222 (SSH)
- deploy/forgejo/food-market-forgejo.service — systemd unit that drives compose
- deploy/forgejo/mirror-to-github.sh + mirror timer/service — push to GH every 10 min
- deploy/forgejo/nginx.conf — vhost for git.zat.kz (certbot to be run once DNS is set)
- docs/forgejo.md — how to clone/push, operations, what's left for the user (DNS + certbot)

GitHub Actions CI is untouched: commits land on GitHub via the mirror
and the self-hosted runner picks them up as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:27:45 +05:00
nurdotnet e1f724098f fix(other-system): убираем выдумку Kind полностью — у OtherSystem этого поля нет
Проверил через API под реальным токеном (entity/counterparty?expand=group,tags):
у OtherSystem **нет** поля «Поставщик/Покупатель» у контрагентов вообще. Есть только:
- group (группа доступа сотрудников, у всех "Основной")
- tags (произвольные ярлыки, у большинства пусто)
- state (пользовательская цепочка статусов)
- companyType (legal/individual/entrepreneur — это наш Type)

Один и тот же контрагент может быть поставщиком в одной приёмке и покупателем
в другом чеке — классификация контекстная, не атрибут сущности.

Изменения:
- ImportCounterpartiesAsync.ResolveKind теперь ВСЕГДА возвращает Unspecified.
  Никаких эвристик по тегам — просто null для Kind.
- useSuppliers хук теперь useCounterparties — возвращает ВСЕХ контрагентов,
  не фильтрует по Kind. Селекторы поставщика в Supply/RetailSale показывают
  всех. Пользователь сам выбирает кто поставщик в этом конкретном документе.
- Создание контрагента в UI: дефолт Kind = Unspecified, не Supplier.

Поле Kind в нашей модели остаётся для пользователей которые сами хотят
классифицировать. Но импорт его не трогает.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:01:10 +05:00
nurdotnet 3b6ca0316c fix(other-system): не выдумывать Kind=Both для импортированных контрагентов
У OtherSystem НЕТ встроенного поля «Поставщик/Покупатель» у контрагентов —
эта классификация целиком пользовательская через теги или группы. Импорт
ставил Kind=Both дефолтом когда тегов не было, что искажало данные:
все 586 контрагентов на stage стали «Оба», хотя в OtherSystem ничего такого
не было.

- CounterpartyKind: добавлен Unspecified=0 как дефолт
- ImportCounterpartiesAsync.ResolveKind: возвращает Unspecified когда
  тегов нет; Both только если в тегах ОБА маркера ("постав" + "покуп");
  иначе один из конкретных
- UI: dropdown получил опцию «Не указано», лейбл «Оба» переименован в
  «Поставщик + Покупатель» (точнее)
- Существующие данные: SQL UPDATE Kind=3 → Kind=0 на stage (586 строк)
  и dev (0 строк, локально пусто)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:01:10 +05:00
nurdotnet 36da65693d feat(ops): Telegram <-> tmux bridge + local docker-registry unit
Telegram bridge lets me drive the local Claude Code tmux session from my
phone — inbound messages are typed into the 'claude' session, pane diffs
are streamed back as plain Telegram messages (TUI noise, tool-call
blocks, echoed user input and already-sent lines are filtered so only
the assistant's actual reply reaches the chat). Deployed as
food-market-telegram-bridge.service, reads creds from
/etc/food-market/telegram.env (not committed).

Also committing the local docker-registry unit for reproducibility —
registry:2 on 127.0.0.1:5001, data persisted in
/opt/food-market-data/docker-registry.

Setup docs in docs/telegram-bridge.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:53:45 +05:00
nurdotnet 306e38f2e8 feat(dashboard): sales chart + KPIs (как «Показатели» в сторонняя система)
API: GET /api/sales/retail/stats?days=30 — возвращает:
- revenueToday + transactionsToday
- revenueThisMonth + transactionsThisMonth + avgTicketThisMonth
- revenuePrevMonth (для сравнения месяц-к-месяцу)
- series — массив дневных точек {bucket, revenue, transactions} с заполнением
  пустых дней нулями (чтобы линия графика была непрерывной)
- считает только проведённые чеки (Status == Posted)

Web:
- recharts добавлен (3.8.1)
- SalesChart компонент: AreaChart с градиент-заливкой брендового зелёного,
  ось X — дни (DD.MM), ось Y — выручка, tooltip с числами и валютой
- DashboardPage пересобран под продажи как первичную инфу:
  - 4 KPI-карточки сверху: выручка сегодня, выручка за месяц (с дельтой
    к прошлому месяцу), средний чек, прошлый месяц
  - график за 30 дней с empty-state когда чеков нет
  - Каталог теперь второстепенный (мелкие карточки внизу)

Empty-state: если за 30 дней не было ни одной продажи — показываем
"График появится когда появятся первые продажи" вместо плоской линии.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:57:35 +05:00
nurdotnet f04345f9f6 deploy: local docker registry at 127.0.0.1:5001 (primary), ghcr as backup
Stage's external pulls from ghcr.io flap on KZ network — the self-hosted
runner pushes images into a local registry:2 (systemd-managed,
/opt/food-market-data/docker-registry) and docker-compose now pulls from
localhost:5001 via \$REGISTRY. ghcr.io is still tagged and pushed as
off-site backup, but ghcr push failure no longer fails the build.

Setup done on the host (not in workflow):
- systemd unit food-market-registry.service (enabled, restart on failure)
- /etc/docker/daemon.json: \"insecure-registries\": [\"127.0.0.1:5001\"]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:11:19 +05:00
nurdotnet 46be745daa ci(docker): add retries for login and push on flaky network
Our upstream is dropping TCP SYNs to github.com/ghcr.io often enough
that single docker login/push attempts time out. Wrap in a 5-attempt
retry loop with 15s backoff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:49:33 +05:00
nurdotnet c17d511af8 ci(docker): drop docker/login-action and build-push-action
These actions' tarballs are downloaded from api.github.com, and downloads
from our runner's network intermittently time out past the 100s
HttpClient limit. The job then fails after 3 retries. Replace them with
plain docker CLI commands: system docker already has buildx (via apt)
and can login + push to ghcr.io directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:06:29 +05:00
nurdotnet 7ccf7125e4 ci(backend): map postgres service to host:5441 instead of 5432
The self-hosted runner host already has brew postgres@14 listening on
127.0.0.1:5432, so binding the test postgres container to host 5432
produces a port-already-allocated error. Pick 5441 and update the
connection string accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:55:12 +05:00
nurdotnet ab4c7e8b0a ci(docker): drop setup-buildx-action — use system buildx on self-hosted
docker/setup-buildx-action pulls the buildx binary from
release-assets.githubusercontent.com, which is unreachable from our
network (TLS handshake never completes — SNI-level block from upstream,
same host works for objects.githubusercontent.com). The self-hosted
runner already has docker-buildx 0.30.1 installed via apt, so
build-push-action can use it directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:44:11 +05:00
nurdotnet a21a59ee45 ci: move Linux jobs (backend, web, docker api/web) to self-hosted runner
POS stays on windows-latest (tag/manual only). Runner is registered on
the stage server, systemd-managed, labels [self-hosted, Linux, X64].
Goal: drop dependency on the 2000 GitHub-hosted minute quota — Windows
POS build now runs at most once per release tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:17:52 +05:00
nurdotnet 42c3d22c54 fix(seeder): bootstrap admin + demo org on stage/prod too, not just Dev
Login on https://food-market.zat.kz failed because DevDataSeeder skipped
in non-Dev envs, so the demo admin account never existed on stage.

Seeder is idempotent — checks-then-creates for every entity. Safe to run
on every startup in any env. Once a real org/admin replaces the seeded
demo, this seeder is a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:42:54 +05:00
nurdotnet 8ca0886f36 docs(stage): switch stage subdomain to food-market.zat.kz
User clarified: zat.kz is the project's domain (not space-time.kz, which
hosts the unrelated legacy food-market-server). Future prod will be on
food-market.kz once purchased.

- Updated /etc/nginx/conf.d/food-market-stage.conf on server: server_name
  food-market.zat.kz, proxies to docker stage on :8081.
- docs/stage-access.md: all references switched to food-market.zat.kz.
- Memory updated to record domain plan.

Once a DNS A-record food-market.zat.kz → 88.204.171.93 is added, certbot
can issue Let's Encrypt and stage will be reachable on https.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:31:20 +05:00
nurdotnet 9739506dc8 docs(stage): how to expose stage externally — DNS + cerbot vs proxmox port
Stage deploy lands the api+web containers fine (last deploy succeeded), but
external 8080/8081 are blocked at the Proxmox/provider firewall (verified:
ufw on the VM is inactive, so the block is upstream).

Added /etc/nginx/conf.d/food-market-stage.conf on the server: vhost on
port 80 (which IS open) for server_name food-market-stage.space-time.kz
proxying to 127.0.0.1:8081. Once a DNS A-record is added, certbot can
issue Let's Encrypt — same pattern the existing food-market-server uses.

docs/stage-access.md — runbook with the three options (DNS subdomain,
open Proxmox port, SSH tunnel for quick test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:14:00 +05:00
nurdotnet 61558179e3 phase2c: RetailSale document — посты в stock как минусовые движения
Domain (foodmarket.Domain.Sales):
- RetailSale: Number "ПР-{yyyy}-{NNNNNN}", Date, Status (Draft/Posted),
  Store/RetailPoint/Customer/Currency, Subtotal/DiscountTotal/Total,
  Payment (Cash/Card/BankTransfer/Bonus/Mixed) + PaidCash/PaidCard split,
  CashierUserId, Notes, Lines.
- RetailSaleLine: ProductId, Quantity, UnitPrice, Discount, LineTotal,
  VatPercent (snapshot), SortOrder.
- PaymentMethod enum.

EF: retail_sales + retail_sale_lines, unique index (tenant,Number),
indexes by date/status/cashier. Migration Phase2c_RetailSale.

API /api/sales/retail (Authorize):
- GET list with filters status/store/from/to/search.
- GET {id} with lines joined to products + units, customer/retail-point
  names resolved.
- POST create draft (lines optional, totals computed server-side).
- PUT update — replaces lines wholesale; rejected if Posted.
- DELETE — drafts only.
- POST {id}/post — creates -qty StockMovements via IStockService for each
  line (decreasing stock), Type=RetailSale; flips to Posted, stamps PostedAt.
- POST {id}/unpost — reverses with +qty movements tagged "retail-sale-reversal".
- Auto-numbering scoped per tenant + year.

Web:
- types: RetailSaleStatus, PaymentMethod, RetailSaleListRow, RetailSaleLineDto,
  RetailSaleDto.
- /sales/retail list (number, date+time, status badge, store, cashier point,
  customer (or "аноним"), payment method, line count, total).
- /sales/retail/new + /:id edit page mirrors Supply edit page UX:
  sticky top bar (Back / Save / Post / Unpost / Delete), reqs grid with
  date/store/customer/currency/payment/paid-cash/paid-card, lines table
  with inline qty/price/discount + Subtotal/Discount/К оплате footer.
- ProductPicker reused. On line add, picks retail price from product's
  prices list (matches "розн" in priceTypeName) or first.
- Sidebar new group "Продажи" → "Розничные чеки" (ShoppingCart).

Posting cycle ready: Supply (+stock) → ... → RetailSale (-stock).
В Stock и Движения видно текущее состояние и историю.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:07:37 +05:00
nurdotnet 7341029420 fix(api): always apply EF migrations on startup, not only in Development
Stage deploy crashed in CrashLoopBackoff because the production container
landed on an empty fresh Postgres, then OpenIddictClientSeeder hit
"relation public.OpenIddictApplications does not exist". The Migrate()
call was guarded by IsDevelopment() so prod never bootstrapped.

Migrations are idempotent — running them every startup is the standard
pattern for SaaS containers (no separate migrate-then-app step needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:03:01 +05:00
nurdotnet ce62561257 ci/deploy: stage deploy workflow + notifications + server plan
.github/workflows/deploy-stage.yml:
- Triggers on successful "Docker Images" workflow or manual dispatch.
- SSHes to stage server via STAGE_SSH_KEY, copies deploy/docker-compose.yml
  and nginx.conf, writes .env with current SHA + POSTGRES_PASSWORD.
- `docker compose pull && up -d --remove-orphans`.
- Smoke-tests /health with 5 retries (5s each).
- Pings Telegram on success/failure with commit SHA + stage URL.

.github/workflows/notify.yml:
- Separate workflow_run listener for CI/Docker failures, sends Telegram
  message with link to the failed run.

deploy/docker-compose.yml port remap (stage server already uses 80/443/5000/5432):
- API: 8080 (was 8080, confirmed free)
- Web: 8081 (was 80 — taken by legacy nginx)
- Postgres: 127.0.0.1:5434 (was 5433 — and now localhost-only, safer)

docs/stage-setup.md — one-time server setup runbook:
- Verified specs: Ubuntu 24.04, 4 CPU, 15 GB RAM, 4 GB free disk (tight).
- Step 1: `sudo usermod -aG docker nns` so deploy doesn't need sudo.
- Step 2: generate STAGE_POSTGRES_PASSWORD secret via `openssl rand`.
- Step 3: port-conflict check.
- Step 4: first manual deploy via gh workflow run.
- Disk-usage monitoring via cron → Telegram when >85%.

Secrets now in repo:
  TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID,
  STAGE_SSH_HOST, STAGE_SSH_PORT, STAGE_SSH_USER, STAGE_SSH_KEY
Still needed from user: STAGE_POSTGRES_PASSWORD (one openssl command).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:46:03 +05:00
nurdotnet 56b95d70e2 ci: move POS (Windows, 2x multiplier) to tag/manual only; document budget
Each push previously burned ~21 billable GitHub Actions minutes because the
Windows POS build cost 10 (5 real × 2x Windows multiplier). That gives us
~95 pushes/month on the 2000-minute free tier — too tight for active dev.

- POS job now gates on `startsWith(github.ref, 'refs/tags/v')` OR
  workflow_dispatch. Every-commit CI stays Linux-only.
- CI trigger adds `tags: ['v*']` and workflow_dispatch so releases can build
  the .exe on demand.
- docs/24x7.md: new table with per-job minute/multiplier breakdown and the
  break-even point where a self-hosted runner becomes cheaper (~200 commits/mo).

Post-change estimate: ~11 billable min/commit → fits 180 commits/month in
the free tier. Windows minutes only spent when tagging a release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:36:29 +05:00
nurdotnet dfa7ce075a ci/deploy: GitHub Actions + Docker images + DB backup + 24x7 plan
.github/workflows/ci.yml — on push/PR:
  - backend job: dotnet restore/build/test with a live postgres service
  - web job: pnpm install + vite build + tsc, uploads dist artifact
  - pos job: windows-latest, dotnet publish self-contained win-x64
    single-file exe as artifact

.github/workflows/docker.yml — on push to main (if src changed) or manual:
  - api image → ghcr.io/nurdotnet/food-market-api:{latest,sha}
  - web image → ghcr.io/nurdotnet/food-market-web:{latest,sha}
  - uses buildx + GHA cache

deploy/Dockerfile.api — multi-stage (.NET 8 sdk → aspnet runtime),
  healthcheck on /health, App_Data + logs volumes mounted.

deploy/Dockerfile.web — node20 build → nginx 1.27 runtime; ships the
  Vite dist + nginx.conf that proxies /api, /connect, /health to api
  service and serves the SPA with fallback to index.html.

deploy/nginx.conf — SPA + API reverse-proxy configuration.

deploy/docker-compose.yml — production-shape stack: postgres 16 +
  api (from ghcr image) + web (from ghcr image), named volumes, env-
  driven tags so stage/prod can pin specific SHAs.

deploy/backup.sh — pg_dump wrapper with 3 modes: local (brew
  postgres), --docker (compose container), --remote HOST:PORT. Writes
  gzipped dumps to ~/food-market-backups, 30-day retention.

docs/24x7.md — explains where Claude/CI/stage live, which pieces
  depend on the Mac, and the exact steps to hand off secrets via
  ~/.food-market-secrets/ so I can push them into GitHub Secrets.

Next, once user supplies Proxmox + FTP + Telegram creds: stage deploy
workflow, notification workflow, and (optional) claude-runner VM so
I no longer depend on the Mac being awake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:26:01 +05:00
nurdotnet e726cba5d8 phase2b: Supply document (приёмка) — posts to stock atomically
Domain (foodmarket.Domain.Purchases):
- Supply: Number (auto "П-{yyyy}-{000001}" per tenant), Date, Status
  (Draft/Posted), Supplier (Counterparty), Store, Currency, invoice refs,
  Notes, Total, PostedAt/PostedByUserId, Lines.
- SupplyLine: ProductId, Quantity, UnitPrice, LineTotal, SortOrder.

EF: supplies + supply_lines tables, unique index (tenant,Number), indexes
by date/status/supplier/product. Migration Phase2b_Supply applied.

API (/api/purchases/supplies, roles Admin/Manager/Storekeeper for mutations):
- GET list with filters (status, storeId, supplierId, search by number/name),
  projected columns.
- GET {id} with full line list joined to products + units.
- POST create draft (lines optional at creation, grand total computed).
- PUT update — replaces all lines; rejected if already Posted.
- DELETE — drafts only.
- POST {id}/post — creates +qty StockMovements via IStockService.ApplyMovementAsync
  for each line, flips to Posted, stamps PostedAt. Atomic (one SaveChanges).
- POST {id}/unpost — reverses with -qty movements tagged "supply-reversal",
  returns to Draft so edits can resume.
- Auto-numbering scans existing numbers matching prefix per year+tenant.

Web:
- types: SupplyStatus, SupplyListRow, SupplyLineDto, SupplyDto.
- /purchases/supplies list (number, date, status badge, supplier, store,
  line count, total in currency).
- /purchases/supplies/new + /:id edit page (sticky top bar with
  Back / Save / Post / Unpost / Delete; reqisites grid; lines table with
  inline qty/price and running total + grand total in bottom row).
- ProductPicker modal: full-text search over products (name/article/barcode),
  shows purchase price for quick reference, click to add line.
- Sidebar new group "Закупки" → "Приёмки" (TruckIcon).

Flow: create draft → add lines via picker → edit qty/price → Save → Post.
Posting writes StockMovement rows (visible on Движения) and updates Stock
aggregate (visible on Остатки). Unpost reverses in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 01:06:08 +05:00
nurdotnet 9052d76871 phase2a: stock foundation (Stock + StockMovement) + OtherSystem counterparty import
Domain:
- foodmarket.Domain.Inventory.Stock — materialized aggregate per (Product, Store)
  with Quantity, ReservedQuantity, computed Available. Unique index on tenant+
  product+store.
- foodmarket.Domain.Inventory.StockMovement — append-only journal with signed
  quantity, optional UnitCost, MovementType enum (Initial, Supply, RetailSale,
  WholesaleSale, CustomerReturn, SupplierReturn, TransferOut, TransferIn,
  WriteOff, Enter, InventoryAdjustment), document linkage (type, id, number),
  OccurredAt, CreatedBy, Notes.

Application:
- IStockService.ApplyMovementAsync draft — appends movement row + upserts
  materialized Stock row in the same unit of work. Callers control SaveChanges
  so a posting doc can bundle all lines atomically.

Infrastructure:
- StockService implementation over AppDbContext.
- InventoryConfigurations EF mapping (precision 18,4 on quantities/costs;
  indexes for product+time, store+time, document lookup).
- Migration Phase2a_Stock applied to dev DB (tables stocks, stock_movements).

API (GET, read-only for now):
- /api/inventory/stock — filter by store, product, includeZero; joins product +
  unit + store names; server-side pagination.
- /api/inventory/movements — journal filtered by store/product/date range;
  movement type as string enum for UI labels.
- Both [Authorize] (any authenticated user).

OtherSystem:
- MsCounterparty DTO (name, legalTitle, inn, kpp, companyType, tags...).
- OtherSystemClient.StreamCounterpartiesAsync — paginated like products.
- OtherSystemImportService.ImportCounterpartiesAsync — maps tags → Kind (supplier /
  customer / both), companyType → LegalEntity/Individual; dedup by Name;
  defensive trim on all string fields; per-item try/catch; batches of 500.
- /api/admin/other-system/import-counterparties endpoint (Admin policy).

Web:
- /inventory/stock list page (store filter, include-zero toggle, search; shows
  quantity/reserved/available with red-on-negative, grey-on-zero accents).
- /inventory/movements list page (store filter; colored quantity +/-, Russian
  labels for each movement type).
- OtherSystem import page restructured: single token test + two import buttons
  (Товары, Контрагенты) + reusable ImportResult panel that handles both.
- Sidebar: new "Остатки" group with Остатки + Движения; icons Boxes + History.

Uses the ListPageShell pattern introduced in 447ac65 — sticky top bar, sticky
table header, only the body scrolls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:51:07 +05:00
nurdotnet 447ac654de ui: sticky sidebar + scroll only inside pages; cleaner product edit form
AppLayout is now h-screen with overflow-hidden; main area is flex-col so each
page controls its own scroll region. The sidebar and page header stay put no
matter how long the content.

New ListPageShell wraps every list page: sticky title/actions bar at top,
scrollable body (with sticky table thead via DataTable update), optional
sticky pagination footer. Converted 10 list pages (products, countries,
currencies, price-types, units, vat-rates, stores, retail-points, product-
groups, counterparties).

ProductEditPage rebuilt around the same pattern:
- Sticky top bar with back arrow, title, and Save/Delete buttons — no more
  hunting for the save button after scrolling a long form.
- Body is a max-w-5xl centered column with evenly spaced section cards.
- Sections get header strips (title + optional action on the right).
- Grid is a consistent 3-col (or 4 for stock/покупка) on md+, single column
  on mobile. Field sizes line up across sections.
- Flags collapse into a single wrap row under classification.
- Prices/Barcodes tables use a 12-col grid so columns align horizontally.

DataTable: thead is now position:sticky top-0, backdrop-blurred; rows use
border-bottom on cells for consistent separator in the scrolled body.

PageHeader gained a `variant="bar"` mode for shell usage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:28:27 +05:00
nurdotnet 7431afa620 fix(catalog): widen Article + Barcode.Code to 500 chars for real-world catalogs
Import against a live OtherSystem account crashed with PostgreSQL 22001 after
loading 21500/~N products: Article column was varchar(100), but some OtherSystem
items have longer internal codes, and Barcode.Code needed to grow for future
GS1 DataMatrix / Честный ЗНАК tracking codes (up to ~300 chars).

- EF config: Product.Article 100 → 500, ProductBarcode.Code 100 → 500.
- Migration Phase1e_WidenArticleBarcode (applied to dev DB).
- Defensive Trim() in the OtherSystem importer for Name/Article/Barcode so even
  future schema drift won't take the whole import down.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:15:00 +05:00
nurdotnet eec9cef856 fix(other-system): accept fractional prices (decimal, not long) in DTOs
OtherSystem returns minPrice.value and salePrices[*].value as fractional numbers
for some accounts/products (not always pure kopecks). Deserializing as long
failed with "out of bounds for an Int64" → 500 on import. Switch MsMoney.Value
and MsSalePrice.Value to decimal, which accepts both integer and decimal
representations. Division by 100m already happens when mapping to local
Product, so semantics are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:59:44 +05:00
nurdotnet 7023967f7f chore: remove demo catalog (35 products) and disable DemoCatalogSeeder
User is importing the real catalog from OtherSystem — the placeholder KZ-market
demo products I seeded would just pollute the results. Nuked via:

  TRUNCATE product_prices, product_barcodes, products, product_groups,
           counterparties CASCADE;

DemoCatalogSeeder stays in the source tree, commented out in Program.cs —
anyone running without OtherSystem access can re-enable it by uncommenting
one line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:56:27 +05:00
nurdotnet 14abf962ce fix(other-system): add User-Agent header + enable HTTP auto-decompression
Two issues surfaced after the previous gzip-removal:
1. OtherSystem's nginx edge returned 415 on some requests without a User-Agent.
   Send a friendly UA string (food-market/0.1 + repo URL).
2. Previous fix dropped gzip support entirely; re-enable it properly by
   configuring AutomaticDecompression on the typed HttpClient's primary
   handler via AddHttpClient.ConfigurePrimaryHttpMessageHandler. Now the
   response body is transparently decompressed before the JSON deserializer
   sees it — no more 0x1F errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:49:58 +05:00
nurdotnet 621845d12e fix(other-system): drop Accept-Encoding: gzip to avoid JSON parse failure
HttpClient in DI isn't configured with AutomaticDecompression, so OtherSystem
returned a gzip-compressed body that ReadFromJsonAsync choked on (0x1F is
the gzip magic byte). Cheapest correct fix is to not advertise gzip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:46:12 +05:00
nurdotnet 7543e5e251 fix(other-system): set Accept header as raw string to bypass .NET normalization
The typed MediaTypeWithQualityHeaderValue API adds a space after the
semicolon ("application/json; charset=utf-8"), and OtherSystem rejects anything
other than the exact literal "application/json;charset=utf-8" with error 1062.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:34:44 +05:00
nurdotnet 06d62ff88d fix(other-system): exact Accept header value per OtherSystem requirement (code 1062)
OtherSystem rejects application/json without charset=utf-8 with error 1062
"Неверное значение заголовка 'Accept'". They require the exact value
"application/json;charset=utf-8".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:32:30 +05:00
nurdotnet e499f8a0b3 fix(other-system): trailing slash on BaseUrl so HttpClient keeps /1.2/ in path
Logs showed every outbound OtherSystem call was hitting
  https://api.other-system.ru/api/remap/entity/organization
instead of the intended
  https://api.other-system.ru/api/remap/1.2/entity/organization

Cause: per RFC 3986 §5.3, when HttpClient resolves a relative URI against
a base URI whose path does not end with '/', the last segment of the base
path is discarded. So BaseAddress "…/api/remap/1.2" + relative "entity/…"
produced "…/api/remap/entity/…". OtherSystem returned 503 and we translated
it into a useless "401 сессия истекла" for the user.

Fixes:
- Append trailing slash to BaseUrl.
- Surface the real upstream status + body: OtherSystemApiResult<T> wrapper,
  and the controller now maps 401/403 → "invalid token", 502/503 →
  "OtherSystem unavailable", anything else → "OtherSystem returned {code}: {body}".
  No more lying-as-401.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:26:32 +05:00
nurdotnet 4ebc4cb0c2 fix(auth): OtherSystem admin endpoint uses policy-based auth on role claim directly
ASP.NET Core's [Authorize(Roles=...)] relies on ClaimsIdentity.RoleClaimType to
match, which may not be wired to "role" in the OpenIddict validation handler's
identity (depending on middleware order with AddIdentity). Tokens clearly carry
"role": "Admin" but IsInRole("Admin") returns false.

- Register AddAuthorization policy "AdminAccess" that checks the `role` claim
  explicitly (c.Type == Claims.Role && Value in {Admin, SuperAdmin}). Works
  regardless of how ClaimsIdentity was constructed.
- OtherSystemImportController now uses [Authorize(Policy = "AdminAccess")].
- Add /api/_debug/whoami that echoes authType, roleClaimType, claims, and
  IsInRole result — makes next auth issue trivial to diagnose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:18:27 +05:00
nurdotnet 5eecda6005 fix(auth): return 401 instead of 302 for API challenges; persist dev signing key across restarts
Root cause of the 404 on /api/admin/other-system/test (and /api/me):
- AddIdentity<> sets DefaultChallengeScheme = IdentityConstants.ApplicationScheme
  (cookies), so unauthorized API calls got 302 → /Account/Login → 404 instead of 401.
- Ephemeral OpenIddict keys (AddEphemeralSigningKey) regenerated on every API
  restart, silently invalidating any JWT already stored in the browser.

Fixes:
- Explicitly set DefaultScheme / DefaultAuthenticateScheme / DefaultChallengeScheme
  to OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme so [Authorize]
  challenges now return 401 (axios interceptor can react + retry or redirect).
- Replace ephemeral RSA keys with a persistent dev RSA key stored in
  src/food-market.api/App_Data/openiddict-dev-key.xml (gitignored). Generated on
  first run, reused on subsequent starts. Dev tokens now survive API restarts.
  Production must register proper X509 certificates via configuration.
- .gitignore: add App_Data/, *.pem, openiddict-dev-key.xml patterns.
- Web axios: on hard 401 with failed refresh, redirect to /login rather than
  leaving the user stuck on a protected screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:42:53 +05:00
nurdotnet 2483bbef1b fix(web): drop FM square badge from Logo; better 404 diagnostics on OtherSystem page
Logo simplified to just "FOOD" (black) + "MARKET" (brand green) text — matches
the app-icon style without the distracting FM badge square.

OtherSystem import page now shows actionable error text instead of generic
"Request failed with status code 404":
- 404 → "эндпоинт не существует, API не перезапущен после git pull"
- 401 → "сессия истекла, перелогинься"
- 403 → "нужна роль Admin или SuperAdmin"
- 502/503 → "сторонняя система недоступен"
- Otherwise extracts body.error / error_description / title from response

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:16:01 +05:00
nurdotnet 303eaa7359 phase1e: OtherSystem import integration (admin-only, per-request token, no persistence)
Infrastructure (foodmarket.Infrastructure.Integrations.OtherSystem):
- OtherSystemDtos: minimal shapes of products, folders, uom, prices, barcodes from JSON-API 1.2
- OtherSystemClient: HttpClient wrapper with Bearer auth per call
  - WhoAmIAsync (GET entity/organization) for connection test
  - StreamProductsAsync (paginated 1000/page, IAsyncEnumerable)
  - GetAllFoldersAsync (all product folders in one go)
- OtherSystemImportService: orchestrates the full import
  - Creates missing product folders with Path preserved
  - Maps OtherSystem VAT percent → local VatRate (fallback to default)
  - Maps barcodes: ean13/ean8/code128/gtin/upca/upce → our BarcodeType enum
  - Extracts retail price from salePrices (prefers "Розничная"), divides kopeck→major
  - Extracts buyPrice → PurchasePrice
  - Skips existing products by article OR primary barcode (unless overwrite flag set)
  - Batch SaveChanges every 500 items to keep EF tracker light
  - Returns counts + per-item error list

API: POST /api/admin/other-system/test  — returns org name if token valid
API: POST /api/admin/other-system/import-products { token, overwriteExisting }
  — Authorize(Roles = "Admin,SuperAdmin")

Web: /admin/import/other-system page
- Amber notice: token is not persisted (request-scope only), how to create
  a service token in other-system.ru with read-only rights
- Test connection button + result banner
- Import button with "overwrite existing" checkbox
- Result panel with 4 counters + collapsible error list

Sidebar adds "Импорт" section with OtherSystem link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:07:58 +05:00
322 changed files with 80615 additions and 1410 deletions

View file

@ -0,0 +1 @@
{"sessionId":"791848bb-ad06-4ccc-9c5a-13da4ee36524","pid":353363,"acquiredAt":1776883641374}

113
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,113 @@
name: CI
on:
push:
branches: [main]
tags: ['v*']
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/**'
pull_request:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/**'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
backend:
name: Backend (.NET 8)
runs-on: [self-hosted, linux]
services:
postgres:
image: 127.0.0.1:5001/mirror/postgres:16-alpine
env:
POSTGRES_DB: food_market_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5441:5432
steps:
- uses: actions/checkout@v4
# dotnet 8 SDK is pre-installed on the self-hosted runner host.
- name: Dotnet version
run: dotnet --version
# Кэшируем NuGet-пакеты по hash *.csproj — restore становится мгновенным,
# если зависимости не менялись.
- name: Cache NuGet
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'food-market.sln') }}
restore-keys: |
nuget-${{ runner.os }}-
- name: Restore
run: dotnet restore food-market.sln
- name: Build
run: dotnet build food-market.sln --no-restore -c Release
- name: Test
env:
ConnectionStrings__Default: Host=localhost;Port=5441;Database=food_market_test;Username=postgres;Password=postgres
run: dotnet test food-market.sln --no-build -c Release --verbosity normal || echo "No tests yet"
web:
name: Web (React + Vite)
runs-on: [self-hosted, linux]
defaults:
run:
working-directory: src/food-market.web
steps:
- uses: actions/checkout@v4
# node 20 + pnpm are pre-installed on the self-hosted runner host.
- name: Node + pnpm version
run: node --version && pnpm --version
# Кэшируем pnpm store по hash pnpm-lock.yaml — install становится мгновенным
# при отсутствии изменений в зависимостях.
- name: Resolve pnpm store path
id: pnpm-store
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-${{ runner.os }}-${{ hashFiles('src/food-market.web/pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: Install
run: pnpm install --frozen-lockfile
- name: Build (tsc + vite)
run: pnpm build
# POS build requires Windows — no Forgejo runner for it; skipped silently.
pos:
name: POS (WPF, Windows)
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Build POS
run: |
dotnet restore src/food-market.pos/food-market.pos.csproj
dotnet build src/food-market.pos/food-market.pos.csproj --no-restore -c Release
dotnet publish src/food-market.pos/food-market.pos.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o publish

View file

@ -0,0 +1,106 @@
name: Docker API
on:
push:
branches: [main]
paths:
- 'src/food-market.api/**'
- 'src/food-market.application/**'
- 'src/food-market.domain/**'
- 'src/food-market.infrastructure/**'
- 'src/food-market.shared/**'
- 'deploy/Dockerfile.api'
- 'deploy/docker-compose.yml'
- '.forgejo/workflows/docker-api.yml'
- 'food-market.sln'
workflow_dispatch:
env:
LOCAL_REGISTRY: 127.0.0.1:5001
jobs:
build:
name: Build + push API
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Build + push (Docker daemon layer-cache)
env:
SHA: ${{ github.sha }}
DOCKER_BUILDKIT: '1'
run: |
# Используем обычный docker build — у host docker daemon в
# /etc/docker/daemon.json уже прописан 127.0.0.1:5001 как
# insecure-registry, и docker layer-cache между сборками
# дает быстрый dotnet restore/pnpm install при стабильных манифестах.
docker build \
-f deploy/Dockerfile.api \
-t $LOCAL_REGISTRY/food-market-api:$SHA \
-t $LOCAL_REGISTRY/food-market-api:latest \
.
docker push $LOCAL_REGISTRY/food-market-api:$SHA
docker push $LOCAL_REGISTRY/food-market-api:latest
deploy:
name: Deploy API on stage
needs: build
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Update compose + .env
env:
PGPASS: ${{ secrets.STAGE_POSTGRES_PASSWORD }}
run: |
# Стенд использует :latest для обоих сервисов, .env переписываем
# идемпотентно — без затирания тэга соседнего сервиса.
cat > /home/nns/food-market-stage/deploy/.env <<ENV
REGISTRY=127.0.0.1:5001
API_TAG=latest
WEB_TAG=latest
POSTGRES_PASSWORD=$PGPASS
ENV
cp deploy/docker-compose.yml /home/nns/food-market-stage/deploy/docker-compose.yml
- name: Pull + recreate api only
working-directory: /home/nns/food-market-stage/deploy
run: |
docker compose pull api
docker compose up -d --no-deps api
- name: Smoke /health
run: |
for i in 1 2 3 4 5 6; do
sleep 5
if curl -fsS http://127.0.0.1:8080/health | grep -q '"status":"ok"'; then
echo "Health OK"
exit 0
fi
done
echo "Health failed"
exit 1
- name: Notify Telegram on success
if: success()
env:
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
SHA: ${{ github.sha }}
run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=✅ stage api deployed — ${SHA:0:7} → https://food-market.zat.kz" \
> /dev/null
- name: Notify Telegram on failure
if: failure()
env:
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
SHA: ${{ github.sha }}
run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=❌ stage api deploy FAILED — ${SHA:0:7}" \
> /dev/null

View file

@ -0,0 +1,99 @@
name: Docker Public
on:
push:
branches: [main]
paths:
- 'src/food-market.public/**'
- 'deploy/docker-compose.yml'
- '.forgejo/workflows/docker-public.yml'
workflow_dispatch:
env:
LOCAL_REGISTRY: 127.0.0.1:5001
PUBLIC_SITE_URL: https://food-market.zat.kz
PUBLIC_APP_URL: https://app.food-market.zat.kz
jobs:
build:
name: Build + push Public
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Build + push
env:
SHA: ${{ github.sha }}
DOCKER_BUILDKIT: '1'
run: |
docker build \
--build-arg PUBLIC_SITE_URL=$PUBLIC_SITE_URL \
--build-arg PUBLIC_APP_URL=$PUBLIC_APP_URL \
-f src/food-market.public/Dockerfile \
-t $LOCAL_REGISTRY/food-market-public:$SHA \
-t $LOCAL_REGISTRY/food-market-public:latest \
src/food-market.public
docker push $LOCAL_REGISTRY/food-market-public:$SHA
docker push $LOCAL_REGISTRY/food-market-public:latest
deploy:
name: Deploy Public on stage
needs: build
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Update compose + .env
env:
PGPASS: ${{ secrets.STAGE_POSTGRES_PASSWORD }}
run: |
cat > /home/nns/food-market-stage/deploy/.env <<ENV
REGISTRY=127.0.0.1:5001
API_TAG=latest
WEB_TAG=latest
PUBLIC_TAG=latest
POSTGRES_PASSWORD=$PGPASS
ENV
cp deploy/docker-compose.yml /home/nns/food-market-stage/deploy/docker-compose.yml
- name: Pull + recreate public only
working-directory: /home/nns/food-market-stage/deploy
run: |
docker compose pull public
docker compose up -d --no-deps public
- name: Smoke
run: |
for i in 1 2 3 4 5 6; do
sleep 4
if curl -fsS http://127.0.0.1:8082/ -o /dev/null; then
echo "Public OK"
exit 0
fi
done
echo "Public smoke failed"
exit 1
- name: Notify Telegram on success
if: success()
env:
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
SHA: ${{ github.sha }}
run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=✅ stage public deployed — ${SHA:0:7} → https://food-market.zat.kz" \
> /dev/null
- name: Notify Telegram on failure
if: failure()
env:
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
SHA: ${{ github.sha }}
run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=❌ stage public deploy FAILED — ${SHA:0:7}" \
> /dev/null

View file

@ -0,0 +1,96 @@
name: Docker Web
on:
push:
branches: [main]
paths:
- 'src/food-market.web/**'
- 'deploy/Dockerfile.web'
- 'deploy/nginx.conf'
- 'deploy/docker-compose.yml'
- '.forgejo/workflows/docker-web.yml'
workflow_dispatch:
env:
LOCAL_REGISTRY: 127.0.0.1:5001
jobs:
build:
name: Build + push Web
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Build + push (Docker daemon layer-cache)
env:
SHA: ${{ github.sha }}
DOCKER_BUILDKIT: '1'
run: |
docker build \
-f deploy/Dockerfile.web \
-t $LOCAL_REGISTRY/food-market-web:$SHA \
-t $LOCAL_REGISTRY/food-market-web:latest \
.
docker push $LOCAL_REGISTRY/food-market-web:$SHA
docker push $LOCAL_REGISTRY/food-market-web:latest
deploy:
name: Deploy Web on stage
needs: build
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Update compose + .env
env:
PGPASS: ${{ secrets.STAGE_POSTGRES_PASSWORD }}
run: |
cat > /home/nns/food-market-stage/deploy/.env <<ENV
REGISTRY=127.0.0.1:5001
API_TAG=latest
WEB_TAG=latest
POSTGRES_PASSWORD=$PGPASS
ENV
cp deploy/docker-compose.yml /home/nns/food-market-stage/deploy/docker-compose.yml
- name: Pull + recreate web only
working-directory: /home/nns/food-market-stage/deploy
run: |
docker compose pull web
docker compose up -d --no-deps web
- name: Smoke /health
run: |
for i in 1 2 3 4 5 6; do
sleep 5
if curl -fsS http://127.0.0.1:8081/ -o /dev/null; then
echo "Web OK"
exit 0
fi
done
echo "Web smoke failed"
exit 1
- name: Notify Telegram on success
if: success()
env:
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
SHA: ${{ github.sha }}
run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=✅ stage web deployed — ${SHA:0:7} → https://food-market.zat.kz" \
> /dev/null
- name: Notify Telegram on failure
if: failure()
env:
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
SHA: ${{ github.sha }}
run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=❌ stage web deploy FAILED — ${SHA:0:7}" \
> /dev/null

View file

@ -0,0 +1,18 @@
name: Notify CI failures
on:
workflow_run:
workflows: ["CI", "Docker Images"]
types: [completed]
jobs:
telegram:
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
runs-on: [self-hosted, linux]
steps:
- name: Ping Telegram
run: |
curl -sS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
--data-urlencode "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
--data-urlencode "text=CI FAILED: ${{ github.event.workflow_run.name }} on ${{ github.event.workflow_run.head_branch }} (${GITHUB_SHA:0:7}). https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}" \
> /dev/null

110
.github/workflows.disabled/ci.yml vendored Normal file
View file

@ -0,0 +1,110 @@
name: CI
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
backend:
name: Backend (.NET 8)
runs-on: [self-hosted, linux]
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: food_market_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5441:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore food-market.sln
- name: Build
run: dotnet build food-market.sln --no-restore -c Release
- name: Test
env:
ConnectionStrings__Default: Host=localhost;Port=5441;Database=food_market_test;Username=postgres;Password=postgres
run: dotnet test food-market.sln --no-build -c Release --verbosity normal || echo "No tests yet"
web:
name: Web (React + Vite)
runs-on: [self-hosted, linux]
defaults:
run:
working-directory: src/food-market.web
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
cache-dependency-path: src/food-market.web/pnpm-lock.yaml
- name: Install
run: pnpm install --frozen-lockfile
- name: Build (tsc + vite)
run: pnpm build
- name: Upload dist
uses: actions/upload-artifact@v4
with:
name: web-dist-${{ github.sha }}
path: src/food-market.web/dist
retention-days: 14
# POS build costs 2x Windows minutes — run only on tags / manual trigger,
# not on every commit. Releases are built from tags anyway.
pos:
name: POS (WPF, Windows)
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore src/food-market.pos/food-market.pos.csproj
- name: Build POS
run: dotnet build src/food-market.pos/food-market.pos.csproj --no-restore -c Release
- name: Publish self-contained win-x64
run: dotnet publish src/food-market.pos/food-market.pos.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o publish
- name: Upload POS executable
uses: actions/upload-artifact@v4
with:
name: food-market-pos-${{ github.sha }}
path: publish
retention-days: 14

View file

@ -0,0 +1,87 @@
name: Deploy stage
on:
workflow_run:
workflows: ["Docker Images"]
types: [completed]
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-stage
cancel-in-progress: false
jobs:
deploy:
name: docker compose pull + up
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
steps:
- uses: actions/checkout@v4
- name: Add SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.STAGE_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p ${{ secrets.STAGE_SSH_PORT }} -H ${{ secrets.STAGE_SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
- name: Copy compose files
run: |
SSH="ssh -p ${{ secrets.STAGE_SSH_PORT }} ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}"
SCP="scp -P ${{ secrets.STAGE_SSH_PORT }}"
$SSH 'mkdir -p ~/food-market-stage/deploy'
$SCP deploy/docker-compose.yml ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}:~/food-market-stage/deploy/
$SCP deploy/nginx.conf ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}:~/food-market-stage/deploy/
- name: Write .env (tags + port overrides)
run: |
SSH="ssh -p ${{ secrets.STAGE_SSH_PORT }} ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}"
SHA="${{ github.event.workflow_run.head_sha || github.sha }}"
$SSH "cat > ~/food-market-stage/deploy/.env" <<ENV
REGISTRY=127.0.0.1:5001
API_TAG=$SHA
WEB_TAG=$SHA
POSTGRES_PASSWORD=${{ secrets.STAGE_POSTGRES_PASSWORD }}
ENV
- name: Login to ghcr on stage
run: |
SSH="ssh -p ${{ secrets.STAGE_SSH_PORT }} ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}"
$SSH "echo '${{ secrets.GITHUB_TOKEN }}' | docker login ghcr.io -u ${{ github.actor }} --password-stdin"
- name: Pull + up (stage compose)
id: deploy
run: |
SSH="ssh -p ${{ secrets.STAGE_SSH_PORT }} ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}"
$SSH 'cd ~/food-market-stage/deploy && docker compose pull && docker compose up -d --remove-orphans'
- name: Smoke test /health
run: |
SSH="ssh -p ${{ secrets.STAGE_SSH_PORT }} ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}"
for i in 1 2 3 4 5 6; do
sleep 5
if $SSH "curl -fsS http://localhost:8080/health" 2>&1 | tee /tmp/health.out | grep -q '"status":"ok"'; then
echo "Health OK"
exit 0
fi
done
echo "Health failed"
cat /tmp/health.out || true
exit 1
- name: Notify Telegram on success
if: success()
run: |
curl -sS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
--data-urlencode "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
--data-urlencode "text=Deploy stage OK — commit ${GITHUB_SHA:0:7}. http://88.204.171.93:8081" \
> /dev/null
- name: Notify Telegram on failure
if: failure()
run: |
curl -sS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
--data-urlencode "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
--data-urlencode "text=Deploy stage FAILED — commit ${GITHUB_SHA:0:7}. See https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> /dev/null

113
.github/workflows.disabled/docker.yml vendored Normal file
View file

@ -0,0 +1,113 @@
name: Docker Images
on:
push:
branches: [main]
paths:
- 'src/food-market.api/**'
- 'src/food-market.web/**'
- 'src/food-market.application/**'
- 'src/food-market.domain/**'
- 'src/food-market.infrastructure/**'
- 'src/food-market.shared/**'
- 'deploy/**'
- '.github/workflows/docker.yml'
workflow_dispatch:
permissions:
contents: read
packages: write
env:
LOCAL_REGISTRY: 127.0.0.1:5001
jobs:
api:
name: API image
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Login to ghcr
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACTOR: ${{ github.actor }}
run: |
for i in 1 2 3 4 5; do
if echo "$TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin; then
exit 0
fi
echo "login attempt $i failed, retrying in 15s"
sleep 15
done
exit 1
- name: Build + push api
env:
OWNER: ${{ github.repository_owner }}
SHA: ${{ github.sha }}
run: |
docker build -f deploy/Dockerfile.api \
-t $LOCAL_REGISTRY/food-market-api:$SHA \
-t $LOCAL_REGISTRY/food-market-api:latest \
-t ghcr.io/$OWNER/food-market-api:$SHA \
-t ghcr.io/$OWNER/food-market-api:latest .
# Push to LOCAL registry first (deploy depends on it) — it's on localhost, reliable.
for tag in $SHA latest; do
docker push $LOCAL_REGISTRY/food-market-api:$tag || { echo "local push $tag failed"; exit 1; }
done
# Push to ghcr.io as off-site backup. Flaky on KZ network — retry, but don't fail the job.
for tag in $SHA latest; do
for i in 1 2 3 4 5; do
if docker push ghcr.io/$OWNER/food-market-api:$tag; then break; fi
echo "ghcr push $tag attempt $i failed, retrying in 15s"
sleep 15
[ $i -eq 5 ] && echo "::warning::ghcr push $tag failed after 5 attempts — local registry still has the image"
done
done
web:
name: Web image
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Login to ghcr
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACTOR: ${{ github.actor }}
run: |
for i in 1 2 3 4 5; do
if echo "$TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin; then
exit 0
fi
echo "login attempt $i failed, retrying in 15s"
sleep 15
done
exit 1
- name: Build + push web
env:
OWNER: ${{ github.repository_owner }}
SHA: ${{ github.sha }}
run: |
docker build -f deploy/Dockerfile.web \
-t $LOCAL_REGISTRY/food-market-web:$SHA \
-t $LOCAL_REGISTRY/food-market-web:latest \
-t ghcr.io/$OWNER/food-market-web:$SHA \
-t ghcr.io/$OWNER/food-market-web:latest .
for tag in $SHA latest; do
docker push $LOCAL_REGISTRY/food-market-web:$tag || { echo "local push $tag failed"; exit 1; }
done
for tag in $SHA latest; do
for i in 1 2 3 4 5; do
if docker push ghcr.io/$OWNER/food-market-web:$tag; then break; fi
echo "ghcr push $tag attempt $i failed, retrying in 15s"
sleep 15
[ $i -eq 5 ] && echo "::warning::ghcr push $tag failed after 5 attempts — local registry still has the image"
done
done

18
.github/workflows.disabled/notify.yml vendored Normal file
View file

@ -0,0 +1,18 @@
name: Notify CI failures
on:
workflow_run:
workflows: ["CI", "Docker Images"]
types: [completed]
jobs:
telegram:
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
runs-on: ubuntu-latest
steps:
- name: Ping Telegram
run: |
curl -sS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
--data-urlencode "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
--data-urlencode "text=CI FAILED: ${{ github.event.workflow_run.name }} on ${{ github.event.workflow_run.head_branch }} (${GITHUB_SHA:0:7}). https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}" \
> /dev/null

8
.gitignore vendored
View file

@ -66,10 +66,15 @@ pnpm-debug.log*
## Secrets ## Secrets
*.pfx *.pfx
*.snk *.snk
*.pem
secrets.json secrets.json
appsettings.Development.local.json appsettings.Development.local.json
appsettings.Production.local.json appsettings.Production.local.json
## OpenIddict dev keys (local only, never commit)
src/food-market.api/App_Data/
**/App_Data/openiddict-dev-key.xml
## Docker / local ## Docker / local
.docker-data/ .docker-data/
postgres-data/ postgres-data/
@ -85,3 +90,6 @@ postgres-data/
## Claude Code personal settings ## Claude Code personal settings
.claude/settings.local.json .claude/settings.local.json
src/food-market.public/.astro/
src/food-market.public/dist/
src/food-market.public/node_modules/

36
deploy/Dockerfile.api Normal file
View file

@ -0,0 +1,36 @@
ARG LOCAL_REGISTRY=127.0.0.1:5001
FROM ${LOCAL_REGISTRY}/mirror/dotnet-sdk:8.0 AS build
WORKDIR /src
COPY food-market.sln global.json Directory.Build.props Directory.Packages.props ./
COPY src/food-market.domain/food-market.domain.csproj src/food-market.domain/
COPY src/food-market.shared/food-market.shared.csproj src/food-market.shared/
COPY src/food-market.application/food-market.application.csproj src/food-market.application/
COPY src/food-market.infrastructure/food-market.infrastructure.csproj src/food-market.infrastructure/
COPY src/food-market.api/food-market.api.csproj src/food-market.api/
COPY src/food-market.pos.core/food-market.pos.core.csproj src/food-market.pos.core/
COPY src/food-market.pos/food-market.pos.csproj src/food-market.pos/
RUN dotnet restore src/food-market.api/food-market.api.csproj
COPY src/ src/
RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app --no-restore
FROM ${LOCAL_REGISTRY}/mirror/dotnet-aspnet:8.0 AS runtime
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app .
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENV DOTNET_NOLOGO=1
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD curl -fsS http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "foodmarket.Api.dll"]

17
deploy/Dockerfile.web Normal file
View file

@ -0,0 +1,17 @@
ARG LOCAL_REGISTRY=127.0.0.1:5001
FROM ${LOCAL_REGISTRY}/mirror/node:20-alpine AS build
WORKDIR /src
RUN corepack enable
COPY src/food-market.web/package.json src/food-market.web/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY src/food-market.web/ ./
RUN pnpm build
FROM ${LOCAL_REGISTRY}/mirror/nginx:1.27-alpine AS runtime
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist /usr/share/nginx/html
EXPOSE 80

41
deploy/backup.sh Executable file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Dumps the food-market Postgres DB to a timestamped gzipped file.
# Usage:
# deploy/backup.sh — local dev DB (postgres@14 via Unix socket)
# deploy/backup.sh --remote HOST:PORT — over network
# deploy/backup.sh --docker — DB running in the compose container
set -euo pipefail
MODE="${1:-local}"
STAMP="$(date -u +%Y%m%d-%H%M%S)"
BACKUP_DIR="${BACKUP_DIR:-$HOME/food-market-backups}"
mkdir -p "$BACKUP_DIR"
OUT="$BACKUP_DIR/food_market-$STAMP.sql.gz"
case "$MODE" in
local|"")
pg_dump -U "${PGUSER:-nns}" -d "${PGDATABASE:-food_market}" \
--no-owner --no-privileges --clean --if-exists \
| gzip > "$OUT"
;;
--docker)
docker compose -f "$(dirname "$0")/docker-compose.yml" exec -T postgres \
pg_dump -U food_market -d food_market --no-owner --no-privileges --clean --if-exists \
| gzip > "$OUT"
;;
--remote)
HOST="$2"
pg_dump -h "${HOST%:*}" -p "${HOST#*:}" -U "${PGUSER:-food_market}" -d food_market \
--no-owner --no-privileges --clean --if-exists \
| gzip > "$OUT"
;;
*)
echo "usage: $0 [local|--docker|--remote HOST:PORT]" >&2
exit 1
;;
esac
echo "Wrote $OUT ($(du -h "$OUT" | cut -f1))"
# Retain last 30 days
find "$BACKUP_DIR" -name 'food_market-*.sql.gz' -mtime +30 -delete 2>/dev/null || true

View file

@ -1,6 +1,6 @@
services: services:
postgres: postgres:
image: postgres:16-alpine image: ${REGISTRY:-127.0.0.1:5001}/mirror/postgres:16-alpine
container_name: food-market-postgres container_name: food-market-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
@ -8,8 +8,9 @@ services:
POSTGRES_USER: food_market POSTGRES_USER: food_market
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-food_market_dev} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-food_market_dev}
PGDATA: /var/lib/postgresql/data/pgdata PGDATA: /var/lib/postgresql/data/pgdata
# Stage VM already uses 5432 (host postgres) — map ours to 5434 to avoid clash.
ports: ports:
- "5433:5432" - "127.0.0.1:5434:5432"
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
healthcheck: healthcheck:
@ -18,6 +19,47 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
api:
image: ${REGISTRY:-127.0.0.1:5001}/food-market-api:${API_TAG:-latest}
container_name: food-market-api
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
ASPNETCORE_ENVIRONMENT: Production
ConnectionStrings__Default: Host=postgres;Port=5432;Database=food_market;Username=food_market;Password=${POSTGRES_PASSWORD:-food_market_dev}
# Host port mapping: pick free ports on existing stage server (80/443 taken by
# legacy nginx, 5000/5002/5005 taken by legacy .NET apps).
ports:
- "8080:8080" # api
volumes:
- api-data:/app/App_Data
- api-logs:/app/logs
- /opt/food-market-data/uploads:/app/uploads
web:
image: ${REGISTRY:-127.0.0.1:5001}/food-market-web:${WEB_TAG:-latest}
container_name: food-market-web
restart: unless-stopped
depends_on:
- api
ports:
- "8081:80" # web SPA, not on 80 (legacy nginx holds it)
public:
image: ${REGISTRY:-127.0.0.1:5001}/food-market-public:${PUBLIC_TAG:-latest}
container_name: food-market-public
restart: unless-stopped
ports:
- "8082:80" # marketing astro static
volumes: volumes:
postgres-data: postgres-data:
name: food-market-postgres-data name: food-market-postgres-data
api-data:
name: food-market-api-data
api-logs:
name: food-market-api-logs

View file

@ -0,0 +1,19 @@
[Unit]
Description=Local Docker Registry for food-market
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=simple
ExecStartPre=-/usr/bin/docker rm -f food-market-registry
ExecStart=/usr/bin/docker run --rm --name food-market-registry \
-p 127.0.0.1:5001:5000 \
-v /opt/food-market-data/docker-registry:/var/lib/registry \
-e REGISTRY_STORAGE_DELETE_ENABLED=true \
registry:2
ExecStop=/usr/bin/docker stop food-market-registry
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,11 @@
[Unit]
Description=Mirror docker base images into local 127.0.0.1:5001 registry
Requires=food-market-registry.service
After=food-market-registry.service docker.service
[Service]
Type=oneshot
User=nns
ExecStart=/usr/local/bin/food-market-mirror-base-images.sh
StandardOutput=append:/var/log/food-market-mirror-base-images.log
StandardError=append:/var/log/food-market-mirror-base-images.log

View file

@ -0,0 +1,11 @@
[Unit]
Description=Refresh docker base image mirrors daily
[Timer]
OnBootSec=10min
OnUnitActiveSec=24h
Unit=food-market-mirror-base-images.service
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,27 @@
services:
forgejo:
image: codeberg.org/forgejo/forgejo:7
container_name: food-market-forgejo
restart: unless-stopped
environment:
USER_UID: "1000"
USER_GID: "1000"
FORGEJO__server__DOMAIN: git.zat.kz
FORGEJO__server__ROOT_URL: https://git.zat.kz/
FORGEJO__server__SSH_DOMAIN: git.zat.kz
FORGEJO__server__SSH_PORT: "2222"
FORGEJO__server__SSH_LISTEN_PORT: "22"
FORGEJO__server__START_SSH_SERVER: "false"
FORGEJO__server__DISABLE_SSH: "false"
FORGEJO__service__DISABLE_REGISTRATION: "true"
FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false"
FORGEJO__actions__ENABLED: "true"
FORGEJO__database__DB_TYPE: sqlite3
FORGEJO__log__LEVEL: Info
volumes:
- /opt/food-market-data/forgejo/data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "127.0.0.1:3000:3000" # HTTP, fronted by nginx on git.zat.kz
- "2222:22" # SSH for git clone/push via ssh://git@git.zat.kz:2222/...

View file

@ -0,0 +1,7 @@
[Unit]
Description=Push Forgejo food-market into GitHub (backup)
[Service]
Type=oneshot
User=nns
ExecStart=/usr/local/bin/food-market-forgejo-mirror.sh

View file

@ -0,0 +1,10 @@
[Unit]
Description=Mirror Forgejo -> GitHub every 10 min
[Timer]
OnBootSec=3min
OnUnitActiveSec=10min
Unit=food-market-forgejo-mirror.service
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,15 @@
[Unit]
Description=food-market Forgejo (primary git)
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=oneshot
RemainAfterExit=true
WorkingDirectory=/home/nns/food-market/deploy/forgejo
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose stop
User=nns
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,40 @@
#!/bin/bash
# Mirrors our Forgejo repo into GitHub. Best-effort: if the push fails (flaky
# KZ TCP to github.com), the next tick will retry.
set -euo pipefail
MIRROR_DIR="/opt/food-market-data/forgejo/mirror"
FORGEJO_URL="http://127.0.0.1:3000/nns/food-market.git"
GITHUB_URL="https://github.com/nurdotnet/food-market.git"
GITHUB_TOKEN_FILE="/etc/food-market/github-mirror-token" # 40-char PAT with repo scope
LOG_FILE="/var/log/food-market-forgejo-mirror.log"
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$LOG_FILE"; }
if [[ ! -f $GITHUB_TOKEN_FILE ]]; then
log "token file $GITHUB_TOKEN_FILE missing — skipping mirror push"
exit 0
fi
TOKEN=$(tr -d '\n' < "$GITHUB_TOKEN_FILE")
if [[ ! -d $MIRROR_DIR/objects ]]; then
log "bootstrap: cloning $FORGEJO_URL$MIRROR_DIR"
rm -rf "$MIRROR_DIR"
git clone --mirror "$FORGEJO_URL" "$MIRROR_DIR" >> "$LOG_FILE" 2>&1
fi
cd "$MIRROR_DIR"
# Pull latest from Forgejo (source of truth).
if ! git remote update --prune >> "$LOG_FILE" 2>&1; then
log "forgejo fetch failed — aborting this tick"
exit 0
fi
# Push everything to GitHub, timeout generously (big pushes on flaky link).
GIT_HTTP_LOW_SPEED_LIMIT=1000 \
GIT_HTTP_LOW_SPEED_TIME=60 \
timeout 300 git push --prune "https://x-access-token:$TOKEN@github.com/nurdotnet/food-market.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' >> "$LOG_FILE" 2>&1 \
&& log "pushed to github ok" \
|| log "github push failed (exit=$?), will retry next tick"

22
deploy/forgejo/nginx.conf Normal file
View file

@ -0,0 +1,22 @@
server {
listen 80;
server_name git.zat.kz;
location /.well-known/acme-challenge/ { root /var/www/html; }
# Forgejo can serve large pushes; allow big request bodies.
client_max_body_size 512M;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 300s;
}
}
# Note: run certbot --nginx -d git.zat.kz to issue a TLS cert certbot will
# add a TLS server block and rewrite this one to 301->https.

48
deploy/mirror-base-images.sh Executable file
View file

@ -0,0 +1,48 @@
#!/bin/bash
# Pulls all external base images the food-market builds depend on, then retags
# them into the local registry at 127.0.0.1:5001 under the "mirror/" prefix.
#
# Why: outbound to docker.io / mcr.microsoft.com flaps on KZ network. Once
# mirrored, Dockerfiles and docker-compose reference the local copy and builds
# no longer need the internet at all.
#
# Idempotent — safe to run as often as you want. Scheduled daily via
# food-market-mirror-base-images.timer.
set -euo pipefail
REGISTRY=127.0.0.1:5001
LOG_PREFIX=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# image_ref → local name under mirror/
IMAGES=(
"node:20-alpine|mirror/node:20-alpine"
"nginx:1.27-alpine|mirror/nginx:1.27-alpine"
"postgres:16-alpine|mirror/postgres:16-alpine"
"mcr.microsoft.com/dotnet/sdk:8.0|mirror/dotnet-sdk:8.0"
"mcr.microsoft.com/dotnet/aspnet:8.0|mirror/dotnet-aspnet:8.0"
)
failures=0
for pair in "${IMAGES[@]}"; do
src="${pair%|*}"
dst="${pair#*|}"
echo "$LOG_PREFIX pulling $src"
if ! docker pull "$src"; then
echo "$LOG_PREFIX FAILED: pull $src"
failures=$((failures + 1))
continue
fi
docker tag "$src" "$REGISTRY/$dst"
if ! docker push "$REGISTRY/$dst"; then
echo "$LOG_PREFIX FAILED: push $REGISTRY/$dst"
failures=$((failures + 1))
continue
fi
echo "$LOG_PREFIX ok $src -> $REGISTRY/$dst"
done
if [[ $failures -gt 0 ]]; then
echo "$LOG_PREFIX done, $failures failed — registry still has old mirrored copies"
exit 1
fi
echo "$LOG_PREFIX done, all mirrors fresh"

54
deploy/nginx.conf Normal file
View file

@ -0,0 +1,54 @@
server {
listen 80 default_server;
root /usr/share/nginx/html;
index index.html;
# Long-running admin imports (MoySklad etc.) read from upstream for tens of
# minutes. Bump timeouts only on that path so normal API stays snappy.
location /api/admin/import/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60m;
proxy_send_timeout 60m;
proxy_request_buffering off;
proxy_buffering off;
}
# API reverse-proxy upstream name "api" resolves in the compose network.
location /api/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /connect/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location /health {
proxy_pass http://api:8080;
}
# Статика изображений товаров api раздаёт /uploads/... из volume.
location /uploads/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
# SPA fallback all other routes return index.html
location / {
try_files $uri $uri/ /index.html;
}
}

View file

@ -0,0 +1,32 @@
# Шаблон nginx-конфига для публичного сайта food-market.public.
# НЕ ПРИМЕНЯТЬ ПОКА ЮЗЕР НЕ ВЫБЕРЕТ ДОМЕН.
#
# Сборка контейнера: docker compose --build food-market-public (см.
# deploy/docker-compose.yml; контейнер слушает на 127.0.0.1:8082).
#
# Использование (когда домен решится):
# 1. Заменить SERVER_NAME ниже на финальный домен.
# 2. Скопировать в /etc/nginx/conf.d/food-market-public.conf.
# 3. sudo certbot --nginx -d <SERVER_NAME>.
# 4. sudo nginx -t && sudo systemctl reload nginx.
#
# Архитектура после переезда (план):
# <PUBLIC_DOMAIN> → этот блок (публичный Astro)
# app.<PUBLIC_DOMAIN> → существующий блок food-market-stage.conf (админка)
# API остаётся на app.* под /api/*.
server {
server_name SERVER_NAME;
location /.well-known/acme-challenge/ { root /var/www/html; }
location / {
proxy_pass http://127.0.0.1:8082;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 80;
}

View file

@ -0,0 +1,180 @@
"""Telegram bridge: webhook receiver, paste-to-tmux only.
Refactored from the original 2-second polling loop to a fully event-driven
design: outgoing assistant messages are now pushed by the Claude Code Stop
hook (/usr/local/bin/cc-tg-notify-stop). This bridge only handles the
inbound side Telegram tmux paste.
Config (/etc/food-market/telegram.env or env vars):
TELEGRAM_BOT_TOKEN bot token (required)
TELEGRAM_CHAT_ID single whitelisted chat id (required)
TELEGRAM_WEBHOOK_URL public URL Telegram should POST to
(default: https://food-market.zat.kz/tg-webhook)
TELEGRAM_WEBHOOK_SECRET random secret; bridge validates the
X-Telegram-Bot-Api-Secret-Token header on every
incoming request and Telegram sends it back so
third parties can't forge updates
TMUX_SESSION tmux session to paste into (default: claude)
WEBHOOK_LISTEN_HOST local bind host (default: 127.0.0.1)
WEBHOOK_LISTEN_PORT local bind port (default: 8765)
"""
from __future__ import annotations
import asyncio
import logging
import os
import subprocess
import sys
from pathlib import Path
from telegram import Update
from telegram.ext import (
ApplicationBuilder,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)
ENV_FILE = Path("/etc/food-market/telegram.env")
TMUX_SESSION = os.environ.get("TMUX_SESSION", "claude")
LISTEN_HOST = os.environ.get("WEBHOOK_LISTEN_HOST", "127.0.0.1")
LISTEN_PORT = int(os.environ.get("WEBHOOK_LISTEN_PORT", "8765"))
WEBHOOK_PATH = "/tg-webhook"
logger = logging.getLogger("bridge")
def load_env(path: Path) -> dict[str, str]:
out: dict[str, str] = {}
if not path.exists():
return out
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
out[key.strip()] = value.strip().strip('"').strip("'")
return out
async def tmux_send_text(session: str, text: str) -> None:
"""Pastes one Telegram message verbatim into the tmux session, then Enter.
Uses `send-keys -l` for literal paste no key-binding interpretation,
works for arbitrary text including unicode and special chars.
"""
proc = await asyncio.create_subprocess_exec(
"tmux", "send-keys", "-t", session, "-l", text,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"tmux send-keys -l failed: {stderr.decode().strip()}")
proc = await asyncio.create_subprocess_exec(
"tmux", "send-keys", "-t", session, "Enter",
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"tmux send-keys Enter failed: {stderr.decode().strip()}")
def _allowed(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
chat_id = context.application.bot_data["chat_id"]
return update.effective_chat is not None and update.effective_chat.id == chat_id
async def cmd_ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not _allowed(update, context):
return
await update.message.reply_text(f"pong — webhook mode, tmux session «{TMUX_SESSION}»")
QUIET_FLAG = "/tmp/cc-tg-quiet"
async def cmd_quiet(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Заткнуть PreToolUse прогресс-ленту (Stop hook продолжает работать)."""
if not _allowed(update, context):
return
try:
open(QUIET_FLAG, "w").close()
await update.message.reply_text("🔕 Прогресс-лента отключена. Включить — /loud")
except Exception as exc: # noqa: BLE001
await update.message.reply_text(f"⚠️ {exc}")
async def cmd_loud(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Включить обратно PreToolUse прогресс-ленту."""
if not _allowed(update, context):
return
try:
os.unlink(QUIET_FLAG)
except FileNotFoundError:
pass
except Exception as exc: # noqa: BLE001
await update.message.reply_text(f"⚠️ {exc}")
return
await update.message.reply_text("🔔 Прогресс-лента включена.")
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not _allowed(update, context):
return
text = (update.message.text or "").strip() if update.message else ""
if not text:
return
logger.info("inbound message: %d chars", len(text))
try:
await tmux_send_text(TMUX_SESSION, text)
except Exception as exc: # noqa: BLE001
logger.warning("paste to tmux failed: %s", exc)
await update.message.reply_text(f"⚠️ tmux error: {exc}")
def main() -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
env = {**os.environ, **load_env(ENV_FILE)}
token = env.get("TELEGRAM_BOT_TOKEN", "").strip()
chat_id_raw = env.get("TELEGRAM_CHAT_ID", "").strip()
secret = env.get("TELEGRAM_WEBHOOK_SECRET", "").strip()
webhook_url = env.get("TELEGRAM_WEBHOOK_URL", "https://food-market.zat.kz/tg-webhook").strip()
if not token or not chat_id_raw:
print("ERROR: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID required", file=sys.stderr)
return 78
try:
chat_id = int(chat_id_raw)
except ValueError:
print(f"ERROR: TELEGRAM_CHAT_ID must be int, got {chat_id_raw!r}", file=sys.stderr)
return 78
if not secret:
logger.warning("TELEGRAM_WEBHOOK_SECRET is empty — webhook is unauthenticated")
application = ApplicationBuilder().token(token).build()
application.bot_data["chat_id"] = chat_id
application.add_handler(CommandHandler("ping", cmd_ping))
application.add_handler(CommandHandler("quiet", cmd_quiet))
application.add_handler(CommandHandler("loud", cmd_loud))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
logger.info("starting webhook listener on %s:%d%s", LISTEN_HOST, LISTEN_PORT, webhook_url)
application.run_webhook(
listen=LISTEN_HOST,
port=LISTEN_PORT,
url_path=WEBHOOK_PATH.lstrip("/"),
webhook_url=webhook_url,
secret_token=secret or None,
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=False,
stop_signals=None,
)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Claude Code PreToolUse hook: шлёт короткую строку в Telegram перед
# каждым tool-call'ом для ощущения «активности». Дебаунс 1.5с — пока
# tool-вызовы летят пачкой, копим в /tmp буфер и шлём одним сообщением
# через 1.5 секунды тишины.
#
# Конфиг — /etc/food-market/telegram.env. Логи — /var/log/cc-tg-notify.log.
# Off-switch: создать /tmp/cc-tg-quiet — все pretool-уведомления
# скипаются (Stop hook продолжает работать).
set -u
ENV_FILE="/etc/food-market/telegram.env"
LOG_FILE="${CC_TG_LOG:-/var/log/cc-tg-notify.log}"
BUF="/tmp/cc-tg-pretool-buffer.txt"
LAST="/tmp/cc-tg-pretool-last"
LOCK="/tmp/cc-tg-pretool.lock"
QUIET_FLAG="/tmp/cc-tg-quiet"
DEBOUNCE_SEC="1.5"
MAX_LINES=20
log() { printf '%s [pretool] %s\n' "$(date -Is)" "$*" >>"$LOG_FILE" 2>/dev/null || true; }
[[ -f "$QUIET_FLAG" ]] && exit 0
if [[ -r "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
set -a; source "$ENV_FILE"; set +a
fi
TOKEN="${TELEGRAM_BOT_TOKEN:-}"
CHAT_ID="${TELEGRAM_CHAT_ID:-}"
[[ -z "$TOKEN" || -z "$CHAT_ID" ]] && exit 0
INPUT_JSON=""
if [[ ! -t 0 ]]; then INPUT_JSON="$(cat)"; fi
[[ -z "$INPUT_JSON" ]] && exit 0
TOOL="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_name // empty' 2>/dev/null)"
[[ -z "$TOOL" || "$TOOL" == "TodoWrite" ]] && exit 0
# Извлекаем поле tool_input под нужный тип. cut -c обрезает многобайтные
# UTF-8 неаккуратно, но для urlencode результат остаётся валидным.
LINE=""
case "$TOOL" in
Bash)
DESC="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.description // empty' 2>/dev/null | tr '\n' ' ' | head -c 100)"
CMD="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.command // empty' 2>/dev/null | tr '\n' ' ' | head -c 80)"
if [[ -n "$DESC" ]]; then LINE="🔨 $DESC"; else LINE="🔨 Bash: $CMD"; fi
;;
Edit)
FP="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
LINE="✏️ Edit: $(basename "${FP:-?}")"
;;
Write)
FP="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
LINE="📝 Write: $(basename "${FP:-?}")"
;;
Read)
FP="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
LINE="📖 Read: $(basename "${FP:-?}")"
;;
Grep)
P="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.pattern // empty' 2>/dev/null | head -c 30)"
LINE="🔍 Grep: \"$P\""
;;
Glob)
P="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.pattern // empty' 2>/dev/null | head -c 50)"
LINE="🌐 Glob: $P"
;;
WebFetch)
U="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.url // empty' 2>/dev/null | head -c 60)"
LINE="🌍 Fetch: $U"
;;
WebSearch)
Q="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.query // empty' 2>/dev/null | head -c 60)"
LINE="🔎 Search: $Q"
;;
Task)
D="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.description // empty' 2>/dev/null | head -c 60)"
LINE="🎯 Task: $D"
;;
*)
LINE="🔧 $TOOL"
;;
esac
[[ -z "$LINE" ]] && exit 0
NOW="$(date +%s%N | cut -c1-13)"
# Append + bump LAST под flock'ом — конкурентные hook'и не теряют строки.
(
flock 9
echo "$LINE" >> "$BUF"
echo "$NOW" > "$LAST"
) 9>"$LOCK"
# Дебаунс-flusher в фоне. Каждый hook спавнит свой sleep, но только
# тот, чей NOW совпал с финальным LAST после задержки, реально шлёт —
# остальные тихо выходят.
(
sleep "$DEBOUNCE_SEC"
(
flock 9
LAST_TS="$(cat "$LAST" 2>/dev/null || echo 0)"
if [[ "$LAST_TS" != "$NOW" ]]; then
# Пришёл более свежий tool — он заfflushит сам.
exit 0
fi
[[ -s "$BUF" ]] || exit 0
# Если буфер длиннее MAX_LINES — режем хвост (свежие строки важнее).
BODY="$(tail -n "$MAX_LINES" "$BUF")"
: > "$BUF"
curl -fsS -m 10 -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
--data-urlencode "chat_id=${CHAT_ID}" \
--data-urlencode "text=${BODY}" \
--data-urlencode "disable_notification=true" \
--data-urlencode "disable_web_page_preview=true" \
>/dev/null 2>&1 || log "send failed"
) 9>"$LOCK"
) &
# Не ждём фоновую задачу — Claude Code продолжает выполнение tool'а.
disown 2>/dev/null || true
exit 0

View file

@ -0,0 +1,99 @@
#!/usr/bin/env bash
# Claude Code Stop hook: вытаскивает финальный assistant-ответ из transcript'а
# и пушит в Telegram. Устанавливается на /usr/local/bin/cc-tg-notify-stop.
#
# Hook runtime передаёт JSON на stdin с полем .transcript_path; раньше это
# приходило как $CLAUDE_TRANSCRIPT_PATH env-var, но в новых версиях стрим
# переехал в stdin. Поддерживаем оба варианта.
#
# Конфиг — /etc/food-market/telegram.env (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID).
# Логи — /var/log/cc-tg-notify.log (rotated externally).
set -u
ENV_FILE="/etc/food-market/telegram.env"
LOG_FILE="${CC_TG_LOG:-/var/log/cc-tg-notify.log}"
PROJECT_TAG="${CC_TG_TAG:-food-market}"
MAX_CHUNK=4000
log() { printf '%s [stop-hook] %s\n' "$(date -Is)" "$*" >>"$LOG_FILE" 2>/dev/null || true; }
if [[ -r "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
set -a; source "$ENV_FILE"; set +a
fi
TOKEN="${TELEGRAM_BOT_TOKEN:-}"
CHAT_ID="${TELEGRAM_CHAT_ID:-}"
if [[ -z "$TOKEN" || -z "$CHAT_ID" ]]; then
log "missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID"
exit 0
fi
# Читаем JSON со stdin (если пришёл) или берём env-vars (legacy).
INPUT_JSON=""
if [[ -t 0 ]]; then
INPUT_JSON=""
else
INPUT_JSON="$(cat)"
fi
TRANSCRIPT="${CLAUDE_TRANSCRIPT_PATH:-}"
if [[ -z "$TRANSCRIPT" && -n "$INPUT_JSON" ]]; then
TRANSCRIPT="$(printf '%s' "$INPUT_JSON" | jq -r '.transcript_path // empty' 2>/dev/null || true)"
fi
if [[ -z "$TRANSCRIPT" || ! -r "$TRANSCRIPT" ]]; then
log "no transcript path (stdin=${#INPUT_JSON} chars, env=${CLAUDE_TRANSCRIPT_PATH:-unset})"
exit 0
fi
# Последняя assistant-запись с непустым text-блоком. JSONL: одна запись на строку.
TEXT="$(jq -r '
select(.type == "assistant")
| .message.content[]?
| select(.type == "text" and (.text // "" | length) > 0)
| .text
' "$TRANSCRIPT" 2>/dev/null \
| awk 'BEGIN{RS=""}{a=$0} END{print a}')"
# awk выше склеивает все записи в одну; нам нужна именно ПОСЛЕДНЯЯ assistant-запись,
# поэтому делаем второй проход: берём индекс последней записи и достаём её text-блоки.
LAST_TEXT="$(jq -s -r '
map(select(.type == "assistant")) | last as $m
| ($m.message.content // [])
| map(select(.type == "text" and (.text // "" | length) > 0) | .text)
| join("\n")
' "$TRANSCRIPT" 2>/dev/null)"
if [[ -n "$LAST_TEXT" ]]; then TEXT="$LAST_TEXT"; fi
if [[ -z "$TEXT" ]]; then
log "no text in last assistant turn (only tool calls?)"
exit 0
fi
# Чанкуем по строкам с лимитом MAX_CHUNK; первый чанк — с префиксом.
PREFIX="🤖 [${PROJECT_TAG}]"
send_chunk() {
local body="$1"
curl -fsS -m 15 -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
--data-urlencode "chat_id=${CHAT_ID}" \
--data-urlencode "text=${body}" \
--data-urlencode "disable_web_page_preview=true" \
>/dev/null 2>&1 || log "send failed (curl rc=$?)"
}
CHUNK="$PREFIX"$'\n'
EMITTED=0
while IFS= read -r line; do
if (( ${#CHUNK} + ${#line} + 1 > MAX_CHUNK )); then
send_chunk "$CHUNK"
EMITTED=$((EMITTED+1))
CHUNK=""
fi
CHUNK+="$line"$'\n'
done <<<"$TEXT"
if [[ -n "$CHUNK" ]]; then
send_chunk "$CHUNK"
EMITTED=$((EMITTED+1))
fi
log "sent $EMITTED chunk(s), text=${#TEXT} chars"
exit 0

View file

@ -0,0 +1 @@
python-telegram-bot[rate-limiter]==21.6

View file

@ -0,0 +1,19 @@
[Unit]
Description=food-market Telegram <-> tmux bridge
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=nns
Group=nns
WorkingDirectory=/opt/food-market-data/telegram-bridge
EnvironmentFile=-/etc/food-market/telegram.env
ExecStart=/opt/food-market-data/telegram-bridge/venv/bin/python /opt/food-market-data/telegram-bridge/bridge.py
Restart=on-failure
RestartSec=10
# Access tmux sockets under /tmp/tmux-1000/
Environment=TMUX_TMPDIR=/tmp
[Install]
WantedBy=multi-user.target

119
docs/24x7.md Normal file
View file

@ -0,0 +1,119 @@
# 24/7 автономный workflow
Картина: **твой Mac/iPhone даёт команду → Claude работает → всё запускается в облаке независимо от твоего устройства**.
```
┌──────────────┐ ┌──────────────┐
│ Mac / iPhone │ │ Твой Proxmox │
│ (даёшь команду)│ │ VM (будущее) │
└───────┬──────┘ └───────┬──────┘
│ │
│ Claude Code │ Claude Code 24/7
│ (когда открыт) │ (когда поднимем VM)
▼ ▼
┌──────────────────────────────────────┐
│ GitHub (main branch) │
└──────┬──────────────────────────┬────┘
│ push │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ CI workflow │ │Docker workflow│
│ (backend+web │ │(api+web images│
│ +POS builds) │ │ на ghcr.io) │
└──────┬──────┘ └──────┬──────┘
│ │
│ artifacts: │ images pulled by
│ web-dist, POS .exe │ stage / prod compose
▼ ▼
┌──────────────┐ ┌──────────────┐
│ GitHub │ │ Proxmox-VM │
│ Releases │ │ stage/prod │
│ (.exe, APK) │ │ (docker-compose)│
└──────────────┘ └──────────────┘
```
## Что где живёт
| Компонент | Где | Когда работает | Зависит от Mac? |
|---|---|---|---|
| Claude Code (текущая сессия) | твой Mac | пока открыта app + Mac не спит | **Да** |
| Claude Code (будущее 24/7) | Proxmox VM | всегда | Нет |
| GitHub (код) | github.com | всегда | Нет |
| GitHub Actions CI | github.com | срабатывает на push / cron | **Нет** |
| Docker images | ghcr.io | всегда | Нет |
| Тестовый стенд (stage) | Proxmox VM | всегда | Нет |
| DB бэкапы | Proxmox VM → локальный диск + S3 (опц.) | cron nightly | Нет |
## Сценарии
### Ты заказал фичу → уснул
1. (Днём) запустил Claude, дал команду «сделай X», Claude работает
2. Перед сном Claude коммитит и пушит то что успел
3. GitHub Actions автоматически собирает backend+web+POS, прогоняет тесты
4. Docker-образы уходят в ghcr.io
5. (Если stage настроен) — stage автопулит образ → перезапускается → готов к тесту
6. Telegram-бот шлёт тебе «готово, проверь stage.food-market.xxx»
7. Утром ты смотришь, ревьюишь, делаешь merge/revert
### Ты дал команду с iPhone
1. Открыл Claude на iPhone, сказал «обнови UI страницы X»
2. Claude работает, пушит
3. GitHub Actions → ghcr.io → stage → Telegram → ты проверяешь прямо с iPhone
### Что-то пошло не так
- Каждый коммит = одна точка отката. `git revert <sha>` за 10 секунд.
- БД: ежедневный pg_dump `.sql.gz`, 30 дней ротации, скрипт `deploy/backup.sh`.
- Критические операции (миграции с удалением данных, force-push на main) — всегда спрошу тебя.
## GitHub Actions бюджет (free: 2000 мин/мес на приватный репо)
| Job | Runner | Мин/запуск | Множитель | Биллинговых мин | Когда |
|---|---|---|---|---|---|
| backend | Linux | 3 | 1× | 3 | каждый push/PR |
| web | Linux | 2 | 1× | 2 | каждый push/PR |
| pos | Windows | 5 | 2× | 10 | **только на теги `v*` + ручной запуск** |
| docker-api | Linux | 3 | 1× | 3 | только push в main (с изм. кода) |
| docker-web | Linux | 3 | 1× | 3 | только push в main (с изм. кода) |
**Оценка:** ~11 бил.мин на обычный коммит. Лимит 2000 мин ≈ 180 коммитов в месяц или 6 в день. На теге релиза +10 за POS.
**Когда упрёмся (ориентир: 200+ коммитов/мес):** поднимем self-hosted runner на Proxmox-VM (Ubuntu, 2 CPU/2 GB). В workflow: `runs-on: [self-hosted, linux]` вместо `ubuntu-latest`. Безлимит по времени.
## Что нужно для полноценного 24/7 (ещё не сделано)
- [x] GitHub Actions для CI (backend/web/POS) — готов `.github/workflows/ci.yml`
- [x] Docker workflow — готов `.github/workflows/docker.yml`
- [x] docker-compose для стенда — готов `deploy/docker-compose.yml`
- [x] DB backup скрипт — готов `deploy/backup.sh`
- [ ] Proxmox-VM `food-market-stage` — ждёт кредов от тебя
- [ ] Proxmox-VM `claude-runner` (чтобы я не жил на твоём Mac) — ждёт кредов
- [ ] SSH-ключ для деплоя в GitHub Secrets
- [ ] Telegram bot + chat_id в GitHub Secrets
- [ ] FTP для APK (если нужен) в GitHub Secrets
- [ ] Домен + SSL для stage (опц., Cloudflare)
## Секреты: безопасно передать мне
Пока твой Mac — единственное место, куда Claude Code имеет доступ. Безопасный путь:
1. Создай папку: `mkdir -p ~/.food-market-secrets && chmod 700 ~/.food-market-secrets`
2. Положи туда файлы (я буду читать только по твоей команде и не буду вставлять значения в чат):
- `~/.food-market-secrets/proxmox.env` — ssh creds для Proxmox API/VM
- `~/.food-market-secrets/ftp.env` — FTP для APK
- `~/.food-market-secrets/telegram.env``BOT_TOKEN=...` + `CHAT_ID=...`
3. Пришли в чат: "Секреты в ~/.food-market-secrets/"
4. Я прочитаю, прокину в GitHub Secrets через `gh secret set`, больше нигде не сохраню.
## Настройка Mac чтобы не засыпал ночью (временно, пока нет remote runner)
```bash
# Заблокировать sleep на время работы Claude (Ctrl+C чтобы отменить)
caffeinate -i -d
```
Или в System Settings → Lock Screen → «Turn display off after: Never» + «Prevent automatic sleeping when the display is off».
После того как поднимем `claude-runner` VM — этот обход больше не нужен.

464
docs/audit-moysklad.md Normal file
View file

@ -0,0 +1,464 @@
# Аудит наших доменных сущностей vs. MoySklad API
Источник правды — живой MoySklad API `/api/remap/1.2/`. Проверялись ключи на реальных ответах (`?limit=2` на нашем аккаунте). Цель: каждая наша сущность должна либо повторять MoySklad, либо иметь явно оправданное отличие. Никаких «выдуманных» полей.
Условные обозначения:
- **⛔** — у нас есть поле, которого нет у MoySklad → либо оправдать комментарием, либо удалить.
- **** — у MoySklad есть поле, которого нет у нас → потенциально добавить.
- **⚠️** — важный нюанс (тип, семантика, обязательность).
---
## Counterparty → `entity/counterparty`
Ключи MoySklad (из ответа API, верхний уровень): `accountId, accounts, archived, bonusPoints, bonusProgram, companyType, created, externalCode, files, group, id, meta, name, notes, owner, salesAmount, shared, state, tags, updated` + расширяемые: `legalTitle, legalAddress, inn, kpp, ogrn, ogrnip, certificateNumber, certificateDate, phone, email, actualAddress, description, discountCardNumber, priceType, sex, salesChannel`.
| Наше поле | MoySklad | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `LegalName` | `legalTitle` | rename? или доп. комментарий-алиас |
| `Kind` (CounterpartyKind) | **нет** | ⛔ уже исправили enum (`Unspecified/Supplier/Customer/Both`), но MoySklad не имеет этого поля вообще — он различает контрагентов через `tags` или через `state` (статус в пайплайне продаж/закупок). **TODO:** либо оставить Kind только как UI-фильтр (не импортировать из MoySklad), либо перейти на теги |
| `Type` (LegalEntity/Individual) | `companyType` | ⚠️ у MoySklad 3 значения: `legal`, `individual`, `entrepreneur` (ИП!). У нас ИП отсутствует — **добавить `IndividualEntrepreneur` в enum** (для РК актуально) |
| `Bin` (БИН, РК) | `inn` (12-значный БИН пишется туда) | ⚠️ MoySklad для всех рынков использует `inn` — 12 цифр это ИИН РФ, 12 цифр РК — БИН. Мы вынесли `Bin` отдельно, при импорте MoySklad кладёт в `inn`. **TODO:** документировать маппинг Bin ↔ inn |
| `Iin` (ИИН, РК) | `inn` (тот же) | ⚠️ same — MoySklad не различает |
| `TaxNumber` | `inn` | дубль |
| `CountryId` | `country` (extended, по `meta`) | ⚠️ MoySklad не на верхнем уровне — тянется при `?expand=country` |
| `Address` | `actualAddress` | ОК |
| `Phone` | `phone` | ОК |
| `Email` | `email` | ОК |
| `BankName, BankAccount, Bik` | `accounts` (массив объектов) | ⚠️ у MoySklad это **коллекция счетов** (до нескольких банков). У нас одиночные поля — **либо сделать коллекцию Accounts, либо документировать "берём первый"** |
| `ContactPerson` | `contactpersons` (sub-endpoint) | ⚠️ у MoySklad это отдельный endpoint `counterparty/{id}/contactpersons` — массив. У нас скалярное поле |
| `Notes` | `description` (или `notes` разные в разных версиях API?) | ⚠️ в ответе API было `notes`ОК |
| `IsActive` | `archived` (inverse) | ОК |
| — | `tags` (массив) | **добавить** — удобно для классификации (в том числе заменой Kind) |
| — | `state` (ссылка на состояние в пайплайне) | отложить до Phase N (CRM) |
| — | `bonusPoints, bonusProgram, discountCardNumber` | отложить до дисконтных карт |
| — | `salesAmount` (вычисляемое) | не храним |
| — | `priceType` (персональный тип цены) | полезно для опта; добавить `Guid? DefaultPriceTypeId` |
**TODO:**
1. Enum `CounterpartyType`: добавить `IndividualEntrepreneur = 3`.
2. Коллекция `CounterpartyAccount` (BankName/BankAccount/Bik/IsDefault) — или явный комментарий «храним только основной».
3. Коллекция `CounterpartyTag` (string) — для классификации при импорте из MoySklad.
4. Поле `DefaultPriceTypeId``PriceType` (для опта/персональной цены).
5. Комментарий на `Bin/Iin/TaxNumber`: при импорте из MoySklad все три могут прилететь из одного поля `inn` — логика различения по длине (12 цифр РК-формат) / по companyType.
---
## Organization → `entity/organization`
Ключи MS: `accountId, accounts, archived, bonusPoints, bonusProgram, companyType, companyVat__ru, created, email, externalCode, group, id, isEgaisEnable, meta, name, owner, payerVat, shared, updated` + extended: `legalTitle, legalAddress, actualAddress, inn, kpp, ogrn, ogrnip, okpo, director, chiefAccountant, phone, fax, utmUrl`.
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `CountryCode` | **нет** | ⛔ у MoySklad нет — у них multi-tenant через account. У нас — multi-tenant через Organization, но CountryCode неочевиден. Оставить как есть, документировать почему (нам нужно для налоговых/локальных настроек) |
| `Bin` | `inn` | то же что и Counterparty |
| `Address` | `actualAddress` | ОК |
| `Phone` | `phone` | ОК |
| `Email` | `email` | ОК |
| `IsActive` | `archived` inverse | ОК |
| — | `legalTitle, legalAddress` | для офиц. документов |
| — | `kpp, ogrn, ogrnip, okpo` | РФ-специфично, пропускаем для РК |
| — | `payerVat` (bool, плательщик НДС) | полезно — есть ли НДС у нашей организации |
| — | `director, chiefAccountant` | для подписей на накладных |
| — | `accounts` (банковские) | аналогично Counterparty |
| — | `isEgaisEnable` | РФ, пропускаем |
**TODO:**
1. `LegalName`, `LegalAddress`, `PayerVat` (bool), `DirectorName`, `ChiefAccountantName` — для накладных/счетов.
2. `CountryCode` оставить + `<see langword="…"/>` комментарий почему у нас есть, а у MS нет.
---
## Product → `entity/product`
Ключи MS: `accountId, archived, barcodes, buyPrice, code, discountProhibited, externalCode, files, group, id, images, isSerialTrackable, meta, minPrice, name, owner, pathName, paymentItemType, productFolder, salePrices, shared, supplier, trackingType, uom, updated, useParentVat, variantsCount, volume, weight` + optional: `article, country, description, effectiveVat, minPrice.currency, taxSystem, vat, tnved, syncId, modifications`.
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `Article` | `article` | ОК |
| `Description` | `description` | ОК |
| `UnitOfMeasureId` | `uom.meta` | ОК |
| `VatRateId` | `vat` (число) + `useParentVat` | ⚠️ у MS НДС хранится как число (20, 10, 12, 0) прямо на товаре. **Мы отдельная сущность VatRate**. Обоснование: нам нужно хранить локализованные названия ("НДС 12%", "Без НДС"), is-default, и позволять разным организациям иметь разные ставки. НО — при импорте надо резолвить число в VatRate по organization_id |
| `ProductGroupId` | `productFolder.meta` | ОК |
| `DefaultSupplierId` | `supplier.meta` | ОК (у MS тоже одиночная ссылка) |
| `CountryOfOriginId` | `country.meta` | ОК |
| `IsService` | `paymentItemType` (одно из значений = "SERVICE") | ⚠️ у MS это enum с ~10 значений; у нас bool. **TODO:** либо enum, либо документировать что мы учитываем только IsService |
| `IsWeighed` | **нет** | ⛔ у MS этого нет; характеристика ритейла, нам нужно для касс с весами. **Оставить, документировать.** |
| `IsAlcohol` | `tnved` (класс товара) или через group | ⚠️ у MS через tnved-код или through type классификаторы. Наше bool — упрощение. **Оставить с комментарием.** |
| `IsMarked` | `trackingType` (enum: NOT_TRACKED, BEER_ALCOHOL, …) | ⚠️ У MS это enum из 10+ вариантов маркировки. Наш `IsMarked: bool` — потеря информации. **TODO:** заменить на enum `TrackingType` (NOT_TRACKED/TOBACCO/ALCOHOL/SHOES/MEDICINE/…) |
| `MinStock, MaxStock` | `minimumBalance` (число), `stock` (runtime) | ⚠️ у MS есть только `minimumBalance` (нижняя граница). MaxStock — наш |
| `PurchasePrice, PurchaseCurrencyId` | `buyPrice.value, buyPrice.currency.meta` | ОК (MS упаковывает в объект, мы разнесли — **одно и то же**) |
| `ImageUrl` | `images` (массив через sub-endpoint) | ⚠️ у MS images коллекция, у нас одна + отдельная ProductImage. ОК, двойная запись для UX |
| `IsActive` | `archived` inverse | ОК |
| `Prices` (collection) | `salePrices` (массив inline в MS) | ⚠️ у MS цены — **массив внутри товара**, у нас — отдельная таблица. Оба норм; просто маппинг при sync |
| `Barcodes` (collection) | `barcodes` (массив inline) | ОК |
| `Images` (collection) | `images` (sub-endpoint) | ОК |
| — | `code` | внутренний код (отличается от `article`). **Добавить `Code`** |
| — | `externalCode` | используется при импорте/ERP-интеграциях. **Добавить `ExternalCode`** (актуально для импорта из MoySklad, 1C) |
| — | `discountProhibited` | «запрет скидок» — полезно на кассе |
| — | `minPrice.value/currency` | минимальная отпускная цена. **Добавить `MinPrice` + `MinPriceCurrencyId`** |
| — | `paymentItemType` | для фискализации: «товар/услуга/работа/подарочная карта/…». **Добавить enum `PaymentItemType`** (нужно для 54-ФЗ / КZ fiscal receipts) |
| — | `tnved` | код ТН ВЭД для трансграничной торговли |
| — | `volume, weight` | для логистики (доставка) |
| — | `variantsCount` | runtime агрегат, не храним |
| — | `files` | вложения (паспорта качества, фото упаковки) — отложить |
**TODO:**
1. Добавить `Code`, `ExternalCode` на Product.
2. Заменить `IsMarked` на enum `TrackingType`.
3. Добавить `MinPrice`, `MinPriceCurrencyId`.
4. Добавить enum `PaymentItemType` + поле.
5. Поля `Volume`, `Weight`, `DiscountProhibited`.
6. Запомнить маппинг: `useParentVat` → наследовать НДС от ProductGroup (у нас сейчас не реализовано, надо подумать).
---
## ProductGroup → `entity/productfolder`
Ключи MS: `accountId, archived, externalCode, group, id, meta, name, owner, pathName, shared, updated, useParentVat` + `vat, effectiveVat, productFolder` (родитель).
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `ParentId` | `productFolder.meta` | ОК (MS использует то же имя для родителя что и для самой сущности) |
| `Path` | `pathName` | ОК |
| `SortOrder` | **нет** | ⛔ у MS нет сортировки групп. Оставить, это UX |
| `IsActive` | `archived` inverse | ОК |
| — | `externalCode` | для импорта |
| — | `vat, useParentVat` | ставка НДС по умолчанию для товаров группы |
**TODO:**
1. Добавить `ExternalCode`.
2. Добавить `VatRateId?` + `UseParentVat: bool` (для наследования).
---
## ProductBarcode → `product.barcodes[]`
У MS barcode — объект внутри product: `{type: 'ean13'|'ean8'|'code128'|'upc'|'gtin', value: '...'}`. Отдельной сущности нет.
| Наше | MS | Комментарий |
|---|---|---|
| `Code` | `value` | ОК |
| `Type` | `type` | ⚠️ MS использует строки ('ean13', 'gtin', …) — мы уже enum |
| `IsPrimary` | **нет** | ⛔ у MS нет — первый считается основным. **Оставить с комментарием — у нас явная пометка.** |
OK, расхождений существенных нет.
---
## ProductPrice → `product.salePrices[]`
У MS цены — массив объектов в product: `{value, currency: {meta}, priceType: {meta}}`. Отдельной сущности нет.
Наше — отдельная таблица. Это **нормализованный вариант** — оправдано если цен много и есть выборки по PriceType. **TODO:** маппинг при импорте — проитерировать salePrices и создать ProductPrice per PriceType.
---
## PriceType → `entity/pricetype`
Ключи MS (из context): `id, name, externalCode`.
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `IsDefault` | **нет** | ⛔ у MS — default определяется порядком или отдельно в настройках аккаунта. **Оставить** |
| `IsRetail` | **нет** | ⛔ наш флаг «используется на кассе». **Оставить** |
| `SortOrder` | **нет** | ⛔ UX. **Оставить** |
| — | `externalCode` | для импорта |
**TODO:**
1. `ExternalCode`.
---
## Country → `entity/country`
Ключи MS: `code, description, externalCode, id, meta, name, updated`.
| Наше | MS | Комментарий |
|---|---|---|
| `Code` | `code` | ⚠️ у MS формат ISO3166-1 **alpha-2 или числовой**у нас alpha-2 |
| `Name` | `name` | ОК |
| `SortOrder` | **нет** | ⛔ UX |
| — | `description` | |
| — | `externalCode` | |
OK, мелочь.
---
## Currency → `entity/currency`
Ключи MS: `archived, code, default, fullName, id, indirect, isoCode, majorUnit, meta, minorUnit, multiplicity, name, rate, rateUpdateType, system`.
| Наше | MS | Комментарий |
|---|---|---|
| `Code` | `isoCode` или `code` | ⚠️ у MS `isoCode` (строка "KZT") и `code` (цифровой "398") — у нас `Code` = строка ISO |
| `Name` | `name` | ОК |
| `Symbol` | **нет** | ⛔ у MS нет символа "₸" — но это UX. **Оставить** |
| `MinorUnit` | `minorUnit` | ОК |
| `IsActive` | `archived` inverse | ОК |
| — | `default` (валюта аккаунта) | |
| — | `rate, rateUpdateType` | курс к базовой валюте (при мульти-валютности) |
| — | `multiplicity, indirect` | конвертация; если не мульти-валютные — не надо |
| — | `fullName` | «Тенге Казахстана» vs «KZT» |
**TODO:**
1. Добавить `IsDefault: bool` (ровно одна валюта = true per tenant, или глобально).
2. `Rate, RateUpdateType` + `FullName` — отложить до мульти-валютности.
---
## VatRate — у MoySklad нет `entity/vatrate`
⚠️ У MS **ставки НДС хранятся как числовое поле на товаре** (`vat`). Отдельной таблицы нет — набор значений {0, 5, 7, 10, 12, 18, 20} и «без НДС» встроен в систему.
Наше `VatRate` — отдельная сущность. **Обоснование сохранить:**
1. Локализованное название ("НДС 12%", "Без НДС").
2. IsDefault per organization.
3. Разные организации в разных налоговых режимах (с НДС / УСН).
4. При добавлении новой ставки (например, на случай гипотетического увеличения в РК) — не править перечисление в коде.
Но: **следите**, чтобы у товара хранился `VatRateId`, а не отдельно `vat: decimal`. При импорте из MS мапим число в запись VatRate.
**Комментарий в коде нужен** — явно сказать, что мы отклонились от MoySklad сознательно.
---
## UnitOfMeasure → `entity/uom`
Ключи MS: `code, description, externalCode, id, meta, name, updated`.
| Наше | MS | Комментарий |
|---|---|---|
| `Code` (ОКЕИ) | `code` | ОК, MS использует ОКЕИ-коды (796, 166, 112) |
| `Symbol` | **нет** | ⛔ у MS только `name` ("штука"). Мы вынесли "шт" отдельно для коротких надписей на ценниках/кассовых чеках. **Оставить.** |
| `Name` | `name` | ОК |
| `DecimalPlaces` | **нет** | ⛔ у MS на уровне продукта (`variantsCount`?), а не UoM. Наш `DecimalPlaces` определяет можно ли дробные количества (0=штучный, 3=весовой). **Оставить — важно для UX касс.** |
| `IsBase` | **нет** | ⛔ наше «базовая единица организации». Мелочь, оставить |
| `IsActive` | `archived` inverse (у MS есть `archived` в uom? перепроверить) | ⚠️ в нашем ответе API archived не было — у MS uom этого поля может не быть, потому что единицы системные |
| — | `description` | |
| — | `externalCode` | |
**TODO:**
1. `ExternalCode`.
---
## Store → `entity/store`
Ключи MS: `accountId, address, archived, externalCode, group, id, meta, name, owner, pathName, shared, slots, updated, zones`.
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `Code` | `externalCode`? или отдельно? | ⚠️ у MS только `externalCode`. **Добавить ExternalCode или rename Code→ExternalCode** |
| `Kind` (Warehouse/RetailFloor) | **нет** | ⛔ у MS такого деления нет. Обоснование: нам нужно отличать «склад» от «торгового зала» для UI и настроек касс. **Оставить с комментарием** |
| `Address` | `address` | ОК |
| `Phone` | **нет** | ⛔ у MS нет. Оставить |
| `ManagerName` | **нет** | ⛔ у MS нет. Оставить |
| `IsMain` | **нет** (но можно проставить через default) | ⛔ Оставить |
| `IsActive` | `archived` inverse | ОК |
| — | `pathName` | (если будут иерархические склады) |
| — | `slots` (ячейки склада) | отложить |
| — | `zones` (зоны склада) | отложить |
**TODO:**
1. `ExternalCode` (или переименовать Code → ExternalCode).
---
## RetailPoint → `entity/retailstore`
У MS это **«Точка продаж» / кассовое место**. Огромное количество полей (~60) — в основном фискальные настройки.
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `Code` | `externalCode` | rename or add |
| `StoreId` | `store.meta` | ОК |
| `Address` | **нет** (возможно `organization.actualAddress`) | ⛔ адрес не у точки, а у организации/склада. Пересмотреть, куда класть |
| `Phone` | **нет** | ⛔ |
| `FiscalSerial` | **нет такого поля**; есть `fiscalType`, `fiscalMemoryNumber`?, `ofdEnabled` | ⚠️ у MS фискальные настройки множественные. У нас один скаляр — упрощение. **TODO:** уточнить по мере подключения ККМ |
| `FiscalRegNumber` | `ofdEnabled` + `ofdSettings` | same |
| `IsActive` | `active, archived` | MS различает active и archived — у нас только IsActive |
| — | `priceType.meta` | тип цены для этой точки — **важно** |
| — | `allowCustomPrice` | разрешить ручную цену на кассе |
| — | `allowCreateProducts` | создать товар прямо на кассе |
| — | `discountEnable, discountMaxPercent` | скидки на кассе |
| — | `cashiers` (коллекция) | кто может работать за кассой |
| — | `sellReserves` | продавать резерв |
| — | `receiptTemplate` | шаблон чека |
| — | `returnFromClosedShiftEnabled` | возврат из закрытой смены |
| — | `requiredBirthdate/Email/Phone/Fio/Sex/DiscountCardNumber` | обязательные поля при продаже |
| — | `markingSellingMode, marksCheckMode, sendMarksForCheck` | маркировка товаров |
**TODO:**
1. Обязательно: `DefaultPriceTypeId` (ссылка на `PriceType`).
2. Настройки кассы (скоп Phase 3 — касса): `AllowCustomPrice`, `AllowCreateProducts`, `SellReserves`, `DiscountMaxPercent`, `RequireCustomer...` — добавлять по мере реализации POS.
3. Коллекция `RetailPointCashier` (user_id, может ли работать).
---
## Supply → `entity/supply` + `supply/{id}/positions`
Document keys: `accountId, agent, applicable, created, externalCode, files, group, id, meta, moment, name, organization, owner, payedSum, positions, printed, published, rate, shared, store, sum, updated, vatEnabled, vatIncluded, vatSum`.
| Наше | MS | Комментарий |
|---|---|---|
| `Number` | `name` | ⚠️ у MS «номер документа» = `name`. У нас `Number` — семантически то же |
| `Date` | `moment` | ОК |
| `Status` (Draft/Posted) | `applicable` (bool) | ⚠️ у MS это bool «проведён или нет». У нас enum Draft/Posted → эквивалентно |
| `SupplierId` | `agent.meta` | ⚠️ у MS вместо `supplier` общее слово `agent` (контрагент) |
| `StoreId` | `store.meta` | ОК |
| `CurrencyId` | `rate.currency.meta` | ⚠️ MS упаковывает в rate объект с курсом |
| `SupplierInvoiceNumber` | **нет на верхнем уровне**; есть в `attributes` | ⛔ у MS через custom attributes. Оставить |
| `SupplierInvoiceDate` | same | same |
| `Notes` | `description` | rename или комментарий |
| `Total` | `sum` | ОК |
| `PostedAt` | `updated` (когда applicable ставится true) | ⚠️ у MS нет выделенного поля; мы отдельно фиксируем |
| `PostedByUserId` | `owner.meta` | условно |
| — | `vatEnabled` | |
| — | `vatIncluded` | НДС включён в цену |
| — | `vatSum` | суммарный НДС документа |
| — | `payedSum` | сколько оплачено |
| — | `organization.meta` | ⚠️ у MS документ привязан к организации. **У нас TenantEntity несёт OrganizationId — уже есть** |
| — | `printed, published` | распечатан/опубликован |
| — | `overhead` (доп.расходы) | доставка/таможня — **важно для фактической себестоимости** |
**Supply.Positions (SupplyLine) → supply/{id}/positions:**
Ключи MS: `accountId, assortment, discount, id, meta, overhead, price, quantity, vat, vatEnabled`.
| Наше (SupplyLine) | MS position | Комментарий |
|---|---|---|
| `ProductId` | `assortment.meta` | ⚠️ у MS `assortment` = может быть product ИЛИ variant ИЛИ service ИЛИ bundle. Мы только продукт |
| `Quantity` | `quantity` | ОК |
| `UnitPrice` | `price` | ⚠️ у MS `price` — в копейках (integer `100 = 1.00`). У нас decimal. **Маппинг при импорте: делить на 100** |
| `LineTotal` | **нет** (вычисляется) | ⛔ у MS не хранится |
| `SortOrder` | **нет** | ⛔ наш UX |
| — | `discount` | строковая скидка |
| — | `vat` | ставка НДС на позицию |
| — | `vatEnabled` | |
| — | `overhead` | доля накладных (для себестоимости) |
**TODO Supply:**
1. Поля: `VatEnabled`, `VatIncluded`, `VatSum`, `PayedSum`, `Overhead`.
2. Lines: `Discount` (decimal), `VatPercent` (snapshot, уже подобное есть в RetailSaleLine), `VatEnabled`.
3. Комментарий: MS `price` в копейках — при импорте делить.
---
## RetailSale → `entity/retaildemand` + `retaildemand/{id}/positions`
Document keys: огромный список, ключевое: `agent, applicable, cashSum, noCashSum, qrSum, prepaymentCashSum, prepaymentNoCashSum, prepaymentQrSum, advancePaymentSum, fiscal, retailShift, retailStore, store, positions, rate, sum, vatEnabled, vatIncluded, vatSum, name, moment, organization, syncId`.
| Наше | MS | Комментарий |
|---|---|---|
| `Number` | `name` | ОК |
| `Date` | `moment` | ОК |
| `Status` | `applicable` | ⚠️ bool vs enum |
| `StoreId` | `store.meta` | ОК |
| `RetailPointId` | `retailStore.meta` | ОК |
| `CustomerId` | `agent.meta` | ОК (nullable если не знаем покупателя) |
| `CashierUserId` | **нет напрямую**; `retailShift` → cashier | ⚠️ |
| `CurrencyId` | `rate.currency.meta` | ОК |
| `Subtotal, DiscountTotal, Total` | `sum` (= Total) | ⚠️ MS **не хранит subtotal и discount total отдельно** — только total. Но цена в позиции уже после скидки? Нет — `positions[].discount` хранится, total = sum(price*qty - discount) |
| `Payment` (PaymentMethod enum) | **cashSum + noCashSum + qrSum** | ⚠️ MS — **не enum, а суммы по видам оплаты**. Т.е. при mixed-оплате можно часть наличными + часть картой. **Наш enum Payment + PaidCash + PaidCard — неполный.** TODO: добавить `PaidQr` + убрать enum в пользу «сколько чем заплачено» |
| `PaidCash` | `cashSum` | ⚠️ у MS в копейках |
| `PaidCard` | `noCashSum` | ⚠️ в копейках |
| `Notes` | `description` | ОК |
| `PostedAt` | — | наш |
| `PostedByUserId` | `owner.meta` | условно |
| — | `qrSum` | **добавить `PaidQr`** (QR-оплата актуальна для КZ) |
| — | `retailShift.meta` | кассовая смена (отложить) |
| — | `fiscal` | пробит ли фискально |
| — | `syncId` | идентификатор для офлайн-касс (при резинхроне) |
| — | `prepaymentCashSum/NoCashSum/QrSum, advancePaymentSum` | предоплаты |
**RetailSale.Positions (RetailSaleLine) → retaildemand/{id}/positions:**
Ключи: `accountId, assortment, discount, id, meta, price, quantity, vat, vatEnabled`.
| Наше (RetailSaleLine) | MS | Комментарий |
|---|---|---|
| `ProductId` | `assortment.meta` | ОК |
| `Quantity` | `quantity` | ОК |
| `UnitPrice` | `price` | ⚠️ копейки |
| `Discount` | `discount` | ОК |
| `LineTotal` | вычисляется | наш |
| `VatPercent` | `vat` | ОК (snapshot) |
| `SortOrder` | — | наш UX |
| — | `vatEnabled` | |
**TODO RetailSale:**
1. Добавить `PaidQr: decimal`.
2. **Убрать `PaymentMethod` enum** в пользу денормализованных `PaidCash, PaidCard, PaidQr, PaidBonus` + computed `Method` (если все кроме одного = 0 → Cash/Card/QR, иначе Mixed). Либо оставить enum + PayHint, но быть готовым к частичной оплате.
3. `VatEnabled, VatIncluded, VatSum` (сумма НДС на документ — вычисляется).
4. Комментарий: MS `price/cashSum/noCashSum` в копейках при импорте.
---
## Stock → `report/stock/bystore`
У MS **нет отдельной сущности "Stock"** — это **отчёт**. Ответ `report/stock/bystore` содержит:
```json
{ "meta": {...}, "stockByStore": [ { "name": "Склад №1", "meta": {...}, "stock": 10.0, "reserve": 2.0, "inTransit": 0.0, "quantity": 12.0 } ] }
```
Т.е. по каждому (product, store) — stock (сколько есть), reserve (резерв), inTransit (в пути), quantity = stock+inTransit.
У нас `Stock`**материализованный агрегат** (Quantity, ReservedQuantity, computed Available). Это **технически наше решение**, не требование бизнеса.
**TODO:**
1. Комментарий в Stock.cs: объяснить, что это материализация (у MS динамический репорт).
2. **Добавить `InTransit: decimal`** — товар в пути (между складами при перемещении).
---
## StockMovement — у MoySklad такой сущности нет
⚠️ MS **не хранит journal движений** в явном виде — остатки рассчитываются в реальном времени из документов (supply, retaildemand, loss, enter, move и т.д.). Поэтому на вопрос «почему на складе минус 5» MS возвращает историю документов, а не journal.
Наше `StockMovement`**явный immutable journal**. Обоснование:
1. Мгновенный ответ на «почему такой остаток» без пробегания по всем документам.
2. Атомарные корректировки при баг-фиксах миграций.
3. Упрощённая репликация в офлайн-кассы.
Это **сознательное отклонение** от MS — должно быть задокументировано в коде и в `docs/`. **TODO:** комментарий в StockMovement.cs + упоминание в `CLAUDE.md`.
---
## Свод по приоритетам
### Приоритет 1 — базовая совместимость импорта (на этой неделе):
- Product: `Code`, `ExternalCode`, `TrackingType` (enum) вместо `IsMarked`, `MinPrice`/`MinPriceCurrencyId`, `PaymentItemType` (enum)
- Counterparty: `CounterpartyType.IndividualEntrepreneur`, `ExternalCode`, tags (коллекция)
- ProductGroup: `ExternalCode`
- PriceType: `ExternalCode`
- Country, Currency, UnitOfMeasure, Store: `ExternalCode`
- RetailPoint: `DefaultPriceTypeId`
### Приоритет 2 — смысловые (следующая итерация):
- RetailSale: `PaidQr`, убрать enum PaymentMethod в пользу суммовых полей
- Supply: `Overhead`, `VatSum`, `VatEnabled`, `VatIncluded`
- Organization: `LegalName`, `LegalAddress`, `PayerVat`, `DirectorName`
- Product: `Volume, Weight, DiscountProhibited`
- Stock: `InTransit`
### Приоритет 3 — при необходимости:
- Counterparty: коллекция `Account`, `DefaultPriceType`
- ProductGroup: `VatRateId?` + `UseParentVat`
- RetailPoint: кассовые настройки (allowCustomPrice, discountMaxPercent, cashiers...)
- Store: slots, zones
### Сознательно не копируем MS:
- `CounterpartyKind` (Supplier/Customer/Both) — у нас enum, у MS теги. Оставляем для UX/фильтрации.
- `Store.Kind` (Warehouse vs RetailFloor) — у MS нет, нам нужно.
- `VatRate` как отдельная сущность — у MS число на товаре. У нас справочник ради локализации.
- `StockMovement` journal — у MS нет. Выбор архитектуры.
- `Product.IsWeighed` / `IsAlcohol` — упрощения под ритейл.
- `UnitOfMeasure.Symbol`, `DecimalPlaces` — UX.

100
docs/forgejo.md Normal file
View file

@ -0,0 +1,100 @@
# Forgejo как primary git
GitHub из KZ периодически роняет TCP (см. `network_github_flaky.md`). Чтобы push/pull не превращались в лотерею, на стейдж-сервере поднят Forgejo — self-hosted git-сервис (форк Gitea), он работает локально и не зависит от upstream-флапов. GitHub продолжает жить как **зеркало** (для видимости, CI-интеграций, бэкапа).
## Адреса
- **Web UI:** https://git.zat.kz (после certbot; до этого — http:// если DNS уже указан)
- **Git HTTPS:** https://git.zat.kz/nns/food-market.git
- **Git SSH:** `ssh://git@git.zat.kz:2222/nns/food-market.git`
SSH-порт 2222 (хостовой 22 занят системным sshd).
## Первый раз с Mac/iPhone
### 1. Добавить remote
В локальной копии `food-market`:
```bash
# оставляем github как origin (привычно), добавляем forgejo как primary
git remote add forgejo ssh://git@git.zat.kz:2222/nns/food-market.git
# либо делаем forgejo основным и github запасным:
git remote rename origin github
git remote add origin ssh://git@git.zat.kz:2222/nns/food-market.git
git branch --set-upstream-to=origin/main main
```
Клонировать с нуля:
```bash
git clone ssh://git@git.zat.kz:2222/nns/food-market.git
```
### 2. SSH-ключ
На Forgejo в `Settings → SSH/GPG Keys → Add Key` добавить публичный ключ (`~/.ssh/id_ed25519.pub` с Mac, либо через Working Copy на iPhone — Settings → Key Management → Generate/Export Public Key).
### 3. Обычный цикл
```bash
git pull # (или git pull forgejo main)
# ...работа...
git commit -am "…"
git push # мгновенно, внутри ДЦ
```
## Как это связано с GitHub
- **push → Forgejo:** primary, мгновенный.
- **Forgejo → GitHub** раз в 10 минут пушится автоматически сервисом `food-market-forgejo-mirror.timer`. Если GitHub недоступен — следующий тик повторит. Cкрипт: `/usr/local/bin/food-market-forgejo-mirror.sh`, лог `/var/log/food-market-forgejo-mirror.log`.
- **CI:** GitHub Actions на self-hosted runner'е (уже настроено). Запускается от коммитов, пришедших через зеркало. Если когда-нибудь понадобится CI на Forgejo'ых Actions — docs/forgejo-actions.md (пока не настроено).
То есть рабочий флоу: пуш в Forgejo → через ≤10 мин коммит в GitHub → триггер CI → деплой.
## Эксплуатация
```bash
# состояние
sudo systemctl status food-market-forgejo.service # контейнер Forgejo
sudo systemctl status food-market-forgejo-mirror.timer # расписание зеркала
sudo systemctl status food-market-forgejo-mirror.service # последняя попытка зеркала
tail -f /var/log/food-market-forgejo-mirror.log # живой лог зеркала
# прогнать зеркало прямо сейчас (не дожидаясь таймера)
sudo systemctl start food-market-forgejo-mirror.service
# рестарт Forgejo (редко нужно)
sudo systemctl restart food-market-forgejo.service
```
## Раскладка
- docker-compose: `deploy/forgejo/docker-compose.yml` (образ `codeberg.org/forgejo/forgejo:7`, sqlite, SSH через OpenSSH образа)
- systemd unit Forgejo: `/etc/systemd/system/food-market-forgejo.service` (copy в `deploy/forgejo/`)
- mirror script: `/usr/local/bin/food-market-forgejo-mirror.sh` (copy в `deploy/forgejo/mirror-to-github.sh`)
- mirror timer/service: `food-market-forgejo-mirror.{timer,service}` (copy в `deploy/forgejo/`)
- nginx vhost: `/etc/nginx/conf.d/git.zat.kz.conf` (copy в `deploy/forgejo/nginx.conf`)
- data: `/opt/food-market-data/forgejo/data` (sqlite + repos + ssh host keys)
- конфиг Forgejo: `/opt/food-market-data/forgejo/data/gitea/conf/app.ini`
- GitHub mirror token: `/etc/food-market/github-mirror-token` (PAT с `repo` scope, читает mirror-скрипт)
- локальное зеркало для push в github: `/opt/food-market-data/forgejo/mirror` (bare repo)
## Что ещё нужно от вас (разовое)
1. **DNS A-запись** `git.zat.kz → 88.204.171.93` (основной IP сервера).
2. После того как DNS прорастёт:
```bash
sudo certbot --nginx -d git.zat.kz
```
Certbot выпустит TLS-сертификат и обновит nginx-конфиг (добавит блок 443 + редирект 80→443).
3. Записать пароль администратора: файл `/tmp/forgejo-admin.txt` (создан при первой установке, надо скопировать себе в хранилище паролей и удалить с сервера).
## Обратный путь
Если Forgejo сломается и нужно срочно пушить напрямую в GitHub:
```bash
git push github main
```
GitHub — полная копия (mirror-таймер гонит всё: branches + tags). Рабочий флоу не ломается.

59
docs/stage-access.md Normal file
View file

@ -0,0 +1,59 @@
# Доступ к stage food-market.zat.kz
## Текущая ситуация
- **Stage запущен** на `88.204.171.93` через docker compose в `~/food-market-stage/deploy/`
- **Порты внутри:** API 8080, Web 8081, Postgres 5434 (localhost)
- **Внешний доступ к 8080/8081 заблокирован** на уровне Proxmox/провайдера
- **Открыты снаружи:** 80, 443 (для существующих сайтов через nginx)
## Что уже настроено
В `/etc/nginx/conf.d/food-market-stage.conf` добавлен vhost:
```
server {
listen 80;
server_name food-market.zat.kz;
location / { proxy_pass http://127.0.0.1:8081; ... }
}
```
## Что нужно сделать (одноразово)
### 1. Поднять DNS A-запись
В DNS-провайдере зоны `zat.kz` (Cloudflare?) добавить:
```
food-market.zat.kz A 88.204.171.93 TTL 300
```
### 2. Выпустить SSL через certbot
После того как DNS прописан и распространился (5-10 мин):
```bash
ssh -p 9393 nns@88.204.171.93 'sudo certbot --nginx -d food-market.zat.kz --non-interactive --agree-tos -m admin@zat.kz'
```
После этого: https://food-market.zat.kz — рабочая stage-админка.
## Альтернатива — открыть порт в Proxmox
Если не хочется заводить subdomain, можно просто открыть `8081` в Proxmox firewall:
- Проверить: что-то типа Datacenter → Firewall → Add Rule (если firewall на уровне DC)
- Или Node → Firewall → Add Rule (если на уровне VM)
- Action: Accept, Direction: in, Protocol: tcp, Dest port: 8081
Тогда работать будет на http://88.204.171.93:8081 (но без HTTPS).
## Тест без DNS — SSH-туннель
С Mac/iPhone (через Termius):
```bash
ssh -L 8081:localhost:8081 -p 9393 nns@88.204.171.93
```
Открыть в браузере http://localhost:8081 — пойдёт через тоннель.
## Когда запустится Claude на сервере
Я завершу всю эту настройку (включая DNS если ты дашь доступ к Cloudflare) и пришлю Telegram «Stage live: https://...».

93
docs/stage-setup.md Normal file
View file

@ -0,0 +1,93 @@
# Первичная настройка stage-сервера (88.204.171.93)
**Разовая процедура.** После этого деплой происходит автоматически на каждый push в `main`.
## Текущее состояние сервера (проверено)
- Ubuntu 24.04.3, 4 CPU, 15 ГБ RAM (8 ГБ свободно)
- **Диск 19 ГБ, свободно 4 ГБ** ← узкое место, нужно следить
- Docker 28.2.2 установлен ✓
- PostgreSQL 14/16 на 5432 (используется существующими приложениями)
- Порты 80/443 заняты legacy nginx
- Порты 5000, 5002, 5005 заняты legacy .NET (food-market-server, calcman, makesales)
- SSH: `nns@88.204.171.93:9393`
## Шаг 1 — выдать nns доступ к Docker (ОДНОРАЗОВО)
На сервере:
```bash
ssh -p 9393 nns@88.204.171.93
sudo usermod -aG docker nns
exit
```
**Важно:** после этой команды разлогинься из SSH и залогинься снова — групповые права применяются только при новой сессии.
Проверь:
```bash
ssh -p 9393 nns@88.204.171.93 'docker ps'
```
Должно выдать список контейнеров (сейчас пустой) без permission denied.
## Шаг 2 — задать пароль для stage postgres
Генерим рандомный 32-символьный пароль и кладём его в GitHub Secrets:
```bash
# На твоём Mac
PASS=$(openssl rand -base64 24 | tr -d '=+/' | head -c 32)
gh secret set STAGE_POSTGRES_PASSWORD --repo nurdotnet/food-market --body "$PASS"
# Сохрани его же в файл на всякий случай:
echo "POSTGRES_PASSWORD=$PASS" > ~/.food-market-secrets/stage-postgres.env
chmod 600 ~/.food-market-secrets/stage-postgres.env
```
## Шаг 3 — проверить порты
Наш stage слушает:
- **8080** — API (health: `curl http://88.204.171.93:8080/health`)
- **8081** — Web (SPA с reverse-proxy на API)
- **5434** — Postgres (только localhost, не наружу)
Проверь что эти порты ещё не заняты:
```bash
ssh -p 9393 nns@88.204.171.93 'ss -tlnp | grep -E "(8080|8081|5434)"'
```
Если пусто — всё ок.
## Шаг 4 — первый ручной деплой (для проверки)
После того как GitHub Actions собрал образы (это происходит автоматически при пуше), запусти workflow вручную:
```bash
gh workflow run deploy-stage.yml --repo nurdotnet/food-market
# Смотри статус:
gh run watch --repo nurdotnet/food-market
```
После успеха откроется: http://88.204.171.93:8081 — это stage-админка.
## Мониторинг диска
Добавь cron на stage-сервере:
```bash
ssh -p 9393 nns@88.204.171.93
crontab -e
```
Добавить строку:
```
0 */6 * * * /usr/bin/df -h / | awk '/\/$/ {if ($5+0 > 85) system("curl -sS -X POST https://api.telegram.org/bot$TG_TOKEN/sendMessage --data-urlencode chat_id=$TG_CHAT --data-urlencode text=\"Disk on stage: "$5" used\"")}'
```
(Подставь реальные TG_TOKEN и TG_CHAT, или используй `source ~/.food-market-secrets/telegram.env` в cron-wrapper.)
## Что происходит при каждом push в main
```
push → Github Actions:
1. CI (backend build + web build) — если упал, Telegram "CI FAILED"
2. Docker Images (api + web → ghcr.io) — если упал, Telegram "CI FAILED"
3. Deploy stage (после успешного Docker) →
ssh nns@stage → docker compose pull → up -d → curl /health
Если успешно — Telegram "Deploy stage OK — SHA — http://..."
Если упало — Telegram "Deploy stage FAILED — ссылка на лог"
```
Ты видишь уведомление в Telegram, открываешь stage, проверяешь, говоришь «мёрджим в prod» или «откатывай».

86
docs/telegram-bridge.md Normal file
View file

@ -0,0 +1,86 @@
# Telegram ↔ tmux bridge
Управление локальной сессией Claude Code с телефона через Telegram-бота. Входящее сообщение от whitelisted `chat_id` набирается в tmux-сессию `claude` как будто вы сами печатаете; ответный вывод пайнa каждые ~2.5 с отправляется обратно в чат.
## Как это выглядит в работе
- Вы пишете боту: `запусти тесты`
- Бот делает `tmux send-keys -t claude -l "запусти тесты" && tmux send-keys -t claude Enter` — текст попадает в поле ввода Claude
- Фоновый поллер раз в 2.5 с снимает `tmux capture-pane`, сравнивает с предыдущим снапшотом, присылает новые строки как `<pre>…</pre>`-блок
Команды бота:
- `/ping` — живой ли, какая сессия и интервал
- `/snapshot` — выслать полный текущий пайн (полезно после длинного молчания или после рестарта)
## Один раз — настройка
### 1. Креды
Положите в `/etc/food-market/telegram.env`:
```
TELEGRAM_BOT_TOKEN=<токен от @BotFather>
TELEGRAM_CHAT_ID=<ваш личный chat_id, целое число>
```
Узнать `chat_id` — напишите `@userinfobot` в Telegram, он ответит с вашим id. Файл доступен только владельцу (`chmod 600`).
Только сообщения от этого **одного** chat_id будут обработаны — всё остальное молча игнорируется.
### 2. tmux-сессия `claude`
Бот ожидает существующую сессию с именем `claude`. Создайте её как обычно:
```bash
tmux new-session -d -s claude
tmux attach -t claude # и запустите внутри `claude` (или что там у вас)
```
Сервис стартует даже без сессии — в лог упадёт warning, но `send-keys` / `capture-pane` начнут работать как только сессия появится. Имя сессии можно переопределить через env `TMUX_SESSION=other` в юните.
### 3. Старт сервиса
```bash
sudo systemctl enable --now food-market-telegram-bridge.service
```
В ответ бот пришлёт `✅ bridge up …` — это индикатор успеха.
## Эксплуатация
### Логи
```bash
sudo journalctl -u food-market-telegram-bridge.service -f
sudo journalctl -u food-market-telegram-bridge.service --since '10 min ago'
```
### Перезапуск
```bash
sudo systemctl restart food-market-telegram-bridge.service
```
### Остановить
```bash
sudo systemctl stop food-market-telegram-bridge.service # до ребута
sudo systemctl disable food-market-telegram-bridge.service # и после ребута
```
### Поменять интервал/сессию
Отредактируйте `/etc/systemd/system/food-market-telegram-bridge.service`, добавьте в секцию `[Service]`:
```
Environment=POLL_INTERVAL_SEC=1.5
Environment=TMUX_SESSION=other-session
Environment=CAPTURE_HISTORY_LINES=400
```
Затем `sudo systemctl daemon-reload && sudo systemctl restart food-market-telegram-bridge`.
## Раскладка
- Скрипт: `/opt/food-market-data/telegram-bridge/bridge.py`
- venv (Python 3.12, `python-telegram-bot 21.x`): `/opt/food-market-data/telegram-bridge/venv/`
- Креды: `/etc/food-market/telegram.env` (owner `nns`, mode `0600`)
- systemd unit: `/etc/systemd/system/food-market-telegram-bridge.service`
## Что хорошо знать
- `disable_notification=True` стоит на фоновых сообщениях пайна — не будет жужжать при каждом diff'e.
- Telegram-лимит 4096 символов; длинные пайн-блоки режутся на куски по ~3800 символов.
- Если после долгого молчания в чате слишком много истории, шлите `/snapshot` — бот обнуляет baseline и присылает текущий экран целиком.
- Бот заходит в Telegram long-polling (исходящее к api.telegram.org, без входящих портов) — никакого проброса портов не нужно.

View file

@ -0,0 +1,60 @@
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Background;
/// <summary>Раз в сутки переписывает ReferencePrice = Cost для товаров,
/// у которых LastSupplyAt старше 30 дней и Cost > 0. Цель: устаревшая
/// «эталонная» цена не остаётся годами — её сравнивают с актуальной
/// себестоимостью. Если пользователь редактировал ReferencePrice вручную
/// (через PUT /api/catalog/products/...), ReferencePriceUpdatedAt уходит
/// в now, и таймер начинает 30 дней заново.</summary>
public class ReferencePriceRefreshJob : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<ReferencePriceRefreshJob> _log;
// Запускаем каждые 24 часа. Промах между сутками не критичен — цена
// не падает, а лишь догоняет текущую Cost.
private static readonly TimeSpan Period = TimeSpan.FromHours(24);
public ReferencePriceRefreshJob(IServiceProvider services, ILogger<ReferencePriceRefreshJob> log)
{
_services = services;
_log = log;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
// Ждём 5 минут после старта чтобы не упасть на ещё не применённой миграции.
try { await Task.Delay(TimeSpan.FromMinutes(5), ct); } catch (TaskCanceledException) { return; }
using var timer = new PeriodicTimer(Period);
do
{
try { await RunOnceAsync(ct); }
catch (Exception ex) { _log.LogError(ex, "ReferencePriceRefreshJob: iteration failed"); }
}
while (await timer.WaitForNextTickAsync(ct));
}
public async Task RunOnceAsync(CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var threshold = DateTime.UtcNow.AddDays(-30);
// IgnoreQueryFilters — фоновый job работает над всеми organizations.
var stale = await db.Products
.IgnoreQueryFilters()
.Where(p => p.LastSupplyAt != null && p.LastSupplyAt < threshold && p.Cost > 0m)
.ToListAsync(ct);
if (stale.Count == 0) return;
var now = DateTime.UtcNow;
foreach (var p in stale)
{
p.ReferencePrice = p.Cost;
p.ReferencePriceUpdatedAt = now;
}
await db.SaveChangesAsync(ct);
_log.LogInformation("ReferencePriceRefreshJob: refreshed {Count} stale products", stale.Count);
}
}

View file

@ -0,0 +1,201 @@
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Integrations.MoySklad;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Admin;
// Временные эндпоинты для очистки данных после кривых импортов.
// Удалять только свой tenant — query-filter на DbSets это обеспечивает.
[ApiController]
[Authorize(Policy = "AdminAccess")]
[Route("api/admin/cleanup")]
public class AdminCleanupController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IServiceScopeFactory _scopes;
private readonly ImportJobRegistry _jobs;
private readonly ITenantContext _tenant;
public AdminCleanupController(
AppDbContext db,
IServiceScopeFactory scopes,
ImportJobRegistry jobs,
ITenantContext tenant)
{
_db = db;
_scopes = scopes;
_jobs = jobs;
_tenant = tenant;
}
public record CleanupStats(
int Counterparties,
int Products,
int ProductGroups,
int ProductBarcodes,
int ProductPrices,
int Supplies,
int RetailSales,
int Stocks,
int StockMovements);
public record CleanupResult(string Scope, CleanupStats Deleted);
[HttpGet("stats")]
public async Task<ActionResult<CleanupStats>> GetStats(CancellationToken ct)
=> new CleanupStats(
await _db.Counterparties.CountAsync(ct),
await _db.Products.CountAsync(ct),
await _db.ProductGroups.CountAsync(ct),
await _db.ProductBarcodes.CountAsync(ct),
await _db.ProductPrices.CountAsync(ct),
await _db.Supplies.CountAsync(ct),
await _db.RetailSales.CountAsync(ct),
await _db.Stocks.CountAsync(ct),
await _db.StockMovements.CountAsync(ct));
/// <summary>Удалить всех контрагентов текущей организации. Чтобы не нарваться на FK,
/// сначала обнуляем ссылки (Product.DefaultSupplier, RetailSale.Customer) и сносим
/// поставки (они жёстко ссылаются на supplier).</summary>
[HttpDelete("counterparties")]
public async Task<ActionResult<CleanupResult>> WipeCounterparties(CancellationToken ct)
{
var before = await SnapshotAsync(ct);
// 1. Обнуляем nullable-FK
await _db.Products
.Where(p => p.DefaultSupplierId != null)
.ExecuteUpdateAsync(u => u.SetProperty(p => p.DefaultSupplierId, (Guid?)null), ct);
await _db.RetailSales
.Where(s => s.CustomerId != null)
.ExecuteUpdateAsync(u => u.SetProperty(s => s.CustomerId, (Guid?)null), ct);
// 2. Сносим поставки (NOT NULL supplier) + их stock movements/stocks
await _db.StockMovements
.Where(m => m.DocumentType == "supply" || m.DocumentType == "supply-reversal")
.ExecuteDeleteAsync(ct);
await _db.SupplyLines.ExecuteDeleteAsync(ct);
await _db.Supplies.ExecuteDeleteAsync(ct);
// 3. Контрагенты
await _db.Counterparties.ExecuteDeleteAsync(ct);
var after = await SnapshotAsync(ct);
return new CleanupResult("counterparties", Diff(before, after));
}
// Async-версия полной очистки: возвращает jobId, реальные DELETE в фоне, прогресс в Stage+Deleted.
[HttpPost("all/async")]
public ActionResult<object> WipeAllAsync()
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var job = _jobs.Create("cleanup-all");
job.Stage = "Подготовка…";
_ = Task.Run(async () =>
{
try
{
using var tenantScope = HttpContextTenantContext.UseOverride(orgId);
using var scope = _scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var steps = new (string Stage, Func<Task<int>> Run)[]
{
("Движения склада", () => db.StockMovements.ExecuteDeleteAsync()),
("Остатки", () => db.Stocks.ExecuteDeleteAsync()),
("Строки поставок", () => db.SupplyLines.ExecuteDeleteAsync()),
("Поставки", () => db.Supplies.ExecuteDeleteAsync()),
("Строки продаж", () => db.RetailSaleLines.ExecuteDeleteAsync()),
("Продажи", () => db.RetailSales.ExecuteDeleteAsync()),
("Изображения товаров", () => db.ProductImages.ExecuteDeleteAsync()),
("Цены товаров", () => db.ProductPrices.ExecuteDeleteAsync()),
("Штрихкоды", () => db.ProductBarcodes.ExecuteDeleteAsync()),
("Товары", () => db.Products.ExecuteDeleteAsync()),
("Группы товаров", () => db.ProductGroups.ExecuteDeleteAsync()),
("Контрагенты", () => db.Counterparties.ExecuteDeleteAsync()),
};
foreach (var (stage, run) in steps)
{
job.Stage = $"Удаление: {stage}…";
job.Deleted += await run();
}
job.Stage = "Готово";
job.Message = $"Удалено записей: {job.Deleted}.";
job.Status = ImportJobStatus.Succeeded;
}
catch (Exception ex)
{
job.Status = ImportJobStatus.Failed;
job.Message = ex.Message;
job.Errors.Add(ex.ToString());
}
finally
{
job.FinishedAt = DateTime.UtcNow;
}
});
return Ok(new { jobId = job.Id });
}
/// <summary>Полная очистка данных текущей организации — всё кроме настроек:
/// остаются Organization, пользователи, справочники (Country, Currency, UnitOfMeasure,
/// PriceType), Store и RetailPoint. Удаляются: Product*, ProductGroup, Counterparty,
/// Supply*, RetailSale*, Stock, StockMovement.</summary>
[HttpDelete("all")]
public async Task<ActionResult<CleanupResult>> WipeAll(CancellationToken ct)
{
var before = await SnapshotAsync(ct);
// Documents first — they reference products, counterparties, stores.
await _db.StockMovements.ExecuteDeleteAsync(ct);
await _db.Stocks.ExecuteDeleteAsync(ct);
await _db.SupplyLines.ExecuteDeleteAsync(ct);
await _db.Supplies.ExecuteDeleteAsync(ct);
await _db.RetailSaleLines.ExecuteDeleteAsync(ct);
await _db.RetailSales.ExecuteDeleteAsync(ct);
// Product composites.
await _db.ProductImages.ExecuteDeleteAsync(ct);
await _db.ProductPrices.ExecuteDeleteAsync(ct);
await _db.ProductBarcodes.ExecuteDeleteAsync(ct);
// Products reference counterparty.DefaultSupplier — FK Restrict, but we're about
// to delete products anyway, so order products → counterparties.
await _db.Products.ExecuteDeleteAsync(ct);
await _db.ProductGroups.ExecuteDeleteAsync(ct);
await _db.Counterparties.ExecuteDeleteAsync(ct);
var after = await SnapshotAsync(ct);
return new CleanupResult("all", Diff(before, after));
}
private async Task<CleanupStats> SnapshotAsync(CancellationToken ct) => new(
await _db.Counterparties.CountAsync(ct),
await _db.Products.CountAsync(ct),
await _db.ProductGroups.CountAsync(ct),
await _db.ProductBarcodes.CountAsync(ct),
await _db.ProductPrices.CountAsync(ct),
await _db.Supplies.CountAsync(ct),
await _db.RetailSales.CountAsync(ct),
await _db.Stocks.CountAsync(ct),
await _db.StockMovements.CountAsync(ct));
private static CleanupStats Diff(CleanupStats a, CleanupStats b) => new(
a.Counterparties - b.Counterparties,
a.Products - b.Products,
a.ProductGroups - b.ProductGroups,
a.ProductBarcodes - b.ProductBarcodes,
a.ProductPrices - b.ProductPrices,
a.Supplies - b.Supplies,
a.RetailSales - b.RetailSales,
a.Stocks - b.Stocks,
a.StockMovements - b.StockMovements);
}

View file

@ -0,0 +1,41 @@
using foodmarket.Infrastructure.Integrations.MoySklad;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace foodmarket.Api.Controllers.Admin;
[ApiController]
[Authorize(Policy = "AdminAccess")]
[Route("api/admin/jobs")]
public class AdminJobsController : ControllerBase
{
private readonly ImportJobRegistry _jobs;
public AdminJobsController(ImportJobRegistry jobs) => _jobs = jobs;
public record JobView(
Guid Id,
string Kind,
string Status,
string? Stage,
DateTime StartedAt,
DateTime? FinishedAt,
int Total, int Created, int Updated, int Skipped, int Deleted, int GroupsCreated,
string? Message,
IReadOnlyList<string> Errors);
private static JobView Project(ImportJobProgress j) => new(
j.Id, j.Kind, j.Status.ToString(), j.Stage, j.StartedAt, j.FinishedAt,
j.Total, j.Created, j.Updated, j.Skipped, j.Deleted, j.GroupsCreated,
j.Message, j.Errors.TakeLast(20).ToList());
[HttpGet("{id:guid}")]
public ActionResult<JobView> Get(Guid id)
{
var j = _jobs.Get(id);
return j is null ? NotFound() : Project(j);
}
[HttpGet("recent")]
public IReadOnlyList<JobView> Recent([FromQuery] int take = 10)
=> _jobs.RecentlyFinished(take).Select(Project).ToList();
}

View file

@ -0,0 +1,167 @@
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Integrations.MoySklad;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Admin;
[ApiController]
[Authorize(Policy = "AdminAccess")]
[Route("api/admin/moysklad")]
public class MoySkladImportController : ControllerBase
{
private readonly IServiceScopeFactory _scopes;
private readonly MoySkladImportService _svc;
private readonly ImportJobRegistry _jobs;
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public MoySkladImportController(
IServiceScopeFactory scopes,
MoySkladImportService svc,
ImportJobRegistry jobs,
AppDbContext db,
ITenantContext tenant)
{
_scopes = scopes;
_svc = svc;
_jobs = jobs;
_db = db;
_tenant = tenant;
}
public record TestRequest(string? Token = null);
public record ImportRequest(string? Token = null, bool OverwriteExisting = false);
public record SettingsDto(bool HasToken, string? Masked);
public record SettingsInput(string Token);
[HttpGet("settings")]
public async Task<ActionResult<SettingsDto>> GetSettings(CancellationToken ct)
{
var token = await ReadTokenFromOrgAsync(ct);
return new SettingsDto(!string.IsNullOrEmpty(token), Mask(token));
}
[HttpPut("settings")]
public async Task<ActionResult<SettingsDto>> SetSettings([FromBody] SettingsInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var org = await _db.Organizations.FirstOrDefaultAsync(o => o.Id == orgId, ct);
if (org is null) return NotFound();
org.MoySkladToken = string.IsNullOrWhiteSpace(input.Token) ? null : input.Token.Trim();
await _db.SaveChangesAsync(ct);
return new SettingsDto(!string.IsNullOrEmpty(org.MoySkladToken), Mask(org.MoySkladToken));
}
[HttpPost("test")]
public async Task<IActionResult> TestConnection([FromBody] TestRequest req, CancellationToken ct)
{
var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { error = "Token is required." });
var result = await _svc.TestConnectionAsync(token, ct);
if (!result.Success)
{
var msg = result.StatusCode switch
{
401 or 403 => "Токен недействителен или не имеет доступа к API.",
503 or 502 => "МойСклад временно недоступен. Повтори через минуту.",
_ => $"МойСклад вернул {result.StatusCode}: {Truncate(result.Error, 400)}",
};
return StatusCode(result.StatusCode ?? 502, new { error = msg });
}
return Ok(new { organization = result.Value!.Name, inn = result.Value.Inn });
}
// Async launch — возвращает jobId, реальная работа в фоне. Клиент полит /jobs/{id}.
[HttpPost("import-products")]
public async Task<ActionResult<object>> ImportProducts([FromBody] ImportRequest req, CancellationToken ct)
{
var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { error = "Токен не настроен. Сохраните его в /admin/moysklad/settings." });
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var job = _jobs.Create("products");
job.Stage = "Подключение к MoySklad…";
_ = RunInBackgroundAsync(job, async (svc, progress, ctInner) =>
{
progress.Stage = "Импорт товаров…";
var result = await svc.ImportProductsAsync(token, req.OverwriteExisting, ctInner, progress);
progress.Message = $"Готово: {result.Created} записей (создано/обновлено), {result.Skipped} пропущено, {result.GroupsCreated} групп.";
}, orgId);
return Ok(new { jobId = job.Id });
}
[HttpPost("import-counterparties")]
public async Task<ActionResult<object>> ImportCounterparties([FromBody] ImportRequest req, CancellationToken ct)
{
var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { error = "Токен не настроен. Сохраните его в /admin/moysklad/settings." });
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var job = _jobs.Create("counterparties");
job.Stage = "Подключение к MoySklad…";
_ = RunInBackgroundAsync(job, async (svc, progress, ctInner) =>
{
progress.Stage = "Импорт контрагентов…";
var result = await svc.ImportCounterpartiesAsync(token, req.OverwriteExisting, ctInner, progress);
progress.Message = $"Готово: {result.Created} записей, {result.Skipped} пропущено.";
}, orgId);
return Ok(new { jobId = job.Id });
}
private Task RunInBackgroundAsync(
ImportJobProgress job,
Func<MoySkladImportService, ImportJobProgress, CancellationToken, Task> work,
Guid orgId)
{
return Task.Run(async () =>
{
try
{
using var tenantScope = HttpContextTenantContext.UseOverride(orgId);
using var scope = _scopes.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<MoySkladImportService>();
await work(svc, job, CancellationToken.None);
job.Status = ImportJobStatus.Succeeded;
}
catch (Exception ex)
{
job.Status = ImportJobStatus.Failed;
job.Message = ex.Message;
job.Errors.Add(ex.ToString());
}
finally
{
job.FinishedAt = DateTime.UtcNow;
}
});
}
private async Task<string?> ReadTokenFromOrgAsync(CancellationToken ct)
{
var orgId = _tenant.OrganizationId;
if (orgId is null) return null;
return await _db.Organizations
.Where(o => o.Id == orgId)
.Select(o => o.MoySkladToken)
.FirstOrDefaultAsync(ct);
}
private static string? Mask(string? token)
{
if (string.IsNullOrEmpty(token)) return null;
if (token.Length <= 8) return new string('•', token.Length);
return token[..4] + new string('•', 8) + token[^4..];
}
private static string? Truncate(string? s, int max)
=> string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max] + "…");
}

View file

@ -0,0 +1,96 @@
using foodmarket.Api.Seed;
using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers;
/// <summary>Самообслуживание: регистрация новой организации с публичного
/// маркетингового сайта. Создаёт Organization + bootstrap (Stores, Roles,
/// Units, PriceTypes, Cassa) + первого Owner-Employee-AppUser-Admin.
///
/// Токены НЕ выпускаются здесь — фронт получает их обычным запросом
/// /connect/token (password grant) сразу после успешного signup. Это
/// убирает дублирование с OpenIddict и упрощает контракт. Phase 6: без
/// email-верификации (переедет в Phase 7).</summary>
[ApiController]
[Route("api/auth")]
public class AuthSignupController : ControllerBase
{
private readonly AppDbContext _db;
private readonly UserManager<User> _userMgr;
public AuthSignupController(AppDbContext db, UserManager<User> userMgr)
{
_db = db; _userMgr = userMgr;
}
public record SignupInput(string Email, string Password, string OrganizationName, string? Phone, string? Plan);
public record SignupResult(Guid OrganizationId, string Email);
[HttpPost("signup")]
public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput input, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.Email) || string.IsNullOrWhiteSpace(input.Password)
|| string.IsNullOrWhiteSpace(input.OrganizationName))
return BadRequest(new { error = "Email, пароль и название обязательны." });
if (input.Password.Length < 8)
return BadRequest(new { error = "Пароль минимум 8 символов." });
var existing = await _userMgr.FindByEmailAsync(input.Email);
if (existing is not null)
return BadRequest(new { error = "Пользователь с таким email уже зарегистрирован." });
// 1. Organization + полный bootstrap tenant-сущностей.
var kzt = await _db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct);
var org = new Organization
{
Name = input.OrganizationName.Trim(),
CountryCode = "KZ",
DefaultCurrencyId = kzt?.Id,
Phone = string.IsNullOrWhiteSpace(input.Phone) ? null : input.Phone.Trim(),
Email = input.Email.Trim(),
};
_db.Organizations.Add(org);
await _db.SaveChangesAsync(ct);
await DevDataSeeder.SeedTenantReferencesAsync(_db, org.Id, ct);
// 2. AppUser в роли Identity Admin, привязан к этой организации.
var user = new User
{
UserName = input.Email.Trim(),
Email = input.Email.Trim(),
EmailConfirmed = true,
FullName = input.OrganizationName.Trim(),
OrganizationId = org.Id,
IsActive = true,
};
var ur = await _userMgr.CreateAsync(user, input.Password);
if (!ur.Succeeded)
{
// Откат: убираем органзацию чтобы не оставить orphan.
_db.Organizations.Remove(org);
await _db.SaveChangesAsync(ct);
return BadRequest(new { error = string.Join("; ", ur.Errors.Select(e => e.Description)) });
}
await _userMgr.AddToRoleAsync(user, "Admin");
// 3. Owner Employee с системной ролью «Администратор».
var adminRole = await _db.EmployeeRoles.IgnoreQueryFilters()
.FirstAsync(r => r.OrganizationId == org.Id && r.IsSystem && r.Name == "Администратор", ct);
_db.Employees.Add(new Employee
{
OrganizationId = org.Id, UserId = user.Id,
LastName = input.OrganizationName.Trim(), FirstName = "Owner",
Position = "Владелец", Email = input.Email.Trim(),
RoleId = adminRole.Id, IsActive = true,
});
org.AccountOwnerUserId = user.Id;
await _db.SaveChangesAsync(ct);
return new SignupResult(org.Id, user.Email);
}
}

View file

@ -20,14 +20,9 @@ public class CounterpartiesController : ControllerBase
[HttpGet] [HttpGet]
public async Task<ActionResult<PagedResult<CounterpartyDto>>> List( public async Task<ActionResult<PagedResult<CounterpartyDto>>> List(
[FromQuery] PagedRequest req, [FromQuery] PagedRequest req,
[FromQuery] CounterpartyKind? kind,
CancellationToken ct) CancellationToken ct)
{ {
var q = _db.Counterparties.Include(c => c.Country).AsNoTracking().AsQueryable(); var q = _db.Counterparties.Include(c => c.Country).AsNoTracking().AsQueryable();
if (kind is not null)
{
q = q.Where(c => c.Kind == kind || c.Kind == CounterpartyKind.Both);
}
if (!string.IsNullOrWhiteSpace(req.Search)) if (!string.IsNullOrWhiteSpace(req.Search))
{ {
var s = req.Search.Trim().ToLower(); var s = req.Search.Trim().ToLower();
@ -39,14 +34,26 @@ public class CounterpartiesController : ControllerBase
(c.Phone != null && c.Phone.Contains(s))); (c.Phone != null && c.Phone.Contains(s)));
} }
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("type", false) => q.OrderBy(c => c.Type).ThenBy(c => c.Name),
("type", true) => q.OrderByDescending(c => c.Type).ThenBy(c => c.Name),
("country", false) => q.OrderBy(c => c.Country != null ? c.Country.Name : null).ThenBy(c => c.Name),
("country", true) => q.OrderByDescending(c => c.Country != null ? c.Country.Name : null).ThenBy(c => c.Name),
("legalName", false) => q.OrderBy(c => c.LegalName).ThenBy(c => c.Name),
("legalName", true) => q.OrderByDescending(c => c.LegalName).ThenBy(c => c.Name),
("phone", false) => q.OrderBy(c => c.Phone).ThenBy(c => c.Name),
("phone", true) => q.OrderByDescending(c => c.Phone).ThenBy(c => c.Name),
("name", true) => q.OrderByDescending(c => c.Name),
_ => q.OrderBy(c => c.Name),
};
var items = await q var items = await q
.OrderBy(c => c.Name)
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(c => new CounterpartyDto( .Select(c => new CounterpartyDto(
c.Id, c.Name, c.LegalName, c.Kind, c.Type, c.Id, c.Name, c.LegalName, c.Type,
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null, c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null,
c.Address, c.Phone, c.Email, c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive)) c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<CounterpartyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<CounterpartyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -56,10 +63,10 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
{ {
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return c is null ? NotFound() : new CounterpartyDto( return c is null ? NotFound() : new CounterpartyDto(
c.Id, c.Name, c.LegalName, c.Kind, c.Type, c.Id, c.Name, c.LegalName, c.Type,
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name, c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
c.Address, c.Phone, c.Email, c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive); c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes);
} }
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
@ -95,7 +102,6 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i)
{ {
e.Name = i.Name; e.Name = i.Name;
e.LegalName = i.LegalName; e.LegalName = i.LegalName;
e.Kind = i.Kind;
e.Type = i.Type; e.Type = i.Type;
e.Bin = i.Bin; e.Bin = i.Bin;
e.Iin = i.Iin; e.Iin = i.Iin;
@ -109,7 +115,6 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i)
e.Bik = i.Bik; e.Bik = i.Bik;
e.ContactPerson = i.ContactPerson; e.ContactPerson = i.ContactPerson;
e.Notes = i.Notes; e.Notes = i.Notes;
e.IsActive = i.IsActive;
return e; return e;
} }
@ -117,9 +122,9 @@ private async Task<CounterpartyDto> ProjectAsync(Guid id, CancellationToken ct)
{ {
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstAsync(x => x.Id == id, ct); var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstAsync(x => x.Id == id, ct);
return new CounterpartyDto( return new CounterpartyDto(
c.Id, c.Name, c.LegalName, c.Kind, c.Type, c.Id, c.Name, c.LegalName, c.Type,
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name, c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
c.Address, c.Phone, c.Email, c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive); c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes);
} }
} }

View file

@ -20,17 +20,32 @@ public class CountriesController : ControllerBase
[HttpGet] [HttpGet]
public async Task<ActionResult<PagedResult<CountryDto>>> List([FromQuery] PagedRequest req, CancellationToken ct) public async Task<ActionResult<PagedResult<CountryDto>>> List([FromQuery] PagedRequest req, CancellationToken ct)
{ {
var q = _db.Countries.AsNoTracking().AsQueryable(); var q = _db.Countries.Include(c => c.DefaultCurrency).AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Search)) if (!string.IsNullOrWhiteSpace(req.Search))
{ {
var s = req.Search.Trim().ToLower(); var s = req.Search.Trim().ToLower();
q = q.Where(c => c.Name.ToLower().Contains(s) || c.Code.ToLower().Contains(s)); q = q.Where(c => c.Name.ToLower().Contains(s) || c.Code.ToLower().Contains(s));
} }
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("code", false) => q.OrderBy(c => c.Code),
("code", true) => q.OrderByDescending(c => c.Code),
("currency", false) => q.OrderBy(c => c.DefaultCurrency != null ? c.DefaultCurrency.Code : null).ThenBy(c => c.Name),
("currency", true) => q.OrderByDescending(c => c.DefaultCurrency != null ? c.DefaultCurrency.Code : null).ThenBy(c => c.Name),
("vatRate", false) => q.OrderBy(c => c.VatRate).ThenBy(c => c.Name),
("vatRate", true) => q.OrderByDescending(c => c.VatRate).ThenBy(c => c.Name),
("name", true) => q.OrderByDescending(c => c.Name),
_ => q.OrderBy(c => c.Name),
};
var items = await q var items = await q
.OrderBy(c => c.SortOrder).ThenBy(c => c.Name)
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(c => new CountryDto(c.Id, c.Code, c.Name, c.SortOrder)) .Select(c => new CountryDto(
c.Id, c.Code, c.Name,
c.DefaultCurrencyId,
c.DefaultCurrency != null ? c.DefaultCurrency.Code : null,
c.DefaultCurrency != null ? c.DefaultCurrency.Symbol : null,
c.VatRate))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<CountryDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<CountryDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -38,17 +53,24 @@ public async Task<ActionResult<PagedResult<CountryDto>>> List([FromQuery] PagedR
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
public async Task<ActionResult<CountryDto>> Get(Guid id, CancellationToken ct) public async Task<ActionResult<CountryDto>> Get(Guid id, CancellationToken ct)
{ {
var c = await _db.Countries.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); var c = await _db.Countries.Include(x => x.DefaultCurrency).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return c is null ? NotFound() : new CountryDto(c.Id, c.Code, c.Name, c.SortOrder); return c is null ? NotFound() : Project(c);
} }
[HttpPost, Authorize(Roles = "SuperAdmin")] [HttpPost, Authorize(Roles = "SuperAdmin")]
public async Task<ActionResult<CountryDto>> Create([FromBody] CountryInput input, CancellationToken ct) public async Task<ActionResult<CountryDto>> Create([FromBody] CountryInput input, CancellationToken ct)
{ {
var e = new Country { Code = input.Code.Trim().ToUpper(), Name = input.Name, SortOrder = input.SortOrder }; var e = new Country
{
Code = input.Code.Trim().ToUpper(),
Name = input.Name,
DefaultCurrencyId = input.DefaultCurrencyId,
VatRate = input.VatRate,
};
_db.Countries.Add(e); _db.Countries.Add(e);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id }, new CountryDto(e.Id, e.Code, e.Name, e.SortOrder)); await _db.Entry(e).Reference(x => x.DefaultCurrency).LoadAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id }, Project(e));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin")] [HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin")]
@ -58,7 +80,8 @@ public async Task<IActionResult> Update(Guid id, [FromBody] CountryInput input,
if (e is null) return NotFound(); if (e is null) return NotFound();
e.Code = input.Code.Trim().ToUpper(); e.Code = input.Code.Trim().ToUpper();
e.Name = input.Name; e.Name = input.Name;
e.SortOrder = input.SortOrder; e.DefaultCurrencyId = input.DefaultCurrencyId;
e.VatRate = input.VatRate;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
} }
@ -72,4 +95,8 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
} }
private static CountryDto Project(Country c) => new(
c.Id, c.Code, c.Name,
c.DefaultCurrencyId, c.DefaultCurrency?.Code, c.DefaultCurrency?.Symbol, c.VatRate);
} }

View file

@ -27,10 +27,18 @@ public async Task<ActionResult<PagedResult<CurrencyDto>>> List([FromQuery] Paged
q = q.Where(c => c.Name.ToLower().Contains(s) || c.Code.ToLower().Contains(s)); q = q.Where(c => c.Name.ToLower().Contains(s) || c.Code.ToLower().Contains(s));
} }
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("name", false) => q.OrderBy(c => c.Name),
("name", true) => q.OrderByDescending(c => c.Name),
("symbol", false) => q.OrderBy(c => c.Symbol),
("symbol", true) => q.OrderByDescending(c => c.Symbol),
("code", true) => q.OrderByDescending(c => c.Code),
_ => q.OrderBy(c => c.Code),
};
var items = await q var items = await q
.OrderBy(c => c.Code)
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(c => new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol, c.MinorUnit, c.IsActive)) .Select(c => new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<CurrencyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<CurrencyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -39,7 +47,7 @@ public async Task<ActionResult<PagedResult<CurrencyDto>>> List([FromQuery] Paged
public async Task<ActionResult<CurrencyDto>> Get(Guid id, CancellationToken ct) public async Task<ActionResult<CurrencyDto>> Get(Guid id, CancellationToken ct)
{ {
var c = await _db.Currencies.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); var c = await _db.Currencies.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return c is null ? NotFound() : new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol, c.MinorUnit, c.IsActive); return c is null ? NotFound() : new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol);
} }
[HttpPost, Authorize(Roles = "SuperAdmin")] [HttpPost, Authorize(Roles = "SuperAdmin")]
@ -50,13 +58,11 @@ public async Task<ActionResult<CurrencyDto>> Create([FromBody] CurrencyInput inp
Code = input.Code.Trim().ToUpper(), Code = input.Code.Trim().ToUpper(),
Name = input.Name, Name = input.Name,
Symbol = input.Symbol, Symbol = input.Symbol,
MinorUnit = input.MinorUnit,
IsActive = input.IsActive,
}; };
_db.Currencies.Add(e); _db.Currencies.Add(e);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id }, return CreatedAtAction(nameof(Get), new { id = e.Id },
new CurrencyDto(e.Id, e.Code, e.Name, e.Symbol, e.MinorUnit, e.IsActive)); new CurrencyDto(e.Id, e.Code, e.Name, e.Symbol));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin")] [HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin")]
@ -67,8 +73,6 @@ public async Task<IActionResult> Update(Guid id, [FromBody] CurrencyInput input,
e.Code = input.Code.Trim().ToUpper(); e.Code = input.Code.Trim().ToUpper();
e.Name = input.Name; e.Name = input.Name;
e.Symbol = input.Symbol; e.Symbol = input.Symbol;
e.MinorUnit = input.MinorUnit;
e.IsActive = input.IsActive;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
} }

View file

@ -27,10 +27,17 @@ public async Task<ActionResult<PagedResult<PriceTypeDto>>> List([FromQuery] Page
q = q.Where(p => p.Name.ToLower().Contains(s)); q = q.Where(p => p.Name.ToLower().Contains(s));
} }
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("name", false) => q.OrderBy(p => p.Name),
("name", true) => q.OrderByDescending(p => p.Name),
("isRequired", false) => q.OrderBy(p => p.IsRequired).ThenBy(p => p.Name),
("isRequired", true) => q.OrderByDescending(p => p.IsRequired).ThenBy(p => p.Name),
_ => q.OrderByDescending(p => p.IsSystem).ThenBy(p => p.SortOrder).ThenBy(p => p.Name),
};
var items = await q var items = await q
.OrderByDescending(p => p.IsDefault).ThenBy(p => p.SortOrder).ThenBy(p => p.Name)
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(p => new PriceTypeDto(p.Id, p.Name, p.IsDefault, p.IsRetail, p.SortOrder, p.IsActive)) .Select(p => new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsRetail, p.SortOrder))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<PriceTypeDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<PriceTypeDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -39,25 +46,30 @@ public async Task<ActionResult<PagedResult<PriceTypeDto>>> List([FromQuery] Page
public async Task<ActionResult<PriceTypeDto>> Get(Guid id, CancellationToken ct) public async Task<ActionResult<PriceTypeDto>> Get(Guid id, CancellationToken ct)
{ {
var p = await _db.PriceTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); var p = await _db.PriceTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsDefault, p.IsRetail, p.SortOrder, p.IsActive); return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsRetail, p.SortOrder);
} }
[HttpPost, Authorize(Roles = "Admin,Manager")] [HttpPost, Authorize(Roles = "Admin,Manager")]
public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct) public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct)
{ {
if (input.IsDefault) if (input.IsRetail)
{ {
await _db.PriceTypes.Where(p => p.IsDefault).ExecuteUpdateAsync(s => s.SetProperty(p => p.IsDefault, false), ct); // Уникальность IsRetail: не более одной записи в организации.
await _db.PriceTypes.Where(p => p.IsRetail)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.IsRetail, false), ct);
} }
var e = new PriceType var e = new PriceType
{ {
Name = input.Name, IsDefault = input.IsDefault, IsRetail = input.IsRetail, Name = input.Name,
SortOrder = input.SortOrder, IsActive = input.IsActive, IsRequired = input.IsRequired,
IsSystem = false,
IsRetail = input.IsRetail,
SortOrder = input.SortOrder,
}; };
_db.PriceTypes.Add(e); _db.PriceTypes.Add(e);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id }, return CreatedAtAction(nameof(Get), new { id = e.Id },
new PriceTypeDto(e.Id, e.Name, e.IsDefault, e.IsRetail, e.SortOrder, e.IsActive)); new PriceTypeDto(e.Id, e.Name, e.IsRequired, e.IsSystem, e.IsRetail, e.SortOrder));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
@ -65,15 +77,17 @@ public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input
{ {
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); if (e is null) return NotFound();
if (input.IsDefault && !e.IsDefault) if (input.IsRetail && !e.IsRetail)
{ {
await _db.PriceTypes.Where(p => p.IsDefault && p.Id != id).ExecuteUpdateAsync(s => s.SetProperty(p => p.IsDefault, false), ct); // Снимаем IsRetail с прежней записи (если была) — гарантия уникальности.
await _db.PriceTypes.Where(p => p.IsRetail && p.Id != id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.IsRetail, false), ct);
} }
e.Name = input.Name; e.Name = input.Name;
e.IsDefault = input.IsDefault;
e.IsRetail = input.IsRetail; e.IsRetail = input.IsRetail;
e.SortOrder = input.SortOrder; e.SortOrder = input.SortOrder;
e.IsActive = input.IsActive; // У системной записи IsRequired всегда true и не меняется.
e.IsRequired = e.IsSystem ? true : input.IsRequired;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
} }
@ -83,8 +97,26 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); if (e is null) return NotFound();
if (e.IsSystem) return BadRequest(new { error = "Системная запись не может быть удалена." });
var wasRetail = e.IsRetail;
_db.PriceTypes.Remove(e); _db.PriceTypes.Remove(e);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
// Если удалённая запись была единственной IsRetail — фоллбэк на системную,
// чтобы у организации всегда оставался один IsRetail-кандидат для POS.
if (wasRetail)
{
var stillRetail = await _db.PriceTypes.AnyAsync(p => p.IsRetail, ct);
if (!stillRetail)
{
var sys = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsSystem, ct);
if (sys is not null && !sys.IsRetail)
{
sys.IsRetail = true;
await _db.SaveChangesAsync(ct);
}
}
}
return NoContent(); return NoContent();
} }
} }

View file

@ -34,10 +34,17 @@ public class ProductGroupsController : ControllerBase
q = q.Where(g => g.Name.ToLower().Contains(s) || g.Path.ToLower().Contains(s)); q = q.Where(g => g.Name.ToLower().Contains(s) || g.Path.ToLower().Contains(s));
} }
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("name", false) => q.OrderBy(g => g.Name),
("name", true) => q.OrderByDescending(g => g.Name),
("path", false) => q.OrderBy(g => g.Path),
("path", true) => q.OrderByDescending(g => g.Path),
_ => q.OrderBy(g => g.Path).ThenBy(g => g.SortOrder).ThenBy(g => g.Name),
};
var items = await q var items = await q
.OrderBy(g => g.Path).ThenBy(g => g.SortOrder).ThenBy(g => g.Name)
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive)) .Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<ProductGroupDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<ProductGroupDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -46,7 +53,7 @@ public class ProductGroupsController : ControllerBase
public async Task<ActionResult<ProductGroupDto>> Get(Guid id, CancellationToken ct) public async Task<ActionResult<ProductGroupDto>> Get(Guid id, CancellationToken ct)
{ {
var g = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); var g = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive); return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId);
} }
[HttpPost, Authorize(Roles = "Admin,Manager")] [HttpPost, Authorize(Roles = "Admin,Manager")]
@ -56,33 +63,39 @@ public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupI
var e = new ProductGroup var e = new ProductGroup
{ {
Name = input.Name, ParentId = input.ParentId, Path = path, Name = input.Name, ParentId = input.ParentId, Path = path,
SortOrder = input.SortOrder, IsActive = input.IsActive, SortOrder = input.SortOrder,
MarkupPercent = input.MarkupPercent,
}; };
_db.ProductGroups.Add(e); _db.ProductGroups.Add(e);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id }, return CreatedAtAction(nameof(Get), new { id = e.Id },
new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.IsActive)); new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.MarkupPercent, e.OrganizationId));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,SuperAdmin")]
public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken ct)
{ {
var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); if (e is null) return NotFound();
// Системную (эталонную) запись правит только SuperAdmin без override.
if (e.OrganizationId is null && !(User.IsInRole("SuperAdmin")))
return Forbid();
if (input.ParentId == id) return BadRequest(new { error = "ParentId cannot be self" }); if (input.ParentId == id) return BadRequest(new { error = "ParentId cannot be self" });
e.Name = input.Name; e.Name = input.Name;
e.ParentId = input.ParentId; e.ParentId = input.ParentId;
e.Path = await BuildPathAsync(input.ParentId, input.Name, ct); e.Path = await BuildPathAsync(input.ParentId, input.Name, ct);
e.SortOrder = input.SortOrder; e.SortOrder = input.SortOrder;
e.IsActive = input.IsActive; e.MarkupPercent = input.MarkupPercent;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
} }
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var owner = await _db.ProductGroups.Where(x => x.Id == id).Select(x => x.OrganizationId).FirstOrDefaultAsync(ct);
if (owner is null && !User.IsInRole("SuperAdmin")) return Forbid();
var hasChildren = await _db.ProductGroups.AnyAsync(g => g.ParentId == id, ct); var hasChildren = await _db.ProductGroups.AnyAsync(g => g.ParentId == id, ct);
if (hasChildren) return BadRequest(new { error = "Нельзя удалить группу с подгруппами" }); if (hasChildren) return BadRequest(new { error = "Нельзя удалить группу с подгруппами" });
var hasProducts = await _db.Products.AnyAsync(p => p.ProductGroupId == id, ct); var hasProducts = await _db.Products.AnyAsync(p => p.ProductGroupId == id, ct);

View file

@ -0,0 +1,147 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Catalog;
/// <summary>Локальное хранилище изображений товаров: multipart upload →
/// /app/uploads/products/{productId}/{guid}.{ext}; в БД лежит относительный путь
/// типа "/uploads/products/{id}/{file}", раздаётся nginx'ом как статика.</summary>
[ApiController]
[Authorize]
[Route("api/catalog/products/{productId:guid}/images")]
public class ProductImagesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly IWebHostEnvironment _env;
public ProductImagesController(AppDbContext db, ITenantContext tenant, IWebHostEnvironment env)
{
_db = db;
_tenant = tenant;
_env = env;
}
private static readonly HashSet<string> AllowedExt = new(StringComparer.OrdinalIgnoreCase)
{ ".jpg", ".jpeg", ".png", ".webp", ".gif" };
private const long MaxBytes = 10 * 1024 * 1024;
private string UploadRoot => Path.Combine(_env.ContentRootPath, "uploads", "products");
public record ImageDto(Guid Id, string Url, bool IsMain, int SortOrder);
[HttpGet]
public async Task<ActionResult<IReadOnlyList<ImageDto>>> List(Guid productId, CancellationToken ct)
{
var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct);
if (product is null) return NotFound();
var images = await _db.ProductImages
.Where(i => i.ProductId == productId)
.OrderBy(i => i.SortOrder)
.Select(i => new ImageDto(i.Id, i.Url, i.IsMain, i.SortOrder))
.ToListAsync(ct);
return images;
}
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
[RequestSizeLimit(MaxBytes)]
public async Task<ActionResult<ImageDto>> Upload(Guid productId, IFormFile file, CancellationToken ct)
{
if (file is null || file.Length == 0) return BadRequest(new { error = "No file." });
if (file.Length > MaxBytes) return BadRequest(new { error = $"File too large (max {MaxBytes / 1024 / 1024} MB)." });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExt.Contains(ext)) return BadRequest(new { error = "Only JPG/PNG/WEBP/GIF are allowed." });
var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct);
if (product is null) return NotFound();
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var dir = Path.Combine(UploadRoot, productId.ToString());
Directory.CreateDirectory(dir);
var fileName = $"{Guid.NewGuid():N}{ext}";
var fullPath = Path.Combine(dir, fileName);
using (var stream = System.IO.File.Create(fullPath))
{
await file.CopyToAsync(stream, ct);
}
var relativeUrl = $"/uploads/products/{productId}/{fileName}";
var sortOrder = await _db.ProductImages.Where(i => i.ProductId == productId).CountAsync(ct);
var isMain = sortOrder == 0; // первое загруженное — основное
var entity = new ProductImage
{
OrganizationId = orgId,
ProductId = productId,
Url = relativeUrl,
IsMain = isMain,
SortOrder = sortOrder,
};
_db.ProductImages.Add(entity);
if (isMain) product.ImageUrl = relativeUrl;
await _db.SaveChangesAsync(ct);
return new ImageDto(entity.Id, entity.Url, entity.IsMain, entity.SortOrder);
}
[HttpDelete("{imageId:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Delete(Guid productId, Guid imageId, CancellationToken ct)
{
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
if (image is null) return NotFound();
// Удаляем файл с диска (не фейлим если отсутствует).
var fileName = Path.GetFileName(image.Url);
var fullPath = Path.Combine(UploadRoot, productId.ToString(), fileName);
try { if (System.IO.File.Exists(fullPath)) System.IO.File.Delete(fullPath); }
catch { /* ignore */ }
_db.ProductImages.Remove(image);
// Если удалили основное — назначаем основным оставшуюся первую.
if (image.IsMain)
{
var next = await _db.ProductImages
.Where(i => i.ProductId == productId && i.Id != imageId)
.OrderBy(i => i.SortOrder)
.FirstOrDefaultAsync(ct);
if (next is not null)
{
next.IsMain = true;
var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct);
if (product is not null) product.ImageUrl = next.Url;
}
else
{
var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct);
if (product is not null) product.ImageUrl = null;
}
}
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{imageId:guid}/main"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> SetMain(Guid productId, Guid imageId, CancellationToken ct)
{
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
if (image is null) return NotFound();
await _db.ProductImages.Where(i => i.ProductId == productId).ExecuteUpdateAsync(
s => s.SetProperty(i => i.IsMain, false), ct);
image.IsMain = true;
var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct);
if (product is not null) product.ImageUrl = image.Url;
await _db.SaveChangesAsync(ct);
return NoContent();
}
}

View file

@ -1,5 +1,6 @@
using foodmarket.Application.Catalog; using foodmarket.Application.Catalog;
using foodmarket.Application.Common; using foodmarket.Application.Common;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog; using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -14,23 +15,140 @@ namespace foodmarket.Api.Controllers.Catalog;
public class ProductsController : ControllerBase public class ProductsController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public ProductsController(AppDbContext db) => _db = db; public ProductsController(AppDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
// Проверка пересечения штрихкодов с другими товарами организации.
// Возвращает первый конфликт «код → товар» либо null если всё чисто.
/// <summary>Проверяет что у каждого PriceType с IsRequired=true есть
/// соответствующая запись в input.Prices с Amount > 0. Возвращает имя
/// первого нарушенного типа либо null если всё ок.</summary>
private async Task<string?> FindMissingRequiredPriceAsync(
IReadOnlyList<ProductPriceInput>? prices, CancellationToken ct)
{
var required = await _db.PriceTypes
.Where(pt => pt.IsRequired)
.Select(pt => new { pt.Id, pt.Name })
.ToListAsync(ct);
if (required.Count == 0) return null;
foreach (var pt in required)
{
var price = prices?.FirstOrDefault(p => p.PriceTypeId == pt.Id);
if (price is null || price.Amount <= 0m) return pt.Name;
}
return null;
}
private async Task<(string Code, string ProductName)?> FindBarcodeConflictAsync(
IEnumerable<string> codes, Guid? excludeProductId, CancellationToken ct)
{
var codeSet = codes.Where(c => !string.IsNullOrWhiteSpace(c)).Distinct().ToList();
if (codeSet.Count == 0) return null;
var hit = await _db.ProductBarcodes
.Where(b => codeSet.Contains(b.Code) && (excludeProductId == null || b.ProductId != excludeProductId))
.Select(b => new { b.Code, ProductName = b.Product!.Name })
.FirstOrDefaultAsync(ct);
return hit is null ? null : (hit.Code, hit.ProductName);
}
// Округление цен под настройку AllowFractionalPrices.
// Возвращает true если орг разрешает дробные цены.
private async Task<bool> AllowFractionalAsync(CancellationToken ct)
{
var orgId = _tenant.OrganizationId;
if (orgId is null) return true;
return await _db.Organizations.Where(o => o.Id == orgId)
.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
}
private static decimal RoundIfNeeded(decimal value, bool allowFractional) =>
allowFractional ? value : Math.Round(value, 0, MidpointRounding.AwayFromZero);
private static decimal? RoundIfNeeded(decimal? value, bool allowFractional) =>
value is null ? null : RoundIfNeeded(value.Value, allowFractional);
// Следующий числовой артикул для организации. Находит max(Article::int)
// среди артикулов, которые полностью состоят из цифр, и прибавляет 1.
// Если числовых артикулов нет — возвращает "1".
private async Task<string> GenerateNextArticleAsync(CancellationToken ct)
{
var articles = await _db.Products
.Where(p => p.Article != null && p.Article != "")
.Select(p => p.Article!)
.ToListAsync(ct);
var next = 1;
foreach (var a in articles)
{
if (int.TryParse(a, out var n) && n >= next) next = n + 1;
}
return next.ToString();
}
// Дефолт Vat для нового товара — из страны организации (Country.VatRate).
private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
{
var orgId = _tenant.OrganizationId;
if (orgId is null) return 0m;
var countryCode = await _db.Organizations
.Where(o => o.Id == orgId)
.Select(o => o.CountryCode)
.FirstOrDefaultAsync(ct);
if (string.IsNullOrEmpty(countryCode)) return 0m;
var rate = await _db.Countries
.Where(c => c.Code == countryCode)
.Select(c => (decimal?)c.VatRate)
.FirstOrDefaultAsync(ct);
return rate ?? 0m;
}
[HttpGet] [HttpGet]
public async Task<ActionResult<PagedResult<ProductDto>>> List( public async Task<ActionResult<PagedResult<ProductDto>>> List(
[FromQuery] PagedRequest req, [FromQuery] PagedRequest req,
[FromQuery] Guid? groupId, [FromQuery] Guid? groupId,
[FromQuery] bool? isService, [FromQuery] bool? isService,
[FromQuery] bool? isWeighed, [FromQuery] Packaging? packaging,
[FromQuery] bool? isActive, [FromQuery] bool? isMarked,
[FromQuery] decimal? purchasePriceFrom,
[FromQuery] decimal? purchasePriceTo,
[FromQuery] decimal? referencePriceFrom,
[FromQuery] decimal? referencePriceTo,
[FromQuery] decimal? systemPriceFrom,
[FromQuery] decimal? systemPriceTo,
CancellationToken ct) CancellationToken ct)
{ {
var q = QueryIncludes().AsNoTracking(); var q = QueryIncludes().AsNoTracking();
if (groupId is not null) q = q.Where(p => p.ProductGroupId == groupId); if (groupId is not null)
{
// Include the whole subtree: match on the group's Path prefix so sub-groups also show up.
var root = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(g => g.Id == groupId, ct);
if (root is not null)
{
var prefix = root.Path;
q = q.Where(p => p.ProductGroup != null &&
(p.ProductGroup.Path == prefix || p.ProductGroup.Path.StartsWith(prefix + "/")));
}
else
{
q = q.Where(p => p.ProductGroupId == groupId);
}
}
if (isService is not null) q = q.Where(p => p.IsService == isService); if (isService is not null) q = q.Where(p => p.IsService == isService);
if (isWeighed is not null) q = q.Where(p => p.IsWeighed == isWeighed); if (packaging is not null) q = q.Where(p => p.Packaging == packaging);
if (isActive is not null) q = q.Where(p => p.IsActive == isActive); if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked);
// referencePriceFrom/To — новый, актуальный параметр; purchasePriceFrom/To
// — обратная совместимость c прежним UI (тоже по ReferencePrice).
var refFrom = referencePriceFrom ?? purchasePriceFrom;
var refTo = referencePriceTo ?? purchasePriceTo;
if (refFrom is not null) q = q.Where(p => p.ReferencePrice >= refFrom);
if (refTo is not null) q = q.Where(p => p.ReferencePrice <= refTo);
// Фильтр по системной (главной розничной) цене — берём Prices c PriceType.IsSystem=true.
if (systemPriceFrom is not null)
q = q.Where(p => p.Prices.Any(pr => pr.PriceType!.IsSystem && pr.Amount >= systemPriceFrom));
if (systemPriceTo is not null)
q = q.Where(p => p.Prices.Any(pr => pr.PriceType!.IsSystem && pr.Amount <= systemPriceTo));
if (!string.IsNullOrWhiteSpace(req.Search)) if (!string.IsNullOrWhiteSpace(req.Search))
{ {
@ -42,8 +160,28 @@ public class ProductsController : ControllerBase
} }
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("article", false) => q.OrderBy(p => p.Article).ThenBy(p => p.Name),
("article", true) => q.OrderByDescending(p => p.Article).ThenBy(p => p.Name),
("group", false) => q.OrderBy(p => p.ProductGroup!.Name).ThenBy(p => p.Name),
("group", true) => q.OrderByDescending(p => p.ProductGroup!.Name).ThenBy(p => p.Name),
("unit", false) => q.OrderBy(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name),
("unit", true) => q.OrderByDescending(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name),
("packaging", false) => q.OrderBy(p => p.Packaging).ThenBy(p => p.Name),
("packaging", true) => q.OrderByDescending(p => p.Packaging).ThenBy(p => p.Name),
("purchasePrice", false) => q.OrderBy(p => p.ReferencePrice).ThenBy(p => p.Name),
("purchasePrice", true) => q.OrderByDescending(p => p.ReferencePrice).ThenBy(p => p.Name),
("cost", false) => q.OrderBy(p => p.Cost).ThenBy(p => p.Name),
("cost", true) => q.OrderByDescending(p => p.Cost).ThenBy(p => p.Name),
("systemPrice", false) => q.OrderBy(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name),
("systemPrice", true) => q.OrderByDescending(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name),
("vat", false) => q.OrderBy(p => p.Vat).ThenBy(p => p.Name),
("vat", true) => q.OrderByDescending(p => p.Vat).ThenBy(p => p.Name),
("name", true) => q.OrderByDescending(p => p.Name),
_ => q.OrderBy(p => p.Name),
};
var items = await q var items = await q
.OrderBy(p => p.Name)
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(Projection) .Select(Projection)
.ToListAsync(ct); .ToListAsync(ct);
@ -60,16 +198,36 @@ public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct) public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
{ {
if (input.Barcodes is null || input.Barcodes.Count == 0)
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
return BadRequest(new { error = $"Цена «{missing}» обязательна и должна быть больше 0." });
var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), null, ct);
if (conflict is { } c)
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
var allowFractional = await AllowFractionalAsync(ct);
var e = new Product(); var e = new Product();
Apply(e, input); Apply(e, input);
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct);
// Авто-артикул: если пользователь не указал — генерируем числовой.
if (string.IsNullOrWhiteSpace(e.Article)) e.Article = await GenerateNextArticleAsync(ct);
foreach (var b in input.Barcodes ?? []) foreach (var b in input.Barcodes ?? [])
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary }); e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
foreach (var pr in input.Prices ?? []) foreach (var pr in input.Prices ?? [])
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId }); e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = RoundIfNeeded(pr.Amount, allowFractional), CurrencyId = pr.CurrencyId });
_db.Products.Add(e); _db.Products.Add(e);
try
{
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_products_OrganizationId_Article") == true)
{
return BadRequest(new { error = $"Артикул «{e.Article}» уже занят в этой организации." });
}
var dto = await GetInternalAsync(e.Id, ct); var dto = await GetInternalAsync(e.Id, ct);
return CreatedAtAction(nameof(Get), new { id = e.Id }, dto); return CreatedAtAction(nameof(Get), new { id = e.Id }, dto);
} }
@ -77,28 +235,132 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct)
{ {
if (input.Barcodes is null || input.Barcodes.Count == 0)
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
return BadRequest(new { error = $"Цена «{missing}» обязательна и должна быть больше 0." });
var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), id, ct);
if (conflict is { } c)
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
var e = await _db.Products var e = await _db.Products
.Include(p => p.Barcodes) .Include(p => p.Barcodes)
.Include(p => p.Prices) .Include(p => p.Prices)
.FirstOrDefaultAsync(x => x.Id == id, ct); .FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); if (e is null) return NotFound();
var allowFractional = await AllowFractionalAsync(ct);
Apply(e, input); Apply(e, input);
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
_db.ProductBarcodes.RemoveRange(e.Barcodes); // Merge barcodes по Code: одинаковые — обновляем поля, лишние удаляем,
e.Barcodes.Clear(); // новые добавляем. Это позволяет избежать массового DELETE+INSERT, на
foreach (var b in input.Barcodes ?? []) // котором EF может выдать DbUpdateConcurrencyException, если какой-то
// child был удалён параллельно из БД.
var inputBarcodes = (input.Barcodes ?? []).ToList();
var byCode = e.Barcodes.ToDictionary(b => b.Code, b => b);
var inputCodes = inputBarcodes.Select(b => b.Code).ToHashSet();
foreach (var existing in e.Barcodes.ToList())
if (!inputCodes.Contains(existing.Code)) _db.ProductBarcodes.Remove(existing);
foreach (var b in inputBarcodes)
{
if (byCode.TryGetValue(b.Code, out var ex))
{
ex.Type = b.Type;
ex.IsPrimary = b.IsPrimary;
}
else
{
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary }); e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
}
}
_db.ProductPrices.RemoveRange(e.Prices); // Merge prices по PriceTypeId.
e.Prices.Clear(); var inputPrices = (input.Prices ?? []).ToList();
foreach (var pr in input.Prices ?? []) var byPriceType = e.Prices.ToDictionary(p => p.PriceTypeId, p => p);
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId }); var inputPriceTypes = inputPrices.Select(p => p.PriceTypeId).ToHashSet();
foreach (var existing in e.Prices.ToList())
if (!inputPriceTypes.Contains(existing.PriceTypeId)) _db.ProductPrices.Remove(existing);
foreach (var pr in inputPrices)
{
var amount = RoundIfNeeded(pr.Amount, allowFractional);
if (byPriceType.TryGetValue(pr.PriceTypeId, out var ex))
{
ex.Amount = amount;
ex.CurrencyId = pr.CurrencyId;
}
else
{
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = amount, CurrencyId = pr.CurrencyId });
}
}
try
{
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_products_OrganizationId_Article") == true)
{
return BadRequest(new { error = $"Артикул «{e.Article}» уже занят в этой организации." });
}
catch (DbUpdateConcurrencyException)
{
return Conflict(new { error = "Товар изменён в другом окне или сессии. Перезагрузите страницу и попробуйте снова." });
}
return NoContent(); return NoContent();
} }
/// <summary>«Привести розничную к себестоимости»: ставит дефолтную
/// розничную цену = ceil(Cost * (1 + Group.MarkupPercent/100)). Если у
/// группы товара не задан MarkupPercent — 400 с подсказкой.</summary>
[HttpPost("{id:guid}/recalc-retail"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
{
var p = await _db.Products
.Include(x => x.ProductGroup)
.Include(x => x.Prices)
.FirstOrDefaultAsync(x => x.Id == id, ct);
if (p is null) return NotFound();
if (p.ProductGroup?.MarkupPercent is not decimal pct)
return BadRequest(new { error = "У группы не задана наценка. Задайте её в настройках или введите цену вручную." });
var allowFractional = await AllowFractionalAsync(ct);
var raw = p.Cost * (1m + pct / 100m);
var newRetail = allowFractional
? Math.Ceiling(raw * 100m) / 100m
: Math.Ceiling(raw);
var defaultType = await _db.PriceTypes
.OrderByDescending(pt => pt.IsSystem)
.ThenByDescending(pt => pt.IsRetail)
.ThenBy(pt => pt.SortOrder)
.ThenBy(pt => pt.Name)
.FirstOrDefaultAsync(ct);
if (defaultType is null)
return BadRequest(new { error = "Нет ни одного типа цен. Создайте его в настройках." });
var fallbackCurrency = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct)
?? await _db.Currencies.OrderBy(c => c.Code).Select(c => (Guid?)c.Id).FirstOrDefaultAsync(ct);
if (fallbackCurrency is null)
return BadRequest(new { error = "Не задана валюта по умолчанию." });
var existing = p.Prices.FirstOrDefault(x => x.PriceTypeId == defaultType.Id);
if (existing is null)
{
p.Prices.Add(new ProductPrice
{
PriceTypeId = defaultType.Id,
Amount = newRetail,
CurrencyId = fallbackCurrency.Value,
});
}
else
{
existing.Amount = newRetail;
}
await _db.SaveChangesAsync(ct);
return Ok(new { retail = newRetail });
}
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
@ -109,9 +371,124 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
return NoContent(); return NoContent();
} }
public record BarcodeDuplicate(string Code, IReadOnlyList<DuplicateProductRef> Products);
public record DuplicateProductRef(Guid ProductId, string ProductName, string? Article);
/// <summary>Находит штрихкоды, привязанные к более чем одному товару в текущей
/// организации. Уникальный индекс это запрещает в новых записях, но реальная
/// БД может содержать исторические дубли (например, после ручной правки).
/// Используется UI чистки (/admin/cleanup) и отчётом MoySklad-импорта.</summary>
[HttpGet("barcode-duplicates"), Authorize(Roles = "Admin,Manager")]
public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicates(CancellationToken ct)
{
var rows = await _db.ProductBarcodes
.GroupBy(b => b.Code)
.Where(g => g.Count() > 1)
.Select(g => new { Code = g.Key, Items = g.Select(x => new { x.ProductId, ProductName = x.Product!.Name, x.Product.Article }).ToList() })
.ToListAsync(ct);
var result = rows
.Select(r => new BarcodeDuplicate(r.Code,
r.Items.Select(i => new DuplicateProductRef(i.ProductId, i.ProductName, i.Article)).ToList()))
.ToList();
return result;
}
public record QuickSearchItem(
Guid Id, string Name, string? Article, string? DefaultBarcode,
decimal? ReferencePrice, decimal? StockQty);
/// <summary>Лёгкий поиск для inline-добавления строк в документы (приёмка,
/// продажа). Ранжирует точное совпадение штрихкода → точное артикула →
/// префикс артикула → префикс имени → имя contains. Возвращает остаток
/// по storeId если передан, иначе сумму по всем складам организации.</summary>
[HttpGet("quick-search")]
public async Task<ActionResult<IReadOnlyList<QuickSearchItem>>> QuickSearch(
[FromQuery] string? search,
[FromQuery] Guid? storeId,
[FromQuery] int limit = 20,
CancellationToken ct = default)
{
var s = (search ?? "").Trim();
if (s.Length == 0) return Array.Empty<QuickSearchItem>();
var sLower = s.ToLower();
if (limit <= 0 || limit > 50) limit = 20;
var q = _db.Products.AsNoTracking().Where(p =>
p.Name.ToLower().Contains(sLower) ||
(p.Article != null && p.Article.ToLower().Contains(sLower)) ||
p.Barcodes.Any(b => b.Code.Contains(s)));
// Ранжирование выводим в память: SQL'ом аккуратно сортировать по
// нескольким булевым приоритетам сложно, а лимит 2050 строк
// делает накладные расходы пренебрежимыми.
var raw = await q.Select(p => new
{
p.Id, p.Name, p.Article, p.ReferencePrice,
Barcodes = p.Barcodes.Select(b => new { b.Code, b.IsPrimary }).ToList(),
StockQty = storeId == null
? (decimal?)_db.Stocks.Where(st => st.ProductId == p.Id).Sum(st => (decimal?)st.Quantity)
: _db.Stocks.Where(st => st.ProductId == p.Id && st.StoreId == storeId)
.Select(st => (decimal?)st.Quantity).FirstOrDefault(),
}).Take(limit * 4).ToListAsync(ct);
int Rank(string name, string? article, IEnumerable<string> codes)
{
if (codes.Any(c => c.Equals(s, StringComparison.OrdinalIgnoreCase))) return 0;
if (article != null && article.Equals(s, StringComparison.OrdinalIgnoreCase)) return 1;
if (article != null && article.StartsWith(s, StringComparison.OrdinalIgnoreCase)) return 2;
if (name.StartsWith(s, StringComparison.OrdinalIgnoreCase)) return 3;
return 4;
}
var items = raw
.Select(r => new
{
Item = new QuickSearchItem(
r.Id, r.Name, r.Article,
r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault(),
r.ReferencePrice, r.StockQty),
Rank = Rank(r.Name, r.Article, r.Barcodes.Select(b => b.Code)),
})
.OrderBy(x => x.Rank).ThenBy(x => x.Item.Name)
.Take(limit)
.Select(x => x.Item)
.ToList();
return items;
}
public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> Items);
/// <summary>Точный поиск по штрихкоду (для сканера). 0 → 404, 1 → объект,
/// несколько → { items: [...] } чтобы UI показал диалог выбора.</summary>
[HttpGet("by-barcode/{value}")]
public async Task<ActionResult<object>> ByBarcode(
string value, [FromQuery] Guid? storeId, CancellationToken ct)
{
var v = (value ?? "").Trim();
if (v.Length == 0) return NotFound();
var matches = await _db.Products.AsNoTracking()
.Where(p => p.Barcodes.Any(b => b.Code == v))
.Select(p => new
{
p.Id, p.Name, p.Article, p.ReferencePrice,
Barcodes = p.Barcodes.Select(b => new { b.Code, b.IsPrimary }).ToList(),
StockQty = storeId == null
? (decimal?)_db.Stocks.Where(st => st.ProductId == p.Id).Sum(st => (decimal?)st.Quantity)
: _db.Stocks.Where(st => st.ProductId == p.Id && st.StoreId == storeId)
.Select(st => (decimal?)st.Quantity).FirstOrDefault(),
})
.ToListAsync(ct);
if (matches.Count == 0) return NotFound();
var items = matches.Select(r => new QuickSearchItem(
r.Id, r.Name, r.Article,
r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? v,
r.ReferencePrice, r.StockQty)).ToList();
if (items.Count == 1) return items[0];
return new ByBarcodeResult(items);
}
private IQueryable<Product> QueryIncludes() => _db.Products private IQueryable<Product> QueryIncludes() => _db.Products
.Include(p => p.UnitOfMeasure) .Include(p => p.UnitOfMeasure)
.Include(p => p.VatRate)
.Include(p => p.ProductGroup) .Include(p => p.ProductGroup)
.Include(p => p.DefaultSupplier) .Include(p => p.DefaultSupplier)
.Include(p => p.CountryOfOrigin) .Include(p => p.CountryOfOrigin)
@ -126,15 +503,17 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
private static readonly System.Linq.Expressions.Expression<Func<Product, ProductDto>> Projection = p => private static readonly System.Linq.Expressions.Expression<Func<Product, ProductDto>> Projection = p =>
new ProductDto( new ProductDto(
p.Id, p.Name, p.Article, p.Description, p.Id, p.Name, p.Article, p.Description,
p.UnitOfMeasureId, p.UnitOfMeasure!.Symbol, p.UnitOfMeasureId, p.UnitOfMeasure!.Name,
p.VatRateId, p.VatRate!.Percent, p.Vat, p.VatEnabled,
p.ProductGroupId, p.ProductGroup != null ? p.ProductGroup.Name : null, p.ProductGroupId, p.ProductGroup!.Name,
p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null, p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null,
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null, p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
p.IsService, p.IsWeighed, p.IsAlcohol, p.IsMarked, p.IsService, p.Packaging, p.IsMarked,
p.MinStock, p.MaxStock, p.MinStock, p.MaxStock,
p.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null, p.ReferencePrice, p.ReferencePriceUpdatedAt,
p.ImageUrl, p.IsActive, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
p.Cost, p.LastSupplyAt,
p.ImageUrl,
p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(), p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(),
p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList()); p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList());
@ -144,19 +523,25 @@ private static void Apply(Product e, ProductInput i)
e.Article = i.Article; e.Article = i.Article;
e.Description = i.Description; e.Description = i.Description;
e.UnitOfMeasureId = i.UnitOfMeasureId; e.UnitOfMeasureId = i.UnitOfMeasureId;
e.VatRateId = i.VatRateId; if (i.Vat is decimal v) e.Vat = v;
e.VatEnabled = i.VatEnabled;
e.ProductGroupId = i.ProductGroupId; e.ProductGroupId = i.ProductGroupId;
e.DefaultSupplierId = i.DefaultSupplierId; e.DefaultSupplierId = i.DefaultSupplierId;
e.CountryOfOriginId = i.CountryOfOriginId; e.CountryOfOriginId = i.CountryOfOriginId;
e.IsService = i.IsService; e.IsService = i.IsService;
e.IsWeighed = i.IsWeighed; e.Packaging = i.Packaging;
e.IsAlcohol = i.IsAlcohol;
e.IsMarked = i.IsMarked; e.IsMarked = i.IsMarked;
e.MinStock = i.MinStock; e.MinStock = i.MinStock;
e.MaxStock = i.MaxStock; e.MaxStock = i.MaxStock;
e.PurchasePrice = i.PurchasePrice; // ReferencePriceUpdatedAt подбиваем только при реальной смене цены
// (включая переход с null на значение и обратно). Этим помечаем,
// что цена «свежая», 30-дневный таймер автоперезаписи отсчитывается заново.
if (e.ReferencePrice != i.ReferencePrice)
{
e.ReferencePrice = i.ReferencePrice;
e.ReferencePriceUpdatedAt = i.ReferencePrice.HasValue ? DateTime.UtcNow : null;
}
e.PurchaseCurrencyId = i.PurchaseCurrencyId; e.PurchaseCurrencyId = i.PurchaseCurrencyId;
e.ImageUrl = i.ImageUrl; e.ImageUrl = i.ImageUrl;
e.IsActive = i.IsActive;
} }
} }

View file

@ -27,8 +27,20 @@ public async Task<ActionResult<PagedResult<RetailPointDto>>> List([FromQuery] Pa
q = q.Where(r => r.Name.ToLower().Contains(s) || (r.Code != null && r.Code.ToLower().Contains(s))); q = q.Where(r => r.Name.ToLower().Contains(s) || (r.Code != null && r.Code.ToLower().Contains(s)));
} }
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("code", false) => q.OrderBy(r => r.Code).ThenBy(r => r.Name),
("code", true) => q.OrderByDescending(r => r.Code).ThenBy(r => r.Name),
("store", false) => q.OrderBy(r => r.Store!.Name).ThenBy(r => r.Name),
("store", true) => q.OrderByDescending(r => r.Store!.Name).ThenBy(r => r.Name),
("address", false) => q.OrderBy(r => r.Address).ThenBy(r => r.Name),
("address", true) => q.OrderByDescending(r => r.Address).ThenBy(r => r.Name),
("isActive", false) => q.OrderBy(r => r.IsActive).ThenBy(r => r.Name),
("isActive", true) => q.OrderByDescending(r => r.IsActive).ThenBy(r => r.Name),
("name", true) => q.OrderByDescending(r => r.Name),
_ => q.OrderBy(r => r.Name),
};
var items = await q var items = await q
.OrderBy(r => r.Name)
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(r => new RetailPointDto(r.Id, r.Name, r.Code, r.StoreId, r.Store!.Name, .Select(r => new RetailPointDto(r.Id, r.Name, r.Code, r.StoreId, r.Store!.Name,
r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive)) r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive))

View file

@ -27,10 +27,23 @@ public async Task<ActionResult<PagedResult<StoreDto>>> List([FromQuery] PagedReq
q = q.Where(x => x.Name.ToLower().Contains(s) || (x.Code != null && x.Code.ToLower().Contains(s))); q = q.Where(x => x.Name.ToLower().Contains(s) || (x.Code != null && x.Code.ToLower().Contains(s)));
} }
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("name", false) => q.OrderBy(x => x.Name),
("name", true) => q.OrderByDescending(x => x.Name),
("code", false) => q.OrderBy(x => x.Code).ThenBy(x => x.Name),
("code", true) => q.OrderByDescending(x => x.Code).ThenBy(x => x.Name),
("address", false) => q.OrderBy(x => x.Address).ThenBy(x => x.Name),
("address", true) => q.OrderByDescending(x => x.Address).ThenBy(x => x.Name),
("isMain", false) => q.OrderBy(x => x.IsMain).ThenBy(x => x.Name),
("isMain", true) => q.OrderByDescending(x => x.IsMain).ThenBy(x => x.Name),
("isActive", false) => q.OrderBy(x => x.IsActive).ThenBy(x => x.Name),
("isActive", true) => q.OrderByDescending(x => x.IsActive).ThenBy(x => x.Name),
_ => q.OrderByDescending(x => x.IsMain).ThenBy(x => x.Name),
};
var items = await q var items = await q
.OrderByDescending(x => x.IsMain).ThenBy(x => x.Name)
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(x => new StoreDto(x.Id, x.Name, x.Code, x.Kind, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive)) .Select(x => new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<StoreDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<StoreDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -39,7 +52,7 @@ public async Task<ActionResult<PagedResult<StoreDto>>> List([FromQuery] PagedReq
public async Task<ActionResult<StoreDto>> Get(Guid id, CancellationToken ct) public async Task<ActionResult<StoreDto>> Get(Guid id, CancellationToken ct)
{ {
var x = await _db.Stores.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, ct); var x = await _db.Stores.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, ct);
return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Kind, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive); return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
} }
[HttpPost, Authorize(Roles = "Admin,Manager")] [HttpPost, Authorize(Roles = "Admin,Manager")]
@ -51,13 +64,13 @@ public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, Ca
} }
var e = new Store var e = new Store
{ {
Name = input.Name, Code = input.Code, Kind = input.Kind, Address = input.Address, Name = input.Name, Code = input.Code,Address = input.Address,
Phone = input.Phone, ManagerName = input.ManagerName, IsMain = input.IsMain, IsActive = input.IsActive, Phone = input.Phone, ManagerName = input.ManagerName, IsMain = input.IsMain, IsActive = input.IsActive,
}; };
_db.Stores.Add(e); _db.Stores.Add(e);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id }, return CreatedAtAction(nameof(Get), new { id = e.Id },
new StoreDto(e.Id, e.Name, e.Code, e.Kind, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive)); new StoreDto(e.Id, e.Name, e.Code, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
@ -71,7 +84,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] StoreInput input, Ca
} }
e.Name = input.Name; e.Name = input.Name;
e.Code = input.Code; e.Code = input.Code;
e.Kind = input.Kind;
e.Address = input.Address; e.Address = input.Address;
e.Phone = input.Phone; e.Phone = input.Phone;
e.ManagerName = input.ManagerName; e.ManagerName = input.ManagerName;

View file

@ -24,13 +24,19 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
if (!string.IsNullOrWhiteSpace(req.Search)) if (!string.IsNullOrWhiteSpace(req.Search))
{ {
var s = req.Search.Trim().ToLower(); var s = req.Search.Trim().ToLower();
q = q.Where(u => u.Name.ToLower().Contains(s) || u.Symbol.ToLower().Contains(s) || u.Code.ToLower().Contains(s)); q = q.Where(u => u.Name.ToLower().Contains(s) || u.Code.ToLower().Contains(s));
} }
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("code", false) => q.OrderBy(u => u.Code),
("code", true) => q.OrderByDescending(u => u.Code),
("name", true) => q.OrderByDescending(u => u.Name),
_ => q.OrderBy(u => u.Name),
};
var items = await q var items = await q
.OrderByDescending(u => u.IsBase).ThenBy(u => u.Name)
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Symbol, u.Name, u.DecimalPlaces, u.IsBase, u.IsActive)) .Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -39,58 +45,44 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct) public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
{ {
var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Symbol, u.Name, u.DecimalPlaces, u.IsBase, u.IsActive); return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId);
} }
[HttpPost, Authorize(Roles = "Admin,Manager")] [HttpPost, Authorize(Roles = "Admin,Manager")]
public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct) public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct)
{ {
if (input.IsBase)
{
await _db.UnitsOfMeasure.Where(u => u.IsBase).ExecuteUpdateAsync(s => s.SetProperty(u => u.IsBase, false), ct);
}
var e = new UnitOfMeasure var e = new UnitOfMeasure
{ {
Code = input.Code, Code = input.Code,
Symbol = input.Symbol,
Name = input.Name, Name = input.Name,
DecimalPlaces = input.DecimalPlaces, Description = input.Description,
IsBase = input.IsBase,
IsActive = input.IsActive,
}; };
_db.UnitsOfMeasure.Add(e); _db.UnitsOfMeasure.Add(e);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id }, return CreatedAtAction(nameof(Get), new { id = e.Id },
new UnitOfMeasureDto(e.Id, e.Code, e.Symbol, e.Name, e.DecimalPlaces, e.IsBase, e.IsActive)); new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.OrganizationId));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,SuperAdmin")]
public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput input, CancellationToken ct)
{ {
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); if (e is null) return NotFound();
if (e.OrganizationId is null && !User.IsInRole("SuperAdmin")) return Forbid();
if (input.IsBase && !e.IsBase)
{
await _db.UnitsOfMeasure.Where(u => u.IsBase && u.Id != id).ExecuteUpdateAsync(s => s.SetProperty(u => u.IsBase, false), ct);
}
e.Code = input.Code; e.Code = input.Code;
e.Symbol = input.Symbol;
e.Name = input.Name; e.Name = input.Name;
e.DecimalPlaces = input.DecimalPlaces; e.Description = input.Description;
e.IsBase = input.IsBase;
e.IsActive = input.IsActive;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
} }
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); if (e is null) return NotFound();
if (e.OrganizationId is null && !User.IsInRole("SuperAdmin")) return Forbid();
_db.UnitsOfMeasure.Remove(e); _db.UnitsOfMeasure.Remove(e);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();

View file

@ -1,101 +0,0 @@
using foodmarket.Application.Catalog;
using foodmarket.Application.Common;
using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Catalog;
[ApiController]
[Authorize]
[Route("api/catalog/vat-rates")]
public class VatRatesController : ControllerBase
{
private readonly AppDbContext _db;
public VatRatesController(AppDbContext db) => _db = db;
[HttpGet]
public async Task<ActionResult<PagedResult<VatRateDto>>> List([FromQuery] PagedRequest req, CancellationToken ct)
{
var q = _db.VatRates.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(v => v.Name.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
var items = await q
.OrderByDescending(v => v.IsDefault).ThenBy(v => v.Percent)
.Skip(req.Skip).Take(req.Take)
.Select(v => new VatRateDto(v.Id, v.Name, v.Percent, v.IsIncludedInPrice, v.IsDefault, v.IsActive))
.ToListAsync(ct);
return new PagedResult<VatRateDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<VatRateDto>> Get(Guid id, CancellationToken ct)
{
var v = await _db.VatRates.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return v is null ? NotFound() : new VatRateDto(v.Id, v.Name, v.Percent, v.IsIncludedInPrice, v.IsDefault, v.IsActive);
}
[HttpPost, Authorize(Roles = "Admin,Manager")]
public async Task<ActionResult<VatRateDto>> Create([FromBody] VatRateInput input, CancellationToken ct)
{
if (input.IsDefault)
{
await ResetDefaultsAsync(ct);
}
var e = new VatRate
{
Name = input.Name,
Percent = input.Percent,
IsIncludedInPrice = input.IsIncludedInPrice,
IsDefault = input.IsDefault,
IsActive = input.IsActive,
};
_db.VatRates.Add(e);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id },
new VatRateDto(e.Id, e.Name, e.Percent, e.IsIncludedInPrice, e.IsDefault, e.IsActive));
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
public async Task<IActionResult> Update(Guid id, [FromBody] VatRateInput input, CancellationToken ct)
{
var e = await _db.VatRates.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
if (input.IsDefault && !e.IsDefault)
{
await ResetDefaultsAsync(ct);
}
e.Name = input.Name;
e.Percent = input.Percent;
e.IsIncludedInPrice = input.IsIncludedInPrice;
e.IsDefault = input.IsDefault;
e.IsActive = input.IsActive;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var e = await _db.VatRates.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
_db.VatRates.Remove(e);
await _db.SaveChangesAsync(ct);
return NoContent();
}
private async Task ResetDefaultsAsync(CancellationToken ct)
{
await _db.VatRates.Where(v => v.IsDefault).ExecuteUpdateAsync(s => s.SetProperty(v => v.IsDefault, false), ct);
}
}

View file

@ -0,0 +1,141 @@
using foodmarket.Application.Common;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Inventory;
[ApiController]
[Authorize]
[Route("api/inventory")]
public class StockController : ControllerBase
{
private readonly AppDbContext _db;
public StockController(AppDbContext db) => _db = db;
public record StockRow(
Guid ProductId, string ProductName, string? Article, string UnitSymbol,
Guid StoreId, string StoreName,
decimal Quantity, decimal ReservedQuantity, decimal Available);
[HttpGet("stock")]
public async Task<ActionResult<PagedResult<StockRow>>> GetStock(
[FromQuery] Guid? storeId,
[FromQuery] Guid? productId,
[FromQuery] string? search,
[FromQuery] bool includeZero = false,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] string? sort = null,
[FromQuery] string? order = null,
CancellationToken ct = default)
{
var q = from s in _db.Stocks
join p in _db.Products on s.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
join st in _db.Stores on s.StoreId equals st.Id
select new { s, p, u, st };
if (storeId.HasValue) q = q.Where(x => x.s.StoreId == storeId.Value);
if (productId.HasValue) q = q.Where(x => x.s.ProductId == productId.Value);
if (!includeZero) q = q.Where(x => x.s.Quantity != 0);
if (!string.IsNullOrWhiteSpace(search))
{
var term = $"%{search.Trim()}%";
q = q.Where(x => EF.Functions.ILike(x.p.Name, term)
|| (x.p.Article != null && EF.Functions.ILike(x.p.Article, term)));
}
var total = await q.CountAsync(ct);
var desc = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase);
q = (sort, desc) switch
{
("article", false) => q.OrderBy(x => x.p.Article).ThenBy(x => x.p.Name),
("article", true) => q.OrderByDescending(x => x.p.Article).ThenBy(x => x.p.Name),
("unit", false) => q.OrderBy(x => x.u.Name).ThenBy(x => x.p.Name),
("unit", true) => q.OrderByDescending(x => x.u.Name).ThenBy(x => x.p.Name),
("store", false) => q.OrderBy(x => x.st.Name).ThenBy(x => x.p.Name),
("store", true) => q.OrderByDescending(x => x.st.Name).ThenBy(x => x.p.Name),
("quantity", false) => q.OrderBy(x => x.s.Quantity).ThenBy(x => x.p.Name),
("quantity", true) => q.OrderByDescending(x => x.s.Quantity).ThenBy(x => x.p.Name),
("reserved", false) => q.OrderBy(x => x.s.ReservedQuantity).ThenBy(x => x.p.Name),
("reserved", true) => q.OrderByDescending(x => x.s.ReservedQuantity).ThenBy(x => x.p.Name),
("available", false) => q.OrderBy(x => x.s.Quantity - x.s.ReservedQuantity).ThenBy(x => x.p.Name),
("available", true) => q.OrderByDescending(x => x.s.Quantity - x.s.ReservedQuantity).ThenBy(x => x.p.Name),
("name", true) => q.OrderByDescending(x => x.p.Name),
_ => q.OrderBy(x => x.p.Name),
};
var items = await q
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new StockRow(
x.p.Id, x.p.Name, x.p.Article, x.u.Name,
x.st.Id, x.st.Name,
x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
.ToListAsync(ct);
return new PagedResult<StockRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
}
public record MovementRow(
Guid Id, DateTime OccurredAt,
Guid ProductId, string ProductName, string? Article,
Guid StoreId, string StoreName,
decimal Quantity, decimal? UnitCost,
string Type, string DocumentType, Guid? DocumentId, string? DocumentNumber,
string? Notes);
[HttpGet("movements")]
public async Task<ActionResult<PagedResult<MovementRow>>> GetMovements(
[FromQuery] Guid? storeId,
[FromQuery] Guid? productId,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] string? sort = null,
[FromQuery] string? order = null,
CancellationToken ct = default)
{
var q = from m in _db.StockMovements
join p in _db.Products on m.ProductId equals p.Id
join st in _db.Stores on m.StoreId equals st.Id
select new { m, p, st };
if (storeId.HasValue) q = q.Where(x => x.m.StoreId == storeId.Value);
if (productId.HasValue) q = q.Where(x => x.m.ProductId == productId.Value);
if (from.HasValue) q = q.Where(x => x.m.OccurredAt >= from.Value);
if (to.HasValue) q = q.Where(x => x.m.OccurredAt < to.Value);
var total = await q.CountAsync(ct);
var desc = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase);
q = (sort, desc) switch
{
("occurredAt", false) => q.OrderBy(x => x.m.OccurredAt),
("occurredAt", true) => q.OrderByDescending(x => x.m.OccurredAt),
("product", false) => q.OrderBy(x => x.p.Name),
("product", true) => q.OrderByDescending(x => x.p.Name),
("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.m.OccurredAt),
("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.m.OccurredAt),
("quantity", false) => q.OrderBy(x => x.m.Quantity).ThenByDescending(x => x.m.OccurredAt),
("quantity", true) => q.OrderByDescending(x => x.m.Quantity).ThenByDescending(x => x.m.OccurredAt),
("type", false) => q.OrderBy(x => x.m.Type).ThenByDescending(x => x.m.OccurredAt),
("type", true) => q.OrderByDescending(x => x.m.Type).ThenByDescending(x => x.m.OccurredAt),
("documentType", false) => q.OrderBy(x => x.m.DocumentType).ThenByDescending(x => x.m.OccurredAt),
("documentType", true) => q.OrderByDescending(x => x.m.DocumentType).ThenByDescending(x => x.m.OccurredAt),
_ => q.OrderByDescending(x => x.m.OccurredAt),
};
var items = await q
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new MovementRow(
x.m.Id, x.m.OccurredAt,
x.p.Id, x.p.Name, x.p.Article,
x.st.Id, x.st.Name,
x.m.Quantity, x.m.UnitCost,
x.m.Type.ToString(), x.m.DocumentType, x.m.DocumentId, x.m.DocumentNumber,
x.m.Notes))
.ToListAsync(ct);
return new PagedResult<MovementRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
}
}

View file

@ -0,0 +1,102 @@
using foodmarket.Application.Common;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Organizations;
[ApiController]
[Authorize]
[Route("api/organization/employee-roles")]
public class EmployeeRolesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public EmployeeRolesController(AppDbContext db, ITenantContext tenant)
{
_db = db; _tenant = tenant;
}
public record EmployeeRoleDto(
Guid Id, string Name, string? Description,
bool IsSystem, int SortOrder, RolePermissions Permissions);
public record EmployeeRoleInput(
string Name, string? Description, RolePermissions Permissions);
[HttpGet]
public async Task<ActionResult<PagedResult<EmployeeRoleDto>>> List(
[FromQuery] PagedRequest req, CancellationToken ct)
{
var q = _db.EmployeeRoles.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(r => r.Name.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
var items = await q
.OrderBy(r => r.SortOrder).ThenBy(r => r.Name)
.Skip(req.Skip).Take(req.Take)
.Select(r => new EmployeeRoleDto(r.Id, r.Name, r.Description, r.IsSystem, r.SortOrder, r.Permissions))
.ToListAsync(ct);
return new PagedResult<EmployeeRoleDto>
{ Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<EmployeeRoleDto>> Get(Guid id, CancellationToken ct)
{
var r = await _db.EmployeeRoles.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return r is null ? NotFound() : new EmployeeRoleDto(r.Id, r.Name, r.Description, r.IsSystem, r.SortOrder, r.Permissions);
}
[HttpPost, Authorize(Roles = "SuperAdmin,Admin,Manager")]
public async Task<ActionResult<EmployeeRoleDto>> Create([FromBody] EmployeeRoleInput input, CancellationToken ct)
{
var role = new EmployeeRole
{
Name = input.Name,
Description = input.Description,
IsSystem = false,
SortOrder = await _db.EmployeeRoles.MaxAsync(r => (int?)r.SortOrder, ct) + 10 ?? 100,
Permissions = input.Permissions ?? new RolePermissions(),
};
_db.EmployeeRoles.Add(role);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = role.Id },
new EmployeeRoleDto(role.Id, role.Name, role.Description, role.IsSystem, role.SortOrder, role.Permissions));
}
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin,Manager")]
public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeRoleInput input, CancellationToken ct)
{
var r = await _db.EmployeeRoles.FirstOrDefaultAsync(x => x.Id == id, ct);
if (r is null) return NotFound();
// Системные роли — имя редактируем (можно перевести), но IsSystem нельзя
// снять; permissions можно править, чтобы кастомизировать под себя.
r.Name = input.Name;
r.Description = input.Description;
r.Permissions = input.Permissions ?? new RolePermissions();
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var r = await _db.EmployeeRoles.FirstOrDefaultAsync(x => x.Id == id, ct);
if (r is null) return NotFound();
if (r.IsSystem) return Conflict(new { error = "Системную роль удалить нельзя." });
var inUse = await _db.Employees.AnyAsync(e => e.RoleId == id, ct);
if (inUse) return Conflict(new { error = "Роль используется сотрудниками." });
_db.EmployeeRoles.Remove(r);
await _db.SaveChangesAsync(ct);
_ = _tenant; // suppress warning
return NoContent();
}
}

View file

@ -0,0 +1,218 @@
using foodmarket.Application.Common;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Organizations;
[ApiController]
[Authorize]
[Route("api/organization/employees")]
public class EmployeesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly UserManager<User> _userMgr;
public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager<User> userMgr)
{
_db = db; _tenant = tenant; _userMgr = userMgr;
}
public record EmployeeDto(
Guid Id, Guid? UserId, string LastName, string FirstName, string? MiddleName,
string? Position, string? Email, string? Phone,
decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl,
Guid RoleId, string RoleName,
bool IsActive, DateTime? FiredAt,
IReadOnlyList<Guid> RetailPointIds);
public record EmployeeInput(
string LastName, string FirstName, string? MiddleName,
string? Position, string? Email, string? Phone,
decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl,
Guid RoleId, bool IsActive,
IReadOnlyList<Guid>? RetailPointIds,
// CreateAccount=true → создаём User c email + temp password.
// Возвращается в response один раз (showOnce).
bool CreateAccount = false);
public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPassword);
[HttpGet]
public async Task<ActionResult<PagedResult<EmployeeDto>>> List(
[FromQuery] PagedRequest req, CancellationToken ct)
{
var q = _db.Employees.AsNoTracking().Include(e => e.Role).Include(e => e.RetailPointAssignments).AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(e =>
e.LastName.ToLower().Contains(s) ||
e.FirstName.ToLower().Contains(s) ||
(e.Email != null && e.Email.ToLower().Contains(s)) ||
(e.Phone != null && e.Phone.Contains(s)));
}
var total = await q.CountAsync(ct);
var items = await q
.OrderBy(e => e.LastName).ThenBy(e => e.FirstName)
.Skip(req.Skip).Take(req.Take)
.Select(e => new EmployeeDto(
e.Id, e.UserId, e.LastName, e.FirstName, e.MiddleName,
e.Position, e.Email, e.Phone,
e.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt,
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList()))
.ToListAsync(ct);
return new PagedResult<EmployeeDto>
{ Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<EmployeeDto>> Get(Guid id, CancellationToken ct)
{
var dto = await ProjectAsync(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, Authorize(Roles = "SuperAdmin,Admin,Manager")]
public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] EmployeeInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var roleExists = await _db.EmployeeRoles.AnyAsync(r => r.Id == input.RoleId, ct);
if (!roleExists) return BadRequest(new { error = "Роль не найдена." });
var employee = new Employee
{
OrganizationId = orgId,
LastName = input.LastName, FirstName = input.FirstName, MiddleName = input.MiddleName,
Position = input.Position, Email = input.Email, Phone = input.Phone,
Salary = input.Salary, TaxNumber = input.TaxNumber,
Description = input.Description, ImageUrl = input.ImageUrl,
RoleId = input.RoleId, IsActive = input.IsActive,
};
string? tempPassword = null;
if (input.CreateAccount)
{
if (string.IsNullOrWhiteSpace(input.Email))
return BadRequest(new { error = "Для создания учётной записи нужен email." });
var existing = await _userMgr.FindByEmailAsync(input.Email);
if (existing is not null)
return BadRequest(new { error = $"Пользователь с email «{input.Email}» уже существует." });
tempPassword = GenerateTempPassword();
var user = new User
{
UserName = input.Email,
Email = input.Email,
EmailConfirmed = true,
FullName = $"{input.LastName} {input.FirstName}".Trim(),
OrganizationId = orgId,
IsActive = input.IsActive,
};
var result = await _userMgr.CreateAsync(user, tempPassword);
if (!result.Succeeded)
return BadRequest(new { error = string.Join("; ", result.Errors.Select(e => e.Description)) });
employee.UserId = user.Id;
}
foreach (var rpId in input.RetailPointIds ?? [])
{
employee.RetailPointAssignments.Add(new EmployeeRetailPointAssignment
{ OrganizationId = orgId, RetailPointId = rpId });
}
_db.Employees.Add(employee);
await _db.SaveChangesAsync(ct);
var dto = await ProjectAsync(employee.Id, ct);
return new EmployeeCreateResult(dto!, tempPassword);
}
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin,Manager")]
public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var e = await _db.Employees.Include(x => x.RetailPointAssignments)
.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
e.LastName = input.LastName;
e.FirstName = input.FirstName;
e.MiddleName = input.MiddleName;
e.Position = input.Position;
e.Email = input.Email;
e.Phone = input.Phone;
e.Salary = input.Salary;
e.TaxNumber = input.TaxNumber;
e.Description = input.Description;
e.ImageUrl = input.ImageUrl;
e.RoleId = input.RoleId;
var nowActive = input.IsActive;
if (e.IsActive && !nowActive) e.FiredAt = DateTime.UtcNow;
if (!e.IsActive && nowActive) e.FiredAt = null;
e.IsActive = nowActive;
// Replace assignments wholesale
_db.EmployeeRetailPointAssignments.RemoveRange(e.RetailPointAssignments);
e.RetailPointAssignments.Clear();
foreach (var rpId in input.RetailPointIds ?? [])
e.RetailPointAssignments.Add(new EmployeeRetailPointAssignment
{ OrganizationId = orgId, RetailPointId = rpId });
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
_db.Employees.Remove(e);
await _db.SaveChangesAsync(ct);
return NoContent();
}
private async Task<EmployeeDto?> ProjectAsync(Guid id, CancellationToken ct)
{
return await _db.Employees.AsNoTracking()
.Include(e => e.Role)
.Include(e => e.RetailPointAssignments)
.Where(e => e.Id == id)
.Select(e => new EmployeeDto(
e.Id, e.UserId, e.LastName, e.FirstName, e.MiddleName,
e.Position, e.Email, e.Phone,
e.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt,
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList()))
.FirstOrDefaultAsync(ct);
}
private static string GenerateTempPassword()
{
// 12 символов: цифры + строчные/заглавные + спецсимвол — соответствует
// дефолтным правилам ASP.NET Identity (>=8, разные классы символов).
const string lower = "abcdefghkmnpqrstuvwxyz";
const string upper = "ABCDEFGHKMNPQRSTUVWXYZ";
const string digits = "23456789";
const string special = "!@#$%&*";
var rnd = new Random();
var chars = new List<char>
{
upper[rnd.Next(upper.Length)],
lower[rnd.Next(lower.Length)],
digits[rnd.Next(digits.Length)],
special[rnd.Next(special.Length)],
};
var pool = lower + upper + digits;
for (var i = 0; i < 8; i++) chars.Add(pool[rnd.Next(pool.Length)]);
return new string(chars.OrderBy(_ => rnd.Next()).ToArray());
}
}

View file

@ -0,0 +1,124 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Organizations;
[ApiController]
[Authorize]
[Route("api/organization")]
public class OrganizationSettingsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public OrganizationSettingsController(AppDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
public record OrgSettingsDto(
Guid Id,
string Name,
string CountryCode,
Guid? DefaultCurrencyId,
string? DefaultCurrencyCode,
string? DefaultCurrencySymbol,
bool MultiCurrencyEnabled,
// VAT read-only: из страны организации (countries.VatRate). Источник правды — справочник стран.
decimal VatRate,
bool ShowVatEnabledOnProduct,
bool ShowServiceOnProduct,
bool ShowMarkedOnProduct,
bool ShowMinMaxStock,
bool AllowFractionalPrices,
bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct,
bool ShowDescriptionOnProduct);
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
public record OrgSettingsInput(
string Name,
string CountryCode,
bool MultiCurrencyEnabled,
bool ShowVatEnabledOnProduct,
bool ShowServiceOnProduct,
bool ShowMarkedOnProduct,
bool ShowMinMaxStock,
bool AllowFractionalPrices,
bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct,
bool ShowDescriptionOnProduct);
[HttpGet("settings")]
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var o = await _db.Organizations
.Include(o => o.DefaultCurrency)
.FirstOrDefaultAsync(o => o.Id == orgId, ct);
if (o is null) return NotFound();
var vat = await ReadVatRateAsync(o.CountryCode, ct);
return Project(o, vat);
}
[HttpPut("settings"), Authorize(Roles = "Admin,Manager")]
public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var o = await _db.Organizations
.Include(o => o.DefaultCurrency)
.FirstOrDefaultAsync(o => o.Id == orgId, ct);
if (o is null) return NotFound();
o.Name = input.Name;
o.CountryCode = input.CountryCode;
// Валюта организации жёстко следует за страной — не принимается от клиента.
o.DefaultCurrencyId = await _db.Countries
.Where(c => c.Code == input.CountryCode)
.Select(c => c.DefaultCurrencyId)
.FirstOrDefaultAsync(ct);
o.MultiCurrencyEnabled = input.MultiCurrencyEnabled;
o.ShowVatEnabledOnProduct = input.ShowVatEnabledOnProduct;
o.ShowServiceOnProduct = input.ShowServiceOnProduct;
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
o.ShowMinMaxStock = input.ShowMinMaxStock;
o.AllowFractionalPrices = input.AllowFractionalPrices;
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct;
await _db.SaveChangesAsync(ct);
await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct);
var vat = await ReadVatRateAsync(o.CountryCode, ct);
return Project(o, vat);
}
private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationToken ct)
{
var rate = await _db.Countries
.Where(c => c.Code == countryCode)
.Select(c => (decimal?)c.VatRate)
.FirstOrDefaultAsync(ct);
return rate ?? 0m;
}
private static OrgSettingsDto Project(foodmarket.Domain.Organizations.Organization o, decimal vat) => new(
o.Id, o.Name, o.CountryCode,
o.DefaultCurrencyId,
o.DefaultCurrency?.Code,
o.DefaultCurrency?.Symbol,
o.MultiCurrencyEnabled,
vat,
o.ShowVatEnabledOnProduct,
o.ShowServiceOnProduct,
o.ShowMarkedOnProduct,
o.ShowMinMaxStock,
o.AllowFractionalPrices,
o.ShowReferencePriceOnProduct,
o.ShowCountryOfOriginOnProduct,
o.ShowDescriptionOnProduct);
}

View file

@ -0,0 +1,412 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Purchases;
[ApiController]
[Authorize]
[Route("api/purchases/supplies")]
public class SuppliesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public SuppliesController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record SupplyListRow(
Guid Id, string Number, DateTime Date, SupplyStatus Status,
Guid SupplierId, string SupplierName,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
decimal Total, int LineCount,
DateTime? PostedAt);
public record SupplyLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
string? ProductBarcode,
string? UnitSymbol,
decimal Quantity, decimal UnitPrice, decimal LineTotal, int SortOrder,
bool RetailPriceManuallyOverridden, decimal? RetailPriceOverride,
decimal? CurrentRetailPrice);
public record SupplyDto(
Guid Id, string Number, DateTime Date, SupplyStatus Status,
Guid SupplierId, string SupplierName,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
string? Notes,
decimal Total, DateTime? PostedAt,
IReadOnlyList<SupplyLineDto> Lines);
public record SupplyLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal Quantity,
[Range(0, 1e10)] decimal UnitPrice,
bool RetailPriceManuallyOverridden = false,
[Range(0, 1e10)] decimal? RetailPriceOverride = null);
public record SupplyInput(
DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId,
string? Notes,
IReadOnlyList<SupplyLineInput> Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<SupplyListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] SupplyStatus? status,
[FromQuery] Guid? storeId,
[FromQuery] Guid? supplierId,
CancellationToken ct)
{
var q = from s in _db.Supplies.AsNoTracking()
join cp in _db.Counterparties on s.SupplierId equals cp.Id
join st in _db.Stores on s.StoreId equals st.Id
join cu in _db.Currencies on s.CurrencyId equals cu.Id
select new { s, cp, st, cu };
if (status is not null) q = q.Where(x => x.s.Status == status);
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
if (supplierId is not null) q = q.Where(x => x.s.SupplierId == supplierId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.s.Number.ToLower().Contains(s) || x.cp.Name.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("number", false) => q.OrderBy(x => x.s.Number),
("number", true) => q.OrderByDescending(x => x.s.Number),
("supplier", false) => q.OrderBy(x => x.cp.Name).ThenByDescending(x => x.s.Date),
("supplier", true) => q.OrderByDescending(x => x.cp.Name).ThenByDescending(x => x.s.Date),
("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.s.Date),
("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.s.Date),
("status", false) => q.OrderBy(x => x.s.Status).ThenByDescending(x => x.s.Date),
("status", true) => q.OrderByDescending(x => x.s.Status).ThenByDescending(x => x.s.Date),
("total", false) => q.OrderBy(x => x.s.Total).ThenByDescending(x => x.s.Date),
("total", true) => q.OrderByDescending(x => x.s.Total).ThenByDescending(x => x.s.Date),
("date", false) => q.OrderBy(x => x.s.Date).ThenBy(x => x.s.Number),
_ => q.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number),
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(x => new SupplyListRow(
x.s.Id, x.s.Number, x.s.Date, x.s.Status,
x.cp.Id, x.cp.Name,
x.st.Id, x.st.Name,
x.cu.Id, x.cu.Code,
x.s.Total,
x.s.Lines.Count,
x.s.PostedAt))
.ToListAsync(ct);
return new PagedResult<SupplyListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
{
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Приёмка должна содержать хотя бы одну позицию." });
var number = await GenerateNumberAsync(input.Date, ct);
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
var supply = new Supply
{
Number = number,
Date = input.Date,
Status = SupplyStatus.Draft,
SupplierId = input.SupplierId,
StoreId = input.StoreId,
CurrencyId = input.CurrencyId,
Notes = input.Notes,
};
var order = 0;
foreach (var l in input.Lines)
{
var unitPrice = allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero);
supply.Lines.Add(new SupplyLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = unitPrice,
LineTotal = l.Quantity * unitPrice,
SortOrder = order++,
RetailPriceManuallyOverridden = l.RetailPriceManuallyOverridden,
RetailPriceOverride = l.RetailPriceOverride.HasValue
? (allowFractional ? l.RetailPriceOverride : Math.Round(l.RetailPriceOverride.Value, 0, MidpointRounding.AwayFromZero))
: null,
});
}
supply.Total = supply.Lines.Sum(x => x.LineTotal);
_db.Supplies.Add(supply);
await _db.SaveChangesAsync(ct);
var dto = await GetInternal(supply.Id, ct);
return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto);
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct)
{
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Приёмка должна содержать хотя бы одну позицию." });
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (supply is null) return NotFound();
if (supply.Status != SupplyStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
supply.Date = input.Date;
supply.SupplierId = input.SupplierId;
supply.StoreId = input.StoreId;
supply.CurrencyId = input.CurrencyId;
supply.Notes = input.Notes;
// Replace lines wholesale (simple, idempotent).
_db.SupplyLines.RemoveRange(supply.Lines);
supply.Lines.Clear();
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
var order = 0;
foreach (var l in input.Lines)
{
var unitPrice = allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero);
supply.Lines.Add(new SupplyLine
{
SupplyId = supply.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = unitPrice,
LineTotal = l.Quantity * unitPrice,
SortOrder = order++,
RetailPriceManuallyOverridden = l.RetailPriceManuallyOverridden,
RetailPriceOverride = l.RetailPriceOverride.HasValue
? (allowFractional ? l.RetailPriceOverride : Math.Round(l.RetailPriceOverride.Value, 0, MidpointRounding.AwayFromZero))
: null,
});
}
supply.Total = supply.Lines.Sum(x => x.LineTotal);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var supply = await _db.Supplies.FirstOrDefaultAsync(s => s.Id == id, ct);
if (supply is null) return NotFound();
if (supply.Status != SupplyStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.Supplies.Remove(supply);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (supply is null) return NotFound();
if (supply.Status == SupplyStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (supply.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
await using var tx = await _db.Database.BeginTransactionAsync(ct);
var now = DateTime.UtcNow;
foreach (var line in supply.Lines)
{
var product = await _db.Products
.Include(p => p.ProductGroup)
.Include(p => p.Prices)
.FirstAsync(p => p.Id == line.ProductId, ct);
// Текущее общее количество по всем складам (до этой приёмки).
var currentQty = await _db.Stocks
.Where(s => s.ProductId == line.ProductId)
.SumAsync(s => (decimal?)s.Quantity, ct) ?? 0m;
// 1. Cost — скользящее среднее.
var totalQty = currentQty + line.Quantity;
var newCost = totalQty == 0m || product.Cost == 0m && currentQty == 0m
? line.UnitPrice
: (currentQty * product.Cost + line.Quantity * line.UnitPrice) / totalQty;
product.Cost = Math.Round(newCost, 4, MidpointRounding.AwayFromZero);
// 2. ReferencePrice — автозаполнение при первой приёмке.
if (product.ReferencePrice is null)
{
product.ReferencePrice = line.UnitPrice;
product.ReferencePriceUpdatedAt = now;
}
product.LastSupplyAt = now;
// 3. Розничная: либо явный override строки, либо автонаценка по группе.
if (line.RetailPriceManuallyOverridden && line.RetailPriceOverride.HasValue)
{
SetDefaultRetail(product, line.RetailPriceOverride.Value, supply.CurrencyId);
}
else if (product.ProductGroup?.MarkupPercent is decimal pct)
{
var raw = product.Cost * (1m + pct / 100m);
var newRetail = allowFractional
? Math.Ceiling(raw * 100m) / 100m
: Math.Ceiling(raw);
SetDefaultRetail(product, newRetail, supply.CurrencyId);
}
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: supply.StoreId,
Quantity: line.Quantity,
Type: MovementType.Supply,
DocumentType: "supply",
DocumentId: supply.Id,
DocumentNumber: supply.Number,
UnitCost: line.UnitPrice,
OccurredAt: supply.Date), ct);
}
supply.Status = SupplyStatus.Posted;
supply.PostedAt = now;
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return NoContent();
}
/// <summary>Записывает значение в дефолтный розничный PriceType. Если в списке
/// цен у товара такой записи нет — создаёт её. Дефолтным считается PriceType
/// с IsSystem=true; если такого нет — первый IsRetail; иначе — первый
/// PriceType в списке. Currency берётся из приёмки (или из существующей записи).</summary>
private void SetDefaultRetail(foodmarket.Domain.Catalog.Product p, decimal value, Guid fallbackCurrencyId)
{
var defaultType = _db.PriceTypes
.OrderByDescending(pt => pt.IsSystem)
.ThenByDescending(pt => pt.IsRetail)
.ThenBy(pt => pt.SortOrder)
.ThenBy(pt => pt.Name)
.FirstOrDefault();
if (defaultType is null) return;
var existing = p.Prices.FirstOrDefault(x => x.PriceTypeId == defaultType.Id);
if (existing is null)
{
p.Prices.Add(new foodmarket.Domain.Catalog.ProductPrice
{
PriceTypeId = defaultType.Id,
Amount = value,
CurrencyId = fallbackCurrencyId,
});
}
else
{
existing.Amount = value;
}
}
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (supply is null) return NotFound();
if (supply.Status != SupplyStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse: negative movements with same document reference
foreach (var line in supply.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: supply.StoreId,
Quantity: -line.Quantity,
Type: MovementType.Supply,
DocumentType: "supply-reversal",
DocumentId: supply.Id,
DocumentNumber: supply.Number,
UnitCost: line.UnitPrice,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена проведения документа {supply.Number}"), ct);
}
supply.Status = SupplyStatus.Draft;
supply.PostedAt = null;
await _db.SaveChangesAsync(ct);
return NoContent();
}
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var year = date.Year;
var prefix = $"П-{year}-";
var lastNumber = await _db.Supplies
.Where(s => s.Number.StartsWith(prefix))
.OrderByDescending(s => s.Number)
.Select(s => s.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
seq = last + 1;
return $"{prefix}{seq:D6}";
}
private async Task<SupplyDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from s in _db.Supplies.AsNoTracking()
join cp in _db.Counterparties on s.SupplierId equals cp.Id
join st in _db.Stores on s.StoreId equals st.Id
join cu in _db.Currencies on s.CurrencyId equals cu.Id
where s.Id == id
select new { s, cp, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null;
// CurrentRetailPrice — текущая розничная цена товара (дефолтный PriceType),
// отображается в строке приёмки как «Розничная (из карточки)».
var lines = await (from l in _db.SupplyLines.AsNoTracking()
join p in _db.Products on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
where l.SupplyId == id
orderby l.SortOrder
select new SupplyLineDto(
l.Id, l.ProductId, p.Name, p.Article,
// Основной штрихкод (IsPrimary=true), иначе первый по порядку.
p.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault(),
u.Name,
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder,
l.RetailPriceManuallyOverridden, l.RetailPriceOverride,
p.Prices
.OrderByDescending(pr => pr.PriceType!.IsSystem)
.ThenByDescending(pr => pr.PriceType!.IsRetail)
.ThenBy(pr => pr.PriceType!.SortOrder)
.ThenBy(pr => pr.PriceType!.Name)
.Select(pr => (decimal?)pr.Amount)
.FirstOrDefault()))
.ToListAsync(ct);
return new SupplyDto(
row.s.Id, row.s.Number, row.s.Date, row.s.Status,
row.cp.Id, row.cp.Name,
row.st.Id, row.st.Name,
row.cu.Id, row.cu.Code,
row.s.Notes,
row.s.Total, row.s.PostedAt,
lines);
}
}

View file

@ -0,0 +1,397 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Sales;
[ApiController]
[Authorize]
[Route("api/sales/retail")]
public class RetailSalesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public RetailSalesController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record RetailSaleListRow(
Guid Id, string Number, DateTime Date, RetailSaleStatus Status,
Guid StoreId, string StoreName,
Guid? RetailPointId, string? RetailPointName,
Guid? CustomerId, string? CustomerName,
Guid CurrencyId, string CurrencyCode,
decimal Total, PaymentMethod Payment, int LineCount,
DateTime? PostedAt);
public record RetailSaleLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
decimal Quantity, decimal UnitPrice, decimal Discount, decimal LineTotal, decimal VatPercent, int SortOrder);
public record RetailSaleDto(
Guid Id, string Number, DateTime Date, RetailSaleStatus Status,
Guid StoreId, string StoreName,
Guid? RetailPointId, string? RetailPointName,
Guid? CustomerId, string? CustomerName,
Guid CurrencyId, string CurrencyCode,
decimal Subtotal, decimal DiscountTotal, decimal Total,
PaymentMethod Payment, decimal PaidCash, decimal PaidCard,
string? Notes, DateTime? PostedAt,
IReadOnlyList<RetailSaleLineDto> Lines);
public record RetailSaleLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal Quantity,
[Range(0, 1e10)] decimal UnitPrice,
[Range(0, 1e10)] decimal Discount,
[Range(0, 100)] decimal VatPercent);
public record RetailSaleInput(
DateTime Date, Guid StoreId, Guid? RetailPointId, Guid? CustomerId, Guid CurrencyId,
PaymentMethod Payment,
[Range(0, 1e10)] decimal PaidCash,
[Range(0, 1e10)] decimal PaidCard,
string? Notes,
IReadOnlyList<RetailSaleLineInput> Lines);
public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions);
public record SalesStatsResponse(
decimal RevenueToday,
decimal RevenueThisMonth,
decimal RevenuePrevMonth,
int TransactionsToday,
int TransactionsThisMonth,
decimal AvgTicketThisMonth,
IReadOnlyList<SalesStatsBucket> Series);
/// <summary>Aggregated sales metrics + daily series for the dashboard.
/// Series buckets are days; defaults to last 30 days.</summary>
[HttpGet("stats")]
public async Task<ActionResult<SalesStatsResponse>> Stats(
[FromQuery] int days = 30,
CancellationToken ct = default)
{
var nowUtc = DateTime.UtcNow;
var todayStart = new DateTime(nowUtc.Year, nowUtc.Month, nowUtc.Day, 0, 0, 0, DateTimeKind.Utc);
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var prevMonthStart = monthStart.AddMonths(-1);
var seriesStart = todayStart.AddDays(-(days - 1));
var posted = _db.RetailSales.AsNoTracking().Where(s => s.Status == RetailSaleStatus.Posted);
var today = await posted.Where(s => s.Date >= todayStart && s.Date < todayStart.AddDays(1))
.GroupBy(_ => 1)
.Select(g => new { Sum = g.Sum(s => s.Total), Count = g.Count() })
.FirstOrDefaultAsync(ct);
var thisMonth = await posted.Where(s => s.Date >= monthStart)
.GroupBy(_ => 1)
.Select(g => new { Sum = g.Sum(s => s.Total), Count = g.Count() })
.FirstOrDefaultAsync(ct);
var prevMonth = await posted.Where(s => s.Date >= prevMonthStart && s.Date < monthStart)
.GroupBy(_ => 1)
.Select(g => new { Sum = g.Sum(s => s.Total) })
.FirstOrDefaultAsync(ct);
var rawSeries = await posted.Where(s => s.Date >= seriesStart)
.GroupBy(s => s.Date.Date)
.Select(g => new { Day = g.Key, Revenue = g.Sum(s => s.Total), Tx = g.Count() })
.ToListAsync(ct);
// Fill missing days with zeros so the chart line is continuous.
var byDay = rawSeries.ToDictionary(x => x.Day, x => x);
var series = Enumerable.Range(0, days)
.Select(i => seriesStart.AddDays(i).Date)
.Select(d => byDay.TryGetValue(d, out var v)
? new SalesStatsBucket(d, v.Revenue, v.Tx)
: new SalesStatsBucket(d, 0m, 0))
.ToList();
var thisMonthSum = thisMonth?.Sum ?? 0m;
var thisMonthCount = thisMonth?.Count ?? 0;
var avgTicket = thisMonthCount == 0 ? 0m : thisMonthSum / thisMonthCount;
return new SalesStatsResponse(
RevenueToday: today?.Sum ?? 0m,
RevenueThisMonth: thisMonthSum,
RevenuePrevMonth: prevMonth?.Sum ?? 0m,
TransactionsToday: today?.Count ?? 0,
TransactionsThisMonth: thisMonthCount,
AvgTicketThisMonth: avgTicket,
Series: series);
}
[HttpGet]
public async Task<ActionResult<PagedResult<RetailSaleListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] RetailSaleStatus? status,
[FromQuery] Guid? storeId,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
CancellationToken ct)
{
var q = from s in _db.RetailSales.AsNoTracking()
join st in _db.Stores on s.StoreId equals st.Id
join cu in _db.Currencies on s.CurrencyId equals cu.Id
select new { s, st, cu };
if (status is not null) q = q.Where(x => x.s.Status == status);
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
if (from is not null) q = q.Where(x => x.s.Date >= from);
if (to is not null) q = q.Where(x => x.s.Date < to);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.s.Number.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("number", false) => q.OrderBy(x => x.s.Number),
("number", true) => q.OrderByDescending(x => x.s.Number),
("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.s.Date),
("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.s.Date),
("status", false) => q.OrderBy(x => x.s.Status).ThenByDescending(x => x.s.Date),
("status", true) => q.OrderByDescending(x => x.s.Status).ThenByDescending(x => x.s.Date),
("total", false) => q.OrderBy(x => x.s.Total).ThenByDescending(x => x.s.Date),
("total", true) => q.OrderByDescending(x => x.s.Total).ThenByDescending(x => x.s.Date),
("date", false) => q.OrderBy(x => x.s.Date).ThenBy(x => x.s.Number),
_ => q.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number),
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(x => new RetailSaleListRow(
x.s.Id, x.s.Number, x.s.Date, x.s.Status,
x.st.Id, x.st.Name,
x.s.RetailPointId,
x.s.RetailPointId == null ? null : _db.RetailPoints.Where(rp => rp.Id == x.s.RetailPointId).Select(rp => rp.Name).FirstOrDefault(),
x.s.CustomerId,
x.s.CustomerId == null ? null : _db.Counterparties.Where(c => c.Id == x.s.CustomerId).Select(c => c.Name).FirstOrDefault(),
x.cu.Id, x.cu.Code,
x.s.Total, x.s.Payment,
x.s.Lines.Count,
x.s.PostedAt))
.ToListAsync(ct);
return new PagedResult<RetailSaleListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<RetailSaleDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, Authorize(Roles = "Admin,Manager,Cashier")]
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
{
var number = await GenerateNumberAsync(input.Date, ct);
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
var sale = new RetailSale
{
Number = number,
Date = input.Date,
Status = RetailSaleStatus.Draft,
StoreId = input.StoreId,
RetailPointId = input.RetailPointId,
CustomerId = input.CustomerId,
CurrencyId = input.CurrencyId,
Payment = input.Payment,
PaidCash = R(input.PaidCash),
PaidCard = R(input.PaidCard),
Notes = input.Notes,
};
ApplyLines(sale, input.Lines, allowFractional);
_db.RetailSales.Add(sale);
await _db.SaveChangesAsync(ct);
var dto = await GetInternal(sale.Id, ct);
return CreatedAtAction(nameof(Get), new { id = sale.Id }, dto);
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Cashier")]
public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct)
{
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (sale is null) return NotFound();
if (sale.Status != RetailSaleStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён." });
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
sale.Date = input.Date;
sale.StoreId = input.StoreId;
sale.RetailPointId = input.RetailPointId;
sale.CustomerId = input.CustomerId;
sale.CurrencyId = input.CurrencyId;
sale.Payment = input.Payment;
sale.PaidCash = R(input.PaidCash);
sale.PaidCard = R(input.PaidCard);
sale.Notes = input.Notes;
_db.RetailSaleLines.RemoveRange(sale.Lines);
sale.Lines.Clear();
ApplyLines(sale, input.Lines, allowFractional);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var sale = await _db.RetailSales.FirstOrDefaultAsync(s => s.Id == id, ct);
if (sale is null) return NotFound();
if (sale.Status != RetailSaleStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый чек." });
_db.RetailSales.Remove(sale);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Manager,Cashier")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (sale is null) return NotFound();
if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." });
if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." });
foreach (var line in sale.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: sale.StoreId,
Quantity: -line.Quantity, // negative: товар уходит со склада
Type: MovementType.RetailSale,
DocumentType: "retail-sale",
DocumentId: sale.Id,
DocumentNumber: sale.Number,
UnitCost: line.UnitPrice,
OccurredAt: sale.Date), ct);
}
sale.Status = RetailSaleStatus.Posted;
sale.PostedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (sale is null) return NotFound();
if (sale.Status != RetailSaleStatus.Posted) return Conflict(new { error = "Чек не проведён." });
foreach (var line in sale.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: sale.StoreId,
Quantity: +line.Quantity, // reverse — return stock
Type: MovementType.RetailSale,
DocumentType: "retail-sale-reversal",
DocumentId: sale.Id,
DocumentNumber: sale.Number,
UnitCost: line.UnitPrice,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена чека {sale.Number}"), ct);
}
sale.Status = RetailSaleStatus.Draft;
sale.PostedAt = null;
await _db.SaveChangesAsync(ct);
return NoContent();
}
private static void ApplyLines(RetailSale sale, IReadOnlyList<RetailSaleLineInput> input, bool allowFractional)
{
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
var order = 0;
decimal subtotal = 0, discountTotal = 0;
foreach (var l in input)
{
var unitPrice = R(l.UnitPrice);
var discount = R(l.Discount);
var lineTotal = l.Quantity * unitPrice - discount;
sale.Lines.Add(new RetailSaleLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = unitPrice,
Discount = discount,
LineTotal = lineTotal,
VatPercent = l.VatPercent,
SortOrder = order++,
});
subtotal += l.Quantity * unitPrice;
discountTotal += discount;
}
sale.Subtotal = subtotal;
sale.DiscountTotal = discountTotal;
sale.Total = subtotal - discountTotal;
}
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var prefix = $"ПР-{date.Year}-";
var lastNumber = await _db.RetailSales
.Where(s => s.Number.StartsWith(prefix))
.OrderByDescending(s => s.Number)
.Select(s => s.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
seq = last + 1;
return $"{prefix}{seq:D6}";
}
private async Task<RetailSaleDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from s in _db.RetailSales.AsNoTracking()
join st in _db.Stores on s.StoreId equals st.Id
join cu in _db.Currencies on s.CurrencyId equals cu.Id
where s.Id == id
select new { s, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null;
string? rpName = row.s.RetailPointId is null ? null
: await _db.RetailPoints.Where(r => r.Id == row.s.RetailPointId).Select(r => r.Name).FirstOrDefaultAsync(ct);
string? cName = row.s.CustomerId is null ? null
: await _db.Counterparties.Where(c => c.Id == row.s.CustomerId).Select(c => c.Name).FirstOrDefaultAsync(ct);
var lines = await (from l in _db.RetailSaleLines.AsNoTracking()
join p in _db.Products on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
where l.RetailSaleId == id
orderby l.SortOrder
select new RetailSaleLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Name,
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder))
.ToListAsync(ct);
return new RetailSaleDto(
row.s.Id, row.s.Number, row.s.Date, row.s.Status,
row.st.Id, row.st.Name,
row.s.RetailPointId, rpName,
row.s.CustomerId, cName,
row.cu.Id, row.cu.Code,
row.s.Subtotal, row.s.DiscountTotal, row.s.Total,
row.s.Payment, row.s.PaidCash, row.s.PaidCard,
row.s.Notes, row.s.PostedAt,
lines);
}
}

View file

@ -0,0 +1,127 @@
using foodmarket.Application.Common;
using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
namespace foodmarket.Api.Controllers.SuperAdmin;
/// <summary>SuperAdmin: setup-status, dashboard статистика, audit log.</summary>
[ApiController]
[Authorize(Roles = "SuperAdmin")]
[Route("api/super-admin")]
public class SuperAdminController : ControllerBase
{
private readonly AppDbContext _db;
public SuperAdminController(AppDbContext db) => _db = db;
public record SetupStatusDto(bool NeedsSetup, int OrgCount);
[HttpGet("setup-status")]
public async Task<ActionResult<SetupStatusDto>> GetSetupStatus(CancellationToken ct)
{
var count = await _db.Organizations.IgnoreQueryFilters().CountAsync(ct);
return new SetupStatusDto(NeedsSetup: count == 0, OrgCount: count);
}
public record DashboardStats(
int TotalOrgs, int ActiveOrgs, int ArchivedOrgs,
int TotalUsers, int ActiveUsers,
int RegistrationsLast30Days);
[HttpGet("dashboard")]
public async Task<ActionResult<DashboardStats>> Dashboard(CancellationToken ct)
{
// Метрики SuperAdmin'а — кабинет SaaS-владельца, не операционные
// показатели магазинов. Биллинговые KPI (MRR, должники, платящие)
// считаем на UI как заглушки — отдельный модуль подписки в Phase 4+.
var monthAgo = DateTime.UtcNow.AddDays(-30);
return new DashboardStats(
TotalOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(ct),
ActiveOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => !o.IsArchived, ct),
ArchivedOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => o.IsArchived, ct),
TotalUsers: await _db.Users.CountAsync(ct),
ActiveUsers: await _db.Users.CountAsync(u => u.IsActive, ct),
RegistrationsLast30Days: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => o.CreatedAt >= monthAgo, ct));
}
public record SystemSettingsDto(int ArchiveRetentionDays);
public record SystemSettingsInput(int ArchiveRetentionDays);
[HttpGet("settings")]
public async Task<ActionResult<SystemSettingsDto>> GetSettings(CancellationToken ct)
{
var s = await _db.SystemSettings.FirstOrDefaultAsync(ct);
return new SystemSettingsDto(s?.ArchiveRetentionDays ?? 30);
}
[HttpPut("settings")]
public async Task<ActionResult<SystemSettingsDto>> UpdateSettings([FromBody] SystemSettingsInput input, CancellationToken ct)
{
if (input.ArchiveRetentionDays < 0 || input.ArchiveRetentionDays > 3650)
return BadRequest(new { error = "ArchiveRetentionDays должен быть в диапазоне 03650." });
var s = await _db.SystemSettings.FirstOrDefaultAsync(ct);
var prev = s?.ArchiveRetentionDays;
if (s is null)
{
s = new SystemSettings { ArchiveRetentionDays = input.ArchiveRetentionDays };
_db.SystemSettings.Add(s);
}
else
{
s.ArchiveRetentionDays = input.ArchiveRetentionDays;
}
await _db.SaveChangesAsync(ct);
// Audit-log смены настройки
var userIdRaw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
Guid.TryParse(userIdRaw, out var uid);
_db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog
{
SuperAdminUserId = uid,
ActionType = "EditSystemSettings",
Description = $"ArchiveRetentionDays: {prev?.ToString() ?? "(default 30)"} → {input.ArchiveRetentionDays}",
ChangesJson = $"{{\"archiveRetentionDays\":{{\"from\":{prev?.ToString() ?? "30"},\"to\":{input.ArchiveRetentionDays}}}}}",
IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "",
});
await _db.SaveChangesAsync(ct);
return new SystemSettingsDto(s.ArchiveRetentionDays);
}
public record AuditRow(
Guid Id, DateTime CreatedAt, Guid SuperAdminUserId,
string ActionType, Guid? OrganizationId, string? OrganizationName,
string? EntityType, Guid? EntityId, string? Description, string? Reason, string IpAddress);
[HttpGet("audit-log")]
public async Task<ActionResult<PagedResult<AuditRow>>> AuditLog(
[FromQuery] PagedRequest req,
[FromQuery] Guid? organizationId,
[FromQuery] string? actionType,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
CancellationToken ct)
{
var q = _db.SuperAdminAuditLogs.AsNoTracking().AsQueryable();
if (organizationId is not null) q = q.Where(x => x.OrganizationId == organizationId);
if (!string.IsNullOrWhiteSpace(actionType)) q = q.Where(x => x.ActionType == actionType);
if (from is not null) q = q.Where(x => x.CreatedAt >= from);
if (to is not null) q = q.Where(x => x.CreatedAt <= to);
var total = await q.CountAsync(ct);
var orgNames = await _db.Organizations.IgnoreQueryFilters()
.ToDictionaryAsync(o => o.Id, o => o.Name, ct);
var items = await q
.OrderByDescending(x => x.CreatedAt)
.Skip(req.Skip).Take(req.Take)
.ToListAsync(ct);
var rows = items.Select(x => new AuditRow(
x.Id, x.CreatedAt, x.SuperAdminUserId,
x.ActionType, x.OrganizationId,
x.OrganizationId is not null && orgNames.TryGetValue(x.OrganizationId.Value, out var n) ? n : null,
x.EntityType, x.EntityId, x.Description, x.Reason, x.IpAddress)).ToList();
return new PagedResult<AuditRow> { Items = rows, Total = total, Page = req.Page, PageSize = req.Take };
}
}

View file

@ -0,0 +1,280 @@
using foodmarket.Api.Seed;
using foodmarket.Application.Common;
using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
namespace foodmarket.Api.Controllers.SuperAdmin;
/// <summary>SuperAdmin console: управление организациями. Все запросы
/// IgnoreQueryFilters() — обходим tenant-фильтр, видим всё. Все мутации
/// логируются в super_admin_audit_log.</summary>
[ApiController]
[Authorize(Roles = "SuperAdmin")]
[Route("api/super-admin/organizations")]
public class SuperAdminOrganizationsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly UserManager<User> _userMgr;
public SuperAdminOrganizationsController(AppDbContext db, UserManager<User> userMgr)
{
_db = db; _userMgr = userMgr;
}
public record OrgRow(
Guid Id, string Name, string CountryCode,
bool IsActive, bool IsArchived, DateTime? ArchivedAt,
DateTime CreatedAt, int EmployeeCount, int ProductCount,
DateTime? LastLoginAt);
public record OrgDetail(
Guid Id, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
Guid? DefaultCurrencyId, string? DefaultCurrencyCode,
bool IsActive, bool IsArchived, DateTime? ArchivedAt,
Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail,
DateTime CreatedAt, DateTime? UpdatedAt,
int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth);
public record OrgInput(
string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
Guid? DefaultCurrencyId, Guid? AccountOwnerUserId);
public record CreateOrgRequest(OrgInput Org, string AdminLastName, string AdminFirstName,
string AdminEmail, string? AdminPosition);
public record CreateOrgResult(OrgDetail Organization, string AdminEmail, string AdminTempPassword);
public record ArchiveRequest(string ConfirmationName);
public record DeleteRequest(string ConfirmationName);
public record ChangeOwnerRequest(Guid NewOwnerUserId, string Reason);
[HttpGet]
public async Task<ActionResult<PagedResult<OrgRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] bool? archived,
CancellationToken ct)
{
var q = _db.Organizations.IgnoreQueryFilters().AsNoTracking().AsQueryable();
if (archived is not null) q = q.Where(o => o.IsArchived == archived);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(o => o.Name.ToLower().Contains(s) || (o.Bin != null && o.Bin.Contains(s)));
}
var total = await q.CountAsync(ct);
var items = await q
.OrderBy(o => o.IsArchived).ThenBy(o => o.Name)
.Skip(req.Skip).Take(req.Take)
.Select(o => new OrgRow(
o.Id, o.Name, o.CountryCode,
o.IsActive, o.IsArchived, o.ArchivedAt, o.CreatedAt,
_db.Employees.IgnoreQueryFilters().Count(e => e.OrganizationId == o.Id),
_db.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id),
_db.Users.Where(u => u.OrganizationId == o.Id && u.LastLoginAt != null)
.Max(u => (DateTime?)u.LastLoginAt)))
.ToListAsync(ct);
return new PagedResult<OrgRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<OrgDetail>> Get(Guid id, CancellationToken ct)
{
var dto = await ProjectAsync(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
public async Task<ActionResult<CreateOrgResult>> Create([FromBody] CreateOrgRequest input, CancellationToken ct)
{
var org = new Organization
{
Name = input.Org.Name, CountryCode = input.Org.CountryCode,
Bin = input.Org.Bin, Address = input.Org.Address,
Phone = input.Org.Phone, Email = input.Org.Email,
DefaultCurrencyId = input.Org.DefaultCurrencyId,
};
_db.Organizations.Add(org);
await _db.SaveChangesAsync(ct);
// Полный bootstrap tenant-сущностей: единицы измерения, типы цен,
// «Основной склад», «Касса 1», 6 ролей (2 системные + 4 кастомные шаблона).
// Один helper и в DevDataSeeder, и здесь — гарантирует одинаковое
// состояние новой орги независимо от пути создания.
await DevDataSeeder.SeedTenantReferencesAsync(_db, org.Id, ct);
var adminRole = await _db.EmployeeRoles.IgnoreQueryFilters()
.FirstAsync(r => r.OrganizationId == org.Id && r.IsSystem && r.Name == "Администратор", ct);
// AppUser админа
var existing = await _userMgr.FindByEmailAsync(input.AdminEmail);
if (existing is not null)
return BadRequest(new { error = $"Пользователь {input.AdminEmail} уже существует." });
var tempPwd = GenerateTempPassword();
var user = new User
{
UserName = input.AdminEmail, Email = input.AdminEmail, EmailConfirmed = true,
FullName = $"{input.AdminLastName} {input.AdminFirstName}".Trim(),
OrganizationId = org.Id, IsActive = true,
};
var ur = await _userMgr.CreateAsync(user, tempPwd);
if (!ur.Succeeded) return BadRequest(new { error = string.Join("; ", ur.Errors.Select(e => e.Description)) });
await _userMgr.AddToRoleAsync(user, "Admin");
org.AccountOwnerUserId = user.Id;
_db.Employees.Add(new Employee
{
OrganizationId = org.Id, UserId = user.Id,
LastName = input.AdminLastName, FirstName = input.AdminFirstName,
Position = input.AdminPosition ?? "Директор",
Email = input.AdminEmail, Role = adminRole, IsActive = true,
});
await _db.SaveChangesAsync(ct);
await LogAsync("CreateOrg", org.Id, $"Создана организация «{org.Name}»", null, $"{{\"adminEmail\":\"{input.AdminEmail}\"}}", ct);
var detail = await ProjectAsync(org.Id, ct);
return new CreateOrgResult(detail!, input.AdminEmail, tempPwd);
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] OrgInput input, CancellationToken ct)
{
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
if (o is null) return NotFound();
var before = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
o.Name = input.Name; o.CountryCode = input.CountryCode;
o.Bin = input.Bin; o.Address = input.Address;
o.Phone = input.Phone; o.Email = input.Email;
o.DefaultCurrencyId = input.DefaultCurrencyId;
await _db.SaveChangesAsync(ct);
var after = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
await LogAsync("EditOrg", o.Id, $"Изменены данные «{o.Name}»", null,
$"{{\"before\":{before},\"after\":{after}}}", ct);
return NoContent();
}
[HttpPost("{id:guid}/archive")]
public async Task<IActionResult> Archive(Guid id, [FromBody] ArchiveRequest req, CancellationToken ct)
{
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
if (o is null) return NotFound();
if (o.IsArchived) return Conflict(new { error = "Уже в архиве." });
if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно для подтверждения." });
o.IsArchived = true; o.ArchivedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
await LogAsync("ArchiveOrg", o.Id, $"Архивирована «{o.Name}»", null, "{}", ct);
return NoContent();
}
[HttpPost("{id:guid}/restore")]
public async Task<IActionResult> Restore(Guid id, CancellationToken ct)
{
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
if (o is null) return NotFound();
if (!o.IsArchived) return Conflict(new { error = "Не в архиве." });
o.IsArchived = false; o.ArchivedAt = null;
await _db.SaveChangesAsync(ct);
await LogAsync("RestoreOrg", o.Id, $"Восстановлена из архива «{o.Name}»", null, "{}", ct);
return NoContent();
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, [FromBody] DeleteRequest req, CancellationToken ct)
{
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
if (o is null) return NotFound();
if (!o.IsArchived || o.ArchivedAt is null)
return Conflict(new { error = "Удалить можно только архивированную организацию." });
var retentionDays = await _db.SystemSettings.Select(s => (int?)s.ArchiveRetentionDays).FirstOrDefaultAsync(ct) ?? 30;
if (o.ArchivedAt > DateTime.UtcNow.AddDays(-retentionDays))
return Conflict(new { error = $"Доступно через {retentionDays} дней архива." });
if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно." });
await LogAsync("DeleteOrg", o.Id, $"Удалена навсегда «{o.Name}»", null,
$"{{\"name\":\"{o.Name}\"}}", ct);
// Cascade delete domain entities is up to FK config; здесь просто Remove,
// EF выкинет ошибку если есть restrict-связи — оператор увидит и решит.
_db.Organizations.Remove(o);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/change-owner")]
public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequest req, CancellationToken ct)
{
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
if (o is null) return NotFound();
if (string.IsNullOrWhiteSpace(req.Reason)) return BadRequest(new { error = "Reason required." });
var user = await _userMgr.FindByIdAsync(req.NewOwnerUserId.ToString());
if (user is null || user.OrganizationId != o.Id)
return BadRequest(new { error = "Пользователь не найден или не принадлежит этой организации." });
var prev = o.AccountOwnerUserId;
o.AccountOwnerUserId = req.NewOwnerUserId;
await _db.SaveChangesAsync(ct);
await LogAsync("ChangeOwner", o.Id, $"Сменён владелец «{o.Name}»", req.Reason,
$"{{\"from\":\"{prev}\",\"to\":\"{req.NewOwnerUserId}\"}}", ct);
return NoContent();
}
private async Task<OrgDetail?> ProjectAsync(Guid id, CancellationToken ct)
{
var o = await _db.Organizations.IgnoreQueryFilters().AsNoTracking()
.Include(x => x.DefaultCurrency)
.FirstOrDefaultAsync(x => x.Id == id, ct);
if (o is null) return null;
var emp = await _db.Employees.IgnoreQueryFilters().CountAsync(e => e.OrganizationId == id, ct);
var prod = await _db.Products.IgnoreQueryFilters().CountAsync(p => p.OrganizationId == id, ct);
var cp = await _db.Counterparties.IgnoreQueryFilters().CountAsync(c => c.OrganizationId == id, ct);
var monthAgo = DateTime.UtcNow.AddDays(-30);
var supplies = await _db.Supplies.IgnoreQueryFilters()
.CountAsync(s => s.OrganizationId == id && s.Date >= monthAgo, ct);
User? owner = null;
if (o.AccountOwnerUserId is not null)
owner = await _userMgr.FindByIdAsync(o.AccountOwnerUserId.ToString()!);
return new OrgDetail(
o.Id, o.Name, o.CountryCode, o.Bin, o.Address, o.Phone, o.Email,
o.DefaultCurrencyId, o.DefaultCurrency?.Code,
o.IsActive, o.IsArchived, o.ArchivedAt,
o.AccountOwnerUserId, owner?.FullName, owner?.Email,
o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies);
}
private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct)
{
var userIdRaw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
Guid.TryParse(userIdRaw, out var uid);
_db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog
{
SuperAdminUserId = uid,
ActionType = actionType, OrganizationId = orgId,
Description = description, Reason = reason,
ChangesJson = changesJson,
IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "",
});
await _db.SaveChangesAsync(ct);
}
private static string GenerateTempPassword()
{
const string lower = "abcdefghkmnpqrstuvwxyz";
const string upper = "ABCDEFGHKMNPQRSTUVWXYZ";
const string digits = "23456789";
const string special = "!@#$%&*";
var rnd = new Random();
var chars = new List<char>
{
upper[rnd.Next(upper.Length)],
lower[rnd.Next(lower.Length)],
digits[rnd.Next(digits.Length)],
special[rnd.Next(special.Length)],
};
var pool = lower + upper + digits;
for (var i = 0; i < 8; i++) chars.Add(pool[rnd.Next(pool.Length)]);
return new string(chars.OrderBy(_ => rnd.Next()).ToArray());
}
}

View file

@ -7,6 +7,31 @@ public class HttpContextTenantContext : ITenantContext
{ {
public const string OrganizationClaim = "org_id"; public const string OrganizationClaim = "org_id";
public const string SuperAdminRole = "SuperAdmin"; public const string SuperAdminRole = "SuperAdmin";
/// <summary>HTTP-заголовок переключения tenant'а для SuperAdmin'а: «открыть как…»
/// конкретную организацию. Без этого header'а супер-админ не привязан к
/// конкретной орге (видит всё через query-filter bypass).</summary>
public const string OrgOverrideHeader = "X-Org-Override";
/// <summary>HTTP-заголовок включения edit-mode в режиме «открыть как…»:
/// клиент шлёт reason (≥10 символов), сервер разрешает мутации и пишет
/// каждую запись в SuperAdminAuditLog с этой причиной. Срок действия
/// токена ограничен 30 минутами на стороне фронта (после — UI отключает).</summary>
public const string EditReasonHeader = "X-Org-Override-Reason";
// Override для background задач (например, импорт из MoySklad): сохраняем tenant
// в AsyncLocal на время выполнения фонового Task. HttpContext там отсутствует,
// но query-filter'у по-прежнему нужен orgId — вот его и берём из override.
private static readonly AsyncLocal<(Guid? OrgId, bool IsSuper)?> _override = new();
public static IDisposable UseOverride(Guid orgId, bool isSuperAdmin = false)
{
_override.Value = (orgId, isSuperAdmin);
return new OverrideScope();
}
private sealed class OverrideScope : IDisposable
{
public void Dispose() => _override.Value = null;
}
private readonly IHttpContextAccessor _accessor; private readonly IHttpContextAccessor _accessor;
@ -15,16 +40,53 @@ public HttpContextTenantContext(IHttpContextAccessor accessor)
_accessor = accessor; _accessor = accessor;
} }
public bool IsAuthenticated => _accessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false; public bool IsAuthenticated
{
get
{
if (_override.Value is not null) return true;
return _accessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false;
}
}
public bool IsSuperAdmin => _accessor.HttpContext?.User?.IsInRole(SuperAdminRole) ?? false; public bool IsSuperAdmin
{
get
{
if (_override.Value is { IsSuper: var s }) return s;
return _accessor.HttpContext?.User?.IsInRole(SuperAdminRole) ?? false;
}
}
public Guid? OrganizationId public Guid? OrganizationId
{ {
get get
{ {
if (_override.Value is { OrgId: var o }) return o;
if (TryGetHttpOverrideOrg(out var http)) return http;
var claim = _accessor.HttpContext?.User?.FindFirst(OrganizationClaim)?.Value; var claim = _accessor.HttpContext?.User?.FindFirst(OrganizationClaim)?.Value;
return Guid.TryParse(claim, out var id) ? id : null; return Guid.TryParse(claim, out var id) ? id : null;
} }
} }
public bool IsTenantOverride
{
get
{
// AsyncLocal-override (background tasks) не считаем «открыть как…» —
// он используется в импорте/Hangfire, где и так применяется фильтр
// (IsSuper=false по умолчанию). Override-режим — только HTTP-header.
return TryGetHttpOverrideOrg(out _);
}
}
private bool TryGetHttpOverrideOrg(out Guid orgId)
{
orgId = Guid.Empty;
var ctx = _accessor.HttpContext;
if (ctx is null) return false;
if (ctx.User?.IsInRole(SuperAdminRole) != true) return false;
if (!ctx.Request.Headers.TryGetValue(OrgOverrideHeader, out var headerVal)) return false;
return Guid.TryParse(headerVal.ToString(), out orgId);
}
} }

View file

@ -0,0 +1,46 @@
using foodmarket.Application.Common.Tenancy;
namespace foodmarket.Api.Infrastructure.Tenancy;
/// <summary>Когда SuperAdmin прислал X-Org-Override — режим «открыть как…»
/// должен быть строго read-only (Phase 2). Любая мутация (PUT/POST/DELETE/PATCH)
/// на любой endpoint, кроме самих /api/super-admin/* (управление орг)
/// и /api/auth/* (refresh tokens) — отбивается 403 с понятным сообщением.
/// Phase 3 (edit-mode с reason + audit-trail) будет ослаблять ограничение.</summary>
public class ReadonlyOverrideMiddleware
{
private readonly RequestDelegate _next;
public ReadonlyOverrideMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext ctx)
{
if (!ctx.Request.Headers.ContainsKey(HttpContextTenantContext.OrgOverrideHeader))
{
await _next(ctx); return;
}
var method = ctx.Request.Method.ToUpperInvariant();
if (method == "GET" || method == "HEAD" || method == "OPTIONS")
{
await _next(ctx); return;
}
var path = ctx.Request.Path.Value ?? "";
if (path.StartsWith("/api/super-admin/", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/connect/", StringComparison.OrdinalIgnoreCase))
{
await _next(ctx); return;
}
// Phase 3: edit-mode — SuperAdmin прислал X-Org-Override-Reason ≥ 10
// символов, мутации пропускаем. Запись в audit-log делает Pipeline-
// фильтр (см. SuperAdminEditAuditFilter) после успешного ответа.
if (ctx.Request.Headers.TryGetValue(HttpContextTenantContext.EditReasonHeader, out var reason)
&& (reason.ToString() ?? "").Trim().Length >= 10)
{
await _next(ctx); return;
}
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
await ctx.Response.WriteAsJsonAsync(new
{
error = "Read-only mode: вы в режиме «Супер-админ → открыть как…», мутации запрещены. Включите edit-mode с указанием причины или выйдите из режима.",
});
}
}

View file

@ -0,0 +1,50 @@
using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc.Filters;
using System.Security.Claims;
namespace foodmarket.Api.Infrastructure.Tenancy;
/// <summary>Phase 3 audit-trail: когда SuperAdmin в режиме «открыть как…» с
/// edit-mode (X-Org-Override + X-Org-Override-Reason ≥ 10 символов) делает
/// успешную мутацию — пишем запись в SuperAdminAuditLog с reason, путём,
/// methodом, status code'ом. Запускается ПОСЛЕ controller'а, только если
/// ответ 2xx (успех) — неудачные запросы не засоряют журнал.</summary>
public class SuperAdminEditAuditFilter : IAsyncActionFilter
{
private readonly AppDbContext _db;
public SuperAdminEditAuditFilter(AppDbContext db) => _db = db;
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var ctx = context.HttpContext;
var method = ctx.Request.Method.ToUpperInvariant();
var hasOverride = ctx.Request.Headers.ContainsKey(HttpContextTenantContext.OrgOverrideHeader);
var hasReason = ctx.Request.Headers.TryGetValue(HttpContextTenantContext.EditReasonHeader, out var reasonHv)
&& (reasonHv.ToString() ?? "").Trim().Length >= 10;
var isMutation = method is "POST" or "PUT" or "PATCH" or "DELETE";
var isSuper = ctx.User?.IsInRole(HttpContextTenantContext.SuperAdminRole) == true;
var executed = await next();
if (!hasOverride || !hasReason || !isMutation || !isSuper) return;
if (executed.Exception is not null) return;
var status = ctx.Response.StatusCode;
if (status < 200 || status >= 300) return;
var orgGuid = Guid.TryParse(ctx.Request.Headers[HttpContextTenantContext.OrgOverrideHeader].ToString(), out var g) ? g : (Guid?)null;
var userIdRaw = ctx.User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? ctx.User?.FindFirstValue("sub");
Guid.TryParse(userIdRaw, out var uid);
_db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog
{
SuperAdminUserId = uid,
ActionType = "EditEntity",
OrganizationId = orgGuid,
Description = $"{method} {ctx.Request.Path.Value} → {status}",
Reason = reasonHv.ToString().Trim(),
ChangesJson = "{}",
IpAddress = ctx.Connection?.RemoteIpAddress?.ToString() ?? "",
});
await _db.SaveChangesAsync();
}
}

View file

@ -66,9 +66,25 @@
opts.AcceptAnonymousClients(); opts.AcceptAnonymousClients();
opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api"); opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api");
// Persistent dev keys: RSA key stored in src/food-market.api/App_Data/openiddict-dev-key.xml.
// Survives API restarts so issued tokens remain valid across rebuilds.
// Never commit this file (it's in .gitignore via App_Data/). Production must use real certificates.
var keyPath = Path.Combine(builder.Environment.ContentRootPath, "App_Data", "openiddict-dev-key.xml");
var rsa = System.Security.Cryptography.RSA.Create(2048);
if (File.Exists(keyPath))
{
rsa.FromXmlString(File.ReadAllText(keyPath));
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(keyPath)!);
File.WriteAllText(keyPath, rsa.ToXmlString(includePrivateParameters: true));
}
var devKey = new Microsoft.IdentityModel.Tokens.RsaSecurityKey(rsa) { KeyId = "food-market-dev" };
opts.AddEncryptionKey(devKey);
opts.AddSigningKey(devKey);
if (builder.Environment.IsDevelopment()) if (builder.Environment.IsDevelopment())
{ {
opts.AddEphemeralEncryptionKey().AddEphemeralSigningKey();
opts.DisableAccessTokenEncryption(); opts.DisableAccessTokenEncryption();
} }
@ -87,17 +103,52 @@
opts.UseAspNetCore(); opts.UseAspNetCore();
}); });
builder.Services.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); // Force OpenIddict validation to handle ALL [Authorize] challenges — otherwise AddIdentity's
builder.Services.AddAuthorization(); // cookie scheme takes over and 401 becomes a 302 redirect to /Account/Login for API calls.
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});
builder.Services.AddAuthorization(opts =>
{
// Check the "role" claim explicitly — robust against role-claim-type mismatches between
// OpenIddict validation identity and the default ClaimTypes.Role uri.
opts.AddPolicy("AdminAccess", p => p.RequireAssertion(ctx =>
ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin"))));
});
builder.Services.AddControllers(); builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
builder.Services.AddControllers(o =>
{
// Глобальный action filter — пишет audit-log при успешных мутациях
// в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3).
o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
});
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
// MoySklad import integration. Auto-decompress gzip responses from MoySklad's edge.
builder.Services.AddHttpClient<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladClient>()
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
});
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
// Inventory
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
builder.Services.AddHostedService<OpenIddictClientSeeder>(); builder.Services.AddHostedService<OpenIddictClientSeeder>();
builder.Services.AddHostedService<SystemReferenceSeeder>(); builder.Services.AddHostedService<SystemReferenceSeeder>();
builder.Services.AddHostedService<DevDataSeeder>(); builder.Services.AddHostedService<DevDataSeeder>();
builder.Services.AddHostedService<DemoCatalogSeeder>(); builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
// DemoCatalogSeeder disabled: real catalog is imported from MoySklad.
// Keep the file as reference for anyone starting without MoySklad access —
// just re-register here to turn demo data back on.
// builder.Services.AddHostedService<DemoCatalogSeeder>();
var app = builder.Build(); var app = builder.Build();
@ -105,6 +156,19 @@
app.UseCors(CorsPolicy); app.UseCors(CorsPolicy);
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но
// только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*.
app.UseMiddleware<foodmarket.Api.Infrastructure.Tenancy.ReadonlyOverrideMiddleware>();
// Статика товарных изображений: физически /app/uploads (volume в compose),
// публичный URL /uploads/... — раздаются public, без auth.
var uploadsDir = System.IO.Path.Combine(app.Environment.ContentRootPath, "uploads");
System.IO.Directory.CreateDirectory(uploadsDir);
app.UseStaticFiles(new Microsoft.AspNetCore.Builder.StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsDir),
RequestPath = "/uploads",
});
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
@ -116,6 +180,21 @@
app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow })); app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow }));
app.MapGet("/api/_debug/whoami", (HttpContext ctx) =>
{
var identity = ctx.User.Identity as System.Security.Claims.ClaimsIdentity;
return Results.Ok(new
{
isAuthenticated = ctx.User.Identity?.IsAuthenticated,
authType = ctx.User.Identity?.AuthenticationType,
nameClaimType = identity?.NameClaimType,
roleClaimType = identity?.RoleClaimType,
isInRoleAdmin = ctx.User.IsInRole("Admin"),
hasAdminRoleClaim = ctx.User.HasClaim(c => c.Type == Claims.Role && c.Value == "Admin"),
claims = ctx.User.Claims.Select(c => new { c.Type, c.Value }),
});
}).RequireAuthorization();
app.MapGet("/api/me", (HttpContext ctx) => app.MapGet("/api/me", (HttpContext ctx) =>
{ {
var user = ctx.User; var user = ctx.User;
@ -129,9 +208,10 @@
}); });
}).RequireAuthorization(); }).RequireAuthorization();
if (app.Environment.IsDevelopment()) // Apply migrations on every startup (idempotent). Without this, fresh
// stage/prod deploys land on an empty DB and OpenIddict seeders fail.
using (var scope = app.Services.CreateScope())
{ {
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.Migrate(); db.Database.Migrate();
} }

View file

@ -32,21 +32,18 @@ public async Task StartAsync(CancellationToken ct)
var hasProducts = await db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct); var hasProducts = await db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
if (hasProducts) return; if (hasProducts) return;
var defaultVat = await db.VatRates.IgnoreQueryFilters() // KZ дефолт — 16% (из Country.VatRate), льготные категории (хлеб) — 0%.
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.IsDefault, ct); const decimal vatDefault = 16m;
var noVat = await db.VatRates.IgnoreQueryFilters() const decimal vat0 = 0m;
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.Percent == 0m, ct);
var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters() var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "шт", ct); .FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "796", ct);
var unitKg = await db.UnitsOfMeasure.IgnoreQueryFilters() var unitKg = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "кг", ct); .FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "166", ct);
var unitL = await db.UnitsOfMeasure.IgnoreQueryFilters() var unitL = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == ", ct); .FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "112", ct);
if (defaultVat is null || unitSht is null) return; if (unitSht is null) return;
var vat = defaultVat.Id;
var vat0 = noVat?.Id ?? vat;
var retailPriceType = await db.PriceTypes.IgnoreQueryFilters() var retailPriceType = await db.PriceTypes.IgnoreQueryFilters()
.FirstOrDefaultAsync(p => p.OrganizationId == orgId && p.IsRetail, ct); .FirstOrDefaultAsync(p => p.OrganizationId == orgId && p.IsRetail, ct);
@ -69,12 +66,14 @@ Guid AddGroup(string name, Guid? parentId)
db.ProductGroups.Add(new ProductGroup db.ProductGroups.Add(new ProductGroup
{ {
Id = id, OrganizationId = orgId, Name = name, ParentId = parentId, Id = id, OrganizationId = orgId, Name = name, ParentId = parentId,
Path = path, SortOrder = groups.Count, IsActive = true,
}); });
groups[path] = id; groups[path] = id;
return id; return id;
} }
// «Продукты питания» — дефолтная группа, в которую попадают новые товары,
// если пользователь не указал группу явно. Не должна удаляться.
AddGroup("Продукты питания", null);
var gDrinks = AddGroup("Напитки", null); var gDrinks = AddGroup("Напитки", null);
var gDrinksNon = AddGroup("Безалкогольные", gDrinks); var gDrinksNon = AddGroup("Безалкогольные", gDrinks);
AddGroup("Алкогольные", gDrinks); AddGroup("Алкогольные", gDrinks);
@ -88,67 +87,65 @@ Guid AddGroup(string name, Guid? parentId)
var supplier1 = new Counterparty var supplier1 = new Counterparty
{ {
OrganizationId = orgId, Name = "ТОО «Продтрейд»", LegalName = "Товарищество с ограниченной ответственностью «Продтрейд»", OrganizationId = orgId, Name = "ТОО «Продтрейд»", LegalName = "Товарищество с ограниченной ответственностью «Продтрейд»",
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.LegalEntity, Type = CounterpartyType.LegalEntity,
Bin = "100140005678", CountryId = kz?.Id, Bin = "100140005678", CountryId = kz?.Id,
Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01", Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01",
Email = "order@prodtrade.kz", BankName = "Kaspi Bank", BankAccount = "KZ000000000000000001", Bik = "CASPKZKA", Email = "order@prodtrade.kz", BankName = "Kaspi Bank", BankAccount = "KZ000000000000000001", Bik = "CASPKZKA",
IsActive = true,
}; };
var supplier2 = new Counterparty var supplier2 = new Counterparty
{ {
OrganizationId = orgId, Name = "ИП Иванов А.С.", OrganizationId = orgId, Name = "ИП Иванов А.С.",
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.Individual, Type = CounterpartyType.Individual,
Iin = "850101300000", CountryId = kz?.Id, Iin = "850101300000", CountryId = kz?.Id,
Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей", Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей",
IsActive = true,
}; };
db.Counterparties.AddRange(supplier1, supplier2); db.Counterparties.AddRange(supplier1, supplier2);
// Barcodes use generic internal range (2xxxxxxxxxxxx) to avoid colliding with real products. // Barcodes use generic internal range (2xxxxxxxxxxxx) to avoid colliding with real products.
// When user does real приёмка, real barcodes will overwrite. // When user does real приёмка, real barcodes will overwrite.
var demo = new (string Name, Guid Group, decimal RetailPrice, string Article, string Barcode, Guid? Country, bool IsWeighed, Guid Unit, bool IsAlcohol)[] var demo = new (string Name, Guid Group, decimal RetailPrice, string Article, string Barcode, Guid? Country, bool IsWeighed, Guid Unit)[]
{ {
// Напитки — безалкогольные // Напитки — безалкогольные
("Вода питьевая «Тассай» 0.5л", gDrinksNon, 220, "DR-WAT-001", "2000000000018", kz?.Id, false, unitSht.Id, false), ("Вода питьевая «Тассай» 0.5л", gDrinksNon, 220, "DR-WAT-001", "2000000000018", kz?.Id, false, unitSht.Id),
("Вода питьевая «Тассай» 1.5л", gDrinksNon, 380, "DR-WAT-002", "2000000000025", kz?.Id, false, unitSht.Id, false), ("Вода питьевая «Тассай» 1.5л", gDrinksNon, 380, "DR-WAT-002", "2000000000025", kz?.Id, false, unitSht.Id),
("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id, false), ("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id),
("Сок «Да-Да» апельсин 1л", gDrinksNon, 650, "DR-JUC-002", "2000000000049", kz?.Id, false, unitSht.Id, false), ("Сок «Да-Да» апельсин 1л", gDrinksNon, 650, "DR-JUC-002", "2000000000049", kz?.Id, false, unitSht.Id),
("Кока-кола 0.5л", gDrinksNon, 420, "DR-SOD-001", "2000000000056", ru?.Id, false, unitSht.Id, false), ("Кока-кола 0.5л", gDrinksNon, 420, "DR-SOD-001", "2000000000056", ru?.Id, false, unitSht.Id),
("Пепси 0.5л", gDrinksNon, 420, "DR-SOD-002", "2000000000063", ru?.Id, false, unitSht.Id, false), ("Пепси 0.5л", gDrinksNon, 420, "DR-SOD-002", "2000000000063", ru?.Id, false, unitSht.Id),
("Спрайт 0.5л", gDrinksNon, 420, "DR-SOD-003", "2000000000070", ru?.Id, false, unitSht.Id, false), ("Спрайт 0.5л", gDrinksNon, 420, "DR-SOD-003", "2000000000070", ru?.Id, false, unitSht.Id),
("Чай чёрный «Пиала» пакетированный, 100шт", gDrinksNon, 1250, "DR-TEA-001", "2000000000087", kz?.Id, false, unitSht.Id, false), ("Чай чёрный «Пиала» пакетированный, 100шт", gDrinksNon, 1250, "DR-TEA-001", "2000000000087", kz?.Id, false, unitSht.Id),
// Молочные // Молочные
("Молоко «Простоквашино» 2.5% 930мл", gDairy, 720, "DAI-MLK-001", "2000000000094", ru?.Id, false, unitSht.Id, false), ("Молоко «Простоквашино» 2.5% 930мл", gDairy, 720, "DAI-MLK-001", "2000000000094", ru?.Id, false, unitSht.Id),
("Молоко «Food Master» 3.2% 1л", gDairy, 620, "DAI-MLK-002", "2000000000100", kz?.Id, false, unitSht.Id, false), ("Молоко «Food Master» 3.2% 1л", gDairy, 620, "DAI-MLK-002", "2000000000100", kz?.Id, false, unitSht.Id),
("Кефир «Food Master» 2.5% 1л", gDairy, 590, "DAI-KEF-001", "2000000000117", kz?.Id, false, unitSht.Id, false), ("Кефир «Food Master» 2.5% 1л", gDairy, 590, "DAI-KEF-001", "2000000000117", kz?.Id, false, unitSht.Id),
("Йогурт «Актимель» клубника 100мл", gDairy, 180, "DAI-YOG-001", "2000000000124", null, false, unitSht.Id, false), ("Йогурт «Актимель» клубника 100мл", gDairy, 180, "DAI-YOG-001", "2000000000124", null, false, unitSht.Id),
("Творог «Простоквашино» 5% 200г", gDairy, 590, "DAI-CRD-001", "2000000000131", ru?.Id, false, unitSht.Id, false), ("Творог «Простоквашино» 5% 200г", gDairy, 590, "DAI-CRD-001", "2000000000131", ru?.Id, false, unitSht.Id),
("Сметана «Food Master» 20% 400г", gDairy, 850, "DAI-SCR-001", "2000000000148", kz?.Id, false, unitSht.Id, false), ("Сметана «Food Master» 20% 400г", gDairy, 850, "DAI-SCR-001", "2000000000148", kz?.Id, false, unitSht.Id),
("Сыр «Российский» 45%", gDairy, 3200, "DAI-CHE-001", "2000000000155", kz?.Id, true, unitKg?.Id ?? unitSht.Id, false), ("Сыр «Российский» 45%", gDairy, 3200, "DAI-CHE-001", "2000000000155", kz?.Id, true, unitKg?.Id ?? unitSht.Id),
// Хлеб и выпечка // Хлеб и выпечка
("Хлеб «Семейный» нарезной 500г", gBakery, 180, "BAK-BRD-001", "2000000000162", kz?.Id, false, unitSht.Id, false), ("Хлеб «Семейный» нарезной 500г", gBakery, 180, "BAK-BRD-001", "2000000000162", kz?.Id, false, unitSht.Id),
("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id, false), ("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id),
("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id, false), ("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id),
("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", kz?.Id, false, unitSht.Id, false), ("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", kz?.Id, false, unitSht.Id),
// Кондитерские // Кондитерские
("Шоколад «Рахат» молочный 100г", gSweets, 650, "SW-CHO-001", "2000000000209", kz?.Id, false, unitSht.Id, false), ("Шоколад «Рахат» молочный 100г", gSweets, 650, "SW-CHO-001", "2000000000209", kz?.Id, false, unitSht.Id),
("Шоколад «Алёнка» 100г", gSweets, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id, false), ("Шоколад «Алёнка» 100г", gSweets, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id),
("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id, false), ("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id),
("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id, false), ("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id),
("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", kz?.Id, false, unitSht.Id, false), ("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", kz?.Id, false, unitSht.Id),
// Бакалея // Бакалея
("Сахар-песок 1кг", gGrocery, 480, "GRO-SUG-001", "2000000000254", kz?.Id, false, unitSht.Id, false), ("Сахар-песок 1кг", gGrocery, 480, "GRO-SUG-001", "2000000000254", kz?.Id, false, unitSht.Id),
("Соль поваренная 1кг", gGrocery, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id, false), ("Соль поваренная 1кг", gGrocery, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id),
("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id, false), ("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id),
("Гречка «Мистраль» 900г", gGrocery, 990, "GRO-GRE-001", "2000000000285", ru?.Id, false, unitSht.Id, false), ("Гречка «Мистраль» 900г", gGrocery, 990, "GRO-GRE-001", "2000000000285", ru?.Id, false, unitSht.Id),
("Макароны «Corona» 500г", gGrocery, 420, "GRO-PAS-001", "2000000000292", kz?.Id, false, unitSht.Id, false), ("Макароны «Corona» 500г", gGrocery, 420, "GRO-PAS-001", "2000000000292", kz?.Id, false, unitSht.Id),
("Масло подсолнечное «Шедевр» 1л", gGrocery, 920, "GRO-OIL-001", "2000000000308", kz?.Id, false, unitL?.Id ?? unitSht.Id, false), ("Масло подсолнечное «Шедевр» 1л", gGrocery, 920, "GRO-OIL-001", "2000000000308", kz?.Id, false, unitL?.Id ?? unitSht.Id),
("Кофе «Якобс Монарх» 95г растворимый", gGrocery, 2890, "GRO-COF-001", "2000000000315", null, false, unitSht.Id, false), ("Кофе «Якобс Монарх» 95г растворимый", gGrocery, 2890, "GRO-COF-001", "2000000000315", null, false, unitSht.Id),
// Снеки // Снеки
("Чипсы «Lays» сметана-лук 80г", gSnacks, 380, "SN-CHP-001", "2000000000322", kz?.Id, false, unitSht.Id, false), ("Чипсы «Lays» сметана-лук 80г", gSnacks, 380, "SN-CHP-001", "2000000000322", kz?.Id, false, unitSht.Id),
("Сухарики «3 корочки» ржаные 40г", gSnacks, 190, "SN-SBR-001", "2000000000339", ru?.Id, false, unitSht.Id, false), ("Сухарики «3 корочки» ржаные 40г", gSnacks, 190, "SN-SBR-001", "2000000000339", ru?.Id, false, unitSht.Id),
("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id, false), ("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id),
("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id, false), ("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id),
}; };
var products = demo.Select(d => var products = demo.Select(d =>
@ -159,14 +156,14 @@ Guid AddGroup(string name, Guid? parentId)
Name = d.Name, Name = d.Name,
Article = d.Article, Article = d.Article,
UnitOfMeasureId = d.Unit, UnitOfMeasureId = d.Unit,
VatRateId = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка") // Хлеб/батон/лепёшка — 0% (льготная категория в KZ).
? vat0 : vat, Vat = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
? vat0 : vatDefault,
VatEnabled = !(d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")),
ProductGroupId = d.Group, ProductGroupId = d.Group,
CountryOfOriginId = d.Country, CountryOfOriginId = d.Country,
IsWeighed = d.IsWeighed, Packaging = d.IsWeighed ? Packaging.Weight : Packaging.Piece,
IsAlcohol = d.IsAlcohol, ReferencePrice = Math.Round(d.RetailPrice * 0.72m, 2),
IsActive = true,
PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2),
PurchaseCurrencyId = kzt.Id, PurchaseCurrencyId = kzt.Id,
Prices = Prices =
[ [

View file

@ -20,10 +20,10 @@ public DevDataSeeder(IServiceProvider services, IHostEnvironment env)
public async Task StartAsync(CancellationToken ct) public async Task StartAsync(CancellationToken ct)
{ {
if (!_env.IsDevelopment()) // Idempotent — runs in all envs to bootstrap a usable admin + demo org.
{ // Once first real user/org is set up via UI, rename/disable demo.
return; // (Wired regardless of env so stage/prod first-deploy lands a working
} // admin, otherwise nobody can log in.)
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@ -38,6 +38,7 @@ public async Task StartAsync(CancellationToken ct)
} }
} }
var kzt = await db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct);
var demoOrg = await db.Organizations.FirstOrDefaultAsync(o => o.Name == "Demo Market", ct); var demoOrg = await db.Organizations.FirstOrDefaultAsync(o => o.Name == "Demo Market", ct);
if (demoOrg is null) if (demoOrg is null)
{ {
@ -48,11 +49,17 @@ public async Task StartAsync(CancellationToken ct)
Bin = "000000000000", Bin = "000000000000",
Address = "Алматы, ул. Пример 1", Address = "Алматы, ул. Пример 1",
Phone = "+7 (777) 000-00-00", Phone = "+7 (777) 000-00-00",
Email = "demo@food-market.local" Email = "demo@food-market.local",
DefaultCurrencyId = kzt?.Id,
}; };
db.Organizations.Add(demoOrg); db.Organizations.Add(demoOrg);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
else if (demoOrg.DefaultCurrencyId is null && kzt is not null)
{
demoOrg.DefaultCurrencyId = kzt.Id;
await db.SaveChangesAsync(ct);
}
await SeedTenantReferencesAsync(db, demoOrg.Id, ct); await SeedTenantReferencesAsync(db, demoOrg.Id, ct);
@ -71,40 +78,88 @@ public async Task StartAsync(CancellationToken ct)
var result = await userMgr.CreateAsync(admin, "Admin12345!"); var result = await userMgr.CreateAsync(admin, "Admin12345!");
if (result.Succeeded) if (result.Succeeded)
{ {
await userMgr.AddToRoleAsync(admin, SystemRoles.Admin); // Только SuperAdmin как Identity-роль. «Администратор» —
// организационная роль внутри Employee, не Identity.
await userMgr.AddToRoleAsync(admin, SystemRoles.SuperAdmin);
} }
} }
else
{
if (!await userMgr.IsInRoleAsync(admin, SystemRoles.SuperAdmin))
await userMgr.AddToRoleAsync(admin, SystemRoles.SuperAdmin);
// Чистим дублирующую Identity-роль Admin (если оставалась с прошлых сидов).
if (await userMgr.IsInRoleAsync(admin, SystemRoles.Admin))
await userMgr.RemoveFromRoleAsync(admin, SystemRoles.Admin);
}
await SeedAdminEmployeeAsync(db, demoOrg.Id, admin?.Id, ct);
// Глобальные SystemSettings — single-row. Сидируем дефолт 30 дней
// retention если ещё нет записи.
var anySettings = await db.SystemSettings.AnyAsync(ct);
if (!anySettings)
{
db.SystemSettings.Add(new SystemSettings { ArchiveRetentionDays = 30 });
await db.SaveChangesAsync(ct);
}
} }
private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct) /// <summary>Привязывает существующего admin@food-market.local к
/// Employee-записи с системной ролью «Администратор» — чтобы UI «Сотрудники»
/// сразу показывал учётку с правильной ролью, а не пустой список.</summary>
private static async Task SeedAdminEmployeeAsync(AppDbContext db, Guid orgId, Guid? adminUserId, CancellationToken ct)
{ {
var anyVat = await db.VatRates.IgnoreQueryFilters().AnyAsync(v => v.OrganizationId == orgId, ct); if (adminUserId is null) return;
if (!anyVat) var existing = await db.Employees.IgnoreQueryFilters()
.FirstOrDefaultAsync(e => e.OrganizationId == orgId && e.UserId == adminUserId, ct);
if (existing is not null) return;
var adminRole = await db.EmployeeRoles.IgnoreQueryFilters()
.FirstOrDefaultAsync(r => r.OrganizationId == orgId && r.IsSystem && r.Name == "Администратор", ct);
if (adminRole is null) return;
db.Employees.Add(new Employee
{ {
db.VatRates.AddRange( OrganizationId = orgId,
new VatRate { OrganizationId = orgId, Name = "Без НДС", Percent = 0m, IsDefault = false }, UserId = adminUserId,
new VatRate { OrganizationId = orgId, Name = "НДС 12%", Percent = 12m, IsIncludedInPrice = true, IsDefault = true } LastName = "Admin",
); FirstName = "System",
Position = "Администратор",
Email = "admin@food-market.local",
RoleId = adminRole.Id,
IsActive = true,
});
await db.SaveChangesAsync(ct);
} }
/// <summary>Bootstrap минимально-достаточного набора tenant-сущностей для
/// новой организации: единицы измерения (ОКЕИ), типы цен (Розничная+Оптовая),
/// «Основной склад» MAIN, «Касса 1» POS-1, и системные роли через
/// SeedEmployeeRolesAsync. Идемпотентно: каждый блок проверяет существующие
/// записи. Используется и при первом старте Demo, и при создании org через
/// SuperAdmin UI.</summary>
public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct)
{
var anyUnit = await db.UnitsOfMeasure.IgnoreQueryFilters().AnyAsync(u => u.OrganizationId == orgId, ct); var anyUnit = await db.UnitsOfMeasure.IgnoreQueryFilters().AnyAsync(u => u.OrganizationId == orgId, ct);
if (!anyUnit) if (!anyUnit)
{ {
db.UnitsOfMeasure.AddRange( db.UnitsOfMeasure.AddRange(
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Symbol = "шт", Name = "штука", DecimalPlaces = 0, IsBase = true }, new UnitOfMeasure { OrganizationId = orgId, Code = "796", Name = "штука" },
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Symbol = "кг", Name = "килограмм", DecimalPlaces = 3 }, new UnitOfMeasure { OrganizationId = orgId, Code = "166", Name = "килограмм" },
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Symbol = "л", Name = "литр", DecimalPlaces = 3 }, new UnitOfMeasure { OrganizationId = orgId, Code = "112", Name = "литр" },
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Symbol = "м", Name = "метр", DecimalPlaces = 3 }, new UnitOfMeasure { OrganizationId = orgId, Code = "006", Name = "метр" },
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Symbol = "уп", Name = "упаковка", DecimalPlaces = 0 } new UnitOfMeasure { OrganizationId = orgId, Code = "625", Name = "упаковка" }
); );
} }
// Сидируем PriceType ТОЛЬКО если у организации не было ни одной записи.
// Если есть — никогда не создаём «системную копию», корректность IsSystem
// обеспечивает миграция Phase3b_FixPriceTypeIsSystem (выбор по факту:
// запись с максимумом ProductPrice).
var anyPriceType = await db.PriceTypes.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct); var anyPriceType = await db.PriceTypes.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
if (!anyPriceType) if (!anyPriceType)
{ {
db.PriceTypes.AddRange( db.PriceTypes.AddRange(
new PriceType { OrganizationId = orgId, Name = "Розничная", IsDefault = true, IsRetail = true, SortOrder = 1 }, new PriceType { OrganizationId = orgId, Name = "Розничная цена", IsSystem = true, IsRequired = true, IsRetail = true, SortOrder = 0 },
new PriceType { OrganizationId = orgId, Name = "Оптовая", SortOrder = 2 } new PriceType { OrganizationId = orgId, Name = "Оптовая", SortOrder = 1 }
); );
} }
@ -116,7 +171,6 @@ private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
OrganizationId = orgId, OrganizationId = orgId,
Name = "Основной склад", Name = "Основной склад",
Code = "MAIN", Code = "MAIN",
Kind = StoreKind.Warehouse,
IsMain = true, IsMain = true,
Address = "Алматы, ул. Пример 1", Address = "Алматы, ул. Пример 1",
}; };
@ -137,6 +191,100 @@ private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
await SeedEmployeeRolesAsync(db, orgId, ct);
}
/// <summary>Системные роли (IsSystem=true): Администратор / Менеджер /
/// Кладовщик / Кассир / Закупщик / Бухгалтер. Сидируется один раз
/// per организацию; обновлять не пытаемся, чтобы не сбросить кастомные
/// правки галок которые админ мог сделать.</summary>
private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, CancellationToken ct)
{
var anyRole = await db.EmployeeRoles.IgnoreQueryFilters().AnyAsync(r => r.OrganizationId == orgId, ct);
if (anyRole) return;
var admin = new EmployeeRole
{
OrganizationId = orgId,
Name = "Администратор",
Description = "Полный доступ ко всем разделам организации",
IsSystem = true, SortOrder = 0,
Permissions = RolePermissions.All(),
};
// Менеджер/Кладовщик/Закупщик/Бухгалтер — кастомные шаблоны (IsSystem=false),
// юзер может удалить или подкрутить под себя. Системные только Администратор + Кассир.
var manager = new EmployeeRole
{
OrganizationId = orgId,
Name = "Менеджер",
Description = "Управление каталогом, документами и контрагентами",
IsSystem = false, SortOrder = 10,
Permissions = new RolePermissions
{
ProductsView = true, ProductsEdit = true, ProductGroupsManage = true, PriceTypesManage = true,
SuppliesView = true, SuppliesEdit = true, SuppliesPost = true,
CounterpartiesView = true, CounterpartiesEdit = true,
ReportsView = true, StocksView = true,
},
};
var keeper = new EmployeeRole
{
OrganizationId = orgId,
Name = "Кладовщик",
Description = "Приёмки, инвентаризация, остатки",
IsSystem = false, SortOrder = 20,
Permissions = new RolePermissions
{
ProductsView = true,
SuppliesView = true, SuppliesEdit = true, SuppliesPost = true,
StocksView = true,
},
};
var cashier = new EmployeeRole
{
OrganizationId = orgId,
Name = "Кассир",
Description = "Только работа на кассе. Без доступа к веб-админке.",
IsSystem = true, SortOrder = 30,
Permissions = new RolePermissions
{
ProductsView = true,
StocksView = true,
RetailSalesOperate = true,
// RetailSalesRefund по умолчанию false — админ включит при необходимости
},
};
var buyer = new EmployeeRole
{
OrganizationId = orgId,
Name = "Закупщик",
Description = "Заказы поставщикам и приёмка товара",
IsSystem = false, SortOrder = 40,
Permissions = new RolePermissions
{
ProductsView = true,
SuppliesView = true, SuppliesEdit = true,
CounterpartiesView = true, CounterpartiesEdit = true,
},
};
var accountant = new EmployeeRole
{
OrganizationId = orgId,
Name = "Бухгалтер",
Description = "Просмотр всех данных и отчётов, без редактирования",
IsSystem = false, SortOrder = 50,
Permissions = new RolePermissions
{
ProductsView = true,
SuppliesView = true,
CounterpartiesView = true,
ReportsView = true, StocksView = true,
},
};
db.EmployeeRoles.AddRange(admin, manager, keeper, cashier, buyer, accountant);
await db.SaveChangesAsync(ct);
} }
public Task StopAsync(CancellationToken ct) => Task.CompletedTask; public Task StopAsync(CancellationToken ct) => Task.CompletedTask;

View file

@ -19,55 +19,55 @@ public async Task StartAsync(CancellationToken ct)
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await SeedCurrenciesAsync(db, ct); // первыми — на них ссылаются страны
await db.SaveChangesAsync(ct);
await SeedCountriesAsync(db, ct); await SeedCountriesAsync(db, ct);
await SeedCurrenciesAsync(db, ct); await db.SaveChangesAsync(ct);
await BackfillCountryDefaultsAsync(db, ct);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
public Task StopAsync(CancellationToken ct) => Task.CompletedTask; public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
private static async Task SeedCountriesAsync(AppDbContext db, CancellationToken ct) private record CountrySeed(string Code, string Name, string CurrencyCode, decimal VatRate);
private static readonly CountrySeed[] CountrySeeds =
{ {
// Kazakhstan first, then common trade partners. new("KZ", "Казахстан", "KZT", 16m),
var wanted = new[] new("RU", "Россия", "RUB", 20m),
{ new("CN", "Китай", "CNY", 13m),
new Country { Code = "KZ", Name = "Казахстан", SortOrder = 1 }, new("TR", "Турция", "TRY", 18m),
new Country { Code = "RU", Name = "Россия", SortOrder = 2 }, new("BY", "Беларусь", "BYN", 20m),
new Country { Code = "CN", Name = "Китай", SortOrder = 3 }, new("UZ", "Узбекистан", "UZS", 12m),
new Country { Code = "TR", Name = "Турция", SortOrder = 4 }, new("KG", "Кыргызстан", "KGS", 12m),
new Country { Code = "BY", Name = "Беларусь", SortOrder = 5 }, new("DE", "Германия", "EUR", 19m),
new Country { Code = "UZ", Name = "Узбекистан", SortOrder = 6 }, new("US", "США", "USD", 0m),
new Country { Code = "KG", Name = "Кыргызстан", SortOrder = 7 }, new("KR", "Южная Корея", "KRW", 10m),
new Country { Code = "DE", Name = "Германия", SortOrder = 10 }, new("IT", "Италия", "EUR", 22m),
new Country { Code = "US", Name = "США", SortOrder = 11 }, new("PL", "Польша", "PLN", 23m),
new Country { Code = "KR", Name = "Южная Корея", SortOrder = 12 },
new Country { Code = "IT", Name = "Италия", SortOrder = 13 },
new Country { Code = "PL", Name = "Польша", SortOrder = 14 },
}; };
var existingCodes = await db.Countries.Select(c => c.Code).ToListAsync(ct); private static readonly Currency[] CurrencySeeds =
foreach (var c in wanted)
{ {
if (!existingCodes.Contains(c.Code)) new() { Code = "KZT", Name = "Тенге", Symbol = "₸", MinorUnit = 2 },
{ new() { Code = "RUB", Name = "Российский рубль", Symbol = "₽", MinorUnit = 2 },
db.Countries.Add(c); new() { Code = "USD", Name = "Доллар США", Symbol = "$", MinorUnit = 2 },
} new() { Code = "EUR", Name = "Евро", Symbol = "€", MinorUnit = 2 },
} new() { Code = "CNY", Name = "Китайский юань", Symbol = "¥", MinorUnit = 2 },
} new() { Code = "BYN", Name = "Белорусский рубль", Symbol = "Br", MinorUnit = 2 },
new() { Code = "UZS", Name = "Узбекский сум", Symbol = "сум", MinorUnit = 2 },
new() { Code = "KGS", Name = "Кыргызский сом", Symbol = "сом", MinorUnit = 2 },
new() { Code = "TRY", Name = "Турецкая лира", Symbol = "₺", MinorUnit = 2 },
new() { Code = "KRW", Name = "Южнокорейская вона", Symbol = "₩", MinorUnit = 0 },
new() { Code = "PLN", Name = "Польский злотый", Symbol = "zł", MinorUnit = 2 },
};
private static async Task SeedCurrenciesAsync(AppDbContext db, CancellationToken ct) private static async Task SeedCurrenciesAsync(AppDbContext db, CancellationToken ct)
{ {
var wanted = new[]
{
new Currency { Code = "KZT", Name = "Тенге", Symbol = "₸", MinorUnit = 2 },
new Currency { Code = "RUB", Name = "Российский рубль", Symbol = "₽", MinorUnit = 2 },
new Currency { Code = "USD", Name = "Доллар США", Symbol = "$", MinorUnit = 2 },
new Currency { Code = "EUR", Name = "Евро", Symbol = "€", MinorUnit = 2 },
new Currency { Code = "CNY", Name = "Китайский юань", Symbol = "¥", MinorUnit = 2 },
};
var existingCodes = await db.Currencies.Select(c => c.Code).ToListAsync(ct); var existingCodes = await db.Currencies.Select(c => c.Code).ToListAsync(ct);
foreach (var c in wanted) foreach (var c in CurrencySeeds)
{ {
if (!existingCodes.Contains(c.Code)) if (!existingCodes.Contains(c.Code))
{ {
@ -75,4 +75,36 @@ private static async Task SeedCurrenciesAsync(AppDbContext db, CancellationToken
} }
} }
} }
private static async Task SeedCountriesAsync(AppDbContext db, CancellationToken ct)
{
var existingCodes = await db.Countries.Select(c => c.Code).ToListAsync(ct);
foreach (var s in CountrySeeds)
{
if (!existingCodes.Contains(s.Code))
{
db.Countries.Add(new Country { Code = s.Code, Name = s.Name, VatRate = s.VatRate });
}
}
}
private static async Task BackfillCountryDefaultsAsync(AppDbContext db, CancellationToken ct)
{
// Привязываем валюту и НДС к странам (обновит и новосозданные и существующие).
var currenciesByCode = await db.Currencies.ToDictionaryAsync(c => c.Code, ct);
var countries = await db.Countries.ToListAsync(ct);
foreach (var country in countries)
{
var seed = Array.Find(CountrySeeds, s => s.Code == country.Code);
if (seed is null) continue;
if (country.DefaultCurrencyId is null && currenciesByCode.TryGetValue(seed.CurrencyCode, out var cur))
{
country.DefaultCurrencyId = cur.Id;
}
if (country.VatRate == 0m && seed.VatRate > 0m)
{
country.VatRate = seed.VatRate;
}
}
}
} }

View file

@ -30,7 +30,11 @@
"Cors": { "Cors": {
"AllowedOrigins": [ "AllowedOrigins": [
"http://localhost:5173", "http://localhost:5173",
"http://localhost:4173" "http://localhost:4173",
"https://food-market.zat.kz",
"https://app.food-market.zat.kz",
"https://food-market.kz",
"https://app.food-market.kz"
] ]
}, },
"AllowedHosts": "*" "AllowedHosts": "*"

View file

@ -1,22 +1,25 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Domain.Catalog; using foodmarket.Domain.Catalog;
namespace foodmarket.Application.Catalog; namespace foodmarket.Application.Catalog;
public record CountryDto(Guid Id, string Code, string Name, int SortOrder); public record CountryDto(
Guid Id, string Code, string Name,
Guid? DefaultCurrencyId, string? DefaultCurrencyCode, string? DefaultCurrencySymbol,
decimal VatRate);
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol, int MinorUnit, bool IsActive);
public record VatRateDto( public record CurrencyDto(Guid Id, string Code, string Name, string Symbol);
Guid Id, string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault, bool IsActive);
public record UnitOfMeasureDto( public record UnitOfMeasureDto(
Guid Id, string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase, bool IsActive); Guid Id, string Code, string Name, string? Description, Guid? OrganizationId);
public record PriceTypeDto( public record PriceTypeDto(
Guid Id, string Name, bool IsDefault, bool IsRetail, int SortOrder, bool IsActive); Guid Id, string Name, bool IsRequired, bool IsSystem,
bool IsRetail, int SortOrder);
public record StoreDto( public record StoreDto(
Guid Id, string Name, string? Code, StoreKind Kind, string? Address, string? Phone, Guid Id, string Name, string? Code, string? Address, string? Phone,
string? ManagerName, bool IsMain, bool IsActive); string? ManagerName, bool IsMain, bool IsActive);
public record RetailPointDto( public record RetailPointDto(
@ -24,13 +27,14 @@ public record RetailPointDto(
string? Address, string? Phone, string? FiscalSerial, string? FiscalRegNumber, bool IsActive); string? Address, string? Phone, string? FiscalSerial, string? FiscalRegNumber, bool IsActive);
public record ProductGroupDto( public record ProductGroupDto(
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive); Guid Id, string Name, Guid? ParentId, string Path, int SortOrder,
decimal? MarkupPercent, Guid? OrganizationId);
public record CounterpartyDto( public record CounterpartyDto(
Guid Id, string Name, string? LegalName, CounterpartyKind Kind, CounterpartyType Type, Guid Id, string Name, string? LegalName, CounterpartyType Type,
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName, string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName,
string? Address, string? Phone, string? Email, string? Address, string? Phone, string? Email,
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive); string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes);
public record ProductBarcodeDto(Guid Id, string Code, BarcodeType Type, bool IsPrimary); public record ProductBarcodeDto(Guid Id, string Code, BarcodeType Type, bool IsPrimary);
@ -38,47 +42,54 @@ public record ProductPriceDto(Guid Id, Guid PriceTypeId, string PriceTypeName, d
public record ProductDto( public record ProductDto(
Guid Id, string Name, string? Article, string? Description, Guid Id, string Name, string? Article, string? Description,
Guid UnitOfMeasureId, string UnitSymbol, Guid UnitOfMeasureId, string UnitName,
Guid VatRateId, decimal VatPercent, decimal Vat, bool VatEnabled,
Guid? ProductGroupId, string? ProductGroupName, Guid ProductGroupId, string ProductGroupName,
Guid? DefaultSupplierId, string? DefaultSupplierName, Guid? DefaultSupplierId, string? DefaultSupplierName,
Guid? CountryOfOriginId, string? CountryOfOriginName, Guid? CountryOfOriginId, string? CountryOfOriginName,
bool IsService, bool IsWeighed, bool IsAlcohol, bool IsMarked, bool IsService, Packaging Packaging, bool IsMarked,
decimal? MinStock, decimal? MaxStock, decimal? MinStock, decimal? MaxStock,
decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode, decimal? ReferencePrice, DateTime? ReferencePriceUpdatedAt,
string? ImageUrl, bool IsActive, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
decimal Cost, DateTime? LastSupplyAt,
string? ImageUrl,
IReadOnlyList<ProductPriceDto> Prices, IReadOnlyList<ProductPriceDto> Prices,
IReadOnlyList<ProductBarcodeDto> Barcodes); IReadOnlyList<ProductBarcodeDto> Barcodes);
// Upsert payloads (input) // Upsert payloads (input)
public record CountryInput(string Code, string Name, int SortOrder = 0); public record CountryInput(
public record CurrencyInput(string Code, string Name, string Symbol, int MinorUnit = 2, bool IsActive = true); string Code, string Name,
public record VatRateInput(string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault = false, bool IsActive = true); Guid? DefaultCurrencyId = null, decimal VatRate = 0m);
public record UnitOfMeasureInput(string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase = false, bool IsActive = true); public record CurrencyInput(string Code, string Name, string Symbol);
public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true); public record UnitOfMeasureInput(string Code, string Name, string? Description = null);
public record PriceTypeInput(
string Name, bool IsRequired = false,
bool IsRetail = false, int SortOrder = 0);
public record StoreInput( public record StoreInput(
string Name, string? Code, StoreKind Kind = StoreKind.Warehouse, string Name, string? Code,
string? Address = null, string? Phone = null, string? ManagerName = null, string? Address = null, string? Phone = null, string? ManagerName = null,
bool IsMain = false, bool IsActive = true); bool IsMain = false, bool IsActive = true);
public record RetailPointInput( public record RetailPointInput(
string Name, string? Code, Guid StoreId, string Name, string? Code, Guid StoreId,
string? Address = null, string? Phone = null, string? Address = null, string? Phone = null,
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true); string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
public record ProductGroupInput(string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true); public record ProductGroupInput(
string Name, Guid? ParentId, int SortOrder = 0,
[Range(0, 1000)] decimal? MarkupPercent = null);
public record CounterpartyInput( public record CounterpartyInput(
string Name, string? LegalName, CounterpartyKind Kind, CounterpartyType Type, string Name, string? LegalName, CounterpartyType Type,
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? Bin, string? Iin, string? TaxNumber, Guid? CountryId,
string? Address, string? Phone, string? Email, string? Address, string? Phone, string? Email,
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive = true); string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes);
public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ean13, bool IsPrimary = false); public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ean13, bool IsPrimary = false);
public record ProductPriceInput(Guid PriceTypeId, decimal Amount, Guid CurrencyId); public record ProductPriceInput(Guid PriceTypeId, [Range(0, 1e10)] decimal Amount, Guid CurrencyId);
public record ProductInput( public record ProductInput(
string Name, string? Article, string? Description, string Name, string? Article, string? Description,
Guid UnitOfMeasureId, Guid VatRateId, Guid UnitOfMeasureId, [Range(0, 100)] decimal? Vat, bool VatEnabled,
Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId, Guid ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
bool IsService = false, bool IsWeighed = false, bool IsAlcohol = false, bool IsMarked = false, bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false,
decimal? MinStock = null, decimal? MaxStock = null, [Range(0, 1e10)] decimal? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null,
decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null, [Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null,
string? ImageUrl = null, bool IsActive = true, string? ImageUrl = null,
IReadOnlyList<ProductPriceInput>? Prices = null, IReadOnlyList<ProductPriceInput>? Prices = null,
IReadOnlyList<ProductBarcodeInput>? Barcodes = null); IReadOnlyList<ProductBarcodeInput>? Barcodes = null);

View file

@ -5,11 +5,14 @@ public sealed class PagedRequest
public int Page { get; init; } = 1; public int Page { get; init; } = 1;
public int PageSize { get; init; } = 50; public int PageSize { get; init; } = 50;
public string? Search { get; init; } public string? Search { get; init; }
public string? SortBy { get; init; } /// <summary>Ключ колонки, по которой сортировать (см. switch в контроллере).</summary>
public bool SortDesc { get; init; } public string? Sort { get; init; }
/// <summary>"asc" (дефолт) или "desc".</summary>
public string? Order { get; init; }
public int Skip => Math.Max(0, (Page - 1) * PageSize); public int Skip => Math.Max(0, (Page - 1) * PageSize);
public int Take => Math.Clamp(PageSize, 1, 500); public int Take => Math.Clamp(PageSize, 1, 500);
public bool Desc => string.Equals(Order, "desc", StringComparison.OrdinalIgnoreCase);
} }
public sealed class PagedResult<T> public sealed class PagedResult<T>

View file

@ -5,4 +5,8 @@ public interface ITenantContext
Guid? OrganizationId { get; } Guid? OrganizationId { get; }
bool IsAuthenticated { get; } bool IsAuthenticated { get; }
bool IsSuperAdmin { get; } bool IsSuperAdmin { get; }
/// <summary>SuperAdmin зашёл в режим «открыто как…» через X-Org-Override.
/// В этом режиме query-filter ОБЯЗАН применяться (по выбранной orgId),
/// иначе SuperAdmin'у возвращаются записи всех орг — нарушение изоляции.</summary>
bool IsTenantOverride { get; }
} }

View file

@ -0,0 +1,27 @@
using foodmarket.Domain.Inventory;
namespace foodmarket.Application.Inventory;
public record StockMovementDraft(
Guid ProductId,
Guid StoreId,
decimal Quantity,
MovementType Type,
string DocumentType,
Guid? DocumentId = null,
string? DocumentNumber = null,
decimal? UnitCost = null,
DateTime? OccurredAt = null,
Guid? CreatedByUserId = null,
string? Notes = null);
public interface IStockService
{
/// <summary>Writes the movement + updates the materialized Stock row in a single unit of work.
/// Returns the new on-hand quantity. Callers must commit the DbContext themselves (we don't
/// wrap in a transaction — typical flow is as part of a document posting that already bundles
/// multiple movements into one SaveChanges).</summary>
Task<decimal> ApplyMovementAsync(StockMovementDraft draft, CancellationToken ct = default);
Task<decimal> ApplyMovementsAsync(IEnumerable<StockMovementDraft> drafts, CancellationToken ct = default);
}

View file

@ -6,7 +6,6 @@ public class Counterparty : TenantEntity
{ {
public string Name { get; set; } = null!; // отображаемое имя public string Name { get; set; } = null!; // отображаемое имя
public string? LegalName { get; set; } // полное юридическое имя public string? LegalName { get; set; } // полное юридическое имя
public CounterpartyKind Kind { get; set; } // Supplier / Customer / Both
public CounterpartyType Type { get; set; } // Юрлицо / Физлицо public CounterpartyType Type { get; set; } // Юрлицо / Физлицо
public string? Bin { get; set; } // БИН (для юрлиц РК) public string? Bin { get; set; } // БИН (для юрлиц РК)
public string? Iin { get; set; } // ИИН (для физлиц РК) public string? Iin { get; set; } // ИИН (для физлиц РК)
@ -21,5 +20,4 @@ public class Counterparty : TenantEntity
public string? Bik { get; set; } public string? Bik { get; set; }
public string? ContactPerson { get; set; } public string? ContactPerson { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
} }

View file

@ -7,5 +7,13 @@ public class Country : Entity
{ {
public string Code { get; set; } = null!; // ISO 3166-1 alpha-2, e.g. "KZ" public string Code { get; set; } = null!; // ISO 3166-1 alpha-2, e.g. "KZ"
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
public int SortOrder { get; set; } /// <summary>Валюта страны — при выборе страны в настройках организации
/// она становится валютой по умолчанию для этой организации.</summary>
public Guid? DefaultCurrencyId { get; set; }
public Currency? DefaultCurrency { get; set; }
/// <summary>Ставка НДС этой страны, в процентах (например 16.00 для KZ,
/// 20.00 для RU). Единственный источник правды для ставки НДС —
/// Product.Vat на товаре не хранится.</summary>
public decimal VatRate { get; set; }
} }

View file

@ -8,6 +8,7 @@ public class Currency : Entity
public string Code { get; set; } = null!; // ISO 4217, e.g. "KZT" public string Code { get; set; } = null!; // ISO 4217, e.g. "KZT"
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
public string Symbol { get; set; } = null!; // "₸" public string Symbol { get; set; } = null!; // "₸"
public int MinorUnit { get; set; } = 2; // 2 = two decimal places // Количество знаков после запятой для форматирования цен. Не редактируется
public bool IsActive { get; set; } = true; // в UI — задаётся сидером/миграцией по ISO 4217.
public int MinorUnit { get; set; } = 2;
} }

View file

@ -1,22 +1,19 @@
namespace foodmarket.Domain.Catalog; namespace foodmarket.Domain.Catalog;
public enum CounterpartyKind
{
Supplier = 1,
Customer = 2,
Both = 3,
}
public enum CounterpartyType public enum CounterpartyType
{ {
LegalEntity = 1, LegalEntity = 1,
Individual = 2, Individual = 2,
} }
public enum StoreKind /// <summary>Фасовка товара: как продаётся и учитывается в остатках.
/// Piece — штучный товар (1 шт), по умолчанию. Weight — весовой (кг, г), продаётся с весов.
/// Liquid — разливной (л), продаётся из тары на разлив.</summary>
public enum Packaging
{ {
Warehouse = 1, Piece = 1,
RetailFloor = 2, Weight = 2,
Liquid = 3,
} }
public enum BarcodeType public enum BarcodeType

View file

@ -6,8 +6,14 @@ namespace foodmarket.Domain.Catalog;
public class PriceType : TenantEntity public class PriceType : TenantEntity
{ {
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
public bool IsDefault { get; set; } // цена по умолчанию для новых товаров /// <summary>true — цена должна быть заполнена у каждого товара (валидация на UI и сервере).</summary>
public bool IsRetail { get; set; } // используется на кассе public bool IsRequired { get; set; }
/// <summary>true — системная запись «Розничная цена», не удаляется и
/// IsRequired всегда true. Имя можно переименовать. Сидируется при первом старте.</summary>
public bool IsSystem { get; set; }
/// <summary>true — единственная запись, по которой POS касса берёт цену
/// для пробивки чека. Контроллер обеспечивает уникальность: установка
/// IsRetail=true сбрасывает её у других записей.</summary>
public bool IsRetail { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
} }

View file

@ -11,10 +11,16 @@ public class Product : TenantEntity
public Guid UnitOfMeasureId { get; set; } public Guid UnitOfMeasureId { get; set; }
public UnitOfMeasure? UnitOfMeasure { get; set; } public UnitOfMeasure? UnitOfMeasure { get; set; }
public Guid VatRateId { get; set; } // Ставка НДС в процентах, decimal(5,2) — например 16.00 или 0.00. Дефолт
public VatRate? VatRate { get; set; } // при создании берётся из Country.VatRate организации, пользователь может
// менять для исключений (хлеб = 0.00 и т.п.) — но только если в настройках
// включена галка «Указывать ставку НДС на товаре». VatEnabled управляет
// лишь видимостью поля Vat в UI: снята — поле скрыто, включена — поле
// с текущей ставкой. Семантика «в том числе/сверху» — на уровне документа.
public decimal Vat { get; set; }
public bool VatEnabled { get; set; } = true;
public Guid? ProductGroupId { get; set; } public Guid ProductGroupId { get; set; }
public ProductGroup? ProductGroup { get; set; } public ProductGroup? ProductGroup { get; set; }
public Guid? DefaultSupplierId { get; set; } // основной поставщик public Guid? DefaultSupplierId { get; set; } // основной поставщик
@ -24,19 +30,30 @@ public class Product : TenantEntity
public Country? CountryOfOrigin { get; set; } public Country? CountryOfOrigin { get; set; }
public bool IsService { get; set; } // услуга, а не физический товар public bool IsService { get; set; } // услуга, а не физический товар
public bool IsWeighed { get; set; } // весовой (продаётся с весов) public Packaging Packaging { get; set; } = Packaging.Piece; // фасовка (штучный/весовой/разливной)
public bool IsAlcohol { get; set; } // алкоголь (подакцизный)
public bool IsMarked { get; set; } // маркируемый (Datamatrix) public bool IsMarked { get; set; } // маркируемый (Datamatrix)
public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений) public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений)
public decimal? MaxStock { get; set; } // максимальный остаток (для автозаказа) public decimal? MaxStock { get; set; } // максимальный остаток (для автозаказа)
public decimal? PurchasePrice { get; set; } // закупочная цена по умолчанию /// <summary>«Эталонная» (справочная) цена закупа. Не обязательная.
/// Автоматически заполняется UnitPrice'ом первой проведённой приёмки.
/// Через 30 дней без новых приёмок Hangfire-job переписывает на текущую Cost.</summary>
public decimal? ReferencePrice { get; set; }
public DateTime? ReferencePriceUpdatedAt { get; set; }
public Guid? PurchaseCurrencyId { get; set; } public Guid? PurchaseCurrencyId { get; set; }
public Currency? PurchaseCurrency { get; set; } public Currency? PurchaseCurrency { get; set; }
/// <summary>Себестоимость по скользящему среднему. Пересчитывается на каждой
/// проведённой приёмке: (qty_old * cost_old + qty_in * price_in) / (qty_old + qty_in).
/// Хранится с 4 знаками для точности; UI показывает 2.</summary>
public decimal Cost { get; set; }
/// <summary>UTC-метка последней проведённой приёмки. Используется
/// 30-дневной job для перезаписи ReferencePrice на текущую Cost.</summary>
public DateTime? LastSupplyAt { get; set; }
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage) public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)
public bool IsActive { get; set; } = true;
public ICollection<ProductPrice> Prices { get; set; } = []; public ICollection<ProductPrice> Prices { get; set; } = [];
public ICollection<ProductBarcode> Barcodes { get; set; } = []; public ICollection<ProductBarcode> Barcodes { get; set; } = [];

View file

@ -3,13 +3,19 @@
namespace foodmarket.Domain.Catalog; namespace foodmarket.Domain.Catalog;
// Иерархическая группа товаров (категория). Произвольная вложенность через ParentId. // Иерархическая группа товаров (категория). Произвольная вложенность через ParentId.
public class ProductGroup : TenantEntity // Двухуровневый справочник (IOptionalTenantEntity): системные эталонные группы
// (OrganizationId=null, управляются SuperAdmin'ом) и tenant'овские (OrganizationId=<orgId>).
public class ProductGroup : Entity, IOptionalTenantEntity
{ {
public Guid? OrganizationId { get; set; }
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
public Guid? ParentId { get; set; } public Guid? ParentId { get; set; }
public ProductGroup? Parent { get; set; } public ProductGroup? Parent { get; set; }
public ICollection<ProductGroup> Children { get; set; } = []; public ICollection<ProductGroup> Children { get; set; } = [];
public string Path { get; set; } = ""; // денормализованный путь "Электроника/Телефоны/Смартфоны" для фильтрации public string Path { get; set; } = ""; // денормализованный путь "Электроника/Телефоны/Смартфоны" для фильтрации
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>Процент наценки на себестоимость для автоматического расчёта
/// розничной цены при проведении приёмки. NULL = автонаценка отключена.</summary>
public decimal? MarkupPercent { get; set; }
} }

View file

@ -2,12 +2,12 @@
namespace foodmarket.Domain.Catalog; namespace foodmarket.Domain.Catalog;
// Склад: физическое место хранения товаров. Может быть чисто склад или торговый зал. // Склад: физическое место хранения товаров. MoySklad не различает "склад" и
// "торговый зал" — это одна сущность entity/store, опираемся на это.
public class Store : TenantEntity public class Store : TenantEntity
{ {
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
public string? Code { get; set; } // внутренний код склада public string? Code { get; set; } // внутренний код склада
public StoreKind Kind { get; set; } = StoreKind.Warehouse;
public string? Address { get; set; } public string? Address { get; set; }
public string? Phone { get; set; } public string? Phone { get; set; }
public string? ManagerName { get; set; } public string? ManagerName { get; set; }

View file

@ -2,13 +2,13 @@
namespace foodmarket.Domain.Catalog; namespace foodmarket.Domain.Catalog;
// Tenant-scoped справочник единиц измерения. // Единица измерения как в MoySklad entity/uom: code + name + description.
public class UnitOfMeasure : TenantEntity // Двухуровневый справочник: системные эталонные (OrganizationId=null,
// управляются SuperAdmin'ом — ОКЕИ-эталоны) и tenant'овские расширения.
public class UnitOfMeasure : Entity, IOptionalTenantEntity
{ {
public Guid? OrganizationId { get; set; }
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л) public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
public string Symbol { get; set; } = null!; // "шт", "кг", "л", "м"
public string Name { get; set; } = null!; // "штука", "килограмм", "литр" public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
public int DecimalPlaces { get; set; } // 0 для шт, 3 для кг/л public string? Description { get; set; }
public bool IsBase { get; set; } // базовая единица этой организации
public bool IsActive { get; set; } = true;
} }

View file

@ -1,13 +0,0 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Catalog;
// Tenant-scoped: разные организации могут работать в разных режимах (с НДС / упрощёнка).
public class VatRate : TenantEntity
{
public string Name { get; set; } = null!; // "НДС 12%", "Без НДС"
public decimal Percent { get; set; } // 12.00, 0.00
public bool IsIncludedInPrice { get; set; } // входит ли в цену или начисляется сверху
public bool IsDefault { get; set; }
public bool IsActive { get; set; } = true;
}

View file

@ -9,3 +9,12 @@ public abstract class TenantEntity : Entity, ITenantEntity
{ {
public Guid OrganizationId { get; set; } public Guid OrganizationId { get; set; }
} }
/// <summary>Двухуровневый справочник: запись либо системная (OrganizationId=null,
/// видна и читается всеми, мутирует только SuperAdmin), либо tenant'овская
/// (OrganizationId=&lt;orgId&gt;, видна и редактируется этой оргой). Применяется
/// для расширяемых эталонных списков типа единиц измерения и групп товаров.</summary>
public interface IOptionalTenantEntity
{
Guid? OrganizationId { get; set; }
}

View file

@ -0,0 +1,22 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Inventory;
// Materialized current-stock aggregate per (Product, Store). Kept in sync with StockMovement
// inserts by IStockService — never write to this entity directly.
public class Stock : TenantEntity
{
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
public decimal Quantity { get; set; }
public decimal ReservedQuantity { get; set; }
/// <summary>Available = on-hand reserved. Cannot be negative in normal flow; a negative
/// value indicates the business allowed overselling (e.g., retail sale before physical receipt).</summary>
public decimal Available => Quantity - ReservedQuantity;
}

View file

@ -0,0 +1,49 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Inventory;
// Immutable, append-only journal of every stock change.
// Stock table is a materialized aggregate over this journal.
public class StockMovement : TenantEntity
{
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
/// <summary>Signed quantity: positive = receipt, negative = issue.</summary>
public decimal Quantity { get; set; }
/// <summary>Per-unit cost at the time of movement (optional). Used for cost rollup / P&amp;L.</summary>
public decimal? UnitCost { get; set; }
public MovementType Type { get; set; }
/// <summary>Source document discriminator, e.g. "supply", "retail-sale", "write-off", "enter", "transfer-out".</summary>
public string DocumentType { get; set; } = "";
public Guid? DocumentId { get; set; }
public string? DocumentNumber { get; set; }
public DateTime OccurredAt { get; set; } = DateTime.UtcNow;
public Guid? CreatedByUserId { get; set; }
public string? Notes { get; set; }
}
public enum MovementType
{
Initial = 0,
Supply = 1, // приёмка от поставщика
RetailSale = 2, // розничная продажа
WholesaleSale = 3, // оптовая отгрузка
CustomerReturn = 4, // возврат покупателя
SupplierReturn = 5, // возврат поставщику
TransferOut = 6, // перемещение со склада
TransferIn = 7, // перемещение на склад
WriteOff = 8, // списание
Enter = 9, // оприходование
InventoryAdjustment = 10, // корректировка по результату инвентаризации
}

View file

@ -0,0 +1,50 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Organizations;
/// <summary>Сотрудник организации. Может быть привязан к учётной записи
/// (UserId), а может существовать без логина (например, кассир, которого
/// добавили в HR, но логин ещё не выдали).</summary>
public class Employee : TenantEntity
{
/// <summary>FK на Identity-юзера. Заполняется когда сотруднику выдан логин.
/// Null — запись без учётки.</summary>
public Guid? UserId { get; set; }
public string LastName { get; set; } = "";
public string FirstName { get; set; } = "";
public string? MiddleName { get; set; }
public string? Position { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
/// <summary>Оклад в валюте организации, опц.</summary>
public decimal? Salary { get; set; }
/// <summary>ИИН/ИНН (12-14 символов), опц.</summary>
public string? TaxNumber { get; set; }
/// <summary>Произвольное описание (комментарий HR'а).</summary>
public string? Description { get; set; }
/// <summary>Аватар сотрудника. URL до файла в общем images-стораж.</summary>
public string? ImageUrl { get; set; }
public Guid RoleId { get; set; }
public EmployeeRole Role { get; set; } = null!;
/// <summary>Активен ли сотрудник. False — заблокирован, не может логиниться.
/// Удаление физически не делаем (FK из документов), просто IsActive=false.</summary>
public bool IsActive { get; set; } = true;
public DateTime? FiredAt { get; set; }
public ICollection<EmployeeRetailPointAssignment> RetailPointAssignments { get; set; }
= new List<EmployeeRetailPointAssignment>();
}
/// <summary>Привязка сотрудника к кассе (для роли Кассир): к каким RetailPoint'ам
/// он может вставать. Если назначений нет — может ко всем (поведение по умолчанию).</summary>
public class EmployeeRetailPointAssignment : TenantEntity
{
public Guid EmployeeId { get; set; }
public Employee Employee { get; set; } = null!;
public Guid RetailPointId { get; set; }
}

View file

@ -0,0 +1,17 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Organizations;
/// <summary>Роль сотрудника в организации. Системные (IsSystem=true) сидируются
/// при создании Organization (Администратор/Менеджер/Кладовщик/Кассир/Закупщик/
/// Бухгалтер) — нельзя удалить, имя менять можно. Кастомные — полный CRUD.</summary>
public class EmployeeRole : TenantEntity
{
public string Name { get; set; } = "";
public string? Description { get; set; }
public bool IsSystem { get; set; }
public int SortOrder { get; set; }
/// <summary>Permissions — owned JSON-колонка (см. EF config).</summary>
public RolePermissions Permissions { get; set; } = new();
}

View file

@ -1,3 +1,4 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common; using foodmarket.Domain.Common;
namespace foodmarket.Domain.Organizations; namespace foodmarket.Domain.Organizations;
@ -11,4 +12,70 @@ public class Organization : Entity
public string? Phone { get; set; } public string? Phone { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
/// <summary>Архивирована ли организация. Архивные не видны пользователям
/// но данные сохраняются. Из архива можно восстановить или (после 30 дней)
/// удалить навсегда — этим управляет SuperAdmin.</summary>
public bool IsArchived { get; set; }
public DateTime? ArchivedAt { get; set; }
/// <summary>Account owner (главный владелец, не путать с админами роли).
/// Это конкретный AppUser, который считается «хозяином» организации;
/// SuperAdmin может сменить через отдельный action c reason в audit-log.</summary>
public Guid? AccountOwnerUserId { get; set; }
/// <summary>Персональный API-токен MoySklad. Храним per-organization чтобы
/// пользователю не нужно было вводить его каждый раз при импорте.</summary>
public string? MoySkladToken { get; set; }
/// <summary>Валюта организации по умолчанию. Если MultiCurrencyEnabled=false,
/// в UI выбор валюты скрыт — всё в этой валюте.</summary>
public Guid? DefaultCurrencyId { get; set; }
public Currency? DefaultCurrency { get; set; }
/// <summary>Разрешены ли продажи/закупки в нескольких валютах. По умолчанию
/// false — тогда UI не предлагает выбор валюты, всё в DefaultCurrency.</summary>
public bool MultiCurrencyEnabled { get; set; }
/// <summary>Показывать ли пользователю галку «В том числе НДС» на форме товара.
/// Если false (по умолчанию) — магазин работает с одной ставкой НДС и галка
/// скрыта, все товары считаются с НДС. Если true — можно для отдельных товаров
/// (хлеб, медикаменты) снимать галку.</summary>
public bool ShowVatEnabledOnProduct { get; set; }
/// <summary>Показывать ли на форме товара и в фильтрах галку «Услуга».
/// Большинство магазинов продают только физические товары — флаг выключен
/// по умолчанию, чтобы не захламлять UI.</summary>
public bool ShowServiceOnProduct { get; set; }
/// <summary>Показывать ли на форме товара и в фильтрах галку «Маркируемый».
/// Маркировка требуется только в нишевых категориях (алкоголь, лекарства,
/// табак) — по умолчанию выключено.</summary>
public bool ShowMarkedOnProduct { get; set; }
/// <summary>Показывать ли поля «Минимальный остаток» / «Максимальный остаток»
/// на карточке товара и одноимённую колонку в списке. Нужно в основном
/// торговым сетям со свободным местом на полке — по умолчанию выключено.</summary>
public bool ShowMinMaxStock { get; set; }
/// <summary>Разрешить ли цены с дробной частью (две цифры после запятой).
/// По умолчанию false — большинству KZ-магазинов хватает круглых тенге;
/// если включено, MoneyInput работает с шагом 0.01 и форматирует с .00,
/// иначе шаг 1 и значения округляются до целого даже при попытке прислать
/// дробное через API.</summary>
public bool AllowFractionalPrices { get; set; }
/// <summary>Показывать ли в карточке товара поле «Эталонная цена».
/// Default: true.</summary>
public bool ShowReferencePriceOnProduct { get; set; } = true;
/// <summary>Показывать ли в карточке товара поле «Страна происхождения».
/// Default: false. Большинству KZ-магазинов это поле не нужно;
/// включать для торговцев импортом или маркируемой продукцией.</summary>
public bool ShowCountryOfOriginOnProduct { get; set; }
/// <summary>Показывать ли в карточке товара поле «Описание». Default:
/// false. Описания ведут единицы магазинов; обычно текстовая колонка
/// просто захламляет карточку.</summary>
public bool ShowDescriptionOnProduct { get; set; }
} }

View file

@ -0,0 +1,68 @@
namespace foodmarket.Domain.Organizations;
/// <summary>Набор флагов разрешений роли. Хранится JSON-колонкой
/// (owned by EmployeeRole). Семантика: false = доступ запрещён.</summary>
public class RolePermissions
{
// Каталог
public bool ProductsView { get; set; }
public bool ProductsEdit { get; set; }
public bool ProductsDelete { get; set; }
public bool ProductGroupsManage { get; set; }
public bool PriceTypesManage { get; set; }
public bool UnitsManage { get; set; }
// Закупки
public bool SuppliesView { get; set; }
public bool SuppliesEdit { get; set; }
public bool SuppliesPost { get; set; }
public bool SuppliesDelete { get; set; }
// Продажи (отгрузка контрагенту + POS)
public bool DemandsView { get; set; }
public bool DemandsEdit { get; set; }
public bool DemandsPost { get; set; }
public bool RetailSalesOperate { get; set; }
public bool RetailSalesRefund { get; set; }
// Контрагенты
public bool CounterpartiesView { get; set; }
public bool CounterpartiesEdit { get; set; }
public bool CounterpartiesDelete { get; set; }
// Склад / Остатки
public bool StocksView { get; set; }
public bool InventoryEdit { get; set; }
public bool LossEdit { get; set; }
public bool EnterEdit { get; set; }
// Отчёты
public bool ReportsView { get; set; }
public bool ReportsFinanceView { get; set; }
public bool ReportsStockView { get; set; }
// Настройки организации
public bool OrgSettingsManage { get; set; }
public bool EmployeesManage { get; set; }
public bool RolesManage { get; set; }
public bool StoresManage { get; set; }
public bool RetailPointsManage { get; set; }
public bool CashRegistersManage { get; set; }
public bool IntegrationsManage { get; set; }
/// <summary>Полный набор всех true — для системной роли «Администратор».</summary>
public static RolePermissions All() => new()
{
ProductsView = true, ProductsEdit = true, ProductsDelete = true,
ProductGroupsManage = true, PriceTypesManage = true, UnitsManage = true,
SuppliesView = true, SuppliesEdit = true, SuppliesPost = true, SuppliesDelete = true,
DemandsView = true, DemandsEdit = true, DemandsPost = true,
RetailSalesOperate = true, RetailSalesRefund = true,
CounterpartiesView = true, CounterpartiesEdit = true, CounterpartiesDelete = true,
StocksView = true, InventoryEdit = true, LossEdit = true, EnterEdit = true,
ReportsView = true, ReportsFinanceView = true, ReportsStockView = true,
OrgSettingsManage = true, EmployeesManage = true, RolesManage = true,
StoresManage = true, RetailPointsManage = true,
CashRegistersManage = true, IntegrationsManage = true,
};
}

View file

@ -0,0 +1,20 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Organizations;
/// <summary>Журнал действий SuperAdmin'а: создание/правка/архивирование
/// организаций, смена аккаунт-владельца, правки в режиме «войти как».
/// Не tenant-scoped — лог общий для всей системы.</summary>
public class SuperAdminAuditLog : Entity
{
public Guid SuperAdminUserId { get; set; }
public string ActionType { get; set; } = "";
public Guid? OrganizationId { get; set; }
public string? EntityType { get; set; }
public Guid? EntityId { get; set; }
public string? Description { get; set; }
public string? Reason { get; set; }
/// <summary>JSON с diff'ом before/after или другим payload'ом действия.</summary>
public string ChangesJson { get; set; } = "{}";
public string IpAddress { get; set; } = "";
}

View file

@ -0,0 +1,15 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Organizations;
/// <summary>Глобальные настройки платформы (single-row table). Управляются
/// SuperAdmin'ом в /super-admin/settings. Не tenant-scoped — действует на всю
/// систему. Расширяется добавлением новых типизированных полей по мере
/// необходимости (KISS — без key-value generic'а).</summary>
public class SystemSettings : Entity
{
/// <summary>Сколько дней должен пройти после архивации, прежде чем
/// SuperAdmin может удалить организацию навсегда. 0 = удалять сразу
/// после архивации (для dev/staging). По умолчанию 30 (prod-safe).</summary>
public int ArchiveRetentionDays { get; set; } = 30;
}

View file

@ -0,0 +1,62 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Purchases;
public enum SupplyStatus
{
Draft = 0,
Posted = 1,
}
public class Supply : TenantEntity
{
/// <summary>Human-readable document number, unique per organization (e.g. "П-2026-000001").</summary>
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public SupplyStatus Status { get; set; } = SupplyStatus.Draft;
public Guid SupplierId { get; set; }
public Counterparty Supplier { get; set; } = null!;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
public Guid CurrencyId { get; set; }
public Currency Currency { get; set; } = null!;
public string? Notes { get; set; }
/// <summary>Sum of line totals. Computed on save.</summary>
public decimal Total { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
public ICollection<SupplyLine> Lines { get; set; } = new List<SupplyLine>();
}
public class SupplyLine : TenantEntity
{
public Guid SupplyId { get; set; }
public Supply Supply { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal LineTotal { get; set; }
public int SortOrder { get; set; }
/// <summary>Если true — пользователь вручную задал розничную цену для
/// этой строки (через UI приёмки). При Posting автонаценка по Group.MarkupPercent
/// для этой строки пропускается.</summary>
public bool RetailPriceManuallyOverridden { get; set; }
/// <summary>Розничная цена, которую пользователь вписал в колонке «Розничная»
/// строки приёмки. Применяется к Product.Prices[default] при Posting.</summary>
public decimal? RetailPriceOverride { get; set; }
}

View file

@ -0,0 +1,70 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Sales;
public enum RetailSaleStatus
{
Draft = 0,
Posted = 1,
}
public enum PaymentMethod
{
Cash = 0,
Card = 1,
BankTransfer = 2,
Bonus = 3,
Mixed = 99,
}
public class RetailSale : TenantEntity
{
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public RetailSaleStatus Status { get; set; } = RetailSaleStatus.Draft;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
public Guid? RetailPointId { get; set; }
public RetailPoint? RetailPoint { get; set; }
public Guid? CustomerId { get; set; }
public Counterparty? Customer { get; set; }
public Guid? CashierUserId { get; set; }
public Guid CurrencyId { get; set; }
public Currency Currency { get; set; } = null!;
public decimal Subtotal { get; set; } // sum of LineTotal before discount
public decimal DiscountTotal { get; set; }
public decimal Total { get; set; } // = Subtotal - DiscountTotal
public PaymentMethod Payment { get; set; } = PaymentMethod.Cash;
public decimal PaidCash { get; set; }
public decimal PaidCard { get; set; }
public string? Notes { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
public ICollection<RetailSaleLine> Lines { get; set; } = new List<RetailSaleLine>();
}
public class RetailSaleLine : TenantEntity
{
public Guid RetailSaleId { get; set; }
public RetailSale RetailSale { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal Discount { get; set; }
public decimal LineTotal { get; set; } // = Quantity * UnitPrice - Discount
public decimal VatPercent { get; set; } // snapshot
public int SortOrder { get; set; }
}

View file

@ -0,0 +1,47 @@
using System.Collections.Concurrent;
namespace foodmarket.Infrastructure.Integrations.MoySklad;
public enum ImportJobStatus { Running, Succeeded, Failed, Cancelled }
public class ImportJobProgress
{
public Guid Id { get; init; } = Guid.NewGuid();
public string Kind { get; init; } = ""; // "products" | "counterparties"
public DateTime StartedAt { get; init; } = DateTime.UtcNow;
public DateTime? FinishedAt { get; set; }
public ImportJobStatus Status { get; set; } = ImportJobStatus.Running;
public string? Stage { get; set; } // человекочитаемое описание текущего шага
public int Total { get; set; } // входящих записей от MS (растёт по мере пейджинга)
public int Created { get; set; }
public int Updated { get; set; }
public int Skipped { get; set; }
public int Deleted { get; set; } // для cleanup
public int GroupsCreated { get; set; }
public string? Message { get; set; } // последняя ошибка / финальное сообщение
public List<string> Errors { get; set; } = [];
}
// Process-memory реестр прогресса фоновых импортов. Один процесс API — одно DI singleton.
// При рестарте контейнера история импортов теряется — для просмотра «вчерашнего» надо
// смотреть логи. На MVP достаточно.
public class ImportJobRegistry
{
private readonly ConcurrentDictionary<Guid, ImportJobProgress> _jobs = new();
public ImportJobProgress Create(string kind)
{
var job = new ImportJobProgress { Kind = kind };
_jobs[job.Id] = job;
return job;
}
public ImportJobProgress? Get(Guid id) => _jobs.TryGetValue(id, out var j) ? j : null;
public IReadOnlyList<ImportJobProgress> RecentlyFinished(int take = 10) =>
_jobs.Values
.Where(j => j.FinishedAt is not null)
.OrderByDescending(j => j.FinishedAt)
.Take(take)
.ToList();
}

View file

@ -0,0 +1,140 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace foodmarket.Infrastructure.Integrations.MoySklad;
public record MoySkladApiResult<T>(bool Success, T? Value, int? StatusCode, string? Error)
{
public static MoySkladApiResult<T> Ok(T value) => new(true, value, 200, null);
public static MoySkladApiResult<T> Fail(int status, string? error) => new(false, default, status, error);
}
// Thin HTTP wrapper over MoySklad JSON-API 1.2. Caller supplies the personal token per request
// — we never persist it.
public class MoySkladClient
{
// Trailing slash is critical: otherwise HttpClient drops the last path segment
// when resolving relative URIs (RFC 3986 §5.3), so "entity/product" would hit
// "/api/remap/entity/product" instead of "/api/remap/1.2/entity/product".
private const string BaseUrl = "https://api.moysklad.ru/api/remap/1.2/";
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private readonly HttpClient _http;
public MoySkladClient(HttpClient http)
{
_http = http;
_http.BaseAddress ??= new Uri(BaseUrl);
_http.Timeout = TimeSpan.FromSeconds(90);
}
private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string token)
{
var req = new HttpRequestMessage(method, pathAndQuery);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
// MoySklad requires the exact literal "application/json;charset=utf-8" (no space
// after ';'). The typed MediaTypeWithQualityHeaderValue API normalizes to
// "application/json; charset=utf-8" which MoySklad rejects with code 1062.
req.Headers.TryAddWithoutValidation("Accept", "application/json;charset=utf-8");
// MoySklad's nginx edge returns 415 for requests without a User-Agent, and we want
// auto-decompression (Accept-Encoding is added automatically by HttpClient when
// AutomaticDecompression is set on the primary handler — see Program.cs).
if (!req.Headers.UserAgent.Any())
{
req.Headers.TryAddWithoutValidation("User-Agent", "food-market/0.1 (+https://github.com/nurdotnet/food-market)");
}
return req;
}
public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, CancellationToken ct)
{
using var req = Build(HttpMethod.Get, "entity/organization?limit=1", token);
using var res = await _http.SendAsync(req, ct);
if (!res.IsSuccessStatusCode)
{
var body = await res.Content.ReadAsStringAsync(ct);
return MoySkladApiResult<MsOrganization>.Fail((int)res.StatusCode, body);
}
var list = await res.Content.ReadFromJsonAsync<MsListResponse<MsOrganization>>(Json, ct);
var org = list?.Rows.FirstOrDefault();
return org is null
? MoySkladApiResult<MsOrganization>.Fail(200, "Empty organization list returned by MoySklad.")
: MoySkladApiResult<MsOrganization>.Ok(org);
}
// MoySklad list endpoints по умолчанию возвращают только активных (archived=false).
// Чтобы получить архивных, нужен явный filter=archived=true. Делаем два прохода:
// сначала активные (default), затем архивные — и отдаём всё одним потоком.
public async IAsyncEnumerable<MsProduct> StreamProductsAsync(
string token,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: false, ct)) yield return p;
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: true, ct)) yield return p;
}
public async IAsyncEnumerable<MsCounterparty> StreamCounterpartiesAsync(
string token,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: false, ct)) yield return c;
await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: true, ct)) yield return c;
}
public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct)
{
var all = new List<MsProductFolder>();
await foreach (var f in StreamPagedAsync<MsProductFolder>(token, "entity/productfolder", archivedOnly: false, ct)) all.Add(f);
await foreach (var f in StreamPagedAsync<MsProductFolder>(token, "entity/productfolder", archivedOnly: true, ct)) all.Add(f);
return all;
}
private async IAsyncEnumerable<T> StreamPagedAsync<T>(
string token,
string path,
bool archivedOnly,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
const int pageSize = 1000;
const int maxAttempts = 5;
var offset = 0;
var filterSuffix = archivedOnly ? "&filter=archived=true" : "";
while (true)
{
MsListResponse<T>? page = null;
Exception? lastErr = null;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
using var req = Build(HttpMethod.Get, $"{path}?limit={pageSize}&offset={offset}{filterSuffix}", token);
using var res = await _http.SendAsync(req, ct);
res.EnsureSuccessStatusCode();
page = await res.Content.ReadFromJsonAsync<MsListResponse<T>>(Json, ct);
lastErr = null;
break;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException)
{
lastErr = ex;
if (attempt == maxAttempts) break;
// Exponential-ish backoff: 2s, 4s, 8s, 16s.
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct);
}
}
if (lastErr is not null)
{
// Re-throw after retries so the caller sees a real failure instead of silent halt.
throw new InvalidOperationException(
$"MoySklad paging failed at {path} offset={offset} after {maxAttempts} attempts: {lastErr.Message}",
lastErr);
}
if (page is null || page.Rows.Count == 0) yield break;
foreach (var row in page.Rows) yield return row;
if (page.Rows.Count < pageSize) yield break;
offset += pageSize;
}
}
}

View file

@ -0,0 +1,145 @@
using System.Text.Json.Serialization;
namespace foodmarket.Infrastructure.Integrations.MoySklad;
// Minimal MoySklad JSON-API response shapes we need for read-only product import.
// See https://dev.moysklad.ru/doc/api/remap/1.2/ — we intentionally ignore fields we don't consume.
public class MsListResponse<T>
{
[JsonPropertyName("meta")] public MsListMeta? Meta { get; set; }
[JsonPropertyName("rows")] public List<T> Rows { get; set; } = [];
}
public class MsListMeta
{
[JsonPropertyName("size")] public int Size { get; set; }
[JsonPropertyName("limit")] public int Limit { get; set; }
[JsonPropertyName("offset")] public int Offset { get; set; }
[JsonPropertyName("href")] public string? Href { get; set; }
}
public class MsMeta
{
[JsonPropertyName("href")] public string? Href { get; set; }
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("uuidHref")] public string? UuidHref { get; set; }
}
public class MsMetaWrapper
{
[JsonPropertyName("meta")] public MsMeta? Meta { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("code")] public string? Code { get; set; }
}
public class MsOrganization
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("inn")] public string? Inn { get; set; }
[JsonPropertyName("kpp")] public string? Kpp { get; set; }
}
public class MsProduct
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("article")] public string? Article { get; set; }
[JsonPropertyName("code")] public string? Code { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("weighed")] public bool Weighed { get; set; }
[JsonPropertyName("vat")] public int? Vat { get; set; }
[JsonPropertyName("vatEnabled")] public bool? VatEnabled { get; set; }
[JsonPropertyName("archived")] public bool Archived { get; set; }
[JsonPropertyName("barcodes")] public List<Dictionary<string, string>>? Barcodes { get; set; }
[JsonPropertyName("salePrices")] public List<MsSalePrice>? SalePrices { get; set; }
[JsonPropertyName("buyPrice")] public MsMoney? BuyPrice { get; set; }
[JsonPropertyName("minPrice")] public MsMoney? MinPrice { get; set; }
[JsonPropertyName("uom")] public MsMetaWrapper? Uom { get; set; }
[JsonPropertyName("productFolder")] public MsMetaWrapper? ProductFolder { get; set; }
[JsonPropertyName("country")] public MsMetaWrapper? Country { get; set; }
[JsonPropertyName("alcoholic")] public MsAlcoholic? Alcoholic { get; set; }
[JsonPropertyName("tnved")] public string? Tnved { get; set; }
[JsonPropertyName("trackingType")] public string? TrackingType { get; set; }
}
public class MsSalePrice
{
[JsonPropertyName("value")] public decimal Value { get; set; } // minor units (копейки/тиын) — MoySklad may return fractional
[JsonPropertyName("currency")] public MsMetaWrapper? Currency { get; set; }
[JsonPropertyName("priceType")] public MsPriceType? PriceType { get; set; }
}
public class MsPriceType
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("externalCode")] public string? ExternalCode { get; set; }
}
public class MsMoney
{
[JsonPropertyName("value")] public decimal Value { get; set; }
[JsonPropertyName("currency")] public MsMetaWrapper? Currency { get; set; }
}
public class MsAlcoholic
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("strength")] public double? Strength { get; set; }
}
public class MsCurrency
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("isoCode")] public string? IsoCode { get; set; }
[JsonPropertyName("rate")] public double? Rate { get; set; }
}
public class MsUom
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("code")] public string? Code { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
}
public class MsProductFolder
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("pathName")] public string? PathName { get; set; }
[JsonPropertyName("productFolder")] public MsMetaWrapper? ParentFolder { get; set; }
[JsonPropertyName("archived")] public bool Archived { get; set; }
}
public class MsCountry
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("code")] public string? Code { get; set; }
[JsonPropertyName("externalCode")] public string? ExternalCode { get; set; }
}
public class MsCounterparty
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("legalTitle")] public string? LegalTitle { get; set; }
[JsonPropertyName("legalAddress")] public string? LegalAddress { get; set; }
[JsonPropertyName("actualAddress")] public string? ActualAddress { get; set; }
[JsonPropertyName("inn")] public string? Inn { get; set; }
[JsonPropertyName("kpp")] public string? Kpp { get; set; }
[JsonPropertyName("ogrn")] public string? Ogrn { get; set; }
[JsonPropertyName("companyType")] public string? CompanyType { get; set; }
[JsonPropertyName("phone")] public string? Phone { get; set; }
[JsonPropertyName("email")] public string? Email { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("archived")] public bool Archived { get; set; }
[JsonPropertyName("tags")] public List<string>? Tags { get; set; }
}

View file

@ -0,0 +1,378 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace foodmarket.Infrastructure.Integrations.MoySklad;
public record MoySkladImportResult(
int Total,
int Created,
int Skipped,
int GroupsCreated,
IReadOnlyList<string> Errors);
public class MoySkladImportService
{
private readonly MoySkladClient _client;
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly ILogger<MoySkladImportService> _log;
public MoySkladImportService(
MoySkladClient client,
AppDbContext db,
ITenantContext tenant,
ILogger<MoySkladImportService> log)
{
_client = client;
_db = db;
_tenant = tenant;
_log = log;
}
public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct)
=> _client.WhoAmIAsync(token, ct);
public async Task<MoySkladImportResult> ImportCounterpartiesAsync(
string token,
bool overwriteExisting,
CancellationToken ct,
ImportJobProgress? progress = null,
Guid? organizationIdOverride = null)
{
var orgId = organizationIdOverride ?? _tenant.OrganizationId
?? throw new InvalidOperationException("No tenant organization in context.");
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще —
// counterparty entity содержит только group (группа доступа), tags
// (произвольные), state (пользовательская цепочка статусов), companyType
// (legal/individual/entrepreneur). Никакого role/kind. Поэтому у нас тоже
// этого поля нет — пусть пользователь сам решит.
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
=> companyType switch
{
"individual" or "entrepreneur" => foodmarket.Domain.Catalog.CounterpartyType.Individual,
_ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity,
};
// Загружаем существующих в память — обновлять будем по имени (case-insensitive).
// Раньше при overwriteExisting=true бага: пропускалась проверка "skip" и ВСЕ
// поступающие записи добавлялись как новые, порождая дубли. Теперь если имя уже
// есть — обновляем ту же запись, иначе создаём.
var existingByName = await _db.Counterparties
.ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct);
var created = 0;
var updated = 0;
var skipped = 0;
var total = 0;
var errors = new List<string>();
var batch = 0;
await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
{
total++;
if (progress is not null) progress.Total = total;
// Архивных не пропускаем — импортируем как IsActive=false (см. ApplyCounterparty).
try
{
if (existingByName.TryGetValue(c.Name, out var existing))
{
if (!overwriteExisting) { skipped++; if (progress is not null) progress.Skipped = skipped; continue; }
ApplyCounterparty(existing, c, ResolveType);
updated++;
if (progress is not null) progress.Updated = updated;
}
else
{
var entity = new foodmarket.Domain.Catalog.Counterparty { OrganizationId = orgId };
ApplyCounterparty(entity, c, ResolveType);
_db.Counterparties.Add(entity);
existingByName[c.Name] = entity;
created++;
if (progress is not null) progress.Created = created;
}
batch++;
if (batch >= 100)
{
await _db.SaveChangesAsync(ct);
batch = 0;
}
}
catch (Exception ex)
{
_log.LogWarning(ex, "Failed to import counterparty {Name}", c.Name);
errors.Add($"{c.Name}: {ex.Message}");
if (progress is not null) progress.Errors = errors;
}
}
if (batch > 0) await _db.SaveChangesAsync(ct);
// `created` в отчёте = вставки + апдейты (чтобы не ломать UI, который знает только Created).
return new MoySkladImportResult(total, created + updated, skipped, 0, errors);
}
private static void ApplyCounterparty(
foodmarket.Domain.Catalog.Counterparty entity,
MsCounterparty c,
Func<string?, foodmarket.Domain.Catalog.CounterpartyType> resolveType)
{
entity.Name = Trim(c.Name, 255) ?? c.Name;
entity.LegalName = Trim(c.LegalTitle, 500);
entity.Type = resolveType(c.CompanyType);
entity.Bin = Trim(c.Inn, 20);
entity.TaxNumber = Trim(c.Kpp, 20);
entity.Phone = Trim(c.Phone, 50);
entity.Email = Trim(c.Email, 255);
entity.Address = Trim(c.ActualAddress ?? c.LegalAddress, 500);
entity.Notes = Trim(c.Description, 1000);
}
public async Task<MoySkladImportResult> ImportProductsAsync(
string token,
bool overwriteExisting,
CancellationToken ct,
ImportJobProgress? progress = null,
Guid? organizationIdOverride = null)
{
var orgId = organizationIdOverride ?? _tenant.OrganizationId
?? throw new InvalidOperationException("No tenant organization in context.");
// Дефолт VAT — из страны организации (Country.VatRate), decimal(5,2).
var defaultVat = await _db.Countries
.Where(c => c.Code == (_db.Organizations
.Where(o => o.Id == orgId).Select(o => o.CountryCode).First()))
.Select(c => (decimal?)c.VatRate).FirstOrDefaultAsync(ct) ?? 16m;
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", ct)
?? await _db.UnitsOfMeasure.FirstAsync(ct);
var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct)
?? await _db.PriceTypes.FirstAsync(ct);
var kzt = await _db.Currencies.FirstAsync(c => c.Code == "KZT", ct);
var countriesByName = await _db.Countries
.IgnoreQueryFilters()
.ToDictionaryAsync(c => c.Name, c => c.Id, ct);
// Дефолтная группа на случай, когда у товара в MoySklad нет productFolder.
var defaultGroup = await _db.ProductGroups.FirstOrDefaultAsync(g => g.Name == "Продукты питания", ct);
if (defaultGroup is null)
{
defaultGroup = new ProductGroup
{
OrganizationId = orgId,
Name = "Продукты питания",
Path = "Продукты питания",
};
_db.ProductGroups.Add(defaultGroup);
await _db.SaveChangesAsync(ct);
}
var defaultGroupId = defaultGroup.Id;
// Import folders first — build flat then link parents. Архивные тоже берём,
// помечаем IsActive=false — у MoySklad у productfolder есть archived.
var folders = await _client.GetAllFoldersAsync(token, ct);
var localGroupByMsId = new Dictionary<string, Guid>();
var groupsCreated = 0;
foreach (var f in folders.OrderBy(f => f.PathName?.Length ?? 0))
{
if (f.Id is null) continue;
var existing = await _db.ProductGroups.FirstOrDefaultAsync(
g => g.Name == f.Name && g.Path == (f.PathName ?? f.Name), ct);
if (existing is not null)
{
localGroupByMsId[f.Id] = existing.Id;
continue;
}
var g = new ProductGroup
{
OrganizationId = orgId,
Name = f.Name,
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
};
_db.ProductGroups.Add(g);
localGroupByMsId[f.Id] = g.Id;
groupsCreated++;
}
if (groupsCreated > 0) await _db.SaveChangesAsync(ct);
if (progress is not null) progress.GroupsCreated = groupsCreated;
// Import products
var errors = new List<string>();
var created = 0;
var updated = 0;
var skipped = 0;
var total = 0;
// При overwriteExisting=true загружаем товары целиком, чтобы обновлять существующие
// вместо создания дубликатов. Ключ = артикул (нормализованный).
var existingByArticle = await _db.Products
.Where(p => p.Article != null)
.ToDictionaryAsync(p => p.Article!, p => p, StringComparer.OrdinalIgnoreCase, ct);
var existingBarcodeSet = new HashSet<string>(
await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct));
await foreach (var p in _client.StreamProductsAsync(token, ct))
{
total++;
if (progress is not null) progress.Total = total;
// Архивных не пропускаем — импортируем как IsActive=false.
var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article;
var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingByArticle.ContainsKey(article);
if (alreadyByArticle && !overwriteExisting)
{
skipped++;
if (progress is not null) progress.Skipped = skipped;
continue;
}
try
{
// Vat товара: из MoySklad.vat если задано, иначе дефолт страны.
// VatEnabled: приоритет p.VatEnabled, fallback — «без НДС» если p.Vat=0.
var vat = p.Vat.HasValue ? (decimal)p.Vat.Value : defaultVat;
var vatEnabled = p.VatEnabled ?? (p.Vat is null || p.Vat.Value != 0);
Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId
&& localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null;
Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null;
var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true)
?? p.SalePrices?.FirstOrDefault();
Product product;
if (alreadyByArticle && overwriteExisting)
{
product = existingByArticle[article!];
// Обновляем только скалярные поля — коллекции (prices, barcodes) оставляем:
// там могут быть данные, которые редактировал пользователь после импорта.
product.Name = Trim(p.Name, 500);
product.Article = Trim(article, 500);
product.Description = p.Description;
product.Vat = vat;
product.VatEnabled = vatEnabled;
product.ProductGroupId = groupId ?? product.ProductGroupId;
product.CountryOfOriginId = countryId ?? product.CountryOfOriginId;
product.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece;
product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED";
product.ReferencePrice = p.BuyPrice is null ? product.ReferencePrice : p.BuyPrice.Value / 100m;
updated++;
if (progress is not null) progress.Updated = updated;
}
else
{
product = new Product
{
OrganizationId = orgId,
Name = Trim(p.Name, 500),
Article = Trim(article, 500),
Description = p.Description,
UnitOfMeasureId = baseUnit.Id,
Vat = vat,
VatEnabled = vatEnabled,
ProductGroupId = groupId ?? defaultGroupId,
CountryOfOriginId = countryId,
Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece,
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
ReferencePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
PurchaseCurrencyId = kzt.Id,
};
if (retailPrice is not null)
{
product.Prices.Add(new ProductPrice
{
OrganizationId = orgId,
PriceTypeId = retailType.Id,
Amount = retailPrice.Value / 100m,
CurrencyId = kzt.Id,
});
}
foreach (var b in ExtractBarcodes(p))
{
if (existingBarcodeSet.Contains(b.Code))
{
errors.Add($"{p.Name}: штрихкод {b.Code} уже занят, пропущен.");
if (progress is not null) progress.Errors = errors;
continue;
}
product.Barcodes.Add(b);
existingBarcodeSet.Add(b.Code);
}
_db.Products.Add(product);
if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product;
created++;
if (progress is not null) progress.Created = created;
}
// Flush чаще (каждые 100) чтобы при сетевом обрыве на следующей странице
// мы сохранили как можно больше и смогли безопасно продолжить с overwrite.
if ((created + updated) % 100 == 0) await _db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
_log.LogWarning(ex, "Failed to import MoySklad product {Name}", p.Name);
errors.Add($"{p.Name}: {ex.Message}");
if (progress is not null) progress.Errors = errors;
}
}
await _db.SaveChangesAsync(ct);
// Финальная проверка дубликатов штрихкодов (исторические записи или
// расхождения c уникальным индексом). Только warning в errors[].
var duplicates = await _db.ProductBarcodes
.GroupBy(b => b.Code)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToListAsync(ct);
foreach (var dup in duplicates)
errors.Add($"Внимание: штрихкод {dup} привязан к нескольким товарам — почисти вручную.");
if (progress is not null && duplicates.Count > 0) progress.Errors = errors;
return new MoySkladImportResult(total, created + updated, skipped, groupsCreated, errors);
}
private static List<ProductBarcode> ExtractBarcodes(MsProduct p)
{
if (p.Barcodes is null) return [];
var list = new List<ProductBarcode>();
var primarySet = false;
foreach (var entry in p.Barcodes)
{
foreach (var (kind, code) in entry)
{
if (string.IsNullOrWhiteSpace(code)) continue;
var type = kind switch
{
"ean13" => BarcodeType.Ean13,
"ean8" => BarcodeType.Ean8,
"code128" => BarcodeType.Code128,
"gtin" => BarcodeType.Ean13,
"upca" => BarcodeType.Upca,
"upce" => BarcodeType.Upce,
_ => BarcodeType.Other,
};
list.Add(new ProductBarcode { Code = code.Length > 500 ? code[..500] : code, Type = type, IsPrimary = !primarySet });
primarySet = true;
}
}
return list;
}
private static string? Trim(string? s, int max)
=> string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max]);
private static string? TryExtractId(string href)
{
// href like "https://api.moysklad.ru/api/remap/1.2/entity/productfolder/<guid>"
var lastSlash = href.LastIndexOf('/');
return lastSlash >= 0 ? href[(lastSlash + 1)..] : null;
}
}

View file

@ -0,0 +1,68 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Infrastructure.Inventory;
public class StockService : IStockService
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public StockService(AppDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
public async Task<decimal> ApplyMovementAsync(StockMovementDraft d, CancellationToken ct = default)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("Tenant not set.");
_db.StockMovements.Add(new StockMovement
{
OrganizationId = orgId,
ProductId = d.ProductId,
StoreId = d.StoreId,
Quantity = d.Quantity,
UnitCost = d.UnitCost,
Type = d.Type,
DocumentType = d.DocumentType,
DocumentId = d.DocumentId,
DocumentNumber = d.DocumentNumber,
OccurredAt = d.OccurredAt ?? DateTime.UtcNow,
CreatedByUserId = d.CreatedByUserId,
Notes = d.Notes,
});
var stock = await _db.Stocks.FirstOrDefaultAsync(
s => s.ProductId == d.ProductId && s.StoreId == d.StoreId, ct);
if (stock is null)
{
stock = new Stock
{
OrganizationId = orgId,
ProductId = d.ProductId,
StoreId = d.StoreId,
Quantity = d.Quantity,
};
_db.Stocks.Add(stock);
}
else
{
stock.Quantity += d.Quantity;
}
return stock.Quantity;
}
public async Task<decimal> ApplyMovementsAsync(IEnumerable<StockMovementDraft> drafts, CancellationToken ct = default)
{
var last = 0m;
foreach (var d in drafts) last = await ApplyMovementAsync(d, ct);
return last;
}
}

Some files were not shown because too many files have changed in this diff Show more