Compare commits

..

2 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
67 changed files with 760 additions and 4377 deletions

View file

@ -1 +0,0 @@
{"sessionId":"791848bb-ad06-4ccc-9c5a-13da4ee36524","pid":353363,"acquiredAt":1776883641374}

View file

@ -1,82 +0,0 @@
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: 127.0.0.1:5001/mirror/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
# dotnet 8 SDK is pre-installed on the self-hosted runner host.
- name: Dotnet version
run: dotnet --version
- 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
# node 20 + pnpm are pre-installed on the self-hosted runner host.
- name: Node + pnpm version
run: node --version && pnpm --version
- name: Install
run: pnpm install --frozen-lockfile
- name: Build (tsc + vite)
run: pnpm build
# POS build requires Windows — no Forgejo runner for it; skipped silently.
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
- name: Build POS
run: |
dotnet restore src/food-market.pos/food-market.pos.csproj
dotnet build src/food-market.pos/food-market.pos.csproj --no-restore -c Release
dotnet publish src/food-market.pos/food-market.pos.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o publish

View file

@ -1,115 +0,0 @@
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/**'
- '.forgejo/workflows/docker.yml'
workflow_dispatch:
env:
LOCAL_REGISTRY: 127.0.0.1:5001
jobs:
api:
name: API image
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Build + push api
env:
SHA: ${{ github.sha }}
run: |
docker build -f deploy/Dockerfile.api \
-t $LOCAL_REGISTRY/food-market-api:$SHA \
-t $LOCAL_REGISTRY/food-market-api:latest .
for tag in $SHA latest; do
docker push $LOCAL_REGISTRY/food-market-api:$tag
done
web:
name: Web image
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Build + push web
env:
SHA: ${{ github.sha }}
run: |
docker build -f deploy/Dockerfile.web \
-t $LOCAL_REGISTRY/food-market-web:$SHA \
-t $LOCAL_REGISTRY/food-market-web:latest .
for tag in $SHA latest; do
docker push $LOCAL_REGISTRY/food-market-web:$tag
done
deploy-stage:
name: Deploy stage
runs-on: [self-hosted, linux]
needs: [api, web]
steps:
- uses: actions/checkout@v4
- name: Write .env + copy compose (runner = stage host)
env:
SHA: ${{ github.sha }}
PGPASS: ${{ secrets.STAGE_POSTGRES_PASSWORD }}
run: |
cat > /home/nns/food-market-stage/deploy/.env <<ENV
REGISTRY=127.0.0.1:5001
API_TAG=$SHA
WEB_TAG=$SHA
POSTGRES_PASSWORD=$PGPASS
ENV
cp deploy/docker-compose.yml /home/nns/food-market-stage/deploy/docker-compose.yml
- name: docker compose pull + up
working-directory: /home/nns/food-market-stage/deploy
run: |
docker compose pull
docker compose up -d --remove-orphans
- name: Smoke /health
run: |
for i in 1 2 3 4 5 6; do
sleep 5
if curl -fsS http://127.0.0.1:8080/health | grep -q '"status":"ok"'; then
echo "Health OK"
exit 0
fi
done
echo "Health failed"
exit 1
- name: Notify Telegram on success
if: success()
env:
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
SHA: ${{ github.sha }}
run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=✅ stage deployed — ${SHA:0:7} → https://food-market.zat.kz" \
> /dev/null
- name: Notify Telegram on failure
if: failure()
env:
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
SHA: ${{ github.sha }}
run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=❌ stage deploy FAILED — ${SHA:0:7}" \
> /dev/null

View file

@ -1,18 +0,0 @@
name: Notify CI failures
on:
workflow_run:
workflows: ["CI", "Docker Images"]
types: [completed]
jobs:
telegram:
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
runs-on: [self-hosted, linux]
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

View file

@ -1,5 +1,4 @@
ARG LOCAL_REGISTRY=127.0.0.1:5001
FROM ${LOCAL_REGISTRY}/mirror/dotnet-sdk:8.0 AS build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY food-market.sln global.json Directory.Build.props Directory.Packages.props ./
@ -16,7 +15,7 @@ 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 ${LOCAL_REGISTRY}/mirror/dotnet-aspnet:8.0 AS runtime
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl \

View file

@ -1,5 +1,4 @@
ARG LOCAL_REGISTRY=127.0.0.1:5001
FROM ${LOCAL_REGISTRY}/mirror/node:20-alpine AS build
FROM node:20-alpine AS build
WORKDIR /src
RUN corepack enable
@ -10,7 +9,7 @@ RUN pnpm install --frozen-lockfile
COPY src/food-market.web/ ./
RUN pnpm build
FROM ${LOCAL_REGISTRY}/mirror/nginx:1.27-alpine AS runtime
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

View file

@ -1,6 +1,6 @@
services:
postgres:
image: ${REGISTRY:-127.0.0.1:5001}/mirror/postgres:16-alpine
image: postgres:16-alpine
container_name: food-market-postgres
restart: unless-stopped
environment:
@ -54,4 +54,3 @@ volumes:
name: food-market-api-data
api-logs:
name: food-market-api-logs

View file

@ -1,19 +0,0 @@
[Unit]
Description=Local Docker Registry for food-market
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=simple
ExecStartPre=-/usr/bin/docker rm -f food-market-registry
ExecStart=/usr/bin/docker run --rm --name food-market-registry \
-p 127.0.0.1:5001:5000 \
-v /opt/food-market-data/docker-registry:/var/lib/registry \
-e REGISTRY_STORAGE_DELETE_ENABLED=true \
registry:2
ExecStop=/usr/bin/docker stop food-market-registry
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View file

@ -1,11 +0,0 @@
[Unit]
Description=Mirror docker base images into local 127.0.0.1:5001 registry
Requires=food-market-registry.service
After=food-market-registry.service docker.service
[Service]
Type=oneshot
User=nns
ExecStart=/usr/local/bin/food-market-mirror-base-images.sh
StandardOutput=append:/var/log/food-market-mirror-base-images.log
StandardError=append:/var/log/food-market-mirror-base-images.log

View file

@ -1,11 +0,0 @@
[Unit]
Description=Refresh docker base image mirrors daily
[Timer]
OnBootSec=10min
OnUnitActiveSec=24h
Unit=food-market-mirror-base-images.service
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -1,27 +0,0 @@
services:
forgejo:
image: codeberg.org/forgejo/forgejo:7
container_name: food-market-forgejo
restart: unless-stopped
environment:
USER_UID: "1000"
USER_GID: "1000"
FORGEJO__server__DOMAIN: git.zat.kz
FORGEJO__server__ROOT_URL: https://git.zat.kz/
FORGEJO__server__SSH_DOMAIN: git.zat.kz
FORGEJO__server__SSH_PORT: "2222"
FORGEJO__server__SSH_LISTEN_PORT: "22"
FORGEJO__server__START_SSH_SERVER: "false"
FORGEJO__server__DISABLE_SSH: "false"
FORGEJO__service__DISABLE_REGISTRATION: "true"
FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false"
FORGEJO__actions__ENABLED: "true"
FORGEJO__database__DB_TYPE: sqlite3
FORGEJO__log__LEVEL: Info
volumes:
- /opt/food-market-data/forgejo/data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "127.0.0.1:3000:3000" # HTTP, fronted by nginx on git.zat.kz
- "2222:22" # SSH for git clone/push via ssh://git@git.zat.kz:2222/...

View file

@ -1,7 +0,0 @@
[Unit]
Description=Push Forgejo food-market into GitHub (backup)
[Service]
Type=oneshot
User=nns
ExecStart=/usr/local/bin/food-market-forgejo-mirror.sh

View file

@ -1,10 +0,0 @@
[Unit]
Description=Mirror Forgejo -> GitHub every 10 min
[Timer]
OnBootSec=3min
OnUnitActiveSec=10min
Unit=food-market-forgejo-mirror.service
[Install]
WantedBy=timers.target

View file

@ -1,15 +0,0 @@
[Unit]
Description=food-market Forgejo (primary git)
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=oneshot
RemainAfterExit=true
WorkingDirectory=/home/nns/food-market/deploy/forgejo
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose stop
User=nns
[Install]
WantedBy=multi-user.target

View file

@ -1,40 +0,0 @@
#!/bin/bash
# Mirrors our Forgejo repo into GitHub. Best-effort: if the push fails (flaky
# KZ TCP to github.com), the next tick will retry.
set -euo pipefail
MIRROR_DIR="/opt/food-market-data/forgejo/mirror"
FORGEJO_URL="http://127.0.0.1:3000/nns/food-market.git"
GITHUB_URL="https://github.com/nurdotnet/food-market.git"
GITHUB_TOKEN_FILE="/etc/food-market/github-mirror-token" # 40-char PAT with repo scope
LOG_FILE="/var/log/food-market-forgejo-mirror.log"
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$LOG_FILE"; }
if [[ ! -f $GITHUB_TOKEN_FILE ]]; then
log "token file $GITHUB_TOKEN_FILE missing — skipping mirror push"
exit 0
fi
TOKEN=$(tr -d '\n' < "$GITHUB_TOKEN_FILE")
if [[ ! -d $MIRROR_DIR/objects ]]; then
log "bootstrap: cloning $FORGEJO_URL$MIRROR_DIR"
rm -rf "$MIRROR_DIR"
git clone --mirror "$FORGEJO_URL" "$MIRROR_DIR" >> "$LOG_FILE" 2>&1
fi
cd "$MIRROR_DIR"
# Pull latest from Forgejo (source of truth).
if ! git remote update --prune >> "$LOG_FILE" 2>&1; then
log "forgejo fetch failed — aborting this tick"
exit 0
fi
# Push everything to GitHub, timeout generously (big pushes on flaky link).
GIT_HTTP_LOW_SPEED_LIMIT=1000 \
GIT_HTTP_LOW_SPEED_TIME=60 \
timeout 300 git push --prune "https://x-access-token:$TOKEN@github.com/nurdotnet/food-market.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' >> "$LOG_FILE" 2>&1 \
&& log "pushed to github ok" \
|| log "github push failed (exit=$?), will retry next tick"

View file

@ -1,22 +0,0 @@
server {
listen 80;
server_name git.zat.kz;
location /.well-known/acme-challenge/ { root /var/www/html; }
# Forgejo can serve large pushes; allow big request bodies.
client_max_body_size 512M;
location / {
proxy_pass http://127.0.0.1:3000;
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_buffering off;
proxy_request_buffering off;
proxy_read_timeout 300s;
}
}
# Note: run certbot --nginx -d git.zat.kz to issue a TLS cert certbot will
# add a TLS server block and rewrite this one to 301->https.

View file

@ -1,48 +0,0 @@
#!/bin/bash
# Pulls all external base images the food-market builds depend on, then retags
# them into the local registry at 127.0.0.1:5001 under the "mirror/" prefix.
#
# Why: outbound to docker.io / mcr.microsoft.com flaps on KZ network. Once
# mirrored, Dockerfiles and docker-compose reference the local copy and builds
# no longer need the internet at all.
#
# Idempotent — safe to run as often as you want. Scheduled daily via
# food-market-mirror-base-images.timer.
set -euo pipefail
REGISTRY=127.0.0.1:5001
LOG_PREFIX=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# image_ref → local name under mirror/
IMAGES=(
"node:20-alpine|mirror/node:20-alpine"
"nginx:1.27-alpine|mirror/nginx:1.27-alpine"
"postgres:16-alpine|mirror/postgres:16-alpine"
"mcr.microsoft.com/dotnet/sdk:8.0|mirror/dotnet-sdk:8.0"
"mcr.microsoft.com/dotnet/aspnet:8.0|mirror/dotnet-aspnet:8.0"
)
failures=0
for pair in "${IMAGES[@]}"; do
src="${pair%|*}"
dst="${pair#*|}"
echo "$LOG_PREFIX pulling $src"
if ! docker pull "$src"; then
echo "$LOG_PREFIX FAILED: pull $src"
failures=$((failures + 1))
continue
fi
docker tag "$src" "$REGISTRY/$dst"
if ! docker push "$REGISTRY/$dst"; then
echo "$LOG_PREFIX FAILED: push $REGISTRY/$dst"
failures=$((failures + 1))
continue
fi
echo "$LOG_PREFIX ok $src -> $REGISTRY/$dst"
done
if [[ $failures -gt 0 ]]; then
echo "$LOG_PREFIX done, $failures failed — registry still has old mirrored copies"
exit 1
fi
echo "$LOG_PREFIX done, all mirrors fresh"

View file

@ -1,432 +0,0 @@
"""Telegram <-> tmux bridge for controlling a local Claude Code session from a phone.
Reads creds from /etc/food-market/telegram.env (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID).
Only the single whitelisted chat_id is allowed; everything else is silently ignored.
Inbound: each Telegram message is typed into tmux session 'claude' via `tmux send-keys
-t claude -l <text>` followed by an Enter keypress.
Outbound: every poll_interval seconds, capture the current pane, diff against the last
snapshot, filter TUI noise (box-drawing, spinners, the user's own echoed prompt), then
send any remaining text as plain Telegram messages.
"""
from __future__ import annotations
import asyncio
import collections
import logging
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from telegram import Update
from telegram.ext import (
Application,
ApplicationBuilder,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)
ENV_FILE = Path("/etc/food-market/telegram.env")
TMUX_SESSION = os.environ.get("TMUX_SESSION", "claude")
POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL_SEC", "8"))
CAPTURE_HISTORY = int(os.environ.get("CAPTURE_HISTORY_LINES", "200"))
TG_MAX_CHARS = 3500
MAX_SEND_PER_TICK = int(os.environ.get("MAX_SEND_CHARS_PER_TICK", "900"))
ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]")
BOX_CHARS = set("╭╮╰╯│─┌┐└┘├┤┬┴┼║═╔╗╚╝╠╣╦╩╬▌▐█▀▄")
SPINNER_CHARS = set("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣾⣽⣻⢿⡿⣟⣯⣷")
# Claude Code TUI markers
TOOL_CALL_RE = re.compile(r"^\s*[⏺●⏻◯◎⬤]\s+\S")
TOOL_RESULT_RE = re.compile(r"^\s*⎿")
USER_PROMPT_RE = re.compile(r"^>\s?(.*)$")
def load_env(path: Path) -> dict[str, str]:
out: dict[str, str] = {}
if not path.exists():
return out
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
out[key.strip()] = value.strip().strip('"').strip("'")
return out
@dataclass
class BridgeState:
chat_id: int
last_snapshot: str = ""
last_sent_text: str = ""
recent_user_inputs: collections.deque = field(
default_factory=lambda: collections.deque(maxlen=50)
)
recently_sent_lines: collections.deque = field(
default_factory=lambda: collections.deque(maxlen=400)
)
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
async def tmux_send_text(session: str, text: str) -> None:
proc = await asyncio.create_subprocess_exec(
"tmux", "send-keys", "-t", session, "-l", text,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"tmux send-keys -l failed: {stderr.decode().strip()}")
proc = await asyncio.create_subprocess_exec(
"tmux", "send-keys", "-t", session, "Enter",
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"tmux send-keys Enter failed: {stderr.decode().strip()}")
async def tmux_capture(session: str) -> str:
proc = await asyncio.create_subprocess_exec(
"tmux", "capture-pane", "-t", session, "-p", "-S", f"-{CAPTURE_HISTORY}",
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"tmux capture-pane failed: {stderr.decode().strip()}")
return stdout.decode("utf-8", errors="replace").rstrip("\n")
def _strip_box(line: str) -> str:
# Drop leading/trailing box-drawing chars and their padding.
while line and (line[0] in BOX_CHARS or (line[0] == " " and len(line) > 1 and line[1] in BOX_CHARS)):
line = line[1:].lstrip()
if not line:
break
while line and (line[-1] in BOX_CHARS or (line[-1] == " " and len(line) > 1 and line[-2] in BOX_CHARS)):
line = line[:-1].rstrip()
if not line:
break
return line
def _is_noise(line: str) -> bool:
s = line.strip()
if not s:
return True
# Lines made of only box/spinner/decoration chars + spaces.
if all(c in BOX_CHARS or c in SPINNER_CHARS or c.isspace() for c in s):
return True
# Claude Code TUI hints.
lowered = s.lower()
if "shift+tab" in lowered or "bypass permissions" in lowered:
return True
if lowered.startswith("? for shortcuts"):
return True
# Spinner + status line ("✻ Thinking…", "· Pondering…").
if s.startswith(("", "", "", "·")) and len(s) < 80:
return True
# Typing indicator prompt ("> " empty or near-empty input box).
if s.startswith(">") and len(s) <= 2:
return True
return False
def clean_text(text: str, recent_user: collections.deque | None = None) -> str:
"""Strip TUI noise, tool-call blocks and echoed user input.
Only the assistant's prose reply should survive.
"""
recent_user = recent_user if recent_user is not None else collections.deque()
out: list[str] = []
in_tool_block = False
for raw in text.splitlines():
line = ANSI_RE.sub("", raw).rstrip()
line = _strip_box(line)
stripped = line.strip()
if not stripped:
in_tool_block = False
out.append("")
continue
# Tool call / tool result blocks — skip the header and any indented follow-ups.
if TOOL_CALL_RE.match(line) or TOOL_RESULT_RE.match(line):
in_tool_block = True
continue
if in_tool_block:
# continuation of a tool block is usually indented; a flush-left line ends it
if line.startswith(" ") or line.startswith("\t"):
continue
in_tool_block = False
# Echo of the user's own prompt ("> hello") — drop it.
m = USER_PROMPT_RE.match(stripped)
if m:
continue
if stripped in recent_user:
continue
if _is_noise(line):
continue
out.append(line)
# collapse runs of blank lines
collapsed: list[str] = []
prev_blank = False
for line in out:
blank = not line.strip()
if blank and prev_blank:
continue
collapsed.append(line)
prev_blank = blank
return "\n".join(collapsed).strip("\n")
def diff_snapshot(prev: str, curr: str) -> str:
"""Return only lines that weren't already present anywhere in the previous snapshot.
Set-based: handles TUI scrolling and partial redraws without re-sending history.
"""
if not prev:
return curr
if prev == curr:
return ""
prev_set = set(prev.splitlines())
new_lines = [ln for ln in curr.splitlines() if ln.rstrip() and ln not in prev_set]
return "\n".join(new_lines)
def chunk_for_telegram(text: str, limit: int = TG_MAX_CHARS) -> list[str]:
if not text:
return []
out: list[str] = []
buf: list[str] = []
buf_len = 0
for line in text.splitlines():
if buf_len + len(line) + 1 > limit and buf:
out.append("\n".join(buf))
buf, buf_len = [], 0
while len(line) > limit:
out.append(line[:limit])
line = line[limit:]
buf.append(line)
buf_len += len(line) + 1
if buf:
out.append("\n".join(buf))
return out
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
state: BridgeState = context.application.bot_data["state"]
if update.effective_chat is None or update.effective_chat.id != state.chat_id:
return
text = (update.message.text or "").strip() if update.message else ""
if not text:
return
# Remember what we sent so we can suppress its echo from the pane capture.
async with state._lock:
state.recent_user_inputs.append(text)
# Also store reasonable substrings in case the TUI wraps or truncates
if len(text) > 40:
state.recent_user_inputs.append(text[:40])
try:
await tmux_send_text(TMUX_SESSION, text)
except Exception as exc: # noqa: BLE001
await update.message.reply_text(f"⚠️ tmux error: {exc}")
async def cmd_ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
state: BridgeState = context.application.bot_data["state"]
if update.effective_chat is None or update.effective_chat.id != state.chat_id:
return
await update.message.reply_text(
f"pong — session '{TMUX_SESSION}', poll {POLL_INTERVAL}s"
)
async def cmd_snapshot(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
state: BridgeState = context.application.bot_data["state"]
if update.effective_chat is None or update.effective_chat.id != state.chat_id:
return
try:
snap = await tmux_capture(TMUX_SESSION)
except Exception as exc: # noqa: BLE001
await update.message.reply_text(f"⚠️ tmux error: {exc}")
return
async with state._lock:
cleaned = clean_text(snap, state.recent_user_inputs)
state.last_snapshot = snap # reset baseline so poller doesn't resend
for part in chunk_for_telegram(cleaned) or ["(nothing to show)"]:
await update.message.reply_text(part)
async def poll_and_forward(application: Application) -> None:
state: BridgeState = application.bot_data["state"]
bot = application.bot
logger = logging.getLogger("bridge.poll")
while True:
await asyncio.sleep(POLL_INTERVAL)
# Stability check: capture twice, ~1.5s apart. If pane still changes, assistant
# is still streaming — skip this tick and try next time.
try:
snap1 = await tmux_capture(TMUX_SESSION)
except Exception as exc: # noqa: BLE001
logger.warning("capture failed: %s", exc)
continue
await asyncio.sleep(1.5)
try:
snap2 = await tmux_capture(TMUX_SESSION)
except Exception as exc: # noqa: BLE001
logger.warning("capture failed: %s", exc)
continue
if snap1 != snap2:
# still being written — don't send partials
continue
snapshot = snap2
async with state._lock:
prev = state.last_snapshot
state.last_snapshot = snapshot
recent_user_copy = list(state.recent_user_inputs)
recently_sent_copy = list(state.recently_sent_lines)
raw_new = diff_snapshot(prev, snapshot)
new_text = clean_text(raw_new, collections.deque(recent_user_copy))
if not new_text:
continue
# Line-level dedup vs. what we already shipped: drop lines that are
# substring-equivalent to a recently sent one (handles streaming dupes).
deduped: list[str] = []
for line in new_text.splitlines():
s = line.rstrip()
if not s.strip():
deduped.append(line)
continue
ss = s.strip()
is_dup = False
for past in recently_sent_copy:
if ss == past:
is_dup = True
break
if len(ss) >= 15 and len(past) >= 15 and (ss in past or past in ss):
is_dup = True
break
if is_dup:
continue
deduped.append(line)
recently_sent_copy.append(ss)
async with state._lock:
state.recently_sent_lines.clear()
state.recently_sent_lines.extend(recently_sent_copy[-400:])
new_text = "\n".join(deduped).strip("\n")
if not new_text:
continue
async with state._lock:
if new_text == state.last_sent_text:
continue
state.last_sent_text = new_text
# Cap total outbound per tick so a big burst doesn't flood Telegram.
if len(new_text) > MAX_SEND_PER_TICK:
keep = new_text[-MAX_SEND_PER_TICK:]
# snap to next newline to avoid cutting mid-line
nl = keep.find("\n")
if 0 <= nl < 200:
keep = keep[nl + 1 :]
dropped = len(new_text) - len(keep)
new_text = f"… (+{dropped} chars earlier)\n{keep}"
for part in chunk_for_telegram(new_text):
try:
await bot.send_message(
chat_id=state.chat_id,
text=part,
disable_notification=True,
)
except Exception as exc: # noqa: BLE001
logger.warning("telegram send failed: %s", exc)
break
async def on_startup(application: Application) -> None:
state: BridgeState = application.bot_data["state"]
try:
state.last_snapshot = await tmux_capture(TMUX_SESSION)
except Exception: # noqa: BLE001
state.last_snapshot = ""
application.bot_data["poll_task"] = asyncio.create_task(
poll_and_forward(application), name="bridge-poll"
)
async def on_shutdown(application: Application) -> None:
task = application.bot_data.get("poll_task")
if task:
task.cancel()
def main() -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
env = {**os.environ, **load_env(ENV_FILE)}
token = env.get("TELEGRAM_BOT_TOKEN", "").strip()
chat_id_raw = env.get("TELEGRAM_CHAT_ID", "").strip()
if not token or not chat_id_raw:
print(
"ERROR: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID must be set in "
f"{ENV_FILE} or environment",
file=sys.stderr,
)
return 78
try:
chat_id = int(chat_id_raw)
except ValueError:
print(f"ERROR: TELEGRAM_CHAT_ID must be an integer, got: {chat_id_raw!r}",
file=sys.stderr)
return 78
check = subprocess.run(
["tmux", "has-session", "-t", TMUX_SESSION],
capture_output=True, text=True,
)
if check.returncode != 0:
print(
f"WARNING: tmux session '{TMUX_SESSION}' not found — bridge will run "
"but send/capture will fail until the session is created.",
file=sys.stderr,
)
application = (
ApplicationBuilder()
.token(token)
.post_init(on_startup)
.post_shutdown(on_shutdown)
.build()
)
application.bot_data["state"] = BridgeState(chat_id=chat_id)
application.add_handler(CommandHandler("ping", cmd_ping))
application.add_handler(CommandHandler("snapshot", cmd_snapshot))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
application.run_polling(allowed_updates=Update.ALL_TYPES, stop_signals=None)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1 +0,0 @@
python-telegram-bot[rate-limiter]==21.6

View file

@ -1,19 +0,0 @@
[Unit]
Description=food-market Telegram <-> tmux bridge
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=nns
Group=nns
WorkingDirectory=/opt/food-market-data/telegram-bridge
EnvironmentFile=-/etc/food-market/telegram.env
ExecStart=/opt/food-market-data/telegram-bridge/venv/bin/python /opt/food-market-data/telegram-bridge/bridge.py
Restart=on-failure
RestartSec=10
# Access tmux sockets under /tmp/tmux-1000/
Environment=TMUX_TMPDIR=/tmp
[Install]
WantedBy=multi-user.target

View file

@ -1,464 +0,0 @@
# Аудит наших доменных сущностей vs. MoySklad API
Источник правды — живой MoySklad API `/api/remap/1.2/`. Проверялись ключи на реальных ответах (`?limit=2` на нашем аккаунте). Цель: каждая наша сущность должна либо повторять MoySklad, либо иметь явно оправданное отличие. Никаких «выдуманных» полей.
Условные обозначения:
- **⛔** — у нас есть поле, которого нет у MoySklad → либо оправдать комментарием, либо удалить.
- **** — у MoySklad есть поле, которого нет у нас → потенциально добавить.
- **⚠️** — важный нюанс (тип, семантика, обязательность).
---
## Counterparty → `entity/counterparty`
Ключи MoySklad (из ответа API, верхний уровень): `accountId, accounts, archived, bonusPoints, bonusProgram, companyType, created, externalCode, files, group, id, meta, name, notes, owner, salesAmount, shared, state, tags, updated` + расширяемые: `legalTitle, legalAddress, inn, kpp, ogrn, ogrnip, certificateNumber, certificateDate, phone, email, actualAddress, description, discountCardNumber, priceType, sex, salesChannel`.
| Наше поле | MoySklad | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `LegalName` | `legalTitle` | rename? или доп. комментарий-алиас |
| `Kind` (CounterpartyKind) | **нет** | ⛔ уже исправили enum (`Unspecified/Supplier/Customer/Both`), но MoySklad не имеет этого поля вообще — он различает контрагентов через `tags` или через `state` (статус в пайплайне продаж/закупок). **TODO:** либо оставить Kind только как UI-фильтр (не импортировать из MoySklad), либо перейти на теги |
| `Type` (LegalEntity/Individual) | `companyType` | ⚠️ у MoySklad 3 значения: `legal`, `individual`, `entrepreneur` (ИП!). У нас ИП отсутствует — **добавить `IndividualEntrepreneur` в enum** (для РК актуально) |
| `Bin` (БИН, РК) | `inn` (12-значный БИН пишется туда) | ⚠️ MoySklad для всех рынков использует `inn` — 12 цифр это ИИН РФ, 12 цифр РК — БИН. Мы вынесли `Bin` отдельно, при импорте MoySklad кладёт в `inn`. **TODO:** документировать маппинг Bin ↔ inn |
| `Iin` (ИИН, РК) | `inn` (тот же) | ⚠️ same — MoySklad не различает |
| `TaxNumber` | `inn` | дубль |
| `CountryId` | `country` (extended, по `meta`) | ⚠️ MoySklad не на верхнем уровне — тянется при `?expand=country` |
| `Address` | `actualAddress` | ОК |
| `Phone` | `phone` | ОК |
| `Email` | `email` | ОК |
| `BankName, BankAccount, Bik` | `accounts` (массив объектов) | ⚠️ у MoySklad это **коллекция счетов** (до нескольких банков). У нас одиночные поля — **либо сделать коллекцию Accounts, либо документировать "берём первый"** |
| `ContactPerson` | `contactpersons` (sub-endpoint) | ⚠️ у MoySklad это отдельный endpoint `counterparty/{id}/contactpersons` — массив. У нас скалярное поле |
| `Notes` | `description` (или `notes` разные в разных версиях API?) | ⚠️ в ответе API было `notes`ОК |
| `IsActive` | `archived` (inverse) | ОК |
| — | `tags` (массив) | **добавить** — удобно для классификации (в том числе заменой Kind) |
| — | `state` (ссылка на состояние в пайплайне) | отложить до Phase N (CRM) |
| — | `bonusPoints, bonusProgram, discountCardNumber` | отложить до дисконтных карт |
| — | `salesAmount` (вычисляемое) | не храним |
| — | `priceType` (персональный тип цены) | полезно для опта; добавить `Guid? DefaultPriceTypeId` |
**TODO:**
1. Enum `CounterpartyType`: добавить `IndividualEntrepreneur = 3`.
2. Коллекция `CounterpartyAccount` (BankName/BankAccount/Bik/IsDefault) — или явный комментарий «храним только основной».
3. Коллекция `CounterpartyTag` (string) — для классификации при импорте из MoySklad.
4. Поле `DefaultPriceTypeId``PriceType` (для опта/персональной цены).
5. Комментарий на `Bin/Iin/TaxNumber`: при импорте из MoySklad все три могут прилететь из одного поля `inn` — логика различения по длине (12 цифр РК-формат) / по companyType.
---
## Organization → `entity/organization`
Ключи MS: `accountId, accounts, archived, bonusPoints, bonusProgram, companyType, companyVat__ru, created, email, externalCode, group, id, isEgaisEnable, meta, name, owner, payerVat, shared, updated` + extended: `legalTitle, legalAddress, actualAddress, inn, kpp, ogrn, ogrnip, okpo, director, chiefAccountant, phone, fax, utmUrl`.
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `CountryCode` | **нет** | ⛔ у MoySklad нет — у них multi-tenant через account. У нас — multi-tenant через Organization, но CountryCode неочевиден. Оставить как есть, документировать почему (нам нужно для налоговых/локальных настроек) |
| `Bin` | `inn` | то же что и Counterparty |
| `Address` | `actualAddress` | ОК |
| `Phone` | `phone` | ОК |
| `Email` | `email` | ОК |
| `IsActive` | `archived` inverse | ОК |
| — | `legalTitle, legalAddress` | для офиц. документов |
| — | `kpp, ogrn, ogrnip, okpo` | РФ-специфично, пропускаем для РК |
| — | `payerVat` (bool, плательщик НДС) | полезно — есть ли НДС у нашей организации |
| — | `director, chiefAccountant` | для подписей на накладных |
| — | `accounts` (банковские) | аналогично Counterparty |
| — | `isEgaisEnable` | РФ, пропускаем |
**TODO:**
1. `LegalName`, `LegalAddress`, `PayerVat` (bool), `DirectorName`, `ChiefAccountantName` — для накладных/счетов.
2. `CountryCode` оставить + `<see langword="…"/>` комментарий почему у нас есть, а у MS нет.
---
## Product → `entity/product`
Ключи MS: `accountId, archived, barcodes, buyPrice, code, discountProhibited, externalCode, files, group, id, images, isSerialTrackable, meta, minPrice, name, owner, pathName, paymentItemType, productFolder, salePrices, shared, supplier, trackingType, uom, updated, useParentVat, variantsCount, volume, weight` + optional: `article, country, description, effectiveVat, minPrice.currency, taxSystem, vat, tnved, syncId, modifications`.
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `Article` | `article` | ОК |
| `Description` | `description` | ОК |
| `UnitOfMeasureId` | `uom.meta` | ОК |
| `VatRateId` | `vat` (число) + `useParentVat` | ⚠️ у MS НДС хранится как число (20, 10, 12, 0) прямо на товаре. **Мы отдельная сущность VatRate**. Обоснование: нам нужно хранить локализованные названия ("НДС 12%", "Без НДС"), is-default, и позволять разным организациям иметь разные ставки. НО — при импорте надо резолвить число в VatRate по organization_id |
| `ProductGroupId` | `productFolder.meta` | ОК |
| `DefaultSupplierId` | `supplier.meta` | ОК (у MS тоже одиночная ссылка) |
| `CountryOfOriginId` | `country.meta` | ОК |
| `IsService` | `paymentItemType` (одно из значений = "SERVICE") | ⚠️ у MS это enum с ~10 значений; у нас bool. **TODO:** либо enum, либо документировать что мы учитываем только IsService |
| `IsWeighed` | **нет** | ⛔ у MS этого нет; характеристика ритейла, нам нужно для касс с весами. **Оставить, документировать.** |
| `IsAlcohol` | `tnved` (класс товара) или через group | ⚠️ у MS через tnved-код или through type классификаторы. Наше bool — упрощение. **Оставить с комментарием.** |
| `IsMarked` | `trackingType` (enum: NOT_TRACKED, BEER_ALCOHOL, …) | ⚠️ У MS это enum из 10+ вариантов маркировки. Наш `IsMarked: bool` — потеря информации. **TODO:** заменить на enum `TrackingType` (NOT_TRACKED/TOBACCO/ALCOHOL/SHOES/MEDICINE/…) |
| `MinStock, MaxStock` | `minimumBalance` (число), `stock` (runtime) | ⚠️ у MS есть только `minimumBalance` (нижняя граница). MaxStock — наш |
| `PurchasePrice, PurchaseCurrencyId` | `buyPrice.value, buyPrice.currency.meta` | ОК (MS упаковывает в объект, мы разнесли — **одно и то же**) |
| `ImageUrl` | `images` (массив через sub-endpoint) | ⚠️ у MS images коллекция, у нас одна + отдельная ProductImage. ОК, двойная запись для UX |
| `IsActive` | `archived` inverse | ОК |
| `Prices` (collection) | `salePrices` (массив inline в MS) | ⚠️ у MS цены — **массив внутри товара**, у нас — отдельная таблица. Оба норм; просто маппинг при sync |
| `Barcodes` (collection) | `barcodes` (массив inline) | ОК |
| `Images` (collection) | `images` (sub-endpoint) | ОК |
| — | `code` | внутренний код (отличается от `article`). **Добавить `Code`** |
| — | `externalCode` | используется при импорте/ERP-интеграциях. **Добавить `ExternalCode`** (актуально для импорта из MoySklad, 1C) |
| — | `discountProhibited` | «запрет скидок» — полезно на кассе |
| — | `minPrice.value/currency` | минимальная отпускная цена. **Добавить `MinPrice` + `MinPriceCurrencyId`** |
| — | `paymentItemType` | для фискализации: «товар/услуга/работа/подарочная карта/…». **Добавить enum `PaymentItemType`** (нужно для 54-ФЗ / КZ fiscal receipts) |
| — | `tnved` | код ТН ВЭД для трансграничной торговли |
| — | `volume, weight` | для логистики (доставка) |
| — | `variantsCount` | runtime агрегат, не храним |
| — | `files` | вложения (паспорта качества, фото упаковки) — отложить |
**TODO:**
1. Добавить `Code`, `ExternalCode` на Product.
2. Заменить `IsMarked` на enum `TrackingType`.
3. Добавить `MinPrice`, `MinPriceCurrencyId`.
4. Добавить enum `PaymentItemType` + поле.
5. Поля `Volume`, `Weight`, `DiscountProhibited`.
6. Запомнить маппинг: `useParentVat` → наследовать НДС от ProductGroup (у нас сейчас не реализовано, надо подумать).
---
## ProductGroup → `entity/productfolder`
Ключи MS: `accountId, archived, externalCode, group, id, meta, name, owner, pathName, shared, updated, useParentVat` + `vat, effectiveVat, productFolder` (родитель).
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `ParentId` | `productFolder.meta` | ОК (MS использует то же имя для родителя что и для самой сущности) |
| `Path` | `pathName` | ОК |
| `SortOrder` | **нет** | ⛔ у MS нет сортировки групп. Оставить, это UX |
| `IsActive` | `archived` inverse | ОК |
| — | `externalCode` | для импорта |
| — | `vat, useParentVat` | ставка НДС по умолчанию для товаров группы |
**TODO:**
1. Добавить `ExternalCode`.
2. Добавить `VatRateId?` + `UseParentVat: bool` (для наследования).
---
## ProductBarcode → `product.barcodes[]`
У MS barcode — объект внутри product: `{type: 'ean13'|'ean8'|'code128'|'upc'|'gtin', value: '...'}`. Отдельной сущности нет.
| Наше | MS | Комментарий |
|---|---|---|
| `Code` | `value` | ОК |
| `Type` | `type` | ⚠️ MS использует строки ('ean13', 'gtin', …) — мы уже enum |
| `IsPrimary` | **нет** | ⛔ у MS нет — первый считается основным. **Оставить с комментарием — у нас явная пометка.** |
OK, расхождений существенных нет.
---
## ProductPrice → `product.salePrices[]`
У MS цены — массив объектов в product: `{value, currency: {meta}, priceType: {meta}}`. Отдельной сущности нет.
Наше — отдельная таблица. Это **нормализованный вариант** — оправдано если цен много и есть выборки по PriceType. **TODO:** маппинг при импорте — проитерировать salePrices и создать ProductPrice per PriceType.
---
## PriceType → `entity/pricetype`
Ключи MS (из context): `id, name, externalCode`.
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `IsDefault` | **нет** | ⛔ у MS — default определяется порядком или отдельно в настройках аккаунта. **Оставить** |
| `IsRetail` | **нет** | ⛔ наш флаг «используется на кассе». **Оставить** |
| `SortOrder` | **нет** | ⛔ UX. **Оставить** |
| — | `externalCode` | для импорта |
**TODO:**
1. `ExternalCode`.
---
## Country → `entity/country`
Ключи MS: `code, description, externalCode, id, meta, name, updated`.
| Наше | MS | Комментарий |
|---|---|---|
| `Code` | `code` | ⚠️ у MS формат ISO3166-1 **alpha-2 или числовой**у нас alpha-2 |
| `Name` | `name` | ОК |
| `SortOrder` | **нет** | ⛔ UX |
| — | `description` | |
| — | `externalCode` | |
OK, мелочь.
---
## Currency → `entity/currency`
Ключи MS: `archived, code, default, fullName, id, indirect, isoCode, majorUnit, meta, minorUnit, multiplicity, name, rate, rateUpdateType, system`.
| Наше | MS | Комментарий |
|---|---|---|
| `Code` | `isoCode` или `code` | ⚠️ у MS `isoCode` (строка "KZT") и `code` (цифровой "398") — у нас `Code` = строка ISO |
| `Name` | `name` | ОК |
| `Symbol` | **нет** | ⛔ у MS нет символа "₸" — но это UX. **Оставить** |
| `MinorUnit` | `minorUnit` | ОК |
| `IsActive` | `archived` inverse | ОК |
| — | `default` (валюта аккаунта) | |
| — | `rate, rateUpdateType` | курс к базовой валюте (при мульти-валютности) |
| — | `multiplicity, indirect` | конвертация; если не мульти-валютные — не надо |
| — | `fullName` | «Тенге Казахстана» vs «KZT» |
**TODO:**
1. Добавить `IsDefault: bool` (ровно одна валюта = true per tenant, или глобально).
2. `Rate, RateUpdateType` + `FullName` — отложить до мульти-валютности.
---
## VatRate — у MoySklad нет `entity/vatrate`
⚠️ У MS **ставки НДС хранятся как числовое поле на товаре** (`vat`). Отдельной таблицы нет — набор значений {0, 5, 7, 10, 12, 18, 20} и «без НДС» встроен в систему.
Наше `VatRate` — отдельная сущность. **Обоснование сохранить:**
1. Локализованное название ("НДС 12%", "Без НДС").
2. IsDefault per organization.
3. Разные организации в разных налоговых режимах (с НДС / УСН).
4. При добавлении новой ставки (например, на случай гипотетического увеличения в РК) — не править перечисление в коде.
Но: **следите**, чтобы у товара хранился `VatRateId`, а не отдельно `vat: decimal`. При импорте из MS мапим число в запись VatRate.
**Комментарий в коде нужен** — явно сказать, что мы отклонились от MoySklad сознательно.
---
## UnitOfMeasure → `entity/uom`
Ключи MS: `code, description, externalCode, id, meta, name, updated`.
| Наше | MS | Комментарий |
|---|---|---|
| `Code` (ОКЕИ) | `code` | ОК, MS использует ОКЕИ-коды (796, 166, 112) |
| `Symbol` | **нет** | ⛔ у MS только `name` ("штука"). Мы вынесли "шт" отдельно для коротких надписей на ценниках/кассовых чеках. **Оставить.** |
| `Name` | `name` | ОК |
| `DecimalPlaces` | **нет** | ⛔ у MS на уровне продукта (`variantsCount`?), а не UoM. Наш `DecimalPlaces` определяет можно ли дробные количества (0=штучный, 3=весовой). **Оставить — важно для UX касс.** |
| `IsBase` | **нет** | ⛔ наше «базовая единица организации». Мелочь, оставить |
| `IsActive` | `archived` inverse (у MS есть `archived` в uom? перепроверить) | ⚠️ в нашем ответе API archived не было — у MS uom этого поля может не быть, потому что единицы системные |
| — | `description` | |
| — | `externalCode` | |
**TODO:**
1. `ExternalCode`.
---
## Store → `entity/store`
Ключи MS: `accountId, address, archived, externalCode, group, id, meta, name, owner, pathName, shared, slots, updated, zones`.
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `Code` | `externalCode`? или отдельно? | ⚠️ у MS только `externalCode`. **Добавить ExternalCode или rename Code→ExternalCode** |
| `Kind` (Warehouse/RetailFloor) | **нет** | ⛔ у MS такого деления нет. Обоснование: нам нужно отличать «склад» от «торгового зала» для UI и настроек касс. **Оставить с комментарием** |
| `Address` | `address` | ОК |
| `Phone` | **нет** | ⛔ у MS нет. Оставить |
| `ManagerName` | **нет** | ⛔ у MS нет. Оставить |
| `IsMain` | **нет** (но можно проставить через default) | ⛔ Оставить |
| `IsActive` | `archived` inverse | ОК |
| — | `pathName` | (если будут иерархические склады) |
| — | `slots` (ячейки склада) | отложить |
| — | `zones` (зоны склада) | отложить |
**TODO:**
1. `ExternalCode` (или переименовать Code → ExternalCode).
---
## RetailPoint → `entity/retailstore`
У MS это **«Точка продаж» / кассовое место**. Огромное количество полей (~60) — в основном фискальные настройки.
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `Code` | `externalCode` | rename or add |
| `StoreId` | `store.meta` | ОК |
| `Address` | **нет** (возможно `organization.actualAddress`) | ⛔ адрес не у точки, а у организации/склада. Пересмотреть, куда класть |
| `Phone` | **нет** | ⛔ |
| `FiscalSerial` | **нет такого поля**; есть `fiscalType`, `fiscalMemoryNumber`?, `ofdEnabled` | ⚠️ у MS фискальные настройки множественные. У нас один скаляр — упрощение. **TODO:** уточнить по мере подключения ККМ |
| `FiscalRegNumber` | `ofdEnabled` + `ofdSettings` | same |
| `IsActive` | `active, archived` | MS различает active и archived — у нас только IsActive |
| — | `priceType.meta` | тип цены для этой точки — **важно** |
| — | `allowCustomPrice` | разрешить ручную цену на кассе |
| — | `allowCreateProducts` | создать товар прямо на кассе |
| — | `discountEnable, discountMaxPercent` | скидки на кассе |
| — | `cashiers` (коллекция) | кто может работать за кассой |
| — | `sellReserves` | продавать резерв |
| — | `receiptTemplate` | шаблон чека |
| — | `returnFromClosedShiftEnabled` | возврат из закрытой смены |
| — | `requiredBirthdate/Email/Phone/Fio/Sex/DiscountCardNumber` | обязательные поля при продаже |
| — | `markingSellingMode, marksCheckMode, sendMarksForCheck` | маркировка товаров |
**TODO:**
1. Обязательно: `DefaultPriceTypeId` (ссылка на `PriceType`).
2. Настройки кассы (скоп Phase 3 — касса): `AllowCustomPrice`, `AllowCreateProducts`, `SellReserves`, `DiscountMaxPercent`, `RequireCustomer...` — добавлять по мере реализации POS.
3. Коллекция `RetailPointCashier` (user_id, может ли работать).
---
## Supply → `entity/supply` + `supply/{id}/positions`
Document keys: `accountId, agent, applicable, created, externalCode, files, group, id, meta, moment, name, organization, owner, payedSum, positions, printed, published, rate, shared, store, sum, updated, vatEnabled, vatIncluded, vatSum`.
| Наше | MS | Комментарий |
|---|---|---|
| `Number` | `name` | ⚠️ у MS «номер документа» = `name`. У нас `Number` — семантически то же |
| `Date` | `moment` | ОК |
| `Status` (Draft/Posted) | `applicable` (bool) | ⚠️ у MS это bool «проведён или нет». У нас enum Draft/Posted → эквивалентно |
| `SupplierId` | `agent.meta` | ⚠️ у MS вместо `supplier` общее слово `agent` (контрагент) |
| `StoreId` | `store.meta` | ОК |
| `CurrencyId` | `rate.currency.meta` | ⚠️ MS упаковывает в rate объект с курсом |
| `SupplierInvoiceNumber` | **нет на верхнем уровне**; есть в `attributes` | ⛔ у MS через custom attributes. Оставить |
| `SupplierInvoiceDate` | same | same |
| `Notes` | `description` | rename или комментарий |
| `Total` | `sum` | ОК |
| `PostedAt` | `updated` (когда applicable ставится true) | ⚠️ у MS нет выделенного поля; мы отдельно фиксируем |
| `PostedByUserId` | `owner.meta` | условно |
| — | `vatEnabled` | |
| — | `vatIncluded` | НДС включён в цену |
| — | `vatSum` | суммарный НДС документа |
| — | `payedSum` | сколько оплачено |
| — | `organization.meta` | ⚠️ у MS документ привязан к организации. **У нас TenantEntity несёт OrganizationId — уже есть** |
| — | `printed, published` | распечатан/опубликован |
| — | `overhead` (доп.расходы) | доставка/таможня — **важно для фактической себестоимости** |
**Supply.Positions (SupplyLine) → supply/{id}/positions:**
Ключи MS: `accountId, assortment, discount, id, meta, overhead, price, quantity, vat, vatEnabled`.
| Наше (SupplyLine) | MS position | Комментарий |
|---|---|---|
| `ProductId` | `assortment.meta` | ⚠️ у MS `assortment` = может быть product ИЛИ variant ИЛИ service ИЛИ bundle. Мы только продукт |
| `Quantity` | `quantity` | ОК |
| `UnitPrice` | `price` | ⚠️ у MS `price` — в копейках (integer `100 = 1.00`). У нас decimal. **Маппинг при импорте: делить на 100** |
| `LineTotal` | **нет** (вычисляется) | ⛔ у MS не хранится |
| `SortOrder` | **нет** | ⛔ наш UX |
| — | `discount` | строковая скидка |
| — | `vat` | ставка НДС на позицию |
| — | `vatEnabled` | |
| — | `overhead` | доля накладных (для себестоимости) |
**TODO Supply:**
1. Поля: `VatEnabled`, `VatIncluded`, `VatSum`, `PayedSum`, `Overhead`.
2. Lines: `Discount` (decimal), `VatPercent` (snapshot, уже подобное есть в RetailSaleLine), `VatEnabled`.
3. Комментарий: MS `price` в копейках — при импорте делить.
---
## RetailSale → `entity/retaildemand` + `retaildemand/{id}/positions`
Document keys: огромный список, ключевое: `agent, applicable, cashSum, noCashSum, qrSum, prepaymentCashSum, prepaymentNoCashSum, prepaymentQrSum, advancePaymentSum, fiscal, retailShift, retailStore, store, positions, rate, sum, vatEnabled, vatIncluded, vatSum, name, moment, organization, syncId`.
| Наше | MS | Комментарий |
|---|---|---|
| `Number` | `name` | ОК |
| `Date` | `moment` | ОК |
| `Status` | `applicable` | ⚠️ bool vs enum |
| `StoreId` | `store.meta` | ОК |
| `RetailPointId` | `retailStore.meta` | ОК |
| `CustomerId` | `agent.meta` | ОК (nullable если не знаем покупателя) |
| `CashierUserId` | **нет напрямую**; `retailShift` → cashier | ⚠️ |
| `CurrencyId` | `rate.currency.meta` | ОК |
| `Subtotal, DiscountTotal, Total` | `sum` (= Total) | ⚠️ MS **не хранит subtotal и discount total отдельно** — только total. Но цена в позиции уже после скидки? Нет — `positions[].discount` хранится, total = sum(price*qty - discount) |
| `Payment` (PaymentMethod enum) | **cashSum + noCashSum + qrSum** | ⚠️ MS — **не enum, а суммы по видам оплаты**. Т.е. при mixed-оплате можно часть наличными + часть картой. **Наш enum Payment + PaidCash + PaidCard — неполный.** TODO: добавить `PaidQr` + убрать enum в пользу «сколько чем заплачено» |
| `PaidCash` | `cashSum` | ⚠️ у MS в копейках |
| `PaidCard` | `noCashSum` | ⚠️ в копейках |
| `Notes` | `description` | ОК |
| `PostedAt` | — | наш |
| `PostedByUserId` | `owner.meta` | условно |
| — | `qrSum` | **добавить `PaidQr`** (QR-оплата актуальна для КZ) |
| — | `retailShift.meta` | кассовая смена (отложить) |
| — | `fiscal` | пробит ли фискально |
| — | `syncId` | идентификатор для офлайн-касс (при резинхроне) |
| — | `prepaymentCashSum/NoCashSum/QrSum, advancePaymentSum` | предоплаты |
**RetailSale.Positions (RetailSaleLine) → retaildemand/{id}/positions:**
Ключи: `accountId, assortment, discount, id, meta, price, quantity, vat, vatEnabled`.
| Наше (RetailSaleLine) | MS | Комментарий |
|---|---|---|
| `ProductId` | `assortment.meta` | ОК |
| `Quantity` | `quantity` | ОК |
| `UnitPrice` | `price` | ⚠️ копейки |
| `Discount` | `discount` | ОК |
| `LineTotal` | вычисляется | наш |
| `VatPercent` | `vat` | ОК (snapshot) |
| `SortOrder` | — | наш UX |
| — | `vatEnabled` | |
**TODO RetailSale:**
1. Добавить `PaidQr: decimal`.
2. **Убрать `PaymentMethod` enum** в пользу денормализованных `PaidCash, PaidCard, PaidQr, PaidBonus` + computed `Method` (если все кроме одного = 0 → Cash/Card/QR, иначе Mixed). Либо оставить enum + PayHint, но быть готовым к частичной оплате.
3. `VatEnabled, VatIncluded, VatSum` (сумма НДС на документ — вычисляется).
4. Комментарий: MS `price/cashSum/noCashSum` в копейках при импорте.
---
## Stock → `report/stock/bystore`
У MS **нет отдельной сущности "Stock"** — это **отчёт**. Ответ `report/stock/bystore` содержит:
```json
{ "meta": {...}, "stockByStore": [ { "name": "Склад №1", "meta": {...}, "stock": 10.0, "reserve": 2.0, "inTransit": 0.0, "quantity": 12.0 } ] }
```
Т.е. по каждому (product, store) — stock (сколько есть), reserve (резерв), inTransit (в пути), quantity = stock+inTransit.
У нас `Stock`**материализованный агрегат** (Quantity, ReservedQuantity, computed Available). Это **технически наше решение**, не требование бизнеса.
**TODO:**
1. Комментарий в Stock.cs: объяснить, что это материализация (у MS динамический репорт).
2. **Добавить `InTransit: decimal`** — товар в пути (между складами при перемещении).
---
## StockMovement — у MoySklad такой сущности нет
⚠️ MS **не хранит journal движений** в явном виде — остатки рассчитываются в реальном времени из документов (supply, retaildemand, loss, enter, move и т.д.). Поэтому на вопрос «почему на складе минус 5» MS возвращает историю документов, а не journal.
Наше `StockMovement`**явный immutable journal**. Обоснование:
1. Мгновенный ответ на «почему такой остаток» без пробегания по всем документам.
2. Атомарные корректировки при баг-фиксах миграций.
3. Упрощённая репликация в офлайн-кассы.
Это **сознательное отклонение** от MS — должно быть задокументировано в коде и в `docs/`. **TODO:** комментарий в StockMovement.cs + упоминание в `CLAUDE.md`.
---
## Свод по приоритетам
### Приоритет 1 — базовая совместимость импорта (на этой неделе):
- Product: `Code`, `ExternalCode`, `TrackingType` (enum) вместо `IsMarked`, `MinPrice`/`MinPriceCurrencyId`, `PaymentItemType` (enum)
- Counterparty: `CounterpartyType.IndividualEntrepreneur`, `ExternalCode`, tags (коллекция)
- ProductGroup: `ExternalCode`
- PriceType: `ExternalCode`
- Country, Currency, UnitOfMeasure, Store: `ExternalCode`
- RetailPoint: `DefaultPriceTypeId`
### Приоритет 2 — смысловые (следующая итерация):
- RetailSale: `PaidQr`, убрать enum PaymentMethod в пользу суммовых полей
- Supply: `Overhead`, `VatSum`, `VatEnabled`, `VatIncluded`
- Organization: `LegalName`, `LegalAddress`, `PayerVat`, `DirectorName`
- Product: `Volume, Weight, DiscountProhibited`
- Stock: `InTransit`
### Приоритет 3 — при необходимости:
- Counterparty: коллекция `Account`, `DefaultPriceType`
- ProductGroup: `VatRateId?` + `UseParentVat`
- RetailPoint: кассовые настройки (allowCustomPrice, discountMaxPercent, cashiers...)
- Store: slots, zones
### Сознательно не копируем MS:
- `CounterpartyKind` (Supplier/Customer/Both) — у нас enum, у MS теги. Оставляем для UX/фильтрации.
- `Store.Kind` (Warehouse vs RetailFloor) — у MS нет, нам нужно.
- `VatRate` как отдельная сущность — у MS число на товаре. У нас справочник ради локализации.
- `StockMovement` journal — у MS нет. Выбор архитектуры.
- `Product.IsWeighed` / `IsAlcohol` — упрощения под ритейл.
- `UnitOfMeasure.Symbol`, `DecimalPlaces` — UX.

View file

@ -1,100 +0,0 @@
# Forgejo как primary git
GitHub из KZ периодически роняет TCP (см. `network_github_flaky.md`). Чтобы push/pull не превращались в лотерею, на стейдж-сервере поднят Forgejo — self-hosted git-сервис (форк Gitea), он работает локально и не зависит от upstream-флапов. GitHub продолжает жить как **зеркало** (для видимости, CI-интеграций, бэкапа).
## Адреса
- **Web UI:** https://git.zat.kz (после certbot; до этого — http:// если DNS уже указан)
- **Git HTTPS:** https://git.zat.kz/nns/food-market.git
- **Git SSH:** `ssh://git@git.zat.kz:2222/nns/food-market.git`
SSH-порт 2222 (хостовой 22 занят системным sshd).
## Первый раз с Mac/iPhone
### 1. Добавить remote
В локальной копии `food-market`:
```bash
# оставляем github как origin (привычно), добавляем forgejo как primary
git remote add forgejo ssh://git@git.zat.kz:2222/nns/food-market.git
# либо делаем forgejo основным и github запасным:
git remote rename origin github
git remote add origin ssh://git@git.zat.kz:2222/nns/food-market.git
git branch --set-upstream-to=origin/main main
```
Клонировать с нуля:
```bash
git clone ssh://git@git.zat.kz:2222/nns/food-market.git
```
### 2. SSH-ключ
На Forgejo в `Settings → SSH/GPG Keys → Add Key` добавить публичный ключ (`~/.ssh/id_ed25519.pub` с Mac, либо через Working Copy на iPhone — Settings → Key Management → Generate/Export Public Key).
### 3. Обычный цикл
```bash
git pull # (или git pull forgejo main)
# ...работа...
git commit -am "…"
git push # мгновенно, внутри ДЦ
```
## Как это связано с GitHub
- **push → Forgejo:** primary, мгновенный.
- **Forgejo → GitHub** раз в 10 минут пушится автоматически сервисом `food-market-forgejo-mirror.timer`. Если GitHub недоступен — следующий тик повторит. Cкрипт: `/usr/local/bin/food-market-forgejo-mirror.sh`, лог `/var/log/food-market-forgejo-mirror.log`.
- **CI:** GitHub Actions на self-hosted runner'е (уже настроено). Запускается от коммитов, пришедших через зеркало. Если когда-нибудь понадобится CI на Forgejo'ых Actions — docs/forgejo-actions.md (пока не настроено).
То есть рабочий флоу: пуш в Forgejo → через ≤10 мин коммит в GitHub → триггер CI → деплой.
## Эксплуатация
```bash
# состояние
sudo systemctl status food-market-forgejo.service # контейнер Forgejo
sudo systemctl status food-market-forgejo-mirror.timer # расписание зеркала
sudo systemctl status food-market-forgejo-mirror.service # последняя попытка зеркала
tail -f /var/log/food-market-forgejo-mirror.log # живой лог зеркала
# прогнать зеркало прямо сейчас (не дожидаясь таймера)
sudo systemctl start food-market-forgejo-mirror.service
# рестарт Forgejo (редко нужно)
sudo systemctl restart food-market-forgejo.service
```
## Раскладка
- docker-compose: `deploy/forgejo/docker-compose.yml` (образ `codeberg.org/forgejo/forgejo:7`, sqlite, SSH через OpenSSH образа)
- systemd unit Forgejo: `/etc/systemd/system/food-market-forgejo.service` (copy в `deploy/forgejo/`)
- mirror script: `/usr/local/bin/food-market-forgejo-mirror.sh` (copy в `deploy/forgejo/mirror-to-github.sh`)
- mirror timer/service: `food-market-forgejo-mirror.{timer,service}` (copy в `deploy/forgejo/`)
- nginx vhost: `/etc/nginx/conf.d/git.zat.kz.conf` (copy в `deploy/forgejo/nginx.conf`)
- data: `/opt/food-market-data/forgejo/data` (sqlite + repos + ssh host keys)
- конфиг Forgejo: `/opt/food-market-data/forgejo/data/gitea/conf/app.ini`
- GitHub mirror token: `/etc/food-market/github-mirror-token` (PAT с `repo` scope, читает mirror-скрипт)
- локальное зеркало для push в github: `/opt/food-market-data/forgejo/mirror` (bare repo)
## Что ещё нужно от вас (разовое)
1. **DNS A-запись** `git.zat.kz → 88.204.171.93` (основной IP сервера).
2. После того как DNS прорастёт:
```bash
sudo certbot --nginx -d git.zat.kz
```
Certbot выпустит TLS-сертификат и обновит nginx-конфиг (добавит блок 443 + редирект 80→443).
3. Записать пароль администратора: файл `/tmp/forgejo-admin.txt` (создан при первой установке, надо скопировать себе в хранилище паролей и удалить с сервера).
## Обратный путь
Если Forgejo сломается и нужно срочно пушить напрямую в GitHub:
```bash
git push github main
```
GitHub — полная копия (mirror-таймер гонит всё: branches + tags). Рабочий флоу не ломается.

View file

@ -1,86 +0,0 @@
# Telegram ↔ tmux bridge
Управление локальной сессией Claude Code с телефона через Telegram-бота. Входящее сообщение от whitelisted `chat_id` набирается в tmux-сессию `claude` как будто вы сами печатаете; ответный вывод пайнa каждые ~2.5 с отправляется обратно в чат.
## Как это выглядит в работе
- Вы пишете боту: `запусти тесты`
- Бот делает `tmux send-keys -t claude -l "запусти тесты" && tmux send-keys -t claude Enter` — текст попадает в поле ввода Claude
- Фоновый поллер раз в 2.5 с снимает `tmux capture-pane`, сравнивает с предыдущим снапшотом, присылает новые строки как `<pre>…</pre>`-блок
Команды бота:
- `/ping` — живой ли, какая сессия и интервал
- `/snapshot` — выслать полный текущий пайн (полезно после длинного молчания или после рестарта)
## Один раз — настройка
### 1. Креды
Положите в `/etc/food-market/telegram.env`:
```
TELEGRAM_BOT_TOKEN=<токен от @BotFather>
TELEGRAM_CHAT_ID=<ваш личный chat_id, целое число>
```
Узнать `chat_id` — напишите `@userinfobot` в Telegram, он ответит с вашим id. Файл доступен только владельцу (`chmod 600`).
Только сообщения от этого **одного** chat_id будут обработаны — всё остальное молча игнорируется.
### 2. tmux-сессия `claude`
Бот ожидает существующую сессию с именем `claude`. Создайте её как обычно:
```bash
tmux new-session -d -s claude
tmux attach -t claude # и запустите внутри `claude` (или что там у вас)
```
Сервис стартует даже без сессии — в лог упадёт warning, но `send-keys` / `capture-pane` начнут работать как только сессия появится. Имя сессии можно переопределить через env `TMUX_SESSION=other` в юните.
### 3. Старт сервиса
```bash
sudo systemctl enable --now food-market-telegram-bridge.service
```
В ответ бот пришлёт `✅ bridge up …` — это индикатор успеха.
## Эксплуатация
### Логи
```bash
sudo journalctl -u food-market-telegram-bridge.service -f
sudo journalctl -u food-market-telegram-bridge.service --since '10 min ago'
```
### Перезапуск
```bash
sudo systemctl restart food-market-telegram-bridge.service
```
### Остановить
```bash
sudo systemctl stop food-market-telegram-bridge.service # до ребута
sudo systemctl disable food-market-telegram-bridge.service # и после ребута
```
### Поменять интервал/сессию
Отредактируйте `/etc/systemd/system/food-market-telegram-bridge.service`, добавьте в секцию `[Service]`:
```
Environment=POLL_INTERVAL_SEC=1.5
Environment=TMUX_SESSION=other-session
Environment=CAPTURE_HISTORY_LINES=400
```
Затем `sudo systemctl daemon-reload && sudo systemctl restart food-market-telegram-bridge`.
## Раскладка
- Скрипт: `/opt/food-market-data/telegram-bridge/bridge.py`
- venv (Python 3.12, `python-telegram-bot 21.x`): `/opt/food-market-data/telegram-bridge/venv/`
- Креды: `/etc/food-market/telegram.env` (owner `nns`, mode `0600`)
- systemd unit: `/etc/systemd/system/food-market-telegram-bridge.service`
## Что хорошо знать
- `disable_notification=True` стоит на фоновых сообщениях пайна — не будет жужжать при каждом diff'e.
- Telegram-лимит 4096 символов; длинные пайн-блоки режутся на куски по ~3800 символов.
- Если после долгого молчания в чате слишком много истории, шлите `/snapshot` — бот обнуляет baseline и присылает текущий экран целиком.
- Бот заходит в Telegram long-polling (исходящее к api.telegram.org, без входящих портов) — никакого проброса портов не нужно.

View file

@ -1,130 +0,0 @@
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Admin;
// Временные эндпоинты для очистки данных после кривых импортов.
// Удалять только свой tenant — query-filter на DbSets это обеспечивает.
[ApiController]
[Authorize(Policy = "AdminAccess")]
[Route("api/admin/cleanup")]
public class AdminCleanupController : ControllerBase
{
private readonly AppDbContext _db;
public AdminCleanupController(AppDbContext db) => _db = db;
public record CleanupStats(
int Counterparties,
int Products,
int ProductGroups,
int ProductBarcodes,
int ProductPrices,
int Supplies,
int RetailSales,
int Stocks,
int StockMovements);
public record CleanupResult(string Scope, CleanupStats Deleted);
[HttpGet("stats")]
public async Task<ActionResult<CleanupStats>> GetStats(CancellationToken ct)
=> new CleanupStats(
await _db.Counterparties.CountAsync(ct),
await _db.Products.CountAsync(ct),
await _db.ProductGroups.CountAsync(ct),
await _db.ProductBarcodes.CountAsync(ct),
await _db.ProductPrices.CountAsync(ct),
await _db.Supplies.CountAsync(ct),
await _db.RetailSales.CountAsync(ct),
await _db.Stocks.CountAsync(ct),
await _db.StockMovements.CountAsync(ct));
/// <summary>Удалить всех контрагентов текущей организации. Чтобы не нарваться на FK,
/// сначала обнуляем ссылки (Product.DefaultSupplier, RetailSale.Customer) и сносим
/// поставки (они жёстко ссылаются на supplier).</summary>
[HttpDelete("counterparties")]
public async Task<ActionResult<CleanupResult>> WipeCounterparties(CancellationToken ct)
{
var before = await SnapshotAsync(ct);
// 1. Обнуляем nullable-FK
await _db.Products
.Where(p => p.DefaultSupplierId != null)
.ExecuteUpdateAsync(u => u.SetProperty(p => p.DefaultSupplierId, (Guid?)null), ct);
await _db.RetailSales
.Where(s => s.CustomerId != null)
.ExecuteUpdateAsync(u => u.SetProperty(s => s.CustomerId, (Guid?)null), ct);
// 2. Сносим поставки (NOT NULL supplier) + их stock movements/stocks
await _db.StockMovements
.Where(m => m.DocumentType == "supply" || m.DocumentType == "supply-reversal")
.ExecuteDeleteAsync(ct);
await _db.SupplyLines.ExecuteDeleteAsync(ct);
await _db.Supplies.ExecuteDeleteAsync(ct);
// 3. Контрагенты
await _db.Counterparties.ExecuteDeleteAsync(ct);
var after = await SnapshotAsync(ct);
return new CleanupResult("counterparties", Diff(before, after));
}
/// <summary>Полная очистка данных текущей организации — всё кроме настроек:
/// остаются Organization, пользователи, справочники (Country, Currency, UnitOfMeasure,
/// PriceType), Store и RetailPoint. Удаляются: Product*, ProductGroup, Counterparty,
/// Supply*, RetailSale*, Stock, StockMovement.</summary>
[HttpDelete("all")]
public async Task<ActionResult<CleanupResult>> WipeAll(CancellationToken ct)
{
var before = await SnapshotAsync(ct);
// Documents first — they reference products, counterparties, stores.
await _db.StockMovements.ExecuteDeleteAsync(ct);
await _db.Stocks.ExecuteDeleteAsync(ct);
await _db.SupplyLines.ExecuteDeleteAsync(ct);
await _db.Supplies.ExecuteDeleteAsync(ct);
await _db.RetailSaleLines.ExecuteDeleteAsync(ct);
await _db.RetailSales.ExecuteDeleteAsync(ct);
// Product composites.
await _db.ProductImages.ExecuteDeleteAsync(ct);
await _db.ProductPrices.ExecuteDeleteAsync(ct);
await _db.ProductBarcodes.ExecuteDeleteAsync(ct);
// Products reference counterparty.DefaultSupplier — FK Restrict, but we're about
// to delete products anyway, so order products → counterparties.
await _db.Products.ExecuteDeleteAsync(ct);
await _db.ProductGroups.ExecuteDeleteAsync(ct);
await _db.Counterparties.ExecuteDeleteAsync(ct);
var after = await SnapshotAsync(ct);
return new CleanupResult("all", Diff(before, after));
}
private async Task<CleanupStats> SnapshotAsync(CancellationToken ct) => new(
await _db.Counterparties.CountAsync(ct),
await _db.Products.CountAsync(ct),
await _db.ProductGroups.CountAsync(ct),
await _db.ProductBarcodes.CountAsync(ct),
await _db.ProductPrices.CountAsync(ct),
await _db.Supplies.CountAsync(ct),
await _db.RetailSales.CountAsync(ct),
await _db.Stocks.CountAsync(ct),
await _db.StockMovements.CountAsync(ct));
private static CleanupStats Diff(CleanupStats a, CleanupStats b) => new(
a.Counterparties - b.Counterparties,
a.Products - b.Products,
a.ProductGroups - b.ProductGroups,
a.ProductBarcodes - b.ProductBarcodes,
a.ProductPrices - b.ProductPrices,
a.Supplies - b.Supplies,
a.RetailSales - b.RetailSales,
a.Stocks - b.Stocks,
a.StockMovements - b.StockMovements);
}

View file

@ -20,9 +20,14 @@ public class CounterpartiesController : ControllerBase
[HttpGet]
public async Task<ActionResult<PagedResult<CounterpartyDto>>> List(
[FromQuery] PagedRequest req,
[FromQuery] CounterpartyKind? kind,
CancellationToken ct)
{
var q = _db.Counterparties.Include(c => c.Country).AsNoTracking().AsQueryable();
if (kind is not null)
{
q = q.Where(c => c.Kind == kind || c.Kind == CounterpartyKind.Both);
}
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
@ -38,7 +43,7 @@ public class CounterpartiesController : ControllerBase
.OrderBy(c => c.Name)
.Skip(req.Skip).Take(req.Take)
.Select(c => new CounterpartyDto(
c.Id, c.Name, c.LegalName, c.Type,
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null,
c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive))
@ -51,7 +56,7 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
{
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return c is null ? NotFound() : new CounterpartyDto(
c.Id, c.Name, c.LegalName, c.Type,
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive);
@ -90,6 +95,7 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i)
{
e.Name = i.Name;
e.LegalName = i.LegalName;
e.Kind = i.Kind;
e.Type = i.Type;
e.Bin = i.Bin;
e.Iin = i.Iin;
@ -111,7 +117,7 @@ private async Task<CounterpartyDto> ProjectAsync(Guid id, CancellationToken ct)
{
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstAsync(x => x.Id == id, ct);
return new CounterpartyDto(
c.Id, c.Name, c.LegalName, c.Type,
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive);

View file

@ -23,33 +23,14 @@ public class ProductsController : ControllerBase
[FromQuery] Guid? groupId,
[FromQuery] bool? isService,
[FromQuery] bool? isWeighed,
[FromQuery] bool? isMarked,
[FromQuery] bool? isActive,
[FromQuery] bool? hasBarcode,
CancellationToken ct)
{
var q = QueryIncludes().AsNoTracking();
if (groupId is not null)
{
// Include the whole subtree: match on the group's Path prefix so sub-groups also show up.
var root = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(g => g.Id == groupId, ct);
if (root is not null)
{
var prefix = root.Path;
q = q.Where(p => p.ProductGroup != null &&
(p.ProductGroup.Path == prefix || p.ProductGroup.Path.StartsWith(prefix + "/")));
}
else
{
q = q.Where(p => p.ProductGroupId == groupId);
}
}
if (groupId is not null) q = q.Where(p => p.ProductGroupId == groupId);
if (isService is not null) q = q.Where(p => p.IsService == isService);
if (isWeighed is not null) q = q.Where(p => p.IsWeighed == isWeighed);
if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked);
if (isActive is not null) q = q.Where(p => p.IsActive == isActive);
if (hasBarcode is not null)
q = hasBarcode == true ? q.Where(p => p.Barcodes.Any()) : q.Where(p => !p.Barcodes.Any());
if (!string.IsNullOrWhiteSpace(req.Search))
{
@ -130,6 +111,7 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
private IQueryable<Product> QueryIncludes() => _db.Products
.Include(p => p.UnitOfMeasure)
.Include(p => p.VatRate)
.Include(p => p.ProductGroup)
.Include(p => p.DefaultSupplier)
.Include(p => p.CountryOfOrigin)
@ -144,12 +126,12 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
private static readonly System.Linq.Expressions.Expression<Func<Product, ProductDto>> Projection = p =>
new ProductDto(
p.Id, p.Name, p.Article, p.Description,
p.UnitOfMeasureId, p.UnitOfMeasure!.Name,
p.Vat, p.VatEnabled,
p.UnitOfMeasureId, p.UnitOfMeasure!.Symbol,
p.VatRateId, p.VatRate!.Percent,
p.ProductGroupId, p.ProductGroup != null ? p.ProductGroup.Name : null,
p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null,
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
p.IsService, p.IsWeighed, p.IsMarked,
p.IsService, p.IsWeighed, p.IsAlcohol, p.IsMarked,
p.MinStock, p.MaxStock,
p.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
p.ImageUrl, p.IsActive,
@ -162,13 +144,13 @@ private static void Apply(Product e, ProductInput i)
e.Article = i.Article;
e.Description = i.Description;
e.UnitOfMeasureId = i.UnitOfMeasureId;
e.Vat = i.Vat;
e.VatEnabled = i.VatEnabled;
e.VatRateId = i.VatRateId;
e.ProductGroupId = i.ProductGroupId;
e.DefaultSupplierId = i.DefaultSupplierId;
e.CountryOfOriginId = i.CountryOfOriginId;
e.IsService = i.IsService;
e.IsWeighed = i.IsWeighed;
e.IsAlcohol = i.IsAlcohol;
e.IsMarked = i.IsMarked;
e.MinStock = i.MinStock;
e.MaxStock = i.MaxStock;

View file

@ -30,7 +30,7 @@ public async Task<ActionResult<PagedResult<StoreDto>>> List([FromQuery] PagedReq
var items = await q
.OrderByDescending(x => x.IsMain).ThenBy(x => x.Name)
.Skip(req.Skip).Take(req.Take)
.Select(x => new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive))
.Select(x => new StoreDto(x.Id, x.Name, x.Code, x.Kind, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive))
.ToListAsync(ct);
return new PagedResult<StoreDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
@ -39,7 +39,7 @@ public async Task<ActionResult<PagedResult<StoreDto>>> List([FromQuery] PagedReq
public async Task<ActionResult<StoreDto>> Get(Guid id, CancellationToken ct)
{
var x = await _db.Stores.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, ct);
return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Kind, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
}
[HttpPost, Authorize(Roles = "Admin,Manager")]
@ -51,13 +51,13 @@ public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, Ca
}
var e = new Store
{
Name = input.Name, Code = input.Code,Address = input.Address,
Name = input.Name, Code = input.Code, Kind = input.Kind, Address = input.Address,
Phone = input.Phone, ManagerName = input.ManagerName, IsMain = input.IsMain, IsActive = input.IsActive,
};
_db.Stores.Add(e);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id },
new StoreDto(e.Id, e.Name, e.Code, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
new StoreDto(e.Id, e.Name, e.Code, e.Kind, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
@ -71,7 +71,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] StoreInput input, Ca
}
e.Name = input.Name;
e.Code = input.Code;
e.Kind = input.Kind;
e.Address = input.Address;
e.Phone = input.Phone;
e.ManagerName = input.ManagerName;

View file

@ -24,13 +24,13 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(u => u.Name.ToLower().Contains(s) || u.Code.ToLower().Contains(s));
q = q.Where(u => u.Name.ToLower().Contains(s) || u.Symbol.ToLower().Contains(s) || u.Code.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
var items = await q
.OrderBy(u => u.Name)
.OrderByDescending(u => u.IsBase).ThenBy(u => u.Name)
.Skip(req.Skip).Take(req.Take)
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.IsActive))
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Symbol, u.Name, u.DecimalPlaces, u.IsBase, u.IsActive))
.ToListAsync(ct);
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
@ -39,23 +39,30 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
{
var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.IsActive);
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Symbol, u.Name, u.DecimalPlaces, u.IsBase, u.IsActive);
}
[HttpPost, Authorize(Roles = "Admin,Manager")]
public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct)
{
if (input.IsBase)
{
await _db.UnitsOfMeasure.Where(u => u.IsBase).ExecuteUpdateAsync(s => s.SetProperty(u => u.IsBase, false), ct);
}
var e = new UnitOfMeasure
{
Code = input.Code,
Symbol = input.Symbol,
Name = input.Name,
Description = input.Description,
DecimalPlaces = input.DecimalPlaces,
IsBase = input.IsBase,
IsActive = input.IsActive,
};
_db.UnitsOfMeasure.Add(e);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id },
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.IsActive));
new UnitOfMeasureDto(e.Id, e.Code, e.Symbol, e.Name, e.DecimalPlaces, e.IsBase, e.IsActive));
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
@ -64,9 +71,16 @@ public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput i
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
if (input.IsBase && !e.IsBase)
{
await _db.UnitsOfMeasure.Where(u => u.IsBase && u.Id != id).ExecuteUpdateAsync(s => s.SetProperty(u => u.IsBase, false), ct);
}
e.Code = input.Code;
e.Symbol = input.Symbol;
e.Name = input.Name;
e.Description = input.Description;
e.DecimalPlaces = input.DecimalPlaces;
e.IsBase = input.IsBase;
e.IsActive = input.IsActive;
await _db.SaveChangesAsync(ct);
return NoContent();

View file

@ -0,0 +1,101 @@
using foodmarket.Application.Catalog;
using foodmarket.Application.Common;
using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Catalog;
[ApiController]
[Authorize]
[Route("api/catalog/vat-rates")]
public class VatRatesController : ControllerBase
{
private readonly AppDbContext _db;
public VatRatesController(AppDbContext db) => _db = db;
[HttpGet]
public async Task<ActionResult<PagedResult<VatRateDto>>> List([FromQuery] PagedRequest req, CancellationToken ct)
{
var q = _db.VatRates.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(v => v.Name.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
var items = await q
.OrderByDescending(v => v.IsDefault).ThenBy(v => v.Percent)
.Skip(req.Skip).Take(req.Take)
.Select(v => new VatRateDto(v.Id, v.Name, v.Percent, v.IsIncludedInPrice, v.IsDefault, v.IsActive))
.ToListAsync(ct);
return new PagedResult<VatRateDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<VatRateDto>> Get(Guid id, CancellationToken ct)
{
var v = await _db.VatRates.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return v is null ? NotFound() : new VatRateDto(v.Id, v.Name, v.Percent, v.IsIncludedInPrice, v.IsDefault, v.IsActive);
}
[HttpPost, Authorize(Roles = "Admin,Manager")]
public async Task<ActionResult<VatRateDto>> Create([FromBody] VatRateInput input, CancellationToken ct)
{
if (input.IsDefault)
{
await ResetDefaultsAsync(ct);
}
var e = new VatRate
{
Name = input.Name,
Percent = input.Percent,
IsIncludedInPrice = input.IsIncludedInPrice,
IsDefault = input.IsDefault,
IsActive = input.IsActive,
};
_db.VatRates.Add(e);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id },
new VatRateDto(e.Id, e.Name, e.Percent, e.IsIncludedInPrice, e.IsDefault, e.IsActive));
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
public async Task<IActionResult> Update(Guid id, [FromBody] VatRateInput input, CancellationToken ct)
{
var e = await _db.VatRates.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
if (input.IsDefault && !e.IsDefault)
{
await ResetDefaultsAsync(ct);
}
e.Name = input.Name;
e.Percent = input.Percent;
e.IsIncludedInPrice = input.IsIncludedInPrice;
e.IsDefault = input.IsDefault;
e.IsActive = input.IsActive;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var e = await _db.VatRates.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
_db.VatRates.Remove(e);
await _db.SaveChangesAsync(ct);
return NoContent();
}
private async Task ResetDefaultsAsync(CancellationToken ct)
{
await _db.VatRates.Where(v => v.IsDefault).ExecuteUpdateAsync(s => s.SetProperty(v => v.IsDefault, false), ct);
}
}

View file

@ -50,7 +50,7 @@ public record StockRow(
.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.Name,
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);

View file

@ -276,7 +276,7 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
where l.SupplyId == id
orderby l.SortOrder
select new SupplyLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Name,
l.Id, l.ProductId, p.Name, p.Article, u.Symbol,
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder))
.ToListAsync(ct);

View file

@ -352,7 +352,7 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
where l.RetailSaleId == id
orderby l.SortOrder
select new RetailSaleLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Name,
l.Id, l.ProductId, p.Name, p.Article, u.Symbol,
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder))
.ToListAsync(ct);

View file

@ -32,18 +32,21 @@ public async Task StartAsync(CancellationToken ct)
var hasProducts = await db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
if (hasProducts) return;
// KZ default VAT is 16% (applies as int on Product).
const int vatDefault = 16;
const int vat0 = 0;
var defaultVat = await db.VatRates.IgnoreQueryFilters()
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.IsDefault, ct);
var noVat = await db.VatRates.IgnoreQueryFilters()
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.Percent == 0m, ct);
var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "796", ct);
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "шт", ct);
var unitKg = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "166", ct);
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "кг", ct);
var unitL = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "112", ct);
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == ", ct);
if (unitSht is null) return;
if (defaultVat is null || unitSht is null) return;
var vat = defaultVat.Id;
var vat0 = noVat?.Id ?? vat;
var retailPriceType = await db.PriceTypes.IgnoreQueryFilters()
.FirstOrDefaultAsync(p => p.OrganizationId == orgId && p.IsRetail, ct);
@ -85,7 +88,7 @@ Guid AddGroup(string name, Guid? parentId)
var supplier1 = new Counterparty
{
OrganizationId = orgId, Name = "ТОО «Продтрейд»", LegalName = "Товарищество с ограниченной ответственностью «Продтрейд»",
Type = CounterpartyType.LegalEntity,
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.LegalEntity,
Bin = "100140005678", CountryId = kz?.Id,
Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01",
Email = "order@prodtrade.kz", BankName = "Kaspi Bank", BankAccount = "KZ000000000000000001", Bik = "CASPKZKA",
@ -94,7 +97,7 @@ Guid AddGroup(string name, Guid? parentId)
var supplier2 = new Counterparty
{
OrganizationId = orgId, Name = "ИП Иванов А.С.",
Type = CounterpartyType.Individual,
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.Individual,
Iin = "850101300000", CountryId = kz?.Id,
Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей",
IsActive = true,
@ -103,49 +106,49 @@ Guid AddGroup(string name, Guid? parentId)
// Barcodes use generic internal range (2xxxxxxxxxxxx) to avoid colliding with real products.
// When user does real приёмка, real barcodes will overwrite.
var demo = new (string Name, Guid Group, decimal RetailPrice, string Article, string Barcode, Guid? Country, bool IsWeighed, Guid Unit)[]
var demo = new (string Name, Guid Group, decimal RetailPrice, string Article, string Barcode, Guid? Country, bool IsWeighed, Guid Unit, bool IsAlcohol)[]
{
// Напитки — безалкогольные
("Вода питьевая «Тассай» 0.5л", gDrinksNon, 220, "DR-WAT-001", "2000000000018", kz?.Id, false, unitSht.Id),
("Вода питьевая «Тассай» 1.5л", gDrinksNon, 380, "DR-WAT-002", "2000000000025", kz?.Id, false, unitSht.Id),
("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id),
("Сок «Да-Да» апельсин 1л", gDrinksNon, 650, "DR-JUC-002", "2000000000049", kz?.Id, false, unitSht.Id),
("Кока-кола 0.5л", gDrinksNon, 420, "DR-SOD-001", "2000000000056", ru?.Id, false, unitSht.Id),
("Пепси 0.5л", gDrinksNon, 420, "DR-SOD-002", "2000000000063", ru?.Id, false, unitSht.Id),
("Спрайт 0.5л", gDrinksNon, 420, "DR-SOD-003", "2000000000070", ru?.Id, false, unitSht.Id),
("Чай чёрный «Пиала» пакетированный, 100шт", gDrinksNon, 1250, "DR-TEA-001", "2000000000087", kz?.Id, false, unitSht.Id),
("Вода питьевая «Тассай» 0.5л", gDrinksNon, 220, "DR-WAT-001", "2000000000018", kz?.Id, false, unitSht.Id, false),
("Вода питьевая «Тассай» 1.5л", gDrinksNon, 380, "DR-WAT-002", "2000000000025", kz?.Id, false, unitSht.Id, false),
("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id, false),
("Сок «Да-Да» апельсин 1л", gDrinksNon, 650, "DR-JUC-002", "2000000000049", kz?.Id, false, unitSht.Id, false),
("Кока-кола 0.5л", gDrinksNon, 420, "DR-SOD-001", "2000000000056", ru?.Id, false, unitSht.Id, false),
("Пепси 0.5л", gDrinksNon, 420, "DR-SOD-002", "2000000000063", ru?.Id, false, unitSht.Id, false),
("Спрайт 0.5л", gDrinksNon, 420, "DR-SOD-003", "2000000000070", ru?.Id, false, unitSht.Id, false),
("Чай чёрный «Пиала» пакетированный, 100шт", gDrinksNon, 1250, "DR-TEA-001", "2000000000087", kz?.Id, false, unitSht.Id, false),
// Молочные
("Молоко «Простоквашино» 2.5% 930мл", gDairy, 720, "DAI-MLK-001", "2000000000094", ru?.Id, false, unitSht.Id),
("Молоко «Food Master» 3.2% 1л", gDairy, 620, "DAI-MLK-002", "2000000000100", kz?.Id, false, unitSht.Id),
("Кефир «Food Master» 2.5% 1л", gDairy, 590, "DAI-KEF-001", "2000000000117", kz?.Id, false, unitSht.Id),
("Йогурт «Актимель» клубника 100мл", gDairy, 180, "DAI-YOG-001", "2000000000124", null, false, unitSht.Id),
("Творог «Простоквашино» 5% 200г", gDairy, 590, "DAI-CRD-001", "2000000000131", ru?.Id, false, unitSht.Id),
("Сметана «Food Master» 20% 400г", gDairy, 850, "DAI-SCR-001", "2000000000148", kz?.Id, false, unitSht.Id),
("Сыр «Российский» 45%", gDairy, 3200, "DAI-CHE-001", "2000000000155", kz?.Id, true, unitKg?.Id ?? unitSht.Id),
("Молоко «Простоквашино» 2.5% 930мл", gDairy, 720, "DAI-MLK-001", "2000000000094", ru?.Id, false, unitSht.Id, false),
("Молоко «Food Master» 3.2% 1л", gDairy, 620, "DAI-MLK-002", "2000000000100", kz?.Id, false, unitSht.Id, false),
("Кефир «Food Master» 2.5% 1л", gDairy, 590, "DAI-KEF-001", "2000000000117", kz?.Id, false, unitSht.Id, false),
("Йогурт «Актимель» клубника 100мл", gDairy, 180, "DAI-YOG-001", "2000000000124", null, false, unitSht.Id, false),
("Творог «Простоквашино» 5% 200г", gDairy, 590, "DAI-CRD-001", "2000000000131", ru?.Id, false, unitSht.Id, false),
("Сметана «Food Master» 20% 400г", gDairy, 850, "DAI-SCR-001", "2000000000148", kz?.Id, false, unitSht.Id, false),
("Сыр «Российский» 45%", gDairy, 3200, "DAI-CHE-001", "2000000000155", kz?.Id, true, unitKg?.Id ?? unitSht.Id, false),
// Хлеб и выпечка
("Хлеб «Семейный» нарезной 500г", gBakery, 180, "BAK-BRD-001", "2000000000162", kz?.Id, false, unitSht.Id),
("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id),
("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id),
("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", kz?.Id, false, unitSht.Id),
("Хлеб «Семейный» нарезной 500г", gBakery, 180, "BAK-BRD-001", "2000000000162", kz?.Id, false, unitSht.Id, false),
("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id, false),
("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id, false),
("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", kz?.Id, false, unitSht.Id, false),
// Кондитерские
("Шоколад «Рахат» молочный 100г", gSweets, 650, "SW-CHO-001", "2000000000209", kz?.Id, false, unitSht.Id),
("Шоколад «Алёнка» 100г", gSweets, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id),
("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id),
("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id),
("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", kz?.Id, false, unitSht.Id),
("Шоколад «Рахат» молочный 100г", gSweets, 650, "SW-CHO-001", "2000000000209", kz?.Id, false, unitSht.Id, false),
("Шоколад «Алёнка» 100г", gSweets, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id, false),
("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id, false),
("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id, false),
("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", kz?.Id, false, unitSht.Id, false),
// Бакалея
("Сахар-песок 1кг", gGrocery, 480, "GRO-SUG-001", "2000000000254", kz?.Id, false, unitSht.Id),
("Соль поваренная 1кг", gGrocery, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id),
("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id),
("Гречка «Мистраль» 900г", gGrocery, 990, "GRO-GRE-001", "2000000000285", ru?.Id, false, unitSht.Id),
("Макароны «Corona» 500г", gGrocery, 420, "GRO-PAS-001", "2000000000292", kz?.Id, false, unitSht.Id),
("Масло подсолнечное «Шедевр» 1л", gGrocery, 920, "GRO-OIL-001", "2000000000308", kz?.Id, false, unitL?.Id ?? unitSht.Id),
("Кофе «Якобс Монарх» 95г растворимый", gGrocery, 2890, "GRO-COF-001", "2000000000315", null, false, unitSht.Id),
("Сахар-песок 1кг", gGrocery, 480, "GRO-SUG-001", "2000000000254", kz?.Id, false, unitSht.Id, false),
("Соль поваренная 1кг", gGrocery, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id, false),
("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id, false),
("Гречка «Мистраль» 900г", gGrocery, 990, "GRO-GRE-001", "2000000000285", ru?.Id, false, unitSht.Id, false),
("Макароны «Corona» 500г", gGrocery, 420, "GRO-PAS-001", "2000000000292", kz?.Id, false, unitSht.Id, false),
("Масло подсолнечное «Шедевр» 1л", gGrocery, 920, "GRO-OIL-001", "2000000000308", kz?.Id, false, unitL?.Id ?? unitSht.Id, false),
("Кофе «Якобс Монарх» 95г растворимый", gGrocery, 2890, "GRO-COF-001", "2000000000315", null, false, unitSht.Id, false),
// Снеки
("Чипсы «Lays» сметана-лук 80г", gSnacks, 380, "SN-CHP-001", "2000000000322", kz?.Id, false, unitSht.Id),
("Сухарики «3 корочки» ржаные 40г", gSnacks, 190, "SN-SBR-001", "2000000000339", ru?.Id, false, unitSht.Id),
("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id),
("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id),
("Чипсы «Lays» сметана-лук 80г", gSnacks, 380, "SN-CHP-001", "2000000000322", kz?.Id, false, unitSht.Id, false),
("Сухарики «3 корочки» ржаные 40г", gSnacks, 190, "SN-SBR-001", "2000000000339", ru?.Id, false, unitSht.Id, false),
("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id, false),
("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id, false),
};
var products = demo.Select(d =>
@ -156,12 +159,12 @@ Guid AddGroup(string name, Guid? parentId)
Name = d.Name,
Article = d.Article,
UnitOfMeasureId = d.Unit,
Vat = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
? vat0 : vatDefault,
VatEnabled = !(d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")),
VatRateId = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
? vat0 : vat,
ProductGroupId = d.Group,
CountryOfOriginId = d.Country,
IsWeighed = d.IsWeighed,
IsAlcohol = d.IsAlcohol,
IsActive = true,
PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2),
PurchaseCurrencyId = kzt.Id,

View file

@ -78,15 +78,24 @@ public async Task StartAsync(CancellationToken ct)
private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct)
{
var anyVat = await db.VatRates.IgnoreQueryFilters().AnyAsync(v => v.OrganizationId == orgId, ct);
if (!anyVat)
{
db.VatRates.AddRange(
new VatRate { OrganizationId = orgId, Name = "Без НДС", Percent = 0m, IsDefault = false },
new VatRate { OrganizationId = orgId, Name = "НДС 12%", Percent = 12m, IsIncludedInPrice = true, IsDefault = true }
);
}
var anyUnit = await db.UnitsOfMeasure.IgnoreQueryFilters().AnyAsync(u => u.OrganizationId == orgId, ct);
if (!anyUnit)
{
db.UnitsOfMeasure.AddRange(
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Name = "штука" },
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Name = "килограмм" },
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Name = "литр" },
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Name = "метр" },
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Name = "упаковка" }
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Symbol = "шт", Name = "штука", DecimalPlaces = 0, IsBase = true },
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Symbol = "кг", Name = "килограмм", DecimalPlaces = 3 },
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Symbol = "л", Name = "литр", DecimalPlaces = 3 },
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Symbol = "м", Name = "метр", DecimalPlaces = 3 },
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Symbol = "уп", Name = "упаковка", DecimalPlaces = 0 }
);
}
@ -107,6 +116,7 @@ private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
OrganizationId = orgId,
Name = "Основной склад",
Code = "MAIN",
Kind = StoreKind.Warehouse,
IsMain = true,
Address = "Алматы, ул. Пример 1",
};

View file

@ -6,14 +6,17 @@ public record CountryDto(Guid Id, string Code, string Name, int SortOrder);
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol, int MinorUnit, bool IsActive);
public record VatRateDto(
Guid Id, string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault, bool IsActive);
public record UnitOfMeasureDto(
Guid Id, string Code, string Name, string? Description, bool IsActive);
Guid Id, string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase, bool IsActive);
public record PriceTypeDto(
Guid Id, string Name, bool IsDefault, bool IsRetail, int SortOrder, bool IsActive);
public record StoreDto(
Guid Id, string Name, string? Code, string? Address, string? Phone,
Guid Id, string Name, string? Code, StoreKind Kind, string? Address, string? Phone,
string? ManagerName, bool IsMain, bool IsActive);
public record RetailPointDto(
@ -24,7 +27,7 @@ public record ProductGroupDto(
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive);
public record CounterpartyDto(
Guid Id, string Name, string? LegalName, CounterpartyType Type,
Guid Id, string Name, string? LegalName, CounterpartyKind Kind, CounterpartyType Type,
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName,
string? Address, string? Phone, string? Email,
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive);
@ -35,12 +38,12 @@ public record ProductPriceDto(Guid Id, Guid PriceTypeId, string PriceTypeName, d
public record ProductDto(
Guid Id, string Name, string? Article, string? Description,
Guid UnitOfMeasureId, string UnitName,
int Vat, bool VatEnabled,
Guid UnitOfMeasureId, string UnitSymbol,
Guid VatRateId, decimal VatPercent,
Guid? ProductGroupId, string? ProductGroupName,
Guid? DefaultSupplierId, string? DefaultSupplierName,
Guid? CountryOfOriginId, string? CountryOfOriginName,
bool IsService, bool IsWeighed, bool IsMarked,
bool IsService, bool IsWeighed, bool IsAlcohol, bool IsMarked,
decimal? MinStock, decimal? MaxStock,
decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
string? ImageUrl, bool IsActive,
@ -50,10 +53,11 @@ public record ProductDto(
// Upsert payloads (input)
public record CountryInput(string Code, string Name, int SortOrder = 0);
public record CurrencyInput(string Code, string Name, string Symbol, int MinorUnit = 2, bool IsActive = true);
public record UnitOfMeasureInput(string Code, string Name, string? Description = null, bool IsActive = true);
public record VatRateInput(string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault = false, bool IsActive = true);
public record UnitOfMeasureInput(string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase = false, bool IsActive = true);
public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true);
public record StoreInput(
string Name, string? Code,
string Name, string? Code, StoreKind Kind = StoreKind.Warehouse,
string? Address = null, string? Phone = null, string? ManagerName = null,
bool IsMain = false, bool IsActive = true);
public record RetailPointInput(
@ -62,7 +66,7 @@ public record RetailPointInput(
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
public record ProductGroupInput(string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true);
public record CounterpartyInput(
string Name, string? LegalName, CounterpartyType Type,
string Name, string? LegalName, CounterpartyKind Kind, CounterpartyType Type,
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId,
string? Address, string? Phone, string? Email,
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive = true);
@ -70,9 +74,9 @@ public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ea
public record ProductPriceInput(Guid PriceTypeId, decimal Amount, Guid CurrencyId);
public record ProductInput(
string Name, string? Article, string? Description,
Guid UnitOfMeasureId, int Vat, bool VatEnabled,
Guid UnitOfMeasureId, Guid VatRateId,
Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
bool IsService = false, bool IsWeighed = false, bool IsMarked = false,
bool IsService = false, bool IsWeighed = false, bool IsAlcohol = false, bool IsMarked = false,
decimal? MinStock = null, decimal? MaxStock = null,
decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null,
string? ImageUrl = null, bool IsActive = true,

View file

@ -6,6 +6,7 @@ public class Counterparty : TenantEntity
{
public string Name { get; set; } = null!; // отображаемое имя
public string? LegalName { get; set; } // полное юридическое имя
public CounterpartyKind Kind { get; set; } // Supplier / Customer / Both
public CounterpartyType Type { get; set; } // Юрлицо / Физлицо
public string? Bin { get; set; } // БИН (для юрлиц РК)
public string? Iin { get; set; } // ИИН (для физлиц РК)

View file

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

View file

@ -11,10 +11,8 @@ public class Product : TenantEntity
public Guid UnitOfMeasureId { get; set; }
public UnitOfMeasure? UnitOfMeasure { get; set; }
// Ставка НДС на товаре — число 0/10/12/16/20 как в MoySklad.
// VatEnabled=true → НДС применяется, false → без НДС.
public int Vat { get; set; }
public bool VatEnabled { get; set; } = true;
public Guid VatRateId { get; set; }
public VatRate? VatRate { get; set; }
public Guid? ProductGroupId { get; set; }
public ProductGroup? ProductGroup { get; set; }
@ -27,6 +25,7 @@ public class Product : TenantEntity
public bool IsService { get; set; } // услуга, а не физический товар
public bool IsWeighed { get; set; } // весовой (продаётся с весов)
public bool IsAlcohol { get; set; } // алкоголь (подакцизный)
public bool IsMarked { get; set; } // маркируемый (Datamatrix)
public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений)

View file

@ -2,12 +2,12 @@
namespace foodmarket.Domain.Catalog;
// Склад: физическое место хранения товаров. MoySklad не различает "склад" и
// "торговый зал" — это одна сущность entity/store, опираемся на это.
// Склад: физическое место хранения товаров. Может быть чисто склад или торговый зал.
public class Store : TenantEntity
{
public string Name { get; set; } = null!;
public string? Code { get; set; } // внутренний код склада
public StoreKind Kind { get; set; } = StoreKind.Warehouse;
public string? Address { get; set; }
public string? Phone { get; set; }
public string? ManagerName { get; set; }

View file

@ -2,11 +2,13 @@
namespace foodmarket.Domain.Catalog;
// Единица измерения как в MoySklad entity/uom: code + name + description.
// Tenant-scoped справочник единиц измерения.
public class UnitOfMeasure : TenantEntity
{
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
public string Symbol { get; set; } = null!; // "шт", "кг", "л", "м"
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
public string? Description { get; set; }
public int DecimalPlaces { get; set; } // 0 для шт, 3 для кг/л
public bool IsBase { get; set; } // базовая единица этой организации
public bool IsActive { get; set; } = true;
}

View file

@ -0,0 +1,13 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Catalog;
// Tenant-scoped: разные организации могут работать в разных режимах (с НДС / упрощёнка).
public class VatRate : TenantEntity
{
public string Name { get; set; } = null!; // "НДС 12%", "Без НДС"
public decimal Percent { get; set; } // 12.00, 0.00
public bool IsIncludedInPrice { get; set; } // входит ли в цену или начисляется сверху
public bool IsDefault { get; set; }
public bool IsActive { get; set; } = true;
}

View file

@ -63,78 +63,60 @@ public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, C
: MoySkladApiResult<MsOrganization>.Ok(org);
}
// MoySklad list endpoints по умолчанию возвращают только активных (archived=false).
// Чтобы получить архивных, нужен явный filter=archived=true. Делаем два прохода:
// сначала активные (default), затем архивные — и отдаём всё одним потоком.
public async IAsyncEnumerable<MsProduct> StreamProductsAsync(
string token,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: false, ct)) yield return p;
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: true, ct)) yield return p;
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)
{
await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: false, ct)) yield return c;
await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: true, ct)) yield return c;
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>();
await foreach (var f in StreamPagedAsync<MsProductFolder>(token, "entity/productfolder", archivedOnly: false, ct)) all.Add(f);
await foreach (var f in StreamPagedAsync<MsProductFolder>(token, "entity/productfolder", archivedOnly: true, ct)) all.Add(f);
return all;
}
private async IAsyncEnumerable<T> StreamPagedAsync<T>(
string token,
string path,
bool archivedOnly,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
const int pageSize = 1000;
const int maxAttempts = 5;
var offset = 0;
var filterSuffix = archivedOnly ? "&filter=archived=true" : "";
const int pageSize = 1000;
while (true)
{
MsListResponse<T>? page = null;
Exception? lastErr = null;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
using var req = Build(HttpMethod.Get, $"{path}?limit={pageSize}&offset={offset}{filterSuffix}", token);
using var res = await _http.SendAsync(req, ct);
res.EnsureSuccessStatusCode();
page = await res.Content.ReadFromJsonAsync<MsListResponse<T>>(Json, ct);
lastErr = null;
break;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException)
{
lastErr = ex;
if (attempt == maxAttempts) break;
// Exponential-ish backoff: 2s, 4s, 8s, 16s.
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct);
}
}
if (lastErr is not null)
{
// Re-throw after retries so the caller sees a real failure instead of silent halt.
throw new InvalidOperationException(
$"MoySklad paging failed at {path} offset={offset} after {maxAttempts} attempts: {lastErr.Message}",
lastErr);
}
if (page is null || page.Rows.Count == 0) yield break;
foreach (var row in page.Rows) yield return row;
if (page.Rows.Count < pageSize) yield break;
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

@ -39,11 +39,14 @@ public async Task<MoySkladImportResult> ImportCounterpartiesAsync(string token,
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще —
// counterparty entity содержит только group (группа доступа), tags
// (произвольные), state (пользовательская цепочка статусов), companyType
// (legal/individual/entrepreneur). Никакого role/kind. Поэтому у нас тоже
// этого поля нет — пусть пользователь сам решит.
// 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
@ -52,15 +55,11 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp
_ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity,
};
// Загружаем существующих в память — обновлять будем по имени (case-insensitive).
// Раньше при overwriteExisting=true бага: пропускалась проверка "skip" и ВСЕ
// поступающие записи добавлялись как новые, порождая дубли. Теперь если имя уже
// есть — обновляем ту же запись, иначе создаём.
var existingByName = await _db.Counterparties
.ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct);
.Select(c => new { c.Id, c.Name })
.ToDictionaryAsync(c => c.Name, c => c.Id, StringComparer.OrdinalIgnoreCase, ct);
var created = 0;
var updated = 0;
var skipped = 0;
var total = 0;
var errors = new List<string>();
@ -69,25 +68,34 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp
await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
{
total++;
// Архивных не пропускаем — импортируем как IsActive=false (см. ApplyCounterparty).
if (c.Archived) { skipped++; continue; }
if (existingByName.ContainsKey(c.Name) && !overwriteExisting)
{
skipped++;
continue;
}
try
{
if (existingByName.TryGetValue(c.Name, out var existing))
var entity = new foodmarket.Domain.Catalog.Counterparty
{
if (!overwriteExisting) { skipped++; continue; }
ApplyCounterparty(existing, c, ResolveType);
updated++;
}
else
{
var entity = new foodmarket.Domain.Catalog.Counterparty { OrganizationId = orgId };
ApplyCounterparty(entity, c, ResolveType);
_db.Counterparties.Add(entity);
existingByName[c.Name] = entity;
created++;
}
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)
{
@ -103,25 +111,7 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
}
if (batch > 0) await _db.SaveChangesAsync(ct);
// `created` в отчёте = вставки + апдейты (чтобы не ломать UI, который знает только Created).
return new MoySkladImportResult(total, created + updated, skipped, 0, errors);
}
private static void ApplyCounterparty(
foodmarket.Domain.Catalog.Counterparty entity,
MsCounterparty c,
Func<string?, foodmarket.Domain.Catalog.CounterpartyType> resolveType)
{
entity.Name = Trim(c.Name, 255) ?? c.Name;
entity.LegalName = Trim(c.LegalTitle, 500);
entity.Type = resolveType(c.CompanyType);
entity.Bin = Trim(c.Inn, 20);
entity.TaxNumber = Trim(c.Kpp, 20);
entity.Phone = Trim(c.Phone, 50);
entity.Email = Trim(c.Email, 255);
entity.Address = Trim(c.ActualAddress ?? c.LegalAddress, 500);
entity.Notes = Trim(c.Description, 1000);
entity.IsActive = !c.Archived;
return new MoySkladImportResult(total, created, skipped, 0, errors);
}
public async Task<MoySkladImportResult> ImportProductsAsync(
@ -131,10 +121,11 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
// Pre-load tenant defaults. KZ default VAT is 16% — applied when product didn't
// carry its own vat from MoySklad.
const int kzDefaultVat = 16;
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", ct)
// 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);
@ -144,12 +135,11 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
.IgnoreQueryFilters()
.ToDictionaryAsync(c => c.Name, c => c.Id, ct);
// Import folders first — build flat then link parents. Архивные тоже берём,
// помечаем IsActive=false — у MoySklad у productfolder есть archived.
// 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.OrderBy(f => f.PathName?.Length ?? 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(
@ -164,7 +154,7 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
OrganizationId = orgId,
Name = f.Name,
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
IsActive = !f.Archived,
IsActive = true,
};
_db.ProductGroups.Add(g);
localGroupByMsId[f.Id] = g.Id;
@ -175,26 +165,28 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
// Import products
var errors = new List<string>();
var created = 0;
var updated = 0;
var skipped = 0;
var total = 0;
// При overwriteExisting=true загружаем товары целиком, чтобы обновлять существующие
// вместо создания дубликатов. Ключ = артикул (нормализованный).
var existingByArticle = await _db.Products
var existingArticles = await _db.Products
.Where(p => p.Article != null)
.ToDictionaryAsync(p => p.Article!, p => p, StringComparer.OrdinalIgnoreCase, ct);
.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++;
// Архивных не пропускаем — импортируем как IsActive=false.
if (p.Archived) { skipped++; continue; }
var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article;
var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingByArticle.ContainsKey(article);
var primaryBarcode = ExtractBarcodes(p).FirstOrDefault();
if (alreadyByArticle && !overwriteExisting)
var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingArticleSet.Contains(article);
var alreadyByBarcode = primaryBarcode is not null && existingBarcodeSet.Contains(primaryBarcode.Code);
if ((alreadyByArticle || alreadyByBarcode) && !overwriteExisting)
{
skipped++;
continue;
@ -202,8 +194,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
try
{
var vat = p.Vat ?? kzDefaultVat;
var vatEnabled = (p.Vat ?? kzDefaultVat) > 0;
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;
@ -211,71 +202,48 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true)
?? p.SalePrices?.FirstOrDefault();
Product product;
if (alreadyByArticle && overwriteExisting)
var product = new Product
{
product = existingByArticle[article!];
// Обновляем только скалярные поля — коллекции (prices, barcodes) оставляем:
// там могут быть данные, которые редактировал пользователь после импорта.
product.Name = Trim(p.Name, 500);
product.Article = Trim(article, 500);
product.Description = p.Description;
product.Vat = vat;
product.VatEnabled = vatEnabled;
product.ProductGroupId = groupId ?? product.ProductGroupId;
product.CountryOfOriginId = countryId ?? product.CountryOfOriginId;
product.IsWeighed = p.Weighed;
product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED";
product.IsActive = !p.Archived;
product.PurchasePrice = p.BuyPrice is null ? product.PurchasePrice : p.BuyPrice.Value / 100m;
updated++;
}
else
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 = new Product
product.Prices.Add(new ProductPrice
{
OrganizationId = orgId,
Name = Trim(p.Name, 500),
Article = Trim(article, 500),
Description = p.Description,
UnitOfMeasureId = baseUnit.Id,
Vat = vat,
VatEnabled = vatEnabled,
ProductGroupId = groupId,
CountryOfOriginId = countryId,
IsWeighed = p.Weighed,
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)) existingByArticle[article] = product;
created++;
PriceTypeId = retailType.Id,
Amount = retailPrice.Value / 100m,
CurrencyId = kzt.Id,
});
}
// Flush чаще (каждые 100) чтобы при сетевом обрыве на следующей странице
// мы сохранили как можно больше и смогли безопасно продолжить с overwrite.
if ((created + updated) % 100 == 0) await _db.SaveChangesAsync(ct);
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)
{
@ -285,7 +253,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
}
await _db.SaveChangesAsync(ct);
return new MoySkladImportResult(total, created + updated, skipped, groupsCreated, errors);
return new MoySkladImportResult(total, created, skipped, groupsCreated, errors);
}
private static List<ProductBarcode> ExtractBarcodes(MsProduct p)

View file

@ -26,6 +26,7 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<Country> Countries => Set<Country>();
public DbSet<Currency> Currencies => Set<Currency>();
public DbSet<VatRate> VatRates => Set<VatRate>();
public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>();
public DbSet<Counterparty> Counterparties => Set<Counterparty>();
public DbSet<Store> Stores => Set<Store>();

View file

@ -10,6 +10,7 @@ public static void ConfigureCatalog(this ModelBuilder b)
{
b.Entity<Country>(ConfigureCountry);
b.Entity<Currency>(ConfigureCurrency);
b.Entity<VatRate>(ConfigureVatRate);
b.Entity<UnitOfMeasure>(ConfigureUnit);
b.Entity<Counterparty>(ConfigureCounterparty);
b.Entity<Store>(ConfigureStore);
@ -39,12 +40,20 @@ private static void ConfigureCurrency(EntityTypeBuilder<Currency> b)
b.HasIndex(x => x.Code).IsUnique();
}
private static void ConfigureVatRate(EntityTypeBuilder<VatRate> b)
{
b.ToTable("vat_rates");
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
b.Property(x => x.Percent).HasPrecision(5, 2);
b.HasIndex(x => new { x.OrganizationId, x.Name }).IsUnique();
}
private static void ConfigureUnit(EntityTypeBuilder<UnitOfMeasure> b)
{
b.ToTable("units_of_measure");
b.Property(x => x.Code).HasMaxLength(10).IsRequired();
b.Property(x => x.Symbol).HasMaxLength(20).IsRequired();
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
b.Property(x => x.Description).HasMaxLength(500);
b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique();
}
@ -65,6 +74,7 @@ private static void ConfigureCounterparty(EntityTypeBuilder<Counterparty> b)
b.HasOne(x => x.Country).WithMany().HasForeignKey(x => x.CountryId).OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => new { x.OrganizationId, x.Name });
b.HasIndex(x => new { x.OrganizationId, x.Bin });
b.HasIndex(x => new { x.OrganizationId, x.Kind });
}
private static void ConfigureStore(EntityTypeBuilder<Store> b)
@ -120,6 +130,7 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
b.Property(x => x.ImageUrl).HasMaxLength(1000);
b.HasOne(x => x.UnitOfMeasure).WithMany().HasForeignKey(x => x.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.VatRate).WithMany().HasForeignKey(x => x.VatRateId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.ProductGroup).WithMany().HasForeignKey(x => x.ProductGroupId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.DefaultSupplier).WithMany().HasForeignKey(x => x.DefaultSupplierId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.CountryOfOrigin).WithMany().HasForeignKey(x => x.CountryOfOriginId).OnDelete(DeleteBehavior.Restrict);

View file

@ -1,70 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Reconciliation migration.
///
/// Предыдущие миграции Phase2c2_MoySkladAlignment и Phase2c3_MsStrict были применены
/// на стейдже, но их исходные .cs файлы были удалены при откате кода (commit 8fc9ef1
/// стёр их, но __EFMigrationsHistory уже содержал записи). В результате:
/// - snapshot был неактуальным (ссылался на VatRate, IsAlcohol, Kind, и т.п.)
/// - БД в состоянии пост-2c3 (поля Vat, VatEnabled, TrackingType; без VatRate,
/// без Kind, без IsAlcohol, без Symbol/DecimalPlaces/IsBase)
/// - код ожидает IsMarked вместо TrackingType
///
/// Задача этой миграции — добить различие в одной колонке: заменить TrackingType
/// (добавленный в Phase2c2) на IsMarked. Всё остальное уже совпадает.
/// EF-scaffold предложил много мусорной работы из-за рассинхрона snapshot'а — это
/// тело переписано вручную.</summary>
public partial class Phase2c4_ReconcileStage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1. Добавляем IsMarked с дефолтом false.
migrationBuilder.AddColumn<bool>(
name: "IsMarked",
schema: "public",
table: "products",
type: "boolean",
nullable: false,
defaultValue: false);
// 2. Если TrackingType есть в БД (стейдж) — бэкфиллим и удаляем.
// На свежей БД (dev, где migrations 2c2/2c3 не применялись отдельно)
// колонки не будет — IF EXISTS защищает от ошибки.
migrationBuilder.Sql("""
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'products'
AND column_name = 'TrackingType') THEN
UPDATE public.products SET "IsMarked" = ("TrackingType" <> 0);
ALTER TABLE public.products DROP COLUMN "TrackingType";
END IF;
END $$;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "TrackingType",
schema: "public",
table: "products",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.Sql("""UPDATE public.products SET "TrackingType" = CASE WHEN "IsMarked" THEN 99 ELSE 0 END;""");
migrationBuilder.DropColumn(
name: "IsMarked",
schema: "public",
table: "products");
}
}
}

View file

@ -380,6 +380,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("LegalName")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
@ -415,6 +418,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("OrganizationId", "Bin");
b.HasIndex("OrganizationId", "Kind");
b.HasIndex("OrganizationId", "Name");
b.ToTable("counterparties", "public");
@ -563,6 +568,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsAlcohol")
.HasColumnType("boolean");
b.Property<bool>("IsMarked")
.HasColumnType("boolean");
@ -604,11 +612,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Vat")
.HasColumnType("integer");
b.Property<bool>("VatEnabled")
.HasColumnType("boolean");
b.Property<Guid>("VatRateId")
.HasColumnType("uuid");
b.HasKey("Id");
@ -622,6 +627,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("UnitOfMeasureId");
b.HasIndex("VatRateId");
b.HasIndex("OrganizationId", "Article");
b.HasIndex("OrganizationId", "IsActive");
@ -869,6 +876,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("IsMain")
.HasColumnType("boolean");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("ManagerName")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
@ -909,13 +919,15 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("DecimalPlaces")
.HasColumnType("integer");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsBase")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
@ -924,6 +936,11 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
@ -935,6 +952,47 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("units_of_measure", "public");
});
modelBuilder.Entity("foodmarket.Domain.Catalog.VatRate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsDefault")
.HasColumnType("boolean");
b.Property<bool>("IsIncludedInPrice")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<decimal>("Percent")
.HasPrecision(5, 2)
.HasColumnType("numeric(5,2)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OrganizationId", "Name")
.IsUnique();
b.ToTable("vat_rates", "public");
});
modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b =>
{
b.Property<Guid>("Id")
@ -1594,6 +1652,12 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("foodmarket.Domain.Catalog.VatRate", "VatRate")
.WithMany()
.HasForeignKey("VatRateId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CountryOfOrigin");
b.Navigation("DefaultSupplier");
@ -1603,6 +1667,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("PurchaseCurrency");
b.Navigation("UnitOfMeasure");
b.Navigation("VatRate");
});
modelBuilder.Entity("foodmarket.Domain.Catalog.ProductBarcode", b =>

View file

@ -4,6 +4,7 @@ import { LoginPage } from '@/pages/LoginPage'
import { DashboardPage } from '@/pages/DashboardPage'
import { CountriesPage } from '@/pages/CountriesPage'
import { CurrenciesPage } from '@/pages/CurrenciesPage'
import { VatRatesPage } from '@/pages/VatRatesPage'
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
import { PriceTypesPage } from '@/pages/PriceTypesPage'
import { StoresPage } from '@/pages/StoresPage'
@ -45,6 +46,7 @@ export default function App() {
<Route path="/catalog/products/:id" element={<ProductEditPage />} />
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
<Route path="/catalog/vat-rates" element={<VatRatesPage />} />
<Route path="/catalog/price-types" element={<PriceTypesPage />} />
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
<Route path="/catalog/stores" element={<StoresPage />} />

View file

@ -1,111 +0,0 @@
import { useMemo, useState } from 'react'
import { ChevronRight, FolderTree } from 'lucide-react'
import { useProductGroups } from '@/lib/useLookups'
import type { ProductGroup } from '@/lib/types'
interface TreeNode {
group: ProductGroup
children: TreeNode[]
productCount?: number
}
function buildTree(groups: ProductGroup[]): TreeNode[] {
const byId = new Map<string, TreeNode>()
groups.forEach((g) => byId.set(g.id, { group: g, children: [] }))
const roots: TreeNode[] = []
byId.forEach((node) => {
if (node.group.parentId && byId.has(node.group.parentId)) {
byId.get(node.group.parentId)!.children.push(node)
} else {
roots.push(node)
}
})
const sortRec = (nodes: TreeNode[]) => {
nodes.sort((a, b) => (a.group.sortOrder - b.group.sortOrder) || a.group.name.localeCompare(b.group.name, 'ru'))
nodes.forEach((n) => sortRec(n.children))
}
sortRec(roots)
return roots
}
interface Props {
selectedId: string | null
onSelect: (id: string | null) => void
}
export function ProductGroupTree({ selectedId, onSelect }: Props) {
const { data: groups, isLoading } = useProductGroups()
const tree = useMemo(() => buildTree(groups ?? []), [groups])
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
const renderNode = (node: TreeNode, depth: number) => {
const hasChildren = node.children.length > 0
const isOpen = expanded.has(node.group.id)
const isActive = selectedId === node.group.id
return (
<div key={node.group.id}>
<div
className={
'flex items-center gap-1 text-sm rounded cursor-pointer select-none pr-2 hover:bg-slate-100 dark:hover:bg-slate-800 ' +
(isActive ? 'bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200 font-medium' : '')
}
style={{ paddingLeft: 4 + depth * 12 }}
>
{hasChildren ? (
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggle(node.group.id) }}
className="p-0.5 text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
aria-label={isOpen ? 'Свернуть' : 'Развернуть'}
>
<ChevronRight className={'w-3.5 h-3.5 transition-transform ' + (isOpen ? 'rotate-90' : '')} />
</button>
) : (
<span className="w-[18px]" />
)}
<button
type="button"
onClick={() => onSelect(node.group.id)}
className="flex-1 text-left py-1 truncate"
title={node.group.path}
>
{node.group.name}
</button>
</div>
{hasChildren && isOpen && node.children.map((c) => renderNode(c, depth + 1))}
</div>
)
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="px-2 py-2 border-b border-slate-200 dark:border-slate-800 flex items-center gap-2 text-xs uppercase tracking-wide text-slate-500">
<FolderTree className="w-3.5 h-3.5" /> Группы
</div>
<div className="flex-1 min-h-0 overflow-y-auto py-1">
<div
className={
'flex items-center gap-1 text-sm rounded cursor-pointer pr-2 hover:bg-slate-100 dark:hover:bg-slate-800 pl-2 ' +
(selectedId === null ? 'bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200 font-medium' : '')
}
onClick={() => onSelect(null)}
>
<button type="button" className="flex-1 text-left py-1">Все товары</button>
</div>
{isLoading && <div className="px-3 py-2 text-xs text-slate-400">Загрузка</div>}
{!isLoading && tree.length === 0 && (
<div className="px-3 py-2 text-xs text-slate-400">Групп ещё нет</div>
)}
{tree.map((n) => renderNode(n, 0))}
</div>
</div>
)
}

View file

@ -71,7 +71,7 @@ export function ProductPicker({ open, onClose, onPick, title = 'Выбор то
<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.unitName}</span>
<span>· {p.unitSymbol}</span>
</div>
</div>
{p.purchasePrice !== null && (

View file

@ -6,18 +6,25 @@ export interface PagedResult<T> {
totalPages: number
}
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
export type CounterpartyType = (typeof CounterpartyType)[keyof typeof CounterpartyType]
export const StoreKind = { Warehouse: 1, RetailFloor: 2 } as const
export type StoreKind = (typeof StoreKind)[keyof typeof StoreKind]
export const BarcodeType = { Ean13: 1, Ean8: 2, Code128: 3, Code39: 4, Upca: 5, Upce: 6, Other: 99 } as const
export type BarcodeType = (typeof BarcodeType)[keyof typeof BarcodeType]
export interface Country { id: string; code: string; name: string; sortOrder: number }
export interface Currency { id: string; code: string; name: string; symbol: string; minorUnit: number; isActive: boolean }
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; isActive: boolean }
export interface VatRate { id: string; name: string; percent: number; isIncludedInPrice: boolean; isDefault: boolean; isActive: boolean }
export interface UnitOfMeasure { id: string; code: string; symbol: string; name: string; decimalPlaces: number; isBase: boolean; isActive: boolean }
export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }
export interface Store {
id: string; name: string; code: string | null; address: string | null; phone: string | null;
id: string; name: string; code: string | null; kind: StoreKind; address: string | null; phone: string | null;
managerName: string | null; isMain: boolean; isActive: boolean
}
export interface RetailPoint {
@ -26,7 +33,7 @@ export interface RetailPoint {
}
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean }
export interface Counterparty {
id: string; name: string; legalName: string | null; type: CounterpartyType;
id: string; name: string; legalName: string | null; kind: CounterpartyKind; type: CounterpartyType;
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;
address: string | null; phone: string | null; email: string | null;
bankName: string | null; bankAccount: string | null; bik: string | null;
@ -36,12 +43,12 @@ export interface ProductBarcode { id: string; code: string; type: BarcodeType; i
export interface ProductPrice { id: string; priceTypeId: string; priceTypeName: string; amount: number; currencyId: string; currencyCode: string }
export interface Product {
id: string; name: string; article: string | null; description: string | null;
unitOfMeasureId: string; unitName: string;
vat: number; vatEnabled: boolean;
unitOfMeasureId: string; unitSymbol: string;
vatRateId: string; vatPercent: number;
productGroupId: string | null; productGroupName: string | null;
defaultSupplierId: string | null; defaultSupplierName: string | null;
countryOfOriginId: string | null; countryOfOriginName: string | null;
isService: boolean; isWeighed: boolean; isMarked: boolean;
isService: boolean; isWeighed: boolean; isAlcohol: boolean; isMarked: boolean;
minStock: number | null; maxStock: number | null;
purchasePrice: number | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
imageUrl: string | null; isActive: boolean;
@ -49,7 +56,7 @@ export interface Product {
}
export interface StockRow {
productId: string; productName: string; article: string | null; unitName: string;
productId: string; productName: string; article: string | null; unitSymbol: string;
storeId: string; storeName: string;
quantity: number; reservedQuantity: number; available: number;
}
@ -76,7 +83,7 @@ export interface SupplyListRow {
export interface SupplyLineDto {
id: string | null; productId: string;
productName: string | null; productArticle: string | null; unitName: string | null;
productName: string | null; productArticle: string | null; unitSymbol: string | null;
quantity: number; unitPrice: number; lineTotal: number; sortOrder: number;
}
@ -109,7 +116,7 @@ export interface RetailSaleListRow {
export interface RetailSaleLineDto {
id: string | null; productId: string;
productName: string | null; productArticle: string | null; unitName: string | null;
productName: string | null; productArticle: string | null; unitSymbol: string | null;
quantity: number; unitPrice: number; discount: number; lineTotal: number;
vatPercent: number; sortOrder: number;
}

View file

@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import type {
PagedResult, UnitOfMeasure, ProductGroup, Counterparty,
PagedResult, UnitOfMeasure, VatRate, ProductGroup, Counterparty,
Country, Currency, Store, PriceType,
} from '@/lib/types'
@ -14,6 +14,7 @@ function useLookup<T>(key: string, url: string) {
}
export const useUnits = () => useLookup<UnitOfMeasure>('units', '/api/catalog/units-of-measure')
export const useVatRates = () => useLookup<VatRate>('vat', '/api/catalog/vat-rates')
export const useProductGroups = () => useLookup<ProductGroup>('groups', '/api/catalog/product-groups')
export const useCountries = () => useLookup<Country>('countries', '/api/catalog/countries')
export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies')

View file

@ -10,7 +10,7 @@ import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types'
import { type Counterparty, type Country, type PagedResult, CounterpartyKind, CounterpartyType } from '@/lib/types'
const URL = '/api/catalog/counterparties'
@ -18,6 +18,7 @@ interface Form {
id?: string
name: string
legalName: string
kind: CounterpartyKind
type: CounterpartyType
bin: string
iin: string
@ -35,16 +36,20 @@ interface Form {
}
const blankForm: Form = {
name: '', legalName: '', type: CounterpartyType.LegalEntity,
// Kind по умолчанию Unspecified — MoySklad не имеет такого поля у контрагентов,
// не выдумываем за пользователя. Пусть выберет вручную если нужно.
name: '', legalName: '', kind: CounterpartyKind.Unspecified, type: CounterpartyType.LegalEntity,
bin: '', iin: '', taxNumber: '', countryId: '',
address: '', phone: '', email: '',
bankName: '', bankAccount: '', bik: '',
contactPerson: '', notes: '', isActive: true,
}
const typeLabel: Record<CounterpartyType, string> = {
[CounterpartyType.LegalEntity]: 'Юрлицо',
[CounterpartyType.Individual]: 'Физлицо',
const kindLabel: Record<CounterpartyKind, string> = {
[CounterpartyKind.Unspecified]: '—',
[CounterpartyKind.Supplier]: 'Поставщик',
[CounterpartyKind.Customer]: 'Покупатель',
[CounterpartyKind.Both]: 'Поставщик + Покупатель',
}
export function CounterpartiesPage() {
@ -87,7 +92,7 @@ export function CounterpartiesPage() {
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, legalName: r.legalName ?? '', type: r.type,
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 ?? '',
@ -95,7 +100,7 @@ export function CounterpartiesPage() {
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: 'Тип', width: '120px', cell: (r) => typeLabel[r.type] },
{ 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 ?? '—' },
@ -134,6 +139,14 @@ export function CounterpartiesPage() {
<Field label="Юридическое название" className="col-span-2">
<TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} />
</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>
</Select>
</Field>
<Field label="Тип лица">
<Select value={form.type} onChange={(e) => setForm({ ...form, type: Number(e.target.value) as CounterpartyType })}>
<option value={CounterpartyType.LegalEntity}>Юрлицо</option>

View file

@ -1,6 +1,6 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package, Trash2, AlertTriangle } from 'lucide-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'
@ -122,141 +122,11 @@ export function MoySkladImportPage() {
<ImportResult title="Товары" result={products} />
<ImportResult title="Контрагенты" result={counterparties} />
<DangerZone />
</div>
</div>
)
}
interface CleanupStats {
counterparties: number
products: number
productGroups: number
productBarcodes: number
productPrices: number
supplies: number
retailSales: number
stocks: number
stockMovements: number
}
interface CleanupResult { scope: string; deleted: CleanupStats }
function DangerZone() {
const qc = useQueryClient()
const stats = useQuery({
queryKey: ['/api/admin/cleanup/stats'],
queryFn: async () => (await api.get<CleanupStats>('/api/admin/cleanup/stats')).data,
refetchOnMount: 'always',
})
const wipeCounterparties = useMutation({
mutationFn: async () => (await api.delete<CleanupResult>('/api/admin/cleanup/counterparties')).data,
onSuccess: () => {
qc.invalidateQueries()
},
})
const wipeAll = useMutation({
mutationFn: async () => (await api.delete<CleanupResult>('/api/admin/cleanup/all')).data,
onSuccess: () => {
qc.invalidateQueries()
},
})
const confirmAndRun = (label: string, run: () => void) => {
const word = prompt(`Введи УДАЛИТЬ чтобы подтвердить: ${label}`)
if (word?.trim().toUpperCase() === 'УДАЛИТЬ') run()
}
const s = stats.data
return (
<section className="mt-8 rounded-xl border-2 border-red-200 dark:border-red-900/50 bg-red-50/40 dark:bg-red-950/20 p-5">
<h2 className="text-sm font-semibold text-red-900 dark:text-red-300 flex items-center gap-2 mb-1.5">
<AlertTriangle className="w-4 h-4" /> Опасная зона временные инструменты очистки
</h2>
<p className="text-xs text-red-900/80 dark:text-red-300/80 mb-4">
Удаляет данные текущей организации. Справочники (ед. измерения, страны, валюты, типы цен, склады, точки продаж), пользователи и сама организация сохраняются.
</p>
{s && (
<dl className="grid grid-cols-3 md:grid-cols-5 gap-2 text-xs mb-4">
<Stat label="Контрагенты" value={s.counterparties} />
<Stat label="Товары" value={s.products} />
<Stat label="Группы" value={s.productGroups} />
<Stat label="Штрихкоды" value={s.productBarcodes} />
<Stat label="Цены" value={s.productPrices} />
<Stat label="Поставки" value={s.supplies} />
<Stat label="Чеки" value={s.retailSales} />
<Stat label="Остатки" value={s.stocks} />
<Stat label="Движения" value={s.stockMovements} />
</dl>
)}
<div className="flex gap-3 flex-wrap">
<Button
variant="danger"
onClick={() => confirmAndRun(
`${s?.counterparties ?? '?'} контрагентов (+ связанные поставки/движения)`,
() => wipeCounterparties.mutate(),
)}
disabled={wipeCounterparties.isPending || !s || s.counterparties === 0}
>
<Trash2 className="w-4 h-4" />
{wipeCounterparties.isPending ? 'Удаляю…' : 'Удалить контрагентов'}
</Button>
<Button
variant="danger"
onClick={() => confirmAndRun(
'ВСЕ данные организации (товары, группы, контрагенты, документы, остатки)',
() => wipeAll.mutate(),
)}
disabled={wipeAll.isPending}
>
<Trash2 className="w-4 h-4" />
{wipeAll.isPending ? 'Удаляю всё…' : 'Очистить все данные'}
</Button>
</div>
{wipeCounterparties.data && (
<div className="mt-3 text-xs text-red-900 dark:text-red-300">
Удалено: {wipeCounterparties.data.deleted.counterparties} контрагентов,
{' '}{wipeCounterparties.data.deleted.supplies} поставок,
{' '}{wipeCounterparties.data.deleted.stockMovements} движений.
</div>
)}
{wipeAll.data && (
<div className="mt-3 text-xs text-red-900 dark:text-red-300">
Удалено: {wipeAll.data.deleted.counterparties} контрагентов,
{' '}{wipeAll.data.deleted.products} товаров,
{' '}{wipeAll.data.deleted.productGroups} групп,
{' '}{wipeAll.data.deleted.supplies} поставок,
{' '}{wipeAll.data.deleted.retailSales} чеков,
{' '}{wipeAll.data.deleted.stockMovements} движений.
</div>
)}
{wipeCounterparties.error && (
<div className="mt-3 text-xs text-red-700">{formatError(wipeCounterparties.error)}</div>
)}
{wipeAll.error && (
<div className="mt-3 text-xs text-red-700">{formatError(wipeAll.error)}</div>
)}
</section>
)
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded bg-white dark:bg-slate-900 border border-red-200 dark:border-red-900/50 px-2 py-1.5">
<dt className="text-[10px] uppercase text-slate-500">{label}</dt>
<dd className="font-mono font-semibold">{value.toLocaleString('ru')}</dd>
</div>
)
}
function ImportResult({ title, result }: { title: string; result: UseMutationResult<ImportResponse, Error, void, unknown> }) {
if (!result.data && !result.error) return null
return (

View file

@ -6,7 +6,7 @@ import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
import {
useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
useUnits, useVatRates, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
} from '@/lib/useLookups'
import { BarcodeType, type Product } from '@/lib/types'
@ -18,13 +18,13 @@ interface Form {
article: string
description: string
unitOfMeasureId: string
vat: number
vatEnabled: boolean
vatRateId: string
productGroupId: string
defaultSupplierId: string
countryOfOriginId: string
isService: boolean
isWeighed: boolean
isAlcohol: boolean
isMarked: boolean
isActive: boolean
minStock: string
@ -36,15 +36,11 @@ interface Form {
barcodes: BarcodeRow[]
}
// KZ default VAT rate.
const defaultVat = 16
const vatChoices = [0, 10, 12, 16, 20]
const emptyForm: Form = {
name: '', article: '', description: '',
unitOfMeasureId: '', vat: defaultVat, vatEnabled: true,
unitOfMeasureId: '', vatRateId: '',
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
isService: false, isWeighed: false, isMarked: false, isActive: true,
isService: false, isWeighed: false, isAlcohol: false, isMarked: false, isActive: true,
minStock: '', maxStock: '',
purchasePrice: '', purchaseCurrencyId: '',
imageUrl: '',
@ -59,6 +55,7 @@ export function ProductEditPage() {
const qc = useQueryClient()
const units = useUnits()
const vats = useVatRates()
const groups = useProductGroups()
const countries = useCountries()
const currencies = useCurrencies()
@ -79,10 +76,10 @@ export function ProductEditPage() {
const p = existing.data
setForm({
name: p.name, article: p.article ?? '', description: p.description ?? '',
unitOfMeasureId: p.unitOfMeasureId, vat: p.vat, vatEnabled: p.vatEnabled,
unitOfMeasureId: p.unitOfMeasureId, vatRateId: p.vatRateId,
productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '',
countryOfOriginId: p.countryOfOriginId ?? '',
isService: p.isService, isWeighed: p.isWeighed, isMarked: p.isMarked,
isService: p.isService, isWeighed: p.isWeighed, isAlcohol: p.isAlcohol, isMarked: p.isMarked,
isActive: p.isActive,
minStock: p.minStock?.toString() ?? '',
maxStock: p.maxStock?.toString() ?? '',
@ -96,13 +93,16 @@ export function ProductEditPage() {
}, [isNew, existing.data])
useEffect(() => {
if (isNew && form.vatRateId === '' && vats.data?.length) {
setForm((f) => ({ ...f, vatRateId: vats.data?.find(v => v.isDefault)?.id ?? vats.data?.[0]?.id ?? '' }))
}
if (isNew && form.unitOfMeasureId === '' && units.data?.length) {
setForm((f) => ({ ...f, unitOfMeasureId: units.data?.[0]?.id ?? '' }))
setForm((f) => ({ ...f, unitOfMeasureId: units.data?.find(u => u.isBase)?.id ?? units.data?.[0]?.id ?? '' }))
}
if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) {
setForm((f) => ({ ...f, purchaseCurrencyId: currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id ?? '' }))
}
}, [isNew, units.data, currencies.data, form.unitOfMeasureId, form.purchaseCurrencyId])
}, [isNew, vats.data, units.data, currencies.data, form.vatRateId, form.unitOfMeasureId, form.purchaseCurrencyId])
const save = useMutation({
mutationFn: async () => {
@ -111,13 +111,13 @@ export function ProductEditPage() {
article: form.article || null,
description: form.description || null,
unitOfMeasureId: form.unitOfMeasureId,
vat: form.vat,
vatEnabled: form.vatEnabled,
vatRateId: form.vatRateId,
productGroupId: form.productGroupId || null,
defaultSupplierId: form.defaultSupplierId || null,
countryOfOriginId: form.countryOfOriginId || null,
isService: form.isService,
isWeighed: form.isWeighed,
isAlcohol: form.isAlcohol,
isMarked: form.isMarked,
isActive: form.isActive,
minStock: form.minStock === '' ? null : Number(form.minStock),
@ -168,7 +168,7 @@ 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
const canSave = form.name.trim().length > 0 && !!form.unitOfMeasureId && !!form.vatRateId
return (
<form onSubmit={onSubmit} className="flex flex-col h-full">
@ -234,12 +234,13 @@ export function ProductEditPage() {
<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.name}</option>)}
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.symbol} {u.name}</option>)}
</Select>
</Field>
<Field label="Ставка НДС, %">
<Select value={form.vat} onChange={(e) => setForm({ ...form, vat: Number(e.target.value) })}>
{vatChoices.map((v) => <option key={v} value={v}>{v}%</option>)}
<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="Группа">
@ -265,9 +266,9 @@ export function ProductEditPage() {
</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.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} />
<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>

View file

@ -1,194 +1,65 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
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 { Plus, Filter, X } from 'lucide-react'
import { Plus } from 'lucide-react'
import { useCatalogList } from '@/lib/useCatalog'
import { ProductGroupTree } from '@/components/ProductGroupTree'
import type { Product } from '@/lib/types'
const URL = '/api/catalog/products'
type TriFilter = 'all' | 'yes' | 'no'
interface Filters {
groupId: string | null
isActive: TriFilter
isService: TriFilter
isWeighed: TriFilter
isMarked: TriFilter
hasBarcode: TriFilter
}
const defaultFilters: Filters = {
groupId: null,
isActive: 'yes',
isService: 'all',
isWeighed: 'all',
isMarked: 'all',
hasBarcode: 'all',
}
const toExtra = (f: Filters): Record<string, string | number | boolean | undefined> => {
const e: Record<string, string | number | boolean | undefined> = {}
if (f.groupId) e.groupId = f.groupId
if (f.isActive !== 'all') e.isActive = f.isActive === 'yes'
if (f.isService !== 'all') e.isService = f.isService === 'yes'
if (f.isWeighed !== 'all') e.isWeighed = f.isWeighed === 'yes'
if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes'
if (f.hasBarcode !== 'all') e.hasBarcode = f.hasBarcode === 'yes'
return e
}
const activeFilterCount = (f: Filters) => {
let n = 0
if (f.groupId) n++
if (f.isActive !== 'yes') n++ // 'yes' is default, count non-default
if (f.isService !== 'all') n++
if (f.isWeighed !== 'all') n++
if (f.isMarked !== 'all') n++
if (f.hasBarcode !== 'all') n++
return n
}
function Tri({
label, value, onChange, yesLabel = 'да', noLabel = 'нет',
}: {
label: string
value: TriFilter
onChange: (v: TriFilter) => void
yesLabel?: string
noLabel?: string
}) {
const opts: { v: TriFilter; t: string }[] = [
{ v: 'all', t: 'все' },
{ v: 'yes', t: yesLabel },
{ v: 'no', t: noLabel },
]
return (
<div className="flex items-center gap-2 text-xs">
<span className="text-slate-500">{label}</span>
<div className="inline-flex rounded border border-slate-200 dark:border-slate-700 overflow-hidden">
{opts.map((o) => (
<button
key={o.v}
type="button"
onClick={() => onChange(o.v)}
className={
'px-2 py-0.5 ' +
(value === o.v
? 'bg-slate-900 text-white dark:bg-slate-200 dark:text-slate-900'
: 'bg-white dark:bg-slate-900 text-slate-600 hover:bg-slate-50 dark:hover:bg-slate-800')
}
>
{o.t}
</button>
))}
</div>
</div>
)
}
export function ProductsPage() {
const navigate = useNavigate()
const [filters, setFilters] = useState<Filters>(defaultFilters)
const [filtersOpen, setFiltersOpen] = useState(false)
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL, toExtra(filters))
const activeCount = activeFilterCount(filters)
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL)
return (
<div className="flex h-full min-h-0">
{/* Left: groups tree */}
<aside className="w-64 flex-shrink-0 border-r border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-900/40 flex flex-col min-h-0">
<ProductGroupTree
selectedId={filters.groupId}
onSelect={(id) => { setFilters({ ...filters, groupId: id }); setPage(1) }}
/>
</aside>
{/* Right: products */}
<div className="flex-1 min-w-0 flex flex-col">
{/* Top bar */}
<div className="px-6 py-3 border-b border-slate-200 dark:border-slate-800 flex items-center gap-3 flex-wrap">
<div>
<h1 className="text-base font-semibold">Товары</h1>
<p className="text-xs text-slate-500">
{data ? `${data.total.toLocaleString('ru')} записей` : 'Каталог товаров и услуг'}
</p>
</div>
<div className="ml-auto flex items-center gap-2">
<SearchBar value={search} onChange={setSearch} placeholder="Поиск: название, артикул, штрихкод…" />
<Button
variant={filtersOpen || activeCount ? 'primary' : 'secondary'}
onClick={() => setFiltersOpen((v) => !v)}
>
<Filter className="w-4 h-4" /> Фильтры{activeCount > 0 ? ` (${activeCount})` : ''}
<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 to="/catalog/products/new">
<Button><Plus className="w-4 h-4" /> Добавить</Button>
</Link>
</div>
</div>
{/* Filter panel */}
{filtersOpen && (
<div className="px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-900/60 flex flex-wrap gap-4 items-center">
<Tri label="Активные" value={filters.isActive} onChange={(v) => { setFilters({ ...filters, isActive: v }); setPage(1) }} />
<Tri label="Услуга" value={filters.isService} onChange={(v) => { setFilters({ ...filters, isService: v }); setPage(1) }} />
<Tri label="Весовой" value={filters.isWeighed} onChange={(v) => { setFilters({ ...filters, isWeighed: v }); setPage(1) }} />
<Tri label="Маркируемый" value={filters.isMarked} onChange={(v) => { setFilters({ ...filters, isMarked: v }); setPage(1) }} />
<Tri label="Со штрихкодом" value={filters.hasBarcode} onChange={(v) => { setFilters({ ...filters, hasBarcode: v }); setPage(1) }} yesLabel="есть" noLabel="нет" />
{activeCount > 0 && (
<button
type="button"
onClick={() => { setFilters(defaultFilters); setPage(1) }}
className="text-xs text-slate-500 hover:text-slate-800 inline-flex items-center gap-1"
>
<X className="w-3.5 h-3.5" /> Сбросить
</button>
)}
</div>
)}
{/* Table */}
<div className="flex-1 min-h-0 overflow-auto">
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => navigate(`/catalog/products/${r.id}`)}
columns={[
{ header: 'Название', cell: (r) => (
<div>
<div className="font-medium">{r.name}</div>
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
</div>
)},
{ header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' },
{ header: 'Ед.', width: '70px', cell: (r) => r.unitName },
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vat}%` },
{ 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>}
{r.isMarked && <span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">Маркир.</span>}
</div>
)},
{ header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
empty="Товаров ещё нет. Они появятся после приёмки или через API."
/>
</div>
{data && data.total > 0 && (
<div className="px-6 py-3 border-t border-slate-200 dark:border-slate-800">
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
</div>
)}
</div>
</div>
</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(`/catalog/products/${r.id}`)}
columns={[
{ header: 'Название', cell: (r) => (
<div>
<div className="font-medium">{r.name}</div>
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
</div>
)},
{ 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: '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>}
{r.isAlcohol && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-red-700">Алкоголь</span>}
{r.isMarked && <span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">Маркир.</span>}
</div>
)},
{ header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
empty="Товаров ещё нет. Они появятся после приёмки или через API."
/>
</ListPageShell>
)
}

View file

@ -14,7 +14,7 @@ interface LineRow {
productId: string
productName: string
productArticle: string | null
unitName: string | null
unitSymbol: string | null
quantity: number
unitPrice: number
discount: number
@ -81,7 +81,7 @@ export function RetailSaleEditPage() {
productId: l.productId,
productName: l.productName ?? '',
productArticle: l.productArticle,
unitName: l.unitName,
unitSymbol: l.unitSymbol,
quantity: l.quantity, unitPrice: l.unitPrice, discount: l.discount,
vatPercent: l.vatPercent,
})),
@ -179,11 +179,11 @@ export function RetailSaleEditPage() {
productId: p.id,
productName: p.name,
productArticle: p.article,
unitName: p.unitName,
unitSymbol: p.unitSymbol,
quantity: 1,
unitPrice: retail?.amount ?? 0,
discount: 0,
vatPercent: p.vat * 1,
vatPercent: p.vatPercent,
}],
})
}
@ -323,7 +323,7 @@ export function RetailSaleEditPage() {
<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.unitName}</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}

View file

@ -60,7 +60,7 @@ export function StockPage() {
</div>
)},
{ header: 'Склад', width: '220px', cell: (r) => r.storeName },
{ header: 'Ед.', width: '80px', cell: (r) => r.unitName },
{ 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) => (

View file

@ -6,9 +6,9 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Checkbox } from '@/components/Field'
import { Field, TextInput, Select, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { type Store } from '@/lib/types'
import { type Store, StoreKind } from '@/lib/types'
const URL = '/api/catalog/stores'
@ -16,6 +16,7 @@ interface Form {
id?: string
name: string
code: string
kind: StoreKind
address: string
phone: string
managerName: string
@ -24,7 +25,7 @@ interface Form {
}
const blankForm: Form = {
name: '', code: '', address: '', phone: '',
name: '', code: '', kind: StoreKind.Warehouse, address: '', phone: '',
managerName: '', isMain: false, isActive: true,
}
@ -61,13 +62,14 @@ export function StoresPage() {
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, code: r.code ?? '',
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 ? '✓' : '—' },
@ -106,6 +108,12 @@ export function StoresPage() {
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
</Field>
</div>
<Field label="Тип">
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as StoreKind })}>
<option value={StoreKind.Warehouse}>Склад</option>
<option value={StoreKind.RetailFloor}>Торговый зал</option>
</Select>
</Field>
<Field label="Адрес">
<TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</Field>

View file

@ -14,7 +14,7 @@ interface LineRow {
productId: string
productName: string
productArticle: string | null
unitName: string | null
unitSymbol: string | null
quantity: number
unitPrice: number
}
@ -76,7 +76,7 @@ export function SupplyEditPage() {
productId: l.productId,
productName: l.productName ?? '',
productArticle: l.productArticle,
unitName: l.unitName,
unitSymbol: l.unitSymbol,
quantity: l.quantity,
unitPrice: l.unitPrice,
})),
@ -169,7 +169,7 @@ export function SupplyEditPage() {
productId: p.id,
productName: p.name,
productArticle: p.article,
unitName: p.unitName,
unitSymbol: p.unitSymbol,
quantity: 1,
unitPrice: p.purchasePrice ?? 0,
}],
@ -304,7 +304,7 @@ export function SupplyEditPage() {
<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.unitName}</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"

View file

@ -15,12 +15,14 @@ const URL = '/api/catalog/units-of-measure'
interface Form {
id?: string
code: string
symbol: string
name: string
description: string
decimalPlaces: number
isBase: boolean
isActive: boolean
}
const blankForm: Form = { code: '', name: '', description: '', isActive: true }
const blankForm: Form = { code: '', symbol: '', name: '', decimalPlaces: 0, isBase: false, isActive: true }
export function UnitsOfMeasurePage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<UnitOfMeasure>(URL)
@ -39,7 +41,7 @@ export function UnitsOfMeasurePage() {
<>
<ListPageShell
title="Единицы измерения"
description="Код по ОКЕИ (штука=796, килограмм=166, литр=112, метр=006)."
description="Код по ОКЕИ (шт=796, кг=166, л=112, м=006)."
actions={
<>
<SearchBar value={search} onChange={setSearch} />
@ -54,14 +56,13 @@ export function UnitsOfMeasurePage() {
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, code: r.code, name: r.name,
description: r.description ?? '', isActive: r.isActive,
})}
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: 'Описание', cell: (r) => r.description ?? '—' },
{ header: 'Дробность', width: '120px', className: 'text-right', cell: (r) => r.decimalPlaces },
{ header: 'Базовая', width: '100px', cell: (r) => r.isBase ? '✓' : '—' },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
@ -90,15 +91,25 @@ export function UnitsOfMeasurePage() {
>
{form && (
<div className="space-y-3">
<Field label="Код ОКЕИ">
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Код ОКЕИ">
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
</Field>
<Field label="Символ">
<TextInput value={form.symbol} onChange={(e) => setForm({ ...form, symbol: e.target.value })} />
</Field>
</div>
<Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Описание">
<TextInput value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<Field label="Количество знаков после запятой">
<TextInput
type="number" min="0" max="6"
value={form.decimalPlaces}
onChange={(e) => setForm({ ...form, decimalPlaces: Number(e.target.value) })}
/>
</Field>
<Checkbox label="Базовая единица организации" checked={form.isBase} onChange={(v) => setForm({ ...form, isBase: v })} />
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
)}

View file

@ -0,0 +1,116 @@
import { useState } from 'react'
import { Plus, Trash2 } 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 { Modal } from '@/components/Modal'
import { Field, TextInput, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { VatRate } from '@/lib/types'
const URL = '/api/catalog/vat-rates'
interface Form {
id?: string
name: string
percent: number
isIncludedInPrice: boolean
isDefault: boolean
isActive: boolean
}
const blankForm: Form = { name: '', percent: 0, isIncludedInPrice: true, isDefault: false, isActive: true }
export function VatRatesPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<VatRate>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const save = async () => {
if (!form) return
const payload = {
name: form.name, percent: form.percent,
isIncludedInPrice: form.isIncludedInPrice, isDefault: form.isDefault, isActive: form.isActive,
}
if (form.id) await update.mutateAsync({ id: form.id, input: payload })
else await create.mutateAsync(payload)
setForm(null)
}
return (
<>
<ListPageShell
title="Ставки НДС"
description="Настройки ставок налога на добавленную стоимость."
actions={
<>
<SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
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}
onClose={() => setForm(null)}
title={form?.id ? 'Редактировать ставку НДС' : 'Новая ставка НДС'}
footer={
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить ставку?')) {
await remove.mutateAsync(form.id!)
setForm(null)
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
</>
}
>
{form && (
<div className="space-y-3">
<Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Процент">
<TextInput
type="number" step="0.01"
value={form.percent}
onChange={(e) => setForm({ ...form, percent: Number(e.target.value) })}
/>
</Field>
<Checkbox label="НДС включён в цену" checked={form.isIncludedInPrice} onChange={(v) => setForm({ ...form, isIncludedInPrice: v })} />
<Checkbox label="По умолчанию" checked={form.isDefault} onChange={(v) => setForm({ ...form, isDefault: v })} />
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
)}
</Modal>
</>
)
}