Код кошторису

Код кошторису — повна версія

Замініть увесь код у редакторі Apps Script на цей. Зміна одна: пункт «Створити кошторис для клієнта» показує список угод з CRM, і доданий пункт «Сформувати PDF».

  1. Натисніть «Скопіювати».
  2. У редакторі скрипта виділіть увесь старий код (Ctrl+A або Cmd+A) і вставте цей.
  3. Збережіть.
  4. Налаштування проєкту → Властивості скрипта → додайте ESTIMATE_SECRET.
koshtorys.gs · 977 рядків
const РЯДКІВ = 4;
const РЯДКІВ_ВЛАСНІ = 3;
const ВІДСТУП = 4;
const ПІДПИС_ПЛЮС = '➕ поставте галочку, щоб додати рядок';

const КАТЕГОРІЇ = [
  ['1.1', 'Меблі, обладнання, інструменти, машини та механізми, інші основні засоби', ''],
  ['1.2', 'Транспортні засоби (комерційні/виробничі цілі)', ''],
  ['1.3', 'Матеріали та послуги для ремонтних робіт приміщень/обладнання', 0.50],
  ['1.4', 'Тварини і птиця, насадження, саджанці, посівний матеріал, сировина, матеріали, товари та послуги за бізнес-планом', 0.50],
  ['1.5', 'Фінансовий лізинг обладнання (крім авто, мотоциклів, ТЗ особистого користування)', 0.50],
  ['2.1', 'Орендна плата за нежитлове приміщення / земельну ділянку', 0.25],
  ['2.2', 'Орендна плата за обладнання', 0.10],
  ['3.1', 'Ліцензійне програмне забезпечення (бухгалтерське, CRM/ERP, касове, кібербезпека)', 0.25],
  ['3.2', 'Веб-сайт, інтернет-магазин, цифрові інструменти автоматизації', 0.10],
  ['3.3', 'Налаштування, інтеграція та супровід цифрових рішень', 0.20],
  ['4.1', 'Послуги маркетингу та реклами', 0.10],
  ['4.2', 'Айдентика, дизайн пакування, макети, друк промоматеріалів, фото/відеоконтент, постпродакшн', 0.50],
  ['4.3', 'Роялті за використання авторського права, суміжних прав, промзразків', 0.20],
  ['4.4', 'Створення за замовленням об’єктів авторського права', 0.20],
  ['4.5', 'Редагування, коректура тексту, макетування друкованої продукції (креативні індустрії)', 0.50],
  ['5',   'Облаштування робочих місць (меблі, обладнання, інструменти, спецодяг, ЗІЗ)', ''],
  ['6',   'Реєстрація ТМ / промзразка, патентний повірений, права інтелектуальної власності', 0.20]
];

const ВЛАСНІ_ДОДАТКОВІ = [
  ['7', 'Страхування (для автомобілів)'],
  ['8', 'Інтернет і телефон'],
  ['9', 'Податки']
];

const ЗАПАСНА = '1.4';

const П = 4;
const СИНІЙ = '#1f4e79';
const ЗЕЛЕНИЙ = '#c6efce';
const ЗЕЛЕНИЙ_ТЕМН = '#a9d08e';
const ЗЕЛЕНИЙ_СВІТ = '#e2efda';
const ЧЕРВОНИЙ = '#ffc7ce';
const ЗАГОЛОВОК = '#d9e2f3';
const СІРИЙ = '#f2f2f2';
const ПАПКА = 'Кошториси клієнтів';

function підпис_(запис) {
  const ліміт = запис[2];
  const хвіст = (ліміт === '' || ліміт === null)
    ? ' (без ліміту)'
    : ' (не більше ' + Math.round(ліміт * 100) + '% від суми гранту)';
  return запис[1] + хвіст;
}

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('📋 Кошторис 738')
    .addItem('📥 Імпорт витрат з тексту', 'імпортЗТексту')
    .addItem('📁 Імпорт витрат з файлу', 'імпортЗФайлу')
    .addSeparator()
    .addItem('➕ Додати рядок у категорію', 'додатиРядок')
    .addItem('➖ Видалити поточний рядок', 'видалитиРядок')
    .addSeparator()
    .addItem('📄 Створити кошторис для клієнта', 'створитиФайлКлієнта')
    .addItem('🧾 Сформувати PDF', 'сформуватиPDF')
    .addItem('🧾 PDF з файлу клієнта', 'PDFзФайлу')
    .addItem('🔧 Додати інструменти у файл клієнта', 'дооснаститиФайл')
    .addItem('🧹 Очистити кошторис', 'очиститиКошторис')
    .addSeparator()
    .addItem('⚙️ Перевірити кнопки «+»', 'увімкнутиТригер')
    .addItem('🔧 Перебудувати шаблон', 'створитиШаблонКошторису')
    .addToUi();
}

function увімкнутиТригер() {
  // Кнопки «+» тепер працюють через простий тригер onEdit — він є в кожній
  // копії сам собою. Старий встановлений тригер треба зняти, інакше на одну
  // галочку додаватиметься два рядки.
  var знято = 0;
  ScriptApp.getProjectTriggers().forEach(function (t) {
    if (t.getHandlerFunction() === 'приЗміні_' || t.getHandlerFunction() === 'onEdit') {
      ScriptApp.deleteTrigger(t);
      знято++;
    }
  });
  SpreadsheetApp.getUi().alert(
    'Кнопки «+» працюють автоматично — і в цій таблиці, і в кожній копії.\n\n' +
    (знято ? 'Знято зайвих тригерів: ' + знято + '. Вони більше не потрібні.'
           : 'Нічого налаштовувати не треба.'));
}

// Назва onEdit — не випадкова. Це «простий тригер»: Google запускає його
// в будь-якій копії таблиці автоматично, нічого встановлювати не треба.
// Раніше тут був встановлений тригер (приЗміні_), а встановлені тригери
// при копіюванні файлу НЕ переносяться — тому в збережених кошторисах
// галочка «+» не працювала.
function onEdit(e) {
  if (!e || !e.range) return;
  const арк = e.range.getSheet();
  if (арк.getName() !== 'Кошторис') return;
  if (e.range.getColumn() !== 3) return;
  if (e.value !== 'TRUE') return;

  const r = e.range.getRow();
  const тип = арк.getRange(r, 9).getValue();
  if (тип !== 'P' && тип !== 'PX') return;

  арк.getRange(r, 3).setValue(false);
  вставитиПорожній_(арк, r, тип === 'PX');
}

function вставитиПорожній_(кш, рядокПлюса, власний) {
  кш.insertRowBefore(рядокПлюса);
  const нов = рядокПлюса;

  кш.getRange(нов, 1, 1, 7).setBackground('#ffffff').setFontWeight('normal')
    .setFontColor('#000000').setFontStyle('normal')
    .setBorder(true, true, true, true, true, true);
  кш.getRange(нов, 1, 1, 9).clearContent();
  кш.getRange(нов, 2).clearNote();
  кш.getRange(нов, 3).clearDataValidations();

  if (власний) {
    const тип = кш.getRange(1, 9, кш.getLastRow(), 1).getValues();
    var влЗаг = 0;
    for (var q = П; q < нов; q++) if (тип[q - 1][0] === 'O') влЗаг = q;
    кш.getRange(нов, 9).setValue('X');
    кш.getRange(нов, 1).setFormula('=IF($B' + нов + '="";"";COUNTIFS($B$' + (влЗаг + 1) +
      ':$B' + нов + ';"<>";$I$' + (влЗаг + 1) + ':$I' + нов + ';"X"))');
  } else {
    кш.getRange(нов, 1).setFormula('=IF($B' + нов + '="";"";COUNTIFS($B$' + П + ':$B' + нов +
      ';"<>";$I$' + П + ':$I' + нов + ';""))');
  }
  кш.getRange(нов, 6).setFormula('=IF(OR($C' + нов + '="";$D' + нов + '="");"";$C' + нов +
    '*$D' + нов + ')');
  кш.getRange(нов, 3).setNumberFormat('#,##0.00');
  кш.getRange(нов, 4).setNumberFormat('#,##0');
  кш.getRange(нов, 6).setNumberFormat('#,##0.00');
  кш.getRange(нов, 5).setHorizontalAlignment('center');
  кш.getRange(нов, 1).setHorizontalAlignment('center');

  перерахувати_(кш);
  кш.getRange(нов, 2).activate();
}

function оформитиПлюс_(кш, r, власний) {
  кш.getRange(r, 1, 1, 7).setBackground(СІРИЙ).setFontColor('#666666')
    .setFontWeight('normal').setBorder(true, true, true, true, true, true);
  кш.getRange(r, 2).setValue(ПІДПИС_ПЛЮС).setFontStyle('italic');
  кш.getRange(r, 3).insertCheckboxes().setValue(false);
  кш.getRange(r, 9).setValue(власний ? 'PX' : 'P');
}

// ==================== ІМПОРТ ====================

function імпортЗТексту() {
  const html = HtmlService.createHtmlOutput(
    '<div style="font-family:Arial;padding:8px">' +
    '<p style="font-size:13px;margin:0 0 6px">Вставте текст із витратами клієнта — ' +
    'у будь-якому вигляді, як прислав. Посилання не видаляйте: з них береться модель і бренд.</p>' +
    '<textarea id="t" style="width:100%;height:230px;font-size:13px"></textarea>' +
    '<div style="margin-top:8px"><button onclick="go()" id="b" ' +
    'style="padding:7px 16px;font-size:13px">Розібрати</button> ' +
    '<span id="s" style="color:#666;font-size:12px"></span></div></div>' +
    '<script>function go(){document.getElementById("b").disabled=true;' +
    'document.getElementById("s").innerText="Аналізую, зачекайте 10–20 секунд...";' +
    'google.script.run.withSuccessHandler(function(){google.script.host.close();})' +
    '.withFailureHandler(function(e){document.getElementById("b").disabled=false;' +
    'document.getElementById("s").innerText="Помилка: "+e.message;})' +
    '.обробитиТекст(document.getElementById("t").value);}</script>')
    .setWidth(560).setHeight(380);
  SpreadsheetApp.getUi().showModalDialog(html, 'Імпорт витрат з тексту');
}

function імпортЗФайлу() {
  const ui = SpreadsheetApp.getUi();
  const р = ui.prompt('Імпорт з файлу',
    'Введіть точну назву файлу на Google Диску або вставте посилання на нього:',
    ui.ButtonSet.OK_CANCEL);
  if (р.getSelectedButton() !== ui.Button.OK) return;
  const запит = р.getResponseText().trim();
  if (!запит) return;

  var файл = null;
  const м = запит.match(/[-\w]{25,}/);
  try {
    if (м) файл = DriveApp.getFileById(м[0]);
    else {
      const іт = DriveApp.getFilesByName(запит);
      if (іт.hasNext()) файл = іт.next();
    }
  } catch (e) { файл = null; }

  if (!файл) { ui.alert('Файл не знайдено. Перевірте назву або посилання.'); return; }

  var текст = '';
  const mime = файл.getMimeType();
  try {
    if (mime === MimeType.GOOGLE_SHEETS || mime === MimeType.MICROSOFT_EXCEL ||
        mime === MimeType.MICROSOFT_EXCEL_LEGACY || mime === MimeType.CSV) {
      var idТабл = файл.getId(), копіяТабл = null;
      if (mime !== MimeType.GOOGLE_SHEETS) {
        копіяТабл = Drive.Files.copy({ title: '__imp__', mimeType: MimeType.GOOGLE_SHEETS },
          файл.getId());
        idТабл = копіяТабл.id;
      }
      SpreadsheetApp.openById(idТабл).getSheets().forEach(function (sh) {
        sh.getDataRange().getValues().forEach(function (row) { текст += row.join(' | ') + '\n'; });
      });
      if (копіяТабл) DriveApp.getFileById(копіяТабл.id).setTrashed(true);
    } else {
      const копіяДок = Drive.Files.copy({ title: '__imp__', mimeType: MimeType.GOOGLE_DOCS },
        файл.getId());
      текст = DocumentApp.openById(копіяДок.id).getBody().getText();
      DriveApp.getFileById(копіяДок.id).setTrashed(true);
    }
  } catch (e) {
    ui.alert('Не вдалося прочитати файл.\n\nЯкщо це PDF-скан, текст із нього не витягується — ' +
      'скопіюйте дані вручну і скористайтесь пунктом «Імпорт витрат з тексту».\n\n' + e.message);
    return;
  }

  if (!текст.trim()) { ui.alert('Файл прочитано, але тексту в ньому не знайдено.'); return; }
  обробитиТекст(текст);
}

function обробитиТекст(текст) {
  const ui = SpreadsheetApp.getUi();
  if (!текст || !текст.trim()) throw new Error('Текст порожній.');

  const результат = GrantAPI.розібрати(текст);
  const сирі = (результат && результат.items) ? результат.items : [];
  const коди = КАТЕГОРІЇ.map(function (c) { return c[0]; });

  const позиції = [];
  сирі.forEach(function (p) {
    const назва = String(p.name || '').trim();
    const ціна = Number(p.price);
    if (!назва || !ціна || ціна <= 0) return;
    var кільк = Number(p.qty);
    if (!кільк || кільк <= 0) кільк = 1;
    var код = String(p.cat || '').trim();
    var сумнів = (p.sure === false);
    if (коди.indexOf(код) === -1) { код = ЗАПАСНА; сумнів = true; }
    позиції.push({ назва: назва, ціна: ціна, кільк: кільк,
      од: String(p.unit || '').trim(), код: код, сумнів: сумнів,
      джерело: String(p.src || '').trim() });
  });

  if (!позиції.length) throw new Error('Модель не знайшла жодної позиції витрат у цьому тексті.');

  var зведення = '', сум = 0;
  позиції.forEach(function (p) {
    зведення += (p.сумнів ? '⚠️ ' : '') + p.назва + ' — ' +
      p.ціна.toFixed(2) + ' × ' + p.кільк + (p.од ? ' ' + p.од : '') +
      ' → кат. ' + p.код + (p.джерело ? ' 🔗' : '') + '\n';
    сум += p.ціна * p.кільк;
  });

  const відп = ui.alert('Розпізнано позицій: ' + позиції.length,
    зведення + '\nЗагальна сума: ' + сум.toFixed(2) + ' грн\n\n' +
    '⚠️ — категорію під питанням, перевірте.  🔗 — збережено посилання у примітці.\n\n' +
    'Додати до кошторису?', ui.ButtonSet.YES_NO);
  if (відп !== ui.Button.YES) return;

  вставитиПозиції_(позиції);
}

function вставитиПозиції_(позиції) {
  const кш = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Кошторис');
  const групи = {};
  позиції.forEach(function (p) {
    if (!групи[p.код]) групи[p.код] = [];
    групи[p.код].push(p);
  });

  Object.keys(групи).sort().reverse().forEach(function (код) {
    const запис = КАТЕГОРІЇ.filter(function (c) { return c[0] === код; })[0];
    if (!запис) return;
    const назваКат = підпис_(запис);
    const last = кш.getLastRow();
    const колB = кш.getRange(1, 2, last, 1).getValues();
    const колI = кш.getRange(1, 9, last, 1).getValues();

    var шапка = 0, плюс = 0;
    for (var r = П; r <= last; r++) {
      if (колI[r - 1][0] === 'H' && колB[r - 1][0] === назваКат) { шапка = r; break; }
    }
    if (!шапка) return;
    for (var r2 = шапка + 1; r2 <= last; r2++) {
      if (колI[r2 - 1][0] === 'P') { плюс = r2; break; }
    }
    if (!плюс) return;

    var вільні = [];
    for (var r3 = шапка + 1; r3 < плюс; r3++) {
      if (String(колB[r3 - 1][0]).trim() === '') вільні.push(r3);
    }

    const треба = групи[код].length;
    if (вільні.length < треба) {
      const бракує = треба - вільні.length;
      кш.insertRowsBefore(плюс, бракує);
      for (var b = 0; b < бракує; b++) {
        var нр = плюс + b;
        кш.getRange(нр, 1, 1, 7).setBackground('#ffffff').setFontColor('#000000')
          .setFontWeight('normal').setFontStyle('normal')
          .setBorder(true, true, true, true, true, true);
        кш.getRange(нр, 3).clearDataValidations();
        кш.getRange(нр, 1, 1, 9).clearContent();
        вільні.push(нр);
      }
    }

    групи[код].forEach(function (p, idx) {
      var r4 = вільні[idx];
      var комірка = кш.getRange(r4, 2);
      комірка.setValue((p.сумнів ? '⚠️ ' : '') + p.назва).setFontStyle('normal');
      if (p.джерело) комірка.setNote('Джерело ціни:\n' + p.джерело);
      кш.getRange(r4, 3).setValue(p.ціна);
      кш.getRange(r4, 4).setValue(p.кільк);
      кш.getRange(r4, 5).setValue(p.од || '');
      кш.getRange(r4, 1).setFormula('=IF($B' + r4 + '="";"";COUNTIFS($B$' + П + ':$B' + r4 +
        ';"<>";$I$' + П + ':$I' + r4 + ';""))');
      кш.getRange(r4, 6).setFormula('=IF(OR($C' + r4 + '="";$D' + r4 + '="");"";$C' + r4 +
        '*$D' + r4 + ')');
      кш.getRange(r4, 3).setNumberFormat('#,##0.00');
      кш.getRange(r4, 4).setNumberFormat('#,##0');
      кш.getRange(r4, 5).setHorizontalAlignment('center');
      кш.getRange(r4, 6).setNumberFormat('#,##0.00');
      кш.getRange(r4, 1).setHorizontalAlignment('center');
    });
  });

  перерахувати_(кш);
  SpreadsheetApp.flush();
}

// ==================== ШАБЛОН ====================

function створитиШаблонКошторису() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const N = КАТЕГОРІЇ.length;
  const КІНЕЦЬ = П + N - 1;
  const СУМА = КІНЕЦЬ + 2;

  const тимч = ss.insertSheet('__tmp__');
  ss.getSheets().forEach(function (s) {
    if (s.getName() !== '__tmp__') ss.deleteSheet(s);
  });
  SpreadsheetApp.flush();

  // -------- КОНТРОЛЬ --------
  const к = ss.insertSheet('Контроль');
  к.clearConditionalFormatRules();
  к.getRange(1, 1, к.getMaxRows(), к.getMaxColumns()).setBackground('#ffffff');

  к.getRange('A1').setValue('КОНТРОЛЬ ЛІМІТІВ ЗА НАПРЯМАМИ ВИТРАТ (постанова КМУ № 738)');
  к.getRange('A1:H1').merge().setFontSize(13).setFontWeight('bold')
    .setBackground(СИНІЙ).setFontColor('#ffffff').setVerticalAlignment('middle');
  к.setRowHeight(1, 32);

  к.getRange(3, 1, 1, 8).setValues([['Код', 'Категорія витрат (напрям за постановою)',
    'Ліміт, %', 'Ліміт, грн', 'Факт, грн', 'Факт, %', 'Запас / перевищення, грн', 'Статус']])
    .setFontWeight('bold').setBackground(ЗАГОЛОВОК).setWrap(true).setHorizontalAlignment('center');

  const катДані = КАТЕГОРІЇ.map(function (c) { return [c[0], підпис_(c), c[2]]; });
  к.getRange(П, 1, N, 1).setNumberFormat('@');
  к.getRange(П, 1, N, 3).setValues(катДані);

  const фК = [], фJ = [];
  for (var i = П; i <= КІНЕЦЬ; i++) {
    фК.push([
      '=IF($C' + i + '="";"";$C' + i + '*$E$' + СУМА + ')',
      '=SUMIF(Кошторис!$B:$B;$B' + i + ';Кошторис!$F:$F)',
      '=IF($E$' + СУМА + '=0;"";$E' + i + '/$E$' + СУМА + ')',
      '=IF($C' + i + '="";"";$D' + i + '-$E' + i + ')',
      '=IF($E' + i + '=0;"—";IF($C' + i + '="";"Без ліміту";IF($E' + i + '>$D' + i +
        ';"ПЕРЕВИЩЕННЯ";"У межах ліміту")))'
    ]);
    фJ.push(['=IF($E' + i + '=0;"";IF($C' + i + '="";"OK";IF($E' + i + '>$D' + i +
      ';"ПЕРЕВИЩЕННЯ";"OK")))']);
  }
  к.getRange(П, 4, N, 5).setFormulas(фК);
  к.getRange(П, 10, N, 1).setFormulas(фJ);

  к.getRange(СУМА, 2).setValue('ЗАГАЛЬНА СУМА КОШТОРИСУ (= сума гранту), грн:').setFontWeight('bold');
  к.getRange(СУМА, 5).setFormula('=SUMIF(Кошторис!$I:$I;"T";Кошторис!$F:$F)').setFontWeight('bold');
  к.getRange(СУМА, 2, 1, 4).setBackground('#ffe699');

  к.getRange(П, 3, N, 1).setNumberFormat('0%');
  к.getRange(П, 4, N, 1).setNumberFormat('#,##0.00');
  к.getRange(П, 5, N, 1).setNumberFormat('#,##0.00');
  к.getRange(П, 6, N, 1).setNumberFormat('0.0%');
  к.getRange(П, 7, N, 1).setNumberFormat('#,##0.00');
  к.getRange(СУМА, 5).setNumberFormat('#,##0.00');
  к.getRange(П, 8, N, 1).setHorizontalAlignment('center').setFontWeight('bold');
  к.getRange(П, 1, N, 1).setHorizontalAlignment('center');
  к.getRange(П, 2, N, 1).setWrap(true);
  к.getRange(3, 1, N + 1, 8).setBorder(true, true, true, true, true, true);

  к.setColumnWidth(1, 55); к.setColumnWidth(2, 460); к.setColumnWidth(3, 75);
  к.setColumnWidth(4, 115); к.setColumnWidth(5, 115); к.setColumnWidth(6, 80);
  к.setColumnWidth(7, 145); к.setColumnWidth(8, 150);
  к.hideColumns(9, 2);
  к.setFrozenRows(3);

  к.setConditionalFormatRules([
    SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied('=$J' + П + '="ПЕРЕВИЩЕННЯ"')
      .setBackground(ЧЕРВОНИЙ).setFontColor('#9c0006')
      .setRanges([к.getRange(П, 1, N, 8)]).build(),
    SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied('=$J' + П + '="OK"')
      .setBackground(ЗЕЛЕНИЙ).setFontColor('#006100')
      .setRanges([к.getRange(П, 1, N, 8)]).build()
  ]);

  // -------- КОШТОРИС --------
  const кш = ss.insertSheet('Кошторис', 0);
  кш.clearConditionalFormatRules();
  кш.getRange(1, 1, кш.getMaxRows(), кш.getMaxColumns())
    .setBackground('#ffffff').setFontColor('#000000');

  кш.getRange('A1').setValue('КОШТОРИС ВИТРАТ ГРАНТОВИХ КОШТІВ');
  кш.getRange('A1:G1').merge().setFontSize(14).setFontWeight('bold')
    .setBackground(СИНІЙ).setFontColor('#ffffff').setVerticalAlignment('middle');
  кш.setRowHeight(1, 34);
  кш.getRange('A2').setValue('Клієнт:').setFontWeight('bold');

  кш.getRange(3, 1, 1, 7).setValues([['№', 'Категорія витрат / найменування',
    'Ціна за од., грн', 'Кільк.', 'Од.', 'Сума, грн', '% від кошторису']])
    .setFontWeight('bold').setBackground(ЗАГОЛОВОК).setWrap(true).setHorizontalAlignment('center');

  const тіло = [], заг = [], плюси = [], влЗаголовки = [], влПлюси = [];
  var р = П;

  for (var t = 0; t < N; t++) {
    заг.push(р);
    тіло.push([КАТЕГОРІЇ[t][0], підпис_(КАТЕГОРІЇ[t]), '', '', '',
      '=SUM($F$' + (р + 1) + ':$F$' + (р + РЯДКІВ) + ')',
      '=IF(SUMIF($I:$I;"T";$F:$F)=0;"";$F' + р + '/SUMIF($I:$I;"T";$F:$F))',
      '=IFERROR(VLOOKUP($B' + р + ';Контроль!$B:$J;9;FALSE);"")', 'H']);
    for (var d = 1; d <= РЯДКІВ; d++) {
      var rr = р + d;
      тіло.push([
        '=IF($B' + rr + '="";"";COUNTIFS($B$' + П + ':$B' + rr + ';"<>";$I$' + П + ':$I' + rr + ';""))',
        '', '', '', '',
        '=IF(OR($C' + rr + '="";$D' + rr + '="");"";$C' + rr + '*$D' + rr + ')', '', '', '']);
    }
    плюси.push(р + РЯДКІВ + 1);
    тіло.push(['', ПІДПИС_ПЛЮС, '', '', '', '', '', '', 'P']);
    р += 2 + РЯДКІВ;
  }

  const РАЗ = р;
  тіло.push(['', 'РАЗОМ ЗА КОШТОРИСОМ:', '', '', '',
    '=SUMIF($I$' + П + ':$I$' + (РАЗ - 1) + ';"H";$F$' + П + ':$F$' + (РАЗ - 1) + ')',
    '=IF($F' + РАЗ + '=0;"";1)', '', 'T']);
  р += 1;

  for (var v = 0; v < ВІДСТУП; v++) { тіло.push(['', '', '', '', '', '', '', '', '']); р += 1; }

  const ВЛ_ЗАГ = р;
  тіло.push(['', 'ВИТРАТИ ВЛАСНИХ КОШТІВ (довідково, не входять до суми гранту)',
    '', '', '', '', '', '', 'O']);
  р += 1;

  const ВЛАСНІ_ВСІ = КАТЕГОРІЇ.map(function (c) { return [c[0], c[1]]; })
    .concat(ВЛАСНІ_ДОДАТКОВІ);

  for (var t2 = 0; t2 < ВЛАСНІ_ВСІ.length; t2++) {
    влЗаголовки.push(р);
    тіло.push([ВЛАСНІ_ВСІ[t2][0], ВЛАСНІ_ВСІ[t2][1], '', '', '',
      '=SUM($F$' + (р + 1) + ':$F$' + (р + РЯДКІВ_ВЛАСНІ) + ')', '', '', 'OH']);
    for (var d2 = 1; d2 <= РЯДКІВ_ВЛАСНІ; d2++) {
      var ww = р + d2;
      тіло.push([
        '=IF($B' + ww + '="";"";COUNTIFS($B$' + (ВЛ_ЗАГ + 1) + ':$B' + ww + ';"<>";$I$' +
          (ВЛ_ЗАГ + 1) + ':$I' + ww + ';"X"))',
        '', '', '', '',
        '=IF(OR($C' + ww + '="";$D' + ww + '="");"";$C' + ww + '*$D' + ww + ')', '', '', 'X']);
    }
    влПлюси.push(р + РЯДКІВ_ВЛАСНІ + 1);
    тіло.push(['', ПІДПИС_ПЛЮС, '', '', '', '', '', '', 'PX']);
    р += 2 + РЯДКІВ_ВЛАСНІ;
  }

  const ВЛ_РАЗ = р;
  тіло.push(['', 'РАЗОМ ВЛАСНІ КОШТИ:', '', '', '',
    '=SUMIF($I$' + (ВЛ_ЗАГ + 1) + ':$I$' + (ВЛ_РАЗ - 1) + ';"OH";$F$' + (ВЛ_ЗАГ + 1) +
      ':$F$' + (ВЛ_РАЗ - 1) + ')', '', '', 'OT']);

  кш.getRange(П, 1, тіло.length, 1).setNumberFormat('@');
  кш.getRange(П, 1, тіло.length, 9).setValues(тіло);

  заг.forEach(function (h) {
    кш.getRange(h, 1, 1, 7).setFontWeight('bold').setBackground(ЗАГОЛОВОК);
  });
  влЗаголовки.forEach(function (h) {
    кш.getRange(h, 1, 1, 7).setFontWeight('bold').setBackground(ЗЕЛЕНИЙ_СВІТ)
      .setFontColor('#1a3a12');
  });
  плюси.forEach(function (pp) { оформитиПлюс_(кш, pp, false); });
  влПлюси.forEach(function (pp) { оформитиПлюс_(кш, pp, true); });

  кш.getRange(РАЗ, 1, 1, 7).setBackground('#ffe699').setFontWeight('bold');
  кш.getRange(РАЗ, 2).setHorizontalAlignment('right');

  кш.getRange(ВЛ_ЗАГ, 1, 1, 7).setBackground(ЗЕЛЕНИЙ_ТЕМН).setFontWeight('bold')
    .setFontColor('#1a3a12');
  кш.getRange(ВЛ_РАЗ, 1, 1, 7).setBackground(ЗЕЛЕНИЙ).setFontWeight('bold')
    .setFontColor('#006100');
  кш.getRange(ВЛ_РАЗ, 2).setHorizontalAlignment('right');

  кш.getRange(П, 4, ВЛ_РАЗ - П + 1, 1).setNumberFormat('#,##0');
  кш.getRange(П, 6, ВЛ_РАЗ - П + 1, 1).setNumberFormat('#,##0.00');
  кш.getRange(П, 7, РАЗ - П + 1, 1).setNumberFormat('0.0%');
  кш.getRange(П, 1, ВЛ_РАЗ - П + 1, 1).setHorizontalAlignment('center');
  кш.getRange(П, 5, ВЛ_РАЗ - П + 1, 1).setHorizontalAlignment('center');
  кш.getRange(П, 2, ВЛ_РАЗ - П + 1, 1).setWrap(true);
  кш.getRange(3, 1, РАЗ - 2, 7).setBorder(true, true, true, true, true, true);
  кш.getRange(ВЛ_ЗАГ, 1, ВЛ_РАЗ - ВЛ_ЗАГ + 1, 7).setBorder(true, true, true, true, true, true);

  кш.setColumnWidth(1, 50); кш.setColumnWidth(2, 600); кш.setColumnWidth(3, 130);
  кш.setColumnWidth(4, 80); кш.setColumnWidth(5, 70); кш.setColumnWidth(6, 150);
  кш.setColumnWidth(7, 110);
  кш.hideColumns(8, 2);
  кш.setFrozenRows(3);

  кш.setConditionalFormatRules([
    SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied('=$H' + П + '="ПЕРЕВИЩЕННЯ"')
      .setBackground(ЧЕРВОНИЙ).setFontColor('#9c0006')
      .setRanges([кш.getRange(П, 1, РАЗ - П + 1, 7)]).build()
  ]);

  к.protect().setDescription('Формули контролю лімітів').setWarningOnly(true);

  ss.deleteSheet(тимч);
  кш.getRange(П + 1, 2).activate();
  SpreadsheetApp.flush();
}

// ==================== СЕРВІС ====================

function перерахувати_(кш) {
  const last = кш.getLastRow();
  const тип = кш.getRange(1, 9, last, 1).getValues();
  const грант = [], власні = [];
  var разом = 0, влЗаг = 0, влРаз = 0;
  for (var r = П; r <= last; r++) {
    var м = тип[r - 1][0];
    if (м === 'H') грант.push(r);
    if (м === 'OH') власні.push(r);
    if (м === 'T') разом = r;
    if (м === 'O') влЗаг = r;
    if (м === 'OT') влРаз = r;
  }

  грант.forEach(function (h) {
    var end = h;
    for (var z = h + 1; z <= last; z++) { if (тип[z - 1][0] === 'P') { end = z - 1; break; } }
    кш.getRange(h, 6).setFormula('=SUM($F$' + (h + 1) + ':$F$' + end + ')');
    кш.getRange(h, 7).setFormula('=IF(SUMIF($I:$I;"T";$F:$F)=0;"";$F' + h +
      '/SUMIF($I:$I;"T";$F:$F))');
    кш.getRange(h, 8).setFormula('=IFERROR(VLOOKUP($B' + h + ';Контроль!$B:$J;9;FALSE);"")');
  });

  власні.forEach(function (h) {
    var end = h;
    for (var z = h + 1; z <= last; z++) { if (тип[z - 1][0] === 'PX') { end = z - 1; break; } }
    кш.getRange(h, 6).setFormula('=SUM($F$' + (h + 1) + ':$F$' + end + ')');
  });

  if (разом) {
    кш.getRange(разом, 6).setFormula('=SUMIF($I$' + П + ':$I$' + (разом - 1) +
      ';"H";$F$' + П + ':$F$' + (разом - 1) + ')');
    кш.getRange(разом, 7).setFormula('=IF($F' + разом + '=0;"";1)');
  }
  if (влЗаг && влРаз) {
    кш.getRange(влРаз, 6).setFormula('=SUMIF($I$' + (влЗаг + 1) + ':$I$' + (влРаз - 1) +
      ';"OH";$F$' + (влЗаг + 1) + ':$F$' + (влРаз - 1) + ')');
  }
}

function додатиРядок() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const ui = SpreadsheetApp.getUi();
  if (ss.getActiveSheet().getName() !== 'Кошторис') {
    ui.alert('Перейдіть на аркуш «Кошторис» і станьте на рядок потрібної категорії.'); return;
  }
  const кш = ss.getSheetByName('Кошторис');
  const r = кш.getActiveRange().getRow();
  if (r < П) { ui.alert('Станьте на рядок усередині потрібної категорії.'); return; }

  const last = кш.getLastRow();
  const тип = кш.getRange(1, 9, last, 1).getValues();
  var плюс = 0, власний = false;
  for (var z = r; z <= last; z++) {
    if (тип[z - 1][0] === 'P') { плюс = z; власний = false; break; }
    if (тип[z - 1][0] === 'PX') { плюс = z; власний = true; break; }
  }
  if (!плюс) { ui.alert('Станьте на рядок усередині потрібної категорії.'); return; }

  вставитиПорожній_(кш, плюс, власний);
}

function видалитиРядок() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const ui = SpreadsheetApp.getUi();
  if (ss.getActiveSheet().getName() !== 'Кошторис') {
    ui.alert('Перейдіть на аркуш «Кошторис».'); return;
  }
  const кш = ss.getSheetByName('Кошторис');
  const r = кш.getActiveRange().getRow();
  const тип = кш.getRange(r, 9).getValue();
  if (r < П || ['H', 'OH', 'T', 'O', 'OT', 'P', 'PX'].indexOf(тип) > -1) {
    ui.alert('Видаляти можна лише рядок позиції.'); return;
  }
  кш.deleteRow(r);
  перерахувати_(кш);
}

function очиститиКошторис() {
  const ui = SpreadsheetApp.getUi();
  const в = ui.alert('Очистити кошторис?',
    'Усі внесені позиції буде видалено (разом із блоком власних коштів). ' +
    'Структура категорій залишиться.', ui.ButtonSet.YES_NO);
  if (в !== ui.Button.YES) return;
  const кш = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Кошторис');
  const last = кш.getLastRow();
  const тип = кш.getRange(1, 9, last, 1).getValues();
  for (var r = П; r <= last; r++) {
    if (тип[r - 1][0] === '' || тип[r - 1][0] === 'X') {
      кш.getRange(r, 2, 1, 4).clearContent();
      кш.getRange(r, 2).clearNote();
    }
  }
  кш.getRange('B2').clearContent();
}

// ==================== ЗВʼЯЗОК З CRM ====================
//
// Кошторис зберігається одразу під угоду: файл отримує назву угоди,
// доступ за посиланням і сам прописується в поле «Таблиця з даними».
// Ключ доступу лежить у властивостях скрипта, а не в коді.

const API_КОШТОРИС = 'https://app.bdn.com.ua/api/estimate';

// Хто може РЕДАГУВАТИ створені кошториси. Решті — лише перегляд за
// посиланням. Додавайте пошти через кому, у лапках.
const РЕДАКТОРИ = [
  'borisenkodn86@gmail.com',
  'fopconsult.org.ua@gmail.com',
  'borisenkodv88@gmail.com',
];

function ключ_() {
  const k = PropertiesService.getScriptProperties().getProperty('ESTIMATE_SECRET');
  if (!k) throw new Error('Не задано ESTIMATE_SECRET у властивостях скрипта.');
  return k;
}

function запитCRM_(шлях, тіло) {
  тіло.secret = ключ_();
  const відп = UrlFetchApp.fetch(API_КОШТОРИС + шлях, {
    method: 'post', contentType: 'application/json',
    payload: JSON.stringify(тіло), muteHttpExceptions: true
  });
  const дані = JSON.parse(відп.getContentText() || '{}');
  if (дані.error) throw new Error(дані.error);
  return дані;
}

// УВАГА: назви цих двох функцій БЕЗ підкреслення в кінці. В Apps Script
// підкреслення робить функцію приватною, і google.script.run її не бачить —
// діалог тоді вічно висить на «Завантажую…».
function списокУгод() {
  return запитCRM_('/deals', {}).deals;
}

function перенестиСкрипт_(idФайла, назва) {
  // Копія таблиці приходить без скрипта: ні makeCopy, ні Drive.Files.copy
  // привʼязаний проєкт не переносять — перевірено на живих файлах. Єдиний
  // спосіб — створити проєкт у копії через Apps Script API і залити туди
  // той самий код.
  //
  // Потрібні дві речі, які робляться ОДИН раз:
  //   1. увімкнути Apps Script API: script.google.com/home/usersettings
  //   2. дозвіл script.projects у маніфесті проєкту
  // Поки їх немає — функція просто не спрацює, і кошторис збережеться без
  // інструментів. Зривати збереження вона не має права.
  const заг = { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() };
  const мій = ScriptApp.getScriptId();

  const вміст = JSON.parse(UrlFetchApp.fetch(
    'https://script.googleapis.com/v1/projects/' + мій + '/content',
    { headers: заг, muteHttpExceptions: true }).getContentText());
  if (!вміст.files) {
    throw new Error((вміст.error && вміст.error.message) || 'скрипт не прочитано');
  }

  const проєкт = JSON.parse(UrlFetchApp.fetch(
    'https://script.googleapis.com/v1/projects', {
      method: 'post', contentType: 'application/json', headers: заг,
      payload: JSON.stringify({ title: 'Кошторис — ' + назва, parentId: idФайла }),
      muteHttpExceptions: true }).getContentText());
  if (!проєкт.scriptId) {
    throw new Error((проєкт.error && проєкт.error.message) || 'проєкт не створено');
  }

  const відп = UrlFetchApp.fetch(
    'https://script.googleapis.com/v1/projects/' + проєкт.scriptId + '/content', {
      method: 'put', contentType: 'application/json', headers: заг,
      payload: JSON.stringify({ files: вміст.files }), muteHttpExceptions: true });
  const рез = JSON.parse(відп.getContentText());
  if (рез.error) throw new Error(рез.error.message);
  return true;
}


function зберегтиДляУгоди(id, назва) {
  const папки = DriveApp.getFoldersByName(ПАПКА);
  const папка = папки.hasNext() ? папки.next() : DriveApp.createFolder(ПАПКА);

  // Копіюємо через розширений сервіс Drive, а не через makeCopy: makeCopy
  // залишає скрипт позаду, і в клієнтському файлі немає ні меню, ні кнопок.
  // Якщо розширений сервіс недоступний — повертаємось до makeCopy, бо файл
  // має створитись у будь-якому разі.
  var файл;
  try {
    const копіяDrive = Drive.Files.copy(
      { title: назва, parents: [{ id: папка.getId() }] },
      SpreadsheetApp.getActiveSpreadsheet().getId());
    файл = DriveApp.getFileById(копіяDrive.id);
  } catch (e) {
    Logger.log('Drive.Files.copy не спрацював: ' + e.message);
    файл = DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId())
      .makeCopy(назва, папка);
  }
  const копія = SpreadsheetApp.openById(файл.getId());
  копія.getSheetByName('Кошторис').getRange('B2').setValue(назва);
  SpreadsheetApp.flush();

  // Усім за посиланням — перегляд; названим особам — редагування.
  файл.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
  if (РЕДАКТОРИ.length) {
    // Помилка в одній пошті не повинна зривати збереження: файл уже
    // створено, і кошторис має потрапити в CRM у будь-якому разі.
    РЕДАКТОРИ.forEach(function (пошта) {
      try { файл.addEditor(пошта); } catch (e) { Logger.log('не додано ' + пошта + ': ' + e.message); }
    });
  }
  // Інструменти в копію — якщо дозволи вже видані. Не вийшло — не біда:
  // кошторис усе одно збережеться і потрапить у картку.
  try {
    перенестиСкрипт_(файл.getId(), назва);
  } catch (e) {
    Logger.log('скрипт у копію не перенесено: ' + e.message);
  }

  запитCRM_('/attach', { dealId: id, url: файл.getUrl() });
  return файл.getUrl();
}

function створитиФайлКлієнта() {
  const html = HtmlService.createHtmlOutput(
    '<div style="font-family:Arial;padding:10px;font-size:13px">' +
    '<p style="margin:0 0 8px">Оберіть угоду — файл отримає її назву, доступ за ' +
    'посиланням і сам пропишеться в CRM у полі «Таблиця з даними».</p>' +
    '<select id="u" style="width:100%;padding:6px;font-size:13px">' +
    '<option>Завантажую…</option></select>' +
    '<div style="margin-top:10px"><button id="b" onclick="go()" disabled ' +
    'style="padding:7px 16px;font-size:13px">Створити</button> ' +
    '<span id="s" style="color:#666;margin-left:8px"></span></div></div>' +
    '<script>' +
    'google.script.run.withSuccessHandler(function(d){' +
    'var s=document.getElementById("u");s.innerHTML="";' +
    'if(!d||!d.length){s.innerHTML="<option>Немає угод на потрібних етапах</option>";return;}' +
    'var g={};d.forEach(function(x){(g[x.step]=g[x.step]||[]).push(x);});' +
    'Object.keys(g).forEach(function(k){var o=document.createElement("optgroup");o.label=k;' +
    'g[k].forEach(function(x){var e=document.createElement("option");e.value=x.id;' +
    'e.text=x.name;o.appendChild(e);});s.appendChild(o);});' +
    'document.getElementById("b").disabled=false;})' +
    '.withFailureHandler(function(e){document.getElementById("u").innerHTML=' +
    '"<option>"+e.message+"</option>";}).списокУгод();' +
    'function go(){var s=document.getElementById("u");' +
    'var id=s.value,nm=s.options[s.selectedIndex].text;' +
    'document.getElementById("b").disabled=true;' +
    'document.getElementById("s").innerText="Створюю…";' +
    'google.script.run.withSuccessHandler(function(u){' +
    'document.getElementById("s").innerHTML="Готово: <a href=\\""+u+"\\" target=\\"_blank\\">відкрити</a>";})' +
    '.withFailureHandler(function(e){document.getElementById("b").disabled=false;' +
    'document.getElementById("s").innerText="Помилка: "+e.message;})' +
    '.зберегтиДляУгоди(id,nm);}' +
    '</script>').setWidth(520).setHeight(230);
  SpreadsheetApp.getUi().showModalDialog(html, 'Створити кошторис для клієнта');
}


// ==================== PDF ====================
//
// У PDF ідуть ТІЛЬКИ заповнені рядки і ТІЛЬКИ грантові кошти. Порожні
// категорії не друкуються взагалі, блок власних коштів не друкується
// ніколи — це вимога до документа, який іде на комісію.
//
// Документ збирається з нуля в тимчасовій таблиці, а не ховає зайве в
// цій: інакше в PDF потрапили б приховані службові стовпці й формули,
// що посилаються на оригінал.

function зібратиГрантові_(ss) {
  const кш = (ss || SpreadsheetApp.getActiveSpreadsheet()).getSheetByName('Кошторис');
  if (!кш) throw new Error('У файлі немає аркуша «Кошторис».');
  const last = кш.getLastRow();
  const дані = кш.getRange(1, 1, last, 9).getValues();
  const блоки = [];
  var поточний = null;

  for (var r = П; r <= last; r++) {
    const тип = String(дані[r - 1][8] || '');
    if (тип === 'O' || тип === 'T') break;          // далі власні кошти або підсумок
    if (тип === 'H') {
      поточний = { код: String(дані[r - 1][0] || ''), назва: String(дані[r - 1][1] || ''),
                   позиції: [] };
      блоки.push(поточний);
      continue;
    }
    if (тип !== '') continue;                        // рядки «+» та службові
    if (!поточний) continue;
    const назва = String(дані[r - 1][1] || '').trim();
    const сума = Number(дані[r - 1][5]) || 0;
    if (!назва || !сума) continue;                   // порожній рядок не друкуємо
    поточний.позиції.push([назва, Number(дані[r - 1][2]) || 0,
                           Number(дані[r - 1][3]) || 0,
                           String(дані[r - 1][4] || ''), сума]);
  }
  return блоки.filter(function (b) { return b.позиції.length; });
}

function сформуватиPDF() {
  зробитиPDF_(SpreadsheetApp.getActiveSpreadsheet());
}


function PDFзФайлу() {
  // Клієнтські файли лишаються без скрипта, тож PDF для них робимо звідси:
  // читаємо чужу таблицю за посиланням і збираємо документ так само.
  const ui = SpreadsheetApp.getUi();
  const р = ui.prompt('PDF з файлу клієнта',
    'Вставте посилання на файл кошторису:', ui.ButtonSet.OK_CANCEL);
  if (р.getSelectedButton() !== ui.Button.OK) return;
  const м = String(р.getResponseText() || '').match(/[-\w]{25,}/);
  if (!м) { ui.alert('У посиланні не знайдено ідентифікатор файлу.'); return; }
  try {
    зробитиPDF_(SpreadsheetApp.openById(м[0]));
  } catch (e) {
    ui.alert('Не вдалося: ' + e.message);
  }
}


function зробитиPDF_(ss) {
  const ui = SpreadsheetApp.getUi();
  const клієнт = String(ss.getSheetByName('Кошторис').getRange('B2').getValue() || '').trim();

  const блоки = зібратиГрантові_(ss);
  if (!блоки.length) {
    ui.alert('У кошторисі немає жодної заповненої позиції грантових коштів.');
    return;
  }

  var разом = 0;
  блоки.forEach(function (b) {
    b.сума = 0;
    b.позиції.forEach(function (p) { b.сума += p[4]; });
    разом += b.сума;
  });

  const тимч = SpreadsheetApp.create('__pdf__');
  const а = тимч.getSheets()[0];
  а.setName('Кошторис');

  const рядки = [];
  рядки.push(['КОШТОРИС ВИТРАТ ГРАНТОВИХ КОШТІВ', '', '', '', '']);
  рядки.push([клієнт ? 'Клієнт: ' + клієнт : '', '', '', '', '']);
  рядки.push(['', '', '', '', '']);
  рядки.push(['Найменування', 'Ціна за од., грн', 'Кільк.', 'Од.', 'Сума, грн']);

  const заголовки = [], підсумки = [];
  блоки.forEach(function (b) {
    заголовки.push(рядки.length + 1);
    рядки.push([b.код + '. ' + b.назва, '', '', '', b.сума]);
    b.позиції.forEach(function (p) { рядки.push(p); });
  });
  підсумки.push(рядки.length + 1);
  рядки.push(['РАЗОМ ЗА КОШТОРИСОМ:', '', '', '', разом]);

  а.getRange(1, 1, рядки.length, 5).setValues(рядки);

  а.getRange('A1:E1').merge().setFontSize(13).setFontWeight('bold')
    .setBackground(СИНІЙ).setFontColor('#ffffff').setHorizontalAlignment('center');
  а.setRowHeight(1, 30);
  а.getRange('A2:E2').merge().setFontWeight('bold');
  а.getRange(4, 1, 1, 5).setFontWeight('bold').setBackground(ЗАГОЛОВОК)
    .setHorizontalAlignment('center').setWrap(true);
  заголовки.forEach(function (r) {
    а.getRange(r, 1, 1, 5).setFontWeight('bold').setBackground(ЗАГОЛОВОК);
  });
  підсумки.forEach(function (r) {
    а.getRange(r, 1, 1, 5).setFontWeight('bold').setBackground('#ffe699');
  });

  а.getRange(4, 1, рядки.length - 3, 5).setBorder(true, true, true, true, true, true);
  а.getRange(5, 2, рядки.length - 4, 1).setNumberFormat('#,##0.00');
  а.getRange(5, 3, рядки.length - 4, 1).setNumberFormat('#,##0');
  а.getRange(5, 5, рядки.length - 4, 1).setNumberFormat('#,##0.00');
  а.getRange(5, 3, рядки.length - 4, 2).setHorizontalAlignment('center');
  а.getRange(1, 1, рядки.length, 1).setWrap(true);
  а.setColumnWidth(1, 420); а.setColumnWidth(2, 110); а.setColumnWidth(3, 60);
  а.setColumnWidth(4, 55); а.setColumnWidth(5, 120);
  SpreadsheetApp.flush();

  const адреса = 'https://docs.google.com/spreadsheets/d/' + тимч.getId() + '/export?' + [
    'format=pdf', 'portrait=true', 'fitw=true', 'gridlines=false', 'sheetnames=false',
    'printtitle=false', 'pagenumbers=false', 'top_margin=0.5', 'bottom_margin=0.5',
    'left_margin=0.5', 'right_margin=0.5', 'gid=' + а.getSheetId()].join('&');

  const назва = 'Кошторис' + (клієнт ? ' — ' + клієнт : '') + '.pdf';
  var файлPDF;
  try {
    const blob = UrlFetchApp.fetch(адреса, {
      headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() }
    }).getBlob().setName(назва);

    const папки = DriveApp.getFoldersByName(ПАПКА);
    const папка = папки.hasNext() ? папки.next() : DriveApp.createFolder(ПАПКА);
    файлPDF = папка.createFile(blob);
  } finally {
    DriveApp.getFileById(тимч.getId()).setTrashed(true);
  }

  const html = HtmlService.createHtmlOutput(
    '<p style="font-family:Arial;font-size:14px">Готово. Категорій у документі: <b>' +
    блоки.length + '</b>, на суму <b>' + разом.toFixed(2) + ' грн</b>.</p>' +
    '<p style="font-family:Arial;font-size:14px"><a href="' + файлPDF.getUrl() +
    '" target="_blank"><b>' + назва + '</b></a></p>').setWidth(430).setHeight(150);
  ui.showModalDialog(html, 'PDF сформовано');
}


function дооснаститиФайл() {
  // Скрипт переноситься в копію лише при створенні. Кошториси, збережені
  // раніше, лишаються без меню — цей пункт дає їх дооснастити, не
  // перестворюючи файл і не втрачаючи вже внесені дані.
  const ui = SpreadsheetApp.getUi();
  const р = ui.prompt('Додати інструменти',
    'Вставте посилання на файл кошторису клієнта:', ui.ButtonSet.OK_CANCEL);
  if (р.getSelectedButton() !== ui.Button.OK) return;

  const м = String(р.getResponseText() || '').match(/[-\w]{25,}/);
  if (!м) { ui.alert('У посиланні не знайдено ідентифікатор файлу.'); return; }

  var назва = '';
  try {
    назва = DriveApp.getFileById(м[0]).getName();
  } catch (e) {
    ui.alert('Файл не знайдено або немає доступу:\n' + e.message);
    return;
  }

  try {
    перенестиСкрипт_(м[0], назва);
  } catch (e) {
    ui.alert('Не вдалося додати інструменти.\n\n' + e.message +
      '\n\nПеревірте: увімкнено Apps Script API на script.google.com/home/usersettings ' +
      'і в маніфесті є дозвіл script.projects.');
    return;
  }
  ui.alert('Готово: «' + назва + '».\n\nВідкрийте файл заново — меню зʼявиться ' +
           'після перезавантаження сторінки.');
}