mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
Добавил черновики накладных и OCR через Яндекс. LLM для расшифровки универсальный
This commit is contained in:
@@ -1,73 +0,0 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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>
|
||||
|
||||
162
rmser-view/src/components/invoices/DraftItemRow.tsx
Normal file
162
rmser-view/src/components/invoices/DraftItemRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
244
rmser-view/src/pages/InvoiceDraftPage.tsx
Normal file
244
rmser-view/src/pages/InvoiceDraftPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user