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

219 lines
9.5 KiB
JavaScript

/* -*- coding: utf-8 -*- */
/**
* 보고서 출력 화면 (scr_report.js) — PM_Prt 대체 · 서식 목록(SET_REP 75종) · 미리보기 · 인쇄 · 엑셀/CSV · 조달청 XML
* 근거: 지식DB STmate/old/20_분석 20 §3(SET_REP) · 30(조달청 XSD) · old/30_원자료/print_form_map.txt
* 계산 = calc.js 결과(ctx.computed) · 시트 모형 = report_*.js(엑셀 대조 시험 resources/tester/test_stmate_report_*.py)
*/
(function (global) {
"use strict";
// 보고서 짜는 모듈 — 이 끼워 넣기 파일이 불릴 때(문서 읽는 중) 함께 실음
if (
typeof document !== "undefined" &&
document.readyState === "loading" &&
!global.STMATE_REPORT_BOOK
)
[
"report_core.js",
"report_unit.js",
"report_boq.js",
"report_book.js",
"report_pps.js",
].forEach((f) => document.write(`<script src="${f}"></script>`));
const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || {
screens: {},
actions: {},
menus: [],
});
const esc = (s) =>
String(s ?? "").replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
const fmtCell = (v) =>
typeof v === "number"
? new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 }).format(v)
: esc(v).replace(/\n/g, "<br>");
const colIdx = (k) => k.split("").reduce((a, ch) => a * 26 + ch.charCodeAt(0) - 64, 0);
const colName = (n) =>
(n > 26 ? String.fromCharCode(64 + Math.floor((n - 1) / 26)) : "") +
String.fromCharCode(65 + ((n - 1) % 26));
// 차수 — 원가계산서가 저장된 차수(kanjubStages) · 2 이상이면 변경 양식
function stages(ctx) {
const st = (ctx.state && ctx.state.kanjubStages) || {};
const ks = Object.keys(st).map(Number);
return ks.length ? ks : [1];
}
function book(ctx) {
const ui = ctx.uiState;
const n = Number(ui.reportStage) || 1;
const key = `${ctx.currentSourcePath}|${n}|${ctx.computed && ctx.computed.kanjubComputed ? ctx.computed.kanjubComputed.grandTotal : 0}`;
if (ui._reportBook && ui._reportBookKey === key) return ui._reportBook;
ui._reportBook = global.STMATE_REPORT_BOOK.buildBook(ctx.dbfData, ctx.state, ctx.computed, n);
ui._reportBookKey = key;
return ui._reportBook;
}
// 시트 모형 → 표(미리보기 · 인쇄)
function sheetHtml(sh) {
let maxC = 1;
for (const r of sh.rows)
for (const k of Object.keys(r)) if (/^[A-Z]{1,2}$/.test(k)) maxC = Math.max(maxC, colIdx(k));
const cols = Array.from({ length: maxC }, (_, i) => colName(i + 1));
const body = sh.rows
.map((r) => {
if (r._k === "page") return `<tr class="rp-page"><td colspan="${maxC}"></td></tr>`;
const tds = cols
.map((c) => {
const v = r[c];
const num = typeof v === "number";
return `<td style="${num ? "text-align:right" : ""}">${v == null ? "" : fmtCell(v)}</td>`;
})
.join("");
const bold = /^(title|head|T\d|sub|total|final|hopyo|parent)$/.test(r._k || "");
return `<tr style="${bold ? "font-weight:bold" : ""}${r._k === "head" ? ";background:#eef3f8" : ""}">${tds}</tr>`;
})
.join("");
return `<table class="rp-table" style="border-collapse:collapse;font-size:12px" border="1">${body}</table>`;
}
P.screens.PM_Prt = {
render(ctx) {
if (!ctx.dbfData)
return `<div class="pane"><div class="pane-title">보고서 출력</div><p>공사를 먼저 불러오십시오.</p></div>`;
const ui = ctx.uiState;
const B = global.STMATE_REPORT_BOOK;
const bk = book(ctx);
const ids = bk.sheets.map((s) => s.id || s.name);
if (!ids.includes(ui.reportSheet))
ui.reportSheet = ids.includes("공사원가계산서") ? "공사원가계산서" : ids[0];
const cur = bk.sheets.find((s) => (s.id || s.name) === ui.reportSheet);
const forms = (ctx.dbfData.set_rep_rows || [])
.slice()
.sort((a, b) => String(a.INDX).localeCompare(String(b.INDX)))
.map((r) => {
const id = B.FORM_SHEET[String(r.INDX).trim()];
const on = id && ids.includes(id);
return `<div class="rp-form${on && id === ui.reportSheet ? " active" : ""}" data-rp-sheet="${on ? esc(id) : ""}" style="padding:2px 6px;cursor:${on ? "pointer" : "default"};color:${on ? "inherit" : "#999"}">${esc(r.INDX)} ${esc(r.NAME)}${on ? "" : " · 미구현"}</div>`;
})
.join("");
const extra = ["〓 목 차 〓", "※※안내※※"]
.filter((id) => ids.includes(id))
.map(
(id) =>
`<div class="rp-form" data-rp-sheet="${esc(id)}" style="padding:2px 6px;cursor:pointer">${esc(id)}</div>`,
)
.join("");
const stOpts = stages(ctx)
.map(
(n) =>
`<option value="${n}" ${Number(ui.reportStage || 1) === n ? "selected" : ""}>${n === 1 ? "당초(1차수)" : `변경 ${n}차수`}</option>`,
)
.join("");
return `<div class="pane" id="rp-root">
<div class="pane-title"><span>보고서 출력 · 미리보기 (SET_REP 서식 ${forms ? (ctx.dbfData.set_rep_rows || []).length : 0}종)</span></div>
<div style="display:flex;gap:6px;align-items:center;margin:4px 0;flex-wrap:wrap">
<label>차수 <select id="rp-stage">${stOpts}</select></label>
<button class="win-btn" id="rp-print">인쇄</button>
<button class="win-btn" id="rp-xlsx-all">엑셀 내보내기(전체)</button>
<button class="win-btn" id="rp-xlsx-one">엑셀(이 시트)</button>
<button class="win-btn" id="rp-csv">CSV(이 시트)</button>
<button class="win-btn" id="rp-pps">조달청 XML</button>
<span id="rp-msg"></span>
</div>
<div style="display:flex;gap:8px;align-items:flex-start">
<div style="width:240px;max-height:70vh;overflow:auto;border:1px solid #ccc">${extra}${forms}</div>
<div id="rp-preview" style="flex:1;max-height:70vh;overflow:auto">
<h3 style="margin:2px 0">${esc(cur ? cur.title : "")}</h3>${cur ? sheetHtml(cur) : ""}
</div>
</div></div>`;
},
bind(ctx, api) {
const ui = ctx.uiState;
const $ = (id) => document.getElementById(id);
const msg = (t) => $("rp-msg") && ($("rp-msg").textContent = t);
document.querySelectorAll("[data-rp-sheet]").forEach((el) => {
el.onclick = () => {
const id = el.getAttribute("data-rp-sheet");
if (!id) return;
ui.reportSheet = id;
api.renderWorkspace();
};
});
if ($("rp-stage"))
$("rp-stage").onchange = (e) => {
ui.reportStage = Number(e.target.value);
api.renderWorkspace();
};
const pname = () =>
String((ctx.dbfData.prjlst && ctx.dbfData.prjlst.PNAME) || "STMATE").replace(
/[\\/:*?"<>|]/g,
"_",
);
const post = async (url, sheets, filename, mime) => {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sheets, filename }),
});
if (!res.ok) throw new Error(await res.text());
api.downloadBlob(await res.blob(), filename, mime);
};
const cur = () => book(ctx).sheets.find((s) => (s.id || s.name) === ui.reportSheet);
const xmime = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
if ($("rp-xlsx-all"))
$("rp-xlsx-all").onclick = () =>
post("/api/report/xlsx", book(ctx).sheets, `${pname()}.xlsx`, xmime).then(
() => msg("엑셀 저장"),
(e) => msg(e.message),
);
if ($("rp-xlsx-one"))
$("rp-xlsx-one").onclick = () =>
post("/api/report/xlsx", [cur()], `${cur().name}.xlsx`, xmime).then(
() => msg("엑셀 저장"),
(e) => msg(e.message),
);
if ($("rp-csv"))
$("rp-csv").onclick = () =>
post("/api/report/csv", [cur()], `${cur().name}.csv`, "text/csv").then(
() => msg("CSV 저장"),
(e) => msg(e.message),
);
if ($("rp-pps"))
$("rp-pps").onclick = async () => {
try {
const xml = global.STMATE_REPORT_PPS.buildPpsXml(
ctx.dbfData,
ctx.state,
ctx.computed,
Number(ui.reportStage) || 1,
);
const v = await api.postJson("/api/report/validate-pps", { xml });
api.downloadBlob(xml, `${pname()}_조달청.xml`, "application/xml;charset=utf-8");
msg(
v.ok
? "조달청 XML 저장 · XSD 검증 통과"
: `XSD 위반 ${v.errors.length}건: ${v.errors[0] || ""}`,
);
} catch (e) {
msg(e.message);
}
};
if ($("rp-print"))
$("rp-print").onclick = () => {
const w = window.open("", "_blank");
if (!w) return msg("팝업 차단됨");
const sh = cur();
w.document.write(
`<html><head><title>${esc(sh.title)}</title><style>@page{size:A4 ${sh.rows.some((r) => r.M != null) ? "landscape" : "portrait"};margin:12mm}table{border-collapse:collapse;font-size:10pt}td{border:1px solid #888;padding:1px 4px}.rp-page{page-break-after:always}</style></head><body><h2 style="text-align:center">${esc(sh.title)}</h2>${sheetHtml(sh)}</body></html>`,
);
w.document.close();
w.focus();
w.print();
};
},
};
})(typeof window !== "undefined" ? window : globalThis);