Система теперь корректно работает на узких экранах (<768px):
- AppLayout: на мобиле фиксированный sidebar заменён hamburger-меню
(Menu icon) + off-canvas drawer с overlay. На md+ — прежний sidebar.
- ProductsPage: дерево групп тоже превращается в drawer на мобиле,
кнопка «Группы» рядом с заголовком; фильтры flex-wrap.
- Modal: на мобиле (<sm) разворачивается на весь экран (items-stretch,
min-h-full, убраны скругления и верхний отступ).
- DataTable: мин-ширина 640px + whitespace-nowrap в заголовках, уменьшен
горизонтальный padding на мобиле. Родительский overflow-auto даёт
плавный горизонтальный скролл.
- PageHeader/ListPageShell: flex-wrap, меньший padding на мобиле.
- SearchBar: flex-1 на узких (занимает доступное место), фикс 256px на sm+.
- ProductEditPage Grid helper: 3/4 колонки теперь grid-cols-1 sm: 2
md: 3/4 — поля не слипаются на телефоне.
- ProductEditPage/Supply/RetailSale/Dashboard/OrganizationSettings:
отступы p-3 sm:p-6, grid grid-cols-2 на страну/валюту → 1 col на мобиле.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Триггерится только при изменении самого файла — после выполнения
будет удалён следующим коммитом.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
При POST /api/catalog/products если Article пустой — сервер берёт
max(Article::int) среди артикулов текущей организации и ставит +1.
Если числовых артикулов нет — «1». Пользователь может указать
артикул руками, тогда используется его значение.
Конфликт по unique index IX_products_OrganizationId_Article на
SaveChanges перехватывается — возвращается 400 с текстом «Артикул
«X» уже занят в этой организации.» вместо 500. Та же обработка
добавлена в Update.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
В таблице товаров:
- «Ед.» → «Фасовка» (packagingLabel из типа товара, sort packaging).
- «Штрихкодов» (count) → «Штрихкод» — первый код монoshrift.
- Убраны колонки «Группа» и «Активен».
- Добавлена «Закупочная цена» с форматированием «305.00 ₸»
(purchasePrice + purchaseCurrencyCode), sort purchasePrice.
На сервере ProductsController.List принимает новые sort keys
packaging и purchasePrice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Добавлена Organization.ShowMinMaxStock (bool, default false) — флаг
видимости полей «Минимальный / Максимальный остаток» на карточке
товара. В UI настроек магазина появилась соответствующая галка
с подсказкой. По умолчанию выключено — большинству магазинов
эти поля не нужны.
Миграция Phase5f_ShowMinMaxStock добавляет колонку.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- lib/barcode.ts: новая утилита generateBarcode(type) — валидные коды
под все форматы (EAN-13 с префиксом 2, EAN-8 с checksum, UPC-A
с checksum, UPC-E 8 цифр, Code128/Code39 12 буквенно-цифровых).
- ProductEditPage: при смене типа штрихкода в dropdown поле кода
регенерируется под новый формат.
- Field.tsx: единая высота h-10 и leading-none для TextInput/Select
чтобы Страна/Валюта/НДС в настройках были одного размера.
TextArea оставлен с h-auto для multiline.
- Pagination.tsx: рядом с ← → добавлен input «Страница [N] из M»
для прыжка на произвольную страницу (Enter / blur применяют).
- ProductEditPage: блок мин/макс остатков теперь показывается только
при org.showMinMaxStock (сама настройка добавится следующим коммитом).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Currency.IsActive удалён полностью (domain/DTO/API/web/миграция).
Валюты — глобальный справочник; «архивировать» USD глобально
бессмысленно, а per-tenant видимости у валют нет.
- MinorUnit остаётся в БД (нужен для форматирования цен), но скрыт
из UI: убран CurrencyDto.MinorUnit, CurrencyInput.MinorUnit,
колонка «Знаки» из списка.
- Форма валюты — 3 поля: Код / Название / Символ.
- Миграция Phase5e_DropCurrencyIsActive дропает колонку.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Единственная роль галки «В том числе НДС» на товаре — показать/скрыть
поле «Ставка НДС %». Никакой семантики «в том числе/сверху» на товаре
не живёт — это логика документа (продажи/поставки).
- Product.Vat: int → decimal (миграция Phase5d_ProductVatDecimal меняет
тип колонки на numeric(5,2)).
- ProductDto/ProductInput: decimal? Vat.
- ResolveDefaultVatAsync, seeders, MoySklad import — decimal.
- MoySklad 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>
Добавлены 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>
Новый штрихкод в товаре сразу получает валидный 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>
Все read-only поля (Валюта, Ставка НДС) теперь единого вида: disabled
TextInput на всю ширину ячейки в grid-cols-2 gap-4, та же высота/padding
что и у editable Select "Страна". Единая подсказка про источник правды
страны вынесена одним параграфом под сеткой.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Во всех таблицах можно сортировать по клику на заголовок столбца:
первый клик — по возрастанию (↑), второй — по убыванию (↓),
смена колонки сбрасывает предыдущую. Без активной сортировки —
серверный 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>
- 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>
- 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>
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.
- MoySklad импорт: дефолт 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 других этапов — не задеты.
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.
Миграция 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 добавлен раздел 'Настройки → Организация', убран
Ставки НДС (сущность удалена раньше).
В списке товаров колонка с бейджами Услуга/Весовой/Маркируемый только
занимает место и ничем не помогает — в UX MoySklad такого нет, убираю.
В форме редактирования минимальный/максимальный остаток (для
уведомлений о пополнении и автозаказа) — второстепенные поля, ушли в
раскрывающийся блок "Расширенные параметры" сразу после блока закупки.
Закупочная цена осталась основной.
НДС-колонка теперь рисует "—" если VatEnabled=false.
UI перестал отправлять токен в теле /test (он теперь из настроек),
а TestRequest был с non-null string — ASP.NET model validation отдавал
400 'One or more validation errors occurred'. Сделал nullable.
Pain points:
1. Импорт на ~30k товарах проходит 15-30 мин, nginx рвал на 60s → 504.
2. При импорте/очистке ничего не видно — ни счётчика, ни прогресса.
3. Токен приходилось вводить каждый раз вручную.
Фиксы:
- Async-job pattern: POST /api/admin/moysklad/import-products и
/api/admin/cleanup/all/async возвращают jobId, реальная работа
в Task.Run. GET /api/admin/jobs/{id} — статус +
Total/Created/Updated/Skipped/Deleted/Stage/Message.
- ImportJobRegistry (singleton, in-memory) — хранит job-progress.
- MoySkladImportService обновляет progress по мере пейджинга
(в т.ч. счётчик Created/Updated/Skipped).
- Cleanup разбит на именованные шаги, Stage меняется по мере
"Товары…" → "Группы…" → "Контрагенты…".
- Токен per-organization: Organization.MoySkladToken + миграция
Phase3_OrganizationMoySkladToken. Endpoints:
GET/PUT /api/admin/moysklad/settings.
- Импорт-endpoints больше не требуют token в теле — берут из org.
- HttpContextTenantContext.UseOverride(orgId) — AsyncLocal-scope
для background tasks (HttpContext там нет, а query-filter'у нужен
orgId — ставим через override).
Nginx (host + web-container) получил 60-минутный timeout на
/api/admin/import/ чтобы старый sync-путь тоже не ронять (на
случай если кто-то вернёт sync call).
Web:
- MoySkladImportPage переработан: блок "Токен API" (save/test
mask), блок импорта с кнопками без поля токена.
- JobCard с polling каждые 1.5s отображает живые счётчики и stage.
- DangerZone тоже теперь async с live-прогрессом.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Почему импорт раньше обрывался на ~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 —
существующие товары обновит, недостающие подтянет.
Что добавлено:
- Слева дерево товарных групп (рекурсивное, с раскрытием), клик
переключает фильтр 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>
Раньше архивных контрагентов/товаров 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
включая архив. Счётчик "Пропущено" отныне значит только "уже существует
и галка Перезаписать не стоит".
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 и пр., которых в коде давно нет).
Локально применилось без ошибок.
Проблема: при импорте контрагентов/товаров с галкой «перезаписать» код
ставил 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 с двумя
красными кнопками + подтверждение словом «УДАЛИТЬ». Показываются счётчики
до и что удалилось после.
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>
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 безопасен.
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`.
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.
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.
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.
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>
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>
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>
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>
Проверил через 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>
У 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>