Перевел на multi-tenant

Добавил поставщиков
Накладные успешно создаются из фронта
This commit is contained in:
2025-12-18 03:56:21 +03:00
parent 47ec8094e5
commit 542beafe0e
38 changed files with 1942 additions and 977 deletions

View File

@@ -1,24 +1,57 @@
import { useEffect, useState } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Result, Button } from 'antd';
import { Providers } from './components/layout/Providers';
import { AppLayout } from './components/layout/AppLayout';
import { Dashboard } from './pages/Dashboard';
import { OcrLearning } from './pages/OcrLearning';
import { InvoiceDraftPage } from './pages/InvoiceDraftPage';
import { DraftsList } from './pages/DraftsList';
import { UNAUTHORIZED_EVENT } from './services/api';
// Компонент заглушки для 401 ошибки
const UnauthorizedScreen = () => (
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff' }}>
<Result
status="403"
title="Доступ запрещен"
subTitle="Мы не нашли вас в базе данных. Пожалуйста, запустите бота и настройте сервер."
extra={
<Button type="primary" href="https://t.me/RmserBot" target="_blank">
Перейти в бота @RmserBot
</Button>
}
/>
</div>
);
function App() {
const [isUnauthorized, setIsUnauthorized] = useState(false);
useEffect(() => {
const handleUnauthorized = () => setIsUnauthorized(true);
// Подписываемся на событие из api.ts
window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
return () => {
window.removeEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
};
}, []);
if (isUnauthorized) {
return <UnauthorizedScreen />;
}
return (
<Providers>
<BrowserRouter>
<Routes>
<Route path="/" element={<AppLayout />}>
<Route index element={<Dashboard />} />
{/* Если Dashboard удален, можно сделать редирект на invoices */}
<Route index element={<Navigate to="/invoices" replace />} />
<Route path="ocr" element={<OcrLearning />} />
{/* Список черновиков */}
<Route path="invoices" element={<DraftsList />} />
{/* Редактирование черновика */}
<Route path="invoice/:id" element={<InvoiceDraftPage />} />
<Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -24,8 +24,14 @@ export const InvoiceDraftPage: React.FC = () => {
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
// --- ЗАПРОСЫ ---
const storesQuery = useQuery({ queryKey: ['stores'], queryFn: api.getStores });
const suppliersQuery = useQuery({ queryKey: ['suppliers'], queryFn: api.getSuppliers });
// Получаем сразу все справочники одним запросом
const dictQuery = useQuery({
queryKey: ['dictionaries'],
queryFn: api.getDictionaries,
staleTime: 1000 * 60 * 5 // Кэшируем на 5 минут
});
const recommendationsQuery = useQuery({ queryKey: ['recommendations'], queryFn: api.getRecommendations });
const draftQuery = useQuery({
@@ -39,8 +45,9 @@ export const InvoiceDraftPage: React.FC = () => {
});
const draft = draftQuery.data;
const stores = dictQuery.data?.stores || [];
const suppliers = dictQuery.data?.suppliers || [];
// ... (МУТАЦИИ оставляем без изменений) ...
const updateItemMutation = useMutation({
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
api.updateDraftItem(id!, vars.itemId, vars.payload),
@@ -115,11 +122,14 @@ export const InvoiceDraftPage: React.FC = () => {
const handleCommit = async () => {
try {
// Валидируем форму (включая нового поставщика)
const values = await form.validateFields();
if (invalidItemsCount > 0) {
message.warning(`Осталось ${invalidItemsCount} нераспознанных товаров!`);
return;
}
commitMutation.mutate({
date_incoming: values.date_incoming.format('YYYY-MM-DD'),
store_id: values.store_id,
@@ -127,7 +137,7 @@ export const InvoiceDraftPage: React.FC = () => {
comment: values.comment || '',
});
} catch {
message.error('Заполните обязательные поля');
message.error('Заполните обязательные поля (Склад, Поставщик)');
}
};
@@ -162,12 +172,11 @@ export const InvoiceDraftPage: React.FC = () => {
return (
<div style={{ paddingBottom: 60 }}>
{/* Header: Уплотненный, без переноса слов */}
{/* Header */}
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/invoices')} size="small" />
{/* Контейнер заголовка и бейджа */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 18, fontWeight: 'bold', whiteSpace: 'nowrap' }}>
{draft.document_number ? `${draft.document_number}` : 'Черновик'}
@@ -189,7 +198,7 @@ export const InvoiceDraftPage: React.FC = () => {
</Button>
</div>
{/* Form: Compact margins */}
{/* Form: Склады и Поставщики */}
<div style={{ background: '#fff', padding: 12, borderRadius: 8, marginBottom: 12, opacity: isCanceled ? 0.6 : 1 }}>
<Form form={form} layout="vertical" initialValues={{ date_incoming: dayjs() }}>
<Row gutter={10}>
@@ -199,22 +208,27 @@ export const InvoiceDraftPage: React.FC = () => {
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="Склад" name="store_id" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
<Form.Item label="Склад" name="store_id" rules={[{ required: true, message: 'Выберите склад' }]} style={{ marginBottom: 8 }}>
<Select
placeholder="Куда?"
loading={storesQuery.isLoading}
options={storesQuery.data?.map(s => ({ label: s.name, value: s.id }))}
loading={dictQuery.isLoading}
options={stores.map(s => ({ label: s.name, value: s.id }))}
size="middle"
/>
</Form.Item>
</Col>
</Row>
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
{/* Поле Поставщика (Обязательное) */}
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true, message: 'Выберите поставщика' }]} style={{ marginBottom: 8 }}>
<Select
placeholder="От кого?"
loading={suppliersQuery.isLoading}
options={suppliersQuery.data?.map(s => ({ label: s.name, value: s.id }))}
loading={dictQuery.isLoading}
options={suppliers.map(s => ({ label: s.name, value: s.id }))}
size="middle"
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
/>
</Form.Item>
<Form.Item label="Комментарий" name="comment" style={{ marginBottom: 0 }}>
@@ -243,14 +257,14 @@ export const InvoiceDraftPage: React.FC = () => {
</div>
{/* Footer Actions */}
<Affix offsetBottom={60} /* Высота нижнего меню */>
<Affix offsetBottom={60}>
<div style={{
background: '#fff',
padding: '8px 16px',
borderTop: '1px solid #eee',
boxShadow: '0 -2px 10px rgba(0,0,0,0.05)',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
borderRadius: '8px 8px 0 0' // Скругление сверху
borderRadius: '8px 8px 0 0'
}}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: 11, color: '#888', lineHeight: 1 }}>Итого:</span>

View File

@@ -13,15 +13,25 @@ import type {
DraftInvoice,
UpdateDraftItemRequest,
CommitDraftRequest,
// Новые типы
ProductSearchResult,
AddContainerRequest,
AddContainerResponse
AddContainerResponse,
DictionariesResponse,
DraftSummary
} from './types';
// Базовый URL
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
// Телеграм объект
const tg = window.Telegram?.WebApp;
// ID для локальной разработки (Fallback)
const DEBUG_USER_ID = 665599275;
// Событие для глобальной обработки 401
export const UNAUTHORIZED_EVENT = 'rms_unauthorized';
const apiClient = axios.create({
baseURL: API_URL,
headers: {
@@ -29,33 +39,36 @@ const apiClient = axios.create({
},
});
// --- Request Interceptor (Авторизация) ---
apiClient.interceptors.request.use((config) => {
// 1. Пробуем взять ID из Telegram WebApp
// 2. Ищем в URL параметрах (удобно для тестов в браузере: ?_tg_id=123)
// 3. Используем хардкод для локальной разработки
const urlParams = new URLSearchParams(window.location.search);
const paramId = urlParams.get('_tg_id');
const userId = tg?.initDataUnsafe?.user?.id || paramId || DEBUG_USER_ID;
if (userId) {
config.headers['X-Telegram-User-ID'] = userId.toString();
}
return config;
});
// --- Response Interceptor (Обработка ошибок) ---
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
// Генерируем кастомное событие, которое поймает App.tsx
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
}
console.error('API Error:', error);
return Promise.reject(error);
}
);
// Мок поставщиков
const MOCK_SUPPLIERS: Supplier[] = [
{ id: '00000000-0000-0000-0000-000000000001', name: 'ООО "Рога и Копыта"' },
{ id: '00000000-0000-0000-0000-000000000002', name: 'ИП Иванов (Овощи)' },
{ id: '00000000-0000-0000-0000-000000000003', name: 'Metro Cash&Carry' },
{ id: '00000000-0000-0000-0000-000000000004', name: 'Simple Wine' },
];
// интерфейс для списка (краткий)
export interface DraftSummary {
id: string;
document_number: string;
date_incoming: string;
status: string;
items_count: number;
total_sum: number;
store_name?: string;
}
export const api = {
checkHealth: async (): Promise<HealthResponse> => {
const { data } = await apiClient.get<HealthResponse>('/health');
@@ -67,14 +80,11 @@ export const api = {
return data;
},
// Оставляем для совместимости со старыми компонентами (если используются),
// но в Draft Flow будем использовать поиск.
getCatalogItems: async (): Promise<CatalogItem[]> => {
const { data } = await apiClient.get<CatalogItem[]>('/ocr/catalog');
return data;
},
// Поиск товаров ---
searchProducts: async (query: string): Promise<ProductSearchResult[]> => {
const { data } = await apiClient.get<ProductSearchResult[]>('/ocr/search', {
params: { q: query }
@@ -82,9 +92,7 @@ export const api = {
return data;
},
// Создание фасовки ---
createContainer: async (payload: AddContainerRequest): Promise<AddContainerResponse> => {
// Внимание: URL эндпоинта взят из вашего ТЗ (/drafts/container)
const { data } = await apiClient.post<AddContainerResponse>('/drafts/container', payload);
return data;
},
@@ -109,15 +117,28 @@ export const api = {
return data;
},
getStores: async (): Promise<Store[]> => {
const { data } = await apiClient.get<Store[]>('/dictionaries/stores');
// --- НОВЫЙ МЕТОД: Получение всех справочников ---
getDictionaries: async (): Promise<DictionariesResponse> => {
const { data } = await apiClient.get<DictionariesResponse>('/dictionaries');
return data;
},
// Старые методы оставляем для совместимости, но они могут вызывать getDictionaries внутри или deprecated endpoint
getStores: async (): Promise<Store[]> => {
// Можно использовать новый эндпоинт и возвращать часть данных
const { data } = await apiClient.get<DictionariesResponse>('/dictionaries');
return data.stores;
},
getSuppliers: async (): Promise<Supplier[]> => {
return new Promise((resolve) => {
setTimeout(() => resolve(MOCK_SUPPLIERS), 300);
});
// Реальный запрос вместо мока
const { data } = await apiClient.get<DictionariesResponse>('/dictionaries');
return data.suppliers;
},
getDrafts: async (): Promise<DraftSummary[]> => {
const { data } = await apiClient.get<DraftSummary[]>('/drafts');
return data;
},
getDraft: async (id: string): Promise<DraftInvoice> => {
@@ -125,12 +146,6 @@ export const api = {
return data;
},
// Получить список черновиков
getDrafts: async (): Promise<DraftSummary[]> => {
const { data } = await apiClient.get<DraftSummary[]>('/drafts');
return data;
},
updateDraftItem: async (draftId: string, itemId: string, payload: UpdateDraftItemRequest): Promise<DraftInvoice> => {
const { data } = await apiClient.patch<DraftInvoice>(`/drafts/${draftId}/items/${itemId}`, payload);
return data;
@@ -140,8 +155,7 @@ export const api = {
const { data } = await apiClient.post<{ document_number: string }>(`/drafts/${draftId}/commit`, payload);
return data;
},
// Отменить/Удалить черновик
deleteDraft: async (id: string): Promise<void> => {
await apiClient.delete(`/drafts/${id}`);
},

View File

@@ -121,6 +121,11 @@ export interface Supplier {
name: string;
}
export interface DictionariesResponse {
stores: Store[];
suppliers: Supplier[];
}
// --- Черновик Накладной (Draft) ---
export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR' | 'CANCELED';
@@ -146,6 +151,18 @@ export interface DraftItem {
container?: ProductContainer; // Развернутый объект для UI
}
// --- Список Черновиков (Summary) ---
export interface DraftSummary {
id: UUID;
document_number: string;
date_incoming: string;
status: DraftStatus; // Используем существующий тип статуса
items_count: number;
total_sum: number;
store_name?: string;
created_at: string;
}
export interface DraftInvoice {
id: UUID;
status: DraftStatus;

24
rmser-view/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
/// <reference types="vite/client" />
interface TelegramWebApp {
initData: string;
initDataUnsafe: {
user?: {
id: number;
first_name: string;
last_name?: string;
username?: string;
language_code?: string;
};
// ... другие поля по необходимости
};
close: () => void;
expand: () => void;
// ... другие методы
}
interface Window {
Telegram?: {
WebApp: TelegramWebApp;
};
}