Compare commits
173 commits
c7bf7e13ce
...
dcc3f9d61c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcc3f9d61c | ||
|
|
5f7cfa0d5b | ||
|
|
f5a232a32e | ||
|
|
4ac7dc585c | ||
|
|
ad09d48a89 | ||
|
|
fc3f63c49a | ||
|
|
2cadb6e5d9 | ||
|
|
79016579c2 | ||
|
|
5c5b231157 | ||
|
|
bd2800837f | ||
|
|
8d72e9da2d | ||
|
|
61cb97a1b7 | ||
|
|
21c2ca89fe | ||
|
|
7c161a138b | ||
|
|
9d8fd2cb53 | ||
|
|
4d4a3a4786 | ||
|
|
8ae9f68119 | ||
|
|
6f61bbd974 | ||
|
|
4dd6b16eed | ||
|
|
b2d6584f09 | ||
|
|
8ff0a56144 | ||
|
|
59983acabd | ||
|
|
92d2eb0432 | ||
|
|
a7fcc9f9e1 | ||
|
|
e3bc5cacfc | ||
|
|
18eb362702 | ||
|
|
e93634fad4 | ||
|
|
bf6880fe0f | ||
|
|
372ca9ec16 | ||
|
|
1d9fd7297c | ||
|
|
d2305b7d40 | ||
|
|
5737c65215 | ||
|
|
d3bcbee8b9 | ||
|
|
f5cccb6f10 | ||
|
|
c714ec265c | ||
|
|
0ff31d1450 | ||
|
|
f38d34f42d | ||
|
|
cec76ecaaf | ||
|
|
dc4b5360b9 | ||
|
|
e96f1cdc86 | ||
|
|
39da12edec | ||
|
|
dc162a6c06 | ||
|
|
c54c26cf2b | ||
|
|
6a673e536e | ||
|
|
c31611d6c4 | ||
|
|
7d09873be2 | ||
|
|
970a9baec3 | ||
|
|
b56c499b45 | ||
|
|
95bf2188c6 | ||
|
|
94d3c4687b | ||
|
|
f7deecc41c | ||
|
|
9008939249 | ||
|
|
532c1aaf24 | ||
|
|
48babf0d10 | ||
|
|
28b264f43b | ||
|
|
168b12345d | ||
|
|
b7288bac1b | ||
|
|
72e602f4ca | ||
|
|
2321010608 | ||
|
|
196658e548 | ||
|
|
306153d128 | ||
|
|
eaf5b7399b | ||
|
|
fea3498b8b | ||
|
|
4649a624c3 | ||
|
|
a8717897b7 | ||
|
|
c257ee7e88 | ||
|
|
ebdc70bd58 | ||
|
|
db3be5bbca | ||
|
|
d1ebbef671 | ||
|
|
126ff97a11 | ||
|
|
c7a498cf9a | ||
|
|
d2160f8910 | ||
|
|
ba7de0b513 | ||
|
|
b257ea528d | ||
|
|
b79c71591d | ||
|
|
9c0e9494f3 | ||
|
|
6729a390bf | ||
|
|
7fe30bd98d | ||
|
|
a5f0fb83d8 | ||
|
|
e74bec3964 | ||
|
|
de23f5fc7a | ||
|
|
38040b4ec7 | ||
|
|
6acf6b7c03 | ||
|
|
b8fd5ec2bd | ||
|
|
a594a433d4 | ||
|
|
fd2da58ad4 | ||
|
|
ad05f9fe30 | ||
|
|
aec5ca5591 | ||
|
|
3c576934c7 | ||
|
|
8d9cd201b4 | ||
|
|
9077c07584 | ||
|
|
71b749fb35 | ||
|
|
87271281b0 | ||
|
|
7d86b7ed73 | ||
|
|
8a0f8c20f9 | ||
|
|
53fa4d2deb | ||
|
|
d1a7e1e647 | ||
|
|
4f4a751d26 | ||
|
|
38f7725593 | ||
|
|
23e29be21b | ||
|
|
adf2c90904 | ||
|
|
68ccc9fa1d | ||
|
|
38f117b2a4 | ||
|
|
6468186ed5 | ||
|
|
9c70de9b3d | ||
|
|
9886b5dee1 | ||
|
|
195ca2e2bb | ||
|
|
efeeb61e42 | ||
|
|
24dc7fc619 | ||
|
|
781f268089 | ||
|
|
42a3d2aa50 | ||
|
|
1227fbdfa5 | ||
|
|
5a06e15924 | ||
|
|
31d528d5c2 | ||
|
|
5bea852b94 | ||
|
|
6979599791 | ||
|
|
e16375ccf6 | ||
|
|
d93edcae2c | ||
|
|
337e790eab | ||
|
|
a8b3ef40ce | ||
|
|
a3b3caa2d3 | ||
|
|
ce0c3acdd6 | ||
|
|
9ce22dee26 | ||
|
|
15ea4b9e86 | ||
|
|
c172cfda5e | ||
|
|
188114d193 | ||
|
|
405fe62475 | ||
|
|
71a2d46bf2 | ||
|
|
654a8ba87d | ||
|
|
d31ecec759 | ||
|
|
720765ee77 | ||
|
|
d7ee6d12f8 | ||
|
|
d5f53e1a00 | ||
|
|
d4dea84502 | ||
|
|
e01e6bfd7b | ||
|
|
5a47c6ae26 | ||
|
|
8772a8826d | ||
|
|
7bf4a8233e | ||
|
|
55a63a6446 | ||
|
|
9facd4845d | ||
|
|
e1f724098f | ||
|
|
3b6ca0316c | ||
|
|
36da65693d | ||
|
|
306e38f2e8 | ||
|
|
f04345f9f6 | ||
|
|
46be745daa | ||
|
|
c17d511af8 | ||
|
|
7ccf7125e4 | ||
|
|
ab4c7e8b0a | ||
|
|
a21a59ee45 | ||
|
|
42c3d22c54 | ||
|
|
8ca0886f36 | ||
|
|
9739506dc8 | ||
|
|
61558179e3 | ||
|
|
7341029420 | ||
|
|
ce62561257 | ||
|
|
56b95d70e2 | ||
|
|
dfa7ce075a | ||
|
|
e726cba5d8 | ||
|
|
9052d76871 | ||
|
|
447ac654de | ||
|
|
7431afa620 | ||
|
|
eec9cef856 | ||
|
|
7023967f7f | ||
|
|
14abf962ce | ||
|
|
621845d12e | ||
|
|
7543e5e251 | ||
|
|
06d62ff88d | ||
|
|
e499f8a0b3 | ||
|
|
4ebc4cb0c2 | ||
|
|
5eecda6005 | ||
|
|
2483bbef1b | ||
|
|
303eaa7359 |
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"sessionId":"791848bb-ad06-4ccc-9c5a-13da4ee36524","pid":353363,"acquiredAt":1776883641374}
|
||||||
113
.forgejo/workflows/ci.yml
Normal file
113
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- 'docs/**'
|
||||||
|
- '.github/**'
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- 'docs/**'
|
||||||
|
- '.github/**'
|
||||||
|
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
|
||||||
|
|
||||||
|
# Кэшируем NuGet-пакеты по hash *.csproj — restore становится мгновенным,
|
||||||
|
# если зависимости не менялись.
|
||||||
|
- name: Cache NuGet
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'food-market.sln') }}
|
||||||
|
restore-keys: |
|
||||||
|
nuget-${{ runner.os }}-
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
# Кэшируем pnpm store по hash pnpm-lock.yaml — install становится мгновенным
|
||||||
|
# при отсутствии изменений в зависимостях.
|
||||||
|
- name: Resolve pnpm store path
|
||||||
|
id: pnpm-store
|
||||||
|
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Cache pnpm store
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-store.outputs.path }}
|
||||||
|
key: pnpm-${{ runner.os }}-${{ hashFiles('src/food-market.web/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
|
||||||
|
- 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
|
||||||
106
.forgejo/workflows/docker-api.yml
Normal file
106
.forgejo/workflows/docker-api.yml
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
name: Docker API
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'src/food-market.api/**'
|
||||||
|
- 'src/food-market.application/**'
|
||||||
|
- 'src/food-market.domain/**'
|
||||||
|
- 'src/food-market.infrastructure/**'
|
||||||
|
- 'src/food-market.shared/**'
|
||||||
|
- 'deploy/Dockerfile.api'
|
||||||
|
- 'deploy/docker-compose.yml'
|
||||||
|
- '.forgejo/workflows/docker-api.yml'
|
||||||
|
- 'food-market.sln'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
LOCAL_REGISTRY: 127.0.0.1:5001
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build + push API
|
||||||
|
runs-on: [self-hosted, linux]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build + push (Docker daemon layer-cache)
|
||||||
|
env:
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
DOCKER_BUILDKIT: '1'
|
||||||
|
run: |
|
||||||
|
# Используем обычный docker build — у host docker daemon в
|
||||||
|
# /etc/docker/daemon.json уже прописан 127.0.0.1:5001 как
|
||||||
|
# insecure-registry, и docker layer-cache между сборками
|
||||||
|
# дает быстрый dotnet restore/pnpm install при стабильных манифестах.
|
||||||
|
docker build \
|
||||||
|
-f deploy/Dockerfile.api \
|
||||||
|
-t $LOCAL_REGISTRY/food-market-api:$SHA \
|
||||||
|
-t $LOCAL_REGISTRY/food-market-api:latest \
|
||||||
|
.
|
||||||
|
docker push $LOCAL_REGISTRY/food-market-api:$SHA
|
||||||
|
docker push $LOCAL_REGISTRY/food-market-api:latest
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy API on stage
|
||||||
|
needs: build
|
||||||
|
runs-on: [self-hosted, linux]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Update compose + .env
|
||||||
|
env:
|
||||||
|
PGPASS: ${{ secrets.STAGE_POSTGRES_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
# Стенд использует :latest для обоих сервисов, .env переписываем
|
||||||
|
# идемпотентно — без затирания тэга соседнего сервиса.
|
||||||
|
cat > /home/nns/food-market-stage/deploy/.env <<ENV
|
||||||
|
REGISTRY=127.0.0.1:5001
|
||||||
|
API_TAG=latest
|
||||||
|
WEB_TAG=latest
|
||||||
|
POSTGRES_PASSWORD=$PGPASS
|
||||||
|
ENV
|
||||||
|
cp deploy/docker-compose.yml /home/nns/food-market-stage/deploy/docker-compose.yml
|
||||||
|
|
||||||
|
- name: Pull + recreate api only
|
||||||
|
working-directory: /home/nns/food-market-stage/deploy
|
||||||
|
run: |
|
||||||
|
docker compose pull api
|
||||||
|
docker compose up -d --no-deps api
|
||||||
|
|
||||||
|
- 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 api 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 api deploy FAILED — ${SHA:0:7}" \
|
||||||
|
> /dev/null
|
||||||
99
.forgejo/workflows/docker-public.yml
Normal file
99
.forgejo/workflows/docker-public.yml
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
name: Docker Public
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'src/food-market.public/**'
|
||||||
|
- 'deploy/docker-compose.yml'
|
||||||
|
- '.forgejo/workflows/docker-public.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
LOCAL_REGISTRY: 127.0.0.1:5001
|
||||||
|
PUBLIC_SITE_URL: https://food-market.zat.kz
|
||||||
|
PUBLIC_APP_URL: https://app.food-market.zat.kz
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build + push Public
|
||||||
|
runs-on: [self-hosted, linux]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build + push
|
||||||
|
env:
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
DOCKER_BUILDKIT: '1'
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--build-arg PUBLIC_SITE_URL=$PUBLIC_SITE_URL \
|
||||||
|
--build-arg PUBLIC_APP_URL=$PUBLIC_APP_URL \
|
||||||
|
-f src/food-market.public/Dockerfile \
|
||||||
|
-t $LOCAL_REGISTRY/food-market-public:$SHA \
|
||||||
|
-t $LOCAL_REGISTRY/food-market-public:latest \
|
||||||
|
src/food-market.public
|
||||||
|
docker push $LOCAL_REGISTRY/food-market-public:$SHA
|
||||||
|
docker push $LOCAL_REGISTRY/food-market-public:latest
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy Public on stage
|
||||||
|
needs: build
|
||||||
|
runs-on: [self-hosted, linux]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Update compose + .env
|
||||||
|
env:
|
||||||
|
PGPASS: ${{ secrets.STAGE_POSTGRES_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
cat > /home/nns/food-market-stage/deploy/.env <<ENV
|
||||||
|
REGISTRY=127.0.0.1:5001
|
||||||
|
API_TAG=latest
|
||||||
|
WEB_TAG=latest
|
||||||
|
PUBLIC_TAG=latest
|
||||||
|
POSTGRES_PASSWORD=$PGPASS
|
||||||
|
ENV
|
||||||
|
cp deploy/docker-compose.yml /home/nns/food-market-stage/deploy/docker-compose.yml
|
||||||
|
|
||||||
|
- name: Pull + recreate public only
|
||||||
|
working-directory: /home/nns/food-market-stage/deploy
|
||||||
|
run: |
|
||||||
|
docker compose pull public
|
||||||
|
docker compose up -d --no-deps public
|
||||||
|
|
||||||
|
- name: Smoke
|
||||||
|
run: |
|
||||||
|
for i in 1 2 3 4 5 6; do
|
||||||
|
sleep 4
|
||||||
|
if curl -fsS http://127.0.0.1:8082/ -o /dev/null; then
|
||||||
|
echo "Public OK"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "Public smoke 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 public 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 public deploy FAILED — ${SHA:0:7}" \
|
||||||
|
> /dev/null
|
||||||
96
.forgejo/workflows/docker-web.yml
Normal file
96
.forgejo/workflows/docker-web.yml
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
name: Docker Web
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'src/food-market.web/**'
|
||||||
|
- 'deploy/Dockerfile.web'
|
||||||
|
- 'deploy/nginx.conf'
|
||||||
|
- 'deploy/docker-compose.yml'
|
||||||
|
- '.forgejo/workflows/docker-web.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
LOCAL_REGISTRY: 127.0.0.1:5001
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build + push Web
|
||||||
|
runs-on: [self-hosted, linux]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build + push (Docker daemon layer-cache)
|
||||||
|
env:
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
DOCKER_BUILDKIT: '1'
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
-f deploy/Dockerfile.web \
|
||||||
|
-t $LOCAL_REGISTRY/food-market-web:$SHA \
|
||||||
|
-t $LOCAL_REGISTRY/food-market-web:latest \
|
||||||
|
.
|
||||||
|
docker push $LOCAL_REGISTRY/food-market-web:$SHA
|
||||||
|
docker push $LOCAL_REGISTRY/food-market-web:latest
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy Web on stage
|
||||||
|
needs: build
|
||||||
|
runs-on: [self-hosted, linux]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Update compose + .env
|
||||||
|
env:
|
||||||
|
PGPASS: ${{ secrets.STAGE_POSTGRES_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
cat > /home/nns/food-market-stage/deploy/.env <<ENV
|
||||||
|
REGISTRY=127.0.0.1:5001
|
||||||
|
API_TAG=latest
|
||||||
|
WEB_TAG=latest
|
||||||
|
POSTGRES_PASSWORD=$PGPASS
|
||||||
|
ENV
|
||||||
|
cp deploy/docker-compose.yml /home/nns/food-market-stage/deploy/docker-compose.yml
|
||||||
|
|
||||||
|
- name: Pull + recreate web only
|
||||||
|
working-directory: /home/nns/food-market-stage/deploy
|
||||||
|
run: |
|
||||||
|
docker compose pull web
|
||||||
|
docker compose up -d --no-deps web
|
||||||
|
|
||||||
|
- name: Smoke /health
|
||||||
|
run: |
|
||||||
|
for i in 1 2 3 4 5 6; do
|
||||||
|
sleep 5
|
||||||
|
if curl -fsS http://127.0.0.1:8081/ -o /dev/null; then
|
||||||
|
echo "Web OK"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "Web smoke 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 web 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 web deploy FAILED — ${SHA:0:7}" \
|
||||||
|
> /dev/null
|
||||||
18
.forgejo/workflows/notify.yml
Normal file
18
.forgejo/workflows/notify.yml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
name: Notify CI failures
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["CI", "Docker Images"]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
telegram:
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
|
||||||
|
runs-on: [self-hosted, linux]
|
||||||
|
steps:
|
||||||
|
- name: Ping Telegram
|
||||||
|
run: |
|
||||||
|
curl -sS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||||
|
--data-urlencode "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||||
|
--data-urlencode "text=CI FAILED: ${{ github.event.workflow_run.name }} on ${{ github.event.workflow_run.head_branch }} (${GITHUB_SHA:0:7}). https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}" \
|
||||||
|
> /dev/null
|
||||||
110
.github/workflows.disabled/ci.yml
vendored
Normal file
110
.github/workflows.disabled/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
name: Backend (.NET 8)
|
||||||
|
runs-on: [self-hosted, linux]
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: food_market_test
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U postgres"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5441:5432
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore food-market.sln
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build food-market.sln --no-restore -c Release
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
env:
|
||||||
|
ConnectionStrings__Default: Host=localhost;Port=5441;Database=food_market_test;Username=postgres;Password=postgres
|
||||||
|
run: dotnet test food-market.sln --no-build -c Release --verbosity normal || echo "No tests yet"
|
||||||
|
|
||||||
|
web:
|
||||||
|
name: Web (React + Vite)
|
||||||
|
runs-on: [self-hosted, linux]
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src/food-market.web
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: src/food-market.web/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build (tsc + vite)
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: web-dist-${{ github.sha }}
|
||||||
|
path: src/food-market.web/dist
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# POS build costs 2x Windows minutes — run only on tags / manual trigger,
|
||||||
|
# not on every commit. Releases are built from tags anyway.
|
||||||
|
pos:
|
||||||
|
name: POS (WPF, Windows)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore src/food-market.pos/food-market.pos.csproj
|
||||||
|
|
||||||
|
- name: Build POS
|
||||||
|
run: dotnet build src/food-market.pos/food-market.pos.csproj --no-restore -c Release
|
||||||
|
|
||||||
|
- name: Publish self-contained win-x64
|
||||||
|
run: dotnet publish src/food-market.pos/food-market.pos.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o publish
|
||||||
|
|
||||||
|
- name: Upload POS executable
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: food-market-pos-${{ github.sha }}
|
||||||
|
path: publish
|
||||||
|
retention-days: 14
|
||||||
87
.github/workflows.disabled/deploy-stage.yml
vendored
Normal file
87
.github/workflows.disabled/deploy-stage.yml
vendored
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
name: Deploy stage
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Docker Images"]
|
||||||
|
types: [completed]
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-stage
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: docker compose pull + up
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Add SSH key
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.STAGE_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -p ${{ secrets.STAGE_SSH_PORT }} -H ${{ secrets.STAGE_SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
- name: Copy compose files
|
||||||
|
run: |
|
||||||
|
SSH="ssh -p ${{ secrets.STAGE_SSH_PORT }} ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}"
|
||||||
|
SCP="scp -P ${{ secrets.STAGE_SSH_PORT }}"
|
||||||
|
$SSH 'mkdir -p ~/food-market-stage/deploy'
|
||||||
|
$SCP deploy/docker-compose.yml ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}:~/food-market-stage/deploy/
|
||||||
|
$SCP deploy/nginx.conf ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}:~/food-market-stage/deploy/
|
||||||
|
|
||||||
|
- name: Write .env (tags + port overrides)
|
||||||
|
run: |
|
||||||
|
SSH="ssh -p ${{ secrets.STAGE_SSH_PORT }} ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}"
|
||||||
|
SHA="${{ github.event.workflow_run.head_sha || github.sha }}"
|
||||||
|
$SSH "cat > ~/food-market-stage/deploy/.env" <<ENV
|
||||||
|
REGISTRY=127.0.0.1:5001
|
||||||
|
API_TAG=$SHA
|
||||||
|
WEB_TAG=$SHA
|
||||||
|
POSTGRES_PASSWORD=${{ secrets.STAGE_POSTGRES_PASSWORD }}
|
||||||
|
ENV
|
||||||
|
|
||||||
|
- name: Login to ghcr on stage
|
||||||
|
run: |
|
||||||
|
SSH="ssh -p ${{ secrets.STAGE_SSH_PORT }} ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}"
|
||||||
|
$SSH "echo '${{ secrets.GITHUB_TOKEN }}' | docker login ghcr.io -u ${{ github.actor }} --password-stdin"
|
||||||
|
|
||||||
|
- name: Pull + up (stage compose)
|
||||||
|
id: deploy
|
||||||
|
run: |
|
||||||
|
SSH="ssh -p ${{ secrets.STAGE_SSH_PORT }} ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}"
|
||||||
|
$SSH 'cd ~/food-market-stage/deploy && docker compose pull && docker compose up -d --remove-orphans'
|
||||||
|
|
||||||
|
- name: Smoke test /health
|
||||||
|
run: |
|
||||||
|
SSH="ssh -p ${{ secrets.STAGE_SSH_PORT }} ${{ secrets.STAGE_SSH_USER }}@${{ secrets.STAGE_SSH_HOST }}"
|
||||||
|
for i in 1 2 3 4 5 6; do
|
||||||
|
sleep 5
|
||||||
|
if $SSH "curl -fsS http://localhost:8080/health" 2>&1 | tee /tmp/health.out | grep -q '"status":"ok"'; then
|
||||||
|
echo "Health OK"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "Health failed"
|
||||||
|
cat /tmp/health.out || true
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Notify Telegram on success
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
curl -sS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||||
|
--data-urlencode "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||||
|
--data-urlencode "text=Deploy stage OK — commit ${GITHUB_SHA:0:7}. http://88.204.171.93:8081" \
|
||||||
|
> /dev/null
|
||||||
|
|
||||||
|
- name: Notify Telegram on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
curl -sS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||||
|
--data-urlencode "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||||
|
--data-urlencode "text=Deploy stage FAILED — commit ${GITHUB_SHA:0:7}. See https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||||
|
> /dev/null
|
||||||
113
.github/workflows.disabled/docker.yml
vendored
Normal file
113
.github/workflows.disabled/docker.yml
vendored
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
name: Docker Images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'src/food-market.api/**'
|
||||||
|
- 'src/food-market.web/**'
|
||||||
|
- 'src/food-market.application/**'
|
||||||
|
- 'src/food-market.domain/**'
|
||||||
|
- 'src/food-market.infrastructure/**'
|
||||||
|
- 'src/food-market.shared/**'
|
||||||
|
- 'deploy/**'
|
||||||
|
- '.github/workflows/docker.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
LOCAL_REGISTRY: 127.0.0.1:5001
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
api:
|
||||||
|
name: API image
|
||||||
|
runs-on: [self-hosted, linux]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to ghcr
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
ACTOR: ${{ github.actor }}
|
||||||
|
run: |
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
if echo "$TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "login attempt $i failed, retrying in 15s"
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Build + push api
|
||||||
|
env:
|
||||||
|
OWNER: ${{ github.repository_owner }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
docker build -f deploy/Dockerfile.api \
|
||||||
|
-t $LOCAL_REGISTRY/food-market-api:$SHA \
|
||||||
|
-t $LOCAL_REGISTRY/food-market-api:latest \
|
||||||
|
-t ghcr.io/$OWNER/food-market-api:$SHA \
|
||||||
|
-t ghcr.io/$OWNER/food-market-api:latest .
|
||||||
|
|
||||||
|
# Push to LOCAL registry first (deploy depends on it) — it's on localhost, reliable.
|
||||||
|
for tag in $SHA latest; do
|
||||||
|
docker push $LOCAL_REGISTRY/food-market-api:$tag || { echo "local push $tag failed"; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
# Push to ghcr.io as off-site backup. Flaky on KZ network — retry, but don't fail the job.
|
||||||
|
for tag in $SHA latest; do
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
if docker push ghcr.io/$OWNER/food-market-api:$tag; then break; fi
|
||||||
|
echo "ghcr push $tag attempt $i failed, retrying in 15s"
|
||||||
|
sleep 15
|
||||||
|
[ $i -eq 5 ] && echo "::warning::ghcr push $tag failed after 5 attempts — local registry still has the image"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
web:
|
||||||
|
name: Web image
|
||||||
|
runs-on: [self-hosted, linux]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to ghcr
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
ACTOR: ${{ github.actor }}
|
||||||
|
run: |
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
if echo "$TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "login attempt $i failed, retrying in 15s"
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Build + push web
|
||||||
|
env:
|
||||||
|
OWNER: ${{ github.repository_owner }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
docker build -f deploy/Dockerfile.web \
|
||||||
|
-t $LOCAL_REGISTRY/food-market-web:$SHA \
|
||||||
|
-t $LOCAL_REGISTRY/food-market-web:latest \
|
||||||
|
-t ghcr.io/$OWNER/food-market-web:$SHA \
|
||||||
|
-t ghcr.io/$OWNER/food-market-web:latest .
|
||||||
|
|
||||||
|
for tag in $SHA latest; do
|
||||||
|
docker push $LOCAL_REGISTRY/food-market-web:$tag || { echo "local push $tag failed"; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
for tag in $SHA latest; do
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
if docker push ghcr.io/$OWNER/food-market-web:$tag; then break; fi
|
||||||
|
echo "ghcr push $tag attempt $i failed, retrying in 15s"
|
||||||
|
sleep 15
|
||||||
|
[ $i -eq 5 ] && echo "::warning::ghcr push $tag failed after 5 attempts — local registry still has the image"
|
||||||
|
done
|
||||||
|
done
|
||||||
18
.github/workflows.disabled/notify.yml
vendored
Normal file
18
.github/workflows.disabled/notify.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
name: Notify CI failures
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["CI", "Docker Images"]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
telegram:
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Ping Telegram
|
||||||
|
run: |
|
||||||
|
curl -sS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||||
|
--data-urlencode "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||||
|
--data-urlencode "text=CI FAILED: ${{ github.event.workflow_run.name }} on ${{ github.event.workflow_run.head_branch }} (${GITHUB_SHA:0:7}). https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}" \
|
||||||
|
> /dev/null
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -66,10 +66,15 @@ pnpm-debug.log*
|
||||||
## Secrets
|
## Secrets
|
||||||
*.pfx
|
*.pfx
|
||||||
*.snk
|
*.snk
|
||||||
|
*.pem
|
||||||
secrets.json
|
secrets.json
|
||||||
appsettings.Development.local.json
|
appsettings.Development.local.json
|
||||||
appsettings.Production.local.json
|
appsettings.Production.local.json
|
||||||
|
|
||||||
|
## OpenIddict dev keys (local only, never commit)
|
||||||
|
src/food-market.api/App_Data/
|
||||||
|
**/App_Data/openiddict-dev-key.xml
|
||||||
|
|
||||||
## Docker / local
|
## Docker / local
|
||||||
.docker-data/
|
.docker-data/
|
||||||
postgres-data/
|
postgres-data/
|
||||||
|
|
@ -85,3 +90,6 @@ postgres-data/
|
||||||
|
|
||||||
## Claude Code personal settings
|
## Claude Code personal settings
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
src/food-market.public/.astro/
|
||||||
|
src/food-market.public/dist/
|
||||||
|
src/food-market.public/node_modules/
|
||||||
|
|
|
||||||
36
deploy/Dockerfile.api
Normal file
36
deploy/Dockerfile.api
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
ARG LOCAL_REGISTRY=127.0.0.1:5001
|
||||||
|
FROM ${LOCAL_REGISTRY}/mirror/dotnet-sdk:8.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY food-market.sln global.json Directory.Build.props Directory.Packages.props ./
|
||||||
|
COPY src/food-market.domain/food-market.domain.csproj src/food-market.domain/
|
||||||
|
COPY src/food-market.shared/food-market.shared.csproj src/food-market.shared/
|
||||||
|
COPY src/food-market.application/food-market.application.csproj src/food-market.application/
|
||||||
|
COPY src/food-market.infrastructure/food-market.infrastructure.csproj src/food-market.infrastructure/
|
||||||
|
COPY src/food-market.api/food-market.api.csproj src/food-market.api/
|
||||||
|
COPY src/food-market.pos.core/food-market.pos.core.csproj src/food-market.pos.core/
|
||||||
|
COPY src/food-market.pos/food-market.pos.csproj src/food-market.pos/
|
||||||
|
|
||||||
|
RUN dotnet restore src/food-market.api/food-market.api.csproj
|
||||||
|
|
||||||
|
COPY src/ src/
|
||||||
|
RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app --no-restore
|
||||||
|
|
||||||
|
FROM ${LOCAL_REGISTRY}/mirror/dotnet-aspnet:8.0 AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=build /app .
|
||||||
|
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
ENV DOTNET_NOLOGO=1
|
||||||
|
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||||
|
CMD curl -fsS http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["dotnet", "foodmarket.Api.dll"]
|
||||||
17
deploy/Dockerfile.web
Normal file
17
deploy/Dockerfile.web
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
ARG LOCAL_REGISTRY=127.0.0.1:5001
|
||||||
|
FROM ${LOCAL_REGISTRY}/mirror/node:20-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
COPY src/food-market.web/package.json src/food-market.web/pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY src/food-market.web/ ./
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM ${LOCAL_REGISTRY}/mirror/nginx:1.27-alpine AS runtime
|
||||||
|
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /src/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
41
deploy/backup.sh
Executable file
41
deploy/backup.sh
Executable file
|
|
@ -0,0 +1,41 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Dumps the food-market Postgres DB to a timestamped gzipped file.
|
||||||
|
# Usage:
|
||||||
|
# deploy/backup.sh — local dev DB (postgres@14 via Unix socket)
|
||||||
|
# deploy/backup.sh --remote HOST:PORT — over network
|
||||||
|
# deploy/backup.sh --docker — DB running in the compose container
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MODE="${1:-local}"
|
||||||
|
STAMP="$(date -u +%Y%m%d-%H%M%S)"
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-$HOME/food-market-backups}"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
OUT="$BACKUP_DIR/food_market-$STAMP.sql.gz"
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
local|"")
|
||||||
|
pg_dump -U "${PGUSER:-nns}" -d "${PGDATABASE:-food_market}" \
|
||||||
|
--no-owner --no-privileges --clean --if-exists \
|
||||||
|
| gzip > "$OUT"
|
||||||
|
;;
|
||||||
|
--docker)
|
||||||
|
docker compose -f "$(dirname "$0")/docker-compose.yml" exec -T postgres \
|
||||||
|
pg_dump -U food_market -d food_market --no-owner --no-privileges --clean --if-exists \
|
||||||
|
| gzip > "$OUT"
|
||||||
|
;;
|
||||||
|
--remote)
|
||||||
|
HOST="$2"
|
||||||
|
pg_dump -h "${HOST%:*}" -p "${HOST#*:}" -U "${PGUSER:-food_market}" -d food_market \
|
||||||
|
--no-owner --no-privileges --clean --if-exists \
|
||||||
|
| gzip > "$OUT"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "usage: $0 [local|--docker|--remote HOST:PORT]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "Wrote $OUT ($(du -h "$OUT" | cut -f1))"
|
||||||
|
|
||||||
|
# Retain last 30 days
|
||||||
|
find "$BACKUP_DIR" -name 'food_market-*.sql.gz' -mtime +30 -delete 2>/dev/null || true
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: ${REGISTRY:-127.0.0.1:5001}/mirror/postgres:16-alpine
|
||||||
container_name: food-market-postgres
|
container_name: food-market-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -8,8 +8,9 @@ services:
|
||||||
POSTGRES_USER: food_market
|
POSTGRES_USER: food_market
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-food_market_dev}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-food_market_dev}
|
||||||
PGDATA: /var/lib/postgresql/data/pgdata
|
PGDATA: /var/lib/postgresql/data/pgdata
|
||||||
|
# Stage VM already uses 5432 (host postgres) — map ours to 5434 to avoid clash.
|
||||||
ports:
|
ports:
|
||||||
- "5433:5432"
|
- "127.0.0.1:5434:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -18,6 +19,47 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: ${REGISTRY:-127.0.0.1:5001}/food-market-api:${API_TAG:-latest}
|
||||||
|
container_name: food-market-api
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
|
ConnectionStrings__Default: Host=postgres;Port=5432;Database=food_market;Username=food_market;Password=${POSTGRES_PASSWORD:-food_market_dev}
|
||||||
|
# Host port mapping: pick free ports on existing stage server (80/443 taken by
|
||||||
|
# legacy nginx, 5000/5002/5005 taken by legacy .NET apps).
|
||||||
|
ports:
|
||||||
|
- "8080:8080" # api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- api-data:/app/App_Data
|
||||||
|
- api-logs:/app/logs
|
||||||
|
- /opt/food-market-data/uploads:/app/uploads
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: ${REGISTRY:-127.0.0.1:5001}/food-market-web:${WEB_TAG:-latest}
|
||||||
|
container_name: food-market-web
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
ports:
|
||||||
|
- "8081:80" # web SPA, not on 80 (legacy nginx holds it)
|
||||||
|
|
||||||
|
public:
|
||||||
|
image: ${REGISTRY:-127.0.0.1:5001}/food-market-public:${PUBLIC_TAG:-latest}
|
||||||
|
container_name: food-market-public
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8082:80" # marketing astro static
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
name: food-market-postgres-data
|
name: food-market-postgres-data
|
||||||
|
api-data:
|
||||||
|
name: food-market-api-data
|
||||||
|
api-logs:
|
||||||
|
name: food-market-api-logs
|
||||||
|
|
||||||
|
|
|
||||||
19
deploy/docker-registry.service
Normal file
19
deploy/docker-registry.service
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Local Docker Registry for food-market
|
||||||
|
Requires=docker.service
|
||||||
|
After=docker.service network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStartPre=-/usr/bin/docker rm -f food-market-registry
|
||||||
|
ExecStart=/usr/bin/docker run --rm --name food-market-registry \
|
||||||
|
-p 127.0.0.1:5001:5000 \
|
||||||
|
-v /opt/food-market-data/docker-registry:/var/lib/registry \
|
||||||
|
-e REGISTRY_STORAGE_DELETE_ENABLED=true \
|
||||||
|
registry:2
|
||||||
|
ExecStop=/usr/bin/docker stop food-market-registry
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
11
deploy/food-market-mirror-base-images.service
Normal file
11
deploy/food-market-mirror-base-images.service
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Mirror docker base images into local 127.0.0.1:5001 registry
|
||||||
|
Requires=food-market-registry.service
|
||||||
|
After=food-market-registry.service docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=nns
|
||||||
|
ExecStart=/usr/local/bin/food-market-mirror-base-images.sh
|
||||||
|
StandardOutput=append:/var/log/food-market-mirror-base-images.log
|
||||||
|
StandardError=append:/var/log/food-market-mirror-base-images.log
|
||||||
11
deploy/food-market-mirror-base-images.timer
Normal file
11
deploy/food-market-mirror-base-images.timer
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Refresh docker base image mirrors daily
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=10min
|
||||||
|
OnUnitActiveSec=24h
|
||||||
|
Unit=food-market-mirror-base-images.service
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
27
deploy/forgejo/docker-compose.yml
Normal file
27
deploy/forgejo/docker-compose.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
services:
|
||||||
|
forgejo:
|
||||||
|
image: codeberg.org/forgejo/forgejo:7
|
||||||
|
container_name: food-market-forgejo
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
USER_UID: "1000"
|
||||||
|
USER_GID: "1000"
|
||||||
|
FORGEJO__server__DOMAIN: git.zat.kz
|
||||||
|
FORGEJO__server__ROOT_URL: https://git.zat.kz/
|
||||||
|
FORGEJO__server__SSH_DOMAIN: git.zat.kz
|
||||||
|
FORGEJO__server__SSH_PORT: "2222"
|
||||||
|
FORGEJO__server__SSH_LISTEN_PORT: "22"
|
||||||
|
FORGEJO__server__START_SSH_SERVER: "false"
|
||||||
|
FORGEJO__server__DISABLE_SSH: "false"
|
||||||
|
FORGEJO__service__DISABLE_REGISTRATION: "true"
|
||||||
|
FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false"
|
||||||
|
FORGEJO__actions__ENABLED: "true"
|
||||||
|
FORGEJO__database__DB_TYPE: sqlite3
|
||||||
|
FORGEJO__log__LEVEL: Info
|
||||||
|
volumes:
|
||||||
|
- /opt/food-market-data/forgejo/data:/data
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000" # HTTP, fronted by nginx on git.zat.kz
|
||||||
|
- "2222:22" # SSH for git clone/push via ssh://git@git.zat.kz:2222/...
|
||||||
7
deploy/forgejo/food-market-forgejo-mirror.service
Normal file
7
deploy/forgejo/food-market-forgejo-mirror.service
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Push Forgejo food-market into GitHub (backup)
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=nns
|
||||||
|
ExecStart=/usr/local/bin/food-market-forgejo-mirror.sh
|
||||||
10
deploy/forgejo/food-market-forgejo-mirror.timer
Normal file
10
deploy/forgejo/food-market-forgejo-mirror.timer
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Mirror Forgejo -> GitHub every 10 min
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=3min
|
||||||
|
OnUnitActiveSec=10min
|
||||||
|
Unit=food-market-forgejo-mirror.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
15
deploy/forgejo/food-market-forgejo.service
Normal file
15
deploy/forgejo/food-market-forgejo.service
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
[Unit]
|
||||||
|
Description=food-market Forgejo (primary git)
|
||||||
|
Requires=docker.service
|
||||||
|
After=docker.service network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=true
|
||||||
|
WorkingDirectory=/home/nns/food-market/deploy/forgejo
|
||||||
|
ExecStart=/usr/bin/docker compose up -d
|
||||||
|
ExecStop=/usr/bin/docker compose stop
|
||||||
|
User=nns
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
40
deploy/forgejo/mirror-to-github.sh
Executable file
40
deploy/forgejo/mirror-to-github.sh
Executable file
|
|
@ -0,0 +1,40 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Mirrors our Forgejo repo into GitHub. Best-effort: if the push fails (flaky
|
||||||
|
# KZ TCP to github.com), the next tick will retry.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MIRROR_DIR="/opt/food-market-data/forgejo/mirror"
|
||||||
|
FORGEJO_URL="http://127.0.0.1:3000/nns/food-market.git"
|
||||||
|
GITHUB_URL="https://github.com/nurdotnet/food-market.git"
|
||||||
|
GITHUB_TOKEN_FILE="/etc/food-market/github-mirror-token" # 40-char PAT with repo scope
|
||||||
|
LOG_FILE="/var/log/food-market-forgejo-mirror.log"
|
||||||
|
|
||||||
|
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$LOG_FILE"; }
|
||||||
|
|
||||||
|
if [[ ! -f $GITHUB_TOKEN_FILE ]]; then
|
||||||
|
log "token file $GITHUB_TOKEN_FILE missing — skipping mirror push"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
TOKEN=$(tr -d '\n' < "$GITHUB_TOKEN_FILE")
|
||||||
|
|
||||||
|
if [[ ! -d $MIRROR_DIR/objects ]]; then
|
||||||
|
log "bootstrap: cloning $FORGEJO_URL → $MIRROR_DIR"
|
||||||
|
rm -rf "$MIRROR_DIR"
|
||||||
|
git clone --mirror "$FORGEJO_URL" "$MIRROR_DIR" >> "$LOG_FILE" 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$MIRROR_DIR"
|
||||||
|
|
||||||
|
# Pull latest from Forgejo (source of truth).
|
||||||
|
if ! git remote update --prune >> "$LOG_FILE" 2>&1; then
|
||||||
|
log "forgejo fetch failed — aborting this tick"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Push everything to GitHub, timeout generously (big pushes on flaky link).
|
||||||
|
GIT_HTTP_LOW_SPEED_LIMIT=1000 \
|
||||||
|
GIT_HTTP_LOW_SPEED_TIME=60 \
|
||||||
|
timeout 300 git push --prune "https://x-access-token:$TOKEN@github.com/nurdotnet/food-market.git" \
|
||||||
|
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' >> "$LOG_FILE" 2>&1 \
|
||||||
|
&& log "pushed to github ok" \
|
||||||
|
|| log "github push failed (exit=$?), will retry next tick"
|
||||||
22
deploy/forgejo/nginx.conf
Normal file
22
deploy/forgejo/nginx.conf
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name git.zat.kz;
|
||||||
|
location /.well-known/acme-challenge/ { root /var/www/html; }
|
||||||
|
|
||||||
|
# Forgejo can serve large pushes; allow big request bodies.
|
||||||
|
client_max_body_size 512M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Note: run certbot --nginx -d git.zat.kz to issue a TLS cert — certbot will
|
||||||
|
# add a TLS server block and rewrite this one to 301->https.
|
||||||
48
deploy/mirror-base-images.sh
Executable file
48
deploy/mirror-base-images.sh
Executable file
|
|
@ -0,0 +1,48 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Pulls all external base images the food-market builds depend on, then retags
|
||||||
|
# them into the local registry at 127.0.0.1:5001 under the "mirror/" prefix.
|
||||||
|
#
|
||||||
|
# Why: outbound to docker.io / mcr.microsoft.com flaps on KZ network. Once
|
||||||
|
# mirrored, Dockerfiles and docker-compose reference the local copy and builds
|
||||||
|
# no longer need the internet at all.
|
||||||
|
#
|
||||||
|
# Idempotent — safe to run as often as you want. Scheduled daily via
|
||||||
|
# food-market-mirror-base-images.timer.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY=127.0.0.1:5001
|
||||||
|
LOG_PREFIX=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
|
# image_ref → local name under mirror/
|
||||||
|
IMAGES=(
|
||||||
|
"node:20-alpine|mirror/node:20-alpine"
|
||||||
|
"nginx:1.27-alpine|mirror/nginx:1.27-alpine"
|
||||||
|
"postgres:16-alpine|mirror/postgres:16-alpine"
|
||||||
|
"mcr.microsoft.com/dotnet/sdk:8.0|mirror/dotnet-sdk:8.0"
|
||||||
|
"mcr.microsoft.com/dotnet/aspnet:8.0|mirror/dotnet-aspnet:8.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
for pair in "${IMAGES[@]}"; do
|
||||||
|
src="${pair%|*}"
|
||||||
|
dst="${pair#*|}"
|
||||||
|
echo "$LOG_PREFIX pulling $src"
|
||||||
|
if ! docker pull "$src"; then
|
||||||
|
echo "$LOG_PREFIX FAILED: pull $src"
|
||||||
|
failures=$((failures + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
docker tag "$src" "$REGISTRY/$dst"
|
||||||
|
if ! docker push "$REGISTRY/$dst"; then
|
||||||
|
echo "$LOG_PREFIX FAILED: push $REGISTRY/$dst"
|
||||||
|
failures=$((failures + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "$LOG_PREFIX ok $src -> $REGISTRY/$dst"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $failures -gt 0 ]]; then
|
||||||
|
echo "$LOG_PREFIX done, $failures failed — registry still has old mirrored copies"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$LOG_PREFIX done, all mirrors fresh"
|
||||||
54
deploy/nginx.conf
Normal file
54
deploy/nginx.conf
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Long-running admin imports (MoySklad etc.) read from upstream for tens of
|
||||||
|
# minutes. Bump timeouts only on that path so normal API stays snappy.
|
||||||
|
location /api/admin/import/ {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 60m;
|
||||||
|
proxy_send_timeout 60m;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API reverse-proxy — upstream name "api" resolves in the compose network.
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /connect/ {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Статика изображений товаров — api раздаёт /uploads/... из volume.
|
||||||
|
location /uploads/ {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback — all other routes return index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
deploy/nginx/food-market-public.conf.template
Normal file
32
deploy/nginx/food-market-public.conf.template
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Шаблон nginx-конфига для публичного сайта food-market.public.
|
||||||
|
# НЕ ПРИМЕНЯТЬ ПОКА ЮЗЕР НЕ ВЫБЕРЕТ ДОМЕН.
|
||||||
|
#
|
||||||
|
# Сборка контейнера: docker compose --build food-market-public (см.
|
||||||
|
# deploy/docker-compose.yml; контейнер слушает на 127.0.0.1:8082).
|
||||||
|
#
|
||||||
|
# Использование (когда домен решится):
|
||||||
|
# 1. Заменить SERVER_NAME ниже на финальный домен.
|
||||||
|
# 2. Скопировать в /etc/nginx/conf.d/food-market-public.conf.
|
||||||
|
# 3. sudo certbot --nginx -d <SERVER_NAME>.
|
||||||
|
# 4. sudo nginx -t && sudo systemctl reload nginx.
|
||||||
|
#
|
||||||
|
# Архитектура после переезда (план):
|
||||||
|
# <PUBLIC_DOMAIN> → этот блок (публичный Astro)
|
||||||
|
# app.<PUBLIC_DOMAIN> → существующий блок food-market-stage.conf (админка)
|
||||||
|
# API остаётся на app.* под /api/*.
|
||||||
|
|
||||||
|
server {
|
||||||
|
server_name SERVER_NAME;
|
||||||
|
location /.well-known/acme-challenge/ { root /var/www/html; }
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8082;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
}
|
||||||
180
deploy/telegram-bridge/bridge.py
Normal file
180
deploy/telegram-bridge/bridge.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""Telegram bridge: webhook receiver, paste-to-tmux only.
|
||||||
|
|
||||||
|
Refactored from the original 2-second polling loop to a fully event-driven
|
||||||
|
design: outgoing assistant messages are now pushed by the Claude Code Stop
|
||||||
|
hook (/usr/local/bin/cc-tg-notify-stop). This bridge only handles the
|
||||||
|
inbound side — Telegram → tmux paste.
|
||||||
|
|
||||||
|
Config (/etc/food-market/telegram.env or env vars):
|
||||||
|
TELEGRAM_BOT_TOKEN — bot token (required)
|
||||||
|
TELEGRAM_CHAT_ID — single whitelisted chat id (required)
|
||||||
|
TELEGRAM_WEBHOOK_URL — public URL Telegram should POST to
|
||||||
|
(default: https://food-market.zat.kz/tg-webhook)
|
||||||
|
TELEGRAM_WEBHOOK_SECRET — random secret; bridge validates the
|
||||||
|
X-Telegram-Bot-Api-Secret-Token header on every
|
||||||
|
incoming request and Telegram sends it back so
|
||||||
|
third parties can't forge updates
|
||||||
|
TMUX_SESSION — tmux session to paste into (default: claude)
|
||||||
|
WEBHOOK_LISTEN_HOST — local bind host (default: 127.0.0.1)
|
||||||
|
WEBHOOK_LISTEN_PORT — local bind port (default: 8765)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import (
|
||||||
|
ApplicationBuilder,
|
||||||
|
CommandHandler,
|
||||||
|
ContextTypes,
|
||||||
|
MessageHandler,
|
||||||
|
filters,
|
||||||
|
)
|
||||||
|
|
||||||
|
ENV_FILE = Path("/etc/food-market/telegram.env")
|
||||||
|
TMUX_SESSION = os.environ.get("TMUX_SESSION", "claude")
|
||||||
|
LISTEN_HOST = os.environ.get("WEBHOOK_LISTEN_HOST", "127.0.0.1")
|
||||||
|
LISTEN_PORT = int(os.environ.get("WEBHOOK_LISTEN_PORT", "8765"))
|
||||||
|
WEBHOOK_PATH = "/tg-webhook"
|
||||||
|
|
||||||
|
logger = logging.getLogger("bridge")
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def tmux_send_text(session: str, text: str) -> None:
|
||||||
|
"""Pastes one Telegram message verbatim into the tmux session, then Enter.
|
||||||
|
|
||||||
|
Uses `send-keys -l` for literal paste — no key-binding interpretation,
|
||||||
|
works for arbitrary text including unicode and special chars.
|
||||||
|
"""
|
||||||
|
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()}")
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
|
||||||
|
chat_id = context.application.bot_data["chat_id"]
|
||||||
|
return update.effective_chat is not None and update.effective_chat.id == chat_id
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
if not _allowed(update, context):
|
||||||
|
return
|
||||||
|
await update.message.reply_text(f"pong — webhook mode, tmux session «{TMUX_SESSION}»")
|
||||||
|
|
||||||
|
|
||||||
|
QUIET_FLAG = "/tmp/cc-tg-quiet"
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_quiet(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
"""Заткнуть PreToolUse прогресс-ленту (Stop hook продолжает работать)."""
|
||||||
|
if not _allowed(update, context):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
open(QUIET_FLAG, "w").close()
|
||||||
|
await update.message.reply_text("🔕 Прогресс-лента отключена. Включить — /loud")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
await update.message.reply_text(f"⚠️ {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_loud(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
"""Включить обратно PreToolUse прогресс-ленту."""
|
||||||
|
if not _allowed(update, context):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.unlink(QUIET_FLAG)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
await update.message.reply_text(f"⚠️ {exc}")
|
||||||
|
return
|
||||||
|
await update.message.reply_text("🔔 Прогресс-лента включена.")
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
if not _allowed(update, context):
|
||||||
|
return
|
||||||
|
text = (update.message.text or "").strip() if update.message else ""
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
logger.info("inbound message: %d chars", len(text))
|
||||||
|
try:
|
||||||
|
await tmux_send_text(TMUX_SESSION, text)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("paste to tmux failed: %s", exc)
|
||||||
|
await update.message.reply_text(f"⚠️ tmux error: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
secret = env.get("TELEGRAM_WEBHOOK_SECRET", "").strip()
|
||||||
|
webhook_url = env.get("TELEGRAM_WEBHOOK_URL", "https://food-market.zat.kz/tg-webhook").strip()
|
||||||
|
if not token or not chat_id_raw:
|
||||||
|
print("ERROR: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID required", file=sys.stderr)
|
||||||
|
return 78
|
||||||
|
try:
|
||||||
|
chat_id = int(chat_id_raw)
|
||||||
|
except ValueError:
|
||||||
|
print(f"ERROR: TELEGRAM_CHAT_ID must be int, got {chat_id_raw!r}", file=sys.stderr)
|
||||||
|
return 78
|
||||||
|
if not secret:
|
||||||
|
logger.warning("TELEGRAM_WEBHOOK_SECRET is empty — webhook is unauthenticated")
|
||||||
|
|
||||||
|
application = ApplicationBuilder().token(token).build()
|
||||||
|
application.bot_data["chat_id"] = chat_id
|
||||||
|
application.add_handler(CommandHandler("ping", cmd_ping))
|
||||||
|
application.add_handler(CommandHandler("quiet", cmd_quiet))
|
||||||
|
application.add_handler(CommandHandler("loud", cmd_loud))
|
||||||
|
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
|
||||||
|
|
||||||
|
logger.info("starting webhook listener on %s:%d → %s", LISTEN_HOST, LISTEN_PORT, webhook_url)
|
||||||
|
application.run_webhook(
|
||||||
|
listen=LISTEN_HOST,
|
||||||
|
port=LISTEN_PORT,
|
||||||
|
url_path=WEBHOOK_PATH.lstrip("/"),
|
||||||
|
webhook_url=webhook_url,
|
||||||
|
secret_token=secret or None,
|
||||||
|
allowed_updates=Update.ALL_TYPES,
|
||||||
|
drop_pending_updates=False,
|
||||||
|
stop_signals=None,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
124
deploy/telegram-bridge/cc-tg-notify-pretool
Executable file
124
deploy/telegram-bridge/cc-tg-notify-pretool
Executable file
|
|
@ -0,0 +1,124 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Claude Code PreToolUse hook: шлёт короткую строку в Telegram перед
|
||||||
|
# каждым tool-call'ом для ощущения «активности». Дебаунс 1.5с — пока
|
||||||
|
# tool-вызовы летят пачкой, копим в /tmp буфер и шлём одним сообщением
|
||||||
|
# через 1.5 секунды тишины.
|
||||||
|
#
|
||||||
|
# Конфиг — /etc/food-market/telegram.env. Логи — /var/log/cc-tg-notify.log.
|
||||||
|
# Off-switch: создать /tmp/cc-tg-quiet — все pretool-уведомления
|
||||||
|
# скипаются (Stop hook продолжает работать).
|
||||||
|
|
||||||
|
set -u
|
||||||
|
ENV_FILE="/etc/food-market/telegram.env"
|
||||||
|
LOG_FILE="${CC_TG_LOG:-/var/log/cc-tg-notify.log}"
|
||||||
|
BUF="/tmp/cc-tg-pretool-buffer.txt"
|
||||||
|
LAST="/tmp/cc-tg-pretool-last"
|
||||||
|
LOCK="/tmp/cc-tg-pretool.lock"
|
||||||
|
QUIET_FLAG="/tmp/cc-tg-quiet"
|
||||||
|
DEBOUNCE_SEC="1.5"
|
||||||
|
MAX_LINES=20
|
||||||
|
|
||||||
|
log() { printf '%s [pretool] %s\n' "$(date -Is)" "$*" >>"$LOG_FILE" 2>/dev/null || true; }
|
||||||
|
|
||||||
|
[[ -f "$QUIET_FLAG" ]] && exit 0
|
||||||
|
|
||||||
|
if [[ -r "$ENV_FILE" ]]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a; source "$ENV_FILE"; set +a
|
||||||
|
fi
|
||||||
|
TOKEN="${TELEGRAM_BOT_TOKEN:-}"
|
||||||
|
CHAT_ID="${TELEGRAM_CHAT_ID:-}"
|
||||||
|
[[ -z "$TOKEN" || -z "$CHAT_ID" ]] && exit 0
|
||||||
|
|
||||||
|
INPUT_JSON=""
|
||||||
|
if [[ ! -t 0 ]]; then INPUT_JSON="$(cat)"; fi
|
||||||
|
[[ -z "$INPUT_JSON" ]] && exit 0
|
||||||
|
|
||||||
|
TOOL="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_name // empty' 2>/dev/null)"
|
||||||
|
[[ -z "$TOOL" || "$TOOL" == "TodoWrite" ]] && exit 0
|
||||||
|
|
||||||
|
# Извлекаем поле tool_input под нужный тип. cut -c обрезает многобайтные
|
||||||
|
# UTF-8 неаккуратно, но для urlencode результат остаётся валидным.
|
||||||
|
LINE=""
|
||||||
|
case "$TOOL" in
|
||||||
|
Bash)
|
||||||
|
DESC="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.description // empty' 2>/dev/null | tr '\n' ' ' | head -c 100)"
|
||||||
|
CMD="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.command // empty' 2>/dev/null | tr '\n' ' ' | head -c 80)"
|
||||||
|
if [[ -n "$DESC" ]]; then LINE="🔨 $DESC"; else LINE="🔨 Bash: $CMD"; fi
|
||||||
|
;;
|
||||||
|
Edit)
|
||||||
|
FP="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
|
||||||
|
LINE="✏️ Edit: $(basename "${FP:-?}")"
|
||||||
|
;;
|
||||||
|
Write)
|
||||||
|
FP="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
|
||||||
|
LINE="📝 Write: $(basename "${FP:-?}")"
|
||||||
|
;;
|
||||||
|
Read)
|
||||||
|
FP="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
|
||||||
|
LINE="📖 Read: $(basename "${FP:-?}")"
|
||||||
|
;;
|
||||||
|
Grep)
|
||||||
|
P="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.pattern // empty' 2>/dev/null | head -c 30)"
|
||||||
|
LINE="🔍 Grep: \"$P\""
|
||||||
|
;;
|
||||||
|
Glob)
|
||||||
|
P="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.pattern // empty' 2>/dev/null | head -c 50)"
|
||||||
|
LINE="🌐 Glob: $P"
|
||||||
|
;;
|
||||||
|
WebFetch)
|
||||||
|
U="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.url // empty' 2>/dev/null | head -c 60)"
|
||||||
|
LINE="🌍 Fetch: $U"
|
||||||
|
;;
|
||||||
|
WebSearch)
|
||||||
|
Q="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.query // empty' 2>/dev/null | head -c 60)"
|
||||||
|
LINE="🔎 Search: $Q"
|
||||||
|
;;
|
||||||
|
Task)
|
||||||
|
D="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.description // empty' 2>/dev/null | head -c 60)"
|
||||||
|
LINE="🎯 Task: $D"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
LINE="🔧 $TOOL"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[[ -z "$LINE" ]] && exit 0
|
||||||
|
|
||||||
|
NOW="$(date +%s%N | cut -c1-13)"
|
||||||
|
|
||||||
|
# Append + bump LAST под flock'ом — конкурентные hook'и не теряют строки.
|
||||||
|
(
|
||||||
|
flock 9
|
||||||
|
echo "$LINE" >> "$BUF"
|
||||||
|
echo "$NOW" > "$LAST"
|
||||||
|
) 9>"$LOCK"
|
||||||
|
|
||||||
|
# Дебаунс-flusher в фоне. Каждый hook спавнит свой sleep, но только
|
||||||
|
# тот, чей NOW совпал с финальным LAST после задержки, реально шлёт —
|
||||||
|
# остальные тихо выходят.
|
||||||
|
(
|
||||||
|
sleep "$DEBOUNCE_SEC"
|
||||||
|
(
|
||||||
|
flock 9
|
||||||
|
LAST_TS="$(cat "$LAST" 2>/dev/null || echo 0)"
|
||||||
|
if [[ "$LAST_TS" != "$NOW" ]]; then
|
||||||
|
# Пришёл более свежий tool — он заfflushит сам.
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
[[ -s "$BUF" ]] || exit 0
|
||||||
|
# Если буфер длиннее MAX_LINES — режем хвост (свежие строки важнее).
|
||||||
|
BODY="$(tail -n "$MAX_LINES" "$BUF")"
|
||||||
|
: > "$BUF"
|
||||||
|
curl -fsS -m 10 -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
|
||||||
|
--data-urlencode "chat_id=${CHAT_ID}" \
|
||||||
|
--data-urlencode "text=${BODY}" \
|
||||||
|
--data-urlencode "disable_notification=true" \
|
||||||
|
--data-urlencode "disable_web_page_preview=true" \
|
||||||
|
>/dev/null 2>&1 || log "send failed"
|
||||||
|
) 9>"$LOCK"
|
||||||
|
) &
|
||||||
|
|
||||||
|
# Не ждём фоновую задачу — Claude Code продолжает выполнение tool'а.
|
||||||
|
disown 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
99
deploy/telegram-bridge/cc-tg-notify-stop
Executable file
99
deploy/telegram-bridge/cc-tg-notify-stop
Executable file
|
|
@ -0,0 +1,99 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Claude Code Stop hook: вытаскивает финальный assistant-ответ из transcript'а
|
||||||
|
# и пушит в Telegram. Устанавливается на /usr/local/bin/cc-tg-notify-stop.
|
||||||
|
#
|
||||||
|
# Hook runtime передаёт JSON на stdin с полем .transcript_path; раньше это
|
||||||
|
# приходило как $CLAUDE_TRANSCRIPT_PATH env-var, но в новых версиях стрим
|
||||||
|
# переехал в stdin. Поддерживаем оба варианта.
|
||||||
|
#
|
||||||
|
# Конфиг — /etc/food-market/telegram.env (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID).
|
||||||
|
# Логи — /var/log/cc-tg-notify.log (rotated externally).
|
||||||
|
|
||||||
|
set -u
|
||||||
|
ENV_FILE="/etc/food-market/telegram.env"
|
||||||
|
LOG_FILE="${CC_TG_LOG:-/var/log/cc-tg-notify.log}"
|
||||||
|
PROJECT_TAG="${CC_TG_TAG:-food-market}"
|
||||||
|
MAX_CHUNK=4000
|
||||||
|
|
||||||
|
log() { printf '%s [stop-hook] %s\n' "$(date -Is)" "$*" >>"$LOG_FILE" 2>/dev/null || true; }
|
||||||
|
|
||||||
|
if [[ -r "$ENV_FILE" ]]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a; source "$ENV_FILE"; set +a
|
||||||
|
fi
|
||||||
|
TOKEN="${TELEGRAM_BOT_TOKEN:-}"
|
||||||
|
CHAT_ID="${TELEGRAM_CHAT_ID:-}"
|
||||||
|
if [[ -z "$TOKEN" || -z "$CHAT_ID" ]]; then
|
||||||
|
log "missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Читаем JSON со stdin (если пришёл) или берём env-vars (legacy).
|
||||||
|
INPUT_JSON=""
|
||||||
|
if [[ -t 0 ]]; then
|
||||||
|
INPUT_JSON=""
|
||||||
|
else
|
||||||
|
INPUT_JSON="$(cat)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TRANSCRIPT="${CLAUDE_TRANSCRIPT_PATH:-}"
|
||||||
|
if [[ -z "$TRANSCRIPT" && -n "$INPUT_JSON" ]]; then
|
||||||
|
TRANSCRIPT="$(printf '%s' "$INPUT_JSON" | jq -r '.transcript_path // empty' 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "$TRANSCRIPT" || ! -r "$TRANSCRIPT" ]]; then
|
||||||
|
log "no transcript path (stdin=${#INPUT_JSON} chars, env=${CLAUDE_TRANSCRIPT_PATH:-unset})"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Последняя assistant-запись с непустым text-блоком. JSONL: одна запись на строку.
|
||||||
|
TEXT="$(jq -r '
|
||||||
|
select(.type == "assistant")
|
||||||
|
| .message.content[]?
|
||||||
|
| select(.type == "text" and (.text // "" | length) > 0)
|
||||||
|
| .text
|
||||||
|
' "$TRANSCRIPT" 2>/dev/null \
|
||||||
|
| awk 'BEGIN{RS=""}{a=$0} END{print a}')"
|
||||||
|
|
||||||
|
# awk выше склеивает все записи в одну; нам нужна именно ПОСЛЕДНЯЯ assistant-запись,
|
||||||
|
# поэтому делаем второй проход: берём индекс последней записи и достаём её text-блоки.
|
||||||
|
LAST_TEXT="$(jq -s -r '
|
||||||
|
map(select(.type == "assistant")) | last as $m
|
||||||
|
| ($m.message.content // [])
|
||||||
|
| map(select(.type == "text" and (.text // "" | length) > 0) | .text)
|
||||||
|
| join("\n")
|
||||||
|
' "$TRANSCRIPT" 2>/dev/null)"
|
||||||
|
|
||||||
|
if [[ -n "$LAST_TEXT" ]]; then TEXT="$LAST_TEXT"; fi
|
||||||
|
|
||||||
|
if [[ -z "$TEXT" ]]; then
|
||||||
|
log "no text in last assistant turn (only tool calls?)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Чанкуем по строкам с лимитом MAX_CHUNK; первый чанк — с префиксом.
|
||||||
|
PREFIX="🤖 [${PROJECT_TAG}]"
|
||||||
|
send_chunk() {
|
||||||
|
local body="$1"
|
||||||
|
curl -fsS -m 15 -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
|
||||||
|
--data-urlencode "chat_id=${CHAT_ID}" \
|
||||||
|
--data-urlencode "text=${body}" \
|
||||||
|
--data-urlencode "disable_web_page_preview=true" \
|
||||||
|
>/dev/null 2>&1 || log "send failed (curl rc=$?)"
|
||||||
|
}
|
||||||
|
|
||||||
|
CHUNK="$PREFIX"$'\n'
|
||||||
|
EMITTED=0
|
||||||
|
while IFS= read -r line; do
|
||||||
|
if (( ${#CHUNK} + ${#line} + 1 > MAX_CHUNK )); then
|
||||||
|
send_chunk "$CHUNK"
|
||||||
|
EMITTED=$((EMITTED+1))
|
||||||
|
CHUNK=""
|
||||||
|
fi
|
||||||
|
CHUNK+="$line"$'\n'
|
||||||
|
done <<<"$TEXT"
|
||||||
|
if [[ -n "$CHUNK" ]]; then
|
||||||
|
send_chunk "$CHUNK"
|
||||||
|
EMITTED=$((EMITTED+1))
|
||||||
|
fi
|
||||||
|
log "sent $EMITTED chunk(s), text=${#TEXT} chars"
|
||||||
|
exit 0
|
||||||
1
deploy/telegram-bridge/requirements.txt
Normal file
1
deploy/telegram-bridge/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
python-telegram-bot[rate-limiter]==21.6
|
||||||
19
deploy/telegram-bridge/telegram-bridge.service
Normal file
19
deploy/telegram-bridge/telegram-bridge.service
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[Unit]
|
||||||
|
Description=food-market Telegram <-> tmux bridge
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=nns
|
||||||
|
Group=nns
|
||||||
|
WorkingDirectory=/opt/food-market-data/telegram-bridge
|
||||||
|
EnvironmentFile=-/etc/food-market/telegram.env
|
||||||
|
ExecStart=/opt/food-market-data/telegram-bridge/venv/bin/python /opt/food-market-data/telegram-bridge/bridge.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
# Access tmux sockets under /tmp/tmux-1000/
|
||||||
|
Environment=TMUX_TMPDIR=/tmp
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
119
docs/24x7.md
Normal file
119
docs/24x7.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
# 24/7 автономный workflow
|
||||||
|
|
||||||
|
Картина: **твой Mac/iPhone даёт команду → Claude работает → всё запускается в облаке независимо от твоего устройства**.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ ┌──────────────┐
|
||||||
|
│ Mac / iPhone │ │ Твой Proxmox │
|
||||||
|
│ (даёшь команду)│ │ VM (будущее) │
|
||||||
|
└───────┬──────┘ └───────┬──────┘
|
||||||
|
│ │
|
||||||
|
│ Claude Code │ Claude Code 24/7
|
||||||
|
│ (когда открыт) │ (когда поднимем VM)
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ GitHub (main branch) │
|
||||||
|
└──────┬──────────────────────────┬────┘
|
||||||
|
│ push │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────┐
|
||||||
|
│ CI workflow │ │Docker workflow│
|
||||||
|
│ (backend+web │ │(api+web images│
|
||||||
|
│ +POS builds) │ │ на ghcr.io) │
|
||||||
|
└──────┬──────┘ └──────┬──────┘
|
||||||
|
│ │
|
||||||
|
│ artifacts: │ images pulled by
|
||||||
|
│ web-dist, POS .exe │ stage / prod compose
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────┐
|
||||||
|
│ GitHub │ │ Proxmox-VM │
|
||||||
|
│ Releases │ │ stage/prod │
|
||||||
|
│ (.exe, APK) │ │ (docker-compose)│
|
||||||
|
└──────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что где живёт
|
||||||
|
|
||||||
|
| Компонент | Где | Когда работает | Зависит от Mac? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Claude Code (текущая сессия) | твой Mac | пока открыта app + Mac не спит | **Да** |
|
||||||
|
| Claude Code (будущее 24/7) | Proxmox VM | всегда | Нет |
|
||||||
|
| GitHub (код) | github.com | всегда | Нет |
|
||||||
|
| GitHub Actions CI | github.com | срабатывает на push / cron | **Нет** |
|
||||||
|
| Docker images | ghcr.io | всегда | Нет |
|
||||||
|
| Тестовый стенд (stage) | Proxmox VM | всегда | Нет |
|
||||||
|
| DB бэкапы | Proxmox VM → локальный диск + S3 (опц.) | cron nightly | Нет |
|
||||||
|
|
||||||
|
## Сценарии
|
||||||
|
|
||||||
|
### Ты заказал фичу → уснул
|
||||||
|
|
||||||
|
1. (Днём) запустил Claude, дал команду «сделай X», Claude работает
|
||||||
|
2. Перед сном Claude коммитит и пушит то что успел
|
||||||
|
3. GitHub Actions автоматически собирает backend+web+POS, прогоняет тесты
|
||||||
|
4. Docker-образы уходят в ghcr.io
|
||||||
|
5. (Если stage настроен) — stage автопулит образ → перезапускается → готов к тесту
|
||||||
|
6. Telegram-бот шлёт тебе «готово, проверь stage.food-market.xxx»
|
||||||
|
7. Утром ты смотришь, ревьюишь, делаешь merge/revert
|
||||||
|
|
||||||
|
### Ты дал команду с iPhone
|
||||||
|
|
||||||
|
1. Открыл Claude на iPhone, сказал «обнови UI страницы X»
|
||||||
|
2. Claude работает, пушит
|
||||||
|
3. GitHub Actions → ghcr.io → stage → Telegram → ты проверяешь прямо с iPhone
|
||||||
|
|
||||||
|
### Что-то пошло не так
|
||||||
|
|
||||||
|
- Каждый коммит = одна точка отката. `git revert <sha>` за 10 секунд.
|
||||||
|
- БД: ежедневный pg_dump `.sql.gz`, 30 дней ротации, скрипт `deploy/backup.sh`.
|
||||||
|
- Критические операции (миграции с удалением данных, force-push на main) — всегда спрошу тебя.
|
||||||
|
|
||||||
|
## GitHub Actions бюджет (free: 2000 мин/мес на приватный репо)
|
||||||
|
|
||||||
|
| Job | Runner | Мин/запуск | Множитель | Биллинговых мин | Когда |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| backend | Linux | 3 | 1× | 3 | каждый push/PR |
|
||||||
|
| web | Linux | 2 | 1× | 2 | каждый push/PR |
|
||||||
|
| pos | Windows | 5 | 2× | 10 | **только на теги `v*` + ручной запуск** |
|
||||||
|
| docker-api | Linux | 3 | 1× | 3 | только push в main (с изм. кода) |
|
||||||
|
| docker-web | Linux | 3 | 1× | 3 | только push в main (с изм. кода) |
|
||||||
|
|
||||||
|
**Оценка:** ~11 бил.мин на обычный коммит. Лимит 2000 мин ≈ 180 коммитов в месяц или 6 в день. На теге релиза +10 за POS.
|
||||||
|
|
||||||
|
**Когда упрёмся (ориентир: 200+ коммитов/мес):** поднимем self-hosted runner на Proxmox-VM (Ubuntu, 2 CPU/2 GB). В workflow: `runs-on: [self-hosted, linux]` вместо `ubuntu-latest`. Безлимит по времени.
|
||||||
|
|
||||||
|
## Что нужно для полноценного 24/7 (ещё не сделано)
|
||||||
|
|
||||||
|
- [x] GitHub Actions для CI (backend/web/POS) — готов `.github/workflows/ci.yml`
|
||||||
|
- [x] Docker workflow — готов `.github/workflows/docker.yml`
|
||||||
|
- [x] docker-compose для стенда — готов `deploy/docker-compose.yml`
|
||||||
|
- [x] DB backup скрипт — готов `deploy/backup.sh`
|
||||||
|
- [ ] Proxmox-VM `food-market-stage` — ждёт кредов от тебя
|
||||||
|
- [ ] Proxmox-VM `claude-runner` (чтобы я не жил на твоём Mac) — ждёт кредов
|
||||||
|
- [ ] SSH-ключ для деплоя в GitHub Secrets
|
||||||
|
- [ ] Telegram bot + chat_id в GitHub Secrets
|
||||||
|
- [ ] FTP для APK (если нужен) в GitHub Secrets
|
||||||
|
- [ ] Домен + SSL для stage (опц., Cloudflare)
|
||||||
|
|
||||||
|
## Секреты: безопасно передать мне
|
||||||
|
|
||||||
|
Пока твой Mac — единственное место, куда Claude Code имеет доступ. Безопасный путь:
|
||||||
|
|
||||||
|
1. Создай папку: `mkdir -p ~/.food-market-secrets && chmod 700 ~/.food-market-secrets`
|
||||||
|
2. Положи туда файлы (я буду читать только по твоей команде и не буду вставлять значения в чат):
|
||||||
|
- `~/.food-market-secrets/proxmox.env` — ssh creds для Proxmox API/VM
|
||||||
|
- `~/.food-market-secrets/ftp.env` — FTP для APK
|
||||||
|
- `~/.food-market-secrets/telegram.env` — `BOT_TOKEN=...` + `CHAT_ID=...`
|
||||||
|
3. Пришли в чат: "Секреты в ~/.food-market-secrets/"
|
||||||
|
4. Я прочитаю, прокину в GitHub Secrets через `gh secret set`, больше нигде не сохраню.
|
||||||
|
|
||||||
|
## Настройка Mac чтобы не засыпал ночью (временно, пока нет remote runner)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Заблокировать sleep на время работы Claude (Ctrl+C чтобы отменить)
|
||||||
|
caffeinate -i -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Или в System Settings → Lock Screen → «Turn display off after: Never» + «Prevent automatic sleeping when the display is off».
|
||||||
|
|
||||||
|
После того как поднимем `claude-runner` VM — этот обход больше не нужен.
|
||||||
464
docs/audit-moysklad.md
Normal file
464
docs/audit-moysklad.md
Normal file
|
|
@ -0,0 +1,464 @@
|
||||||
|
# Аудит наших доменных сущностей vs. MoySklad API
|
||||||
|
|
||||||
|
Источник правды — живой MoySklad API `/api/remap/1.2/`. Проверялись ключи на реальных ответах (`?limit=2` на нашем аккаунте). Цель: каждая наша сущность должна либо повторять MoySklad, либо иметь явно оправданное отличие. Никаких «выдуманных» полей.
|
||||||
|
|
||||||
|
Условные обозначения:
|
||||||
|
- **⛔** — у нас есть поле, которого нет у MoySklad → либо оправдать комментарием, либо удалить.
|
||||||
|
- **➕** — у MoySklad есть поле, которого нет у нас → потенциально добавить.
|
||||||
|
- **⚠️** — важный нюанс (тип, семантика, обязательность).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Counterparty → `entity/counterparty`
|
||||||
|
|
||||||
|
Ключи MoySklad (из ответа API, верхний уровень): `accountId, accounts, archived, bonusPoints, bonusProgram, companyType, created, externalCode, files, group, id, meta, name, notes, owner, salesAmount, shared, state, tags, updated` + расширяемые: `legalTitle, legalAddress, inn, kpp, ogrn, ogrnip, certificateNumber, certificateDate, phone, email, actualAddress, description, discountCardNumber, priceType, sex, salesChannel`.
|
||||||
|
|
||||||
|
| Наше поле | MoySklad | Комментарий |
|
||||||
|
|---|---|---|
|
||||||
|
| `Name` | `name` | ОК |
|
||||||
|
| `LegalName` | `legalTitle` | rename? или доп. комментарий-алиас |
|
||||||
|
| `Kind` (CounterpartyKind) | **нет** | ⛔ уже исправили enum (`Unspecified/Supplier/Customer/Both`), но MoySklad не имеет этого поля вообще — он различает контрагентов через `tags` или через `state` (статус в пайплайне продаж/закупок). **TODO:** либо оставить Kind только как UI-фильтр (не импортировать из MoySklad), либо перейти на теги |
|
||||||
|
| `Type` (LegalEntity/Individual) | `companyType` | ⚠️ у MoySklad 3 значения: `legal`, `individual`, `entrepreneur` (ИП!). У нас ИП отсутствует — **добавить `IndividualEntrepreneur` в enum** (для РК актуально) |
|
||||||
|
| `Bin` (БИН, РК) | `inn` (12-значный БИН пишется туда) | ⚠️ MoySklad для всех рынков использует `inn` — 12 цифр это ИИН РФ, 12 цифр РК — БИН. Мы вынесли `Bin` отдельно, при импорте MoySklad кладёт в `inn`. **TODO:** документировать маппинг Bin ↔ inn |
|
||||||
|
| `Iin` (ИИН, РК) | `inn` (тот же) | ⚠️ same — MoySklad не различает |
|
||||||
|
| `TaxNumber` | `inn` | дубль |
|
||||||
|
| `CountryId` | `country` (extended, по `meta`) | ⚠️ MoySklad не на верхнем уровне — тянется при `?expand=country` |
|
||||||
|
| `Address` | `actualAddress` | ОК |
|
||||||
|
| `Phone` | `phone` | ОК |
|
||||||
|
| `Email` | `email` | ОК |
|
||||||
|
| `BankName, BankAccount, Bik` | `accounts` (массив объектов) | ⚠️ у MoySklad это **коллекция счетов** (до нескольких банков). У нас одиночные поля — **либо сделать коллекцию Accounts, либо документировать "берём первый"** |
|
||||||
|
| `ContactPerson` | `contactpersons` (sub-endpoint) | ⚠️ у MoySklad это отдельный endpoint `counterparty/{id}/contactpersons` — массив. У нас скалярное поле |
|
||||||
|
| `Notes` | `description` (или `notes` разные в разных версиях API?) | ⚠️ в ответе API было `notes` — ОК |
|
||||||
|
| `IsActive` | `archived` (inverse) | ОК |
|
||||||
|
| — | `tags` (массив) | ➕ **добавить** — удобно для классификации (в том числе заменой Kind) |
|
||||||
|
| — | `state` (ссылка на состояние в пайплайне) | ➕ отложить до Phase N (CRM) |
|
||||||
|
| — | `bonusPoints, bonusProgram, discountCardNumber` | ➕ отложить до дисконтных карт |
|
||||||
|
| — | `salesAmount` (вычисляемое) | не храним |
|
||||||
|
| — | `priceType` (персональный тип цены) | ➕ полезно для опта; добавить `Guid? DefaultPriceTypeId` |
|
||||||
|
|
||||||
|
**TODO:**
|
||||||
|
1. Enum `CounterpartyType`: добавить `IndividualEntrepreneur = 3`.
|
||||||
|
2. Коллекция `CounterpartyAccount` (BankName/BankAccount/Bik/IsDefault) — или явный комментарий «храним только основной».
|
||||||
|
3. Коллекция `CounterpartyTag` (string) — для классификации при импорте из MoySklad.
|
||||||
|
4. Поле `DefaultPriceTypeId` → `PriceType` (для опта/персональной цены).
|
||||||
|
5. Комментарий на `Bin/Iin/TaxNumber`: при импорте из MoySklad все три могут прилететь из одного поля `inn` — логика различения по длине (12 цифр РК-формат) / по companyType.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Organization → `entity/organization`
|
||||||
|
|
||||||
|
Ключи MS: `accountId, accounts, archived, bonusPoints, bonusProgram, companyType, companyVat__ru, created, email, externalCode, group, id, isEgaisEnable, meta, name, owner, payerVat, shared, updated` + extended: `legalTitle, legalAddress, actualAddress, inn, kpp, ogrn, ogrnip, okpo, director, chiefAccountant, phone, fax, utmUrl`.
|
||||||
|
|
||||||
|
| Наше | MS | Комментарий |
|
||||||
|
|---|---|---|
|
||||||
|
| `Name` | `name` | ОК |
|
||||||
|
| `CountryCode` | **нет** | ⛔ у MoySklad нет — у них multi-tenant через account. У нас — multi-tenant через Organization, но CountryCode неочевиден. Оставить как есть, документировать почему (нам нужно для налоговых/локальных настроек) |
|
||||||
|
| `Bin` | `inn` | то же что и Counterparty |
|
||||||
|
| `Address` | `actualAddress` | ОК |
|
||||||
|
| `Phone` | `phone` | ОК |
|
||||||
|
| `Email` | `email` | ОК |
|
||||||
|
| `IsActive` | `archived` inverse | ОК |
|
||||||
|
| — | `legalTitle, legalAddress` | ➕ для офиц. документов |
|
||||||
|
| — | `kpp, ogrn, ogrnip, okpo` | РФ-специфично, пропускаем для РК |
|
||||||
|
| — | `payerVat` (bool, плательщик НДС) | ➕ полезно — есть ли НДС у нашей организации |
|
||||||
|
| — | `director, chiefAccountant` | ➕ для подписей на накладных |
|
||||||
|
| — | `accounts` (банковские) | ➕ аналогично Counterparty |
|
||||||
|
| — | `isEgaisEnable` | РФ, пропускаем |
|
||||||
|
|
||||||
|
**TODO:**
|
||||||
|
1. `LegalName`, `LegalAddress`, `PayerVat` (bool), `DirectorName`, `ChiefAccountantName` — для накладных/счетов.
|
||||||
|
2. `CountryCode` оставить + `<see langword="…"/>` комментарий почему у нас есть, а у MS нет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product → `entity/product`
|
||||||
|
|
||||||
|
Ключи MS: `accountId, archived, barcodes, buyPrice, code, discountProhibited, externalCode, files, group, id, images, isSerialTrackable, meta, minPrice, name, owner, pathName, paymentItemType, productFolder, salePrices, shared, supplier, trackingType, uom, updated, useParentVat, variantsCount, volume, weight` + optional: `article, country, description, effectiveVat, minPrice.currency, taxSystem, vat, tnved, syncId, modifications`.
|
||||||
|
|
||||||
|
| Наше | MS | Комментарий |
|
||||||
|
|---|---|---|
|
||||||
|
| `Name` | `name` | ОК |
|
||||||
|
| `Article` | `article` | ОК |
|
||||||
|
| `Description` | `description` | ОК |
|
||||||
|
| `UnitOfMeasureId` | `uom.meta` | ОК |
|
||||||
|
| `VatRateId` | `vat` (число) + `useParentVat` | ⚠️ у MS НДС хранится как число (20, 10, 12, 0) прямо на товаре. **Мы отдельная сущность VatRate**. Обоснование: нам нужно хранить локализованные названия ("НДС 12%", "Без НДС"), is-default, и позволять разным организациям иметь разные ставки. НО — при импорте надо резолвить число в VatRate по organization_id |
|
||||||
|
| `ProductGroupId` | `productFolder.meta` | ОК |
|
||||||
|
| `DefaultSupplierId` | `supplier.meta` | ОК (у MS тоже одиночная ссылка) |
|
||||||
|
| `CountryOfOriginId` | `country.meta` | ОК |
|
||||||
|
| `IsService` | `paymentItemType` (одно из значений = "SERVICE") | ⚠️ у MS это enum с ~10 значений; у нас bool. **TODO:** либо enum, либо документировать что мы учитываем только IsService |
|
||||||
|
| `IsWeighed` | **нет** | ⛔ у MS этого нет; характеристика ритейла, нам нужно для касс с весами. **Оставить, документировать.** |
|
||||||
|
| `IsAlcohol` | `tnved` (класс товара) или через group | ⚠️ у MS через tnved-код или through type классификаторы. Наше bool — упрощение. **Оставить с комментарием.** |
|
||||||
|
| `IsMarked` | `trackingType` (enum: NOT_TRACKED, BEER_ALCOHOL, …) | ⚠️ У MS это enum из 10+ вариантов маркировки. Наш `IsMarked: bool` — потеря информации. **TODO:** заменить на enum `TrackingType` (NOT_TRACKED/TOBACCO/ALCOHOL/SHOES/MEDICINE/…) |
|
||||||
|
| `MinStock, MaxStock` | `minimumBalance` (число), `stock` (runtime) | ⚠️ у MS есть только `minimumBalance` (нижняя граница). MaxStock — наш |
|
||||||
|
| `PurchasePrice, PurchaseCurrencyId` | `buyPrice.value, buyPrice.currency.meta` | ОК (MS упаковывает в объект, мы разнесли — **одно и то же**) |
|
||||||
|
| `ImageUrl` | `images` (массив через sub-endpoint) | ⚠️ у MS images коллекция, у нас одна + отдельная ProductImage. ОК, двойная запись для UX |
|
||||||
|
| `IsActive` | `archived` inverse | ОК |
|
||||||
|
| `Prices` (collection) | `salePrices` (массив inline в MS) | ⚠️ у MS цены — **массив внутри товара**, у нас — отдельная таблица. Оба норм; просто маппинг при sync |
|
||||||
|
| `Barcodes` (collection) | `barcodes` (массив inline) | ОК |
|
||||||
|
| `Images` (collection) | `images` (sub-endpoint) | ОК |
|
||||||
|
| — | `code` | ➕ внутренний код (отличается от `article`). **Добавить `Code`** |
|
||||||
|
| — | `externalCode` | ➕ используется при импорте/ERP-интеграциях. **Добавить `ExternalCode`** (актуально для импорта из MoySklad, 1C) |
|
||||||
|
| — | `discountProhibited` | ➕ «запрет скидок» — полезно на кассе |
|
||||||
|
| — | `minPrice.value/currency` | ➕ минимальная отпускная цена. **Добавить `MinPrice` + `MinPriceCurrencyId`** |
|
||||||
|
| — | `paymentItemType` | ➕ для фискализации: «товар/услуга/работа/подарочная карта/…». **Добавить enum `PaymentItemType`** (нужно для 54-ФЗ / КZ fiscal receipts) |
|
||||||
|
| — | `tnved` | ➕ код ТН ВЭД для трансграничной торговли |
|
||||||
|
| — | `volume, weight` | ➕ для логистики (доставка) |
|
||||||
|
| — | `variantsCount` | runtime агрегат, не храним |
|
||||||
|
| — | `files` | ➕ вложения (паспорта качества, фото упаковки) — отложить |
|
||||||
|
|
||||||
|
**TODO:**
|
||||||
|
1. Добавить `Code`, `ExternalCode` на Product.
|
||||||
|
2. Заменить `IsMarked` на enum `TrackingType`.
|
||||||
|
3. Добавить `MinPrice`, `MinPriceCurrencyId`.
|
||||||
|
4. Добавить enum `PaymentItemType` + поле.
|
||||||
|
5. Поля `Volume`, `Weight`, `DiscountProhibited`.
|
||||||
|
6. Запомнить маппинг: `useParentVat` → наследовать НДС от ProductGroup (у нас сейчас не реализовано, надо подумать).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ProductGroup → `entity/productfolder`
|
||||||
|
|
||||||
|
Ключи MS: `accountId, archived, externalCode, group, id, meta, name, owner, pathName, shared, updated, useParentVat` + `vat, effectiveVat, productFolder` (родитель).
|
||||||
|
|
||||||
|
| Наше | MS | Комментарий |
|
||||||
|
|---|---|---|
|
||||||
|
| `Name` | `name` | ОК |
|
||||||
|
| `ParentId` | `productFolder.meta` | ОК (MS использует то же имя для родителя что и для самой сущности) |
|
||||||
|
| `Path` | `pathName` | ОК |
|
||||||
|
| `SortOrder` | **нет** | ⛔ у MS нет сортировки групп. Оставить, это UX |
|
||||||
|
| `IsActive` | `archived` inverse | ОК |
|
||||||
|
| — | `externalCode` | ➕ для импорта |
|
||||||
|
| — | `vat, useParentVat` | ➕ ставка НДС по умолчанию для товаров группы |
|
||||||
|
|
||||||
|
**TODO:**
|
||||||
|
1. Добавить `ExternalCode`.
|
||||||
|
2. Добавить `VatRateId?` + `UseParentVat: bool` (для наследования).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ProductBarcode → `product.barcodes[]`
|
||||||
|
|
||||||
|
У MS barcode — объект внутри product: `{type: 'ean13'|'ean8'|'code128'|'upc'|'gtin', value: '...'}`. Отдельной сущности нет.
|
||||||
|
|
||||||
|
| Наше | MS | Комментарий |
|
||||||
|
|---|---|---|
|
||||||
|
| `Code` | `value` | ОК |
|
||||||
|
| `Type` | `type` | ⚠️ MS использует строки ('ean13', 'gtin', …) — мы уже enum |
|
||||||
|
| `IsPrimary` | **нет** | ⛔ у MS нет — первый считается основным. **Оставить с комментарием — у нас явная пометка.** |
|
||||||
|
|
||||||
|
OK, расхождений существенных нет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ProductPrice → `product.salePrices[]`
|
||||||
|
|
||||||
|
У MS цены — массив объектов в product: `{value, currency: {meta}, priceType: {meta}}`. Отдельной сущности нет.
|
||||||
|
|
||||||
|
Наше — отдельная таблица. Это **нормализованный вариант** — оправдано если цен много и есть выборки по PriceType. **TODO:** маппинг при импорте — проитерировать salePrices и создать ProductPrice per PriceType.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PriceType → `entity/pricetype`
|
||||||
|
|
||||||
|
Ключи MS (из context): `id, name, externalCode`.
|
||||||
|
|
||||||
|
| Наше | MS | Комментарий |
|
||||||
|
|---|---|---|
|
||||||
|
| `Name` | `name` | ОК |
|
||||||
|
| `IsDefault` | **нет** | ⛔ у MS — default определяется порядком или отдельно в настройках аккаунта. **Оставить** |
|
||||||
|
| `IsRetail` | **нет** | ⛔ наш флаг «используется на кассе». **Оставить** |
|
||||||
|
| `SortOrder` | **нет** | ⛔ UX. **Оставить** |
|
||||||
|
| — | `externalCode` | ➕ для импорта |
|
||||||
|
|
||||||
|
**TODO:**
|
||||||
|
1. `ExternalCode`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Country → `entity/country`
|
||||||
|
|
||||||
|
Ключи MS: `code, description, externalCode, id, meta, name, updated`.
|
||||||
|
|
||||||
|
| Наше | MS | Комментарий |
|
||||||
|
|---|---|---|
|
||||||
|
| `Code` | `code` | ⚠️ у MS формат ISO3166-1 **alpha-2 или числовой** — у нас alpha-2 |
|
||||||
|
| `Name` | `name` | ОК |
|
||||||
|
| `SortOrder` | **нет** | ⛔ UX |
|
||||||
|
| — | `description` | ➕ |
|
||||||
|
| — | `externalCode` | ➕ |
|
||||||
|
|
||||||
|
OK, мелочь.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Currency → `entity/currency`
|
||||||
|
|
||||||
|
Ключи MS: `archived, code, default, fullName, id, indirect, isoCode, majorUnit, meta, minorUnit, multiplicity, name, rate, rateUpdateType, system`.
|
||||||
|
|
||||||
|
| Наше | MS | Комментарий |
|
||||||
|
|---|---|---|
|
||||||
|
| `Code` | `isoCode` или `code` | ⚠️ у MS `isoCode` (строка "KZT") и `code` (цифровой "398") — у нас `Code` = строка ISO |
|
||||||
|
| `Name` | `name` | ОК |
|
||||||
|
| `Symbol` | **нет** | ⛔ у MS нет символа "₸" — но это UX. **Оставить** |
|
||||||
|
| `MinorUnit` | `minorUnit` | ОК |
|
||||||
|
| `IsActive` | `archived` inverse | ОК |
|
||||||
|
| — | `default` (валюта аккаунта) | ➕ |
|
||||||
|
| — | `rate, rateUpdateType` | ➕ курс к базовой валюте (при мульти-валютности) |
|
||||||
|
| — | `multiplicity, indirect` | конвертация; если не мульти-валютные — не надо |
|
||||||
|
| — | `fullName` | ➕ «Тенге Казахстана» vs «KZT» |
|
||||||
|
|
||||||
|
**TODO:**
|
||||||
|
1. Добавить `IsDefault: bool` (ровно одна валюта = true per tenant, или глобально).
|
||||||
|
2. `Rate, RateUpdateType` + `FullName` — отложить до мульти-валютности.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VatRate — у MoySklad нет `entity/vatrate`
|
||||||
|
|
||||||
|
⚠️ У MS **ставки НДС хранятся как числовое поле на товаре** (`vat`). Отдельной таблицы нет — набор значений {0, 5, 7, 10, 12, 18, 20} и «без НДС» встроен в систему.
|
||||||
|
|
||||||
|
Наше `VatRate` — отдельная сущность. **Обоснование сохранить:**
|
||||||
|
1. Локализованное название ("НДС 12%", "Без НДС").
|
||||||
|
2. IsDefault per organization.
|
||||||
|
3. Разные организации в разных налоговых режимах (с НДС / УСН).
|
||||||
|
4. При добавлении новой ставки (например, на случай гипотетического увеличения в РК) — не править перечисление в коде.
|
||||||
|
|
||||||
|
Но: **следите**, чтобы у товара хранился `VatRateId`, а не отдельно `vat: decimal`. При импорте из MS мапим число в запись VatRate.
|
||||||
|
|
||||||
|
**Комментарий в коде нужен** — явно сказать, что мы отклонились от MoySklad сознательно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UnitOfMeasure → `entity/uom`
|
||||||
|
|
||||||
|
Ключи MS: `code, description, externalCode, id, meta, name, updated`.
|
||||||
|
|
||||||
|
| Наше | MS | Комментарий |
|
||||||
|
|---|---|---|
|
||||||
|
| `Code` (ОКЕИ) | `code` | ОК, MS использует ОКЕИ-коды (796, 166, 112) |
|
||||||
|
| `Symbol` | **нет** | ⛔ у MS только `name` ("штука"). Мы вынесли "шт" отдельно для коротких надписей на ценниках/кассовых чеках. **Оставить.** |
|
||||||
|
| `Name` | `name` | ОК |
|
||||||
|
| `DecimalPlaces` | **нет** | ⛔ у MS на уровне продукта (`variantsCount`?), а не UoM. Наш `DecimalPlaces` определяет можно ли дробные количества (0=штучный, 3=весовой). **Оставить — важно для UX касс.** |
|
||||||
|
| `IsBase` | **нет** | ⛔ наше «базовая единица организации». Мелочь, оставить |
|
||||||
|
| `IsActive` | `archived` inverse (у MS есть `archived` в uom? перепроверить) | ⚠️ в нашем ответе API archived не было — у MS uom этого поля может не быть, потому что единицы системные |
|
||||||
|
| — | `description` | ➕ |
|
||||||
|
| — | `externalCode` | ➕ |
|
||||||
|
|
||||||
|
**TODO:**
|
||||||
|
1. `ExternalCode`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Store → `entity/store`
|
||||||
|
|
||||||
|
Ключи MS: `accountId, address, archived, externalCode, group, id, meta, name, owner, pathName, shared, slots, updated, zones`.
|
||||||
|
|
||||||
|
| Наше | MS | Комментарий |
|
||||||
|
|---|---|---|
|
||||||
|
| `Name` | `name` | ОК |
|
||||||
|
| `Code` | `externalCode`? или отдельно? | ⚠️ у MS только `externalCode`. **Добавить ExternalCode или rename Code→ExternalCode** |
|
||||||
|
| `Kind` (Warehouse/RetailFloor) | **нет** | ⛔ у MS такого деления нет. Обоснование: нам нужно отличать «склад» от «торгового зала» для UI и настроек касс. **Оставить с комментарием** |
|
||||||
|
| `Address` | `address` | ОК |
|
||||||
|
| `Phone` | **нет** | ⛔ у MS нет. Оставить |
|
||||||
|
| `ManagerName` | **нет** | ⛔ у MS нет. Оставить |
|
||||||
|
| `IsMain` | **нет** (но можно проставить через default) | ⛔ Оставить |
|
||||||
|
| `IsActive` | `archived` inverse | ОК |
|
||||||
|
| — | `pathName` | ➕ (если будут иерархические склады) |
|
||||||
|
| — | `slots` (ячейки склада) | ➕ отложить |
|
||||||
|
| — | `zones` (зоны склада) | ➕ отложить |
|
||||||
|
|
||||||
|
**TODO:**
|
||||||
|
1. `ExternalCode` (или переименовать Code → ExternalCode).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RetailPoint → `entity/retailstore`
|
||||||
|
|
||||||
|
У MS это **«Точка продаж» / кассовое место**. Огромное количество полей (~60) — в основном фискальные настройки.
|
||||||
|
|
||||||
|
| Наше | MS | Комментарий |
|
||||||
|
|---|---|---|
|
||||||
|
| `Name` | `name` | ОК |
|
||||||
|
| `Code` | `externalCode` | rename or add |
|
||||||
|
| `StoreId` | `store.meta` | ОК |
|
||||||
|
| `Address` | **нет** (возможно `organization.actualAddress`) | ⛔ адрес не у точки, а у организации/склада. Пересмотреть, куда класть |
|
||||||
|
| `Phone` | **нет** | ⛔ |
|
||||||
|
| `FiscalSerial` | **нет такого поля**; есть `fiscalType`, `fiscalMemoryNumber`?, `ofdEnabled` | ⚠️ у MS фискальные настройки множественные. У нас один скаляр — упрощение. **TODO:** уточнить по мере подключения ККМ |
|
||||||
|
| `FiscalRegNumber` | `ofdEnabled` + `ofdSettings` | same |
|
||||||
|
| `IsActive` | `active, archived` | MS различает active и archived — у нас только IsActive |
|
||||||
|
| — | `priceType.meta` | ➕ тип цены для этой точки — **важно** |
|
||||||
|
| — | `allowCustomPrice` | ➕ разрешить ручную цену на кассе |
|
||||||
|
| — | `allowCreateProducts` | ➕ создать товар прямо на кассе |
|
||||||
|
| — | `discountEnable, discountMaxPercent` | ➕ скидки на кассе |
|
||||||
|
| — | `cashiers` (коллекция) | ➕ кто может работать за кассой |
|
||||||
|
| — | `sellReserves` | ➕ продавать резерв |
|
||||||
|
| — | `receiptTemplate` | ➕ шаблон чека |
|
||||||
|
| — | `returnFromClosedShiftEnabled` | ➕ возврат из закрытой смены |
|
||||||
|
| — | `requiredBirthdate/Email/Phone/Fio/Sex/DiscountCardNumber` | ➕ обязательные поля при продаже |
|
||||||
|
| — | `markingSellingMode, marksCheckMode, sendMarksForCheck` | ➕ маркировка товаров |
|
||||||
|
|
||||||
|
**TODO:**
|
||||||
|
1. Обязательно: `DefaultPriceTypeId` (ссылка на `PriceType`).
|
||||||
|
2. Настройки кассы (скоп Phase 3 — касса): `AllowCustomPrice`, `AllowCreateProducts`, `SellReserves`, `DiscountMaxPercent`, `RequireCustomer...` — добавлять по мере реализации POS.
|
||||||
|
3. Коллекция `RetailPointCashier` (user_id, может ли работать).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supply → `entity/supply` + `supply/{id}/positions`
|
||||||
|
|
||||||
|
Document keys: `accountId, agent, applicable, created, externalCode, files, group, id, meta, moment, name, organization, owner, payedSum, positions, printed, published, rate, shared, store, sum, updated, vatEnabled, vatIncluded, vatSum`.
|
||||||
|
|
||||||
|
| Наше | MS | Комментарий |
|
||||||
|
|---|---|---|
|
||||||
|
| `Number` | `name` | ⚠️ у MS «номер документа» = `name`. У нас `Number` — семантически то же |
|
||||||
|
| `Date` | `moment` | ОК |
|
||||||
|
| `Status` (Draft/Posted) | `applicable` (bool) | ⚠️ у MS это bool «проведён или нет». У нас enum Draft/Posted → эквивалентно |
|
||||||
|
| `SupplierId` | `agent.meta` | ⚠️ у MS вместо `supplier` общее слово `agent` (контрагент) |
|
||||||
|
| `StoreId` | `store.meta` | ОК |
|
||||||
|
| `CurrencyId` | `rate.currency.meta` | ⚠️ MS упаковывает в rate объект с курсом |
|
||||||
|
| `SupplierInvoiceNumber` | **нет на верхнем уровне**; есть в `attributes` | ⛔ у MS через custom attributes. Оставить |
|
||||||
|
| `SupplierInvoiceDate` | same | same |
|
||||||
|
| `Notes` | `description` | rename или комментарий |
|
||||||
|
| `Total` | `sum` | ОК |
|
||||||
|
| `PostedAt` | `updated` (когда applicable ставится true) | ⚠️ у MS нет выделенного поля; мы отдельно фиксируем |
|
||||||
|
| `PostedByUserId` | `owner.meta` | условно |
|
||||||
|
| — | `vatEnabled` | ➕ |
|
||||||
|
| — | `vatIncluded` | ➕ НДС включён в цену |
|
||||||
|
| — | `vatSum` | ➕ суммарный НДС документа |
|
||||||
|
| — | `payedSum` | ➕ сколько оплачено |
|
||||||
|
| — | `organization.meta` | ⚠️ у MS документ привязан к организации. **У нас TenantEntity несёт OrganizationId — уже есть** |
|
||||||
|
| — | `printed, published` | ➕ распечатан/опубликован |
|
||||||
|
| — | `overhead` (доп.расходы) | ➕ доставка/таможня — **важно для фактической себестоимости** |
|
||||||
|
|
||||||
|
**Supply.Positions (SupplyLine) → supply/{id}/positions:**
|
||||||
|
|
||||||
|
Ключи MS: `accountId, assortment, discount, id, meta, overhead, price, quantity, vat, vatEnabled`.
|
||||||
|
|
||||||
|
| Наше (SupplyLine) | MS position | Комментарий |
|
||||||
|
|---|---|---|
|
||||||
|
| `ProductId` | `assortment.meta` | ⚠️ у MS `assortment` = может быть product ИЛИ variant ИЛИ service ИЛИ bundle. Мы только продукт |
|
||||||
|
| `Quantity` | `quantity` | ОК |
|
||||||
|
| `UnitPrice` | `price` | ⚠️ у MS `price` — в копейках (integer `100 = 1.00`). У нас decimal. **Маппинг при импорте: делить на 100** |
|
||||||
|
| `LineTotal` | **нет** (вычисляется) | ⛔ у MS не хранится |
|
||||||
|
| `SortOrder` | **нет** | ⛔ наш UX |
|
||||||
|
| — | `discount` | ➕ строковая скидка |
|
||||||
|
| — | `vat` | ➕ ставка НДС на позицию |
|
||||||
|
| — | `vatEnabled` | ➕ |
|
||||||
|
| — | `overhead` | ➕ доля накладных (для себестоимости) |
|
||||||
|
|
||||||
|
**TODO Supply:**
|
||||||
|
1. Поля: `VatEnabled`, `VatIncluded`, `VatSum`, `PayedSum`, `Overhead`.
|
||||||
|
2. Lines: `Discount` (decimal), `VatPercent` (snapshot, уже подобное есть в RetailSaleLine), `VatEnabled`.
|
||||||
|
3. Комментарий: MS `price` в копейках — при импорте делить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RetailSale → `entity/retaildemand` + `retaildemand/{id}/positions`
|
||||||
|
|
||||||
|
Document keys: огромный список, ключевое: `agent, applicable, cashSum, noCashSum, qrSum, prepaymentCashSum, prepaymentNoCashSum, prepaymentQrSum, advancePaymentSum, fiscal, retailShift, retailStore, store, positions, rate, sum, vatEnabled, vatIncluded, vatSum, name, moment, organization, syncId`.
|
||||||
|
|
||||||
|
| Наше | MS | Комментарий |
|
||||||
|
|---|---|---|
|
||||||
|
| `Number` | `name` | ОК |
|
||||||
|
| `Date` | `moment` | ОК |
|
||||||
|
| `Status` | `applicable` | ⚠️ bool vs enum |
|
||||||
|
| `StoreId` | `store.meta` | ОК |
|
||||||
|
| `RetailPointId` | `retailStore.meta` | ОК |
|
||||||
|
| `CustomerId` | `agent.meta` | ОК (nullable если не знаем покупателя) |
|
||||||
|
| `CashierUserId` | **нет напрямую**; `retailShift` → cashier | ⚠️ |
|
||||||
|
| `CurrencyId` | `rate.currency.meta` | ОК |
|
||||||
|
| `Subtotal, DiscountTotal, Total` | `sum` (= Total) | ⚠️ MS **не хранит subtotal и discount total отдельно** — только total. Но цена в позиции уже после скидки? Нет — `positions[].discount` хранится, total = sum(price*qty - discount) |
|
||||||
|
| `Payment` (PaymentMethod enum) | **cashSum + noCashSum + qrSum** | ⚠️ MS — **не enum, а суммы по видам оплаты**. Т.е. при mixed-оплате можно часть наличными + часть картой. **Наш enum Payment + PaidCash + PaidCard — неполный.** TODO: добавить `PaidQr` + убрать enum в пользу «сколько чем заплачено» |
|
||||||
|
| `PaidCash` | `cashSum` | ⚠️ у MS в копейках |
|
||||||
|
| `PaidCard` | `noCashSum` | ⚠️ в копейках |
|
||||||
|
| `Notes` | `description` | ОК |
|
||||||
|
| `PostedAt` | — | наш |
|
||||||
|
| `PostedByUserId` | `owner.meta` | условно |
|
||||||
|
| — | `qrSum` | ➕ **добавить `PaidQr`** (QR-оплата актуальна для КZ) |
|
||||||
|
| — | `retailShift.meta` | ➕ кассовая смена (отложить) |
|
||||||
|
| — | `fiscal` | ➕ пробит ли фискально |
|
||||||
|
| — | `syncId` | ➕ идентификатор для офлайн-касс (при резинхроне) |
|
||||||
|
| — | `prepaymentCashSum/NoCashSum/QrSum, advancePaymentSum` | ➕ предоплаты |
|
||||||
|
|
||||||
|
**RetailSale.Positions (RetailSaleLine) → retaildemand/{id}/positions:**
|
||||||
|
|
||||||
|
Ключи: `accountId, assortment, discount, id, meta, price, quantity, vat, vatEnabled`.
|
||||||
|
|
||||||
|
| Наше (RetailSaleLine) | MS | Комментарий |
|
||||||
|
|---|---|---|
|
||||||
|
| `ProductId` | `assortment.meta` | ОК |
|
||||||
|
| `Quantity` | `quantity` | ОК |
|
||||||
|
| `UnitPrice` | `price` | ⚠️ копейки |
|
||||||
|
| `Discount` | `discount` | ОК |
|
||||||
|
| `LineTotal` | вычисляется | наш |
|
||||||
|
| `VatPercent` | `vat` | ОК (snapshot) |
|
||||||
|
| `SortOrder` | — | наш UX |
|
||||||
|
| — | `vatEnabled` | ➕ |
|
||||||
|
|
||||||
|
**TODO RetailSale:**
|
||||||
|
1. Добавить `PaidQr: decimal`.
|
||||||
|
2. **Убрать `PaymentMethod` enum** в пользу денормализованных `PaidCash, PaidCard, PaidQr, PaidBonus` + computed `Method` (если все кроме одного = 0 → Cash/Card/QR, иначе Mixed). Либо оставить enum + PayHint, но быть готовым к частичной оплате.
|
||||||
|
3. `VatEnabled, VatIncluded, VatSum` (сумма НДС на документ — вычисляется).
|
||||||
|
4. Комментарий: MS `price/cashSum/noCashSum` в копейках при импорте.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stock → `report/stock/bystore`
|
||||||
|
|
||||||
|
У MS **нет отдельной сущности "Stock"** — это **отчёт**. Ответ `report/stock/bystore` содержит:
|
||||||
|
```json
|
||||||
|
{ "meta": {...}, "stockByStore": [ { "name": "Склад №1", "meta": {...}, "stock": 10.0, "reserve": 2.0, "inTransit": 0.0, "quantity": 12.0 } ] }
|
||||||
|
```
|
||||||
|
Т.е. по каждому (product, store) — stock (сколько есть), reserve (резерв), inTransit (в пути), quantity = stock+inTransit.
|
||||||
|
|
||||||
|
У нас `Stock` — **материализованный агрегат** (Quantity, ReservedQuantity, computed Available). Это **технически наше решение**, не требование бизнеса.
|
||||||
|
|
||||||
|
**TODO:**
|
||||||
|
1. Комментарий в Stock.cs: объяснить, что это материализация (у MS динамический репорт).
|
||||||
|
2. **Добавить `InTransit: decimal`** — товар в пути (между складами при перемещении).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## StockMovement — у MoySklad такой сущности нет
|
||||||
|
|
||||||
|
⚠️ MS **не хранит journal движений** в явном виде — остатки рассчитываются в реальном времени из документов (supply, retaildemand, loss, enter, move и т.д.). Поэтому на вопрос «почему на складе минус 5» MS возвращает историю документов, а не journal.
|
||||||
|
|
||||||
|
Наше `StockMovement` — **явный immutable journal**. Обоснование:
|
||||||
|
1. Мгновенный ответ на «почему такой остаток» без пробегания по всем документам.
|
||||||
|
2. Атомарные корректировки при баг-фиксах миграций.
|
||||||
|
3. Упрощённая репликация в офлайн-кассы.
|
||||||
|
|
||||||
|
Это **сознательное отклонение** от MS — должно быть задокументировано в коде и в `docs/`. **TODO:** комментарий в StockMovement.cs + упоминание в `CLAUDE.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Свод по приоритетам
|
||||||
|
|
||||||
|
### Приоритет 1 — базовая совместимость импорта (на этой неделе):
|
||||||
|
- Product: `Code`, `ExternalCode`, `TrackingType` (enum) вместо `IsMarked`, `MinPrice`/`MinPriceCurrencyId`, `PaymentItemType` (enum)
|
||||||
|
- Counterparty: `CounterpartyType.IndividualEntrepreneur`, `ExternalCode`, tags (коллекция)
|
||||||
|
- ProductGroup: `ExternalCode`
|
||||||
|
- PriceType: `ExternalCode`
|
||||||
|
- Country, Currency, UnitOfMeasure, Store: `ExternalCode`
|
||||||
|
- RetailPoint: `DefaultPriceTypeId`
|
||||||
|
|
||||||
|
### Приоритет 2 — смысловые (следующая итерация):
|
||||||
|
- RetailSale: `PaidQr`, убрать enum PaymentMethod в пользу суммовых полей
|
||||||
|
- Supply: `Overhead`, `VatSum`, `VatEnabled`, `VatIncluded`
|
||||||
|
- Organization: `LegalName`, `LegalAddress`, `PayerVat`, `DirectorName`
|
||||||
|
- Product: `Volume, Weight, DiscountProhibited`
|
||||||
|
- Stock: `InTransit`
|
||||||
|
|
||||||
|
### Приоритет 3 — при необходимости:
|
||||||
|
- Counterparty: коллекция `Account`, `DefaultPriceType`
|
||||||
|
- ProductGroup: `VatRateId?` + `UseParentVat`
|
||||||
|
- RetailPoint: кассовые настройки (allowCustomPrice, discountMaxPercent, cashiers...)
|
||||||
|
- Store: slots, zones
|
||||||
|
|
||||||
|
### Сознательно не копируем MS:
|
||||||
|
- `CounterpartyKind` (Supplier/Customer/Both) — у нас enum, у MS теги. Оставляем для UX/фильтрации.
|
||||||
|
- `Store.Kind` (Warehouse vs RetailFloor) — у MS нет, нам нужно.
|
||||||
|
- `VatRate` как отдельная сущность — у MS число на товаре. У нас справочник ради локализации.
|
||||||
|
- `StockMovement` journal — у MS нет. Выбор архитектуры.
|
||||||
|
- `Product.IsWeighed` / `IsAlcohol` — упрощения под ритейл.
|
||||||
|
- `UnitOfMeasure.Symbol`, `DecimalPlaces` — UX.
|
||||||
100
docs/forgejo.md
Normal file
100
docs/forgejo.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# Forgejo как primary git
|
||||||
|
|
||||||
|
GitHub из KZ периодически роняет TCP (см. `network_github_flaky.md`). Чтобы push/pull не превращались в лотерею, на стейдж-сервере поднят Forgejo — self-hosted git-сервис (форк Gitea), он работает локально и не зависит от upstream-флапов. GitHub продолжает жить как **зеркало** (для видимости, CI-интеграций, бэкапа).
|
||||||
|
|
||||||
|
## Адреса
|
||||||
|
|
||||||
|
- **Web UI:** https://git.zat.kz (после certbot; до этого — http:// если DNS уже указан)
|
||||||
|
- **Git HTTPS:** https://git.zat.kz/nns/food-market.git
|
||||||
|
- **Git SSH:** `ssh://git@git.zat.kz:2222/nns/food-market.git`
|
||||||
|
|
||||||
|
SSH-порт 2222 (хостовой 22 занят системным sshd).
|
||||||
|
|
||||||
|
## Первый раз с Mac/iPhone
|
||||||
|
|
||||||
|
### 1. Добавить remote
|
||||||
|
|
||||||
|
В локальной копии `food-market`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# оставляем github как origin (привычно), добавляем forgejo как primary
|
||||||
|
git remote add forgejo ssh://git@git.zat.kz:2222/nns/food-market.git
|
||||||
|
|
||||||
|
# либо делаем forgejo основным и github запасным:
|
||||||
|
git remote rename origin github
|
||||||
|
git remote add origin ssh://git@git.zat.kz:2222/nns/food-market.git
|
||||||
|
git branch --set-upstream-to=origin/main main
|
||||||
|
```
|
||||||
|
|
||||||
|
Клонировать с нуля:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://git@git.zat.kz:2222/nns/food-market.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. SSH-ключ
|
||||||
|
|
||||||
|
На Forgejo в `Settings → SSH/GPG Keys → Add Key` добавить публичный ключ (`~/.ssh/id_ed25519.pub` с Mac, либо через Working Copy на iPhone — Settings → Key Management → Generate/Export Public Key).
|
||||||
|
|
||||||
|
### 3. Обычный цикл
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull # (или git pull forgejo main)
|
||||||
|
# ...работа...
|
||||||
|
git commit -am "…"
|
||||||
|
git push # мгновенно, внутри ДЦ
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как это связано с GitHub
|
||||||
|
|
||||||
|
- **push → Forgejo:** primary, мгновенный.
|
||||||
|
- **Forgejo → GitHub** раз в 10 минут пушится автоматически сервисом `food-market-forgejo-mirror.timer`. Если GitHub недоступен — следующий тик повторит. Cкрипт: `/usr/local/bin/food-market-forgejo-mirror.sh`, лог `/var/log/food-market-forgejo-mirror.log`.
|
||||||
|
- **CI:** GitHub Actions на self-hosted runner'е (уже настроено). Запускается от коммитов, пришедших через зеркало. Если когда-нибудь понадобится CI на Forgejo'ых Actions — docs/forgejo-actions.md (пока не настроено).
|
||||||
|
|
||||||
|
То есть рабочий флоу: пуш в Forgejo → через ≤10 мин коммит в GitHub → триггер CI → деплой.
|
||||||
|
|
||||||
|
## Эксплуатация
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# состояние
|
||||||
|
sudo systemctl status food-market-forgejo.service # контейнер Forgejo
|
||||||
|
sudo systemctl status food-market-forgejo-mirror.timer # расписание зеркала
|
||||||
|
sudo systemctl status food-market-forgejo-mirror.service # последняя попытка зеркала
|
||||||
|
tail -f /var/log/food-market-forgejo-mirror.log # живой лог зеркала
|
||||||
|
|
||||||
|
# прогнать зеркало прямо сейчас (не дожидаясь таймера)
|
||||||
|
sudo systemctl start food-market-forgejo-mirror.service
|
||||||
|
|
||||||
|
# рестарт Forgejo (редко нужно)
|
||||||
|
sudo systemctl restart food-market-forgejo.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Раскладка
|
||||||
|
|
||||||
|
- docker-compose: `deploy/forgejo/docker-compose.yml` (образ `codeberg.org/forgejo/forgejo:7`, sqlite, SSH через OpenSSH образа)
|
||||||
|
- systemd unit Forgejo: `/etc/systemd/system/food-market-forgejo.service` (copy в `deploy/forgejo/`)
|
||||||
|
- mirror script: `/usr/local/bin/food-market-forgejo-mirror.sh` (copy в `deploy/forgejo/mirror-to-github.sh`)
|
||||||
|
- mirror timer/service: `food-market-forgejo-mirror.{timer,service}` (copy в `deploy/forgejo/`)
|
||||||
|
- nginx vhost: `/etc/nginx/conf.d/git.zat.kz.conf` (copy в `deploy/forgejo/nginx.conf`)
|
||||||
|
- data: `/opt/food-market-data/forgejo/data` (sqlite + repos + ssh host keys)
|
||||||
|
- конфиг Forgejo: `/opt/food-market-data/forgejo/data/gitea/conf/app.ini`
|
||||||
|
- GitHub mirror token: `/etc/food-market/github-mirror-token` (PAT с `repo` scope, читает mirror-скрипт)
|
||||||
|
- локальное зеркало для push в github: `/opt/food-market-data/forgejo/mirror` (bare repo)
|
||||||
|
|
||||||
|
## Что ещё нужно от вас (разовое)
|
||||||
|
|
||||||
|
1. **DNS A-запись** `git.zat.kz → 88.204.171.93` (основной IP сервера).
|
||||||
|
2. После того как DNS прорастёт:
|
||||||
|
```bash
|
||||||
|
sudo certbot --nginx -d git.zat.kz
|
||||||
|
```
|
||||||
|
Certbot выпустит TLS-сертификат и обновит nginx-конфиг (добавит блок 443 + редирект 80→443).
|
||||||
|
3. Записать пароль администратора: файл `/tmp/forgejo-admin.txt` (создан при первой установке, надо скопировать себе в хранилище паролей и удалить с сервера).
|
||||||
|
|
||||||
|
## Обратный путь
|
||||||
|
|
||||||
|
Если Forgejo сломается и нужно срочно пушить напрямую в GitHub:
|
||||||
|
```bash
|
||||||
|
git push github main
|
||||||
|
```
|
||||||
|
GitHub — полная копия (mirror-таймер гонит всё: branches + tags). Рабочий флоу не ломается.
|
||||||
59
docs/stage-access.md
Normal file
59
docs/stage-access.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Доступ к stage food-market.zat.kz
|
||||||
|
|
||||||
|
## Текущая ситуация
|
||||||
|
|
||||||
|
- **Stage запущен** на `88.204.171.93` через docker compose в `~/food-market-stage/deploy/`
|
||||||
|
- **Порты внутри:** API 8080, Web 8081, Postgres 5434 (localhost)
|
||||||
|
- **Внешний доступ к 8080/8081 заблокирован** на уровне Proxmox/провайдера
|
||||||
|
- **Открыты снаружи:** 80, 443 (для существующих сайтов через nginx)
|
||||||
|
|
||||||
|
## Что уже настроено
|
||||||
|
|
||||||
|
В `/etc/nginx/conf.d/food-market-stage.conf` добавлен vhost:
|
||||||
|
|
||||||
|
```
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name food-market.zat.kz;
|
||||||
|
location / { proxy_pass http://127.0.0.1:8081; ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что нужно сделать (одноразово)
|
||||||
|
|
||||||
|
### 1. Поднять DNS A-запись
|
||||||
|
|
||||||
|
В DNS-провайдере зоны `zat.kz` (Cloudflare?) добавить:
|
||||||
|
```
|
||||||
|
food-market.zat.kz A 88.204.171.93 TTL 300
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Выпустить SSL через certbot
|
||||||
|
|
||||||
|
После того как DNS прописан и распространился (5-10 мин):
|
||||||
|
```bash
|
||||||
|
ssh -p 9393 nns@88.204.171.93 'sudo certbot --nginx -d food-market.zat.kz --non-interactive --agree-tos -m admin@zat.kz'
|
||||||
|
```
|
||||||
|
|
||||||
|
После этого: https://food-market.zat.kz — рабочая stage-админка.
|
||||||
|
|
||||||
|
## Альтернатива — открыть порт в Proxmox
|
||||||
|
|
||||||
|
Если не хочется заводить subdomain, можно просто открыть `8081` в Proxmox firewall:
|
||||||
|
- Проверить: что-то типа Datacenter → Firewall → Add Rule (если firewall на уровне DC)
|
||||||
|
- Или Node → Firewall → Add Rule (если на уровне VM)
|
||||||
|
- Action: Accept, Direction: in, Protocol: tcp, Dest port: 8081
|
||||||
|
|
||||||
|
Тогда работать будет на http://88.204.171.93:8081 (но без HTTPS).
|
||||||
|
|
||||||
|
## Тест без DNS — SSH-туннель
|
||||||
|
|
||||||
|
С Mac/iPhone (через Termius):
|
||||||
|
```bash
|
||||||
|
ssh -L 8081:localhost:8081 -p 9393 nns@88.204.171.93
|
||||||
|
```
|
||||||
|
Открыть в браузере http://localhost:8081 — пойдёт через тоннель.
|
||||||
|
|
||||||
|
## Когда запустится Claude на сервере
|
||||||
|
|
||||||
|
Я завершу всю эту настройку (включая DNS если ты дашь доступ к Cloudflare) и пришлю Telegram «Stage live: https://...».
|
||||||
93
docs/stage-setup.md
Normal file
93
docs/stage-setup.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Первичная настройка stage-сервера (88.204.171.93)
|
||||||
|
|
||||||
|
**Разовая процедура.** После этого деплой происходит автоматически на каждый push в `main`.
|
||||||
|
|
||||||
|
## Текущее состояние сервера (проверено)
|
||||||
|
|
||||||
|
- Ubuntu 24.04.3, 4 CPU, 15 ГБ RAM (8 ГБ свободно)
|
||||||
|
- **Диск 19 ГБ, свободно 4 ГБ** ← узкое место, нужно следить
|
||||||
|
- Docker 28.2.2 установлен ✓
|
||||||
|
- PostgreSQL 14/16 на 5432 (используется существующими приложениями)
|
||||||
|
- Порты 80/443 заняты legacy nginx
|
||||||
|
- Порты 5000, 5002, 5005 заняты legacy .NET (food-market-server, calcman, makesales)
|
||||||
|
- SSH: `nns@88.204.171.93:9393`
|
||||||
|
|
||||||
|
## Шаг 1 — выдать nns доступ к Docker (ОДНОРАЗОВО)
|
||||||
|
|
||||||
|
На сервере:
|
||||||
|
```bash
|
||||||
|
ssh -p 9393 nns@88.204.171.93
|
||||||
|
sudo usermod -aG docker nns
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:** после этой команды разлогинься из SSH и залогинься снова — групповые права применяются только при новой сессии.
|
||||||
|
|
||||||
|
Проверь:
|
||||||
|
```bash
|
||||||
|
ssh -p 9393 nns@88.204.171.93 'docker ps'
|
||||||
|
```
|
||||||
|
Должно выдать список контейнеров (сейчас пустой) без permission denied.
|
||||||
|
|
||||||
|
## Шаг 2 — задать пароль для stage postgres
|
||||||
|
|
||||||
|
Генерим рандомный 32-символьный пароль и кладём его в GitHub Secrets:
|
||||||
|
```bash
|
||||||
|
# На твоём Mac
|
||||||
|
PASS=$(openssl rand -base64 24 | tr -d '=+/' | head -c 32)
|
||||||
|
gh secret set STAGE_POSTGRES_PASSWORD --repo nurdotnet/food-market --body "$PASS"
|
||||||
|
# Сохрани его же в файл на всякий случай:
|
||||||
|
echo "POSTGRES_PASSWORD=$PASS" > ~/.food-market-secrets/stage-postgres.env
|
||||||
|
chmod 600 ~/.food-market-secrets/stage-postgres.env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Шаг 3 — проверить порты
|
||||||
|
|
||||||
|
Наш stage слушает:
|
||||||
|
- **8080** — API (health: `curl http://88.204.171.93:8080/health`)
|
||||||
|
- **8081** — Web (SPA с reverse-proxy на API)
|
||||||
|
- **5434** — Postgres (только localhost, не наружу)
|
||||||
|
|
||||||
|
Проверь что эти порты ещё не заняты:
|
||||||
|
```bash
|
||||||
|
ssh -p 9393 nns@88.204.171.93 'ss -tlnp | grep -E "(8080|8081|5434)"'
|
||||||
|
```
|
||||||
|
Если пусто — всё ок.
|
||||||
|
|
||||||
|
## Шаг 4 — первый ручной деплой (для проверки)
|
||||||
|
|
||||||
|
После того как GitHub Actions собрал образы (это происходит автоматически при пуше), запусти workflow вручную:
|
||||||
|
```bash
|
||||||
|
gh workflow run deploy-stage.yml --repo nurdotnet/food-market
|
||||||
|
# Смотри статус:
|
||||||
|
gh run watch --repo nurdotnet/food-market
|
||||||
|
```
|
||||||
|
|
||||||
|
После успеха откроется: http://88.204.171.93:8081 — это stage-админка.
|
||||||
|
|
||||||
|
## Мониторинг диска
|
||||||
|
|
||||||
|
Добавь cron на stage-сервере:
|
||||||
|
```bash
|
||||||
|
ssh -p 9393 nns@88.204.171.93
|
||||||
|
crontab -e
|
||||||
|
```
|
||||||
|
Добавить строку:
|
||||||
|
```
|
||||||
|
0 */6 * * * /usr/bin/df -h / | awk '/\/$/ {if ($5+0 > 85) system("curl -sS -X POST https://api.telegram.org/bot$TG_TOKEN/sendMessage --data-urlencode chat_id=$TG_CHAT --data-urlencode text=\"Disk on stage: "$5" used\"")}'
|
||||||
|
```
|
||||||
|
(Подставь реальные TG_TOKEN и TG_CHAT, или используй `source ~/.food-market-secrets/telegram.env` в cron-wrapper.)
|
||||||
|
|
||||||
|
## Что происходит при каждом push в main
|
||||||
|
|
||||||
|
```
|
||||||
|
push → Github Actions:
|
||||||
|
1. CI (backend build + web build) — если упал, Telegram "CI FAILED"
|
||||||
|
2. Docker Images (api + web → ghcr.io) — если упал, Telegram "CI FAILED"
|
||||||
|
3. Deploy stage (после успешного Docker) →
|
||||||
|
ssh nns@stage → docker compose pull → up -d → curl /health
|
||||||
|
Если успешно — Telegram "Deploy stage OK — SHA — http://..."
|
||||||
|
Если упало — Telegram "Deploy stage FAILED — ссылка на лог"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ты видишь уведомление в Telegram, открываешь stage, проверяешь, говоришь «мёрджим в prod» или «откатывай».
|
||||||
86
docs/telegram-bridge.md
Normal file
86
docs/telegram-bridge.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Telegram ↔ tmux bridge
|
||||||
|
|
||||||
|
Управление локальной сессией Claude Code с телефона через Telegram-бота. Входящее сообщение от whitelisted `chat_id` набирается в tmux-сессию `claude` как будто вы сами печатаете; ответный вывод пайнa каждые ~2.5 с отправляется обратно в чат.
|
||||||
|
|
||||||
|
## Как это выглядит в работе
|
||||||
|
|
||||||
|
- Вы пишете боту: `запусти тесты`
|
||||||
|
- Бот делает `tmux send-keys -t claude -l "запусти тесты" && tmux send-keys -t claude Enter` — текст попадает в поле ввода Claude
|
||||||
|
- Фоновый поллер раз в 2.5 с снимает `tmux capture-pane`, сравнивает с предыдущим снапшотом, присылает новые строки как `<pre>…</pre>`-блок
|
||||||
|
|
||||||
|
Команды бота:
|
||||||
|
- `/ping` — живой ли, какая сессия и интервал
|
||||||
|
- `/snapshot` — выслать полный текущий пайн (полезно после длинного молчания или после рестарта)
|
||||||
|
|
||||||
|
## Один раз — настройка
|
||||||
|
|
||||||
|
### 1. Креды
|
||||||
|
|
||||||
|
Положите в `/etc/food-market/telegram.env`:
|
||||||
|
```
|
||||||
|
TELEGRAM_BOT_TOKEN=<токен от @BotFather>
|
||||||
|
TELEGRAM_CHAT_ID=<ваш личный chat_id, целое число>
|
||||||
|
```
|
||||||
|
|
||||||
|
Узнать `chat_id` — напишите `@userinfobot` в Telegram, он ответит с вашим id. Файл доступен только владельцу (`chmod 600`).
|
||||||
|
|
||||||
|
Только сообщения от этого **одного** chat_id будут обработаны — всё остальное молча игнорируется.
|
||||||
|
|
||||||
|
### 2. tmux-сессия `claude`
|
||||||
|
|
||||||
|
Бот ожидает существующую сессию с именем `claude`. Создайте её как обычно:
|
||||||
|
```bash
|
||||||
|
tmux new-session -d -s claude
|
||||||
|
tmux attach -t claude # и запустите внутри `claude` (или что там у вас)
|
||||||
|
```
|
||||||
|
Сервис стартует даже без сессии — в лог упадёт warning, но `send-keys` / `capture-pane` начнут работать как только сессия появится. Имя сессии можно переопределить через env `TMUX_SESSION=other` в юните.
|
||||||
|
|
||||||
|
### 3. Старт сервиса
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable --now food-market-telegram-bridge.service
|
||||||
|
```
|
||||||
|
|
||||||
|
В ответ бот пришлёт `✅ bridge up …` — это индикатор успеха.
|
||||||
|
|
||||||
|
## Эксплуатация
|
||||||
|
|
||||||
|
### Логи
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u food-market-telegram-bridge.service -f
|
||||||
|
sudo journalctl -u food-market-telegram-bridge.service --since '10 min ago'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Перезапуск
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart food-market-telegram-bridge.service
|
||||||
|
```
|
||||||
|
|
||||||
|
### Остановить
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop food-market-telegram-bridge.service # до ребута
|
||||||
|
sudo systemctl disable food-market-telegram-bridge.service # и после ребута
|
||||||
|
```
|
||||||
|
|
||||||
|
### Поменять интервал/сессию
|
||||||
|
Отредактируйте `/etc/systemd/system/food-market-telegram-bridge.service`, добавьте в секцию `[Service]`:
|
||||||
|
```
|
||||||
|
Environment=POLL_INTERVAL_SEC=1.5
|
||||||
|
Environment=TMUX_SESSION=other-session
|
||||||
|
Environment=CAPTURE_HISTORY_LINES=400
|
||||||
|
```
|
||||||
|
Затем `sudo systemctl daemon-reload && sudo systemctl restart food-market-telegram-bridge`.
|
||||||
|
|
||||||
|
## Раскладка
|
||||||
|
|
||||||
|
- Скрипт: `/opt/food-market-data/telegram-bridge/bridge.py`
|
||||||
|
- venv (Python 3.12, `python-telegram-bot 21.x`): `/opt/food-market-data/telegram-bridge/venv/`
|
||||||
|
- Креды: `/etc/food-market/telegram.env` (owner `nns`, mode `0600`)
|
||||||
|
- systemd unit: `/etc/systemd/system/food-market-telegram-bridge.service`
|
||||||
|
|
||||||
|
## Что хорошо знать
|
||||||
|
|
||||||
|
- `disable_notification=True` стоит на фоновых сообщениях пайна — не будет жужжать при каждом diff'e.
|
||||||
|
- Telegram-лимит 4096 символов; длинные пайн-блоки режутся на куски по ~3800 символов.
|
||||||
|
- Если после долгого молчания в чате слишком много истории, шлите `/snapshot` — бот обнуляет baseline и присылает текущий экран целиком.
|
||||||
|
- Бот заходит в Telegram long-polling (исходящее к api.telegram.org, без входящих портов) — никакого проброса портов не нужно.
|
||||||
60
src/food-market.api/Background/ReferencePriceRefreshJob.cs
Normal file
60
src/food-market.api/Background/ReferencePriceRefreshJob.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Background;
|
||||||
|
|
||||||
|
/// <summary>Раз в сутки переписывает ReferencePrice = Cost для товаров,
|
||||||
|
/// у которых LastSupplyAt старше 30 дней и Cost > 0. Цель: устаревшая
|
||||||
|
/// «эталонная» цена не остаётся годами — её сравнивают с актуальной
|
||||||
|
/// себестоимостью. Если пользователь редактировал ReferencePrice вручную
|
||||||
|
/// (через PUT /api/catalog/products/...), ReferencePriceUpdatedAt уходит
|
||||||
|
/// в now, и таймер начинает 30 дней заново.</summary>
|
||||||
|
public class ReferencePriceRefreshJob : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<ReferencePriceRefreshJob> _log;
|
||||||
|
// Запускаем каждые 24 часа. Промах между сутками не критичен — цена
|
||||||
|
// не падает, а лишь догоняет текущую Cost.
|
||||||
|
private static readonly TimeSpan Period = TimeSpan.FromHours(24);
|
||||||
|
|
||||||
|
public ReferencePriceRefreshJob(IServiceProvider services, ILogger<ReferencePriceRefreshJob> log)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Ждём 5 минут после старта чтобы не упасть на ещё не применённой миграции.
|
||||||
|
try { await Task.Delay(TimeSpan.FromMinutes(5), ct); } catch (TaskCanceledException) { return; }
|
||||||
|
|
||||||
|
using var timer = new PeriodicTimer(Period);
|
||||||
|
do
|
||||||
|
{
|
||||||
|
try { await RunOnceAsync(ct); }
|
||||||
|
catch (Exception ex) { _log.LogError(ex, "ReferencePriceRefreshJob: iteration failed"); }
|
||||||
|
}
|
||||||
|
while (await timer.WaitForNextTickAsync(ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RunOnceAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var threshold = DateTime.UtcNow.AddDays(-30);
|
||||||
|
// IgnoreQueryFilters — фоновый job работает над всеми organizations.
|
||||||
|
var stale = await db.Products
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(p => p.LastSupplyAt != null && p.LastSupplyAt < threshold && p.Cost > 0m)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
if (stale.Count == 0) return;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
foreach (var p in stale)
|
||||||
|
{
|
||||||
|
p.ReferencePrice = p.Cost;
|
||||||
|
p.ReferencePriceUpdatedAt = now;
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
_log.LogInformation("ReferencePriceRefreshJob: refreshed {Count} stale products", stale.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
201
src/food-market.api/Controllers/Admin/AdminCleanupController.cs
Normal file
201
src/food-market.api/Controllers/Admin/AdminCleanupController.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
using foodmarket.Api.Infrastructure.Tenancy;
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Infrastructure.Integrations.MoySklad;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Admin;
|
||||||
|
|
||||||
|
// Временные эндпоинты для очистки данных после кривых импортов.
|
||||||
|
// Удалять только свой tenant — query-filter на DbSets это обеспечивает.
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = "AdminAccess")]
|
||||||
|
[Route("api/admin/cleanup")]
|
||||||
|
public class AdminCleanupController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IServiceScopeFactory _scopes;
|
||||||
|
private readonly ImportJobRegistry _jobs;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
|
||||||
|
public AdminCleanupController(
|
||||||
|
AppDbContext db,
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
ImportJobRegistry jobs,
|
||||||
|
ITenantContext tenant)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_scopes = scopes;
|
||||||
|
_jobs = jobs;
|
||||||
|
_tenant = tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CleanupStats(
|
||||||
|
int Counterparties,
|
||||||
|
int Products,
|
||||||
|
int ProductGroups,
|
||||||
|
int ProductBarcodes,
|
||||||
|
int ProductPrices,
|
||||||
|
int Supplies,
|
||||||
|
int RetailSales,
|
||||||
|
int Stocks,
|
||||||
|
int StockMovements);
|
||||||
|
|
||||||
|
public record CleanupResult(string Scope, CleanupStats Deleted);
|
||||||
|
|
||||||
|
[HttpGet("stats")]
|
||||||
|
public async Task<ActionResult<CleanupStats>> GetStats(CancellationToken ct)
|
||||||
|
=> new CleanupStats(
|
||||||
|
await _db.Counterparties.CountAsync(ct),
|
||||||
|
await _db.Products.CountAsync(ct),
|
||||||
|
await _db.ProductGroups.CountAsync(ct),
|
||||||
|
await _db.ProductBarcodes.CountAsync(ct),
|
||||||
|
await _db.ProductPrices.CountAsync(ct),
|
||||||
|
await _db.Supplies.CountAsync(ct),
|
||||||
|
await _db.RetailSales.CountAsync(ct),
|
||||||
|
await _db.Stocks.CountAsync(ct),
|
||||||
|
await _db.StockMovements.CountAsync(ct));
|
||||||
|
|
||||||
|
/// <summary>Удалить всех контрагентов текущей организации. Чтобы не нарваться на FK,
|
||||||
|
/// сначала обнуляем ссылки (Product.DefaultSupplier, RetailSale.Customer) и сносим
|
||||||
|
/// поставки (они жёстко ссылаются на supplier).</summary>
|
||||||
|
[HttpDelete("counterparties")]
|
||||||
|
public async Task<ActionResult<CleanupResult>> WipeCounterparties(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var before = await SnapshotAsync(ct);
|
||||||
|
|
||||||
|
// 1. Обнуляем nullable-FK
|
||||||
|
await _db.Products
|
||||||
|
.Where(p => p.DefaultSupplierId != null)
|
||||||
|
.ExecuteUpdateAsync(u => u.SetProperty(p => p.DefaultSupplierId, (Guid?)null), ct);
|
||||||
|
await _db.RetailSales
|
||||||
|
.Where(s => s.CustomerId != null)
|
||||||
|
.ExecuteUpdateAsync(u => u.SetProperty(s => s.CustomerId, (Guid?)null), ct);
|
||||||
|
|
||||||
|
// 2. Сносим поставки (NOT NULL supplier) + их stock movements/stocks
|
||||||
|
await _db.StockMovements
|
||||||
|
.Where(m => m.DocumentType == "supply" || m.DocumentType == "supply-reversal")
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
await _db.SupplyLines.ExecuteDeleteAsync(ct);
|
||||||
|
await _db.Supplies.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
// 3. Контрагенты
|
||||||
|
await _db.Counterparties.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
var after = await SnapshotAsync(ct);
|
||||||
|
return new CleanupResult("counterparties", Diff(before, after));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async-версия полной очистки: возвращает jobId, реальные DELETE в фоне, прогресс в Stage+Deleted.
|
||||||
|
[HttpPost("all/async")]
|
||||||
|
public ActionResult<object> WipeAllAsync()
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||||
|
var job = _jobs.Create("cleanup-all");
|
||||||
|
job.Stage = "Подготовка…";
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var tenantScope = HttpContextTenantContext.UseOverride(orgId);
|
||||||
|
using var scope = _scopes.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var steps = new (string Stage, Func<Task<int>> Run)[]
|
||||||
|
{
|
||||||
|
("Движения склада", () => db.StockMovements.ExecuteDeleteAsync()),
|
||||||
|
("Остатки", () => db.Stocks.ExecuteDeleteAsync()),
|
||||||
|
("Строки поставок", () => db.SupplyLines.ExecuteDeleteAsync()),
|
||||||
|
("Поставки", () => db.Supplies.ExecuteDeleteAsync()),
|
||||||
|
("Строки продаж", () => db.RetailSaleLines.ExecuteDeleteAsync()),
|
||||||
|
("Продажи", () => db.RetailSales.ExecuteDeleteAsync()),
|
||||||
|
("Изображения товаров", () => db.ProductImages.ExecuteDeleteAsync()),
|
||||||
|
("Цены товаров", () => db.ProductPrices.ExecuteDeleteAsync()),
|
||||||
|
("Штрихкоды", () => db.ProductBarcodes.ExecuteDeleteAsync()),
|
||||||
|
("Товары", () => db.Products.ExecuteDeleteAsync()),
|
||||||
|
("Группы товаров", () => db.ProductGroups.ExecuteDeleteAsync()),
|
||||||
|
("Контрагенты", () => db.Counterparties.ExecuteDeleteAsync()),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var (stage, run) in steps)
|
||||||
|
{
|
||||||
|
job.Stage = $"Удаление: {stage}…";
|
||||||
|
job.Deleted += await run();
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Stage = "Готово";
|
||||||
|
job.Message = $"Удалено записей: {job.Deleted}.";
|
||||||
|
job.Status = ImportJobStatus.Succeeded;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
job.Status = ImportJobStatus.Failed;
|
||||||
|
job.Message = ex.Message;
|
||||||
|
job.Errors.Add(ex.ToString());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
job.FinishedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Ok(new { jobId = job.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Полная очистка данных текущей организации — всё кроме настроек:
|
||||||
|
/// остаются Organization, пользователи, справочники (Country, Currency, UnitOfMeasure,
|
||||||
|
/// PriceType), Store и RetailPoint. Удаляются: Product*, ProductGroup, Counterparty,
|
||||||
|
/// Supply*, RetailSale*, Stock, StockMovement.</summary>
|
||||||
|
[HttpDelete("all")]
|
||||||
|
public async Task<ActionResult<CleanupResult>> WipeAll(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var before = await SnapshotAsync(ct);
|
||||||
|
|
||||||
|
// Documents first — they reference products, counterparties, stores.
|
||||||
|
await _db.StockMovements.ExecuteDeleteAsync(ct);
|
||||||
|
await _db.Stocks.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
await _db.SupplyLines.ExecuteDeleteAsync(ct);
|
||||||
|
await _db.Supplies.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
await _db.RetailSaleLines.ExecuteDeleteAsync(ct);
|
||||||
|
await _db.RetailSales.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
// Product composites.
|
||||||
|
await _db.ProductImages.ExecuteDeleteAsync(ct);
|
||||||
|
await _db.ProductPrices.ExecuteDeleteAsync(ct);
|
||||||
|
await _db.ProductBarcodes.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
// Products reference counterparty.DefaultSupplier — FK Restrict, but we're about
|
||||||
|
// to delete products anyway, so order products → counterparties.
|
||||||
|
await _db.Products.ExecuteDeleteAsync(ct);
|
||||||
|
await _db.ProductGroups.ExecuteDeleteAsync(ct);
|
||||||
|
await _db.Counterparties.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
var after = await SnapshotAsync(ct);
|
||||||
|
return new CleanupResult("all", Diff(before, after));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CleanupStats> SnapshotAsync(CancellationToken ct) => new(
|
||||||
|
await _db.Counterparties.CountAsync(ct),
|
||||||
|
await _db.Products.CountAsync(ct),
|
||||||
|
await _db.ProductGroups.CountAsync(ct),
|
||||||
|
await _db.ProductBarcodes.CountAsync(ct),
|
||||||
|
await _db.ProductPrices.CountAsync(ct),
|
||||||
|
await _db.Supplies.CountAsync(ct),
|
||||||
|
await _db.RetailSales.CountAsync(ct),
|
||||||
|
await _db.Stocks.CountAsync(ct),
|
||||||
|
await _db.StockMovements.CountAsync(ct));
|
||||||
|
|
||||||
|
private static CleanupStats Diff(CleanupStats a, CleanupStats b) => new(
|
||||||
|
a.Counterparties - b.Counterparties,
|
||||||
|
a.Products - b.Products,
|
||||||
|
a.ProductGroups - b.ProductGroups,
|
||||||
|
a.ProductBarcodes - b.ProductBarcodes,
|
||||||
|
a.ProductPrices - b.ProductPrices,
|
||||||
|
a.Supplies - b.Supplies,
|
||||||
|
a.RetailSales - b.RetailSales,
|
||||||
|
a.Stocks - b.Stocks,
|
||||||
|
a.StockMovements - b.StockMovements);
|
||||||
|
}
|
||||||
41
src/food-market.api/Controllers/Admin/AdminJobsController.cs
Normal file
41
src/food-market.api/Controllers/Admin/AdminJobsController.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
using foodmarket.Infrastructure.Integrations.MoySklad;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Admin;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = "AdminAccess")]
|
||||||
|
[Route("api/admin/jobs")]
|
||||||
|
public class AdminJobsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ImportJobRegistry _jobs;
|
||||||
|
public AdminJobsController(ImportJobRegistry jobs) => _jobs = jobs;
|
||||||
|
|
||||||
|
public record JobView(
|
||||||
|
Guid Id,
|
||||||
|
string Kind,
|
||||||
|
string Status,
|
||||||
|
string? Stage,
|
||||||
|
DateTime StartedAt,
|
||||||
|
DateTime? FinishedAt,
|
||||||
|
int Total, int Created, int Updated, int Skipped, int Deleted, int GroupsCreated,
|
||||||
|
string? Message,
|
||||||
|
IReadOnlyList<string> Errors);
|
||||||
|
|
||||||
|
private static JobView Project(ImportJobProgress j) => new(
|
||||||
|
j.Id, j.Kind, j.Status.ToString(), j.Stage, j.StartedAt, j.FinishedAt,
|
||||||
|
j.Total, j.Created, j.Updated, j.Skipped, j.Deleted, j.GroupsCreated,
|
||||||
|
j.Message, j.Errors.TakeLast(20).ToList());
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public ActionResult<JobView> Get(Guid id)
|
||||||
|
{
|
||||||
|
var j = _jobs.Get(id);
|
||||||
|
return j is null ? NotFound() : Project(j);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("recent")]
|
||||||
|
public IReadOnlyList<JobView> Recent([FromQuery] int take = 10)
|
||||||
|
=> _jobs.RecentlyFinished(take).Select(Project).ToList();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
using foodmarket.Api.Infrastructure.Tenancy;
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Infrastructure.Integrations.MoySklad;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Admin;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = "AdminAccess")]
|
||||||
|
[Route("api/admin/moysklad")]
|
||||||
|
public class MoySkladImportController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopes;
|
||||||
|
private readonly MoySkladImportService _svc;
|
||||||
|
private readonly ImportJobRegistry _jobs;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
|
||||||
|
public MoySkladImportController(
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
MoySkladImportService svc,
|
||||||
|
ImportJobRegistry jobs,
|
||||||
|
AppDbContext db,
|
||||||
|
ITenantContext tenant)
|
||||||
|
{
|
||||||
|
_scopes = scopes;
|
||||||
|
_svc = svc;
|
||||||
|
_jobs = jobs;
|
||||||
|
_db = db;
|
||||||
|
_tenant = tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record TestRequest(string? Token = null);
|
||||||
|
public record ImportRequest(string? Token = null, bool OverwriteExisting = false);
|
||||||
|
public record SettingsDto(bool HasToken, string? Masked);
|
||||||
|
public record SettingsInput(string Token);
|
||||||
|
|
||||||
|
[HttpGet("settings")]
|
||||||
|
public async Task<ActionResult<SettingsDto>> GetSettings(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var token = await ReadTokenFromOrgAsync(ct);
|
||||||
|
return new SettingsDto(!string.IsNullOrEmpty(token), Mask(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("settings")]
|
||||||
|
public async Task<ActionResult<SettingsDto>> SetSettings([FromBody] SettingsInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||||
|
var org = await _db.Organizations.FirstOrDefaultAsync(o => o.Id == orgId, ct);
|
||||||
|
if (org is null) return NotFound();
|
||||||
|
org.MoySkladToken = string.IsNullOrWhiteSpace(input.Token) ? null : input.Token.Trim();
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return new SettingsDto(!string.IsNullOrEmpty(org.MoySkladToken), Mask(org.MoySkladToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("test")]
|
||||||
|
public async Task<IActionResult> TestConnection([FromBody] TestRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
return BadRequest(new { error = "Token is required." });
|
||||||
|
|
||||||
|
var result = await _svc.TestConnectionAsync(token, ct);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
var msg = result.StatusCode switch
|
||||||
|
{
|
||||||
|
401 or 403 => "Токен недействителен или не имеет доступа к API.",
|
||||||
|
503 or 502 => "МойСклад временно недоступен. Повтори через минуту.",
|
||||||
|
_ => $"МойСклад вернул {result.StatusCode}: {Truncate(result.Error, 400)}",
|
||||||
|
};
|
||||||
|
return StatusCode(result.StatusCode ?? 502, new { error = msg });
|
||||||
|
}
|
||||||
|
return Ok(new { organization = result.Value!.Name, inn = result.Value.Inn });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async launch — возвращает jobId, реальная работа в фоне. Клиент полит /jobs/{id}.
|
||||||
|
|
||||||
|
[HttpPost("import-products")]
|
||||||
|
public async Task<ActionResult<object>> ImportProducts([FromBody] ImportRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
return BadRequest(new { error = "Токен не настроен. Сохраните его в /admin/moysklad/settings." });
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||||
|
|
||||||
|
var job = _jobs.Create("products");
|
||||||
|
job.Stage = "Подключение к MoySklad…";
|
||||||
|
_ = RunInBackgroundAsync(job, async (svc, progress, ctInner) =>
|
||||||
|
{
|
||||||
|
progress.Stage = "Импорт товаров…";
|
||||||
|
var result = await svc.ImportProductsAsync(token, req.OverwriteExisting, ctInner, progress);
|
||||||
|
progress.Message = $"Готово: {result.Created} записей (создано/обновлено), {result.Skipped} пропущено, {result.GroupsCreated} групп.";
|
||||||
|
}, orgId);
|
||||||
|
return Ok(new { jobId = job.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("import-counterparties")]
|
||||||
|
public async Task<ActionResult<object>> ImportCounterparties([FromBody] ImportRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
return BadRequest(new { error = "Токен не настроен. Сохраните его в /admin/moysklad/settings." });
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||||
|
|
||||||
|
var job = _jobs.Create("counterparties");
|
||||||
|
job.Stage = "Подключение к MoySklad…";
|
||||||
|
_ = RunInBackgroundAsync(job, async (svc, progress, ctInner) =>
|
||||||
|
{
|
||||||
|
progress.Stage = "Импорт контрагентов…";
|
||||||
|
var result = await svc.ImportCounterpartiesAsync(token, req.OverwriteExisting, ctInner, progress);
|
||||||
|
progress.Message = $"Готово: {result.Created} записей, {result.Skipped} пропущено.";
|
||||||
|
}, orgId);
|
||||||
|
return Ok(new { jobId = job.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task RunInBackgroundAsync(
|
||||||
|
ImportJobProgress job,
|
||||||
|
Func<MoySkladImportService, ImportJobProgress, CancellationToken, Task> work,
|
||||||
|
Guid orgId)
|
||||||
|
{
|
||||||
|
return Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var tenantScope = HttpContextTenantContext.UseOverride(orgId);
|
||||||
|
using var scope = _scopes.CreateScope();
|
||||||
|
var svc = scope.ServiceProvider.GetRequiredService<MoySkladImportService>();
|
||||||
|
await work(svc, job, CancellationToken.None);
|
||||||
|
job.Status = ImportJobStatus.Succeeded;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
job.Status = ImportJobStatus.Failed;
|
||||||
|
job.Message = ex.Message;
|
||||||
|
job.Errors.Add(ex.ToString());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
job.FinishedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> ReadTokenFromOrgAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId;
|
||||||
|
if (orgId is null) return null;
|
||||||
|
return await _db.Organizations
|
||||||
|
.Where(o => o.Id == orgId)
|
||||||
|
.Select(o => o.MoySkladToken)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Mask(string? token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
|
if (token.Length <= 8) return new string('•', token.Length);
|
||||||
|
return token[..4] + new string('•', 8) + token[^4..];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Truncate(string? s, int max)
|
||||||
|
=> string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max] + "…");
|
||||||
|
}
|
||||||
96
src/food-market.api/Controllers/AuthSignupController.cs
Normal file
96
src/food-market.api/Controllers/AuthSignupController.cs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
using foodmarket.Api.Seed;
|
||||||
|
using foodmarket.Domain.Organizations;
|
||||||
|
using foodmarket.Infrastructure.Identity;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Самообслуживание: регистрация новой организации с публичного
|
||||||
|
/// маркетингового сайта. Создаёт Organization + bootstrap (Stores, Roles,
|
||||||
|
/// Units, PriceTypes, Cassa) + первого Owner-Employee-AppUser-Admin.
|
||||||
|
///
|
||||||
|
/// Токены НЕ выпускаются здесь — фронт получает их обычным запросом
|
||||||
|
/// /connect/token (password grant) сразу после успешного signup. Это
|
||||||
|
/// убирает дублирование с OpenIddict и упрощает контракт. Phase 6: без
|
||||||
|
/// email-верификации (переедет в Phase 7).</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/auth")]
|
||||||
|
public class AuthSignupController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly UserManager<User> _userMgr;
|
||||||
|
|
||||||
|
public AuthSignupController(AppDbContext db, UserManager<User> userMgr)
|
||||||
|
{
|
||||||
|
_db = db; _userMgr = userMgr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SignupInput(string Email, string Password, string OrganizationName, string? Phone, string? Plan);
|
||||||
|
public record SignupResult(Guid OrganizationId, string Email);
|
||||||
|
|
||||||
|
[HttpPost("signup")]
|
||||||
|
public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input.Email) || string.IsNullOrWhiteSpace(input.Password)
|
||||||
|
|| string.IsNullOrWhiteSpace(input.OrganizationName))
|
||||||
|
return BadRequest(new { error = "Email, пароль и название обязательны." });
|
||||||
|
if (input.Password.Length < 8)
|
||||||
|
return BadRequest(new { error = "Пароль минимум 8 символов." });
|
||||||
|
|
||||||
|
var existing = await _userMgr.FindByEmailAsync(input.Email);
|
||||||
|
if (existing is not null)
|
||||||
|
return BadRequest(new { error = "Пользователь с таким email уже зарегистрирован." });
|
||||||
|
|
||||||
|
// 1. Organization + полный bootstrap tenant-сущностей.
|
||||||
|
var kzt = await _db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct);
|
||||||
|
var org = new Organization
|
||||||
|
{
|
||||||
|
Name = input.OrganizationName.Trim(),
|
||||||
|
CountryCode = "KZ",
|
||||||
|
DefaultCurrencyId = kzt?.Id,
|
||||||
|
Phone = string.IsNullOrWhiteSpace(input.Phone) ? null : input.Phone.Trim(),
|
||||||
|
Email = input.Email.Trim(),
|
||||||
|
};
|
||||||
|
_db.Organizations.Add(org);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
await DevDataSeeder.SeedTenantReferencesAsync(_db, org.Id, ct);
|
||||||
|
|
||||||
|
// 2. AppUser в роли Identity Admin, привязан к этой организации.
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
UserName = input.Email.Trim(),
|
||||||
|
Email = input.Email.Trim(),
|
||||||
|
EmailConfirmed = true,
|
||||||
|
FullName = input.OrganizationName.Trim(),
|
||||||
|
OrganizationId = org.Id,
|
||||||
|
IsActive = true,
|
||||||
|
};
|
||||||
|
var ur = await _userMgr.CreateAsync(user, input.Password);
|
||||||
|
if (!ur.Succeeded)
|
||||||
|
{
|
||||||
|
// Откат: убираем органзацию чтобы не оставить orphan.
|
||||||
|
_db.Organizations.Remove(org);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return BadRequest(new { error = string.Join("; ", ur.Errors.Select(e => e.Description)) });
|
||||||
|
}
|
||||||
|
await _userMgr.AddToRoleAsync(user, "Admin");
|
||||||
|
|
||||||
|
// 3. Owner Employee с системной ролью «Администратор».
|
||||||
|
var adminRole = await _db.EmployeeRoles.IgnoreQueryFilters()
|
||||||
|
.FirstAsync(r => r.OrganizationId == org.Id && r.IsSystem && r.Name == "Администратор", ct);
|
||||||
|
_db.Employees.Add(new Employee
|
||||||
|
{
|
||||||
|
OrganizationId = org.Id, UserId = user.Id,
|
||||||
|
LastName = input.OrganizationName.Trim(), FirstName = "Owner",
|
||||||
|
Position = "Владелец", Email = input.Email.Trim(),
|
||||||
|
RoleId = adminRole.Id, IsActive = true,
|
||||||
|
});
|
||||||
|
org.AccountOwnerUserId = user.Id;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new SignupResult(org.Id, user.Email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,14 +20,9 @@ public class CounterpartiesController : ControllerBase
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<PagedResult<CounterpartyDto>>> List(
|
public async Task<ActionResult<PagedResult<CounterpartyDto>>> List(
|
||||||
[FromQuery] PagedRequest req,
|
[FromQuery] PagedRequest req,
|
||||||
[FromQuery] CounterpartyKind? kind,
|
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var q = _db.Counterparties.Include(c => c.Country).AsNoTracking().AsQueryable();
|
var q = _db.Counterparties.Include(c => c.Country).AsNoTracking().AsQueryable();
|
||||||
if (kind is not null)
|
|
||||||
{
|
|
||||||
q = q.Where(c => c.Kind == kind || c.Kind == CounterpartyKind.Both);
|
|
||||||
}
|
|
||||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
{
|
{
|
||||||
var s = req.Search.Trim().ToLower();
|
var s = req.Search.Trim().ToLower();
|
||||||
|
|
@ -39,14 +34,26 @@ public class CounterpartiesController : ControllerBase
|
||||||
(c.Phone != null && c.Phone.Contains(s)));
|
(c.Phone != null && c.Phone.Contains(s)));
|
||||||
}
|
}
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
|
q = (req.Sort, req.Desc) switch
|
||||||
|
{
|
||||||
|
("type", false) => q.OrderBy(c => c.Type).ThenBy(c => c.Name),
|
||||||
|
("type", true) => q.OrderByDescending(c => c.Type).ThenBy(c => c.Name),
|
||||||
|
("country", false) => q.OrderBy(c => c.Country != null ? c.Country.Name : null).ThenBy(c => c.Name),
|
||||||
|
("country", true) => q.OrderByDescending(c => c.Country != null ? c.Country.Name : null).ThenBy(c => c.Name),
|
||||||
|
("legalName", false) => q.OrderBy(c => c.LegalName).ThenBy(c => c.Name),
|
||||||
|
("legalName", true) => q.OrderByDescending(c => c.LegalName).ThenBy(c => c.Name),
|
||||||
|
("phone", false) => q.OrderBy(c => c.Phone).ThenBy(c => c.Name),
|
||||||
|
("phone", true) => q.OrderByDescending(c => c.Phone).ThenBy(c => c.Name),
|
||||||
|
("name", true) => q.OrderByDescending(c => c.Name),
|
||||||
|
_ => q.OrderBy(c => c.Name),
|
||||||
|
};
|
||||||
var items = await q
|
var items = await q
|
||||||
.OrderBy(c => c.Name)
|
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(c => new CounterpartyDto(
|
.Select(c => new CounterpartyDto(
|
||||||
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
|
c.Id, c.Name, c.LegalName, c.Type,
|
||||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null,
|
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null,
|
||||||
c.Address, c.Phone, c.Email,
|
c.Address, c.Phone, c.Email,
|
||||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive))
|
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<CounterpartyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<CounterpartyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -56,10 +63,10 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
|
||||||
{
|
{
|
||||||
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
return c is null ? NotFound() : new CounterpartyDto(
|
return c is null ? NotFound() : new CounterpartyDto(
|
||||||
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
|
c.Id, c.Name, c.LegalName, c.Type,
|
||||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
||||||
c.Address, c.Phone, c.Email,
|
c.Address, c.Phone, c.Email,
|
||||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive);
|
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
|
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
|
|
@ -95,7 +102,6 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i)
|
||||||
{
|
{
|
||||||
e.Name = i.Name;
|
e.Name = i.Name;
|
||||||
e.LegalName = i.LegalName;
|
e.LegalName = i.LegalName;
|
||||||
e.Kind = i.Kind;
|
|
||||||
e.Type = i.Type;
|
e.Type = i.Type;
|
||||||
e.Bin = i.Bin;
|
e.Bin = i.Bin;
|
||||||
e.Iin = i.Iin;
|
e.Iin = i.Iin;
|
||||||
|
|
@ -109,7 +115,6 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i)
|
||||||
e.Bik = i.Bik;
|
e.Bik = i.Bik;
|
||||||
e.ContactPerson = i.ContactPerson;
|
e.ContactPerson = i.ContactPerson;
|
||||||
e.Notes = i.Notes;
|
e.Notes = i.Notes;
|
||||||
e.IsActive = i.IsActive;
|
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,9 +122,9 @@ private async Task<CounterpartyDto> ProjectAsync(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstAsync(x => x.Id == id, ct);
|
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstAsync(x => x.Id == id, ct);
|
||||||
return new CounterpartyDto(
|
return new CounterpartyDto(
|
||||||
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
|
c.Id, c.Name, c.LegalName, c.Type,
|
||||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
||||||
c.Address, c.Phone, c.Email,
|
c.Address, c.Phone, c.Email,
|
||||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive);
|
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,17 +20,32 @@ public class CountriesController : ControllerBase
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<PagedResult<CountryDto>>> List([FromQuery] PagedRequest req, CancellationToken ct)
|
public async Task<ActionResult<PagedResult<CountryDto>>> List([FromQuery] PagedRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var q = _db.Countries.AsNoTracking().AsQueryable();
|
var q = _db.Countries.Include(c => c.DefaultCurrency).AsNoTracking().AsQueryable();
|
||||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
{
|
{
|
||||||
var s = req.Search.Trim().ToLower();
|
var s = req.Search.Trim().ToLower();
|
||||||
q = q.Where(c => c.Name.ToLower().Contains(s) || c.Code.ToLower().Contains(s));
|
q = q.Where(c => c.Name.ToLower().Contains(s) || c.Code.ToLower().Contains(s));
|
||||||
}
|
}
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
|
q = (req.Sort, req.Desc) switch
|
||||||
|
{
|
||||||
|
("code", false) => q.OrderBy(c => c.Code),
|
||||||
|
("code", true) => q.OrderByDescending(c => c.Code),
|
||||||
|
("currency", false) => q.OrderBy(c => c.DefaultCurrency != null ? c.DefaultCurrency.Code : null).ThenBy(c => c.Name),
|
||||||
|
("currency", true) => q.OrderByDescending(c => c.DefaultCurrency != null ? c.DefaultCurrency.Code : null).ThenBy(c => c.Name),
|
||||||
|
("vatRate", false) => q.OrderBy(c => c.VatRate).ThenBy(c => c.Name),
|
||||||
|
("vatRate", true) => q.OrderByDescending(c => c.VatRate).ThenBy(c => c.Name),
|
||||||
|
("name", true) => q.OrderByDescending(c => c.Name),
|
||||||
|
_ => q.OrderBy(c => c.Name),
|
||||||
|
};
|
||||||
var items = await q
|
var items = await q
|
||||||
.OrderBy(c => c.SortOrder).ThenBy(c => c.Name)
|
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(c => new CountryDto(c.Id, c.Code, c.Name, c.SortOrder))
|
.Select(c => new CountryDto(
|
||||||
|
c.Id, c.Code, c.Name,
|
||||||
|
c.DefaultCurrencyId,
|
||||||
|
c.DefaultCurrency != null ? c.DefaultCurrency.Code : null,
|
||||||
|
c.DefaultCurrency != null ? c.DefaultCurrency.Symbol : null,
|
||||||
|
c.VatRate))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<CountryDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<CountryDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -38,17 +53,24 @@ public async Task<ActionResult<PagedResult<CountryDto>>> List([FromQuery] PagedR
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<ActionResult<CountryDto>> Get(Guid id, CancellationToken ct)
|
public async Task<ActionResult<CountryDto>> Get(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var c = await _db.Countries.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
var c = await _db.Countries.Include(x => x.DefaultCurrency).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
return c is null ? NotFound() : new CountryDto(c.Id, c.Code, c.Name, c.SortOrder);
|
return c is null ? NotFound() : Project(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "SuperAdmin")]
|
[HttpPost, Authorize(Roles = "SuperAdmin")]
|
||||||
public async Task<ActionResult<CountryDto>> Create([FromBody] CountryInput input, CancellationToken ct)
|
public async Task<ActionResult<CountryDto>> Create([FromBody] CountryInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = new Country { Code = input.Code.Trim().ToUpper(), Name = input.Name, SortOrder = input.SortOrder };
|
var e = new Country
|
||||||
|
{
|
||||||
|
Code = input.Code.Trim().ToUpper(),
|
||||||
|
Name = input.Name,
|
||||||
|
DefaultCurrencyId = input.DefaultCurrencyId,
|
||||||
|
VatRate = input.VatRate,
|
||||||
|
};
|
||||||
_db.Countries.Add(e);
|
_db.Countries.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id }, new CountryDto(e.Id, e.Code, e.Name, e.SortOrder));
|
await _db.Entry(e).Reference(x => x.DefaultCurrency).LoadAsync(ct);
|
||||||
|
return CreatedAtAction(nameof(Get), new { id = e.Id }, Project(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin")]
|
||||||
|
|
@ -58,7 +80,8 @@ public async Task<IActionResult> Update(Guid id, [FromBody] CountryInput input,
|
||||||
if (e is null) return NotFound();
|
if (e is null) return NotFound();
|
||||||
e.Code = input.Code.Trim().ToUpper();
|
e.Code = input.Code.Trim().ToUpper();
|
||||||
e.Name = input.Name;
|
e.Name = input.Name;
|
||||||
e.SortOrder = input.SortOrder;
|
e.DefaultCurrencyId = input.DefaultCurrencyId;
|
||||||
|
e.VatRate = input.VatRate;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
@ -72,4 +95,8 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static CountryDto Project(Country c) => new(
|
||||||
|
c.Id, c.Code, c.Name,
|
||||||
|
c.DefaultCurrencyId, c.DefaultCurrency?.Code, c.DefaultCurrency?.Symbol, c.VatRate);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,18 @@ public async Task<ActionResult<PagedResult<CurrencyDto>>> List([FromQuery] Paged
|
||||||
q = q.Where(c => c.Name.ToLower().Contains(s) || c.Code.ToLower().Contains(s));
|
q = q.Where(c => c.Name.ToLower().Contains(s) || c.Code.ToLower().Contains(s));
|
||||||
}
|
}
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
|
q = (req.Sort, req.Desc) switch
|
||||||
|
{
|
||||||
|
("name", false) => q.OrderBy(c => c.Name),
|
||||||
|
("name", true) => q.OrderByDescending(c => c.Name),
|
||||||
|
("symbol", false) => q.OrderBy(c => c.Symbol),
|
||||||
|
("symbol", true) => q.OrderByDescending(c => c.Symbol),
|
||||||
|
("code", true) => q.OrderByDescending(c => c.Code),
|
||||||
|
_ => q.OrderBy(c => c.Code),
|
||||||
|
};
|
||||||
var items = await q
|
var items = await q
|
||||||
.OrderBy(c => c.Code)
|
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(c => new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol, c.MinorUnit, c.IsActive))
|
.Select(c => new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<CurrencyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<CurrencyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -39,7 +47,7 @@ public async Task<ActionResult<PagedResult<CurrencyDto>>> List([FromQuery] Paged
|
||||||
public async Task<ActionResult<CurrencyDto>> Get(Guid id, CancellationToken ct)
|
public async Task<ActionResult<CurrencyDto>> Get(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var c = await _db.Currencies.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
var c = await _db.Currencies.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
return c is null ? NotFound() : new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol, c.MinorUnit, c.IsActive);
|
return c is null ? NotFound() : new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "SuperAdmin")]
|
[HttpPost, Authorize(Roles = "SuperAdmin")]
|
||||||
|
|
@ -50,13 +58,11 @@ public async Task<ActionResult<CurrencyDto>> Create([FromBody] CurrencyInput inp
|
||||||
Code = input.Code.Trim().ToUpper(),
|
Code = input.Code.Trim().ToUpper(),
|
||||||
Name = input.Name,
|
Name = input.Name,
|
||||||
Symbol = input.Symbol,
|
Symbol = input.Symbol,
|
||||||
MinorUnit = input.MinorUnit,
|
|
||||||
IsActive = input.IsActive,
|
|
||||||
};
|
};
|
||||||
_db.Currencies.Add(e);
|
_db.Currencies.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||||
new CurrencyDto(e.Id, e.Code, e.Name, e.Symbol, e.MinorUnit, e.IsActive));
|
new CurrencyDto(e.Id, e.Code, e.Name, e.Symbol));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin")]
|
||||||
|
|
@ -67,8 +73,6 @@ public async Task<IActionResult> Update(Guid id, [FromBody] CurrencyInput input,
|
||||||
e.Code = input.Code.Trim().ToUpper();
|
e.Code = input.Code.Trim().ToUpper();
|
||||||
e.Name = input.Name;
|
e.Name = input.Name;
|
||||||
e.Symbol = input.Symbol;
|
e.Symbol = input.Symbol;
|
||||||
e.MinorUnit = input.MinorUnit;
|
|
||||||
e.IsActive = input.IsActive;
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,17 @@ public async Task<ActionResult<PagedResult<PriceTypeDto>>> List([FromQuery] Page
|
||||||
q = q.Where(p => p.Name.ToLower().Contains(s));
|
q = q.Where(p => p.Name.ToLower().Contains(s));
|
||||||
}
|
}
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
|
q = (req.Sort, req.Desc) switch
|
||||||
|
{
|
||||||
|
("name", false) => q.OrderBy(p => p.Name),
|
||||||
|
("name", true) => q.OrderByDescending(p => p.Name),
|
||||||
|
("isRequired", false) => q.OrderBy(p => p.IsRequired).ThenBy(p => p.Name),
|
||||||
|
("isRequired", true) => q.OrderByDescending(p => p.IsRequired).ThenBy(p => p.Name),
|
||||||
|
_ => q.OrderByDescending(p => p.IsSystem).ThenBy(p => p.SortOrder).ThenBy(p => p.Name),
|
||||||
|
};
|
||||||
var items = await q
|
var items = await q
|
||||||
.OrderByDescending(p => p.IsDefault).ThenBy(p => p.SortOrder).ThenBy(p => p.Name)
|
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(p => new PriceTypeDto(p.Id, p.Name, p.IsDefault, p.IsRetail, p.SortOrder, p.IsActive))
|
.Select(p => new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsRetail, p.SortOrder))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<PriceTypeDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<PriceTypeDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -39,25 +46,30 @@ public async Task<ActionResult<PagedResult<PriceTypeDto>>> List([FromQuery] Page
|
||||||
public async Task<ActionResult<PriceTypeDto>> Get(Guid id, CancellationToken ct)
|
public async Task<ActionResult<PriceTypeDto>> Get(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var p = await _db.PriceTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
var p = await _db.PriceTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsDefault, p.IsRetail, p.SortOrder, p.IsActive);
|
return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsRetail, p.SortOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||||
public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct)
|
public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (input.IsDefault)
|
if (input.IsRetail)
|
||||||
{
|
{
|
||||||
await _db.PriceTypes.Where(p => p.IsDefault).ExecuteUpdateAsync(s => s.SetProperty(p => p.IsDefault, false), ct);
|
// Уникальность IsRetail: не более одной записи в организации.
|
||||||
|
await _db.PriceTypes.Where(p => p.IsRetail)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(p => p.IsRetail, false), ct);
|
||||||
}
|
}
|
||||||
var e = new PriceType
|
var e = new PriceType
|
||||||
{
|
{
|
||||||
Name = input.Name, IsDefault = input.IsDefault, IsRetail = input.IsRetail,
|
Name = input.Name,
|
||||||
SortOrder = input.SortOrder, IsActive = input.IsActive,
|
IsRequired = input.IsRequired,
|
||||||
|
IsSystem = false,
|
||||||
|
IsRetail = input.IsRetail,
|
||||||
|
SortOrder = input.SortOrder,
|
||||||
};
|
};
|
||||||
_db.PriceTypes.Add(e);
|
_db.PriceTypes.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||||
new PriceTypeDto(e.Id, e.Name, e.IsDefault, e.IsRetail, e.SortOrder, e.IsActive));
|
new PriceTypeDto(e.Id, e.Name, e.IsRequired, e.IsSystem, e.IsRetail, e.SortOrder));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||||
|
|
@ -65,15 +77,17 @@ public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input
|
||||||
{
|
{
|
||||||
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (e is null) return NotFound();
|
if (e is null) return NotFound();
|
||||||
if (input.IsDefault && !e.IsDefault)
|
if (input.IsRetail && !e.IsRetail)
|
||||||
{
|
{
|
||||||
await _db.PriceTypes.Where(p => p.IsDefault && p.Id != id).ExecuteUpdateAsync(s => s.SetProperty(p => p.IsDefault, false), ct);
|
// Снимаем IsRetail с прежней записи (если была) — гарантия уникальности.
|
||||||
|
await _db.PriceTypes.Where(p => p.IsRetail && p.Id != id)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(p => p.IsRetail, false), ct);
|
||||||
}
|
}
|
||||||
e.Name = input.Name;
|
e.Name = input.Name;
|
||||||
e.IsDefault = input.IsDefault;
|
|
||||||
e.IsRetail = input.IsRetail;
|
e.IsRetail = input.IsRetail;
|
||||||
e.SortOrder = input.SortOrder;
|
e.SortOrder = input.SortOrder;
|
||||||
e.IsActive = input.IsActive;
|
// У системной записи IsRequired всегда true и не меняется.
|
||||||
|
e.IsRequired = e.IsSystem ? true : input.IsRequired;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
@ -83,8 +97,26 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (e is null) return NotFound();
|
if (e is null) return NotFound();
|
||||||
|
if (e.IsSystem) return BadRequest(new { error = "Системная запись не может быть удалена." });
|
||||||
|
var wasRetail = e.IsRetail;
|
||||||
_db.PriceTypes.Remove(e);
|
_db.PriceTypes.Remove(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// Если удалённая запись была единственной IsRetail — фоллбэк на системную,
|
||||||
|
// чтобы у организации всегда оставался один IsRetail-кандидат для POS.
|
||||||
|
if (wasRetail)
|
||||||
|
{
|
||||||
|
var stillRetail = await _db.PriceTypes.AnyAsync(p => p.IsRetail, ct);
|
||||||
|
if (!stillRetail)
|
||||||
|
{
|
||||||
|
var sys = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsSystem, ct);
|
||||||
|
if (sys is not null && !sys.IsRetail)
|
||||||
|
{
|
||||||
|
sys.IsRetail = true;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,17 @@ public class ProductGroupsController : ControllerBase
|
||||||
q = q.Where(g => g.Name.ToLower().Contains(s) || g.Path.ToLower().Contains(s));
|
q = q.Where(g => g.Name.ToLower().Contains(s) || g.Path.ToLower().Contains(s));
|
||||||
}
|
}
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
|
q = (req.Sort, req.Desc) switch
|
||||||
|
{
|
||||||
|
("name", false) => q.OrderBy(g => g.Name),
|
||||||
|
("name", true) => q.OrderByDescending(g => g.Name),
|
||||||
|
("path", false) => q.OrderBy(g => g.Path),
|
||||||
|
("path", true) => q.OrderByDescending(g => g.Path),
|
||||||
|
_ => q.OrderBy(g => g.Path).ThenBy(g => g.SortOrder).ThenBy(g => g.Name),
|
||||||
|
};
|
||||||
var items = await q
|
var items = await q
|
||||||
.OrderBy(g => g.Path).ThenBy(g => g.SortOrder).ThenBy(g => g.Name)
|
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive))
|
.Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<ProductGroupDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<ProductGroupDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +53,7 @@ public class ProductGroupsController : ControllerBase
|
||||||
public async Task<ActionResult<ProductGroupDto>> Get(Guid id, CancellationToken ct)
|
public async Task<ActionResult<ProductGroupDto>> Get(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var g = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
var g = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive);
|
return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||||
|
|
@ -56,33 +63,39 @@ public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupI
|
||||||
var e = new ProductGroup
|
var e = new ProductGroup
|
||||||
{
|
{
|
||||||
Name = input.Name, ParentId = input.ParentId, Path = path,
|
Name = input.Name, ParentId = input.ParentId, Path = path,
|
||||||
SortOrder = input.SortOrder, IsActive = input.IsActive,
|
SortOrder = input.SortOrder,
|
||||||
|
MarkupPercent = input.MarkupPercent,
|
||||||
};
|
};
|
||||||
_db.ProductGroups.Add(e);
|
_db.ProductGroups.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||||
new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.IsActive));
|
new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.MarkupPercent, e.OrganizationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,SuperAdmin")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (e is null) return NotFound();
|
if (e is null) return NotFound();
|
||||||
|
// Системную (эталонную) запись правит только SuperAdmin без override.
|
||||||
|
if (e.OrganizationId is null && !(User.IsInRole("SuperAdmin")))
|
||||||
|
return Forbid();
|
||||||
if (input.ParentId == id) return BadRequest(new { error = "ParentId cannot be self" });
|
if (input.ParentId == id) return BadRequest(new { error = "ParentId cannot be self" });
|
||||||
|
|
||||||
e.Name = input.Name;
|
e.Name = input.Name;
|
||||||
e.ParentId = input.ParentId;
|
e.ParentId = input.ParentId;
|
||||||
e.Path = await BuildPathAsync(input.ParentId, input.Name, ct);
|
e.Path = await BuildPathAsync(input.ParentId, input.Name, ct);
|
||||||
e.SortOrder = input.SortOrder;
|
e.SortOrder = input.SortOrder;
|
||||||
e.IsActive = input.IsActive;
|
e.MarkupPercent = input.MarkupPercent;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var owner = await _db.ProductGroups.Where(x => x.Id == id).Select(x => x.OrganizationId).FirstOrDefaultAsync(ct);
|
||||||
|
if (owner is null && !User.IsInRole("SuperAdmin")) return Forbid();
|
||||||
var hasChildren = await _db.ProductGroups.AnyAsync(g => g.ParentId == id, ct);
|
var hasChildren = await _db.ProductGroups.AnyAsync(g => g.ParentId == id, ct);
|
||||||
if (hasChildren) return BadRequest(new { error = "Нельзя удалить группу с подгруппами" });
|
if (hasChildren) return BadRequest(new { error = "Нельзя удалить группу с подгруппами" });
|
||||||
var hasProducts = await _db.Products.AnyAsync(p => p.ProductGroupId == id, ct);
|
var hasProducts = await _db.Products.AnyAsync(p => p.ProductGroupId == id, ct);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Domain.Catalog;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Catalog;
|
||||||
|
|
||||||
|
/// <summary>Локальное хранилище изображений товаров: multipart upload →
|
||||||
|
/// /app/uploads/products/{productId}/{guid}.{ext}; в БД лежит относительный путь
|
||||||
|
/// типа "/uploads/products/{id}/{file}", раздаётся nginx'ом как статика.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/catalog/products/{productId:guid}/images")]
|
||||||
|
public class ProductImagesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
private readonly IWebHostEnvironment _env;
|
||||||
|
|
||||||
|
public ProductImagesController(AppDbContext db, ITenantContext tenant, IWebHostEnvironment env)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_tenant = tenant;
|
||||||
|
_env = env;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly HashSet<string> AllowedExt = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{ ".jpg", ".jpeg", ".png", ".webp", ".gif" };
|
||||||
|
|
||||||
|
private const long MaxBytes = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
private string UploadRoot => Path.Combine(_env.ContentRootPath, "uploads", "products");
|
||||||
|
|
||||||
|
public record ImageDto(Guid Id, string Url, bool IsMain, int SortOrder);
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<ImageDto>>> List(Guid productId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct);
|
||||||
|
if (product is null) return NotFound();
|
||||||
|
|
||||||
|
var images = await _db.ProductImages
|
||||||
|
.Where(i => i.ProductId == productId)
|
||||||
|
.OrderBy(i => i.SortOrder)
|
||||||
|
.Select(i => new ImageDto(i.Id, i.Url, i.IsMain, i.SortOrder))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
|
[RequestSizeLimit(MaxBytes)]
|
||||||
|
public async Task<ActionResult<ImageDto>> Upload(Guid productId, IFormFile file, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (file is null || file.Length == 0) return BadRequest(new { error = "No file." });
|
||||||
|
if (file.Length > MaxBytes) return BadRequest(new { error = $"File too large (max {MaxBytes / 1024 / 1024} MB)." });
|
||||||
|
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||||
|
if (!AllowedExt.Contains(ext)) return BadRequest(new { error = "Only JPG/PNG/WEBP/GIF are allowed." });
|
||||||
|
|
||||||
|
var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct);
|
||||||
|
if (product is null) return NotFound();
|
||||||
|
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||||
|
var dir = Path.Combine(UploadRoot, productId.ToString());
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
var fileName = $"{Guid.NewGuid():N}{ext}";
|
||||||
|
var fullPath = Path.Combine(dir, fileName);
|
||||||
|
using (var stream = System.IO.File.Create(fullPath))
|
||||||
|
{
|
||||||
|
await file.CopyToAsync(stream, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var relativeUrl = $"/uploads/products/{productId}/{fileName}";
|
||||||
|
var sortOrder = await _db.ProductImages.Where(i => i.ProductId == productId).CountAsync(ct);
|
||||||
|
var isMain = sortOrder == 0; // первое загруженное — основное
|
||||||
|
var entity = new ProductImage
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
ProductId = productId,
|
||||||
|
Url = relativeUrl,
|
||||||
|
IsMain = isMain,
|
||||||
|
SortOrder = sortOrder,
|
||||||
|
};
|
||||||
|
_db.ProductImages.Add(entity);
|
||||||
|
if (isMain) product.ImageUrl = relativeUrl;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new ImageDto(entity.Id, entity.Url, entity.IsMain, entity.SortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{imageId:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
|
public async Task<IActionResult> Delete(Guid productId, Guid imageId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
|
||||||
|
if (image is null) return NotFound();
|
||||||
|
|
||||||
|
// Удаляем файл с диска (не фейлим если отсутствует).
|
||||||
|
var fileName = Path.GetFileName(image.Url);
|
||||||
|
var fullPath = Path.Combine(UploadRoot, productId.ToString(), fileName);
|
||||||
|
try { if (System.IO.File.Exists(fullPath)) System.IO.File.Delete(fullPath); }
|
||||||
|
catch { /* ignore */ }
|
||||||
|
|
||||||
|
_db.ProductImages.Remove(image);
|
||||||
|
|
||||||
|
// Если удалили основное — назначаем основным оставшуюся первую.
|
||||||
|
if (image.IsMain)
|
||||||
|
{
|
||||||
|
var next = await _db.ProductImages
|
||||||
|
.Where(i => i.ProductId == productId && i.Id != imageId)
|
||||||
|
.OrderBy(i => i.SortOrder)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (next is not null)
|
||||||
|
{
|
||||||
|
next.IsMain = true;
|
||||||
|
var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct);
|
||||||
|
if (product is not null) product.ImageUrl = next.Url;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct);
|
||||||
|
if (product is not null) product.ImageUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{imageId:guid}/main"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
|
public async Task<IActionResult> SetMain(Guid productId, Guid imageId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
|
||||||
|
if (image is null) return NotFound();
|
||||||
|
|
||||||
|
await _db.ProductImages.Where(i => i.ProductId == productId).ExecuteUpdateAsync(
|
||||||
|
s => s.SetProperty(i => i.IsMain, false), ct);
|
||||||
|
image.IsMain = true;
|
||||||
|
|
||||||
|
var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct);
|
||||||
|
if (product is not null) product.ImageUrl = image.Url;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using foodmarket.Application.Catalog;
|
using foodmarket.Application.Catalog;
|
||||||
using foodmarket.Application.Common;
|
using foodmarket.Application.Common;
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
using foodmarket.Domain.Catalog;
|
using foodmarket.Domain.Catalog;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
@ -14,23 +15,140 @@ namespace foodmarket.Api.Controllers.Catalog;
|
||||||
public class ProductsController : ControllerBase
|
public class ProductsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
|
||||||
public ProductsController(AppDbContext db) => _db = db;
|
public ProductsController(AppDbContext db, ITenantContext tenant)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_tenant = tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка пересечения штрихкодов с другими товарами организации.
|
||||||
|
// Возвращает первый конфликт «код → товар» либо null если всё чисто.
|
||||||
|
/// <summary>Проверяет что у каждого PriceType с IsRequired=true есть
|
||||||
|
/// соответствующая запись в input.Prices с Amount > 0. Возвращает имя
|
||||||
|
/// первого нарушенного типа либо null если всё ок.</summary>
|
||||||
|
private async Task<string?> FindMissingRequiredPriceAsync(
|
||||||
|
IReadOnlyList<ProductPriceInput>? prices, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var required = await _db.PriceTypes
|
||||||
|
.Where(pt => pt.IsRequired)
|
||||||
|
.Select(pt => new { pt.Id, pt.Name })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
if (required.Count == 0) return null;
|
||||||
|
foreach (var pt in required)
|
||||||
|
{
|
||||||
|
var price = prices?.FirstOrDefault(p => p.PriceTypeId == pt.Id);
|
||||||
|
if (price is null || price.Amount <= 0m) return pt.Name;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string Code, string ProductName)?> FindBarcodeConflictAsync(
|
||||||
|
IEnumerable<string> codes, Guid? excludeProductId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var codeSet = codes.Where(c => !string.IsNullOrWhiteSpace(c)).Distinct().ToList();
|
||||||
|
if (codeSet.Count == 0) return null;
|
||||||
|
var hit = await _db.ProductBarcodes
|
||||||
|
.Where(b => codeSet.Contains(b.Code) && (excludeProductId == null || b.ProductId != excludeProductId))
|
||||||
|
.Select(b => new { b.Code, ProductName = b.Product!.Name })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
return hit is null ? null : (hit.Code, hit.ProductName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Округление цен под настройку AllowFractionalPrices.
|
||||||
|
// Возвращает true если орг разрешает дробные цены.
|
||||||
|
private async Task<bool> AllowFractionalAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId;
|
||||||
|
if (orgId is null) return true;
|
||||||
|
return await _db.Organizations.Where(o => o.Id == orgId)
|
||||||
|
.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
|
}
|
||||||
|
private static decimal RoundIfNeeded(decimal value, bool allowFractional) =>
|
||||||
|
allowFractional ? value : Math.Round(value, 0, MidpointRounding.AwayFromZero);
|
||||||
|
private static decimal? RoundIfNeeded(decimal? value, bool allowFractional) =>
|
||||||
|
value is null ? null : RoundIfNeeded(value.Value, allowFractional);
|
||||||
|
|
||||||
|
// Следующий числовой артикул для организации. Находит max(Article::int)
|
||||||
|
// среди артикулов, которые полностью состоят из цифр, и прибавляет 1.
|
||||||
|
// Если числовых артикулов нет — возвращает "1".
|
||||||
|
private async Task<string> GenerateNextArticleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var articles = await _db.Products
|
||||||
|
.Where(p => p.Article != null && p.Article != "")
|
||||||
|
.Select(p => p.Article!)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var next = 1;
|
||||||
|
foreach (var a in articles)
|
||||||
|
{
|
||||||
|
if (int.TryParse(a, out var n) && n >= next) next = n + 1;
|
||||||
|
}
|
||||||
|
return next.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дефолт Vat для нового товара — из страны организации (Country.VatRate).
|
||||||
|
private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId;
|
||||||
|
if (orgId is null) return 0m;
|
||||||
|
var countryCode = await _db.Organizations
|
||||||
|
.Where(o => o.Id == orgId)
|
||||||
|
.Select(o => o.CountryCode)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (string.IsNullOrEmpty(countryCode)) return 0m;
|
||||||
|
var rate = await _db.Countries
|
||||||
|
.Where(c => c.Code == countryCode)
|
||||||
|
.Select(c => (decimal?)c.VatRate)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
return rate ?? 0m;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<PagedResult<ProductDto>>> List(
|
public async Task<ActionResult<PagedResult<ProductDto>>> List(
|
||||||
[FromQuery] PagedRequest req,
|
[FromQuery] PagedRequest req,
|
||||||
[FromQuery] Guid? groupId,
|
[FromQuery] Guid? groupId,
|
||||||
[FromQuery] bool? isService,
|
[FromQuery] bool? isService,
|
||||||
[FromQuery] bool? isWeighed,
|
[FromQuery] Packaging? packaging,
|
||||||
[FromQuery] bool? isActive,
|
[FromQuery] bool? isMarked,
|
||||||
|
[FromQuery] decimal? purchasePriceFrom,
|
||||||
|
[FromQuery] decimal? purchasePriceTo,
|
||||||
|
[FromQuery] decimal? referencePriceFrom,
|
||||||
|
[FromQuery] decimal? referencePriceTo,
|
||||||
|
[FromQuery] decimal? systemPriceFrom,
|
||||||
|
[FromQuery] decimal? systemPriceTo,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var q = QueryIncludes().AsNoTracking();
|
var q = QueryIncludes().AsNoTracking();
|
||||||
if (groupId is not null) q = q.Where(p => p.ProductGroupId == groupId);
|
if (groupId is not null)
|
||||||
|
{
|
||||||
|
// Include the whole subtree: match on the group's Path prefix so sub-groups also show up.
|
||||||
|
var root = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(g => g.Id == groupId, ct);
|
||||||
|
if (root is not null)
|
||||||
|
{
|
||||||
|
var prefix = root.Path;
|
||||||
|
q = q.Where(p => p.ProductGroup != null &&
|
||||||
|
(p.ProductGroup.Path == prefix || p.ProductGroup.Path.StartsWith(prefix + "/")));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
q = q.Where(p => p.ProductGroupId == groupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isService is not null) q = q.Where(p => p.IsService == isService);
|
if (isService is not null) q = q.Where(p => p.IsService == isService);
|
||||||
if (isWeighed is not null) q = q.Where(p => p.IsWeighed == isWeighed);
|
if (packaging is not null) q = q.Where(p => p.Packaging == packaging);
|
||||||
if (isActive is not null) q = q.Where(p => p.IsActive == isActive);
|
if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked);
|
||||||
|
// referencePriceFrom/To — новый, актуальный параметр; purchasePriceFrom/To
|
||||||
|
// — обратная совместимость c прежним UI (тоже по ReferencePrice).
|
||||||
|
var refFrom = referencePriceFrom ?? purchasePriceFrom;
|
||||||
|
var refTo = referencePriceTo ?? purchasePriceTo;
|
||||||
|
if (refFrom is not null) q = q.Where(p => p.ReferencePrice >= refFrom);
|
||||||
|
if (refTo is not null) q = q.Where(p => p.ReferencePrice <= refTo);
|
||||||
|
// Фильтр по системной (главной розничной) цене — берём Prices c PriceType.IsSystem=true.
|
||||||
|
if (systemPriceFrom is not null)
|
||||||
|
q = q.Where(p => p.Prices.Any(pr => pr.PriceType!.IsSystem && pr.Amount >= systemPriceFrom));
|
||||||
|
if (systemPriceTo is not null)
|
||||||
|
q = q.Where(p => p.Prices.Any(pr => pr.PriceType!.IsSystem && pr.Amount <= systemPriceTo));
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
{
|
{
|
||||||
|
|
@ -42,8 +160,28 @@ public class ProductsController : ControllerBase
|
||||||
}
|
}
|
||||||
|
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
|
q = (req.Sort, req.Desc) switch
|
||||||
|
{
|
||||||
|
("article", false) => q.OrderBy(p => p.Article).ThenBy(p => p.Name),
|
||||||
|
("article", true) => q.OrderByDescending(p => p.Article).ThenBy(p => p.Name),
|
||||||
|
("group", false) => q.OrderBy(p => p.ProductGroup!.Name).ThenBy(p => p.Name),
|
||||||
|
("group", true) => q.OrderByDescending(p => p.ProductGroup!.Name).ThenBy(p => p.Name),
|
||||||
|
("unit", false) => q.OrderBy(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name),
|
||||||
|
("unit", true) => q.OrderByDescending(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name),
|
||||||
|
("packaging", false) => q.OrderBy(p => p.Packaging).ThenBy(p => p.Name),
|
||||||
|
("packaging", true) => q.OrderByDescending(p => p.Packaging).ThenBy(p => p.Name),
|
||||||
|
("purchasePrice", false) => q.OrderBy(p => p.ReferencePrice).ThenBy(p => p.Name),
|
||||||
|
("purchasePrice", true) => q.OrderByDescending(p => p.ReferencePrice).ThenBy(p => p.Name),
|
||||||
|
("cost", false) => q.OrderBy(p => p.Cost).ThenBy(p => p.Name),
|
||||||
|
("cost", true) => q.OrderByDescending(p => p.Cost).ThenBy(p => p.Name),
|
||||||
|
("systemPrice", false) => q.OrderBy(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name),
|
||||||
|
("systemPrice", true) => q.OrderByDescending(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name),
|
||||||
|
("vat", false) => q.OrderBy(p => p.Vat).ThenBy(p => p.Name),
|
||||||
|
("vat", true) => q.OrderByDescending(p => p.Vat).ThenBy(p => p.Name),
|
||||||
|
("name", true) => q.OrderByDescending(p => p.Name),
|
||||||
|
_ => q.OrderBy(p => p.Name),
|
||||||
|
};
|
||||||
var items = await q
|
var items = await q
|
||||||
.OrderBy(p => p.Name)
|
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(Projection)
|
.Select(Projection)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
@ -60,16 +198,36 @@ public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
|
||||||
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
|
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
|
public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (input.Barcodes is null || input.Barcodes.Count == 0)
|
||||||
|
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
|
||||||
|
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
|
||||||
|
return BadRequest(new { error = $"Цена «{missing}» обязательна и должна быть больше 0." });
|
||||||
|
var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), null, ct);
|
||||||
|
if (conflict is { } c)
|
||||||
|
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
|
||||||
|
var allowFractional = await AllowFractionalAsync(ct);
|
||||||
var e = new Product();
|
var e = new Product();
|
||||||
Apply(e, input);
|
Apply(e, input);
|
||||||
|
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
|
||||||
|
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
|
||||||
|
if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct);
|
||||||
|
// Авто-артикул: если пользователь не указал — генерируем числовой.
|
||||||
|
if (string.IsNullOrWhiteSpace(e.Article)) e.Article = await GenerateNextArticleAsync(ct);
|
||||||
|
|
||||||
foreach (var b in input.Barcodes ?? [])
|
foreach (var b in input.Barcodes ?? [])
|
||||||
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
|
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
|
||||||
foreach (var pr in input.Prices ?? [])
|
foreach (var pr in input.Prices ?? [])
|
||||||
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId });
|
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = RoundIfNeeded(pr.Amount, allowFractional), CurrencyId = pr.CurrencyId });
|
||||||
|
|
||||||
_db.Products.Add(e);
|
_db.Products.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
try
|
||||||
|
{
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_products_OrganizationId_Article") == true)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = $"Артикул «{e.Article}» уже занят в этой организации." });
|
||||||
|
}
|
||||||
var dto = await GetInternalAsync(e.Id, ct);
|
var dto = await GetInternalAsync(e.Id, ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id }, dto);
|
return CreatedAtAction(nameof(Get), new { id = e.Id }, dto);
|
||||||
}
|
}
|
||||||
|
|
@ -77,28 +235,132 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (input.Barcodes is null || input.Barcodes.Count == 0)
|
||||||
|
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
|
||||||
|
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
|
||||||
|
return BadRequest(new { error = $"Цена «{missing}» обязательна и должна быть больше 0." });
|
||||||
|
var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), id, ct);
|
||||||
|
if (conflict is { } c)
|
||||||
|
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
|
||||||
var e = await _db.Products
|
var e = await _db.Products
|
||||||
.Include(p => p.Barcodes)
|
.Include(p => p.Barcodes)
|
||||||
.Include(p => p.Prices)
|
.Include(p => p.Prices)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, ct);
|
.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (e is null) return NotFound();
|
if (e is null) return NotFound();
|
||||||
|
|
||||||
|
var allowFractional = await AllowFractionalAsync(ct);
|
||||||
Apply(e, input);
|
Apply(e, input);
|
||||||
|
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
|
||||||
|
|
||||||
_db.ProductBarcodes.RemoveRange(e.Barcodes);
|
// Merge barcodes по Code: одинаковые — обновляем поля, лишние удаляем,
|
||||||
e.Barcodes.Clear();
|
// новые добавляем. Это позволяет избежать массового DELETE+INSERT, на
|
||||||
foreach (var b in input.Barcodes ?? [])
|
// котором EF может выдать DbUpdateConcurrencyException, если какой-то
|
||||||
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
|
// child был удалён параллельно из БД.
|
||||||
|
var inputBarcodes = (input.Barcodes ?? []).ToList();
|
||||||
|
var byCode = e.Barcodes.ToDictionary(b => b.Code, b => b);
|
||||||
|
var inputCodes = inputBarcodes.Select(b => b.Code).ToHashSet();
|
||||||
|
foreach (var existing in e.Barcodes.ToList())
|
||||||
|
if (!inputCodes.Contains(existing.Code)) _db.ProductBarcodes.Remove(existing);
|
||||||
|
foreach (var b in inputBarcodes)
|
||||||
|
{
|
||||||
|
if (byCode.TryGetValue(b.Code, out var ex))
|
||||||
|
{
|
||||||
|
ex.Type = b.Type;
|
||||||
|
ex.IsPrimary = b.IsPrimary;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_db.ProductPrices.RemoveRange(e.Prices);
|
// Merge prices по PriceTypeId.
|
||||||
e.Prices.Clear();
|
var inputPrices = (input.Prices ?? []).ToList();
|
||||||
foreach (var pr in input.Prices ?? [])
|
var byPriceType = e.Prices.ToDictionary(p => p.PriceTypeId, p => p);
|
||||||
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId });
|
var inputPriceTypes = inputPrices.Select(p => p.PriceTypeId).ToHashSet();
|
||||||
|
foreach (var existing in e.Prices.ToList())
|
||||||
|
if (!inputPriceTypes.Contains(existing.PriceTypeId)) _db.ProductPrices.Remove(existing);
|
||||||
|
foreach (var pr in inputPrices)
|
||||||
|
{
|
||||||
|
var amount = RoundIfNeeded(pr.Amount, allowFractional);
|
||||||
|
if (byPriceType.TryGetValue(pr.PriceTypeId, out var ex))
|
||||||
|
{
|
||||||
|
ex.Amount = amount;
|
||||||
|
ex.CurrencyId = pr.CurrencyId;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = amount, CurrencyId = pr.CurrencyId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync(ct);
|
try
|
||||||
|
{
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_products_OrganizationId_Article") == true)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = $"Артикул «{e.Article}» уже занят в этой организации." });
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
return Conflict(new { error = "Товар изменён в другом окне или сессии. Перезагрузите страницу и попробуйте снова." });
|
||||||
|
}
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>«Привести розничную к себестоимости»: ставит дефолтную
|
||||||
|
/// розничную цену = ceil(Cost * (1 + Group.MarkupPercent/100)). Если у
|
||||||
|
/// группы товара не задан MarkupPercent — 400 с подсказкой.</summary>
|
||||||
|
[HttpPost("{id:guid}/recalc-retail"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
|
public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await _db.Products
|
||||||
|
.Include(x => x.ProductGroup)
|
||||||
|
.Include(x => x.Prices)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (p is null) return NotFound();
|
||||||
|
if (p.ProductGroup?.MarkupPercent is not decimal pct)
|
||||||
|
return BadRequest(new { error = "У группы не задана наценка. Задайте её в настройках или введите цену вручную." });
|
||||||
|
|
||||||
|
var allowFractional = await AllowFractionalAsync(ct);
|
||||||
|
var raw = p.Cost * (1m + pct / 100m);
|
||||||
|
var newRetail = allowFractional
|
||||||
|
? Math.Ceiling(raw * 100m) / 100m
|
||||||
|
: Math.Ceiling(raw);
|
||||||
|
|
||||||
|
var defaultType = await _db.PriceTypes
|
||||||
|
.OrderByDescending(pt => pt.IsSystem)
|
||||||
|
.ThenByDescending(pt => pt.IsRetail)
|
||||||
|
.ThenBy(pt => pt.SortOrder)
|
||||||
|
.ThenBy(pt => pt.Name)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (defaultType is null)
|
||||||
|
return BadRequest(new { error = "Нет ни одного типа цен. Создайте его в настройках." });
|
||||||
|
|
||||||
|
var fallbackCurrency = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct)
|
||||||
|
?? await _db.Currencies.OrderBy(c => c.Code).Select(c => (Guid?)c.Id).FirstOrDefaultAsync(ct);
|
||||||
|
if (fallbackCurrency is null)
|
||||||
|
return BadRequest(new { error = "Не задана валюта по умолчанию." });
|
||||||
|
|
||||||
|
var existing = p.Prices.FirstOrDefault(x => x.PriceTypeId == defaultType.Id);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
p.Prices.Add(new ProductPrice
|
||||||
|
{
|
||||||
|
PriceTypeId = defaultType.Id,
|
||||||
|
Amount = newRetail,
|
||||||
|
CurrencyId = fallbackCurrency.Value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.Amount = newRetail;
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return Ok(new { retail = newRetail });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
@ -109,9 +371,124 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record BarcodeDuplicate(string Code, IReadOnlyList<DuplicateProductRef> Products);
|
||||||
|
public record DuplicateProductRef(Guid ProductId, string ProductName, string? Article);
|
||||||
|
|
||||||
|
/// <summary>Находит штрихкоды, привязанные к более чем одному товару в текущей
|
||||||
|
/// организации. Уникальный индекс это запрещает в новых записях, но реальная
|
||||||
|
/// БД может содержать исторические дубли (например, после ручной правки).
|
||||||
|
/// Используется UI чистки (/admin/cleanup) и отчётом MoySklad-импорта.</summary>
|
||||||
|
[HttpGet("barcode-duplicates"), Authorize(Roles = "Admin,Manager")]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicates(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var rows = await _db.ProductBarcodes
|
||||||
|
.GroupBy(b => b.Code)
|
||||||
|
.Where(g => g.Count() > 1)
|
||||||
|
.Select(g => new { Code = g.Key, Items = g.Select(x => new { x.ProductId, ProductName = x.Product!.Name, x.Product.Article }).ToList() })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var result = rows
|
||||||
|
.Select(r => new BarcodeDuplicate(r.Code,
|
||||||
|
r.Items.Select(i => new DuplicateProductRef(i.ProductId, i.ProductName, i.Article)).ToList()))
|
||||||
|
.ToList();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record QuickSearchItem(
|
||||||
|
Guid Id, string Name, string? Article, string? DefaultBarcode,
|
||||||
|
decimal? ReferencePrice, decimal? StockQty);
|
||||||
|
|
||||||
|
/// <summary>Лёгкий поиск для inline-добавления строк в документы (приёмка,
|
||||||
|
/// продажа). Ранжирует точное совпадение штрихкода → точное артикула →
|
||||||
|
/// префикс артикула → префикс имени → имя contains. Возвращает остаток
|
||||||
|
/// по storeId если передан, иначе сумму по всем складам организации.</summary>
|
||||||
|
[HttpGet("quick-search")]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<QuickSearchItem>>> QuickSearch(
|
||||||
|
[FromQuery] string? search,
|
||||||
|
[FromQuery] Guid? storeId,
|
||||||
|
[FromQuery] int limit = 20,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var s = (search ?? "").Trim();
|
||||||
|
if (s.Length == 0) return Array.Empty<QuickSearchItem>();
|
||||||
|
var sLower = s.ToLower();
|
||||||
|
if (limit <= 0 || limit > 50) limit = 20;
|
||||||
|
|
||||||
|
var q = _db.Products.AsNoTracking().Where(p =>
|
||||||
|
p.Name.ToLower().Contains(sLower) ||
|
||||||
|
(p.Article != null && p.Article.ToLower().Contains(sLower)) ||
|
||||||
|
p.Barcodes.Any(b => b.Code.Contains(s)));
|
||||||
|
|
||||||
|
// Ранжирование выводим в память: SQL'ом аккуратно сортировать по
|
||||||
|
// нескольким булевым приоритетам сложно, а лимит 20–50 строк
|
||||||
|
// делает накладные расходы пренебрежимыми.
|
||||||
|
var raw = await q.Select(p => new
|
||||||
|
{
|
||||||
|
p.Id, p.Name, p.Article, p.ReferencePrice,
|
||||||
|
Barcodes = p.Barcodes.Select(b => new { b.Code, b.IsPrimary }).ToList(),
|
||||||
|
StockQty = storeId == null
|
||||||
|
? (decimal?)_db.Stocks.Where(st => st.ProductId == p.Id).Sum(st => (decimal?)st.Quantity)
|
||||||
|
: _db.Stocks.Where(st => st.ProductId == p.Id && st.StoreId == storeId)
|
||||||
|
.Select(st => (decimal?)st.Quantity).FirstOrDefault(),
|
||||||
|
}).Take(limit * 4).ToListAsync(ct);
|
||||||
|
|
||||||
|
int Rank(string name, string? article, IEnumerable<string> codes)
|
||||||
|
{
|
||||||
|
if (codes.Any(c => c.Equals(s, StringComparison.OrdinalIgnoreCase))) return 0;
|
||||||
|
if (article != null && article.Equals(s, StringComparison.OrdinalIgnoreCase)) return 1;
|
||||||
|
if (article != null && article.StartsWith(s, StringComparison.OrdinalIgnoreCase)) return 2;
|
||||||
|
if (name.StartsWith(s, StringComparison.OrdinalIgnoreCase)) return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = raw
|
||||||
|
.Select(r => new
|
||||||
|
{
|
||||||
|
Item = new QuickSearchItem(
|
||||||
|
r.Id, r.Name, r.Article,
|
||||||
|
r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault(),
|
||||||
|
r.ReferencePrice, r.StockQty),
|
||||||
|
Rank = Rank(r.Name, r.Article, r.Barcodes.Select(b => b.Code)),
|
||||||
|
})
|
||||||
|
.OrderBy(x => x.Rank).ThenBy(x => x.Item.Name)
|
||||||
|
.Take(limit)
|
||||||
|
.Select(x => x.Item)
|
||||||
|
.ToList();
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> Items);
|
||||||
|
|
||||||
|
/// <summary>Точный поиск по штрихкоду (для сканера). 0 → 404, 1 → объект,
|
||||||
|
/// несколько → { items: [...] } чтобы UI показал диалог выбора.</summary>
|
||||||
|
[HttpGet("by-barcode/{value}")]
|
||||||
|
public async Task<ActionResult<object>> ByBarcode(
|
||||||
|
string value, [FromQuery] Guid? storeId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var v = (value ?? "").Trim();
|
||||||
|
if (v.Length == 0) return NotFound();
|
||||||
|
var matches = await _db.Products.AsNoTracking()
|
||||||
|
.Where(p => p.Barcodes.Any(b => b.Code == v))
|
||||||
|
.Select(p => new
|
||||||
|
{
|
||||||
|
p.Id, p.Name, p.Article, p.ReferencePrice,
|
||||||
|
Barcodes = p.Barcodes.Select(b => new { b.Code, b.IsPrimary }).ToList(),
|
||||||
|
StockQty = storeId == null
|
||||||
|
? (decimal?)_db.Stocks.Where(st => st.ProductId == p.Id).Sum(st => (decimal?)st.Quantity)
|
||||||
|
: _db.Stocks.Where(st => st.ProductId == p.Id && st.StoreId == storeId)
|
||||||
|
.Select(st => (decimal?)st.Quantity).FirstOrDefault(),
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
if (matches.Count == 0) return NotFound();
|
||||||
|
var items = matches.Select(r => new QuickSearchItem(
|
||||||
|
r.Id, r.Name, r.Article,
|
||||||
|
r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? v,
|
||||||
|
r.ReferencePrice, r.StockQty)).ToList();
|
||||||
|
if (items.Count == 1) return items[0];
|
||||||
|
return new ByBarcodeResult(items);
|
||||||
|
}
|
||||||
|
|
||||||
private IQueryable<Product> QueryIncludes() => _db.Products
|
private IQueryable<Product> QueryIncludes() => _db.Products
|
||||||
.Include(p => p.UnitOfMeasure)
|
.Include(p => p.UnitOfMeasure)
|
||||||
.Include(p => p.VatRate)
|
|
||||||
.Include(p => p.ProductGroup)
|
.Include(p => p.ProductGroup)
|
||||||
.Include(p => p.DefaultSupplier)
|
.Include(p => p.DefaultSupplier)
|
||||||
.Include(p => p.CountryOfOrigin)
|
.Include(p => p.CountryOfOrigin)
|
||||||
|
|
@ -126,15 +503,17 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
private static readonly System.Linq.Expressions.Expression<Func<Product, ProductDto>> Projection = p =>
|
private static readonly System.Linq.Expressions.Expression<Func<Product, ProductDto>> Projection = p =>
|
||||||
new ProductDto(
|
new ProductDto(
|
||||||
p.Id, p.Name, p.Article, p.Description,
|
p.Id, p.Name, p.Article, p.Description,
|
||||||
p.UnitOfMeasureId, p.UnitOfMeasure!.Symbol,
|
p.UnitOfMeasureId, p.UnitOfMeasure!.Name,
|
||||||
p.VatRateId, p.VatRate!.Percent,
|
p.Vat, p.VatEnabled,
|
||||||
p.ProductGroupId, p.ProductGroup != null ? p.ProductGroup.Name : null,
|
p.ProductGroupId, p.ProductGroup!.Name,
|
||||||
p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null,
|
p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null,
|
||||||
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
|
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
|
||||||
p.IsService, p.IsWeighed, p.IsAlcohol, p.IsMarked,
|
p.IsService, p.Packaging, p.IsMarked,
|
||||||
p.MinStock, p.MaxStock,
|
p.MinStock, p.MaxStock,
|
||||||
p.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
|
p.ReferencePrice, p.ReferencePriceUpdatedAt,
|
||||||
p.ImageUrl, p.IsActive,
|
p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
|
||||||
|
p.Cost, p.LastSupplyAt,
|
||||||
|
p.ImageUrl,
|
||||||
p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(),
|
p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(),
|
||||||
p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList());
|
p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList());
|
||||||
|
|
||||||
|
|
@ -144,19 +523,25 @@ private static void Apply(Product e, ProductInput i)
|
||||||
e.Article = i.Article;
|
e.Article = i.Article;
|
||||||
e.Description = i.Description;
|
e.Description = i.Description;
|
||||||
e.UnitOfMeasureId = i.UnitOfMeasureId;
|
e.UnitOfMeasureId = i.UnitOfMeasureId;
|
||||||
e.VatRateId = i.VatRateId;
|
if (i.Vat is decimal v) e.Vat = v;
|
||||||
|
e.VatEnabled = i.VatEnabled;
|
||||||
e.ProductGroupId = i.ProductGroupId;
|
e.ProductGroupId = i.ProductGroupId;
|
||||||
e.DefaultSupplierId = i.DefaultSupplierId;
|
e.DefaultSupplierId = i.DefaultSupplierId;
|
||||||
e.CountryOfOriginId = i.CountryOfOriginId;
|
e.CountryOfOriginId = i.CountryOfOriginId;
|
||||||
e.IsService = i.IsService;
|
e.IsService = i.IsService;
|
||||||
e.IsWeighed = i.IsWeighed;
|
e.Packaging = i.Packaging;
|
||||||
e.IsAlcohol = i.IsAlcohol;
|
|
||||||
e.IsMarked = i.IsMarked;
|
e.IsMarked = i.IsMarked;
|
||||||
e.MinStock = i.MinStock;
|
e.MinStock = i.MinStock;
|
||||||
e.MaxStock = i.MaxStock;
|
e.MaxStock = i.MaxStock;
|
||||||
e.PurchasePrice = i.PurchasePrice;
|
// ReferencePriceUpdatedAt подбиваем только при реальной смене цены
|
||||||
|
// (включая переход с null на значение и обратно). Этим помечаем,
|
||||||
|
// что цена «свежая», 30-дневный таймер автоперезаписи отсчитывается заново.
|
||||||
|
if (e.ReferencePrice != i.ReferencePrice)
|
||||||
|
{
|
||||||
|
e.ReferencePrice = i.ReferencePrice;
|
||||||
|
e.ReferencePriceUpdatedAt = i.ReferencePrice.HasValue ? DateTime.UtcNow : null;
|
||||||
|
}
|
||||||
e.PurchaseCurrencyId = i.PurchaseCurrencyId;
|
e.PurchaseCurrencyId = i.PurchaseCurrencyId;
|
||||||
e.ImageUrl = i.ImageUrl;
|
e.ImageUrl = i.ImageUrl;
|
||||||
e.IsActive = i.IsActive;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,20 @@ public async Task<ActionResult<PagedResult<RetailPointDto>>> List([FromQuery] Pa
|
||||||
q = q.Where(r => r.Name.ToLower().Contains(s) || (r.Code != null && r.Code.ToLower().Contains(s)));
|
q = q.Where(r => r.Name.ToLower().Contains(s) || (r.Code != null && r.Code.ToLower().Contains(s)));
|
||||||
}
|
}
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
|
q = (req.Sort, req.Desc) switch
|
||||||
|
{
|
||||||
|
("code", false) => q.OrderBy(r => r.Code).ThenBy(r => r.Name),
|
||||||
|
("code", true) => q.OrderByDescending(r => r.Code).ThenBy(r => r.Name),
|
||||||
|
("store", false) => q.OrderBy(r => r.Store!.Name).ThenBy(r => r.Name),
|
||||||
|
("store", true) => q.OrderByDescending(r => r.Store!.Name).ThenBy(r => r.Name),
|
||||||
|
("address", false) => q.OrderBy(r => r.Address).ThenBy(r => r.Name),
|
||||||
|
("address", true) => q.OrderByDescending(r => r.Address).ThenBy(r => r.Name),
|
||||||
|
("isActive", false) => q.OrderBy(r => r.IsActive).ThenBy(r => r.Name),
|
||||||
|
("isActive", true) => q.OrderByDescending(r => r.IsActive).ThenBy(r => r.Name),
|
||||||
|
("name", true) => q.OrderByDescending(r => r.Name),
|
||||||
|
_ => q.OrderBy(r => r.Name),
|
||||||
|
};
|
||||||
var items = await q
|
var items = await q
|
||||||
.OrderBy(r => r.Name)
|
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(r => new RetailPointDto(r.Id, r.Name, r.Code, r.StoreId, r.Store!.Name,
|
.Select(r => new RetailPointDto(r.Id, r.Name, r.Code, r.StoreId, r.Store!.Name,
|
||||||
r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive))
|
r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive))
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,23 @@ public async Task<ActionResult<PagedResult<StoreDto>>> List([FromQuery] PagedReq
|
||||||
q = q.Where(x => x.Name.ToLower().Contains(s) || (x.Code != null && x.Code.ToLower().Contains(s)));
|
q = q.Where(x => x.Name.ToLower().Contains(s) || (x.Code != null && x.Code.ToLower().Contains(s)));
|
||||||
}
|
}
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
|
q = (req.Sort, req.Desc) switch
|
||||||
|
{
|
||||||
|
("name", false) => q.OrderBy(x => x.Name),
|
||||||
|
("name", true) => q.OrderByDescending(x => x.Name),
|
||||||
|
("code", false) => q.OrderBy(x => x.Code).ThenBy(x => x.Name),
|
||||||
|
("code", true) => q.OrderByDescending(x => x.Code).ThenBy(x => x.Name),
|
||||||
|
("address", false) => q.OrderBy(x => x.Address).ThenBy(x => x.Name),
|
||||||
|
("address", true) => q.OrderByDescending(x => x.Address).ThenBy(x => x.Name),
|
||||||
|
("isMain", false) => q.OrderBy(x => x.IsMain).ThenBy(x => x.Name),
|
||||||
|
("isMain", true) => q.OrderByDescending(x => x.IsMain).ThenBy(x => x.Name),
|
||||||
|
("isActive", false) => q.OrderBy(x => x.IsActive).ThenBy(x => x.Name),
|
||||||
|
("isActive", true) => q.OrderByDescending(x => x.IsActive).ThenBy(x => x.Name),
|
||||||
|
_ => q.OrderByDescending(x => x.IsMain).ThenBy(x => x.Name),
|
||||||
|
};
|
||||||
var items = await q
|
var items = await q
|
||||||
.OrderByDescending(x => x.IsMain).ThenBy(x => x.Name)
|
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(x => new StoreDto(x.Id, x.Name, x.Code, x.Kind, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive))
|
.Select(x => new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<StoreDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<StoreDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -39,7 +52,7 @@ public async Task<ActionResult<PagedResult<StoreDto>>> List([FromQuery] PagedReq
|
||||||
public async Task<ActionResult<StoreDto>> Get(Guid id, CancellationToken ct)
|
public async Task<ActionResult<StoreDto>> Get(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var x = await _db.Stores.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, ct);
|
var x = await _db.Stores.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Kind, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
|
return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||||
|
|
@ -51,13 +64,13 @@ public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, Ca
|
||||||
}
|
}
|
||||||
var e = new Store
|
var e = new Store
|
||||||
{
|
{
|
||||||
Name = input.Name, Code = input.Code, Kind = input.Kind, Address = input.Address,
|
Name = input.Name, Code = input.Code,Address = input.Address,
|
||||||
Phone = input.Phone, ManagerName = input.ManagerName, IsMain = input.IsMain, IsActive = input.IsActive,
|
Phone = input.Phone, ManagerName = input.ManagerName, IsMain = input.IsMain, IsActive = input.IsActive,
|
||||||
};
|
};
|
||||||
_db.Stores.Add(e);
|
_db.Stores.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||||
new StoreDto(e.Id, e.Name, e.Code, e.Kind, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
|
new StoreDto(e.Id, e.Name, e.Code, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||||
|
|
@ -71,7 +84,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] StoreInput input, Ca
|
||||||
}
|
}
|
||||||
e.Name = input.Name;
|
e.Name = input.Name;
|
||||||
e.Code = input.Code;
|
e.Code = input.Code;
|
||||||
e.Kind = input.Kind;
|
|
||||||
e.Address = input.Address;
|
e.Address = input.Address;
|
||||||
e.Phone = input.Phone;
|
e.Phone = input.Phone;
|
||||||
e.ManagerName = input.ManagerName;
|
e.ManagerName = input.ManagerName;
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,19 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
|
||||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
{
|
{
|
||||||
var s = req.Search.Trim().ToLower();
|
var s = req.Search.Trim().ToLower();
|
||||||
q = q.Where(u => u.Name.ToLower().Contains(s) || u.Symbol.ToLower().Contains(s) || u.Code.ToLower().Contains(s));
|
q = q.Where(u => u.Name.ToLower().Contains(s) || u.Code.ToLower().Contains(s));
|
||||||
}
|
}
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
|
q = (req.Sort, req.Desc) switch
|
||||||
|
{
|
||||||
|
("code", false) => q.OrderBy(u => u.Code),
|
||||||
|
("code", true) => q.OrderByDescending(u => u.Code),
|
||||||
|
("name", true) => q.OrderByDescending(u => u.Name),
|
||||||
|
_ => q.OrderBy(u => u.Name),
|
||||||
|
};
|
||||||
var items = await q
|
var items = await q
|
||||||
.OrderByDescending(u => u.IsBase).ThenBy(u => u.Name)
|
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Symbol, u.Name, u.DecimalPlaces, u.IsBase, u.IsActive))
|
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -39,58 +45,44 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
|
||||||
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
|
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Symbol, u.Name, u.DecimalPlaces, u.IsBase, u.IsActive);
|
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||||
public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct)
|
public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (input.IsBase)
|
|
||||||
{
|
|
||||||
await _db.UnitsOfMeasure.Where(u => u.IsBase).ExecuteUpdateAsync(s => s.SetProperty(u => u.IsBase, false), ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
var e = new UnitOfMeasure
|
var e = new UnitOfMeasure
|
||||||
{
|
{
|
||||||
Code = input.Code,
|
Code = input.Code,
|
||||||
Symbol = input.Symbol,
|
|
||||||
Name = input.Name,
|
Name = input.Name,
|
||||||
DecimalPlaces = input.DecimalPlaces,
|
Description = input.Description,
|
||||||
IsBase = input.IsBase,
|
|
||||||
IsActive = input.IsActive,
|
|
||||||
};
|
};
|
||||||
_db.UnitsOfMeasure.Add(e);
|
_db.UnitsOfMeasure.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||||
new UnitOfMeasureDto(e.Id, e.Code, e.Symbol, e.Name, e.DecimalPlaces, e.IsBase, e.IsActive));
|
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.OrganizationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,SuperAdmin")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput input, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (e is null) return NotFound();
|
if (e is null) return NotFound();
|
||||||
|
if (e.OrganizationId is null && !User.IsInRole("SuperAdmin")) return Forbid();
|
||||||
if (input.IsBase && !e.IsBase)
|
|
||||||
{
|
|
||||||
await _db.UnitsOfMeasure.Where(u => u.IsBase && u.Id != id).ExecuteUpdateAsync(s => s.SetProperty(u => u.IsBase, false), ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
e.Code = input.Code;
|
e.Code = input.Code;
|
||||||
e.Symbol = input.Symbol;
|
|
||||||
e.Name = input.Name;
|
e.Name = input.Name;
|
||||||
e.DecimalPlaces = input.DecimalPlaces;
|
e.Description = input.Description;
|
||||||
e.IsBase = input.IsBase;
|
|
||||||
e.IsActive = input.IsActive;
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (e is null) return NotFound();
|
if (e is null) return NotFound();
|
||||||
|
if (e.OrganizationId is null && !User.IsInRole("SuperAdmin")) return Forbid();
|
||||||
_db.UnitsOfMeasure.Remove(e);
|
_db.UnitsOfMeasure.Remove(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
using foodmarket.Application.Catalog;
|
|
||||||
using foodmarket.Application.Common;
|
|
||||||
using foodmarket.Domain.Catalog;
|
|
||||||
using foodmarket.Infrastructure.Persistence;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace foodmarket.Api.Controllers.Catalog;
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Authorize]
|
|
||||||
[Route("api/catalog/vat-rates")]
|
|
||||||
public class VatRatesController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _db;
|
|
||||||
|
|
||||||
public VatRatesController(AppDbContext db) => _db = db;
|
|
||||||
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<ActionResult<PagedResult<VatRateDto>>> List([FromQuery] PagedRequest req, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var q = _db.VatRates.AsNoTracking().AsQueryable();
|
|
||||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
|
||||||
{
|
|
||||||
var s = req.Search.Trim().ToLower();
|
|
||||||
q = q.Where(v => v.Name.ToLower().Contains(s));
|
|
||||||
}
|
|
||||||
var total = await q.CountAsync(ct);
|
|
||||||
var items = await q
|
|
||||||
.OrderByDescending(v => v.IsDefault).ThenBy(v => v.Percent)
|
|
||||||
.Skip(req.Skip).Take(req.Take)
|
|
||||||
.Select(v => new VatRateDto(v.Id, v.Name, v.Percent, v.IsIncludedInPrice, v.IsDefault, v.IsActive))
|
|
||||||
.ToListAsync(ct);
|
|
||||||
return new PagedResult<VatRateDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
|
||||||
public async Task<ActionResult<VatRateDto>> Get(Guid id, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var v = await _db.VatRates.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
|
||||||
return v is null ? NotFound() : new VatRateDto(v.Id, v.Name, v.Percent, v.IsIncludedInPrice, v.IsDefault, v.IsActive);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
|
||||||
public async Task<ActionResult<VatRateDto>> Create([FromBody] VatRateInput input, CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (input.IsDefault)
|
|
||||||
{
|
|
||||||
await ResetDefaultsAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
var e = new VatRate
|
|
||||||
{
|
|
||||||
Name = input.Name,
|
|
||||||
Percent = input.Percent,
|
|
||||||
IsIncludedInPrice = input.IsIncludedInPrice,
|
|
||||||
IsDefault = input.IsDefault,
|
|
||||||
IsActive = input.IsActive,
|
|
||||||
};
|
|
||||||
_db.VatRates.Add(e);
|
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
|
||||||
new VatRateDto(e.Id, e.Name, e.Percent, e.IsIncludedInPrice, e.IsDefault, e.IsActive));
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] VatRateInput input, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var e = await _db.VatRates.FirstOrDefaultAsync(x => x.Id == id, ct);
|
|
||||||
if (e is null) return NotFound();
|
|
||||||
|
|
||||||
if (input.IsDefault && !e.IsDefault)
|
|
||||||
{
|
|
||||||
await ResetDefaultsAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
e.Name = input.Name;
|
|
||||||
e.Percent = input.Percent;
|
|
||||||
e.IsIncludedInPrice = input.IsIncludedInPrice;
|
|
||||||
e.IsDefault = input.IsDefault;
|
|
||||||
e.IsActive = input.IsActive;
|
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var e = await _db.VatRates.FirstOrDefaultAsync(x => x.Id == id, ct);
|
|
||||||
if (e is null) return NotFound();
|
|
||||||
_db.VatRates.Remove(e);
|
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ResetDefaultsAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
await _db.VatRates.Where(v => v.IsDefault).ExecuteUpdateAsync(s => s.SetProperty(v => v.IsDefault, false), ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
141
src/food-market.api/Controllers/Inventory/StockController.cs
Normal file
141
src/food-market.api/Controllers/Inventory/StockController.cs
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
using foodmarket.Application.Common;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Inventory;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/inventory")]
|
||||||
|
public class StockController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public StockController(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public record StockRow(
|
||||||
|
Guid ProductId, string ProductName, string? Article, string UnitSymbol,
|
||||||
|
Guid StoreId, string StoreName,
|
||||||
|
decimal Quantity, decimal ReservedQuantity, decimal Available);
|
||||||
|
|
||||||
|
[HttpGet("stock")]
|
||||||
|
public async Task<ActionResult<PagedResult<StockRow>>> GetStock(
|
||||||
|
[FromQuery] Guid? storeId,
|
||||||
|
[FromQuery] Guid? productId,
|
||||||
|
[FromQuery] string? search,
|
||||||
|
[FromQuery] bool includeZero = false,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 50,
|
||||||
|
[FromQuery] string? sort = null,
|
||||||
|
[FromQuery] string? order = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = from s in _db.Stocks
|
||||||
|
join p in _db.Products on s.ProductId equals p.Id
|
||||||
|
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
|
||||||
|
join st in _db.Stores on s.StoreId equals st.Id
|
||||||
|
select new { s, p, u, st };
|
||||||
|
|
||||||
|
if (storeId.HasValue) q = q.Where(x => x.s.StoreId == storeId.Value);
|
||||||
|
if (productId.HasValue) q = q.Where(x => x.s.ProductId == productId.Value);
|
||||||
|
if (!includeZero) q = q.Where(x => x.s.Quantity != 0);
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
var term = $"%{search.Trim()}%";
|
||||||
|
q = q.Where(x => EF.Functions.ILike(x.p.Name, term)
|
||||||
|
|| (x.p.Article != null && EF.Functions.ILike(x.p.Article, term)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var desc = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase);
|
||||||
|
q = (sort, desc) switch
|
||||||
|
{
|
||||||
|
("article", false) => q.OrderBy(x => x.p.Article).ThenBy(x => x.p.Name),
|
||||||
|
("article", true) => q.OrderByDescending(x => x.p.Article).ThenBy(x => x.p.Name),
|
||||||
|
("unit", false) => q.OrderBy(x => x.u.Name).ThenBy(x => x.p.Name),
|
||||||
|
("unit", true) => q.OrderByDescending(x => x.u.Name).ThenBy(x => x.p.Name),
|
||||||
|
("store", false) => q.OrderBy(x => x.st.Name).ThenBy(x => x.p.Name),
|
||||||
|
("store", true) => q.OrderByDescending(x => x.st.Name).ThenBy(x => x.p.Name),
|
||||||
|
("quantity", false) => q.OrderBy(x => x.s.Quantity).ThenBy(x => x.p.Name),
|
||||||
|
("quantity", true) => q.OrderByDescending(x => x.s.Quantity).ThenBy(x => x.p.Name),
|
||||||
|
("reserved", false) => q.OrderBy(x => x.s.ReservedQuantity).ThenBy(x => x.p.Name),
|
||||||
|
("reserved", true) => q.OrderByDescending(x => x.s.ReservedQuantity).ThenBy(x => x.p.Name),
|
||||||
|
("available", false) => q.OrderBy(x => x.s.Quantity - x.s.ReservedQuantity).ThenBy(x => x.p.Name),
|
||||||
|
("available", true) => q.OrderByDescending(x => x.s.Quantity - x.s.ReservedQuantity).ThenBy(x => x.p.Name),
|
||||||
|
("name", true) => q.OrderByDescending(x => x.p.Name),
|
||||||
|
_ => q.OrderBy(x => x.p.Name),
|
||||||
|
};
|
||||||
|
var items = await q
|
||||||
|
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||||
|
.Select(x => new StockRow(
|
||||||
|
x.p.Id, x.p.Name, x.p.Article, x.u.Name,
|
||||||
|
x.st.Id, x.st.Name,
|
||||||
|
x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new PagedResult<StockRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MovementRow(
|
||||||
|
Guid Id, DateTime OccurredAt,
|
||||||
|
Guid ProductId, string ProductName, string? Article,
|
||||||
|
Guid StoreId, string StoreName,
|
||||||
|
decimal Quantity, decimal? UnitCost,
|
||||||
|
string Type, string DocumentType, Guid? DocumentId, string? DocumentNumber,
|
||||||
|
string? Notes);
|
||||||
|
|
||||||
|
[HttpGet("movements")]
|
||||||
|
public async Task<ActionResult<PagedResult<MovementRow>>> GetMovements(
|
||||||
|
[FromQuery] Guid? storeId,
|
||||||
|
[FromQuery] Guid? productId,
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 50,
|
||||||
|
[FromQuery] string? sort = null,
|
||||||
|
[FromQuery] string? order = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = from m in _db.StockMovements
|
||||||
|
join p in _db.Products on m.ProductId equals p.Id
|
||||||
|
join st in _db.Stores on m.StoreId equals st.Id
|
||||||
|
select new { m, p, st };
|
||||||
|
|
||||||
|
if (storeId.HasValue) q = q.Where(x => x.m.StoreId == storeId.Value);
|
||||||
|
if (productId.HasValue) q = q.Where(x => x.m.ProductId == productId.Value);
|
||||||
|
if (from.HasValue) q = q.Where(x => x.m.OccurredAt >= from.Value);
|
||||||
|
if (to.HasValue) q = q.Where(x => x.m.OccurredAt < to.Value);
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var desc = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase);
|
||||||
|
q = (sort, desc) switch
|
||||||
|
{
|
||||||
|
("occurredAt", false) => q.OrderBy(x => x.m.OccurredAt),
|
||||||
|
("occurredAt", true) => q.OrderByDescending(x => x.m.OccurredAt),
|
||||||
|
("product", false) => q.OrderBy(x => x.p.Name),
|
||||||
|
("product", true) => q.OrderByDescending(x => x.p.Name),
|
||||||
|
("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.m.OccurredAt),
|
||||||
|
("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.m.OccurredAt),
|
||||||
|
("quantity", false) => q.OrderBy(x => x.m.Quantity).ThenByDescending(x => x.m.OccurredAt),
|
||||||
|
("quantity", true) => q.OrderByDescending(x => x.m.Quantity).ThenByDescending(x => x.m.OccurredAt),
|
||||||
|
("type", false) => q.OrderBy(x => x.m.Type).ThenByDescending(x => x.m.OccurredAt),
|
||||||
|
("type", true) => q.OrderByDescending(x => x.m.Type).ThenByDescending(x => x.m.OccurredAt),
|
||||||
|
("documentType", false) => q.OrderBy(x => x.m.DocumentType).ThenByDescending(x => x.m.OccurredAt),
|
||||||
|
("documentType", true) => q.OrderByDescending(x => x.m.DocumentType).ThenByDescending(x => x.m.OccurredAt),
|
||||||
|
_ => q.OrderByDescending(x => x.m.OccurredAt),
|
||||||
|
};
|
||||||
|
var items = await q
|
||||||
|
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||||
|
.Select(x => new MovementRow(
|
||||||
|
x.m.Id, x.m.OccurredAt,
|
||||||
|
x.p.Id, x.p.Name, x.p.Article,
|
||||||
|
x.st.Id, x.st.Name,
|
||||||
|
x.m.Quantity, x.m.UnitCost,
|
||||||
|
x.m.Type.ToString(), x.m.DocumentType, x.m.DocumentId, x.m.DocumentNumber,
|
||||||
|
x.m.Notes))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new PagedResult<MovementRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
using foodmarket.Application.Common;
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Domain.Organizations;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Organizations;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/organization/employee-roles")]
|
||||||
|
public class EmployeeRolesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
|
||||||
|
public EmployeeRolesController(AppDbContext db, ITenantContext tenant)
|
||||||
|
{
|
||||||
|
_db = db; _tenant = tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record EmployeeRoleDto(
|
||||||
|
Guid Id, string Name, string? Description,
|
||||||
|
bool IsSystem, int SortOrder, RolePermissions Permissions);
|
||||||
|
|
||||||
|
public record EmployeeRoleInput(
|
||||||
|
string Name, string? Description, RolePermissions Permissions);
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<PagedResult<EmployeeRoleDto>>> List(
|
||||||
|
[FromQuery] PagedRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = _db.EmployeeRoles.AsNoTracking().AsQueryable();
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
|
{
|
||||||
|
var s = req.Search.Trim().ToLower();
|
||||||
|
q = q.Where(r => r.Name.ToLower().Contains(s));
|
||||||
|
}
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var items = await q
|
||||||
|
.OrderBy(r => r.SortOrder).ThenBy(r => r.Name)
|
||||||
|
.Skip(req.Skip).Take(req.Take)
|
||||||
|
.Select(r => new EmployeeRoleDto(r.Id, r.Name, r.Description, r.IsSystem, r.SortOrder, r.Permissions))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return new PagedResult<EmployeeRoleDto>
|
||||||
|
{ Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<ActionResult<EmployeeRoleDto>> Get(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var r = await _db.EmployeeRoles.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
return r is null ? NotFound() : new EmployeeRoleDto(r.Id, r.Name, r.Description, r.IsSystem, r.SortOrder, r.Permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Authorize(Roles = "SuperAdmin,Admin,Manager")]
|
||||||
|
public async Task<ActionResult<EmployeeRoleDto>> Create([FromBody] EmployeeRoleInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var role = new EmployeeRole
|
||||||
|
{
|
||||||
|
Name = input.Name,
|
||||||
|
Description = input.Description,
|
||||||
|
IsSystem = false,
|
||||||
|
SortOrder = await _db.EmployeeRoles.MaxAsync(r => (int?)r.SortOrder, ct) + 10 ?? 100,
|
||||||
|
Permissions = input.Permissions ?? new RolePermissions(),
|
||||||
|
};
|
||||||
|
_db.EmployeeRoles.Add(role);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return CreatedAtAction(nameof(Get), new { id = role.Id },
|
||||||
|
new EmployeeRoleDto(role.Id, role.Name, role.Description, role.IsSystem, role.SortOrder, role.Permissions));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin,Manager")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeRoleInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var r = await _db.EmployeeRoles.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (r is null) return NotFound();
|
||||||
|
// Системные роли — имя редактируем (можно перевести), но IsSystem нельзя
|
||||||
|
// снять; permissions можно править, чтобы кастомизировать под себя.
|
||||||
|
r.Name = input.Name;
|
||||||
|
r.Description = input.Description;
|
||||||
|
r.Permissions = input.Permissions ?? new RolePermissions();
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var r = await _db.EmployeeRoles.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (r is null) return NotFound();
|
||||||
|
if (r.IsSystem) return Conflict(new { error = "Системную роль удалить нельзя." });
|
||||||
|
var inUse = await _db.Employees.AnyAsync(e => e.RoleId == id, ct);
|
||||||
|
if (inUse) return Conflict(new { error = "Роль используется сотрудниками." });
|
||||||
|
_db.EmployeeRoles.Remove(r);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
_ = _tenant; // suppress warning
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
using foodmarket.Application.Common;
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Domain.Organizations;
|
||||||
|
using foodmarket.Infrastructure.Identity;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Organizations;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/organization/employees")]
|
||||||
|
public class EmployeesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
private readonly UserManager<User> _userMgr;
|
||||||
|
|
||||||
|
public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager<User> userMgr)
|
||||||
|
{
|
||||||
|
_db = db; _tenant = tenant; _userMgr = userMgr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record EmployeeDto(
|
||||||
|
Guid Id, Guid? UserId, string LastName, string FirstName, string? MiddleName,
|
||||||
|
string? Position, string? Email, string? Phone,
|
||||||
|
decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl,
|
||||||
|
Guid RoleId, string RoleName,
|
||||||
|
bool IsActive, DateTime? FiredAt,
|
||||||
|
IReadOnlyList<Guid> RetailPointIds);
|
||||||
|
|
||||||
|
public record EmployeeInput(
|
||||||
|
string LastName, string FirstName, string? MiddleName,
|
||||||
|
string? Position, string? Email, string? Phone,
|
||||||
|
decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl,
|
||||||
|
Guid RoleId, bool IsActive,
|
||||||
|
IReadOnlyList<Guid>? RetailPointIds,
|
||||||
|
// CreateAccount=true → создаём User c email + temp password.
|
||||||
|
// Возвращается в response один раз (showOnce).
|
||||||
|
bool CreateAccount = false);
|
||||||
|
|
||||||
|
public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPassword);
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<PagedResult<EmployeeDto>>> List(
|
||||||
|
[FromQuery] PagedRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = _db.Employees.AsNoTracking().Include(e => e.Role).Include(e => e.RetailPointAssignments).AsQueryable();
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
|
{
|
||||||
|
var s = req.Search.Trim().ToLower();
|
||||||
|
q = q.Where(e =>
|
||||||
|
e.LastName.ToLower().Contains(s) ||
|
||||||
|
e.FirstName.ToLower().Contains(s) ||
|
||||||
|
(e.Email != null && e.Email.ToLower().Contains(s)) ||
|
||||||
|
(e.Phone != null && e.Phone.Contains(s)));
|
||||||
|
}
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var items = await q
|
||||||
|
.OrderBy(e => e.LastName).ThenBy(e => e.FirstName)
|
||||||
|
.Skip(req.Skip).Take(req.Take)
|
||||||
|
.Select(e => new EmployeeDto(
|
||||||
|
e.Id, e.UserId, e.LastName, e.FirstName, e.MiddleName,
|
||||||
|
e.Position, e.Email, e.Phone,
|
||||||
|
e.Salary, e.TaxNumber, e.Description, e.ImageUrl,
|
||||||
|
e.RoleId, e.Role.Name,
|
||||||
|
e.IsActive, e.FiredAt,
|
||||||
|
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList()))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return new PagedResult<EmployeeDto>
|
||||||
|
{ Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<ActionResult<EmployeeDto>> Get(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await ProjectAsync(id, ct);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Authorize(Roles = "SuperAdmin,Admin,Manager")]
|
||||||
|
public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] EmployeeInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||||
|
var roleExists = await _db.EmployeeRoles.AnyAsync(r => r.Id == input.RoleId, ct);
|
||||||
|
if (!roleExists) return BadRequest(new { error = "Роль не найдена." });
|
||||||
|
|
||||||
|
var employee = new Employee
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
LastName = input.LastName, FirstName = input.FirstName, MiddleName = input.MiddleName,
|
||||||
|
Position = input.Position, Email = input.Email, Phone = input.Phone,
|
||||||
|
Salary = input.Salary, TaxNumber = input.TaxNumber,
|
||||||
|
Description = input.Description, ImageUrl = input.ImageUrl,
|
||||||
|
RoleId = input.RoleId, IsActive = input.IsActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
string? tempPassword = null;
|
||||||
|
if (input.CreateAccount)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input.Email))
|
||||||
|
return BadRequest(new { error = "Для создания учётной записи нужен email." });
|
||||||
|
var existing = await _userMgr.FindByEmailAsync(input.Email);
|
||||||
|
if (existing is not null)
|
||||||
|
return BadRequest(new { error = $"Пользователь с email «{input.Email}» уже существует." });
|
||||||
|
|
||||||
|
tempPassword = GenerateTempPassword();
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
UserName = input.Email,
|
||||||
|
Email = input.Email,
|
||||||
|
EmailConfirmed = true,
|
||||||
|
FullName = $"{input.LastName} {input.FirstName}".Trim(),
|
||||||
|
OrganizationId = orgId,
|
||||||
|
IsActive = input.IsActive,
|
||||||
|
};
|
||||||
|
var result = await _userMgr.CreateAsync(user, tempPassword);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
return BadRequest(new { error = string.Join("; ", result.Errors.Select(e => e.Description)) });
|
||||||
|
employee.UserId = user.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var rpId in input.RetailPointIds ?? [])
|
||||||
|
{
|
||||||
|
employee.RetailPointAssignments.Add(new EmployeeRetailPointAssignment
|
||||||
|
{ OrganizationId = orgId, RetailPointId = rpId });
|
||||||
|
}
|
||||||
|
_db.Employees.Add(employee);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
var dto = await ProjectAsync(employee.Id, ct);
|
||||||
|
return new EmployeeCreateResult(dto!, tempPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin,Manager")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||||
|
var e = await _db.Employees.Include(x => x.RetailPointAssignments)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (e is null) return NotFound();
|
||||||
|
e.LastName = input.LastName;
|
||||||
|
e.FirstName = input.FirstName;
|
||||||
|
e.MiddleName = input.MiddleName;
|
||||||
|
e.Position = input.Position;
|
||||||
|
e.Email = input.Email;
|
||||||
|
e.Phone = input.Phone;
|
||||||
|
e.Salary = input.Salary;
|
||||||
|
e.TaxNumber = input.TaxNumber;
|
||||||
|
e.Description = input.Description;
|
||||||
|
e.ImageUrl = input.ImageUrl;
|
||||||
|
e.RoleId = input.RoleId;
|
||||||
|
var nowActive = input.IsActive;
|
||||||
|
if (e.IsActive && !nowActive) e.FiredAt = DateTime.UtcNow;
|
||||||
|
if (!e.IsActive && nowActive) e.FiredAt = null;
|
||||||
|
e.IsActive = nowActive;
|
||||||
|
|
||||||
|
// Replace assignments wholesale
|
||||||
|
_db.EmployeeRetailPointAssignments.RemoveRange(e.RetailPointAssignments);
|
||||||
|
e.RetailPointAssignments.Clear();
|
||||||
|
foreach (var rpId in input.RetailPointIds ?? [])
|
||||||
|
e.RetailPointAssignments.Add(new EmployeeRetailPointAssignment
|
||||||
|
{ OrganizationId = orgId, RetailPointId = rpId });
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (e is null) return NotFound();
|
||||||
|
_db.Employees.Remove(e);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<EmployeeDto?> ProjectAsync(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await _db.Employees.AsNoTracking()
|
||||||
|
.Include(e => e.Role)
|
||||||
|
.Include(e => e.RetailPointAssignments)
|
||||||
|
.Where(e => e.Id == id)
|
||||||
|
.Select(e => new EmployeeDto(
|
||||||
|
e.Id, e.UserId, e.LastName, e.FirstName, e.MiddleName,
|
||||||
|
e.Position, e.Email, e.Phone,
|
||||||
|
e.Salary, e.TaxNumber, e.Description, e.ImageUrl,
|
||||||
|
e.RoleId, e.Role.Name,
|
||||||
|
e.IsActive, e.FiredAt,
|
||||||
|
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList()))
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateTempPassword()
|
||||||
|
{
|
||||||
|
// 12 символов: цифры + строчные/заглавные + спецсимвол — соответствует
|
||||||
|
// дефолтным правилам ASP.NET Identity (>=8, разные классы символов).
|
||||||
|
const string lower = "abcdefghkmnpqrstuvwxyz";
|
||||||
|
const string upper = "ABCDEFGHKMNPQRSTUVWXYZ";
|
||||||
|
const string digits = "23456789";
|
||||||
|
const string special = "!@#$%&*";
|
||||||
|
var rnd = new Random();
|
||||||
|
var chars = new List<char>
|
||||||
|
{
|
||||||
|
upper[rnd.Next(upper.Length)],
|
||||||
|
lower[rnd.Next(lower.Length)],
|
||||||
|
digits[rnd.Next(digits.Length)],
|
||||||
|
special[rnd.Next(special.Length)],
|
||||||
|
};
|
||||||
|
var pool = lower + upper + digits;
|
||||||
|
for (var i = 0; i < 8; i++) chars.Add(pool[rnd.Next(pool.Length)]);
|
||||||
|
return new string(chars.OrderBy(_ => rnd.Next()).ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Organizations;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/organization")]
|
||||||
|
public class OrganizationSettingsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
|
||||||
|
public OrganizationSettingsController(AppDbContext db, ITenantContext tenant)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_tenant = tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record OrgSettingsDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string CountryCode,
|
||||||
|
Guid? DefaultCurrencyId,
|
||||||
|
string? DefaultCurrencyCode,
|
||||||
|
string? DefaultCurrencySymbol,
|
||||||
|
bool MultiCurrencyEnabled,
|
||||||
|
// VAT read-only: из страны организации (countries.VatRate). Источник правды — справочник стран.
|
||||||
|
decimal VatRate,
|
||||||
|
bool ShowVatEnabledOnProduct,
|
||||||
|
bool ShowServiceOnProduct,
|
||||||
|
bool ShowMarkedOnProduct,
|
||||||
|
bool ShowMinMaxStock,
|
||||||
|
bool AllowFractionalPrices,
|
||||||
|
bool ShowReferencePriceOnProduct,
|
||||||
|
bool ShowCountryOfOriginOnProduct,
|
||||||
|
bool ShowDescriptionOnProduct);
|
||||||
|
|
||||||
|
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
|
||||||
|
public record OrgSettingsInput(
|
||||||
|
string Name,
|
||||||
|
string CountryCode,
|
||||||
|
bool MultiCurrencyEnabled,
|
||||||
|
bool ShowVatEnabledOnProduct,
|
||||||
|
bool ShowServiceOnProduct,
|
||||||
|
bool ShowMarkedOnProduct,
|
||||||
|
bool ShowMinMaxStock,
|
||||||
|
bool AllowFractionalPrices,
|
||||||
|
bool ShowReferencePriceOnProduct,
|
||||||
|
bool ShowCountryOfOriginOnProduct,
|
||||||
|
bool ShowDescriptionOnProduct);
|
||||||
|
|
||||||
|
[HttpGet("settings")]
|
||||||
|
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||||
|
var o = await _db.Organizations
|
||||||
|
.Include(o => o.DefaultCurrency)
|
||||||
|
.FirstOrDefaultAsync(o => o.Id == orgId, ct);
|
||||||
|
if (o is null) return NotFound();
|
||||||
|
var vat = await ReadVatRateAsync(o.CountryCode, ct);
|
||||||
|
return Project(o, vat);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("settings"), Authorize(Roles = "Admin,Manager")]
|
||||||
|
public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||||
|
var o = await _db.Organizations
|
||||||
|
.Include(o => o.DefaultCurrency)
|
||||||
|
.FirstOrDefaultAsync(o => o.Id == orgId, ct);
|
||||||
|
if (o is null) return NotFound();
|
||||||
|
|
||||||
|
o.Name = input.Name;
|
||||||
|
o.CountryCode = input.CountryCode;
|
||||||
|
// Валюта организации жёстко следует за страной — не принимается от клиента.
|
||||||
|
o.DefaultCurrencyId = await _db.Countries
|
||||||
|
.Where(c => c.Code == input.CountryCode)
|
||||||
|
.Select(c => c.DefaultCurrencyId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
o.MultiCurrencyEnabled = input.MultiCurrencyEnabled;
|
||||||
|
o.ShowVatEnabledOnProduct = input.ShowVatEnabledOnProduct;
|
||||||
|
o.ShowServiceOnProduct = input.ShowServiceOnProduct;
|
||||||
|
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
|
||||||
|
o.ShowMinMaxStock = input.ShowMinMaxStock;
|
||||||
|
o.AllowFractionalPrices = input.AllowFractionalPrices;
|
||||||
|
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
|
||||||
|
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
|
||||||
|
o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct);
|
||||||
|
var vat = await ReadVatRateAsync(o.CountryCode, ct);
|
||||||
|
return Project(o, vat);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var rate = await _db.Countries
|
||||||
|
.Where(c => c.Code == countryCode)
|
||||||
|
.Select(c => (decimal?)c.VatRate)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
return rate ?? 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OrgSettingsDto Project(foodmarket.Domain.Organizations.Organization o, decimal vat) => new(
|
||||||
|
o.Id, o.Name, o.CountryCode,
|
||||||
|
o.DefaultCurrencyId,
|
||||||
|
o.DefaultCurrency?.Code,
|
||||||
|
o.DefaultCurrency?.Symbol,
|
||||||
|
o.MultiCurrencyEnabled,
|
||||||
|
vat,
|
||||||
|
o.ShowVatEnabledOnProduct,
|
||||||
|
o.ShowServiceOnProduct,
|
||||||
|
o.ShowMarkedOnProduct,
|
||||||
|
o.ShowMinMaxStock,
|
||||||
|
o.AllowFractionalPrices,
|
||||||
|
o.ShowReferencePriceOnProduct,
|
||||||
|
o.ShowCountryOfOriginOnProduct,
|
||||||
|
o.ShowDescriptionOnProduct);
|
||||||
|
}
|
||||||
412
src/food-market.api/Controllers/Purchases/SuppliesController.cs
Normal file
412
src/food-market.api/Controllers/Purchases/SuppliesController.cs
Normal file
|
|
@ -0,0 +1,412 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using foodmarket.Application.Common;
|
||||||
|
using foodmarket.Application.Inventory;
|
||||||
|
using foodmarket.Domain.Inventory;
|
||||||
|
using foodmarket.Domain.Purchases;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Purchases;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/purchases/supplies")]
|
||||||
|
public class SuppliesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IStockService _stock;
|
||||||
|
|
||||||
|
public SuppliesController(AppDbContext db, IStockService stock)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_stock = stock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SupplyListRow(
|
||||||
|
Guid Id, string Number, DateTime Date, SupplyStatus Status,
|
||||||
|
Guid SupplierId, string SupplierName,
|
||||||
|
Guid StoreId, string StoreName,
|
||||||
|
Guid CurrencyId, string CurrencyCode,
|
||||||
|
decimal Total, int LineCount,
|
||||||
|
DateTime? PostedAt);
|
||||||
|
|
||||||
|
public record SupplyLineDto(
|
||||||
|
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
|
||||||
|
string? ProductBarcode,
|
||||||
|
string? UnitSymbol,
|
||||||
|
decimal Quantity, decimal UnitPrice, decimal LineTotal, int SortOrder,
|
||||||
|
bool RetailPriceManuallyOverridden, decimal? RetailPriceOverride,
|
||||||
|
decimal? CurrentRetailPrice);
|
||||||
|
|
||||||
|
public record SupplyDto(
|
||||||
|
Guid Id, string Number, DateTime Date, SupplyStatus Status,
|
||||||
|
Guid SupplierId, string SupplierName,
|
||||||
|
Guid StoreId, string StoreName,
|
||||||
|
Guid CurrencyId, string CurrencyCode,
|
||||||
|
string? Notes,
|
||||||
|
decimal Total, DateTime? PostedAt,
|
||||||
|
IReadOnlyList<SupplyLineDto> Lines);
|
||||||
|
|
||||||
|
public record SupplyLineInput(
|
||||||
|
Guid ProductId,
|
||||||
|
[Range(0, 1e10)] decimal Quantity,
|
||||||
|
[Range(0, 1e10)] decimal UnitPrice,
|
||||||
|
bool RetailPriceManuallyOverridden = false,
|
||||||
|
[Range(0, 1e10)] decimal? RetailPriceOverride = null);
|
||||||
|
public record SupplyInput(
|
||||||
|
DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId,
|
||||||
|
string? Notes,
|
||||||
|
IReadOnlyList<SupplyLineInput> Lines);
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<PagedResult<SupplyListRow>>> List(
|
||||||
|
[FromQuery] PagedRequest req,
|
||||||
|
[FromQuery] SupplyStatus? status,
|
||||||
|
[FromQuery] Guid? storeId,
|
||||||
|
[FromQuery] Guid? supplierId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = from s in _db.Supplies.AsNoTracking()
|
||||||
|
join cp in _db.Counterparties on s.SupplierId equals cp.Id
|
||||||
|
join st in _db.Stores on s.StoreId equals st.Id
|
||||||
|
join cu in _db.Currencies on s.CurrencyId equals cu.Id
|
||||||
|
select new { s, cp, st, cu };
|
||||||
|
|
||||||
|
if (status is not null) q = q.Where(x => x.s.Status == status);
|
||||||
|
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
|
||||||
|
if (supplierId is not null) q = q.Where(x => x.s.SupplierId == supplierId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
|
{
|
||||||
|
var s = req.Search.Trim().ToLower();
|
||||||
|
q = q.Where(x => x.s.Number.ToLower().Contains(s) || x.cp.Name.ToLower().Contains(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
q = (req.Sort, req.Desc) switch
|
||||||
|
{
|
||||||
|
("number", false) => q.OrderBy(x => x.s.Number),
|
||||||
|
("number", true) => q.OrderByDescending(x => x.s.Number),
|
||||||
|
("supplier", false) => q.OrderBy(x => x.cp.Name).ThenByDescending(x => x.s.Date),
|
||||||
|
("supplier", true) => q.OrderByDescending(x => x.cp.Name).ThenByDescending(x => x.s.Date),
|
||||||
|
("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.s.Date),
|
||||||
|
("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.s.Date),
|
||||||
|
("status", false) => q.OrderBy(x => x.s.Status).ThenByDescending(x => x.s.Date),
|
||||||
|
("status", true) => q.OrderByDescending(x => x.s.Status).ThenByDescending(x => x.s.Date),
|
||||||
|
("total", false) => q.OrderBy(x => x.s.Total).ThenByDescending(x => x.s.Date),
|
||||||
|
("total", true) => q.OrderByDescending(x => x.s.Total).ThenByDescending(x => x.s.Date),
|
||||||
|
("date", false) => q.OrderBy(x => x.s.Date).ThenBy(x => x.s.Number),
|
||||||
|
_ => q.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number),
|
||||||
|
};
|
||||||
|
var items = await q
|
||||||
|
.Skip(req.Skip).Take(req.Take)
|
||||||
|
.Select(x => new SupplyListRow(
|
||||||
|
x.s.Id, x.s.Number, x.s.Date, x.s.Status,
|
||||||
|
x.cp.Id, x.cp.Name,
|
||||||
|
x.st.Id, x.st.Name,
|
||||||
|
x.cu.Id, x.cu.Code,
|
||||||
|
x.s.Total,
|
||||||
|
x.s.Lines.Count,
|
||||||
|
x.s.PostedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new PagedResult<SupplyListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await GetInternal(id, ct);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
|
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (input.Lines is null || input.Lines.Count == 0)
|
||||||
|
return BadRequest(new { error = "Приёмка должна содержать хотя бы одну позицию." });
|
||||||
|
var number = await GenerateNumberAsync(input.Date, ct);
|
||||||
|
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
|
var supply = new Supply
|
||||||
|
{
|
||||||
|
Number = number,
|
||||||
|
Date = input.Date,
|
||||||
|
Status = SupplyStatus.Draft,
|
||||||
|
SupplierId = input.SupplierId,
|
||||||
|
StoreId = input.StoreId,
|
||||||
|
CurrencyId = input.CurrencyId,
|
||||||
|
Notes = input.Notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
var order = 0;
|
||||||
|
foreach (var l in input.Lines)
|
||||||
|
{
|
||||||
|
var unitPrice = allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero);
|
||||||
|
supply.Lines.Add(new SupplyLine
|
||||||
|
{
|
||||||
|
ProductId = l.ProductId,
|
||||||
|
Quantity = l.Quantity,
|
||||||
|
UnitPrice = unitPrice,
|
||||||
|
LineTotal = l.Quantity * unitPrice,
|
||||||
|
SortOrder = order++,
|
||||||
|
RetailPriceManuallyOverridden = l.RetailPriceManuallyOverridden,
|
||||||
|
RetailPriceOverride = l.RetailPriceOverride.HasValue
|
||||||
|
? (allowFractional ? l.RetailPriceOverride : Math.Round(l.RetailPriceOverride.Value, 0, MidpointRounding.AwayFromZero))
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
supply.Total = supply.Lines.Sum(x => x.LineTotal);
|
||||||
|
|
||||||
|
_db.Supplies.Add(supply);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
var dto = await GetInternal(supply.Id, ct);
|
||||||
|
return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (input.Lines is null || input.Lines.Count == 0)
|
||||||
|
return BadRequest(new { error = "Приёмка должна содержать хотя бы одну позицию." });
|
||||||
|
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (supply is null) return NotFound();
|
||||||
|
if (supply.Status != SupplyStatus.Draft)
|
||||||
|
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
|
||||||
|
|
||||||
|
supply.Date = input.Date;
|
||||||
|
supply.SupplierId = input.SupplierId;
|
||||||
|
supply.StoreId = input.StoreId;
|
||||||
|
supply.CurrencyId = input.CurrencyId;
|
||||||
|
supply.Notes = input.Notes;
|
||||||
|
|
||||||
|
// Replace lines wholesale (simple, idempotent).
|
||||||
|
_db.SupplyLines.RemoveRange(supply.Lines);
|
||||||
|
supply.Lines.Clear();
|
||||||
|
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
|
var order = 0;
|
||||||
|
foreach (var l in input.Lines)
|
||||||
|
{
|
||||||
|
var unitPrice = allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero);
|
||||||
|
supply.Lines.Add(new SupplyLine
|
||||||
|
{
|
||||||
|
SupplyId = supply.Id,
|
||||||
|
ProductId = l.ProductId,
|
||||||
|
Quantity = l.Quantity,
|
||||||
|
UnitPrice = unitPrice,
|
||||||
|
LineTotal = l.Quantity * unitPrice,
|
||||||
|
SortOrder = order++,
|
||||||
|
RetailPriceManuallyOverridden = l.RetailPriceManuallyOverridden,
|
||||||
|
RetailPriceOverride = l.RetailPriceOverride.HasValue
|
||||||
|
? (allowFractional ? l.RetailPriceOverride : Math.Round(l.RetailPriceOverride.Value, 0, MidpointRounding.AwayFromZero))
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
supply.Total = supply.Lines.Sum(x => x.LineTotal);
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var supply = await _db.Supplies.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (supply is null) return NotFound();
|
||||||
|
if (supply.Status != SupplyStatus.Draft)
|
||||||
|
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
|
||||||
|
_db.Supplies.Remove(supply);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
|
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (supply is null) return NotFound();
|
||||||
|
if (supply.Status == SupplyStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
|
||||||
|
if (supply.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
|
||||||
|
|
||||||
|
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
await using var tx = await _db.Database.BeginTransactionAsync(ct);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
foreach (var line in supply.Lines)
|
||||||
|
{
|
||||||
|
var product = await _db.Products
|
||||||
|
.Include(p => p.ProductGroup)
|
||||||
|
.Include(p => p.Prices)
|
||||||
|
.FirstAsync(p => p.Id == line.ProductId, ct);
|
||||||
|
|
||||||
|
// Текущее общее количество по всем складам (до этой приёмки).
|
||||||
|
var currentQty = await _db.Stocks
|
||||||
|
.Where(s => s.ProductId == line.ProductId)
|
||||||
|
.SumAsync(s => (decimal?)s.Quantity, ct) ?? 0m;
|
||||||
|
|
||||||
|
// 1. Cost — скользящее среднее.
|
||||||
|
var totalQty = currentQty + line.Quantity;
|
||||||
|
var newCost = totalQty == 0m || product.Cost == 0m && currentQty == 0m
|
||||||
|
? line.UnitPrice
|
||||||
|
: (currentQty * product.Cost + line.Quantity * line.UnitPrice) / totalQty;
|
||||||
|
product.Cost = Math.Round(newCost, 4, MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
// 2. ReferencePrice — автозаполнение при первой приёмке.
|
||||||
|
if (product.ReferencePrice is null)
|
||||||
|
{
|
||||||
|
product.ReferencePrice = line.UnitPrice;
|
||||||
|
product.ReferencePriceUpdatedAt = now;
|
||||||
|
}
|
||||||
|
product.LastSupplyAt = now;
|
||||||
|
|
||||||
|
// 3. Розничная: либо явный override строки, либо автонаценка по группе.
|
||||||
|
if (line.RetailPriceManuallyOverridden && line.RetailPriceOverride.HasValue)
|
||||||
|
{
|
||||||
|
SetDefaultRetail(product, line.RetailPriceOverride.Value, supply.CurrencyId);
|
||||||
|
}
|
||||||
|
else if (product.ProductGroup?.MarkupPercent is decimal pct)
|
||||||
|
{
|
||||||
|
var raw = product.Cost * (1m + pct / 100m);
|
||||||
|
var newRetail = allowFractional
|
||||||
|
? Math.Ceiling(raw * 100m) / 100m
|
||||||
|
: Math.Ceiling(raw);
|
||||||
|
SetDefaultRetail(product, newRetail, supply.CurrencyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||||||
|
ProductId: line.ProductId,
|
||||||
|
StoreId: supply.StoreId,
|
||||||
|
Quantity: line.Quantity,
|
||||||
|
Type: MovementType.Supply,
|
||||||
|
DocumentType: "supply",
|
||||||
|
DocumentId: supply.Id,
|
||||||
|
DocumentNumber: supply.Number,
|
||||||
|
UnitCost: line.UnitPrice,
|
||||||
|
OccurredAt: supply.Date), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
supply.Status = SupplyStatus.Posted;
|
||||||
|
supply.PostedAt = now;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
await tx.CommitAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Записывает значение в дефолтный розничный PriceType. Если в списке
|
||||||
|
/// цен у товара такой записи нет — создаёт её. Дефолтным считается PriceType
|
||||||
|
/// с IsSystem=true; если такого нет — первый IsRetail; иначе — первый
|
||||||
|
/// PriceType в списке. Currency берётся из приёмки (или из существующей записи).</summary>
|
||||||
|
private void SetDefaultRetail(foodmarket.Domain.Catalog.Product p, decimal value, Guid fallbackCurrencyId)
|
||||||
|
{
|
||||||
|
var defaultType = _db.PriceTypes
|
||||||
|
.OrderByDescending(pt => pt.IsSystem)
|
||||||
|
.ThenByDescending(pt => pt.IsRetail)
|
||||||
|
.ThenBy(pt => pt.SortOrder)
|
||||||
|
.ThenBy(pt => pt.Name)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (defaultType is null) return;
|
||||||
|
var existing = p.Prices.FirstOrDefault(x => x.PriceTypeId == defaultType.Id);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
p.Prices.Add(new foodmarket.Domain.Catalog.ProductPrice
|
||||||
|
{
|
||||||
|
PriceTypeId = defaultType.Id,
|
||||||
|
Amount = value,
|
||||||
|
CurrencyId = fallbackCurrencyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.Amount = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
|
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (supply is null) return NotFound();
|
||||||
|
if (supply.Status != SupplyStatus.Posted) return Conflict(new { error = "Документ не проведён." });
|
||||||
|
|
||||||
|
// Reverse: negative movements with same document reference
|
||||||
|
foreach (var line in supply.Lines)
|
||||||
|
{
|
||||||
|
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||||||
|
ProductId: line.ProductId,
|
||||||
|
StoreId: supply.StoreId,
|
||||||
|
Quantity: -line.Quantity,
|
||||||
|
Type: MovementType.Supply,
|
||||||
|
DocumentType: "supply-reversal",
|
||||||
|
DocumentId: supply.Id,
|
||||||
|
DocumentNumber: supply.Number,
|
||||||
|
UnitCost: line.UnitPrice,
|
||||||
|
OccurredAt: DateTime.UtcNow,
|
||||||
|
Notes: $"Отмена проведения документа {supply.Number}"), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
supply.Status = SupplyStatus.Draft;
|
||||||
|
supply.PostedAt = null;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var year = date.Year;
|
||||||
|
var prefix = $"П-{year}-";
|
||||||
|
var lastNumber = await _db.Supplies
|
||||||
|
.Where(s => s.Number.StartsWith(prefix))
|
||||||
|
.OrderByDescending(s => s.Number)
|
||||||
|
.Select(s => s.Number)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
var seq = 1;
|
||||||
|
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
|
||||||
|
seq = last + 1;
|
||||||
|
return $"{prefix}{seq:D6}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SupplyDto?> GetInternal(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var row = await (from s in _db.Supplies.AsNoTracking()
|
||||||
|
join cp in _db.Counterparties on s.SupplierId equals cp.Id
|
||||||
|
join st in _db.Stores on s.StoreId equals st.Id
|
||||||
|
join cu in _db.Currencies on s.CurrencyId equals cu.Id
|
||||||
|
where s.Id == id
|
||||||
|
select new { s, cp, st, cu }).FirstOrDefaultAsync(ct);
|
||||||
|
if (row is null) return null;
|
||||||
|
|
||||||
|
// CurrentRetailPrice — текущая розничная цена товара (дефолтный PriceType),
|
||||||
|
// отображается в строке приёмки как «Розничная (из карточки)».
|
||||||
|
var lines = await (from l in _db.SupplyLines.AsNoTracking()
|
||||||
|
join p in _db.Products on l.ProductId equals p.Id
|
||||||
|
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
|
||||||
|
where l.SupplyId == id
|
||||||
|
orderby l.SortOrder
|
||||||
|
select new SupplyLineDto(
|
||||||
|
l.Id, l.ProductId, p.Name, p.Article,
|
||||||
|
// Основной штрихкод (IsPrimary=true), иначе первый по порядку.
|
||||||
|
p.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault(),
|
||||||
|
u.Name,
|
||||||
|
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder,
|
||||||
|
l.RetailPriceManuallyOverridden, l.RetailPriceOverride,
|
||||||
|
p.Prices
|
||||||
|
.OrderByDescending(pr => pr.PriceType!.IsSystem)
|
||||||
|
.ThenByDescending(pr => pr.PriceType!.IsRetail)
|
||||||
|
.ThenBy(pr => pr.PriceType!.SortOrder)
|
||||||
|
.ThenBy(pr => pr.PriceType!.Name)
|
||||||
|
.Select(pr => (decimal?)pr.Amount)
|
||||||
|
.FirstOrDefault()))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new SupplyDto(
|
||||||
|
row.s.Id, row.s.Number, row.s.Date, row.s.Status,
|
||||||
|
row.cp.Id, row.cp.Name,
|
||||||
|
row.st.Id, row.st.Name,
|
||||||
|
row.cu.Id, row.cu.Code,
|
||||||
|
row.s.Notes,
|
||||||
|
row.s.Total, row.s.PostedAt,
|
||||||
|
lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
397
src/food-market.api/Controllers/Sales/RetailSalesController.cs
Normal file
397
src/food-market.api/Controllers/Sales/RetailSalesController.cs
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using foodmarket.Application.Common;
|
||||||
|
using foodmarket.Application.Inventory;
|
||||||
|
using foodmarket.Domain.Inventory;
|
||||||
|
using foodmarket.Domain.Sales;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Sales;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/sales/retail")]
|
||||||
|
public class RetailSalesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IStockService _stock;
|
||||||
|
|
||||||
|
public RetailSalesController(AppDbContext db, IStockService stock)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_stock = stock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RetailSaleListRow(
|
||||||
|
Guid Id, string Number, DateTime Date, RetailSaleStatus Status,
|
||||||
|
Guid StoreId, string StoreName,
|
||||||
|
Guid? RetailPointId, string? RetailPointName,
|
||||||
|
Guid? CustomerId, string? CustomerName,
|
||||||
|
Guid CurrencyId, string CurrencyCode,
|
||||||
|
decimal Total, PaymentMethod Payment, int LineCount,
|
||||||
|
DateTime? PostedAt);
|
||||||
|
|
||||||
|
public record RetailSaleLineDto(
|
||||||
|
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
|
||||||
|
decimal Quantity, decimal UnitPrice, decimal Discount, decimal LineTotal, decimal VatPercent, int SortOrder);
|
||||||
|
|
||||||
|
public record RetailSaleDto(
|
||||||
|
Guid Id, string Number, DateTime Date, RetailSaleStatus Status,
|
||||||
|
Guid StoreId, string StoreName,
|
||||||
|
Guid? RetailPointId, string? RetailPointName,
|
||||||
|
Guid? CustomerId, string? CustomerName,
|
||||||
|
Guid CurrencyId, string CurrencyCode,
|
||||||
|
decimal Subtotal, decimal DiscountTotal, decimal Total,
|
||||||
|
PaymentMethod Payment, decimal PaidCash, decimal PaidCard,
|
||||||
|
string? Notes, DateTime? PostedAt,
|
||||||
|
IReadOnlyList<RetailSaleLineDto> Lines);
|
||||||
|
|
||||||
|
public record RetailSaleLineInput(
|
||||||
|
Guid ProductId,
|
||||||
|
[Range(0, 1e10)] decimal Quantity,
|
||||||
|
[Range(0, 1e10)] decimal UnitPrice,
|
||||||
|
[Range(0, 1e10)] decimal Discount,
|
||||||
|
[Range(0, 100)] decimal VatPercent);
|
||||||
|
public record RetailSaleInput(
|
||||||
|
DateTime Date, Guid StoreId, Guid? RetailPointId, Guid? CustomerId, Guid CurrencyId,
|
||||||
|
PaymentMethod Payment,
|
||||||
|
[Range(0, 1e10)] decimal PaidCash,
|
||||||
|
[Range(0, 1e10)] decimal PaidCard,
|
||||||
|
string? Notes,
|
||||||
|
IReadOnlyList<RetailSaleLineInput> Lines);
|
||||||
|
|
||||||
|
public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions);
|
||||||
|
|
||||||
|
public record SalesStatsResponse(
|
||||||
|
decimal RevenueToday,
|
||||||
|
decimal RevenueThisMonth,
|
||||||
|
decimal RevenuePrevMonth,
|
||||||
|
int TransactionsToday,
|
||||||
|
int TransactionsThisMonth,
|
||||||
|
decimal AvgTicketThisMonth,
|
||||||
|
IReadOnlyList<SalesStatsBucket> Series);
|
||||||
|
|
||||||
|
/// <summary>Aggregated sales metrics + daily series for the dashboard.
|
||||||
|
/// Series buckets are days; defaults to last 30 days.</summary>
|
||||||
|
[HttpGet("stats")]
|
||||||
|
public async Task<ActionResult<SalesStatsResponse>> Stats(
|
||||||
|
[FromQuery] int days = 30,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
var todayStart = new DateTime(nowUtc.Year, nowUtc.Month, nowUtc.Day, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var prevMonthStart = monthStart.AddMonths(-1);
|
||||||
|
var seriesStart = todayStart.AddDays(-(days - 1));
|
||||||
|
|
||||||
|
var posted = _db.RetailSales.AsNoTracking().Where(s => s.Status == RetailSaleStatus.Posted);
|
||||||
|
|
||||||
|
var today = await posted.Where(s => s.Date >= todayStart && s.Date < todayStart.AddDays(1))
|
||||||
|
.GroupBy(_ => 1)
|
||||||
|
.Select(g => new { Sum = g.Sum(s => s.Total), Count = g.Count() })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
var thisMonth = await posted.Where(s => s.Date >= monthStart)
|
||||||
|
.GroupBy(_ => 1)
|
||||||
|
.Select(g => new { Sum = g.Sum(s => s.Total), Count = g.Count() })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
var prevMonth = await posted.Where(s => s.Date >= prevMonthStart && s.Date < monthStart)
|
||||||
|
.GroupBy(_ => 1)
|
||||||
|
.Select(g => new { Sum = g.Sum(s => s.Total) })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
var rawSeries = await posted.Where(s => s.Date >= seriesStart)
|
||||||
|
.GroupBy(s => s.Date.Date)
|
||||||
|
.Select(g => new { Day = g.Key, Revenue = g.Sum(s => s.Total), Tx = g.Count() })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Fill missing days with zeros so the chart line is continuous.
|
||||||
|
var byDay = rawSeries.ToDictionary(x => x.Day, x => x);
|
||||||
|
var series = Enumerable.Range(0, days)
|
||||||
|
.Select(i => seriesStart.AddDays(i).Date)
|
||||||
|
.Select(d => byDay.TryGetValue(d, out var v)
|
||||||
|
? new SalesStatsBucket(d, v.Revenue, v.Tx)
|
||||||
|
: new SalesStatsBucket(d, 0m, 0))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var thisMonthSum = thisMonth?.Sum ?? 0m;
|
||||||
|
var thisMonthCount = thisMonth?.Count ?? 0;
|
||||||
|
var avgTicket = thisMonthCount == 0 ? 0m : thisMonthSum / thisMonthCount;
|
||||||
|
|
||||||
|
return new SalesStatsResponse(
|
||||||
|
RevenueToday: today?.Sum ?? 0m,
|
||||||
|
RevenueThisMonth: thisMonthSum,
|
||||||
|
RevenuePrevMonth: prevMonth?.Sum ?? 0m,
|
||||||
|
TransactionsToday: today?.Count ?? 0,
|
||||||
|
TransactionsThisMonth: thisMonthCount,
|
||||||
|
AvgTicketThisMonth: avgTicket,
|
||||||
|
Series: series);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<PagedResult<RetailSaleListRow>>> List(
|
||||||
|
[FromQuery] PagedRequest req,
|
||||||
|
[FromQuery] RetailSaleStatus? status,
|
||||||
|
[FromQuery] Guid? storeId,
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = from s in _db.RetailSales.AsNoTracking()
|
||||||
|
join st in _db.Stores on s.StoreId equals st.Id
|
||||||
|
join cu in _db.Currencies on s.CurrencyId equals cu.Id
|
||||||
|
select new { s, st, cu };
|
||||||
|
|
||||||
|
if (status is not null) q = q.Where(x => x.s.Status == status);
|
||||||
|
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
|
||||||
|
if (from is not null) q = q.Where(x => x.s.Date >= from);
|
||||||
|
if (to is not null) q = q.Where(x => x.s.Date < to);
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
|
{
|
||||||
|
var s = req.Search.Trim().ToLower();
|
||||||
|
q = q.Where(x => x.s.Number.ToLower().Contains(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
q = (req.Sort, req.Desc) switch
|
||||||
|
{
|
||||||
|
("number", false) => q.OrderBy(x => x.s.Number),
|
||||||
|
("number", true) => q.OrderByDescending(x => x.s.Number),
|
||||||
|
("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.s.Date),
|
||||||
|
("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.s.Date),
|
||||||
|
("status", false) => q.OrderBy(x => x.s.Status).ThenByDescending(x => x.s.Date),
|
||||||
|
("status", true) => q.OrderByDescending(x => x.s.Status).ThenByDescending(x => x.s.Date),
|
||||||
|
("total", false) => q.OrderBy(x => x.s.Total).ThenByDescending(x => x.s.Date),
|
||||||
|
("total", true) => q.OrderByDescending(x => x.s.Total).ThenByDescending(x => x.s.Date),
|
||||||
|
("date", false) => q.OrderBy(x => x.s.Date).ThenBy(x => x.s.Number),
|
||||||
|
_ => q.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number),
|
||||||
|
};
|
||||||
|
var items = await q
|
||||||
|
.Skip(req.Skip).Take(req.Take)
|
||||||
|
.Select(x => new RetailSaleListRow(
|
||||||
|
x.s.Id, x.s.Number, x.s.Date, x.s.Status,
|
||||||
|
x.st.Id, x.st.Name,
|
||||||
|
x.s.RetailPointId,
|
||||||
|
x.s.RetailPointId == null ? null : _db.RetailPoints.Where(rp => rp.Id == x.s.RetailPointId).Select(rp => rp.Name).FirstOrDefault(),
|
||||||
|
x.s.CustomerId,
|
||||||
|
x.s.CustomerId == null ? null : _db.Counterparties.Where(c => c.Id == x.s.CustomerId).Select(c => c.Name).FirstOrDefault(),
|
||||||
|
x.cu.Id, x.cu.Code,
|
||||||
|
x.s.Total, x.s.Payment,
|
||||||
|
x.s.Lines.Count,
|
||||||
|
x.s.PostedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new PagedResult<RetailSaleListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<ActionResult<RetailSaleDto>> Get(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await GetInternal(id, ct);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Authorize(Roles = "Admin,Manager,Cashier")]
|
||||||
|
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var number = await GenerateNumberAsync(input.Date, ct);
|
||||||
|
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
|
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
||||||
|
var sale = new RetailSale
|
||||||
|
{
|
||||||
|
Number = number,
|
||||||
|
Date = input.Date,
|
||||||
|
Status = RetailSaleStatus.Draft,
|
||||||
|
StoreId = input.StoreId,
|
||||||
|
RetailPointId = input.RetailPointId,
|
||||||
|
CustomerId = input.CustomerId,
|
||||||
|
CurrencyId = input.CurrencyId,
|
||||||
|
Payment = input.Payment,
|
||||||
|
PaidCash = R(input.PaidCash),
|
||||||
|
PaidCard = R(input.PaidCard),
|
||||||
|
Notes = input.Notes,
|
||||||
|
};
|
||||||
|
ApplyLines(sale, input.Lines, allowFractional);
|
||||||
|
_db.RetailSales.Add(sale);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
var dto = await GetInternal(sale.Id, ct);
|
||||||
|
return CreatedAtAction(nameof(Get), new { id = sale.Id }, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Cashier")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (sale is null) return NotFound();
|
||||||
|
if (sale.Status != RetailSaleStatus.Draft)
|
||||||
|
return Conflict(new { error = "Только черновик может быть изменён." });
|
||||||
|
|
||||||
|
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
|
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
||||||
|
sale.Date = input.Date;
|
||||||
|
sale.StoreId = input.StoreId;
|
||||||
|
sale.RetailPointId = input.RetailPointId;
|
||||||
|
sale.CustomerId = input.CustomerId;
|
||||||
|
sale.CurrencyId = input.CurrencyId;
|
||||||
|
sale.Payment = input.Payment;
|
||||||
|
sale.PaidCash = R(input.PaidCash);
|
||||||
|
sale.PaidCard = R(input.PaidCard);
|
||||||
|
sale.Notes = input.Notes;
|
||||||
|
|
||||||
|
_db.RetailSaleLines.RemoveRange(sale.Lines);
|
||||||
|
sale.Lines.Clear();
|
||||||
|
ApplyLines(sale, input.Lines, allowFractional);
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sale = await _db.RetailSales.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (sale is null) return NotFound();
|
||||||
|
if (sale.Status != RetailSaleStatus.Draft)
|
||||||
|
return Conflict(new { error = "Нельзя удалить проведённый чек." });
|
||||||
|
_db.RetailSales.Remove(sale);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Manager,Cashier")]
|
||||||
|
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (sale is null) return NotFound();
|
||||||
|
if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." });
|
||||||
|
if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." });
|
||||||
|
|
||||||
|
foreach (var line in sale.Lines)
|
||||||
|
{
|
||||||
|
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||||||
|
ProductId: line.ProductId,
|
||||||
|
StoreId: sale.StoreId,
|
||||||
|
Quantity: -line.Quantity, // negative: товар уходит со склада
|
||||||
|
Type: MovementType.RetailSale,
|
||||||
|
DocumentType: "retail-sale",
|
||||||
|
DocumentId: sale.Id,
|
||||||
|
DocumentNumber: sale.Number,
|
||||||
|
UnitCost: line.UnitPrice,
|
||||||
|
OccurredAt: sale.Date), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
sale.Status = RetailSaleStatus.Posted;
|
||||||
|
sale.PostedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager")]
|
||||||
|
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (sale is null) return NotFound();
|
||||||
|
if (sale.Status != RetailSaleStatus.Posted) return Conflict(new { error = "Чек не проведён." });
|
||||||
|
|
||||||
|
foreach (var line in sale.Lines)
|
||||||
|
{
|
||||||
|
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||||||
|
ProductId: line.ProductId,
|
||||||
|
StoreId: sale.StoreId,
|
||||||
|
Quantity: +line.Quantity, // reverse — return stock
|
||||||
|
Type: MovementType.RetailSale,
|
||||||
|
DocumentType: "retail-sale-reversal",
|
||||||
|
DocumentId: sale.Id,
|
||||||
|
DocumentNumber: sale.Number,
|
||||||
|
UnitCost: line.UnitPrice,
|
||||||
|
OccurredAt: DateTime.UtcNow,
|
||||||
|
Notes: $"Отмена чека {sale.Number}"), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
sale.Status = RetailSaleStatus.Draft;
|
||||||
|
sale.PostedAt = null;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyLines(RetailSale sale, IReadOnlyList<RetailSaleLineInput> input, bool allowFractional)
|
||||||
|
{
|
||||||
|
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
||||||
|
var order = 0;
|
||||||
|
decimal subtotal = 0, discountTotal = 0;
|
||||||
|
foreach (var l in input)
|
||||||
|
{
|
||||||
|
var unitPrice = R(l.UnitPrice);
|
||||||
|
var discount = R(l.Discount);
|
||||||
|
var lineTotal = l.Quantity * unitPrice - discount;
|
||||||
|
sale.Lines.Add(new RetailSaleLine
|
||||||
|
{
|
||||||
|
ProductId = l.ProductId,
|
||||||
|
Quantity = l.Quantity,
|
||||||
|
UnitPrice = unitPrice,
|
||||||
|
Discount = discount,
|
||||||
|
LineTotal = lineTotal,
|
||||||
|
VatPercent = l.VatPercent,
|
||||||
|
SortOrder = order++,
|
||||||
|
});
|
||||||
|
subtotal += l.Quantity * unitPrice;
|
||||||
|
discountTotal += discount;
|
||||||
|
}
|
||||||
|
sale.Subtotal = subtotal;
|
||||||
|
sale.DiscountTotal = discountTotal;
|
||||||
|
sale.Total = subtotal - discountTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var prefix = $"ПР-{date.Year}-";
|
||||||
|
var lastNumber = await _db.RetailSales
|
||||||
|
.Where(s => s.Number.StartsWith(prefix))
|
||||||
|
.OrderByDescending(s => s.Number)
|
||||||
|
.Select(s => s.Number)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
var seq = 1;
|
||||||
|
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
|
||||||
|
seq = last + 1;
|
||||||
|
return $"{prefix}{seq:D6}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<RetailSaleDto?> GetInternal(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var row = await (from s in _db.RetailSales.AsNoTracking()
|
||||||
|
join st in _db.Stores on s.StoreId equals st.Id
|
||||||
|
join cu in _db.Currencies on s.CurrencyId equals cu.Id
|
||||||
|
where s.Id == id
|
||||||
|
select new { s, st, cu }).FirstOrDefaultAsync(ct);
|
||||||
|
if (row is null) return null;
|
||||||
|
|
||||||
|
string? rpName = row.s.RetailPointId is null ? null
|
||||||
|
: await _db.RetailPoints.Where(r => r.Id == row.s.RetailPointId).Select(r => r.Name).FirstOrDefaultAsync(ct);
|
||||||
|
string? cName = row.s.CustomerId is null ? null
|
||||||
|
: await _db.Counterparties.Where(c => c.Id == row.s.CustomerId).Select(c => c.Name).FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
var lines = await (from l in _db.RetailSaleLines.AsNoTracking()
|
||||||
|
join p in _db.Products on l.ProductId equals p.Id
|
||||||
|
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
|
||||||
|
where l.RetailSaleId == id
|
||||||
|
orderby l.SortOrder
|
||||||
|
select new RetailSaleLineDto(
|
||||||
|
l.Id, l.ProductId, p.Name, p.Article, u.Name,
|
||||||
|
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new RetailSaleDto(
|
||||||
|
row.s.Id, row.s.Number, row.s.Date, row.s.Status,
|
||||||
|
row.st.Id, row.st.Name,
|
||||||
|
row.s.RetailPointId, rpName,
|
||||||
|
row.s.CustomerId, cName,
|
||||||
|
row.cu.Id, row.cu.Code,
|
||||||
|
row.s.Subtotal, row.s.DiscountTotal, row.s.Total,
|
||||||
|
row.s.Payment, row.s.PaidCash, row.s.PaidCard,
|
||||||
|
row.s.Notes, row.s.PostedAt,
|
||||||
|
lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
using foodmarket.Application.Common;
|
||||||
|
using foodmarket.Domain.Organizations;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.SuperAdmin;
|
||||||
|
|
||||||
|
/// <summary>SuperAdmin: setup-status, dashboard статистика, audit log.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Roles = "SuperAdmin")]
|
||||||
|
[Route("api/super-admin")]
|
||||||
|
public class SuperAdminController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public SuperAdminController(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public record SetupStatusDto(bool NeedsSetup, int OrgCount);
|
||||||
|
|
||||||
|
[HttpGet("setup-status")]
|
||||||
|
public async Task<ActionResult<SetupStatusDto>> GetSetupStatus(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var count = await _db.Organizations.IgnoreQueryFilters().CountAsync(ct);
|
||||||
|
return new SetupStatusDto(NeedsSetup: count == 0, OrgCount: count);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DashboardStats(
|
||||||
|
int TotalOrgs, int ActiveOrgs, int ArchivedOrgs,
|
||||||
|
int TotalUsers, int ActiveUsers,
|
||||||
|
int RegistrationsLast30Days);
|
||||||
|
|
||||||
|
[HttpGet("dashboard")]
|
||||||
|
public async Task<ActionResult<DashboardStats>> Dashboard(CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Метрики SuperAdmin'а — кабинет SaaS-владельца, не операционные
|
||||||
|
// показатели магазинов. Биллинговые KPI (MRR, должники, платящие)
|
||||||
|
// считаем на UI как заглушки — отдельный модуль подписки в Phase 4+.
|
||||||
|
var monthAgo = DateTime.UtcNow.AddDays(-30);
|
||||||
|
return new DashboardStats(
|
||||||
|
TotalOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(ct),
|
||||||
|
ActiveOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => !o.IsArchived, ct),
|
||||||
|
ArchivedOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => o.IsArchived, ct),
|
||||||
|
TotalUsers: await _db.Users.CountAsync(ct),
|
||||||
|
ActiveUsers: await _db.Users.CountAsync(u => u.IsActive, ct),
|
||||||
|
RegistrationsLast30Days: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => o.CreatedAt >= monthAgo, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SystemSettingsDto(int ArchiveRetentionDays);
|
||||||
|
public record SystemSettingsInput(int ArchiveRetentionDays);
|
||||||
|
|
||||||
|
[HttpGet("settings")]
|
||||||
|
public async Task<ActionResult<SystemSettingsDto>> GetSettings(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var s = await _db.SystemSettings.FirstOrDefaultAsync(ct);
|
||||||
|
return new SystemSettingsDto(s?.ArchiveRetentionDays ?? 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("settings")]
|
||||||
|
public async Task<ActionResult<SystemSettingsDto>> UpdateSettings([FromBody] SystemSettingsInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (input.ArchiveRetentionDays < 0 || input.ArchiveRetentionDays > 3650)
|
||||||
|
return BadRequest(new { error = "ArchiveRetentionDays должен быть в диапазоне 0–3650." });
|
||||||
|
var s = await _db.SystemSettings.FirstOrDefaultAsync(ct);
|
||||||
|
var prev = s?.ArchiveRetentionDays;
|
||||||
|
if (s is null)
|
||||||
|
{
|
||||||
|
s = new SystemSettings { ArchiveRetentionDays = input.ArchiveRetentionDays };
|
||||||
|
_db.SystemSettings.Add(s);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
s.ArchiveRetentionDays = input.ArchiveRetentionDays;
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// Audit-log смены настройки
|
||||||
|
var userIdRaw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||||
|
Guid.TryParse(userIdRaw, out var uid);
|
||||||
|
_db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog
|
||||||
|
{
|
||||||
|
SuperAdminUserId = uid,
|
||||||
|
ActionType = "EditSystemSettings",
|
||||||
|
Description = $"ArchiveRetentionDays: {prev?.ToString() ?? "(default 30)"} → {input.ArchiveRetentionDays}",
|
||||||
|
ChangesJson = $"{{\"archiveRetentionDays\":{{\"from\":{prev?.ToString() ?? "30"},\"to\":{input.ArchiveRetentionDays}}}}}",
|
||||||
|
IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "",
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return new SystemSettingsDto(s.ArchiveRetentionDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AuditRow(
|
||||||
|
Guid Id, DateTime CreatedAt, Guid SuperAdminUserId,
|
||||||
|
string ActionType, Guid? OrganizationId, string? OrganizationName,
|
||||||
|
string? EntityType, Guid? EntityId, string? Description, string? Reason, string IpAddress);
|
||||||
|
|
||||||
|
[HttpGet("audit-log")]
|
||||||
|
public async Task<ActionResult<PagedResult<AuditRow>>> AuditLog(
|
||||||
|
[FromQuery] PagedRequest req,
|
||||||
|
[FromQuery] Guid? organizationId,
|
||||||
|
[FromQuery] string? actionType,
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = _db.SuperAdminAuditLogs.AsNoTracking().AsQueryable();
|
||||||
|
if (organizationId is not null) q = q.Where(x => x.OrganizationId == organizationId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(actionType)) q = q.Where(x => x.ActionType == actionType);
|
||||||
|
if (from is not null) q = q.Where(x => x.CreatedAt >= from);
|
||||||
|
if (to is not null) q = q.Where(x => x.CreatedAt <= to);
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var orgNames = await _db.Organizations.IgnoreQueryFilters()
|
||||||
|
.ToDictionaryAsync(o => o.Id, o => o.Name, ct);
|
||||||
|
var items = await q
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.Skip(req.Skip).Take(req.Take)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var rows = items.Select(x => new AuditRow(
|
||||||
|
x.Id, x.CreatedAt, x.SuperAdminUserId,
|
||||||
|
x.ActionType, x.OrganizationId,
|
||||||
|
x.OrganizationId is not null && orgNames.TryGetValue(x.OrganizationId.Value, out var n) ? n : null,
|
||||||
|
x.EntityType, x.EntityId, x.Description, x.Reason, x.IpAddress)).ToList();
|
||||||
|
return new PagedResult<AuditRow> { Items = rows, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
using foodmarket.Api.Seed;
|
||||||
|
using foodmarket.Application.Common;
|
||||||
|
using foodmarket.Domain.Organizations;
|
||||||
|
using foodmarket.Infrastructure.Identity;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.SuperAdmin;
|
||||||
|
|
||||||
|
/// <summary>SuperAdmin console: управление организациями. Все запросы
|
||||||
|
/// IgnoreQueryFilters() — обходим tenant-фильтр, видим всё. Все мутации
|
||||||
|
/// логируются в super_admin_audit_log.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Roles = "SuperAdmin")]
|
||||||
|
[Route("api/super-admin/organizations")]
|
||||||
|
public class SuperAdminOrganizationsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly UserManager<User> _userMgr;
|
||||||
|
|
||||||
|
public SuperAdminOrganizationsController(AppDbContext db, UserManager<User> userMgr)
|
||||||
|
{
|
||||||
|
_db = db; _userMgr = userMgr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record OrgRow(
|
||||||
|
Guid Id, string Name, string CountryCode,
|
||||||
|
bool IsActive, bool IsArchived, DateTime? ArchivedAt,
|
||||||
|
DateTime CreatedAt, int EmployeeCount, int ProductCount,
|
||||||
|
DateTime? LastLoginAt);
|
||||||
|
|
||||||
|
public record OrgDetail(
|
||||||
|
Guid Id, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
|
||||||
|
Guid? DefaultCurrencyId, string? DefaultCurrencyCode,
|
||||||
|
bool IsActive, bool IsArchived, DateTime? ArchivedAt,
|
||||||
|
Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail,
|
||||||
|
DateTime CreatedAt, DateTime? UpdatedAt,
|
||||||
|
int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth);
|
||||||
|
|
||||||
|
public record OrgInput(
|
||||||
|
string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
|
||||||
|
Guid? DefaultCurrencyId, Guid? AccountOwnerUserId);
|
||||||
|
|
||||||
|
public record CreateOrgRequest(OrgInput Org, string AdminLastName, string AdminFirstName,
|
||||||
|
string AdminEmail, string? AdminPosition);
|
||||||
|
|
||||||
|
public record CreateOrgResult(OrgDetail Organization, string AdminEmail, string AdminTempPassword);
|
||||||
|
|
||||||
|
public record ArchiveRequest(string ConfirmationName);
|
||||||
|
public record DeleteRequest(string ConfirmationName);
|
||||||
|
public record ChangeOwnerRequest(Guid NewOwnerUserId, string Reason);
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<PagedResult<OrgRow>>> List(
|
||||||
|
[FromQuery] PagedRequest req,
|
||||||
|
[FromQuery] bool? archived,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = _db.Organizations.IgnoreQueryFilters().AsNoTracking().AsQueryable();
|
||||||
|
if (archived is not null) q = q.Where(o => o.IsArchived == archived);
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
|
{
|
||||||
|
var s = req.Search.Trim().ToLower();
|
||||||
|
q = q.Where(o => o.Name.ToLower().Contains(s) || (o.Bin != null && o.Bin.Contains(s)));
|
||||||
|
}
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var items = await q
|
||||||
|
.OrderBy(o => o.IsArchived).ThenBy(o => o.Name)
|
||||||
|
.Skip(req.Skip).Take(req.Take)
|
||||||
|
.Select(o => new OrgRow(
|
||||||
|
o.Id, o.Name, o.CountryCode,
|
||||||
|
o.IsActive, o.IsArchived, o.ArchivedAt, o.CreatedAt,
|
||||||
|
_db.Employees.IgnoreQueryFilters().Count(e => e.OrganizationId == o.Id),
|
||||||
|
_db.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id),
|
||||||
|
_db.Users.Where(u => u.OrganizationId == o.Id && u.LastLoginAt != null)
|
||||||
|
.Max(u => (DateTime?)u.LastLoginAt)))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return new PagedResult<OrgRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<ActionResult<OrgDetail>> Get(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await ProjectAsync(id, ct);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<CreateOrgResult>> Create([FromBody] CreateOrgRequest input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var org = new Organization
|
||||||
|
{
|
||||||
|
Name = input.Org.Name, CountryCode = input.Org.CountryCode,
|
||||||
|
Bin = input.Org.Bin, Address = input.Org.Address,
|
||||||
|
Phone = input.Org.Phone, Email = input.Org.Email,
|
||||||
|
DefaultCurrencyId = input.Org.DefaultCurrencyId,
|
||||||
|
};
|
||||||
|
_db.Organizations.Add(org);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// Полный bootstrap tenant-сущностей: единицы измерения, типы цен,
|
||||||
|
// «Основной склад», «Касса 1», 6 ролей (2 системные + 4 кастомные шаблона).
|
||||||
|
// Один helper и в DevDataSeeder, и здесь — гарантирует одинаковое
|
||||||
|
// состояние новой орги независимо от пути создания.
|
||||||
|
await DevDataSeeder.SeedTenantReferencesAsync(_db, org.Id, ct);
|
||||||
|
|
||||||
|
var adminRole = await _db.EmployeeRoles.IgnoreQueryFilters()
|
||||||
|
.FirstAsync(r => r.OrganizationId == org.Id && r.IsSystem && r.Name == "Администратор", ct);
|
||||||
|
|
||||||
|
// AppUser админа
|
||||||
|
var existing = await _userMgr.FindByEmailAsync(input.AdminEmail);
|
||||||
|
if (existing is not null)
|
||||||
|
return BadRequest(new { error = $"Пользователь {input.AdminEmail} уже существует." });
|
||||||
|
var tempPwd = GenerateTempPassword();
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
UserName = input.AdminEmail, Email = input.AdminEmail, EmailConfirmed = true,
|
||||||
|
FullName = $"{input.AdminLastName} {input.AdminFirstName}".Trim(),
|
||||||
|
OrganizationId = org.Id, IsActive = true,
|
||||||
|
};
|
||||||
|
var ur = await _userMgr.CreateAsync(user, tempPwd);
|
||||||
|
if (!ur.Succeeded) return BadRequest(new { error = string.Join("; ", ur.Errors.Select(e => e.Description)) });
|
||||||
|
await _userMgr.AddToRoleAsync(user, "Admin");
|
||||||
|
org.AccountOwnerUserId = user.Id;
|
||||||
|
|
||||||
|
_db.Employees.Add(new Employee
|
||||||
|
{
|
||||||
|
OrganizationId = org.Id, UserId = user.Id,
|
||||||
|
LastName = input.AdminLastName, FirstName = input.AdminFirstName,
|
||||||
|
Position = input.AdminPosition ?? "Директор",
|
||||||
|
Email = input.AdminEmail, Role = adminRole, IsActive = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
await LogAsync("CreateOrg", org.Id, $"Создана организация «{org.Name}»", null, $"{{\"adminEmail\":\"{input.AdminEmail}\"}}", ct);
|
||||||
|
|
||||||
|
var detail = await ProjectAsync(org.Id, ct);
|
||||||
|
return new CreateOrgResult(detail!, input.AdminEmail, tempPwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] OrgInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (o is null) return NotFound();
|
||||||
|
var before = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
|
||||||
|
o.Name = input.Name; o.CountryCode = input.CountryCode;
|
||||||
|
o.Bin = input.Bin; o.Address = input.Address;
|
||||||
|
o.Phone = input.Phone; o.Email = input.Email;
|
||||||
|
o.DefaultCurrencyId = input.DefaultCurrencyId;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
var after = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
|
||||||
|
await LogAsync("EditOrg", o.Id, $"Изменены данные «{o.Name}»", null,
|
||||||
|
$"{{\"before\":{before},\"after\":{after}}}", ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/archive")]
|
||||||
|
public async Task<IActionResult> Archive(Guid id, [FromBody] ArchiveRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (o is null) return NotFound();
|
||||||
|
if (o.IsArchived) return Conflict(new { error = "Уже в архиве." });
|
||||||
|
if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно для подтверждения." });
|
||||||
|
o.IsArchived = true; o.ArchivedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
await LogAsync("ArchiveOrg", o.Id, $"Архивирована «{o.Name}»", null, "{}", ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/restore")]
|
||||||
|
public async Task<IActionResult> Restore(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (o is null) return NotFound();
|
||||||
|
if (!o.IsArchived) return Conflict(new { error = "Не в архиве." });
|
||||||
|
o.IsArchived = false; o.ArchivedAt = null;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
await LogAsync("RestoreOrg", o.Id, $"Восстановлена из архива «{o.Name}»", null, "{}", ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, [FromBody] DeleteRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (o is null) return NotFound();
|
||||||
|
if (!o.IsArchived || o.ArchivedAt is null)
|
||||||
|
return Conflict(new { error = "Удалить можно только архивированную организацию." });
|
||||||
|
var retentionDays = await _db.SystemSettings.Select(s => (int?)s.ArchiveRetentionDays).FirstOrDefaultAsync(ct) ?? 30;
|
||||||
|
if (o.ArchivedAt > DateTime.UtcNow.AddDays(-retentionDays))
|
||||||
|
return Conflict(new { error = $"Доступно через {retentionDays} дней архива." });
|
||||||
|
if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно." });
|
||||||
|
await LogAsync("DeleteOrg", o.Id, $"Удалена навсегда «{o.Name}»", null,
|
||||||
|
$"{{\"name\":\"{o.Name}\"}}", ct);
|
||||||
|
// Cascade delete domain entities is up to FK config; здесь просто Remove,
|
||||||
|
// EF выкинет ошибку если есть restrict-связи — оператор увидит и решит.
|
||||||
|
_db.Organizations.Remove(o);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/change-owner")]
|
||||||
|
public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (o is null) return NotFound();
|
||||||
|
if (string.IsNullOrWhiteSpace(req.Reason)) return BadRequest(new { error = "Reason required." });
|
||||||
|
var user = await _userMgr.FindByIdAsync(req.NewOwnerUserId.ToString());
|
||||||
|
if (user is null || user.OrganizationId != o.Id)
|
||||||
|
return BadRequest(new { error = "Пользователь не найден или не принадлежит этой организации." });
|
||||||
|
var prev = o.AccountOwnerUserId;
|
||||||
|
o.AccountOwnerUserId = req.NewOwnerUserId;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
await LogAsync("ChangeOwner", o.Id, $"Сменён владелец «{o.Name}»", req.Reason,
|
||||||
|
$"{{\"from\":\"{prev}\",\"to\":\"{req.NewOwnerUserId}\"}}", ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OrgDetail?> ProjectAsync(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var o = await _db.Organizations.IgnoreQueryFilters().AsNoTracking()
|
||||||
|
.Include(x => x.DefaultCurrency)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (o is null) return null;
|
||||||
|
var emp = await _db.Employees.IgnoreQueryFilters().CountAsync(e => e.OrganizationId == id, ct);
|
||||||
|
var prod = await _db.Products.IgnoreQueryFilters().CountAsync(p => p.OrganizationId == id, ct);
|
||||||
|
var cp = await _db.Counterparties.IgnoreQueryFilters().CountAsync(c => c.OrganizationId == id, ct);
|
||||||
|
var monthAgo = DateTime.UtcNow.AddDays(-30);
|
||||||
|
var supplies = await _db.Supplies.IgnoreQueryFilters()
|
||||||
|
.CountAsync(s => s.OrganizationId == id && s.Date >= monthAgo, ct);
|
||||||
|
User? owner = null;
|
||||||
|
if (o.AccountOwnerUserId is not null)
|
||||||
|
owner = await _userMgr.FindByIdAsync(o.AccountOwnerUserId.ToString()!);
|
||||||
|
return new OrgDetail(
|
||||||
|
o.Id, o.Name, o.CountryCode, o.Bin, o.Address, o.Phone, o.Email,
|
||||||
|
o.DefaultCurrencyId, o.DefaultCurrency?.Code,
|
||||||
|
o.IsActive, o.IsArchived, o.ArchivedAt,
|
||||||
|
o.AccountOwnerUserId, owner?.FullName, owner?.Email,
|
||||||
|
o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userIdRaw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||||
|
Guid.TryParse(userIdRaw, out var uid);
|
||||||
|
_db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog
|
||||||
|
{
|
||||||
|
SuperAdminUserId = uid,
|
||||||
|
ActionType = actionType, OrganizationId = orgId,
|
||||||
|
Description = description, Reason = reason,
|
||||||
|
ChangesJson = changesJson,
|
||||||
|
IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "",
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateTempPassword()
|
||||||
|
{
|
||||||
|
const string lower = "abcdefghkmnpqrstuvwxyz";
|
||||||
|
const string upper = "ABCDEFGHKMNPQRSTUVWXYZ";
|
||||||
|
const string digits = "23456789";
|
||||||
|
const string special = "!@#$%&*";
|
||||||
|
var rnd = new Random();
|
||||||
|
var chars = new List<char>
|
||||||
|
{
|
||||||
|
upper[rnd.Next(upper.Length)],
|
||||||
|
lower[rnd.Next(lower.Length)],
|
||||||
|
digits[rnd.Next(digits.Length)],
|
||||||
|
special[rnd.Next(special.Length)],
|
||||||
|
};
|
||||||
|
var pool = lower + upper + digits;
|
||||||
|
for (var i = 0; i < 8; i++) chars.Add(pool[rnd.Next(pool.Length)]);
|
||||||
|
return new string(chars.OrderBy(_ => rnd.Next()).ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,31 @@ public class HttpContextTenantContext : ITenantContext
|
||||||
{
|
{
|
||||||
public const string OrganizationClaim = "org_id";
|
public const string OrganizationClaim = "org_id";
|
||||||
public const string SuperAdminRole = "SuperAdmin";
|
public const string SuperAdminRole = "SuperAdmin";
|
||||||
|
/// <summary>HTTP-заголовок переключения tenant'а для SuperAdmin'а: «открыть как…»
|
||||||
|
/// конкретную организацию. Без этого header'а супер-админ не привязан к
|
||||||
|
/// конкретной орге (видит всё через query-filter bypass).</summary>
|
||||||
|
public const string OrgOverrideHeader = "X-Org-Override";
|
||||||
|
/// <summary>HTTP-заголовок включения edit-mode в режиме «открыть как…»:
|
||||||
|
/// клиент шлёт reason (≥10 символов), сервер разрешает мутации и пишет
|
||||||
|
/// каждую запись в SuperAdminAuditLog с этой причиной. Срок действия
|
||||||
|
/// токена ограничен 30 минутами на стороне фронта (после — UI отключает).</summary>
|
||||||
|
public const string EditReasonHeader = "X-Org-Override-Reason";
|
||||||
|
|
||||||
|
// Override для background задач (например, импорт из MoySklad): сохраняем tenant
|
||||||
|
// в AsyncLocal на время выполнения фонового Task. HttpContext там отсутствует,
|
||||||
|
// но query-filter'у по-прежнему нужен orgId — вот его и берём из override.
|
||||||
|
private static readonly AsyncLocal<(Guid? OrgId, bool IsSuper)?> _override = new();
|
||||||
|
|
||||||
|
public static IDisposable UseOverride(Guid orgId, bool isSuperAdmin = false)
|
||||||
|
{
|
||||||
|
_override.Value = (orgId, isSuperAdmin);
|
||||||
|
return new OverrideScope();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class OverrideScope : IDisposable
|
||||||
|
{
|
||||||
|
public void Dispose() => _override.Value = null;
|
||||||
|
}
|
||||||
|
|
||||||
private readonly IHttpContextAccessor _accessor;
|
private readonly IHttpContextAccessor _accessor;
|
||||||
|
|
||||||
|
|
@ -15,16 +40,53 @@ public HttpContextTenantContext(IHttpContextAccessor accessor)
|
||||||
_accessor = accessor;
|
_accessor = accessor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsAuthenticated => _accessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false;
|
public bool IsAuthenticated
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_override.Value is not null) return true;
|
||||||
|
return _accessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsSuperAdmin => _accessor.HttpContext?.User?.IsInRole(SuperAdminRole) ?? false;
|
public bool IsSuperAdmin
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_override.Value is { IsSuper: var s }) return s;
|
||||||
|
return _accessor.HttpContext?.User?.IsInRole(SuperAdminRole) ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Guid? OrganizationId
|
public Guid? OrganizationId
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
|
if (_override.Value is { OrgId: var o }) return o;
|
||||||
|
if (TryGetHttpOverrideOrg(out var http)) return http;
|
||||||
var claim = _accessor.HttpContext?.User?.FindFirst(OrganizationClaim)?.Value;
|
var claim = _accessor.HttpContext?.User?.FindFirst(OrganizationClaim)?.Value;
|
||||||
return Guid.TryParse(claim, out var id) ? id : null;
|
return Guid.TryParse(claim, out var id) ? id : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsTenantOverride
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// AsyncLocal-override (background tasks) не считаем «открыть как…» —
|
||||||
|
// он используется в импорте/Hangfire, где и так применяется фильтр
|
||||||
|
// (IsSuper=false по умолчанию). Override-режим — только HTTP-header.
|
||||||
|
return TryGetHttpOverrideOrg(out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetHttpOverrideOrg(out Guid orgId)
|
||||||
|
{
|
||||||
|
orgId = Guid.Empty;
|
||||||
|
var ctx = _accessor.HttpContext;
|
||||||
|
if (ctx is null) return false;
|
||||||
|
if (ctx.User?.IsInRole(SuperAdminRole) != true) return false;
|
||||||
|
if (!ctx.Request.Headers.TryGetValue(OrgOverrideHeader, out var headerVal)) return false;
|
||||||
|
return Guid.TryParse(headerVal.ToString(), out orgId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Infrastructure.Tenancy;
|
||||||
|
|
||||||
|
/// <summary>Когда SuperAdmin прислал X-Org-Override — режим «открыть как…»
|
||||||
|
/// должен быть строго read-only (Phase 2). Любая мутация (PUT/POST/DELETE/PATCH)
|
||||||
|
/// на любой endpoint, кроме самих /api/super-admin/* (управление орг)
|
||||||
|
/// и /api/auth/* (refresh tokens) — отбивается 403 с понятным сообщением.
|
||||||
|
/// Phase 3 (edit-mode с reason + audit-trail) будет ослаблять ограничение.</summary>
|
||||||
|
public class ReadonlyOverrideMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
public ReadonlyOverrideMiddleware(RequestDelegate next) => _next = next;
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext ctx)
|
||||||
|
{
|
||||||
|
if (!ctx.Request.Headers.ContainsKey(HttpContextTenantContext.OrgOverrideHeader))
|
||||||
|
{
|
||||||
|
await _next(ctx); return;
|
||||||
|
}
|
||||||
|
var method = ctx.Request.Method.ToUpperInvariant();
|
||||||
|
if (method == "GET" || method == "HEAD" || method == "OPTIONS")
|
||||||
|
{
|
||||||
|
await _next(ctx); return;
|
||||||
|
}
|
||||||
|
var path = ctx.Request.Path.Value ?? "";
|
||||||
|
if (path.StartsWith("/api/super-admin/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| path.StartsWith("/connect/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await _next(ctx); return;
|
||||||
|
}
|
||||||
|
// Phase 3: edit-mode — SuperAdmin прислал X-Org-Override-Reason ≥ 10
|
||||||
|
// символов, мутации пропускаем. Запись в audit-log делает Pipeline-
|
||||||
|
// фильтр (см. SuperAdminEditAuditFilter) после успешного ответа.
|
||||||
|
if (ctx.Request.Headers.TryGetValue(HttpContextTenantContext.EditReasonHeader, out var reason)
|
||||||
|
&& (reason.ToString() ?? "").Trim().Length >= 10)
|
||||||
|
{
|
||||||
|
await _next(ctx); return;
|
||||||
|
}
|
||||||
|
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new
|
||||||
|
{
|
||||||
|
error = "Read-only mode: вы в режиме «Супер-админ → открыть как…», мутации запрещены. Включите edit-mode с указанием причины или выйдите из режима.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
using foodmarket.Domain.Organizations;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Infrastructure.Tenancy;
|
||||||
|
|
||||||
|
/// <summary>Phase 3 audit-trail: когда SuperAdmin в режиме «открыть как…» с
|
||||||
|
/// edit-mode (X-Org-Override + X-Org-Override-Reason ≥ 10 символов) делает
|
||||||
|
/// успешную мутацию — пишем запись в SuperAdminAuditLog с reason, путём,
|
||||||
|
/// methodом, status code'ом. Запускается ПОСЛЕ controller'а, только если
|
||||||
|
/// ответ 2xx (успех) — неудачные запросы не засоряют журнал.</summary>
|
||||||
|
public class SuperAdminEditAuditFilter : IAsyncActionFilter
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public SuperAdminEditAuditFilter(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||||
|
{
|
||||||
|
var ctx = context.HttpContext;
|
||||||
|
var method = ctx.Request.Method.ToUpperInvariant();
|
||||||
|
var hasOverride = ctx.Request.Headers.ContainsKey(HttpContextTenantContext.OrgOverrideHeader);
|
||||||
|
var hasReason = ctx.Request.Headers.TryGetValue(HttpContextTenantContext.EditReasonHeader, out var reasonHv)
|
||||||
|
&& (reasonHv.ToString() ?? "").Trim().Length >= 10;
|
||||||
|
var isMutation = method is "POST" or "PUT" or "PATCH" or "DELETE";
|
||||||
|
var isSuper = ctx.User?.IsInRole(HttpContextTenantContext.SuperAdminRole) == true;
|
||||||
|
|
||||||
|
var executed = await next();
|
||||||
|
|
||||||
|
if (!hasOverride || !hasReason || !isMutation || !isSuper) return;
|
||||||
|
if (executed.Exception is not null) return;
|
||||||
|
var status = ctx.Response.StatusCode;
|
||||||
|
if (status < 200 || status >= 300) return;
|
||||||
|
|
||||||
|
var orgGuid = Guid.TryParse(ctx.Request.Headers[HttpContextTenantContext.OrgOverrideHeader].ToString(), out var g) ? g : (Guid?)null;
|
||||||
|
var userIdRaw = ctx.User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? ctx.User?.FindFirstValue("sub");
|
||||||
|
Guid.TryParse(userIdRaw, out var uid);
|
||||||
|
_db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog
|
||||||
|
{
|
||||||
|
SuperAdminUserId = uid,
|
||||||
|
ActionType = "EditEntity",
|
||||||
|
OrganizationId = orgGuid,
|
||||||
|
Description = $"{method} {ctx.Request.Path.Value} → {status}",
|
||||||
|
Reason = reasonHv.ToString().Trim(),
|
||||||
|
ChangesJson = "{}",
|
||||||
|
IpAddress = ctx.Connection?.RemoteIpAddress?.ToString() ?? "",
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -66,9 +66,25 @@
|
||||||
opts.AcceptAnonymousClients();
|
opts.AcceptAnonymousClients();
|
||||||
opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api");
|
opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api");
|
||||||
|
|
||||||
|
// Persistent dev keys: RSA key stored in src/food-market.api/App_Data/openiddict-dev-key.xml.
|
||||||
|
// Survives API restarts so issued tokens remain valid across rebuilds.
|
||||||
|
// Never commit this file (it's in .gitignore via App_Data/). Production must use real certificates.
|
||||||
|
var keyPath = Path.Combine(builder.Environment.ContentRootPath, "App_Data", "openiddict-dev-key.xml");
|
||||||
|
var rsa = System.Security.Cryptography.RSA.Create(2048);
|
||||||
|
if (File.Exists(keyPath))
|
||||||
|
{
|
||||||
|
rsa.FromXmlString(File.ReadAllText(keyPath));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(keyPath)!);
|
||||||
|
File.WriteAllText(keyPath, rsa.ToXmlString(includePrivateParameters: true));
|
||||||
|
}
|
||||||
|
var devKey = new Microsoft.IdentityModel.Tokens.RsaSecurityKey(rsa) { KeyId = "food-market-dev" };
|
||||||
|
opts.AddEncryptionKey(devKey);
|
||||||
|
opts.AddSigningKey(devKey);
|
||||||
if (builder.Environment.IsDevelopment())
|
if (builder.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
opts.AddEphemeralEncryptionKey().AddEphemeralSigningKey();
|
|
||||||
opts.DisableAccessTokenEncryption();
|
opts.DisableAccessTokenEncryption();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,17 +103,52 @@
|
||||||
opts.UseAspNetCore();
|
opts.UseAspNetCore();
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
|
// Force OpenIddict validation to handle ALL [Authorize] challenges — otherwise AddIdentity's
|
||||||
builder.Services.AddAuthorization();
|
// cookie scheme takes over and 401 becomes a 302 redirect to /Account/Login for API calls.
|
||||||
|
builder.Services.AddAuthentication(options =>
|
||||||
|
{
|
||||||
|
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||||||
|
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||||||
|
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||||||
|
});
|
||||||
|
builder.Services.AddAuthorization(opts =>
|
||||||
|
{
|
||||||
|
// Check the "role" claim explicitly — robust against role-claim-type mismatches between
|
||||||
|
// OpenIddict validation identity and the default ClaimTypes.Role uri.
|
||||||
|
opts.AddPolicy("AdminAccess", p => p.RequireAssertion(ctx =>
|
||||||
|
ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin"))));
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
|
||||||
|
builder.Services.AddControllers(o =>
|
||||||
|
{
|
||||||
|
// Глобальный action filter — пишет audit-log при успешных мутациях
|
||||||
|
// в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3).
|
||||||
|
o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
|
||||||
|
});
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
|
// MoySklad import integration. Auto-decompress gzip responses from MoySklad's edge.
|
||||||
|
builder.Services.AddHttpClient<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladClient>()
|
||||||
|
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||||
|
{
|
||||||
|
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
|
||||||
|
});
|
||||||
|
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
|
||||||
|
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
|
||||||
|
|
||||||
|
// Inventory
|
||||||
|
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
|
||||||
|
|
||||||
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
||||||
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
||||||
builder.Services.AddHostedService<DevDataSeeder>();
|
builder.Services.AddHostedService<DevDataSeeder>();
|
||||||
builder.Services.AddHostedService<DemoCatalogSeeder>();
|
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
|
||||||
|
// DemoCatalogSeeder disabled: real catalog is imported from MoySklad.
|
||||||
|
// Keep the file as reference for anyone starting without MoySklad access —
|
||||||
|
// just re-register here to turn demo data back on.
|
||||||
|
// builder.Services.AddHostedService<DemoCatalogSeeder>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|
@ -105,6 +156,19 @@
|
||||||
app.UseCors(CorsPolicy);
|
app.UseCors(CorsPolicy);
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
// SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но
|
||||||
|
// только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*.
|
||||||
|
app.UseMiddleware<foodmarket.Api.Infrastructure.Tenancy.ReadonlyOverrideMiddleware>();
|
||||||
|
|
||||||
|
// Статика товарных изображений: физически /app/uploads (volume в compose),
|
||||||
|
// публичный URL /uploads/... — раздаются public, без auth.
|
||||||
|
var uploadsDir = System.IO.Path.Combine(app.Environment.ContentRootPath, "uploads");
|
||||||
|
System.IO.Directory.CreateDirectory(uploadsDir);
|
||||||
|
app.UseStaticFiles(new Microsoft.AspNetCore.Builder.StaticFileOptions
|
||||||
|
{
|
||||||
|
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsDir),
|
||||||
|
RequestPath = "/uploads",
|
||||||
|
});
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
|
|
@ -116,6 +180,21 @@
|
||||||
|
|
||||||
app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow }));
|
app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow }));
|
||||||
|
|
||||||
|
app.MapGet("/api/_debug/whoami", (HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
var identity = ctx.User.Identity as System.Security.Claims.ClaimsIdentity;
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
isAuthenticated = ctx.User.Identity?.IsAuthenticated,
|
||||||
|
authType = ctx.User.Identity?.AuthenticationType,
|
||||||
|
nameClaimType = identity?.NameClaimType,
|
||||||
|
roleClaimType = identity?.RoleClaimType,
|
||||||
|
isInRoleAdmin = ctx.User.IsInRole("Admin"),
|
||||||
|
hasAdminRoleClaim = ctx.User.HasClaim(c => c.Type == Claims.Role && c.Value == "Admin"),
|
||||||
|
claims = ctx.User.Claims.Select(c => new { c.Type, c.Value }),
|
||||||
|
});
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/me", (HttpContext ctx) =>
|
app.MapGet("/api/me", (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var user = ctx.User;
|
var user = ctx.User;
|
||||||
|
|
@ -129,9 +208,10 @@
|
||||||
});
|
});
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
// Apply migrations on every startup (idempotent). Without this, fresh
|
||||||
|
// stage/prod deploys land on an empty DB and OpenIddict seeders fail.
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
using var scope = app.Services.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
db.Database.Migrate();
|
db.Database.Migrate();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,21 +32,18 @@ public async Task StartAsync(CancellationToken ct)
|
||||||
var hasProducts = await db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
|
var hasProducts = await db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
|
||||||
if (hasProducts) return;
|
if (hasProducts) return;
|
||||||
|
|
||||||
var defaultVat = await db.VatRates.IgnoreQueryFilters()
|
// KZ дефолт — 16% (из Country.VatRate), льготные категории (хлеб) — 0%.
|
||||||
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.IsDefault, ct);
|
const decimal vatDefault = 16m;
|
||||||
var noVat = await db.VatRates.IgnoreQueryFilters()
|
const decimal vat0 = 0m;
|
||||||
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.Percent == 0m, ct);
|
|
||||||
|
|
||||||
var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "шт", ct);
|
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "796", ct);
|
||||||
var unitKg = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
var unitKg = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "кг", ct);
|
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "166", ct);
|
||||||
var unitL = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
var unitL = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "л", ct);
|
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "112", ct);
|
||||||
|
|
||||||
if (defaultVat is null || unitSht is null) return;
|
if (unitSht is null) return;
|
||||||
var vat = defaultVat.Id;
|
|
||||||
var vat0 = noVat?.Id ?? vat;
|
|
||||||
|
|
||||||
var retailPriceType = await db.PriceTypes.IgnoreQueryFilters()
|
var retailPriceType = await db.PriceTypes.IgnoreQueryFilters()
|
||||||
.FirstOrDefaultAsync(p => p.OrganizationId == orgId && p.IsRetail, ct);
|
.FirstOrDefaultAsync(p => p.OrganizationId == orgId && p.IsRetail, ct);
|
||||||
|
|
@ -69,12 +66,14 @@ Guid AddGroup(string name, Guid? parentId)
|
||||||
db.ProductGroups.Add(new ProductGroup
|
db.ProductGroups.Add(new ProductGroup
|
||||||
{
|
{
|
||||||
Id = id, OrganizationId = orgId, Name = name, ParentId = parentId,
|
Id = id, OrganizationId = orgId, Name = name, ParentId = parentId,
|
||||||
Path = path, SortOrder = groups.Count, IsActive = true,
|
|
||||||
});
|
});
|
||||||
groups[path] = id;
|
groups[path] = id;
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// «Продукты питания» — дефолтная группа, в которую попадают новые товары,
|
||||||
|
// если пользователь не указал группу явно. Не должна удаляться.
|
||||||
|
AddGroup("Продукты питания", null);
|
||||||
var gDrinks = AddGroup("Напитки", null);
|
var gDrinks = AddGroup("Напитки", null);
|
||||||
var gDrinksNon = AddGroup("Безалкогольные", gDrinks);
|
var gDrinksNon = AddGroup("Безалкогольные", gDrinks);
|
||||||
AddGroup("Алкогольные", gDrinks);
|
AddGroup("Алкогольные", gDrinks);
|
||||||
|
|
@ -88,67 +87,65 @@ Guid AddGroup(string name, Guid? parentId)
|
||||||
var supplier1 = new Counterparty
|
var supplier1 = new Counterparty
|
||||||
{
|
{
|
||||||
OrganizationId = orgId, Name = "ТОО «Продтрейд»", LegalName = "Товарищество с ограниченной ответственностью «Продтрейд»",
|
OrganizationId = orgId, Name = "ТОО «Продтрейд»", LegalName = "Товарищество с ограниченной ответственностью «Продтрейд»",
|
||||||
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.LegalEntity,
|
Type = CounterpartyType.LegalEntity,
|
||||||
Bin = "100140005678", CountryId = kz?.Id,
|
Bin = "100140005678", CountryId = kz?.Id,
|
||||||
Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01",
|
Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01",
|
||||||
Email = "order@prodtrade.kz", BankName = "Kaspi Bank", BankAccount = "KZ000000000000000001", Bik = "CASPKZKA",
|
Email = "order@prodtrade.kz", BankName = "Kaspi Bank", BankAccount = "KZ000000000000000001", Bik = "CASPKZKA",
|
||||||
IsActive = true,
|
|
||||||
};
|
};
|
||||||
var supplier2 = new Counterparty
|
var supplier2 = new Counterparty
|
||||||
{
|
{
|
||||||
OrganizationId = orgId, Name = "ИП Иванов А.С.",
|
OrganizationId = orgId, Name = "ИП Иванов А.С.",
|
||||||
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.Individual,
|
Type = CounterpartyType.Individual,
|
||||||
Iin = "850101300000", CountryId = kz?.Id,
|
Iin = "850101300000", CountryId = kz?.Id,
|
||||||
Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей",
|
Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей",
|
||||||
IsActive = true,
|
|
||||||
};
|
};
|
||||||
db.Counterparties.AddRange(supplier1, supplier2);
|
db.Counterparties.AddRange(supplier1, supplier2);
|
||||||
|
|
||||||
// Barcodes use generic internal range (2xxxxxxxxxxxx) to avoid colliding with real products.
|
// Barcodes use generic internal range (2xxxxxxxxxxxx) to avoid colliding with real products.
|
||||||
// When user does real приёмка, real barcodes will overwrite.
|
// When user does real приёмка, real barcodes will overwrite.
|
||||||
var demo = new (string Name, Guid Group, decimal RetailPrice, string Article, string Barcode, Guid? Country, bool IsWeighed, Guid Unit, bool IsAlcohol)[]
|
var demo = new (string Name, Guid Group, decimal RetailPrice, string Article, string Barcode, Guid? Country, bool IsWeighed, Guid Unit)[]
|
||||||
{
|
{
|
||||||
// Напитки — безалкогольные
|
// Напитки — безалкогольные
|
||||||
("Вода питьевая «Тассай» 0.5л", gDrinksNon, 220, "DR-WAT-001", "2000000000018", kz?.Id, false, unitSht.Id, false),
|
("Вода питьевая «Тассай» 0.5л", gDrinksNon, 220, "DR-WAT-001", "2000000000018", kz?.Id, false, unitSht.Id),
|
||||||
("Вода питьевая «Тассай» 1.5л", gDrinksNon, 380, "DR-WAT-002", "2000000000025", kz?.Id, false, unitSht.Id, false),
|
("Вода питьевая «Тассай» 1.5л", gDrinksNon, 380, "DR-WAT-002", "2000000000025", kz?.Id, false, unitSht.Id),
|
||||||
("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id, false),
|
("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id),
|
||||||
("Сок «Да-Да» апельсин 1л", gDrinksNon, 650, "DR-JUC-002", "2000000000049", kz?.Id, false, unitSht.Id, false),
|
("Сок «Да-Да» апельсин 1л", gDrinksNon, 650, "DR-JUC-002", "2000000000049", kz?.Id, false, unitSht.Id),
|
||||||
("Кока-кола 0.5л", gDrinksNon, 420, "DR-SOD-001", "2000000000056", ru?.Id, false, unitSht.Id, false),
|
("Кока-кола 0.5л", gDrinksNon, 420, "DR-SOD-001", "2000000000056", ru?.Id, false, unitSht.Id),
|
||||||
("Пепси 0.5л", gDrinksNon, 420, "DR-SOD-002", "2000000000063", ru?.Id, false, unitSht.Id, false),
|
("Пепси 0.5л", gDrinksNon, 420, "DR-SOD-002", "2000000000063", ru?.Id, false, unitSht.Id),
|
||||||
("Спрайт 0.5л", gDrinksNon, 420, "DR-SOD-003", "2000000000070", ru?.Id, false, unitSht.Id, false),
|
("Спрайт 0.5л", gDrinksNon, 420, "DR-SOD-003", "2000000000070", ru?.Id, false, unitSht.Id),
|
||||||
("Чай чёрный «Пиала» пакетированный, 100шт", gDrinksNon, 1250, "DR-TEA-001", "2000000000087", kz?.Id, false, unitSht.Id, false),
|
("Чай чёрный «Пиала» пакетированный, 100шт", gDrinksNon, 1250, "DR-TEA-001", "2000000000087", kz?.Id, false, unitSht.Id),
|
||||||
// Молочные
|
// Молочные
|
||||||
("Молоко «Простоквашино» 2.5% 930мл", gDairy, 720, "DAI-MLK-001", "2000000000094", ru?.Id, false, unitSht.Id, false),
|
("Молоко «Простоквашино» 2.5% 930мл", gDairy, 720, "DAI-MLK-001", "2000000000094", ru?.Id, false, unitSht.Id),
|
||||||
("Молоко «Food Master» 3.2% 1л", gDairy, 620, "DAI-MLK-002", "2000000000100", kz?.Id, false, unitSht.Id, false),
|
("Молоко «Food Master» 3.2% 1л", gDairy, 620, "DAI-MLK-002", "2000000000100", kz?.Id, false, unitSht.Id),
|
||||||
("Кефир «Food Master» 2.5% 1л", gDairy, 590, "DAI-KEF-001", "2000000000117", kz?.Id, false, unitSht.Id, false),
|
("Кефир «Food Master» 2.5% 1л", gDairy, 590, "DAI-KEF-001", "2000000000117", kz?.Id, false, unitSht.Id),
|
||||||
("Йогурт «Актимель» клубника 100мл", gDairy, 180, "DAI-YOG-001", "2000000000124", null, false, unitSht.Id, false),
|
("Йогурт «Актимель» клубника 100мл", gDairy, 180, "DAI-YOG-001", "2000000000124", null, false, unitSht.Id),
|
||||||
("Творог «Простоквашино» 5% 200г", gDairy, 590, "DAI-CRD-001", "2000000000131", ru?.Id, false, unitSht.Id, false),
|
("Творог «Простоквашино» 5% 200г", gDairy, 590, "DAI-CRD-001", "2000000000131", ru?.Id, false, unitSht.Id),
|
||||||
("Сметана «Food Master» 20% 400г", gDairy, 850, "DAI-SCR-001", "2000000000148", kz?.Id, false, unitSht.Id, false),
|
("Сметана «Food Master» 20% 400г", gDairy, 850, "DAI-SCR-001", "2000000000148", kz?.Id, false, unitSht.Id),
|
||||||
("Сыр «Российский» 45%", gDairy, 3200, "DAI-CHE-001", "2000000000155", kz?.Id, true, unitKg?.Id ?? unitSht.Id, false),
|
("Сыр «Российский» 45%", gDairy, 3200, "DAI-CHE-001", "2000000000155", kz?.Id, true, unitKg?.Id ?? unitSht.Id),
|
||||||
// Хлеб и выпечка
|
// Хлеб и выпечка
|
||||||
("Хлеб «Семейный» нарезной 500г", gBakery, 180, "BAK-BRD-001", "2000000000162", kz?.Id, false, unitSht.Id, false),
|
("Хлеб «Семейный» нарезной 500г", gBakery, 180, "BAK-BRD-001", "2000000000162", kz?.Id, false, unitSht.Id),
|
||||||
("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id, false),
|
("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id),
|
||||||
("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id, false),
|
("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id),
|
||||||
("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", kz?.Id, false, unitSht.Id, false),
|
("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", kz?.Id, false, unitSht.Id),
|
||||||
// Кондитерские
|
// Кондитерские
|
||||||
("Шоколад «Рахат» молочный 100г", gSweets, 650, "SW-CHO-001", "2000000000209", kz?.Id, false, unitSht.Id, false),
|
("Шоколад «Рахат» молочный 100г", gSweets, 650, "SW-CHO-001", "2000000000209", kz?.Id, false, unitSht.Id),
|
||||||
("Шоколад «Алёнка» 100г", gSweets, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id, false),
|
("Шоколад «Алёнка» 100г", gSweets, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id),
|
||||||
("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id, false),
|
("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id),
|
||||||
("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id, false),
|
("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id),
|
||||||
("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", kz?.Id, false, unitSht.Id, false),
|
("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", kz?.Id, false, unitSht.Id),
|
||||||
// Бакалея
|
// Бакалея
|
||||||
("Сахар-песок 1кг", gGrocery, 480, "GRO-SUG-001", "2000000000254", kz?.Id, false, unitSht.Id, false),
|
("Сахар-песок 1кг", gGrocery, 480, "GRO-SUG-001", "2000000000254", kz?.Id, false, unitSht.Id),
|
||||||
("Соль поваренная 1кг", gGrocery, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id, false),
|
("Соль поваренная 1кг", gGrocery, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id),
|
||||||
("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id, false),
|
("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id),
|
||||||
("Гречка «Мистраль» 900г", gGrocery, 990, "GRO-GRE-001", "2000000000285", ru?.Id, false, unitSht.Id, false),
|
("Гречка «Мистраль» 900г", gGrocery, 990, "GRO-GRE-001", "2000000000285", ru?.Id, false, unitSht.Id),
|
||||||
("Макароны «Corona» 500г", gGrocery, 420, "GRO-PAS-001", "2000000000292", kz?.Id, false, unitSht.Id, false),
|
("Макароны «Corona» 500г", gGrocery, 420, "GRO-PAS-001", "2000000000292", kz?.Id, false, unitSht.Id),
|
||||||
("Масло подсолнечное «Шедевр» 1л", gGrocery, 920, "GRO-OIL-001", "2000000000308", kz?.Id, false, unitL?.Id ?? unitSht.Id, false),
|
("Масло подсолнечное «Шедевр» 1л", gGrocery, 920, "GRO-OIL-001", "2000000000308", kz?.Id, false, unitL?.Id ?? unitSht.Id),
|
||||||
("Кофе «Якобс Монарх» 95г растворимый", gGrocery, 2890, "GRO-COF-001", "2000000000315", null, false, unitSht.Id, false),
|
("Кофе «Якобс Монарх» 95г растворимый", gGrocery, 2890, "GRO-COF-001", "2000000000315", null, false, unitSht.Id),
|
||||||
// Снеки
|
// Снеки
|
||||||
("Чипсы «Lays» сметана-лук 80г", gSnacks, 380, "SN-CHP-001", "2000000000322", kz?.Id, false, unitSht.Id, false),
|
("Чипсы «Lays» сметана-лук 80г", gSnacks, 380, "SN-CHP-001", "2000000000322", kz?.Id, false, unitSht.Id),
|
||||||
("Сухарики «3 корочки» ржаные 40г", gSnacks, 190, "SN-SBR-001", "2000000000339", ru?.Id, false, unitSht.Id, false),
|
("Сухарики «3 корочки» ржаные 40г", gSnacks, 190, "SN-SBR-001", "2000000000339", ru?.Id, false, unitSht.Id),
|
||||||
("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id, false),
|
("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id),
|
||||||
("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id, false),
|
("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id),
|
||||||
};
|
};
|
||||||
|
|
||||||
var products = demo.Select(d =>
|
var products = demo.Select(d =>
|
||||||
|
|
@ -159,14 +156,14 @@ Guid AddGroup(string name, Guid? parentId)
|
||||||
Name = d.Name,
|
Name = d.Name,
|
||||||
Article = d.Article,
|
Article = d.Article,
|
||||||
UnitOfMeasureId = d.Unit,
|
UnitOfMeasureId = d.Unit,
|
||||||
VatRateId = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
|
// Хлеб/батон/лепёшка — 0% (льготная категория в KZ).
|
||||||
? vat0 : vat,
|
Vat = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
|
||||||
|
? vat0 : vatDefault,
|
||||||
|
VatEnabled = !(d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")),
|
||||||
ProductGroupId = d.Group,
|
ProductGroupId = d.Group,
|
||||||
CountryOfOriginId = d.Country,
|
CountryOfOriginId = d.Country,
|
||||||
IsWeighed = d.IsWeighed,
|
Packaging = d.IsWeighed ? Packaging.Weight : Packaging.Piece,
|
||||||
IsAlcohol = d.IsAlcohol,
|
ReferencePrice = Math.Round(d.RetailPrice * 0.72m, 2),
|
||||||
IsActive = true,
|
|
||||||
PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2),
|
|
||||||
PurchaseCurrencyId = kzt.Id,
|
PurchaseCurrencyId = kzt.Id,
|
||||||
Prices =
|
Prices =
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,10 @@ public DevDataSeeder(IServiceProvider services, IHostEnvironment env)
|
||||||
|
|
||||||
public async Task StartAsync(CancellationToken ct)
|
public async Task StartAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!_env.IsDevelopment())
|
// Idempotent — runs in all envs to bootstrap a usable admin + demo org.
|
||||||
{
|
// Once first real user/org is set up via UI, rename/disable demo.
|
||||||
return;
|
// (Wired regardless of env so stage/prod first-deploy lands a working
|
||||||
}
|
// admin, otherwise nobody can log in.)
|
||||||
|
|
||||||
using var scope = _services.CreateScope();
|
using var scope = _services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
@ -38,6 +38,7 @@ public async Task StartAsync(CancellationToken ct)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var kzt = await db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct);
|
||||||
var demoOrg = await db.Organizations.FirstOrDefaultAsync(o => o.Name == "Demo Market", ct);
|
var demoOrg = await db.Organizations.FirstOrDefaultAsync(o => o.Name == "Demo Market", ct);
|
||||||
if (demoOrg is null)
|
if (demoOrg is null)
|
||||||
{
|
{
|
||||||
|
|
@ -48,11 +49,17 @@ public async Task StartAsync(CancellationToken ct)
|
||||||
Bin = "000000000000",
|
Bin = "000000000000",
|
||||||
Address = "Алматы, ул. Пример 1",
|
Address = "Алматы, ул. Пример 1",
|
||||||
Phone = "+7 (777) 000-00-00",
|
Phone = "+7 (777) 000-00-00",
|
||||||
Email = "demo@food-market.local"
|
Email = "demo@food-market.local",
|
||||||
|
DefaultCurrencyId = kzt?.Id,
|
||||||
};
|
};
|
||||||
db.Organizations.Add(demoOrg);
|
db.Organizations.Add(demoOrg);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
else if (demoOrg.DefaultCurrencyId is null && kzt is not null)
|
||||||
|
{
|
||||||
|
demoOrg.DefaultCurrencyId = kzt.Id;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
await SeedTenantReferencesAsync(db, demoOrg.Id, ct);
|
await SeedTenantReferencesAsync(db, demoOrg.Id, ct);
|
||||||
|
|
||||||
|
|
@ -71,40 +78,88 @@ public async Task StartAsync(CancellationToken ct)
|
||||||
var result = await userMgr.CreateAsync(admin, "Admin12345!");
|
var result = await userMgr.CreateAsync(admin, "Admin12345!");
|
||||||
if (result.Succeeded)
|
if (result.Succeeded)
|
||||||
{
|
{
|
||||||
await userMgr.AddToRoleAsync(admin, SystemRoles.Admin);
|
// Только SuperAdmin как Identity-роль. «Администратор» —
|
||||||
|
// организационная роль внутри Employee, не Identity.
|
||||||
|
await userMgr.AddToRoleAsync(admin, SystemRoles.SuperAdmin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!await userMgr.IsInRoleAsync(admin, SystemRoles.SuperAdmin))
|
||||||
|
await userMgr.AddToRoleAsync(admin, SystemRoles.SuperAdmin);
|
||||||
|
// Чистим дублирующую Identity-роль Admin (если оставалась с прошлых сидов).
|
||||||
|
if (await userMgr.IsInRoleAsync(admin, SystemRoles.Admin))
|
||||||
|
await userMgr.RemoveFromRoleAsync(admin, SystemRoles.Admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
await SeedAdminEmployeeAsync(db, demoOrg.Id, admin?.Id, ct);
|
||||||
|
|
||||||
|
// Глобальные SystemSettings — single-row. Сидируем дефолт 30 дней
|
||||||
|
// retention если ещё нет записи.
|
||||||
|
var anySettings = await db.SystemSettings.AnyAsync(ct);
|
||||||
|
if (!anySettings)
|
||||||
|
{
|
||||||
|
db.SystemSettings.Add(new SystemSettings { ArchiveRetentionDays = 30 });
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct)
|
/// <summary>Привязывает существующего admin@food-market.local к
|
||||||
|
/// Employee-записи с системной ролью «Администратор» — чтобы UI «Сотрудники»
|
||||||
|
/// сразу показывал учётку с правильной ролью, а не пустой список.</summary>
|
||||||
|
private static async Task SeedAdminEmployeeAsync(AppDbContext db, Guid orgId, Guid? adminUserId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var anyVat = await db.VatRates.IgnoreQueryFilters().AnyAsync(v => v.OrganizationId == orgId, ct);
|
if (adminUserId is null) return;
|
||||||
if (!anyVat)
|
var existing = await db.Employees.IgnoreQueryFilters()
|
||||||
|
.FirstOrDefaultAsync(e => e.OrganizationId == orgId && e.UserId == adminUserId, ct);
|
||||||
|
if (existing is not null) return;
|
||||||
|
var adminRole = await db.EmployeeRoles.IgnoreQueryFilters()
|
||||||
|
.FirstOrDefaultAsync(r => r.OrganizationId == orgId && r.IsSystem && r.Name == "Администратор", ct);
|
||||||
|
if (adminRole is null) return;
|
||||||
|
db.Employees.Add(new Employee
|
||||||
{
|
{
|
||||||
db.VatRates.AddRange(
|
OrganizationId = orgId,
|
||||||
new VatRate { OrganizationId = orgId, Name = "Без НДС", Percent = 0m, IsDefault = false },
|
UserId = adminUserId,
|
||||||
new VatRate { OrganizationId = orgId, Name = "НДС 12%", Percent = 12m, IsIncludedInPrice = true, IsDefault = true }
|
LastName = "Admin",
|
||||||
);
|
FirstName = "System",
|
||||||
}
|
Position = "Администратор",
|
||||||
|
Email = "admin@food-market.local",
|
||||||
|
RoleId = adminRole.Id,
|
||||||
|
IsActive = true,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bootstrap минимально-достаточного набора tenant-сущностей для
|
||||||
|
/// новой организации: единицы измерения (ОКЕИ), типы цен (Розничная+Оптовая),
|
||||||
|
/// «Основной склад» MAIN, «Касса 1» POS-1, и системные роли через
|
||||||
|
/// SeedEmployeeRolesAsync. Идемпотентно: каждый блок проверяет существующие
|
||||||
|
/// записи. Используется и при первом старте Demo, и при создании org через
|
||||||
|
/// SuperAdmin UI.</summary>
|
||||||
|
public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct)
|
||||||
|
{
|
||||||
var anyUnit = await db.UnitsOfMeasure.IgnoreQueryFilters().AnyAsync(u => u.OrganizationId == orgId, ct);
|
var anyUnit = await db.UnitsOfMeasure.IgnoreQueryFilters().AnyAsync(u => u.OrganizationId == orgId, ct);
|
||||||
if (!anyUnit)
|
if (!anyUnit)
|
||||||
{
|
{
|
||||||
db.UnitsOfMeasure.AddRange(
|
db.UnitsOfMeasure.AddRange(
|
||||||
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Symbol = "шт", Name = "штука", DecimalPlaces = 0, IsBase = true },
|
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Name = "штука" },
|
||||||
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Symbol = "кг", Name = "килограмм", DecimalPlaces = 3 },
|
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Name = "килограмм" },
|
||||||
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Symbol = "л", Name = "литр", DecimalPlaces = 3 },
|
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Name = "литр" },
|
||||||
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Symbol = "м", Name = "метр", DecimalPlaces = 3 },
|
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Name = "метр" },
|
||||||
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Symbol = "уп", Name = "упаковка", DecimalPlaces = 0 }
|
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Name = "упаковка" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сидируем PriceType ТОЛЬКО если у организации не было ни одной записи.
|
||||||
|
// Если есть — никогда не создаём «системную копию», корректность IsSystem
|
||||||
|
// обеспечивает миграция Phase3b_FixPriceTypeIsSystem (выбор по факту:
|
||||||
|
// запись с максимумом ProductPrice).
|
||||||
var anyPriceType = await db.PriceTypes.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
|
var anyPriceType = await db.PriceTypes.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
|
||||||
if (!anyPriceType)
|
if (!anyPriceType)
|
||||||
{
|
{
|
||||||
db.PriceTypes.AddRange(
|
db.PriceTypes.AddRange(
|
||||||
new PriceType { OrganizationId = orgId, Name = "Розничная", IsDefault = true, IsRetail = true, SortOrder = 1 },
|
new PriceType { OrganizationId = orgId, Name = "Розничная цена", IsSystem = true, IsRequired = true, IsRetail = true, SortOrder = 0 },
|
||||||
new PriceType { OrganizationId = orgId, Name = "Оптовая", SortOrder = 2 }
|
new PriceType { OrganizationId = orgId, Name = "Оптовая", SortOrder = 1 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,7 +171,6 @@ private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
|
||||||
OrganizationId = orgId,
|
OrganizationId = orgId,
|
||||||
Name = "Основной склад",
|
Name = "Основной склад",
|
||||||
Code = "MAIN",
|
Code = "MAIN",
|
||||||
Kind = StoreKind.Warehouse,
|
|
||||||
IsMain = true,
|
IsMain = true,
|
||||||
Address = "Алматы, ул. Пример 1",
|
Address = "Алматы, ул. Пример 1",
|
||||||
};
|
};
|
||||||
|
|
@ -137,6 +191,100 @@ private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await SeedEmployeeRolesAsync(db, orgId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Системные роли (IsSystem=true): Администратор / Менеджер /
|
||||||
|
/// Кладовщик / Кассир / Закупщик / Бухгалтер. Сидируется один раз
|
||||||
|
/// per организацию; обновлять не пытаемся, чтобы не сбросить кастомные
|
||||||
|
/// правки галок которые админ мог сделать.</summary>
|
||||||
|
private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var anyRole = await db.EmployeeRoles.IgnoreQueryFilters().AnyAsync(r => r.OrganizationId == orgId, ct);
|
||||||
|
if (anyRole) return;
|
||||||
|
|
||||||
|
var admin = new EmployeeRole
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
Name = "Администратор",
|
||||||
|
Description = "Полный доступ ко всем разделам организации",
|
||||||
|
IsSystem = true, SortOrder = 0,
|
||||||
|
Permissions = RolePermissions.All(),
|
||||||
|
};
|
||||||
|
// Менеджер/Кладовщик/Закупщик/Бухгалтер — кастомные шаблоны (IsSystem=false),
|
||||||
|
// юзер может удалить или подкрутить под себя. Системные только Администратор + Кассир.
|
||||||
|
var manager = new EmployeeRole
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
Name = "Менеджер",
|
||||||
|
Description = "Управление каталогом, документами и контрагентами",
|
||||||
|
IsSystem = false, SortOrder = 10,
|
||||||
|
Permissions = new RolePermissions
|
||||||
|
{
|
||||||
|
ProductsView = true, ProductsEdit = true, ProductGroupsManage = true, PriceTypesManage = true,
|
||||||
|
SuppliesView = true, SuppliesEdit = true, SuppliesPost = true,
|
||||||
|
CounterpartiesView = true, CounterpartiesEdit = true,
|
||||||
|
ReportsView = true, StocksView = true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var keeper = new EmployeeRole
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
Name = "Кладовщик",
|
||||||
|
Description = "Приёмки, инвентаризация, остатки",
|
||||||
|
IsSystem = false, SortOrder = 20,
|
||||||
|
Permissions = new RolePermissions
|
||||||
|
{
|
||||||
|
ProductsView = true,
|
||||||
|
SuppliesView = true, SuppliesEdit = true, SuppliesPost = true,
|
||||||
|
StocksView = true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var cashier = new EmployeeRole
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
Name = "Кассир",
|
||||||
|
Description = "Только работа на кассе. Без доступа к веб-админке.",
|
||||||
|
IsSystem = true, SortOrder = 30,
|
||||||
|
Permissions = new RolePermissions
|
||||||
|
{
|
||||||
|
ProductsView = true,
|
||||||
|
StocksView = true,
|
||||||
|
RetailSalesOperate = true,
|
||||||
|
// RetailSalesRefund по умолчанию false — админ включит при необходимости
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var buyer = new EmployeeRole
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
Name = "Закупщик",
|
||||||
|
Description = "Заказы поставщикам и приёмка товара",
|
||||||
|
IsSystem = false, SortOrder = 40,
|
||||||
|
Permissions = new RolePermissions
|
||||||
|
{
|
||||||
|
ProductsView = true,
|
||||||
|
SuppliesView = true, SuppliesEdit = true,
|
||||||
|
CounterpartiesView = true, CounterpartiesEdit = true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var accountant = new EmployeeRole
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
Name = "Бухгалтер",
|
||||||
|
Description = "Просмотр всех данных и отчётов, без редактирования",
|
||||||
|
IsSystem = false, SortOrder = 50,
|
||||||
|
Permissions = new RolePermissions
|
||||||
|
{
|
||||||
|
ProductsView = true,
|
||||||
|
SuppliesView = true,
|
||||||
|
CounterpartiesView = true,
|
||||||
|
ReportsView = true, StocksView = true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
db.EmployeeRoles.AddRange(admin, manager, keeper, cashier, buyer, accountant);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
|
||||||
|
|
@ -19,55 +19,55 @@ public async Task StartAsync(CancellationToken ct)
|
||||||
using var scope = _services.CreateScope();
|
using var scope = _services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
await SeedCurrenciesAsync(db, ct); // первыми — на них ссылаются страны
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
await SeedCountriesAsync(db, ct);
|
await SeedCountriesAsync(db, ct);
|
||||||
await SeedCurrenciesAsync(db, ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await BackfillCountryDefaultsAsync(db, ct);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
private static async Task SeedCountriesAsync(AppDbContext db, CancellationToken ct)
|
private record CountrySeed(string Code, string Name, string CurrencyCode, decimal VatRate);
|
||||||
{
|
|
||||||
// Kazakhstan first, then common trade partners.
|
|
||||||
var wanted = new[]
|
|
||||||
{
|
|
||||||
new Country { Code = "KZ", Name = "Казахстан", SortOrder = 1 },
|
|
||||||
new Country { Code = "RU", Name = "Россия", SortOrder = 2 },
|
|
||||||
new Country { Code = "CN", Name = "Китай", SortOrder = 3 },
|
|
||||||
new Country { Code = "TR", Name = "Турция", SortOrder = 4 },
|
|
||||||
new Country { Code = "BY", Name = "Беларусь", SortOrder = 5 },
|
|
||||||
new Country { Code = "UZ", Name = "Узбекистан", SortOrder = 6 },
|
|
||||||
new Country { Code = "KG", Name = "Кыргызстан", SortOrder = 7 },
|
|
||||||
new Country { Code = "DE", Name = "Германия", SortOrder = 10 },
|
|
||||||
new Country { Code = "US", Name = "США", SortOrder = 11 },
|
|
||||||
new Country { Code = "KR", Name = "Южная Корея", SortOrder = 12 },
|
|
||||||
new Country { Code = "IT", Name = "Италия", SortOrder = 13 },
|
|
||||||
new Country { Code = "PL", Name = "Польша", SortOrder = 14 },
|
|
||||||
};
|
|
||||||
|
|
||||||
var existingCodes = await db.Countries.Select(c => c.Code).ToListAsync(ct);
|
private static readonly CountrySeed[] CountrySeeds =
|
||||||
foreach (var c in wanted)
|
{
|
||||||
{
|
new("KZ", "Казахстан", "KZT", 16m),
|
||||||
if (!existingCodes.Contains(c.Code))
|
new("RU", "Россия", "RUB", 20m),
|
||||||
{
|
new("CN", "Китай", "CNY", 13m),
|
||||||
db.Countries.Add(c);
|
new("TR", "Турция", "TRY", 18m),
|
||||||
}
|
new("BY", "Беларусь", "BYN", 20m),
|
||||||
}
|
new("UZ", "Узбекистан", "UZS", 12m),
|
||||||
}
|
new("KG", "Кыргызстан", "KGS", 12m),
|
||||||
|
new("DE", "Германия", "EUR", 19m),
|
||||||
|
new("US", "США", "USD", 0m),
|
||||||
|
new("KR", "Южная Корея", "KRW", 10m),
|
||||||
|
new("IT", "Италия", "EUR", 22m),
|
||||||
|
new("PL", "Польша", "PLN", 23m),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Currency[] CurrencySeeds =
|
||||||
|
{
|
||||||
|
new() { Code = "KZT", Name = "Тенге", Symbol = "₸", MinorUnit = 2 },
|
||||||
|
new() { Code = "RUB", Name = "Российский рубль", Symbol = "₽", MinorUnit = 2 },
|
||||||
|
new() { Code = "USD", Name = "Доллар США", Symbol = "$", MinorUnit = 2 },
|
||||||
|
new() { Code = "EUR", Name = "Евро", Symbol = "€", MinorUnit = 2 },
|
||||||
|
new() { Code = "CNY", Name = "Китайский юань", Symbol = "¥", MinorUnit = 2 },
|
||||||
|
new() { Code = "BYN", Name = "Белорусский рубль", Symbol = "Br", MinorUnit = 2 },
|
||||||
|
new() { Code = "UZS", Name = "Узбекский сум", Symbol = "сум", MinorUnit = 2 },
|
||||||
|
new() { Code = "KGS", Name = "Кыргызский сом", Symbol = "сом", MinorUnit = 2 },
|
||||||
|
new() { Code = "TRY", Name = "Турецкая лира", Symbol = "₺", MinorUnit = 2 },
|
||||||
|
new() { Code = "KRW", Name = "Южнокорейская вона", Symbol = "₩", MinorUnit = 0 },
|
||||||
|
new() { Code = "PLN", Name = "Польский злотый", Symbol = "zł", MinorUnit = 2 },
|
||||||
|
};
|
||||||
|
|
||||||
private static async Task SeedCurrenciesAsync(AppDbContext db, CancellationToken ct)
|
private static async Task SeedCurrenciesAsync(AppDbContext db, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var wanted = new[]
|
|
||||||
{
|
|
||||||
new Currency { Code = "KZT", Name = "Тенге", Symbol = "₸", MinorUnit = 2 },
|
|
||||||
new Currency { Code = "RUB", Name = "Российский рубль", Symbol = "₽", MinorUnit = 2 },
|
|
||||||
new Currency { Code = "USD", Name = "Доллар США", Symbol = "$", MinorUnit = 2 },
|
|
||||||
new Currency { Code = "EUR", Name = "Евро", Symbol = "€", MinorUnit = 2 },
|
|
||||||
new Currency { Code = "CNY", Name = "Китайский юань", Symbol = "¥", MinorUnit = 2 },
|
|
||||||
};
|
|
||||||
|
|
||||||
var existingCodes = await db.Currencies.Select(c => c.Code).ToListAsync(ct);
|
var existingCodes = await db.Currencies.Select(c => c.Code).ToListAsync(ct);
|
||||||
foreach (var c in wanted)
|
foreach (var c in CurrencySeeds)
|
||||||
{
|
{
|
||||||
if (!existingCodes.Contains(c.Code))
|
if (!existingCodes.Contains(c.Code))
|
||||||
{
|
{
|
||||||
|
|
@ -75,4 +75,36 @@ private static async Task SeedCurrenciesAsync(AppDbContext db, CancellationToken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task SeedCountriesAsync(AppDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var existingCodes = await db.Countries.Select(c => c.Code).ToListAsync(ct);
|
||||||
|
foreach (var s in CountrySeeds)
|
||||||
|
{
|
||||||
|
if (!existingCodes.Contains(s.Code))
|
||||||
|
{
|
||||||
|
db.Countries.Add(new Country { Code = s.Code, Name = s.Name, VatRate = s.VatRate });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task BackfillCountryDefaultsAsync(AppDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Привязываем валюту и НДС к странам (обновит и новосозданные и существующие).
|
||||||
|
var currenciesByCode = await db.Currencies.ToDictionaryAsync(c => c.Code, ct);
|
||||||
|
var countries = await db.Countries.ToListAsync(ct);
|
||||||
|
foreach (var country in countries)
|
||||||
|
{
|
||||||
|
var seed = Array.Find(CountrySeeds, s => s.Code == country.Code);
|
||||||
|
if (seed is null) continue;
|
||||||
|
if (country.DefaultCurrencyId is null && currenciesByCode.TryGetValue(seed.CurrencyCode, out var cur))
|
||||||
|
{
|
||||||
|
country.DefaultCurrencyId = cur.Id;
|
||||||
|
}
|
||||||
|
if (country.VatRate == 0m && seed.VatRate > 0m)
|
||||||
|
{
|
||||||
|
country.VatRate = seed.VatRate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,11 @@
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"AllowedOrigins": [
|
"AllowedOrigins": [
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
"http://localhost:4173"
|
"http://localhost:4173",
|
||||||
|
"https://food-market.zat.kz",
|
||||||
|
"https://app.food-market.zat.kz",
|
||||||
|
"https://food-market.kz",
|
||||||
|
"https://app.food-market.kz"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
using foodmarket.Domain.Catalog;
|
using foodmarket.Domain.Catalog;
|
||||||
|
|
||||||
namespace foodmarket.Application.Catalog;
|
namespace foodmarket.Application.Catalog;
|
||||||
|
|
||||||
public record CountryDto(Guid Id, string Code, string Name, int SortOrder);
|
public record CountryDto(
|
||||||
|
Guid Id, string Code, string Name,
|
||||||
|
Guid? DefaultCurrencyId, string? DefaultCurrencyCode, string? DefaultCurrencySymbol,
|
||||||
|
decimal VatRate);
|
||||||
|
|
||||||
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol, int MinorUnit, bool IsActive);
|
|
||||||
|
|
||||||
public record VatRateDto(
|
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol);
|
||||||
Guid Id, string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault, bool IsActive);
|
|
||||||
|
|
||||||
public record UnitOfMeasureDto(
|
public record UnitOfMeasureDto(
|
||||||
Guid Id, string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase, bool IsActive);
|
Guid Id, string Code, string Name, string? Description, Guid? OrganizationId);
|
||||||
|
|
||||||
public record PriceTypeDto(
|
public record PriceTypeDto(
|
||||||
Guid Id, string Name, bool IsDefault, bool IsRetail, int SortOrder, bool IsActive);
|
Guid Id, string Name, bool IsRequired, bool IsSystem,
|
||||||
|
bool IsRetail, int SortOrder);
|
||||||
|
|
||||||
public record StoreDto(
|
public record StoreDto(
|
||||||
Guid Id, string Name, string? Code, StoreKind Kind, string? Address, string? Phone,
|
Guid Id, string Name, string? Code, string? Address, string? Phone,
|
||||||
string? ManagerName, bool IsMain, bool IsActive);
|
string? ManagerName, bool IsMain, bool IsActive);
|
||||||
|
|
||||||
public record RetailPointDto(
|
public record RetailPointDto(
|
||||||
|
|
@ -24,13 +27,14 @@ public record RetailPointDto(
|
||||||
string? Address, string? Phone, string? FiscalSerial, string? FiscalRegNumber, bool IsActive);
|
string? Address, string? Phone, string? FiscalSerial, string? FiscalRegNumber, bool IsActive);
|
||||||
|
|
||||||
public record ProductGroupDto(
|
public record ProductGroupDto(
|
||||||
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive);
|
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder,
|
||||||
|
decimal? MarkupPercent, Guid? OrganizationId);
|
||||||
|
|
||||||
public record CounterpartyDto(
|
public record CounterpartyDto(
|
||||||
Guid Id, string Name, string? LegalName, CounterpartyKind Kind, CounterpartyType Type,
|
Guid Id, string Name, string? LegalName, CounterpartyType Type,
|
||||||
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName,
|
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName,
|
||||||
string? Address, string? Phone, string? Email,
|
string? Address, string? Phone, string? Email,
|
||||||
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive);
|
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes);
|
||||||
|
|
||||||
public record ProductBarcodeDto(Guid Id, string Code, BarcodeType Type, bool IsPrimary);
|
public record ProductBarcodeDto(Guid Id, string Code, BarcodeType Type, bool IsPrimary);
|
||||||
|
|
||||||
|
|
@ -38,47 +42,54 @@ public record ProductPriceDto(Guid Id, Guid PriceTypeId, string PriceTypeName, d
|
||||||
|
|
||||||
public record ProductDto(
|
public record ProductDto(
|
||||||
Guid Id, string Name, string? Article, string? Description,
|
Guid Id, string Name, string? Article, string? Description,
|
||||||
Guid UnitOfMeasureId, string UnitSymbol,
|
Guid UnitOfMeasureId, string UnitName,
|
||||||
Guid VatRateId, decimal VatPercent,
|
decimal Vat, bool VatEnabled,
|
||||||
Guid? ProductGroupId, string? ProductGroupName,
|
Guid ProductGroupId, string ProductGroupName,
|
||||||
Guid? DefaultSupplierId, string? DefaultSupplierName,
|
Guid? DefaultSupplierId, string? DefaultSupplierName,
|
||||||
Guid? CountryOfOriginId, string? CountryOfOriginName,
|
Guid? CountryOfOriginId, string? CountryOfOriginName,
|
||||||
bool IsService, bool IsWeighed, bool IsAlcohol, bool IsMarked,
|
bool IsService, Packaging Packaging, bool IsMarked,
|
||||||
decimal? MinStock, decimal? MaxStock,
|
decimal? MinStock, decimal? MaxStock,
|
||||||
decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
|
decimal? ReferencePrice, DateTime? ReferencePriceUpdatedAt,
|
||||||
string? ImageUrl, bool IsActive,
|
Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
|
||||||
|
decimal Cost, DateTime? LastSupplyAt,
|
||||||
|
string? ImageUrl,
|
||||||
IReadOnlyList<ProductPriceDto> Prices,
|
IReadOnlyList<ProductPriceDto> Prices,
|
||||||
IReadOnlyList<ProductBarcodeDto> Barcodes);
|
IReadOnlyList<ProductBarcodeDto> Barcodes);
|
||||||
|
|
||||||
// Upsert payloads (input)
|
// Upsert payloads (input)
|
||||||
public record CountryInput(string Code, string Name, int SortOrder = 0);
|
public record CountryInput(
|
||||||
public record CurrencyInput(string Code, string Name, string Symbol, int MinorUnit = 2, bool IsActive = true);
|
string Code, string Name,
|
||||||
public record VatRateInput(string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault = false, bool IsActive = true);
|
Guid? DefaultCurrencyId = null, decimal VatRate = 0m);
|
||||||
public record UnitOfMeasureInput(string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase = false, bool IsActive = true);
|
public record CurrencyInput(string Code, string Name, string Symbol);
|
||||||
public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true);
|
public record UnitOfMeasureInput(string Code, string Name, string? Description = null);
|
||||||
|
public record PriceTypeInput(
|
||||||
|
string Name, bool IsRequired = false,
|
||||||
|
bool IsRetail = false, int SortOrder = 0);
|
||||||
public record StoreInput(
|
public record StoreInput(
|
||||||
string Name, string? Code, StoreKind Kind = StoreKind.Warehouse,
|
string Name, string? Code,
|
||||||
string? Address = null, string? Phone = null, string? ManagerName = null,
|
string? Address = null, string? Phone = null, string? ManagerName = null,
|
||||||
bool IsMain = false, bool IsActive = true);
|
bool IsMain = false, bool IsActive = true);
|
||||||
public record RetailPointInput(
|
public record RetailPointInput(
|
||||||
string Name, string? Code, Guid StoreId,
|
string Name, string? Code, Guid StoreId,
|
||||||
string? Address = null, string? Phone = null,
|
string? Address = null, string? Phone = null,
|
||||||
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
|
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
|
||||||
public record ProductGroupInput(string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true);
|
public record ProductGroupInput(
|
||||||
|
string Name, Guid? ParentId, int SortOrder = 0,
|
||||||
|
[Range(0, 1000)] decimal? MarkupPercent = null);
|
||||||
public record CounterpartyInput(
|
public record CounterpartyInput(
|
||||||
string Name, string? LegalName, CounterpartyKind Kind, CounterpartyType Type,
|
string Name, string? LegalName, CounterpartyType Type,
|
||||||
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId,
|
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId,
|
||||||
string? Address, string? Phone, string? Email,
|
string? Address, string? Phone, string? Email,
|
||||||
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive = true);
|
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes);
|
||||||
public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ean13, bool IsPrimary = false);
|
public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ean13, bool IsPrimary = false);
|
||||||
public record ProductPriceInput(Guid PriceTypeId, decimal Amount, Guid CurrencyId);
|
public record ProductPriceInput(Guid PriceTypeId, [Range(0, 1e10)] decimal Amount, Guid CurrencyId);
|
||||||
public record ProductInput(
|
public record ProductInput(
|
||||||
string Name, string? Article, string? Description,
|
string Name, string? Article, string? Description,
|
||||||
Guid UnitOfMeasureId, Guid VatRateId,
|
Guid UnitOfMeasureId, [Range(0, 100)] decimal? Vat, bool VatEnabled,
|
||||||
Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
|
Guid ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
|
||||||
bool IsService = false, bool IsWeighed = false, bool IsAlcohol = false, bool IsMarked = false,
|
bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false,
|
||||||
decimal? MinStock = null, decimal? MaxStock = null,
|
[Range(0, 1e10)] decimal? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null,
|
||||||
decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null,
|
[Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null,
|
||||||
string? ImageUrl = null, bool IsActive = true,
|
string? ImageUrl = null,
|
||||||
IReadOnlyList<ProductPriceInput>? Prices = null,
|
IReadOnlyList<ProductPriceInput>? Prices = null,
|
||||||
IReadOnlyList<ProductBarcodeInput>? Barcodes = null);
|
IReadOnlyList<ProductBarcodeInput>? Barcodes = null);
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,14 @@ public sealed class PagedRequest
|
||||||
public int Page { get; init; } = 1;
|
public int Page { get; init; } = 1;
|
||||||
public int PageSize { get; init; } = 50;
|
public int PageSize { get; init; } = 50;
|
||||||
public string? Search { get; init; }
|
public string? Search { get; init; }
|
||||||
public string? SortBy { get; init; }
|
/// <summary>Ключ колонки, по которой сортировать (см. switch в контроллере).</summary>
|
||||||
public bool SortDesc { get; init; }
|
public string? Sort { get; init; }
|
||||||
|
/// <summary>"asc" (дефолт) или "desc".</summary>
|
||||||
|
public string? Order { get; init; }
|
||||||
|
|
||||||
public int Skip => Math.Max(0, (Page - 1) * PageSize);
|
public int Skip => Math.Max(0, (Page - 1) * PageSize);
|
||||||
public int Take => Math.Clamp(PageSize, 1, 500);
|
public int Take => Math.Clamp(PageSize, 1, 500);
|
||||||
|
public bool Desc => string.Equals(Order, "desc", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class PagedResult<T>
|
public sealed class PagedResult<T>
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,8 @@ public interface ITenantContext
|
||||||
Guid? OrganizationId { get; }
|
Guid? OrganizationId { get; }
|
||||||
bool IsAuthenticated { get; }
|
bool IsAuthenticated { get; }
|
||||||
bool IsSuperAdmin { get; }
|
bool IsSuperAdmin { get; }
|
||||||
|
/// <summary>SuperAdmin зашёл в режим «открыто как…» через X-Org-Override.
|
||||||
|
/// В этом режиме query-filter ОБЯЗАН применяться (по выбранной orgId),
|
||||||
|
/// иначе SuperAdmin'у возвращаются записи всех орг — нарушение изоляции.</summary>
|
||||||
|
bool IsTenantOverride { get; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
src/food-market.application/Inventory/IStockService.cs
Normal file
27
src/food-market.application/Inventory/IStockService.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
using foodmarket.Domain.Inventory;
|
||||||
|
|
||||||
|
namespace foodmarket.Application.Inventory;
|
||||||
|
|
||||||
|
public record StockMovementDraft(
|
||||||
|
Guid ProductId,
|
||||||
|
Guid StoreId,
|
||||||
|
decimal Quantity,
|
||||||
|
MovementType Type,
|
||||||
|
string DocumentType,
|
||||||
|
Guid? DocumentId = null,
|
||||||
|
string? DocumentNumber = null,
|
||||||
|
decimal? UnitCost = null,
|
||||||
|
DateTime? OccurredAt = null,
|
||||||
|
Guid? CreatedByUserId = null,
|
||||||
|
string? Notes = null);
|
||||||
|
|
||||||
|
public interface IStockService
|
||||||
|
{
|
||||||
|
/// <summary>Writes the movement + updates the materialized Stock row in a single unit of work.
|
||||||
|
/// Returns the new on-hand quantity. Callers must commit the DbContext themselves (we don't
|
||||||
|
/// wrap in a transaction — typical flow is as part of a document posting that already bundles
|
||||||
|
/// multiple movements into one SaveChanges).</summary>
|
||||||
|
Task<decimal> ApplyMovementAsync(StockMovementDraft draft, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<decimal> ApplyMovementsAsync(IEnumerable<StockMovementDraft> drafts, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@ public class Counterparty : TenantEntity
|
||||||
{
|
{
|
||||||
public string Name { get; set; } = null!; // отображаемое имя
|
public string Name { get; set; } = null!; // отображаемое имя
|
||||||
public string? LegalName { get; set; } // полное юридическое имя
|
public string? LegalName { get; set; } // полное юридическое имя
|
||||||
public CounterpartyKind Kind { get; set; } // Supplier / Customer / Both
|
|
||||||
public CounterpartyType Type { get; set; } // Юрлицо / Физлицо
|
public CounterpartyType Type { get; set; } // Юрлицо / Физлицо
|
||||||
public string? Bin { get; set; } // БИН (для юрлиц РК)
|
public string? Bin { get; set; } // БИН (для юрлиц РК)
|
||||||
public string? Iin { get; set; } // ИИН (для физлиц РК)
|
public string? Iin { get; set; } // ИИН (для физлиц РК)
|
||||||
|
|
@ -21,5 +20,4 @@ public class Counterparty : TenantEntity
|
||||||
public string? Bik { get; set; }
|
public string? Bik { get; set; }
|
||||||
public string? ContactPerson { get; set; }
|
public string? ContactPerson { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,13 @@ public class Country : Entity
|
||||||
{
|
{
|
||||||
public string Code { get; set; } = null!; // ISO 3166-1 alpha-2, e.g. "KZ"
|
public string Code { get; set; } = null!; // ISO 3166-1 alpha-2, e.g. "KZ"
|
||||||
public string Name { get; set; } = null!;
|
public string Name { get; set; } = null!;
|
||||||
public int SortOrder { get; set; }
|
/// <summary>Валюта страны — при выборе страны в настройках организации
|
||||||
|
/// она становится валютой по умолчанию для этой организации.</summary>
|
||||||
|
public Guid? DefaultCurrencyId { get; set; }
|
||||||
|
public Currency? DefaultCurrency { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Ставка НДС этой страны, в процентах (например 16.00 для KZ,
|
||||||
|
/// 20.00 для RU). Единственный источник правды для ставки НДС —
|
||||||
|
/// Product.Vat на товаре не хранится.</summary>
|
||||||
|
public decimal VatRate { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ public class Currency : Entity
|
||||||
public string Code { get; set; } = null!; // ISO 4217, e.g. "KZT"
|
public string Code { get; set; } = null!; // ISO 4217, e.g. "KZT"
|
||||||
public string Name { get; set; } = null!;
|
public string Name { get; set; } = null!;
|
||||||
public string Symbol { get; set; } = null!; // "₸"
|
public string Symbol { get; set; } = null!; // "₸"
|
||||||
public int MinorUnit { get; set; } = 2; // 2 = two decimal places
|
// Количество знаков после запятой для форматирования цен. Не редактируется
|
||||||
public bool IsActive { get; set; } = true;
|
// в UI — задаётся сидером/миграцией по ISO 4217.
|
||||||
|
public int MinorUnit { get; set; } = 2;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,19 @@
|
||||||
namespace foodmarket.Domain.Catalog;
|
namespace foodmarket.Domain.Catalog;
|
||||||
|
|
||||||
public enum CounterpartyKind
|
|
||||||
{
|
|
||||||
Supplier = 1,
|
|
||||||
Customer = 2,
|
|
||||||
Both = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum CounterpartyType
|
public enum CounterpartyType
|
||||||
{
|
{
|
||||||
LegalEntity = 1,
|
LegalEntity = 1,
|
||||||
Individual = 2,
|
Individual = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum StoreKind
|
/// <summary>Фасовка товара: как продаётся и учитывается в остатках.
|
||||||
|
/// Piece — штучный товар (1 шт), по умолчанию. Weight — весовой (кг, г), продаётся с весов.
|
||||||
|
/// Liquid — разливной (л), продаётся из тары на разлив.</summary>
|
||||||
|
public enum Packaging
|
||||||
{
|
{
|
||||||
Warehouse = 1,
|
Piece = 1,
|
||||||
RetailFloor = 2,
|
Weight = 2,
|
||||||
|
Liquid = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum BarcodeType
|
public enum BarcodeType
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,14 @@ namespace foodmarket.Domain.Catalog;
|
||||||
public class PriceType : TenantEntity
|
public class PriceType : TenantEntity
|
||||||
{
|
{
|
||||||
public string Name { get; set; } = null!;
|
public string Name { get; set; } = null!;
|
||||||
public bool IsDefault { get; set; } // цена по умолчанию для новых товаров
|
/// <summary>true — цена должна быть заполнена у каждого товара (валидация на UI и сервере).</summary>
|
||||||
public bool IsRetail { get; set; } // используется на кассе
|
public bool IsRequired { get; set; }
|
||||||
|
/// <summary>true — системная запись «Розничная цена», не удаляется и
|
||||||
|
/// IsRequired всегда true. Имя можно переименовать. Сидируется при первом старте.</summary>
|
||||||
|
public bool IsSystem { get; set; }
|
||||||
|
/// <summary>true — единственная запись, по которой POS касса берёт цену
|
||||||
|
/// для пробивки чека. Контроллер обеспечивает уникальность: установка
|
||||||
|
/// IsRetail=true сбрасывает её у других записей.</summary>
|
||||||
|
public bool IsRetail { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,16 @@ public class Product : TenantEntity
|
||||||
public Guid UnitOfMeasureId { get; set; }
|
public Guid UnitOfMeasureId { get; set; }
|
||||||
public UnitOfMeasure? UnitOfMeasure { get; set; }
|
public UnitOfMeasure? UnitOfMeasure { get; set; }
|
||||||
|
|
||||||
public Guid VatRateId { get; set; }
|
// Ставка НДС в процентах, decimal(5,2) — например 16.00 или 0.00. Дефолт
|
||||||
public VatRate? VatRate { get; set; }
|
// при создании берётся из Country.VatRate организации, пользователь может
|
||||||
|
// менять для исключений (хлеб = 0.00 и т.п.) — но только если в настройках
|
||||||
|
// включена галка «Указывать ставку НДС на товаре». VatEnabled управляет
|
||||||
|
// лишь видимостью поля Vat в UI: снята — поле скрыто, включена — поле
|
||||||
|
// с текущей ставкой. Семантика «в том числе/сверху» — на уровне документа.
|
||||||
|
public decimal Vat { get; set; }
|
||||||
|
public bool VatEnabled { get; set; } = true;
|
||||||
|
|
||||||
public Guid? ProductGroupId { get; set; }
|
public Guid ProductGroupId { get; set; }
|
||||||
public ProductGroup? ProductGroup { get; set; }
|
public ProductGroup? ProductGroup { get; set; }
|
||||||
|
|
||||||
public Guid? DefaultSupplierId { get; set; } // основной поставщик
|
public Guid? DefaultSupplierId { get; set; } // основной поставщик
|
||||||
|
|
@ -24,19 +30,30 @@ public class Product : TenantEntity
|
||||||
public Country? CountryOfOrigin { get; set; }
|
public Country? CountryOfOrigin { get; set; }
|
||||||
|
|
||||||
public bool IsService { get; set; } // услуга, а не физический товар
|
public bool IsService { get; set; } // услуга, а не физический товар
|
||||||
public bool IsWeighed { get; set; } // весовой (продаётся с весов)
|
public Packaging Packaging { get; set; } = Packaging.Piece; // фасовка (штучный/весовой/разливной)
|
||||||
public bool IsAlcohol { get; set; } // алкоголь (подакцизный)
|
|
||||||
public bool IsMarked { get; set; } // маркируемый (Datamatrix)
|
public bool IsMarked { get; set; } // маркируемый (Datamatrix)
|
||||||
|
|
||||||
public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений)
|
public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений)
|
||||||
public decimal? MaxStock { get; set; } // максимальный остаток (для автозаказа)
|
public decimal? MaxStock { get; set; } // максимальный остаток (для автозаказа)
|
||||||
|
|
||||||
public decimal? PurchasePrice { get; set; } // закупочная цена по умолчанию
|
/// <summary>«Эталонная» (справочная) цена закупа. Не обязательная.
|
||||||
|
/// Автоматически заполняется UnitPrice'ом первой проведённой приёмки.
|
||||||
|
/// Через 30 дней без новых приёмок Hangfire-job переписывает на текущую Cost.</summary>
|
||||||
|
public decimal? ReferencePrice { get; set; }
|
||||||
|
public DateTime? ReferencePriceUpdatedAt { get; set; }
|
||||||
public Guid? PurchaseCurrencyId { get; set; }
|
public Guid? PurchaseCurrencyId { get; set; }
|
||||||
public Currency? PurchaseCurrency { get; set; }
|
public Currency? PurchaseCurrency { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Себестоимость по скользящему среднему. Пересчитывается на каждой
|
||||||
|
/// проведённой приёмке: (qty_old * cost_old + qty_in * price_in) / (qty_old + qty_in).
|
||||||
|
/// Хранится с 4 знаками для точности; UI показывает 2.</summary>
|
||||||
|
public decimal Cost { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UTC-метка последней проведённой приёмки. Используется
|
||||||
|
/// 30-дневной job для перезаписи ReferencePrice на текущую Cost.</summary>
|
||||||
|
public DateTime? LastSupplyAt { get; set; }
|
||||||
|
|
||||||
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)
|
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)
|
||||||
public bool IsActive { get; set; } = true;
|
|
||||||
|
|
||||||
public ICollection<ProductPrice> Prices { get; set; } = [];
|
public ICollection<ProductPrice> Prices { get; set; } = [];
|
||||||
public ICollection<ProductBarcode> Barcodes { get; set; } = [];
|
public ICollection<ProductBarcode> Barcodes { get; set; } = [];
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,19 @@
|
||||||
namespace foodmarket.Domain.Catalog;
|
namespace foodmarket.Domain.Catalog;
|
||||||
|
|
||||||
// Иерархическая группа товаров (категория). Произвольная вложенность через ParentId.
|
// Иерархическая группа товаров (категория). Произвольная вложенность через ParentId.
|
||||||
public class ProductGroup : TenantEntity
|
// Двухуровневый справочник (IOptionalTenantEntity): системные эталонные группы
|
||||||
|
// (OrganizationId=null, управляются SuperAdmin'ом) и tenant'овские (OrganizationId=<orgId>).
|
||||||
|
public class ProductGroup : Entity, IOptionalTenantEntity
|
||||||
{
|
{
|
||||||
|
public Guid? OrganizationId { get; set; }
|
||||||
public string Name { get; set; } = null!;
|
public string Name { get; set; } = null!;
|
||||||
public Guid? ParentId { get; set; }
|
public Guid? ParentId { get; set; }
|
||||||
public ProductGroup? Parent { get; set; }
|
public ProductGroup? Parent { get; set; }
|
||||||
public ICollection<ProductGroup> Children { get; set; } = [];
|
public ICollection<ProductGroup> Children { get; set; } = [];
|
||||||
public string Path { get; set; } = ""; // денормализованный путь "Электроника/Телефоны/Смартфоны" для фильтрации
|
public string Path { get; set; } = ""; // денормализованный путь "Электроника/Телефоны/Смартфоны" для фильтрации
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
|
||||||
|
/// <summary>Процент наценки на себестоимость для автоматического расчёта
|
||||||
|
/// розничной цены при проведении приёмки. NULL = автонаценка отключена.</summary>
|
||||||
|
public decimal? MarkupPercent { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
namespace foodmarket.Domain.Catalog;
|
namespace foodmarket.Domain.Catalog;
|
||||||
|
|
||||||
// Склад: физическое место хранения товаров. Может быть чисто склад или торговый зал.
|
// Склад: физическое место хранения товаров. MoySklad не различает "склад" и
|
||||||
|
// "торговый зал" — это одна сущность entity/store, опираемся на это.
|
||||||
public class Store : TenantEntity
|
public class Store : TenantEntity
|
||||||
{
|
{
|
||||||
public string Name { get; set; } = null!;
|
public string Name { get; set; } = null!;
|
||||||
public string? Code { get; set; } // внутренний код склада
|
public string? Code { get; set; } // внутренний код склада
|
||||||
public StoreKind Kind { get; set; } = StoreKind.Warehouse;
|
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
public string? ManagerName { get; set; }
|
public string? ManagerName { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
namespace foodmarket.Domain.Catalog;
|
namespace foodmarket.Domain.Catalog;
|
||||||
|
|
||||||
// Tenant-scoped справочник единиц измерения.
|
// Единица измерения как в MoySklad entity/uom: code + name + description.
|
||||||
public class UnitOfMeasure : TenantEntity
|
// Двухуровневый справочник: системные эталонные (OrganizationId=null,
|
||||||
|
// управляются SuperAdmin'ом — ОКЕИ-эталоны) и tenant'овские расширения.
|
||||||
|
public class UnitOfMeasure : Entity, IOptionalTenantEntity
|
||||||
{
|
{
|
||||||
|
public Guid? OrganizationId { get; set; }
|
||||||
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
|
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
|
||||||
public string Symbol { get; set; } = null!; // "шт", "кг", "л", "м"
|
|
||||||
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
|
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
|
||||||
public int DecimalPlaces { get; set; } // 0 для шт, 3 для кг/л
|
public string? Description { get; set; }
|
||||||
public bool IsBase { get; set; } // базовая единица этой организации
|
|
||||||
public bool IsActive { get; set; } = true;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
using foodmarket.Domain.Common;
|
|
||||||
|
|
||||||
namespace foodmarket.Domain.Catalog;
|
|
||||||
|
|
||||||
// Tenant-scoped: разные организации могут работать в разных режимах (с НДС / упрощёнка).
|
|
||||||
public class VatRate : TenantEntity
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = null!; // "НДС 12%", "Без НДС"
|
|
||||||
public decimal Percent { get; set; } // 12.00, 0.00
|
|
||||||
public bool IsIncludedInPrice { get; set; } // входит ли в цену или начисляется сверху
|
|
||||||
public bool IsDefault { get; set; }
|
|
||||||
public bool IsActive { get; set; } = true;
|
|
||||||
}
|
|
||||||
|
|
@ -9,3 +9,12 @@ public abstract class TenantEntity : Entity, ITenantEntity
|
||||||
{
|
{
|
||||||
public Guid OrganizationId { get; set; }
|
public Guid OrganizationId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Двухуровневый справочник: запись либо системная (OrganizationId=null,
|
||||||
|
/// видна и читается всеми, мутирует только SuperAdmin), либо tenant'овская
|
||||||
|
/// (OrganizationId=<orgId>, видна и редактируется этой оргой). Применяется
|
||||||
|
/// для расширяемых эталонных списков типа единиц измерения и групп товаров.</summary>
|
||||||
|
public interface IOptionalTenantEntity
|
||||||
|
{
|
||||||
|
Guid? OrganizationId { get; set; }
|
||||||
|
}
|
||||||
|
|
|
||||||
22
src/food-market.domain/Inventory/Stock.cs
Normal file
22
src/food-market.domain/Inventory/Stock.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
using foodmarket.Domain.Catalog;
|
||||||
|
using foodmarket.Domain.Common;
|
||||||
|
|
||||||
|
namespace foodmarket.Domain.Inventory;
|
||||||
|
|
||||||
|
// Materialized current-stock aggregate per (Product, Store). Kept in sync with StockMovement
|
||||||
|
// inserts by IStockService — never write to this entity directly.
|
||||||
|
public class Stock : TenantEntity
|
||||||
|
{
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
public Product Product { get; set; } = null!;
|
||||||
|
|
||||||
|
public Guid StoreId { get; set; }
|
||||||
|
public Store Store { get; set; } = null!;
|
||||||
|
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal ReservedQuantity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Available = on-hand − reserved. Cannot be negative in normal flow; a negative
|
||||||
|
/// value indicates the business allowed overselling (e.g., retail sale before physical receipt).</summary>
|
||||||
|
public decimal Available => Quantity - ReservedQuantity;
|
||||||
|
}
|
||||||
49
src/food-market.domain/Inventory/StockMovement.cs
Normal file
49
src/food-market.domain/Inventory/StockMovement.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
using foodmarket.Domain.Catalog;
|
||||||
|
using foodmarket.Domain.Common;
|
||||||
|
|
||||||
|
namespace foodmarket.Domain.Inventory;
|
||||||
|
|
||||||
|
// Immutable, append-only journal of every stock change.
|
||||||
|
// Stock table is a materialized aggregate over this journal.
|
||||||
|
public class StockMovement : TenantEntity
|
||||||
|
{
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
public Product Product { get; set; } = null!;
|
||||||
|
|
||||||
|
public Guid StoreId { get; set; }
|
||||||
|
public Store Store { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>Signed quantity: positive = receipt, negative = issue.</summary>
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Per-unit cost at the time of movement (optional). Used for cost rollup / P&L.</summary>
|
||||||
|
public decimal? UnitCost { get; set; }
|
||||||
|
|
||||||
|
public MovementType Type { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Source document discriminator, e.g. "supply", "retail-sale", "write-off", "enter", "transfer-out".</summary>
|
||||||
|
public string DocumentType { get; set; } = "";
|
||||||
|
|
||||||
|
public Guid? DocumentId { get; set; }
|
||||||
|
public string? DocumentNumber { get; set; }
|
||||||
|
|
||||||
|
public DateTime OccurredAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public Guid? CreatedByUserId { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MovementType
|
||||||
|
{
|
||||||
|
Initial = 0,
|
||||||
|
Supply = 1, // приёмка от поставщика
|
||||||
|
RetailSale = 2, // розничная продажа
|
||||||
|
WholesaleSale = 3, // оптовая отгрузка
|
||||||
|
CustomerReturn = 4, // возврат покупателя
|
||||||
|
SupplierReturn = 5, // возврат поставщику
|
||||||
|
TransferOut = 6, // перемещение со склада
|
||||||
|
TransferIn = 7, // перемещение на склад
|
||||||
|
WriteOff = 8, // списание
|
||||||
|
Enter = 9, // оприходование
|
||||||
|
InventoryAdjustment = 10, // корректировка по результату инвентаризации
|
||||||
|
}
|
||||||
50
src/food-market.domain/Organizations/Employee.cs
Normal file
50
src/food-market.domain/Organizations/Employee.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
using foodmarket.Domain.Common;
|
||||||
|
|
||||||
|
namespace foodmarket.Domain.Organizations;
|
||||||
|
|
||||||
|
/// <summary>Сотрудник организации. Может быть привязан к учётной записи
|
||||||
|
/// (UserId), а может существовать без логина (например, кассир, которого
|
||||||
|
/// добавили в HR, но логин ещё не выдали).</summary>
|
||||||
|
public class Employee : TenantEntity
|
||||||
|
{
|
||||||
|
/// <summary>FK на Identity-юзера. Заполняется когда сотруднику выдан логин.
|
||||||
|
/// Null — запись без учётки.</summary>
|
||||||
|
public Guid? UserId { get; set; }
|
||||||
|
|
||||||
|
public string LastName { get; set; } = "";
|
||||||
|
public string FirstName { get; set; } = "";
|
||||||
|
public string? MiddleName { get; set; }
|
||||||
|
public string? Position { get; set; }
|
||||||
|
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Оклад в валюте организации, опц.</summary>
|
||||||
|
public decimal? Salary { get; set; }
|
||||||
|
/// <summary>ИИН/ИНН (12-14 символов), опц.</summary>
|
||||||
|
public string? TaxNumber { get; set; }
|
||||||
|
/// <summary>Произвольное описание (комментарий HR'а).</summary>
|
||||||
|
public string? Description { get; set; }
|
||||||
|
/// <summary>Аватар сотрудника. URL до файла в общем images-стораж.</summary>
|
||||||
|
public string? ImageUrl { get; set; }
|
||||||
|
|
||||||
|
public Guid RoleId { get; set; }
|
||||||
|
public EmployeeRole Role { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>Активен ли сотрудник. False — заблокирован, не может логиниться.
|
||||||
|
/// Удаление физически не делаем (FK из документов), просто IsActive=false.</summary>
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public DateTime? FiredAt { get; set; }
|
||||||
|
|
||||||
|
public ICollection<EmployeeRetailPointAssignment> RetailPointAssignments { get; set; }
|
||||||
|
= new List<EmployeeRetailPointAssignment>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Привязка сотрудника к кассе (для роли Кассир): к каким RetailPoint'ам
|
||||||
|
/// он может вставать. Если назначений нет — может ко всем (поведение по умолчанию).</summary>
|
||||||
|
public class EmployeeRetailPointAssignment : TenantEntity
|
||||||
|
{
|
||||||
|
public Guid EmployeeId { get; set; }
|
||||||
|
public Employee Employee { get; set; } = null!;
|
||||||
|
public Guid RetailPointId { get; set; }
|
||||||
|
}
|
||||||
17
src/food-market.domain/Organizations/EmployeeRole.cs
Normal file
17
src/food-market.domain/Organizations/EmployeeRole.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
using foodmarket.Domain.Common;
|
||||||
|
|
||||||
|
namespace foodmarket.Domain.Organizations;
|
||||||
|
|
||||||
|
/// <summary>Роль сотрудника в организации. Системные (IsSystem=true) сидируются
|
||||||
|
/// при создании Organization (Администратор/Менеджер/Кладовщик/Кассир/Закупщик/
|
||||||
|
/// Бухгалтер) — нельзя удалить, имя менять можно. Кастомные — полный CRUD.</summary>
|
||||||
|
public class EmployeeRole : TenantEntity
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public bool IsSystem { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Permissions — owned JSON-колонка (см. EF config).</summary>
|
||||||
|
public RolePermissions Permissions { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using foodmarket.Domain.Catalog;
|
||||||
using foodmarket.Domain.Common;
|
using foodmarket.Domain.Common;
|
||||||
|
|
||||||
namespace foodmarket.Domain.Organizations;
|
namespace foodmarket.Domain.Organizations;
|
||||||
|
|
@ -11,4 +12,70 @@ public class Organization : Entity
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Архивирована ли организация. Архивные не видны пользователям
|
||||||
|
/// но данные сохраняются. Из архива можно восстановить или (после 30 дней)
|
||||||
|
/// удалить навсегда — этим управляет SuperAdmin.</summary>
|
||||||
|
public bool IsArchived { get; set; }
|
||||||
|
public DateTime? ArchivedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Account owner (главный владелец, не путать с админами роли).
|
||||||
|
/// Это конкретный AppUser, который считается «хозяином» организации;
|
||||||
|
/// SuperAdmin может сменить через отдельный action c reason в audit-log.</summary>
|
||||||
|
public Guid? AccountOwnerUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Персональный API-токен MoySklad. Храним per-organization чтобы
|
||||||
|
/// пользователю не нужно было вводить его каждый раз при импорте.</summary>
|
||||||
|
public string? MoySkladToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Валюта организации по умолчанию. Если MultiCurrencyEnabled=false,
|
||||||
|
/// в UI выбор валюты скрыт — всё в этой валюте.</summary>
|
||||||
|
public Guid? DefaultCurrencyId { get; set; }
|
||||||
|
public Currency? DefaultCurrency { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Разрешены ли продажи/закупки в нескольких валютах. По умолчанию
|
||||||
|
/// false — тогда UI не предлагает выбор валюты, всё в DefaultCurrency.</summary>
|
||||||
|
public bool MultiCurrencyEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Показывать ли пользователю галку «В том числе НДС» на форме товара.
|
||||||
|
/// Если false (по умолчанию) — магазин работает с одной ставкой НДС и галка
|
||||||
|
/// скрыта, все товары считаются с НДС. Если true — можно для отдельных товаров
|
||||||
|
/// (хлеб, медикаменты) снимать галку.</summary>
|
||||||
|
public bool ShowVatEnabledOnProduct { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Показывать ли на форме товара и в фильтрах галку «Услуга».
|
||||||
|
/// Большинство магазинов продают только физические товары — флаг выключен
|
||||||
|
/// по умолчанию, чтобы не захламлять UI.</summary>
|
||||||
|
public bool ShowServiceOnProduct { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Показывать ли на форме товара и в фильтрах галку «Маркируемый».
|
||||||
|
/// Маркировка требуется только в нишевых категориях (алкоголь, лекарства,
|
||||||
|
/// табак) — по умолчанию выключено.</summary>
|
||||||
|
public bool ShowMarkedOnProduct { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Показывать ли поля «Минимальный остаток» / «Максимальный остаток»
|
||||||
|
/// на карточке товара и одноимённую колонку в списке. Нужно в основном
|
||||||
|
/// торговым сетям со свободным местом на полке — по умолчанию выключено.</summary>
|
||||||
|
public bool ShowMinMaxStock { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Разрешить ли цены с дробной частью (две цифры после запятой).
|
||||||
|
/// По умолчанию false — большинству KZ-магазинов хватает круглых тенге;
|
||||||
|
/// если включено, MoneyInput работает с шагом 0.01 и форматирует с .00,
|
||||||
|
/// иначе шаг 1 и значения округляются до целого даже при попытке прислать
|
||||||
|
/// дробное через API.</summary>
|
||||||
|
public bool AllowFractionalPrices { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Показывать ли в карточке товара поле «Эталонная цена».
|
||||||
|
/// Default: true.</summary>
|
||||||
|
public bool ShowReferencePriceOnProduct { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Показывать ли в карточке товара поле «Страна происхождения».
|
||||||
|
/// Default: false. Большинству KZ-магазинов это поле не нужно;
|
||||||
|
/// включать для торговцев импортом или маркируемой продукцией.</summary>
|
||||||
|
public bool ShowCountryOfOriginOnProduct { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Показывать ли в карточке товара поле «Описание». Default:
|
||||||
|
/// false. Описания ведут единицы магазинов; обычно текстовая колонка
|
||||||
|
/// просто захламляет карточку.</summary>
|
||||||
|
public bool ShowDescriptionOnProduct { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
68
src/food-market.domain/Organizations/RolePermissions.cs
Normal file
68
src/food-market.domain/Organizations/RolePermissions.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
namespace foodmarket.Domain.Organizations;
|
||||||
|
|
||||||
|
/// <summary>Набор флагов разрешений роли. Хранится JSON-колонкой
|
||||||
|
/// (owned by EmployeeRole). Семантика: false = доступ запрещён.</summary>
|
||||||
|
public class RolePermissions
|
||||||
|
{
|
||||||
|
// Каталог
|
||||||
|
public bool ProductsView { get; set; }
|
||||||
|
public bool ProductsEdit { get; set; }
|
||||||
|
public bool ProductsDelete { get; set; }
|
||||||
|
public bool ProductGroupsManage { get; set; }
|
||||||
|
public bool PriceTypesManage { get; set; }
|
||||||
|
public bool UnitsManage { get; set; }
|
||||||
|
|
||||||
|
// Закупки
|
||||||
|
public bool SuppliesView { get; set; }
|
||||||
|
public bool SuppliesEdit { get; set; }
|
||||||
|
public bool SuppliesPost { get; set; }
|
||||||
|
public bool SuppliesDelete { get; set; }
|
||||||
|
|
||||||
|
// Продажи (отгрузка контрагенту + POS)
|
||||||
|
public bool DemandsView { get; set; }
|
||||||
|
public bool DemandsEdit { get; set; }
|
||||||
|
public bool DemandsPost { get; set; }
|
||||||
|
public bool RetailSalesOperate { get; set; }
|
||||||
|
public bool RetailSalesRefund { get; set; }
|
||||||
|
|
||||||
|
// Контрагенты
|
||||||
|
public bool CounterpartiesView { get; set; }
|
||||||
|
public bool CounterpartiesEdit { get; set; }
|
||||||
|
public bool CounterpartiesDelete { get; set; }
|
||||||
|
|
||||||
|
// Склад / Остатки
|
||||||
|
public bool StocksView { get; set; }
|
||||||
|
public bool InventoryEdit { get; set; }
|
||||||
|
public bool LossEdit { get; set; }
|
||||||
|
public bool EnterEdit { get; set; }
|
||||||
|
|
||||||
|
// Отчёты
|
||||||
|
public bool ReportsView { get; set; }
|
||||||
|
public bool ReportsFinanceView { get; set; }
|
||||||
|
public bool ReportsStockView { get; set; }
|
||||||
|
|
||||||
|
// Настройки организации
|
||||||
|
public bool OrgSettingsManage { get; set; }
|
||||||
|
public bool EmployeesManage { get; set; }
|
||||||
|
public bool RolesManage { get; set; }
|
||||||
|
public bool StoresManage { get; set; }
|
||||||
|
public bool RetailPointsManage { get; set; }
|
||||||
|
public bool CashRegistersManage { get; set; }
|
||||||
|
public bool IntegrationsManage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Полный набор всех true — для системной роли «Администратор».</summary>
|
||||||
|
public static RolePermissions All() => new()
|
||||||
|
{
|
||||||
|
ProductsView = true, ProductsEdit = true, ProductsDelete = true,
|
||||||
|
ProductGroupsManage = true, PriceTypesManage = true, UnitsManage = true,
|
||||||
|
SuppliesView = true, SuppliesEdit = true, SuppliesPost = true, SuppliesDelete = true,
|
||||||
|
DemandsView = true, DemandsEdit = true, DemandsPost = true,
|
||||||
|
RetailSalesOperate = true, RetailSalesRefund = true,
|
||||||
|
CounterpartiesView = true, CounterpartiesEdit = true, CounterpartiesDelete = true,
|
||||||
|
StocksView = true, InventoryEdit = true, LossEdit = true, EnterEdit = true,
|
||||||
|
ReportsView = true, ReportsFinanceView = true, ReportsStockView = true,
|
||||||
|
OrgSettingsManage = true, EmployeesManage = true, RolesManage = true,
|
||||||
|
StoresManage = true, RetailPointsManage = true,
|
||||||
|
CashRegistersManage = true, IntegrationsManage = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
20
src/food-market.domain/Organizations/SuperAdminAuditLog.cs
Normal file
20
src/food-market.domain/Organizations/SuperAdminAuditLog.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
using foodmarket.Domain.Common;
|
||||||
|
|
||||||
|
namespace foodmarket.Domain.Organizations;
|
||||||
|
|
||||||
|
/// <summary>Журнал действий SuperAdmin'а: создание/правка/архивирование
|
||||||
|
/// организаций, смена аккаунт-владельца, правки в режиме «войти как».
|
||||||
|
/// Не tenant-scoped — лог общий для всей системы.</summary>
|
||||||
|
public class SuperAdminAuditLog : Entity
|
||||||
|
{
|
||||||
|
public Guid SuperAdminUserId { get; set; }
|
||||||
|
public string ActionType { get; set; } = "";
|
||||||
|
public Guid? OrganizationId { get; set; }
|
||||||
|
public string? EntityType { get; set; }
|
||||||
|
public Guid? EntityId { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Reason { get; set; }
|
||||||
|
/// <summary>JSON с diff'ом before/after или другим payload'ом действия.</summary>
|
||||||
|
public string ChangesJson { get; set; } = "{}";
|
||||||
|
public string IpAddress { get; set; } = "";
|
||||||
|
}
|
||||||
15
src/food-market.domain/Organizations/SystemSettings.cs
Normal file
15
src/food-market.domain/Organizations/SystemSettings.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
using foodmarket.Domain.Common;
|
||||||
|
|
||||||
|
namespace foodmarket.Domain.Organizations;
|
||||||
|
|
||||||
|
/// <summary>Глобальные настройки платформы (single-row table). Управляются
|
||||||
|
/// SuperAdmin'ом в /super-admin/settings. Не tenant-scoped — действует на всю
|
||||||
|
/// систему. Расширяется добавлением новых типизированных полей по мере
|
||||||
|
/// необходимости (KISS — без key-value generic'а).</summary>
|
||||||
|
public class SystemSettings : Entity
|
||||||
|
{
|
||||||
|
/// <summary>Сколько дней должен пройти после архивации, прежде чем
|
||||||
|
/// SuperAdmin может удалить организацию навсегда. 0 = удалять сразу
|
||||||
|
/// после архивации (для dev/staging). По умолчанию 30 (prod-safe).</summary>
|
||||||
|
public int ArchiveRetentionDays { get; set; } = 30;
|
||||||
|
}
|
||||||
62
src/food-market.domain/Purchases/Supply.cs
Normal file
62
src/food-market.domain/Purchases/Supply.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
using foodmarket.Domain.Catalog;
|
||||||
|
using foodmarket.Domain.Common;
|
||||||
|
|
||||||
|
namespace foodmarket.Domain.Purchases;
|
||||||
|
|
||||||
|
public enum SupplyStatus
|
||||||
|
{
|
||||||
|
Draft = 0,
|
||||||
|
Posted = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Supply : TenantEntity
|
||||||
|
{
|
||||||
|
/// <summary>Human-readable document number, unique per organization (e.g. "П-2026-000001").</summary>
|
||||||
|
public string Number { get; set; } = "";
|
||||||
|
|
||||||
|
public DateTime Date { get; set; } = DateTime.UtcNow;
|
||||||
|
public SupplyStatus Status { get; set; } = SupplyStatus.Draft;
|
||||||
|
|
||||||
|
public Guid SupplierId { get; set; }
|
||||||
|
public Counterparty Supplier { get; set; } = null!;
|
||||||
|
|
||||||
|
public Guid StoreId { get; set; }
|
||||||
|
public Store Store { get; set; } = null!;
|
||||||
|
|
||||||
|
public Guid CurrencyId { get; set; }
|
||||||
|
public Currency Currency { get; set; } = null!;
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Sum of line totals. Computed on save.</summary>
|
||||||
|
public decimal Total { get; set; }
|
||||||
|
|
||||||
|
public DateTime? PostedAt { get; set; }
|
||||||
|
public Guid? PostedByUserId { get; set; }
|
||||||
|
|
||||||
|
public ICollection<SupplyLine> Lines { get; set; } = new List<SupplyLine>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SupplyLine : TenantEntity
|
||||||
|
{
|
||||||
|
public Guid SupplyId { get; set; }
|
||||||
|
public Supply Supply { get; set; } = null!;
|
||||||
|
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
public Product Product { get; set; } = null!;
|
||||||
|
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
public decimal LineTotal { get; set; }
|
||||||
|
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Если true — пользователь вручную задал розничную цену для
|
||||||
|
/// этой строки (через UI приёмки). При Posting автонаценка по Group.MarkupPercent
|
||||||
|
/// для этой строки пропускается.</summary>
|
||||||
|
public bool RetailPriceManuallyOverridden { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Розничная цена, которую пользователь вписал в колонке «Розничная»
|
||||||
|
/// строки приёмки. Применяется к Product.Prices[default] при Posting.</summary>
|
||||||
|
public decimal? RetailPriceOverride { get; set; }
|
||||||
|
}
|
||||||
70
src/food-market.domain/Sales/RetailSale.cs
Normal file
70
src/food-market.domain/Sales/RetailSale.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
using foodmarket.Domain.Catalog;
|
||||||
|
using foodmarket.Domain.Common;
|
||||||
|
|
||||||
|
namespace foodmarket.Domain.Sales;
|
||||||
|
|
||||||
|
public enum RetailSaleStatus
|
||||||
|
{
|
||||||
|
Draft = 0,
|
||||||
|
Posted = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PaymentMethod
|
||||||
|
{
|
||||||
|
Cash = 0,
|
||||||
|
Card = 1,
|
||||||
|
BankTransfer = 2,
|
||||||
|
Bonus = 3,
|
||||||
|
Mixed = 99,
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RetailSale : TenantEntity
|
||||||
|
{
|
||||||
|
public string Number { get; set; } = "";
|
||||||
|
public DateTime Date { get; set; } = DateTime.UtcNow;
|
||||||
|
public RetailSaleStatus Status { get; set; } = RetailSaleStatus.Draft;
|
||||||
|
|
||||||
|
public Guid StoreId { get; set; }
|
||||||
|
public Store Store { get; set; } = null!;
|
||||||
|
|
||||||
|
public Guid? RetailPointId { get; set; }
|
||||||
|
public RetailPoint? RetailPoint { get; set; }
|
||||||
|
|
||||||
|
public Guid? CustomerId { get; set; }
|
||||||
|
public Counterparty? Customer { get; set; }
|
||||||
|
|
||||||
|
public Guid? CashierUserId { get; set; }
|
||||||
|
|
||||||
|
public Guid CurrencyId { get; set; }
|
||||||
|
public Currency Currency { get; set; } = null!;
|
||||||
|
|
||||||
|
public decimal Subtotal { get; set; } // sum of LineTotal before discount
|
||||||
|
public decimal DiscountTotal { get; set; }
|
||||||
|
public decimal Total { get; set; } // = Subtotal - DiscountTotal
|
||||||
|
|
||||||
|
public PaymentMethod Payment { get; set; } = PaymentMethod.Cash;
|
||||||
|
public decimal PaidCash { get; set; }
|
||||||
|
public decimal PaidCard { get; set; }
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public DateTime? PostedAt { get; set; }
|
||||||
|
public Guid? PostedByUserId { get; set; }
|
||||||
|
|
||||||
|
public ICollection<RetailSaleLine> Lines { get; set; } = new List<RetailSaleLine>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RetailSaleLine : TenantEntity
|
||||||
|
{
|
||||||
|
public Guid RetailSaleId { get; set; }
|
||||||
|
public RetailSale RetailSale { get; set; } = null!;
|
||||||
|
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
public Product Product { get; set; } = null!;
|
||||||
|
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
public decimal Discount { get; set; }
|
||||||
|
public decimal LineTotal { get; set; } // = Quantity * UnitPrice - Discount
|
||||||
|
public decimal VatPercent { get; set; } // snapshot
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Integrations.MoySklad;
|
||||||
|
|
||||||
|
public enum ImportJobStatus { Running, Succeeded, Failed, Cancelled }
|
||||||
|
|
||||||
|
public class ImportJobProgress
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; } = Guid.NewGuid();
|
||||||
|
public string Kind { get; init; } = ""; // "products" | "counterparties"
|
||||||
|
public DateTime StartedAt { get; init; } = DateTime.UtcNow;
|
||||||
|
public DateTime? FinishedAt { get; set; }
|
||||||
|
public ImportJobStatus Status { get; set; } = ImportJobStatus.Running;
|
||||||
|
public string? Stage { get; set; } // человекочитаемое описание текущего шага
|
||||||
|
public int Total { get; set; } // входящих записей от MS (растёт по мере пейджинга)
|
||||||
|
public int Created { get; set; }
|
||||||
|
public int Updated { get; set; }
|
||||||
|
public int Skipped { get; set; }
|
||||||
|
public int Deleted { get; set; } // для cleanup
|
||||||
|
public int GroupsCreated { get; set; }
|
||||||
|
public string? Message { get; set; } // последняя ошибка / финальное сообщение
|
||||||
|
public List<string> Errors { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process-memory реестр прогресса фоновых импортов. Один процесс API — одно DI singleton.
|
||||||
|
// При рестарте контейнера история импортов теряется — для просмотра «вчерашнего» надо
|
||||||
|
// смотреть логи. На MVP достаточно.
|
||||||
|
public class ImportJobRegistry
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<Guid, ImportJobProgress> _jobs = new();
|
||||||
|
|
||||||
|
public ImportJobProgress Create(string kind)
|
||||||
|
{
|
||||||
|
var job = new ImportJobProgress { Kind = kind };
|
||||||
|
_jobs[job.Id] = job;
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImportJobProgress? Get(Guid id) => _jobs.TryGetValue(id, out var j) ? j : null;
|
||||||
|
|
||||||
|
public IReadOnlyList<ImportJobProgress> RecentlyFinished(int take = 10) =>
|
||||||
|
_jobs.Values
|
||||||
|
.Where(j => j.FinishedAt is not null)
|
||||||
|
.OrderByDescending(j => j.FinishedAt)
|
||||||
|
.Take(take)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Integrations.MoySklad;
|
||||||
|
|
||||||
|
public record MoySkladApiResult<T>(bool Success, T? Value, int? StatusCode, string? Error)
|
||||||
|
{
|
||||||
|
public static MoySkladApiResult<T> Ok(T value) => new(true, value, 200, null);
|
||||||
|
public static MoySkladApiResult<T> Fail(int status, string? error) => new(false, default, status, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thin HTTP wrapper over MoySklad JSON-API 1.2. Caller supplies the personal token per request
|
||||||
|
// — we never persist it.
|
||||||
|
public class MoySkladClient
|
||||||
|
{
|
||||||
|
// Trailing slash is critical: otherwise HttpClient drops the last path segment
|
||||||
|
// when resolving relative URIs (RFC 3986 §5.3), so "entity/product" would hit
|
||||||
|
// "/api/remap/entity/product" instead of "/api/remap/1.2/entity/product".
|
||||||
|
private const string BaseUrl = "https://api.moysklad.ru/api/remap/1.2/";
|
||||||
|
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
|
||||||
|
public MoySkladClient(HttpClient http)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_http.BaseAddress ??= new Uri(BaseUrl);
|
||||||
|
_http.Timeout = TimeSpan.FromSeconds(90);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string token)
|
||||||
|
{
|
||||||
|
var req = new HttpRequestMessage(method, pathAndQuery);
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
// MoySklad requires the exact literal "application/json;charset=utf-8" (no space
|
||||||
|
// after ';'). The typed MediaTypeWithQualityHeaderValue API normalizes to
|
||||||
|
// "application/json; charset=utf-8" which MoySklad rejects with code 1062.
|
||||||
|
req.Headers.TryAddWithoutValidation("Accept", "application/json;charset=utf-8");
|
||||||
|
// MoySklad's nginx edge returns 415 for requests without a User-Agent, and we want
|
||||||
|
// auto-decompression (Accept-Encoding is added automatically by HttpClient when
|
||||||
|
// AutomaticDecompression is set on the primary handler — see Program.cs).
|
||||||
|
if (!req.Headers.UserAgent.Any())
|
||||||
|
{
|
||||||
|
req.Headers.TryAddWithoutValidation("User-Agent", "food-market/0.1 (+https://github.com/nurdotnet/food-market)");
|
||||||
|
}
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var req = Build(HttpMethod.Get, "entity/organization?limit=1", token);
|
||||||
|
using var res = await _http.SendAsync(req, ct);
|
||||||
|
if (!res.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await res.Content.ReadAsStringAsync(ct);
|
||||||
|
return MoySkladApiResult<MsOrganization>.Fail((int)res.StatusCode, body);
|
||||||
|
}
|
||||||
|
var list = await res.Content.ReadFromJsonAsync<MsListResponse<MsOrganization>>(Json, ct);
|
||||||
|
var org = list?.Rows.FirstOrDefault();
|
||||||
|
return org is null
|
||||||
|
? MoySkladApiResult<MsOrganization>.Fail(200, "Empty organization list returned by MoySklad.")
|
||||||
|
: MoySkladApiResult<MsOrganization>.Ok(org);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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" : "";
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
MsListResponse<T>? page = null;
|
||||||
|
Exception? lastErr = null;
|
||||||
|
for (var attempt = 1; attempt <= maxAttempts; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var req = Build(HttpMethod.Get, $"{path}?limit={pageSize}&offset={offset}{filterSuffix}", token);
|
||||||
|
using var res = await _http.SendAsync(req, ct);
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
page = await res.Content.ReadFromJsonAsync<MsListResponse<T>>(Json, ct);
|
||||||
|
lastErr = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException)
|
||||||
|
{
|
||||||
|
lastErr = ex;
|
||||||
|
if (attempt == maxAttempts) break;
|
||||||
|
// Exponential-ish backoff: 2s, 4s, 8s, 16s.
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastErr is not null)
|
||||||
|
{
|
||||||
|
// Re-throw after retries so the caller sees a real failure instead of silent halt.
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"MoySklad paging failed at {path} offset={offset} after {maxAttempts} attempts: {lastErr.Message}",
|
||||||
|
lastErr);
|
||||||
|
}
|
||||||
|
if (page is null || page.Rows.Count == 0) yield break;
|
||||||
|
foreach (var row in page.Rows) yield return row;
|
||||||
|
if (page.Rows.Count < pageSize) yield break;
|
||||||
|
offset += pageSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Integrations.MoySklad;
|
||||||
|
|
||||||
|
// Minimal MoySklad JSON-API response shapes we need for read-only product import.
|
||||||
|
// See https://dev.moysklad.ru/doc/api/remap/1.2/ — we intentionally ignore fields we don't consume.
|
||||||
|
|
||||||
|
public class MsListResponse<T>
|
||||||
|
{
|
||||||
|
[JsonPropertyName("meta")] public MsListMeta? Meta { get; set; }
|
||||||
|
[JsonPropertyName("rows")] public List<T> Rows { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MsListMeta
|
||||||
|
{
|
||||||
|
[JsonPropertyName("size")] public int Size { get; set; }
|
||||||
|
[JsonPropertyName("limit")] public int Limit { get; set; }
|
||||||
|
[JsonPropertyName("offset")] public int Offset { get; set; }
|
||||||
|
[JsonPropertyName("href")] public string? Href { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MsMeta
|
||||||
|
{
|
||||||
|
[JsonPropertyName("href")] public string? Href { get; set; }
|
||||||
|
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||||
|
[JsonPropertyName("uuidHref")] public string? UuidHref { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MsMetaWrapper
|
||||||
|
{
|
||||||
|
[JsonPropertyName("meta")] public MsMeta? Meta { get; set; }
|
||||||
|
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||||
|
[JsonPropertyName("code")] public string? Code { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MsOrganization
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string? Id { get; set; }
|
||||||
|
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||||
|
[JsonPropertyName("inn")] public string? Inn { get; set; }
|
||||||
|
[JsonPropertyName("kpp")] public string? Kpp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MsProduct
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string? Id { get; set; }
|
||||||
|
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||||
|
[JsonPropertyName("article")] public string? Article { get; set; }
|
||||||
|
[JsonPropertyName("code")] public string? Code { get; set; }
|
||||||
|
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||||
|
[JsonPropertyName("weighed")] public bool Weighed { get; set; }
|
||||||
|
[JsonPropertyName("vat")] public int? Vat { get; set; }
|
||||||
|
[JsonPropertyName("vatEnabled")] public bool? VatEnabled { get; set; }
|
||||||
|
[JsonPropertyName("archived")] public bool Archived { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("barcodes")] public List<Dictionary<string, string>>? Barcodes { get; set; }
|
||||||
|
[JsonPropertyName("salePrices")] public List<MsSalePrice>? SalePrices { get; set; }
|
||||||
|
[JsonPropertyName("buyPrice")] public MsMoney? BuyPrice { get; set; }
|
||||||
|
[JsonPropertyName("minPrice")] public MsMoney? MinPrice { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("uom")] public MsMetaWrapper? Uom { get; set; }
|
||||||
|
[JsonPropertyName("productFolder")] public MsMetaWrapper? ProductFolder { get; set; }
|
||||||
|
[JsonPropertyName("country")] public MsMetaWrapper? Country { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("alcoholic")] public MsAlcoholic? Alcoholic { get; set; }
|
||||||
|
[JsonPropertyName("tnved")] public string? Tnved { get; set; }
|
||||||
|
[JsonPropertyName("trackingType")] public string? TrackingType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MsSalePrice
|
||||||
|
{
|
||||||
|
[JsonPropertyName("value")] public decimal Value { get; set; } // minor units (копейки/тиын) — MoySklad may return fractional
|
||||||
|
[JsonPropertyName("currency")] public MsMetaWrapper? Currency { get; set; }
|
||||||
|
[JsonPropertyName("priceType")] public MsPriceType? PriceType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MsPriceType
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string? Id { get; set; }
|
||||||
|
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||||
|
[JsonPropertyName("externalCode")] public string? ExternalCode { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MsMoney
|
||||||
|
{
|
||||||
|
[JsonPropertyName("value")] public decimal Value { get; set; }
|
||||||
|
[JsonPropertyName("currency")] public MsMetaWrapper? Currency { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MsAlcoholic
|
||||||
|
{
|
||||||
|
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||||
|
[JsonPropertyName("strength")] public double? Strength { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MsCurrency
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string? Id { get; set; }
|
||||||
|
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||||
|
[JsonPropertyName("isoCode")] public string? IsoCode { get; set; }
|
||||||
|
[JsonPropertyName("rate")] public double? Rate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MsUom
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string? Id { get; set; }
|
||||||
|
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||||
|
[JsonPropertyName("code")] public string? Code { get; set; }
|
||||||
|
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MsProductFolder
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string? Id { get; set; }
|
||||||
|
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||||
|
[JsonPropertyName("pathName")] public string? PathName { get; set; }
|
||||||
|
[JsonPropertyName("productFolder")] public MsMetaWrapper? ParentFolder { get; set; }
|
||||||
|
[JsonPropertyName("archived")] public bool Archived { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MsCountry
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string? Id { get; set; }
|
||||||
|
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||||
|
[JsonPropertyName("code")] public string? Code { get; set; }
|
||||||
|
[JsonPropertyName("externalCode")] public string? ExternalCode { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MsCounterparty
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string? Id { get; set; }
|
||||||
|
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||||
|
[JsonPropertyName("legalTitle")] public string? LegalTitle { get; set; }
|
||||||
|
[JsonPropertyName("legalAddress")] public string? LegalAddress { get; set; }
|
||||||
|
[JsonPropertyName("actualAddress")] public string? ActualAddress { get; set; }
|
||||||
|
[JsonPropertyName("inn")] public string? Inn { get; set; }
|
||||||
|
[JsonPropertyName("kpp")] public string? Kpp { get; set; }
|
||||||
|
[JsonPropertyName("ogrn")] public string? Ogrn { get; set; }
|
||||||
|
[JsonPropertyName("companyType")] public string? CompanyType { get; set; }
|
||||||
|
[JsonPropertyName("phone")] public string? Phone { get; set; }
|
||||||
|
[JsonPropertyName("email")] public string? Email { get; set; }
|
||||||
|
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||||
|
[JsonPropertyName("archived")] public bool Archived { get; set; }
|
||||||
|
[JsonPropertyName("tags")] public List<string>? Tags { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,378 @@
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Domain.Catalog;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Integrations.MoySklad;
|
||||||
|
|
||||||
|
public record MoySkladImportResult(
|
||||||
|
int Total,
|
||||||
|
int Created,
|
||||||
|
int Skipped,
|
||||||
|
int GroupsCreated,
|
||||||
|
IReadOnlyList<string> Errors);
|
||||||
|
|
||||||
|
public class MoySkladImportService
|
||||||
|
{
|
||||||
|
private readonly MoySkladClient _client;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
private readonly ILogger<MoySkladImportService> _log;
|
||||||
|
|
||||||
|
public MoySkladImportService(
|
||||||
|
MoySkladClient client,
|
||||||
|
AppDbContext db,
|
||||||
|
ITenantContext tenant,
|
||||||
|
ILogger<MoySkladImportService> log)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_db = db;
|
||||||
|
_tenant = tenant;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct)
|
||||||
|
=> _client.WhoAmIAsync(token, ct);
|
||||||
|
|
||||||
|
public async Task<MoySkladImportResult> ImportCounterpartiesAsync(
|
||||||
|
string token,
|
||||||
|
bool overwriteExisting,
|
||||||
|
CancellationToken ct,
|
||||||
|
ImportJobProgress? progress = null,
|
||||||
|
Guid? organizationIdOverride = null)
|
||||||
|
{
|
||||||
|
var orgId = organizationIdOverride ?? _tenant.OrganizationId
|
||||||
|
?? throw new InvalidOperationException("No tenant organization in context.");
|
||||||
|
|
||||||
|
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще —
|
||||||
|
// counterparty entity содержит только group (группа доступа), tags
|
||||||
|
// (произвольные), state (пользовательская цепочка статусов), companyType
|
||||||
|
// (legal/individual/entrepreneur). Никакого role/kind. Поэтому у нас тоже
|
||||||
|
// этого поля нет — пусть пользователь сам решит.
|
||||||
|
|
||||||
|
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
|
||||||
|
=> companyType switch
|
||||||
|
{
|
||||||
|
"individual" or "entrepreneur" => foodmarket.Domain.Catalog.CounterpartyType.Individual,
|
||||||
|
_ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Загружаем существующих в память — обновлять будем по имени (case-insensitive).
|
||||||
|
// Раньше при overwriteExisting=true бага: пропускалась проверка "skip" и ВСЕ
|
||||||
|
// поступающие записи добавлялись как новые, порождая дубли. Теперь если имя уже
|
||||||
|
// есть — обновляем ту же запись, иначе создаём.
|
||||||
|
var existingByName = await _db.Counterparties
|
||||||
|
.ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct);
|
||||||
|
|
||||||
|
var created = 0;
|
||||||
|
var updated = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
var total = 0;
|
||||||
|
var errors = new List<string>();
|
||||||
|
var batch = 0;
|
||||||
|
|
||||||
|
await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
|
{
|
||||||
|
total++;
|
||||||
|
if (progress is not null) progress.Total = total;
|
||||||
|
// Архивных не пропускаем — импортируем как IsActive=false (см. ApplyCounterparty).
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (existingByName.TryGetValue(c.Name, out var existing))
|
||||||
|
{
|
||||||
|
if (!overwriteExisting) { skipped++; if (progress is not null) progress.Skipped = skipped; continue; }
|
||||||
|
ApplyCounterparty(existing, c, ResolveType);
|
||||||
|
updated++;
|
||||||
|
if (progress is not null) progress.Updated = updated;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var entity = new foodmarket.Domain.Catalog.Counterparty { OrganizationId = orgId };
|
||||||
|
ApplyCounterparty(entity, c, ResolveType);
|
||||||
|
_db.Counterparties.Add(entity);
|
||||||
|
existingByName[c.Name] = entity;
|
||||||
|
created++;
|
||||||
|
if (progress is not null) progress.Created = created;
|
||||||
|
}
|
||||||
|
|
||||||
|
batch++;
|
||||||
|
if (batch >= 100)
|
||||||
|
{
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
batch = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "Failed to import counterparty {Name}", c.Name);
|
||||||
|
errors.Add($"{c.Name}: {ex.Message}");
|
||||||
|
if (progress is not null) progress.Errors = errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MoySkladImportResult> ImportProductsAsync(
|
||||||
|
string token,
|
||||||
|
bool overwriteExisting,
|
||||||
|
CancellationToken ct,
|
||||||
|
ImportJobProgress? progress = null,
|
||||||
|
Guid? organizationIdOverride = null)
|
||||||
|
{
|
||||||
|
var orgId = organizationIdOverride ?? _tenant.OrganizationId
|
||||||
|
?? throw new InvalidOperationException("No tenant organization in context.");
|
||||||
|
|
||||||
|
// Дефолт VAT — из страны организации (Country.VatRate), decimal(5,2).
|
||||||
|
var defaultVat = await _db.Countries
|
||||||
|
.Where(c => c.Code == (_db.Organizations
|
||||||
|
.Where(o => o.Id == orgId).Select(o => o.CountryCode).First()))
|
||||||
|
.Select(c => (decimal?)c.VatRate).FirstOrDefaultAsync(ct) ?? 16m;
|
||||||
|
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", ct)
|
||||||
|
?? await _db.UnitsOfMeasure.FirstAsync(ct);
|
||||||
|
var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct)
|
||||||
|
?? await _db.PriceTypes.FirstAsync(ct);
|
||||||
|
var kzt = await _db.Currencies.FirstAsync(c => c.Code == "KZT", ct);
|
||||||
|
|
||||||
|
var countriesByName = await _db.Countries
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.ToDictionaryAsync(c => c.Name, c => c.Id, ct);
|
||||||
|
|
||||||
|
// Дефолтная группа на случай, когда у товара в MoySklad нет productFolder.
|
||||||
|
var defaultGroup = await _db.ProductGroups.FirstOrDefaultAsync(g => g.Name == "Продукты питания", ct);
|
||||||
|
if (defaultGroup is null)
|
||||||
|
{
|
||||||
|
defaultGroup = new ProductGroup
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
Name = "Продукты питания",
|
||||||
|
Path = "Продукты питания",
|
||||||
|
};
|
||||||
|
_db.ProductGroups.Add(defaultGroup);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
var defaultGroupId = defaultGroup.Id;
|
||||||
|
|
||||||
|
// Import folders first — build flat then link parents. Архивные тоже берём,
|
||||||
|
// помечаем IsActive=false — у MoySklad у productfolder есть archived.
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
if (f.Id is null) continue;
|
||||||
|
var existing = await _db.ProductGroups.FirstOrDefaultAsync(
|
||||||
|
g => g.Name == f.Name && g.Path == (f.PathName ?? f.Name), ct);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
localGroupByMsId[f.Id] = existing.Id;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var g = new ProductGroup
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
Name = f.Name,
|
||||||
|
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
|
||||||
|
};
|
||||||
|
_db.ProductGroups.Add(g);
|
||||||
|
localGroupByMsId[f.Id] = g.Id;
|
||||||
|
groupsCreated++;
|
||||||
|
}
|
||||||
|
if (groupsCreated > 0) await _db.SaveChangesAsync(ct);
|
||||||
|
if (progress is not null) progress.GroupsCreated = groupsCreated;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
.Where(p => p.Article != null)
|
||||||
|
.ToDictionaryAsync(p => p.Article!, p => p, StringComparer.OrdinalIgnoreCase, ct);
|
||||||
|
var existingBarcodeSet = new HashSet<string>(
|
||||||
|
await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct));
|
||||||
|
|
||||||
|
await foreach (var p in _client.StreamProductsAsync(token, ct))
|
||||||
|
{
|
||||||
|
total++;
|
||||||
|
if (progress is not null) progress.Total = total;
|
||||||
|
// Архивных не пропускаем — импортируем как IsActive=false.
|
||||||
|
|
||||||
|
var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article;
|
||||||
|
var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingByArticle.ContainsKey(article);
|
||||||
|
|
||||||
|
if (alreadyByArticle && !overwriteExisting)
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
if (progress is not null) progress.Skipped = skipped;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Vat товара: из MoySklad.vat если задано, иначе дефолт страны.
|
||||||
|
// VatEnabled: приоритет p.VatEnabled, fallback — «без НДС» если p.Vat=0.
|
||||||
|
var vat = p.Vat.HasValue ? (decimal)p.Vat.Value : defaultVat;
|
||||||
|
var vatEnabled = p.VatEnabled ?? (p.Vat is null || p.Vat.Value != 0);
|
||||||
|
Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId
|
||||||
|
&& localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null;
|
||||||
|
Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null;
|
||||||
|
|
||||||
|
var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
?? p.SalePrices?.FirstOrDefault();
|
||||||
|
|
||||||
|
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.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece;
|
||||||
|
product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED";
|
||||||
|
product.ReferencePrice = p.BuyPrice is null ? product.ReferencePrice : p.BuyPrice.Value / 100m;
|
||||||
|
updated++;
|
||||||
|
if (progress is not null) progress.Updated = updated;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
product = new Product
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
Name = Trim(p.Name, 500),
|
||||||
|
Article = Trim(article, 500),
|
||||||
|
Description = p.Description,
|
||||||
|
UnitOfMeasureId = baseUnit.Id,
|
||||||
|
Vat = vat,
|
||||||
|
VatEnabled = vatEnabled,
|
||||||
|
ProductGroupId = groupId ?? defaultGroupId,
|
||||||
|
CountryOfOriginId = countryId,
|
||||||
|
Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece,
|
||||||
|
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
|
||||||
|
ReferencePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
|
||||||
|
PurchaseCurrencyId = kzt.Id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (retailPrice is not null)
|
||||||
|
{
|
||||||
|
product.Prices.Add(new ProductPrice
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
PriceTypeId = retailType.Id,
|
||||||
|
Amount = retailPrice.Value / 100m,
|
||||||
|
CurrencyId = kzt.Id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var b in ExtractBarcodes(p))
|
||||||
|
{
|
||||||
|
if (existingBarcodeSet.Contains(b.Code))
|
||||||
|
{
|
||||||
|
errors.Add($"{p.Name}: штрихкод {b.Code} уже занят, пропущен.");
|
||||||
|
if (progress is not null) progress.Errors = errors;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
product.Barcodes.Add(b);
|
||||||
|
existingBarcodeSet.Add(b.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.Products.Add(product);
|
||||||
|
if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product;
|
||||||
|
created++;
|
||||||
|
if (progress is not null) progress.Created = created;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush чаще (каждые 100) чтобы при сетевом обрыве на следующей странице
|
||||||
|
// мы сохранили как можно больше и смогли безопасно продолжить с overwrite.
|
||||||
|
if ((created + updated) % 100 == 0) await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "Failed to import MoySklad product {Name}", p.Name);
|
||||||
|
errors.Add($"{p.Name}: {ex.Message}");
|
||||||
|
if (progress is not null) progress.Errors = errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// Финальная проверка дубликатов штрихкодов (исторические записи или
|
||||||
|
// расхождения c уникальным индексом). Только warning в errors[].
|
||||||
|
var duplicates = await _db.ProductBarcodes
|
||||||
|
.GroupBy(b => b.Code)
|
||||||
|
.Where(g => g.Count() > 1)
|
||||||
|
.Select(g => g.Key)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
foreach (var dup in duplicates)
|
||||||
|
errors.Add($"Внимание: штрихкод {dup} привязан к нескольким товарам — почисти вручную.");
|
||||||
|
if (progress is not null && duplicates.Count > 0) progress.Errors = errors;
|
||||||
|
|
||||||
|
return new MoySkladImportResult(total, created + updated, skipped, groupsCreated, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ProductBarcode> ExtractBarcodes(MsProduct p)
|
||||||
|
{
|
||||||
|
if (p.Barcodes is null) return [];
|
||||||
|
var list = new List<ProductBarcode>();
|
||||||
|
var primarySet = false;
|
||||||
|
foreach (var entry in p.Barcodes)
|
||||||
|
{
|
||||||
|
foreach (var (kind, code) in entry)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(code)) continue;
|
||||||
|
var type = kind switch
|
||||||
|
{
|
||||||
|
"ean13" => BarcodeType.Ean13,
|
||||||
|
"ean8" => BarcodeType.Ean8,
|
||||||
|
"code128" => BarcodeType.Code128,
|
||||||
|
"gtin" => BarcodeType.Ean13,
|
||||||
|
"upca" => BarcodeType.Upca,
|
||||||
|
"upce" => BarcodeType.Upce,
|
||||||
|
_ => BarcodeType.Other,
|
||||||
|
};
|
||||||
|
list.Add(new ProductBarcode { Code = code.Length > 500 ? code[..500] : code, Type = type, IsPrimary = !primarySet });
|
||||||
|
primarySet = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Trim(string? s, int max)
|
||||||
|
=> string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max]);
|
||||||
|
|
||||||
|
private static string? TryExtractId(string href)
|
||||||
|
{
|
||||||
|
// href like "https://api.moysklad.ru/api/remap/1.2/entity/productfolder/<guid>"
|
||||||
|
var lastSlash = href.LastIndexOf('/');
|
||||||
|
return lastSlash >= 0 ? href[(lastSlash + 1)..] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/food-market.infrastructure/Inventory/StockService.cs
Normal file
68
src/food-market.infrastructure/Inventory/StockService.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Application.Inventory;
|
||||||
|
using foodmarket.Domain.Inventory;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Inventory;
|
||||||
|
|
||||||
|
public class StockService : IStockService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
|
||||||
|
public StockService(AppDbContext db, ITenantContext tenant)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_tenant = tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<decimal> ApplyMovementAsync(StockMovementDraft d, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("Tenant not set.");
|
||||||
|
|
||||||
|
_db.StockMovements.Add(new StockMovement
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
ProductId = d.ProductId,
|
||||||
|
StoreId = d.StoreId,
|
||||||
|
Quantity = d.Quantity,
|
||||||
|
UnitCost = d.UnitCost,
|
||||||
|
Type = d.Type,
|
||||||
|
DocumentType = d.DocumentType,
|
||||||
|
DocumentId = d.DocumentId,
|
||||||
|
DocumentNumber = d.DocumentNumber,
|
||||||
|
OccurredAt = d.OccurredAt ?? DateTime.UtcNow,
|
||||||
|
CreatedByUserId = d.CreatedByUserId,
|
||||||
|
Notes = d.Notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
var stock = await _db.Stocks.FirstOrDefaultAsync(
|
||||||
|
s => s.ProductId == d.ProductId && s.StoreId == d.StoreId, ct);
|
||||||
|
|
||||||
|
if (stock is null)
|
||||||
|
{
|
||||||
|
stock = new Stock
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
ProductId = d.ProductId,
|
||||||
|
StoreId = d.StoreId,
|
||||||
|
Quantity = d.Quantity,
|
||||||
|
};
|
||||||
|
_db.Stocks.Add(stock);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stock.Quantity += d.Quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stock.Quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<decimal> ApplyMovementsAsync(IEnumerable<StockMovementDraft> drafts, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var last = 0m;
|
||||||
|
foreach (var d in drafts) last = await ApplyMovementAsync(d, ct);
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue