- 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>
373 lines
15 KiB
JavaScript
373 lines
15 KiB
JavaScript
/* -*- 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;
|