1 Commits
11 changed files with 24 additions and 335 deletions
@@ -160,3 +160,25 @@
- M2 고침(안): 가름의 잣대 = 금액(노 · 재 · 경 · 계)보다 **구성 줄(손료계수 · 주연료량 · 조종원 — 품셈 값)** — 원파일은 구성 표 · 엑셀은 「기계경비」 시트(산출근거 줄)에 있음 · 가격 · 유가는 값이라 잣대에서 뺌. - M2 고침(안): 가름의 잣대 = 금액(노 · 재 · 경 · 계)보다 **구성 줄(손료계수 · 주연료량 · 조종원 — 품셈 값)** — 원파일은 구성 표 · 엑셀은 「기계경비」 시트(산출근거 줄)에 있음 · 가격 · 유가는 값이라 잣대에서 뺌.
- M1 = PLAN 816-1 로 진행(요구 770-1). - M1 = PLAN 816-1 로 진행(요구 770-1).
## O. 구글 시트 「수동연결(자재)」 · 「수동연결(기계)」 검토(요구 772 · 시트 = AISLO_이슈관리대장 · 읽기 = 드라이브 커넥터로 xlsx 받아 `tmp/q772_sheet.xlsx`)
기계 30 줄(사용자 메모 대조):
| 갈래 | 줄 | 내용 |
|---|---|---|
| 이미 이어짐(목록이 옛 것 — 815-2 별칭 다섯 합치기 전에 뜬 목록) | 4 | 바이브레이트 → EQ000248 · 버킷준설기 → EQ000425 · 아스팔트페이버(피니셔) 3.0m → EQ000193 · 1.7m → EQ000192 |
| 816 에서 처리 중 | 8 | 덤프트럭 1.0Ton · 굴착기 1.0 · 0.6㎥ · 굴삭기(1.0m3) 둘 · 크레인(트럭) 10 · 15Ton · 엔진톱 18" |
| 메모 글 검색으로 바로 나옴(별칭만 넣으면 됨) | 3 | 덤프자동덮개 15톤 → EQ000078 덤프트럭 자동덮개시설 15톤용 · 24톤 → EQ000080 · 트럭트랙터및트레일러 20톤 → EQ000183 트럭 트랙터 및 평판트레일러 20ton |
| 확인 필요 | 2 | 커터(콘크리트 및 아스팔트용) 320-440mm ↔ EQ000234 320~400(규격 범위 다름) · 크로울러드릴 탑승유압식 ↔ EQ000337 110㎾ / EQ000338 150㎾(가져온 쪽 규격 없음) |
| 우리 표 · 현행 품셈에 없음 | 13 | 발전기 5.5 · 7 · 10kW · 브레이카 0.18㎥ · 모노펌프 · 에어 호스 · 파워트로웰 · 콘크리트펌프용 파이프 여섯(EST 는 기계 손료로 둠) |
- 크레인(트럭) 10 · 15Ton: 사용자 메모 = 트럭탑재형 크레인 · EST 구성 줄 = 크레인(타이어)와 같은 값(2,298 · 136,400 천원 · 3.8ℓ — 트럭탑재형 10ton 은 2,598 · 91,593 천원 · 10.3ℓ) → 사용자 판단 대기.
자재 100 줄(브레인이 한 줄씩 읽음 · 사용자 메모 칸은 비어 있음):
- 우리 줄 하나로 또렷(표기만 다름 — 별칭만 넣으면 됨) 65 줄: 2 · 18 · 26 → MT000467 / 5 · 25 → MT000470 / 19 → MT000472 / 15 · 17 → MT000209 / 16 · 50 · 52 · 20 → MT000018 / 21 · 65 → MT000499 / 3 · 7 · 51 · 53 → MT000393 / 10 → MT009202 / 27 · 63 → MT007111 / 28 → MT005677 / 31 → MT005687 / 35 · 68 → MT006544 / 37 → MT002926 / 62 · 85 → MT002861 / 87 → MT002859 / 40 · 95 → MT003202 / 96 → MT003241 / 42 · 54 → MT005749 / 44 · 94 → MT000481 / 47 → MT000487 / 55 · 74 → MT003163 / 73 → MT003165 / 56 → MT003166 / 66 · 84 → MT000453 / 67 → MT007130 / 71 → MT023493 / 72 → MT023491 / 77 → MT023654 / 78 → MT023655 / 75 → MT002536 / 82 → MT006490 / 86 → MT006540 / 89 → MT015883 / 91 → MT015887 / 90 → MT007025 / 92 → MT007032 / 98 → MT003311 / 100 → MT003372 / 57 → MT003398 / 4 → MT006763 / 49 → MT006665 / 48 → MT006667 / 33 · 79 · 81 → MT006664 / 83 → MT006788 (번호 = 시트 행 차례 · 머리 뺀 1 ~ 100)
- 사람이 골라야 함(우리 줄 여럿 · 규격이 조금 다름) 31 줄: 1 접착제 · 6 결속선(보통철선 대 어닐링철선) · 13 · 14 · 23 · 24 · 58 · 59 종자 · 32 미송(판재) · 45 철물 · 70 어닐링철선 #6 · 36 PVC용접봉 · 69 용접봉(연강용) · 29 BUTTON BIT · 41 표지판지주 3.5M · 64 강관비계 6m · 93 잔디 · 22 에폭시수지 · 46 각재(말뚝) · 76 보조강재 · 97 공사안내표지판 · 99 윙카호스 · 11 · 12 낙석방지책 · 38 혼합골재 · 61 코어비트 · 30 샹크 · 34 유기질퇴비 · 60 우드칩 · 80 부산물비료 · 88 휠라
- 잇지 않음 · 없음 4 줄: 8 파형강관 · 39 이형철근(「별도계상」 = 자리만) · 9 금강사 · 43 떼
- 못 이은 까닭 = 정확 일치만 잇는 규칙(이름 낱말 차례 「유로폼(패널)」 ↔ 「유로폼패널」 · 「내수합판1급」 ↔ 「합판(내수)」 · 규격 표기 12mm ↔ 12t · 차례 뒤집힘 T5×200 ↔ 200×5T · 덧글 KSM · 국산 · 지역 꼬리) · 가까운 줄 제안이 틀린 것도 있음(신나 → 신나무 · 중하중그레이팅 → 다른 치수).
- 구글 시트 직접 쓰기 토큰 만료(invalid_grant) — 사용자가 데스크탑에 있어 랩탑 로그인 창을 못 누름 · 읽기는 드라이브 커넥터로 됨.
@@ -1,56 +0,0 @@
"""EST 테스트 앱 사용자 양식 보관함(원본 양식목록 등록·삭제·열기)."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def node(script: str, *args: str):
out = subprocess.check_output(["node", "-e", script, *args], cwd=ROOT)
return json.loads(out.decode("utf-8"))
def test_store_saves_replaces_lists_and_deletes(tmp_path):
result = node(
"""
const f = require('./tools/est-test/forms-store.cjs'), dir = process.argv[1];
const errors = [];
const t = (fn) => { try { fn(); errors.push('ok'); } catch (e) { errors.push(e.message.slice(0, 6)); } };
f.save(dir, { kind: 'wonga', name: '회사 기본', data: [{ Name: 'a' }] });
f.save(dir, { kind: 'wonga', name: '회사 기본', data: [{ Name: 'b' }] });
f.save(dir, { kind: 'document', name: '표지', form: 1, data: 'x' });
const listed = f.read(dir).map((x) => [x.kind, x.name, x.data]);
t(() => f.save(dir, { kind: 'bad', name: 'x', data: 1 }));
t(() => f.save(dir, { kind: 'wonga', name: ' ', data: 1 }));
t(() => f.save(dir, { kind: 'wonga', name: 'x' }));
t(() => f.remove(dir, 'wonga', '없는 양식'));
f.remove(dir, 'wonga', '회사 기본');
process.stdout.write(JSON.stringify({ listed, errors, left: f.read(dir).map((x) => x.name) }));
""",
str(tmp_path),
)
assert result["listed"] == [["wonga", "회사 기본", [{"Name": "b"}]], ["document", "표지", "x"]]
assert [e[:5] for e in result["errors"]] == ["양식 종류", "양식 이름", "양식 내용", "지울 양식"]
assert result["left"] == ["표지"]
def test_template_drops_amounts_and_rejects_bad_rows():
result = node(
"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({ structuredClone });
vm.runInContext(fs.readFileSync('tools/est-test/native-forms-library.js', 'utf8'), ctx);
const out = vm.runInContext(`(() => {
const rows = EstFormsLibrary.strip([{ Gubun: 'N', Name: '직접 노무비', Cost: 100, VSusic: 'N' }]);
const errors = [];
for (const bad of [[], null, [1]]) { try { EstFormsLibrary.validRows(bad); } catch (e) { errors.push(e.message.slice(0, 9)); } }
return { rows, errors, other: EstFormsLibrary.handle('other', {}, null) };
})()`, ctx);
process.stdout.write(JSON.stringify(out));
"""
)
assert result["rows"] == [{"Gubun": "N", "Name": "직접 노무비", "VSusic": "N"}]
assert len(result["errors"]) == 3
assert result["other"] is False
@@ -1,39 +0,0 @@
"""EST 테스트 앱 예정공정표 눈금 단위(원본 1·5·10·15·20일, 1주, 1개월)."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def run_js(code: str):
runner = f"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({{ structuredClone, console }});
vm.runInContext(fs.readFileSync('tools/est-test/native-schedule.js', 'utf8'), ctx);
process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
"""
return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8"))
def test_day_week_month_ticks():
result = run_js(
"""(() => {
const t = (s, e, k, n) => EstSchedule.ticks(s, e, k, n);
const week = t('2026-01-01', '2026-01-28', '1w');
const month = t('2026-01-15', '2026-04-10', '1m');
const tenth = t('2026-01-01', '2026-01-30', '10d');
const many = t('2026-01-01', '2026-12-31', '1d', 60);
let bad = ''; try { t('2026-01-01', '2026-01-02', 'x'); } catch (e) { bad = e.message; }
return { week: week.ticks.map((x) => [x.label, Math.round(x.left)]),
month: month.ticks.map((x) => x.label), tenth: tenth.ticks.map((x) => x.label),
cut: many.cut, count: many.ticks.length, empty: t('', '', '1d').ticks.length, bad };
})()"""
)
assert result["week"] == [["01-01", 0], ["01-08", 25], ["01-15", 50], ["01-22", 75]]
assert result["month"] == ["2026-01", "2026-02", "2026-03", "2026-04"]
assert result["tenth"] == ["01-01", "01-11", "01-21"]
assert result["cut"] is True and result["count"] == 60
assert result["empty"] == 0
assert result["bad"] == "눈금 단위를 고르세요."
-5
View File
@@ -452,11 +452,6 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- [x] 낙찰률 적용 범위(원본 TFRMBIMOGBIUL 내역서낙찰·공종별) — 전체낙찰율/비목별, 전체·선택한 줄·선택한 공종 하위, 낙찰 적용된 줄 제외. 기존 계산(`EstBusiness.bid`)에 행 묶음 지정만 더함. 편집 메뉴에 낙찰률 적용·당초단가 복원. - [x] 낙찰률 적용 범위(원본 TFRMBIMOGBIUL 내역서낙찰·공종별) — 전체낙찰율/비목별, 전체·선택한 줄·선택한 공종 하위, 낙찰 적용된 줄 제외. 기존 계산(`EstBusiness.bid`)에 행 묶음 지정만 더함. 편집 메뉴에 낙찰률 적용·당초단가 복원.
- 검증완료: `test_est_bid.py` 2개(공종 하위만 변경·밖 불변, 적용된 줄 제외·빈 선택·제목 없음·음수 거부), 낙찰 관련 업무·설계변경 시험 포함 41개 통과. ORCA 브라우저에서 「구조물공」 선택 → 전체 80% 공종별 → 하위 4줄만 변경·순공사비 45,293,066 → 42,598,790, 되돌리기 원상. - 검증완료: `test_est_bid.py` 2개(공종 하위만 변경·밖 불변, 적용된 줄 제외·빈 선택·제목 없음·음수 거부), 낙찰 관련 업무·설계변경 시험 포함 41개 통과. ORCA 브라우저에서 「구조물공」 선택 → 전체 80% 공종별 → 하위 4줄만 변경·순공사비 45,293,066 → 42,598,790, 되돌리기 원상.
- 남음: 근거자료낙찰 탭(공종별 낙찰율 목록), 「연금·건강보험료·안전관리비 고정」. - 남음: 근거자료낙찰 탭(공종별 낙찰율 목록), 「연금·건강보험료·안전관리비 고정」.
- [x] 예정공정표 눈금 단위(원본 TFRMSCHEDULE mnu1Day·5Day·10Day·15Day·20Day·1Week·1Month) — 기간 칸 머리에 눈금 날짜, 막대 뒤 눈금선, 60개 초과 시 앞 60개.
- 검증완료: `test_est_schedule_scale.py`(1주·1개월·10일 눈금 위치·날짜, 60개 자르기, 빈 기간, 잘못된 단위 거부) 통과. ORCA 브라우저에서 1개월 → 1주 전환 시 머리 날짜 03-01·03-08… 11개, 글자 간격 47px 이상 확인 후 샘플 복구.
- [x] 원가계산서 사용자 양식(원본 TFRMWONGABASE popupSaveAs·popupFormats·popupLoadFrom, TFRMFORMATFILELIST 등록·삭제·열기·파일) — 서버 보관함 `data/forms.json`(`forms-store.cjs`, 종류 wonga·document), 등록·목록·열기·삭제·파일 내려받기/불러오기. 금액 제외, 열 때 도급액 고정 해제(저장 금액을 쓰는 옵션이라 금액 없는 양식에서 0이 됨을 확인).
- 검증완료: `test_est_forms_library.py` 2개(등록·같은 이름 교체·목록·삭제·잘못된 종류/이름/내용 거부, 금액 제외·잘못된 행 거부·다른 동작 무시) 통과. ORCA 브라우저에서 등록 → 목록 31행 → 원가 표를 3행으로 줄인 뒤 열기 → 31행 복구·도급액 79,603,324(고정 해제) → 삭제·보관함 비움, 샘플 복구.
- 남음: 기본양식으로 저장(새 공사 기본값), 갑지·표지 문서 양식.
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
- [ ] 남은 핵심(실무 빈도순): - [ ] 남은 핵심(실무 빈도순):
1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기. 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
-4
View File
@@ -22,10 +22,6 @@
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. 내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
`원가계산서 → 원가계산서 양식 목록·등록`은 현재 원가계산서의 항목·수식·요율을 이름 붙여 이 앱 `data/forms.json`에 등록하고, 목록에서 열기·삭제하며, 파일로 내려받거나 불러옵니다. 금액은 남기지 않고 열 때 다시 계산하므로 도급액 고정은 풀립니다.
예정공정표의 `눈금`은 원본처럼 1일·5일·10일·15일·20일·1주·1개월 단위로 기간 칸 위에 날짜를 적고 막대 뒤에 세로 눈금선을 그립니다. 눈금이 60개를 넘으면 앞 60개만 그립니다.
`편집 → 낙찰률 적용`은 원본 낙찰율 창처럼 전체낙찰율(한 비율) 또는 비목별낙찰율을 고르고, 전체 줄·선택한 줄·선택한 공종(하위 줄 모두)에 적용합니다. 이미 낙찰이 적용된 줄은 제외할 수 있고, `당초단가(100%) 복원`으로 되돌립니다. `편집 → 낙찰률 적용`은 원본 낙찰율 창처럼 전체낙찰율(한 비율) 또는 비목별낙찰율을 고르고, 전체 줄·선택한 줄·선택한 공종(하위 줄 모두)에 적용합니다. 이미 낙찰이 적용된 줄은 제외할 수 있고, `당초단가(100%) 복원`으로 되돌립니다.
`파일 → 프로젝트 추가`는 다른 공사 파일(ESTX·웹 JSON)의 순공사비 위 내역 행 전체를 하위 자료와 함께 이 공사의 순공사비 위에 합칩니다. 같은 코드 자료는 이 공사의 것을 그대로 씁니다. `파일 → 프로젝트 추가`는 다른 공사 파일(ESTX·웹 JSON)의 순공사비 위 내역 행 전체를 하위 자료와 함께 이 공사의 순공사비 위에 합칩니다. 같은 코드 자료는 이 공사의 것을 그대로 씁니다.
-54
View File
@@ -1,54 +0,0 @@
// 원본 양식목록(TFRMFORMATFILELIST: 등록·삭제·열기)의 사용자 양식 보관함 — 이 앱 data/forms.json 한 파일.
// 종류(kind)별 이름 목록. 같은 종류·이름이면 바꿔 쓴다.
const fs = require("node:fs");
const path = require("node:path");
const kinds = new Set(["wonga", "document"]);
const LIMIT = 2 * 1024 * 1024;
function file(directory) {
return path.join(directory, "forms.json");
}
function read(directory) {
const target = file(directory);
if (!fs.existsSync(target)) return [];
const list = JSON.parse(fs.readFileSync(target, "utf8"));
return Array.isArray(list) ? list : [];
}
function write(directory, list) {
fs.mkdirSync(directory, { recursive: true });
const target = file(directory),
temporary = `${target}.tmp`;
fs.writeFileSync(temporary, JSON.stringify(list), "utf8");
fs.renameSync(temporary, target);
}
function check(kind, name) {
if (!kinds.has(kind)) throw Error("양식 종류가 올바르지 않습니다.");
if (typeof name !== "string" || !name.trim() || name.length > 100)
throw Error("양식 이름은 1~100자입니다.");
}
function save(directory, entry) {
check(entry?.kind, entry?.name);
if (entry.data == null) throw Error("양식 내용이 없습니다.");
if (JSON.stringify(entry.data).length > LIMIT) throw Error("양식은 2MB 이하만 등록합니다.");
const list = read(directory).filter((f) => !(f.kind === entry.kind && f.name === entry.name));
list.push({
kind: entry.kind,
name: entry.name.trim(),
form: entry.form ?? null,
data: entry.data,
saved: new Date().toISOString(),
});
write(directory, list);
return list;
}
function remove(directory, kind, name) {
check(kind, name);
const list = read(directory),
next = list.filter((f) => !(f.kind === kind && f.name === name));
if (next.length === list.length) throw Error("지울 양식이 없습니다.");
write(directory, next);
return next;
}
module.exports = { read, save, remove };
-2
View File
@@ -44,7 +44,6 @@
<script defer src="native-excel-map.js"></script> <script defer src="native-excel-map.js"></script>
<script defer src="native-transfer.js"></script> <script defer src="native-transfer.js"></script>
<script defer src="native-bid.js"></script> <script defer src="native-bid.js"></script>
<script defer src="native-forms-library.js"></script>
<script defer src="native-actions.js"></script> <script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script> <script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script> <script defer src="native-app.js"></script>
@@ -151,7 +150,6 @@
><button data-action="template">원가 서식 선택</button ><button data-action="template">원가 서식 선택</button
><button data-action="settings">자릿수·도급액 고정</button ><button data-action="settings">자릿수·도급액 고정</button
><button data-action="reset-wonga">원가계산서 초기화</button> ><button data-action="reset-wonga">원가계산서 초기화</button>
<button data-action="wonga-forms">원가계산서 양식 목록·등록</button>
</div> </div>
</details> </details>
<details> <details>
+1 -2
View File
@@ -605,8 +605,7 @@ const EstBusinessActions = (() => {
(typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state, node)) || (typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state, node)) ||
(typeof EstExcelMap !== "undefined" && EstExcelMap.handle(name, state)) || (typeof EstExcelMap !== "undefined" && EstExcelMap.handle(name, state)) ||
(typeof EstTransfer !== "undefined" && EstTransfer.handle(name, state)) || (typeof EstTransfer !== "undefined" && EstTransfer.handle(name, state)) ||
(typeof EstBid !== "undefined" && EstBid.handle(name, state)) || (typeof EstBid !== "undefined" && EstBid.handle(name, state))
(typeof EstFormsLibrary !== "undefined" && EstFormsLibrary.handle(name, state, node))
) { ) {
EstApp.render(); EstApp.render();
} else if (name === "load-contract" || name === "load-bid") { } else if (name === "load-contract" || name === "load-bid") {
-111
View File
@@ -1,111 +0,0 @@
// 원본 원가계산서 양식(TFRMWONGABASE popupSaveAs·popupFormats·popupLoadFrom)과 양식목록(TFRMFORMATFILELIST)
// — 현재 원가 표를 이름 붙여 서버 보관함(data/forms.json)에 등록하고, 목록에서 열기·삭제, 파일로 내려받기·불러오기.
const EstFormsLibrary = (() => {
// 원가 표 양식으로 남길 필드 — 금액(Cost)은 계산 결과라 남기지 않는다.
function strip(rows) {
return rows.map((row) => {
const { Cost, ...rest } = row;
return rest;
});
}
function validRows(rows) {
if (!Array.isArray(rows) || !rows.length) throw Error("원가계산서 양식에 행이 없습니다.");
if (rows.some((row) => !row || typeof row !== "object" || Array.isArray(row)))
throw Error("원가계산서 양식 행이 올바르지 않습니다.");
return rows;
}
async function list() {
return (await EstApp.api("/api/forms")).filter((f) => f.kind === "wonga");
}
async function show(state) {
const { esc } = EstApp,
forms = await list();
EstApp.showDialog(
"원가계산서 양식 목록",
`<p>현재 원가계산서의 항목·수식·요율을 양식으로 등록하고, 다른 공사에서 열어 씁니다. 금액은 열 때 다시 계산합니다.</p><button data-action="wonga-form-register">현재 원가계산서 등록</button> <button data-action="wonga-form-export">파일로 내려받기</button> <button data-action="wonga-form-import">파일에서 불러오기</button><table class="sheet" style="margin-top:8px"><thead><tr><th>양식 이름</th><th>행</th><th>등록</th><th>작업</th></tr></thead><tbody>${forms.map((f) => `<tr><td>${esc(f.name)}</td><td>${f.data.length}</td><td>${esc(f.saved.slice(0, 10))}</td><td><button data-action="wonga-form-open" data-name="${esc(f.name)}">열기</button><button data-action="wonga-form-delete" data-name="${esc(f.name)}">삭제</button></td></tr>`).join("") || '<tr><td colspan="4">등록된 양식이 없습니다.</td></tr>'}</tbody></table>`,
);
state.formsLibrary = forms;
}
function apply(state, rows, name) {
validRows(rows);
if (!confirm(`현재 원가계산서를 「${name}」 양식으로 바꾸시겠습니까?`)) return;
EstApp.byId("dialog").close();
// 도급액 고정은 저장된 금액을 그대로 쓰므로 금액 없는 양식에서는 풀어야 다시 계산된다(기초 뼈대와 같음).
const fixed = state.project.options.TotalFix;
EstApp.change((project) => {
project.options.TotalFix = false;
project.tables.Wonga = structuredClone(rows);
});
state.notice = `원가계산서를 「${name}」 양식으로 바꿨습니다.${fixed ? " 도급액 고정은 풀었으니 필요하면 다시 맞추세요." : ""}`;
}
async function run(name, state, node) {
if (name === "wonga-forms") await show(state);
else if (name === "wonga-form-register") {
const title = prompt("양식 이름", state.project.name || "원가계산서");
if (!title) return;
const data = strip(validRows(state.project.tables.Wonga));
await EstApp.api("/api/forms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "wonga", name: title, data }),
});
await show(state);
} else if (name === "wonga-form-open") {
const form = state.formsLibrary?.find((f) => f.name === node.dataset.name);
if (!form) throw Error("양식을 찾을 수 없습니다. 목록을 다시 여세요.");
apply(state, form.data, form.name);
} else if (name === "wonga-form-delete") {
if (!confirm(`「${node.dataset.name}」 양식을 지우시겠습니까?`)) return;
await EstApp.api("/api/forms/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "wonga", name: node.dataset.name }),
});
await show(state);
} else if (name === "wonga-form-export") {
const data = strip(validRows(state.project.tables.Wonga));
EstApp.download(
`${state.project.name || "원가계산서"}.wonga.json`,
JSON.stringify({ kind: "est-web-wonga-1", rows: data }, null, 2),
);
} else if (name === "wonga-form-import") {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.addEventListener("change", async () => {
try {
const file = input.files[0];
if (!file) return;
const parsed = JSON.parse(await file.text());
if (parsed?.kind !== "est-web-wonga-1") throw Error("원가계산서 양식 파일이 아닙니다.");
apply(state, parsed.rows, file.name);
} catch (error) {
state.notice = error.message;
}
EstApp.render();
});
input.click();
}
}
const names = new Set([
"wonga-forms",
"wonga-form-register",
"wonga-form-open",
"wonga-form-delete",
"wonga-form-export",
"wonga-form-import",
]);
// 동작 사슬은 동기 — 이름만 먼저 가리고 서버 요청은 뒤에서 돌린다.
function handle(name, state, node) {
if (!names.has(name)) return false;
run(name, state, node).catch((error) => {
state.notice = error.message;
EstApp.render();
});
return true;
}
return { strip, validRows, handle };
})();
+1 -50
View File
@@ -152,55 +152,12 @@ const EstSchedule = (() => {
function clearSchedule(project) { function clearSchedule(project) {
project.schedule = []; project.schedule = [];
} }
// 원본 예정공정표 눈금(mnu1Day·mnu5Day·mnu10Day·mnu15Day·mnu20Day·mnu1Week·mnu1Month).
const scales = [
["1d", "1일", 1],
["5d", "5일", 5],
["10d", "10일", 10],
["15d", "15일", 15],
["20d", "20일", 20],
["1w", "1주", 7],
["1m", "1개월", 0],
];
function ticks(start, end, scale, limit = 60) {
if (!start || !end) return { ticks: [], cut: false };
const first = day(start),
span = day(end) - first + 1,
step = scales.find(([key]) => key === scale)?.[2];
if (step == null) throw Error("눈금 단위를 고르세요.");
const out = [];
if (step) for (let d = first; d < first + span; d += step) out.push(d);
else {
const date = new Date(first * 86400000);
for (let y = date.getUTCFullYear(), m = date.getUTCMonth(); ; m++) {
const t = Date.UTC(y, m, 1) / 86400000;
if (t >= first + span) break;
out.push(Math.max(t, first));
}
}
const cut = out.length > limit;
return {
cut,
ticks: out.slice(0, limit).map((d) => ({
left: ((d - first) / span) * 100,
label: new Date(d * 86400000).toISOString().slice(step ? 5 : 0, step ? 10 : 7),
})),
};
}
function render() { function render() {
const { esc, number, state } = EstApp, const { esc, number, state } = EstApp,
output = calculate(state.project); output = calculate(state.project);
state.scheduleScale ||= "1m";
const grid = ticks(output.start, output.end, state.scheduleScale),
lines = grid.ticks
.map((t) => `linear-gradient(#999,#999) ${t.left}% 0/1px 100% no-repeat`)
.join(","),
width = Math.max(180, grid.ticks.length * 44),
header = `<div style="position:relative;height:14px;font-size:10px;min-width:${width}px">${grid.ticks.map((t) => `<span style="position:absolute;left:${t.left}%">${esc(t.label)}</span>`).join("")}</div>`,
scaleSelect = `<label>눈금 <select data-schedule-scale>${scales.map(([key, label]) => `<option value="${key}" ${key === state.scheduleScale ? "selected" : ""}>${label}</option>`).join("")}</select></label>${grid.cut ? "<span>눈금이 많아 앞 60개만 표시</span>" : ""}`;
const rowsCount = output.rows.length; const rowsCount = output.rows.length;
EstApp.byId("work-content").innerHTML = EstApp.byId("work-content").innerHTML =
`<div class="business-tools"><button data-action="schedule-add">공정 추가</button><button data-action="schedule-import">내역 일괄 추가</button><button data-action="schedule-weights">내역 금액으로 가중치 갱신</button><button data-action="schedule-progress">최근 확정 기성으로 진도 갱신</button><button data-action="schedule-clear" ${rowsCount === 0 ? "disabled" : ""}>공정 전체 비우기</button>${scaleSelect}<span>시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%</span></div><p>기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.</p><table class="sheet"><thead><tr>${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => `<th>${h === "기간" ? `기간${header}` : h}</th>`).join("")}</tr></thead><tbody>${output.rows.map((r, i) => `<tr>${["Code", "Name", "start", "end", "weight", "progress"].map((k) => `<td><input data-schedule="${k}" data-index="${i}" type="${["start", "end"].includes(k) ? "date" : ["weight", "progress"].includes(k) ? "number" : "text"}" value="${esc(r[k])}" step="any"></td>`).join("")}<td style="min-width:${width}px"><div style="height:12px;background:${lines ? `${lines},` : ""}#ddd"><div style="margin-left:${r.left}%;width:${r.width}%;height:12px;background:#316ea5"></div></div></td><td><button data-action="schedule-move" data-index="${i}" data-direction="-1" ${i === 0 ? "disabled" : ""}>↑</button><button data-action="schedule-move" data-index="${i}" data-direction="1" ${i === rowsCount - 1 ? "disabled" : ""}>↓</button><button data-action="schedule-delete" data-index="${i}">삭제</button></td></tr>`).join("")}</tbody></table>`; `<div class="business-tools"><button data-action="schedule-add">공정 추가</button><button data-action="schedule-import">내역 일괄 추가</button><button data-action="schedule-weights">내역 금액으로 가중치 갱신</button><button data-action="schedule-progress">최근 확정 기성으로 진도 갱신</button><button data-action="schedule-clear" ${rowsCount === 0 ? "disabled" : ""}>공정 전체 비우기</button><span>시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%</span></div><p>기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.</p><table class="sheet"><thead><tr>${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => `<th>${h}</th>`).join("")}</tr></thead><tbody>${output.rows.map((r, i) => `<tr>${["Code", "Name", "start", "end", "weight", "progress"].map((k) => `<td><input data-schedule="${k}" data-index="${i}" type="${["start", "end"].includes(k) ? "date" : ["weight", "progress"].includes(k) ? "number" : "text"}" value="${esc(r[k])}" step="any"></td>`).join("")}<td style="min-width:180px"><div style="height:12px;background:#ddd"><div style="margin-left:${r.left}%;width:${r.width}%;height:12px;background:#316ea5"></div></div></td><td><button data-action="schedule-move" data-index="${i}" data-direction="-1" ${i === 0 ? "disabled" : ""}>↑</button><button data-action="schedule-move" data-index="${i}" data-direction="1" ${i === rowsCount - 1 ? "disabled" : ""}>↓</button><button data-action="schedule-delete" data-index="${i}">삭제</button></td></tr>`).join("")}</tbody></table>`;
} }
function install() { function install() {
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
@@ -256,11 +213,6 @@ const EstSchedule = (() => {
}); });
document.addEventListener("change", (event) => { document.addEventListener("change", (event) => {
const node = event.target; const node = event.target;
if (node.dataset.scheduleScale !== undefined) {
EstApp.state.scheduleScale = node.value;
EstApp.render();
return;
}
if (!node.dataset.schedule) return; if (!node.dataset.schedule) return;
const p = structuredClone(EstApp.state.project), const p = structuredClone(EstApp.state.project),
row = p.schedule[Number(node.dataset.index)], row = p.schedule[Number(node.dataset.index)],
@@ -279,7 +231,6 @@ const EstSchedule = (() => {
return { return {
validate, validate,
calculate, calculate,
ticks,
fromEstimate, fromEstimate,
refreshWeights, refreshWeights,
refreshProgress, refreshProgress,
-12
View File
@@ -9,7 +9,6 @@ const exchange = require("./exchange.cjs");
const { exportEstx } = require("./export-estx.cjs"); const { exportEstx } = require("./export-estx.cjs");
const jdl = require("./jdl.cjs"); const jdl = require("./jdl.cjs");
const { inspectAccom } = require("./inspect-accom.cjs"); const { inspectAccom } = require("./inspect-accom.cjs");
const forms = require("./forms-store.cjs");
const base = __dirname, const base = __dirname,
port = Number(process.env.EST_TEST_PORT || 5175), port = Number(process.env.EST_TEST_PORT || 5175),
host = process.env.EST_TEST_HOST || "100.65.141.15"; host = process.env.EST_TEST_HOST || "100.65.141.15";
@@ -65,7 +64,6 @@ const allowed = new Set([
"native-excel-map.js", "native-excel-map.js",
"native-transfer.js", "native-transfer.js",
"native-bid.js", "native-bid.js",
"native-forms-library.js",
"native-issues.js", "native-issues.js",
"native-attachments.js", "native-attachments.js",
"native-standard.js", "native-standard.js",
@@ -154,16 +152,6 @@ const server = http.createServer(async (req, res) => {
} }
return; return;
} }
if (url.pathname === "/api/forms" && ["GET", "POST"].includes(req.method)) {
const input = req.method === "POST" ? JSON.parse((await body(req)).toString("utf8")) : null;
reply(res, 200, input ? forms.save(dataDirectory, input) : forms.read(dataDirectory));
return;
}
if (req.method === "POST" && url.pathname === "/api/forms/delete") {
const input = JSON.parse((await body(req)).toString("utf8"));
reply(res, 200, forms.remove(dataDirectory, input.kind, input.name));
return;
}
if (req.method === "GET" && url.pathname === "/api/projects") { if (req.method === "GET" && url.pathname === "/api/projects") {
reply(res, 200, list()); reply(res, 200, list());
return; return;