3 Commits
23 changed files with 41 additions and 1291 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) — 사용자가 데스크탑에 있어 랩탑 로그인 창을 못 누름 · 읽기는 드라이브 커넥터로 됨.
@@ -902,3 +902,18 @@ b3 품셈 원문에 있는데 누락된 것만 추가하면 된다는거지?
1. 다음으로 너가 공유해준 엑셀파일을 구글 스프레드 시트로 가져와 수동연결(자재)탭과 수동연결(기계)탭으로 넣었고 기계와 관련해서는 해당 행의 메모에 의견을 작성해 뒀음. 이때 벌써 별칭으로 연결된 정보도 있었고 간단한 텍스트 검색으로도 분류가 되는 경우가 있어. 확인해봐. 1. 다음으로 너가 공유해준 엑셀파일을 구글 스프레드 시트로 가져와 수동연결(자재)탭과 수동연결(기계)탭으로 넣었고 기계와 관련해서는 해당 행의 메모에 의견을 작성해 뒀음. 이때 벌써 별칭으로 연결된 정보도 있었고 간단한 텍스트 검색으로도 분류가 되는 경우가 있어. 확인해봐.
2. 재료에 대한 메모 의견은 다시 한번 너도 직접 일어봐봐. 읽어보고 대화하자. 대부분 연결되는 것글이 많은 것같은데? 2. 재료에 대한 메모 의견은 다시 한번 너도 직접 일어봐봐. 읽어보고 대화하자. 대부분 연결되는 것글이 많은 것같은데?
## 요구 773 (11-11 · 사용자 · 1 크롤러드릴 기본 110kW · 2 커터 320~440 인정 → 우리 320~400 · 3 크레인(트럭) = 크레인(타이어) · 값 비교로 잇지 말고 로직으로 판단 · 4 콘크리트펌프용 파이프 = 손료 — 어떤 장비 손료인지 확인 · 5 자재: 표기 다름은 분석 엔진 개선 · 종자는 로직 개선으로 찾을 수 있을 듯(종자 & 초본류 조회) · 별도계상은 단가산출 뒤 연결)
1. 크롤러는 기본 110kw로 지정
2. 커터는 320~440 인정, 우리 자료의 320~400으로 지정
3. 그럼 크레인 타이어가 맞지. (대신 값을 비교해서 연결하면 문제가 됨. 값은 업데이트 되기때문 과거 자료일수록 더 값이 안맞음. 그러니 로직으로 판단하게 되어야함. 굴삭기가 그렇게 되어 있을것으로 판단됨. )
4. 콘크리트펌프용 파이프는 손료가 맞는것같음. 어떤 장비의 손료인지. 확인
5. 자재에서
- 표기 다름은 잘 찾도록 분석엔진 개선
- 사람이 골라야하는 정보들 중 종자 같은건 로직 개선치 찾을 수 있을것 같음. 종자 & 초본류로 조회해봐.
- 별도계상은 단가산출에서는 원가를 합산하지는 않지만 나중에 프로젝트 내부에서는 연결이 되어야함. 그러니 별도계상은 항목도 연결이 불가능에 가까움. 사용자가 별도계상하더라도 옵션은 단가산출을 만든 후 연결하게 해야함. 벌써 반영되어 있을것으로 예상
## 요구 774 (11-11 · 사용자 · 별칭은 사용자가 지정 · 브레인은 분석 엔진 업데이트에 집중)
그리고 별칭은 내가 지정할거야. 너는 분석 엔진 업데이트에 집중해줘.
-102
View File
@@ -1,102 +0,0 @@
"""EST 테스트 앱 무관자료 삭제(원본 mnuNotLinkDelete·DelSort·MulDelete)."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FILES = [
"native-math.js",
"native-formula.js",
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
"native-row-match.js",
"native-escalation.js",
"native-progress.js",
"native-business.js",
"native-review.js",
"native-resources.js",
"native-schedule.js",
"native-model.js",
"native-verify.js",
"native-cleanup.js",
]
def run_js(code: str):
runner = f"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({{ structuredClone, console }});
for (const f of {json.dumps(FILES)})
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
ctx.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
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_unused_rows_removed_and_totals_unchanged():
result = run_js(
"""(() => {
const calc = (p) => EstNative.calculate(EstModel.validate(p));
const p = calc(structuredClone(source));
p.tables.StuffcDat.push({ Code: 'MZZZ', Name: '안 쓰는 자재', Index: 999, Cost1: 1, ValueIndex: 0 });
p.tables.HeadMainDat.push({ Index: 900, Code: 'UZZZ', Name: '안 쓰는 일위', TitleCode: 'UZZZ' },
{ Code: 'MZZZ', LinkIndex: 5, GN: 1 });
const before = calc(p), total = before.calculation.total, direct = before.calculation.direct.TC;
const counts = Object.fromEntries(['StuffcDat','LaborcDat','HeadMainDat','CostMainDat','MachMainDat'].map((t) => [t, p.tables[t].length]));
const removed = EstCleanup.clean(p, false);
const after = calc(p);
return { total: after.calculation.total === total, direct: after.calculation.direct.TC === direct,
errors: after.calculation.errors.length,
gone: !p.tables.StuffcDat.some((r) => r.Code === 'MZZZ') && !EstNative.groups(p.tables.HeadMainDat).has('UZZZ'),
removedStuff: removed.StuffcDat >= 1, removedHead: removed.HeadMainDat >= 1,
estimate: p.tables.EstMainDat.length === before.tables.EstMainDat.length };
})()"""
)
assert result == {
"total": True,
"direct": True,
"errors": 0,
"gone": True,
"removedStuff": True,
"removedHead": True,
"estimate": True,
}
def test_sorted_mode_follows_first_use_and_keeps_codes():
result = run_js(
"""(() => {
const calc = (p) => EstNative.calculate(EstModel.validate(p));
const p = calc(structuredClone(source));
const order = EstCleanup.used(p);
const total = p.calculation.total;
const codes = new Set(p.tables.HeadTitleDat.map((r) => r.Code));
EstCleanup.clean(p, true);
const after = calc(p);
return { heads: [...EstNative.groups(p.tables.HeadMainDat).keys()].join() === order.get('HeadMainDat').join(),
stuff: p.tables.StuffcDat.map((r) => r.Code).join() === order.get('StuffcDat').join(),
codes: p.tables.HeadTitleDat.every((r) => codes.has(r.Code)),
total: after.calculation.total === total, errors: after.calculation.errors.length };
})()"""
)
assert result == {"heads": True, "stuff": True, "codes": True, "total": True, "errors": 0}
def test_blank_rows_before_heads_and_quantity_sheets():
result = run_js(
"""(() => {
const rows = [{ Index: 1, Code: 'A' }, { Name: 'x', GN: 1 }, {}, {}, { Index: 2, Code: 'B' }, {}];
const trimmed = EstCleanup.trimBeforeHeads(rows, 'HeadMainDat').map((r) => r.Code || r.Name || '-');
const p = { tables: { EstMainDat: [{ PkID: 7, Code: 'D1' }] }, quantitySheets: [
{ Code: 'Q1', items: [{ TargetKey: 'id:7' }] }, { Code: 'Q2', items: [{ TargetCode: 'NONE' }] }, { Code: 'Q3', items: [] }] };
const gone = EstCleanup.cleanQuantity(p);
return { trimmed, gone, left: p.quantitySheets.map((s) => s.Code) };
})()"""
)
assert result == {"trimmed": ["A", "x", "B", "-"], "gone": 2, "left": ["Q1"]}
@@ -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
-87
View File
@@ -1,87 +0,0 @@
"""EST 테스트 앱 정산원가계산서(원본 당초금액 초기화·정산원가계산서 작성/해제)."""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FILES = [
"native-math.js",
"native-formula.js",
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity.cjs",
"native-wonga.js",
"native-jungsang.js",
"native-engine.js",
"native-row-match.js",
"native-escalation.js",
"native-progress.js",
"native-business.js",
"native-review.js",
"native-resources.js",
"native-schedule.js",
"native-model.js",
"native-verify.js",
]
def run_js(code: str):
runner = f"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({{ structuredClone, console }});
for (const f of {json.dumps(FILES)})
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
ctx.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
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_difference_text_and_row_reuse():
result = run_js(
"""(() => {
const rows = [{ Name: '순공사비', Cost: 100, SC: 120 }, { Name: '', Cost: 0 }, { Name: '', Cost: 0 }];
const less = EstJungSang.difference(rows);
rows[0].Cost = 150;
const more = EstJungSang.difference(rows);
return { less, more, at: rows.findIndex((r) => r.LinkIndex === 66), size: rows.length, row: rows[1] };
})()"""
)
assert result["less"] == "(감)20" and result["more"] == "(증)30"
assert result["at"] == 1 and result["size"] == 3
assert result["row"]["Name"] == "." and result["row"]["EndLineIndex"] == 1
def test_toggle_copies_original_amounts_and_recalc_rewrites_difference():
result = run_js(
"""(() => {
const calc = (p) => EstNative.calculate(EstModel.validate(p));
let p = calc(structuredClone(source));
const before = p.tables.Wonga.map((r) => Number(r.Cost) || 0);
const fixed = p.tables.Wonga.findIndex((r) => Number(r.LinkIndex) === 55);
EstJungSang.toggle(p);
const copied = p.tables.Wonga.every((r, i) => r.LinkIndex === 66 || Number(r.SC) === (before[i] ?? 0));
p = calc(p);
const zero = p.tables.Wonga.find((r) => r.LinkIndex === 66).VSusic;
const row = p.tables.EstMainDat.find((r) => Number(r.GN) > 0 && Number(r.TU) > 0 && !Number(r.Level));
row.GN = Number(row.GN) * 2;
p = calc(p);
const diff = p.tables.Wonga.find((r) => r.LinkIndex === 66).VSusic;
const errors = p.calculation.errors.length;
EstJungSang.toggle(p);
const off = { show: p.options.JungSangShow, has66: p.tables.Wonga.some((r) => r.LinkIndex === 66),
keptSC: p.tables.Wonga.some((r) => Number(r.SC) > 0) };
return { copied, released: Number(p.tables.Wonga[fixed].LinkIndex), zero, diff, errors, off,
columns: EstJungSang.columns({ options: { JungSangShow: true } }, ['Name', 'VSusic', 'Cost']) };
})()"""
)
assert result["copied"] is True
assert result["released"] == 0
assert re.match(r"^(0|\((증|감)\)\d+)$", result["zero"])
assert result["diff"].startswith("(증)")
assert result["errors"] == 0
assert result["off"] == {"show": False, "has66": False, "keptSC": True}
assert result["columns"] == ["Name", "VSusic", "SC", "Cost"]
-102
View File
@@ -1,102 +0,0 @@
"""EST 테스트 앱 품명정리와 자재·견적단가 등록(원본 TfrmNameDevide·popMainStuffcReg·popMainEstimateReg)."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FILES = [
"native-math.js",
"native-formula.js",
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
"native-row-match.js",
"native-escalation.js",
"native-progress.js",
"native-business.js",
"native-review.js",
"native-resources.js",
"native-schedule.js",
"native-model.js",
"native-verify.js",
"native-name-reg.js",
]
def run_js(code: str):
runner = f"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({{ structuredClone, console }});
for (const f of {json.dumps(FILES)})
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
ctx.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
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_divide_splits_at_position_and_syncs_title():
result = run_js(
"""(() => {
const p = { tables: {
HeadMainDat: [{ Index: 1, Code: 'U1', Name: '거푸집 설치 합판', Size: '3회', Unite: '' }, { Code: 'L1', LinkIndex: 6 },
{ Index: 2, Code: 'U2', Name: '거푸집 해체 강재', Size: '', Unite: '㎡' }],
HeadTitleDat: [{ Code: 'U1', Name: '거푸집 설치 합판' }, { Code: 'U2', Name: '거푸집 해체 강재' }] } };
const n = EstNameReg.divide(p, [0, 1, 2], 4, 0, '㎡');
const c = { tables: { HeadMainDat: [{ Index: 1, Code: 'X', Name: 'AB-CD', Size: '' }] } };
EstNameReg.divide(c, [0], 2, 1, '');
let bad = ''; try { EstNameReg.divide(p, [1], 4, 0, ''); } catch (e) { bad = e.message.slice(0, 6); }
const r = p.tables.HeadMainDat;
return { n, a: [r[0].Name, r[0].Size, r[0].Unite], b: [r[2].Name, r[2].Size, r[2].Unite],
title: p.tables.HeadTitleDat[0].Size, bad, cut: c.tables.HeadMainDat[0].Size };
})()"""
)
assert result["n"] == 2
assert result["a"] == ["거푸집 ", "설치 합판3회", "㎡"]
assert result["b"] == ["거푸집 ", "해체 강재", "㎡"]
assert result["title"] == "설치 합판3회"
assert result["bad"] == "품명을 정리"
assert result["cut"] == "CD"
def test_register_stuff_and_estimate_link_row_and_keep_price():
result = run_js(
r"""(() => {
const calc = (x) => EstNative.calculate(EstModel.validate(x));
const p = calc(structuredClone(source));
const i = p.tables.EstMainDat.findIndex((r) => Number(r.GU) > 0 && !Number(r.Level) && Number(r.LinkIndex) === 5);
p.tables.EstMainDat[i].Name = '시험 자재 등록품';
const before = p.tables.EstMainDat[i].TC;
const code = EstNameReg.register(p, 'EstMainDat', i, 'stuff');
const rec = p.tables.StuffcDat.find((r) => r.Code === code);
const j = p.tables.EstMainDat.findIndex((r, k) => k !== i && Number(r.JU) + Number(r.NU) > 0 && !Number(r.Level) && r.Name);
p.tables.EstMainDat[j].Name = '시험 견적 등록품';
const w = EstNameReg.register(p, 'EstMainDat', j, 'estimate');
const est = p.tables.EstiMateDat.find((r) => r.Code === w);
const after = calc(p);
const errors = [];
for (const fn of [() => EstNameReg.register(p, 'EstMainDat', i, 'stuff'),
() => EstNameReg.register(p, 'HeadMainDat', p.tables.HeadMainDat.findIndex((r) => Number(r.Index) > 0), 'stuff')])
try { fn(); } catch (e) { errors.push(e.message.slice(0, 6)); }
return { code: /^M\d{10}$/.test(code), price: rec.Cost1 === Number(p.tables.EstMainDat[i].GU) && rec.TC === rec.Cost1,
link: [p.tables.EstMainDat[i].LinkIndex, p.tables.EstMainDat[i].Code === code],
same: after.tables.EstMainDat[i].TC === before, w: /^W\d{10}$/.test(w),
est: est.TC === est.NC + est.GC + est.JC && est.ONC === est.NC, estLink: p.tables.EstMainDat[j].LinkIndex,
errors, calcErrors: after.calculation.errors.length };
})()"""
)
assert result == {
"code": True,
"price": True,
"link": [5, True],
"same": True,
"w": True,
"est": True,
"estLink": 101,
"errors": ["같은 명칭의", "호표는 자재"],
"calcErrors": 0,
}
@@ -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"] == "눈금 단위를 고르세요."
-90
View File
@@ -1,90 +0,0 @@
"""EST 테스트 앱 정렬·데이타 위치정리·호표 정리(원본 BarSort·TFRMTABLESORT·popTitleSort)."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FILES = [
"native-math.js",
"native-formula.js",
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
"native-row-match.js",
"native-escalation.js",
"native-progress.js",
"native-business.js",
"native-review.js",
"native-resources.js",
"native-schedule.js",
"native-model.js",
"native-verify.js",
"native-tidy.js",
]
def run_js(code: str):
runner = f"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({{ structuredClone, console }});
for (const f of {json.dumps(FILES)})
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
ctx.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
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_sort_base_groups_by_gangub_and_drops_blank():
result = run_js(
"""(() => {
const p = { tables: { StuffcDat: [
{ Index: 1, Code: 'M3', Name: '철근', Gangub: 0 }, { Index: 2, Code: 'M1', Name: '레미콘', Gangub: 1 },
{ Index: 3, Code: 'M2', Name: '거푸집', Gangub: 0 }, { Index: 0, Code: 'M9', Name: '번호없음' }, { Index: 4 } ] } };
const dropped = EstTidy.sortBase(p, 'StuffcDat');
const s = { tables: { SilJukDat: [{ Index: 1, Code: 'S2', Name: '가' }, { Index: 2, Code: 'S1', Name: '나' }] } };
EstTidy.sortBase(s, 'SilJukDat', true);
let bad = ''; try { EstTidy.sortBase(p, 'HeadMainDat'); } catch (e) { bad = e.message.slice(0, 4); }
return { order: p.tables.StuffcDat.map((r) => [r.Code, r.Index]), dropped, code: s.tables.SilJukDat.map((r) => r.Code), bad };
})()"""
)
assert result == {
"order": [["M2", 1], ["M3", 2], ["M1", 3]],
"dropped": 2,
"code": ["S1", "S2"],
"bad": "자재·노",
}
def test_arrange_groups_and_number_heads_keep_totals():
result = run_js(
"""(() => {
const calc = (x) => EstNative.calculate(EstModel.validate(x));
const p = calc(structuredClone(source));
const total = p.calculation.total;
const n = EstTidy.arrange(p, 'HeadMainDat', false, 10);
const heads = [...EstNative.groups(p.tables.HeadMainDat).values()].map((g) => g.rows[0]);
const sorted = heads.every((h, i) => i === 0 || String(heads[i - 1].Name) <= String(h.Name));
const numbers = heads.map((h) => h.Index);
const titles = p.tables.HeadTitleDat.filter((t) => heads.some((h) => h.Code === t.Code)).every((t) => t.Index === heads.find((h) => h.Code === t.Code).Index);
EstTidy.numberHeads(p, 'HeadMainDat', 1);
const after = calc(p);
return { n: n === heads.length, sorted, first: numbers[0], step: numbers[1] - numbers[0], titles,
renum: [...EstNative.groups(p.tables.HeadMainDat).values()].map((g) => g.rows[0].Index).join() === heads.map((_, i) => i + 1).join(),
total: after.calculation.total === total, errors: after.calculation.errors.length };
})()"""
)
assert result == {
"n": True,
"sorted": True,
"first": 10,
"step": 1,
"titles": True,
"renum": True,
"total": True,
"errors": 0,
}
-15
View File
@@ -452,21 +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(고정 해제) → 삭제·보관함 비움, 샘플 복구.
- 남음: 기본양식으로 저장(새 공사 기본값), 갑지·표지 문서 양식.
- [x] 정산원가계산서(원본 popJunSangCalc 0xd0eb28→0xd0f6fc, popJungSangShow 0xd0ea24, 재계산 0xca31c8 — 디스어셈블 근거) — 당초금액 초기화(SC := Cost, 55 → 0, JungSangInit), 작성/해제 토글(JungSangShow), 차액 행(LinkIndex 66, (감)/(증)) 재계산마다 갱신, 원가 화면 당초금액·정산금액 열. 엔진은 66 행 수식을 계산하지 않음.
- 검증완료: `test_est_jungsang.py` 2개(차액 글자·빈 행 재사용, 당초 복사·55 해제·재계산 차액·해제 시 66 비움·SC 유지·열 순서), 대조·업무 시험 포함 통과. ORCA 브라우저에서 작성 → 열 「당초금액, 정산금액」·차액 (증)324(도급액 고정 해제분)·오류 0, 해제 → 「원가금액」 복귀, 샘플 복구.
- 남음: 당초분·정산분 색상 옵션.
- [x] 무관자료 삭제(원본 mnuNotLinkDelete·DelSort → f039f0, MulDelete → 1116db0 — 디스어셈블 근거) — 내역서에서 LinkIndex 5·6·21·101·103·104·105·Code(설계변경 Code_ 포함)로 하위까지 따라가 쓰는 자료만 남김, 묶음 머리 앞 빈 줄 정리, 내역서순 재배열(코드 유지), 내역 미연결 수량산출서 삭제. 원본의 한 번 훑기 누락(기계경비 안에서만 쓰인 일위대가 하위)은 웹에서 끝까지 따라가 막음. LinkIndex 211–218 코드 비우기는 뜻 미확인으로 하지 않음.
- 검증완료: `test_est_cleanup.py` 3개(안 쓰는 자재·일위 삭제·합계 불변·오류 0, 내역서순 순서·코드 유지, 빈 줄·수량산출서) 통과. 설치 main·sample·산림토목 ESTX에서 정리 전후 도급액·오류 수 같음. ORCA 브라우저에서 내역서순 정리 → 자재 30 → 17·도급액 79,603,000 유지·되살리기 복원.
- [x] 품명정리·자재/견적단가 등록(원본 ActionNameDeivde 0xf5a2b4·TfrmNameDevide 0xf595c0/0xf59640, popMainStuffcReg/EstimateReg → c_f00264/c_f00670 — 디스어셈블 근거) — 커서 자리 나누기·삭제할 칸·빈 단위만 채움·호표 목록 동기화, 등록 시 같은 품명·규격 거부·호표 거부·M/W+PkID 10자리 코드·자재 Cost1=TC=재료단가·견적 NC/GC/JC·원래 행 연결. 원본은 품명정리를 2개 이상 선택 때만 돌리나 웹은 1개도 허용.
- 검증완료: `test_est_name_reg.py` 2개(나누기·삭제할 칸·제목 동기화·호표 아님 거부, 자재/견적 등록 코드·가격·연결·금액 불변·중복/호표 거부·계산 오류 0) 통과. ORCA 브라우저에서 내역 행 자재 등록 → M0000000038·연결 5, 일위대가 「레미콘타설(…)」 3자리 나누기 → 품명 「레미콘」·규격 「타설(인력운반타설)철근구조물」, 샘플 복구.
- [x] 정렬·데이타 위치정리·호표 정리·근거(메모)(원본 BarSort 0x110ADEC·TfrmSiljukSort, mnuTableSort 0x10CCAEC·TFRMTABLESORT, popTitleSort 0x10D88EC, mnuGunguEdit/Del·DifineMemo — 디스어셈블 근거) — 관급구분별 가나다·Index 재번호·빈 행 제외(30줄 채우기는 안 함), 품명 오름/내림·시작번호·묶음 통째 이동, 번호만 재매김·제목표 동기화, 메모 WPTools 글자 읽기 전용 보기·삭제. 원본 위치정리 콤보 글자 뒤바뀜은 웹에서 실제 순서대로 표기.
- 검증완료: `test_est_tidy.py` 2개(관급구분·빈 행·실적 코드 정렬·화면 거부, 위치정리·번호정렬·제목 동기화·도급액 불변·오류 0) 통과. ORCA 브라우저에서 설치 main.estx 메모를 넣어 보기 → 「2015년 건설표준품셈 … 잔디블록」 글자 표시, 삭제 → null, 자재 정렬 → 빈 행 12개 제외, 샘플 복구.
- 남음: 메모 편집(원본 서식 저장), 위치정리 드래그 이동.
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
- [ ] 남은 핵심(실무 빈도순): - [ ] 남은 핵심(실무 빈도순):
1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기. 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
-12
View File
@@ -22,18 +22,6 @@
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. 내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
`편집 → 선택 행 자재단가 등록`·`견적단가 등록`은 원본처럼 내역·일위대가 행을 새 자재(M)·견적(W) 단가로 등록하고 그 행을 새 코드에 연결합니다. 같은 품명·규격이 있으면 거부하고, 일위대가 호표 제목 줄은 등록하지 않습니다. `품명정리`는 고른 일위대가 호표의 품명을 커서 자리에서 나눠 뒤쪽을 규격 앞에 붙이고, 단위가 비어 있으면 채웁니다.
`편집 → 정렬(가나다)`은 기초단가 표를 관급구분별로 묶어 품명·규격 순(실적은 코드 순 선택)으로 정렬하고 번호를 다시 매기며 빈 행은 뺍니다. `데이타 위치정리`는 기초단가 또는 일위대가·단가산출·기계경비 호표를 품명 오름/내림으로 다시 놓고 시작번호부터 번호를 매깁니다. `호표 정리`는 차례는 그대로 두고 호표 번호만 시작번호부터 다시 매깁니다. `근거(메모) 보기`는 원본 서식 메모의 글자를 읽기 전용으로 보이고, `근거(메모) 삭제`는 고른 행의 메모를 지웁니다.
`편집 → 무관자료 삭제`는 원본처럼 내역서가 쓰는 자재·노임·실적·견적과 일위대가·단가산출·기계경비 호표를 하위까지 따라가 표시하고 나머지를 지웁니다. 내역서·원가계산서·기계경비 적용기준은 지우지 않고, 묶음 머리 앞의 빈 줄을 정리합니다. `(내역서순)`은 표를 내역서 첫 등장 순서로 다시 늘어놓으며 코드는 바꾸지 않습니다. `무관 수량산출서 삭제`는 내역에 연결되지 않은 수량산출서를 지웁니다. 모두 되살리기로 되돌릴 수 있습니다.
`원가계산서 → 정산원가계산서 작성/해제`는 원본처럼 원가 표에 당초금액 열을 금액 앞에 보이고 금액 열을 정산금액으로 바꿉니다. 처음 켤 때(또는 `당초금액 초기화`) 원가 행마다 현재 금액을 당초금액으로 옮기고 도급액 고정 연결(55)을 풀며, 맨 아래 금액 행의 차이를 `(감)`·`(증)` 금액 행으로 적습니다. 켜져 있는 동안 재계산마다 차액 행을 다시 씁니다. 끄면 차액 행만 비우고 당초금액은 남깁니다.
`원가계산서 → 원가계산서 양식 목록·등록`은 현재 원가계산서의 항목·수식·요율을 이름 붙여 이 앱 `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 };
-20
View File
@@ -12,7 +12,6 @@
<script defer src="native-text.js"></script> <script defer src="native-text.js"></script>
<script defer src="native-quantity.cjs"></script> <script defer src="native-quantity.cjs"></script>
<script defer src="native-wonga.js"></script> <script defer src="native-wonga.js"></script>
<script defer src="native-jungsang.js"></script>
<script defer src="native-engine.js"></script> <script defer src="native-engine.js"></script>
<script defer src="native-row-match.js"></script> <script defer src="native-row-match.js"></script>
<script defer src="native-escalation.js"></script> <script defer src="native-escalation.js"></script>
@@ -45,10 +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-cleanup.js"></script>
<script defer src="native-name-reg.js"></script>
<script defer src="native-tidy.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>
@@ -105,9 +100,6 @@
><button data-action="block-del">블럭 삭제</button> ><button data-action="block-del">블럭 삭제</button>
<hr /> <hr />
<button data-action="find">찾기·다음찾기(Ctrl+F)</button> <button data-action="find">찾기·다음찾기(Ctrl+F)</button>
<button data-action="register-stuff">선택 행 자재단가 등록</button
><button data-action="register-estimate">선택 행 견적단가 등록</button
><button data-action="name-divide">품명정리(일위대가 호표)</button>
<button data-action="pick-open"> <button data-action="pick-open">
단가 고르기(자재·노임·기계·일위·단가산출·견적·실적) 단가 고르기(자재·노임·기계·일위·단가산출·견적·실적)
</button> </button>
@@ -122,15 +114,6 @@
<button data-action="clear-estimate-quantities">내역 수량 초기화</button> <button data-action="clear-estimate-quantities">내역 수량 초기화</button>
<button data-action="bid-rates">낙찰률 적용</button <button data-action="bid-rates">낙찰률 적용</button
><button data-action="restore-bid">당초단가(100%) 복원</button> ><button data-action="restore-bid">당초단가(100%) 복원</button>
<hr />
<button data-action="tidy-sort">정렬(가나다, 기초단가)</button
><button data-action="tidy-arrange">데이타 위치정리</button
><button data-action="tidy-number">호표 정리(번호정렬)</button
><button data-action="memo-view">근거(메모) 보기</button
><button data-action="memo-delete">근거(메모) 삭제</button>
<button data-action="notlink-delete">무관자료 삭제</button
><button data-action="notlink-sort">무관자료 삭제(내역서순)</button
><button data-action="notlink-quantity">무관 수량산출서 삭제</button>
</div> </div>
</details> </details>
<details> <details>
@@ -167,9 +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>
<button data-action="jungsang-toggle">정산원가계산서 작성/해제</button
><button data-action="jungsang-init">당초금액 초기화</button>
</div> </div>
</details> </details>
<details> <details>
+1 -6
View File
@@ -605,12 +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)) ||
(typeof EstJungSang !== "undefined" && EstJungSang.handle(name, state)) ||
(typeof EstCleanup !== "undefined" && EstCleanup.handle(name, state)) ||
(typeof EstNameReg !== "undefined" && EstNameReg.handle(name, state)) ||
(typeof EstTidy !== "undefined" && EstTidy.handle(name, state))
) { ) {
EstApp.render(); EstApp.render();
} else if (name === "load-contract" || name === "load-bid") { } else if (name === "load-contract" || name === "load-bid") {
-147
View File
@@ -1,147 +0,0 @@
// 원본 무관자료 삭제(TfrmEstMain mnuNotLinkDelete 0x10cd2a8·mnuNotLinkDelSort 0x10cd2b8 → f039f0,
// mnuNotLinkMulDelete 0x10cd2c8 → 1116db0). 내역서가 쓰는 자료(LinkIndex 5·6·21·101·103·104·105, Code 일치)를
// 일위대가·단가산출·기계경비 묶음 안까지 따라가 표시하고 나머지 기초·묶음 자료를 지운다. 내역서·원가·적용기준은 지우지 않는다.
// 내역서순: 내역서 첫 등장 순서(중첩은 깊이 우선)로 표를 다시 늘어놓고, 코드는 바꾸지 않는다.
// 설계변경 당초 코드(Code_)도 함께 따라간다. 원본은 내역서→일위→단가→기계 차례 한 번 훑기라 기계경비 안에서만
// 쓰인 일위대가의 하위를 놓칠 수 있으나, 웹은 끝까지 따라가 쓰는 자료를 지우지 않는다.
const EstCleanup = (() => {
const flat = { 5: "StuffcDat", 6: "LaborcDat", 21: "SilJukDat", 101: "EstiMateDat" };
const grouped = {
103: ["HeadTitleDat", "HeadMainDat"],
104: ["CostTitleDat", "CostMainDat"],
105: ["MachTitleDat", "MachMainDat"],
};
const num = (value) => Number(value) || 0;
const text = (value) => String(value ?? "").trim();
function used(project) {
const order = new Map(Object.values(flat).map((t) => [t, []])),
seen = new Set(),
groups = {};
for (const [, [, main]] of Object.entries(grouped)) {
order.set(main, []);
groups[main] = EstNative.groups(project.tables[main] || []);
}
function visit(rows) {
for (const row of rows)
for (const code of new Set([row.Code, row.Code_].filter(Boolean))) {
const kind = num(row.LinkIndex),
id = `${kind}:${code}`;
if (seen.has(id)) continue;
if (flat[kind]) {
if ((project.tables[flat[kind]] || []).some((r) => r.Code === code)) {
seen.add(id);
order.get(flat[kind]).push(code);
}
} else if (grouped[kind]) {
const [, main] = grouped[kind],
group = groups[main].get(code);
if (!group) continue;
seen.add(id);
order.get(main).push(code);
visit(group.rows.slice(1));
}
}
}
visit(project.tables.EstMainDat || []);
return order;
}
function blankRow(table, row) {
if (table === "CostMainDat") return !text(row.LineData);
return (
!text(row.Name) &&
!text(row.Size) &&
!text(row.Unite) &&
!(typeof row.Bigo === "string" && text(row.Bigo)) &&
!num(row.GN) &&
!num(row.TC)
);
}
// 빈 줄 정리(f038ac): 묶음 머리 바로 앞의 빈 행을 지운다.
function trimBeforeHeads(rows, table) {
return rows.filter((row, i) => {
if (!blankRow(table, row) || num(row.Index) > 0) return true;
let next = i + 1;
while (next < rows.length && blankRow(table, rows[next]) && !(num(rows[next].Index) > 0))
next++;
return !(next < rows.length && num(rows[next].Index) > 0);
});
}
function clean(project, sorted) {
const order = used(project),
removed = {};
for (const table of Object.values(flat)) {
const rows = project.tables[table] || [],
keep = order.get(table),
byCode = new Map(rows.map((r) => [r.Code, r]));
const next = sorted
? keep.map((code) => byCode.get(code))
: rows.filter((r) => keep.includes(r.Code));
removed[table] = rows.length - next.length;
project.tables[table] = next;
}
for (const [title, main] of Object.values(grouped)) {
const keep = order.get(main),
groups = EstNative.groups(project.tables[main] || []),
before = groups.size;
const codes = sorted ? keep : [...groups.keys()].filter((code) => keep.includes(code));
project.tables[main] = trimBeforeHeads(
codes.flatMap((code) => groups.get(code)?.rows || []),
main,
);
const titles = project.tables[title] || [],
byCode = new Map(titles.map((r) => [r.Code, r]));
project.tables[title] = sorted
? codes.map((code) => byCode.get(code)).filter(Boolean)
: titles.filter((r) => keep.includes(r.Code));
removed[main] = before - codes.length;
}
return removed;
}
// 수량산출서: 내역 행이 쓰는(MullangIndex 9999·MulCode 앞 조각) 산출서와, 항목이 내역에 연결된 산출서만 남긴다.
function cleanQuantity(project) {
const sheets = project.quantitySheets || [],
estimate = project.tables.EstMainDat || [],
codes = new Set(estimate.map((r) => r.Code).filter(Boolean)),
keys = new Set(estimate.map((r) => r.PkID != null && `id:${r.PkID}`).filter(Boolean)),
named = new Set(
estimate
.filter((r) => num(r.MullangIndex) === 9999 && typeof r.MulCode === "string")
.map((r) => r.MulCode.split("\t")[0]),
);
const keep = sheets.filter(
(sheet) =>
named.has(sheet.Code) ||
(sheet.items || []).some(
(item) =>
(item.TargetKey && keys.has(item.TargetKey)) ||
(item.TargetCode && codes.has(item.TargetCode)),
),
);
project.quantitySheets = keep;
return sheets.length - keep.length;
}
const WARN =
"내역서상에 사용한 데이타(수량산출)만 남기고 나머지는 삭제합니다.\n삭제된 자료는 되살리기로만 복구할 수 있습니다.";
function handle(name, state) {
if (name === "notlink-delete" || name === "notlink-sort") {
if (!confirm(WARN)) return true;
let removed;
EstApp.change((project) => (removed = clean(project, name === "notlink-sort")));
const total = Object.values(removed).reduce((a, b) => a + b, 0);
state.notice = `안 쓰는 자료 ${total}건을 지웠습니다${name === "notlink-sort" ? "(내역서 순서로 정리)" : ""}.`;
} else if (name === "notlink-quantity") {
if (!confirm(WARN)) return true;
let removed;
EstApp.change((project) => (removed = cleanQuantity(project)), false);
state.notice = `내역에 연결되지 않은 수량산출서 ${removed}개를 지웠습니다.`;
} else return false;
return true;
}
return { used, clean, cleanQuantity, trimBeforeHeads, handle };
})();
-2
View File
@@ -339,8 +339,6 @@ const EstNative = (() => {
직접공사비: num(direct.TC), 직접공사비: num(direct.TC),
}); });
tables.Wonga = wonga.rows; tables.Wonga = wonga.rows;
if (options.JungSangShow && typeof EstJungSang !== "undefined")
EstJungSang.difference(tables.Wonga);
errors.push(...wonga.errors); errors.push(...wonga.errors);
const wongaNames = new Map( const wongaNames = new Map(
wonga.rows wonga.rows
-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 };
})();
-82
View File
@@ -1,82 +0,0 @@
// 원본 정산원가계산서(TfrmWongaBase popJunSangCalc 0xd0eb28 → 0xd0f6fc, popJungSangShow 0xd0ea24, 재계산 0xca31c8).
// 당초금액 초기화: 원가 행마다 SC(당초금액) := Cost, LinkIndex 55 → 0, 초기화 표지(JungSangInit), 차액 행 기록.
// 정산원가계산서 작성/해제: JungSangShow 토글 — 켤 때 초기화 전이면 확인 없이 초기화, 끌 때 차액 행(66)만 비움(SC 유지).
// 켜져 있는 동안 재계산마다 차액 행을 다시 쓴다. 차액 d = 마지막 금액 행의 SC − Cost, d>0 「(감)」·d<0 「(증)」.
const EstJungSang = (() => {
const num = (value) => Number(value) || 0;
const blank = (row) => !String(row.Name ?? "").trim() && !num(row.Cost) && !row.VSusic;
function difference(rows) {
let last = -1;
rows.forEach((row, i) => {
if (num(row.LinkIndex) !== 66 && String(row.Name ?? "").trim() && num(row.Cost)) last = i;
});
const d = last < 0 ? 0 : num(rows[last].SC) - num(rows[last].Cost);
const text = d > 0 ? `(감)${Math.abs(d)}` : d < 0 ? `(증)${Math.abs(d)}` : "0";
let at = rows.findIndex((row) => num(row.LinkIndex) === 66);
if (at < 0) {
for (let i = rows.length - 1; i >= 0 && blank(rows[i]); i--) at = i;
}
if (at < 0) {
rows.push({});
at = rows.length - 1;
}
Object.assign(rows[at], {
EndLineIndex: 1,
Name: ".",
VGungu: text,
VSusic: text,
LinkIndex: 66,
});
return text;
}
function init(project) {
const rows = project.tables.Wonga || [];
for (const row of rows) {
row.SC = num(row.Cost);
if (num(row.LinkIndex) === 55) row.LinkIndex = 0;
}
project.options.JungSangInit = 1;
return difference(rows);
}
function clear(project) {
const row = (project.tables.Wonga || []).find((r) => num(r.LinkIndex) === 66);
if (row) Object.assign(row, { Name: "", VGungu: "", VSusic: "", LinkIndex: 0 });
}
function toggle(project) {
project.options.JungSangShow = !project.options.JungSangShow;
if (project.options.JungSangShow) {
if (!num(project.options.JungSangInit)) init(project);
} else clear(project);
return project.options.JungSangShow;
}
// 화면: 정산 모드에서는 원가 표에 당초금액 열을 금액 앞에 보이고 금액 열 이름을 정산금액으로 바꾼다.
function columns(project, base) {
if (!project.options?.JungSangShow) return base;
const out = base.filter((key) => key !== "SC");
out.splice(out.indexOf("Cost"), 0, "SC");
return out;
}
function label(project, key) {
if (!project.options?.JungSangShow) return null;
return { SC: "당초금액", Cost: "정산금액" }[key] || null;
}
function handle(name, state) {
if (name === "jungsang-init") {
if (!confirm("당초금액이 정산금액으로 대체됩니다.")) return true;
EstApp.change((project) => init(project));
} else if (name === "jungsang-toggle") {
let on;
EstApp.change((project) => (on = toggle(project)));
state.notice = on ? "정산원가계산서를 작성합니다." : "정산원가계산서를 해제했습니다.";
} else return false;
return true;
}
return { difference, init, clear, toggle, columns, label, handle };
})();
-131
View File
@@ -1,131 +0,0 @@
// 원본 품명정리(TfrmHead ActionNameDeivde 0xf5a2b4·TfrmNameDevide 적용 0xf595c0·규격으로지정 0xf59640)와
// 자재단가·견적단가 등록(popMainStuffcReg·popMainEstimateReg → c_f00264·c_f00670).
// 품명정리: 기존명칭에서 고른 자리 앞 = 품명(다듬지 않음), 뒤에서 「삭제할 칸」만큼 떼고 기존 규격을 붙여 다듬은 값 = 규격,
// 단위는 비어 있을 때만 채움. 고른 일위대가 호표마다 같은 자리로 나누고 HeadTitleDat 의 같은 코드도 맞춘다.
// 등록: 같은 품명·규격(빈칸·| 무시) 있으면 거부, 코드 = 머리글자(M·W) + PkID 10자리(쓰인 코드면 하나씩 올림),
// 자재 Cost1 = TC = 행 재료단가 · 견적 NC/GC/JC = 행 노무/재료/경비단가, 원래 행은 새 코드·연결(5·101)로 바꾼다.
const EstNameReg = (() => {
const num = (value) => Number(value) || 0;
const strip = (value) => String(value ?? "").replace(/[\s|]/g, "");
function divide(project, indices, position, cut, unit) {
if (!Number.isInteger(position) || position < 0) throw Error("나눌 자리를 고르세요.");
if (!Number.isInteger(cut) || cut < 0) throw Error("삭제할 칸은 0 이상의 정수입니다.");
const rows = project.tables.HeadMainDat,
titles = project.tables.HeadTitleDat || [];
const picked = indices.filter((i) => num(rows[i]?.Index) > 0);
if (!picked.length) throw Error("품명을 정리할 일위대가 호표(제목 줄)를 고르세요.");
for (const i of picked) {
const row = rows[i],
name = String(row.Name ?? "");
const rest = name.slice(position).slice(cut);
row.Name = name.slice(0, position);
row.Size = `${rest}${row.Size ?? ""}`.trim();
if (!strip(row.Unite) && unit) row.Unite = unit;
const title = titles.find((t) => t.Code === row.Code);
if (title) Object.assign(title, { Name: row.Name, Size: row.Size, Unite: row.Unite });
}
return picked.length;
}
const targets = {
stuff: { table: "StuffcDat", kind: 5, letter: "M", label: "자재단가" },
estimate: { table: "EstiMateDat", kind: 101, letter: "W", label: "견적단가" },
};
function register(project, view, index, which) {
const target = targets[which],
row = project.tables[view]?.[index];
if (!target || !row) throw Error("등록할 행을 고르세요.");
if (view === "HeadMainDat" && num(row.Index) > 0)
throw Error(`호표는 ${target.label}에 등록할수 없습니다.`);
if (!String(row.Name ?? "").trim()) throw Error("명칭을 입력하세요.");
const rows = (project.tables[target.table] ||= []);
const same = rows.find(
(r) => strip(r.Name) === strip(row.Name) && strip(r.Size) === strip(row.Size),
);
if (same)
throw Error(`같은 명칭의 데이타가 있습니다 . ${target.label} (${num(same.Index)} 번)`);
let pk = Math.max(0, ...rows.map((r) => num(r.PkID))) + 1;
const used = new Set(rows.map((r) => r.Code));
let code = `${target.letter}${String(pk).padStart(10, "0").slice(-10)}`;
while (used.has(code)) code = `${target.letter}${String(++pk).padStart(10, "0").slice(-10)}`;
const schema = rows[0] ? Object.keys(rows[0]) : ["Name", "Size", "Unite"];
const record = {};
for (const key of schema)
if (!["PkID", "Index", "Code"].includes(key) && Object.hasOwn(row, key))
record[key] = row[key];
Object.assign(record, { PkID: pk, Code: code, Index: rows.length + 1 });
if (which === "stuff")
Object.assign(record, { Cost1: num(row.GU), TC: num(row.GU), OTC: num(row.GU) });
else {
const [nc, gc, jc] = [num(row.NU), num(row.GU), num(row.JU)];
Object.assign(record, {
NC: nc,
GC: gc,
JC: jc,
TC: nc + gc + jc,
ONC: nc,
OGC: gc,
OJC: jc,
OTC: nc + gc + jc,
});
}
rows.push(record);
row.Code = code;
row.LinkIndex = target.kind;
return code;
}
function divideDialog(state) {
if (state.view !== "HeadMainDat") throw Error("일위대가 화면에서 품명을 정리하세요.");
const rows = state.project.tables.HeadMainDat,
picked = [...state.selection].sort((a, b) => a - b).filter((i) => num(rows[i]?.Index) > 0);
if (!picked.length) throw Error("품명을 정리할 일위대가 호표(제목 줄)를 고르세요.");
const { esc } = EstApp;
EstApp.showDialog(
"품명정리",
`<p>기존명칭에서 품명과 규격을 나눌 자리에 커서를 두고 「규격으로 지정」을 누르세요. 고른 ${picked.length}개 호표에 같은 자리로 적용합니다.</p><div class="form-fields"><label>기존명칭<input name="nd-all" value="${esc(rows[picked[0]].Name ?? "")}"></label><label>명칭<input name="nd-name" readonly></label><label>규격<input name="nd-size" readonly></label><label>규격에서 삭제할 칸<input name="nd-cut" type="number" value="0"></label><label>단위(비어 있을 때만)<input name="nd-unit"></label></div><button data-action="name-divide-set">규격으로 지정</button> <button data-action="name-divide-apply">적용</button>`,
);
state.nameDivide = { picked, position: null };
}
function field(name) {
return document.querySelector(`#dialog-content [name="${name}"]`);
}
function handle(name, state) {
if (name === "name-divide") divideDialog(state);
else if (name === "name-divide-set") {
const all = field("nd-all"),
position = all.selectionStart ?? 0;
state.nameDivide.position = position;
field("nd-name").value = all.value.slice(0, position);
field("nd-size").value = all.value.slice(position);
} else if (name === "name-divide-apply") {
const info = state.nameDivide;
if (info?.position == null) throw Error("먼저 「규격으로 지정」으로 나눌 자리를 정하세요.");
const cut = Number(field("nd-cut").value),
unit = field("nd-unit").value.trim();
EstApp.byId("dialog").close();
let count;
EstApp.change((project) => (count = divide(project, info.picked, info.position, cut, unit)));
state.notice = `${count}개 호표의 품명을 정리했습니다.`;
} else if (name === "register-stuff" || name === "register-estimate") {
const which = name === "register-stuff" ? "stuff" : "estimate",
index = [...state.selection][0],
row = state.project.tables[state.view]?.[index];
if (!["EstMainDat", "HeadMainDat"].includes(state.view) || state.selection.size !== 1 || !row)
throw Error("내역서·일위대가에서 등록할 행 하나를 고르세요.");
const label = targets[which].label;
register(structuredClone(state.project), state.view, index, which);
if (!confirm(`"${row.Name ?? ""}" ${row.Size ?? ""} 를 ${label}에 등록하시겠습니까?`))
return true;
let code;
EstApp.change((project) => (code = register(project, state.view, index, which)));
state.notice = `${label}에 ${code}로 등록하고 이 행을 연결했습니다.`;
} else return false;
return true;
}
return { divide, register, 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,
-163
View File
@@ -1,163 +0,0 @@
// 원본 표 정리 — 정렬(BarSort 0x110ADEC·TfrmSiljukSort), 데이타 위치정리(mnuTableSort 0x10CCAEC·TFRMTABLESORT),
// 호표 정리(popTitleSort 0x10D88EC), 근거(메모) 보기·삭제(mnuGunguEdit·mnuGunguDel, 필드 DifineMemo).
// 정렬: 기초단가 표를 관급구분(Gangub) 0·1·2 차례로 묶고 각 묶음을 품명+규격(실적은 코드 선택 가능) 순으로, Index 1..n.
// Index ≤ 0 이거나 품명·규격·수량·금액이 모두 빈 행은 버린다. 원본의 빈 행 30줄 채우기는 웹에서 하지 않는다.
// 위치정리: 기초단가·일위대가·단가산출·기계경비를 품명 오름/내림으로 다시 놓고 시작번호부터 Index — 묶음은 통째로 옮긴다.
// 원본 콤보 글자는 오름/내림이 뒤바뀌어 보이나(분석 추정) 웹은 실제 순서대로 적는다.
// 호표 정리: 현재 차례 그대로 묶음 머리 Index 를 시작번호부터 다시 매기고 제목표 같은 코드도 맞춘다(행·코드 불변).
const EstTidy = (() => {
const num = (value) => Number(value) || 0;
const base = ["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"];
const grouped = {
HeadMainDat: "HeadTitleDat",
CostMainDat: "CostTitleDat",
MachMainDat: "MachTitleDat",
};
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const key = (row) => `${row.Name ?? ""}${row.Size ?? ""}`;
function sortBase(project, table, byCode = false) {
if (!base.includes(table)) throw Error("자재·노임·실적·견적 단가 화면에서 정렬하세요.");
const rows = project.tables[table] || [];
const kept = rows.filter(
(row) => num(row.Index) > 0 && (key(row).trim() || num(row.GN) || num(row.TC)),
);
const out = [];
for (const gangub of [0, 1, 2])
out.push(
...kept
.filter((row) => num(row.Gangub) === gangub)
.sort((a, b) =>
byCode ? compare(String(a.Code ?? ""), String(b.Code ?? "")) : compare(key(a), key(b)),
),
);
out.forEach((row, i) => (row.Index = i + 1));
project.tables[table] = out;
return rows.length - out.length;
}
function arrange(project, table, descending, start) {
if (!Number.isInteger(start)) throw Error("시작번호는 정수입니다.");
const order = (a, b) => compare(String(a ?? ""), String(b ?? "")) * (descending ? -1 : 1);
if (base.includes(table)) {
const rows = (project.tables[table] || []).filter((row) => row.Code);
rows.sort((a, b) => order(a.Name, b.Name)).forEach((row, i) => (row.Index = start + i));
project.tables[table] = rows;
return rows.length;
}
if (!grouped[table]) throw Error("이 화면은 위치정리를 할 수 없습니다.");
const groups = [...EstNative.groups(project.tables[table] || []).values()].sort((a, b) =>
order(a.rows[0].Name, b.rows[0].Name),
);
project.tables[table] = groups.flatMap((g) => g.rows);
numberHeads(project, table, start);
return groups.length;
}
function numberHeads(project, table, start) {
if (!grouped[table]) throw Error("일위대가·단가산출·기계경비 화면에서 호표를 정리하세요.");
if (!Number.isInteger(start)) throw Error("시작번호는 정수입니다.");
const titles = new Map((project.tables[grouped[table]] || []).map((t) => [t.Code, t]));
let n = start;
for (const row of project.tables[table] || [])
if (num(row.Index) > 0) {
row.Index = n;
const title = titles.get(row.Code);
if (title) title.Index = n;
n++;
}
return n - start;
}
// 근거(메모): 원본 DifineMemo 는 WPTools 서식(zlib) — 웹은 글자만 꺼내 읽기 전용으로 보인다.
async function memoText(memo) {
if (memo == null) return "";
if (typeof memo === "string") return memo;
if (!memo.binary) return "";
let bytes = Uint8Array.from(atob(memo.binary), (c) => c.charCodeAt(0));
if (bytes[0] === 0x78)
bytes = new Uint8Array(
await new Response(
new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate")),
).arrayBuffer(),
);
const source = new TextDecoder("euc-kr").decode(bytes);
return EstQuantity.entities(
source
.replace(/<(numberstyles|stylesheet)[\s\S]*?<\/\1>/gi, "")
.replace(/<\/(div|p)>|<br\s*\/?>/gi, "\n")
.replace(/<[^>]*>/g, ""),
)
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function memoTarget(state) {
const index = [...state.selection][0],
row = state.project.tables[state.view]?.[index];
if (state.selection.size !== 1 || !row) throw Error("근거(메모)를 볼 행 하나를 고르세요.");
return { index, row };
}
async function run(name, state) {
if (name === "tidy-sort") {
const table = state.view;
let byCode = false;
if (table === "SilJukDat") byCode = !confirm("품명,규격으로 정렬할까요? [취소] 코드로 정렬");
if (!confirm("가,나,다 순으로 정렬합니다. 빈 행과 번호 없는 행은 빠집니다.")) return;
let dropped;
EstApp.change((project) => (dropped = sortBase(project, table, byCode)));
state.notice = `정렬했습니다${dropped ? ` (빈 행 ${dropped}개 제외)` : ""}.`;
} else if (name === "tidy-arrange" || name === "tidy-number") {
EstActions.form(
name === "tidy-arrange" ? "데이타 위치정리" : "호표 정리(번호정렬)",
[
...(name === "tidy-arrange"
? [
{
name: "order",
label: "품명 정렬",
value: "asc",
choices: [
["asc", "오름차순(가나다)"],
["desc", "내림차순"],
],
},
]
: []),
{ name: "start", label: "시작번호", type: "number", value: 1 },
],
(values) =>
EstApp.change((project) =>
name === "tidy-arrange"
? arrange(project, state.view, values.order === "desc", values.start)
: numberHeads(project, state.view, values.start),
),
);
} else if (name === "memo-view") {
const { row } = memoTarget(state),
body = await memoText(row.DifineMemo);
EstApp.showDialog(
`근거(메모) — ${row.Name ?? row.Code ?? ""}`,
body
? `<pre style="white-space:pre-wrap;max-height:400px;overflow:auto">${EstApp.esc(body)}</pre>${typeof row.DifineMemo === "string" ? "" : "<p>원본 서식 메모라 웹에서는 읽기만 합니다.</p>"}`
: "<p>이 행에는 근거(메모)가 없습니다.</p>",
);
} else if (name === "memo-delete") {
const { index } = memoTarget(state);
EstApp.change((project) => (project.tables[state.view][index].DifineMemo = null), false);
state.notice = "근거(메모)를 지웠습니다.";
}
}
const names = new Set(["tidy-sort", "tidy-arrange", "tidy-number", "memo-view", "memo-delete"]);
function handle(name, state) {
if (!names.has(name)) return false;
run(name, state).catch((error) => {
state.notice = error.message;
EstApp.render();
});
return true;
}
return { sortBase, arrange, numberHeads, memoText, handle };
})();
+2 -5
View File
@@ -76,7 +76,7 @@ const EstViews = (() => {
} }
function grid(table, list, columns) { function grid(table, list, columns) {
const { esc, number, state } = EstApp; const { esc, number, state } = EstApp;
return `<table class="sheet" data-grid="${table}"><thead><tr><th class="row-number">선택</th>${columns.map((key) => `<th class="col-${key}">${esc((table === "Wonga" && typeof EstJungSang !== "undefined" && EstJungSang.label(state.project, key)) || EstModel.labels[key] || key)}</th>`).join("")}</tr></thead><tbody>${list.map(({ row, index }) => `<tr data-index="${index}" class="${state.selection.has(index) ? "selected " : ""}${Number(row.Index) > 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}"><th class="row-number"><label><input type="checkbox" data-select="${index}" ${state.selection.has(index) ? "checked" : ""}>${index + 1}</label></th>${columns.map((key) => `<td class="col-${key}">${cell(table, index, key, row[key])}</td>`).join("")}</tr>`).join("")}</tbody></table>`; return `<table class="sheet" data-grid="${table}"><thead><tr><th class="row-number">선택</th>${columns.map((key) => `<th class="col-${key}">${esc(EstModel.labels[key] || key)}</th>`).join("")}</tr></thead><tbody>${list.map(({ row, index }) => `<tr data-index="${index}" class="${state.selection.has(index) ? "selected " : ""}${Number(row.Index) > 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}"><th class="row-number"><label><input type="checkbox" data-select="${index}" ${state.selection.has(index) ? "checked" : ""}>${index + 1}</label></th>${columns.map((key) => `<td class="col-${key}">${cell(table, index, key, row[key])}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
} }
function render(state) { function render(state) {
const { project, view } = state, const { project, view } = state,
@@ -121,10 +121,7 @@ const EstViews = (() => {
const all = EstApp.visibleRows(), const all = EstApp.visibleRows(),
start = state.page * 100, start = state.page * 100,
list = all.slice(start, start + 100), list = all.slice(start, start + 100),
columns = columns = EstModel.columns(view);
view === "Wonga" && typeof EstJungSang !== "undefined"
? EstJungSang.columns(project, EstModel.columns(view))
: EstModel.columns(view);
byId("work-content").innerHTML = grid(view, list, columns); byId("work-content").innerHTML = grid(view, list, columns);
byId("paging").hidden = false; byId("paging").hidden = false;
byId("page-info").textContent = byId("page-info").textContent =
-1
View File
@@ -75,7 +75,6 @@ const EstWonga = (() => {
row.Cost = defaultBases[row.BaseField]; row.Cost = defaultBases[row.BaseField];
} else if ( } else if (
row.VSusic && row.VSusic &&
num(row.LinkIndex) !== 66 &&
!/^원금액\s*:/.test(row.VSusic) && !/^원금액\s*:/.test(row.VSusic) &&
![1, 3].includes(num(row.CalcIndex)) ![1, 3].includes(num(row.CalcIndex))
) { ) {
-16
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";
@@ -22,7 +21,6 @@ const scripts = [
"native-text.js", "native-text.js",
"native-quantity.cjs", "native-quantity.cjs",
"native-wonga.js", "native-wonga.js",
"native-jungsang.js",
"native-engine.js", "native-engine.js",
"native-row-match.js", "native-row-match.js",
"native-escalation.js", "native-escalation.js",
@@ -66,10 +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-cleanup.js",
"native-name-reg.js",
"native-tidy.js",
"native-issues.js", "native-issues.js",
"native-attachments.js", "native-attachments.js",
"native-standard.js", "native-standard.js",
@@ -158,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;