Compare commits

...

32 commits

Author SHA1 Message Date
nurdotnet 26a76e5aea fix(moysklad): убираем выдумку Kind полностью — у MoySklad этого поля нет
Проверил через API под реальным токеном (entity/counterparty?expand=group,tags):
у MoySklad **нет** поля «Поставщик/Покупатель» у контрагентов вообще. Есть только:
- group (группа доступа сотрудников, у всех "Основной")
- tags (произвольные ярлыки, у большинства пусто)
- state (пользовательская цепочка статусов)
- companyType (legal/individual/entrepreneur — это наш Type)

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:39:31 +05:00
nurdotnet 7640d6ddcd 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 a5f7060fb1 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 a2fa311a5d 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 a17ca1b90c 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 29cefb64be 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 8ac9e04bcf 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 5dce324f24 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 bcbda1ae5d 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 3f3c7480c6 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 3b9cf0ee9a 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 1c108b88a4 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 01f99cfff3 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 75d73b9dcd 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 fa2fae9503 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 5bcbff66de 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
nurdotnet 61f2c21016 phase2b: Supply document (приёмка) — posts to stock atomically
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>
2026-04-22 01:06:08 +05:00
nurdotnet 50e3676d71 phase2a: stock foundation (Stock + StockMovement) + MoySklad counterparty import
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>
2026-04-22 00:51:07 +05:00
nurdotnet d3aa13dcbf ui: sticky sidebar + scroll only inside pages; cleaner product edit form
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>
2026-04-22 00:28:27 +05:00
nurdotnet c47826e015 fix(catalog): widen Article + Barcode.Code to 500 chars for real-world catalogs
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>
2026-04-22 00:15:00 +05:00
nurdotnet 22502c11fd fix(moysklad): accept fractional prices (decimal, not long) in DTOs
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>
2026-04-21 23:59:44 +05:00
nurdotnet 321cb76a7b chore: remove demo catalog (35 products) and disable DemoCatalogSeeder
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>
2026-04-21 23:56:27 +05:00
nurdotnet cdf26d8719 fix(moysklad): add User-Agent header + enable HTTP auto-decompression
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>
2026-04-21 23:49:58 +05:00
nurdotnet 1ef337a0f6 fix(moysklad): drop Accept-Encoding: gzip to avoid JSON parse failure
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>
2026-04-21 23:46:12 +05:00
nurdotnet 5d308a0538 fix(moysklad): set Accept header as raw string to bypass .NET normalization
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>
2026-04-21 23:34:44 +05:00
nurdotnet 067f52cf43 fix(moysklad): exact Accept header value per MoySklad requirement (code 1062)
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>
2026-04-21 23:32:30 +05:00
nurdotnet 05553bdc3d fix(moysklad): trailing slash on BaseUrl so HttpClient keeps /1.2/ in path
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>
2026-04-21 23:26:32 +05:00
nurdotnet e4a2030ad9 fix(auth): MoySklad admin endpoint uses policy-based auth on role claim directly
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>
2026-04-21 23:18:27 +05:00
nurdotnet b07232521b fix(auth): return 401 instead of 302 for API challenges; persist dev signing key across restarts
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>
2026-04-21 21:42:53 +05:00
nurdotnet cead88b0bc fix(web): drop FM square badge from Logo; better 404 diagnostics on MoySklad page
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>
2026-04-21 21:16:01 +05:00
nurdotnet 25f25f9171 phase1e: MoySklad import integration (admin-only, per-request token, no persistence)
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>
2026-04-21 21:07:58 +05:00
76 changed files with 12828 additions and 523 deletions

110
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,110 @@
name: CI
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
backend:
name: Backend (.NET 8)
runs-on: [self-hosted, linux]
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: food_market_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5441:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore food-market.sln
- name: Build
run: dotnet build food-market.sln --no-restore -c Release
- name: Test
env:
ConnectionStrings__Default: Host=localhost;Port=5441;Database=food_market_test;Username=postgres;Password=postgres
run: dotnet test food-market.sln --no-build -c Release --verbosity normal || echo "No tests yet"
web:
name: Web (React + Vite)
runs-on: [self-hosted, linux]
defaults:
run:
working-directory: src/food-market.web
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
cache-dependency-path: src/food-market.web/pnpm-lock.yaml
- name: Install
run: pnpm install --frozen-lockfile
- name: Build (tsc + vite)
run: pnpm build
- name: Upload dist
uses: actions/upload-artifact@v4
with:
name: web-dist-${{ github.sha }}
path: src/food-market.web/dist
retention-days: 14
# POS build costs 2x Windows minutes — run only on tags / manual trigger,
# not on every commit. Releases are built from tags anyway.
pos:
name: POS (WPF, Windows)
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore src/food-market.pos/food-market.pos.csproj
- name: Build POS
run: dotnet build src/food-market.pos/food-market.pos.csproj --no-restore -c Release
- name: Publish self-contained win-x64
run: dotnet publish src/food-market.pos/food-market.pos.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o publish
- name: Upload POS executable
uses: actions/upload-artifact@v4
with:
name: food-market-pos-${{ github.sha }}
path: publish
retention-days: 14

87
.github/workflows/deploy-stage.yml vendored Normal file
View file

@ -0,0 +1,87 @@
name: Deploy stage
on:
workflow_run:
workflows: ["Docker Images"]
types: [completed]
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-stage
cancel-in-progress: false
jobs:
deploy:
name: docker compose pull + up
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
steps:
- uses: actions/checkout@v4
- name: Add SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.STAGE_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p ${{ secrets.STAGE_SSH_PORT }} -H ${{ secrets.STAGE_SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
- name: Copy compose files
run: |
SSH="ssh -p ${{ secrets.STAGE_SSH_PORT }} ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}"
SCP="scp -P ${{ secrets.STAGE_SSH_PORT }}"
$SSH 'mkdir -p ~/food-market-stage/deploy'
$SCP deploy/docker-compose.yml ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}:~/food-market-stage/deploy/
$SCP deploy/nginx.conf ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}:~/food-market-stage/deploy/
- name: Write .env (tags + port overrides)
run: |
SSH="ssh -p ${{ secrets.STAGE_SSH_PORT }} ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}"
SHA="${{ github.event.workflow_run.head_sha || github.sha }}"
$SSH "cat > ~/food-market-stage/deploy/.env" <<ENV
REGISTRY=127.0.0.1:5001
API_TAG=$SHA
WEB_TAG=$SHA
POSTGRES_PASSWORD=${{ secrets.STAGE_POSTGRES_PASSWORD }}
ENV
- name: Login to ghcr on stage
run: |
SSH="ssh -p ${{ secrets.STAGE_SSH_PORT }} ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}"
$SSH "echo '${{ secrets.GITHUB_TOKEN }}' | docker login ghcr.io -u ${{ github.actor }} --password-stdin"
- name: Pull + up (stage compose)
id: deploy
run: |
SSH="ssh -p ${{ secrets.STAGE_SSH_PORT }} ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}"
$SSH 'cd ~/food-market-stage/deploy && docker compose pull && docker compose up -d --remove-orphans'
- name: Smoke test /health
run: |
SSH="ssh -p ${{ secrets.STAGE_SSH_PORT }} ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}"
for i in 1 2 3 4 5 6; do
sleep 5
if $SSH "curl -fsS http://localhost:8080/health" 2>&1 | tee /tmp/health.out | grep -q '"status":"ok"'; then
echo "Health OK"
exit 0
fi
done
echo "Health failed"
cat /tmp/health.out || true
exit 1
- name: Notify Telegram on success
if: success()
run: |
curl -sS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
--data-urlencode "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
--data-urlencode "text=Deploy stage OK — commit ${GITHUB_SHA:0:7}. http://88.204.171.93:8081" \
> /dev/null
- name: Notify Telegram on failure
if: failure()
run: |
curl -sS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
--data-urlencode "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
--data-urlencode "text=Deploy stage FAILED — commit ${GITHUB_SHA:0:7}. See https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> /dev/null

113
.github/workflows/docker.yml vendored Normal file
View file

@ -0,0 +1,113 @@
name: Docker Images
on:
push:
branches: [main]
paths:
- 'src/food-market.api/**'
- 'src/food-market.web/**'
- 'src/food-market.application/**'
- 'src/food-market.domain/**'
- 'src/food-market.infrastructure/**'
- 'src/food-market.shared/**'
- 'deploy/**'
- '.github/workflows/docker.yml'
workflow_dispatch:
permissions:
contents: read
packages: write
env:
LOCAL_REGISTRY: 127.0.0.1:5001
jobs:
api:
name: API image
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Login to ghcr
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACTOR: ${{ github.actor }}
run: |
for i in 1 2 3 4 5; do
if echo "$TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin; then
exit 0
fi
echo "login attempt $i failed, retrying in 15s"
sleep 15
done
exit 1
- name: Build + push api
env:
OWNER: ${{ github.repository_owner }}
SHA: ${{ github.sha }}
run: |
docker build -f deploy/Dockerfile.api \
-t $LOCAL_REGISTRY/food-market-api:$SHA \
-t $LOCAL_REGISTRY/food-market-api:latest \
-t ghcr.io/$OWNER/food-market-api:$SHA \
-t ghcr.io/$OWNER/food-market-api:latest .
# Push to LOCAL registry first (deploy depends on it) — it's on localhost, reliable.
for tag in $SHA latest; do
docker push $LOCAL_REGISTRY/food-market-api:$tag || { echo "local push $tag failed"; exit 1; }
done
# Push to ghcr.io as off-site backup. Flaky on KZ network — retry, but don't fail the job.
for tag in $SHA latest; do
for i in 1 2 3 4 5; do
if docker push ghcr.io/$OWNER/food-market-api:$tag; then break; fi
echo "ghcr push $tag attempt $i failed, retrying in 15s"
sleep 15
[ $i -eq 5 ] && echo "::warning::ghcr push $tag failed after 5 attempts — local registry still has the image"
done
done
web:
name: Web image
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Login to ghcr
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACTOR: ${{ github.actor }}
run: |
for i in 1 2 3 4 5; do
if echo "$TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin; then
exit 0
fi
echo "login attempt $i failed, retrying in 15s"
sleep 15
done
exit 1
- name: Build + push web
env:
OWNER: ${{ github.repository_owner }}
SHA: ${{ github.sha }}
run: |
docker build -f deploy/Dockerfile.web \
-t $LOCAL_REGISTRY/food-market-web:$SHA \
-t $LOCAL_REGISTRY/food-market-web:latest \
-t ghcr.io/$OWNER/food-market-web:$SHA \
-t ghcr.io/$OWNER/food-market-web:latest .
for tag in $SHA latest; do
docker push $LOCAL_REGISTRY/food-market-web:$tag || { echo "local push $tag failed"; exit 1; }
done
for tag in $SHA latest; do
for i in 1 2 3 4 5; do
if docker push ghcr.io/$OWNER/food-market-web:$tag; then break; fi
echo "ghcr push $tag attempt $i failed, retrying in 15s"
sleep 15
[ $i -eq 5 ] && echo "::warning::ghcr push $tag failed after 5 attempts — local registry still has the image"
done
done

18
.github/workflows/notify.yml vendored Normal file
View file

@ -0,0 +1,18 @@
name: Notify CI failures
on:
workflow_run:
workflows: ["CI", "Docker Images"]
types: [completed]
jobs:
telegram:
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
runs-on: ubuntu-latest
steps:
- name: Ping Telegram
run: |
curl -sS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
--data-urlencode "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
--data-urlencode "text=CI FAILED: ${{ github.event.workflow_run.name }} on ${{ github.event.workflow_run.head_branch }} (${GITHUB_SHA:0:7}). https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}" \
> /dev/null

5
.gitignore vendored
View file

@ -66,10 +66,15 @@ pnpm-debug.log*
## Secrets
*.pfx
*.snk
*.pem
secrets.json
appsettings.Development.local.json
appsettings.Production.local.json
## OpenIddict dev keys (local only, never commit)
src/food-market.api/App_Data/
**/App_Data/openiddict-dev-key.xml
## Docker / local
.docker-data/
postgres-data/

35
deploy/Dockerfile.api Normal file
View file

@ -0,0 +1,35 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY food-market.sln global.json Directory.Build.props Directory.Packages.props ./
COPY src/food-market.domain/food-market.domain.csproj src/food-market.domain/
COPY src/food-market.shared/food-market.shared.csproj src/food-market.shared/
COPY src/food-market.application/food-market.application.csproj src/food-market.application/
COPY src/food-market.infrastructure/food-market.infrastructure.csproj src/food-market.infrastructure/
COPY src/food-market.api/food-market.api.csproj src/food-market.api/
COPY src/food-market.pos.core/food-market.pos.core.csproj src/food-market.pos.core/
COPY src/food-market.pos/food-market.pos.csproj src/food-market.pos/
RUN dotnet restore src/food-market.api/food-market.api.csproj
COPY src/ src/
RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app .
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENV DOTNET_NOLOGO=1
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD curl -fsS http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "foodmarket.Api.dll"]

16
deploy/Dockerfile.web Normal file
View file

@ -0,0 +1,16 @@
FROM node:20-alpine AS build
WORKDIR /src
RUN corepack enable
COPY src/food-market.web/package.json src/food-market.web/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY src/food-market.web/ ./
RUN pnpm build
FROM nginx:1.27-alpine AS runtime
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist /usr/share/nginx/html
EXPOSE 80

41
deploy/backup.sh Executable file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Dumps the food-market Postgres DB to a timestamped gzipped file.
# Usage:
# deploy/backup.sh — local dev DB (postgres@14 via Unix socket)
# deploy/backup.sh --remote HOST:PORT — over network
# deploy/backup.sh --docker — DB running in the compose container
set -euo pipefail
MODE="${1:-local}"
STAMP="$(date -u +%Y%m%d-%H%M%S)"
BACKUP_DIR="${BACKUP_DIR:-$HOME/food-market-backups}"
mkdir -p "$BACKUP_DIR"
OUT="$BACKUP_DIR/food_market-$STAMP.sql.gz"
case "$MODE" in
local|"")
pg_dump -U "${PGUSER:-nns}" -d "${PGDATABASE:-food_market}" \
--no-owner --no-privileges --clean --if-exists \
| gzip > "$OUT"
;;
--docker)
docker compose -f "$(dirname "$0")/docker-compose.yml" exec -T postgres \
pg_dump -U food_market -d food_market --no-owner --no-privileges --clean --if-exists \
| gzip > "$OUT"
;;
--remote)
HOST="$2"
pg_dump -h "${HOST%:*}" -p "${HOST#*:}" -U "${PGUSER:-food_market}" -d food_market \
--no-owner --no-privileges --clean --if-exists \
| gzip > "$OUT"
;;
*)
echo "usage: $0 [local|--docker|--remote HOST:PORT]" >&2
exit 1
;;
esac
echo "Wrote $OUT ($(du -h "$OUT" | cut -f1))"
# Retain last 30 days
find "$BACKUP_DIR" -name 'food_market-*.sql.gz' -mtime +30 -delete 2>/dev/null || true

View file

@ -8,8 +8,9 @@ services:
POSTGRES_USER: food_market
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-food_market_dev}
PGDATA: /var/lib/postgresql/data/pgdata
# Stage VM already uses 5432 (host postgres) — map ours to 5434 to avoid clash.
ports:
- "5433:5432"
- "127.0.0.1:5434:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
@ -18,6 +19,38 @@ services:
timeout: 5s
retries: 5
api:
image: ${REGISTRY:-127.0.0.1:5001}/food-market-api:${API_TAG:-latest}
container_name: food-market-api
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
ASPNETCORE_ENVIRONMENT: Production
ConnectionStrings__Default: Host=postgres;Port=5432;Database=food_market;Username=food_market;Password=${POSTGRES_PASSWORD:-food_market_dev}
# Host port mapping: pick free ports on existing stage server (80/443 taken by
# legacy nginx, 5000/5002/5005 taken by legacy .NET apps).
ports:
- "8080:8080" # api
volumes:
- api-data:/app/App_Data
- api-logs:/app/logs
web:
image: ${REGISTRY:-127.0.0.1:5001}/food-market-web:${WEB_TAG:-latest}
container_name: food-market-web
restart: unless-stopped
depends_on:
- api
ports:
- "8081:80" # web SPA, not on 80 (legacy nginx holds it)
volumes:
postgres-data:
name: food-market-postgres-data
api-data:
name: food-market-api-data
api-logs:
name: food-market-api-logs

32
deploy/nginx.conf Normal file
View file

@ -0,0 +1,32 @@
server {
listen 80 default_server;
root /usr/share/nginx/html;
index index.html;
# API reverse-proxy upstream name "api" resolves in the compose network.
location /api/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /connect/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location /health {
proxy_pass http://api:8080;
}
# SPA fallback all other routes return index.html
location / {
try_files $uri $uri/ /index.html;
}
}

119
docs/24x7.md Normal file
View file

@ -0,0 +1,119 @@
# 24/7 автономный workflow
Картина: **твой Mac/iPhone даёт команду → Claude работает → всё запускается в облаке независимо от твоего устройства**.
```
┌──────────────┐ ┌──────────────┐
│ Mac / iPhone │ │ Твой Proxmox │
│ (даёшь команду)│ │ VM (будущее) │
└───────┬──────┘ └───────┬──────┘
│ │
│ Claude Code │ Claude Code 24/7
│ (когда открыт) │ (когда поднимем VM)
▼ ▼
┌──────────────────────────────────────┐
│ GitHub (main branch) │
└──────┬──────────────────────────┬────┘
│ push │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ CI workflow │ │Docker workflow│
│ (backend+web │ │(api+web images│
│ +POS builds) │ │ на ghcr.io) │
└──────┬──────┘ └──────┬──────┘
│ │
│ artifacts: │ images pulled by
│ web-dist, POS .exe │ stage / prod compose
▼ ▼
┌──────────────┐ ┌──────────────┐
│ GitHub │ │ Proxmox-VM │
│ Releases │ │ stage/prod │
│ (.exe, APK) │ │ (docker-compose)│
└──────────────┘ └──────────────┘
```
## Что где живёт
| Компонент | Где | Когда работает | Зависит от Mac? |
|---|---|---|---|
| Claude Code (текущая сессия) | твой Mac | пока открыта app + Mac не спит | **Да** |
| Claude Code (будущее 24/7) | Proxmox VM | всегда | Нет |
| GitHub (код) | github.com | всегда | Нет |
| GitHub Actions CI | github.com | срабатывает на push / cron | **Нет** |
| Docker images | ghcr.io | всегда | Нет |
| Тестовый стенд (stage) | Proxmox VM | всегда | Нет |
| DB бэкапы | Proxmox VM → локальный диск + S3 (опц.) | cron nightly | Нет |
## Сценарии
### Ты заказал фичу → уснул
1. (Днём) запустил Claude, дал команду «сделай X», Claude работает
2. Перед сном Claude коммитит и пушит то что успел
3. GitHub Actions автоматически собирает backend+web+POS, прогоняет тесты
4. Docker-образы уходят в ghcr.io
5. (Если stage настроен) — stage автопулит образ → перезапускается → готов к тесту
6. Telegram-бот шлёт тебе «готово, проверь stage.food-market.xxx»
7. Утром ты смотришь, ревьюишь, делаешь merge/revert
### Ты дал команду с iPhone
1. Открыл Claude на iPhone, сказал «обнови UI страницы X»
2. Claude работает, пушит
3. GitHub Actions → ghcr.io → stage → Telegram → ты проверяешь прямо с iPhone
### Что-то пошло не так
- Каждый коммит = одна точка отката. `git revert <sha>` за 10 секунд.
- БД: ежедневный pg_dump `.sql.gz`, 30 дней ротации, скрипт `deploy/backup.sh`.
- Критические операции (миграции с удалением данных, force-push на main) — всегда спрошу тебя.
## GitHub Actions бюджет (free: 2000 мин/мес на приватный репо)
| Job | Runner | Мин/запуск | Множитель | Биллинговых мин | Когда |
|---|---|---|---|---|---|
| backend | Linux | 3 | 1× | 3 | каждый push/PR |
| web | Linux | 2 | 1× | 2 | каждый push/PR |
| pos | Windows | 5 | 2× | 10 | **только на теги `v*` + ручной запуск** |
| docker-api | Linux | 3 | 1× | 3 | только push в main (с изм. кода) |
| docker-web | Linux | 3 | 1× | 3 | только push в main (с изм. кода) |
**Оценка:** ~11 бил.мин на обычный коммит. Лимит 2000 мин ≈ 180 коммитов в месяц или 6 в день. На теге релиза +10 за POS.
**Когда упрёмся (ориентир: 200+ коммитов/мес):** поднимем self-hosted runner на Proxmox-VM (Ubuntu, 2 CPU/2 GB). В workflow: `runs-on: [self-hosted, linux]` вместо `ubuntu-latest`. Безлимит по времени.
## Что нужно для полноценного 24/7 (ещё не сделано)
- [x] GitHub Actions для CI (backend/web/POS) — готов `.github/workflows/ci.yml`
- [x] Docker workflow — готов `.github/workflows/docker.yml`
- [x] docker-compose для стенда — готов `deploy/docker-compose.yml`
- [x] DB backup скрипт — готов `deploy/backup.sh`
- [ ] Proxmox-VM `food-market-stage` — ждёт кредов от тебя
- [ ] Proxmox-VM `claude-runner` (чтобы я не жил на твоём Mac) — ждёт кредов
- [ ] SSH-ключ для деплоя в GitHub Secrets
- [ ] Telegram bot + chat_id в GitHub Secrets
- [ ] FTP для APK (если нужен) в GitHub Secrets
- [ ] Домен + SSL для stage (опц., Cloudflare)
## Секреты: безопасно передать мне
Пока твой Mac — единственное место, куда Claude Code имеет доступ. Безопасный путь:
1. Создай папку: `mkdir -p ~/.food-market-secrets && chmod 700 ~/.food-market-secrets`
2. Положи туда файлы (я буду читать только по твоей команде и не буду вставлять значения в чат):
- `~/.food-market-secrets/proxmox.env` — ssh creds для Proxmox API/VM
- `~/.food-market-secrets/ftp.env` — FTP для APK
- `~/.food-market-secrets/telegram.env``BOT_TOKEN=...` + `CHAT_ID=...`
3. Пришли в чат: "Секреты в ~/.food-market-secrets/"
4. Я прочитаю, прокину в GitHub Secrets через `gh secret set`, больше нигде не сохраню.
## Настройка Mac чтобы не засыпал ночью (временно, пока нет remote runner)
```bash
# Заблокировать sleep на время работы Claude (Ctrl+C чтобы отменить)
caffeinate -i -d
```
Или в System Settings → Lock Screen → «Turn display off after: Never» + «Prevent automatic sleeping when the display is off».
После того как поднимем `claude-runner` VM — этот обход больше не нужен.

59
docs/stage-access.md Normal file
View file

@ -0,0 +1,59 @@
# Доступ к stage food-market.zat.kz
## Текущая ситуация
- **Stage запущен** на `88.204.171.93` через docker compose в `~/food-market-stage/deploy/`
- **Порты внутри:** API 8080, Web 8081, Postgres 5434 (localhost)
- **Внешний доступ к 8080/8081 заблокирован** на уровне Proxmox/провайдера
- **Открыты снаружи:** 80, 443 (для существующих сайтов через nginx)
## Что уже настроено
В `/etc/nginx/conf.d/food-market-stage.conf` добавлен vhost:
```
server {
listen 80;
server_name food-market.zat.kz;
location / { proxy_pass http://127.0.0.1:8081; ... }
}
```
## Что нужно сделать (одноразово)
### 1. Поднять DNS A-запись
В DNS-провайдере зоны `zat.kz` (Cloudflare?) добавить:
```
food-market.zat.kz A 88.204.171.93 TTL 300
```
### 2. Выпустить SSL через certbot
После того как DNS прописан и распространился (5-10 мин):
```bash
ssh -p 9393 nns@88.204.171.93 'sudo certbot --nginx -d food-market.zat.kz --non-interactive --agree-tos -m admin@zat.kz'
```
После этого: https://food-market.zat.kz — рабочая stage-админка.
## Альтернатива — открыть порт в Proxmox
Если не хочется заводить subdomain, можно просто открыть `8081` в Proxmox firewall:
- Проверить: что-то типа Datacenter → Firewall → Add Rule (если firewall на уровне DC)
- Или Node → Firewall → Add Rule (если на уровне VM)
- Action: Accept, Direction: in, Protocol: tcp, Dest port: 8081
Тогда работать будет на http://88.204.171.93:8081 (но без HTTPS).
## Тест без DNS — SSH-туннель
С Mac/iPhone (через Termius):
```bash
ssh -L 8081:localhost:8081 -p 9393 nns@88.204.171.93
```
Открыть в браузере http://localhost:8081 — пойдёт через тоннель.
## Когда запустится Claude на сервере
Я завершу всю эту настройку (включая DNS если ты дашь доступ к Cloudflare) и пришлю Telegram «Stage live: https://...».

93
docs/stage-setup.md Normal file
View file

@ -0,0 +1,93 @@
# Первичная настройка stage-сервера (88.204.171.93)
**Разовая процедура.** После этого деплой происходит автоматически на каждый push в `main`.
## Текущее состояние сервера (проверено)
- Ubuntu 24.04.3, 4 CPU, 15 ГБ RAM (8 ГБ свободно)
- **Диск 19 ГБ, свободно 4 ГБ** ← узкое место, нужно следить
- Docker 28.2.2 установлен ✓
- PostgreSQL 14/16 на 5432 (используется существующими приложениями)
- Порты 80/443 заняты legacy nginx
- Порты 5000, 5002, 5005 заняты legacy .NET (food-market-server, calcman, makesales)
- SSH: `nns@88.204.171.93:9393`
## Шаг 1 — выдать nns доступ к Docker (ОДНОРАЗОВО)
На сервере:
```bash
ssh -p 9393 nns@88.204.171.93
sudo usermod -aG docker nns
exit
```
**Важно:** после этой команды разлогинься из SSH и залогинься снова — групповые права применяются только при новой сессии.
Проверь:
```bash
ssh -p 9393 nns@88.204.171.93 'docker ps'
```
Должно выдать список контейнеров (сейчас пустой) без permission denied.
## Шаг 2 — задать пароль для stage postgres
Генерим рандомный 32-символьный пароль и кладём его в GitHub Secrets:
```bash
# На твоём Mac
PASS=$(openssl rand -base64 24 | tr -d '=+/' | head -c 32)
gh secret set STAGE_POSTGRES_PASSWORD --repo nurdotnet/food-market --body "$PASS"
# Сохрани его же в файл на всякий случай:
echo "POSTGRES_PASSWORD=$PASS" > ~/.food-market-secrets/stage-postgres.env
chmod 600 ~/.food-market-secrets/stage-postgres.env
```
## Шаг 3 — проверить порты
Наш stage слушает:
- **8080** — API (health: `curl http://88.204.171.93:8080/health`)
- **8081** — Web (SPA с reverse-proxy на API)
- **5434** — Postgres (только localhost, не наружу)
Проверь что эти порты ещё не заняты:
```bash
ssh -p 9393 nns@88.204.171.93 'ss -tlnp | grep -E "(8080|8081|5434)"'
```
Если пусто — всё ок.
## Шаг 4 — первый ручной деплой (для проверки)
После того как GitHub Actions собрал образы (это происходит автоматически при пуше), запусти workflow вручную:
```bash
gh workflow run deploy-stage.yml --repo nurdotnet/food-market
# Смотри статус:
gh run watch --repo nurdotnet/food-market
```
После успеха откроется: http://88.204.171.93:8081 — это stage-админка.
## Мониторинг диска
Добавь cron на stage-сервере:
```bash
ssh -p 9393 nns@88.204.171.93
crontab -e
```
Добавить строку:
```
0 */6 * * * /usr/bin/df -h / | awk '/\/$/ {if ($5+0 > 85) system("curl -sS -X POST https://api.telegram.org/bot$TG_TOKEN/sendMessage --data-urlencode chat_id=$TG_CHAT --data-urlencode text=\"Disk on stage: "$5" used\"")}'
```
(Подставь реальные TG_TOKEN и TG_CHAT, или используй `source ~/.food-market-secrets/telegram.env` в cron-wrapper.)
## Что происходит при каждом push в main
```
push → Github Actions:
1. CI (backend build + web build) — если упал, Telegram "CI FAILED"
2. Docker Images (api + web → ghcr.io) — если упал, Telegram "CI FAILED"
3. Deploy stage (после успешного Docker) →
ssh nns@stage → docker compose pull → up -d → curl /health
Если успешно — Telegram "Deploy stage OK — SHA — http://..."
Если упало — Telegram "Deploy stage FAILED — ссылка на лог"
```
Ты видишь уведомление в Telegram, открываешь stage, проверяешь, говоришь «мёрджим в prod» или «откатывай».

View file

@ -0,0 +1,61 @@
using foodmarket.Infrastructure.Integrations.MoySklad;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace foodmarket.Api.Controllers.Admin;
[ApiController]
[Authorize(Policy = "AdminAccess")]
[Route("api/admin/moysklad")]
public class MoySkladImportController : ControllerBase
{
private readonly MoySkladImportService _svc;
public MoySkladImportController(MoySkladImportService svc) => _svc = svc;
public record TestRequest(string Token);
public record ImportRequest(string Token, bool OverwriteExisting = false);
[HttpPost("test")]
public async Task<IActionResult> TestConnection([FromBody] TestRequest req, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(req.Token))
return BadRequest(new { error = "Token is required." });
var result = await _svc.TestConnectionAsync(req.Token, ct);
if (!result.Success)
{
var msg = result.StatusCode switch
{
401 or 403 => "Токен недействителен или не имеет доступа к API.",
503 or 502 => "МойСклад временно недоступен. Повтори через минуту.",
_ => $"МойСклад вернул {result.StatusCode}: {Truncate(result.Error, 400)}",
};
return StatusCode(result.StatusCode ?? 502, new { error = msg });
}
return Ok(new { organization = result.Value!.Name, inn = result.Value.Inn });
}
private static string? Truncate(string? s, int max)
=> string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max] + "…");
[HttpPost("import-products")]
public async Task<ActionResult<MoySkladImportResult>> ImportProducts([FromBody] ImportRequest req, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(req.Token))
return BadRequest(new { error = "Token is required." });
var result = await _svc.ImportProductsAsync(req.Token, req.OverwriteExisting, ct);
return result;
}
[HttpPost("import-counterparties")]
public async Task<ActionResult<MoySkladImportResult>> ImportCounterparties([FromBody] ImportRequest req, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(req.Token))
return BadRequest(new { error = "Token is required." });
var result = await _svc.ImportCounterpartiesAsync(req.Token, req.OverwriteExisting, ct);
return result;
}
}

View file

@ -0,0 +1,104 @@
using foodmarket.Application.Common;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Inventory;
[ApiController]
[Authorize]
[Route("api/inventory")]
public class StockController : ControllerBase
{
private readonly AppDbContext _db;
public StockController(AppDbContext db) => _db = db;
public record StockRow(
Guid ProductId, string ProductName, string? Article, string UnitSymbol,
Guid StoreId, string StoreName,
decimal Quantity, decimal ReservedQuantity, decimal Available);
[HttpGet("stock")]
public async Task<ActionResult<PagedResult<StockRow>>> GetStock(
[FromQuery] Guid? storeId,
[FromQuery] Guid? productId,
[FromQuery] string? search,
[FromQuery] bool includeZero = false,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
CancellationToken ct = default)
{
var q = from s in _db.Stocks
join p in _db.Products on s.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
join st in _db.Stores on s.StoreId equals st.Id
select new { s, p, u, st };
if (storeId.HasValue) q = q.Where(x => x.s.StoreId == storeId.Value);
if (productId.HasValue) q = q.Where(x => x.s.ProductId == productId.Value);
if (!includeZero) q = q.Where(x => x.s.Quantity != 0);
if (!string.IsNullOrWhiteSpace(search))
{
var term = $"%{search.Trim()}%";
q = q.Where(x => EF.Functions.ILike(x.p.Name, term)
|| (x.p.Article != null && EF.Functions.ILike(x.p.Article, term)));
}
var total = await q.CountAsync(ct);
var items = await q
.OrderBy(x => x.p.Name)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new StockRow(
x.p.Id, x.p.Name, x.p.Article, x.u.Symbol,
x.st.Id, x.st.Name,
x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
.ToListAsync(ct);
return new PagedResult<StockRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
}
public record MovementRow(
Guid Id, DateTime OccurredAt,
Guid ProductId, string ProductName, string? Article,
Guid StoreId, string StoreName,
decimal Quantity, decimal? UnitCost,
string Type, string DocumentType, Guid? DocumentId, string? DocumentNumber,
string? Notes);
[HttpGet("movements")]
public async Task<ActionResult<PagedResult<MovementRow>>> GetMovements(
[FromQuery] Guid? storeId,
[FromQuery] Guid? productId,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
CancellationToken ct = default)
{
var q = from m in _db.StockMovements
join p in _db.Products on m.ProductId equals p.Id
join st in _db.Stores on m.StoreId equals st.Id
select new { m, p, st };
if (storeId.HasValue) q = q.Where(x => x.m.StoreId == storeId.Value);
if (productId.HasValue) q = q.Where(x => x.m.ProductId == productId.Value);
if (from.HasValue) q = q.Where(x => x.m.OccurredAt >= from.Value);
if (to.HasValue) q = q.Where(x => x.m.OccurredAt < to.Value);
var total = await q.CountAsync(ct);
var items = await q
.OrderByDescending(x => x.m.OccurredAt)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new MovementRow(
x.m.Id, x.m.OccurredAt,
x.p.Id, x.p.Name, x.p.Article,
x.st.Id, x.st.Name,
x.m.Quantity, x.m.UnitCost,
x.m.Type.ToString(), x.m.DocumentType, x.m.DocumentId, x.m.DocumentNumber,
x.m.Notes))
.ToListAsync(ct);
return new PagedResult<MovementRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
}
}

View file

@ -0,0 +1,293 @@
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Purchases;
[ApiController]
[Authorize]
[Route("api/purchases/supplies")]
public class SuppliesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public SuppliesController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record SupplyListRow(
Guid Id, string Number, DateTime Date, SupplyStatus Status,
Guid SupplierId, string SupplierName,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
decimal Total, int LineCount,
DateTime? PostedAt);
public record SupplyLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
string? UnitSymbol,
decimal Quantity, decimal UnitPrice, decimal LineTotal, int SortOrder);
public record SupplyDto(
Guid Id, string Number, DateTime Date, SupplyStatus Status,
Guid SupplierId, string SupplierName,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate,
string? Notes,
decimal Total, DateTime? PostedAt,
IReadOnlyList<SupplyLineDto> Lines);
public record SupplyLineInput(Guid ProductId, decimal Quantity, decimal UnitPrice);
public record SupplyInput(
DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId,
string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate,
string? Notes,
IReadOnlyList<SupplyLineInput> Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<SupplyListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] SupplyStatus? status,
[FromQuery] Guid? storeId,
[FromQuery] Guid? supplierId,
CancellationToken ct)
{
var q = from s in _db.Supplies.AsNoTracking()
join cp in _db.Counterparties on s.SupplierId equals cp.Id
join st in _db.Stores on s.StoreId equals st.Id
join cu in _db.Currencies on s.CurrencyId equals cu.Id
select new { s, cp, st, cu };
if (status is not null) q = q.Where(x => x.s.Status == status);
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
if (supplierId is not null) q = q.Where(x => x.s.SupplierId == supplierId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.s.Number.ToLower().Contains(s) || x.cp.Name.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
var items = await q
.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number)
.Skip(req.Skip).Take(req.Take)
.Select(x => new SupplyListRow(
x.s.Id, x.s.Number, x.s.Date, x.s.Status,
x.cp.Id, x.cp.Name,
x.st.Id, x.st.Name,
x.cu.Id, x.cu.Code,
x.s.Total,
x.s.Lines.Count,
x.s.PostedAt))
.ToListAsync(ct);
return new PagedResult<SupplyListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
{
var number = await GenerateNumberAsync(input.Date, ct);
var supply = new Supply
{
Number = number,
Date = input.Date,
Status = SupplyStatus.Draft,
SupplierId = input.SupplierId,
StoreId = input.StoreId,
CurrencyId = input.CurrencyId,
SupplierInvoiceNumber = input.SupplierInvoiceNumber,
SupplierInvoiceDate = input.SupplierInvoiceDate,
Notes = input.Notes,
};
var order = 0;
foreach (var l in input.Lines)
{
supply.Lines.Add(new SupplyLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice,
LineTotal = l.Quantity * l.UnitPrice,
SortOrder = order++,
});
}
supply.Total = supply.Lines.Sum(x => x.LineTotal);
_db.Supplies.Add(supply);
await _db.SaveChangesAsync(ct);
var dto = await GetInternal(supply.Id, ct);
return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto);
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct)
{
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (supply is null) return NotFound();
if (supply.Status != SupplyStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
supply.Date = input.Date;
supply.SupplierId = input.SupplierId;
supply.StoreId = input.StoreId;
supply.CurrencyId = input.CurrencyId;
supply.SupplierInvoiceNumber = input.SupplierInvoiceNumber;
supply.SupplierInvoiceDate = input.SupplierInvoiceDate;
supply.Notes = input.Notes;
// Replace lines wholesale (simple, idempotent).
_db.SupplyLines.RemoveRange(supply.Lines);
supply.Lines.Clear();
var order = 0;
foreach (var l in input.Lines)
{
supply.Lines.Add(new SupplyLine
{
SupplyId = supply.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice,
LineTotal = l.Quantity * l.UnitPrice,
SortOrder = order++,
});
}
supply.Total = supply.Lines.Sum(x => x.LineTotal);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var supply = await _db.Supplies.FirstOrDefaultAsync(s => s.Id == id, ct);
if (supply is null) return NotFound();
if (supply.Status != SupplyStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.Supplies.Remove(supply);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (supply is null) return NotFound();
if (supply.Status == SupplyStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (supply.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
foreach (var line in supply.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: supply.StoreId,
Quantity: line.Quantity,
Type: MovementType.Supply,
DocumentType: "supply",
DocumentId: supply.Id,
DocumentNumber: supply.Number,
UnitCost: line.UnitPrice,
OccurredAt: supply.Date), ct);
}
supply.Status = SupplyStatus.Posted;
supply.PostedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (supply is null) return NotFound();
if (supply.Status != SupplyStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse: negative movements with same document reference
foreach (var line in supply.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: supply.StoreId,
Quantity: -line.Quantity,
Type: MovementType.Supply,
DocumentType: "supply-reversal",
DocumentId: supply.Id,
DocumentNumber: supply.Number,
UnitCost: line.UnitPrice,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена проведения документа {supply.Number}"), ct);
}
supply.Status = SupplyStatus.Draft;
supply.PostedAt = null;
await _db.SaveChangesAsync(ct);
return NoContent();
}
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var year = date.Year;
var prefix = $"П-{year}-";
var lastNumber = await _db.Supplies
.Where(s => s.Number.StartsWith(prefix))
.OrderByDescending(s => s.Number)
.Select(s => s.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
seq = last + 1;
return $"{prefix}{seq:D6}";
}
private async Task<SupplyDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from s in _db.Supplies.AsNoTracking()
join cp in _db.Counterparties on s.SupplierId equals cp.Id
join st in _db.Stores on s.StoreId equals st.Id
join cu in _db.Currencies on s.CurrencyId equals cu.Id
where s.Id == id
select new { s, cp, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null;
var lines = await (from l in _db.SupplyLines.AsNoTracking()
join p in _db.Products on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
where l.SupplyId == id
orderby l.SortOrder
select new SupplyLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Symbol,
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder))
.ToListAsync(ct);
return new SupplyDto(
row.s.Id, row.s.Number, row.s.Date, row.s.Status,
row.cp.Id, row.cp.Name,
row.st.Id, row.st.Name,
row.cu.Id, row.cu.Code,
row.s.SupplierInvoiceNumber, row.s.SupplierInvoiceDate,
row.s.Notes,
row.s.Total, row.s.PostedAt,
lines);
}
}

View file

@ -0,0 +1,370 @@
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Sales;
[ApiController]
[Authorize]
[Route("api/sales/retail")]
public class RetailSalesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public RetailSalesController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record RetailSaleListRow(
Guid Id, string Number, DateTime Date, RetailSaleStatus Status,
Guid StoreId, string StoreName,
Guid? RetailPointId, string? RetailPointName,
Guid? CustomerId, string? CustomerName,
Guid CurrencyId, string CurrencyCode,
decimal Total, PaymentMethod Payment, int LineCount,
DateTime? PostedAt);
public record RetailSaleLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
decimal Quantity, decimal UnitPrice, decimal Discount, decimal LineTotal, decimal VatPercent, int SortOrder);
public record RetailSaleDto(
Guid Id, string Number, DateTime Date, RetailSaleStatus Status,
Guid StoreId, string StoreName,
Guid? RetailPointId, string? RetailPointName,
Guid? CustomerId, string? CustomerName,
Guid CurrencyId, string CurrencyCode,
decimal Subtotal, decimal DiscountTotal, decimal Total,
PaymentMethod Payment, decimal PaidCash, decimal PaidCard,
string? Notes, DateTime? PostedAt,
IReadOnlyList<RetailSaleLineDto> Lines);
public record RetailSaleLineInput(Guid ProductId, decimal Quantity, decimal UnitPrice, decimal Discount, decimal VatPercent);
public record RetailSaleInput(
DateTime Date, Guid StoreId, Guid? RetailPointId, Guid? CustomerId, Guid CurrencyId,
PaymentMethod Payment, decimal PaidCash, decimal PaidCard,
string? Notes,
IReadOnlyList<RetailSaleLineInput> Lines);
public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions);
public record SalesStatsResponse(
decimal RevenueToday,
decimal RevenueThisMonth,
decimal RevenuePrevMonth,
int TransactionsToday,
int TransactionsThisMonth,
decimal AvgTicketThisMonth,
IReadOnlyList<SalesStatsBucket> Series);
/// <summary>Aggregated sales metrics + daily series for the dashboard.
/// Series buckets are days; defaults to last 30 days.</summary>
[HttpGet("stats")]
public async Task<ActionResult<SalesStatsResponse>> Stats(
[FromQuery] int days = 30,
CancellationToken ct = default)
{
var nowUtc = DateTime.UtcNow;
var todayStart = new DateTime(nowUtc.Year, nowUtc.Month, nowUtc.Day, 0, 0, 0, DateTimeKind.Utc);
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var prevMonthStart = monthStart.AddMonths(-1);
var seriesStart = todayStart.AddDays(-(days - 1));
var posted = _db.RetailSales.AsNoTracking().Where(s => s.Status == RetailSaleStatus.Posted);
var today = await posted.Where(s => s.Date >= todayStart && s.Date < todayStart.AddDays(1))
.GroupBy(_ => 1)
.Select(g => new { Sum = g.Sum(s => s.Total), Count = g.Count() })
.FirstOrDefaultAsync(ct);
var thisMonth = await posted.Where(s => s.Date >= monthStart)
.GroupBy(_ => 1)
.Select(g => new { Sum = g.Sum(s => s.Total), Count = g.Count() })
.FirstOrDefaultAsync(ct);
var prevMonth = await posted.Where(s => s.Date >= prevMonthStart && s.Date < monthStart)
.GroupBy(_ => 1)
.Select(g => new { Sum = g.Sum(s => s.Total) })
.FirstOrDefaultAsync(ct);
var rawSeries = await posted.Where(s => s.Date >= seriesStart)
.GroupBy(s => s.Date.Date)
.Select(g => new { Day = g.Key, Revenue = g.Sum(s => s.Total), Tx = g.Count() })
.ToListAsync(ct);
// Fill missing days with zeros so the chart line is continuous.
var byDay = rawSeries.ToDictionary(x => x.Day, x => x);
var series = Enumerable.Range(0, days)
.Select(i => seriesStart.AddDays(i).Date)
.Select(d => byDay.TryGetValue(d, out var v)
? new SalesStatsBucket(d, v.Revenue, v.Tx)
: new SalesStatsBucket(d, 0m, 0))
.ToList();
var thisMonthSum = thisMonth?.Sum ?? 0m;
var thisMonthCount = thisMonth?.Count ?? 0;
var avgTicket = thisMonthCount == 0 ? 0m : thisMonthSum / thisMonthCount;
return new SalesStatsResponse(
RevenueToday: today?.Sum ?? 0m,
RevenueThisMonth: thisMonthSum,
RevenuePrevMonth: prevMonth?.Sum ?? 0m,
TransactionsToday: today?.Count ?? 0,
TransactionsThisMonth: thisMonthCount,
AvgTicketThisMonth: avgTicket,
Series: series);
}
[HttpGet]
public async Task<ActionResult<PagedResult<RetailSaleListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] RetailSaleStatus? status,
[FromQuery] Guid? storeId,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
CancellationToken ct)
{
var q = from s in _db.RetailSales.AsNoTracking()
join st in _db.Stores on s.StoreId equals st.Id
join cu in _db.Currencies on s.CurrencyId equals cu.Id
select new { s, st, cu };
if (status is not null) q = q.Where(x => x.s.Status == status);
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
if (from is not null) q = q.Where(x => x.s.Date >= from);
if (to is not null) q = q.Where(x => x.s.Date < to);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.s.Number.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
var items = await q
.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number)
.Skip(req.Skip).Take(req.Take)
.Select(x => new RetailSaleListRow(
x.s.Id, x.s.Number, x.s.Date, x.s.Status,
x.st.Id, x.st.Name,
x.s.RetailPointId,
x.s.RetailPointId == null ? null : _db.RetailPoints.Where(rp => rp.Id == x.s.RetailPointId).Select(rp => rp.Name).FirstOrDefault(),
x.s.CustomerId,
x.s.CustomerId == null ? null : _db.Counterparties.Where(c => c.Id == x.s.CustomerId).Select(c => c.Name).FirstOrDefault(),
x.cu.Id, x.cu.Code,
x.s.Total, x.s.Payment,
x.s.Lines.Count,
x.s.PostedAt))
.ToListAsync(ct);
return new PagedResult<RetailSaleListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<RetailSaleDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, Authorize(Roles = "Admin,Manager,Cashier")]
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
{
var number = await GenerateNumberAsync(input.Date, ct);
var sale = new RetailSale
{
Number = number,
Date = input.Date,
Status = RetailSaleStatus.Draft,
StoreId = input.StoreId,
RetailPointId = input.RetailPointId,
CustomerId = input.CustomerId,
CurrencyId = input.CurrencyId,
Payment = input.Payment,
PaidCash = input.PaidCash,
PaidCard = input.PaidCard,
Notes = input.Notes,
};
ApplyLines(sale, input.Lines);
_db.RetailSales.Add(sale);
await _db.SaveChangesAsync(ct);
var dto = await GetInternal(sale.Id, ct);
return CreatedAtAction(nameof(Get), new { id = sale.Id }, dto);
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Cashier")]
public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct)
{
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (sale is null) return NotFound();
if (sale.Status != RetailSaleStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён." });
sale.Date = input.Date;
sale.StoreId = input.StoreId;
sale.RetailPointId = input.RetailPointId;
sale.CustomerId = input.CustomerId;
sale.CurrencyId = input.CurrencyId;
sale.Payment = input.Payment;
sale.PaidCash = input.PaidCash;
sale.PaidCard = input.PaidCard;
sale.Notes = input.Notes;
_db.RetailSaleLines.RemoveRange(sale.Lines);
sale.Lines.Clear();
ApplyLines(sale, input.Lines);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var sale = await _db.RetailSales.FirstOrDefaultAsync(s => s.Id == id, ct);
if (sale is null) return NotFound();
if (sale.Status != RetailSaleStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый чек." });
_db.RetailSales.Remove(sale);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Manager,Cashier")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (sale is null) return NotFound();
if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." });
if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." });
foreach (var line in sale.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: sale.StoreId,
Quantity: -line.Quantity, // negative: товар уходит со склада
Type: MovementType.RetailSale,
DocumentType: "retail-sale",
DocumentId: sale.Id,
DocumentNumber: sale.Number,
UnitCost: line.UnitPrice,
OccurredAt: sale.Date), ct);
}
sale.Status = RetailSaleStatus.Posted;
sale.PostedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (sale is null) return NotFound();
if (sale.Status != RetailSaleStatus.Posted) return Conflict(new { error = "Чек не проведён." });
foreach (var line in sale.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: sale.StoreId,
Quantity: +line.Quantity, // reverse — return stock
Type: MovementType.RetailSale,
DocumentType: "retail-sale-reversal",
DocumentId: sale.Id,
DocumentNumber: sale.Number,
UnitCost: line.UnitPrice,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена чека {sale.Number}"), ct);
}
sale.Status = RetailSaleStatus.Draft;
sale.PostedAt = null;
await _db.SaveChangesAsync(ct);
return NoContent();
}
private static void ApplyLines(RetailSale sale, IReadOnlyList<RetailSaleLineInput> input)
{
var order = 0;
decimal subtotal = 0, discountTotal = 0;
foreach (var l in input)
{
var lineTotal = l.Quantity * l.UnitPrice - l.Discount;
sale.Lines.Add(new RetailSaleLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice,
Discount = l.Discount,
LineTotal = lineTotal,
VatPercent = l.VatPercent,
SortOrder = order++,
});
subtotal += l.Quantity * l.UnitPrice;
discountTotal += l.Discount;
}
sale.Subtotal = subtotal;
sale.DiscountTotal = discountTotal;
sale.Total = subtotal - discountTotal;
}
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var prefix = $"ПР-{date.Year}-";
var lastNumber = await _db.RetailSales
.Where(s => s.Number.StartsWith(prefix))
.OrderByDescending(s => s.Number)
.Select(s => s.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
seq = last + 1;
return $"{prefix}{seq:D6}";
}
private async Task<RetailSaleDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from s in _db.RetailSales.AsNoTracking()
join st in _db.Stores on s.StoreId equals st.Id
join cu in _db.Currencies on s.CurrencyId equals cu.Id
where s.Id == id
select new { s, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null;
string? rpName = row.s.RetailPointId is null ? null
: await _db.RetailPoints.Where(r => r.Id == row.s.RetailPointId).Select(r => r.Name).FirstOrDefaultAsync(ct);
string? cName = row.s.CustomerId is null ? null
: await _db.Counterparties.Where(c => c.Id == row.s.CustomerId).Select(c => c.Name).FirstOrDefaultAsync(ct);
var lines = await (from l in _db.RetailSaleLines.AsNoTracking()
join p in _db.Products on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
where l.RetailSaleId == id
orderby l.SortOrder
select new RetailSaleLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Symbol,
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder))
.ToListAsync(ct);
return new RetailSaleDto(
row.s.Id, row.s.Number, row.s.Date, row.s.Status,
row.st.Id, row.st.Name,
row.s.RetailPointId, rpName,
row.s.CustomerId, cName,
row.cu.Id, row.cu.Code,
row.s.Subtotal, row.s.DiscountTotal, row.s.Total,
row.s.Payment, row.s.PaidCash, row.s.PaidCard,
row.s.Notes, row.s.PostedAt,
lines);
}
}

View file

@ -66,9 +66,25 @@
opts.AcceptAnonymousClients();
opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api");
// Persistent dev keys: RSA key stored in src/food-market.api/App_Data/openiddict-dev-key.xml.
// Survives API restarts so issued tokens remain valid across rebuilds.
// Never commit this file (it's in .gitignore via App_Data/). Production must use real certificates.
var keyPath = Path.Combine(builder.Environment.ContentRootPath, "App_Data", "openiddict-dev-key.xml");
var rsa = System.Security.Cryptography.RSA.Create(2048);
if (File.Exists(keyPath))
{
rsa.FromXmlString(File.ReadAllText(keyPath));
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(keyPath)!);
File.WriteAllText(keyPath, rsa.ToXmlString(includePrivateParameters: true));
}
var devKey = new Microsoft.IdentityModel.Tokens.RsaSecurityKey(rsa) { KeyId = "food-market-dev" };
opts.AddEncryptionKey(devKey);
opts.AddSigningKey(devKey);
if (builder.Environment.IsDevelopment())
{
opts.AddEphemeralEncryptionKey().AddEphemeralSigningKey();
opts.DisableAccessTokenEncryption();
}
@ -87,17 +103,44 @@
opts.UseAspNetCore();
});
builder.Services.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
builder.Services.AddAuthorization();
// Force OpenIddict validation to handle ALL [Authorize] challenges — otherwise AddIdentity's
// cookie scheme takes over and 401 becomes a 302 redirect to /Account/Login for API calls.
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});
builder.Services.AddAuthorization(opts =>
{
// Check the "role" claim explicitly — robust against role-claim-type mismatches between
// OpenIddict validation identity and the default ClaimTypes.Role uri.
opts.AddPolicy("AdminAccess", p => p.RequireAssertion(ctx =>
ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin"))));
});
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// MoySklad import integration. Auto-decompress gzip responses from MoySklad's edge.
builder.Services.AddHttpClient<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladClient>()
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
});
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
// Inventory
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
builder.Services.AddHostedService<OpenIddictClientSeeder>();
builder.Services.AddHostedService<SystemReferenceSeeder>();
builder.Services.AddHostedService<DevDataSeeder>();
builder.Services.AddHostedService<DemoCatalogSeeder>();
// DemoCatalogSeeder disabled: real catalog is imported from MoySklad.
// Keep the file as reference for anyone starting without MoySklad access —
// just re-register here to turn demo data back on.
// builder.Services.AddHostedService<DemoCatalogSeeder>();
var app = builder.Build();
@ -116,6 +159,21 @@
app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow }));
app.MapGet("/api/_debug/whoami", (HttpContext ctx) =>
{
var identity = ctx.User.Identity as System.Security.Claims.ClaimsIdentity;
return Results.Ok(new
{
isAuthenticated = ctx.User.Identity?.IsAuthenticated,
authType = ctx.User.Identity?.AuthenticationType,
nameClaimType = identity?.NameClaimType,
roleClaimType = identity?.RoleClaimType,
isInRoleAdmin = ctx.User.IsInRole("Admin"),
hasAdminRoleClaim = ctx.User.HasClaim(c => c.Type == Claims.Role && c.Value == "Admin"),
claims = ctx.User.Claims.Select(c => new { c.Type, c.Value }),
});
}).RequireAuthorization();
app.MapGet("/api/me", (HttpContext ctx) =>
{
var user = ctx.User;
@ -129,9 +187,10 @@
});
}).RequireAuthorization();
if (app.Environment.IsDevelopment())
// Apply migrations on every startup (idempotent). Without this, fresh
// stage/prod deploys land on an empty DB and OpenIddict seeders fail.
using (var scope = app.Services.CreateScope())
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.Migrate();
}

View file

@ -20,10 +20,10 @@ public DevDataSeeder(IServiceProvider services, IHostEnvironment env)
public async Task StartAsync(CancellationToken ct)
{
if (!_env.IsDevelopment())
{
return;
}
// Idempotent — runs in all envs to bootstrap a usable admin + demo org.
// Once first real user/org is set up via UI, rename/disable demo.
// (Wired regardless of env so stage/prod first-deploy lands a working
// admin, otherwise nobody can log in.)
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

View file

@ -0,0 +1,27 @@
using foodmarket.Domain.Inventory;
namespace foodmarket.Application.Inventory;
public record StockMovementDraft(
Guid ProductId,
Guid StoreId,
decimal Quantity,
MovementType Type,
string DocumentType,
Guid? DocumentId = null,
string? DocumentNumber = null,
decimal? UnitCost = null,
DateTime? OccurredAt = null,
Guid? CreatedByUserId = null,
string? Notes = null);
public interface IStockService
{
/// <summary>Writes the movement + updates the materialized Stock row in a single unit of work.
/// Returns the new on-hand quantity. Callers must commit the DbContext themselves (we don't
/// wrap in a transaction — typical flow is as part of a document posting that already bundles
/// multiple movements into one SaveChanges).</summary>
Task<decimal> ApplyMovementAsync(StockMovementDraft draft, CancellationToken ct = default);
Task<decimal> ApplyMovementsAsync(IEnumerable<StockMovementDraft> drafts, CancellationToken ct = default);
}

View file

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

View file

@ -0,0 +1,22 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Inventory;
// Materialized current-stock aggregate per (Product, Store). Kept in sync with StockMovement
// inserts by IStockService — never write to this entity directly.
public class Stock : TenantEntity
{
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
public decimal Quantity { get; set; }
public decimal ReservedQuantity { get; set; }
/// <summary>Available = on-hand reserved. Cannot be negative in normal flow; a negative
/// value indicates the business allowed overselling (e.g., retail sale before physical receipt).</summary>
public decimal Available => Quantity - ReservedQuantity;
}

View file

@ -0,0 +1,49 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Inventory;
// Immutable, append-only journal of every stock change.
// Stock table is a materialized aggregate over this journal.
public class StockMovement : TenantEntity
{
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
/// <summary>Signed quantity: positive = receipt, negative = issue.</summary>
public decimal Quantity { get; set; }
/// <summary>Per-unit cost at the time of movement (optional). Used for cost rollup / P&amp;L.</summary>
public decimal? UnitCost { get; set; }
public MovementType Type { get; set; }
/// <summary>Source document discriminator, e.g. "supply", "retail-sale", "write-off", "enter", "transfer-out".</summary>
public string DocumentType { get; set; } = "";
public Guid? DocumentId { get; set; }
public string? DocumentNumber { get; set; }
public DateTime OccurredAt { get; set; } = DateTime.UtcNow;
public Guid? CreatedByUserId { get; set; }
public string? Notes { get; set; }
}
public enum MovementType
{
Initial = 0,
Supply = 1, // приёмка от поставщика
RetailSale = 2, // розничная продажа
WholesaleSale = 3, // оптовая отгрузка
CustomerReturn = 4, // возврат покупателя
SupplierReturn = 5, // возврат поставщику
TransferOut = 6, // перемещение со склада
TransferIn = 7, // перемещение на склад
WriteOff = 8, // списание
Enter = 9, // оприходование
InventoryAdjustment = 10, // корректировка по результату инвентаризации
}

View file

@ -0,0 +1,55 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Purchases;
public enum SupplyStatus
{
Draft = 0,
Posted = 1,
}
public class Supply : TenantEntity
{
/// <summary>Human-readable document number, unique per organization (e.g. "П-2026-000001").</summary>
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public SupplyStatus Status { get; set; } = SupplyStatus.Draft;
public Guid SupplierId { get; set; }
public Counterparty Supplier { get; set; } = null!;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
public Guid CurrencyId { get; set; }
public Currency Currency { get; set; } = null!;
public string? SupplierInvoiceNumber { get; set; }
public DateTime? SupplierInvoiceDate { get; set; }
public string? Notes { get; set; }
/// <summary>Sum of line totals. Computed on save.</summary>
public decimal Total { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
public ICollection<SupplyLine> Lines { get; set; } = new List<SupplyLine>();
}
public class SupplyLine : TenantEntity
{
public Guid SupplyId { get; set; }
public Supply Supply { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal LineTotal { get; set; }
public int SortOrder { get; set; }
}

View file

@ -0,0 +1,70 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Sales;
public enum RetailSaleStatus
{
Draft = 0,
Posted = 1,
}
public enum PaymentMethod
{
Cash = 0,
Card = 1,
BankTransfer = 2,
Bonus = 3,
Mixed = 99,
}
public class RetailSale : TenantEntity
{
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public RetailSaleStatus Status { get; set; } = RetailSaleStatus.Draft;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
public Guid? RetailPointId { get; set; }
public RetailPoint? RetailPoint { get; set; }
public Guid? CustomerId { get; set; }
public Counterparty? Customer { get; set; }
public Guid? CashierUserId { get; set; }
public Guid CurrencyId { get; set; }
public Currency Currency { get; set; } = null!;
public decimal Subtotal { get; set; } // sum of LineTotal before discount
public decimal DiscountTotal { get; set; }
public decimal Total { get; set; } // = Subtotal - DiscountTotal
public PaymentMethod Payment { get; set; } = PaymentMethod.Cash;
public decimal PaidCash { get; set; }
public decimal PaidCard { get; set; }
public string? Notes { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
public ICollection<RetailSaleLine> Lines { get; set; } = new List<RetailSaleLine>();
}
public class RetailSaleLine : TenantEntity
{
public Guid RetailSaleId { get; set; }
public RetailSale RetailSale { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal Discount { get; set; }
public decimal LineTotal { get; set; } // = Quantity * UnitPrice - Discount
public decimal VatPercent { get; set; } // snapshot
public int SortOrder { get; set; }
}

View file

@ -0,0 +1,122 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace foodmarket.Infrastructure.Integrations.MoySklad;
public record MoySkladApiResult<T>(bool Success, T? Value, int? StatusCode, string? Error)
{
public static MoySkladApiResult<T> Ok(T value) => new(true, value, 200, null);
public static MoySkladApiResult<T> Fail(int status, string? error) => new(false, default, status, error);
}
// Thin HTTP wrapper over MoySklad JSON-API 1.2. Caller supplies the personal token per request
// — we never persist it.
public class MoySkladClient
{
// Trailing slash is critical: otherwise HttpClient drops the last path segment
// when resolving relative URIs (RFC 3986 §5.3), so "entity/product" would hit
// "/api/remap/entity/product" instead of "/api/remap/1.2/entity/product".
private const string BaseUrl = "https://api.moysklad.ru/api/remap/1.2/";
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private readonly HttpClient _http;
public MoySkladClient(HttpClient http)
{
_http = http;
_http.BaseAddress ??= new Uri(BaseUrl);
_http.Timeout = TimeSpan.FromSeconds(90);
}
private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string token)
{
var req = new HttpRequestMessage(method, pathAndQuery);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
// MoySklad requires the exact literal "application/json;charset=utf-8" (no space
// after ';'). The typed MediaTypeWithQualityHeaderValue API normalizes to
// "application/json; charset=utf-8" which MoySklad rejects with code 1062.
req.Headers.TryAddWithoutValidation("Accept", "application/json;charset=utf-8");
// MoySklad's nginx edge returns 415 for requests without a User-Agent, and we want
// auto-decompression (Accept-Encoding is added automatically by HttpClient when
// AutomaticDecompression is set on the primary handler — see Program.cs).
if (!req.Headers.UserAgent.Any())
{
req.Headers.TryAddWithoutValidation("User-Agent", "food-market/0.1 (+https://github.com/nurdotnet/food-market)");
}
return req;
}
public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, CancellationToken ct)
{
using var req = Build(HttpMethod.Get, "entity/organization?limit=1", token);
using var res = await _http.SendAsync(req, ct);
if (!res.IsSuccessStatusCode)
{
var body = await res.Content.ReadAsStringAsync(ct);
return MoySkladApiResult<MsOrganization>.Fail((int)res.StatusCode, body);
}
var list = await res.Content.ReadFromJsonAsync<MsListResponse<MsOrganization>>(Json, ct);
var org = list?.Rows.FirstOrDefault();
return org is null
? MoySkladApiResult<MsOrganization>.Fail(200, "Empty organization list returned by MoySklad.")
: MoySkladApiResult<MsOrganization>.Ok(org);
}
public async IAsyncEnumerable<MsProduct> StreamProductsAsync(
string token,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
const int pageSize = 1000;
var offset = 0;
while (true)
{
using var req = Build(HttpMethod.Get, $"entity/product?limit={pageSize}&offset={offset}", token);
using var res = await _http.SendAsync(req, ct);
res.EnsureSuccessStatusCode();
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsProduct>>(Json, ct);
if (page is null || page.Rows.Count == 0) yield break;
foreach (var p in page.Rows) yield return p;
if (page.Rows.Count < pageSize) yield break;
offset += pageSize;
}
}
public async IAsyncEnumerable<MsCounterparty> StreamCounterpartiesAsync(
string token,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
const int pageSize = 1000;
var offset = 0;
while (true)
{
using var req = Build(HttpMethod.Get, $"entity/counterparty?limit={pageSize}&offset={offset}", token);
using var res = await _http.SendAsync(req, ct);
res.EnsureSuccessStatusCode();
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsCounterparty>>(Json, ct);
if (page is null || page.Rows.Count == 0) yield break;
foreach (var c in page.Rows) yield return c;
if (page.Rows.Count < pageSize) yield break;
offset += pageSize;
}
}
public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct)
{
var all = new List<MsProductFolder>();
var offset = 0;
const int pageSize = 1000;
while (true)
{
using var req = Build(HttpMethod.Get, $"entity/productfolder?limit={pageSize}&offset={offset}", token);
using var res = await _http.SendAsync(req, ct);
res.EnsureSuccessStatusCode();
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsProductFolder>>(Json, ct);
if (page is null || page.Rows.Count == 0) break;
all.AddRange(page.Rows);
if (page.Rows.Count < pageSize) break;
offset += pageSize;
}
return all;
}
}

View file

@ -0,0 +1,145 @@
using System.Text.Json.Serialization;
namespace foodmarket.Infrastructure.Integrations.MoySklad;
// Minimal MoySklad JSON-API response shapes we need for read-only product import.
// See https://dev.moysklad.ru/doc/api/remap/1.2/ — we intentionally ignore fields we don't consume.
public class MsListResponse<T>
{
[JsonPropertyName("meta")] public MsListMeta? Meta { get; set; }
[JsonPropertyName("rows")] public List<T> Rows { get; set; } = [];
}
public class MsListMeta
{
[JsonPropertyName("size")] public int Size { get; set; }
[JsonPropertyName("limit")] public int Limit { get; set; }
[JsonPropertyName("offset")] public int Offset { get; set; }
[JsonPropertyName("href")] public string? Href { get; set; }
}
public class MsMeta
{
[JsonPropertyName("href")] public string? Href { get; set; }
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("uuidHref")] public string? UuidHref { get; set; }
}
public class MsMetaWrapper
{
[JsonPropertyName("meta")] public MsMeta? Meta { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("code")] public string? Code { get; set; }
}
public class MsOrganization
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("inn")] public string? Inn { get; set; }
[JsonPropertyName("kpp")] public string? Kpp { get; set; }
}
public class MsProduct
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("article")] public string? Article { get; set; }
[JsonPropertyName("code")] public string? Code { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("weighed")] public bool Weighed { get; set; }
[JsonPropertyName("vat")] public int? Vat { get; set; }
[JsonPropertyName("vatEnabled")] public bool? VatEnabled { get; set; }
[JsonPropertyName("archived")] public bool Archived { get; set; }
[JsonPropertyName("barcodes")] public List<Dictionary<string, string>>? Barcodes { get; set; }
[JsonPropertyName("salePrices")] public List<MsSalePrice>? SalePrices { get; set; }
[JsonPropertyName("buyPrice")] public MsMoney? BuyPrice { get; set; }
[JsonPropertyName("minPrice")] public MsMoney? MinPrice { get; set; }
[JsonPropertyName("uom")] public MsMetaWrapper? Uom { get; set; }
[JsonPropertyName("productFolder")] public MsMetaWrapper? ProductFolder { get; set; }
[JsonPropertyName("country")] public MsMetaWrapper? Country { get; set; }
[JsonPropertyName("alcoholic")] public MsAlcoholic? Alcoholic { get; set; }
[JsonPropertyName("tnved")] public string? Tnved { get; set; }
[JsonPropertyName("trackingType")] public string? TrackingType { get; set; }
}
public class MsSalePrice
{
[JsonPropertyName("value")] public decimal Value { get; set; } // minor units (копейки/тиын) — MoySklad may return fractional
[JsonPropertyName("currency")] public MsMetaWrapper? Currency { get; set; }
[JsonPropertyName("priceType")] public MsPriceType? PriceType { get; set; }
}
public class MsPriceType
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("externalCode")] public string? ExternalCode { get; set; }
}
public class MsMoney
{
[JsonPropertyName("value")] public decimal Value { get; set; }
[JsonPropertyName("currency")] public MsMetaWrapper? Currency { get; set; }
}
public class MsAlcoholic
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("strength")] public double? Strength { get; set; }
}
public class MsCurrency
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("isoCode")] public string? IsoCode { get; set; }
[JsonPropertyName("rate")] public double? Rate { get; set; }
}
public class MsUom
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("code")] public string? Code { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
}
public class MsProductFolder
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("pathName")] public string? PathName { get; set; }
[JsonPropertyName("productFolder")] public MsMetaWrapper? ParentFolder { get; set; }
[JsonPropertyName("archived")] public bool Archived { get; set; }
}
public class MsCountry
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("code")] public string? Code { get; set; }
[JsonPropertyName("externalCode")] public string? ExternalCode { get; set; }
}
public class MsCounterparty
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("legalTitle")] public string? LegalTitle { get; set; }
[JsonPropertyName("legalAddress")] public string? LegalAddress { get; set; }
[JsonPropertyName("actualAddress")] public string? ActualAddress { get; set; }
[JsonPropertyName("inn")] public string? Inn { get; set; }
[JsonPropertyName("kpp")] public string? Kpp { get; set; }
[JsonPropertyName("ogrn")] public string? Ogrn { get; set; }
[JsonPropertyName("companyType")] public string? CompanyType { get; set; }
[JsonPropertyName("phone")] public string? Phone { get; set; }
[JsonPropertyName("email")] public string? Email { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("archived")] public bool Archived { get; set; }
[JsonPropertyName("tags")] public List<string>? Tags { get; set; }
}

View file

@ -0,0 +1,295 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace foodmarket.Infrastructure.Integrations.MoySklad;
public record MoySkladImportResult(
int Total,
int Created,
int Skipped,
int GroupsCreated,
IReadOnlyList<string> Errors);
public class MoySkladImportService
{
private readonly MoySkladClient _client;
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly ILogger<MoySkladImportService> _log;
public MoySkladImportService(
MoySkladClient client,
AppDbContext db,
ITenantContext tenant,
ILogger<MoySkladImportService> log)
{
_client = client;
_db = db;
_tenant = tenant;
_log = log;
}
public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct)
=> _client.WhoAmIAsync(token, ct);
public async Task<MoySkladImportResult> ImportCounterpartiesAsync(string token, bool overwriteExisting, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще — это
// не наша выдумка, проверено через API: counterparty entity содержит только
// group (группа доступа), tags (произвольные), state (пользовательская цепочка
// статусов), companyType (legal/individual/entrepreneur). Никакой role/kind.
// Поэтому при импорте ВСЕГДА ставим Unspecified — пользователь сам решит.
// Параметр tags оставлен ради совместимости сигнатуры, не используется.
static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList<string>? tags)
=> foodmarket.Domain.Catalog.CounterpartyKind.Unspecified;
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
=> companyType switch
{
"individual" or "entrepreneur" => foodmarket.Domain.Catalog.CounterpartyType.Individual,
_ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity,
};
var existingByName = await _db.Counterparties
.Select(c => new { c.Id, c.Name })
.ToDictionaryAsync(c => c.Name, c => c.Id, StringComparer.OrdinalIgnoreCase, ct);
var created = 0;
var skipped = 0;
var total = 0;
var errors = new List<string>();
var batch = 0;
await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
{
total++;
if (c.Archived) { skipped++; continue; }
if (existingByName.ContainsKey(c.Name) && !overwriteExisting)
{
skipped++;
continue;
}
try
{
var entity = new foodmarket.Domain.Catalog.Counterparty
{
OrganizationId = orgId,
Name = Trim(c.Name, 255) ?? c.Name,
LegalName = Trim(c.LegalTitle, 500),
Kind = ResolveKind(c.Tags),
Type = ResolveType(c.CompanyType),
Bin = Trim(c.Inn, 20),
TaxNumber = Trim(c.Kpp, 20),
Phone = Trim(c.Phone, 50),
Email = Trim(c.Email, 255),
Address = Trim(c.ActualAddress ?? c.LegalAddress, 500),
Notes = Trim(c.Description, 1000),
IsActive = !c.Archived,
};
_db.Counterparties.Add(entity);
existingByName[c.Name] = entity.Id;
created++;
batch++;
if (batch >= 500)
{
await _db.SaveChangesAsync(ct);
batch = 0;
}
}
catch (Exception ex)
{
_log.LogWarning(ex, "Failed to import counterparty {Name}", c.Name);
errors.Add($"{c.Name}: {ex.Message}");
}
}
if (batch > 0) await _db.SaveChangesAsync(ct);
return new MoySkladImportResult(total, created, skipped, 0, errors);
}
public async Task<MoySkladImportResult> ImportProductsAsync(
string token,
bool overwriteExisting,
CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
// Pre-load tenant defaults.
var defaultVat = await _db.VatRates.FirstOrDefaultAsync(v => v.IsDefault, ct)
?? await _db.VatRates.FirstAsync(ct);
var vatByPercent = await _db.VatRates.ToDictionaryAsync(v => (int)v.Percent, v => v.Id, ct);
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.IsBase, ct)
?? await _db.UnitsOfMeasure.FirstAsync(ct);
var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct)
?? await _db.PriceTypes.FirstAsync(ct);
var kzt = await _db.Currencies.FirstAsync(c => c.Code == "KZT", ct);
var countriesByName = await _db.Countries
.IgnoreQueryFilters()
.ToDictionaryAsync(c => c.Name, c => c.Id, ct);
// Import folders first — build flat then link parents.
var folders = await _client.GetAllFoldersAsync(token, ct);
var localGroupByMsId = new Dictionary<string, Guid>();
var groupsCreated = 0;
foreach (var f in folders.Where(f => !f.Archived).OrderBy(f => f.PathName?.Length ?? 0))
{
if (f.Id is null) continue;
var existing = await _db.ProductGroups.FirstOrDefaultAsync(
g => g.Name == f.Name && g.Path == (f.PathName ?? f.Name), ct);
if (existing is not null)
{
localGroupByMsId[f.Id] = existing.Id;
continue;
}
var g = new ProductGroup
{
OrganizationId = orgId,
Name = f.Name,
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
IsActive = true,
};
_db.ProductGroups.Add(g);
localGroupByMsId[f.Id] = g.Id;
groupsCreated++;
}
if (groupsCreated > 0) await _db.SaveChangesAsync(ct);
// Import products
var errors = new List<string>();
var created = 0;
var skipped = 0;
var total = 0;
var existingArticles = await _db.Products
.Where(p => p.Article != null)
.Select(p => p.Article!)
.ToListAsync(ct);
var existingArticleSet = new HashSet<string>(existingArticles, StringComparer.OrdinalIgnoreCase);
var existingBarcodeSet = new HashSet<string>(
await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct));
await foreach (var p in _client.StreamProductsAsync(token, ct))
{
total++;
if (p.Archived) { skipped++; continue; }
var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article;
var primaryBarcode = ExtractBarcodes(p).FirstOrDefault();
var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingArticleSet.Contains(article);
var alreadyByBarcode = primaryBarcode is not null && existingBarcodeSet.Contains(primaryBarcode.Code);
if ((alreadyByArticle || alreadyByBarcode) && !overwriteExisting)
{
skipped++;
continue;
}
try
{
var vatId = p.Vat is int vp && vatByPercent.TryGetValue(vp, out var id) ? id : defaultVat.Id;
Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId
&& localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null;
Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null;
var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true)
?? p.SalePrices?.FirstOrDefault();
var product = new Product
{
OrganizationId = orgId,
Name = Trim(p.Name, 500),
Article = Trim(article, 500),
Description = p.Description,
UnitOfMeasureId = baseUnit.Id,
VatRateId = vatId,
ProductGroupId = groupId,
CountryOfOriginId = countryId,
IsWeighed = p.Weighed,
IsAlcohol = p.Alcoholic is not null,
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
IsActive = !p.Archived,
PurchasePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
PurchaseCurrencyId = kzt.Id,
};
if (retailPrice is not null)
{
product.Prices.Add(new ProductPrice
{
OrganizationId = orgId,
PriceTypeId = retailType.Id,
Amount = retailPrice.Value / 100m,
CurrencyId = kzt.Id,
});
}
foreach (var b in ExtractBarcodes(p))
{
if (existingBarcodeSet.Contains(b.Code)) continue;
product.Barcodes.Add(b);
existingBarcodeSet.Add(b.Code);
}
_db.Products.Add(product);
if (!string.IsNullOrWhiteSpace(article)) existingArticleSet.Add(article);
created++;
// Flush every 500 products to keep change tracker light.
if (created % 500 == 0) await _db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
_log.LogWarning(ex, "Failed to import MoySklad product {Name}", p.Name);
errors.Add($"{p.Name}: {ex.Message}");
}
}
await _db.SaveChangesAsync(ct);
return new MoySkladImportResult(total, created, skipped, groupsCreated, errors);
}
private static List<ProductBarcode> ExtractBarcodes(MsProduct p)
{
if (p.Barcodes is null) return [];
var list = new List<ProductBarcode>();
var primarySet = false;
foreach (var entry in p.Barcodes)
{
foreach (var (kind, code) in entry)
{
if (string.IsNullOrWhiteSpace(code)) continue;
var type = kind switch
{
"ean13" => BarcodeType.Ean13,
"ean8" => BarcodeType.Ean8,
"code128" => BarcodeType.Code128,
"gtin" => BarcodeType.Ean13,
"upca" => BarcodeType.Upca,
"upce" => BarcodeType.Upce,
_ => BarcodeType.Other,
};
list.Add(new ProductBarcode { Code = code.Length > 500 ? code[..500] : code, Type = type, IsPrimary = !primarySet });
primarySet = true;
}
}
return list;
}
private static string? Trim(string? s, int max)
=> string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max]);
private static string? TryExtractId(string href)
{
// href like "https://api.moysklad.ru/api/remap/1.2/entity/productfolder/<guid>"
var lastSlash = href.LastIndexOf('/');
return lastSlash >= 0 ? href[(lastSlash + 1)..] : null;
}
}

View file

@ -0,0 +1,68 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Infrastructure.Inventory;
public class StockService : IStockService
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public StockService(AppDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
public async Task<decimal> ApplyMovementAsync(StockMovementDraft d, CancellationToken ct = default)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("Tenant not set.");
_db.StockMovements.Add(new StockMovement
{
OrganizationId = orgId,
ProductId = d.ProductId,
StoreId = d.StoreId,
Quantity = d.Quantity,
UnitCost = d.UnitCost,
Type = d.Type,
DocumentType = d.DocumentType,
DocumentId = d.DocumentId,
DocumentNumber = d.DocumentNumber,
OccurredAt = d.OccurredAt ?? DateTime.UtcNow,
CreatedByUserId = d.CreatedByUserId,
Notes = d.Notes,
});
var stock = await _db.Stocks.FirstOrDefaultAsync(
s => s.ProductId == d.ProductId && s.StoreId == d.StoreId, ct);
if (stock is null)
{
stock = new Stock
{
OrganizationId = orgId,
ProductId = d.ProductId,
StoreId = d.StoreId,
Quantity = d.Quantity,
};
_db.Stocks.Add(stock);
}
else
{
stock.Quantity += d.Quantity;
}
return stock.Quantity;
}
public async Task<decimal> ApplyMovementsAsync(IEnumerable<StockMovementDraft> drafts, CancellationToken ct = default)
{
var last = 0m;
foreach (var d in drafts) last = await ApplyMovementAsync(d, ct);
return last;
}
}

View file

@ -1,6 +1,9 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Identity;
using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Persistence.Configurations;
@ -35,6 +38,15 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<ProductBarcode> ProductBarcodes => Set<ProductBarcode>();
public DbSet<ProductImage> ProductImages => Set<ProductImage>();
public DbSet<Stock> Stocks => Set<Stock>();
public DbSet<StockMovement> StockMovements => Set<StockMovement>();
public DbSet<Supply> Supplies => Set<Supply>();
public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>();
public DbSet<RetailSale> RetailSales => Set<RetailSale>();
public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
@ -62,6 +74,9 @@ protected override void OnModelCreating(ModelBuilder builder)
});
builder.ConfigureCatalog();
builder.ConfigureInventory();
builder.ConfigurePurchases();
builder.ConfigureSales();
// Apply multi-tenant query filter to every entity that implements ITenantEntity
foreach (var entityType in builder.Model.GetEntityTypes())

View file

@ -123,7 +123,7 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
{
b.ToTable("products");
b.Property(x => x.Name).HasMaxLength(500).IsRequired();
b.Property(x => x.Article).HasMaxLength(100);
b.Property(x => x.Article).HasMaxLength(500);
b.Property(x => x.MinStock).HasPrecision(18, 4);
b.Property(x => x.MaxStock).HasPrecision(18, 4);
b.Property(x => x.PurchasePrice).HasPrecision(18, 4);
@ -155,7 +155,8 @@ private static void ConfigureProductPrice(EntityTypeBuilder<ProductPrice> b)
private static void ConfigureBarcode(EntityTypeBuilder<ProductBarcode> b)
{
b.ToTable("product_barcodes");
b.Property(x => x.Code).HasMaxLength(100).IsRequired();
// Up to 500 to accommodate GS1 DataMatrix / crypto-tail tracking codes (Честный ЗНАК etc.)
b.Property(x => x.Code).HasMaxLength(500).IsRequired();
b.HasOne(x => x.Product).WithMany(p => p.Barcodes).HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique();
}

View file

@ -0,0 +1,41 @@
using foodmarket.Domain.Inventory;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Infrastructure.Persistence.Configurations;
public static class InventoryConfigurations
{
public static void ConfigureInventory(this ModelBuilder b)
{
b.Entity<Stock>(e =>
{
e.ToTable("stocks");
e.Property(x => x.Quantity).HasPrecision(18, 4);
e.Property(x => x.ReservedQuantity).HasPrecision(18, 4);
e.Ignore(x => x.Available);
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.OrganizationId, x.ProductId, x.StoreId }).IsUnique();
e.HasIndex(x => new { x.OrganizationId, x.StoreId });
});
b.Entity<StockMovement>(e =>
{
e.ToTable("stock_movements");
e.Property(x => x.Quantity).HasPrecision(18, 4);
e.Property(x => x.UnitCost).HasPrecision(18, 4);
e.Property(x => x.DocumentType).HasMaxLength(50).IsRequired();
e.Property(x => x.DocumentNumber).HasMaxLength(50);
e.Property(x => x.Notes).HasMaxLength(500);
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.OrganizationId, x.ProductId, x.OccurredAt });
e.HasIndex(x => new { x.OrganizationId, x.StoreId, x.OccurredAt });
e.HasIndex(x => new { x.DocumentType, x.DocumentId });
});
}
}

View file

@ -0,0 +1,42 @@
using foodmarket.Domain.Purchases;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Infrastructure.Persistence.Configurations;
public static class PurchasesConfigurations
{
public static void ConfigurePurchases(this ModelBuilder b)
{
b.Entity<Supply>(e =>
{
e.ToTable("supplies");
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
e.Property(x => x.SupplierInvoiceNumber).HasMaxLength(100);
e.Property(x => x.Notes).HasMaxLength(1000);
e.Property(x => x.Total).HasPrecision(18, 4);
e.HasOne(x => x.Supplier).WithMany().HasForeignKey(x => x.SupplierId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
e.HasMany(x => x.Lines).WithOne(l => l.Supply).HasForeignKey(l => l.SupplyId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
e.HasIndex(x => new { x.OrganizationId, x.Date });
e.HasIndex(x => new { x.OrganizationId, x.Status });
e.HasIndex(x => new { x.OrganizationId, x.SupplierId });
});
b.Entity<SupplyLine>(e =>
{
e.ToTable("supply_lines");
e.Property(x => x.Quantity).HasPrecision(18, 4);
e.Property(x => x.UnitPrice).HasPrecision(18, 4);
e.Property(x => x.LineTotal).HasPrecision(18, 4);
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
});
}
}

View file

@ -0,0 +1,47 @@
using foodmarket.Domain.Sales;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Infrastructure.Persistence.Configurations;
public static class SalesConfigurations
{
public static void ConfigureSales(this ModelBuilder b)
{
b.Entity<RetailSale>(e =>
{
e.ToTable("retail_sales");
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
e.Property(x => x.Notes).HasMaxLength(1000);
e.Property(x => x.Subtotal).HasPrecision(18, 4);
e.Property(x => x.DiscountTotal).HasPrecision(18, 4);
e.Property(x => x.Total).HasPrecision(18, 4);
e.Property(x => x.PaidCash).HasPrecision(18, 4);
e.Property(x => x.PaidCard).HasPrecision(18, 4);
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.RetailPoint).WithMany().HasForeignKey(x => x.RetailPointId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
e.HasMany(x => x.Lines).WithOne(l => l.RetailSale).HasForeignKey(l => l.RetailSaleId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
e.HasIndex(x => new { x.OrganizationId, x.Date });
e.HasIndex(x => new { x.OrganizationId, x.Status });
e.HasIndex(x => new { x.OrganizationId, x.CashierUserId });
});
b.Entity<RetailSaleLine>(e =>
{
e.ToTable("retail_sale_lines");
e.Property(x => x.Quantity).HasPrecision(18, 4);
e.Property(x => x.UnitPrice).HasPrecision(18, 4);
e.Property(x => x.Discount).HasPrecision(18, 4);
e.Property(x => x.LineTotal).HasPrecision(18, 4);
e.Property(x => x.VatPercent).HasPrecision(5, 2);
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
});
}
}

View file

@ -0,0 +1,64 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase1e_WidenArticleBarcode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Article",
schema: "public",
table: "products",
type: "character varying(500)",
maxLength: 500,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(100)",
oldMaxLength: 100,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Code",
schema: "public",
table: "product_barcodes",
type: "character varying(500)",
maxLength: 500,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(100)",
oldMaxLength: 100);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Article",
schema: "public",
table: "products",
type: "character varying(100)",
maxLength: 100,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(500)",
oldMaxLength: 500,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Code",
schema: "public",
table: "product_barcodes",
type: "character varying(100)",
maxLength: 100,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(500)",
oldMaxLength: 500);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,155 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase2a_Stock : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "stock_movements",
schema: "public",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ProductId = table.Column<Guid>(type: "uuid", nullable: false),
StoreId = table.Column<Guid>(type: "uuid", nullable: false),
Quantity = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UnitCost = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: true),
Type = table.Column<int>(type: "integer", nullable: false),
DocumentType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
DocumentId = table.Column<Guid>(type: "uuid", nullable: true),
DocumentNumber = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
OccurredAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
Notes = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_stock_movements", x => x.Id);
table.ForeignKey(
name: "FK_stock_movements_products_ProductId",
column: x => x.ProductId,
principalSchema: "public",
principalTable: "products",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_movements_stores_StoreId",
column: x => x.StoreId,
principalSchema: "public",
principalTable: "stores",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "stocks",
schema: "public",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ProductId = table.Column<Guid>(type: "uuid", nullable: false),
StoreId = table.Column<Guid>(type: "uuid", nullable: false),
Quantity = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
ReservedQuantity = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_stocks", x => x.Id);
table.ForeignKey(
name: "FK_stocks_products_ProductId",
column: x => x.ProductId,
principalSchema: "public",
principalTable: "products",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_stocks_stores_StoreId",
column: x => x.StoreId,
principalSchema: "public",
principalTable: "stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_stock_movements_DocumentType_DocumentId",
schema: "public",
table: "stock_movements",
columns: new[] { "DocumentType", "DocumentId" });
migrationBuilder.CreateIndex(
name: "IX_stock_movements_OrganizationId_ProductId_OccurredAt",
schema: "public",
table: "stock_movements",
columns: new[] { "OrganizationId", "ProductId", "OccurredAt" });
migrationBuilder.CreateIndex(
name: "IX_stock_movements_OrganizationId_StoreId_OccurredAt",
schema: "public",
table: "stock_movements",
columns: new[] { "OrganizationId", "StoreId", "OccurredAt" });
migrationBuilder.CreateIndex(
name: "IX_stock_movements_ProductId",
schema: "public",
table: "stock_movements",
column: "ProductId");
migrationBuilder.CreateIndex(
name: "IX_stock_movements_StoreId",
schema: "public",
table: "stock_movements",
column: "StoreId");
migrationBuilder.CreateIndex(
name: "IX_stocks_OrganizationId_ProductId_StoreId",
schema: "public",
table: "stocks",
columns: new[] { "OrganizationId", "ProductId", "StoreId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_stocks_OrganizationId_StoreId",
schema: "public",
table: "stocks",
columns: new[] { "OrganizationId", "StoreId" });
migrationBuilder.CreateIndex(
name: "IX_stocks_ProductId",
schema: "public",
table: "stocks",
column: "ProductId");
migrationBuilder.CreateIndex(
name: "IX_stocks_StoreId",
schema: "public",
table: "stocks",
column: "StoreId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "stock_movements",
schema: "public");
migrationBuilder.DropTable(
name: "stocks",
schema: "public");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,171 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase2b_Supply : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "supplies",
schema: "public",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Number = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
SupplierId = table.Column<Guid>(type: "uuid", nullable: false),
StoreId = table.Column<Guid>(type: "uuid", nullable: false),
CurrencyId = table.Column<Guid>(type: "uuid", nullable: false),
SupplierInvoiceNumber = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
SupplierInvoiceDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Notes = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
Total = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
PostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
PostedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_supplies", x => x.Id);
table.ForeignKey(
name: "FK_supplies_counterparties_SupplierId",
column: x => x.SupplierId,
principalSchema: "public",
principalTable: "counterparties",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_supplies_currencies_CurrencyId",
column: x => x.CurrencyId,
principalSchema: "public",
principalTable: "currencies",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_supplies_stores_StoreId",
column: x => x.StoreId,
principalSchema: "public",
principalTable: "stores",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "supply_lines",
schema: "public",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SupplyId = table.Column<Guid>(type: "uuid", nullable: false),
ProductId = table.Column<Guid>(type: "uuid", nullable: false),
Quantity = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_supply_lines", x => x.Id);
table.ForeignKey(
name: "FK_supply_lines_products_ProductId",
column: x => x.ProductId,
principalSchema: "public",
principalTable: "products",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_supply_lines_supplies_SupplyId",
column: x => x.SupplyId,
principalSchema: "public",
principalTable: "supplies",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_supplies_CurrencyId",
schema: "public",
table: "supplies",
column: "CurrencyId");
migrationBuilder.CreateIndex(
name: "IX_supplies_OrganizationId_Date",
schema: "public",
table: "supplies",
columns: new[] { "OrganizationId", "Date" });
migrationBuilder.CreateIndex(
name: "IX_supplies_OrganizationId_Number",
schema: "public",
table: "supplies",
columns: new[] { "OrganizationId", "Number" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_supplies_OrganizationId_Status",
schema: "public",
table: "supplies",
columns: new[] { "OrganizationId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_supplies_OrganizationId_SupplierId",
schema: "public",
table: "supplies",
columns: new[] { "OrganizationId", "SupplierId" });
migrationBuilder.CreateIndex(
name: "IX_supplies_StoreId",
schema: "public",
table: "supplies",
column: "StoreId");
migrationBuilder.CreateIndex(
name: "IX_supplies_SupplierId",
schema: "public",
table: "supplies",
column: "SupplierId");
migrationBuilder.CreateIndex(
name: "IX_supply_lines_OrganizationId_ProductId",
schema: "public",
table: "supply_lines",
columns: new[] { "OrganizationId", "ProductId" });
migrationBuilder.CreateIndex(
name: "IX_supply_lines_ProductId",
schema: "public",
table: "supply_lines",
column: "ProductId");
migrationBuilder.CreateIndex(
name: "IX_supply_lines_SupplyId",
schema: "public",
table: "supply_lines",
column: "SupplyId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "supply_lines",
schema: "public");
migrationBuilder.DropTable(
name: "supplies",
schema: "public");
}
}
}

View file

@ -0,0 +1,191 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase2c_RetailSale : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "retail_sales",
schema: "public",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Number = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
StoreId = table.Column<Guid>(type: "uuid", nullable: false),
RetailPointId = table.Column<Guid>(type: "uuid", nullable: true),
CustomerId = table.Column<Guid>(type: "uuid", nullable: true),
CashierUserId = table.Column<Guid>(type: "uuid", nullable: true),
CurrencyId = table.Column<Guid>(type: "uuid", nullable: false),
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
DiscountTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
Total = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
Payment = table.Column<int>(type: "integer", nullable: false),
PaidCash = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
PaidCard = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
Notes = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
PostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
PostedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_retail_sales", x => x.Id);
table.ForeignKey(
name: "FK_retail_sales_counterparties_CustomerId",
column: x => x.CustomerId,
principalSchema: "public",
principalTable: "counterparties",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_retail_sales_currencies_CurrencyId",
column: x => x.CurrencyId,
principalSchema: "public",
principalTable: "currencies",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_retail_sales_retail_points_RetailPointId",
column: x => x.RetailPointId,
principalSchema: "public",
principalTable: "retail_points",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_retail_sales_stores_StoreId",
column: x => x.StoreId,
principalSchema: "public",
principalTable: "stores",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "retail_sale_lines",
schema: "public",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
RetailSaleId = table.Column<Guid>(type: "uuid", nullable: false),
ProductId = table.Column<Guid>(type: "uuid", nullable: false),
Quantity = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
Discount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
VatPercent = table.Column<decimal>(type: "numeric(5,2)", precision: 5, scale: 2, nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_retail_sale_lines", x => x.Id);
table.ForeignKey(
name: "FK_retail_sale_lines_products_ProductId",
column: x => x.ProductId,
principalSchema: "public",
principalTable: "products",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_retail_sale_lines_retail_sales_RetailSaleId",
column: x => x.RetailSaleId,
principalSchema: "public",
principalTable: "retail_sales",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_retail_sale_lines_OrganizationId_ProductId",
schema: "public",
table: "retail_sale_lines",
columns: new[] { "OrganizationId", "ProductId" });
migrationBuilder.CreateIndex(
name: "IX_retail_sale_lines_ProductId",
schema: "public",
table: "retail_sale_lines",
column: "ProductId");
migrationBuilder.CreateIndex(
name: "IX_retail_sale_lines_RetailSaleId",
schema: "public",
table: "retail_sale_lines",
column: "RetailSaleId");
migrationBuilder.CreateIndex(
name: "IX_retail_sales_CurrencyId",
schema: "public",
table: "retail_sales",
column: "CurrencyId");
migrationBuilder.CreateIndex(
name: "IX_retail_sales_CustomerId",
schema: "public",
table: "retail_sales",
column: "CustomerId");
migrationBuilder.CreateIndex(
name: "IX_retail_sales_OrganizationId_CashierUserId",
schema: "public",
table: "retail_sales",
columns: new[] { "OrganizationId", "CashierUserId" });
migrationBuilder.CreateIndex(
name: "IX_retail_sales_OrganizationId_Date",
schema: "public",
table: "retail_sales",
columns: new[] { "OrganizationId", "Date" });
migrationBuilder.CreateIndex(
name: "IX_retail_sales_OrganizationId_Number",
schema: "public",
table: "retail_sales",
columns: new[] { "OrganizationId", "Number" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_retail_sales_OrganizationId_Status",
schema: "public",
table: "retail_sales",
columns: new[] { "OrganizationId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_retail_sales_RetailPointId",
schema: "public",
table: "retail_sales",
column: "RetailPointId");
migrationBuilder.CreateIndex(
name: "IX_retail_sales_StoreId",
schema: "public",
table: "retail_sales",
column: "StoreId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "retail_sale_lines",
schema: "public");
migrationBuilder.DropTable(
name: "retail_sales",
schema: "public");
}
}
}

View file

@ -546,8 +546,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnType("uuid");
b.Property<string>("Article")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<Guid?>("CountryOfOriginId")
.HasColumnType("uuid");
@ -648,8 +648,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@ -993,6 +993,118 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("vat_rates", "public");
});
modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid>("ProductId")
.HasColumnType("uuid");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("ReservedQuantity")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<Guid>("StoreId")
.HasColumnType("uuid");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ProductId");
b.HasIndex("StoreId");
b.HasIndex("OrganizationId", "StoreId");
b.HasIndex("OrganizationId", "ProductId", "StoreId")
.IsUnique();
b.ToTable("stocks", "public");
});
modelBuilder.Entity("foodmarket.Domain.Inventory.StockMovement", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("CreatedByUserId")
.HasColumnType("uuid");
b.Property<Guid?>("DocumentId")
.HasColumnType("uuid");
b.Property<string>("DocumentNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("DocumentType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("OccurredAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid>("ProductId")
.HasColumnType("uuid");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<Guid>("StoreId")
.HasColumnType("uuid");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<decimal?>("UnitCost")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ProductId");
b.HasIndex("StoreId");
b.HasIndex("DocumentType", "DocumentId");
b.HasIndex("OrganizationId", "ProductId", "OccurredAt");
b.HasIndex("OrganizationId", "StoreId", "OccurredAt");
b.ToTable("stock_movements", "public");
});
modelBuilder.Entity("foodmarket.Domain.Organizations.Organization", b =>
{
b.Property<Guid>("Id")
@ -1038,6 +1150,280 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("organizations", "public");
});
modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CurrencyId")
.HasColumnType("uuid");
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Number")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<DateTime?>("PostedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PostedByUserId")
.HasColumnType("uuid");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<Guid>("StoreId")
.HasColumnType("uuid");
b.Property<Guid>("SupplierId")
.HasColumnType("uuid");
b.Property<DateTime?>("SupplierInvoiceDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("SupplierInvoiceNumber")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<decimal>("Total")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CurrencyId");
b.HasIndex("StoreId");
b.HasIndex("SupplierId");
b.HasIndex("OrganizationId", "Date");
b.HasIndex("OrganizationId", "Number")
.IsUnique();
b.HasIndex("OrganizationId", "Status");
b.HasIndex("OrganizationId", "SupplierId");
b.ToTable("supplies", "public");
});
modelBuilder.Entity("foodmarket.Domain.Purchases.SupplyLine", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("LineTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid>("ProductId")
.HasColumnType("uuid");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<Guid>("SupplyId")
.HasColumnType("uuid");
b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ProductId");
b.HasIndex("SupplyId");
b.HasIndex("OrganizationId", "ProductId");
b.ToTable("supply_lines", "public");
});
modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CashierUserId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CurrencyId")
.HasColumnType("uuid");
b.Property<Guid?>("CustomerId")
.HasColumnType("uuid");
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("DiscountTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Number")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<decimal>("PaidCard")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("PaidCash")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("Payment")
.HasColumnType("integer");
b.Property<DateTime?>("PostedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PostedByUserId")
.HasColumnType("uuid");
b.Property<Guid?>("RetailPointId")
.HasColumnType("uuid");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<Guid>("StoreId")
.HasColumnType("uuid");
b.Property<decimal>("Subtotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("Total")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CurrencyId");
b.HasIndex("CustomerId");
b.HasIndex("RetailPointId");
b.HasIndex("StoreId");
b.HasIndex("OrganizationId", "CashierUserId");
b.HasIndex("OrganizationId", "Date");
b.HasIndex("OrganizationId", "Number")
.IsUnique();
b.HasIndex("OrganizationId", "Status");
b.ToTable("retail_sales", "public");
});
modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("Discount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("LineTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid>("ProductId")
.HasColumnType("uuid");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<Guid>("RetailSaleId")
.HasColumnType("uuid");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("VatPercent")
.HasPrecision(5, 2)
.HasColumnType("numeric(5,2)");
b.HasKey("Id");
b.HasIndex("ProductId");
b.HasIndex("RetailSaleId");
b.HasIndex("OrganizationId", "ProductId");
b.ToTable("retail_sale_lines", "public");
});
modelBuilder.Entity("foodmarket.Infrastructure.Identity.Role", b =>
{
b.Property<Guid>("Id")
@ -1355,6 +1741,142 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("Store");
});
modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b =>
{
b.HasOne("foodmarket.Domain.Catalog.Product", "Product")
.WithMany()
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("foodmarket.Domain.Catalog.Store", "Store")
.WithMany()
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Product");
b.Navigation("Store");
});
modelBuilder.Entity("foodmarket.Domain.Inventory.StockMovement", b =>
{
b.HasOne("foodmarket.Domain.Catalog.Product", "Product")
.WithMany()
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("foodmarket.Domain.Catalog.Store", "Store")
.WithMany()
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Product");
b.Navigation("Store");
});
modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b =>
{
b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency")
.WithMany()
.HasForeignKey("CurrencyId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("foodmarket.Domain.Catalog.Store", "Store")
.WithMany()
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Supplier")
.WithMany()
.HasForeignKey("SupplierId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Currency");
b.Navigation("Store");
b.Navigation("Supplier");
});
modelBuilder.Entity("foodmarket.Domain.Purchases.SupplyLine", b =>
{
b.HasOne("foodmarket.Domain.Catalog.Product", "Product")
.WithMany()
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("foodmarket.Domain.Purchases.Supply", "Supply")
.WithMany("Lines")
.HasForeignKey("SupplyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Product");
b.Navigation("Supply");
});
modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b =>
{
b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency")
.WithMany()
.HasForeignKey("CurrencyId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("foodmarket.Domain.Catalog.RetailPoint", "RetailPoint")
.WithMany()
.HasForeignKey("RetailPointId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("foodmarket.Domain.Catalog.Store", "Store")
.WithMany()
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Currency");
b.Navigation("Customer");
b.Navigation("RetailPoint");
b.Navigation("Store");
});
modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b =>
{
b.HasOne("foodmarket.Domain.Catalog.Product", "Product")
.WithMany()
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("foodmarket.Domain.Sales.RetailSale", "RetailSale")
.WithMany("Lines")
.HasForeignKey("RetailSaleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Product");
b.Navigation("RetailSale");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
{
b.Navigation("Authorizations");
@ -1380,6 +1902,16 @@ protected override void BuildModel(ModelBuilder modelBuilder)
{
b.Navigation("Children");
});
modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b =>
{
b.Navigation("Lines");
});
#pragma warning restore 612, 618
}
}

View file

@ -21,6 +21,7 @@
"react-dom": "^19.2.5",
"react-hook-form": "^7.73.1",
"react-router-dom": "^7.14.1",
"recharts": "^3.8.1",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.3.6"

View file

@ -41,6 +41,9 @@ importers:
react-router-dom:
specifier: ^7.14.1
version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@19.2.5)(react@19.2.5)(redux@5.0.1)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
@ -266,6 +269,17 @@ packages:
'@oxc-project/types@0.126.0':
resolution: {integrity: sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==}
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/binding-android-arm64@1.0.0-rc.16':
resolution: {integrity: sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -367,6 +381,9 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.7':
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
@ -484,6 +501,33 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -501,6 +545,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@typescript-eslint/eslint-plugin@8.59.0':
resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -680,6 +727,50 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@ -689,6 +780,9 @@ packages:
supports-color:
optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@ -727,6 +821,9 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
es-toolkit@1.46.0:
resolution: {integrity: sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==}
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@ -792,6 +889,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@ -910,6 +1010,12 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.1.4:
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@ -918,6 +1024,10 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@ -1152,6 +1262,21 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-is@19.2.5:
resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==}
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-router-dom@7.14.1:
resolution: {integrity: sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==}
engines: {node: '>=20.0.0'}
@ -1173,6 +1298,25 @@ packages:
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'}
recharts@3.8.1:
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -1232,6 +1376,9 @@ packages:
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
engines: {node: '>=6'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
@ -1273,6 +1420,14 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite@8.0.9:
resolution: {integrity: sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -1554,6 +1709,18 @@ snapshots:
'@oxc-project/types@0.126.0': {}
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.4
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 19.2.5
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1)
'@rolldown/binding-android-arm64@1.0.0-rc.16':
optional: true
@ -1607,6 +1774,8 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.7': {}
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@tailwindcss/node@4.2.3':
@ -1697,6 +1866,30 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
@ -1713,6 +1906,8 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/use-sync-external-store@0.0.6': {}
'@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@ -1914,10 +2109,50 @@ snapshots:
csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
decimal.js-light@2.5.1: {}
deep-is@0.1.4: {}
delayed-stream@1.0.0: {}
@ -1952,6 +2187,8 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.3
es-toolkit@1.46.0: {}
escalade@3.2.0: {}
escape-string-regexp@4.0.0: {}
@ -2041,6 +2278,8 @@ snapshots:
esutils@2.0.3: {}
eventemitter3@5.0.4: {}
fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {}
@ -2138,6 +2377,10 @@ snapshots:
ignore@7.0.5: {}
immer@10.2.0: {}
immer@11.1.4: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@ -2145,6 +2388,8 @@ snapshots:
imurmurhash@0.1.4: {}
internmap@2.0.3: {}
is-extglob@2.1.1: {}
is-glob@4.0.3:
@ -2323,6 +2568,17 @@ snapshots:
dependencies:
react: 19.2.5
react-is@19.2.5: {}
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.5
use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
redux: 5.0.1
react-router-dom@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
react: 19.2.5
@ -2339,6 +2595,34 @@ snapshots:
react@19.2.5: {}
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@19.2.5)(react@19.2.5)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.46.0
eventemitter3: 5.0.4
immer: 10.2.0
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-is: 19.2.5
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.5)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reselect@5.1.1: {}
resolve-from@4.0.0: {}
rolldown@1.0.0-rc.16:
@ -2394,6 +2678,8 @@ snapshots:
tapable@2.3.2: {}
tiny-invariant@1.3.3: {}
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
@ -2435,6 +2721,27 @@ snapshots:
dependencies:
punycode: 2.3.1
use-sync-external-store@1.6.0(react@19.2.5):
dependencies:
react: 19.2.5
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite@8.0.9(@types/node@24.12.2)(jiti@2.6.1):
dependencies:
lightningcss: 1.32.0

View file

@ -13,6 +13,13 @@ import { ProductGroupsPage } from '@/pages/ProductGroupsPage'
import { CounterpartiesPage } from '@/pages/CounterpartiesPage'
import { ProductsPage } from '@/pages/ProductsPage'
import { ProductEditPage } from '@/pages/ProductEditPage'
import { MoySkladImportPage } from '@/pages/MoySkladImportPage'
import { StockPage } from '@/pages/StockPage'
import { StockMovementsPage } from '@/pages/StockMovementsPage'
import { SuppliesPage } from '@/pages/SuppliesPage'
import { SupplyEditPage } from '@/pages/SupplyEditPage'
import { RetailSalesPage } from '@/pages/RetailSalesPage'
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
import { AppLayout } from '@/components/AppLayout'
import { ProtectedRoute } from '@/components/ProtectedRoute'
@ -46,6 +53,15 @@ export default function App() {
<Route path="/catalog/retail-points" element={<RetailPointsPage />} />
<Route path="/catalog/countries" element={<CountriesPage />} />
<Route path="/catalog/currencies" element={<CurrenciesPage />} />
<Route path="/inventory/stock" element={<StockPage />} />
<Route path="/inventory/movements" element={<StockMovementsPage />} />
<Route path="/purchases/supplies" element={<SuppliesPage />} />
<Route path="/purchases/supplies/new" element={<SupplyEditPage />} />
<Route path="/purchases/supplies/:id" element={<SupplyEditPage />} />
<Route path="/sales/retail" element={<RetailSalesPage />} />
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />
<Route path="/admin/import/moysklad" element={<MoySkladImportPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />

View file

@ -5,7 +5,8 @@ import { logout } from '@/lib/auth'
import { cn } from '@/lib/utils'
import {
LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag,
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut,
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download,
Boxes, History, TruckIcon, ShoppingCart,
} from 'lucide-react'
import { Logo } from './Logo'
@ -35,10 +36,23 @@ const nav = [
{ to: '/catalog/stores', icon: Warehouse, label: 'Склады' },
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'Точки продаж' },
]},
{ group: 'Остатки', items: [
{ to: '/inventory/stock', icon: Boxes, label: 'Остатки' },
{ to: '/inventory/movements', icon: History, label: 'Движения' },
]},
{ group: 'Закупки', items: [
{ to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' },
]},
{ group: 'Продажи', items: [
{ to: '/sales/retail', icon: ShoppingCart, label: 'Розничные чеки' },
]},
{ group: 'Справочники', items: [
{ to: '/catalog/countries', icon: Globe, label: 'Страны' },
{ to: '/catalog/currencies', icon: Coins, label: 'Валюты' },
]},
{ group: 'Импорт', items: [
{ to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' },
]},
] as const
export function AppLayout() {
@ -49,10 +63,10 @@ export function AppLayout() {
})
return (
<div className="min-h-screen flex bg-slate-50 dark:bg-slate-950">
<aside className="w-60 flex-shrink-0 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col">
<div className="h-screen flex bg-slate-50 dark:bg-slate-950 overflow-hidden">
<aside className="w-60 flex-shrink-0 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col h-full">
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
<Logo size={28} />
<Logo />
</div>
<nav className="flex-1 overflow-y-auto py-3">
@ -95,7 +109,7 @@ export function AppLayout() {
</div>
</aside>
<main className="flex-1 overflow-x-hidden">
<main className="flex-1 min-w-0 flex flex-col overflow-hidden">
<Outlet />
</main>
</div>

View file

@ -15,58 +15,83 @@ interface DataTableProps<T> {
onRowClick?: (row: T) => void
empty?: ReactNode
isLoading?: boolean
/** If true (default), the table wraps itself in a scrollable container with a sticky thead.
* If false, use when the caller provides its own scroll container. */
scrollable?: boolean
}
export function DataTable<T>({ rows, columns, rowKey, onRowClick, empty, isLoading }: DataTableProps<T>) {
return (
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800/50 text-left">
export function DataTable<T>({
rows, columns, rowKey, onRowClick, empty, isLoading, scrollable = true,
}: DataTableProps<T>) {
const table = (
<table className="w-full text-sm border-separate border-spacing-0">
<thead className="sticky top-0 z-10 bg-slate-50 dark:bg-slate-800/90 backdrop-blur text-left">
<tr>
{columns.map((c, i) => (
<th
key={i}
className={cn(
'px-4 py-2.5 font-medium text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200 dark:border-slate-700',
c.className,
)}
style={c.width ? { width: c.width } : undefined}
>
{c.header}
</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
{columns.map((c, i) => (
<th
key={i}
className={cn('px-4 py-2.5 font-medium text-xs uppercase tracking-wide text-slate-500', c.className)}
style={c.width ? { width: c.width } : undefined}
>
{c.header}
</th>
))}
<td colSpan={columns.length} className="px-4 py-8 text-center text-slate-400">
Загрузка
</td>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={columns.length} className="px-4 py-8 text-center text-slate-400">
Загрузка
</td>
) : rows.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-8 text-center text-slate-400">
{empty ?? 'Нет данных'}
</td>
</tr>
) : (
rows.map((row) => (
<tr
key={rowKey(row)}
onClick={() => onRowClick?.(row)}
className={cn(
onRowClick && 'cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/30',
)}
>
{columns.map((c, i) => (
<td
key={i}
className={cn(
'px-4 py-2.5 text-slate-700 dark:text-slate-200 border-b border-slate-100 dark:border-slate-800',
c.className,
)}
>
{c.cell(row)}
</td>
))}
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-8 text-center text-slate-400">
{empty ?? 'Нет данных'}
</td>
</tr>
) : (
rows.map((row) => (
<tr
key={rowKey(row)}
onClick={() => onRowClick?.(row)}
className={cn(
'border-t border-slate-100 dark:border-slate-800',
onRowClick && 'cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/30'
)}
>
{columns.map((c, i) => (
<td key={i} className={cn('px-4 py-2.5 text-slate-700 dark:text-slate-200', c.className)}>
{c.cell(row)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
))
)}
</tbody>
</table>
)
if (!scrollable) {
return (
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 overflow-hidden">
{table}
</div>
)
}
return (
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 h-full overflow-auto">
{table}
</div>
)
}

View file

@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
import { PageHeader } from './PageHeader'
interface Props {
title: string
description?: string
actions?: ReactNode
children: ReactNode
footer?: ReactNode
}
/** Fullheight list-page layout: sticky top bar + scrollable content + optional sticky footer (pagination). */
export function ListPageShell({ title, description, actions, children, footer }: Props) {
return (
<div className="flex flex-col h-full">
<PageHeader variant="bar" title={title} description={description} actions={actions} />
<div className="flex-1 min-h-0 p-4">{children}</div>
{footer && (
<div className="border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 px-4 py-2">
{footer}
</div>
)}
</div>
)
}

View file

@ -1,25 +1,17 @@
import { cn } from '@/lib/utils'
export function Logo({ size = 28, showText = true, className }: { size?: number; showText?: boolean; className?: string }) {
export function Logo({ className }: { className?: string }) {
return (
<div className={cn('flex items-center gap-2.5', className)}>
<div
className="flex items-center justify-center rounded-md font-black text-white leading-none"
style={{
backgroundColor: 'var(--color-brand)',
width: size,
height: size,
fontSize: Math.floor(size * 0.38),
}}
<div className={cn('flex flex-col leading-none select-none', className)}>
<span className="font-black text-slate-900 dark:text-slate-100 tracking-[0.08em] text-base">
FOOD
</span>
<span
className="font-black text-[11px] tracking-[0.24em] mt-0.5"
style={{ color: 'var(--color-brand)' }}
>
FM
</div>
{showText && (
<div className="leading-tight">
<div className="font-black text-slate-900 dark:text-slate-100 tracking-wide">FOOD</div>
<div className="font-black text-xs tracking-[0.2em]" style={{ color: 'var(--color-brand)' }}>MARKET</div>
</div>
)}
MARKET
</span>
</div>
)
}

View file

@ -4,16 +4,30 @@ interface PageHeaderProps {
title: string
description?: string
actions?: ReactNode
/** Visual style — set 'bar' to render inside a sticky top bar (used by list/edit pages). */
variant?: 'plain' | 'bar'
}
export function PageHeader({ title, description, actions }: PageHeaderProps) {
export function PageHeader({ title, description, actions, variant = 'plain' }: PageHeaderProps) {
if (variant === 'bar') {
return (
<div className="flex items-center justify-between gap-4 px-6 py-4 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
<div className="min-w-0">
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100 truncate">{title}</h1>
{description && (
<p className="text-xs text-slate-500 mt-0.5 truncate">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-2 flex-shrink-0">{actions}</div>}
</div>
)
}
return (
<div className="flex items-start justify-between gap-4 mb-5">
<div>
<h1 className="text-xl font-semibold text-slate-900 dark:text-slate-100">{title}</h1>
{description && (
<p className="text-sm text-slate-500 mt-0.5">{description}</p>
)}
{description && <p className="text-sm text-slate-500 mt-0.5">{description}</p>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>

View file

@ -0,0 +1,88 @@
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Search, X } from 'lucide-react'
import { api } from '@/lib/api'
import type { PagedResult, Product } from '@/lib/types'
interface Props {
open: boolean
onClose: () => void
onPick: (product: Product) => void
title?: string
}
export function ProductPicker({ open, onClose, onPick, title = 'Выбор товара' }: Props) {
const [search, setSearch] = useState('')
useEffect(() => { if (!open) setSearch('') }, [open])
const results = useQuery({
queryKey: ['product-picker', search],
queryFn: async () => {
const params = new URLSearchParams({ pageSize: '30' })
if (search) params.set('search', search)
return (await api.get<PagedResult<Product>>(`/api/catalog/products?${params}`)).data.items
},
enabled: open,
})
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="px-5 py-3 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">{title}</h3>
<button onClick={onClose} className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 py-3 border-b border-slate-200 dark:border-slate-800">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
autoFocus
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="По названию, артикулу или штрихкоду…"
className="w-full pl-9 pr-3 py-2 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)]"
/>
</div>
</div>
<div className="flex-1 overflow-auto">
{results.isLoading && <div className="p-6 text-center text-slate-400 text-sm">Загрузка</div>}
{results.data && results.data.length === 0 && (
<div className="p-6 text-center text-slate-400 text-sm">
{search ? 'Ничего не найдено' : 'Начни вводить название или штрихкод'}
</div>
)}
{results.data && results.data.map((p) => (
<button
key={p.id}
type="button"
onClick={() => { onPick(p); onClose() }}
className="w-full text-left px-5 py-2.5 border-b border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center justify-between gap-3"
>
<div className="min-w-0">
<div className="font-medium text-slate-900 dark:text-slate-100 truncate">{p.name}</div>
<div className="text-xs text-slate-400 flex gap-2 font-mono">
{p.article && <span>{p.article}</span>}
{p.barcodes[0] && <span>· {p.barcodes[0].code}</span>}
<span>· {p.unitSymbol}</span>
</div>
</div>
{p.purchasePrice !== null && (
<div className="text-xs text-slate-500 font-mono flex-shrink-0">
закуп: {p.purchasePrice.toLocaleString('ru')} {p.purchaseCurrencyCode ?? ''}
</div>
)}
</button>
))}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,77 @@
import {
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
CartesianGrid,
} from 'recharts'
import type { SalesStatsBucket } from '@/lib/types'
interface Props {
series: SalesStatsBucket[]
currencyCode?: string
}
const fmt = new Intl.NumberFormat('ru', { maximumFractionDigits: 0 })
const fmtDay = (s: string) => {
const d = new Date(s)
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}`
}
export function SalesChart({ series, currencyCode = 'KZT' }: Props) {
const data = series.map((b) => ({
day: fmtDay(b.bucket),
revenue: b.revenue,
transactions: b.transactions,
}))
return (
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="revenue-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-brand)" stopOpacity={0.35} />
<stop offset="95%" stopColor="var(--color-brand)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis
dataKey="day"
stroke="#94a3b8"
fontSize={11}
interval="preserveStartEnd"
tickLine={false}
/>
<YAxis
stroke="#94a3b8"
fontSize={11}
tickFormatter={(v) => fmt.format(v)}
tickLine={false}
axisLine={false}
width={70}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(15, 23, 42, 0.95)',
border: 'none',
borderRadius: '8px',
color: '#f1f5f9',
fontSize: '12px',
}}
labelStyle={{ color: '#94a3b8', marginBottom: 4 }}
formatter={(value, name) => {
const num = typeof value === 'number' ? value : Number(value ?? 0)
if (name === 'revenue') return [`${fmt.format(num)} ${currencyCode}`, 'Выручка']
return [String(value), String(name)]
}}
/>
<Area
type="monotone"
dataKey="revenue"
stroke="var(--color-brand)"
strokeWidth={2}
fill="url(#revenue-fill)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
)
}

View file

@ -29,6 +29,10 @@ api.interceptors.response.use(
return api(original)
}
clearTokens()
// Redirect to login so user isn't stuck on a protected page with stale tokens.
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.href = '/login'
}
}
return Promise.reject(error)
},

View file

@ -6,7 +6,7 @@ export interface PagedResult<T> {
totalPages: number
}
export const CounterpartyKind = { Supplier: 1, Customer: 2, Both: 3 } as const
export const CounterpartyKind = { Unspecified: 0, Supplier: 1, Customer: 2, Both: 3 } as const
export type CounterpartyKind = (typeof CounterpartyKind)[keyof typeof CounterpartyKind]
export const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const
@ -54,3 +54,97 @@ export interface Product {
imageUrl: string | null; isActive: boolean;
prices: ProductPrice[]; barcodes: ProductBarcode[]
}
export interface StockRow {
productId: string; productName: string; article: string | null; unitSymbol: string;
storeId: string; storeName: string;
quantity: number; reservedQuantity: number; available: number;
}
export interface MovementRow {
id: string; occurredAt: string;
productId: string; productName: string; article: string | null;
storeId: string; storeName: string;
quantity: number; unitCost: number | null;
type: string; documentType: string; documentId: string | null; documentNumber: string | null;
notes: string | null;
}
export const SupplyStatus = { Draft: 0, Posted: 1 } as const
export type SupplyStatus = (typeof SupplyStatus)[keyof typeof SupplyStatus]
export interface SupplyListRow {
id: string; number: string; date: string; status: SupplyStatus;
supplierId: string; supplierName: string;
storeId: string; storeName: string;
currencyId: string; currencyCode: string;
total: number; lineCount: number; postedAt: string | null;
}
export interface SupplyLineDto {
id: string | null; productId: string;
productName: string | null; productArticle: string | null; unitSymbol: string | null;
quantity: number; unitPrice: number; lineTotal: number; sortOrder: number;
}
export interface SupplyDto {
id: string; number: string; date: string; status: SupplyStatus;
supplierId: string; supplierName: string;
storeId: string; storeName: string;
currencyId: string; currencyCode: string;
supplierInvoiceNumber: string | null; supplierInvoiceDate: string | null;
notes: string | null;
total: number; postedAt: string | null;
lines: SupplyLineDto[];
}
export const RetailSaleStatus = { Draft: 0, Posted: 1 } as const
export type RetailSaleStatus = (typeof RetailSaleStatus)[keyof typeof RetailSaleStatus]
export const PaymentMethod = { Cash: 0, Card: 1, BankTransfer: 2, Bonus: 3, Mixed: 99 } as const
export type PaymentMethod = (typeof PaymentMethod)[keyof typeof PaymentMethod]
export interface RetailSaleListRow {
id: string; number: string; date: string; status: RetailSaleStatus;
storeId: string; storeName: string;
retailPointId: string | null; retailPointName: string | null;
customerId: string | null; customerName: string | null;
currencyId: string; currencyCode: string;
total: number; payment: PaymentMethod; lineCount: number;
postedAt: string | null;
}
export interface RetailSaleLineDto {
id: string | null; productId: string;
productName: string | null; productArticle: string | null; unitSymbol: string | null;
quantity: number; unitPrice: number; discount: number; lineTotal: number;
vatPercent: number; sortOrder: number;
}
export interface SalesStatsBucket {
bucket: string
revenue: number
transactions: number
}
export interface SalesStatsResponse {
revenueToday: number
revenueThisMonth: number
revenuePrevMonth: number
transactionsToday: number
transactionsThisMonth: number
avgTicketThisMonth: number
series: SalesStatsBucket[]
}
export interface RetailSaleDto {
id: string; number: string; date: string; status: RetailSaleStatus;
storeId: string; storeName: string;
retailPointId: string | null; retailPointName: string | null;
customerId: string | null; customerName: string | null;
currencyId: string; currencyCode: string;
subtotal: number; discountTotal: number; total: number;
payment: PaymentMethod; paidCash: number; paidCard: number;
notes: string | null; postedAt: string | null;
lines: RetailSaleLineDto[];
}

View file

@ -20,8 +20,8 @@ export const useCountries = () => useLookup<Country>('countries', '/api/catalog/
export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies')
export const useStores = () => useLookup<Store>('stores', '/api/catalog/stores')
export const usePriceTypes = () => useLookup<PriceType>('price-types', '/api/catalog/price-types')
export const useSuppliers = () => useQuery({
queryKey: ['lookup:suppliers'],
queryFn: async () => (await api.get<PagedResult<Counterparty>>('/api/catalog/counterparties?pageSize=500&kind=1')).data.items,
staleTime: 5 * 60 * 1000,
})
// MoySklad-style: контрагент один, может быть и поставщиком, и покупателем
// в разных документах. Не фильтруем по Kind — пользователь сам выбирает.
export const useCounterparties = () => useLookup<Counterparty>('counterparties', '/api/catalog/counterparties')
// Алиас для обратной совместимости со старым кодом форм Supply/RetailSale.
export const useSuppliers = useCounterparties

View file

@ -2,7 +2,7 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
@ -36,7 +36,9 @@ interface Form {
}
const blankForm: Form = {
name: '', legalName: '', kind: CounterpartyKind.Supplier, type: CounterpartyType.LegalEntity,
// Kind по умолчанию Unspecified — MoySklad не имеет такого поля у контрагентов,
// не выдумываем за пользователя. Пусть выберет вручную если нужно.
name: '', legalName: '', kind: CounterpartyKind.Unspecified, type: CounterpartyType.LegalEntity,
bin: '', iin: '', taxNumber: '', countryId: '',
address: '', phone: '', email: '',
bankName: '', bankAccount: '', bik: '',
@ -44,9 +46,10 @@ const blankForm: Form = {
}
const kindLabel: Record<CounterpartyKind, string> = {
[CounterpartyKind.Unspecified]: '—',
[CounterpartyKind.Supplier]: 'Поставщик',
[CounterpartyKind.Customer]: 'Покупатель',
[CounterpartyKind.Both]: 'Оба',
[CounterpartyKind.Both]: 'Поставщик + Покупатель',
}
export function CounterpartiesPage() {
@ -70,8 +73,8 @@ export function CounterpartiesPage() {
}
return (
<div className="p-6">
<PageHeader
<>
<ListPageShell
title="Контрагенты"
description="Поставщики и покупатели."
actions={
@ -80,30 +83,31 @@ export function CounterpartiesPage() {
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
/>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, legalName: r.legalName ?? '', kind: r.kind, type: r.type,
bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '',
countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '',
bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '',
contactPerson: r.contactPerson ?? '', notes: r.notes ?? '', isActive: r.isActive,
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: 'Тип', width: '120px', cell: (r) => kindLabel[r.kind] },
{ header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> },
{ header: 'Телефон', width: '160px', cell: (r) => r.phone ?? '—' },
{ header: 'Страна', width: '120px', cell: (r) => r.countryName ?? '—' },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, legalName: r.legalName ?? '', kind: r.kind, type: r.type,
bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '',
countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '',
bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '',
contactPerson: r.contactPerson ?? '', notes: r.notes ?? '', isActive: r.isActive,
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: 'Тип', width: '120px', cell: (r) => kindLabel[r.kind] },
{ header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> },
{ header: 'Телефон', width: '160px', cell: (r) => r.phone ?? '—' },
{ header: 'Страна', width: '120px', cell: (r) => r.countryName ?? '—' },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
</ListPageShell>
<Modal
open={!!form}
@ -137,9 +141,10 @@ export function CounterpartiesPage() {
</Field>
<Field label="Роль">
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as CounterpartyKind })}>
<option value={CounterpartyKind.Unspecified}>Не указано</option>
<option value={CounterpartyKind.Supplier}>Поставщик</option>
<option value={CounterpartyKind.Customer}>Покупатель</option>
<option value={CounterpartyKind.Both}>Оба</option>
<option value={CounterpartyKind.Both}>Поставщик + Покупатель</option>
</Select>
</Field>
<Field label="Тип лица">
@ -193,6 +198,6 @@ export function CounterpartiesPage() {
</div>
)}
</Modal>
</div>
</>
)
}

View file

@ -1,4 +1,4 @@
import { PageHeader } from '@/components/PageHeader'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
@ -9,11 +9,14 @@ export function CountriesPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Country>('/api/catalog/countries')
return (
<div className="p-6">
<PageHeader title="Страны" description="Глобальный справочник. По умолчанию Казахстан." actions={
<SearchBar value={search} onChange={setSearch} />
} />
<ListPageShell
title="Страны"
description="Глобальный справочник. По умолчанию Казахстан."
actions={<SearchBar value={search} onChange={setSearch} />}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
@ -24,8 +27,6 @@ export function CountriesPage() {
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
</div>
</ListPageShell>
)
}

View file

@ -1,4 +1,4 @@
import { PageHeader } from '@/components/PageHeader'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
@ -9,11 +9,14 @@ export function CurrenciesPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Currency>('/api/catalog/currencies')
return (
<div className="p-6">
<PageHeader title="Валюты" description="Доступные валюты для операций. Основная — тенге (KZT)." actions={
<SearchBar value={search} onChange={setSearch} />
} />
<ListPageShell
title="Валюты"
description="Доступные валюты для операций. Основная — тенге (KZT)."
actions={<SearchBar value={search} onChange={setSearch} />}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
@ -26,8 +29,6 @@ export function CurrenciesPage() {
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
</div>
</ListPageShell>
)
}

View file

@ -1,8 +1,9 @@
import { useQuery } from '@tanstack/react-query'
import { Package, Users, Warehouse, Store } from 'lucide-react'
import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { SalesChart } from '@/components/SalesChart'
import { api } from '@/lib/api'
import type { PagedResult } from '@/lib/types'
import type { PagedResult, SalesStatsResponse } from '@/lib/types'
interface MeResponse {
sub: string
@ -19,22 +20,54 @@ function useCount(url: string) {
})
}
interface StatCardProps {
const fmt = new Intl.NumberFormat('ru', { maximumFractionDigits: 0 })
const fmtMoney = (n: number) => fmt.format(n)
interface KpiCardProps {
icon: React.ComponentType<{ className?: string }>
label: string
value: number | string | undefined
isLoading: boolean
value: string | number
hint?: string
delta?: { value: number; positive: boolean }
}
function StatCard({ icon: Icon, label, value, isLoading }: StatCardProps) {
function KpiCard({ icon: Icon, label, value, hint, delta }: KpiCardProps) {
return (
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
<div className="flex items-start justify-between">
<div className="min-w-0">
<div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
<div className="mt-1 text-2xl font-semibold text-slate-900 dark:text-slate-100 truncate">
{value}
</div>
{hint && <div className="mt-0.5 text-xs text-slate-400">{hint}</div>}
</div>
<Icon className="w-5 h-5 text-slate-400 flex-shrink-0" />
</div>
{delta && (
<div className={`mt-2 inline-flex items-center gap-1 text-xs font-medium ${delta.positive ? 'text-green-600' : 'text-red-600'}`}>
{delta.positive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
{delta.positive ? '+' : ''}{delta.value.toFixed(1)}% к прошлому месяцу
</div>
)}
</div>
)
}
function MiniCard({ icon: Icon, label, value, isLoading }: {
icon: React.ComponentType<{ className?: string }>
label: string
value: number | undefined
isLoading: boolean
}) {
return (
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-500">{label}</span>
<span className="text-xs text-slate-500">{label}</span>
<Icon className="w-4 h-4 text-slate-400" />
</div>
<div className="text-2xl font-semibold mt-2 text-slate-900 dark:text-slate-100">
{isLoading ? '…' : value ?? '—'}
<div className="text-xl font-semibold mt-1.5 text-slate-900 dark:text-slate-100">
{isLoading ? '…' : value !== undefined ? fmt.format(value) : '—'}
</div>
</div>
)
@ -45,57 +78,90 @@ export function DashboardPage() {
queryKey: ['me'],
queryFn: async () => (await api.get<MeResponse>('/api/me')).data,
})
const stats = useQuery({
queryKey: ['/api/sales/retail/stats'],
queryFn: async () => (await api.get<SalesStatsResponse>('/api/sales/retail/stats?days=30')).data,
})
const products = useCount('/api/catalog/products')
const counterparties = useCount('/api/catalog/counterparties')
const stores = useCount('/api/catalog/stores')
const retailPoints = useCount('/api/catalog/retail-points')
const anyError = [products, counterparties, stores, retailPoints].find(q => q.error)?.error as Error | undefined
const monthDelta = stats.data && stats.data.revenuePrevMonth > 0
? ((stats.data.revenueThisMonth - stats.data.revenuePrevMonth) / stats.data.revenuePrevMonth) * 100
: null
const hasAnySales = stats.data && stats.data.series.some((b) => b.revenue > 0)
return (
<div className="p-6">
<div className="p-6 space-y-6 overflow-auto">
<PageHeader
title="Dashboard"
description={me.data ? `Добро пожаловать, ${me.data.name}` : 'Общие показатели системы'}
description={me.data ? `Добро пожаловать, ${me.data.name}` : 'Сводка по продажам и каталогу'}
/>
{anyError && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-900/50 p-3 text-sm text-amber-800 dark:text-amber-200">
<div className="font-medium">API недоступен или ещё не обновился</div>
<div className="text-amber-700 dark:text-amber-300 text-xs mt-0.5">
Перезапусти API после git pull: <code className="font-mono">Ctrl+C dotnet run --project src/food-market.api</code>
</div>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard icon={Package} label="Товаров" value={products.data} isLoading={products.isLoading} />
<StatCard icon={Users} label="Контрагентов" value={counterparties.data} isLoading={counterparties.isLoading} />
<StatCard icon={Warehouse} label="Складов" value={stores.data} isLoading={stores.isLoading} />
<StatCard icon={Store} label="Точек продаж" value={retailPoints.data} isLoading={retailPoints.isLoading} />
{/* KPI блок продажи */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<KpiCard
icon={Banknote}
label="Выручка сегодня"
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenueToday ?? 0)}`}
hint={`${stats.data?.transactionsToday ?? 0} чеков`}
/>
<KpiCard
icon={Calendar}
label="Выручка за месяц"
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenueThisMonth ?? 0)}`}
hint={`${stats.data?.transactionsThisMonth ?? 0} чеков`}
delta={monthDelta !== null ? { value: monthDelta, positive: monthDelta >= 0 } : undefined}
/>
<KpiCard
icon={Receipt}
label="Средний чек"
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.avgTicketThisMonth ?? 0)}`}
hint="за месяц"
/>
<KpiCard
icon={TrendingUp}
label="Прошлый месяц"
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenuePrevMonth ?? 0)}`}
hint="для сравнения"
/>
</div>
{me.data && (
<section className="mt-6 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100 mb-2.5">Текущий пользователь</h2>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
<div><dt className="text-slate-500 inline">Имя: </dt><dd className="inline text-slate-900 dark:text-slate-100 font-medium">{me.data.name}</dd></div>
<div><dt className="text-slate-500 inline">Email: </dt><dd className="inline">{me.data.email}</dd></div>
<div><dt className="text-slate-500 inline">Роли: </dt><dd className="inline">{me.data.roles.join(', ')}</dd></div>
<div><dt className="text-slate-500 inline">Организация: </dt><dd className="inline font-mono text-xs">{me.data.orgId}</dd></div>
</dl>
</section>
)}
<section className="mt-6 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-6">
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100 mb-2">Что дальше</h2>
<ul className="text-sm text-slate-600 dark:text-slate-300 space-y-1.5 list-disc list-inside">
<li>Phase 2: приёмка товара, розничные продажи, складские остатки</li>
<li>Phase 3: инвентаризация, списание, оприходование, возвраты поставщикам</li>
<li>Phase 4: ценники, отчёты P&L, бонусная система, аудит</li>
<li>Phase 5: Windows-касса + синхронизация + весы</li>
</ul>
{/* График продаж */}
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">Выручка за 30 дней</h2>
<p className="text-xs text-slate-500 mt-0.5">Сумма продаж по дням, проведённые чеки</p>
</div>
</div>
{stats.isLoading ? (
<div className="h-72 flex items-center justify-center text-slate-400 text-sm">Загрузка</div>
) : !hasAnySales ? (
<div className="h-72 flex flex-col items-center justify-center text-slate-400 text-sm gap-2">
<Receipt className="w-8 h-8 text-slate-300" />
<div>Чеков пока нет.</div>
<div className="text-xs">График появится когда появятся первые продажи.</div>
</div>
) : (
<SalesChart series={stats.data!.series} currencyCode="₸" />
)}
</section>
{/* Каталог */}
<div>
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3 uppercase tracking-wide">
Каталог
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<MiniCard icon={Package} label="Товаров" value={products.data} isLoading={products.isLoading} />
<MiniCard icon={Users} label="Контрагентов" value={counterparties.data} isLoading={counterparties.isLoading} />
<MiniCard icon={Warehouse} label="Складов" value={stores.data} isLoading={stores.isLoading} />
<MiniCard icon={Store} label="Точек продаж" value={retailPoints.data} isLoading={retailPoints.isLoading} />
</div>
</div>
</div>
)
}

View file

@ -33,9 +33,9 @@ export function LoginPage() {
onSubmit={handleSubmit}
className="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 space-y-5"
>
<div className="space-y-3">
<Logo size={44} />
<p className="text-sm text-slate-500">Вход в систему</p>
<div>
<Logo />
<p className="text-sm text-slate-500 mt-2.5">Вход в систему</p>
</div>
<label className="block space-y-1.5">

View file

@ -0,0 +1,175 @@
import { useState } from 'react'
import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package } from 'lucide-react'
import { AxiosError } from 'axios'
import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/Button'
import { Field, TextInput, Checkbox } from '@/components/Field'
function formatError(err: unknown): string {
if (err instanceof AxiosError) {
const status = err.response?.status
const body = err.response?.data as { error?: string; error_description?: string; title?: string } | undefined
const detail = body?.error ?? body?.error_description ?? body?.title
if (status === 404) {
return '404 Not Found — эндпоинт не существует. Вероятно, API не перезапущен после git pull.'
}
if (status === 401) return '401 Unauthorized — сессия истекла, перелогинься.'
if (status === 403) return '403 Forbidden — нужна роль Admin или SuperAdmin.'
if (status === 502 || status === 503) return `${status} — МойСклад недоступен, попробуй позже.`
return detail ? `${status ?? ''} ${detail}` : err.message
}
if (err instanceof Error) return err.message
return String(err)
}
interface TestResponse { organization: string; inn?: string | null }
interface ImportResponse {
total: number; created: number; skipped: number; groupsCreated: number; errors: string[]
}
export function MoySkladImportPage() {
const qc = useQueryClient()
const [token, setToken] = useState('')
const [overwrite, setOverwrite] = useState(false)
const test = useMutation({
mutationFn: async () => (await api.post<TestResponse>('/api/admin/moysklad/test', { token })).data,
})
const products = useMutation({
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-products', { token, overwriteExisting: overwrite })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }),
})
const counterparties = useMutation({
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-counterparties', { token, overwriteExisting: overwrite })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/counterparties'] }),
})
return (
<div className="h-full overflow-auto">
<div className="p-6 max-w-3xl">
<PageHeader
title="Импорт из МойСклад"
description="Перенос товаров, групп, контрагентов из учётной записи МойСклад в food-market."
/>
<section className="mb-5 rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-900/50 p-3.5 text-sm">
<div className="flex gap-2.5 items-start">
<AlertCircle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="text-amber-900 dark:text-amber-200 space-y-1.5">
<p><strong>Токен не сохраняется</strong> передаётся только в текущий запрос и не пишется ни в БД, ни в логи.</p>
<p>Получить токен: <a className="underline" href="https://online.moysklad.ru/app/#admin" target="_blank" rel="noreferrer">online.moysklad.ru/app</a> Настройки аккаунта Доступ к API создать токен.</p>
<p>Рекомендуется отдельный сервисный аккаунт с правом только на чтение.</p>
</div>
</div>
</section>
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
<Field label="Токен МойСклад (Bearer)">
<TextInput
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="персональный токен или токен сервисного аккаунта"
autoComplete="off"
spellCheck={false}
/>
</Field>
<div className="flex gap-3 items-center flex-wrap">
<Button
variant="secondary"
onClick={() => test.mutate()}
disabled={!token || test.isPending}
>
<KeyRound className="w-4 h-4" />
{test.isPending ? 'Проверяю…' : 'Проверить соединение'}
</Button>
{test.data && (
<div className="text-sm text-green-700 dark:text-green-400 flex items-center gap-1.5">
<CheckCircle className="w-4 h-4" />
Подключено: <strong>{test.data.organization}</strong>
{test.data.inn && <span className="text-slate-500">(ИНН {test.data.inn})</span>}
</div>
)}
{test.error && <div className="text-sm text-red-600">{formatError(test.error)}</div>}
</div>
</section>
<section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Операции импорта</h2>
<Checkbox
label="Перезаписать существующие записи (по артикулу/имени)"
checked={overwrite}
onChange={setOverwrite}
/>
<div className="flex gap-3 flex-wrap">
<Button onClick={() => products.mutate()} disabled={!token || products.isPending}>
<Package className="w-4 h-4" />
{products.isPending ? 'Импортирую товары…' : 'Товары + группы + цены'}
</Button>
<Button onClick={() => counterparties.mutate()} disabled={!token || counterparties.isPending}>
<Download className="w-4 h-4" />
<Users className="w-4 h-4" />
{counterparties.isPending ? 'Импортирую контрагентов…' : 'Контрагенты'}
</Button>
</div>
</section>
<ImportResult title="Товары" result={products} />
<ImportResult title="Контрагенты" result={counterparties} />
</div>
</div>
)
}
function ImportResult({ title, result }: { title: string; result: UseMutationResult<ImportResponse, Error, void, unknown> }) {
if (!result.data && !result.error) return null
return (
<section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
<h3 className="text-sm font-semibold mb-3 text-slate-900 dark:text-slate-100 flex items-center gap-2">
{result.data
? <><CheckCircle className="w-4 h-4 text-green-600" /> {title} импорт завершён</>
: <><AlertCircle className="w-4 h-4 text-red-600" /> {title} ошибка</>}
</h3>
{result.data && (
<>
<dl className="grid grid-cols-4 gap-3 text-sm">
<StatBox label="Всего получено" value={result.data.total} />
<StatBox label="Создано" value={result.data.created} accent="green" />
<StatBox label="Пропущено" value={result.data.skipped} />
<StatBox label="Групп создано" value={result.data.groupsCreated} />
</dl>
{result.data.errors.length > 0 && (
<details className="mt-4">
<summary className="text-sm text-red-600 cursor-pointer">
Ошибок: {result.data.errors.length} (развернуть)
</summary>
<ul className="mt-2 text-xs font-mono bg-red-50 dark:bg-red-950/30 p-3 rounded space-y-0.5 max-h-80 overflow-auto">
{result.data.errors.map((e, i) => <li key={i}>{e}</li>)}
</ul>
</details>
)}
</>
)}
{result.error && (
<div className="text-sm text-red-700 dark:text-red-300">{formatError(result.error)}</div>
)}
</section>
)
}
function StatBox({ label, value, accent }: { label: string; value: number; accent?: 'green' }) {
const bg = accent === 'green' ? 'bg-green-50 dark:bg-green-950/30' : 'bg-slate-50 dark:bg-slate-800/50'
const fg = accent === 'green' ? 'text-green-700 dark:text-green-400' : ''
return (
<div className={`rounded-lg ${bg} p-3`}>
<dt className={`text-xs uppercase ${accent === 'green' ? fg : 'text-slate-500'}`}>{label}</dt>
<dd className={`text-xl font-semibold mt-1 ${fg}`}>{value.toLocaleString('ru')}</dd>
</div>
)
}

View file

@ -1,6 +1,6 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
@ -29,8 +29,8 @@ export function PriceTypesPage() {
}
return (
<div className="p-6">
<PageHeader
<>
<ListPageShell
title="Типы цен"
description="Розничная, оптовая и другие ценовые группы."
actions={
@ -39,23 +39,24 @@ export function PriceTypesPage() {
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
/>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({ ...r })}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: 'Розничная', width: '120px', cell: (r) => r.isRetail ? '✓' : '—' },
{ header: 'По умолчанию', width: '140px', cell: (r) => r.isDefault ? '✓' : '—' },
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({ ...r })}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: 'Розничная', width: '120px', cell: (r) => r.isRetail ? '✓' : '—' },
{ header: 'По умолчанию', width: '140px', cell: (r) => r.isDefault ? '✓' : '—' },
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
</ListPageShell>
<Modal
open={!!form}
@ -95,6 +96,6 @@ export function PriceTypesPage() {
</div>
)}
</Modal>
</div>
</>
)
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect, type FormEvent } from 'react'
import { useState, useEffect, type FormEvent, type ReactNode } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react'
@ -93,7 +93,6 @@ export function ProductEditPage() {
}, [isNew, existing.data])
useEffect(() => {
// Pre-fill defaults for new product
if (isNew && form.vatRateId === '' && vats.data?.length) {
setForm((f) => ({ ...f, vatRateId: vats.data?.find(v => v.isDefault)?.id ?? vats.data?.[0]?.id ?? '' }))
}
@ -169,21 +168,30 @@ export function ProductEditPage() {
const updateBarcode = (i: number, patch: Partial<BarcodeRow>) =>
setForm({ ...form, barcodes: form.barcodes.map((b, ix) => ix === i ? { ...b, ...patch } : b) })
const canSave = form.name.trim().length > 0 && !!form.unitOfMeasureId && !!form.vatRateId
return (
<form onSubmit={onSubmit} className="p-6 max-w-5xl">
<div className="flex items-center justify-between gap-4 mb-5">
<div className="flex items-center gap-3">
<Link to="/catalog/products" className="text-slate-400 hover:text-slate-600">
<form onSubmit={onSubmit} className="flex flex-col h-full">
{/* Sticky top bar */}
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
<div className="flex items-center gap-3 min-w-0">
<Link
to="/catalog/products"
className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 flex-shrink-0"
title="Назад к списку"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
<div className="min-w-0">
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новый товар' : form.name || 'Товар'}
</h1>
<p className="text-sm text-slate-500">Справочник товаров и услуг</p>
<p className="text-xs text-slate-500">
{isNew ? 'Создание новой позиции каталога' : 'Редактирование'}
</p>
</div>
</div>
<div className="flex gap-2">
<div className="flex gap-2 flex-shrink-0">
{!isNew && (
<Button
type="button"
@ -194,177 +202,198 @@ export function ProductEditPage() {
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button type="submit" disabled={!form.name || !form.unitOfMeasureId || !form.vatRateId}>
<Save className="w-4 h-4" /> Сохранить
<Button type="submit" disabled={!canSave || save.isPending}>
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
</Button>
</div>
</div>
{error && (
<div className="mb-4 p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
)}
<div className="space-y-5">
<Section title="Основное">
<div className="grid grid-cols-3 gap-3">
<Field label="Название *" className="col-span-2">
<TextInput required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Артикул">
<TextInput value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
</Field>
</div>
<Field label="Описание">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
</Section>
<Section title="Классификация">
<div className="grid grid-cols-3 gap-3">
<Field label="Единица измерения *">
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
<option value=""></option>
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.symbol} {u.name}</option>)}
</Select>
</Field>
<Field label="Ставка НДС *">
<Select required value={form.vatRateId} onChange={(e) => setForm({ ...form, vatRateId: e.target.value })}>
<option value=""></option>
{vats.data?.map((v) => <option key={v.id} value={v.id}>{v.name} ({v.percent}%)</option>)}
</Select>
</Field>
<Field label="Группа">
<Select value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}>
<option value=""></option>
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}
</Select>
</Field>
<Field label="Страна происхождения">
<Select value={form.countryOfOriginId} onChange={(e) => setForm({ ...form, countryOfOriginId: e.target.value })}>
<option value=""></option>
{countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
<Field label="Основной поставщик">
<Select value={form.defaultSupplierId} onChange={(e) => setForm({ ...form, defaultSupplierId: e.target.value })}>
<option value=""></option>
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
<Field label="URL изображения">
<TextInput value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-5 gap-3 pt-1">
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
<Checkbox label="Весовой" checked={form.isWeighed} onChange={(v) => setForm({ ...form, isWeighed: v })} />
<Checkbox label="Алкоголь" checked={form.isAlcohol} onChange={(v) => setForm({ ...form, isAlcohol: v })} />
<Checkbox label="Маркируемый" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
</Section>
<Section title="Остатки и закупка">
<div className="grid grid-cols-4 gap-3">
<Field label="Мин. остаток">
<TextInput type="number" step="0.001" value={form.minStock} onChange={(e) => setForm({ ...form, minStock: e.target.value })} />
</Field>
<Field label="Макс. остаток">
<TextInput type="number" step="0.001" value={form.maxStock} onChange={(e) => setForm({ ...form, maxStock: e.target.value })} />
</Field>
<Field label="Закупочная цена">
<TextInput type="number" step="0.01" value={form.purchasePrice} onChange={(e) => setForm({ ...form, purchasePrice: e.target.value })} />
</Field>
<Field label="Валюта закупки">
<Select value={form.purchaseCurrencyId} onChange={(e) => setForm({ ...form, purchaseCurrencyId: e.target.value })}>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</Field>
</div>
</Section>
<Section title="Цены продажи"
action={<Button type="button" variant="secondary" size="sm" onClick={addPrice}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
>
{form.prices.length === 0 ? (
<div className="text-sm text-slate-400">Цен ещё нет. Добавь хотя бы розничную.</div>
) : (
<div className="space-y-2">
{form.prices.map((p, i) => (
<div key={i} className="grid grid-cols-[2fr_1fr_1fr_40px] gap-2 items-end">
<Field label={i === 0 ? 'Тип цены' : ''}>
<Select value={p.priceTypeId} onChange={(e) => updatePrice(i, { priceTypeId: e.target.value })}>
{priceTypes.data?.map((pt) => <option key={pt.id} value={pt.id}>{pt.name}</option>)}
</Select>
</Field>
<Field label={i === 0 ? 'Сумма' : ''}>
<TextInput type="number" step="0.01" value={p.amount} onChange={(e) => updatePrice(i, { amount: Number(e.target.value) })} />
</Field>
<Field label={i === 0 ? 'Валюта' : ''}>
<Select value={p.currencyId} onChange={(e) => updatePrice(i, { currencyId: e.target.value })}>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</Field>
<button type="button" onClick={() => removePrice(i)} className="text-slate-400 hover:text-red-600 pb-2">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-auto">
<div className="max-w-5xl mx-auto p-6 space-y-5">
{error && (
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
)}
</Section>
<Section title="Штрихкоды"
action={<Button type="button" variant="secondary" size="sm" onClick={addBarcode}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
>
{form.barcodes.length === 0 ? (
<div className="text-sm text-slate-400">Штрихкодов нет.</div>
) : (
<div className="space-y-2">
{form.barcodes.map((b, i) => (
<div key={i} className="grid grid-cols-[2fr_1fr_auto_40px] gap-2 items-end">
<Field label={i === 0 ? 'Код' : ''}>
<TextInput value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} />
</Field>
<Field label={i === 0 ? 'Тип' : ''}>
<Select value={b.type} onChange={(e) => updateBarcode(i, { type: Number(e.target.value) as BarcodeType })}>
<option value={BarcodeType.Ean13}>EAN-13</option>
<option value={BarcodeType.Ean8}>EAN-8</option>
<option value={BarcodeType.Code128}>CODE 128</option>
<option value={BarcodeType.Code39}>CODE 39</option>
<option value={BarcodeType.Upca}>UPC-A</option>
<option value={BarcodeType.Upce}>UPC-E</option>
<option value={BarcodeType.Other}>Прочий</option>
</Select>
</Field>
<div className="pb-2">
<Checkbox label="Основной" checked={b.isPrimary} onChange={(v) => {
// Enforce single primary
setForm({ ...form, barcodes: form.barcodes.map((x, ix) => ({ ...x, isPrimary: ix === i ? v : false })) })
}} />
<Section title="Основное">
<Grid cols={3}>
<Field label="Название *" className="col-span-2">
<TextInput required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Артикул">
<TextInput value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
</Field>
<Field label="Описание" className="col-span-3">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
</Grid>
</Section>
<Section title="Классификация">
<Grid cols={3}>
<Field label="Единица измерения *">
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
<option value=""></option>
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.symbol} {u.name}</option>)}
</Select>
</Field>
<Field label="Ставка НДС *">
<Select required value={form.vatRateId} onChange={(e) => setForm({ ...form, vatRateId: e.target.value })}>
<option value=""></option>
{vats.data?.map((v) => <option key={v.id} value={v.id}>{v.name} ({v.percent}%)</option>)}
</Select>
</Field>
<Field label="Группа">
<Select value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}>
<option value=""></option>
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}
</Select>
</Field>
<Field label="Страна происхождения">
<Select value={form.countryOfOriginId} onChange={(e) => setForm({ ...form, countryOfOriginId: e.target.value })}>
<option value=""></option>
{countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
<Field label="Основной поставщик">
<Select value={form.defaultSupplierId} onChange={(e) => setForm({ ...form, defaultSupplierId: e.target.value })}>
<option value=""></option>
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
<Field label="URL изображения">
<TextInput value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
</Field>
</Grid>
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2">
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
<Checkbox label="Весовой" checked={form.isWeighed} onChange={(v) => setForm({ ...form, isWeighed: v })} />
<Checkbox label="Алкоголь" checked={form.isAlcohol} onChange={(v) => setForm({ ...form, isAlcohol: v })} />
<Checkbox label="Маркируемый" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
</Section>
<Section title="Остатки и закупка">
<Grid cols={4}>
<Field label="Мин. остаток">
<TextInput type="number" step="0.001" value={form.minStock} onChange={(e) => setForm({ ...form, minStock: e.target.value })} />
</Field>
<Field label="Макс. остаток">
<TextInput type="number" step="0.001" value={form.maxStock} onChange={(e) => setForm({ ...form, maxStock: e.target.value })} />
</Field>
<Field label="Закупочная цена">
<TextInput type="number" step="0.01" value={form.purchasePrice} onChange={(e) => setForm({ ...form, purchasePrice: e.target.value })} />
</Field>
<Field label="Валюта закупки">
<Select value={form.purchaseCurrencyId} onChange={(e) => setForm({ ...form, purchaseCurrencyId: e.target.value })}>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</Field>
</Grid>
</Section>
<Section
title="Цены продажи"
action={<Button type="button" variant="secondary" size="sm" onClick={addPrice}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
>
{form.prices.length === 0 ? (
<div className="text-sm text-slate-400 py-2">Цен ещё нет. Добавь хотя бы розничную.</div>
) : (
<div className="space-y-2">
{form.prices.map((p, i) => (
<div key={i} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-6">
<Select value={p.priceTypeId} onChange={(e) => updatePrice(i, { priceTypeId: e.target.value })}>
{priceTypes.data?.map((pt) => <option key={pt.id} value={pt.id}>{pt.name}</option>)}
</Select>
</div>
<div className="col-span-3">
<TextInput type="number" step="0.01" value={p.amount} onChange={(e) => updatePrice(i, { amount: Number(e.target.value) })} />
</div>
<div className="col-span-2">
<Select value={p.currencyId} onChange={(e) => updatePrice(i, { currencyId: e.target.value })}>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</div>
<button
type="button"
onClick={() => removePrice(i)}
className="col-span-1 text-slate-400 hover:text-red-600 flex justify-center"
title="Удалить строку"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<button type="button" onClick={() => removeBarcode(i)} className="text-slate-400 hover:text-red-600 pb-2">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</Section>
))}
</div>
)}
</Section>
<Section
title="Штрихкоды"
action={<Button type="button" variant="secondary" size="sm" onClick={addBarcode}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
>
{form.barcodes.length === 0 ? (
<div className="text-sm text-slate-400 py-2">Штрихкодов нет.</div>
) : (
<div className="space-y-2">
{form.barcodes.map((b, i) => (
<div key={i} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-6">
<TextInput placeholder="Код" value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} />
</div>
<div className="col-span-3">
<Select value={b.type} onChange={(e) => updateBarcode(i, { type: Number(e.target.value) as BarcodeType })}>
<option value={BarcodeType.Ean13}>EAN-13</option>
<option value={BarcodeType.Ean8}>EAN-8</option>
<option value={BarcodeType.Code128}>CODE 128</option>
<option value={BarcodeType.Code39}>CODE 39</option>
<option value={BarcodeType.Upca}>UPC-A</option>
<option value={BarcodeType.Upce}>UPC-E</option>
<option value={BarcodeType.Other}>Прочий</option>
</Select>
</div>
<div className="col-span-2">
<Checkbox
label="Основной"
checked={b.isPrimary}
onChange={(v) => setForm({ ...form, barcodes: form.barcodes.map((x, ix) => ({ ...x, isPrimary: ix === i ? v : false })) })}
/>
</div>
<button
type="button"
onClick={() => removeBarcode(i)}
className="col-span-1 text-slate-400 hover:text-red-600 flex justify-center"
title="Удалить строку"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</Section>
</div>
</div>
</form>
)
}
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
function Section({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
return (
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
<div className="flex items-center justify-between mb-3">
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<header className="flex items-center justify-between px-5 py-3 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
{action}
</div>
<div className="space-y-3">{children}</div>
</header>
<div className="p-5">{children}</div>
</section>
)
}
function Grid({ cols, children }: { cols: 2 | 3 | 4; children: ReactNode }) {
const cls = cols === 2 ? 'grid-cols-1 md:grid-cols-2' : cols === 3 ? 'grid-cols-1 md:grid-cols-3' : 'grid-cols-2 md:grid-cols-4'
return <div className={`grid ${cls} gap-x-4 gap-y-3`}>{children}</div>
}

View file

@ -1,6 +1,6 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
@ -29,8 +29,8 @@ export function ProductGroupsPage() {
}
return (
<div className="p-6">
<PageHeader
<>
<ListPageShell
title="Группы товаров"
description="Иерархический справочник категорий."
actions={
@ -39,22 +39,23 @@ export function ProductGroupsPage() {
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
/>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, isActive: r.isActive })}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: 'Путь', cell: (r) => <span className="text-slate-500">{r.path}</span> },
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, isActive: r.isActive })}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: 'Путь', cell: (r) => <span className="text-slate-500">{r.path}</span> },
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
</ListPageShell>
<Modal
open={!!form}
@ -103,6 +104,6 @@ export function ProductGroupsPage() {
</div>
)}
</Modal>
</div>
</>
)
}

View file

@ -1,5 +1,5 @@
import { Link, useNavigate } from 'react-router-dom'
import { PageHeader } from '@/components/PageHeader'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
@ -15,22 +15,23 @@ export function ProductsPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL)
return (
<div className="p-6">
<PageHeader
title="Товары"
description="Каталог товаров и услуг."
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" />
<Link to="/catalog/products/new">
<Button>
<Plus className="w-4 h-4" /> Добавить
</Button>
</Link>
</>
}
/>
<ListPageShell
title="Товары"
description={data ? `${data.total.toLocaleString('ru')} записей в каталоге` : 'Каталог товаров и услуг'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" />
<Link to="/catalog/products/new">
<Button>
<Plus className="w-4 h-4" /> Добавить
</Button>
</Link>
</>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
@ -43,10 +44,10 @@ export function ProductsPage() {
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
</div>
)},
{ header: 'Группа', width: '180px', cell: (r) => r.productGroupName ?? '—' },
{ header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' },
{ header: 'Ед.', width: '70px', cell: (r) => r.unitSymbol },
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vatPercent}%` },
{ header: 'Тип', width: '120px', cell: (r) => (
{ header: 'Тип', width: '140px', cell: (r) => (
<div className="flex gap-1 flex-wrap">
{r.isService && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">Услуга</span>}
{r.isWeighed && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Весовой</span>}
@ -59,8 +60,6 @@ export function ProductsPage() {
]}
empty="Товаров ещё нет. Они появятся после приёмки или через API."
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
</div>
</ListPageShell>
)
}

View file

@ -2,7 +2,7 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
@ -53,8 +53,8 @@ export function RetailPointsPage() {
const firstStore = stores.data?.[0]?.id ?? ''
return (
<div className="p-6">
<PageHeader
<>
<ListPageShell
title="Точки продаж"
description="Кассовые точки. Привязаны к складу, с которого идут продажи."
actions={
@ -65,29 +65,30 @@ export function RetailPointsPage() {
</Button>
</>
}
/>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, code: r.code ?? '', storeId: r.storeId,
address: r.address ?? '', phone: r.phone ?? '',
fiscalSerial: r.fiscalSerial ?? '', fiscalRegNumber: r.fiscalRegNumber ?? '',
isActive: r.isActive,
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: 'Код', width: '120px', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> },
{ header: 'Склад', cell: (r) => r.storeName ?? '—' },
{ header: 'Адрес', cell: (r) => r.address ?? '—' },
{ header: 'ККМ', width: '140px', cell: (r) => r.fiscalSerial ?? '—' },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, code: r.code ?? '', storeId: r.storeId,
address: r.address ?? '', phone: r.phone ?? '',
fiscalSerial: r.fiscalSerial ?? '', fiscalRegNumber: r.fiscalRegNumber ?? '',
isActive: r.isActive,
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: 'Код', width: '120px', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> },
{ header: 'Склад', cell: (r) => r.storeName ?? '—' },
{ header: 'Адрес', cell: (r) => r.address ?? '—' },
{ header: 'ККМ', width: '140px', cell: (r) => r.fiscalSerial ?? '—' },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
</ListPageShell>
<Modal
open={!!form}
@ -145,6 +146,6 @@ export function RetailPointsPage() {
</div>
)}
</Modal>
</div>
</>
)
}

View file

@ -0,0 +1,393 @@
import { useState, useEffect, type FormEvent, type ReactNode } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select } from '@/components/Field'
import { ProductPicker } from '@/components/ProductPicker'
import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups'
import { RetailSaleStatus, PaymentMethod, type RetailSaleDto, type Product } from '@/lib/types'
interface LineRow {
id?: string
productId: string
productName: string
productArticle: string | null
unitSymbol: string | null
quantity: number
unitPrice: number
discount: number
vatPercent: number
}
interface Form {
date: string
storeId: string
retailPointId: string
customerId: string
currencyId: string
payment: PaymentMethod
paidCash: number
paidCard: number
notes: string
lines: LineRow[]
}
const todayIso = () => new Date().toISOString().slice(0, 16)
const empty: Form = {
date: todayIso(),
storeId: '', retailPointId: '', customerId: '', currencyId: '',
payment: PaymentMethod.Cash, paidCash: 0, paidCard: 0,
notes: '', lines: [],
}
export function RetailSaleEditPage() {
const { id } = useParams<{ id: string }>()
const isNew = !id || id === 'new'
const navigate = useNavigate()
const qc = useQueryClient()
const stores = useStores()
const currencies = useCurrencies()
const customers = useSuppliers() // we re-use the suppliers hook (returns all counterparties) — fine for MVP
const [form, setForm] = useState<Form>(empty)
const [pickerOpen, setPickerOpen] = useState(false)
const [error, setError] = useState<string | null>(null)
const existing = useQuery({
queryKey: ['/api/sales/retail', id],
queryFn: async () => (await api.get<RetailSaleDto>(`/api/sales/retail/${id}`)).data,
enabled: !isNew,
})
useEffect(() => {
if (!isNew && existing.data) {
const s = existing.data
setForm({
date: s.date.slice(0, 16),
storeId: s.storeId,
retailPointId: s.retailPointId ?? '',
customerId: s.customerId ?? '',
currencyId: s.currencyId,
payment: s.payment,
paidCash: s.paidCash,
paidCard: s.paidCard,
notes: s.notes ?? '',
lines: s.lines.map((l) => ({
id: l.id ?? undefined,
productId: l.productId,
productName: l.productName ?? '',
productArticle: l.productArticle,
unitSymbol: l.unitSymbol,
quantity: l.quantity, unitPrice: l.unitPrice, discount: l.discount,
vatPercent: l.vatPercent,
})),
})
}
}, [isNew, existing.data])
useEffect(() => {
if (isNew) {
if (!form.storeId && stores.data?.length) {
setForm((f) => ({ ...f, storeId: stores.data!.find((s) => s.isMain)?.id ?? stores.data![0].id }))
}
if (!form.currencyId && currencies.data?.length) {
setForm((f) => ({ ...f, currencyId: currencies.data!.find((c) => c.code === 'KZT')?.id ?? currencies.data![0].id }))
}
}
}, [isNew, stores.data, currencies.data, form.storeId, form.currencyId])
const isDraft = isNew || existing.data?.status === RetailSaleStatus.Draft
const isPosted = existing.data?.status === RetailSaleStatus.Posted
const lineTotal = (l: LineRow) => l.quantity * l.unitPrice - l.discount
const subtotal = form.lines.reduce((s, l) => s + l.quantity * l.unitPrice, 0)
const discountTotal = form.lines.reduce((s, l) => s + l.discount, 0)
const grandTotal = subtotal - discountTotal
const save = useMutation({
mutationFn: async () => {
const payload = {
date: new Date(form.date).toISOString(),
storeId: form.storeId,
retailPointId: form.retailPointId || null,
customerId: form.customerId || null,
currencyId: form.currencyId,
payment: form.payment,
paidCash: Number(form.paidCash),
paidCard: Number(form.paidCard),
notes: form.notes || null,
lines: form.lines.map((l) => ({
productId: l.productId,
quantity: l.quantity,
unitPrice: l.unitPrice,
discount: l.discount,
vatPercent: l.vatPercent,
})),
}
if (isNew) {
return (await api.post<RetailSaleDto>('/api/sales/retail', payload)).data
}
await api.put(`/api/sales/retail/${id}`, payload)
return null
},
onSuccess: (created) => {
qc.invalidateQueries({ queryKey: ['/api/sales/retail'] })
navigate(created ? `/sales/retail/${created.id}` : `/sales/retail/${id}`)
},
onError: (e: Error) => setError(e.message),
})
const post = useMutation({
mutationFn: async () => { await api.post(`/api/sales/retail/${id}/post`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/sales/retail'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch()
},
onError: (e: Error) => setError(e.message),
})
const unpost = useMutation({
mutationFn: async () => { await api.post(`/api/sales/retail/${id}/unpost`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/sales/retail'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch()
},
onError: (e: Error) => setError(e.message),
})
const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/sales/retail/${id}`) },
onSuccess: () => navigate('/sales/retail'),
onError: (e: Error) => setError(e.message),
})
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }
const addLineFromProduct = (p: Product) => {
const retail = p.prices.find((x) => x.priceTypeName?.toLowerCase().includes('розн')) ?? p.prices[0]
setForm({
...form,
lines: [...form.lines, {
productId: p.id,
productName: p.name,
productArticle: p.article,
unitSymbol: p.unitSymbol,
quantity: 1,
unitPrice: retail?.amount ?? 0,
discount: 0,
vatPercent: p.vatPercent,
}],
})
}
const updateLine = (i: number, patch: Partial<LineRow>) =>
setForm({ ...form, lines: form.lines.map((l, ix) => ix === i ? { ...l, ...patch } : l) })
const removeLine = (i: number) =>
setForm({ ...form, lines: form.lines.filter((_, ix) => ix !== i) })
const canSave = !!form.storeId && !!form.currencyId && isDraft
return (
<form onSubmit={onSubmit} className="flex flex-col h-full">
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
<div className="flex items-center gap-3 min-w-0">
<Link to="/sales/retail" className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 flex-shrink-0">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="min-w-0">
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новый чек' : existing.data?.number ?? 'Чек'}
</h1>
<p className="text-xs text-slate-500">
{isPosted
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
: 'Черновик — товар не списывается со склада до проведения'}
</p>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
{isPosted && (
<Button type="button" variant="secondary" onClick={() => unpost.mutate()} disabled={unpost.isPending}>
<Undo2 className="w-4 h-4" /> Отменить
</Button>
)}
{isDraft && !isNew && (
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик?')) remove.mutate() }}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
{isDraft && (
<Button type="submit" variant="secondary" disabled={!canSave || save.isPending}>
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
</Button>
)}
{isDraft && !isNew && (
<Button type="button" onClick={() => post.mutate()} disabled={post.isPending || form.lines.length === 0}>
<CheckCircle className="w-4 h-4" /> {post.isPending ? 'Провожу…' : 'Провести'}
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-auto">
<div className="max-w-6xl mx-auto p-6 space-y-5">
{error && <div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>}
<Section title="Реквизиты чека">
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
<Field label="Дата/время">
<TextInput type="datetime-local" value={form.date} disabled={isPosted}
onChange={(e) => setForm({ ...form, date: e.target.value })} />
</Field>
<Field label="Магазин *">
<Select value={form.storeId} disabled={isPosted}
onChange={(e) => setForm({ ...form, storeId: e.target.value })}>
<option value=""></option>
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</Field>
<Field label="Валюта *">
<Select value={form.currencyId} disabled={isPosted}
onChange={(e) => setForm({ ...form, currencyId: e.target.value })}>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</Field>
<Field label="Покупатель (опц.)">
<Select value={form.customerId} disabled={isPosted}
onChange={(e) => setForm({ ...form, customerId: e.target.value })}>
<option value=""> анонимный </option>
{customers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
<Field label="Способ оплаты">
<Select value={form.payment} disabled={isPosted}
onChange={(e) => setForm({ ...form, payment: Number(e.target.value) as PaymentMethod })}>
<option value={PaymentMethod.Cash}>Наличные</option>
<option value={PaymentMethod.Card}>Карта</option>
<option value={PaymentMethod.BankTransfer}>Банковский перевод</option>
<option value={PaymentMethod.Bonus}>Бонусы</option>
<option value={PaymentMethod.Mixed}>Смешанная</option>
</Select>
</Field>
<Field label="Получено наличными">
<TextInput type="number" step="0.01" value={form.paidCash} disabled={isPosted}
onChange={(e) => setForm({ ...form, paidCash: Number(e.target.value) })} />
</Field>
<Field label="Получено картой">
<TextInput type="number" step="0.01" value={form.paidCard} disabled={isPosted}
onChange={(e) => setForm({ ...form, paidCard: Number(e.target.value) })} />
</Field>
<Field label="Примечание" className="md:col-span-3">
<TextArea rows={2} value={form.notes} disabled={isPosted}
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</Field>
</div>
</Section>
<Section
title="Позиции"
action={!isPosted && (
<Button type="button" variant="secondary" size="sm" onClick={() => setPickerOpen(true)}>
<Plus className="w-3.5 h-3.5" /> Добавить товар
</Button>
)}
>
{form.lines.length === 0 ? (
<div className="text-sm text-slate-400 py-4 text-center">Пусто. Добавь хотя бы одну позицию.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 text-left">
<th className="py-2 pr-3 font-medium text-xs uppercase tracking-wide text-slate-500">Товар</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[80px]">Ед.</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[120px] text-right">Кол-во</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[120px] text-right">Цена</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[110px] text-right">Скидка</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[140px] text-right">Сумма</th>
<th className="py-2 pl-3 w-[40px]"></th>
</tr>
</thead>
<tbody>
{form.lines.map((l, i) => (
<tr key={i} className="border-b border-slate-100 dark:border-slate-800">
<td className="py-2 pr-3">
<div className="font-medium">{l.productName}</div>
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
</td>
<td className="py-2 px-3 text-slate-500">{l.unitSymbol}</td>
<td className="py-2 px-3">
<TextInput type="number" step="0.001" disabled={isPosted}
className="text-right font-mono" value={l.quantity}
onChange={(e) => updateLine(i, { quantity: Number(e.target.value) })} />
</td>
<td className="py-2 px-3">
<TextInput type="number" step="0.01" disabled={isPosted}
className="text-right font-mono" value={l.unitPrice}
onChange={(e) => updateLine(i, { unitPrice: Number(e.target.value) })} />
</td>
<td className="py-2 px-3">
<TextInput type="number" step="0.01" disabled={isPosted}
className="text-right font-mono" value={l.discount}
onChange={(e) => updateLine(i, { discount: Number(e.target.value) })} />
</td>
<td className="py-2 px-3 text-right font-mono font-semibold">
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}
</td>
<td className="py-2 pl-3">
{!isPosted && (
<button type="button" onClick={() => removeLine(i)} className="text-slate-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr><td colSpan={4} className="py-2 pr-3 text-right text-sm text-slate-500">Подытог:</td>
<td className="py-2 px-3 text-right text-sm text-slate-500"></td>
<td className="py-2 px-3 text-right font-mono">{subtotal.toLocaleString('ru', { maximumFractionDigits: 2 })}</td>
<td/></tr>
<tr><td colSpan={4} className="py-1 pr-3 text-right text-sm text-slate-500">Скидка:</td>
<td className="py-1 px-3"></td>
<td className="py-1 px-3 text-right font-mono text-red-600">{discountTotal.toLocaleString('ru', { maximumFractionDigits: 2 })}</td>
<td/></tr>
<tr><td colSpan={4} className="py-3 pr-3 text-right text-sm font-semibold text-slate-700 dark:text-slate-200">К оплате:</td>
<td/>
<td className="py-3 px-3 text-right font-mono text-lg font-bold">
{grandTotal.toLocaleString('ru', { maximumFractionDigits: 2 })}{' '}
<span className="text-sm text-slate-500">{currencies.data?.find((c) => c.id === form.currencyId)?.code ?? ''}</span>
</td><td/></tr>
</tfoot>
</table>
</div>
)}
</Section>
</div>
</div>
<ProductPicker open={pickerOpen} onClose={() => setPickerOpen(false)} onPick={addLineFromProduct} />
</form>
)
}
function Section({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
return (
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<header className="flex items-center justify-between px-5 py-3 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
{action}
</header>
<div className="p-5">{children}</div>
</section>
)
}

View file

@ -0,0 +1,65 @@
import { Link, useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { useCatalogList } from '@/lib/useCatalog'
import { type RetailSaleListRow, RetailSaleStatus, PaymentMethod } from '@/lib/types'
const URL = '/api/sales/retail'
const paymentLabel: Record<number, string> = {
[PaymentMethod.Cash]: 'Наличные',
[PaymentMethod.Card]: 'Карта',
[PaymentMethod.BankTransfer]: 'Перевод',
[PaymentMethod.Bonus]: 'Бонусы',
[PaymentMethod.Mixed]: 'Смешанная',
}
export function RetailSalesPage() {
const navigate = useNavigate()
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<RetailSaleListRow>(URL)
return (
<ListPageShell
title="Розничные продажи"
description={data ? `${data.total.toLocaleString('ru')} чеков` : 'Чеки розничных продаж'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По номеру чека…" />
<Link to="/sales/retail/new">
<Button><Plus className="w-4 h-4" /> Новый чек</Button>
</Link>
</>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => navigate(`/sales/retail/${r.id}`)}
columns={[
{ header: '№', width: '160px', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Дата/время', width: '160px', cell: (r) => new Date(r.date).toLocaleString('ru') },
{ header: 'Статус', width: '120px', cell: (r) => (
r.status === RetailSaleStatus.Posted
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
)},
{ header: 'Магазин', cell: (r) => r.storeName },
{ header: 'Касса', width: '160px', cell: (r) => r.retailPointName ?? '—' },
{ header: 'Покупатель', width: '180px', cell: (r) => r.customerName ?? <span className="text-slate-400">аноним</span> },
{ header: 'Оплата', width: '120px', cell: (r) => paymentLabel[r.payment] ?? '?' },
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Сумма', width: '160px', className: 'text-right font-mono font-semibold', cell: (r) => `${r.total.toLocaleString('ru', { maximumFractionDigits: 2 })} ${r.currencyCode}` },
]}
empty="Чеков пока нет. На Phase 5 здесь начнут падать продажи с касс. Сейчас можно создать вручную для теста."
/>
</ListPageShell>
)
}

View file

@ -0,0 +1,81 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { Select } from '@/components/Field'
import { api } from '@/lib/api'
import { useStores } from '@/lib/useLookups'
import type { PagedResult, MovementRow } from '@/lib/types'
const typeLabels: Record<string, string> = {
Initial: 'Начальный',
Supply: 'Приёмка',
RetailSale: 'Розн. продажа',
WholesaleSale: 'Опт. продажа',
CustomerReturn: 'Возврат покуп.',
SupplierReturn: 'Возврат пост.',
TransferOut: 'Перемещ. из',
TransferIn: 'Перемещ. в',
WriteOff: 'Списание',
Enter: 'Оприходование',
InventoryAdjustment: 'Инвентаризация',
}
export function StockMovementsPage() {
const stores = useStores()
const [storeId, setStoreId] = useState('')
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery({
queryKey: ['/api/inventory/movements', { storeId, page }],
queryFn: async () => {
const params = new URLSearchParams({ page: String(page), pageSize: '50' })
if (storeId) params.set('storeId', storeId)
return (await api.get<PagedResult<MovementRow>>(`/api/inventory/movements?${params}`)).data
},
placeholderData: (prev) => prev,
})
return (
<ListPageShell
title="Движения"
description={data ? `${data.total.toLocaleString('ru')} операций в журнале` : 'Журнал всех изменений остатков'}
actions={
<div className="w-52">
<Select value={storeId} onChange={(e) => { setStoreId(e.target.value); setPage(1) }}>
<option value="">Все склады</option>
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</div>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
columns={[
{ header: 'Дата', width: '160px', cell: (r) => new Date(r.occurredAt).toLocaleString('ru') },
{ header: 'Операция', width: '160px', cell: (r) => typeLabels[r.type] ?? r.type },
{ header: 'Товар', cell: (r) => (
<div>
<div className="font-medium">{r.productName}</div>
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
</div>
)},
{ header: 'Склад', width: '180px', cell: (r) => r.storeName },
{ header: 'Количество', width: '140px', className: 'text-right font-mono', cell: (r) => (
<span className={r.quantity > 0 ? 'text-green-700' : r.quantity < 0 ? 'text-red-600' : ''}>
{r.quantity > 0 ? '+' : ''}{r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 })}
</span>
)},
{ header: 'Документ', width: '200px', cell: (r) => r.documentNumber ? <span className="text-slate-500">{r.documentType} · {r.documentNumber}</span> : <span className="text-slate-400"></span> },
]}
empty="Движений ещё нет."
/>
</ListPageShell>
)
}

View file

@ -0,0 +1,76 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Select, Checkbox } from '@/components/Field'
import { api } from '@/lib/api'
import { useStores } from '@/lib/useLookups'
import type { PagedResult, StockRow } from '@/lib/types'
export function StockPage() {
const stores = useStores()
const [storeId, setStoreId] = useState('')
const [search, setSearch] = useState('')
const [includeZero, setIncludeZero] = useState(false)
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery({
queryKey: ['/api/inventory/stock', { storeId, search, includeZero, page }],
queryFn: async () => {
const params = new URLSearchParams({ page: String(page), pageSize: '50' })
if (storeId) params.set('storeId', storeId)
if (search) params.set('search', search)
if (includeZero) params.set('includeZero', 'true')
return (await api.get<PagedResult<StockRow>>(`/api/inventory/stock?${params}`)).data
},
placeholderData: (prev) => prev,
})
return (
<ListPageShell
title="Остатки"
description={data ? `${data.total.toLocaleString('ru')} позиций${storeId ? ' на выбранном складе' : ' по всем складам'}` : 'Текущие остатки по складам'}
actions={
<div className="flex items-center gap-3">
<div className="w-52">
<Select value={storeId} onChange={(e) => { setStoreId(e.target.value); setPage(1) }}>
<option value="">Все склады</option>
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</div>
<Checkbox label="Показать нулевые" checked={includeZero} onChange={(v) => { setIncludeZero(v); setPage(1) }} />
<SearchBar value={search} onChange={(v) => { setSearch(v); setPage(1) }} placeholder="По названию или артикулу…" />
</div>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => `${r.productId}:${r.storeId}`}
columns={[
{ header: 'Товар', cell: (r) => (
<div>
<div className="font-medium">{r.productName}</div>
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
</div>
)},
{ header: 'Склад', width: '220px', cell: (r) => r.storeName },
{ header: 'Ед.', width: '80px', cell: (r) => r.unitSymbol },
{ header: 'Остаток', width: '130px', className: 'text-right font-mono', cell: (r) => r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 }) },
{ header: 'Резерв', width: '110px', className: 'text-right font-mono text-slate-400', cell: (r) => r.reservedQuantity ? r.reservedQuantity.toLocaleString('ru') : '—' },
{ header: 'Доступно', width: '130px', className: 'text-right font-mono font-semibold', cell: (r) => (
<span className={r.available < 0 ? 'text-red-600' : r.available === 0 ? 'text-slate-400' : ''}>
{r.available.toLocaleString('ru', { maximumFractionDigits: 3 })}
</span>
)},
]}
empty="Остатков нет. Они появятся после первой приёмки (Phase 2b)."
/>
</ListPageShell>
)
}

View file

@ -1,6 +1,6 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
@ -43,8 +43,8 @@ export function StoresPage() {
}
return (
<div className="p-6">
<PageHeader
<>
<ListPageShell
title="Склады"
description="Физические места хранения товара."
actions={
@ -53,28 +53,29 @@ export function StoresPage() {
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
/>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, code: r.code ?? '', kind: r.kind,
address: r.address ?? '', phone: r.phone ?? '', managerName: r.managerName ?? '',
isMain: r.isMain, isActive: r.isActive,
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: 'Код', width: '120px', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> },
{ header: 'Тип', width: '150px', cell: (r) => r.kind === StoreKind.Warehouse ? 'Склад' : 'Торговый зал' },
{ header: 'Адрес', cell: (r) => r.address ?? '—' },
{ header: 'Основной', width: '110px', cell: (r) => r.isMain ? '✓' : '—' },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, code: r.code ?? '', kind: r.kind,
address: r.address ?? '', phone: r.phone ?? '', managerName: r.managerName ?? '',
isMain: r.isMain, isActive: r.isActive,
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: 'Код', width: '120px', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> },
{ header: 'Тип', width: '150px', cell: (r) => r.kind === StoreKind.Warehouse ? 'Склад' : 'Торговый зал' },
{ header: 'Адрес', cell: (r) => r.address ?? '—' },
{ header: 'Основной', width: '110px', cell: (r) => r.isMain ? '✓' : '—' },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
</ListPageShell>
<Modal
open={!!form}
@ -129,6 +130,6 @@ export function StoresPage() {
</div>
)}
</Modal>
</div>
</>
)
}

View file

@ -0,0 +1,55 @@
import { Link, useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { useCatalogList } from '@/lib/useCatalog'
import { type SupplyListRow, SupplyStatus } from '@/lib/types'
const URL = '/api/purchases/supplies'
export function SuppliesPage() {
const navigate = useNavigate()
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<SupplyListRow>(URL)
return (
<ListPageShell
title="Приёмки от поставщиков"
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Документы поступления товара от поставщиков'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По номеру или поставщику…" />
<Link to="/purchases/supplies/new">
<Button><Plus className="w-4 h-4" /> Новая приёмка</Button>
</Link>
</>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => navigate(`/purchases/supplies/${r.id}`)}
columns={[
{ header: '№', width: '160px', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Дата', width: '120px', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
{ header: 'Статус', width: '130px', cell: (r) => (
r.status === SupplyStatus.Posted
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
)},
{ header: 'Поставщик', cell: (r) => r.supplierName },
{ header: 'Склад', width: '180px', cell: (r) => r.storeName },
{ header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', cell: (r) => `${r.total.toLocaleString('ru', { maximumFractionDigits: 2 })} ${r.currencyCode}` },
]}
empty="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения."
/>
</ListPageShell>
)
}

View file

@ -0,0 +1,370 @@
import { useState, useEffect, type FormEvent, type ReactNode } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select } from '@/components/Field'
import { ProductPicker } from '@/components/ProductPicker'
import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups'
import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types'
interface LineRow {
id?: string
productId: string
productName: string
productArticle: string | null
unitSymbol: string | null
quantity: number
unitPrice: number
}
interface Form {
date: string
supplierId: string
storeId: string
currencyId: string
supplierInvoiceNumber: string
supplierInvoiceDate: string
notes: string
lines: LineRow[]
}
const todayIso = () => new Date().toISOString().slice(0, 10)
const emptyForm: Form = {
date: todayIso(),
supplierId: '', storeId: '', currencyId: '',
supplierInvoiceNumber: '', supplierInvoiceDate: '',
notes: '',
lines: [],
}
export function SupplyEditPage() {
const { id } = useParams<{ id: string }>()
const isNew = !id || id === 'new'
const navigate = useNavigate()
const qc = useQueryClient()
const stores = useStores()
const currencies = useCurrencies()
const suppliers = useSuppliers()
const [form, setForm] = useState<Form>(emptyForm)
const [pickerOpen, setPickerOpen] = useState(false)
const [error, setError] = useState<string | null>(null)
const existing = useQuery({
queryKey: ['/api/purchases/supplies', id],
queryFn: async () => (await api.get<SupplyDto>(`/api/purchases/supplies/${id}`)).data,
enabled: !isNew,
})
useEffect(() => {
if (!isNew && existing.data) {
const s = existing.data
setForm({
date: s.date.slice(0, 10),
supplierId: s.supplierId,
storeId: s.storeId,
currencyId: s.currencyId,
supplierInvoiceNumber: s.supplierInvoiceNumber ?? '',
supplierInvoiceDate: s.supplierInvoiceDate ? s.supplierInvoiceDate.slice(0, 10) : '',
notes: s.notes ?? '',
lines: s.lines.map((l) => ({
id: l.id ?? undefined,
productId: l.productId,
productName: l.productName ?? '',
productArticle: l.productArticle,
unitSymbol: l.unitSymbol,
quantity: l.quantity,
unitPrice: l.unitPrice,
})),
})
}
}, [isNew, existing.data])
useEffect(() => {
// Prefill defaults for new document.
if (isNew) {
if (!form.storeId && stores.data?.length) {
const main = stores.data.find((s) => s.isMain) ?? stores.data[0]
setForm((f) => ({ ...f, storeId: main.id }))
}
if (!form.currencyId && currencies.data?.length) {
const kzt = currencies.data.find((c) => c.code === 'KZT') ?? currencies.data[0]
setForm((f) => ({ ...f, currencyId: kzt.id }))
}
if (!form.supplierId && suppliers.data?.length) {
setForm((f) => ({ ...f, supplierId: suppliers.data![0].id }))
}
}
}, [isNew, stores.data, currencies.data, suppliers.data, form.storeId, form.currencyId, form.supplierId])
const isDraft = isNew || existing.data?.status === SupplyStatus.Draft
const isPosted = existing.data?.status === SupplyStatus.Posted
const lineTotal = (l: LineRow) => l.quantity * l.unitPrice
const grandTotal = form.lines.reduce((s, l) => s + lineTotal(l), 0)
const save = useMutation({
mutationFn: async () => {
const payload = {
date: new Date(form.date).toISOString(),
supplierId: form.supplierId,
storeId: form.storeId,
currencyId: form.currencyId,
supplierInvoiceNumber: form.supplierInvoiceNumber || null,
supplierInvoiceDate: form.supplierInvoiceDate ? new Date(form.supplierInvoiceDate).toISOString() : null,
notes: form.notes || null,
lines: form.lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.unitPrice })),
}
if (isNew) {
return (await api.post<SupplyDto>('/api/purchases/supplies', payload)).data
}
await api.put(`/api/purchases/supplies/${id}`, payload)
return null
},
onSuccess: (created) => {
qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] })
navigate(created ? `/purchases/supplies/${created.id}` : `/purchases/supplies/${id}`)
},
onError: (e: Error) => setError(e.message),
})
const post = useMutation({
mutationFn: async () => { await api.post(`/api/purchases/supplies/${id}/post`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch()
},
onError: (e: Error) => setError(e.message),
})
const unpost = useMutation({
mutationFn: async () => { await api.post(`/api/purchases/supplies/${id}/unpost`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch()
},
onError: (e: Error) => setError(e.message),
})
const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/purchases/supplies/${id}`) },
onSuccess: () => navigate('/purchases/supplies'),
onError: (e: Error) => setError(e.message),
})
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }
const addLineFromProduct = (p: Product) => {
setForm({
...form,
lines: [...form.lines, {
productId: p.id,
productName: p.name,
productArticle: p.article,
unitSymbol: p.unitSymbol,
quantity: 1,
unitPrice: p.purchasePrice ?? 0,
}],
})
}
const updateLine = (i: number, patch: Partial<LineRow>) =>
setForm({ ...form, lines: form.lines.map((l, ix) => ix === i ? { ...l, ...patch } : l) })
const removeLine = (i: number) =>
setForm({ ...form, lines: form.lines.filter((_, ix) => ix !== i) })
const canSave = !!form.supplierId && !!form.storeId && !!form.currencyId && isDraft
return (
<form onSubmit={onSubmit} className="flex flex-col h-full">
{/* Sticky top bar */}
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
<div className="flex items-center gap-3 min-w-0">
<Link to="/purchases/supplies" className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 flex-shrink-0">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="min-w-0">
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новая приёмка' : existing.data?.number ?? 'Приёмка'}
</h1>
<p className="text-xs text-slate-500">
{isPosted
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
: 'Черновик — товар не попадает на склад, пока не проведёшь'}
</p>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
{isPosted && (
<Button type="button" variant="secondary" onClick={() => unpost.mutate()} disabled={unpost.isPending}>
<Undo2 className="w-4 h-4" /> Отменить проведение
</Button>
)}
{isDraft && !isNew && (
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик приёмки?')) remove.mutate() }}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
{isDraft && (
<Button type="submit" variant="secondary" disabled={!canSave || save.isPending}>
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
</Button>
)}
{isDraft && !isNew && (
<Button type="button" onClick={() => post.mutate()} disabled={post.isPending || form.lines.length === 0}>
<CheckCircle className="w-4 h-4" /> {post.isPending ? 'Провожу…' : 'Провести'}
</Button>
)}
</div>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-auto">
<div className="max-w-6xl mx-auto p-6 space-y-5">
{error && (
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
)}
<Section title="Реквизиты документа">
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
<Field label="Дата">
<TextInput type="date" value={form.date} disabled={isPosted}
onChange={(e) => setForm({ ...form, date: e.target.value })} />
</Field>
<Field label="Поставщик *">
<Select value={form.supplierId} disabled={isPosted}
onChange={(e) => setForm({ ...form, supplierId: e.target.value })}>
<option value=""></option>
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
<Field label="Склад *">
<Select value={form.storeId} disabled={isPosted}
onChange={(e) => setForm({ ...form, storeId: e.target.value })}>
<option value=""></option>
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</Field>
<Field label="Валюта *">
<Select value={form.currencyId} disabled={isPosted}
onChange={(e) => setForm({ ...form, currencyId: e.target.value })}>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</Field>
<Field label="№ накладной поставщика">
<TextInput value={form.supplierInvoiceNumber} disabled={isPosted}
onChange={(e) => setForm({ ...form, supplierInvoiceNumber: e.target.value })} />
</Field>
<Field label="Дата накладной">
<TextInput type="date" value={form.supplierInvoiceDate} disabled={isPosted}
onChange={(e) => setForm({ ...form, supplierInvoiceDate: e.target.value })} />
</Field>
<Field label="Примечание" className="md:col-span-3">
<TextArea rows={2} value={form.notes} disabled={isPosted}
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</Field>
</div>
</Section>
<Section
title="Позиции"
action={!isPosted && (
<Button type="button" variant="secondary" size="sm" onClick={() => setPickerOpen(true)}>
<Plus className="w-3.5 h-3.5" /> Добавить товар
</Button>
)}
>
{form.lines.length === 0 ? (
<div className="text-sm text-slate-400 py-4 text-center">Позиций нет. Нажми «Добавить товар».</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left">
<tr className="border-b border-slate-200 dark:border-slate-700">
<th className="py-2 pr-3 font-medium text-xs uppercase tracking-wide text-slate-500">Товар</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[90px]">Ед.</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[140px] text-right">Количество</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[140px] text-right">Цена</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[160px] text-right">Сумма</th>
<th className="py-2 pl-3 w-[40px]"></th>
</tr>
</thead>
<tbody>
{form.lines.map((l, i) => (
<tr key={i} className="border-b border-slate-100 dark:border-slate-800">
<td className="py-2 pr-3">
<div className="font-medium">{l.productName}</div>
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
</td>
<td className="py-2 px-3 text-slate-500">{l.unitSymbol}</td>
<td className="py-2 px-3">
<TextInput type="number" step="0.001" disabled={isPosted}
className="text-right font-mono"
value={l.quantity}
onChange={(e) => updateLine(i, { quantity: Number(e.target.value) })} />
</td>
<td className="py-2 px-3">
<TextInput type="number" step="0.01" disabled={isPosted}
className="text-right font-mono"
value={l.unitPrice}
onChange={(e) => updateLine(i, { unitPrice: Number(e.target.value) })} />
</td>
<td className="py-2 px-3 text-right font-mono font-semibold">
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}
</td>
<td className="py-2 pl-3">
{!isPosted && (
<button type="button" onClick={() => removeLine(i)} className="text-slate-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={4} className="py-3 pr-3 text-right text-sm font-semibold text-slate-600 dark:text-slate-300">
Итого:
</td>
<td className="py-3 px-3 text-right font-mono text-lg font-bold">
{grandTotal.toLocaleString('ru', { maximumFractionDigits: 2 })}
{' '}
<span className="text-sm text-slate-500">
{currencies.data?.find((c) => c.id === form.currencyId)?.code ?? ''}
</span>
</td>
<td />
</tr>
</tfoot>
</table>
</div>
)}
</Section>
</div>
</div>
<ProductPicker open={pickerOpen} onClose={() => setPickerOpen(false)} onPick={addLineFromProduct} />
</form>
)
}
function Section({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
return (
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<header className="flex items-center justify-between px-5 py-3 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
{action}
</header>
<div className="p-5">{children}</div>
</section>
)
}

View file

@ -1,6 +1,6 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
@ -38,8 +38,8 @@ export function UnitsOfMeasurePage() {
}
return (
<div className="p-6">
<PageHeader
<>
<ListPageShell
title="Единицы измерения"
description="Код по ОКЕИ (шт=796, кг=166, л=112, м=006)."
actions={
@ -48,24 +48,25 @@ export function UnitsOfMeasurePage() {
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
/>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({ ...r })}
columns={[
{ header: 'Код', width: '90px', cell: (r) => <span className="font-mono">{r.code}</span> },
{ header: 'Символ', width: '100px', cell: (r) => r.symbol },
{ header: 'Название', cell: (r) => r.name },
{ header: 'Дробность', width: '120px', className: 'text-right', cell: (r) => r.decimalPlaces },
{ header: 'Базовая', width: '100px', cell: (r) => r.isBase ? '✓' : '—' },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({ ...r })}
columns={[
{ header: 'Код', width: '90px', cell: (r) => <span className="font-mono">{r.code}</span> },
{ header: 'Символ', width: '100px', cell: (r) => r.symbol },
{ header: 'Название', cell: (r) => r.name },
{ header: 'Дробность', width: '120px', className: 'text-right', cell: (r) => r.decimalPlaces },
{ header: 'Базовая', width: '100px', cell: (r) => r.isBase ? '✓' : '—' },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
</ListPageShell>
<Modal
open={!!form}
@ -113,6 +114,6 @@ export function UnitsOfMeasurePage() {
</div>
)}
</Modal>
</div>
</>
)
}

View file

@ -1,6 +1,6 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
@ -40,8 +40,8 @@ export function VatRatesPage() {
}
return (
<div className="p-6">
<PageHeader
<>
<ListPageShell
title="Ставки НДС"
description="Настройки ставок налога на добавленную стоимость."
actions={
@ -50,26 +50,27 @@ export function VatRatesPage() {
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
/>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, percent: r.percent,
isIncludedInPrice: r.isIncludedInPrice, isDefault: r.isDefault, isActive: r.isActive,
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: '%', width: '90px', className: 'text-right font-mono', cell: (r) => r.percent.toFixed(2) },
{ header: 'В цене', width: '100px', cell: (r) => r.isIncludedInPrice ? '✓' : '—' },
{ header: 'По умолчанию', width: '140px', cell: (r) => r.isDefault ? '✓' : '—' },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, percent: r.percent,
isIncludedInPrice: r.isIncludedInPrice, isDefault: r.isDefault, isActive: r.isActive,
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: '%', width: '90px', className: 'text-right font-mono', cell: (r) => r.percent.toFixed(2) },
{ header: 'В цене', width: '100px', cell: (r) => r.isIncludedInPrice ? '✓' : '—' },
{ header: 'По умолчанию', width: '140px', cell: (r) => r.isDefault ? '✓' : '—' },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
</ListPageShell>
<Modal
open={!!form}
@ -110,6 +111,6 @@ export function VatRatesPage() {
</div>
)}
</Modal>
</div>
</>
)
}