// ferro-data.jsx — helpers, API layer, data mapping for Ferrolend КП const RUB = (n) => (Math.round(n) || 0).toLocaleString("ru-RU").replace(/ /g, " ") + " ₽"; const NUM = (n) => (Math.round(n) || 0).toLocaleString("ru-RU").replace(/ /g, " "); const GLASS_NOTE = "Двери максимального остекления (ст/п. 24мм), сталь 1,5мм"; // ── Типы дверей, которые умеет считать прайс (для подсказки в выпадающем списке) ── window.DOOR_TYPE_OPTIONS = [ "Двери металлические распашные", "Двери металлические распашные противопожарные EI-60", "Двери металлические распашные противопожарные EIS-60", "Двери металлические распашные противопожарные EI-90", "Двери металлические распашные противопожарные EIS-90", "Двери решетчатые", "Двери с максимальным остеклением", "Двери с максимальным остеклением EIS-60", "Ворота распашные металлические", "Ворота распашные металлические противопожарные EI-60", "Люки металлические распашные", "Люки металлические распашные противопожарные EI-60", "Люки металлические распашные противопожарные EIS-60", ]; // ── Типы доводчика (для позиции-доводчика, см. pricing.split_closer_positions) ── // «На обе створки» больше не отдельный вариант текста — это просто количество // 2 у обычного «Доводчик рычажный» (см. extractor.detect_closer/доводчик_количество). window.CLOSER_TYPE_OPTIONS = [ "Доводчик рычажный", "Доводчик скользящий", "Notedo 025-095 со скользящей тягой", "Доводчик NOTEDO DC-120 серебро", ]; // ── Наличник: типовые ширины 20–100мм (шаг 10) + без наличника + обратный // (автоматически при внутреннем открывании, см. extractor.detect_nalichnik) ── window.NALICHNIK_OPTIONS = [ "без наличника", "20мм", "30мм", "40мм", "50мм", "60мм", "70мм", "80мм", "90мм", "100мм", "обратный наличник", ]; // ── Открывание ── window.OPENING_OPTIONS = ["левая", "правая", "?"]; // ── Зоны доставки: цена известна только "в пределах СПб", остальные — ручной ввод менеджером ── window.DELIVERY_ZONES = [ { label: "По СПб", cost: 5500 }, { label: "До 10 км", cost: null }, { label: "До 25 км", cost: null }, { label: "До 50 км", cost: null }, { label: "До 75 км", cost: null }, { label: "До 100 км", cost: null }, ]; // ── Что нового: список изменений для колокольчика в шапке, новые сверху, обновляется вручную при каждом деплое ── window.CHANGELOG = [ { date: "2026-08-20", text: "Расширил фикс \"Створки N/M\" на разные написания — раньше ловил только \"/\", теперь ещё дефис, длинное/короткое тире, \"х\"/\"x\"/\"×\", запятую, союз \"и\", с \":\"/\"мм\" между словом и числами или без (\"Створки: 900х300\", \"полотна 900мм/300мм\", \"створки 900 и 300\" и т.п.) — не только тот единственный формат, что был в разбираемой заявке" }, { date: "2026-08-20", text: "Догон вчерашнего фикса \"Створки 900/300\": расчёт (раб. створка 900мм) уже был верным, но в попапе \"Текст заявки\" (вкладки \"Эта позиция\"/\"Вся заявка\") продолжал показываться СЫРОЙ вывод ИИ-нормализатора со старым \"Конструкция: 2-ств, равнопол.\" — таблица и попап противоречили друг другу. Теперь при срабатывании этого фикса текст в попапе тоже правится на \"неравнопол. (рабочая створка N мм)\", согласовано с расчётом" }, { date: "2026-08-20", text: "Баг с деньгами/фурнитурой: заявка \"Створки 900/300\" (явно РАЗНЫЕ створки — рабочая 900мм, пассивная 300мм) — ИИ-нормализатор иногда ошибочно писал \"Конструкция: 2-ств, равнопол.\", хотя это прямо противоречит числам в тексте клиента. Обработчика для формулировки \"Створки N/M\" не было вообще (был только для симметричного случая \"ширина ОДНОЙ створки N\"). Теперь: если в исходном тексте клиента два РАЗНЫХ числа рядом со словом \"створки\" — результат берём напрямую из этих чисел (большее = рабочая створка), а не из вывода нормализатора" }, { date: "2026-08-20", text: "Баг: подсказка у размера (проём/короб) показывала не дословную цитату из заявки, а свою же вычисленную строку вида \"2070×1250 мм (проём)\" — хотя клиент написал \"Размер проёма 1250/2070мм\". Теперь та ячейка, что реально найдена в тексте (не рассчитана по формуле от второй), цитирует заявку дословно; если рядом со словом \"проём\"/\"коробка\" пару чисел найти не удалось — честно оставляет прежнее вычисленное описание, а не гадает по первой попавшейся паре чисел в заявке (например размеру створок)" }, { date: "2026-08-20", text: "Баг: RAL считался верно (клиент написал \"дверь белую\" → 9016), но подсказка показывала \"по умолчанию\" — цветовые слова (белый/серый/коричневый) искались только в мужском роде именительного падежа, а \"белую\"/\"белая\"/\"серая\" и т.п. не совпадали. Теперь ловятся все родовые/падежные окончания" }, { date: "2026-08-20", text: "Баг: в колонке \"Раб. створка\" значение показывало \"равнопольная\", а подсказка рядом цитировала слово \"двухстворчатая\" — потому что за значение и за подсказку отвечали два разных места в коде с разными правилами, и второе (подсказка) не знало про формулировки вроде \"створки одинаковые по ширине\", только про буквальное слово \"равнопольная\". Теперь оба места используют одно и то же правило, вдобавок починил опечатку \"однаковые\" → \"одинаковые\", из-за которой часть таких формулировок вообще не срабатывала" }, { date: "2026-08-20", text: "Добавлена подсказка \"откуда взято значение\" и на колонку \"Проект\" (марка/маркировка позиции, например Д-1) — раньше её там не было вообще" }, { date: "2026-08-20", text: "Исправлены два бага новой подсказки: 1) подсказка улетала в левый верхний угол экрана вместо появления у курсора — причина в CSS-обёртке (display:contents ломает getBoundingClientRect), теперь позиция считается прямо от координат курсора; 2) блок примечания \"Наружная - сталь ...\" всегда показывал \"по умолчанию\", даже когда клиент явно написал \"наружная\" — потому что наружная трактовка технически решается через отдельный код-хинт, а не напрямую по слову клиента; теперь подсказка ищет в тексте клиента то самое слово, которое привело к уже посчитанному значению" }, { date: "2026-08-20", text: "Подсказки \"откуда взято значение\" в КП переделаны: вместо нативной всплывающей подсказки браузера (голое слово без контекста) — своя подсказка с кусочком текста ДО и ПОСЛЕ найденной фразы (по 10 символов с каждой стороны), а сама фраза подсвечена жёлтым маркером — сразу видно, из какого места заявки взято значение. Работает везде: тип двери, размер, раб. створка, порог, RAL, наличник, сторона, замок, ручка, доводчик, каждый блок примечания" }, { date: "2026-08-20", text: "На вкладке \"Оригинал\" в попапе \"Текст заявки\" можно отредактировать текст заявки прямо на месте: карандашик ✎ сверху справа — текст под ним становится редактируемым тут же, без отдельных попапов и блоков; галочка ✓ слева от него сохраняет и сразу пересчитывает заявку. Для случаев, когда заявка текстовая, а сверху приложен чертёж/фото с деталями, которых нет в тексте (например \"наружная, равнопольная\" у одной из 15 дверей) — правку нужно вписать ровно туда, где в тексте описана нужная позиция, не в конец" }, { date: "2026-08-20", text: "Подсказки \"Из текста заявки\" на всех полях (порог, RAL/цвет, наличник, сторона, ручка, замок, доводчик, тип двери, раб. створка, блоки примечания) теперь берут цитату из ОРИГИНАЛЬНОГО текста клиента, а не из текста, переписанного ИИ-нормализатором перед расчётом — раньше подсказка могла показывать \"RAL 1015\", хотя клиент написал \"RAL - 1015\", и т.п. Расчётные значения не изменились, поменялся только текст цитаты в подсказке" }, { date: "2026-08-20", text: "Исправлено: ручка снова ложно подменялась на \"систему антипаники\", когда в тексте (уже после нормализации) между словом \"замок\" и \"антипаника\" оказывалось длинное описание модели (\"Замок: цилиндр кл-верт, врезной противопожарный Apecs 2000-Panic-ZN с функцией антипаника\" — ~80 символов между ними, старая проверка смотрела только 20 символов вокруг). Теперь для лейблированного поля \"Замок: ...\" расстояние не ограничено — антипаника внутри поля замка не трогает ручку" }, { date: "2026-08-20", text: "Попап «Текст заявки» раньше показывал только сырой текст (до ИИ-нормализации) — из-за этого не было видно, когда нормализация теряла детали при сжатии в поля. Теперь 3 вкладки: «Эта позиция»/«Вся заявка» показывают именно то, что реально ушло в расчёт (после нормализации), «Оригинал» — текст как прислал клиент, без изменений. Для фото-заявок отдельно осталась вкладка «Фото до OCR»" }, { date: "2026-08-20", text: "Найдена и исправлена настоящая причина, почему порог/модель замка не подхватывались: ИИ-нормализатор (сжимает текст заявки в короткие поля перед расчётом) на нестандартных формулировках («минимальный порог», конкретная модель замка вроде Apecs 2000-Panic-ZN) иногда выбрасывает эти детали целиком из своего вывода — расчёт их просто не видел, хотя в исходном тексте заявки (та самая всплывающая подсказка) всё было на месте. Добавлены те же защитные подсказки-дублёры, что уже были для антипаники: если после нормализации порог/бренд замка пропал — подставляем обратно из исходного текста перед расчётом" }, { date: "2026-08-20", text: "Исправлено: подсказка на размерах в КП врала — обе пары ячеек (проём и короб) показывали одни и те же числа короба, подписанные \"Из текста заявки\", даже когда в заявке был указан проём, а короб просто посчитан по формуле (−20/−40мм). Теперь у проёма и короба разные подсказки: найденная пара помечена как из текста, вторая — как рассчитанная" }, { date: "2026-08-20", text: "Добавлено распознавание конкретной модели замка \"Apecs *-Panic-*\" (сертифицированный противопожарный врезной замок со встроенной антипаникой) — теперь берётся дословно из заявки вместо стандартного Fuaro/DL V1901, цилиндр (кл-вертушка/кл-кл) определяется как обычно" }, { date: "2026-08-20", text: "Исправлено на реальной заявке: RAL с пробелами вокруг тире (\"RAL - 1015\") не распознавался и уходил в цвет по умолчанию \"стандарт\"; \"минимальный порог\" (без цифры) теперь распознаётся как 14мм; ручка больше не подменяется на \"систему антипаники\", если в тексте замок описан как \"с функцией антипаника с обычной ручкой\" (антипаника — функция механизма замка, а не отдельная система, заменяющая ручку)" }, { date: "2026-08-20", text: "В примечании КП каждый кусочек (цинкогрунт, СКУД, эл-магн. замок, координатор, нержавейка, антипаника и т.д.) теперь показывает свою подсказку при наведении — откуда в тексте заявки он взят; если менеджер переписал примечание вручную, подсказки не показываются (текст уже не совпадает с автосборкой)" }, { date: "2026-08-20", text: "В КП появилась подсказка при наведении на значение (тип двери, размер, раб. створка, порог, RAL/цвет, наличник, сторона открывания, замок, ручка, доводчик): показывает точный кусок текста заявки, откуда взято значение, или «по умолчанию — не указано», если это предположение системы, а не то, что явно написал клиент" }, { date: "2026-08-20", text: "Исправлено: при системе антипаники замок и ручка в КП дублировали друг друга дословно одним и тем же текстом — теперь описываются по-разному (замок как замок, ручка как ручка), хотя это один физический комплект" }, { date: "2026-08-18", text: "Загрузка файла в «Новом расчёте» — теперь одна вкладка «Файл» вместо «Таблица»/«PDF»/«Фото»: тип файла (таблица, PDF или фото) определяется автоматически, выбирать формат больше не нужно" }, { date: "2026-08-18", text: "Карточка загрузки файла стала аккуратнее — можно перетащить файл в окно или нажать, чтобы выбрать; ссылка на Яндекс.Диск — в том же блоке" }, { date: "2026-08-18", text: "PDF-заявки: подписи на эскизе двери (например, размер и положение остекления) теперь попадают в текст позиции, а не теряются молча" }, { date: "2026-08-18", text: "В редакторе разметки фото можно вручную разрезать кусок на две позиции (двойной клик) или, наоборот, объединить два куска обратно в один (клик по линии между ними), а также сбросить разметку куска и начать заново" }, { date: "2026-08-18", text: "Распознавание таблиц в фото/PDF заявок с тонкими линиями между строками стало точнее — раньше некоторые такие таблицы резались на строки неправильно" }, { date: "2026-08-18", text: "Исправлено: упоминание слова «коробка»/«проём» не по поводу размера (например, «коробка в цвет двери») больше не сбивает автоопределение высоты 2100 мм по ГОСТ" }, { date: "2026-08-18", text: "Сверка тендерной ведомости: добавлена AI-проверка спорных расхождений («не найдено»/«неоднозначно») — вторая пара глаз для случаев, которые не разрешились автоматически" }, { date: "2026-08-17", text: "Дверь EI-90 теперь помечается уведомлением менеджеру «запросить стоимость двери у поставщика» и пометкой «Добровольная сертификация» первой строкой в примечании" }, { date: "2026-08-17", text: "При внутреннем открывании двери автоматически ставится обратный наличник, а в примечании первой строкой добавляется «Дверь внутреннего открывания»" }, { date: "2026-08-17", text: "Наценка за внутреннее открывание (+600/+800 ₽) теперь распознаётся и по формулировкам через тире («Открывание – вовнутрь»), и в обратном порядке слов («Направление открывания: внутреннее»)" }, { date: "2026-08-12", text: "При загрузке фото заявки теперь можно выбрать файл из галереи или файлового менеджера — раньше сразу открывалась камера" }, { date: "2026-08-11", text: "Загрузка фото/PDF/таблиц стала надёжнее при нестабильном интернете — при обрыве соединения система сама повторяет отправку" }, { date: "2026-08-11", text: "Исправлено: доводчик в Excel-выгрузке КП иногда задваивался при пересчёте позиции, а такелаж и доставка не попадали в файл" }, { date: "2026-08-11", text: "Исправлено: система антипаники (Panic/пушбар в описании фурнитуры) иногда не распознавалась при вводе текстом" }, { date: "2026-08-10", text: "Распознавание шапки таблицы в фото/PDF заявок теперь работает с любыми формулировками заголовков столбцов, а не только с одним заранее известным форматом" }, { date: "2026-08-10", text: "После распознавания фото — построчный экран проверки: видно каждую позицию с картинкой, найденный размер стекла округляется до 50 мм и показывается вместе с точностью" }, { date: "2026-08-09", text: "Добавлено автоматическое определение примерного размера остекления по эскизу двери на фото" }, { date: "2026-08-07", text: "Распознавание фото заявок стало заметно точнее — новый OCR-сервис лучше читает кириллицу и цифры размеров, чем раньше" }, { date: "2026-08-07", text: "В редакторе разметки фото можно отметить строку с названиями столбцов таблицы («Шапка», 3-й клик по куску) — тогда код сам понимает, где марка, где обозначение, где количество и т.д., и точнее режет остальные строки" }, { date: "2026-08-07", text: "Исправлено: высота 2100 мм без уточнения «по коробке»/«по проёму» теперь всегда считается размером проёма (по ГОСТ), как и задумано — раньше это иногда сбивалось на этапе автонормализации текста" }, { date: "2026-08-06", text: "Форматы «Таблица» и «PDF» в «Новом расчёте»: можно загрузить сам файл (.xlsx/.csv или .pdf) вместо вставки текста руками" }, { date: "2026-08-06", text: "Формат «Фото» в «Новом расчёте»: загрузка фото заявки (можно сразу несколько) — текст распознаётся автоматически, перед расчётом можно проверить и поправить" }, { date: "2026-08-05", text: "Кнопка «Что нового» в шапке — здесь можно посмотреть последние изменения" }, { date: "2026-08-05", text: "Добавлена подсказка «как посчитано стекло» — открывается у позиции рядом со стоимостью стекла" }, { date: "2026-08-05", text: "Добавлен выбор зоны доставки при создании заявки (по умолчанию «По СПб» — 5 500 ₽)" }, { date: "2026-07-06", text: "Исходный текст заявки сохраняется отдельно — вкладка «Оригинал» в попапе текста" }, { date: "2026-07-03", text: "Улучшено распознавание размеров в табличных заявках (проём/коробка)" }, { date: "2026-07-02", text: "Кнопка пересчёта запоминает выбор «с монтажом / без монтажа»" }, { date: "2026-07-01", text: "Добавлена подсказка «как посчитана дверь» и наценки за сталь/цвет" }, { date: "2026-07-01", text: "Добавлен переключатель ценовой группы (розница / от 5 / от 10 / от 50 / от 100 шт)" }, ]; // ── Порог: ровно 4 значения умеет считать прайс ── window.POROG_OPTIONS = ["14 мм", "45 мм", "без порога", "выпадающий порог"]; // ── Система антипаники: одно устройство на замок+ручку — выбор в одном поле зеркалится в другое ── window.ANTIPANIC_VALUES = [ "система антипаники Apecs на рабочую створку", "система антипаники DoorLock на рабочую створку (Италия)", "система антипаники DoorLock на обе створки (Италия)", ]; // ── Замок (из листа "замки" прайса) ── window.ZAMOK_OPTIONS = [ "замок Fuaro, цилиндр кл-кл", "замок Fuaro, цилиндр кл-верт", "замок Гардиан 12.01, цилиндр кл-кл", "замок Гардиан 12.01, цилиндр кл-верт", "замок DL V1901 с функц. антипаника", "Замок врезной противопожарный Apecs 2000-Panic-ZN , цилиндр кл-кл", "Замок врезной противопожарный Apecs 2000-Panic-ZN , цилиндр кл-вертушка", ...window.ANTIPANIC_VALUES, ]; // ── Ручка (из листа "ручки" прайса) ── window.RUCHKA_OPTIONS = [ "ручка черная нажимная", "ручка хром нажимная", "ручка-скоба приварная", "ручка-скоба хром 200мм", "ручка-скоба хром 300мм", "ручка-скоба DOORLOCK PHS11/32/2ст, L=450мм, нерж.", "ручка-скоба DOORLOCK PHS11/32/2ст, L=1000мм, нерж.", "ручка-скоба DOORLOCK PHS11/32/2ст, L=1500мм, нерж.", ...window.ANTIPANIC_VALUES, ]; // ── Полный список полей менеджерского расчёта ──────────────────────────────── const MGR_GROUPS = [ { key: "izdelie", label: "Двери", fields: [ { key: "doors", label: "Двери" }, { key: "framuga", label: "Фрамуга" }, { key: "naicenkaStal", label: "Толщина стали" }, { key: "naicenkaColor", label: "Окрас" }, { key: "dovodchikRab", label: "Доводчик на раб.створку" }, { key: "dovodchikPas", label: "Доводчик на пас.створку" }, { key: "glass", label: "Стоимость стекла" }, { key: "ramka", label: "Рамка на стекло" }, { key: "stopperGlassRab", label: "Отбойник на стекло раб.створки" }, { key: "stopperGlassPas", label: "Отбойник на стекло пас.створки" }, { key: "gratingRab", label: "Решётка раб.створки" }, { key: "gratingPas", label: "Решётка доп.створки" }, { key: "vypadPorog", label: "Выпадающий порог" }, { key: "stopperRab", label: "Отбойная пластина раб.створка" }, { key: "stopperPas", label: "Отбойная пластина пас.створка" }, { key: "lockMain", label: "Замок основной" }, { key: "lockExtra", label: "Замок дополнительный" }, { key: "cylinder", label: "Цилиндр" }, { key: "handlePress", label: "Ручка нажимная" }, { key: "handleBar", label: "Ручка скоба" }, { key: "coordinator", label: "Координатор закрывания" }, { key: "doorStopper", label: "Дверной стопор" }, { key: "antipanic", label: "Антипаника раб.створка" }, { key: "antipanic2", label: "Антипаника пас.створка" }, { key: "electromagnet", label: "Электромагнитный замок" }, { key: "herkon", label: "Геркон" }, { key: "electromech", label: "Электромеханический замок" }, { key: "cablePath", label: "Кабельпереход" }, { key: "cable", label: "Кабель" }, { key: "skud", label: "Подготовка под СКУД" }, { key: "openingDir", label: "Внутреннее открывание (доплата)" }, ], }, { key: "montazh", label: "Монтаж", fields: [ { key: "montDoor", label: "Монтаж двери" }, { key: "montCloserRab", label: "Монтаж доводчика раб.створка" }, { key: "montCloserPas", label: "Монтаж доводчика пас.створка" }, { key: "montCoordinator", label: "Монтаж координатора" }, { key: "montPorog", label: "Монтаж выпадающего порога" }, { key: "montAntipanic", label: "Монтаж антипаники раб.створка" }, { key: "montAntipanic2", label: "Монтаж антипаники пас.створка" }, { key: "montStopperRab", label: "Монтаж дверного стопора раб." }, { key: "montStopperPas", label: "Монтаж дверного стопора пас." }, { key: "montPlateRab", label: "Монтаж отбойной пластины раб." }, { key: "montPlatePas", label: "Монтаж отбойной пластины пас." }, { key: "montGlassRab", label: "Монтаж стекла раб.створка" }, { key: "montGlassPas", label: "Монтаж стекла пас.створка" }, { key: "demontazh", label: "Демонтаж" }, { key: "takelDoor", label: "Такелаж двери" }, { key: "takelGlass", label: "Такелаж стекла" }, ], }, { key: "uslugi", label: "Услуги", fields: [ { key: "trash", label: "Мусор" }, { key: "electricity", label: "Электроэнергия" }, { key: "cleaning", label: "Клининг" }, { key: "stiz", label: "Стиз" }, { key: "packSingle", label: "Упаковка однопольной двери" }, { key: "packDouble", label: "Упаковка двупольной двери" }, { key: "furnitureReplace", label: "Замена фурнитуры+МДФ панелей" }, { key: "handover", label: "Сдача дольщикам" }, { key: "deliveryMop", label: "Доставка МОП" }, { key: "deliveryApt", label: "Доставка квартирные" }, { key: "fines", label: "Штрафы и непредвиденные" }, { key: "genContractor", label: "Ген.подрядные услуги" }, { key: "retention", label: "Гарантийное удержание" }, ], }, ]; // дефолтный объект mgr — все поля = 0 const MGR_DEFAULTS = Object.fromEntries( MGR_GROUPS.flatMap(g => g.fields.map(f => [f.key, 0])) ); // canonical position shape used by all UI components function mkPos(o) { return { id: o.id, proekt: o.proekt || "—", doorType: o.doorType || "—", proemH: o.proemH || "", proemW: o.proemW || "", korobH: o.korobH || "", korobW: o.korobW || "", qty: o.qty, rabStvorka: o.rabStvorka || "—", nalichnik: o.nalichnik || "—", opening: o.opening || "—", ral: o.ral || "—", porog: o.porog || "—", zamok: o.zamok || "—", ruchka: o.ruchka || "—", dovodchik: o.dovodchik !== false, dovodchikType: o.dovodchikType || "", dovodchikCount: o.dovodchikCount || 1, kind: o.kind || "door", note: o.note || "", noteAuto: o.noteAuto || "", noteBlocksDetail: o.noteBlocksDetail || [], positionText: o.positionText || "", positionTextNormalized: o.positionTextNormalized || "", alerts: o.alerts || [], fieldSources: o.fieldSources || {}, glassNote: o.glassNote || GLASS_NOTE, priceHint: o.priceHint || "", glassHint: o.glassHint || "", lockHint: o.lockHint || "", glazingText: o.glazingText || "", glazingPriceOverride: o.glazingPriceOverride || null, mgr: Object.assign({}, MGR_DEFAULTS, o.mgr || {}), }; } // derived totals for one position. installOn=false скрывает монтаж/услуги // (переключатель "с монтажем/без монтажа" в КП) — не трогает сами mgr-поля, // только сумму, поэтому включить обратно можно без потери цифр. function calcPos(p, installOn = true) { const m = p.mgr; const qty = Math.max(1, +p.qty || 1); const itogoIzdelie = MGR_GROUPS[0].fields.reduce((s, f) => s + (+m[f.key] || 0), 0) * qty; const itogoMontazhFull = MGR_GROUPS[1].fields.reduce((s, f) => s + (+m[f.key] || 0), 0) * qty; const itogoUslugiFull = MGR_GROUPS[2].fields.reduce((s, f) => s + (+m[f.key] || 0), 0) * qty; const demontazhFull = (+m.demontazh || 0) * qty; const itogoMontazh = installOn ? itogoMontazhFull : 0; const itogoUslugi = installOn ? itogoUslugiFull : 0; const demontazh = installOn ? demontazhFull : 0; const montazhRaboty = itogoMontazh + itogoUslugi - demontazh; return { priceEach: MGR_GROUPS[0].fields.reduce((s, f) => s + (+m[f.key] || 0), 0), itogoIzdelie, itogoUslugi, itogoMontazh, demontazh, montazhRaboty, lineTotal: itogoIzdelie + itogoUslugi + itogoMontazh, }; } function grandTotal(positions, installOn = true) { return positions.reduce((s, p) => s + calcPos(p, installOn).lineTotal, 0); } // ── API ────────────────────────────────────────────────────────────────────── // Загрузка файла (фото/PDF/таблица) иногда падает с "Failed to fetch" — это // сетевой обрыв на пути клиент→сервер (не ответ сервера с ошибкой, запрос до // него физически не доехал), сервер это никак не залогирует. На реальных // заявках такое уже случалось несколько раз подряд — вместо того чтобы // заставлять менеджера каждый раз замечать ошибку и жать заново руками, // повторяем один раз сами, только для именно этой сетевой ошибки (не для // ответов сервера с кодом ошибки — те как были, так и остаются реальной // ошибкой, не сетевым сбоем). async function fetchWithRetry(url, options, retries = 1, delayMs = 1500) { for (let attempt = 0; ; attempt++) { try { return await fetch(url, options); } catch (e) { if (attempt >= retries) throw e; await new Promise((r) => setTimeout(r, delayMs)); } } } // Файл режется на куски по ~500КБ и грузится по одному через /api/upload-chunk // вместо одной большой передачи — fetch() (в отличие от скачивания файла // браузером) не умеет докачивать после обрыва, поэтому один короткий сетевой // сбой посреди загрузки крупного фото/PDF ронял всю передачу целиком. Кусками // теряется максимум один кусок, и fetchWithRetry перезаливает только его. const UPLOAD_CHUNK_SIZE = 512 * 1024; function _uploadId() { if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID(); return Date.now().toString(36) + Math.random().toString(36).slice(2); } async function uploadFileChunked(file) { const uploadId = _uploadId(); const total = Math.max(1, Math.ceil(file.size / UPLOAD_CHUNK_SIZE)); let result = null; for (let i = 0; i < total; i++) { const start = i * UPLOAD_CHUNK_SIZE; const chunk = file.slice(start, start + UPLOAD_CHUNK_SIZE); const fd = new FormData(); fd.append("upload_id", uploadId); fd.append("index", i); fd.append("total", total); fd.append("chunk", chunk, file.name); const r = await fetchWithRetry("/api/upload-chunk", { method: "POST", body: fd }, 3, 1500); if (!r.ok) throw new Error(await r.text()); result = await r.json(); } if (!result || result.status !== "done") throw new Error("Не удалось собрать файл на сервере"); return result.token; } async function apiFetch(method, path, body) { const opts = { method, headers: { "Content-Type": "application/json" } }; if (body !== undefined) opts.body = JSON.stringify(body); const r = await fetch("/api" + path, opts); if (!r.ok) throw new Error(await r.text()); return r.json(); } // ── ФОРМУЛА СТЕКЛА (размер + цена/м², редактируется в КП) ──────────────────── // Размер в тексте "остекление" всегда в порядке высота×ширина (см. // extractor.detect_glazing / pricing.calc_glazing) — держим тот же порядок // при чтении и записи, иначе площадь пересчитается неверно. function parseGlazingDims(text) { const norm = (text || "").replace(/[xX×]/g, "х"); const m = /(\d{3,4})х(\d{3,4})/.exec(norm); return m ? { h: m[1], w: m[2] } : { h: "", w: "" }; } function withGlazingDims(text, h, w) { const t = text || ""; const norm = t.replace(/[xX×]/g, "х"); if (/\d{3,4}х\d{3,4}/.test(norm)) { return norm.replace(/\d{3,4}х\d{3,4}/, `${h}х${w}`); } return (t ? t + " " : "") + `${h}х${w}`; } // Цена/м² уже применённая наценке — парсим из готовой подсказки бэкенда // (см. pricing.calc_glazing._hint), только для того чтобы предзаполнить поле // ввода актуальным значением, если менеджер ещё не задавал ручную цену. function parseGlazingRate(hint) { const m = /×\s*([\d\s]+)\s*₽\/м²/.exec(hint || ""); return m ? m[1].replace(/\s/g, "") : ""; } // ── NOTE BUILDER ───────────────────────────────────────────────────────────── // [ПРАВИЛО] Тот же порядок блоков примечания, что и в Excel-КП (см. // extractor.DEFAULT_NOTE_ORDER/kp_gen._build_note) — держать вручную в // синхроне. Порядок настраивается на /corrections, фронт подтягивает // сохранённое значение один раз при старте (меняется редко — только когда // менеджер переставит блоки, свежего при перезагрузке страницы достаточно). const DEFAULT_NOTE_ORDER = [ "nerj", "aluminum_warning", "zincogrunt", "emag_lock", "skud", "coordinator", "steel", "glazing", "closer", "transom", "kickplate", "handle_antipanic", ]; window.NOTE_BLOCK_ORDER = null; fetch("/api/note-block-order") .then((r) => r.json()) .then((d) => { window.NOTE_BLOCK_ORDER = d.order; }) .catch(() => {}); // [ПРАВИЛО] Собирает список кусочков примечания вместе с источником каждого // (для покусочной подсказки в КП, см. NoteCell в ferro-app.jsx) — единственное // место, где строится примечание; buildNoteFromBackend ниже просто склеивает // текст этого же списка, чтобы не дублировать логику сборки в двух местах. function buildNoteBlocksDetail(p, mode = "retail") { let fieldSrc = {}; if (p.field_sources && typeof p.field_sources === "object") fieldSrc = p.field_sources; else if (typeof p.field_sources === "string") { try { fieldSrc = JSON.parse(p.field_sources) || {}; } catch (e) {} } const blockSrc = fieldSrc["примечание_блоки"] || {}; const blocks = []; // [{id, text, source}] let rawBlocks = []; try { rawBlocks = JSON.parse(p.note_blocks || "[]"); } catch (e) {} for (const item of rawBlocks) { if (Array.isArray(item) && item.length === 2) { blocks.push({ id: item[0], text: item[1], source: blockSrc[item[0]] ?? null }); } } if (p.steel && !/нержавеющ/i.test(p.steel)) { blocks.push({ id: "steel", text: p.steel.replace(/(\d)мм/g, "$1 мм") + ".", source: fieldSrc["сталь"] ?? null }); } if (p.glazing) { let gl = p.glazing.charAt(0).toUpperCase() + p.glazing.slice(1); gl = gl.replace(/(\d{3,4})[×x](\d{3,4})(?!\s*мм)/g, "$1×$2мм"); const is2 = (p.sash || "").includes("2 створч"); if (is2 && !/створк|сторон/.test(gl)) gl += " в рабочей створке"; blocks.push({ id: "glazing", text: gl + ".", source: null }); } // [ПРАВИЛО] В рознице доводчик — отдельная позиция (pricing.split_closer_positions), в примечании // не повторяется. В тендерах остаётся внутри примечания, как было раньше. if (mode !== "retail" && p.closer && p.closer !== "без доводчика") { let cl = p.closer.charAt(0).toUpperCase() + p.closer.slice(1); const is2sash = (p.sash || "").includes("2 створч"); if (is2sash && !/створк|на \w/.test(cl)) cl += " на активной створке"; blocks.push({ id: "closer", text: cl + ".", source: fieldSrc["доводчик"] ?? null }); } if (p.handle && /антипаник|пуш.?бар|push.?bar/i.test(p.handle)) { let hnd = p.handle.replace(/антипаника\s+DL\b/gi, "антипаники DoorLock"); if (/\bDL\b|doorlock/i.test(p.handle) && !/\(италия\)/i.test(hnd)) hnd += " (Италия)"; hnd = hnd.charAt(0).toUpperCase() + hnd.slice(1); blocks.push({ id: "handle_antipanic", text: hnd + ".", source: fieldSrc["ручка"] ?? null }); } if (p.transom) blocks.push({ id: "transom", text: p.transom + ".", source: null }); if (p.kickplate) blocks.push({ id: "kickplate", text: p.kickplate + ".", source: null }); const order = window.NOTE_BLOCK_ORDER || DEFAULT_NOTE_ORDER; const rank = {}; order.forEach((id, i) => { rank[id] = i; }); blocks.sort((a, b) => (rank[a.id] ?? order.length) - (rank[b.id] ?? order.length)); return blocks; } function buildNoteFromBackend(p, mode = "retail") { return buildNoteBlocksDetail(p, mode).map((b) => b.text).join(" "); } // ── DATA MAPPING: backend position → UI pos ────────────────────────────────── function backendToPos(p, mode = "retail") { const qty = Math.max(1, +p.quantity || 1); const computed = { // Двери (цена за 1 шт.) doors: Math.round(+p.price_door), framuga: Math.round(+p.markup_transom), naicenkaStal: Math.round(+p.markup_steel), naicenkaColor: Math.round(+p.markup_color), dovodchikRab: Math.round(+p.markup_closer), glass: Math.round(+p.markup_glazing), ramka: Math.round(+p.markup_hardware + +p.markup_vent_grille), skud: Math.round(+p.markup_skud || 0), coordinator: Math.round(+p.markup_coordinator || 0), openingDir: Math.round(+p.markup_opening_dir || 0), vypadPorog: Math.round(+p.markup_threshold), lockMain: Math.round(+p.markup_lock), handlePress: Math.round(+p.markup_handle), // Монтаж (цена за 1 шт.) montDoor: Math.round(+p.price_install), montCloserRab: Math.round(+p.install_closer), montCoordinator: Math.round(+p.install_coordinator || 0), montPorog: Math.round(+p.install_threshold), }; let overrides = {}; if (p.mgr_json) { try { overrides = JSON.parse(p.mgr_json); } catch (e) {} } const mgr = Object.assign({}, MGR_DEFAULTS, computed, overrides); return mkPos({ id: p.id, proekt: p.project_code || "—", doorType: p.type || "—", proemH: p.height_proem || "", proemW: p.width_proem || "", korobH: p.height || "", korobW: p.width || "", qty, rabStvorka: p.active_leaf_width ? String(p.active_leaf_width) : "—", nalichnik: (p.nalichnik && p.nalichnik !== "без наличника") ? p.nalichnik : "—", opening: p.side || "—", ral: p.color ? String(p.color).replace(/^ral[\s-]*/i, "") : "—", porog: (p.threshold && !/без порога/i.test(p.threshold)) ? p.threshold : "—", zamok: p.lock_ || "—", ruchka: p.handle || "—", dovodchik: !!(p.closer && p.closer !== "без доводчика"), dovodchikType: p.closer || "", dovodchikCount: p.dovodchik_count || 1, kind: p.kind || "door", note: p.note_override || buildNoteFromBackend(p, mode), noteAuto: buildNoteFromBackend(p, mode), noteBlocksDetail: buildNoteBlocksDetail(p, mode), positionText: p.position_text_raw || p.position_text || "", positionTextNormalized: p.position_text || "", alerts: Array.isArray(p.manager_alerts) ? p.manager_alerts : [], fieldSources: (() => { if (p.field_sources && typeof p.field_sources === "object") return p.field_sources; if (typeof p.field_sources === "string") { try { return JSON.parse(p.field_sources) || {}; } catch (e) { return {}; } } return {}; })(), priceHint: p.price_hint || "", glassHint: p.markup_glazing_hint || "", lockHint: p.markup_lock_hint || "", glazingText: p.glazing || "", glazingPriceOverride: p.glazing_price_override || null, mgr, }); } // ── MISC UTILS ──────────────────────────────────────────────────────────────── function fmtDate(iso) { if (!iso) return ""; const d = new Date(iso); return d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", }); } function statusLabel(s) { return { new: "Новая", processing: "Обрабатывается", ready: "Готово", sent: "Отправлено", error: "Ошибка" }[s] || s; } Object.assign(window, { RUB, NUM, GLASS_NOTE, MGR_GROUPS, MGR_DEFAULTS, mkPos, calcPos, grandTotal, apiFetch, backendToPos, buildNoteFromBackend, buildNoteBlocksDetail, fmtDate, statusLabel, parseGlazingDims, withGlazingDims, parseGlazingRate });