правки от claude
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
name: Deploy
|
||||
|
||||
# Ручной запуск из вкладки Actions в Gitea — как "Run pipeline" в GitLab
|
||||
# с выбором, что именно катить: back / front / всё сразу.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: "Что деплоим"
|
||||
type: choice
|
||||
default: all
|
||||
options:
|
||||
- back
|
||||
- front
|
||||
- all
|
||||
|
||||
jobs:
|
||||
build-backend:
|
||||
if: ${{ inputs.target == 'back' || inputs.target == 'all' }}
|
||||
runs-on: self-hosted
|
||||
container:
|
||||
image: docker:24-cli
|
||||
outputs:
|
||||
tag: ${{ steps.vars.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- id: vars
|
||||
run: echo "tag=${GITEA_SHA}" >> "$GITEA_OUTPUT"
|
||||
|
||||
- name: Docker login
|
||||
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USER }}" --password-stdin
|
||||
|
||||
- name: Build backend image
|
||||
run: docker build -f deployment/charts/back/Dockerfile --target architector -t kayashov/architector:${{ steps.vars.outputs.tag }} .
|
||||
|
||||
- name: Push backend image
|
||||
run: docker push kayashov/architector:${{ steps.vars.outputs.tag }}
|
||||
|
||||
deploy-backend:
|
||||
if: ${{ inputs.target == 'back' || inputs.target == 'all' }}
|
||||
needs: build-backend
|
||||
runs-on: self-hosted
|
||||
container:
|
||||
image: alpine/helm:3.14.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Configure kubectl access
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > ~/.kube/config
|
||||
|
||||
- name: Helm upgrade backend
|
||||
run: |
|
||||
helm upgrade -i architector ./deployment/charts/back \
|
||||
-f ./deployment/values.yaml \
|
||||
--set global.version=${{ needs.build-backend.outputs.tag }} \
|
||||
--namespace media
|
||||
|
||||
build-front:
|
||||
if: ${{ inputs.target == 'front' || inputs.target == 'all' }}
|
||||
runs-on: self-hosted
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: npm ci && build
|
||||
working-directory: front
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
# /front-build смонтирован в раннер как тот же hostPath, что
|
||||
# front-под берёт под nginx (см. deployment/ci/runner-deployment.yaml).
|
||||
# Новый билд подхватится сразу, без рестарта пода.
|
||||
- name: Publish static files
|
||||
run: |
|
||||
rm -rf /front-build/*
|
||||
cp -r front/build/* /front-build/
|
||||
@@ -27,4 +27,6 @@ spec:
|
||||
- name: DB_NAME
|
||||
value: {{ .Values.global.datasource.login }}
|
||||
- name: DB_PASSWORD
|
||||
value: {{ .Values.global.datasource.pass }}
|
||||
value: {{ .Values.global.datasource.pass }}
|
||||
- name: CORS_ALLOWED_ORIGINS
|
||||
value: {{ .Values.global.back.corsAllowedOrigins | default "*" | quote }}
|
||||
@@ -0,0 +1,89 @@
|
||||
# CI/CD: пуш → ручной деплой одной кнопкой
|
||||
|
||||
Схема: Gitea Actions + раннер (образ `gitea/runner` — старый `gitea/act_runner`
|
||||
уже deprecated) внутри k3s. Пайплайн запускается вручную из вкладки **Actions**
|
||||
репозитория (кнопка "Run workflow"), с выбором `target`: `back` / `front` /
|
||||
`all` — аналог кнопки "Run pipeline" с переменной в GitLab.
|
||||
|
||||
Это первая рабочая версия. Она не протестирована на реальном кластере (я не
|
||||
имею доступа к твоему серверу) — почти наверняка на первом запуске придётся
|
||||
что-то поправить по логам, это нормально.
|
||||
|
||||
## Что нужно сделать один раз руками
|
||||
|
||||
### 1. Включить Actions в Gitea
|
||||
Site Administration → Configuration → убедиться, что Actions включены
|
||||
(`[actions] ENABLED = true` в app.ini, если ещё не включено — потребуется
|
||||
перезапуск Gitea). Затем в самом репозитории: Settings → Actions → включить.
|
||||
|
||||
### 2. Развернуть раннера в кластере
|
||||
```bash
|
||||
# впиши токен регистрации (Site Administration → Actions → Runners →
|
||||
# Create new Runner, или в настройках репозитория/организации)
|
||||
echo -n "<токен>" | base64
|
||||
# вставь результат вместо PLACEHOLDER_BASE64_TOKEN в runner-deployment.yaml,
|
||||
# поправь GITEA_INSTANCE_URL на свой адрес
|
||||
|
||||
kubectl apply -f deployment/ci/namespace.yaml
|
||||
kubectl apply -f deployment/ci/runner-rbac.yaml
|
||||
kubectl apply -f deployment/ci/runner-deployment.yaml
|
||||
```
|
||||
Раннер должен появиться в Gitea (Site Administration → Actions → Runners) со
|
||||
статусом Idle. Если нет — смотри `kubectl logs -n ci deploy/act-runner` (там же
|
||||
будет видно, если, например, неверный `GITEA_INSTANCE_URL` или токен).
|
||||
|
||||
### 3. Собрать kubeconfig для пайплайна
|
||||
После применения `runner-rbac.yaml` у нас есть токен ServiceAccount
|
||||
`architector-ci` (лежит в namespace `ci`, но права у него через ClusterRole —
|
||||
на весь кластер, не только на `media`, чтобы этим же раннером и токеном можно
|
||||
было деплоить будущие проекты в других namespace'ах). Собираем из него
|
||||
kubeconfig (с любой машины, у которой уже есть доступ к кластеру через свой
|
||||
kubectl):
|
||||
|
||||
```bash
|
||||
TOKEN=$(kubectl get secret architector-ci-token -n ci -o jsonpath='{.data.token}' | base64 -d)
|
||||
CA=$(kubectl get secret architector-ci-token -n ci -o jsonpath='{.data.ca\.crt}')
|
||||
SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
|
||||
|
||||
cat > architector-ci-kubeconfig.yaml <<EOF
|
||||
apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
- name: k3s
|
||||
cluster:
|
||||
server: ${SERVER}
|
||||
certificate-authority-data: ${CA}
|
||||
contexts:
|
||||
- name: architector-ci
|
||||
context:
|
||||
cluster: k3s
|
||||
namespace: media
|
||||
user: architector-ci
|
||||
current-context: architector-ci
|
||||
users:
|
||||
- name: architector-ci
|
||||
user:
|
||||
token: ${TOKEN}
|
||||
EOF
|
||||
|
||||
base64 -w0 architector-ci-kubeconfig.yaml # это значение пойдёт в секрет KUBE_CONFIG
|
||||
```
|
||||
|
||||
### 4. Секреты пайплайна в Gitea
|
||||
Repository → Settings → Actions → Secrets, добавить:
|
||||
|
||||
- `DOCKERHUB_USER`, `DOCKERHUB_TOKEN` — логин и access token Docker Hub
|
||||
(Docker Hub → Account Settings → Security → New Access Token, не пароль).
|
||||
- `KUBE_CONFIG` — вывод команды `base64 -w0` из шага 3.
|
||||
|
||||
### 5. Проверка
|
||||
Repository → Actions → Deploy → Run workflow → выбрать `target` → Run.
|
||||
Смотреть логи джобов там же.
|
||||
|
||||
## На будущее
|
||||
|
||||
Ты упоминал переезд фронта на PVC вместо hostPath — когда дойдём до этого,
|
||||
`build-front` job поменяется на полноценную сборку Docker-образа (нужен будет
|
||||
свой `deployment/charts/front/Dockerfile`) плюс `helm upgrade` для front-чарта,
|
||||
аналогично backend. hostPath-подход в `runner-deployment.yaml` тогда тоже
|
||||
уйдёт — обсудим отдельно, как ты и предлагал.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Отдельный namespace для CI-инфраструктуры (раннер, его ServiceAccount и т.д.),
|
||||
# чтобы не мешать в одну кучу с namespace приложений (media и будущими).
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: ci
|
||||
@@ -0,0 +1,94 @@
|
||||
# Раннер Gitea Actions внутри k3s.
|
||||
#
|
||||
# ВАЖНО (правки после первого неудачного запуска):
|
||||
# - образ gitea/act_runner устарел, проект переехал в gitea/runner —
|
||||
# используем его.
|
||||
# - вместо связки "act_runner + отдельный docker:dind сайдкар с ручным
|
||||
# command/wait-loop" (там был баг: путь /opt/act/run.sh в текущих сборках
|
||||
# уже не существует) берём вариант "-dind" — это один образ, где раннер и
|
||||
# docker-демон запущены вместе через s6 и сами разбираются с порядком
|
||||
# старта. Меньше кастомной логики — меньше способов сломать.
|
||||
#
|
||||
# Добавлено относительно оригинала:
|
||||
# - hostPath-том на тот же путь, что смонтирован в front-чарте
|
||||
# (global.front.buildPath, сейчас /md0/services/architector/build) —
|
||||
# чтобы шаг "собрать и выложить фронт" мог прямо записать build/ на диск ноды.
|
||||
# ВАЖНО: это работает надёжно только если k3s однонодовый (раннер и
|
||||
# front-под физически на одной машине). Если нод несколько — нужно
|
||||
# закрепить оба пода на одной ноде через nodeSelector/affinity.
|
||||
#
|
||||
# Перед применением:
|
||||
# 1. Впиши токен регистрации раннера в runner-secret.token (base64) —
|
||||
# токен берётся в Gitea: Site Administration → Actions → Runners →
|
||||
# Create new Runner (или в настройках конкретного репозитория/организации).
|
||||
# 2. Проверь GITEA_INSTANCE_URL — адрес твоего Gitea, доступный изнутри кластера.
|
||||
# 3. kubectl apply -f namespace.yaml -f runner-rbac.yaml -f runner-deployment.yaml
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: act-runner-vol
|
||||
namespace: ci
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: runner-secret
|
||||
namespace: ci
|
||||
type: Opaque
|
||||
data:
|
||||
# base64 от токена регистрации, например: echo -n "<токен>" | base64
|
||||
token: VGM5ZzROeXpoNVM1TmxIWlVrMHlZZTdRVXh3Y1NuMzN5WG1va1ZFYQ==
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: act-runner
|
||||
namespace: ci
|
||||
labels:
|
||||
app: act-runner
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: act-runner
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: act-runner
|
||||
spec:
|
||||
restartPolicy: Always
|
||||
volumes:
|
||||
- name: runner-data
|
||||
persistentVolumeClaim:
|
||||
claimName: act-runner-vol
|
||||
- name: front-build
|
||||
hostPath:
|
||||
path: /md0/services/architector/build
|
||||
type: DirectoryOrCreate
|
||||
containers:
|
||||
- name: runner
|
||||
# -dind = раннер + docker-демон в одном образе (управляются s6).
|
||||
# Можно закрепить конкретную версию вместо latest, если хочется
|
||||
# стабильности между пересозданиями пода, например latest-dind -> 0.6-dind.
|
||||
image: gitea/runner:latest-dind
|
||||
securityContext:
|
||||
privileged: true # нужно докер-демону внутри контейнера
|
||||
env:
|
||||
- name: GITEA_INSTANCE_URL
|
||||
value: http://gitea.git.svc.cluster.local:3000 # поправь под свой адрес Gitea
|
||||
- name: GITEA_RUNNER_REGISTRATION_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: runner-secret
|
||||
key: token
|
||||
volumeMounts:
|
||||
- name: runner-data
|
||||
mountPath: /data
|
||||
- name: front-build
|
||||
mountPath: /front-build
|
||||
@@ -0,0 +1,50 @@
|
||||
# Права, которыми будет пользоваться CI-пайплайн для деплоя через helm/kubectl.
|
||||
# Сделано ClusterRole + ClusterRoleBinding (не ограничено одним namespace),
|
||||
# т.к. раннер планируется переиспользовать под будущие проекты в других
|
||||
# namespace'ах. Это по-прежнему НЕ cluster-admin — только конкретные
|
||||
# ресурсы (Deployment/Service/ConfigMap/Secret/Pod), нужные helm upgrade.
|
||||
#
|
||||
# Применить один раз: kubectl apply -f namespace.yaml -f runner-rbac.yaml
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: architector-ci
|
||||
namespace: ci
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: architector-ci-deployer
|
||||
rules:
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["services", "configmaps", "secrets", "pods", "namespaces"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: architector-ci-deployer-binding
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: architector-ci
|
||||
namespace: ci
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: architector-ci-deployer
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
---
|
||||
# Начиная с Kubernetes 1.24 токены для ServiceAccount не создаются
|
||||
# автоматически — этот Secret явно просит Kubernetes выпустить
|
||||
# долгоживущий токен для architector-ci. Именно из него мы соберём
|
||||
# kubeconfig для пайплайна (см. deployment/ci/README.md).
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: architector-ci-token
|
||||
namespace: ci
|
||||
annotations:
|
||||
kubernetes.io/service-account.name: architector-ci
|
||||
type: kubernetes.io/service-account-token
|
||||
@@ -3,7 +3,6 @@ import {BrowserRouter as Router, Route, Routes} from 'react-router-dom';
|
||||
import YearList from './components/YearList';
|
||||
import ChapterList from './components/ChapterList';
|
||||
import OrderItemList from './components/orderItems/OrderItemList'; // новый компонент
|
||||
import OrderBuilder from './components/OrderBuilder';
|
||||
import StorePage from "./components/store/StorePage";
|
||||
import OrderList from "./components/OrderList";
|
||||
import Navigation from "./components/Navigation";
|
||||
@@ -11,9 +10,6 @@ import Navigation from "./components/Navigation";
|
||||
function App() {
|
||||
const [selectedYear, setSelectedYear] = useState(null);
|
||||
const [selectedChapter, setSelectedChapter] = useState(null);
|
||||
const [selectedStore, setSelectedStore] = useState(null);
|
||||
|
||||
console.log(selectedYear);
|
||||
|
||||
return (
|
||||
<Router>
|
||||
@@ -59,15 +55,6 @@ function App() {
|
||||
<StorePage/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/order-builder/:storeId"
|
||||
element={
|
||||
<OrderBuilder
|
||||
storeId={selectedStore?.id}
|
||||
chapterId={selectedChapter?.id}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ const ChapterList = ({year, onSelectChapter}) => {
|
||||
let currentYearId;
|
||||
|
||||
if (!year) {
|
||||
if(!paramYearId) {
|
||||
if (paramYearId) {
|
||||
currentYearId = paramYearId;
|
||||
} else {
|
||||
navigate('/', {replace: true});
|
||||
@@ -29,7 +29,7 @@ const ChapterList = ({year, onSelectChapter}) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await chapterService.getByYear(currentYearId);
|
||||
setChapters(response.data._embedded?.chapters || []);
|
||||
setChapters(response.data || []);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки глав:', error);
|
||||
} finally {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import { useOrders } from '../hooks/useOrders';
|
||||
|
||||
const OrderBuilder = ({ storeId, chapterId }) => {
|
||||
const { activeOrder, loading, loadActiveOrder, addItemToOrder } = useOrders();
|
||||
const [newItem, setNewItem] = useState({
|
||||
name: '',
|
||||
count: 1,
|
||||
unitPrice: 0,
|
||||
chapter: { href: `http://localhost:8080/api/chapters/${chapterId}` }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadActiveOrder(storeId);
|
||||
}, [storeId]);
|
||||
|
||||
const handleAddItem = () => {
|
||||
addItemToOrder(newItem);
|
||||
setNewItem({ ...newItem, name: '', count: 1, unitPrice: 0 });
|
||||
};
|
||||
|
||||
if (loading) return <div>Загрузка...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Сборка заказа для магазина {storeId}</h3>
|
||||
{activeOrder && (
|
||||
<div>
|
||||
<h4>Активный заказ #{activeOrder.id}</h4>
|
||||
<ul>
|
||||
{activeOrder.items.map(item => (
|
||||
<li key={item.id}>
|
||||
{item.name} - {item.count} шт. × {item.unitPrice} руб.
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<input
|
||||
placeholder="Название товара"
|
||||
value={newItem.name}
|
||||
onChange={(e) => setNewItem({ ...newItem, name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Количество"
|
||||
value={newItem.count}
|
||||
onChange={(e) => setNewItem({ ...newItem, count: parseInt(e.target.value) })}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Цена за единицу"
|
||||
value={newItem.unitPrice}
|
||||
onChange={(e) => setNewItem({ ...newItem, unitPrice: parseFloat(e.target.value) })}
|
||||
/>
|
||||
<button onClick={handleAddItem}>Добавить в заказ</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderBuilder;
|
||||
@@ -12,7 +12,7 @@ const YearList = ({ onSelectYear }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await yearService.getAll();
|
||||
setYears(response.data._embedded?.years || []);
|
||||
setYears(response.data || []);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки лет:', error);
|
||||
} finally {
|
||||
|
||||
@@ -3,8 +3,6 @@ import {useNavigate, useParams} from 'react-router-dom';
|
||||
import {itemsService, orderItemService, storeService, targetService, unitService,} from '../../services/api';
|
||||
import OrderItemsTable from './OrderItemsTable';
|
||||
import SummarySection from './SummarySection';
|
||||
import {c} from "react/compiler-runtime";
|
||||
import {logDOM} from "@testing-library/dom";
|
||||
|
||||
const newItem = (currentChapterId) => {
|
||||
return {
|
||||
@@ -80,15 +78,14 @@ const OrderItemList = ({chapter}) => {
|
||||
|
||||
// Расчёт итоговых значений
|
||||
const totalSum = orderItems.reduce((sum, item) => sum + (item.item?.unitPrice * item.count), 0);
|
||||
const paidSum = orderItems
|
||||
.filter(item => item.paid)
|
||||
.reduce((sum, item) => sum + (item.item?.unitPrice * item.count), 0);
|
||||
const paidSum = orderItems.reduce((sum, item) => sum + (item.paid || 0), 0);
|
||||
const remainingSum = totalSum - paidSum;
|
||||
|
||||
// Обработчики событий
|
||||
const startEditing = (item) => setEditingItem({...item});
|
||||
|
||||
const handleSave = async (item, isNew = false) => {
|
||||
console.log(item)
|
||||
try {
|
||||
let savedItem;
|
||||
if (!validate(item)) {
|
||||
@@ -149,7 +146,7 @@ const OrderItemList = ({chapter}) => {
|
||||
|
||||
const toggleActive = async (item) => {
|
||||
try {
|
||||
const response = await orderItemService.toggle(item);
|
||||
const response = await orderItemService.toggle(item.id);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Ошибка обновления статуса оплаты:', error);
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {orderService, orderItemService, API_BASE} from '../services/api';
|
||||
|
||||
export const useOrders = () => {
|
||||
const [activeOrder, setActiveOrder] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadActiveOrder = async (storeId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await orderService.getActiveByStore(storeId);
|
||||
setActiveOrder(response.data._embedded?.orders?.[0] || null);
|
||||
} catch (error) {
|
||||
console.error('Error loading active order:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addItemToOrder = async (itemData) => {
|
||||
if (!activeOrder) return;
|
||||
|
||||
const item = {
|
||||
...itemData,
|
||||
orders: {
|
||||
href: `${API_BASE}/orders/${activeOrder.id}`
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await orderItemService.add(item);
|
||||
loadActiveOrder(activeOrder.store.id);
|
||||
} catch (error) {
|
||||
console.error('Error adding item to order:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return { activeOrder, loading, loadActiveOrder, addItemToOrder };
|
||||
};
|
||||
@@ -1,17 +1,19 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// export const API_BASE = 'http://localhost:8080';
|
||||
export const API_BASE = 'https://architector.kayashov.keenetic.pro/api';
|
||||
// Локально по умолчанию бэкенд на localhost:8080.
|
||||
// Для прод-сборки задать REACT_APP_API_BASE в .env.production (например,
|
||||
// https://architector.kayashov.keenetic.pro/api).
|
||||
export const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:8080';
|
||||
|
||||
// API для Years
|
||||
export const yearService = {
|
||||
getAll: () => axios.get(`${API_BASE}/years`),
|
||||
getAll: () => axios.get(`${API_BASE}/year`),
|
||||
create: (year) => axios.post(`${API_BASE}/years`, year),
|
||||
};
|
||||
|
||||
// API для Chapters
|
||||
export const chapterService = {
|
||||
getByYear: (yearId) => axios.get(`${API_BASE}/chapters/search/findByYearId?yearId=${yearId}`),
|
||||
getByYear: (yearId) => axios.get(`${API_BASE}/chapter/${yearId}`),
|
||||
create: (chapter) => axios.post(`${API_BASE}/chapters`, chapter),
|
||||
};
|
||||
|
||||
@@ -30,7 +32,6 @@ export const orderItemService = {
|
||||
update: (item) => axios.put(`${API_BASE}/orderItem`, item),
|
||||
delete: (id) => axios.delete(`${API_BASE}/orderItems/${id}`),
|
||||
toggle: (id) => axios.patch(`${API_BASE}/orderItem/${id}`),
|
||||
getByStore: (storeId) => axios.get(`/api/stores/${storeId}/orders`),
|
||||
// другие методы...
|
||||
};
|
||||
|
||||
@@ -39,7 +40,7 @@ export const unitService = {
|
||||
}
|
||||
|
||||
export const targetService = {
|
||||
getAll: () => axios.get(`${API_BASE}/targets`),
|
||||
getAll: () => axios.get(`${API_BASE}/targets?size=500`),
|
||||
}
|
||||
|
||||
export const itemsService = {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export const OrderItemDTO = {
|
||||
id: null, // ID записи OrderItem
|
||||
count: 1,
|
||||
paidAmount: 0,
|
||||
chapter: { id: null }, // Ссылка на главу
|
||||
item: {
|
||||
id: null, // ID товара (Items)
|
||||
name: '',
|
||||
unitPrice: 0,
|
||||
link: '',
|
||||
unit: {
|
||||
id: null,
|
||||
name: ''
|
||||
}
|
||||
},
|
||||
target: { id: null, name: '' },
|
||||
store: { id: null, name: '' }
|
||||
};
|
||||
@@ -1,21 +1,46 @@
|
||||
package ru.kayashov.architector.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.filter.CorsFilter;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Разрешённые origin настраиваются через свойство app.cors.allowed-origins
|
||||
* (или переменную окружения CORS_ALLOWED_ORIGINS).
|
||||
* По умолчанию (локальная разработка) — "*", т.е. разрешено всё, как и раньше.
|
||||
* В проде задаётся конкретный домен фронта, например:
|
||||
* CORS_ALLOWED_ORIGINS=https://architector.kayashov.keenetic.pro
|
||||
* (несколько значений можно перечислить через запятую)
|
||||
*/
|
||||
@Configuration
|
||||
public class CorsConfig {
|
||||
|
||||
@Value("${app.cors.allowed-origins:*}")
|
||||
private String allowedOrigins;
|
||||
|
||||
@Bean
|
||||
public CorsFilter corsFilter() {
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
|
||||
// config.setAllowCredentials(true);
|
||||
config.addAllowedOrigin("*");
|
||||
String origins = allowedOrigins == null ? "*" : allowedOrigins.trim();
|
||||
|
||||
if (origins.isEmpty() || origins.equals("*")) {
|
||||
// Локальный дев-режим: разрешаем всё, credentials не нужны
|
||||
config.addAllowedOriginPattern("*");
|
||||
} else {
|
||||
Arrays.stream(origins.split(","))
|
||||
.map(String::trim)
|
||||
.filter(o -> !o.isEmpty())
|
||||
.forEach(config::addAllowedOrigin);
|
||||
config.setAllowCredentials(true);
|
||||
}
|
||||
|
||||
config.addAllowedHeader("*");
|
||||
config.addAllowedMethod("*");
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package ru.kayashov.architector.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import ru.kayashov.architector.model.Chapter;
|
||||
import ru.kayashov.architector.model.Orders;
|
||||
import ru.kayashov.architector.repository.ChapterRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("chapter")
|
||||
@RequiredArgsConstructor
|
||||
public class ChapterController {
|
||||
|
||||
private final ChapterRepository chapterRepository;
|
||||
|
||||
// readOnly = true запрещает Hibernate автоматически сбрасывать (flush)
|
||||
// изменённые в памяти сущности в БД — GET не должен иметь побочных эффектов
|
||||
@Transactional(readOnly = true)
|
||||
@GetMapping("/{yearId}")
|
||||
public List<Chapter> getByYear(@PathVariable Long yearId) {
|
||||
List<Chapter> chapters = chapterRepository.findByYearId(yearId);
|
||||
for (Chapter chapter : chapters) {
|
||||
Float sum = (float) chapter.getItems().stream()
|
||||
.filter(oi -> oi.getOrders().getStatus() == Orders.OrderStatus.COMPLETED)
|
||||
.mapToDouble(oi -> oi.getCount() * oi.getItem().getUnitPrice())
|
||||
.sum();
|
||||
chapter.setCost(sum);
|
||||
}
|
||||
return chapters;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package ru.kayashov.architector.controller;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
/**
|
||||
* Единая обработка ошибок для REST-контроллеров.
|
||||
* Без этого orElseThrow()/IllegalStateException долетали до клиента как голый 500.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(NoSuchElementException.class)
|
||||
public ResponseEntity<Object> handleNotFound(NoSuchElementException ex) {
|
||||
return build(HttpStatus.NOT_FOUND, "Запрашиваемый объект не найден");
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalStateException.class)
|
||||
public ResponseEntity<Object> handleConflict(IllegalStateException ex) {
|
||||
return build(HttpStatus.CONFLICT, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Object> handleBadRequest(IllegalArgumentException ex) {
|
||||
return build(HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Object> handleGeneric(Exception ex) {
|
||||
log.error("Необработанная ошибка", ex);
|
||||
return build(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера");
|
||||
}
|
||||
|
||||
private ResponseEntity<Object> build(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("timestamp", LocalDateTime.now());
|
||||
body.put("status", status.value());
|
||||
body.put("error", status.getReasonPhrase());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package ru.kayashov.architector.controller;
|
||||
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -19,6 +20,7 @@ public class OrderController {
|
||||
this.ordersRepository = ordersRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@PatchMapping("order")
|
||||
public Orders updateOrder(@RequestParam String status, @RequestParam Long id) {
|
||||
Orders order = ordersRepository.findById(id).orElseThrow();
|
||||
@@ -35,22 +37,28 @@ public class OrderController {
|
||||
return ordersRepository.save(order);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@PutMapping("order")
|
||||
public Orders pay(@RequestParam Float count, @RequestParam Long id) {
|
||||
Orders order = ordersRepository.findById(id).orElseThrow();
|
||||
Float sum = order.getItems().stream().map(o -> o.getCount() * o.getItem().getUnitPrice()).reduce(0F, Float::sum);
|
||||
|
||||
if(count < sum) {
|
||||
order.setPaid(count);
|
||||
// count — это вносимая сумма, её нужно прибавлять к уже оплаченному,
|
||||
// а не перезаписывать order.paid целиком
|
||||
float alreadyPaid = order.getPaid() != null ? order.getPaid() : 0F;
|
||||
float newPaid = alreadyPaid + count;
|
||||
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
if (newPaid < sum) {
|
||||
order.setPaid(newPaid);
|
||||
order.setStatus(OrderStatus.PREPAYED);
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
return ordersRepository.save(order);
|
||||
} else {
|
||||
// не даём paid уйти выше полной стоимости заказа
|
||||
order.setPaid(sum);
|
||||
order.setStatus(OrderStatus.COMPLETED);
|
||||
}
|
||||
|
||||
order.setPaid(sum);
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
order.setStatus(OrderStatus.COMPLETED);
|
||||
return ordersRepository.save(order);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package ru.kayashov.architector.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -33,6 +34,7 @@ import java.time.LocalDateTime;
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
public class OrderItemController {
|
||||
|
||||
private final UnitsRepository unitsRepository;
|
||||
@@ -82,7 +84,7 @@ public class OrderItemController {
|
||||
|
||||
OrderItemResponse itemResponse = update(dto, chapter);
|
||||
|
||||
total += item.getItem().getUnitPrice() * item.getCount();
|
||||
total += itemResponse.getItem().getUnitPrice() * itemResponse.getCount();
|
||||
totalYear += total;
|
||||
|
||||
chapter.setCost(total);
|
||||
@@ -127,7 +129,7 @@ public class OrderItemController {
|
||||
Units units = unitsRepository.findByNameIgnoreCase(dto.getItem().getUnit().getName())
|
||||
.orElseGet(() -> unitsRepository.save(new Units(dto.getItem().getUnit().getName())));
|
||||
|
||||
Items items = itemsRepository.findByNameIgnoreCaseAndStoreIdAndUnitId(dto.getItem().getName(), store.getId(), units.getId())
|
||||
Items items = itemsRepository.findByNameIgnoreCaseAndStoreIdAndUnitIdAndUnitPrice(dto.getItem().getName(), store.getId(), units.getId(), dto.getItem().getUnitPrice())
|
||||
.orElseGet(() -> itemsRepository.save(Items.builder()
|
||||
.name(dto.getItem().getName())
|
||||
.link(dto.getItem().getLink())
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package ru.kayashov.architector.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import ru.kayashov.architector.model.Chapter;
|
||||
import ru.kayashov.architector.model.Year;
|
||||
import ru.kayashov.architector.repository.YearRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/year")
|
||||
@RequiredArgsConstructor
|
||||
public class YearController {
|
||||
|
||||
private final YearRepository yearRepository;
|
||||
|
||||
// readOnly = true запрещает Hibernate автоматически сбрасывать (flush)
|
||||
// изменённые в памяти сущности в БД — GET не должен иметь побочных эффектов
|
||||
@Transactional(readOnly = true)
|
||||
@GetMapping
|
||||
public List<Year> getAll() {
|
||||
List<Year> years = yearRepository.findAll();
|
||||
for (Year year : years) {
|
||||
float totalCost = (float) year.getChapters().stream()
|
||||
.mapToDouble(Chapter::getCost)
|
||||
.sum();
|
||||
year.setTotalCost(totalCost);
|
||||
}
|
||||
return years;
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,5 @@ import java.util.Optional;
|
||||
|
||||
@RepositoryRestResource(excerptProjection = ItemsProjection.class)
|
||||
public interface ItemsRepository extends JpaRepository<Items, Long> {
|
||||
Optional<Items> findByNameIgnoreCaseAndStoreIdAndUnitId(String name, Long storeId, Integer unitId);
|
||||
Optional<Items> findByNameIgnoreCaseAndStoreIdAndUnitIdAndUnitPrice(String name, Long storeId, Integer unitId, float price);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@ import ru.kayashov.architector.model.Units;
|
||||
import java.util.Optional;
|
||||
|
||||
@RepositoryRestResource
|
||||
public interface UnitsRepository extends JpaRepository<Units, Long> {
|
||||
public interface UnitsRepository extends JpaRepository<Units, Integer> {
|
||||
Optional<Units> findByNameIgnoreCase(String name);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,5 @@ import ru.kayashov.architector.model.Year;
|
||||
|
||||
@RepositoryRestResource
|
||||
public interface YearRepository extends JpaRepository<Year, Long> {
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package ru.kayashov.architector.repository.projection;
|
||||
|
||||
import org.springframework.data.rest.core.config.Projection;
|
||||
import ru.kayashov.architector.model.OrderItem;
|
||||
import ru.kayashov.architector.model.Orders;
|
||||
import ru.kayashov.architector.model.Store;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Projection(name = "orderProjection", types = OrderItem.class)
|
||||
@Projection(name = "orderProjection", types = Orders.class)
|
||||
public interface OrderProjection {
|
||||
Long getId();
|
||||
|
||||
|
||||
@@ -5,4 +5,8 @@ spring.datasource.username=${DB_NAME:architector}
|
||||
spring.datasource.password=${DB_PASS:kayash73}
|
||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||
spring.jpa.generate-ddl=true
|
||||
#spring.jpa.show-sql=true
|
||||
#spring.jpa.show-sql=true
|
||||
|
||||
# "*" для локальной разработки. В проде переопределить через переменную окружения:
|
||||
# CORS_ALLOWED_ORIGINS=https://architector.kayashov.keenetic.pro
|
||||
app.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:*}
|
||||
Reference in New Issue
Block a user