Compare commits

..

23 commits

Author SHA1 Message Date
nurdotnet 57e8491f0d fix(moysklad/test): сделать Token опциональным
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 29s
CI / Web (React + Vite) (push) Successful in 24s
Docker Images / API image (push) Successful in 37s
Docker Images / Web image (push) Successful in 6s
Docker Images / Deploy stage (push) Successful in 18s
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 2fc6d207f3 feat(moysklad-import): async jobs с прогрессом + токен в настройках
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 36s
CI / Web (React + Vite) (push) Successful in 23s
Docker Images / API image (push) Successful in 42s
Docker Images / Web image (push) Successful in 25s
Docker Images / Deploy stage (push) Successful in 18s
Pain points:
1. Импорт на ~30k товарах проходит 15-30 мин, nginx рвал на 60s → 504.
2. При импорте/очистке ничего не видно — ни счётчика, ни прогресса.
3. Токен приходилось вводить каждый раз вручную.

Фиксы:
- Async-job pattern: POST /api/admin/moysklad/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.
- MoySkladImportService обновляет progress по мере пейджинга
  (в т.ч. счётчик Created/Updated/Skipped).
- Cleanup разбит на именованные шаги, Stage меняется по мере
  "Товары…" → "Группы…" → "Контрагенты…".
- Токен per-organization: Organization.MoySkladToken + миграция
  Phase3_OrganizationMoySkladToken. Endpoints:
  GET/PUT /api/admin/moysklad/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:
- MoySkladImportPage переработан: блок "Токен 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 bd15854b42 fix(moysklad/import): per-page retry + чаще SaveChanges
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 27s
CI / Web (React + Vite) (push) Successful in 23s
Docker Images / API image (push) Successful in 35s
Docker Images / Web image (push) Successful in 5s
Docker Images / Deploy stage (push) Successful in 17s
Почему импорт раньше обрывался на ~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 69e6fd808a feat(catalog/products): tree-of-groups + фильтры как в MoySklad
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 29s
CI / Web (React + Vite) (push) Successful in 23s
Docker Images / API image (push) Successful in 36s
Docker Images / Web image (push) Successful in 26s
Docker Images / Deploy stage (push) Successful in 18s
Что добавлено:
- Слева дерево товарных групп (рекурсивное, с раскрытием), клик
  переключает фильтр ProductsPage. Клик на "Все товары" — показать весь
  каталог. Выбор группы включает её поддерево (матчинг по Path prefix
  на бэкенде, чтобы сабгруппы тоже попадали в выборку).
- Кнопка "Фильтры" разворачивает верхнюю панель с тумблерами
  (all/да/нет): Активные, Услуга, Весовой, Маркируемый, Со штрихкодом.
  Счётчик в кнопке показывает количество активных не-дефолтных фильтров.
- "Сбросить" очищает всё, кроме группы.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:18:50 +05:00
nurdotnet beae0ad604 feat(moysklad): import archived entities too (as IsActive=false)
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 50s
CI / Web (React + Vite) (push) Successful in 25s
Docker Images / API image (push) Successful in 36s
Docker Images / Web image (push) Successful in 6s
Docker Images / Deploy stage (push) Successful in 18s
Раньше архивных контрагентов/товаров MoySklad API по умолчанию не
возвращает (default filter = active only). Для полной синхронизации
MoySklad → 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)
вместо трёх копий постраничного цикла.

Теперь пользовательский MoySklad-каталог мапится в food-market 1:1
включая архив. Счётчик "Пропущено" отныне значит только "уже существует
и галка Перезаписать не стоит".
2026-04-23 21:35:44 +05:00
nurdotnet 5f0692587a fix(db): reconcile stage schema — drop TrackingType, add IsMarked
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 39s
CI / Web (React + Vite) (push) Successful in 22s
Docker Images / API image (push) Successful in 36s
Docker Images / Web image (push) Successful in 4s
Docker Images / Deploy stage (push) Successful in 18s
Phase2c2_MoySkladAlignment и Phase2c3_MsStrict остались в
__EFMigrationsHistory на стейдже, но .cs-файлы были удалены при откате
кода (8fc9ef1). В результате:
- снапшот не соответствовал актуальной БД
- колонка TrackingType висела в БД, а код ждал IsMarked
- /api/admin/moysklad/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 8346c9a72e feat(admin): temp cleanup buttons + fix MoySklad import duplicates
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 35s
CI / Web (React + Vite) (push) Successful in 23s
Docker Images / API image (push) Successful in 1m10s
Docker Images / Web image (push) Successful in 41s
Docker Images / Deploy stage (push) Successful in 18s
Проблема: при импорте контрагентов/товаров с галкой «перезаписать» код
ставил 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/moysklad с двумя
красными кнопками + подтверждение словом «УДАЛИТЬ». Показываются счётчики
до и что удалилось после.
2026-04-23 20:58:59 +05:00
nurdotnet 9891280bfd deploy: mirror all base images into local registry — builds no longer need internet
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 35s
CI / Web (React + Vite) (push) Successful in 24s
Docker Images / API image (push) Successful in 6s
Docker Images / Web image (push) Successful in 5s
Docker Images / Deploy stage (push) Successful in 29s
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 8fc9ef1a2e feat: strict MoySklad schema — реплика потерянного f7087e9
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 27s
CI / Web (React + Vite) (push) Successful in 23s
Docker Images / API image (push) Successful in 34s
Docker Images / Web image (push) Successful in 27s
Docker Images / Deploy stage (push) Successful in 15s
Main расходился с БД стейджа (Phase2c3_MsStrict в history, но код ещё ссылался на VatRate etc.) — деплой ломался. Реплицирую удаление сущностей вручную, чтобы код совпадал с таблицами.

Убрано (нет в MoySklad — не выдумываем):
- 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.

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

MoySkladImportService:
- 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 3fd2f8a223 ci: disable .github/workflows — Forgejo Actions is the primary CI now
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 42s
CI / Web (React + Vite) (push) Successful in 23s
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 6ab8ff00d1 ci(forgejo): guard deploy-stage — main code ≠ stage DB schema
Some checks failed
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Has been cancelled
Docker Images / API image (push) Successful in 5s
Docker Images / Web image (push) Successful in 6s
Docker Images / Deploy stage (push) Has been skipped
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 MoySklad 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 41fe088586 ci(forgejo): fold deploy-stage into docker workflow via needs
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 25s
CI / Web (React + Vite) (push) Successful in 21s
Docker Images / API image (push) Successful in 6s
Docker Images / Web image (push) Successful in 6s
Docker Images / Deploy stage (push) Failing after 39s
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 82d74bd8fe ci(forgejo/docker): drop ghcr push — Forgejo GITHUB_TOKEN can't auth to ghcr.io
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 24s
CI / Web (React + Vite) (push) Successful in 23s
Docker Images / API image (push) Successful in 6s
Docker Images / Web image (push) Successful in 27s
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 e408647b4b ci(forgejo): trigger Docker Images workflow for first Forgejo run
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 24s
CI / Web (React + Vite) (push) Successful in 24s
Docker Images / API image (push) Failing after 1m25s
Docker Images / Web image (push) Has been cancelled
2026-04-23 16:43:13 +05:00
nurdotnet 326af2f361 ci(forgejo): retrigger — dotnet 8.0.420 now on default path
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 26s
CI / Web (React + Vite) (push) Successful in 23s
2026-04-23 16:41:44 +05:00
nurdotnet 50f6db8569 ci(forgejo): retrigger after dotnet sdk 8.0.420 available system-wide
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Failing after 8s
CI / Web (React + Vite) (push) Successful in 23s
2026-04-23 16:25:08 +05:00
nurdotnet 2b0a677221 ci(forgejo): drop setup-dotnet/setup-node/pnpm actions on Forgejo
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Failing after 4s
CI / Web (React + Vite) (push) Successful in 27s
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 3c17b963f3 ci(forgejo): mirror .github/workflows to .forgejo/workflows
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Failing after 1s
CI / Web (React + Vite) (push) Failing after 8s
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 495f0aabee docs: audit of our domain entities vs. live MoySklad API
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Failing after 1s
CI / Web (React + Vite) (push) Failing after 1m13s
Cross-checked every entity (Product, Counterparty, Supply, RetailSale,
Stock, Store, RetailPoint, Organization, ProductGroup, Barcode, Price,
PriceType, Country, Currency, VatRate, UoM) against real responses from
MoySklad'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 afbf01304a ops: Forgejo on git.zat.kz as primary, GitHub as mirror
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Images / API image (push) Failing after 15s
Docker Images / Web image (push) Failing after 2s
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 e9a82dd528 fix(moysklad): убираем выдумку Kind полностью — у MoySklad этого поля нет
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Images / API image (push) Waiting to run
Docker Images / Web image (push) Waiting to run
Проверил через API под реальным токеном (entity/counterparty?expand=group,tags):
у MoySklad **нет** поля «Поставщик/Покупатель» у контрагентов вообще. Есть только:
- 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 50f12ef7f0 fix(moysklad): не выдумывать Kind=Both для импортированных контрагентов
У MoySklad НЕТ встроенного поля «Поставщик/Покупатель» у контрагентов —
эта классификация целиком пользовательская через теги или группы. Импорт
ставил Kind=Both дефолтом когда тегов не было, что искажало данные:
все 586 контрагентов на stage стали «Оба», хотя в MoySklad ничего такого
не было.

- 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 d455087bc8 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
76 changed files with 6733 additions and 873 deletions

View file

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

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

@ -0,0 +1,82 @@
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: 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
- 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
- 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,115 @@
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/**'
- '.forgejo/workflows/docker.yml'
workflow_dispatch:
env:
LOCAL_REGISTRY: 127.0.0.1:5001
jobs:
api:
name: API image
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Build + push api
env:
SHA: ${{ github.sha }}
run: |
docker build -f deploy/Dockerfile.api \
-t $LOCAL_REGISTRY/food-market-api:$SHA \
-t $LOCAL_REGISTRY/food-market-api:latest .
for tag in $SHA latest; do
docker push $LOCAL_REGISTRY/food-market-api:$tag
done
web:
name: Web image
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Build + push web
env:
SHA: ${{ github.sha }}
run: |
docker build -f deploy/Dockerfile.web \
-t $LOCAL_REGISTRY/food-market-web:$SHA \
-t $LOCAL_REGISTRY/food-market-web:latest .
for tag in $SHA latest; do
docker push $LOCAL_REGISTRY/food-market-web:$tag
done
deploy-stage:
name: Deploy stage
runs-on: [self-hosted, linux]
needs: [api, web]
steps:
- uses: actions/checkout@v4
- name: Write .env + copy compose (runner = stage host)
env:
SHA: ${{ github.sha }}
PGPASS: ${{ secrets.STAGE_POSTGRES_PASSWORD }}
run: |
cat > /home/nns/food-market-stage/deploy/.env <<ENV
REGISTRY=127.0.0.1:5001
API_TAG=$SHA
WEB_TAG=$SHA
POSTGRES_PASSWORD=$PGPASS
ENV
cp deploy/docker-compose.yml /home/nns/food-market-stage/deploy/docker-compose.yml
- name: docker compose pull + up
working-directory: /home/nns/food-market-stage/deploy
run: |
docker compose pull
docker compose up -d --remove-orphans
- 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 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 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

View file

@ -1,4 +1,5 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build ARG LOCAL_REGISTRY=127.0.0.1:5001
FROM ${LOCAL_REGISTRY}/mirror/dotnet-sdk:8.0 AS build
WORKDIR /src WORKDIR /src
COPY food-market.sln global.json Directory.Build.props Directory.Packages.props ./ COPY food-market.sln global.json Directory.Build.props Directory.Packages.props ./
@ -15,7 +16,7 @@ RUN dotnet restore src/food-market.api/food-market.api.csproj
COPY src/ src/ COPY src/ src/
RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app --no-restore RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime FROM ${LOCAL_REGISTRY}/mirror/dotnet-aspnet:8.0 AS runtime
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl \ RUN apt-get update && apt-get install -y --no-install-recommends curl \

View file

@ -1,4 +1,5 @@
FROM node:20-alpine AS build ARG LOCAL_REGISTRY=127.0.0.1:5001
FROM ${LOCAL_REGISTRY}/mirror/node:20-alpine AS build
WORKDIR /src WORKDIR /src
RUN corepack enable RUN corepack enable
@ -9,7 +10,7 @@ RUN pnpm install --frozen-lockfile
COPY src/food-market.web/ ./ COPY src/food-market.web/ ./
RUN pnpm build RUN pnpm build
FROM nginx:1.27-alpine AS runtime FROM ${LOCAL_REGISTRY}/mirror/nginx:1.27-alpine AS runtime
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist /usr/share/nginx/html COPY --from=build /src/dist /usr/share/nginx/html

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:
@ -54,3 +54,4 @@ volumes:
name: food-market-api-data name: food-market-api-data
api-logs: api-logs:
name: food-market-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"

View file

@ -3,6 +3,21 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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. # API reverse-proxy upstream name "api" resolves in the compose network.
location /api/ { location /api/ {
proxy_pass http://api:8080; proxy_pass http://api:8080;

View file

@ -0,0 +1,432 @@
"""Telegram <-> tmux bridge for controlling a local Claude Code session from a phone.
Reads creds from /etc/food-market/telegram.env (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID).
Only the single whitelisted chat_id is allowed; everything else is silently ignored.
Inbound: each Telegram message is typed into tmux session 'claude' via `tmux send-keys
-t claude -l <text>` followed by an Enter keypress.
Outbound: every poll_interval seconds, capture the current pane, diff against the last
snapshot, filter TUI noise (box-drawing, spinners, the user's own echoed prompt), then
send any remaining text as plain Telegram messages.
"""
from __future__ import annotations
import asyncio
import collections
import logging
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from telegram import Update
from telegram.ext import (
Application,
ApplicationBuilder,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)
ENV_FILE = Path("/etc/food-market/telegram.env")
TMUX_SESSION = os.environ.get("TMUX_SESSION", "claude")
POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL_SEC", "8"))
CAPTURE_HISTORY = int(os.environ.get("CAPTURE_HISTORY_LINES", "200"))
TG_MAX_CHARS = 3500
MAX_SEND_PER_TICK = int(os.environ.get("MAX_SEND_CHARS_PER_TICK", "900"))
ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]")
BOX_CHARS = set("╭╮╰╯│─┌┐└┘├┤┬┴┼║═╔╗╚╝╠╣╦╩╬▌▐█▀▄")
SPINNER_CHARS = set("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣾⣽⣻⢿⡿⣟⣯⣷")
# Claude Code TUI markers
TOOL_CALL_RE = re.compile(r"^\s*[⏺●⏻◯◎⬤]\s+\S")
TOOL_RESULT_RE = re.compile(r"^\s*⎿")
USER_PROMPT_RE = re.compile(r"^>\s?(.*)$")
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
@dataclass
class BridgeState:
chat_id: int
last_snapshot: str = ""
last_sent_text: str = ""
recent_user_inputs: collections.deque = field(
default_factory=lambda: collections.deque(maxlen=50)
)
recently_sent_lines: collections.deque = field(
default_factory=lambda: collections.deque(maxlen=400)
)
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
async def tmux_send_text(session: str, text: str) -> None:
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()}")
async def tmux_capture(session: str) -> str:
proc = await asyncio.create_subprocess_exec(
"tmux", "capture-pane", "-t", session, "-p", "-S", f"-{CAPTURE_HISTORY}",
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"tmux capture-pane failed: {stderr.decode().strip()}")
return stdout.decode("utf-8", errors="replace").rstrip("\n")
def _strip_box(line: str) -> str:
# Drop leading/trailing box-drawing chars and their padding.
while line and (line[0] in BOX_CHARS or (line[0] == " " and len(line) > 1 and line[1] in BOX_CHARS)):
line = line[1:].lstrip()
if not line:
break
while line and (line[-1] in BOX_CHARS or (line[-1] == " " and len(line) > 1 and line[-2] in BOX_CHARS)):
line = line[:-1].rstrip()
if not line:
break
return line
def _is_noise(line: str) -> bool:
s = line.strip()
if not s:
return True
# Lines made of only box/spinner/decoration chars + spaces.
if all(c in BOX_CHARS or c in SPINNER_CHARS or c.isspace() for c in s):
return True
# Claude Code TUI hints.
lowered = s.lower()
if "shift+tab" in lowered or "bypass permissions" in lowered:
return True
if lowered.startswith("? for shortcuts"):
return True
# Spinner + status line ("✻ Thinking…", "· Pondering…").
if s.startswith(("", "", "", "·")) and len(s) < 80:
return True
# Typing indicator prompt ("> " empty or near-empty input box).
if s.startswith(">") and len(s) <= 2:
return True
return False
def clean_text(text: str, recent_user: collections.deque | None = None) -> str:
"""Strip TUI noise, tool-call blocks and echoed user input.
Only the assistant's prose reply should survive.
"""
recent_user = recent_user if recent_user is not None else collections.deque()
out: list[str] = []
in_tool_block = False
for raw in text.splitlines():
line = ANSI_RE.sub("", raw).rstrip()
line = _strip_box(line)
stripped = line.strip()
if not stripped:
in_tool_block = False
out.append("")
continue
# Tool call / tool result blocks — skip the header and any indented follow-ups.
if TOOL_CALL_RE.match(line) or TOOL_RESULT_RE.match(line):
in_tool_block = True
continue
if in_tool_block:
# continuation of a tool block is usually indented; a flush-left line ends it
if line.startswith(" ") or line.startswith("\t"):
continue
in_tool_block = False
# Echo of the user's own prompt ("> hello") — drop it.
m = USER_PROMPT_RE.match(stripped)
if m:
continue
if stripped in recent_user:
continue
if _is_noise(line):
continue
out.append(line)
# collapse runs of blank lines
collapsed: list[str] = []
prev_blank = False
for line in out:
blank = not line.strip()
if blank and prev_blank:
continue
collapsed.append(line)
prev_blank = blank
return "\n".join(collapsed).strip("\n")
def diff_snapshot(prev: str, curr: str) -> str:
"""Return only lines that weren't already present anywhere in the previous snapshot.
Set-based: handles TUI scrolling and partial redraws without re-sending history.
"""
if not prev:
return curr
if prev == curr:
return ""
prev_set = set(prev.splitlines())
new_lines = [ln for ln in curr.splitlines() if ln.rstrip() and ln not in prev_set]
return "\n".join(new_lines)
def chunk_for_telegram(text: str, limit: int = TG_MAX_CHARS) -> list[str]:
if not text:
return []
out: list[str] = []
buf: list[str] = []
buf_len = 0
for line in text.splitlines():
if buf_len + len(line) + 1 > limit and buf:
out.append("\n".join(buf))
buf, buf_len = [], 0
while len(line) > limit:
out.append(line[:limit])
line = line[limit:]
buf.append(line)
buf_len += len(line) + 1
if buf:
out.append("\n".join(buf))
return out
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
state: BridgeState = context.application.bot_data["state"]
if update.effective_chat is None or update.effective_chat.id != state.chat_id:
return
text = (update.message.text or "").strip() if update.message else ""
if not text:
return
# Remember what we sent so we can suppress its echo from the pane capture.
async with state._lock:
state.recent_user_inputs.append(text)
# Also store reasonable substrings in case the TUI wraps or truncates
if len(text) > 40:
state.recent_user_inputs.append(text[:40])
try:
await tmux_send_text(TMUX_SESSION, text)
except Exception as exc: # noqa: BLE001
await update.message.reply_text(f"⚠️ tmux error: {exc}")
async def cmd_ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
state: BridgeState = context.application.bot_data["state"]
if update.effective_chat is None or update.effective_chat.id != state.chat_id:
return
await update.message.reply_text(
f"pong — session '{TMUX_SESSION}', poll {POLL_INTERVAL}s"
)
async def cmd_snapshot(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
state: BridgeState = context.application.bot_data["state"]
if update.effective_chat is None or update.effective_chat.id != state.chat_id:
return
try:
snap = await tmux_capture(TMUX_SESSION)
except Exception as exc: # noqa: BLE001
await update.message.reply_text(f"⚠️ tmux error: {exc}")
return
async with state._lock:
cleaned = clean_text(snap, state.recent_user_inputs)
state.last_snapshot = snap # reset baseline so poller doesn't resend
for part in chunk_for_telegram(cleaned) or ["(nothing to show)"]:
await update.message.reply_text(part)
async def poll_and_forward(application: Application) -> None:
state: BridgeState = application.bot_data["state"]
bot = application.bot
logger = logging.getLogger("bridge.poll")
while True:
await asyncio.sleep(POLL_INTERVAL)
# Stability check: capture twice, ~1.5s apart. If pane still changes, assistant
# is still streaming — skip this tick and try next time.
try:
snap1 = await tmux_capture(TMUX_SESSION)
except Exception as exc: # noqa: BLE001
logger.warning("capture failed: %s", exc)
continue
await asyncio.sleep(1.5)
try:
snap2 = await tmux_capture(TMUX_SESSION)
except Exception as exc: # noqa: BLE001
logger.warning("capture failed: %s", exc)
continue
if snap1 != snap2:
# still being written — don't send partials
continue
snapshot = snap2
async with state._lock:
prev = state.last_snapshot
state.last_snapshot = snapshot
recent_user_copy = list(state.recent_user_inputs)
recently_sent_copy = list(state.recently_sent_lines)
raw_new = diff_snapshot(prev, snapshot)
new_text = clean_text(raw_new, collections.deque(recent_user_copy))
if not new_text:
continue
# Line-level dedup vs. what we already shipped: drop lines that are
# substring-equivalent to a recently sent one (handles streaming dupes).
deduped: list[str] = []
for line in new_text.splitlines():
s = line.rstrip()
if not s.strip():
deduped.append(line)
continue
ss = s.strip()
is_dup = False
for past in recently_sent_copy:
if ss == past:
is_dup = True
break
if len(ss) >= 15 and len(past) >= 15 and (ss in past or past in ss):
is_dup = True
break
if is_dup:
continue
deduped.append(line)
recently_sent_copy.append(ss)
async with state._lock:
state.recently_sent_lines.clear()
state.recently_sent_lines.extend(recently_sent_copy[-400:])
new_text = "\n".join(deduped).strip("\n")
if not new_text:
continue
async with state._lock:
if new_text == state.last_sent_text:
continue
state.last_sent_text = new_text
# Cap total outbound per tick so a big burst doesn't flood Telegram.
if len(new_text) > MAX_SEND_PER_TICK:
keep = new_text[-MAX_SEND_PER_TICK:]
# snap to next newline to avoid cutting mid-line
nl = keep.find("\n")
if 0 <= nl < 200:
keep = keep[nl + 1 :]
dropped = len(new_text) - len(keep)
new_text = f"… (+{dropped} chars earlier)\n{keep}"
for part in chunk_for_telegram(new_text):
try:
await bot.send_message(
chat_id=state.chat_id,
text=part,
disable_notification=True,
)
except Exception as exc: # noqa: BLE001
logger.warning("telegram send failed: %s", exc)
break
async def on_startup(application: Application) -> None:
state: BridgeState = application.bot_data["state"]
try:
state.last_snapshot = await tmux_capture(TMUX_SESSION)
except Exception: # noqa: BLE001
state.last_snapshot = ""
application.bot_data["poll_task"] = asyncio.create_task(
poll_and_forward(application), name="bridge-poll"
)
async def on_shutdown(application: Application) -> None:
task = application.bot_data.get("poll_task")
if task:
task.cancel()
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()
if not token or not chat_id_raw:
print(
"ERROR: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID must be set in "
f"{ENV_FILE} or environment",
file=sys.stderr,
)
return 78
try:
chat_id = int(chat_id_raw)
except ValueError:
print(f"ERROR: TELEGRAM_CHAT_ID must be an integer, got: {chat_id_raw!r}",
file=sys.stderr)
return 78
check = subprocess.run(
["tmux", "has-session", "-t", TMUX_SESSION],
capture_output=True, text=True,
)
if check.returncode != 0:
print(
f"WARNING: tmux session '{TMUX_SESSION}' not found — bridge will run "
"but send/capture will fail until the session is created.",
file=sys.stderr,
)
application = (
ApplicationBuilder()
.token(token)
.post_init(on_startup)
.post_shutdown(on_shutdown)
.build()
)
application.bot_data["state"] = BridgeState(chat_id=chat_id)
application.add_handler(CommandHandler("ping", cmd_ping))
application.add_handler(CommandHandler("snapshot", cmd_snapshot))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
application.run_polling(allowed_updates=Update.ALL_TYPES, stop_signals=None)
return 0
if __name__ == "__main__":
sys.exit(main())

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

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). Рабочий флоу не ломается.

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,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

@ -1,6 +1,10 @@
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Integrations.MoySklad; using foodmarket.Infrastructure.Integrations.MoySklad;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Admin; namespace foodmarket.Api.Controllers.Admin;
@ -9,20 +13,57 @@ namespace foodmarket.Api.Controllers.Admin;
[Route("api/admin/moysklad")] [Route("api/admin/moysklad")]
public class MoySkladImportController : ControllerBase public class MoySkladImportController : ControllerBase
{ {
private readonly IServiceScopeFactory _scopes;
private readonly MoySkladImportService _svc; private readonly MoySkladImportService _svc;
private readonly ImportJobRegistry _jobs;
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public MoySkladImportController(MoySkladImportService svc) => _svc = svc; 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); public record TestRequest(string? Token = null);
public record ImportRequest(string Token, bool OverwriteExisting = false); 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")] [HttpPost("test")]
public async Task<IActionResult> TestConnection([FromBody] TestRequest req, CancellationToken ct) public async Task<IActionResult> TestConnection([FromBody] TestRequest req, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(req.Token)) var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { error = "Token is required." }); return BadRequest(new { error = "Token is required." });
var result = await _svc.TestConnectionAsync(req.Token, ct); var result = await _svc.TestConnectionAsync(token, ct);
if (!result.Success) if (!result.Success)
{ {
var msg = result.StatusCode switch var msg = result.StatusCode switch
@ -36,26 +77,91 @@ public async Task<IActionResult> TestConnection([FromBody] TestRequest req, Canc
return Ok(new { organization = result.Value!.Name, inn = result.Value.Inn }); return Ok(new { organization = result.Value!.Name, inn = result.Value.Inn });
} }
private static string? Truncate(string? s, int max) // Async launch — возвращает jobId, реальная работа в фоне. Клиент полит /jobs/{id}.
=> string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max] + "…");
[HttpPost("import-products")] [HttpPost("import-products")]
public async Task<ActionResult<MoySkladImportResult>> ImportProducts([FromBody] ImportRequest req, CancellationToken ct) public async Task<ActionResult<object>> ImportProducts([FromBody] ImportRequest req, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(req.Token)) var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
return BadRequest(new { error = "Token is required." }); if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { error = "Токен не настроен. Сохраните его в /admin/moysklad/settings." });
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var result = await _svc.ImportProductsAsync(req.Token, req.OverwriteExisting, ct); var job = _jobs.Create("products");
return result; 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")] [HttpPost("import-counterparties")]
public async Task<ActionResult<MoySkladImportResult>> ImportCounterparties([FromBody] ImportRequest req, CancellationToken ct) public async Task<ActionResult<object>> ImportCounterparties([FromBody] ImportRequest req, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(req.Token)) var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
return BadRequest(new { error = "Token is required." }); if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { error = "Токен не настроен. Сохраните его в /admin/moysklad/settings." });
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var result = await _svc.ImportCounterpartiesAsync(req.Token, req.OverwriteExisting, ct); var job = _jobs.Create("counterparties");
return result; 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

@ -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();
@ -43,7 +38,7 @@ public class CounterpartiesController : ControllerBase
.OrderBy(c => c.Name) .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, c.IsActive))
@ -56,7 +51,7 @@ 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, c.IsActive);
@ -95,7 +90,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;
@ -117,7 +111,7 @@ 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, c.IsActive);

View file

@ -23,14 +23,33 @@ public class ProductsController : ControllerBase
[FromQuery] Guid? groupId, [FromQuery] Guid? groupId,
[FromQuery] bool? isService, [FromQuery] bool? isService,
[FromQuery] bool? isWeighed, [FromQuery] bool? isWeighed,
[FromQuery] bool? isMarked,
[FromQuery] bool? isActive, [FromQuery] bool? isActive,
[FromQuery] bool? hasBarcode,
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 (isWeighed is not null) q = q.Where(p => p.IsWeighed == isWeighed);
if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked);
if (isActive is not null) q = q.Where(p => p.IsActive == isActive); if (isActive is not null) q = q.Where(p => p.IsActive == isActive);
if (hasBarcode is not null)
q = hasBarcode == true ? q.Where(p => p.Barcodes.Any()) : q.Where(p => !p.Barcodes.Any());
if (!string.IsNullOrWhiteSpace(req.Search)) if (!string.IsNullOrWhiteSpace(req.Search))
{ {
@ -111,7 +130,6 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
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,12 +144,12 @@ 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 != null ? p.ProductGroup.Name : null,
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.IsWeighed, p.IsMarked,
p.MinStock, p.MaxStock, p.MinStock, p.MaxStock,
p.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null, p.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
p.ImageUrl, p.IsActive, p.ImageUrl, p.IsActive,
@ -144,13 +162,13 @@ 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; e.Vat = i.Vat;
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.IsWeighed = i.IsWeighed;
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;

View file

@ -30,7 +30,7 @@ public async Task<ActionResult<PagedResult<StoreDto>>> List([FromQuery] PagedReq
var items = await q var items = await q
.OrderByDescending(x => x.IsMain).ThenBy(x => x.Name) .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 +39,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 +51,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 +71,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,13 @@ 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);
var items = await q var items = await q
.OrderByDescending(u => u.IsBase).ThenBy(u => u.Name) .OrderBy(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.IsActive))
.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,30 +39,23 @@ 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.IsActive);
} }
[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, 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.IsActive));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
@ -71,16 +64,9 @@ public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput i
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 (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; e.IsActive = input.IsActive;
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

@ -50,7 +50,7 @@ public record StockRow(
.OrderBy(x => x.p.Name) .OrderBy(x => x.p.Name)
.Skip((page - 1) * pageSize).Take(pageSize) .Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new StockRow( .Select(x => new StockRow(
x.p.Id, x.p.Name, x.p.Article, x.u.Symbol, x.p.Id, x.p.Name, x.p.Article, x.u.Name,
x.st.Id, x.st.Name, x.st.Id, x.st.Name,
x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity)) x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
.ToListAsync(ct); .ToListAsync(ct);

View file

@ -276,7 +276,7 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
where l.SupplyId == id where l.SupplyId == id
orderby l.SortOrder orderby l.SortOrder
select new SupplyLineDto( select new SupplyLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Symbol, l.Id, l.ProductId, p.Name, p.Article, u.Name,
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder)) l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder))
.ToListAsync(ct); .ToListAsync(ct);

View file

@ -352,7 +352,7 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
where l.RetailSaleId == id where l.RetailSaleId == id
orderby l.SortOrder orderby l.SortOrder
select new RetailSaleLineDto( select new RetailSaleLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Symbol, l.Id, l.ProductId, p.Name, p.Article, u.Name,
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder)) l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder))
.ToListAsync(ct); .ToListAsync(ct);

View file

@ -8,6 +8,22 @@ 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";
// 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;
public HttpContextTenantContext(IHttpContextAccessor accessor) public HttpContextTenantContext(IHttpContextAccessor accessor)
@ -15,14 +31,29 @@ 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;
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;
} }

View file

@ -130,6 +130,7 @@
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate, AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
}); });
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>(); builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
// Inventory // Inventory
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>(); builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();

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 default VAT is 16% (applies as int on Product).
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.IsDefault, ct); const int vatDefault = 16;
var noVat = await db.VatRates.IgnoreQueryFilters() const int vat0 = 0;
.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);
@ -88,7 +85,7 @@ 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",
@ -97,7 +94,7 @@ Guid AddGroup(string name, Guid? parentId)
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, IsActive = true,
@ -106,49 +103,49 @@ Guid AddGroup(string name, Guid? parentId)
// 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,12 +156,12 @@ 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("Лепёшка") Vat = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
? vat0 : vat, ? 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, IsWeighed = d.IsWeighed,
IsAlcohol = d.IsAlcohol,
IsActive = true, IsActive = true,
PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2), PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2),
PurchaseCurrencyId = kzt.Id, PurchaseCurrencyId = kzt.Id,

View file

@ -78,24 +78,15 @@ public async Task StartAsync(CancellationToken ct)
private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct) private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct)
{ {
var anyVat = await db.VatRates.IgnoreQueryFilters().AnyAsync(v => v.OrganizationId == orgId, ct);
if (!anyVat)
{
db.VatRates.AddRange(
new VatRate { OrganizationId = orgId, Name = "Без НДС", Percent = 0m, IsDefault = false },
new VatRate { OrganizationId = orgId, Name = "НДС 12%", Percent = 12m, IsIncludedInPrice = true, IsDefault = true }
);
}
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 = "упаковка" }
); );
} }
@ -116,7 +107,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",
}; };

View file

@ -6,17 +6,14 @@ public record CountryDto(Guid Id, string Code, string Name, int SortOrder);
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol, int MinorUnit, bool IsActive); public record CurrencyDto(Guid Id, string Code, string Name, string Symbol, int MinorUnit, bool IsActive);
public record VatRateDto(
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, bool IsActive);
public record PriceTypeDto( public record PriceTypeDto(
Guid Id, string Name, bool IsDefault, bool IsRetail, int SortOrder, bool IsActive); Guid Id, string Name, bool IsDefault, bool IsRetail, int SortOrder, bool IsActive);
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(
@ -27,7 +24,7 @@ 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, bool IsActive);
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, bool IsActive);
@ -38,12 +35,12 @@ 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, int 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, bool IsWeighed, bool IsMarked,
decimal? MinStock, decimal? MaxStock, decimal? MinStock, decimal? MaxStock,
decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode, decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
string? ImageUrl, bool IsActive, string? ImageUrl, bool IsActive,
@ -53,11 +50,10 @@ public record ProductDto(
// Upsert payloads (input) // Upsert payloads (input)
public record CountryInput(string Code, string Name, int SortOrder = 0); public record CountryInput(string Code, string Name, int SortOrder = 0);
public record CurrencyInput(string Code, string Name, string Symbol, int MinorUnit = 2, bool IsActive = true); public record CurrencyInput(string Code, string Name, string Symbol, int MinorUnit = 2, bool IsActive = true);
public record VatRateInput(string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault = false, bool IsActive = true); public record UnitOfMeasureInput(string Code, string Name, string? Description = null, bool IsActive = true);
public record UnitOfMeasureInput(string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase = false, bool IsActive = true);
public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true); public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true);
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(
@ -66,7 +62,7 @@ public record RetailPointInput(
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, bool IsActive = true);
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, bool IsActive = true);
@ -74,9 +70,9 @@ public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ea
public record ProductPriceInput(Guid PriceTypeId, decimal Amount, Guid CurrencyId); public record ProductPriceInput(Guid PriceTypeId, 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, int 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, bool IsWeighed = false, bool IsMarked = false,
decimal? MinStock = null, decimal? MaxStock = null, decimal? MinStock = null, decimal? MaxStock = null,
decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null, decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null,
string? ImageUrl = null, bool IsActive = true, string? ImageUrl = null, bool IsActive = true,

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; } // ИИН (для физлиц РК)

View file

@ -1,28 +1,11 @@
namespace foodmarket.Domain.Catalog; namespace foodmarket.Domain.Catalog;
public enum CounterpartyKind
{
/// <summary>Не указано — дефолт для импортированных без явной классификации.
/// MoySklad сам не имеет встроенного поля Supplier/Customer, оно ставится
/// через теги или группы, и часто отсутствует. Не выдумываем за пользователя.</summary>
Unspecified = 0,
Supplier = 1,
Customer = 2,
Both = 3,
}
public enum CounterpartyType public enum CounterpartyType
{ {
LegalEntity = 1, LegalEntity = 1,
Individual = 2, Individual = 2,
} }
public enum StoreKind
{
Warehouse = 1,
RetailFloor = 2,
}
public enum BarcodeType public enum BarcodeType
{ {
Ean13 = 1, Ean13 = 1,

View file

@ -11,8 +11,10 @@ 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; } // Ставка НДС на товаре — число 0/10/12/16/20 как в MoySklad.
public VatRate? VatRate { get; set; } // VatEnabled=true → НДС применяется, false → без НДС.
public int 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; }
@ -25,7 +27,6 @@ public class Product : TenantEntity
public bool IsService { get; set; } // услуга, а не физический товар public bool IsService { get; set; } // услуга, а не физический товар
public bool IsWeighed { get; set; } // весовой (продаётся с весов) public bool IsWeighed { get; set; } // весовой (продаётся с весов)
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; } // минимальный остаток (для уведомлений)

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,11 @@
namespace foodmarket.Domain.Catalog; namespace foodmarket.Domain.Catalog;
// Tenant-scoped справочник единиц измерения. // Единица измерения как в MoySklad entity/uom: code + name + description.
public class UnitOfMeasure : TenantEntity public class UnitOfMeasure : TenantEntity
{ {
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; 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

@ -11,4 +11,8 @@ 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>Персональный API-токен MoySklad. Храним per-organization чтобы
/// пользователю не нужно было вводить его каждый раз при импорте.</summary>
public string? MoySkladToken { 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

@ -63,60 +63,78 @@ public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, C
: MoySkladApiResult<MsOrganization>.Ok(org); : MoySkladApiResult<MsOrganization>.Ok(org);
} }
// MoySklad list endpoints по умолчанию возвращают только активных (archived=false).
// Чтобы получить архивных, нужен явный filter=archived=true. Делаем два прохода:
// сначала активные (default), затем архивные — и отдаём всё одним потоком.
public async IAsyncEnumerable<MsProduct> StreamProductsAsync( public async IAsyncEnumerable<MsProduct> StreamProductsAsync(
string token, string token,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{ {
const int pageSize = 1000; await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: false, ct)) yield return p;
var offset = 0; await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: true, ct)) yield return p;
while (true)
{
using var req = Build(HttpMethod.Get, $"entity/product?limit={pageSize}&offset={offset}", token);
using var res = await _http.SendAsync(req, ct);
res.EnsureSuccessStatusCode();
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsProduct>>(Json, ct);
if (page is null || page.Rows.Count == 0) yield break;
foreach (var p in page.Rows) yield return p;
if (page.Rows.Count < pageSize) yield break;
offset += pageSize;
}
} }
public async IAsyncEnumerable<MsCounterparty> StreamCounterpartiesAsync( public async IAsyncEnumerable<MsCounterparty> StreamCounterpartiesAsync(
string token, string token,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{ {
const int pageSize = 1000; await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: false, ct)) yield return c;
var offset = 0; await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: true, ct)) yield return c;
while (true)
{
using var req = Build(HttpMethod.Get, $"entity/counterparty?limit={pageSize}&offset={offset}", token);
using var res = await _http.SendAsync(req, ct);
res.EnsureSuccessStatusCode();
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsCounterparty>>(Json, ct);
if (page is null || page.Rows.Count == 0) yield break;
foreach (var c in page.Rows) yield return c;
if (page.Rows.Count < pageSize) yield break;
offset += pageSize;
}
} }
public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct) public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct)
{ {
var all = new List<MsProductFolder>(); var all = new List<MsProductFolder>();
var offset = 0; await foreach (var f in StreamPagedAsync<MsProductFolder>(token, "entity/productfolder", archivedOnly: false, ct)) all.Add(f);
const int pageSize = 1000; await foreach (var f in StreamPagedAsync<MsProductFolder>(token, "entity/productfolder", archivedOnly: true, ct)) all.Add(f);
while (true)
{
using var req = Build(HttpMethod.Get, $"entity/productfolder?limit={pageSize}&offset={offset}", token);
using var res = await _http.SendAsync(req, ct);
res.EnsureSuccessStatusCode();
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsProductFolder>>(Json, ct);
if (page is null || page.Rows.Count == 0) break;
all.AddRange(page.Rows);
if (page.Rows.Count < pageSize) break;
offset += pageSize;
}
return all; 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

@ -35,18 +35,21 @@ public class MoySkladImportService
public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct) public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct)
=> _client.WhoAmIAsync(token, ct); => _client.WhoAmIAsync(token, ct);
public async Task<MoySkladImportResult> ImportCounterpartiesAsync(string token, bool overwriteExisting, CancellationToken ct) public async Task<MoySkladImportResult> ImportCounterpartiesAsync(
string token,
bool overwriteExisting,
CancellationToken ct,
ImportJobProgress? progress = null,
Guid? organizationIdOverride = null)
{ {
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context."); var orgId = organizationIdOverride ?? _tenant.OrganizationId
?? throw new InvalidOperationException("No tenant organization in context.");
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще — это // MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще —
// не наша выдумка, проверено через API: counterparty entity содержит только // counterparty entity содержит только group (группа доступа), tags
// group (группа доступа), tags (произвольные), state (пользовательская цепочка // (произвольные), state (пользовательская цепочка статусов), companyType
// статусов), companyType (legal/individual/entrepreneur). Никакой role/kind. // (legal/individual/entrepreneur). Никакого role/kind. Поэтому у нас тоже
// Поэтому при импорте ВСЕГДА ставим Unspecified — пользователь сам решит. // этого поля нет — пусть пользователь сам решит.
// Параметр tags оставлен ради совместимости сигнатуры, не используется.
static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList<string>? tags)
=> foodmarket.Domain.Catalog.CounterpartyKind.Unspecified;
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType) static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
=> companyType switch => companyType switch
@ -55,11 +58,15 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp
_ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity, _ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity,
}; };
// Загружаем существующих в память — обновлять будем по имени (case-insensitive).
// Раньше при overwriteExisting=true бага: пропускалась проверка "skip" и ВСЕ
// поступающие записи добавлялись как новые, порождая дубли. Теперь если имя уже
// есть — обновляем ту же запись, иначе создаём.
var existingByName = await _db.Counterparties var existingByName = await _db.Counterparties
.Select(c => new { c.Id, c.Name }) .ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct);
.ToDictionaryAsync(c => c.Name, c => c.Id, StringComparer.OrdinalIgnoreCase, ct);
var created = 0; var created = 0;
var updated = 0;
var skipped = 0; var skipped = 0;
var total = 0; var total = 0;
var errors = new List<string>(); var errors = new List<string>();
@ -68,36 +75,30 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp
await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
{ {
total++; total++;
if (c.Archived) { skipped++; continue; } if (progress is not null) progress.Total = total;
// Архивных не пропускаем — импортируем как IsActive=false (см. ApplyCounterparty).
if (existingByName.ContainsKey(c.Name) && !overwriteExisting)
{
skipped++;
continue;
}
try try
{ {
var entity = new foodmarket.Domain.Catalog.Counterparty if (existingByName.TryGetValue(c.Name, out var existing))
{ {
OrganizationId = orgId, if (!overwriteExisting) { skipped++; if (progress is not null) progress.Skipped = skipped; continue; }
Name = Trim(c.Name, 255) ?? c.Name, ApplyCounterparty(existing, c, ResolveType);
LegalName = Trim(c.LegalTitle, 500), updated++;
Kind = ResolveKind(c.Tags), if (progress is not null) progress.Updated = updated;
Type = ResolveType(c.CompanyType), }
Bin = Trim(c.Inn, 20), else
TaxNumber = Trim(c.Kpp, 20), {
Phone = Trim(c.Phone, 50), var entity = new foodmarket.Domain.Catalog.Counterparty { OrganizationId = orgId };
Email = Trim(c.Email, 255), ApplyCounterparty(entity, c, ResolveType);
Address = Trim(c.ActualAddress ?? c.LegalAddress, 500),
Notes = Trim(c.Description, 1000),
IsActive = !c.Archived,
};
_db.Counterparties.Add(entity); _db.Counterparties.Add(entity);
existingByName[c.Name] = entity.Id; existingByName[c.Name] = entity;
created++; created++;
if (progress is not null) progress.Created = created;
}
batch++; batch++;
if (batch >= 500) if (batch >= 100)
{ {
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
batch = 0; batch = 0;
@ -107,25 +108,46 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
{ {
_log.LogWarning(ex, "Failed to import counterparty {Name}", c.Name); _log.LogWarning(ex, "Failed to import counterparty {Name}", c.Name);
errors.Add($"{c.Name}: {ex.Message}"); errors.Add($"{c.Name}: {ex.Message}");
if (progress is not null) progress.Errors = errors;
} }
} }
if (batch > 0) await _db.SaveChangesAsync(ct); if (batch > 0) await _db.SaveChangesAsync(ct);
return new MoySkladImportResult(total, created, skipped, 0, errors); // `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);
entity.IsActive = !c.Archived;
} }
public async Task<MoySkladImportResult> ImportProductsAsync( public async Task<MoySkladImportResult> ImportProductsAsync(
string token, string token,
bool overwriteExisting, bool overwriteExisting,
CancellationToken ct) CancellationToken ct,
ImportJobProgress? progress = null,
Guid? organizationIdOverride = null)
{ {
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context."); var orgId = organizationIdOverride ?? _tenant.OrganizationId
?? throw new InvalidOperationException("No tenant organization in context.");
// Pre-load tenant defaults. // Pre-load tenant defaults. KZ default VAT is 16% — applied when product didn't
var defaultVat = await _db.VatRates.FirstOrDefaultAsync(v => v.IsDefault, ct) // carry its own vat from MoySklad.
?? await _db.VatRates.FirstAsync(ct); const int kzDefaultVat = 16;
var vatByPercent = await _db.VatRates.ToDictionaryAsync(v => (int)v.Percent, v => v.Id, ct); var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", ct)
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.IsBase, ct)
?? await _db.UnitsOfMeasure.FirstAsync(ct); ?? await _db.UnitsOfMeasure.FirstAsync(ct);
var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct) var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct)
?? await _db.PriceTypes.FirstAsync(ct); ?? await _db.PriceTypes.FirstAsync(ct);
@ -135,11 +157,12 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
.IgnoreQueryFilters() .IgnoreQueryFilters()
.ToDictionaryAsync(c => c.Name, c => c.Id, ct); .ToDictionaryAsync(c => c.Name, c => c.Id, ct);
// Import folders first — build flat then link parents. // Import folders first — build flat then link parents. Архивные тоже берём,
// помечаем IsActive=false — у MoySklad у productfolder есть archived.
var folders = await _client.GetAllFoldersAsync(token, ct); var folders = await _client.GetAllFoldersAsync(token, ct);
var localGroupByMsId = new Dictionary<string, Guid>(); var localGroupByMsId = new Dictionary<string, Guid>();
var groupsCreated = 0; var groupsCreated = 0;
foreach (var f in folders.Where(f => !f.Archived).OrderBy(f => f.PathName?.Length ?? 0)) foreach (var f in folders.OrderBy(f => f.PathName?.Length ?? 0))
{ {
if (f.Id is null) continue; if (f.Id is null) continue;
var existing = await _db.ProductGroups.FirstOrDefaultAsync( var existing = await _db.ProductGroups.FirstOrDefaultAsync(
@ -154,47 +177,49 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
OrganizationId = orgId, OrganizationId = orgId,
Name = f.Name, Name = f.Name,
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}", Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
IsActive = true, IsActive = !f.Archived,
}; };
_db.ProductGroups.Add(g); _db.ProductGroups.Add(g);
localGroupByMsId[f.Id] = g.Id; localGroupByMsId[f.Id] = g.Id;
groupsCreated++; groupsCreated++;
} }
if (groupsCreated > 0) await _db.SaveChangesAsync(ct); if (groupsCreated > 0) await _db.SaveChangesAsync(ct);
if (progress is not null) progress.GroupsCreated = groupsCreated;
// Import products // Import products
var errors = new List<string>(); var errors = new List<string>();
var created = 0; var created = 0;
var updated = 0;
var skipped = 0; var skipped = 0;
var total = 0; var total = 0;
var existingArticles = await _db.Products // При overwriteExisting=true загружаем товары целиком, чтобы обновлять существующие
// вместо создания дубликатов. Ключ = артикул (нормализованный).
var existingByArticle = await _db.Products
.Where(p => p.Article != null) .Where(p => p.Article != null)
.Select(p => p.Article!) .ToDictionaryAsync(p => p.Article!, p => p, StringComparer.OrdinalIgnoreCase, ct);
.ToListAsync(ct);
var existingArticleSet = new HashSet<string>(existingArticles, StringComparer.OrdinalIgnoreCase);
var existingBarcodeSet = new HashSet<string>( var existingBarcodeSet = new HashSet<string>(
await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct)); await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct));
await foreach (var p in _client.StreamProductsAsync(token, ct)) await foreach (var p in _client.StreamProductsAsync(token, ct))
{ {
total++; total++;
if (p.Archived) { skipped++; continue; } if (progress is not null) progress.Total = total;
// Архивных не пропускаем — импортируем как IsActive=false.
var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article; var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article;
var primaryBarcode = ExtractBarcodes(p).FirstOrDefault(); var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingByArticle.ContainsKey(article);
var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingArticleSet.Contains(article); if (alreadyByArticle && !overwriteExisting)
var alreadyByBarcode = primaryBarcode is not null && existingBarcodeSet.Contains(primaryBarcode.Code);
if ((alreadyByArticle || alreadyByBarcode) && !overwriteExisting)
{ {
skipped++; skipped++;
if (progress is not null) progress.Skipped = skipped;
continue; continue;
} }
try try
{ {
var vatId = p.Vat is int vp && vatByPercent.TryGetValue(vp, out var id) ? id : defaultVat.Id; var vat = p.Vat ?? kzDefaultVat;
var vatEnabled = (p.Vat ?? kzDefaultVat) > 0;
Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId
&& localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null; && localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null;
Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null; Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null;
@ -202,18 +227,40 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true) var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true)
?? p.SalePrices?.FirstOrDefault(); ?? p.SalePrices?.FirstOrDefault();
var product = new Product 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.IsWeighed = p.Weighed;
product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED";
product.IsActive = !p.Archived;
product.PurchasePrice = p.BuyPrice is null ? product.PurchasePrice : p.BuyPrice.Value / 100m;
updated++;
if (progress is not null) progress.Updated = updated;
}
else
{
product = new Product
{ {
OrganizationId = orgId, OrganizationId = orgId,
Name = Trim(p.Name, 500), Name = Trim(p.Name, 500),
Article = Trim(article, 500), Article = Trim(article, 500),
Description = p.Description, Description = p.Description,
UnitOfMeasureId = baseUnit.Id, UnitOfMeasureId = baseUnit.Id,
VatRateId = vatId, Vat = vat,
VatEnabled = vatEnabled,
ProductGroupId = groupId, ProductGroupId = groupId,
CountryOfOriginId = countryId, CountryOfOriginId = countryId,
IsWeighed = p.Weighed, IsWeighed = p.Weighed,
IsAlcohol = p.Alcoholic is not null,
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED", IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
IsActive = !p.Archived, IsActive = !p.Archived,
PurchasePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m, PurchasePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
@ -239,21 +286,25 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
} }
_db.Products.Add(product); _db.Products.Add(product);
if (!string.IsNullOrWhiteSpace(article)) existingArticleSet.Add(article); if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product;
created++; created++;
if (progress is not null) progress.Created = created;
}
// Flush every 500 products to keep change tracker light. // Flush чаще (каждые 100) чтобы при сетевом обрыве на следующей странице
if (created % 500 == 0) await _db.SaveChangesAsync(ct); // мы сохранили как можно больше и смогли безопасно продолжить с overwrite.
if ((created + updated) % 100 == 0) await _db.SaveChangesAsync(ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
_log.LogWarning(ex, "Failed to import MoySklad product {Name}", p.Name); _log.LogWarning(ex, "Failed to import MoySklad product {Name}", p.Name);
errors.Add($"{p.Name}: {ex.Message}"); errors.Add($"{p.Name}: {ex.Message}");
if (progress is not null) progress.Errors = errors;
} }
} }
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return new MoySkladImportResult(total, created, skipped, groupsCreated, errors); return new MoySkladImportResult(total, created + updated, skipped, groupsCreated, errors);
} }
private static List<ProductBarcode> ExtractBarcodes(MsProduct p) private static List<ProductBarcode> ExtractBarcodes(MsProduct p)

View file

@ -26,7 +26,6 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<Country> Countries => Set<Country>(); public DbSet<Country> Countries => Set<Country>();
public DbSet<Currency> Currencies => Set<Currency>(); public DbSet<Currency> Currencies => Set<Currency>();
public DbSet<VatRate> VatRates => Set<VatRate>();
public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>(); public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>();
public DbSet<Counterparty> Counterparties => Set<Counterparty>(); public DbSet<Counterparty> Counterparties => Set<Counterparty>();
public DbSet<Store> Stores => Set<Store>(); public DbSet<Store> Stores => Set<Store>();
@ -70,6 +69,7 @@ protected override void OnModelCreating(ModelBuilder builder)
b.Property(o => o.Name).HasMaxLength(200).IsRequired(); b.Property(o => o.Name).HasMaxLength(200).IsRequired();
b.Property(o => o.CountryCode).HasMaxLength(2).IsRequired(); b.Property(o => o.CountryCode).HasMaxLength(2).IsRequired();
b.Property(o => o.Bin).HasMaxLength(20); b.Property(o => o.Bin).HasMaxLength(20);
b.Property(o => o.MoySkladToken).HasMaxLength(200);
b.HasIndex(o => o.Name); b.HasIndex(o => o.Name);
}); });

View file

@ -10,7 +10,6 @@ public static void ConfigureCatalog(this ModelBuilder b)
{ {
b.Entity<Country>(ConfigureCountry); b.Entity<Country>(ConfigureCountry);
b.Entity<Currency>(ConfigureCurrency); b.Entity<Currency>(ConfigureCurrency);
b.Entity<VatRate>(ConfigureVatRate);
b.Entity<UnitOfMeasure>(ConfigureUnit); b.Entity<UnitOfMeasure>(ConfigureUnit);
b.Entity<Counterparty>(ConfigureCounterparty); b.Entity<Counterparty>(ConfigureCounterparty);
b.Entity<Store>(ConfigureStore); b.Entity<Store>(ConfigureStore);
@ -40,20 +39,12 @@ private static void ConfigureCurrency(EntityTypeBuilder<Currency> b)
b.HasIndex(x => x.Code).IsUnique(); b.HasIndex(x => x.Code).IsUnique();
} }
private static void ConfigureVatRate(EntityTypeBuilder<VatRate> b)
{
b.ToTable("vat_rates");
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
b.Property(x => x.Percent).HasPrecision(5, 2);
b.HasIndex(x => new { x.OrganizationId, x.Name }).IsUnique();
}
private static void ConfigureUnit(EntityTypeBuilder<UnitOfMeasure> b) private static void ConfigureUnit(EntityTypeBuilder<UnitOfMeasure> b)
{ {
b.ToTable("units_of_measure"); b.ToTable("units_of_measure");
b.Property(x => x.Code).HasMaxLength(10).IsRequired(); b.Property(x => x.Code).HasMaxLength(10).IsRequired();
b.Property(x => x.Symbol).HasMaxLength(20).IsRequired();
b.Property(x => x.Name).HasMaxLength(100).IsRequired(); b.Property(x => x.Name).HasMaxLength(100).IsRequired();
b.Property(x => x.Description).HasMaxLength(500);
b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique(); b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique();
} }
@ -74,7 +65,6 @@ private static void ConfigureCounterparty(EntityTypeBuilder<Counterparty> b)
b.HasOne(x => x.Country).WithMany().HasForeignKey(x => x.CountryId).OnDelete(DeleteBehavior.Restrict); b.HasOne(x => x.Country).WithMany().HasForeignKey(x => x.CountryId).OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => new { x.OrganizationId, x.Name }); b.HasIndex(x => new { x.OrganizationId, x.Name });
b.HasIndex(x => new { x.OrganizationId, x.Bin }); b.HasIndex(x => new { x.OrganizationId, x.Bin });
b.HasIndex(x => new { x.OrganizationId, x.Kind });
} }
private static void ConfigureStore(EntityTypeBuilder<Store> b) private static void ConfigureStore(EntityTypeBuilder<Store> b)
@ -130,7 +120,6 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
b.Property(x => x.ImageUrl).HasMaxLength(1000); b.Property(x => x.ImageUrl).HasMaxLength(1000);
b.HasOne(x => x.UnitOfMeasure).WithMany().HasForeignKey(x => x.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict); b.HasOne(x => x.UnitOfMeasure).WithMany().HasForeignKey(x => x.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.VatRate).WithMany().HasForeignKey(x => x.VatRateId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.ProductGroup).WithMany().HasForeignKey(x => x.ProductGroupId).OnDelete(DeleteBehavior.Restrict); b.HasOne(x => x.ProductGroup).WithMany().HasForeignKey(x => x.ProductGroupId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.DefaultSupplier).WithMany().HasForeignKey(x => x.DefaultSupplierId).OnDelete(DeleteBehavior.Restrict); b.HasOne(x => x.DefaultSupplier).WithMany().HasForeignKey(x => x.DefaultSupplierId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.CountryOfOrigin).WithMany().HasForeignKey(x => x.CountryOfOriginId).OnDelete(DeleteBehavior.Restrict); b.HasOne(x => x.CountryOfOrigin).WithMany().HasForeignKey(x => x.CountryOfOriginId).OnDelete(DeleteBehavior.Restrict);

View file

@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Reconciliation migration.
///
/// Предыдущие миграции Phase2c2_MoySkladAlignment и Phase2c3_MsStrict были применены
/// на стейдже, но их исходные .cs файлы были удалены при откате кода (commit 8fc9ef1
/// стёр их, но __EFMigrationsHistory уже содержал записи). В результате:
/// - snapshot был неактуальным (ссылался на VatRate, IsAlcohol, Kind, и т.п.)
/// - БД в состоянии пост-2c3 (поля Vat, VatEnabled, TrackingType; без VatRate,
/// без Kind, без IsAlcohol, без Symbol/DecimalPlaces/IsBase)
/// - код ожидает IsMarked вместо TrackingType
///
/// Задача этой миграции — добить различие в одной колонке: заменить TrackingType
/// (добавленный в Phase2c2) на IsMarked. Всё остальное уже совпадает.
/// EF-scaffold предложил много мусорной работы из-за рассинхрона snapshot'а — это
/// тело переписано вручную.</summary>
public partial class Phase2c4_ReconcileStage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1. Добавляем IsMarked с дефолтом false.
migrationBuilder.AddColumn<bool>(
name: "IsMarked",
schema: "public",
table: "products",
type: "boolean",
nullable: false,
defaultValue: false);
// 2. Если TrackingType есть в БД (стейдж) — бэкфиллим и удаляем.
// На свежей БД (dev, где migrations 2c2/2c3 не применялись отдельно)
// колонки не будет — IF EXISTS защищает от ошибки.
migrationBuilder.Sql("""
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'products'
AND column_name = 'TrackingType') THEN
UPDATE public.products SET "IsMarked" = ("TrackingType" <> 0);
ALTER TABLE public.products DROP COLUMN "TrackingType";
END IF;
END $$;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "TrackingType",
schema: "public",
table: "products",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.Sql("""UPDATE public.products SET "TrackingType" = CASE WHEN "IsMarked" THEN 99 ELSE 0 END;""");
migrationBuilder.DropColumn(
name: "IsMarked",
schema: "public",
table: "products");
}
}
}

View file

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Добавляем колонку organizations.MoySkladToken — per-tenant API-токен
/// MoySklad. Хранится, чтобы не вводить вручную при каждом импорте.</summary>
public partial class Phase3_OrganizationMoySkladToken : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "MoySkladToken",
schema: "public",
table: "organizations",
type: "character varying(200)",
maxLength: 200,
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MoySkladToken",
schema: "public",
table: "organizations");
}
}
}

View file

@ -380,9 +380,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("LegalName") b.Property<string>("LegalName")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("character varying(500)"); .HasColumnType("character varying(500)");
@ -418,8 +415,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("OrganizationId", "Bin"); b.HasIndex("OrganizationId", "Bin");
b.HasIndex("OrganizationId", "Kind");
b.HasIndex("OrganizationId", "Name"); b.HasIndex("OrganizationId", "Name");
b.ToTable("counterparties", "public"); b.ToTable("counterparties", "public");
@ -568,9 +563,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("IsAlcohol")
.HasColumnType("boolean");
b.Property<bool>("IsMarked") b.Property<bool>("IsMarked")
.HasColumnType("boolean"); .HasColumnType("boolean");
@ -612,8 +604,11 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<DateTime?>("UpdatedAt") b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<Guid>("VatRateId") b.Property<int>("Vat")
.HasColumnType("uuid"); .HasColumnType("integer");
b.Property<bool>("VatEnabled")
.HasColumnType("boolean");
b.HasKey("Id"); b.HasKey("Id");
@ -627,8 +622,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("UnitOfMeasureId"); b.HasIndex("UnitOfMeasureId");
b.HasIndex("VatRateId");
b.HasIndex("OrganizationId", "Article"); b.HasIndex("OrganizationId", "Article");
b.HasIndex("OrganizationId", "IsActive"); b.HasIndex("OrganizationId", "IsActive");
@ -876,9 +869,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("IsMain") b.Property<bool>("IsMain")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("ManagerName") b.Property<string>("ManagerName")
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
@ -919,15 +909,13 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<int>("DecimalPlaces") b.Property<string>("Description")
.HasColumnType("integer"); .HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("IsBase")
.HasColumnType("boolean");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@ -936,11 +924,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<Guid>("OrganizationId") b.Property<Guid>("OrganizationId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime?>("UpdatedAt") b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@ -952,47 +935,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("units_of_measure", "public"); b.ToTable("units_of_measure", "public");
}); });
modelBuilder.Entity("foodmarket.Domain.Catalog.VatRate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsDefault")
.HasColumnType("boolean");
b.Property<bool>("IsIncludedInPrice")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<decimal>("Percent")
.HasPrecision(5, 2)
.HasColumnType("numeric(5,2)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OrganizationId", "Name")
.IsUnique();
b.ToTable("vat_rates", "public");
});
modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b => modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -1132,6 +1074,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<string>("MoySkladToken")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)
@ -1652,12 +1598,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.HasOne("foodmarket.Domain.Catalog.VatRate", "VatRate")
.WithMany()
.HasForeignKey("VatRateId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CountryOfOrigin"); b.Navigation("CountryOfOrigin");
b.Navigation("DefaultSupplier"); b.Navigation("DefaultSupplier");
@ -1667,8 +1607,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("PurchaseCurrency"); b.Navigation("PurchaseCurrency");
b.Navigation("UnitOfMeasure"); b.Navigation("UnitOfMeasure");
b.Navigation("VatRate");
}); });
modelBuilder.Entity("foodmarket.Domain.Catalog.ProductBarcode", b => modelBuilder.Entity("foodmarket.Domain.Catalog.ProductBarcode", b =>

View file

@ -4,7 +4,6 @@ import { LoginPage } from '@/pages/LoginPage'
import { DashboardPage } from '@/pages/DashboardPage' import { DashboardPage } from '@/pages/DashboardPage'
import { CountriesPage } from '@/pages/CountriesPage' import { CountriesPage } from '@/pages/CountriesPage'
import { CurrenciesPage } from '@/pages/CurrenciesPage' import { CurrenciesPage } from '@/pages/CurrenciesPage'
import { VatRatesPage } from '@/pages/VatRatesPage'
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage' import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
import { PriceTypesPage } from '@/pages/PriceTypesPage' import { PriceTypesPage } from '@/pages/PriceTypesPage'
import { StoresPage } from '@/pages/StoresPage' import { StoresPage } from '@/pages/StoresPage'
@ -46,7 +45,6 @@ export default function App() {
<Route path="/catalog/products/:id" element={<ProductEditPage />} /> <Route path="/catalog/products/:id" element={<ProductEditPage />} />
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} /> <Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} /> <Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
<Route path="/catalog/vat-rates" element={<VatRatesPage />} />
<Route path="/catalog/price-types" element={<PriceTypesPage />} /> <Route path="/catalog/price-types" element={<PriceTypesPage />} />
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} /> <Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
<Route path="/catalog/stores" element={<StoresPage />} /> <Route path="/catalog/stores" element={<StoresPage />} />

View file

@ -0,0 +1,111 @@
import { useMemo, useState } from 'react'
import { ChevronRight, FolderTree } from 'lucide-react'
import { useProductGroups } from '@/lib/useLookups'
import type { ProductGroup } from '@/lib/types'
interface TreeNode {
group: ProductGroup
children: TreeNode[]
productCount?: number
}
function buildTree(groups: ProductGroup[]): TreeNode[] {
const byId = new Map<string, TreeNode>()
groups.forEach((g) => byId.set(g.id, { group: g, children: [] }))
const roots: TreeNode[] = []
byId.forEach((node) => {
if (node.group.parentId && byId.has(node.group.parentId)) {
byId.get(node.group.parentId)!.children.push(node)
} else {
roots.push(node)
}
})
const sortRec = (nodes: TreeNode[]) => {
nodes.sort((a, b) => (a.group.sortOrder - b.group.sortOrder) || a.group.name.localeCompare(b.group.name, 'ru'))
nodes.forEach((n) => sortRec(n.children))
}
sortRec(roots)
return roots
}
interface Props {
selectedId: string | null
onSelect: (id: string | null) => void
}
export function ProductGroupTree({ selectedId, onSelect }: Props) {
const { data: groups, isLoading } = useProductGroups()
const tree = useMemo(() => buildTree(groups ?? []), [groups])
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
const renderNode = (node: TreeNode, depth: number) => {
const hasChildren = node.children.length > 0
const isOpen = expanded.has(node.group.id)
const isActive = selectedId === node.group.id
return (
<div key={node.group.id}>
<div
className={
'flex items-center gap-1 text-sm rounded cursor-pointer select-none pr-2 hover:bg-slate-100 dark:hover:bg-slate-800 ' +
(isActive ? 'bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200 font-medium' : '')
}
style={{ paddingLeft: 4 + depth * 12 }}
>
{hasChildren ? (
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggle(node.group.id) }}
className="p-0.5 text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
aria-label={isOpen ? 'Свернуть' : 'Развернуть'}
>
<ChevronRight className={'w-3.5 h-3.5 transition-transform ' + (isOpen ? 'rotate-90' : '')} />
</button>
) : (
<span className="w-[18px]" />
)}
<button
type="button"
onClick={() => onSelect(node.group.id)}
className="flex-1 text-left py-1 truncate"
title={node.group.path}
>
{node.group.name}
</button>
</div>
{hasChildren && isOpen && node.children.map((c) => renderNode(c, depth + 1))}
</div>
)
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="px-2 py-2 border-b border-slate-200 dark:border-slate-800 flex items-center gap-2 text-xs uppercase tracking-wide text-slate-500">
<FolderTree className="w-3.5 h-3.5" /> Группы
</div>
<div className="flex-1 min-h-0 overflow-y-auto py-1">
<div
className={
'flex items-center gap-1 text-sm rounded cursor-pointer pr-2 hover:bg-slate-100 dark:hover:bg-slate-800 pl-2 ' +
(selectedId === null ? 'bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200 font-medium' : '')
}
onClick={() => onSelect(null)}
>
<button type="button" className="flex-1 text-left py-1">Все товары</button>
</div>
{isLoading && <div className="px-3 py-2 text-xs text-slate-400">Загрузка</div>}
{!isLoading && tree.length === 0 && (
<div className="px-3 py-2 text-xs text-slate-400">Групп ещё нет</div>
)}
{tree.map((n) => renderNode(n, 0))}
</div>
</div>
)
}

View file

@ -71,7 +71,7 @@ export function ProductPicker({ open, onClose, onPick, title = 'Выбор то
<div className="text-xs text-slate-400 flex gap-2 font-mono"> <div className="text-xs text-slate-400 flex gap-2 font-mono">
{p.article && <span>{p.article}</span>} {p.article && <span>{p.article}</span>}
{p.barcodes[0] && <span>· {p.barcodes[0].code}</span>} {p.barcodes[0] && <span>· {p.barcodes[0].code}</span>}
<span>· {p.unitSymbol}</span> <span>· {p.unitName}</span>
</div> </div>
</div> </div>
{p.purchasePrice !== null && ( {p.purchasePrice !== null && (

View file

@ -6,25 +6,18 @@ export interface PagedResult<T> {
totalPages: number totalPages: number
} }
export const CounterpartyKind = { Unspecified: 0, Supplier: 1, Customer: 2, Both: 3 } as const
export type CounterpartyKind = (typeof CounterpartyKind)[keyof typeof CounterpartyKind]
export const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const export const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const
export type CounterpartyType = (typeof CounterpartyType)[keyof typeof CounterpartyType] export type CounterpartyType = (typeof CounterpartyType)[keyof typeof CounterpartyType]
export const StoreKind = { Warehouse: 1, RetailFloor: 2 } as const
export type StoreKind = (typeof StoreKind)[keyof typeof StoreKind]
export const BarcodeType = { Ean13: 1, Ean8: 2, Code128: 3, Code39: 4, Upca: 5, Upce: 6, Other: 99 } as const export const BarcodeType = { Ean13: 1, Ean8: 2, Code128: 3, Code39: 4, Upca: 5, Upce: 6, Other: 99 } as const
export type BarcodeType = (typeof BarcodeType)[keyof typeof BarcodeType] export type BarcodeType = (typeof BarcodeType)[keyof typeof BarcodeType]
export interface Country { id: string; code: string; name: string; sortOrder: number } export interface Country { id: string; code: string; name: string; sortOrder: number }
export interface Currency { id: string; code: string; name: string; symbol: string; minorUnit: number; isActive: boolean } export interface Currency { id: string; code: string; name: string; symbol: string; minorUnit: number; isActive: boolean }
export interface VatRate { id: string; name: string; percent: number; isIncludedInPrice: boolean; isDefault: boolean; isActive: boolean } export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; isActive: boolean }
export interface UnitOfMeasure { id: string; code: string; symbol: string; name: string; decimalPlaces: number; isBase: boolean; isActive: boolean }
export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean } export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }
export interface Store { export interface Store {
id: string; name: string; code: string | null; kind: StoreKind; address: string | null; phone: string | null; id: string; name: string; code: string | null; address: string | null; phone: string | null;
managerName: string | null; isMain: boolean; isActive: boolean managerName: string | null; isMain: boolean; isActive: boolean
} }
export interface RetailPoint { export interface RetailPoint {
@ -33,7 +26,7 @@ export interface RetailPoint {
} }
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean } export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean }
export interface Counterparty { export interface Counterparty {
id: string; name: string; legalName: string | null; kind: CounterpartyKind; type: CounterpartyType; id: string; name: string; legalName: string | null; type: CounterpartyType;
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null; bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;
address: string | null; phone: string | null; email: string | null; address: string | null; phone: string | null; email: string | null;
bankName: string | null; bankAccount: string | null; bik: string | null; bankName: string | null; bankAccount: string | null; bik: string | null;
@ -43,12 +36,12 @@ export interface ProductBarcode { id: string; code: string; type: BarcodeType; i
export interface ProductPrice { id: string; priceTypeId: string; priceTypeName: string; amount: number; currencyId: string; currencyCode: string } export interface ProductPrice { id: string; priceTypeId: string; priceTypeName: string; amount: number; currencyId: string; currencyCode: string }
export interface Product { export interface Product {
id: string; name: string; article: string | null; description: string | null; id: string; name: string; article: string | null; description: string | null;
unitOfMeasureId: string; unitSymbol: string; unitOfMeasureId: string; unitName: string;
vatRateId: string; vatPercent: number; vat: number; vatEnabled: boolean;
productGroupId: string | null; productGroupName: string | null; productGroupId: string | null; productGroupName: string | null;
defaultSupplierId: string | null; defaultSupplierName: string | null; defaultSupplierId: string | null; defaultSupplierName: string | null;
countryOfOriginId: string | null; countryOfOriginName: string | null; countryOfOriginId: string | null; countryOfOriginName: string | null;
isService: boolean; isWeighed: boolean; isAlcohol: boolean; isMarked: boolean; isService: boolean; isWeighed: boolean; isMarked: boolean;
minStock: number | null; maxStock: number | null; minStock: number | null; maxStock: number | null;
purchasePrice: number | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null; purchasePrice: number | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
imageUrl: string | null; isActive: boolean; imageUrl: string | null; isActive: boolean;
@ -56,7 +49,7 @@ export interface Product {
} }
export interface StockRow { export interface StockRow {
productId: string; productName: string; article: string | null; unitSymbol: string; productId: string; productName: string; article: string | null; unitName: string;
storeId: string; storeName: string; storeId: string; storeName: string;
quantity: number; reservedQuantity: number; available: number; quantity: number; reservedQuantity: number; available: number;
} }
@ -83,7 +76,7 @@ export interface SupplyListRow {
export interface SupplyLineDto { export interface SupplyLineDto {
id: string | null; productId: string; id: string | null; productId: string;
productName: string | null; productArticle: string | null; unitSymbol: string | null; productName: string | null; productArticle: string | null; unitName: string | null;
quantity: number; unitPrice: number; lineTotal: number; sortOrder: number; quantity: number; unitPrice: number; lineTotal: number; sortOrder: number;
} }
@ -116,7 +109,7 @@ export interface RetailSaleListRow {
export interface RetailSaleLineDto { export interface RetailSaleLineDto {
id: string | null; productId: string; id: string | null; productId: string;
productName: string | null; productArticle: string | null; unitSymbol: string | null; productName: string | null; productArticle: string | null; unitName: string | null;
quantity: number; unitPrice: number; discount: number; lineTotal: number; quantity: number; unitPrice: number; discount: number; lineTotal: number;
vatPercent: number; sortOrder: number; vatPercent: number; sortOrder: number;
} }

View file

@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import type { import type {
PagedResult, UnitOfMeasure, VatRate, ProductGroup, Counterparty, PagedResult, UnitOfMeasure, ProductGroup, Counterparty,
Country, Currency, Store, PriceType, Country, Currency, Store, PriceType,
} from '@/lib/types' } from '@/lib/types'
@ -14,7 +14,6 @@ function useLookup<T>(key: string, url: string) {
} }
export const useUnits = () => useLookup<UnitOfMeasure>('units', '/api/catalog/units-of-measure') export const useUnits = () => useLookup<UnitOfMeasure>('units', '/api/catalog/units-of-measure')
export const useVatRates = () => useLookup<VatRate>('vat', '/api/catalog/vat-rates')
export const useProductGroups = () => useLookup<ProductGroup>('groups', '/api/catalog/product-groups') export const useProductGroups = () => useLookup<ProductGroup>('groups', '/api/catalog/product-groups')
export const useCountries = () => useLookup<Country>('countries', '/api/catalog/countries') export const useCountries = () => useLookup<Country>('countries', '/api/catalog/countries')
export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies') export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies')

View file

@ -10,7 +10,7 @@ import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal' import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field' import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog' import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { type Counterparty, type Country, type PagedResult, CounterpartyKind, CounterpartyType } from '@/lib/types' import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types'
const URL = '/api/catalog/counterparties' const URL = '/api/catalog/counterparties'
@ -18,7 +18,6 @@ interface Form {
id?: string id?: string
name: string name: string
legalName: string legalName: string
kind: CounterpartyKind
type: CounterpartyType type: CounterpartyType
bin: string bin: string
iin: string iin: string
@ -36,20 +35,16 @@ interface Form {
} }
const blankForm: Form = { const blankForm: Form = {
// Kind по умолчанию Unspecified — MoySklad не имеет такого поля у контрагентов, name: '', legalName: '', type: CounterpartyType.LegalEntity,
// не выдумываем за пользователя. Пусть выберет вручную если нужно.
name: '', legalName: '', kind: CounterpartyKind.Unspecified, type: CounterpartyType.LegalEntity,
bin: '', iin: '', taxNumber: '', countryId: '', bin: '', iin: '', taxNumber: '', countryId: '',
address: '', phone: '', email: '', address: '', phone: '', email: '',
bankName: '', bankAccount: '', bik: '', bankName: '', bankAccount: '', bik: '',
contactPerson: '', notes: '', isActive: true, contactPerson: '', notes: '', isActive: true,
} }
const kindLabel: Record<CounterpartyKind, string> = { const typeLabel: Record<CounterpartyType, string> = {
[CounterpartyKind.Unspecified]: '—', [CounterpartyType.LegalEntity]: 'Юрлицо',
[CounterpartyKind.Supplier]: 'Поставщик', [CounterpartyType.Individual]: 'Физлицо',
[CounterpartyKind.Customer]: 'Покупатель',
[CounterpartyKind.Both]: 'Поставщик + Покупатель',
} }
export function CounterpartiesPage() { export function CounterpartiesPage() {
@ -92,7 +87,7 @@ export function CounterpartiesPage() {
isLoading={isLoading} isLoading={isLoading}
rowKey={(r) => r.id} rowKey={(r) => r.id}
onRowClick={(r) => setForm({ onRowClick={(r) => setForm({
id: r.id, name: r.name, legalName: r.legalName ?? '', kind: r.kind, type: r.type, id: r.id, name: r.name, legalName: r.legalName ?? '', type: r.type,
bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '', bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '',
countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '', countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '',
bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '', bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '',
@ -100,7 +95,7 @@ export function CounterpartiesPage() {
})} })}
columns={[ columns={[
{ header: 'Название', cell: (r) => r.name }, { header: 'Название', cell: (r) => r.name },
{ header: 'Тип', width: '120px', cell: (r) => kindLabel[r.kind] }, { header: 'Тип', width: '120px', cell: (r) => typeLabel[r.type] },
{ header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> }, { header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> },
{ header: 'Телефон', width: '160px', cell: (r) => r.phone ?? '—' }, { header: 'Телефон', width: '160px', cell: (r) => r.phone ?? '—' },
{ header: 'Страна', width: '120px', cell: (r) => r.countryName ?? '—' }, { header: 'Страна', width: '120px', cell: (r) => r.countryName ?? '—' },
@ -139,14 +134,6 @@ export function CounterpartiesPage() {
<Field label="Юридическое название" className="col-span-2"> <Field label="Юридическое название" className="col-span-2">
<TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} /> <TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} />
</Field> </Field>
<Field label="Роль">
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as CounterpartyKind })}>
<option value={CounterpartyKind.Unspecified}>Не указано</option>
<option value={CounterpartyKind.Supplier}>Поставщик</option>
<option value={CounterpartyKind.Customer}>Покупатель</option>
<option value={CounterpartyKind.Both}>Поставщик + Покупатель</option>
</Select>
</Field>
<Field label="Тип лица"> <Field label="Тип лица">
<Select value={form.type} onChange={(e) => setForm({ ...form, type: Number(e.target.value) as CounterpartyType })}> <Select value={form.type} onChange={(e) => setForm({ ...form, type: Number(e.target.value) as CounterpartyType })}>
<option value={CounterpartyType.LegalEntity}>Юрлицо</option> <option value={CounterpartyType.LegalEntity}>Юрлицо</option>

View file

@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package } from 'lucide-react' import { AlertCircle, CheckCircle, KeyRound, Users, Package, Trash2, AlertTriangle, Save } from 'lucide-react'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
@ -12,164 +12,308 @@ function formatError(err: unknown): string {
const status = err.response?.status const status = err.response?.status
const body = err.response?.data as { error?: string; error_description?: string; title?: string } | undefined const body = err.response?.data as { error?: string; error_description?: string; title?: string } | undefined
const detail = body?.error ?? body?.error_description ?? body?.title const detail = body?.error ?? body?.error_description ?? body?.title
if (status === 404) { if (status === 404) return '404 — эндпоинт не существует. API обновлён?'
return '404 Not Found — эндпоинт не существует. Вероятно, API не перезапущен после git pull.' if (status === 401) return '401 — сессия истекла, перелогинься.'
} if (status === 403) return '403 — нужна роль Admin.'
if (status === 401) return '401 Unauthorized — сессия истекла, перелогинься.' if (status === 502 || status === 503) return `${status} — МойСклад недоступен.`
if (status === 403) return '403 Forbidden — нужна роль Admin или SuperAdmin.'
if (status === 502 || status === 503) return `${status} — МойСклад недоступен, попробуй позже.`
return detail ? `${status ?? ''} ${detail}` : err.message return detail ? `${status ?? ''} ${detail}` : err.message
} }
if (err instanceof Error) return err.message if (err instanceof Error) return err.message
return String(err) return String(err)
} }
interface TestResponse { organization: string; inn?: string | null } interface SettingsDto { hasToken: boolean; masked: string | null }
interface ImportResponse { interface JobView {
total: number; created: number; skipped: number; groupsCreated: number; errors: string[] id: string
kind: string
status: 'Running' | 'Succeeded' | 'Failed' | 'Cancelled'
stage: string | null
startedAt: string
finishedAt: string | null
total: number; created: number; updated: number; skipped: number; deleted: number; groupsCreated: number
message: string | null
errors: string[]
}
function useJob(jobId: string | null) {
return useQuery({
queryKey: ['admin-job', jobId],
enabled: !!jobId,
queryFn: async () => (await api.get<JobView>(`/api/admin/jobs/${jobId}`)).data,
refetchInterval: (q) => {
const status = q.state.data?.status
return status === 'Succeeded' || status === 'Failed' ? false : 1500
},
})
} }
export function MoySkladImportPage() { export function MoySkladImportPage() {
const qc = useQueryClient() const qc = useQueryClient()
const [token, setToken] = useState('')
const [overwrite, setOverwrite] = useState(false) const settings = useQuery({
queryKey: ['/api/admin/moysklad/settings'],
queryFn: async () => (await api.get<SettingsDto>('/api/admin/moysklad/settings')).data,
})
const [tokenInput, setTokenInput] = useState('')
const [overwrite, setOverwrite] = useState(true)
const saveToken = useMutation({
mutationFn: async () =>
(await api.put<SettingsDto>('/api/admin/moysklad/settings', { token: tokenInput })).data,
onSuccess: () => {
setTokenInput('')
qc.invalidateQueries({ queryKey: ['/api/admin/moysklad/settings'] })
},
})
const test = useMutation({ const test = useMutation({
mutationFn: async () => (await api.post<TestResponse>('/api/admin/moysklad/test', { token })).data, mutationFn: async () =>
(await api.post<{ organization: string; inn?: string }>('/api/admin/moysklad/test', {})).data,
}) })
const products = useMutation({ const [productsJobId, setProductsJobId] = useState<string | null>(null)
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-products', { token, overwriteExisting: overwrite })).data, const [counterpartiesJobId, setCounterpartiesJobId] = useState<string | null>(null)
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }),
const startProducts = useMutation({
mutationFn: async () =>
(await api.post<{ jobId: string }>('/api/admin/moysklad/import-products', { overwriteExisting: overwrite })).data,
onSuccess: (d) => setProductsJobId(d.jobId),
})
const startCounterparties = useMutation({
mutationFn: async () =>
(await api.post<{ jobId: string }>('/api/admin/moysklad/import-counterparties', { overwriteExisting: overwrite })).data,
onSuccess: (d) => setCounterpartiesJobId(d.jobId),
}) })
const counterparties = useMutation({ const productsJob = useJob(productsJobId)
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-counterparties', { token, overwriteExisting: overwrite })).data, const counterpartiesJob = useJob(counterpartiesJobId)
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/counterparties'] }),
}) const hasToken = settings.data?.hasToken ?? false
return ( return (
<div className="h-full overflow-auto"> <div className="h-full overflow-auto">
<div className="p-6 max-w-3xl"> <div className="p-6 max-w-3xl">
<PageHeader <PageHeader
title="Импорт из МойСклад" title="Импорт из МойСклад"
description="Перенос товаров, групп, контрагентов из учётной записи МойСклад в food-market." description="Перенос товаров, групп и контрагентов из учётной записи МойСклад."
/> />
<section className="mb-5 rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-900/50 p-3.5 text-sm">
<div className="flex gap-2.5 items-start">
<AlertCircle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="text-amber-900 dark:text-amber-200 space-y-1.5">
<p><strong>Токен не сохраняется</strong> передаётся только в текущий запрос и не пишется ни в БД, ни в логи.</p>
<p>Получить токен: <a className="underline" href="https://online.moysklad.ru/app/#admin" target="_blank" rel="noreferrer">online.moysklad.ru/app</a> Настройки аккаунта Доступ к API создать токен.</p>
<p>Рекомендуется отдельный сервисный аккаунт с правом только на чтение.</p>
</div>
</div>
</section>
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4"> <section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
<Field label="Токен МойСклад (Bearer)"> <h2 className="text-sm font-semibold">Токен API</h2>
{settings.data && (
<div className="text-sm">
{hasToken ? (
<div className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
<CheckCircle className="w-4 h-4" /> сохранён:{' '}
<code className="font-mono">{settings.data.masked}</code>
</div>
) : (
<div className="text-amber-700 dark:text-amber-400">Ещё не задан импорт не сработает.</div>
)}
</div>
)}
<Field label={hasToken ? 'Заменить токен' : 'Bearer-токен MoySklad'}>
<TextInput <TextInput
type="password" type="password"
value={token} value={tokenInput}
onChange={(e) => setToken(e.target.value)} onChange={(e) => setTokenInput(e.target.value)}
placeholder="персональный токен или токен сервисного аккаунта" placeholder="персональный или сервисный токен"
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
/> />
</Field> </Field>
<div className="flex gap-2 items-center flex-wrap">
<div className="flex gap-3 items-center flex-wrap"> <Button
onClick={() => saveToken.mutate()}
disabled={!tokenInput || saveToken.isPending}
>
<Save className="w-4 h-4" />
{saveToken.isPending ? 'Сохраняю…' : 'Сохранить токен'}
</Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => test.mutate()} onClick={() => test.mutate()}
disabled={!token || test.isPending} disabled={!hasToken || test.isPending}
> >
<KeyRound className="w-4 h-4" /> <KeyRound className="w-4 h-4" />
{test.isPending ? 'Проверяю…' : 'Проверить соединение'} {test.isPending ? 'Проверяю…' : 'Проверить соединение'}
</Button> </Button>
{test.data && ( {test.data && (
<div className="text-sm text-green-700 dark:text-green-400 flex items-center gap-1.5"> <div className="text-sm text-emerald-700 dark:text-emerald-400 flex items-center gap-1.5">
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-4 h-4" /> Подключено: <strong>{test.data.organization}</strong>
Подключено: <strong>{test.data.organization}</strong>
{test.data.inn && <span className="text-slate-500">(ИНН {test.data.inn})</span>} {test.data.inn && <span className="text-slate-500">(ИНН {test.data.inn})</span>}
</div> </div>
)} )}
{test.error && <div className="text-sm text-red-600">{formatError(test.error)}</div>} {test.error && <div className="text-sm text-red-600">{formatError(test.error)}</div>}
{saveToken.error && <div className="text-sm text-red-600">{formatError(saveToken.error)}</div>}
</div> </div>
</section> </section>
<section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4"> <section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Операции импорта</h2> <h2 className="text-sm font-semibold">Операции импорта</h2>
<Checkbox <Checkbox
label="Перезаписать существующие записи (по артикулу/имени)" label="Обновлять уже импортированные записи (если найдены по артикулу/имени)"
checked={overwrite} checked={overwrite}
onChange={setOverwrite} onChange={setOverwrite}
/> />
<div className="flex gap-3 flex-wrap"> <div className="flex gap-3 flex-wrap">
<Button onClick={() => products.mutate()} disabled={!token || products.isPending}> <Button onClick={() => startProducts.mutate()} disabled={!hasToken || startProducts.isPending || productsJob.data?.status === 'Running'}>
<Package className="w-4 h-4" /> <Package className="w-4 h-4" />
{products.isPending ? 'Импортирую товары…' : 'Товары + группы + цены'} {productsJob.data?.status === 'Running' ? 'Товары импортируются…' : 'Товары + группы + цены'}
</Button> </Button>
<Button onClick={() => counterparties.mutate()} disabled={!token || counterparties.isPending}> <Button onClick={() => startCounterparties.mutate()} disabled={!hasToken || startCounterparties.isPending || counterpartiesJob.data?.status === 'Running'}>
<Download className="w-4 h-4" />
<Users className="w-4 h-4" /> <Users className="w-4 h-4" />
{counterparties.isPending ? 'Импортирую контрагентов…' : 'Контрагенты'} {counterpartiesJob.data?.status === 'Running' ? 'Контрагенты импортируются…' : 'Контрагенты'}
</Button> </Button>
</div> </div>
</section> </section>
<ImportResult title="Товары" result={products} /> {productsJob.data && <JobCard title="Импорт товаров" job={productsJob.data} />}
<ImportResult title="Контрагенты" result={counterparties} /> {counterpartiesJob.data && <JobCard title="Импорт контрагентов" job={counterpartiesJob.data} />}
<DangerZone />
</div> </div>
</div> </div>
) )
} }
function ImportResult({ title, result }: { title: string; result: UseMutationResult<ImportResponse, Error, void, unknown> }) { function JobCard({ title, job }: { title: string; job: JobView }) {
if (!result.data && !result.error) return null const done = job.status === 'Succeeded' || job.status === 'Failed'
const color = job.status === 'Succeeded' ? 'text-emerald-600'
: job.status === 'Failed' ? 'text-red-600' : 'text-slate-500'
return ( return (
<section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5"> <section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
<h3 className="text-sm font-semibold mb-3 text-slate-900 dark:text-slate-100 flex items-center gap-2"> <h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
{result.data {job.status === 'Succeeded'
? <><CheckCircle className="w-4 h-4 text-green-600" /> {title} импорт завершён</> ? <CheckCircle className="w-4 h-4 text-emerald-600" />
: <><AlertCircle className="w-4 h-4 text-red-600" /> {title} ошибка</>} : job.status === 'Failed'
? <AlertCircle className="w-4 h-4 text-red-600" />
: <span className="inline-block w-3 h-3 rounded-full bg-emerald-500 animate-pulse" />}
{title}
<span className={`text-xs font-normal ${color}`}>
{job.status === 'Running' ? (job.stage ?? 'идёт…') : job.status}
</span>
</h3> </h3>
{result.data && ( <dl className="grid grid-cols-5 gap-3 text-sm">
<> <Stat label="Всего" value={job.total} />
<dl className="grid grid-cols-4 gap-3 text-sm"> <Stat label="Создано" value={job.created} accent="green" />
<StatBox label="Всего получено" value={result.data.total} /> <Stat label="Обновлено" value={job.updated} />
<StatBox label="Создано" value={result.data.created} accent="green" /> <Stat label="Пропущено" value={job.skipped} />
<StatBox label="Пропущено" value={result.data.skipped} /> <Stat label="Групп" value={job.groupsCreated} />
<StatBox label="Групп создано" value={result.data.groupsCreated} />
</dl> </dl>
{result.data.errors.length > 0 && ( {done && job.message && (
<details className="mt-4"> <div className={`mt-3 text-sm ${color}`}>{job.message}</div>
<summary className="text-sm text-red-600 cursor-pointer"> )}
Ошибок: {result.data.errors.length} (развернуть) {job.errors.length > 0 && (
</summary> <details className="mt-3">
<ul className="mt-2 text-xs font-mono bg-red-50 dark:bg-red-950/30 p-3 rounded space-y-0.5 max-h-80 overflow-auto"> <summary className="text-xs text-red-600 cursor-pointer">Ошибок: {job.errors.length}</summary>
{result.data.errors.map((e, i) => <li key={i}>{e}</li>)} <ul className="mt-2 text-[11px] font-mono bg-red-50 dark:bg-red-950/30 p-3 rounded space-y-0.5 max-h-60 overflow-auto">
{job.errors.map((e, i) => <li key={i}>{e}</li>)}
</ul> </ul>
</details> </details>
)} )}
</> </section>
)
}
function Stat({ label, value, accent }: { label: string; value: number; accent?: 'green' }) {
const bg = accent === 'green' ? 'bg-emerald-50 dark:bg-emerald-950/30' : 'bg-slate-50 dark:bg-slate-800/50'
const fg = accent === 'green' ? 'text-emerald-700 dark:text-emerald-400' : ''
return (
<div className={`rounded-lg ${bg} p-3`}>
<dt className={`text-xs uppercase ${accent === 'green' ? fg : 'text-slate-500'}`}>{label}</dt>
<dd className={`text-lg font-semibold mt-1 ${fg}`}>{value.toLocaleString('ru')}</dd>
</div>
)
}
interface CleanupStats {
counterparties: number; products: number; productGroups: number
productBarcodes: number; productPrices: number; supplies: number
retailSales: number; stocks: number; stockMovements: number
}
function DangerZone() {
const qc = useQueryClient()
const stats = useQuery({
queryKey: ['/api/admin/cleanup/stats'],
queryFn: async () => (await api.get<CleanupStats>('/api/admin/cleanup/stats')).data,
refetchOnMount: 'always',
})
const [wipeJobId, setWipeJobId] = useState<string | null>(null)
const wipeJob = useJob(wipeJobId)
const startWipe = useMutation({
mutationFn: async () => (await api.post<{ jobId: string }>('/api/admin/cleanup/all/async')).data,
onSuccess: (d) => {
setWipeJobId(d.jobId)
qc.invalidateQueries({ queryKey: ['/api/admin/cleanup/stats'] })
},
})
const confirmAndRun = (label: string, run: () => void) => {
const word = prompt(`Введи УДАЛИТЬ чтобы подтвердить: ${label}`)
if (word?.trim().toUpperCase() === 'УДАЛИТЬ') run()
}
const s = stats.data
return (
<section className="mt-8 rounded-xl border-2 border-red-200 dark:border-red-900/50 bg-red-50/40 dark:bg-red-950/20 p-5">
<h2 className="text-sm font-semibold text-red-900 dark:text-red-300 flex items-center gap-2 mb-1.5">
<AlertTriangle className="w-4 h-4" /> Опасная зона полная очистка данных
</h2>
<p className="text-xs text-red-900/80 dark:text-red-300/80 mb-4">
Удаляет товары, группы, контрагентов, документы и остатки. Справочники, пользователи, склады и организация сохраняются.
</p>
{s && (
<dl className="grid grid-cols-3 md:grid-cols-5 gap-2 text-xs mb-4">
<Tile label="Контрагенты" value={s.counterparties} />
<Tile label="Товары" value={s.products} />
<Tile label="Группы" value={s.productGroups} />
<Tile label="Штрихкоды" value={s.productBarcodes} />
<Tile label="Цены" value={s.productPrices} />
<Tile label="Поставки" value={s.supplies} />
<Tile label="Чеки" value={s.retailSales} />
<Tile label="Остатки" value={s.stocks} />
<Tile label="Движения" value={s.stockMovements} />
</dl>
)} )}
{result.error && ( <Button
<div className="text-sm text-red-700 dark:text-red-300">{formatError(result.error)}</div> variant="danger"
onClick={() => confirmAndRun(
'ВСЕ данные организации',
() => startWipe.mutate(),
)}
disabled={startWipe.isPending || wipeJob.data?.status === 'Running'}
>
<Trash2 className="w-4 h-4" />
{wipeJob.data?.status === 'Running' ? 'Очищаю…' : 'Очистить все данные'}
</Button>
{wipeJob.data && (
<div className="mt-4 rounded-lg bg-white dark:bg-slate-900 border border-red-200 dark:border-red-900/50 p-3 text-sm">
<div className="flex items-center gap-2 mb-2">
{wipeJob.data.status === 'Succeeded' && <CheckCircle className="w-4 h-4 text-emerald-600" />}
{wipeJob.data.status === 'Failed' && <AlertCircle className="w-4 h-4 text-red-600" />}
{wipeJob.data.status === 'Running' && <span className="inline-block w-3 h-3 rounded-full bg-red-500 animate-pulse" />}
<strong>{wipeJob.data.stage ?? wipeJob.data.status}</strong>
<span className="text-xs text-slate-500">удалено записей: {wipeJob.data.deleted.toLocaleString('ru')}</span>
</div>
{wipeJob.data.message && <div className="text-xs text-slate-600">{wipeJob.data.message}</div>}
</div>
)} )}
</section> </section>
) )
} }
function StatBox({ label, value, accent }: { label: string; value: number; accent?: 'green' }) { function Tile({ label, value }: { label: string; value: number }) {
const bg = accent === 'green' ? 'bg-green-50 dark:bg-green-950/30' : 'bg-slate-50 dark:bg-slate-800/50'
const fg = accent === 'green' ? 'text-green-700 dark:text-green-400' : ''
return ( return (
<div className={`rounded-lg ${bg} p-3`}> <div className="rounded bg-white dark:bg-slate-900 border border-red-200 dark:border-red-900/50 px-2 py-1.5">
<dt className={`text-xs uppercase ${accent === 'green' ? fg : 'text-slate-500'}`}>{label}</dt> <dt className="text-[10px] uppercase text-slate-500">{label}</dt>
<dd className={`text-xl font-semibold mt-1 ${fg}`}>{value.toLocaleString('ru')}</dd> <dd className="font-mono font-semibold">{value.toLocaleString('ru')}</dd>
</div> </div>
) )
} }

View file

@ -6,7 +6,7 @@ import { api } from '@/lib/api'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field' import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
import { import {
useUnits, useVatRates, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers, useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
} from '@/lib/useLookups' } from '@/lib/useLookups'
import { BarcodeType, type Product } from '@/lib/types' import { BarcodeType, type Product } from '@/lib/types'
@ -18,13 +18,13 @@ interface Form {
article: string article: string
description: string description: string
unitOfMeasureId: string unitOfMeasureId: string
vatRateId: string vat: number
vatEnabled: boolean
productGroupId: string productGroupId: string
defaultSupplierId: string defaultSupplierId: string
countryOfOriginId: string countryOfOriginId: string
isService: boolean isService: boolean
isWeighed: boolean isWeighed: boolean
isAlcohol: boolean
isMarked: boolean isMarked: boolean
isActive: boolean isActive: boolean
minStock: string minStock: string
@ -36,11 +36,15 @@ interface Form {
barcodes: BarcodeRow[] barcodes: BarcodeRow[]
} }
// KZ default VAT rate.
const defaultVat = 16
const vatChoices = [0, 10, 12, 16, 20]
const emptyForm: Form = { const emptyForm: Form = {
name: '', article: '', description: '', name: '', article: '', description: '',
unitOfMeasureId: '', vatRateId: '', unitOfMeasureId: '', vat: defaultVat, vatEnabled: true,
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '', productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
isService: false, isWeighed: false, isAlcohol: false, isMarked: false, isActive: true, isService: false, isWeighed: false, isMarked: false, isActive: true,
minStock: '', maxStock: '', minStock: '', maxStock: '',
purchasePrice: '', purchaseCurrencyId: '', purchasePrice: '', purchaseCurrencyId: '',
imageUrl: '', imageUrl: '',
@ -55,7 +59,6 @@ export function ProductEditPage() {
const qc = useQueryClient() const qc = useQueryClient()
const units = useUnits() const units = useUnits()
const vats = useVatRates()
const groups = useProductGroups() const groups = useProductGroups()
const countries = useCountries() const countries = useCountries()
const currencies = useCurrencies() const currencies = useCurrencies()
@ -76,10 +79,10 @@ export function ProductEditPage() {
const p = existing.data const p = existing.data
setForm({ setForm({
name: p.name, article: p.article ?? '', description: p.description ?? '', name: p.name, article: p.article ?? '', description: p.description ?? '',
unitOfMeasureId: p.unitOfMeasureId, vatRateId: p.vatRateId, unitOfMeasureId: p.unitOfMeasureId, vat: p.vat, vatEnabled: p.vatEnabled,
productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '', productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '',
countryOfOriginId: p.countryOfOriginId ?? '', countryOfOriginId: p.countryOfOriginId ?? '',
isService: p.isService, isWeighed: p.isWeighed, isAlcohol: p.isAlcohol, isMarked: p.isMarked, isService: p.isService, isWeighed: p.isWeighed, isMarked: p.isMarked,
isActive: p.isActive, isActive: p.isActive,
minStock: p.minStock?.toString() ?? '', minStock: p.minStock?.toString() ?? '',
maxStock: p.maxStock?.toString() ?? '', maxStock: p.maxStock?.toString() ?? '',
@ -93,16 +96,13 @@ export function ProductEditPage() {
}, [isNew, existing.data]) }, [isNew, existing.data])
useEffect(() => { useEffect(() => {
if (isNew && form.vatRateId === '' && vats.data?.length) {
setForm((f) => ({ ...f, vatRateId: vats.data?.find(v => v.isDefault)?.id ?? vats.data?.[0]?.id ?? '' }))
}
if (isNew && form.unitOfMeasureId === '' && units.data?.length) { if (isNew && form.unitOfMeasureId === '' && units.data?.length) {
setForm((f) => ({ ...f, unitOfMeasureId: units.data?.find(u => u.isBase)?.id ?? units.data?.[0]?.id ?? '' })) setForm((f) => ({ ...f, unitOfMeasureId: units.data?.[0]?.id ?? '' }))
} }
if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) { if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) {
setForm((f) => ({ ...f, purchaseCurrencyId: currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id ?? '' })) setForm((f) => ({ ...f, purchaseCurrencyId: currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id ?? '' }))
} }
}, [isNew, vats.data, units.data, currencies.data, form.vatRateId, form.unitOfMeasureId, form.purchaseCurrencyId]) }, [isNew, units.data, currencies.data, form.unitOfMeasureId, form.purchaseCurrencyId])
const save = useMutation({ const save = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -111,13 +111,13 @@ export function ProductEditPage() {
article: form.article || null, article: form.article || null,
description: form.description || null, description: form.description || null,
unitOfMeasureId: form.unitOfMeasureId, unitOfMeasureId: form.unitOfMeasureId,
vatRateId: form.vatRateId, vat: form.vat,
vatEnabled: form.vatEnabled,
productGroupId: form.productGroupId || null, productGroupId: form.productGroupId || null,
defaultSupplierId: form.defaultSupplierId || null, defaultSupplierId: form.defaultSupplierId || null,
countryOfOriginId: form.countryOfOriginId || null, countryOfOriginId: form.countryOfOriginId || null,
isService: form.isService, isService: form.isService,
isWeighed: form.isWeighed, isWeighed: form.isWeighed,
isAlcohol: form.isAlcohol,
isMarked: form.isMarked, isMarked: form.isMarked,
isActive: form.isActive, isActive: form.isActive,
minStock: form.minStock === '' ? null : Number(form.minStock), minStock: form.minStock === '' ? null : Number(form.minStock),
@ -168,7 +168,7 @@ export function ProductEditPage() {
const updateBarcode = (i: number, patch: Partial<BarcodeRow>) => const updateBarcode = (i: number, patch: Partial<BarcodeRow>) =>
setForm({ ...form, barcodes: form.barcodes.map((b, ix) => ix === i ? { ...b, ...patch } : b) }) setForm({ ...form, barcodes: form.barcodes.map((b, ix) => ix === i ? { ...b, ...patch } : b) })
const canSave = form.name.trim().length > 0 && !!form.unitOfMeasureId && !!form.vatRateId const canSave = form.name.trim().length > 0 && !!form.unitOfMeasureId
return ( return (
<form onSubmit={onSubmit} className="flex flex-col h-full"> <form onSubmit={onSubmit} className="flex flex-col h-full">
@ -234,13 +234,12 @@ export function ProductEditPage() {
<Field label="Единица измерения *"> <Field label="Единица измерения *">
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}> <Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
<option value=""></option> <option value=""></option>
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.symbol} {u.name}</option>)} {units.data?.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</Select> </Select>
</Field> </Field>
<Field label="Ставка НДС *"> <Field label="Ставка НДС, %">
<Select required value={form.vatRateId} onChange={(e) => setForm({ ...form, vatRateId: e.target.value })}> <Select value={form.vat} onChange={(e) => setForm({ ...form, vat: Number(e.target.value) })}>
<option value=""></option> {vatChoices.map((v) => <option key={v} value={v}>{v}%</option>)}
{vats.data?.map((v) => <option key={v.id} value={v.id}>{v.name} ({v.percent}%)</option>)}
</Select> </Select>
</Field> </Field>
<Field label="Группа"> <Field label="Группа">
@ -266,9 +265,9 @@ export function ProductEditPage() {
</Field> </Field>
</Grid> </Grid>
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2"> <div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2">
<Checkbox label="НДС применяется" checked={form.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} />
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} /> <Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
<Checkbox label="Весовой" checked={form.isWeighed} onChange={(v) => setForm({ ...form, isWeighed: v })} /> <Checkbox label="Весовой" checked={form.isWeighed} onChange={(v) => setForm({ ...form, isWeighed: v })} />
<Checkbox label="Алкоголь" checked={form.isAlcohol} onChange={(v) => setForm({ ...form, isAlcohol: v })} />
<Checkbox label="Маркируемый" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} /> <Checkbox label="Маркируемый" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} /> <Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div> </div>

View file

@ -1,37 +1,159 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Plus } from 'lucide-react' import { Plus, Filter, X } from 'lucide-react'
import { useCatalogList } from '@/lib/useCatalog' import { useCatalogList } from '@/lib/useCatalog'
import { ProductGroupTree } from '@/components/ProductGroupTree'
import type { Product } from '@/lib/types' import type { Product } from '@/lib/types'
const URL = '/api/catalog/products' const URL = '/api/catalog/products'
type TriFilter = 'all' | 'yes' | 'no'
interface Filters {
groupId: string | null
isActive: TriFilter
isService: TriFilter
isWeighed: TriFilter
isMarked: TriFilter
hasBarcode: TriFilter
}
const defaultFilters: Filters = {
groupId: null,
isActive: 'yes',
isService: 'all',
isWeighed: 'all',
isMarked: 'all',
hasBarcode: 'all',
}
const toExtra = (f: Filters): Record<string, string | number | boolean | undefined> => {
const e: Record<string, string | number | boolean | undefined> = {}
if (f.groupId) e.groupId = f.groupId
if (f.isActive !== 'all') e.isActive = f.isActive === 'yes'
if (f.isService !== 'all') e.isService = f.isService === 'yes'
if (f.isWeighed !== 'all') e.isWeighed = f.isWeighed === 'yes'
if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes'
if (f.hasBarcode !== 'all') e.hasBarcode = f.hasBarcode === 'yes'
return e
}
const activeFilterCount = (f: Filters) => {
let n = 0
if (f.groupId) n++
if (f.isActive !== 'yes') n++ // 'yes' is default, count non-default
if (f.isService !== 'all') n++
if (f.isWeighed !== 'all') n++
if (f.isMarked !== 'all') n++
if (f.hasBarcode !== 'all') n++
return n
}
function Tri({
label, value, onChange, yesLabel = 'да', noLabel = 'нет',
}: {
label: string
value: TriFilter
onChange: (v: TriFilter) => void
yesLabel?: string
noLabel?: string
}) {
const opts: { v: TriFilter; t: string }[] = [
{ v: 'all', t: 'все' },
{ v: 'yes', t: yesLabel },
{ v: 'no', t: noLabel },
]
return (
<div className="flex items-center gap-2 text-xs">
<span className="text-slate-500">{label}</span>
<div className="inline-flex rounded border border-slate-200 dark:border-slate-700 overflow-hidden">
{opts.map((o) => (
<button
key={o.v}
type="button"
onClick={() => onChange(o.v)}
className={
'px-2 py-0.5 ' +
(value === o.v
? 'bg-slate-900 text-white dark:bg-slate-200 dark:text-slate-900'
: 'bg-white dark:bg-slate-900 text-slate-600 hover:bg-slate-50 dark:hover:bg-slate-800')
}
>
{o.t}
</button>
))}
</div>
</div>
)
}
export function ProductsPage() { export function ProductsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL) const [filters, setFilters] = useState<Filters>(defaultFilters)
const [filtersOpen, setFiltersOpen] = useState(false)
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL, toExtra(filters))
const activeCount = activeFilterCount(filters)
return ( return (
<ListPageShell <div className="flex h-full min-h-0">
title="Товары" {/* Left: groups tree */}
description={data ? `${data.total.toLocaleString('ru')} записей в каталоге` : 'Каталог товаров и услуг'} <aside className="w-64 flex-shrink-0 border-r border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-900/40 flex flex-col min-h-0">
actions={ <ProductGroupTree
<> selectedId={filters.groupId}
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" /> onSelect={(id) => { setFilters({ ...filters, groupId: id }); setPage(1) }}
<Link to="/catalog/products/new"> />
<Button> </aside>
<Plus className="w-4 h-4" /> Добавить
</Button> {/* Right: products */}
</Link> <div className="flex-1 min-w-0 flex flex-col">
</> {/* Top bar */}
} <div className="px-6 py-3 border-b border-slate-200 dark:border-slate-800 flex items-center gap-3 flex-wrap">
footer={data && data.total > 0 && ( <div>
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} /> <h1 className="text-base font-semibold">Товары</h1>
)} <p className="text-xs text-slate-500">
{data ? `${data.total.toLocaleString('ru')} записей` : 'Каталог товаров и услуг'}
</p>
</div>
<div className="ml-auto flex items-center gap-2">
<SearchBar value={search} onChange={setSearch} placeholder="Поиск: название, артикул, штрихкод…" />
<Button
variant={filtersOpen || activeCount ? 'primary' : 'secondary'}
onClick={() => setFiltersOpen((v) => !v)}
> >
<Filter className="w-4 h-4" /> Фильтры{activeCount > 0 ? ` (${activeCount})` : ''}
</Button>
<Link to="/catalog/products/new">
<Button><Plus className="w-4 h-4" /> Добавить</Button>
</Link>
</div>
</div>
{/* Filter panel */}
{filtersOpen && (
<div className="px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-900/60 flex flex-wrap gap-4 items-center">
<Tri label="Активные" value={filters.isActive} onChange={(v) => { setFilters({ ...filters, isActive: v }); setPage(1) }} />
<Tri label="Услуга" value={filters.isService} onChange={(v) => { setFilters({ ...filters, isService: v }); setPage(1) }} />
<Tri label="Весовой" value={filters.isWeighed} onChange={(v) => { setFilters({ ...filters, isWeighed: v }); setPage(1) }} />
<Tri label="Маркируемый" value={filters.isMarked} onChange={(v) => { setFilters({ ...filters, isMarked: v }); setPage(1) }} />
<Tri label="Со штрихкодом" value={filters.hasBarcode} onChange={(v) => { setFilters({ ...filters, hasBarcode: v }); setPage(1) }} yesLabel="есть" noLabel="нет" />
{activeCount > 0 && (
<button
type="button"
onClick={() => { setFilters(defaultFilters); setPage(1) }}
className="text-xs text-slate-500 hover:text-slate-800 inline-flex items-center gap-1"
>
<X className="w-3.5 h-3.5" /> Сбросить
</button>
)}
</div>
)}
{/* Table */}
<div className="flex-1 min-h-0 overflow-auto">
<DataTable <DataTable
rows={data?.items ?? []} rows={data?.items ?? []}
isLoading={isLoading} isLoading={isLoading}
@ -45,13 +167,12 @@ export function ProductsPage() {
</div> </div>
)}, )},
{ header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' }, { header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' },
{ header: 'Ед.', width: '70px', cell: (r) => r.unitSymbol }, { header: 'Ед.', width: '70px', cell: (r) => r.unitName },
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vatPercent}%` }, { header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vat}%` },
{ header: 'Тип', width: '140px', cell: (r) => ( { header: 'Тип', width: '140px', cell: (r) => (
<div className="flex gap-1 flex-wrap"> <div className="flex gap-1 flex-wrap">
{r.isService && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">Услуга</span>} {r.isService && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">Услуга</span>}
{r.isWeighed && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Весовой</span>} {r.isWeighed && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Весовой</span>}
{r.isAlcohol && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-red-700">Алкоголь</span>}
{r.isMarked && <span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">Маркир.</span>} {r.isMarked && <span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">Маркир.</span>}
</div> </div>
)}, )},
@ -60,6 +181,14 @@ export function ProductsPage() {
]} ]}
empty="Товаров ещё нет. Они появятся после приёмки или через API." empty="Товаров ещё нет. Они появятся после приёмки или через API."
/> />
</ListPageShell> </div>
{data && data.total > 0 && (
<div className="px-6 py-3 border-t border-slate-200 dark:border-slate-800">
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
</div>
)}
</div>
</div>
) )
} }

View file

@ -14,7 +14,7 @@ interface LineRow {
productId: string productId: string
productName: string productName: string
productArticle: string | null productArticle: string | null
unitSymbol: string | null unitName: string | null
quantity: number quantity: number
unitPrice: number unitPrice: number
discount: number discount: number
@ -81,7 +81,7 @@ export function RetailSaleEditPage() {
productId: l.productId, productId: l.productId,
productName: l.productName ?? '', productName: l.productName ?? '',
productArticle: l.productArticle, productArticle: l.productArticle,
unitSymbol: l.unitSymbol, unitName: l.unitName,
quantity: l.quantity, unitPrice: l.unitPrice, discount: l.discount, quantity: l.quantity, unitPrice: l.unitPrice, discount: l.discount,
vatPercent: l.vatPercent, vatPercent: l.vatPercent,
})), })),
@ -179,11 +179,11 @@ export function RetailSaleEditPage() {
productId: p.id, productId: p.id,
productName: p.name, productName: p.name,
productArticle: p.article, productArticle: p.article,
unitSymbol: p.unitSymbol, unitName: p.unitName,
quantity: 1, quantity: 1,
unitPrice: retail?.amount ?? 0, unitPrice: retail?.amount ?? 0,
discount: 0, discount: 0,
vatPercent: p.vatPercent, vatPercent: p.vat * 1,
}], }],
}) })
} }
@ -323,7 +323,7 @@ export function RetailSaleEditPage() {
<div className="font-medium">{l.productName}</div> <div className="font-medium">{l.productName}</div>
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>} {l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
</td> </td>
<td className="py-2 px-3 text-slate-500">{l.unitSymbol}</td> <td className="py-2 px-3 text-slate-500">{l.unitName}</td>
<td className="py-2 px-3"> <td className="py-2 px-3">
<TextInput type="number" step="0.001" disabled={isPosted} <TextInput type="number" step="0.001" disabled={isPosted}
className="text-right font-mono" value={l.quantity} className="text-right font-mono" value={l.quantity}

View file

@ -60,7 +60,7 @@ export function StockPage() {
</div> </div>
)}, )},
{ header: 'Склад', width: '220px', cell: (r) => r.storeName }, { header: 'Склад', width: '220px', cell: (r) => r.storeName },
{ header: 'Ед.', width: '80px', cell: (r) => r.unitSymbol }, { header: 'Ед.', width: '80px', cell: (r) => r.unitName },
{ header: 'Остаток', width: '130px', className: 'text-right font-mono', cell: (r) => r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 }) }, { header: 'Остаток', width: '130px', className: 'text-right font-mono', cell: (r) => r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 }) },
{ header: 'Резерв', width: '110px', className: 'text-right font-mono text-slate-400', cell: (r) => r.reservedQuantity ? r.reservedQuantity.toLocaleString('ru') : '—' }, { header: 'Резерв', width: '110px', className: 'text-right font-mono text-slate-400', cell: (r) => r.reservedQuantity ? r.reservedQuantity.toLocaleString('ru') : '—' },
{ header: 'Доступно', width: '130px', className: 'text-right font-mono font-semibold', cell: (r) => ( { header: 'Доступно', width: '130px', className: 'text-right font-mono font-semibold', cell: (r) => (

View file

@ -6,9 +6,9 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal' import { Modal } from '@/components/Modal'
import { Field, TextInput, Select, Checkbox } from '@/components/Field' import { Field, TextInput, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog' import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { type Store, StoreKind } from '@/lib/types' import { type Store } from '@/lib/types'
const URL = '/api/catalog/stores' const URL = '/api/catalog/stores'
@ -16,7 +16,6 @@ interface Form {
id?: string id?: string
name: string name: string
code: string code: string
kind: StoreKind
address: string address: string
phone: string phone: string
managerName: string managerName: string
@ -25,7 +24,7 @@ interface Form {
} }
const blankForm: Form = { const blankForm: Form = {
name: '', code: '', kind: StoreKind.Warehouse, address: '', phone: '', name: '', code: '', address: '', phone: '',
managerName: '', isMain: false, isActive: true, managerName: '', isMain: false, isActive: true,
} }
@ -62,14 +61,13 @@ export function StoresPage() {
isLoading={isLoading} isLoading={isLoading}
rowKey={(r) => r.id} rowKey={(r) => r.id}
onRowClick={(r) => setForm({ onRowClick={(r) => setForm({
id: r.id, name: r.name, code: r.code ?? '', kind: r.kind, id: r.id, name: r.name, code: r.code ?? '',
address: r.address ?? '', phone: r.phone ?? '', managerName: r.managerName ?? '', address: r.address ?? '', phone: r.phone ?? '', managerName: r.managerName ?? '',
isMain: r.isMain, isActive: r.isActive, isMain: r.isMain, isActive: r.isActive,
})} })}
columns={[ columns={[
{ header: 'Название', cell: (r) => r.name }, { header: 'Название', cell: (r) => r.name },
{ header: 'Код', width: '120px', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> }, { header: 'Код', width: '120px', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> },
{ header: 'Тип', width: '150px', cell: (r) => r.kind === StoreKind.Warehouse ? 'Склад' : 'Торговый зал' },
{ header: 'Адрес', cell: (r) => r.address ?? '—' }, { header: 'Адрес', cell: (r) => r.address ?? '—' },
{ header: 'Основной', width: '110px', cell: (r) => r.isMain ? '✓' : '—' }, { header: 'Основной', width: '110px', cell: (r) => r.isMain ? '✓' : '—' },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, { header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
@ -108,12 +106,6 @@ export function StoresPage() {
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /> <TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
</Field> </Field>
</div> </div>
<Field label="Тип">
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as StoreKind })}>
<option value={StoreKind.Warehouse}>Склад</option>
<option value={StoreKind.RetailFloor}>Торговый зал</option>
</Select>
</Field>
<Field label="Адрес"> <Field label="Адрес">
<TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} /> <TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</Field> </Field>

View file

@ -14,7 +14,7 @@ interface LineRow {
productId: string productId: string
productName: string productName: string
productArticle: string | null productArticle: string | null
unitSymbol: string | null unitName: string | null
quantity: number quantity: number
unitPrice: number unitPrice: number
} }
@ -76,7 +76,7 @@ export function SupplyEditPage() {
productId: l.productId, productId: l.productId,
productName: l.productName ?? '', productName: l.productName ?? '',
productArticle: l.productArticle, productArticle: l.productArticle,
unitSymbol: l.unitSymbol, unitName: l.unitName,
quantity: l.quantity, quantity: l.quantity,
unitPrice: l.unitPrice, unitPrice: l.unitPrice,
})), })),
@ -169,7 +169,7 @@ export function SupplyEditPage() {
productId: p.id, productId: p.id,
productName: p.name, productName: p.name,
productArticle: p.article, productArticle: p.article,
unitSymbol: p.unitSymbol, unitName: p.unitName,
quantity: 1, quantity: 1,
unitPrice: p.purchasePrice ?? 0, unitPrice: p.purchasePrice ?? 0,
}], }],
@ -304,7 +304,7 @@ export function SupplyEditPage() {
<div className="font-medium">{l.productName}</div> <div className="font-medium">{l.productName}</div>
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>} {l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
</td> </td>
<td className="py-2 px-3 text-slate-500">{l.unitSymbol}</td> <td className="py-2 px-3 text-slate-500">{l.unitName}</td>
<td className="py-2 px-3"> <td className="py-2 px-3">
<TextInput type="number" step="0.001" disabled={isPosted} <TextInput type="number" step="0.001" disabled={isPosted}
className="text-right font-mono" className="text-right font-mono"

View file

@ -15,14 +15,12 @@ const URL = '/api/catalog/units-of-measure'
interface Form { interface Form {
id?: string id?: string
code: string code: string
symbol: string
name: string name: string
decimalPlaces: number description: string
isBase: boolean
isActive: boolean isActive: boolean
} }
const blankForm: Form = { code: '', symbol: '', name: '', decimalPlaces: 0, isBase: false, isActive: true } const blankForm: Form = { code: '', name: '', description: '', isActive: true }
export function UnitsOfMeasurePage() { export function UnitsOfMeasurePage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<UnitOfMeasure>(URL) const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<UnitOfMeasure>(URL)
@ -41,7 +39,7 @@ export function UnitsOfMeasurePage() {
<> <>
<ListPageShell <ListPageShell
title="Единицы измерения" title="Единицы измерения"
description="Код по ОКЕИ (шт=796, кг=166, л=112, м=006)." description="Код по ОКЕИ (штука=796, килограмм=166, литр=112, метр=006)."
actions={ actions={
<> <>
<SearchBar value={search} onChange={setSearch} /> <SearchBar value={search} onChange={setSearch} />
@ -56,13 +54,14 @@ export function UnitsOfMeasurePage() {
rows={data?.items ?? []} rows={data?.items ?? []}
isLoading={isLoading} isLoading={isLoading}
rowKey={(r) => r.id} rowKey={(r) => r.id}
onRowClick={(r) => setForm({ ...r })} onRowClick={(r) => setForm({
id: r.id, code: r.code, name: r.name,
description: r.description ?? '', isActive: r.isActive,
})}
columns={[ columns={[
{ header: 'Код', width: '90px', cell: (r) => <span className="font-mono">{r.code}</span> }, { header: 'Код', width: '90px', cell: (r) => <span className="font-mono">{r.code}</span> },
{ header: 'Символ', width: '100px', cell: (r) => r.symbol },
{ header: 'Название', cell: (r) => r.name }, { header: 'Название', cell: (r) => r.name },
{ header: 'Дробность', width: '120px', className: 'text-right', cell: (r) => r.decimalPlaces }, { header: 'Описание', cell: (r) => r.description ?? '—' },
{ header: 'Базовая', width: '100px', cell: (r) => r.isBase ? '✓' : '—' },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, { header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]} ]}
/> />
@ -91,25 +90,15 @@ export function UnitsOfMeasurePage() {
> >
{form && ( {form && (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Код ОКЕИ"> <Field label="Код ОКЕИ">
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /> <TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
</Field> </Field>
<Field label="Символ">
<TextInput value={form.symbol} onChange={(e) => setForm({ ...form, symbol: e.target.value })} />
</Field>
</div>
<Field label="Название"> <Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> <TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field> </Field>
<Field label="Количество знаков после запятой"> <Field label="Описание">
<TextInput <TextInput value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
type="number" min="0" max="6"
value={form.decimalPlaces}
onChange={(e) => setForm({ ...form, decimalPlaces: Number(e.target.value) })}
/>
</Field> </Field>
<Checkbox label="Базовая единица организации" checked={form.isBase} onChange={(v) => setForm({ ...form, isBase: v })} />
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} /> <Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div> </div>
)} )}

View file

@ -1,116 +0,0 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { VatRate } from '@/lib/types'
const URL = '/api/catalog/vat-rates'
interface Form {
id?: string
name: string
percent: number
isIncludedInPrice: boolean
isDefault: boolean
isActive: boolean
}
const blankForm: Form = { name: '', percent: 0, isIncludedInPrice: true, isDefault: false, isActive: true }
export function VatRatesPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<VatRate>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const save = async () => {
if (!form) return
const payload = {
name: form.name, percent: form.percent,
isIncludedInPrice: form.isIncludedInPrice, isDefault: form.isDefault, isActive: form.isActive,
}
if (form.id) await update.mutateAsync({ id: form.id, input: payload })
else await create.mutateAsync(payload)
setForm(null)
}
return (
<>
<ListPageShell
title="Ставки НДС"
description="Настройки ставок налога на добавленную стоимость."
actions={
<>
<SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, percent: r.percent,
isIncludedInPrice: r.isIncludedInPrice, isDefault: r.isDefault, isActive: r.isActive,
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: '%', width: '90px', className: 'text-right font-mono', cell: (r) => r.percent.toFixed(2) },
{ header: 'В цене', width: '100px', cell: (r) => r.isIncludedInPrice ? '✓' : '—' },
{ header: 'По умолчанию', width: '140px', cell: (r) => r.isDefault ? '✓' : '—' },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
</ListPageShell>
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? 'Редактировать ставку НДС' : 'Новая ставка НДС'}
footer={
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить ставку?')) {
await remove.mutateAsync(form.id!)
setForm(null)
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
</>
}
>
{form && (
<div className="space-y-3">
<Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Процент">
<TextInput
type="number" step="0.01"
value={form.percent}
onChange={(e) => setForm({ ...form, percent: Number(e.target.value) })}
/>
</Field>
<Checkbox label="НДС включён в цену" checked={form.isIncludedInPrice} onChange={(v) => setForm({ ...form, isIncludedInPrice: v })} />
<Checkbox label="По умолчанию" checked={form.isDefault} onChange={(v) => setForm({ ...form, isDefault: v })} />
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
)}
</Modal>
</>
)
}