Compare commits
23 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57e8491f0d | ||
|
|
2fc6d207f3 | ||
|
|
bd15854b42 | ||
|
|
69e6fd808a | ||
|
|
beae0ad604 | ||
|
|
5f0692587a | ||
|
|
8346c9a72e | ||
|
|
9891280bfd | ||
|
|
8fc9ef1a2e | ||
|
|
3fd2f8a223 | ||
|
|
6ab8ff00d1 | ||
|
|
41fe088586 | ||
|
|
82d74bd8fe | ||
|
|
e408647b4b | ||
|
|
326af2f361 | ||
|
|
50f6db8569 | ||
|
|
2b0a677221 | ||
|
|
3c17b963f3 | ||
|
|
495f0aabee | ||
|
|
afbf01304a | ||
|
|
e9a82dd528 | ||
|
|
50f12ef7f0 | ||
|
|
d455087bc8 |
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"sessionId":"791848bb-ad06-4ccc-9c5a-13da4ee36524","pid":353363,"acquiredAt":1776883641374}
|
||||||
82
.forgejo/workflows/ci.yml
Normal file
82
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
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
|
||||||
|
|
||||||
115
.forgejo/workflows/docker.yml
Normal file
115
.forgejo/workflows/docker.yml
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
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
|
||||||
18
.forgejo/workflows/notify.yml
Normal file
18
.forgejo/workflows/notify.yml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
name: Notify CI failures
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["CI", "Docker Images"]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
telegram:
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
|
||||||
|
runs-on: [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
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
ARG LOCAL_REGISTRY=127.0.0.1:5001
|
||||||
|
FROM ${LOCAL_REGISTRY}/mirror/dotnet-sdk:8.0 AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY food-market.sln global.json Directory.Build.props Directory.Packages.props ./
|
COPY food-market.sln global.json Directory.Build.props Directory.Packages.props ./
|
||||||
|
|
@ -15,7 +16,7 @@ RUN dotnet restore src/food-market.api/food-market.api.csproj
|
||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app --no-restore
|
RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app --no-restore
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
FROM ${LOCAL_REGISTRY}/mirror/dotnet-aspnet:8.0 AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
FROM node:20-alpine AS build
|
ARG LOCAL_REGISTRY=127.0.0.1:5001
|
||||||
|
FROM ${LOCAL_REGISTRY}/mirror/node:20-alpine AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
@ -9,7 +10,7 @@ RUN pnpm install --frozen-lockfile
|
||||||
COPY src/food-market.web/ ./
|
COPY src/food-market.web/ ./
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
FROM nginx:1.27-alpine AS runtime
|
FROM ${LOCAL_REGISTRY}/mirror/nginx:1.27-alpine AS runtime
|
||||||
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=build /src/dist /usr/share/nginx/html
|
COPY --from=build /src/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: ${REGISTRY:-127.0.0.1:5001}/mirror/postgres:16-alpine
|
||||||
container_name: food-market-postgres
|
container_name: food-market-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -54,3 +54,4 @@ volumes:
|
||||||
name: food-market-api-data
|
name: food-market-api-data
|
||||||
api-logs:
|
api-logs:
|
||||||
name: food-market-api-logs
|
name: food-market-api-logs
|
||||||
|
|
||||||
|
|
|
||||||
19
deploy/docker-registry.service
Normal file
19
deploy/docker-registry.service
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[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
|
||||||
11
deploy/food-market-mirror-base-images.service
Normal file
11
deploy/food-market-mirror-base-images.service
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[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
|
||||||
11
deploy/food-market-mirror-base-images.timer
Normal file
11
deploy/food-market-mirror-base-images.timer
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[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
|
||||||
27
deploy/forgejo/docker-compose.yml
Normal file
27
deploy/forgejo/docker-compose.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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/...
|
||||||
7
deploy/forgejo/food-market-forgejo-mirror.service
Normal file
7
deploy/forgejo/food-market-forgejo-mirror.service
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Push Forgejo food-market into GitHub (backup)
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=nns
|
||||||
|
ExecStart=/usr/local/bin/food-market-forgejo-mirror.sh
|
||||||
10
deploy/forgejo/food-market-forgejo-mirror.timer
Normal file
10
deploy/forgejo/food-market-forgejo-mirror.timer
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Mirror Forgejo -> GitHub every 10 min
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=3min
|
||||||
|
OnUnitActiveSec=10min
|
||||||
|
Unit=food-market-forgejo-mirror.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
15
deploy/forgejo/food-market-forgejo.service
Normal file
15
deploy/forgejo/food-market-forgejo.service
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
[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
|
||||||
40
deploy/forgejo/mirror-to-github.sh
Executable file
40
deploy/forgejo/mirror-to-github.sh
Executable file
|
|
@ -0,0 +1,40 @@
|
||||||
|
#!/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"
|
||||||
22
deploy/forgejo/nginx.conf
Normal file
22
deploy/forgejo/nginx.conf
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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.
|
||||||
48
deploy/mirror-base-images.sh
Executable file
48
deploy/mirror-base-images.sh
Executable file
|
|
@ -0,0 +1,48 @@
|
||||||
|
#!/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"
|
||||||
|
|
@ -3,6 +3,21 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Long-running admin imports (MoySklad etc.) read from upstream for tens of
|
||||||
|
# minutes. Bump timeouts only on that path so normal API stays snappy.
|
||||||
|
location /api/admin/import/ {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 60m;
|
||||||
|
proxy_send_timeout 60m;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
# API reverse-proxy — upstream name "api" resolves in the compose network.
|
# API reverse-proxy — upstream name "api" resolves in the compose network.
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:8080;
|
proxy_pass http://api:8080;
|
||||||
|
|
|
||||||
432
deploy/telegram-bridge/bridge.py
Normal file
432
deploy/telegram-bridge/bridge.py
Normal file
|
|
@ -0,0 +1,432 @@
|
||||||
|
"""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())
|
||||||
1
deploy/telegram-bridge/requirements.txt
Normal file
1
deploy/telegram-bridge/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
python-telegram-bot[rate-limiter]==21.6
|
||||||
19
deploy/telegram-bridge/telegram-bridge.service
Normal file
19
deploy/telegram-bridge/telegram-bridge.service
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[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
|
||||||
464
docs/audit-moysklad.md
Normal file
464
docs/audit-moysklad.md
Normal file
|
|
@ -0,0 +1,464 @@
|
||||||
|
# Аудит наших доменных сущностей 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.
|
||||||
100
docs/forgejo.md
Normal file
100
docs/forgejo.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# 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). Рабочий флоу не ломается.
|
||||||
86
docs/telegram-bridge.md
Normal file
86
docs/telegram-bridge.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# 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, без входящих портов) — никакого проброса портов не нужно.
|
||||||
201
src/food-market.api/Controllers/Admin/AdminCleanupController.cs
Normal file
201
src/food-market.api/Controllers/Admin/AdminCleanupController.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
using foodmarket.Api.Infrastructure.Tenancy;
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Infrastructure.Integrations.MoySklad;
|
||||||
|
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;
|
||||||
|
private readonly IServiceScopeFactory _scopes;
|
||||||
|
private readonly ImportJobRegistry _jobs;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
|
||||||
|
public AdminCleanupController(
|
||||||
|
AppDbContext db,
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
ImportJobRegistry jobs,
|
||||||
|
ITenantContext tenant)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_scopes = scopes;
|
||||||
|
_jobs = jobs;
|
||||||
|
_tenant = tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async-версия полной очистки: возвращает jobId, реальные DELETE в фоне, прогресс в Stage+Deleted.
|
||||||
|
[HttpPost("all/async")]
|
||||||
|
public ActionResult<object> WipeAllAsync()
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||||
|
var job = _jobs.Create("cleanup-all");
|
||||||
|
job.Stage = "Подготовка…";
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var tenantScope = HttpContextTenantContext.UseOverride(orgId);
|
||||||
|
using var scope = _scopes.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var steps = new (string Stage, Func<Task<int>> Run)[]
|
||||||
|
{
|
||||||
|
("Движения склада", () => db.StockMovements.ExecuteDeleteAsync()),
|
||||||
|
("Остатки", () => db.Stocks.ExecuteDeleteAsync()),
|
||||||
|
("Строки поставок", () => db.SupplyLines.ExecuteDeleteAsync()),
|
||||||
|
("Поставки", () => db.Supplies.ExecuteDeleteAsync()),
|
||||||
|
("Строки продаж", () => db.RetailSaleLines.ExecuteDeleteAsync()),
|
||||||
|
("Продажи", () => db.RetailSales.ExecuteDeleteAsync()),
|
||||||
|
("Изображения товаров", () => db.ProductImages.ExecuteDeleteAsync()),
|
||||||
|
("Цены товаров", () => db.ProductPrices.ExecuteDeleteAsync()),
|
||||||
|
("Штрихкоды", () => db.ProductBarcodes.ExecuteDeleteAsync()),
|
||||||
|
("Товары", () => db.Products.ExecuteDeleteAsync()),
|
||||||
|
("Группы товаров", () => db.ProductGroups.ExecuteDeleteAsync()),
|
||||||
|
("Контрагенты", () => db.Counterparties.ExecuteDeleteAsync()),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var (stage, run) in steps)
|
||||||
|
{
|
||||||
|
job.Stage = $"Удаление: {stage}…";
|
||||||
|
job.Deleted += await run();
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Stage = "Готово";
|
||||||
|
job.Message = $"Удалено записей: {job.Deleted}.";
|
||||||
|
job.Status = ImportJobStatus.Succeeded;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
job.Status = ImportJobStatus.Failed;
|
||||||
|
job.Message = ex.Message;
|
||||||
|
job.Errors.Add(ex.ToString());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
job.FinishedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Ok(new { jobId = job.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
41
src/food-market.api/Controllers/Admin/AdminJobsController.cs
Normal file
41
src/food-market.api/Controllers/Admin/AdminJobsController.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
using foodmarket.Infrastructure.Integrations.MoySklad;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Admin;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = "AdminAccess")]
|
||||||
|
[Route("api/admin/jobs")]
|
||||||
|
public class AdminJobsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ImportJobRegistry _jobs;
|
||||||
|
public AdminJobsController(ImportJobRegistry jobs) => _jobs = jobs;
|
||||||
|
|
||||||
|
public record JobView(
|
||||||
|
Guid Id,
|
||||||
|
string Kind,
|
||||||
|
string Status,
|
||||||
|
string? Stage,
|
||||||
|
DateTime StartedAt,
|
||||||
|
DateTime? FinishedAt,
|
||||||
|
int Total, int Created, int Updated, int Skipped, int Deleted, int GroupsCreated,
|
||||||
|
string? Message,
|
||||||
|
IReadOnlyList<string> Errors);
|
||||||
|
|
||||||
|
private static JobView Project(ImportJobProgress j) => new(
|
||||||
|
j.Id, j.Kind, j.Status.ToString(), j.Stage, j.StartedAt, j.FinishedAt,
|
||||||
|
j.Total, j.Created, j.Updated, j.Skipped, j.Deleted, j.GroupsCreated,
|
||||||
|
j.Message, j.Errors.TakeLast(20).ToList());
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public ActionResult<JobView> Get(Guid id)
|
||||||
|
{
|
||||||
|
var j = _jobs.Get(id);
|
||||||
|
return j is null ? NotFound() : Project(j);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("recent")]
|
||||||
|
public IReadOnlyList<JobView> Recent([FromQuery] int take = 10)
|
||||||
|
=> _jobs.RecentlyFinished(take).Select(Project).ToList();
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
|
using foodmarket.Api.Infrastructure.Tenancy;
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
using foodmarket.Infrastructure.Integrations.MoySklad;
|
using foodmarket.Infrastructure.Integrations.MoySklad;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace foodmarket.Api.Controllers.Admin;
|
namespace foodmarket.Api.Controllers.Admin;
|
||||||
|
|
||||||
|
|
@ -9,20 +13,57 @@ namespace foodmarket.Api.Controllers.Admin;
|
||||||
[Route("api/admin/moysklad")]
|
[Route("api/admin/moysklad")]
|
||||||
public class MoySkladImportController : ControllerBase
|
public class MoySkladImportController : ControllerBase
|
||||||
{
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopes;
|
||||||
private readonly MoySkladImportService _svc;
|
private readonly MoySkladImportService _svc;
|
||||||
|
private readonly ImportJobRegistry _jobs;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
|
||||||
public MoySkladImportController(MoySkladImportService svc) => _svc = svc;
|
public MoySkladImportController(
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
MoySkladImportService svc,
|
||||||
|
ImportJobRegistry jobs,
|
||||||
|
AppDbContext db,
|
||||||
|
ITenantContext tenant)
|
||||||
|
{
|
||||||
|
_scopes = scopes;
|
||||||
|
_svc = svc;
|
||||||
|
_jobs = jobs;
|
||||||
|
_db = db;
|
||||||
|
_tenant = tenant;
|
||||||
|
}
|
||||||
|
|
||||||
public record TestRequest(string Token);
|
public record TestRequest(string? Token = null);
|
||||||
public record ImportRequest(string Token, bool OverwriteExisting = false);
|
public record ImportRequest(string? Token = null, bool OverwriteExisting = false);
|
||||||
|
public record SettingsDto(bool HasToken, string? Masked);
|
||||||
|
public record SettingsInput(string Token);
|
||||||
|
|
||||||
|
[HttpGet("settings")]
|
||||||
|
public async Task<ActionResult<SettingsDto>> GetSettings(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var token = await ReadTokenFromOrgAsync(ct);
|
||||||
|
return new SettingsDto(!string.IsNullOrEmpty(token), Mask(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("settings")]
|
||||||
|
public async Task<ActionResult<SettingsDto>> SetSettings([FromBody] SettingsInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||||
|
var org = await _db.Organizations.FirstOrDefaultAsync(o => o.Id == orgId, ct);
|
||||||
|
if (org is null) return NotFound();
|
||||||
|
org.MoySkladToken = string.IsNullOrWhiteSpace(input.Token) ? null : input.Token.Trim();
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return new SettingsDto(!string.IsNullOrEmpty(org.MoySkladToken), Mask(org.MoySkladToken));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("test")]
|
[HttpPost("test")]
|
||||||
public async Task<IActionResult> TestConnection([FromBody] TestRequest req, CancellationToken ct)
|
public async Task<IActionResult> TestConnection([FromBody] TestRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(req.Token))
|
var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
return BadRequest(new { error = "Token is required." });
|
return BadRequest(new { error = "Token is required." });
|
||||||
|
|
||||||
var result = await _svc.TestConnectionAsync(req.Token, ct);
|
var result = await _svc.TestConnectionAsync(token, ct);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
var msg = result.StatusCode switch
|
var msg = result.StatusCode switch
|
||||||
|
|
@ -36,26 +77,91 @@ public async Task<IActionResult> TestConnection([FromBody] TestRequest req, Canc
|
||||||
return Ok(new { organization = result.Value!.Name, inn = result.Value.Inn });
|
return Ok(new { organization = result.Value!.Name, inn = result.Value.Inn });
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? Truncate(string? s, int max)
|
// Async launch — возвращает jobId, реальная работа в фоне. Клиент полит /jobs/{id}.
|
||||||
=> string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max] + "…");
|
|
||||||
|
|
||||||
[HttpPost("import-products")]
|
[HttpPost("import-products")]
|
||||||
public async Task<ActionResult<MoySkladImportResult>> ImportProducts([FromBody] ImportRequest req, CancellationToken ct)
|
public async Task<ActionResult<object>> ImportProducts([FromBody] ImportRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(req.Token))
|
var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
|
||||||
return BadRequest(new { error = "Token is required." });
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
return BadRequest(new { error = "Токен не настроен. Сохраните его в /admin/moysklad/settings." });
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||||
|
|
||||||
var result = await _svc.ImportProductsAsync(req.Token, req.OverwriteExisting, ct);
|
var job = _jobs.Create("products");
|
||||||
return result;
|
job.Stage = "Подключение к MoySklad…";
|
||||||
|
_ = RunInBackgroundAsync(job, async (svc, progress, ctInner) =>
|
||||||
|
{
|
||||||
|
progress.Stage = "Импорт товаров…";
|
||||||
|
var result = await svc.ImportProductsAsync(token, req.OverwriteExisting, ctInner, progress);
|
||||||
|
progress.Message = $"Готово: {result.Created} записей (создано/обновлено), {result.Skipped} пропущено, {result.GroupsCreated} групп.";
|
||||||
|
}, orgId);
|
||||||
|
return Ok(new { jobId = job.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("import-counterparties")]
|
[HttpPost("import-counterparties")]
|
||||||
public async Task<ActionResult<MoySkladImportResult>> ImportCounterparties([FromBody] ImportRequest req, CancellationToken ct)
|
public async Task<ActionResult<object>> ImportCounterparties([FromBody] ImportRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(req.Token))
|
var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
|
||||||
return BadRequest(new { error = "Token is required." });
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
return BadRequest(new { error = "Токен не настроен. Сохраните его в /admin/moysklad/settings." });
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||||
|
|
||||||
var result = await _svc.ImportCounterpartiesAsync(req.Token, req.OverwriteExisting, ct);
|
var job = _jobs.Create("counterparties");
|
||||||
return result;
|
job.Stage = "Подключение к MoySklad…";
|
||||||
|
_ = RunInBackgroundAsync(job, async (svc, progress, ctInner) =>
|
||||||
|
{
|
||||||
|
progress.Stage = "Импорт контрагентов…";
|
||||||
|
var result = await svc.ImportCounterpartiesAsync(token, req.OverwriteExisting, ctInner, progress);
|
||||||
|
progress.Message = $"Готово: {result.Created} записей, {result.Skipped} пропущено.";
|
||||||
|
}, orgId);
|
||||||
|
return Ok(new { jobId = job.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task RunInBackgroundAsync(
|
||||||
|
ImportJobProgress job,
|
||||||
|
Func<MoySkladImportService, ImportJobProgress, CancellationToken, Task> work,
|
||||||
|
Guid orgId)
|
||||||
|
{
|
||||||
|
return Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var tenantScope = HttpContextTenantContext.UseOverride(orgId);
|
||||||
|
using var scope = _scopes.CreateScope();
|
||||||
|
var svc = scope.ServiceProvider.GetRequiredService<MoySkladImportService>();
|
||||||
|
await work(svc, job, CancellationToken.None);
|
||||||
|
job.Status = ImportJobStatus.Succeeded;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
job.Status = ImportJobStatus.Failed;
|
||||||
|
job.Message = ex.Message;
|
||||||
|
job.Errors.Add(ex.ToString());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
job.FinishedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> ReadTokenFromOrgAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId;
|
||||||
|
if (orgId is null) return null;
|
||||||
|
return await _db.Organizations
|
||||||
|
.Where(o => o.Id == orgId)
|
||||||
|
.Select(o => o.MoySkladToken)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Mask(string? token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
|
if (token.Length <= 8) return new string('•', token.Length);
|
||||||
|
return token[..4] + new string('•', 8) + token[^4..];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Truncate(string? s, int max)
|
||||||
|
=> string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max] + "…");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,9 @@ public class CounterpartiesController : ControllerBase
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<PagedResult<CounterpartyDto>>> List(
|
public async Task<ActionResult<PagedResult<CounterpartyDto>>> List(
|
||||||
[FromQuery] PagedRequest req,
|
[FromQuery] PagedRequest req,
|
||||||
[FromQuery] CounterpartyKind? kind,
|
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var q = _db.Counterparties.Include(c => c.Country).AsNoTracking().AsQueryable();
|
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))
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
{
|
{
|
||||||
var s = req.Search.Trim().ToLower();
|
var s = req.Search.Trim().ToLower();
|
||||||
|
|
@ -43,7 +38,7 @@ public class CounterpartiesController : ControllerBase
|
||||||
.OrderBy(c => c.Name)
|
.OrderBy(c => c.Name)
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(c => new CounterpartyDto(
|
.Select(c => new CounterpartyDto(
|
||||||
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
|
c.Id, c.Name, c.LegalName, c.Type,
|
||||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null,
|
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null,
|
||||||
c.Address, c.Phone, c.Email,
|
c.Address, c.Phone, c.Email,
|
||||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive))
|
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive))
|
||||||
|
|
@ -56,7 +51,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);
|
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
return c is null ? NotFound() : new CounterpartyDto(
|
return c is null ? NotFound() : new CounterpartyDto(
|
||||||
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
|
c.Id, c.Name, c.LegalName, c.Type,
|
||||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
||||||
c.Address, c.Phone, c.Email,
|
c.Address, c.Phone, c.Email,
|
||||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive);
|
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive);
|
||||||
|
|
@ -95,7 +90,6 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i)
|
||||||
{
|
{
|
||||||
e.Name = i.Name;
|
e.Name = i.Name;
|
||||||
e.LegalName = i.LegalName;
|
e.LegalName = i.LegalName;
|
||||||
e.Kind = i.Kind;
|
|
||||||
e.Type = i.Type;
|
e.Type = i.Type;
|
||||||
e.Bin = i.Bin;
|
e.Bin = i.Bin;
|
||||||
e.Iin = i.Iin;
|
e.Iin = i.Iin;
|
||||||
|
|
@ -117,7 +111,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);
|
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstAsync(x => x.Id == id, ct);
|
||||||
return new CounterpartyDto(
|
return new CounterpartyDto(
|
||||||
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
|
c.Id, c.Name, c.LegalName, c.Type,
|
||||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
||||||
c.Address, c.Phone, c.Email,
|
c.Address, c.Phone, c.Email,
|
||||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive);
|
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive);
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,33 @@ public class ProductsController : ControllerBase
|
||||||
[FromQuery] Guid? groupId,
|
[FromQuery] Guid? groupId,
|
||||||
[FromQuery] bool? isService,
|
[FromQuery] bool? isService,
|
||||||
[FromQuery] bool? isWeighed,
|
[FromQuery] bool? isWeighed,
|
||||||
|
[FromQuery] bool? isMarked,
|
||||||
[FromQuery] bool? isActive,
|
[FromQuery] bool? isActive,
|
||||||
|
[FromQuery] bool? hasBarcode,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var q = QueryIncludes().AsNoTracking();
|
var q = QueryIncludes().AsNoTracking();
|
||||||
if (groupId is not null) q = q.Where(p => p.ProductGroupId == groupId);
|
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 (isService is not null) q = q.Where(p => p.IsService == isService);
|
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 (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 (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))
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
{
|
{
|
||||||
|
|
@ -111,7 +130,6 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
|
||||||
private IQueryable<Product> QueryIncludes() => _db.Products
|
private IQueryable<Product> QueryIncludes() => _db.Products
|
||||||
.Include(p => p.UnitOfMeasure)
|
.Include(p => p.UnitOfMeasure)
|
||||||
.Include(p => p.VatRate)
|
|
||||||
.Include(p => p.ProductGroup)
|
.Include(p => p.ProductGroup)
|
||||||
.Include(p => p.DefaultSupplier)
|
.Include(p => p.DefaultSupplier)
|
||||||
.Include(p => p.CountryOfOrigin)
|
.Include(p => p.CountryOfOrigin)
|
||||||
|
|
@ -126,12 +144,12 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
private static readonly System.Linq.Expressions.Expression<Func<Product, ProductDto>> Projection = p =>
|
private static readonly System.Linq.Expressions.Expression<Func<Product, ProductDto>> Projection = p =>
|
||||||
new ProductDto(
|
new ProductDto(
|
||||||
p.Id, p.Name, p.Article, p.Description,
|
p.Id, p.Name, p.Article, p.Description,
|
||||||
p.UnitOfMeasureId, p.UnitOfMeasure!.Symbol,
|
p.UnitOfMeasureId, p.UnitOfMeasure!.Name,
|
||||||
p.VatRateId, p.VatRate!.Percent,
|
p.Vat, p.VatEnabled,
|
||||||
p.ProductGroupId, p.ProductGroup != null ? p.ProductGroup.Name : null,
|
p.ProductGroupId, p.ProductGroup != null ? p.ProductGroup.Name : null,
|
||||||
p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null,
|
p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null,
|
||||||
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
|
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
|
||||||
p.IsService, p.IsWeighed, p.IsAlcohol, p.IsMarked,
|
p.IsService, p.IsWeighed, p.IsMarked,
|
||||||
p.MinStock, p.MaxStock,
|
p.MinStock, p.MaxStock,
|
||||||
p.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
|
p.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
|
||||||
p.ImageUrl, p.IsActive,
|
p.ImageUrl, p.IsActive,
|
||||||
|
|
@ -144,13 +162,13 @@ private static void Apply(Product e, ProductInput i)
|
||||||
e.Article = i.Article;
|
e.Article = i.Article;
|
||||||
e.Description = i.Description;
|
e.Description = i.Description;
|
||||||
e.UnitOfMeasureId = i.UnitOfMeasureId;
|
e.UnitOfMeasureId = i.UnitOfMeasureId;
|
||||||
e.VatRateId = i.VatRateId;
|
e.Vat = i.Vat;
|
||||||
|
e.VatEnabled = i.VatEnabled;
|
||||||
e.ProductGroupId = i.ProductGroupId;
|
e.ProductGroupId = i.ProductGroupId;
|
||||||
e.DefaultSupplierId = i.DefaultSupplierId;
|
e.DefaultSupplierId = i.DefaultSupplierId;
|
||||||
e.CountryOfOriginId = i.CountryOfOriginId;
|
e.CountryOfOriginId = i.CountryOfOriginId;
|
||||||
e.IsService = i.IsService;
|
e.IsService = i.IsService;
|
||||||
e.IsWeighed = i.IsWeighed;
|
e.IsWeighed = i.IsWeighed;
|
||||||
e.IsAlcohol = i.IsAlcohol;
|
|
||||||
e.IsMarked = i.IsMarked;
|
e.IsMarked = i.IsMarked;
|
||||||
e.MinStock = i.MinStock;
|
e.MinStock = i.MinStock;
|
||||||
e.MaxStock = i.MaxStock;
|
e.MaxStock = i.MaxStock;
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ public async Task<ActionResult<PagedResult<StoreDto>>> List([FromQuery] PagedReq
|
||||||
var items = await q
|
var items = await q
|
||||||
.OrderByDescending(x => x.IsMain).ThenBy(x => x.Name)
|
.OrderByDescending(x => x.IsMain).ThenBy(x => x.Name)
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(x => new StoreDto(x.Id, x.Name, x.Code, x.Kind, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive))
|
.Select(x => new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<StoreDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
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)
|
public async Task<ActionResult<StoreDto>> Get(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var x = await _db.Stores.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, 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.Kind, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
|
return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||||
|
|
@ -51,13 +51,13 @@ public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, Ca
|
||||||
}
|
}
|
||||||
var e = new Store
|
var e = new Store
|
||||||
{
|
{
|
||||||
Name = input.Name, Code = input.Code, Kind = input.Kind, Address = input.Address,
|
Name = input.Name, Code = input.Code,Address = input.Address,
|
||||||
Phone = input.Phone, ManagerName = input.ManagerName, IsMain = input.IsMain, IsActive = input.IsActive,
|
Phone = input.Phone, ManagerName = input.ManagerName, IsMain = input.IsMain, IsActive = input.IsActive,
|
||||||
};
|
};
|
||||||
_db.Stores.Add(e);
|
_db.Stores.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||||
new StoreDto(e.Id, e.Name, e.Code, e.Kind, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
|
new StoreDto(e.Id, e.Name, e.Code, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
[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.Name = input.Name;
|
||||||
e.Code = input.Code;
|
e.Code = input.Code;
|
||||||
e.Kind = input.Kind;
|
|
||||||
e.Address = input.Address;
|
e.Address = input.Address;
|
||||||
e.Phone = input.Phone;
|
e.Phone = input.Phone;
|
||||||
e.ManagerName = input.ManagerName;
|
e.ManagerName = input.ManagerName;
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,13 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
|
||||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
{
|
{
|
||||||
var s = req.Search.Trim().ToLower();
|
var s = req.Search.Trim().ToLower();
|
||||||
q = q.Where(u => u.Name.ToLower().Contains(s) || u.Symbol.ToLower().Contains(s) || u.Code.ToLower().Contains(s));
|
q = q.Where(u => u.Name.ToLower().Contains(s) || u.Code.ToLower().Contains(s));
|
||||||
}
|
}
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
var items = await q
|
var items = await q
|
||||||
.OrderByDescending(u => u.IsBase).ThenBy(u => u.Name)
|
.OrderBy(u => u.Name)
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Symbol, u.Name, u.DecimalPlaces, u.IsBase, u.IsActive))
|
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.IsActive))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -39,30 +39,23 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
|
||||||
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
|
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, 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.Symbol, u.Name, u.DecimalPlaces, u.IsBase, u.IsActive);
|
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.IsActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||||
public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct)
|
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
|
var e = new UnitOfMeasure
|
||||||
{
|
{
|
||||||
Code = input.Code,
|
Code = input.Code,
|
||||||
Symbol = input.Symbol,
|
|
||||||
Name = input.Name,
|
Name = input.Name,
|
||||||
DecimalPlaces = input.DecimalPlaces,
|
Description = input.Description,
|
||||||
IsBase = input.IsBase,
|
|
||||||
IsActive = input.IsActive,
|
IsActive = input.IsActive,
|
||||||
};
|
};
|
||||||
_db.UnitsOfMeasure.Add(e);
|
_db.UnitsOfMeasure.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||||
new UnitOfMeasureDto(e.Id, e.Code, e.Symbol, e.Name, e.DecimalPlaces, e.IsBase, e.IsActive));
|
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.IsActive));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||||
|
|
@ -71,16 +64,9 @@ public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput i
|
||||||
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (e is null) return NotFound();
|
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.Code = input.Code;
|
||||||
e.Symbol = input.Symbol;
|
|
||||||
e.Name = input.Name;
|
e.Name = input.Name;
|
||||||
e.DecimalPlaces = input.DecimalPlaces;
|
e.Description = input.Description;
|
||||||
e.IsBase = input.IsBase;
|
|
||||||
e.IsActive = input.IsActive;
|
e.IsActive = input.IsActive;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -50,7 +50,7 @@ public record StockRow(
|
||||||
.OrderBy(x => x.p.Name)
|
.OrderBy(x => x.p.Name)
|
||||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||||
.Select(x => new StockRow(
|
.Select(x => new StockRow(
|
||||||
x.p.Id, x.p.Name, x.p.Article, x.u.Symbol,
|
x.p.Id, x.p.Name, x.p.Article, x.u.Name,
|
||||||
x.st.Id, x.st.Name,
|
x.st.Id, x.st.Name,
|
||||||
x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
|
x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
|
||||||
|
|
@ -276,7 +276,7 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
|
||||||
where l.SupplyId == id
|
where l.SupplyId == id
|
||||||
orderby l.SortOrder
|
orderby l.SortOrder
|
||||||
select new SupplyLineDto(
|
select new SupplyLineDto(
|
||||||
l.Id, l.ProductId, p.Name, p.Article, u.Symbol,
|
l.Id, l.ProductId, p.Name, p.Article, u.Name,
|
||||||
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder))
|
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -352,7 +352,7 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
|
||||||
where l.RetailSaleId == id
|
where l.RetailSaleId == id
|
||||||
orderby l.SortOrder
|
orderby l.SortOrder
|
||||||
select new RetailSaleLineDto(
|
select new RetailSaleLineDto(
|
||||||
l.Id, l.ProductId, p.Name, p.Article, u.Symbol,
|
l.Id, l.ProductId, p.Name, p.Article, u.Name,
|
||||||
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder))
|
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,22 @@ public class HttpContextTenantContext : ITenantContext
|
||||||
public const string OrganizationClaim = "org_id";
|
public const string OrganizationClaim = "org_id";
|
||||||
public const string SuperAdminRole = "SuperAdmin";
|
public const string SuperAdminRole = "SuperAdmin";
|
||||||
|
|
||||||
|
// Override для background задач (например, импорт из MoySklad): сохраняем tenant
|
||||||
|
// в AsyncLocal на время выполнения фонового Task. HttpContext там отсутствует,
|
||||||
|
// но query-filter'у по-прежнему нужен orgId — вот его и берём из override.
|
||||||
|
private static readonly AsyncLocal<(Guid? OrgId, bool IsSuper)?> _override = new();
|
||||||
|
|
||||||
|
public static IDisposable UseOverride(Guid orgId, bool isSuperAdmin = false)
|
||||||
|
{
|
||||||
|
_override.Value = (orgId, isSuperAdmin);
|
||||||
|
return new OverrideScope();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class OverrideScope : IDisposable
|
||||||
|
{
|
||||||
|
public void Dispose() => _override.Value = null;
|
||||||
|
}
|
||||||
|
|
||||||
private readonly IHttpContextAccessor _accessor;
|
private readonly IHttpContextAccessor _accessor;
|
||||||
|
|
||||||
public HttpContextTenantContext(IHttpContextAccessor accessor)
|
public HttpContextTenantContext(IHttpContextAccessor accessor)
|
||||||
|
|
@ -15,14 +31,29 @@ public HttpContextTenantContext(IHttpContextAccessor accessor)
|
||||||
_accessor = accessor;
|
_accessor = accessor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsAuthenticated => _accessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false;
|
public bool IsAuthenticated
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_override.Value is not null) return true;
|
||||||
|
return _accessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsSuperAdmin => _accessor.HttpContext?.User?.IsInRole(SuperAdminRole) ?? false;
|
public bool IsSuperAdmin
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_override.Value is { IsSuper: var s }) return s;
|
||||||
|
return _accessor.HttpContext?.User?.IsInRole(SuperAdminRole) ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Guid? OrganizationId
|
public Guid? OrganizationId
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
|
if (_override.Value is { OrgId: var o }) return o;
|
||||||
var claim = _accessor.HttpContext?.User?.FindFirst(OrganizationClaim)?.Value;
|
var claim = _accessor.HttpContext?.User?.FindFirst(OrganizationClaim)?.Value;
|
||||||
return Guid.TryParse(claim, out var id) ? id : null;
|
return Guid.TryParse(claim, out var id) ? id : null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,7 @@
|
||||||
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
|
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
|
||||||
});
|
});
|
||||||
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
|
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
|
||||||
|
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
|
||||||
|
|
||||||
// Inventory
|
// Inventory
|
||||||
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
|
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
|
||||||
|
|
|
||||||
|
|
@ -32,21 +32,18 @@ public async Task StartAsync(CancellationToken ct)
|
||||||
var hasProducts = await db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
|
var hasProducts = await db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
|
||||||
if (hasProducts) return;
|
if (hasProducts) return;
|
||||||
|
|
||||||
var defaultVat = await db.VatRates.IgnoreQueryFilters()
|
// KZ default VAT is 16% (applies as int on Product).
|
||||||
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.IsDefault, ct);
|
const int vatDefault = 16;
|
||||||
var noVat = await db.VatRates.IgnoreQueryFilters()
|
const int vat0 = 0;
|
||||||
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.Percent == 0m, ct);
|
|
||||||
|
|
||||||
var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "шт", ct);
|
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "796", ct);
|
||||||
var unitKg = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
var unitKg = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "кг", ct);
|
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "166", ct);
|
||||||
var unitL = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
var unitL = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "л", ct);
|
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "112", ct);
|
||||||
|
|
||||||
if (defaultVat is null || unitSht is null) return;
|
if (unitSht is null) return;
|
||||||
var vat = defaultVat.Id;
|
|
||||||
var vat0 = noVat?.Id ?? vat;
|
|
||||||
|
|
||||||
var retailPriceType = await db.PriceTypes.IgnoreQueryFilters()
|
var retailPriceType = await db.PriceTypes.IgnoreQueryFilters()
|
||||||
.FirstOrDefaultAsync(p => p.OrganizationId == orgId && p.IsRetail, ct);
|
.FirstOrDefaultAsync(p => p.OrganizationId == orgId && p.IsRetail, ct);
|
||||||
|
|
@ -88,7 +85,7 @@ Guid AddGroup(string name, Guid? parentId)
|
||||||
var supplier1 = new Counterparty
|
var supplier1 = new Counterparty
|
||||||
{
|
{
|
||||||
OrganizationId = orgId, Name = "ТОО «Продтрейд»", LegalName = "Товарищество с ограниченной ответственностью «Продтрейд»",
|
OrganizationId = orgId, Name = "ТОО «Продтрейд»", LegalName = "Товарищество с ограниченной ответственностью «Продтрейд»",
|
||||||
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.LegalEntity,
|
Type = CounterpartyType.LegalEntity,
|
||||||
Bin = "100140005678", CountryId = kz?.Id,
|
Bin = "100140005678", CountryId = kz?.Id,
|
||||||
Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01",
|
Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01",
|
||||||
Email = "order@prodtrade.kz", BankName = "Kaspi Bank", BankAccount = "KZ000000000000000001", Bik = "CASPKZKA",
|
Email = "order@prodtrade.kz", BankName = "Kaspi Bank", BankAccount = "KZ000000000000000001", Bik = "CASPKZKA",
|
||||||
|
|
@ -97,7 +94,7 @@ Guid AddGroup(string name, Guid? parentId)
|
||||||
var supplier2 = new Counterparty
|
var supplier2 = new Counterparty
|
||||||
{
|
{
|
||||||
OrganizationId = orgId, Name = "ИП Иванов А.С.",
|
OrganizationId = orgId, Name = "ИП Иванов А.С.",
|
||||||
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.Individual,
|
Type = CounterpartyType.Individual,
|
||||||
Iin = "850101300000", CountryId = kz?.Id,
|
Iin = "850101300000", CountryId = kz?.Id,
|
||||||
Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей",
|
Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей",
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
|
|
@ -106,49 +103,49 @@ Guid AddGroup(string name, Guid? parentId)
|
||||||
|
|
||||||
// Barcodes use generic internal range (2xxxxxxxxxxxx) to avoid colliding with real products.
|
// Barcodes use generic internal range (2xxxxxxxxxxxx) to avoid colliding with real products.
|
||||||
// When user does real приёмка, real barcodes will overwrite.
|
// 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, bool IsAlcohol)[]
|
var demo = new (string Name, Guid Group, decimal RetailPrice, string Article, string Barcode, Guid? Country, bool IsWeighed, Guid Unit)[]
|
||||||
{
|
{
|
||||||
// Напитки — безалкогольные
|
// Напитки — безалкогольные
|
||||||
("Вода питьевая «Тассай» 0.5л", gDrinksNon, 220, "DR-WAT-001", "2000000000018", kz?.Id, false, unitSht.Id, false),
|
("Вода питьевая «Тассай» 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, false),
|
("Вода питьевая «Тассай» 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, false),
|
("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id),
|
||||||
("Сок «Да-Да» апельсин 1л", gDrinksNon, 650, "DR-JUC-002", "2000000000049", kz?.Id, false, unitSht.Id, false),
|
("Сок «Да-Да» апельсин 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, false),
|
("Кока-кола 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, false),
|
("Пепси 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, false),
|
("Спрайт 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, false),
|
("Чай чёрный «Пиала» пакетированный, 100шт", gDrinksNon, 1250, "DR-TEA-001", "2000000000087", kz?.Id, false, unitSht.Id),
|
||||||
// Молочные
|
// Молочные
|
||||||
("Молоко «Простоквашино» 2.5% 930мл", gDairy, 720, "DAI-MLK-001", "2000000000094", ru?.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, false),
|
("Молоко «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, false),
|
("Кефир «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, false),
|
("Йогурт «Актимель» клубника 100мл", gDairy, 180, "DAI-YOG-001", "2000000000124", null, false, unitSht.Id),
|
||||||
("Творог «Простоквашино» 5% 200г", gDairy, 590, "DAI-CRD-001", "2000000000131", ru?.Id, false, unitSht.Id, false),
|
("Творог «Простоквашино» 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, false),
|
("Сметана «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, false),
|
("Сыр «Российский» 45%", gDairy, 3200, "DAI-CHE-001", "2000000000155", kz?.Id, true, unitKg?.Id ?? unitSht.Id),
|
||||||
// Хлеб и выпечка
|
// Хлеб и выпечка
|
||||||
("Хлеб «Семейный» нарезной 500г", gBakery, 180, "BAK-BRD-001", "2000000000162", kz?.Id, false, 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, false),
|
("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id),
|
||||||
("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id, false),
|
("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id),
|
||||||
("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", kz?.Id, false, unitSht.Id, false),
|
("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", kz?.Id, false, unitSht.Id),
|
||||||
// Кондитерские
|
// Кондитерские
|
||||||
("Шоколад «Рахат» молочный 100г", gSweets, 650, "SW-CHO-001", "2000000000209", 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, false),
|
("Шоколад «Алёнка» 100г", gSweets, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id),
|
||||||
("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id, false),
|
("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id),
|
||||||
("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id, false),
|
("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id),
|
||||||
("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", kz?.Id, false, unitSht.Id, false),
|
("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", kz?.Id, false, unitSht.Id),
|
||||||
// Бакалея
|
// Бакалея
|
||||||
("Сахар-песок 1кг", gGrocery, 480, "GRO-SUG-001", "2000000000254", 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, false),
|
("Соль поваренная 1кг", gGrocery, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id),
|
||||||
("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id, false),
|
("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id),
|
||||||
("Гречка «Мистраль» 900г", gGrocery, 990, "GRO-GRE-001", "2000000000285", ru?.Id, false, unitSht.Id, false),
|
("Гречка «Мистраль» 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, false),
|
("Макароны «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, false),
|
("Масло подсолнечное «Шедевр» 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, false),
|
("Кофе «Якобс Монарх» 95г растворимый", gGrocery, 2890, "GRO-COF-001", "2000000000315", null, false, unitSht.Id),
|
||||||
// Снеки
|
// Снеки
|
||||||
("Чипсы «Lays» сметана-лук 80г", gSnacks, 380, "SN-CHP-001", "2000000000322", kz?.Id, 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, false),
|
("Сухарики «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, false),
|
("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id),
|
||||||
("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id, false),
|
("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id),
|
||||||
};
|
};
|
||||||
|
|
||||||
var products = demo.Select(d =>
|
var products = demo.Select(d =>
|
||||||
|
|
@ -159,12 +156,12 @@ Guid AddGroup(string name, Guid? parentId)
|
||||||
Name = d.Name,
|
Name = d.Name,
|
||||||
Article = d.Article,
|
Article = d.Article,
|
||||||
UnitOfMeasureId = d.Unit,
|
UnitOfMeasureId = d.Unit,
|
||||||
VatRateId = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
|
Vat = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
|
||||||
? vat0 : vat,
|
? vat0 : vatDefault,
|
||||||
|
VatEnabled = !(d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")),
|
||||||
ProductGroupId = d.Group,
|
ProductGroupId = d.Group,
|
||||||
CountryOfOriginId = d.Country,
|
CountryOfOriginId = d.Country,
|
||||||
IsWeighed = d.IsWeighed,
|
IsWeighed = d.IsWeighed,
|
||||||
IsAlcohol = d.IsAlcohol,
|
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2),
|
PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2),
|
||||||
PurchaseCurrencyId = kzt.Id,
|
PurchaseCurrencyId = kzt.Id,
|
||||||
|
|
|
||||||
|
|
@ -78,24 +78,15 @@ public async Task StartAsync(CancellationToken ct)
|
||||||
|
|
||||||
private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, 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);
|
var anyUnit = await db.UnitsOfMeasure.IgnoreQueryFilters().AnyAsync(u => u.OrganizationId == orgId, ct);
|
||||||
if (!anyUnit)
|
if (!anyUnit)
|
||||||
{
|
{
|
||||||
db.UnitsOfMeasure.AddRange(
|
db.UnitsOfMeasure.AddRange(
|
||||||
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Symbol = "шт", Name = "штука", DecimalPlaces = 0, IsBase = true },
|
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Name = "штука" },
|
||||||
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Symbol = "кг", Name = "килограмм", DecimalPlaces = 3 },
|
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Name = "килограмм" },
|
||||||
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Symbol = "л", Name = "литр", DecimalPlaces = 3 },
|
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Name = "литр" },
|
||||||
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Symbol = "м", Name = "метр", DecimalPlaces = 3 },
|
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Name = "метр" },
|
||||||
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Symbol = "уп", Name = "упаковка", DecimalPlaces = 0 }
|
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Name = "упаковка" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,7 +107,6 @@ private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
|
||||||
OrganizationId = orgId,
|
OrganizationId = orgId,
|
||||||
Name = "Основной склад",
|
Name = "Основной склад",
|
||||||
Code = "MAIN",
|
Code = "MAIN",
|
||||||
Kind = StoreKind.Warehouse,
|
|
||||||
IsMain = true,
|
IsMain = true,
|
||||||
Address = "Алматы, ул. Пример 1",
|
Address = "Алматы, ул. Пример 1",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,14 @@ 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 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(
|
public record UnitOfMeasureDto(
|
||||||
Guid Id, string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase, bool IsActive);
|
Guid Id, string Code, string Name, string? Description, bool IsActive);
|
||||||
|
|
||||||
public record PriceTypeDto(
|
public record PriceTypeDto(
|
||||||
Guid Id, string Name, bool IsDefault, bool IsRetail, int SortOrder, bool IsActive);
|
Guid Id, string Name, bool IsDefault, bool IsRetail, int SortOrder, bool IsActive);
|
||||||
|
|
||||||
public record StoreDto(
|
public record StoreDto(
|
||||||
Guid Id, string Name, string? Code, StoreKind Kind, string? Address, string? Phone,
|
Guid Id, string Name, string? Code, string? Address, string? Phone,
|
||||||
string? ManagerName, bool IsMain, bool IsActive);
|
string? ManagerName, bool IsMain, bool IsActive);
|
||||||
|
|
||||||
public record RetailPointDto(
|
public record RetailPointDto(
|
||||||
|
|
@ -27,7 +24,7 @@ public record ProductGroupDto(
|
||||||
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive);
|
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive);
|
||||||
|
|
||||||
public record CounterpartyDto(
|
public record CounterpartyDto(
|
||||||
Guid Id, string Name, string? LegalName, CounterpartyKind Kind, CounterpartyType Type,
|
Guid Id, string Name, string? LegalName, CounterpartyType Type,
|
||||||
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName,
|
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName,
|
||||||
string? Address, string? Phone, string? Email,
|
string? Address, string? Phone, string? Email,
|
||||||
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive);
|
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive);
|
||||||
|
|
@ -38,12 +35,12 @@ public record ProductPriceDto(Guid Id, Guid PriceTypeId, string PriceTypeName, d
|
||||||
|
|
||||||
public record ProductDto(
|
public record ProductDto(
|
||||||
Guid Id, string Name, string? Article, string? Description,
|
Guid Id, string Name, string? Article, string? Description,
|
||||||
Guid UnitOfMeasureId, string UnitSymbol,
|
Guid UnitOfMeasureId, string UnitName,
|
||||||
Guid VatRateId, decimal VatPercent,
|
int Vat, bool VatEnabled,
|
||||||
Guid? ProductGroupId, string? ProductGroupName,
|
Guid? ProductGroupId, string? ProductGroupName,
|
||||||
Guid? DefaultSupplierId, string? DefaultSupplierName,
|
Guid? DefaultSupplierId, string? DefaultSupplierName,
|
||||||
Guid? CountryOfOriginId, string? CountryOfOriginName,
|
Guid? CountryOfOriginId, string? CountryOfOriginName,
|
||||||
bool IsService, bool IsWeighed, bool IsAlcohol, bool IsMarked,
|
bool IsService, bool IsWeighed, bool IsMarked,
|
||||||
decimal? MinStock, decimal? MaxStock,
|
decimal? MinStock, decimal? MaxStock,
|
||||||
decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
|
decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
|
||||||
string? ImageUrl, bool IsActive,
|
string? ImageUrl, bool IsActive,
|
||||||
|
|
@ -53,11 +50,10 @@ public record ProductDto(
|
||||||
// Upsert payloads (input)
|
// Upsert payloads (input)
|
||||||
public record CountryInput(string Code, string Name, int SortOrder = 0);
|
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 CurrencyInput(string Code, string Name, string Symbol, int MinorUnit = 2, bool IsActive = true);
|
||||||
public record VatRateInput(string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault = false, bool IsActive = true);
|
public record UnitOfMeasureInput(string Code, string Name, string? Description = null, 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 PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true);
|
||||||
public record StoreInput(
|
public record StoreInput(
|
||||||
string Name, string? Code, StoreKind Kind = StoreKind.Warehouse,
|
string Name, string? Code,
|
||||||
string? Address = null, string? Phone = null, string? ManagerName = null,
|
string? Address = null, string? Phone = null, string? ManagerName = null,
|
||||||
bool IsMain = false, bool IsActive = true);
|
bool IsMain = false, bool IsActive = true);
|
||||||
public record RetailPointInput(
|
public record RetailPointInput(
|
||||||
|
|
@ -66,7 +62,7 @@ public record RetailPointInput(
|
||||||
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
|
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
|
||||||
public record ProductGroupInput(string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true);
|
public record ProductGroupInput(string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true);
|
||||||
public record CounterpartyInput(
|
public record CounterpartyInput(
|
||||||
string Name, string? LegalName, CounterpartyKind Kind, CounterpartyType Type,
|
string Name, string? LegalName, CounterpartyType Type,
|
||||||
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId,
|
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId,
|
||||||
string? Address, string? Phone, string? Email,
|
string? Address, string? Phone, string? Email,
|
||||||
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive = true);
|
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive = true);
|
||||||
|
|
@ -74,9 +70,9 @@ public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ea
|
||||||
public record ProductPriceInput(Guid PriceTypeId, decimal Amount, Guid CurrencyId);
|
public record ProductPriceInput(Guid PriceTypeId, decimal Amount, Guid CurrencyId);
|
||||||
public record ProductInput(
|
public record ProductInput(
|
||||||
string Name, string? Article, string? Description,
|
string Name, string? Article, string? Description,
|
||||||
Guid UnitOfMeasureId, Guid VatRateId,
|
Guid UnitOfMeasureId, int Vat, bool VatEnabled,
|
||||||
Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
|
Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
|
||||||
bool IsService = false, bool IsWeighed = false, bool IsAlcohol = false, bool IsMarked = false,
|
bool IsService = false, bool IsWeighed = false, bool IsMarked = false,
|
||||||
decimal? MinStock = null, decimal? MaxStock = null,
|
decimal? MinStock = null, decimal? MaxStock = null,
|
||||||
decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null,
|
decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null,
|
||||||
string? ImageUrl = null, bool IsActive = true,
|
string? ImageUrl = null, bool IsActive = true,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ public class Counterparty : TenantEntity
|
||||||
{
|
{
|
||||||
public string Name { get; set; } = null!; // отображаемое имя
|
public string Name { get; set; } = null!; // отображаемое имя
|
||||||
public string? LegalName { get; set; } // полное юридическое имя
|
public string? LegalName { get; set; } // полное юридическое имя
|
||||||
public CounterpartyKind Kind { get; set; } // Supplier / Customer / Both
|
|
||||||
public CounterpartyType Type { get; set; } // Юрлицо / Физлицо
|
public CounterpartyType Type { get; set; } // Юрлицо / Физлицо
|
||||||
public string? Bin { get; set; } // БИН (для юрлиц РК)
|
public string? Bin { get; set; } // БИН (для юрлиц РК)
|
||||||
public string? Iin { get; set; } // ИИН (для физлиц РК)
|
public string? Iin { get; set; } // ИИН (для физлиц РК)
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,11 @@
|
||||||
namespace foodmarket.Domain.Catalog;
|
namespace foodmarket.Domain.Catalog;
|
||||||
|
|
||||||
public enum CounterpartyKind
|
|
||||||
{
|
|
||||||
Supplier = 1,
|
|
||||||
Customer = 2,
|
|
||||||
Both = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum CounterpartyType
|
public enum CounterpartyType
|
||||||
{
|
{
|
||||||
LegalEntity = 1,
|
LegalEntity = 1,
|
||||||
Individual = 2,
|
Individual = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum StoreKind
|
|
||||||
{
|
|
||||||
Warehouse = 1,
|
|
||||||
RetailFloor = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum BarcodeType
|
public enum BarcodeType
|
||||||
{
|
{
|
||||||
Ean13 = 1,
|
Ean13 = 1,
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,10 @@ public class Product : TenantEntity
|
||||||
public Guid UnitOfMeasureId { get; set; }
|
public Guid UnitOfMeasureId { get; set; }
|
||||||
public UnitOfMeasure? UnitOfMeasure { get; set; }
|
public UnitOfMeasure? UnitOfMeasure { get; set; }
|
||||||
|
|
||||||
public Guid VatRateId { get; set; }
|
// Ставка НДС на товаре — число 0/10/12/16/20 как в MoySklad.
|
||||||
public VatRate? VatRate { get; set; }
|
// VatEnabled=true → НДС применяется, false → без НДС.
|
||||||
|
public int Vat { get; set; }
|
||||||
|
public bool VatEnabled { get; set; } = true;
|
||||||
|
|
||||||
public Guid? ProductGroupId { get; set; }
|
public Guid? ProductGroupId { get; set; }
|
||||||
public ProductGroup? ProductGroup { get; set; }
|
public ProductGroup? ProductGroup { get; set; }
|
||||||
|
|
@ -25,7 +27,6 @@ public class Product : TenantEntity
|
||||||
|
|
||||||
public bool IsService { get; set; } // услуга, а не физический товар
|
public bool IsService { get; set; } // услуга, а не физический товар
|
||||||
public bool IsWeighed { get; set; } // весовой (продаётся с весов)
|
public bool IsWeighed { get; set; } // весовой (продаётся с весов)
|
||||||
public bool IsAlcohol { get; set; } // алкоголь (подакцизный)
|
|
||||||
public bool IsMarked { get; set; } // маркируемый (Datamatrix)
|
public bool IsMarked { get; set; } // маркируемый (Datamatrix)
|
||||||
|
|
||||||
public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений)
|
public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений)
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
namespace foodmarket.Domain.Catalog;
|
namespace foodmarket.Domain.Catalog;
|
||||||
|
|
||||||
// Склад: физическое место хранения товаров. Может быть чисто склад или торговый зал.
|
// Склад: физическое место хранения товаров. MoySklad не различает "склад" и
|
||||||
|
// "торговый зал" — это одна сущность entity/store, опираемся на это.
|
||||||
public class Store : TenantEntity
|
public class Store : TenantEntity
|
||||||
{
|
{
|
||||||
public string Name { get; set; } = null!;
|
public string Name { get; set; } = null!;
|
||||||
public string? Code { get; set; } // внутренний код склада
|
public string? Code { get; set; } // внутренний код склада
|
||||||
public StoreKind Kind { get; set; } = StoreKind.Warehouse;
|
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
public string? ManagerName { get; set; }
|
public string? ManagerName { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,11 @@
|
||||||
|
|
||||||
namespace foodmarket.Domain.Catalog;
|
namespace foodmarket.Domain.Catalog;
|
||||||
|
|
||||||
// Tenant-scoped справочник единиц измерения.
|
// Единица измерения как в MoySklad entity/uom: code + name + description.
|
||||||
public class UnitOfMeasure : TenantEntity
|
public class UnitOfMeasure : TenantEntity
|
||||||
{
|
{
|
||||||
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
|
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
|
||||||
public string Symbol { get; set; } = null!; // "шт", "кг", "л", "м"
|
|
||||||
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
|
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
|
||||||
public int DecimalPlaces { get; set; } // 0 для шт, 3 для кг/л
|
public string? Description { get; set; }
|
||||||
public bool IsBase { get; set; } // базовая единица этой организации
|
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -11,4 +11,8 @@ public class Organization : Entity
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Персональный API-токен MoySklad. Храним per-organization чтобы
|
||||||
|
/// пользователю не нужно было вводить его каждый раз при импорте.</summary>
|
||||||
|
public string? MoySkladToken { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Integrations.MoySklad;
|
||||||
|
|
||||||
|
public enum ImportJobStatus { Running, Succeeded, Failed, Cancelled }
|
||||||
|
|
||||||
|
public class ImportJobProgress
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; } = Guid.NewGuid();
|
||||||
|
public string Kind { get; init; } = ""; // "products" | "counterparties"
|
||||||
|
public DateTime StartedAt { get; init; } = DateTime.UtcNow;
|
||||||
|
public DateTime? FinishedAt { get; set; }
|
||||||
|
public ImportJobStatus Status { get; set; } = ImportJobStatus.Running;
|
||||||
|
public string? Stage { get; set; } // человекочитаемое описание текущего шага
|
||||||
|
public int Total { get; set; } // входящих записей от MS (растёт по мере пейджинга)
|
||||||
|
public int Created { get; set; }
|
||||||
|
public int Updated { get; set; }
|
||||||
|
public int Skipped { get; set; }
|
||||||
|
public int Deleted { get; set; } // для cleanup
|
||||||
|
public int GroupsCreated { get; set; }
|
||||||
|
public string? Message { get; set; } // последняя ошибка / финальное сообщение
|
||||||
|
public List<string> Errors { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process-memory реестр прогресса фоновых импортов. Один процесс API — одно DI singleton.
|
||||||
|
// При рестарте контейнера история импортов теряется — для просмотра «вчерашнего» надо
|
||||||
|
// смотреть логи. На MVP достаточно.
|
||||||
|
public class ImportJobRegistry
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<Guid, ImportJobProgress> _jobs = new();
|
||||||
|
|
||||||
|
public ImportJobProgress Create(string kind)
|
||||||
|
{
|
||||||
|
var job = new ImportJobProgress { Kind = kind };
|
||||||
|
_jobs[job.Id] = job;
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImportJobProgress? Get(Guid id) => _jobs.TryGetValue(id, out var j) ? j : null;
|
||||||
|
|
||||||
|
public IReadOnlyList<ImportJobProgress> RecentlyFinished(int take = 10) =>
|
||||||
|
_jobs.Values
|
||||||
|
.Where(j => j.FinishedAt is not null)
|
||||||
|
.OrderByDescending(j => j.FinishedAt)
|
||||||
|
.Take(take)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
@ -63,60 +63,78 @@ public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, C
|
||||||
: MoySkladApiResult<MsOrganization>.Ok(org);
|
: MoySkladApiResult<MsOrganization>.Ok(org);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoySklad list endpoints по умолчанию возвращают только активных (archived=false).
|
||||||
|
// Чтобы получить архивных, нужен явный filter=archived=true. Делаем два прохода:
|
||||||
|
// сначала активные (default), затем архивные — и отдаём всё одним потоком.
|
||||||
|
|
||||||
public async IAsyncEnumerable<MsProduct> StreamProductsAsync(
|
public async IAsyncEnumerable<MsProduct> StreamProductsAsync(
|
||||||
string token,
|
string token,
|
||||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
||||||
{
|
{
|
||||||
const int pageSize = 1000;
|
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: false, ct)) yield return p;
|
||||||
var offset = 0;
|
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: true, ct)) yield return p;
|
||||||
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(
|
public async IAsyncEnumerable<MsCounterparty> StreamCounterpartiesAsync(
|
||||||
string token,
|
string token,
|
||||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
||||||
{
|
{
|
||||||
const int pageSize = 1000;
|
await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: false, ct)) yield return c;
|
||||||
var offset = 0;
|
await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: true, ct)) yield return c;
|
||||||
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)
|
public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var all = new List<MsProductFolder>();
|
var all = new List<MsProductFolder>();
|
||||||
var offset = 0;
|
await foreach (var f in StreamPagedAsync<MsProductFolder>(token, "entity/productfolder", archivedOnly: false, ct)) all.Add(f);
|
||||||
const int pageSize = 1000;
|
await foreach (var f in StreamPagedAsync<MsProductFolder>(token, "entity/productfolder", archivedOnly: true, ct)) all.Add(f);
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
using var req = Build(HttpMethod.Get, $"entity/productfolder?limit={pageSize}&offset={offset}", token);
|
|
||||||
using var res = await _http.SendAsync(req, ct);
|
|
||||||
res.EnsureSuccessStatusCode();
|
|
||||||
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsProductFolder>>(Json, ct);
|
|
||||||
if (page is null || page.Rows.Count == 0) break;
|
|
||||||
all.AddRange(page.Rows);
|
|
||||||
if (page.Rows.Count < pageSize) break;
|
|
||||||
offset += pageSize;
|
|
||||||
}
|
|
||||||
return all;
|
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" : "";
|
||||||
|
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;
|
||||||
|
offset += pageSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,21 +35,21 @@ public class MoySkladImportService
|
||||||
public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct)
|
public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct)
|
||||||
=> _client.WhoAmIAsync(token, ct);
|
=> _client.WhoAmIAsync(token, ct);
|
||||||
|
|
||||||
public async Task<MoySkladImportResult> ImportCounterpartiesAsync(string token, bool overwriteExisting, CancellationToken ct)
|
public async Task<MoySkladImportResult> ImportCounterpartiesAsync(
|
||||||
|
string token,
|
||||||
|
bool overwriteExisting,
|
||||||
|
CancellationToken ct,
|
||||||
|
ImportJobProgress? progress = null,
|
||||||
|
Guid? organizationIdOverride = null)
|
||||||
{
|
{
|
||||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
|
var orgId = organizationIdOverride ?? _tenant.OrganizationId
|
||||||
|
?? throw new InvalidOperationException("No tenant organization in context.");
|
||||||
|
|
||||||
// Map MoySklad tag set → local CounterpartyKind. If no tags say otherwise, assume Both.
|
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще —
|
||||||
static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList<string>? tags)
|
// counterparty entity содержит только group (группа доступа), tags
|
||||||
{
|
// (произвольные), state (пользовательская цепочка статусов), companyType
|
||||||
if (tags is null || tags.Count == 0) return foodmarket.Domain.Catalog.CounterpartyKind.Both;
|
// (legal/individual/entrepreneur). Никакого role/kind. Поэтому у нас тоже
|
||||||
var lower = tags.Select(t => t.ToLowerInvariant()).ToList();
|
// этого поля нет — пусть пользователь сам решит.
|
||||||
var hasSupplier = lower.Any(t => t.Contains("постав"));
|
|
||||||
var hasCustomer = lower.Any(t => t.Contains("покуп") || t.Contains("клиент"));
|
|
||||||
if (hasSupplier && !hasCustomer) return foodmarket.Domain.Catalog.CounterpartyKind.Supplier;
|
|
||||||
if (hasCustomer && !hasSupplier) return foodmarket.Domain.Catalog.CounterpartyKind.Customer;
|
|
||||||
return foodmarket.Domain.Catalog.CounterpartyKind.Both;
|
|
||||||
}
|
|
||||||
|
|
||||||
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
|
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
|
||||||
=> companyType switch
|
=> companyType switch
|
||||||
|
|
@ -58,11 +58,15 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp
|
||||||
_ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity,
|
_ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Загружаем существующих в память — обновлять будем по имени (case-insensitive).
|
||||||
|
// Раньше при overwriteExisting=true бага: пропускалась проверка "skip" и ВСЕ
|
||||||
|
// поступающие записи добавлялись как новые, порождая дубли. Теперь если имя уже
|
||||||
|
// есть — обновляем ту же запись, иначе создаём.
|
||||||
var existingByName = await _db.Counterparties
|
var existingByName = await _db.Counterparties
|
||||||
.Select(c => new { c.Id, c.Name })
|
.ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct);
|
||||||
.ToDictionaryAsync(c => c.Name, c => c.Id, StringComparer.OrdinalIgnoreCase, ct);
|
|
||||||
|
|
||||||
var created = 0;
|
var created = 0;
|
||||||
|
var updated = 0;
|
||||||
var skipped = 0;
|
var skipped = 0;
|
||||||
var total = 0;
|
var total = 0;
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
|
|
@ -71,36 +75,30 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp
|
||||||
await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
{
|
{
|
||||||
total++;
|
total++;
|
||||||
if (c.Archived) { skipped++; continue; }
|
if (progress is not null) progress.Total = total;
|
||||||
|
// Архивных не пропускаем — импортируем как IsActive=false (см. ApplyCounterparty).
|
||||||
if (existingByName.ContainsKey(c.Name) && !overwriteExisting)
|
|
||||||
{
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var entity = new foodmarket.Domain.Catalog.Counterparty
|
if (existingByName.TryGetValue(c.Name, out var existing))
|
||||||
{
|
{
|
||||||
OrganizationId = orgId,
|
if (!overwriteExisting) { skipped++; if (progress is not null) progress.Skipped = skipped; continue; }
|
||||||
Name = Trim(c.Name, 255) ?? c.Name,
|
ApplyCounterparty(existing, c, ResolveType);
|
||||||
LegalName = Trim(c.LegalTitle, 500),
|
updated++;
|
||||||
Kind = ResolveKind(c.Tags),
|
if (progress is not null) progress.Updated = updated;
|
||||||
Type = ResolveType(c.CompanyType),
|
}
|
||||||
Bin = Trim(c.Inn, 20),
|
else
|
||||||
TaxNumber = Trim(c.Kpp, 20),
|
{
|
||||||
Phone = Trim(c.Phone, 50),
|
var entity = new foodmarket.Domain.Catalog.Counterparty { OrganizationId = orgId };
|
||||||
Email = Trim(c.Email, 255),
|
ApplyCounterparty(entity, c, ResolveType);
|
||||||
Address = Trim(c.ActualAddress ?? c.LegalAddress, 500),
|
_db.Counterparties.Add(entity);
|
||||||
Notes = Trim(c.Description, 1000),
|
existingByName[c.Name] = entity;
|
||||||
IsActive = !c.Archived,
|
created++;
|
||||||
};
|
if (progress is not null) progress.Created = created;
|
||||||
_db.Counterparties.Add(entity);
|
}
|
||||||
existingByName[c.Name] = entity.Id;
|
|
||||||
created++;
|
|
||||||
batch++;
|
batch++;
|
||||||
if (batch >= 500)
|
if (batch >= 100)
|
||||||
{
|
{
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
batch = 0;
|
batch = 0;
|
||||||
|
|
@ -110,25 +108,46 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
{
|
{
|
||||||
_log.LogWarning(ex, "Failed to import counterparty {Name}", c.Name);
|
_log.LogWarning(ex, "Failed to import counterparty {Name}", c.Name);
|
||||||
errors.Add($"{c.Name}: {ex.Message}");
|
errors.Add($"{c.Name}: {ex.Message}");
|
||||||
|
if (progress is not null) progress.Errors = errors;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (batch > 0) await _db.SaveChangesAsync(ct);
|
if (batch > 0) await _db.SaveChangesAsync(ct);
|
||||||
return new MoySkladImportResult(total, created, skipped, 0, errors);
|
// `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;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MoySkladImportResult> ImportProductsAsync(
|
public async Task<MoySkladImportResult> ImportProductsAsync(
|
||||||
string token,
|
string token,
|
||||||
bool overwriteExisting,
|
bool overwriteExisting,
|
||||||
CancellationToken ct)
|
CancellationToken ct,
|
||||||
|
ImportJobProgress? progress = null,
|
||||||
|
Guid? organizationIdOverride = null)
|
||||||
{
|
{
|
||||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
|
var orgId = organizationIdOverride ?? _tenant.OrganizationId
|
||||||
|
?? throw new InvalidOperationException("No tenant organization in context.");
|
||||||
|
|
||||||
// Pre-load tenant defaults.
|
// Pre-load tenant defaults. KZ default VAT is 16% — applied when product didn't
|
||||||
var defaultVat = await _db.VatRates.FirstOrDefaultAsync(v => v.IsDefault, ct)
|
// carry its own vat from MoySklad.
|
||||||
?? await _db.VatRates.FirstAsync(ct);
|
const int kzDefaultVat = 16;
|
||||||
var vatByPercent = await _db.VatRates.ToDictionaryAsync(v => (int)v.Percent, v => v.Id, ct);
|
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", ct)
|
||||||
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.IsBase, ct)
|
|
||||||
?? await _db.UnitsOfMeasure.FirstAsync(ct);
|
?? await _db.UnitsOfMeasure.FirstAsync(ct);
|
||||||
var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct)
|
var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct)
|
||||||
?? await _db.PriceTypes.FirstAsync(ct);
|
?? await _db.PriceTypes.FirstAsync(ct);
|
||||||
|
|
@ -138,11 +157,12 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.ToDictionaryAsync(c => c.Name, c => c.Id, ct);
|
.ToDictionaryAsync(c => c.Name, c => c.Id, ct);
|
||||||
|
|
||||||
// Import folders first — build flat then link parents.
|
// Import folders first — build flat then link parents. Архивные тоже берём,
|
||||||
|
// помечаем IsActive=false — у MoySklad у productfolder есть archived.
|
||||||
var folders = await _client.GetAllFoldersAsync(token, ct);
|
var folders = await _client.GetAllFoldersAsync(token, ct);
|
||||||
var localGroupByMsId = new Dictionary<string, Guid>();
|
var localGroupByMsId = new Dictionary<string, Guid>();
|
||||||
var groupsCreated = 0;
|
var groupsCreated = 0;
|
||||||
foreach (var f in folders.Where(f => !f.Archived).OrderBy(f => f.PathName?.Length ?? 0))
|
foreach (var f in folders.OrderBy(f => f.PathName?.Length ?? 0))
|
||||||
{
|
{
|
||||||
if (f.Id is null) continue;
|
if (f.Id is null) continue;
|
||||||
var existing = await _db.ProductGroups.FirstOrDefaultAsync(
|
var existing = await _db.ProductGroups.FirstOrDefaultAsync(
|
||||||
|
|
@ -157,47 +177,49 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
OrganizationId = orgId,
|
OrganizationId = orgId,
|
||||||
Name = f.Name,
|
Name = f.Name,
|
||||||
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
|
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
|
||||||
IsActive = true,
|
IsActive = !f.Archived,
|
||||||
};
|
};
|
||||||
_db.ProductGroups.Add(g);
|
_db.ProductGroups.Add(g);
|
||||||
localGroupByMsId[f.Id] = g.Id;
|
localGroupByMsId[f.Id] = g.Id;
|
||||||
groupsCreated++;
|
groupsCreated++;
|
||||||
}
|
}
|
||||||
if (groupsCreated > 0) await _db.SaveChangesAsync(ct);
|
if (groupsCreated > 0) await _db.SaveChangesAsync(ct);
|
||||||
|
if (progress is not null) progress.GroupsCreated = groupsCreated;
|
||||||
|
|
||||||
// Import products
|
// Import products
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
var created = 0;
|
var created = 0;
|
||||||
|
var updated = 0;
|
||||||
var skipped = 0;
|
var skipped = 0;
|
||||||
var total = 0;
|
var total = 0;
|
||||||
var existingArticles = await _db.Products
|
// При overwriteExisting=true загружаем товары целиком, чтобы обновлять существующие
|
||||||
|
// вместо создания дубликатов. Ключ = артикул (нормализованный).
|
||||||
|
var existingByArticle = await _db.Products
|
||||||
.Where(p => p.Article != null)
|
.Where(p => p.Article != null)
|
||||||
.Select(p => p.Article!)
|
.ToDictionaryAsync(p => p.Article!, p => p, StringComparer.OrdinalIgnoreCase, ct);
|
||||||
.ToListAsync(ct);
|
|
||||||
var existingArticleSet = new HashSet<string>(existingArticles, StringComparer.OrdinalIgnoreCase);
|
|
||||||
var existingBarcodeSet = new HashSet<string>(
|
var existingBarcodeSet = new HashSet<string>(
|
||||||
await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct));
|
await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct));
|
||||||
|
|
||||||
await foreach (var p in _client.StreamProductsAsync(token, ct))
|
await foreach (var p in _client.StreamProductsAsync(token, ct))
|
||||||
{
|
{
|
||||||
total++;
|
total++;
|
||||||
if (p.Archived) { skipped++; continue; }
|
if (progress is not null) progress.Total = total;
|
||||||
|
// Архивных не пропускаем — импортируем как IsActive=false.
|
||||||
|
|
||||||
var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article;
|
var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article;
|
||||||
var primaryBarcode = ExtractBarcodes(p).FirstOrDefault();
|
var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingByArticle.ContainsKey(article);
|
||||||
|
|
||||||
var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingArticleSet.Contains(article);
|
if (alreadyByArticle && !overwriteExisting)
|
||||||
var alreadyByBarcode = primaryBarcode is not null && existingBarcodeSet.Contains(primaryBarcode.Code);
|
|
||||||
|
|
||||||
if ((alreadyByArticle || alreadyByBarcode) && !overwriteExisting)
|
|
||||||
{
|
{
|
||||||
skipped++;
|
skipped++;
|
||||||
|
if (progress is not null) progress.Skipped = skipped;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var vatId = p.Vat is int vp && vatByPercent.TryGetValue(vp, out var id) ? id : defaultVat.Id;
|
var vat = p.Vat ?? kzDefaultVat;
|
||||||
|
var vatEnabled = (p.Vat ?? kzDefaultVat) > 0;
|
||||||
Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId
|
Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId
|
||||||
&& localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null;
|
&& localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null;
|
||||||
Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null;
|
Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null;
|
||||||
|
|
@ -205,58 +227,84 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
||||||
var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true)
|
var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
?? p.SalePrices?.FirstOrDefault();
|
?? p.SalePrices?.FirstOrDefault();
|
||||||
|
|
||||||
var product = new Product
|
Product product;
|
||||||
|
if (alreadyByArticle && overwriteExisting)
|
||||||
{
|
{
|
||||||
OrganizationId = orgId,
|
product = existingByArticle[article!];
|
||||||
Name = Trim(p.Name, 500),
|
// Обновляем только скалярные поля — коллекции (prices, barcodes) оставляем:
|
||||||
Article = Trim(article, 500),
|
// там могут быть данные, которые редактировал пользователь после импорта.
|
||||||
Description = p.Description,
|
product.Name = Trim(p.Name, 500);
|
||||||
UnitOfMeasureId = baseUnit.Id,
|
product.Article = Trim(article, 500);
|
||||||
VatRateId = vatId,
|
product.Description = p.Description;
|
||||||
ProductGroupId = groupId,
|
product.Vat = vat;
|
||||||
CountryOfOriginId = countryId,
|
product.VatEnabled = vatEnabled;
|
||||||
IsWeighed = p.Weighed,
|
product.ProductGroupId = groupId ?? product.ProductGroupId;
|
||||||
IsAlcohol = p.Alcoholic is not null,
|
product.CountryOfOriginId = countryId ?? product.CountryOfOriginId;
|
||||||
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
|
product.IsWeighed = p.Weighed;
|
||||||
IsActive = !p.Archived,
|
product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED";
|
||||||
PurchasePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
|
product.IsActive = !p.Archived;
|
||||||
PurchaseCurrencyId = kzt.Id,
|
product.PurchasePrice = p.BuyPrice is null ? product.PurchasePrice : p.BuyPrice.Value / 100m;
|
||||||
};
|
updated++;
|
||||||
|
if (progress is not null) progress.Updated = updated;
|
||||||
if (retailPrice is not null)
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
product.Prices.Add(new ProductPrice
|
product = new Product
|
||||||
{
|
{
|
||||||
OrganizationId = orgId,
|
OrganizationId = orgId,
|
||||||
PriceTypeId = retailType.Id,
|
Name = Trim(p.Name, 500),
|
||||||
Amount = retailPrice.Value / 100m,
|
Article = Trim(article, 500),
|
||||||
CurrencyId = kzt.Id,
|
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++;
|
||||||
|
if (progress is not null) progress.Created = created;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var b in ExtractBarcodes(p))
|
// Flush чаще (каждые 100) чтобы при сетевом обрыве на следующей странице
|
||||||
{
|
// мы сохранили как можно больше и смогли безопасно продолжить с overwrite.
|
||||||
if (existingBarcodeSet.Contains(b.Code)) continue;
|
if ((created + updated) % 100 == 0) await _db.SaveChangesAsync(ct);
|
||||||
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_log.LogWarning(ex, "Failed to import MoySklad product {Name}", p.Name);
|
_log.LogWarning(ex, "Failed to import MoySklad product {Name}", p.Name);
|
||||||
errors.Add($"{p.Name}: {ex.Message}");
|
errors.Add($"{p.Name}: {ex.Message}");
|
||||||
|
if (progress is not null) progress.Errors = errors;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return new MoySkladImportResult(total, created, skipped, groupsCreated, errors);
|
return new MoySkladImportResult(total, created + updated, skipped, groupsCreated, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<ProductBarcode> ExtractBarcodes(MsProduct p)
|
private static List<ProductBarcode> ExtractBarcodes(MsProduct p)
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
||||||
|
|
||||||
public DbSet<Country> Countries => Set<Country>();
|
public DbSet<Country> Countries => Set<Country>();
|
||||||
public DbSet<Currency> Currencies => Set<Currency>();
|
public DbSet<Currency> Currencies => Set<Currency>();
|
||||||
public DbSet<VatRate> VatRates => Set<VatRate>();
|
|
||||||
public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>();
|
public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>();
|
||||||
public DbSet<Counterparty> Counterparties => Set<Counterparty>();
|
public DbSet<Counterparty> Counterparties => Set<Counterparty>();
|
||||||
public DbSet<Store> Stores => Set<Store>();
|
public DbSet<Store> Stores => Set<Store>();
|
||||||
|
|
@ -70,6 +69,7 @@ protected override void OnModelCreating(ModelBuilder builder)
|
||||||
b.Property(o => o.Name).HasMaxLength(200).IsRequired();
|
b.Property(o => o.Name).HasMaxLength(200).IsRequired();
|
||||||
b.Property(o => o.CountryCode).HasMaxLength(2).IsRequired();
|
b.Property(o => o.CountryCode).HasMaxLength(2).IsRequired();
|
||||||
b.Property(o => o.Bin).HasMaxLength(20);
|
b.Property(o => o.Bin).HasMaxLength(20);
|
||||||
|
b.Property(o => o.MoySkladToken).HasMaxLength(200);
|
||||||
b.HasIndex(o => o.Name);
|
b.HasIndex(o => o.Name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ public static void ConfigureCatalog(this ModelBuilder b)
|
||||||
{
|
{
|
||||||
b.Entity<Country>(ConfigureCountry);
|
b.Entity<Country>(ConfigureCountry);
|
||||||
b.Entity<Currency>(ConfigureCurrency);
|
b.Entity<Currency>(ConfigureCurrency);
|
||||||
b.Entity<VatRate>(ConfigureVatRate);
|
|
||||||
b.Entity<UnitOfMeasure>(ConfigureUnit);
|
b.Entity<UnitOfMeasure>(ConfigureUnit);
|
||||||
b.Entity<Counterparty>(ConfigureCounterparty);
|
b.Entity<Counterparty>(ConfigureCounterparty);
|
||||||
b.Entity<Store>(ConfigureStore);
|
b.Entity<Store>(ConfigureStore);
|
||||||
|
|
@ -40,20 +39,12 @@ private static void ConfigureCurrency(EntityTypeBuilder<Currency> b)
|
||||||
b.HasIndex(x => x.Code).IsUnique();
|
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)
|
private static void ConfigureUnit(EntityTypeBuilder<UnitOfMeasure> b)
|
||||||
{
|
{
|
||||||
b.ToTable("units_of_measure");
|
b.ToTable("units_of_measure");
|
||||||
b.Property(x => x.Code).HasMaxLength(10).IsRequired();
|
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.Name).HasMaxLength(100).IsRequired();
|
||||||
|
b.Property(x => x.Description).HasMaxLength(500);
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique();
|
b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +65,6 @@ private static void ConfigureCounterparty(EntityTypeBuilder<Counterparty> b)
|
||||||
b.HasOne(x => x.Country).WithMany().HasForeignKey(x => x.CountryId).OnDelete(DeleteBehavior.Restrict);
|
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.Name });
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.Bin });
|
b.HasIndex(x => new { x.OrganizationId, x.Bin });
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.Kind });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ConfigureStore(EntityTypeBuilder<Store> b)
|
private static void ConfigureStore(EntityTypeBuilder<Store> b)
|
||||||
|
|
@ -130,7 +120,6 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
|
||||||
b.Property(x => x.ImageUrl).HasMaxLength(1000);
|
b.Property(x => x.ImageUrl).HasMaxLength(1000);
|
||||||
|
|
||||||
b.HasOne(x => x.UnitOfMeasure).WithMany().HasForeignKey(x => x.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict);
|
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.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.DefaultSupplier).WithMany().HasForeignKey(x => x.DefaultSupplierId).OnDelete(DeleteBehavior.Restrict);
|
||||||
b.HasOne(x => x.CountryOfOrigin).WithMany().HasForeignKey(x => x.CountryOfOriginId).OnDelete(DeleteBehavior.Restrict);
|
b.HasOne(x => x.CountryOfOrigin).WithMany().HasForeignKey(x => x.CountryOfOriginId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
|
||||||
1855
src/food-market.infrastructure/Persistence/Migrations/20260423161923_Phase2c4_ReconcileStage.Designer.cs
generated
Normal file
1855
src/food-market.infrastructure/Persistence/Migrations/20260423161923_Phase2c4_ReconcileStage.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,70 @@
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,30 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>Добавляем колонку organizations.MoySkladToken — per-tenant API-токен
|
||||||
|
/// MoySklad. Хранится, чтобы не вводить вручную при каждом импорте.</summary>
|
||||||
|
public partial class Phase3_OrganizationMoySkladToken : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "MoySkladToken",
|
||||||
|
schema: "public",
|
||||||
|
table: "organizations",
|
||||||
|
type: "character varying(200)",
|
||||||
|
maxLength: 200,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MoySkladToken",
|
||||||
|
schema: "public",
|
||||||
|
table: "organizations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -380,9 +380,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<int>("Kind")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("LegalName")
|
b.Property<string>("LegalName")
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("character varying(500)");
|
.HasColumnType("character varying(500)");
|
||||||
|
|
@ -418,8 +415,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
|
||||||
b.HasIndex("OrganizationId", "Bin");
|
b.HasIndex("OrganizationId", "Bin");
|
||||||
|
|
||||||
b.HasIndex("OrganizationId", "Kind");
|
|
||||||
|
|
||||||
b.HasIndex("OrganizationId", "Name");
|
b.HasIndex("OrganizationId", "Name");
|
||||||
|
|
||||||
b.ToTable("counterparties", "public");
|
b.ToTable("counterparties", "public");
|
||||||
|
|
@ -568,9 +563,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool>("IsAlcohol")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("IsMarked")
|
b.Property<bool>("IsMarked")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
|
@ -612,8 +604,11 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<Guid>("VatRateId")
|
b.Property<int>("Vat")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("VatEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
|
@ -627,8 +622,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
|
||||||
b.HasIndex("UnitOfMeasureId");
|
b.HasIndex("UnitOfMeasureId");
|
||||||
|
|
||||||
b.HasIndex("VatRateId");
|
|
||||||
|
|
||||||
b.HasIndex("OrganizationId", "Article");
|
b.HasIndex("OrganizationId", "Article");
|
||||||
|
|
||||||
b.HasIndex("OrganizationId", "IsActive");
|
b.HasIndex("OrganizationId", "IsActive");
|
||||||
|
|
@ -876,9 +869,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Property<bool>("IsMain")
|
b.Property<bool>("IsMain")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<int>("Kind")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("ManagerName")
|
b.Property<string>("ManagerName")
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
@ -919,15 +909,13 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<int>("DecimalPlaces")
|
b.Property<string>("Description")
|
||||||
.HasColumnType("integer");
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool>("IsBase")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
|
|
@ -936,11 +924,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Property<Guid>("OrganizationId")
|
b.Property<Guid>("OrganizationId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("Symbol")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(20)
|
|
||||||
.HasColumnType("character varying(20)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
|
@ -952,47 +935,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.ToTable("units_of_measure", "public");
|
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 =>
|
modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|
@ -1132,6 +1074,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("MoySkladToken")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
|
|
@ -1652,12 +1598,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("foodmarket.Domain.Catalog.VatRate", "VatRate")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("VatRateId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("CountryOfOrigin");
|
b.Navigation("CountryOfOrigin");
|
||||||
|
|
||||||
b.Navigation("DefaultSupplier");
|
b.Navigation("DefaultSupplier");
|
||||||
|
|
@ -1667,8 +1607,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Navigation("PurchaseCurrency");
|
b.Navigation("PurchaseCurrency");
|
||||||
|
|
||||||
b.Navigation("UnitOfMeasure");
|
b.Navigation("UnitOfMeasure");
|
||||||
|
|
||||||
b.Navigation("VatRate");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("foodmarket.Domain.Catalog.ProductBarcode", b =>
|
modelBuilder.Entity("foodmarket.Domain.Catalog.ProductBarcode", b =>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { LoginPage } from '@/pages/LoginPage'
|
||||||
import { DashboardPage } from '@/pages/DashboardPage'
|
import { DashboardPage } from '@/pages/DashboardPage'
|
||||||
import { CountriesPage } from '@/pages/CountriesPage'
|
import { CountriesPage } from '@/pages/CountriesPage'
|
||||||
import { CurrenciesPage } from '@/pages/CurrenciesPage'
|
import { CurrenciesPage } from '@/pages/CurrenciesPage'
|
||||||
import { VatRatesPage } from '@/pages/VatRatesPage'
|
|
||||||
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
|
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
|
||||||
import { PriceTypesPage } from '@/pages/PriceTypesPage'
|
import { PriceTypesPage } from '@/pages/PriceTypesPage'
|
||||||
import { StoresPage } from '@/pages/StoresPage'
|
import { StoresPage } from '@/pages/StoresPage'
|
||||||
|
|
@ -46,7 +45,6 @@ export default function App() {
|
||||||
<Route path="/catalog/products/:id" element={<ProductEditPage />} />
|
<Route path="/catalog/products/:id" element={<ProductEditPage />} />
|
||||||
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
|
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
|
||||||
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
|
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
|
||||||
<Route path="/catalog/vat-rates" element={<VatRatesPage />} />
|
|
||||||
<Route path="/catalog/price-types" element={<PriceTypesPage />} />
|
<Route path="/catalog/price-types" element={<PriceTypesPage />} />
|
||||||
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
|
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
|
||||||
<Route path="/catalog/stores" element={<StoresPage />} />
|
<Route path="/catalog/stores" element={<StoresPage />} />
|
||||||
|
|
|
||||||
111
src/food-market.web/src/components/ProductGroupTree.tsx
Normal file
111
src/food-market.web/src/components/ProductGroupTree.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -71,7 +71,7 @@ export function ProductPicker({ open, onClose, onPick, title = 'Выбор то
|
||||||
<div className="text-xs text-slate-400 flex gap-2 font-mono">
|
<div className="text-xs text-slate-400 flex gap-2 font-mono">
|
||||||
{p.article && <span>{p.article}</span>}
|
{p.article && <span>{p.article}</span>}
|
||||||
{p.barcodes[0] && <span>· {p.barcodes[0].code}</span>}
|
{p.barcodes[0] && <span>· {p.barcodes[0].code}</span>}
|
||||||
<span>· {p.unitSymbol}</span>
|
<span>· {p.unitName}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{p.purchasePrice !== null && (
|
{p.purchasePrice !== null && (
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,18 @@ export interface PagedResult<T> {
|
||||||
totalPages: number
|
totalPages: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CounterpartyKind = { 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 const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const
|
||||||
export type CounterpartyType = (typeof CounterpartyType)[keyof typeof CounterpartyType]
|
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 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 type BarcodeType = (typeof BarcodeType)[keyof typeof BarcodeType]
|
||||||
|
|
||||||
export interface Country { id: string; code: string; name: string; sortOrder: number }
|
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 Currency { id: string; code: string; name: string; symbol: string; minorUnit: number; isActive: boolean }
|
||||||
export interface VatRate { id: string; name: string; percent: number; isIncludedInPrice: boolean; isDefault: boolean; isActive: boolean }
|
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; 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 PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }
|
||||||
export interface Store {
|
export interface Store {
|
||||||
id: string; name: string; code: string | null; kind: StoreKind; address: string | null; phone: string | null;
|
id: string; name: string; code: string | null; address: string | null; phone: string | null;
|
||||||
managerName: string | null; isMain: boolean; isActive: boolean
|
managerName: string | null; isMain: boolean; isActive: boolean
|
||||||
}
|
}
|
||||||
export interface RetailPoint {
|
export interface RetailPoint {
|
||||||
|
|
@ -33,7 +26,7 @@ export interface RetailPoint {
|
||||||
}
|
}
|
||||||
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean }
|
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean }
|
||||||
export interface Counterparty {
|
export interface Counterparty {
|
||||||
id: string; name: string; legalName: string | null; kind: CounterpartyKind; type: CounterpartyType;
|
id: string; name: string; legalName: string | null; type: CounterpartyType;
|
||||||
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;
|
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;
|
||||||
address: string | null; phone: string | null; email: string | null;
|
address: string | null; phone: string | null; email: string | null;
|
||||||
bankName: string | null; bankAccount: string | null; bik: string | null;
|
bankName: string | null; bankAccount: string | null; bik: string | null;
|
||||||
|
|
@ -43,12 +36,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 ProductPrice { id: string; priceTypeId: string; priceTypeName: string; amount: number; currencyId: string; currencyCode: string }
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: string; name: string; article: string | null; description: string | null;
|
id: string; name: string; article: string | null; description: string | null;
|
||||||
unitOfMeasureId: string; unitSymbol: string;
|
unitOfMeasureId: string; unitName: string;
|
||||||
vatRateId: string; vatPercent: number;
|
vat: number; vatEnabled: boolean;
|
||||||
productGroupId: string | null; productGroupName: string | null;
|
productGroupId: string | null; productGroupName: string | null;
|
||||||
defaultSupplierId: string | null; defaultSupplierName: string | null;
|
defaultSupplierId: string | null; defaultSupplierName: string | null;
|
||||||
countryOfOriginId: string | null; countryOfOriginName: string | null;
|
countryOfOriginId: string | null; countryOfOriginName: string | null;
|
||||||
isService: boolean; isWeighed: boolean; isAlcohol: boolean; isMarked: boolean;
|
isService: boolean; isWeighed: boolean; isMarked: boolean;
|
||||||
minStock: number | null; maxStock: number | null;
|
minStock: number | null; maxStock: number | null;
|
||||||
purchasePrice: number | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
|
purchasePrice: number | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
|
||||||
imageUrl: string | null; isActive: boolean;
|
imageUrl: string | null; isActive: boolean;
|
||||||
|
|
@ -56,7 +49,7 @@ export interface Product {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StockRow {
|
export interface StockRow {
|
||||||
productId: string; productName: string; article: string | null; unitSymbol: string;
|
productId: string; productName: string; article: string | null; unitName: string;
|
||||||
storeId: string; storeName: string;
|
storeId: string; storeName: string;
|
||||||
quantity: number; reservedQuantity: number; available: number;
|
quantity: number; reservedQuantity: number; available: number;
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +76,7 @@ export interface SupplyListRow {
|
||||||
|
|
||||||
export interface SupplyLineDto {
|
export interface SupplyLineDto {
|
||||||
id: string | null; productId: string;
|
id: string | null; productId: string;
|
||||||
productName: string | null; productArticle: string | null; unitSymbol: string | null;
|
productName: string | null; productArticle: string | null; unitName: string | null;
|
||||||
quantity: number; unitPrice: number; lineTotal: number; sortOrder: number;
|
quantity: number; unitPrice: number; lineTotal: number; sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,7 +109,7 @@ export interface RetailSaleListRow {
|
||||||
|
|
||||||
export interface RetailSaleLineDto {
|
export interface RetailSaleLineDto {
|
||||||
id: string | null; productId: string;
|
id: string | null; productId: string;
|
||||||
productName: string | null; productArticle: string | null; unitSymbol: string | null;
|
productName: string | null; productArticle: string | null; unitName: string | null;
|
||||||
quantity: number; unitPrice: number; discount: number; lineTotal: number;
|
quantity: number; unitPrice: number; discount: number; lineTotal: number;
|
||||||
vatPercent: number; sortOrder: number;
|
vatPercent: number; sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import type {
|
import type {
|
||||||
PagedResult, UnitOfMeasure, VatRate, ProductGroup, Counterparty,
|
PagedResult, UnitOfMeasure, ProductGroup, Counterparty,
|
||||||
Country, Currency, Store, PriceType,
|
Country, Currency, Store, PriceType,
|
||||||
} from '@/lib/types'
|
} from '@/lib/types'
|
||||||
|
|
||||||
|
|
@ -14,14 +14,13 @@ function useLookup<T>(key: string, url: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUnits = () => useLookup<UnitOfMeasure>('units', '/api/catalog/units-of-measure')
|
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 useProductGroups = () => useLookup<ProductGroup>('groups', '/api/catalog/product-groups')
|
||||||
export const useCountries = () => useLookup<Country>('countries', '/api/catalog/countries')
|
export const useCountries = () => useLookup<Country>('countries', '/api/catalog/countries')
|
||||||
export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies')
|
export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies')
|
||||||
export const useStores = () => useLookup<Store>('stores', '/api/catalog/stores')
|
export const useStores = () => useLookup<Store>('stores', '/api/catalog/stores')
|
||||||
export const usePriceTypes = () => useLookup<PriceType>('price-types', '/api/catalog/price-types')
|
export const usePriceTypes = () => useLookup<PriceType>('price-types', '/api/catalog/price-types')
|
||||||
export const useSuppliers = () => useQuery({
|
// MoySklad-style: контрагент один, может быть и поставщиком, и покупателем
|
||||||
queryKey: ['lookup:suppliers'],
|
// в разных документах. Не фильтруем по Kind — пользователь сам выбирает.
|
||||||
queryFn: async () => (await api.get<PagedResult<Counterparty>>('/api/catalog/counterparties?pageSize=500&kind=1')).data.items,
|
export const useCounterparties = () => useLookup<Counterparty>('counterparties', '/api/catalog/counterparties')
|
||||||
staleTime: 5 * 60 * 1000,
|
// Алиас для обратной совместимости со старым кодом форм Supply/RetailSale.
|
||||||
})
|
export const useSuppliers = useCounterparties
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { Button } from '@/components/Button'
|
||||||
import { Modal } from '@/components/Modal'
|
import { Modal } from '@/components/Modal'
|
||||||
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
|
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
|
||||||
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
||||||
import { type Counterparty, type Country, type PagedResult, CounterpartyKind, CounterpartyType } from '@/lib/types'
|
import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types'
|
||||||
|
|
||||||
const URL = '/api/catalog/counterparties'
|
const URL = '/api/catalog/counterparties'
|
||||||
|
|
||||||
|
|
@ -18,7 +18,6 @@ interface Form {
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
legalName: string
|
legalName: string
|
||||||
kind: CounterpartyKind
|
|
||||||
type: CounterpartyType
|
type: CounterpartyType
|
||||||
bin: string
|
bin: string
|
||||||
iin: string
|
iin: string
|
||||||
|
|
@ -36,17 +35,16 @@ interface Form {
|
||||||
}
|
}
|
||||||
|
|
||||||
const blankForm: Form = {
|
const blankForm: Form = {
|
||||||
name: '', legalName: '', kind: CounterpartyKind.Supplier, type: CounterpartyType.LegalEntity,
|
name: '', legalName: '', type: CounterpartyType.LegalEntity,
|
||||||
bin: '', iin: '', taxNumber: '', countryId: '',
|
bin: '', iin: '', taxNumber: '', countryId: '',
|
||||||
address: '', phone: '', email: '',
|
address: '', phone: '', email: '',
|
||||||
bankName: '', bankAccount: '', bik: '',
|
bankName: '', bankAccount: '', bik: '',
|
||||||
contactPerson: '', notes: '', isActive: true,
|
contactPerson: '', notes: '', isActive: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
const kindLabel: Record<CounterpartyKind, string> = {
|
const typeLabel: Record<CounterpartyType, string> = {
|
||||||
[CounterpartyKind.Supplier]: 'Поставщик',
|
[CounterpartyType.LegalEntity]: 'Юрлицо',
|
||||||
[CounterpartyKind.Customer]: 'Покупатель',
|
[CounterpartyType.Individual]: 'Физлицо',
|
||||||
[CounterpartyKind.Both]: 'Оба',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CounterpartiesPage() {
|
export function CounterpartiesPage() {
|
||||||
|
|
@ -89,7 +87,7 @@ export function CounterpartiesPage() {
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
rowKey={(r) => r.id}
|
rowKey={(r) => r.id}
|
||||||
onRowClick={(r) => setForm({
|
onRowClick={(r) => setForm({
|
||||||
id: r.id, name: r.name, legalName: r.legalName ?? '', kind: r.kind, type: r.type,
|
id: r.id, name: r.name, legalName: r.legalName ?? '', type: r.type,
|
||||||
bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '',
|
bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '',
|
||||||
countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '',
|
countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '',
|
||||||
bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '',
|
bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '',
|
||||||
|
|
@ -97,7 +95,7 @@ export function CounterpartiesPage() {
|
||||||
})}
|
})}
|
||||||
columns={[
|
columns={[
|
||||||
{ header: 'Название', cell: (r) => r.name },
|
{ header: 'Название', cell: (r) => r.name },
|
||||||
{ header: 'Тип', width: '120px', cell: (r) => kindLabel[r.kind] },
|
{ header: 'Тип', width: '120px', cell: (r) => typeLabel[r.type] },
|
||||||
{ header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> },
|
{ header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> },
|
||||||
{ header: 'Телефон', width: '160px', cell: (r) => r.phone ?? '—' },
|
{ header: 'Телефон', width: '160px', cell: (r) => r.phone ?? '—' },
|
||||||
{ header: 'Страна', width: '120px', cell: (r) => r.countryName ?? '—' },
|
{ header: 'Страна', width: '120px', cell: (r) => r.countryName ?? '—' },
|
||||||
|
|
@ -136,13 +134,6 @@ export function CounterpartiesPage() {
|
||||||
<Field label="Юридическое название" className="col-span-2">
|
<Field label="Юридическое название" className="col-span-2">
|
||||||
<TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} />
|
<TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Роль">
|
|
||||||
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as CounterpartyKind })}>
|
|
||||||
<option value={CounterpartyKind.Supplier}>Поставщик</option>
|
|
||||||
<option value={CounterpartyKind.Customer}>Покупатель</option>
|
|
||||||
<option value={CounterpartyKind.Both}>Оба</option>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Field label="Тип лица">
|
<Field label="Тип лица">
|
||||||
<Select value={form.type} onChange={(e) => setForm({ ...form, type: Number(e.target.value) as CounterpartyType })}>
|
<Select value={form.type} onChange={(e) => setForm({ ...form, type: Number(e.target.value) as CounterpartyType })}>
|
||||||
<option value={CounterpartyType.LegalEntity}>Юрлицо</option>
|
<option value={CounterpartyType.LegalEntity}>Юрлицо</option>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package } from 'lucide-react'
|
import { AlertCircle, CheckCircle, KeyRound, Users, Package, Trash2, AlertTriangle, Save } from 'lucide-react'
|
||||||
import { AxiosError } from 'axios'
|
import { AxiosError } from 'axios'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
|
@ -12,164 +12,308 @@ function formatError(err: unknown): string {
|
||||||
const status = err.response?.status
|
const status = err.response?.status
|
||||||
const body = err.response?.data as { error?: string; error_description?: string; title?: string } | undefined
|
const body = err.response?.data as { error?: string; error_description?: string; title?: string } | undefined
|
||||||
const detail = body?.error ?? body?.error_description ?? body?.title
|
const detail = body?.error ?? body?.error_description ?? body?.title
|
||||||
if (status === 404) {
|
if (status === 404) return '404 — эндпоинт не существует. API обновлён?'
|
||||||
return '404 Not Found — эндпоинт не существует. Вероятно, API не перезапущен после git pull.'
|
if (status === 401) return '401 — сессия истекла, перелогинься.'
|
||||||
}
|
if (status === 403) return '403 — нужна роль Admin.'
|
||||||
if (status === 401) return '401 Unauthorized — сессия истекла, перелогинься.'
|
if (status === 502 || status === 503) return `${status} — МойСклад недоступен.`
|
||||||
if (status === 403) return '403 Forbidden — нужна роль Admin или SuperAdmin.'
|
|
||||||
if (status === 502 || status === 503) return `${status} — МойСклад недоступен, попробуй позже.`
|
|
||||||
return detail ? `${status ?? ''} ${detail}` : err.message
|
return detail ? `${status ?? ''} ${detail}` : err.message
|
||||||
}
|
}
|
||||||
if (err instanceof Error) return err.message
|
if (err instanceof Error) return err.message
|
||||||
return String(err)
|
return String(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TestResponse { organization: string; inn?: string | null }
|
interface SettingsDto { hasToken: boolean; masked: string | null }
|
||||||
interface ImportResponse {
|
interface JobView {
|
||||||
total: number; created: number; skipped: number; groupsCreated: number; errors: string[]
|
id: string
|
||||||
|
kind: string
|
||||||
|
status: 'Running' | 'Succeeded' | 'Failed' | 'Cancelled'
|
||||||
|
stage: string | null
|
||||||
|
startedAt: string
|
||||||
|
finishedAt: string | null
|
||||||
|
total: number; created: number; updated: number; skipped: number; deleted: number; groupsCreated: number
|
||||||
|
message: string | null
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function useJob(jobId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin-job', jobId],
|
||||||
|
enabled: !!jobId,
|
||||||
|
queryFn: async () => (await api.get<JobView>(`/api/admin/jobs/${jobId}`)).data,
|
||||||
|
refetchInterval: (q) => {
|
||||||
|
const status = q.state.data?.status
|
||||||
|
return status === 'Succeeded' || status === 'Failed' ? false : 1500
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MoySkladImportPage() {
|
export function MoySkladImportPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [token, setToken] = useState('')
|
|
||||||
const [overwrite, setOverwrite] = useState(false)
|
const settings = useQuery({
|
||||||
|
queryKey: ['/api/admin/moysklad/settings'],
|
||||||
|
queryFn: async () => (await api.get<SettingsDto>('/api/admin/moysklad/settings')).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [tokenInput, setTokenInput] = useState('')
|
||||||
|
const [overwrite, setOverwrite] = useState(true)
|
||||||
|
|
||||||
|
const saveToken = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
(await api.put<SettingsDto>('/api/admin/moysklad/settings', { token: tokenInput })).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
setTokenInput('')
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/admin/moysklad/settings'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const test = useMutation({
|
const test = useMutation({
|
||||||
mutationFn: async () => (await api.post<TestResponse>('/api/admin/moysklad/test', { token })).data,
|
mutationFn: async () =>
|
||||||
|
(await api.post<{ organization: string; inn?: string }>('/api/admin/moysklad/test', {})).data,
|
||||||
})
|
})
|
||||||
|
|
||||||
const products = useMutation({
|
const [productsJobId, setProductsJobId] = useState<string | null>(null)
|
||||||
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-products', { token, overwriteExisting: overwrite })).data,
|
const [counterpartiesJobId, setCounterpartiesJobId] = useState<string | null>(null)
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }),
|
|
||||||
|
const startProducts = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
(await api.post<{ jobId: string }>('/api/admin/moysklad/import-products', { overwriteExisting: overwrite })).data,
|
||||||
|
onSuccess: (d) => setProductsJobId(d.jobId),
|
||||||
|
})
|
||||||
|
const startCounterparties = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
(await api.post<{ jobId: string }>('/api/admin/moysklad/import-counterparties', { overwriteExisting: overwrite })).data,
|
||||||
|
onSuccess: (d) => setCounterpartiesJobId(d.jobId),
|
||||||
})
|
})
|
||||||
|
|
||||||
const counterparties = useMutation({
|
const productsJob = useJob(productsJobId)
|
||||||
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-counterparties', { token, overwriteExisting: overwrite })).data,
|
const counterpartiesJob = useJob(counterpartiesJobId)
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/counterparties'] }),
|
|
||||||
})
|
const hasToken = settings.data?.hasToken ?? false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto">
|
<div className="h-full overflow-auto">
|
||||||
<div className="p-6 max-w-3xl">
|
<div className="p-6 max-w-3xl">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Импорт из МойСклад"
|
title="Импорт из МойСклад"
|
||||||
description="Перенос товаров, групп, контрагентов из учётной записи МойСклад в food-market."
|
description="Перенос товаров, групп и контрагентов из учётной записи МойСклад."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="mb-5 rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-900/50 p-3.5 text-sm">
|
|
||||||
<div className="flex gap-2.5 items-start">
|
|
||||||
<AlertCircle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
|
||||||
<div className="text-amber-900 dark:text-amber-200 space-y-1.5">
|
|
||||||
<p><strong>Токен не сохраняется</strong> — передаётся только в текущий запрос и не пишется ни в БД, ни в логи.</p>
|
|
||||||
<p>Получить токен: <a className="underline" href="https://online.moysklad.ru/app/#admin" target="_blank" rel="noreferrer">online.moysklad.ru/app</a> → Настройки аккаунта → Доступ к API → создать токен.</p>
|
|
||||||
<p>Рекомендуется отдельный сервисный аккаунт с правом только на чтение.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
|
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
|
||||||
<Field label="Токен МойСклад (Bearer)">
|
<h2 className="text-sm font-semibold">Токен API</h2>
|
||||||
|
{settings.data && (
|
||||||
|
<div className="text-sm">
|
||||||
|
{hasToken ? (
|
||||||
|
<div className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
|
||||||
|
<CheckCircle className="w-4 h-4" /> сохранён:{' '}
|
||||||
|
<code className="font-mono">{settings.data.masked}</code>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-amber-700 dark:text-amber-400">Ещё не задан — импорт не сработает.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Field label={hasToken ? 'Заменить токен' : 'Bearer-токен MoySklad'}>
|
||||||
<TextInput
|
<TextInput
|
||||||
type="password"
|
type="password"
|
||||||
value={token}
|
value={tokenInput}
|
||||||
onChange={(e) => setToken(e.target.value)}
|
onChange={(e) => setTokenInput(e.target.value)}
|
||||||
placeholder="персональный токен или токен сервисного аккаунта"
|
placeholder="персональный или сервисный токен"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<div className="flex gap-2 items-center flex-wrap">
|
||||||
<div className="flex gap-3 items-center flex-wrap">
|
<Button
|
||||||
|
onClick={() => saveToken.mutate()}
|
||||||
|
disabled={!tokenInput || saveToken.isPending}
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{saveToken.isPending ? 'Сохраняю…' : 'Сохранить токен'}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => test.mutate()}
|
onClick={() => test.mutate()}
|
||||||
disabled={!token || test.isPending}
|
disabled={!hasToken || test.isPending}
|
||||||
>
|
>
|
||||||
<KeyRound className="w-4 h-4" />
|
<KeyRound className="w-4 h-4" />
|
||||||
{test.isPending ? 'Проверяю…' : 'Проверить соединение'}
|
{test.isPending ? 'Проверяю…' : 'Проверить соединение'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{test.data && (
|
{test.data && (
|
||||||
<div className="text-sm text-green-700 dark:text-green-400 flex items-center gap-1.5">
|
<div className="text-sm text-emerald-700 dark:text-emerald-400 flex items-center gap-1.5">
|
||||||
<CheckCircle className="w-4 h-4" />
|
<CheckCircle className="w-4 h-4" /> Подключено: <strong>{test.data.organization}</strong>
|
||||||
Подключено: <strong>{test.data.organization}</strong>
|
|
||||||
{test.data.inn && <span className="text-slate-500">(ИНН {test.data.inn})</span>}
|
{test.data.inn && <span className="text-slate-500">(ИНН {test.data.inn})</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{test.error && <div className="text-sm text-red-600">{formatError(test.error)}</div>}
|
{test.error && <div className="text-sm text-red-600">{formatError(test.error)}</div>}
|
||||||
|
{saveToken.error && <div className="text-sm text-red-600">{formatError(saveToken.error)}</div>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
|
<section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
|
||||||
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Операции импорта</h2>
|
<h2 className="text-sm font-semibold">Операции импорта</h2>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Перезаписать существующие записи (по артикулу/имени)"
|
label="Обновлять уже импортированные записи (если найдены по артикулу/имени)"
|
||||||
checked={overwrite}
|
checked={overwrite}
|
||||||
onChange={setOverwrite}
|
onChange={setOverwrite}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-3 flex-wrap">
|
<div className="flex gap-3 flex-wrap">
|
||||||
<Button onClick={() => products.mutate()} disabled={!token || products.isPending}>
|
<Button onClick={() => startProducts.mutate()} disabled={!hasToken || startProducts.isPending || productsJob.data?.status === 'Running'}>
|
||||||
<Package className="w-4 h-4" />
|
<Package className="w-4 h-4" />
|
||||||
{products.isPending ? 'Импортирую товары…' : 'Товары + группы + цены'}
|
{productsJob.data?.status === 'Running' ? 'Товары импортируются…' : 'Товары + группы + цены'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => counterparties.mutate()} disabled={!token || counterparties.isPending}>
|
<Button onClick={() => startCounterparties.mutate()} disabled={!hasToken || startCounterparties.isPending || counterpartiesJob.data?.status === 'Running'}>
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
<Users className="w-4 h-4" />
|
<Users className="w-4 h-4" />
|
||||||
{counterparties.isPending ? 'Импортирую контрагентов…' : 'Контрагенты'}
|
{counterpartiesJob.data?.status === 'Running' ? 'Контрагенты импортируются…' : 'Контрагенты'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ImportResult title="Товары" result={products} />
|
{productsJob.data && <JobCard title="Импорт товаров" job={productsJob.data} />}
|
||||||
<ImportResult title="Контрагенты" result={counterparties} />
|
{counterpartiesJob.data && <JobCard title="Импорт контрагентов" job={counterpartiesJob.data} />}
|
||||||
|
|
||||||
|
<DangerZone />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImportResult({ title, result }: { title: string; result: UseMutationResult<ImportResponse, Error, void, unknown> }) {
|
function JobCard({ title, job }: { title: string; job: JobView }) {
|
||||||
if (!result.data && !result.error) return null
|
const done = job.status === 'Succeeded' || job.status === 'Failed'
|
||||||
|
const color = job.status === 'Succeeded' ? 'text-emerald-600'
|
||||||
|
: job.status === 'Failed' ? 'text-red-600' : 'text-slate-500'
|
||||||
return (
|
return (
|
||||||
<section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
|
<section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
|
||||||
<h3 className="text-sm font-semibold mb-3 text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
{result.data
|
{job.status === 'Succeeded'
|
||||||
? <><CheckCircle className="w-4 h-4 text-green-600" /> {title} — импорт завершён</>
|
? <CheckCircle className="w-4 h-4 text-emerald-600" />
|
||||||
: <><AlertCircle className="w-4 h-4 text-red-600" /> {title} — ошибка</>}
|
: job.status === 'Failed'
|
||||||
|
? <AlertCircle className="w-4 h-4 text-red-600" />
|
||||||
|
: <span className="inline-block w-3 h-3 rounded-full bg-emerald-500 animate-pulse" />}
|
||||||
|
{title}
|
||||||
|
<span className={`text-xs font-normal ${color}`}>
|
||||||
|
{job.status === 'Running' ? (job.stage ?? 'идёт…') : job.status}
|
||||||
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
{result.data && (
|
<dl className="grid grid-cols-5 gap-3 text-sm">
|
||||||
<>
|
<Stat label="Всего" value={job.total} />
|
||||||
<dl className="grid grid-cols-4 gap-3 text-sm">
|
<Stat label="Создано" value={job.created} accent="green" />
|
||||||
<StatBox label="Всего получено" value={result.data.total} />
|
<Stat label="Обновлено" value={job.updated} />
|
||||||
<StatBox label="Создано" value={result.data.created} accent="green" />
|
<Stat label="Пропущено" value={job.skipped} />
|
||||||
<StatBox label="Пропущено" value={result.data.skipped} />
|
<Stat label="Групп" value={job.groupsCreated} />
|
||||||
<StatBox label="Групп создано" value={result.data.groupsCreated} />
|
</dl>
|
||||||
</dl>
|
{done && job.message && (
|
||||||
{result.data.errors.length > 0 && (
|
<div className={`mt-3 text-sm ${color}`}>{job.message}</div>
|
||||||
<details className="mt-4">
|
|
||||||
<summary className="text-sm text-red-600 cursor-pointer">
|
|
||||||
Ошибок: {result.data.errors.length} (развернуть)
|
|
||||||
</summary>
|
|
||||||
<ul className="mt-2 text-xs font-mono bg-red-50 dark:bg-red-950/30 p-3 rounded space-y-0.5 max-h-80 overflow-auto">
|
|
||||||
{result.data.errors.map((e, i) => <li key={i}>{e}</li>)}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{result.error && (
|
{job.errors.length > 0 && (
|
||||||
<div className="text-sm text-red-700 dark:text-red-300">{formatError(result.error)}</div>
|
<details className="mt-3">
|
||||||
|
<summary className="text-xs text-red-600 cursor-pointer">Ошибок: {job.errors.length}</summary>
|
||||||
|
<ul className="mt-2 text-[11px] font-mono bg-red-50 dark:bg-red-950/30 p-3 rounded space-y-0.5 max-h-60 overflow-auto">
|
||||||
|
{job.errors.map((e, i) => <li key={i}>{e}</li>)}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatBox({ label, value, accent }: { label: string; value: number; accent?: 'green' }) {
|
function Stat({ label, value, accent }: { label: string; value: number; accent?: 'green' }) {
|
||||||
const bg = accent === 'green' ? 'bg-green-50 dark:bg-green-950/30' : 'bg-slate-50 dark:bg-slate-800/50'
|
const bg = accent === 'green' ? 'bg-emerald-50 dark:bg-emerald-950/30' : 'bg-slate-50 dark:bg-slate-800/50'
|
||||||
const fg = accent === 'green' ? 'text-green-700 dark:text-green-400' : ''
|
const fg = accent === 'green' ? 'text-emerald-700 dark:text-emerald-400' : ''
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg ${bg} p-3`}>
|
<div className={`rounded-lg ${bg} p-3`}>
|
||||||
<dt className={`text-xs uppercase ${accent === 'green' ? fg : 'text-slate-500'}`}>{label}</dt>
|
<dt className={`text-xs uppercase ${accent === 'green' ? fg : 'text-slate-500'}`}>{label}</dt>
|
||||||
<dd className={`text-xl font-semibold mt-1 ${fg}`}>{value.toLocaleString('ru')}</dd>
|
<dd className={`text-lg font-semibold mt-1 ${fg}`}>{value.toLocaleString('ru')}</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CleanupStats {
|
||||||
|
counterparties: number; products: number; productGroups: number
|
||||||
|
productBarcodes: number; productPrices: number; supplies: number
|
||||||
|
retailSales: number; stocks: number; stockMovements: number
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [wipeJobId, setWipeJobId] = useState<string | null>(null)
|
||||||
|
const wipeJob = useJob(wipeJobId)
|
||||||
|
|
||||||
|
const startWipe = useMutation({
|
||||||
|
mutationFn: async () => (await api.post<{ jobId: string }>('/api/admin/cleanup/all/async')).data,
|
||||||
|
onSuccess: (d) => {
|
||||||
|
setWipeJobId(d.jobId)
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/admin/cleanup/stats'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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">
|
||||||
|
<Tile label="Контрагенты" value={s.counterparties} />
|
||||||
|
<Tile label="Товары" value={s.products} />
|
||||||
|
<Tile label="Группы" value={s.productGroups} />
|
||||||
|
<Tile label="Штрихкоды" value={s.productBarcodes} />
|
||||||
|
<Tile label="Цены" value={s.productPrices} />
|
||||||
|
<Tile label="Поставки" value={s.supplies} />
|
||||||
|
<Tile label="Чеки" value={s.retailSales} />
|
||||||
|
<Tile label="Остатки" value={s.stocks} />
|
||||||
|
<Tile label="Движения" value={s.stockMovements} />
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => confirmAndRun(
|
||||||
|
'ВСЕ данные организации',
|
||||||
|
() => startWipe.mutate(),
|
||||||
|
)}
|
||||||
|
disabled={startWipe.isPending || wipeJob.data?.status === 'Running'}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
{wipeJob.data?.status === 'Running' ? 'Очищаю…' : 'Очистить все данные'}
|
||||||
|
</Button>
|
||||||
|
{wipeJob.data && (
|
||||||
|
<div className="mt-4 rounded-lg bg-white dark:bg-slate-900 border border-red-200 dark:border-red-900/50 p-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{wipeJob.data.status === 'Succeeded' && <CheckCircle className="w-4 h-4 text-emerald-600" />}
|
||||||
|
{wipeJob.data.status === 'Failed' && <AlertCircle className="w-4 h-4 text-red-600" />}
|
||||||
|
{wipeJob.data.status === 'Running' && <span className="inline-block w-3 h-3 rounded-full bg-red-500 animate-pulse" />}
|
||||||
|
<strong>{wipeJob.data.stage ?? wipeJob.data.status}</strong>
|
||||||
|
<span className="text-xs text-slate-500">удалено записей: {wipeJob.data.deleted.toLocaleString('ru')}</span>
|
||||||
|
</div>
|
||||||
|
{wipeJob.data.message && <div className="text-xs text-slate-600">{wipeJob.data.message}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tile({ 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { api } from '@/lib/api'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
|
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
|
||||||
import {
|
import {
|
||||||
useUnits, useVatRates, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
|
useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
|
||||||
} from '@/lib/useLookups'
|
} from '@/lib/useLookups'
|
||||||
import { BarcodeType, type Product } from '@/lib/types'
|
import { BarcodeType, type Product } from '@/lib/types'
|
||||||
|
|
||||||
|
|
@ -18,13 +18,13 @@ interface Form {
|
||||||
article: string
|
article: string
|
||||||
description: string
|
description: string
|
||||||
unitOfMeasureId: string
|
unitOfMeasureId: string
|
||||||
vatRateId: string
|
vat: number
|
||||||
|
vatEnabled: boolean
|
||||||
productGroupId: string
|
productGroupId: string
|
||||||
defaultSupplierId: string
|
defaultSupplierId: string
|
||||||
countryOfOriginId: string
|
countryOfOriginId: string
|
||||||
isService: boolean
|
isService: boolean
|
||||||
isWeighed: boolean
|
isWeighed: boolean
|
||||||
isAlcohol: boolean
|
|
||||||
isMarked: boolean
|
isMarked: boolean
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
minStock: string
|
minStock: string
|
||||||
|
|
@ -36,11 +36,15 @@ interface Form {
|
||||||
barcodes: BarcodeRow[]
|
barcodes: BarcodeRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KZ default VAT rate.
|
||||||
|
const defaultVat = 16
|
||||||
|
const vatChoices = [0, 10, 12, 16, 20]
|
||||||
|
|
||||||
const emptyForm: Form = {
|
const emptyForm: Form = {
|
||||||
name: '', article: '', description: '',
|
name: '', article: '', description: '',
|
||||||
unitOfMeasureId: '', vatRateId: '',
|
unitOfMeasureId: '', vat: defaultVat, vatEnabled: true,
|
||||||
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
|
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
|
||||||
isService: false, isWeighed: false, isAlcohol: false, isMarked: false, isActive: true,
|
isService: false, isWeighed: false, isMarked: false, isActive: true,
|
||||||
minStock: '', maxStock: '',
|
minStock: '', maxStock: '',
|
||||||
purchasePrice: '', purchaseCurrencyId: '',
|
purchasePrice: '', purchaseCurrencyId: '',
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
|
|
@ -55,7 +59,6 @@ export function ProductEditPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const units = useUnits()
|
const units = useUnits()
|
||||||
const vats = useVatRates()
|
|
||||||
const groups = useProductGroups()
|
const groups = useProductGroups()
|
||||||
const countries = useCountries()
|
const countries = useCountries()
|
||||||
const currencies = useCurrencies()
|
const currencies = useCurrencies()
|
||||||
|
|
@ -76,10 +79,10 @@ export function ProductEditPage() {
|
||||||
const p = existing.data
|
const p = existing.data
|
||||||
setForm({
|
setForm({
|
||||||
name: p.name, article: p.article ?? '', description: p.description ?? '',
|
name: p.name, article: p.article ?? '', description: p.description ?? '',
|
||||||
unitOfMeasureId: p.unitOfMeasureId, vatRateId: p.vatRateId,
|
unitOfMeasureId: p.unitOfMeasureId, vat: p.vat, vatEnabled: p.vatEnabled,
|
||||||
productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '',
|
productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '',
|
||||||
countryOfOriginId: p.countryOfOriginId ?? '',
|
countryOfOriginId: p.countryOfOriginId ?? '',
|
||||||
isService: p.isService, isWeighed: p.isWeighed, isAlcohol: p.isAlcohol, isMarked: p.isMarked,
|
isService: p.isService, isWeighed: p.isWeighed, isMarked: p.isMarked,
|
||||||
isActive: p.isActive,
|
isActive: p.isActive,
|
||||||
minStock: p.minStock?.toString() ?? '',
|
minStock: p.minStock?.toString() ?? '',
|
||||||
maxStock: p.maxStock?.toString() ?? '',
|
maxStock: p.maxStock?.toString() ?? '',
|
||||||
|
|
@ -93,16 +96,13 @@ export function ProductEditPage() {
|
||||||
}, [isNew, existing.data])
|
}, [isNew, existing.data])
|
||||||
|
|
||||||
useEffect(() => {
|
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) {
|
if (isNew && form.unitOfMeasureId === '' && units.data?.length) {
|
||||||
setForm((f) => ({ ...f, unitOfMeasureId: units.data?.find(u => u.isBase)?.id ?? units.data?.[0]?.id ?? '' }))
|
setForm((f) => ({ ...f, unitOfMeasureId: units.data?.[0]?.id ?? '' }))
|
||||||
}
|
}
|
||||||
if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) {
|
if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) {
|
||||||
setForm((f) => ({ ...f, purchaseCurrencyId: currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id ?? '' }))
|
setForm((f) => ({ ...f, purchaseCurrencyId: currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id ?? '' }))
|
||||||
}
|
}
|
||||||
}, [isNew, vats.data, units.data, currencies.data, form.vatRateId, form.unitOfMeasureId, form.purchaseCurrencyId])
|
}, [isNew, units.data, currencies.data, form.unitOfMeasureId, form.purchaseCurrencyId])
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
|
@ -111,13 +111,13 @@ export function ProductEditPage() {
|
||||||
article: form.article || null,
|
article: form.article || null,
|
||||||
description: form.description || null,
|
description: form.description || null,
|
||||||
unitOfMeasureId: form.unitOfMeasureId,
|
unitOfMeasureId: form.unitOfMeasureId,
|
||||||
vatRateId: form.vatRateId,
|
vat: form.vat,
|
||||||
|
vatEnabled: form.vatEnabled,
|
||||||
productGroupId: form.productGroupId || null,
|
productGroupId: form.productGroupId || null,
|
||||||
defaultSupplierId: form.defaultSupplierId || null,
|
defaultSupplierId: form.defaultSupplierId || null,
|
||||||
countryOfOriginId: form.countryOfOriginId || null,
|
countryOfOriginId: form.countryOfOriginId || null,
|
||||||
isService: form.isService,
|
isService: form.isService,
|
||||||
isWeighed: form.isWeighed,
|
isWeighed: form.isWeighed,
|
||||||
isAlcohol: form.isAlcohol,
|
|
||||||
isMarked: form.isMarked,
|
isMarked: form.isMarked,
|
||||||
isActive: form.isActive,
|
isActive: form.isActive,
|
||||||
minStock: form.minStock === '' ? null : Number(form.minStock),
|
minStock: form.minStock === '' ? null : Number(form.minStock),
|
||||||
|
|
@ -168,7 +168,7 @@ export function ProductEditPage() {
|
||||||
const updateBarcode = (i: number, patch: Partial<BarcodeRow>) =>
|
const updateBarcode = (i: number, patch: Partial<BarcodeRow>) =>
|
||||||
setForm({ ...form, barcodes: form.barcodes.map((b, ix) => ix === i ? { ...b, ...patch } : b) })
|
setForm({ ...form, barcodes: form.barcodes.map((b, ix) => ix === i ? { ...b, ...patch } : b) })
|
||||||
|
|
||||||
const canSave = form.name.trim().length > 0 && !!form.unitOfMeasureId && !!form.vatRateId
|
const canSave = form.name.trim().length > 0 && !!form.unitOfMeasureId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit} className="flex flex-col h-full">
|
<form onSubmit={onSubmit} className="flex flex-col h-full">
|
||||||
|
|
@ -234,13 +234,12 @@ export function ProductEditPage() {
|
||||||
<Field label="Единица измерения *">
|
<Field label="Единица измерения *">
|
||||||
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
|
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.symbol} — {u.name}</option>)}
|
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Ставка НДС *">
|
<Field label="Ставка НДС, %">
|
||||||
<Select required value={form.vatRateId} onChange={(e) => setForm({ ...form, vatRateId: e.target.value })}>
|
<Select value={form.vat} onChange={(e) => setForm({ ...form, vat: Number(e.target.value) })}>
|
||||||
<option value="">—</option>
|
{vatChoices.map((v) => <option key={v} value={v}>{v}%</option>)}
|
||||||
{vats.data?.map((v) => <option key={v.id} value={v.id}>{v.name} ({v.percent}%)</option>)}
|
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Группа">
|
<Field label="Группа">
|
||||||
|
|
@ -266,9 +265,9 @@ export function ProductEditPage() {
|
||||||
</Field>
|
</Field>
|
||||||
</Grid>
|
</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">
|
<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.isService} onChange={(v) => setForm({ ...form, isService: v })} />
|
||||||
<Checkbox label="Весовой" checked={form.isWeighed} onChange={(v) => setForm({ ...form, isWeighed: 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.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
|
||||||
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
|
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,194 @@
|
||||||
|
import { useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { ListPageShell } from '@/components/ListPageShell'
|
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus, Filter, X } from 'lucide-react'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
|
import { ProductGroupTree } from '@/components/ProductGroupTree'
|
||||||
import type { Product } from '@/lib/types'
|
import type { Product } from '@/lib/types'
|
||||||
|
|
||||||
const URL = '/api/catalog/products'
|
const URL = '/api/catalog/products'
|
||||||
|
|
||||||
export function ProductsPage() {
|
type TriFilter = 'all' | 'yes' | 'no'
|
||||||
const navigate = useNavigate()
|
|
||||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL)
|
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<ListPageShell
|
<div className="flex items-center gap-2 text-xs">
|
||||||
title="Товары"
|
<span className="text-slate-500">{label}</span>
|
||||||
description={data ? `${data.total.toLocaleString('ru')} записей в каталоге` : 'Каталог товаров и услуг'}
|
<div className="inline-flex rounded border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||||
actions={
|
{opts.map((o) => (
|
||||||
<>
|
<button
|
||||||
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" />
|
key={o.v}
|
||||||
<Link to="/catalog/products/new">
|
type="button"
|
||||||
<Button>
|
onClick={() => onChange(o.v)}
|
||||||
<Plus className="w-4 h-4" /> Добавить
|
className={
|
||||||
</Button>
|
'px-2 py-0.5 ' +
|
||||||
</Link>
|
(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')
|
||||||
footer={data && data.total > 0 && (
|
}
|
||||||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
>
|
||||||
)}
|
{o.t}
|
||||||
>
|
</button>
|
||||||
<DataTable
|
))}
|
||||||
rows={data?.items ?? []}
|
</div>
|
||||||
isLoading={isLoading}
|
</div>
|
||||||
rowKey={(r) => r.id}
|
)
|
||||||
onRowClick={(r) => navigate(`/catalog/products/${r.id}`)}
|
}
|
||||||
columns={[
|
|
||||||
{ header: 'Название', cell: (r) => (
|
export function ProductsPage() {
|
||||||
<div>
|
const navigate = useNavigate()
|
||||||
<div className="font-medium">{r.name}</div>
|
const [filters, setFilters] = useState<Filters>(defaultFilters)
|
||||||
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
|
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||||
</div>
|
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL, toExtra(filters))
|
||||||
)},
|
const activeCount = activeFilterCount(filters)
|
||||||
{ header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' },
|
|
||||||
{ header: 'Ед.', width: '70px', cell: (r) => r.unitSymbol },
|
return (
|
||||||
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vatPercent}%` },
|
<div className="flex h-full min-h-0">
|
||||||
{ header: 'Тип', width: '140px', cell: (r) => (
|
{/* Left: groups tree */}
|
||||||
<div className="flex gap-1 flex-wrap">
|
<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">
|
||||||
{r.isService && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">Услуга</span>}
|
<ProductGroupTree
|
||||||
{r.isWeighed && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Весовой</span>}
|
selectedId={filters.groupId}
|
||||||
{r.isAlcohol && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-red-700">Алкоголь</span>}
|
onSelect={(id) => { setFilters({ ...filters, groupId: id }); setPage(1) }}
|
||||||
{r.isMarked && <span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">Маркир.</span>}
|
/>
|
||||||
</div>
|
</aside>
|
||||||
)},
|
|
||||||
{ header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length },
|
{/* Right: products */}
|
||||||
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
]}
|
{/* Top bar */}
|
||||||
empty="Товаров ещё нет. Они появятся после приёмки или через API."
|
<div className="px-6 py-3 border-b border-slate-200 dark:border-slate-800 flex items-center gap-3 flex-wrap">
|
||||||
/>
|
<div>
|
||||||
</ListPageShell>
|
<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})` : ''}
|
||||||
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ interface LineRow {
|
||||||
productId: string
|
productId: string
|
||||||
productName: string
|
productName: string
|
||||||
productArticle: string | null
|
productArticle: string | null
|
||||||
unitSymbol: string | null
|
unitName: string | null
|
||||||
quantity: number
|
quantity: number
|
||||||
unitPrice: number
|
unitPrice: number
|
||||||
discount: number
|
discount: number
|
||||||
|
|
@ -81,7 +81,7 @@ export function RetailSaleEditPage() {
|
||||||
productId: l.productId,
|
productId: l.productId,
|
||||||
productName: l.productName ?? '',
|
productName: l.productName ?? '',
|
||||||
productArticle: l.productArticle,
|
productArticle: l.productArticle,
|
||||||
unitSymbol: l.unitSymbol,
|
unitName: l.unitName,
|
||||||
quantity: l.quantity, unitPrice: l.unitPrice, discount: l.discount,
|
quantity: l.quantity, unitPrice: l.unitPrice, discount: l.discount,
|
||||||
vatPercent: l.vatPercent,
|
vatPercent: l.vatPercent,
|
||||||
})),
|
})),
|
||||||
|
|
@ -179,11 +179,11 @@ export function RetailSaleEditPage() {
|
||||||
productId: p.id,
|
productId: p.id,
|
||||||
productName: p.name,
|
productName: p.name,
|
||||||
productArticle: p.article,
|
productArticle: p.article,
|
||||||
unitSymbol: p.unitSymbol,
|
unitName: p.unitName,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unitPrice: retail?.amount ?? 0,
|
unitPrice: retail?.amount ?? 0,
|
||||||
discount: 0,
|
discount: 0,
|
||||||
vatPercent: p.vatPercent,
|
vatPercent: p.vat * 1,
|
||||||
}],
|
}],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -323,7 +323,7 @@ export function RetailSaleEditPage() {
|
||||||
<div className="font-medium">{l.productName}</div>
|
<div className="font-medium">{l.productName}</div>
|
||||||
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
|
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 text-slate-500">{l.unitSymbol}</td>
|
<td className="py-2 px-3 text-slate-500">{l.unitName}</td>
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<TextInput type="number" step="0.001" disabled={isPosted}
|
<TextInput type="number" step="0.001" disabled={isPosted}
|
||||||
className="text-right font-mono" value={l.quantity}
|
className="text-right font-mono" value={l.quantity}
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ export function StockPage() {
|
||||||
</div>
|
</div>
|
||||||
)},
|
)},
|
||||||
{ header: 'Склад', width: '220px', cell: (r) => r.storeName },
|
{ header: 'Склад', width: '220px', cell: (r) => r.storeName },
|
||||||
{ header: 'Ед.', width: '80px', cell: (r) => r.unitSymbol },
|
{ header: 'Ед.', width: '80px', cell: (r) => r.unitName },
|
||||||
{ header: 'Остаток', width: '130px', className: 'text-right font-mono', cell: (r) => r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 }) },
|
{ 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: '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) => (
|
{ header: 'Доступно', width: '130px', className: 'text-right font-mono font-semibold', cell: (r) => (
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { Modal } from '@/components/Modal'
|
import { Modal } from '@/components/Modal'
|
||||||
import { Field, TextInput, Select, Checkbox } from '@/components/Field'
|
import { Field, TextInput, Checkbox } from '@/components/Field'
|
||||||
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
||||||
import { type Store, StoreKind } from '@/lib/types'
|
import { type Store } from '@/lib/types'
|
||||||
|
|
||||||
const URL = '/api/catalog/stores'
|
const URL = '/api/catalog/stores'
|
||||||
|
|
||||||
|
|
@ -16,7 +16,6 @@ interface Form {
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
code: string
|
code: string
|
||||||
kind: StoreKind
|
|
||||||
address: string
|
address: string
|
||||||
phone: string
|
phone: string
|
||||||
managerName: string
|
managerName: string
|
||||||
|
|
@ -25,7 +24,7 @@ interface Form {
|
||||||
}
|
}
|
||||||
|
|
||||||
const blankForm: Form = {
|
const blankForm: Form = {
|
||||||
name: '', code: '', kind: StoreKind.Warehouse, address: '', phone: '',
|
name: '', code: '', address: '', phone: '',
|
||||||
managerName: '', isMain: false, isActive: true,
|
managerName: '', isMain: false, isActive: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,14 +61,13 @@ export function StoresPage() {
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
rowKey={(r) => r.id}
|
rowKey={(r) => r.id}
|
||||||
onRowClick={(r) => setForm({
|
onRowClick={(r) => setForm({
|
||||||
id: r.id, name: r.name, code: r.code ?? '', kind: r.kind,
|
id: r.id, name: r.name, code: r.code ?? '',
|
||||||
address: r.address ?? '', phone: r.phone ?? '', managerName: r.managerName ?? '',
|
address: r.address ?? '', phone: r.phone ?? '', managerName: r.managerName ?? '',
|
||||||
isMain: r.isMain, isActive: r.isActive,
|
isMain: r.isMain, isActive: r.isActive,
|
||||||
})}
|
})}
|
||||||
columns={[
|
columns={[
|
||||||
{ header: 'Название', cell: (r) => r.name },
|
{ header: 'Название', cell: (r) => r.name },
|
||||||
{ header: 'Код', width: '120px', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> },
|
{ 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: 'Адрес', cell: (r) => r.address ?? '—' },
|
||||||
{ header: 'Основной', width: '110px', cell: (r) => r.isMain ? '✓' : '—' },
|
{ header: 'Основной', width: '110px', cell: (r) => r.isMain ? '✓' : '—' },
|
||||||
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
||||||
|
|
@ -108,12 +106,6 @@ export function StoresPage() {
|
||||||
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
|
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</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="Адрес">
|
<Field label="Адрес">
|
||||||
<TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
<TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ interface LineRow {
|
||||||
productId: string
|
productId: string
|
||||||
productName: string
|
productName: string
|
||||||
productArticle: string | null
|
productArticle: string | null
|
||||||
unitSymbol: string | null
|
unitName: string | null
|
||||||
quantity: number
|
quantity: number
|
||||||
unitPrice: number
|
unitPrice: number
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +76,7 @@ export function SupplyEditPage() {
|
||||||
productId: l.productId,
|
productId: l.productId,
|
||||||
productName: l.productName ?? '',
|
productName: l.productName ?? '',
|
||||||
productArticle: l.productArticle,
|
productArticle: l.productArticle,
|
||||||
unitSymbol: l.unitSymbol,
|
unitName: l.unitName,
|
||||||
quantity: l.quantity,
|
quantity: l.quantity,
|
||||||
unitPrice: l.unitPrice,
|
unitPrice: l.unitPrice,
|
||||||
})),
|
})),
|
||||||
|
|
@ -169,7 +169,7 @@ export function SupplyEditPage() {
|
||||||
productId: p.id,
|
productId: p.id,
|
||||||
productName: p.name,
|
productName: p.name,
|
||||||
productArticle: p.article,
|
productArticle: p.article,
|
||||||
unitSymbol: p.unitSymbol,
|
unitName: p.unitName,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unitPrice: p.purchasePrice ?? 0,
|
unitPrice: p.purchasePrice ?? 0,
|
||||||
}],
|
}],
|
||||||
|
|
@ -304,7 +304,7 @@ export function SupplyEditPage() {
|
||||||
<div className="font-medium">{l.productName}</div>
|
<div className="font-medium">{l.productName}</div>
|
||||||
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
|
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 text-slate-500">{l.unitSymbol}</td>
|
<td className="py-2 px-3 text-slate-500">{l.unitName}</td>
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<TextInput type="number" step="0.001" disabled={isPosted}
|
<TextInput type="number" step="0.001" disabled={isPosted}
|
||||||
className="text-right font-mono"
|
className="text-right font-mono"
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,12 @@ const URL = '/api/catalog/units-of-measure'
|
||||||
interface Form {
|
interface Form {
|
||||||
id?: string
|
id?: string
|
||||||
code: string
|
code: string
|
||||||
symbol: string
|
|
||||||
name: string
|
name: string
|
||||||
decimalPlaces: number
|
description: string
|
||||||
isBase: boolean
|
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const blankForm: Form = { code: '', symbol: '', name: '', decimalPlaces: 0, isBase: false, isActive: true }
|
const blankForm: Form = { code: '', name: '', description: '', isActive: true }
|
||||||
|
|
||||||
export function UnitsOfMeasurePage() {
|
export function UnitsOfMeasurePage() {
|
||||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<UnitOfMeasure>(URL)
|
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<UnitOfMeasure>(URL)
|
||||||
|
|
@ -41,7 +39,7 @@ export function UnitsOfMeasurePage() {
|
||||||
<>
|
<>
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
title="Единицы измерения"
|
title="Единицы измерения"
|
||||||
description="Код по ОКЕИ (шт=796, кг=166, л=112, м=006)."
|
description="Код по ОКЕИ (штука=796, килограмм=166, литр=112, метр=006)."
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<SearchBar value={search} onChange={setSearch} />
|
<SearchBar value={search} onChange={setSearch} />
|
||||||
|
|
@ -56,13 +54,14 @@ export function UnitsOfMeasurePage() {
|
||||||
rows={data?.items ?? []}
|
rows={data?.items ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
rowKey={(r) => r.id}
|
rowKey={(r) => r.id}
|
||||||
onRowClick={(r) => setForm({ ...r })}
|
onRowClick={(r) => setForm({
|
||||||
|
id: r.id, code: r.code, name: r.name,
|
||||||
|
description: r.description ?? '', isActive: r.isActive,
|
||||||
|
})}
|
||||||
columns={[
|
columns={[
|
||||||
{ header: 'Код', width: '90px', cell: (r) => <span className="font-mono">{r.code}</span> },
|
{ 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.name },
|
||||||
{ header: 'Дробность', width: '120px', className: 'text-right', cell: (r) => r.decimalPlaces },
|
{ header: 'Описание', cell: (r) => r.description ?? '—' },
|
||||||
{ header: 'Базовая', width: '100px', cell: (r) => r.isBase ? '✓' : '—' },
|
|
||||||
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
@ -91,25 +90,15 @@ export function UnitsOfMeasurePage() {
|
||||||
>
|
>
|
||||||
{form && (
|
{form && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<Field label="Код ОКЕИ">
|
||||||
<Field label="Код ОКЕИ">
|
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
|
||||||
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
|
</Field>
|
||||||
</Field>
|
|
||||||
<Field label="Символ">
|
|
||||||
<TextInput value={form.symbol} onChange={(e) => setForm({ ...form, symbol: e.target.value })} />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<Field label="Название">
|
<Field label="Название">
|
||||||
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Количество знаков после запятой">
|
<Field label="Описание">
|
||||||
<TextInput
|
<TextInput value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||||
type="number" min="0" max="6"
|
|
||||||
value={form.decimalPlaces}
|
|
||||||
onChange={(e) => setForm({ ...form, decimalPlaces: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
<Checkbox label="Базовая единица организации" checked={form.isBase} onChange={(v) => setForm({ ...form, isBase: v })} />
|
|
||||||
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
|
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue