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>
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>
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>
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>
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>
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>
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>
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>
Default launchSettings.json from `dotnet new web` picked random port 5039,
which doesn't match the Vite proxy target http://localhost:5081, so the
React app can't reach /connect/token during login.
- Fix http profile to 5081 (HTTPS to 5443)
- Remove IIS Express profile (Mac-only dev, not needed)
- Disable launchBrowser (we run Web separately)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>