Files
Aislo/tools/stmate-test/report_cost_fmla.js
T
eomsangdonandClaude Opus 5.5 01ed06f5f6 feat(stmate-test): 총괄내역서 원가 줄·공사원가계산서 엑셀 수식(F5-31) · 화면 파일 캐시 확인(no-cache) · 장부 95.9%
- report_cost_fmla.js — 원가 줄 D·E 수식 · H 숨은 메타 · I~P 보정·요율 칸 · 이윤 끝전 사슬 · 공사원가계산서 참조·소계·구성비·바로가기(변경 양식 포함)
- 원본 5건(봉화·영월·대흥·울진 착공·영덕 변경) 1392칸 수식 글·값 일치 · 24·25판 괄호 구분(Ghidra 0x494b53)
- server.py — /api 밖 응답 Cache-Control: no-cache(고친 뒤 옛 js 가 섞여 「require is not defined」 나던 문제)
- 브라우저 확인(봉화2024): 전체 엑셀 내보내기에 원가 줄 수식 포함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:46:31 +09:00

373 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* -*- coding: utf-8 -*- */
/**
* 원가 줄 엑셀 수식 (report_cost_fmla.js) — 총괄내역서(0601) 원가 줄 · 공사원가계산서(0608) 칸 수식
* 근거: 지식DB STmate/REA 분석/38_보고서_잔여_작업자료_20261114/38b_F5-31_원가줄수식.md
* (ST_P_XLS 0x494b53 식 틀 · 0x4b8c6c 숨은 메타 · 원본 출력 엑셀 5건 수식 실측)
* 정답 대조: resources/tester/test_stmate_report_cost_fmla.py(원본 수식 글 · 값 그대로)
* 총괄 원가 줄(가. 아래) — D 금액 식 · E 설명 식 · H 숨은 메타 · I~P 보정·요율·추가액 칸(이윤 끝전 J·K·L 사슬)
* 공사원가계산서 — D(변경 양식 D·E) = 총괄 행 참조 · 구성비 = D/총공사비 · 비고 = 총괄 E 참조 · 바로가기
* 원본에 없는 갈래(수식 꼴 미확인)는 값 그대로 둠 — 값은 계산 엔진(kanjub_calc) 값
*/
const StmateReportCostFmla = (() => {
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
// 세부공종코드(D_TYP) — 조달청 공사제경비 코드(report_pps 와 같은 표) · 원본 메타 `DTP|` 실측
const DTP = { KANNO: 201, SANBO: 301, KOBO: 302, KUNBO: 303, SLVBO: 310, YUNBO: 304 };
Object.assign(DTP, { TBUGUM: 305, ANJUN: 306, KITA: 321, HWAN: 309, EQIP: 311 });
Object.assign(DTP, { NA: 400, ILBAN: 401, DA: 410, IYUN: 402, RA: 500, VAT: 502 });
Object.assign(DTP, { MA: 600, GOV: 653, BA: 700 });
const SUBS = new Set(["NA", "DA", "RA", "MA", "BA"]);
const sre = (c) =>
c === "KANNO"
? "LA"
: SUBS.has(c)
? "TA-F"
: c === "ILBAN" || c === "IYUN" || c === "VAT"
? "TA"
: c === "GOV"
? "TA-3"
: c === "ADD"
? ""
: "SA";
const CHG = new Set(["KUNBO", "SLVBO", "YUNBO", "TBUGUM"]);
const ITEM_CODES = ["KANNO", "SANBO", "KOBO", "KUNBO", "SLVBO", "YUNBO", "TBUGUM", "ANJUN"];
ITEM_CODES.push("KITA", "HWAN", "EQIP", "ILBAN", "IYUN");
/** 차수 하나의 수식 재료 — 설정 키(차수 접두 뗌) · 계산 결과 · 판 번호(PRG_V) */
function kanInfo(stage, dbf) {
const g = globalThis.STMATE_KANJUB_PARTS || require("./kanjub_parts.js");
const P = g({ num });
const cfg = (stage && stage.cfg) || {};
const o = P.reader(cfg, null);
const k = stage.kanjub || {};
const ini = (dbf && dbf.start_ini) || {};
const fixed = ITEM_CODES.filter((c) => o.fix(c) != null);
const keysOut = k.keysOut || {};
return {
slot: num(stage.slot) || 1,
v25: num(ini.PRG_V) >= 25,
totJeol: o.k("I_TOT_JEOL"),
buga: o.k("I_BUGA"),
bugaUp: num(keysOut.B_BUGA_UP ?? o.k("B_BUGA_UP")),
exempt: o.k("C_BUGA_M"),
iyunM: num(k.pretaxProfitAdj),
kanJeol: o.k("I_KAN_JEOLSA"),
kwnJeol: o.k("I_KWN_JEOLSA"),
kwnUpdn: o.k("I_KWN_UPDN"),
rat5: o.k("I_ANJUN_K") * 10 + o.k("I_ANJUN_B"),
anjunAuto: o.k("I_ANJUNAUTO"),
bfch: o.k("I_BF_CH_BO"),
hol: o.k("C_EIS_HOLLAB"),
anjunAdd: num(cfg.anjunBaseAdd ?? o.k("C_ANJUN_G")),
govMat: num(k.govRawTotal) + num(k.govFeeTotal),
govFee: num(k.govFeeTotal),
fixed,
};
}
const dig = (n) => (num(n) ? String(-num(n)) : "0");
const setF = (row, col, v) => ((row._f = row._f || {}), (row._f[col] = v));
// 설명 식 — 「밑 x 율%」 글의 율 자리를 M 칸 ROUND 로(원본 꼴)
const pctNote = (text, mref) => {
const m = /^(.*) x ([\d.]+)%$/s.exec(String(text || ""));
return m ? `="${m[1]} x "&ROUND(${mref},5)&"%"` : null;
};
/**
* 숨은 메타(H) — ST_P_XLS 0x4b8c6c: 0x07 + `키|값 0x05 … + ` · 첫 차수 줄 = DTP·QTY·BDC·SRE
* 둘째 차수 줄 = DTP·QTY · 덧붙임 DRT(고정) · CBO(변경 당초액 잇기) · ANB·ANK(안전 RAT5) · ADN·ADB(추가금액)
*/
function meta(row, info, first) {
const c = row._c;
const add = c === "ADD" ? row._add : null;
const dtp = add ? (add[0] + 2) * 100 + 60 + add[1] : DTP[c];
if (!dtp) return null;
const n = info.slot;
const p = [`DTP|${dtp}`, `QTY${n}|${n}`];
if (first)
p.push(`BDC|${add ? `A${add[0]}${String(add[1]).padStart(2, "0")}` : ""}`, `SRE|${sre(c)}`);
if (info.fixed.includes(c)) p.push("DRT|T");
if (n > 1 && CHG.has(c) && (info.bfch === 1 || info.bfch === 2)) p.push(`CBO|${info.bfch}`);
if (n > 1 && c === "ANJUN" && (info.anjunAuto === 1 || info.anjunAuto === 2))
p.push(`CBO|${info.anjunAuto}`);
if (c === "ANJUN" && info.rat5) {
if (info.rat5 % 10) p.push("ANB|1");
const kk = Math.floor(info.rat5 / 10);
if (kk >= 1 && kk <= 5) p.push(`ANK|${kk}`);
}
if (add) p.push(`ADN|${String(row.B || "").replace(/^\s*\d+\.\s*/, "")}`, "ADB|");
return "_x0007_" + p.map((x) => "`" + x + "_x0005_").join("") + "`";
}
/** 총괄 원가 줄 — 차수(_s)마다 가. 줄부터 차례로 */
function linkSummaryCost(sm) {
const kan = sm._kan || [];
const at = kan.map(() => ({ seq: [] }));
sm.rows.forEach((r, i) => {
if (!r._c || !at[r._s || 0]) return;
const p = at[r._s || 0];
p.seq.push([r, i + 1]);
if (r._c !== "ADD" && p[r._c] == null) p[r._c] = i + 1;
});
at.forEach((p, s) => {
if (p.GA == null) return;
p.last = p.BA || p.MA;
p.seq.forEach(([r, n]) => costRow(r, n, kan[s], p, at[0], s));
});
}
// 사이 합 — from 열쇠 줄부터 to 열쇠 줄 앞까지 · 열(D·J·L)
const span = (p, from, to, col) => {
const i = p.seq.findIndex(([r]) => r._c === from);
const k = p.seq.findIndex(([r]) => r._c === to);
return (
"=(" +
p.seq
.slice(i, k)
.map(([, n]) => `${col}${n}`)
.join("+") +
")"
);
};
function costRow(r, n, info, p, p0, s) {
const c = r._c;
if (c === "GA") return;
const g = p.GA,
J = dig(info.kanJeol);
const fx = info.fixed.includes(c);
for (const col of "IMNOP") setF(r, col, 0);
if (r._rt != null) setF(r, "M", r._rt);
const h = meta(r, info, s === 0);
if (h) setF(r, "H", h);
const note = String(r.E || "");
const pctD = (base) => setF(r, "D", `=ROUNDDOWN(${base}*M${n}/100,${J})`);
const pctE = () => {
const e = pctNote(note, `M${n}`);
if (e) setF(r, "E", e);
};
const v25 = info.v25; // 25 판 = ST_P_XLS 0x494b53 「([L_AMT][+LAB])」 겉 괄호 · 24 판 출력은 없음(원본 실측)
const lab = `(E${g}+D${p.KANNO})`;
const chain = () => (setF(r, "J", `=D${n}`), setF(r, "L", `=D${n}`));
const sum3 = (from, to) => {
for (const col of "DJL") setF(r, col, span(p, from, to, col));
};
switch (c) {
case "KANNO":
if (!fx && note.startsWith("직접노무비 x ")) (pctD(`E${g}`), pctE());
break;
case "SANBO":
case "KOBO":
if (!fx && !info.hol && note.startsWith("(직접노무비+간접노무비) x ") && p.KANNO)
(pctD(v25 ? `(${lab})` : lab), pctE());
break;
case "KUNBO":
case "YUNBO":
setF(r, "J", `=E${g}`);
if (!fx && note.startsWith("직접노무비 x ")) (pctD(`E${g}`), pctE());
break;
case "SLVBO":
if (p.KUNBO) setF(r, "J", `=D${p.KUNBO}`);
if (!fx && p.KUNBO && note.startsWith("건강보험료 x ")) (pctD(`D${p.KUNBO}`), pctE());
break;
case "ANJUN": {
const gov = info.govMat > 0 && p.GOV ? `+(I${p.GOV}-O${p.GOV})/1.1` : "";
const base = `((E${g}+(F${g}))${gov})`;
setF(r, "J", `=${base}`);
setF(r, "N", info.anjunAdd);
const b0 = p0.ANJUN;
if (fx) break;
if (s > 0 && b0 && note.startsWith("당초금액"))
setF(r, "D", `=D${b0}+ROUNDDOWN(D${b0}*((J${n}-J${b0})/J${b0}),${J})`);
else if (gov && String(r.C || "").startsWith("A)"))
setF(r, "D", `=ROUNDDOWN(${base}*M${n}/100+${info.anjunAdd},0)`);
break;
}
case "KITA":
if (!fx && !info.hol && note.startsWith("(직접노무비+간접노무비+재료비) x ") && p.KANNO) {
const b = `(${lab}+(F${g}))`;
(pctD(v25 ? `((${b}))` : `(${b})`), pctE());
}
break;
case "HWAN":
case "EQIP":
if (!fx && note.startsWith("(재료비 + 직접노무비 + 산출경비) x "))
(pctD(`(D${g})`), pctE());
break;
case "NA":
setF(r, "D", span(p, "GA", "NA", "D"));
break;
case "ILBAN":
if (!fx && note.startsWith("(나.소 계) x "))
(setF(r, "D", `=ROUNDDOWN(((D${p.NA})+0)*M${n}/100,0)`), pctE());
break;
case "DA":
setF(r, "D", `=(D${p.NA}+D${p.ILBAN})`);
setF(r, "I", info.totJeol);
chain();
break;
case "IYUN": {
const da = p.DA,
ba = p.last,
ga = `-(F${g}))*M${n}/100,0)`;
setF(r, "D", `=ROUNDDOWN(((D${da})${ga}-I${n}-K${n}`);
setF(r, "J", `=ROUNDDOWN(((J${da})${ga}`);
setF(r, "L", `=ROUNDDOWN(((L${da})${ga}-I${n}`);
const m = /^(.*) x ([\d.]+)% = [\d,]+$/s.exec(note);
if (m) setF(r, "E", `="${m[1]} x "&ROUND(M${n},5)&"% = "&TEXT(J${n},"#,##0")`);
if (info.totJeol > 0 && ba) {
const t = `ROUNDDOWN(J${ba},-I${da})`;
setF(r, "I", info.buga === 0 ? `=ROUND((J${ba}-${t})/1.1,0)` : `=J${ba}-${t}`);
setF(r, "K", `=ABS(L${ba}-${t})*IF(L${ba}>${t},1,-1)`);
} else setF(r, "I", info.iyunM);
break;
}
case "RA":
sum3("DA", "RA");
break;
case "VAT": {
setF(r, "I", info.bugaUp);
setF(r, "N", info.buga === 4 ? info.exempt : 0);
setF(r, "O", info.buga);
const ex = `(D${g}-E${g}-${info.exempt})`;
const f =
info.buga === 0
? (col) => `=ROUNDDOWN((${col}${p.RA})*M${n}/100,0)+I${n}`
: info.buga === 4
? () => `=ROUNDDOWN(${ex}*M${n}/100,0)+I${n}`
: null;
if (f && p.RA) {
for (const col of "DJL") setF(r, col, f(col));
pctE();
}
break;
}
case "MA":
sum3("RA", "MA");
setF(r, "O", info.totJeol);
break;
case "GOV": {
setF(r, "I", info.govMat);
setF(r, "M", info.kwnJeol);
setF(r, "O", info.govFee);
const fn = ["ROUNDUP", "ROUND", "ROUNDDOWN"][info.kwnUpdn];
if (fn && note.startsWith("원자재대:")) {
for (const col of "DJL") setF(r, col, `=${fn}(I${n},-${info.kwnJeol})`);
const t = `TEXT(I${n},"#,0")&" (조달수수료: "&TEXT(O${n},"#,0")&" 포함)"`;
setF(r, "E", `="원자재대: "&${t}`);
}
break;
}
case "BA":
sum3("MA", "BA");
setF(r, "O", info.totJeol);
break;
case "ADD":
if (r._add[0] > 1) chain(); // 이윤 뒤 추가금액부터 끝전 사슬(J·L)에 듦
break;
default:
break;
}
}
/**
* 공사원가계산서 — 총괄 행 참조 · 소계 식 · 구성비 · 비고(총괄 E 참조 / 율 ROUND) · 「총괄표 →」 바로가기
* 변경 양식(당초·변경) = D 당초 · E 변경 · F 증감 · G 구성비 · H 비고(변경 줄) · 바로가기 = 당초 줄
*/
function linkWon(won, sm, ini, L) {
const kan = sm._kan || [];
const two = won.rows.some((r) => r._k === "head" && r.D === "당 초");
if (!kan.length || (two && kan.length < 2)) return;
const pos = kan.map(() => ({ adds: [] }));
sm.rows.forEach((r, i) => {
const p = pos[r._s || 0];
if (!r._c || !p) return;
if (r._c === "ADD") p.adds.push([r, i + 1]);
else if (p[r._c] == null) p[r._c] = i + 1;
});
const C = globalThis.STMATE_REPORT_CORE || require("./report_core.js");
const scol = {};
C.bimokOrder(ini).forEach((k, i) => (scol[k] = "DEFG"[i]));
const S = L.q(sm.name);
const rows = won.rows;
const idx = {};
rows.forEach((r, i) => r._c && (idx[r._c] = i + 1));
const last = rows.length;
const lc = L.linkCol(won);
const addRow = (p, key) => {
const m = /^A(\d)(.*)$/s.exec(key);
const hit = m && p.adds.find(([r]) => r._add[0] === +m[1] && r.B.endsWith(`. ${m[2]}`));
return hit ? hit[1] : 0;
};
// 열쇠 → [총괄 열, 총괄 행]
const ref = (key, p) => {
const t = { dM: ["M"], dL: ["L"], dS: ["S"] }[key];
if (t) return [scol[t[0]], p.GA];
const m = { SUP: "RA", CON: "MA", GRAND: "BA" }[key] || key;
if (/^A\d/.test(key)) return ["D", addRow(p, key)];
return p[m] ? ["D", p[m]] : null;
};
const vc = two ? ["D", "E"] : ["D"];
const ps = two ? [pos[0], pos[1]] : [pos[0]];
const sumKeys = { sM: ["dM", "iM", "by"], sL: ["dL", "KANNO"] };
rows.forEach((r, i) => {
const n = i + 1;
if (!r._c || n < 5) return;
const key = r._c;
vc.forEach((col, s) => {
const p = ps[s];
if (key === "sM") setF(r, col, `=+${col}${idx.dM}+${col}${idx.iM}-${col}${idx.by}`);
else if (sumKeys[key]) setF(r, col, "=+" + sumKeys[key].map((k) => col + idx[k]).join("+"));
else if (key === "sS") {
const parts = [];
for (let q = idx.dS; q < n; q++) parts.push(col + q);
setF(r, col, "=+" + parts.join("+"));
} else if (key === "GRAND" && !p.BA) setF(r, col, `=${col}${idx.CON}`);
else {
const rf = ref(key, p);
if (!rf || !rf[1]) return;
setF(
r,
col,
`=${S}!${rf[0]}${rf[1]}` + (key === "dM" ? `-${col}${idx.iM}+${col}${idx.by}` : ""),
);
}
});
// 증감 · 구성비
if (two) {
setF(r, "F", `=E${n}-D${n}`);
if (n < last) setF(r, "G", `=E${n}/E${last}*100`);
} else if (n < last) setF(r, "E", `=D${n}/D${last}*100`);
// 비고 — 총괄 E 참조(설명 있는 줄) · 일반관리비·이윤·부가세 = 원가계산서 설명 + 총괄 M 율
const nc = two ? "H" : "F";
const p = ps[ps.length - 1];
const rf = ref(key, p);
if (rf && rf[0] === "D" && rf[1]) {
const srow = sm.rows[rf[1] - 1];
if (key === "ILBAN" || key === "IYUN" || key === "VAT") {
const e = pctNote(r[nc], `${S}!M${rf[1]}`);
if (e) setF(r, nc, e);
} else if (srow && srow.E != null && srow.E !== "") setF(r, nc, `=${S}!E${rf[1]}`);
}
// 바로가기 — 당초(첫) 줄
const r0 = ref(key, ps[0]);
if (lc && r0 && r0[1]) {
r[lc] = "총괄표 →";
r._links = Object.assign(r._links || {}, { [lc]: `${S}!${r0[0]}${r0[1]}` });
setF(r, lc, L.jump(sm, r0[0], r0[1], "총괄표 →"));
}
});
}
function linkCost(by, ini, L) {
const sm = by.총괄내역서;
if (!sm || !sm._kan) return;
linkSummaryCost(sm);
if (by.공사원가계산서) linkWon(by.공사원가계산서, sm, ini, L);
}
return { kanInfo, linkCost, DTP };
})();
if (typeof window !== "undefined") window.STMATE_REPORT_COST_FMLA = StmateReportCostFmla;
if (typeof module !== "undefined" && module.exports) module.exports = StmateReportCostFmla;