- 보고서: 원본 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>
319 lines
12 KiB
JavaScript
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;
|