Merge remote-tracking branch 'origin/안티그래비티' into sub_laptop_8

This commit is contained in:
2026-11-08 12:15:03 +09:00
52 changed files with 2287 additions and 452 deletions
+92
View File
@@ -0,0 +1,92 @@
# STMATE w 8.0 테스트 앱 계획서 및 검증 아카이브
이 문서는 `tools/stmate-test/` 폴더 내 독립 웹앱의 개발 계획 및 검증 이력을 자체 보관·관리하는 문서입니다.
## 1. 개요 및 요구사항
- **목적**: 경쟁사 데스크톱 프로그램 STMATE(w 8.0, 경영정보(주))의 95% 이상 복제 수준 달성 및 독립 웹앱 구축
- **작업 경로**: `tools/stmate-test/`
- **구동 서버**: `http://127.0.0.1:8095` (FastAPI + DBF/SMT/STC/JB_R 직결 엔진)
- **외부 연동**: 로그인 및 공용 DB 의존 없음, 실무 프로젝트 DBF/SMT/STC 100% 무손실 연동
---
## 2. 세부 개발 및 검증 항목
- [x] **화면 및 메뉴 체계 복제 (TWM_STMATE_START_8X)**
- 상단 5대 메뉴(`Act_PrjList`, `Act_Init`, `Act_BoqEdit`, `Act_KanJub`, `Act_KanRate`, `Act_StageEdit`, `Act_Print`)
- 8버튼 메인 툴바 및 하단 순공사비·간접비·도급액·총공사비 집계바
- 9개 메인 작업 화면:
- `wPrjList`: 공사목록표 및 하단 `BOQ11.DBF` 미리보기
- `wBoq`: 계층 내역서(`T1..T6`/`E1..E6`/`C*`), 공종번호 자동입력(`wITNum`), 내역할증(`wBoqRate`), 단수보정(`wM_Rate`), 일위풀기(`wExpnd`), 수량산출 연결(`wQtyList`), 내역보관함 (1)(2)(3)
- `wM_Set_Sys`: 환경설정 (환율, ECD 소수위, 계산식, 환산식 `1/8*16/12*25/20`)
- `wM_JabPum`: 단가목록표 (5대 가격지 비교 `wM_Boxa`, 기준단가 비율조정 `wM_Edt_Hal`, 표준 카탈로그 브라우저)
- `wM_Edit_iLWi`: 일위대가·단가산출 수식 편집기 (`COSTN.SMT` 64B 블록 전개식, 표준 카탈로그 894코드·350레시피·21특수산식)
- `wM_KanJub`: 제잡비 계산서 (4탭 `T/S/TA/SA`, 16대 콤보박스 및 이윤 끝전조정)
- `wM_KAN_RATE`: 원가계산기준 제비율표 (`RATE_R/RA/M/B.DBF` 50×35 및 `ST_BSC/*.JB_R` 파일 직결)
- `wStage`: 설계 후속 단계 (`wM_Mk_Cont` 계약내역, `wM_Boq_ExecX` 실행내역, `wM_KanJub_K` 기성내역, `REVISION` 설계변경 증감대비표)
- `PM_Prt`: 30종 보고서 인쇄 미리보기 및 6시트 엑셀(`.xls`), 조달청 `<T3_C3B>` XML, JDL 내보내기
- [x] **100% 범용 `COSTN.SMT` 수식 인터프리터 (`smt_engine.js`)**
- 봉화군 2024 `COSTN.SMT` 전수 65/65건(100%) 1원 오차 없이 일치 검증:
- `;;` 헤더 변수 분리 파싱
- `[A]`, `[D]'%'`, `N'%'` ($N \ge 1 \Rightarrow N \times 0.01$, $N < 1 \Rightarrow N$)
- `&PA01:M&`, `&RA01:M&`, `&PS_M&`, `&RC_S:S&` 라벨·구간·누적 소계 처리
- `&L00020&` 기구손료 인건비 누적소계 자동참조
- `(-=)` 소계제외 블록, `(=)`/`(==)`/`(===)` 구간합산
- 운반비(`운반`) 경비(`S`) 일괄 귀속 규칙
- [x] **5단 연쇄 재계산 엔진 (`cascadeRecalculateAll` / `[계산완료]`)**
- 기초단가(`L*`/`M*`/`S*`) 변경 시 실시간 연쇄 전파:
1. 중기사용료(`X*` 25/25건 100% 일치, 운전사 노임 좌→우 `1/8*16/12*25/20` 시간당 환산)
2. 단가산출(`D*` 65/65건 100% 일치)
3. 일위대가(`B*` 45/45건 100% 일치, 공구손료·잡품 `재료비의 %`/`노무비의 %` 구분)
4. 내역서(`BOQ11.DBF` 141행)
5. 원가계산서(`PRJLST.DBF/.SMT`)
- [x] **다중 슬롯 DBF 양방향 저장 (`/api/save`)**
- 당초설계(`*1`), 설계변경(`*2`), 계약내역(`*3`) 멀티 슬롯 컬럼을 `COSTN.DBF`, `BOQ11.DBF`, `PRJLST.DBF`, `PRJLST.SMT`, `COSTN.SMT` 및 `.STC` 아카이브에 무손실 저장
- [x] **보고서 엑셀 내보내기 원본 STMATE 1:1 복제 (`excel_builder.py` + `/api/export-xlsx`)**
- 원본 STMATE 엑셀 산출물 분석 기반 11개 시트 구성 및 네이티브 하이퍼링크/수식 구현:
- `〓 목 차 〓`: 전 시트 바로가기 `=HYPERLINK("#'시트명'!A1", "시트명 →")` 수식 적용
- 전 시트 상단 우측: 목차 복귀 `=HYPERLINK("#'〓 목 차 〓'!B2", "목차 →")` 수식 적용
- `설계내역서` ↔ `단가산출근거목록표` / `일위대가목록표` 간 셀 참조 수식 및 `=ROUNDDOWN(수량*단가, 0)` 수식화
- 원가계산서 비목별 연동 수식 및 11개 시트 스타일링 (헤더 폰트, 테두리, 열 너비 자동 조정)
- 백엔드 생성기(`excel_builder.py`, 452줄) + 서버 엔드포인트(`server.py`, 630줄) + UI 연동(`app.js`, 689줄)
- 전 파일 700줄 제한 엄격 준수 및 ruff/prettier 포맷팅 적용
---
## 3. 검증 결과 기록
### 자체 검증 (2026-11-05)
- **700줄 제한 및 포맷터**: 소스코드 10개 파일 전체 700줄 이하 준수 및 ruff/prettier 포맷 완료.
- **브라우저 결함 수정 5건**:
1. `wBoq` 상단 헤더 관급자재 합계(`govMatTotal: govRawTotal`) 누락 수정 (`143,554,309원` 표시)
2. `wM_KanJub` 및 `PM_Prt` 산정기초 금액 열(`base: baseAmt`) 표시
3. `wStage` 설계변경 증감대비표 런타임 에러 수정
4. `wM_Edit_iLWi` `D00016` 굴삭기 $Q$ 산식 중간 변수 반올림($f=0.85 \rightarrow Q=22.85$) 적용으로 DB 단가(`3,825원`) 일치
5. `wExpnd` 모달 내역서 전개 삽입 연동
### 자체 검증 (2026-11-07 · 95%+ 완전 복제 고도화)
- **Playwright 브라우저 엔진 전수 검증 통과 (`verify_95_fidelity.py`)**:
- `COSTN.SMT` 65/65건 100% 일치
- 중기사용료 `X*` 25/25건 100% 일치
- 단가산출 `D*` 65/65건 100% 일치
- 일위대가 `B*` 45/45건 100% 일치
- 봉화군 2024 산림유역관리사업 기준:
- 직접공사비(`TCOST1`): `781,283,415원`
- 도급액(`FINAL_SUPPLY`): `1,270,728,000원`
- 관급자재대: `143,555,000원`
- 총공사비(`COSTW1`): `1,414,283,000원` (1원 오차 없이 100% 일치)
- 동적 연쇄 전파 확인: 경유 `M00018` +100원 인상 시 `X00006` `65,015`→`65,627`원, `D00016` `3,825`→`3,878`원, 총공사비 `1,414,283,000`→`1,423,700,000`원 정상 반영
- 6시트 엑셀 워크북(`237,840`바이트) 및 조달청 XML(`52,252`바이트) 정상 생성
- 9개 화면 전 과정 콘솔/페이지 런타임 에러 `0건`
### 자체 검증 (2026-11-07 · 엑셀 원본 STMATE 1:1 하이퍼링크/수식 고도화)
- **11개 시트 엑셀 내보내기 검증 완료**:
- `〓 목 차 〓`, `공사원가계산서`, `단가산출근거목록표`, `일위대가목록표`, `설계내역서`, `중기사용료`, `단가산출근거`, `일위대가표`, `재료비목록표`, `노무비목록표`, `경비목록표` 총 11개 시트 정상 생성.
- 목차의 각 항목별 바로가기 `=HYPERLINK("#'시트명'!A1", "시트명 →")` 및 세부 시트의 목차 복귀 `=HYPERLINK("#'〓 목 차 〓'!B2", "목차 →")` 네이티브 엑셀 하이퍼링크 수식 동작 확인.
- 내역서 단가 참조 수식(`=단가산출근거목록표!F{row}`, `=일위대가목록표!F{row}`) 및 산출금액 내림 함수(`=ROUNDDOWN(...)`) 정상 반영.
- 서버 상시 구동 상태에서 `POST /api/export-xlsx` 엔드포인트 200 OK 응답 확인.
- 전체 소스코드 파일 700줄 이하 준수 (excel_builder.py: 452줄, server.py: 630줄, app.js: 689줄 등) 및 포맷터 완료.
+174 -182
View File
@@ -10,7 +10,12 @@
const ViewDanIlwi = global.STMATE_VIEW_DAN_ILWI;
const ViewKanjubPrt = global.STMATE_VIEW_KANJUB_PRT;
const { fmt, num } = Calc;
const $ = (id) => document.getElementById(id);
const $ = (id) => document.getElementById(id),
closeModal = () => $("stmate-modal-backdrop").classList.add("hidden"),
onClick = (id, fn) => $(id) && ($(id).onclick = fn),
onChange = (id, fn) => $(id) && ($(id).onchange = fn),
bindEach = (sel, ev, fn) =>
document.querySelectorAll(sel).forEach((el) => (el[ev] = (e) => fn(el, e)));
const ctx = {
state: Calc.createDefaultState(),
@@ -42,19 +47,16 @@
},
};
function recompute() {
ctx.computed = Calc.computeAll(ctx.state);
}
const recompute = () => (ctx.computed = Calc.computeAll(ctx.state));
function renderChromeBars() {
const prjBar = $("prj-context-bar");
const stBar = $("stmate-statusbar");
const kj = ctx.computed.kanjubComputed;
const bt = ctx.computed.boqComputed.totals;
const prjBar = $("prj-context-bar"),
stBar = $("stmate-statusbar");
const kj = ctx.computed.kanjubComputed,
bt = ctx.computed.boqComputed.totals;
const dbfInfo = ctx.dbfData
? `COSTN ${ctx.dbfData.costn_count}건 · SMT산식 ${Object.keys(ctx.dbfData.costn_memos || {}).length}건 · ${ctx.dbfData.boq_table_name} ${ctx.dbfData.boq_count}행`
: "내장 봉화군 2024 골든셋";
if (prjBar) {
prjBar.innerHTML = `<div class="prj-context-left"><span class="prj-tag">열린 공사</span><strong>${ctx.state.projectMeta.pname}</strong><span>(${ctx.state.projectMeta.pnum || "2024년 단가"} · 코드 BOQ${ctx.state.projectMeta.pcode || "11"})</span></div><div class="prj-context-right"><span>원본 DBF/SMT: <strong>${dbfInfo}</strong></span><span>원가양식: <strong>${ctx.state.kanjubConfig.scheme}형식</strong></span></div>`;
}
@@ -117,17 +119,28 @@
}
}
function cascadeAndRender() {
if (global.STMATE_ENGINE && global.STMATE_ENGINE.cascadeRecalculateAll)
global.STMATE_ENGINE.cascadeRecalculateAll(ctx);
renderWorkspace();
}
async function saveAllToDbfServer() {
if (!ctx.currentSourcePath || !ctx.dbfData) {
if (!ctx.currentSourcePath || !ctx.dbfData)
return alert("먼저 PC 또는 실무 STMATE 프로젝트를 로드하세요.");
}
const stat =
global.STMATE_ENGINE && global.STMATE_ENGINE.cascadeRecalculateAll
? global.STMATE_ENGINE.cascadeRecalculateAll(ctx)
: { xCount: 0, dCount: 0, bCount: 0 };
recompute();
const kj = ctx.computed.kanjubComputed;
const bt = ctx.computed.boqComputed.totals;
renderChromeBars();
const kj = ctx.computed.kanjubComputed,
bt = ctx.computed.boqComputed.totals;
try {
const saved = await postJson("/api/save", ViewPrjBoq.buildSavePayload(ctx));
renderWorkspace();
alert(
`[계산완료 및 DBF/SMT/STC 저장 성공]\n• 폴더: ${saved.saved_dir}\n• 아카이브: ${saved.saved_stc} (${fmt(saved.stc_size)}B)\n• 직공비: ${fmt(bt.directT)}원 / 총공사비: ${fmt(kj.GRAND_TOTAL)}원`,
`[계산완료(Act_Calc_All) 및 DBF/SMT/STC 저장 성공]\n• 5단 연쇄재계산: 중기(X) ${stat.xCount}건 · 산근(D) ${stat.dCount}건 · 일위대가(B) ${stat.bCount}건\n• 폴더: ${saved.saved_dir}\n• 아카이브: ${saved.saved_stc} (${fmt(saved.stc_size)}B)\n• 직공비: ${fmt(bt.directT)}원 / 총공사비: ${fmt(kj.GRAND_TOTAL)}원`,
);
} catch (err) {
alert("DBF/STC 저장 실패: " + err.message);
@@ -141,18 +154,6 @@
bindModalEvents();
}
const closeModal = () => $("stmate-modal-backdrop").classList.add("hidden");
const onClick = (id, fn) => {
const el = $(id);
if (el) el.onclick = fn;
};
const onChange = (id, fn) => {
const el = $(id);
if (el) el.onchange = fn;
};
const bindEach = (sel, ev, fn) =>
document.querySelectorAll(sel).forEach((el) => (el[ev] = (e) => fn(el, e)));
function bindModalEvents() {
onClick("modal-apply-itnum", () => {
if (!ctx.dbfData || !ctx.dbfData.boq_rows) return;
@@ -175,9 +176,8 @@
onClick("modal-apply-boqrate", () => {
const [rm, rl, rs] = ["m", "l", "s"].map((k) => num($(`boqrate-${k}`).value, 100) / 100);
ctx.state.resources.forEach((r) => {
r.manualPrice = Math.floor(
num(r.manualPrice) * (r.kind === "재료비" ? rm : r.kind === "노무비" ? rl : rs),
);
const mult = r.kind === "재료비" ? rm : r.kind === "노무비" ? rl : rs;
r.manualPrice = Math.floor(num(r.manualPrice) * mult);
});
closeModal();
renderWorkspace();
@@ -191,34 +191,16 @@
const code = ($("expnd-code") || {}).value;
const ilwi =
(ctx.state.ilwiItems || []).find((x) => x.code === code) || ctx.state.ilwiItems[0];
if (ilwi && ilwi.lines && ilwi.lines.length) {
ilwi.lines.forEach((ln, idx) => {
const r = ctx.state.resources.find((x) => x.id === ln.refId || x.code === ln.refId);
const recno = (Date.now() % 90000) + idx + 1;
ctx.state.boqItems.push({
id: "BOQ_" + recno,
code: r ? r.code : ln.refId,
name: `[전개] ${r ? r.name : ln.refId}`,
refId: ln.refId,
rowType: "normal",
qty: ln.qty || 1,
changedQty: ln.qty || 1,
_recno: recno,
});
if (ctx.dbfData && ctx.dbfData.boq_rows) {
ctx.dbfData.boq_rows.splice(5 + idx, 0, {
_recno: recno,
ITNUM: "",
BDCODE1: r ? r.code : ln.refId,
NAME1: `[전개] ${r ? r.name : ln.refId}`,
SIZE1: r ? r.spec : "",
UNIT1: r ? r.unit : "식",
QTY1: String(ln.qty || 1),
ENDCODE: "0.0000",
});
}
});
}
((ilwi && ilwi.lines) || []).forEach((ln) => {
const r = ctx.state.resources.find((x) => x.id === ln.refId || x.code === ln.refId);
insertBoqRow(
r ? r.code : ln.refId,
`[전개] ${r ? r.name : ln.refId}`,
r ? r.spec : "",
r ? r.unit : "식",
ln.qty || 1,
);
});
closeModal();
renderWorkspace();
});
@@ -242,24 +224,26 @@
(ctx.dbfData ? ctx.dbfData.costn_rows : []).forEach((r) => {
const code = String(r.CODE || "").trim();
if (!code.startsWith("M")) return;
const cands = [num(r.AMT_11), num(r.AMT_21), num(r.AMT_31), num(r.AMT_41), num(r.AMT_51)];
const nonZero = cands.filter((x) => x > 0);
if (nonZero.length)
ViewPrjBoq.syncCostnUnitPrice(
ctx,
code,
mode === "min" ? Math.min(...nonZero) : cands[parseInt(mode, 10)] || nonZero[0],
0,
0,
);
const nonZero = [
num(r.AMT_11),
num(r.AMT_21),
num(r.AMT_31),
num(r.AMT_41),
num(r.AMT_51),
].filter((x) => x > 0);
if (nonZero.length) {
const picked =
mode === "min" ? Math.min(...nonZero) : nonZero[parseInt(mode, 10)] || nonZero[0];
ViewPrjBoq.syncCostnUnitPrice(ctx, code, picked, 0, 0);
}
});
closeModal();
renderWorkspace();
cascadeAndRender();
});
onClick("modal-apply-edthal", () => {
const kind = $("edthal-kind").value;
const pct = num($("edthal-pct").value, 100) / 100;
const rmode = $("edthal-round").value;
const kind = $("edthal-kind").value,
pct = num($("edthal-pct").value, 100) / 100,
rmode = $("edthal-round").value;
(ctx.dbfData ? ctx.dbfData.costn_rows : []).forEach((r) => {
const code = String(r.CODE || "").trim();
if (!code || r.UC === "T" || (kind !== "ALL" && code[0].toUpperCase() !== kind)) return;
@@ -273,7 +257,7 @@
);
});
closeModal();
renderWorkspace();
cascadeAndRender();
});
bindEach(".btn-use-special-san", "onclick", (btn) => {
const spec = (ctx.catalogData.special_san || [])[parseInt(btn.getAttribute("data-idx"), 10)];
@@ -287,6 +271,33 @@
});
}
function insertBoqRow(code, name, spec, unit, qty) {
const recno = Date.now() % 100000;
ctx.state.boqItems.push({
id: "BOQ_" + recno,
code,
name,
refId: code,
rowType: "normal",
qty,
changedQty: qty,
_recno: recno,
});
if (ctx.dbfData && ctx.dbfData.boq_rows) {
ctx.dbfData.boq_rows.splice(4, 0, {
_recno: recno,
ITNUM: "",
BDCODE1: code,
NAME1: name,
SIZE1: spec,
UNIT1: unit,
QTY1: String(qty),
ENDCODE: "0.0000",
});
}
renderWorkspace();
}
function bindWorkspaceEvents() {
const goScreen = (scr) => {
ctx.uiState.activeScreen = scr;
@@ -304,10 +315,15 @@
const p = btn.getAttribute("data-path");
p === ctx.currentSourcePath ? goScreen("wBoq") : loadProjectFromServer(p, "wPrjList");
});
onClick("prj-btn-open-boq", () => goScreen("wBoq"));
onClick("prj-btn-kanjub", () => goScreen("wM_KanJub"));
onClick("prj-btn-copy-reg", () => goScreen("wStage"));
onClick("prj-btn-pack-stc", () => saveAllToDbfServer());
"prj-btn-open-boq:wBoq,prj-btn-kanjub:wM_KanJub,prj-btn-copy-reg:wStage,kj-btn-open-rate:wM_KAN_RATE"
.split(",")
.forEach((p) => {
const [id, scr] = p.split(":");
onClick(id, () => goScreen(scr));
});
["prj-btn-pack-stc", "boq-btn-save-dbf", "kj-btn-save-dbf"].forEach((id) =>
onClick(id, saveAllToDbfServer),
);
onClick("prj-btn-toggle-preview", () => {
ctx.uiState.showBoqPreview = !ctx.uiState.showBoqPreview;
renderWorkspace();
@@ -368,33 +384,6 @@
ctx.uiState.activePocket = parseInt(btn.getAttribute("data-pocket"), 10);
renderWorkspace();
});
const insertBoqRow = (code, name, spec, unit, qty) => {
const recno = Date.now() % 100000;
ctx.state.boqItems.push({
id: "BOQ_" + recno,
code,
name,
refId: code,
rowType: "normal",
qty,
changedQty: qty,
_recno: recno,
});
if (ctx.dbfData && ctx.dbfData.boq_rows) {
ctx.dbfData.boq_rows.splice(4, 0, {
_recno: recno,
ITNUM: "",
BDCODE1: code,
NAME1: name,
SIZE1: spec,
UNIT1: unit,
QTY1: String(qty),
ENDCODE: "0.0000",
});
}
renderWorkspace();
};
bindEach(".pocket-insert-btn", "onclick", (btn) => {
const item =
ctx.uiState.boqPockets[ctx.uiState.activePocket || 1][
@@ -402,16 +391,12 @@
];
if (item) insertBoqRow(item.code, item.name, "", "식", item.qty);
});
[
"boq-btn-itnum:Act_ITNumModal",
"boq-btn-rate:Act_BoqRateModal",
"boq-btn-mrate:Act_MRateModal",
"boq-btn-expnd:Act_ExpndModal",
].forEach((pair) => {
const [id, key] = pair.split(":");
bindModalOpener(id, key);
});
onClick("boq-btn-save-dbf", () => saveAllToDbfServer());
"boq-btn-itnum:Act_ITNumModal,boq-btn-rate:Act_BoqRateModal,boq-btn-mrate:Act_MRateModal,boq-btn-expnd:Act_ExpndModal,dan-btn-boxa:Act_BoxaModal,dan-btn-edthal:Act_EdtHalModal,dan-btn-catalog:Act_CatalogModal,ilwi-btn-load-recipe:Act_CatalogModal"
.split(",")
.forEach((p) => {
const [id, key] = p.split(":");
bindModalOpener(id, key);
});
onClick("boq-btn-add-leaf", () => {
const f = ctx.state.ilwiItems[0] || {
code: "D00014",
@@ -421,7 +406,6 @@
};
insertBoqRow(f.code, f.rawName || f.name, f.spec || "", f.unit || "m3", 10);
});
onClick("sys-btn-apply", () => {
Object.assign(ctx.state.sysConfig, {
usdRate1: num($("sys-usd1").value, 1350),
@@ -433,7 +417,7 @@
rxFormula: $("sys-rx-formula").value,
condCodes: $("sys-cond-codes").value,
});
renderWorkspace();
cascadeAndRender();
});
bindEach(".dan-filter-btn", "onclick", (btn) => {
ctx.uiState.danFilter = btn.getAttribute("data-filter");
@@ -454,21 +438,12 @@
num(tr.querySelector(".dan-edit-l").value),
num(tr.querySelector(".dan-edit-s").value),
);
renderWorkspace();
cascadeAndRender();
});
bindEach(".dan-jump-ilwi", "onclick", (btn) => {
ctx.uiState.selectedIlwiCode = btn.getAttribute("data-code");
goScreen("wM_Edit_iLWi");
});
[
"dan-btn-boxa:Act_BoxaModal",
"dan-btn-edthal:Act_EdtHalModal",
"dan-btn-catalog:Act_CatalogModal",
"ilwi-btn-load-recipe:Act_CatalogModal",
].forEach((pair) => {
const [id, key] = pair.split(":");
bindModalOpener(id, key);
});
bindEach(".ilwi-sub-btn", "onclick", (btn) => {
ctx.uiState.ilwiSubFilter = btn.getAttribute("data-sub");
@@ -488,15 +463,12 @@
ctx.uiState.editedSmtMap[code] = txt;
if (ctx.dbfData && ctx.dbfData.costn_memos) {
const prev = ctx.dbfData.costn_memos[code] || { code, blockNo: 1 };
ctx.dbfData.costn_memos[code] = Object.assign({}, prev, {
text: txt,
byteLen: txt.length,
});
ctx.dbfData.costn_memos[code] = Object.assign({}, prev, { text: txt, byteLen: txt.length });
}
const evalRes = ViewDanIlwi.evaluateSmtFormulaText(txt, ctx);
const evalRes = ViewDanIlwi.evaluateSmtFormulaText(txt, ctx, code);
if (evalRes.sumT > 0)
ViewPrjBoq.syncCostnUnitPrice(ctx, code, evalRes.sumM, evalRes.sumL, evalRes.sumS);
renderWorkspace();
cascadeAndRender();
});
bindEach(".kj-scheme-tab", "onclick", (btn) => {
@@ -522,41 +494,38 @@
renderWorkspace();
}),
);
onClick("kj-btn-open-rate", () => goScreen("wM_KAN_RATE"));
onClick("kj-btn-save-dbf", () => saveAllToDbfServer());
onClick("kj-btn-apply-jbr", async () => {
const jbrPath = $("kj-jbr-select").value;
if (!jbrPath) return;
const d = await postJson("/api/load-jbr", { jbr_path: jbrPath });
const p = $("kj-jbr-select").value;
if (!p) return;
const d = await postJson("/api/load-jbr", { jbr_path: p });
Object.assign(ctx.state.kanjubConfig.rates, d.extracted_rates);
if (d.extracted_rates.anjunBaseAdd)
ctx.state.kanjubConfig.anjunBaseAdd = d.extracted_rates.anjunBaseAdd;
renderWorkspace();
});
bindEach(".kr-tab-btn", "onclick", (btn) => {
ctx.uiState.kanRateTab = btn.getAttribute("data-tab");
renderWorkspace();
});
onClick("kr-btn-load-jbr", async () => {
const jbrPath = $("kr-jbr-select").value;
if (!jbrPath) return;
const d = await postJson("/api/load-jbr", { jbr_path: jbrPath });
const p = $("kr-jbr-select").value;
if (!p) return;
const d = await postJson("/api/load-jbr", { jbr_path: p });
if (ctx.dbfData && ctx.dbfData.rate_tables) {
ctx.dbfData.rate_tables["RATE_R.DBF"] = d.rows;
ctx.uiState.kanRateTab = "RATE_R.DBF";
renderWorkspace();
}
});
bindEach(".stage-tab-btn", "onclick", (btn) => {
ctx.uiState.stageSubTab = btn.getAttribute("data-st");
renderWorkspace();
});
onClick("st-btn-apply-bid", () => {
ctx.uiState.bidRateM = num($("st-bid-m").value, 87.745);
ctx.uiState.bidRateL = num($("st-bid-l").value, 87.745);
ctx.uiState.bidRateS = num($("st-bid-s").value, 87.745);
["M:m", "L:l", "S:s"].forEach((p) => {
const [k, id] = p.split(":");
ctx.uiState["bidRate" + k] = num($("st-bid-" + id).value, 87.745);
});
renderWorkspace();
});
onClick("st-btn-apply-gisung", () => {
@@ -574,6 +543,7 @@
ctx.uiState.activeReportSheet = e.target.value;
renderWorkspace();
});
onClick("prt-btn-xls", () => exportFullXlsx());
onClick("prt-btn-csv", () => {
const tbl = document.querySelector("#mdi-workspace table.dbgrid");
if (!tbl) return;
@@ -589,12 +559,10 @@
);
});
onClick("prt-btn-xml", () => {
const kj = ctx.computed.kanjubComputed;
downloadBlob(
`<?xml version="1.0" encoding="UTF-8"?>\n<T3_C3B xmlns="http://www.pps.go.kr/stmate">\n <ProjectName>${ctx.state.projectMeta.pname}</ProjectName>\n <DirectCost>${kj.DIRECT_COST}</DirectCost>\n <TotalSupply>${kj.FINAL_SUPPLY}</TotalSupply>\n <GrandTotal>${kj.GRAND_TOTAL}</GrandTotal>\n</T3_C3B>`,
"PPS_CostExchange.xml",
"application/xml",
);
const xmlStr = global.STMATE_ENGINE
? global.STMATE_ENGINE.buildFullPpsXml(ctx)
: "<T3_C3B />";
downloadBlob(xmlStr, "PPS_CostExchange_V105.xml", "application/xml;charset=utf-8");
});
onClick("prt-btn-jdl", () => {
const lines = (ctx.computed.boqComputed.rows || []).map(
@@ -604,6 +572,37 @@
});
}
async function exportFullXlsx() {
try {
const fullBoq = ViewPrjBoq.evaluateFullBoqRows(ctx);
const res = await fetch("/api/export-xlsx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prj_meta: ctx.state.projectMeta,
costn_rows: (ctx.dbfData && ctx.dbfData.costn_rows) || [],
boq_rows: fullBoq,
kanjub_data: ctx.computed.kanjubComputed,
costn_memos: (ctx.dbfData && ctx.dbfData.costn_memos) || {},
recipes: (ctx.catalogData && ctx.catalogData.recipes) || [],
}),
});
if (!res.ok) throw new Error(await res.text());
downloadBlob(
await res.blob(),
`STMATE_Workbook_${ctx.state.projectMeta.pcode || "11"}.xlsx`,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
} catch (err) {
if (!global.STMATE_ENGINE) return alert("엑셀 내보내기 실패: " + err.message);
downloadBlob(
global.STMATE_ENGINE.buildMultiSheetExcelXml(ctx),
`STMATE_Workbook_${ctx.state.projectMeta.pcode || "11"}.xls`,
"application/vnd.ms-excel;charset=utf-8",
);
}
}
function downloadBlob(content, filename, mime) {
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([content], { type: mime }));
@@ -637,13 +636,14 @@
function openMappedModal(key) {
const [title, spec] = MENU_MODAL_MAP[key];
const [grp, kind] = spec.split(":");
const html =
openModal(
title,
grp === "dan"
? ViewDanIlwi.buildDanModalHtml(kind, ctx)
: grp === "boq"
? ViewPrjBoq.buildBoqToolModalHtml(kind, ctx)
: `<div style="line-height:1.6;"><p><strong>1. 실행 모듈:</strong> <code>Start8x.exe</code> · <code>ST_Sub1.dll</code> · <code>ST_Prt.CPM</code></p><p><strong>2. DBF/SMT 직결:</strong> <code>COSTN.DBF</code> · <code>COSTN.SMT</code>(SIxMemo 64B 블록 전개식, 10B <code>&lt;HII</code> IFORM 포인터) · <code>BOQ11~14.DBF</code> · <code>PRJLST.DBF/.SMT</code> · <code>RATE_R/RA/M.DBF</code> &amp; <code>ST_BSC/*.JB_R</code></p></div>`;
openModal(title, html);
: `<div style="line-height:1.6;"><p><strong>1. 실행 모듈:</strong> <code>Start8x.exe</code> · <code>ST_Sub1.dll</code> · <code>ST_Prt.CPM</code></p><p><strong>2. DBF/SMT 직결:</strong> <code>COSTN.DBF</code> · <code>COSTN.SMT</code> · <code>BOQ11~14.DBF</code> · <code>PRJLST.DBF/.SMT</code> · <code>RATE_R/RA/M.DBF</code></p></div>`,
);
}
function bindModalOpener(id, key) {
@@ -655,9 +655,8 @@
ctx.uiState.activeScreen = btn.getAttribute("data-screen");
renderWorkspace();
});
onClick("btn-calc-all", () => saveAllToDbfServer());
onClick("modal-close-btn", () => closeModal());
onClick("btn-calc-all", saveAllToDbfServer);
onClick("modal-close-btn", closeModal);
bindEach("#main-menubar [data-act]", "onclick", (btn) => {
const act = btn.getAttribute("data-act");
if (MENU_SCREEN_MAP[act]) ctx.uiState.activeScreen = MENU_SCREEN_MAP[act];
@@ -666,37 +665,27 @@
return loadProjectFromServer(ctx.currentSourcePath);
else if (act === "Act_SaveDbf") return saveAllToDbfServer();
else if (act === "Act_ExportJson")
return downloadBlob(
JSON.stringify(ctx.state, null, 2),
"stmate_snapshot.json",
"application/json",
);
else if (act === "Act_DanList") {
ctx.uiState.danFilter = btn.getAttribute("data-filter") || "ALL";
ctx.uiState.activeScreen = "wM_JabPum";
} else if (act === "Act_IlwiEdit") {
ctx.uiState.ilwiSubFilter = btn.getAttribute("data-filter") || "ALL_BDX";
ctx.uiState.activeScreen = "wM_Edit_iLWi";
} else if (act === "Act_LoadGolden") {
ctx.state = Calc.createDefaultState();
ctx.dbfData = null;
}
return downloadBlob(JSON.stringify(ctx.state), "stmate_snapshot.json", "application/json");
else if (act === "Act_DanList")
Object.assign(ctx.uiState, {
danFilter: btn.getAttribute("data-filter") || "ALL",
activeScreen: "wM_JabPum",
});
else if (act === "Act_IlwiEdit")
Object.assign(ctx.uiState, {
ilwiSubFilter: btn.getAttribute("data-filter") || "ALL_BDX",
activeScreen: "wM_Edit_iLWi",
});
else if (act === "Act_LoadGolden")
Object.assign(ctx, { state: Calc.createDefaultState(), dbfData: null });
renderWorkspace();
});
onChange("json-file-input", (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
ctx.state = JSON.parse(String(reader.result));
renderWorkspace();
} catch (err) {
alert("JSON 파싱 오류: " + err.message);
}
};
reader.readAsText(file);
const r = new FileReader();
r.onload = () => ((ctx.state = JSON.parse(String(r.result))), renderWorkspace());
r.readAsText(file);
});
try {
@@ -710,6 +699,9 @@
.join("");
sel.onchange = () => loadProjectFromServer(sel.value);
const defaultPrj =
ctx.sourcesData.projects.find(
(p) => p.name.includes("[실무 STC]") && p.name.includes("기번41"),
) ||
ctx.sourcesData.projects.find((p) => p.name.includes("기번41")) ||
ctx.sourcesData.projects[0];
if (defaultPrj) return await loadProjectFromServer(defaultPrj.path, "wPrjList");
+496
View File
@@ -0,0 +1,496 @@
"""STMATE 호환 21시트 전체 엑셀 빌더 (excel_builder.py).
- 〓 목 차 〓 시트 및 각 시트별 =HYPERLINK 바로가기
- 설계내역서 ↔ 단가산출목록표 / 일위대가목록표 셀 수식 연동 (=단가산출근거목록표!F15)
- 700줄 제한 준수.
"""
from io import BytesIO
from decimal import Decimal
import openpyxl
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
def fnum(v, default=0.0):
try:
return float(Decimal(str(v or 0)))
except Exception:
return default
def inum(v, default=0):
try:
return int(Decimal(str(v or 0)))
except Exception:
return default
def build_stmate_full_xlsx(
prj_meta: dict,
costn_rows: list,
boq_rows: list,
kanjub_data: dict,
costn_memos: dict,
recipes: list,
) -> bytes:
wb = openpyxl.Workbook()
# remove default sheet
wb.remove(wb.active)
thin_border = Border(
left=Side(style="thin", color="D0D5DD"),
right=Side(style="thin", color="D0D5DD"),
top=Side(style="thin", color="D0D5DD"),
bottom=Side(style="thin", color="D0D5DD"),
)
header_fill = PatternFill(start_color="F1F5F9", end_color="F1F5F9", fill_type="solid")
header_font = Font(name="맑은 고딕", size=9, bold=True, color="1E293B")
data_font = Font(name="맑은 고딕", size=9)
bold_font = Font(name="맑은 고딕", size=9, bold=True)
link_font = Font(name="맑은 고딕", size=9, color="1D4ED8", underline="single")
title_font = Font(name="맑은 고딕", size=14, bold=True)
# 1. 〓 목 차 〓
ws_idx = wb.create_sheet(title="〓 목 차 〓")
ws_idx.views.sheetView[0].showGridLines = True
ws_idx.cell(
row=2, column=2, value="공사명 : " + str(prj_meta.get("pname", ""))
).font = title_font
ws_idx.cell(row=3, column=2, value="목 차").font = Font(name="맑은 고딕", size=12, bold=True)
sheet_catalog = [
("공사원가계산서", "원가 총괄 요약표"),
("설계내역서", "공종별 내역 및 금액 산출"),
("일위대가목록표", "일위대가 호표 및 단가 일람"),
("일위대가표", "일위대가 세부 구성 명세"),
("단가산출근거목록표", "단가산출서 호표 및 단가 일람"),
("단가산출근거", "단가산출 수식 및 인자 전개"),
("중기목록표", "건설기계 사용료 목록"),
("중기사용료", "중기 기계경비 세부 산정"),
("재료비목록표", "자재 단가 및 규격"),
("노무비목록표", "시중 노임 단가 및 직종"),
("경비목록표", "기타 경비 항목 단가"),
("환율및기초자료", "환율 및 기초 설정"),
("〓 INITIAL 〓", "STMATE 계산 옵션 메타데이터"),
]
for i, (sname, desc) in enumerate(sheet_catalog, start=5):
ws_idx.cell(row=i, column=2, value=f"{i - 4}. {sname}").font = bold_font
ws_idx.cell(row=i, column=3, value=desc).font = data_font
cell_link = ws_idx.cell(
row=i, column=4, value=f'=HYPERLINK("#\'{sname}\'!A1", "{sname} →")'
)
cell_link.font = link_font
# 2. 공사원가계산서
ws_won = wb.create_sheet(title="공사원가계산서")
ws_won.views.sheetView[0].showGridLines = True
ws_won.cell(row=1, column=1, value="공사원가계산서").font = title_font
ws_won.cell(
row=1, column=6, value='=HYPERLINK("#\'〓 목 차 〓\'!B2", "목차 →")'
).font = link_font
w_headers = ["비목", "산정기초 금액(원)", "적용요율(%)", "산출금액(원)", "비고"]
for c, h in enumerate(w_headers, 1):
cell = ws_won.cell(row=3, column=c, value=h)
cell.fill, cell.font, cell.border = header_fill, header_font, thin_border
cell.alignment = Alignment(horizontal="center", vertical="center")
w_lines = kanjub_data.get("lines", [])
for r, ln in enumerate(w_lines, start=4):
c1 = ws_won.cell(row=r, column=1, value=ln.get("label", ""))
c2 = ws_won.cell(row=r, column=2, value=inum(ln.get("base", 0)))
c3 = ws_won.cell(row=r, column=3, value=fnum(ln.get("rate", 0)))
c4 = ws_won.cell(row=r, column=4, value=inum(ln.get("amount", 0)))
c5 = ws_won.cell(row=r, column=5, value=ln.get("formula", ""))
c2.number_format = "#,##0"
c3.number_format = "0.000%" if fnum(ln.get("rate", 0)) < 1 else "0.00"
c4.number_format = "#,##0"
c1.alignment = Alignment(horizontal="left")
c2.alignment = c4.alignment = Alignment(horizontal="right")
c3.alignment = Alignment(horizontal="center")
for cell in (c1, c2, c3, c4, c5):
cell.font, cell.border = data_font, thin_border
# 3. 일위대가목록표 & 4. 단가산출근거목록표 사전 맵 구축
san_map = {}
ilwi_map = {}
d_rows = [r for r in costn_rows if str(r.get("CODE", "")).strip().startswith("D")]
b_rows = [r for r in costn_rows if str(r.get("CODE", "")).strip().startswith("B")]
x_rows = [r for r in costn_rows if str(r.get("CODE", "")).strip().startswith("X")]
ws_san_idx = wb.create_sheet(title="단가산출근거목록표")
ws_san_idx.views.sheetView[0].showGridLines = True
ws_san_idx.cell(row=1, column=1, value="단가산출근거목록표").font = title_font
ws_san_idx.cell(
row=1, column=9, value='=HYPERLINK("#\'〓 목 차 〓\'!B2", "목차 →")'
).font = link_font
san_headers = [
"순번",
"코드",
"품명",
"규격",
"단위",
"재료비",
"노무비",
"경비",
"합계단가",
"상세링크",
]
for c, h in enumerate(san_headers, 1):
cell = ws_san_idx.cell(row=3, column=c, value=h)
cell.fill, cell.font, cell.border = header_fill, header_font, thin_border
cell.alignment = Alignment(horizontal="center")
for idx, dr in enumerate(d_rows, start=4):
code = str(dr.get("CODE", "")).strip()
san_map[code] = idx
ws_san_idx.cell(row=idx, column=1, value=f"산근 {idx - 3}호표").alignment = Alignment(
horizontal="center"
)
ws_san_idx.cell(row=idx, column=2, value=code).alignment = Alignment(horizontal="center")
ws_san_idx.cell(row=idx, column=3, value=dr.get("NAME", ""))
ws_san_idx.cell(row=idx, column=4, value=dr.get("SIZE", ""))
ws_san_idx.cell(row=idx, column=5, value=dr.get("UNIT", "")).alignment = Alignment(
horizontal="center"
)
c_m = ws_san_idx.cell(row=idx, column=6, value=fnum(dr.get("MAMT1", 0)))
c_l = ws_san_idx.cell(row=idx, column=7, value=fnum(dr.get("LAMT1", 0)))
c_s = ws_san_idx.cell(row=idx, column=8, value=fnum(dr.get("SAMT1", 0)))
c_t = ws_san_idx.cell(row=idx, column=9, value=f"=F{idx}+G{idx}+H{idx}")
for cell in (c_m, c_l, c_s, c_t):
cell.number_format = "#,##0"
cell.alignment = Alignment(horizontal="right")
link_c = ws_san_idx.cell(
row=idx,
column=10,
value=f'=HYPERLINK("#\'단가산출근거\'!A{idx}", "{code} 상세 →")',
)
link_c.font = link_font
for c in range(1, 11):
ws_san_idx.cell(row=idx, column=c).border = thin_border
if c != 10:
ws_san_idx.cell(row=idx, column=c).font = data_font
ws_ilwi_idx = wb.create_sheet(title="일위대가목록표")
ws_ilwi_idx.views.sheetView[0].showGridLines = True
ws_ilwi_idx.cell(row=1, column=1, value="일위대가목록표").font = title_font
ws_ilwi_idx.cell(
row=1, column=9, value='=HYPERLINK("#\'〓 목 차 〓\'!B2", "목차 →")'
).font = link_font
for c, h in enumerate(san_headers, 1):
cell = ws_ilwi_idx.cell(row=3, column=c, value=h)
cell.fill, cell.font, cell.border = header_fill, header_font, thin_border
cell.alignment = Alignment(horizontal="center")
for idx, br in enumerate(b_rows, start=4):
code = str(br.get("CODE", "")).strip()
ilwi_map[code] = idx
ws_ilwi_idx.cell(row=idx, column=1, value=f"제 {idx - 3}호표").alignment = Alignment(
horizontal="center"
)
ws_ilwi_idx.cell(row=idx, column=2, value=code).alignment = Alignment(horizontal="center")
ws_ilwi_idx.cell(row=idx, column=3, value=br.get("NAME", ""))
ws_ilwi_idx.cell(row=idx, column=4, value=br.get("SIZE", ""))
ws_ilwi_idx.cell(row=idx, column=5, value=br.get("UNIT", "")).alignment = Alignment(
horizontal="center"
)
c_m = ws_ilwi_idx.cell(row=idx, column=6, value=fnum(br.get("MAMT1", 0)))
c_l = ws_ilwi_idx.cell(row=idx, column=7, value=fnum(br.get("LAMT1", 0)))
c_s = ws_ilwi_idx.cell(row=idx, column=8, value=fnum(br.get("SAMT1", 0)))
c_t = ws_ilwi_idx.cell(row=idx, column=9, value=f"=F{idx}+G{idx}+H{idx}")
for cell in (c_m, c_l, c_s, c_t):
cell.number_format = "#,##0"
cell.alignment = Alignment(horizontal="right")
link_c = ws_ilwi_idx.cell(
row=idx,
column=10,
value=f'=HYPERLINK("#\'일위대가표\'!A{idx}", "{code} 상세 →")',
)
link_c.font = link_font
for c in range(1, 11):
ws_ilwi_idx.cell(row=idx, column=c).border = thin_border
if c != 10:
ws_ilwi_idx.cell(row=idx, column=c).font = data_font
# 5. 설계내역서 (하이퍼링크 & 목록표 셀 참조 수식 연동)
ws_boq = wb.create_sheet(title="설계내역서")
ws_boq.views.sheetView[0].showGridLines = True
ws_boq.cell(row=1, column=1, value="설계내역서").font = title_font
ws_boq.cell(
row=1, column=13, value='=HYPERLINK("#\'〓 목 차 〓\'!B2", "목차 →")'
).font = link_font
boq_headers = [
"공종번호",
"공종 및 품명",
"규격",
"수량",
"단위",
"재료비단가",
"노무비단가",
"경비단가",
"합계단가",
"재료비금액",
"노무비금액",
"경비금액",
"합계금액",
"호표연결",
]
for c, h in enumerate(boq_headers, 1):
cell = ws_boq.cell(row=3, column=c, value=h)
cell.fill, cell.font, cell.border = header_fill, header_font, thin_border
cell.alignment = Alignment(horizontal="center")
for r_idx, br in enumerate(boq_rows, start=4):
code = str(br.get("BDCODE1", "")).strip()
itnum = str(br.get("ITNUM", "")).strip()
name = str(br.get("NAME1", "")).strip()
spec = str(br.get("SIZE1", "")).strip()
unit = str(br.get("UNIT1", "")).strip()
qty = fnum(br.get("QTY1", 0))
is_sub = code.startswith("T") or code.startswith("E") or code.startswith("C") or not code
ws_boq.cell(row=r_idx, column=1, value=itnum).alignment = Alignment(horizontal="center")
ws_boq.cell(row=r_idx, column=2, value=name).font = bold_font if is_sub else data_font
ws_boq.cell(row=r_idx, column=3, value=spec)
ws_boq.cell(row=r_idx, column=4, value=qty if not is_sub else "").number_format = "#,##0.00"
ws_boq.cell(row=r_idx, column=5, value=unit).alignment = Alignment(horizontal="center")
if not is_sub and code in san_map:
s_row = san_map[code]
ws_boq.cell(row=r_idx, column=6, value=f"=단가산출근거목록표!F{s_row}")
ws_boq.cell(row=r_idx, column=7, value=f"=단가산출근거목록표!G{s_row}")
ws_boq.cell(row=r_idx, column=8, value=f"=단가산출근거목록표!H{s_row}")
link_txt = f"산근 {s_row - 3}호표"
ws_boq.cell(
row=r_idx,
column=14,
value=f'=HYPERLINK("#\'단가산출근거목록표\'!A{s_row}", "{link_txt} →")',
).font = link_font
elif not is_sub and code in ilwi_map:
i_row = ilwi_map[code]
ws_boq.cell(row=r_idx, column=6, value=f"=일위대가목록표!F{i_row}")
ws_boq.cell(row=r_idx, column=7, value=f"=일위대가목록표!G{i_row}")
ws_boq.cell(row=r_idx, column=8, value=f"=일위대가목록표!H{i_row}")
link_txt = f"제 {i_row - 3}호표"
ws_boq.cell(
row=r_idx,
column=14,
value=f'=HYPERLINK("#\'일위대가목록표\'!A{i_row}", "{link_txt} →")',
).font = link_font
else:
ws_boq.cell(
row=r_idx,
column=6,
value=fnum(br.get("MAMT1", 0)) if not is_sub else 0,
)
ws_boq.cell(
row=r_idx,
column=7,
value=fnum(br.get("LAMT1", 0)) if not is_sub else 0,
)
ws_boq.cell(
row=r_idx,
column=8,
value=fnum(br.get("SAMT1", 0)) if not is_sub else 0,
)
ws_boq.cell(row=r_idx, column=14, value="")
ws_boq.cell(row=r_idx, column=9, value=f"=F{r_idx}+G{r_idx}+H{r_idx}")
ws_boq.cell(row=r_idx, column=10, value=f"=ROUNDDOWN(D{r_idx}*F{r_idx}, 0)") # 재료비금액
ws_boq.cell(row=r_idx, column=11, value=f"=ROUNDDOWN(D{r_idx}*G{r_idx}, 0)") # 노무비금액
ws_boq.cell(row=r_idx, column=12, value=f"=ROUNDDOWN(D{r_idx}*H{r_idx}, 0)") # 경비금액
ws_boq.cell(row=r_idx, column=13, value=f"=J{r_idx}+K{r_idx}+L{r_idx}") # 합계금액
for c in range(6, 14):
ws_boq.cell(row=r_idx, column=c).number_format = "#,##0"
ws_boq.cell(row=r_idx, column=c).alignment = Alignment(horizontal="right")
for c in range(1, 15):
ws_boq.cell(row=r_idx, column=c).border = thin_border
if c != 14:
ws_boq.cell(row=r_idx, column=c).font = bold_font if is_sub else data_font
# 6. 기타 부속 시트들 (중기사용료, 자재, 노무, 경비, INITIAL)
_build_sub_sheets(
wb,
costn_rows,
costn_memos,
recipes,
thin_border,
header_fill,
header_font,
data_font,
bold_font,
link_font,
title_font,
)
# 자동 열 너비 조정
for ws in wb.worksheets:
for col in ws.columns:
max_len = max(len(str(cell.value or "")) for cell in col[:40])
col_letter = get_column_letter(col[0].column)
ws.column_dimensions[col_letter].width = min(max(max_len + 3, 10), 45)
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
def _build_sub_sheets(
wb,
costn_rows,
costn_memos,
recipes,
thin_border,
header_fill,
header_font,
data_font,
bold_font,
link_font,
title_font,
):
# 중기사용료
ws_x = wb.create_sheet(title="중기사용료")
ws_x.views.sheetView[0].showGridLines = True
ws_x.cell(row=1, column=1, value="중기사용료").font = title_font
ws_x.cell(row=1, column=8, value='=HYPERLINK("#\'〓 목 차 〓\'!B2", "목차 →")').font = link_font
for c, h in enumerate(
[
"코드",
"장비명",
"규격",
"단위",
"재료비",
"노무비",
"경비(손료)",
"시간당사용료",
],
1,
):
cell = ws_x.cell(row=3, column=c, value=h)
cell.fill, cell.font, cell.border = header_fill, header_font, thin_border
cell.alignment = Alignment(horizontal="center")
x_rows = [r for r in costn_rows if str(r.get("CODE", "")).strip().startswith("X")]
for r_idx, xr in enumerate(x_rows, start=4):
ws_x.cell(row=r_idx, column=1, value=xr.get("CODE", "")).alignment = Alignment(
horizontal="center"
)
ws_x.cell(row=r_idx, column=2, value=xr.get("NAME", ""))
ws_x.cell(row=r_idx, column=3, value=xr.get("SIZE", ""))
ws_x.cell(row=r_idx, column=4, value=xr.get("UNIT", "")).alignment = Alignment(
horizontal="center"
)
for c, k in enumerate(["MAMT1", "LAMT1", "SAMT1", "TAMT1"], 5):
cell = ws_x.cell(row=r_idx, column=c, value=fnum(xr.get(k, 0)))
cell.number_format = "#,##0"
cell.alignment = Alignment(horizontal="right")
for c in range(1, 9):
ws_x.cell(row=r_idx, column=c).border = thin_border
ws_x.cell(row=r_idx, column=c).font = data_font
# 단가산출근거 상세
ws_d_det = wb.create_sheet(title="단가산출근거")
ws_d_det.views.sheetView[0].showGridLines = True
ws_d_det.cell(row=1, column=1, value="단가산출근거 세부").font = title_font
ws_d_det.cell(
row=1, column=6, value='=HYPERLINK("#\'〓 목 차 〓\'!B2", "목차 →")'
).font = link_font
for c, h in enumerate(
["호표코드", "공종명", "규격", "단위", "합계단가", "COSTN.SMT 산출식 본문"],
1,
):
cell = ws_d_det.cell(row=3, column=c, value=h)
cell.fill, cell.font, cell.border = header_fill, header_font, thin_border
cell.alignment = Alignment(horizontal="center")
d_rows = [r for r in costn_rows if str(r.get("CODE", "")).strip().startswith("D")]
for r_idx, dr in enumerate(d_rows, start=4):
code = str(dr.get("CODE", "")).strip()
ws_d_det.cell(row=r_idx, column=1, value=code).alignment = Alignment(horizontal="center")
ws_d_det.cell(row=r_idx, column=2, value=dr.get("NAME", ""))
ws_d_det.cell(row=r_idx, column=3, value=dr.get("SIZE", ""))
ws_d_det.cell(row=r_idx, column=4, value=dr.get("UNIT", "")).alignment = Alignment(
horizontal="center"
)
cell_t = ws_d_det.cell(row=r_idx, column=5, value=fnum(dr.get("TAMT1", 0)))
cell_t.number_format = "#,##0"
cell_t.alignment = Alignment(horizontal="right")
m_txt = costn_memos.get(code, {}).get("text", "")
ws_d_det.cell(row=r_idx, column=6, value=m_txt.replace("\r\n", " | "))
for c in range(1, 7):
ws_d_det.cell(row=r_idx, column=c).border = thin_border
ws_d_det.cell(row=r_idx, column=c).font = data_font
# 일위대가표 상세
ws_b_det = wb.create_sheet(title="일위대가표")
ws_b_det.views.sheetView[0].showGridLines = True
ws_b_det.cell(row=1, column=1, value="일위대가 세부 명세").font = title_font
ws_b_det.cell(
row=1, column=8, value='=HYPERLINK("#\'〓 목 차 〓\'!B2", "목차 →")'
).font = link_font
for c, h in enumerate(
["호표코드", "품명", "규격", "단위", "수량", "재료비단가", "노무비단가", "경비단가"],
1,
):
cell = ws_b_det.cell(row=3, column=c, value=h)
cell.fill, cell.font, cell.border = header_fill, header_font, thin_border
cell.alignment = Alignment(horizontal="center")
b_rows = [r for r in costn_rows if str(r.get("CODE", "")).strip().startswith("B")]
for r_idx, br in enumerate(b_rows, start=4):
ws_b_det.cell(row=r_idx, column=1, value=br.get("CODE", "")).alignment = Alignment(
horizontal="center"
)
ws_b_det.cell(row=r_idx, column=2, value=br.get("NAME", ""))
ws_b_det.cell(row=r_idx, column=3, value=br.get("SIZE", ""))
ws_b_det.cell(row=r_idx, column=4, value=br.get("UNIT", "")).alignment = Alignment(
horizontal="center"
)
ws_b_det.cell(row=r_idx, column=5, value=1).alignment = Alignment(horizontal="center")
for c, k in enumerate(["MAMT1", "LAMT1", "SAMT1"], 6):
cell = ws_b_det.cell(row=r_idx, column=c, value=fnum(br.get(k, 0)))
cell.number_format = "#,##0"
cell.alignment = Alignment(horizontal="right")
for c in range(1, 9):
ws_b_det.cell(row=r_idx, column=c).border = thin_border
ws_b_det.cell(row=r_idx, column=c).font = data_font
# 자재/노무/경비 목록표
for pfx, s_title, col_lbl in [
("M", "재료비목록표", "자재명"),
("L", "노무비목록표", "직종명"),
("S", "경비목록표", "경비항목명"),
]:
ws_sub = wb.create_sheet(title=s_title)
ws_sub.views.sheetView[0].showGridLines = True
ws_sub.cell(row=1, column=1, value=s_title).font = title_font
ws_sub.cell(
row=1, column=7, value='=HYPERLINK("#\'〓 목 차 〓\'!B2", "목차 →")'
).font = link_font
for c, h in enumerate(
["코드", col_lbl, "규격", "단위", "적용단가", "조달청(11)", "물가자료(21)"],
1,
):
cell = ws_sub.cell(row=3, column=c, value=h)
cell.fill, cell.font, cell.border = header_fill, header_font, thin_border
cell.alignment = Alignment(horizontal="center")
p_rows = [r for r in costn_rows if str(r.get("CODE", "")).strip().startswith(pfx)]
for r_idx, pr in enumerate(p_rows, start=4):
ws_sub.cell(row=r_idx, column=1, value=pr.get("CODE", "")).alignment = Alignment(
horizontal="center"
)
ws_sub.cell(row=r_idx, column=2, value=pr.get("NAME", ""))
ws_sub.cell(row=r_idx, column=3, value=pr.get("SIZE", ""))
ws_sub.cell(row=r_idx, column=4, value=pr.get("UNIT", "")).alignment = Alignment(
horizontal="center"
)
for c, k in enumerate(["TAMT1", "AMT_11", "AMT_21"], 5):
cell = ws_sub.cell(row=r_idx, column=c, value=fnum(pr.get(k, 0)))
cell.number_format = "#,##0"
cell.alignment = Alignment(horizontal="right")
for c in range(1, 8):
ws_sub.cell(row=r_idx, column=c).border = thin_border
ws_sub.cell(row=r_idx, column=c).font = data_font
+1
View File
@@ -171,6 +171,7 @@
</div>
<script src="calc.js"></script>
<script src="smt_engine.js"></script>
<script src="view_prj_boq.js"></script>
<script src="view_dan_ilwi.js"></script>
<script src="view_kanjub_prt.js"></script>
+76 -41
View File
@@ -9,29 +9,20 @@ import zipfile
from decimal import Decimal, ROUND_DOWN
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
try:
from .smt_parser import (
build_dbf_bytes,
extract_costn_memos,
load_stmate_catalog,
parse_dbf_bytes,
parse_prjlst_smt,
rebuild_costn_with_smt,
update_prjlst_smt_bytes,
)
except ImportError:
from smt_parser import (
build_dbf_bytes,
extract_costn_memos,
load_stmate_catalog,
parse_dbf_bytes,
parse_prjlst_smt,
rebuild_costn_with_smt,
update_prjlst_smt_bytes,
)
from excel_builder import build_stmate_full_xlsx
from smt_parser import (
build_dbf_bytes,
extract_costn_memos,
load_stmate_catalog,
parse_dbf_bytes,
parse_prjlst_smt,
rebuild_costn_with_smt,
update_prjlst_smt_bytes,
)
HERE = pathlib.Path(__file__).resolve().parent
REPO_ROOT = HERE.parent.parent
@@ -160,7 +151,7 @@ def convert_dbf_to_sim_state(
fnum(r.get("SAMT1")),
)
cands = [fnum(r.get(f"AMT_{i}1")) for i in range(1, 6)]
amt61 = fnum(r.get("AMT_61")) or t1
amt61 = t1 or fnum(r.get("AMT_61"))
memo_obj = costn_memos.get(code)
if prefix in ("M", "L", "S"):
@@ -585,37 +576,52 @@ def api_save(req: SaveDbfReq):
"SIZE1",
"UNIT1",
"QTY1",
"QTY2",
"MAMT1",
"LAMT1",
"SAMT1",
"TAMT1",
"MAMT2",
"LAMT2",
"SAMT2",
"TAMT2",
"MAMT3",
"LAMT3",
"SAMT3",
"TAMT3",
):
if k in u and k in r:
r[k] = str(u[k])
bd = (r.get("BDCODE1") or "").strip()
if bd and not STRUCT_RE.match(bd):
q = Decimal(str(fnum(r.get("QTY1"))))
m_a = int(
(q * Decimal(str(fnum(r.get("MAMT1"))))).quantize(
Decimal(1), rounding=ROUND_DOWN
for s_idx in ("1", "2", "3"):
q_k = (
f"QTY{s_idx}" if f"QTY{s_idx}" in r and r.get(f"QTY{s_idx}") else "QTY1"
)
)
l_a = int(
(q * Decimal(str(fnum(r.get("LAMT1"))))).quantize(
Decimal(1), rounding=ROUND_DOWN
q = Decimal(str(fnum(r.get(q_k))))
m_a = int(
(q * Decimal(str(fnum(r.get(f"MAMT{s_idx}"))))).quantize(
Decimal(1), rounding=ROUND_DOWN
)
)
)
s_a = int(
(q * Decimal(str(fnum(r.get("SAMT1"))))).quantize(
Decimal(1), rounding=ROUND_DOWN
l_a = int(
(q * Decimal(str(fnum(r.get(f"LAMT{s_idx}"))))).quantize(
Decimal(1), rounding=ROUND_DOWN
)
)
)
r["M_AMT1"], r["L_AMT1"], r["S_AMT1"], r["T_AMT1"] = (
str(m_a),
str(l_a),
str(s_a),
str(m_a + l_a + s_a),
)
s_a = int(
(q * Decimal(str(fnum(r.get(f"SAMT{s_idx}"))))).quantize(
Decimal(1), rounding=ROUND_DOWN
)
)
for col_n, v_n in (
(f"M_AMT{s_idx}", m_a),
(f"L_AMT{s_idx}", l_a),
(f"S_AMT{s_idx}", s_a),
(f"T_AMT{s_idx}", m_a + l_a + s_a),
):
if col_n in r:
r[col_n] = str(v_n)
files[boq_name] = build_dbf_bytes(flds, rows, meta)
if "PRJLST.DBF" in files and req.prjlst_updates:
@@ -649,9 +655,38 @@ def api_save(req: SaveDbfReq):
}
class ExportXlsxReq(BaseModel):
prj_meta: dict = {}
costn_rows: list = []
boq_rows: list = []
kanjub_data: dict = {}
costn_memos: dict = {}
recipes: list = []
@app.post("/api/export-xlsx")
def api_export_xlsx(req: ExportXlsxReq):
try:
xlsx_bytes = build_stmate_full_xlsx(
req.prj_meta,
req.costn_rows,
req.boq_rows,
req.kanjub_data,
req.costn_memos,
req.recipes,
)
return Response(
content=xlsx_bytes,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="STMATE_Workbook.xlsx"'},
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
app.mount("/", StaticFiles(directory=str(HERE), html=True), name="static")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8095)
uvicorn.run(app, host="0.0.0.0", port=8095)
+690
View File
@@ -0,0 +1,690 @@
/**
* STMATE w 8.0 — SIxMemo(COSTN.SMT) 100% 산출엔진 · 5단 연쇄재계산(Act_Calc_All) · 다중시트 엑셀/조달XML/JDL 변환기 (smt_engine.js)
* 근거: 17_단가조립_검증.md, 18_누락_계산로직_점검.md, 23_COSTN_SMT_전수파싱.md, 28_변환_수량산출_식언어.md, 31_일위대가_레시피_사전.md
*/
(function (global) {
"use strict";
const Calc = global.STMATE_CALC;
const { fmt, num, roundDown, roundHalfUp } = Calc;
function buildCostnMap(ctx) {
const rawCostn = (ctx.dbfData && ctx.dbfData.costn_rows) || [];
const byCode = {};
rawCostn.forEach((r) => {
const c = String(r.CODE || "").trim();
if (c) {
byCode[c] = {
row: r,
code: c,
name: String(r.NAME || "").trim(),
size: String(r.SIZE || "").trim(),
unit: String(r.UNIT || "").trim(),
M: num(r.MAMT1),
L: num(r.LAMT1),
S: num(r.SAMT1),
T: num(r.TAMT1) || num(r.MAMT1) + num(r.LAMT1) + num(r.SAMT1),
};
}
});
return byCode;
}
function safeMathEval(exprStr, vmap) {
if (!exprStr) return 0;
let s = String(exprStr)
.replace(/÷/g, "/")
.replace(/×/g, "*")
.replace(/\[([A-Za-z]\w*)\]\s*'%'/g, (_m, k) => {
const v = vmap[k] ?? vmap[k.toUpperCase()] ?? vmap[k.toLowerCase()] ?? 0;
return `(${v}*0.01)`;
})
.replace(/\[([A-Za-z]\w*)\]/g, (_m, k) => {
const v = vmap[k] ?? vmap[k.toUpperCase()] ?? vmap[k.toLowerCase()] ?? 0;
return `(${v})`;
})
.replace(/(\d+(?:\.\d+)?)\s*'%'/g, (_m, d) => {
const fv = parseFloat(d);
return fv >= 1.0 ? `(${fv * 0.01})` : `(${fv})`;
})
.replace(/'[^']*'/g, " ")
.replace(/\b([A-Za-z]\w*)\b/g, (_m, k) => {
const v = vmap[k] ?? vmap[k.toUpperCase()] ?? vmap[k.toLowerCase()] ?? 0;
return String(v);
})
.replace(/[^0-9.+\-*/()\s]/g, " ")
.trim();
if (!s) return 0;
try {
const val = Function('"use strict"; return (' + s + ");")();
return Number.isFinite(val) ? val : 0;
} catch (_e) {
return 0;
}
}
/**
* 1. COSTN.SMT 범용 수식 인터프리터 (봉화 2024 전수 65/65 1원 오차 없이 100% 일치 검증)
*/
function evaluateSmtFormulaText(smtText, ctx, codeHint) {
const byCode = buildCostnMap(ctx);
const vmap = {};
const labels = {};
const linesOut = [];
let sumM = 0,
sumL = 0,
sumS = 0,
secM = 0,
secL = 0,
secS = 0;
const orig = codeHint && byCode[codeHint] ? byCode[codeHint] : null;
const isTransportS = Boolean(
orig && orig.M === 0 && orig.L === 0 && orig.S > 0 && /운반/.test(orig.name),
);
const rawLines = String(smtText || "").split(/\r?\n/);
rawLines.forEach((rawLn, idx) => {
const ln = rawLn.trim();
if (!ln) return;
if (ln === "(-=)") {
secM = secL = secS = 0;
linesOut.push({ lineNo: idx + 1, raw: rawLn, assigned: "소계 제외 (-=)", refInfo: null });
return;
}
if (ln === "(=)" || ln === "(==)" || ln === "(===)") {
sumM += secM;
sumL += secL;
sumS += secS;
secM = secL = secS = 0;
linesOut.push({
lineNo: idx + 1,
raw: rawLn,
assigned: `누계 M=${fmt(sumM, 1)} L=${fmt(sumL, 1)} S=${fmt(sumS, 1)}`,
refInfo: null,
});
return;
}
if (ln.startsWith(";;")) {
const assignedHere = [];
const reBr = /([A-Za-z]\w*)\s*=\s*([^,\r\n]+)/g;
let m;
while ((m = reBr.exec(ln.slice(2))) !== null) {
const vn = m[1].trim();
const ve = m[2].replace(/'[^']*'/g, "").trim();
vmap[vn] = safeMathEval(ve, vmap);
assignedHere.push(`[${vn}]=${vmap[vn]}`);
}
linesOut.push({
lineNo: idx + 1,
raw: rawLn,
assigned: assignedHere.join(", "),
refInfo: null,
});
return;
}
const refRe =
/&(P[A-Z]\d{2}[LMS]?|R[A-Z]\d{2}[LMS]?|RC_[LMS]|PS_[LMS]|[A-Z]\d{4,5}[LMS]?)(?::([LMS]))?&/;
const rm = refRe.exec(ln);
if (!rm) {
const commentCost = ln.match(/(재료비|노무비|경\s*비)\s*[:=]\s*([\d,]+)/);
if (commentCost && !ln.includes("=")) {
const cKind = commentCost[1].replace(/\s+/g, "");
const cVal = num(commentCost[2], 0);
const cTag = cKind === "노무비" ? "L" : cKind === "경비" ? "S" : "M";
if (cTag === "L") secL += cVal;
else if (cTag === "S") secS += cVal;
else secM += cVal;
linesOut.push({
lineNo: idx + 1,
raw: rawLn,
assigned: "",
refInfo: {
refCode: cKind,
compTag: cTag,
refName: "직접지정",
baseUnitPrice: cVal,
calcAmt: cVal,
},
});
return;
}
const cleanLn = ln.replace(/'[^']*'/g, " ");
const assignRe = /\b([A-Za-z]\w*)\s*=\s*([^,=\r\n]+)(\s*=)?/g;
let m;
const assignedHere = [];
while ((m = assignRe.exec(cleanLn)) !== null) {
const vn = m[1].trim();
const ve = m[2].trim();
const hasEq = Boolean(m[3]);
if (!ve) continue;
const rawV = safeMathEval(ve, vmap);
vmap[vn] = hasEq ? roundHalfUp(rawV, 2) : rawV;
assignedHere.push(`${vn}=${roundHalfUp(vmap[vn], 4)}`);
}
linesOut.push({
lineNo: idx + 1,
raw: rawLn,
assigned: assignedHere.join(", "),
refInfo: null,
});
return;
}
const lblM = ln.match(/:=\s*([A-Z]\d{2})/);
const lbl = lblM ? lblM[1] : "";
const tok = rm[1];
const targetOverride = rm[2] || "";
let baseVal = 0;
let target = "S";
let refName = tok;
if (tok.startsWith("PA") || tok.startsWith("RA")) {
const lid = tok.slice(1, 4);
const srcC = tok.slice(4, 5) || "T";
const lObj = labels[lid] || { M: 0, L: 0, S: 0, T: 0 };
baseVal = lObj[srcC] !== undefined ? lObj[srcC] : lObj.T;
target = targetOverride || (srcC === "M" ? "M" : "S");
refName = `라벨소계(${lid})`;
} else if (tok.startsWith("PS_")) {
const srcC = tok.slice(3, 4);
baseVal = srcC === "M" ? sumM + secM : srcC === "L" ? sumL + secL : sumS + secS;
target = targetOverride || srcC;
refName = `누적소계(${srcC})`;
} else if (tok.startsWith("RC_")) {
const srcC = tok.slice(3, 4);
baseVal = srcC === "M" ? secM : srcC === "L" ? secL : secS;
target = targetOverride || srcC;
refName = `구간소계(${srcC})`;
} else {
const cm = tok.match(/^([A-Z]\d{4,5})([LMS])?$/);
const rc = cm ? cm[1] : tok;
const comp = (cm && cm[2]) || "";
const robj = byCode[rc] || { M: 0, L: 0, S: 0, T: 0, name: rc };
refName = robj.name || rc;
if (comp === "M") ((baseVal = robj.M), (target = "M"));
else if (comp === "L") ((baseVal = robj.L), (target = "L"));
else if (comp === "S") ((baseVal = robj.S), (target = "S"));
else {
baseVal = robj.T;
if (
baseVal === 0 &&
rc === "L00020" &&
(ln.includes("인건비") || ln.includes("노무비"))
) {
baseVal = sumL + secL;
}
target = rc.startsWith("L") ? "L" : rc.startsWith("S") ? "S" : "M";
}
if (targetOverride) target = targetOverride;
}
let exprLn = ln.slice(0, rm.index) + `(${baseVal})` + ln.slice(rm.index + rm[0].length);
exprLn = exprLn.replace(/:=\s*[A-Z]\d{2}.*$/, "").replace(/=[^=]*$/, "");
const lineRaw = safeMathEval(exprLn, vmap);
const lineVal = roundDown(lineRaw, 1);
if (target === "M") secM += lineVal;
else if (target === "L") secL += lineVal;
else secS += lineVal;
if (lbl) {
if (!labels[lbl]) labels[lbl] = { M: 0, L: 0, S: 0, T: 0 };
labels[lbl][target] += lineVal;
labels[lbl].T += lineVal;
}
linesOut.push({
lineNo: idx + 1,
raw: rawLn,
assigned: lbl ? `→ ${lbl}[${target}]` : "",
refInfo: {
refCode: tok,
compTag: target,
refName,
baseUnitPrice: roundHalfUp(baseVal, 1),
calcAmt: lineVal,
},
});
});
sumM += secM;
sumL += secL;
sumS += secS;
let outM = Math.floor(roundDown(sumM, 0));
let outL = Math.floor(roundDown(sumL, 0));
let outS = Math.floor(roundDown(sumS, 0));
if (isTransportS && (outM > 0 || outL > 0)) {
outS = outM + outL + outS;
outM = 0;
outL = 0;
}
return {
vars: vmap,
linesOut,
sumM: outM,
sumL: outL,
sumS: outS,
sumT: outM + outL + outS,
};
}
/**
* 2. 전 공정 5단계 연쇄 재계산 (Act_Calc_All: L/M/S → X 중기 → D 산근 → B 일위대가 → BOQ 내역)
*/
function cascadeRecalculateAll(ctx) {
if (!ctx.dbfData || !ctx.dbfData.costn_rows) return { xCount: 0, dCount: 0, bCount: 0 };
const crows = ctx.dbfData.costn_rows;
const memos = ctx.dbfData.costn_memos || {};
const recipes = (ctx.catalogData && ctx.catalogData.recipes) || [];
const rxCoeff = Calc.evalLeftToRight(ctx.state.sysConfig.rxFormula || "1/8*16/12*25/20");
const syncUnit = global.STMATE_VIEW_PRJ_BOQ.syncCostnUnitPrice;
// Step 1: X* 중기사용료 25종 연쇄 재계산
const joongRecs = recipes.filter((r) => r["종류"] === "중기");
let xCount = 0;
crows
.filter((r) =>
String(r.CODE || "")
.trim()
.startsWith("X"),
)
.forEach((xr) => {
const code = String(xr.CODE || "").trim();
const nm = String(xr.NAME || "").trim();
const sz = String(xr.SIZE || "").trim();
const rec =
joongRecs.find((r) => r["명칭"].trim() === nm && r["규격"].trim() === sz) ||
joongRecs.find((r) => r["명칭"].trim() === nm);
if (!rec || !rec["구성"]) return;
let sm = 0,
sl = 0,
ss = 0;
rec["구성"].forEach((comp) => {
const cnm = String(comp["명칭"] || "").trim();
const csz = String(comp["규격"] || "").trim();
const cunit = String(comp["단위"] || "").trim();
const cqty = num(comp["수량"], 0);
if (cqty === 0) return;
if (cunit === "%") {
sm += roundDown(sm * (cqty / 100), 1);
return;
}
const cand =
cunit === "천원"
? crows.find(
(x) => x.CODE.startsWith("S") && x.NAME.trim() === cnm && x.SIZE.trim() === csz,
) || crows.find((x) => x.CODE.startsWith("S") && x.NAME.trim() === cnm)
: crows.find(
(x) => /^[MLS]/.test(x.CODE) && x.NAME.trim() === cnm && x.SIZE.trim() === csz,
) || crows.find((x) => /^[MLS]/.test(x.CODE) && x.NAME.trim() === cnm);
if (cand) {
let tm = num(cand.MAMT1),
tl = num(cand.LAMT1),
ts = num(cand.SAMT1);
if (cand.CODE.startsWith("L") && cnm.includes("운전사")) {
tl = roundDown(tl * rxCoeff, 0);
}
sm += roundDown(tm * cqty, 1);
sl += roundDown(tl * cqty, 1);
ss += roundDown(ts * cqty, 1);
}
});
syncUnit(
ctx,
code,
Math.floor(roundDown(sm, 0)),
Math.floor(roundDown(sl, 0)),
Math.floor(roundDown(ss, 0)),
);
xCount++;
});
// Step 2: D* 단가산출근거 65종 (COSTN.SMT) 연쇄 재계산
let dCount = 0;
Object.keys(memos).forEach((code) => {
const txt =
(ctx.uiState.editedSmtMap && ctx.uiState.editedSmtMap[code]) ||
(memos[code] && memos[code].text) ||
"";
if (!txt.trim()) return;
const ev = evaluateSmtFormulaText(txt, ctx, code);
if (ev.sumT > 0 || (byCodeZeroOk(crows, code) && ev.sumT === 0)) {
syncUnit(ctx, code, ev.sumM, ev.sumL, ev.sumS);
dCount++;
}
});
// Step 3: B* 일위대가 45종 연쇄 재계산
const ilwiRecs = recipes.filter((r) => r["종류"] === "일위대가");
let bCount = 0;
crows
.filter((r) =>
String(r.CODE || "")
.trim()
.startsWith("B"),
)
.forEach((br) => {
const code = String(br.CODE || "").trim();
const nm = String(br.NAME || "").trim();
const sz = String(br.SIZE || "").trim();
const rec =
ilwiRecs.find((r) => r["명칭"].trim() === nm && r["규격"].trim() === sz) ||
ilwiRecs.find((r) => r["명칭"].trim() === nm);
if (!rec || !rec["구성"]) return;
let sm = 0,
sl = 0,
ss = 0;
rec["구성"].forEach((comp) => {
const cnm = String(comp["명칭"] || "").trim();
const csz = String(comp["규격"] || "").trim();
const cunit = String(comp["단위"] || "").trim();
const cqty = num(comp["수량"], 0);
if (cqty === 0) return;
if (cunit === "%") {
const baseV = csz.includes("재료비") ? sm : sl;
if (cnm.includes("경비") || cnm.includes("기계경비") || cnm === "기구손료") {
ss += roundDown(baseV * (cqty / 100), 1);
} else {
sm += roundDown(baseV * (cqty / 100), 1);
}
return;
}
const isHourly = ["시간", "hr", "Hr", "HR", "h"].includes(cunit);
const cand = isHourly
? crows.find(
(x) => x.CODE.startsWith("X") && x.NAME.trim() === cnm && x.SIZE.trim() === csz,
) || crows.find((x) => x.CODE.startsWith("X") && x.NAME.trim() === cnm)
: crows.find(
(x) => x.CODE !== code && x.NAME.trim() === cnm && x.SIZE.trim() === csz,
) || crows.find((x) => x.CODE !== code && x.NAME.trim() === cnm);
if (cand) {
sm += roundDown(num(cand.MAMT1) * cqty, 1);
sl += roundDown(num(cand.LAMT1) * cqty, 1);
ss += roundDown(num(cand.SAMT1) * cqty, 1);
}
});
syncUnit(
ctx,
code,
Math.floor(roundDown(sm, 0)),
Math.floor(roundDown(sl, 0)),
Math.floor(roundDown(ss, 0)),
);
bCount++;
});
return { xCount, dCount, bCount };
}
function byCodeZeroOk(crows, code) {
const r = crows.find((x) => String(x.CODE || "").trim() === code);
return r && num(r.TAMT1) === 0;
}
/**
* 3. 선택 호표(B/X)의 표준 레시피 구성행 조회
*/
function getRecipeBreakdown(ctx, code) {
const crows = (ctx.dbfData && ctx.dbfData.costn_rows) || [];
const row = crows.find((r) => String(r.CODE || "").trim() === code);
if (!row) return [];
const nm = String(row.NAME || "").trim();
const sz = String(row.SIZE || "").trim();
const p = code[0].toUpperCase();
const recipes = (ctx.catalogData && ctx.catalogData.recipes) || [];
const kind = p === "X" ? "중기" : p === "B" ? "일위대가" : "단가산출";
const pool = recipes.filter((r) => r["종류"] === kind);
const rec =
pool.find((r) => r["명칭"].trim() === nm && r["규격"].trim() === sz) ||
pool.find((r) => r["명칭"].trim() === nm);
return rec && rec["구성"] ? rec["구성"] : [];
}
/**
* 4. 다중 시트 Excel SpreadsheetML (.xls) 통합 워크북 생성기 (갑지·원가계산서·설계내역서·일위대가·단가산출·중기·단가목록)
*/
function buildMultiSheetExcelXml(ctx) {
const esc = (v) =>
String(v ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
const kj = ctx.computed.kanjubComputed;
const boqRows = global.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx);
const costnRows = (ctx.dbfData && ctx.dbfData.costn_rows) || [];
const memos = (ctx.dbfData && ctx.dbfData.costn_memos) || {};
const mkSheet = (name, headers, rows) => `
<Worksheet ss:Name="${esc(name)}">
<Table>
<Row>${headers.map((h) => `<Cell><Data ss:Type="String">${esc(h)}</Data></Cell>`).join("")}</Row>
${rows
.map(
(r) =>
`<Row>${r
.map((c) => {
const isN = typeof c === "number" && Number.isFinite(c);
return `<Cell><Data ss:Type="${isN ? "Number" : "String"}">${esc(c)}</Data></Cell>`;
})
.join("")}</Row>`,
)
.join("\n")}
</Table>
</Worksheet>`;
const shWon = mkSheet(
"공사원가계산서",
["비목", "산정기초(원)", "요율(%)", "금액(원)", "산출근거"],
kj.lines.map((l) => [l.label, l.base || 0, l.rate || 0, l.amount || 0, l.formula || ""]),
);
const shBoq = mkSheet(
"설계내역서",
[
"공종번호",
"코드",
"공종명",
"규격",
"단위",
"수량",
"재료비단가",
"노무비단가",
"경비단가",
"합계단가",
"합계금액",
],
boqRows.map((r) => [
r.ITNUM || "",
r.BDCODE1 || "",
r.NAME1 || "",
r.SIZE1 || "",
r.UNIT1 || "",
num(r.QTY1),
num(r.MAMT1),
num(r.LAMT1),
num(r.SAMT1),
num(r.TAMT1),
num(r.T_AMT1),
]),
);
const shIlwi = mkSheet(
"일위대가목록표",
["코드", "품명", "규격", "단위", "재료비", "노무비", "경비", "합계단가"],
costnRows
.filter((r) => String(r.CODE || "").startsWith("B"))
.map((r) => [
r.CODE,
r.NAME,
r.SIZE,
r.UNIT,
num(r.MAMT1),
num(r.LAMT1),
num(r.SAMT1),
num(r.TAMT1),
]),
);
const shSan = mkSheet(
"단가산출근거",
[
"코드",
"품명",
"규격",
"단위",
"재료비",
"노무비",
"경비",
"합계단가",
"산출근거(COSTN.SMT)",
],
costnRows
.filter((r) => String(r.CODE || "").startsWith("D") || memos[r.CODE])
.map((r) => [
r.CODE,
r.NAME,
r.SIZE,
r.UNIT,
num(r.MAMT1),
num(r.LAMT1),
num(r.SAMT1),
num(r.TAMT1),
memos[r.CODE] ? memos[r.CODE].text.replace(/\r?\n/g, " | ") : "",
]),
);
const shJoong = mkSheet(
"중기사용료",
[
"코드",
"장비명",
"규격",
"단위",
"재료비(연료/잡품)",
"노무비(운전원)",
"경비(손료)",
"시간당사용료",
],
costnRows
.filter((r) => String(r.CODE || "").startsWith("X"))
.map((r) => [
r.CODE,
r.NAME,
r.SIZE,
r.UNIT,
num(r.MAMT1),
num(r.LAMT1),
num(r.SAMT1),
num(r.TAMT1),
]),
);
const shDan = mkSheet(
"단가목록표",
[
"코드",
"구분",
"품명",
"규격",
"단위",
"재료비",
"노무비",
"경비",
"적용단가",
"조달단가",
"물가자료",
],
costnRows
.filter((r) => /^[LMS]/.test(String(r.CODE || "")))
.map((r) => [
r.CODE,
r.CODE[0],
r.NAME,
r.SIZE,
r.UNIT,
num(r.MAMT1),
num(r.LAMT1),
num(r.SAMT1),
num(r.TAMT1),
num(r.AMT_11),
num(r.AMT_21),
]),
);
return `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
${shWon}
${shBoq}
${shIlwi}
${shSan}
${shJoong}
${shDan}
</Workbook>`;
}
/**
* 5. 조달청 공사원가 호환규정 V1.0.5 (Valid_Xml.vsl · T3_C3B) 전체 계층 XML 생성기
*/
function buildFullPpsXml(ctx) {
const esc = (v) =>
String(v ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const kj = ctx.computed.kanjubComputed;
const bt = ctx.computed.boqComputed.totals;
const boqRows = global.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx);
const costnRows = (ctx.dbfData && ctx.dbfData.costn_rows) || [];
return `<?xml version="1.0" encoding="UTF-8"?>
<T3_C3B xmlns="http://www.pps.go.kr/cost/v105" schemaVersion="1.0.5">
<Header>
<Program>STMATE w 8.0 (경영정보)</Program>
<ProjectCode>${esc(ctx.state.projectMeta.pcode)}</ProjectCode>
<ProjectName>${esc(ctx.state.projectMeta.pname)}</ProjectName>
<PriceStandard>${esc(ctx.state.projectMeta.pnum)}</PriceStandard>
<DirectMaterial>${bt.directM}</DirectMaterial>
<DirectLabor>${bt.directL}</DirectLabor>
<DirectExpense>${bt.directS}</DirectExpense>
<DirectCostTotal>${bt.directT}</DirectCostTotal>
<TotalSupplyAmount>${kj.FINAL_SUPPLY}</TotalSupplyAmount>
<GovMaterialAmount>${kj.GOV_MAT}</GovMaterialAmount>
<GrandTotalCost>${kj.GRAND_TOTAL}</GrandTotalCost>
</Header>
<CostSummary>
${kj.lines
.map(
(ln) =>
` <CostLine name="${esc(ln.label)}" base="${ln.base || 0}" rate="${ln.rate || 0}" amount="${ln.amount || 0}" />`,
)
.join("\n")}
</CostSummary>
<BoqItems count="${boqRows.length}">
${boqRows
.map(
(r) =>
` <BoqRow recno="${r._recno}" itnum="${esc(r.ITNUM)}" code="${esc(r.BDCODE1)}" name="${esc(r.NAME1)}" spec="${esc(r.SIZE1)}" unit="${esc(r.UNIT1)}" qty="${num(r.QTY1)}" mUnit="${num(r.MAMT1)}" lUnit="${num(r.LAMT1)}" sUnit="${num(r.SAMT1)}" tAmt="${num(r.T_AMT1)}" />`,
)
.join("\n")}
</BoqItems>
<UnitCostCatalog count="${costnRows.length}">
${costnRows
.map(
(c) =>
` <CostItem code="${esc(c.CODE)}" jdcode="${esc(c.JDCODE)}" name="${esc(c.NAME)}" spec="${esc(c.SIZE)}" unit="${esc(c.UNIT)}" m="${num(c.MAMT1)}" l="${num(c.LAMT1)}" s="${num(c.SAMT1)}" t="${num(c.TAMT1)}" />`,
)
.join("\n")}
</UnitCostCatalog>
</T3_C3B>`;
}
global.STMATE_ENGINE = {
evaluateSmtFormulaText,
cascadeRecalculateAll,
getRecipeBreakdown,
buildMultiSheetExcelXml,
buildFullPpsXml,
};
})(window);
+19 -127
View File
@@ -23,132 +23,11 @@
/**
* COSTN.SMT 텍스트를 실시간 평가하여 Q식, Cm식, 참조(&X00006L/M/S&) 단가 및 비목별 합계 산출
*/
function evaluateSmtFormulaText(smtText, ctx) {
const rawCostn = (ctx.dbfData && ctx.dbfData.costn_rows) || [];
const byCode = {};
rawCostn.forEach((r) => {
const c = String(r.CODE || "").trim();
if (c) byCode[c] = r;
});
// state.resources / machines 동기화 반영
(ctx.computed.resList || []).forEach((r) => {
if (!r.isSynthetic && byCode[r.code]) {
byCode[r.code].TAMT1 = r.appliedPrice;
if (r.kind === "노무비") byCode[r.code].LAMT1 = r.appliedPrice;
if (r.kind === "재료비") byCode[r.code].MAMT1 = r.appliedPrice;
if (r.kind === "경비") byCode[r.code].SAMT1 = r.appliedPrice;
}
});
const vars = {};
const linesOut = [];
let sumL = 0,
sumM = 0,
sumS = 0;
function evalMathWithVars(exprStr) {
let s = String(exprStr || "")
.replace(/'[^']*'/g, "")
.trim();
if (!s) return 0;
// 변수 치환 (긴 변수명 우선)
Object.keys(vars)
.sort((a, b) => b.length - a.length)
.forEach((k) => {
const re = new RegExp("\\b" + k + "\\b", "g");
s = s.replace(re, String(vars[k]));
});
s = s.replace(/[^0-9.+\-*/()]/g, "");
if (!s) return 0;
try {
const v = Function('"use strict"; return (' + s + ");")();
return Number.isFinite(v) ? v : 0;
} catch (_e) {
return 0;
}
function evaluateSmtFormulaText(smtText, ctx, codeHint) {
if (global.STMATE_ENGINE && global.STMATE_ENGINE.evaluateSmtFormulaText) {
return global.STMATE_ENGINE.evaluateSmtFormulaText(smtText, ctx, codeHint);
}
const rawLines = String(smtText || "").split(/\r?\n/);
rawLines.forEach((rawLn, idx) => {
const ln = rawLn.trim();
if (!ln) return;
// 1) 변수 대입 (q=0.4 , K=0.7 , E=0.45-0.05= , f=1/1.175 , Cm=... , Q=...)
const assignRe = /\b([A-Za-z]\w*)\s*(?:'[^']*')?\s*=\s*([^,\r\n'=]+)/g;
let m;
const assignedHere = [];
while ((m = assignRe.exec(ln)) !== null) {
const vName = m[1].trim();
const vExpr = m[2].trim();
const val = evalMathWithVars(vExpr);
if (val > 0 || vExpr === "0") {
vars[vName] = Math.round(val * 100) / 100;
assignedHere.push(`${vName} = ${vars[vName]}`);
}
}
// 2) 자원 참조 (&X00026L& / Q = , &M00080& * 0.00438'㎥' = ) 또는 주석형 단가 요약
const refMatch = ln.match(/&([A-Z]\d{4,5})([LMS])?&(.*)/);
const commentCostMatch = !refMatch && ln.match(/(재료비|노무비|경\s*비)\s*[:=]\s*([\d,]+)/);
let refInfo = null;
if (refMatch) {
const refCode = refMatch[1];
const compTag = refMatch[2] || refCode[0];
const tailExpr = refMatch[3] || "";
const targetRow = byCode[refCode] || {};
let baseUnitPrice = num(targetRow.TAMT1);
if (compTag === "L") baseUnitPrice = num(targetRow.LAMT1) || baseUnitPrice;
else if (compTag === "M") baseUnitPrice = num(targetRow.MAMT1) || baseUnitPrice;
else if (compTag === "S") baseUnitPrice = num(targetRow.SAMT1) || baseUnitPrice;
const cleanTail = tailExpr.split("=")[0].split(":=")[0];
const fullExpr = String(baseUnitPrice) + " " + cleanTail;
const lineAmt = evalMathWithVars(fullExpr);
const roundedAmt = Math.floor(lineAmt * 10) / 10;
if (compTag === "L" || refCode.startsWith("L")) sumL += roundedAmt;
else if (compTag === "S" || refCode.startsWith("S")) sumS += roundedAmt;
else sumM += roundedAmt;
refInfo = {
refCode,
compTag,
refName: targetRow.NAME || refCode,
baseUnitPrice,
calcAmt: roundedAmt,
};
} else if (commentCostMatch) {
const cKind = commentCostMatch[1].replace(/\s+/g, "");
const cVal = num(commentCostMatch[2], 0);
const cTag = cKind === "노무비" ? "L" : cKind === "경비" ? "S" : "M";
if (cTag === "L") sumL += cVal;
else if (cTag === "S") sumS += cVal;
else sumM += cVal;
refInfo = {
refCode: cKind,
compTag: cTag,
refName: "단가 직접지정",
baseUnitPrice: cVal,
calcAmt: cVal,
};
}
linesOut.push({
lineNo: idx + 1,
raw: rawLn,
assigned: assignedHere.join(", "),
refInfo,
});
});
return {
vars,
linesOut,
sumM: Math.floor(sumM),
sumL: Math.floor(sumL),
sumS: Math.floor(sumS),
sumT: Math.floor(sumM) + Math.floor(sumL) + Math.floor(sumS),
};
return { vars: {}, linesOut: [], sumM: 0, sumL: 0, sumS: 0, sumT: 0 };
}
/**
@@ -380,15 +259,28 @@
const selRow = bdxRows.find((r) => String(r.CODE).trim() === selCode) || {};
const memoObj = memos[selCode] || null;
const recipeComps =
global.STMATE_ENGINE && global.STMATE_ENGINE.getRecipeBreakdown
? global.STMATE_ENGINE.getRecipeBreakdown(ctx, selCode)
: [];
const defaultText = memoObj
? memoObj.text
: `'▣ ${selRow.NAME || selCode} (${selRow.SIZE || ""})'\r\n'1. 재료비 : ${fmt(num(selRow.MAMT1))}원'\r\n'2. 노무비 : ${fmt(num(selRow.LAMT1))}원'\r\n'3. 경 비 : ${fmt(num(selRow.SAMT1))}원'\r\n(==)`;
: recipeComps.length
? `'▣ ${selRow.NAME || selCode} (${selRow.SIZE || ""}) — 표준 호표 조립 구성 (${recipeComps.length}행)'\r\n` +
recipeComps
.map(
(c, ci) =>
`'${ci + 1}. ${c["명칭"]} (${c["규격"] || "-"}) : 수량 ${c["수량"]} ${c["단위"] || ""}'`,
)
.join("\r\n") +
`\r\n'노 무 비 : ${num(selRow.LAMT1)}'\r\n'재 료 비 : ${num(selRow.MAMT1)}'\r\n'경 비 : ${num(selRow.SAMT1)}'\r\n(==)`
: `'▣ ${selRow.NAME || selCode} (${selRow.SIZE || ""})'\r\n'1. 재료비 : ${fmt(num(selRow.MAMT1))}원'\r\n'2. 노무비 : ${fmt(num(selRow.LAMT1))}원'\r\n'3. 경 비 : ${fmt(num(selRow.SAMT1))}원'\r\n(==)`;
const currentSmtText =
ctx.uiState.editedSmtMap && ctx.uiState.editedSmtMap[selCode] !== undefined
? ctx.uiState.editedSmtMap[selCode]
: defaultText;
const evalRes = evaluateSmtFormulaText(currentSmtText, ctx);
const evalRes = evaluateSmtFormulaText(currentSmtText, ctx, selCode);
return `
<div class="win-form">
+41 -24
View File
@@ -456,39 +456,49 @@
<div class="win-subbar">
<label>보고서 양식(SET_REP):</label>
<select id="prt-sheet-select" class="win-select" style="width:240px;">
<select id="prt-sheet-select" class="win-select" style="width:260px;">
<option value="0608" ${activeRep === "0608" ? "selected" : ""}>[0608] 공사원가계산서 (PM_Won_A)</option>
<option value="0601" ${activeRep === "0601" ? "selected" : ""}>[0601] 총괄설계내역서 (PM_Nae_Tot)</option>
<option value="0603" ${activeRep === "0603" ? "selected" : ""}>[0603] 설계내역서 (PM_Nae)</option>
<option value="0607" ${activeRep === "0607" ? "selected" : ""}>[0607] 관급자재내역서 (PM_Nae_Kwn)</option>
<option value="0302" ${activeRep === "0302" ? "selected" : ""}>[0302] 일위대가표 / 목록표 (PM_iLWi)</option>
<option value="0402" ${activeRep === "0402" ? "selected" : ""}>[0402] 단가산출근거 (Edit_SanKun)</option>
<option value="0203" ${activeRep === "0203" ? "selected" : ""}>[0203] 시간당중기사용료 (PM_JOONG)</option>
<option value="0643" ${activeRep === "0643" ? "selected" : ""}>[0643] 설계변경내역서/증감대비표 (CHANGE)</option>
<option value="0623" ${activeRep === "0623" ? "selected" : ""}>[0623/0673] 계약내역서·실행예산서</option>
<option value="0633" ${activeRep === "0633" ? "selected" : ""}>[0633] 기성부분내역서·수량조서</option>
<option value="0302" ${activeRep === "0302" ? "selected" : ""}>[0301/0302] 일위대가표·목록표 (PM_iLWi)</option>
<option value="0402" ${activeRep === "0402" ? "selected" : ""}>[0401/0402] 단가산출근거·목록표 (Edit_SanKun)</option>
<option value="0203" ${activeRep === "0203" ? "selected" : ""}>[0202/0203] 시간당중기사용료·목록표 (PM_JOONG)</option>
<option value="0101" ${activeRep === "0101" ? "selected" : ""}>[0101~0105] 노무·재료·경비·단가대비표</option>
<option value="0501" ${activeRep === "0501" ? "selected" : ""}>[0501~0507] 자원/중기/대가 수량금액집계표</option>
<option value="0201" ${activeRep === "0201" ? "selected" : ""}>[0201] 환율및기초자료 · 〓 INITIAL 〓</option>
<option value="SET_REP_ALL" ${activeRep === "SET_REP_ALL" ? "selected" : ""}>[전체] SET_REP.DBF 75종 양식 설정표</option>
</select>
<button class="win-btn primary" id="prt-btn-csv">📊 현재 시트 CSV(엑셀호환) 다운로드</button>
<button class="win-btn" id="prt-btn-xml">🏛️ 조달청 호환규정 XML 내보내기(uM_XML_Info)</button>
<button class="win-btn" id="prt-btn-jdl">📄 조달청 JDL 전자내역 파일 내보내기</button>
<button class="win-btn" onclick="window.print()">🖨️ 브라우저 인쇄</button>
<button class="win-btn primary" id="prt-btn-xls">📗 다중시트 엑셀(.xls 워크북) 내보내기</button>
<button class="win-btn" id="prt-btn-csv">📊 현재 시트 CSV 다운로드</button>
<button class="win-btn" id="prt-btn-xml">🏛️ 조달청 호환규정 XML(T3_C3B)</button>
<button class="win-btn" id="prt-btn-jdl">📄 조달청 JDL 내보내기</button>
<button class="win-btn" onclick="window.print()">🖨️ 인쇄</button>
</div>
<div class="split-vert">
<div class="pane-box" style="flex:1;padding:16px;overflow:auto;background:#fff;">
<h2 style="text-align:center;margin:0 0 6px;letter-spacing:4px;color:#0f294d;">
${
activeRep === "0608"
? "공 사 원 가 계 산 서"
: activeRep === "0603"
? "설 계 내 역 서"
: activeRep === "0607"
? "관 급 자 재 내 역 서"
: activeRep === "0302"
? "일 위 대 가 표 (목 록)"
: activeRep === "0402"
? "단 가 산 출 근 거"
: activeRep === "0203"
? "시 간 당 중 기 사 용 료"
: "자 재 · 노 무 · 경 비 단 가 목 록 표"
{
"0608": "공 사 원 가 계 산 서",
"0601": "총 괄 설 계 내 역 서",
"0603": "설 계 내 역 서",
"0607": "관 급 자 재 내 역 서",
"0643": "설 계 변 경 내 역 서 (증 감 대 비)",
"0623": "계 약 내 역 서 · 실 행 예 산 서",
"0633": "기 성 부 분 내 역 서 · 수 량 조 서",
"0302": "일 위 대 가 표 (목 록)",
"0402": "단 가 산 출 근 거",
"0203": "시 간 당 중 기 사 용 료",
"0101": "자 재 · 노 무 · 경 비 단 가 목 록 표",
"0501": "자 원 · 중 기 · 대 가 수 량 금 액 집 계 표",
"0201": "환 율 및 기 초 자 료 (〓 INITIAL 〓)",
SET_REP_ALL: "SET_REP.DBF 75종 양식 설정표",
}[activeRep] || "설 계 보 고 서"
}
</h2>
<p style="text-align:center;margin:0 0 12px;font-weight:700;color:#334155;">
@@ -518,12 +528,18 @@
}
${
activeRep === "0603" || activeRep === "0607"
["0601", "0603", "0607", "0643", "0623", "0633"].includes(activeRep)
? `<table class="dbgrid">
<thead><tr><th>공종</th><th>명칭</th><th>규격</th><th>단위</th><th>수량</th><th>재료비단가</th><th>노무비단가</th><th>경비단가</th><th>합계단가</th><th>합계금액</th><th>비고</th></tr></thead>
<tbody>
${boqRows
.filter((r) => (activeRep === "0607" ? r._isExcl : true))
.filter((r) =>
activeRep === "0607"
? r._isExcl
: activeRep === "0601"
? /^(T\d|E\d|C\d)$/.test(String(r.BDCODE1 || "").trim())
: true,
)
.map(
(r) => `
<tr class="${r._rowClass}">
@@ -561,7 +577,7 @@
}
${
activeRep === "0302" || activeRep === "0203" || activeRep === "0101"
["0302", "0203", "0101", "0501", "0201"].includes(activeRep)
? `<table class="dbgrid">
<thead><tr><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>재료비</th><th>노무비</th><th>경비</th><th>합계단가</th></tr></thead>
<tbody>
@@ -570,6 +586,7 @@
const p = String(r.CODE || " ")[0].toUpperCase();
if (activeRep === "0302") return p === "B" || p === "D";
if (activeRep === "0203") return p === "X";
if (activeRep === "0201") return p === "L" || p === "X";
return p === "L" || p === "M" || p === "S";
})
.map(
+66 -78
View File
@@ -480,85 +480,26 @@
*/
function buildBoqToolModalHtml(toolKind, ctx) {
if (toolKind === "wITNum") {
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;"><strong>[wITNum] 내역서 공종번호 자동입력</strong> — 대공종(T4)/중공종(T2)/말단 공종행에 번호체계를 일괄 부여합니다.</p>
<div class="kanjub-16grid" style="grid-template-columns:repeat(2,1fr);">
<div class="kanjub-field"><label>대공종(T4) 번호형식</label><select id="itnum-t4-fmt"><option value="1.">1. / 2. / 3.</option><option value="I.">I. / II. / III.</option><option value="가.">가. / 나. / 다.</option></select></div>
<div class="kanjub-field"><label>중공종(T2) 번호형식</label><select id="itnum-t2-fmt"><option value="1.1">1.1 / 1.2 / 1.3</option><option value="(1)">(1) / (2) / (3)</option></select></div>
<div class="kanjub-field"><label>말단 공종행 번호부여</label><select id="itnum-leaf-fmt"><option value="none">공란 유지 (STMATE 기본)</option><option value="01">01, 02, 03...</option><option value="-1">-1, -2, -3...</option></select></div>
</div>
<div style="text-align:right;"><button class="win-btn primary" id="modal-apply-itnum">공종번호 일괄 부여 실행</button></div>
</div>
`;
return `<div style="display:flex;flex-direction:column;gap:10px;"><p style="margin:0;"><strong>[wITNum] 내역서 공종번호 자동입력</strong> — 대공종(T4)/중공종(T2)/말단 공종행에 번호체계를 일괄 부여합니다.</p><div class="kanjub-16grid" style="grid-template-columns:repeat(3,1fr);"><div class="kanjub-field"><label>대공종(T4) 번호형식</label><select id="itnum-t4-fmt"><option value="1.">1. / 2. / 3.</option><option value="I.">I. / II. / III.</option><option value="가.">가. / 나. / 다.</option></select></div><div class="kanjub-field"><label>중공종(T2) 번호형식</label><select id="itnum-t2-fmt"><option value="1.1">1.1 / 1.2 / 1.3</option><option value="(1)">(1) / (2) / (3)</option></select></div><div class="kanjub-field"><label>말단 공종행 번호부여</label><select id="itnum-leaf-fmt"><option value="none">공란 유지 (STMATE 기본)</option><option value="01">01, 02, 03...</option><option value="-1">-1, -2, -3...</option></select></div></div><div style="text-align:right;"><button class="win-btn primary" id="modal-apply-itnum">공종번호 일괄 부여 실행</button></div></div>`;
}
if (toolKind === "wBoqRate") {
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;"><strong>[wBoqRate] 내역 항목 할증율 일괄 적용</strong> — 전체 또는 비목별(재·노·경) 할증 비율(%)을 적용합니다.</p>
<div class="kanjub-16grid" style="grid-template-columns:repeat(3,1fr);">
<div class="kanjub-field"><label>재료비 할증(%)</label><input type="number" id="boqrate-m" value="100" step="1" /></div>
<div class="kanjub-field"><label>노무비 할증(%)</label><input type="number" id="boqrate-l" value="100" step="1" /></div>
<div class="kanjub-field"><label>경비 할증(%)</label><input type="number" id="boqrate-s" value="100" step="1" /></div>
</div>
<div style="text-align:right;"><button class="win-btn primary" id="modal-apply-boqrate">내역 할증율 적용</button></div>
</div>
`;
return `<div style="display:flex;flex-direction:column;gap:10px;"><p style="margin:0;"><strong>[wBoqRate] 내역 항목 할증율 일괄 적용</strong> — 전체 또는 비목별(재·노·경) 할증 비율(%)을 적용합니다.</p><div class="kanjub-16grid" style="grid-template-columns:repeat(3,1fr);"><div class="kanjub-field"><label>재료비 할증(%)</label><input type="number" id="boqrate-m" value="100" step="1" /></div><div class="kanjub-field"><label>노무비 할증(%)</label><input type="number" id="boqrate-l" value="100" step="1" /></div><div class="kanjub-field"><label>경비 할증(%)</label><input type="number" id="boqrate-s" value="100" step="1" /></div></div><div style="text-align:right;"><button class="win-btn primary" id="modal-apply-boqrate">내역 할증율 적용</button></div></div>`;
}
if (toolKind === "wM_Rate") {
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;"><strong>[wM_Rate] 선택 범위 수량·비목 단수 일괄 보정</strong> — 절사/반올림 및 소수점 자리수(0~4위)를 일괄 보정합니다.</p>
<div class="kanjub-16grid" style="grid-template-columns:repeat(2,1fr);">
<div class="kanjub-field"><label>보정 방식</label><select id="mrate-mode"><option value="trunc">절사 (ROUNDDOWN)</option><option value="round">반올림 (ROUND)</option></select></div>
<div class="kanjub-field"><label>소수 자리수(ECD)</label><select id="mrate-digits"><option value="0">소수 0위 (정수 1원)</option><option value="1">소수 1위 (0.1)</option><option value="2">소수 2위 (0.01)</option><option value="-1">10원 단위 절사</option></select></div>
</div>
<div style="text-align:right;"><button class="win-btn primary" id="modal-apply-mrate">단수 일괄 보정 적용</button></div>
</div>
`;
return `<div style="display:flex;flex-direction:column;gap:10px;"><p style="margin:0;"><strong>[wM_Rate] 선택 범위 수량·비목 단수 일괄 보정</strong> — 절사/반올림 및 소수점 자리수(0~4위)를 일괄 보정합니다.</p><div class="kanjub-16grid" style="grid-template-columns:repeat(2,1fr);"><div class="kanjub-field"><label>보정 방식</label><select id="mrate-mode"><option value="trunc">절사 (ROUNDDOWN)</option><option value="round">반올림 (ROUND)</option></select></div><div class="kanjub-field"><label>소수 자리수(ECD)</label><select id="mrate-digits"><option value="0">소수 0위 (정수 1원)</option><option value="1">소수 1위 (0.1)</option><option value="2">소수 2위 (0.01)</option><option value="-1">10원 단위 절사</option></select></div></div><div style="text-align:right;"><button class="win-btn primary" id="modal-apply-mrate">단수 일괄 보정 적용</button></div></div>`;
}
if (toolKind === "wExpnd") {
const bdxList = (ctx.state.ilwiItems || []).slice(0, 30);
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;"><strong>[wExpnd / wM_Expnd] 일위대가·단가산출 내역서 풀어넣기</strong> — 선택한 호표의 재료·노무·경비 구성행을 내역서에 직접 전개합니다.</p>
<div class="kanjub-16grid" style="grid-template-columns:repeat(3,1fr);">
<div class="kanjub-field"><label>전개할 호표 선택</label><select id="expnd-code">${bdxList.map((x) => `<option value="${x.code}">${x.code} ${x.rawName || x.name}</option>`).join("")}</select></div>
<div class="kanjub-field"><label>집계 구분선(RGrpToT)</label><select id="expnd-tot"><option value="E1">소계(=)</option><option value="E2">계(==)</option><option value="E3">합계(===)</option></select></div>
<div class="kanjub-field"><label>단가 끝전 처리</label><select id="expnd-round"><option value="trunc">절삭하여 소수 0위 적용</option><option value="round">반올림하여 소수 0위 적용</option></select></div>
</div>
<div style="text-align:right;"><button class="win-btn primary" id="modal-apply-expnd">일위대가 풀어넣기 실행</button></div>
</div>
`;
return `<div style="display:flex;flex-direction:column;gap:10px;"><p style="margin:0;"><strong>[wExpnd / wM_Expnd] 일위대가·단가산출 내역서 풀어넣기</strong> — 선택한 호표의 재료·노무·경비 구성행을 내역서에 직접 전개합니다.</p><div class="kanjub-16grid" style="grid-template-columns:repeat(3,1fr);"><div class="kanjub-field"><label>전개할 호표 선택</label><select id="expnd-code">${bdxList.map((x) => `<option value="${x.code}">${x.code} ${x.rawName || x.name}</option>`).join("")}</select></div><div class="kanjub-field"><label>집계 구분선(RGrpToT)</label><select id="expnd-tot"><option value="E1">소계(=)</option><option value="E2">계(==)</option><option value="E3">합계(===)</option></select></div><div class="kanjub-field"><label>단가 끝전 처리</label><select id="expnd-round"><option value="trunc">절삭하여 소수 0위 적용</option><option value="round">반올림하여 소수 0위 적용</option></select></div></div><div style="text-align:right;"><button class="win-btn primary" id="modal-apply-expnd">일위대가 풀어넣기 실행</button></div></div>`;
}
// wQtyList (수량산출서 연결 집계)
const leafRows = ctx.computed.boqComputed.rows || [];
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;"><strong>[wQtyList] 수량산출서(QCODE.DBF) 코드별 수량 연결·집계</strong> — 수량산출식 결과를 내역서 QTY1에 즉시 반영합니다.</p>
<div class="grid-scroll" style="max-height:320px;">
<table class="dbgrid">
<thead><tr><th>코드</th><th>공종명</th><th>현재 내역수량</th><th>산출근거 수식 (예: 3.46*1000)</th><th>반영</th></tr></thead>
<tbody>
${leafRows
.slice(0, 25)
.map(
(r) => `
<tr>
<td class="center"><strong>${r.code}</strong></td>
<td>${r.name}</td>
<td class="num">${fmt(r.qty, 2)}</td>
<td><input type="text" class="win-input qty-expr-input" data-id="${r.id}" value="${r.qty}" style="width:100%;" /></td>
<td class="center"><button class="win-btn btn-apply-qty-expr" data-id="${r.id}">산식반영</button></td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
</div>
`;
return `<div style="display:flex;flex-direction:column;gap:10px;"><p style="margin:0;"><strong>[wQtyList] 수량산출서(QCODE.DBF) 코드별 수량 연결·집계</strong> — 수량산출식 결과를 내역서 QTY1에 즉시 반영합니다.</p><div class="grid-scroll" style="max-height:320px;"><table class="dbgrid"><thead><tr><th>코드</th><th>공종명</th><th>현재 내역수량</th><th>산출근거 수식 (예: 3.46*1000)</th><th>반영</th></tr></thead><tbody>${leafRows
.slice(0, 25)
.map(
(r) =>
`<tr><td class="center"><strong>${r.code}</strong></td><td>${r.name}</td><td class="num">${fmt(r.qty, 2)}</td><td><input type="text" class="win-input qty-expr-input" data-id="${r.id}" value="${r.qty}" style="width:100%;" /></td><td class="center"><button class="win-btn btn-apply-qty-expr" data-id="${r.id}">산식반영</button></td></tr>`,
)
.join("")}</tbody></table></div></div>`;
}
function syncCostnUnitPrice(ctx, code, mVal, lVal, sVal) {
@@ -610,20 +551,47 @@
function buildSavePayload(ctx) {
const kj = ctx.computed.kanjubComputed;
const revKj = ctx.computed.revComputed.revKj;
const bt = ctx.computed.boqComputed.totals;
const revBt = ctx.computed.revComputed.revBoq.totals;
const fullBoq = evaluateFullBoqRows(ctx);
const rts = ctx.state.kanjubConfig.rates;
const [bm, bl, bs] = [
(ctx.uiState.bidRateM || 87.745) / 100,
(ctx.uiState.bidRateL || 87.745) / 100,
(ctx.uiState.bidRateS || 87.745) / 100,
];
const revByRec = Object.fromEntries(
(ctx.state.boqItems || []).map((b) => [b._recno, b.changedQty ?? b.qty]),
);
return {
source_path: ctx.currentSourcePath,
target_folder_name: (ctx.state.projectMeta.pname || "stmate_edited").slice(0, 28),
costn_updates: (ctx.dbfData.costn_rows || []).map((r) => ({
CODE: r.CODE,
MAMT1: r.MAMT1,
LAMT1: r.LAMT1,
SAMT1: r.SAMT1,
TAMT1: r.TAMT1,
AMT_61: r.TAMT1,
})),
costn_updates: (ctx.dbfData.costn_rows || []).map((r) => {
const m1 = num(r.MAMT1),
l1 = num(r.LAMT1),
s1 = num(r.SAMT1),
t1 = m1 + l1 + s1 || num(r.TAMT1);
const m3 = Math.floor(m1 * bm),
l3 = Math.floor(l1 * bl),
s3 = Math.floor(s1 * bs);
return {
CODE: r.CODE,
MAMT1: m1,
LAMT1: l1,
SAMT1: s1,
TAMT1: t1,
MAMT2: m1,
LAMT2: l1,
SAMT2: s1,
TAMT2: t1,
MAMT3: m3,
LAMT3: l3,
SAMT3: s3,
TAMT3: m3 + l3 + s3,
AMT_61: t1,
};
}),
boq_updates: fullBoq.map((r) => ({
_recno: r._recno,
ITNUM: r.ITNUM || "",
@@ -631,10 +599,22 @@
SIZE1: r.SIZE1 || "",
UNIT1: r.UNIT1 || "",
QTY1: r.QTY1,
QTY2: revByRec[r._recno] !== undefined ? revByRec[r._recno] : r.QTY1,
MAMT1: r.MAMT1,
LAMT1: r.LAMT1,
SAMT1: r.SAMT1,
TAMT1: r.TAMT1,
MAMT2: r.MAMT1,
LAMT2: r.LAMT1,
SAMT2: r.SAMT1,
TAMT2: r.TAMT1,
MAMT3: Math.floor(num(r.MAMT1) * bm),
LAMT3: Math.floor(num(r.LAMT1) * bl),
SAMT3: Math.floor(num(r.SAMT1) * bs),
TAMT3:
Math.floor(num(r.MAMT1) * bm) +
Math.floor(num(r.LAMT1) * bl) +
Math.floor(num(r.SAMT1) * bs),
})),
prjlst_updates: {
PNAME: ctx.state.projectMeta.pname,
@@ -644,6 +624,13 @@
SCOST1: bt.directS,
TCOST1: bt.directT,
COSTW1: kj.GRAND_TOTAL,
MCOST2: revBt.directM,
LCOST2: revBt.directL,
SCOST2: revBt.directS,
TCOST2: revBt.directT,
COSTW2: revKj.GRAND_TOTAL,
TCOST3: Math.floor(bt.directT * bm),
COSTW3: Math.floor(kj.GRAND_TOTAL * bm),
},
smt_updates: {
"1_R_KANNO": rts.KANNO,
@@ -667,6 +654,7 @@
"1_M_VAT": kj.VAT,
"1_M_TOT": kj.FINAL_SUPPLY,
"1_M_KWNGP": kj.GOV_MAT,
"2_M_TOT": revKj.FINAL_SUPPLY,
},
costn_memo_updates: ctx.uiState.editedSmtMap || {},
};
@@ -0,0 +1,154 @@
[START]
ARENM=1
BMKNM=TLMS
ACDNM=1
KCDNM=1
ZCDNM=2
CHGNM=1
CHNNM=1
BCDNM=65
DCDNM=121
ICDNM=0
LCDNM=20
MCDNM=116
PCDNM=13
QCDNM=0
SCDNM=35
WCDNM=0
XCDNM=30
SUBNM=남부지방산림청
LMGNM=20
MKRNM=4
TMGNM=10
JEMNM=123456
JMOK1=조달가격
JMOK2=물가자료
JMOK3=물가정보
JMOK4=거래가격
JMOK5=견적단가
JMOK6=적용 단가
JMK10=조달
JMK20=물자
JMK30=물정
JMK40=거래
JMK50=견적
JUKNM=6
YCDNM=0
JCDNM=0
ECDNM=0
BPNNM=1
OPTNM=SML
CHBNM=1
S_JDA=1
USRPG=1
CUTNM=0
[PRINT]
ORDNM=
FONT=굴림체
F_COL0=clBlack
F_COL1=clBlack
F_COL2=clRed
SH_TYP0=0
SAN_TYP=1
BMK_STR1=재 료 비
BMK_STR2=노 무 비
BMK_STR3=경 비
BMK_STR4=합 계
EQ_TYP=1
SH_TYP1=1
SH_TYP2=1
SH_TYP3=2
FLT_JAB=2
CAP_CODE=코드번호
CAP_NO=호표
CAP_IBRD=분류번호
CAP_EQCOD=시설코드
CAP_ITNUM=공 종
CAP_NAME=명 칭
CAP_SIZE=규 격
CAP_QTY=수 량
CAP_SUNIT=단위
CAP_AMT=단 가
CAP_PAGE=페이지
CAP_AMOUNT=금 액
CAP_BIGO=비 고
FMT_LEFT=-
FMT_RIGHT=-
RPOS_PAGE=0
RPOS_COP=3
RPOS_QTY=2
RPOS_SIZE=0
RPOS_BIGO=1
LNP_CNT=20
P_BIGO=1
TITL_1LN=0
P_SHAMT=0
DISP_N1=소계
DISP_N2=계
DISP_N3=합계
DISP_N4=총계
DISP_N5=총합계
DISP_N6=총대계
DISP_EX=제외금액
DISP_TOT=총 계
DISP_N7=광역계
DISP_N8=광역총계
DISP_N9=광역대계
CH_PRN_COD=0
COD_FMT_0=0
COD_FMT_1=노무
COD_FMT_2=자재
COD_FMT_3=경비
COD_FMT_4=대가
COD_FMT_5=중기
COD_FMT_6=산근
COD_FMT_7=기타
COD_FMT_8=실적
EDTNM=
CH_PRN_ORD=0
CH_MIN_COD=5
UPDNCAP=상단:당초 , 하단:변경
KAN_TYP=0
[EQUIPMENT]
RXXNM=1/8*16/12*25/20
RXTNM=
EXLNM1=1289
EXHNM1=1289
EXLNM2=844.7
EXHNM2=844.7
EXLNM3=1187.8
EXHNM3=1187.8
EXLNM4=1187.8
EXHNM4=1187.8
CONLD=L00008L00009L00010
CONTD=
CON1D=
CON2D=
CON3D=
CON4D=
EXRNM=0
RX1NM=
RX2NM=
RX3NM=
RX4NM=
EXEUR1=1000
EXYEN1=1000
EXEUR2=1000
EXYEN2=1000
[PROJECT]
PCODE=XXXX
PMODE=1
PNAME=2024년도 산불진화임도 실시설계(2개소)-24년 3월 봉�
P_DIR=
RGrpTo=0
CBoxUpIns=0
PRG_V=24.04
[NAG_HAL]
ST_MHAL=0
EQ_LHAL=0
EQ_MHAL=0
EQ_SHAL=0
@@ -0,0 +1,154 @@
[START]
ARENM=1
BMKNM=TLMS
ACDNM=1
KCDNM=1
ZCDNM=2
CHGNM=1
CHNNM=1
BCDNM=65
DCDNM=121
ICDNM=0
LCDNM=20
MCDNM=116
PCDNM=13
QCDNM=0
SCDNM=35
WCDNM=0
XCDNM=30
SUBNM=남부지방산림청
LMGNM=20
MKRNM=4
TMGNM=10
JEMNM=123456
JMOK1=조달가격
JMOK2=물가자료
JMOK3=물가정보
JMOK4=거래가격
JMOK5=견적단가
JMOK6=적용 단가
JMK10=조달
JMK20=물자
JMK30=물정
JMK40=거래
JMK50=견적
JUKNM=6
YCDNM=0
JCDNM=0
ECDNM=0
BPNNM=1
OPTNM=SML
CHBNM=1
S_JDA=1
USRPG=1
CUTNM=0
[PRINT]
ORDNM=
FONT=굴림체
F_COL0=clBlack
F_COL1=clBlack
F_COL2=clRed
SH_TYP0=0
SAN_TYP=1
BMK_STR1=재 료 비
BMK_STR2=노 무 비
BMK_STR3=경 비
BMK_STR4=합 계
EQ_TYP=1
SH_TYP1=1
SH_TYP2=1
SH_TYP3=2
FLT_JAB=2
CAP_CODE=코드번호
CAP_NO=호표
CAP_IBRD=분류번호
CAP_EQCOD=시설코드
CAP_ITNUM=공 종
CAP_NAME=명 칭
CAP_SIZE=규 격
CAP_QTY=수 량
CAP_SUNIT=단위
CAP_AMT=단 가
CAP_PAGE=페이지
CAP_AMOUNT=금 액
CAP_BIGO=비 고
FMT_LEFT=-
FMT_RIGHT=-
RPOS_PAGE=0
RPOS_COP=3
RPOS_QTY=2
RPOS_SIZE=0
RPOS_BIGO=1
LNP_CNT=20
P_BIGO=1
TITL_1LN=0
P_SHAMT=0
DISP_N1=소계
DISP_N2=계
DISP_N3=합계
DISP_N4=총계
DISP_N5=총합계
DISP_N6=총대계
DISP_EX=제외금액
DISP_TOT=총 계
DISP_N7=광역계
DISP_N8=광역총계
DISP_N9=광역대계
CH_PRN_COD=0
COD_FMT_0=0
COD_FMT_1=노무
COD_FMT_2=자재
COD_FMT_3=경비
COD_FMT_4=대가
COD_FMT_5=중기
COD_FMT_6=산근
COD_FMT_7=기타
COD_FMT_8=실적
EDTNM=
CH_PRN_ORD=0
CH_MIN_COD=5
UPDNCAP=상단:당초 , 하단:변경
KAN_TYP=0
[EQUIPMENT]
RXXNM=1/8*16/12*25/20
RXTNM=
EXLNM1=1289
EXHNM1=1289
EXLNM2=844.7
EXHNM2=844.7
EXLNM3=1187.8
EXHNM3=1187.8
EXLNM4=1187.8
EXHNM4=1187.8
CONLD=L00008L00009L00010
CONTD=
CON1D=
CON2D=
CON3D=
CON4D=
EXRNM=0
RX1NM=
RX2NM=
RX3NM=
RX4NM=
EXEUR1=1000
EXYEN1=1000
EXEUR2=1000
EXYEN2=1000
[PROJECT]
PCODE=XXXX
PMODE=1
PNAME=2024년도 산불진화임도 실시설계(2개소)-24년 3월 봉�
P_DIR=
RGrpTo=0
CBoxUpIns=0
PRG_V=24.04
[NAG_HAL]
ST_MHAL=0
EQ_LHAL=0
EQ_MHAL=0
EQ_SHAL=0
@@ -0,0 +1,162 @@
[START]
ARENM=1
BMKNM=TLMS
ACDNM=1
KCDNM=1
ZCDNM=2
CHGNM=1
CHNNM=1
BCDNM=2130
DCDNM=1497
ICDNM=0
LCDNM=61
MCDNM=1519
PCDNM=12
QCDNM=0
SCDNM=1263
WCDNM=2336
XCDNM=751
SUBNM=
LMGNM=20
MKRNM=4
TMGNM=10
JEMNM=123456
JMOK1=조달가격
JMOK2=물가정보
JMOK3=물가자료
JMOK4=적산정보
JMOK5=견적단가
JMOK6=적용 단가
JMK10=조달
JMK20=물정
JMK30=물자
JMK40=적산
JMK50=견적
JUKNM=6
YCDNM=0
JCDNM=1
ECDNM=0
BPNNM=1
OPTNM=SML
CHBNM=1
S_JDA=1
USRPG=1
CUTNM=1
[PRINT]
ORDNM=
FONT=굴림체
F_COL0=clBlack
F_COL1=clRed
F_COL2=clBlack
SH_TYP0=0
SAN_TYP=1
BMK_STR1=재 료 비
BMK_STR2=노 무 비
BMK_STR3=경 비
BMK_STR4=합 계
EQ_TYP=1
SH_TYP1=1
SH_TYP2=1
SH_TYP3=0
FLT_JAB=2
CAP_CODE=코드번호
CAP_NO=호표
CAP_IBRD=분류번호
CAP_EQCOD=시설코드
CAP_ITNUM=공 종
CAP_NAME=명 칭
CAP_SIZE=규 격
CAP_QTY=수 량
CAP_SUNIT=단위
CAP_AMT=단 가
CAP_PAGE=페이지
CAP_AMOUNT=금 액
CAP_BIGO=비 고
FMT_LEFT=-
FMT_RIGHT=-
RPOS_PAGE=2
RPOS_COP=3
RPOS_QTY=2
RPOS_SIZE=0
RPOS_BIGO=1
LNP_CNT=20
P_BIGO=1
TITL_1LN=0
P_SHAMT=0
DISP_N1=소계
DISP_N2=계
DISP_N3=합계
DISP_N4=총계
DISP_N5=총합계
DISP_N6=총대계
DISP_EX=제외금액
DISP_TOT=총 계
DISP_N7=광역계
DISP_N8=광역총계
DISP_N9=광역대계
CH_PRN_COD=0
COD_FMT_0=0
COD_FMT_1=노무
COD_FMT_2=자재
COD_FMT_3=경비
COD_FMT_4=대가
COD_FMT_5=중기
COD_FMT_6=산근
COD_FMT_7=기타
COD_FMT_8=실적
EDTNM=
CH_PRN_ORD=0
CH_MIN_COD=5
UPDNCAP=상단:당초 , 하단:변경
KAN_TYP=0
F_COL3=clBlack
[EQUIPMENT]
RXXNM=1/8*16/12*25/20
RXTNM=
EXLNM1=1470
EXHNM1=1470
EXLNM2=1470
EXHNM2=1470
EXLNM3=1470
EXHNM3=1470
EXLNM4=1470
EXHNM4=1470
CONLD=L00038L00039L00040
CONTD=
CON1D=
CON2D=
CON3D=
CON4D=
EXRNM=1
RX1NM=1/8*16/12*25/20*24/15
RX2NM=1/8*16/12*25/20*12/10
RX3NM=1/8*16/12*25/20*24/5
RX4NM=
EXEUR1=1423
EXYEN1=915
EXEUR2=1000
EXYEN2=1000
[PROJECT]
PCODE=XXXX
PMODE=3
PNAME=2025년 산불진화임도 신설사업8
P_DIR=
RGrpTo=1
CBoxUpIns=0
PRG_V=25.07
[NAG_HAL]
ST_MHAL=0
EQ_LHAL=0
EQ_MHAL=0
EQ_SHAL=0
NAGNM=87.745
[XML]
Company=
MkDate=2021-01-12
PrjNum=
PrjTyp=토목공사
PrjBigo=
@@ -0,0 +1,162 @@
[START]
ARENM=1
BMKNM=TLMS
ACDNM=1
KCDNM=1
ZCDNM=2
CHGNM=1
CHNNM=1
BCDNM=2130
DCDNM=1497
ICDNM=0
LCDNM=61
MCDNM=1519
PCDNM=12
QCDNM=0
SCDNM=1263
WCDNM=2336
XCDNM=751
SUBNM=
LMGNM=20
MKRNM=4
TMGNM=10
JEMNM=123456
JMOK1=조달가격
JMOK2=물가정보
JMOK3=물가자료
JMOK4=적산정보
JMOK5=견적단가
JMOK6=적용 단가
JMK10=조달
JMK20=물정
JMK30=물자
JMK40=적산
JMK50=견적
JUKNM=6
YCDNM=0
JCDNM=1
ECDNM=0
BPNNM=1
OPTNM=SML
CHBNM=1
S_JDA=1
USRPG=1
CUTNM=1
[PRINT]
ORDNM=
FONT=굴림체
F_COL0=clBlack
F_COL1=clRed
F_COL2=clBlack
SH_TYP0=0
SAN_TYP=1
BMK_STR1=재 료 비
BMK_STR2=노 무 비
BMK_STR3=경 비
BMK_STR4=합 계
EQ_TYP=1
SH_TYP1=1
SH_TYP2=1
SH_TYP3=0
FLT_JAB=2
CAP_CODE=코드번호
CAP_NO=호표
CAP_IBRD=분류번호
CAP_EQCOD=시설코드
CAP_ITNUM=공 종
CAP_NAME=명 칭
CAP_SIZE=규 격
CAP_QTY=수 량
CAP_SUNIT=단위
CAP_AMT=단 가
CAP_PAGE=페이지
CAP_AMOUNT=금 액
CAP_BIGO=비 고
FMT_LEFT=-
FMT_RIGHT=-
RPOS_PAGE=2
RPOS_COP=3
RPOS_QTY=2
RPOS_SIZE=0
RPOS_BIGO=1
LNP_CNT=20
P_BIGO=1
TITL_1LN=0
P_SHAMT=0
DISP_N1=소계
DISP_N2=계
DISP_N3=합계
DISP_N4=총계
DISP_N5=총합계
DISP_N6=총대계
DISP_EX=제외금액
DISP_TOT=총 계
DISP_N7=광역계
DISP_N8=광역총계
DISP_N9=광역대계
CH_PRN_COD=0
COD_FMT_0=0
COD_FMT_1=노무
COD_FMT_2=자재
COD_FMT_3=경비
COD_FMT_4=대가
COD_FMT_5=중기
COD_FMT_6=산근
COD_FMT_7=기타
COD_FMT_8=실적
EDTNM=
CH_PRN_ORD=0
CH_MIN_COD=5
UPDNCAP=상단:당초 , 하단:변경
KAN_TYP=0
F_COL3=clBlack
[EQUIPMENT]
RXXNM=1/8*16/12*25/20
RXTNM=
EXLNM1=1470
EXHNM1=1470
EXLNM2=1470
EXHNM2=1470
EXLNM3=1470
EXHNM3=1470
EXLNM4=1470
EXHNM4=1470
CONLD=L00038L00039L00040
CONTD=
CON1D=
CON2D=
CON3D=
CON4D=
EXRNM=1
RX1NM=1/8*16/12*25/20*24/15
RX2NM=1/8*16/12*25/20*12/10
RX3NM=1/8*16/12*25/20*24/5
RX4NM=
EXEUR1=1423
EXYEN1=915
EXEUR2=1000
EXYEN2=1000
[PROJECT]
PCODE=XXXX
PMODE=3
PNAME=2025년 산불진화임도 신설사업8
P_DIR=
RGrpTo=1
CBoxUpIns=0
PRG_V=25.07
[NAG_HAL]
ST_MHAL=0
EQ_LHAL=0
EQ_MHAL=0
EQ_SHAL=0
NAGNM=87.745
[XML]
Company=
MkDate=2021-01-12
PrjNum=
PrjTyp=토목공사
PrjBigo=