Files
Aislo/tools/stmate-test/ui_dan.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

236 lines
8.4 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.
/**
* STMATE w 8.0 — 단가목록표(wM_JabPum)·가격지 비교(wM_Boxa)·기준단가 수정(wM_Edt_Hal)·사용처 찾기 순수 계산 (ui_dan.js)
* 근거: ui_form_catalog.txt wM_JabPum cBoxCode(10종)·cBox_IFORM · wM_Boxa RG_MComp(변동없음/1~5 단가/최소단가)
* · 22_UI_폼_분석 §3.1 wM_Edt_Hal(적용단가 = 기준단가 × % · 소수 자리 미만 절사/반올림 · 적용단가 0원 제외)
* · STC_필드사전_핵심(AMT_11~51 후보 5칸 · PG_1 = 채택 출처 · PG_11~51 페이지) · Start8x.Ini JMOK1~5·JMK10~50·JEMNM
* 그림코드는 실자료 표본에 없음 — 앞 글자 미확인이라 걸러지는 행 없음
*/
(function (global) {
"use strict";
const num = (v) => {
const n = parseFloat(
String(v == null ? "" : v)
.replace(/,/g, "")
.trim(),
);
return Number.isFinite(n) ? n : 0;
};
const codeOf = (r) => String((r && r.CODE) || "").trim();
const KINDS = [
["ALL", "모든단가", () => true],
["L", "노무비", (p) => p === "L"],
["M", "재료비", (p) => p === "M"],
["S", "경비", (p) => p === "S"],
["B", "일위대가", (p) => p === "B"],
["X", "중기비", (p) => p === "X"],
["D", "단가산출", (p) => p === "D"],
["W", "견적단가", (p) => p === "W"],
["BD", "대가,산근", (p) => p === "B" || p === "D"],
["G", "그림코드", () => false],
];
const OWN = { M: "MAMT", L: "LAMT", S: "SAMT" };
const isBase = (code) => /^[MLS]/.test(code);
// 가격지 이름 — 프로젝트 초기값(JMOK1~5 · 줄임 JMK10~50 · 표시 차례 JEMNM)
function slotNames(ini) {
const i = ini || {};
const names = [1, 2, 3, 4, 5].map((n) => ({
slot: n,
name: String(i[`JMOK${n}`] || `${n} 단가`).trim(),
short: String(i[`JMK${n}0`] || "").trim(),
}));
const order = String(i.JEMNM || "12345")
.split("")
.map(Number)
.filter((n) => n >= 1 && n <= 5);
const seen = new Set();
const out = order.filter((n) => !seen.has(n) && seen.add(n)).map((n) => names[n - 1]);
names.forEach((x) => !seen.has(x.slot) && out.push(x));
return { list: out, applied: String(i.JMOK6 || "적용 단가").trim() };
}
const candidates = (r) => [1, 2, 3, 4, 5].map((n) => num(r[`AMT_${n}1`]));
const pages = (r) => [1, 2, 3, 4, 5].map((n) => String(r[`PG_${n}1`] || "").trim());
const appliedPrice = (r) => num(r.MAMT1) + num(r.LAMT1) + num(r.SAMT1) || num(r.TAMT1);
const adoptedSlot = (r) => num(r.PG_1);
function counts(rows) {
const out = {};
KINDS.forEach(([k, , f]) => (out[k] = rows.filter((r) => codeOf(r) && f(codeOf(r)[0])).length));
return out;
}
// 거르기 — 코드종류 · 찾기(명칭,규격 / 산근내용) · 정렬(코드·명칭·분류번호·시설코드)
function filterRows(rows, o) {
const opt = Object.assign({ kind: "ALL", kw: "", mode: "name", sort: "C", memos: {} }, o || {});
const f = (KINDS.find((k) => k[0] === opt.kind) || KINDS[0])[2];
const kw = String(opt.kw || "")
.trim()
.toLowerCase();
let list = rows.filter((r) => {
const c = codeOf(r);
if (!c || !f(c[0])) return false;
if (!kw) return true;
if (opt.mode === "memo")
return String((opt.memos[c] || {}).text || "")
.toLowerCase()
.includes(kw);
return `${c} ${r.NAME || ""} ${r.SIZE || ""} ${r.NAME2 || ""} ${r.SIZE2 || ""}`
.toLowerCase()
.includes(kw);
});
const key =
{
C: codeOf,
N: (r) => `${r.NAME || ""} ${r.SIZE || ""}`,
I: (r) => String(r.BQCODE || ""),
E: (r) => String(r.EQCODE || ""),
}[opt.sort] || codeOf;
list = list
.slice()
.sort((a, b) => key(a).localeCompare(key(b), "ko") || codeOf(a).localeCompare(codeOf(b)));
return list;
}
// 단가 칸 쓰기 — 기초단가(M·L·S)는 자기 비목 한 칸 · 합계 TAMT1
function writeBase(r, v) {
const c = codeOf(r);
["MAMT1", "LAMT1", "SAMT1"].forEach((k) => (r[k] = "0"));
r[`${OWN[c[0]]}1`] = String(v);
r.TAMT1 = String(v);
}
/**
* 채택 — slot 1~5(그 후보 단가) · "min"(0 아닌 최소) · "none"(변동없음)
* 결과 = { code, from, to, slot } 또는 null(바뀐 것 없음·건너뜀)
*/
function adopt(r, how, o) {
const opt = Object.assign({ skipZero: true }, o || {});
const c = codeOf(r);
if (!isBase(c) || how === "none") return null;
const cand = candidates(r);
let slot = Number(how),
v = cand[slot - 1];
if (how === "min") {
const nz = cand.filter((x) => x > 0);
v = nz.length ? Math.min(...nz) : 0;
slot = v ? cand.indexOf(v) + 1 : 0;
}
if (!(slot >= 1 && slot <= 5)) return null;
if (opt.skipZero && !(v > 0)) return null;
const from = appliedPrice(r);
writeBase(r, v);
r.PG_1 = String(slot);
return { code: c, from, to: v, slot };
}
// 사용자가 직접 고친 단가 — 적용단가가 후보 5칸 어디에도 없음
const isUserEdited = (r) => {
const a = appliedPrice(r);
return a > 0 && !candidates(r).some((x) => Math.abs(x - a) < 1e-6);
};
// 재료비 적용단가 일괄적용(wM_Set_Sys GroupBox4 · wM_Boxa RG_MComp)
function adoptAll(rows, how, o) {
const opt = Object.assign({ kinds: "M", skipZero: true, keepUser: true }, o || {});
const out = [];
rows.forEach((r) => {
const c = codeOf(r);
if (!c || !opt.kinds.includes(c[0])) return;
if (opt.keepUser && isUserEdited(r)) return;
const ch = adopt(r, how, opt);
if (ch && ch.from !== ch.to) out.push(ch);
});
return out;
}
// 반올림·절사 — 소수 d 자리(음수 = 10^-d 원 단위)
function roundTo(v, d, mode) {
const f = Math.pow(10, Number(d) || 0);
const x = Number((v * f).toFixed(6));
return (mode === "round" ? Math.round(x) : Math.trunc(x)) / f;
}
/**
* 기준단가 수정(wM_Edt_Hal) — 적용단가 = 기준단가 × pct% · 소수 digits 자리 미만 절사/반올림
* 기준단가 = 채택 후보(PG_1) 단가, 채택 없음이면 지금 적용단가 · 적용단가 0원은 건너뜀(화면 안내문)
* 같은 비율을 두 번 적용해도 결과가 같음(기준단가 기준)
*/
function edtHal(rows, o) {
const opt = Object.assign(
{ kinds: "MLS", pct: 100, digits: 0, mode: "trunc", codes: null },
o || {},
);
const only = opt.codes ? new Set(opt.codes) : null;
const out = [];
rows.forEach((r) => {
const c = codeOf(r);
if (!c || !isBase(c) || !opt.kinds.includes(c[0]) || (only && !only.has(c))) return;
const cur = appliedPrice(r);
if (!(cur > 0)) return;
const s = adoptedSlot(r),
cand = s >= 1 && s <= 5 ? candidates(r)[s - 1] : 0;
const base = cand > 0 ? cand : cur;
const v = roundTo((base * num(opt.pct)) / 100, opt.digits, opt.mode);
if (v === cur) return;
writeBase(r, v);
out.push({ code: c, base, from: cur, to: v });
});
return out;
}
// 사용처 찾기(ST_FindUse) — 내역서(모든 BOQ 표) · 구성표(BDQTY) · 산출근거 식(COSTN.SMT &코드&)
function findUse(code, d) {
const c = String(code || "")
.trim()
.toUpperCase();
const out = { boq: [], comp: [], memo: [] };
if (!c) return out;
Object.entries((d && d.boq_tables) || {}).forEach(([t, rows]) =>
(rows || []).forEach((r) => {
if (String(r.BDCODE1 || "").trim() === c)
out.boq.push({
table: t,
recno: r._recno,
itnum: r.ITNUM || "",
name: r.NAME1 || "",
qty: num(r.QTY1),
});
}),
);
((d && d.bdqty_rows) || []).forEach((r) => {
if (String(r.CODE || "").trim() === c)
out.comp.push({ parent: String(r.BDCODE || "").trim(), qty: num(r.QTY1) });
});
const re = new RegExp(`&${c}(?:[LMS])?(?::[LMS])?&`);
Object.entries((d && d.costn_memos) || {}).forEach(([k, m]) => {
if (re.test(String((m && m.text) || ""))) out.memo.push(k);
});
return out;
}
global.STMATE_UI_DAN = {
KINDS,
num,
codeOf,
isBase,
slotNames,
candidates,
pages,
appliedPrice,
adoptedSlot,
counts,
filterRows,
writeBase,
adopt,
isUserEdited,
adoptAll,
roundTo,
edtHal,
findUse,
};
if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_UI_DAN;
})(typeof window !== "undefined" ? window : globalThis);