Files
Aislo/tools/stmate-test/scr_kanjub_rate.js
T
eomsangdonandClaude Opus 5.5 3d8fddfc34 feat(stmate-test): 보고서·제잡비·식언어·수량산출·화면 묶음 합침 · 재계산 후 저장 무변경
- 보고서: 원본 STMATE 엑셀과 셀 대조(목록표·자재단가대비·중기·일위대가·산근 목록·설계내역서·원가계산서) · 조달청 XML 공식 XSD 검증 · PM_Prt 화면
- 제잡비: 원본 계산 순서 4형식(T/S/TA/SA) · 실무 7건 저장 결과키 전수 1원 · 요율표 조회 · 선택지 계산 · 제잡비·제비율 화면
- 식언어·수량: 함수·조건식·금액변수·문법 검사·수량변수·QCODE · 산근·구성·중기공식 편집 화면 · 순환 알림
- 화면: 원본 메뉴 체계·단축키 · 설계내역서 편집(행 넣기·지우기·이동·레벨·보관함) · 초기값 · 단가목록표
- 저장: 초기값(Start8x.Ini) 바뀐 키 저장 · 노임 환산 직종 = 초기값 CONLD · 산근·구성 결과에 적용단가 칸 채우지 않음(원본 0)
- DBF 글 칸 앞 빈칸 보존 · 시험 도우미 표준입력 utf8(한글 조각 깨짐 원인)
- 실무 5건 재계산 후 저장 = 바뀐 칸 0 · 시험 332건 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 19:12:04 +09:00

375 lines
18 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 -*- */
/**
* 제비율표(wM_KAN_RATE) 화면 + 요율 조회 (scr_kanjub_rate.js)
* RATE_R(50×35) 행렬 = 5행 묶음(값 4행 + 빈 1행) · 열 1~5 간접노무비 · 6~10 기타경비(토목·산설토목·조경·건축·산설건축)
* 묶음 시작 행 = 공사규모(20 50억 미만 · 30 50~300억 · 35 300~1000억 · 40 1000억 이상 · 10 소규모 — 값이 있는 표만)
* 묶음 안 행 = 공사기간(I_KONG_KIKAN 화면 순번) · 일반관리비 열 11 · 이윤 열 16(묶음 첫 행)
* 부속표 — 산재[45,1] · 건강[46,1] · 연금[46,6] · 장기요양[46,11] · 고용 등급[45,10+등급] · 환경[47,공종] · 건설기계[49,구분+1]
* 안전관리비 — 열 17 율 · 18 기초액(천원) · 행 = 규모 묶음(10 5억 미만 · 20 5~50억 · 25 50억 이상) + 공종 자리(건축0·토목1·특수2·철도3·중건설4)
* 근거: 지식DB STmate/old/20_분석 06 §1~2 · 10 §4 · 12 §8~10 · 15 §4(좌표 확정) · 30_원자료/TWM_KAN_RATE.json
* 순환: 공사규모 밑수가 총공사비면 요율 → 금액 → 규모 → 요율 고리 — 왕복하면 자동 확정하지 않고 알림(06 §2 · 11 §8)
* 정답 대조: resources/tester/test_stmate_kanjub_rate.py(실무 7건 저장 요율)
*/
const StmateRate = (() => {
const n0 = (v) => Number(String(v ?? "").replace(/,/g, "")) || 0;
// 공사종류 내부값(cmBx_Kong_Type 0 토목·2 조경·4 산설토목·3 건축·1 산설건축·5 국가유산) → 간접노무비 열
const KONG_COL = { 0: 1, 4: 2, 2: 3, 3: 4, 1: 5, 5: 21 };
const KONG_NAME = {
0: "토목",
4: "산업설비(토목)",
2: "조경",
3: "건축",
1: "산업설비(건축)",
5: "국가유산",
};
// 공사규모 묶음(간접노무비·기타경비·일반관리비·이윤) — 시작 행 · 상한(원)
const BANDS = [
{ row: 10, max: 5e8, name: "5억 미만(소규모)", optional: true },
{ row: 20, max: 50e8, name: "50억 미만" },
{ row: 30, max: 300e8, name: "50억~300억 미만" },
{ row: 35, max: 1000e8, name: "300억~1000억 미만" },
{ row: 40, max: Infinity, name: "1000억 이상" },
];
const KIKAN = ["6개월 이하", "7개월~12개월 이하", "13개월~36개월 이하", "36개월 이상"];
// 안전관리비 — 공종 내부값(cmBx_AnJun_Type 건축0·토목1·중건설3·특수4) → 행 자리(옛 철도·궤도 = 2)
const ANJUN_OFF = { 0: 0, 1: 1, 4: 2, 2: 3, 3: 4 };
const ANJUN_BANDS = [
{ row: 10, max: 5e8, name: "5억 미만" },
{ row: 20, max: 50e8, name: "5억~50억 미만", base: true },
{ row: 25, max: Infinity, name: "50억 이상" },
];
const HEALTH_ROW = 35; // 보건관리자 선임 대상(800억 이상)
// DBF 행(RATE_01..·RATE0..) → 숫자 행렬
function matrixFromRows(rows) {
if (!Array.isArray(rows) || !rows.length) return [];
if (Array.isArray(rows[0])) return rows.map((r) => r.map(n0));
const keys = Object.keys(rows[0])
.filter((k) => /^RATE_?\d+$/.test(k))
.sort((a, b) => Number(a.replace(/\D/g, "")) - Number(b.replace(/\D/g, "")));
return rows.map((r) => keys.map((k) => n0(r[k])));
}
const cell = (m, r, c) => (m[r] && m[r][c - 1] != null ? n0(m[r][c - 1]) : 0);
// 규모 묶음 고르기 — 소규모 묶음은 그 표에 값이 있을 때만
function bandOf(m, scale, col) {
for (const b of BANDS) {
if (b.optional && !cell(m, b.row, col)) continue;
if (scale < b.max) return b;
}
return BANDS[BANDS.length - 1];
}
// 요율 조회 — opts: kongType · kikan · scale(직접공사비) · anjunType · anjunScale · koboType · hwan · eqip · tbugum
function lookup(m, opts = {}) {
const rates = {},
cells = {},
amounts = {},
notes = [];
const put = (code, r, c, v = cell(m, r, c)) => ((rates[code] = v), (cells[code] = [r, c]), v);
const col = KONG_COL[opts.kongType ?? 0] ?? 1,
kik = Math.min(3, Math.max(0, n0(opts.kikan)));
const band = bandOf(m, n0(opts.scale), col);
if (col === 21) {
// 국가유산 — 행 0~19 4묶음(0·5·10·15) · 열 21 간접노무비 · 26 기타경비(10 §4)
const b = [0, 5, 10, 15][Math.max(0, BANDS.indexOf(band) - 1)] ?? 0;
put("KANNO", b + kik, 21);
put("KITA", b + kik, 26);
} else {
put("KANNO", band.row + kik, col);
put("KITA", band.row + kik, col + 5);
}
const head = band.optional ? 20 : band.row;
put("ILBAN", head, opts.ilbanType === 1 ? 15 : 11);
put("IYUN", head, 16);
put("SANBO", 45, 1);
put("KUNBO", 46, 1);
put("YUNBO", 46, 6);
put("SLVBO", 46, 11);
const kg = n0(opts.koboType);
if (kg > 0) put("KOBO", 45, 10 + Math.min(7, kg));
else rates.KOBO = 0;
// 안전관리비 — 대상액 규모 · 공종 · 5~50억은 기초액(천원 → 원)
const off = ANJUN_OFF[n0(opts.anjunType)] ?? 1,
as = n0(opts.anjunScale);
const ab = opts.health ? { row: HEALTH_ROW } : ANJUN_BANDS.find((b) => as < b.max);
for (const c of ["ANJUN", "ANJUN1", "ANJUN2"]) put(c, ab.row + off, 17);
const g = ab.base ? cell(m, ab.row + off, 18) * 1000 : 0;
Object.assign(amounts, { C_ANJUN_G: g, C_ANJUN1_G: g, C_ANJUN2_G: g });
const hw = n0(opts.hwan);
if (hw > 0) put("HWAN", 47, hw);
else rates.HWAN = 0;
const eq = opts.eqip == null ? 0 : n0(opts.eqip);
if (eq >= 0) put("EQIP", 49, eq + 1);
else rates.EQIP = 0;
// 퇴직공제부금비 — 46행 16~19열(토목·준설·건축·기타) · 사용자 선택일 때만 넣음
put("TBUGUM", 46, 16 + Math.min(3, Math.max(0, n0(opts.tbugum))));
for (const [c, v] of Object.entries(rates))
if (!v && cells[c] && !["KOBO", "HWAN", "EQIP", "TBUGUM"].includes(c))
notes.push(`${c} — 표 칸 [${cells[c]}] 이 비어 있음(직접 입력 요율 유지)`);
return { rates, cells, amounts, band, anjunBand: ab, notes };
}
// 설정 → 조회 입력(직접공사비 · 안전관리비 대상액은 계산 결과에서)
function optsFromCfg(cfg, kj) {
const k = (cfg && cfg.keys) || {};
return {
kongType: n0(k.I_KONG_TYPE),
kikan: n0(k.I_KONG_KIKAN),
scale: kj ? n0(kj.dT) : 0,
anjunType: n0(k.I_ANJUN_TYPE),
anjunScale: kj ? n0(kj.anjunBaseRaw) : 0,
koboType: n0(k.I_KOBO_TYPE),
hwan: n0(k.I_HWAN),
eqip: k.I_EQIP == null ? 0 : n0(k.I_EQIP),
tbugum: n0(k.I_TBUGUM),
ilbanType: n0(k.I_ILBAN),
};
}
// 조회 결과 → 설정(R_·C_ANJUN_G 키와 옛 rates 칸 함께) · 빈 칸은 건너뜀 · 퇴직공제는 withTbugum 일 때만
function applyToCfg(cfg, res, { withTbugum = false } = {}) {
cfg.rates = cfg.rates || {};
cfg.keys = cfg.keys || {};
const RK = { KITA: "T_KITA", EQIP: "EQIPSU" };
for (const [c, v] of Object.entries(res.rates)) {
if (c === "TBUGUM" && !withTbugum) continue;
if (!v && !["KOBO", "HWAN", "EQIP", "TBUGUM"].includes(c)) continue;
cfg.rates[c] = v;
cfg.keys["R_" + (RK[c] || c)] = v;
}
Object.assign(cfg.keys, res.amounts);
cfg.anjunBaseAdd = res.amounts.C_ANJUN_G;
return cfg;
}
// 순환 점검 — 규모 밑수를 계산 결과에서 다시 잡을 때 묶음이 왕복하면 멈추고 알림(06 §2 계약)
function applyWithLoopCheck(cfg, m, calcFn, { scaleOf = (kj) => kj.dT, maxIter = 6 } = {}) {
const seen = [];
let kj = calcFn(cfg);
for (let i = 0; i < maxIter; i++) {
const res = lookup(m, Object.assign(optsFromCfg(cfg, kj), { scale: scaleOf(kj) }));
const sig = `${res.band.row}/${res.anjunBand.row}`;
if (seen.includes(sig) && seen[seen.length - 1] !== sig)
return {
cfg,
kj,
loop: true,
msg: "공사규모에 따른 제비율 변경으로 공사규모가 계속 변경되는 무한루프 — 일반관리비·이윤 등 요율을 직접 수정",
};
if (seen[seen.length - 1] === sig) return { cfg, kj, loop: false };
seen.push(sig);
applyToCfg(cfg, res);
kj = calcFn(cfg);
}
return { cfg, kj, loop: true, msg: "요율 확정이 수렴하지 않음 — 직접 확정" };
}
const api = { KONG_COL, KONG_NAME, BANDS, KIKAN, ANJUN_OFF, ANJUN_BANDS, HEALTH_ROW };
Object.assign(api, { matrixFromRows, cell, bandOf, lookup, optsFromCfg, applyToCfg });
return Object.assign(api, { applyWithLoopCheck });
})();
if (typeof window !== "undefined") window.StmateRate = StmateRate;
if (typeof module !== "undefined" && module.exports) module.exports = StmateRate;
// ── 화면 wM_KAN_RATE — 표 고르기(공사 안 RATE_* · 기준 JB_R) · 이름 붙은 표(규모·기간 · 부속표) · 원본 행렬 · 조회·적용
(function (global) {
if (!global.document) return;
const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || {
screens: {},
actions: {},
menus: [],
});
const R = StmateRate;
const n0 = (v) => Number(String(v ?? "").replace(/,/g, "")) || 0;
const esc = (s) =>
String(s ?? "").replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
const STC_TABLES = ["RATE_R.DBF", "RATE_RA.DBF", "RATE_M.DBF", "RATE.DBF"];
function current(ctx) {
const u = ctx.uiState;
u.krMats = u.krMats || {};
const tables = (ctx.dbfData && ctx.dbfData.rate_tables) || {};
for (const t of STC_TABLES)
if (tables[t] && !u.krMats[t]) u.krMats[t] = R.matrixFromRows(tables[t]);
const src = u.krSrc && u.krMats[u.krSrc] ? u.krSrc : Object.keys(u.krMats)[0] || "";
return { u, src, m: u.krMats[src] || [] };
}
const box = (m, r, c, w = 54) =>
`<input class="kr-cell" data-r="${r}" data-c="${c}" value="${R.cell(m, r, c) || ""}" style="width:${w}px;text-align:right" title="[${r},${c}]">`;
function scaleGrid(m) {
const kongs = [
[1, "토목"],
[2, "산설(토)"],
[3, "조경"],
[4, "건축"],
[5, "산설(건)"],
[21, "국가유산"],
];
const head = `<tr><th>공사규모</th><th>공사기간</th>${kongs.map(([, l]) => `<th>간노 ${l}</th>`).join("")}${kongs.map(([, l]) => `<th>기타 ${l}</th>`).join("")}<th>일반관리비(일반)</th><th>일반관리비(전문)</th><th>이윤</th></tr>`;
const body = R.BANDS.flatMap((b) =>
R.KIKAN.map((k, p) => {
const row = b.row + p,
rr = (c) => (c === 21 ? Math.max(0, row - 20) : row);
const cells =
kongs.map(([c]) => `<td>${box(m, rr(c), c)}</td>`).join("") +
kongs.map(([c]) => `<td>${box(m, rr(c), c + 5)}</td>`).join("");
const tail =
p === 0
? `<td>${box(m, row, 11)}</td><td>${box(m, row, 15)}</td><td>${box(m, row, 16)}</td>`
: "<td></td><td></td><td></td>";
return `<tr>${p === 0 ? `<td rowspan="4">${esc(b.name)}</td>` : ""}<td>${k}</td>${cells}${tail}</tr>`;
}),
);
return `<table class="dbgrid"><thead>${head}</thead><tbody>${body.join("")}</tbody></table>`;
}
function annexGrid(m) {
const ins = [
["산재보험료", 45, 1],
["건강보험료", 46, 1],
["연금보험료", 46, 6],
["노인장기요양", 46, 11],
]
.map(([l, r, c]) => `<tr><td>${l}</td><td>${box(m, r, c)}</td></tr>`)
.join("");
const kobo = [1, 2, 3, 4, 5, 6, 7]
.map(
(g) =>
`<tr><td>${g < 7 ? `${g} 등급` : "7 등급이하"}</td><td>${box(m, 45, 10 + g)}</td></tr>`,
)
.join("");
const ag = [
["건축", 0],
["토목", 1],
["중건설", 3],
["특수건설", 4],
]
.map(([l, t]) => {
const o = R.ANJUN_OFF[t];
return `<tr><td>${l}</td><td>${box(m, 10 + o, 17)}</td><td>${box(m, 20 + o, 17)} + ${box(m, 20 + o, 18, 60)}천원</td><td>${box(m, 25 + o, 17)}</td><td>${box(m, R.HEALTH_ROW + o, 17)}</td></tr>`;
})
.join("");
const strip = (r, c0, nn, lab) =>
Array.from({ length: nn }, (_, i) => `<td>${lab(i)}<br>${box(m, r, c0 + i, 44)}</td>`).join(
"",
);
return `<div style="display:flex;gap:12px;flex-wrap:wrap">
<table class="dbgrid"><thead><tr><th>보험</th><th>요율</th></tr></thead><tbody>${ins}</tbody></table>
<table class="dbgrid"><thead><tr><th>고용보험 등급</th><th>요율</th></tr></thead><tbody>${kobo}</tbody></table>
<table class="dbgrid"><thead><tr><th>안전 공종</th><th>5억 미만</th><th>5~50억 미만 + 기초액</th><th>50억 이상</th><th>보건관리자 대상</th></tr></thead><tbody>${ag}</tbody></table></div>
<table class="dbgrid"><thead><tr><th colspan="14">환경보전비 (재료비+직접노무비+산출경비) × 율 — 공종 번호</th></tr></thead><tbody><tr>${strip(47, 1, 14, (i) => i + 1)}</tr></tbody></table>
<table class="dbgrid"><thead><tr><th colspan="10">건설기계대여금 지급보증 — 구분 번호(0 일반공사)</th></tr></thead><tbody><tr>${strip(49, 1, 10, (i) => i)}</tr></tbody></table>
<table class="dbgrid"><thead><tr><th colspan="4">퇴직공제부금비 직접노무비 × 율</th></tr></thead><tbody><tr>${strip(46, 16, 4, (i) => ["토목", "준설", "건축", "기타"][i])}</tr></tbody></table>`;
}
function rawGrid(m) {
const cols = m[0] ? m[0].length : 0;
const head = Array.from({ length: cols }, (_, c) => `<th>${c + 1}</th>`).join("");
const body = m.map(
(row, r) =>
`<tr><td>${r}</td>${row.map((_, c) => `<td>${box(m, r, c + 1, 46)}</td>`).join("")}</tr>`,
);
return `<table class="dbgrid"><thead><tr><th>행</th>${head}</tr></thead><tbody>${body.join("")}</tbody></table>`;
}
function lookupPanel(ctx, m) {
const S = global.STMATE_KANJUB_SCREEN;
if (!S || !m.length) return "";
const { n, cfg } = S.stageCfg(ctx);
const kj =
(ctx.computed.stages && ctx.computed.stages[n] && ctx.computed.stages[n].kanjub) ||
ctx.computed.kanjubComputed;
const res = R.lookup(m, R.optsFromCfg(cfg, kj));
const rows = Object.entries(res.rates)
.map(([c, v]) => {
const cur = cfg.rates[c];
return `<tr><td>${c}</td><td>${res.cells[c] ? `[${res.cells[c]}]` : "-"}</td><td style="text-align:right">${v}</td><td style="text-align:right">${cur ?? ""}</td><td>${cur != null && cur !== v ? "다름(수동값)" : ""}</td></tr>`;
})
.join("");
return `<div class="win-subbar"><b>${n}차수 조회</b> — 규모 ${esc(res.band.name)} · 안전 ${esc(res.anjunBand.name || "보건관리자 대상")} · 기초액 ${res.amounts.C_ANJUN_G}
<label><input type="checkbox" id="kr-tbugum"> 퇴직공제 포함</label><button class="win-btn primary" id="kr-apply">현재 차수에 적용</button></div>
<table class="dbgrid"><thead><tr><th>항목</th><th>칸</th><th>표 값</th><th>지금 요율</th><th></th></tr></thead><tbody>${rows}</tbody></table>
${res.notes.map((t) => `<div style="color:#a60">${esc(t)}</div>`).join("")}`;
}
function render(ctx) {
const { u, src, m } = current(ctx);
const jbr = (ctx.sourcesData && ctx.sourcesData.jbr_files) || [];
const tab = u.krTab || "scale";
const srcOpts = Object.keys(u.krMats)
.map((t) => `<option ${t === src ? "selected" : ""}>${esc(t)}</option>`)
.join("");
const tabs = [
["scale", "규모·기간(Rate_R/R1)"],
["annex", "부속표(보험·안전·환경·보증)"],
["raw", "원본 행렬"],
]
.map(
([t, l]) =>
`<button class="win-btn kr-tab2 ${t === tab ? "primary" : ""}" data-t="${t}">${l}</button>`,
)
.join("");
const body = !m.length
? "<div>표 없음 — 공사를 불러오거나 기준 JB_R 을 엶</div>"
: tab === "annex"
? annexGrid(m)
: tab === "raw"
? rawGrid(m)
: scaleGrid(m);
return `<div class="win-form"><div class="win-form-header"><span>원가계산 제비율표 [TWM_KAN_RATE · ${esc(src)} ${m.length}행×${m[0] ? m[0].length : 0}열]</span></div>
<div class="win-subbar"><label>표</label><select id="kr-src" class="win-select">${srcOpts}</select>
<select id="kr-jbr" class="win-select" style="width:200px">${jbr.map((f) => `<option value="${esc(f.path)}">${esc(f.name)}</option>`).join("")}</select>
<button class="win-btn" id="kr-load">기준 JB_R 열기</button><button class="win-btn" id="kr-back">제잡비 계산으로</button></div>
${lookupPanel(ctx, m)}<div class="win-subbar">${tabs}</div><div style="overflow:auto;max-height:520px">${body}</div></div>`;
}
function bind(ctx, api) {
const re = () => api.renderWorkspace();
const { u, src, m } = current(ctx);
api.onChange("kr-src", (e) => ((u.krSrc = e.target.value), re()));
api.bindEach(".kr-tab2", "onclick", (b) => ((u.krTab = b.getAttribute("data-t")), re()));
api.onClick("kr-back", () => ((u.activeScreen = "wM_KanJub"), re()));
api.onClick("kr-load", async () => {
const p = (api.$("kr-jbr") || {}).value;
if (!p) return;
const d = await api.postJson("/api/load-jbr", { jbr_path: p });
u.krMats[d.name] = R.matrixFromRows(d.rows);
u.krSrc = d.name;
re();
});
api.bindEach(".kr-cell", "onchange", (el) => {
const r = n0(el.getAttribute("data-r")),
c = n0(el.getAttribute("data-c"));
if (!m[r]) return;
m[r][c - 1] = n0(el.value);
// 공사 안 표면 원본 행(RATE_xx 칸)에도 적음
const rows = ctx.dbfData && ctx.dbfData.rate_tables && ctx.dbfData.rate_tables[src];
if (rows && rows[r]) {
const keys = Object.keys(rows[r])
.filter((k) => /^RATE_?\d+$/.test(k))
.sort((a, b) => n0(a.replace(/\D/g, "")) - n0(b.replace(/\D/g, "")));
if (keys[c - 1]) rows[r][keys[c - 1]] = String(n0(el.value));
}
re();
});
api.onClick("kr-apply", () => {
const S = global.STMATE_KANJUB_SCREEN,
{ n, cfg } = S.stageCfg(ctx);
api.recompute();
const kj = (ctx.computed.stages[n] || {}).kanjub || ctx.computed.kanjubComputed;
const res = R.lookup(m, R.optsFromCfg(cfg, kj));
R.applyToCfg(cfg, res, { withTbugum: !!(api.$("kr-tbugum") || {}).checked });
u.kjMsg = res.notes.join(" · ");
re();
});
}
P.screens.wM_KAN_RATE = { render, bind };
})(typeof window !== "undefined" ? window : globalThis);