// estimate-builder.jsx — Кошторис (Додаток № 4) до КП і до Договору. // Форми за Настановою (наказ Мінрегіону № 281 від 01.11.2021). Нумерація наскрізна й динамічна // (обстеження можна вимкнути — тоді решта перенумеровується): // • Кошторис № 1 — Зведений (форма 1-П) — авто-зведення // • Технічне обстеження будівель і споруд (3-П) — вишукувальні; не потрібне для нового будівництва // • Проєктні роботи (3-П) — посади × люд-дн × показник // • Реєстрація в ЄДЕССБ (3-П) // • Експертиза (2-П) — ручна база + % + ПДВ (автоінтерполяція — пізніше) // • Розрахунок показника вартості 1 люд-дня (аркуш 6) // Кошторис живе полем `estimate` на записі КП; при друці КП і Договору рендериться як Додаток № 4. // Показник — глобальний (window.ESTIMATE_RATE), редагується тут і зберігається через PUT /estimate-rate. // ─────────────────────────── Обчислення показника (люд-день) ─────────────────────────── function esN(v, d) { const n = Number(v); return isFinite(n) ? n : (d || 0); } function esRound2(n) { return Math.round((Number(n) || 0) * 100) / 100; } function esRnd(n, d) { const f = Math.pow(10, d); return Math.round((Number(n) || 0) * f) / f; } // Повний ланцюг розрахунку показника з аркуша 6 (звірено з кошторисом О-1: 1780×2,2216=3954,448). window.estimateRate = function (over) { const r = Object.assign({}, window.ESTIMATE_RATE || {}, over || {}); const hoursPerMonth = esN(r.hoursPerMonth, 171.75); // 1.1 const hoursPerDay = esN(r.hoursPerDay, 8); // 1.2 const baseIndicator = esN(r.baseIndicator, 1780); // 1.4 const monthlyWageBase38 = esN(r.monthlyWageBase38, 10628.39); // 1.5 const rankCoef7 = esN(r.rankCoef7, 2.075); // 1.7 const rankCoef38 = esN(r.rankCoef38, 1.308); // 1.8 const esvPct = esN(r.esvPct, 22); // 1.13 const currentWage38 = esN(r.currentWage38, 34748); // 3.1 // Методика округлює кожен нумерований рядок до показаної точності і далі рахує // від округленого значення (звірено з кошторисом О-1: 1780×2,2216=3954,448). const daysPerMonth = hoursPerDay ? esRnd(hoursPerMonth / hoursPerDay, 4) : 0; // 1.3 const dailyWageBase38 = daysPerMonth ? esRnd(monthlyWageBase38 / daysPerMonth, 4) : 0; // 1.6 const rankRatio = rankCoef38 ? esRnd(rankCoef7 / rankCoef38, 4) : 0; // 1.9 const dailyWage7 = esRnd(dailyWageBase38 * rankRatio, 4); // 1.10 const shareWage = baseIndicator ? esRnd(dailyWage7 / baseIndicator, 4) : 0; // 1.11 const shareWageWithEsv = esRnd(shareWage * (1 + esvPct / 100), 4); // 1.14 const shareOther = esRnd(1 - shareWageWithEsv, 4); // 2.1 const currentWage7 = esRound2(currentWage38 * rankRatio); // 3.2 const coefWageChange = monthlyWageBase38 ? esRnd(currentWage38 / monthlyWageBase38, 5) : 0; // 3.3 const coefContract = esRnd(shareWageWithEsv * coefWageChange + shareOther, 4); // 3.4 const manDay = esRnd(baseIndicator * coefContract, 3); // 4 return { year: r.year, hoursPerMonth, hoursPerDay, baseIndicator, monthlyWageBase38, rankCoef7, rankCoef38, esvPct, currentWage38, daysPerMonth, dailyWageBase38, rankRatio, dailyWage7, shareWage, shareWagePct: shareWage * 100, shareWageWithEsv, shareOther, currentWage7, coefWageChange, coefContract, manDay, }; }; // Готовий об'єкт для збереження в ESTIMATE_RATE (входи + результат). window.estimateRatePayload = function (over) { const c = window.estimateRate(over); return { year: c.year, hoursPerMonth: c.hoursPerMonth, hoursPerDay: c.hoursPerDay, baseIndicator: c.baseIndicator, monthlyWageBase38: c.monthlyWageBase38, rankCoef7: c.rankCoef7, rankCoef38: c.rankCoef38, esvPct: c.esvPct, currentWage38: c.currentWage38, manDay: c.manDay, }; }; window.estimateManDay = function () { return window.estimateRate().manDay; }; // ─────────────────────────── Підсумки кошторису ─────────────────────────── window.estimateTotals = function (est, manDayOverride) { est = est || {}; const manDay = manDayOverride != null ? manDayOverride : esN(est.manDayRate, window.estimateManDay()); const sumRows = (rows) => (rows || []).reduce((s, r) => s + esN(r.count, 1) * esN(r.days) * manDay, 0); const survey = est.survey || {}; // Обстеження показуємо лише коли блок присутній (нові кошториси) і не вимкнений вручну. // Старі збережені кошториси без цього блока лишаються без порожнього аркуша. const surveyEnabled = est.survey != null && survey.enabled !== false; const surveyExact = surveyEnabled ? sumRows(survey.rows) : 0; const projectExact = sumRows(est.project && est.project.rows); const registrationExact = sumRows(est.registration && est.registration.rows); const ex = est.expertise || {}; const expEnabled = ex.enabled !== false; const vatPct = esN(ex.vatPct, 20); const expNoVat = expEnabled ? esN(ex.sumNoVat) : 0; const expVat = esRound2(expNoVat * vatPct / 100); const expExact = expNoVat * (1 + vatPct / 100); const expTotal = esRound2(expExact); // ПДВ на проєктні роботи й реєстрацію нараховуємо, лише якщо виконавець — платник ПДВ (ТОВ). // ФОП на спрощеній системі (напр. зразок «Їдальня О-1») працює без ПДВ. const worksVat = est.execEntity === "tov"; const worksVatPct = worksVat ? esN(est.worksVatPct, 20) : 0; const surveyVat = esRound2(surveyExact * worksVatPct / 100); const projectVat = esRound2(projectExact * worksVatPct / 100); const registrationVat = esRound2(registrationExact * worksVatPct / 100); const surveyTotal = esRound2(surveyExact + surveyVat); const projectTotal = esRound2(projectExact + projectVat); const registrationTotal = esRound2(registrationExact + registrationVat); const worksNet = surveyExact + projectExact + registrationExact; const worksVatSum = esRound2(surveyVat + projectVat + registrationVat); const grand = esRound2(surveyTotal + projectTotal + registrationTotal + expTotal); // Наскрізна нумерація кошторисів: № 1 — зведений, далі за складом (обстеження перед проєктними). let seq = 1; const num = {}; if (surveyEnabled) num.survey = ++seq; num.project = ++seq; num.registration = ++seq; if (expEnabled) num.expertise = ++seq; return { manDay, expEnabled, vatPct, surveyEnabled, num, surveyExact, projectExact, registrationExact, surveyTotal, projectTotal, registrationTotal, expNoVat, expVat, expExact, expTotal, grand, worksVat, worksVatPct, surveyVat, projectVat, registrationVat, worksNet, worksVatSum, // Зведений (1-П): вишукувальні (обстеження) / проєктні / додаткові / всього — з точних // (нерозкруглених) сум, без ПДВ на роботи (ПДВ показуємо окремим рядком у зведеному). summary: { vyshukuvalnyh: surveyExact, proektnih: projectExact, dodatkovih: registrationExact + expExact, vsogo: surveyExact + projectExact + registrationExact + expExact, }, }; }; // Технічне обстеження не потрібне лише для нового будівництва — визначаємо за назвою об'єкта // («Нове будівництво…», «Нового будівництва…»). Реконструкція/капремонт/тех.переоснащення потребують. window.isNewConstruction = function (name) { return /нов\S*\s+будівництв/i.test(String(name || "")); }; // Стандартний склад команди (посади) — стартові рядки нового кошторису. window.defaultEstimate = function (seed) { seed = seed || {}; const pos = (p, days) => ({ pos: p, count: 1, days: days || 0 }); return { objectName: seed.objectName || "", objectAddress: seed.objectAddress || "", execEntity: seed.execEntity || "fop", consequenceClass: seed.consequenceClass || "СС1", stage: seed.stage || "Робочий проєкт (РП)", gip: seed.gip || "", manDayRate: window.estimateManDay(), survey: { enabled: !window.isNewConstruction(seed.objectName), // потрібне завжди, крім нового будівництва workName: "Технічне обстеження будівель і споруд", rows: [ pos("Головний інженер проєкту"), pos("Архітектор"), pos("Інженер-проєктувальник КБ"), pos("Інженер-проєктувальник ОВ"), pos("Інженер-проєктувальник ВК"), pos("Інженерка з проєктно-кошторисної роботи"), ], }, project: { workName: "Проєктні роботи", rows: [ pos("Головний інженер проєкту"), pos("Архітектор"), pos("Інженер-проєктувальник КБ"), pos("Інженер-проєктувальник ОВ"), pos("Інженер-проєктувальник ВК"), pos("Еколог"), pos("Провідна інженерка з проєктно-кошторисної роботи"), pos("Інженерка з проєктно-кошторисної роботи"), ], }, registration: { workName: "Реєстрація проєктної документації в ЄДЕССБ", rows: [pos("Директор", 1)], }, expertise: { enabled: true, base: 0, sumNoVat: 0, vatPct: 20, calcText: "Експертиза за двома напрямами (міцність, надійність, довговічність та кошторисна частина). " + "Розрахунок згідно з Настановою з визначення вартості ПВР (наказ Мінрегіону № 281 від 01.11.2021), " + "розділ VII, п. 7.1–7.7, Додаток 6.", }, }; }; // Зразок «Їдальня О-1» — заповнює трудовитрати й експертизу реальними числами. window.estimateSampleO1 = function (est) { const days = { "Головний інженер проєкту": 2, "Архітектор": 2, "Інженер-проєктувальник КБ": 4, "Інженер-проєктувальник ОВ": 4, "Інженер-проєктувальник ВК": 4, "Еколог": 1, "Провідна інженерка з проєктно-кошторисної роботи": 0.5, "Інженерка з проєктно-кошторисної роботи": 5 }; const e = JSON.parse(JSON.stringify(est || window.defaultEstimate())); e.project.rows = e.project.rows.map(r => ({ ...r, days: days[r.pos] != null ? days[r.pos] : r.days })); e.registration.rows = [{ pos: "Директор", count: 1, days: 1 }]; e.expertise = { ...e.expertise, enabled: true, base: 22000000, sumNoVat: 20111.93, vatPct: 20 }; if (!e.objectName) e.objectName = "Капітальний ремонт їдальні солдатської № 101300012 за генпланом літера «О-1» військової частини 3014 Національної гвардії України (м. Одеса)"; if (!e.objectAddress) e.objectAddress = "м. Одеса, вул. Люстдорфська дорога, 7"; return e; }; // ─────────────────────────── Форматування чисел ─────────────────────────── function esFmt(n, min, max) { return Number(n || 0).toLocaleString("uk-UA", { minimumFractionDigits: min, maximumFractionDigits: max }); } function esMoney(n) { return esFmt(n, 2, 2); } // грн function esNum3(n) { return esFmt(n, 2, 3); } // люд-дн / показник (до 3 знаків) function esThous(n) { return esFmt((Number(n) || 0) / 1000, 2, 6); } // тис грн (зведений) function esWords(n) { return window.moneyInWords ? window.moneyInWords(esRound2(n)) : ""; } // ═══════════════════════════ КОНСТРУКТОР ═══════════════════════════ function EstimateBuilder({ initial, seed, onClose, onSave }) { const [est, setEst] = React.useState(() => { const base = initial ? JSON.parse(JSON.stringify(initial)) : window.defaultEstimate(seed); if (!base.survey) base.survey = window.defaultEstimate(seed).survey; // бекфіл блока «Технічне обстеження» для старих кошторисів return base; }); const [rate, setRate] = React.useState(() => Object.assign({}, window.ESTIMATE_RATE || {})); const [showCalc, setShowCalc] = React.useState(false); const [savingRate, setSavingRate] = React.useState(false); const [rateSaved, setRateSaved] = React.useState(false); const [print, setPrint] = React.useState(false); // Обстеження авто-синхронізується з назвою об'єкта, поки користувач не перемкне галочку вручну. const [surveyAuto, setSurveyAuto] = React.useState(() => !initial); React.useEffect(() => { const onKey = (e) => e.key === "Escape" && !print && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose, print]); const calc = window.estimateRate(rate); // живий показник (з полів калькулятора) const manDay = calc.manDay; const tot = window.estimateTotals(est, manDay); // підсумки на поточному показнику const setE = (patch) => setEst(p => ({ ...p, ...patch })); const setRateField = (k, v) => { setRate(p => ({ ...p, [k]: v })); setRateSaved(false); }; // Зміна назви об'єкта: якщо не перемикали галочку вручну — авто-вмикаємо/вимикаємо обстеження. const setObjectName = (v) => setEst(p => { const next = { ...p, objectName: v }; if (surveyAuto && p.survey) next.survey = { ...p.survey, enabled: !window.isNewConstruction(v) }; return next; }); // рядки таблиць const editRow = (form, i, k, v) => setEst(p => { const rows = p[form].rows.slice(); rows[i] = { ...rows[i], [k]: v }; return { ...p, [form]: { ...p[form], rows } }; }); const addRow = (form) => setEst(p => ({ ...p, [form]: { ...p[form], rows: [...p[form].rows, { pos: "", count: 1, days: 0 }] } })); const delRow = (form, i) => setEst(p => ({ ...p, [form]: { ...p[form], rows: p[form].rows.filter((_, j) => j !== i) } })); const setExp = (k, v) => setEst(p => ({ ...p, expertise: { ...p.expertise, [k]: v } })); const setFormEnabled = (form, v) => setEst(p => ({ ...p, [form]: { ...p[form], enabled: v } })); const applySample = () => setEst(window.estimateSampleO1(est)); const saveRateGlobal = async () => { setSavingRate(true); const payload = window.estimateRatePayload(rate); try { if (window.API && window.API.put) await window.API.put("/estimate-rate", payload); window.ESTIMATE_RATE = payload; setRateSaved(true); } catch (e) { console.error("[ERP] save estimate-rate failed:", e); alert("Не вдалося зберегти показник на сервері."); } finally { setSavingRate(false); } }; const doSave = () => { const out = JSON.parse(JSON.stringify(est)); out.manDayRate = manDay; out.rateSnapshot = window.estimateRatePayload(rate); // знімок параметрів показника (для аркуша «Розрахунок») out.total = tot.grand; onSave && onSave(out, tot.grand); onClose(); }; // Викликаємо як функції (не як -компоненти), інакше React перемонтовує піддерево // на кожен рендер і поля вводу втрачають фокус після кожної клавіші. const rateRow = (n, label, val, unit) => (
{n}{label} {val}{unit ? {unit} : null}
); const renderFormTable = (form, title, subtitle, opts) => { opts = opts || {}; const f = est[form]; const enabled = !opts.togglable || f.enabled !== false; const heading = opts.num ? `Кошторис № ${opts.num} — ${title}` : title; const rows = f.rows; const sub = rows.reduce((s, r) => s + esN(r.count, 1) * esN(r.days) * manDay, 0); const vat = esRound2(sub * tot.worksVatPct / 100); return (
{opts.togglable ? : <>{heading} {subtitle}} {opts.togglable && opts.hint && {opts.hint}}
{enabled && <> {rows.map((r, i) => ( ))} {tot.worksVat && } {tot.worksVat && }
Найменування посад виконавців К-сть, люд.Трудовитрати, люд.-дн. Показник, грн/деньСума, грн
{i + 1} editRow(form, i, "pos", e.target.value)} placeholder="Посада" /> editRow(form, i, "count", e.target.value)} /> editRow(form, i, "days", e.target.value)} /> {esNum3(manDay)} {esNum3(esN(r.count, 1) * esN(r.days) * manDay)}
Разом{tot.worksVat ? " без ПДВ" : ""}:{esMoney(sub)}
ПДВ {tot.worksVatPct}%:{esMoney(vat)}
Всього з ПДВ:{esMoney(esRound2(sub + vat))}
}
); }; return (
Кошторис
форми 1-П · 2-П · 3-П · показник {esNum3(manDay)} грн/люд-день
Заповніть трудовитрати (люд.-дні) — суми рахуються за показником. Зведений кошторис збереться автоматично.
{/* Шапка об'єкта */}