mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-05 03:12:34 -06:00
Добавил черновики накладных и OCR через Яндекс. LLM для расшифровки универсальный
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user