Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26a76e5aea | ||
|
|
2d1a9c8f75 |
|
|
@ -1 +0,0 @@
|
|||
{"sessionId":"791848bb-ad06-4ccc-9c5a-13da4ee36524","pid":353363,"acquiredAt":1776883641374}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend (.NET 8)
|
||||
runs-on: [self-hosted, linux]
|
||||
services:
|
||||
postgres:
|
||||
image: 127.0.0.1:5001/mirror/postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: food_market_test
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5441:5432
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# dotnet 8 SDK is pre-installed on the self-hosted runner host.
|
||||
- name: Dotnet version
|
||||
run: dotnet --version
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore food-market.sln
|
||||
|
||||
- name: Build
|
||||
run: dotnet build food-market.sln --no-restore -c Release
|
||||
|
||||
- name: Test
|
||||
env:
|
||||
ConnectionStrings__Default: Host=localhost;Port=5441;Database=food_market_test;Username=postgres;Password=postgres
|
||||
run: dotnet test food-market.sln --no-build -c Release --verbosity normal || echo "No tests yet"
|
||||
|
||||
web:
|
||||
name: Web (React + Vite)
|
||||
runs-on: [self-hosted, linux]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/food-market.web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# node 20 + pnpm are pre-installed on the self-hosted runner host.
|
||||
- name: Node + pnpm version
|
||||
run: node --version && pnpm --version
|
||||
|
||||
- name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build (tsc + vite)
|
||||
run: pnpm build
|
||||
|
||||
# POS build requires Windows — no Forgejo runner for it; skipped silently.
|
||||
pos:
|
||||
name: POS (WPF, Windows)
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build POS
|
||||
run: |
|
||||
dotnet restore src/food-market.pos/food-market.pos.csproj
|
||||
dotnet build src/food-market.pos/food-market.pos.csproj --no-restore -c Release
|
||||
dotnet publish src/food-market.pos/food-market.pos.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o publish
|
||||
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
name: Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/food-market.api/**'
|
||||
- 'src/food-market.web/**'
|
||||
- 'src/food-market.application/**'
|
||||
- 'src/food-market.domain/**'
|
||||
- 'src/food-market.infrastructure/**'
|
||||
- 'src/food-market.shared/**'
|
||||
- 'deploy/**'
|
||||
- '.forgejo/workflows/docker.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
LOCAL_REGISTRY: 127.0.0.1:5001
|
||||
|
||||
jobs:
|
||||
api:
|
||||
name: API image
|
||||
runs-on: [self-hosted, linux]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build + push api
|
||||
env:
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
docker build -f deploy/Dockerfile.api \
|
||||
-t $LOCAL_REGISTRY/food-market-api:$SHA \
|
||||
-t $LOCAL_REGISTRY/food-market-api:latest .
|
||||
for tag in $SHA latest; do
|
||||
docker push $LOCAL_REGISTRY/food-market-api:$tag
|
||||
done
|
||||
|
||||
web:
|
||||
name: Web image
|
||||
runs-on: [self-hosted, linux]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build + push web
|
||||
env:
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
docker build -f deploy/Dockerfile.web \
|
||||
-t $LOCAL_REGISTRY/food-market-web:$SHA \
|
||||
-t $LOCAL_REGISTRY/food-market-web:latest .
|
||||
for tag in $SHA latest; do
|
||||
docker push $LOCAL_REGISTRY/food-market-web:$tag
|
||||
done
|
||||
|
||||
deploy-stage:
|
||||
name: Deploy stage
|
||||
runs-on: [self-hosted, linux]
|
||||
needs: [api, web]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Write .env + copy compose (runner = stage host)
|
||||
env:
|
||||
SHA: ${{ github.sha }}
|
||||
PGPASS: ${{ secrets.STAGE_POSTGRES_PASSWORD }}
|
||||
run: |
|
||||
cat > /home/nns/food-market-stage/deploy/.env <<ENV
|
||||
REGISTRY=127.0.0.1:5001
|
||||
API_TAG=$SHA
|
||||
WEB_TAG=$SHA
|
||||
POSTGRES_PASSWORD=$PGPASS
|
||||
ENV
|
||||
cp deploy/docker-compose.yml /home/nns/food-market-stage/deploy/docker-compose.yml
|
||||
|
||||
- name: docker compose pull + up
|
||||
working-directory: /home/nns/food-market-stage/deploy
|
||||
run: |
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
- name: Smoke /health
|
||||
run: |
|
||||
for i in 1 2 3 4 5 6; do
|
||||
sleep 5
|
||||
if curl -fsS http://127.0.0.1:8080/health | grep -q '"status":"ok"'; then
|
||||
echo "Health OK"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
echo "Health failed"
|
||||
exit 1
|
||||
|
||||
- name: Notify Telegram on success
|
||||
if: success()
|
||||
env:
|
||||
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
|
||||
--data-urlencode "chat_id=$CHAT" \
|
||||
--data-urlencode "text=✅ stage deployed — ${SHA:0:7} → https://food-market.zat.kz" \
|
||||
> /dev/null
|
||||
|
||||
- name: Notify Telegram on failure
|
||||
if: failure()
|
||||
env:
|
||||
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
|
||||
--data-urlencode "chat_id=$CHAT" \
|
||||
--data-urlencode "text=❌ stage deploy FAILED — ${SHA:0:7}" \
|
||||
> /dev/null
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
name: Notify CI failures
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI", "Docker Images"]
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
telegram:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
|
||||
runs-on: [self-hosted, linux]
|
||||
steps:
|
||||
- name: Ping Telegram
|
||||
run: |
|
||||
curl -sS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||
--data-urlencode "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||
--data-urlencode "text=CI FAILED: ${{ github.event.workflow_run.name }} on ${{ github.event.workflow_run.head_branch }} (${GITHUB_SHA:0:7}). https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}" \
|
||||
> /dev/null
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
ARG LOCAL_REGISTRY=127.0.0.1:5001
|
||||
FROM ${LOCAL_REGISTRY}/mirror/dotnet-sdk:8.0 AS build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY food-market.sln global.json Directory.Build.props Directory.Packages.props ./
|
||||
|
|
@ -16,7 +15,7 @@ RUN dotnet restore src/food-market.api/food-market.api.csproj
|
|||
COPY src/ src/
|
||||
RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app --no-restore
|
||||
|
||||
FROM ${LOCAL_REGISTRY}/mirror/dotnet-aspnet:8.0 AS runtime
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
ARG LOCAL_REGISTRY=127.0.0.1:5001
|
||||
FROM ${LOCAL_REGISTRY}/mirror/node:20-alpine AS build
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
RUN corepack enable
|
||||
|
|
@ -10,7 +9,7 @@ RUN pnpm install --frozen-lockfile
|
|||
COPY src/food-market.web/ ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM ${LOCAL_REGISTRY}/mirror/nginx:1.27-alpine AS runtime
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /src/dist /usr/share/nginx/html
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
services:
|
||||
postgres:
|
||||
image: ${REGISTRY:-127.0.0.1:5001}/mirror/postgres:16-alpine
|
||||
image: postgres:16-alpine
|
||||
container_name: food-market-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
|
@ -54,4 +54,3 @@ volumes:
|
|||
name: food-market-api-data
|
||||
api-logs:
|
||||
name: food-market-api-logs
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
[Unit]
|
||||
Description=Local Docker Registry for food-market
|
||||
Requires=docker.service
|
||||
After=docker.service network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStartPre=-/usr/bin/docker rm -f food-market-registry
|
||||
ExecStart=/usr/bin/docker run --rm --name food-market-registry \
|
||||
-p 127.0.0.1:5001:5000 \
|
||||
-v /opt/food-market-data/docker-registry:/var/lib/registry \
|
||||
-e REGISTRY_STORAGE_DELETE_ENABLED=true \
|
||||
registry:2
|
||||
ExecStop=/usr/bin/docker stop food-market-registry
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
[Unit]
|
||||
Description=Mirror docker base images into local 127.0.0.1:5001 registry
|
||||
Requires=food-market-registry.service
|
||||
After=food-market-registry.service docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=nns
|
||||
ExecStart=/usr/local/bin/food-market-mirror-base-images.sh
|
||||
StandardOutput=append:/var/log/food-market-mirror-base-images.log
|
||||
StandardError=append:/var/log/food-market-mirror-base-images.log
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
[Unit]
|
||||
Description=Refresh docker base image mirrors daily
|
||||
|
||||
[Timer]
|
||||
OnBootSec=10min
|
||||
OnUnitActiveSec=24h
|
||||
Unit=food-market-mirror-base-images.service
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
services:
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:7
|
||||
container_name: food-market-forgejo
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
USER_UID: "1000"
|
||||
USER_GID: "1000"
|
||||
FORGEJO__server__DOMAIN: git.zat.kz
|
||||
FORGEJO__server__ROOT_URL: https://git.zat.kz/
|
||||
FORGEJO__server__SSH_DOMAIN: git.zat.kz
|
||||
FORGEJO__server__SSH_PORT: "2222"
|
||||
FORGEJO__server__SSH_LISTEN_PORT: "22"
|
||||
FORGEJO__server__START_SSH_SERVER: "false"
|
||||
FORGEJO__server__DISABLE_SSH: "false"
|
||||
FORGEJO__service__DISABLE_REGISTRATION: "true"
|
||||
FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false"
|
||||
FORGEJO__actions__ENABLED: "true"
|
||||
FORGEJO__database__DB_TYPE: sqlite3
|
||||
FORGEJO__log__LEVEL: Info
|
||||
volumes:
|
||||
- /opt/food-market-data/forgejo/data:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000" # HTTP, fronted by nginx on git.zat.kz
|
||||
- "2222:22" # SSH for git clone/push via ssh://git@git.zat.kz:2222/...
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
[Unit]
|
||||
Description=Push Forgejo food-market into GitHub (backup)
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=nns
|
||||
ExecStart=/usr/local/bin/food-market-forgejo-mirror.sh
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
[Unit]
|
||||
Description=Mirror Forgejo -> GitHub every 10 min
|
||||
|
||||
[Timer]
|
||||
OnBootSec=3min
|
||||
OnUnitActiveSec=10min
|
||||
Unit=food-market-forgejo-mirror.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
[Unit]
|
||||
Description=food-market Forgejo (primary git)
|
||||
Requires=docker.service
|
||||
After=docker.service network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=true
|
||||
WorkingDirectory=/home/nns/food-market/deploy/forgejo
|
||||
ExecStart=/usr/bin/docker compose up -d
|
||||
ExecStop=/usr/bin/docker compose stop
|
||||
User=nns
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Mirrors our Forgejo repo into GitHub. Best-effort: if the push fails (flaky
|
||||
# KZ TCP to github.com), the next tick will retry.
|
||||
set -euo pipefail
|
||||
|
||||
MIRROR_DIR="/opt/food-market-data/forgejo/mirror"
|
||||
FORGEJO_URL="http://127.0.0.1:3000/nns/food-market.git"
|
||||
GITHUB_URL="https://github.com/nurdotnet/food-market.git"
|
||||
GITHUB_TOKEN_FILE="/etc/food-market/github-mirror-token" # 40-char PAT with repo scope
|
||||
LOG_FILE="/var/log/food-market-forgejo-mirror.log"
|
||||
|
||||
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$LOG_FILE"; }
|
||||
|
||||
if [[ ! -f $GITHUB_TOKEN_FILE ]]; then
|
||||
log "token file $GITHUB_TOKEN_FILE missing — skipping mirror push"
|
||||
exit 0
|
||||
fi
|
||||
TOKEN=$(tr -d '\n' < "$GITHUB_TOKEN_FILE")
|
||||
|
||||
if [[ ! -d $MIRROR_DIR/objects ]]; then
|
||||
log "bootstrap: cloning $FORGEJO_URL → $MIRROR_DIR"
|
||||
rm -rf "$MIRROR_DIR"
|
||||
git clone --mirror "$FORGEJO_URL" "$MIRROR_DIR" >> "$LOG_FILE" 2>&1
|
||||
fi
|
||||
|
||||
cd "$MIRROR_DIR"
|
||||
|
||||
# Pull latest from Forgejo (source of truth).
|
||||
if ! git remote update --prune >> "$LOG_FILE" 2>&1; then
|
||||
log "forgejo fetch failed — aborting this tick"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Push everything to GitHub, timeout generously (big pushes on flaky link).
|
||||
GIT_HTTP_LOW_SPEED_LIMIT=1000 \
|
||||
GIT_HTTP_LOW_SPEED_TIME=60 \
|
||||
timeout 300 git push --prune "https://x-access-token:$TOKEN@github.com/nurdotnet/food-market.git" \
|
||||
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' >> "$LOG_FILE" 2>&1 \
|
||||
&& log "pushed to github ok" \
|
||||
|| log "github push failed (exit=$?), will retry next tick"
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name git.zat.kz;
|
||||
location /.well-known/acme-challenge/ { root /var/www/html; }
|
||||
|
||||
# Forgejo can serve large pushes; allow big request bodies.
|
||||
client_max_body_size 512M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
# Note: run certbot --nginx -d git.zat.kz to issue a TLS cert — certbot will
|
||||
# add a TLS server block and rewrite this one to 301->https.
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Pulls all external base images the food-market builds depend on, then retags
|
||||
# them into the local registry at 127.0.0.1:5001 under the "mirror/" prefix.
|
||||
#
|
||||
# Why: outbound to docker.io / mcr.microsoft.com flaps on KZ network. Once
|
||||
# mirrored, Dockerfiles and docker-compose reference the local copy and builds
|
||||
# no longer need the internet at all.
|
||||
#
|
||||
# Idempotent — safe to run as often as you want. Scheduled daily via
|
||||
# food-market-mirror-base-images.timer.
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY=127.0.0.1:5001
|
||||
LOG_PREFIX=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# image_ref → local name under mirror/
|
||||
IMAGES=(
|
||||
"node:20-alpine|mirror/node:20-alpine"
|
||||
"nginx:1.27-alpine|mirror/nginx:1.27-alpine"
|
||||
"postgres:16-alpine|mirror/postgres:16-alpine"
|
||||
"mcr.microsoft.com/dotnet/sdk:8.0|mirror/dotnet-sdk:8.0"
|
||||
"mcr.microsoft.com/dotnet/aspnet:8.0|mirror/dotnet-aspnet:8.0"
|
||||
)
|
||||
|
||||
failures=0
|
||||
for pair in "${IMAGES[@]}"; do
|
||||
src="${pair%|*}"
|
||||
dst="${pair#*|}"
|
||||
echo "$LOG_PREFIX pulling $src"
|
||||
if ! docker pull "$src"; then
|
||||
echo "$LOG_PREFIX FAILED: pull $src"
|
||||
failures=$((failures + 1))
|
||||
continue
|
||||
fi
|
||||
docker tag "$src" "$REGISTRY/$dst"
|
||||
if ! docker push "$REGISTRY/$dst"; then
|
||||
echo "$LOG_PREFIX FAILED: push $REGISTRY/$dst"
|
||||
failures=$((failures + 1))
|
||||
continue
|
||||
fi
|
||||
echo "$LOG_PREFIX ok $src -> $REGISTRY/$dst"
|
||||
done
|
||||
|
||||
if [[ $failures -gt 0 ]]; then
|
||||
echo "$LOG_PREFIX done, $failures failed — registry still has old mirrored copies"
|
||||
exit 1
|
||||
fi
|
||||
echo "$LOG_PREFIX done, all mirrors fresh"
|
||||
|
|
@ -1,432 +0,0 @@
|
|||
"""Telegram <-> tmux bridge for controlling a local Claude Code session from a phone.
|
||||
|
||||
Reads creds from /etc/food-market/telegram.env (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID).
|
||||
Only the single whitelisted chat_id is allowed; everything else is silently ignored.
|
||||
|
||||
Inbound: each Telegram message is typed into tmux session 'claude' via `tmux send-keys
|
||||
-t claude -l <text>` followed by an Enter keypress.
|
||||
|
||||
Outbound: every poll_interval seconds, capture the current pane, diff against the last
|
||||
snapshot, filter TUI noise (box-drawing, spinners, the user's own echoed prompt), then
|
||||
send any remaining text as plain Telegram messages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from telegram import Update
|
||||
from telegram.ext import (
|
||||
Application,
|
||||
ApplicationBuilder,
|
||||
CommandHandler,
|
||||
ContextTypes,
|
||||
MessageHandler,
|
||||
filters,
|
||||
)
|
||||
|
||||
ENV_FILE = Path("/etc/food-market/telegram.env")
|
||||
TMUX_SESSION = os.environ.get("TMUX_SESSION", "claude")
|
||||
POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL_SEC", "8"))
|
||||
CAPTURE_HISTORY = int(os.environ.get("CAPTURE_HISTORY_LINES", "200"))
|
||||
TG_MAX_CHARS = 3500
|
||||
MAX_SEND_PER_TICK = int(os.environ.get("MAX_SEND_CHARS_PER_TICK", "900"))
|
||||
|
||||
ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]")
|
||||
BOX_CHARS = set("╭╮╰╯│─┌┐└┘├┤┬┴┼║═╔╗╚╝╠╣╦╩╬▌▐█▀▄")
|
||||
SPINNER_CHARS = set("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣾⣽⣻⢿⡿⣟⣯⣷")
|
||||
# Claude Code TUI markers
|
||||
TOOL_CALL_RE = re.compile(r"^\s*[⏺●⏻◯◎⬤]\s+\S")
|
||||
TOOL_RESULT_RE = re.compile(r"^\s*⎿")
|
||||
USER_PROMPT_RE = re.compile(r"^>\s?(.*)$")
|
||||
|
||||
|
||||
def load_env(path: Path) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
if not path.exists():
|
||||
return out
|
||||
for raw in path.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
out[key.strip()] = value.strip().strip('"').strip("'")
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class BridgeState:
|
||||
chat_id: int
|
||||
last_snapshot: str = ""
|
||||
last_sent_text: str = ""
|
||||
recent_user_inputs: collections.deque = field(
|
||||
default_factory=lambda: collections.deque(maxlen=50)
|
||||
)
|
||||
recently_sent_lines: collections.deque = field(
|
||||
default_factory=lambda: collections.deque(maxlen=400)
|
||||
)
|
||||
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
|
||||
async def tmux_send_text(session: str, text: str) -> None:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux", "send-keys", "-t", session, "-l", text,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"tmux send-keys -l failed: {stderr.decode().strip()}")
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux", "send-keys", "-t", session, "Enter",
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"tmux send-keys Enter failed: {stderr.decode().strip()}")
|
||||
|
||||
|
||||
async def tmux_capture(session: str) -> str:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux", "capture-pane", "-t", session, "-p", "-S", f"-{CAPTURE_HISTORY}",
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"tmux capture-pane failed: {stderr.decode().strip()}")
|
||||
return stdout.decode("utf-8", errors="replace").rstrip("\n")
|
||||
|
||||
|
||||
def _strip_box(line: str) -> str:
|
||||
# Drop leading/trailing box-drawing chars and their padding.
|
||||
while line and (line[0] in BOX_CHARS or (line[0] == " " and len(line) > 1 and line[1] in BOX_CHARS)):
|
||||
line = line[1:].lstrip()
|
||||
if not line:
|
||||
break
|
||||
while line and (line[-1] in BOX_CHARS or (line[-1] == " " and len(line) > 1 and line[-2] in BOX_CHARS)):
|
||||
line = line[:-1].rstrip()
|
||||
if not line:
|
||||
break
|
||||
return line
|
||||
|
||||
|
||||
def _is_noise(line: str) -> bool:
|
||||
s = line.strip()
|
||||
if not s:
|
||||
return True
|
||||
# Lines made of only box/spinner/decoration chars + spaces.
|
||||
if all(c in BOX_CHARS or c in SPINNER_CHARS or c.isspace() for c in s):
|
||||
return True
|
||||
# Claude Code TUI hints.
|
||||
lowered = s.lower()
|
||||
if "shift+tab" in lowered or "bypass permissions" in lowered:
|
||||
return True
|
||||
if lowered.startswith("? for shortcuts"):
|
||||
return True
|
||||
# Spinner + status line ("✻ Thinking…", "· Pondering…").
|
||||
if s.startswith(("✻", "✽", "✢", "·")) and len(s) < 80:
|
||||
return True
|
||||
# Typing indicator prompt ("> " empty or near-empty input box).
|
||||
if s.startswith(">") and len(s) <= 2:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def clean_text(text: str, recent_user: collections.deque | None = None) -> str:
|
||||
"""Strip TUI noise, tool-call blocks and echoed user input.
|
||||
|
||||
Only the assistant's prose reply should survive.
|
||||
"""
|
||||
recent_user = recent_user if recent_user is not None else collections.deque()
|
||||
out: list[str] = []
|
||||
in_tool_block = False
|
||||
for raw in text.splitlines():
|
||||
line = ANSI_RE.sub("", raw).rstrip()
|
||||
line = _strip_box(line)
|
||||
stripped = line.strip()
|
||||
|
||||
if not stripped:
|
||||
in_tool_block = False
|
||||
out.append("")
|
||||
continue
|
||||
|
||||
# Tool call / tool result blocks — skip the header and any indented follow-ups.
|
||||
if TOOL_CALL_RE.match(line) or TOOL_RESULT_RE.match(line):
|
||||
in_tool_block = True
|
||||
continue
|
||||
if in_tool_block:
|
||||
# continuation of a tool block is usually indented; a flush-left line ends it
|
||||
if line.startswith(" ") or line.startswith("\t"):
|
||||
continue
|
||||
in_tool_block = False
|
||||
|
||||
# Echo of the user's own prompt ("> hello") — drop it.
|
||||
m = USER_PROMPT_RE.match(stripped)
|
||||
if m:
|
||||
continue
|
||||
if stripped in recent_user:
|
||||
continue
|
||||
|
||||
if _is_noise(line):
|
||||
continue
|
||||
|
||||
out.append(line)
|
||||
|
||||
# collapse runs of blank lines
|
||||
collapsed: list[str] = []
|
||||
prev_blank = False
|
||||
for line in out:
|
||||
blank = not line.strip()
|
||||
if blank and prev_blank:
|
||||
continue
|
||||
collapsed.append(line)
|
||||
prev_blank = blank
|
||||
return "\n".join(collapsed).strip("\n")
|
||||
|
||||
|
||||
def diff_snapshot(prev: str, curr: str) -> str:
|
||||
"""Return only lines that weren't already present anywhere in the previous snapshot.
|
||||
|
||||
Set-based: handles TUI scrolling and partial redraws without re-sending history.
|
||||
"""
|
||||
if not prev:
|
||||
return curr
|
||||
if prev == curr:
|
||||
return ""
|
||||
prev_set = set(prev.splitlines())
|
||||
new_lines = [ln for ln in curr.splitlines() if ln.rstrip() and ln not in prev_set]
|
||||
return "\n".join(new_lines)
|
||||
|
||||
|
||||
def chunk_for_telegram(text: str, limit: int = TG_MAX_CHARS) -> list[str]:
|
||||
if not text:
|
||||
return []
|
||||
out: list[str] = []
|
||||
buf: list[str] = []
|
||||
buf_len = 0
|
||||
for line in text.splitlines():
|
||||
if buf_len + len(line) + 1 > limit and buf:
|
||||
out.append("\n".join(buf))
|
||||
buf, buf_len = [], 0
|
||||
while len(line) > limit:
|
||||
out.append(line[:limit])
|
||||
line = line[limit:]
|
||||
buf.append(line)
|
||||
buf_len += len(line) + 1
|
||||
if buf:
|
||||
out.append("\n".join(buf))
|
||||
return out
|
||||
|
||||
|
||||
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
state: BridgeState = context.application.bot_data["state"]
|
||||
if update.effective_chat is None or update.effective_chat.id != state.chat_id:
|
||||
return
|
||||
text = (update.message.text or "").strip() if update.message else ""
|
||||
if not text:
|
||||
return
|
||||
# Remember what we sent so we can suppress its echo from the pane capture.
|
||||
async with state._lock:
|
||||
state.recent_user_inputs.append(text)
|
||||
# Also store reasonable substrings in case the TUI wraps or truncates
|
||||
if len(text) > 40:
|
||||
state.recent_user_inputs.append(text[:40])
|
||||
try:
|
||||
await tmux_send_text(TMUX_SESSION, text)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await update.message.reply_text(f"⚠️ tmux error: {exc}")
|
||||
|
||||
|
||||
async def cmd_ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
state: BridgeState = context.application.bot_data["state"]
|
||||
if update.effective_chat is None or update.effective_chat.id != state.chat_id:
|
||||
return
|
||||
await update.message.reply_text(
|
||||
f"pong — session '{TMUX_SESSION}', poll {POLL_INTERVAL}s"
|
||||
)
|
||||
|
||||
|
||||
async def cmd_snapshot(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
state: BridgeState = context.application.bot_data["state"]
|
||||
if update.effective_chat is None or update.effective_chat.id != state.chat_id:
|
||||
return
|
||||
try:
|
||||
snap = await tmux_capture(TMUX_SESSION)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await update.message.reply_text(f"⚠️ tmux error: {exc}")
|
||||
return
|
||||
async with state._lock:
|
||||
cleaned = clean_text(snap, state.recent_user_inputs)
|
||||
state.last_snapshot = snap # reset baseline so poller doesn't resend
|
||||
for part in chunk_for_telegram(cleaned) or ["(nothing to show)"]:
|
||||
await update.message.reply_text(part)
|
||||
|
||||
|
||||
async def poll_and_forward(application: Application) -> None:
|
||||
state: BridgeState = application.bot_data["state"]
|
||||
bot = application.bot
|
||||
logger = logging.getLogger("bridge.poll")
|
||||
while True:
|
||||
await asyncio.sleep(POLL_INTERVAL)
|
||||
# Stability check: capture twice, ~1.5s apart. If pane still changes, assistant
|
||||
# is still streaming — skip this tick and try next time.
|
||||
try:
|
||||
snap1 = await tmux_capture(TMUX_SESSION)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("capture failed: %s", exc)
|
||||
continue
|
||||
await asyncio.sleep(1.5)
|
||||
try:
|
||||
snap2 = await tmux_capture(TMUX_SESSION)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("capture failed: %s", exc)
|
||||
continue
|
||||
if snap1 != snap2:
|
||||
# still being written — don't send partials
|
||||
continue
|
||||
snapshot = snap2
|
||||
|
||||
async with state._lock:
|
||||
prev = state.last_snapshot
|
||||
state.last_snapshot = snapshot
|
||||
recent_user_copy = list(state.recent_user_inputs)
|
||||
recently_sent_copy = list(state.recently_sent_lines)
|
||||
|
||||
raw_new = diff_snapshot(prev, snapshot)
|
||||
new_text = clean_text(raw_new, collections.deque(recent_user_copy))
|
||||
if not new_text:
|
||||
continue
|
||||
|
||||
# Line-level dedup vs. what we already shipped: drop lines that are
|
||||
# substring-equivalent to a recently sent one (handles streaming dupes).
|
||||
deduped: list[str] = []
|
||||
for line in new_text.splitlines():
|
||||
s = line.rstrip()
|
||||
if not s.strip():
|
||||
deduped.append(line)
|
||||
continue
|
||||
ss = s.strip()
|
||||
is_dup = False
|
||||
for past in recently_sent_copy:
|
||||
if ss == past:
|
||||
is_dup = True
|
||||
break
|
||||
if len(ss) >= 15 and len(past) >= 15 and (ss in past or past in ss):
|
||||
is_dup = True
|
||||
break
|
||||
if is_dup:
|
||||
continue
|
||||
deduped.append(line)
|
||||
recently_sent_copy.append(ss)
|
||||
|
||||
async with state._lock:
|
||||
state.recently_sent_lines.clear()
|
||||
state.recently_sent_lines.extend(recently_sent_copy[-400:])
|
||||
|
||||
new_text = "\n".join(deduped).strip("\n")
|
||||
if not new_text:
|
||||
continue
|
||||
|
||||
async with state._lock:
|
||||
if new_text == state.last_sent_text:
|
||||
continue
|
||||
state.last_sent_text = new_text
|
||||
|
||||
# Cap total outbound per tick so a big burst doesn't flood Telegram.
|
||||
if len(new_text) > MAX_SEND_PER_TICK:
|
||||
keep = new_text[-MAX_SEND_PER_TICK:]
|
||||
# snap to next newline to avoid cutting mid-line
|
||||
nl = keep.find("\n")
|
||||
if 0 <= nl < 200:
|
||||
keep = keep[nl + 1 :]
|
||||
dropped = len(new_text) - len(keep)
|
||||
new_text = f"… (+{dropped} chars earlier)\n{keep}"
|
||||
|
||||
for part in chunk_for_telegram(new_text):
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=state.chat_id,
|
||||
text=part,
|
||||
disable_notification=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("telegram send failed: %s", exc)
|
||||
break
|
||||
|
||||
|
||||
async def on_startup(application: Application) -> None:
|
||||
state: BridgeState = application.bot_data["state"]
|
||||
try:
|
||||
state.last_snapshot = await tmux_capture(TMUX_SESSION)
|
||||
except Exception: # noqa: BLE001
|
||||
state.last_snapshot = ""
|
||||
application.bot_data["poll_task"] = asyncio.create_task(
|
||||
poll_and_forward(application), name="bridge-poll"
|
||||
)
|
||||
|
||||
|
||||
async def on_shutdown(application: Application) -> None:
|
||||
task = application.bot_data.get("poll_task")
|
||||
if task:
|
||||
task.cancel()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
env = {**os.environ, **load_env(ENV_FILE)}
|
||||
token = env.get("TELEGRAM_BOT_TOKEN", "").strip()
|
||||
chat_id_raw = env.get("TELEGRAM_CHAT_ID", "").strip()
|
||||
if not token or not chat_id_raw:
|
||||
print(
|
||||
"ERROR: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID must be set in "
|
||||
f"{ENV_FILE} or environment",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 78
|
||||
try:
|
||||
chat_id = int(chat_id_raw)
|
||||
except ValueError:
|
||||
print(f"ERROR: TELEGRAM_CHAT_ID must be an integer, got: {chat_id_raw!r}",
|
||||
file=sys.stderr)
|
||||
return 78
|
||||
|
||||
check = subprocess.run(
|
||||
["tmux", "has-session", "-t", TMUX_SESSION],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if check.returncode != 0:
|
||||
print(
|
||||
f"WARNING: tmux session '{TMUX_SESSION}' not found — bridge will run "
|
||||
"but send/capture will fail until the session is created.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
application = (
|
||||
ApplicationBuilder()
|
||||
.token(token)
|
||||
.post_init(on_startup)
|
||||
.post_shutdown(on_shutdown)
|
||||
.build()
|
||||
)
|
||||
application.bot_data["state"] = BridgeState(chat_id=chat_id)
|
||||
application.add_handler(CommandHandler("ping", cmd_ping))
|
||||
application.add_handler(CommandHandler("snapshot", cmd_snapshot))
|
||||
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
|
||||
|
||||
application.run_polling(allowed_updates=Update.ALL_TYPES, stop_signals=None)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -1 +0,0 @@
|
|||
python-telegram-bot[rate-limiter]==21.6
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
[Unit]
|
||||
Description=food-market Telegram <-> tmux bridge
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nns
|
||||
Group=nns
|
||||
WorkingDirectory=/opt/food-market-data/telegram-bridge
|
||||
EnvironmentFile=-/etc/food-market/telegram.env
|
||||
ExecStart=/opt/food-market-data/telegram-bridge/venv/bin/python /opt/food-market-data/telegram-bridge/bridge.py
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
# Access tmux sockets under /tmp/tmux-1000/
|
||||
Environment=TMUX_TMPDIR=/tmp
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -1,464 +0,0 @@
|
|||
# Аудит наших доменных сущностей vs. MoySklad API
|
||||
|
||||
Источник правды — живой MoySklad API `/api/remap/1.2/`. Проверялись ключи на реальных ответах (`?limit=2` на нашем аккаунте). Цель: каждая наша сущность должна либо повторять MoySklad, либо иметь явно оправданное отличие. Никаких «выдуманных» полей.
|
||||
|
||||
Условные обозначения:
|
||||
- **⛔** — у нас есть поле, которого нет у MoySklad → либо оправдать комментарием, либо удалить.
|
||||
- **➕** — у MoySklad есть поле, которого нет у нас → потенциально добавить.
|
||||
- **⚠️** — важный нюанс (тип, семантика, обязательность).
|
||||
|
||||
---
|
||||
|
||||
## Counterparty → `entity/counterparty`
|
||||
|
||||
Ключи MoySklad (из ответа API, верхний уровень): `accountId, accounts, archived, bonusPoints, bonusProgram, companyType, created, externalCode, files, group, id, meta, name, notes, owner, salesAmount, shared, state, tags, updated` + расширяемые: `legalTitle, legalAddress, inn, kpp, ogrn, ogrnip, certificateNumber, certificateDate, phone, email, actualAddress, description, discountCardNumber, priceType, sex, salesChannel`.
|
||||
|
||||
| Наше поле | MoySklad | Комментарий |
|
||||
|---|---|---|
|
||||
| `Name` | `name` | ОК |
|
||||
| `LegalName` | `legalTitle` | rename? или доп. комментарий-алиас |
|
||||
| `Kind` (CounterpartyKind) | **нет** | ⛔ уже исправили enum (`Unspecified/Supplier/Customer/Both`), но MoySklad не имеет этого поля вообще — он различает контрагентов через `tags` или через `state` (статус в пайплайне продаж/закупок). **TODO:** либо оставить Kind только как UI-фильтр (не импортировать из MoySklad), либо перейти на теги |
|
||||
| `Type` (LegalEntity/Individual) | `companyType` | ⚠️ у MoySklad 3 значения: `legal`, `individual`, `entrepreneur` (ИП!). У нас ИП отсутствует — **добавить `IndividualEntrepreneur` в enum** (для РК актуально) |
|
||||
| `Bin` (БИН, РК) | `inn` (12-значный БИН пишется туда) | ⚠️ MoySklad для всех рынков использует `inn` — 12 цифр это ИИН РФ, 12 цифр РК — БИН. Мы вынесли `Bin` отдельно, при импорте MoySklad кладёт в `inn`. **TODO:** документировать маппинг Bin ↔ inn |
|
||||
| `Iin` (ИИН, РК) | `inn` (тот же) | ⚠️ same — MoySklad не различает |
|
||||
| `TaxNumber` | `inn` | дубль |
|
||||
| `CountryId` | `country` (extended, по `meta`) | ⚠️ MoySklad не на верхнем уровне — тянется при `?expand=country` |
|
||||
| `Address` | `actualAddress` | ОК |
|
||||
| `Phone` | `phone` | ОК |
|
||||
| `Email` | `email` | ОК |
|
||||
| `BankName, BankAccount, Bik` | `accounts` (массив объектов) | ⚠️ у MoySklad это **коллекция счетов** (до нескольких банков). У нас одиночные поля — **либо сделать коллекцию Accounts, либо документировать "берём первый"** |
|
||||
| `ContactPerson` | `contactpersons` (sub-endpoint) | ⚠️ у MoySklad это отдельный endpoint `counterparty/{id}/contactpersons` — массив. У нас скалярное поле |
|
||||
| `Notes` | `description` (или `notes` разные в разных версиях API?) | ⚠️ в ответе API было `notes` — ОК |
|
||||
| `IsActive` | `archived` (inverse) | ОК |
|
||||
| — | `tags` (массив) | ➕ **добавить** — удобно для классификации (в том числе заменой Kind) |
|
||||
| — | `state` (ссылка на состояние в пайплайне) | ➕ отложить до Phase N (CRM) |
|
||||
| — | `bonusPoints, bonusProgram, discountCardNumber` | ➕ отложить до дисконтных карт |
|
||||
| — | `salesAmount` (вычисляемое) | не храним |
|
||||
| — | `priceType` (персональный тип цены) | ➕ полезно для опта; добавить `Guid? DefaultPriceTypeId` |
|
||||
|
||||
**TODO:**
|
||||
1. Enum `CounterpartyType`: добавить `IndividualEntrepreneur = 3`.
|
||||
2. Коллекция `CounterpartyAccount` (BankName/BankAccount/Bik/IsDefault) — или явный комментарий «храним только основной».
|
||||
3. Коллекция `CounterpartyTag` (string) — для классификации при импорте из MoySklad.
|
||||
4. Поле `DefaultPriceTypeId` → `PriceType` (для опта/персональной цены).
|
||||
5. Комментарий на `Bin/Iin/TaxNumber`: при импорте из MoySklad все три могут прилететь из одного поля `inn` — логика различения по длине (12 цифр РК-формат) / по companyType.
|
||||
|
||||
---
|
||||
|
||||
## Organization → `entity/organization`
|
||||
|
||||
Ключи MS: `accountId, accounts, archived, bonusPoints, bonusProgram, companyType, companyVat__ru, created, email, externalCode, group, id, isEgaisEnable, meta, name, owner, payerVat, shared, updated` + extended: `legalTitle, legalAddress, actualAddress, inn, kpp, ogrn, ogrnip, okpo, director, chiefAccountant, phone, fax, utmUrl`.
|
||||
|
||||
| Наше | MS | Комментарий |
|
||||
|---|---|---|
|
||||
| `Name` | `name` | ОК |
|
||||
| `CountryCode` | **нет** | ⛔ у MoySklad нет — у них multi-tenant через account. У нас — multi-tenant через Organization, но CountryCode неочевиден. Оставить как есть, документировать почему (нам нужно для налоговых/локальных настроек) |
|
||||
| `Bin` | `inn` | то же что и Counterparty |
|
||||
| `Address` | `actualAddress` | ОК |
|
||||
| `Phone` | `phone` | ОК |
|
||||
| `Email` | `email` | ОК |
|
||||
| `IsActive` | `archived` inverse | ОК |
|
||||
| — | `legalTitle, legalAddress` | ➕ для офиц. документов |
|
||||
| — | `kpp, ogrn, ogrnip, okpo` | РФ-специфично, пропускаем для РК |
|
||||
| — | `payerVat` (bool, плательщик НДС) | ➕ полезно — есть ли НДС у нашей организации |
|
||||
| — | `director, chiefAccountant` | ➕ для подписей на накладных |
|
||||
| — | `accounts` (банковские) | ➕ аналогично Counterparty |
|
||||
| — | `isEgaisEnable` | РФ, пропускаем |
|
||||
|
||||
**TODO:**
|
||||
1. `LegalName`, `LegalAddress`, `PayerVat` (bool), `DirectorName`, `ChiefAccountantName` — для накладных/счетов.
|
||||
2. `CountryCode` оставить + `<see langword="…"/>` комментарий почему у нас есть, а у MS нет.
|
||||
|
||||
---
|
||||
|
||||
## Product → `entity/product`
|
||||
|
||||
Ключи MS: `accountId, archived, barcodes, buyPrice, code, discountProhibited, externalCode, files, group, id, images, isSerialTrackable, meta, minPrice, name, owner, pathName, paymentItemType, productFolder, salePrices, shared, supplier, trackingType, uom, updated, useParentVat, variantsCount, volume, weight` + optional: `article, country, description, effectiveVat, minPrice.currency, taxSystem, vat, tnved, syncId, modifications`.
|
||||
|
||||
| Наше | MS | Комментарий |
|
||||
|---|---|---|
|
||||
| `Name` | `name` | ОК |
|
||||
| `Article` | `article` | ОК |
|
||||
| `Description` | `description` | ОК |
|
||||
| `UnitOfMeasureId` | `uom.meta` | ОК |
|
||||
| `VatRateId` | `vat` (число) + `useParentVat` | ⚠️ у MS НДС хранится как число (20, 10, 12, 0) прямо на товаре. **Мы отдельная сущность VatRate**. Обоснование: нам нужно хранить локализованные названия ("НДС 12%", "Без НДС"), is-default, и позволять разным организациям иметь разные ставки. НО — при импорте надо резолвить число в VatRate по organization_id |
|
||||
| `ProductGroupId` | `productFolder.meta` | ОК |
|
||||
| `DefaultSupplierId` | `supplier.meta` | ОК (у MS тоже одиночная ссылка) |
|
||||
| `CountryOfOriginId` | `country.meta` | ОК |
|
||||
| `IsService` | `paymentItemType` (одно из значений = "SERVICE") | ⚠️ у MS это enum с ~10 значений; у нас bool. **TODO:** либо enum, либо документировать что мы учитываем только IsService |
|
||||
| `IsWeighed` | **нет** | ⛔ у MS этого нет; характеристика ритейла, нам нужно для касс с весами. **Оставить, документировать.** |
|
||||
| `IsAlcohol` | `tnved` (класс товара) или через group | ⚠️ у MS через tnved-код или through type классификаторы. Наше bool — упрощение. **Оставить с комментарием.** |
|
||||
| `IsMarked` | `trackingType` (enum: NOT_TRACKED, BEER_ALCOHOL, …) | ⚠️ У MS это enum из 10+ вариантов маркировки. Наш `IsMarked: bool` — потеря информации. **TODO:** заменить на enum `TrackingType` (NOT_TRACKED/TOBACCO/ALCOHOL/SHOES/MEDICINE/…) |
|
||||
| `MinStock, MaxStock` | `minimumBalance` (число), `stock` (runtime) | ⚠️ у MS есть только `minimumBalance` (нижняя граница). MaxStock — наш |
|
||||
| `PurchasePrice, PurchaseCurrencyId` | `buyPrice.value, buyPrice.currency.meta` | ОК (MS упаковывает в объект, мы разнесли — **одно и то же**) |
|
||||
| `ImageUrl` | `images` (массив через sub-endpoint) | ⚠️ у MS images коллекция, у нас одна + отдельная ProductImage. ОК, двойная запись для UX |
|
||||
| `IsActive` | `archived` inverse | ОК |
|
||||
| `Prices` (collection) | `salePrices` (массив inline в MS) | ⚠️ у MS цены — **массив внутри товара**, у нас — отдельная таблица. Оба норм; просто маппинг при sync |
|
||||
| `Barcodes` (collection) | `barcodes` (массив inline) | ОК |
|
||||
| `Images` (collection) | `images` (sub-endpoint) | ОК |
|
||||
| — | `code` | ➕ внутренний код (отличается от `article`). **Добавить `Code`** |
|
||||
| — | `externalCode` | ➕ используется при импорте/ERP-интеграциях. **Добавить `ExternalCode`** (актуально для импорта из MoySklad, 1C) |
|
||||
| — | `discountProhibited` | ➕ «запрет скидок» — полезно на кассе |
|
||||
| — | `minPrice.value/currency` | ➕ минимальная отпускная цена. **Добавить `MinPrice` + `MinPriceCurrencyId`** |
|
||||
| — | `paymentItemType` | ➕ для фискализации: «товар/услуга/работа/подарочная карта/…». **Добавить enum `PaymentItemType`** (нужно для 54-ФЗ / КZ fiscal receipts) |
|
||||
| — | `tnved` | ➕ код ТН ВЭД для трансграничной торговли |
|
||||
| — | `volume, weight` | ➕ для логистики (доставка) |
|
||||
| — | `variantsCount` | runtime агрегат, не храним |
|
||||
| — | `files` | ➕ вложения (паспорта качества, фото упаковки) — отложить |
|
||||
|
||||
**TODO:**
|
||||
1. Добавить `Code`, `ExternalCode` на Product.
|
||||
2. Заменить `IsMarked` на enum `TrackingType`.
|
||||
3. Добавить `MinPrice`, `MinPriceCurrencyId`.
|
||||
4. Добавить enum `PaymentItemType` + поле.
|
||||
5. Поля `Volume`, `Weight`, `DiscountProhibited`.
|
||||
6. Запомнить маппинг: `useParentVat` → наследовать НДС от ProductGroup (у нас сейчас не реализовано, надо подумать).
|
||||
|
||||
---
|
||||
|
||||
## ProductGroup → `entity/productfolder`
|
||||
|
||||
Ключи MS: `accountId, archived, externalCode, group, id, meta, name, owner, pathName, shared, updated, useParentVat` + `vat, effectiveVat, productFolder` (родитель).
|
||||
|
||||
| Наше | MS | Комментарий |
|
||||
|---|---|---|
|
||||
| `Name` | `name` | ОК |
|
||||
| `ParentId` | `productFolder.meta` | ОК (MS использует то же имя для родителя что и для самой сущности) |
|
||||
| `Path` | `pathName` | ОК |
|
||||
| `SortOrder` | **нет** | ⛔ у MS нет сортировки групп. Оставить, это UX |
|
||||
| `IsActive` | `archived` inverse | ОК |
|
||||
| — | `externalCode` | ➕ для импорта |
|
||||
| — | `vat, useParentVat` | ➕ ставка НДС по умолчанию для товаров группы |
|
||||
|
||||
**TODO:**
|
||||
1. Добавить `ExternalCode`.
|
||||
2. Добавить `VatRateId?` + `UseParentVat: bool` (для наследования).
|
||||
|
||||
---
|
||||
|
||||
## ProductBarcode → `product.barcodes[]`
|
||||
|
||||
У MS barcode — объект внутри product: `{type: 'ean13'|'ean8'|'code128'|'upc'|'gtin', value: '...'}`. Отдельной сущности нет.
|
||||
|
||||
| Наше | MS | Комментарий |
|
||||
|---|---|---|
|
||||
| `Code` | `value` | ОК |
|
||||
| `Type` | `type` | ⚠️ MS использует строки ('ean13', 'gtin', …) — мы уже enum |
|
||||
| `IsPrimary` | **нет** | ⛔ у MS нет — первый считается основным. **Оставить с комментарием — у нас явная пометка.** |
|
||||
|
||||
OK, расхождений существенных нет.
|
||||
|
||||
---
|
||||
|
||||
## ProductPrice → `product.salePrices[]`
|
||||
|
||||
У MS цены — массив объектов в product: `{value, currency: {meta}, priceType: {meta}}`. Отдельной сущности нет.
|
||||
|
||||
Наше — отдельная таблица. Это **нормализованный вариант** — оправдано если цен много и есть выборки по PriceType. **TODO:** маппинг при импорте — проитерировать salePrices и создать ProductPrice per PriceType.
|
||||
|
||||
---
|
||||
|
||||
## PriceType → `entity/pricetype`
|
||||
|
||||
Ключи MS (из context): `id, name, externalCode`.
|
||||
|
||||
| Наше | MS | Комментарий |
|
||||
|---|---|---|
|
||||
| `Name` | `name` | ОК |
|
||||
| `IsDefault` | **нет** | ⛔ у MS — default определяется порядком или отдельно в настройках аккаунта. **Оставить** |
|
||||
| `IsRetail` | **нет** | ⛔ наш флаг «используется на кассе». **Оставить** |
|
||||
| `SortOrder` | **нет** | ⛔ UX. **Оставить** |
|
||||
| — | `externalCode` | ➕ для импорта |
|
||||
|
||||
**TODO:**
|
||||
1. `ExternalCode`.
|
||||
|
||||
---
|
||||
|
||||
## Country → `entity/country`
|
||||
|
||||
Ключи MS: `code, description, externalCode, id, meta, name, updated`.
|
||||
|
||||
| Наше | MS | Комментарий |
|
||||
|---|---|---|
|
||||
| `Code` | `code` | ⚠️ у MS формат ISO3166-1 **alpha-2 или числовой** — у нас alpha-2 |
|
||||
| `Name` | `name` | ОК |
|
||||
| `SortOrder` | **нет** | ⛔ UX |
|
||||
| — | `description` | ➕ |
|
||||
| — | `externalCode` | ➕ |
|
||||
|
||||
OK, мелочь.
|
||||
|
||||
---
|
||||
|
||||
## Currency → `entity/currency`
|
||||
|
||||
Ключи MS: `archived, code, default, fullName, id, indirect, isoCode, majorUnit, meta, minorUnit, multiplicity, name, rate, rateUpdateType, system`.
|
||||
|
||||
| Наше | MS | Комментарий |
|
||||
|---|---|---|
|
||||
| `Code` | `isoCode` или `code` | ⚠️ у MS `isoCode` (строка "KZT") и `code` (цифровой "398") — у нас `Code` = строка ISO |
|
||||
| `Name` | `name` | ОК |
|
||||
| `Symbol` | **нет** | ⛔ у MS нет символа "₸" — но это UX. **Оставить** |
|
||||
| `MinorUnit` | `minorUnit` | ОК |
|
||||
| `IsActive` | `archived` inverse | ОК |
|
||||
| — | `default` (валюта аккаунта) | ➕ |
|
||||
| — | `rate, rateUpdateType` | ➕ курс к базовой валюте (при мульти-валютности) |
|
||||
| — | `multiplicity, indirect` | конвертация; если не мульти-валютные — не надо |
|
||||
| — | `fullName` | ➕ «Тенге Казахстана» vs «KZT» |
|
||||
|
||||
**TODO:**
|
||||
1. Добавить `IsDefault: bool` (ровно одна валюта = true per tenant, или глобально).
|
||||
2. `Rate, RateUpdateType` + `FullName` — отложить до мульти-валютности.
|
||||
|
||||
---
|
||||
|
||||
## VatRate — у MoySklad нет `entity/vatrate`
|
||||
|
||||
⚠️ У MS **ставки НДС хранятся как числовое поле на товаре** (`vat`). Отдельной таблицы нет — набор значений {0, 5, 7, 10, 12, 18, 20} и «без НДС» встроен в систему.
|
||||
|
||||
Наше `VatRate` — отдельная сущность. **Обоснование сохранить:**
|
||||
1. Локализованное название ("НДС 12%", "Без НДС").
|
||||
2. IsDefault per organization.
|
||||
3. Разные организации в разных налоговых режимах (с НДС / УСН).
|
||||
4. При добавлении новой ставки (например, на случай гипотетического увеличения в РК) — не править перечисление в коде.
|
||||
|
||||
Но: **следите**, чтобы у товара хранился `VatRateId`, а не отдельно `vat: decimal`. При импорте из MS мапим число в запись VatRate.
|
||||
|
||||
**Комментарий в коде нужен** — явно сказать, что мы отклонились от MoySklad сознательно.
|
||||
|
||||
---
|
||||
|
||||
## UnitOfMeasure → `entity/uom`
|
||||
|
||||
Ключи MS: `code, description, externalCode, id, meta, name, updated`.
|
||||
|
||||
| Наше | MS | Комментарий |
|
||||
|---|---|---|
|
||||
| `Code` (ОКЕИ) | `code` | ОК, MS использует ОКЕИ-коды (796, 166, 112) |
|
||||
| `Symbol` | **нет** | ⛔ у MS только `name` ("штука"). Мы вынесли "шт" отдельно для коротких надписей на ценниках/кассовых чеках. **Оставить.** |
|
||||
| `Name` | `name` | ОК |
|
||||
| `DecimalPlaces` | **нет** | ⛔ у MS на уровне продукта (`variantsCount`?), а не UoM. Наш `DecimalPlaces` определяет можно ли дробные количества (0=штучный, 3=весовой). **Оставить — важно для UX касс.** |
|
||||
| `IsBase` | **нет** | ⛔ наше «базовая единица организации». Мелочь, оставить |
|
||||
| `IsActive` | `archived` inverse (у MS есть `archived` в uom? перепроверить) | ⚠️ в нашем ответе API archived не было — у MS uom этого поля может не быть, потому что единицы системные |
|
||||
| — | `description` | ➕ |
|
||||
| — | `externalCode` | ➕ |
|
||||
|
||||
**TODO:**
|
||||
1. `ExternalCode`.
|
||||
|
||||
---
|
||||
|
||||
## Store → `entity/store`
|
||||
|
||||
Ключи MS: `accountId, address, archived, externalCode, group, id, meta, name, owner, pathName, shared, slots, updated, zones`.
|
||||
|
||||
| Наше | MS | Комментарий |
|
||||
|---|---|---|
|
||||
| `Name` | `name` | ОК |
|
||||
| `Code` | `externalCode`? или отдельно? | ⚠️ у MS только `externalCode`. **Добавить ExternalCode или rename Code→ExternalCode** |
|
||||
| `Kind` (Warehouse/RetailFloor) | **нет** | ⛔ у MS такого деления нет. Обоснование: нам нужно отличать «склад» от «торгового зала» для UI и настроек касс. **Оставить с комментарием** |
|
||||
| `Address` | `address` | ОК |
|
||||
| `Phone` | **нет** | ⛔ у MS нет. Оставить |
|
||||
| `ManagerName` | **нет** | ⛔ у MS нет. Оставить |
|
||||
| `IsMain` | **нет** (но можно проставить через default) | ⛔ Оставить |
|
||||
| `IsActive` | `archived` inverse | ОК |
|
||||
| — | `pathName` | ➕ (если будут иерархические склады) |
|
||||
| — | `slots` (ячейки склада) | ➕ отложить |
|
||||
| — | `zones` (зоны склада) | ➕ отложить |
|
||||
|
||||
**TODO:**
|
||||
1. `ExternalCode` (или переименовать Code → ExternalCode).
|
||||
|
||||
---
|
||||
|
||||
## RetailPoint → `entity/retailstore`
|
||||
|
||||
У MS это **«Точка продаж» / кассовое место**. Огромное количество полей (~60) — в основном фискальные настройки.
|
||||
|
||||
| Наше | MS | Комментарий |
|
||||
|---|---|---|
|
||||
| `Name` | `name` | ОК |
|
||||
| `Code` | `externalCode` | rename or add |
|
||||
| `StoreId` | `store.meta` | ОК |
|
||||
| `Address` | **нет** (возможно `organization.actualAddress`) | ⛔ адрес не у точки, а у организации/склада. Пересмотреть, куда класть |
|
||||
| `Phone` | **нет** | ⛔ |
|
||||
| `FiscalSerial` | **нет такого поля**; есть `fiscalType`, `fiscalMemoryNumber`?, `ofdEnabled` | ⚠️ у MS фискальные настройки множественные. У нас один скаляр — упрощение. **TODO:** уточнить по мере подключения ККМ |
|
||||
| `FiscalRegNumber` | `ofdEnabled` + `ofdSettings` | same |
|
||||
| `IsActive` | `active, archived` | MS различает active и archived — у нас только IsActive |
|
||||
| — | `priceType.meta` | ➕ тип цены для этой точки — **важно** |
|
||||
| — | `allowCustomPrice` | ➕ разрешить ручную цену на кассе |
|
||||
| — | `allowCreateProducts` | ➕ создать товар прямо на кассе |
|
||||
| — | `discountEnable, discountMaxPercent` | ➕ скидки на кассе |
|
||||
| — | `cashiers` (коллекция) | ➕ кто может работать за кассой |
|
||||
| — | `sellReserves` | ➕ продавать резерв |
|
||||
| — | `receiptTemplate` | ➕ шаблон чека |
|
||||
| — | `returnFromClosedShiftEnabled` | ➕ возврат из закрытой смены |
|
||||
| — | `requiredBirthdate/Email/Phone/Fio/Sex/DiscountCardNumber` | ➕ обязательные поля при продаже |
|
||||
| — | `markingSellingMode, marksCheckMode, sendMarksForCheck` | ➕ маркировка товаров |
|
||||
|
||||
**TODO:**
|
||||
1. Обязательно: `DefaultPriceTypeId` (ссылка на `PriceType`).
|
||||
2. Настройки кассы (скоп Phase 3 — касса): `AllowCustomPrice`, `AllowCreateProducts`, `SellReserves`, `DiscountMaxPercent`, `RequireCustomer...` — добавлять по мере реализации POS.
|
||||
3. Коллекция `RetailPointCashier` (user_id, может ли работать).
|
||||
|
||||
---
|
||||
|
||||
## Supply → `entity/supply` + `supply/{id}/positions`
|
||||
|
||||
Document keys: `accountId, agent, applicable, created, externalCode, files, group, id, meta, moment, name, organization, owner, payedSum, positions, printed, published, rate, shared, store, sum, updated, vatEnabled, vatIncluded, vatSum`.
|
||||
|
||||
| Наше | MS | Комментарий |
|
||||
|---|---|---|
|
||||
| `Number` | `name` | ⚠️ у MS «номер документа» = `name`. У нас `Number` — семантически то же |
|
||||
| `Date` | `moment` | ОК |
|
||||
| `Status` (Draft/Posted) | `applicable` (bool) | ⚠️ у MS это bool «проведён или нет». У нас enum Draft/Posted → эквивалентно |
|
||||
| `SupplierId` | `agent.meta` | ⚠️ у MS вместо `supplier` общее слово `agent` (контрагент) |
|
||||
| `StoreId` | `store.meta` | ОК |
|
||||
| `CurrencyId` | `rate.currency.meta` | ⚠️ MS упаковывает в rate объект с курсом |
|
||||
| `SupplierInvoiceNumber` | **нет на верхнем уровне**; есть в `attributes` | ⛔ у MS через custom attributes. Оставить |
|
||||
| `SupplierInvoiceDate` | same | same |
|
||||
| `Notes` | `description` | rename или комментарий |
|
||||
| `Total` | `sum` | ОК |
|
||||
| `PostedAt` | `updated` (когда applicable ставится true) | ⚠️ у MS нет выделенного поля; мы отдельно фиксируем |
|
||||
| `PostedByUserId` | `owner.meta` | условно |
|
||||
| — | `vatEnabled` | ➕ |
|
||||
| — | `vatIncluded` | ➕ НДС включён в цену |
|
||||
| — | `vatSum` | ➕ суммарный НДС документа |
|
||||
| — | `payedSum` | ➕ сколько оплачено |
|
||||
| — | `organization.meta` | ⚠️ у MS документ привязан к организации. **У нас TenantEntity несёт OrganizationId — уже есть** |
|
||||
| — | `printed, published` | ➕ распечатан/опубликован |
|
||||
| — | `overhead` (доп.расходы) | ➕ доставка/таможня — **важно для фактической себестоимости** |
|
||||
|
||||
**Supply.Positions (SupplyLine) → supply/{id}/positions:**
|
||||
|
||||
Ключи MS: `accountId, assortment, discount, id, meta, overhead, price, quantity, vat, vatEnabled`.
|
||||
|
||||
| Наше (SupplyLine) | MS position | Комментарий |
|
||||
|---|---|---|
|
||||
| `ProductId` | `assortment.meta` | ⚠️ у MS `assortment` = может быть product ИЛИ variant ИЛИ service ИЛИ bundle. Мы только продукт |
|
||||
| `Quantity` | `quantity` | ОК |
|
||||
| `UnitPrice` | `price` | ⚠️ у MS `price` — в копейках (integer `100 = 1.00`). У нас decimal. **Маппинг при импорте: делить на 100** |
|
||||
| `LineTotal` | **нет** (вычисляется) | ⛔ у MS не хранится |
|
||||
| `SortOrder` | **нет** | ⛔ наш UX |
|
||||
| — | `discount` | ➕ строковая скидка |
|
||||
| — | `vat` | ➕ ставка НДС на позицию |
|
||||
| — | `vatEnabled` | ➕ |
|
||||
| — | `overhead` | ➕ доля накладных (для себестоимости) |
|
||||
|
||||
**TODO Supply:**
|
||||
1. Поля: `VatEnabled`, `VatIncluded`, `VatSum`, `PayedSum`, `Overhead`.
|
||||
2. Lines: `Discount` (decimal), `VatPercent` (snapshot, уже подобное есть в RetailSaleLine), `VatEnabled`.
|
||||
3. Комментарий: MS `price` в копейках — при импорте делить.
|
||||
|
||||
---
|
||||
|
||||
## RetailSale → `entity/retaildemand` + `retaildemand/{id}/positions`
|
||||
|
||||
Document keys: огромный список, ключевое: `agent, applicable, cashSum, noCashSum, qrSum, prepaymentCashSum, prepaymentNoCashSum, prepaymentQrSum, advancePaymentSum, fiscal, retailShift, retailStore, store, positions, rate, sum, vatEnabled, vatIncluded, vatSum, name, moment, organization, syncId`.
|
||||
|
||||
| Наше | MS | Комментарий |
|
||||
|---|---|---|
|
||||
| `Number` | `name` | ОК |
|
||||
| `Date` | `moment` | ОК |
|
||||
| `Status` | `applicable` | ⚠️ bool vs enum |
|
||||
| `StoreId` | `store.meta` | ОК |
|
||||
| `RetailPointId` | `retailStore.meta` | ОК |
|
||||
| `CustomerId` | `agent.meta` | ОК (nullable если не знаем покупателя) |
|
||||
| `CashierUserId` | **нет напрямую**; `retailShift` → cashier | ⚠️ |
|
||||
| `CurrencyId` | `rate.currency.meta` | ОК |
|
||||
| `Subtotal, DiscountTotal, Total` | `sum` (= Total) | ⚠️ MS **не хранит subtotal и discount total отдельно** — только total. Но цена в позиции уже после скидки? Нет — `positions[].discount` хранится, total = sum(price*qty - discount) |
|
||||
| `Payment` (PaymentMethod enum) | **cashSum + noCashSum + qrSum** | ⚠️ MS — **не enum, а суммы по видам оплаты**. Т.е. при mixed-оплате можно часть наличными + часть картой. **Наш enum Payment + PaidCash + PaidCard — неполный.** TODO: добавить `PaidQr` + убрать enum в пользу «сколько чем заплачено» |
|
||||
| `PaidCash` | `cashSum` | ⚠️ у MS в копейках |
|
||||
| `PaidCard` | `noCashSum` | ⚠️ в копейках |
|
||||
| `Notes` | `description` | ОК |
|
||||
| `PostedAt` | — | наш |
|
||||
| `PostedByUserId` | `owner.meta` | условно |
|
||||
| — | `qrSum` | ➕ **добавить `PaidQr`** (QR-оплата актуальна для КZ) |
|
||||
| — | `retailShift.meta` | ➕ кассовая смена (отложить) |
|
||||
| — | `fiscal` | ➕ пробит ли фискально |
|
||||
| — | `syncId` | ➕ идентификатор для офлайн-касс (при резинхроне) |
|
||||
| — | `prepaymentCashSum/NoCashSum/QrSum, advancePaymentSum` | ➕ предоплаты |
|
||||
|
||||
**RetailSale.Positions (RetailSaleLine) → retaildemand/{id}/positions:**
|
||||
|
||||
Ключи: `accountId, assortment, discount, id, meta, price, quantity, vat, vatEnabled`.
|
||||
|
||||
| Наше (RetailSaleLine) | MS | Комментарий |
|
||||
|---|---|---|
|
||||
| `ProductId` | `assortment.meta` | ОК |
|
||||
| `Quantity` | `quantity` | ОК |
|
||||
| `UnitPrice` | `price` | ⚠️ копейки |
|
||||
| `Discount` | `discount` | ОК |
|
||||
| `LineTotal` | вычисляется | наш |
|
||||
| `VatPercent` | `vat` | ОК (snapshot) |
|
||||
| `SortOrder` | — | наш UX |
|
||||
| — | `vatEnabled` | ➕ |
|
||||
|
||||
**TODO RetailSale:**
|
||||
1. Добавить `PaidQr: decimal`.
|
||||
2. **Убрать `PaymentMethod` enum** в пользу денормализованных `PaidCash, PaidCard, PaidQr, PaidBonus` + computed `Method` (если все кроме одного = 0 → Cash/Card/QR, иначе Mixed). Либо оставить enum + PayHint, но быть готовым к частичной оплате.
|
||||
3. `VatEnabled, VatIncluded, VatSum` (сумма НДС на документ — вычисляется).
|
||||
4. Комментарий: MS `price/cashSum/noCashSum` в копейках при импорте.
|
||||
|
||||
---
|
||||
|
||||
## Stock → `report/stock/bystore`
|
||||
|
||||
У MS **нет отдельной сущности "Stock"** — это **отчёт**. Ответ `report/stock/bystore` содержит:
|
||||
```json
|
||||
{ "meta": {...}, "stockByStore": [ { "name": "Склад №1", "meta": {...}, "stock": 10.0, "reserve": 2.0, "inTransit": 0.0, "quantity": 12.0 } ] }
|
||||
```
|
||||
Т.е. по каждому (product, store) — stock (сколько есть), reserve (резерв), inTransit (в пути), quantity = stock+inTransit.
|
||||
|
||||
У нас `Stock` — **материализованный агрегат** (Quantity, ReservedQuantity, computed Available). Это **технически наше решение**, не требование бизнеса.
|
||||
|
||||
**TODO:**
|
||||
1. Комментарий в Stock.cs: объяснить, что это материализация (у MS динамический репорт).
|
||||
2. **Добавить `InTransit: decimal`** — товар в пути (между складами при перемещении).
|
||||
|
||||
---
|
||||
|
||||
## StockMovement — у MoySklad такой сущности нет
|
||||
|
||||
⚠️ MS **не хранит journal движений** в явном виде — остатки рассчитываются в реальном времени из документов (supply, retaildemand, loss, enter, move и т.д.). Поэтому на вопрос «почему на складе минус 5» MS возвращает историю документов, а не journal.
|
||||
|
||||
Наше `StockMovement` — **явный immutable journal**. Обоснование:
|
||||
1. Мгновенный ответ на «почему такой остаток» без пробегания по всем документам.
|
||||
2. Атомарные корректировки при баг-фиксах миграций.
|
||||
3. Упрощённая репликация в офлайн-кассы.
|
||||
|
||||
Это **сознательное отклонение** от MS — должно быть задокументировано в коде и в `docs/`. **TODO:** комментарий в StockMovement.cs + упоминание в `CLAUDE.md`.
|
||||
|
||||
---
|
||||
|
||||
## Свод по приоритетам
|
||||
|
||||
### Приоритет 1 — базовая совместимость импорта (на этой неделе):
|
||||
- Product: `Code`, `ExternalCode`, `TrackingType` (enum) вместо `IsMarked`, `MinPrice`/`MinPriceCurrencyId`, `PaymentItemType` (enum)
|
||||
- Counterparty: `CounterpartyType.IndividualEntrepreneur`, `ExternalCode`, tags (коллекция)
|
||||
- ProductGroup: `ExternalCode`
|
||||
- PriceType: `ExternalCode`
|
||||
- Country, Currency, UnitOfMeasure, Store: `ExternalCode`
|
||||
- RetailPoint: `DefaultPriceTypeId`
|
||||
|
||||
### Приоритет 2 — смысловые (следующая итерация):
|
||||
- RetailSale: `PaidQr`, убрать enum PaymentMethod в пользу суммовых полей
|
||||
- Supply: `Overhead`, `VatSum`, `VatEnabled`, `VatIncluded`
|
||||
- Organization: `LegalName`, `LegalAddress`, `PayerVat`, `DirectorName`
|
||||
- Product: `Volume, Weight, DiscountProhibited`
|
||||
- Stock: `InTransit`
|
||||
|
||||
### Приоритет 3 — при необходимости:
|
||||
- Counterparty: коллекция `Account`, `DefaultPriceType`
|
||||
- ProductGroup: `VatRateId?` + `UseParentVat`
|
||||
- RetailPoint: кассовые настройки (allowCustomPrice, discountMaxPercent, cashiers...)
|
||||
- Store: slots, zones
|
||||
|
||||
### Сознательно не копируем MS:
|
||||
- `CounterpartyKind` (Supplier/Customer/Both) — у нас enum, у MS теги. Оставляем для UX/фильтрации.
|
||||
- `Store.Kind` (Warehouse vs RetailFloor) — у MS нет, нам нужно.
|
||||
- `VatRate` как отдельная сущность — у MS число на товаре. У нас справочник ради локализации.
|
||||
- `StockMovement` journal — у MS нет. Выбор архитектуры.
|
||||
- `Product.IsWeighed` / `IsAlcohol` — упрощения под ритейл.
|
||||
- `UnitOfMeasure.Symbol`, `DecimalPlaces` — UX.
|
||||
100
docs/forgejo.md
100
docs/forgejo.md
|
|
@ -1,100 +0,0 @@
|
|||
# Forgejo как primary git
|
||||
|
||||
GitHub из KZ периодически роняет TCP (см. `network_github_flaky.md`). Чтобы push/pull не превращались в лотерею, на стейдж-сервере поднят Forgejo — self-hosted git-сервис (форк Gitea), он работает локально и не зависит от upstream-флапов. GitHub продолжает жить как **зеркало** (для видимости, CI-интеграций, бэкапа).
|
||||
|
||||
## Адреса
|
||||
|
||||
- **Web UI:** https://git.zat.kz (после certbot; до этого — http:// если DNS уже указан)
|
||||
- **Git HTTPS:** https://git.zat.kz/nns/food-market.git
|
||||
- **Git SSH:** `ssh://git@git.zat.kz:2222/nns/food-market.git`
|
||||
|
||||
SSH-порт 2222 (хостовой 22 занят системным sshd).
|
||||
|
||||
## Первый раз с Mac/iPhone
|
||||
|
||||
### 1. Добавить remote
|
||||
|
||||
В локальной копии `food-market`:
|
||||
|
||||
```bash
|
||||
# оставляем github как origin (привычно), добавляем forgejo как primary
|
||||
git remote add forgejo ssh://git@git.zat.kz:2222/nns/food-market.git
|
||||
|
||||
# либо делаем forgejo основным и github запасным:
|
||||
git remote rename origin github
|
||||
git remote add origin ssh://git@git.zat.kz:2222/nns/food-market.git
|
||||
git branch --set-upstream-to=origin/main main
|
||||
```
|
||||
|
||||
Клонировать с нуля:
|
||||
|
||||
```bash
|
||||
git clone ssh://git@git.zat.kz:2222/nns/food-market.git
|
||||
```
|
||||
|
||||
### 2. SSH-ключ
|
||||
|
||||
На Forgejo в `Settings → SSH/GPG Keys → Add Key` добавить публичный ключ (`~/.ssh/id_ed25519.pub` с Mac, либо через Working Copy на iPhone — Settings → Key Management → Generate/Export Public Key).
|
||||
|
||||
### 3. Обычный цикл
|
||||
|
||||
```bash
|
||||
git pull # (или git pull forgejo main)
|
||||
# ...работа...
|
||||
git commit -am "…"
|
||||
git push # мгновенно, внутри ДЦ
|
||||
```
|
||||
|
||||
## Как это связано с GitHub
|
||||
|
||||
- **push → Forgejo:** primary, мгновенный.
|
||||
- **Forgejo → GitHub** раз в 10 минут пушится автоматически сервисом `food-market-forgejo-mirror.timer`. Если GitHub недоступен — следующий тик повторит. Cкрипт: `/usr/local/bin/food-market-forgejo-mirror.sh`, лог `/var/log/food-market-forgejo-mirror.log`.
|
||||
- **CI:** GitHub Actions на self-hosted runner'е (уже настроено). Запускается от коммитов, пришедших через зеркало. Если когда-нибудь понадобится CI на Forgejo'ых Actions — docs/forgejo-actions.md (пока не настроено).
|
||||
|
||||
То есть рабочий флоу: пуш в Forgejo → через ≤10 мин коммит в GitHub → триггер CI → деплой.
|
||||
|
||||
## Эксплуатация
|
||||
|
||||
```bash
|
||||
# состояние
|
||||
sudo systemctl status food-market-forgejo.service # контейнер Forgejo
|
||||
sudo systemctl status food-market-forgejo-mirror.timer # расписание зеркала
|
||||
sudo systemctl status food-market-forgejo-mirror.service # последняя попытка зеркала
|
||||
tail -f /var/log/food-market-forgejo-mirror.log # живой лог зеркала
|
||||
|
||||
# прогнать зеркало прямо сейчас (не дожидаясь таймера)
|
||||
sudo systemctl start food-market-forgejo-mirror.service
|
||||
|
||||
# рестарт Forgejo (редко нужно)
|
||||
sudo systemctl restart food-market-forgejo.service
|
||||
```
|
||||
|
||||
## Раскладка
|
||||
|
||||
- docker-compose: `deploy/forgejo/docker-compose.yml` (образ `codeberg.org/forgejo/forgejo:7`, sqlite, SSH через OpenSSH образа)
|
||||
- systemd unit Forgejo: `/etc/systemd/system/food-market-forgejo.service` (copy в `deploy/forgejo/`)
|
||||
- mirror script: `/usr/local/bin/food-market-forgejo-mirror.sh` (copy в `deploy/forgejo/mirror-to-github.sh`)
|
||||
- mirror timer/service: `food-market-forgejo-mirror.{timer,service}` (copy в `deploy/forgejo/`)
|
||||
- nginx vhost: `/etc/nginx/conf.d/git.zat.kz.conf` (copy в `deploy/forgejo/nginx.conf`)
|
||||
- data: `/opt/food-market-data/forgejo/data` (sqlite + repos + ssh host keys)
|
||||
- конфиг Forgejo: `/opt/food-market-data/forgejo/data/gitea/conf/app.ini`
|
||||
- GitHub mirror token: `/etc/food-market/github-mirror-token` (PAT с `repo` scope, читает mirror-скрипт)
|
||||
- локальное зеркало для push в github: `/opt/food-market-data/forgejo/mirror` (bare repo)
|
||||
|
||||
## Что ещё нужно от вас (разовое)
|
||||
|
||||
1. **DNS A-запись** `git.zat.kz → 88.204.171.93` (основной IP сервера).
|
||||
2. После того как DNS прорастёт:
|
||||
```bash
|
||||
sudo certbot --nginx -d git.zat.kz
|
||||
```
|
||||
Certbot выпустит TLS-сертификат и обновит nginx-конфиг (добавит блок 443 + редирект 80→443).
|
||||
3. Записать пароль администратора: файл `/tmp/forgejo-admin.txt` (создан при первой установке, надо скопировать себе в хранилище паролей и удалить с сервера).
|
||||
|
||||
## Обратный путь
|
||||
|
||||
Если Forgejo сломается и нужно срочно пушить напрямую в GitHub:
|
||||
```bash
|
||||
git push github main
|
||||
```
|
||||
GitHub — полная копия (mirror-таймер гонит всё: branches + tags). Рабочий флоу не ломается.
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
# Telegram ↔ tmux bridge
|
||||
|
||||
Управление локальной сессией Claude Code с телефона через Telegram-бота. Входящее сообщение от whitelisted `chat_id` набирается в tmux-сессию `claude` как будто вы сами печатаете; ответный вывод пайнa каждые ~2.5 с отправляется обратно в чат.
|
||||
|
||||
## Как это выглядит в работе
|
||||
|
||||
- Вы пишете боту: `запусти тесты`
|
||||
- Бот делает `tmux send-keys -t claude -l "запусти тесты" && tmux send-keys -t claude Enter` — текст попадает в поле ввода Claude
|
||||
- Фоновый поллер раз в 2.5 с снимает `tmux capture-pane`, сравнивает с предыдущим снапшотом, присылает новые строки как `<pre>…</pre>`-блок
|
||||
|
||||
Команды бота:
|
||||
- `/ping` — живой ли, какая сессия и интервал
|
||||
- `/snapshot` — выслать полный текущий пайн (полезно после длинного молчания или после рестарта)
|
||||
|
||||
## Один раз — настройка
|
||||
|
||||
### 1. Креды
|
||||
|
||||
Положите в `/etc/food-market/telegram.env`:
|
||||
```
|
||||
TELEGRAM_BOT_TOKEN=<токен от @BotFather>
|
||||
TELEGRAM_CHAT_ID=<ваш личный chat_id, целое число>
|
||||
```
|
||||
|
||||
Узнать `chat_id` — напишите `@userinfobot` в Telegram, он ответит с вашим id. Файл доступен только владельцу (`chmod 600`).
|
||||
|
||||
Только сообщения от этого **одного** chat_id будут обработаны — всё остальное молча игнорируется.
|
||||
|
||||
### 2. tmux-сессия `claude`
|
||||
|
||||
Бот ожидает существующую сессию с именем `claude`. Создайте её как обычно:
|
||||
```bash
|
||||
tmux new-session -d -s claude
|
||||
tmux attach -t claude # и запустите внутри `claude` (или что там у вас)
|
||||
```
|
||||
Сервис стартует даже без сессии — в лог упадёт warning, но `send-keys` / `capture-pane` начнут работать как только сессия появится. Имя сессии можно переопределить через env `TMUX_SESSION=other` в юните.
|
||||
|
||||
### 3. Старт сервиса
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now food-market-telegram-bridge.service
|
||||
```
|
||||
|
||||
В ответ бот пришлёт `✅ bridge up …` — это индикатор успеха.
|
||||
|
||||
## Эксплуатация
|
||||
|
||||
### Логи
|
||||
```bash
|
||||
sudo journalctl -u food-market-telegram-bridge.service -f
|
||||
sudo journalctl -u food-market-telegram-bridge.service --since '10 min ago'
|
||||
```
|
||||
|
||||
### Перезапуск
|
||||
```bash
|
||||
sudo systemctl restart food-market-telegram-bridge.service
|
||||
```
|
||||
|
||||
### Остановить
|
||||
```bash
|
||||
sudo systemctl stop food-market-telegram-bridge.service # до ребута
|
||||
sudo systemctl disable food-market-telegram-bridge.service # и после ребута
|
||||
```
|
||||
|
||||
### Поменять интервал/сессию
|
||||
Отредактируйте `/etc/systemd/system/food-market-telegram-bridge.service`, добавьте в секцию `[Service]`:
|
||||
```
|
||||
Environment=POLL_INTERVAL_SEC=1.5
|
||||
Environment=TMUX_SESSION=other-session
|
||||
Environment=CAPTURE_HISTORY_LINES=400
|
||||
```
|
||||
Затем `sudo systemctl daemon-reload && sudo systemctl restart food-market-telegram-bridge`.
|
||||
|
||||
## Раскладка
|
||||
|
||||
- Скрипт: `/opt/food-market-data/telegram-bridge/bridge.py`
|
||||
- venv (Python 3.12, `python-telegram-bot 21.x`): `/opt/food-market-data/telegram-bridge/venv/`
|
||||
- Креды: `/etc/food-market/telegram.env` (owner `nns`, mode `0600`)
|
||||
- systemd unit: `/etc/systemd/system/food-market-telegram-bridge.service`
|
||||
|
||||
## Что хорошо знать
|
||||
|
||||
- `disable_notification=True` стоит на фоновых сообщениях пайна — не будет жужжать при каждом diff'e.
|
||||
- Telegram-лимит 4096 символов; длинные пайн-блоки режутся на куски по ~3800 символов.
|
||||
- Если после долгого молчания в чате слишком много истории, шлите `/snapshot` — бот обнуляет baseline и присылает текущий экран целиком.
|
||||
- Бот заходит в Telegram long-polling (исходящее к api.telegram.org, без входящих портов) — никакого проброса портов не нужно.
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace foodmarket.Api.Controllers.Admin;
|
||||
|
||||
// Временные эндпоинты для очистки данных после кривых импортов.
|
||||
// Удалять только свой tenant — query-filter на DbSets это обеспечивает.
|
||||
[ApiController]
|
||||
[Authorize(Policy = "AdminAccess")]
|
||||
[Route("api/admin/cleanup")]
|
||||
public class AdminCleanupController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AdminCleanupController(AppDbContext db) => _db = db;
|
||||
|
||||
public record CleanupStats(
|
||||
int Counterparties,
|
||||
int Products,
|
||||
int ProductGroups,
|
||||
int ProductBarcodes,
|
||||
int ProductPrices,
|
||||
int Supplies,
|
||||
int RetailSales,
|
||||
int Stocks,
|
||||
int StockMovements);
|
||||
|
||||
public record CleanupResult(string Scope, CleanupStats Deleted);
|
||||
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult<CleanupStats>> GetStats(CancellationToken ct)
|
||||
=> new CleanupStats(
|
||||
await _db.Counterparties.CountAsync(ct),
|
||||
await _db.Products.CountAsync(ct),
|
||||
await _db.ProductGroups.CountAsync(ct),
|
||||
await _db.ProductBarcodes.CountAsync(ct),
|
||||
await _db.ProductPrices.CountAsync(ct),
|
||||
await _db.Supplies.CountAsync(ct),
|
||||
await _db.RetailSales.CountAsync(ct),
|
||||
await _db.Stocks.CountAsync(ct),
|
||||
await _db.StockMovements.CountAsync(ct));
|
||||
|
||||
/// <summary>Удалить всех контрагентов текущей организации. Чтобы не нарваться на FK,
|
||||
/// сначала обнуляем ссылки (Product.DefaultSupplier, RetailSale.Customer) и сносим
|
||||
/// поставки (они жёстко ссылаются на supplier).</summary>
|
||||
[HttpDelete("counterparties")]
|
||||
public async Task<ActionResult<CleanupResult>> WipeCounterparties(CancellationToken ct)
|
||||
{
|
||||
var before = await SnapshotAsync(ct);
|
||||
|
||||
// 1. Обнуляем nullable-FK
|
||||
await _db.Products
|
||||
.Where(p => p.DefaultSupplierId != null)
|
||||
.ExecuteUpdateAsync(u => u.SetProperty(p => p.DefaultSupplierId, (Guid?)null), ct);
|
||||
await _db.RetailSales
|
||||
.Where(s => s.CustomerId != null)
|
||||
.ExecuteUpdateAsync(u => u.SetProperty(s => s.CustomerId, (Guid?)null), ct);
|
||||
|
||||
// 2. Сносим поставки (NOT NULL supplier) + их stock movements/stocks
|
||||
await _db.StockMovements
|
||||
.Where(m => m.DocumentType == "supply" || m.DocumentType == "supply-reversal")
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await _db.SupplyLines.ExecuteDeleteAsync(ct);
|
||||
await _db.Supplies.ExecuteDeleteAsync(ct);
|
||||
|
||||
// 3. Контрагенты
|
||||
await _db.Counterparties.ExecuteDeleteAsync(ct);
|
||||
|
||||
var after = await SnapshotAsync(ct);
|
||||
return new CleanupResult("counterparties", Diff(before, after));
|
||||
}
|
||||
|
||||
/// <summary>Полная очистка данных текущей организации — всё кроме настроек:
|
||||
/// остаются Organization, пользователи, справочники (Country, Currency, UnitOfMeasure,
|
||||
/// PriceType), Store и RetailPoint. Удаляются: Product*, ProductGroup, Counterparty,
|
||||
/// Supply*, RetailSale*, Stock, StockMovement.</summary>
|
||||
[HttpDelete("all")]
|
||||
public async Task<ActionResult<CleanupResult>> WipeAll(CancellationToken ct)
|
||||
{
|
||||
var before = await SnapshotAsync(ct);
|
||||
|
||||
// Documents first — they reference products, counterparties, stores.
|
||||
await _db.StockMovements.ExecuteDeleteAsync(ct);
|
||||
await _db.Stocks.ExecuteDeleteAsync(ct);
|
||||
|
||||
await _db.SupplyLines.ExecuteDeleteAsync(ct);
|
||||
await _db.Supplies.ExecuteDeleteAsync(ct);
|
||||
|
||||
await _db.RetailSaleLines.ExecuteDeleteAsync(ct);
|
||||
await _db.RetailSales.ExecuteDeleteAsync(ct);
|
||||
|
||||
// Product composites.
|
||||
await _db.ProductImages.ExecuteDeleteAsync(ct);
|
||||
await _db.ProductPrices.ExecuteDeleteAsync(ct);
|
||||
await _db.ProductBarcodes.ExecuteDeleteAsync(ct);
|
||||
|
||||
// Products reference counterparty.DefaultSupplier — FK Restrict, but we're about
|
||||
// to delete products anyway, so order products → counterparties.
|
||||
await _db.Products.ExecuteDeleteAsync(ct);
|
||||
await _db.ProductGroups.ExecuteDeleteAsync(ct);
|
||||
await _db.Counterparties.ExecuteDeleteAsync(ct);
|
||||
|
||||
var after = await SnapshotAsync(ct);
|
||||
return new CleanupResult("all", Diff(before, after));
|
||||
}
|
||||
|
||||
private async Task<CleanupStats> SnapshotAsync(CancellationToken ct) => new(
|
||||
await _db.Counterparties.CountAsync(ct),
|
||||
await _db.Products.CountAsync(ct),
|
||||
await _db.ProductGroups.CountAsync(ct),
|
||||
await _db.ProductBarcodes.CountAsync(ct),
|
||||
await _db.ProductPrices.CountAsync(ct),
|
||||
await _db.Supplies.CountAsync(ct),
|
||||
await _db.RetailSales.CountAsync(ct),
|
||||
await _db.Stocks.CountAsync(ct),
|
||||
await _db.StockMovements.CountAsync(ct));
|
||||
|
||||
private static CleanupStats Diff(CleanupStats a, CleanupStats b) => new(
|
||||
a.Counterparties - b.Counterparties,
|
||||
a.Products - b.Products,
|
||||
a.ProductGroups - b.ProductGroups,
|
||||
a.ProductBarcodes - b.ProductBarcodes,
|
||||
a.ProductPrices - b.ProductPrices,
|
||||
a.Supplies - b.Supplies,
|
||||
a.RetailSales - b.RetailSales,
|
||||
a.Stocks - b.Stocks,
|
||||
a.StockMovements - b.StockMovements);
|
||||
}
|
||||
|
|
@ -20,9 +20,14 @@ public class CounterpartiesController : ControllerBase
|
|||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<CounterpartyDto>>> List(
|
||||
[FromQuery] PagedRequest req,
|
||||
[FromQuery] CounterpartyKind? kind,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var q = _db.Counterparties.Include(c => c.Country).AsNoTracking().AsQueryable();
|
||||
if (kind is not null)
|
||||
{
|
||||
q = q.Where(c => c.Kind == kind || c.Kind == CounterpartyKind.Both);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||
{
|
||||
var s = req.Search.Trim().ToLower();
|
||||
|
|
@ -38,7 +43,7 @@ public class CounterpartiesController : ControllerBase
|
|||
.OrderBy(c => c.Name)
|
||||
.Skip(req.Skip).Take(req.Take)
|
||||
.Select(c => new CounterpartyDto(
|
||||
c.Id, c.Name, c.LegalName, c.Type,
|
||||
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
|
||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null,
|
||||
c.Address, c.Phone, c.Email,
|
||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive))
|
||||
|
|
@ -51,7 +56,7 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
|
|||
{
|
||||
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
return c is null ? NotFound() : new CounterpartyDto(
|
||||
c.Id, c.Name, c.LegalName, c.Type,
|
||||
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
|
||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
||||
c.Address, c.Phone, c.Email,
|
||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive);
|
||||
|
|
@ -90,6 +95,7 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i)
|
|||
{
|
||||
e.Name = i.Name;
|
||||
e.LegalName = i.LegalName;
|
||||
e.Kind = i.Kind;
|
||||
e.Type = i.Type;
|
||||
e.Bin = i.Bin;
|
||||
e.Iin = i.Iin;
|
||||
|
|
@ -111,7 +117,7 @@ private async Task<CounterpartyDto> ProjectAsync(Guid id, CancellationToken ct)
|
|||
{
|
||||
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstAsync(x => x.Id == id, ct);
|
||||
return new CounterpartyDto(
|
||||
c.Id, c.Name, c.LegalName, c.Type,
|
||||
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
|
||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
||||
c.Address, c.Phone, c.Email,
|
||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive);
|
||||
|
|
|
|||
|
|
@ -23,33 +23,14 @@ public class ProductsController : ControllerBase
|
|||
[FromQuery] Guid? groupId,
|
||||
[FromQuery] bool? isService,
|
||||
[FromQuery] bool? isWeighed,
|
||||
[FromQuery] bool? isMarked,
|
||||
[FromQuery] bool? isActive,
|
||||
[FromQuery] bool? hasBarcode,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var q = QueryIncludes().AsNoTracking();
|
||||
if (groupId is not null)
|
||||
{
|
||||
// Include the whole subtree: match on the group's Path prefix so sub-groups also show up.
|
||||
var root = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(g => g.Id == groupId, ct);
|
||||
if (root is not null)
|
||||
{
|
||||
var prefix = root.Path;
|
||||
q = q.Where(p => p.ProductGroup != null &&
|
||||
(p.ProductGroup.Path == prefix || p.ProductGroup.Path.StartsWith(prefix + "/")));
|
||||
}
|
||||
else
|
||||
{
|
||||
q = q.Where(p => p.ProductGroupId == groupId);
|
||||
}
|
||||
}
|
||||
if (groupId is not null) q = q.Where(p => p.ProductGroupId == groupId);
|
||||
if (isService is not null) q = q.Where(p => p.IsService == isService);
|
||||
if (isWeighed is not null) q = q.Where(p => p.IsWeighed == isWeighed);
|
||||
if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked);
|
||||
if (isActive is not null) q = q.Where(p => p.IsActive == isActive);
|
||||
if (hasBarcode is not null)
|
||||
q = hasBarcode == true ? q.Where(p => p.Barcodes.Any()) : q.Where(p => !p.Barcodes.Any());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||
{
|
||||
|
|
@ -130,6 +111,7 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
|||
|
||||
private IQueryable<Product> QueryIncludes() => _db.Products
|
||||
.Include(p => p.UnitOfMeasure)
|
||||
.Include(p => p.VatRate)
|
||||
.Include(p => p.ProductGroup)
|
||||
.Include(p => p.DefaultSupplier)
|
||||
.Include(p => p.CountryOfOrigin)
|
||||
|
|
@ -144,12 +126,12 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
|||
private static readonly System.Linq.Expressions.Expression<Func<Product, ProductDto>> Projection = p =>
|
||||
new ProductDto(
|
||||
p.Id, p.Name, p.Article, p.Description,
|
||||
p.UnitOfMeasureId, p.UnitOfMeasure!.Name,
|
||||
p.Vat, p.VatEnabled,
|
||||
p.UnitOfMeasureId, p.UnitOfMeasure!.Symbol,
|
||||
p.VatRateId, p.VatRate!.Percent,
|
||||
p.ProductGroupId, p.ProductGroup != null ? p.ProductGroup.Name : null,
|
||||
p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null,
|
||||
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
|
||||
p.IsService, p.IsWeighed, p.IsMarked,
|
||||
p.IsService, p.IsWeighed, p.IsAlcohol, p.IsMarked,
|
||||
p.MinStock, p.MaxStock,
|
||||
p.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
|
||||
p.ImageUrl, p.IsActive,
|
||||
|
|
@ -162,13 +144,13 @@ private static void Apply(Product e, ProductInput i)
|
|||
e.Article = i.Article;
|
||||
e.Description = i.Description;
|
||||
e.UnitOfMeasureId = i.UnitOfMeasureId;
|
||||
e.Vat = i.Vat;
|
||||
e.VatEnabled = i.VatEnabled;
|
||||
e.VatRateId = i.VatRateId;
|
||||
e.ProductGroupId = i.ProductGroupId;
|
||||
e.DefaultSupplierId = i.DefaultSupplierId;
|
||||
e.CountryOfOriginId = i.CountryOfOriginId;
|
||||
e.IsService = i.IsService;
|
||||
e.IsWeighed = i.IsWeighed;
|
||||
e.IsAlcohol = i.IsAlcohol;
|
||||
e.IsMarked = i.IsMarked;
|
||||
e.MinStock = i.MinStock;
|
||||
e.MaxStock = i.MaxStock;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ public async Task<ActionResult<PagedResult<StoreDto>>> List([FromQuery] PagedReq
|
|||
var items = await q
|
||||
.OrderByDescending(x => x.IsMain).ThenBy(x => x.Name)
|
||||
.Skip(req.Skip).Take(req.Take)
|
||||
.Select(x => new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive))
|
||||
.Select(x => new StoreDto(x.Id, x.Name, x.Code, x.Kind, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<StoreDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ public async Task<ActionResult<PagedResult<StoreDto>>> List([FromQuery] PagedReq
|
|||
public async Task<ActionResult<StoreDto>> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
var x = await _db.Stores.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
|
||||
return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Kind, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||
|
|
@ -51,13 +51,13 @@ public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, Ca
|
|||
}
|
||||
var e = new Store
|
||||
{
|
||||
Name = input.Name, Code = input.Code,Address = input.Address,
|
||||
Name = input.Name, Code = input.Code, Kind = input.Kind, Address = input.Address,
|
||||
Phone = input.Phone, ManagerName = input.ManagerName, IsMain = input.IsMain, IsActive = input.IsActive,
|
||||
};
|
||||
_db.Stores.Add(e);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||
new StoreDto(e.Id, e.Name, e.Code, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
|
||||
new StoreDto(e.Id, e.Name, e.Code, e.Kind, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||
|
|
@ -71,7 +71,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] StoreInput input, Ca
|
|||
}
|
||||
e.Name = input.Name;
|
||||
e.Code = input.Code;
|
||||
|
||||
e.Kind = input.Kind;
|
||||
e.Address = input.Address;
|
||||
e.Phone = input.Phone;
|
||||
e.ManagerName = input.ManagerName;
|
||||
|
|
|
|||
|
|
@ -24,13 +24,13 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
|
|||
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||
{
|
||||
var s = req.Search.Trim().ToLower();
|
||||
q = q.Where(u => u.Name.ToLower().Contains(s) || u.Code.ToLower().Contains(s));
|
||||
q = q.Where(u => u.Name.ToLower().Contains(s) || u.Symbol.ToLower().Contains(s) || u.Code.ToLower().Contains(s));
|
||||
}
|
||||
var total = await q.CountAsync(ct);
|
||||
var items = await q
|
||||
.OrderBy(u => u.Name)
|
||||
.OrderByDescending(u => u.IsBase).ThenBy(u => u.Name)
|
||||
.Skip(req.Skip).Take(req.Take)
|
||||
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.IsActive))
|
||||
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Symbol, u.Name, u.DecimalPlaces, u.IsBase, u.IsActive))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
|
@ -39,23 +39,30 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
|
|||
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.IsActive);
|
||||
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Symbol, u.Name, u.DecimalPlaces, u.IsBase, u.IsActive);
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||
public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct)
|
||||
{
|
||||
if (input.IsBase)
|
||||
{
|
||||
await _db.UnitsOfMeasure.Where(u => u.IsBase).ExecuteUpdateAsync(s => s.SetProperty(u => u.IsBase, false), ct);
|
||||
}
|
||||
|
||||
var e = new UnitOfMeasure
|
||||
{
|
||||
Code = input.Code,
|
||||
Symbol = input.Symbol,
|
||||
Name = input.Name,
|
||||
Description = input.Description,
|
||||
DecimalPlaces = input.DecimalPlaces,
|
||||
IsBase = input.IsBase,
|
||||
IsActive = input.IsActive,
|
||||
};
|
||||
_db.UnitsOfMeasure.Add(e);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.IsActive));
|
||||
new UnitOfMeasureDto(e.Id, e.Code, e.Symbol, e.Name, e.DecimalPlaces, e.IsBase, e.IsActive));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||
|
|
@ -64,9 +71,16 @@ public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput i
|
|||
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (e is null) return NotFound();
|
||||
|
||||
if (input.IsBase && !e.IsBase)
|
||||
{
|
||||
await _db.UnitsOfMeasure.Where(u => u.IsBase && u.Id != id).ExecuteUpdateAsync(s => s.SetProperty(u => u.IsBase, false), ct);
|
||||
}
|
||||
|
||||
e.Code = input.Code;
|
||||
e.Symbol = input.Symbol;
|
||||
e.Name = input.Name;
|
||||
e.Description = input.Description;
|
||||
e.DecimalPlaces = input.DecimalPlaces;
|
||||
e.IsBase = input.IsBase;
|
||||
e.IsActive = input.IsActive;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
|
|
|
|||
101
src/food-market.api/Controllers/Catalog/VatRatesController.cs
Normal file
101
src/food-market.api/Controllers/Catalog/VatRatesController.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
using foodmarket.Application.Catalog;
|
||||
using foodmarket.Application.Common;
|
||||
using foodmarket.Domain.Catalog;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace foodmarket.Api.Controllers.Catalog;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/catalog/vat-rates")]
|
||||
public class VatRatesController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public VatRatesController(AppDbContext db) => _db = db;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<VatRateDto>>> List([FromQuery] PagedRequest req, CancellationToken ct)
|
||||
{
|
||||
var q = _db.VatRates.AsNoTracking().AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||
{
|
||||
var s = req.Search.Trim().ToLower();
|
||||
q = q.Where(v => v.Name.ToLower().Contains(s));
|
||||
}
|
||||
var total = await q.CountAsync(ct);
|
||||
var items = await q
|
||||
.OrderByDescending(v => v.IsDefault).ThenBy(v => v.Percent)
|
||||
.Skip(req.Skip).Take(req.Take)
|
||||
.Select(v => new VatRateDto(v.Id, v.Name, v.Percent, v.IsIncludedInPrice, v.IsDefault, v.IsActive))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<VatRateDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<VatRateDto>> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
var v = await _db.VatRates.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
return v is null ? NotFound() : new VatRateDto(v.Id, v.Name, v.Percent, v.IsIncludedInPrice, v.IsDefault, v.IsActive);
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||
public async Task<ActionResult<VatRateDto>> Create([FromBody] VatRateInput input, CancellationToken ct)
|
||||
{
|
||||
if (input.IsDefault)
|
||||
{
|
||||
await ResetDefaultsAsync(ct);
|
||||
}
|
||||
|
||||
var e = new VatRate
|
||||
{
|
||||
Name = input.Name,
|
||||
Percent = input.Percent,
|
||||
IsIncludedInPrice = input.IsIncludedInPrice,
|
||||
IsDefault = input.IsDefault,
|
||||
IsActive = input.IsActive,
|
||||
};
|
||||
_db.VatRates.Add(e);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||
new VatRateDto(e.Id, e.Name, e.Percent, e.IsIncludedInPrice, e.IsDefault, e.IsActive));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] VatRateInput input, CancellationToken ct)
|
||||
{
|
||||
var e = await _db.VatRates.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (e is null) return NotFound();
|
||||
|
||||
if (input.IsDefault && !e.IsDefault)
|
||||
{
|
||||
await ResetDefaultsAsync(ct);
|
||||
}
|
||||
|
||||
e.Name = input.Name;
|
||||
e.Percent = input.Percent;
|
||||
e.IsIncludedInPrice = input.IsIncludedInPrice;
|
||||
e.IsDefault = input.IsDefault;
|
||||
e.IsActive = input.IsActive;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var e = await _db.VatRates.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (e is null) return NotFound();
|
||||
_db.VatRates.Remove(e);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private async Task ResetDefaultsAsync(CancellationToken ct)
|
||||
{
|
||||
await _db.VatRates.Where(v => v.IsDefault).ExecuteUpdateAsync(s => s.SetProperty(v => v.IsDefault, false), ct);
|
||||
}
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ public record StockRow(
|
|||
.OrderBy(x => x.p.Name)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(x => new StockRow(
|
||||
x.p.Id, x.p.Name, x.p.Article, x.u.Name,
|
||||
x.p.Id, x.p.Name, x.p.Article, x.u.Symbol,
|
||||
x.st.Id, x.st.Name,
|
||||
x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
|
||||
.ToListAsync(ct);
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
|
|||
where l.SupplyId == id
|
||||
orderby l.SortOrder
|
||||
select new SupplyLineDto(
|
||||
l.Id, l.ProductId, p.Name, p.Article, u.Name,
|
||||
l.Id, l.ProductId, p.Name, p.Article, u.Symbol,
|
||||
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder))
|
||||
.ToListAsync(ct);
|
||||
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
|
|||
where l.RetailSaleId == id
|
||||
orderby l.SortOrder
|
||||
select new RetailSaleLineDto(
|
||||
l.Id, l.ProductId, p.Name, p.Article, u.Name,
|
||||
l.Id, l.ProductId, p.Name, p.Article, u.Symbol,
|
||||
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder))
|
||||
.ToListAsync(ct);
|
||||
|
||||
|
|
|
|||
|
|
@ -32,18 +32,21 @@ public async Task StartAsync(CancellationToken ct)
|
|||
var hasProducts = await db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
|
||||
if (hasProducts) return;
|
||||
|
||||
// KZ default VAT is 16% (applies as int on Product).
|
||||
const int vatDefault = 16;
|
||||
const int vat0 = 0;
|
||||
var defaultVat = await db.VatRates.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.IsDefault, ct);
|
||||
var noVat = await db.VatRates.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.Percent == 0m, ct);
|
||||
|
||||
var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "796", ct);
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "шт", ct);
|
||||
var unitKg = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "166", ct);
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "кг", ct);
|
||||
var unitL = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "112", ct);
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "л", ct);
|
||||
|
||||
if (unitSht is null) return;
|
||||
if (defaultVat is null || unitSht is null) return;
|
||||
var vat = defaultVat.Id;
|
||||
var vat0 = noVat?.Id ?? vat;
|
||||
|
||||
var retailPriceType = await db.PriceTypes.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(p => p.OrganizationId == orgId && p.IsRetail, ct);
|
||||
|
|
@ -85,7 +88,7 @@ Guid AddGroup(string name, Guid? parentId)
|
|||
var supplier1 = new Counterparty
|
||||
{
|
||||
OrganizationId = orgId, Name = "ТОО «Продтрейд»", LegalName = "Товарищество с ограниченной ответственностью «Продтрейд»",
|
||||
Type = CounterpartyType.LegalEntity,
|
||||
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.LegalEntity,
|
||||
Bin = "100140005678", CountryId = kz?.Id,
|
||||
Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01",
|
||||
Email = "order@prodtrade.kz", BankName = "Kaspi Bank", BankAccount = "KZ000000000000000001", Bik = "CASPKZKA",
|
||||
|
|
@ -94,7 +97,7 @@ Guid AddGroup(string name, Guid? parentId)
|
|||
var supplier2 = new Counterparty
|
||||
{
|
||||
OrganizationId = orgId, Name = "ИП Иванов А.С.",
|
||||
Type = CounterpartyType.Individual,
|
||||
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.Individual,
|
||||
Iin = "850101300000", CountryId = kz?.Id,
|
||||
Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей",
|
||||
IsActive = true,
|
||||
|
|
@ -103,49 +106,49 @@ Guid AddGroup(string name, Guid? parentId)
|
|||
|
||||
// Barcodes use generic internal range (2xxxxxxxxxxxx) to avoid colliding with real products.
|
||||
// When user does real приёмка, real barcodes will overwrite.
|
||||
var demo = new (string Name, Guid Group, decimal RetailPrice, string Article, string Barcode, Guid? Country, bool IsWeighed, Guid Unit)[]
|
||||
var demo = new (string Name, Guid Group, decimal RetailPrice, string Article, string Barcode, Guid? Country, bool IsWeighed, Guid Unit, bool IsAlcohol)[]
|
||||
{
|
||||
// Напитки — безалкогольные
|
||||
("Вода питьевая «Тассай» 0.5л", gDrinksNon, 220, "DR-WAT-001", "2000000000018", kz?.Id, false, unitSht.Id),
|
||||
("Вода питьевая «Тассай» 1.5л", gDrinksNon, 380, "DR-WAT-002", "2000000000025", kz?.Id, false, unitSht.Id),
|
||||
("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id),
|
||||
("Сок «Да-Да» апельсин 1л", gDrinksNon, 650, "DR-JUC-002", "2000000000049", kz?.Id, false, unitSht.Id),
|
||||
("Кока-кола 0.5л", gDrinksNon, 420, "DR-SOD-001", "2000000000056", ru?.Id, false, unitSht.Id),
|
||||
("Пепси 0.5л", gDrinksNon, 420, "DR-SOD-002", "2000000000063", ru?.Id, false, unitSht.Id),
|
||||
("Спрайт 0.5л", gDrinksNon, 420, "DR-SOD-003", "2000000000070", ru?.Id, false, unitSht.Id),
|
||||
("Чай чёрный «Пиала» пакетированный, 100шт", gDrinksNon, 1250, "DR-TEA-001", "2000000000087", kz?.Id, false, unitSht.Id),
|
||||
("Вода питьевая «Тассай» 0.5л", gDrinksNon, 220, "DR-WAT-001", "2000000000018", kz?.Id, false, unitSht.Id, false),
|
||||
("Вода питьевая «Тассай» 1.5л", gDrinksNon, 380, "DR-WAT-002", "2000000000025", kz?.Id, false, unitSht.Id, false),
|
||||
("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id, false),
|
||||
("Сок «Да-Да» апельсин 1л", gDrinksNon, 650, "DR-JUC-002", "2000000000049", kz?.Id, false, unitSht.Id, false),
|
||||
("Кока-кола 0.5л", gDrinksNon, 420, "DR-SOD-001", "2000000000056", ru?.Id, false, unitSht.Id, false),
|
||||
("Пепси 0.5л", gDrinksNon, 420, "DR-SOD-002", "2000000000063", ru?.Id, false, unitSht.Id, false),
|
||||
("Спрайт 0.5л", gDrinksNon, 420, "DR-SOD-003", "2000000000070", ru?.Id, false, unitSht.Id, false),
|
||||
("Чай чёрный «Пиала» пакетированный, 100шт", gDrinksNon, 1250, "DR-TEA-001", "2000000000087", kz?.Id, false, unitSht.Id, false),
|
||||
// Молочные
|
||||
("Молоко «Простоквашино» 2.5% 930мл", gDairy, 720, "DAI-MLK-001", "2000000000094", ru?.Id, false, unitSht.Id),
|
||||
("Молоко «Food Master» 3.2% 1л", gDairy, 620, "DAI-MLK-002", "2000000000100", kz?.Id, false, unitSht.Id),
|
||||
("Кефир «Food Master» 2.5% 1л", gDairy, 590, "DAI-KEF-001", "2000000000117", kz?.Id, false, unitSht.Id),
|
||||
("Йогурт «Актимель» клубника 100мл", gDairy, 180, "DAI-YOG-001", "2000000000124", null, false, unitSht.Id),
|
||||
("Творог «Простоквашино» 5% 200г", gDairy, 590, "DAI-CRD-001", "2000000000131", ru?.Id, false, unitSht.Id),
|
||||
("Сметана «Food Master» 20% 400г", gDairy, 850, "DAI-SCR-001", "2000000000148", kz?.Id, false, unitSht.Id),
|
||||
("Сыр «Российский» 45%", gDairy, 3200, "DAI-CHE-001", "2000000000155", kz?.Id, true, unitKg?.Id ?? unitSht.Id),
|
||||
("Молоко «Простоквашино» 2.5% 930мл", gDairy, 720, "DAI-MLK-001", "2000000000094", ru?.Id, false, unitSht.Id, false),
|
||||
("Молоко «Food Master» 3.2% 1л", gDairy, 620, "DAI-MLK-002", "2000000000100", kz?.Id, false, unitSht.Id, false),
|
||||
("Кефир «Food Master» 2.5% 1л", gDairy, 590, "DAI-KEF-001", "2000000000117", kz?.Id, false, unitSht.Id, false),
|
||||
("Йогурт «Актимель» клубника 100мл", gDairy, 180, "DAI-YOG-001", "2000000000124", null, false, unitSht.Id, false),
|
||||
("Творог «Простоквашино» 5% 200г", gDairy, 590, "DAI-CRD-001", "2000000000131", ru?.Id, false, unitSht.Id, false),
|
||||
("Сметана «Food Master» 20% 400г", gDairy, 850, "DAI-SCR-001", "2000000000148", kz?.Id, false, unitSht.Id, false),
|
||||
("Сыр «Российский» 45%", gDairy, 3200, "DAI-CHE-001", "2000000000155", kz?.Id, true, unitKg?.Id ?? unitSht.Id, false),
|
||||
// Хлеб и выпечка
|
||||
("Хлеб «Семейный» нарезной 500г", gBakery, 180, "BAK-BRD-001", "2000000000162", kz?.Id, false, unitSht.Id),
|
||||
("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id),
|
||||
("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id),
|
||||
("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", kz?.Id, false, unitSht.Id),
|
||||
("Хлеб «Семейный» нарезной 500г", gBakery, 180, "BAK-BRD-001", "2000000000162", kz?.Id, false, unitSht.Id, false),
|
||||
("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id, false),
|
||||
("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id, false),
|
||||
("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", kz?.Id, false, unitSht.Id, false),
|
||||
// Кондитерские
|
||||
("Шоколад «Рахат» молочный 100г", gSweets, 650, "SW-CHO-001", "2000000000209", kz?.Id, false, unitSht.Id),
|
||||
("Шоколад «Алёнка» 100г", gSweets, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id),
|
||||
("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id),
|
||||
("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id),
|
||||
("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", kz?.Id, false, unitSht.Id),
|
||||
("Шоколад «Рахат» молочный 100г", gSweets, 650, "SW-CHO-001", "2000000000209", kz?.Id, false, unitSht.Id, false),
|
||||
("Шоколад «Алёнка» 100г", gSweets, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id, false),
|
||||
("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id, false),
|
||||
("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id, false),
|
||||
("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", kz?.Id, false, unitSht.Id, false),
|
||||
// Бакалея
|
||||
("Сахар-песок 1кг", gGrocery, 480, "GRO-SUG-001", "2000000000254", kz?.Id, false, unitSht.Id),
|
||||
("Соль поваренная 1кг", gGrocery, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id),
|
||||
("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id),
|
||||
("Гречка «Мистраль» 900г", gGrocery, 990, "GRO-GRE-001", "2000000000285", ru?.Id, false, unitSht.Id),
|
||||
("Макароны «Corona» 500г", gGrocery, 420, "GRO-PAS-001", "2000000000292", kz?.Id, false, unitSht.Id),
|
||||
("Масло подсолнечное «Шедевр» 1л", gGrocery, 920, "GRO-OIL-001", "2000000000308", kz?.Id, false, unitL?.Id ?? unitSht.Id),
|
||||
("Кофе «Якобс Монарх» 95г растворимый", gGrocery, 2890, "GRO-COF-001", "2000000000315", null, false, unitSht.Id),
|
||||
("Сахар-песок 1кг", gGrocery, 480, "GRO-SUG-001", "2000000000254", kz?.Id, false, unitSht.Id, false),
|
||||
("Соль поваренная 1кг", gGrocery, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id, false),
|
||||
("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id, false),
|
||||
("Гречка «Мистраль» 900г", gGrocery, 990, "GRO-GRE-001", "2000000000285", ru?.Id, false, unitSht.Id, false),
|
||||
("Макароны «Corona» 500г", gGrocery, 420, "GRO-PAS-001", "2000000000292", kz?.Id, false, unitSht.Id, false),
|
||||
("Масло подсолнечное «Шедевр» 1л", gGrocery, 920, "GRO-OIL-001", "2000000000308", kz?.Id, false, unitL?.Id ?? unitSht.Id, false),
|
||||
("Кофе «Якобс Монарх» 95г растворимый", gGrocery, 2890, "GRO-COF-001", "2000000000315", null, false, unitSht.Id, false),
|
||||
// Снеки
|
||||
("Чипсы «Lays» сметана-лук 80г", gSnacks, 380, "SN-CHP-001", "2000000000322", kz?.Id, false, unitSht.Id),
|
||||
("Сухарики «3 корочки» ржаные 40г", gSnacks, 190, "SN-SBR-001", "2000000000339", ru?.Id, false, unitSht.Id),
|
||||
("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id),
|
||||
("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id),
|
||||
("Чипсы «Lays» сметана-лук 80г", gSnacks, 380, "SN-CHP-001", "2000000000322", kz?.Id, false, unitSht.Id, false),
|
||||
("Сухарики «3 корочки» ржаные 40г", gSnacks, 190, "SN-SBR-001", "2000000000339", ru?.Id, false, unitSht.Id, false),
|
||||
("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id, false),
|
||||
("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id, false),
|
||||
};
|
||||
|
||||
var products = demo.Select(d =>
|
||||
|
|
@ -156,12 +159,12 @@ Guid AddGroup(string name, Guid? parentId)
|
|||
Name = d.Name,
|
||||
Article = d.Article,
|
||||
UnitOfMeasureId = d.Unit,
|
||||
Vat = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
|
||||
? vat0 : vatDefault,
|
||||
VatEnabled = !(d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")),
|
||||
VatRateId = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
|
||||
? vat0 : vat,
|
||||
ProductGroupId = d.Group,
|
||||
CountryOfOriginId = d.Country,
|
||||
IsWeighed = d.IsWeighed,
|
||||
IsAlcohol = d.IsAlcohol,
|
||||
IsActive = true,
|
||||
PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2),
|
||||
PurchaseCurrencyId = kzt.Id,
|
||||
|
|
|
|||
|
|
@ -78,15 +78,24 @@ public async Task StartAsync(CancellationToken ct)
|
|||
|
||||
private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct)
|
||||
{
|
||||
var anyVat = await db.VatRates.IgnoreQueryFilters().AnyAsync(v => v.OrganizationId == orgId, ct);
|
||||
if (!anyVat)
|
||||
{
|
||||
db.VatRates.AddRange(
|
||||
new VatRate { OrganizationId = orgId, Name = "Без НДС", Percent = 0m, IsDefault = false },
|
||||
new VatRate { OrganizationId = orgId, Name = "НДС 12%", Percent = 12m, IsIncludedInPrice = true, IsDefault = true }
|
||||
);
|
||||
}
|
||||
|
||||
var anyUnit = await db.UnitsOfMeasure.IgnoreQueryFilters().AnyAsync(u => u.OrganizationId == orgId, ct);
|
||||
if (!anyUnit)
|
||||
{
|
||||
db.UnitsOfMeasure.AddRange(
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Name = "штука" },
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Name = "килограмм" },
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Name = "литр" },
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Name = "метр" },
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Name = "упаковка" }
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Symbol = "шт", Name = "штука", DecimalPlaces = 0, IsBase = true },
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Symbol = "кг", Name = "килограмм", DecimalPlaces = 3 },
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Symbol = "л", Name = "литр", DecimalPlaces = 3 },
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Symbol = "м", Name = "метр", DecimalPlaces = 3 },
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Symbol = "уп", Name = "упаковка", DecimalPlaces = 0 }
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -107,6 +116,7 @@ private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
|
|||
OrganizationId = orgId,
|
||||
Name = "Основной склад",
|
||||
Code = "MAIN",
|
||||
Kind = StoreKind.Warehouse,
|
||||
IsMain = true,
|
||||
Address = "Алматы, ул. Пример 1",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,14 +6,17 @@ public record CountryDto(Guid Id, string Code, string Name, int SortOrder);
|
|||
|
||||
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol, int MinorUnit, bool IsActive);
|
||||
|
||||
public record VatRateDto(
|
||||
Guid Id, string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault, bool IsActive);
|
||||
|
||||
public record UnitOfMeasureDto(
|
||||
Guid Id, string Code, string Name, string? Description, bool IsActive);
|
||||
Guid Id, string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase, bool IsActive);
|
||||
|
||||
public record PriceTypeDto(
|
||||
Guid Id, string Name, bool IsDefault, bool IsRetail, int SortOrder, bool IsActive);
|
||||
|
||||
public record StoreDto(
|
||||
Guid Id, string Name, string? Code, string? Address, string? Phone,
|
||||
Guid Id, string Name, string? Code, StoreKind Kind, string? Address, string? Phone,
|
||||
string? ManagerName, bool IsMain, bool IsActive);
|
||||
|
||||
public record RetailPointDto(
|
||||
|
|
@ -24,7 +27,7 @@ public record ProductGroupDto(
|
|||
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive);
|
||||
|
||||
public record CounterpartyDto(
|
||||
Guid Id, string Name, string? LegalName, CounterpartyType Type,
|
||||
Guid Id, string Name, string? LegalName, CounterpartyKind Kind, CounterpartyType Type,
|
||||
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName,
|
||||
string? Address, string? Phone, string? Email,
|
||||
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive);
|
||||
|
|
@ -35,12 +38,12 @@ public record ProductPriceDto(Guid Id, Guid PriceTypeId, string PriceTypeName, d
|
|||
|
||||
public record ProductDto(
|
||||
Guid Id, string Name, string? Article, string? Description,
|
||||
Guid UnitOfMeasureId, string UnitName,
|
||||
int Vat, bool VatEnabled,
|
||||
Guid UnitOfMeasureId, string UnitSymbol,
|
||||
Guid VatRateId, decimal VatPercent,
|
||||
Guid? ProductGroupId, string? ProductGroupName,
|
||||
Guid? DefaultSupplierId, string? DefaultSupplierName,
|
||||
Guid? CountryOfOriginId, string? CountryOfOriginName,
|
||||
bool IsService, bool IsWeighed, bool IsMarked,
|
||||
bool IsService, bool IsWeighed, bool IsAlcohol, bool IsMarked,
|
||||
decimal? MinStock, decimal? MaxStock,
|
||||
decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
|
||||
string? ImageUrl, bool IsActive,
|
||||
|
|
@ -50,10 +53,11 @@ public record ProductDto(
|
|||
// Upsert payloads (input)
|
||||
public record CountryInput(string Code, string Name, int SortOrder = 0);
|
||||
public record CurrencyInput(string Code, string Name, string Symbol, int MinorUnit = 2, bool IsActive = true);
|
||||
public record UnitOfMeasureInput(string Code, string Name, string? Description = null, bool IsActive = true);
|
||||
public record VatRateInput(string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault = false, bool IsActive = true);
|
||||
public record UnitOfMeasureInput(string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase = false, bool IsActive = true);
|
||||
public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true);
|
||||
public record StoreInput(
|
||||
string Name, string? Code,
|
||||
string Name, string? Code, StoreKind Kind = StoreKind.Warehouse,
|
||||
string? Address = null, string? Phone = null, string? ManagerName = null,
|
||||
bool IsMain = false, bool IsActive = true);
|
||||
public record RetailPointInput(
|
||||
|
|
@ -62,7 +66,7 @@ public record RetailPointInput(
|
|||
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
|
||||
public record ProductGroupInput(string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true);
|
||||
public record CounterpartyInput(
|
||||
string Name, string? LegalName, CounterpartyType Type,
|
||||
string Name, string? LegalName, CounterpartyKind Kind, CounterpartyType Type,
|
||||
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId,
|
||||
string? Address, string? Phone, string? Email,
|
||||
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive = true);
|
||||
|
|
@ -70,9 +74,9 @@ public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ea
|
|||
public record ProductPriceInput(Guid PriceTypeId, decimal Amount, Guid CurrencyId);
|
||||
public record ProductInput(
|
||||
string Name, string? Article, string? Description,
|
||||
Guid UnitOfMeasureId, int Vat, bool VatEnabled,
|
||||
Guid UnitOfMeasureId, Guid VatRateId,
|
||||
Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
|
||||
bool IsService = false, bool IsWeighed = false, bool IsMarked = false,
|
||||
bool IsService = false, bool IsWeighed = false, bool IsAlcohol = false, bool IsMarked = false,
|
||||
decimal? MinStock = null, decimal? MaxStock = null,
|
||||
decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null,
|
||||
string? ImageUrl = null, bool IsActive = true,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ public class Counterparty : TenantEntity
|
|||
{
|
||||
public string Name { get; set; } = null!; // отображаемое имя
|
||||
public string? LegalName { get; set; } // полное юридическое имя
|
||||
public CounterpartyKind Kind { get; set; } // Supplier / Customer / Both
|
||||
public CounterpartyType Type { get; set; } // Юрлицо / Физлицо
|
||||
public string? Bin { get; set; } // БИН (для юрлиц РК)
|
||||
public string? Iin { get; set; } // ИИН (для физлиц РК)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,28 @@
|
|||
namespace foodmarket.Domain.Catalog;
|
||||
|
||||
public enum CounterpartyKind
|
||||
{
|
||||
/// <summary>Не указано — дефолт для импортированных без явной классификации.
|
||||
/// MoySklad сам не имеет встроенного поля Supplier/Customer, оно ставится
|
||||
/// через теги или группы, и часто отсутствует. Не выдумываем за пользователя.</summary>
|
||||
Unspecified = 0,
|
||||
Supplier = 1,
|
||||
Customer = 2,
|
||||
Both = 3,
|
||||
}
|
||||
|
||||
public enum CounterpartyType
|
||||
{
|
||||
LegalEntity = 1,
|
||||
Individual = 2,
|
||||
}
|
||||
|
||||
public enum StoreKind
|
||||
{
|
||||
Warehouse = 1,
|
||||
RetailFloor = 2,
|
||||
}
|
||||
|
||||
public enum BarcodeType
|
||||
{
|
||||
Ean13 = 1,
|
||||
|
|
|
|||
|
|
@ -11,10 +11,8 @@ public class Product : TenantEntity
|
|||
public Guid UnitOfMeasureId { get; set; }
|
||||
public UnitOfMeasure? UnitOfMeasure { get; set; }
|
||||
|
||||
// Ставка НДС на товаре — число 0/10/12/16/20 как в MoySklad.
|
||||
// VatEnabled=true → НДС применяется, false → без НДС.
|
||||
public int Vat { get; set; }
|
||||
public bool VatEnabled { get; set; } = true;
|
||||
public Guid VatRateId { get; set; }
|
||||
public VatRate? VatRate { get; set; }
|
||||
|
||||
public Guid? ProductGroupId { get; set; }
|
||||
public ProductGroup? ProductGroup { get; set; }
|
||||
|
|
@ -27,6 +25,7 @@ public class Product : TenantEntity
|
|||
|
||||
public bool IsService { get; set; } // услуга, а не физический товар
|
||||
public bool IsWeighed { get; set; } // весовой (продаётся с весов)
|
||||
public bool IsAlcohol { get; set; } // алкоголь (подакцизный)
|
||||
public bool IsMarked { get; set; } // маркируемый (Datamatrix)
|
||||
|
||||
public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений)
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
namespace foodmarket.Domain.Catalog;
|
||||
|
||||
// Склад: физическое место хранения товаров. MoySklad не различает "склад" и
|
||||
// "торговый зал" — это одна сущность entity/store, опираемся на это.
|
||||
// Склад: физическое место хранения товаров. Может быть чисто склад или торговый зал.
|
||||
public class Store : TenantEntity
|
||||
{
|
||||
public string Name { get; set; } = null!;
|
||||
public string? Code { get; set; } // внутренний код склада
|
||||
public StoreKind Kind { get; set; } = StoreKind.Warehouse;
|
||||
public string? Address { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? ManagerName { get; set; }
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@
|
|||
|
||||
namespace foodmarket.Domain.Catalog;
|
||||
|
||||
// Единица измерения как в MoySklad entity/uom: code + name + description.
|
||||
// Tenant-scoped справочник единиц измерения.
|
||||
public class UnitOfMeasure : TenantEntity
|
||||
{
|
||||
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
|
||||
public string Symbol { get; set; } = null!; // "шт", "кг", "л", "м"
|
||||
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
|
||||
public string? Description { get; set; }
|
||||
public int DecimalPlaces { get; set; } // 0 для шт, 3 для кг/л
|
||||
public bool IsBase { get; set; } // базовая единица этой организации
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
|
|
|||
13
src/food-market.domain/Catalog/VatRate.cs
Normal file
13
src/food-market.domain/Catalog/VatRate.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using foodmarket.Domain.Common;
|
||||
|
||||
namespace foodmarket.Domain.Catalog;
|
||||
|
||||
// Tenant-scoped: разные организации могут работать в разных режимах (с НДС / упрощёнка).
|
||||
public class VatRate : TenantEntity
|
||||
{
|
||||
public string Name { get; set; } = null!; // "НДС 12%", "Без НДС"
|
||||
public decimal Percent { get; set; } // 12.00, 0.00
|
||||
public bool IsIncludedInPrice { get; set; } // входит ли в цену или начисляется сверху
|
||||
public bool IsDefault { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
|
@ -63,78 +63,60 @@ public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, C
|
|||
: MoySkladApiResult<MsOrganization>.Ok(org);
|
||||
}
|
||||
|
||||
// MoySklad list endpoints по умолчанию возвращают только активных (archived=false).
|
||||
// Чтобы получить архивных, нужен явный filter=archived=true. Делаем два прохода:
|
||||
// сначала активные (default), затем архивные — и отдаём всё одним потоком.
|
||||
|
||||
public async IAsyncEnumerable<MsProduct> StreamProductsAsync(
|
||||
string token,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: false, ct)) yield return p;
|
||||
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: true, ct)) yield return p;
|
||||
const int pageSize = 1000;
|
||||
var offset = 0;
|
||||
while (true)
|
||||
{
|
||||
using var req = Build(HttpMethod.Get, $"entity/product?limit={pageSize}&offset={offset}", token);
|
||||
using var res = await _http.SendAsync(req, ct);
|
||||
res.EnsureSuccessStatusCode();
|
||||
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsProduct>>(Json, ct);
|
||||
if (page is null || page.Rows.Count == 0) yield break;
|
||||
foreach (var p in page.Rows) yield return p;
|
||||
if (page.Rows.Count < pageSize) yield break;
|
||||
offset += pageSize;
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<MsCounterparty> StreamCounterpartiesAsync(
|
||||
string token,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: false, ct)) yield return c;
|
||||
await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: true, ct)) yield return c;
|
||||
const int pageSize = 1000;
|
||||
var offset = 0;
|
||||
while (true)
|
||||
{
|
||||
using var req = Build(HttpMethod.Get, $"entity/counterparty?limit={pageSize}&offset={offset}", token);
|
||||
using var res = await _http.SendAsync(req, ct);
|
||||
res.EnsureSuccessStatusCode();
|
||||
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsCounterparty>>(Json, ct);
|
||||
if (page is null || page.Rows.Count == 0) yield break;
|
||||
foreach (var c in page.Rows) yield return c;
|
||||
if (page.Rows.Count < pageSize) yield break;
|
||||
offset += pageSize;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct)
|
||||
{
|
||||
var all = new List<MsProductFolder>();
|
||||
await foreach (var f in StreamPagedAsync<MsProductFolder>(token, "entity/productfolder", archivedOnly: false, ct)) all.Add(f);
|
||||
await foreach (var f in StreamPagedAsync<MsProductFolder>(token, "entity/productfolder", archivedOnly: true, ct)) all.Add(f);
|
||||
return all;
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<T> StreamPagedAsync<T>(
|
||||
string token,
|
||||
string path,
|
||||
bool archivedOnly,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
const int pageSize = 1000;
|
||||
const int maxAttempts = 5;
|
||||
var offset = 0;
|
||||
var filterSuffix = archivedOnly ? "&filter=archived=true" : "";
|
||||
const int pageSize = 1000;
|
||||
while (true)
|
||||
{
|
||||
MsListResponse<T>? page = null;
|
||||
Exception? lastErr = null;
|
||||
for (var attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var req = Build(HttpMethod.Get, $"{path}?limit={pageSize}&offset={offset}{filterSuffix}", token);
|
||||
using var req = Build(HttpMethod.Get, $"entity/productfolder?limit={pageSize}&offset={offset}", 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,11 +39,14 @@ public async Task<MoySkladImportResult> ImportCounterpartiesAsync(string token,
|
|||
{
|
||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
|
||||
|
||||
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще —
|
||||
// counterparty entity содержит только group (группа доступа), tags
|
||||
// (произвольные), state (пользовательская цепочка статусов), companyType
|
||||
// (legal/individual/entrepreneur). Никакого role/kind. Поэтому у нас тоже
|
||||
// этого поля нет — пусть пользователь сам решит.
|
||||
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще — это
|
||||
// не наша выдумка, проверено через API: counterparty entity содержит только
|
||||
// group (группа доступа), tags (произвольные), state (пользовательская цепочка
|
||||
// статусов), companyType (legal/individual/entrepreneur). Никакой role/kind.
|
||||
// Поэтому при импорте ВСЕГДА ставим Unspecified — пользователь сам решит.
|
||||
// Параметр tags оставлен ради совместимости сигнатуры, не используется.
|
||||
static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList<string>? tags)
|
||||
=> foodmarket.Domain.Catalog.CounterpartyKind.Unspecified;
|
||||
|
||||
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
|
||||
=> companyType switch
|
||||
|
|
@ -52,15 +55,11 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp
|
|||
_ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity,
|
||||
};
|
||||
|
||||
// Загружаем существующих в память — обновлять будем по имени (case-insensitive).
|
||||
// Раньше при overwriteExisting=true бага: пропускалась проверка "skip" и ВСЕ
|
||||
// поступающие записи добавлялись как новые, порождая дубли. Теперь если имя уже
|
||||
// есть — обновляем ту же запись, иначе создаём.
|
||||
var existingByName = await _db.Counterparties
|
||||
.ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct);
|
||||
.Select(c => new { c.Id, c.Name })
|
||||
.ToDictionaryAsync(c => c.Name, c => c.Id, StringComparer.OrdinalIgnoreCase, ct);
|
||||
|
||||
var created = 0;
|
||||
var updated = 0;
|
||||
var skipped = 0;
|
||||
var total = 0;
|
||||
var errors = new List<string>();
|
||||
|
|
@ -69,25 +68,34 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp
|
|||
await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||
{
|
||||
total++;
|
||||
// Архивных не пропускаем — импортируем как IsActive=false (см. ApplyCounterparty).
|
||||
if (c.Archived) { skipped++; continue; }
|
||||
|
||||
if (existingByName.ContainsKey(c.Name) && !overwriteExisting)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (existingByName.TryGetValue(c.Name, out var existing))
|
||||
var entity = new foodmarket.Domain.Catalog.Counterparty
|
||||
{
|
||||
if (!overwriteExisting) { skipped++; continue; }
|
||||
ApplyCounterparty(existing, c, ResolveType);
|
||||
updated++;
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = new foodmarket.Domain.Catalog.Counterparty { OrganizationId = orgId };
|
||||
ApplyCounterparty(entity, c, ResolveType);
|
||||
OrganizationId = orgId,
|
||||
Name = Trim(c.Name, 255) ?? c.Name,
|
||||
LegalName = Trim(c.LegalTitle, 500),
|
||||
Kind = ResolveKind(c.Tags),
|
||||
Type = ResolveType(c.CompanyType),
|
||||
Bin = Trim(c.Inn, 20),
|
||||
TaxNumber = Trim(c.Kpp, 20),
|
||||
Phone = Trim(c.Phone, 50),
|
||||
Email = Trim(c.Email, 255),
|
||||
Address = Trim(c.ActualAddress ?? c.LegalAddress, 500),
|
||||
Notes = Trim(c.Description, 1000),
|
||||
IsActive = !c.Archived,
|
||||
};
|
||||
_db.Counterparties.Add(entity);
|
||||
existingByName[c.Name] = entity;
|
||||
existingByName[c.Name] = entity.Id;
|
||||
created++;
|
||||
}
|
||||
|
||||
batch++;
|
||||
if (batch >= 500)
|
||||
{
|
||||
|
|
@ -103,25 +111,7 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
|||
}
|
||||
|
||||
if (batch > 0) await _db.SaveChangesAsync(ct);
|
||||
// `created` в отчёте = вставки + апдейты (чтобы не ломать UI, который знает только Created).
|
||||
return new MoySkladImportResult(total, created + updated, skipped, 0, errors);
|
||||
}
|
||||
|
||||
private static void ApplyCounterparty(
|
||||
foodmarket.Domain.Catalog.Counterparty entity,
|
||||
MsCounterparty c,
|
||||
Func<string?, foodmarket.Domain.Catalog.CounterpartyType> resolveType)
|
||||
{
|
||||
entity.Name = Trim(c.Name, 255) ?? c.Name;
|
||||
entity.LegalName = Trim(c.LegalTitle, 500);
|
||||
entity.Type = resolveType(c.CompanyType);
|
||||
entity.Bin = Trim(c.Inn, 20);
|
||||
entity.TaxNumber = Trim(c.Kpp, 20);
|
||||
entity.Phone = Trim(c.Phone, 50);
|
||||
entity.Email = Trim(c.Email, 255);
|
||||
entity.Address = Trim(c.ActualAddress ?? c.LegalAddress, 500);
|
||||
entity.Notes = Trim(c.Description, 1000);
|
||||
entity.IsActive = !c.Archived;
|
||||
return new MoySkladImportResult(total, created, skipped, 0, errors);
|
||||
}
|
||||
|
||||
public async Task<MoySkladImportResult> ImportProductsAsync(
|
||||
|
|
@ -131,10 +121,11 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
|||
{
|
||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
|
||||
|
||||
// Pre-load tenant defaults. KZ default VAT is 16% — applied when product didn't
|
||||
// carry its own vat from MoySklad.
|
||||
const int kzDefaultVat = 16;
|
||||
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", ct)
|
||||
// Pre-load tenant defaults.
|
||||
var defaultVat = await _db.VatRates.FirstOrDefaultAsync(v => v.IsDefault, ct)
|
||||
?? await _db.VatRates.FirstAsync(ct);
|
||||
var vatByPercent = await _db.VatRates.ToDictionaryAsync(v => (int)v.Percent, v => v.Id, ct);
|
||||
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.IsBase, ct)
|
||||
?? await _db.UnitsOfMeasure.FirstAsync(ct);
|
||||
var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct)
|
||||
?? await _db.PriceTypes.FirstAsync(ct);
|
||||
|
|
@ -144,12 +135,11 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
|||
.IgnoreQueryFilters()
|
||||
.ToDictionaryAsync(c => c.Name, c => c.Id, ct);
|
||||
|
||||
// Import folders first — build flat then link parents. Архивные тоже берём,
|
||||
// помечаем IsActive=false — у MoySklad у productfolder есть archived.
|
||||
// Import folders first — build flat then link parents.
|
||||
var folders = await _client.GetAllFoldersAsync(token, ct);
|
||||
var localGroupByMsId = new Dictionary<string, Guid>();
|
||||
var groupsCreated = 0;
|
||||
foreach (var f in folders.OrderBy(f => f.PathName?.Length ?? 0))
|
||||
foreach (var f in folders.Where(f => !f.Archived).OrderBy(f => f.PathName?.Length ?? 0))
|
||||
{
|
||||
if (f.Id is null) continue;
|
||||
var existing = await _db.ProductGroups.FirstOrDefaultAsync(
|
||||
|
|
@ -164,7 +154,7 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
|||
OrganizationId = orgId,
|
||||
Name = f.Name,
|
||||
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
|
||||
IsActive = !f.Archived,
|
||||
IsActive = true,
|
||||
};
|
||||
_db.ProductGroups.Add(g);
|
||||
localGroupByMsId[f.Id] = g.Id;
|
||||
|
|
@ -175,26 +165,28 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
|||
// Import products
|
||||
var errors = new List<string>();
|
||||
var created = 0;
|
||||
var updated = 0;
|
||||
var skipped = 0;
|
||||
var total = 0;
|
||||
// При overwriteExisting=true загружаем товары целиком, чтобы обновлять существующие
|
||||
// вместо создания дубликатов. Ключ = артикул (нормализованный).
|
||||
var existingByArticle = await _db.Products
|
||||
var existingArticles = await _db.Products
|
||||
.Where(p => p.Article != null)
|
||||
.ToDictionaryAsync(p => p.Article!, p => p, StringComparer.OrdinalIgnoreCase, ct);
|
||||
.Select(p => p.Article!)
|
||||
.ToListAsync(ct);
|
||||
var existingArticleSet = new HashSet<string>(existingArticles, StringComparer.OrdinalIgnoreCase);
|
||||
var existingBarcodeSet = new HashSet<string>(
|
||||
await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct));
|
||||
|
||||
await foreach (var p in _client.StreamProductsAsync(token, ct))
|
||||
{
|
||||
total++;
|
||||
// Архивных не пропускаем — импортируем как IsActive=false.
|
||||
if (p.Archived) { skipped++; continue; }
|
||||
|
||||
var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article;
|
||||
var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingByArticle.ContainsKey(article);
|
||||
var primaryBarcode = ExtractBarcodes(p).FirstOrDefault();
|
||||
|
||||
if (alreadyByArticle && !overwriteExisting)
|
||||
var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingArticleSet.Contains(article);
|
||||
var alreadyByBarcode = primaryBarcode is not null && existingBarcodeSet.Contains(primaryBarcode.Code);
|
||||
|
||||
if ((alreadyByArticle || alreadyByBarcode) && !overwriteExisting)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
|
|
@ -202,8 +194,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
|||
|
||||
try
|
||||
{
|
||||
var vat = p.Vat ?? kzDefaultVat;
|
||||
var vatEnabled = (p.Vat ?? kzDefaultVat) > 0;
|
||||
var vatId = p.Vat is int vp && vatByPercent.TryGetValue(vp, out var id) ? id : defaultVat.Id;
|
||||
Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId
|
||||
&& localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null;
|
||||
Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null;
|
||||
|
|
@ -211,39 +202,18 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
|||
var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true)
|
||||
?? p.SalePrices?.FirstOrDefault();
|
||||
|
||||
Product product;
|
||||
if (alreadyByArticle && overwriteExisting)
|
||||
{
|
||||
product = existingByArticle[article!];
|
||||
// Обновляем только скалярные поля — коллекции (prices, barcodes) оставляем:
|
||||
// там могут быть данные, которые редактировал пользователь после импорта.
|
||||
product.Name = Trim(p.Name, 500);
|
||||
product.Article = Trim(article, 500);
|
||||
product.Description = p.Description;
|
||||
product.Vat = vat;
|
||||
product.VatEnabled = vatEnabled;
|
||||
product.ProductGroupId = groupId ?? product.ProductGroupId;
|
||||
product.CountryOfOriginId = countryId ?? product.CountryOfOriginId;
|
||||
product.IsWeighed = p.Weighed;
|
||||
product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED";
|
||||
product.IsActive = !p.Archived;
|
||||
product.PurchasePrice = p.BuyPrice is null ? product.PurchasePrice : p.BuyPrice.Value / 100m;
|
||||
updated++;
|
||||
}
|
||||
else
|
||||
{
|
||||
product = new Product
|
||||
var product = new Product
|
||||
{
|
||||
OrganizationId = orgId,
|
||||
Name = Trim(p.Name, 500),
|
||||
Article = Trim(article, 500),
|
||||
Description = p.Description,
|
||||
UnitOfMeasureId = baseUnit.Id,
|
||||
Vat = vat,
|
||||
VatEnabled = vatEnabled,
|
||||
VatRateId = vatId,
|
||||
ProductGroupId = groupId,
|
||||
CountryOfOriginId = countryId,
|
||||
IsWeighed = p.Weighed,
|
||||
IsAlcohol = p.Alcoholic is not null,
|
||||
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
|
||||
IsActive = !p.Archived,
|
||||
PurchasePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
|
||||
|
|
@ -269,13 +239,11 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
|||
}
|
||||
|
||||
_db.Products.Add(product);
|
||||
if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product;
|
||||
if (!string.IsNullOrWhiteSpace(article)) existingArticleSet.Add(article);
|
||||
created++;
|
||||
}
|
||||
|
||||
// Flush чаще (каждые 100) чтобы при сетевом обрыве на следующей странице
|
||||
// мы сохранили как можно больше и смогли безопасно продолжить с overwrite.
|
||||
if ((created + updated) % 100 == 0) await _db.SaveChangesAsync(ct);
|
||||
// Flush every 500 products to keep change tracker light.
|
||||
if (created % 500 == 0) await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -285,7 +253,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
|||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new MoySkladImportResult(total, created + updated, skipped, groupsCreated, errors);
|
||||
return new MoySkladImportResult(total, created, skipped, groupsCreated, errors);
|
||||
}
|
||||
|
||||
private static List<ProductBarcode> ExtractBarcodes(MsProduct p)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
|||
|
||||
public DbSet<Country> Countries => Set<Country>();
|
||||
public DbSet<Currency> Currencies => Set<Currency>();
|
||||
public DbSet<VatRate> VatRates => Set<VatRate>();
|
||||
public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>();
|
||||
public DbSet<Counterparty> Counterparties => Set<Counterparty>();
|
||||
public DbSet<Store> Stores => Set<Store>();
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ public static void ConfigureCatalog(this ModelBuilder b)
|
|||
{
|
||||
b.Entity<Country>(ConfigureCountry);
|
||||
b.Entity<Currency>(ConfigureCurrency);
|
||||
b.Entity<VatRate>(ConfigureVatRate);
|
||||
b.Entity<UnitOfMeasure>(ConfigureUnit);
|
||||
b.Entity<Counterparty>(ConfigureCounterparty);
|
||||
b.Entity<Store>(ConfigureStore);
|
||||
|
|
@ -39,12 +40,20 @@ private static void ConfigureCurrency(EntityTypeBuilder<Currency> b)
|
|||
b.HasIndex(x => x.Code).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureVatRate(EntityTypeBuilder<VatRate> b)
|
||||
{
|
||||
b.ToTable("vat_rates");
|
||||
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
|
||||
b.Property(x => x.Percent).HasPrecision(5, 2);
|
||||
b.HasIndex(x => new { x.OrganizationId, x.Name }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureUnit(EntityTypeBuilder<UnitOfMeasure> b)
|
||||
{
|
||||
b.ToTable("units_of_measure");
|
||||
b.Property(x => x.Code).HasMaxLength(10).IsRequired();
|
||||
b.Property(x => x.Symbol).HasMaxLength(20).IsRequired();
|
||||
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
|
||||
b.Property(x => x.Description).HasMaxLength(500);
|
||||
b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique();
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +74,7 @@ private static void ConfigureCounterparty(EntityTypeBuilder<Counterparty> b)
|
|||
b.HasOne(x => x.Country).WithMany().HasForeignKey(x => x.CountryId).OnDelete(DeleteBehavior.Restrict);
|
||||
b.HasIndex(x => new { x.OrganizationId, x.Name });
|
||||
b.HasIndex(x => new { x.OrganizationId, x.Bin });
|
||||
b.HasIndex(x => new { x.OrganizationId, x.Kind });
|
||||
}
|
||||
|
||||
private static void ConfigureStore(EntityTypeBuilder<Store> b)
|
||||
|
|
@ -120,6 +130,7 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
|
|||
b.Property(x => x.ImageUrl).HasMaxLength(1000);
|
||||
|
||||
b.HasOne(x => x.UnitOfMeasure).WithMany().HasForeignKey(x => x.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict);
|
||||
b.HasOne(x => x.VatRate).WithMany().HasForeignKey(x => x.VatRateId).OnDelete(DeleteBehavior.Restrict);
|
||||
b.HasOne(x => x.ProductGroup).WithMany().HasForeignKey(x => x.ProductGroupId).OnDelete(DeleteBehavior.Restrict);
|
||||
b.HasOne(x => x.DefaultSupplier).WithMany().HasForeignKey(x => x.DefaultSupplierId).OnDelete(DeleteBehavior.Restrict);
|
||||
b.HasOne(x => x.CountryOfOrigin).WithMany().HasForeignKey(x => x.CountryOfOriginId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,70 +0,0 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <summary>Reconciliation migration.
|
||||
///
|
||||
/// Предыдущие миграции Phase2c2_MoySkladAlignment и Phase2c3_MsStrict были применены
|
||||
/// на стейдже, но их исходные .cs файлы были удалены при откате кода (commit 8fc9ef1
|
||||
/// стёр их, но __EFMigrationsHistory уже содержал записи). В результате:
|
||||
/// - snapshot был неактуальным (ссылался на VatRate, IsAlcohol, Kind, и т.п.)
|
||||
/// - БД в состоянии пост-2c3 (поля Vat, VatEnabled, TrackingType; без VatRate,
|
||||
/// без Kind, без IsAlcohol, без Symbol/DecimalPlaces/IsBase)
|
||||
/// - код ожидает IsMarked вместо TrackingType
|
||||
///
|
||||
/// Задача этой миграции — добить различие в одной колонке: заменить TrackingType
|
||||
/// (добавленный в Phase2c2) на IsMarked. Всё остальное уже совпадает.
|
||||
/// EF-scaffold предложил много мусорной работы из-за рассинхрона snapshot'а — это
|
||||
/// тело переписано вручную.</summary>
|
||||
public partial class Phase2c4_ReconcileStage : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 1. Добавляем IsMarked с дефолтом false.
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsMarked",
|
||||
schema: "public",
|
||||
table: "products",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
// 2. Если TrackingType есть в БД (стейдж) — бэкфиллим и удаляем.
|
||||
// На свежей БД (dev, где migrations 2c2/2c3 не применялись отдельно)
|
||||
// колонки не будет — IF EXISTS защищает от ошибки.
|
||||
migrationBuilder.Sql("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'products'
|
||||
AND column_name = 'TrackingType') THEN
|
||||
UPDATE public.products SET "IsMarked" = ("TrackingType" <> 0);
|
||||
ALTER TABLE public.products DROP COLUMN "TrackingType";
|
||||
END IF;
|
||||
END $$;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TrackingType",
|
||||
schema: "public",
|
||||
table: "products",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.Sql("""UPDATE public.products SET "TrackingType" = CASE WHEN "IsMarked" THEN 99 ELSE 0 END;""");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsMarked",
|
||||
schema: "public",
|
||||
table: "products");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -380,6 +380,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("LegalName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
|
@ -415,6 +418,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
|
||||
b.HasIndex("OrganizationId", "Bin");
|
||||
|
||||
b.HasIndex("OrganizationId", "Kind");
|
||||
|
||||
b.HasIndex("OrganizationId", "Name");
|
||||
|
||||
b.ToTable("counterparties", "public");
|
||||
|
|
@ -563,6 +568,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsAlcohol")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsMarked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
|
|
@ -604,11 +612,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Vat")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("VatEnabled")
|
||||
.HasColumnType("boolean");
|
||||
b.Property<Guid>("VatRateId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
|
|
@ -622,6 +627,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
|
||||
b.HasIndex("UnitOfMeasureId");
|
||||
|
||||
b.HasIndex("VatRateId");
|
||||
|
||||
b.HasIndex("OrganizationId", "Article");
|
||||
|
||||
b.HasIndex("OrganizationId", "IsActive");
|
||||
|
|
@ -869,6 +876,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
b.Property<bool>("IsMain")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ManagerName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
|
@ -909,13 +919,15 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
b.Property<int>("DecimalPlaces")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBase")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
|
|
@ -924,6 +936,11 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
|
|
@ -935,6 +952,47 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
b.ToTable("units_of_measure", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("foodmarket.Domain.Catalog.VatRate", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsIncludedInPrice")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal>("Percent")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("numeric(5,2)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("vat_rates", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
|
@ -1594,6 +1652,12 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("foodmarket.Domain.Catalog.VatRate", "VatRate")
|
||||
.WithMany()
|
||||
.HasForeignKey("VatRateId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CountryOfOrigin");
|
||||
|
||||
b.Navigation("DefaultSupplier");
|
||||
|
|
@ -1603,6 +1667,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
b.Navigation("PurchaseCurrency");
|
||||
|
||||
b.Navigation("UnitOfMeasure");
|
||||
|
||||
b.Navigation("VatRate");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("foodmarket.Domain.Catalog.ProductBarcode", b =>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { LoginPage } from '@/pages/LoginPage'
|
|||
import { DashboardPage } from '@/pages/DashboardPage'
|
||||
import { CountriesPage } from '@/pages/CountriesPage'
|
||||
import { CurrenciesPage } from '@/pages/CurrenciesPage'
|
||||
import { VatRatesPage } from '@/pages/VatRatesPage'
|
||||
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
|
||||
import { PriceTypesPage } from '@/pages/PriceTypesPage'
|
||||
import { StoresPage } from '@/pages/StoresPage'
|
||||
|
|
@ -45,6 +46,7 @@ export default function App() {
|
|||
<Route path="/catalog/products/:id" element={<ProductEditPage />} />
|
||||
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
|
||||
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
|
||||
<Route path="/catalog/vat-rates" element={<VatRatesPage />} />
|
||||
<Route path="/catalog/price-types" element={<PriceTypesPage />} />
|
||||
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
|
||||
<Route path="/catalog/stores" element={<StoresPage />} />
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { ChevronRight, FolderTree } from 'lucide-react'
|
||||
import { useProductGroups } from '@/lib/useLookups'
|
||||
import type { ProductGroup } from '@/lib/types'
|
||||
|
||||
interface TreeNode {
|
||||
group: ProductGroup
|
||||
children: TreeNode[]
|
||||
productCount?: number
|
||||
}
|
||||
|
||||
function buildTree(groups: ProductGroup[]): TreeNode[] {
|
||||
const byId = new Map<string, TreeNode>()
|
||||
groups.forEach((g) => byId.set(g.id, { group: g, children: [] }))
|
||||
const roots: TreeNode[] = []
|
||||
byId.forEach((node) => {
|
||||
if (node.group.parentId && byId.has(node.group.parentId)) {
|
||||
byId.get(node.group.parentId)!.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
const sortRec = (nodes: TreeNode[]) => {
|
||||
nodes.sort((a, b) => (a.group.sortOrder - b.group.sortOrder) || a.group.name.localeCompare(b.group.name, 'ru'))
|
||||
nodes.forEach((n) => sortRec(n.children))
|
||||
}
|
||||
sortRec(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
interface Props {
|
||||
selectedId: string | null
|
||||
onSelect: (id: string | null) => void
|
||||
}
|
||||
|
||||
export function ProductGroupTree({ selectedId, onSelect }: Props) {
|
||||
const { data: groups, isLoading } = useProductGroups()
|
||||
const tree = useMemo(() => buildTree(groups ?? []), [groups])
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const renderNode = (node: TreeNode, depth: number) => {
|
||||
const hasChildren = node.children.length > 0
|
||||
const isOpen = expanded.has(node.group.id)
|
||||
const isActive = selectedId === node.group.id
|
||||
return (
|
||||
<div key={node.group.id}>
|
||||
<div
|
||||
className={
|
||||
'flex items-center gap-1 text-sm rounded cursor-pointer select-none pr-2 hover:bg-slate-100 dark:hover:bg-slate-800 ' +
|
||||
(isActive ? 'bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200 font-medium' : '')
|
||||
}
|
||||
style={{ paddingLeft: 4 + depth * 12 }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); toggle(node.group.id) }}
|
||||
className="p-0.5 text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
|
||||
aria-label={isOpen ? 'Свернуть' : 'Развернуть'}
|
||||
>
|
||||
<ChevronRight className={'w-3.5 h-3.5 transition-transform ' + (isOpen ? 'rotate-90' : '')} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-[18px]" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(node.group.id)}
|
||||
className="flex-1 text-left py-1 truncate"
|
||||
title={node.group.path}
|
||||
>
|
||||
{node.group.name}
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && isOpen && node.children.map((c) => renderNode(c, depth + 1))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="px-2 py-2 border-b border-slate-200 dark:border-slate-800 flex items-center gap-2 text-xs uppercase tracking-wide text-slate-500">
|
||||
<FolderTree className="w-3.5 h-3.5" /> Группы
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto py-1">
|
||||
<div
|
||||
className={
|
||||
'flex items-center gap-1 text-sm rounded cursor-pointer pr-2 hover:bg-slate-100 dark:hover:bg-slate-800 pl-2 ' +
|
||||
(selectedId === null ? 'bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200 font-medium' : '')
|
||||
}
|
||||
onClick={() => onSelect(null)}
|
||||
>
|
||||
<button type="button" className="flex-1 text-left py-1">Все товары</button>
|
||||
</div>
|
||||
{isLoading && <div className="px-3 py-2 text-xs text-slate-400">Загрузка…</div>}
|
||||
{!isLoading && tree.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-slate-400">Групп ещё нет</div>
|
||||
)}
|
||||
{tree.map((n) => renderNode(n, 0))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -71,7 +71,7 @@ export function ProductPicker({ open, onClose, onPick, title = 'Выбор то
|
|||
<div className="text-xs text-slate-400 flex gap-2 font-mono">
|
||||
{p.article && <span>{p.article}</span>}
|
||||
{p.barcodes[0] && <span>· {p.barcodes[0].code}</span>}
|
||||
<span>· {p.unitName}</span>
|
||||
<span>· {p.unitSymbol}</span>
|
||||
</div>
|
||||
</div>
|
||||
{p.purchasePrice !== null && (
|
||||
|
|
|
|||
|
|
@ -6,18 +6,25 @@ export interface PagedResult<T> {
|
|||
totalPages: number
|
||||
}
|
||||
|
||||
export const CounterpartyKind = { Unspecified: 0, Supplier: 1, Customer: 2, Both: 3 } as const
|
||||
export type CounterpartyKind = (typeof CounterpartyKind)[keyof typeof CounterpartyKind]
|
||||
|
||||
export const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const
|
||||
export type CounterpartyType = (typeof CounterpartyType)[keyof typeof CounterpartyType]
|
||||
|
||||
export const StoreKind = { Warehouse: 1, RetailFloor: 2 } as const
|
||||
export type StoreKind = (typeof StoreKind)[keyof typeof StoreKind]
|
||||
|
||||
export const BarcodeType = { Ean13: 1, Ean8: 2, Code128: 3, Code39: 4, Upca: 5, Upce: 6, Other: 99 } as const
|
||||
export type BarcodeType = (typeof BarcodeType)[keyof typeof BarcodeType]
|
||||
|
||||
export interface Country { id: string; code: string; name: string; sortOrder: number }
|
||||
export interface Currency { id: string; code: string; name: string; symbol: string; minorUnit: number; isActive: boolean }
|
||||
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; isActive: boolean }
|
||||
export interface VatRate { id: string; name: string; percent: number; isIncludedInPrice: boolean; isDefault: boolean; isActive: boolean }
|
||||
export interface UnitOfMeasure { id: string; code: string; symbol: string; name: string; decimalPlaces: number; isBase: boolean; isActive: boolean }
|
||||
export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }
|
||||
export interface Store {
|
||||
id: string; name: string; code: string | null; address: string | null; phone: string | null;
|
||||
id: string; name: string; code: string | null; kind: StoreKind; address: string | null; phone: string | null;
|
||||
managerName: string | null; isMain: boolean; isActive: boolean
|
||||
}
|
||||
export interface RetailPoint {
|
||||
|
|
@ -26,7 +33,7 @@ export interface RetailPoint {
|
|||
}
|
||||
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean }
|
||||
export interface Counterparty {
|
||||
id: string; name: string; legalName: string | null; type: CounterpartyType;
|
||||
id: string; name: string; legalName: string | null; kind: CounterpartyKind; type: CounterpartyType;
|
||||
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;
|
||||
address: string | null; phone: string | null; email: string | null;
|
||||
bankName: string | null; bankAccount: string | null; bik: string | null;
|
||||
|
|
@ -36,12 +43,12 @@ export interface ProductBarcode { id: string; code: string; type: BarcodeType; i
|
|||
export interface ProductPrice { id: string; priceTypeId: string; priceTypeName: string; amount: number; currencyId: string; currencyCode: string }
|
||||
export interface Product {
|
||||
id: string; name: string; article: string | null; description: string | null;
|
||||
unitOfMeasureId: string; unitName: string;
|
||||
vat: number; vatEnabled: boolean;
|
||||
unitOfMeasureId: string; unitSymbol: string;
|
||||
vatRateId: string; vatPercent: number;
|
||||
productGroupId: string | null; productGroupName: string | null;
|
||||
defaultSupplierId: string | null; defaultSupplierName: string | null;
|
||||
countryOfOriginId: string | null; countryOfOriginName: string | null;
|
||||
isService: boolean; isWeighed: boolean; isMarked: boolean;
|
||||
isService: boolean; isWeighed: boolean; isAlcohol: boolean; isMarked: boolean;
|
||||
minStock: number | null; maxStock: number | null;
|
||||
purchasePrice: number | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
|
||||
imageUrl: string | null; isActive: boolean;
|
||||
|
|
@ -49,7 +56,7 @@ export interface Product {
|
|||
}
|
||||
|
||||
export interface StockRow {
|
||||
productId: string; productName: string; article: string | null; unitName: string;
|
||||
productId: string; productName: string; article: string | null; unitSymbol: string;
|
||||
storeId: string; storeName: string;
|
||||
quantity: number; reservedQuantity: number; available: number;
|
||||
}
|
||||
|
|
@ -76,7 +83,7 @@ export interface SupplyListRow {
|
|||
|
||||
export interface SupplyLineDto {
|
||||
id: string | null; productId: string;
|
||||
productName: string | null; productArticle: string | null; unitName: string | null;
|
||||
productName: string | null; productArticle: string | null; unitSymbol: string | null;
|
||||
quantity: number; unitPrice: number; lineTotal: number; sortOrder: number;
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +116,7 @@ export interface RetailSaleListRow {
|
|||
|
||||
export interface RetailSaleLineDto {
|
||||
id: string | null; productId: string;
|
||||
productName: string | null; productArticle: string | null; unitName: string | null;
|
||||
productName: string | null; productArticle: string | null; unitSymbol: string | null;
|
||||
quantity: number; unitPrice: number; discount: number; lineTotal: number;
|
||||
vatPercent: number; sortOrder: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
PagedResult, UnitOfMeasure, ProductGroup, Counterparty,
|
||||
PagedResult, UnitOfMeasure, VatRate, ProductGroup, Counterparty,
|
||||
Country, Currency, Store, PriceType,
|
||||
} from '@/lib/types'
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ function useLookup<T>(key: string, url: string) {
|
|||
}
|
||||
|
||||
export const useUnits = () => useLookup<UnitOfMeasure>('units', '/api/catalog/units-of-measure')
|
||||
export const useVatRates = () => useLookup<VatRate>('vat', '/api/catalog/vat-rates')
|
||||
export const useProductGroups = () => useLookup<ProductGroup>('groups', '/api/catalog/product-groups')
|
||||
export const useCountries = () => useLookup<Country>('countries', '/api/catalog/countries')
|
||||
export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies')
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Button } from '@/components/Button'
|
|||
import { Modal } from '@/components/Modal'
|
||||
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
|
||||
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
||||
import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types'
|
||||
import { type Counterparty, type Country, type PagedResult, CounterpartyKind, CounterpartyType } from '@/lib/types'
|
||||
|
||||
const URL = '/api/catalog/counterparties'
|
||||
|
||||
|
|
@ -18,6 +18,7 @@ interface Form {
|
|||
id?: string
|
||||
name: string
|
||||
legalName: string
|
||||
kind: CounterpartyKind
|
||||
type: CounterpartyType
|
||||
bin: string
|
||||
iin: string
|
||||
|
|
@ -35,16 +36,20 @@ interface Form {
|
|||
}
|
||||
|
||||
const blankForm: Form = {
|
||||
name: '', legalName: '', type: CounterpartyType.LegalEntity,
|
||||
// Kind по умолчанию Unspecified — MoySklad не имеет такого поля у контрагентов,
|
||||
// не выдумываем за пользователя. Пусть выберет вручную если нужно.
|
||||
name: '', legalName: '', kind: CounterpartyKind.Unspecified, type: CounterpartyType.LegalEntity,
|
||||
bin: '', iin: '', taxNumber: '', countryId: '',
|
||||
address: '', phone: '', email: '',
|
||||
bankName: '', bankAccount: '', bik: '',
|
||||
contactPerson: '', notes: '', isActive: true,
|
||||
}
|
||||
|
||||
const typeLabel: Record<CounterpartyType, string> = {
|
||||
[CounterpartyType.LegalEntity]: 'Юрлицо',
|
||||
[CounterpartyType.Individual]: 'Физлицо',
|
||||
const kindLabel: Record<CounterpartyKind, string> = {
|
||||
[CounterpartyKind.Unspecified]: '—',
|
||||
[CounterpartyKind.Supplier]: 'Поставщик',
|
||||
[CounterpartyKind.Customer]: 'Покупатель',
|
||||
[CounterpartyKind.Both]: 'Поставщик + Покупатель',
|
||||
}
|
||||
|
||||
export function CounterpartiesPage() {
|
||||
|
|
@ -87,7 +92,7 @@ export function CounterpartiesPage() {
|
|||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
onRowClick={(r) => setForm({
|
||||
id: r.id, name: r.name, legalName: r.legalName ?? '', type: r.type,
|
||||
id: r.id, name: r.name, legalName: r.legalName ?? '', kind: r.kind, type: r.type,
|
||||
bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '',
|
||||
countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '',
|
||||
bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '',
|
||||
|
|
@ -95,7 +100,7 @@ export function CounterpartiesPage() {
|
|||
})}
|
||||
columns={[
|
||||
{ header: 'Название', cell: (r) => r.name },
|
||||
{ header: 'Тип', width: '120px', cell: (r) => typeLabel[r.type] },
|
||||
{ header: 'Тип', width: '120px', cell: (r) => kindLabel[r.kind] },
|
||||
{ header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> },
|
||||
{ header: 'Телефон', width: '160px', cell: (r) => r.phone ?? '—' },
|
||||
{ header: 'Страна', width: '120px', cell: (r) => r.countryName ?? '—' },
|
||||
|
|
@ -134,6 +139,14 @@ export function CounterpartiesPage() {
|
|||
<Field label="Юридическое название" className="col-span-2">
|
||||
<TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Роль">
|
||||
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as CounterpartyKind })}>
|
||||
<option value={CounterpartyKind.Unspecified}>Не указано</option>
|
||||
<option value={CounterpartyKind.Supplier}>Поставщик</option>
|
||||
<option value={CounterpartyKind.Customer}>Покупатель</option>
|
||||
<option value={CounterpartyKind.Both}>Поставщик + Покупатель</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Тип лица">
|
||||
<Select value={form.type} onChange={(e) => setForm({ ...form, type: Number(e.target.value) as CounterpartyType })}>
|
||||
<option value={CounterpartyType.LegalEntity}>Юрлицо</option>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
|
||||
import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package, Trash2, AlertTriangle } from 'lucide-react'
|
||||
import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
|
||||
import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package } from 'lucide-react'
|
||||
import { AxiosError } from 'axios'
|
||||
import { api } from '@/lib/api'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
|
|
@ -122,141 +122,11 @@ export function MoySkladImportPage() {
|
|||
|
||||
<ImportResult title="Товары" result={products} />
|
||||
<ImportResult title="Контрагенты" result={counterparties} />
|
||||
|
||||
<DangerZone />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CleanupStats {
|
||||
counterparties: number
|
||||
products: number
|
||||
productGroups: number
|
||||
productBarcodes: number
|
||||
productPrices: number
|
||||
supplies: number
|
||||
retailSales: number
|
||||
stocks: number
|
||||
stockMovements: number
|
||||
}
|
||||
|
||||
interface CleanupResult { scope: string; deleted: CleanupStats }
|
||||
|
||||
function DangerZone() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const stats = useQuery({
|
||||
queryKey: ['/api/admin/cleanup/stats'],
|
||||
queryFn: async () => (await api.get<CleanupStats>('/api/admin/cleanup/stats')).data,
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
|
||||
const wipeCounterparties = useMutation({
|
||||
mutationFn: async () => (await api.delete<CleanupResult>('/api/admin/cleanup/counterparties')).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries()
|
||||
},
|
||||
})
|
||||
|
||||
const wipeAll = useMutation({
|
||||
mutationFn: async () => (await api.delete<CleanupResult>('/api/admin/cleanup/all')).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries()
|
||||
},
|
||||
})
|
||||
|
||||
const confirmAndRun = (label: string, run: () => void) => {
|
||||
const word = prompt(`Введи УДАЛИТЬ чтобы подтвердить: ${label}`)
|
||||
if (word?.trim().toUpperCase() === 'УДАЛИТЬ') run()
|
||||
}
|
||||
|
||||
const s = stats.data
|
||||
|
||||
return (
|
||||
<section className="mt-8 rounded-xl border-2 border-red-200 dark:border-red-900/50 bg-red-50/40 dark:bg-red-950/20 p-5">
|
||||
<h2 className="text-sm font-semibold text-red-900 dark:text-red-300 flex items-center gap-2 mb-1.5">
|
||||
<AlertTriangle className="w-4 h-4" /> Опасная зона — временные инструменты очистки
|
||||
</h2>
|
||||
<p className="text-xs text-red-900/80 dark:text-red-300/80 mb-4">
|
||||
Удаляет данные текущей организации. Справочники (ед. измерения, страны, валюты, типы цен, склады, точки продаж), пользователи и сама организация сохраняются.
|
||||
</p>
|
||||
|
||||
{s && (
|
||||
<dl className="grid grid-cols-3 md:grid-cols-5 gap-2 text-xs mb-4">
|
||||
<Stat label="Контрагенты" value={s.counterparties} />
|
||||
<Stat label="Товары" value={s.products} />
|
||||
<Stat label="Группы" value={s.productGroups} />
|
||||
<Stat label="Штрихкоды" value={s.productBarcodes} />
|
||||
<Stat label="Цены" value={s.productPrices} />
|
||||
<Stat label="Поставки" value={s.supplies} />
|
||||
<Stat label="Чеки" value={s.retailSales} />
|
||||
<Stat label="Остатки" value={s.stocks} />
|
||||
<Stat label="Движения" value={s.stockMovements} />
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => confirmAndRun(
|
||||
`${s?.counterparties ?? '?'} контрагентов (+ связанные поставки/движения)`,
|
||||
() => wipeCounterparties.mutate(),
|
||||
)}
|
||||
disabled={wipeCounterparties.isPending || !s || s.counterparties === 0}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{wipeCounterparties.isPending ? 'Удаляю…' : 'Удалить контрагентов'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => confirmAndRun(
|
||||
'ВСЕ данные организации (товары, группы, контрагенты, документы, остатки)',
|
||||
() => wipeAll.mutate(),
|
||||
)}
|
||||
disabled={wipeAll.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{wipeAll.isPending ? 'Удаляю всё…' : 'Очистить все данные'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{wipeCounterparties.data && (
|
||||
<div className="mt-3 text-xs text-red-900 dark:text-red-300">
|
||||
Удалено: {wipeCounterparties.data.deleted.counterparties} контрагентов,
|
||||
{' '}{wipeCounterparties.data.deleted.supplies} поставок,
|
||||
{' '}{wipeCounterparties.data.deleted.stockMovements} движений.
|
||||
</div>
|
||||
)}
|
||||
{wipeAll.data && (
|
||||
<div className="mt-3 text-xs text-red-900 dark:text-red-300">
|
||||
Удалено: {wipeAll.data.deleted.counterparties} контрагентов,
|
||||
{' '}{wipeAll.data.deleted.products} товаров,
|
||||
{' '}{wipeAll.data.deleted.productGroups} групп,
|
||||
{' '}{wipeAll.data.deleted.supplies} поставок,
|
||||
{' '}{wipeAll.data.deleted.retailSales} чеков,
|
||||
{' '}{wipeAll.data.deleted.stockMovements} движений.
|
||||
</div>
|
||||
)}
|
||||
{wipeCounterparties.error && (
|
||||
<div className="mt-3 text-xs text-red-700">{formatError(wipeCounterparties.error)}</div>
|
||||
)}
|
||||
{wipeAll.error && (
|
||||
<div className="mt-3 text-xs text-red-700">{formatError(wipeAll.error)}</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded bg-white dark:bg-slate-900 border border-red-200 dark:border-red-900/50 px-2 py-1.5">
|
||||
<dt className="text-[10px] uppercase text-slate-500">{label}</dt>
|
||||
<dd className="font-mono font-semibold">{value.toLocaleString('ru')}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportResult({ title, result }: { title: string; result: UseMutationResult<ImportResponse, Error, void, unknown> }) {
|
||||
if (!result.data && !result.error) return null
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { api } from '@/lib/api'
|
|||
import { Button } from '@/components/Button'
|
||||
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
|
||||
import {
|
||||
useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
|
||||
useUnits, useVatRates, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
|
||||
} from '@/lib/useLookups'
|
||||
import { BarcodeType, type Product } from '@/lib/types'
|
||||
|
||||
|
|
@ -18,13 +18,13 @@ interface Form {
|
|||
article: string
|
||||
description: string
|
||||
unitOfMeasureId: string
|
||||
vat: number
|
||||
vatEnabled: boolean
|
||||
vatRateId: string
|
||||
productGroupId: string
|
||||
defaultSupplierId: string
|
||||
countryOfOriginId: string
|
||||
isService: boolean
|
||||
isWeighed: boolean
|
||||
isAlcohol: boolean
|
||||
isMarked: boolean
|
||||
isActive: boolean
|
||||
minStock: string
|
||||
|
|
@ -36,15 +36,11 @@ interface Form {
|
|||
barcodes: BarcodeRow[]
|
||||
}
|
||||
|
||||
// KZ default VAT rate.
|
||||
const defaultVat = 16
|
||||
const vatChoices = [0, 10, 12, 16, 20]
|
||||
|
||||
const emptyForm: Form = {
|
||||
name: '', article: '', description: '',
|
||||
unitOfMeasureId: '', vat: defaultVat, vatEnabled: true,
|
||||
unitOfMeasureId: '', vatRateId: '',
|
||||
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
|
||||
isService: false, isWeighed: false, isMarked: false, isActive: true,
|
||||
isService: false, isWeighed: false, isAlcohol: false, isMarked: false, isActive: true,
|
||||
minStock: '', maxStock: '',
|
||||
purchasePrice: '', purchaseCurrencyId: '',
|
||||
imageUrl: '',
|
||||
|
|
@ -59,6 +55,7 @@ export function ProductEditPage() {
|
|||
const qc = useQueryClient()
|
||||
|
||||
const units = useUnits()
|
||||
const vats = useVatRates()
|
||||
const groups = useProductGroups()
|
||||
const countries = useCountries()
|
||||
const currencies = useCurrencies()
|
||||
|
|
@ -79,10 +76,10 @@ export function ProductEditPage() {
|
|||
const p = existing.data
|
||||
setForm({
|
||||
name: p.name, article: p.article ?? '', description: p.description ?? '',
|
||||
unitOfMeasureId: p.unitOfMeasureId, vat: p.vat, vatEnabled: p.vatEnabled,
|
||||
unitOfMeasureId: p.unitOfMeasureId, vatRateId: p.vatRateId,
|
||||
productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '',
|
||||
countryOfOriginId: p.countryOfOriginId ?? '',
|
||||
isService: p.isService, isWeighed: p.isWeighed, isMarked: p.isMarked,
|
||||
isService: p.isService, isWeighed: p.isWeighed, isAlcohol: p.isAlcohol, isMarked: p.isMarked,
|
||||
isActive: p.isActive,
|
||||
minStock: p.minStock?.toString() ?? '',
|
||||
maxStock: p.maxStock?.toString() ?? '',
|
||||
|
|
@ -96,13 +93,16 @@ export function ProductEditPage() {
|
|||
}, [isNew, existing.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew && form.vatRateId === '' && vats.data?.length) {
|
||||
setForm((f) => ({ ...f, vatRateId: vats.data?.find(v => v.isDefault)?.id ?? vats.data?.[0]?.id ?? '' }))
|
||||
}
|
||||
if (isNew && form.unitOfMeasureId === '' && units.data?.length) {
|
||||
setForm((f) => ({ ...f, unitOfMeasureId: units.data?.[0]?.id ?? '' }))
|
||||
setForm((f) => ({ ...f, unitOfMeasureId: units.data?.find(u => u.isBase)?.id ?? units.data?.[0]?.id ?? '' }))
|
||||
}
|
||||
if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) {
|
||||
setForm((f) => ({ ...f, purchaseCurrencyId: currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id ?? '' }))
|
||||
}
|
||||
}, [isNew, units.data, currencies.data, form.unitOfMeasureId, form.purchaseCurrencyId])
|
||||
}, [isNew, vats.data, units.data, currencies.data, form.vatRateId, form.unitOfMeasureId, form.purchaseCurrencyId])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
|
|
@ -111,13 +111,13 @@ export function ProductEditPage() {
|
|||
article: form.article || null,
|
||||
description: form.description || null,
|
||||
unitOfMeasureId: form.unitOfMeasureId,
|
||||
vat: form.vat,
|
||||
vatEnabled: form.vatEnabled,
|
||||
vatRateId: form.vatRateId,
|
||||
productGroupId: form.productGroupId || null,
|
||||
defaultSupplierId: form.defaultSupplierId || null,
|
||||
countryOfOriginId: form.countryOfOriginId || null,
|
||||
isService: form.isService,
|
||||
isWeighed: form.isWeighed,
|
||||
isAlcohol: form.isAlcohol,
|
||||
isMarked: form.isMarked,
|
||||
isActive: form.isActive,
|
||||
minStock: form.minStock === '' ? null : Number(form.minStock),
|
||||
|
|
@ -168,7 +168,7 @@ export function ProductEditPage() {
|
|||
const updateBarcode = (i: number, patch: Partial<BarcodeRow>) =>
|
||||
setForm({ ...form, barcodes: form.barcodes.map((b, ix) => ix === i ? { ...b, ...patch } : b) })
|
||||
|
||||
const canSave = form.name.trim().length > 0 && !!form.unitOfMeasureId
|
||||
const canSave = form.name.trim().length > 0 && !!form.unitOfMeasureId && !!form.vatRateId
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="flex flex-col h-full">
|
||||
|
|
@ -234,12 +234,13 @@ export function ProductEditPage() {
|
|||
<Field label="Единица измерения *">
|
||||
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.symbol} — {u.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Ставка НДС, %">
|
||||
<Select value={form.vat} onChange={(e) => setForm({ ...form, vat: Number(e.target.value) })}>
|
||||
{vatChoices.map((v) => <option key={v} value={v}>{v}%</option>)}
|
||||
<Field label="Ставка НДС *">
|
||||
<Select required value={form.vatRateId} onChange={(e) => setForm({ ...form, vatRateId: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{vats.data?.map((v) => <option key={v.id} value={v.id}>{v.name} ({v.percent}%)</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Группа">
|
||||
|
|
@ -265,9 +266,9 @@ export function ProductEditPage() {
|
|||
</Field>
|
||||
</Grid>
|
||||
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2">
|
||||
<Checkbox label="НДС применяется" checked={form.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} />
|
||||
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
|
||||
<Checkbox label="Весовой" checked={form.isWeighed} onChange={(v) => setForm({ ...form, isWeighed: v })} />
|
||||
<Checkbox label="Алкоголь" checked={form.isAlcohol} onChange={(v) => setForm({ ...form, isAlcohol: v })} />
|
||||
<Checkbox label="Маркируемый" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
|
||||
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,159 +1,37 @@
|
|||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { ListPageShell } from '@/components/ListPageShell'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
import { Plus, Filter, X } from 'lucide-react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useCatalogList } from '@/lib/useCatalog'
|
||||
import { ProductGroupTree } from '@/components/ProductGroupTree'
|
||||
import type { Product } from '@/lib/types'
|
||||
|
||||
const URL = '/api/catalog/products'
|
||||
|
||||
type TriFilter = 'all' | 'yes' | 'no'
|
||||
|
||||
interface Filters {
|
||||
groupId: string | null
|
||||
isActive: TriFilter
|
||||
isService: TriFilter
|
||||
isWeighed: TriFilter
|
||||
isMarked: TriFilter
|
||||
hasBarcode: TriFilter
|
||||
}
|
||||
|
||||
const defaultFilters: Filters = {
|
||||
groupId: null,
|
||||
isActive: 'yes',
|
||||
isService: 'all',
|
||||
isWeighed: 'all',
|
||||
isMarked: 'all',
|
||||
hasBarcode: 'all',
|
||||
}
|
||||
|
||||
const toExtra = (f: Filters): Record<string, string | number | boolean | undefined> => {
|
||||
const e: Record<string, string | number | boolean | undefined> = {}
|
||||
if (f.groupId) e.groupId = f.groupId
|
||||
if (f.isActive !== 'all') e.isActive = f.isActive === 'yes'
|
||||
if (f.isService !== 'all') e.isService = f.isService === 'yes'
|
||||
if (f.isWeighed !== 'all') e.isWeighed = f.isWeighed === 'yes'
|
||||
if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes'
|
||||
if (f.hasBarcode !== 'all') e.hasBarcode = f.hasBarcode === 'yes'
|
||||
return e
|
||||
}
|
||||
|
||||
const activeFilterCount = (f: Filters) => {
|
||||
let n = 0
|
||||
if (f.groupId) n++
|
||||
if (f.isActive !== 'yes') n++ // 'yes' is default, count non-default
|
||||
if (f.isService !== 'all') n++
|
||||
if (f.isWeighed !== 'all') n++
|
||||
if (f.isMarked !== 'all') n++
|
||||
if (f.hasBarcode !== 'all') n++
|
||||
return n
|
||||
}
|
||||
|
||||
function Tri({
|
||||
label, value, onChange, yesLabel = 'да', noLabel = 'нет',
|
||||
}: {
|
||||
label: string
|
||||
value: TriFilter
|
||||
onChange: (v: TriFilter) => void
|
||||
yesLabel?: string
|
||||
noLabel?: string
|
||||
}) {
|
||||
const opts: { v: TriFilter; t: string }[] = [
|
||||
{ v: 'all', t: 'все' },
|
||||
{ v: 'yes', t: yesLabel },
|
||||
{ v: 'no', t: noLabel },
|
||||
]
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-slate-500">{label}</span>
|
||||
<div className="inline-flex rounded border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
{opts.map((o) => (
|
||||
<button
|
||||
key={o.v}
|
||||
type="button"
|
||||
onClick={() => onChange(o.v)}
|
||||
className={
|
||||
'px-2 py-0.5 ' +
|
||||
(value === o.v
|
||||
? 'bg-slate-900 text-white dark:bg-slate-200 dark:text-slate-900'
|
||||
: 'bg-white dark:bg-slate-900 text-slate-600 hover:bg-slate-50 dark:hover:bg-slate-800')
|
||||
}
|
||||
>
|
||||
{o.t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProductsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [filters, setFilters] = useState<Filters>(defaultFilters)
|
||||
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL, toExtra(filters))
|
||||
const activeCount = activeFilterCount(filters)
|
||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL)
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
{/* Left: groups tree */}
|
||||
<aside className="w-64 flex-shrink-0 border-r border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-900/40 flex flex-col min-h-0">
|
||||
<ProductGroupTree
|
||||
selectedId={filters.groupId}
|
||||
onSelect={(id) => { setFilters({ ...filters, groupId: id }); setPage(1) }}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Right: products */}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{/* Top bar */}
|
||||
<div className="px-6 py-3 border-b border-slate-200 dark:border-slate-800 flex items-center gap-3 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">Товары</h1>
|
||||
<p className="text-xs text-slate-500">
|
||||
{data ? `${data.total.toLocaleString('ru')} записей` : 'Каталог товаров и услуг'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SearchBar value={search} onChange={setSearch} placeholder="Поиск: название, артикул, штрихкод…" />
|
||||
<Button
|
||||
variant={filtersOpen || activeCount ? 'primary' : 'secondary'}
|
||||
onClick={() => setFiltersOpen((v) => !v)}
|
||||
>
|
||||
<Filter className="w-4 h-4" /> Фильтры{activeCount > 0 ? ` (${activeCount})` : ''}
|
||||
</Button>
|
||||
<ListPageShell
|
||||
title="Товары"
|
||||
description={data ? `${data.total.toLocaleString('ru')} записей в каталоге` : 'Каталог товаров и услуг'}
|
||||
actions={
|
||||
<>
|
||||
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" />
|
||||
<Link to="/catalog/products/new">
|
||||
<Button><Plus className="w-4 h-4" /> Добавить</Button>
|
||||
<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"
|
||||
</>
|
||||
}
|
||||
footer={data && data.total > 0 && (
|
||||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
|
|
@ -167,12 +45,13 @@ export function ProductsPage() {
|
|||
</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: '70px', cell: (r) => r.unitSymbol },
|
||||
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vatPercent}%` },
|
||||
{ header: 'Тип', width: '140px', cell: (r) => (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{r.isService && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">Услуга</span>}
|
||||
{r.isWeighed && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Весовой</span>}
|
||||
{r.isAlcohol && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-red-700">Алкоголь</span>}
|
||||
{r.isMarked && <span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">Маркир.</span>}
|
||||
</div>
|
||||
)},
|
||||
|
|
@ -181,14 +60,6 @@ export function ProductsPage() {
|
|||
]}
|
||||
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>
|
||||
</ListPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ interface LineRow {
|
|||
productId: string
|
||||
productName: string
|
||||
productArticle: string | null
|
||||
unitName: string | null
|
||||
unitSymbol: string | null
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
discount: number
|
||||
|
|
@ -81,7 +81,7 @@ export function RetailSaleEditPage() {
|
|||
productId: l.productId,
|
||||
productName: l.productName ?? '',
|
||||
productArticle: l.productArticle,
|
||||
unitName: l.unitName,
|
||||
unitSymbol: l.unitSymbol,
|
||||
quantity: l.quantity, unitPrice: l.unitPrice, discount: l.discount,
|
||||
vatPercent: l.vatPercent,
|
||||
})),
|
||||
|
|
@ -179,11 +179,11 @@ export function RetailSaleEditPage() {
|
|||
productId: p.id,
|
||||
productName: p.name,
|
||||
productArticle: p.article,
|
||||
unitName: p.unitName,
|
||||
unitSymbol: p.unitSymbol,
|
||||
quantity: 1,
|
||||
unitPrice: retail?.amount ?? 0,
|
||||
discount: 0,
|
||||
vatPercent: p.vat * 1,
|
||||
vatPercent: p.vatPercent,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
|
@ -323,7 +323,7 @@ export function RetailSaleEditPage() {
|
|||
<div className="font-medium">{l.productName}</div>
|
||||
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-slate-500">{l.unitName}</td>
|
||||
<td className="py-2 px-3 text-slate-500">{l.unitSymbol}</td>
|
||||
<td className="py-2 px-3">
|
||||
<TextInput type="number" step="0.001" disabled={isPosted}
|
||||
className="text-right font-mono" value={l.quantity}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export function StockPage() {
|
|||
</div>
|
||||
)},
|
||||
{ header: 'Склад', width: '220px', cell: (r) => r.storeName },
|
||||
{ header: 'Ед.', width: '80px', cell: (r) => r.unitName },
|
||||
{ header: 'Ед.', width: '80px', cell: (r) => r.unitSymbol },
|
||||
{ header: 'Остаток', width: '130px', className: 'text-right font-mono', cell: (r) => r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 }) },
|
||||
{ header: 'Резерв', width: '110px', className: 'text-right font-mono text-slate-400', cell: (r) => r.reservedQuantity ? r.reservedQuantity.toLocaleString('ru') : '—' },
|
||||
{ header: 'Доступно', width: '130px', className: 'text-right font-mono font-semibold', cell: (r) => (
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import { Pagination } from '@/components/Pagination'
|
|||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
import { Modal } from '@/components/Modal'
|
||||
import { Field, TextInput, Checkbox } from '@/components/Field'
|
||||
import { Field, TextInput, Select, Checkbox } from '@/components/Field'
|
||||
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
||||
import { type Store } from '@/lib/types'
|
||||
import { type Store, StoreKind } from '@/lib/types'
|
||||
|
||||
const URL = '/api/catalog/stores'
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ interface Form {
|
|||
id?: string
|
||||
name: string
|
||||
code: string
|
||||
kind: StoreKind
|
||||
address: string
|
||||
phone: string
|
||||
managerName: string
|
||||
|
|
@ -24,7 +25,7 @@ interface Form {
|
|||
}
|
||||
|
||||
const blankForm: Form = {
|
||||
name: '', code: '', address: '', phone: '',
|
||||
name: '', code: '', kind: StoreKind.Warehouse, address: '', phone: '',
|
||||
managerName: '', isMain: false, isActive: true,
|
||||
}
|
||||
|
||||
|
|
@ -61,13 +62,14 @@ export function StoresPage() {
|
|||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
onRowClick={(r) => setForm({
|
||||
id: r.id, name: r.name, code: r.code ?? '',
|
||||
id: r.id, name: r.name, code: r.code ?? '', kind: r.kind,
|
||||
address: r.address ?? '', phone: r.phone ?? '', managerName: r.managerName ?? '',
|
||||
isMain: r.isMain, isActive: r.isActive,
|
||||
})}
|
||||
columns={[
|
||||
{ header: 'Название', cell: (r) => r.name },
|
||||
{ header: 'Код', width: '120px', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> },
|
||||
{ header: 'Тип', width: '150px', cell: (r) => r.kind === StoreKind.Warehouse ? 'Склад' : 'Торговый зал' },
|
||||
{ header: 'Адрес', cell: (r) => r.address ?? '—' },
|
||||
{ header: 'Основной', width: '110px', cell: (r) => r.isMain ? '✓' : '—' },
|
||||
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
||||
|
|
@ -106,6 +108,12 @@ export function StoresPage() {
|
|||
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Тип">
|
||||
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as StoreKind })}>
|
||||
<option value={StoreKind.Warehouse}>Склад</option>
|
||||
<option value={StoreKind.RetailFloor}>Торговый зал</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Адрес">
|
||||
<TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||||
</Field>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ interface LineRow {
|
|||
productId: string
|
||||
productName: string
|
||||
productArticle: string | null
|
||||
unitName: string | null
|
||||
unitSymbol: string | null
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
}
|
||||
|
|
@ -76,7 +76,7 @@ export function SupplyEditPage() {
|
|||
productId: l.productId,
|
||||
productName: l.productName ?? '',
|
||||
productArticle: l.productArticle,
|
||||
unitName: l.unitName,
|
||||
unitSymbol: l.unitSymbol,
|
||||
quantity: l.quantity,
|
||||
unitPrice: l.unitPrice,
|
||||
})),
|
||||
|
|
@ -169,7 +169,7 @@ export function SupplyEditPage() {
|
|||
productId: p.id,
|
||||
productName: p.name,
|
||||
productArticle: p.article,
|
||||
unitName: p.unitName,
|
||||
unitSymbol: p.unitSymbol,
|
||||
quantity: 1,
|
||||
unitPrice: p.purchasePrice ?? 0,
|
||||
}],
|
||||
|
|
@ -304,7 +304,7 @@ export function SupplyEditPage() {
|
|||
<div className="font-medium">{l.productName}</div>
|
||||
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-slate-500">{l.unitName}</td>
|
||||
<td className="py-2 px-3 text-slate-500">{l.unitSymbol}</td>
|
||||
<td className="py-2 px-3">
|
||||
<TextInput type="number" step="0.001" disabled={isPosted}
|
||||
className="text-right font-mono"
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ const URL = '/api/catalog/units-of-measure'
|
|||
interface Form {
|
||||
id?: string
|
||||
code: string
|
||||
symbol: string
|
||||
name: string
|
||||
description: string
|
||||
decimalPlaces: number
|
||||
isBase: boolean
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const blankForm: Form = { code: '', name: '', description: '', isActive: true }
|
||||
const blankForm: Form = { code: '', symbol: '', name: '', decimalPlaces: 0, isBase: false, isActive: true }
|
||||
|
||||
export function UnitsOfMeasurePage() {
|
||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<UnitOfMeasure>(URL)
|
||||
|
|
@ -39,7 +41,7 @@ export function UnitsOfMeasurePage() {
|
|||
<>
|
||||
<ListPageShell
|
||||
title="Единицы измерения"
|
||||
description="Код по ОКЕИ (штука=796, килограмм=166, литр=112, метр=006)."
|
||||
description="Код по ОКЕИ (шт=796, кг=166, л=112, м=006)."
|
||||
actions={
|
||||
<>
|
||||
<SearchBar value={search} onChange={setSearch} />
|
||||
|
|
@ -54,14 +56,13 @@ export function UnitsOfMeasurePage() {
|
|||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
onRowClick={(r) => setForm({
|
||||
id: r.id, code: r.code, name: r.name,
|
||||
description: r.description ?? '', isActive: r.isActive,
|
||||
})}
|
||||
onRowClick={(r) => setForm({ ...r })}
|
||||
columns={[
|
||||
{ header: 'Код', width: '90px', cell: (r) => <span className="font-mono">{r.code}</span> },
|
||||
{ header: 'Символ', width: '100px', cell: (r) => r.symbol },
|
||||
{ header: 'Название', cell: (r) => r.name },
|
||||
{ header: 'Описание', cell: (r) => r.description ?? '—' },
|
||||
{ header: 'Дробность', width: '120px', className: 'text-right', cell: (r) => r.decimalPlaces },
|
||||
{ header: 'Базовая', width: '100px', cell: (r) => r.isBase ? '✓' : '—' },
|
||||
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
||||
]}
|
||||
/>
|
||||
|
|
@ -90,15 +91,25 @@ export function UnitsOfMeasurePage() {
|
|||
>
|
||||
{form && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Код ОКЕИ">
|
||||
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Символ">
|
||||
<TextInput value={form.symbol} onChange={(e) => setForm({ ...form, symbol: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Название">
|
||||
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Описание">
|
||||
<TextInput value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
<Field label="Количество знаков после запятой">
|
||||
<TextInput
|
||||
type="number" min="0" max="6"
|
||||
value={form.decimalPlaces}
|
||||
onChange={(e) => setForm({ ...form, decimalPlaces: Number(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
<Checkbox label="Базовая единица организации" checked={form.isBase} onChange={(v) => setForm({ ...form, isBase: v })} />
|
||||
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
116
src/food-market.web/src/pages/VatRatesPage.tsx
Normal file
116
src/food-market.web/src/pages/VatRatesPage.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { useState } from 'react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { ListPageShell } from '@/components/ListPageShell'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
import { Modal } from '@/components/Modal'
|
||||
import { Field, TextInput, Checkbox } from '@/components/Field'
|
||||
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
||||
import type { VatRate } from '@/lib/types'
|
||||
|
||||
const URL = '/api/catalog/vat-rates'
|
||||
|
||||
interface Form {
|
||||
id?: string
|
||||
name: string
|
||||
percent: number
|
||||
isIncludedInPrice: boolean
|
||||
isDefault: boolean
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const blankForm: Form = { name: '', percent: 0, isIncludedInPrice: true, isDefault: false, isActive: true }
|
||||
|
||||
export function VatRatesPage() {
|
||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<VatRate>(URL)
|
||||
const { create, update, remove } = useCatalogMutations(URL, URL)
|
||||
const [form, setForm] = useState<Form | null>(null)
|
||||
|
||||
const save = async () => {
|
||||
if (!form) return
|
||||
const payload = {
|
||||
name: form.name, percent: form.percent,
|
||||
isIncludedInPrice: form.isIncludedInPrice, isDefault: form.isDefault, isActive: form.isActive,
|
||||
}
|
||||
if (form.id) await update.mutateAsync({ id: form.id, input: payload })
|
||||
else await create.mutateAsync(payload)
|
||||
setForm(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListPageShell
|
||||
title="Ставки НДС"
|
||||
description="Настройки ставок налога на добавленную стоимость."
|
||||
actions={
|
||||
<>
|
||||
<SearchBar value={search} onChange={setSearch} />
|
||||
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
|
||||
</>
|
||||
}
|
||||
footer={data && data.total > 0 && (
|
||||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
)}
|
||||
>
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
onRowClick={(r) => setForm({
|
||||
id: r.id, name: r.name, percent: r.percent,
|
||||
isIncludedInPrice: r.isIncludedInPrice, isDefault: r.isDefault, isActive: r.isActive,
|
||||
})}
|
||||
columns={[
|
||||
{ header: 'Название', cell: (r) => r.name },
|
||||
{ header: '%', width: '90px', className: 'text-right font-mono', cell: (r) => r.percent.toFixed(2) },
|
||||
{ header: 'В цене', width: '100px', cell: (r) => r.isIncludedInPrice ? '✓' : '—' },
|
||||
{ header: 'По умолчанию', width: '140px', cell: (r) => r.isDefault ? '✓' : '—' },
|
||||
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
||||
]}
|
||||
/>
|
||||
</ListPageShell>
|
||||
|
||||
<Modal
|
||||
open={!!form}
|
||||
onClose={() => setForm(null)}
|
||||
title={form?.id ? 'Редактировать ставку НДС' : 'Новая ставка НДС'}
|
||||
footer={
|
||||
<>
|
||||
{form?.id && (
|
||||
<Button variant="danger" size="sm" onClick={async () => {
|
||||
if (confirm('Удалить ставку?')) {
|
||||
await remove.mutateAsync(form.id!)
|
||||
setForm(null)
|
||||
}
|
||||
}}>
|
||||
<Trash2 className="w-4 h-4" /> Удалить
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
|
||||
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{form && (
|
||||
<div className="space-y-3">
|
||||
<Field label="Название">
|
||||
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Процент">
|
||||
<TextInput
|
||||
type="number" step="0.01"
|
||||
value={form.percent}
|
||||
onChange={(e) => setForm({ ...form, percent: Number(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
<Checkbox label="НДС включён в цену" checked={form.isIncludedInPrice} onChange={(v) => setForm({ ...form, isIncludedInPrice: v })} />
|
||||
<Checkbox label="По умолчанию" checked={form.isDefault} onChange={(v) => setForm({ ...form, isDefault: v })} />
|
||||
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue