Compare commits
32 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26a76e5aea | ||
|
|
2d1a9c8f75 | ||
|
|
7640d6ddcd | ||
|
|
a5f7060fb1 | ||
|
|
a2fa311a5d | ||
|
|
a17ca1b90c | ||
|
|
29cefb64be | ||
|
|
8ac9e04bcf | ||
|
|
5dce324f24 | ||
|
|
bcbda1ae5d | ||
|
|
3f3c7480c6 | ||
|
|
3b9cf0ee9a | ||
|
|
1c108b88a4 | ||
|
|
01f99cfff3 | ||
|
|
75d73b9dcd | ||
|
|
fa2fae9503 | ||
|
|
5bcbff66de | ||
|
|
61f2c21016 | ||
|
|
50e3676d71 | ||
|
|
d3aa13dcbf | ||
|
|
c47826e015 | ||
|
|
22502c11fd | ||
|
|
321cb76a7b | ||
|
|
cdf26d8719 | ||
|
|
1ef337a0f6 | ||
|
|
5d308a0538 | ||
|
|
067f52cf43 | ||
|
|
05553bdc3d | ||
|
|
e4a2030ad9 | ||
|
|
b07232521b | ||
|
|
cead88b0bc | ||
|
|
25f25f9171 |
110
.github/workflows/ci.yml
vendored
Normal file
110
.github/workflows/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/deploy-stage.yml
vendored
Normal file
87
.github/workflows/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/docker.yml
vendored
Normal file
113
.github/workflows/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/notify.yml
vendored
Normal file
18
.github/workflows/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
|
||||||
5
.gitignore
vendored
5
.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/
|
||||||
|
|
|
||||||
35
deploy/Dockerfile.api
Normal file
35
deploy/Dockerfile.api
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY food-market.sln global.json Directory.Build.props Directory.Packages.props ./
|
||||||
|
COPY src/food-market.domain/food-market.domain.csproj src/food-market.domain/
|
||||||
|
COPY src/food-market.shared/food-market.shared.csproj src/food-market.shared/
|
||||||
|
COPY src/food-market.application/food-market.application.csproj src/food-market.application/
|
||||||
|
COPY src/food-market.infrastructure/food-market.infrastructure.csproj src/food-market.infrastructure/
|
||||||
|
COPY src/food-market.api/food-market.api.csproj src/food-market.api/
|
||||||
|
COPY src/food-market.pos.core/food-market.pos.core.csproj src/food-market.pos.core/
|
||||||
|
COPY src/food-market.pos/food-market.pos.csproj src/food-market.pos/
|
||||||
|
|
||||||
|
RUN dotnet restore src/food-market.api/food-market.api.csproj
|
||||||
|
|
||||||
|
COPY src/ src/
|
||||||
|
RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app --no-restore
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=build /app .
|
||||||
|
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
ENV DOTNET_NOLOGO=1
|
||||||
|
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||||
|
CMD curl -fsS http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["dotnet", "foodmarket.Api.dll"]
|
||||||
16
deploy/Dockerfile.web
Normal file
16
deploy/Dockerfile.web
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
COPY src/food-market.web/package.json src/food-market.web/pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY src/food-market.web/ ./
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /src/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
41
deploy/backup.sh
Executable file
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
|
||||||
|
|
@ -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,38 @@ 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
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: ${REGISTRY:-127.0.0.1:5001}/food-market-web:${WEB_TAG:-latest}
|
||||||
|
container_name: food-market-web
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
ports:
|
||||||
|
- "8081:80" # web SPA, not on 80 (legacy nginx holds it)
|
||||||
|
|
||||||
volumes:
|
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
|
||||||
|
|
|
||||||
32
deploy/nginx.conf
Normal file
32
deploy/nginx.conf
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# API reverse-proxy — upstream name "api" resolves in the compose network.
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /connect/ {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback — all other routes return index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
docs/24x7.md
Normal file
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 — этот обход больше не нужен.
|
||||||
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» или «откатывай».
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
using foodmarket.Infrastructure.Integrations.MoySklad;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Admin;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = "AdminAccess")]
|
||||||
|
[Route("api/admin/moysklad")]
|
||||||
|
public class MoySkladImportController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly MoySkladImportService _svc;
|
||||||
|
|
||||||
|
public MoySkladImportController(MoySkladImportService svc) => _svc = svc;
|
||||||
|
|
||||||
|
public record TestRequest(string Token);
|
||||||
|
public record ImportRequest(string Token, bool OverwriteExisting = false);
|
||||||
|
|
||||||
|
[HttpPost("test")]
|
||||||
|
public async Task<IActionResult> TestConnection([FromBody] TestRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(req.Token))
|
||||||
|
return BadRequest(new { error = "Token is required." });
|
||||||
|
|
||||||
|
var result = await _svc.TestConnectionAsync(req.Token, ct);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
var msg = result.StatusCode switch
|
||||||
|
{
|
||||||
|
401 or 403 => "Токен недействителен или не имеет доступа к API.",
|
||||||
|
503 or 502 => "МойСклад временно недоступен. Повтори через минуту.",
|
||||||
|
_ => $"МойСклад вернул {result.StatusCode}: {Truncate(result.Error, 400)}",
|
||||||
|
};
|
||||||
|
return StatusCode(result.StatusCode ?? 502, new { error = msg });
|
||||||
|
}
|
||||||
|
return Ok(new { organization = result.Value!.Name, inn = result.Value.Inn });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Truncate(string? s, int max)
|
||||||
|
=> string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max] + "…");
|
||||||
|
|
||||||
|
[HttpPost("import-products")]
|
||||||
|
public async Task<ActionResult<MoySkladImportResult>> ImportProducts([FromBody] ImportRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(req.Token))
|
||||||
|
return BadRequest(new { error = "Token is required." });
|
||||||
|
|
||||||
|
var result = await _svc.ImportProductsAsync(req.Token, req.OverwriteExisting, ct);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("import-counterparties")]
|
||||||
|
public async Task<ActionResult<MoySkladImportResult>> ImportCounterparties([FromBody] ImportRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(req.Token))
|
||||||
|
return BadRequest(new { error = "Token is required." });
|
||||||
|
|
||||||
|
var result = await _svc.ImportCounterpartiesAsync(req.Token, req.OverwriteExisting, ct);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/food-market.api/Controllers/Inventory/StockController.cs
Normal file
104
src/food-market.api/Controllers/Inventory/StockController.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
using foodmarket.Application.Common;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Inventory;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/inventory")]
|
||||||
|
public class StockController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public StockController(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public record StockRow(
|
||||||
|
Guid ProductId, string ProductName, string? Article, string UnitSymbol,
|
||||||
|
Guid StoreId, string StoreName,
|
||||||
|
decimal Quantity, decimal ReservedQuantity, decimal Available);
|
||||||
|
|
||||||
|
[HttpGet("stock")]
|
||||||
|
public async Task<ActionResult<PagedResult<StockRow>>> GetStock(
|
||||||
|
[FromQuery] Guid? storeId,
|
||||||
|
[FromQuery] Guid? productId,
|
||||||
|
[FromQuery] string? search,
|
||||||
|
[FromQuery] bool includeZero = false,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 50,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = from s in _db.Stocks
|
||||||
|
join p in _db.Products on s.ProductId equals p.Id
|
||||||
|
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
|
||||||
|
join st in _db.Stores on s.StoreId equals st.Id
|
||||||
|
select new { s, p, u, st };
|
||||||
|
|
||||||
|
if (storeId.HasValue) q = q.Where(x => x.s.StoreId == storeId.Value);
|
||||||
|
if (productId.HasValue) q = q.Where(x => x.s.ProductId == productId.Value);
|
||||||
|
if (!includeZero) q = q.Where(x => x.s.Quantity != 0);
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
var term = $"%{search.Trim()}%";
|
||||||
|
q = q.Where(x => EF.Functions.ILike(x.p.Name, term)
|
||||||
|
|| (x.p.Article != null && EF.Functions.ILike(x.p.Article, term)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var items = await q
|
||||||
|
.OrderBy(x => x.p.Name)
|
||||||
|
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||||
|
.Select(x => new StockRow(
|
||||||
|
x.p.Id, x.p.Name, x.p.Article, x.u.Symbol,
|
||||||
|
x.st.Id, x.st.Name,
|
||||||
|
x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new PagedResult<StockRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MovementRow(
|
||||||
|
Guid Id, DateTime OccurredAt,
|
||||||
|
Guid ProductId, string ProductName, string? Article,
|
||||||
|
Guid StoreId, string StoreName,
|
||||||
|
decimal Quantity, decimal? UnitCost,
|
||||||
|
string Type, string DocumentType, Guid? DocumentId, string? DocumentNumber,
|
||||||
|
string? Notes);
|
||||||
|
|
||||||
|
[HttpGet("movements")]
|
||||||
|
public async Task<ActionResult<PagedResult<MovementRow>>> GetMovements(
|
||||||
|
[FromQuery] Guid? storeId,
|
||||||
|
[FromQuery] Guid? productId,
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 50,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = from m in _db.StockMovements
|
||||||
|
join p in _db.Products on m.ProductId equals p.Id
|
||||||
|
join st in _db.Stores on m.StoreId equals st.Id
|
||||||
|
select new { m, p, st };
|
||||||
|
|
||||||
|
if (storeId.HasValue) q = q.Where(x => x.m.StoreId == storeId.Value);
|
||||||
|
if (productId.HasValue) q = q.Where(x => x.m.ProductId == productId.Value);
|
||||||
|
if (from.HasValue) q = q.Where(x => x.m.OccurredAt >= from.Value);
|
||||||
|
if (to.HasValue) q = q.Where(x => x.m.OccurredAt < to.Value);
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var items = await q
|
||||||
|
.OrderByDescending(x => x.m.OccurredAt)
|
||||||
|
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||||
|
.Select(x => new MovementRow(
|
||||||
|
x.m.Id, x.m.OccurredAt,
|
||||||
|
x.p.Id, x.p.Name, x.p.Article,
|
||||||
|
x.st.Id, x.st.Name,
|
||||||
|
x.m.Quantity, x.m.UnitCost,
|
||||||
|
x.m.Type.ToString(), x.m.DocumentType, x.m.DocumentId, x.m.DocumentNumber,
|
||||||
|
x.m.Notes))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new PagedResult<MovementRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
|
||||||
|
}
|
||||||
|
}
|
||||||
293
src/food-market.api/Controllers/Purchases/SuppliesController.cs
Normal file
293
src/food-market.api/Controllers/Purchases/SuppliesController.cs
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
using foodmarket.Application.Common;
|
||||||
|
using foodmarket.Application.Inventory;
|
||||||
|
using foodmarket.Domain.Inventory;
|
||||||
|
using foodmarket.Domain.Purchases;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Purchases;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/purchases/supplies")]
|
||||||
|
public class SuppliesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IStockService _stock;
|
||||||
|
|
||||||
|
public SuppliesController(AppDbContext db, IStockService stock)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_stock = stock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SupplyListRow(
|
||||||
|
Guid Id, string Number, DateTime Date, SupplyStatus Status,
|
||||||
|
Guid SupplierId, string SupplierName,
|
||||||
|
Guid StoreId, string StoreName,
|
||||||
|
Guid CurrencyId, string CurrencyCode,
|
||||||
|
decimal Total, int LineCount,
|
||||||
|
DateTime? PostedAt);
|
||||||
|
|
||||||
|
public record SupplyLineDto(
|
||||||
|
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
|
||||||
|
string? UnitSymbol,
|
||||||
|
decimal Quantity, decimal UnitPrice, decimal LineTotal, int SortOrder);
|
||||||
|
|
||||||
|
public record SupplyDto(
|
||||||
|
Guid Id, string Number, DateTime Date, SupplyStatus Status,
|
||||||
|
Guid SupplierId, string SupplierName,
|
||||||
|
Guid StoreId, string StoreName,
|
||||||
|
Guid CurrencyId, string CurrencyCode,
|
||||||
|
string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate,
|
||||||
|
string? Notes,
|
||||||
|
decimal Total, DateTime? PostedAt,
|
||||||
|
IReadOnlyList<SupplyLineDto> Lines);
|
||||||
|
|
||||||
|
public record SupplyLineInput(Guid ProductId, decimal Quantity, decimal UnitPrice);
|
||||||
|
public record SupplyInput(
|
||||||
|
DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId,
|
||||||
|
string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate,
|
||||||
|
string? Notes,
|
||||||
|
IReadOnlyList<SupplyLineInput> Lines);
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<PagedResult<SupplyListRow>>> List(
|
||||||
|
[FromQuery] PagedRequest req,
|
||||||
|
[FromQuery] SupplyStatus? status,
|
||||||
|
[FromQuery] Guid? storeId,
|
||||||
|
[FromQuery] Guid? supplierId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = from s in _db.Supplies.AsNoTracking()
|
||||||
|
join cp in _db.Counterparties on s.SupplierId equals cp.Id
|
||||||
|
join st in _db.Stores on s.StoreId equals st.Id
|
||||||
|
join cu in _db.Currencies on s.CurrencyId equals cu.Id
|
||||||
|
select new { s, cp, st, cu };
|
||||||
|
|
||||||
|
if (status is not null) q = q.Where(x => x.s.Status == status);
|
||||||
|
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
|
||||||
|
if (supplierId is not null) q = q.Where(x => x.s.SupplierId == supplierId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
|
{
|
||||||
|
var s = req.Search.Trim().ToLower();
|
||||||
|
q = q.Where(x => x.s.Number.ToLower().Contains(s) || x.cp.Name.ToLower().Contains(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var items = await q
|
||||||
|
.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number)
|
||||||
|
.Skip(req.Skip).Take(req.Take)
|
||||||
|
.Select(x => new SupplyListRow(
|
||||||
|
x.s.Id, x.s.Number, x.s.Date, x.s.Status,
|
||||||
|
x.cp.Id, x.cp.Name,
|
||||||
|
x.st.Id, x.st.Name,
|
||||||
|
x.cu.Id, x.cu.Code,
|
||||||
|
x.s.Total,
|
||||||
|
x.s.Lines.Count,
|
||||||
|
x.s.PostedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new PagedResult<SupplyListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await GetInternal(id, ct);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
|
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var number = await GenerateNumberAsync(input.Date, ct);
|
||||||
|
var supply = new Supply
|
||||||
|
{
|
||||||
|
Number = number,
|
||||||
|
Date = input.Date,
|
||||||
|
Status = SupplyStatus.Draft,
|
||||||
|
SupplierId = input.SupplierId,
|
||||||
|
StoreId = input.StoreId,
|
||||||
|
CurrencyId = input.CurrencyId,
|
||||||
|
SupplierInvoiceNumber = input.SupplierInvoiceNumber,
|
||||||
|
SupplierInvoiceDate = input.SupplierInvoiceDate,
|
||||||
|
Notes = input.Notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
var order = 0;
|
||||||
|
foreach (var l in input.Lines)
|
||||||
|
{
|
||||||
|
supply.Lines.Add(new SupplyLine
|
||||||
|
{
|
||||||
|
ProductId = l.ProductId,
|
||||||
|
Quantity = l.Quantity,
|
||||||
|
UnitPrice = l.UnitPrice,
|
||||||
|
LineTotal = l.Quantity * l.UnitPrice,
|
||||||
|
SortOrder = order++,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
supply.Total = supply.Lines.Sum(x => x.LineTotal);
|
||||||
|
|
||||||
|
_db.Supplies.Add(supply);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
var dto = await GetInternal(supply.Id, ct);
|
||||||
|
return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (supply is null) return NotFound();
|
||||||
|
if (supply.Status != SupplyStatus.Draft)
|
||||||
|
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
|
||||||
|
|
||||||
|
supply.Date = input.Date;
|
||||||
|
supply.SupplierId = input.SupplierId;
|
||||||
|
supply.StoreId = input.StoreId;
|
||||||
|
supply.CurrencyId = input.CurrencyId;
|
||||||
|
supply.SupplierInvoiceNumber = input.SupplierInvoiceNumber;
|
||||||
|
supply.SupplierInvoiceDate = input.SupplierInvoiceDate;
|
||||||
|
supply.Notes = input.Notes;
|
||||||
|
|
||||||
|
// Replace lines wholesale (simple, idempotent).
|
||||||
|
_db.SupplyLines.RemoveRange(supply.Lines);
|
||||||
|
supply.Lines.Clear();
|
||||||
|
var order = 0;
|
||||||
|
foreach (var l in input.Lines)
|
||||||
|
{
|
||||||
|
supply.Lines.Add(new SupplyLine
|
||||||
|
{
|
||||||
|
SupplyId = supply.Id,
|
||||||
|
ProductId = l.ProductId,
|
||||||
|
Quantity = l.Quantity,
|
||||||
|
UnitPrice = l.UnitPrice,
|
||||||
|
LineTotal = l.Quantity * l.UnitPrice,
|
||||||
|
SortOrder = order++,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
supply.Total = supply.Lines.Sum(x => x.LineTotal);
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var supply = await _db.Supplies.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (supply is null) return NotFound();
|
||||||
|
if (supply.Status != SupplyStatus.Draft)
|
||||||
|
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
|
||||||
|
_db.Supplies.Remove(supply);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
|
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (supply is null) return NotFound();
|
||||||
|
if (supply.Status == SupplyStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
|
||||||
|
if (supply.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
|
||||||
|
|
||||||
|
foreach (var line in supply.Lines)
|
||||||
|
{
|
||||||
|
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||||||
|
ProductId: line.ProductId,
|
||||||
|
StoreId: supply.StoreId,
|
||||||
|
Quantity: line.Quantity,
|
||||||
|
Type: MovementType.Supply,
|
||||||
|
DocumentType: "supply",
|
||||||
|
DocumentId: supply.Id,
|
||||||
|
DocumentNumber: supply.Number,
|
||||||
|
UnitCost: line.UnitPrice,
|
||||||
|
OccurredAt: supply.Date), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
supply.Status = SupplyStatus.Posted;
|
||||||
|
supply.PostedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
|
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (supply is null) return NotFound();
|
||||||
|
if (supply.Status != SupplyStatus.Posted) return Conflict(new { error = "Документ не проведён." });
|
||||||
|
|
||||||
|
// Reverse: negative movements with same document reference
|
||||||
|
foreach (var line in supply.Lines)
|
||||||
|
{
|
||||||
|
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||||||
|
ProductId: line.ProductId,
|
||||||
|
StoreId: supply.StoreId,
|
||||||
|
Quantity: -line.Quantity,
|
||||||
|
Type: MovementType.Supply,
|
||||||
|
DocumentType: "supply-reversal",
|
||||||
|
DocumentId: supply.Id,
|
||||||
|
DocumentNumber: supply.Number,
|
||||||
|
UnitCost: line.UnitPrice,
|
||||||
|
OccurredAt: DateTime.UtcNow,
|
||||||
|
Notes: $"Отмена проведения документа {supply.Number}"), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
supply.Status = SupplyStatus.Draft;
|
||||||
|
supply.PostedAt = null;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var year = date.Year;
|
||||||
|
var prefix = $"П-{year}-";
|
||||||
|
var lastNumber = await _db.Supplies
|
||||||
|
.Where(s => s.Number.StartsWith(prefix))
|
||||||
|
.OrderByDescending(s => s.Number)
|
||||||
|
.Select(s => s.Number)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
var seq = 1;
|
||||||
|
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
|
||||||
|
seq = last + 1;
|
||||||
|
return $"{prefix}{seq:D6}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SupplyDto?> GetInternal(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var row = await (from s in _db.Supplies.AsNoTracking()
|
||||||
|
join cp in _db.Counterparties on s.SupplierId equals cp.Id
|
||||||
|
join st in _db.Stores on s.StoreId equals st.Id
|
||||||
|
join cu in _db.Currencies on s.CurrencyId equals cu.Id
|
||||||
|
where s.Id == id
|
||||||
|
select new { s, cp, st, cu }).FirstOrDefaultAsync(ct);
|
||||||
|
if (row is null) return null;
|
||||||
|
|
||||||
|
var lines = await (from l in _db.SupplyLines.AsNoTracking()
|
||||||
|
join p in _db.Products on l.ProductId equals p.Id
|
||||||
|
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
|
||||||
|
where l.SupplyId == id
|
||||||
|
orderby l.SortOrder
|
||||||
|
select new SupplyLineDto(
|
||||||
|
l.Id, l.ProductId, p.Name, p.Article, u.Symbol,
|
||||||
|
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new SupplyDto(
|
||||||
|
row.s.Id, row.s.Number, row.s.Date, row.s.Status,
|
||||||
|
row.cp.Id, row.cp.Name,
|
||||||
|
row.st.Id, row.st.Name,
|
||||||
|
row.cu.Id, row.cu.Code,
|
||||||
|
row.s.SupplierInvoiceNumber, row.s.SupplierInvoiceDate,
|
||||||
|
row.s.Notes,
|
||||||
|
row.s.Total, row.s.PostedAt,
|
||||||
|
lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
370
src/food-market.api/Controllers/Sales/RetailSalesController.cs
Normal file
370
src/food-market.api/Controllers/Sales/RetailSalesController.cs
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
using foodmarket.Application.Common;
|
||||||
|
using foodmarket.Application.Inventory;
|
||||||
|
using foodmarket.Domain.Inventory;
|
||||||
|
using foodmarket.Domain.Sales;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Sales;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/sales/retail")]
|
||||||
|
public class RetailSalesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IStockService _stock;
|
||||||
|
|
||||||
|
public RetailSalesController(AppDbContext db, IStockService stock)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_stock = stock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RetailSaleListRow(
|
||||||
|
Guid Id, string Number, DateTime Date, RetailSaleStatus Status,
|
||||||
|
Guid StoreId, string StoreName,
|
||||||
|
Guid? RetailPointId, string? RetailPointName,
|
||||||
|
Guid? CustomerId, string? CustomerName,
|
||||||
|
Guid CurrencyId, string CurrencyCode,
|
||||||
|
decimal Total, PaymentMethod Payment, int LineCount,
|
||||||
|
DateTime? PostedAt);
|
||||||
|
|
||||||
|
public record RetailSaleLineDto(
|
||||||
|
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
|
||||||
|
decimal Quantity, decimal UnitPrice, decimal Discount, decimal LineTotal, decimal VatPercent, int SortOrder);
|
||||||
|
|
||||||
|
public record RetailSaleDto(
|
||||||
|
Guid Id, string Number, DateTime Date, RetailSaleStatus Status,
|
||||||
|
Guid StoreId, string StoreName,
|
||||||
|
Guid? RetailPointId, string? RetailPointName,
|
||||||
|
Guid? CustomerId, string? CustomerName,
|
||||||
|
Guid CurrencyId, string CurrencyCode,
|
||||||
|
decimal Subtotal, decimal DiscountTotal, decimal Total,
|
||||||
|
PaymentMethod Payment, decimal PaidCash, decimal PaidCard,
|
||||||
|
string? Notes, DateTime? PostedAt,
|
||||||
|
IReadOnlyList<RetailSaleLineDto> Lines);
|
||||||
|
|
||||||
|
public record RetailSaleLineInput(Guid ProductId, decimal Quantity, decimal UnitPrice, decimal Discount, decimal VatPercent);
|
||||||
|
public record RetailSaleInput(
|
||||||
|
DateTime Date, Guid StoreId, Guid? RetailPointId, Guid? CustomerId, Guid CurrencyId,
|
||||||
|
PaymentMethod Payment, decimal PaidCash, decimal PaidCard,
|
||||||
|
string? Notes,
|
||||||
|
IReadOnlyList<RetailSaleLineInput> Lines);
|
||||||
|
|
||||||
|
public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions);
|
||||||
|
|
||||||
|
public record SalesStatsResponse(
|
||||||
|
decimal RevenueToday,
|
||||||
|
decimal RevenueThisMonth,
|
||||||
|
decimal RevenuePrevMonth,
|
||||||
|
int TransactionsToday,
|
||||||
|
int TransactionsThisMonth,
|
||||||
|
decimal AvgTicketThisMonth,
|
||||||
|
IReadOnlyList<SalesStatsBucket> Series);
|
||||||
|
|
||||||
|
/// <summary>Aggregated sales metrics + daily series for the dashboard.
|
||||||
|
/// Series buckets are days; defaults to last 30 days.</summary>
|
||||||
|
[HttpGet("stats")]
|
||||||
|
public async Task<ActionResult<SalesStatsResponse>> Stats(
|
||||||
|
[FromQuery] int days = 30,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
var todayStart = new DateTime(nowUtc.Year, nowUtc.Month, nowUtc.Day, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var prevMonthStart = monthStart.AddMonths(-1);
|
||||||
|
var seriesStart = todayStart.AddDays(-(days - 1));
|
||||||
|
|
||||||
|
var posted = _db.RetailSales.AsNoTracking().Where(s => s.Status == RetailSaleStatus.Posted);
|
||||||
|
|
||||||
|
var today = await posted.Where(s => s.Date >= todayStart && s.Date < todayStart.AddDays(1))
|
||||||
|
.GroupBy(_ => 1)
|
||||||
|
.Select(g => new { Sum = g.Sum(s => s.Total), Count = g.Count() })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
var thisMonth = await posted.Where(s => s.Date >= monthStart)
|
||||||
|
.GroupBy(_ => 1)
|
||||||
|
.Select(g => new { Sum = g.Sum(s => s.Total), Count = g.Count() })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
var prevMonth = await posted.Where(s => s.Date >= prevMonthStart && s.Date < monthStart)
|
||||||
|
.GroupBy(_ => 1)
|
||||||
|
.Select(g => new { Sum = g.Sum(s => s.Total) })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
var rawSeries = await posted.Where(s => s.Date >= seriesStart)
|
||||||
|
.GroupBy(s => s.Date.Date)
|
||||||
|
.Select(g => new { Day = g.Key, Revenue = g.Sum(s => s.Total), Tx = g.Count() })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Fill missing days with zeros so the chart line is continuous.
|
||||||
|
var byDay = rawSeries.ToDictionary(x => x.Day, x => x);
|
||||||
|
var series = Enumerable.Range(0, days)
|
||||||
|
.Select(i => seriesStart.AddDays(i).Date)
|
||||||
|
.Select(d => byDay.TryGetValue(d, out var v)
|
||||||
|
? new SalesStatsBucket(d, v.Revenue, v.Tx)
|
||||||
|
: new SalesStatsBucket(d, 0m, 0))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var thisMonthSum = thisMonth?.Sum ?? 0m;
|
||||||
|
var thisMonthCount = thisMonth?.Count ?? 0;
|
||||||
|
var avgTicket = thisMonthCount == 0 ? 0m : thisMonthSum / thisMonthCount;
|
||||||
|
|
||||||
|
return new SalesStatsResponse(
|
||||||
|
RevenueToday: today?.Sum ?? 0m,
|
||||||
|
RevenueThisMonth: thisMonthSum,
|
||||||
|
RevenuePrevMonth: prevMonth?.Sum ?? 0m,
|
||||||
|
TransactionsToday: today?.Count ?? 0,
|
||||||
|
TransactionsThisMonth: thisMonthCount,
|
||||||
|
AvgTicketThisMonth: avgTicket,
|
||||||
|
Series: series);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<PagedResult<RetailSaleListRow>>> List(
|
||||||
|
[FromQuery] PagedRequest req,
|
||||||
|
[FromQuery] RetailSaleStatus? status,
|
||||||
|
[FromQuery] Guid? storeId,
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = from s in _db.RetailSales.AsNoTracking()
|
||||||
|
join st in _db.Stores on s.StoreId equals st.Id
|
||||||
|
join cu in _db.Currencies on s.CurrencyId equals cu.Id
|
||||||
|
select new { s, st, cu };
|
||||||
|
|
||||||
|
if (status is not null) q = q.Where(x => x.s.Status == status);
|
||||||
|
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
|
||||||
|
if (from is not null) q = q.Where(x => x.s.Date >= from);
|
||||||
|
if (to is not null) q = q.Where(x => x.s.Date < to);
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
|
{
|
||||||
|
var s = req.Search.Trim().ToLower();
|
||||||
|
q = q.Where(x => x.s.Number.ToLower().Contains(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var items = await q
|
||||||
|
.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number)
|
||||||
|
.Skip(req.Skip).Take(req.Take)
|
||||||
|
.Select(x => new RetailSaleListRow(
|
||||||
|
x.s.Id, x.s.Number, x.s.Date, x.s.Status,
|
||||||
|
x.st.Id, x.st.Name,
|
||||||
|
x.s.RetailPointId,
|
||||||
|
x.s.RetailPointId == null ? null : _db.RetailPoints.Where(rp => rp.Id == x.s.RetailPointId).Select(rp => rp.Name).FirstOrDefault(),
|
||||||
|
x.s.CustomerId,
|
||||||
|
x.s.CustomerId == null ? null : _db.Counterparties.Where(c => c.Id == x.s.CustomerId).Select(c => c.Name).FirstOrDefault(),
|
||||||
|
x.cu.Id, x.cu.Code,
|
||||||
|
x.s.Total, x.s.Payment,
|
||||||
|
x.s.Lines.Count,
|
||||||
|
x.s.PostedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new PagedResult<RetailSaleListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<ActionResult<RetailSaleDto>> Get(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await GetInternal(id, ct);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Authorize(Roles = "Admin,Manager,Cashier")]
|
||||||
|
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var number = await GenerateNumberAsync(input.Date, ct);
|
||||||
|
var sale = new RetailSale
|
||||||
|
{
|
||||||
|
Number = number,
|
||||||
|
Date = input.Date,
|
||||||
|
Status = RetailSaleStatus.Draft,
|
||||||
|
StoreId = input.StoreId,
|
||||||
|
RetailPointId = input.RetailPointId,
|
||||||
|
CustomerId = input.CustomerId,
|
||||||
|
CurrencyId = input.CurrencyId,
|
||||||
|
Payment = input.Payment,
|
||||||
|
PaidCash = input.PaidCash,
|
||||||
|
PaidCard = input.PaidCard,
|
||||||
|
Notes = input.Notes,
|
||||||
|
};
|
||||||
|
ApplyLines(sale, input.Lines);
|
||||||
|
_db.RetailSales.Add(sale);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
var dto = await GetInternal(sale.Id, ct);
|
||||||
|
return CreatedAtAction(nameof(Get), new { id = sale.Id }, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Cashier")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (sale is null) return NotFound();
|
||||||
|
if (sale.Status != RetailSaleStatus.Draft)
|
||||||
|
return Conflict(new { error = "Только черновик может быть изменён." });
|
||||||
|
|
||||||
|
sale.Date = input.Date;
|
||||||
|
sale.StoreId = input.StoreId;
|
||||||
|
sale.RetailPointId = input.RetailPointId;
|
||||||
|
sale.CustomerId = input.CustomerId;
|
||||||
|
sale.CurrencyId = input.CurrencyId;
|
||||||
|
sale.Payment = input.Payment;
|
||||||
|
sale.PaidCash = input.PaidCash;
|
||||||
|
sale.PaidCard = input.PaidCard;
|
||||||
|
sale.Notes = input.Notes;
|
||||||
|
|
||||||
|
_db.RetailSaleLines.RemoveRange(sale.Lines);
|
||||||
|
sale.Lines.Clear();
|
||||||
|
ApplyLines(sale, input.Lines);
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sale = await _db.RetailSales.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (sale is null) return NotFound();
|
||||||
|
if (sale.Status != RetailSaleStatus.Draft)
|
||||||
|
return Conflict(new { error = "Нельзя удалить проведённый чек." });
|
||||||
|
_db.RetailSales.Remove(sale);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Manager,Cashier")]
|
||||||
|
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (sale is null) return NotFound();
|
||||||
|
if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." });
|
||||||
|
if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." });
|
||||||
|
|
||||||
|
foreach (var line in sale.Lines)
|
||||||
|
{
|
||||||
|
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||||||
|
ProductId: line.ProductId,
|
||||||
|
StoreId: sale.StoreId,
|
||||||
|
Quantity: -line.Quantity, // negative: товар уходит со склада
|
||||||
|
Type: MovementType.RetailSale,
|
||||||
|
DocumentType: "retail-sale",
|
||||||
|
DocumentId: sale.Id,
|
||||||
|
DocumentNumber: sale.Number,
|
||||||
|
UnitCost: line.UnitPrice,
|
||||||
|
OccurredAt: sale.Date), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
sale.Status = RetailSaleStatus.Posted;
|
||||||
|
sale.PostedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager")]
|
||||||
|
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
if (sale is null) return NotFound();
|
||||||
|
if (sale.Status != RetailSaleStatus.Posted) return Conflict(new { error = "Чек не проведён." });
|
||||||
|
|
||||||
|
foreach (var line in sale.Lines)
|
||||||
|
{
|
||||||
|
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||||||
|
ProductId: line.ProductId,
|
||||||
|
StoreId: sale.StoreId,
|
||||||
|
Quantity: +line.Quantity, // reverse — return stock
|
||||||
|
Type: MovementType.RetailSale,
|
||||||
|
DocumentType: "retail-sale-reversal",
|
||||||
|
DocumentId: sale.Id,
|
||||||
|
DocumentNumber: sale.Number,
|
||||||
|
UnitCost: line.UnitPrice,
|
||||||
|
OccurredAt: DateTime.UtcNow,
|
||||||
|
Notes: $"Отмена чека {sale.Number}"), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
sale.Status = RetailSaleStatus.Draft;
|
||||||
|
sale.PostedAt = null;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyLines(RetailSale sale, IReadOnlyList<RetailSaleLineInput> input)
|
||||||
|
{
|
||||||
|
var order = 0;
|
||||||
|
decimal subtotal = 0, discountTotal = 0;
|
||||||
|
foreach (var l in input)
|
||||||
|
{
|
||||||
|
var lineTotal = l.Quantity * l.UnitPrice - l.Discount;
|
||||||
|
sale.Lines.Add(new RetailSaleLine
|
||||||
|
{
|
||||||
|
ProductId = l.ProductId,
|
||||||
|
Quantity = l.Quantity,
|
||||||
|
UnitPrice = l.UnitPrice,
|
||||||
|
Discount = l.Discount,
|
||||||
|
LineTotal = lineTotal,
|
||||||
|
VatPercent = l.VatPercent,
|
||||||
|
SortOrder = order++,
|
||||||
|
});
|
||||||
|
subtotal += l.Quantity * l.UnitPrice;
|
||||||
|
discountTotal += l.Discount;
|
||||||
|
}
|
||||||
|
sale.Subtotal = subtotal;
|
||||||
|
sale.DiscountTotal = discountTotal;
|
||||||
|
sale.Total = subtotal - discountTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var prefix = $"ПР-{date.Year}-";
|
||||||
|
var lastNumber = await _db.RetailSales
|
||||||
|
.Where(s => s.Number.StartsWith(prefix))
|
||||||
|
.OrderByDescending(s => s.Number)
|
||||||
|
.Select(s => s.Number)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
var seq = 1;
|
||||||
|
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
|
||||||
|
seq = last + 1;
|
||||||
|
return $"{prefix}{seq:D6}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<RetailSaleDto?> GetInternal(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var row = await (from s in _db.RetailSales.AsNoTracking()
|
||||||
|
join st in _db.Stores on s.StoreId equals st.Id
|
||||||
|
join cu in _db.Currencies on s.CurrencyId equals cu.Id
|
||||||
|
where s.Id == id
|
||||||
|
select new { s, st, cu }).FirstOrDefaultAsync(ct);
|
||||||
|
if (row is null) return null;
|
||||||
|
|
||||||
|
string? rpName = row.s.RetailPointId is null ? null
|
||||||
|
: await _db.RetailPoints.Where(r => r.Id == row.s.RetailPointId).Select(r => r.Name).FirstOrDefaultAsync(ct);
|
||||||
|
string? cName = row.s.CustomerId is null ? null
|
||||||
|
: await _db.Counterparties.Where(c => c.Id == row.s.CustomerId).Select(c => c.Name).FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
var lines = await (from l in _db.RetailSaleLines.AsNoTracking()
|
||||||
|
join p in _db.Products on l.ProductId equals p.Id
|
||||||
|
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
|
||||||
|
where l.RetailSaleId == id
|
||||||
|
orderby l.SortOrder
|
||||||
|
select new RetailSaleLineDto(
|
||||||
|
l.Id, l.ProductId, p.Name, p.Article, u.Symbol,
|
||||||
|
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new RetailSaleDto(
|
||||||
|
row.s.Id, row.s.Number, row.s.Date, row.s.Status,
|
||||||
|
row.st.Id, row.st.Name,
|
||||||
|
row.s.RetailPointId, rpName,
|
||||||
|
row.s.CustomerId, cName,
|
||||||
|
row.cu.Id, row.cu.Code,
|
||||||
|
row.s.Subtotal, row.s.DiscountTotal, row.s.Total,
|
||||||
|
row.s.Payment, row.s.PaidCash, row.s.PaidCard,
|
||||||
|
row.s.Notes, row.s.PostedAt,
|
||||||
|
lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,44 @@
|
||||||
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.AddControllers();
|
||||||
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>();
|
||||||
|
|
||||||
|
// 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>();
|
// 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();
|
||||||
|
|
||||||
|
|
@ -116,6 +159,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 +187,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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
|
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,10 @@ namespace foodmarket.Domain.Catalog;
|
||||||
|
|
||||||
public enum CounterpartyKind
|
public enum CounterpartyKind
|
||||||
{
|
{
|
||||||
|
/// <summary>Не указано — дефолт для импортированных без явной классификации.
|
||||||
|
/// MoySklad сам не имеет встроенного поля Supplier/Customer, оно ставится
|
||||||
|
/// через теги или группы, и часто отсутствует. Не выдумываем за пользователя.</summary>
|
||||||
|
Unspecified = 0,
|
||||||
Supplier = 1,
|
Supplier = 1,
|
||||||
Customer = 2,
|
Customer = 2,
|
||||||
Both = 3,
|
Both = 3,
|
||||||
|
|
|
||||||
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, // корректировка по результату инвентаризации
|
||||||
|
}
|
||||||
55
src/food-market.domain/Purchases/Supply.cs
Normal file
55
src/food-market.domain/Purchases/Supply.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
using foodmarket.Domain.Catalog;
|
||||||
|
using foodmarket.Domain.Common;
|
||||||
|
|
||||||
|
namespace foodmarket.Domain.Purchases;
|
||||||
|
|
||||||
|
public enum SupplyStatus
|
||||||
|
{
|
||||||
|
Draft = 0,
|
||||||
|
Posted = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Supply : TenantEntity
|
||||||
|
{
|
||||||
|
/// <summary>Human-readable document number, unique per organization (e.g. "П-2026-000001").</summary>
|
||||||
|
public string Number { get; set; } = "";
|
||||||
|
|
||||||
|
public DateTime Date { get; set; } = DateTime.UtcNow;
|
||||||
|
public SupplyStatus Status { get; set; } = SupplyStatus.Draft;
|
||||||
|
|
||||||
|
public Guid SupplierId { get; set; }
|
||||||
|
public Counterparty Supplier { get; set; } = null!;
|
||||||
|
|
||||||
|
public Guid StoreId { get; set; }
|
||||||
|
public Store Store { get; set; } = null!;
|
||||||
|
|
||||||
|
public Guid CurrencyId { get; set; }
|
||||||
|
public Currency Currency { get; set; } = null!;
|
||||||
|
|
||||||
|
public string? SupplierInvoiceNumber { get; set; }
|
||||||
|
public DateTime? SupplierInvoiceDate { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Sum of line totals. Computed on save.</summary>
|
||||||
|
public decimal Total { get; set; }
|
||||||
|
|
||||||
|
public DateTime? PostedAt { get; set; }
|
||||||
|
public Guid? PostedByUserId { get; set; }
|
||||||
|
|
||||||
|
public ICollection<SupplyLine> Lines { get; set; } = new List<SupplyLine>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SupplyLine : TenantEntity
|
||||||
|
{
|
||||||
|
public Guid SupplyId { get; set; }
|
||||||
|
public Supply Supply { get; set; } = null!;
|
||||||
|
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
public Product Product { get; set; } = null!;
|
||||||
|
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
public decimal LineTotal { get; set; }
|
||||||
|
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
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,122 @@
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Integrations.MoySklad;
|
||||||
|
|
||||||
|
public record MoySkladApiResult<T>(bool Success, T? Value, int? StatusCode, string? Error)
|
||||||
|
{
|
||||||
|
public static MoySkladApiResult<T> Ok(T value) => new(true, value, 200, null);
|
||||||
|
public static MoySkladApiResult<T> Fail(int status, string? error) => new(false, default, status, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thin HTTP wrapper over MoySklad JSON-API 1.2. Caller supplies the personal token per request
|
||||||
|
// — we never persist it.
|
||||||
|
public class MoySkladClient
|
||||||
|
{
|
||||||
|
// Trailing slash is critical: otherwise HttpClient drops the last path segment
|
||||||
|
// when resolving relative URIs (RFC 3986 §5.3), so "entity/product" would hit
|
||||||
|
// "/api/remap/entity/product" instead of "/api/remap/1.2/entity/product".
|
||||||
|
private const string BaseUrl = "https://api.moysklad.ru/api/remap/1.2/";
|
||||||
|
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
|
||||||
|
public MoySkladClient(HttpClient http)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_http.BaseAddress ??= new Uri(BaseUrl);
|
||||||
|
_http.Timeout = TimeSpan.FromSeconds(90);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string token)
|
||||||
|
{
|
||||||
|
var req = new HttpRequestMessage(method, pathAndQuery);
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
// MoySklad requires the exact literal "application/json;charset=utf-8" (no space
|
||||||
|
// after ';'). The typed MediaTypeWithQualityHeaderValue API normalizes to
|
||||||
|
// "application/json; charset=utf-8" which MoySklad rejects with code 1062.
|
||||||
|
req.Headers.TryAddWithoutValidation("Accept", "application/json;charset=utf-8");
|
||||||
|
// MoySklad's nginx edge returns 415 for requests without a User-Agent, and we want
|
||||||
|
// auto-decompression (Accept-Encoding is added automatically by HttpClient when
|
||||||
|
// AutomaticDecompression is set on the primary handler — see Program.cs).
|
||||||
|
if (!req.Headers.UserAgent.Any())
|
||||||
|
{
|
||||||
|
req.Headers.TryAddWithoutValidation("User-Agent", "food-market/0.1 (+https://github.com/nurdotnet/food-market)");
|
||||||
|
}
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var req = Build(HttpMethod.Get, "entity/organization?limit=1", token);
|
||||||
|
using var res = await _http.SendAsync(req, ct);
|
||||||
|
if (!res.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await res.Content.ReadAsStringAsync(ct);
|
||||||
|
return MoySkladApiResult<MsOrganization>.Fail((int)res.StatusCode, body);
|
||||||
|
}
|
||||||
|
var list = await res.Content.ReadFromJsonAsync<MsListResponse<MsOrganization>>(Json, ct);
|
||||||
|
var org = list?.Rows.FirstOrDefault();
|
||||||
|
return org is null
|
||||||
|
? MoySkladApiResult<MsOrganization>.Fail(200, "Empty organization list returned by MoySklad.")
|
||||||
|
: MoySkladApiResult<MsOrganization>.Ok(org);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<MsProduct> StreamProductsAsync(
|
||||||
|
string token,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
||||||
|
{
|
||||||
|
const int pageSize = 1000;
|
||||||
|
var offset = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
using var req = Build(HttpMethod.Get, $"entity/product?limit={pageSize}&offset={offset}", token);
|
||||||
|
using var res = await _http.SendAsync(req, ct);
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsProduct>>(Json, ct);
|
||||||
|
if (page is null || page.Rows.Count == 0) yield break;
|
||||||
|
foreach (var p in page.Rows) yield return p;
|
||||||
|
if (page.Rows.Count < pageSize) yield break;
|
||||||
|
offset += pageSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<MsCounterparty> StreamCounterpartiesAsync(
|
||||||
|
string token,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
||||||
|
{
|
||||||
|
const int pageSize = 1000;
|
||||||
|
var offset = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
using var req = Build(HttpMethod.Get, $"entity/counterparty?limit={pageSize}&offset={offset}", token);
|
||||||
|
using var res = await _http.SendAsync(req, ct);
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsCounterparty>>(Json, ct);
|
||||||
|
if (page is null || page.Rows.Count == 0) yield break;
|
||||||
|
foreach (var c in page.Rows) yield return c;
|
||||||
|
if (page.Rows.Count < pageSize) yield break;
|
||||||
|
offset += pageSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var all = new List<MsProductFolder>();
|
||||||
|
var offset = 0;
|
||||||
|
const int pageSize = 1000;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
using var req = Build(HttpMethod.Get, $"entity/productfolder?limit={pageSize}&offset={offset}", token);
|
||||||
|
using var res = await _http.SendAsync(req, ct);
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsProductFolder>>(Json, ct);
|
||||||
|
if (page is null || page.Rows.Count == 0) break;
|
||||||
|
all.AddRange(page.Rows);
|
||||||
|
if (page.Rows.Count < pageSize) break;
|
||||||
|
offset += pageSize;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,295 @@
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Domain.Catalog;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Integrations.MoySklad;
|
||||||
|
|
||||||
|
public record MoySkladImportResult(
|
||||||
|
int Total,
|
||||||
|
int Created,
|
||||||
|
int Skipped,
|
||||||
|
int GroupsCreated,
|
||||||
|
IReadOnlyList<string> Errors);
|
||||||
|
|
||||||
|
public class MoySkladImportService
|
||||||
|
{
|
||||||
|
private readonly MoySkladClient _client;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
private readonly ILogger<MoySkladImportService> _log;
|
||||||
|
|
||||||
|
public MoySkladImportService(
|
||||||
|
MoySkladClient client,
|
||||||
|
AppDbContext db,
|
||||||
|
ITenantContext tenant,
|
||||||
|
ILogger<MoySkladImportService> log)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_db = db;
|
||||||
|
_tenant = tenant;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct)
|
||||||
|
=> _client.WhoAmIAsync(token, ct);
|
||||||
|
|
||||||
|
public async Task<MoySkladImportResult> ImportCounterpartiesAsync(string token, bool overwriteExisting, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
|
||||||
|
|
||||||
|
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще — это
|
||||||
|
// не наша выдумка, проверено через API: counterparty entity содержит только
|
||||||
|
// group (группа доступа), tags (произвольные), state (пользовательская цепочка
|
||||||
|
// статусов), companyType (legal/individual/entrepreneur). Никакой role/kind.
|
||||||
|
// Поэтому при импорте ВСЕГДА ставим Unspecified — пользователь сам решит.
|
||||||
|
// Параметр tags оставлен ради совместимости сигнатуры, не используется.
|
||||||
|
static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList<string>? tags)
|
||||||
|
=> foodmarket.Domain.Catalog.CounterpartyKind.Unspecified;
|
||||||
|
|
||||||
|
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
|
||||||
|
=> companyType switch
|
||||||
|
{
|
||||||
|
"individual" or "entrepreneur" => foodmarket.Domain.Catalog.CounterpartyType.Individual,
|
||||||
|
_ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity,
|
||||||
|
};
|
||||||
|
|
||||||
|
var existingByName = await _db.Counterparties
|
||||||
|
.Select(c => new { c.Id, c.Name })
|
||||||
|
.ToDictionaryAsync(c => c.Name, c => c.Id, StringComparer.OrdinalIgnoreCase, ct);
|
||||||
|
|
||||||
|
var created = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
var total = 0;
|
||||||
|
var errors = new List<string>();
|
||||||
|
var batch = 0;
|
||||||
|
|
||||||
|
await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
|
{
|
||||||
|
total++;
|
||||||
|
if (c.Archived) { skipped++; continue; }
|
||||||
|
|
||||||
|
if (existingByName.ContainsKey(c.Name) && !overwriteExisting)
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entity = new foodmarket.Domain.Catalog.Counterparty
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
Name = Trim(c.Name, 255) ?? c.Name,
|
||||||
|
LegalName = Trim(c.LegalTitle, 500),
|
||||||
|
Kind = ResolveKind(c.Tags),
|
||||||
|
Type = ResolveType(c.CompanyType),
|
||||||
|
Bin = Trim(c.Inn, 20),
|
||||||
|
TaxNumber = Trim(c.Kpp, 20),
|
||||||
|
Phone = Trim(c.Phone, 50),
|
||||||
|
Email = Trim(c.Email, 255),
|
||||||
|
Address = Trim(c.ActualAddress ?? c.LegalAddress, 500),
|
||||||
|
Notes = Trim(c.Description, 1000),
|
||||||
|
IsActive = !c.Archived,
|
||||||
|
};
|
||||||
|
_db.Counterparties.Add(entity);
|
||||||
|
existingByName[c.Name] = entity.Id;
|
||||||
|
created++;
|
||||||
|
batch++;
|
||||||
|
if (batch >= 500)
|
||||||
|
{
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
batch = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "Failed to import counterparty {Name}", c.Name);
|
||||||
|
errors.Add($"{c.Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch > 0) await _db.SaveChangesAsync(ct);
|
||||||
|
return new MoySkladImportResult(total, created, skipped, 0, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MoySkladImportResult> ImportProductsAsync(
|
||||||
|
string token,
|
||||||
|
bool overwriteExisting,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
|
||||||
|
|
||||||
|
// Pre-load tenant defaults.
|
||||||
|
var defaultVat = await _db.VatRates.FirstOrDefaultAsync(v => v.IsDefault, ct)
|
||||||
|
?? await _db.VatRates.FirstAsync(ct);
|
||||||
|
var vatByPercent = await _db.VatRates.ToDictionaryAsync(v => (int)v.Percent, v => v.Id, ct);
|
||||||
|
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.IsBase, ct)
|
||||||
|
?? await _db.UnitsOfMeasure.FirstAsync(ct);
|
||||||
|
var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct)
|
||||||
|
?? await _db.PriceTypes.FirstAsync(ct);
|
||||||
|
var kzt = await _db.Currencies.FirstAsync(c => c.Code == "KZT", ct);
|
||||||
|
|
||||||
|
var countriesByName = await _db.Countries
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.ToDictionaryAsync(c => c.Name, c => c.Id, ct);
|
||||||
|
|
||||||
|
// Import folders first — build flat then link parents.
|
||||||
|
var folders = await _client.GetAllFoldersAsync(token, ct);
|
||||||
|
var localGroupByMsId = new Dictionary<string, Guid>();
|
||||||
|
var groupsCreated = 0;
|
||||||
|
foreach (var f in folders.Where(f => !f.Archived).OrderBy(f => f.PathName?.Length ?? 0))
|
||||||
|
{
|
||||||
|
if (f.Id is null) continue;
|
||||||
|
var existing = await _db.ProductGroups.FirstOrDefaultAsync(
|
||||||
|
g => g.Name == f.Name && g.Path == (f.PathName ?? f.Name), ct);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
localGroupByMsId[f.Id] = existing.Id;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var g = new ProductGroup
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
Name = f.Name,
|
||||||
|
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
|
||||||
|
IsActive = true,
|
||||||
|
};
|
||||||
|
_db.ProductGroups.Add(g);
|
||||||
|
localGroupByMsId[f.Id] = g.Id;
|
||||||
|
groupsCreated++;
|
||||||
|
}
|
||||||
|
if (groupsCreated > 0) await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// Import products
|
||||||
|
var errors = new List<string>();
|
||||||
|
var created = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
var total = 0;
|
||||||
|
var existingArticles = await _db.Products
|
||||||
|
.Where(p => p.Article != null)
|
||||||
|
.Select(p => p.Article!)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var existingArticleSet = new HashSet<string>(existingArticles, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var existingBarcodeSet = new HashSet<string>(
|
||||||
|
await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct));
|
||||||
|
|
||||||
|
await foreach (var p in _client.StreamProductsAsync(token, ct))
|
||||||
|
{
|
||||||
|
total++;
|
||||||
|
if (p.Archived) { skipped++; continue; }
|
||||||
|
|
||||||
|
var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article;
|
||||||
|
var primaryBarcode = ExtractBarcodes(p).FirstOrDefault();
|
||||||
|
|
||||||
|
var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingArticleSet.Contains(article);
|
||||||
|
var alreadyByBarcode = primaryBarcode is not null && existingBarcodeSet.Contains(primaryBarcode.Code);
|
||||||
|
|
||||||
|
if ((alreadyByArticle || alreadyByBarcode) && !overwriteExisting)
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var vatId = p.Vat is int vp && vatByPercent.TryGetValue(vp, out var id) ? id : defaultVat.Id;
|
||||||
|
Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId
|
||||||
|
&& localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null;
|
||||||
|
Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null;
|
||||||
|
|
||||||
|
var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
?? p.SalePrices?.FirstOrDefault();
|
||||||
|
|
||||||
|
var product = new Product
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
Name = Trim(p.Name, 500),
|
||||||
|
Article = Trim(article, 500),
|
||||||
|
Description = p.Description,
|
||||||
|
UnitOfMeasureId = baseUnit.Id,
|
||||||
|
VatRateId = vatId,
|
||||||
|
ProductGroupId = groupId,
|
||||||
|
CountryOfOriginId = countryId,
|
||||||
|
IsWeighed = p.Weighed,
|
||||||
|
IsAlcohol = p.Alcoholic is not null,
|
||||||
|
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
|
||||||
|
IsActive = !p.Archived,
|
||||||
|
PurchasePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
|
||||||
|
PurchaseCurrencyId = kzt.Id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (retailPrice is not null)
|
||||||
|
{
|
||||||
|
product.Prices.Add(new ProductPrice
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
PriceTypeId = retailType.Id,
|
||||||
|
Amount = retailPrice.Value / 100m,
|
||||||
|
CurrencyId = kzt.Id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var b in ExtractBarcodes(p))
|
||||||
|
{
|
||||||
|
if (existingBarcodeSet.Contains(b.Code)) continue;
|
||||||
|
product.Barcodes.Add(b);
|
||||||
|
existingBarcodeSet.Add(b.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.Products.Add(product);
|
||||||
|
if (!string.IsNullOrWhiteSpace(article)) existingArticleSet.Add(article);
|
||||||
|
created++;
|
||||||
|
|
||||||
|
// Flush every 500 products to keep change tracker light.
|
||||||
|
if (created % 500 == 0) await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "Failed to import MoySklad product {Name}", p.Name);
|
||||||
|
errors.Add($"{p.Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return new MoySkladImportResult(total, created, skipped, groupsCreated, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ProductBarcode> ExtractBarcodes(MsProduct p)
|
||||||
|
{
|
||||||
|
if (p.Barcodes is null) return [];
|
||||||
|
var list = new List<ProductBarcode>();
|
||||||
|
var primarySet = false;
|
||||||
|
foreach (var entry in p.Barcodes)
|
||||||
|
{
|
||||||
|
foreach (var (kind, code) in entry)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(code)) continue;
|
||||||
|
var type = kind switch
|
||||||
|
{
|
||||||
|
"ean13" => BarcodeType.Ean13,
|
||||||
|
"ean8" => BarcodeType.Ean8,
|
||||||
|
"code128" => BarcodeType.Code128,
|
||||||
|
"gtin" => BarcodeType.Ean13,
|
||||||
|
"upca" => BarcodeType.Upca,
|
||||||
|
"upce" => BarcodeType.Upce,
|
||||||
|
_ => BarcodeType.Other,
|
||||||
|
};
|
||||||
|
list.Add(new ProductBarcode { Code = code.Length > 500 ? code[..500] : code, Type = type, IsPrimary = !primarySet });
|
||||||
|
primarySet = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Trim(string? s, int max)
|
||||||
|
=> string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max]);
|
||||||
|
|
||||||
|
private static string? TryExtractId(string href)
|
||||||
|
{
|
||||||
|
// href like "https://api.moysklad.ru/api/remap/1.2/entity/productfolder/<guid>"
|
||||||
|
var lastSlash = href.LastIndexOf('/');
|
||||||
|
return lastSlash >= 0 ? href[(lastSlash + 1)..] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
using foodmarket.Application.Common.Tenancy;
|
using foodmarket.Application.Common.Tenancy;
|
||||||
using foodmarket.Domain.Catalog;
|
using foodmarket.Domain.Catalog;
|
||||||
using foodmarket.Domain.Common;
|
using foodmarket.Domain.Common;
|
||||||
|
using foodmarket.Domain.Inventory;
|
||||||
|
using foodmarket.Domain.Purchases;
|
||||||
|
using foodmarket.Domain.Sales;
|
||||||
using foodmarket.Infrastructure.Identity;
|
using foodmarket.Infrastructure.Identity;
|
||||||
using foodmarket.Domain.Organizations;
|
using foodmarket.Domain.Organizations;
|
||||||
using foodmarket.Infrastructure.Persistence.Configurations;
|
using foodmarket.Infrastructure.Persistence.Configurations;
|
||||||
|
|
@ -35,6 +38,15 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
||||||
public DbSet<ProductBarcode> ProductBarcodes => Set<ProductBarcode>();
|
public DbSet<ProductBarcode> ProductBarcodes => Set<ProductBarcode>();
|
||||||
public DbSet<ProductImage> ProductImages => Set<ProductImage>();
|
public DbSet<ProductImage> ProductImages => Set<ProductImage>();
|
||||||
|
|
||||||
|
public DbSet<Stock> Stocks => Set<Stock>();
|
||||||
|
public DbSet<StockMovement> StockMovements => Set<StockMovement>();
|
||||||
|
|
||||||
|
public DbSet<Supply> Supplies => Set<Supply>();
|
||||||
|
public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>();
|
||||||
|
|
||||||
|
public DbSet<RetailSale> RetailSales => Set<RetailSale>();
|
||||||
|
public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(builder);
|
base.OnModelCreating(builder);
|
||||||
|
|
@ -62,6 +74,9 @@ protected override void OnModelCreating(ModelBuilder builder)
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.ConfigureCatalog();
|
builder.ConfigureCatalog();
|
||||||
|
builder.ConfigureInventory();
|
||||||
|
builder.ConfigurePurchases();
|
||||||
|
builder.ConfigureSales();
|
||||||
|
|
||||||
// Apply multi-tenant query filter to every entity that implements ITenantEntity
|
// Apply multi-tenant query filter to every entity that implements ITenantEntity
|
||||||
foreach (var entityType in builder.Model.GetEntityTypes())
|
foreach (var entityType in builder.Model.GetEntityTypes())
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
|
||||||
{
|
{
|
||||||
b.ToTable("products");
|
b.ToTable("products");
|
||||||
b.Property(x => x.Name).HasMaxLength(500).IsRequired();
|
b.Property(x => x.Name).HasMaxLength(500).IsRequired();
|
||||||
b.Property(x => x.Article).HasMaxLength(100);
|
b.Property(x => x.Article).HasMaxLength(500);
|
||||||
b.Property(x => x.MinStock).HasPrecision(18, 4);
|
b.Property(x => x.MinStock).HasPrecision(18, 4);
|
||||||
b.Property(x => x.MaxStock).HasPrecision(18, 4);
|
b.Property(x => x.MaxStock).HasPrecision(18, 4);
|
||||||
b.Property(x => x.PurchasePrice).HasPrecision(18, 4);
|
b.Property(x => x.PurchasePrice).HasPrecision(18, 4);
|
||||||
|
|
@ -155,7 +155,8 @@ private static void ConfigureProductPrice(EntityTypeBuilder<ProductPrice> b)
|
||||||
private static void ConfigureBarcode(EntityTypeBuilder<ProductBarcode> b)
|
private static void ConfigureBarcode(EntityTypeBuilder<ProductBarcode> b)
|
||||||
{
|
{
|
||||||
b.ToTable("product_barcodes");
|
b.ToTable("product_barcodes");
|
||||||
b.Property(x => x.Code).HasMaxLength(100).IsRequired();
|
// Up to 500 to accommodate GS1 DataMatrix / crypto-tail tracking codes (Честный ЗНАК etc.)
|
||||||
|
b.Property(x => x.Code).HasMaxLength(500).IsRequired();
|
||||||
b.HasOne(x => x.Product).WithMany(p => p.Barcodes).HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Cascade);
|
b.HasOne(x => x.Product).WithMany(p => p.Barcodes).HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Cascade);
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique();
|
b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
using foodmarket.Domain.Inventory;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
public static class InventoryConfigurations
|
||||||
|
{
|
||||||
|
public static void ConfigureInventory(this ModelBuilder b)
|
||||||
|
{
|
||||||
|
b.Entity<Stock>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("stocks");
|
||||||
|
e.Property(x => x.Quantity).HasPrecision(18, 4);
|
||||||
|
e.Property(x => x.ReservedQuantity).HasPrecision(18, 4);
|
||||||
|
e.Ignore(x => x.Available);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.ProductId, x.StoreId }).IsUnique();
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.StoreId });
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Entity<StockMovement>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("stock_movements");
|
||||||
|
e.Property(x => x.Quantity).HasPrecision(18, 4);
|
||||||
|
e.Property(x => x.UnitCost).HasPrecision(18, 4);
|
||||||
|
e.Property(x => x.DocumentType).HasMaxLength(50).IsRequired();
|
||||||
|
e.Property(x => x.DocumentNumber).HasMaxLength(50);
|
||||||
|
e.Property(x => x.Notes).HasMaxLength(500);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.ProductId, x.OccurredAt });
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.StoreId, x.OccurredAt });
|
||||||
|
e.HasIndex(x => new { x.DocumentType, x.DocumentId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
using foodmarket.Domain.Purchases;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
public static class PurchasesConfigurations
|
||||||
|
{
|
||||||
|
public static void ConfigurePurchases(this ModelBuilder b)
|
||||||
|
{
|
||||||
|
b.Entity<Supply>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("supplies");
|
||||||
|
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
|
||||||
|
e.Property(x => x.SupplierInvoiceNumber).HasMaxLength(100);
|
||||||
|
e.Property(x => x.Notes).HasMaxLength(1000);
|
||||||
|
e.Property(x => x.Total).HasPrecision(18, 4);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Supplier).WithMany().HasForeignKey(x => x.SupplierId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasMany(x => x.Lines).WithOne(l => l.Supply).HasForeignKey(l => l.SupplyId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.Date });
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.Status });
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.SupplierId });
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Entity<SupplyLine>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("supply_lines");
|
||||||
|
e.Property(x => x.Quantity).HasPrecision(18, 4);
|
||||||
|
e.Property(x => x.UnitPrice).HasPrecision(18, 4);
|
||||||
|
e.Property(x => x.LineTotal).HasPrecision(18, 4);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
using foodmarket.Domain.Sales;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
public static class SalesConfigurations
|
||||||
|
{
|
||||||
|
public static void ConfigureSales(this ModelBuilder b)
|
||||||
|
{
|
||||||
|
b.Entity<RetailSale>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("retail_sales");
|
||||||
|
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
|
||||||
|
e.Property(x => x.Notes).HasMaxLength(1000);
|
||||||
|
e.Property(x => x.Subtotal).HasPrecision(18, 4);
|
||||||
|
e.Property(x => x.DiscountTotal).HasPrecision(18, 4);
|
||||||
|
e.Property(x => x.Total).HasPrecision(18, 4);
|
||||||
|
e.Property(x => x.PaidCash).HasPrecision(18, 4);
|
||||||
|
e.Property(x => x.PaidCard).HasPrecision(18, 4);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
e.HasOne(x => x.RetailPoint).WithMany().HasForeignKey(x => x.RetailPointId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
e.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasMany(x => x.Lines).WithOne(l => l.RetailSale).HasForeignKey(l => l.RetailSaleId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.Date });
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.Status });
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.CashierUserId });
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Entity<RetailSaleLine>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("retail_sale_lines");
|
||||||
|
e.Property(x => x.Quantity).HasPrecision(18, 4);
|
||||||
|
e.Property(x => x.UnitPrice).HasPrecision(18, 4);
|
||||||
|
e.Property(x => x.Discount).HasPrecision(18, 4);
|
||||||
|
e.Property(x => x.LineTotal).HasPrecision(18, 4);
|
||||||
|
e.Property(x => x.VatPercent).HasPrecision(5, 2);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1389
src/food-market.infrastructure/Persistence/Migrations/20260421191122_Phase1e_WidenArticleBarcode.Designer.cs
generated
Normal file
1389
src/food-market.infrastructure/Persistence/Migrations/20260421191122_Phase1e_WidenArticleBarcode.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,64 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Phase1e_WidenArticleBarcode : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Article",
|
||||||
|
schema: "public",
|
||||||
|
table: "products",
|
||||||
|
type: "character varying(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(100)",
|
||||||
|
oldMaxLength: 100,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Code",
|
||||||
|
schema: "public",
|
||||||
|
table: "product_barcodes",
|
||||||
|
type: "character varying(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(100)",
|
||||||
|
oldMaxLength: 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Article",
|
||||||
|
schema: "public",
|
||||||
|
table: "products",
|
||||||
|
type: "character varying(100)",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(500)",
|
||||||
|
oldMaxLength: 500,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Code",
|
||||||
|
schema: "public",
|
||||||
|
table: "product_barcodes",
|
||||||
|
type: "character varying(100)",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(500)",
|
||||||
|
oldMaxLength: 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1539
src/food-market.infrastructure/Persistence/Migrations/20260421194521_Phase2a_Stock.Designer.cs
generated
Normal file
1539
src/food-market.infrastructure/Persistence/Migrations/20260421194521_Phase2a_Stock.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,155 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Phase2a_Stock : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "stock_movements",
|
||||||
|
schema: "public",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ProductId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
StoreId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Quantity = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
UnitCost = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: true),
|
||||||
|
Type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
DocumentType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
DocumentId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
DocumentNumber = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
OccurredAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_stock_movements", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_stock_movements_products_ProductId",
|
||||||
|
column: x => x.ProductId,
|
||||||
|
principalSchema: "public",
|
||||||
|
principalTable: "products",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_stock_movements_stores_StoreId",
|
||||||
|
column: x => x.StoreId,
|
||||||
|
principalSchema: "public",
|
||||||
|
principalTable: "stores",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "stocks",
|
||||||
|
schema: "public",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ProductId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
StoreId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Quantity = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
ReservedQuantity = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_stocks", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_stocks_products_ProductId",
|
||||||
|
column: x => x.ProductId,
|
||||||
|
principalSchema: "public",
|
||||||
|
principalTable: "products",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_stocks_stores_StoreId",
|
||||||
|
column: x => x.StoreId,
|
||||||
|
principalSchema: "public",
|
||||||
|
principalTable: "stores",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_stock_movements_DocumentType_DocumentId",
|
||||||
|
schema: "public",
|
||||||
|
table: "stock_movements",
|
||||||
|
columns: new[] { "DocumentType", "DocumentId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_stock_movements_OrganizationId_ProductId_OccurredAt",
|
||||||
|
schema: "public",
|
||||||
|
table: "stock_movements",
|
||||||
|
columns: new[] { "OrganizationId", "ProductId", "OccurredAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_stock_movements_OrganizationId_StoreId_OccurredAt",
|
||||||
|
schema: "public",
|
||||||
|
table: "stock_movements",
|
||||||
|
columns: new[] { "OrganizationId", "StoreId", "OccurredAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_stock_movements_ProductId",
|
||||||
|
schema: "public",
|
||||||
|
table: "stock_movements",
|
||||||
|
column: "ProductId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_stock_movements_StoreId",
|
||||||
|
schema: "public",
|
||||||
|
table: "stock_movements",
|
||||||
|
column: "StoreId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_stocks_OrganizationId_ProductId_StoreId",
|
||||||
|
schema: "public",
|
||||||
|
table: "stocks",
|
||||||
|
columns: new[] { "OrganizationId", "ProductId", "StoreId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_stocks_OrganizationId_StoreId",
|
||||||
|
schema: "public",
|
||||||
|
table: "stocks",
|
||||||
|
columns: new[] { "OrganizationId", "StoreId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_stocks_ProductId",
|
||||||
|
schema: "public",
|
||||||
|
table: "stocks",
|
||||||
|
column: "ProductId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_stocks_StoreId",
|
||||||
|
schema: "public",
|
||||||
|
table: "stocks",
|
||||||
|
column: "StoreId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "stock_movements",
|
||||||
|
schema: "public");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "stocks",
|
||||||
|
schema: "public");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1713
src/food-market.infrastructure/Persistence/Migrations/20260421195644_Phase2b_Supply.Designer.cs
generated
Normal file
1713
src/food-market.infrastructure/Persistence/Migrations/20260421195644_Phase2b_Supply.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,171 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Phase2b_Supply : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "supplies",
|
||||||
|
schema: "public",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Number = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
SupplierId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
StoreId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
CurrencyId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
SupplierInvoiceNumber = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
SupplierInvoiceDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
|
Total = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
PostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
PostedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_supplies", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_supplies_counterparties_SupplierId",
|
||||||
|
column: x => x.SupplierId,
|
||||||
|
principalSchema: "public",
|
||||||
|
principalTable: "counterparties",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_supplies_currencies_CurrencyId",
|
||||||
|
column: x => x.CurrencyId,
|
||||||
|
principalSchema: "public",
|
||||||
|
principalTable: "currencies",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_supplies_stores_StoreId",
|
||||||
|
column: x => x.StoreId,
|
||||||
|
principalSchema: "public",
|
||||||
|
principalTable: "stores",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "supply_lines",
|
||||||
|
schema: "public",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
SupplyId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ProductId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Quantity = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_supply_lines", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_supply_lines_products_ProductId",
|
||||||
|
column: x => x.ProductId,
|
||||||
|
principalSchema: "public",
|
||||||
|
principalTable: "products",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_supply_lines_supplies_SupplyId",
|
||||||
|
column: x => x.SupplyId,
|
||||||
|
principalSchema: "public",
|
||||||
|
principalTable: "supplies",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_supplies_CurrencyId",
|
||||||
|
schema: "public",
|
||||||
|
table: "supplies",
|
||||||
|
column: "CurrencyId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_supplies_OrganizationId_Date",
|
||||||
|
schema: "public",
|
||||||
|
table: "supplies",
|
||||||
|
columns: new[] { "OrganizationId", "Date" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_supplies_OrganizationId_Number",
|
||||||
|
schema: "public",
|
||||||
|
table: "supplies",
|
||||||
|
columns: new[] { "OrganizationId", "Number" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_supplies_OrganizationId_Status",
|
||||||
|
schema: "public",
|
||||||
|
table: "supplies",
|
||||||
|
columns: new[] { "OrganizationId", "Status" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_supplies_OrganizationId_SupplierId",
|
||||||
|
schema: "public",
|
||||||
|
table: "supplies",
|
||||||
|
columns: new[] { "OrganizationId", "SupplierId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_supplies_StoreId",
|
||||||
|
schema: "public",
|
||||||
|
table: "supplies",
|
||||||
|
column: "StoreId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_supplies_SupplierId",
|
||||||
|
schema: "public",
|
||||||
|
table: "supplies",
|
||||||
|
column: "SupplierId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_supply_lines_OrganizationId_ProductId",
|
||||||
|
schema: "public",
|
||||||
|
table: "supply_lines",
|
||||||
|
columns: new[] { "OrganizationId", "ProductId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_supply_lines_ProductId",
|
||||||
|
schema: "public",
|
||||||
|
table: "supply_lines",
|
||||||
|
column: "ProductId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_supply_lines_SupplyId",
|
||||||
|
schema: "public",
|
||||||
|
table: "supply_lines",
|
||||||
|
column: "SupplyId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "supply_lines",
|
||||||
|
schema: "public");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "supplies",
|
||||||
|
schema: "public");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1921
src/food-market.infrastructure/Persistence/Migrations/20260422110503_Phase2c_RetailSale.Designer.cs
generated
Normal file
1921
src/food-market.infrastructure/Persistence/Migrations/20260422110503_Phase2c_RetailSale.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,191 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Phase2c_RetailSale : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "retail_sales",
|
||||||
|
schema: "public",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Number = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
StoreId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
RetailPointId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
CustomerId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
CashierUserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
CurrencyId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
DiscountTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
Total = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
Payment = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
PaidCash = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
PaidCard = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
|
PostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
PostedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_retail_sales", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_retail_sales_counterparties_CustomerId",
|
||||||
|
column: x => x.CustomerId,
|
||||||
|
principalSchema: "public",
|
||||||
|
principalTable: "counterparties",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_retail_sales_currencies_CurrencyId",
|
||||||
|
column: x => x.CurrencyId,
|
||||||
|
principalSchema: "public",
|
||||||
|
principalTable: "currencies",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_retail_sales_retail_points_RetailPointId",
|
||||||
|
column: x => x.RetailPointId,
|
||||||
|
principalSchema: "public",
|
||||||
|
principalTable: "retail_points",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_retail_sales_stores_StoreId",
|
||||||
|
column: x => x.StoreId,
|
||||||
|
principalSchema: "public",
|
||||||
|
principalTable: "stores",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "retail_sale_lines",
|
||||||
|
schema: "public",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
RetailSaleId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ProductId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Quantity = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
Discount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
VatPercent = table.Column<decimal>(type: "numeric(5,2)", precision: 5, scale: 2, nullable: false),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_retail_sale_lines", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_retail_sale_lines_products_ProductId",
|
||||||
|
column: x => x.ProductId,
|
||||||
|
principalSchema: "public",
|
||||||
|
principalTable: "products",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_retail_sale_lines_retail_sales_RetailSaleId",
|
||||||
|
column: x => x.RetailSaleId,
|
||||||
|
principalSchema: "public",
|
||||||
|
principalTable: "retail_sales",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_retail_sale_lines_OrganizationId_ProductId",
|
||||||
|
schema: "public",
|
||||||
|
table: "retail_sale_lines",
|
||||||
|
columns: new[] { "OrganizationId", "ProductId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_retail_sale_lines_ProductId",
|
||||||
|
schema: "public",
|
||||||
|
table: "retail_sale_lines",
|
||||||
|
column: "ProductId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_retail_sale_lines_RetailSaleId",
|
||||||
|
schema: "public",
|
||||||
|
table: "retail_sale_lines",
|
||||||
|
column: "RetailSaleId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_retail_sales_CurrencyId",
|
||||||
|
schema: "public",
|
||||||
|
table: "retail_sales",
|
||||||
|
column: "CurrencyId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_retail_sales_CustomerId",
|
||||||
|
schema: "public",
|
||||||
|
table: "retail_sales",
|
||||||
|
column: "CustomerId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_retail_sales_OrganizationId_CashierUserId",
|
||||||
|
schema: "public",
|
||||||
|
table: "retail_sales",
|
||||||
|
columns: new[] { "OrganizationId", "CashierUserId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_retail_sales_OrganizationId_Date",
|
||||||
|
schema: "public",
|
||||||
|
table: "retail_sales",
|
||||||
|
columns: new[] { "OrganizationId", "Date" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_retail_sales_OrganizationId_Number",
|
||||||
|
schema: "public",
|
||||||
|
table: "retail_sales",
|
||||||
|
columns: new[] { "OrganizationId", "Number" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_retail_sales_OrganizationId_Status",
|
||||||
|
schema: "public",
|
||||||
|
table: "retail_sales",
|
||||||
|
columns: new[] { "OrganizationId", "Status" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_retail_sales_RetailPointId",
|
||||||
|
schema: "public",
|
||||||
|
table: "retail_sales",
|
||||||
|
column: "RetailPointId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_retail_sales_StoreId",
|
||||||
|
schema: "public",
|
||||||
|
table: "retail_sales",
|
||||||
|
column: "StoreId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "retail_sale_lines",
|
||||||
|
schema: "public");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "retail_sales",
|
||||||
|
schema: "public");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -546,8 +546,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("Article")
|
b.Property<string>("Article")
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("character varying(100)");
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
b.Property<Guid?>("CountryOfOriginId")
|
b.Property<Guid?>("CountryOfOriginId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
@ -648,8 +648,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
|
||||||
b.Property<string>("Code")
|
b.Property<string>("Code")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("character varying(100)");
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
@ -993,6 +993,118 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.ToTable("vat_rates", "public");
|
b.ToTable("vat_rates", "public");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProductId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("Quantity")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("ReservedQuantity")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<Guid>("StoreId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ProductId");
|
||||||
|
|
||||||
|
b.HasIndex("StoreId");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "StoreId");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "ProductId", "StoreId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("stocks", "public");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("foodmarket.Domain.Inventory.StockMovement", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedByUserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DocumentId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("DocumentNumber")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("DocumentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("OccurredAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProductId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("Quantity")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<Guid>("StoreId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal?>("UnitCost")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ProductId");
|
||||||
|
|
||||||
|
b.HasIndex("StoreId");
|
||||||
|
|
||||||
|
b.HasIndex("DocumentType", "DocumentId");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "ProductId", "OccurredAt");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "StoreId", "OccurredAt");
|
||||||
|
|
||||||
|
b.ToTable("stock_movements", "public");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("foodmarket.Domain.Organizations.Organization", b =>
|
modelBuilder.Entity("foodmarket.Domain.Organizations.Organization", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|
@ -1038,6 +1150,280 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.ToTable("organizations", "public");
|
b.ToTable("organizations", "public");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("CurrencyId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Date")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Number")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PostedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PostedByUserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("StoreId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("SupplierId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SupplierInvoiceDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("SupplierInvoiceNumber")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Total")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CurrencyId");
|
||||||
|
|
||||||
|
b.HasIndex("StoreId");
|
||||||
|
|
||||||
|
b.HasIndex("SupplierId");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "Date");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "Number")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "Status");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "SupplierId");
|
||||||
|
|
||||||
|
b.ToTable("supplies", "public");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("foodmarket.Domain.Purchases.SupplyLine", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("LineTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProductId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("Quantity")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("SupplyId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("UnitPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ProductId");
|
||||||
|
|
||||||
|
b.HasIndex("SupplyId");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "ProductId");
|
||||||
|
|
||||||
|
b.ToTable("supply_lines", "public");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CashierUserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("CurrencyId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CustomerId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Date")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("DiscountTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Number")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("PaidCard")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PaidCash")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("Payment")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PostedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PostedByUserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("RetailPointId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("StoreId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("Subtotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Total")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CurrencyId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerId");
|
||||||
|
|
||||||
|
b.HasIndex("RetailPointId");
|
||||||
|
|
||||||
|
b.HasIndex("StoreId");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "CashierUserId");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "Date");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "Number")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "Status");
|
||||||
|
|
||||||
|
b.ToTable("retail_sales", "public");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("Discount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("LineTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProductId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("Quantity")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<Guid>("RetailSaleId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("UnitPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("VatPercent")
|
||||||
|
.HasPrecision(5, 2)
|
||||||
|
.HasColumnType("numeric(5,2)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ProductId");
|
||||||
|
|
||||||
|
b.HasIndex("RetailSaleId");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "ProductId");
|
||||||
|
|
||||||
|
b.ToTable("retail_sale_lines", "public");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("foodmarket.Infrastructure.Identity.Role", b =>
|
modelBuilder.Entity("foodmarket.Infrastructure.Identity.Role", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|
@ -1355,6 +1741,142 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Navigation("Store");
|
b.Navigation("Store");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("foodmarket.Domain.Catalog.Product", "Product")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ProductId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("foodmarket.Domain.Catalog.Store", "Store")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StoreId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Product");
|
||||||
|
|
||||||
|
b.Navigation("Store");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("foodmarket.Domain.Inventory.StockMovement", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("foodmarket.Domain.Catalog.Product", "Product")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ProductId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("foodmarket.Domain.Catalog.Store", "Store")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StoreId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Product");
|
||||||
|
|
||||||
|
b.Navigation("Store");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CurrencyId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("foodmarket.Domain.Catalog.Store", "Store")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StoreId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Supplier")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SupplierId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Currency");
|
||||||
|
|
||||||
|
b.Navigation("Store");
|
||||||
|
|
||||||
|
b.Navigation("Supplier");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("foodmarket.Domain.Purchases.SupplyLine", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("foodmarket.Domain.Catalog.Product", "Product")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ProductId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("foodmarket.Domain.Purchases.Supply", "Supply")
|
||||||
|
.WithMany("Lines")
|
||||||
|
.HasForeignKey("SupplyId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Product");
|
||||||
|
|
||||||
|
b.Navigation("Supply");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CurrencyId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Customer")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("foodmarket.Domain.Catalog.RetailPoint", "RetailPoint")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RetailPointId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("foodmarket.Domain.Catalog.Store", "Store")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StoreId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Currency");
|
||||||
|
|
||||||
|
b.Navigation("Customer");
|
||||||
|
|
||||||
|
b.Navigation("RetailPoint");
|
||||||
|
|
||||||
|
b.Navigation("Store");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("foodmarket.Domain.Catalog.Product", "Product")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ProductId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("foodmarket.Domain.Sales.RetailSale", "RetailSale")
|
||||||
|
.WithMany("Lines")
|
||||||
|
.HasForeignKey("RetailSaleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Product");
|
||||||
|
|
||||||
|
b.Navigation("RetailSale");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
|
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Authorizations");
|
b.Navigation("Authorizations");
|
||||||
|
|
@ -1380,6 +1902,16 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
b.Navigation("Children");
|
b.Navigation("Children");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Lines");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Lines");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-hook-form": "^7.73.1",
|
"react-hook-form": "^7.73.1",
|
||||||
"react-router-dom": "^7.14.1",
|
"react-router-dom": "^7.14.1",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ importers:
|
||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^7.14.1
|
specifier: ^7.14.1
|
||||||
version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
|
recharts:
|
||||||
|
specifier: ^3.8.1
|
||||||
|
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@19.2.5)(react@19.2.5)(redux@5.0.1)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
|
|
@ -266,6 +269,17 @@ packages:
|
||||||
'@oxc-project/types@0.126.0':
|
'@oxc-project/types@0.126.0':
|
||||||
resolution: {integrity: sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==}
|
resolution: {integrity: sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==}
|
||||||
|
|
||||||
|
'@reduxjs/toolkit@2.11.2':
|
||||||
|
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
|
||||||
|
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-redux:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.0-rc.16':
|
'@rolldown/binding-android-arm64@1.0.0-rc.16':
|
||||||
resolution: {integrity: sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==}
|
resolution: {integrity: sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
|
@ -367,6 +381,9 @@ packages:
|
||||||
'@rolldown/pluginutils@1.0.0-rc.7':
|
'@rolldown/pluginutils@1.0.0-rc.7':
|
||||||
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
|
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.1.0':
|
||||||
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
'@standard-schema/utils@0.3.0':
|
'@standard-schema/utils@0.3.0':
|
||||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||||
|
|
||||||
|
|
@ -484,6 +501,33 @@ packages:
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
|
'@types/d3-array@3.2.2':
|
||||||
|
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3':
|
||||||
|
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||||
|
|
||||||
|
'@types/d3-ease@3.0.2':
|
||||||
|
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||||
|
|
||||||
|
'@types/d3-path@3.1.1':
|
||||||
|
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
|
||||||
|
|
||||||
|
'@types/d3-scale@4.0.9':
|
||||||
|
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||||
|
|
||||||
|
'@types/d3-shape@3.1.8':
|
||||||
|
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||||
|
|
||||||
|
'@types/d3-time@3.0.4':
|
||||||
|
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||||
|
|
||||||
|
'@types/d3-timer@3.0.2':
|
||||||
|
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
|
@ -501,6 +545,9 @@ packages:
|
||||||
'@types/react@19.2.14':
|
'@types/react@19.2.14':
|
||||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6':
|
||||||
|
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.59.0':
|
'@typescript-eslint/eslint-plugin@8.59.0':
|
||||||
resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==}
|
resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
@ -680,6 +727,50 @@ packages:
|
||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-color@3.1.0:
|
||||||
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-ease@3.0.1:
|
||||||
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-format@3.1.2:
|
||||||
|
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-path@3.1.0:
|
||||||
|
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-scale@4.0.2:
|
||||||
|
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-shape@3.2.0:
|
||||||
|
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-time-format@4.1.0:
|
||||||
|
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-time@3.1.0:
|
||||||
|
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-timer@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
|
|
@ -689,6 +780,9 @@ packages:
|
||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decimal.js-light@2.5.1:
|
||||||
|
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||||
|
|
||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
|
|
||||||
|
|
@ -727,6 +821,9 @@ packages:
|
||||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-toolkit@1.46.0:
|
||||||
|
resolution: {integrity: sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==}
|
||||||
|
|
||||||
escalade@3.2.0:
|
escalade@3.2.0:
|
||||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -792,6 +889,9 @@ packages:
|
||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
eventemitter3@5.0.4:
|
||||||
|
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3:
|
fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
|
|
@ -910,6 +1010,12 @@ packages:
|
||||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
immer@10.2.0:
|
||||||
|
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||||
|
|
||||||
|
immer@11.1.4:
|
||||||
|
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -918,6 +1024,10 @@ packages:
|
||||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||||
engines: {node: '>=0.8.19'}
|
engines: {node: '>=0.8.19'}
|
||||||
|
|
||||||
|
internmap@2.0.3:
|
||||||
|
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
is-extglob@2.1.1:
|
is-extglob@2.1.1:
|
||||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -1152,6 +1262,21 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
|
react-is@19.2.5:
|
||||||
|
resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==}
|
||||||
|
|
||||||
|
react-redux@9.2.0:
|
||||||
|
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': ^18.2.25 || ^19
|
||||||
|
react: ^18.0 || ^19
|
||||||
|
redux: ^5.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
redux:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react-router-dom@7.14.1:
|
react-router-dom@7.14.1:
|
||||||
resolution: {integrity: sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==}
|
resolution: {integrity: sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
@ -1173,6 +1298,25 @@ packages:
|
||||||
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
|
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
recharts@3.8.1:
|
||||||
|
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
redux-thunk@3.1.0:
|
||||||
|
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||||
|
peerDependencies:
|
||||||
|
redux: ^5.0.0
|
||||||
|
|
||||||
|
redux@5.0.1:
|
||||||
|
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||||
|
|
||||||
|
reselect@5.1.1:
|
||||||
|
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||||
|
|
||||||
resolve-from@4.0.0:
|
resolve-from@4.0.0:
|
||||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
@ -1232,6 +1376,9 @@ packages:
|
||||||
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
|
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
tiny-invariant@1.3.3:
|
||||||
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
tinyglobby@0.2.16:
|
tinyglobby@0.2.16:
|
||||||
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
@ -1273,6 +1420,14 @@ packages:
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||||
|
|
||||||
|
use-sync-external-store@1.6.0:
|
||||||
|
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
victory-vendor@37.3.6:
|
||||||
|
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||||
|
|
||||||
vite@8.0.9:
|
vite@8.0.9:
|
||||||
resolution: {integrity: sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==}
|
resolution: {integrity: sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
|
@ -1554,6 +1709,18 @@ snapshots:
|
||||||
|
|
||||||
'@oxc-project/types@0.126.0': {}
|
'@oxc-project/types@0.126.0': {}
|
||||||
|
|
||||||
|
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)':
|
||||||
|
dependencies:
|
||||||
|
'@standard-schema/spec': 1.1.0
|
||||||
|
'@standard-schema/utils': 0.3.0
|
||||||
|
immer: 11.1.4
|
||||||
|
redux: 5.0.1
|
||||||
|
redux-thunk: 3.1.0(redux@5.0.1)
|
||||||
|
reselect: 5.1.1
|
||||||
|
optionalDependencies:
|
||||||
|
react: 19.2.5
|
||||||
|
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1)
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.0-rc.16':
|
'@rolldown/binding-android-arm64@1.0.0-rc.16':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -1607,6 +1774,8 @@ snapshots:
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@standard-schema/utils@0.3.0': {}
|
'@standard-schema/utils@0.3.0': {}
|
||||||
|
|
||||||
'@tailwindcss/node@4.2.3':
|
'@tailwindcss/node@4.2.3':
|
||||||
|
|
@ -1697,6 +1866,30 @@ snapshots:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@types/d3-array@3.2.2': {}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3': {}
|
||||||
|
|
||||||
|
'@types/d3-ease@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-color': 3.1.3
|
||||||
|
|
||||||
|
'@types/d3-path@3.1.1': {}
|
||||||
|
|
||||||
|
'@types/d3-scale@4.0.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-time': 3.0.4
|
||||||
|
|
||||||
|
'@types/d3-shape@3.1.8':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-path': 3.1.1
|
||||||
|
|
||||||
|
'@types/d3-time@3.0.4': {}
|
||||||
|
|
||||||
|
'@types/d3-timer@3.0.2': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
@ -1713,6 +1906,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6': {}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
|
'@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
|
|
@ -1914,10 +2109,50 @@ snapshots:
|
||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
dependencies:
|
||||||
|
internmap: 2.0.3
|
||||||
|
|
||||||
|
d3-color@3.1.0: {}
|
||||||
|
|
||||||
|
d3-ease@3.0.1: {}
|
||||||
|
|
||||||
|
d3-format@3.1.2: {}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
|
||||||
|
d3-path@3.1.0: {}
|
||||||
|
|
||||||
|
d3-scale@4.0.2:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
d3-format: 3.1.2
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-time: 3.1.0
|
||||||
|
d3-time-format: 4.1.0
|
||||||
|
|
||||||
|
d3-shape@3.2.0:
|
||||||
|
dependencies:
|
||||||
|
d3-path: 3.1.0
|
||||||
|
|
||||||
|
d3-time-format@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
d3-time: 3.1.0
|
||||||
|
|
||||||
|
d3-time@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
|
||||||
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decimal.js-light@2.5.1: {}
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
delayed-stream@1.0.0: {}
|
delayed-stream@1.0.0: {}
|
||||||
|
|
@ -1952,6 +2187,8 @@ snapshots:
|
||||||
has-tostringtag: 1.0.2
|
has-tostringtag: 1.0.2
|
||||||
hasown: 2.0.3
|
hasown: 2.0.3
|
||||||
|
|
||||||
|
es-toolkit@1.46.0: {}
|
||||||
|
|
||||||
escalade@3.2.0: {}
|
escalade@3.2.0: {}
|
||||||
|
|
||||||
escape-string-regexp@4.0.0: {}
|
escape-string-regexp@4.0.0: {}
|
||||||
|
|
@ -2041,6 +2278,8 @@ snapshots:
|
||||||
|
|
||||||
esutils@2.0.3: {}
|
esutils@2.0.3: {}
|
||||||
|
|
||||||
|
eventemitter3@5.0.4: {}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
fast-json-stable-stringify@2.1.0: {}
|
fast-json-stable-stringify@2.1.0: {}
|
||||||
|
|
@ -2138,6 +2377,10 @@ snapshots:
|
||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
|
|
||||||
|
immer@10.2.0: {}
|
||||||
|
|
||||||
|
immer@11.1.4: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
parent-module: 1.0.1
|
parent-module: 1.0.1
|
||||||
|
|
@ -2145,6 +2388,8 @@ snapshots:
|
||||||
|
|
||||||
imurmurhash@0.1.4: {}
|
imurmurhash@0.1.4: {}
|
||||||
|
|
||||||
|
internmap@2.0.3: {}
|
||||||
|
|
||||||
is-extglob@2.1.1: {}
|
is-extglob@2.1.1: {}
|
||||||
|
|
||||||
is-glob@4.0.3:
|
is-glob@4.0.3:
|
||||||
|
|
@ -2323,6 +2568,17 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.5
|
react: 19.2.5
|
||||||
|
|
||||||
|
react-is@19.2.5: {}
|
||||||
|
|
||||||
|
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
'@types/use-sync-external-store': 0.0.6
|
||||||
|
react: 19.2.5
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.5)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
redux: 5.0.1
|
||||||
|
|
||||||
react-router-dom@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
react-router-dom@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.5
|
react: 19.2.5
|
||||||
|
|
@ -2339,6 +2595,34 @@ snapshots:
|
||||||
|
|
||||||
react@19.2.5: {}
|
react@19.2.5: {}
|
||||||
|
|
||||||
|
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@19.2.5)(react@19.2.5)(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)
|
||||||
|
clsx: 2.1.1
|
||||||
|
decimal.js-light: 2.5.1
|
||||||
|
es-toolkit: 1.46.0
|
||||||
|
eventemitter3: 5.0.4
|
||||||
|
immer: 10.2.0
|
||||||
|
react: 19.2.5
|
||||||
|
react-dom: 19.2.5(react@19.2.5)
|
||||||
|
react-is: 19.2.5
|
||||||
|
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1)
|
||||||
|
reselect: 5.1.1
|
||||||
|
tiny-invariant: 1.3.3
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.5)
|
||||||
|
victory-vendor: 37.3.6
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- redux
|
||||||
|
|
||||||
|
redux-thunk@3.1.0(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
redux: 5.0.1
|
||||||
|
|
||||||
|
redux@5.0.1: {}
|
||||||
|
|
||||||
|
reselect@5.1.1: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
|
|
||||||
rolldown@1.0.0-rc.16:
|
rolldown@1.0.0-rc.16:
|
||||||
|
|
@ -2394,6 +2678,8 @@ snapshots:
|
||||||
|
|
||||||
tapable@2.3.2: {}
|
tapable@2.3.2: {}
|
||||||
|
|
||||||
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
tinyglobby@0.2.16:
|
tinyglobby@0.2.16:
|
||||||
dependencies:
|
dependencies:
|
||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
|
|
@ -2435,6 +2721,27 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
|
use-sync-external-store@1.6.0(react@19.2.5):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.5
|
||||||
|
|
||||||
|
victory-vendor@37.3.6:
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-array': 3.2.2
|
||||||
|
'@types/d3-ease': 3.0.2
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-scale': 4.0.9
|
||||||
|
'@types/d3-shape': 3.1.8
|
||||||
|
'@types/d3-time': 3.0.4
|
||||||
|
'@types/d3-timer': 3.0.2
|
||||||
|
d3-array: 3.2.4
|
||||||
|
d3-ease: 3.0.1
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-scale: 4.0.2
|
||||||
|
d3-shape: 3.2.0
|
||||||
|
d3-time: 3.1.0
|
||||||
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
vite@8.0.9(@types/node@24.12.2)(jiti@2.6.1):
|
vite@8.0.9(@types/node@24.12.2)(jiti@2.6.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,13 @@ import { ProductGroupsPage } from '@/pages/ProductGroupsPage'
|
||||||
import { CounterpartiesPage } from '@/pages/CounterpartiesPage'
|
import { CounterpartiesPage } from '@/pages/CounterpartiesPage'
|
||||||
import { ProductsPage } from '@/pages/ProductsPage'
|
import { ProductsPage } from '@/pages/ProductsPage'
|
||||||
import { ProductEditPage } from '@/pages/ProductEditPage'
|
import { ProductEditPage } from '@/pages/ProductEditPage'
|
||||||
|
import { MoySkladImportPage } from '@/pages/MoySkladImportPage'
|
||||||
|
import { StockPage } from '@/pages/StockPage'
|
||||||
|
import { StockMovementsPage } from '@/pages/StockMovementsPage'
|
||||||
|
import { SuppliesPage } from '@/pages/SuppliesPage'
|
||||||
|
import { SupplyEditPage } from '@/pages/SupplyEditPage'
|
||||||
|
import { RetailSalesPage } from '@/pages/RetailSalesPage'
|
||||||
|
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
|
||||||
import { AppLayout } from '@/components/AppLayout'
|
import { AppLayout } from '@/components/AppLayout'
|
||||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||||
|
|
||||||
|
|
@ -46,6 +53,15 @@ export default function App() {
|
||||||
<Route path="/catalog/retail-points" element={<RetailPointsPage />} />
|
<Route path="/catalog/retail-points" element={<RetailPointsPage />} />
|
||||||
<Route path="/catalog/countries" element={<CountriesPage />} />
|
<Route path="/catalog/countries" element={<CountriesPage />} />
|
||||||
<Route path="/catalog/currencies" element={<CurrenciesPage />} />
|
<Route path="/catalog/currencies" element={<CurrenciesPage />} />
|
||||||
|
<Route path="/inventory/stock" element={<StockPage />} />
|
||||||
|
<Route path="/inventory/movements" element={<StockMovementsPage />} />
|
||||||
|
<Route path="/purchases/supplies" element={<SuppliesPage />} />
|
||||||
|
<Route path="/purchases/supplies/new" element={<SupplyEditPage />} />
|
||||||
|
<Route path="/purchases/supplies/:id" element={<SupplyEditPage />} />
|
||||||
|
<Route path="/sales/retail" element={<RetailSalesPage />} />
|
||||||
|
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
|
||||||
|
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />
|
||||||
|
<Route path="/admin/import/moysklad" element={<MoySkladImportPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import { logout } from '@/lib/auth'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag,
|
LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag,
|
||||||
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut,
|
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download,
|
||||||
|
Boxes, History, TruckIcon, ShoppingCart,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Logo } from './Logo'
|
import { Logo } from './Logo'
|
||||||
|
|
||||||
|
|
@ -35,10 +36,23 @@ const nav = [
|
||||||
{ to: '/catalog/stores', icon: Warehouse, label: 'Склады' },
|
{ to: '/catalog/stores', icon: Warehouse, label: 'Склады' },
|
||||||
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'Точки продаж' },
|
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'Точки продаж' },
|
||||||
]},
|
]},
|
||||||
|
{ group: 'Остатки', items: [
|
||||||
|
{ to: '/inventory/stock', icon: Boxes, label: 'Остатки' },
|
||||||
|
{ to: '/inventory/movements', icon: History, label: 'Движения' },
|
||||||
|
]},
|
||||||
|
{ group: 'Закупки', items: [
|
||||||
|
{ to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' },
|
||||||
|
]},
|
||||||
|
{ group: 'Продажи', items: [
|
||||||
|
{ to: '/sales/retail', icon: ShoppingCart, label: 'Розничные чеки' },
|
||||||
|
]},
|
||||||
{ group: 'Справочники', items: [
|
{ group: 'Справочники', items: [
|
||||||
{ to: '/catalog/countries', icon: Globe, label: 'Страны' },
|
{ to: '/catalog/countries', icon: Globe, label: 'Страны' },
|
||||||
{ to: '/catalog/currencies', icon: Coins, label: 'Валюты' },
|
{ to: '/catalog/currencies', icon: Coins, label: 'Валюты' },
|
||||||
]},
|
]},
|
||||||
|
{ group: 'Импорт', items: [
|
||||||
|
{ to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' },
|
||||||
|
]},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
|
|
@ -49,10 +63,10 @@ export function AppLayout() {
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-slate-50 dark:bg-slate-950">
|
<div className="h-screen flex bg-slate-50 dark:bg-slate-950 overflow-hidden">
|
||||||
<aside className="w-60 flex-shrink-0 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col">
|
<aside className="w-60 flex-shrink-0 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col h-full">
|
||||||
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
|
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
|
||||||
<Logo size={28} />
|
<Logo />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto py-3">
|
<nav className="flex-1 overflow-y-auto py-3">
|
||||||
|
|
@ -95,7 +109,7 @@ export function AppLayout() {
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="flex-1 overflow-x-hidden">
|
<main className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,18 +15,25 @@ interface DataTableProps<T> {
|
||||||
onRowClick?: (row: T) => void
|
onRowClick?: (row: T) => void
|
||||||
empty?: ReactNode
|
empty?: ReactNode
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
|
/** If true (default), the table wraps itself in a scrollable container with a sticky thead.
|
||||||
|
* If false, use when the caller provides its own scroll container. */
|
||||||
|
scrollable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<T>({ rows, columns, rowKey, onRowClick, empty, isLoading }: DataTableProps<T>) {
|
export function DataTable<T>({
|
||||||
return (
|
rows, columns, rowKey, onRowClick, empty, isLoading, scrollable = true,
|
||||||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 overflow-hidden">
|
}: DataTableProps<T>) {
|
||||||
<table className="w-full text-sm">
|
const table = (
|
||||||
<thead className="bg-slate-50 dark:bg-slate-800/50 text-left">
|
<table className="w-full text-sm border-separate border-spacing-0">
|
||||||
|
<thead className="sticky top-0 z-10 bg-slate-50 dark:bg-slate-800/90 backdrop-blur text-left">
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((c, i) => (
|
{columns.map((c, i) => (
|
||||||
<th
|
<th
|
||||||
key={i}
|
key={i}
|
||||||
className={cn('px-4 py-2.5 font-medium text-xs uppercase tracking-wide text-slate-500', c.className)}
|
className={cn(
|
||||||
|
'px-4 py-2.5 font-medium text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200 dark:border-slate-700',
|
||||||
|
c.className,
|
||||||
|
)}
|
||||||
style={c.width ? { width: c.width } : undefined}
|
style={c.width ? { width: c.width } : undefined}
|
||||||
>
|
>
|
||||||
{c.header}
|
{c.header}
|
||||||
|
|
@ -53,12 +60,17 @@ export function DataTable<T>({ rows, columns, rowKey, onRowClick, empty, isLoadi
|
||||||
key={rowKey(row)}
|
key={rowKey(row)}
|
||||||
onClick={() => onRowClick?.(row)}
|
onClick={() => onRowClick?.(row)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-t border-slate-100 dark:border-slate-800',
|
onRowClick && 'cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/30',
|
||||||
onRowClick && 'cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/30'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{columns.map((c, i) => (
|
{columns.map((c, i) => (
|
||||||
<td key={i} className={cn('px-4 py-2.5 text-slate-700 dark:text-slate-200', c.className)}>
|
<td
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2.5 text-slate-700 dark:text-slate-200 border-b border-slate-100 dark:border-slate-800',
|
||||||
|
c.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{c.cell(row)}
|
{c.cell(row)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|
@ -67,6 +79,19 @@ export function DataTable<T>({ rows, columns, rowKey, onRowClick, empty, isLoadi
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!scrollable) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||||
|
{table}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 h-full overflow-auto">
|
||||||
|
{table}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
src/food-market.web/src/components/ListPageShell.tsx
Normal file
25
src/food-market.web/src/components/ListPageShell.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { PageHeader } from './PageHeader'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
actions?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fullheight list-page layout: sticky top bar + scrollable content + optional sticky footer (pagination). */
|
||||||
|
export function ListPageShell({ title, description, actions, children, footer }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<PageHeader variant="bar" title={title} description={description} actions={actions} />
|
||||||
|
<div className="flex-1 min-h-0 p-4">{children}</div>
|
||||||
|
{footer && (
|
||||||
|
<div className="border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 px-4 py-2">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,25 +1,17 @@
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function Logo({ size = 28, showText = true, className }: { size?: number; showText?: boolean; className?: string }) {
|
export function Logo({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center gap-2.5', className)}>
|
<div className={cn('flex flex-col leading-none select-none', className)}>
|
||||||
<div
|
<span className="font-black text-slate-900 dark:text-slate-100 tracking-[0.08em] text-base">
|
||||||
className="flex items-center justify-center rounded-md font-black text-white leading-none"
|
FOOD
|
||||||
style={{
|
</span>
|
||||||
backgroundColor: 'var(--color-brand)',
|
<span
|
||||||
width: size,
|
className="font-black text-[11px] tracking-[0.24em] mt-0.5"
|
||||||
height: size,
|
style={{ color: 'var(--color-brand)' }}
|
||||||
fontSize: Math.floor(size * 0.38),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
FM
|
MARKET
|
||||||
</div>
|
</span>
|
||||||
{showText && (
|
|
||||||
<div className="leading-tight">
|
|
||||||
<div className="font-black text-slate-900 dark:text-slate-100 tracking-wide">FOOD</div>
|
|
||||||
<div className="font-black text-xs tracking-[0.2em]" style={{ color: 'var(--color-brand)' }}>MARKET</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,30 @@ interface PageHeaderProps {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
actions?: ReactNode
|
actions?: ReactNode
|
||||||
|
/** Visual style — set 'bar' to render inside a sticky top bar (used by list/edit pages). */
|
||||||
|
variant?: 'plain' | 'bar'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({ title, description, actions, variant = 'plain' }: PageHeaderProps) {
|
||||||
|
if (variant === 'bar') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 px-6 py-4 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100 truncate">{title}</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5 truncate">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex items-center gap-2 flex-shrink-0">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-4 mb-5">
|
<div className="flex items-start justify-between gap-4 mb-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-slate-900 dark:text-slate-100">{title}</h1>
|
<h1 className="text-xl font-semibold text-slate-900 dark:text-slate-100">{title}</h1>
|
||||||
{description && (
|
{description && <p className="text-sm text-slate-500 mt-0.5">{description}</p>}
|
||||||
<p className="text-sm text-slate-500 mt-0.5">{description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
88
src/food-market.web/src/components/ProductPicker.tsx
Normal file
88
src/food-market.web/src/components/ProductPicker.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Search, X } from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import type { PagedResult, Product } from '@/lib/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onPick: (product: Product) => void
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductPicker({ open, onClose, onPick, title = 'Выбор товара' }: Props) {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => { if (!open) setSearch('') }, [open])
|
||||||
|
|
||||||
|
const results = useQuery({
|
||||||
|
queryKey: ['product-picker', search],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams({ pageSize: '30' })
|
||||||
|
if (search) params.set('search', search)
|
||||||
|
return (await api.get<PagedResult<Product>>(`/api/catalog/products?${params}`)).data.items
|
||||||
|
},
|
||||||
|
enabled: open,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||||
|
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="px-5 py-3 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-slate-100">{title}</h3>
|
||||||
|
<button onClick={onClose} className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-3 border-b border-slate-200 dark:border-slate-800">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="По названию, артикулу или штрихкоду…"
|
||||||
|
className="w-full pl-9 pr-3 py-2 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{results.isLoading && <div className="p-6 text-center text-slate-400 text-sm">Загрузка…</div>}
|
||||||
|
{results.data && results.data.length === 0 && (
|
||||||
|
<div className="p-6 text-center text-slate-400 text-sm">
|
||||||
|
{search ? 'Ничего не найдено' : 'Начни вводить название или штрихкод'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{results.data && results.data.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onPick(p); onClose() }}
|
||||||
|
className="w-full text-left px-5 py-2.5 border-b border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium text-slate-900 dark:text-slate-100 truncate">{p.name}</div>
|
||||||
|
<div className="text-xs text-slate-400 flex gap-2 font-mono">
|
||||||
|
{p.article && <span>{p.article}</span>}
|
||||||
|
{p.barcodes[0] && <span>· {p.barcodes[0].code}</span>}
|
||||||
|
<span>· {p.unitSymbol}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{p.purchasePrice !== null && (
|
||||||
|
<div className="text-xs text-slate-500 font-mono flex-shrink-0">
|
||||||
|
закуп: {p.purchasePrice.toLocaleString('ru')} {p.purchaseCurrencyCode ?? ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
src/food-market.web/src/components/SalesChart.tsx
Normal file
77
src/food-market.web/src/components/SalesChart.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import {
|
||||||
|
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||||
|
CartesianGrid,
|
||||||
|
} from 'recharts'
|
||||||
|
import type { SalesStatsBucket } from '@/lib/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
series: SalesStatsBucket[]
|
||||||
|
currencyCode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat('ru', { maximumFractionDigits: 0 })
|
||||||
|
const fmtDay = (s: string) => {
|
||||||
|
const d = new Date(s)
|
||||||
|
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SalesChart({ series, currencyCode = 'KZT' }: Props) {
|
||||||
|
const data = series.map((b) => ({
|
||||||
|
day: fmtDay(b.bucket),
|
||||||
|
revenue: b.revenue,
|
||||||
|
transactions: b.transactions,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-72 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="revenue-fill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-brand)" stopOpacity={0.35} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-brand)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
stroke="#94a3b8"
|
||||||
|
fontSize={11}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="#94a3b8"
|
||||||
|
fontSize={11}
|
||||||
|
tickFormatter={(v) => fmt.format(v)}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={70}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'rgba(15, 23, 42, 0.95)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#f1f5f9',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: '#94a3b8', marginBottom: 4 }}
|
||||||
|
formatter={(value, name) => {
|
||||||
|
const num = typeof value === 'number' ? value : Number(value ?? 0)
|
||||||
|
if (name === 'revenue') return [`${fmt.format(num)} ${currencyCode}`, 'Выручка']
|
||||||
|
return [String(value), String(name)]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stroke="var(--color-brand)"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#revenue-fill)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,10 @@ api.interceptors.response.use(
|
||||||
return api(original)
|
return api(original)
|
||||||
}
|
}
|
||||||
clearTokens()
|
clearTokens()
|
||||||
|
// Redirect to login so user isn't stuck on a protected page with stale tokens.
|
||||||
|
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export interface PagedResult<T> {
|
||||||
totalPages: number
|
totalPages: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CounterpartyKind = { Supplier: 1, Customer: 2, Both: 3 } as const
|
export const CounterpartyKind = { Unspecified: 0, Supplier: 1, Customer: 2, Both: 3 } as const
|
||||||
export type CounterpartyKind = (typeof CounterpartyKind)[keyof typeof CounterpartyKind]
|
export type CounterpartyKind = (typeof CounterpartyKind)[keyof typeof CounterpartyKind]
|
||||||
|
|
||||||
export const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const
|
export const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const
|
||||||
|
|
@ -54,3 +54,97 @@ export interface Product {
|
||||||
imageUrl: string | null; isActive: boolean;
|
imageUrl: string | null; isActive: boolean;
|
||||||
prices: ProductPrice[]; barcodes: ProductBarcode[]
|
prices: ProductPrice[]; barcodes: ProductBarcode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StockRow {
|
||||||
|
productId: string; productName: string; article: string | null; unitSymbol: string;
|
||||||
|
storeId: string; storeName: string;
|
||||||
|
quantity: number; reservedQuantity: number; available: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MovementRow {
|
||||||
|
id: string; occurredAt: string;
|
||||||
|
productId: string; productName: string; article: string | null;
|
||||||
|
storeId: string; storeName: string;
|
||||||
|
quantity: number; unitCost: number | null;
|
||||||
|
type: string; documentType: string; documentId: string | null; documentNumber: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SupplyStatus = { Draft: 0, Posted: 1 } as const
|
||||||
|
export type SupplyStatus = (typeof SupplyStatus)[keyof typeof SupplyStatus]
|
||||||
|
|
||||||
|
export interface SupplyListRow {
|
||||||
|
id: string; number: string; date: string; status: SupplyStatus;
|
||||||
|
supplierId: string; supplierName: string;
|
||||||
|
storeId: string; storeName: string;
|
||||||
|
currencyId: string; currencyCode: string;
|
||||||
|
total: number; lineCount: number; postedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplyLineDto {
|
||||||
|
id: string | null; productId: string;
|
||||||
|
productName: string | null; productArticle: string | null; unitSymbol: string | null;
|
||||||
|
quantity: number; unitPrice: number; lineTotal: number; sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplyDto {
|
||||||
|
id: string; number: string; date: string; status: SupplyStatus;
|
||||||
|
supplierId: string; supplierName: string;
|
||||||
|
storeId: string; storeName: string;
|
||||||
|
currencyId: string; currencyCode: string;
|
||||||
|
supplierInvoiceNumber: string | null; supplierInvoiceDate: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
total: number; postedAt: string | null;
|
||||||
|
lines: SupplyLineDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RetailSaleStatus = { Draft: 0, Posted: 1 } as const
|
||||||
|
export type RetailSaleStatus = (typeof RetailSaleStatus)[keyof typeof RetailSaleStatus]
|
||||||
|
|
||||||
|
export const PaymentMethod = { Cash: 0, Card: 1, BankTransfer: 2, Bonus: 3, Mixed: 99 } as const
|
||||||
|
export type PaymentMethod = (typeof PaymentMethod)[keyof typeof PaymentMethod]
|
||||||
|
|
||||||
|
export interface RetailSaleListRow {
|
||||||
|
id: string; number: string; date: string; status: RetailSaleStatus;
|
||||||
|
storeId: string; storeName: string;
|
||||||
|
retailPointId: string | null; retailPointName: string | null;
|
||||||
|
customerId: string | null; customerName: string | null;
|
||||||
|
currencyId: string; currencyCode: string;
|
||||||
|
total: number; payment: PaymentMethod; lineCount: number;
|
||||||
|
postedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetailSaleLineDto {
|
||||||
|
id: string | null; productId: string;
|
||||||
|
productName: string | null; productArticle: string | null; unitSymbol: string | null;
|
||||||
|
quantity: number; unitPrice: number; discount: number; lineTotal: number;
|
||||||
|
vatPercent: number; sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesStatsBucket {
|
||||||
|
bucket: string
|
||||||
|
revenue: number
|
||||||
|
transactions: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesStatsResponse {
|
||||||
|
revenueToday: number
|
||||||
|
revenueThisMonth: number
|
||||||
|
revenuePrevMonth: number
|
||||||
|
transactionsToday: number
|
||||||
|
transactionsThisMonth: number
|
||||||
|
avgTicketThisMonth: number
|
||||||
|
series: SalesStatsBucket[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetailSaleDto {
|
||||||
|
id: string; number: string; date: string; status: RetailSaleStatus;
|
||||||
|
storeId: string; storeName: string;
|
||||||
|
retailPointId: string | null; retailPointName: string | null;
|
||||||
|
customerId: string | null; customerName: string | null;
|
||||||
|
currencyId: string; currencyCode: string;
|
||||||
|
subtotal: number; discountTotal: number; total: number;
|
||||||
|
payment: PaymentMethod; paidCash: number; paidCard: number;
|
||||||
|
notes: string | null; postedAt: string | null;
|
||||||
|
lines: RetailSaleLineDto[];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ export const useCountries = () => useLookup<Country>('countries', '/api/catalog/
|
||||||
export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies')
|
export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies')
|
||||||
export const useStores = () => useLookup<Store>('stores', '/api/catalog/stores')
|
export const useStores = () => useLookup<Store>('stores', '/api/catalog/stores')
|
||||||
export const usePriceTypes = () => useLookup<PriceType>('price-types', '/api/catalog/price-types')
|
export const usePriceTypes = () => useLookup<PriceType>('price-types', '/api/catalog/price-types')
|
||||||
export const useSuppliers = () => useQuery({
|
// MoySklad-style: контрагент один, может быть и поставщиком, и покупателем
|
||||||
queryKey: ['lookup:suppliers'],
|
// в разных документах. Не фильтруем по Kind — пользователь сам выбирает.
|
||||||
queryFn: async () => (await api.get<PagedResult<Counterparty>>('/api/catalog/counterparties?pageSize=500&kind=1')).data.items,
|
export const useCounterparties = () => useLookup<Counterparty>('counterparties', '/api/catalog/counterparties')
|
||||||
staleTime: 5 * 60 * 1000,
|
// Алиас для обратной совместимости со старым кодом форм Supply/RetailSale.
|
||||||
})
|
export const useSuppliers = useCounterparties
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
|
|
@ -36,7 +36,9 @@ interface Form {
|
||||||
}
|
}
|
||||||
|
|
||||||
const blankForm: Form = {
|
const blankForm: Form = {
|
||||||
name: '', legalName: '', kind: CounterpartyKind.Supplier, type: CounterpartyType.LegalEntity,
|
// Kind по умолчанию Unspecified — MoySklad не имеет такого поля у контрагентов,
|
||||||
|
// не выдумываем за пользователя. Пусть выберет вручную если нужно.
|
||||||
|
name: '', legalName: '', kind: CounterpartyKind.Unspecified, type: CounterpartyType.LegalEntity,
|
||||||
bin: '', iin: '', taxNumber: '', countryId: '',
|
bin: '', iin: '', taxNumber: '', countryId: '',
|
||||||
address: '', phone: '', email: '',
|
address: '', phone: '', email: '',
|
||||||
bankName: '', bankAccount: '', bik: '',
|
bankName: '', bankAccount: '', bik: '',
|
||||||
|
|
@ -44,9 +46,10 @@ const blankForm: Form = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const kindLabel: Record<CounterpartyKind, string> = {
|
const kindLabel: Record<CounterpartyKind, string> = {
|
||||||
|
[CounterpartyKind.Unspecified]: '—',
|
||||||
[CounterpartyKind.Supplier]: 'Поставщик',
|
[CounterpartyKind.Supplier]: 'Поставщик',
|
||||||
[CounterpartyKind.Customer]: 'Покупатель',
|
[CounterpartyKind.Customer]: 'Покупатель',
|
||||||
[CounterpartyKind.Both]: 'Оба',
|
[CounterpartyKind.Both]: 'Поставщик + Покупатель',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CounterpartiesPage() {
|
export function CounterpartiesPage() {
|
||||||
|
|
@ -70,8 +73,8 @@ export function CounterpartiesPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<>
|
||||||
<PageHeader
|
<ListPageShell
|
||||||
title="Контрагенты"
|
title="Контрагенты"
|
||||||
description="Поставщики и покупатели."
|
description="Поставщики и покупатели."
|
||||||
actions={
|
actions={
|
||||||
|
|
@ -80,8 +83,10 @@ export function CounterpartiesPage() {
|
||||||
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
|
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
footer={data && data.total > 0 && (
|
||||||
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={data?.items ?? []}
|
rows={data?.items ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|
@ -102,8 +107,7 @@ export function CounterpartiesPage() {
|
||||||
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</ListPageShell>
|
||||||
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={!!form}
|
open={!!form}
|
||||||
|
|
@ -137,9 +141,10 @@ export function CounterpartiesPage() {
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Роль">
|
<Field label="Роль">
|
||||||
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as CounterpartyKind })}>
|
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as CounterpartyKind })}>
|
||||||
|
<option value={CounterpartyKind.Unspecified}>Не указано</option>
|
||||||
<option value={CounterpartyKind.Supplier}>Поставщик</option>
|
<option value={CounterpartyKind.Supplier}>Поставщик</option>
|
||||||
<option value={CounterpartyKind.Customer}>Покупатель</option>
|
<option value={CounterpartyKind.Customer}>Покупатель</option>
|
||||||
<option value={CounterpartyKind.Both}>Оба</option>
|
<option value={CounterpartyKind.Both}>Поставщик + Покупатель</option>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Тип лица">
|
<Field label="Тип лица">
|
||||||
|
|
@ -193,6 +198,6 @@ export function CounterpartiesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
|
|
@ -9,11 +9,14 @@ export function CountriesPage() {
|
||||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Country>('/api/catalog/countries')
|
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Country>('/api/catalog/countries')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<ListPageShell
|
||||||
<PageHeader title="Страны" description="Глобальный справочник. По умолчанию Казахстан." actions={
|
title="Страны"
|
||||||
<SearchBar value={search} onChange={setSearch} />
|
description="Глобальный справочник. По умолчанию Казахстан."
|
||||||
} />
|
actions={<SearchBar value={search} onChange={setSearch} />}
|
||||||
|
footer={data && data.total > 0 && (
|
||||||
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={data?.items ?? []}
|
rows={data?.items ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|
@ -24,8 +27,6 @@ export function CountriesPage() {
|
||||||
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
|
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</ListPageShell>
|
||||||
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
|
|
@ -9,11 +9,14 @@ export function CurrenciesPage() {
|
||||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Currency>('/api/catalog/currencies')
|
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Currency>('/api/catalog/currencies')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<ListPageShell
|
||||||
<PageHeader title="Валюты" description="Доступные валюты для операций. Основная — тенге (KZT)." actions={
|
title="Валюты"
|
||||||
<SearchBar value={search} onChange={setSearch} />
|
description="Доступные валюты для операций. Основная — тенге (KZT)."
|
||||||
} />
|
actions={<SearchBar value={search} onChange={setSearch} />}
|
||||||
|
footer={data && data.total > 0 && (
|
||||||
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={data?.items ?? []}
|
rows={data?.items ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|
@ -26,8 +29,6 @@ export function CurrenciesPage() {
|
||||||
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</ListPageShell>
|
||||||
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Package, Users, Warehouse, Store } from 'lucide-react'
|
import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar } from 'lucide-react'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { SalesChart } from '@/components/SalesChart'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import type { PagedResult } from '@/lib/types'
|
import type { PagedResult, SalesStatsResponse } from '@/lib/types'
|
||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
sub: string
|
sub: string
|
||||||
|
|
@ -19,22 +20,54 @@ function useCount(url: string) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatCardProps {
|
const fmt = new Intl.NumberFormat('ru', { maximumFractionDigits: 0 })
|
||||||
|
const fmtMoney = (n: number) => fmt.format(n)
|
||||||
|
|
||||||
|
interface KpiCardProps {
|
||||||
icon: React.ComponentType<{ className?: string }>
|
icon: React.ComponentType<{ className?: string }>
|
||||||
label: string
|
label: string
|
||||||
value: number | string | undefined
|
value: string | number
|
||||||
isLoading: boolean
|
hint?: string
|
||||||
|
delta?: { value: number; positive: boolean }
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ icon: Icon, label, value, isLoading }: StatCardProps) {
|
function KpiCard({ icon: Icon, label, value, hint, delta }: KpiCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
|
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold text-slate-900 dark:text-slate-100 truncate">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{hint && <div className="mt-0.5 text-xs text-slate-400">{hint}</div>}
|
||||||
|
</div>
|
||||||
|
<Icon className="w-5 h-5 text-slate-400 flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
{delta && (
|
||||||
|
<div className={`mt-2 inline-flex items-center gap-1 text-xs font-medium ${delta.positive ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{delta.positive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
||||||
|
{delta.positive ? '+' : ''}{delta.value.toFixed(1)}% к прошлому месяцу
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniCard({ icon: Icon, label, value, isLoading }: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
label: string
|
||||||
|
value: number | undefined
|
||||||
|
isLoading: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-slate-500">{label}</span>
|
<span className="text-xs text-slate-500">{label}</span>
|
||||||
<Icon className="w-4 h-4 text-slate-400" />
|
<Icon className="w-4 h-4 text-slate-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-semibold mt-2 text-slate-900 dark:text-slate-100">
|
<div className="text-xl font-semibold mt-1.5 text-slate-900 dark:text-slate-100">
|
||||||
{isLoading ? '…' : value ?? '—'}
|
{isLoading ? '…' : value !== undefined ? fmt.format(value) : '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -45,57 +78,90 @@ export function DashboardPage() {
|
||||||
queryKey: ['me'],
|
queryKey: ['me'],
|
||||||
queryFn: async () => (await api.get<MeResponse>('/api/me')).data,
|
queryFn: async () => (await api.get<MeResponse>('/api/me')).data,
|
||||||
})
|
})
|
||||||
|
const stats = useQuery({
|
||||||
|
queryKey: ['/api/sales/retail/stats'],
|
||||||
|
queryFn: async () => (await api.get<SalesStatsResponse>('/api/sales/retail/stats?days=30')).data,
|
||||||
|
})
|
||||||
const products = useCount('/api/catalog/products')
|
const products = useCount('/api/catalog/products')
|
||||||
const counterparties = useCount('/api/catalog/counterparties')
|
const counterparties = useCount('/api/catalog/counterparties')
|
||||||
const stores = useCount('/api/catalog/stores')
|
const stores = useCount('/api/catalog/stores')
|
||||||
const retailPoints = useCount('/api/catalog/retail-points')
|
const retailPoints = useCount('/api/catalog/retail-points')
|
||||||
|
|
||||||
const anyError = [products, counterparties, stores, retailPoints].find(q => q.error)?.error as Error | undefined
|
const monthDelta = stats.data && stats.data.revenuePrevMonth > 0
|
||||||
|
? ((stats.data.revenueThisMonth - stats.data.revenuePrevMonth) / stats.data.revenuePrevMonth) * 100
|
||||||
|
: null
|
||||||
|
|
||||||
|
const hasAnySales = stats.data && stats.data.series.some((b) => b.revenue > 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6 space-y-6 overflow-auto">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
description={me.data ? `Добро пожаловать, ${me.data.name}` : 'Общие показатели системы'}
|
description={me.data ? `Добро пожаловать, ${me.data.name}` : 'Сводка по продажам и каталогу'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{anyError && (
|
{/* KPI блок продажи */}
|
||||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-900/50 p-3 text-sm text-amber-800 dark:text-amber-200">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="font-medium">API недоступен или ещё не обновился</div>
|
<KpiCard
|
||||||
<div className="text-amber-700 dark:text-amber-300 text-xs mt-0.5">
|
icon={Banknote}
|
||||||
Перезапусти API после git pull: <code className="font-mono">Ctrl+C → dotnet run --project src/food-market.api</code>
|
label="Выручка сегодня"
|
||||||
|
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenueToday ?? 0)} ₸`}
|
||||||
|
hint={`${stats.data?.transactionsToday ?? 0} чеков`}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={Calendar}
|
||||||
|
label="Выручка за месяц"
|
||||||
|
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenueThisMonth ?? 0)} ₸`}
|
||||||
|
hint={`${stats.data?.transactionsThisMonth ?? 0} чеков`}
|
||||||
|
delta={monthDelta !== null ? { value: monthDelta, positive: monthDelta >= 0 } : undefined}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={Receipt}
|
||||||
|
label="Средний чек"
|
||||||
|
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.avgTicketThisMonth ?? 0)} ₸`}
|
||||||
|
hint="за месяц"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={TrendingUp}
|
||||||
|
label="Прошлый месяц"
|
||||||
|
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenuePrevMonth ?? 0)} ₸`}
|
||||||
|
hint="для сравнения"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
{/* График продаж */}
|
||||||
|
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">Выручка за 30 дней</h2>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">Сумма продаж по дням, проведённые чеки</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{stats.isLoading ? (
|
||||||
|
<div className="h-72 flex items-center justify-center text-slate-400 text-sm">Загрузка…</div>
|
||||||
|
) : !hasAnySales ? (
|
||||||
|
<div className="h-72 flex flex-col items-center justify-center text-slate-400 text-sm gap-2">
|
||||||
|
<Receipt className="w-8 h-8 text-slate-300" />
|
||||||
|
<div>Чеков пока нет.</div>
|
||||||
|
<div className="text-xs">График появится когда появятся первые продажи.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SalesChart series={stats.data!.series} currencyCode="₸" />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Каталог */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3 uppercase tracking-wide">
|
||||||
|
Каталог
|
||||||
|
</h2>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<StatCard icon={Package} label="Товаров" value={products.data} isLoading={products.isLoading} />
|
<MiniCard icon={Package} label="Товаров" value={products.data} isLoading={products.isLoading} />
|
||||||
<StatCard icon={Users} label="Контрагентов" value={counterparties.data} isLoading={counterparties.isLoading} />
|
<MiniCard icon={Users} label="Контрагентов" value={counterparties.data} isLoading={counterparties.isLoading} />
|
||||||
<StatCard icon={Warehouse} label="Складов" value={stores.data} isLoading={stores.isLoading} />
|
<MiniCard icon={Warehouse} label="Складов" value={stores.data} isLoading={stores.isLoading} />
|
||||||
<StatCard icon={Store} label="Точек продаж" value={retailPoints.data} isLoading={retailPoints.isLoading} />
|
<MiniCard icon={Store} label="Точек продаж" value={retailPoints.data} isLoading={retailPoints.isLoading} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{me.data && (
|
|
||||||
<section className="mt-6 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
|
|
||||||
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100 mb-2.5">Текущий пользователь</h2>
|
|
||||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
|
|
||||||
<div><dt className="text-slate-500 inline">Имя: </dt><dd className="inline text-slate-900 dark:text-slate-100 font-medium">{me.data.name}</dd></div>
|
|
||||||
<div><dt className="text-slate-500 inline">Email: </dt><dd className="inline">{me.data.email}</dd></div>
|
|
||||||
<div><dt className="text-slate-500 inline">Роли: </dt><dd className="inline">{me.data.roles.join(', ')}</dd></div>
|
|
||||||
<div><dt className="text-slate-500 inline">Организация: </dt><dd className="inline font-mono text-xs">{me.data.orgId}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className="mt-6 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-6">
|
|
||||||
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100 mb-2">Что дальше</h2>
|
|
||||||
<ul className="text-sm text-slate-600 dark:text-slate-300 space-y-1.5 list-disc list-inside">
|
|
||||||
<li>Phase 2: приёмка товара, розничные продажи, складские остатки</li>
|
|
||||||
<li>Phase 3: инвентаризация, списание, оприходование, возвраты поставщикам</li>
|
|
||||||
<li>Phase 4: ценники, отчёты P&L, бонусная система, аудит</li>
|
|
||||||
<li>Phase 5: Windows-касса + синхронизация + весы</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,9 @@ export function LoginPage() {
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 space-y-5"
|
className="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 space-y-5"
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div>
|
||||||
<Logo size={44} />
|
<Logo />
|
||||||
<p className="text-sm text-slate-500">Вход в систему</p>
|
<p className="text-sm text-slate-500 mt-2.5">Вход в систему</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="block space-y-1.5">
|
<label className="block space-y-1.5">
|
||||||
|
|
|
||||||
175
src/food-market.web/src/pages/MoySkladImportPage.tsx
Normal file
175
src/food-market.web/src/pages/MoySkladImportPage.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
|
||||||
|
import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package } from 'lucide-react'
|
||||||
|
import { AxiosError } from 'axios'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { Button } from '@/components/Button'
|
||||||
|
import { Field, TextInput, Checkbox } from '@/components/Field'
|
||||||
|
|
||||||
|
function formatError(err: unknown): string {
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
const status = err.response?.status
|
||||||
|
const body = err.response?.data as { error?: string; error_description?: string; title?: string } | undefined
|
||||||
|
const detail = body?.error ?? body?.error_description ?? body?.title
|
||||||
|
if (status === 404) {
|
||||||
|
return '404 Not Found — эндпоинт не существует. Вероятно, API не перезапущен после git pull.'
|
||||||
|
}
|
||||||
|
if (status === 401) return '401 Unauthorized — сессия истекла, перелогинься.'
|
||||||
|
if (status === 403) return '403 Forbidden — нужна роль Admin или SuperAdmin.'
|
||||||
|
if (status === 502 || status === 503) return `${status} — МойСклад недоступен, попробуй позже.`
|
||||||
|
return detail ? `${status ?? ''} ${detail}` : err.message
|
||||||
|
}
|
||||||
|
if (err instanceof Error) return err.message
|
||||||
|
return String(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestResponse { organization: string; inn?: string | null }
|
||||||
|
interface ImportResponse {
|
||||||
|
total: number; created: number; skipped: number; groupsCreated: number; errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoySkladImportPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [token, setToken] = useState('')
|
||||||
|
const [overwrite, setOverwrite] = useState(false)
|
||||||
|
|
||||||
|
const test = useMutation({
|
||||||
|
mutationFn: async () => (await api.post<TestResponse>('/api/admin/moysklad/test', { token })).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
const products = useMutation({
|
||||||
|
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-products', { token, overwriteExisting: overwrite })).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const counterparties = useMutation({
|
||||||
|
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-counterparties', { token, overwriteExisting: overwrite })).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/counterparties'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
<div className="p-6 max-w-3xl">
|
||||||
|
<PageHeader
|
||||||
|
title="Импорт из МойСклад"
|
||||||
|
description="Перенос товаров, групп, контрагентов из учётной записи МойСклад в food-market."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="mb-5 rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-900/50 p-3.5 text-sm">
|
||||||
|
<div className="flex gap-2.5 items-start">
|
||||||
|
<AlertCircle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-amber-900 dark:text-amber-200 space-y-1.5">
|
||||||
|
<p><strong>Токен не сохраняется</strong> — передаётся только в текущий запрос и не пишется ни в БД, ни в логи.</p>
|
||||||
|
<p>Получить токен: <a className="underline" href="https://online.moysklad.ru/app/#admin" target="_blank" rel="noreferrer">online.moysklad.ru/app</a> → Настройки аккаунта → Доступ к API → создать токен.</p>
|
||||||
|
<p>Рекомендуется отдельный сервисный аккаунт с правом только на чтение.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
|
||||||
|
<Field label="Токен МойСклад (Bearer)">
|
||||||
|
<TextInput
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
placeholder="персональный токен или токен сервисного аккаунта"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="flex gap-3 items-center flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => test.mutate()}
|
||||||
|
disabled={!token || test.isPending}
|
||||||
|
>
|
||||||
|
<KeyRound className="w-4 h-4" />
|
||||||
|
{test.isPending ? 'Проверяю…' : 'Проверить соединение'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{test.data && (
|
||||||
|
<div className="text-sm text-green-700 dark:text-green-400 flex items-center gap-1.5">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
Подключено: <strong>{test.data.organization}</strong>
|
||||||
|
{test.data.inn && <span className="text-slate-500">(ИНН {test.data.inn})</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{test.error && <div className="text-sm text-red-600">{formatError(test.error)}</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Операции импорта</h2>
|
||||||
|
<Checkbox
|
||||||
|
label="Перезаписать существующие записи (по артикулу/имени)"
|
||||||
|
checked={overwrite}
|
||||||
|
onChange={setOverwrite}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3 flex-wrap">
|
||||||
|
<Button onClick={() => products.mutate()} disabled={!token || products.isPending}>
|
||||||
|
<Package className="w-4 h-4" />
|
||||||
|
{products.isPending ? 'Импортирую товары…' : 'Товары + группы + цены'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => counterparties.mutate()} disabled={!token || counterparties.isPending}>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
{counterparties.isPending ? 'Импортирую контрагентов…' : 'Контрагенты'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ImportResult title="Товары" result={products} />
|
||||||
|
<ImportResult title="Контрагенты" result={counterparties} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportResult({ title, result }: { title: string; result: UseMutationResult<ImportResponse, Error, void, unknown> }) {
|
||||||
|
if (!result.data && !result.error) return null
|
||||||
|
return (
|
||||||
|
<section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
|
||||||
|
<h3 className="text-sm font-semibold mb-3 text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||||
|
{result.data
|
||||||
|
? <><CheckCircle className="w-4 h-4 text-green-600" /> {title} — импорт завершён</>
|
||||||
|
: <><AlertCircle className="w-4 h-4 text-red-600" /> {title} — ошибка</>}
|
||||||
|
</h3>
|
||||||
|
{result.data && (
|
||||||
|
<>
|
||||||
|
<dl className="grid grid-cols-4 gap-3 text-sm">
|
||||||
|
<StatBox label="Всего получено" value={result.data.total} />
|
||||||
|
<StatBox label="Создано" value={result.data.created} accent="green" />
|
||||||
|
<StatBox label="Пропущено" value={result.data.skipped} />
|
||||||
|
<StatBox label="Групп создано" value={result.data.groupsCreated} />
|
||||||
|
</dl>
|
||||||
|
{result.data.errors.length > 0 && (
|
||||||
|
<details className="mt-4">
|
||||||
|
<summary className="text-sm text-red-600 cursor-pointer">
|
||||||
|
Ошибок: {result.data.errors.length} (развернуть)
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-2 text-xs font-mono bg-red-50 dark:bg-red-950/30 p-3 rounded space-y-0.5 max-h-80 overflow-auto">
|
||||||
|
{result.data.errors.map((e, i) => <li key={i}>{e}</li>)}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{result.error && (
|
||||||
|
<div className="text-sm text-red-700 dark:text-red-300">{formatError(result.error)}</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatBox({ label, value, accent }: { label: string; value: number; accent?: 'green' }) {
|
||||||
|
const bg = accent === 'green' ? 'bg-green-50 dark:bg-green-950/30' : 'bg-slate-50 dark:bg-slate-800/50'
|
||||||
|
const fg = accent === 'green' ? 'text-green-700 dark:text-green-400' : ''
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg ${bg} p-3`}>
|
||||||
|
<dt className={`text-xs uppercase ${accent === 'green' ? fg : 'text-slate-500'}`}>{label}</dt>
|
||||||
|
<dd className={`text-xl font-semibold mt-1 ${fg}`}>{value.toLocaleString('ru')}</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
|
|
@ -29,8 +29,8 @@ export function PriceTypesPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<>
|
||||||
<PageHeader
|
<ListPageShell
|
||||||
title="Типы цен"
|
title="Типы цен"
|
||||||
description="Розничная, оптовая и другие ценовые группы."
|
description="Розничная, оптовая и другие ценовые группы."
|
||||||
actions={
|
actions={
|
||||||
|
|
@ -39,8 +39,10 @@ export function PriceTypesPage() {
|
||||||
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
|
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
footer={data && data.total > 0 && (
|
||||||
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={data?.items ?? []}
|
rows={data?.items ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|
@ -54,8 +56,7 @@ export function PriceTypesPage() {
|
||||||
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</ListPageShell>
|
||||||
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={!!form}
|
open={!!form}
|
||||||
|
|
@ -95,6 +96,6 @@ export function PriceTypesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect, type FormEvent } from 'react'
|
import { useState, useEffect, type FormEvent, type ReactNode } from 'react'
|
||||||
import { useNavigate, useParams, Link } from 'react-router-dom'
|
import { useNavigate, useParams, Link } from 'react-router-dom'
|
||||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||||
import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react'
|
import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react'
|
||||||
|
|
@ -93,7 +93,6 @@ export function ProductEditPage() {
|
||||||
}, [isNew, existing.data])
|
}, [isNew, existing.data])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Pre-fill defaults for new product
|
|
||||||
if (isNew && form.vatRateId === '' && vats.data?.length) {
|
if (isNew && form.vatRateId === '' && vats.data?.length) {
|
||||||
setForm((f) => ({ ...f, vatRateId: vats.data?.find(v => v.isDefault)?.id ?? vats.data?.[0]?.id ?? '' }))
|
setForm((f) => ({ ...f, vatRateId: vats.data?.find(v => v.isDefault)?.id ?? vats.data?.[0]?.id ?? '' }))
|
||||||
}
|
}
|
||||||
|
|
@ -169,21 +168,30 @@ export function ProductEditPage() {
|
||||||
const updateBarcode = (i: number, patch: Partial<BarcodeRow>) =>
|
const updateBarcode = (i: number, patch: Partial<BarcodeRow>) =>
|
||||||
setForm({ ...form, barcodes: form.barcodes.map((b, ix) => ix === i ? { ...b, ...patch } : b) })
|
setForm({ ...form, barcodes: form.barcodes.map((b, ix) => ix === i ? { ...b, ...patch } : b) })
|
||||||
|
|
||||||
|
const canSave = form.name.trim().length > 0 && !!form.unitOfMeasureId && !!form.vatRateId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit} className="p-6 max-w-5xl">
|
<form onSubmit={onSubmit} className="flex flex-col h-full">
|
||||||
<div className="flex items-center justify-between gap-4 mb-5">
|
{/* Sticky top bar */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
|
||||||
<Link to="/catalog/products" className="text-slate-400 hover:text-slate-600">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Link
|
||||||
|
to="/catalog/products"
|
||||||
|
className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 flex-shrink-0"
|
||||||
|
title="Назад к списку"
|
||||||
|
>
|
||||||
<ArrowLeft className="w-5 h-5" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
|
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
|
||||||
{isNew ? 'Новый товар' : form.name || 'Товар'}
|
{isNew ? 'Новый товар' : form.name || 'Товар'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-500">Справочник товаров и услуг</p>
|
<p className="text-xs text-slate-500">
|
||||||
|
{isNew ? 'Создание новой позиции каталога' : 'Редактирование'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
{!isNew && (
|
{!isNew && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -194,33 +202,35 @@ export function ProductEditPage() {
|
||||||
<Trash2 className="w-4 h-4" /> Удалить
|
<Trash2 className="w-4 h-4" /> Удалить
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" disabled={!form.name || !form.unitOfMeasureId || !form.vatRateId}>
|
<Button type="submit" disabled={!canSave || save.isPending}>
|
||||||
<Save className="w-4 h-4" /> Сохранить
|
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable body */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<div className="max-w-5xl mx-auto p-6 space-y-5">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
|
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-5">
|
|
||||||
<Section title="Основное">
|
<Section title="Основное">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<Grid cols={3}>
|
||||||
<Field label="Название *" className="col-span-2">
|
<Field label="Название *" className="col-span-2">
|
||||||
<TextInput required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
<TextInput required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Артикул">
|
<Field label="Артикул">
|
||||||
<TextInput value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
|
<TextInput value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
<Field label="Описание" className="col-span-3">
|
||||||
<Field label="Описание">
|
|
||||||
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
</Grid>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Классификация">
|
<Section title="Классификация">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<Grid cols={3}>
|
||||||
<Field label="Единица измерения *">
|
<Field label="Единица измерения *">
|
||||||
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
|
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
|
|
@ -254,8 +264,8 @@ export function ProductEditPage() {
|
||||||
<Field label="URL изображения">
|
<Field label="URL изображения">
|
||||||
<TextInput value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
|
<TextInput value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</Grid>
|
||||||
<div className="grid grid-cols-5 gap-3 pt-1">
|
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2">
|
||||||
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
|
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
|
||||||
<Checkbox label="Весовой" checked={form.isWeighed} onChange={(v) => setForm({ ...form, isWeighed: v })} />
|
<Checkbox label="Весовой" checked={form.isWeighed} onChange={(v) => setForm({ ...form, isWeighed: v })} />
|
||||||
<Checkbox label="Алкоголь" checked={form.isAlcohol} onChange={(v) => setForm({ ...form, isAlcohol: v })} />
|
<Checkbox label="Алкоголь" checked={form.isAlcohol} onChange={(v) => setForm({ ...form, isAlcohol: v })} />
|
||||||
|
|
@ -265,7 +275,7 @@ export function ProductEditPage() {
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Остатки и закупка">
|
<Section title="Остатки и закупка">
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<Grid cols={4}>
|
||||||
<Field label="Мин. остаток">
|
<Field label="Мин. остаток">
|
||||||
<TextInput type="number" step="0.001" value={form.minStock} onChange={(e) => setForm({ ...form, minStock: e.target.value })} />
|
<TextInput type="number" step="0.001" value={form.minStock} onChange={(e) => setForm({ ...form, minStock: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -281,32 +291,38 @@ export function ProductEditPage() {
|
||||||
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
|
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</Grid>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Цены продажи"
|
<Section
|
||||||
|
title="Цены продажи"
|
||||||
action={<Button type="button" variant="secondary" size="sm" onClick={addPrice}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
|
action={<Button type="button" variant="secondary" size="sm" onClick={addPrice}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
|
||||||
>
|
>
|
||||||
{form.prices.length === 0 ? (
|
{form.prices.length === 0 ? (
|
||||||
<div className="text-sm text-slate-400">Цен ещё нет. Добавь хотя бы розничную.</div>
|
<div className="text-sm text-slate-400 py-2">Цен ещё нет. Добавь хотя бы розничную.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{form.prices.map((p, i) => (
|
{form.prices.map((p, i) => (
|
||||||
<div key={i} className="grid grid-cols-[2fr_1fr_1fr_40px] gap-2 items-end">
|
<div key={i} className="grid grid-cols-12 gap-2 items-center">
|
||||||
<Field label={i === 0 ? 'Тип цены' : ''}>
|
<div className="col-span-6">
|
||||||
<Select value={p.priceTypeId} onChange={(e) => updatePrice(i, { priceTypeId: e.target.value })}>
|
<Select value={p.priceTypeId} onChange={(e) => updatePrice(i, { priceTypeId: e.target.value })}>
|
||||||
{priceTypes.data?.map((pt) => <option key={pt.id} value={pt.id}>{pt.name}</option>)}
|
{priceTypes.data?.map((pt) => <option key={pt.id} value={pt.id}>{pt.name}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</div>
|
||||||
<Field label={i === 0 ? 'Сумма' : ''}>
|
<div className="col-span-3">
|
||||||
<TextInput type="number" step="0.01" value={p.amount} onChange={(e) => updatePrice(i, { amount: Number(e.target.value) })} />
|
<TextInput type="number" step="0.01" value={p.amount} onChange={(e) => updatePrice(i, { amount: Number(e.target.value) })} />
|
||||||
</Field>
|
</div>
|
||||||
<Field label={i === 0 ? 'Валюта' : ''}>
|
<div className="col-span-2">
|
||||||
<Select value={p.currencyId} onChange={(e) => updatePrice(i, { currencyId: e.target.value })}>
|
<Select value={p.currencyId} onChange={(e) => updatePrice(i, { currencyId: e.target.value })}>
|
||||||
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
|
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</div>
|
||||||
<button type="button" onClick={() => removePrice(i)} className="text-slate-400 hover:text-red-600 pb-2">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removePrice(i)}
|
||||||
|
className="col-span-1 text-slate-400 hover:text-red-600 flex justify-center"
|
||||||
|
title="Удалить строку"
|
||||||
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -315,19 +331,20 @@ export function ProductEditPage() {
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Штрихкоды"
|
<Section
|
||||||
|
title="Штрихкоды"
|
||||||
action={<Button type="button" variant="secondary" size="sm" onClick={addBarcode}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
|
action={<Button type="button" variant="secondary" size="sm" onClick={addBarcode}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
|
||||||
>
|
>
|
||||||
{form.barcodes.length === 0 ? (
|
{form.barcodes.length === 0 ? (
|
||||||
<div className="text-sm text-slate-400">Штрихкодов нет.</div>
|
<div className="text-sm text-slate-400 py-2">Штрихкодов нет.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{form.barcodes.map((b, i) => (
|
{form.barcodes.map((b, i) => (
|
||||||
<div key={i} className="grid grid-cols-[2fr_1fr_auto_40px] gap-2 items-end">
|
<div key={i} className="grid grid-cols-12 gap-2 items-center">
|
||||||
<Field label={i === 0 ? 'Код' : ''}>
|
<div className="col-span-6">
|
||||||
<TextInput value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} />
|
<TextInput placeholder="Код" value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} />
|
||||||
</Field>
|
</div>
|
||||||
<Field label={i === 0 ? 'Тип' : ''}>
|
<div className="col-span-3">
|
||||||
<Select value={b.type} onChange={(e) => updateBarcode(i, { type: Number(e.target.value) as BarcodeType })}>
|
<Select value={b.type} onChange={(e) => updateBarcode(i, { type: Number(e.target.value) as BarcodeType })}>
|
||||||
<option value={BarcodeType.Ean13}>EAN-13</option>
|
<option value={BarcodeType.Ean13}>EAN-13</option>
|
||||||
<option value={BarcodeType.Ean8}>EAN-8</option>
|
<option value={BarcodeType.Ean8}>EAN-8</option>
|
||||||
|
|
@ -337,14 +354,20 @@ export function ProductEditPage() {
|
||||||
<option value={BarcodeType.Upce}>UPC-E</option>
|
<option value={BarcodeType.Upce}>UPC-E</option>
|
||||||
<option value={BarcodeType.Other}>Прочий</option>
|
<option value={BarcodeType.Other}>Прочий</option>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
|
||||||
<div className="pb-2">
|
|
||||||
<Checkbox label="Основной" checked={b.isPrimary} onChange={(v) => {
|
|
||||||
// Enforce single primary
|
|
||||||
setForm({ ...form, barcodes: form.barcodes.map((x, ix) => ({ ...x, isPrimary: ix === i ? v : false })) })
|
|
||||||
}} />
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => removeBarcode(i)} className="text-slate-400 hover:text-red-600 pb-2">
|
<div className="col-span-2">
|
||||||
|
<Checkbox
|
||||||
|
label="Основной"
|
||||||
|
checked={b.isPrimary}
|
||||||
|
onChange={(v) => setForm({ ...form, barcodes: form.barcodes.map((x, ix) => ({ ...x, isPrimary: ix === i ? v : false })) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeBarcode(i)}
|
||||||
|
className="col-span-1 text-slate-400 hover:text-red-600 flex justify-center"
|
||||||
|
title="Удалить строку"
|
||||||
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -353,18 +376,24 @@ export function ProductEditPage() {
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
function Section({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
|
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<header className="flex items-center justify-between px-5 py-3 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
|
||||||
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
|
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</header>
|
||||||
<div className="space-y-3">{children}</div>
|
<div className="p-5">{children}</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Grid({ cols, children }: { cols: 2 | 3 | 4; children: ReactNode }) {
|
||||||
|
const cls = cols === 2 ? 'grid-cols-1 md:grid-cols-2' : cols === 3 ? 'grid-cols-1 md:grid-cols-3' : 'grid-cols-2 md:grid-cols-4'
|
||||||
|
return <div className={`grid ${cls} gap-x-4 gap-y-3`}>{children}</div>
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
|
|
@ -29,8 +29,8 @@ export function ProductGroupsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<>
|
||||||
<PageHeader
|
<ListPageShell
|
||||||
title="Группы товаров"
|
title="Группы товаров"
|
||||||
description="Иерархический справочник категорий."
|
description="Иерархический справочник категорий."
|
||||||
actions={
|
actions={
|
||||||
|
|
@ -39,8 +39,10 @@ export function ProductGroupsPage() {
|
||||||
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
|
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
footer={data && data.total > 0 && (
|
||||||
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={data?.items ?? []}
|
rows={data?.items ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|
@ -53,8 +55,7 @@ export function ProductGroupsPage() {
|
||||||
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</ListPageShell>
|
||||||
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={!!form}
|
open={!!form}
|
||||||
|
|
@ -103,6 +104,6 @@ export function ProductGroupsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
|
|
@ -15,10 +15,9 @@ export function ProductsPage() {
|
||||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL)
|
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<ListPageShell
|
||||||
<PageHeader
|
|
||||||
title="Товары"
|
title="Товары"
|
||||||
description="Каталог товаров и услуг."
|
description={data ? `${data.total.toLocaleString('ru')} записей в каталоге` : 'Каталог товаров и услуг'}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" />
|
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" />
|
||||||
|
|
@ -29,8 +28,10 @@ export function ProductsPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
footer={data && data.total > 0 && (
|
||||||
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={data?.items ?? []}
|
rows={data?.items ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|
@ -43,10 +44,10 @@ export function ProductsPage() {
|
||||||
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
|
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
|
||||||
</div>
|
</div>
|
||||||
)},
|
)},
|
||||||
{ header: 'Группа', width: '180px', cell: (r) => r.productGroupName ?? '—' },
|
{ header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' },
|
||||||
{ header: 'Ед.', width: '70px', cell: (r) => r.unitSymbol },
|
{ header: 'Ед.', width: '70px', cell: (r) => r.unitSymbol },
|
||||||
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vatPercent}%` },
|
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vatPercent}%` },
|
||||||
{ header: 'Тип', width: '120px', cell: (r) => (
|
{ header: 'Тип', width: '140px', cell: (r) => (
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="flex gap-1 flex-wrap">
|
||||||
{r.isService && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">Услуга</span>}
|
{r.isService && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">Услуга</span>}
|
||||||
{r.isWeighed && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Весовой</span>}
|
{r.isWeighed && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Весовой</span>}
|
||||||
|
|
@ -59,8 +60,6 @@ export function ProductsPage() {
|
||||||
]}
|
]}
|
||||||
empty="Товаров ещё нет. Они появятся после приёмки или через API."
|
empty="Товаров ещё нет. Они появятся после приёмки или через API."
|
||||||
/>
|
/>
|
||||||
|
</ListPageShell>
|
||||||
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
|
|
@ -53,8 +53,8 @@ export function RetailPointsPage() {
|
||||||
const firstStore = stores.data?.[0]?.id ?? ''
|
const firstStore = stores.data?.[0]?.id ?? ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<>
|
||||||
<PageHeader
|
<ListPageShell
|
||||||
title="Точки продаж"
|
title="Точки продаж"
|
||||||
description="Кассовые точки. Привязаны к складу, с которого идут продажи."
|
description="Кассовые точки. Привязаны к складу, с которого идут продажи."
|
||||||
actions={
|
actions={
|
||||||
|
|
@ -65,8 +65,10 @@ export function RetailPointsPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
footer={data && data.total > 0 && (
|
||||||
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={data?.items ?? []}
|
rows={data?.items ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|
@ -86,8 +88,7 @@ export function RetailPointsPage() {
|
||||||
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</ListPageShell>
|
||||||
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={!!form}
|
open={!!form}
|
||||||
|
|
@ -145,6 +146,6 @@ export function RetailPointsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
393
src/food-market.web/src/pages/RetailSaleEditPage.tsx
Normal file
393
src/food-market.web/src/pages/RetailSaleEditPage.tsx
Normal file
|
|
@ -0,0 +1,393 @@
|
||||||
|
import { useState, useEffect, type FormEvent, type ReactNode } from 'react'
|
||||||
|
import { useNavigate, useParams, Link } from 'react-router-dom'
|
||||||
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||||
|
import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { Button } from '@/components/Button'
|
||||||
|
import { Field, TextInput, TextArea, Select } from '@/components/Field'
|
||||||
|
import { ProductPicker } from '@/components/ProductPicker'
|
||||||
|
import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups'
|
||||||
|
import { RetailSaleStatus, PaymentMethod, type RetailSaleDto, type Product } from '@/lib/types'
|
||||||
|
|
||||||
|
interface LineRow {
|
||||||
|
id?: string
|
||||||
|
productId: string
|
||||||
|
productName: string
|
||||||
|
productArticle: string | null
|
||||||
|
unitSymbol: string | null
|
||||||
|
quantity: number
|
||||||
|
unitPrice: number
|
||||||
|
discount: number
|
||||||
|
vatPercent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Form {
|
||||||
|
date: string
|
||||||
|
storeId: string
|
||||||
|
retailPointId: string
|
||||||
|
customerId: string
|
||||||
|
currencyId: string
|
||||||
|
payment: PaymentMethod
|
||||||
|
paidCash: number
|
||||||
|
paidCard: number
|
||||||
|
notes: string
|
||||||
|
lines: LineRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayIso = () => new Date().toISOString().slice(0, 16)
|
||||||
|
|
||||||
|
const empty: Form = {
|
||||||
|
date: todayIso(),
|
||||||
|
storeId: '', retailPointId: '', customerId: '', currencyId: '',
|
||||||
|
payment: PaymentMethod.Cash, paidCash: 0, paidCard: 0,
|
||||||
|
notes: '', lines: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RetailSaleEditPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const isNew = !id || id === 'new'
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const stores = useStores()
|
||||||
|
const currencies = useCurrencies()
|
||||||
|
const customers = useSuppliers() // we re-use the suppliers hook (returns all counterparties) — fine for MVP
|
||||||
|
|
||||||
|
const [form, setForm] = useState<Form>(empty)
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const existing = useQuery({
|
||||||
|
queryKey: ['/api/sales/retail', id],
|
||||||
|
queryFn: async () => (await api.get<RetailSaleDto>(`/api/sales/retail/${id}`)).data,
|
||||||
|
enabled: !isNew,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNew && existing.data) {
|
||||||
|
const s = existing.data
|
||||||
|
setForm({
|
||||||
|
date: s.date.slice(0, 16),
|
||||||
|
storeId: s.storeId,
|
||||||
|
retailPointId: s.retailPointId ?? '',
|
||||||
|
customerId: s.customerId ?? '',
|
||||||
|
currencyId: s.currencyId,
|
||||||
|
payment: s.payment,
|
||||||
|
paidCash: s.paidCash,
|
||||||
|
paidCard: s.paidCard,
|
||||||
|
notes: s.notes ?? '',
|
||||||
|
lines: s.lines.map((l) => ({
|
||||||
|
id: l.id ?? undefined,
|
||||||
|
productId: l.productId,
|
||||||
|
productName: l.productName ?? '',
|
||||||
|
productArticle: l.productArticle,
|
||||||
|
unitSymbol: l.unitSymbol,
|
||||||
|
quantity: l.quantity, unitPrice: l.unitPrice, discount: l.discount,
|
||||||
|
vatPercent: l.vatPercent,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [isNew, existing.data])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew) {
|
||||||
|
if (!form.storeId && stores.data?.length) {
|
||||||
|
setForm((f) => ({ ...f, storeId: stores.data!.find((s) => s.isMain)?.id ?? stores.data![0].id }))
|
||||||
|
}
|
||||||
|
if (!form.currencyId && currencies.data?.length) {
|
||||||
|
setForm((f) => ({ ...f, currencyId: currencies.data!.find((c) => c.code === 'KZT')?.id ?? currencies.data![0].id }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isNew, stores.data, currencies.data, form.storeId, form.currencyId])
|
||||||
|
|
||||||
|
const isDraft = isNew || existing.data?.status === RetailSaleStatus.Draft
|
||||||
|
const isPosted = existing.data?.status === RetailSaleStatus.Posted
|
||||||
|
|
||||||
|
const lineTotal = (l: LineRow) => l.quantity * l.unitPrice - l.discount
|
||||||
|
const subtotal = form.lines.reduce((s, l) => s + l.quantity * l.unitPrice, 0)
|
||||||
|
const discountTotal = form.lines.reduce((s, l) => s + l.discount, 0)
|
||||||
|
const grandTotal = subtotal - discountTotal
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const payload = {
|
||||||
|
date: new Date(form.date).toISOString(),
|
||||||
|
storeId: form.storeId,
|
||||||
|
retailPointId: form.retailPointId || null,
|
||||||
|
customerId: form.customerId || null,
|
||||||
|
currencyId: form.currencyId,
|
||||||
|
payment: form.payment,
|
||||||
|
paidCash: Number(form.paidCash),
|
||||||
|
paidCard: Number(form.paidCard),
|
||||||
|
notes: form.notes || null,
|
||||||
|
lines: form.lines.map((l) => ({
|
||||||
|
productId: l.productId,
|
||||||
|
quantity: l.quantity,
|
||||||
|
unitPrice: l.unitPrice,
|
||||||
|
discount: l.discount,
|
||||||
|
vatPercent: l.vatPercent,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
if (isNew) {
|
||||||
|
return (await api.post<RetailSaleDto>('/api/sales/retail', payload)).data
|
||||||
|
}
|
||||||
|
await api.put(`/api/sales/retail/${id}`, payload)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
onSuccess: (created) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/sales/retail'] })
|
||||||
|
navigate(created ? `/sales/retail/${created.id}` : `/sales/retail/${id}`)
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const post = useMutation({
|
||||||
|
mutationFn: async () => { await api.post(`/api/sales/retail/${id}/post`) },
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/sales/retail'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
|
||||||
|
existing.refetch()
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const unpost = useMutation({
|
||||||
|
mutationFn: async () => { await api.post(`/api/sales/retail/${id}/unpost`) },
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/sales/retail'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
|
||||||
|
existing.refetch()
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: async () => { await api.delete(`/api/sales/retail/${id}`) },
|
||||||
|
onSuccess: () => navigate('/sales/retail'),
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }
|
||||||
|
|
||||||
|
const addLineFromProduct = (p: Product) => {
|
||||||
|
const retail = p.prices.find((x) => x.priceTypeName?.toLowerCase().includes('розн')) ?? p.prices[0]
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
lines: [...form.lines, {
|
||||||
|
productId: p.id,
|
||||||
|
productName: p.name,
|
||||||
|
productArticle: p.article,
|
||||||
|
unitSymbol: p.unitSymbol,
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: retail?.amount ?? 0,
|
||||||
|
discount: 0,
|
||||||
|
vatPercent: p.vatPercent,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const updateLine = (i: number, patch: Partial<LineRow>) =>
|
||||||
|
setForm({ ...form, lines: form.lines.map((l, ix) => ix === i ? { ...l, ...patch } : l) })
|
||||||
|
const removeLine = (i: number) =>
|
||||||
|
setForm({ ...form, lines: form.lines.filter((_, ix) => ix !== i) })
|
||||||
|
|
||||||
|
const canSave = !!form.storeId && !!form.currencyId && isDraft
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Link to="/sales/retail" className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 flex-shrink-0">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
|
||||||
|
{isNew ? 'Новый чек' : existing.data?.number ?? 'Чек'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{isPosted
|
||||||
|
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
|
||||||
|
: 'Черновик — товар не списывается со склада до проведения'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
|
{isPosted && (
|
||||||
|
<Button type="button" variant="secondary" onClick={() => unpost.mutate()} disabled={unpost.isPending}>
|
||||||
|
<Undo2 className="w-4 h-4" /> Отменить
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isDraft && !isNew && (
|
||||||
|
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик?')) remove.mutate() }}>
|
||||||
|
<Trash2 className="w-4 h-4" /> Удалить
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isDraft && (
|
||||||
|
<Button type="submit" variant="secondary" disabled={!canSave || save.isPending}>
|
||||||
|
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isDraft && !isNew && (
|
||||||
|
<Button type="button" onClick={() => post.mutate()} disabled={post.isPending || form.lines.length === 0}>
|
||||||
|
<CheckCircle className="w-4 h-4" /> {post.isPending ? 'Провожу…' : 'Провести'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<div className="max-w-6xl mx-auto p-6 space-y-5">
|
||||||
|
{error && <div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>}
|
||||||
|
|
||||||
|
<Section title="Реквизиты чека">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
|
||||||
|
<Field label="Дата/время">
|
||||||
|
<TextInput type="datetime-local" value={form.date} disabled={isPosted}
|
||||||
|
onChange={(e) => setForm({ ...form, date: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Магазин *">
|
||||||
|
<Select value={form.storeId} disabled={isPosted}
|
||||||
|
onChange={(e) => setForm({ ...form, storeId: e.target.value })}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Валюта *">
|
||||||
|
<Select value={form.currencyId} disabled={isPosted}
|
||||||
|
onChange={(e) => setForm({ ...form, currencyId: e.target.value })}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Покупатель (опц.)">
|
||||||
|
<Select value={form.customerId} disabled={isPosted}
|
||||||
|
onChange={(e) => setForm({ ...form, customerId: e.target.value })}>
|
||||||
|
<option value="">— анонимный —</option>
|
||||||
|
{customers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Способ оплаты">
|
||||||
|
<Select value={form.payment} disabled={isPosted}
|
||||||
|
onChange={(e) => setForm({ ...form, payment: Number(e.target.value) as PaymentMethod })}>
|
||||||
|
<option value={PaymentMethod.Cash}>Наличные</option>
|
||||||
|
<option value={PaymentMethod.Card}>Карта</option>
|
||||||
|
<option value={PaymentMethod.BankTransfer}>Банковский перевод</option>
|
||||||
|
<option value={PaymentMethod.Bonus}>Бонусы</option>
|
||||||
|
<option value={PaymentMethod.Mixed}>Смешанная</option>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Получено наличными">
|
||||||
|
<TextInput type="number" step="0.01" value={form.paidCash} disabled={isPosted}
|
||||||
|
onChange={(e) => setForm({ ...form, paidCash: Number(e.target.value) })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Получено картой">
|
||||||
|
<TextInput type="number" step="0.01" value={form.paidCard} disabled={isPosted}
|
||||||
|
onChange={(e) => setForm({ ...form, paidCard: Number(e.target.value) })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Примечание" className="md:col-span-3">
|
||||||
|
<TextArea rows={2} value={form.notes} disabled={isPosted}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="Позиции"
|
||||||
|
action={!isPosted && (
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={() => setPickerOpen(true)}>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Добавить товар
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{form.lines.length === 0 ? (
|
||||||
|
<div className="text-sm text-slate-400 py-4 text-center">Пусто. Добавь хотя бы одну позицию.</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 dark:border-slate-700 text-left">
|
||||||
|
<th className="py-2 pr-3 font-medium text-xs uppercase tracking-wide text-slate-500">Товар</th>
|
||||||
|
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[80px]">Ед.</th>
|
||||||
|
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[120px] text-right">Кол-во</th>
|
||||||
|
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[120px] text-right">Цена</th>
|
||||||
|
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[110px] text-right">Скидка</th>
|
||||||
|
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[140px] text-right">Сумма</th>
|
||||||
|
<th className="py-2 pl-3 w-[40px]"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{form.lines.map((l, i) => (
|
||||||
|
<tr key={i} className="border-b border-slate-100 dark:border-slate-800">
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<div className="font-medium">{l.productName}</div>
|
||||||
|
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-slate-500">{l.unitSymbol}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<TextInput type="number" step="0.001" disabled={isPosted}
|
||||||
|
className="text-right font-mono" value={l.quantity}
|
||||||
|
onChange={(e) => updateLine(i, { quantity: Number(e.target.value) })} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<TextInput type="number" step="0.01" disabled={isPosted}
|
||||||
|
className="text-right font-mono" value={l.unitPrice}
|
||||||
|
onChange={(e) => updateLine(i, { unitPrice: Number(e.target.value) })} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<TextInput type="number" step="0.01" disabled={isPosted}
|
||||||
|
className="text-right font-mono" value={l.discount}
|
||||||
|
onChange={(e) => updateLine(i, { discount: Number(e.target.value) })} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-right font-mono font-semibold">
|
||||||
|
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pl-3">
|
||||||
|
{!isPosted && (
|
||||||
|
<button type="button" onClick={() => removeLine(i)} className="text-slate-400 hover:text-red-600">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr><td colSpan={4} className="py-2 pr-3 text-right text-sm text-slate-500">Подытог:</td>
|
||||||
|
<td className="py-2 px-3 text-right text-sm text-slate-500"></td>
|
||||||
|
<td className="py-2 px-3 text-right font-mono">{subtotal.toLocaleString('ru', { maximumFractionDigits: 2 })}</td>
|
||||||
|
<td/></tr>
|
||||||
|
<tr><td colSpan={4} className="py-1 pr-3 text-right text-sm text-slate-500">Скидка:</td>
|
||||||
|
<td className="py-1 px-3"></td>
|
||||||
|
<td className="py-1 px-3 text-right font-mono text-red-600">−{discountTotal.toLocaleString('ru', { maximumFractionDigits: 2 })}</td>
|
||||||
|
<td/></tr>
|
||||||
|
<tr><td colSpan={4} className="py-3 pr-3 text-right text-sm font-semibold text-slate-700 dark:text-slate-200">К оплате:</td>
|
||||||
|
<td/>
|
||||||
|
<td className="py-3 px-3 text-right font-mono text-lg font-bold">
|
||||||
|
{grandTotal.toLocaleString('ru', { maximumFractionDigits: 2 })}{' '}
|
||||||
|
<span className="text-sm text-slate-500">{currencies.data?.find((c) => c.id === form.currencyId)?.code ?? ''}</span>
|
||||||
|
</td><td/></tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProductPicker open={pickerOpen} onClose={() => setPickerOpen(false)} onPick={addLineFromProduct} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||||
|
<header className="flex items-center justify-between px-5 py-3 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
|
||||||
|
{action}
|
||||||
|
</header>
|
||||||
|
<div className="p-5">{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
src/food-market.web/src/pages/RetailSalesPage.tsx
Normal file
65
src/food-market.web/src/pages/RetailSalesPage.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
|
import { DataTable } from '@/components/DataTable'
|
||||||
|
import { Pagination } from '@/components/Pagination'
|
||||||
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
|
import { Button } from '@/components/Button'
|
||||||
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
|
import { type RetailSaleListRow, RetailSaleStatus, PaymentMethod } from '@/lib/types'
|
||||||
|
|
||||||
|
const URL = '/api/sales/retail'
|
||||||
|
|
||||||
|
const paymentLabel: Record<number, string> = {
|
||||||
|
[PaymentMethod.Cash]: 'Наличные',
|
||||||
|
[PaymentMethod.Card]: 'Карта',
|
||||||
|
[PaymentMethod.BankTransfer]: 'Перевод',
|
||||||
|
[PaymentMethod.Bonus]: 'Бонусы',
|
||||||
|
[PaymentMethod.Mixed]: 'Смешанная',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RetailSalesPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<RetailSaleListRow>(URL)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListPageShell
|
||||||
|
title="Розничные продажи"
|
||||||
|
description={data ? `${data.total.toLocaleString('ru')} чеков` : 'Чеки розничных продаж'}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<SearchBar value={search} onChange={setSearch} placeholder="По номеру чека…" />
|
||||||
|
<Link to="/sales/retail/new">
|
||||||
|
<Button><Plus className="w-4 h-4" /> Новый чек</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
footer={data && data.total > 0 && (
|
||||||
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DataTable
|
||||||
|
rows={data?.items ?? []}
|
||||||
|
isLoading={isLoading}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
onRowClick={(r) => navigate(`/sales/retail/${r.id}`)}
|
||||||
|
columns={[
|
||||||
|
{ header: '№', width: '160px', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
|
||||||
|
{ header: 'Дата/время', width: '160px', cell: (r) => new Date(r.date).toLocaleString('ru') },
|
||||||
|
{ header: 'Статус', width: '120px', cell: (r) => (
|
||||||
|
r.status === RetailSaleStatus.Posted
|
||||||
|
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
|
||||||
|
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
|
||||||
|
)},
|
||||||
|
{ header: 'Магазин', cell: (r) => r.storeName },
|
||||||
|
{ header: 'Касса', width: '160px', cell: (r) => r.retailPointName ?? '—' },
|
||||||
|
{ header: 'Покупатель', width: '180px', cell: (r) => r.customerName ?? <span className="text-slate-400">аноним</span> },
|
||||||
|
{ header: 'Оплата', width: '120px', cell: (r) => paymentLabel[r.payment] ?? '?' },
|
||||||
|
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount },
|
||||||
|
{ header: 'Сумма', width: '160px', className: 'text-right font-mono font-semibold', cell: (r) => `${r.total.toLocaleString('ru', { maximumFractionDigits: 2 })} ${r.currencyCode}` },
|
||||||
|
]}
|
||||||
|
empty="Чеков пока нет. На Phase 5 здесь начнут падать продажи с касс. Сейчас можно создать вручную для теста."
|
||||||
|
/>
|
||||||
|
</ListPageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
src/food-market.web/src/pages/StockMovementsPage.tsx
Normal file
81
src/food-market.web/src/pages/StockMovementsPage.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
|
import { DataTable } from '@/components/DataTable'
|
||||||
|
import { Pagination } from '@/components/Pagination'
|
||||||
|
import { Select } from '@/components/Field'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { useStores } from '@/lib/useLookups'
|
||||||
|
import type { PagedResult, MovementRow } from '@/lib/types'
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
Initial: 'Начальный',
|
||||||
|
Supply: 'Приёмка',
|
||||||
|
RetailSale: 'Розн. продажа',
|
||||||
|
WholesaleSale: 'Опт. продажа',
|
||||||
|
CustomerReturn: 'Возврат покуп.',
|
||||||
|
SupplierReturn: 'Возврат пост.',
|
||||||
|
TransferOut: 'Перемещ. из',
|
||||||
|
TransferIn: 'Перемещ. в',
|
||||||
|
WriteOff: 'Списание',
|
||||||
|
Enter: 'Оприходование',
|
||||||
|
InventoryAdjustment: 'Инвентаризация',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StockMovementsPage() {
|
||||||
|
const stores = useStores()
|
||||||
|
const [storeId, setStoreId] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['/api/inventory/movements', { storeId, page }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams({ page: String(page), pageSize: '50' })
|
||||||
|
if (storeId) params.set('storeId', storeId)
|
||||||
|
return (await api.get<PagedResult<MovementRow>>(`/api/inventory/movements?${params}`)).data
|
||||||
|
},
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListPageShell
|
||||||
|
title="Движения"
|
||||||
|
description={data ? `${data.total.toLocaleString('ru')} операций в журнале` : 'Журнал всех изменений остатков'}
|
||||||
|
actions={
|
||||||
|
<div className="w-52">
|
||||||
|
<Select value={storeId} onChange={(e) => { setStoreId(e.target.value); setPage(1) }}>
|
||||||
|
<option value="">Все склады</option>
|
||||||
|
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
footer={data && data.total > 0 && (
|
||||||
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DataTable
|
||||||
|
rows={data?.items ?? []}
|
||||||
|
isLoading={isLoading}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
columns={[
|
||||||
|
{ header: 'Дата', width: '160px', cell: (r) => new Date(r.occurredAt).toLocaleString('ru') },
|
||||||
|
{ header: 'Операция', width: '160px', cell: (r) => typeLabels[r.type] ?? r.type },
|
||||||
|
{ header: 'Товар', cell: (r) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{r.productName}</div>
|
||||||
|
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
|
||||||
|
</div>
|
||||||
|
)},
|
||||||
|
{ header: 'Склад', width: '180px', cell: (r) => r.storeName },
|
||||||
|
{ header: 'Количество', width: '140px', className: 'text-right font-mono', cell: (r) => (
|
||||||
|
<span className={r.quantity > 0 ? 'text-green-700' : r.quantity < 0 ? 'text-red-600' : ''}>
|
||||||
|
{r.quantity > 0 ? '+' : ''}{r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 })}
|
||||||
|
</span>
|
||||||
|
)},
|
||||||
|
{ header: 'Документ', width: '200px', cell: (r) => r.documentNumber ? <span className="text-slate-500">{r.documentType} · {r.documentNumber}</span> : <span className="text-slate-400">—</span> },
|
||||||
|
]}
|
||||||
|
empty="Движений ещё нет."
|
||||||
|
/>
|
||||||
|
</ListPageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
src/food-market.web/src/pages/StockPage.tsx
Normal file
76
src/food-market.web/src/pages/StockPage.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
|
import { DataTable } from '@/components/DataTable'
|
||||||
|
import { Pagination } from '@/components/Pagination'
|
||||||
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
|
import { Select, Checkbox } from '@/components/Field'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { useStores } from '@/lib/useLookups'
|
||||||
|
import type { PagedResult, StockRow } from '@/lib/types'
|
||||||
|
|
||||||
|
export function StockPage() {
|
||||||
|
const stores = useStores()
|
||||||
|
const [storeId, setStoreId] = useState('')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [includeZero, setIncludeZero] = useState(false)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['/api/inventory/stock', { storeId, search, includeZero, page }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams({ page: String(page), pageSize: '50' })
|
||||||
|
if (storeId) params.set('storeId', storeId)
|
||||||
|
if (search) params.set('search', search)
|
||||||
|
if (includeZero) params.set('includeZero', 'true')
|
||||||
|
return (await api.get<PagedResult<StockRow>>(`/api/inventory/stock?${params}`)).data
|
||||||
|
},
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListPageShell
|
||||||
|
title="Остатки"
|
||||||
|
description={data ? `${data.total.toLocaleString('ru')} позиций${storeId ? ' на выбранном складе' : ' по всем складам'}` : 'Текущие остатки по складам'}
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-52">
|
||||||
|
<Select value={storeId} onChange={(e) => { setStoreId(e.target.value); setPage(1) }}>
|
||||||
|
<option value="">Все склады</option>
|
||||||
|
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Checkbox label="Показать нулевые" checked={includeZero} onChange={(v) => { setIncludeZero(v); setPage(1) }} />
|
||||||
|
<SearchBar value={search} onChange={(v) => { setSearch(v); setPage(1) }} placeholder="По названию или артикулу…" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
footer={data && data.total > 0 && (
|
||||||
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DataTable
|
||||||
|
rows={data?.items ?? []}
|
||||||
|
isLoading={isLoading}
|
||||||
|
rowKey={(r) => `${r.productId}:${r.storeId}`}
|
||||||
|
columns={[
|
||||||
|
{ header: 'Товар', cell: (r) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{r.productName}</div>
|
||||||
|
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
|
||||||
|
</div>
|
||||||
|
)},
|
||||||
|
{ header: 'Склад', width: '220px', cell: (r) => r.storeName },
|
||||||
|
{ header: 'Ед.', width: '80px', cell: (r) => r.unitSymbol },
|
||||||
|
{ header: 'Остаток', width: '130px', className: 'text-right font-mono', cell: (r) => r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 }) },
|
||||||
|
{ header: 'Резерв', width: '110px', className: 'text-right font-mono text-slate-400', cell: (r) => r.reservedQuantity ? r.reservedQuantity.toLocaleString('ru') : '—' },
|
||||||
|
{ header: 'Доступно', width: '130px', className: 'text-right font-mono font-semibold', cell: (r) => (
|
||||||
|
<span className={r.available < 0 ? 'text-red-600' : r.available === 0 ? 'text-slate-400' : ''}>
|
||||||
|
{r.available.toLocaleString('ru', { maximumFractionDigits: 3 })}
|
||||||
|
</span>
|
||||||
|
)},
|
||||||
|
]}
|
||||||
|
empty="Остатков нет. Они появятся после первой приёмки (Phase 2b)."
|
||||||
|
/>
|
||||||
|
</ListPageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
|
|
@ -43,8 +43,8 @@ export function StoresPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<>
|
||||||
<PageHeader
|
<ListPageShell
|
||||||
title="Склады"
|
title="Склады"
|
||||||
description="Физические места хранения товара."
|
description="Физические места хранения товара."
|
||||||
actions={
|
actions={
|
||||||
|
|
@ -53,8 +53,10 @@ export function StoresPage() {
|
||||||
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
|
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
footer={data && data.total > 0 && (
|
||||||
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={data?.items ?? []}
|
rows={data?.items ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|
@ -73,8 +75,7 @@ export function StoresPage() {
|
||||||
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</ListPageShell>
|
||||||
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={!!form}
|
open={!!form}
|
||||||
|
|
@ -129,6 +130,6 @@ export function StoresPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
55
src/food-market.web/src/pages/SuppliesPage.tsx
Normal file
55
src/food-market.web/src/pages/SuppliesPage.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
|
import { DataTable } from '@/components/DataTable'
|
||||||
|
import { Pagination } from '@/components/Pagination'
|
||||||
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
|
import { Button } from '@/components/Button'
|
||||||
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
|
import { type SupplyListRow, SupplyStatus } from '@/lib/types'
|
||||||
|
|
||||||
|
const URL = '/api/purchases/supplies'
|
||||||
|
|
||||||
|
export function SuppliesPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<SupplyListRow>(URL)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListPageShell
|
||||||
|
title="Приёмки от поставщиков"
|
||||||
|
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Документы поступления товара от поставщиков'}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<SearchBar value={search} onChange={setSearch} placeholder="По номеру или поставщику…" />
|
||||||
|
<Link to="/purchases/supplies/new">
|
||||||
|
<Button><Plus className="w-4 h-4" /> Новая приёмка</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
footer={data && data.total > 0 && (
|
||||||
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DataTable
|
||||||
|
rows={data?.items ?? []}
|
||||||
|
isLoading={isLoading}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
onRowClick={(r) => navigate(`/purchases/supplies/${r.id}`)}
|
||||||
|
columns={[
|
||||||
|
{ header: '№', width: '160px', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
|
||||||
|
{ header: 'Дата', width: '120px', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||||
|
{ header: 'Статус', width: '130px', cell: (r) => (
|
||||||
|
r.status === SupplyStatus.Posted
|
||||||
|
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
|
||||||
|
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
|
||||||
|
)},
|
||||||
|
{ header: 'Поставщик', cell: (r) => r.supplierName },
|
||||||
|
{ header: 'Склад', width: '180px', cell: (r) => r.storeName },
|
||||||
|
{ header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount },
|
||||||
|
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', cell: (r) => `${r.total.toLocaleString('ru', { maximumFractionDigits: 2 })} ${r.currencyCode}` },
|
||||||
|
]}
|
||||||
|
empty="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения."
|
||||||
|
/>
|
||||||
|
</ListPageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
370
src/food-market.web/src/pages/SupplyEditPage.tsx
Normal file
370
src/food-market.web/src/pages/SupplyEditPage.tsx
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
import { useState, useEffect, type FormEvent, type ReactNode } from 'react'
|
||||||
|
import { useNavigate, useParams, Link } from 'react-router-dom'
|
||||||
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||||
|
import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { Button } from '@/components/Button'
|
||||||
|
import { Field, TextInput, TextArea, Select } from '@/components/Field'
|
||||||
|
import { ProductPicker } from '@/components/ProductPicker'
|
||||||
|
import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups'
|
||||||
|
import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types'
|
||||||
|
|
||||||
|
interface LineRow {
|
||||||
|
id?: string
|
||||||
|
productId: string
|
||||||
|
productName: string
|
||||||
|
productArticle: string | null
|
||||||
|
unitSymbol: string | null
|
||||||
|
quantity: number
|
||||||
|
unitPrice: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Form {
|
||||||
|
date: string
|
||||||
|
supplierId: string
|
||||||
|
storeId: string
|
||||||
|
currencyId: string
|
||||||
|
supplierInvoiceNumber: string
|
||||||
|
supplierInvoiceDate: string
|
||||||
|
notes: string
|
||||||
|
lines: LineRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayIso = () => new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
const emptyForm: Form = {
|
||||||
|
date: todayIso(),
|
||||||
|
supplierId: '', storeId: '', currencyId: '',
|
||||||
|
supplierInvoiceNumber: '', supplierInvoiceDate: '',
|
||||||
|
notes: '',
|
||||||
|
lines: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupplyEditPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const isNew = !id || id === 'new'
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const stores = useStores()
|
||||||
|
const currencies = useCurrencies()
|
||||||
|
const suppliers = useSuppliers()
|
||||||
|
|
||||||
|
const [form, setForm] = useState<Form>(emptyForm)
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const existing = useQuery({
|
||||||
|
queryKey: ['/api/purchases/supplies', id],
|
||||||
|
queryFn: async () => (await api.get<SupplyDto>(`/api/purchases/supplies/${id}`)).data,
|
||||||
|
enabled: !isNew,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNew && existing.data) {
|
||||||
|
const s = existing.data
|
||||||
|
setForm({
|
||||||
|
date: s.date.slice(0, 10),
|
||||||
|
supplierId: s.supplierId,
|
||||||
|
storeId: s.storeId,
|
||||||
|
currencyId: s.currencyId,
|
||||||
|
supplierInvoiceNumber: s.supplierInvoiceNumber ?? '',
|
||||||
|
supplierInvoiceDate: s.supplierInvoiceDate ? s.supplierInvoiceDate.slice(0, 10) : '',
|
||||||
|
notes: s.notes ?? '',
|
||||||
|
lines: s.lines.map((l) => ({
|
||||||
|
id: l.id ?? undefined,
|
||||||
|
productId: l.productId,
|
||||||
|
productName: l.productName ?? '',
|
||||||
|
productArticle: l.productArticle,
|
||||||
|
unitSymbol: l.unitSymbol,
|
||||||
|
quantity: l.quantity,
|
||||||
|
unitPrice: l.unitPrice,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [isNew, existing.data])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Prefill defaults for new document.
|
||||||
|
if (isNew) {
|
||||||
|
if (!form.storeId && stores.data?.length) {
|
||||||
|
const main = stores.data.find((s) => s.isMain) ?? stores.data[0]
|
||||||
|
setForm((f) => ({ ...f, storeId: main.id }))
|
||||||
|
}
|
||||||
|
if (!form.currencyId && currencies.data?.length) {
|
||||||
|
const kzt = currencies.data.find((c) => c.code === 'KZT') ?? currencies.data[0]
|
||||||
|
setForm((f) => ({ ...f, currencyId: kzt.id }))
|
||||||
|
}
|
||||||
|
if (!form.supplierId && suppliers.data?.length) {
|
||||||
|
setForm((f) => ({ ...f, supplierId: suppliers.data![0].id }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isNew, stores.data, currencies.data, suppliers.data, form.storeId, form.currencyId, form.supplierId])
|
||||||
|
|
||||||
|
const isDraft = isNew || existing.data?.status === SupplyStatus.Draft
|
||||||
|
const isPosted = existing.data?.status === SupplyStatus.Posted
|
||||||
|
|
||||||
|
const lineTotal = (l: LineRow) => l.quantity * l.unitPrice
|
||||||
|
const grandTotal = form.lines.reduce((s, l) => s + lineTotal(l), 0)
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const payload = {
|
||||||
|
date: new Date(form.date).toISOString(),
|
||||||
|
supplierId: form.supplierId,
|
||||||
|
storeId: form.storeId,
|
||||||
|
currencyId: form.currencyId,
|
||||||
|
supplierInvoiceNumber: form.supplierInvoiceNumber || null,
|
||||||
|
supplierInvoiceDate: form.supplierInvoiceDate ? new Date(form.supplierInvoiceDate).toISOString() : null,
|
||||||
|
notes: form.notes || null,
|
||||||
|
lines: form.lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.unitPrice })),
|
||||||
|
}
|
||||||
|
if (isNew) {
|
||||||
|
return (await api.post<SupplyDto>('/api/purchases/supplies', payload)).data
|
||||||
|
}
|
||||||
|
await api.put(`/api/purchases/supplies/${id}`, payload)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
onSuccess: (created) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] })
|
||||||
|
navigate(created ? `/purchases/supplies/${created.id}` : `/purchases/supplies/${id}`)
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const post = useMutation({
|
||||||
|
mutationFn: async () => { await api.post(`/api/purchases/supplies/${id}/post`) },
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
|
||||||
|
existing.refetch()
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const unpost = useMutation({
|
||||||
|
mutationFn: async () => { await api.post(`/api/purchases/supplies/${id}/unpost`) },
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
|
||||||
|
existing.refetch()
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: async () => { await api.delete(`/api/purchases/supplies/${id}`) },
|
||||||
|
onSuccess: () => navigate('/purchases/supplies'),
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }
|
||||||
|
|
||||||
|
const addLineFromProduct = (p: Product) => {
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
lines: [...form.lines, {
|
||||||
|
productId: p.id,
|
||||||
|
productName: p.name,
|
||||||
|
productArticle: p.article,
|
||||||
|
unitSymbol: p.unitSymbol,
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: p.purchasePrice ?? 0,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const updateLine = (i: number, patch: Partial<LineRow>) =>
|
||||||
|
setForm({ ...form, lines: form.lines.map((l, ix) => ix === i ? { ...l, ...patch } : l) })
|
||||||
|
const removeLine = (i: number) =>
|
||||||
|
setForm({ ...form, lines: form.lines.filter((_, ix) => ix !== i) })
|
||||||
|
|
||||||
|
const canSave = !!form.supplierId && !!form.storeId && !!form.currencyId && isDraft
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col h-full">
|
||||||
|
{/* Sticky top bar */}
|
||||||
|
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Link to="/purchases/supplies" className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 flex-shrink-0">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
|
||||||
|
{isNew ? 'Новая приёмка' : existing.data?.number ?? 'Приёмка'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{isPosted
|
||||||
|
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
|
||||||
|
: 'Черновик — товар не попадает на склад, пока не проведёшь'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
|
{isPosted && (
|
||||||
|
<Button type="button" variant="secondary" onClick={() => unpost.mutate()} disabled={unpost.isPending}>
|
||||||
|
<Undo2 className="w-4 h-4" /> Отменить проведение
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isDraft && !isNew && (
|
||||||
|
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик приёмки?')) remove.mutate() }}>
|
||||||
|
<Trash2 className="w-4 h-4" /> Удалить
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isDraft && (
|
||||||
|
<Button type="submit" variant="secondary" disabled={!canSave || save.isPending}>
|
||||||
|
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isDraft && !isNew && (
|
||||||
|
<Button type="button" onClick={() => post.mutate()} disabled={post.isPending || form.lines.length === 0}>
|
||||||
|
<CheckCircle className="w-4 h-4" /> {post.isPending ? 'Провожу…' : 'Провести'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable body */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<div className="max-w-6xl mx-auto p-6 space-y-5">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="Реквизиты документа">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
|
||||||
|
<Field label="Дата">
|
||||||
|
<TextInput type="date" value={form.date} disabled={isPosted}
|
||||||
|
onChange={(e) => setForm({ ...form, date: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Поставщик *">
|
||||||
|
<Select value={form.supplierId} disabled={isPosted}
|
||||||
|
onChange={(e) => setForm({ ...form, supplierId: e.target.value })}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Склад *">
|
||||||
|
<Select value={form.storeId} disabled={isPosted}
|
||||||
|
onChange={(e) => setForm({ ...form, storeId: e.target.value })}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Валюта *">
|
||||||
|
<Select value={form.currencyId} disabled={isPosted}
|
||||||
|
onChange={(e) => setForm({ ...form, currencyId: e.target.value })}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="№ накладной поставщика">
|
||||||
|
<TextInput value={form.supplierInvoiceNumber} disabled={isPosted}
|
||||||
|
onChange={(e) => setForm({ ...form, supplierInvoiceNumber: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Дата накладной">
|
||||||
|
<TextInput type="date" value={form.supplierInvoiceDate} disabled={isPosted}
|
||||||
|
onChange={(e) => setForm({ ...form, supplierInvoiceDate: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Примечание" className="md:col-span-3">
|
||||||
|
<TextArea rows={2} value={form.notes} disabled={isPosted}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="Позиции"
|
||||||
|
action={!isPosted && (
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={() => setPickerOpen(true)}>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Добавить товар
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{form.lines.length === 0 ? (
|
||||||
|
<div className="text-sm text-slate-400 py-4 text-center">Позиций нет. Нажми «Добавить товар».</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-left">
|
||||||
|
<tr className="border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<th className="py-2 pr-3 font-medium text-xs uppercase tracking-wide text-slate-500">Товар</th>
|
||||||
|
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[90px]">Ед.</th>
|
||||||
|
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[140px] text-right">Количество</th>
|
||||||
|
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[140px] text-right">Цена</th>
|
||||||
|
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[160px] text-right">Сумма</th>
|
||||||
|
<th className="py-2 pl-3 w-[40px]"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{form.lines.map((l, i) => (
|
||||||
|
<tr key={i} className="border-b border-slate-100 dark:border-slate-800">
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<div className="font-medium">{l.productName}</div>
|
||||||
|
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-slate-500">{l.unitSymbol}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<TextInput type="number" step="0.001" disabled={isPosted}
|
||||||
|
className="text-right font-mono"
|
||||||
|
value={l.quantity}
|
||||||
|
onChange={(e) => updateLine(i, { quantity: Number(e.target.value) })} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<TextInput type="number" step="0.01" disabled={isPosted}
|
||||||
|
className="text-right font-mono"
|
||||||
|
value={l.unitPrice}
|
||||||
|
onChange={(e) => updateLine(i, { unitPrice: Number(e.target.value) })} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-right font-mono font-semibold">
|
||||||
|
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pl-3">
|
||||||
|
{!isPosted && (
|
||||||
|
<button type="button" onClick={() => removeLine(i)} className="text-slate-400 hover:text-red-600">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="py-3 pr-3 text-right text-sm font-semibold text-slate-600 dark:text-slate-300">
|
||||||
|
Итого:
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3 text-right font-mono text-lg font-bold">
|
||||||
|
{grandTotal.toLocaleString('ru', { maximumFractionDigits: 2 })}
|
||||||
|
{' '}
|
||||||
|
<span className="text-sm text-slate-500">
|
||||||
|
{currencies.data?.find((c) => c.id === form.currencyId)?.code ?? ''}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProductPicker open={pickerOpen} onClose={() => setPickerOpen(false)} onPick={addLineFromProduct} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||||
|
<header className="flex items-center justify-between px-5 py-3 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
|
||||||
|
{action}
|
||||||
|
</header>
|
||||||
|
<div className="p-5">{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
|
|
@ -38,8 +38,8 @@ export function UnitsOfMeasurePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<>
|
||||||
<PageHeader
|
<ListPageShell
|
||||||
title="Единицы измерения"
|
title="Единицы измерения"
|
||||||
description="Код по ОКЕИ (шт=796, кг=166, л=112, м=006)."
|
description="Код по ОКЕИ (шт=796, кг=166, л=112, м=006)."
|
||||||
actions={
|
actions={
|
||||||
|
|
@ -48,8 +48,10 @@ export function UnitsOfMeasurePage() {
|
||||||
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
|
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
footer={data && data.total > 0 && (
|
||||||
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={data?.items ?? []}
|
rows={data?.items ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|
@ -64,8 +66,7 @@ export function UnitsOfMeasurePage() {
|
||||||
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</ListPageShell>
|
||||||
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={!!form}
|
open={!!form}
|
||||||
|
|
@ -113,6 +114,6 @@ export function UnitsOfMeasurePage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
|
|
@ -40,8 +40,8 @@ export function VatRatesPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<>
|
||||||
<PageHeader
|
<ListPageShell
|
||||||
title="Ставки НДС"
|
title="Ставки НДС"
|
||||||
description="Настройки ставок налога на добавленную стоимость."
|
description="Настройки ставок налога на добавленную стоимость."
|
||||||
actions={
|
actions={
|
||||||
|
|
@ -50,8 +50,10 @@ export function VatRatesPage() {
|
||||||
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
|
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
footer={data && data.total > 0 && (
|
||||||
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={data?.items ?? []}
|
rows={data?.items ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|
@ -68,8 +70,7 @@ export function VatRatesPage() {
|
||||||
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</ListPageShell>
|
||||||
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={!!form}
|
open={!!form}
|
||||||
|
|
@ -110,6 +111,6 @@ export function VatRatesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue