Commit graph

423 commits

Author SHA1 Message Date
nns 24dc7fc619 refactor(vat): Product.Vat как decimal(5,2), поле видно только при VatEnabled
Единственная роль галки «В том числе НДС» на товаре — показать/скрыть
поле «Ставка НДС %». Никакой семантики «в том числе/сверху» на товаре
не живёт — это логика документа (продажи/поставки).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Setup docs in docs/telegram-bridge.md.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:26:01 +05:00