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>
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>
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>
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>
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>
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>
.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>
Domain (foodmarket.Domain.Purchases):
- Supply: Number (auto "П-{yyyy}-{000001}" per tenant), Date, Status
(Draft/Posted), Supplier (Counterparty), Store, Currency, invoice refs,
Notes, Total, PostedAt/PostedByUserId, Lines.
- SupplyLine: ProductId, Quantity, UnitPrice, LineTotal, SortOrder.
EF: supplies + supply_lines tables, unique index (tenant,Number), indexes
by date/status/supplier/product. Migration Phase2b_Supply applied.
API (/api/purchases/supplies, roles Admin/Manager/Storekeeper for mutations):
- GET list with filters (status, storeId, supplierId, search by number/name),
projected columns.
- GET {id} with full line list joined to products + units.
- POST create draft (lines optional at creation, grand total computed).
- PUT update — replaces all lines; rejected if already Posted.
- DELETE — drafts only.
- POST {id}/post — creates +qty StockMovements via IStockService.ApplyMovementAsync
for each line, flips to Posted, stamps PostedAt. Atomic (one SaveChanges).
- POST {id}/unpost — reverses with -qty movements tagged "supply-reversal",
returns to Draft so edits can resume.
- Auto-numbering scans existing numbers matching prefix per year+tenant.
Web:
- types: SupplyStatus, SupplyListRow, SupplyLineDto, SupplyDto.
- /purchases/supplies list (number, date, status badge, supplier, store,
line count, total in currency).
- /purchases/supplies/new + /:id edit page (sticky top bar with
Back / Save / Post / Unpost / Delete; reqisites grid; lines table with
inline qty/price and running total + grand total in bottom row).
- ProductPicker modal: full-text search over products (name/article/barcode),
shows purchase price for quick reference, click to add line.
- Sidebar new group "Закупки" → "Приёмки" (TruckIcon).
Flow: create draft → add lines via picker → edit qty/price → Save → Post.
Posting writes StockMovement rows (visible on Движения) and updates Stock
aggregate (visible on Остатки). Unpost reverses in place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Domain:
- foodmarket.Domain.Inventory.Stock — materialized aggregate per (Product, Store)
with Quantity, ReservedQuantity, computed Available. Unique index on tenant+
product+store.
- foodmarket.Domain.Inventory.StockMovement — append-only journal with signed
quantity, optional UnitCost, MovementType enum (Initial, Supply, RetailSale,
WholesaleSale, CustomerReturn, SupplierReturn, TransferOut, TransferIn,
WriteOff, Enter, InventoryAdjustment), document linkage (type, id, number),
OccurredAt, CreatedBy, Notes.
Application:
- IStockService.ApplyMovementAsync draft — appends movement row + upserts
materialized Stock row in the same unit of work. Callers control SaveChanges
so a posting doc can bundle all lines atomically.
Infrastructure:
- StockService implementation over AppDbContext.
- InventoryConfigurations EF mapping (precision 18,4 on quantities/costs;
indexes for product+time, store+time, document lookup).
- Migration Phase2a_Stock applied to dev DB (tables stocks, stock_movements).
API (GET, read-only for now):
- /api/inventory/stock — filter by store, product, includeZero; joins product +
unit + store names; server-side pagination.
- /api/inventory/movements — journal filtered by store/product/date range;
movement type as string enum for UI labels.
- Both [Authorize] (any authenticated user).
MoySklad:
- MsCounterparty DTO (name, legalTitle, inn, kpp, companyType, tags...).
- MoySkladClient.StreamCounterpartiesAsync — paginated like products.
- MoySkladImportService.ImportCounterpartiesAsync — maps tags → Kind (supplier /
customer / both), companyType → LegalEntity/Individual; dedup by Name;
defensive trim on all string fields; per-item try/catch; batches of 500.
- /api/admin/moysklad/import-counterparties endpoint (Admin policy).
Web:
- /inventory/stock list page (store filter, include-zero toggle, search; shows
quantity/reserved/available with red-on-negative, grey-on-zero accents).
- /inventory/movements list page (store filter; colored quantity +/-, Russian
labels for each movement type).
- MoySklad import page restructured: single token test + two import buttons
(Товары, Контрагенты) + reusable ImportResult panel that handles both.
- Sidebar: new "Остатки" group with Остатки + Движения; icons Boxes + History.
Uses the ListPageShell pattern introduced in d3aa13d — sticky top bar, sticky
table header, only the body scrolls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AppLayout is now h-screen with overflow-hidden; main area is flex-col so each
page controls its own scroll region. The sidebar and page header stay put no
matter how long the content.
New ListPageShell wraps every list page: sticky title/actions bar at top,
scrollable body (with sticky table thead via DataTable update), optional
sticky pagination footer. Converted 10 list pages (products, countries,
currencies, price-types, units, vat-rates, stores, retail-points, product-
groups, counterparties).
ProductEditPage rebuilt around the same pattern:
- Sticky top bar with back arrow, title, and Save/Delete buttons — no more
hunting for the save button after scrolling a long form.
- Body is a max-w-5xl centered column with evenly spaced section cards.
- Sections get header strips (title + optional action on the right).
- Grid is a consistent 3-col (or 4 for stock/покупка) on md+, single column
on mobile. Field sizes line up across sections.
- Flags collapse into a single wrap row under classification.
- Prices/Barcodes tables use a 12-col grid so columns align horizontally.
DataTable: thead is now position:sticky top-0, backdrop-blurred; rows use
border-bottom on cells for consistent separator in the scrolled body.
PageHeader gained a `variant="bar"` mode for shell usage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Import against a live MoySklad account crashed with PostgreSQL 22001 after
loading 21500/~N products: Article column was varchar(100), but some MoySklad
items have longer internal codes, and Barcode.Code needed to grow for future
GS1 DataMatrix / Честный ЗНАК tracking codes (up to ~300 chars).
- EF config: Product.Article 100 → 500, ProductBarcode.Code 100 → 500.
- Migration Phase1e_WidenArticleBarcode (applied to dev DB).
- Defensive Trim() in the MoySklad importer for Name/Article/Barcode so even
future schema drift won't take the whole import down.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MoySklad returns minPrice.value and salePrices[*].value as fractional numbers
for some accounts/products (not always pure kopecks). Deserializing as long
failed with "out of bounds for an Int64" → 500 on import. Switch MsMoney.Value
and MsSalePrice.Value to decimal, which accepts both integer and decimal
representations. Division by 100m already happens when mapping to local
Product, so semantics are unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User is importing the real catalog from MoySklad — the placeholder KZ-market
demo products I seeded would just pollute the results. Nuked via:
TRUNCATE product_prices, product_barcodes, products, product_groups,
counterparties CASCADE;
DemoCatalogSeeder stays in the source tree, commented out in Program.cs —
anyone running without MoySklad access can re-enable it by uncommenting
one line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues surfaced after the previous gzip-removal:
1. MoySklad's nginx edge returned 415 on some requests without a User-Agent.
Send a friendly UA string (food-market/0.1 + repo URL).
2. Previous fix dropped gzip support entirely; re-enable it properly by
configuring AutomaticDecompression on the typed HttpClient's primary
handler via AddHttpClient.ConfigurePrimaryHttpMessageHandler. Now the
response body is transparently decompressed before the JSON deserializer
sees it — no more 0x1F errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HttpClient in DI isn't configured with AutomaticDecompression, so MoySklad
returned a gzip-compressed body that ReadFromJsonAsync choked on (0x1F is
the gzip magic byte). Cheapest correct fix is to not advertise gzip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The typed MediaTypeWithQualityHeaderValue API adds a space after the
semicolon ("application/json; charset=utf-8"), and MoySklad rejects anything
other than the exact literal "application/json;charset=utf-8" with error 1062.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MoySklad rejects application/json without charset=utf-8 with error 1062
"Неверное значение заголовка 'Accept'". They require the exact value
"application/json;charset=utf-8".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Logs showed every outbound MoySklad call was hitting
https://api.moysklad.ru/api/remap/entity/organization
instead of the intended
https://api.moysklad.ru/api/remap/1.2/entity/organization
Cause: per RFC 3986 §5.3, when HttpClient resolves a relative URI against
a base URI whose path does not end with '/', the last segment of the base
path is discarded. So BaseAddress "…/api/remap/1.2" + relative "entity/…"
produced "…/api/remap/entity/…". MoySklad returned 503 and we translated
it into a useless "401 сессия истекла" for the user.
Fixes:
- Append trailing slash to BaseUrl.
- Surface the real upstream status + body: MoySkladApiResult<T> wrapper,
and the controller now maps 401/403 → "invalid token", 502/503 →
"MoySklad unavailable", anything else → "MoySklad returned {code}: {body}".
No more lying-as-401.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ASP.NET Core's [Authorize(Roles=...)] relies on ClaimsIdentity.RoleClaimType to
match, which may not be wired to "role" in the OpenIddict validation handler's
identity (depending on middleware order with AddIdentity). Tokens clearly carry
"role": "Admin" but IsInRole("Admin") returns false.
- Register AddAuthorization policy "AdminAccess" that checks the `role` claim
explicitly (c.Type == Claims.Role && Value in {Admin, SuperAdmin}). Works
regardless of how ClaimsIdentity was constructed.
- MoySkladImportController now uses [Authorize(Policy = "AdminAccess")].
- Add /api/_debug/whoami that echoes authType, roleClaimType, claims, and
IsInRole result — makes next auth issue trivial to diagnose.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of the 404 on /api/admin/moysklad/test (and /api/me):
- AddIdentity<> sets DefaultChallengeScheme = IdentityConstants.ApplicationScheme
(cookies), so unauthorized API calls got 302 → /Account/Login → 404 instead of 401.
- Ephemeral OpenIddict keys (AddEphemeralSigningKey) regenerated on every API
restart, silently invalidating any JWT already stored in the browser.
Fixes:
- Explicitly set DefaultScheme / DefaultAuthenticateScheme / DefaultChallengeScheme
to OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme so [Authorize]
challenges now return 401 (axios interceptor can react + retry or redirect).
- Replace ephemeral RSA keys with a persistent dev RSA key stored in
src/food-market.api/App_Data/openiddict-dev-key.xml (gitignored). Generated on
first run, reused on subsequent starts. Dev tokens now survive API restarts.
Production must register proper X509 certificates via configuration.
- .gitignore: add App_Data/, *.pem, openiddict-dev-key.xml patterns.
- Web axios: on hard 401 with failed refresh, redirect to /login rather than
leaving the user stuck on a protected screen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Logo simplified to just "FOOD" (black) + "MARKET" (brand green) text — matches
the app-icon style without the distracting FM badge square.
MoySklad import page now shows actionable error text instead of generic
"Request failed with status code 404":
- 404 → "эндпоинт не существует, API не перезапущен после git pull"
- 401 → "сессия истекла, перелогинься"
- 403 → "нужна роль Admin или SuperAdmin"
- 502/503 → "МойСклад недоступен"
- Otherwise extracts body.error / error_description / title from response
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Infrastructure (foodmarket.Infrastructure.Integrations.MoySklad):
- MoySkladDtos: minimal shapes of products, folders, uom, prices, barcodes from JSON-API 1.2
- MoySkladClient: HttpClient wrapper with Bearer auth per call
- WhoAmIAsync (GET entity/organization) for connection test
- StreamProductsAsync (paginated 1000/page, IAsyncEnumerable)
- GetAllFoldersAsync (all product folders in one go)
- MoySkladImportService: orchestrates the full import
- Creates missing product folders with Path preserved
- Maps MoySklad VAT percent → local VatRate (fallback to default)
- Maps barcodes: ean13/ean8/code128/gtin/upca/upce → our BarcodeType enum
- Extracts retail price from salePrices (prefers "Розничная"), divides kopeck→major
- Extracts buyPrice → PurchasePrice
- Skips existing products by article OR primary barcode (unless overwrite flag set)
- Batch SaveChanges every 500 items to keep EF tracker light
- Returns counts + per-item error list
API: POST /api/admin/moysklad/test — returns org name if token valid
API: POST /api/admin/moysklad/import-products { token, overwriteExisting }
— Authorize(Roles = "Admin,SuperAdmin")
Web: /admin/import/moysklad page
- Amber notice: token is not persisted (request-scope only), how to create
a service token in moysklad.ru with read-only rights
- Test connection button + result banner
- Import button with "overwrite existing" checkbox
- Result panel with 4 counters + collapsible error list
Sidebar adds "Импорт" section with MoySklad link.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extract brand colors from food-market-app/.../AppIcon/appicon.svg
(background #00B207, white FOOD, pale-green E8F5E9 MARKET)
- Add @theme custom colors in index.css:
--color-brand #00B207, --color-brand-hover #009305, --color-brand-dark #007605,
--color-brand-light #E8F5E9, --color-brand-tint #D7F2D9, --color-brand-foreground #FFFFFF
- Replace all violet-* Tailwind classes with var(--color-brand*) in:
LoginPage, Button, Field (input+checkbox), SearchBar, AppLayout (nav active state)
- New Logo component: FM square badge + "FOOD" + "MARKET" typography in brand colors
- Put Logo in sidebar header and on LoginPage
- Replace Vite default favicon with branded SVG (green square + FOOD MARKET)
- Page title "FOOD MARKET", theme-color meta tag for mobile browsers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Starter experience so the system is usable immediately after git clone → migrate → run.
DemoCatalogSeeder (Development only, runs once — skips if tenant has any products):
- 8 product groups: Напитки (Безалкогольные, Алкогольные), Молочка, Хлеб, Кондитерские,
Бакалея, Снеки — hierarchical Path computed
- 2 demo suppliers: ТОО «Продтрейд» (legal entity, KZ BIN, bank details), ИП Иванов (individual)
- 35 realistic KZ-market products with:
- Demo barcodes in 2xxx internal range (won't collide with real products)
- Retail price + purchase price at 72% of retail
- Country of origin (KZ / RU)
- Хлеб marked as 0% VAT (socially important goods in KZ)
- Сыр «Российский» marked as весовой
- Articles in kebab-case: DR-SOD-001, DAI-MLK-002, SW-CHO-001 etc.
Product form (full page /catalog/products/new and /:id, not modal):
- 5 sections: Основное / Классификация / Остатки и закупка / Цены / Штрихкоды
- Dropdowns for unit, VAT, group, country, supplier, currency via useLookups hooks
- Defaults pre-filled for new product (default VAT, base unit, KZT)
- Prices table: add/remove rows, pick price type + amount + currency
- Barcodes table: EAN-13/8/CODE128/UPC options, "primary" enforces single
- Server-side atomic save (existing Prices+Barcodes replaced on PUT)
Products page: row click → edit page, Add button → new page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop ReactQueryDevtools floating button (the "palm tree" in corner)
- Dashboard now shows: greeting with user name, stat cards, user profile
(name/email/roles/orgId), and roadmap
- Add amber banner when API calls fail (typical cause: API not restarted
after pulling new catalog code) with explicit fix instructions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Application layer:
- PagedRequest/PagedResult<T> with sane defaults (pageSize 50, max 500)
- CatalogDtos: read DTOs with joined names + input DTOs for upsert
- Product input supports nested Prices[] and Barcodes[] for atomic save
API controllers (api/catalog/…):
- countries, currencies (global, write requires SuperAdmin)
- vat-rates, units-of-measure, price-types, stores (write: Admin/Manager)
- retail-points (references Store, Admin/Manager write)
- product-groups: hierarchy with auto-computed Path, delete guarded against children/products
- counterparties: filter by kind (Supplier/Customer/Both), full join with Country
- products: includes joined lookups, filter by group/isService/isWeighed/isActive,
search by name/article/barcode, write replaces Prices+Barcodes atomically
Role semantics:
- SuperAdmin: mutates global references only
- Admin: mutates/deletes tenant references
- Manager: mutates tenant references (no delete on some)
- Storekeeper: can manage counterparties and products (but not delete)
All endpoints guarded by [Authorize]. Multi-tenant isolation via EF query filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>