Compare commits

..

21 commits

Author SHA1 Message Date
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
67 changed files with 4384 additions and 767 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
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/
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
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
RUN corepack enable
@ -9,7 +10,7 @@ RUN pnpm install --frozen-lockfile
COPY src/food-market.web/ ./
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 --from=build /src/dist /usr/share/nginx/html

View file

@ -1,6 +1,6 @@
services:
postgres:
image: postgres:16-alpine
image: ${REGISTRY:-127.0.0.1:5001}/mirror/postgres:16-alpine
container_name: food-market-postgres
restart: unless-stopped
environment:
@ -54,3 +54,4 @@ volumes:
name: food-market-api-data
api-logs:
name: food-market-api-logs

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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,130 @@
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;
public AdminCleanupController(AppDbContext db) => _db = db;
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));
}
/// <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

@ -20,14 +20,9 @@ public class CounterpartiesController : ControllerBase
[HttpGet]
public async Task<ActionResult<PagedResult<CounterpartyDto>>> List(
[FromQuery] PagedRequest req,
[FromQuery] CounterpartyKind? kind,
CancellationToken ct)
{
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))
{
var s = req.Search.Trim().ToLower();
@ -43,7 +38,7 @@ public class CounterpartiesController : ControllerBase
.OrderBy(c => c.Name)
.Skip(req.Skip).Take(req.Take)
.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.Address, c.Phone, c.Email,
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);
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.Address, c.Phone, c.Email,
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.LegalName = i.LegalName;
e.Kind = i.Kind;
e.Type = i.Type;
e.Bin = i.Bin;
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);
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.Address, c.Phone, c.Email,
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] bool? isService,
[FromQuery] bool? isWeighed,
[FromQuery] bool? isMarked,
[FromQuery] bool? isActive,
[FromQuery] bool? hasBarcode,
CancellationToken ct)
{
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 (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 (hasBarcode is not null)
q = hasBarcode == true ? q.Where(p => p.Barcodes.Any()) : q.Where(p => !p.Barcodes.Any());
if (!string.IsNullOrWhiteSpace(req.Search))
{
@ -111,7 +130,6 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
private IQueryable<Product> QueryIncludes() => _db.Products
.Include(p => p.UnitOfMeasure)
.Include(p => p.VatRate)
.Include(p => p.ProductGroup)
.Include(p => p.DefaultSupplier)
.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 =>
new ProductDto(
p.Id, p.Name, p.Article, p.Description,
p.UnitOfMeasureId, p.UnitOfMeasure!.Symbol,
p.VatRateId, p.VatRate!.Percent,
p.UnitOfMeasureId, p.UnitOfMeasure!.Name,
p.Vat, p.VatEnabled,
p.ProductGroupId, p.ProductGroup != null ? p.ProductGroup.Name : null,
p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.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.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
p.ImageUrl, p.IsActive,
@ -144,13 +162,13 @@ private static void Apply(Product e, ProductInput i)
e.Article = i.Article;
e.Description = i.Description;
e.UnitOfMeasureId = i.UnitOfMeasureId;
e.VatRateId = i.VatRateId;
e.Vat = i.Vat;
e.VatEnabled = i.VatEnabled;
e.ProductGroupId = i.ProductGroupId;
e.DefaultSupplierId = i.DefaultSupplierId;
e.CountryOfOriginId = i.CountryOfOriginId;
e.IsService = i.IsService;
e.IsWeighed = i.IsWeighed;
e.IsAlcohol = i.IsAlcohol;
e.IsMarked = i.IsMarked;
e.MinStock = i.MinStock;
e.MaxStock = i.MaxStock;

View file

@ -30,7 +30,7 @@ public async Task<ActionResult<PagedResult<StoreDto>>> List([FromQuery] PagedReq
var items = await q
.OrderByDescending(x => x.IsMain).ThenBy(x => x.Name)
.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);
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)
{
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")]
@ -51,13 +51,13 @@ public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, Ca
}
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,
};
_db.Stores.Add(e);
await _db.SaveChangesAsync(ct);
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")]
@ -71,7 +71,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] StoreInput input, Ca
}
e.Name = input.Name;
e.Code = input.Code;
e.Kind = input.Kind;
e.Address = input.Address;
e.Phone = input.Phone;
e.ManagerName = input.ManagerName;

View file

@ -24,13 +24,13 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
if (!string.IsNullOrWhiteSpace(req.Search))
{
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 items = await q
.OrderByDescending(u => u.IsBase).ThenBy(u => u.Name)
.OrderBy(u => u.Name)
.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);
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)
{
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")]
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
{
Code = input.Code,
Symbol = input.Symbol,
Name = input.Name,
DecimalPlaces = input.DecimalPlaces,
IsBase = input.IsBase,
Description = input.Description,
IsActive = input.IsActive,
};
_db.UnitsOfMeasure.Add(e);
await _db.SaveChangesAsync(ct);
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")]
@ -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);
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.Symbol = input.Symbol;
e.Name = input.Name;
e.DecimalPlaces = input.DecimalPlaces;
e.IsBase = input.IsBase;
e.Description = input.Description;
e.IsActive = input.IsActive;
await _db.SaveChangesAsync(ct);
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)
.Skip((page - 1) * pageSize).Take(pageSize)
.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.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
.ToListAsync(ct);

View file

@ -276,7 +276,7 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
where l.SupplyId == id
orderby l.SortOrder
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))
.ToListAsync(ct);

View file

@ -352,7 +352,7 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
where l.RetailSaleId == id
orderby l.SortOrder
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))
.ToListAsync(ct);

View file

@ -32,21 +32,18 @@ public async Task StartAsync(CancellationToken ct)
var hasProducts = await db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
if (hasProducts) return;
var defaultVat = await db.VatRates.IgnoreQueryFilters()
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.IsDefault, ct);
var noVat = await db.VatRates.IgnoreQueryFilters()
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.Percent == 0m, ct);
// KZ default VAT is 16% (applies as int on Product).
const int vatDefault = 16;
const int vat0 = 0;
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()
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "кг", ct);
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "166", ct);
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;
var vat = defaultVat.Id;
var vat0 = noVat?.Id ?? vat;
if (unitSht is null) return;
var retailPriceType = await db.PriceTypes.IgnoreQueryFilters()
.FirstOrDefaultAsync(p => p.OrganizationId == orgId && p.IsRetail, ct);
@ -88,7 +85,7 @@ Guid AddGroup(string name, Guid? parentId)
var supplier1 = new Counterparty
{
OrganizationId = orgId, Name = "ТОО «Продтрейд»", LegalName = "Товарищество с ограниченной ответственностью «Продтрейд»",
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.LegalEntity,
Type = CounterpartyType.LegalEntity,
Bin = "100140005678", CountryId = kz?.Id,
Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01",
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
{
OrganizationId = orgId, Name = "ИП Иванов А.С.",
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.Individual,
Type = CounterpartyType.Individual,
Iin = "850101300000", CountryId = kz?.Id,
Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей",
IsActive = true,
@ -106,49 +103,49 @@ Guid AddGroup(string name, Guid? parentId)
// Barcodes use generic internal range (2xxxxxxxxxxxx) to avoid colliding with real products.
// 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),
("Вода питьевая «Тассай» 1.5л", gDrinksNon, 380, "DR-WAT-002", "2000000000025", kz?.Id, false, unitSht.Id, false),
("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id, false),
("Сок «Да-Да» апельсин 1л", gDrinksNon, 650, "DR-JUC-002", "2000000000049", kz?.Id, false, unitSht.Id, false),
("Кока-кола 0.5л", gDrinksNon, 420, "DR-SOD-001", "2000000000056", ru?.Id, false, unitSht.Id, false),
("Пепси 0.5л", gDrinksNon, 420, "DR-SOD-002", "2000000000063", ru?.Id, false, unitSht.Id, false),
("Спрайт 0.5л", gDrinksNon, 420, "DR-SOD-003", "2000000000070", ru?.Id, false, unitSht.Id, false),
("Чай чёрный «Пиала» пакетированный, 100шт", gDrinksNon, 1250, "DR-TEA-001", "2000000000087", 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),
("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id),
("Сок «Да-Да» апельсин 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),
("Пепси 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),
("Чай чёрный «Пиала» пакетированный, 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),
("Молоко «Food Master» 3.2% 1л", gDairy, 620, "DAI-MLK-002", "2000000000100", kz?.Id, false, unitSht.Id, false),
("Кефир «Food Master» 2.5% 1л", gDairy, 590, "DAI-KEF-001", "2000000000117", kz?.Id, false, unitSht.Id, false),
("Йогурт «Актимель» клубника 100мл", gDairy, 180, "DAI-YOG-001", "2000000000124", null, false, unitSht.Id, false),
("Творог «Простоквашино» 5% 200г", gDairy, 590, "DAI-CRD-001", "2000000000131", ru?.Id, false, unitSht.Id, false),
("Сметана «Food Master» 20% 400г", gDairy, 850, "DAI-SCR-001", "2000000000148", kz?.Id, false, unitSht.Id, false),
("Сыр «Российский» 45%", gDairy, 3200, "DAI-CHE-001", "2000000000155", kz?.Id, true, unitKg?.Id ?? 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),
("Кефир «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),
("Творог «Простоквашино» 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),
("Сыр «Российский» 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),
("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id, false),
("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id, false),
("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", 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),
("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id),
("Круассан шоколадный", 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, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id, false),
("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id, false),
("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id, false),
("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", 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),
("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id),
("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id),
("Вафли «Артек» 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, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id, false),
("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id, false),
("Гречка «Мистраль» 900г", gGrocery, 990, "GRO-GRE-001", "2000000000285", ru?.Id, false, unitSht.Id, false),
("Макароны «Corona» 500г", gGrocery, 420, "GRO-PAS-001", "2000000000292", kz?.Id, false, unitSht.Id, false),
("Масло подсолнечное «Шедевр» 1л", gGrocery, 920, "GRO-OIL-001", "2000000000308", kz?.Id, false, unitL?.Id ?? unitSht.Id, false),
("Кофе «Якобс Монарх» 95г растворимый", gGrocery, 2890, "GRO-COF-001", "2000000000315", null, 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),
("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id),
("Гречка «Мистраль» 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),
("Масло подсолнечное «Шедевр» 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),
// Снеки
("Чипсы «Lays» сметана-лук 80г", gSnacks, 380, "SN-CHP-001", "2000000000322", kz?.Id, false, unitSht.Id, false),
("Сухарики «3 корочки» ржаные 40г", gSnacks, 190, "SN-SBR-001", "2000000000339", ru?.Id, false, unitSht.Id, false),
("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id, false),
("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.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),
("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id),
("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id),
};
var products = demo.Select(d =>
@ -159,12 +156,12 @@ Guid AddGroup(string name, Guid? parentId)
Name = d.Name,
Article = d.Article,
UnitOfMeasureId = d.Unit,
VatRateId = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
? vat0 : vat,
Vat = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
? vat0 : vatDefault,
VatEnabled = !(d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")),
ProductGroupId = d.Group,
CountryOfOriginId = d.Country,
IsWeighed = d.IsWeighed,
IsAlcohol = d.IsAlcohol,
IsActive = true,
PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2),
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)
{
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);
if (!anyUnit)
{
db.UnitsOfMeasure.AddRange(
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Symbol = "шт", Name = "штука", DecimalPlaces = 0, IsBase = true },
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Symbol = "кг", Name = "килограмм", DecimalPlaces = 3 },
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Symbol = "л", Name = "литр", DecimalPlaces = 3 },
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Symbol = "м", Name = "метр", DecimalPlaces = 3 },
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Symbol = "уп", Name = "упаковка", DecimalPlaces = 0 }
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Name = "штука" },
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Name = "килограмм" },
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Name = "литр" },
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Name = "метр" },
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Name = "упаковка" }
);
}
@ -116,7 +107,6 @@ private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
OrganizationId = orgId,
Name = "Основной склад",
Code = "MAIN",
Kind = StoreKind.Warehouse,
IsMain = true,
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 VatRateDto(
Guid Id, string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault, bool IsActive);
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(
Guid Id, string Name, bool IsDefault, bool IsRetail, int SortOrder, bool IsActive);
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);
public record RetailPointDto(
@ -27,7 +24,7 @@ public record ProductGroupDto(
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive);
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? Address, string? Phone, string? Email,
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(
Guid Id, string Name, string? Article, string? Description,
Guid UnitOfMeasureId, string UnitSymbol,
Guid VatRateId, decimal VatPercent,
Guid UnitOfMeasureId, string UnitName,
int Vat, bool VatEnabled,
Guid? ProductGroupId, string? ProductGroupName,
Guid? DefaultSupplierId, string? DefaultSupplierName,
Guid? CountryOfOriginId, string? CountryOfOriginName,
bool IsService, bool IsWeighed, bool IsAlcohol, bool IsMarked,
bool IsService, bool IsWeighed, bool IsMarked,
decimal? MinStock, decimal? MaxStock,
decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
string? ImageUrl, bool IsActive,
@ -53,11 +50,10 @@ public record ProductDto(
// Upsert payloads (input)
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 VatRateInput(string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault = false, bool IsActive = true);
public record UnitOfMeasureInput(string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase = false, bool IsActive = true);
public record UnitOfMeasureInput(string Code, string Name, string? Description = null, bool IsActive = true);
public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true);
public record StoreInput(
string Name, string? Code, StoreKind Kind = StoreKind.Warehouse,
string Name, string? Code,
string? Address = null, string? Phone = null, string? ManagerName = null,
bool IsMain = false, bool IsActive = true);
public record RetailPointInput(
@ -66,7 +62,7 @@ public record RetailPointInput(
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
public record ProductGroupInput(string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true);
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? Address, string? Phone, string? Email,
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 ProductInput(
string Name, string? Article, string? Description,
Guid UnitOfMeasureId, Guid VatRateId,
Guid UnitOfMeasureId, int Vat, bool VatEnabled,
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? PurchasePrice = null, Guid? PurchaseCurrencyId = null,
string? ImageUrl = null, bool IsActive = true,

View file

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

View file

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

View file

@ -11,8 +11,10 @@ public class Product : TenantEntity
public Guid UnitOfMeasureId { get; set; }
public UnitOfMeasure? UnitOfMeasure { get; set; }
public Guid VatRateId { get; set; }
public VatRate? VatRate { get; set; }
// Ставка НДС на товаре — число 0/10/12/16/20 как в MoySklad.
// VatEnabled=true → НДС применяется, false → без НДС.
public int Vat { get; set; }
public bool VatEnabled { get; set; } = true;
public Guid? ProductGroupId { get; set; }
public ProductGroup? ProductGroup { get; set; }
@ -25,7 +27,6 @@ public class Product : TenantEntity
public bool IsService { get; set; } // услуга, а не физический товар
public bool IsWeighed { get; set; } // весовой (продаётся с весов)
public bool IsAlcohol { get; set; } // алкоголь (подакцизный)
public bool IsMarked { get; set; } // маркируемый (Datamatrix)
public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений)

View file

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

View file

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

View file

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

View file

@ -63,60 +63,78 @@ public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, C
: MoySkladApiResult<MsOrganization>.Ok(org);
}
// MoySklad list endpoints по умолчанию возвращают только активных (archived=false).
// Чтобы получить архивных, нужен явный filter=archived=true. Делаем два прохода:
// сначала активные (default), затем архивные — и отдаём всё одним потоком.
public async IAsyncEnumerable<MsProduct> StreamProductsAsync(
string token,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
const int pageSize = 1000;
var offset = 0;
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;
}
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: false, ct)) yield return p;
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: true, ct)) yield return p;
}
public async IAsyncEnumerable<MsCounterparty> StreamCounterpartiesAsync(
string token,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
const int pageSize = 1000;
var offset = 0;
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;
}
await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: false, ct)) yield return c;
await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: true, ct)) yield return c;
}
public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct)
{
var all = new List<MsProductFolder>();
var offset = 0;
const int pageSize = 1000;
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;
}
await foreach (var f in StreamPagedAsync<MsProductFolder>(token, "entity/productfolder", archivedOnly: false, ct)) all.Add(f);
await foreach (var f in StreamPagedAsync<MsProductFolder>(token, "entity/productfolder", archivedOnly: true, ct)) all.Add(f);
return all;
}
private async IAsyncEnumerable<T> StreamPagedAsync<T>(
string token,
string path,
bool archivedOnly,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
const int pageSize = 1000;
const int maxAttempts = 5;
var offset = 0;
var filterSuffix = archivedOnly ? "&filter=archived=true" : "";
while (true)
{
MsListResponse<T>? page = null;
Exception? lastErr = null;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
using var req = Build(HttpMethod.Get, $"{path}?limit={pageSize}&offset={offset}{filterSuffix}", token);
using var res = await _http.SendAsync(req, ct);
res.EnsureSuccessStatusCode();
page = await res.Content.ReadFromJsonAsync<MsListResponse<T>>(Json, ct);
lastErr = null;
break;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException)
{
lastErr = ex;
if (attempt == maxAttempts) break;
// Exponential-ish backoff: 2s, 4s, 8s, 16s.
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct);
}
}
if (lastErr is not null)
{
// Re-throw after retries so the caller sees a real failure instead of silent halt.
throw new InvalidOperationException(
$"MoySklad paging failed at {path} offset={offset} after {maxAttempts} attempts: {lastErr.Message}",
lastErr);
}
if (page is null || page.Rows.Count == 0) yield break;
foreach (var row in page.Rows) yield return row;
if (page.Rows.Count < pageSize) yield break;
offset += pageSize;
}
}
}

View file

@ -39,14 +39,11 @@ public async Task<MoySkladImportResult> ImportCounterpartiesAsync(string token,
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще — это
// не наша выдумка, проверено через API: counterparty entity содержит только
// group (группа доступа), tags (произвольные), state (пользовательская цепочка
// статусов), companyType (legal/individual/entrepreneur). Никакой role/kind.
// Поэтому при импорте ВСЕГДА ставим Unspecified — пользователь сам решит.
// Параметр tags оставлен ради совместимости сигнатуры, не используется.
static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList<string>? tags)
=> foodmarket.Domain.Catalog.CounterpartyKind.Unspecified;
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще —
// counterparty entity содержит только group (группа доступа), tags
// (произвольные), state (пользовательская цепочка статусов), companyType
// (legal/individual/entrepreneur). Никакого role/kind. Поэтому у нас тоже
// этого поля нет — пусть пользователь сам решит.
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
=> companyType switch
@ -55,11 +52,15 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp
_ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity,
};
// Загружаем существующих в память — обновлять будем по имени (case-insensitive).
// Раньше при overwriteExisting=true бага: пропускалась проверка "skip" и ВСЕ
// поступающие записи добавлялись как новые, порождая дубли. Теперь если имя уже
// есть — обновляем ту же запись, иначе создаём.
var existingByName = await _db.Counterparties
.Select(c => new { c.Id, c.Name })
.ToDictionaryAsync(c => c.Name, c => c.Id, StringComparer.OrdinalIgnoreCase, ct);
.ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct);
var created = 0;
var updated = 0;
var skipped = 0;
var total = 0;
var errors = new List<string>();
@ -68,34 +69,25 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp
await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
{
total++;
if (c.Archived) { skipped++; continue; }
if (existingByName.ContainsKey(c.Name) && !overwriteExisting)
{
skipped++;
continue;
}
// Архивных не пропускаем — импортируем как IsActive=false (см. ApplyCounterparty).
try
{
var entity = new foodmarket.Domain.Catalog.Counterparty
if (existingByName.TryGetValue(c.Name, out var existing))
{
OrganizationId = orgId,
Name = Trim(c.Name, 255) ?? c.Name,
LegalName = Trim(c.LegalTitle, 500),
Kind = ResolveKind(c.Tags),
Type = ResolveType(c.CompanyType),
Bin = Trim(c.Inn, 20),
TaxNumber = Trim(c.Kpp, 20),
Phone = Trim(c.Phone, 50),
Email = Trim(c.Email, 255),
Address = Trim(c.ActualAddress ?? c.LegalAddress, 500),
Notes = Trim(c.Description, 1000),
IsActive = !c.Archived,
};
_db.Counterparties.Add(entity);
existingByName[c.Name] = entity.Id;
created++;
if (!overwriteExisting) { skipped++; continue; }
ApplyCounterparty(existing, c, ResolveType);
updated++;
}
else
{
var entity = new foodmarket.Domain.Catalog.Counterparty { OrganizationId = orgId };
ApplyCounterparty(entity, c, ResolveType);
_db.Counterparties.Add(entity);
existingByName[c.Name] = entity;
created++;
}
batch++;
if (batch >= 500)
{
@ -111,7 +103,25 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, 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(
@ -121,11 +131,10 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
// Pre-load tenant defaults.
var defaultVat = await _db.VatRates.FirstOrDefaultAsync(v => v.IsDefault, ct)
?? await _db.VatRates.FirstAsync(ct);
var vatByPercent = await _db.VatRates.ToDictionaryAsync(v => (int)v.Percent, v => v.Id, ct);
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.IsBase, ct)
// Pre-load tenant defaults. KZ default VAT is 16% — applied when product didn't
// carry its own vat from MoySklad.
const int kzDefaultVat = 16;
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", ct)
?? await _db.UnitsOfMeasure.FirstAsync(ct);
var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct)
?? await _db.PriceTypes.FirstAsync(ct);
@ -135,11 +144,12 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
.IgnoreQueryFilters()
.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 localGroupByMsId = new Dictionary<string, Guid>();
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;
var existing = await _db.ProductGroups.FirstOrDefaultAsync(
@ -154,7 +164,7 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
OrganizationId = orgId,
Name = f.Name,
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
IsActive = true,
IsActive = !f.Archived,
};
_db.ProductGroups.Add(g);
localGroupByMsId[f.Id] = g.Id;
@ -165,28 +175,26 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
// Import products
var errors = new List<string>();
var created = 0;
var updated = 0;
var skipped = 0;
var total = 0;
var existingArticles = await _db.Products
// При overwriteExisting=true загружаем товары целиком, чтобы обновлять существующие
// вместо создания дубликатов. Ключ = артикул (нормализованный).
var existingByArticle = await _db.Products
.Where(p => p.Article != null)
.Select(p => p.Article!)
.ToListAsync(ct);
var existingArticleSet = new HashSet<string>(existingArticles, StringComparer.OrdinalIgnoreCase);
.ToDictionaryAsync(p => p.Article!, p => p, StringComparer.OrdinalIgnoreCase, ct);
var existingBarcodeSet = new HashSet<string>(
await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct));
await foreach (var p in _client.StreamProductsAsync(token, ct))
{
total++;
if (p.Archived) { skipped++; continue; }
// Архивных не пропускаем — импортируем как IsActive=false.
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);
var alreadyByBarcode = primaryBarcode is not null && existingBarcodeSet.Contains(primaryBarcode.Code);
if ((alreadyByArticle || alreadyByBarcode) && !overwriteExisting)
if (alreadyByArticle && !overwriteExisting)
{
skipped++;
continue;
@ -194,7 +202,8 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
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
&& localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null;
Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null;
@ -202,48 +211,71 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true)
?? p.SalePrices?.FirstOrDefault();
var product = new Product
Product product;
if (alreadyByArticle && overwriteExisting)
{
OrganizationId = orgId,
Name = Trim(p.Name, 500),
Article = Trim(article, 500),
Description = p.Description,
UnitOfMeasureId = baseUnit.Id,
VatRateId = vatId,
ProductGroupId = groupId,
CountryOfOriginId = countryId,
IsWeighed = p.Weighed,
IsAlcohol = p.Alcoholic is not null,
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
IsActive = !p.Archived,
PurchasePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
PurchaseCurrencyId = kzt.Id,
};
if (retailPrice is not null)
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++;
}
else
{
product.Prices.Add(new ProductPrice
product = new Product
{
OrganizationId = orgId,
PriceTypeId = retailType.Id,
Amount = retailPrice.Value / 100m,
CurrencyId = kzt.Id,
});
Name = Trim(p.Name, 500),
Article = Trim(article, 500),
Description = p.Description,
UnitOfMeasureId = baseUnit.Id,
Vat = vat,
VatEnabled = vatEnabled,
ProductGroupId = groupId,
CountryOfOriginId = countryId,
IsWeighed = p.Weighed,
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
IsActive = !p.Archived,
PurchasePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
PurchaseCurrencyId = kzt.Id,
};
if (retailPrice is not null)
{
product.Prices.Add(new ProductPrice
{
OrganizationId = orgId,
PriceTypeId = retailType.Id,
Amount = retailPrice.Value / 100m,
CurrencyId = kzt.Id,
});
}
foreach (var b in ExtractBarcodes(p))
{
if (existingBarcodeSet.Contains(b.Code)) continue;
product.Barcodes.Add(b);
existingBarcodeSet.Add(b.Code);
}
_db.Products.Add(product);
if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product;
created++;
}
foreach (var b in ExtractBarcodes(p))
{
if (existingBarcodeSet.Contains(b.Code)) continue;
product.Barcodes.Add(b);
existingBarcodeSet.Add(b.Code);
}
_db.Products.Add(product);
if (!string.IsNullOrWhiteSpace(article)) existingArticleSet.Add(article);
created++;
// Flush every 500 products to keep change tracker light.
if (created % 500 == 0) await _db.SaveChangesAsync(ct);
// Flush чаще (каждые 100) чтобы при сетевом обрыве на следующей странице
// мы сохранили как можно больше и смогли безопасно продолжить с overwrite.
if ((created + updated) % 100 == 0) await _db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
@ -253,7 +285,7 @@ await foreach (var p in _client.StreamProductsAsync(token, 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)

View file

@ -26,7 +26,6 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<Country> Countries => Set<Country>();
public DbSet<Currency> Currencies => Set<Currency>();
public DbSet<VatRate> VatRates => Set<VatRate>();
public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>();
public DbSet<Counterparty> Counterparties => Set<Counterparty>();
public DbSet<Store> Stores => Set<Store>();

View file

@ -10,7 +10,6 @@ public static void ConfigureCatalog(this ModelBuilder b)
{
b.Entity<Country>(ConfigureCountry);
b.Entity<Currency>(ConfigureCurrency);
b.Entity<VatRate>(ConfigureVatRate);
b.Entity<UnitOfMeasure>(ConfigureUnit);
b.Entity<Counterparty>(ConfigureCounterparty);
b.Entity<Store>(ConfigureStore);
@ -40,20 +39,12 @@ private static void ConfigureCurrency(EntityTypeBuilder<Currency> b)
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)
{
b.ToTable("units_of_measure");
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.Description).HasMaxLength(500);
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.HasIndex(x => new { x.OrganizationId, x.Name });
b.HasIndex(x => new { x.OrganizationId, x.Bin });
b.HasIndex(x => new { x.OrganizationId, x.Kind });
}
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.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.DefaultSupplier).WithMany().HasForeignKey(x => x.DefaultSupplierId).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

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

View file

@ -4,7 +4,6 @@ import { LoginPage } from '@/pages/LoginPage'
import { DashboardPage } from '@/pages/DashboardPage'
import { CountriesPage } from '@/pages/CountriesPage'
import { CurrenciesPage } from '@/pages/CurrenciesPage'
import { VatRatesPage } from '@/pages/VatRatesPage'
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
import { PriceTypesPage } from '@/pages/PriceTypesPage'
import { StoresPage } from '@/pages/StoresPage'
@ -46,7 +45,6 @@ export default function App() {
<Route path="/catalog/products/:id" element={<ProductEditPage />} />
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
<Route path="/catalog/vat-rates" element={<VatRatesPage />} />
<Route path="/catalog/price-types" element={<PriceTypesPage />} />
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
<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">
{p.article && <span>{p.article}</span>}
{p.barcodes[0] && <span>· {p.barcodes[0].code}</span>}
<span>· {p.unitSymbol}</span>
<span>· {p.unitName}</span>
</div>
</div>
{p.purchasePrice !== null && (

View file

@ -6,25 +6,18 @@ export interface PagedResult<T> {
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 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 type BarcodeType = (typeof BarcodeType)[keyof typeof BarcodeType]
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 VatRate { id: string; name: string; percent: number; isIncludedInPrice: boolean; isDefault: boolean; isActive: boolean }
export interface UnitOfMeasure { id: string; code: string; symbol: string; name: string; decimalPlaces: number; isBase: boolean; isActive: boolean }
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; isActive: boolean }
export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }
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
}
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 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;
address: string | null; phone: string | null; email: 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 Product {
id: string; name: string; article: string | null; description: string | null;
unitOfMeasureId: string; unitSymbol: string;
vatRateId: string; vatPercent: number;
unitOfMeasureId: string; unitName: string;
vat: number; vatEnabled: boolean;
productGroupId: string | null; productGroupName: string | null;
defaultSupplierId: string | null; defaultSupplierName: 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;
purchasePrice: number | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
imageUrl: string | null; isActive: boolean;
@ -56,7 +49,7 @@ export interface Product {
}
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;
quantity: number; reservedQuantity: number; available: number;
}
@ -83,7 +76,7 @@ export interface SupplyListRow {
export interface SupplyLineDto {
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;
}
@ -116,7 +109,7 @@ export interface RetailSaleListRow {
export interface RetailSaleLineDto {
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;
vatPercent: number; sortOrder: number;
}

View file

@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import type {
PagedResult, UnitOfMeasure, VatRate, ProductGroup, Counterparty,
PagedResult, UnitOfMeasure, ProductGroup, Counterparty,
Country, Currency, Store, PriceType,
} 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 useVatRates = () => useLookup<VatRate>('vat', '/api/catalog/vat-rates')
export const useProductGroups = () => useLookup<ProductGroup>('groups', '/api/catalog/product-groups')
export const useCountries = () => useLookup<Country>('countries', '/api/catalog/countries')
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 { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
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'
@ -18,7 +18,6 @@ interface Form {
id?: string
name: string
legalName: string
kind: CounterpartyKind
type: CounterpartyType
bin: string
iin: string
@ -36,20 +35,16 @@ interface Form {
}
const blankForm: Form = {
// Kind по умолчанию Unspecified — MoySklad не имеет такого поля у контрагентов,
// не выдумываем за пользователя. Пусть выберет вручную если нужно.
name: '', legalName: '', kind: CounterpartyKind.Unspecified, type: CounterpartyType.LegalEntity,
name: '', legalName: '', type: CounterpartyType.LegalEntity,
bin: '', iin: '', taxNumber: '', countryId: '',
address: '', phone: '', email: '',
bankName: '', bankAccount: '', bik: '',
contactPerson: '', notes: '', isActive: true,
}
const kindLabel: Record<CounterpartyKind, string> = {
[CounterpartyKind.Unspecified]: '—',
[CounterpartyKind.Supplier]: 'Поставщик',
[CounterpartyKind.Customer]: 'Покупатель',
[CounterpartyKind.Both]: 'Поставщик + Покупатель',
const typeLabel: Record<CounterpartyType, string> = {
[CounterpartyType.LegalEntity]: 'Юрлицо',
[CounterpartyType.Individual]: 'Физлицо',
}
export function CounterpartiesPage() {
@ -92,7 +87,7 @@ export function CounterpartiesPage() {
isLoading={isLoading}
rowKey={(r) => r.id}
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 ?? '',
countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '',
bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '',
@ -100,7 +95,7 @@ export function CounterpartiesPage() {
})}
columns={[
{ 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: '160px', cell: (r) => r.phone ?? '—' },
{ header: 'Страна', width: '120px', cell: (r) => r.countryName ?? '—' },
@ -139,14 +134,6 @@ export function CounterpartiesPage() {
<Field label="Юридическое название" className="col-span-2">
<TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} />
</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="Тип лица">
<Select value={form.type} onChange={(e) => setForm({ ...form, type: Number(e.target.value) as CounterpartyType })}>
<option value={CounterpartyType.LegalEntity}>Юрлицо</option>

View file

@ -1,6 +1,6 @@
import { useState } from 'react'
import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package } from 'lucide-react'
import { useMutation, useQuery, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package, Trash2, AlertTriangle } from 'lucide-react'
import { AxiosError } from 'axios'
import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader'
@ -122,11 +122,141 @@ export function MoySkladImportPage() {
<ImportResult title="Товары" result={products} />
<ImportResult title="Контрагенты" result={counterparties} />
<DangerZone />
</div>
</div>
)
}
interface CleanupStats {
counterparties: number
products: number
productGroups: number
productBarcodes: number
productPrices: number
supplies: number
retailSales: number
stocks: number
stockMovements: number
}
interface CleanupResult { scope: string; deleted: CleanupStats }
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 wipeCounterparties = useMutation({
mutationFn: async () => (await api.delete<CleanupResult>('/api/admin/cleanup/counterparties')).data,
onSuccess: () => {
qc.invalidateQueries()
},
})
const wipeAll = useMutation({
mutationFn: async () => (await api.delete<CleanupResult>('/api/admin/cleanup/all')).data,
onSuccess: () => {
qc.invalidateQueries()
},
})
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">
<Stat label="Контрагенты" value={s.counterparties} />
<Stat label="Товары" value={s.products} />
<Stat label="Группы" value={s.productGroups} />
<Stat label="Штрихкоды" value={s.productBarcodes} />
<Stat label="Цены" value={s.productPrices} />
<Stat label="Поставки" value={s.supplies} />
<Stat label="Чеки" value={s.retailSales} />
<Stat label="Остатки" value={s.stocks} />
<Stat label="Движения" value={s.stockMovements} />
</dl>
)}
<div className="flex gap-3 flex-wrap">
<Button
variant="danger"
onClick={() => confirmAndRun(
`${s?.counterparties ?? '?'} контрагентов (+ связанные поставки/движения)`,
() => wipeCounterparties.mutate(),
)}
disabled={wipeCounterparties.isPending || !s || s.counterparties === 0}
>
<Trash2 className="w-4 h-4" />
{wipeCounterparties.isPending ? 'Удаляю…' : 'Удалить контрагентов'}
</Button>
<Button
variant="danger"
onClick={() => confirmAndRun(
'ВСЕ данные организации (товары, группы, контрагенты, документы, остатки)',
() => wipeAll.mutate(),
)}
disabled={wipeAll.isPending}
>
<Trash2 className="w-4 h-4" />
{wipeAll.isPending ? 'Удаляю всё…' : 'Очистить все данные'}
</Button>
</div>
{wipeCounterparties.data && (
<div className="mt-3 text-xs text-red-900 dark:text-red-300">
Удалено: {wipeCounterparties.data.deleted.counterparties} контрагентов,
{' '}{wipeCounterparties.data.deleted.supplies} поставок,
{' '}{wipeCounterparties.data.deleted.stockMovements} движений.
</div>
)}
{wipeAll.data && (
<div className="mt-3 text-xs text-red-900 dark:text-red-300">
Удалено: {wipeAll.data.deleted.counterparties} контрагентов,
{' '}{wipeAll.data.deleted.products} товаров,
{' '}{wipeAll.data.deleted.productGroups} групп,
{' '}{wipeAll.data.deleted.supplies} поставок,
{' '}{wipeAll.data.deleted.retailSales} чеков,
{' '}{wipeAll.data.deleted.stockMovements} движений.
</div>
)}
{wipeCounterparties.error && (
<div className="mt-3 text-xs text-red-700">{formatError(wipeCounterparties.error)}</div>
)}
{wipeAll.error && (
<div className="mt-3 text-xs text-red-700">{formatError(wipeAll.error)}</div>
)}
</section>
)
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<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-[10px] uppercase text-slate-500">{label}</dt>
<dd className="font-mono font-semibold">{value.toLocaleString('ru')}</dd>
</div>
)
}
function ImportResult({ title, result }: { title: string; result: UseMutationResult<ImportResponse, Error, void, unknown> }) {
if (!result.data && !result.error) return null
return (

View file

@ -6,7 +6,7 @@ import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
import {
useUnits, useVatRates, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
} from '@/lib/useLookups'
import { BarcodeType, type Product } from '@/lib/types'
@ -18,13 +18,13 @@ interface Form {
article: string
description: string
unitOfMeasureId: string
vatRateId: string
vat: number
vatEnabled: boolean
productGroupId: string
defaultSupplierId: string
countryOfOriginId: string
isService: boolean
isWeighed: boolean
isAlcohol: boolean
isMarked: boolean
isActive: boolean
minStock: string
@ -36,11 +36,15 @@ interface Form {
barcodes: BarcodeRow[]
}
// KZ default VAT rate.
const defaultVat = 16
const vatChoices = [0, 10, 12, 16, 20]
const emptyForm: Form = {
name: '', article: '', description: '',
unitOfMeasureId: '', vatRateId: '',
unitOfMeasureId: '', vat: defaultVat, vatEnabled: true,
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
isService: false, isWeighed: false, isAlcohol: false, isMarked: false, isActive: true,
isService: false, isWeighed: false, isMarked: false, isActive: true,
minStock: '', maxStock: '',
purchasePrice: '', purchaseCurrencyId: '',
imageUrl: '',
@ -55,7 +59,6 @@ export function ProductEditPage() {
const qc = useQueryClient()
const units = useUnits()
const vats = useVatRates()
const groups = useProductGroups()
const countries = useCountries()
const currencies = useCurrencies()
@ -76,10 +79,10 @@ export function ProductEditPage() {
const p = existing.data
setForm({
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 ?? '',
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,
minStock: p.minStock?.toString() ?? '',
maxStock: p.maxStock?.toString() ?? '',
@ -93,16 +96,13 @@ export function ProductEditPage() {
}, [isNew, existing.data])
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) {
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) {
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({
mutationFn: async () => {
@ -111,13 +111,13 @@ export function ProductEditPage() {
article: form.article || null,
description: form.description || null,
unitOfMeasureId: form.unitOfMeasureId,
vatRateId: form.vatRateId,
vat: form.vat,
vatEnabled: form.vatEnabled,
productGroupId: form.productGroupId || null,
defaultSupplierId: form.defaultSupplierId || null,
countryOfOriginId: form.countryOfOriginId || null,
isService: form.isService,
isWeighed: form.isWeighed,
isAlcohol: form.isAlcohol,
isMarked: form.isMarked,
isActive: form.isActive,
minStock: form.minStock === '' ? null : Number(form.minStock),
@ -168,7 +168,7 @@ export function ProductEditPage() {
const updateBarcode = (i: number, patch: Partial<BarcodeRow>) =>
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 (
<form onSubmit={onSubmit} className="flex flex-col h-full">
@ -234,13 +234,12 @@ export function ProductEditPage() {
<Field label="Единица измерения *">
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
<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>
</Field>
<Field label="Ставка НДС *">
<Select required value={form.vatRateId} onChange={(e) => setForm({ ...form, vatRateId: e.target.value })}>
<option value=""></option>
{vats.data?.map((v) => <option key={v.id} value={v.id}>{v.name} ({v.percent}%)</option>)}
<Field label="Ставка НДС, %">
<Select value={form.vat} onChange={(e) => setForm({ ...form, vat: Number(e.target.value) })}>
{vatChoices.map((v) => <option key={v} value={v}>{v}%</option>)}
</Select>
</Field>
<Field label="Группа">
@ -266,9 +265,9 @@ export function ProductEditPage() {
</Field>
</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">
<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.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.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>

View file

@ -1,65 +1,194 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
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 { Plus } from 'lucide-react'
import { Plus, Filter, X } from 'lucide-react'
import { useCatalogList } from '@/lib/useCatalog'
import { ProductGroupTree } from '@/components/ProductGroupTree'
import type { Product } from '@/lib/types'
const URL = '/api/catalog/products'
export function ProductsPage() {
const navigate = useNavigate()
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL)
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 (
<ListPageShell
title="Товары"
description={data ? `${data.total.toLocaleString('ru')} записей в каталоге` : 'Каталог товаров и услуг'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" />
<Link to="/catalog/products/new">
<Button>
<Plus className="w-4 h-4" /> Добавить
</Button>
</Link>
</>
}
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) => navigate(`/catalog/products/${r.id}`)}
columns={[
{ header: 'Название', cell: (r) => (
<div>
<div className="font-medium">{r.name}</div>
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
</div>
)},
{ header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' },
{ header: 'Ед.', width: '70px', cell: (r) => r.unitSymbol },
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vatPercent}%` },
{ header: 'Тип', width: '140px', cell: (r) => (
<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.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>}
</div>
)},
{ header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
empty="Товаров ещё нет. Они появятся после приёмки или через API."
/>
</ListPageShell>
<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() {
const navigate = useNavigate()
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 (
<div className="flex h-full min-h-0">
{/* Left: groups tree */}
<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">
<ProductGroupTree
selectedId={filters.groupId}
onSelect={(id) => { setFilters({ ...filters, groupId: id }); setPage(1) }}
/>
</aside>
{/* Right: products */}
<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">
<div>
<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
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => navigate(`/catalog/products/${r.id}`)}
columns={[
{ header: 'Название', cell: (r) => (
<div>
<div className="font-medium">{r.name}</div>
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
</div>
)},
{ header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' },
{ header: 'Ед.', width: '70px', cell: (r) => r.unitName },
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vat}%` },
{ header: 'Тип', width: '140px', cell: (r) => (
<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.isWeighed && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Весовой</span>}
{r.isMarked && <span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">Маркир.</span>}
</div>
)},
{ header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
empty="Товаров ещё нет. Они появятся после приёмки или через API."
/>
</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
productName: string
productArticle: string | null
unitSymbol: string | null
unitName: string | null
quantity: number
unitPrice: number
discount: number
@ -81,7 +81,7 @@ export function RetailSaleEditPage() {
productId: l.productId,
productName: l.productName ?? '',
productArticle: l.productArticle,
unitSymbol: l.unitSymbol,
unitName: l.unitName,
quantity: l.quantity, unitPrice: l.unitPrice, discount: l.discount,
vatPercent: l.vatPercent,
})),
@ -179,11 +179,11 @@ export function RetailSaleEditPage() {
productId: p.id,
productName: p.name,
productArticle: p.article,
unitSymbol: p.unitSymbol,
unitName: p.unitName,
quantity: 1,
unitPrice: retail?.amount ?? 0,
discount: 0,
vatPercent: p.vatPercent,
vatPercent: p.vat * 1,
}],
})
}
@ -323,7 +323,7 @@ export function RetailSaleEditPage() {
<div className="font-medium">{l.productName}</div>
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
</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">
<TextInput type="number" step="0.001" disabled={isPosted}
className="text-right font-mono" value={l.quantity}

View file

@ -60,7 +60,7 @@ export function StockPage() {
</div>
)},
{ 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: '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) => (

View file

@ -6,9 +6,9 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
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 { type Store, StoreKind } from '@/lib/types'
import { type Store } from '@/lib/types'
const URL = '/api/catalog/stores'
@ -16,7 +16,6 @@ interface Form {
id?: string
name: string
code: string
kind: StoreKind
address: string
phone: string
managerName: string
@ -25,7 +24,7 @@ interface Form {
}
const blankForm: Form = {
name: '', code: '', kind: StoreKind.Warehouse, address: '', phone: '',
name: '', code: '', address: '', phone: '',
managerName: '', isMain: false, isActive: true,
}
@ -62,14 +61,13 @@ export function StoresPage() {
isLoading={isLoading}
rowKey={(r) => r.id}
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 ?? '',
isMain: r.isMain, isActive: r.isActive,
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ 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: 'Основной', width: '110px', cell: (r) => r.isMain ? '✓' : '—' },
{ 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 })} />
</Field>
</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="Адрес">
<TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</Field>

View file

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

View file

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