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

319 lines
12 KiB
JavaScript

/* -*- coding: utf-8 -*- */
/**
* 수량산출서 계산 (scr_qty_sheet.js) — 시트 셈 · 셀별명(확장 수량변수) · 수량코드(기본 수량변수) ·
* 내역 가져가기 · 코드 종류별 수량 · 내역↔BDQTY 수량 연결(자원 역집계) · 순수 계산
* 근거: 지식DB STmate/old/20_분석 28 §2(기본수량변수 ↔ 확장수량변수(셀별명) · 전송 · 수량산출서 파일) ·
* 02(QCODE CODE·NAME·BQCODE·QTY0~9·IFORM·XCODE) · 10(ST_U_ConBD CON_QTY · BOQ↔BDQTY) ·
* REA 28 TWQTYLIST(내역명칭·규격·단위·수량·시작줄·끝줄·단가·공종번호·내역코드 필드 · 명칭/코드 기준 가져가기)
* · 수량모듈 중간메모(시작~끝 작은 행부터 양 끝 포함 · Tamt1 = Mamt1+Lamt1+Samt1 · 수량 곱셈 없음)
* · REA 30 quantity_variable_resolution(QTY0~9 직접 · XCODE 「이름=값」 · 빈 값 0 · 없는 이름 오류)
*/
const StmateQtySheet = (() => {
const X = () =>
(typeof window !== "undefined" && window.STMATE_SMT_EXPR) ||
(typeof require !== "undefined" ? require("./scr_edit_expr.js") : null);
const num = (v) => {
const n = parseFloat(String(v == null ? "" : v).replace(/,/g, ""));
return Number.isFinite(n) ? n : 0;
};
const SHEET_SIG = "STMATE-QTY-SHEET/1";
// 열 이름 ↔ 번호(A=0)
const colName = (i) =>
i < 26 ? String.fromCharCode(65 + i) : colName(Math.floor(i / 26) - 1) + colName(i % 26);
const colIndex = (s) =>
[...String(s).toUpperCase()].reduce((a, c) => a * 26 + c.charCodeAt(0) - 64, 0) - 1;
const addr = (r, c) => `${colName(c)}${r + 1}`;
const parseAddr = (a) => {
const m = String(a)
.toUpperCase()
.match(/^([A-Z]{1,2})(\d+)$/);
return m ? { r: Number(m[2]) - 1, c: colIndex(m[1]) } : null;
};
function newSheet(rows = 30, cols = 8) {
return { sig: SHEET_SIG, rows, cols, cells: {}, aliases: {}, roles: {}, start: 0, end: 0 };
}
/**
* 시트 셈 — 「=식」 칸은 셀 주소(A1)·셀별명을 변수로 평가 · 순환·오류 칸은 errors
* 돌려줌: { values: {A1: 수 또는 글자}, errors: {A1: 글}, aliasValues: {별명: 수} }
*/
function evalSheet(sheet) {
const values = {},
errors = {},
state = {};
const aliasOf = {};
for (const [nm, a] of Object.entries(sheet.aliases || {}))
aliasOf[nm.toUpperCase()] = String(a).toUpperCase();
const cellOf = (name) => aliasOf[name] || (parseAddr(name) ? name : null);
function calc(a) {
if (state[a] === 2) return values[a];
if (state[a] === 1) {
errors[a] = "셀참조 순환";
return 0;
}
state[a] = 1;
const raw = String((sheet.cells || {})[a] ?? "").trim();
let v;
if (raw.startsWith("=")) {
const vars = {};
const ids = (raw.slice(1).match(/[A-Za-z_가-힣][A-Za-z_0-9가-힣]*/g) || []).map((s) =>
s.toUpperCase(),
);
for (const id of ids) {
const at = cellOf(id);
if (at && !(id in vars)) {
const sub = calc(at);
if (errors[at]) errors[a] = errors[a] || `셀참조 에러(${id})`;
vars[id] = typeof sub === "number" ? sub : num(sub);
}
}
const r = X().evaluate(raw.slice(1), vars);
if (r.error) errors[a] = r.error;
else if (r.missing.length) errors[a] = `없는 셀별명 「${r.missing[0]}」`;
v = r.value === null ? 0 : r.value;
} else if (raw !== "" && /^[-+]?[\d,]*\.?\d+$/.test(raw)) v = num(raw);
else v = raw;
values[a] = v;
state[a] = 2;
return v;
}
for (const a of Object.keys(sheet.cells || {})) calc(a.toUpperCase());
const aliasValues = {};
for (const [nm, a] of Object.entries(aliasOf)) {
const v = calc(a);
aliasValues[nm] = typeof v === "number" ? v : num(v);
if (errors[a]) errors[`@${nm}`] = errors[a];
}
return { values, errors, aliasValues };
}
/**
* 기본 수량변수 ← 확장 수량변수 — 수량코드 행 IFORM 의 「QTYn=식」 줄을 셀별명으로 셈해 QTYn 에 넣고
* XCODE 에 셀별명 값 전부를 「이름=값」 줄로 남김(방금 저장된 수량산출서의 확장수량변수 값)
*/
function assignFromSheet(qrows, sheet) {
const ev = evalSheet(sheet);
const errors = [];
const xcode = Object.entries(ev.aliasValues)
.map(([k, v]) => `${k}=${v}`)
.join("\r\n");
const out = qrows.map((r) => {
const row = { ...r };
for (const ln of String(r.IFORM || "").split(/\r?\n/)) {
const m = ln.match(/^\s*(QTY\d)\s*=\s*(.+)$/i);
if (!m) continue;
const res = X().evaluate(m[2], { ...ev.aliasValues });
if (res.error || res.missing.length) {
errors.push({
code: r.CODE,
msg: `${m[1]} — ${res.error || `없는 셀별명 「${res.missing[0]}」`}`,
});
continue;
}
row[m[1].toUpperCase()] = String(res.value);
}
row.XCODE = xcode;
return row;
});
return { rows: out, errors, sheetErrors: ev.errors };
}
// 수량코드 검사(F3-29) — 형식 · 중복 · 내역코드 · 수치 · XCODE 줄 · IFORM 줄
function checkQcodes(qrows, costnCodes) {
const errs = [];
const seen = new Set();
const codes = costnCodes instanceof Set ? costnCodes : new Set(costnCodes || []);
qrows.forEach((r, i) => {
const c = String(r.CODE || "")
.trim()
.toUpperCase();
const at = `${i + 1}행 ${c || "(빈 코드)"}`;
if (!/^Q\d{5}$/.test(c)) errs.push(`${at}: 수량코드는 Q + 숫자 다섯 자리`);
if (seen.has(c)) errs.push(`${at}: 같은 수량코드가 이미 있음`);
seen.add(c);
const bq = String(r.BQCODE || "").trim();
if (bq && codes.size && !codes.has(bq)) errs.push(`${at}: 내역코드 ${bq} 는 단가목록에 없음`);
for (let k = 0; k < 10; k++) {
const v = String(r[`QTY${k}`] ?? "").trim();
if (v && !/^[-+]?[\d,]*\.?\d+$/.test(v))
errs.push(`${at}: QTY${k} 값 「${v}」 은 숫자가 아님`);
}
String(r.XCODE || "")
.split(/\r?\n/)
.filter((ln) => ln.trim())
.forEach((ln) => {
if (!/^\s*[A-Za-z가-힣_][^=]*=/.test(ln))
errs.push(`${at}: 확장수량변수 줄 「${ln}」 은 이름=값 꼴이 아님`);
});
String(r.IFORM || "")
.split(/\r?\n/)
.filter((ln) => ln.trim())
.forEach((ln) => {
if (!/^\s*QTY\d\s*=/.test(ln))
errs.push(`${at}: 연결식 줄 「${ln}」 은 QTYn=식 꼴이 아님`);
});
});
return errs;
}
// 다음 수량코드 번호
function nextQcode(qrows) {
const max = qrows.reduce((m, r) => Math.max(m, num(String(r.CODE || "").slice(1))), 0);
return "Q" + String(max + 1).padStart(5, "0");
}
/**
* 내역서로 가져가기 — 시작~끝 줄(작은 쪽부터 양 끝 포함) · mode code = 내역코드 칸 · name = 명칭 칸
* 돌려줌 [{ row, code, name, size, unit, qty, M, L, S, T, itnum }] · 단가 T = M+L+S(수량 곱 없음)
*/
function sheetToBoq(sheet, mode = "code") {
const ev = evalSheet(sheet);
const roles = sheet.roles || {};
const cell = (role, r) =>
roles[role] === undefined || roles[role] === ""
? ""
: ev.values[addr(r, colIndex(roles[role]))];
const need = mode === "code" ? ["code", "qty"] : ["name", "size", "unit", "qty"];
const missing = need.filter((k) => roles[k] === undefined || roles[k] === "");
if (missing.length) return { items: [], error: `셀별명 설정 안 됨: ${missing.join(", ")}` };
const a = Math.min(sheet.start, sheet.end),
b = Math.max(sheet.start, sheet.end);
const items = [];
for (let r = a; r <= b; r++) {
const code = String(cell("code", r) ?? "").trim();
const name = String(cell("name", r) ?? "").trim();
if (mode === "code" ? !code : !name) continue;
const M = num(cell("mamt", r)),
L = num(cell("lamt", r)),
S = num(cell("samt", r));
items.push({
row: r + 1,
code,
name,
size: String(cell("size", r) ?? ""),
unit: String(cell("unit", r) ?? ""),
qty: num(cell("qty", r)),
M,
L,
S,
T: M + L + S,
itnum: String(cell("no", r) ?? ""),
});
}
return { items, error: null };
}
// 코드 종류(wQtyList · wM_JabPum cBoxCode) — 모든단가 · 노무 · 재료 · 경비 · 일위대가 · 중기 · 단가산출 · 견적 · 대가산근
const KINDS = [
["ALL", "모든단가", () => true],
["L", "노무비", (c) => c[0] === "L"],
["M", "재료비", (c) => c[0] === "M"],
["S", "경비", (c) => c[0] === "S"],
["B", "일위대가", (c) => c[0] === "B"],
["X", "중기비", (c) => c[0] === "X"],
["D", "단가산출", (c) => c[0] === "D"],
["W", "견적단가", (c) => c[0] === "W"],
["BD", "대가,산근", (c) => c[0] === "B" || c[0] === "D"],
];
const STRUCT = /^[TCE]\d*$/;
// 코드 종류별 내역 수량(F3-24) — 내역 단가코드 행의 수량을 코드별로 모음
function qtyByCode(boqRows, kind = "ALL", field = "QTY1") {
const test = (KINDS.find((k) => k[0] === kind) || KINDS[0])[2];
const by = {};
for (const r of boqRows || []) {
const c = String(r.BDCODE1 || "").trim();
if (!c || STRUCT.test(c) || !test(c)) continue;
const o = (by[c] = by[c] || {
code: c,
name: r.NAME1 || "",
size: r.SIZE1 || "",
unit: r.UNIT1 || "",
qty: 0,
rows: 0,
});
o.qty += num(r[field]);
o.rows += 1;
}
return Object.values(by).sort((a, b) => a.code.localeCompare(b.code));
}
/**
* 내역↔BDQTY 수량 연결(F3-28 · ST_U_ConBD CON_QTY) — 내역 수량 × 구성 수량을 하위로 펼쳐
* 기초 자원(L·M·S)과 중간 호표(B·D·X)의 소요 수량을 모음 · 비율(PR·RR)·구분(T·C) 줄은 건너뜀
* 돌려줌 { totals: {코드: 수량}, cycles: [경로] }
*/
function rollup(boqRows, bdqtyRows, field = "QTY1") {
const groups = {};
for (const r of bdqtyRows || [])
(groups[String(r.BDCODE || "").trim()] = groups[String(r.BDCODE || "").trim()] || []).push(r);
const totals = {},
cycles = [];
const qOf = (row) => {
const q = num(row.QTY1);
return ["T", "t", "1", true].includes(row.DEVD1) ? (q ? 1 / q : 0) : q;
};
const walk = (code, qty, path) => {
totals[code] = (totals[code] || 0) + qty;
const g = groups[code];
if (!g) return;
if (path.includes(code)) return cycles.push([...path, code].join(" → "));
for (const row of g) {
const c = String(row.CODE || "").trim();
if (!c || /^(PR|RR)$/.test(c) || STRUCT.test(c)) continue;
walk(c, qty * qOf(row), [...path, code]);
}
};
for (const r of boqRows || []) {
const c = String(r.BDCODE1 || "").trim();
if (!c || STRUCT.test(c)) continue;
const q = num(r[field]);
if (q) walk(c, q, []);
}
return { totals, cycles };
}
// 수량산출서 파일 — 서명 검사(호환되지 않는 파일형식 알림)
function serialize(sheet, qrows) {
return JSON.stringify({ sig: SHEET_SIG, sheet, qcode: qrows || [] });
}
function deserialize(text) {
let d;
try {
d = JSON.parse(text);
} catch (_e) {
return { error: "수량산출서로 저장하려는 파일이 손상되었거나 호환되지 않는 파일형식" };
}
if (!d || d.sig !== SHEET_SIG || !d.sheet || typeof d.sheet.cells !== "object")
return { error: "호환되지 않는 파일형식 — STMATE 수량산출서가 아님" };
return {
sheet: Object.assign(newSheet(), d.sheet),
qcode: Array.isArray(d.qcode) ? d.qcode : [],
};
}
return {
SHEET_SIG,
KINDS,
colName,
colIndex,
addr,
parseAddr,
newSheet,
evalSheet,
assignFromSheet,
checkQcodes,
nextQcode,
sheetToBoq,
qtyByCode,
rollup,
serialize,
deserialize,
};
})();
if (typeof window !== "undefined") window.STMATE_QTY_SHEET = StmateQtySheet;
if (typeof module !== "undefined" && module.exports) module.exports = StmateQtySheet;