Добавил черновики накладных и OCR через Яндекс. LLM для расшифровки универсальный

This commit is contained in:
2025-12-17 03:38:24 +03:00
parent fda30276a5
commit e2df2350f7
32 changed files with 1785 additions and 214 deletions

View File

@@ -1,11 +1,12 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Providers } from './components/layout/Providers';
import { AppLayout } from './components/layout/AppLayout';
import { Dashboard } from './pages/Dashboard'; // Импортируем созданную страницу
import { Dashboard } from './pages/Dashboard';
import { OcrLearning } from './pages/OcrLearning';
import { InvoiceDraftPage } from './pages/InvoiceDraftPage'; // Импорт
// Заглушки для остальных страниц пока оставим
const InvoicesPage = () => <h2>Список накладных</h2>;
// Заглушки для списка накладных пока оставим (или можно сделать пустую страницу)
const InvoicesListPage = () => <h2>История накладных (в разработке)</h2>;
function App() {
return (
@@ -13,9 +14,15 @@ function App() {
<BrowserRouter>
<Routes>
<Route path="/" element={<AppLayout />}>
<Route index element={<Dashboard />} /> {/* Используем компонент */}
<Route index element={<Dashboard />} />
<Route path="ocr" element={<OcrLearning />} />
<Route path="invoices" element={<InvoicesPage />} />
{/* Роут для черновика. :id - UUID черновика */}
<Route path="invoice/:id" element={<InvoiceDraftPage />} />
{/* Страница списка */}
<Route path="invoices" element={<InvoicesListPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>

View File

@@ -0,0 +1,162 @@
import React, { useMemo } from 'react';
import { Card, Flex, InputNumber, Typography, Select, Tag } from 'antd';
import { SyncOutlined } from '@ant-design/icons';
import { CatalogSelect } from '../ocr/CatalogSelect';
import type { DraftItem, CatalogItem, UpdateDraftItemRequest } from '../../services/types';
const { Text } = Typography;
interface Props {
item: DraftItem;
catalog: CatalogItem[];
onUpdate: (itemId: string, changes: UpdateDraftItemRequest) => void;
isUpdating: boolean; // Флаг, что конкретно эта строка сейчас сохраняется
}
export const DraftItemRow: React.FC<Props> = ({ item, catalog, onUpdate, isUpdating }) => {
// 1. Поиск выбранного товара в полном каталоге, чтобы получить доступ к containers
const selectedProductObj = useMemo(() => {
if (!item.product_id) return null;
return catalog.find(c => c.id === item.product_id || c.ID === item.product_id);
}, [item.product_id, catalog]);
// 2. Список фасовок для селекта
const containerOptions = useMemo(() => {
if (!selectedProductObj) return [];
const conts = selectedProductObj.containers || selectedProductObj.Containers || [];
const baseUom = selectedProductObj.measure_unit || selectedProductObj.MeasureUnit || 'ед.';
return [
{ value: null, label: `Базовая (${baseUom})` }, // null значит базовая единица
...conts.map(c => ({
value: c.id,
label: c.name // "Коробка"
}))
];
}, [selectedProductObj]);
// 3. Хендлеры изменений
const handleProductChange = (prodId: string) => {
// При смене товара: сбрасываем фасовку, подставляем исходные кол-во/цену, если они были нулями (логика "default")
// Но по ТЗ: "При выборе товара автоматически подставлять quantity = raw_amount..."
// Это лучше делать, передавая эти данные.
onUpdate(item.id, {
product_id: prodId,
container_id: null, // Сброс фасовки
quantity: item.quantity || item.raw_amount || 1,
price: item.price || item.raw_price || 0
});
};
const handleContainerChange = (val: string | null) => {
// При смене фасовки просто шлем ID. Сервер сам не пересчитывает цифры, фронт тоже не должен.
// Пользователь сам поправит цену, если она изменилась за упаковку.
onUpdate(item.id, {
container_id: val || null // Antd Select может вернуть undefined, приводим к null
});
};
const handleBlur = (field: 'quantity' | 'price', val: number | null) => {
// Сохраняем только если значение изменилось и валидно
if (val === null) return;
if (val === item[field]) return;
onUpdate(item.id, {
[field]: val
});
};
// Вычисляем статус цвета
const cardBorderColor = !item.product_id ? '#ffa39e' : item.is_matched ? '#b7eb8f' : '#d9d9d9'; // Красный если нет товара, Зеленый если сматчился сам, Серый если правим
return (
<Card
size="small"
style={{
marginBottom: 8,
borderLeft: `4px solid ${cardBorderColor}`,
background: item.product_id ? '#fff' : '#fff1f0' // Легкий красный фон если не распознан
}}
bodyStyle={{ padding: 12 }}
>
<Flex vertical gap="small">
{/* Верхняя строка: Исходное название и статус */}
<Flex justify="space-between" align="start">
<div style={{ flex: 1 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.raw_name}
</Text>
{item.raw_amount > 0 && (
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}>
(в чеке: {item.raw_amount} x {item.raw_price})
</Text>
)}
</div>
<div>
{isUpdating && <SyncOutlined spin style={{ color: '#1890ff' }} />}
{!item.product_id && <Tag color="error">Не найден</Tag>}
</div>
</Flex>
{/* Выбор товара */}
<CatalogSelect
catalog={catalog}
value={item.product_id || undefined}
onChange={handleProductChange}
/>
{/* Нижний блок: Фасовка, Кол-во, Цена, Сумма */}
<Flex gap={8} align="center">
{/* Если есть фасовки, показываем селект. Если нет - просто лейбл ед. изм */}
<div style={{ flex: 2, minWidth: 90 }}>
{containerOptions.length > 1 ? (
<Select
size="middle"
style={{ width: '100%' }}
placeholder="Ед. изм."
options={containerOptions}
value={item.container_id || null} // null для базовой
onChange={handleContainerChange}
disabled={!item.product_id}
/>
) : (
<div style={{ padding: '4px 11px', background: '#f5f5f5', borderRadius: 6, fontSize: 13, color: '#888', border: '1px solid #d9d9d9' }}>
{selectedProductObj?.measure_unit || 'ед.'}
</div>
)}
</div>
<InputNumber
style={{ flex: 1.5, minWidth: 60 }}
placeholder="Кол-во"
value={item.quantity}
min={0}
onBlur={(e) => handleBlur('quantity', parseFloat(e.target.value))}
// В Antd onBlur event target value is string
/>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', minWidth: 60 }}>
<InputNumber
style={{ width: '100%' }}
placeholder="Цена"
value={item.price}
min={0}
onBlur={(e) => handleBlur('price', parseFloat(e.target.value))}
/>
<Text type="secondary" style={{ fontSize: 10 }}>Цена за ед.</Text>
</div>
</Flex>
{/* Итоговая сумма (расчетная) */}
<div style={{ textAlign: 'right', marginTop: 4 }}>
<Text strong>
= {(item.quantity * item.price).toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}
</Text>
</div>
</Flex>
</Card>
);
};

View File

@@ -0,0 +1,244 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Spin, Alert, Button, Form, Select, DatePicker, Input,
Typography, message, Row, Col, Affix
} from 'antd';
import { ArrowLeftOutlined, CheckOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '../services/api';
import { DraftItemRow } from '../components/invoices/DraftItemRow';
import type { UpdateDraftItemRequest, CommitDraftRequest } from '../services/types';
const { Title, Text } = Typography;
const { TextArea } = Input;
export const InvoiceDraftPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [form] = Form.useForm();
// Локальное состояние для отслеживания какие строки сейчас обновляются
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
// 1. Загрузка справочников
const storesQuery = useQuery({ queryKey: ['stores'], queryFn: api.getStores });
const suppliersQuery = useQuery({ queryKey: ['suppliers'], queryFn: api.getSuppliers });
const catalogQuery = useQuery({ queryKey: ['catalog'], queryFn: api.getCatalogItems, staleTime: 1000 * 60 * 10 });
// 2. Загрузка черновика
const draftQuery = useQuery({
queryKey: ['draft', id],
queryFn: () => api.getDraft(id!),
enabled: !!id,
refetchInterval: (query) => {
const status = query.state.data?.status;
// Продолжаем опрашивать, пока статус PROCESSING, чтобы подтянуть новые товары, если они долетают
return status === 'PROCESSING' ? 3000 : false;
},
});
// 3. Мутация обновления строки
const updateItemMutation = useMutation({
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
api.updateDraftItem(id!, vars.itemId, vars.payload),
onMutate: async ({ itemId }) => {
setUpdatingItems(prev => new Set(prev).add(itemId));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['draft', id] });
},
onError: () => {
message.error('Не удалось сохранить строку');
},
onSettled: (_data, _err, vars) => {
setUpdatingItems(prev => {
const next = new Set(prev);
next.delete(vars.itemId);
return next;
});
}
});
// 4. Мутация коммита
const commitMutation = useMutation({
mutationFn: (payload: CommitDraftRequest) => api.commitDraft(id!, payload),
onSuccess: (data) => {
message.success(`Накладная ${data.document_number} создана!`);
navigate('/invoices');
},
onError: () => {
message.error('Ошибка при создании накладной');
}
});
const draft = draftQuery.data;
// Инициализация формы.
// Убрали проверку status !== 'PROCESSING', чтобы форма заполнялась сразу, как пришли данные.
useEffect(() => {
if (draft) {
// Проверяем, не менял ли пользователь уже поля, чтобы не перезатирать их при поллинге
const currentValues = form.getFieldsValue();
if (!currentValues.store_id && draft.store_id) {
form.setFieldValue('store_id', draft.store_id);
}
if (!currentValues.supplier_id && draft.supplier_id) {
form.setFieldValue('supplier_id', draft.supplier_id);
}
if (!currentValues.comment && draft.comment) {
form.setFieldValue('comment', draft.comment);
}
// Дату ставим, если её нет в форме
if (!currentValues.date_incoming) {
form.setFieldValue('date_incoming', draft.date_incoming ? dayjs(draft.date_incoming) : dayjs());
}
}
}, [draft, form]);
// Вычисляемые данные для UI
const totalSum = useMemo(() => {
// Добавил Number(), так как API возвращает строки ("250"), а нам нужны числа
return draft?.items.reduce((acc, item) => acc + (Number(item.quantity) * Number(item.price)), 0) || 0;
}, [draft?.items]);
const invalidItemsCount = useMemo(() => {
return draft?.items.filter(i => !i.product_id).length || 0;
}, [draft?.items]);
const handleItemUpdate = (itemId: string, changes: UpdateDraftItemRequest) => {
updateItemMutation.mutate({ itemId, payload: changes });
};
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,
supplier_id: values.supplier_id,
comment: values.comment || '',
});
} catch {
message.error('Заполните обязательные поля в шапке (Склад, Поставщик)');
}
};
// --- Рендер ---
// Показываем спиннер ТОЛЬКО если данных нет вообще, или статус PROCESSING и список пуст.
// Если статус PROCESSING, но items уже пришли — показываем интерфейс.
const showSpinner = draftQuery.isLoading || (draft?.status === 'PROCESSING' && (!draft?.items || draft.items.length === 0));
if (showSpinner) {
return (
<div style={{ textAlign: 'center', padding: 50 }}>
<Spin size="large" tip="Обработка чека..." />
<div style={{ marginTop: 16, color: '#888' }}>ИИ читает ваш чек, подождите...</div>
</div>
);
}
if (draftQuery.isError || !draft) {
return <Alert type="error" message="Ошибка загрузки черновика" description="Попробуйте обновить страницу" />;
}
return (
<div style={{ paddingBottom: 80 }}>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 12 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')} />
<Title level={4} style={{ margin: 0 }}>
Черновик {draft.document_number ? `${draft.document_number}` : ''}
{draft.status === 'PROCESSING' && <Spin size="small" style={{ marginLeft: 8 }} />}
</Title>
</div>
<div style={{ background: '#fff', padding: 16, borderRadius: 8, marginBottom: 16 }}>
<Form form={form} layout="vertical" initialValues={{ date_incoming: dayjs() }}>
<Row gutter={12}>
<Col span={12}>
<Form.Item label="Дата" name="date_incoming" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="Склад" name="store_id" rules={[{ required: true, message: 'Выберите склад' }]}>
<Select
placeholder="Куда?"
loading={storesQuery.isLoading}
options={storesQuery.data?.map(s => ({ label: s.name, value: s.id }))}
/>
</Form.Item>
</Col>
</Row>
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true, message: 'Выберите поставщика' }]}>
<Select
placeholder="От кого?"
loading={suppliersQuery.isLoading}
options={suppliersQuery.data?.map(s => ({ label: s.name, value: s.id }))}
/>
</Form.Item>
<Form.Item label="Комментарий" name="comment">
<TextArea rows={1} placeholder="Прим: Довоз за пятницу" />
</Form.Item>
</Form>
</div>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={5} style={{ margin: 0 }}>Позиции ({draft.items.length})</Title>
{invalidItemsCount > 0 && <Text type="danger">{invalidItemsCount} нераспознано</Text>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{draft.items.map(item => (
<DraftItemRow
key={item.id}
item={item}
catalog={catalogQuery.data || []}
onUpdate={handleItemUpdate}
isUpdating={updatingItems.has(item.id)}
/>
))}
</div>
<Affix offsetBottom={0}>
<div style={{
background: '#fff',
padding: '12px 16px',
borderTop: '1px solid #eee',
boxShadow: '0 -2px 10px rgba(0,0,0,0.05)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div>
<div style={{ fontSize: 12, color: '#888' }}>Итого:</div>
<div style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff' }}>
{totalSum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}
</div>
</div>
<Button
type="primary"
size="large"
icon={<CheckOutlined />}
onClick={handleCommit}
loading={commitMutation.isPending}
disabled={invalidItemsCount > 0}
>
Отправить в iiko
</Button>
</div>
</Affix>
</div>
);
};

View File

@@ -7,7 +7,12 @@ import type {
InvoiceResponse,
ProductMatch,
Recommendation,
UnmatchedItem
UnmatchedItem,
Store,
Supplier,
DraftInvoice,
UpdateDraftItemRequest,
CommitDraftRequest
} from './types';
// Базовый URL
@@ -28,6 +33,14 @@ apiClient.interceptors.response.use(
}
);
// Мок поставщиков (так как эндпоинта пока нет)
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 const api = {
checkHealth: async (): Promise<HealthResponse> => {
const { data } = await apiClient.get<HealthResponse>('/health');
@@ -64,4 +77,39 @@ export const api = {
const { data } = await apiClient.post<InvoiceResponse>('/invoices/send', payload);
return data;
},
// Получить список складов
getStores: async (): Promise<Store[]> => {
const { data } = await apiClient.get<Store[]>('/dictionaries/stores');
return data;
},
// Получить список поставщиков (Mock)
getSuppliers: async (): Promise<Supplier[]> => {
// Имитация асинхронности
return new Promise((resolve) => {
setTimeout(() => resolve(MOCK_SUPPLIERS), 300);
});
},
// Получить черновик
getDraft: async (id: string): Promise<DraftInvoice> => {
const { data } = await apiClient.get<DraftInvoice>(`/drafts/${id}`);
return data;
},
// Обновить строку черновика (и обучить модель)
updateDraftItem: async (draftId: string, itemId: string, payload: UpdateDraftItemRequest): Promise<DraftInvoice> => {
// Бэкенд возвращает обновленный черновик целиком (обычно) или обновленный item.
// Предположим, что возвращается обновленный Item или просто 200 OK.
// Но для React Query удобно возвращать данные.
// Если бэк возвращает только item, типизацию нужно уточнить. Пока ждем DraftInvoice или any.
const { data } = await apiClient.patch<DraftInvoice>(`/drafts/${draftId}/items/${itemId}`, payload);
return data;
},
// Зафиксировать черновик
commitDraft: async (draftId: string, payload: CommitDraftRequest): Promise<{ document_number: string }> => {
const { data } = await apiClient.post<{ document_number: string }>(`/drafts/${draftId}/commit`, payload);
return data;
},
};

View File

@@ -95,4 +95,69 @@ export interface InvoiceResponse {
export interface HealthResponse {
status: string;
time: string;
}
// --- Справочники ---
export interface Store {
id: UUID;
name: string;
}
export interface Supplier {
id: UUID;
name: string;
}
// --- Черновик Накладной (Draft) ---
export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR';
export interface DraftItem {
id: UUID;
// Данные из OCR (Read-only)
raw_name: string;
raw_amount: number;
raw_price: number;
// Редактируемые данные
product_id: UUID | null;
container_id: UUID | null; // Фасовка
quantity: number;
price: number;
sum: number;
// Мета-данные
is_matched: boolean;
product?: CatalogItem; // Развернутый объект для UI
container?: ProductContainer; // Развернутый объект для UI
}
export interface DraftInvoice {
id: UUID;
status: DraftStatus;
document_number: string;
date_incoming: string | null; // YYYY-MM-DD
store_id: UUID | null;
supplier_id: UUID | null;
comment: string;
items: DraftItem[];
created_at?: string;
}
// DTO для обновления строки
export interface UpdateDraftItemRequest {
product_id?: UUID;
container_id?: UUID | null; // null если сбросили фасовку
quantity?: number;
price?: number;
}
// DTO для коммита
export interface CommitDraftRequest {
date_incoming: string;
store_id: UUID;
supplier_id: UUID;
comment: string;
}