wip(est-test): 개발 중 저장 3 — 깃 싱크(11-08)

This commit is contained in:
2026-11-08 17:44:13 +09:00
parent d39eece45e
commit db71cdbf7c
28 changed files with 1027 additions and 202 deletions
+42
View File
@@ -423,6 +423,42 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 백그라운드 상태 감시 및 자동 알림: `tools/est-test/monitor.cjs`를 통해 15초 주기로 `orca.exe` CLI(`shell: false`, timeout 4~5s)를 호출하여 CODEX 워크트리의 안티그래비티 작업 상태와 EST 5175 서버 헬스를 조회하고, 상태 변경 시에만 `monitor.log`에 기록(최대 512KB 순환). 안티그래비티 완료/대기 전이 시 Codex Orchestration Run(`run_cdb1558b9280`)으로 단발성 자동 알림(`status`/`messageId=msg_2c7bca748f91`)이 실제 전달됨을 확인(중복 폭주 방지 플래그 적용, 민감 환경변수 미기록, 재시작/자동지시 없음).
## Codex · EST 독립 테스트 앱 (2026-11-04)
- [x] 수량산출 항목을 대용량 내역에 반영할 때 반복 검색을 줄이고 기존 연결·중복 오류 판정을 유지한다.
- 자체 검증: 5,000개 항목과 내역 연결 및 중복 식별자 거부 확인. 수량 기능 시험 13개 통과.
- [x] 수량산출 화면에서 내역 선택 목록을 필요할 때 생성하여 대용량 화면의 과도한 옵션 생성을 줄인다.
- 자체 검증: 수량 시험 14개 통과. 실제 앱의 Edge 화면에서 기존 연결 표시, 포커스 시 목록 생성 및 다른 내역 선택 반영을 확인했다. 1,000개 선택 항목은 최초 옵션 2,000개만 생성하고 선택 시 해당 목록을 연다.
- [x] 여러 수량산출서를 한꺼번에 반영할 때 내역 검색 자료를 공유하고 합계와 오류 처리를 유지한다.
- 자체 검증: 여러 수량산출서 합산, 오류 시 변경 방지 등 수량 시험 14개 통과.
- [x] 기성 내역 화면을 페이지로 나누어 대용량 자료도 조작하고 전체 계산·출력을 유지한다.
- 자체 검증: 기성 시험 12개 통과. 실제 Edge 화면에서 205행 중 100행씩 표시, 다음 쪽 101번 행 입력 및 전체 계산 결과 205행 유지를 확인했다.
- [x] 물가변동 내역 화면을 페이지로 나누고 행별 입력과 전체 계산을 유지한다.
- 자체 검증: 물가·페이지 시험 9개 통과. 실제 Edge 화면에서 205행 중 다음 쪽 101번 행 입력과 전체 계산 결과 보존을 확인했다.
- [x] 설계변경 비교 화면을 페이지로 나누고 신규·삭제·변경 내역과 원가 비교를 유지한다.
- 자체 검증: 설계변경·페이지 시험 18개 통과. 실제 Edge 화면에서 205행 비교와 다음 쪽 표시를 확인했다.
- [x] 새 작업을 열면 업무 화면의 페이지 위치를 첫 페이지로 초기화한다.
- 자체 검증: 실제 Edge에서 새 작업을 연 뒤 기성·물가변동·설계변경 페이지가 모두 첫 페이지로 돌아가는 것을 확인했다.
- [x] 업무 화면에서 코드·품명 검색과 기성·물가변동의 규격·단위 검색을 지원하고 검색할 때 첫 페이지부터 표시한다.
- 자체 검증: 업무별 검색과 원래 행 인덱스 유지, 검색어 변경 시 첫 페이지 및 전체 계산 유지 확인. 관련 시험 37개 통과, 실제 Edge 기성 화면에서 검색 및 입력 행 확인.
- [x] 물가변동에서 중복 코드 행의 순서가 바뀌어도 식별 가능한 행은 당초 단가와 정확히 연결한다.
- 자체 검증: 중복 코드의 행을 뒤바꾸고 JSON 저장·재열기 후 당초 단가, 적용 수량 및 제외금액 확인. 물가·업무 시험 27개 통과. 품명·규격·단위까지 중복된 행은 기존 순서 대응을 유지한다.
- [x] 설계변경 비교에서도 중복 코드 행의 재정렬을 식별해 당초 행과 연결한다.
- 자체 검증: 설계변경·물가변동 시험 24개 통과. 실제 Edge 앱에서 같은 코드의 행 순서 변경 후 각 당초 수량과 계약 단가를 확인했다. 품명·규격·단위가 모두 같은 경우는 기존 순서 대응을 유지한다.
- [x] 실행 중 새로 추가한 소스 파일도 감시하여 서버에 자동 반영하고 작업 데이터 저장에는 재시작하지 않는다.
- 자체 검증: 별도 임시 서버에서 신규 파일 생성·수정에 따른 재시작과 데이터 하위 폴더 변경 무시 확인. 실제 테스트 서버에서 신규 소스 변경 시 프로세스 갱신 및 HTTP 200 확인.
- [x] 중복 코드 행의 추가·삭제가 있어도 구별 가능한 행을 당초 자료와 연결한다.
- 자체 검증: 중복 코드의 일부 삭제와 신규 행 추가 후 당초 수량과 신규·삭제 분류를 확인했다. 설계변경·물가 시험 25개 통과. 식별 정보까지 같은 행은 기존 순서 기준이다.
- [x] 설계변경 원가 비교에서 중복 비목 코드의 순서 변경과 추가·삭제를 구별한다.
- 자체 검증: 중복 코드 원가의 재정렬·신규·삭제에 따른 당초·변경 금액을 검증했다. 설계변경·물가 시험 26개 통과.
- [x] 당초 확정 시 기계경비 호표 구성을 보관하고 선택한 호표를 복원한다. 기존 당초 자료와 현재 기초단가 사용 방식은 보존한다.
- 자체 검증: JSON 저장·재열기, 다른 호표와 당초 사본 보존, 구버전 당초 및 중복 코드 거부 확인. 실제 Edge 화면에서 호표 복원과 실행 취소, 자동계산 300→200 및 서버 재계산 결과 보존을 확인했고 기초단가·설계변경·행 조작 시험 39개 통과. 당초 단가 자체가 아니라 산식을 복원하며, 자동계산 시 현재 기초단가를 사용한다.
- [x] 기계경비 설계변경에서 당초 대비 신규·변경·삭제 항목을 표시한다.
- 자체 검증: 기계경비 신규·변경·삭제 금액 및 상태 시험 포함 기초단가 시험 7개 통과. 실제 Edge에서 신규·삭제 행 표시와 삭제된 호표 열기 버튼 미표시 확인.
- [x] 삭제한 기계경비 호표를 당초 구성에서 되살리고 기존 호표의 식별자를 보존한다.
- 자체 검증: 원래 순서와 식별자 보존, 충돌 거부, JSON 재열기 후 연결 내역 재계산 및 실제 Edge에서 삭제 복원·실행 취소 확인. 기계경비 관련 시험 5개 통과.
- 추가 회귀: 기초단가·설계변경·물가변동·기성·수량·서버 감시 시험 65개 통과.
- 구버전 당초 사본에 기계 호표가 없는 경우 현재 호표와 기존 당초 필드를 보존하며, 초기화 확인창에 사본에 없는 자료가 유지됨을 표시한다. 기계 당초·행 조작 시험 16개 통과.
- 기계 호표 당초 사본에는 기존 `_` 필드도 보관하며 설계변경 초기화 시 원래 값을 유지한다. 기계 당초·기초단가 시험 10개 통과.
- [x] 기성 회차 초기화가 선택한 회차만 변경하도록 보호하고 오류 시 입력 보존을 확인한다.
- 자체 검증: 존재하지 않는 회차를 선택했을 때 첫 회차를 초기화하지 않고 오류를 표시하며 기존 입력이 보존되는 회귀 시험 통과 (`test_reset_progress_inputs_action`, 1 passed).
- [x] 설계변경 초기화 시 수량 연결 대상을 실제 수량 반영 규칙과 동일하게 검사한다.
@@ -430,6 +466,12 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- [x] 확정된 전회 기성 결과가 없을 때 금회 누계를 잘못 확정하지 않도록 보호한다.
- 자체 검증: 전회 결과가 없는 확정 회차 다음 계산에 오류를 표시하고 확정을 거부한다. 기성·행 조작 시험 24개 통과.
- 추가 회귀: 설계변경·기성·물가변동·연차 관련 시험 79개 통과(기성·수량·행 조작 35개, 업무·설계변경·물가변동·연차 44개).
- [x] 전회 기성 원가 결과가 누락된 경우 전회 원가 누계가 0원으로 바뀌지 않도록 보호한다.
- 자체 검증: 이전 회차의 원가 결과가 없을 때 계산 오류를 표시하고 확정을 거부한다. 기성 시험 11개 통과.
- [x] 원가 초기화 계산 실패 시 수식·금액·고정 옵션을 보존한다.
- 자체 검증: 순환 원가 수식에서 초기화가 실패해도 작업본과 고정 옵션이 변하지 않으며 정상 초기화도 통과한다 (`test_reset_wonga`, 2 passed).
- [x] 자재 부가세 계산의 절상 선택이 단단위 미만 금액을 올바르게 올리는지 확인한다.
- 자체 검증: 십원 미만 나머지가 작은 값과 정확한 십원 경계의 절상을 확인했다 (`test_vat`, 2 passed).
- [x] 일위대가 상세 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 원천 불일치·다른 호표 보존 시험.
- 자체 검증: 관련 시험 33개 통과. Tailscale HTTP Edge에서 자동·수동 계산, 다른 호표 보존, 원천 단가 불일치 시 복원 차단 및 화면 오류 0 확인.
- [x] 원천 재조회가 꺼진 내역의 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 미연결·다른 행 보존 시험.
+4
View File
@@ -6,6 +6,10 @@
내역·일위대가·수식 단가산출·기계경비·기초 단가·원가계산서를 원본 테이블 구조로 편집합니다. 수량 연결, 설계변경 당초 보존, 낙찰률, 기성 회차와 사정 누계, 품목조정, 연차 배분, 소요 자원 전개, 예정공정표 화면을 제공합니다. 실행 취소·다시 실행, 검색, 행 편집, JSON 저장·열기, CSV·Excel·조달청 XML·JDL 기본 표 입출력과 브라우저 인쇄를 사용할 수 있습니다.
설계변경·기성·물가변동 내역은 100행씩 표시하며 코드와 품명으로 검색할 수 있습니다. 기성·물가변동은 규격과 단위도 검색합니다. 페이지와 검색은 화면 표시만 바꾸며 전체 내역 계산에는 영향을 주지 않습니다.
새로 당초를 확정하면 기계경비 호표 구성도 보관합니다. 기계경비 사용 내역에서 선택한 호표의 당초 산식을 복원하거나 설계변경 화면에서 삭제된 호표를 되살릴 수 있습니다. 자동계산이 켜져 있으면 현재 기초단가로 다시 계산합니다. 이전에 확정한 자료에는 호표 원본이 없을 수 있습니다.
작업모드의 `자료 오류 검색`은 코드 중복, 연결 자료 누락, 연결된 품명·규격 차이와 계산 오류를 표시합니다. 항목의 `행 열기`로 해당 자료 위치로 이동할 수 있습니다. 품명·규격 차이는 사용자가 확인할 항목으로 표시합니다.
작업모드의 `표준시장단가 사용 내역`은 표준시장단가에 연결된 내역별 적용 단가와 현재 기초 단가, 차이와 연결 상태를 보여 줍니다. 원래 내역으로 이동하거나 해당 표를 인쇄·Excel로 출력할 수 있습니다.
+8
View File
@@ -13,6 +13,7 @@
<script defer src="native-quantity.cjs"></script>
<script defer src="native-wonga.js"></script>
<script defer src="native-engine.js"></script>
<script defer src="native-row-match.js"></script>
<script defer src="native-escalation.js"></script>
<script defer src="native-progress.js"></script>
<script defer src="native-business.js"></script>
@@ -175,6 +176,13 @@
<main>
<div id="messages" role="alert" hidden></div>
<div class="work-title"><b id="view-title">내역서</b><span id="selected-info"></span></div>
<input
id="business-search"
type="search"
aria-label="업무 내역 검색"
placeholder="품명·규격·코드 검색"
hidden
/>
<div id="standard-tools" class="row-tools">
<button id="add-group" data-action="add-group" hidden>묶음 추가</button
><button id="haljung-calc-btn" data-action="haljung-calc" hidden>할증계산</button
+9
View File
@@ -217,6 +217,7 @@ const EstApp = (() => {
state.selection.clear();
state.group = "";
state.page = 0;
state.progressPage = state.escalationPage = state.changePage = 0;
state.quantitySheet = state.project.quantitySheets?.[0]?.Code || "manual";
state.roundId =
state.project.progress.find((round) => round.id === state.roundId)?.id ||
@@ -260,6 +261,10 @@ const EstApp = (() => {
}
function render() {
if (!state.project) return;
if (state.query !== state.previousBusinessQuery) {
state.progressPage = state.escalationPage = state.changePage = 0;
state.previousBusinessQuery = state.query;
}
const active = document.activeElement,
scroll = byId("work-content")?.scrollLeft || 0;
const focus = active?.dataset.table
@@ -394,6 +399,10 @@ const EstApp = (() => {
}
if (!saved) saved = await loadFromStorage(LEGACY_CACHE);
setProject(saved ? JSON.parse(saved) : await api("/api/sample"));
byId("business-search").addEventListener("input", (event) => {
state.query = event.target.value;
render();
});
EstActions.install();
EstQuantity.install();
EstReview.install();
+89 -18
View File
@@ -341,19 +341,20 @@ const EstBlockActions = (() => {
}
if (
!confirm(
"설계변경 작업을 초기화하고 당초 내역 상태로 되돌리시겠습니까?\n(입력된 변경 수량 및 단가가 모두 당초 상태로 환원됩니다)",
"설계변경 작업을 초기화하고 당초 내역 상태로 되돌리시겠습니까?\n당초 사본에 포함되지 않은 기계·기초단가 자료는 현재 상태로 유지됩니다.",
)
) {
return;
}
// 설계변경 대상 7개 테이블 목록 (EstBusiness.baseline 스펙 일치)
// 설계변경 당초 사본에 포함된 테이블만 복원한다.
const changeTables = [
"EstMainDat",
"HeadMainDat",
"HeadTitleDat",
"CostMainDat",
"CostTitleDat",
"MachMainDat",
"MachTitleDat",
"Wonga",
];
@@ -381,7 +382,7 @@ const EstBlockActions = (() => {
];
EstApp.change(() => {
// 1) baseline.tables에 존재하는 7개 대상 테이블만 당초 상태로 완전 복원 (신규/삭제 행 및 수량/단가 완벽 복원)
// 1) baseline.tables에 존재하는 대상 테이블만 복원한다.
for (const tName of changeTables) {
if (project.baseline.tables[tName]) {
project.tables[tName] = structuredClone(project.baseline.tables[tName]);
@@ -390,7 +391,8 @@ const EstBlockActions = (() => {
// 2) 대상 테이블의 행들에서만 설계변경 관련 _ 접미사 필드 제거 (다른 업무 원본 필드, _origLinkIndex, _prevSCheck 등 완벽 보존)
for (const tName of changeTables) {
const rows = project.tables[tName];
const rows =
tName !== "MachMainDat" && project.baseline.tables[tName] && project.tables[tName];
if (!Array.isArray(rows)) continue;
for (const row of rows) {
row.NewItem = false;
@@ -422,22 +424,24 @@ const EstBlockActions = (() => {
return;
}
const direct = state.project.calculation?.direct || {};
const bases = {
NC: Number(direct.NC) || 0,
GC: Number(direct.GC) || 0,
JC: Number(direct.JC) || 0,
SC: Number(direct.SC) || 0,
TC: Number(direct.TC) || 0,
노무비: Number(direct.NC) || 0,
재료비: Number(direct.GC) || 0,
경비: Number(direct.JC) || 0,
실적: Number(direct.SC) || 0,
직접공사비: Number(direct.TC) || 0,
};
const restored = structuredClone(rows);
EstWonga.resetWonga(restored, state.project.options, bases);
EstApp.change(() => {
const direct = state.project.calculation?.direct || {};
const bases = {
NC: Number(direct.NC) || 0,
GC: Number(direct.GC) || 0,
JC: Number(direct.JC) || 0,
SC: Number(direct.SC) || 0,
TC: Number(direct.TC) || 0,
노무비: Number(direct.NC) || 0,
재료비: Number(direct.GC) || 0,
경비: Number(direct.JC) || 0,
실적: Number(direct.SC) || 0,
직접공사비: Number(direct.TC) || 0,
};
state.project.tables.Wonga = restored;
state.project.options.TotalFix = false;
EstWonga.resetWonga(rows, state.project.options, bases);
});
state.notice = "원가계산서 항목이 초기화되었습니다.";
}
@@ -549,8 +553,72 @@ const EstBlockActions = (() => {
});
state.notice = `${selected.length}개 행의 레벨이 일괄 ${delta > 0 ? "증가" : "감소"}되었습니다.`;
}
function pageRows(state, rows, key, eligible) {
const query = String(state.query || "")
.trim()
.toLowerCase();
const filtered = rows
.map((row, index) => ({ row, index }))
.filter(
({ row }) =>
eligible(row) &&
(!query ||
["Code", "Name", "Size", "Unite"].some((field) =>
String(row[field] ?? "")
.toLowerCase()
.includes(query),
)),
);
const pages = Math.max(1, Math.ceil(filtered.length / 100));
state[key] = Math.max(0, Math.min(Number(state[key]) || 0, pages - 1));
return {
rows: filtered.slice(state[key] * 100, state[key] * 100 + 100),
pages,
total: filtered.length,
};
}
function progressPage(state, rows) {
return pageRows(
state,
rows,
"progressPage",
(row) =>
!EstMath.num(row.Level) &&
(row.Code || row.Name || [54, 55, 57, 58, 62, 67].includes(Number(row.LinkIndex))),
);
}
function escalationPage(state, rows) {
return pageRows(
state,
rows,
"escalationPage",
(row) =>
(row.Code || row.Name) &&
!EstMath.num(row.Level) &&
![54, 55, 58, 59].includes(Number(row.LinkIndex)),
);
}
function changePage(state, rows) {
return pageRows(state, rows, "changePage", (row) => row.Code || row.Name);
}
function handle(name, state) {
if (name === "progress-prev" || name === "progress-next") {
state.progressPage = (Number(state.progressPage) || 0) + (name === "progress-next" ? 1 : -1);
EstApp.render();
return true;
}
if (name === "escalation-prev" || name === "escalation-next") {
state.escalationPage =
(Number(state.escalationPage) || 0) + (name === "escalation-next" ? 1 : -1);
EstApp.render();
return true;
}
if (name === "change-prev" || name === "change-next") {
state.changePage = (Number(state.changePage) || 0) + (name === "change-next" ? 1 : -1);
EstApp.render();
return true;
}
if (name === "block-start") {
setBlockStart(state);
return true;
@@ -624,6 +692,9 @@ const EstBlockActions = (() => {
resetWongaAction,
resetChangeAction,
adjustLevels,
progressPage,
escalationPage,
changePage,
handle,
};
})();
+14 -16
View File
@@ -265,13 +265,15 @@ const EstBusiness = (() => {
"HeadTitleDat",
"CostMainDat",
"CostTitleDat",
"MachMainDat",
"MachTitleDat",
"Wonga",
]) {
tables[table] = (project.tables[table] || []).map((row) => {
const values = Object.fromEntries(
Object.entries(row).filter(([key]) => !key.endsWith("_")),
);
const values =
table === "MachMainDat"
? row
: Object.fromEntries(Object.entries(row).filter(([key]) => !key.endsWith("_")));
return copy(values);
});
}
@@ -302,15 +304,16 @@ const EstBusiness = (() => {
}
function changes(project) {
const old = project.baseline?.tables.EstMainDat || [];
const ids = EstRowMatch.identities(old, project.tables.EstMainDat, rowKey);
const groups = new Map();
old.forEach((row, index) => {
const key = rowKey(row, index),
const key = ids.original[index],
group = groups.get(key) || [];
group.push({ row, index });
groups.set(key, group);
});
const changes = project.tables.EstMainDat.map((row, index) => {
const saved = groups.get(rowKey(row, index))?.shift()?.row;
const saved = groups.get(ids.current[index])?.shift()?.row;
const stored =
!project.baseline && ["Code_", "Name_", "GN_", "TC_"].some((key) => row[key] != null)
? Object.fromEntries(
@@ -384,17 +387,12 @@ const EstBusiness = (() => {
}));
}
const old = project.baseline.tables.Wonga || [];
function keyed(rows) {
const counts = new Map();
return rows.map((row) => {
const identity = JSON.stringify(row.Gubun ? ["code", row.Gubun] : ["name", row.Name || ""]);
const occurrence = counts.get(identity) || 0;
counts.set(identity, occurrence + 1);
return { row, key: JSON.stringify([identity, occurrence]) };
});
}
const previous = new Map(keyed(old).map(({ row, key }) => [key, row]));
const rows = keyed(current).map(({ row, key }) => {
const identify = (row) =>
JSON.stringify(row.Gubun ? ["code", row.Gubun] : ["name", row.Name || ""]);
const ids = EstRowMatch.identities(old, current, identify);
const previous = new Map(old.map((row, index) => [ids.original[index], row]));
const rows = current.map((row, index) => {
const key = ids.current[index];
const saved = previous.get(key);
previous.delete(key);
return {
+3 -11
View File
@@ -16,17 +16,9 @@ const EstEscalation = (() => {
if (row[field + "_"] != null) old[field] = row[field + "_"];
return old;
});
const identities = (items) => {
const counts = new Map();
return items.map((row, index) => {
const id = key(row, index),
occurrence = counts.get(id) || 0;
counts.set(id, occurrence + 1);
return JSON.stringify([id, occurrence]);
});
};
const originalKeys = identities(original),
rowKeys = identities(rows);
const identities = EstRowMatch.identities(original, rows, key),
originalKeys = identities.original,
rowKeys = identities.current;
const contracts = new Map(original.map((row, index) => [originalKeys[index], row]));
const design = structuredClone(original),
excluded = structuredClone(original);
+94 -95
View File
@@ -84,27 +84,29 @@ const EstExtra = (() => {
);
} else if (view === "change") {
const changes = EstBusiness.changes(project);
const page = EstBlockActions.changePage(state, changes);
html = box(
"설계변경",
button("capture-baseline", "현재 내역을 당초로 확정") +
button("load-contract", "당초·계약 내역 불러오기") +
button("recalculate-original", "당초 단가 재계산") +
button("bid-rates", "낙찰률 적용") +
button("restore-bid", "당초단가(100%) 복원"),
button("restore-bid", "당초단가(100%) 복원") +
button("change-prev", "이전", state.changePage === 0 ? "disabled" : "") +
`<span>${state.changePage + 1} / ${page.pages}쪽 · ${page.total}행</span>` +
button("change-next", "다음", state.changePage + 1 >= page.pages ? "disabled" : ""),
table(
["구분", "코드", "품명", "당초 수량", "변경 수량", "당초 금액", "변경 금액", "증감"],
changes
.filter((row) => row.Code || row.Name)
.map((row) => [
esc({ new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" }[row.status]),
esc(row.Code),
esc(row.Name),
number(row.originalGN),
number(row.GN),
number(row.originalTC),
number(row.TC),
number(row.difference),
]),
page.rows.map(({ row }) => [
esc({ new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" }[row.status]),
esc(row.Code),
esc(row.Name),
number(row.originalGN),
number(row.GN),
number(row.originalTC),
number(row.TC),
number(row.difference),
]),
) +
table(
["원가 구분", "비목", "당초 금액", "변경 금액", "증감"],
@@ -150,6 +152,11 @@ const EstExtra = (() => {
output = { rows: [], errors: [e.message] };
}
state.business = output;
const page = EstBlockActions.progressPage(state, output.rows);
tools +=
button("progress-prev", "이전", state.progressPage === 0 ? "disabled" : "") +
`<span>${state.progressPage + 1} / ${page.pages}쪽 · ${page.total}행</span>` +
button("progress-next", "다음", state.progressPage + 1 >= page.pages ? "disabled" : "");
body =
table(
[
@@ -166,58 +173,49 @@ const EstExtra = (() => {
"누계수량",
"누계금액",
],
output.rows
.map((row, index) => ({ row, index }))
.filter(
({ row }) =>
!EstMath.num(row.Level) &&
(row.Code ||
row.Name ||
[54, 55, 57, 58, 62, 67].includes(Number(row.LinkIndex))),
)
.map(({ row, index }) => {
const editable = ![54, 55, 58, 59, 62, 67].includes(Number(row.LinkIndex));
const key = EstBusiness.rowKey(row, index),
input = round.inputs?.[key] || {};
const edit = (prefix) =>
`<input data-progress="${prefix}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix] ?? row[prefix + "GN"] ?? 0}" ${round.locked || !editable || input[prefix + "Mode"] === "components" ? "disabled" : ""}>` +
(input[prefix + "Mode"] === "components"
? EstMath.components
.map(
(field, i) =>
`<label>${["노무", "재료", "경비", "실적"][i]}<input data-progress="${prefix + field}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix + field] ?? 0}" ${round.locked ? "disabled" : ""}></label>`,
)
.join("")
: "");
const method = (prefix) =>
!editable
? "자동계산"
: `<select data-progress="${prefix}Mode" data-key="${esc(key)}" ${round.locked ? "disabled" : ""}>${[
["quantity", "수량"],
["amount", "금액"],
["ratio", "비율 %"],
["components", "비목 금액"],
]
.map(
([v, label]) =>
`<option value="${v}" ${v === (input[`${prefix}Mode`] || "quantity") ? "selected" : ""}>${label}</option>`,
)
.join("")}</select>`;
return [
esc(row.Code),
esc(row.Name),
number(row.GN),
number(row.JunGN),
edit("Gum"),
method("Gum"),
edit("Sa"),
method("Sa"),
number(row.GumTC),
number(row.SaTC),
number(row.TotalGN),
number(row.TotalTC),
];
}),
page.rows.map(({ row, index }) => {
const editable = ![54, 55, 58, 59, 62, 67].includes(Number(row.LinkIndex));
const key = EstBusiness.rowKey(row, index),
input = round.inputs?.[key] || {};
const edit = (prefix) =>
`<input data-progress="${prefix}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix] ?? row[prefix + "GN"] ?? 0}" ${round.locked || !editable || input[prefix + "Mode"] === "components" ? "disabled" : ""}>` +
(input[prefix + "Mode"] === "components"
? EstMath.components
.map(
(field, i) =>
`<label>${["노무", "재료", "경비", "실적"][i]}<input data-progress="${prefix + field}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix + field] ?? 0}" ${round.locked ? "disabled" : ""}></label>`,
)
.join("")
: "");
const method = (prefix) =>
!editable
? "자동계산"
: `<select data-progress="${prefix}Mode" data-key="${esc(key)}" ${round.locked ? "disabled" : ""}>${[
["quantity", "수량"],
["amount", "금액"],
["ratio", "비율 %"],
["components", "비목 금액"],
]
.map(
([v, label]) =>
`<option value="${v}" ${v === (input[`${prefix}Mode`] || "quantity") ? "selected" : ""}>${label}</option>`,
)
.join("")}</select>`;
return [
esc(row.Code),
esc(row.Name),
number(row.GN),
number(row.JunGN),
edit("Gum"),
method("Gum"),
edit("Sa"),
method("Sa"),
number(row.GumTC),
number(row.SaTC),
number(row.TotalGN),
number(row.TotalTC),
];
}),
) +
`<p class="business-note">${esc((output.errors || []).join(" / "))} 금회 도급액 ${number(output.total)} · 기성비율 ${number(output.ratio)}%</p>`;
body += table(
@@ -248,36 +246,30 @@ const EstExtra = (() => {
`<option value="${key}" ${key === comp ? "selected" : ""}>${["노무", "재료", "경비", "실적"][i]}</option>`,
)
.join("");
const rows = project.tables.EstMainDat.map((row, index) => ({ row, index }))
.filter(
({ row }) =>
(row.Code || row.Name) &&
!EstMath.num(row.Level) &&
![54, 55, 58, 59].includes(Number(row.LinkIndex)),
)
.map(({ row, index }) => {
const result = output.rows[index];
return [
esc(row.Code),
esc(row.Name),
EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]),
number(result[`Contract${comp}`]),
EstViews.cell("EstMainDat", index, comp, row[comp]),
number(result[`D${comp}`]),
number(result[`E${comp}`]),
number(result[`F${comp}`]),
EstViews.cell("EstMainDat", index, "EscPlanned", row.EscPlanned || 0),
EstViews.cell("EstMainDat", index, "EscActual", row.EscActual || 0),
EstViews.cell("EstMainDat", index, "EscProgress", row.EscProgress || 0),
number(result.EscExcluded),
number(result.EscApplied),
`<input type="checkbox" data-esc-auto="${index}" ${row.EscAuto !== false ? "checked" : ""} aria-label="적용제외 자동계산">`,
EstViews.cell("EstMainDat", index, "EscManualQuantity", row.EscManualQuantity ?? 0),
EstViews.cell("EstMainDat", index, "EscConstruction", row.EscConstruction ?? 0),
EstViews.cell("EstMainDat", index, "EscPS", row.EscPS ?? 0),
EstViews.cell("EstMainDat", index, "EscBefore", row.EscBefore ?? 0),
];
});
const page = EstBlockActions.escalationPage(state, project.tables.EstMainDat);
const rows = page.rows.map(({ row, index }) => {
const result = output.rows[index];
return [
esc(row.Code),
esc(row.Name),
EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]),
number(result[`Contract${comp}`]),
EstViews.cell("EstMainDat", index, comp, row[comp]),
number(result[`D${comp}`]),
number(result[`E${comp}`]),
number(result[`F${comp}`]),
EstViews.cell("EstMainDat", index, "EscPlanned", row.EscPlanned || 0),
EstViews.cell("EstMainDat", index, "EscActual", row.EscActual || 0),
EstViews.cell("EstMainDat", index, "EscProgress", row.EscProgress || 0),
number(result.EscExcluded),
number(result.EscApplied),
`<input type="checkbox" data-esc-auto="${index}" ${row.EscAuto !== false ? "checked" : ""} aria-label="적용제외 자동계산">`,
EstViews.cell("EstMainDat", index, "EscManualQuantity", row.EscManualQuantity ?? 0),
EstViews.cell("EstMainDat", index, "EscConstruction", row.EscConstruction ?? 0),
EstViews.cell("EstMainDat", index, "EscPS", row.EscPS ?? 0),
EstViews.cell("EstMainDat", index, "EscBefore", row.EscBefore ?? 0),
];
});
html = box(
"물가변동 품목조정",
`<select id="escalation-component">${choices}</select>` +
@@ -287,6 +279,13 @@ const EstExtra = (() => {
button("load-progress-exclusion", "기성 누계수량 불러오기") +
button("reset-escalation", "물가변동 입력 초기화") +
button("capture-baseline", "계약 내역 확정") +
button("escalation-prev", "이전", state.escalationPage === 0 ? "disabled" : "") +
`<span>${state.escalationPage + 1} / ${page.pages}쪽 · ${page.total}행</span>` +
button(
"escalation-next",
"다음",
state.escalationPage + 1 >= page.pages ? "disabled" : "",
) +
`<label>조사 도급액(0=단가로 계산) <input type="number" step="any" data-option="EscDesignTotal" value="${project.options.EscDesignTotal ?? 0}"></label>`,
table(
[
+163 -9
View File
@@ -135,12 +135,25 @@ const EstMachine = (() => {
function usage(project) {
const titles = project.tables?.MachTitleDat || [];
const grps = EstNative.groups(project.tables?.MachMainDat || []);
const original = EstNative.groups(project.baseline?.tables?.MachMainDat || []);
const oldTitles = project.baseline?.tables?.MachTitleDat || [];
const oldByCode = new Map();
for (const row of oldTitles) {
const rows = oldByCode.get(row.Code) || [];
rows.push(row);
oldByCode.set(row.Code, rows);
}
return titles.map((title, index) => {
const code = title.Code || "";
const group = grps.get(code);
const appliedPrice = EstMath.num(title.TC);
const hasOriginal = title.TC_ != null;
const originalPrice = hasOriginal ? EstMath.num(title.TC_) : null;
const saved = oldByCode.get(code)?.length === 1 ? oldByCode.get(code)[0] : null;
const hasOriginal = Boolean(saved) || title.TC_ != null;
const originalPrice = saved
? EstMath.num(saved.TC)
: hasOriginal
? EstMath.num(title.TC_)
: null;
const difference = hasOriginal ? appliedPrice - originalPrice : null;
return {
index,
@@ -157,6 +170,7 @@ const EstMachine = (() => {
detailCount: group ? group.rows.length : 0,
status: !hasOriginal ? "신규(당초없음)" : Math.abs(difference) > 1e-6 ? "당초차이" : "일치",
canApply: false, // 당초 계약값은 비교용이며 현재 단가 덮어쓰기 적용을 지원하지 않음
canRestore: Boolean(group && original.has(code)),
};
});
}
@@ -164,14 +178,28 @@ const EstMachine = (() => {
function changes(project) {
const labels = { new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" };
const titles = project.tables?.MachTitleDat || [];
return titles.map((title, index) => {
const old = project.baseline?.tables?.MachTitleDat || [];
const oldGroups = EstNative.groups(project.baseline?.tables?.MachMainDat || []);
const identities = EstRowMatch.identities(old, titles, (row) => String(row.Code || ""));
const previous = new Map(old.map((row, index) => [identities.original[index], row]));
const result = titles.map((title, index) => {
const appliedPrice = EstMath.num(title.TC);
const hasOriginal = title.TC_ != null;
const originalPrice = hasOriginal ? EstMath.num(title.TC_) : null;
const saved = previous.get(identities.current[index]);
previous.delete(identities.current[index]);
const hasOriginal = Boolean(saved) || title.TC_ != null;
const originalPrice = saved
? EstMath.num(saved.TC)
: hasOriginal
? EstMath.num(title.TC_)
: null;
const difference = hasOriginal ? appliedPrice - originalPrice : null;
const statusKey = !hasOriginal
? "new"
: Math.abs(difference) > 1e-6
: Math.abs(difference) > 1e-6 ||
(saved &&
["Name", "Size", "Unite"].some(
(field) => (saved[field] || "") !== (title[field] || ""),
))
? "changed"
: "unchanged";
return {
@@ -186,6 +214,20 @@ const EstMachine = (() => {
status: labels[statusKey] || statusKey,
};
});
for (const title of previous.values())
result.push({
index: null,
Code: title.Code || "",
Name: title.Name || "",
Size: title.Size || "",
Unite: title.Unite || "",
originalTC: EstMath.num(title.TC),
TC: 0,
difference: -EstMath.num(title.TC),
status: labels.deleted,
canRecover: oldGroups.has(title.Code),
});
return result;
}
function applyRowPrice() {
@@ -200,6 +242,77 @@ const EstMachine = (() => {
);
}
function ensureRestoredIds(saved, existing) {
const ids = saved.map((row) => row.PkID).filter((id) => id != null);
const unique = new Set(ids);
if (unique.size !== ids.length || existing.some((row) => unique.has(row.PkID)))
throw Error("기존 호표의 식별자와 충돌하여 복원할 수 없습니다.");
}
function restoreGroup(project, code) {
const current = project.tables?.MachMainDat || [],
baseline = project.baseline?.tables?.MachMainDat;
if (!Array.isArray(baseline))
throw Error("기계 호표의 당초 구성이 없습니다. 당초를 다시 확정하세요.");
const count = (rows) => rows.filter((row) => Number(row.Index) > 0 && row.Code === code).length;
if (!code || count(current) !== 1 || count(baseline) !== 1)
throw Error("복원할 기계 호표가 없거나 코드가 중복됩니다.");
const from = EstNative.groups(baseline).get(code),
to = EstNative.groups(current).get(code);
ensureRestoredIds(from.rows, [
...current.slice(0, to.start),
...current.slice(to.start + to.rows.length),
]);
return [
...current.slice(0, to.start),
...structuredClone(from.rows),
...current.slice(to.start + to.rows.length),
];
}
function recoverGroup(project, code) {
const baseline = project.baseline?.tables;
const original = baseline?.MachMainDat,
oldTitles = baseline?.MachTitleDat;
const current = project.tables.MachMainDat,
titles = project.tables.MachTitleDat;
if (!Array.isArray(original) || !Array.isArray(oldTitles))
throw Error("기계 호표의 당초 구성이 없습니다.");
const originals = original.filter((row) => Number(row.Index) > 0 && row.Code === code);
const savedTitles = oldTitles.filter((row) => row.Code === code);
if (
!code ||
originals.length !== 1 ||
savedTitles.length !== 1 ||
current.some((row) => Number(row.Index) > 0 && row.Code === code) ||
titles.some((row) => row.Code === code)
)
throw Error("복원할 호표가 없거나 코드가 중복됩니다.");
const group = EstNative.groups(original).get(code).rows;
ensureRestoredIds(group, current);
ensureRestoredIds(savedTitles, titles);
const position = (saved, existing, isHeader) => {
const at = saved.findIndex((row) => isHeader(row) && row.Code === code);
const positions = new Map();
existing.forEach((row, index) => {
if (isHeader(row) && !positions.has(row.Code)) positions.set(row.Code, index);
});
for (const row of saved.slice(at + 1))
if (isHeader(row) && positions.has(row.Code)) return positions.get(row.Code);
return existing.length;
};
const rowAt = position(original, current, (row) => Number(row.Index) > 0);
const titleAt = position(oldTitles, titles, () => true);
return {
rows: [...current.slice(0, rowAt), ...structuredClone(group), ...current.slice(rowAt)],
titles: [
...titles.slice(0, titleAt),
structuredClone(savedTitles[0]),
...titles.slice(titleAt),
],
};
}
function render() {
const { state, esc, number, byId } = EstApp,
isChange = state.view === "machine-change",
@@ -209,15 +322,46 @@ const EstMachine = (() => {
value = (v) => (v == null ? "" : number(v));
if (isChange) {
byId("work-content").innerHTML =
`<div class="business-tools"><b>기계경비 설계변경</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-machine-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-machine-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><table class="sheet"><thead><tr><th>구분</th><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>당초 금액</th><th>변경 금액</th><th>증감</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.status)}</td><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.originalTC)}</td><td>${value(row.TC)}</td><td>${value(row.difference)}</td><td><button data-machine-group="${esc(row.Code)}">호표 열기</button></td></tr>`).join("")}</tbody></table>`;
`<div class="business-tools"><b>기계경비 설계변경</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-machine-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-machine-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><table class="sheet"><thead><tr><th>구분</th><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>당초 금액</th><th>변경 금액</th><th>증감</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.status)}</td><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.originalTC)}</td><td>${value(row.TC)}</td><td>${value(row.difference)}</td><td>${row.index == null ? (row.canRecover ? `<button data-machine-recover="${esc(row.Code)}">당초 호표 복원</button>` : "") : `<button data-machine-group="${esc(row.Code)}">호표 열기</button>`}</td></tr>`).join("")}</tbody></table>`;
return;
}
byId("work-content").innerHTML =
`<div class="business-tools"><b>기계경비 사용 내역</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-machine-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-machine-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><p>기계경비 호표의 산출 단가와 당초 계약 단가를 비교합니다. (기계 호표 단가는 MachMainDat 산출 공식에 의해 결정되며 당초값 덮어쓰기는 지원하지 않습니다.)</p><table class="sheet"><thead><tr><th>코드</th><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>${visible.map((row) => `<tr><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.appliedPrice)}</td><td>${value(row.originalPrice)}</td><td>${value(row.difference)}</td><td>${esc(row.status)}</td><td>${value(row.NC)}</td><td>${value(row.GC)}</td><td>${value(row.JC)}</td><td><button data-machine-group="${esc(row.Code)}">호표 열기</button></td></tr>`).join("")}</tbody></table>`;
`<div class="business-tools"><b>기계경비 사용 내역</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-machine-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-machine-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><p>자동계산이 켜져 있으면 당초 산식 복원 후 현재 기초단가로 다시 계산합니다.</p><table class="sheet"><thead><tr><th>코드</th><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>${visible.map((row) => `<tr><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.appliedPrice)}</td><td>${value(row.originalPrice)}</td><td>${value(row.difference)}</td><td>${esc(row.status)}</td><td>${value(row.NC)}</td><td>${value(row.GC)}</td><td>${value(row.JC)}</td><td><button data-machine-group="${esc(row.Code)}">호표 열기</button>${row.canRestore ? ` <button data-machine-restore="${esc(row.Code)}">당초 산식 복원</button>` : ""}</td></tr>`).join("")}</tbody></table>`;
}
function install() {
document.addEventListener("click", (event) => {
const recover = event.target.closest("[data-machine-recover]");
if (recover) {
try {
const result = recoverGroup(EstApp.state.project, recover.dataset.machineRecover);
if (confirm("삭제한 기계 호표를 당초 구성으로 복원하시겠습니까?"))
EstApp.change(() => {
EstApp.state.project.tables.MachMainDat = result.rows;
EstApp.state.project.tables.MachTitleDat = result.titles;
});
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
}
return;
}
const restore = event.target.closest("[data-machine-restore]");
if (restore) {
try {
const rows = restoreGroup(EstApp.state.project, restore.dataset.machineRestore);
if (
confirm(
"당초 기계 호표 구성을 복원하시겠습니까? 자동계산이 켜져 있으면 현재 기초단가로 다시 계산합니다.",
)
)
EstApp.change(() => (EstApp.state.project.tables.MachMainDat = rows));
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
}
return;
}
const step = event.target.closest("[data-machine-page]");
if (step) {
page += Number(step.dataset.machinePage);
@@ -232,5 +376,15 @@ const EstMachine = (() => {
});
}
return { calculate, usage, changes, applyRowPrice, applyAllPrices, render, install };
return {
calculate,
usage,
changes,
applyRowPrice,
applyAllPrices,
restoreGroup,
recoverGroup,
render,
install,
};
})();
+7
View File
@@ -46,6 +46,13 @@ const EstProgress = (() => {
oldRows = new Map((previous?.rows || []).map((row, index) => [key(row, index), row]));
if (earlier.at(-1)?.locked && !Array.isArray(previous?.rows))
errors.push("확정된 전회 기성 결과가 없습니다. 전회 결과를 확인하세요.");
if (
earlier.at(-1)?.locked &&
source.wonga?.length &&
!Array.isArray(previous?.costs) &&
!Array.isArray(previous?.wonga)
)
errors.push("확정된 전회 기성 원가 결과가 없습니다. 전회 결과를 확인하세요.");
const segments = { Gum: [], Sa: [] };
const separated = round.separateComponents !== false;
rows.forEach((row, index) => {
+41 -17
View File
@@ -141,7 +141,22 @@ const EstQuantity = (() => {
});
return { rows, errors };
}
function totals(project, selected, allowEmpty = false) {
function estimateTargets(project) {
const byKey = new Map(),
byCode = new Map();
project.tables.EstMainDat.forEach((row, index) => {
if (Number(row.Level)) return;
const add = (map, key) => {
const entries = map.get(key) || [];
entries.push(index);
map.set(key, entries);
};
if (row.PkID != null) add(byKey, `id:${row.PkID}`);
add(byCode, row.Code);
});
return { byKey, byCode };
}
function totals(project, selected, allowEmpty = false, targets = estimateTargets(project)) {
let items;
if (selected && selected !== "manual") {
const sheet = project.quantitySheets?.find((s) => s.Code === selected);
@@ -154,20 +169,15 @@ const EstQuantity = (() => {
if (output.errors.length) throw Error(output.errors.join(" / "));
items = output.rows;
}
const { byKey, byCode } = targets;
const quantities = new Map();
for (const item of items) {
const code = item.TargetCode ?? item.LinkCode;
if (!code && !item.TargetKey) continue;
const targets = project.tables.EstMainDat.map((row, index) => ({ row, index })).filter(
({ row }) =>
!Number(row.Level) &&
(item.TargetKey
? row.PkID != null && `id:${row.PkID}` === item.TargetKey
: row.Code === code),
);
const targets = (item.TargetKey ? byKey.get(item.TargetKey) : byCode.get(code)) || [];
if (!targets.length) throw Error(`연결 내역 없음: ${item.TargetKey || code}`);
if (targets.length > 1) throw Error(`연결 내역 중복: ${item.TargetKey || code}`);
const index = targets[0].index;
const index = targets[0];
quantities.set(index, (quantities.get(index) || 0) + Number(item.GN || 0));
}
if (!quantities.size && !allowEmpty) throw Error("연결할 내역코드를 먼저 입력하세요.");
@@ -181,8 +191,9 @@ const EstQuantity = (() => {
new Set(codes).size !== codes.length
)
throw Error("수량산출서 코드가 없거나 중복됩니다.");
const targets = estimateTargets(project);
for (const selected of ["manual", ...codes])
for (const [index, value] of totals(project, selected, true))
for (const [index, value] of totals(project, selected, true, targets))
combined.set(index, (combined.get(index) || 0) + value);
if (!combined.size) throw Error("연결할 내역코드를 먼저 입력하세요.");
return combined;
@@ -192,13 +203,10 @@ const EstQuantity = (() => {
}
function targetSelect(project, item, section, index) {
const { esc } = EstApp;
const rows = project.tables.EstMainDat.filter((row) => row.PkID != null && !Number(row.Level));
return `<select data-quantity-select="${section}" data-index="${index}" aria-label="연결 내역 행"><option value="">내역 행 선택</option>${rows
.map((row) => {
const key = `id:${row.PkID}`;
return `<option value="${esc(key)}" ${item.TargetKey === key ? "selected" : ""}>${esc(row.Name || "내역")} · ${esc(row.Code || "코드 없음")} · ${esc(row.PkID)}</option>`;
})
.join("")}</select>`;
const selected = item.TargetKey
? `<option value="${esc(item.TargetKey)}" selected>${esc(item.TargetCode || "연결 내역")} · ${esc(item.TargetKey)}</option>`
: "";
return `<select data-quantity-select="${section}" data-index="${index}" aria-label="연결 내역 행"><option value="">내역 행 선택</option>${selected}</select>`;
}
function newCode(items) {
const codes = new Set(items.map((row) => row.Code));
@@ -310,6 +318,22 @@ const EstQuantity = (() => {
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>단위</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th><th>작업</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 행 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
}
function install() {
document.addEventListener("focusin", (event) => {
const node = event.target;
if (node.dataset.quantitySelect === undefined || node.dataset.quantityOptionsLoaded) return;
const value = node.value;
const { esc, state } = EstApp;
node.innerHTML =
'<option value="">내역 행 선택</option>' +
state.project.tables.EstMainDat.filter((row) => row.PkID != null && !Number(row.Level))
.map((row) => {
const key = `id:${row.PkID}`;
return `<option value="${esc(key)}">${esc(row.Name || "내역")} · ${esc(row.Code || "코드 없음")} · ${esc(row.PkID)}</option>`;
})
.join("");
node.value = value;
node.dataset.quantityOptionsLoaded = "1";
});
document.addEventListener("change", (event) => {
const node = event.target,
{ state } = EstApp;
+57
View File
@@ -0,0 +1,57 @@
// Match repeated codes by their unique descriptions when both sets agree.
const EstRowMatch = (() => {
function identities(original, current, key) {
const signature = (row) => JSON.stringify([row.Name || "", row.Size || "", row.Unite || ""]);
const groups = (items) => {
const result = new Map();
items.forEach((row, index) => {
const id = key(row, index),
values = result.get(id) || [];
values.push({ index, signature: signature(row) });
result.set(id, values);
});
return result;
};
const oldGroups = groups(original),
newGroups = groups(current),
oldIds = [],
currentIds = [];
for (const id of new Set([...oldGroups.keys(), ...newGroups.keys()])) {
const oldValues = oldGroups.get(id) || [],
currentValues = newGroups.get(id) || [];
if (oldValues.length <= 1 && currentValues.length <= 1) {
const identity = JSON.stringify([id, "single"]);
if (oldValues.length) oldIds[oldValues[0].index] = identity;
if (currentValues.length) currentIds[currentValues[0].index] = identity;
continue;
}
const counts = (values) => {
const result = new Map();
for (const value of values)
result.set(value.signature, (result.get(value.signature) || 0) + 1);
return result;
};
const oldCounts = counts(oldValues),
currentCounts = counts(currentValues);
const unique = (value) =>
oldCounts.get(value.signature) === 1 && currentCounts.get(value.signature) === 1;
for (const value of oldValues.filter(unique))
oldIds[value.index] = JSON.stringify([id, "signature", value.signature]);
for (const value of currentValues.filter(unique))
currentIds[value.index] = JSON.stringify([id, "signature", value.signature]);
const changedCount = oldValues.length !== currentValues.length;
oldValues
.filter((value) => !unique(value))
.forEach((value, index) => {
oldIds[value.index] = JSON.stringify([id, changedCount ? "old" : "remaining", index]);
});
currentValues
.filter((value) => !unique(value))
.forEach((value, index) => {
currentIds[value.index] = JSON.stringify([id, changedCount ? "new" : "remaining", index]);
});
}
return { original: oldIds, current: currentIds };
}
return { identities };
})();
+5 -1
View File
@@ -1,7 +1,7 @@
// REA TFRMCONVERTTOGANGUB (0xf616f0, 0xf61888)
// 자재단가(StuffcDat) 부가세 계산: 포함(*1.1) / 제외(/1.1) 및 단단위 자릿수(사사오입/절하/절상) 처리
const EstVat = (() => {
const { num, fixed } = EstMath;
const { num, fixed, even } = EstMath;
// REA radCalcMethod: 0 = 부가세포함(×1.1), 1 = 부가세제외(÷1.1)
// REA radDigetIndex: 0 = 사사오입(1), 1 = 절하(2), 2 = 절상(3)
@@ -16,6 +16,10 @@ const EstVat = (() => {
// REA 관례: 0이면 원단위(0), n > 0이면 10^n 미만(-n) 또는 소수점
// 일반적으로 단단위계산에서 spnDiget은 자리수를 나타냄. 0이면 정수 원단위(0), n이면 -n 단위 절하/사사오입/절상.
const digits = -Math.max(0, Math.trunc(num(diget)));
if (digetMethod === 2) {
const step = 10 ** -digits;
return Math.ceil(even(scaled * 10000) / 10000 / step) * step;
}
const m = methodMap[digetMethod] ?? 2;
return fixed(scaled, digits, m);
}
+2
View File
@@ -95,6 +95,8 @@ const EstViews = (() => {
.join("") || '<p class="muted">자료가 없습니다. 묶음 추가를 누르세요.</p>';
}
byId("search").value = state.query;
byId("business-search").value = state.query;
byId("business-search").hidden = !["progress", "escalation", "change"].includes(view);
byId("standard-tools").hidden = !project.tables[view];
byId("add-group").hidden = !grouped;
const haljungBtn = byId("haljung-calc-btn");
+4 -3
View File
@@ -233,7 +233,8 @@ const EstWonga = (() => {
}
// 1) 당초 보존값(VSusic_, Cost_, CalcIndex_)이 있는 경우 복원
for (const r of rows) {
const restored = structuredClone(rows);
for (const r of restored) {
if (r.VSusic_ !== undefined) {
r.VSusic = r.VSusic_;
}
@@ -247,14 +248,14 @@ const EstWonga = (() => {
// 2) TotalFix 옵션 해제 및 일반 재계산 수행
const newOptions = { ...options, TotalFix: false };
const calcResult = calculate(rows, newOptions, bases);
const calcResult = calculate(restored, newOptions, bases);
if (calcResult.errors && calcResult.errors.length) {
throw Error(`원가계산서 초기화 재계산 오류: ${calcResult.errors.join("; ")}`);
}
for (let i = 0; i < rows.length; i++) {
if (calcResult.rows[i]) {
rows[i].Cost = calcResult.rows[i].Cost;
Object.assign(rows[i], calcResult.rows[i]);
}
}
+1
View File
@@ -21,6 +21,7 @@ const scripts = [
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
"native-row-match.js",
"native-escalation.js",
"native-progress.js",
"native-business.js",
+45 -31
View File
@@ -1,43 +1,57 @@
// 독립 EST 테스트 서버 감시/감독 프로세스
// EST 서버 감시: 작업 데이터 디렉터리는 감시하지 않는다.
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const base = __dirname;
const serverScript = path.join(base, "server.cjs");
let child,
timer,
restarting = false,
stopping = false;
// data 디렉터리, 로그 파일 등은 제외하고 소스코드 파일만 명시적으로 감시 대상에 포함
const watchArgs = fs
.readdirSync(base)
.filter(
(file) =>
(file.endsWith(".js") ||
file.endsWith(".cjs") ||
file === "index.html" ||
file === "native.css") &&
!file.startsWith(".") &&
file !== "watch-server.cjs",
)
.map((file) => `--watch-path=${path.join(base, file)}`);
function start() {
child = spawn(process.execPath, [path.join(base, "server.cjs")], {
cwd: base,
env: process.env,
stdio: "inherit",
});
child.on("exit", (code, signal) => {
if (stopping) return;
if (restarting) {
restarting = false;
start();
} else {
console.error(`[supervisor] 서버 종료: ${code ?? signal}`);
process.exit(code || 1);
}
});
}
const args = ["--watch", ...watchArgs, serverScript];
console.log("[supervisor] Starting server with watch paths (excluding data/)...");
const child = spawn(process.execPath, args, {
cwd: base,
env: process.env,
stdio: "inherit",
const watcher = fs.watch(base, (event, filename) => {
const name = filename && filename.toString();
if (!name || name === "watch-server.cjs" || !/\.(?:js|cjs|html|css)$/i.test(name)) return;
clearTimeout(timer);
timer = setTimeout(() => {
if (restarting || stopping) return;
restarting = true;
console.log(`[supervisor] 변경 감지: ${name}`);
child.kill();
}, 300);
});
watcher.on("error", (error) => {
console.error(`[supervisor] 감시 오류: ${error.message}`);
stopping = true;
child.kill();
process.exit(1);
});
child.on("exit", (code, signal) => {
console.log(`[supervisor] Server exited with code ${code}, signal ${signal}`);
process.exit(code || 0);
});
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
process.on(sig, () => {
child.kill(sig);
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
process.on(signal, () => {
stopping = true;
clearTimeout(timer);
watcher.close();
child.kill();
process.exit(0);
});
}
start();