Накладная на 3 страницах: как ScanFlow собирает её обратно в один документ

Содержание
  1. Бумажная накладная не всегда умещается в 1 кадр
  2. Как обычно клеят: бухгалтер вручную
  3. Как это работает в ScanFlow
  4. Что важно учесть
  5. FAQ
  6. Что дальше

Если у поставщика 40-100 позиций в одной накладной — а это нормальный размер у крупного опта в продуктах, стройматериалах, авто-запчастях, — на бумаге она физически не помещается на один лист. Печатается на трёх-четырёх страницах с одним и тем же номером, одной и той же шапкой, и сквозной нумерацией строк. Оператор фотографирует её так же — стопкой кадров, по одной странице за раз. Дальше начинается интересное: что должна сделать OCR-система, чтобы не превратить четыре кадра в четыре отдельные накладные в базе.

В этой статье — как именно ScanFlow распознаёт, что несколько фотографий принадлежат одной бумажной накладной, и собирает их обратно в один документ; что внутри функции findRecentByNumber; и в каких случаях алгоритм осознанно отказывается мерджить и оставляет решение оператору.

Бумажная накладная не всегда умещается в 1 кадр

Сценарий рутинный. Привезли поставку: 4-страничная ТОРГ-12, 60 позиций, две тележки на склад. Кладовщик принимает товар, расписывается. Бухгалтер берёт у него накладную, чтобы провести в учётной системе. На столе четыре листа A4 — он по очереди фотографирует их телефоном и заливает в ScanFlow. Четыре кадра уходят в обработку.

Без специальной логики мерджа в базе появятся четыре независимые записи в таблице invoices. У каждой будет один и тот же номер (потому что на каждой странице в шапке стоит «№ 2841»), один и тот же ИНН поставщика, одинаковая дата, разные подмножества позиций. С точки зрения 1С — это четыре прихода с одинаковыми реквизитами, которые потом будут конфликтовать на выгрузке: либо три из них поставщик откажется проводить, либо они проведутся и склад покажет учётверённый объём поставки.

У крупного опта это типовая история. На 100-позиционной накладной редко удаётся уложиться в один лист, даже мелким шрифтом и с переносами. В строймате это норма — щебень, цемент, арматура, профили, метизы, — каждая позиция со своим артикулом, и за один заезд приезжает машина на 80-150 строк. Если OCR-система не умеет склеивать страницы — она в принципе не пригодна для такого клиента: бухгалтер всё равно будет руками собирать четыре документа в один.

Как обычно делают: бухгалтер вручную клеит позиции

В отсутствие автоматики процесс выглядит так. Бухгалтер видит в очереди распознанных документов четыре строки с одним и тем же номером 2841. Открывает первую, копирует её позиции в буфер мысленно или в Excel. Открывает вторую, дописывает позиции туда же. Третью и четвёртую — так же. Потом удаляет три дубликата и оставляет одну запись со всеми 60 позициями. Сверяет ИТОГО внизу с тем, что напечатано на четвёртой странице бумажной накладной.

На одну такую процедуру уходит 5-10 минут, и в ней легко ошибиться четырьмя разными способами, каждый из которых выстреливает в проде регулярно.

Пропущенная страница. Оператор сфотографировал три из четырёх, а четвёртую забыл или она прилипла к третьей. В накладной не хватает 15 позиций, итоговая сумма не сходится, но если бухгалтер мерджит руками без сверки итогов — этого можно вообще не заметить до квартальной инвентаризации.

Дубль позиции на стыке страниц. Бывает, что последняя строка стр. 1 продублирована как первая строка стр. 2 — типографский «висячий» перенос для удобства чтения. При ручной склейке такая строка попадает в итог дважды, и накладная завышается на одну позицию.

Дублирование подытогов. На промежуточных страницах внизу часто стоит «Подитог по странице: 12 345.00 ₽», а на последней — «Итого: 184 200.00 ₽». При ручной склейке легко скопировать промежуточный подитог в общий список как обычную позицию.

Перепутанные накладные. Если в один день привезли две поставки от одного и того же поставщика с близкими номерами (2841 и 2842), при ручной сборке страницы запросто перепутать местами. Особенно если фотографии в галерее лежат не в порядке съёмки, а отсортированы по «качеству» галереей телефона.

Всё это решаемо вниманием — но внимания на каждую накладную в потоке 50 шт. в день не хватит. Этот класс ошибок и должна снимать автоматика.

Как это работает в ScanFlow

Идея простая: после OCR каждой страницы мы спрашиваем у базы — «не приходила ли за последние несколько минут накладная с тем же номером и тем же поставщиком?». Если да — позиции добавляются к существующей записи. Если нет — создаём новую. Окно в несколько минут (по умолчанию 10) — это компромисс: достаточно долго, чтобы оператор успел сфотографировать все страницы стопкой; достаточно коротко, чтобы две независимые поставки от одного поставщика в течение дня не склеились случайно.

Алгоритм мерджа: общий номер + поставщик

Точка входа — функция findRecentByNumber в src/database/repositories/invoiceRepo.ts. После того как Claude вернул структурированный JSON для свежего фото, парсер забирает из него invoice_number и supplier (имя + ИНН) и идёт искать кандидата.

// src/database/repositories/invoiceRepo.ts
async findRecentByNumber(
  invoiceNumber: string,
  supplier?: string,
  withinMinutes: number = 10,
): Promise<Invoice | undefined> {
  const targetNormalized = normalizeInvoiceNumber(invoiceNumber);
  if (!targetNormalized) return undefined;

  const candidates = await getDb().prepare(
    `SELECT * FROM invoices
     WHERE invoice_number IS NOT NULL AND invoice_number != ''
       AND created_at > (NOW() - INTERVAL ${withinMinutes} MINUTE)
       AND status IN ('processed', 'parsing', 'ocr_processing')
     ORDER BY created_at DESC`,
  ).all<Invoice>();

  for (const candidate of candidates) {
    if (normalizeInvoiceNumber(candidate.invoice_number) === targetNormalized) {
      if (!supplier || candidate.supplier === supplier) return candidate;
    }
  }
  // ...digit-only fallback см. ниже
}

Если кандидат нашёлся — оригинальная запись остаётся в БД, к ней UPSERT-ятся новые позиции в invoice_items с продолжающимся row_no, обновляется invoice.pages (счётчик страниц) и пересчитывается флаг items_total_mismatch на свежем объединённом наборе позиций. Дубль-запись в invoices, которую мы успели создать «авансом» под текущее фото, удаляется в той же транзакции — мерж атомарен, в БД никогда не оказывается двух полу-склеенных половин.

Если кандидата нет — создаётся новая запись, как обычно. Никакой эвристики «может, это одинокая страница из набора» нет: пока мы не увидели вторую страницу, у нас нет оснований считать накладную многостраничной.

Что внутри normalizeInvoiceNumber

Самая тонкая часть — это не сам мерж, а нормализация номера перед сравнением. На разных страницах одной и той же накладной OCR может прочитать номер по-разному: в шапке стр. 1 «№ 2841», в шапке стр. 2 «2841/2026», в подвале стр. 3 «No 2841», на стр. 4 «N° 2841». А ещё хуже — кириллические буквы, которые визуально неотличимы от латинских: «ВМ-611» (кириллица) и «BM-611» (латиница) для глаза одинаковы, для побайтового сравнения — два разных номера.

Чтобы это работало, перед сравнением мы прогоняем номер через нормализацию (src/utils/invoiceNumber.ts):

export function normalizeInvoiceNumber(num: string | null | undefined): string {
  if (!num) return '';

  // 1. Trim + uppercase (маппит как латиницу, так и кириллицу)
  let result = num.trim().toUpperCase();

  // 2. Кириллические гомоглифы → латинские эквиваленты
  //    А → A, В → B, С → C, Е → E, Н → H, К → K, М → M,
  //    О → O, Р → P, Т → T, Х → X, У → Y
  result = result.split('').map(ch => CYRILLIC_TO_LATIN[ch] || ch).join('');

  // 3. Срезаем ведущие № / #
  result = result.replace(/^[№#]+/, '');

  // 4. Убираем все разделители: пробелы, дефис, подчёркивание,
  //    точка, слэш, обратный слэш
  result = result.replace(/[\s\-_./\\]+/g, '');

  return result;
}

На этой канонизации «№ 2841», «No-2841», «2841/2026», «ВМ-611» и «BM 611» сводятся к одной строке, по которой и происходит точное сравнение. Дополнительно есть fallback по чистым цифрам — extractDigitSequence вынимает из строки только числовую часть. Это спасает в граничном случае, когда OCR на одной странице прочитал префикс «МСМС-40626», а на другой — просто «40626»: цифровая часть совпадает, supplier совпадает фаззи-матчем (suppliersMatch), мердж проходит.

Какой страницей подписывается merged-документ

У накладной в БД есть одно «главное» фото — оно показывается в дашборде превьюшкой и уезжает в 1С как файл-приложение к документу. Когда мы склеиваем несколько страниц в одну запись, нужно выбрать, какой именно файл назначить главным.

Логика: «главной» становится страница с подписями (отпустил / принял / груз получил) и итоговой суммой. Это всегда последняя страница накладной — на ней есть «Всего к оплате», «Подписано», иногда печать. С точки зрения 1С это правильное приложение, потому что именно эта страница юридически замыкает документ. Если страницы пришли в порядке съёмки — мы её увидим последней; если оператор сфотографировал в произвольном порядке — мы детектируем подпись/итог в OCR-результате и обновляем file_name на эту страницу. Остальные фото остаются в файловой системе в data/processed/; в дашборде у накладной можно открыть полный набор страниц одной кнопкой.

Что важно учесть

Разные форматы номеров: №2841, 2841/2026, No 2841

Канонизация работает на 95% реальных накладных, но в оставшихся 5% есть пограничные кейсы, в которых мы осознанно не пытаемся быть умнее людей.

«2841-А» vs «2841/А» — формально могут быть и одной накладной (просто на одной странице разделитель «-», на другой «/»), и двумя разными (например, основная партия и «довоз», у которого свой суффикс). По нашей канонизации они равны — оба превратятся в 2841A. Если поставщик при этом тот же — мердж пройдёт. Если оператор увидит, что «довоз» из 5 позиций неожиданно дописался к основной поставке из 60 — он может разделить их обратно через UI (см. FAQ).

«б/н от 30.01.2026» — накладная без номера, с пометкой «без номера». Бывает у мелких поставщиков и при внутренних перемещениях. После нормализации получаем пустую строку (леттер «б/н» воспринимается как сепаратор), и наша функция возвращает '' — мердж не отрабатывает в принципе. Это правильное поведение: без номера у нас нет надёжного признака «та же накладная», и склеивать два «б/н» от одного поставщика в один день — не вариант, можем перепутать две разные поставки. Оставляем как две записи, оператор разрулит.

Разные года в номере. «2841/2025» и «2841/2026» — это две разные накладные одного поставщика в декабре-январе, на стыке годов. После нашей нормализации они превращаются в 28412025 и 28412026 — точное сравнение их различает, мердж не происходит. Это правильно, и поэтому мы не вырезаем год отдельно — была бы реальная регрессия.

Лучше пометить как incomplete_multipage и спросить оператора, чем тихо отгрузить в 1С неполную накладную.

Оторванная подпись: что если страница ИТОГО не загружена

Бывает, что оператор успел сфотографировать первые 2-3 страницы из 4, а на четвёртую отвлёкся и забыл. Накладная попадает в базу неполной: позиции вроде есть, но «Всего к оплате» и подписи отсутствуют. С точки зрения автоматического импорта в 1С это опасное состояние — мы можем посчитать сумму по тем позициям, которые видим, не зная, что страницы 4 в принципе не было.

Не пересчитываем total из позиций «втихую». Соблазн посчитать «давайте просто просуммируем items.total и используем это значение» — заманчивый, но опасный. Если оператор не доснял страницу 3 из 4, наша «сумма по позициям» будет на 25% меньше реальной, и накладная улетит в 1С с заниженной суммой. Лучше пометить накладную как incomplete_multipage, подсветить жёлтым в дашборде и приостановить автоматическую отправку — пока оператор не подтвердит, что состав полный.

В дашборде такая накладная отображается жёлтой плашкой «Неполная — нет итога/подписи?». Кнопка «Отправить в 1С» в этом состоянии заблокирована: автоматическая отправка пропускает такие записи. Оператор либо досылает недостающую страницу (она автоматически домерджится в существующую запись, если уложилась в окно мерджа), либо подтверждает, что состав полный — вручную сверившись с бумажным оригиналом, — и тогда отправка разблокируется.

💡

Фотографируйте страницы подряд и с одного ракурса. Лучший способ помочь мерджу — снять все страницы быстро, одну за другой, с одного и того же расстояния, на одинаковом фоне. Тогда у Claude одинаковый контекст для распознавания шапки на каждой странице, номер читается стабильно, и нормализация почти всегда сводит варианты к одной строке. Если страницы сняты с интервалом в полдня под разным освещением — растёт шанс, что номер на одной из страниц прочитается с ошибкой, и она не приклеится к остальным.

FAQ

Можно ли вручную склеить накладные, если мердж не сработал?

Да. У каждой накладной в дашборде есть кнопка «Объединить с…»: открывается список последних поступлений того же поставщика, можно выбрать целевую — позиции из текущей перенесутся в неё, флаг file_name обновится на ту страницу, где есть подпись и ИТОГО, а текущая запись помечается как merged_into=<id> и убирается из активной очереди. Оригинальные фотографии при этом сохраняются обе — если потом потребуется развести записи обратно, исходники под рукой.

Если за 10 минут не доснял — что делать?

Загрузить позже. ScanFlow создаст вторую запись (потому что окно мерджа уже закрылось), и в дашборде появятся две накладные с одним номером 2841. Откройте любую из них, нажмите «Объединить с…», выберите парную — и они склеятся как при автомерже. Окно в 10 минут — это компромисс: при большем окне (скажем, час) повышается риск, что пакетная загрузка одной поставки случайно склеится с другой накладной того же поставщика, пришедшей в тот же интервал; при меньшем (скажем, минута) — реальные кейсы «сходил на склад за второй страницей и принёс через пять минут» не успевают.

А если две накладные пришли в один день от одного поставщика с одинаковым номером?

Бывает у крупных оптовиков с разделением нумерации по складам: на каждом из складов своя сквозная нумерация, и в один день два разных склада одного юрлица могут выписать №2841. По нашему алгоритму они с большой вероятностью склеятся в одну запись в БД — это известное ограничение. Оператор увидит это сразу: позиций в накладной заметно больше ожидаемого, ИТОГО не совпадает с тем, что напечатано ни на одной из страниц. В дашборде есть зеркальная кнопка «Разделить накладную», которая возвращает позиции по двум исходным записям — на основе того, какой кадр их принёс. Исходные фото у нас сохранены, поэтому split-операция всегда обратима.

Что дальше

Мы разобрали, как ScanFlow видит, что несколько фотографий принадлежат одной бумажной накладной, и собирает их обратно в один документ. Связка — нормализация номера + точное сравнение supplier'а + временное окно, плюс осознанная остановка алгоритма в граничных случаях, где автоматика не уверена и решение должно быть за оператором.

Если хочется глубже копнуть в смежные темы:

Хотите попробовать на своих многостраничных накладных — зарегистрируйтесь, первые пять распознаваний бесплатно. Возьмите одну реальную 3-4 страничную накладную, сфотографируйте все страницы и залейте подряд — посмотрите, как она появится в дашборде одной записью с полным набором позиций.