feat(est-test): 엑셀형 산출서 편집 동작과 조달청 표준공사코드 적용
- 엑셀형 산출서: 범위 고르기, 행·열 삽입/삭제(수식 참조·병합·수량 연결 이동, 지운 칸 #REF!), 잘라내기·복사·붙여넣기(상대 참조), 내용지우기, 굵게·기울임·밑줄·정렬, 병합·분리, 대각선, 다시 계산, 품명·규격·단위로 지정, 원본 .xls 병합 정보 읽기 - 조달청 표준공사코드 적용(원본 TfrmCodeChange): 7종 탭, 코드변경·검사 색·변경취소·적용(참조 행 포함, 재계산 없음), 코드일괄변경(.estx), 자동변경, 다산코드정리, 사용자정의코드, 검색. 조달청 목록은 사용자가 고른 PPS_*.dat 를 /api/pps-table 로 읽음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
"""EST 테스트 앱 조달청 표준공사코드 적용(원본 TfrmCodeChange — 코드변경·변경취소·적용·일괄변경·자동변경·다산정리·사용자정의코드)."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
JODAL = Path("C:/Dasansoft/Est Plus 2/System/MainSys/JodalCodeDB.estx")
|
||||
PPS = Path("C:/Dasansoft/Est Plus 2/System/MainSys/PPS_STUFFC.dat")
|
||||
|
||||
|
||||
def run_js(code: str, *args: str):
|
||||
runner = f"""
|
||||
const fs = require('fs'), vm = require('vm');
|
||||
const ctx = vm.createContext({{ structuredClone, console, require, process }});
|
||||
for (const f of ['native-autolink.js', 'native-code-change.js'])
|
||||
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
|
||||
process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
|
||||
"""
|
||||
return json.loads(
|
||||
subprocess.check_output(["node", "-e", runner, *args], cwd=ROOT).decode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
def test_input_check_cancel_apply():
|
||||
out = run_js(
|
||||
r"""(() => {
|
||||
const C = EstCodeChange;
|
||||
const p = { tables: {
|
||||
StuffcDat: [{ Index: 1, Code: 'M0000000001', Name: '휘발유', Size: '', Unite: 'ℓ' },
|
||||
{ Index: 2, Code: '1510150620282202', Name: '경유', Size: '', Unite: 'ℓ' }],
|
||||
LaborcDat: [{ Index: 7, Code: 'L001010101000001', Name: '작업반장', Size: '' }],
|
||||
EstMainDat: [{ LinkIndex: 5, Code: 'M0000000001', Index: 0 }, { LinkIndex: 6, Code: 'M0000000001' }],
|
||||
CostMainDat: [{ LinkIndex: 5, Code: 'M0000000001', Index: 1 }, { LinkIndex: 5, Code: 'M0000000001', Index: 0 }],
|
||||
MachTitleDat: [], MachMainDat: [{ TitleCode: 'E0000000001', Code: 'E0000000001' }] } };
|
||||
const rows = C.load(p, 5);
|
||||
const bad = C.validate(p, 5, rows);
|
||||
const err = (code) => { try { C.input(p, 5, rows, 0, code); return ''; } catch (e) { return e.message; } };
|
||||
const out = { bad, flags: rows.map((r) => r.Flag), dup: err('1510150620282202'),
|
||||
other: err('L001010101000001'), short: err('123'), est: err('M0000000009') };
|
||||
C.input(p, 5, rows, 0, '1510150620282999');
|
||||
C.cancel(rows, 0);
|
||||
out.cancel = rows[0].Code;
|
||||
C.input(p, 5, rows, 0, '1510150620282999');
|
||||
out.applied = C.apply(p, 5, rows);
|
||||
out.codes = [p.tables.StuffcDat[0].Code, p.tables.EstMainDat.map((r) => r.Code), p.tables.CostMainDat.map((r) => r.Code)];
|
||||
const m = [{ Code: 'E0000000001', Name: '불도저' }];
|
||||
p.tables.MachTitleDat = m;
|
||||
const mr = C.load(p, 105);
|
||||
C.input(p, 105, mr, 0, '0000010100070000');
|
||||
C.apply(p, 105, mr);
|
||||
out.mach = [m[0].Code, p.tables.MachMainDat[0]];
|
||||
const s = [{ Code: 'OB010.11001' }, { Code: 'OB010.11001' }, { Code: '' }];
|
||||
C.auto(s);
|
||||
out.auto = s.map((r) => r.Code);
|
||||
const d = [{ Code: 'x' }, { Code: '' }];
|
||||
C.dasan(d, 5);
|
||||
out.dasan = d.map((r) => r.Code);
|
||||
out.user = [C.userCode(p, 6, C.load(p, 6), 'L001010101000001'), C.userCode(p, 103, [], 'CAA16123000S'), C.userCode(p, 105, [], '00000101000700000')];
|
||||
out.search = C.search([{ Name: '강관 지주', Size: '6m' }, { Name: '강관', Size: '' }], '강관&6 m').length;
|
||||
return out;
|
||||
})()"""
|
||||
)
|
||||
assert out["bad"] == 1 and out["flags"] == [1, 0]
|
||||
assert out["dup"] == "같은 코드가 있습니다. 확인하세요."
|
||||
assert out["other"].startswith('노임"7.작업반장()" ')
|
||||
assert out["short"].startswith("코드자리수가 틀립니다")
|
||||
assert out["est"] == "조달청 코드가 아닙니다."
|
||||
assert out["cancel"] == "M0000000001"
|
||||
assert out["applied"] == 3
|
||||
assert out["codes"] == [
|
||||
"1510150620282999",
|
||||
["1510150620282999", "M0000000001"],
|
||||
["M0000000001", "1510150620282999"],
|
||||
]
|
||||
assert out["mach"] == [
|
||||
"00000101000700000",
|
||||
{"TitleCode": "00000101000700000", "Code": "00000101000700000"},
|
||||
]
|
||||
assert out["auto"] == ["OB01011001", "COB010110010", ""]
|
||||
assert out["dasan"] == ["M0000000000", "M0000000001"]
|
||||
assert out["user"] == ["L0010101010Z0000", "CAA16123Z000", "00000101000Z00000"]
|
||||
assert out["search"] == 1
|
||||
|
||||
|
||||
def test_bulk_from_installed_jodal_db_and_pps_list():
|
||||
if not JODAL.exists() or not PPS.exists():
|
||||
pytest.skip("installed EST code DB unavailable")
|
||||
out = run_js(
|
||||
r"""(() => {
|
||||
const { importEstx, table } = require(process.cwd() + '/tools/est-test/import-estx.cjs');
|
||||
const fs = require('fs');
|
||||
const source = importEstx(fs.readFileSync(process.argv[1]));
|
||||
const p = { tables: { LaborcDat: [
|
||||
{ Index: 1, Code: 'L0000000001', Name: '작업반장', Size: '', Unite: '인' },
|
||||
{ Index: 2, Code: 'L0000000002', Name: '없는직종', Size: '', Unite: '인' },
|
||||
{ Index: 3, Code: 'L001010101000001', Name: '작업반장', Size: '', Unite: '인' } ] } };
|
||||
const rows = EstCodeChange.load(p, 6);
|
||||
rows.splice(2, 1);
|
||||
const n = EstCodeChange.bulk(p, 6, rows, source);
|
||||
const pps = table(fs.readFileSync(process.argv[2]), null, []);
|
||||
return { n, codes: rows.map((r) => r.Code), checked: rows.map((r) => r.Checked), pps: pps.length,
|
||||
found: EstCodeChange.search(pps, '작업반장').some((r) => r.Code === 'L001010101000001') };
|
||||
})()""",
|
||||
str(JODAL),
|
||||
str(PPS),
|
||||
)
|
||||
assert out["n"] == 1 and out["checked"] == [True, False]
|
||||
assert out["codes"][0] == "L001010101000001"
|
||||
assert out["codes"][1] == "L0000000002"
|
||||
assert out["pps"] > 50000 and out["found"]
|
||||
@@ -70,3 +70,65 @@ def test_real_sheets_recompute_and_link_items():
|
||||
assert out["after"][:3] != out["before"][:3]
|
||||
assert out["same"] is True
|
||||
assert out["message"].startswith("엑셀형 수량산출서 편집은 ESTX")
|
||||
|
||||
|
||||
def test_edit_rows_columns_clipboard_merge_and_style():
|
||||
out = node(r"""
|
||||
const S = require(process.cwd() + '/tools/est-test/native-excel-sheet.cjs');
|
||||
const E = require(process.cwd() + '/tools/est-test/native-excel-edit.cjs');
|
||||
const s = { cells: { '0,0': { value: 1 }, '1,0': { value: 2 }, '2,0': { value: 3 },
|
||||
'3,0': { value: 0, formula: 'SUM(A1:A3)' }, '3,1': { value: 0, formula: 'A2*$A$1+LOG10(2)' } } };
|
||||
S.recalc(s.cells);
|
||||
const links = { X: { n: 0, row: 3, col: 0 } };
|
||||
const r = {};
|
||||
E.shift(s, 'row', 1, 1, links);
|
||||
r.insert = [s.cells['4,0'].formula, s.cells['4,0'].value, s.cells['4,1'].formula, links.X.row];
|
||||
E.shift(s, 'row', 2, -1, links);
|
||||
r.remove = [s.cells['3,0'].formula, s.cells['3,0'].value, s.cells['3,1'].formula, s.cells['3,1'].value];
|
||||
E.shift(s, 'col', 0, 1, links);
|
||||
r.col = [s.cells['3,1'].formula, links.X.col];
|
||||
const clip = E.copyRange(s, { r1: 3, c1: 1, r2: 3, c2: 1 });
|
||||
E.pasteRange(s, clip, 5, 3);
|
||||
r.paste = [s.cells['5,3'].formula, E.offsetFormula('SUM(A1:B2)+$C$3+A$1', 1, 1)];
|
||||
E.merge(s, { r1: 0, c1: 0, r2: 1, c2: 2 });
|
||||
E.merge(s, { r1: 1, c1: 1, r2: 2, c2: 2 });
|
||||
r.merge = s.merges;
|
||||
E.shift(s, 'row', 0, -1, {});
|
||||
r.mergeShift = s.merges;
|
||||
E.split(s, { r1: 1, c1: 1, r2: 1, c2: 1 });
|
||||
r.split = s.merges.length;
|
||||
E.style(s, { r1: 0, c1: 0, r2: 0, c2: 1 }, 'b');
|
||||
E.style(s, { r1: 0, c1: 0, r2: 0, c2: 0 }, 'diag', 'left');
|
||||
r.style = [s.cells['0,0'].style, s.cells['0,1'].style];
|
||||
E.style(s, { r1: 0, c1: 0, r2: 0, c2: 1 }, 'b');
|
||||
r.unstyle = [s.cells['0,0'].style, s.cells['0,1'].style ?? null];
|
||||
E.clearRange(s, { r1: 0, c1: 0, r2: 0, c2: 0 }, true);
|
||||
r.clear = s.cells['0,0'];
|
||||
process.stdout.write(JSON.stringify(r));
|
||||
""")
|
||||
assert out["insert"] == ["SUM(A1:A4)", 6, "A3*$A$1+LOG10(2)", 4]
|
||||
assert out["remove"] == ["SUM(A1:A3)", 4, "#REF!*$A$1+LOG10(2)", "#오류"]
|
||||
assert out["col"] == ["SUM(B1:B3)", 1]
|
||||
assert out["paste"] == ["SUM(D3:D5)", "SUM(B2:C3)+$C$3+B$1"]
|
||||
assert out["merge"] == [{"r1": 1, "r2": 2, "c1": 1, "c2": 2}]
|
||||
assert out["mergeShift"] == [{"r1": 0, "r2": 1, "c1": 1, "c2": 2}]
|
||||
assert out["split"] == 0
|
||||
assert out["style"] == [{"b": True, "diag": "left"}, {"b": True}]
|
||||
assert out["unstyle"] == [{"diag": "left"}, None]
|
||||
assert out["clear"] == {"value": "", "style": {"diag": "left"}}
|
||||
|
||||
|
||||
def test_real_sheets_read_merged_cells():
|
||||
if not MAIN.exists():
|
||||
pytest.skip("installed EST main sample unavailable")
|
||||
out = node(
|
||||
"""
|
||||
const { importEstx } = require(process.cwd() + '/tools/est-test/import-estx.cjs');
|
||||
const p = importEstx(require('fs').readFileSync(process.argv[1]));
|
||||
const s = p.quantitySheets.find((x) => x.Code === 'N0000000136');
|
||||
process.stdout.write(JSON.stringify({ count: s.excel[0].merges.length, first: s.excel[0].merges[0] }));
|
||||
""",
|
||||
str(MAIN),
|
||||
)
|
||||
assert out["count"] == 14
|
||||
assert out["first"] == {"r1": 2, "r2": 4, "c1": 1, "c2": 1}
|
||||
|
||||
@@ -474,7 +474,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
|
||||
- 검증완료: `test_est_xls_read.py` 2개(설치 main.estx 엑셀형 6개·토적계산표 B2·No.1·20·수식 16개, OLE 아님 거부·격자 그리기), 수량 시험 포함 22개 통과. 엑셀형 붙인 뒤에도 ESTX 원본 그대로 저장 동일. ORCA 브라우저에서 토적계산표 격자 표시·수식 칸 16개 확인 후 샘플 복구.
|
||||
- [x] 2단계 계산·편집 — BIFF 수식 토큰(참조·범위·사칙·괄호·숫자·문자·SUM 등) A1 식으로 읽기(`decodeFormula`), `native-excel-sheet.cjs` 계산기(설치 6개 시트 수식 221개 모두 해석), 수식줄 편집·다시 계산, 4방향 합(원본 셀 합 규칙), 수량으로 지정/해제(`sheet.excelLinks`), 가져올 때 추정 연결(값이 같은 합계 셀), 편집 시 ESTX 저장 거부.
|
||||
- 검증완료: `test_est_excel_sheet.py` 2개(수식·함수·순환 오류·셀 합, 설치 main.estx 수식 221개 해석·토적계산표 항목 V000001~3 추정 연결 E33·G33·I33·수량 17·12·58·거리 바꾸면 수량 변함·원본 재저장 동일·편집 시 ESTX 거부) 통과. 다시 계산한 값이 Excel 저장값(0, 갱신 안 됨)이 아니라 EST 저장 수량(17·12·58·25.16·32.76·15.92·2.17)과 맞음. ORCA 브라우저에서 거리 C7 20→40 → 항목 17→34·12→24·58→116, 위 합 → SUM(E6:E33), 샘플 복구.
|
||||
- 남음: 행·열 넣기/지우기·셀 서식·병합, 원본 셀↔항목 대응 근거, .xls 저장.
|
||||
- [x] 3단계 편집 동작(원본 TFRMMULEXCEL 단추·팝업) — 범위 고르기(Shift), 행·열 삽입/삭제(수식 참조 이동·지운 칸 #REF!·범위 줄임·병합·수량 연결 함께 이동), 잘라내기·복사·붙여넣기(상대 참조 이동, Ctrl+X·C·V·Delete), 내용지우기, 굵게·기울임·밑줄·정렬, 병합·분리, 왼쪽·오른쪽 대각선·삭제, 다시 계산, 품명·규격·단위로 지정(셀 글자를 항목에). 원본 .xls 병합 정보(MERGEDCELLS) 읽어 머리글 그대로 표시. 되돌리기는 앱 취소(↶).
|
||||
- 검증완료: `test_est_excel_sheet.py` 4개(행 넣기·지우기·열 넣기 참조, 붙여넣기 상대 참조, 병합 이동·분리, 서식 켜고 끄기, 설치 main.estx 병합 14개) 등 44개 통과. ORCA 브라우저에서 설치 main.estx 토적계산표: 병합 머리글 14개 표시, 행 삽입·삭제 뒤 수량 연결 E33 유지, 범위 굵게·병합, 복사·붙여넣기 =(F33+F34)/2*E34, 임시 작업 삭제.
|
||||
- 남음: 원본 셀↔항목 대응 근거, 셀 서식 대화(글꼴·테두리·표시형식), .xls 저장.
|
||||
- [x] 수량산출 누계(원본 TfrmMulWord ActionAdd Ctrl+U → 0x11155c4, 재계산 0x111c0b0 — 디스어셈블 근거) — 원본 F·a 표지를 `` 쌍·`row.totals` 로 읽고, 위쪽 품명 줄(포함)/앞 누계(제외)부터 줄마다 첫 결과 합을 표지 반올림(F+6자리+방식+자리)으로 매 계산 갱신, 커서 자리 누계 넣기. 원본 TFRMMULNAMEINPUT 「누계계산」 단추는 실제로 지정해제와 같은 처리라 따로 만들지 않음.
|
||||
- 검증완료: `test_est_quantity_total.py`(소계 범위·앞 누계 제외·반올림 표지·값 갱신 6,003·넣기 이름·표지 안 거부·원본 F 표지 읽기) 통과, 수량 시험 포함 26개 통과. ORCA 브라우저에서 「소계」 줄 끝에 누계 → ⟦누계⟧9⟦누계⟧(6+3), 샘플 복구.
|
||||
- [x] 갑지·표지·공사설명서·시방서(원본 TFRMGABGI·TFRMCOVER·TFRMESTEDITOR, DatGabgi/DatCover/DatExplean/DatSiBang.opt — 디스어셈블 근거) — `midas-read.cjs` MIDAS 레코드·WPTools 글자 읽기 → `project.docs`, 편집 창, 공사명↔공사 이름 동기화, 공사금액 자동입력 두 방식·한글금액(일 생략 없음), 설명서·시방서 갑지 붙여넣기, 인쇄 미리보기 8·9·10·11. 문서 편집 작업은 ESTX 저장 거부.
|
||||
@@ -485,6 +487,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
|
||||
- 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일).
|
||||
- [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지.
|
||||
- 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구.
|
||||
- [x] 조달청 표준공사코드 적용(원본 mnuCodeChange → TfrmCodeChange — 디스어셈블 근거) — 작업데이타 7종 탭(일위·단산·기계·표준·견적·자재·노임), 코드변경(목록 코드·직접 입력, 기계 16자리면 끝 0), 검사 색(EST 코드 노랑·다른 표 중복 연분홍+비고·자리수 진황), 변경취소, 적용(자기 표·본문 머리 Code/TitleCode·참조 행 LinkIndex=종류·Index≤0 만, 재계산 없음), 코드일괄변경(고른 .estx 의 같은 표에서 자동연결 맞춤 규칙, EST→비EST 만·중복 금지, 표준은 옛 코드 부분일치), 자동변경(표준 행 번호 구간 C·A·M), 다산코드정리, 사용자정의코드, 목록·작업데이타 검색(& 두 조각). 조달청 목록은 원본 설치의 PPS_STUFFC.dat·PPS_HEAD.dat 를 사용자가 골라 불러옴(내장 자료 없음, `/api/pps-table`). 원본의 분류 트리(PPS_*TYPE)·다산정리 PkID 쓰기는 하지 않음.
|
||||
- 검증완료: `test_est_code_change.py` 2개(검사·중복·자리수 거부, 취소, 적용 범위·Index>0 제외, 기계 머리 TitleCode, 자동변경·다산·사용자정의코드 꼴, 설치 JodalCodeDB 로 노임 일괄변경 1건·PPS_STUFFC 58,653행) 통과. ORCA 브라우저에서 샘플 노임 「보통인부」 L0000000000→L001010101000001 적용 → 일위·단산 참조 11행 함께 바뀜, 도급액 79,603,000 불변, 샘플 복구.
|
||||
- [x] 기성 원가계산서 편집(원본 TfrmWongaAccomH/V 팝업 A3~A8 — 디스어셈블 근거) — 회차마다 원가 서식 사본(`round.wonga`)에 한줄 삽입·추가(끝줄 표지 이어받기)·삭제(확인), 내용 삭제(연결·금액·요율·수식·자리 비움), 선택항목 절하(도급액 행 허용), 선택데이타 절하(직전 자리, 구성비 기준 행 제외), 공사비역산 자리수(금회 도급액 10^n 미만 절하 → 이윤 역산), 초기화(사본 지움 = 도급 원가 서식), 되살리기(직전 조작 전), 산출근거 약어로 표시(수식칸 잠금만). 기성 행도 Diget>0 이면 자리맞춤. 도급금액 열은 순서가 아닌 기호·이름으로 짝지음. 원본의 번호·참조 재작성은 하지 않음(새 행 기호는 직접 입력). 확정 회차는 거부. 서버 허용 목록에서 빠져 있던 할증·부가세 스크립트(404)도 넣음.
|
||||
- 검증완료: `test_est_progress_wonga.py`(삽입·되살리기·절하 1,000 단위·역산 4자리·내용 삭제·초기화와 되살리기·확정 거부, 도급 원가 서식 불변) 및 기성 관련 시험 40개 통과. ORCA 브라우저에서 시험 회차(50%) 한줄 삽입 31→32행·되살리기 31행·역산 4자리 42,409,505→42,400,000, 시험 회차 삭제로 복구.
|
||||
- [x] 원가 행 자리수(Diget·DigetIndex) 반영(원본 행 계산 c9d278·c9d818·기성 ca6a6c 끝부분, 자리맞춤 0x11293e8, 선택항목 절하 확인 0xd094dc — 디스어셈블 근거) — 수식 결과 소수 4자리 반올림 뒤 원 미만 절사, 도급액(55) 아니고 Diget>0 이면 10^Diget 단위 자리맞춤(DigetIndex 0 처리 없음 · 1 사사오입 · 2 절하 · 3 절상 · 4+ 절하). 원가계산서 화면의 「선택 행 자릿수 설정」은 원본 「선택항목 절하하기」 대화(사사오입·절하·절상·초기화, 0 원~6 백만원, 도급액 행 거부, 입력 행은 원금액 TC 기준, 마지막 자리 기억). 기성 원가도 같은 엔진.
|
||||
|
||||
@@ -74,7 +74,9 @@
|
||||
|
||||
기성고 화면 아래 `기성 원가계산서`는 회차마다 따로 고칠 수 있습니다. 줄을 고른 뒤 한줄 삽입·추가·삭제, 내용 삭제, 선택항목 절하, 공사비역산 자리수 맞추기를 하고, `되살리기`로 바로 앞 조작을, `원가계산서 초기화`로 도급 원가 서식을 되돌립니다. 확정한 회차는 고칠 수 없습니다.
|
||||
|
||||
원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값과 수식을 함께 읽어 수량산출 화면 아래 격자로 보입니다. 셀을 누르면 수식줄에서 값이나 `=수식`을 고치고 바로 다시 계산합니다(참조·범위·사칙·SUM·ROUND 등). `우측·위·좌측·아래 합`은 원본처럼 그 방향의 숫자 셀을 더하는 SUM 을 넣고, `수량으로 지정`은 셀을 수량 항목에 연결해 항목 수량이 그 셀 값(산출서 자리수)을 따르게 합니다. 원본 파일에는 셀과 항목의 대응이 보이지 않아, 가져올 때 저장 수량과 값이 같은 합계 셀을 「추정 연결」로 둡니다. 셀을 고친 엑셀형 산출서는 ESTX 저장을 거부합니다.
|
||||
편집(E) › `조달청 표준공사코드 적용`은 노임·자재·기계·일위 등의 코드를 조달청 코드로 바꿉니다. 왼쪽 목록은 원본 설치 폴더 `System\MainSys` 의 `PPS_STUFFC.dat`(노임·자재·장비) 또는 `PPS_HEAD.dat`(일위·단산·표준)를 골라 불러옵니다. 코드를 바꾼 뒤 `적용`을 누르면 그 코드를 쓰는 내역·일위·단산 줄도 함께 바뀝니다. `코드일괄변경`은 고른 .estx 파일(예: 원본 JodalCodeDB.estx)에서 품명·규격이 같은 코드를 찾아 EST 코드만 바꿉니다.
|
||||
|
||||
원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값과 수식을 함께 읽어 수량산출 화면 아래 격자로 보입니다. 셀을 누르면 수식줄에서 값이나 `=수식`을 고치고 바로 다시 계산합니다(참조·범위·사칙·SUM·ROUND 등). Shift 로 범위를 골라 행·열 넣기·지우기, 잘라내기·복사·붙여넣기, 굵게·정렬·병합·대각선을 쓸 수 있고, 수식의 참조는 원본처럼 따라 옮겨집니다. `우측·위·좌측·아래 합`은 원본처럼 그 방향의 숫자 셀을 더하는 SUM 을 넣고, `수량으로 지정`은 셀을 수량 항목에 연결해 항목 수량이 그 셀 값(산출서 자리수)을 따르게 합니다. 원본 파일에는 셀과 항목의 대응이 보이지 않아, 가져올 때 저장 수량과 값이 같은 합계 셀을 「추정 연결」로 둡니다. 셀을 고친 엑셀형 산출서는 ESTX 저장을 거부합니다.
|
||||
|
||||
산출 줄 옆 `수량 지정`은 줄 글에서 드래그해 고른 숫자를 결과 표지로 감싸고 수량 항목을 새로 만듭니다. 품명·규격·단위 표지가 있으면 항목 이름에 옮깁니다. `지정 해제`는 커서가 놓인 결과 표지를 풀고, 다른 줄에서 쓰지 않는 항목을 지웁니다. 내역에 연결된 항목은 먼저 연결을 비워야 합니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부합니다.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<script defer src="native-rows.js"></script>
|
||||
<script defer src="native-text.js"></script>
|
||||
<script defer src="native-excel-sheet.cjs"></script>
|
||||
<script defer src="native-excel-edit.cjs"></script>
|
||||
<script defer src="native-quantity-total.js"></script>
|
||||
<script defer src="native-quantity.cjs"></script>
|
||||
<script defer src="native-wonga.js"></script>
|
||||
@@ -58,6 +59,8 @@
|
||||
<script defer src="native-xml-check.js"></script>
|
||||
<script defer src="native-review-auto.js"></script>
|
||||
<script defer src="native-autolink.js"></script>
|
||||
<script defer src="native-code-change.js"></script>
|
||||
<script defer src="native-code-change-view.js"></script>
|
||||
<script defer src="native-est-tools.js"></script>
|
||||
<script defer src="native-print-batch.js"></script>
|
||||
<script defer src="native-mach-formula.js"></script>
|
||||
@@ -133,6 +136,7 @@
|
||||
><button data-action="register-estimate">선택 행 견적단가 등록</button
|
||||
><button data-action="name-divide">품명정리(일위대가 호표)</button>
|
||||
<button data-action="autolink">데이타 자동연결</button>
|
||||
<button data-action="code-change">조달청 표준공사코드 적용</button>
|
||||
<button data-action="pick-open">
|
||||
단가 고르기(자재·노임·기계·일위·단가산출·견적·실적)
|
||||
</button>
|
||||
|
||||
@@ -624,7 +624,8 @@ const EstBusinessActions = (() => {
|
||||
(typeof EstMachFormula !== "undefined" && EstMachFormula.handle(name, state, node)) ||
|
||||
(typeof EstDefaults !== "undefined" && EstDefaults.handle(name, state, node)) ||
|
||||
(typeof EstExcelView !== "undefined" && EstExcelView.handle(name, state, node)) ||
|
||||
(typeof EstProgressWonga !== "undefined" && EstProgressWonga.handle(name, state))
|
||||
(typeof EstProgressWonga !== "undefined" && EstProgressWonga.handle(name, state)) ||
|
||||
(typeof EstCodeChangeView !== "undefined" && EstCodeChangeView.handle(name, state, node))
|
||||
) {
|
||||
EstApp.render();
|
||||
} else if (name === "load-contract" || name === "load-bid") {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// 「조달청 표준공사코드 적용」 대화 화면 — 왼쪽 조달청 목록(불러온 PPS 표) · 오른쪽 작업데이타 7종 탭. 계산은 native-code-change.js.
|
||||
const EstCodeChangeView = (() => {
|
||||
const PAGE = 100,
|
||||
COLORS = { 1: "#fff59d", 2: "#f8d7e0", 3: "#ffcc80" };
|
||||
const C = () => EstCodeChange;
|
||||
|
||||
function open(state, tag) {
|
||||
const view = state.view,
|
||||
guess = {
|
||||
HeadMainDat: 103,
|
||||
CostMainDat: 104,
|
||||
MachMainDat: 105,
|
||||
SilJukDat: 21,
|
||||
EstiMateDat: 101,
|
||||
StuffcDat: 5,
|
||||
LaborcDat: 6,
|
||||
}[view];
|
||||
const cc = (state.codeChange ||= {
|
||||
pps: {},
|
||||
word: "",
|
||||
rowWord: "",
|
||||
autoMove: true,
|
||||
nameChange: false,
|
||||
});
|
||||
cc.tag = tag ?? cc.tag ?? guess ?? 6;
|
||||
cc.rows = C().load(state.project, cc.tag);
|
||||
C().validate(state.project, cc.tag, cc.rows);
|
||||
cc.sel = cc.rows.length ? 0 : undefined;
|
||||
cc.page = 0;
|
||||
cc.dirty = false;
|
||||
cc.pick = undefined;
|
||||
draw(state);
|
||||
}
|
||||
function ppsKind(tag) {
|
||||
return [21, 103, 104].includes(tag) ? "HEAD" : "STUFFC";
|
||||
}
|
||||
function shownRows(cc) {
|
||||
const w = String(cc.rowWord || "").replace(/[\s|()]/g, "");
|
||||
return cc.rows
|
||||
.map((r, i) => ({ r, i }))
|
||||
.filter(
|
||||
({ r }) =>
|
||||
!w ||
|
||||
(cc.rowMode === "name"
|
||||
? `${r.Name}${r.Size}`.replace(/[\s|()]/g, "").includes(w)
|
||||
: String(r.OldCode)
|
||||
.replace(/[\s|()]/g, "")
|
||||
.includes(w)),
|
||||
);
|
||||
}
|
||||
|
||||
function draw(state) {
|
||||
const { esc } = EstApp,
|
||||
cc = state.codeChange,
|
||||
C2 = C(),
|
||||
list = cc.pps[ppsKind(cc.tag)] || [],
|
||||
found = C2.search(list, cc.word).slice(0, PAGE),
|
||||
rows = shownRows(cc),
|
||||
pages = Math.max(1, Math.ceil(rows.length / PAGE)),
|
||||
page = Math.min(cc.page, pages - 1);
|
||||
const tabs = C2.ORDER.map(
|
||||
(t) =>
|
||||
`<button data-action="code-change-tab" data-tag="${t}"${t === cc.tag ? ' aria-pressed="true" style="font-weight:bold"' : ""}>${C2.NAMES[t]}</button>`,
|
||||
).join("");
|
||||
const left = `<div style="flex:1 1 420px;min-width:0"><b>조달청코드 목록</b> (${ppsKind(cc.tag) === "HEAD" ? "단산·일위·표준 = PPS_HEAD" : "노임·자재·장비·견적 = PPS_STUFFC"}) <label>목록 불러오기 <input type="file" accept=".dat" data-cc-pps></label><div><input data-cc-word value="${esc(cc.word)}" placeholder="검색어 (A&B = 둘 다)"><button data-action="code-change-search">검색</button><button data-action="code-change-all">전체보기</button> ${list.length ? `${found.length}/${list.length}` : "목록 없음 — 원본 System\\MainSys 의 PPS_*.dat 를 고르세요"}</div><div style="max-height:360px;overflow:auto"><table class="sheet"><thead><tr><th></th><th>코드</th><th>명칭</th><th>규격</th><th>단위</th></tr></thead><tbody>${found
|
||||
.map(
|
||||
(x, i) =>
|
||||
`<tr><td><input type="radio" name="cc-pick" data-cc-pick="${i}"${cc.pick?.Code === x.Code ? " checked" : ""}></td><td>${esc(x.Code)}</td><td>${esc(x.Name)}</td><td>${esc(x.Size)}</td><td>${esc(x.Unit)}</td></tr>`,
|
||||
)
|
||||
.join("")}</tbody></table></div></div>`;
|
||||
const body = rows
|
||||
.slice(page * PAGE, page * PAGE + PAGE)
|
||||
.map(
|
||||
({ r, i }) =>
|
||||
`<tr>${[
|
||||
`<input type="radio" name="cc-row" data-cc-row="${i}"${cc.sel === i ? " checked" : ""}>`,
|
||||
esc(r.Index ?? ""),
|
||||
esc(r.OldCode),
|
||||
`<input data-cc-code="${i}" value="${esc(r.Code)}" size="18"${r.Checked ? ' style="font-weight:bold"' : ""}>`,
|
||||
esc(r.Name),
|
||||
esc(r.Size),
|
||||
esc(r.Unite),
|
||||
esc(r.Bigo),
|
||||
]
|
||||
.map((v) => `<td${r.Flag ? ` style="background:${COLORS[r.Flag]}"` : ""}>${v}</td>`)
|
||||
.join("")}</tr>`,
|
||||
)
|
||||
.join("");
|
||||
const user = cc.pick ? safeUser(state) : "";
|
||||
const right = `<div style="flex:1 1 560px;min-width:0"><div>${tabs}</div><div><input data-cc-rowword value="${esc(cc.rowWord || "")}" placeholder="작업데이타 검색"><select data-cc-rowmode><option value="code">코드검색</option><option value="name"${cc.rowMode === "name" ? " selected" : ""}>명칭검색</option></select><button data-action="code-change-rowsearch">검색</button> ${rows.length}행 · <button data-action="code-change-page" data-step="-1"${page ? "" : " disabled"}>이전</button>${page + 1}/${pages}<button data-action="code-change-page" data-step="1"${page + 1 < pages ? "" : " disabled"}>다음</button></div><div style="max-height:330px;overflow:auto"><table class="sheet"><thead><tr><th></th><th>No</th><th>당초코드</th><th>변경코드</th><th>명칭</th><th>규격</th><th>단위</th><th>비고</th></tr></thead><tbody>${body}</tbody></table></div><p class="business-note">*조달청호환 코드로 미변경(노랑), 중복코드(연분홍 비고란 참조), 코드자리수틀림(진황색)*</p></div>`;
|
||||
const tools = [
|
||||
`<button data-action="code-change-input">코드변경</button>`,
|
||||
`<button data-action="code-change-user"${user ? "" : " disabled"}>사용자정의코드 ${EstApp.esc(user)}</button>`,
|
||||
`<button data-action="code-change-cancel">변경취소</button>`,
|
||||
`<button data-action="code-change-auto"${cc.tag === 21 ? "" : " disabled"}>자동변경</button>`,
|
||||
`<button data-action="code-change-bulk">코드일괄변경(.estx 선택)</button>`,
|
||||
`<button data-action="code-change-dasan">다산코드정리</button>`,
|
||||
`<button data-action="code-change-apply"${cc.dirty ? "" : " disabled"}>적용</button>`,
|
||||
`<button data-action="code-change-close">닫기</button>`,
|
||||
`<label><input type="checkbox" data-cc-opt="autoMove"${cc.autoMove ? " checked" : ""}> 코드변경후 다음항목</label>`,
|
||||
`<label><input type="checkbox" data-cc-opt="nameChange"${cc.nameChange ? " checked" : ""}> 명칭,규격 변경</label>`,
|
||||
].join(" ");
|
||||
EstApp.showDialog(
|
||||
`조달청 표준공사코드 적용 — ${C2.NAMES[cc.tag]}`,
|
||||
`${cc.message ? `<p class="business-note" role="status">${esc(cc.message)}</p>` : ""}<div style="display:flex;flex-wrap:wrap;gap:12px">${left}${right}</div><div class="business-tools">${tools}</div>`,
|
||||
);
|
||||
cc.message = "";
|
||||
}
|
||||
function safeUser(state) {
|
||||
const cc = state.codeChange;
|
||||
if (!cc.pick || [21, 105, 101].includes(cc.tag)) return "";
|
||||
try {
|
||||
return C().userCode(state.project, cc.tag, cc.rows, cc.pick.Code);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function run(name, state, node) {
|
||||
const cc = state.codeChange;
|
||||
if (name === "code-change") return open(state);
|
||||
if (!cc) return;
|
||||
const C2 = C();
|
||||
if (name === "code-change-tab") {
|
||||
if (cc.dirty && confirm(`변경된 ${C2.NAMES[cc.tag]} 의 코드를 내역서 전체에 적용할까요?`))
|
||||
applyNow(state);
|
||||
return open(state, Number(node.dataset.tag));
|
||||
}
|
||||
if (name === "code-change-search")
|
||||
cc.word = document.querySelector("[data-cc-word]")?.value ?? "";
|
||||
else if (name === "code-change-all") cc.word = "";
|
||||
else if (name === "code-change-rowsearch") {
|
||||
cc.rowWord = document.querySelector("[data-cc-rowword]")?.value ?? "";
|
||||
cc.rowMode = document.querySelector("[data-cc-rowmode]")?.value;
|
||||
cc.page = 0;
|
||||
} else if (name === "code-change-page")
|
||||
cc.page = Math.max(0, cc.page + Number(node.dataset.step));
|
||||
else if (name === "code-change-input" || name === "code-change-user") {
|
||||
if (!cc.pick) throw Error("조달청코드 목록에서 코드를 고르세요.");
|
||||
const code = name === "code-change-user" ? safeUser(state) : cc.pick.Code;
|
||||
C2.input(
|
||||
state.project,
|
||||
cc.tag,
|
||||
cc.rows,
|
||||
cc.sel,
|
||||
code,
|
||||
name === "code-change-input" && cc.nameChange ? { ...cc.pick, nameChange: true } : null,
|
||||
);
|
||||
cc.dirty = true;
|
||||
if (cc.autoMove && cc.sel < cc.rows.length - 1) cc.sel++;
|
||||
} else if (name === "code-change-cancel") C2.cancel(cc.rows, cc.sel);
|
||||
else if (name === "code-change-auto") {
|
||||
C2.auto(cc.rows);
|
||||
cc.dirty = true;
|
||||
} else if (name === "code-change-dasan") {
|
||||
if (!confirm("코드를 일련번호로 새로 부여하시겠습니까?")) return;
|
||||
C2.dasan(cc.rows, cc.tag);
|
||||
cc.dirty = true;
|
||||
} else if (name === "code-change-bulk") return pickEstx(state);
|
||||
else if (name === "code-change-apply") {
|
||||
if (!confirm(`변경된 ${C2.NAMES[cc.tag]} 의 코드를 내역서 전체에 적용할까요?`)) return;
|
||||
applyNow(state);
|
||||
} else if (name === "code-change-close") {
|
||||
if (
|
||||
cc.dirty &&
|
||||
!confirm("내역서에 변경된 코드를 적용하지 않았습니다.코드변경작업을 종료하시겠습니까?")
|
||||
)
|
||||
return;
|
||||
EstApp.byId("dialog").close();
|
||||
state.codeChange = { ...cc, rows: [], dirty: false };
|
||||
return;
|
||||
}
|
||||
draw(state);
|
||||
}
|
||||
function applyNow(state) {
|
||||
const cc = state.codeChange,
|
||||
C2 = C();
|
||||
if (
|
||||
C2.validate(state.project, cc.tag, cc.rows) &&
|
||||
!confirm("조달청코드로 변경안된 데이타가 있습니다.그래도 적용하시겠습니까??")
|
||||
)
|
||||
return;
|
||||
let count = 0;
|
||||
EstApp.change((project) => (count = C2.apply(project, cc.tag, cc.rows)));
|
||||
cc.dirty = false;
|
||||
state.notice = `${C2.NAMES[cc.tag]}의 코드변경이 완료 되었습니다. (${count}행)`;
|
||||
}
|
||||
function pickEstx(state) {
|
||||
const cc = state.codeChange,
|
||||
picker = document.createElement("input");
|
||||
picker.type = "file";
|
||||
picker.accept = ".estx";
|
||||
picker.addEventListener("change", async () => {
|
||||
try {
|
||||
const file = picker.files[0];
|
||||
if (!file) return;
|
||||
if (
|
||||
!confirm(
|
||||
`현재 작업데이타의 ${C().NAMES[cc.tag]} 코드를 "${file.name}"의 코드로 변경하시겠습니까?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
const source = await EstApp.api(`/api/import?name=${encodeURIComponent(file.name)}`, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
});
|
||||
const n = C().bulk(state.project, cc.tag, cc.rows, source);
|
||||
if (n) cc.dirty = true;
|
||||
cc.message = n
|
||||
? `${n}개 코드를 바꿨습니다. 적용을 누르세요.`
|
||||
: "코드가 변경된 항목이 없습니다.";
|
||||
} catch (error) {
|
||||
cc.message = error.message;
|
||||
}
|
||||
draw(state);
|
||||
});
|
||||
picker.click();
|
||||
}
|
||||
|
||||
function install() {
|
||||
document.addEventListener("change", async (event) => {
|
||||
const t = event.target,
|
||||
state = EstApp.state,
|
||||
cc = state.codeChange;
|
||||
if (!cc?.rows) return;
|
||||
try {
|
||||
if (t.dataset.ccRow !== undefined) cc.sel = Number(t.dataset.ccRow);
|
||||
else if (t.dataset.ccPick !== undefined)
|
||||
cc.pick = EstCodeChange.search(cc.pps[ppsKind(cc.tag)] || [], cc.word)[
|
||||
Number(t.dataset.ccPick)
|
||||
];
|
||||
else if (t.dataset.ccOpt !== undefined) cc[t.dataset.ccOpt] = t.checked;
|
||||
else if (t.dataset.ccCode !== undefined) {
|
||||
const i = Number(t.dataset.ccCode);
|
||||
C().input(state.project, cc.tag, cc.rows, i, t.value);
|
||||
cc.dirty = true;
|
||||
} else if (t.dataset.ccPps !== undefined) {
|
||||
const file = t.files[0];
|
||||
if (!file) return;
|
||||
const rows = await EstApp.api("/api/pps-table", { method: "POST", body: file });
|
||||
const kind = rows.some((r) => /공사$/.test(r.Gubun ?? "")) ? "HEAD" : "STUFFC";
|
||||
cc.pps[kind] = rows;
|
||||
cc.message = `조달청 ${kind === "HEAD" ? "공종" : "물품"} 코드 ${rows.length}개를 불러왔습니다.`;
|
||||
} else return;
|
||||
} catch (error) {
|
||||
cc.message = error.message;
|
||||
}
|
||||
draw(state);
|
||||
});
|
||||
}
|
||||
|
||||
const names = new Set([
|
||||
"code-change",
|
||||
"code-change-tab",
|
||||
"code-change-search",
|
||||
"code-change-all",
|
||||
"code-change-rowsearch",
|
||||
"code-change-page",
|
||||
"code-change-input",
|
||||
"code-change-user",
|
||||
"code-change-cancel",
|
||||
"code-change-auto",
|
||||
"code-change-dasan",
|
||||
"code-change-bulk",
|
||||
"code-change-apply",
|
||||
"code-change-close",
|
||||
]);
|
||||
function handle(name, state, node) {
|
||||
if (!names.has(name)) return false;
|
||||
run(name, state, node).catch((error) => {
|
||||
if (!state.codeChange?.rows) return void (state.notice = error.message);
|
||||
state.codeChange.message = error.message;
|
||||
draw(state);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined" && document.addEventListener) install();
|
||||
return { open, handle };
|
||||
})();
|
||||
@@ -0,0 +1,273 @@
|
||||
// 원본 「조달청 표준공사코드 적용」(mnuCodeChange 0x10d10fc → TfrmCodeChange — 디스어셈블 근거 tmp 명세 spec_codechange).
|
||||
// 작업데이타 7종(일위 103·단산 104·기계 105·표준 21·견적 101·자재 5·노임 6)의 코드를 메모리 사본에서 바꾼 뒤 「적용」 때
|
||||
// 자기 표 Code, 본문 머리 Code·TitleCode, 참조 행(LinkIndex = 종류 · Index ≤ 0 · Code = 옛 코드) Code 만 바꾼다(재계산 없음).
|
||||
// 검사(df5e34): EST 코드 = 노랑(1) · 다른 표에 같은 코드 = 연분홍(2, 비고) · 자리수(일위·단산·표준 12, 장비 17, 견적·자재·노임 16) = 진황(3).
|
||||
// 코드일괄변경(df2b98): 고른 .estx 의 같은 종류 표에서 품명·규격·단위 맞춤(자동연결 규칙) 첫 행 — 현재 EST 코드 → 비EST 코드만,
|
||||
// 표준(21)은 「.」 뺀 옛 코드를 부분일치로. 자동변경(표준 탭): 행 번호 2~2092 C · 2095~2717 A · 2720~3361 M 앞에, 끝에 0, 「.」 제거.
|
||||
// 다산코드정리: 종류 글자 + 10자리 일련번호. 사용자정의코드(df4b64): 고른 코드 앞자리 + Z 꼴 빈 번호.
|
||||
// 원본의 조달청 목록(PPS_STUFFC·PPS_HEAD)은 설치 폴더 System\MainSys 의 .dat 를 사용자가 골라 불러온다(내장 자료 없음).
|
||||
const EstCodeChange = (() => {
|
||||
const TABLES = {
|
||||
103: "HeadTitleDat",
|
||||
104: "CostTitleDat",
|
||||
105: "MachTitleDat",
|
||||
21: "SilJukDat",
|
||||
101: "EstiMateDat",
|
||||
5: "StuffcDat",
|
||||
6: "LaborcDat",
|
||||
};
|
||||
const MAIN = { 103: "HeadMainDat", 104: "CostMainDat", 105: "MachMainDat" };
|
||||
const NAMES = {
|
||||
103: "일위대가",
|
||||
104: "단가산출",
|
||||
105: "기계경비",
|
||||
21: "표준시장단가",
|
||||
101: "견적단가",
|
||||
5: "자재단가",
|
||||
6: "노임단가",
|
||||
};
|
||||
const SHORT = {
|
||||
103: "일위",
|
||||
104: "단가",
|
||||
105: "장비",
|
||||
21: "표준",
|
||||
101: "견적",
|
||||
5: "자재",
|
||||
6: "노임",
|
||||
};
|
||||
const LETTER = {
|
||||
5: "M",
|
||||
6: "L",
|
||||
15: "N",
|
||||
21: "S",
|
||||
101: "W",
|
||||
103: "U",
|
||||
104: "D",
|
||||
105: "E",
|
||||
151: "G",
|
||||
};
|
||||
const ORDER = [103, 104, 105, 21, 101, 5, 6];
|
||||
const num = (v) => Number(v) || 0;
|
||||
const trim = (v) => String(v ?? "").trim();
|
||||
const norm = (s) => String(s ?? "").replace(/[\s|()]/g, "");
|
||||
|
||||
const letter = (tag) => LETTER[tag] || "Z";
|
||||
const isEst = (code, tag) => {
|
||||
const c = trim(code);
|
||||
return c.length > 0 && c.length <= 11 && c[0] === letter(tag) && c[1] === "0";
|
||||
};
|
||||
|
||||
function load(project, tag) {
|
||||
return (project.tables[TABLES[tag]] || []).map((row) => ({
|
||||
Index: row.Index,
|
||||
OldCode: row.Code ?? "",
|
||||
Code: row.Code ?? "",
|
||||
Name: row.Name ?? "",
|
||||
Size: row.Size ?? "",
|
||||
Unite: row.Unite ?? "",
|
||||
Flag: 0,
|
||||
Checked: false,
|
||||
Bigo: "",
|
||||
}));
|
||||
}
|
||||
// 다른 6 표에 같은 코드가 있으면 그 행 설명
|
||||
function elsewhere(project, tag, code) {
|
||||
for (const t of ORDER) {
|
||||
if (t === tag) continue;
|
||||
const row = (project.tables[TABLES[t]] || []).find((r) => trim(r.Code) === code);
|
||||
if (row) return `${SHORT[t]}"${row.Index ?? ""}.${row.Name ?? ""}(${row.Size ?? ""})" `;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
function check(project, tag, row, code = trim(row.Code)) {
|
||||
row.Flag = 0;
|
||||
if (isEst(code, tag)) return (row.Flag = 1);
|
||||
const other = elsewhere(project, tag, code);
|
||||
if (other) {
|
||||
row.Bigo = other;
|
||||
return (row.Flag = 2);
|
||||
}
|
||||
let size = [21, 103, 104].includes(tag) ? 12 : tag === 105 ? 17 : 16;
|
||||
if (tag === 105 && !/^[0-9Z]/.test(code)) return (row.Flag = 1);
|
||||
if (code.length !== size) row.Flag = 3;
|
||||
return row.Flag;
|
||||
}
|
||||
function validate(project, tag, rows) {
|
||||
for (const row of rows) {
|
||||
row.Flag = 0;
|
||||
row.Checked = false;
|
||||
row.Bigo = "";
|
||||
if (trim(row.Code)) check(project, tag, row);
|
||||
}
|
||||
return rows.filter((r) => r.Flag).length;
|
||||
}
|
||||
const MESSAGE = {
|
||||
1: "조달청 코드가 아닙니다.",
|
||||
3: "코드자리수가 틀립니다.일위,단산,표준:12자리, 장비:17자리 ,견적,노임,자재:16자리",
|
||||
};
|
||||
|
||||
// 코드변경(df20f4): 목록 코드 또는 직접 입력을 현재 행에. 실패하면 문구를 던진다.
|
||||
function input(project, tag, rows, i, code, from) {
|
||||
let c = trim(code);
|
||||
if (tag === 105 && c.length === 16) c += "0";
|
||||
if (!rows[i]) throw Error("바꿀 작업데이타 행을 고르세요.");
|
||||
const dup = rows.findIndex((r, j) => j !== i && trim(r.Code) === c);
|
||||
if (dup >= 0) {
|
||||
rows[dup].Flag = 2;
|
||||
throw Error("같은 코드가 있습니다. 확인하세요.");
|
||||
}
|
||||
const probe = { ...rows[i] };
|
||||
const flag = check(project, tag, probe, c);
|
||||
if (flag === 2) throw Error(`${probe.Bigo}에 같은 코드가 있습니다.`);
|
||||
if (flag) throw Error(MESSAGE[flag]);
|
||||
rows[i].Code = c;
|
||||
if (from?.nameChange && trim(from.Name)) {
|
||||
rows[i].Name = from.Name;
|
||||
rows[i].Size = from.Size ?? "";
|
||||
rows[i].Unite = from.Unit ?? from.Unite ?? "";
|
||||
}
|
||||
}
|
||||
function cancel(rows, i) {
|
||||
if (rows[i]) rows[i].Code = rows[i].OldCode;
|
||||
}
|
||||
|
||||
// 적용(df1c40)
|
||||
function apply(project, tag, rows) {
|
||||
const t = project.tables,
|
||||
own = t[TABLES[tag]] || [];
|
||||
let changed = 0;
|
||||
for (const r of rows) {
|
||||
const neu = trim(r.Code),
|
||||
old = trim(r.OldCode);
|
||||
if (!neu || neu === old) continue;
|
||||
const same = (x) => (x.Name ?? "") === r.Name && (x.Size ?? "") === r.Size;
|
||||
let hit = own.find((x) => trim(x.Code) === old);
|
||||
if (hit && !same(hit)) hit = own.find((x) => trim(x.Code) === old && same(x));
|
||||
if (hit) {
|
||||
hit.Code = neu;
|
||||
changed++;
|
||||
}
|
||||
if (MAIN[tag])
|
||||
for (const x of t[MAIN[tag]] || [])
|
||||
if (trim(x.TitleCode) === old) {
|
||||
x.Code = neu;
|
||||
x.TitleCode = neu;
|
||||
}
|
||||
const refs =
|
||||
tag === 103
|
||||
? ["HeadMainDat", "CostMainDat"]
|
||||
: tag === 104
|
||||
? ["CostMainDat", "HeadMainDat"]
|
||||
: tag === 105
|
||||
? ["MachMainDat", "CostMainDat", "HeadMainDat"]
|
||||
: ["MachMainDat", "CostMainDat", "HeadMainDat"];
|
||||
for (const name of [...refs, "EstMainDat"])
|
||||
for (const x of t[name] || [])
|
||||
if (num(x.LinkIndex) === tag && trim(x.Code) === old && num(x.Index) <= 0) {
|
||||
x.Code = neu;
|
||||
changed++;
|
||||
}
|
||||
r.OldCode = neu;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// 코드일괄변경(df2b98) — source = 고른 .estx 를 읽은 공사
|
||||
function bulk(project, tag, rows, source) {
|
||||
const list = source.tables?.[TABLES[tag]] || [];
|
||||
let changed = 0;
|
||||
rows.forEach((r) => (r.Checked = false));
|
||||
for (const r of rows) {
|
||||
const cur = trim(r.Code);
|
||||
if (!cur) continue;
|
||||
let found;
|
||||
if (tag === 21) {
|
||||
if (!cur.includes(".")) continue;
|
||||
const k = cur.replaceAll(".", "");
|
||||
found = list.find((x) => String(x.Code ?? "").includes(k));
|
||||
if (!found) continue;
|
||||
const c = trim(found.Code);
|
||||
if (rows.some((x) => trim(x.Code) === c) || elsewhere(project, tag, c)) continue;
|
||||
r.Code = c;
|
||||
} else {
|
||||
found = EstAutoLink.find(source, tag, EstAutoLink.key(r, 0));
|
||||
if (!found) continue;
|
||||
const c = trim(found.Code);
|
||||
if (rows.some((x) => trim(x.Code) === c) || elsewhere(project, tag, c)) continue;
|
||||
if (!isEst(cur, tag) || isEst(c, tag)) continue;
|
||||
r.Code = c;
|
||||
r.Flag = 0;
|
||||
}
|
||||
r.Checked = true;
|
||||
changed++;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
function auto(rows) {
|
||||
rows.forEach((r, i) => {
|
||||
const n = i + 1,
|
||||
c = trim(r.Code);
|
||||
if (!c) return;
|
||||
const head =
|
||||
n >= 2 && n <= 2092
|
||||
? "C"
|
||||
: n >= 2095 && n <= 2717
|
||||
? "A"
|
||||
: n >= 2720 && n <= 3361
|
||||
? "M"
|
||||
: "";
|
||||
r.Code = (head ? `${head}${c}0` : c).replaceAll(".", "");
|
||||
});
|
||||
}
|
||||
function dasan(rows, tag) {
|
||||
rows.forEach((r, i) => (r.Code = letter(tag) + String(i).padStart(10, "0")));
|
||||
}
|
||||
function userCode(project, tag, rows, base) {
|
||||
const s = `${trim(base)}00000000`;
|
||||
const make = (n) => {
|
||||
const nn = `000${n}`;
|
||||
if (tag === 103 || tag === 104) return `${s.slice(0, 8)}Z${nn.slice(-3)}`;
|
||||
if (tag === 105) return `${s.slice(0, 8)}000Z${nn.slice(-4)}0`;
|
||||
if (tag === 101 || tag === 5) return `${s.slice(0, 6)}ZZ000Z${nn.slice(-4)}`;
|
||||
if (tag === 6) return `${s.slice(0, 8)}010Z${nn.slice(-4)}`;
|
||||
return "";
|
||||
};
|
||||
const extra = { 103: "CostTitleDat", 104: "HeadTitleDat", 105: "MachTitleDat" }[tag];
|
||||
for (let n = 0; n <= 999; n++) {
|
||||
const c = make(n);
|
||||
if (!c) return "";
|
||||
if (rows.some((r) => trim(r.Code) === c)) continue;
|
||||
if (extra && (project.tables[extra] || []).some((r) => trim(r.Code) === c)) continue;
|
||||
return c;
|
||||
}
|
||||
throw Error("사용자 정의코드 갯수범위를 초과 했습니다.");
|
||||
}
|
||||
// 목록 검색(df6b5c): 정규화한 품명+규격에 검색어 포함, 「&」 이면 두 조각 모두
|
||||
function search(list, word) {
|
||||
const w = norm(word);
|
||||
if (!w) return list;
|
||||
const parts = w.includes("&") ? w.split("&").slice(0, 2) : [w];
|
||||
return list.filter((x) => {
|
||||
const k = norm(x.Name) + norm(x.Size);
|
||||
return parts.every((p) => k.includes(p));
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
TABLES,
|
||||
NAMES,
|
||||
ORDER,
|
||||
isEst,
|
||||
load,
|
||||
validate,
|
||||
input,
|
||||
cancel,
|
||||
apply,
|
||||
bulk,
|
||||
auto,
|
||||
dasan,
|
||||
userCode,
|
||||
search,
|
||||
};
|
||||
})();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = EstCodeChange;
|
||||
@@ -0,0 +1,163 @@
|
||||
// 원본 엑셀형 수량산출서(TFRMMULEXCEL) 편집 동작 — 행·열 삽입/삭제(actInsertRows·actInsertCol·actCells·actDeleteCellS),
|
||||
// 잘라내기·복사·붙여넣기(popCut·popCopy·popPaste, 상대 참조는 옮긴 만큼 이동), 내용지우기(popCellClear),
|
||||
// 병합·분리(actMergeCells·actSplitCells), 글꼴·정렬(tbBold·tbItalic·tbUnderline·tb*Align), 대각선(popTilt*).
|
||||
// 시트 = { name, cells: {"행,열": { value, formula?, style? }}, merges?: [{ r1, c1, r2, c2 }] }.
|
||||
// 행·열을 지우면 그 칸을 가리키던 참조는 #REF! 가 되고(계산 오류), 범위 끝이 지워지면 범위를 줄인다.
|
||||
const EstExcelEdit = (() => {
|
||||
const Sheet =
|
||||
typeof EstExcelSheet !== "undefined" ? EstExcelSheet : require("./native-excel-sheet.cjs");
|
||||
const REF =
|
||||
/("(?:[^"]|"")*")|(\$?)([A-Za-z]{1,3})(\$?)(\d+)(?::(\$?)([A-Za-z]{1,3})(\$?)(\d+))?(?![A-Za-z0-9_(])/g;
|
||||
const colOf = (s) => [...s.toUpperCase()].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1;
|
||||
const write = (p) => `${p.ac}${Sheet.letters(p.col)}${p.ar}${p.row + 1}`;
|
||||
|
||||
// fn(ends) → 새 끝점 배열 또는 null(#REF!). ends = [{ col, row, ac, ar }] (범위면 둘)
|
||||
function mapRefs(formula, fn) {
|
||||
return String(formula).replace(REF, (all, str, ac, c, ar, r, ac2, c2, ar2, r2) => {
|
||||
if (str) return str;
|
||||
const ends = [{ ac, col: colOf(c), ar, row: Number(r) - 1 }];
|
||||
if (c2) ends.push({ ac: ac2, col: colOf(c2), ar: ar2, row: Number(r2) - 1 });
|
||||
const out = fn(ends);
|
||||
if (!out) return "#REF!";
|
||||
return out.map(write).join(":");
|
||||
});
|
||||
}
|
||||
function offsetFormula(formula, dr, dc) {
|
||||
return mapRefs(formula, (ends) => {
|
||||
const out = ends.map((e) => ({
|
||||
...e,
|
||||
row: e.ar ? e.row : e.row + dr,
|
||||
col: e.ac ? e.col : e.col + dc,
|
||||
}));
|
||||
return out.some((e) => e.row < 0 || e.col < 0) ? null : out;
|
||||
});
|
||||
}
|
||||
|
||||
// axis "row" | "col", at = 첫 위치, count > 0 넣기 · < 0 지우기
|
||||
function shift(sheet, axis, at, count, links) {
|
||||
const k = axis === "row" ? "row" : "col",
|
||||
gone = (i) => count < 0 && i >= at && i < at - count,
|
||||
move = (i) => (i >= at ? i + count : i);
|
||||
const cells = {};
|
||||
for (const [key, cell] of Object.entries(sheet.cells)) {
|
||||
const [row, col] = key.split(",").map(Number),
|
||||
p = { row, col };
|
||||
if (gone(p[k])) continue;
|
||||
p[k] = move(p[k]);
|
||||
cells[`${p.row},${p.col}`] = cell;
|
||||
}
|
||||
for (const cell of Object.values(cells))
|
||||
if (typeof cell.formula === "string")
|
||||
cell.formula = mapRefs(cell.formula, (ends) => {
|
||||
if (ends.length === 1) {
|
||||
if (gone(ends[0][k])) return null;
|
||||
return [{ ...ends[0], [k]: move(ends[0][k]) }];
|
||||
}
|
||||
let [a, b] = ends.map((e) => ({ ...e }));
|
||||
if (a[k] > b[k]) [a, b] = [b, a];
|
||||
if (count < 0) {
|
||||
if (gone(a[k]) && gone(b[k])) return null;
|
||||
if (gone(a[k])) a[k] = at - count;
|
||||
if (gone(b[k])) b[k] = at - 1;
|
||||
}
|
||||
a[k] = move(a[k]);
|
||||
b[k] = move(b[k]);
|
||||
return [a, b];
|
||||
});
|
||||
sheet.cells = cells;
|
||||
const k1 = k === "row" ? "r1" : "c1",
|
||||
k2 = k === "row" ? "r2" : "c2";
|
||||
sheet.merges = (sheet.merges || [])
|
||||
.filter((m) => !(gone(m[k1]) && gone(m[k2])))
|
||||
.map((m) => {
|
||||
const x = { ...m };
|
||||
if (gone(x[k1])) x[k1] = at - count;
|
||||
if (gone(x[k2])) x[k2] = at - 1;
|
||||
x[k1] = move(x[k1]);
|
||||
x[k2] = move(x[k2]);
|
||||
return x;
|
||||
})
|
||||
.filter((m) => m.r2 > m.r1 || m.c2 > m.c1);
|
||||
for (const [code, l] of Object.entries(links || {})) {
|
||||
if (gone(l[k])) delete links[code];
|
||||
else l[k] = move(l[k]);
|
||||
}
|
||||
return Sheet.recalc(sheet.cells);
|
||||
}
|
||||
|
||||
const norm = (r) => ({
|
||||
r1: Math.min(r.r1, r.r2),
|
||||
r2: Math.max(r.r1, r.r2),
|
||||
c1: Math.min(r.c1, r.c2),
|
||||
c2: Math.max(r.c1, r.c2),
|
||||
});
|
||||
function copyRange(sheet, range) {
|
||||
const { r1, r2, c1, c2 } = norm(range),
|
||||
cells = {};
|
||||
for (let r = r1; r <= r2; r++)
|
||||
for (let c = c1; c <= c2; c++) {
|
||||
const cell = sheet.cells[`${r},${c}`];
|
||||
if (cell) cells[`${r - r1},${c - c1}`] = structuredClone(cell);
|
||||
}
|
||||
return { rows: r2 - r1 + 1, cols: c2 - c1 + 1, from: { row: r1, col: c1 }, cells };
|
||||
}
|
||||
function clearRange(sheet, range, keepStyle = false) {
|
||||
const { r1, r2, c1, c2 } = norm(range);
|
||||
for (let r = r1; r <= r2; r++)
|
||||
for (let c = c1; c <= c2; c++) {
|
||||
const k = `${r},${c}`,
|
||||
style = sheet.cells[k]?.style;
|
||||
if (keepStyle && style) sheet.cells[k] = { value: "", style };
|
||||
else delete sheet.cells[k];
|
||||
}
|
||||
return Sheet.recalc(sheet.cells);
|
||||
}
|
||||
function pasteRange(sheet, clip, row, col) {
|
||||
const dr = row - clip.from.row,
|
||||
dc = col - clip.from.col;
|
||||
for (let r = 0; r < clip.rows; r++)
|
||||
for (let c = 0; c < clip.cols; c++) delete sheet.cells[`${row + r},${col + c}`];
|
||||
for (const [k, cell] of Object.entries(clip.cells)) {
|
||||
const [r, c] = k.split(",").map(Number),
|
||||
copy = structuredClone(cell);
|
||||
if (typeof copy.formula === "string") copy.formula = offsetFormula(copy.formula, dr, dc);
|
||||
sheet.cells[`${row + r},${col + c}`] = copy;
|
||||
}
|
||||
return Sheet.recalc(sheet.cells);
|
||||
}
|
||||
|
||||
function merge(sheet, range) {
|
||||
const m = norm(range);
|
||||
if (m.r1 === m.r2 && m.c1 === m.c2) throw Error("병합할 범위를 두 칸 이상 고르세요.");
|
||||
split(sheet, m);
|
||||
sheet.merges.push(m);
|
||||
}
|
||||
function split(sheet, range) {
|
||||
const m = norm(range);
|
||||
sheet.merges = (sheet.merges || []).filter(
|
||||
(x) => x.r2 < m.r1 || x.r1 > m.r2 || x.c2 < m.c1 || x.c1 > m.c2,
|
||||
);
|
||||
}
|
||||
// name: b·i·u (켜고 끄기), align: left·center·right, diag: left·right·"" (대각선)
|
||||
function style(sheet, range, name, value) {
|
||||
const { r1, r2, c1, c2 } = norm(range);
|
||||
let on = null;
|
||||
for (let r = r1; r <= r2; r++)
|
||||
for (let c = c1; c <= c2; c++) {
|
||||
const k = `${r},${c}`,
|
||||
cell = (sheet.cells[k] ||= { value: "" }),
|
||||
s = { ...(cell.style || {}) };
|
||||
if (["b", "i", "u"].includes(name)) {
|
||||
on ??= !s[name];
|
||||
if (on) s[name] = true;
|
||||
else delete s[name];
|
||||
} else if (value) s[name] = value;
|
||||
else delete s[name];
|
||||
if (Object.keys(s).length) cell.style = s;
|
||||
else delete cell.style;
|
||||
}
|
||||
}
|
||||
|
||||
return { mapRefs, offsetFormula, shift, copyRange, clearRange, pasteRange, merge, split, style };
|
||||
})();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = EstExcelEdit;
|
||||
@@ -1,9 +1,65 @@
|
||||
// 원본 엑셀형 수량산출서(TFRMMULEXCEL) 웹 편집 화면 — 셀 고르기·수식줄 편집·다시 계산, 셀 합(우·상·좌·하 btnSum*),
|
||||
// 수량으로 지정(popSetMullang)·지정 해제(popSetFree) = 셀 ↔ 수량 항목 연결(sheet.excelLinks). 항목 수량 = 연결 셀 값(산출서 자리수).
|
||||
// 원본 파일 속 셀↔항목 대응은 찾지 못해, 가져올 때 값이 같은 셀 하나(합계 수식·아래쪽 우선)를 「추정」 연결로 둔다.
|
||||
// 편집한 엑셀형 산출서는 .xls 로 다시 쓰지 못하므로 ESTX 저장을 막는다(sheet.excelEdited).
|
||||
// 원본 엑셀형 수량산출서(TFRMMULEXCEL) 웹 편집 화면 — 셀 고르기(Shift 로 범위)·수식줄 편집·다시 계산(btnReCalc),
|
||||
// 셀 합(우·상·좌·하 btnSum*), 수량·품명·규격·단위로 지정(popSetMullang·popSetName·popSetSize·popSetUnite)·지정 해제(popSetFree),
|
||||
// 잘라내기·복사·붙여넣기·내용지우기, 행·열 삽입/삭제, 굵게·기울임·밑줄·정렬, 병합·분리, 대각선(native-excel-edit.cjs).
|
||||
// 수량 지정 = 셀 ↔ 항목 연결(sheet.excelLinks) — 항목 수량이 그 셀 값(산출서 자리수)을 따른다.
|
||||
// 품명·규격·단위 지정은 그때 셀 글자를 항목에 적는다. 원본 파일 속 셀↔항목 대응은 찾지 못해, 가져올 때 값이 같은 셀
|
||||
// 하나(합계 수식·아래쪽 우선)를 「추정」 연결로 둔다. 편집한 엑셀형 산출서는 .xls 로 다시 쓰지 못하므로 ESTX 저장을 막는다.
|
||||
// 되돌리기(actUndo)는 앱 전체 취소(↶)를 쓴다.
|
||||
const EstExcelView = (() => {
|
||||
const L = (n) => EstExcelSheet.letters(n);
|
||||
const DIAG = {
|
||||
left: "linear-gradient(to bottom right,transparent 49%,#555 50%,transparent 51%)",
|
||||
right: "linear-gradient(to top right,transparent 49%,#555 50%,transparent 51%)",
|
||||
};
|
||||
const range = (sel) => ({
|
||||
r1: Math.min(sel.row, sel.row2 ?? sel.row),
|
||||
r2: Math.max(sel.row, sel.row2 ?? sel.row),
|
||||
c1: Math.min(sel.col, sel.col2 ?? sel.col),
|
||||
c2: Math.max(sel.col, sel.col2 ?? sel.col),
|
||||
});
|
||||
const btn = (action, label, extra = "") =>
|
||||
`<button data-action="${action}"${extra ? ` ${extra}` : ""}>${label}</button>`;
|
||||
|
||||
function toolbar(sheet) {
|
||||
const items = (sheet.items || [])
|
||||
.map(
|
||||
(i) =>
|
||||
`<option value="${EstApp.esc(i.Code)}">${EstApp.esc(i.Code)} ${EstApp.esc(i.Name ?? "")} ${EstApp.esc(i.Size ?? "")}</option>`,
|
||||
)
|
||||
.join("");
|
||||
return [
|
||||
btn("excel-recalc", "다시 계산"),
|
||||
btn("excel-cut", "잘라내기"),
|
||||
btn("excel-copy", "복사"),
|
||||
btn("excel-paste", "붙여넣기"),
|
||||
btn("excel-clear", "내용지우기"),
|
||||
btn("excel-insert", "행 삽입", 'data-axis="row"'),
|
||||
btn("excel-insert", "열 삽입", 'data-axis="col"'),
|
||||
btn("excel-delete", "행 삭제", 'data-axis="row"'),
|
||||
btn("excel-delete", "열 삭제", 'data-axis="col"'),
|
||||
btn("excel-style", "<b>B</b>", 'data-name="b" title="굵게"'),
|
||||
btn("excel-style", "<i>I</i>", 'data-name="i" title="기울임"'),
|
||||
btn("excel-style", "<u>U</u>", 'data-name="u" title="밑줄"'),
|
||||
btn("excel-style", "왼쪽", 'data-name="align" data-value="left"'),
|
||||
btn("excel-style", "가운데", 'data-name="align" data-value="center"'),
|
||||
btn("excel-style", "오른쪽", 'data-name="align" data-value="right"'),
|
||||
btn("excel-merge", "병합"),
|
||||
btn("excel-split", "분리"),
|
||||
btn("excel-style", "왼쪽대각선", 'data-name="diag" data-value="left"'),
|
||||
btn("excel-style", "오른쪽대각선", 'data-name="diag" data-value="right"'),
|
||||
btn("excel-style", "대각선 삭제", 'data-name="diag" data-value=""'),
|
||||
btn("excel-sum", "우측 합", 'data-dir="right"'),
|
||||
btn("excel-sum", "위 합", 'data-dir="up"'),
|
||||
btn("excel-sum", "좌측 합", 'data-dir="left"'),
|
||||
btn("excel-sum", "아래 합", 'data-dir="down"'),
|
||||
`<select data-excel-item aria-label="수량 항목">${items}</select>`,
|
||||
btn("excel-link", "수량으로 지정"),
|
||||
btn("excel-field", "품명으로 지정", 'data-field="Name"'),
|
||||
btn("excel-field", "규격으로 지정", 'data-field="Size"'),
|
||||
btn("excel-field", "단위로 지정", 'data-field="Unite"'),
|
||||
btn("excel-unlink", "지정 해제"),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function render(sheet, state) {
|
||||
if (!sheet?.excel?.length) return "";
|
||||
@@ -14,30 +70,36 @@ const EstExcelView = (() => {
|
||||
Object.entries(links).map(([code, l]) => [`${l.n},${l.row},${l.col}`, { code, ...l }]),
|
||||
);
|
||||
return sheet.excel
|
||||
.map(({ name, cells }, n) => {
|
||||
.map(({ name, cells, merges = [] }, n) => {
|
||||
const keys = Object.keys(cells).map((k) => k.split(",").map(Number));
|
||||
const rows = Math.max(20, ...keys.map(([r]) => r + 2)),
|
||||
cols = Math.min(40, Math.max(8, ...keys.map(([, c]) => c + 2)));
|
||||
const raw = sel && sel.n === n ? cells[`${sel.row},${sel.col}`] : null;
|
||||
const bar =
|
||||
sel && sel.n === n
|
||||
? `${L(sel.col)}${sel.row + 1} <input data-excel-bar style="width:50%" value="${esc(raw ? (typeof raw.formula === "string" ? `=${raw.formula}` : (raw.value ?? "")) : "")}" aria-label="수식줄">`
|
||||
: "셀을 누르면 수식줄에서 고칩니다.";
|
||||
const items = (sheet.items || [])
|
||||
.map(
|
||||
(i) =>
|
||||
`<option value="${esc(i.Code)}">${esc(i.Code)} ${esc(i.Name ?? "")} ${esc(i.Size ?? "")}</option>`,
|
||||
)
|
||||
.join("");
|
||||
const tools =
|
||||
sel && sel.n === n
|
||||
? ` <button data-action="excel-sum" data-dir="right">우측 합</button><button data-action="excel-sum" data-dir="up">위 합</button><button data-action="excel-sum" data-dir="left">좌측 합</button><button data-action="excel-sum" data-dir="down">아래 합</button> <select data-excel-item aria-label="수량 항목">${items}</select><button data-action="excel-link">수량으로 지정</button><button data-action="excel-unlink">지정 해제</button>`
|
||||
: "";
|
||||
const rows = Math.max(20, ...keys.map(([r]) => r + 2), ...merges.map((m) => m.r2 + 2)),
|
||||
cols = Math.min(
|
||||
40,
|
||||
Math.max(8, ...keys.map(([, c]) => c + 2), ...merges.map((m) => m.c2 + 2)),
|
||||
);
|
||||
const here = sel && sel.n === n,
|
||||
box = here ? range(sel) : null;
|
||||
const raw = here ? cells[`${sel.row},${sel.col}`] : null;
|
||||
const bar = here
|
||||
? `${L(sel.col)}${sel.row + 1}${box.r2 > box.r1 || box.c2 > box.c1 ? `:${L(box.c2)}${box.r2 + 1}` : ""} <input data-excel-bar style="width:50%" value="${esc(raw ? (typeof raw.formula === "string" ? `=${raw.formula}` : (raw.value ?? "")) : "")}" aria-label="수식줄"><br>${toolbar(sheet)}`
|
||||
: "셀을 누르면 수식줄에서 고칩니다. Shift 로 범위를 고릅니다.";
|
||||
const covered = new Set(),
|
||||
spans = new Map();
|
||||
for (const m of merges) {
|
||||
spans.set(`${m.r1},${m.c1}`, m);
|
||||
for (let r = m.r1; r <= m.r2; r++)
|
||||
for (let c = m.c1; c <= m.c2; c++)
|
||||
if (r !== m.r1 || c !== m.c1) covered.add(`${r},${c}`);
|
||||
}
|
||||
const body = Array.from({ length: rows }, (_, r) => {
|
||||
const tds = Array.from({ length: cols }, (_, c) => {
|
||||
const cell = cells[`${r},${c}`],
|
||||
const k = `${r},${c}`;
|
||||
if (covered.has(k)) return "";
|
||||
const cell = cells[k],
|
||||
link = linked.get(`${n},${r},${c}`),
|
||||
chosen = sel && sel.n === n && sel.row === r && sel.col === c;
|
||||
m = spans.get(k),
|
||||
chosen = box && r >= box.r1 && r <= box.r2 && c >= box.c1 && c <= box.c2,
|
||||
s = cell?.style || {};
|
||||
const value =
|
||||
cell == null
|
||||
? ""
|
||||
@@ -45,9 +107,13 @@ const EstExcelView = (() => {
|
||||
? number(cell.value)
|
||||
: esc(cell.value);
|
||||
const style = [
|
||||
cell?.formula ? "font-weight:bold" : "",
|
||||
link ? "background:#e6f4ea" : "",
|
||||
chosen ? "outline:2px solid #316ea5" : "",
|
||||
cell?.formula || s.b ? "font-weight:bold" : "",
|
||||
s.i ? "font-style:italic" : "",
|
||||
s.u ? "text-decoration:underline" : "",
|
||||
s.align ? `text-align:${s.align}` : "",
|
||||
s.diag ? `background-image:${DIAG[s.diag]}` : "",
|
||||
link ? "background-color:#e6f4ea" : chosen ? "background-color:#e8f0fb" : "",
|
||||
here && sel.row === r && sel.col === c ? "outline:2px solid #316ea5" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(";");
|
||||
@@ -57,11 +123,14 @@ const EstExcelView = (() => {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return `<td data-excel-pick="${n},${r},${c}"${style ? ` style="${style}"` : ""}${title ? ` title="${esc(title)}"` : ""}>${value}</td>`;
|
||||
const span = m
|
||||
? `${m.r2 > m.r1 ? ` rowspan="${m.r2 - m.r1 + 1}"` : ""}${m.c2 > m.c1 ? ` colspan="${m.c2 - m.c1 + 1}"` : ""}`
|
||||
: "";
|
||||
return `<td data-excel-pick="${n},${r},${c}"${span}${style ? ` style="${style}"` : ""}${title ? ` title="${esc(title)}"` : ""}>${value}</td>`;
|
||||
}).join("");
|
||||
return `<tr><th>${r + 1}</th>${tds}</tr>`;
|
||||
}).join("");
|
||||
return `<h4>엑셀형 산출서 · ${esc(name)}</h4><div class="business-tools">${bar}${tools}</div><div style="overflow:auto;max-height:420px"><table class="sheet" style="font-size:11px"><thead><tr><th></th>${Array.from({ length: cols }, (_, c) => `<th>${L(c)}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div>`;
|
||||
return `<h4>엑셀형 산출서 · ${esc(name)}</h4><div class="business-tools">${bar}</div><div style="overflow:auto;max-height:420px"><table class="sheet" style="font-size:11px"><thead><tr><th></th>${Array.from({ length: cols }, (_, c) => `<th>${L(c)}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -72,23 +141,35 @@ const EstExcelView = (() => {
|
||||
function setCell(sheet, n, row, col, text) {
|
||||
const cells = sheet.excel[n].cells,
|
||||
k = `${row},${col}`,
|
||||
t = String(text ?? "");
|
||||
if (t === "") delete cells[k];
|
||||
t = String(text ?? ""),
|
||||
style = cells[k]?.style;
|
||||
if (t === "" && !style) delete cells[k];
|
||||
else if (t.startsWith("=")) cells[k] = { value: 0, formula: t.slice(1) };
|
||||
else {
|
||||
const v = Number(t.replaceAll(",", ""));
|
||||
cells[k] = { value: t.trim() !== "" && Number.isFinite(v) ? v : t };
|
||||
}
|
||||
if (style && cells[k]) cells[k].style = style;
|
||||
sheet.excelEdited = true;
|
||||
return EstExcelSheet.recalc(cells);
|
||||
}
|
||||
function linksOf(sheet, n) {
|
||||
const out = {};
|
||||
for (const [code, l] of Object.entries(sheet.excelLinks || {})) if (l.n === n) out[code] = l;
|
||||
return out;
|
||||
}
|
||||
|
||||
function install() {
|
||||
document.addEventListener("click", (event) => {
|
||||
const td = event.target.closest("[data-excel-pick]");
|
||||
if (!td) return;
|
||||
const [n, row, col] = td.dataset.excelPick.split(",").map(Number);
|
||||
EstApp.state.excelCell = { sheet: EstApp.state.quantitySheet, n, row, col };
|
||||
const [n, row, col] = td.dataset.excelPick.split(",").map(Number),
|
||||
state = EstApp.state,
|
||||
old = state.excelCell;
|
||||
state.excelCell =
|
||||
event.shiftKey && old && old.sheet === state.quantitySheet && old.n === n
|
||||
? { ...old, row2: row, col2: col }
|
||||
: { sheet: state.quantitySheet, n, row, col };
|
||||
EstApp.render();
|
||||
});
|
||||
document.addEventListener("change", (event) => {
|
||||
@@ -102,23 +183,101 @@ const EstExcelView = (() => {
|
||||
}, false);
|
||||
if (errors.length) state.notice = errors.slice(0, 3).join(" / ");
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
const state = EstApp.state;
|
||||
if (!state.excelCell || event.target.closest?.("input,textarea,select")) return;
|
||||
const keyAction = event.ctrlKey
|
||||
? { c: "excel-copy", x: "excel-cut", v: "excel-paste" }[event.key.toLowerCase()]
|
||||
: event.key === "Delete"
|
||||
? "excel-clear"
|
||||
: null;
|
||||
if (!keyAction || !sheetOf(state)?.excel) return;
|
||||
event.preventDefault();
|
||||
try {
|
||||
handle(keyAction, state, { dataset: {} });
|
||||
} catch (error) {
|
||||
state.notice = error.message;
|
||||
EstApp.render();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const names = new Set([
|
||||
"excel-sum",
|
||||
"excel-link",
|
||||
"excel-unlink",
|
||||
"excel-field",
|
||||
"excel-recalc",
|
||||
"excel-cut",
|
||||
"excel-copy",
|
||||
"excel-paste",
|
||||
"excel-clear",
|
||||
"excel-insert",
|
||||
"excel-delete",
|
||||
"excel-style",
|
||||
"excel-merge",
|
||||
"excel-split",
|
||||
]);
|
||||
function handle(name, state, node) {
|
||||
if (!["excel-sum", "excel-link", "excel-unlink"].includes(name)) return false;
|
||||
if (!names.has(name)) return false;
|
||||
const sheet = sheetOf(state),
|
||||
sel = state.excelCell;
|
||||
if (!sheet?.excel || !sel) throw Error("엑셀형 산출서에서 셀을 고르세요.");
|
||||
const page = sheet.excel[sel.n],
|
||||
box = range(sel),
|
||||
edit = (fn) =>
|
||||
EstApp.change(() => {
|
||||
const errors = fn() || [];
|
||||
sheet.excelEdited = true;
|
||||
if (errors.length) state.notice = errors.slice(0, 3).join(" / ");
|
||||
}, false);
|
||||
if (name === "excel-sum") {
|
||||
const formula = EstExcelSheet.sumFormula(
|
||||
sheet.excel[sel.n].cells,
|
||||
sel.row,
|
||||
sel.col,
|
||||
node.dataset.dir,
|
||||
);
|
||||
const formula = EstExcelSheet.sumFormula(page.cells, sel.row, sel.col, node.dataset.dir);
|
||||
EstApp.change(() => setCell(sheet, sel.n, sel.row, sel.col, `=${formula}`), false);
|
||||
} else {
|
||||
const code = document.querySelector("[data-excel-item]")?.value;
|
||||
} else if (name === "excel-recalc") edit(() => EstExcelSheet.recalc(page.cells));
|
||||
else if (name === "excel-copy" || name === "excel-cut") {
|
||||
state.excelClip = EstExcelEdit.copyRange(page, box);
|
||||
if (name === "excel-cut") edit(() => EstExcelEdit.clearRange(page, box));
|
||||
else state.notice = `${state.excelClip.rows}×${state.excelClip.cols} 칸을 복사했습니다.`;
|
||||
} else if (name === "excel-paste") {
|
||||
if (!state.excelClip) throw Error("복사한 셀이 없습니다.");
|
||||
edit(() => EstExcelEdit.pasteRange(page, state.excelClip, box.r1, box.c1));
|
||||
} else if (name === "excel-clear") edit(() => EstExcelEdit.clearRange(page, box, true));
|
||||
else if (name === "excel-insert" || name === "excel-delete") {
|
||||
const axis = node.dataset.axis,
|
||||
at = axis === "row" ? box.r1 : box.c1,
|
||||
count = axis === "row" ? box.r2 - box.r1 + 1 : box.c2 - box.c1 + 1;
|
||||
if (
|
||||
name === "excel-delete" &&
|
||||
!confirm(`${axis === "row" ? "행" : "열"} ${count}개를 삭제하시겠습니까?`)
|
||||
)
|
||||
return true;
|
||||
edit(() =>
|
||||
EstExcelEdit.shift(
|
||||
page,
|
||||
axis,
|
||||
at,
|
||||
name === "excel-insert" ? count : -count,
|
||||
linksOf(sheet, sel.n),
|
||||
),
|
||||
);
|
||||
state.excelCell = { sheet: sel.sheet, n: sel.n, row: box.r1, col: box.c1 };
|
||||
} else if (name === "excel-style")
|
||||
edit(() => EstExcelEdit.style(page, box, node.dataset.name, node.dataset.value));
|
||||
else if (name === "excel-merge") edit(() => EstExcelEdit.merge(page, box));
|
||||
else if (name === "excel-split") edit(() => EstExcelEdit.split(page, box));
|
||||
else {
|
||||
const code = document.querySelector("[data-excel-item]")?.value,
|
||||
item = sheet.items?.find((i) => i.Code === code);
|
||||
if (name === "excel-field") {
|
||||
if (!item) throw Error("항목을 고르세요.");
|
||||
const v = page.cells[`${sel.row},${sel.col}`]?.value;
|
||||
if (v == null || v === "") throw Error("빈 셀입니다.");
|
||||
edit(() => {
|
||||
item[node.dataset.field] = String(v);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
EstApp.change(() => {
|
||||
sheet.excelLinks ||= {};
|
||||
for (const [k, l] of Object.entries(sheet.excelLinks))
|
||||
|
||||
@@ -4,7 +4,7 @@ const http = require("node:http"),
|
||||
path = require("node:path"),
|
||||
vm = require("node:vm"),
|
||||
crypto = require("node:crypto");
|
||||
const { importEstx } = require("./import-estx.cjs");
|
||||
const { importEstx, table: dbisamTable } = require("./import-estx.cjs");
|
||||
const exchange = require("./exchange.cjs");
|
||||
const { exportEstx } = require("./export-estx.cjs");
|
||||
const jdl = require("./jdl.cjs");
|
||||
@@ -85,6 +85,9 @@ const allowed = new Set([
|
||||
"native-mach-formula.js",
|
||||
"native-defaults.js",
|
||||
"native-excel-view.js",
|
||||
"native-excel-edit.cjs",
|
||||
"native-code-change.js",
|
||||
"native-code-change-view.js",
|
||||
"native-progress-wonga.js",
|
||||
"native-haljung.js",
|
||||
"native-vat.js",
|
||||
@@ -233,6 +236,18 @@ const server = http.createServer(async (req, res) => {
|
||||
reply(res, 200, importEstx(bytes, name));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/pps-table") {
|
||||
// 조달청 코드 목록(원본 System\MainSys\PPS_STUFFC.dat · PPS_HEAD.dat, DBISAM) — 코드·명칭·규격·단위만
|
||||
const rows = dbisamTable(await body(req), null, []);
|
||||
if (!rows.length || !("Code" in rows[0]) || !("Name" in rows[0]))
|
||||
throw Error("조달청 코드 목록(PPS_STUFFC.dat · PPS_HEAD.dat)이 아닙니다.");
|
||||
reply(
|
||||
res,
|
||||
200,
|
||||
rows.map(({ Gubun, Code, Name, Size, Unit }) => ({ Gubun, Code, Name, Size, Unit })),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/inspect-accom") {
|
||||
reply(res, 200, inspectAccom(await body(req)));
|
||||
return;
|
||||
|
||||
@@ -329,13 +329,26 @@ function readXls(input) {
|
||||
else
|
||||
put(row, col, kind === 1 ? Boolean(data[8]) : kind === 2 ? `#ERR${data[8]}` : "", text);
|
||||
} else put(row, col, data.readDoubleLE(6), text);
|
||||
} else if (type === 0xe5) {
|
||||
// MERGEDCELLS: 개수, (첫 행·끝 행·첫 열·끝 열) 반복
|
||||
for (let i = 0, count = data.readUInt16LE(0); i < count; i++) {
|
||||
const at = 2 + i * 8;
|
||||
(sheet.merges ||= []).push({
|
||||
r1: data.readUInt16LE(at),
|
||||
r2: data.readUInt16LE(at + 2),
|
||||
c1: data.readUInt16LE(at + 4),
|
||||
c2: data.readUInt16LE(at + 6),
|
||||
});
|
||||
}
|
||||
} else if (type === 0x207 && pendingFormula) {
|
||||
put(pendingFormula.row, pendingFormula.col, unicode(data, 0).text, pendingFormula.text);
|
||||
pendingFormula = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return sheets.map(({ name, cells }) => ({ name, cells }));
|
||||
return sheets.map(({ name, cells, merges }) =>
|
||||
merges ? { name, cells, merges } : { name, cells },
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { readXls, cfb, decodeFormula, columnName };
|
||||
|
||||
Reference in New Issue
Block a user