Files
Aislo/tools/stmate-test/scr_kanjub_rate.js
T
eomsangdonandClaude Opus 5.5 7eac0d93b0 feat(stmate-test): 적용기준 행자부(I_SET_BASE 1) 요율 조회 화면 연결 — 간접노무비·일반관리비·이윤 = RATE_M
- 요율 조회(scr_kanjub_rate.lookup) — 적용기준 1 이면 rateMLookup(ST_KanJub 0x47a1bc) 값으로 KANNO·ILBAN·IYUN · 그 밖 RATE_R 그대로
- 적용기준 콤보를 바꾸면 제비율표 다시 조회(요율 축에 I_SET_BASE)
- 브라우저 확인(봉화2024): 행자부 → 간접노무비 14.5 → 14.6(RATE_M 11줄 RATE1) · 지방계약법 되돌림 14.5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:50:01 +09:00

495 lines
23 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,…]
* 원본 조회(ST_KanJub 0x47a1bc · 0x47fa24 · 지식DB REA 34)
* 건설기계 = 50번째 줄(Go 0x32) · 구분 1~5 → RATE_06~10 · 구분 0 → 공사종류(0 RATE_01 · 1·4 RATE_04 · 2 RATE_02 · 3 RATE_03 · 그 밖 0)
* · 구분 −1 · 공사종류 5(국가유산)·8·9 → 0(0x47fd74~0x47fecb)
* 퇴직공제 = 47번째 줄 RATE_(16+구분) · 구분 0~2 만 · 3(기타)은 손대지 않음(0x47ae79)
* 공사기간 「30일 미만」(I_KONG_KIKAN −1 = 화면 0번 − 1 · 0x470eee) → 건강·장기요양·연금 율 0(0x47b376)
* 안전관리비 — 열 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];
}
// 원본 차수·조회 규칙 묶음(kanjub_parts.js) — 브라우저 = 전역 · node = require
function kanjubNative() {
const g = typeof globalThis !== "undefined" ? globalThis : {};
if (g.STMATE_KANJUB_PARTS) return g.STMATE_KANJUB_PARTS.NATIVE;
try {
return require("./kanjub_parts.js").NATIVE;
} catch {
return null;
}
}
// 요율 조회 — 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) => {
const x = v === undefined ? cell(m, r, c) : v;
return ((rates[code] = x), (cells[code] = [r, c]), x);
};
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);
// 공사기간 30일 미만(−1) → 건강·연금·장기요양 0 (ST_KanJub 0x47b376)
const short = n0(opts.kikan) < 0;
put("KUNBO", 46, 1, short ? 0 : undefined);
put("YUNBO", 46, 6, short ? 0 : undefined);
put("SLVBO", 46, 11, short ? 0 : undefined);
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;
// 건설기계대여금 지급보증 — ST_KanJub 0x47fa24(분기표 0x47fdad · 공사종류 분기표 0x47fe5f)
const eq = opts.eqip == null ? 0 : n0(opts.eqip),
kt = n0(opts.kongType);
const eqCol =
eq < 0 || kt === 5 || kt === 8 || kt === 9
? 0
: eq >= 1 && eq <= 5
? eq + 5
: ({ 0: 1, 1: 4, 2: 2, 3: 3, 4: 4 }[kt] ?? 0);
if (eqCol) put("EQIP", 49, eqCol);
else rates.EQIP = 0;
// 퇴직공제부금비 — 46행 16~18열(토목·준설·건축) · 3(기타)은 조회하지 않음(0x47ae79) · 사용자 선택일 때만 넣음
const tb = n0(opts.tbugum);
if (tb >= 0 && tb < 3) put("TBUGUM", 46, 16 + tb);
for (const [c, v] of Object.entries(rates))
if (
!v &&
cells[c] &&
!["KOBO", "HWAN", "EQIP", "TBUGUM"].includes(c) &&
!(short && ["KUNBO", "YUNBO", "SLVBO"].includes(c))
)
notes.push(`${c} — 표 칸 [${cells[c]}] 이 비어 있음(직접 입력 요율 유지)`);
const zeroed = short ? ["KUNBO", "YUNBO", "SLVBO"] : [];
// 적용기준 행자부(I_SET_BASE 1) — 간접노무비·일반관리비·이윤만 RATE_M 에서(ST_KanJub 0x47a1bc · REA 36 §4.2)
const NATIVE = kanjubNative();
if (n0(opts.setBase) === 1 && NATIVE && (opts.rateMRows || []).length) {
const rm = NATIVE.rateMLookup(opts.rateMRows, {
scale: opts.scale,
kongType: opts.kongType,
ilban: opts.ilbanType,
kikan: opts.kikan,
});
for (const [c, v] of Object.entries(rm.rates)) {
rates[c] = v;
cells[c] = ["RATE_M", ...rm.cells[c]];
}
}
return { rates, cells, amounts, band, anjunBand: ab, notes, zeroed };
}
// 설정 → 조회 입력(직접공사비 · 안전관리비 대상액은 계산 결과에서)
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),
setBase: n0(k.I_SET_BASE),
};
}
// 조회 결과 → 설정(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) && !(res.zeroed || []).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 계약 ①~③)
// 수동 확정(cfg.rateConfirm.manual)이 있으면 다시 고르지 않음 · 왕복이면 오간 조회 결과(choices)를 돌려줌
function applyWithLoopCheck(
cfg,
m,
calcFn,
{ scaleOf = (kj) => kj.dT, maxIter = 6, withTbugum = false, extra = {} } = {},
) {
let kj = calcFn(cfg);
if (cfg.rateConfirm && cfg.rateConfirm.manual) return { cfg, kj, loop: false, manual: true };
const seen = [],
found = {};
for (let i = 0; i < maxIter; i++) {
const res = lookup(m, Object.assign(optsFromCfg(cfg, kj), extra, { scale: scaleOf(kj) }));
const sig = `${res.band.row}/${res.anjunBand.row}`;
if (seen.includes(sig) && seen[seen.length - 1] !== sig) {
const choices = seen.slice(seen.indexOf(sig)).map((g) => found[g]);
const msg =
"공사규모에 따른 제비율 변경으로 공사규모가 계속 변경되는 무한루프 — 일반관리비·이윤 등 요율을 직접 수정";
return { cfg, kj, loop: true, msg, choices };
}
if (seen[seen.length - 1] === sig) return { cfg, kj, loop: false };
seen.push(sig);
found[sig] = res;
applyToCfg(cfg, res, { withTbugum });
kj = calcFn(cfg);
}
return {
cfg,
kj,
loop: true,
msg: "요율 확정이 수렴하지 않음 — 직접 확정",
choices: Object.values(found),
};
}
// 수동 확정(06 §2 계약 ③④) — 고른 조회 결과의 요율을 넣고 확정 상태·표 좌표·사용자 확정 여부를 설정에 남김
// 확정을 풀면(clearManual) 다음 적용 때 다시 자동으로 고름
function confirmManual(cfg, res, opts = {}) {
applyToCfg(cfg, res, opts);
cfg.rateConfirm = {
manual: true,
band: res.band.row,
bandName: res.band.name,
anjunBand: res.anjunBand.row,
cells: Object.assign({}, res.cells),
};
return cfg;
}
const clearManual = (cfg) => (delete cfg.rateConfirm, cfg);
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, confirmManual, clearManual });
})();
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>`;
}
// 조회 입력 + 행자부기준용 RATE_M 행(그 STC 의 표)
const optsOf = (ctx, cfg, kj) =>
Object.assign(R.optsFromCfg(cfg, kj), {
rateMRows: ((ctx.dbfData && ctx.dbfData.rate_tables) || {})["RATE_M.DBF"] || [],
});
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, optsOf(ctx, 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>
<button class="win-btn" id="kr-undo" ${u.krOrig && u.krOrig[src] ? "" : "disabled"}>수정 취소</button>
<button class="win-btn" id="kr-print">출력</button>
<span class="stm-note">${ctx.dbfData && ctx.dbfData.rate_dirty ? "고친 칸은 [계산완료·저장] 때 공사 STC 의 이 표에 씀" : ""}</span></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;
// 처음 고칠 때 원래 표를 남김(수정 취소)
const rows0 = ctx.dbfData && ctx.dbfData.rate_tables && ctx.dbfData.rate_tables[src];
u.krOrig = u.krOrig || {};
if (!u.krOrig[src])
u.krOrig[src] = {
m: JSON.parse(JSON.stringify(m)),
rows: rows0 && JSON.parse(JSON.stringify(rows0)),
};
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));
ctx.dbfData.rate_dirty = true; // 저장 길 = /api/ui/save rate_tables(api_ui_code._rates)
}
re();
});
api.onClick("kr-undo", () => {
const o = u.krOrig && u.krOrig[src];
if (!o) return;
u.krMats[src] = o.m;
if (o.rows && ctx.dbfData && ctx.dbfData.rate_tables) ctx.dbfData.rate_tables[src] = o.rows;
delete u.krOrig[src];
if (ctx.dbfData) ctx.dbfData.rate_dirty = Object.keys(u.krOrig).length > 0;
re();
});
api.onClick("kr-print", () => {
const w = global.open("", "_blank");
if (!w) return;
const t = m
.map((row, i) => `<tr><th>${i}</th>${row.map((v) => `<td>${v}</td>`).join("")}</tr>`)
.join("");
w.document.write(
`<html><head><meta charset="utf-8"><title>원가계산 제비율표 ${esc(src)}</title><style>td,th{border:1px solid #999;padding:2px 4px;font:11px monospace;text-align:right}table{border-collapse:collapse}</style></head><body><h3>원가계산 제비율표 — ${esc(src)}</h3><table>${t}</table></body></html>`,
);
w.document.close();
w.print();
});
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, optsOf(ctx, 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);