// ferro-pages.jsx — Заявки list + Новый расчёт form const { useState: usePagesState, useEffect: usePagesEffect } = React; // poll until request status leaves "processing" async function waitReady(reqId) { for (let i = 0; i < 90; i++) { await new Promise((r) => setTimeout(r, 1000)); const data = await apiFetch("GET", `/requests/${reqId}`); if (data.status !== "processing" && data.status !== "new") return data; } throw new Error("Превышено время ожидания"); } /* ---------- Заявки ---------- */ function ZCards({ rows, onOpen, onDelete }) { return (
{rows.map((z) => (
onOpen(z.id)}>
№{z.id}
{z.status} {z.calcVerified === true && Верный} {z.calcVerified === false && Ошибки}
{z.source} · {z.positions} поз. · {z.date}
{z.email &&
{z.email}
}
{RUB(z.sum)}
))}
); } function ZayavkiPage({ onOpen, onNew }) { const [requests, setRequests] = usePagesState([]); const [loading, setLoading] = usePagesState(true); const [emailQuery, setEmailQuery] = usePagesState(""); const loadRequests = (email) => { setLoading(true); const path = email ? `/requests?email=${encodeURIComponent(email)}` : "/requests"; apiFetch("GET", path) .then((data) => { setRequests(data); setLoading(false); }) .catch(() => setLoading(false)); }; const isFirstRender = React.useRef(true); usePagesEffect(() => { if (isFirstRender.current) { isFirstRender.current = false; loadRequests(); return; } // Debounce — не дёргать сервер на каждое нажатие клавиши при вводе email. const t = setTimeout(() => loadRequests(emailQuery.trim()), 350); return () => clearTimeout(t); }, [emailQuery]); const handleDelete = async (id) => { try { await apiFetch("DELETE", `/requests/${id}`); setRequests((rs) => rs.filter((r) => r.id !== id)); } catch (e) { console.error("delete error", e); } }; const rows = requests.map((r) => ({ id: r.id, source: r.source === "email" ? "Email" : "Ручной ввод", positions: r.pos_count || 0, sum: r.grand_total || 0, status: statusLabel(r.status), date: fmtDate(r.created_at), calcVerified: r.calc_verified === 1 ? true : r.calc_verified === 0 ? false : null, email: r.from_email || "", })); return (

Заявки

setEmailQuery(e.target.value)} />
{loading ? (
Загрузка…
) : rows.length === 0 ? (
{emailQuery.trim() ? "Ничего не найдено" : "Заявок пока нет"}
) : ( )}
); } /* ---------- Новый расчёт ---------- */ function Seg({ options, value, onChange }) { return (
{options.map((o) => ( ))}
); } const _HEADER_FIELD_RU = { pos: "Поз.", mark: "Марка", designation: "Обознач.", name: "Наимен.", opening_size: "Проём", qty: "Кол-во", description: "Описание", note: "Примеч.", scheme: "Схема (не читаем)", }; // Полные подписи полей для построчного экрана проверки после распознавания — // то же самое, что _HEADER_FIELD_LABELS в parser.py, плюс 'text'/'note' для // кусков без разметки шапки (там одно общее поле, а не подколонки). const _FIELD_LABELS_FULL = { pos: "Поз.", mark: "Маркировка", designation: "Обозначение", name: "Наименование", opening_size: "Размер (по проёму)", qty: "Количество", description: "Описание", note: "Примечание", text: "Текст", }; function _assembleFieldsText(fields) { return Object.entries(fields || {}) .map(([field, text]) => `${_FIELD_LABELS_FULL[field] || field}: ${text}`) .join("; "); } function ZoomIcon({ zoomed }) { return ( {!zoomed && } ); } // Все ключи "ci,ri" разметки — используется как дефолтный набор excluded // (по умолчанию исключено ВСЁ, менеджер явно отмечает нужное). function _allPieceKeys(layout) { const keys = []; for (let ci = 0; ci < layout.columns.length - 1; ci++) { for (let ri = 0; ri < layout.rows[ci].length - 1; ri++) keys.push(`${ci},${ri}`); } return new Set(keys); } // Режимы разметки — переключаются одной кнопкой в общей панели над списком // страниц (см. PhotoSplitToolbar), не тапом по каждому куску отдельно. // "Дверь"/"Шапка"/"Примечание" — тап по куску ставит/снимает именно этот тег // (повторный тап тем же тегом снимает его, кусок снова нейтральный/excluded). // "Разметка" — тап по куску ничего не делает, только перетаскивание линий. // "Линии" — тап внутри куска режет его в этой точке (как раньше двойной клик). const PHOTO_TAG_MODES = ["Дверь", "Шапка", "Примечание", "Разметка", "Линии"]; const _TAG_MODE_TO_KEY = { "Дверь": "door", "Шапка": "header", "Примечание": "note" }; function PhotoSplitEditor({ item, onChange, mode }) { const [hoverKey, setHoverKey] = usePagesState(null); const [zoomed, setZoomed] = usePagesState(false); const [headerBusy, setHeaderBusy] = usePagesState(new Set()); const itemRef = React.useRef(item); itemRef.current = item; const headerQueueRef = React.useRef(Promise.resolve()); const { layout, excluded, notes, header = new Set(), headerFields = {} } = item; const { columns, rows } = layout; const maxW = zoomed ? Math.min(1600, window.innerWidth - 96) : 640; const scale = Math.min(1, maxW / layout.width); const dispW = Math.round(layout.width * scale); const dispH = Math.round(layout.height * scale); const setLayout = (updater) => onChange({ ...item, layout: updater(layout) }); // Менеджер отметил кусок как «Шапка» — режем его на подколонки и сопоставляем // каждую с полем по ключевому слову в подписи (Марка/Обозначение/Кол-во и т.д.), // целиком кодом, без ИИ (см. detect_header_fields в parser.py). Найденные X-границы // дальше применяются ко всем строкам данных этой же половины страницы — код // заранее знает, какой текст относится к какому полю, вместо того чтобы гадать // по формату прозы. // Если менеджер отмечает шапку в обеих половинах страницы быстро подряд, два // запроса летят почти одновременно — тот, что завершится вторым, читает // itemRef.current ДО того как первый успел применить свой onChange, и может // затереть его результат (гонка состояний). Прогоняем через одну очередь — // следующий вызов ждёт, пока предыдущий полностью применит свой onChange, // прежде чем начать (headerQueueRef — цепочка промисов, а не два в параллель). const detectHeaderFields = (ci, x0, y0, x1, y1) => { headerQueueRef.current = headerQueueRef.current.then(async () => { setHeaderBusy((s) => new Set(s).add(ci)); try { const fd = new FormData(); if (item.token) fd.append("file_token", item.token); else fd.append("file", item.file); fd.append("x0", x0); fd.append("y0", y0); fd.append("x1", x1); fd.append("y1", y1); const r = await fetchWithRetry("/api/detect-header-fields", { method: "POST", body: fd }); if (!r.ok) throw new Error(await r.text()); const res = await r.json(); const cur = itemRef.current; onChange({ ...cur, headerFields: { ...cur.headerFields, [ci]: res.fields } }); } catch (e) { console.error("detect-header-fields", e); } finally { setHeaderBusy((s) => { const next = new Set(s); next.delete(ci); return next; }); } }); }; // Сдвигает ключи "ci,ri" в excluded/notes/header при вставке/удалении границы // строки в колонке colIdx — иначе после разрезания/объединения кусков пометки // "поедут" не на те куски. Кусок(и) на месте самой операции (splitAtRi при // разрезании, mergedRi/lineIdx при объединении) сбрасываются в обычный — // предсказуемее, чем гадать, какую из двух старых пометок сохранить. const _reindexRowKeys = (colIdx, dropRis, shiftFn) => (s) => { const next = new Set(); for (const k of s) { const [kc, kr] = k.split(",").map(Number); if (kc !== colIdx) { next.add(k); continue; } if (dropRis.includes(kr)) continue; next.add(`${kc},${shiftFn(kr)}`); } return next; }; // Двойной клик внутри куска — разрезать его пополам в месте клика (когда // авто-разметка склеила несколько настоящих позиций в один кусок). Это // правка разметки, не отметка — обе новые половины должны стать нейтральными // (excluded), а не унаследовать что-то по умолчанию (см. историю: раньше // "без тега" само по себе значило "дверь", теперь означает excluded — // reindex сам по себе только вычищает старые ключи, но новые в excluded // не добавляет, поэтому добавляем явно). const splitPiece = (colIdx, ri, clickY) => { const rowsArr = rows[colIdx]; const top = rowsArr[ri], bot = rowsArr[ri + 1]; const y = Math.round(Math.max(top + 10, Math.min(bot - 10, clickY))); if (y <= top + 10 || y >= bot - 10) return; const newRows = layout.rows.map((r, i) => (i === colIdx ? [...r.slice(0, ri + 1), y, ...r.slice(ri + 1)] : r)); const reindex = _reindexRowKeys(colIdx, [ri], (kr) => (kr > ri ? kr + 1 : kr)); const nextExcluded = reindex(excluded); nextExcluded.add(`${colIdx},${ri}`); nextExcluded.add(`${colIdx},${ri + 1}`); onChange({ ...item, layout: { ...layout, rows: newRows }, excluded: nextExcluded, notes: reindex(notes), header: reindex(header), }); }; // Клик (без перетаскивания) по линии между кусками — убрать её, объединив // соседние куски в один (когда авто-разметка нарезала одну позицию на много // кусков, напр. таблицы с полями "Ширина/Высота/..." по отдельным строкам). // Тоже правка разметки, не отметка — объединённый кусок должен стать // нейтральным (excluded), а не "дверью" по умолчанию (см. splitPiece выше). const removeRowLine = (colIdx, lineIdx) => { const newRows = layout.rows.map((r, i) => (i === colIdx ? r.filter((_, j) => j !== lineIdx) : r)); const reindex = _reindexRowKeys(colIdx, [lineIdx - 1, lineIdx], (kr) => (kr > lineIdx ? kr - 1 : kr)); const nextExcluded = reindex(excluded); nextExcluded.add(`${colIdx},${lineIdx - 1}`); onChange({ ...item, layout: { ...layout, rows: newRows }, excluded: nextExcluded, notes: reindex(notes), header: reindex(header), }); }; // Сбросить разметку куска целиком — колонка становится одним куском на всю // высоту (снова нейтральным/excluded — см. _allPieceKeys), дальше можно // расставить границы и отметить нужное заново. const resetSplit = () => { const newRows = columns.slice(0, -1).map(() => [0, layout.height]); onChange({ ...item, layout: { ...layout, rows: newRows }, excluded: new Set(newRows.map((_, ci) => `${ci},0`)), notes: new Set(), header: new Set(), headerFields: {}, }); }; // boundaryIdx — индекс внутренней границы в columns (1 .. length-2). Лист // может делиться не на 2, а на N колонок (реальный случай — 3 таблицы в // ряд) — на каждую внутреннюю границу свой перетаскиваемый разделитель, // двигаем только его, зажатый соседними границами, чтобы колонки не // могли поменяться местами/схлопнуться. const startDragCol = (boundaryIdx) => (e) => { e.preventDefault(); e.stopPropagation(); const startX = e.clientX; const startVal = columns[boundaryIdx]; const minX = columns[boundaryIdx - 1] + 20; const maxX = columns[boundaryIdx + 1] - 20; const onMove = (ev) => { const dx = (ev.clientX - startX) / scale; const next = Math.round(Math.max(minX, Math.min(maxX, startVal + dx))); setLayout((l) => ({ ...l, columns: l.columns.map((v, i) => (i === boundaryIdx ? next : v)), })); }; const onUp = () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }; const startDragRow = (colIdx, lineIdx) => (e) => { e.preventDefault(); e.stopPropagation(); const startY = e.clientY; const rowsArr = rows[colIdx]; const startVal = rowsArr[lineIdx]; const minY = rowsArr[lineIdx - 1] + 10; const maxY = rowsArr[lineIdx + 1] - 10; let moved = false; // порог в экранных px, не зависит от масштаба — иначе при // отдалённом виде (scale<1) любое дрожание руки уже считалось бы перетаскиванием const onMove = (ev) => { if (Math.abs(ev.clientY - startY) > 4) moved = true; const dy = (ev.clientY - startY) / scale; const next = Math.round(Math.max(minY, Math.min(maxY, startVal + dy))); setLayout((l) => ({ ...l, rows: l.rows.map((r, i) => (i === colIdx ? r.map((v, j) => (j === lineIdx ? next : v)) : r)), })); }; const onUp = () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); // Клик без перетаскивания по линии — не двигаем, а убираем её (объединяем куски) if (!moved) removeRowLine(colIdx, lineIdx); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }; // Тап по куску в режиме tag ("door"/"header"/"note") ставит этот тег — // повторный тап тем же тегом снимает его обратно в нейтральное (excluded). // Раньше был цикл обычный→мусор→примечание→шапка→обычный на КАЖДЫЙ кусок — // неудобно тапать по несколько раз; теперь выбираешь режим один раз в // панели над списком и просто отмечаешь им нужные куски подряд. const setTag = (ci, ri, tag) => { const key = `${ci},${ri}`; const nextExcluded = new Set(excluded); const nextNotes = new Set(notes); const nextHeader = new Set(header); const nextHeaderFields = { ...headerFields }; const current = header.has(key) ? "header" : notes.has(key) ? "note" : excluded.has(key) ? "excluded" : "door"; nextExcluded.delete(key); nextNotes.delete(key); nextHeader.delete(key); if (current === tag) { nextExcluded.add(key); } else if (tag === "note") { nextNotes.add(key); } else if (tag === "header") { nextHeader.add(key); } // tag === "door" — уже достаточно того, что кусок ушёл из всех трёх сетов выше // Шапка иногда автоматически разрезана на 2+ куска (напр. двухуровневая — // "Марка/Обозначение/.../Приме-" сверху, "поз./0.600/+3.600/+7.200/Всего/..." // снизу, разделены настоящей линией сетки) — раньше при отметке/снятии // каждого куска по отдельности detectHeaderFields считал поля только по // ЕГО СОБСТВЕННЫМ границам и перезаписывал headerFields[ci] последним // результатом, теряя то, что нашли в других отмеченных кусках той же // колонки (реальный случай: отмечен только верхний кусок — "Поз." // распознаётся как "Марка"; только нижний — колонка "Наименование" // вообще теряется). Если набор кусков-шапки этой колонки изменился (тут // отметили или сняли) — пересчитываем по ОБЪЕДИНЁННОМУ диапазону всех // кусков-шапки колонки, что остались, а не по одному последнему клику; // если не осталось ни одного — стираем устаревший результат. if (current === "header" || tag === "header") { const headerRis = [...nextHeader] .map((k) => k.split(",").map(Number)) .filter(([kc]) => kc === ci) .map(([, kr]) => kr); if (headerRis.length) { const x0 = columns[ci], x1 = columns[ci + 1]; const y0 = Math.min(...headerRis.map((kr) => rows[ci][kr])); const y1 = Math.max(...headerRis.map((kr) => rows[ci][kr + 1])); detectHeaderFields(ci, x0, y0, x1, y1); } else { delete nextHeaderFields[ci]; } } onChange({ ...item, excluded: nextExcluded, notes: nextNotes, header: nextHeader, headerFields: nextHeaderFields }); }; return (
{columns.slice(0, -1).map((_, ci) => { const x0 = columns[ci] * scale, x1 = columns[ci + 1] * scale; return rows[ci].slice(0, -1).map((_, ri) => { const y0 = rows[ci][ri] * scale, y1 = rows[ci][ri + 1] * scale; const key = `${ci},${ri}`; const isExcluded = excluded.has(key); const isNotes = notes.has(key); const isHeader = header.has(key); const isDoor = !isExcluded && !isNotes && !isHeader; const isHeaderBusy = isHeader && headerBusy.has(ci); const dimmed = hoverKey && hoverKey !== key && isDoor; const isLayoutMode = mode === "Разметка"; const isLinesMode = mode === "Линии"; const title = isLayoutMode ? "Режим «Разметка» — тап по куску ничего не делает, двигайте линии между кусками" : isLinesMode ? "Режим «Линии» — клик внутри куска разрежет его в этой точке" : isDoor ? `Это дверь — нажмите, чтобы снять отметку` : `Нажмите, чтобы отметить как «${mode}»`; return (
setHoverKey(key)} onMouseLeave={() => setHoverKey(null)} onClick={(e) => { if (isLayoutMode) return; if (isLinesMode) { const rect = e.currentTarget.getBoundingClientRect(); splitPiece(ci, ri, (e.clientY - rect.top) / scale + rows[ci][ri]); return; } setTag(ci, ri, _TAG_MODE_TO_KEY[mode]); }} onDoubleClick={(e) => { const rect = e.currentTarget.getBoundingClientRect(); splitPiece(ci, ri, (e.clientY - rect.top) / scale + rows[ci][ri]); }} title={title + " · двойной клик — разрезать здесь"} > {isDoor && Д} {isNotes && П} {isHeader && {isHeaderBusy ? "…" : "Ш"}} {isHeader && !isHeaderBusy && headerFields[ci] && (
{headerFields[ci].map((f, fi) => (
{_HEADER_FIELD_RU[f.field] || f.field}
))}
)}
); }); })} {columns.slice(1, -1).map((x, i) => (
))} {columns.slice(0, -1).map((_, ci) => { const x0 = columns[ci] * scale, x1 = columns[ci + 1] * scale; return rows[ci].slice(1, -1).map((y, li) => (
)); })}
Двойной клик по изображению — добавить границу строки в этом месте (повторяйте для каждой позиции). Клик по уже проведённой линии — убрать её и объединить куски обратно.
); } function _round50(v) { return Math.round(v / 50) * 50; } function PhotoReviewRow({ row, index, onFeedback }) { const text = _assembleFieldsText(row.fields); const g = row.glazing; return (
{text || (пусто)}
{row.sketch_b64 && (
Найден эскиз этой позиции (отдельно на листе):
Эскиз позиции
)} {g && (
Эскиз: {g.door_type === "double" ? "двупольная" : "однопольная"} дверь{" "} (уверенность: {Math.round(g.door_confidence * 100)}%) {g.panes.length > 0 && ( <> , {g.panes.length === 1 ? "стекло" : `стёкол: ${g.panes.length}`}{" "} {g.panes.map((p, i) => ( {i > 0 && ", "}{_round50(p.width_mm)}×{_round50(p.height_mm)} мм{" "} (найдено ~{p.width_mm}×{p.height_mm}, {Math.round(p.confidence * 100)}%) ))} {g.incomplete && ( — на двупольной двери нашли только одно стекло, возможно пропустили второе )} )}
{g.transom && (
⚠ Фрамуга на чертеже: {_round50(g.transom.width_mm)}×{_round50(g.transom.height_mm)} мм,{" "} {g.transom.glazed ? "остеклённая" : "глухая"}{" "} (по масштабу, найдено ~{g.transom.width_mm}×{g.transom.height_mm}, {Math.round(g.transom.confidence * 100)}%) {" "} — нет в тексте заявки, требуется подтверждение размера
)}
)}
); } function PhotoReviewScreen({ rows, onFeedback, onDone, busy, progress }) { return (
{busy ? "Распознавание ещё идёт — уже готовые строки можно проверять прямо сейчас, не дожидаясь конца." : "Проверьте распознанное построчно — картинка слева, что нашли справа. Отметьте ✓/✕, где явно видно ошибку — это поможет точнее распознавать в следующий раз."}{" "} Текст ниже не меняется от отметок — если что-то не так, поправьте прямо в поле «Текст заявки» после того как нажмёте «Готово».
{busy && (
{progress && progress.total > 0 ? ( <>
Распознано {progress.done} из {progress.total}… ) : ( Готовим распознавание… )}
)}
{rows.map((row, i) => ( ))}
); } function NewCalcPage({ onCalc, text, onTextChange, onReconcile }) { const [email, setEmail] = usePagesState(""); const [tip, setTip] = usePagesState("Розница"); const [fmt, setFmt] = usePagesState("Текст"); const [mont, setMont] = usePagesState("Да"); const [qtyTier, setQtyTier] = usePagesState("Авто"); const [delivery, setDelivery] = usePagesState("По СПб"); const setText = onTextChange; const [originalText, setOriginalText] = usePagesState(""); const [note, setNote] = usePagesState(""); const [busy, setBusy] = usePagesState(false); const [normBusy, setNormBusy] = usePagesState(false); const [photoItems, setPhotoItems] = usePagesState([]); const [photoTagMode, setPhotoTagMode] = usePagesState("Дверь"); const [photoLayoutBusy, setPhotoLayoutBusy] = usePagesState(false); const [photoPendingPages, setPhotoPendingPages] = usePagesState([]); const [photoSplitBusy, setPhotoSplitBusy] = usePagesState(false); // Раньше было 4 вкладки формата (Текст/Таблица/PDF/Фото) — менеджеру нужно // было самому понять, что перед ним, прежде чем грузить файл; путало (см. // историю). Теперь только "Текст"/"Файл" — тип файла определяет код сам, // по расширению. photoFallbackActive — не переключение вкладки, а флаг // "сейчас идёт распознавание по фото/OCR" (скан-PDF без текстового слоя, // фото, или ссылка, которая пришла картинкой) — показываем тот же экран // отбора страниц/разметки прямо на вкладке "Файл", ничего никуда не дёргая. const [photoFallbackActive, setPhotoFallbackActive] = usePagesState(false); // Чем оказался успешно прочитанный файл — влияет на input_type при отправке // заявки (какой LLM-промпт парсить, см. parser.parse_text): 'table' | 'pdf' | null. const [fileKind, setFileKind] = usePagesState(null); const [photoBusy, setPhotoBusy] = usePagesState(false); const [photoProgress, setPhotoProgress] = usePagesState(null); const [photoRows, setPhotoRows] = usePagesState([]); const [photoFinalText, setPhotoFinalText] = usePagesState(null); const [photoReviewing, setPhotoReviewing] = usePagesState(false); const [photoPendingCount, setPhotoPendingCount] = usePagesState(0); const photoRevealQueueRef = React.useRef([]); const photoRevealTimerRef = React.useRef(null); const [fileBusy, setFileBusy] = usePagesState(false); const [fileName, setFileName] = usePagesState(""); const [dragOver, setDragOver] = usePagesState(false); // Предупреждения от детерминированного разбора табличной заявки по шапке // (пропущенные нераспознанные строки + находки AI-перепроверки) — НЕ часть // текста заявки (текст должен остаться чистым для расчёта), показываются // менеджеру отдельным блоком рядом со статусом загрузки. const [tableWarnings, setTableWarnings] = usePagesState([]); const [errMsg, setErrMsg] = usePagesState(""); const [statusMsg, setStatusMsg] = usePagesState(""); // Сверка с КП менеджера — берёт заявку прямо из этой формы (text/fileKind, // как есть, тем же способом, что и обычный расчёт), просит только КП // менеджера (файл/ссылка) в попапе поверх формы. const [showReconcile, setShowReconcile] = usePagesState(false); const [kpFile, setKpFile] = usePagesState(null); const [kpLink, setKpLink] = usePagesState(""); const [kpDragOver, setKpDragOver] = usePagesState(false); const [kpBusy, setKpBusy] = usePagesState(false); const [kpErr, setKpErr] = usePagesState(""); const handleNorm = async () => { if (!text.trim()) return; setNormBusy(true); setErrMsg(""); try { if (!originalText) setOriginalText(text); const res = await apiFetch("POST", "/normalize-text", { text }); setText(res.notes ? res.text + "\n\n" + res.notes : res.text); } catch (e) { setErrMsg("Ошибка нормализации: " + e.message); } finally { setNormBusy(false); } }; const expandPdfToImages = async (file) => { const token = await uploadFileChunked(file); const fd = new FormData(); fd.append("file_token", token); const r = await fetchWithRetry("/api/pdf-to-images", { method: "POST", body: fd }); if (!r.ok) throw new Error(await r.text()); const res = await r.json(); const base = file.name.replace(/\.pdf$/i, ""); const suggestedSet = new Set(res.suggested || []); return res.images.map((b64, i) => { const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let j = 0; j < bin.length; j++) bytes[j] = bin.charCodeAt(j); const pageNo = (res.pages && res.pages[i]) || i + 1; const file = new File([bytes], `${base}_стр${pageNo}.png`, { type: "image/png" }); return { file, page: pageNo, suggested: suggestedSet.has(pageNo), sketches: (res.sketches && res.sketches[i]) || [] }; }); }; // Файлы (в т.ч. страницы, на которые распались PDF) сначала попадают в // «отборочный» экран — можно убрать лишние страницы (титульный лист, // не относящееся к заявке и т.п.) до того, как на каждую уйдёт запрос // разметки (/api/photo-layout — не бесплатно и не мгновенно). const handlePhotoSelect = async (fileList) => { const rawFiles = Array.from(fileList || []); if (!rawFiles.length) return; setPhotoSplitBusy(true); setErrMsg(""); try { const entries = []; for (const f of rawFiles) { if (f.type === "application/pdf" || /\.pdf$/i.test(f.name)) { entries.push(...await expandPdfToImages(f)); } else { entries.push({ file: f, page: null, suggested: false, sketches: [] }); } } setPhotoPendingPages(entries.map((e) => ({ file: e.file, previewUrl: URL.createObjectURL(e.file), page: e.page, suggested: e.suggested, keep: e.suggested, sketches: e.sketches || [], }))); } catch (e) { setErrMsg("Ошибка обработки фото: " + e.message); } finally { setPhotoSplitBusy(false); } }; const togglePendingPage = (idx) => { setPhotoPendingPages((pages) => pages.map((p, i) => (i === idx ? { ...p, keep: !p.keep } : p))); }; const handlePhotoPagesCancel = () => { setPhotoPendingPages([]); }; const handlePhotoPagesConfirm = async () => { const kept = photoPendingPages.filter((p) => p.keep); if (!kept.length) return; setPhotoPendingPages([]); setPhotoLayoutBusy(true); setErrMsg(""); try { const items = []; for (const p of kept) { // p.token — страница уже лежит на сервере (пришла по ссылке с Яндекс.Диска), // повторно заливать не нужно. Иначе — обычный локальный файл. const token = p.token || await uploadFileChunked(p.file); const fd = new FormData(); fd.append("file_token", token); const r = await fetchWithRetry("/api/photo-layout", { method: "POST", body: fd }); if (!r.ok) throw new Error(await r.text()); const layout = await r.json(); items.push({ file: p.file, token: p.file ? undefined : token, previewUrl: p.previewUrl, // По умолчанию ВСЕ куски исключены (нейтральны) — менеджер явно // отмечает нужные (см. _allPieceKeys): "отметить нужные двери, а // не убирать лишнее" логичнее, когда на листе много посторонних // кусков (заголовки разделов, штамп, пустые поля). layout, excluded: _allPieceKeys(layout), notes: new Set(), header: new Set(), headerFields: {}, // Эскизы, найденные заранее по векторным линиям PDF (см. p.sketches // из /api/upload-link или /api/pdf-to-images) — сервер сопоставит их // со строкой при распознавании, см. handlePhotoRecognize. sketches: p.sketches || [], }); } setPhotoItems(items); } catch (e) { setErrMsg("Ошибка обработки фото: " + e.message); } finally { setPhotoLayoutBusy(false); } }; const updatePhotoItem = (idx, next) => { setPhotoItems((items) => items.map((it, i) => (i === idx ? next : it))); }; // Строки прилетают с сервера быстрее, чем менеджер успевает их читать (после // перехода на 1 запрос на строку вместо 7 на поле) — специально придерживаем // показ каждой следующей строки, чтобы за распознаванием реально можно было // успевать следить, а не только постфактум. Первая строка показывается сразу, // как только готова, дальше — по одной раз в REVEAL_DELAY_MS. const REVEAL_DELAY_MS = 3000; const scheduleReveal = () => { if (photoRevealTimerRef.current) return; const tick = () => { const queue = photoRevealQueueRef.current; if (queue.length === 0) { photoRevealTimerRef.current = null; return; } const next = queue.shift(); setPhotoRows((prev) => [...prev, next]); setPhotoPendingCount(queue.length); photoRevealTimerRef.current = setTimeout(tick, REVEAL_DELAY_MS); }; tick(); }; // Общий разбор ответа-потока NDJSON (строка = один JSON-объект): {row:{...}} // по каждой обработанной строке (для построчного экрана проверки), // {progress,total} для индикатора, в конце {done,text} или {error}. // Используется и обычной загрузкой файла, и загрузкой по ссылке с Яндекс.Диска. const consumePhotoStream = async (r) => { if (!r.ok) throw new Error(await r.text()); const reader = r.body.getReader(); const decoder = new TextDecoder(); let buf = ""; let finalText = null; let streamError = null; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); let nl; while ((nl = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (!line) continue; const evt = JSON.parse(line); if (evt.error) streamError = evt.error; else if (evt.done) finalText = evt.text; else if (evt.row) { photoRevealQueueRef.current.push({ ...evt.row, glazingFeedback: null }); setPhotoPendingCount(photoRevealQueueRef.current.length); scheduleReveal(); } else setPhotoProgress({ done: evt.progress, total: evt.total }); } } if (streamError) throw new Error(streamError); if (finalText == null) throw new Error("Пустой ответ сервера"); return finalText; }; const resetPhotoRecognizeState = () => { setPhotoBusy(true); setErrMsg(""); setPhotoProgress(null); setPhotoRows([]); setPhotoFinalText(null); setPhotoPendingCount(0); if (photoRevealTimerRef.current) { clearTimeout(photoRevealTimerRef.current); photoRevealTimerRef.current = null; } photoRevealQueueRef.current = []; }; const handlePhotoRecognize = async () => { if (!photoItems.length) return; resetPhotoRecognizeState(); try { // Грузим по одному файлу за раз (не Promise.all) — несколько параллельных // долгих передач на нестабильном канале только повышают шанс словить // обрыв сразу в нескольких местах одновременно. Если фото пришло по // ссылке (item.token) — оно уже лежит на сервере, заливать заново не надо. const tokens = []; for (const it of photoItems) tokens.push(it.token || await uploadFileChunked(it.file)); const fd = new FormData(); fd.append("file_tokens", JSON.stringify(tokens)); const layouts = photoItems.map((it) => ({ ...it.layout, excluded: Array.from(it.excluded).map((k) => k.split(",").map(Number)), notes: Array.from(it.notes).map((k) => k.split(",").map(Number)), header: Array.from(it.header || []).map((k) => k.split(",").map(Number)), headerFields: it.headerFields || {}, // Эскизы, найденные заранее по векторным линиям PDF (см. // main._find_page_sketches) — сервер сопоставит их со строками по // полю "Поз."/"Марка" (см. normalize_photo_stream в parser.py). sketches: it.sketches || [], })); fd.append("layouts", JSON.stringify(layouts)); // Ретрай только на уровне самого fetch() (обрыв до установки соединения) — // поток NDJSON после начала передачи здесь намеренно не перезапускаем // (уже показанные строки пришлось бы откатывать, это отдельная задача). const r = await fetchWithRetry("/api/normalize-photo", { method: "POST", body: fd }); const finalText = await consumePhotoStream(r); setPhotoFinalText(finalText); setPhotoReviewing(true); } catch (e) { setErrMsg("Ошибка распознавания фото: " + e.message); } finally { setPhotoBusy(false); setPhotoProgress(null); } }; // Обходной путь для тех, у кого стабильно рвётся прямая загрузка файла — // файл идёт с её устройства на Яндекс.Диск (это не режется), сервер сам // скачивает его оттуда и кладёт под токеном у себя, её соединение до нас в // передаче файла не участвует вообще. Одна ручка на все типы файлов (см. // /api/upload-link в main.py) — сервер сам решает, таблица это, PDF с // текстом или скан/фото, ровно как и handleUnifiedFileSelect для локальной // загрузки, только тип файла узнаём уже после скачивания. const [fileLink, setFileLink] = usePagesState(""); const [fileLinkBusy, setFileLinkBusy] = usePagesState(false); const handleUnifiedLink = async () => { const link = fileLink.trim(); if (!link) return; setFileLinkBusy(true); setErrMsg(""); try { const r = await fetchWithRetry("/api/upload-link", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: link }), }); if (!r.ok) throw new Error(await r.text()); const res = await r.json(); setFileLink(""); if (res.type === "text") { setFileName(res.filename); setFileKind(res.kind); setTableWarnings(res.warnings || []); setPhotoFallbackActive(false); setText(res.text); } else { setFileName(""); setFileKind(null); setTableWarnings([]); setPhotoFallbackActive(true); setPhotoPendingPages(res.items.map((it) => ({ token: it.token, previewUrl: "data:image/png;base64," + it.image_b64, page: it.page, suggested: !!it.suggested, keep: !!it.suggested, sketches: it.sketches || [], }))); } } catch (e) { setErrMsg("Ошибка обработки файла по ссылке: " + e.message); } finally { setFileLinkBusy(false); } }; // Подтверждение верно/неверно нужно только для находки по эскизу — OCR-текст // подтверждать бесполезно, поправить его подтверждением всё равно нельзя, // в отличие от эскиза (эти отметки копятся для дообучения модели Roboflow). const handlePhotoFeedback = async (rowIndex, confirmed) => { const row = photoRows[rowIndex]; if (!row || !row.glazing) return; setPhotoRows((prev) => prev.map((r, i) => (i === rowIndex ? { ...r, glazingFeedback: confirmed } : r)) ); try { await apiFetch("POST", "/photo-feedback", { kind: "glazing", field: null, value: row.glazing.panes.map((p) => `${p.width_mm}x${p.height_mm}`).join(", "), confirmed, image_b64: row.image_b64, scheme_b64: row.scheme_b64 || null, }); } catch (e) { console.error("photo-feedback", e); } }; const handlePhotoReviewDone = () => { setText(photoFinalText); setPhotoReviewing(false); setPhotoRows([]); setPhotoFinalText(null); }; const submitFileToken = async (token, filename, endpoint) => { const fd = new FormData(); fd.append("file_token", token); fd.append("filename", filename); const r = await fetchWithRetry(endpoint, { method: "POST", body: fd }); if (!r.ok) throw new Error(await r.text()); const res = await r.json(); setText(res.text); return res; }; const handleFileUpload = async (file, endpoint) => { if (!file) return; setFileBusy(true); setErrMsg(""); setFileName(file.name); setTableWarnings([]); try { const token = await uploadFileChunked(file); const res = await submitFileToken(token, file.name, endpoint); // res.kind — только у /api/normalize-table (см. main.py::_normalize_table_smart): // 'presplit', если таблицу удалось детерминированно прочитать по шапке // (столбцы по номеру, не угадывание LLM), иначе как раньше 'table'. // У /api/normalize-pdf такого поля нет — используем endpoint как раньше. setFileKind(res.kind || (endpoint === "/api/normalize-table" ? "table" : "pdf")); setTableWarnings(res.warnings || []); setPhotoFallbackActive(false); } catch (e) { // Обычный человек грузит сюда любой PDF, не разбирая заранее скан это // или нет — а причин, по которым чтение PDF как текста не удалось, две: // нет текстового слоя (см. normalize_pdf_file в parser.py — формулировка // "похоже, это скан...") ИЛИ слишком много текста для одного запроса // де-колонизации (реальный случай — большой архитектурный чертёж на // много страниц упирается в лимит токенов/минуту у OpenAI). Средство // одно и то же в обоих случаях — не заставляем самого разбираться, // продолжаем тем же файлом через отбор страниц + распознавание по фото. if (endpoint === "/api/normalize-pdf") { setFileName(""); setFileKind(null); setPhotoFallbackActive(true); await handlePhotoSelect([file]); } else { setErrMsg("Ошибка чтения файла: " + e.message); } } finally { setFileBusy(false); } }; // Единая точка входа для локальной загрузки на вкладке "Файл" — тип файла // определяем сами по расширению, чтобы не заставлять менеджера выбирать // формат (было 4 вкладки: Текст/Таблица/PDF/Фото — путало, см. историю). // Несколько файлов или файл без распознанного табличного/PDF-расширения — // считаем фото-заявкой (страницы/сканы), единственный xlsx/csv/pdf — // читаем как таблицу/текст соответствующей ручкой. const handleUnifiedFileSelect = async (fileList) => { const files = Array.from(fileList || []); if (!files.length) return; const first = files[0]; const name = (first.name || "").toLowerCase(); const isTable = /\.(xlsx|xls|csv)$/.test(name); const isPdf = first.type === "application/pdf" || /\.pdf$/.test(name); if (files.length === 1 && isTable) { await handleFileUpload(first, "/api/normalize-table"); } else if (files.length === 1 && isPdf) { await handleFileUpload(first, "/api/normalize-pdf"); } else { setFileKind(null); setPhotoFallbackActive(true); await handlePhotoSelect(files); } }; const handleCalc = async () => { if (!text.trim()) return; setBusy(true); setErrMsg(""); setStatusMsg("Отправляем заявку…"); try { const tierMap = { "Розница": 1, "от 5 шт": 5, "от 10 шт": 10, "от 50 шт": 50, "от 100 шт": 100 }; const res = await apiFetch("POST", "/requests", { from_email: email || "", from_name: "", mode: tip === "Тендер" ? "tender" : "retail", input_type: photoFallbackActive || fileKind === "presplit" ? "presplit" : fileKind === "table" ? "table" : fileKind === "pdf" ? "pdf" : "text", include_install: mont !== "Нет", price_qty_override: tierMap[qtyTier] || null, delivery_zone: delivery, text, text_original: originalText || null, notes: note, }); setStatusMsg(""); await waitReady(res.id); onCalc(res.id); } catch (e) { setErrMsg("Ошибка: " + e.message); setBusy(false); setStatusMsg(""); } }; const calcInputType = () => photoFallbackActive || fileKind === "presplit" ? "presplit" : fileKind === "table" ? "table" : fileKind === "pdf" ? "pdf" : "text"; const handleReconcile = async () => { if (!kpFile && !kpLink.trim()) return; setKpBusy(true); setKpErr(""); try { const fd = new FormData(); fd.append("zayavka_text", text); fd.append("input_type", calcInputType()); fd.append("mode", tip === "Тендер" ? "tender" : "retail"); if (kpFile) fd.append("kp", kpFile); else fd.append("kp_link", kpLink.trim()); const r = await fetchWithRetry("/api/reconcile-kp", { method: "POST", body: fd }); if (!r.ok) throw new Error(await r.text()); const data = await r.json(); setShowReconcile(false); setKpFile(null); setKpLink(""); onReconcile(data); } catch (e) { setKpErr("Ошибка сверки: " + e.message); } finally { setKpBusy(false); } }; return (

Новый расчёт

Тип
Формат текста { setFmt(v); setPhotoFallbackActive(false); setFileKind(null); }} />
Монтаж
Ценовая группа
Доставка
{fmt === "Файл" && (
Файл заявки (таблица, PDF или фото — необязательно, можно просто вставить текст ниже) {!fileBusy && !photoFallbackActive && !photoSplitBusy && !photoLayoutBusy && !photoBusy && !photoReviewing && photoRows.length === 0 && photoPendingPages.length === 0 && photoItems.length === 0 && (
или по ссылке
setFileLink(e.target.value)} disabled={fileLinkBusy} />
)} {fileBusy && Читаем файл…} {fileName && !fileBusy && {fileName} — прочитан, проверьте текст ниже} {tableWarnings.length > 0 && (
Проверьте перед расчётом:
{tableWarnings.map((w, i) => (
{w}
))}
)} {photoSplitBusy && Разбираем PDF на страницы…} {photoLayoutBusy && Определяем разметку…} {photoPendingPages.length > 0 && ( <>
{photoPendingPages.some((p) => p.suggested) ? "Похоже, нашли страницы с ведомостью проёмов — отметили их, можно поправить выбор. Клик по странице — выбрать/снять." : "Отметьте страницы, которые относятся к заявке — на них уйдёт разметка, остальные не учитываются. Клик по странице — выбрать/снять."}
{photoPendingPages.map((p, i) => (
togglePendingPage(i)} > {`Страница {p.page || i + 1} {p.suggested && похоже на нужную} {p.keep && }
))}
)} {photoItems.length > 0 && !photoLayoutBusy && !photoBusy && !photoReviewing && photoRows.length === 0 && ( <>
Так фото будет прочитано по кускам. По умолчанию всё исключено — выберите режим в панели ниже и отмечайте нужные куски: «Дверь» — позиция для расчёта, «Шапка» — строка с подписями столбцов (код сам определит Марку/Обозначение/Кол-во и применит ко всем строкам ниже), «Примечание» — общие указания, не дверь. Повторный клик тем же режимом снимает отметку. «Разметка» — клики по кускам не действуют, только двигайте линии между ними. «Линии» — клик внутри куска режет его в этой точке; клик по самой линии (без перетаскивания) убирает её, объединяя соседние куски. «Сбросить разметку» под фото — начать заново с нуля.
{photoItems.map((item, i) => ( updatePhotoItem(i, next)} /> ))}
)} {(photoBusy || photoPendingCount > 0 || photoReviewing || photoRows.length > 0) && ( 0} progress={photoProgress ? { done: photoRows.length, total: photoProgress.total } : null} /> )}
)}