Author SHA1 Message Date
eomsangdonandClaude 489db2bf1d feat(m01): 파워트로웰 별칭 → POWER TLOWEL(EQ000534 · 품셈 8-4-8) · 방향 장부 V 장(기계 시간당 식 조사)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 17:05:40 +09:00
eomsangdonandClaude 0f596602f6 knowledge(요구797): 사본 빈틈 고침 · 시간당 식 · 14 대 출처 · 옛 시험 정리 원문
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 17:02:20 +09:00
eomsangdon fdf8e82910 Merge branch 'sub_laptop_1' of C:/Aislo_wt/sub_laptop_1 into main_laptop_1 2026-11-12 14:50:25 +09:00
eomsangdon 595f1629aa Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1 2026-11-12 14:45:44 +09:00
eomsangdonandClaude Opus 5.5 804472d400 fix(821-3): 줄 쌓기로 고른 내 기계 — 값 사본에 박음(고친 뒤 금액 그대로 + 새 값 · 갱신 · 지워짐) · 찾기 값 수로
- M02_Doc_Sk_Path.edited: 문서의 기계='개인 EQP…' 가운데 값 사본에 없는 것만 지금 값으로 더함(미리 셈 · 저장)
- M02_MyMachine_Look.find: Decimal → 수(글이면 양식 미리보기 0)
- 양식 막힘 글은 고치면 지움 · 좌측 칸 안내 짧게
- 시험 test_821_3 ⑤ 줄 쌓기 내 기계 사본 · 새값 · 갱신 · 지운 뒤 마지막 값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:43:58 +09:00
eomsangdonandClaude Opus 5.5 bc0a758ae0 refactor(821-4): 820-1 기계 13 줄 · 파워트로웰 식 GC001195 지움 — 품셈에 없는 기계 = 내 기계
- 기계.json EQ000656 ~ 668(발전기 5.5 · 7 · 10㎾ · 대형 브레이커 0.18㎥ · 에어 호스 · 모노펌프 · 파워트로웰 · 콘크리트펌프용 파이프 여섯) 지움 · 로직 08장 GC001195 · GC000268 · GC000273 고르기에 끼운 「자체 …」 원문번호 뺌 · 수식 문서 08장 같이(820-1 바로 앞 판과 같음)
- _키대장 = 열넷 「폐기」 로 옮김(결번 · 다음 번호 EQ 669 · GC 1196 그대로 · 재사용 안 함)
- 둠: 덤프트럭(4륜구동) 1ton EQ000655 · GF000145 · 체인톱 시간당 식 GF000337 · 기계 별칭
- 기술문서 기계경비_산정 7 절 = 「품셈에 없는 기계 = 사용자 내 기계(요구서 796)」 한 단락
- 802-2 변환 대조 --check 같음(로직 1,514 · 저장본 · 목록 다시 쓸 것 없음)
- 낡은 시험 지움: test_820_1_small_machines(13 줄 값 · GC001195 계산 지킴 — 줄이 없어짐) · 빌더 build_기계_소형보조_820_1 — 지킴 뜻(빈칸 든 입력 이름 이음 글 왕복 · EST main 504 블록 금액 546)은 test_821_4 로 옮김
- 새 지킴 시험 test_821_4_import_my_machine(구성 → 내 기계 값 · 엑셀 · STmate 시트 · 직접 꼴 · 같은 이름 재사용 · 다시 불러와도 중복 없음 · 미리 보기 안 만듦 · 금액 그대로 · 13 줄 없음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:25:07 +09:00
eomsangdonandClaude Opus 5.5 4e71ec515b feat(821-4): 불러오기 못 이은 기계 = 내 기계 자동 생성 · 「내 기계 생성 n」 표시
- 새 M02_San_MyMachine — 그 파일 기계 구성(EST 원파일 MachMainDat · EST 엑셀 「기계경비」 · STmate 엑셀 「중기사용료」) → 우리 틀 값(가격 천원 · 달러 = 환율 FX000001 + 비고 · 손료계수 · 주연료 → 우리 유가 · 잡품 % · 조종원 인 + 우리 직종 · 전력) · 경쟁사 조종원 노임 환산 안 가져옴 · 구성 없음 = 시간당 값 직접 · 조합 기계 = 조각마다
- 만드는 때 = 층 저장 길(FromHopyo.make) 만 · 같은 이름 · 규격 내 기계 있으면 그것 · 다시 불러와도 중복 없음 · 미리 보기(emit) = 원본 「내기계예정」 만 · 개인 층 아님 = 안 만듦
- 불러오기 기계 찾기 = M01 만(Doc_FromNative.emit · Machine_Find.m01_only) — 내 기계 잇기는 한 곳(다시 불러와도 같은 SK)
- 가져온 식 색인 = M01 + 그 층 내 기계(Imported._whole) · 직접 꼴 = [우리 단가로] 내 기계 시간당 · 「못 이음」 에서 뺌 · 수식 문서 변수 설명 「내 기계 키」
- EST 원파일 둘 꼴 칸 기계구성 · STmate 엑셀 중기사용료 시트 둠
- 가져온 식 패널 — 「내 기계 생성 n」 · 「내 기계 이음 n」 · 줄마다 「내 기계 · 이름 규격」 꼬리표 · 「만들어질 내 기계 n」
- 금액(엑셀 단가 그대로) 줄 값 안 건드림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:24:56 +09:00
eomsangdonandClaude Opus 5.5 9b5275a78e feat(821-3): M02 내 기계 화면 · 기계 고르기 내 기계 묶음 · 복사해 내 기계로 · 새 값 · 지워짐 배지 · 내 품목
- 좌측 「내 기계」 · 「내 품목」 칸(펼치면 가운데 화면) — 목록(이름 · 규격 · 방식 · 시간당 · 쓰는 곳 · 넣은 · 고친 날 · 찾기) → 양식(821-2 부품 · 바로 넣기 기본 · 저장 때만 서버)
- 단가산출 줄 쌓기 기계 고르기에 「내 기계」 묶음 · [새 기계 만들기] · 품셈 기계 줄 「복사해 내 기계로」
- 수식 머리 배지 — 값만 바뀜 = 「새 값 있음」 · 지운 키 = 「지워짐 n」 · [단가 갱신] 확인 창에 바뀐 자원 줄
- 기계 양식 state 에 방식 · 시간당 직접 꼴 · 확인창 글 줄바꿈 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:21:32 +09:00
eomsangdonandClaude Opus 5.5 d373b3b2b9 feat(821-3): 내 기계 화면 읽기 길 — 양식 목록 · 비슷한 기계 찾기(내 기계 + M01 · 원문번호) · 내 품목 모아 보기
- M02_MyMachine_Look: 로그인 사용자용 양식 목록(M01 기계 화면 값) · 개인 층 찾기 · 바로 적은 재료비 줄 묶음
- main.py 등록 한 줄 · 시험 test_821_3_my_machine_screen 4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:21:22 +09:00
eomsangdon 6aa8e20d3a Merge remote-tracking branches 'origin/CODEX' and 'origin/main_laptop_1' into sub_laptop_1 2026-11-12 14:05:30 +09:00
eomsangdon db96517d08 Merge branch 'sub_laptop_1' of C:/Aislo_wt/sub_laptop_1 into main_laptop_1 2026-11-12 14:03:06 +09:00
eomsangdonandClaude Opus 5.5 086f6d214a feat(821-2): M01 기계 화면 — 목록 열 줄이기 · 상세 페이지 · 새 기계(비슷한 기계에서 시작) · 저장 때 계수 서버 재계산
- 목록 = 이름 · 규격 · 구분 · 가격 · 손료계수 · 주연료 · 조종원 · 시간당 경비(서버 값) · 별칭 · 비고 (단위 · 출처 열 없음 · 가로 넘침 없음) · 다른 요소 표 그대로
- 상세 = 기계 양식 + 원문번호 · 원문 · 구분 · 상세구분 · 별칭 · 비고 · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인) · 주소 ?machine= 로 바로 열림 · 뒤로 가기 = 목록 · 저장 안 한 고침 나가기 경고
- 고침 = 초안 → 페이지 [저장](자동저장 없음) · 서버 M01_MasterData_Machine.fix = 품셈 입력이 바뀌면 계수 짝으로 다시 셈 · 단위 · 출처 칸 채움(새 칸 = 마스터 단위 · 「자체」)
- API /api/m01/machine/lists · hourly · uses · find · row (시스템 관리자만) · 시험 test_821_2_machine_save.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:02:04 +09:00
eomsangdon 3af9e689b7 Merge remote-tracking branches 'origin/CODEX' and 'origin/sub_laptop_1' into sub_laptop_3 2026-11-12 13:44:11 +09:00
eomsangdonandClaude Opus 5.5 b97ecf47de feat(est-test): 단가산출 전역변수·새내역서 파일로저장·산출서 삽입과 선택 삭제
- 전역변수:  표지 대입 변수를 호표 넘어 유지, 지정·해제, 목록·이동
- 새내역서 파일로저장: 당초값·신규 표시·당초 사본 지운 새 작업 따로 저장
- 수량산출서 삽입(공종명칭·규격)·선택공종 삭제

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:43:43 +09:00
eomsangdonandClaude Opus 5.5 6e0d86c961 feat(821-2): 기계 양식 부품 — 칸 여섯 · 손료계수 두 방식 · 시간당 값 직접 · 옆 계산식 즉시 · 비슷한 기계에서 시작
- ui_template_machine_form.ts · .css — 목록 · 찾기 · 저장은 부르는 쪽이 줌(M01 · M02 에 안 묶임) · 숫자만 · 범위 경고 · 고친 칸 노랑 · 같은 이름 알림 자리 · 좁은 폭 한 줄
- 품셈 방식 = 비율 칸 · 계수 넷 읽기 전용(입력이 처음과 같으면 저장 값 그대로)
- 사전 ui_template_locale_machine.ts · 부품 모음 견본 한 칸

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:39:59 +09:00
eomsangdonandClaude Opus 5.5 3f05c69b17 test(821-1): 직접 꼴 같은 이름 알림 시험 규격 맞춤
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:39:46 +09:00
eomsangdon 046a27b1aa Merge commit 'f8e6d0d14' into sub_laptop_3 2026-11-12 13:36:27 +09:00
eomsangdonandClaude Opus 5.5 ebd1bc0121 feat(821-1): 내 기계(개인 층 기계 줄) 저장 · 길 · 찾기 · 시간당 · 사본 · 단가 갱신이 개인 줄 읽기
- M02_MyMachine — 층 뿌리 내기계.json · 줄 = M01 기계 줄 꼴 · 키 EQP{사용자}-#### · 원문번호 「개인 {키}」 · 바로 · 품셈(계수 짝) · 직접 세 꼴 · 같은 이름 알림 · 쓰는 곳
- 셈 색인 = M01 + 지금 층 내 기계(skc.Copy) — 단가산출 셈 · 저장 · 사본 찍기 · 단가 갱신 · 새값 표지 · 기계 찾기 · 시간당 · 사본 모으기가 읽음 · 층 모르는 길 = M01 만
- 시간당 식 = 채운 칸으로 고른 GC 식(268 · 272 · 276 · 273) · M01 기계 619 대 셈 방식 대조 차이 0
- 길 /api/m02/layers/personal/my-machines(목록 · 하나 · 만들기 · 고치기 · 지우기 · 본뜬)
- 지킴 시험 test_821_1_my_machine

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:36:24 +09:00
eomsangdonandClaude Opus 5.5 f8e6d0d14e feat(821-2): 기계 계수 계산 짝(파이썬 · TS) — 손료계수 넷 · 시간당 미리보기 · 거울 시험
- master_machine_coef.py · common_util_machine_coef.ts — 건설품셈 8-1-5 계수(정수 반올림 · 손료계수 = 합) · 시간당 미리보기(줄 0.1 원 버림 · 잡품 = 버린 주연료 × % · 비목 합 원 버림)
- 계수 전수 대조 600 줄 중 598 같음 · 다른 2 줄 = 점보드릴 원문 표 값(777 · 셈 777.8)
- 미리보기 = 서버 기계경비식 619 대 중 602 같음 · 견본 스무 대 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:35:23 +09:00
eomsangdonandClaude Opus 5.5 0f353f8606 feat(est-test): JDLExcel과 단가비교
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:33:50 +09:00
eomsangdonandClaude Opus 5.5 515b683b82 feat(est-test): 통합 검색 근거자료보기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:31:39 +09:00
eomsangdonandClaude Opus 5.5 3900d88a7c feat(est-test): ES 정보(물가변동 기준일·선급금)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:29:57 +09:00
eomsangdonandClaude Opus 5.5 009c58a72d feat(est-test): 설계참고철(Main Data) 검색과 단가 범위 검색
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:28:44 +09:00
eomsangdonandClaude Opus 5.5 86a1ad3144 feat(est-test): 비목별 단가 불러오기·SUM 색인지정·하위데이타 연결/끊기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:25:38 +09:00
eomsangdon 1522000d96 Merge remote-tracking branches 'origin/CODEX' and 'origin/main_laptop_1' into sub_laptop_1 2026-11-12 13:22:52 +09:00
eomsangdonandClaude Opus 5.5 14f960dde1 feat(est-test): 묶음 인쇄 간지 일괄적용·기본양식 저장·전체선택/해제
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:22:39 +09:00
eomsangdon 74cb53ea6f Merge remote-tracking branches 'origin/CODEX' and 'origin/main_laptop_1' into sub_laptop_3 2026-11-12 13:22:37 +09:00
eomsangdonandClaude 00046d309a knowledge(요구796): 요구서 796 내 기계 · 기계 양식(PLAN 821) · 착수 원문
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 13:22:29 +09:00
eomsangdonandClaude Opus 5.5 d515aa1eb1 feat(est-test): 예정공정표 표시/해제 토글
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:20:57 +09:00
eomsangdonandClaude Opus 5.5 edd86767d2 feat(est-test): 갑지 공사금액 자동변경
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:19:56 +09:00
eomsangdonandClaude Opus 5.5 053fc83ccf feat(est-test): 설명서·시방서 글자모양과 전체삭제
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:18:22 +09:00
eomsangdonandClaude Opus 5.5 04cba1082c feat(est-test): 엑셀형 산출서 셀 소수자리 서식과 인쇄
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:17:02 +09:00
eomsangdonandClaude Opus 5.5 d515875322 feat(est-test): 선택 행 예정공정표에 표시
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:15:24 +09:00
eomsangdonandClaude d5d2d6dce5 knowledge(요구795): 내 기계 = M01 기계처럼(사본 복사) — 코드 확인 · 방향 장부 U 장
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 13:14:05 +09:00
eomsangdonandClaude Opus 5.5 42bb744455 feat(est-test): 셀복사·셀붙여넣기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:14:05 +09:00
eomsangdonandClaude Opus 5.5 b3f5cc2b7b feat(est-test): 산출 줄 잘라내기·복사·붙여넣기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:12:34 +09:00
eomsangdonandClaude Opus 5.5 d7682c69c6 feat(est-test): 수량산출서 엑셀형식 전환
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:10:25 +09:00
eomsangdonandClaude 841a66747c knowledge(요구795): 내 기계 = M01 기계처럼 · 생성 때 복사·연결 끊김 확인 요청 원문
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 13:09:21 +09:00
eomsangdonandClaude Opus 5.5 684e0e7684 feat(est-test): 표시 설정(비목순서·구분 표시·품명정렬)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:08:24 +09:00
eomsangdonandClaude Opus 5.5 8be1ff6135 feat(est-test): 원가계산서 순공사비 입력
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:06:32 +09:00
eomsangdonandClaude Opus 5.5 785e96809f feat(est-test): Excel 가져오기 셀부호 밀기·당기기와 설계일자 선택
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:05:24 +09:00
eomsangdonandClaude Opus 5.5 84f9eb4248 feat(est-test): 연차 차수 중간 삽입
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:03:05 +09:00
eomsangdonandClaude Opus 5.5 c500ac8281 feat(est-test): 블럭 파일로 저장하기·블럭 파일에서 붙여넣기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:02:03 +09:00
eomsangdonandClaude Opus 5.5 854e251129 feat(est-test): 인쇄 용지 A0·A1·B4 와 인쇄 기본양식 저장·열기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:00:30 +09:00
eomsangdonandClaude Opus 5.5 21f49df818 feat(est-test): 원가계산서 내용 삭제와 기본양식 저장
- 원본 popupContentDel: 고른 줄의 연결·금액·요율·산출근거·자리 비움
- 원본 popupSaveAsMain: 원가 양식 「기본양식」 저장, 새 내역서에 적용

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:58:32 +09:00
eomsangdonandClaude Opus 5.5 9de0cb665a feat(est-test): 갑지·표지 양식 저장·불러오기·기본양식·초기화·인쇄
- 원본 TFRMGABGI·TFRMCOVER 팝업: 양식목록, 양식저장하기, 기본양식으로 저장(처음 열 때 채움), 초기화, 인쇄 미리보기

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:56:48 +09:00
eomsangdonandClaude Opus 5.5 dc425995bc feat(est-test): 공사설명서·시방서 서식 편집
- 원본 TFRMESTEDITOR: 문단모양, 표만들기, 표모양, 책갈피, 양식목록, 기본양식으로 저장(빈 문서에 채움)
- 본문 html(허용 태그만)과 글자를 함께 저장, 인쇄는 html 우선, 갑지 붙여넣기는 커서 자리

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:54:20 +09:00
eomsangdonandClaude e432b89042 knowledge(요구794): 기계를 바꿀 때 동작 물음 원문
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 12:53:43 +09:00
eomsangdonandClaude 8965663410 knowledge(요구793): 값의 주인(M01 대 사용자) 확인 물음 원문
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 12:52:33 +09:00
eomsangdonandClaude Opus 5.5 cc1a6bcbde feat(est-test): 원가계산서 산출근거 약어로 표시
- 원본 popupSNameShow: 산출근거 식의 기호를 비목 약어로, 숫자를 요율 글자로 바꿔 화면·인쇄에 표시하고 편집 잠금, 저장 값은 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:51:14 +09:00
eomsangdonandClaude Opus 5.5 7222577442 feat(est-test): 발췌근거 출처 열 이름·보이기
- 원본 barbtnColumn: 자재·노임 단가의 출처 열(Cost1~6) 이름·쪽수·보기 편집, 자재는 8가지 출처 이름·중복 거부
- 숨긴 출처는 최저·평균·최고 단가 고르기에서 제외(브라우저·서버 같은 엔진)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:48:37 +09:00
eomsangdonandClaude c4d1b1de1b knowledge(요구792): 기계 양식 세 곳 공통 여부 확인 물음 원문
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 12:47:35 +09:00
eomsangdonandClaude Opus 5.5 5e6c2988fc feat(est-test): 엑셀형 산출서 편집 동작과 조달청 표준공사코드 적용
- 엑셀형 산출서: 범위 고르기, 행·열 삽입/삭제(수식 참조·병합·수량 연결 이동, 지운 칸 #REF!), 잘라내기·복사·붙여넣기(상대 참조), 내용지우기, 굵게·기울임·밑줄·정렬, 병합·분리, 대각선, 다시 계산, 품명·규격·단위로 지정, 원본 .xls 병합 정보 읽기
- 조달청 표준공사코드 적용(원본 TfrmCodeChange): 7종 탭, 코드변경·검사 색·변경취소·적용(참조 행 포함, 재계산 없음), 코드일괄변경(.estx), 자동변경, 다산코드정리, 사용자정의코드, 검색. 조달청 목록은 사용자가 고른 PPS_*.dat 를 /api/pps-table 로 읽음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:44:54 +09:00
eomsangdonandClaude 36460116c8 knowledge(요구791): 기계 생성·수정 쉬움 조사·안 — 방향 장부 T 장
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 12:40:31 +09:00
eomsangdonandClaude a4fb628d6b knowledge(요구791): 1·2·3 동의(착수 대기) · 기계 생성·수정 쉬움 검토 요청 원문
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 12:37:19 +09:00
eomsangdonandClaude Opus 5.5 627e04e5a2 feat(est-test): 기성 원가계산서 회차별 편집
- 원본 TfrmWongaAccomH/V 팝업: 한줄 삽입·추가·삭제, 내용 삭제, 선택항목·선택데이타 절하, 공사비역산 자리수, 초기화, 되살리기, 산출근거 약어 표시(수식칸 잠금)
- 기성 행 Diget 자리맞춤, 역산 시 이윤 역산, 도급금액 열을 기호·이름으로 짝지음
- 서버 허용 목록에 빠진 할증·부가세 스크립트 추가(404 수정)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:30:17 +09:00
eomsangdon 399c8fea49 Merge remote-tracking branch 'origin/main_laptop_1' into CODEX 2026-11-12 12:24:27 +09:00
eomsangdon 596fcf99e0 auto: 2026-11-12 12:24 (EOMSANGDON-HOME) 2026-11-12 12:24:10 +09:00
eomsangdonandClaude Opus 5.5 d38d2a2893 feat(est-test): 엑셀형 수량산출서 계산·편집과 원가 행 자리수
- 엑셀형 산출서 BIFF 수식 해석·다시 계산·수식줄 편집·4방향 합·수량으로 지정(가져올 때 값이 같은 합계 셀 추정 연결), 셀 편집 시 ESTX 저장 거부
- 원가 행 계산을 원본 c9d278 끝부분대로: 소수 4자리 반올림 뒤 원 미만 절사, Diget>0 행 자리맞춤(0x11293e8)
- 원가계산서 「선택 행 자릿수 설정」을 원본 선택항목 절하하기 대화로(도급액 행 거부, 입력 행 원금액 기준)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:24:06 +09:00
eomsangdonandClaude 787ff7b0b8 knowledge(요구790): 품셈 밖 기계·재료 개인 관리 검토 — 방향 장부 S 장
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 10:55:17 +09:00
eomsangdonandClaude 8231f8a2a2 knowledge(요구790): 새 기계·재료 개인 관리 · 기계 생성 로직 · M01 기계 상세 · 추가 기계 삭제 정밀 검토 요청 원문
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 10:53:44 +09:00
122 changed files with 9293 additions and 1135 deletions
+36 -4
View File
@@ -5,6 +5,8 @@
* [재생성](지금 M01 문서로 다시 찍음 · 버리는 것 알림) — 누를 때만(자동 갱신 없음)
* 흐름 = 미리 셈(`{길}/preview` {할일}) → 확인 창(계 전 → 후 · 바뀐 자원 · 버리는 것) → 저장(`{길}/save` {할일, 판})
* 층 쓰기 권한 없으면 「읽기만」 배지
* (821-3) 값 줄만 바뀜(M01 단가 · 내 기계 고침) = 「새 값 있음」(같은 자리 · 같은 꼴) · 지운 키(M01 · 내 기계) = 「지워짐 n」 ·
* [단가 갱신] 확인 창 = 바뀐 자원 줄(이름 · 바뀐 칸 전 → 후 · 지워짐 = 마지막 값 유지)
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
@@ -50,9 +52,16 @@ export function isStale(m: CopyMark | undefined): boolean {
export function copyBadges(m: CopyMark | undefined, edit: boolean): HTMLElement[] {
const out: HTMLElement[] = [];
if (isStale(m)) {
const b = createBadge(ft("New_M01"), "danger");
b.dataset.copyStale = "";
const doc = !!m?.M01새판 || !!m?.문서새판?.length;
if (doc || m?.단가새값) {
const b = createBadge(ft(doc ? "New_M01" : "New_Value"), "danger");
b.dataset.copyStale = doc ? "doc" : "value";
out.push(b);
}
if (m?.지운키?.length) {
const b = createBadge(`${ft("Gone")} ${m.지운키.length}`, "warning");
b.title = `${m.지운키.join(" · ")} — ${ft("Gone_Note")}`;
b.dataset.copyGone = m.지운키.join(",");
out.push(b);
}
if (!edit) out.push(createBadge(ft("Read_Only"), "neutral"));
@@ -76,17 +85,40 @@ export function copyTools(edit: boolean, run: (job: CopyJob) => void): HTMLEleme
const sum = (m: Money | undefined): string =>
typeof m?.계 === "number" ? `${eqText(m.계)} 원` : "—";
/** 바뀐 자원 한 줄 — 「이름 칸 전 → 후」(값 묶음에서 다른 칸만) · 지워짐 = 마지막 값 유지 */
export function changedLine(x: unknown): string {
const r = (x ?? {}) as {
키?: string;
이름?: string;
전?: unknown;
후?: unknown;
지워짐?: boolean;
};
const name = r.이름 || r.키 || "";
if (r.지워짐) return `${name} ${ft("Gone")}(${ft("Gone_Note")})`;
const show = (v: unknown): string => (typeof v === "number" ? eqText(v) : String(v ?? "—"));
const a = r.전 && typeof r.전 === "object" ? (r.전 as Record<string, unknown>) : null;
const b = r.후 && typeof r.후 === "object" ? (r.후 as Record<string, unknown>) : null;
if (!a || !b) return `${name} ${show(r.전)} → ${show(r.후)}`;
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].filter(
(k) => JSON.stringify(a[k]) !== JSON.stringify(b[k]),
);
return `${name} ${keys.map((k) => `${k} ${show(a[k])} → ${show(b[k])}`).join(", ")}`;
}
/** 할일 알림 — 계 전 → 후 · 바뀐 자원 수 · 재생성이 버리는 것(이름) → 확인이면 true */
export function askJob(job: CopyJob, got: JobReply, names: Map<string, string>): Promise<boolean> {
const bits = [
job === "단가갱신" ? ft("Price_Ask") : ft("Regen_Ask"),
`${ft("Before_After")} ${sum(got.전)} → ${sum(got.후)}`,
];
const lines = job === "단가갱신" ? (got.바뀐자원 ?? []).slice(0, 8).map(changedLine) : []; // 821-3
if (job === "단가갱신") bits.push(`${ft("Changed")} ${got.바뀐자원?.length ?? 0}`);
const lost = [
...(got.버림?.문서고침 ? [ft("Regen_Edit")] : []),
...(got.버림?.끊음 ?? []).map((k) => `${ft("Cut")} ${names.get(k) || k}`),
];
if (job === "재생성" && lost.length) bits.push(`${ft("Regen_Lost")} — ${lost.join(" · ")}`);
return showConfirmDialog(bits.join(" · "), job === "단가갱신" ? ft("Price") : ft("Regen"));
const text = [bits.join(" · "), ...lines].join("\n");
return showConfirmDialog(text, job === "단가갱신" ? ft("Price") : ft("Regen"));
}
+3
View File
@@ -63,6 +63,9 @@ const TEXT = {
"Discard edits to this basis and follow the copy again",
],
New_M01: ["M01 새 판 있음", "New M01 version"],
New_Value: ["새 값 있음", "New values"],
Gone: ["지워짐", "Deleted"],
Gone_Note: ["마지막 값 유지", "last value kept"],
Price: ["단가 갱신", "Refresh prices"],
Regen: ["재생성", "Regenerate"],
Price_Ask: ["자원 단가만 지금 M01 값으로 — 식 · 연결 그대로", "Prices only — equation unchanged"],
+10 -1
View File
@@ -46,6 +46,7 @@ import {
import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick";
import { reach } from "./formula_doc_render";
import { attachDrag } from "./line_stack_drag";
import { toMineButton } from "./line_stack_mine";
import { openStackPick, type StackTake } from "./line_stack_pick";
import { buildBlock, sourceTag } from "./line_stack_rows";
import { lt, type LsKey } from "./line_stack_text";
@@ -165,7 +166,7 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
openStackPick(
kind,
(p) => h.change((doc) => repick(doc, i, own, kind, p)),
undefined,
kind === "기계" ? h.take : undefined, // 821-3 개인 층 = 내 기계 묶음도
found?.찾기어 ?? String(mat?.검색어 ?? line.이름 ?? ""),
),
});
@@ -175,6 +176,14 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
head.push(pick);
const mark = machineMark(found, link);
if (mark) head.push(mark);
// 821-3 품셈 기계 줄 → 복사해 내 기계로(개인 층 SK)
const mine =
kind === "기계"
? toMineButton(h.take, link as { 갈래?: string; 입력?: Record<string, string> }, (p) =>
h.change((doc) => repick(doc, i, own, kind, p)),
)
: null;
if (mine) head.push(mine);
} else
head.push(
field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "m"),
+61
View File
@@ -0,0 +1,61 @@
/* =============================================================================
* line_stack_mine.ts
* 줄 쌓기 기계 줄 「복사해 내 기계로」(PLAN 821-3 · 요구서 796 3 장) — M02 개인 층 단가산출만.
* 품셈 기계 줄(기계='원문번호') → 그 기계 값을 복사한 내 기계 양식 창 → 저장하면 줄이 그 내 기계로(기계='개인 EQP…')
* M01 정본은 그대로 · 내 기계 줄(기계='개인 …') · M01 단가산출 기초 = 단추 없음
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { showToast } from "@ui/ui_template_elements";
import type { Picked } from "../common_util/common_util_line_stack_pick";
import { findSimilar, machineArg, mineElement } from "../M02_MasterTemplete/M02_MyMachine_Api";
import { openMyMachineModal } from "../M02_MasterTemplete/M02_MyMachine_Form";
import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text";
import type { StackTake } from "./line_stack_pick";
/** 기계 줄 연결 → 품셈 기계 원문번호(내 기계 · 기계 입력 없음 = null) */
export function m01MachineNo(link: {
갈래?: string;
입력?: Record<string, string>;
}): string | null {
const arg = link.갈래 === "단가산출" ? link.입력?.기계 : undefined;
const no = typeof arg === "string" ? arg.trim().replace(/^'|'$/g, "") : "";
return no && !no.startsWith("개인 ") ? no : null;
}
/** 「복사해 내 기계로」 단추 — 개인 층 SK 품셈 기계 줄만 · 고르면 `done(새 줄 고름)` */
export function toMineButton(
take: StackTake | undefined,
link: { 갈래?: string; 입력?: Record<string, string> },
done: (p: Picked) => void,
): HTMLElement | null {
const no = m01MachineNo(link);
if (!no || take?.layer !== "personal") return null;
const b = createButton({
label: mt("To_Mine"),
variant: "ghost",
size: 24,
iconStart: "copy",
onClick: async () => {
const rows = await findSimilar(take.owner, no).catch(() => []);
const seed = rows.find((r) => r.출처 === "M01" && r.원문번호 === no);
if (!seed) return showToast(`${mt("Hourly_None")} — ${no}`, "warning");
openMyMachineModal(
take.owner,
(got) => {
const e = mineElement(got.줄, got.시간당);
if (!e.로직) return showToast(mt("Pick_Direct"), "warning");
done({
키: e.로직,
이름: e.이름,
입력: { 기계: machineArg(got.줄.원문번호) },
...(e.규격 ? { 규격: e.규격 } : {}),
});
},
seed,
);
},
});
b.dataset.toMine = no;
return b;
}
+2
View File
@@ -3,6 +3,7 @@
* 줄 쌓기 고르기 창(PLAN 804-3 · 804-4 · 요구 678-1 · 678-3 · 680-1 · 681)
* 찾아 넣기 = M02 [항목 추가] 창 그대로(`openFindPicker` · 탭 M01 전체 · 찾기 칸 · 표) — 줄 갈래마다 그 종류만
* 인력(노임 LB) · 재료(자재 MT) · 기계(EQ → 시간당 경비 로직 + 기계='원문번호') · 다른 단가산출(M01 단가산출 로직)
* 기계 + M02 개인 층(821-3) = 품셈 기계 위 「내 기계」 묶음 · 머리 [새 기계 만들기] — 고름 = 로직 + 기계='개인 EQP…'
* M01 단가산출 기초 줄 쌓기 = 「M01 전체」 탭만 — M01 은 원천 기준값이라 M02 단가산출 · 일위대가를 부르지 않음(요구 680-1)
* M02 단가산출 줄 쌓기(`take` 있음) · 다른 단가산출 = 탭 [M01 전체 | M02 단가산출 | 일위대가](위 층 · 자기 SK 뺌) —
* 고름 = 복사해 펼침(`take(src)` · src = M01 키 | `참조|층|주인|키`) · 참조 줄 없음(요구 681)
@@ -56,6 +57,7 @@ export function openStackPick(
owner: copy?.owner ?? null,
title: `${lt("Find_Title")} — ${lt(copy ? "K_복사" : (`K_${kind}` as never))}`,
kinds: copy ? ["logic", "sk", "pre"] : [want], // M01 = M01 전체만(680-1)
mine: kind === "기계" && m02?.layer === "personal" ? { owner: m02.owner } : undefined,
query: query.trim() || undefined,
ref: copy
? (r) =>
+222
View File
@@ -0,0 +1,222 @@
"""M01 기계 화면(PLAN 821-2) — 양식 목록 · 시간당 경비(서버 값) · 쓰는 곳 · 비슷한 기계 찾기 ·
저장 전 기계 줄 손질(계수 서버 재계산 · 단위 · 출처 채움).
계수 = `master_machine_coef`(TS 짝 `common_util/common_util_machine_coef.ts`) ·
시간당 = 서버 기계경비식(`M02_Machine_Hourly.hourly` · 전국평균 · 기본 옵션) ·
마스터 파일은 읽기만 — 쓰기는 `Store_Make.save`.
"""
from __future__ import annotations
import json
import re
from decimal import Decimal
from typing import Any
import master_machine_coef as mc # noqa: E402 (`store` 가 scripts 를 길에 둠)
from M01_MasterData import M01_MasterData_Store as store
FILE = "기계.json"
OWN_SOURCE = "자체"
_EQ = re.compile(r"EQ\d{6}")
_cache: dict[str, Any] = {"ver": None, "hourly": {}, "uses": None}
def _rows() -> list[dict]:
return store.read(FILE)[0].get("줄") or []
def _fresh() -> None:
"""마스터를 다시 읽었으면(저장 뒤) 셈 보관을 비움."""
ver = id(store.loaded()[1])
if _cache["ver"] != ver:
_cache.update(ver=ver, hourly={}, uses=None)
def _plain(v):
if isinstance(v, Decimal):
return int(v) if v == v.to_integral_value() else float(v)
return v
# ── 양식 목록 ────────────────────────────────────────────────────────
def lists() -> dict:
"""연료(유가 전국평균) · 직종(인력) · 전력 단가 · 시간환산 · 원문 · 구분 — 양식 고르기."""
files = store.loaded()[0]
oil = files.get("재료_유가전력.json", {}).get("줄") or []
fuels = [
{
"value": str(r["원문번호"]).split(":")[0],
"text": str(r["원문번호"]).split(":")[0],
"price": _plain(r.get("값")),
}
for r in oil
if r.get("구분") == "유류" and str(r.get("원문번호", "")).endswith(":전국평균")
]
power = next(
(_plain(r.get("값")) for r in oil if r.get("원문번호") == "일반용갑I저압:전력량_평균"), 0
)
jobs = [
{
"value": str(r["원문번호"]),
"text": f"{r['원문번호']} {r.get('이름') or ''}",
"price": _plain(r.get("값")),
}
for r in files.get("인력.json", {}).get("줄") or []
if isinstance(r.get("값"), (int, float, Decimal))
]
head = store.read(FILE)[0]
hour = Decimal(1) / Decimal(8)
for t in files.get("계수_건설품셈_08장_건설기계.json", {}).get("표") or []:
if t.get("키") == "CC000336":
got = next((x for x in t.get("줄") or [] if x.get("구분") == "시간환산"), None)
if got:
hour = Decimal(got["분자"]) / Decimal(got["분모"])
return {
"fuels": fuels,
"jobs": jobs,
"power": power,
"hour": float(hour),
"books": list(head.get("원문") or {}),
"groups": {k: (v or {}).get("원문") for k, v in (head.get("구분") or {}).items()},
}
# ── 시간당 경비 ──────────────────────────────────────────────────────
def hourly(keys: list[str]) -> dict[str, dict]:
"""키마다 {노무비 · 재료비 · 경비 · 계} 또는 {reason} — 마스터 판이 같으면 한 번만 셈."""
from M02_MasterTemplete import M02_Machine_Hourly as mh # M02 가 M01 을 부름 — 늦게
_fresh()
out = {}
for key in keys[:100]:
if key not in _cache["hourly"]:
got = mh.hourly(key)
_cache["hourly"][key] = (
{k: got[k] for k in ("노무비", "재료비", "경비", "계", "로직")}
if got.get("ok")
else {"reason": got.get("reason")}
)
out[key] = _cache["hourly"][key]
return out
# ── 쓰는 곳 ─────────────────────────────────────────────────────────
def _logic_index() -> tuple[dict[str, list[str]], dict[str, list[str]]]:
"""(원문번호 → 로직 「키 이름」 · 기계 키 → 로직) — 기계 고르기 · 식 안 EQ 키."""
_fresh()
if _cache["uses"] is None:
by_code: dict[str, list[str]] = {}
by_key: dict[str, list[str]] = {}
for data in store.loaded()[0].values():
if data.get("그룹") != "로직":
continue
for row in data.get("줄") or []:
label = f"로직 {row.get('키')} {row.get('이름') or ''}".strip()
for i in row.get("입력") or []:
if i.get("이름") == "기계":
for c in i.get("고르기") or []:
by_code.setdefault(str(c), []).append(label)
text = json.dumps(row, ensure_ascii=False, default=str)
for k in set(_EQ.findall(text)):
by_key.setdefault(k, []).append(label)
_cache["uses"] = (by_code, by_key)
return _cache["uses"]
def uses(key: str) -> list[str]:
"""그 기계를 쓰는 곳 — 로직(기계 고르기 · 식) · M02 단가산출(사본 · 줄에 키)."""
from M02_MasterTemplete import M02_Template_Layers as layers # M02 가 M01 을 부름 — 늦게
row = next((r for r in _rows() if r.get("키") == key), None)
if row is None:
raise store.StoreError(404, f"없는 기계 「{key}」")
by_code, by_key = _logic_index()
code = str(row.get("원문번호") or "")
out = list(dict.fromkeys([*(by_code.get(code, []) if code else []), *by_key.get(key, [])]))
mark = f'"{key}'.encode()
for where, path in layers.layer_files("sangeun"):
try:
raw = path.read_bytes()
except OSError:
continue
if mark in raw:
try:
name = json.loads(raw).get("이름") or path.stem
except ValueError:
name = path.stem
out.append(f"M02 단가산출 {where} {name}")
return out
# ── 비슷한 기계 찾기 · 한 줄 ─────────────────────────────────────────
def find(q: str, limit: int = 30) -> list[dict]:
words = [w for w in q.lower().split() if w]
out = []
for r in _rows():
hay = " ".join(str(r.get(k) or "") for k in ("이름", "규격", "별칭", "상세구분", "키"))
hay = hay.lower()
if all(w in hay for w in words):
out.append(_brief(r))
if len(out) >= limit:
break
return out
def _brief(r: dict) -> dict:
keep = ("키", "원문번호", "원문", "구분", "상세구분", "이름", "규격", "값", "별칭", "비고")
return {k: r[k] for k in keep if k in r}
def row(key: str) -> dict:
r = next((x for x in _rows() if x.get("키") == key), None)
if r is None:
raise store.StoreError(404, f"없는 기계 「{key}」")
return {"version": store.read(FILE)[1], "row": r}
# ── 저장 전 손질(`Store_Make.save` 가 부름) ──────────────────────────
def _units() -> dict[str, str]:
out: dict[str, str] = {}
for r in _rows():
for k, u in (r.get("단위") or {}).items():
out.setdefault(k, u)
return out
def _same_inputs(a: dict, b: dict) -> bool:
return all(
float(a[k]) == float(b[k]) if isinstance(b.get(k), (int, float, Decimal)) else False
for k in mc.INPUTS
)
def fix(part: dict) -> None:
"""기계 줄 고침 · 더함 — 계수는 서버가 짝으로 다시 셈(품셈 입력이 저장 값과 같으면
저장 값 그대로) · 단위 · 출처 묶음 = 값 칸과 같은 칸
(있던 칸 그대로 · 새 칸 = 마스터 단위 · 출처 「자체」)."""
if part.get("file") != FILE:
return
stored = {str(r.get("키")): r for r in _rows()}
units = _units()
for ch in part.get("changes") or []:
row = ch.get("row")
if (
ch.get("op") == "delete"
or not isinstance(row, dict)
or not isinstance(row.get("값"), dict)
):
continue
old = stored.get(str(ch.get("key") or "")) if ch.get("op") == "edit" else None
values = row["값"]
old_values = (old or {}).get("값") or {}
if all(isinstance(values.get(k), (int, float)) for k in mc.INPUTS):
if old and _same_inputs(values, old_values):
values.update({k: old_values[k] for k in mc.COEFS if k in old_values})
elif (got := mc.coef_of(values)) is not None:
values.update(got)
was_unit = (old or {}).get("단위") or {}
was_src = (old or {}).get("출처") or {}
row["단위"] = {k: was_unit.get(k, units.get(k, "")) for k in values}
row["출처"] = {k: was_src.get(k, OWN_SOURCE) for k in values}
@@ -0,0 +1,57 @@
/* =============================================================================
* M01_MasterData_Machine_Api.ts
* M01 기계 화면 서버 호출(PLAN 821-2) — 양식 목록 · 시간당 경비 · 쓰는 곳 · 비슷한 기계 찾기 · 한 줄.
* 서버 = `M01_MasterData_Machine.py` · 시스템 관리자만.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { MachinePick } from "@ui/ui_template_machine_form";
import type { Row } from "./M01_MasterData_Api_Fetch";
export interface MachineLists {
fuels: MachinePick[];
jobs: MachinePick[];
power: number;
hour: number;
books: string[];
/** 구분 → 원문 */
groups: Record<string, string>;
}
export interface MachineHourly {
노무비?: number;
재료비?: number;
경비?: number;
계?: number;
reason?: string;
}
async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
const query = new URLSearchParams(params);
const res = await fetch(`${API_BASE_URL}/m01/machine${path}?${query}`, {
credentials: "include",
});
const body = (await res.json()) as T & { detail?: unknown };
if (!res.ok) throw new Error(String(body.detail ?? `HTTP ${res.status}`));
return body;
}
let lists: Promise<MachineLists> | null = null;
/** 한 번 받아 둠(페이지 다시 그리면 다시 받음) */
export const fetchMachineLists = (fresh = false): Promise<MachineLists> => {
if (!lists || fresh)
lists = get<MachineLists>("/lists").catch((e) => ((lists = null), Promise.reject(e)));
return lists;
};
export const fetchMachineHourly = (keys: string[]): Promise<Record<string, MachineHourly>> =>
keys.length ? get("/hourly", { keys: keys.join(",") }) : Promise.resolve({});
export const fetchMachineUses = async (key: string): Promise<string[]> =>
(await get<{ uses: string[] }>("/uses", { key })).uses;
export const findMachines = async (q: string): Promise<Row[]> =>
(await get<{ rows: Row[] }>("/find", { q })).rows;
export const fetchMachineRow = (key: string): Promise<{ version: string; row: Row }> =>
get("/row", { key });
+29
View File
@@ -14,6 +14,7 @@ from pydantic import BaseModel
from M01_MasterData import M01_MasterData_Doc as doc_screen
from M01_MasterData import M01_MasterData_Equation as equation
from M01_MasterData import M01_MasterData_Machine as machine
from M01_MasterData import M01_MasterData_Parts as parts
from M01_MasterData import M01_MasterData_Sheet as line_sheet
from M01_MasterData import M01_MasterData_Store as store
@@ -259,6 +260,34 @@ def get_materials(
return _call(store.materials, sub, detail, spec, region, unit, limit)
@router.get("/machine/lists")
def get_machine_lists() -> dict:
"""기계 양식 고르기 — 연료 · 직종 · 단가 · 원문 · 구분(821-2)."""
return _call(machine.lists)
@router.get("/machine/hourly")
def get_machine_hourly(keys: str) -> dict:
"""기계 키(쉼표) → 시간당 경비(서버 기계경비식 · 전국평균)."""
return _call(machine.hourly, [k for k in keys.split(",") if k])
@router.get("/machine/uses")
def get_machine_uses(key: str) -> dict:
return {"uses": _call(machine.uses, key)}
@router.get("/machine/find")
def get_machine_find(q: str = "") -> dict:
"""비슷한 기계에서 시작 — 이름 · 규격 · 별칭 찾기(값 묶음 같이)."""
return {"rows": _call(machine.find, q)}
@router.get("/machine/row")
def get_machine_row(key: str) -> dict:
return _call(machine.row, key)
@router.post("/calc")
def post_calc(body: CalcBody) -> dict:
with store.cm.mf.mp.choosing(body.고름):
@@ -11,6 +11,7 @@ import re
from pathlib import Path
from common_util.common_util_time import kst_today
from M01_MasterData import M01_MasterData_Machine as machine
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData import M01_MasterData_Store_Revise as revise
@@ -291,6 +292,7 @@ def save(batch: list[dict], confirmed: bool = False) -> dict:
): # 별칭(815-2) = 앞뒤 빈칸만 뗌 · 빈 글 = 칸 없음
text = str(row["별칭"] or "").strip()
row.update(별칭=text) if text else row.pop("별칭")
machine.fix(part) # 기계 줄 = 계수 서버 재계산 · 단위 · 출처 채움(821-2)
_keep_same(part)
fresh = False
for part in batch:
@@ -0,0 +1,68 @@
/* M01 기계 목록 · 상세(821-2) */
.m01-machine__row {
cursor: pointer;
}
.m01-machine__row:hover td {
background: var(--color-hover);
}
.m01-master__grid tr.is-new td {
background: color-mix(in srgb, var(--color-success) 12%, var(--color-canvas));
}
.m01-machine__grid td.m01-machine__money {
text-align: end;
font-variant-numeric: tabular-nums;
}
/* 목록 열은 화면 폭 안에 — 가로 넘침 없음 · 긴 글은 말줄임(마우스 글 = 전체) */
table.m01-master__grid.m01-machine__grid {
width: 100%;
table-layout: fixed;
}
.m01-machine__grid th:nth-child(1) {
width: 16%;
}
.m01-machine__grid th:nth-child(3),
.m01-machine__grid th:nth-child(10) {
width: 14%;
}
.m01-machine__grid th:nth-child(2),
.m01-machine__grid th:nth-child(9) {
width: 10%;
}
.m01-machine-detail {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
padding-bottom: var(--spacing-16);
}
.m01-machine-detail__meta {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--spacing-8) var(--spacing-12);
}
.m01-machine-detail__uses {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-machine-detail__uses ul {
margin: 0;
padding-left: var(--spacing-16);
font-size: var(--text-body-sm);
color: var(--color-text-secondary);
}
.m01-machine-detail__uses ul {
max-height: 240px;
overflow: auto;
}
+177
View File
@@ -0,0 +1,177 @@
/* =============================================================================
* M01_MasterData_UI_Machine.ts
* M01 기계 목록(PLAN 821-2 · 요구 790-2) — 표는 목록용 열만(이름 · 규격 · 구분 · 가격 · 손료계수 ·
* 주연료 · 조종원 · 시간당 경비(서버 값) · 별칭 · 비고) · 줄을 누르면 상세 페이지 · [새 기계] = 양식.
* 단위 · 출처 열 없음 · 고친 것 · 새 줄 · 지울 줄은 초안(노랑 · 새 · 지움 표시) — 저장은 페이지 [저장].
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createEmpty } from "@ui/ui_template_empty";
import { guessRows, pageKeepFirst, watchFit } from "@ui/ui_template_pager";
import { fetchRows, type Row, type RowsPage } from "./M01_MasterData_Api_Fetch";
import { onDraftChange, peek, same } from "./M01_MasterData_Draft";
import { fetchMachineHourly } from "./M01_MasterData_Machine_Api";
import { buildPager } from "./M01_MasterData_UI_Cells";
import { saveView, type Pick } from "./M01_MasterData_UI_Side";
const BAR_H = 52;
const num = (v: unknown): string =>
typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "";
/** 목록 열 — 이름 · 줄 → 글 */
const COLS: [string, (r: Row) => string][] = [
["이름", (r) => String(r["이름"] ?? "")],
["규격", (r) => String(r["규격"] ?? "")],
["구분", (r) => [r["구분"], r["상세구분"]].filter(Boolean).join(" · ")],
["가격(천원)", (r) => num(val(r)["가격"])],
["손료계수", (r) => num(val(r)["손료계수"])],
[
"주연료",
(r) =>
val(r)["주연료"] ? `${val(r)["주연료종류"] ?? ""} ${num(val(r)["주연료"])}ℓ`.trim() : "",
],
[
"조종원",
(r) =>
val(r)["조종원"] ? `${num(val(r)["조종원"])}인 ${val(r)["조종원직종"] ?? ""}`.trim() : "",
],
];
const val = (r: Row): Record<string, unknown> => (r["값"] ?? {}) as Record<string, unknown>;
/** 돌려받은 함수 = 걷기. `open(target)` = 상세(키 · 새 줄 `add:<차례>` · 새 기계 `new`). */
export function renderMachines(
host: HTMLElement,
pick: Pick,
q: string,
open: (target: string) => void,
): () => void {
const file = pick.file.file;
let size = guessRows(host, BAR_H);
let page = pick.view?.page ?? 1;
let data: RowsPage | null = null;
const hourly = new Map<string, string>();
const hourText = (key: string): string => hourly.get(key) ?? "…";
const line = (row: Row, cur: Row, cls: string, target: string, key = ""): HTMLElement => {
const tr = el("tr", {
className: `m01-machine__row ${cls}`.trim(),
attrs: { "data-key": key || target },
children: [
...COLS.map(([, get]) => {
const text = get(cur);
const td = el("td", { text, attrs: { title: text } });
td.classList.toggle("is-changed", cls !== "is-new" && text !== get(row));
return td;
}),
el("td", {
className: "m01-machine__money",
text: key ? hourText(key) : "",
attrs: key ? { "data-hourly": key } : {},
}),
el("td", { text: String(cur["별칭"] ?? ""), attrs: { title: String(cur["별칭"] ?? "") } }),
el("td", { text: String(cur["비고"] ?? ""), attrs: { title: String(cur["비고"] ?? "") } }),
],
});
tr.addEventListener("click", () => open(target));
return tr;
};
const paint = (): void => {
if (!data) return;
const d = data;
const draft = peek(file);
const body = el("tbody");
(draft?.adds ?? []).forEach((r, i) => body.append(line(r, r, "is-new", `add:${i}`)));
for (const row of d.rows) {
const key = String(row["키"]);
const cur = draft?.edits[key] ?? row;
const cls = draft?.deletes.includes(key) ? "is-deleted" : same(cur, row) ? "" : "is-changed";
body.append(line(row, cur, cls, key, key));
}
const add = createButton({ label: L("M01_MachineNew"), variant: "ghost", iconStart: "plus" });
add.addEventListener("click", () => open("new"));
const empty =
d.rows.length || draft?.adds.length
? []
: [createEmpty({ text: L("M01_NoRows"), small: true })];
host.replaceChildren(
el("div", { className: "ui-toolbar m01-master__bar", children: [add] }),
el("div", {
className: "ui-table-wrap m01-master__grid-wrap m01-machine__wrap",
children: [
el("table", {
className: "ui-table m01-master__grid m01-machine__grid",
children: [
el("thead", {
children: [
el("tr", {
children: [
...COLS.map(([h]) => el("th", { text: h })),
el("th", { text: `${L("M01_MachineHourly")}(원)` }),
el("th", { text: "별칭" }),
el("th", { text: "비고" }),
],
}),
],
}),
body,
],
}),
],
}),
buildPager(d.total, size, d.page, go),
...empty,
);
fit.ask();
};
/** 시간당 경비 — 표를 그린 뒤 서버 값으로 채움(마스터 판마다 서버가 한 번 셈) */
const fillHourly = async (keys: string[]): Promise<void> => {
const want = keys.filter((k) => !hourly.has(k));
if (!want.length) return;
try {
const got = await fetchMachineHourly(want);
for (const [k, h] of Object.entries(got)) hourly.set(k, h.계 === undefined ? "—" : num(h.계));
} catch {
for (const k of want) hourly.set(k, "—");
}
host.querySelectorAll<HTMLElement>("td[data-hourly]").forEach((td) => {
const text = hourly.get(td.dataset.hourly!);
if (text) td.textContent = text;
});
};
const go = (next: number): void => {
page = next;
void load();
};
const load = async (): Promise<void> => {
try {
data = await fetchRows(file, page, size, q, false, pick.sub, pick.detail, pick.state);
saveView(pick.group, { page: data.page });
paint();
void fillHourly(data.rows.map((r) => String(r["키"])));
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
}
};
const fit = watchFit(
host,
() => host.querySelector<HTMLElement>(".m01-master__grid-wrap"),
(rows) => {
if (rows === size) return;
page = pageKeepFirst(data?.page ?? page, size, rows);
size = rows;
void load();
},
);
const off = onDraftChange(paint);
void load();
return () => {
fit.stop();
off();
};
}
@@ -0,0 +1,304 @@
/* =============================================================================
* M01_MasterData_UI_MachineDetail.ts
* M01 기계 상세 페이지(PLAN 821-2 · 요구 790-2 · 791) — 기계 양식 부품 + 원문번호 · 원문 · 구분 ·
* 상세구분 · 별칭 · 비고 · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인).
* 고침은 초안(sessionStorage)에 바로 쌓임 → 페이지 [저장] 한 번에 서버로(자동저장 없음) ·
* 서버가 계수를 계수 짝으로 다시 셈 · 단위 · 출처 칸을 채움(`M01_MasterData_Machine.fix`).
* 대상 = 키(EQ…) · 저장 전 새 줄 `add:<차례>` · 새 기계 `new`(비슷한 기계에서 시작이 펼쳐짐).
* ========================================================================== */
import {
createButton,
createInputField,
createSelectField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createMachineForm, type MachineSimilar } from "@ui/ui_template_machine_form";
import { createText } from "@ui/ui_template_text";
import { setLeaveGuard } from "../A00_Common/router";
import type { Row } from "./M01_MasterData_Api_Fetch";
import { addRow, editRow, peek, removeAdd, setAdd, toggleDelete } from "./M01_MasterData_Draft";
import {
fetchMachineLists,
fetchMachineRow,
fetchMachineUses,
findMachines,
type MachineLists,
} from "./M01_MasterData_Machine_Api";
import type { Pick } from "./M01_MasterData_UI_Side";
export interface MachineDetailOptions {
target: string;
pick: Pick;
back: () => void;
/** 새 기계가 초안 새 줄이 되면 — 주소를 `add:<차례>` 로 */
onAdded?: (target: string) => void;
}
const META = ["원문번호", "원문", "구분", "상세구분", "별칭", "비고"] as const;
type Meta = Record<(typeof META)[number], string>;
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
/** 돌려받은 함수 = 걷기 */
export function openMachineDetail(host: HTMLElement, opts: MachineDetailOptions): () => void {
let alive = true;
const stop = (): void => {
alive = false;
setLeaveGuard(null);
};
void build(host, opts, () => alive).catch((error) => {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
opts.back();
});
return stop;
}
async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () => boolean) {
const file = opts.pick.file.file;
const lists = await fetchMachineLists();
let version = opts.pick.file.version;
let key = "";
let addAt: number | null = null;
let orig: Row | null = null;
let cur: Row;
if (opts.target.startsWith("add:")) {
addAt = Number(opts.target.slice(4));
const row = peek(file)?.adds[addAt];
if (!row) throw new Error(L("M01_MachineNotFound"));
cur = row;
} else if (opts.target === "new") {
cur = blankRow(lists);
} else {
key = opts.target;
const got = await fetchMachineRow(key);
version = peek(file)?.version ?? got.version;
orig = got.row;
cur = peek(file)?.edits[key] ?? got.row;
}
if (!alive()) return;
let base: Row = orig ?? cur; // 줄 칸 차례 — 고침은 원래 줄 · 새 줄은 지금 줄
const meta: Meta = Object.fromEntries(META.map((m) => [m, text(cur[m])])) as Meta;
const metaOrig: Meta = Object.fromEntries(META.map((m) => [m, text((orig ?? {})[m])])) as Meta;
const isNew = !key;
/* --- 비슷한 기계 찾기(양식이 고르면 값 · 이름 · 규격 복사 · 여기서 원문 · 구분 복사) --- */
const found = new Map<string, Row>();
const findSimilar = async (q: string): Promise<MachineSimilar[]> => {
const rows = await findMachines(q);
return rows.map((r) => {
found.set(String(r["키"]), r);
return {
key: String(r["키"]),
name: text(r["이름"]),
spec: text(r["규격"]),
tag: text(r["상세구분"] || r["구분"]),
values: (r["값"] ?? {}) as Row,
};
});
};
const notice = el("div");
const form = createMachineForm({
name: text(cur["이름"]),
spec: text(cur["규격"]),
values: (cur["값"] ?? {}) as Row,
coefMode: "pumsem",
fuels: lists.fuels,
jobs: lists.jobs,
power: lists.power,
hour: lists.hour,
findSimilar,
onSimilar: (item) => {
const r = found.get(item.key);
if (!r || !isNew) return;
base = { ...blankRow(lists), ...pickMeta(r), 값: r["값"] ?? {}, 비고: r["비고"] ?? null };
for (const m of ["원문", "구분", "상세구분"] as const) meta[m] = text(r[m]);
paintMeta();
},
onChange: () => commit(),
});
/** 양식 · 머리 칸 → 초안(이름 빈 칸 · 숫자 아닌 칸이 있으면 안 넣음) */
const commit = (): void => {
const s = form.state();
const errors = [
...s.errors,
...(s.name ? [] : [`${L("MF_Name")} · ${L("M01_MachineErrors")}`]),
];
notice.replaceChildren(...errors.map((e) => createText(e, "error")));
if (errors.length) return;
const row: Row = { ...base, ...cur, 이름: s.name, 규격: s.spec, 값: s.values };
for (const m of META) {
if (orig && meta[m] === metaOrig[m]) {
if (m in orig) row[m] = orig[m];
else delete row[m]; // 안 고친 칸 = 원래 그대로
} else if (meta[m]) row[m] = meta[m];
else if (m === "별칭")
delete row[m]; // 빈 별칭 = 칸 없음
else row[m] = m === "비고" ? null : "";
}
const ordered: Row = Object.fromEntries(
[...Object.keys(base), ...Object.keys(row)]
.filter((k, i, all) => all.indexOf(k) === i && k in row)
.map((k) => [k, row[k]]),
);
if (key && orig) editRow(file, version, key, orig, ordered);
else if (addAt !== null) setAdd(file, addAt, ordered);
else {
addRow(file, version, { 키: "", ...ordered });
addAt = (peek(file)?.adds.length ?? 1) - 1;
opts.onAdded?.(`add:${addAt}`);
}
cur = ordered;
checkSame(s.name, s.spec);
};
/* --- 같은 이름 · 규격 알림 --- */
let sameTimer: number | undefined;
const checkSame = (name: string, spec: string): void => {
window.clearTimeout(sameTimer);
sameTimer = window.setTimeout(async () => {
const rows = name ? await findMachines(name).catch(() => [] as Row[]) : [];
const hit = rows.find(
(r) => r["키"] !== key && text(r["이름"]) === name && text(r["규격"]) === spec,
);
form.setNotice(hit ? L("M01_MachineSame").replace("{value}", text(hit["키"])) : "");
}, 300);
};
/* --- 머리 칸 --- */
const metaBox = el("div", { className: "m01-machine-detail__meta" });
const paintMeta = (): void => {
const field = (m: (typeof META)[number], label: string): HTMLElement => {
const options = m === "원문" ? lists.books : m === "구분" ? Object.keys(lists.groups) : null;
const mark = (root: HTMLElement): void => {
root.classList.toggle("is-changed", !isNew && meta[m] !== metaOrig[m]);
};
if (options) {
const list = options.includes(meta[m]) || !meta[m] ? options : [meta[m], ...options];
const f = createSelectField({
label,
options: list.map((v) => ({ value: v, text: v })),
value: meta[m],
size: 28,
});
f.select.addEventListener("change", () => {
meta[m] = f.select.value;
if (m === "구분" && lists.groups[meta[m]]) meta["원문"] = lists.groups[meta[m]];
mark(f.root);
commit();
if (m === "구분") paintMeta();
});
mark(f.root);
return f.root;
}
const f = createInputField({ label, value: meta[m], size: 28 });
f.input.addEventListener("input", () => {
meta[m] = f.input.value;
mark(f.root);
commit();
});
mark(f.root);
return f.root;
};
metaBox.replaceChildren(
field("원문번호", L("M01_MachineSourceNo")),
field("원문", "원문"),
field("구분", "구분"),
field("상세구분", L("M01_MachineDetailGroup")),
field("별칭", "별칭"),
field("비고", "비고"),
);
};
paintMeta();
/* --- 쓰는 곳 --- */
const usesBox = el("section", { className: "m01-machine-detail__uses" });
let uses: string[] = [];
const paintUses = (): void => {
usesBox.replaceChildren(
el("strong", { text: `${L("M01_MachineUses")} ${uses.length}` }),
uses.length
? el("ul", { children: uses.map((u) => el("li", { text: u })) })
: createText(L("M01_MachineNoUses")),
);
};
if (key)
void fetchMachineUses(key)
.then((got) => {
uses = got;
paintUses();
})
.catch(() => paintUses());
else paintUses();
/* --- 단추 · 떠남 --- */
const dirty = (): boolean => addAt !== null || Boolean(key && peek(file)?.edits[key]);
const leave = async (): Promise<boolean> =>
!dirty() || (await showConfirmDialog(L("M01_MachineLeave")));
setLeaveGuard(leave);
const backBtn = createButton({
label: L("M01_MachineBack"),
variant: "ghost",
iconStart: "chevronLeft",
});
backBtn.addEventListener("click", async () => {
if (await leave()) opts.back();
});
const del = createButton({ label: L("M01_MachineDelete"), variant: "danger-ghost" });
del.addEventListener("click", async () => {
const ask = L("M01_MachineDeleteAsk").replace("{value}", String(uses.length));
if (!(await showConfirmDialog(uses.length ? `${ask}\n${uses.join("\n")}` : ask))) return;
if (key) toggleDelete(file, version, key);
else if (addAt !== null) removeAdd(file, addAt);
setLeaveGuard(null);
opts.back();
});
if (!key && addAt === null) del.hidden = true;
const title = isNew ? L("M01_MachineNew") : `${text(cur["이름"])} ${text(cur["규격"])}`.trim();
host.replaceChildren(
el("div", {
className: "m01-machine-detail",
children: [
el("div", {
className: "ui-toolbar m01-master__bar",
children: [
backBtn,
el("strong", { text: title }),
el("span", { className: "ui-text ui-text--muted", text: key }),
del,
],
}),
notice,
metaBox,
form.root,
usesBox,
],
}),
);
if (opts.target === "new")
host.querySelector<HTMLDetailsElement>("details")?.setAttribute("open", "");
}
/** 새 기계 바탕 — 칸 차례는 기계 줄과 같음 · 단위 · 출처는 서버가 채움 */
function blankRow(lists: MachineLists): Row {
const group = Object.keys(lists.groups)[0] ?? "";
return {
원문번호: "",
원문: lists.groups[group] ?? lists.books[0] ?? "",
구분: group,
상세구분: "",
이름: "",
규격: "",
단위: {},
값: {},
출처: {},
비고: null,
};
}
/** 고른 기계의 원문 · 구분 · 상세구분(새 기계 바탕) */
const pickMeta = (r: Row): Row => ({ 원문: r["원문"], 구분: r["구분"], 상세구분: r["상세구분"] });
+55 -3
View File
@@ -33,6 +33,9 @@ import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch";
import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft";
import { buildHeadBar, buildHeadFilters } from "./M01_MasterData_UI_Head";
import { renderRows } from "./M01_MasterData_UI_Rows";
import { renderMachines } from "./M01_MasterData_UI_Machine";
import { openMachineDetail } from "./M01_MasterData_UI_MachineDetail";
import "./M01_MasterData_UI_Machine.css";
import { renderRateRows } from "./M01_MasterData_UI_RateSheet";
import {
buildSide,
@@ -54,6 +57,11 @@ import type { LogicHandle } from "./M01_MasterData_UI_LogicLab";
import "./M01_MasterData_UI_Style.css";
const TABLE_GROUPS = ["소요량", "계수"];
// 기계 = 목록 + 상세 페이지(821-2) · 주소 `?machine=키|add:n|new` · 목록 거름은 되돌아올 때 다시 엶
const MACHINE_FILE = "기계.json";
const MACHINE_BACK = "m01.machine.back";
const machineUrl = (target?: string): string =>
`#/${ROUTES.M01_MASTER_DATA}${target ? `?machine=${encodeURIComponent(target)}` : ""}`;
export async function renderM01MasterData(root: HTMLElement): Promise<void> {
const user = await fetchSessionUser().catch(() => null);
@@ -171,15 +179,39 @@ function buildPage(): HTMLElement {
current = { ...pick, view: undefined };
const groupTitle = pick.group === pick.label ? [pick.group] : [pick.group, pick.label];
title.textContent = groupTitle.join(" · ");
const machine = pick.file.file === MACHINE_FILE;
if (machine) sessionStorage.setItem(MACHINE_BACK, JSON.stringify(pick));
else sessionStorage.removeItem(MACHINE_BACK);
if (currentQuery().has("machine")) history.replaceState(null, "", machineUrl());
dispose = TABLE_GROUPS.includes(pick.group)
? renderTables(body, pick, query, openLogicTab)
: pick.group === "요율"
? renderRateRows(body, pick, query)
: renderRows(body, pick, query);
: machine
? renderMachines(body, pick, query, (target) => openMachine(target, true))
: pick.group === "요율"
? renderRateRows(body, pick, query)
: renderRows(body, pick, query);
renderParts(partsHost, pick, (key) => openLogicTab(key));
showNotice(isStale(pick.file.file, pick.file.version) ? [L("M01_FileStale")] : []);
};
/** 기계 상세 — 목록에서 열면 주소를 쌓아 뒤로 가기 = 목록(페이지를 다시 그려 목록 거름을 되엶) */
const openMachine = (target: string, push: boolean): void => {
if (!current) return;
const pick = current;
if (push) history.pushState({ m01Machine: true }, "", machineUrl(target));
else history.replaceState(history.state, "", machineUrl(target));
dispose();
dispose = openMachineDetail(body, {
target,
pick,
back: () => {
if ((history.state as { m01Machine?: boolean } | null)?.m01Machine) return history.back();
openFile({ ...pick, view: loadView(pick.group) });
},
onAdded: (next) => history.replaceState(history.state, "", machineUrl(next)),
});
};
let searchTimer: number | undefined;
search.addEventListener("input", () => {
window.clearTimeout(searchTimer);
@@ -342,5 +374,25 @@ function buildPage(): HTMLElement {
// 주소 `?open=` — 대시보드 설계 기본값 줄 · 옛 M02 `open=옵션` 즐겨찾기가 그 칸을 고른 채 엶
const want = currentQuery().get("open");
if (want === "로직" || want === "옵션" || want === "별칭") side.reveal(want);
const machine = currentQuery().get("machine");
const backTo = sessionStorage.getItem(MACHINE_BACK);
if (machine || (backTo && !want)) {
void side.refresh("기계").then((files) => {
const saved = (backTo ? JSON.parse(backTo) : {}) as Partial<Pick>;
const file = files.find((f) => f.file === MACHINE_FILE);
if (!file) return;
const label = saved.label ?? "기계";
openFile({
group: "기계",
sub: "",
detail: "",
label,
...saved,
file,
view: loadView("기계"),
});
if (machine) openMachine(machine, false);
});
}
return layout;
}
+12 -1
View File
@@ -50,6 +50,9 @@ def pack(got: dict, name: str) -> dict[str, Any]:
"""파서 답 → 둘 꼴(셈 · 변환에 쓰는 칸만 · JSON)."""
out: dict[str, Any] = {"꼴": got["꼴"], "파일": name, "표지": got.get("표지") or []}
out |= {"엑셀": True} if got.get("엑셀") else {} # 812-1 엑셀 읽기기(`M02_San_Xlsx`) — 같은 꼴
out |= (
{"기계구성": got["기계구성"]} if got.get("기계구성") else {}
) # 821-4 EST 원파일 기계 구성
head = ("코드", "번", "이름", "규격", "단위", "저장")
if got.get(
"엑셀"
@@ -104,7 +107,15 @@ def numbers(saved: dict) -> dict[int, dict]:
def emit(saved: dict, no: int, keys: bool = True) -> dict[str, Any]:
"""원파일 산근 블록 하나 → `{로직, 원본}`(원본 `원파일` True · 코드 · 확정 수) · 없는 번 = KeyError."""
"""원파일 산근 블록 하나 → `{로직, 원본}`(원본 `원파일` True · 코드 · 확정 수) · 없는 번 = KeyError ·
기계 찾기 = M01 만(821-4 — 못 이은 기계 → 내 기계는 `M02_San_MyMachine.attach`)."""
from M02_MasterTemplete.M02_Machine_Find import m01_only
with m01_only():
return _emit(saved, no, keys)
def _emit(saved: dict, no: int, keys: bool) -> dict[str, Any]:
blk = numbers(saved)[int(no)]
if saved["꼴"] == "EST":
names = {b["코드"]: n for n, b in numbers(saved).items()}
+2
View File
@@ -325,6 +325,8 @@ def _attach(doc: dict, row: dict, ids: list[str], src: dict[str, dict]) -> None:
if not item.get("참조") and var["연결"].get("값") != excel:
continue # SK 덮개 단가 = 사용자 값
link, note = _link_of(item)
if mine := (item.get("자원") or {}).get("내기계"): # 821-4 꼬리표 「내 기계」
note = f"내 기계 {mine}" + ("" if link else " · 시간당 값 직접 — 엑셀 단가 그대로")
if note:
var["설명"] = note
if link is None:
+13 -2
View File
@@ -21,6 +21,7 @@ from M02_MasterTemplete import M02_Doc_Import as dim
from M02_MasterTemplete import M02_Doc_Screen as screen
from M02_MasterTemplete import M02_Doc_Sk as dsk
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
@@ -40,11 +41,10 @@ def _file(key: str) -> dict[str, Any]:
def _settled(doc: dict, job: Any = None) -> tuple[dict, Any]:
"""옛 셈 길 첫머리(`calc_doc`)와 같게 — (정리한 문서, 사본 색인)."""
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Sangeun_Calc as calc
raw = {**doc, skc.JOB: job} if job else doc
whole = skc.over(doc := skc.settle(raw), m01.loaded()[1])
whole = skc.over(doc := skc.settle(raw), skc.whole_()) # 내 기계(821-1)
try:
return sd.clean(doc, None, whole), whole
except sd.DocError as e:
@@ -129,11 +129,22 @@ def edited(copy: dict, body: dict, key: str, sk: dict | None = None) -> dict:
*(c for d in out.get("끊음", {}).values() for c in dc.calls(d)),
]
if not dim.is_imported(sk):
out |= _mine(out)
out["부름"] = dc.closure(dc.over(out), calls, copy.get("부름"))
out["따로"] = True
return out
def _mine(copy: dict) -> dict:
"""줄 쌓기에서 고른 내 기계(기계='개인 EQP…') 가운데 값 사본에 없는 것만 지금 값으로 더함
(821-3) — 그 뒤 내 기계를 고쳐도 금액 그대로 + 「단가새값」 · [단가 갱신] 으로 반영 ·
지워도 마지막 값."""
keys = mym.keys_in(copy.get("문서"))
if not keys or "로직" not in copy:
return {}
return skc.grow(dc.values_of(copy), skc.snap([], keys))
def preview(key: str, body: dict) -> dict[str, Any]:
"""미리 셈(저장 없음) — {셈, 전, 후, 바뀐자원?, 버림?}."""
doc = _file(key)["문서"]
+5 -1
View File
@@ -23,6 +23,7 @@ from decimal import Decimal
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MyMachine as mym
mf = m01.cm.mf
mk = m01.mk
@@ -239,6 +240,8 @@ def collect(
`extra` = 마스터 밖 로직 줄(굳힌 단가산출 719-1) — 그 줄이 닿는 것만 모음(줄은 안 담음) ·
`over` = M02 로직 사본 줄(795-4) — 같은 키는 M01 줄 대신 · M01 에 없는 키 = `COPY_FILE`."""
files, whole = master()
whole = mym.whole(whole) # 지금 층 내 기계 줄(821-1) — 없으면 M01 그대로
own = {str(r.get("키")): r for r in mym.added(whole)}
mine = {str(r.get("키")): r for r in over or []}
if mine:
from M02_MasterTemplete import M02_Sangeun_Copy as skc
@@ -263,7 +266,8 @@ def collect(
seen.update(str(row.get("키")) for row in rows)
if rows:
out[name] = {**{k: v for k, v in data.items() if k != slot}, slot: rows}
if left := [r for k, r in mine.items() if k in collector.keys and k not in seen]:
left = [r for k, r in {**own, **mine}.items() if k in collector.keys and k not in seen]
if left:
out[COPY_FILE] = {"그룹": "사본", "줄": left}
if collector.missing:
out["_빠진"] = sorted(set(collector.missing))
+30 -10
View File
@@ -1,6 +1,6 @@
"""M02 기계 찾기(PLAN 812-9 · 요구 718 · 719) — 가져온 기계 글(이름 · 규격) → 우리 EQ · 서버 단독.
과녁 = M01 기계표 전체(EQ) · 부르는 곳 = 불러오기 이음(`M02_Hopyo_Row.m01_picks` · `M02_San_Emit._m01`) ·
과녁 = M01 기계표 전체(EQ) + 지금 층 내 기계(821-1 · `M02_MyMachine.whole`) · 부르는 곳 = 불러오기 이음(`M02_Hopyo_Row.m01_picks` · `M02_San_Emit._m01`) ·
일위대가 호표(`M02_Hopyo_Elem.machine(s)` · `M02_Hopyo_Fixed._eqs`).
차례:
1. 별칭(M01 기계 줄 「별칭」 칸 · 815-1 `M02_Elem_Alias`) — 「이름 규격」 통째 별칭 = 그 EQ(여럿 =
@@ -19,11 +19,12 @@ import math
import re
import time
import unicodedata
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from functools import lru_cache
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
# 소리만 다른 표기 → 한 글(양쪽에 같이 씀 · 차례대로)
SOUND = (
("로우더", "로더"), ("로울러", "롤러"), ("브레이카", "브레이커"), ("브레카", "브레이커"),
@@ -142,20 +143,39 @@ class Row:
) # 규격 글 낱말(「더블」 · 「엔진식」)
@lru_cache(maxsize=2)
def _table(_whole: int) -> tuple[list[Row], frozenset[str], dict[str, Row]]:
rows = [Row(r) for r in m01.loaded()[1].numbers.get("EQ", {}).values()]
@lru_cache(maxsize=4)
def _table(whole: Any) -> tuple[list[Row], frozenset[str], dict[str, Row]]:
rows = [Row(r) for r in whole.numbers.get("EQ", {}).values()]
words = frozenset(w for r in rows for w in r.words)
return rows, words, {r.key: r for r in rows}
_SEEN: list = [0.0, None] # (때, 표) — M01 판 확인(파일 도장)은 1 초에 한 번
_SEEN: list = [0.0, None, None] # (때, 표, 층) — M01 · 내 기계 판 확인은 층마다 1 초에 한 번
_M01_ONLY: ContextVar[bool] = ContextVar("m01_only", default=False)
@contextmanager
def m01_only() -> Iterator[None]:
"""이 안의 찾기 = M01 기계만(821-4 불러오기 — 내 기계 잇기는 `M02_San_MyMachine.attach` 한 곳)."""
token = _M01_ONLY.set(True)
try:
yield
finally:
_M01_ONLY.reset(token)
def table() -> tuple[list[Row], frozenset[str], dict[str, Row]]:
now = time.monotonic()
if _SEEN[1] is None or now - _SEEN[0] > 1.0:
_SEEN[:] = [now, _table(id(m01.loaded()[1]))]
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
if _M01_ONLY.get():
return _table(m01.loaded()[1])
now, here = time.monotonic(), store.root()
if _SEEN[1] is None or now - _SEEN[0] > 1.0 or _SEEN[2] != here:
_SEEN[:] = [now, _table(mym.whole()), here]
return _SEEN[1]
+9 -4
View File
@@ -5,6 +5,8 @@
(`로직(GC, 기계='원문번호')` · 옵션은 기본값). 짝 차례 · 셈은
`DataFlow/M02_DF_Values._machine_hourly` 와 같은 규칙(그 폴더는 시험 길이라 따로 둠).
마스터 파일은 읽기만 · 로그인 사용자 모두.
기계표 = M01 + 지금 층 내 기계(821-1 · `M02_MyMachine` — 식은 채운 칸으로 고른 같은 GC 식 · 직접 = 그 값) ·
층을 모르는 길(`/api/m02/logic-lib/machine-hourly`)은 M01 만.
"""
from __future__ import annotations
@@ -12,6 +14,7 @@ from __future__ import annotations
from typing import Any
from M01_MasterData import M01_MasterData_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
cm = store.cm
_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용
@@ -43,7 +46,7 @@ def _rank(row: dict) -> tuple:
def _operated(code: str) -> bool:
"""기계표(원문 8-4) 조종원 > 0 — 그 원문번호 기계 가운데 하나라도."""
rows = store.loaded()[1].index.get("EQ", {}).values()
rows = mym.whole().index.get("EQ", {}).values()
return any(
str(r.get("원문번호")) == code and float((r.get("값") or {}).get("조종원") or 0) > 0
for r in rows
@@ -56,7 +59,7 @@ def logic_of(code: str) -> dict | None:
(「조종원 없는 기종」 목록에 겹친 기계 — 진동롤러(핸드가이드식) · 고소작업차 …)."""
fit = [r for r in _family() if code in [str(c) for c in _codes(r)]]
if not fit:
return _own(code)
return _own(code) or mym.logic_for(code)
op = _operated(code)
def order(row: dict) -> tuple:
@@ -70,7 +73,7 @@ def logic_of(code: str) -> dict | None:
def _own(code: str) -> dict | None:
"""그 기계만의 시간당 경비 식(816-1 · 4륜구동 1톤 덤프트럭 = GF000145 · 816-3 체인톱 = GF000337) —
「기계」 입력 없는 기계경비식이 그 원문번호 기계 줄 값(`EQ….가격` · `EQ….손료계수`)을 읽으면 그 식."""
rows = store.loaded()[1].index.get("EQ", {}).values()
rows = mym.whole().index.get("EQ", {}).values()
keys = [f"{r.get('키')}." for r in rows if code and str(r.get("원문번호")) == code]
for data in store.loaded()[0].values():
for row in data.get("줄") or [] if data.get("그룹") == "로직" else []:
@@ -91,9 +94,11 @@ def hourly(key: str) -> dict[str, Any]:
if not key.startswith("EQ"):
return {"ok": False, "reason": f"「{key}」 는 기계 아님"}
try:
row = store.loaded()[1].get(key)
row = (whole := mym.whole()).get(key)
except cm.mf.FormulaError as e:
return {"ok": False, "reason": str(e)}
if mym.mine(key):
return mym.hourly(row, whole)
code = str(row.get("원문번호") or "")
logic = logic_of(code)
if not logic:
@@ -17,6 +17,8 @@
* · (728-2) 돈 아닌 기초(결과단위가 「원」 아님 · ㎥/시간 등 21 개) = 목록에서 뺌
* · (785-3) `oil` = M01 유가전력(MO · `/logic-lib/elements?group=재료` 의 MO 줄) 고름 · 고정 줄 꼴 = 이음 유가 MO ·
* 재료비 = 지금 값(셈은 M01 값) · 「M01 전체」 탭 재료 아래
* · (821-3) `mine` = 개인 층 내 기계 묶음(품셈 기계 위 · 구분 「내 기계」 · 출처 「개인 키」 · 금액 = 서버 시간당) ·
* 머리 [새 기계 만들기](양식 창 → 저장하면 그 기계로 고름) · 「시간당 값 직접」 내 기계 = 줄 쌓기 식 없음 → 회색
* ========================================================================== */
import { createDataTable } from "@ui/ui_template_data_table";
@@ -24,6 +26,9 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e
import { t as L } from "@ui/ui_template_locale";
import { aliasNote, matchLogic } from "@ui/ui_template_logic_find";
import { openNewMaterial } from "./M02_Material_Pick";
import { listMyMachines, mineElement, type MyMachineRow } from "./M02_MyMachine_Api";
import { openMyMachineModal } from "./M02_MyMachine_Form";
import { mt } from "./M02_MyMachine_Text";
import { openModal } from "@ui/ui_template_modal";
import { createTabs } from "@ui/ui_template_tabs";
import { createText } from "@ui/ui_template_text";
@@ -83,6 +88,8 @@ export interface FindPickOptions {
machine?: (e: M01Element) => void;
/** 785-3 M01 유가전력(MO) 고름 — 있을 때만 유가 줄이 보임 */
oil?: (e: M01Element) => void;
/** 821-3 개인 층 내 기계 묶음(`machine` 과 함께) — 주인(null = 자기 층) */
mine?: { owner: string | null };
}
/** 727-1 기초마다 한 줄(옛 창 `Labor_Pick` 도 씀) — 규격 칸 = 「옵션 N」 */
@@ -182,6 +189,7 @@ export function openFindPicker(opt: FindPickOptions): void {
let goods: PickItem[] = [];
let machines: PickItem[] = [];
let oils: PickItem[] = []; // 785-3 유가전력 MO
let mines: MyMachineRow[] = []; // 821-3 내 기계
const lists: Record<"sk" | "pre", SangeunRow[] | null> = { sk: null, pre: null };
let shown: Line[] = [];
let seq = 0;
@@ -280,6 +288,26 @@ export function openFindPicker(opt: FindPickOptions): void {
: take(() => put(e)),
};
});
/** 821-3 내 기계 줄 — 금액 = 서버 시간당 · 직접 = 회색(줄 쌓기 식 없음) */
const mineLines = (q: string): Line[] => {
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
return mines
.filter((r) => words.every((w) => `${r.이름} ${r.규격} ${r.키}`.toLowerCase().includes(w)))
.map((r) => {
const e = mineElement(r, r.시간당);
const off = !e.로직 || !opt.machine;
return {
품명: r.이름,
규격: r.규격,
단위: "시간",
금액: e.값,
구분: join(mt("Pick_Group"), mt(r.방식), off ? mt("Pick_Direct") : ""),
출처: r.원문번호,
dim: off,
go: off ? () => showToast(mt("Pick_Direct"), "warning") : take(() => opt.machine?.(e)),
};
});
};
const docLines = (rows: SangeunRow[], kind: string): Line[] =>
rows.map((x) => ({
품명: x.제목 || x.이름,
@@ -299,6 +327,7 @@ export function openFindPicker(opt: FindPickOptions): void {
...(ok("labor") ? elementLines("인력", people) : []),
...(ok("material") ? elementLines("재료", goods) : []),
...(opt.oil && ok("oil") ? elementLines("유가", oils) : []),
...(opt.machine && opt.mine && ok("machine") ? mineLines(q) : []),
...(opt.machine && ok("machine") ? elementLines("기계", machines) : []),
];
} else {
@@ -387,9 +416,25 @@ export function openFindPicker(opt: FindPickOptions): void {
});
newMat.dataset.materialNew = "";
const canNew = opt.layer === "personal" && !!opt.material && ok("material");
// 821-3 내 기계 묶음이면 머리 [새 기계 만들기] — 양식 창에서 저장하면 그 기계로 고름
const newMach = createButton({
label: mt("Pick_New"),
variant: "ghost",
onClick: () =>
openMyMachineModal(opt.mine?.owner ?? null, (got) => {
const e = mineElement(got.줄, got.시간당);
if (!e.로직) return showToast(mt("Pick_Direct"), "warning");
take(() => opt.machine?.(e))();
}),
});
newMach.dataset.machineNew = "";
const headBtns = [
...(canNew ? [newMat] : []),
...(opt.mine && opt.machine && ok("machine") ? [newMach] : []),
];
({ close } = openModal({
title: opt.title ?? L("M02_FindTitle"),
...(canNew ? { actions: [newMat] } : {}),
...(headBtns.length ? { actions: headBtns } : {}),
closeLabel: tc("Pick_Close"),
dialogClass: "m01-logic__pick m01-combo",
width: "960px", // 표 여섯 칸
@@ -415,6 +460,13 @@ export function openFindPicker(opt: FindPickOptions): void {
})
.catch(fail);
void findElements();
if (opt.mine && opt.machine && ok("machine"))
void listMyMachines(opt.mine.owner)
.then((rows) => {
mines = rows;
draw();
})
.catch(fail);
void upper(opt.layer, opt.owner)
.then((scopes) =>
Promise.all(
@@ -5,6 +5,7 @@
* 바꾸기 전후 금액 · [원본 글 보기]
* · 줄 2 = 이음 없는 자원 = 표지 + [이음 고르기]
* · 줄 2-2 = 소요량 · 계수 이음(797-2 · 후보 집계 + [이음 보기] → `Hopyo_QtyLinks` 모달)
* · 줄 2-3 = 못 이은 기계 → 내 기계(821-4) — 「내 기계 생성 n」 · 줄마다 「내 기계」 꼬리표 · 개인 층 밖 = 「만들어질 내 기계 n」
* · 경쟁사 로직 그대로 — 기초로 바꾸기 · 근거 기초 제안 없음(809-1 · 요구 703)
* 부품 = 단추 · 배지 · 글 · 입력 · 띠 · 표 · 모달(날 단추 · 입력 없음) · 서버 길은 부르는 쪽.
* ========================================================================== */
@@ -47,6 +48,12 @@ const TEXT = {
Gap: ["바꾸기 전 {a} → 후 {b} ({d})", "Before {a} → after {b} ({d})"],
QtyLinks: ["소요량 · 계수 이음 {n}", "Quantity / coefficient links {n}"],
QtyOpen: ["이음 보기", "Show links"],
MyMade: ["내 기계 생성 {n}", "My machines created {n}"],
MyUsed: ["내 기계 이음 {n}", "Linked to my machines {n}"],
MyPlan: ["만들어질 내 기계 {n}", "My machines to create {n}"],
MyTag: ["내 기계", "My machine"],
MyNew: ["이번에 만듦 · 값 확인 필요", "Created now · check values"],
MyOld: ["있던 내 기계에 이음", "Linked to an existing one"],
Close: ["닫기", "Close"],
} as const satisfies Record<string, readonly [string, string]>;
export const tx = (k: keyof typeof TEXT): string =>
@@ -116,6 +123,34 @@ function checkBadge(doc: ImportedDoc): HTMLElement {
return b;
}
/** 못 이은 기계 → 내 기계(821-4) — 생성 수 · 이은 수 · 줄마다 꼬리표 · 예정 수 */
function myMachines(src: NonNullable<ImportedDoc["가져온"]>["원본"]): HTMLElement | null {
const got = src?.내기계 ?? [];
const plan = src?.내기계예정 ?? [];
if (!got.length && !plan.length) return null;
const made = got.filter((m) => m.새로).length;
const items: HTMLElement[] = [];
if (made) items.push(createBadge(tx("MyMade").replace("{n}", String(made)), "info"));
if (got.length > made) items.push(createBadge(tx("MyUsed").replace("{n}", String(got.length - made)), "neutral")); // prettier-ignore
for (const m of got) {
const b = createBadge(
`${tx("MyTag")} · ${[m.이름, m.규격].filter(Boolean).join(" ")}`,
"accent",
);
b.title = `${m.키} · ${m.새로 ? tx("MyNew") : tx("MyOld")}`;
b.dataset.importedMine = m.키;
items.push(b);
}
if (plan.length) {
const b = createBadge(tx("MyPlan").replace("{n}", String(plan.length)), "warning");
b.title = plan.map((m) => [m.이름, m.규격].filter(Boolean).join(" ")).join("\n");
items.push(b);
}
const bar = createToolbar({ items });
bar.dataset.importedMineBar = String(made);
return bar;
}
/** 머리 판 */
export function importedPanel(s: PanelState, a: PanelActs): HTMLElement {
const meta = s.doc.가져온 ?? {};
@@ -163,6 +198,8 @@ export function importedPanel(s: PanelState, a: PanelActs): HTMLElement {
];
kids.push(createToolbar({ items: [...tags, ...items] }));
}
const mine = myMachines(src);
if (here && mine) kids.push(mine);
if (here && s.qty?.자리) {
const open = createButton({ label: tx("QtyOpen"), size: 24, variant: "ghost", onClick: a.qty });
open.dataset.importedQty = String(s.qty.자리);
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 차례(716-3 · 717-2a) — 「단가산출」(층 단추 따로 · 기본 개인 · `UI_Side_Sangeun`) › 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` ·
* 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
* 50-3 · 134-3) › 「내 기계」 · 「내 품목」(821-3 · 개인 층 · 펼치면 가운데 화면) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기).
* 구조물 옵션 정의는 M01 로 옮김(716-1).
* 도면 양식 = 시스템 관리자면 늘(마스터 층 것 · 구조물 조합 층과 무관 · 720-4).
@@ -37,6 +37,9 @@ import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";
import type { Selection } from "./M02_MasterTemplete_UI_Main";
import { buildPrecomboSection, type PrecomboOptions } from "./M02_MasterTemplete_UI_Side_Precombo";
import { buildSangeunSection, type SangeunOptions } from "./M02_MasterTemplete_UI_Side_Sangeun";
import { buildMyItemsView } from "./M02_MyItems_View";
import { buildMyMachineView } from "./M02_MyMachine_View";
import { mt } from "./M02_MyMachine_Text";
import { createEmpty } from "@ui/ui_template_empty";
import { createDivider } from "@ui/ui_template_divider";
@@ -154,6 +157,35 @@ export function buildSide(opt: SideOptions): SideHandle {
getOpen: opt.getOpen,
});
const sangeunSec = section(L("M02_SangeunTitle"), sangeun.body);
// 내 기계 · 내 품목(821-3) — 자기 개인 층 · 펼치면 가운데 화면(목록 · 양식)
const hint = (key: "Side_Hint" | "Items_Side_Hint"): HTMLElement =>
createEmpty({ text: mt(key), small: true });
const myMachSec = openOnExpand(section(mt("Title"), hint("Side_Hint")), async () => {
if (!(await opt.confirmLeave())) return;
let mark = (_d: boolean): void => {};
const node = buildMyMachineView({ owner: null, setDirty: (d) => mark(d) });
mark = opt.showView(mt("Title"), node);
});
myMachSec.dataset.myMachines = "";
const myItemsSec = openOnExpand(section(mt("Items_Title"), hint("Items_Side_Hint")), async () => {
if (!(await opt.confirmLeave())) return;
const node = buildMyItemsView({
owner: null,
openSk: async (u) => {
if (!(await opt.confirmLeave())) return;
opt.onOpen({
layer: "personal",
owner: null,
canWrite: true,
projectId: null,
kind: "sangeun",
name: u.키,
label: `${u.키} ${u.이름}`.trim(),
});
},
});
opt.showView(mt("Items_Title"), node);
});
// 실무자료로 일위대가를 만들었음(확인 창 · 엑셀 보기 창) → 목록 다시 · 이 패널이 내려갔으면 듣기 끝
const onBuilt = (): void =>
precombo.body.isConnected
@@ -173,6 +205,8 @@ export function buildSide(opt: SideOptions): SideHandle {
children: [
sangeunSec,
precomboSec,
myMachSec,
myItemsSec,
summary,
createDivider(),
section(hopyo.title, hopyo.body),
+95
View File
@@ -0,0 +1,95 @@
/* =============================================================================
* M02_MyItems_View.ts
* M02 「내 품목」 모아 보기(PLAN 821-3 · 요구서 796 3 장 · 읽기만) — 좌측 「내 품목」 칸을 펼치면 가운데에 뜸(개인 층).
* 줄 = 그 층 단가산출 줄에 값을 바로 적은 재료(우리 자재에 안 이어진 것 · 서버 `M02_MyMachine_Look.items`)
* 칸 = 이름 · 규격 · 단위 · 값 · 쓰는 단가산출 수 · 같은 이름 수(같은 이름 묶음 = 이름 차례로 붙어 보임)
* 줄 누름 = 아래 쓰는 단가산출 목록 → 누르면 그 단가산출로(고치기는 거기서 · 여기선 저장 없음)
* ========================================================================== */
import { createDataTable } from "@ui/ui_template_data_table";
import { createInputField, el } from "@ui/ui_template_elements";
import { createFindList } from "@ui/ui_template_find_list";
import { createText } from "@ui/ui_template_text";
import { listMyItems, type MyItem, type Use } from "./M02_MyMachine_Api";
import { mt } from "./M02_MyMachine_Text";
export interface MyItemsViewOptions {
owner: string | null;
/** 그 단가산출 열기(떠남 확인은 부르는 쪽) */
openSk: (use: Use) => void;
}
export function buildMyItemsView(opt: MyItemsViewOptions): HTMLElement {
let rows: MyItem[] = [];
let shown: MyItem[] = [];
const search = createInputField({ type: "search", placeholder: mt("Find") });
const hint = createText("");
const fail = el("div");
const uses = createFindList({ placeholder: mt("Open_Sk") });
uses.root.hidden = true;
const table = createDataTable({
caption: mt("Items_Title"),
columns: [
{ key: "이름", label: mt("Name"), width: 200 },
{ key: "규격", label: mt("Spec"), width: 140 },
{ key: "단위", label: mt("Unit"), width: 60 },
{ key: "값", label: mt("Value"), kind: "number", decimals: 2, trim: true, width: 100 },
{ key: "쓰는곳", label: mt("Uses_Count"), kind: "number", decimals: 0, width: 70 },
{ key: "같은이름", label: mt("Same_Name"), kind: "number", decimals: 0, width: 70 },
],
rows: [],
empty: mt("Items_None"),
mergeSame: ["이름"],
onRowClick: (i) => pick(shown[i]),
});
const pick = (item: MyItem | undefined): void => {
if (!item) return;
uses.root.hidden = false;
uses.hint.textContent = `${item.이름} ${item.규격}`.trim();
uses.setRows(
item.쓰는곳.map((u) => ({
key: u.키,
name: u.이름 || u.키,
sub: u.키,
onPick: () => opt.openSk(u),
})),
);
};
const draw = (): void => {
const words = search.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
const count = new Map<string, number>();
for (const r of rows) count.set(r.이름, (count.get(r.이름) ?? 0) + 1);
shown = rows.filter((r) => words.every((w) => `${r.이름} ${r.규격}`.toLowerCase().includes(w)));
hint.textContent = rows.length ? `${shown.length} / ${rows.length}` : "";
table.setRows(
shown.map((r) => ({
이름: r.이름,
규격: r.규격,
단위: r.단위,
값: typeof r.값 === "number" ? r.값 : Number(r.값) || null,
쓰는곳: r.쓰는곳.length,
같은이름: count.get(r.이름) ?? 1,
})),
);
};
search.input.addEventListener("input", draw);
void listMyItems(opt.owner)
.then((got) => (rows = got))
.catch((error) =>
fail.replaceChildren(
createText(error instanceof Error ? error.message : mt("Personal_Only"), "error"),
),
)
.finally(draw);
return el("div", {
className: "m02-mymach-view",
attrs: { "data-my-items": "" },
children: [
el("div", { className: "ui-toolbar", children: [search.root, hint] }),
createText(mt("Items_Note"), "muted"),
fail,
table.root,
uses.root,
],
});
}
+16
View File
@@ -0,0 +1,16 @@
/* M02 내 기계 · 내 품목 화면(PLAN 821-3) — 차림만 · 색 · 크기는 공통 값 */
.m02-mymach,
.m02-mymach-view,
.m02-mymach-view__list {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m02-mymach-view {
padding: var(--spacing-8);
}
.m02-mymach__uses ul {
margin: var(--spacing-4) 0 0;
padding-left: var(--spacing-16);
}
+363
View File
@@ -0,0 +1,363 @@
"""M02 내 기계(PLAN 821-1 · 요구 795 · 요구서_796 3 · 7 장) — 개인 층 기계 줄 목록 · 서버 단독.
저장 = 층 뿌리 `내기계.json` {줄: [줄], 다음: 수} — 줄 = M01 기계 줄 꼴
{키 `EQP{사용자}-####`, 원문번호 「개인 {키}」, 원문 · 구분 · 출처 = 개인, 상세구분 · 이름, 규격,
단위, 값, 비고, 방식 바로|품셈|직접, 시간당?(직접 {노무비, 재료비, 경비}), 넣은날, 고친날, 본뜬?}
값 칸 이름 = M01 기계 줄과 같음 · 바로 = 손료계수 한 칸 · 품셈 = 비율 다섯 → 계수 넷(저장 때 서버가
계수 짝 `master_machine_coef` 로 다시 셈) · 직접 = 시간당 값만.
셈 색인 `whole()` = 지금 층(`store.root()`) 줄을 M01 위에 얹은 `skc.Copy`(키 앞 두 글자 EQ ·
원문번호로도 찾음) — 바로 = 상각비계수 ← 손료계수 · 정비 · 관리 0(식은 M01 기계와 같은 GC 식) ·
내 기계 없는 층 = M01 그대로. 단가산출 사본 · [단가 갱신] · 「단가새값」(`M02_Sangeun_Copy`) ·
기계 찾기(`M02_Machine_Find`) · 시간당(`M02_Machine_Hourly`) · 사본 모으기(`M02_Item_Prices.collect`)가
이 색인을 읽음 — 층을 모르는 길(`/api/m02/logic-lib/machine-hourly` 등)은 M01 만.
식 고름(채운 칸) = 연료 · 조종원 둘 다 GC000268 · 연료만 GC000272 · 조종원만 GC000276 · 없음 GC000273.
전력 칸은 담기만(M01 기계경비 식에 전력 줄 없음).
권한(본인 개인 층 · 맡은 회사 관리자)은 길(`M02_MyMachine_Router`) 몫.
"""
from __future__ import annotations
import json
import re
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Store as store
FILE = "내기계.json"
PREFIX, NO = "EQP", "개인 "
FIND = re.compile(rf"{NO}({PREFIX}\d+-\d+)") # 문서 안 기계 입력 「개인 EQP…」
EASY, BOOK, DIRECT = "바로", "품셈", "직접"
COSTS = ("노무비", "재료비", "경비")
RATIOS = ("내용시간", "연간표준가동시간", "상각비율", "정비비율", "연간관리비율")
COEFS = ("상각비계수", "정비비계수", "관리비계수")
NUMS = ("가격", *RATIOS, *COEFS, "손료계수", "주연료", "잡재료율", "조종원", "전력")
UNITS = {
"가격": "천원", "내용시간": "시간", "연간표준가동시간": "시간", "상각비율": "", "정비비율": "",
"연간관리비율": "", **{k: "10^-7/시간" for k in (*COEFS, "손료계수")}, "주연료": "ℓ/hr",
"주연료종류": "", "잡재료율": "주연료의 %", "조종원": "인/일", "조종원직종": "", "전력": "kWh/hr",
} # fmt: skip
LOGIC = {(True, True): "GC000268", (True, False): "GC000272",
(False, True): "GC000276", (False, False): "GC000273"} # fmt: skip
def mine(key: Any) -> bool:
return str(key or "").startswith(PREFIX)
def _now() -> str:
return datetime.now().isoformat(timespec="seconds")
def _bad(text: str, status: int = 400) -> store.StoreError:
return store.StoreError(status, text)
# ── 저장 ──────────────────────────────────────────────────────────────
def _path(folder: Path | None = None) -> Path:
return Path(folder or store.root()) / FILE
def _read(folder: Path | None = None) -> dict[str, Any]:
try:
got = json.loads(_path(folder).read_bytes())
except (OSError, ValueError):
got = {}
rows = [r for r in got.get("줄") or [] if isinstance(r, dict) and mine(r.get("키"))]
return {"줄": rows, "다음": int(got.get("다음") or 1)}
def rows(folder: Path | None = None) -> list[dict]:
"""지금 층 내 기계 줄(저장 꼴)."""
return _read(folder)["줄"]
# ── 셈 색인 ──────────────────────────────────────────────────────────
def engine(row: dict) -> dict:
"""셈 꼴 — 바로 = 상각비계수 ← 손료계수 · 정비 · 관리 0 · 빈 연료 · 조종원 칸 = 0 · 수 = Decimal."""
vals = dict(row.get("값") or {})
if row.get("방식") == EASY:
vals |= {"상각비계수": vals.get("손료계수") or 0, "정비비계수": 0, "관리비계수": 0}
if row.get("방식") != DIRECT:
vals = {"주연료": 0, "잡재료율": 0, "조종원": 0, "주연료종류": "", "조종원직종": ""} | vals
raw = json.dumps({**row, "값": vals}, ensure_ascii=False)
return json.loads(raw, parse_float=Decimal, parse_int=Decimal)
_MEMO: dict[str, tuple] = {}
def whole(base: Any = None) -> Any:
"""M01 색인 + 지금 층 내 기계 줄 — 파일 · M01 이 그대로면 같은 객체(셈 메모가 붙음)."""
from M02_MasterTemplete import M02_Sangeun_Copy as skc
base = m01.loaded()[1] if base is None else base
path = _path()
try:
st = path.stat()
except OSError:
return base
sig = (st.st_mtime_ns, st.st_size)
got = _MEMO.get(str(path))
if got and got[0] == sig and got[1] is base:
return got[2]
own = [engine(r) for r in rows(path.parent)]
out = skc.Copy(base, own, ("내기계", sig)) if own else base
if len(_MEMO) > 16: # ponytail: 통째 비움 · 층이 많아지면 LRU 로
_MEMO.clear()
_MEMO[str(path)] = (sig, base, out)
return out
def added(index: Any) -> list[dict]:
"""`whole()` 이 얹은 내 기계 셈 줄(사본 모으기 `collect` 가 마스터 밖 줄로 담음)."""
return [r for k, r in (getattr(index, "index", {}).get("EQ") or {}).items() if mine(k)]
def keys_in(doc: Any) -> list[str]:
"""문서 안 기계 입력 「개인 EQP…」 → 내 기계 키(줄 쌓기 `로직(GC…, 기계='개인 …')` · 사본 찍기)."""
return sorted(set(FIND.findall(json.dumps(doc, ensure_ascii=False, default=str))))
def logic_key(row: dict) -> str:
vals = row.get("값") or {}
return LOGIC[(float(vals.get("주연료") or 0) > 0, float(vals.get("조종원") or 0) > 0)]
def logic_for(code: str) -> dict | None:
"""내 기계 원문번호 → 시간당 경비 식(채운 칸으로 고름) · 내 기계 아님 = None."""
index = whole()
row = (index.numbers.get("EQ") or {}).get(code)
if row is None or not mine(row.get("키")) or row.get("방식") == DIRECT:
return None
return index.logic(logic_key(row))
def hourly(row: dict, index: Any) -> dict[str, Any]:
"""`M02_Machine_Hourly.hourly` 꼴 — 직접 = 그 값 · 아니면 고른 GC 식(입력 기본값 · 기계 = 원문번호)."""
head = {"이름": row.get("이름"), "규격": row.get("규격"), "원문번호": row.get("원문번호")}
if row.get("방식") == DIRECT:
sums = {k: float((row.get("시간당") or {}).get(k) or 0) for k in COSTS}
return {"ok": True, **head, "로직": None, "직접": True, **sums, "계": sum(sums.values())}
import master_cut as mcut
mf = m01.cm.mf
logic = index.logic(logic_key(row))
given = {i["이름"]: (i.get("고르기") or [""])[0] for i in logic.get("입력") or []}
given["기계"] = str(row.get("원문번호"))
try:
got = mcut.engine(
index, logic["키"], mf.run(index, logic["키"], mf.fill_empty(logic, given))
)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return {"ok": False, "reason": str(e) or type(e).__name__}
sums = {k: float(got[k]) for k in (*COSTS, "계")}
return {"ok": True, **head, "로직": logic["키"], **sums}
# ── 검사 ──────────────────────────────────────────────────────────────
def _num(value: Any, name: str) -> float | None:
if value in (None, ""):
return None
try:
got = float(value)
except (TypeError, ValueError):
raise _bad(f"「{name}」 은 수") from None
if got < 0 or got != got:
raise _bad(f"「{name}」 은 0 이상")
return int(got) if got == int(got) else got
def _coef(vals: dict) -> dict:
"""품셈 방식 — 비율 다섯 → 계수 넷(계수 짝 `master_machine_coef` · 821-2)."""
import master_machine_coef as mmc
got = mmc.coef_of(vals)
if got is None:
raise _bad("품셈 방식 — 내용시간 · 연간표준가동시간은 0 보다 큼")
return got
def clean(raw: Any) -> dict[str, Any]:
"""받은 줄 → 저장 꼴 몸(키 · 날 빼고) — 틀리면 400."""
if not isinstance(raw, dict):
raise _bad("내 기계 줄이 묶음 아님")
name, spec = str(raw.get("이름") or "").strip(), str(raw.get("규격") or "").strip()
if not name:
raise _bad("이름 없음")
how = raw.get("방식") or EASY
if how not in (EASY, BOOK, DIRECT):
raise _bad("방식은 바로 · 품셈 · 직접")
given = raw.get("값") if isinstance(raw.get("값"), dict) else {}
out: dict[str, Any] = {"이름": name, "규격": spec, "방식": how}
if how == DIRECT:
hour = raw.get("시간당") if isinstance(raw.get("시간당"), dict) else {}
out["시간당"] = {k: _num(hour.get(k), k) or 0 for k in COSTS}
vals: dict[str, Any] = {}
else:
vals = {k: got for k in NUMS if (got := _num(given.get(k), k)) is not None}
need = ("가격", "손료계수") if how == EASY else ("가격", *RATIOS)
if lost := [k for k in need if not vals.get(k)]:
raise _bad(f"빈 칸 — {' · '.join(lost)}")
drop = (*RATIOS, *COEFS) if how == EASY else ()
vals = {k: v for k, v in vals.items() if k not in drop}
if how == BOOK:
vals |= _coef(vals)
vals |= _kinds(given, vals)
out["값"] = vals
out["단위"] = {k: UNITS[k] for k in vals if k in UNITS}
out["출처"] = {k: "개인" for k in vals}
if note := str(raw.get("비고") or "").strip():
out["비고"] = note
return out
def _kinds(given: dict, vals: dict) -> dict:
"""주연료종류 = M01 유가 이름 · 조종원직종 = M01 인력 원문번호(쓸 때만 · 고르기 목록 밖 = 400)."""
index, out = m01.loaded()[1], {}
fuel, job = str(given.get("주연료종류") or ""), str(given.get("조종원직종") or "")
if vals.get("주연료"):
if f"{fuel}:전국평균" not in (index.numbers.get("MO") or {}):
raise _bad(f"주연료종류 「{fuel}」 — 유가 목록에서 고름")
out["주연료종류"] = fuel
if vals.get("조종원"):
if job not in (index.numbers.get("LB") or {}):
raise _bad(f"조종원직종 「{job}」 — 인력 목록에서 고름")
out["조종원직종"] = job
return out
# ── 만들기 · 고치기 · 지우기 · 읽기 ─────────────────────────────────────
def _seed(key: str) -> dict:
"""「비슷한 기계에서 시작」 — M01 · 내 기계 줄 값 복사(품셈 비율 있으면 품셈 · 없으면 바로)."""
try:
src = whole().get(key)
except m01.cm.mf.FormulaError:
raise _bad(f"없는 기계 「{key}」", 404) from None
if not str(src.get("키")).startswith("EQ"):
raise _bad(f"「{key}」 는 기계 아님")
if mine(key):
got = next(r for r in rows() if r["키"] == key)
return {k: got[k] for k in ("이름", "규격", "방식", "값", "시간당", "비고") if k in got}
vals = json.loads(json.dumps(src.get("값") or {}, default=float))
how = BOOK if all(vals.get(k) for k in RATIOS) else EASY
return {"이름": src.get("이름"), "규격": src.get("규격"), "방식": how, "값": vals}
def _user() -> str:
"""개인 층(`storage/{회사}/{사용자}/templates`) 사용자 번호 — 그 밖 층 = 404."""
from M02_MasterTemplete import M02_Template_Layers as layers
here = store.root().resolve()
user, company = here.parent.name, here.parent.parent.name
if not (user.isdigit() and company.isdigit()) or here != layers.personal_dir(company, user):
raise _bad("내 기계는 개인 층만", 404)
return user
def create(raw: Any = None, base: str | None = None) -> dict[str, Any]:
_user()
seed = _seed(base) if base else {}
raw = raw if isinstance(raw, dict) else {}
body = clean({**seed, **raw, "값": {**seed.get("값", {}), **(raw.get("값") or {})}})
data = _read()
key = f"{PREFIX}{_user()}-{data['다음']:04d}"
when = _now()
row = {"키": key, "원문번호": f"{NO}{key}", "원문": "개인", "구분": "개인", "상세구분": body["이름"],
**body, "넣은날": when, "고친날": when, **({"본뜬": base} if base else {})} # fmt: skip
atomic_write_json(_path(), {"줄": [*data["줄"], row], "다음": data["다음"] + 1})
return answer(row)
def update(key: str, raw: Any) -> dict[str, Any]:
_user()
data = _read()
old = next((r for r in data["줄"] if r["키"] == key), None)
if old is None:
raise _bad(f"없는 내 기계 「{key}」", 404)
body = clean(raw)
keep = {k: old[k] for k in ("키", "원문번호", "원문", "구분", "넣은날", "본뜬") if k in old}
row = {**keep, "상세구분": body["이름"], **body, "고친날": _now()}
atomic_write_json(_path(), {**data, "줄": [row if r["키"] == key else r for r in data["줄"]]})
return answer(row)
def delete(key: str) -> dict[str, Any]:
"""지움 — 이미 만든 단가산출은 사본의 마지막 값 그대로(「지운키」)."""
_user()
data = _read()
if not any(r["키"] == key for r in data["줄"]):
raise _bad(f"없는 내 기계 「{key}」", 404)
used = uses().get(key, [])
atomic_write_json(_path(), {**data, "줄": [r for r in data["줄"] if r["키"] != key]})
return {"지움": key, "쓰는곳": used}
def uses() -> dict[str, list[dict]]:
"""{내 기계 키: [{키, 이름}]} — 이 층 단가산출 가운데 사본 단가 줄에 그 키를 가진 것."""
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
out: dict[str, list[dict]] = {}
for path in sorted((store.root() / sd.KIND).glob("SK-*.json")):
raw = path.read_bytes()
if PREFIX.encode() not in raw:
continue
try:
doc = json.loads(raw)
except ValueError:
continue
for row in (doc.get(skc.KEY) or {}).get("단가") or []:
if mine(row.get("키")):
out.setdefault(str(row["키"]), []).append(
{"키": doc.get("키"), "이름": doc.get("이름")}
)
return out
def same(name: Any, spec: Any, skip: str = "") -> list[dict]:
"""같은 이름 · 규격 알림 — M01 기계 · 이 층 내 기계(표기 정규화 `M02_Machine_Find.norm`)."""
from M02_MasterTemplete.M02_Machine_Find import norm
want = (norm(name), norm(spec))
return [
{
"키": r["키"],
"이름": r.get("이름"),
"규격": r.get("규격"),
"출처": "개인" if mine(r["키"]) else "M01",
} # fmt: skip
for r in (whole().index.get("EQ") or {}).values()
if r["키"] != skip and (norm(r.get("이름")), norm(r.get("규격"))) == want
]
def answer(row: dict, used: list | None = None) -> dict[str, Any]:
out = {
"줄": row,
"시간당": hourly(engine(row), whole()),
"같은이름": same(row["이름"], row["규격"], row["키"]),
}
return out | ({"쓰는곳": used} if used is not None else {})
def one(key: str) -> dict[str, Any]:
got = next((r for r in rows() if r["키"] == key), None)
if got is None:
raise _bad(f"없는 내 기계 「{key}」", 404)
return answer(got, uses().get(key, []))
def listing() -> dict[str, Any]:
"""목록 — 줄마다 시간당 · 쓰는곳수(넣은 차례)."""
index, used = whole(), uses()
return {
"줄": [
{**r, "시간당": hourly(engine(r), index), "쓰는곳수": len(used.get(r["키"], []))}
for r in rows()
]
}
+156
View File
@@ -0,0 +1,156 @@
/* =============================================================================
* M02_MyMachine_Api.ts
* M02 내 기계 · 내 품목 서버 호출(PLAN 821-3) — 개인 층만.
* 내 기계 = `/layers/personal/my-machines`(821-1 · `M02_MyMachine_Router`) — 목록 · 하나 · 만들기 · 고치기 · 지우기
* 양식 목록 · 비슷한 기계 찾기 · 내 품목 = `M02_MyMachine_Look`(읽기만)
* ========================================================================== */
import type { MachinePick, MachineValues } from "@ui/ui_template_machine_form";
import { call } from "./M02_MasterTemplete_Api_Fetch";
import type { M01Element } from "./M02_MasterTemplete_Find_Pick";
export type MachineHow = "바로" | "품셈" | "직접";
type Costs = Record<"노무비" | "재료비" | "경비", number>;
/** 서버 시간당 셈 답 — ok 아님 = 까닭 */
export interface MyHourly extends Partial<Costs> {
ok: boolean;
계?: number;
로직?: string | null;
직접?: boolean;
reason?: string;
}
/** 내 기계 줄(저장 꼴) */
export interface MyMachine {
키: string;
원문번호: string;
이름: string;
규격: string;
방식: MachineHow;
값: MachineValues;
시간당?: Costs;
비고?: string;
넣은날: string;
고친날: string;
본뜬?: string;
}
export interface MyMachineRow extends Omit<MyMachine, "시간당"> {
시간당: MyHourly;
쓰는곳수: number;
}
export interface Use {
키: string;
이름: string;
}
export interface MyMachineOne {
줄: MyMachine;
시간당: MyHourly;
같은이름: { 키: string; 이름: string; 규격: string; 출처: string }[];
쓰는곳?: Use[];
}
/** 보내는 줄 — 방식 · 값 또는 시간당(직접) */
export interface MyMachineBody {
이름: string;
규격: string;
방식: MachineHow;
값?: MachineValues;
시간당?: Partial<Costs>;
}
export interface MachineLists {
fuels: MachinePick[];
jobs: MachinePick[];
power: number;
hour: number;
}
export interface SimilarRow {
키: string;
원문번호?: string;
이름: string;
규격: string;
구분: string;
값?: MachineValues;
방식?: MachineHow;
시간당?: Costs;
출처: "개인" | "M01";
}
export interface MyItem {
이름: string;
규격: string;
단위: string;
값: unknown;
쓰는곳: Use[];
}
const at = (owner: string | null, tail = "", q: Record<string, string> = {}): string => {
const ask = new URLSearchParams({ ...q, ...(owner ? { owner } : {}) }).toString();
return `/layers/personal/my-machines${tail}${ask ? `?${ask}` : ""}`;
};
const look = (owner: string | null, path: string, q: Record<string, string> = {}): string => {
const ask = new URLSearchParams({ ...q, ...(owner ? { owner } : {}) }).toString();
return `/layers/personal/${path}${ask ? `?${ask}` : ""}`;
};
const key = (k: string): string => `/${encodeURIComponent(k)}`;
export const listMyMachines = async (owner: string | null): Promise<MyMachineRow[]> =>
(await call<{ 줄: MyMachineRow[] }>(at(owner))).줄;
export const readMyMachine = (owner: string | null, k: string): Promise<MyMachineOne> =>
call(at(owner, key(k)));
export const createMyMachine = (
owner: string | null,
body: MyMachineBody,
base?: string,
): Promise<MyMachineOne> =>
call(at(owner), { method: "POST", body: JSON.stringify({ 줄: body, 본뜬: base ?? null }) });
export const updateMyMachine = (
owner: string | null,
k: string,
body: MyMachineBody,
): Promise<MyMachineOne> =>
call(at(owner, key(k)), { method: "PUT", body: JSON.stringify({ 줄: body }) });
export const deleteMyMachine = (owner: string | null, k: string): Promise<{ 쓰는곳: Use[] }> =>
call(at(owner, key(k)), { method: "DELETE" });
let lists: Promise<MachineLists> | null = null;
/** 양식 고르기 목록 — 한 번 받아 둠 */
export const fetchMyMachineLists = (): Promise<MachineLists> =>
(lists ??= call<MachineLists>("/my-machine-lists").catch((e) => {
lists = null;
return Promise.reject(e);
}));
export const findSimilar = async (owner: string | null, q: string): Promise<SimilarRow[]> =>
(await call<{ rows: SimilarRow[] }>(look(owner, "my-machine-find", { q }))).rows;
export const listMyItems = async (owner: string | null): Promise<MyItem[]> =>
(await call<{ rows: MyItem[] }>(look(owner, "my-items"))).rows;
/** 단가산출 줄 기계 입력 글(줄 쌓기 `기계='개인 EQP…'`) */
export const machineArg = (no: string): string => `'${no}'`;
/** 내 기계 → 기계 고르기 한 줄(`M01Element` 꼴 · 줄 쌓기 = 로직 + 기계='개인 EQP…') */
export const mineElement = (
row: Pick<MyMachine, "키" | "원문번호" | "이름" | "규격">,
h: MyHourly,
): M01Element => ({
종류: "기계",
코드: row.키,
이름: row.이름,
규격: row.규격,
단위: "시간",
값: h.ok ? (h.계 ?? null) : null,
단가: { 노무비: h.노무비 ?? 0, 재료비: h.재료비 ?? 0, 경비: h.경비 ?? 0 },
로직: h.로직 ?? undefined,
원문번호: row.원문번호,
});
+296
View File
@@ -0,0 +1,296 @@
/* =============================================================================
* M02_MyMachine_Form.ts
* M02 내 기계 양식 한 장(PLAN 821-3 · 요구서 796 1 · 3 장) — 기계 양식 부품(`ui_template_machine_form` · 821-2) +
* [저장] · [지우기] · 「개인 값」 표지 · 같은 이름 · 규격 알림 · 서버 정본 시간당 · 쓰는 곳.
* 기본 = 손료계수 「바로 넣기」 · 「시간당 값 직접」 꼴 · 직종 · 연료 = M01 목록 고르기 · 옆 계산식 즉시(계수 짝 TS)
* [저장] 때만 서버(자동저장 없음) — 서버가 정본으로 다시 셈한 시간당을 받아 보임
* 새 기계 = 「비슷한 기계에서 시작」(M01 · 내 기계 · 고른 키 = 서버 `본뜬`) 또는 빈 양식 · `seed` = 품셈 기계 복사
* 지우기 = 쓰는 단가산출 이름 보이고 확인 + 「이미 만든 단가산출은 마지막 값 유지」
* 부르는 곳 = 내 기계 화면(`M02_MyMachine_View`) · 기계 고르기 창 「새 기계 만들기」 · 줄 쌓기 「복사해 내 기계로」(창 `openMyMachineModal`)
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import {
createMachineForm,
HOURLY_KEY,
type MachineSimilar,
type MachineValues,
} from "@ui/ui_template_machine_form";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import {
createMyMachine,
deleteMyMachine,
fetchMyMachineLists,
findSimilar,
readMyMachine,
updateMyMachine,
type MyHourly,
type MyMachine,
type MyMachineBody,
type MyMachineOne,
type SimilarRow,
} from "./M02_MyMachine_Api";
import { mt } from "./M02_MyMachine_Text";
import "./M02_MyMachine.css";
export interface MyMachineFormOptions {
owner: string | null;
/** 고칠 내 기계 키 — 없으면 새 기계 */
key?: string;
/** 새 기계 바탕(복사해 내 기계로) — 값 · 이름 · 규격 · 본뜬 키 */
seed?: SimilarRow;
/** 새 기계면 「비슷한 기계에서 시작」 을 펼침 */
openSimilar?: boolean;
onSaved: (got: MyMachineOne) => void;
onDeleted?: () => void;
onBack?: () => void;
onDirty?: (dirty: boolean) => void;
}
export interface MyMachineFormHandle {
root: HTMLElement;
dirty: () => boolean;
}
const won = (v: number | undefined): string =>
`${(v ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 2 })}${mt("Won")}`;
/** 줄(저장 꼴) → 양식 값 묶음 — 직접 = 시간당 칸 하나 */
export function valuesOf(row: Pick<MyMachine, "방식" | "값" | "시간당">): MachineValues {
return row.방식 === "직접" ? { [HOURLY_KEY]: { ...(row.시간당 ?? {}) } } : { ...row.값 };
}
/** 서버 시간당 한 줄 글 */
export function hourlyText(h: MyHourly | undefined): string {
if (!h?.ok) return `${mt("Hourly_None")}${h?.reason ? ` — ${h.reason}` : ""}`;
return `${won(h.계)} (${mt("Labor")} ${won(h.노무비)} · ${mt("Material")} ${won(h.재료비)} · ${mt("Expense")} ${won(h.경비)})`;
}
export async function mountMyMachineForm(
host: HTMLElement,
opt: MyMachineFormOptions,
): Promise<MyMachineFormHandle> {
const [lists, one] = await Promise.all([
fetchMyMachineLists(),
opt.key ? readMyMachine(opt.owner, opt.key) : Promise.resolve(null),
]);
const row = one?.줄;
const seed = opt.seed;
let base = seed && seed.출처 === "M01" ? seed.키 : undefined; // 서버 `본뜬`
const found = new Map<string, SimilarRow>();
const notice = el("div", { className: "m02-mymach__notice" });
const server = el("div", { className: "m02-mymach__server" });
const usesBox = el("div", { className: "m02-mymach__uses" });
const form = createMachineForm({
name: row?.이름 ?? seed?.이름 ?? "",
spec: row?.규격 ?? seed?.규격 ?? "",
values: row
? valuesOf(row)
: seed
? valuesOf({ 방식: seed.방식 ?? "바로", 값: seed.값 ?? {}, 시간당: seed.시간당 })
: {},
coefMode: "direct",
allowHourly: true,
fuels: lists.fuels,
jobs: lists.jobs,
power: lists.power,
hour: lists.hour,
findSimilar: row
? undefined
: async (q): Promise<MachineSimilar[]> =>
(await findSimilar(opt.owner, q)).map((r) => {
found.set(r.키, r);
return {
key: r.키,
name: r.이름,
spec: r.규격,
tag: r.출처 === "개인" ? mt("Mine") : r.구분,
values: valuesOf({ 방식: r.방식 ?? "바로", 값: r.값 ?? {}, 시간당: r.시간당 }),
};
}),
onSimilar: (item) => {
base = item.key;
checkSame();
},
onChange: () => {
notice.replaceChildren(); // 지난 저장 막힘 글 지움(다시 [저장] 때 새로)
opt.onDirty?.(dirty());
checkSame();
},
});
let saved = !opt.key && seed ? false : true;
const dirty = (): boolean => form.state().changed || !saved;
/* --- 같은 이름 · 규격 알림(M01 · 내 기계) --- */
let timer: number | undefined;
let seq = 0;
const sameText = (hits: { 키: string; 출처: string }[]): string =>
hits.length
? mt("Same").replace("{value}", hits.map((h) => `${h.출처} ${h.키}`).join(" · "))
: "";
const checkSame = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(async () => {
const mine = ++seq;
const s = form.state();
const rows = s.name ? await findSimilar(opt.owner, s.name).catch(() => []) : [];
if (mine !== seq) return;
const hits = rows.filter(
(r) => r.키 !== opt.key && r.이름.trim() === s.name && (r.규격 ?? "").trim() === s.spec,
);
form.setNotice(sameText(hits));
}, 300);
};
/* --- 저장 --- */
const bodyOf = (): MyMachineBody | null => {
const s = form.state();
const errors = [...s.errors, ...(s.name ? [] : [mt("Name_Needed")])];
notice.replaceChildren(...errors.map((e) => createText(e, "error")));
if (errors.length) return null;
if (s.hourly)
return {
이름: s.name,
규격: s.spec,
방식: "직접",
시간당: (s.values[HOURLY_KEY] ?? {}) as MyMachineBody["시간당"],
};
const { [HOURLY_KEY]: _drop, ...vals } = s.values;
return { 이름: s.name, 규격: s.spec, 방식: s.mode === "pumsem" ? "품셈" : "바로", 값: vals };
};
const saveBtn = createButton({ label: mt("Save"), variant: "filled", size: 28 });
saveBtn.dataset.mymachSave = "";
saveBtn.addEventListener("click", async () => {
const body = bodyOf();
if (!body) return;
saveBtn.disabled = true;
try {
const got = opt.key
? await updateMyMachine(opt.owner, opt.key, body)
: await createMyMachine(opt.owner, body, base);
saved = true;
opt.onDirty?.(false);
showToast(mt("Saved"), "success");
opt.onSaved(got);
} catch (error) {
notice.replaceChildren(
createText(error instanceof Error ? error.message : mt("Failed"), "error"),
);
} finally {
saveBtn.disabled = false;
}
});
/* --- 지우기 --- */
const delBtn = createButton({ label: mt("Delete"), variant: "danger-ghost", size: 28 });
delBtn.hidden = !opt.key;
delBtn.addEventListener("click", async () => {
if (!opt.key) return;
const uses = (await readMyMachine(opt.owner, opt.key).catch(() => one))?.쓰는곳 ?? [];
const ask = [
mt("Delete_Ask").replace("{value}", String(uses.length)),
...uses.map((u) => `${u.키} ${u.이름}`),
mt("Delete_Keep"),
].join("\n");
if (!(await showConfirmDialog(ask, mt("Delete")))) return;
try {
await deleteMyMachine(opt.owner, opt.key);
saved = true;
opt.onDirty?.(false);
showToast(mt("Deleted"), "success");
opt.onDeleted?.();
} catch (error) {
showToast(error instanceof Error ? error.message : mt("Failed"), "error");
}
});
const back = opt.onBack
? [
createButton({
label: mt("Back"),
variant: "ghost",
size: 28,
iconStart: "chevronLeft",
onClick: opt.onBack,
}),
]
: [];
const title = row ? `${row.이름} ${row.규격}`.trim() : mt("New");
server.replaceChildren(
...(one ? [createText(`${mt("Server_Hourly")} ${hourlyText(one.시간당)}`)] : []),
);
if (one?.같은이름.length) form.setNotice(sameText(one.같은이름));
const uses = one?.쓰는곳 ?? [];
usesBox.replaceChildren(
el("strong", { text: `${mt("Uses")} ${uses.length}` }),
...(uses.length
? [el("ul", { children: uses.map((u) => el("li", { text: `${u.키} ${u.이름}` })) })]
: [createText(mt("No_Uses"), "muted")]),
);
const root = el("div", {
className: "m02-mymach",
attrs: { "data-mymach-form": opt.key ?? "new" },
children: [
el("div", {
className: "ui-toolbar m02-mymach__bar",
children: [
...back,
el("strong", { text: title }),
el("span", { className: "ui-text ui-text--muted", text: opt.key ?? "" }),
createBadge(mt("Own_Value"), "info"),
saveBtn,
delBtn,
],
}),
createText(mt("Own_Note"), "muted"),
notice,
server,
form.root,
...(opt.key ? [usesBox] : []),
],
});
host.replaceChildren(root);
if (!row && (opt.openSimilar ?? !seed))
root.querySelector<HTMLDetailsElement>("details")?.setAttribute("open", "");
if (seed) {
form.setNotice(mt("Copied").replace("{value}", `${seed.이름} ${seed.규격}`.trim()));
opt.onDirty?.(true);
}
return { root, dirty };
}
/** 창으로 띄움 — 저장하면 닫고 `done(답)` · 저장 안 하고 닫으면 물음 */
export function openMyMachineModal(
owner: string | null,
done: (got: MyMachineOne) => void,
seed?: SimilarRow,
): void {
let handle: MyMachineFormHandle | null = null;
const { close } = openModal({
title: seed ? mt("Copy_Title") : mt("New"),
closeLabel: mt("Close"),
width: "1100px",
wide: true,
beforeClose: async () => !handle?.dirty() || showConfirmDialog(mt("Leave_Ask")),
mount: (body) => {
void mountMyMachineForm(body, {
owner,
seed,
onSaved: (got) => {
close();
done(got);
},
})
.then((h) => (handle = h))
.catch((error) => {
showToast(error instanceof Error ? error.message : mt("Failed"), "error");
close();
});
},
});
}
+127
View File
@@ -0,0 +1,127 @@
"""M02 내 기계 화면 읽기 길(PLAN 821-3) — 양식 목록 · 비슷한 기계 찾기 · 내 품목 · 쓰기 없음.
GET `/api/m02/my-machine-lists` = 양식 고르기(연료 · 직종 · 전력 단가 · 시간환산) —
M01 기계 화면과 같은 값(`M01_MasterData_Machine.lists` · 그 길은 시스템 관리자만).
GET `/api/m02/layers/{층}/my-machine-find?q=&owner=` = 「비슷한 기계에서 시작」 ·
「복사해 내 기계로」 — 그 층 내 기계(앞) + M01 기계(`M01_MasterData_Machine.find`) ·
q 가 M01 원문번호와 같으면 그 기계 먼저.
GET `/api/m02/layers/{층}/my-items?owner=` = 내 품목 — 그 층 단가산출 수식 문서 재료비 줄 가운데
단가가 우리 자재에 안 이어진 것(단가 변수 연결 = 숫자 · 입력) ·
(이름 · 규격 · 단위 · 값) 묶음마다 쓰는 단가산출.
권한 = 내 기계 길과 같음(`M02_MyMachine_Router._folder` — 개인 층 · 본인 · 맡은 회사 관리자 읽기).
⚠ 등록은 `main.py`(로그인만).
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Depends
from common_util.common_util_auth import verify_session
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Session, _call
from M02_MasterTemplete.M02_MyMachine_Router import _folder
router = APIRouter(prefix="/api/m02", tags=["M02 My Machine Look"])
DIRECT = ("숫자", "입력") # 단가 변수 연결 — 값을 줄에 바로 적은 것
def _m01m():
from M01_MasterData import M01_MasterData_Machine as m01m # M01 store 가 scripts 를 길에 둔 뒤
return m01m
def lists() -> dict[str, Any]:
got = _m01m().lists()
return {k: got[k] for k in ("fuels", "jobs", "power", "hour")}
def find(q: str, limit: int = 30) -> list[dict]:
"""내 기계(앞) + M01 기계 — 줄 {키, 원문번호, 이름, 규격, 구분, 값, 방식?, 시간당?, 출처}."""
words = [w for w in q.lower().split() if w]
keep = ("키", "원문번호", "이름", "규격", "값", "방식", "시간당")
out = [
{**{k: r[k] for k in keep if k in r}, "구분": "내 기계", "출처": "개인"}
for r in mym.rows()
if all(w in f"{r.get('이름')} {r.get('규격')} {r.get('키')}".lower() for w in words)
]
exact = (m01.loaded()[1].numbers.get("EQ") or {}).get(q.strip())
head = [exact] if exact is not None and not mym.mine(exact.get("키")) else []
m01_rows = [*head, *_m01m().find(q, limit)]
seen = set()
for r in m01_rows:
if r["키"] in seen:
continue
seen.add(r["키"])
brief = {k: r[k] for k in ("키", "원문번호", "이름", "규격", "값") if k in r}
out.append({**brief, "구분": r.get("상세구분") or r.get("구분") or "", "출처": "M01"})
return json.loads(json.dumps(out[:limit], default=_m01m()._plain)) # Decimal → 수(글 아님)
def _items_of(doc: dict) -> list[dict]:
"""수식 문서 재료비 줄 — 단가 = 바로 적은 값 변수(숫자 · 입력)."""
import master_doc as md
body = ((doc.get("수식") or {}).get("문서")) or {}
vars_ = {md.var_id(v): v for v in body.get("변수") or [] if isinstance(v, dict)}
out = []
for line in body.get("금액") or []:
if not isinstance(line, dict) or line.get("비목") != "재료비":
continue
var = vars_.get(str(line.get("단가") or "").strip())
link = (var or {}).get("연결") or {}
if link.get("갈래") not in DIRECT:
continue
out.append(
{
"이름": str(line.get("이름") or var.get("이름") or ""),
"규격": str(line.get("규격") or ""),
"단위": str(line.get("단위") or ""),
"값": link.get("값"),
}
)
return out
def items() -> list[dict]:
"""내 품목 — (이름 · 규격 · 단위 · 값) 묶음 · 쓰는곳 [{키, 이름}] · 이름 차례."""
from M02_MasterTemplete import M02_Sangeun_Doc as sd
groups: dict[tuple, dict] = {}
for path in sorted((store.root() / sd.KIND).glob("SK-*.json")):
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
for it in _items_of(doc):
got = groups.setdefault(
(it["이름"], it["규격"], it["단위"], json.dumps(it["값"])), {**it, "쓰는곳": []}
)
use = {"키": doc.get("키") or path.stem, "이름": doc.get("이름") or ""}
if use not in got["쓰는곳"]:
got["쓰는곳"].append(use)
return sorted(groups.values(), key=lambda g: (g["이름"], g["규격"], g["단위"]))
@router.get("/my-machine-lists")
def get_lists() -> dict:
return lists()
@router.get("/layers/{layer}/my-machine-find")
def get_find(
layer: str, q: str = "", owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
return {"rows": _call(_folder(session, layer, owner), find, q)}
@router.get("/layers/{layer}/my-items")
def get_items(
layer: str, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
return {"rows": _call(_folder(session, layer, owner), items)}
@@ -0,0 +1,73 @@
"""M02 내 기계 길(PLAN 821-1 · `M02_MyMachine`) — `/api/m02/layers/personal/my-machines`(?owner=).
GET 목록(줄마다 시간당 · 쓰는곳수) · GET `/{키}` 하나(+시간당 · 쓰는곳 · 같은이름) ·
POST 만들기 몸 {줄?, 본뜬?: M01 · 내 기계 키(「비슷한 기계에서 시작」 · 「M01 기계 복사해 만들기」)} ·
PUT `/{키}` 몸 {줄} · DELETE `/{키}`(답 = 쓰는곳 — 그 단가산출은 사본의 마지막 값 그대로).
권한 = 층 권한 한 벌(`Router_Items._root` — 본인 개인 층 · 맡은 회사 관리자) · 개인 층만(그 밖 404).
⚠ 등록은 `main.py`(로그인만).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Session, _call, _root
router = APIRouter(prefix="/api/m02", tags=["M02 My Machine"])
PATH = "/layers/{layer}/my-machines"
class MachineBody(BaseModel):
줄: dict[str, Any] | None = None
본뜬: str | None = None
def _folder(session: Session, layer: str, owner: str | None, write: bool = False):
if layer != "personal":
raise HTTPException(status_code=404, detail="내 기계는 개인 층만")
return _root(session, layer, owner, write=write)
@router.get(PATH)
def my_machines(layer: str, owner: str | None = None, session: Session = Depends(verify_session)):
return _call(_folder(session, layer, owner), mym.listing)
@router.get(PATH + "/{key}")
def my_machine(
layer: str, key: str, owner: str | None = None, session: Session = Depends(verify_session)
):
return _call(_folder(session, layer, owner), mym.one, key)
@router.post(PATH)
def my_machine_create(
layer: str,
body: MachineBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
return _call(_folder(session, layer, owner, True), mym.create, body.줄, body.본뜬)
@router.put(PATH + "/{key}")
def my_machine_update(
layer: str,
key: str,
body: MachineBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
return _call(_folder(session, layer, owner, True), mym.update, key, body.줄)
@router.delete(PATH + "/{key}")
def my_machine_delete(
layer: str, key: str, owner: str | None = None, session: Session = Depends(verify_session)
):
return _call(_folder(session, layer, owner, True), mym.delete, key)
+67
View File
@@ -0,0 +1,67 @@
/* =============================================================================
* M02_MyMachine_Text.ts
* M02 내 기계 · 내 품목 화면 글(PLAN 821-3).
* ========================================================================== */
const TEXT = {
Title: "내 기계",
Items_Title: "내 품목",
New: "새 기계",
Copy_Title: "복사해 내 기계로",
Find: "이름 · 규격 · 키로 찾기",
Name: "이름",
Spec: "규격",
How: "방식",
Hourly: "시간당 경비(원)",
Uses_Count: "쓰는 곳",
Made: "넣은 날",
Edited: "고친 날",
None: "내 기계 없음 — [새 기계] 로 만듦",
Save: "저장",
Saved: "내 기계 저장 — 서버가 시간당을 다시 셈",
Delete: "지우기",
Deleted: "내 기계 지움",
Delete_Ask: "이 내 기계를 지울까? 쓰는 단가산출 {value}",
Delete_Keep: "지워도 이미 만든 단가산출은 마지막 값 유지(「지워짐」 표시)",
Back: "목록",
Close: "닫기",
Failed: "못 함",
Name_Needed: "이름 · 비어 있음",
Same: "같은 이름 · 규격 있음 — {value}",
Copied: "「{value}」 값을 복사 — 고친 뒤 [저장]",
Own_Value: "개인 값",
Own_Note:
"개인 값 = 이 기계 고유 값(가격 · 손료계수 · 연료 ℓ · 잡품 % · 조종원 수) · 단가(노임 · 유가)는 M01 값 · 단가산출엔 만들 때 복사 → 고친 뒤 「새 값」 → [단가 갱신]",
Server_Hourly: "서버 셈 시간당",
Hourly_None: "시간당 못 셈",
Uses: "쓰는 단가산출",
No_Uses: "쓰는 단가산출 없음",
Leave_Ask: "저장 안 한 고침을 버리고 닫을까?",
Mine: "내 기계",
Side_Hint: "펼치면 가운데에 목록 · 양식",
Items_Side_Hint: "펼치면 가운데에 모아 보기(읽기)",
Won: "원",
Labor: "노",
Material: "재",
Expense: "경",
바로: "바로 넣기",
품셈: "품셈 방식",
직접: "시간당 값 직접",
// 기계 고르기 창 · 줄 쌓기
Pick_Group: "내 기계",
Pick_New: "새 기계 만들기",
Pick_Direct: "시간당 값 직접 기계 — 줄 쌓기 식이 없어 못 넣음",
To_Mine: "복사해 내 기계로",
// 내 품목
Items_Note:
"단가산출 줄에 값을 바로 적은 재료(우리 자재에 안 이어진 것) — 고치기는 그 단가산출에서 · 줄 누르면 쓰는 단가산출",
Items_None: "내 품목 없음",
Value: "값(원)",
Unit: "단위",
Same_Name: "같은 이름",
Open_Sk: "쓰는 단가산출 — 누르면 그 단가산출로",
Personal_Only: "개인 층에서만 — 로그인 사용자 회사 · 개인 층이 없음",
} as const;
export type MtKey = keyof typeof TEXT;
export const mt = (key: MtKey): string => TEXT[key];
+127
View File
@@ -0,0 +1,127 @@
/* =============================================================================
* M02_MyMachine_View.ts
* M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「내 기계」 칸을 펼치면 가운데에 뜸(개인 층).
* 목록 = 이름 · 규격 · 방식 · 시간당 경비(서버 셈) · 쓰는 곳 수 · 넣은 날 · 고친 날 · 찾기 · [새 기계]
* 줄 누름 = 양식 한 장(`M02_MyMachine_Form`) · [목록] = 저장 안 한 고침이면 물음 · 저장 · 지우기 뒤 목록 다시
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { createDataTable } from "@ui/ui_template_data_table";
import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { createText } from "@ui/ui_template_text";
import { listMyMachines, type MyMachineRow } from "./M02_MyMachine_Api";
import { mountMyMachineForm } from "./M02_MyMachine_Form";
import { mt, type MtKey } from "./M02_MyMachine_Text";
import "./M02_MyMachine.css";
export interface MyMachineViewOptions {
owner: string | null;
/** 저장 안 한 고침 표시(페이지 떠남 물음) */
setDirty: (dirty: boolean) => void;
}
const day = (t: string | undefined): string => (t ?? "").slice(0, 10);
export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement {
const host = el("div", { className: "m02-mymach-view" });
let rows: MyMachineRow[] = [];
let shown: MyMachineRow[] = [];
let dirty = (): boolean => false;
const mark = (d: boolean): void => opt.setDirty(d);
const search = createInputField({ type: "search", placeholder: mt("Find") });
const hint = createText("");
const fail = el("div");
const table = createDataTable({
caption: mt("Title"),
columns: [
{ key: "이름", label: mt("Name"), width: 180 },
{ key: "규격", label: mt("Spec"), width: 120 },
{ key: "방식", label: mt("How"), width: 100 },
{ key: "시간당", label: mt("Hourly"), kind: "number", decimals: 0, width: 110 },
{ key: "쓰는곳", label: mt("Uses_Count"), kind: "number", decimals: 0, width: 70 },
{ key: "넣은날", label: mt("Made"), width: 96 },
{ key: "고친날", label: mt("Edited"), width: 96 },
],
rows: [],
empty: mt("None"),
onRowClick: (i) => shown[i] && void openForm(shown[i].키),
});
const draw = (): void => {
const words = search.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
shown = rows.filter((r) => {
const text = `${r.이름} ${r.규격} ${r.키}`.toLowerCase();
return words.every((w) => text.includes(w));
});
hint.textContent = rows.length ? `${shown.length} / ${rows.length}` : "";
table.setRows(
shown.map((r) => ({
이름: r.이름,
규격: r.규격,
방식: mt(r.방식 as MtKey),
시간당: r.시간당.ok ? (r.시간당.계 ?? null) : null,
쓰는곳: r.쓰는곳수,
넣은날: day(r.넣은날),
고친날: day(r.고친날),
})),
);
};
search.input.addEventListener("input", draw);
const newBtn = createButton({
label: mt("New"),
variant: "filled",
size: 28,
onClick: () => void openForm(),
});
newBtn.dataset.mymachNew = "";
const list = el("div", {
className: "m02-mymach-view__list",
children: [
el("div", { className: "ui-toolbar", children: [newBtn, search.root, hint] }),
createText(mt("Own_Note"), "muted"),
fail,
table.root,
],
});
async function showList(): Promise<void> {
dirty = () => false;
mark(false);
host.replaceChildren(list);
fail.replaceChildren();
try {
rows = await listMyMachines(opt.owner);
} catch (error) {
rows = [];
fail.replaceChildren(
createText(error instanceof Error ? error.message : mt("Personal_Only"), "error"),
);
}
draw();
}
async function openForm(key?: string): Promise<void> {
const slot = el("div");
host.replaceChildren(slot);
try {
const h = await mountMyMachineForm(slot, {
owner: opt.owner,
key,
onSaved: (got) => void openForm(got.줄.키),
onDeleted: () => void showList(),
onBack: async () => {
if (!dirty() || (await showConfirmDialog(mt("Leave_Ask")))) await showList();
},
onDirty: mark,
});
dirty = h.dirty;
} catch (error) {
showToast(error instanceof Error ? error.message : mt("Failed"), "error");
await showList();
}
}
void showList();
return host;
}
+1
View File
@@ -21,6 +21,7 @@ STM_SHEETS = (
"재료비목록표",
"경비목록표",
"중기목록표",
"중기사용료", # 821-4 기계 구성(못 이은 기계 → 내 기계 값)
)
EST_SHEETS = (
"단가산출총괄표",
+3
View File
@@ -444,5 +444,8 @@ def read(data: bytes) -> dict[str, Any]:
blk["줄합"] = head_sum(lines)
out[key].append({**blk, "줄": lines, "표지": tags})
out["자원"] = {f"{LINK[k[0]]}:{k[1]}": v for k, v in res.items()}
from M02_MasterTemplete.M02_San_MyMachine import est_makes
out["기계구성"] = est_makes(t) # 821-4 못 이은 기계 → 내 기계 값
out["표지"] = sorted(warn) + ["옵션(datestoption.opt) · 원가 서식(wongadat) 안 읽음"]
return out
+312
View File
@@ -0,0 +1,312 @@
"""불러오기 못 이은 기계 → 내 기계 자동 생성(PLAN 821-4 · 요구서_796 4 장 · 방향 장부 Q1 · S) — 서버 단독.
구성 읽기(그 파일 값 → 우리 틀 값 · 경쟁사 조종원 노임 환산 ×16/12 ×25/20 은 안 가져옴):
EST 원파일 MachMainDat(`est_makes` · 올릴 때 둘 꼴 칸 `기계구성`) · EST 엑셀 「기계경비」 ·
STmate 엑셀 「중기사용료」(`xlsx_makes` · 둔 산근 시트에서 그때 읽음) → {열쇠: 구성}
구성 = {이름, 규격, 손료 [[계수 10⁻⁷, 가격 원, 달러?]], 주연료종류, 주연료 ℓ, 잡재료율 %, 조종원 인,
조종원직종(글), 전력 kWh} · 열쇠 = 자원 코드 · 「이름|규격」(표기 정규화 `M02_Machine_Find.norm`).
고름(`plan`) = 가져온 로직 호표 기계 줄 가운데 이음 없음 · 찾기 판정 「못 찾음」(별칭 · 표기 · 같은 파일 대조 뒤 ·
후보 여럿 = 사람이 고름이라 안 만듦) · 조합 기계 = 조각마다(첫 조각 = 첫 손료 + 연료 · 조종원 ·
다음 조각 = 그 차례 손료만 · 구성 없는 조합 = 안 만듦).
몸 = 구성 있음 → 방식 바로(가격 천원 · 달러 = × 환율 FX000001 + 비고 · 손료계수 · 주연료 → 우리 유가 이름 ·
잡품 % · 조종원 → 우리 직종 원문번호 · 전력) · 못 이은 연료 · 직종 = 칸 비움 + 비고 「확인 필요」 ·
구성 없음 → 직접(그 파일 시간당 노 · 재 · 경).
잇기(`attach` · 층 저장 길 `M02_Sangeun_FromHopyo.make` 만) = 그 층 내 기계 같은 이름 · 규격이면 그 줄 · 없으면
새로(`M02_MyMachine.create`) · 바로 = 기계 줄 이음 키 · 로직 줄 = 시간당 부름 글 / 직접 = 이음 없이 자원
`내기계` 키(셈 = 그 파일 값 · [우리 단가로] = 내 기계 시간당 · `M02_Sangeun_Imported.row_of`) ·
원본 `내기계` [{키, 이름, 규격, 새로}] · 개인 층 아님 = 안 만듦(원본 `내기계예정` [{이름, 규격}] 만).
금액(엑셀 단가 그대로)은 줄 값을 안 건드려 그대로.
"""
from __future__ import annotations
import re
from typing import Any
from M02_MasterTemplete.M02_Machine_Find import NONE, norm
NOTE = "불러오기 자동 생성 — {file} · 값 확인 필요"
CHECK = "확인 필요"
COST = {"L": "노무비", "M": "재료비", "S": "경비"}
def key_of(name: Any, spec: Any) -> str:
return f"{norm(name)}|{norm(spec)}"
def _num(v: Any) -> float | None:
if isinstance(v, bool) or v is None:
return None
if isinstance(v, (int, float)):
return float(v)
s = str(v).strip().replace(",", "")
return float(s) if re.fullmatch(r"-?\d+(?:\.\d*)?|-?\.\d+", s) else None
def _blank(name: str = "", spec: str = "") -> dict[str, Any]:
return {"이름": name, "규격": spec, "손료": []}
def _fuel(make: dict, kind: Any, litre: Any) -> None:
"""주연료 종류 · ℓ(처음 것) — 종류 「전력」 = 전력 kWh 칸."""
if re.sub(r"\s", "", str(kind or "")) == "전력" and (_num(litre) or 0) > 0:
make["전력"] = _num(litre)
elif make.get("주연료") is None and (_num(litre) or 0) > 0:
make["주연료종류"], make["주연료"] = str(kind or "").strip(), _num(litre)
# ── 구성 읽기 ─────────────────────────────────────────────────────────
def est_makes(t: dict[str, list[dict]]) -> dict[str, dict]:
"""EST 원파일 MachMainDat → {자원 코드: 구성} — TitleCode 머리 다음 줄부터 빈 줄까지 ·
Index -3 손료(GN 계수 · TU 가격 · 단위 「$」 = 달러) · -2 「주연료」(Size 종류) · 연료 이름 줄(선박용경유 등 ℓ) ·
잡품 · 잡유 % · 전력 KWH · -1 조종원(직종 · GN 인)."""
heads = {r.get("Code"): r for r in t.get("MachTitleDat", [])}
out: dict[str, dict] = {}
cur = None
for r in t.get("MachMainDat", []):
if r.get("TitleCode"):
head = heads.get(r["TitleCode"]) or r
cur = out.setdefault(
r["TitleCode"], _blank(head.get("Name") or "", head.get("Size") or "")
)
elif not r.get("Name"):
cur = None
elif cur is not None:
i, gn, word = r.get("Index"), r.get("GN"), re.sub(r"\s", "", str(r["Name"]))
unit = str(r.get("Unite") or "").strip().upper()
if i == -3 and gn:
cur["손료"].append([gn, r.get("TU"), unit == "$"])
elif i == -2 and word == "주연료":
_fuel(cur, r.get("Size"), gn)
elif i == -2 and word in ("잡품", "잡유") and cur.get("잡재료율") is None:
cur["잡재료율"] = gn
elif i == -2 and word == "전력":
cur["전력"] = gn
elif i == -2 and unit == "ℓ":
_fuel(cur, r["Name"], gn)
elif i == -1 and (gn or 0) > 0 and not cur.get("조종원"):
cur["조종원"], cur["조종원직종"] = gn, str(r["Name"]).strip()
return {k: v for k, v in out.items() if v["손료"]}
def _est_sheet(rows: list[list]) -> dict[str, dict]:
"""EST 엑셀 「기계경비」 — 「N. 이름 · 규격」 머리 아래 빈 줄까지: 「손료 가격 ₩×(달러 $×) 계수 × 10(-7)」 ·
「주연료 종류 ℓ」 · 「잡품 주연료의 % 」 · 「직종 인수 인」 · 「전력 … kWh」."""
out: dict[str, dict] = {}
cur = None
for row in rows:
v = [c for c in row if c not in (None, "")]
head = re.match(r"\d+\.\s*(.+)", str(v[0])) if v else None
if head:
spec = v[1] if len(v) > 1 and isinstance(v[1], str) else ""
cur = out.setdefault(key_of(head.group(1), spec), _blank(head.group(1).strip(), spec))
elif not v:
cur = None
elif cur is not None:
word = re.sub(r"\s", "", str(v[0]))
mark = next((x for x in v if x in ("₩×", "$×")), None)
if "손료" in word and mark and (at := v.index(mark) + 1) < len(v):
cur["손료"].append([_num(v[at]), _num(v[1]), mark == "$×"])
elif word == "주연료" and len(v) > 2:
_fuel(cur, v[1], v[2])
elif word in ("잡품", "잡유") and len(v) > 2 and cur.get("잡재료율") is None:
cur["잡재료율"] = _num(v[2])
elif word == "전력" and len(v) > 2:
cur["전력"] = _num(v[1]) if _num(v[1]) is not None else _num(v[2])
elif len(v) > 2 and v[2] == "인" and (_num(v[1]) or 0) > 0 and not cur.get("조종원"):
cur["조종원"], cur["조종원직종"] = _num(v[1]), str(v[0]).strip()
elif len(v) > 3 and str(v[3]).upper() in ("ℓ", "L") and (_num(v[2]) or 0) > 0:
_fuel(cur, v[0], v[2])
return {k: m for k, m in out.items() if m["손료"] and None not in m["손료"][0][:2]}
def _stm_sheet(rows: list[list]) -> dict[str, dict]:
"""STmate 엑셀 「중기사용료」 — 「제 N 호표」 뒤 머리 [이름, 규격, 시간, 코드] · 손료 [이름, 규격, 계수(천원당),
「천원」, 가격 천원 …] · 조종원 [직종, 인, 「인」 …] · 연료 [이름, 등급, ℓ, 「L」 …] · 잡품 [잡품, 주연료의%, % …] ·
「합 계」 끝 → {코드 · 이름|규격: 구성}(손료계수 = 계수 × 10⁴ · 가격 = 천원 × 1000 원)."""
out: dict[str, dict] = {}
cur = None
for row in rows:
v = [c for c in row if c not in (None, "")]
first = re.sub(r"\s", "", str(v[0])) if v else ""
if re.fullmatch(r"제\d+호표", first):
cur = {}
continue
if cur is None or not v:
continue
if first == "합계":
cur = None
continue
if (
not cur
and len(v) >= 4
and str(v[2]) in ("시간", "hr")
and re.fullmatch(r"X\d+", str(v[-1]))
):
cur.update(_blank(str(v[0]), str(v[1])))
out[str(v[-1])] = out[key_of(v[0], v[1])] = cur
elif not cur:
continue
elif len(v) > 4 and str(v[3]) == "천원" and _num(v[2]) and _num(v[4]):
cur["손료"].append([_num(v[2]) * 10_000, _num(v[4]) * 1000, False])
elif len(v) > 2 and str(v[2]) == "인" and (_num(v[1]) or 0) > 0 and not cur.get("조종원"):
cur["조종원"], cur["조종원직종"] = _num(v[1]), str(v[0]).strip()
elif first == "잡품" and len(v) > 2 and cur.get("잡재료율") is None:
cur["잡재료율"] = _num(v[2])
elif len(v) > 3 and str(v[3]).upper() in ("L", "ℓ"):
_fuel(cur, v[0], v[2])
elif len(v) > 3 and str(v[3]).upper() == "KWH":
cur["전력"] = _num(v[2])
return {k: m for k, m in out.items() if m.get("손료")}
def xlsx_makes(sheets: dict[str, list]) -> dict[str, dict]:
"""엑셀 산근 시트 → 구성(EST 「기계경비」 · STmate 「중기사용료」 · 없음 = {})."""
if sheets.get("기계경비"):
return _est_sheet(sheets["기계경비"])
if sheets.get("중기사용료"):
return _stm_sheet(sheets["중기사용료"])
return {}
# ── 고름 · 몸 ─────────────────────────────────────────────────────────
def _todo(logic: dict) -> list[tuple[dict, int]]:
"""(줄, 조각 차례) — 기계 줄 · 이음 없음 · 찾기 판정 「못 찾음」."""
out, n = [], 0
for line in logic.get("호표") or []:
res = line.get("자원") or {}
if res.get("갈래") != "기계":
continue
n = n + 1 if res.get("조합") else 0
if line.get("이음") or res.get("조합식") or res.get("내기계"):
continue
if (res.get("찾기") or {}).get("판정") == NONE:
out.append((line, n))
return out
def _make_of(res: dict, makes: dict) -> dict | None:
return makes.get(str(res.get("코드") or "")) or makes.get(
key_of(res.get("이름"), res.get("규격"))
)
def _kinds(make: dict, notes: list[str]) -> dict[str, Any]:
"""연료 · 조종원 → 우리 유가 이름 · 직종 원문번호(못 이음 = 칸 비움 + 비고)."""
from M01_MasterData import M01_MasterData_Store as m01
index, out = m01.loaded()[1], {}
fuel, litre = str(make.get("주연료종류") or ""), make.get("주연료") or 0
if litre:
ours = next(
(n for n in ("경유", "휘발유", "실내등유") if n == re.sub(r"\s", "", fuel)), None
)
ours = ours or next((n for n in ("경유", "휘발유", "실내등유") if fuel.startswith(n)), None)
if ours and f"{ours}:전국평균" in (index.numbers.get("MO") or {}):
out |= {"주연료": litre, "주연료종류": ours}
out |= {"잡재료율": make["잡재료율"]} if make.get("잡재료율") else {}
else:
notes.append(f"주연료 「{fuel}」 {litre:g}ℓ — 우리 유가에 못 이음({CHECK})")
if make.get("조종원"):
job = norm(make.get("조종원직종"))
hit = [k for k, r in (index.numbers.get("LB") or {}).items() if norm(r.get("이름")) == job]
if len(hit) == 1:
out |= {"조종원": make["조종원"], "조종원직종": hit[0]}
else:
notes.append(
f"조종원 「{make.get('조종원직종')}」 {make['조종원']:g}인 — 우리 직종에 못 이음({CHECK})"
)
if make.get("전력"):
out["전력"] = make["전력"]
return out
def body(make: dict | None, n: int, line: dict, file: str) -> dict | None:
"""내 기계 몸(`M02_MyMachine.create`) — 구성 있음 = 바로 · 없음 = 직접(조합 조각 = 안 만듦)."""
from M01_MasterData import M01_MasterData_Store as m01
res = line.get("자원") or {}
found = res.get("찾기") or {}
name = str(found.get("이름") or res.get("이름") or line.get("이름") or "").strip()
spec = str((found.get("규격") if found.get("이름") else res.get("규격")) or "").strip()
head = {"이름": name, "규격": spec}
notes = [NOTE.format(file=file)]
if make is None:
if n or res.get("조합첫"):
return None
got = res.get("단가") or {}
pack = line.get("값묶음") or {}
hour = {v: _num(got.get(c)) or _num(pack.get(v)) or 0 for c, v in COST.items()}
return {**head, "방식": "직접", "시간당": hour, "비고": " · ".join(notes)}
if n >= len(make["손료"]):
return None
coef, price, usd = make["손료"][n]
if usd:
rate = float(m01.loaded()[1].index["FX"]["FX000001"]["값"])
notes.append(f"가격 ${price:g} × 환율 {rate:,} 원/USD")
price = price * rate
vals: dict[str, Any] = {"가격": round(float(price) / 1000, 4), "손료계수": coef}
if n == 0:
vals |= _kinds(make, notes)
return {**head, "방식": "바로", "값": vals, "비고": " · ".join(notes)}
def plan(logic: dict, makes: dict, file: str) -> list[dict]:
"""만들(또는 이을) 내 기계 [{줄, 열쇠, 몸}] — 글 · 층 안 건드림."""
out = []
for line, n in _todo(logic):
got = body(_make_of(line.get("자원") or {}, makes), n, line, file)
if got:
out.append({"줄": line, "열쇠": key_of(got["이름"], got["규격"]), "몸": got})
return out
def preview(out: dict, makes: dict) -> None:
"""미리 보기 · 분석 길 — 원본 `내기계예정` [{이름, 규격}](안 만듦)."""
todo = plan(out["로직"], makes, str(out["원본"].get("파일") or ""))
seen = {t["열쇠"]: {"이름": t["몸"]["이름"], "규격": t["몸"]["규격"]} for t in todo}
if seen:
out["원본"]["내기계예정"] = list(seen.values())
# ── 잇기(층 쓰기) ─────────────────────────────────────────────────────
def link(line: dict, row: dict) -> None:
"""가져온 줄 → 내 기계 — 바로 = 기계 줄 키 · 로직 줄 시간당 부름 글 / 직접 = 자원 `내기계` 만."""
from M02_MasterTemplete.M02_San_Link import hourly_text
line["자원"]["내기계"] = row["키"]
if row.get("방식") == "직접":
return
line["이음"] = hourly_text(row["키"]) if line.get("종류") == "로직" else row["키"]
def attach(out: dict, makes: dict) -> list[dict]:
"""층 저장 길 — 못 이은 기계 줄을 그 층 내 기계(같은 이름 · 규격) · 새 내 기계에 이음 · 원본 `내기계`."""
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
src = out["원본"]
todo = plan(out["로직"], makes, str(src.get("파일") or ""))
if not todo:
return []
try:
mym._user()
except store.StoreError: # 개인 층 아님 — 안 만듦(예정만)
preview(out, makes)
return []
done: dict[str, dict] = {}
for one in todo:
if one["열쇠"] not in done:
have = next(
(r for r in mym.rows() if key_of(r.get("이름"), r.get("규격")) == one["열쇠"]), None
)
row = have or mym.create(one["몸"])["줄"]
done[one["열쇠"]] = {"줄": row, "새로": have is None}
link(one["줄"], done[one["열쇠"]]["줄"])
made = [
{**{k: d["줄"][k] for k in ("키", "이름", "규격", "방식")}, "새로": d["새로"]}
for d in done.values()
]
src["내기계"] = made
src.pop("내기계예정", None)
return made
+2 -4
View File
@@ -549,10 +549,8 @@ def _check_refs(doc: dict, here_: refs.Here) -> None:
def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]:
"""저장 꼴(`Labor_Ref.clean_doc` 가 부름) — 검사 · 참조 · 순환 · 서버가 `원값` · `바탕판`."""
from M01_MasterData import M01_MasterData_Store as m01
raw, job = doc, doc.get(skc.JOB) if isinstance(doc, dict) else None # 수식 사본(802-4a)
whole = skc.over(doc := skc.settle(doc, True), m01.loaded()[1]) # 사본(795-1)
whole = skc.over(doc := skc.settle(doc, True), skc.whole_()) # 사본(795-1) · 내 기계(821-1)
try:
rough = sd.clean(doc, None, whole)
_check_refs(rough, here_)
@@ -604,7 +602,7 @@ def calc_doc(doc: Any, given: dict | None = None, seen: bool = True) -> dict[str
from M01_MasterData import M01_MasterData_Store as m01
with m01.cache.steady(): # 한 셈 동안 M01 파일 자국 한 번(792-2)
whole = skc.over(doc := skc.settle(doc), m01.loaded()[1]) # 사본(795-1)
whole = skc.over(doc := skc.settle(doc), skc.whole_()) # 사본(795-1) · 내 기계(821-1)
try:
clean = sd.clean(doc, None, whole)
_check_refs(clean, refs.here()) # 저장 전 문서도 위 층만 · 있는 문서 · 순환 422
+6 -1
View File
@@ -30,6 +30,7 @@ from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_Item_Prices as prices
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Imported as imp
@@ -41,7 +42,10 @@ _MASTER = prices.mf.Master
def whole_() -> Any:
return prices.m01.loaded()[1]
"""M01 색인 + 지금 층 내 기계 줄(821-1 · `M02_MyMachine.whole`)."""
from M02_MasterTemplete import M02_MyMachine as mym
return mym.whole()
def _fp(rows: Any) -> str:
@@ -149,6 +153,7 @@ def stamp(doc: dict) -> dict | None:
return None
keys, links = reach(doc.get("더한줄"))
keys = [str(base["로직"]), *keys] if base.get("로직") else keys
links += mym.keys_in(doc) # 줄 쌓기 기계 입력 「개인 EQP…」(821-1)
extra = [row] if (row := imp.row_of(doc)) else []
return snap(keys, links, extra)
+17 -6
View File
@@ -25,6 +25,7 @@ from M02_MasterTemplete import M02_Doc_FromNative as fn
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_San_MyMachine as mm
DIRNAME = "산근"
RELOAD = "가져온 식 자료 없음 — 엑셀을 다시 올림"
@@ -109,7 +110,15 @@ def has(folder: str | Path, key: str) -> bool:
def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]:
"""HP 단산 블록 하나 → `{로직, 원본}`(원파일 우선 · 아니면 엑셀 읽기기 같은 길 · 이음 = M01 키)."""
"""HP 단산 블록 하나 → `{로직, 원본}`(원파일 우선 · 아니면 엑셀 읽기기 같은 길 · 이음 = M01 키) ·
못 이은 기계 = 원본 `내기계예정`(821-4 · 안 만듦)."""
out, makes = _emit(folder, key, no)
mm.preview(out, makes)
return out
def _emit(folder: str | Path, key: str, no: int) -> tuple[dict[str, Any], dict]:
"""(블록 `{로직, 원본}`, 그 파일 기계 구성 — 원파일 둘 꼴 `기계구성` · 엑셀 = 둔 시트 · 821-4)."""
hopyo_store.read(folder, key) # 없는 HP = 404
path = _path(folder, key)
try:
@@ -118,13 +127,13 @@ def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]:
raise store.StoreError(409, RELOAD) from e
native = saved.get("원파일")
if isinstance(native, dict) and int(no) in fn.numbers(native): # 원파일 우선(810-2)
return fn.emit(native, int(no))
return fn.emit(native, int(no)), native.get("기계구성") or {}
if not saved.get("시트"):
raise store.StoreError(404, f"없는 단산{no}")
excel = _excel_of(path, saved)
if int(no) not in fn.numbers(excel):
raise store.StoreError(404, f"없는 단산{no}")
return fn.emit(excel, int(no))
return fn.emit(excel, int(no)), mm.xlsx_makes(saved["시트"])
def _inner(
@@ -173,7 +182,7 @@ def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) ->
안쪽 산근 블록은 형제 SK 를 먼저 만들어 참조(797-1)."""
from M02_MasterTemplete import M02_Sangeun_Imported as imp
out = emit(source, key, no)
out, makes = _emit(source, key, no)
out["원본"]["가져온때"] = utc_now_text() # 원문 패널 「어디서 왔나」(807-1)
_link_inner(source, key, no, out, _trail)
try: # 소요량 · 계수 후보 표지(797-2 · 사람 확인) — 못 셈 = 표지 없이 만듦
@@ -182,13 +191,15 @@ def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) ->
out["원본"]["표지"] += qty.flags(out["로직"])
except Exception: # noqa: BLE001 — 역색인 짓기 실패가 가져온 식 만들기를 막지 않음
pass
made = mm.attach(out, makes) # 821-4 못 이은 기계 = 내 기계(그 층 같은 것 · 새로)
mine = {"내기계": made} if made else {}
title = str(name or "").strip() or str(out["로직"].get("이름") or f"단산{no}")
try:
got = imp.create(out["로직"], out["원본"], title)
return {**got, "다시씀": False}
return {**got, "다시씀": False, **mine}
except store.StoreError as e:
same = (e.detail or {}).get("같은것") if isinstance(e.detail, dict) else None
if e.status != 409 or not same:
raise
got = imp.create(out["로직"], out["원본"], title, use=same[0]["키"])
return {**got, "다시씀": True}
return {**got, "다시씀": True, **mine}
+19 -4
View File
@@ -63,9 +63,10 @@ def _mf() -> Any:
def _whole() -> Any:
from M01_MasterData import M01_MasterData_Store as m01
"""M01 + 지금 층 내 기계(821-4 · 못 이은 기계를 이은 개인 키 `EQP…`)."""
from M02_MasterTemplete import M02_MyMachine as mym
return m01.loaded()[1]
return mym.whole()
def is_imported(doc: Any) -> bool:
@@ -80,7 +81,7 @@ def hourly_of(key: str) -> dict:
"""이음 기계(EQ) 시간당 노 · 재 · 경 — M01 판이 바뀌면 다시(캐시 = M01 묶음 하나에 매임)."""
from M02_MasterTemplete import M02_Machine_Hourly as machine
whole = _whole()
whole = _whole() # 내 기계 고침 = 색인이 바뀜 → 다시
if _HOURLY["whole"] is not whole:
_HOURLY.update(whole=whole, rows={})
if key not in _HOURLY["rows"]:
@@ -112,6 +113,8 @@ def row_of(doc: Any) -> dict | None:
"참조"
): # 안쪽 산근 = 형제 SK 1 단위 값(셈이 채움 · 엑셀 값묶음은 얼린 사본으로 둠)
item |= {"요소": None, "값묶음": None, "기준": "우리" if ours else "엑셀"}
if ours and not link and (mine := (one.get("자원") or {}).get("내기계")):
_direct(item, one, mine) # 821-4 시간당 값 직접 내기계 = 그 줄 값
if ours and link:
item.pop("단가", None)
item.pop("값묶음", None)
@@ -124,6 +127,18 @@ def row_of(doc: Any) -> dict | None:
return {**row, "키": doc.get("키"), "호표": items}
def _direct(item: dict, one: dict, key: str) -> None:
"""[우리 단가로] 이음 없이 이은 내 기계(시간당 값 직접 · 821-4) — 단가 = 그 비목 · 값묶음 = 셋 ·
지운 내 기계 = 엑셀 값 그대로."""
got = hourly_of(key)
if not got.get("ok"):
return
if "단가" in item and one.get("비목") in COSTS:
item["단가"] = sd.plain(Decimal(str(got[one["비목"]])))
elif "값묶음" in item:
item["값묶음"] = {k: sd.plain(Decimal(str(got[k]))) for k in COSTS}
def base_row(doc: dict) -> dict[str, Any]:
"""바탕 없는 SK 바탕 줄(`sd.empty_row`) — 가져온 식 = 그 줄 · 아니면 입력 · 줄 없음."""
return row_of(doc) or {
@@ -314,7 +329,7 @@ def unlinked(doc: dict) -> list[dict]:
{"자리": ids[n], "이름": one.get("이름") or "", "까닭": link.why(one)}
| ({"견적": True} if (one.get("자원") or {}).get("갈래") == "고정" else {})
for n, one in enumerate(doc[KEY]["로직"].get("호표") or [])
if not one.get("이음") and not one.get("참조")
if not one.get("이음") and not one.get("참조") and not (one.get("자원") or {}).get("내기계")
]
@@ -27,6 +27,10 @@ export interface ImportedMeta {
갈래?: string;
엑셀금액?: Money4;
글줄?: { 행?: number | null; 글?: string; 자리?: string; 값?: number | null }[];
/** 못 이은 기계 → 내 기계(821-4 · 새로 = 이번에 만듦 · 아니면 그 층 같은 것) */
내기계?: { 키: string; 이름: string; 규격?: string; 방식?: string; 새로?: boolean }[];
/** 개인 층 밖에서 연 것 — 만들어질 내 기계(안 만듦) */
내기계예정?: { 이름: string; 규격?: string }[];
};
검산?: {
같음: boolean;
+92
View File
@@ -0,0 +1,92 @@
/* =============================================================================
* common_util_machine_coef.ts
* 기계 손료계수 · 시간당 경비 미리보기(PLAN 821-2 · 요구서 796 1 장) — 기계 양식 즉시 셈.
*
* ⚠ 짝: `resources/master_data/scripts/master_machine_coef.py` — 같은 셈 · 같은 차례
* (거울 시험 `resources/tester/test_821_2_machine_coef.py`). 한쪽만 고치면 화면과 저장이 갈림.
* 계수 = 건설품셈 공통 8-1-5(계수마다 정수 반올림 · 손료계수 = 합) · 미리보기 끝수 = 서버
* 기계경비식(줄 0.1 원 아래 버림 · 잡품 = 버린 주연료 × % · 비목 합 원 미만 버림) · 정본은 서버 식.
* ========================================================================== */
export const MACHINE_INPUTS = [
"내용시간",
"연간표준가동시간",
"상각비율",
"정비비율",
"연간관리비율",
] as const;
export const MACHINE_COEFS = ["상각비계수", "정비비계수", "관리비계수", "손료계수"] as const;
export type MachineCoefs = Record<(typeof MACHINE_COEFS)[number], number>;
const E = 1e-6; // 끊는 경계 부동소수 흔들림 막음
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
const halfUp = (x: number): number => Math.floor(x + 0.5 + E);
/** 줄 금액 = 0.1 원 아래 버림(B09 끝수 줄 자리). */
const tenth = (x: number): number => Math.floor(x * 10 + E) / 10;
/** (내용시간 · 연간표준가동시간 · 상각비율 · 정비비율 · 연간관리비율) → 계수 넷 · 못 셈 = null. */
export function machineCoef(
life: unknown,
yearly: unknown,
dep: unknown,
rep: unknown,
mgmt: unknown,
): MachineCoefs | null {
const [t, h, a, m, k] = [life, yearly, dep, rep, mgmt].map(num);
if (t <= 0 || h <= 0) return null;
const n = t / h;
const s = halfUp((a / t) * 1e7);
const j = halfUp((m / t) * 1e7);
const g = halfUp(((k * (1.1 * n + 0.9)) / (2 * n) / h) * 1e7);
return { 상각비계수: s, 정비비계수: j, 관리비계수: g, 손료계수: s + j + g };
}
/** 값 묶음에서 품셈 입력 다섯이 다 수일 때만 계수 넷. */
export function machineCoefOf(values: Record<string, unknown>): MachineCoefs | null {
if (!MACHINE_INPUTS.every((k) => typeof values[k] === "number")) return null;
return machineCoef(
...(MACHINE_INPUTS.map((k) => values[k]) as [number, number, number, number, number]),
);
}
export interface MachineHourly {
손료: number;
주연료: number;
잡품: number;
전력: number;
조종원: number;
노무비: number;
재료비: number;
경비: number;
계: number;
}
/** 값 묶음 + 유가(원/ℓ) · 노임(원/일) · 전력(원/kWh) · 시간환산 → 시간당 줄 · 비목 · 계. */
export function machineHourly(
values: Record<string, unknown>,
fuel: number,
wage: number,
power = 0,
hour = 1 / 8,
): MachineHourly {
const loss = Math.floor((num(values["가격"]) * 1000 * num(values["손료계수"])) / 1e7 + E);
const oil = tenth(num(values["주연료"]) * num(fuel));
const misc = tenth((oil * num(values["잡재료율"])) / 100);
const elec = tenth(num(values["전력"]) * num(power));
const crew = tenth(num(values["조종원"]) * hour * num(wage));
const labor = Math.floor(crew + E);
const material = Math.floor(oil + misc + elec + E);
return {
손료: loss,
주연료: oil,
잡품: misc,
전력: elec,
조종원: crew,
노무비: labor,
재료비: material,
경비: loss,
계: labor + material + loss,
};
}
@@ -212,3 +212,45 @@
- 손료계수 = 품셈 8장(상각·정비·관리 계수) · 소형(발전기 5.5·7·10kW)은 현행 품셈 가격표(25kW~)·손료계수표에 없음 → 같은 기종 계수(발전기 2,362 · 브레이커 6,601) 차용 + 가격은 행안부 표준시장가격 파일.
- 사용자 뜻(요구 783 · 784): EST 숫자 안 씀 · 조달청/행안부 공식 자료로 · 행안부 시가표준액표가 있으면 사용.
- 미정: 행안부 표준시장가격 파일을 (가) 사용자가 받아 줄지 (나) 브레인이 공개 연도별 「건설기계경비 산출표」에서 해당 기계 값만 뽑을지.
## S. 품셈 밖 기계·재료 = 개인 관리 검토(요구 790 · 사용자 결정 전)
- 기계표 668 줄 구성 꼴: 손료+연료+조종원 281 · 손료만 267 · 손료+연료 58 · 손료+조종원 19 · 가격 없는 줄 약 40 · 전력 1 → 네 꼴이 94 % → 신규 기계 입력 칸 = 이름·규격 · 가격 · 손료계수 · 주연료(종류 · ℓ/hr) · 잡품 % · 조종원(직종 · 인) (+ 전력 kWh) · 꼴은 채운 칸으로 자동.
- 안 S1(1 번): M01 = 품셈 정본만 · 품셈 밖 기계 = M02 개인 층 「기계 시간당 경비」 단가산출 한 장(손료 = 가격 × 계수 · 주연료 → 우리 유가 · 잡품 % · 조종원 → 우리 노임) · 쓰는 단가산출은 그 장 참조 · 불러오기에서 못 이은 기계는 그 파일 구성(EST 원파일 구성표 · 엑셀 기계경비 시트)으로 자동 생성 · 구성 없는 파일(STmate 원파일)은 값 한 줄 장.
- 안 S2(2 번): M01 기계 표 = 목록 · 줄 누르면 상세(값 묶음 · 시간당 계산식 미리보기 · 별칭 · 쓰는 곳) · S1 양식과 같은 부품.
- 안 S3(3 번): 층마다 요소 표(저장소)를 새로 두는 것은 비권장(키 둘 · 찾는 차례 · 갱신 책임 · 동봉 · 삭제 깨짐) · 대신 「내 기계 · 내 품목」 모아 보기(읽기 중심 · 같은 이름 묶음 · 쓰는 곳 수) + 만들 때 같은 이름 안내.
- 안 S4(4 번): 820-1 의 13 줄(EQ000656 ~ 668) · GC001195 는 S1 방향이면 지움 · 덤프 1 톤(산림품셈 10-9) · 체인톱 시간당 식(산림품셈 2 장) · 별칭 10 은 둠 · 시점 = S1 기능 넣는 묶음에서 같이(공백 없음).
- S5(5 번): 차례 = 표기 엔진 · 별칭 · 같은 파일 대조로 품셈 요소에 최대한 → 남는 것만 개인 장 → 반복되면 마스터 승격 제안 · 불러오기 결과에 「개인 생성 n」 표시.
- 참고: 현행 품셈 8 장에 조종원 노임 ×16/12 ×25/20 없음(우리 = ÷8) · EST 구성은 옛 환산 포함 → 자동 생성은 우리 틀에 값만 담는 쪽 권장.
## T. 기계 신규 생성·수정 — 쉽게(요구 791 · 조사 + 안 · 착수 대기)
- 사용자 결정(요구 791): S 장 정할 것 1·2·3 동의(모아 보기만 · 13 줄 삭제는 새 기능과 같이 · 자동 생성은 우리 틀에 값만) · 착수 대기.
- 지금(실측 · M01 › 기계): 표 55 열(기본 7 + 단위 15 + 값 15 + 출처 15 + 별칭 · 비고) · 표 폭 약 6,000 px(화면의 4.7 배) · 고정 열 없음 · [줄 추가] = 맨 위 빈 줄(모달 · 복사 · 기본값 없음) · 단위 · 출처 15 칸씩 손으로(출처 비면 저장 422) · 칸 = 글 입력(숫자 검증 없음) · 계수 넷 자동 계산 없음 · 주연료종류 · 조종원직종 자유 글 · 시간당 경비는 기계 화면에서 못 봄(저장 뒤 다른 화면) · 저장하면 새 줄이 파일 끝으로.
- 계수 식 확인(굴착기 1.0㎥): 상각비계수 = 상각비율 ÷ 내용시간 × 10⁷(900) · 정비비계수 = 정비비율 ÷ 내용시간 × 10⁷(700) · 관리비계수 = 연간관리비율 × 평균취득가격 비율(0.9(N+1)/2N + 0.1 · N = 내용시간 ÷ 연간표준가동시간) ÷ 연간표준가동시간 × 10⁷(485) · 손료계수 = 합(2,085).
- 안 T1 만들기: 「비슷한 기계에서 시작」(찾아 고르면 값 복사) 또는 빈 양식 · 묻는 칸 = 이름 · 규격 / 가격 / 손료계수 / 주연료(종류 고르기 + ℓ/hr) / 잡품 % / 조종원(직종 고르기 + 인) (+ 전력) · 단위 · 출처는 자동.
- 안 T2 손료계수 두 방식: 「바로 넣기」(한 칸) · 「품셈 방식」(내용시간 · 연간표준가동시간 · 비율 셋 → 계수 자동) · M02 기본 = 바로 넣기 · M01 기본 = 품셈 방식.
- 안 T3 미리보기: 양식 옆에 시간당 경비 계산식이 넣는 즉시(손료 · 주연료 · 잡품 · 조종원 · 계) — 브라우저 즉시 셈 · 저장은 서버 재계산.
- 안 T4 고치기: 표는 목록용(이름 · 규격 · 가격 · 손료계수 · 연료 · 조종원 · 시간당 경비 · 단위 · 출처 열 숨김) · 줄 누르면 같은 양식의 상세 페이지.
- 안 T5 안전: 숫자 칸 · 범위 경고 · 같은 이름 · 규격 알림 · 쓰는 곳 보이기(지우기 전) · 저장 전 바뀐 것 요약.
- 안 T6 불러오기: 못 이은 기계 = 경쟁사 구성으로 미리 채운 양식(확인만).
- 부품: `ui_template` 입력 · 고르기 · 모달 · `createCalcFormula` · `createKv` 재사용 · 숫자 전용 칸만 보탬 · M01 · M02 같은 양식 부품.
## U. 내 기계 = M01 기계처럼(요구 795 · 코드 확인 · 착수 대기)
- 확인(코드): 단가산출(SK)을 만들 때 사본을 박음 — 로직 · 부르는 로직(기계 시간당 경비식 포함) · 요소 값 줄 전체(LB · MT · EQ · MO · FX — 기계 가격 · 손료계수 · 주연료 · 조종원)를 만든 시점 그대로(`M02_Sangeun_Copy.snap`) · 셈은 사본에서 읽음(`calc_doc` → `Copy`) · M01 이 바뀌어도 그대로 · 「단가새값」 · 「M01새판」 배지만 · [단가 갱신] = 값 줄만 지금 M01 로(사용자 고침 보존) · [재생성] = 로직까지 다시 · 프로젝트(B09)도 사본 그대로 굳음 · 사본 없는 옛 SK 만 M01 현재값.
- 그래서 S1 고침: 내 기계 = 「단가산출 한 장 참조」 가 아니라 **개인 층의 기계 줄**(M01 기계 줄과 같은 꼴 · 개인 키) → 단가산출을 만들 때 M01 기계와 똑같이 사본에 복사 · 그 뒤 내 기계를 고쳐도 이미 만든 단가산출은 그대로 · 「새 값」 배지 → [단가 갱신] 으로 반영 · 지워도 기존 단가산출은 마지막 값 유지.
- 시간당 경비 식 = M01 기계와 같은 식(GC000267 ~ · 조종원 유무 등 채운 칸으로 고름) · 값만 개인 줄.
- S3 고침: 「모아 보기만」 → 개인 층에 **내 기계 줄 목록**(작은 저장 하나)을 둠 · 요소 표 전체를 층마다 두는 것은 여전히 안 함(기계만 · 재료는 줄에 든 품목 모아 보기) · 앞서 든 위험(프로젝트 동봉 · 삭제 깨짐 · 여러 곳 같이 바뀜)은 사본 방식이라 없어짐.
- 고칠 자리(코드 조사): 기계 찾기 · 고르기(`M02_Machine_Find.table` · `M02_Machine_Hourly`) 와 사본 모으기(`M02_Item_Prices._Collector`)가 개인 줄도 읽게 · [단가 갱신](`skc.refresh` · `state`)이 개인 키를 개인 목록에서 찾게(지금은 M01 만 봐 「지워짐」 으로 오판).
- 요구 794 답의 「모두 반영 / 이 단가산출만 따로」 물음은 뺌(M01 과 같은 규칙).
## V. 요구 797 조사(기계 시간당 식)
- 기계 시간당 식 = 목록 방식: 식마다 「기계」 고르기 목록(원문번호)이 있고 그 목록에 든 기계만 그 식으로 셈 — GC000267(굴착기 무한궤도 9) · 268(손료 보정 없는 기종 209) · 269(손료 보정 129) · 270(+집게 6) · 271(+브레이커 9) · 272(조종원 없는 기종 59) · 275(조종원직종 입력 39) · 276(연료 없는 기종 8) · 273(손료만 327) · 274(손료만 · 보정 121) · GF000145(덤프 1 톤) · GF000337(체인톱).
- M01 [새 기계] 로 만든 줄은 어느 목록에도 없어 시간당 「—」(내 기계는 821-1 이 채운 칸으로 식을 고름).
- 손료만 식(GC000273 · 비고 「원문 운전경비 표에 주연료 · 조종원이 없는 기종 — 동력비는 원문대로 별도」) 목록 327 대 가운데 **기계표에 연료나 조종원이 있는 것 33 대**: 타워크레인 5 · 크러셔(이동식) 4 · 골재세척설비 · 이동식 임목파쇄기 · 점보드릴 2(조종원 1 · 직종 빈 칸 · 연료 「-」 또는 kW) · 아스팔트 믹싱플랜트 5(연료 + 조종원 2 · 직종 있음) · 아스팔트 스프레어 2 · 현장가열 표층재생기(조종원 7) · 펌프 준설선 2(연료 + 조종원 2) · 전동 기계 9(TBM · 자동세륜기 2 · 모르타르 펌프 3 · 모르타르 믹서 · 양수기 · POWER TLOWEL — 주연료 칸에 kW 글 또는 휘발유) → 시간당 경비에 연료 · 조종원 · 전력이 빠짐.
- 조종원 값의 출처 = 건설품셈 8-4 운전경비 표 「조종원(인/일)」 열(타워크레인 8-4-3 「- | - | 1」 · 주⑥ 「타워크레인의 연료 소모량은 별도 계상」 · 크러셔(이동식) 8-4-6 「- | - | 1」 · 점보드릴 「135㎾ | 6 | 1」) · 원문 표에 직종 열은 없음.
- 원문 8-1-6 3: 운전경비 = 연료 · 전력 · 윤활유 + 운전 노무비 + 소모품비 · 8-1-7 4: 손료 산정에 동력이 포함되지 않은 경우 해당 엔진 · 모터의 손료 · 운전경비 적용 · 전동기 부하율 규정은 없음.
- POWER TROWEL 은 품셈에 있음(8-4-8 「7994-0050 POWER TROWEL 3.73㎾ 휘발유1 10 -」) · 우리 기계표 EQ000534 이름이 「POWER TLOWEL」(오자) → 별칭 「파워트로웰」 넣음(가져온 「파워트로웰 3.73kW」 = 품셈 기계로 이음).
- 안(사용자 「진행」 전): ㉰ 「시간당 값 직접」 식(노 · 재 · 경 = 기계 줄 값) ㉱ 전력 줄(전력 kW × 전력 단가 · 잡재료 % 있으면 같이) ㉲ M01 새 기계 = 저장 때 꼴에 맞는 식 목록에 등록 + 상세 페이지에 쓰는 식 표시 ㉳ 위 33 대를 맞는 식으로 옮김(직종 빈 칸 13 대는 직종 확인 필요).
@@ -984,3 +984,45 @@ STmate 의 「굴삭기(1.0m3)은 무한궤도
## 요구 789 (11-12 · 사용자 · 경쟁사 세 파일 현재 매칭율 표시)
경쟁사 3개 파일 현재 매칭율 표시
## 요구 790 (11-12 · 사용자 · 정밀 검토 요청 — 1 우리 기계는 품셈 기준 그대로 · 새 기계·재료는 M02 단가산출로 개인 관리 · 기계 패턴 분석해 쉬운 신규 기계 생성 로직 · 2 M01 기계 컨테이너 = 표라 값 확인·수정 불편 → 별도 페이지 · 3 M02 에도 기계·재료 개인 관리 기능(중복 확인 · 단가산출 만들 때 복사) — 관리 안 될 것 같으면 반영 안 함 · 4 매칭 안 돼 추가한 기계 10 여 개 삭제 여부 · 5 특수한 경우 빼면 매칭을 높여 개인 관리 최소화)
1. 잠시 고민해봤는데 조금 문제가 있네. 나중에 다른 기계나 신규 재료 품목이 들어올 경우가 문제네. 우리의 기계는 품셈을 기준으로 그냥두고 새로운 기계나 재료의 경우 m02의 단가산출로 개인관리하게 하는건 어떤가해서? 자재는 품목과 값만 있으면 되니까 문제가 없는데 기계의 경우 조금 복잡함. 기계들의 패턴을 분석해서 쉽게 만드는 방법이 없는지 검토. 이런 기능이 있으면 경쟁사 프로그램에서 가져오는 정보 중 매칭이 안되는 것이 있다고 하더라도 문제없을것같아서 말이야. 그렇다면 단순한 신규 기계 생성로직이 필요한거야.
2. m01의 기계 컨테이너의 페이지가 테이블로 구성되어 있음. 리스트는 문제가 없으나값확인이나 수정이 불편함. 별도의 페이지로 접근하게 반영.
3. m02에 m01에서와 동일하게 기계와 재료를 개인이 관리할 수 있는 기능이 있는게 어떤지 그럼 중복확인이 쉬울것같은데. 이 역시도 단가산출을 생성할때는 복사하여 사용하는거지. 어떻게 생각함. 관리가 안될것같으면 반영안할거야.
4. 이전 반영 중 매칭이 안되서 추가한 기계 10여개는 삭제하는게 어떤지.
5. 하지만 특수한 경우를 제외하면 매칭을 높여 개인관리를 최소화해야하는건 맞아.
일단 정밀 검토해보자.
## 요구 791 (11-12 · 사용자 · 1·2·3 동의 · 착수 대기 · 추가 검토 — 기계 신규 생성·수정 기능이 사용자가 쓰기 편하고 쉬워야 함 · 조사·검토)
1,2,3은 동의하고 착수대기. 하나더 검토 현재 기계의 신규 생성 및 수정기능이 사용자가 사용하기 편하고 쉬워야함. 조사하고 검토해줘
## 요구 792 (11-12 · 사용자 · 확인 물음 — M01 기계 · M02 단가산출 내부 기계 · 경쟁사 불러오기 기계가 같게 입력 · 변경 · 생성된다는 뜻인지)
결과적으로는 m01 기계, m02 단가산출 내부의 기계 경쟁사 정보 불러오기의 기계가 하게 입력되고 변경이 가능하며 생성된다는 말이지?
## 요구 793 (11-12 · 사용자 · 확인 물음 — 단가산출 화면은 좌측 조작 · 우측 산출근거 · 로직은 복사하되 값은 M01 값 사용·갱신 · 매칭 안 돼 새로 만든 정보의 일부 계수·가격만 사용자가 갱신 · 인력 등 나머지는 M01 값)
단가산출의 화면은 좌측과 우측의 산출근거 영역이 나눠져있고 보통 조작은 좌측에서 하게됨. 대부분의 정보들은 로직은 복사하되 값은 m01의 값을 사용하거나 업데이트됨. 대신 매칭이 안되어서 새롭게만든정보 중 일부 계수나 가격은 사용자가 업데이트 하는게 맞고 나머지 인력이나 다른 항목은 당연히 m01값을 사용. 맞는거지?
## 요구 794 (11-12 · 사용자 · 물음 — 그럼 기계를 변경할 때는?)
그럼 기계를 변경할때는?
## 요구 795 (11-12 · 사용자 · 내 기계도 M01 기계처럼 동작해야 함 · 먼저 기계를 만들고 단가산출을 만들면 복사 개념 · 생성 시점에 복사하면 연결이 끊김 — 확인)
내 기계도 사실상 m01 의 기계처럼 동작해야함. 먼저 기계를 만들고 단가산출을 만들개되면 복사한 개념으로 접근하고 생성시점에 복사를 하면 연결을 끊게되잖아 확인해봐
## 요구 796 (11-12 · 사용자 · 상세 계획서 작성하고 착수 진행)
상세계획서 작성하고 착수 진행해
## 요구 797 (11-12 · 사용자 · 1 사본 빈틈 있으면 안 됨 · 2 시간당 값 계산 로직을 만들어 넣을 수 없는지 확인 · 3 M01 은 기초 데이터 · 기초 로직 공간 — 시간당 경비가 없다는 건 문제 · 더 설명 · 4 조종원 1 인 14 대가 어디서 나온 정보인지 · 5 (전력) 시간당 계산식을 만들 수 없는지 · 6 옛 시험은 알아서 수정 · 삭제)
1번 사본 빈틈이 있으면 안됨.
2번 시간당 값 계산로직을 만들어 넣을수는 없는지 확인
3. m01은 기초 데이터와 기초로직을 다루는 공간임 시간당 경비가 없다는건 문제인것같은데 좀더설명 필여
4.조종원이 1인인 14대가 어디서 나온 정보인지 알려줘야 내가 알수 있음.
5. 시간당 계산식을 만들수 없는지.
6. 옛시험은 너가 알아서 수정이나 삭제
@@ -0,0 +1,74 @@
# 요구서 796 — 내 기계 · 기계 양식 (PLAN 821)
근거 = 사용자 요구 790 ~ 796 · 방향 장부 `_방향_불러오기.md` S · T · U 장.
## 0. 원칙
- M01 기계 = 품셈 정본만. 품셈에 없는 기계 = 사용자의 「내 기계」(개인 층).
- 내 기계는 M01 기계처럼 동작 — 먼저 기계를 만들고 · 단가산출을 만들 때 사본에 복사 · 그 뒤 연결 끊김 · [단가 갱신] 으로만 값 반영.
- 사용자 몫 값 = 그 기계 고유 값(가격 · 손료계수 · 연료 소비량 · 잡품율 · 조종원 수). 단가(노임 · 유가 · 전력 · 환율)는 M01 값.
- 매칭 먼저 — 표기 엔진 · 별칭 · 같은 파일 대조로 품셈 기계에 잇고 · 끝내 못 이은 것만 내 기계.
- 기계 만들기 · 고치기는 쉬워야 함 — 같은 양식 하나를 M01 · M02 · 불러오기가 같이 씀.
- 층마다 요소 표 전체를 두지 않음 — 기계만 개인 목록 · 재료는 줄에 든 품목 모아 보기.
## 1. 기계 양식(공통 부품)
- 묻는 칸: 이름 · 규격 / 가격(천원) / 손료계수 / 주연료(종류 고르기 + ℓ/hr) / 잡품 %(주연료의) / 조종원(직종 고르기 + 인) / 전력(kWh · 선택).
- 손료계수 두 방식: 「바로 넣기」(한 칸) · 「품셈 방식」(내용시간 · 연간표준가동시간 · 상각비율 · 정비비율 · 연간관리비율 → 상각비 · 정비비 · 관리비계수 · 손료계수 자동). M01 기본 = 품셈 방식 · 내 기계 기본 = 바로 넣기.
- 꼴은 채운 칸으로 정함(연료 비움 = 손료만 · 조종원 0 = 조종원 없는 식). 구성 없는 기계 = 「시간당 값 직접」(노무비 · 재료비 · 경비).
- 「비슷한 기계에서 시작」: 기계를 찾아 고르면 값 복사(M01 기계 · 내 기계 모두) · 빈 양식도 됨.
- 직종 · 연료 종류는 M01 인력 · 유가 목록에서 고름(자유 글 아님). 숫자 칸은 숫자만 · 범위 벗어나면 경고.
- 옆에 시간당 경비 계산식이 넣는 즉시: 손료 · 주연료 · 잡품 · 조종원 · (전력) · 계 — 브라우저 즉시 셈 · 저장은 서버가 정본으로 다시 셈.
- 단위 · 출처 칸은 프로그램이 채움(M01 = 정본 규칙에 맞는 값 · 내 기계 = 「개인」).
- 같은 이름 · 규격이 있으면 알림 · 지우기 전에 쓰는 곳 보임.
## 2. M01 기계 화면
- 표 = 목록용: 이름 · 규격 · 가격 · 손료계수 · 주연료 · 조종원 · 시간당 경비(전국평균) · 별칭 등 — 단위 · 출처 열 숨김(기계만 · 인력 · 재료 표는 그대로).
- 줄을 누르면 상세 페이지(1 장 양식) — 값 묶음 · 계산식 미리보기 · 별칭 · 쓰는 곳 · [저장] 은 지금 M01 저장 길(시스템 관리자).
- [새 기계] = 양식(비슷한 기계에서 시작 기본) · 빈 줄 추가 방식은 걷음.
## 3. 내 기계(M02 개인 층)
- 저장 = 개인 층에 기계 줄 목록 하나 · 줄 꼴은 M01 기계 줄과 같음 · 키 · 원문번호는 M01 과 안 겹치는 개인 꼴 · 사본 색인에는 기계(EQ)로 얹힘.
- 시간당 경비 식 = M01 기계와 같은 식(조종원 유무 등으로 고름) · 값만 개인 줄.
- 기계 찾기 · 고르기 · 사본 모으기 · [단가 갱신] · 「새 값」 표시가 내 기계도 M01 기계와 같게 동작(단가산출 만들 때 복사 · 고친 뒤 배지 → 갱신 · 지워도 기존 단가산출은 마지막 값).
- 화면: M02 왼쪽 「내 기계」 목록(이름 · 규격 · 시간당 · 쓰는 곳 수 · 넣은 날) → 양식 · 단가산출 좌측 기계 고르기 목록에 「내 기계」 묶음 · 「내 품목」(줄에 든 재료 품목) 모아 보기(읽기).
- 품셈 기계를 「복사해 내 기계로」 가져와 고칠 수 있음(M01 정본은 그대로).
## 4. 불러오기
- 끝내 못 이은 기계 → 내 기계 줄 자동 생성: 그 파일의 구성(EST 원파일 구성표 · 엑셀 기계경비 시트)을 우리 틀 값으로 담음(손료계수 · 가격 · 연료 ℓ · 잡품 % · 조종원 인 · 직종은 우리 직종에 이음) · 구성 없는 파일(STmate 원파일)은 「시간당 값 직접」.
- 같은 이름 · 규격 내 기계가 이미 있으면 그것을 씀(새로 안 만듦).
- 불러오기 결과에 「내 기계 생성 n」 표시 · 사용자는 양식으로 확인 · 고침.
- 금액(엑셀 단가 그대로) 100 % 그대로 · [우리 단가로] 는 내 기계 값으로 셈.
## 5. 정리
- 820-1 의 기계 13 줄(EQ000656 ~ 668) · 파워트로웰 식(GC001195) · 계산식 목록에 끼운 것 · 기술문서 절 = 지움(키는 결번 · 재사용 안 함). 덤프트럭 1 톤(EQ000655) · 체인톱 시간당 식 · 기계 별칭 10 은 둠.
- 다음 장 후보였던 데이터 흐름 파워트로웰 흠은 같이 없어짐.
## 6. 걸음(PLAN 821)
| 걸음 | 창 | 내용 | 먼저 있어야 할 것 |
|---|---|---|---|
| 821-1 | sub3 | 내 기계 저장 · API · 찾기 · 시간당 · 사본 · 단가 갱신이 개인 줄 읽기 | — |
| 821-2 | sub1 | 계수 계산 짝(파이썬 · TS) · 기계 양식 부품 · M01 표 열 줄이기 · 상세 페이지 · 새 기계 | — |
| 821-3 | sub1 | M02 내 기계 목록 · 양식 · 기계 고르기에 내 기계 · 내 품목 모아 보기 | 821-1 · 821-2 |
| 821-4 | sub3 | 불러오기 자동 생성 · 「내 기계 생성 n」 · 13 줄 정리 · 원천 셋 전후 전수 | 821-1 |
## 7. 두 창의 약속
- 내 기계 줄 값 칸 이름 = M01 기계 줄과 같음(가격 · 내용시간 · 연간표준가동시간 · 상각비율 · 정비비율 · 연간관리비율 · 상각비계수 · 정비비계수 · 관리비계수 · 손료계수 · 주연료 · 주연료종류 · 잡재료율 · 조종원 · 조종원직종 · 전력) · 「바로 넣기」 = 손료계수만 · 「시간당 값 직접」 = 따로 한 칸 묶음.
- 계수 계산 짝 = sub1 이 먼저 만들어 push(파이썬 `resources/master_data/scripts/master_machine_coef.py` · TS `common_util/common_util_machine_coef.ts` · 거울 시험 · 품셈 표 값 전수 대조) → sub3 은 저장 때 파이썬 것을 씀.
- 내 기계 API 길 · 키 꼴은 sub3 이 정해 PLAN 821 절 아래 한 줄로 먼저 적음 → sub1 821-3 이 그것을 씀.
- M01 정본(`기계.json`) 줄 내용은 821-4 의 13 줄 지우기 말고는 안 고침.
## 8. 완료 기준(걸음마다 자기 검증)
- 고친 대상 시험 + `npm run typecheck` 0 + ORCA 자기 포트 사용자 길(만들기 · 고치기 · 단가산출에 넣기 · 고친 뒤 배지 → 단가 갱신 · 지우기 · 빈 값 · 같은 이름 · 권한 없음 · 밝음 · 어두움) · 만든 것 되돌림.
- 821-4: 원천 셋(EST · STmate · 엑셀) 전후 표 — 금액 100 % · 기계 이음(품셈 기계 + 내 기계) · 블록 「모두 우리 값」.
## 9. 안 하는 것
- 재료 개인 표 · 회사 층 화면 · 마스터 승격 제안 흐름 · 화면 속도 · 설명서 고침.
+10
View File
@@ -578,6 +578,16 @@ app.include_router(
__import__("M02_MasterTemplete.M02_Sangeun_Router", fromlist=["router"]).router,
dependencies=protected,
)
# 내 기계(PLAN 821-1) — 로그인 · 개인 층 권한은 길 안에서
app.include_router(
__import__("M02_MasterTemplete.M02_MyMachine_Router", fromlist=["router"]).router,
dependencies=protected,
)
# 내 기계 화면 읽기(PLAN 821-3) — 양식 목록 · 비슷한 기계 찾기 · 내 품목 · 로그인 · 층 권한은 길 안에서
app.include_router(
__import__("M02_MasterTemplete.M02_MyMachine_Look", fromlist=["router"]).router,
dependencies=protected,
)
# 단가산출(SK) 수식 문서 길(PLAN 802-4a) — 로그인 · 층 권한은 길 안에서
app.include_router(
__import__("M02_MasterTemplete.M02_Doc_Router", fromlist=["router"]).router,
@@ -97,21 +97,6 @@ last_updated: 2026-08-15
| 환율·유가 원천과 스냅샷 | ECOS·오피넷 실사 2026-08-14 | [원가_기초데이터_원천 §4](원가_기초데이터_원천.md) |
| ESTX 기계경비 조립 관측 | 2026-07 배포본 재검증 | [원가_기초데이터_원천 §3](원가_기초데이터_원천.md) |
## 7. 품셈에 없는 소형·보조 기계 — EST 구성 기준 · 가격 갱신 필요
## 7. 품셈에 없는 기계 = 사용자 내 기계(요구서 796)
현행 품셈 기계 목록(발전기 25㎾~ 등)에 없는 기계를 EST 단가 구성대로 우리 기계표에 둔 줄. 손료계수 · 연료 · 잡품 · 조종원 = 품셈 값(같은 계열 차용) · 가격 = EST 씨앗(임시) — 조달청 · 행안부 건설기계 표준시장가격 · 제조사 값으로 바꿀 것.
| 기종 | 규격 | 손료계수(10⁻⁷/hr) | 연료 · 잡품 · 전력 | 조종원 | 가격 씨앗(천원) | 시간당 식 | 출처 |
|---|---|---|---|---|---|---|---|
| 발전기 | 5.5㎾ | 2,362(발전기 계열) | 경유 0.625ℓ · 11 % | 일반기계운전사 1 | 1,063 | GC000268 | EST · 2016 사방품셈(안) 같은 꼴 |
| 발전기 | 7㎾ | 2,362 | — | — | 1,828.9 | GC000273(손료만) | 2016 사방품셈(안) 기계경비 |
| 발전기 | 10㎾ | 2,362 | — | — | 3,213 | GC000273 | 2016 사방품셈(안) 기계경비 |
| 대형 브레이커 | 0.18㎥ | 6,601(0.2㎥ 계수) | — | — | 4,559(0.2㎥ 값) | GC000273 | 굴착기 부속 · 우리 0.2㎥ 줄 |
| 에어 호스 | 1.91㎝ | 5,625 | — | — | 105 | GC000273 | EST |
| 모노펌프 | 5-20㎥/hr | 4,346 | — | — | 4,900 | GC000273 | EST |
| 파워트로웰 | 3.73㎾ | 5,313 | 휘발유 1.0ℓ · 10 % · 전력 3.73㎾h | — | 2,680 | GC001195 | EST |
| 콘크리트펌프용 파이프(압송관) | 150㎜ × 1 · 2 · 3m · 곡관 45 · 60 · 90° | 4,500 | — | — | $25 · 37 · 46 · 67 · 84 · 103 × 환율 | GC000273 | EST($ 표기) · 환율 FX000001 |
- 손료 = 버림(가격 × 1,000 × 손료계수 ÷ 10⁷) · 조종원 = 하루 노임 × 1/8(품셈 본문 · EST 의 16/12 · 25/20 뺌).
- 파워트로웰 잡품 = 주연료 줄의 % (전력 줄엔 안 붙임).
- 파이프 가격 = 달러 표기 → 환율 적용값 · 원화 갱신 필요.
품셈 기계표(M01)는 품셈 정본만 둠. 품셈에 없는 기계(발전기 5.5 · 7 · 10㎾ · 소형 브레이커 · 에어 호스 · 모노펌프 · 파워트로웰 · 압송관 등)는 사용자 개인 층의 「내 기계」 — 가격 · 손료계수 · 주연료 ℓ · 잡품 % · 조종원 인 · 직종(우리 노임) · 전력을 사용자가 넣고 시간당 경비는 M01 기계와 같은 식(손료 = 버림(가격 × 1,000 × 손료계수 ÷ 10⁷) · 조종원 = 하루 노임 × 1/8)으로 셈. 경쟁사 불러오기에서 끝내 못 이은 기계는 그 파일 구성(EST 원파일 구성표 · 엑셀 기계경비 · STmate 중기사용료)으로 내 기계를 자동으로 만들고(경쟁사 조종원 노임 환산 ×16/12 · ×25/20 은 안 가져옴 · 달러 표기 = 환율 적용 · 비고 「값 확인 필요」) · 구성 없는 파일은 시간당 값을 그대로 담음.
File diff suppressed because one or more lines are too long
@@ -1,241 +0,0 @@
"""품셈에 없는 소형 · 보조 기계를 우리 기계표 줄로(PLAN 820-1 · 요구 785 ~ 787 · 방향 장부 Q1 · R).
EST 단가 구성 → 우리 줄 — 손료계수 · 연료 · 잡품 · 조종원 = 품셈 값(발전기 계열 2,362 · 대형 브레이커 6,601) ·
가격 = EST 씨앗(출처 「자체」 · 비고 「갱신 필요」) · 콘크리트펌프용 파이프 = 달러값 × 한국은행 환율(FX000001).
계산식:
손료만(브레이커 0.18 · 에어 호스 · 모노펌프 · 파이프 · 발전기 7 · 10㎾) = GC000273 고르기에 더함
발전기 5.5㎾(경유 · 잡품 · 일반기계운전사) = GC000268 고르기에 더함
→ 고르기 더하기는 `build_기계목록_빠진기계_814_1`(로직 · 수식 문서 목록 같이)
파워트로웰(휘발유 · 잡품 · 전력 · 조종원 없음) = 새 기계경비식 GC001195 — 기계 줄 값을 바로 읽음
(`M02_Machine_Hourly._own` 이 고름) · 잡품 = 주연료 줄의 % (전력 줄엔 안 붙임)
더하기만 — 다른 줄 · 서식 그대로 · 두 번 돌려도 같음.
쓰기: venv python resources/master_data/scripts/build_기계_소형보조_820_1.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
DATA = HERE.parent
ROOT = DATA.parents[1]
MACHINE = DATA / "기계.json"
BOOK = DATA / "_키대장.json"
LOGIC = DATA / "로직_건설품셈_08장_건설기계.json"
DOC = DATA / "수식문서" / "수식_건설품셈_08장_건설기계.json"
USD = 1414.9 # 원/USD — 한국은행 환율 FX000001(2026-08-14)
SEED = "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요)"
GEN = {"상각비계수": 1125, "정비비계수": 563, "관리비계수": 674, "손료계수": 2362} # 발전기 계열
BRK = {"상각비계수": 3000, "정비비계수": 2833, "관리비계수": 768, "손료계수": 6601} # 0230-0002
UNIT = {
"가격": "천원", "상각비계수": "10^-7/시간", "정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간", "손료계수": "10^-7/시간", "주연료": "ℓ/hr", "주연료종류": "",
"잡재료율": "주연료의 %", "조종원": "인/일", "조종원직종": "", "전력": "㎾h/hr",
} # fmt: skip
FIX_SRC = {"발전기": "건설품셈 공통 8-3-8", "브레이커": "건설품셈 공통 8-3-1"}
WAGE = "건설품셈 공통 8-1-3 5 · 건설노임 1050"
SAB = "2016 사방사업표준품셈(안) 기계경비"
PIPE = {"150㎜×1m": 25, "150㎜×2m": 37, "150㎜×3m": 46,
"곡관 150㎜ 45°": 67, "곡관 150㎜ 60°": 84, "곡관 150㎜ 90°": 103} # fmt: skip
def _row(n, gubun, detail, name, spec, value, note, fix=None):
src = {k: (FIX_SRC[fix] if fix and k in GEN else "자체") for k in value}
if "조종원직종" in value:
src["조종원직종"] = WAGE
return {
"키": f"EQ{n:06d}",
"원문번호": f"자체 {name} {spec}",
"원문": "건설품셈",
"구분": gubun,
"상세구분": detail,
"이름": name,
"규격": spec,
"단위": {k: UNIT[k] for k in value},
"값": value,
"출처": src,
"비고": f"{SEED} · {note}",
}
def rows() -> list[dict]:
g, c, t = "[70]기타기계", "[40]콘크리트기계", "[00]토공기계"
fuel5 = {
"주연료": 0.625,
"주연료종류": "경유",
"잡재료율": 11,
"조종원": 1,
"조종원직종": "1050",
}
out = [
_row(656, g, "발전기", "발전기", "5.5㎾", {"가격": 1063, **GEN, **fuel5},
"EST 계 37,760 · 주연료 · 잡품 · 조종원(일반기계운전사) = " + SAB + "과 같음", "발전기"),
_row(657, g, "발전기", "발전기", "7㎾", {"가격": 1828.9, **GEN},
f"가격 = {SAB}(1,828,900 원) · 그 표 = 손료만(연료 · 조종원 없음)", "발전기"),
_row(658, g, "발전기", "발전기", "10㎾", {"가격": 3213, **GEN},
f"가격 = {SAB}(3,213,000 원) · 그 표 = 손료만(연료 · 조종원 없음)", "발전기"),
_row(659, t, "브레이커", "대형 브레이커", "0.18㎥", {"가격": 4559, **BRK},
"손료만(굴착기 부속) · 가격 = 0.2㎥(0230-0002) 값 · 계수 = 0.2㎥ 계수", "브레이커"),
_row(660, g, "에어 호스", "에어 호스", "1.91㎝", {"가격": 105, "손료계수": 5625},
"손료만 · EST 계 59"),
_row(661, g, "모노펌프", "모노펌프", "5-20㎥/hr", {"가격": 4900, "손료계수": 4346},
"손료만 · EST 계 2,129"),
_row(662, c, "파워트로웰", "파워트로웰", "3.73㎾",
{"가격": 2680, "손료계수": 5313, "주연료": 1.0, "주연료종류": "휘발유", "잡재료율": 10, "전력": 3.73},
"주연료 휘발유 · 잡품 · 전력 3.73 ㎾h · 조종원 없음 · EST 계 3,858 · 시간당 경비 = GC001195"),
] # fmt: skip
for i, (spec, usd) in enumerate(PIPE.items()):
out.append(
_row(663 + i, c, "콘크리트펌프용 파이프", "콘크리트펌프용 파이프", spec,
{"가격": round(usd * USD / 1000, 4), "손료계수": 4500},
f"압송관 · 손료만 · ${usd} 표기 · 환율 적용({USD:,} 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요")
) # fmt: skip
return out
EQ = "EQ000662"
LOGIC_ROW = {
"키": "GC001195",
"원문번호": "8",
"구분": "건설품셈 공통",
"상세구분": "08장 건설기계",
"이름": "파워트로웰 시간당 기계경비",
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"부품": {"갈래": "기계경비식"},
"소유": "공용",
"비고": f"품셈에 없는 기계 — {SEED} · 기계표 {EQ} 파워트로웰 3.73㎾ 값을 읽음 · 손료 = 가격 × 손료계수 · 잡품 = 주연료 줄의 % · 전력 = 기계표 전력 칸 · 조종원 없음",
"입력": [
{
"이름": "지역",
"고르기": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"],
"출처": "건설품셈 공통 8-1-7 5",
},
{
"이름": "전력 계약종별",
"고르기": ["주택용 저압", "일반용(갑)Ⅰ 저압"],
"출처": "유가전력 한전 전기요금표 2025-01-01",
"비고": "품셈·계약예규에 계약종별 규정 없음(전력비는 실비 계상) · 임시전력(갑)은 주택용 저압 요금 그대로(요금표 6.가)",
},
],
"중간": [
{"이름": "손료", "식": f"버림({EQ}.가격 * 1000 * {EQ}.손료계수 / 10000000, 0)", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4"},
],
"호표": [
{"종류": "재료", "요소": "MO:휘발유:{지역}", "이름": "주연료", "단위": "ℓ", "수량": f"{EQ}.주연료", "비목": "재료비", "출처": "건설품셈 공통 8-4-1"},
{"종류": "재료", "요소": {"이름": "전력량요금", "계약종별": "입력"}, "이름": "전력", "단위": "㎾h", "수량": f"{EQ}.전력", "비목": "재료비", "출처": "자체"},
],
"덧줄": [
{"이름": "잡재료비", "식": f"줄.'주연료' * {EQ}.잡재료율 / 100", "비목": "재료비", "출처": "건설품셈 공통 8-4 주②"},
{"이름": "기계손료", "식": "손료", "비목": "경비", "출처": "건설품셈 공통 8-1-6 2"},
],
"끝수": "손료 원 미만 버림(건설품셈 공통 8-1-5 12) · 합계 원문 규정 없음",
"변수표": {
"지역": {"기호": "S", "아래": "지역"},
"전력 계약종별": {"기호": "S", "아래": "전력계약종별"},
"손료": {"기호": "C", "아래": "손료"},
},
} # fmt: skip
def _block(obj: dict, nl: str) -> str:
"""줄 하나 — 두 칸 들여쓴 JSON 을 넷 칸 자리에."""
text = json.dumps(obj, ensure_ascii=False, indent=2)
return nl.join(" " + line for line in text.split("\n"))
def _insert_tail(path: Path, objs: list[dict]) -> None:
"""「줄」 목록 끝(`]` 앞)에 더함 — 이미 있는 키는 건너뜀."""
text = path.read_text("utf-8")
have = {r["키"] for r in json.loads(text)["줄"]}
new = [o for o in objs if o["키"] not in have]
if not new:
return
nl = "\r\n" if "\r\n" in text else "\n"
close = text.rstrip().rindex("]")
head = text[:close].rstrip()
body = "".join(f",{nl}{_block(o, nl)}" for o in new)
out = head + body + nl + " " + text[close:]
json.loads(out)
path.write_text(out, "utf-8", newline="")
def _insert_after(path: Path, after_key: str, obj: dict) -> None:
"""키 `after_key` 블록 뒤에 줄 하나 — 이미 있으면 건너뜀."""
text = path.read_text("utf-8")
if f'"키": "{obj["키"]}"' in text:
return
nl = "\r\n" if "\r\n" in text else "\n"
data = json.loads(text)
at = text.index(f'"키": "{after_key}"')
nxt = [r["키"] for r in data["줄"]]
follow = nxt[nxt.index(after_key) + 1]
cut = text.rindex("{", 0, text.index(f'"키": "{follow}"'))
cut = text.rindex(",", at, cut) + 1
out = text[:cut] + nl + _block(obj, nl) + "," + text[cut:]
json.loads(out)
path.write_text(out, "utf-8", newline="")
def _book(machines: list[dict]) -> None:
text = BOOK.read_text("utf-8")
nl = "\r\n" if "\r\n" in text else "\n"
adds = [(r["키"], r["원문번호"], "기계.json", "EQ000655") for r in machines]
adds.append(("GC001195", "공통 8 파워트로웰 시간당 경비", LOGIC.name, "GC001194"))
for key, number, file, after in adds:
if f'"{key}":' in text:
continue
line = f' "{key}": {json.dumps({"원문번호": number, "파일": file}, ensure_ascii=False)},'
prev = max((k for k, *_ in adds if k[:2] == key[:2] and f'"{k}":' in text), default=after)
at = text.index(f' "{prev}":')
end = text.index(nl, at) + len(nl)
text = text[:end] + line + nl + text[end:]
book = json.loads(text)
nums = {"EQ": 669, "GC": 1196}
for tid, n in nums.items():
old = book["다음"][tid]
if old < n:
text = text.replace(f'"{tid}": {old},', f'"{tid}": {n},', 1)
json.loads(text)
BOOK.write_text(text, "utf-8", newline="")
def _doc() -> None:
"""GC001195 수식 문서 — 옛 로직 변환(`master_doc_convert`) · 파일 끝에 더함."""
for p in (str(ROOT), str(HERE)):
if p not in sys.path:
sys.path.insert(0, p)
import master_doc_convert as cv
import master_doc_link as ml
from M01_MasterData import M01_MasterData_Store as m01
data = json.loads(DOC.read_text("utf-8"))
if any(d.get("키") == "GC001195" for d in data["줄"]):
return
files, whole = m01.loaded()
doc, notes = cv.convert(whole, LOGIC_ROW)
if notes:
print("변환 알림:", notes)
data["줄"].append(doc)
DOC.write_text(ml.dump(data), "utf-8", newline="")
def main() -> None:
machines = rows()
_insert_tail(MACHINE, machines)
_book(machines)
_insert_after(LOGIC, "GC000276", LOGIC_ROW)
_doc()
import build_기계목록_빠진기계_814_1 as b814
b814.main()
if __name__ == "__main__":
sys.path.insert(0, str(HERE))
main()
@@ -0,0 +1,78 @@
"""기계 손료계수 · 시간당 경비 미리보기(PLAN 821-2 · 요구서 796 1 장).
⚠ 짝: `common_util/common_util_machine_coef.ts` — 같은 셈 · 같은 차례(거울 시험
`resources/tester/test_821_2_machine_coef.py`). 한쪽만 고치면 화면과 저장이 갈림.
계수(건설품셈 공통 8-1-5 · 시간당 10^-7):
상각비계수 = 상각비율 ÷ 내용시간 · 정비비계수 = 정비비율 ÷ 내용시간 ·
관리비계수 = 연간관리비율 × 평균취득가격 비율 ÷ 연간표준가동시간 ·
평균취득가격 비율 = (1.1 N + 0.9) ÷ 2N · N = 내용시간 ÷ 연간표준가동시간 ·
계수마다 정수로 반올림 · 손료계수 = 셋의 합.
미리보기 = 서버 기계경비식(GC000267 ~)을 기본 옵션(보정 없음)으로 부른 값과 같은 끝수 —
손료 = 가격(천원) × 1000 × 손료계수 ÷ 10^7 원 미만 버림 · 줄 금액은 0.1 원 아래 버림 ·
잡품 = 버린 주연료 금액 × 잡재료율 % · 비목 합은 원 미만 버림(B09 끝수 `master_cut.engine`).
정본은 서버 식(`M02_Machine_Hourly`) — 이것은 화면 즉시 셈용.
"""
from __future__ import annotations
import math
INPUTS = ("내용시간", "연간표준가동시간", "상각비율", "정비비율", "연간관리비율")
COEFS = ("상각비계수", "정비비계수", "관리비계수", "손료계수")
_E = 1e-6 # 끊는 경계 부동소수 흔들림 막음
def _num(v) -> float:
return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else 0.0
def _half_up(x: float) -> int:
return math.floor(x + 0.5 + _E)
def _tenth(x: float) -> float:
"""줄 금액 = 0.1 원 아래 버림(B09 끝수 줄 자리)."""
return math.floor(x * 10 + _E) / 10
def coef(life, yearly, dep, rep, mgmt) -> dict | None:
"""(내용시간 · 연간표준가동시간 · 상각 · 정비 · 연간관리 비율) → 계수 넷 · 못 셈 = None."""
life, yearly, dep, rep, mgmt = (_num(x) for x in (life, yearly, dep, rep, mgmt))
if life <= 0 or yearly <= 0:
return None
n = life / yearly
s = _half_up(dep / life * 1e7)
j = _half_up(rep / life * 1e7)
g = _half_up(mgmt * (1.1 * n + 0.9) / (2 * n) / yearly * 1e7)
return {"상각비계수": s, "정비비계수": j, "관리비계수": g, "손료계수": s + j + g}
def coef_of(values: dict) -> dict | None:
"""값 묶음(기계 줄 `값`)에서 품셈 입력 다섯이 다 수일 때만 계수 넷."""
if not all(isinstance(values.get(k), (int, float)) for k in INPUTS):
return None
return coef(*(values[k] for k in INPUTS))
def preview(
values: dict, fuel: float, wage: float, power: float = 0.0, hour: float = 1 / 8
) -> dict:
"""값 묶음 + 유가(원/ℓ) · 노임(원/일) · 전력(원/kWh) · 시간환산 → 시간당 줄 · 비목 · 계."""
loss = math.floor(_num(values.get("가격")) * 1000 * _num(values.get("손료계수")) / 1e7 + _E)
oil = _tenth(_num(values.get("주연료")) * _num(fuel))
misc = _tenth(oil * _num(values.get("잡재료율")) / 100)
elec = _tenth(_num(values.get("전력")) * _num(power))
crew = _tenth(_num(values.get("조종원")) * hour * _num(wage))
labor, material = math.floor(crew + _E), math.floor(oil + misc + elec + _E)
return {
"손료": loss,
"주연료": oil,
"잡품": misc,
"전력": elec,
"조종원": crew,
"노무비": labor,
"재료비": material,
"경비": loss,
"계": labor + material + loss,
}
+3 -353
View File
@@ -33404,10 +33404,9 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"값원문": {
"8-4-8 주연료": "휘발유1"
},
"비고": "주연료종류: 원문 글을 가름"
"값원문": { "8-4-8 주연료": "휘발유1" },
"비고": "주연료종류: 원문 글을 가름",
"별칭": "파워트로웰"
},
{
"키": "EQ000535",
@@ -39717,355 +39716,6 @@
"가격": "산림품셈 10-9"
},
"비고": "원문 이름: 4륜구동 1톤 덤프트럭 · 중기가격 = 조달청 등재가격(기본값 28,000 천원 = EST 2026-07 「덤프트럭 1.0Ton」 · 바꿀 수 있음) · 작업량은 건설품셈 · 손료 및 운전경비 = 2.5톤 덤프트럭(0602-0025) · 주 연료 = 2.5톤의 80% · 시간당 경비 = GF000145"
},
{
"키": "EQ000656",
"원문번호": "자체 발전기 5.5㎾",
"원문": "건설품셈",
"구분": "[70]기타기계",
"상세구분": "발전기",
"이름": "발전기",
"규격": "5.5㎾",
"단위": {
"가격": "천원",
"상각비계수": "10^-7/시간",
"정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간",
"손료계수": "10^-7/시간",
"주연료": "ℓ/hr",
"주연료종류": "",
"잡재료율": "주연료의 %",
"조종원": "인/일",
"조종원직종": ""
},
"값": {
"가격": 1063,
"상각비계수": 1125,
"정비비계수": 563,
"관리비계수": 674,
"손료계수": 2362,
"주연료": 0.625,
"주연료종류": "경유",
"잡재료율": 11,
"조종원": 1,
"조종원직종": "1050"
},
"출처": {
"가격": "자체",
"상각비계수": "건설품셈 공통 8-3-8",
"정비비계수": "건설품셈 공통 8-3-8",
"관리비계수": "건설품셈 공통 8-3-8",
"손료계수": "건설품셈 공통 8-3-8",
"주연료": "자체",
"주연료종류": "자체",
"잡재료율": "자체",
"조종원": "자체",
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1050"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · EST 계 37,760 · 주연료 · 잡품 · 조종원(일반기계운전사) = 2016 사방사업표준품셈(안) 기계경비과 같음"
},
{
"키": "EQ000657",
"원문번호": "자체 발전기 7㎾",
"원문": "건설품셈",
"구분": "[70]기타기계",
"상세구분": "발전기",
"이름": "발전기",
"규격": "7㎾",
"단위": {
"가격": "천원",
"상각비계수": "10^-7/시간",
"정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 1828.9,
"상각비계수": 1125,
"정비비계수": 563,
"관리비계수": 674,
"손료계수": 2362
},
"출처": {
"가격": "자체",
"상각비계수": "건설품셈 공통 8-3-8",
"정비비계수": "건설품셈 공통 8-3-8",
"관리비계수": "건설품셈 공통 8-3-8",
"손료계수": "건설품셈 공통 8-3-8"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 가격 = 2016 사방사업표준품셈(안) 기계경비(1,828,900 원) · 그 표 = 손료만(연료 · 조종원 없음)"
},
{
"키": "EQ000658",
"원문번호": "자체 발전기 10㎾",
"원문": "건설품셈",
"구분": "[70]기타기계",
"상세구분": "발전기",
"이름": "발전기",
"규격": "10㎾",
"단위": {
"가격": "천원",
"상각비계수": "10^-7/시간",
"정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 3213,
"상각비계수": 1125,
"정비비계수": 563,
"관리비계수": 674,
"손료계수": 2362
},
"출처": {
"가격": "자체",
"상각비계수": "건설품셈 공통 8-3-8",
"정비비계수": "건설품셈 공통 8-3-8",
"관리비계수": "건설품셈 공통 8-3-8",
"손료계수": "건설품셈 공통 8-3-8"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 가격 = 2016 사방사업표준품셈(안) 기계경비(3,213,000 원) · 그 표 = 손료만(연료 · 조종원 없음)"
},
{
"키": "EQ000659",
"원문번호": "자체 대형 브레이커 0.18㎥",
"원문": "건설품셈",
"구분": "[00]토공기계",
"상세구분": "브레이커",
"이름": "대형 브레이커",
"규격": "0.18㎥",
"단위": {
"가격": "천원",
"상각비계수": "10^-7/시간",
"정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 4559,
"상각비계수": 3000,
"정비비계수": 2833,
"관리비계수": 768,
"손료계수": 6601
},
"출처": {
"가격": "자체",
"상각비계수": "건설품셈 공통 8-3-1",
"정비비계수": "건설품셈 공통 8-3-1",
"관리비계수": "건설품셈 공통 8-3-1",
"손료계수": "건설품셈 공통 8-3-1"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 손료만(굴착기 부속) · 가격 = 0.2㎥(0230-0002) 값 · 계수 = 0.2㎥ 계수"
},
{
"키": "EQ000660",
"원문번호": "자체 에어 호스 1.91㎝",
"원문": "건설품셈",
"구분": "[70]기타기계",
"상세구분": "에어 호스",
"이름": "에어 호스",
"규격": "1.91㎝",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 105,
"손료계수": 5625
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 손료만 · EST 계 59"
},
{
"키": "EQ000661",
"원문번호": "자체 모노펌프 5-20㎥/hr",
"원문": "건설품셈",
"구분": "[70]기타기계",
"상세구분": "모노펌프",
"이름": "모노펌프",
"규격": "5-20㎥/hr",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 4900,
"손료계수": 4346
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 손료만 · EST 계 2,129"
},
{
"키": "EQ000662",
"원문번호": "자체 파워트로웰 3.73㎾",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "파워트로웰",
"이름": "파워트로웰",
"규격": "3.73㎾",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간",
"주연료": "ℓ/hr",
"주연료종류": "",
"잡재료율": "주연료의 %",
"전력": "㎾h/hr"
},
"값": {
"가격": 2680,
"손료계수": 5313,
"주연료": 1.0,
"주연료종류": "휘발유",
"잡재료율": 10,
"전력": 3.73
},
"출처": {
"가격": "자체",
"손료계수": "자체",
"주연료": "자체",
"주연료종류": "자체",
"잡재료율": "자체",
"전력": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 주연료 휘발유 · 잡품 · 전력 3.73 ㎾h · 조종원 없음 · EST 계 3,858 · 시간당 경비 = GC001195"
},
{
"키": "EQ000663",
"원문번호": "자체 콘크리트펌프용 파이프 150㎜×1m",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "150㎜×1m",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 35.3725,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $25 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
},
{
"키": "EQ000664",
"원문번호": "자체 콘크리트펌프용 파이프 150㎜×2m",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "150㎜×2m",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 52.3513,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $37 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
},
{
"키": "EQ000665",
"원문번호": "자체 콘크리트펌프용 파이프 150㎜×3m",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "150㎜×3m",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 65.0854,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $46 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
},
{
"키": "EQ000666",
"원문번호": "자체 콘크리트펌프용 파이프 곡관 150㎜ 45°",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "곡관 150㎜ 45°",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 94.7983,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $67 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
},
{
"키": "EQ000667",
"원문번호": "자체 콘크리트펌프용 파이프 곡관 150㎜ 60°",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "곡관 150㎜ 60°",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 118.8516,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $84 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
},
{
"키": "EQ000668",
"원문번호": "자체 콘크리트펌프용 파이프 곡관 150㎜ 90°",
"원문": "건설품셈",
"구분": "[40]콘크리트기계",
"상세구분": "콘크리트펌프용 파이프",
"이름": "콘크리트펌프용 파이프",
"규격": "곡관 150㎜ 90°",
"단위": {
"가격": "천원",
"손료계수": "10^-7/시간"
},
"값": {
"가격": 145.7347,
"손료계수": 4500
},
"출처": {
"가격": "자체",
"손료계수": "자체"
},
"비고": "EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 압송관 · 손료만 · $103 표기 · 환율 적용(1,414.9 원/USD · 한국은행 2026-08-14 FX000001) 또는 원화 갱신 필요"
}
]
}
@@ -284,8 +284,7 @@
"9090-1300",
"9090-1400",
"9090-1500",
"9090-1600",
"자체 발전기 5.5㎾"
"9090-1600"
]
},
{
@@ -1089,18 +1088,7 @@
"9080-1500",
"9080-1750",
"9080-2000",
"9080-3000",
"자체 발전기 7㎾",
"자체 발전기 10㎾",
"자체 대형 브레이커 0.18㎥",
"자체 에어 호스 1.91㎝",
"자체 모노펌프 5-20㎥/hr",
"자체 콘크리트펌프용 파이프 150㎜×1m",
"자체 콘크리트펌프용 파이프 150㎜×2m",
"자체 콘크리트펌프용 파이프 150㎜×3m",
"자체 콘크리트펌프용 파이프 곡관 150㎜ 45°",
"자체 콘크리트펌프용 파이프 곡관 150㎜ 60°",
"자체 콘크리트펌프용 파이프 곡관 150㎜ 90°"
"9080-3000"
]
}
],
@@ -1416,114 +1404,6 @@
"시간환산": { "기호": "Q", "아래": "시간환산" }
}
},
{
"키": "GC001195",
"원문번호": "8",
"구분": "건설품셈 공통",
"상세구분": "08장 건설기계",
"이름": "파워트로웰 시간당 기계경비",
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"부품": {
"갈래": "기계경비식"
},
"소유": "공용",
"비고": "품셈에 없는 기계 — EST 단가 구성 → 우리 줄 · 손료계수·연료 = 품셈 값 · 가격 = EST 씨앗(갱신 필요) · 기계표 EQ000662 파워트로웰 3.73㎾ 값을 읽음 · 손료 = 가격 × 손료계수 · 잡품 = 주연료 줄의 % · 전력 = 기계표 전력 칸 · 조종원 없음",
"입력": [
{
"이름": "지역",
"고르기": [
"전국평균",
"전국",
"서울",
"경기",
"강원",
"충북",
"충남",
"전북",
"경북",
"경남",
"부산",
"제주",
"대구",
"인천",
"대전",
"울산",
"세종",
"전남광주"
],
"출처": "건설품셈 공통 8-1-7 5"
},
{
"이름": "전력 계약종별",
"고르기": [
"주택용 저압",
"일반용(갑)Ⅰ 저압"
],
"출처": "유가전력 한전 전기요금표 2025-01-01",
"비고": "품셈·계약예규에 계약종별 규정 없음(전력비는 실비 계상) · 임시전력(갑)은 주택용 저압 요금 그대로(요금표 6.가)"
}
],
"중간": [
{
"이름": "손료",
"식": "버림(EQ000662.가격 * 1000 * EQ000662.손료계수 / 10000000, 0)",
"출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4"
}
],
"호표": [
{
"종류": "재료",
"요소": "MO:휘발유:{지역}",
"이름": "주연료",
"단위": "ℓ",
"수량": "EQ000662.주연료",
"비목": "재료비",
"출처": "건설품셈 공통 8-4-1"
},
{
"종류": "재료",
"요소": {
"이름": "전력량요금",
"계약종별": "입력"
},
"이름": "전력",
"단위": "㎾h",
"수량": "EQ000662.전력",
"비목": "재료비",
"출처": "자체"
}
],
"덧줄": [
{
"이름": "잡재료비",
"식": "줄.'주연료' * EQ000662.잡재료율 / 100",
"비목": "재료비",
"출처": "건설품셈 공통 8-4 주②"
},
{
"이름": "기계손료",
"식": "손료",
"비목": "경비",
"출처": "건설품셈 공통 8-1-6 2"
}
],
"끝수": "손료 원 미만 버림(건설품셈 공통 8-1-5 12) · 합계 원문 규정 없음",
"변수표": {
"지역": {
"기호": "S",
"아래": "지역"
},
"전력 계약종별": {
"기호": "S",
"아래": "전력계약종별"
},
"손료": {
"기호": "C",
"아래": "손료"
}
}
},
{
"키": "GC001143",
"원문번호": "8-1-4",
@@ -62,7 +62,7 @@
"갈래": "기계경비식"
},
"변수": [
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["1730-0015", "7204-0055", "0602-0150", "2702-0020", "0101-0007", "0101-0010", "0101-0012", "0101-0019", "0101-0032", "2105-0005", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "4504-0032", "4504-0036", "4504-0041", "4504-0043", "4504-0047", "4504-0052", "4205-0010", "4205-0017", "4205-0020", "4205-0030", "4205-0040", "4205-0045", "2105-0002", "2105-0003", "2105-0010", "2105-0015", "2105-0018", "2502-0025", "0602-0025", "2502-0050", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "7204-0018", "7204-0160", "5205-0210", "5405-0110", "7505-0450", "5205-0255", "7505-0150", "5205-0103", "6701-0147", "6701-0184", "5205-0170", "7505-0350", "0302-0134", "7505-0100", "7505-0050", "5205-0071", "7505-0025", "0502-0036", "1306-0100", "1506-0032", "0121-0004", "1305-0007", "7204-0038", "7204-0065", "0302-0057", "5701-0020", "0302-0095", "3201-0003", "1106-0012", "1406-0015", "1206-0008", "3601-0102", "1306-0025", "0211-0018", "0602-0045", "0602-0060", "0602-0105", "1630-0080", "3901-0300", "7505-0250", "2502-0035", "1306-0120", "1106-0010", "3201-0001", "1406-0008", "3601-0202", "3601-0402", "3611-0142", "2502-0020", "2502-0075", "5205-0035", "2702-0030", "2702-0060", "0211-0060", "0211-0080", "0211-0100", "0221-0040", "0221-0070", "0302-0025", "0302-0172", "0302-0229", "0302-0287", "0302-0350", "0302-0500", "0406-0054", "0406-0115", "0406-0161", "0406-0206", "0503-0036", "0602-0200", "0602-0240", "0602-0320", "1106-0015", "1206-0010", "1206-0014", "1209-0001", "1209-0002", "1209-0004", "1209-0006", "1209-0007", "1209-0008", "1209-0013", "1306-0044", "1306-0060", "1406-0025", "1506-0011", "1506-0012", "1506-0015", "1506-0019", "1506-0025", "1506-0030", "1506-0037", "2602-0015", "2602-0025", "2602-0035", "2602-0045", "2702-0040", "3530-0015", "3530-0036", "3601-0204", "3601-0302", "3701-0200", "3805-0120", "4504-0021", "4504-0028", "5405-0150", "5701-0010", "7106-0035", "7505-0125", "7505-0200", "7505-0500", "7505-0700", "9010-0003", "9010-0006", "9010-0010", "9010-0012", "9010-0020", "9010-0022", "9010-0033", "9010-0040", "9010-0044", "9010-0060", "9010-0080", "9020-0010", "9020-0015", "9020-0016", "9020-0022", "9020-0035", "9020-0050", "9020-0072", "9020-0160", "9020-0180", "9020-0200", "9050-0075", "9050-0150", "9050-0450", "9050-0750", "9050-0850", "9090-0800", "9090-1000", "9090-1200", "9090-1300", "9090-1400", "9090-1500", "9090-1600", "자체 발전기 5.5㎾"], "값": "1730-0015"}},
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["1730-0015", "7204-0055", "0602-0150", "2702-0020", "0101-0007", "0101-0010", "0101-0012", "0101-0019", "0101-0032", "2105-0005", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "4504-0032", "4504-0036", "4504-0041", "4504-0043", "4504-0047", "4504-0052", "4205-0010", "4205-0017", "4205-0020", "4205-0030", "4205-0040", "4205-0045", "2105-0002", "2105-0003", "2105-0010", "2105-0015", "2105-0018", "2502-0025", "0602-0025", "2502-0050", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "7204-0018", "7204-0160", "5205-0210", "5405-0110", "7505-0450", "5205-0255", "7505-0150", "5205-0103", "6701-0147", "6701-0184", "5205-0170", "7505-0350", "0302-0134", "7505-0100", "7505-0050", "5205-0071", "7505-0025", "0502-0036", "1306-0100", "1506-0032", "0121-0004", "1305-0007", "7204-0038", "7204-0065", "0302-0057", "5701-0020", "0302-0095", "3201-0003", "1106-0012", "1406-0015", "1206-0008", "3601-0102", "1306-0025", "0211-0018", "0602-0045", "0602-0060", "0602-0105", "1630-0080", "3901-0300", "7505-0250", "2502-0035", "1306-0120", "1106-0010", "3201-0001", "1406-0008", "3601-0202", "3601-0402", "3611-0142", "2502-0020", "2502-0075", "5205-0035", "2702-0030", "2702-0060", "0211-0060", "0211-0080", "0211-0100", "0221-0040", "0221-0070", "0302-0025", "0302-0172", "0302-0229", "0302-0287", "0302-0350", "0302-0500", "0406-0054", "0406-0115", "0406-0161", "0406-0206", "0503-0036", "0602-0200", "0602-0240", "0602-0320", "1106-0015", "1206-0010", "1206-0014", "1209-0001", "1209-0002", "1209-0004", "1209-0006", "1209-0007", "1209-0008", "1209-0013", "1306-0044", "1306-0060", "1406-0025", "1506-0011", "1506-0012", "1506-0015", "1506-0019", "1506-0025", "1506-0030", "1506-0037", "2602-0015", "2602-0025", "2602-0035", "2602-0045", "2702-0040", "3530-0015", "3530-0036", "3601-0204", "3601-0302", "3701-0200", "3805-0120", "4504-0021", "4504-0028", "5405-0150", "5701-0010", "7106-0035", "7505-0125", "7505-0200", "7505-0500", "7505-0700", "9010-0003", "9010-0006", "9010-0010", "9010-0012", "9010-0020", "9010-0022", "9010-0033", "9010-0040", "9010-0044", "9010-0060", "9010-0080", "9020-0010", "9020-0015", "9020-0016", "9020-0022", "9020-0035", "9020-0050", "9020-0072", "9020-0160", "9020-0180", "9020-0200", "9050-0075", "9050-0150", "9050-0450", "9050-0750", "9050-0850", "9090-0800", "9090-1000", "9090-1200", "9090-1300", "9090-1400", "9090-1500", "9090-1600"], "값": "1730-0015"}},
{"열쇠": "지역", "기호": "S", "아래": "지역", "위": "", "이름": "지역", "단위": "", "출처": "건설품셈 공통 8-1-7 5", "연결": {"갈래": "선택", "목록": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], "값": "전국평균"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "연료", "기호": "S", "아래": "연료", "위": "", "이름": "연료", "단위": "", "출처": "건설품셈 공통 8-4 주①㉰", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료종류"}},
@@ -306,7 +306,7 @@
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"변수": [
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["7620-0003", "7621-0100", "7995-0050", "5210-0013", "5210-0027", "5220-0015", "7730-0150", "6516-0060", "6516-0120", "6516-0250", "6516-0300", "6105-0190", "6202-0060", "0230-0002", "6410-0080", "6410-0100", "6410-0120", "6410-0150", "6410-0200", "6901-0010", "6530-0090", "6530-0120", "6517-0100", "6517-0150", "6517-0200", "6517-0250", "6517-0300", "6510-0100", "6510-0150", "6510-0200", "6510-0250", "6510-0300", "6518-0100", "6518-0150", "6518-0200", "6518-0250", "6518-0300", "5501-0080", "7750-0016", "7750-0025", "7730-0050", "5401-0017", "5202-0127", "5202-0240", "5202-0300", "7611-0200", "7612-0200", "5204-0200", "5204-0300", "5204-0400", "5204-0500", "5204-0600", "5702-0095", "3430-0400", "7431-1300", "7740-0080", "7740-0150", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "2208-5008", "2208-5010", "2208-5012", "2208-5016", "2208-5020", "2105-0002", "2105-0003", "2105-0005", "2105-0010", "2105-0015", "2105-0018", "0201-0012", "0201-0020", "0201-0040", "0201-0060", "0201-0070", "0201-0080", "0201-0100", "0201-0120", "0201-0200", "6105-0390", "6202-0125", "7830-0081", "7950-0149", "7951-0066", "7952-0246", "7953-3400", "7954-1119", "7611-0300", "7431-1100", "7431-2300", "7431-2500", "7611-0400", "7611-0500", "7614-0300", "0250-0100", "5210-0010", "5210-0019", "0103-0016", "0103-0019", "0103-0023", "0103-0027", "0103-0032", "0230-0004", "0230-0006", "0230-0007", "0230-0008", "0230-0010", "0240-0007", "0250-0080", "0407-0054", "0407-0092", "0407-0107", "0407-0161", "0407-0206", "0610-0150", "0610-0200", "0610-0240", "2115-0024", "2115-0031", "2115-0036", "2116-0031", "2116-0036", "2117-0022", "2117-0027", "2118-0010", "2210-0145", "3430-0300", "3450-0642", "4115-0100", "4115-0150", "4115-0200", "4115-0300", "4505-0015", "4505-0026", "4611-0075", "5105-0050", "5105-0100", "5105-0150", "5105-0200", "5111-0040", "5111-0050", "5111-0060", "5111-0076", "5111-0091", "5112-0001", "5112-0002", "5112-0003", "5112-0004", "5112-0005", "5113-0001", "5113-0002", "5113-0003", "5113-0004", "5113-0005", "5113-0006", "5113-0007", "5113-0008", "5113-0009", "5113-0010", "5113-0011", "5114-0001", "5114-0002", "5114-0003", "5114-0004", "5114-0005", "5114-0006", "5114-0007", "5114-0008", "5115-0030", "5115-0055", "5115-0075", "5115-0095", "5116-0001", "5116-0002", "5116-0003", "5116-0004", "5116-0005", "5116-0006", "5116-0007", "5116-0008", "5117-0001", "5117-0002", "5117-0003", "5117-0004", "5117-0005", "5117-0006", "5117-0007", "5117-0008", "5118-0001", "5118-0002", "5118-0003", "5118-0004", "5118-0005", "5118-0006", "5118-0007", "5119-0625", "5203-1800", "5203-2200", "5203-2700", "5203-3500", "5203-4500", "5330-0074", "5805-0002", "5805-0003", "5901-0006", "5901-0010", "5901-0016", "6202-0200", "6408-0015", "6408-0020", "6408-0030", "6408-0040", "6408-0050", "6408-0085", "6408-0100", "6515-0090", "6530-0030", "6530-0040", "6530-0045", "6530-0060", "6532-0220", "6550-0130", "7103-0010", "7104-0010", "7202-0008", "7202-0010", "7205-0125", "7206-0020", "7206-0070", "7206-0100", "7430-1100", "7430-1300", "7430-1500", "7430-2300", "7430-2500", "7612-0300", "7612-0400", "7613-0075", "7613-0150", "7613-0300", "7613-0400", "7613-0600", "7613-0900", "7620-0002", "7730-0080", "7730-0100", "7730-0125", "7740-0100", "7770-0004", "7930-0001", "7930-0002", "7930-0003", "7930-0005", "7930-0007", "7930-0010", "7930-0015", "7930-0020", "7930-0025", "7930-0030", "7930-0040", "7930-0050", "7930-0075", "7930-0100", "7935-0180", "7991-0050", "7991-0100", "7991-0500", "7993-0020", "8201-0100", "8202-0100", "8203-0019", "8204-0100", "8205-0100", "9060-0060", "9060-0100", "9060-0200", "9060-0300", "9060-0500", "9060-0600", "9080-0050", "9080-0080", "9080-0100", "9080-0120", "9080-0150", "9080-0200", "9080-0300", "9080-0500", "9080-0700", "9080-1000", "9080-1100", "9080-1400", "9080-1500", "9080-1750", "9080-2000", "9080-3000", "자체 발전기 7㎾", "자체 발전기 10㎾", "자체 대형 브레이커 0.18㎥", "자체 에어 호스 1.91㎝", "자체 모노펌프 5-20㎥/hr", "자체 콘크리트펌프용 파이프 150㎜×1m", "자체 콘크리트펌프용 파이프 150㎜×2m", "자체 콘크리트펌프용 파이프 150㎜×3m", "자체 콘크리트펌프용 파이프 곡관 150㎜ 45°", "자체 콘크리트펌프용 파이프 곡관 150㎜ 60°", "자체 콘크리트펌프용 파이프 곡관 150㎜ 90°"], "값": "7620-0003"}},
{"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["7620-0003", "7621-0100", "7995-0050", "5210-0013", "5210-0027", "5220-0015", "7730-0150", "6516-0060", "6516-0120", "6516-0250", "6516-0300", "6105-0190", "6202-0060", "0230-0002", "6410-0080", "6410-0100", "6410-0120", "6410-0150", "6410-0200", "6901-0010", "6530-0090", "6530-0120", "6517-0100", "6517-0150", "6517-0200", "6517-0250", "6517-0300", "6510-0100", "6510-0150", "6510-0200", "6510-0250", "6510-0300", "6518-0100", "6518-0150", "6518-0200", "6518-0250", "6518-0300", "5501-0080", "7750-0016", "7750-0025", "7730-0050", "5401-0017", "5202-0127", "5202-0240", "5202-0300", "7611-0200", "7612-0200", "5204-0200", "5204-0300", "5204-0400", "5204-0500", "5204-0600", "5702-0095", "3430-0400", "7431-1300", "7740-0080", "7740-0150", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "2208-5008", "2208-5010", "2208-5012", "2208-5016", "2208-5020", "2105-0002", "2105-0003", "2105-0005", "2105-0010", "2105-0015", "2105-0018", "0201-0012", "0201-0020", "0201-0040", "0201-0060", "0201-0070", "0201-0080", "0201-0100", "0201-0120", "0201-0200", "6105-0390", "6202-0125", "7830-0081", "7950-0149", "7951-0066", "7952-0246", "7953-3400", "7954-1119", "7611-0300", "7431-1100", "7431-2300", "7431-2500", "7611-0400", "7611-0500", "7614-0300", "0250-0100", "5210-0010", "5210-0019", "0103-0016", "0103-0019", "0103-0023", "0103-0027", "0103-0032", "0230-0004", "0230-0006", "0230-0007", "0230-0008", "0230-0010", "0240-0007", "0250-0080", "0407-0054", "0407-0092", "0407-0107", "0407-0161", "0407-0206", "0610-0150", "0610-0200", "0610-0240", "2115-0024", "2115-0031", "2115-0036", "2116-0031", "2116-0036", "2117-0022", "2117-0027", "2118-0010", "2210-0145", "3430-0300", "3450-0642", "4115-0100", "4115-0150", "4115-0200", "4115-0300", "4505-0015", "4505-0026", "4611-0075", "5105-0050", "5105-0100", "5105-0150", "5105-0200", "5111-0040", "5111-0050", "5111-0060", "5111-0076", "5111-0091", "5112-0001", "5112-0002", "5112-0003", "5112-0004", "5112-0005", "5113-0001", "5113-0002", "5113-0003", "5113-0004", "5113-0005", "5113-0006", "5113-0007", "5113-0008", "5113-0009", "5113-0010", "5113-0011", "5114-0001", "5114-0002", "5114-0003", "5114-0004", "5114-0005", "5114-0006", "5114-0007", "5114-0008", "5115-0030", "5115-0055", "5115-0075", "5115-0095", "5116-0001", "5116-0002", "5116-0003", "5116-0004", "5116-0005", "5116-0006", "5116-0007", "5116-0008", "5117-0001", "5117-0002", "5117-0003", "5117-0004", "5117-0005", "5117-0006", "5117-0007", "5117-0008", "5118-0001", "5118-0002", "5118-0003", "5118-0004", "5118-0005", "5118-0006", "5118-0007", "5119-0625", "5203-1800", "5203-2200", "5203-2700", "5203-3500", "5203-4500", "5330-0074", "5805-0002", "5805-0003", "5901-0006", "5901-0010", "5901-0016", "6202-0200", "6408-0015", "6408-0020", "6408-0030", "6408-0040", "6408-0050", "6408-0085", "6408-0100", "6515-0090", "6530-0030", "6530-0040", "6530-0045", "6530-0060", "6532-0220", "6550-0130", "7103-0010", "7104-0010", "7202-0008", "7202-0010", "7205-0125", "7206-0020", "7206-0070", "7206-0100", "7430-1100", "7430-1300", "7430-1500", "7430-2300", "7430-2500", "7612-0300", "7612-0400", "7613-0075", "7613-0150", "7613-0300", "7613-0400", "7613-0600", "7613-0900", "7620-0002", "7730-0080", "7730-0100", "7730-0125", "7740-0100", "7770-0004", "7930-0001", "7930-0002", "7930-0003", "7930-0005", "7930-0007", "7930-0010", "7930-0015", "7930-0020", "7930-0025", "7930-0030", "7930-0040", "7930-0050", "7930-0075", "7930-0100", "7935-0180", "7991-0050", "7991-0100", "7991-0500", "7993-0020", "8201-0100", "8202-0100", "8203-0019", "8204-0100", "8205-0100", "9060-0060", "9060-0100", "9060-0200", "9060-0300", "9060-0500", "9060-0600", "9080-0050", "9080-0080", "9080-0100", "9080-0120", "9080-0150", "9080-0200", "9080-0300", "9080-0500", "9080-0700", "9080-1000", "9080-1100", "9080-1400", "9080-1500", "9080-1750", "9080-2000", "9080-3000"], "값": "7620-0003"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}},
{"열쇠": "k_기계손료", "기호": "k", "아래": "기계손료", "위": "", "이름": "EQ:{기계}.손료계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "손료계수"}}
@@ -2213,43 +2213,6 @@
"비목": {"방법": "버림", "자리": 0},
"끝전": "비목버림"
}
},
{
"꼴": "수식문서",
"판": 1,
"키": "GC001195",
"이름": "파워트로웰 시간당 기계경비",
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"부품": {
"갈래": "기계경비식"
},
"변수": [
{"열쇠": "지역", "기호": "S", "아래": "지역", "위": "", "이름": "지역", "단위": "", "출처": "건설품셈 공통 8-1-7 5", "연결": {"갈래": "선택", "목록": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], "값": "전국평균"}},
{"열쇠": "전력 계약종별", "기호": "S", "아래": "전력계약종별", "위": "", "이름": "전력 계약종별", "단위": "", "출처": "유가전력 한전 전기요금표 2025-01-01", "연결": {"갈래": "선택", "목록": ["주택용 저압", "일반용(갑)Ⅰ 저압"], "값": "주택용 저압"}},
{"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}},
{"열쇠": "P_파워트로웰", "기호": "P", "아래": "파워트로웰", "위": "", "이름": "EQ000662.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ000662", "칸": "가격", "계약종별": "S_전력계약종별"}},
{"열쇠": "k_파워트로웰", "기호": "k", "아래": "파워트로웰", "위": "", "이름": "EQ000662.손료계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ000662", "칸": "손료계수", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_파워트로웰2", "기호": "P", "아래": "파워트로웰2", "위": "", "이름": "EQ000662.주연료", "단위": "", "연결": {"갈래": "요소", "키": "EQ000662", "칸": "주연료", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_휘발유", "기호": "P", "아래": "휘발유", "위": "", "이름": "휘발유단가", "단위": "", "연결": {"갈래": "요소", "키": "MO:휘발유:{S_지역}", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_파워트로웰3", "기호": "P", "아래": "파워트로웰3", "위": "", "이름": "EQ000662.전력", "단위": "", "연결": {"갈래": "요소", "키": "EQ000662", "칸": "전력", "계약종별": "S_전력계약종별"}},
{"열쇠": "P_전력", "기호": "P", "아래": "전력", "위": "", "이름": "전력단가", "단위": "", "연결": {"갈래": "요소", "재료": {"이름": "전력량요금", "계약종별": "입력"}, "단위": "㎾h", "계약종별": "S_전력계약종별"}},
{"열쇠": "r_파워트로웰", "기호": "r", "아래": "파워트로웰", "위": "", "이름": "EQ000662.잡재료율", "단위": "", "연결": {"갈래": "요소", "키": "EQ000662", "칸": "잡재료율", "계약종별": "S_전력계약종별"}}
],
"식": [
{"기호": "C_손료", "식": "버림(P_파워트로웰 × 1000 × k_파워트로웰 ÷ 10000000, 0)"}
],
"금액": [
{"이름": "주연료", "단위": "ℓ", "비목": "재료비", "수량": "P_파워트로웰2", "단가": "P_휘발유", "출처": "건설품셈 공통 8-4-1", "기호": "C_주연료"},
{"이름": "전력", "단위": "㎾h", "비목": "재료비", "수량": "P_파워트로웰3", "단가": "P_전력", "출처": "자체"},
{"이름": "잡재료비", "단위": "식", "비목": "재료비", "단가": "C_주연료 × r_파워트로웰 ÷ 100", "출처": "건설품셈 공통 8-4 주②"},
{"이름": "기계손료", "단위": "식", "비목": "경비", "단가": "C_손료", "출처": "건설품셈 공통 8-1-6 2"}
],
"끝수": {
"줄": {"방법": "버림", "자리": 1},
"비목": {"방법": "버림", "자리": 0},
"끝전": "비목버림"
}
}
]
}
@@ -1,173 +0,0 @@
"""820-1 지킴 — 품셈에 없는 소형 · 보조 기계 13 줄(EST 구성 씨앗 · 가격 갱신 필요 · 요구 785 ~ 787).
(a) 새 줄 · 값 — 발전기 손료계수 2362 공통(품셈 발전기 계열) · 브레이커 0.18 = 0.2㎥ 계수 · 가격 = 씨앗 ·
파이프 = 달러 × 환율(비고에 $ 값) · 비고 「갱신 필요」
(b) 계산식 — 손료 = 버림(가격 × 1000 × 손료계수 ÷ 10⁷) · 손료만 = GC000273 · 발전기 5.5 = GC000268 ·
파워트로웰 = GC001195(휘발유 + 잡품(주연료 줄의 %) + 전력) · 옛 엔진 = 새 수식 문서 · 시간당 이음 한 길
(c) 이음 — 가져온 이름 · 규격 → 새 줄 · 옛 기계 그대로
(d) 다른 문서 그대로 — 새 고르기는 목록 끝에만 · 기본값 그대로
"""
from __future__ import annotations
import importlib
import json
import sys
from decimal import Decimal
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
for p in (ROOT, ROOT / "resources/master_data/scripts"):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
from test_815_1_import_link import no_aliases # noqa: E402 — 진짜 마스터 별칭 빼고
from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402
from M02_MasterTemplete import M02_Doc_Import as di # noqa: E402
from M02_MasterTemplete import M02_Machine_Find as mf # noqa: E402
from M02_MasterTemplete import M02_Machine_Hourly as mh # noqa: E402
ml = importlib.import_module("master_doc_link")
mc = importlib.import_module("master_doc_calc")
b802 = importlib.import_module("build_수식문서_802_2")
meq = importlib.import_module("master_equation")
NEW = {f"EQ{n:06d}" for n in range(656, 669)}
SEED = { # 키 → (이름, 규격, 가격, 손료계수, 시간당 식)
"EQ000656": ("발전기", "5.5㎾", 1063, 2362, "GC000268"),
"EQ000657": ("발전기", "7㎾", 1828.9, 2362, "GC000273"),
"EQ000658": ("발전기", "10㎾", 3213, 2362, "GC000273"),
"EQ000659": ("대형 브레이커", "0.18㎥", 4559, 6601, "GC000273"),
"EQ000660": ("에어 호스", "1.91㎝", 105, 5625, "GC000273"),
"EQ000661": ("모노펌프", "5-20㎥/hr", 4900, 4346, "GC000273"),
"EQ000662": ("파워트로웰", "3.73㎾", 2680, 5313, "GC001195"),
}
USD = {"150㎜×1m": 25, "150㎜×2m": 37, "150㎜×3m": 46,
"곡관 150㎜ 45°": 67, "곡관 150㎜ 60°": 84, "곡관 150㎜ 90°": 103} # fmt: skip
def _eq() -> dict[str, dict]:
return m01.loaded()[1].index["EQ"]
def _docs():
docs = ml.load_all()
return docs, ml.Links(m01.loaded()[1], docs)
def _loss(price, factor) -> Decimal:
return (Decimal(str(price)) * 1000 * Decimal(factor) / 10_000_000).quantize(Decimal(1), "ROUND_DOWN") # fmt: skip
def test_rows_and_values() -> None:
eq = _eq()
assert NEW <= set(eq)
for key, (name, spec, price, factor, _) in SEED.items():
row = eq[key]
assert (row["이름"], row["규격"]) == (name, spec), key
assert row["값"]["가격"] == Decimal(str(price)) and row["값"]["손료계수"] == factor, key
assert "갱신 필요" in row["비고"] and row["출처"]["가격"] == "자체", key
gens = [r for r in eq.values() if r.get("상세구분") == "발전기"]
assert {int(r["값"]["손료계수"]) for r in gens} == {2362} # 발전기 계열 공통
small, big = eq["EQ000659"]["값"], eq["EQ000031"]["값"]
for k in ("가격", "상각비계수", "정비비계수", "관리비계수", "손료계수"):
assert small[k] == big[k], k
pipes = {r["규격"]: r for r in eq.values() if r.get("상세구분") == "콘크리트펌프용 파이프"}
assert set(pipes) == set(USD)
rate = m01.loaded()[1].index["FX"]["FX000001"]["값"]
for spec, usd in USD.items():
row = pipes[spec]
assert row["값"]["손료계수"] == 4500 and f"${usd} 표기" in row["비고"]
assert abs(float(row["값"]["가격"]) - usd * float(rate) / 1000) < 1e-3
trowel = eq["EQ000662"]["값"]
assert (trowel["주연료종류"], trowel["주연료"], trowel["잡재료율"], trowel["전력"]) == ("휘발유", 1, 10, Decimal("3.73")) # fmt: skip
def test_formula_loss_and_one_way() -> None:
docs, links = _docs()
files, whole = m01.loaded()
logic = {r["키"]: r for _, r in b802.rows_of(files)}
for key in sorted(NEW):
row = _eq()[key]
got = mh.hourly(key)
assert got["ok"], (key, got)
want = SEED.get(key, (None, None, None, None, "GC000273"))[4]
assert got["로직"] == want, key
lg = logic[want]
given = meq.given_of(whole, lg)
changed = {"기계": row["원문번호"]} if mh.takes_machine(lg) else None
new = b802.new_answer(links, docs[want], changed)
old = b802.old_answer(whole, lg, {**given, **(changed or {})})
assert old == new and float(new["계"]) == got["계"], key # 옛 엔진 = 새 문서 = 시간당 이음
assert new["경비"] == _loss(row["값"]["가격"], row["값"]["손료계수"]), (
key
) # 손료 = 가격 × 계수
if want == "GC000273":
assert new["노무비"] == new["재료비"] == 0, key
assert di.hourly_link("EQ000662", "재료비")["키"] == "GC001195"
assert di.hourly_link("EQ000656", "노무비")["입력"]["기계"] == "'자체 발전기 5.5㎾'"
def test_trowel_lines() -> None:
docs, links = _docs()
got = mc.calc(docs["GC001195"], links)
lines = {x["이름"]: x for x in got["줄"]}
assert set(lines) == {"주연료", "전력", "잡재료비", "기계손료"}
assert lines["전력"]["수량"] == Decimal("3.73") and lines["주연료"]["수량"] == 1
assert lines["잡재료비"]["금액"] == (lines["주연료"]["금액"] * Decimal("0.1")).quantize(Decimal("0.1"), "ROUND_DOWN") # fmt: skip
assert got["노무비"] == 0 and got["경비"] == _loss(2680, 5313)
def test_find_links() -> None:
cases = {
("발전기", "5.5kW"): "EQ000656", ("발전기", "7kW"): "EQ000657",
("발전기", "10kW"): "EQ000658", ("발전기", "25kW"): "EQ000452",
("에어 호스", "1.91cm"): "EQ000660", ("에어호스", "1.91cm"): "EQ000660",
("모노펌프", "5-20㎥/hr"): "EQ000661", ("모노펌프", ""): "EQ000661",
("브레이카", "0.18㎥"): "EQ000659", ("대형브레이카", "0.2㎥"): "EQ000031",
("파워트로웰", "3.73kW"): "EQ000662",
("콘크리트펌프용 파이프", "150mm×1m"): "EQ000663",
("콘크리트펌프용 파이프", "150㎜×3m"): "EQ000665",
("콘크리트펌프용 파이프", "곡관 45˚"): "EQ000666",
("콘크리트펌프용 파이프", "곡관 150㎜ 90°"): "EQ000668",
} # fmt: skip
with no_aliases("EQ"):
for (name, spec), key in cases.items():
got = mf.find(name, spec)
assert (got["판정"], got["키"]) == (mf.LINKED, key), (name, spec, got)
def test_other_docs_same() -> None:
"""새 고르기는 기계경비식 목록 끝에만 · 기본값은 옛 기계 그대로."""
text = (ROOT / "resources/master_data/로직_건설품셈_08장_건설기계.json").read_text("utf-8")
codes = {r["원문번호"] for k, r in _eq().items() if k in NEW}
for row in json.loads(text)["줄"]:
for spec in row.get("입력") or []:
pick = [str(c) for c in spec.get("고르기") or []]
mine = [i for i, c in enumerate(pick) if c in codes]
if mine:
assert mine == list(range(len(pick) - len(mine), len(pick))), row["키"]
assert pick[0] not in codes, row["키"]
old = [k for k, r in _eq().items() if k not in NEW]
assert not [k for k in old if str(_eq()[k].get("원문번호") or "").startswith("자체 ")]
def test_blank_input_name_link_roundtrip() -> None:
"""820-2 — 빈칸 든 입력 이름(「전력 계약종별」)이 이음 글 → 읽기 왕복 · EST main 504 번 = 저장 금액 · 우리 단가 셈."""
from M02_MasterTemplete import M02_San_Link as sl
from M02_MasterTemplete import M02_Sangeun_Imported as imp
text = sl.hourly_text("EQ000662")
assert "'전력 계약종별'=" in text
node = di.dc.cv.mf.parse(text)
assert node[0] == "logic" and "전력 계약종별" in [k for k, _ in node[2]]
assert imp._link(text, "로직", "시험", imp._whole()) == text
import test_810_2_native_doc as t
out = t.fn.emit(t.native("EST", "main"), 504)
sk, _, got = t.doc_and_money(out)
assert float(got["계"]) == 546 # 원파일 저장 금액
sk[imp.KEY]["단가기준"] = "우리"
ours = di.run(di.derive(sk, None, t.whole()), sk, t.whole())
assert ours.get("셈됨") is not False and ours["계"] is not None
+178
View File
@@ -0,0 +1,178 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""821-1 내 기계(개인 층 기계 줄) — M01 기계처럼: 만들 때 사본에 복사 · 고쳐도 금액 그대로 + 「단가새값」 ·
[단가 갱신] 으로 반영 · 지워도 마지막 값(임시 storage · M01 정본 안 고침).
① 세 꼴(바로 · 품셈 · 직접) 저장 · 키 · 원문번호 M01 과 안 겹침 ② 시간당 = M01 같은 값 기계와 같음
③ 찾기 · 남의 층 안 보임 ④ SK 사본에 박힘 · 고친 뒤 그대로 + 새값 · 갱신 뒤 반영 · 지운 뒤 마지막 값 ·
층 사이 복사 ⑤ M01 만 쓰는 SK 금액 그대로 ⑥ 권한.
"""
from __future__ import annotations
import json
import pytest
from fastapi import HTTPException
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Machine_Find as mfind
from M02_MasterTemplete import M02_Machine_Hourly as mh
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete import M02_MyMachine_Router as route
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ — 연료 · 조종원 있음
PCT = "GC001109" # M01 만 쓰는 SK 바탕
NAME = "서브셋임시기계" # 찾기 기종 글(수 = 규격으로 읽음 · 창 표시는 글로)
def _at(folder, fn, *args):
with store.at(folder):
return fn(*args)
def _easy(**vals):
base = {"가격": 1000, "손료계수": 2000, "주연료": 2, "주연료종류": "경유", "잡재료율": 10,
"조종원": 1, "조종원직종": "1048"} # fmt: skip
return {"이름": NAME, "규격": "1㎾", "방식": "바로", "값": base | vals}
def _sk(folder, machine_no):
line = {"종류": "로직", "로직": "GC000268", "입력값": {"기계": machine_no, "지역": "전국평균"},
"수량": "1"} # fmt: skip
body = {"이름": "sub3임시 내기계", "단위": "시간", "더한줄": [line]}
return _at(folder, lambda: refs.create("sangeun", body)["이름"])
def _doc(folder, key):
return _at(folder, lambda: store.read("sangeun", key)["문서"])
def _calc(folder, doc):
return _at(folder, calc.calc_doc, doc, None, False)
def _sum(folder, key):
return _calc(folder, _doc(folder, key))["기준"]["계"]
def test_three_shapes_and_keys(world):
"""① 바로 · 품셈 · 직접 — 키 EQP{사용자}-#### · 원문번호 「개인 …」 · M01 과 안 겹침 · 날."""
me = world["personal"]
easy = _at(me, mym.create, _easy())["줄"]
assert easy["키"] == "EQP42-0001" and easy["원문번호"] == "개인 EQP42-0001"
assert set(easy["값"]) >= {"가격", "손료계수"} and "상각비계수" not in easy["값"]
assert easy["넣은날"] == easy["고친날"] and easy["출처"]["가격"] == "개인"
book = _at(me, mym.create, {"방식": "품셈"}, DIG)["줄"] # M01 기계 복사해 만들기(비율 → 계수)
m01_row = m01.loaded()[1].get(DIG)["값"]
for k in ("상각비계수", "정비비계수", "관리비계수", "손료계수"):
assert float(book["값"][k]) == float(m01_row[k]), k
direct = _at(me, mym.create, {"이름": NAME, "규격": "1㎾", "방식": "직접",
"시간당": {"노무비": 10, "재료비": 20, "경비": 30}}) # fmt: skip
assert direct["시간당"]["계"] == 60 and direct["시간당"]["직접"] is True
m01_eq = m01.loaded()[1]
for row in _at(me, mym.rows):
assert row["키"] not in m01_eq.index["EQ"] and row["원문번호"] not in m01_eq.numbers["EQ"]
assert [r["키"] for r in _at(me, mym.rows)] == ["EQP42-0001", "EQP42-0002", "EQP42-0003"]
assert {x["키"] for x in direct["같은이름"]} == {"EQP42-0001"} # 같은 이름 · 규격 알림
for bad in ({"이름": ""}, {**_easy(), "값": {"가격": 1}}, {**_easy(가격=-1)},
{**_easy(주연료종류="물")}, {**_easy(조종원직종="없음")}): # fmt: skip
with pytest.raises(store.StoreError):
_at(me, mym.create, bad)
def test_hourly_same_as_m01(world):
"""② 굴착기 1.0㎥ 값을 내 기계로(바로 · 품셈) → 시간당 = M01 기계 · 식은 채운 칸으로."""
me = world["personal"]
want = mh.hourly(DIG)
for how in ("바로", "품셈"):
got = _at(me, mym.create, {"방식": how}, DIG)
hour = _at(me, mh.hourly, got["줄"]["키"])
assert hour["ok"] and hour["로직"] == "GC000268", how
for k in ("노무비", "재료비", "경비", "계"):
assert hour[k] == want[k], (how, k)
loss = _at(me, mym.create, _easy(주연료=0, 조종원=0))["시간당"]
assert loss["로직"] == "GC000273" and loss["계"] == loss["경비"] == 200 # 1000×1000×2000/10⁷
assert _at(me, mym.create, _easy(조종원=0))["시간당"]["로직"] == "GC000272"
assert _at(me, mym.create, _easy(주연료=0))["시간당"]["로직"] == "GC000276"
def test_find_and_other_layer_hidden(world):
"""③ 기계 찾기 = 그 층 내 기계도 · 남의 층 · 층 모르는 길 = M01 만."""
me, other = world["personal"], layers.personal_dir(7, 43)
key = _at(me, mym.create, _easy())["줄"]["키"]
got = _at(me, mfind.find, NAME, "1㎾")
assert got["판정"] == mfind.LINKED and got["키"] == key
assert _at(other, mfind.find, NAME, "1㎾")["판정"] == mfind.NONE
assert _at(other, mh.hourly, key)["ok"] is False
assert mh.hourly(key)["ok"] is False # 층 모르는 길
assert key not in _at(other, skc.whole_).index["EQ"]
def test_sk_copy_edit_refresh_delete(world):
"""④ 만들 때 사본에 박힘 → 고쳐도 그대로 + 새값 → [단가 갱신] 반영 → 지워도 마지막 값 · 층 복사."""
me = world["personal"]
made = _at(me, mym.create, _easy())
key, no = made["줄"]["키"], made["줄"]["원문번호"]
sk = _sk(me, no)
copy = _doc(me, sk)["사본"]
assert key in {r["키"] for r in copy["단가"]}
was = _sum(me, sk)
assert float(was) == made["시간당"]["계"]
assert _at(me, mym.one, key)["쓰는곳"] == [{"키": sk, "이름": "sub3임시 내기계"}]
_at(me, mym.update, key, _easy(가격=3000)) # 고침
now = _calc(me, _doc(me, sk))
assert now["기준"]["계"] == was and now["사본"]["단가새값"] is True
assert now["사본"]["지운키"] == []
new = _at(me, mh.hourly, key)["계"]
look = _at(me, skc.preview, _doc(me, sk), skc.PRICE)
assert float(look["전"]["계"]) == float(was) and float(look["후"]["계"]) == new
assert [x["키"] for x in look["바뀐자원"]] == [key]
with store.at(me): # [저장] = 할일 적용
store.write("sangeun", sk, store.read("sangeun", sk)["판"], look["문서"])
assert float(_sum(me, sk)) == new
assert _calc(me, _doc(me, sk))["사본"]["단가새값"] is False
# 층 사이 복사 — 사본이 따라감(받은 층에 그 내 기계 없음 · 마지막 값)
other = layers.personal_dir(7, 43)
moved = _at(other, skc.settle, {**_doc(me, sk), "키": "SK-999999"})
assert float(_calc(other, moved)["기준"]["계"]) == new
gone = _at(me, mym.delete, key)
assert gone["쓰는곳"] == [{"키": sk, "이름": "sub3임시 내기계"}]
after = _calc(me, _doc(me, sk))
assert float(after["기준"]["계"]) == new and after["사본"]["지운키"] == [key]
look = _at(me, skc.preview, _doc(me, sk), skc.PRICE)
assert look["바뀐자원"] == [{"키": key, "이름": NAME, "지워짐": True}]
assert float(look["후"]["계"]) == new
def test_m01_only_sk_same(world):
"""⑤ 내 기계가 있는 층에서도 M01 만 쓰는 SK 금액 · 사본 같음 · 사본 모으기에 내 기계 안 낌."""
me, other = world["personal"], layers.personal_dir(7, 43)
base = {"이름": "sub3임시 M01", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []}
plain = _at(other, lambda: refs.create("sangeun", base)["이름"])
_at(me, mym.create, _easy())
mine = _at(me, lambda: refs.create("sangeun", base)["이름"])
a, b = _doc(other, plain)["사본"], _doc(me, mine)["사본"]
assert json.dumps(a["단가"], sort_keys=True) == json.dumps(b["단가"], sort_keys=True)
assert a["판"] == b["판"] and _sum(other, plain) == _sum(me, mine)
def test_rights(world):
"""⑥ 남의 개인 층 = 403(일반) · 회사 관리자 = 됨 · 개인 층 밖 = 404."""
user = {"role": "USER", "company_id": 7, "user_id": 42}
boss = {"role": "ADMIN", "company_id": 7, "user_id": 1}
with pytest.raises(HTTPException) as e:
route._folder(user, "personal", "7/43", True)
assert e.value.status_code == 403
with pytest.raises(HTTPException) as e:
route._folder(user, "company", None, True)
assert e.value.status_code == 404
assert route._folder(user, "personal", None, True) == world["personal"]
assert route._folder(boss, "personal", "7/42") == world["personal"] # 맡은 회사 관리자 읽기
with pytest.raises(store.StoreError): # 개인 층 아닌 자리에서 만들기
_at(world["company"], mym.create, _easy())
+133
View File
@@ -0,0 +1,133 @@
"""PLAN 821-2 기계 계수 계산 짝 — 지킴 시험.
① 계수 전수 대조: M01 기계표에서 품셈 입력 다섯 · 계수 넷이 다 있는 줄 = 파이썬 계수 셈과 같음
(다른 줄 = 원문 표 값 그대로인 점보드릴 2 줄만 · 원문 777 · 셈 777.8).
② 파이썬 · TS 거울: 같은 입력 → 같은 계수 · 같은 시간당 미리보기.
③ 미리보기 = 서버 기계경비식(`M02_Machine_Hourly.hourly`) 견본 스무 대 · 비목 셋 같음.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "resources" / "master_data" / "scripts"))
import master_machine_coef as mc # noqa: E402
DATA = ROOT / "resources" / "master_data"
TS = ROOT / "common_util" / "common_util_machine_coef.ts"
# 원문 표 값 ≠ 식(정비비계수 0.7 ÷ 9,000 × 10^7 = 777.8 → 원문 777 · 계 2,515) — 식을 비틀지 않음
ORIGINAL_ODD = {"EQ000347", "EQ000348"}
SAMPLES = (
"EQ000001 EQ000006 EQ000022 EQ000026 EQ000040 EQ000051 EQ000073 EQ000076 EQ000126 EQ000151 "
"EQ000176 EQ000201 EQ000226 EQ000229 EQ000246 EQ000326 EQ000401 EQ000434 EQ000455 EQ000544"
).split()
def _load(name: str) -> list[dict]:
return json.loads((DATA / name).read_text(encoding="utf-8"))["줄"]
MACHINES = _load("기계.json")
FUEL = {r["원문번호"]: r["값"] for r in _load("재료_유가전력.json")}
WAGE = {r["원문번호"]: r["값"] for r in _load("인력.json")}
def _full(row: dict) -> bool:
v = row["값"]
return all(isinstance(v.get(k), (int, float)) for k in (*mc.INPUTS, *mc.COEFS))
def _prices(v: dict) -> tuple[float, float]:
"""전국평균 유가 · 노임(직종 비면 기계경비식 조종원직종 첫 고르기 1048)."""
return FUEL.get(f"{v.get('주연료종류')}:전국평균", 0), WAGE.get(
str(v.get("조종원직종") or "1048"), 0
)
def test_coef_matches_master_table():
rows = [r for r in MACHINES if _full(r)]
assert len(rows) >= 590
odd = {r["키"] for r in rows if mc.coef_of(r["값"]) != {k: r["값"][k] for k in mc.COEFS}}
assert odd == ORIGINAL_ODD
def test_coef_excavator_example():
got = mc.coef(10000, 1250, 0.9, 0.7, 0.1)
assert got == {"상각비계수": 900, "정비비계수": 700, "관리비계수": 485, "손료계수": 2085}
assert mc.coef(0, 1250, 0.9, 0.7, 0.1) is None
assert mc.coef_of({"내용시간": 10000}) is None
@pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
def test_ts_mirror(tmp_path):
coef_cases = [[r["값"][k] for k in mc.INPUTS] for r in MACHINES if _full(r)]
coef_cases += [[10000, 1250, 0.9, 0.7, 0.1], [0, 1, 1, 1, 1], [3600, 540, 0.9, 1.15, 0.1]]
hour_cases = []
for r in MACHINES:
fuel, wage = _prices(r["값"])
hour_cases.append([r["값"], fuel, wage, 114.4, 0.125])
hour_cases.append(
[
{"가격": 2680, "손료계수": 5313, "주연료": 1.0, "잡재료율": 10, "전력": 3.73},
2000,
0,
114.4,
0.125,
]
)
src = tmp_path / "in.json"
src.write_text(
json.dumps({"coef": coef_cases, "hour": hour_cases}, ensure_ascii=False), encoding="utf-8"
)
script = f"""
const fs = require("fs");
const ts = require({json.dumps(str(ROOT / "config" / "node_modules" / "typescript"))});
const opt = {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }};
const src = fs.readFileSync({json.dumps(str(TS))}, "utf8");
const js = ts.transpileModule(src, {{ compilerOptions: opt }}).outputText;
const m = {{ exports: {{}} }};
new Function("exports", "module", js)(m.exports, m);
const inp = JSON.parse(fs.readFileSync({json.dumps(str(src))}, "utf8"));
process.stdout.write(JSON.stringify({{
coef: inp.coef.map((c) => m.exports.machineCoef(...c)),
hour: inp.hour.map((h) => m.exports.machineHourly(...h)),
}}));
"""
out = subprocess.run(
["node", "-e", script], capture_output=True, text=True, encoding="utf-8", check=True
)
got = json.loads(out.stdout)
want_coef = [mc.coef(*c) for c in coef_cases]
want_hour = [mc.preview(*h) for h in hour_cases]
assert got["coef"] == want_coef
assert len(got["hour"]) == len(want_hour)
for a, b in zip(got["hour"], want_hour):
assert a == pytest.approx(b, abs=1e-9)
def test_preview_equals_server_hourly():
from M02_MasterTemplete import M02_Machine_Hourly as mh
rows = {r["키"]: r for r in MACHINES}
for key in SAMPLES:
h = mh.hourly(key)
assert h["ok"], (key, h)
v = rows[key]["값"]
p = mc.preview(v, *_prices(v))
assert (p["노무비"], p["재료비"], p["경비"], p["계"]) == (
h["노무비"],
h["재료비"],
h["경비"],
h["계"],
), (key, h["로직"])
+156
View File
@@ -0,0 +1,156 @@
"""PLAN 821-2 M01 기계 저장 — 지킴 시험(임시 사본 폴더만 · 정본 안 씀).
① 새 기계 = 단위 · 출처 자동(마스터 단위 · 「자체」) · 계수 서버 재계산 · 다른 줄 그대로
② 고침 = 그 칸 한 줄만 · 품셈 입력이 같으면 저장 계수 그대로(점보드릴 777) · 바뀌면 다시 셈
③ 기계 길은 시스템 관리자만(아니면 403) · 양식 목록 · 비슷한 기계 찾기 · 쓰는 곳
"""
from __future__ import annotations
import difflib
import json
import shutil
import sys
from pathlib import Path
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from common_util import common_util_auth as auth
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
sys.path.insert(
0, str(Path(__file__).resolve().parents[2] / "resources" / "master_data" / "scripts")
)
import master_machine_coef as mc # noqa: E402
REAL = store.FOLDER
MACHINE = "기계.json"
@pytest.fixture
def copy(tmp_path: Path, monkeypatch) -> Path:
for path in REAL.glob("*.json"):
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
shutil.copy(path, tmp_path / path.name)
monkeypatch.setattr(store, "FOLDER", tmp_path)
return tmp_path
def _app(role: str = "SYSTEM_ADMIN") -> TestClient:
app = FastAPI()
app.dependency_overrides[auth.verify_session] = lambda: {
"user_id": 1,
"name": "시험",
"role": role,
"company_id": 1,
}
deps = [Depends(auth.verify_session), Depends(auth.require_system_admin)]
app.include_router(router_module.router, dependencies=deps)
return TestClient(app)
def _save(client: TestClient, version: str, change: dict) -> None:
res = client.post(
"/api/m01/save",
json={"files": [{"file": MACHINE, "version": version, "changes": [change]}]},
)
assert res.status_code == 200, res.text
def _diff(before: str, after: str) -> list[str]:
return [
x
for x in difflib.unified_diff(before.splitlines(), after.splitlines(), lineterm="", n=0)
if x[:1] in "+-" and not x.startswith(("+++", "---"))
]
def test_새_기계_단위_출처_자동_계수_서버_재계산(copy: Path) -> None:
client = _app()
lists = client.get("/api/m01/machine/lists").json()
assert {"경유", "휘발유"} <= {f["value"] for f in lists["fuels"]}
assert any(j["value"] == "1048" for j in lists["jobs"]) and lists["hour"] == 0.125
found = client.get("/api/m01/machine/find", params={"q": "굴착기 1.0㎥"}).json()["rows"]
seed = next(r for r in found if r["키"] == "EQ000022")
before = (copy / MACHINE).read_text(encoding="utf-8")
version = client.get("/api/m01/machine/row", params={"key": "EQ000022"}).json()["version"]
values = {**seed["값"], "내용시간": 8000, "손료계수": 1, "상각비계수": 1} # 계수 = 틀린 값
row = {
"키": "",
"원문번호": "",
**{k: seed[k] for k in ("원문", "구분", "상세구분")},
"이름": "시험기계sub1",
"규격": "9.9㎥",
"단위": {},
"값": values,
"출처": {},
"비고": None,
}
_save(client, version, {"op": "add", "row": row})
after = (copy / MACHINE).read_text(encoding="utf-8")
rows = json.loads(after)["줄"]
new = rows[-1]
assert new["이름"] == "시험기계sub1" and new["키"].startswith("EQ")
assert new["값"] | mc.coef(8000, 1250, 0.9, 0.7, 0.1) == new["값"] # 서버가 다시 셈
assert new["값"]["손료계수"] == 1125 + 875 + 496
assert set(new["단위"]) == set(new["값"]) == set(new["출처"])
assert new["단위"] == {
k: seed_unit for k, seed_unit in _units(before).items() if k in new["값"]
}
assert set(new["출처"].values()) == {"자체"}
assert json.loads(before)["줄"] == rows[:-1] # 다른 줄 그대로
assert all(x.startswith("+") for x in _diff(before, after)) # 글은 더하기만
def _units(text: str) -> dict:
return next(r for r in json.loads(text)["줄"] if r["키"] == "EQ000022")["단위"]
def _edit(client: TestClient, key: str, change) -> tuple[dict, dict]:
got = client.get("/api/m01/machine/row", params={"key": key}).json()
row = got["row"]
_save(client, got["version"], {"op": "edit", "key": key, "row": change(row)})
now = client.get("/api/m01/machine/row", params={"key": key}).json()["row"]
return row, now
def test_고침_그_줄_그_칸만(copy: Path) -> None:
client = _app()
before = (copy / MACHINE).read_text(encoding="utf-8")
row, now = _edit(client, "EQ000022", lambda r: {**r, "값": {**r["값"], "가격": 150000}})
assert now == {**row, "값": {**row["값"], "가격": 150000}}
lines = _diff(before, (copy / MACHINE).read_text(encoding="utf-8"))
assert len(lines) == 2 and "142069" in lines[0] and "150000" in lines[1] # 그 칸 한 줄만
def test_입력_같으면_저장_계수_그대로_바뀌면_다시_셈(copy: Path) -> None:
client = _app()
# 가격만 고침 + 화면이 계수를 틀리게 보내도 — 입력이 같으면 원문 표 값(정비비계수 777) 그대로
row, now = _edit(
client,
"EQ000347",
lambda r: {**r, "값": {**r["값"], "가격": 999, "정비비계수": 778, "손료계수": 1}},
)
assert now["값"] == {**row["값"], "가격": 999}
_, now = _edit(client, "EQ000347", lambda r: {**r, "값": {**r["값"], "내용시간": 10000}})
assert {k: now["값"][k] for k in mc.COEFS} == mc.coef(10000, 800, 0.9, 0.7, 0.1)
def test_기계_길은_시스템_관리자만() -> None:
for role in ("USER", "COMPANY_ADMIN"):
client = _app(role)
assert client.get("/api/m01/machine/lists").status_code == 403
assert client.get("/api/m01/machine/hourly", params={"keys": "EQ000022"}).status_code == 403
assert client.post("/api/m01/save", json={"files": []}).status_code == 403
def test_쓰는_곳_찾기(copy: Path, monkeypatch) -> None:
from M02_MasterTemplete import M02_Template_Layers as layers
monkeypatch.setattr(layers, "layer_files", lambda kind: []) # 공유 storage 안 읽음
uses = _app().get("/api/m01/machine/uses", params={"key": "EQ000022"}).json()["uses"]
assert any("GC000267" in u for u in uses)
assert _app().get("/api/m01/machine/uses", params={"key": "EQ999999"}).status_code == 404
@@ -0,0 +1,144 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""821-3 내 기계 화면 읽기 길(`M02_MyMachine_Look`) — 임시 storage · 쓰기 없음.
① 양식 목록 = M01 기계 화면 목록과 같음(연료 · 직종 · 전력 · 시간환산)
② 비슷한 기계 찾기 = 내 기계(앞) + M01 · 원문번호 = 그 M01 기계 먼저 · 남의 층 내 기계 안 보임
③ 내 품목 = 값을 바로 적은 재료비 줄만 · 같은 품목 묶음 · 쓰는 단가산출 ④ 개인 층 밖 = 404.
"""
from __future__ import annotations
import json
import pytest
from fastapi import HTTPException
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete import M02_MyMachine_Look as look
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥
NAME = "서브원임시기계"
def _at(folder, fn, *args):
with store.at(folder):
return fn(*args)
def _mine(folder):
row = {"이름": NAME, "규격": "1㎾", "방식": "바로", "값": {"가격": 1000, "손료계수": 2000}}
return _at(folder, mym.create, row)["줄"]
def test_lists_same_as_m01():
"""① 양식 목록 = M01 기계 화면 값."""
from M01_MasterData import M01_MasterData_Machine as m01m
got, want = look.lists(), m01m.lists()
assert set(got) == {"fuels", "jobs", "power", "hour"}
for k in got:
assert got[k] == want[k], k
assert got["fuels"] and got["jobs"] and got["hour"] == 0.125
def test_find_mine_first_and_number(world):
"""② 내 기계 앞 · 원문번호 = 그 M01 기계 먼저 · 남의 층 = 내 기계 안 보임."""
me, other = world["personal"], layers.personal_dir(7, 43)
key = _mine(me)["키"]
rows = _at(me, look.find, NAME)
assert rows[0]["키"] == key and rows[0]["출처"] == "개인" and rows[0]["값"]["손료계수"] == 2000
assert all(r["출처"] == "M01" for r in _at(other, look.find, NAME))
no = str(m01.loaded()[1].get(DIG)["원문번호"])
first = _at(me, look.find, no)[0]
assert first["키"] == DIG and first["원문번호"] == no and first["출처"] == "M01"
assert isinstance(first["값"]["가격"], int | float) and first["구분"] # 수(글이면 미리보기 0)
dig = [r for r in _at(me, look.find, "굴착기") if r["출처"] == "M01"]
assert dig and len({r["키"] for r in dig}) == len(dig) # 겹침 없음
def _sk(folder, key, name, lines):
"""수식 문서 재료비 줄 — (이름, 연결) · 숫자 = 바로 적은 값 · 요소 = 우리 자재."""
vars_, money = [], []
for i, (item, link) in enumerate(lines):
vars_.append({"열쇠": f"P_x{i}", "기호": "P", "아래": f"x{i}", "위": "", "연결": link})
money.append(
{"이름": item, "규격": "10㎜", "단위": "개", "비목": "재료비", "단가": f"P_x{i}"}
)
money.append({"이름": "인부", "단위": "인", "비목": "노무비", "단가": "P_x0"})
doc = {"키": key, "이름": name, "수식": {"문서": {"변수": vars_, "금액": money}}}
path = folder / "sangeun" / f"{key}.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
def test_items_direct_only(world):
"""③ 바로 적은 값 재료만 · 같은 품목 = 한 묶음 · 쓰는 단가산출 · 값 다르면 따로."""
me = world["personal"]
_sk(me, "SK-000001", "가", [("볼트", {"갈래": "숫자", "값": 500}),
("시멘트", {"갈래": "요소", "키": "MT004176"})]) # fmt: skip
_sk(me, "SK-000002", "나", [("볼트", {"갈래": "숫자", "값": 500}),
("볼트", {"갈래": "입력", "값": 700})]) # fmt: skip
got = _at(me, look.items)
assert [(r["이름"], r["값"]) for r in got] == [("볼트", 500), ("볼트", 700)]
assert [u["키"] for u in got[0]["쓰는곳"]] == ["SK-000001", "SK-000002"]
assert got[1]["쓰는곳"] == [{"키": "SK-000002", "이름": "나"}]
assert got[0]["규격"] == "10㎜" and got[0]["단위"] == "개"
assert _at(layers.personal_dir(7, 43), look.items) == []
def test_personal_only(world):
"""④ 개인 층 밖 = 404 · 남의 개인 층 = 403(일반)."""
user = {"role": "USER", "company_id": 7, "user_id": 42}
for layer, owner, code in (("company", None, 404), ("personal", "7/43", 403)):
with pytest.raises(HTTPException) as e:
look.get_items(layer, owner, user)
assert e.value.status_code == code
assert look.get_items("personal", None, user) == {"rows": []}
def _stack_doc(key, no):
"""줄 쌓기 기계 줄 하나(화면이 저장하는 꼴) — 기계='개인 …'."""
var = {"기호": "P", "아래": "새", "위": "", "이름": NAME, "단위": "",
"연결": {"갈래": "단가산출", "키": "GC000268", "입력": {"기계": f"'{no}'"}}} # fmt: skip
line = {"이름": NAME, "단위": "hr", "비목": "묶음", "수량": "1", "단가": "P_새"}
return {"꼴": "수식문서", "판": 1, "키": key, "이름": "sub1임시", "결과단위": "원",
"변수": [var], "식": [], "금액": [line]} # fmt: skip
def test_stack_line_copy(world):
"""⑤ 줄 쌓기로 고른 내 기계 — 미리 셈 됨 · 저장 때 값 사본에 박힘 →
고쳐도 금액 그대로 + 단가새값 → [단가 갱신] 바뀐 자원 1 → 지워도 마지막 값 + 지운키."""
from M02_MasterTemplete import M02_Doc_Sk_Path as path
from M02_MasterTemplete import M02_Labor_Ref as refs
me = world["personal"]
made = _at(me, mym.create, {**_mine_row(), "값": {**_mine_row()["값"], "주연료": 2,
"주연료종류": "경유", "조종원": 1, "조종원직종": "1048"}}) # fmt: skip
key, no, was = made["줄"]["키"], made["줄"]["원문번호"], made["시간당"]["계"]
sk = _at(
me,
lambda: refs.create("sangeun", {"이름": "sub1임시", "단위": "시간", "더한줄": []})["이름"],
)
doc = _stack_doc(sk, no)
look_ = _at(me, path.preview, sk, {"문서": doc})
assert look_["셈"]["계"] == was # 사본에 아직 없는 내 기계도 셈
판 = _at(me, path.read, sk)["판"]
_at(me, path.save, sk, {"문서": doc, "판": 판})
copy = _at(me, lambda: store.read("sangeun", sk)["문서"]["수식"])
assert key in [r["키"] for r in copy["단가"]] # 값 사본에 박힘
_at(me, mym.update, key, {**made["줄"], "값": {**made["줄"]["값"], "가격": 3000}})
now = _at(me, path.read, sk)
assert now["셈"]["계"] == was and now["사본"]["단가새값"] is True
job = _at(me, path.preview, sk, {"할일": "단가갱신"})
assert [x["키"] for x in job["바뀐자원"]] == [key] and job["후"]["계"] > was
_at(me, mym.delete, key)
gone = _at(me, path.read, sk)
assert gone["셈"]["계"] == was and gone["사본"]["지운키"] == [key]
def _mine_row():
return {"이름": NAME, "규격": "1㎾", "방식": "바로", "값": {"가격": 1000, "손료계수": 2000}}
@@ -0,0 +1,216 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""821-4 지킴 — 불러오기에서 끝내 못 이은 기계 = 내 기계 자동 생성(요구서_796 4 · 5 장 · 임시 storage).
① 구성 → 우리 틀 값(EST 원파일 발전기 5.5kW — 손료계수 · 가격 · 경유 0.625ℓ · 잡품 11 % · 일반기계운전사 1 인 ·
경쟁사 노임 환산 안 가져옴) · 엑셀 「기계경비」 · STmate 「중기사용료」 · 구성 없음 = 직접 꼴 · 달러 = 환율
② 층 저장 길(`FromHopyo.make`) = 만듦 · 다시 불러와도 중복 없음 · 같은 이름 있으면 그것 · 미리 보기는 안 만듦 ·
금액(엑셀 단가 그대로) 그대로 · [우리 단가로] = 내 기계 값 · 「못 이음」 에서 빠짐 · 사본에 박힘
③ 820-1 정리 — 13 줄 · GC001195 없음(대장 폐기 · 결번) · 덤프 1 톤 · 체인톱 식 · 별칭 그대로 ·
빈칸 든 입력 이름 이음 글 왕복(820-2 지킴 옮김) · EST main 504 파워트로웰 블록 금액 546 그대로
"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Doc_FromNative as fn
from M02_MasterTemplete import M02_Doc_Import as dim
from M02_MasterTemplete import M02_Machine_Hourly as mh
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Estx as estx
from M02_MasterTemplete import M02_San_MyMachine as mm
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
from M02_MasterTemplete import M02_Sangeun_Imported as imp
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
ROOT = Path(__file__).resolve().parents[2]
MAIN = ROOT / "resources/knowledge/original/경쟁사 프로그램/EST Plus 2/다산소프트/2026-07-main.estx"
GEN = "E0000001016" # EST main 발전기 5.5kW
GONE = [f"EQ{n:06d}" for n in range(656, 669)]
@lru_cache(maxsize=1)
def _main_raw() -> bytes:
return MAIN.read_bytes()
@lru_cache(maxsize=1)
def _main() -> dict:
return fn.read(MAIN.name, _main_raw())
def _excel(tag: str) -> dict:
import test_797_1a_import_flags as practice
p = practice.FILES.get(tag)
if not p or not p.exists():
pytest.skip(f"실무 엑셀 없음 {tag}")
return book.sheets(p.name, p.read_bytes())
def _line(name: str, spec: str, **res) -> dict:
found = {"판정": "못 찾음", "이름": name, "규격": spec}
return {"종류": "기계", "이름": name, "자원": {"갈래": "기계", "이름": name, "규격": spec, "찾기": found, **res}} # fmt: skip
def _labor(name: str) -> str:
return next(k for k, r in m01.loaded()[1].numbers["LB"].items() if r["이름"] == name)
# ── ① 구성 → 우리 틀 값 ───────────────────────────────────────────────
def test_est_native_generator_values() -> None:
make = estx.makes # 816-2 대조 꼴은 그대로(손료 · 주연료 · 조종원 있음만)
assert make is not None
got = _main()["기계구성"][GEN]
assert got["손료"] == [[2362.0, 1063000.0, False]] and got["주연료"] == 0.625
assert (got["주연료종류"], got["잡재료율"], got["조종원"], got["조종원직종"]) == ("경유", 11.0, 1.0, "일반기계운전사") # fmt: skip
body = mm.body(got, 0, _line("발전기", "5.5kW", 코드=GEN), "main.estx")
want = {"가격": 1063.0, "손료계수": 2362.0, "주연료": 0.625, "주연료종류": "경유", "잡재료율": 11.0,
"조종원": 1.0, "조종원직종": _labor("일반기계운전사")} # fmt: skip
assert body["방식"] == "바로" and body["값"] == want
assert body["비고"].startswith("불러오기 자동 생성 — main.estx · 값 확인 필요")
def test_dollar_and_combo_pieces() -> None:
made = _main()["기계구성"]
pipe = mm.body(made["E0000000623"], 0, _line("콘크리트펌프용 파이프", "150㎜×1m"), "f")
rate = float(m01.loaded()[1].index["FX"]["FX000001"]["값"])
assert abs(pipe["값"]["가격"] - 25 * rate / 1000) < 1e-3 and "$25" in pipe["비고"]
combo = made["E0000001170"] # 대형브레이카 +굴착기(1.0㎥) — 조각 둘
first = mm.body(combo, 0, _line("굴착기", "1.0㎥"), "f")["값"]
second = mm.body(combo, 1, _line("대형브레이카", "1.0㎥"), "f")["값"]
assert first["손료계수"] == 2085 and first["주연료"] == 19.5 and first["조종원"] == 1
assert second == {"가격": 28692.0, "손료계수": 6601.0} # 다음 조각 = 손료만
assert mm.body(None, 1, _line("x", "1"), "f") is None # 구성 없는 조합 조각 = 안 만듦
def test_excel_sheets() -> None:
est = mm.xlsx_makes(_excel("2016사방"))[mm.key_of("발전기", "5.5kW")]
assert est["손료"] == [[2294.0, 1167000.0, False]] and est["주연료"] == 0.625
assert (est["잡재료율"], est["조종원"], est["조종원직종"]) == (11.0, 1.0, "일반기계운전사")
stm = mm.xlsx_makes(_excel("봉화"))["X00001"] # STmate 중기사용료 — 계수(천원당) × 10⁴
assert stm["손료"] == [[2085.0, 62550000.0, False]]
assert (stm["주연료종류"], stm["주연료"], stm["잡재료율"], stm["조종원직종"]) == ("경유", 5.0, 21.0, "건설기계운전사") # fmt: skip
def test_direct_without_make() -> None:
line = _line("소형기계", "1대", 단가={"L": 10, "M": 20, "S": 30})
got = mm.body(None, 0, line, "a.stc")
assert got["방식"] == "직접" and got["시간당"] == {"노무비": 10, "재료비": 20, "경비": 30}
# ── ② 층 저장 길 ──────────────────────────────────────────────────────
TROWEL = 504 # EST main 「콘크리트 바닥 마감(Power Trowel)」 — 파워트로웰 3.73kW = 이제 품셈에 없음
WANT = ("파워트로웰", "3.73kW")
def test_generator_ours_formula(world) -> None:
"""발전기 5.5kW 내 기계 시간당 노무비 = 우리 노임 ÷ 8 × 1 인(EST 의 ×16/12 ×25/20 안 씀)."""
me = world["personal"]
me.mkdir(parents=True, exist_ok=True)
body = mm.body(_main()["기계구성"][GEN], 0, _line("발전기", "5.5kW"), "main.estx")
with store.at(me):
key = mym.create(body)["줄"]["키"]
hour = mh.hourly(key)
lb = m01.loaded()[1].numbers["LB"][_labor("일반기계운전사")]
assert hour["ok"] and hour["로직"] == "GC000268"
assert abs(hour["노무비"] - float(lb["값"]) / 8) < 1
def test_make_flow(world) -> None:
me = world["personal"]
me.mkdir(parents=True, exist_ok=True)
with store.at(me):
hp = fh.upload_native(me, MAIN.name, _main_raw(), {"이름": "sub3임시"})["키"]
out = fh.emit(me, hp, TROWEL) # 미리 보기 = 안 만듦 · 예정만
assert {"이름": WANT[0], "규격": WANT[1]} in out["원본"]["내기계예정"]
assert mym.rows() == []
got = fh.make(me, hp, TROWEL)
made = {(m["이름"], m["규격"]): m for m in got["내기계"]}
one = made[WANT]
assert one["새로"] and one["키"].startswith("EQP42-") and one["방식"] == "바로"
assert got["검산"]["같음"] # 금액(엑셀 단가 그대로) 그대로
n = len(mym.rows())
again = fh.make(me, hp, TROWEL) # 다시 불러옴 = 중복 없음 · 같은 SK
assert len(mym.rows()) == n and again["다시씀"]
assert not any(m["새로"] for m in again["내기계"])
doc = store.read("sangeun", got["이름"])["문서"]
assert doc[imp.KEY]["원본"]["내기계"][0]["키"] == one["키"]
items = doc[imp.KEY]["로직"]["호표"]
mine = [x for x in items if (x.get("자원") or {}).get("내기계") == one["키"]]
assert mine and all(f"개인 {one['키']}" in str(x.get("이음")) for x in mine)
assert not [
u for u in imp.unlinked(doc) if "파워트로웰" in u["이름"]
] # 「못 이음」 에서 빠짐
row = next(r for r in mym.rows() if r["키"] == one["키"])
assert row["값"]["가격"] == 2680 and row["값"]["주연료종류"] == "휘발유"
assert row["값"]["전력"] == 3.73 and "값 확인 필요" in row["비고"]
ours = imp.to_price(doc, "우리") # [우리 단가로] = 내 기계 값
assert ours["후"]["계"] is not None and ours["후"] != ours["전"]
assert not [u for u in ours["이음없음"] if "파워트로웰" in u["이름"]]
assert one["키"] in {r["키"] for r in (doc.get(skc.KEY) or {}).get("단가") or []} # 사본
conv = dim.derive(doc, None, imp._whole())
assert f"내 기계 {one['키']}" in json.dumps(
conv["변수"], ensure_ascii=False, default=str
) # 꼬리표
def test_reuse_same_name(world) -> None:
me = world["personal"]
me.mkdir(parents=True, exist_ok=True)
with store.at(me):
have = mym.create({"이름": WANT[0], "규격": WANT[1], "방식": "바로", "값": {"가격": 9, "손료계수": 9}}) # fmt: skip
out = fn.emit(_main(), TROWEL)
made = mm.attach(out, _main()["기계구성"])
assert [(m["키"], m["새로"]) for m in made] == [(have["줄"]["키"], False)]
assert len(mym.rows()) == 1
def test_not_personal_makes_nothing(world) -> None:
out = fn.emit(_main(), TROWEL)
with store.at(world["company"]):
assert mm.attach(out, _main()["기계구성"]) == []
assert out["원본"]["내기계예정"] == [{"이름": WANT[0], "규격": WANT[1]}]
# ── ③ 820-1 정리 ─────────────────────────────────────────────────────
def test_cleanup_820_1() -> None:
files, whole = m01.loaded()
eq = whole.index["EQ"]
assert not set(GONE) & set(eq) and "EQ000655" in eq and eq["EQ000655"]["규격"] == "1ton"
assert not [r for r in eq.values() if str(r.get("원문번호") or "").startswith("자체 ")]
logics = {r["키"] for d in files.values() if d.get("그룹") == "로직" for r in d.get("줄") or []}
assert "GC001195" not in logics and "GF000337" in logics and "GF000145" in logics
text = json.dumps(
[d for d in files.values() if d.get("그룹") == "로직"], ensure_ascii=False, default=str
)
assert "자체 발전기" not in text and "자체 콘크리트펌프용" not in text
led = json.loads((ROOT / "resources/master_data/_키대장.json").read_text("utf-8"))
assert all(k in led["폐기"] and k not in led["키"] for k in [*GONE, "GC001195"])
assert led["다음"]["EQ"] == 669 and led["다음"]["GC"] == 1196 # 결번 · 재사용 안 함
alias = [r for r in eq.values() if r.get("별칭")]
assert len(alias) >= 10
def test_blank_input_name_roundtrip() -> None:
"""820-2 지킴 옮김 — 빈칸 든 입력 이름은 따옴표로 이음 글 → 읽기 왕복."""
text = f"로직(GC000268, {dim._q('전력 계약종별')}='일반용(갑)Ⅰ 저압', 기계='0101-0007')"
got = dim._logic_link(text)
assert got and got["입력"]["전력 계약종별"] == "'일반용(갑)Ⅰ 저압'"
def test_main_504_amount_same() -> None:
"""EST main 504 「콘크리트 바닥 마감(Power Trowel)」 = 원파일 저장 금액 546(파워트로웰 = 이제 못 이음 · 엑셀 값)."""
import test_810_2_native_doc as t
out = fn.emit(_main(), 504)
_, _, got = t.doc_and_money(out)
assert float(got["계"]) == 546
+113
View File
@@ -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
View File
@@ -0,0 +1,70 @@
"""EST 테스트 앱 표시 설정(원본 연차공사 환경설정 TfrmEstMultiOption — 비목순서·구분 표시·품명정렬)."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_column_order_gubun_and_align():
code = r"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({});
vm.runInContext(fs.readFileSync('tools/est-test/native-display.js', 'utf8'), ctx);
const D = vm.runInContext('EstDisplay', ctx);
const cols = ['Code', 'Name', 'ValueIndex', 'GN', 'TU', 'NU', 'GU', 'JU', 'TC', 'NC', 'GC', 'JC', 'SC'];
const o = (Display) => ({ Display });
process.stdout.write(JSON.stringify({
base: D.columns({}, cols),
jae: D.columns(o({ order: 3 }), cols),
four: D.columns(o({ order: 4, gubun: false }), cols),
align: [D.nameStyle({}), D.nameStyle(o({ align: 2 }))],
}));
"""
out = json.loads(subprocess.check_output(["node", "-e", code], cwd=ROOT).decode("utf-8"))
assert out["base"] == [
"Code",
"Name",
"ValueIndex",
"GN",
"TU",
"NU",
"GU",
"JU",
"TC",
"NC",
"GC",
"JC",
"SC",
]
assert out["jae"] == [
"Code",
"Name",
"ValueIndex",
"GN",
"GU",
"NU",
"JU",
"TU",
"GC",
"NC",
"JC",
"SC",
"TC",
]
assert out["four"] == [
"Code",
"Name",
"GN",
"TU",
"NU",
"GU",
"JU",
"TC",
"NC",
"GC",
"JC",
"SC",
]
assert out["align"] == ["", "text-align:center"]
+16
View File
@@ -132,3 +132,19 @@ def test_total_price_without_split_is_rejected_and_replace_mode():
assert result["length"] is True
assert result["first"] == "토공사"
assert result["net"] == 54
def test_shift_column_letters_like_original():
out = run_js(
r"""(() => {
const keys = EstExcelMap.fields.map(([k]) => k);
const right = EstExcelMap.shift({ Code: 'A', Name: 'Q' }, 1);
const left = EstExcelMap.shift({ Code: 'C' }, -1);
let low = ''; try { EstExcelMap.shift({ Code: 'A' }, -1); } catch (e) { low = e.message; }
return { right: keys.map((k) => right[k]).slice(0, 4), last: right[keys.at(-1)], left: left.Code, low };
})()"""
)
assert out["right"] == ["B", "C", "D", "E"]
assert out["last"] == "P"
assert out["left"] == "B"
assert out["low"] == '시작 부호는 "A" 이하로는 할수 없습니다.'
+134
View File
@@ -0,0 +1,134 @@
"""EST 테스트 앱 엑셀형 수량산출서 계산·편집(원본 TFRMMULEXCEL, .xls BIFF 수식)."""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
MAIN = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
def node(script: str, *args: str):
return json.loads(
subprocess.check_output(["node", "-e", script, *args], cwd=ROOT).decode("utf-8")
)
def test_evaluate_and_directional_sum():
out = node("""
const X = require(process.cwd() + '/tools/est-test/native-excel-sheet.cjs');
const cells = { '0,0': { value: '품명' }, '1,0': { value: 2 }, '2,0': { value: 3 }, '3,0': {},
'1,1': { value: 0, formula: '(A2+A3)/2*4' }, '1,2': { value: 0, formula: 'SUM(A2:B2)' },
'4,4': { value: 0, formula: 'ROUND(10/3,2)+ROUNDUP(1.21,1)+ROUNDDOWN(1.29,1)' }, '5,5': { value: 0, formula: 'F6+1' } };
const errors = X.recalc(cells);
const down = X.sumFormula(cells, 3, 0, 'up');
let none = ''; try { X.sumFormula({ '0,0': { value: 'x' } }, 1, 0, 'up'); } catch (e) { none = e.message; }
process.stdout.write(JSON.stringify({ b2: cells['1,1'].value, c2: cells['1,2'].value, e5: cells['4,4'].value,
cycle: [cells['5,5'].value, errors.length], down, none }));
""")
assert out["b2"] == 10 and out["c2"] == 12
assert out["e5"] == 3.33 + 1.3 + 1.2
assert out["cycle"] == ["#오류", 1]
assert out["down"] == "SUM(A2:A3)"
assert out["none"] == "더할 셀이 없습니다."
def test_real_sheets_recompute_and_link_items():
if not MAIN.exists():
pytest.skip("installed EST main sample unavailable")
out = node(
"""
const fs = require('fs'), vm = require('vm');
const imp = require(process.cwd() + '/tools/est-test/import-estx.cjs'), ex = require(process.cwd() + '/tools/est-test/export-estx.cjs');
const p = imp.importEstx(fs.readFileSync(process.argv[1]));
const s = p.quantitySheets.find((x) => x.Code === 'N0000000136');
const formulas = p.quantitySheets.filter((x) => x.excel).flatMap((x) => Object.values(x.excel[0].cells)).filter((c) => c.formula);
const same = Buffer.compare(ex.exportEstx(p), fs.readFileSync(process.argv[1])) === 0;
const ctx = vm.createContext({ structuredClone });
for (const f of ['native-math.js', 'native-formula.js', 'native-text.js', 'native-excel-sheet.cjs', 'native-quantity.cjs'])
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, { filename: f });
ctx.s = s;
const before = vm.runInContext('EstQuantity.calculate(s).items.map((i) => i.GN)', ctx);
vm.runInContext("s.excel[0].cells['6,2'].value = 40; EstExcelSheet.recalc(s.excel[0].cells)", ctx);
const after = vm.runInContext('EstQuantity.calculate(s).items.map((i) => i.GN)', ctx);
s.excelEdited = true;
let message = ''; try { ex.exportEstx(p); } catch (e) { message = e.message; }
process.stdout.write(JSON.stringify({ decoded: formulas.every((c) => typeof c.formula === 'string'), count: formulas.length,
links: Object.fromEntries(Object.entries(s.excelLinks).map(([k, l]) => [k, [l.row, l.col, l.inferred]])), before, after, same, message }));
""",
str(MAIN),
)
assert out["decoded"] is True and out["count"] == 221
assert out["links"] == {
"V000001": [32, 4, True],
"V000002": [32, 6, True],
"V000003": [32, 8, True],
}
assert out["before"][:3] == [17, 12, 58]
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}
+45
View File
@@ -0,0 +1,45 @@
"""EST 테스트 앱 단가산출 전역변수(원본 \x05 표지 — 호표를 넘어 값 유지, 지정·해제, 목록)."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_global_variable_crosses_groups_and_toggle():
code = r"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({ structuredClone });
for (const f of ['native-math.js', 'native-formula.js', 'native-text.js', 'native-global-vars.js'])
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, { filename: f });
const T = vm.runInContext('EstText', ctx), G = vm.runInContext('EstGlobalVars', ctx);
const rows = [
{ Index: 1, Code: 'D1', LineData: '호표1' },
{ Index: 0, SCheck: 0, LineData: '\x05Q\x05 = 2 × 3 = \x126\x12' },
{ Index: 1, Code: 'D2', LineData: '호표2' },
{ Index: 0, SCheck: -1, LineData: '노무 = Q × 10 = \x120\x12' },
{ Index: 1, Code: 'D3', LineData: '호표3' },
{ Index: 0, SCheck: 0, LineData: 'R = 1 = \x121\x12' },
{ Index: 1, Code: 'D4', LineData: '호표4' },
{ Index: 0, SCheck: -1, LineData: '노무 = R × 10 = \x120\x12' },
];
const r = T.calculate(rows, {});
let bad = ''; try { G.toggle('가 = 3', 0); } catch (e) { bad = e.message.slice(0, 12); }
process.stdout.write(JSON.stringify({
used: r.rows[3].LineData.match(/\x12([^\x12]+)\x12/)[1], kept: r.rows[1].LineData.startsWith('\x05Q\x05'),
local: r.errors.length, globals: r.globals,
on: G.toggle('Q = 2', 0), off: G.toggle('⟦전역⟧Q⟦전역⟧ = 2', 5), bad,
clean: T.clean('\x05Q\x05 = 1'), shown: T.display('\x05V1\x05'), back: T.encode('⟦전역⟧V1⟦전역⟧') === '\x05V1\x05',
}));
"""
out = json.loads(subprocess.check_output(["node", "-e", code], cwd=ROOT).decode("utf-8"))
assert out["used"] == "60"
assert out["kept"] is True
assert out["local"] == 1 # R 은 전역이 아니라 D4 에서 못 찾음
assert out["globals"] == [{"name": "Q", "value": 6, "index": 1}]
assert out["on"] == "⟦전역⟧Q⟦전역⟧ = 2"
assert out["off"] == "Q = 2"
assert out["bad"] == "선택한 변수는 전역변수"
assert out["clean"] == "Q = 1"
assert out["shown"] == "⟦전역⟧V1⟦전역⟧" and out["back"] is True
+32
View File
@@ -0,0 +1,32 @@
"""EST 테스트 앱 JDLExcel과 단가비교(원본 ActionJDLCompare — Excel 코드 칸이 코드를 품으면 같은 행 합계와 TC 비교)."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_compare_contains_code_limit_and_xls_tables():
code = r"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({});
vm.runInContext(fs.readFileSync('tools/est-test/native-jdl-compare.js', 'utf8'), ctx);
const J = vm.runInContext('EstJdlCompare', ctx);
const rows = [{ Code: 'U001', Name: '가', TC: 1000 }, { Code: 'U002', Name: '나', TC: 500 },
{ Code: 'U003', Name: '다', TC: 7 }, { Code: '', Name: '머리' }];
const table = [['코드', '', '합계'], ['[U001] 가', '', '1,000'], ['U002', '', 499]];
const out = J.compare(rows, table, 0, 2).map((r) => [r.Code, r.OTC, r.GN, r.Checked]);
const far = Array.from({ length: 60 }, () => ['x']); far.push(['U009', '', 5]);
const limit = J.compare([{ Code: 'U009', TC: 5 }], far, 0, 2)[0].OTC;
const xls = J.xlsTables([{ name: 'S1', cells: { '0,0': { value: 'U001' }, '0,7': { value: 1000 }, '2,1': { value: 'b' } } }]);
process.stdout.write(JSON.stringify({ out, limit, xls: xls.S1 }));
"""
out = json.loads(subprocess.check_output(["node", "-e", code], cwd=ROOT).decode("utf-8"))
assert out["out"] == [
["U001", 1000, 0, False],
["U002", 499, 1, True],
["U003", None, None, True],
]
assert out["limit"] is None
assert out["xls"][0][0] == "U001" and out["xls"][0][7] == 1000 and out["xls"][2][1] == "b"
+19
View File
@@ -34,3 +34,22 @@ def test_search_finds_words_in_selected_kinds(tmp_path):
assert result["none"]["total"] == 0
assert result["excluded"]["total"] == 0
assert result["err"] == ["검색어를", "검색할 "]
def test_search_price_range_and_main_data_only(tmp_path):
sample = json.loads((ROOT / "tools/est-test/sample.json").read_text(encoding="utf-8"))
(tmp_path / ("a" * 24 + ".json")).write_text(json.dumps(sample), encoding="utf-8")
(tmp_path / ("c" * 24 + ".json")).write_text(json.dumps(sample), encoding="utf-8")
script = """
const s = require('./tools/est-test/search-store.cjs'), dir = process.argv[1];
const range = s.search(dir, '1~999999999', ['5']);
const exact = s.search(dir, String(range.hits[0].price), ['5']);
const only = s.search(dir, '1~999999999', ['5'], '', ['c'.repeat(24)]);
process.stdout.write(JSON.stringify({ range: range.hits.map((h) => h.price), exact: exact.total, only: only.hits.map((h) => h.id), total: range.total }));
"""
out = json.loads(
subprocess.check_output(["node", "-e", script, str(tmp_path)], cwd=ROOT).decode("utf-8")
)
assert out["range"] and all(1 <= p <= 999999999 for p in out["range"])
assert out["exact"] >= 2
assert set(out["only"]) == {"c" * 24} and len(out["only"]) == out["total"] // 2
@@ -0,0 +1,95 @@
"""EST 테스트 앱 기성 원가계산서 편집(원본 TfrmWongaAccomH/V 팝업) — 회차별 원가 서식 사본."""
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-wonga-tools.js",
"native-progress-wonga.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_progress_wonga_edits_apply_to_round_copy_only():
result = run_js(
r"""(() => {
const p = EstNative.calculate(EstModel.validate(structuredClone(source)));
const inputs = {};
p.tables.EstMainDat.forEach((row, i) => {
if (row.Code && !Number(row.Level)) inputs[EstBusiness.rowKey(row, i)] = { Gum: 50, GumMode: 'ratio' };
});
p.progress = [{ id: 'one', name: '1회', inputs }];
const round = p.progress[0], ops = EstProgressWonga.ops;
const calc = () => EstProgress.calculate(p, 'one');
const plain = (t) => String(t).replace(/\s+/g, '');
const base = calc();
const anjun = base.costs.findIndex((r) => plain(r.Name).includes('산업안전') && Number(r.CalcIndex) !== 2);
const total = base.costs.findIndex((r) => Number(r.LinkIndex) === 55);
const out = { errors: base.errors, baseTotal: base.total, length: base.costs.length };
ops.insert(p, round, 1);
const ins = calc();
out.inserted = [ins.costs.length, ins.costs[1].Name, ins.costs[1].ContractCost, ins.costs[2].ContractCost === base.costs[1].ContractCost, p.tables.Wonga.length];
ops.undo(p, round);
out.undone = ['wonga' in round, 'wongaUndo' in round];
ops.position(p, round, anjun, 2, 3);
const cut = calc();
out.anjun = [base.costs[anjun].GumCost, cut.costs[anjun].GumCost, cut.costs[anjun].ContractCost];
ops.total(p, round, 4);
const fixed = calc();
out.total = [cut.total, fixed.total, fixed.errors];
ops.clear(p, round, 0);
const r0 = round.wonga[0];
out.cleared = [r0.VSusic, r0.Cost, r0.Youl, r0.Diget, r0.Name !== ''];
ops.init(p, round);
out.init = ['wonga' in round, 'wongaTotalDigits' in round, calc().total === base.total];
ops.undo(p, round);
out.reinit = Array.isArray(round.wonga);
ops.init(p, round);
round.locked = true;
try { ops.append(p, round); } catch (e) { out.locked = e.message; }
return out;
})()"""
)
assert result["errors"] == []
assert result["baseTotal"] > 0
n = result["length"]
assert result["inserted"] == [n + 1, "", 0, True, n]
assert result["undone"] == [False, False]
before, after, contract = result["anjun"]
assert after == before // 1000 * 1000 and after != before and contract > 0
cut_total, fixed_total, errors = result["total"]
assert fixed_total == cut_total // 10000 * 10000 and errors == []
assert result["cleared"] == ["", 0, "", 0, True]
assert result["init"] == [False, False, True]
assert result["reinit"] is True
assert result["locked"] == "확정한 회차는 고칠 수 없습니다."
+53
View File
@@ -0,0 +1,53 @@
"""EST 테스트 앱 줄 연결 도구(원본 비목별 단가 불러오기 · SUM 색인지정 · 하위데이타 연결/끊기)."""
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 }});
for (const f of ['native-autolink.js', 'native-row-links.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], cwd=ROOT).decode("utf-8"))
def test_cost_part_sum_index_and_toggle():
out = run_js(
r"""(() => {
const L = EstRowLinks, p = { tables: {
CostTitleDat: [{ Code: 'D1', Name: '거푸집', Size: '합판', Unite: 'm2', TC: 60, NC: 40, GC: 15, JC: 5, SC: 0 }],
StuffcDat: [{ Code: 'M1', Name: '시멘트', Size: '40kg', Unite: '포', TC: 5000, GC: 5000 }],
LaborcDat: [], HeadTitleDat: [], MachTitleDat: [], SilJukDat: [], EstiMateDat: [] } };
const r = { LinkIndex: 104, Code: 'D1', NU: 1, GU: 1, JU: 1, SU: 1, TU: 9 };
L.costPart(p, r, 3);
const jc = [r.NU, r.GU, r.JU, r.SU, r.OCheck, r.TU];
L.costPart(p, r, 0);
const all = [r.NU, r.GU, r.JU, r.OCheck];
let refused = ''; try { L.costPart(p, { LinkIndex: 5 }, 0); } catch (e) { refused = e.message; }
const est = { Code: '', Name: '시멘트', Size: '40kg', Unite: '포', LinkIndex: 0 };
const linked = [L.toggle(p, 'EstMainDat', est), est.Code, est.LinkIndex, est.GU, est.TU];
const unlinked = [L.toggle(p, 'EstMainDat', est), est.LinkIndex, est.Code];
let none = ''; try { L.toggle(p, 'EstMainDat', { Name: '없음', LinkIndex: 0 }); } catch (e) { none = e.message; }
const skip = L.toggle(p, 'EstMainDat', { LinkIndex: 54 });
const s = { LinkIndex: 0, Index: 0 }; L.sumIndex('EstMainDat', s, 3);
const bad = []; for (const row of [{ Level: 1, GN: 0 }, { LinkIndex: 53 }, { Index: 1 }])
try { L.sumIndex('EstMainDat', row, 1); } catch (e) { bad.push(e.message.slice(0, 6)); }
return { jc, all, refused, linked, unlinked, none, skip, skind: s.SKind, bad };
})()"""
)
assert out["jc"] == [0, 0, 5, 0, 3, 9]
assert out["all"] == [40, 15, 5, 0]
assert out["refused"] == "노임,자재,표준시장단가는 적용할수 없습니다."
assert out["linked"] == ["link", "M1", 5, 5000, 5000]
assert out["unlinked"] == ["unlink", 0, "M1"]
assert out["none"] == "해당 하위data를 찾지못해 연결시키지 못했습니다."
assert out["skip"] == "skip"
assert out["skind"] == 3
assert out["bad"] == ["레벨지정한 ", "현재라인의 ", "현재라인의 "]
+60
View File
@@ -0,0 +1,60 @@
"""EST 테스트 앱 발췌근거(원본 barbtnColumn — 가격 출처 열 이름·보이기, 숨긴 출처는 단가 고르기에서 제외)."""
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-sources.js",
"native-engine.js",
"native-model.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 }});
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_hidden_source_leaves_price_choice_and_labels():
out = run_js(
r"""(() => {
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.tables.StuffcDat = [{ Code: 'M1', Name: '철근', Cost1: 100, Cost2: 80, Cost3: 120, ValueIndex: 0 },
{ Code: 'M2', Name: '모래', Cost1: 100, Cost2: 80, Cost3: 120, ValueIndex: 2 }];
const tc = (q) => EstNative.calculate(q).tables.StuffcDat.map((r) => r.GC);
const before = tc(p);
const list = EstSources.get(p.options, 'StuffcDat');
list[1] = { caption: '물가자료', page: '120', visible: false };
list[2] = { caption: '유통물가', page: '', visible: false };
EstSources.set(p.options, 'StuffcDat', list);
const after = tc(p);
let dup = '';
try { EstSources.set(p.options, 'StuffcDat', list.map(() => ({ caption: '가격정보' }))); } catch (e) { dup = e.message; }
EstSources.set(p.options, 'LaborcDat', list.map(() => ({ caption: '같음' })));
return { before, after, dup, label: EstSources.label(p.options, 'StuffcDat', 'Cost2'),
other: EstSources.label(p.options, 'EstMainDat', 'Cost2'),
cols: EstSources.columns(p.options, 'StuffcDat', ['Code', 'Cost1', 'Cost2', 'Cost3', 'TC']) };
})()"""
)
assert out["before"] == [80, 120]
assert out["after"] == [100, 100]
assert out["dup"] == "같은 발췌근거가 있습니다."
assert out["label"] == "물가자료 (120)"
assert out["other"] == ""
assert out["cols"] == ["Code", "Cost1", "TC"]
+79
View File
@@ -87,3 +87,82 @@ def test_gita_picks_smaller_base_and_validates():
assert result["errors"] == ["1 이하", "기타경비"]
assert result["none"] == "산업안전보건관리비 비목이 없습니다."
assert result["stored"] == [0.4, 0.6]
def test_diget_round_follows_original_engine():
result = run_js(
r"""(() => {
const r = (Diget, DigetIndex, v) => EstWonga.digetRound({ Diget, DigetIndex }, v);
const row = (Diget, DigetIndex, LinkIndex, v) => EstWonga.rowCost({ Diget, DigetIndex, LinkIndex }, v);
return {
none: r(3, 0, 1660273), half: r(3, 1, 1660500), cut: r(3, 2, 1660999), up: r(3, 3, 1660001),
other: r(2, 7, 1234), neg: r(3, 2, -1660999), dec: r(-2, 1, 1.236),
nearly: row(0, 0, 0, 1660273.99996), keep55: row(3, 2, 55, 79603324), cut1: row(1, 2, 0, 1234.9),
p: (() => {
const p = EstNative.calculate(EstModel.validate(structuredClone(source)));
const plain = (t) => String(t).replace(/\s+/g, '');
return p.tables.Wonga.filter((w) => /산업안전|이윤|도급액/.test(plain(w.Name))).map((w) => [plain(w.Name), w.Cost]);
})(),
};
})()"""
)
assert [result[k] for k in ("none", "half", "cut", "up", "other", "neg")] == [
1660273,
1661000,
1660000,
1661000,
1200,
-1660000,
]
assert abs(result["dec"] - 1.24) < 1e-9
assert result["nearly"] == 1660274
assert result["keep55"] == 79603324
assert result["cut1"] == 1230
costs = [cost for _, cost in result["p"]]
assert {1660273, 1887446, 7620104, 79603000} <= set(costs)
def test_position_sets_row_digits_like_original():
result = run_js(
r"""(() => {
const calc = (p) => EstNative.calculate(EstModel.validate(p));
const p = calc(structuredClone(source));
const plain = (t) => String(t).replace(/\s+/g, '');
const rows = p.tables.Wonga;
const anjun = rows.findIndex((r) => plain(r.Name).includes('산업안전') && Number(r.CalcIndex) !== 2);
const total = rows.findIndex((r) => Number(r.LinkIndex) === 55);
EstWongaTools.position(p, [anjun], 2, 3);
const cut = calc(p).tables.Wonga[anjun].Cost;
EstWongaTools.position(p, [anjun], 0, 3);
const reset = calc(p).tables.Wonga[anjun].Cost;
let refused = '';
try { EstWongaTools.position(p, [total], 2, 3); } catch (e) { refused = e.message; }
const input = { options: {}, tables: { Wonga: [{ Name: '관급자재대', Cost: 1234567 }] } };
EstWongaTools.position(input, [0], 1, 2);
return { cut, reset, refused, input: input.tables.Wonga[0], last: input.options.WongaLastDiget };
})()"""
)
assert result["cut"] == 1660000
assert result["reset"] == 1660273
assert result["refused"].startswith("도급액 역산 자리수")
assert result["input"]["Cost"] == 1234600 and result["input"]["TC"] == 1234567
assert result["input"]["Diget"] == 2 and result["input"]["DigetIndex"] == 1
assert result["last"] == 2
def test_abbreviate_formula_like_original_sname_show():
result = run_js(
r"""(() => {
const rows = source.tables.Wonga, a = EstWongaTools.abbreviate(rows, source.options);
const by = (g) => a[rows.findIndex((r) => r.Gubun === g)];
const nf = EstWongaTools.abbreviate([{ Index: 1, NumStr: '1', Name: '직 접 노 무 비' },
{ NumStr: '2', VSusic: '1 × 0.05', Youl: '5%' }], { VerFormat: false });
return { five: by('5'), c: by('C'), e: by('E'), empty: a[0], nf: nf[1], same: rows.every((r, i) => r.VSusic === source.tables.Wonga[i].VSusic) };
})()"""
)
assert result["five"] == "직.노 × 16.5%"
assert result["c"] == "( 산출경비 ~ 18 )"
assert result["e"] == "순.공 × 8%"
assert result["empty"] == ""
assert result["nf"] == "직접노무비 × 5%"
assert result["same"] is True
+3 -2
View File
@@ -42,8 +42,9 @@ def test_rejects_non_ole_and_renders_grid():
const fs = require('fs'), vm = require('vm');
const { readXls } = require(process.cwd() + '/tools/est-test/xls-read.cjs');
let message = ''; try { readXls(Buffer.from('not an xls file at all')); } catch (e) { message = e.message; }
const ctx = vm.createContext({ structuredClone, EstApp: { esc: (v) => String(v), number: (v) => String(v) } });
vm.runInContext(fs.readFileSync('tools/est-test/native-quantity-sheets.js', 'utf8'), ctx);
const ctx = vm.createContext({ structuredClone, EstApp: { esc: (v) => String(v), number: (v) => String(v), state: {} } });
for (const f of ['native-excel-sheet.cjs', 'native-excel-view.js', 'native-quantity-sheets.js'])
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx);
ctx.sheet = { excel: [{ name: 'S', cells: { '0,0': { value: '측점' }, '1,1': { value: 3, formula: true } } }] };
const html = vm.runInContext('EstQuantitySheets.excelView(sheet)', ctx);
process.stdout.write(JSON.stringify({ message, html: [html.includes('측점'), html.includes('<th>B</th>'), html.includes('font-weight:bold'), vm.runInContext('EstQuantitySheets.excelView({})', ctx)] }));
+68 -2
View File
@@ -472,7 +472,13 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- [ ] 엑셀형 수량산출서(원본 TFRMMULEXCEL, MulMainDat.Editor = OLE2 .xls BIFF8)
- [x] 1단계 읽기 — `xls-read.cjs`(복합문서·SST/CONTINUE·LABELSST·NUMBER·RK·MULRK·LABEL·BOOLERR·FORMULA 저장값), 가져올 때 `sheet.excel`, 수량산출 화면 읽기 전용 격자(수식 셀 굵게).
- 검증완료: `test_est_xls_read.py` 2개(설치 main.estx 엑셀형 6개·토적계산표 B2·No.1·20·수식 16개, OLE 아님 거부·격자 그리기), 수량 시험 포함 22개 통과. 엑셀형 붙인 뒤에도 ESTX 원본 그대로 저장 동일. ORCA 브라우저에서 토적계산표 격자 표시·수식 칸 16개 확인 후 샘플 복구.
- 남음: 셀 편집·수식 계산·4방향 합계·품명/규격/수량 지정(항목과 셀 대응 근거 미확인), .xls 저장.
- [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), 샘플 복구.
- [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, 임시 작업 삭제.
- [x] 4단계 — 셀 서식 소수자리(actFormatCell 일부: 0~4자리·해제), 미리보기·인쇄(tbsPreView·tbsPrint — 고른 시트 격자만 새 창에서 인쇄).
- 검증완료: 바인딩 시험 통과. ORCA 브라우저에서 =10/3 → 소수 2자리 「3.33」, 인쇄 창 html 에 표(도구 단추 없음), 샘플 복구.
- 남음: 원본 셀↔항목 대응 근거, 셀 서식의 글꼴·테두리, .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 저장 거부.
@@ -483,7 +489,67 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일).
- [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지.
- 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구.
- [ ] 원가 행 자리수(Diget·DigetIndex) 반영 — 웹 원가 엔진은 행 자리수를 계산에 쓰지 않음(선택항목 자리수 조정은 값만 기록). 설치 자료 중 sample.estx 만 Diget 사용(도급액 3·산업안전 3, DigetIndex 0)이고 산업안전 1,660,273 처럼 천단위가 아닌 값이 있어 DigetIndex 0 의 뜻(사사오입/없음) 확정 불가 — 근거 확보 전 엔진 변경 보류. 기성 원가계산서 편집(절하·한줄 삽입 등)도 같은 근거가 필요해 보류.
- [x] 수량산출서 삽입·선택공종 삭제(원본 TFRMMULLANG btnInsert·btnSelectDel) — 「삽입」: 현재 산출서 앞에 공종명칭·규격을 물어 새 산출서, 「선택공종 삭제」: 고른 여러 산출서를 한 번에 지움.
- 검증완료: 산출서·바인딩 시험 7개 통과. ORCA 브라우저에서 둘째 위치에 「끼운 공종/1m」 삽입, 1·3번째 고른 삭제 → 「끼운 공종」만 남음, 샘플 복구.
- [x] 새내역서 파일로저장(원본 mnuSaveNewEst 0x113384c) — 현재 화면은 그대로, 표 12개의 「_」 당초값 칸 비움·신규 표시 끔·원가 안전관리비 특례 줄(CalcIndex 4)→1·당초 사본 뺀 새 작업을 이름 붙여 서버에 따로 저장하고 내려받기. 재계산 없음.
- 검증완료: 바인딩 시험 통과. ORCA 브라우저에서 GN_ 5·신규 표시·당초 사본 둔 샘플 → 새 작업에는 GN_ 없음·신규 끔·당초 사본 없음, 현재 화면은 GN_ 5 그대로, 시험 작업 지우고 샘플 복구.
- [x] 단가산출 전역변수(원본 mnuCostSetGlobalVar 0xf053cc · mnuCostGlobalvarList · 전역표 0x93c22c — spec_cost_tools §2) — LineData 의 이름(화면 ⟦전역⟧) 로 대입한 변수는 호표가 바뀌어도 남아 다른 호표에서 표지 없이 써도 찾음(그 호표 변수 → 전역표 차례). 「전역변수 지정,해제」: 마지막 산출근거 칸 커서 아래 낱말(영문자 포함 [0-9A-Za-z_])을 감싸거나 풂(원본 거부 문구). 「전역변수 목록」: 처음부터 계산한 대입마다 변수명·값·이동. 원본처럼 전역표는 저장하지 않고 계산 때마다 다시 만듦.
- 검증완료: `test_est_global_vars.py`(다른 호표 Q×10 = 60, 표지 유지, 비전역 R 은 못 찾음, 지정·해제·거부, 표시·되돌림) 및 REA 대조 39개 통과. ORCA 브라우저에서 샘플 단가산출 q 줄 지정 → 목록 「q 0.7」·이동으로 그 줄 선택, 도급액 불변, 샘플 복구.
- [x] JDLExcel과 단가비교(원본 ActionJDLCompare, 숨은 Ctrl+Alt+J → TfrmJDLExcelCompare) — 일위대가·단가산출·기계경비 탭, 시트번호·코드칼럼(A)·합계셀칼럼(H), Excel(.xlsx·.xls — `/api/read-xls`) 1행~(호표 수+50)행에서 코드를 품은 칸의 같은 행 합계와 TC 비교, 차이·없음 표시. 보기 전용. 웹은 메뉴 단추로 엶.
- 검증완료: `test_est_jdl_compare.py`(같음·차이 1·없음·+50행 한계·xls 셀 변환) 통과. ORCA 브라우저에서 일위대가 8개 중 같음 1·차이 10·없음 6 표시.
- 제외: TfrmMultiEditor(인쇄 부품의 설계용 편집기 — 실행 파일에 클래스 없음), Est Plus(구버전 .est) 단가비교·내보내기(숨은 단축키, 394바이트 레코드 형식 — 구버전 불러오기와 함께 남음).
- [x] 검색 결과 근거자료보기(원본 TfrmFoundDetail btnClose 0xefa7e8) — 통합 검색 결과의 일위·단산·기계 줄 「근거」: 그 작업의 호표 세부 줄(단산은 산출근거 글)과 총액·노무·재료·경비, 「적용」은 그 결과를 고르고 검색 창으로(가져오기는 검색 창에서). 고른 결과는 창을 오가도 유지.
- 검증완료: 바인딩·통합 검색 시험 통과. ORCA 브라우저에서 「거푸집」 일위 → 「[합판거푸집]의 근거자료보기」 총액 53,029·노무 38,289·재료 14,740·세부 6줄, 적용 → 그 결과 체크.
- [x] ES 정보(원본 mnuESInfo → TfrmESInfo 0x10b838c) — 기준시점·비교시점·최종기성일·선급금지급일(기본 오늘)·선급금지급액을 `project.esInfo` 에 기록(원본 [EscInfo] SDATE·CDATE·ACDATE·PreDATE·PreCost). 원본도 ES 제출 파일 작성에만 씀 — 웹 물가변동 계산과 잇지 않음, ESTX 에는 남지 않음.
- 검증완료: 바인딩 시험 통과. ORCA 브라우저에서 기본 오늘 · 기준시점 2025-01-15·선급금 1,000,000 저장 → 다시 열면 그대로, 지워 복구.
- [x] 설계참고철(Main Data)과 단가 범위 검색(원본 TfrmMainTableList 「MAIN DATA(설계참고철) 추가 및 삭제」 · TfrmFolderSerch 「Main Data에서 검색」·범위 검색 0xefefb0) — 통합 검색 「설계참고철 관리」에서 저장된 작업 중 최대 10개 지정(양식 보관함 종류 options), 검색위치 「설계참고철」이면 그 작업만, 검색어 「a~b」·숫자 하나는 단가 범위·같음. 원본의 검색중지는 서버 검색이 바로 끝나 두지 않음.
- 검증완료: `test_est_multi_search.py` 2개(범위·같음·지정 작업만) 통과. ORCA 브라우저에서 2개 지정 → 「1000~2000」 24건 모두 지정 작업·범위 안, 목록 지워 복구.
- [x] 줄 연결 도구(원본 TfrmHead popupCostNC·GC·JC·All 0xf1b54c · popMainSumIndex · mnuDataLink 0xf00e6c — 디스어셈블 근거) — 비목별 단가 불러오기(일위대가 줄: 하위 견적·일위·단산·기계에서 고른 비목 단가만 남기고 나머지 0, 기준비목 지정, 노임·자재·표준 거부), SUM 색인지정(해제·A~Z → 구간 SKind, 레벨·소계·도급액·조달수수료·호표머리 줄 거부), 하위데이타 연결/끊기(연결 줄은 끊고 값 유지, 끊긴 줄은 일위→단산→기계→표준→견적→자재→노임 차례로 코드 또는 품명·규격·단위 맞춤으로 연결, 자재는 재료단가·노임은 노무단가 = 원본 TC). 원본은 초점 한 줄, 웹은 고른 줄 모두.
- 검증완료: `test_est_row_links.py`(경비만 5·전체 40/15/5, 거부, 자재 연결 M1·5000·끊기, 못 찾음 문구, 색인 3·거부 3종) 통과. ORCA 브라우저에서 일위대가 합판거푸집 노무비만 → 0, 재료비만 → 재료단가 34,666·금액 13,173, 내역 일위 연결 줄 끊기 → 다시 연결 103·도급액 불변, SUM 색인 B, 샘플 복구.
- [x] 묶음 인쇄 간지 설정값 일괄적용·기본양식에저장·전체선택/선택해제(원본 TFRMESTPRINTPRIVATE btnGangiAllSet·btnSaveToMain, TFRMESTPRINT2 btnNoselect) — 간지 인쇄·간지 겹테두리를 모든 서식에 같게, 고른 서식·간지 설정을 「묶음기본양식」(양식 보관함 종류 print)으로 저장해 묶음 설정 없는 공사에서 열 때 씀.
- 검증완료: 묶음 인쇄·바인딩 시험 통과. ORCA 브라우저에서 선택해제 0 · 전체선택 40, 겹테두리 끔 → 미리보기 간지 테두리 없음, 기본양식 저장 뒤 설정 지우고 다시 열면 1개·겹테두리 끔 복원, 양식 지우고 샘플 복구.
- [x] 갑지 공사금액 자동변경(원본 TFRMGABGI popupCostChange) — 「공사금액 자동변경」 체크 시 고른 원가 비목·방식을 기억해 인쇄 때마다 현재 원가 금액으로 공사금액 글을 다시 만듦(다시 열면 고른 비목·방식 그대로).
- 검증완료: 문서 시험 3개 통과. ORCA 브라우저에서 도급액 고정 풀고 자동변경 저장 → 수량 2배 뒤 인쇄 금액 「칠천구백육십만삼천삼백이십사원」→「팔천이백칠만사천팔백구십일원」, 저장 글은 그대로, 샘플 복구.
- [x] 선택 행 예정공정표에 표시/해제(원본 mnuShowSchedule · popMainShowChart) — 내역서에서 고른 상세 행만 공정표에 넣음(이미 있는 행은 건너뜀), 고른 행이 모두 있으면 뺌(검증: 2행 넣기 → 다시 눌러 0행).
- 검증완료: 바인딩·공정표 시험 통과. ORCA 브라우저에서 3행 고름 → 공정 3개, 다시 눌러도 3개, 샘플 복구.
- [x] 셀복사·셀붙여넣기(원본 ActionCellCopy·ActionCellPaste) — 마지막으로 누른 칸의 열을 고른 행들(없으면 그 행)만큼 복사(시스템 클립보드에도 글자로), 다른 칸을 누르고 붙이면 그 행부터 아래로 차례로 넣음(열 달라도 됨, 수치 칸은 수치만, 코드·결과비목 칸 거부).
- 검증완료: 바인딩 시험 통과. ORCA 브라우저에서 자재 품명 2칸 복사 → 6번째 행 규격부터 붙여넣기(경유·휘발유), 코드 칸 거부, 샘플 복구.
- [x] 산출 줄 잘라내기·복사·붙여넣기(원본 TFRMMULWORD popCut·popCopy·popPaste) — 산출 줄마다 복사·잘라내기·아래 붙여넣기. 붙일 때 수량 지정 표지를 떼고 글만(같은 항목이 두 번 잡히지 않게), 수량 지정된 줄은 잘라내기 거부.
- 검증완료: 산출서·수량 흐름·바인딩 시험 22개 통과. ORCA 브라우저에서 표지 있는 줄 복사 → 아래 붙여넣기(표지 없음), 표지 줄 잘라내기 거부, 샘플 복구.
- [x] 수량산출서 엑셀형식 전환(원본 TFRMMULLANG popSetMode — DataKind 로 편집기 교체) — 산출서 화면 「엑셀형식으로」: 엑셀 시트가 없으면 빈 시트를 만들어 엑셀형으로, 「일반형식으로」는 엑셀 내용을 보관한 채 끄고 셀 연결 수량을 쓰지 않음. 바꾼 산출서는 ESTX 저장 거부(JSON).
- 검증완료: 엑셀·산출서·바인딩 시험 11개 통과. ORCA 브라우저에서 새 산출서 → 엑셀형식(격자 160칸) → A1 =2*3 → 6, 일반형식으로 → 격자 숨김·보관, 샘플 복구.
- 제외(원본에서도 쓰지 않거나 외부 프로그램): 제비율 적용기준 보기(설치 폴더 한글 문서를 한글 프로그램으로 열 뿐, 그림 3장), 엑셀물량 열기(Microsoft Excel 을 COM 으로 열 뿐 EST 로 읽지 않음), 플러스 일위대가로 변환(메뉴에 걸리지 않아 원본에서 실행 불가).
- [x] 표시 설정(원본 연차공사 환경설정 TfrmEstMultiOption 0xf9c8f0 · 열 순서 0xf0f620) — 비목순서(합/노/재/경 · 합/재/노/경 · 노/재/경/합 · 재/노/경/합, 4·5 는 원본처럼 그대로) 금액·단가 열 재배치, 구분(선택출처) 열 보이기, 품명정렬. 계산 환경 `Display`, 계산 불변. 원본은 연차공사 문서에서만 열지만 웹은 모든 표에 적용. 품명 두 줄 표기·비고란 발췌근거는 하지 않음.
- 검증완료: `test_est_display.py`(재/노/경/합 순·4번 그대로·구분 숨김·정렬) 통과. ORCA 브라우저에서 내역서 노/재/경/합 → NU·GU·JU·SU·TU / NC·GC·JC·SC·TC, 품명 가운데, 도급액 불변, 설정 지워 복구.
- [x] 원가계산서 순공사비 입력(원본 가로양식 TfrmWongaH popTotalInput 0xe7fbf8) — 연결 54 줄이 있으면 「순공사비 항목이 있습니다」(그 줄 고름), 없으면 맨 위에 「순 공 사 비」(연결 54, 금액 = 직접공사비) 넣고 다시 계산. 원본이 이어서 하는 원가 전체 초기화(모든 줄 자리수·도급액 고정 지움)는 사용자 설정을 지우는 부작용이라 하지 않음.
- 검증완료: 원가·바인딩 시험 통과. ORCA 브라우저에서 넣기 → 맨 위 순공사비 45,293,066(= 직접공사비)·도급액 79,603,000 불변, 다시 누르면 거부 문구, 샘플 복구.
- 제외: 자동비목생성(Ctrl+Alt+G 숨은 시험용 — 비목군을 무작위로 채움), 단가산출 정리(TfrmCostSpaceEdit — 원본 실행 파일에 코드가 링크되지 않아 열 수 없음).
- [x] Excel 가져오기 셀부호 밀기·당기기(원본 TFRMEXCELTOEST popCellLeft·popupCellRight) — 첫 필드 열만 보고 모든 필드 열을 한 칸 오른쪽(왼쪽)부터 차례로 다시 매김, A 아래는 원본 문구로 거부(웹은 AA 등 두 글자 열도 이어 매김).
- [x] 설계일자 선택(원본 TfrmMonthDate) — 갑지 「날짜 선택」 → 「{년}년 {월}월 일 설계」(일은 빈칸), 견적서 「견적일자 선택」 → 「{년}년 {월}월 {일}일」.
- 검증완료: `test_est_excel_map.py` 4개(밀기 A→B…P, 당기기 C→B, A 거부)·견적서·문서·바인딩 시험 통과. ORCA 브라우저에서 갑지 날짜 2026-03-05 → 「2026년 3월 일 설계」, 저장하지 않고 닫아 복구.
- [x] 연차 차수 중간 삽입(원본 TFRMESTMULTIEDIT popInsert) — 차수 줄의 「위에 삽입」: 고른 차수 앞에 새 차수([기준]) 넣기.
- 검증완료: 바인딩 시험 통과. ORCA 브라우저에서 1차·2차 → 2번째 위 삽입 → 1차·3차·2차, 차수 비워 복구.
- [x] 블럭 파일로 저장하기·블럭 파일에서 붙여넣기(원본 mnuBlockSaveToFile·mnuLoadFromFile) — 복사·잘라낸 행 묶음을 `.estblock.json`(종류 est-web-block-1, 표 이름·행)으로 내려받고, 파일을 읽어 같은 종류 화면에 붙여넣음(다른 공사로 옮기기).
- 검증완료: 바인딩 시험 통과. ORCA 브라우저에서 자재 2행 복사 → 파일 저장(2,559 바이트) → 같은 내용 읽어 붙여넣기 30→32행, 잘못된 파일 거부, 샘플 복구.
- [x] 인쇄 용지 A0·A1·B4 와 인쇄 기본양식(원본 TFRMSCHEDULE mnuA0~B4 · TFRMESTPREVIEW2 mnuFormatSave·mnuFromatOpen) — 용지 A4·A3·B4·A1·A0(A0·A1 은 mm 크기, 가로면 바꿔 씀), 인쇄 창 「기본양식저장」(용지·여백·글자·줄 수 등, 서식 고르기·본문 글 제외 — 양식 보관함 종류 print) · 「기본양식열기」.
- 검증완료: 바인딩·양식 시험 3개 통과. ORCA 브라우저에서 A1 가로 → @page 841mm 594mm, 기본양식 저장 뒤 A4 로 바꾸고 열기 → A1 복원, 양식·설정 지워 복구.
- [x] 원가계산서 내용 삭제·기본양식으로 저장(원본 TFRMWONGABASE popupContentDel A7 · popupSaveAsMain) — 원가계산서(W) 「선택 줄 내용 삭제」: 연결·금액·비율·요율·산출근거·자리 비움(이름·기호 유지, 재계산 없음). 양식 목록 「기본양식으로 저장」: 이름 「기본양식」 — 새 내역서를 만들 때 원가계산서로 씀.
- 검증완료: 양식·원가·바인딩 시험 8개 통과. ORCA 브라우저에서 간접노무비 줄 내용 삭제(이름 유지·식·요율·금액 비움), 기본양식 저장 → 새 내역서 원가 31줄, 양식 지우고 샘플 복구.
- [x] 갑지·표지 양식(원본 TFRMGABGI·TFRMCOVER popupSaveFormat·popupLoadFormat·popupFormatTableS·popupSaveToDefault·popupInit·popupPrint) — 편집 창 위 단추: 양식목록(불러오기·지우기), 양식저장하기(이름), 기본양식으로 저장(처음 여는 갑지·표지에 채움), 초기화(문서 지우고 다시 열기), 인쇄(서식 8·9 미리보기로). 양식은 서버 보관함 종류 document 에 { docKind, fields }, 공사명은 넣지 않음.
- 검증완료: 문서·바인딩 시험 4개 통과. ORCA 브라우저에서 갑지 공사위치 「X위치」 양식 저장·기본양식 저장 → 초기화 뒤 다시 열면 「X위치」 채움, 양식목록에서 불러오기, 인쇄 → 미리보기 서식 8 에 갑지, 양식·인쇄 설정 지워 복구.
- [x] 공사설명서·시방서 서식 편집(원본 TFRMESTEDITOR mnuFormatPar·mnuTableCreate·mnuFormatTable·popupBookMark·mnuFormatTableS·mnuSaveToDefault) — 서식 있는 편집 칸: 문단모양(정렬·들여쓰기·왼쪽 여백·줄간격), 표만들기(줄·칸), 표모양(테두리·칸 여백·너비), 책갈피(넣기·찾아가기), 양식목록(서버 양식 보관함 종류 document — 불러오기·지우기·현재 문서 저장), 기본양식으로 저장(빈 문서를 열면 채움). 본문은 `docs[kind].html`(허용 태그·속성만)과 글자 `text` 를 함께 두고 인쇄는 html 우선. 갑지에서 붙여넣기는 커서 자리에. 원본 WPT 서식 가져오기·ESTX 저장은 여전히 하지 않음.
- 검증완료: 문서·바인딩 시험 4개 통과. ORCA 브라우저에서 설명서 첫 문단 가운데 정렬, 3×3 표·테두리 2px, 공사명 붙여넣기, 저장 뒤 인쇄 html 에 표·정렬 반영, 기본양식 저장 → 빈 문서 열 때 표 채움, 양식·문서 지워 복구. (DOM 이 필요해 pytest 단위 시험은 없음)
- [x] 글자모양(mnuformatFont: 굵게·기울임·밑줄·크기 pt)·전체삭제(mnuAllDelete). 검증완료: ORCA 브라우저에서 「나다」 10pt span 유지(허용 속성), 전체삭제 뒤 빈 문단, 저장 안 하고 닫음.
- [x] 원가계산서 산출근거 약어로 표시(원본 popupSNameShow 0xd0e494 · 약어 생성 0xc97790) — 원가계산서(W) 메뉴 단추로 켜고 끔. 산출근거 식의 기호(영문 시작 또는 2자 이하, 범위 기호 뒤 제외)를 그 행 약어(SName, 없으면 빈칸 뺀 이름)로, 숫자×100 이 그 행 요율이면 요율 글자로, 「:」 는 「~」 로 바꿔 화면·인쇄에 보이고 그동안 산출근거 칸 잠금. 저장 값(VSusic)은 그대로. 기성 원가 화면의 같은 확인란과 같은 설정.
- 검증완료: `test_est_wonga_tools.py` 5개(샘플 「직.노 × 16.5%」·「( 산출경비 ~ 18 )」·「순.공 × 8%」, 번호 열쇠, 원본 식 불변) 통과. ORCA 브라우저에서 켜기 → 약어 표시·입력칸 0, 끄기 → 입력칸 31, 설정 지워 복구.
- [x] 발췌근거(원본 barbtnColumn — TfrmHeaderChange 0x110b7ec · 자재 TfrmStuffcHeadChange 0xf61b84) — 자재·노임 화면 「발췌근거」 단추로 출처 열(Cost1~6) 이름·쪽수(자재)·보기 편집, 자재는 8가지 출처 이름에서 고르고 중복 거부, 숨긴 출처는 최저·평균·최고 단가 고르기에서 빠짐(서버 재계산도 같은 코드). 값은 계산 환경 `PriceSources`(ESTX 저장에는 남지 않음 — 원본도 출처 이름은 격자 배치에만 둠).
- 검증완료: `test_est_sources.py`(최저 80→100·최고 120→100, 중복 거부, 머리글 「물가자료 (120)」, 숨긴 열 빠짐) 및 REA 대조 39개 통과. ORCA 브라우저에서 자재단가 발췌근거 2번 「물가자료」 쪽 77, 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 기준, 마지막 자리 기억). 기성 원가도 같은 엔진.
- 검증완료: `test_est_wonga_tools.py` 4개(자리맞춤 방식별·음수·소수·4자리 반올림·도급액 제외, 샘플 산업안전 1,660,273·비교 1,887,446·이윤 7,620,104·도급액 79,603,000 그대로, 대화 절하 1,660,000·초기화 복귀·도급액 행 거부·입력 행 TC) 통과. ORCA 브라우저에서 샘플 산업안전 행 천원 절하 → 1,660,000, 도급액 79,603,000 유지, 복구.
- [x] 견적서 작성(원본 mnuEstiMatePrn → TfrmEstiMatePrn 0x10c1994·그리기 0x10c4768 — 디스어셈블 근거) — 공사별 설정(`project.estimatePrint`), 원본 `.einf` INI 세 구역 저장·두 구역 불러오기, 레벨 행 선택, 7열, 1쪽 머리·공급자·합계금액(아래에서 위로 Checked 금액, 한글), 첫/나머지 쪽 줄수, 미리보기·인쇄. 수탁공사변환·XML 역변환 비교는 원본에서 숨긴 개발용 메뉴라 범위 밖.
- 검증완료: `test_est_estimate_print.py`(전체/레벨 2 행, 쪽 27·36·7, 합계 「일금:구천칠백일십구만구천원정 (₩97,199,000원정)」, INI 왕복, 미리보기 글) 통과. ORCA 브라우저에서 상호·레벨 2 → 2쪽·28줄·합계·공급자 표, 공사 설정 저장 확인 후 샘플 복구.
- 남음: 견적서 Excel 변환, 글꼴·크기·여백 세부값.
+15 -1
View File
@@ -70,7 +70,21 @@
산출 줄 옆 `누계`는 원본 누계(Ctrl+U)처럼 커서 자리에 누계 값을 넣습니다. 값은 가장 가까운 위쪽 품명 줄(포함) 또는 앞 누계 줄(제외)부터 바로 위 줄까지 줄마다 첫 결과 수량의 합(소계)이며, 계산할 때마다 다시 구합니다. 원본 파일의 누계 표지도 읽어 다시 계산합니다.
원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값을 읽어 수량산출 화면 아래에 읽기 전용 격자로 보입니다. 수식 셀은 Excel이 저장해 둔 계산값을 굵게 보이며, 셀 편집·다시 계산은 아직 하지 않습니다.
원가계산서에서 행을 고르고 `선택 행 자릿수 설정`을 누르면 원본 「선택항목 절하하기」처럼 원·십원·백원… 단위로 사사오입·절하·절상하거나 초기화합니다. 수식 행은 계산 때마다 그 자리수로 맞추고, 도급액 고정 행은 바꿀 수 없습니다.
기성고 화면 아래 `기성 원가계산서`는 회차마다 따로 고칠 수 있습니다. 줄을 고른 뒤 한줄 삽입·추가·삭제, 내용 삭제, 선택항목 절하, 공사비역산 자리수 맞추기를 하고, `되살리기`로 바로 앞 조작을, `원가계산서 초기화`로 도급 원가 서식을 되돌립니다. 확정한 회차는 고칠 수 없습니다.
편집(E) › `조달청 표준공사코드 적용`은 노임·자재·기계·일위 등의 코드를 조달청 코드로 바꿉니다. 왼쪽 목록은 원본 설치 폴더 `System\MainSys` 의 `PPS_STUFFC.dat`(노임·자재·장비) 또는 `PPS_HEAD.dat`(일위·단산·표준)를 골라 불러옵니다. 코드를 바꾼 뒤 `적용`을 누르면 그 코드를 쓰는 내역·일위·단산 줄도 함께 바뀝니다. `코드일괄변경`은 고른 .estx 파일(예: 원본 JodalCodeDB.estx)에서 품명·규격이 같은 코드를 찾아 EST 코드만 바꿉니다.
갑지·표지 편집 창 위의 `양식저장하기`·`양식목록`으로 발주처·결재란 같은 내용을 양식으로 두고 불러올 수 있습니다. `기본양식으로 저장`한 내용은 새 공사에서 갑지·표지를 처음 열 때 채워지고, `초기화`는 처음 상태로, `인쇄`는 미리보기로 갑니다.
공사설명서·시방서 편집 창에서는 문단 정렬·들여쓰기·줄간격, 표 만들기와 표 테두리, 책갈피를 쓸 수 있습니다. `양식목록`으로 자주 쓰는 문서를 저장·불러오고, `기본양식으로 저장`한 문서는 빈 설명서·시방서를 열 때 자동으로 채워집니다.
원가계산서(W) › `산출근거 약어로 표시`를 누르면 산출근거가 「직.노 × 16.5%」처럼 비목 약어와 요율로 보이고(인쇄 포함), 다시 누르면 원래 식으로 돌아옵니다. 약어로 보이는 동안에는 산출근거를 고칠 수 없습니다.
자재단가·노임단가 화면의 `발췌근거` 단추로 가격 출처 열(출처1~6)의 이름과 쪽수를 바꾸거나 숨길 수 있습니다. 숨긴 출처는 최저·평균·최고 단가를 고를 때 빠집니다.
원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값과 수식을 함께 읽어 수량산출 화면 아래 격자로 보입니다. 셀을 누르면 수식줄에서 값이나 `=수식`을 고치고 바로 다시 계산합니다(참조·범위·사칙·SUM·ROUND 등). Shift 로 범위를 골라 행·열 넣기·지우기, 잘라내기·복사·붙여넣기, 굵게·정렬·병합·대각선을 쓸 수 있고, 수식의 참조는 원본처럼 따라 옮겨집니다. `우측·위·좌측·아래 합`은 원본처럼 그 방향의 숫자 셀을 더하는 SUM 을 넣고, `수량으로 지정`은 셀을 수량 항목에 연결해 항목 수량이 그 셀 값(산출서 자리수)을 따르게 합니다. 원본 파일에는 셀과 항목의 대응이 보이지 않아, 가져올 때 저장 수량과 값이 같은 합계 셀을 「추정 연결」로 둡니다. 셀을 고친 엑셀형 산출서는 ESTX 저장을 거부합니다.
산출 줄 옆 `수량 지정`은 줄 글에서 드래그해 고른 숫자를 결과 표지로 감싸고 수량 항목을 새로 만듭니다. 품명·규격·단위 표지가 있으면 항목 이름에 옮깁니다. `지정 해제`는 커서가 놓인 결과 표지를 풀고, 다른 줄에서 쓰지 않는 항목을 지웁니다. 내역에 연결된 항목은 먼저 연결을 비워야 합니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부합니다.
+2
View File
@@ -418,6 +418,8 @@ function updateQuantities(project, base, cp949, memory) {
const sheets = project.quantitySheets || [],
originals = base.quantitySheets || [];
if (sheets.length !== originals.length) throw Error("수량산출서 추가·삭제는 지원하지 않습니다.");
if (sheets.some((sheet) => sheet.excelEdited))
throw Error("엑셀형 수량산출서 편집은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
if (sheets.some((sheet) => sheet.markerEdited))
throw Error("산출 줄의 수량 지정·해제는 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
if (sheets.some((sheet, i) => sheet.Code !== originals[i].Code))
+1 -1
View File
@@ -3,7 +3,7 @@
const fs = require("node:fs");
const path = require("node:path");
const kinds = new Set(["wonga", "document", "machformula", "options"]);
const kinds = new Set(["wonga", "document", "machformula", "options", "print"]);
const LIMIT = 2 * 1024 * 1024;
function file(directory) {
+4 -1
View File
@@ -1,5 +1,6 @@
// Read-only ESTX import. Layout: knowledge/다산소프트/ESTX_파일구조.md.
const { readXls } = require("./xls-read.cjs");
const excelSheet = require("./native-excel-sheet.cjs");
const { readDocuments } = require("./midas-read.cjs");
const zlib = require("node:zlib");
const quantity = require("./native-quantity.cjs");
@@ -373,8 +374,10 @@ function importEstx(buffer, name = "가져온 내역서") {
roundMode: (props.TMulLangOption?.CalcMethod ?? 1) + 1,
});
// 엑셀형 수량산출서(OLE2 .xls)는 읽기 전용 셀 값으로 붙인다.
if (bytes.length > 8 && Buffer.from(bytes).readUInt32LE(0) === 0xe011cfd0)
if (bytes.length > 8 && Buffer.from(bytes).readUInt32LE(0) === 0xe011cfd0) {
sheet.excel = readXls(Buffer.from(bytes));
sheet.excelLinks = excelSheet.inferLinks(sheet);
}
quantitySheets.push(sheet);
} catch (error) {
warnings.add(`수량산출 ${row.Code}: ${error.message}`);
+38 -2
View File
@@ -10,10 +10,13 @@
<script defer src="native-machine.js"></script>
<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>
<script defer src="native-jungsang.js"></script>
<script defer src="native-sources.js"></script>
<script defer src="native-engine.js"></script>
<script defer src="native-row-match.js"></script>
<script defer src="native-escalation.js"></script>
@@ -34,6 +37,7 @@
<script defer src="native-views.js"></script>
<script defer src="native-extra.js"></script>
<script defer src="native-settings.js"></script>
<script defer src="native-display.js"></script>
<script defer src="native-report.js"></script>
<script defer src="native-forms.js"></script>
<script defer src="native-exchange.js"></script>
@@ -51,16 +55,29 @@
<script defer src="native-name-reg.js"></script>
<script defer src="native-tidy.js"></script>
<script defer src="native-multi-search.js"></script>
<script defer src="native-doc-editor.js"></script>
<script defer src="native-docs.js"></script>
<script defer src="native-doc-forms.js"></script>
<script defer src="native-block-file.js"></script>
<script defer src="native-cell-clip.js"></script>
<script defer src="native-row-links.js"></script>
<script defer src="native-es-info.js"></script>
<script defer src="native-jdl-compare.js"></script>
<script defer src="native-global-vars.js"></script>
<script defer src="native-new-estimate.js"></script>
<script defer src="native-wonga-tools.js"></script>
<script defer src="native-estimate-print.js"></script>
<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>
<script defer src="native-defaults.js"></script>
<script defer src="native-excel-view.js"></script>
<script defer src="native-progress-wonga.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
@@ -81,6 +98,7 @@
<hr />
<button data-action="save">저장하기 Ctrl+S</button
><button data-action="save-as">다른 이름으로 저장</button
><button data-action="save-new-estimate">새내역서 파일로저장</button
><button data-action="sample">REA 샘플 열기</button>
<hr />
<button data-action="csv">현재 자료 CSV 내보내기</button
@@ -122,7 +140,17 @@
><button data-action="block-end">블럭 끝</button
><button data-action="block-clear">블럭 해제</button
><button data-action="select-all-rows">전체 선택</button
><button data-action="block-del">블럭 삭제</button>
><button data-action="block-del">블럭 삭제</button
><button data-action="block-save-file">블럭 파일로 저장하기</button
><button data-action="block-load-file">블럭 파일에서 붙여넣기</button
><button data-action="cell-copy">셀복사</button
><button data-action="cell-paste">셀붙여넣기</button
><button data-action="schedule-mark">선택 행 예정공정표에 표시/해제</button
><button data-action="data-link">하위데이타 연결/끊기</button
><button data-action="sum-index">SUM 색인지정</button
><button data-action="cost-part">비목별 단가 불러오기(일위대가)</button
><button data-action="global-var">전역변수 지정,해제</button
><button data-action="global-var-list">전역변수 목록</button>
<hr />
<button data-action="find">찾기·다음찾기(Ctrl+F)</button>
<button data-action="multi-search">통합 검색(저장된 다른 작업)</button>
@@ -130,6 +158,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>
@@ -194,13 +223,17 @@
<button data-view="Wonga">원가계산서 보기</button
><button data-action="template">원가 서식 선택</button
><button data-action="settings">자릿수·도급액 고정</button
><button data-action="display-options">표시 설정(비목순서·구분·품명정렬)</button
><button data-action="options-save-default">기본환경으로 설정</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>
<button data-action="wonga-gita">기타경비 계산</button
><button data-action="wonga-anjun">산업안전보건관리비 비교</button>
><button data-action="wonga-anjun">산업안전보건관리비 비교</button
><button data-action="wonga-sname">산출근거 약어로 표시</button
><button data-action="wonga-content-del">선택 줄 내용 삭제</button
><button data-action="wonga-total-input">순공사비 입력</button>
</div>
</details>
<details>
@@ -219,6 +252,8 @@
><button data-view="machine-change">기계경비 설계변경</button
><button data-view="change">설계변경</button><button data-view="progress">기성고</button
><button data-view="escalation">물가변동</button
><button data-action="es-info">ES 정보</button
><button data-action="jdl-compare">JDL엑셀과 단가비교</button
><button data-view="annual">연차공사</button><button data-view="review">계약심사</button
><button data-view="resources">동원인원·자재·장비</button>
<button data-view="schedule">예정공정표</button>
@@ -261,6 +296,7 @@
><button id="haljung-calc-btn" data-action="haljung-calc" hidden>할증계산</button
><button id="vat-calc-btn" data-action="vat-calc" hidden>부가세계산</button
><button id="unit-calc-btn" data-action="unit-calc" hidden>단위환산</button
><button id="sources-btn" data-action="sources" hidden title="발췌근거 삭제,삽입 및 변경">발췌근거</button
><button id="uses-search-btn" data-action="uses-data-search">사용위치</button
><button data-action="add-row">행 추가</button><button data-action="copy-row">복사</button
><button data-action="paste-row">붙여넣기</button
+6 -1
View File
@@ -140,6 +140,7 @@ const EstActions = (() => {
if (confirm("저장하지 않은 입력을 비우고 새 내역서를 만드시겠습니까?")) {
EstApp.setProject(EstNative.empty());
if (typeof EstDefaults !== "undefined") await EstDefaults.applyNew();
if (typeof EstFormsLibrary !== "undefined") await EstFormsLibrary.applyDefault();
}
} else if (name === "sample") {
if (confirm("현재 작업을 REA 샘플로 바꾸시겠습니까?"))
@@ -283,6 +284,8 @@ const EstActions = (() => {
else if (name === "import-xlsx") EstApp.byId("excel-file").click();
else if (name === "print") await EstReport.open();
else if (name === "print-document") EstReport.print();
else if (name === "print-default-save") await EstReport.saveDefault();
else if (name === "print-default-open") await EstReport.openDefault();
else if (name === "csv") {
const rows = activeRows(),
keys = EstModel.columns(state.view);
@@ -682,5 +685,7 @@ const EstActions = (() => {
EstAttachments.install();
EstStandard.install();
}
return { install, form, undo };
const clip = () => ({ table: clipboardTable, rows: clipboard });
const setClip = (table, rows) => ((clipboardTable = table), (clipboard = structuredClone(rows)));
return { install, form, undo, clip, setClip };
})();
+48
View File
@@ -0,0 +1,48 @@
// 원본 「블럭 파일로 저장하기」(mnuBlockSaveToFile)·「블럭 파일에서 붙여넣기」(mnuLoadFromFile) — 복사·잘라낸 행 묶음을
// 파일(.estblock.json)로 내려받고, 파일을 읽어 복사판에 올린 뒤 현재 화면에 붙여넣는다(같은 종류 자료만).
const EstBlockFile = (() => {
const KIND = "est-web-block-1";
function save() {
const { table, rows } = EstActions.clip();
if (!rows.length) throw Error("먼저 행을 복사하거나 잘라내세요.");
EstApp.download(
`${EstApp.state.project.name || "블럭"}.${table}.estblock.json`,
JSON.stringify({ kind: KIND, table, rows }, null, 1),
);
}
function parse(text) {
const data = JSON.parse(text);
if (data?.kind !== KIND || !Array.isArray(data.rows) || !data.rows.length || !data.table)
throw Error("블럭 파일이 아닙니다.");
if (data.rows.some((r) => !r || typeof r !== "object" || Array.isArray(r)))
throw Error("블럭 파일 행이 올바르지 않습니다.");
return data;
}
function load(state) {
const picker = document.createElement("input");
picker.type = "file";
picker.accept = ".json";
picker.addEventListener("change", async () => {
try {
const file = picker.files[0];
if (!file) return;
const data = parse(await file.text());
if (data.table !== state.view)
throw Error("블럭 파일과 같은 종류의 자료 화면에서 붙여넣으세요.");
EstActions.setClip(data.table, data.rows);
document.querySelector('[data-action="paste-row"]')?.click();
} catch (error) {
state.notice = error.message;
EstApp.render();
}
});
picker.click();
}
function handle(name, state) {
if (name === "block-save-file") save();
else if (name === "block-load-file") load(state);
else return false;
return true;
}
return { parse, handle };
})();
+27 -3
View File
@@ -229,6 +229,7 @@ const EstBusinessActions = (() => {
throw Error("이 자료에는 자릿수 설정이 없습니다.");
if (!indices.length || indices.some((index) => !rows[index]))
throw Error("설정할 행을 선택하세요.");
if (state.view === "Wonga") return EstWongaTools.positionDialog(state, indices);
EstActions.form(
"선택 행 자릿수",
[
@@ -603,7 +604,7 @@ const EstBusinessActions = (() => {
(typeof EstFind !== "undefined" && EstFind.handle(name, state)) ||
(typeof EstBlank !== "undefined" && EstBlank.handle(name, state)) ||
(typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state, node)) ||
(typeof EstExcelMap !== "undefined" && EstExcelMap.handle(name, state)) ||
(typeof EstExcelMap !== "undefined" && EstExcelMap.handle(name, state, node)) ||
(typeof EstTransfer !== "undefined" && EstTransfer.handle(name, state)) ||
(typeof EstBid !== "undefined" && EstBid.handle(name, state)) ||
(typeof EstFormsLibrary !== "undefined" && EstFormsLibrary.handle(name, state, node)) ||
@@ -611,7 +612,7 @@ const EstBusinessActions = (() => {
(typeof EstCleanup !== "undefined" && EstCleanup.handle(name, state)) ||
(typeof EstNameReg !== "undefined" && EstNameReg.handle(name, state)) ||
(typeof EstTidy !== "undefined" && EstTidy.handle(name, state)) ||
(typeof EstMultiSearch !== "undefined" && EstMultiSearch.handle(name, state)) ||
(typeof EstMultiSearch !== "undefined" && EstMultiSearch.handle(name, state, node)) ||
(typeof EstDocs !== "undefined" && EstDocs.handle(name, state, node)) ||
(typeof EstWongaTools !== "undefined" && EstWongaTools.handle(name, state)) ||
(typeof EstEstimatePrint !== "undefined" && EstEstimatePrint.handle(name, state)) ||
@@ -621,9 +622,32 @@ const EstBusinessActions = (() => {
(typeof EstEstTools !== "undefined" && EstEstTools.handle(name, state)) ||
(typeof EstPrintBatch !== "undefined" && EstPrintBatch.handle(name, state)) ||
(typeof EstMachFormula !== "undefined" && EstMachFormula.handle(name, state, node)) ||
(typeof EstDefaults !== "undefined" && EstDefaults.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 EstCodeChangeView !== "undefined" && EstCodeChangeView.handle(name, state, node)) ||
(typeof EstSources !== "undefined" && EstSources.handle(name, state)) ||
(typeof EstDocEditor !== "undefined" && EstDocEditor.handle(name, state, node)) ||
(typeof EstDocForms !== "undefined" && EstDocForms.handle(name, state, node)) ||
(typeof EstBlockFile !== "undefined" && EstBlockFile.handle(name, state)) ||
(typeof EstDisplay !== "undefined" && EstDisplay.handle(name, state)) ||
(typeof EstCellClip !== "undefined" && EstCellClip.handle(name, state)) ||
(typeof EstRowLinks !== "undefined" && EstRowLinks.handle(name, state)) ||
(typeof EstEsInfo !== "undefined" && EstEsInfo.handle(name, state)) ||
(typeof EstJdlCompare !== "undefined" && EstJdlCompare.handle(name, state, node)) ||
(typeof EstGlobalVars !== "undefined" && EstGlobalVars.handle(name, state, node)) ||
(typeof EstNewEstimate !== "undefined" && EstNewEstimate.handle(name, state))
) {
EstApp.render();
} else if (name === "insert-annual") {
// 원본 열(차수) 삽입(TFRMESTMULTIEDIT popInsert): 고른 차수 앞에 새 차수
const at = Math.max(0, Math.min(project.annual.length, Number(node.dataset.row) || 0));
EstApp.change(() => {
project.annual.splice(at, 0, {
name: EstBusiness.newAnnualName(project.annual),
formula: "[기준]",
});
}, false);
} else if (name === "load-contract" || name === "load-bid") {
purpose = name;
documentKey = state.documentKey;
+49
View File
@@ -0,0 +1,49 @@
// 원본 셀복사·셀붙여넣기(TfrmEstMain ActionCellCopy·ActionCellPaste) — 마지막으로 누른 칸의 열을, 고른 행들(없으면 그 행)만큼 복사하고
// 다른 칸을 누른 뒤 그 행부터 아래로 차례로 붙여넣는다(열이 달라도 됨). 코드·결과비목 칸은 연결 처리가 따로 있어 붙여넣기 거부.
const EstCellClip = (() => {
let last = null;
function copy(state) {
if (!last) throw Error("복사할 칸을 먼저 누르세요.");
const rows = state.project.tables[last.table] || [],
picked = [...state.selection].filter((i) => rows[i]).sort((a, b) => a - b),
list = picked.includes(last.row) ? picked : [last.row];
state.cellClip = { field: last.field, values: list.map((i) => rows[i][last.field] ?? null) };
const text = state.cellClip.values
.map((v) => (last.field === "LineData" ? EstText.display(v ?? "") : (v ?? "")))
.join("\n");
navigator.clipboard?.writeText(text).catch(() => {});
state.notice = `${list.length}칸을 복사했습니다.`;
}
function paste(state) {
if (!last) throw Error("붙여넣을 칸을 먼저 누르세요.");
if (!state.cellClip?.values.length) throw Error("복사한 칸이 없습니다.");
if (["Code", "SCheck"].includes(last.field))
throw Error("코드·결과비목 칸은 셀붙여넣기를 쓸 수 없습니다. 칸에서 직접 고치세요.");
const rows = state.project.tables[last.table] || [],
numeric = EstModel.numeric.has(last.field);
const values = state.cellClip.values.map((v) => {
if (!numeric) return v == null ? "" : String(v);
const n = v === "" || v == null ? null : Number(v);
if (n !== null && !Number.isFinite(n))
throw Error("수치 칸에는 수치만 붙여넣을 수 있습니다.");
return n;
});
if (last.row + values.length > rows.length) throw Error("붙여넣을 행이 모자랍니다.");
EstApp.change((project) => {
const table = project.tables[last.table];
values.forEach((v, i) => (table[last.row + i][last.field] = v));
});
}
if (typeof document !== "undefined" && document.addEventListener)
document.addEventListener("focusin", (event) => {
const t = event.target.closest?.("[data-table][data-field][data-row]");
if (t) last = { table: t.dataset.table, row: Number(t.dataset.row), field: t.dataset.field };
});
function handle(name, state) {
if (name === "cell-copy") copy(state);
else if (name === "cell-paste") paste(state);
else return false;
return true;
}
return { handle };
})();
+280
View File
@@ -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 };
})();
+273
View File
@@ -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;
+85
View File
@@ -0,0 +1,85 @@
// 원본 연차공사 환경설정(TfrmEstMultiOption 0xf9c8f0 — 적용 0xf9cc6c · 열 순서 0xf0f620) — 화면 표시만 바꾸고 계산은 그대로.
// 비목순서: 0 합/노/재/경 · 1 합/재/노/경 · 2 노/재/경/합 · 3 재/노/경/합 (4·5 는 원본도 열을 옮기지 않음) — 금액(TC·NC·GC·JC·SC)과
// 단가(TU·NU·GU·JU·SU) 열을 그 자리 안에서 다시 늘어놓는다. 구분 표시 = 선택출처(ValueIndex) 열 보이기. 품명정렬 = 품명 칸 정렬.
// 값은 계산 환경 Display 에 둔다(원본 옵션 객체 +0x34·+0x38·+0x39). 품명 두 줄 표기·비고란 발췌근거는 하지 않음.
const EstDisplay = (() => {
const ORDERS = [
["T", "N", "G", "J", "S"],
["T", "G", "N", "J", "S"],
["N", "G", "J", "S", "T"],
["G", "N", "J", "S", "T"],
];
const LABELS = [
"합/노/재/경",
"합/재/노/경",
"노/재/경/합",
"재/노/경/합",
"합/경/재/노",
"경/재/노/합",
];
const get = (options) => ({ order: 0, gubun: true, align: 0, ...(options?.Display || {}) });
function columns(options, cols) {
const d = get(options);
let out = d.gubun ? cols : cols.filter((k) => k !== "ValueIndex");
const order = ORDERS[d.order];
if (!order || d.order === 0) return out;
for (const suffix of ["C", "U"]) {
const keys = order.map((p) => p + suffix),
at = out.map((k, i) => (keys.includes(k) ? i : -1)).filter((i) => i >= 0);
if (at.length < 2) continue;
const present = keys.filter((k) => out.includes(k));
out = out.slice();
at.forEach((i, n) => (out[i] = present[n]));
}
return out;
}
function nameStyle(options) {
return ["", "text-align:right", "text-align:center"][get(options).align] || "";
}
function dialog(state) {
const d = get(state.project.options);
EstActions.form(
"환경설정(표시)",
[
{ name: "order", label: "비목순서", value: d.order, choices: LABELS.map((l, i) => [i, l]) },
{
name: "gubun",
label: "구분 표시",
value: d.gubun ? "1" : "0",
choices: [
["1", "표시"],
["0", "숨김"],
],
},
{
name: "align",
label: "품명정렬",
value: d.align,
choices: [
[0, "좌정렬"],
[1, "우정렬"],
[2, "중앙정렬"],
],
},
],
(values) =>
EstApp.change(
(project) =>
(project.options.Display = {
order: Number(values.order),
gubun: values.gubun === "1",
align: Number(values.align),
}),
false,
),
);
}
function handle(name, state) {
if (name !== "display-options") return false;
dialog(state);
return true;
}
return { columns, nameStyle, handle };
})();
+329
View File
@@ -0,0 +1,329 @@
// 원본 공사설명서·시방서 편집기(TFRMESTEDITOR) 서식 — 문단모양(mnuFormatPar: 정렬·들여쓰기·줄간격), 표만들기(mnuTableCreate),
// 표모양(mnuFormatTable: 테두리·칸 여백·너비), 책갈피(popupBookMark: 이름 붙이기·찾아가기),
// 양식목록(mnuFormatTableS·popupListTableS: 서버 양식 보관함 종류 document 에서 불러오기·지우기),
// 기본양식으로 저장(mnuSaveToDefault·popupSavetoDefault: 이름 「기본양식-설명서」·「기본양식-시방서」 — 빈 문서를 열 때 채움).
// 본문은 docs[kind].html(허용한 태그·속성만 남김)과 글자만 뽑은 docs[kind].text 를 함께 둔다.
const EstDocEditor = (() => {
const TAGS = new Set([
"P",
"DIV",
"BR",
"B",
"STRONG",
"I",
"EM",
"U",
"TABLE",
"TBODY",
"THEAD",
"TR",
"TD",
"TH",
"A",
"SPAN",
"H1",
"H2",
"H3",
"UL",
"OL",
"LI",
]);
const STYLE =
/^(text-align|text-indent|line-height|margin-left|border|border-collapse|padding|width|font-weight|font-style|text-decoration|font-size)$/;
const DEFAULT = { explan: "기본양식-설명서", sibang: "기본양식-시방서" };
// 허용 태그·속성만 남김(id 는 책갈피, style 은 정해진 속성만)
function clean(html) {
const box = document.createElement("div");
box.innerHTML = String(html ?? "");
const walk = (node) => {
for (const child of [...node.childNodes]) {
if (child.nodeType === 3) continue;
if (child.nodeType !== 1 || !TAGS.has(child.tagName)) {
if (child.nodeType === 1) {
walk(child);
child.replaceWith(...child.childNodes);
} else child.remove();
continue;
}
for (const attr of [...child.attributes]) {
if (attr.name === "id" && child.tagName === "A") continue;
if (["colspan", "rowspan"].includes(attr.name)) continue;
if (attr.name === "style") {
const kept = attr.value
.split(";")
.map((p) => p.split(":").map((x) => x.trim()))
.filter(([k, v]) => k && v && STYLE.test(k) && !/url|expression/i.test(v))
.map(([k, v]) => `${k}:${v}`)
.join(";");
if (kept) child.setAttribute("style", kept);
else child.removeAttribute("style");
continue;
}
child.removeAttribute(attr.name);
}
walk(child);
}
};
walk(box);
return box.innerHTML;
}
const escText = (t) =>
String(t ?? "")
.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c])
.split(/\r?\n/)
.map((l) => `<p>${l || "<br>"}</p>`)
.join("");
function source(d) {
return d.html ? clean(d.html) : escText(d.text);
}
function printHtml(d) {
if (d.html) return `<div class="document-text document-rich">${clean(d.html)}</div>`;
return "";
}
function body(d) {
return `<div class="business-tools">${[
["doc-font", "글자모양"],
["doc-par", "문단모양"],
["doc-table", "표만들기"],
["doc-table-style", "표모양"],
["doc-bookmark", "책갈피"],
["doc-forms", "양식목록"],
["doc-default", "기본양식으로 저장"],
]
.map(([a, l]) => `<button data-action="${a}">${l}</button>`)
.join(
"",
)}</div><div id="doc-rich" contenteditable="true" style="min-height:320px;max-height:460px;overflow:auto;border:1px solid #bbb;padding:8px;background:#fff">${source(d)}</div>`;
}
const rich = () => document.getElementById("doc-rich");
function collect() {
const node = rich();
return {
html: clean(node.innerHTML),
text: node.innerText.replace(/\n{3,}/g, "\n\n").trimEnd(),
};
}
// 커서 자리 기억(대화 단추를 누르면 선택이 풀리므로)
let saved = null;
function remember() {
const sel = getSelection();
if (sel.rangeCount && rich()?.contains(sel.anchorNode)) saved = sel.getRangeAt(0).cloneRange();
}
function restore() {
const node = rich();
if (!node) return null;
node.focus();
const sel = getSelection();
sel.removeAllRanges();
if (saved && node.contains(saved.startContainer)) sel.addRange(saved);
else {
const r = document.createRange();
r.selectNodeContents(node);
r.collapse(false);
sel.addRange(r);
}
return sel.getRangeAt(0);
}
function insert(html) {
const range = restore();
range.deleteContents();
const frag = range.createContextualFragment(clean(html));
const last = frag.lastChild;
range.insertNode(frag);
if (last) {
range.setStartAfter(last);
range.collapse(true);
}
}
function insertText(text) {
const range = restore();
range.deleteContents();
range.insertNode(document.createTextNode(text));
range.collapse(false);
}
function blockAt(range) {
let n = range.startContainer;
if (n.nodeType === 3) n = n.parentNode;
const node = rich();
while (n && n !== node && !/^(P|DIV|H1|H2|H3|LI|TD|TH)$/.test(n.tagName)) n = n.parentNode;
if (!n || n === node) {
document.execCommand("formatBlock", false, "p");
n = getSelection().anchorNode;
if (n?.nodeType === 3) n = n.parentNode;
}
return n && n !== node ? n : null;
}
function tableAt(range) {
let n = range.startContainer;
while (n && n !== rich() && n.tagName !== "TABLE") n = n.parentNode;
return n?.tagName === "TABLE" ? n : null;
}
// 작은 입력 창(대화 안에 겹쳐 띄움)
function panel(html) {
let box = document.getElementById("doc-panel");
if (!box) {
box = document.createElement("div");
box.id = "doc-panel";
box.className = "business-tools";
rich().before(box);
}
box.innerHTML = html;
}
const v = (name) => document.querySelector(`#doc-panel [name="${name}"]`)?.value;
async function run(name, state, node) {
const d = state.docEditor;
if (name === "doc-par") {
remember();
panel(
`문단모양 <select name="align"><option value="left">왼쪽</option><option value="center">가운데</option><option value="right">오른쪽</option><option value="justify">양쪽</option></select> 들여쓰기(pt) <input name="indent" type="number" value="0" style="width:60px"> 왼쪽 여백(pt) <input name="margin" type="number" value="0" style="width:60px"> 줄간격(%) <input name="line" type="number" value="160" style="width:60px"> <button data-action="doc-par-ok">적용</button>`,
);
} else if (name === "doc-par-ok") {
const block = blockAt(restore());
if (!block) throw Error("문단을 고르세요.");
block.style.textAlign = v("align");
block.style.textIndent = `${Number(v("indent")) || 0}pt`;
block.style.marginLeft = `${Number(v("margin")) || 0}pt`;
block.style.lineHeight = `${Number(v("line")) || 160}%`;
panel("");
} else if (name === "doc-table") {
remember();
panel(
`표만들기 줄 <input name="rows" type="number" value="3" min="1" max="100" style="width:60px"> 칸 <input name="cols" type="number" value="3" min="1" max="30" style="width:60px"> <button data-action="doc-table-ok">만들기</button>`,
);
} else if (name === "doc-table-ok") {
const rows = Math.min(100, Math.max(1, Number(v("rows")) || 1)),
cols = Math.min(30, Math.max(1, Number(v("cols")) || 1));
insert(
`<table style="border-collapse:collapse;width:100%"><tbody>${Array.from({ length: rows }, () => `<tr>${Array.from({ length: cols }, () => '<td style="border:1px solid #000;padding:2pt"><br></td>').join("")}</tr>`).join("")}</tbody></table><p><br></p>`,
);
panel("");
} else if (name === "doc-table-style") {
remember();
if (!saved || !tableAt(saved)) throw Error("표 안을 누른 뒤 표모양을 고르세요.");
panel(
`표모양 테두리(px) <input name="border" type="number" value="1" min="0" max="5" style="width:60px"> 칸 여백(pt) <input name="pad" type="number" value="2" style="width:60px"> 너비(%) <input name="width" type="number" value="100" style="width:60px"> <button data-action="doc-table-style-ok">적용</button>`,
);
} else if (name === "doc-table-style-ok") {
const table = tableAt(restore());
if (!table) throw Error("표 안을 누른 뒤 표모양을 고르세요.");
table.style.width = `${Number(v("width")) || 100}%`;
for (const cell of table.querySelectorAll("td,th")) {
cell.style.border = `${Number(v("border")) || 0}px solid #000`;
cell.style.padding = `${Number(v("pad")) || 0}pt`;
}
panel("");
} else if (name === "doc-bookmark") {
remember();
const marks = [...rich().querySelectorAll("a[id]")].map((a) => a.id);
panel(
`책갈피 이름 <input name="mark"> <button data-action="doc-bookmark-ok">넣기</button> ${marks.map((m) => `<button data-action="doc-bookmark-go" data-mark="${EstApp.esc(m)}">${EstApp.esc(m)}</button>`).join("")}`,
);
} else if (name === "doc-bookmark-ok") {
const mark = String(v("mark") ?? "").trim();
if (!/^[\w가-힣-]{1,40}$/.test(mark))
throw Error("책갈피 이름은 글자·숫자로 40자 안에서 적으세요.");
if (rich().querySelector(`a[id="${CSS.escape(mark)}"]`))
throw Error("같은 책갈피가 있습니다.");
insert(`<a id="${mark}"></a>`);
panel("");
} else if (name === "doc-bookmark-go") {
rich()
.querySelector(`a[id="${CSS.escape(node.dataset.mark)}"]`)
?.scrollIntoView({ block: "center" });
} else if (name === "doc-font") {
remember();
panel(
`글자모양 <button data-action="doc-font-ok" data-cmd="bold"><b>굵게</b></button><button data-action="doc-font-ok" data-cmd="italic"><i>기울임</i></button><button data-action="doc-font-ok" data-cmd="underline"><u>밑줄</u></button> 크기(pt) <input name="size" type="number" value="10" min="6" max="40" style="width:60px"><button data-action="doc-font-ok" data-cmd="size">크기 적용</button> <button data-action="doc-clear">전체삭제</button>`,
);
} else if (name === "doc-font-ok") {
const range = restore();
if (range.collapsed) throw Error("글자를 고른 뒤 적용하세요.");
if (node.dataset.cmd === "size") {
const span = document.createElement("span");
span.style.fontSize = `${Math.min(40, Math.max(6, Number(v("size")) || 10))}pt`;
span.append(range.extractContents());
range.insertNode(span);
} else document.execCommand(node.dataset.cmd);
remember();
} else if (name === "doc-clear") {
if (!confirm("문서 내용을 모두 지우시겠습니까?")) return;
rich().innerHTML = "<p><br></p>";
panel("");
} else if (name === "doc-forms") {
const list = (await EstApp.api("/api/forms")).filter((f) => f.kind === "document");
panel(
list.length
? `양식목록 ${list.map((f) => `<button data-action="doc-form-load" data-name="${EstApp.esc(f.name)}">${EstApp.esc(f.name)}</button><button data-action="doc-form-delete" data-name="${EstApp.esc(f.name)}" title="지우기">×</button>`).join(" ")} <input name="form-name" placeholder="새 양식 이름"><button data-action="doc-form-save">현재 문서를 양식으로</button>`
: `저장한 양식이 없습니다. <input name="form-name" placeholder="새 양식 이름"><button data-action="doc-form-save">현재 문서를 양식으로</button>`,
);
} else if (name === "doc-form-load") {
const f = (await EstApp.api("/api/forms")).find(
(x) => x.kind === "document" && x.name === node.dataset.name,
);
if (!f) throw Error("양식이 없습니다.");
if (rich().innerText.trim() && !confirm("현재 내용을 양식으로 바꾸시겠습니까?")) return;
rich().innerHTML = f.data.html ? clean(f.data.html) : escText(f.data.text);
panel("");
} else if (name === "doc-form-delete") {
if (!confirm(`「${node.dataset.name}」 양식을 지우시겠습니까?`)) return;
await EstApp.api("/api/forms/delete", json({ kind: "document", name: node.dataset.name }));
return run("doc-forms", state, node);
} else if (name === "doc-form-save" || name === "doc-default") {
const fname = name === "doc-default" ? DEFAULT[d.kind] : String(v("form-name") ?? "").trim();
if (!fname) throw Error("양식 이름을 적으세요.");
await EstApp.api("/api/forms", json({ kind: "document", name: fname, data: collect() }));
panel(`「${EstApp.esc(fname)}」 양식으로 저장했습니다.`);
}
}
const json = (body) => ({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
// 빈 문서를 열면 기본양식을 채운다
async function fill(kind) {
const node = rich();
if (!node || node.innerText.trim()) return;
const f = (await EstApp.api("/api/forms")).find(
(x) => x.kind === "document" && x.name === DEFAULT[kind],
);
if (f && !node.innerText.trim())
node.innerHTML = f.data.html ? clean(f.data.html) : escText(f.data.text);
}
const names = new Set([
"doc-par",
"doc-par-ok",
"doc-table",
"doc-table-ok",
"doc-table-style",
"doc-table-style-ok",
"doc-bookmark",
"doc-bookmark-ok",
"doc-bookmark-go",
"doc-forms",
"doc-form-load",
"doc-form-delete",
"doc-form-save",
"doc-default",
"doc-font",
"doc-font-ok",
"doc-clear",
]);
function handle(name, state, node) {
if (!names.has(name)) return false;
run(name, state, node).catch((error) => panel(EstApp.esc(error.message)));
return true;
}
if (typeof document !== "undefined" && document.addEventListener)
document.addEventListener("selectionchange", () => {
if (rich()) remember();
});
return { clean, body, collect, printHtml, insertText, fill, handle };
})();
+151
View File
@@ -0,0 +1,151 @@
// 원본 갑지(TFRMGABGI)·표지(TFRMCOVER) 팝업 — 양식저장하기·양식불러오기·양식목록(popupSaveFormat·popupLoadFormat·popupFormatTableS),
// 기본양식으로 저장(popupSaveToDefault: 새 공사에서 갑지·표지를 처음 열 때 채움), 갑지·표지 초기화(popupInit), 인쇄(popupPrint).
// 양식은 서버 양식 보관함 종류 document 에 { docKind, fields } 로 둔다(공사명은 양식에 넣지 않음 — 늘 현재 공사 이름).
const EstDocForms = (() => {
const DEFAULT = { gabgi: "기본양식-갑지", cover: "기본양식-표지" };
const FIELDS = {
gabgi: ["Position", "CompanyName", "Date", "CompanyPos", "Explan", "Cost", "Gulga"],
cover: ["Name2", "Date", "CompanyName", "CompanyPos"],
};
const post = (body) => ({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const field = (name) => document.querySelector(`#dialog-content [name="doc-${name}"]`);
function read(kind) {
return Object.fromEntries(
FIELDS[kind]
.filter((k) => field(k))
.map((k) => {
const v = field(k).value;
return [
k,
k === "Gulga"
? v
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
: k === "CompanyPos"
? Number(v)
: v,
];
}),
);
}
function write(kind, fields) {
for (const k of FIELDS[kind]) {
const node = field(k);
if (!node || fields[k] === undefined) continue;
node.value = Array.isArray(fields[k]) ? fields[k].join("\n") : fields[k];
}
}
async function list(kind) {
return (await EstApp.api("/api/forms")).filter(
(f) => f.kind === "document" && f.data?.docKind === kind,
);
}
function panel(html) {
let box = document.getElementById("doc-forms-panel");
if (!box) {
box = document.createElement("div");
box.id = "doc-forms-panel";
box.className = "business-tools";
document.querySelector("#dialog-content .form-fields")?.before(box);
}
box.innerHTML = html;
}
function tools(kind) {
return `<div class="business-tools">${[
["docf-list", "양식목록"],
["docf-save", "양식저장하기"],
["docf-default", "기본양식으로 저장"],
["docf-init", kind === "gabgi" ? "갑지초기화" : "표지초기화"],
["docf-print", "인쇄"],
]
.map(([a, l]) => `<button data-action="${a}" data-kind="${kind}">${l}</button>`)
.join("")}</div>`;
}
// 처음 여는 갑지·표지에 기본양식을 채움(저장은 사용자가 [저장])
async function fill(state, kind) {
if (state.project.docs?.[kind]) return;
const f = (await list(kind)).find((x) => x.name === DEFAULT[kind]);
if (f) write(kind, f.data.fields);
}
async function run(name, state, node) {
const kind = node.dataset.kind,
esc = EstApp.esc;
if (name === "docf-list") {
const forms = await list(kind);
panel(
forms.length
? `양식목록 ${forms.map((f) => `<button data-action="docf-load" data-kind="${kind}" data-name="${esc(f.name)}">${esc(f.name)}</button><button data-action="docf-delete" data-kind="${kind}" data-name="${esc(f.name)}" title="지우기">×</button>`).join(" ")}`
: "저장한 양식이 없습니다.",
);
} else if (name === "docf-load") {
const f = (await list(kind)).find((x) => x.name === node.dataset.name);
if (!f) throw Error("양식이 없습니다.");
write(kind, f.data.fields);
panel(`「${esc(f.name)}」 양식을 불러왔습니다. [저장]을 누르면 반영됩니다.`);
} else if (name === "docf-delete") {
if (!confirm(`「${node.dataset.name}」 양식을 지우시겠습니까?`)) return;
await EstApp.api("/api/forms/delete", post({ kind: "document", name: node.dataset.name }));
return run("docf-list", state, node);
} else if (name === "docf-save" || name === "docf-default") {
const fname = name === "docf-default" ? DEFAULT[kind] : prompt("양식 이름", "")?.trim();
if (!fname) return;
await EstApp.api(
"/api/forms",
post({ kind: "document", name: fname, data: { docKind: kind, fields: read(kind) } }),
);
panel(`「${esc(fname)}」 양식으로 저장했습니다.`);
} else if (name === "docf-init") {
if (!confirm(`${kind === "gabgi" ? "갑지" : "표지"}를 처음 상태로 되돌리시겠습니까?`)) return;
EstApp.byId("dialog").close();
EstApp.change((project) => {
if (project.docs) delete project.docs[kind];
}, false);
EstDocs.handle("doc-edit", state, { dataset: { kind } });
} else if (name === "docf-print") {
EstApp.byId("dialog").close();
await EstReport.open();
const select = document.getElementById("print-form");
select.value = kind === "gabgi" ? "8" : "9";
select.dispatchEvent(new Event("change", { bubbles: true }));
}
}
const names = new Set([
"docf-list",
"docf-load",
"docf-delete",
"docf-save",
"docf-default",
"docf-init",
"docf-print",
]);
function handle(name, state, node) {
if (!names.has(name)) return false;
run(name, state, node).catch((error) => panel(EstApp.esc(error.message)));
return true;
}
// 설계일자 선택(TfrmMonthDate 0xda6438): 갑지 「{년}년 {월}월 일 설계」(일은 빈칸) · 견적서 「{년}년 {월}월 {일}일」
function dateText(value, format) {
const [y, m, d] = String(value).split("-").map(Number);
if (!y || !m) return "";
return format === "estimate" ? `${y}년 ${m}월 ${d}일` : `${y}년 ${m}월 일 설계`;
}
if (typeof document !== "undefined" && document.addEventListener)
document.addEventListener("change", (event) => {
const t = event.target;
if (t.dataset?.dateTarget === undefined || !t.value) return;
const target = document.querySelector(`#dialog-content [name="${t.dataset.dateTarget}"]`);
if (!target) return;
target.value = dateText(t.value, t.dataset.dateFormat);
target.dispatchEvent(new Event("input", { bubbles: true }));
target.dispatchEvent(new Event("change", { bubbles: true }));
});
return { tools, fill, dateText, handle };
})();
+30 -11
View File
@@ -2,7 +2,7 @@
// 공사명은 열 때 공사 이름을 따르고 고치면 공사 이름에도 적는다(원본 TCommonOption.ProjectName 동기화).
// 공사금액 자동입력: 원가계산서에서 고른 비목을 두 방식으로 적는다(원본 c_cb2e78·c_cb3080, 한글금액 c_931b44).
// 1 = 「도급액 : 금 일천오백만원(₩15,000,000)」 · 2 = 「도급액 : 금15,000,000원(금일천오백만원)」, 「일」을 줄이지 않는다.
// 설명서·시방서 붙여넣기는 갑지의 공사명·공사위치·공사개요·공사금액을 커서 자리에 넣는다. 서식·여백은 다루지 않는다.
// 설명서·시방서 붙여넣기는 갑지의 공사명·공사위치·공사개요·공사금액을 커서 자리에 넣는다. 서식은 native-doc-editor.js.
const EstDocs = (() => {
const digits = ["", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구"];
const places = ["천", "백", "십", ""];
@@ -33,6 +33,15 @@ const EstDocs = (() => {
? `${name} : 금${comma(cost)}원(금${korean(cost)}원)`
: `${name} : 금 ${korean(cost)}원(₩${comma(cost)})`;
}
// 공사금액 자동변경(popupCostChange): 저장한 비목·방식으로 인쇄 때마다 현재 원가 금액으로 다시 만듦
function cost(project, g) {
if (!g.CostAuto?.indices?.length) return g.Cost;
try {
return costText(project, g.CostAuto.indices, g.CostAuto.method || 1);
} catch {
return g.Cost;
}
}
function costText(project, indices, method) {
const rows = project.tables.Wonga || [];
const picked = indices.map((i) => rows[i]).filter(Boolean);
@@ -98,11 +107,12 @@ const EstDocs = (() => {
const gulga = g.Gulga.length
? `<table class="sheet" style="float:right;margin-bottom:20px"><tr>${g.Gulga.map((n) => `<th>${esc(n)}</th>`).join("")}</tr><tr>${g.Gulga.map(() => "<td style='height:48px;min-width:60px'></td>").join("")}</tr></table>`
: "";
return `<div class="document-gabgi">${gulga}<p style="clear:both">${esc(g.Date)}</p>${g.Position ? `<p>공사위치 : ${esc(g.Position)}</p>` : ""}<h1 style="font-size:26pt;text-align:center;margin:40px 0">${esc(g.Name)}</h1>${g.Explan ? `<p>공사개요 :</p><pre class="document-text">${esc(g.Explan)}</pre>` : ""}${g.Cost ? `<p>공사금액 :</p><pre class="document-text">${esc(g.Cost)}</pre>` : ""}<p style="margin-top:60px;text-align:${align(g.CompanyPos)}">${esc(g.CompanyName)}</p></div>`;
return `<div class="document-gabgi">${gulga}<p style="clear:both">${esc(g.Date)}</p>${g.Position ? `<p>공사위치 : ${esc(g.Position)}</p>` : ""}<h1 style="font-size:26pt;text-align:center;margin:40px 0">${esc(g.Name)}</h1>${g.Explan ? `<p>공사개요 :</p><pre class="document-text">${esc(g.Explan)}</pre>` : ""}${cost(project, g) ? `<p>공사금액 :</p><pre class="document-text">${esc(cost(project, g))}</pre>` : ""}<p style="margin-top:60px;text-align:${align(g.CompanyPos)}">${esc(g.CompanyName)}</p></div>`;
}
if (form === 10 || form === 11) {
const text = doc(project, form === 10 ? "sibang" : "explan").text;
return text ? `<pre class="document-text">${esc(text)}</pre>` : "";
const d = doc(project, form === 10 ? "sibang" : "explan");
if (d.html && typeof EstDocEditor !== "undefined") return EstDocEditor.printHtml(d);
return d.text ? `<pre class="document-text">${esc(d.text)}</pre>` : "";
}
return "";
}
@@ -136,14 +146,15 @@ const EstDocs = (() => {
input("Position", "공사위치", d.Position) +
input("CompanyName", "발주처", d.CompanyName) +
input("Date", "설계일자", d.Date) +
`<label>날짜 선택<input type="date" data-date-target="doc-Date" data-date-format="design"></label>` +
pos(d.CompanyPos) +
area("Explan", "공사개요", d.Explan) +
area("Cost", "공사금액", d.Cost, 3) +
`<p>공사금액 자동입력: <select name="doc-rows" multiple size="4">${rows.map(([r, i]) => `<option value="${i}" ${i === fixed ? "selected" : ""}>${esc(r.Name)} ${EstApp.number(r.Cost)}</option>`).join("")}</select> <select name="doc-method"><option value="1">금 일천…원(₩…)</option><option value="2">금…원(금 일천…원)</option></select> <button data-action="doc-cost">공사금액 만들기</button></p>` +
`<p>공사금액 자동입력: <select name="doc-rows" multiple size="4">${rows.map(([r, i]) => `<option value="${i}" ${(d.CostAuto?.indices ?? [fixed]).includes(i) ? "selected" : ""}>${esc(r.Name)} ${EstApp.number(r.Cost)}</option>`).join("")}</select> <select name="doc-method"><option value="1">금 일천…원(₩…)</option><option value="2" ${d.CostAuto?.method === 2 ? "selected" : ""}>금…원(금 일천…원)</option></select> <button data-action="doc-cost">공사금액 만들기</button> <label><input type="checkbox" name="doc-CostAuto" ${d.CostAuto ? "checked" : ""}> 공사금액 자동변경(원가가 바뀌면 인쇄 때 다시 만듦)</label></p>` +
area("Gulga", "결재란(한 줄에 하나)", d.Gulga.join("\n"), 4);
} else
body =
area("text", kind === "explan" ? "공사설명서" : "시방서", d.text, 14) +
EstDocEditor.body(d) +
`<p>갑지에서 붙여넣기: ${[
["Name", "공사명"],
["Position", "공사위치"],
@@ -155,8 +166,12 @@ const EstDocs = (() => {
const title = { gabgi: "갑지", cover: "표지", explan: "공사설명서", sibang: "시방서" }[kind];
EstApp.showDialog(
`${title} 편집`,
`<div class="form-fields">${body}</div><button data-action="doc-save" data-kind="${kind}">저장</button>`,
`${(kind === "gabgi" || kind === "cover") && typeof EstDocForms !== "undefined" ? EstDocForms.tools(kind) : ""}<div class="form-fields">${body}</div><button data-action="doc-save" data-kind="${kind}">저장</button>`,
);
if (kind === "explan" || kind === "sibang") {
state.docEditor = { kind };
EstDocEditor.fill(kind);
} else if (typeof EstDocForms !== "undefined") EstDocForms.fill(state, kind);
}
function collect(kind) {
const value = (name) => field(`doc-${name}`)?.value ?? "";
@@ -177,12 +192,18 @@ const EstDocs = (() => {
CompanyPos: Number(value("CompanyPos")),
Explan: value("Explan"),
Cost: value("Cost"),
CostAuto: field("doc-CostAuto")?.checked
? {
indices: [...field("doc-rows").selectedOptions].map((o) => Number(o.value)),
method: Number(value("method")),
}
: null,
Gulga: value("Gulga")
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean),
};
return { text: value("text") };
return EstDocEditor.collect();
}
function handle(name, state, node) {
@@ -205,9 +226,7 @@ const EstDocs = (() => {
g = doc(state.project, "gabgi"),
text = String(g[key] ?? "").trim();
if (!text) throw Error("갑지에서 입력을 하지 않았습니다.");
const area = field("doc-text"),
at = area.selectionStart ?? area.value.length;
area.value = area.value.slice(0, at) + text + area.value.slice(area.selectionEnd ?? at);
EstDocEditor.insertText(text);
} else return false;
return true;
}
+11 -5
View File
@@ -33,10 +33,11 @@ const EstNative = (() => {
"MulTitleDat",
];
const clone = (value) => structuredClone(value);
function price(row, kind) {
const candidates = Array.from({ length: 6 }, (_, i) => num(row[`Cost${i + 1}`])).filter(
(value) => value !== 0,
);
function price(row, kind, hidden) {
// 발췌근거에서 숨긴 출처(EstSources)는 최저·평균·최고에서 뺀다
const candidates = Array.from({ length: 6 }, (_, i) =>
hidden?.has(i + 1) ? 0 : num(row[`Cost${i + 1}`]),
).filter((value) => value !== 0);
let total;
switch (num(row.ValueIndex)) {
case 0:
@@ -144,7 +145,12 @@ const EstNative = (() => {
.concat(calculated.filter((row) => !existing.has(row.Code)));
}
for (const kind of [5, 6, 21, 101])
for (const row of tables[sourceTables[kind]]) price(row, kind);
for (const row of tables[sourceTables[kind]])
price(
row,
kind,
typeof EstSources !== "undefined" ? EstSources.hidden(options, sourceTables[kind]) : null,
);
const machine = EstMachine.calculate(tables.MachMainDat, tables.MachGijunDat, options);
tables.MachMainDat = machine.rows;
saveTitles("MachTitleDat", machine.titles);
+30
View File
@@ -0,0 +1,30 @@
// 원본 「ES 정보」(ES도구 mnuESInfo → TfrmESInfo 0x10b838c) — 조달청 물가변동(ES) 제출용 기준시점·비교시점·최종기성일·
// 선급금지급일·선급금지급액. 원본은 작업 폴더 ProjectDataInfo.Ini [EscInfo] SDATE·CDATE·ACDATE·PreDATE·PreCost 에 두고
// 날짜 기본값은 오늘. 웹은 project.esInfo 에 둔다(물가변동 계산과는 직접 잇지 않음 — 원본도 ES 제출 파일 작성에만 씀).
const EstEsInfo = (() => {
const today = () => new Date().toISOString().slice(0, 10);
function dialog(state) {
const e = state.project.esInfo || {};
EstActions.form(
"ES 정보",
[
{ name: "SDATE", label: "기준시점", type: "date", value: e.SDATE || today() },
{ name: "CDATE", label: "비교시점", type: "date", value: e.CDATE || today() },
{ name: "ACDATE", label: "최종기성일", type: "date", value: e.ACDATE || today() },
{ name: "PreDATE", label: "선급금지급일", type: "date", value: e.PreDATE || today() },
{ name: "PreCost", label: "선급금지급액(원)", type: "number", value: e.PreCost ?? 0 },
],
(values) => {
if (!Number.isFinite(values.PreCost) || values.PreCost < 0)
throw Error("선급금지급액은 0 이상 수치입니다.");
EstApp.change((project) => (project.esInfo = { ...values }), false);
},
);
}
function handle(name, state) {
if (name !== "es-info") return false;
dialog(state);
return true;
}
return { handle };
})();
+1 -1
View File
@@ -171,7 +171,7 @@ const EstEstimatePrint = (() => {
`<label><input name="ep-${k}" type="checkbox" ${s[k] ? "checked" : ""}>${label}</label>`;
EstApp.showDialog(
"견적서 작성",
`<div class="form-fields">${input("Title_Name", "제목")}${input("Project_Name", "공사명(| 줄바꿈)")}${input("Order_name", "발주처")}${input("Esti_Date", "견적일자")}${input("Level", "설계내역 레벨표시(0~9)", "number")}${input("F_PageCount", "첫 페이지 줄수", "number")}${input("L_PageCount", "나머지 페이지 줄수", "number")}<label>품명·규격 정렬<select name="ep-Aligment"><option value="0" ${num(s.Aligment) ? "" : "selected"}>왼쪽</option><option value="1" ${num(s.Aligment) ? "selected" : ""}>가운데</option></select></label>${check("Bigo_Show", "비고란 보기")}${check("Company_Show", "공급자 정보 인쇄")}</div><h4>공급자</h4><div class="form-fields">${input("Reg_Num", "등록번호")}${input("ComPany_Name", "상호")}${input("Ceo_Name", "대표자")}${input("Addr", "사업장주소")}${input("Uptae", "업태")}${input("Jongmog", "종목")}${input("Tel", "전화,(FAX)")}</div><button data-action="estimate-print-preview">미리보기</button> <button data-action="estimate-print-print">인쇄</button> <button data-action="estimate-print-save">공급자정보 저장하기(.einf)</button> <button data-action="estimate-print-load">공급자정보 불러오기</button><iframe id="estimate-print-frame" style="width:100%;height:420px;border:1px solid #ccc;margin-top:8px"></iframe>`,
`<div class="form-fields">${input("Title_Name", "제목")}${input("Project_Name", "공사명(| 줄바꿈)")}${input("Order_name", "발주처")}${input("Esti_Date", "견적일자")}<label>견적일자 선택<input type="date" data-date-target="ep-Esti_Date" data-date-format="estimate"></label>${input("Level", "설계내역 레벨표시(0~9)", "number")}${input("F_PageCount", "첫 페이지 줄수", "number")}${input("L_PageCount", "나머지 페이지 줄수", "number")}<label>품명·규격 정렬<select name="ep-Aligment"><option value="0" ${num(s.Aligment) ? "" : "selected"}>왼쪽</option><option value="1" ${num(s.Aligment) ? "selected" : ""}>가운데</option></select></label>${check("Bigo_Show", "비고란 보기")}${check("Company_Show", "공급자 정보 인쇄")}</div><h4>공급자</h4><div class="form-fields">${input("Reg_Num", "등록번호")}${input("ComPany_Name", "상호")}${input("Ceo_Name", "대표자")}${input("Addr", "사업장주소")}${input("Uptae", "업태")}${input("Jongmog", "종목")}${input("Tel", "전화,(FAX)")}</div><button data-action="estimate-print-preview">미리보기</button> <button data-action="estimate-print-print">인쇄</button> <button data-action="estimate-print-save">공급자정보 저장하기(.einf)</button> <button data-action="estimate-print-load">공급자정보 불러오기</button><iframe id="estimate-print-frame" style="width:100%;height:420px;border:1px solid #ccc;margin-top:8px"></iframe>`,
);
}
function read(state) {
+163
View File
@@ -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;
+30 -3
View File
@@ -205,7 +205,7 @@ const EstExcelMap = (() => {
`<option value="${value}" ${map.mode === value ? "selected" : ""}>${label}</option>`;
EstApp.showDialog(
"Excel 내역 열 맞춰 가져오기",
`<div class="form-fields"><label>시트<select name="xm-sheet">${sheets}</select></label><label>시작행<input name="xm-start" type="number" value="${map.start}"></label><label>끝행<input name="xm-end" type="number" value="${map.end}"></label><label>가져오기<select name="xm-mode">${mode("append", "순공사비 위에 추가")}${mode("replace", "순공사비 위 행을 이것으로 바꾸기")}</select></label></div><p>각 항목이 있는 Excel 열 문자(A, B …)를 적으세요. 비우면 가져오지 않습니다. 금액은 단가×수량으로 다시 계산합니다.</p><div class="form-fields">${fields.map(([key, label]) => `<label>${label}<input name="xm-${key}" value="${esc(map.columns[key] || "")}" size="3"></label>`).join("")}</div><button data-action="excel-map-preview">미리보기 새로고침</button> <button data-action="excel-map-apply">가져오기</button><div id="excel-map-preview">${preview(state)}</div>`,
`<div class="form-fields"><label>시트<select name="xm-sheet">${sheets}</select></label><label>시작행<input name="xm-start" type="number" value="${map.start}"></label><label>끝행<input name="xm-end" type="number" value="${map.end}"></label><label>가져오기<select name="xm-mode">${mode("append", "순공사비 위에 추가")}${mode("replace", "순공사비 위 행을 이것으로 바꾸기")}</select></label></div><p>각 항목이 있는 Excel 열 문자(A, B …)를 적으세요. 비우면 가져오지 않습니다. 금액은 단가×수량으로 다시 계산합니다.</p><div class="form-fields">${fields.map(([key, label]) => `<label>${label}<input name="xm-${key}" value="${esc(map.columns[key] || "")}" size="3"></label>`).join("")}</div><button data-action="excel-map-shift" data-step="1">셀부호 우로밀기</button><button data-action="excel-map-shift" data-step="-1">셀부호 좌로 당기기</button> <button data-action="excel-map-preview">미리보기 새로고침</button> <button data-action="excel-map-apply">가져오기</button><div id="excel-map-preview">${preview(state)}</div>`,
);
}
@@ -273,7 +273,23 @@ const EstExcelMap = (() => {
state.notice = `Excel에서 ${built.length}행을 가져왔습니다. 단가 고르기로 기초자료에 연결할 수 있습니다.`;
}
function handle(name, state) {
// 셀부호 밀기·당기기(원본 popCellLeft·popupCellRight 0x109ed40·0x109ed70): 첫 필드 열만 보고
// 모든 필드를 그 다음(앞) 열부터 차례로 다시 매긴다(손으로 고친 열은 사라짐).
function shift(columns, step) {
const first = columnIndex(columns[fields[0][0]] || "A");
if (step < 0 && first <= 0) throw Error('시작 부호는 "A" 이하로는 할수 없습니다.');
const start = first + step;
fields.forEach(([key], i) => (columns[key] = columnName(start + i)));
return columns;
}
function handle(name, state, node) {
if (name === "excel-map-shift") {
read(state);
shift(state.excelMap.columns, Number(node.dataset.step));
form(state);
return true;
}
if (name === "excel-map-open") open(state);
else if (name === "excel-map-preview") {
read(state);
@@ -283,5 +299,16 @@ const EstExcelMap = (() => {
return true;
}
return { fields, columnIndex, columnName, records, rows, insert, load, handle };
return {
fields,
columnIndex,
columnName,
records,
rows,
insert,
load,
shift,
sheetTables,
handle,
};
})();
+260
View File
@@ -0,0 +1,260 @@
// 원본 엑셀형 수량산출서(TFRMMULEXCEL) 웹 계산·편집. 셀 = sheet.excel[n].cells["행,열"] = { value, formula? }.
// 수식: 셀 참조·범위·+−×÷^·&·비교·괄호·숫자·문자·SUM·AVERAGE·MIN·MAX·ABS·INT·ROUND·ROUNDUP·ROUNDDOWN·TRUNC·SQRT·PRODUCT·IF.
// 셀 합(btnSumRight·Top·Left·Bottom, 원본 ActionAdd* 와 같은 규칙): 고른 셀에서 그 방향으로 빈칸·숫자 셀을 지나
// 글자 셀이나 끝에서 멈춘 범위로 =SUM(…) 을 넣는다.
const EstExcelSheet = (() => {
const letters = (n) => {
let s = "";
for (n += 1; n > 0; n = Math.floor((n - 1) / 26))
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
return s;
};
const parseRef = (text) => {
const m = String(text)
.toUpperCase()
.replace(/\$/g, "")
.match(/^([A-Z]{1,3})(\d+)$/);
if (!m) return null;
return {
col: [...m[1]].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1,
row: Number(m[2]) - 1,
};
};
const key = (row, col) => `${row},${col}`;
const round = (v, d, mode) => {
const k = 10 ** d,
x = v * k;
const r =
mode === "up"
? Math.sign(x) * Math.ceil(Math.abs(x) - 1e-9)
: mode === "down"
? Math.trunc(x)
: Math.sign(x) * Math.round(Math.abs(x));
return r / k;
};
const fns = {
SUM: (a) => a.flat().reduce((n, v) => n + (typeof v === "number" ? v : 0), 0),
AVERAGE: (a) => {
const v = a.flat().filter((x) => typeof x === "number");
return v.length ? v.reduce((n, x) => n + x, 0) / v.length : 0;
},
MIN: (a) => Math.min(...a.flat().filter((x) => typeof x === "number")),
MAX: (a) => Math.max(...a.flat().filter((x) => typeof x === "number")),
PRODUCT: (a) =>
a
.flat()
.filter((x) => typeof x === "number")
.reduce((n, x) => n * x, 1),
ABS: ([x]) => Math.abs(num(x)),
INT: ([x]) => Math.floor(num(x)),
SQRT: ([x]) => Math.sqrt(num(x)),
ROUND: ([x, d]) => round(num(x), num(d)),
ROUNDUP: ([x, d]) => round(num(x), num(d), "up"),
ROUNDDOWN: ([x, d]) => round(num(x), num(d), "down"),
TRUNC: ([x, d = 0]) => round(num(x), num(d), "down"),
IF: ([c, a = true, b = false]) => (c && c !== 0 ? a : b),
};
function num(v) {
if (Array.isArray(v)) v = v.flat()[0];
if (typeof v === "number") return v;
if (typeof v === "boolean") return v ? 1 : 0;
const n = Number(String(v ?? "").replaceAll(",", ""));
return Number.isFinite(n) ? n : 0;
}
function tokenize(text) {
const tokens = [],
re =
/\s*("(?:[^"]|"")*"|\$?[A-Za-z]{1,3}\$?\d+(?::\$?[A-Za-z]{1,3}\$?\d+)?|[A-Za-z_][A-Za-z0-9_.]*(?=\()|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|<=|>=|<>|[-+*/^&=<>(),%:])/y;
let m;
re.lastIndex = 0;
while (re.lastIndex < text.length && (m = re.exec(text))) tokens.push(m[1]);
if (re.lastIndex < text.trimEnd().length) throw Error(`수식 해석 오류: ${text}`);
return tokens;
}
function evaluate(text, get) {
const t = tokenize(String(text).replace(/^=/, "").replace(/×/g, "*").replace(/÷/g, "/"));
let i = 0;
const peek = () => t[i],
next = () => t[i++];
const range = (a, b) => {
const p = parseRef(a),
q = parseRef(b),
out = [];
for (let r = Math.min(p.row, q.row); r <= Math.max(p.row, q.row); r++)
for (let c = Math.min(p.col, q.col); c <= Math.max(p.col, q.col); c++) out.push(get(r, c));
return out;
};
function primary() {
const tok = next();
if (tok === undefined) throw Error("수식이 끝나지 않았습니다.");
if (tok === "(") {
const v = compare();
if (next() !== ")") throw Error("괄호가 맞지 않습니다.");
return v;
}
if (tok === "-") return -num(power());
if (tok === "+") return num(power());
if (tok.startsWith('"')) return tok.slice(1, -1).replaceAll('""', '"');
if (/^\d/.test(tok)) return Number(tok);
if (/^(TRUE|FALSE)$/i.test(tok)) return /^TRUE$/i.test(tok);
if (tok.includes(":")) {
const [a, b] = tok.split(":");
return range(a, b);
}
if (peek() === "(" && fns[tok.toUpperCase()]) {
next();
const args = [];
if (peek() !== ")")
do args.push(compare());
while (peek() === "," && next());
if (next() !== ")") throw Error("함수 괄호가 맞지 않습니다.");
return fns[tok.toUpperCase()](args);
}
const ref = parseRef(tok);
if (ref) return get(ref.row, ref.col);
throw Error(`모르는 수식 낱말: ${tok}`);
}
function percent() {
let v = primary();
while (peek() === "%") (next(), (v = num(v) / 100));
return v;
}
function power() {
let v = percent();
while (peek() === "^") (next(), (v = num(v) ** num(percent())));
return v;
}
function product() {
let v = power();
while (peek() === "*" || peek() === "/")
v = next() === "*" ? num(v) * num(power()) : num(v) / num(power());
return v;
}
function sum() {
let v = product();
while (peek() === "+" || peek() === "-")
v = next() === "+" ? num(v) + num(product()) : num(v) - num(product());
return v;
}
function concat() {
let v = sum();
while (peek() === "&") (next(), (v = `${v ?? ""}${sum() ?? ""}`));
return v;
}
function compare() {
let v = concat();
while (["=", "<>", "<", ">", "<=", ">="].includes(peek())) {
const op = next(),
w = concat();
v = { "=": v == w, "<>": v != w, "<": v < w, ">": v > w, "<=": v <= w, ">=": v >= w }[op];
}
return v;
}
const value = compare();
if (i < t.length) throw Error(`수식 뒤 남은 글: ${t.slice(i).join("")}`);
return Array.isArray(value) ? value.flat()[0] : value;
}
// 시트 전체 다시 계산(수식 셀). 오류 셀은 value 를 「#오류」로 두고 errors 에 모은다.
function recalc(cells) {
const done = new Set(),
active = new Set(),
errors = [];
function get(row, col) {
const k = key(row, col),
cell = cells[k];
if (!cell) return null;
if (typeof cell.formula !== "string" || done.has(k)) return cell.value;
if (active.has(k)) throw Error(`순환 참조: ${letters(col)}${row + 1}`);
active.add(k);
try {
const v = evaluate(cell.formula, get);
cell.value = typeof v === "number" ? Number(v.toFixed(10)) : v;
} finally {
active.delete(k);
done.add(k);
}
return cell.value;
}
for (const k of Object.keys(cells)) {
const [r, c] = k.split(",").map(Number);
try {
get(r, c);
} catch (error) {
cells[k].value = "#오류";
errors.push(`${letters(c)}${r + 1}: ${error.message}`);
}
}
return errors;
}
// 원본 셀 합: dir = right·left·up·down(Top=up). 고른 셀에서 반대쪽으로 빈칸·숫자 셀을 지나 글자 셀/끝에서 멈춤.
function sumFormula(cells, row, col, dir) {
const step = { left: [0, -1], right: [0, 1], up: [-1, 0], down: [1, 0] }[dir];
if (!step) throw Error("합 방향을 고르세요.");
let r = row + step[0],
c = col + step[1],
last = null;
const limit = {
rows: 1 + Math.max(0, ...Object.keys(cells).map((k) => Number(k.split(",")[0]))),
cols: 1 + Math.max(0, ...Object.keys(cells).map((k) => Number(k.split(",")[1]))),
};
while (r >= 0 && c >= 0 && r < limit.rows && c < limit.cols) {
const v = cells[key(r, c)]?.value;
if (v != null && v !== "" && typeof v !== "number") break;
last = [r, c];
r += step[0];
c += step[1];
}
if (!last) throw Error("더할 셀이 없습니다.");
const a = `${letters(Math.min(col + step[1], last[1]))}${Math.min(row + step[0], last[0]) + 1}`,
b = `${letters(Math.max(col + step[1], last[1]))}${Math.max(row + step[0], last[0]) + 1}`;
return `SUM(${a}:${b})`;
}
// 산출서 자리수(1 반올림·2 절사·3 올림)
function fix(value, digits = 2, mode = 2) {
if (typeof EstMath !== "undefined") return EstMath.fixed(value, digits, mode);
return round(value, digits, mode === 1 ? undefined : mode === 3 ? "up" : "down");
}
// 연결된 셀 값 → 항목 수량
function itemValues(sheet) {
const out = new Map();
if (sheet.excelOff) return out; // 일반형으로 돌린 산출서는 셀 연결을 쓰지 않음
const digits = sheet.options?.digits ?? 2,
mode = sheet.options?.roundMode ?? 2;
for (const [code, l] of Object.entries(sheet.excelLinks || {})) {
const v = sheet.excel?.[l.n]?.cells?.[key(l.row, l.col)]?.value;
if (typeof v === "number") out.set(code, fix(v, digits, mode));
}
return out;
}
// 가져올 때 추정 연결: 다시 계산한 셀 중 값이 항목 수량과 같은 셀 하나(합계 수식 → 아래쪽 우선).
function inferLinks(sheet) {
const links = {},
digits = sheet.options?.digits ?? 2,
mode = sheet.options?.roundMode ?? 2;
(sheet.excel || []).forEach((s) => recalc(s.cells));
for (const item of sheet.items || []) {
const target = Number(item.GN);
if (!target) continue;
const found = [];
(sheet.excel || []).forEach((s, n) => {
for (const [k, c] of Object.entries(s.cells))
if (typeof c.value === "number" && fix(c.value, digits, mode) === target) {
const [row, col] = k.split(",").map(Number);
found.push({ n, row, col, sum: /^SUM\(/i.test(String(c.formula ?? "")) });
}
});
found.sort((a, b) => Number(b.sum) - Number(a.sum) || b.row - a.row || a.col - b.col);
if (found[0])
links[item.Code] = { n: found[0].n, row: found[0].row, col: found[0].col, inferred: true };
}
return links;
}
return { evaluate, recalc, sumFormula, parseRef, letters, itemValues, inferLinks };
})();
if (typeof module !== "undefined" && module.exports) module.exports = EstExcelSheet;
+327
View File
@@ -0,0 +1,327 @@
// 원본 엑셀형 수량산출서(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-print", "미리보기·인쇄"),
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", "분리"),
`<select data-excel-digits aria-label="셀 서식 소수자리"><option value="">셀 서식(소수자리)</option>${[0, 1, 2, 3, 4].map((d) => `<option value="${d}">소수 ${d}자리</option>`).join("")}<option value="-">서식 해제</option></select>`,
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 "";
const { esc, number } = EstApp,
sel = state.excelCell?.sheet === sheet.Code ? state.excelCell : null,
links = sheet.excelLinks || {};
const linked = new Map(
Object.entries(links).map(([code, l]) => [`${l.n},${l.row},${l.col}`, { code, ...l }]),
);
return sheet.excel
.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), ...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 k = `${r},${c}`;
if (covered.has(k)) return "";
const cell = cells[k],
link = linked.get(`${n},${r},${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
? ""
: typeof cell.value === "number"
? s.digits != null && s.digits !== ""
? cell.value.toLocaleString("ko-KR", {
minimumFractionDigits: Number(s.digits),
maximumFractionDigits: Number(s.digits),
})
: number(cell.value)
: esc(cell.value);
const style = [
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(";");
const title = [
typeof cell?.formula === "string" ? `=${cell.formula}` : "",
link ? `수량: ${link.code}${link.inferred ? " (추정 연결)" : ""}` : "",
]
.filter(Boolean)
.join(" · ");
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}</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("");
}
function sheetOf(state) {
return state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
}
function setCell(sheet, n, row, col, text) {
const cells = sheet.excel[n].cells,
k = `${row},${col}`,
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),
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) => {
if (event.target.dataset.excelDigits !== undefined) {
const v = event.target.value,
state = EstApp.state,
sheet = sheetOf(state),
sel = state.excelCell;
if (!v || !sheet?.excel || !sel) return;
EstApp.change(() => {
EstExcelEdit.style(sheet.excel[sel.n], range(sel), "digits", v === "-" ? "" : v);
sheet.excelEdited = true;
}, false);
return;
}
if (event.target.dataset.excelBar === undefined) return;
const state = EstApp.state,
sel = state.excelCell,
value = event.target.value;
let errors = [];
EstApp.change(() => {
errors = setCell(sheetOf(state), sel.n, sel.row, sel.col, value);
}, 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",
"excel-print",
]);
function handle(name, state, node) {
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(page.cells, sel.row, sel.col, node.dataset.dir);
EstApp.change(() => setCell(sheet, sel.n, sel.row, sel.col, `=${formula}`), false);
} 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 if (name === "excel-print") {
// 미리보기·인쇄(tbsPreView·tbsPrint): 고른 시트 격자만 새 창에
const html = render({ ...sheet, excel: [page], excelLinks: {} }, { excelCell: null })
.replace(/<div class="business-tools">[\s\S]*?<\/div>/, "")
.replace(/ data-excel-pick="[^"]*"/g, "");
const win = open("", "_blank");
if (!win) throw Error("새 창을 열 수 없습니다. 팝업 차단을 확인하세요.");
win.document.write(
`<!doctype html><meta charset="utf-8"><title>${EstApp.esc(sheet.Name || page.name)}</title><style>table{border-collapse:collapse;font:10pt sans-serif}td,th{border:1px solid #888;padding:2px 4px}th{background:#eee}</style>${html}<script>print()<\/script>`,
);
win.document.close();
} 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))
if (l.n === sel.n && l.row === sel.row && l.col === sel.col) delete sheet.excelLinks[k];
if (name === "excel-link") {
if (!code) throw Error("수량 항목을 고르세요.");
sheet.excelLinks[code] = { n: sel.n, row: sel.row, col: sel.col };
}
}, false);
}
return true;
}
if (typeof document !== "undefined" && document.addEventListener) install();
return { render, setCell, handle };
})();
+2 -11
View File
@@ -220,17 +220,7 @@ const EstExtra = (() => {
}),
) +
`<p class="business-note">${esc((output.errors || []).join(" / "))} 금회 도급액 ${number(output.total)} · 기성비율 ${number(output.ratio)}%</p>`;
body += table(
["원가 비목", "도급금액", "전회", "금회", "사정", "누계"],
(output.costs || []).map((row) => [
esc(row.Name || row.Gubun),
number(row.ContractCost),
number(row.JunCost),
number(row.GumCost),
number(row.SaCost),
number(row.TotalCost),
]),
);
body += EstProgressWonga.render(state, round, output);
}
html = box(
"기성고",
@@ -338,6 +328,7 @@ const EstExtra = (() => {
"↓",
`data-row="${index}" data-direction="1" ${index === project.annual.length - 1 ? "disabled" : ""}`,
) +
button("insert-annual", "위에 삽입", `data-row="${index}"`) +
button("delete-extra", "삭제", `data-section="annual" data-row="${index}"`),
]),
),
+25 -2
View File
@@ -1,6 +1,8 @@
// 원본 원가계산서 양식(TFRMWONGABASE popupSaveAs·popupFormats·popupLoadFrom)과 양식목록(TFRMFORMATFILELIST)
// — 현재 원가 표를 이름 붙여 서버 보관함(data/forms.json)에 등록하고, 목록에서 열기·삭제, 파일로 내려받기·불러오기.
// 기본양식으로 저장(popupSaveAsMain): 이름 「기본양식」 — 새 내역서를 만들 때 원가계산서로 쓴다.
const EstFormsLibrary = (() => {
const DEFAULT = "기본양식";
// 원가 표 양식으로 남길 필드 — 금액(Cost)은 계산 결과라 남기지 않는다.
function strip(rows) {
return rows.map((row) => {
@@ -23,7 +25,7 @@ const EstFormsLibrary = (() => {
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>`,
`<p>현재 원가계산서의 항목·수식·요율을 양식으로 등록하고, 다른 공사에서 열어 씁니다. 금액은 열 때 다시 계산합니다.</p><button data-action="wonga-form-register">현재 원가계산서 등록</button> <button data-action="wonga-form-default">기본양식으로 저장</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;
}
@@ -52,6 +54,18 @@ const EstFormsLibrary = (() => {
body: JSON.stringify({ kind: "wonga", name: title, data }),
});
await show(state);
} else if (name === "wonga-form-default") {
if (!confirm("현재 원가계산서를 새 내역서의 기본양식으로 저장하시겠습니까?")) return;
await EstApp.api("/api/forms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind: "wonga",
name: DEFAULT,
data: strip(validRows(state.project.tables.Wonga)),
}),
});
await show(state);
} else if (name === "wonga-form-open") {
const form = state.formsLibrary?.find((f) => f.name === node.dataset.name);
if (!form) throw Error("양식을 찾을 수 없습니다. 목록을 다시 여세요.");
@@ -92,6 +106,7 @@ const EstFormsLibrary = (() => {
const names = new Set([
"wonga-forms",
"wonga-form-register",
"wonga-form-default",
"wonga-form-open",
"wonga-form-delete",
"wonga-form-export",
@@ -107,5 +122,13 @@ const EstFormsLibrary = (() => {
return true;
}
return { strip, validRows, handle };
// 새 내역서에 기본양식 원가계산서
async function applyDefault() {
const form = (await list()).find((f) => f.name === DEFAULT);
if (form)
EstApp.change((project) => (project.tables.Wonga = structuredClone(validRows(form.data))));
return Boolean(form);
}
return { strip, validRows, applyDefault, handle };
})();
+9
View File
@@ -266,6 +266,15 @@ const EstForms = (() => {
columns = [...common, "GN", "TU", "TC", "NC", "GC", "JC", "SC"];
}
}
if (
table === "Wonga" &&
project.options.WongaSNameShow &&
typeof EstWongaTools !== "undefined"
) {
const rows = project.tables.Wonga || [],
abbr = EstWongaTools.abbreviate(rows, project.options);
return { table, columns, rows: rows.map((row, i) => ({ ...row, VSusic: abbr[i] })) };
}
return { table, columns, rows: project.tables[table] || [] };
}
const totals = {
+70
View File
@@ -0,0 +1,70 @@
// 원본 단가산출 「전역변수 지정,해제」(mnuCostSetGlobalVar 0xf053cc)·「전역변수 목록」(mnuCostGlobalvarList → TfrmGlobalVarList).
// 지정·해제: 마지막으로 누른 산출근거 칸의 커서 아래 낱말([0-9A-Za-z_], 영문자 하나 이상)을 ⟦전역⟧ 표지로 감싸거나 푼다.
// 목록: 단가산출을 처음부터 계산해 전역변수 대입마다 변수명·값과 그 줄로 이동.
const EstGlobalVars = (() => {
const MARK = "⟦전역⟧";
let last = null;
if (typeof document !== "undefined" && document.addEventListener)
document.addEventListener("focusin", (event) => {
const t = event.target.closest?.('[data-table="CostMainDat"][data-field="LineData"]');
if (t) last = t;
});
function toggle(text, caret) {
const word = /[0-9A-Za-z_]/;
let a = caret,
b = caret;
while (a > 0 && word.test(text[a - 1])) a--;
while (b < text.length && word.test(text[b])) b++;
const name = text.slice(a, b);
if (!name || !/[A-Za-z]/.test(name))
throw Error(
'선택한 변수는 전역변수로 지정할수 없습니다.\n\n변수는 알파벳과 숫자,"_"로 이루어져야합니다.\n\n예) Q , V1 , L_1',
);
const before = text.slice(0, a),
after = text.slice(b);
if (before.endsWith(MARK) && after.startsWith(MARK))
return before.slice(0, -MARK.length) + name + after.slice(MARK.length);
return `${before}${MARK}${name}${MARK}${after}`;
}
function list(state) {
const out = EstText.calculate(state.project.tables.CostMainDat || [], state.project.options);
const { esc, number } = EstApp;
EstApp.showDialog(
"전역변수 목록",
out.globals.length
? `<table class="sheet"><thead><tr><th>변수명</th><th>값</th><th>이동</th></tr></thead><tbody>${out.globals.map((g) => `<tr><td>${esc(g.name)}</td><td>${number(g.value)}</td><td><button data-action="global-var-go" data-row="${g.index}">이동</button></td></tr>`).join("")}</tbody></table>`
: "<p>전역변수가 없습니다. 단가산출 산출근거의 변수에 커서를 두고 「전역변수 지정,해제」를 누르세요.</p>",
);
}
function handle(name, state, node) {
if (name === "global-var") {
if (!last) throw Error("단가산출 산출근거 칸의 변수에 커서를 두세요.");
const index = Number(last.dataset.row),
text = last.value,
next = toggle(text, last.selectionStart ?? 0);
EstApp.change(
(project) => (project.tables.CostMainDat[index].LineData = EstText.encode(next)),
);
} else if (name === "global-var-list") list(state);
else if (name === "global-var-go") {
EstApp.byId("dialog").close();
const index = Number(node.dataset.row);
state.view = "CostMainDat";
const rows = state.project.tables.CostMainDat;
let head = index;
while (head > 0 && !(Number(rows[head].Index) > 0)) head--;
state.group = rows[head]?.Code || state.group;
state.selection.clear();
state.selection.add(index);
EstApp.render();
document
.querySelector(`[data-table="CostMainDat"][data-row="${index}"][data-field="LineData"]`)
?.scrollIntoView({ block: "center" });
} else return false;
return true;
}
return { toggle, handle };
})();
+124
View File
@@ -0,0 +1,124 @@
// 원본 「JDLExcel과 단가비교」(ActionJDLCompare, 숨은 단축키 Ctrl+Alt+J → TfrmJDLExcelCompare) — 일위대가·단가산출·기계경비 호표마다
// Excel 의 코드 열에서 그 코드를 품은 셀(Pos(코드, 셀) > 0)을 1행~(호표 수+50)행 안에서 찾아 같은 행 합계 열 값과 TC 를 비교한다.
// 차이(|TC − Excel|)가 있으면 표시, 못 찾으면 「없음」. 자료는 바꾸지 않는다(보기 전용). Excel 은 .xlsx·.xls 모두.
const EstJdlCompare = (() => {
const TABLES = [
["HeadTitleDat", "일위대가"],
["CostTitleDat", "단가산출"],
["MachTitleDat", "기계경비"],
];
function compare(rows, table, codeCol, totalCol) {
const limit = rows.length + 50;
return rows
.filter((r) => String(r.Code ?? "").trim())
.map((r) => {
const code = String(r.Code).trim();
let found = null;
for (let i = 0; i < Math.min(limit, table.length); i++) {
const cell = table[i]?.[codeCol];
if (cell != null && String(cell).includes(code)) {
found = Number(String(table[i][totalCol] ?? "").replace(/,/g, "")) || 0;
break;
}
}
const tc = Number(r.TC) || 0;
return {
Code: code,
Name: r.Name ?? "",
Size: r.Size ?? "",
Unite: r.Unite ?? "",
TC: tc,
OTC: found,
GN: found == null ? null : Math.abs(tc - found),
Checked: found == null || Math.abs(tc - found) > 1e-9,
};
});
}
// .xls(BIFF) 셀 → 행 배열
function xlsTables(sheets) {
const out = {};
for (const s of sheets) {
const table = [];
for (const [k, c] of Object.entries(s.cells)) {
const [r, col] = k.split(",").map(Number);
(table[r] ||= [])[col] = c.value;
}
out[s.name] = table;
}
return out;
}
function form(state) {
const c = (state.jdlCompare ||= {
tab: 0,
sheet: 1,
code: "A",
total: "H",
tables: null,
result: [],
}),
{ esc, number } = EstApp,
diff = c.result.filter((r) => r.Checked).length;
EstApp.showDialog(
"JDL엑셀 단가 비교",
`<div class="business-tools">${TABLES.map(([, l], i) => `<button data-action="jdl-compare-tab" data-i="${i}"${i === c.tab ? ' style="font-weight:bold"' : ""}>${l}</button>`).join("")}</div><div class="form-fields"><label>시트번호<input name="jc-sheet" type="number" min="1" value="${c.sheet}"></label><label>코드칼럼<input name="jc-code" value="${esc(c.code)}" size="3"></label><label>합계셀칼럼<input name="jc-total" value="${esc(c.total)}" size="3"></label></div><button data-action="jdl-compare-open">엑셀파일열기</button> <button data-action="jdl-compare-run">검 색</button> ${c.tables ? `시트 ${Object.keys(c.tables).length}개` : "Excel 을 여세요"} · 다른 금액·없음 ${diff}건<div style="max-height:380px;overflow:auto"><table class="sheet"><thead><tr><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>EST 금액</th><th>Excel 금액</th><th>차이</th></tr></thead><tbody>${c.result.map((r) => `<tr${r.Checked ? ' style="background:#fde2e2"' : ""}><td>${esc(r.Code)}</td><td style="text-align:left">${esc(r.Name)}</td><td>${esc(r.Size)}</td><td>${esc(r.Unite)}</td><td>${number(r.TC)}</td><td>${r.OTC == null ? "없음" : number(r.OTC)}</td><td>${r.GN == null ? "" : number(r.GN)}</td></tr>`).join("")}</tbody></table></div>`,
);
}
function read(c) {
const v = (n) => document.querySelector(`#dialog-content [name="${n}"]`)?.value;
c.sheet = Math.max(1, Number(v("jc-sheet")) || 1);
c.code = String(v("jc-code") || "A").toUpperCase();
c.total = String(v("jc-total") || "H").toUpperCase();
}
async function run(name, state, node) {
if (name === "jdl-compare") return form(state);
const c = state.jdlCompare;
read(c);
if (name === "jdl-compare-tab") {
c.tab = Number(node.dataset.i);
c.result = [];
} else if (name === "jdl-compare-open") {
const input = document.createElement("input");
input.type = "file";
input.accept = ".xlsx,.xls";
input.addEventListener("change", async () => {
try {
const file = input.files[0];
if (!file) return;
const body = await file.arrayBuffer();
c.tables = /\.xls$/i.test(file.name)
? xlsTables(await EstApp.api("/api/read-xls", { method: "POST", body }))
: EstExcelMap.sheetTables(
await EstApp.api("/api/import-xlsx", { method: "POST", body }),
);
} catch (error) {
state.notice = error.message;
}
form(state);
});
return input.click();
} else if (name === "jdl-compare-run") {
if (!c.tables) throw Error("엑셀파일을 먼저 여세요.");
const table = Object.values(c.tables)[c.sheet - 1];
if (!table) throw Error(`시트 ${c.sheet} 이 없습니다.`);
const col = (x) => EstExcelMap.columnIndex(x);
c.result = compare(
state.project.tables[TABLES[c.tab][0]] || [],
table,
col(c.code),
col(c.total),
);
}
form(state);
}
const names = new Set(["jdl-compare", "jdl-compare-tab", "jdl-compare-open", "jdl-compare-run"]);
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 { compare, xlsTables, handle };
})();
+3 -1
View File
@@ -337,7 +337,7 @@ const EstModel = (() => {
throw Error("인쇄 설정 범위 오류");
}
if (
(s.paper != null && !["A3", "A4"].includes(s.paper)) ||
(s.paper != null && !["A0", "A1", "A3", "A4", "B4"].includes(s.paper)) ||
(s.orientation != null && !["landscape", "portrait"].includes(s.orientation))
)
throw Error("인쇄 용지/방향 오류");
@@ -442,6 +442,8 @@ const EstModel = (() => {
for (const field of ["locked", "approved", "separateComponents"])
if (round[field] != null && typeof round[field] !== "boolean")
throw Error("기성 옵션 형식 오류");
if (round.wonga != null && !Array.isArray(round.wonga))
throw Error("기성 원가 서식 형식 오류");
if (round.name != null && typeof round.name !== "string")
throw Error("기성 회차 이름 형식 오류");
round.inputs ??= {};
+91 -6
View File
@@ -18,16 +18,25 @@ const EstMultiSearch = (() => {
const rows = saved.hits
.map(
(h, i) =>
`<tr><td><input type="checkbox" name="ms-hit" value="${i}"></td><td>${esc(h.project)}</td><td>${esc(h.label)}</td><td>${esc(h.Code)}</td><td style="text-align:left">${esc(h.Name)}</td><td>${esc(h.Size)}</td><td>${esc(h.Unite)}</td><td style="text-align:right">${EstApp.number(h.price)}</td></tr>`,
`<tr><td><input type="checkbox" name="ms-hit" value="${i}" ${saved.picked?.has(i) ? "checked" : ""}>${[103, 104, 105].includes(h.kind) ? `<button data-action="multi-search-detail" data-i="${i}" title="근거자료보기">근거</button>` : ""}</td><td>${esc(h.project)}</td><td>${esc(h.label)}</td><td>${esc(h.Code)}</td><td style="text-align:left">${esc(h.Name)}</td><td>${esc(h.Size)}</td><td>${esc(h.Unite)}</td><td style="text-align:right">${EstApp.number(h.price)}</td></tr>`,
)
.join("");
EstApp.showDialog(
"통합 검색",
`<p>검색위치: 이 앱에 저장된 다른 작업</p><div class="form-fields">${kinds.map(([k, label]) => `<label><input type="checkbox" name="ms-kind" value="${k}" ${saved.kinds.includes(k) ? "checked" : ""}>${label}</label>`).join("")}<label>검색어<input name="ms-query" value="${esc(saved.query)}"></label></div><button data-action="multi-search-run">검색</button> <button data-action="multi-search-take">고른 자료를 이 공사로 가져오기</button><div style="max-height:360px;overflow:auto;border:1px solid #ccc;margin-top:6px"><table class="sheet" style="font-size:12px"><thead><tr><th></th><th>작업</th><th>구분</th><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>단가</th></tr></thead><tbody>${rows}</tbody></table></div><p>찾은 자료 ${saved.total}건${saved.total > saved.hits.length ? ` 중 앞 ${saved.hits.length}건` : ""}</p>`,
`<p>검색위치: <label><input type="radio" name="ms-where" value="all" ${saved.where !== "main" ? "checked" : ""}>저장된 모든 작업</label> <label><input type="radio" name="ms-where" value="main" ${saved.where === "main" ? "checked" : ""}>설계참고철(Main Data)</label> <button data-action="main-list">설계참고철 관리</button> · 검색어에 「10000~20000」처럼 쓰면 단가 범위</p><div class="form-fields">${kinds.map(([k, label]) => `<label><input type="checkbox" name="ms-kind" value="${k}" ${saved.kinds.includes(k) ? "checked" : ""}>${label}</label>`).join("")}<label>검색어<input name="ms-query" value="${esc(saved.query)}"></label></div><button data-action="multi-search-run">검색</button> <button data-action="multi-search-take">고른 자료를 이 공사로 가져오기</button><div style="max-height:360px;overflow:auto;border:1px solid #ccc;margin-top:6px"><table class="sheet" style="font-size:12px"><thead><tr><th></th><th>작업</th><th>구분</th><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>단가</th></tr></thead><tbody>${rows}</tbody></table></div><p>찾은 자료 ${saved.total}건${saved.total > saved.hits.length ? ` 중 앞 ${saved.hits.length}건` : ""}</p>`,
);
}
async function run(name, state) {
async function run(name, state, node) {
if (name === "multi-search-detail") return detail(state, Number(node.dataset.i));
if (name === "multi-search-pick")
(state.multiSearch.picked ||= new Set()).add(Number(node.dataset.i));
if (name === "multi-search-pick" || name === "multi-search-back") return form(state);
if (name === "main-list") return mainForm(state);
if (name === "main-list-save") {
await mainSave();
return form(state);
}
if (name === "multi-search") form(state);
else if (name === "multi-search-run") {
const saved = state.multiSearch;
@@ -35,9 +44,17 @@ const EstMultiSearch = (() => {
saved.kinds = [...document.querySelectorAll('#dialog-content [name="ms-kind"]:checked')].map(
(n) => Number(n.value),
);
saved.where = document.querySelector('#dialog-content [name="ms-where"]:checked')?.value;
const params = new URLSearchParams({ q: saved.query, kinds: saved.kinds.join(",") });
if (saved.where === "main") {
const ids = await mainList();
if (!ids.length)
throw Error("설계참고철에 등록한 작업이 없습니다. 설계참고철 관리에서 추가하세요.");
params.set("only", ids.join(","));
}
if (state.project.id) params.set("exclude", state.project.id);
Object.assign(saved, await EstApp.api(`/api/search?${params}`));
saved.picked = new Set();
form(state);
} else if (name === "multi-search-take") {
const picked = [...document.querySelectorAll('#dialog-content [name="ms-hit"]:checked')].map(
@@ -65,10 +82,78 @@ const EstMultiSearch = (() => {
EstApp.render();
}
}
const names = new Set(["multi-search", "multi-search-run", "multi-search-take"]);
function handle(name, state) {
// 설계참고철(TfrmMainTableList 「MAIN DATA 추가 및 삭제」): 저장된 작업 중 최대 10개 — 양식 보관함(종류 options, 이름 「설계참고철」)
const MAIN = "설계참고철";
async function mainList() {
return (
(await EstApp.api("/api/forms")).find((f) => f.kind === "options" && f.name === MAIN)?.data
?.ids || []
);
}
async function mainForm(state) {
const ids = await mainList(),
projects = await EstApp.api("/api/projects"),
{ esc } = EstApp;
EstApp.showDialog(
"MAIN DATA(설계참고철) 추가 및 삭제",
`<p>통합 검색 「설계참고철」에서 찾을 작업을 고르세요(최대 10개).</p><div style="max-height:360px;overflow:auto">${projects.map((p) => `<label style="display:block"><input type="checkbox" name="ml-id" value="${esc(p.id)}" ${ids.includes(p.id) ? "checked" : ""}>${esc(p.name || p.id)}</label>`).join("")}</div><button data-action="main-list-save">적용</button> <button data-action="multi-search">통합 검색으로</button>`,
);
}
async function mainSave() {
const ids = [...document.querySelectorAll('#dialog-content [name="ml-id"]:checked')].map(
(n) => n.value,
);
if (ids.length > 10) throw Error("Main Data는 10개 이상 추가할수없습니다.");
await EstApp.api("/api/forms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "options", name: MAIN, data: { ids } }),
});
}
// 근거자료보기(TfrmFoundDetail): 찾은 일위·단산·기계 호표의 세부 줄과 총액·노무·재료·경비, 「적용」 = 그 결과를 고름
const BODY = { 103: "HeadMainDat", 104: "CostMainDat", 105: "MachMainDat" };
async function detail(state, i) {
remember(state);
const hit = state.multiSearch.hits[i],
project = await EstApp.api(`/api/projects/${hit.id}`),
{ esc, number } = EstApp,
rows = project.tables[BODY[hit.kind]] || [],
group = EstNative.groups(rows).get(hit.Code);
if (!group) throw Error("근거 자료를 찾지 못했습니다.");
const head = group.rows[0],
body = group.rows
.slice(1)
.map((r) =>
hit.kind === 104
? `<tr><td colspan="5" style="text-align:left">${esc(EstText.clean(r.LineData || ""))}</td></tr>`
: `<tr><td>${esc(r.Code)}</td><td style="text-align:left">${esc(r.Name)}</td><td>${esc(r.Size)}</td><td>${esc(r.Unite)}</td><td style="text-align:right">${number(r.GN)}</td></tr>`,
)
.join("");
EstApp.showDialog(
`[${hit.Name}]의 근거자료보기`,
`<p>총 액 : ${number(head.TC)} · 노무비 : ${number(head.NC)} · 재료비 : ${number(head.GC)} · 경 비 : ${number(head.JC)}</p><div style="max-height:360px;overflow:auto"><table class="sheet"><tbody>${body}</tbody></table></div><button data-action="multi-search-pick" data-i="${i}">적 용</button> <button data-action="multi-search-back">돌아가기</button>`,
);
}
function remember(state) {
const boxes = document.querySelectorAll('#dialog-content [name="ms-hit"]');
if (boxes.length)
state.multiSearch.picked = new Set(
[...boxes].filter((n) => n.checked).map((n) => Number(n.value)),
);
}
const names = new Set([
"multi-search",
"multi-search-run",
"multi-search-take",
"main-list",
"main-list-save",
"multi-search-detail",
"multi-search-pick",
"multi-search-back",
]);
function handle(name, state, node) {
if (!names.has(name)) return false;
run(name, state).catch((error) => {
run(name, state, node).catch((error) => {
state.notice = error.message;
EstApp.render();
});
+54
View File
@@ -0,0 +1,54 @@
// 원본 「새내역서 파일로저장」(mnuSaveNewEst 0x113384c) — 현재 문서는 그대로 두고 설계변경 흔적을 지운 새 작업을 따로 저장.
// 표 12개(노임·자재·견적·표준·기계기준·기계 본문/호표·단산 본문/호표·일위 본문/호표·내역서)의 「_」로 끝나는 칸(당초값)을 비우고
// 신규 표시(NewItem)를 끄며, 원가 안전관리비 특례 줄(CalcIndex 4)은 1 로, 당초 사본(baseline)은 뺀다. 재계산 없음.
const EstNewEstimate = (() => {
const TABLES = [
"LaborcDat",
"StuffcDat",
"EstiMateDat",
"SilJukDat",
"MachGijunDat",
"MachMainDat",
"MachTitleDat",
"CostMainDat",
"CostTitleDat",
"HeadMainDat",
"HeadTitleDat",
"EstMainDat",
];
function build(project, name) {
const next = structuredClone(project);
delete next.id;
delete next.baseline;
next.name = name;
for (const table of TABLES)
for (const row of next.tables[table] || []) {
for (const key of Object.keys(row)) if (key.endsWith("_")) row[key] = null;
if ("NewItem" in row) row.NewItem = false;
}
for (const row of next.tables.Wonga || [])
if ((Number(row.CalcIndex) & 0xff) === 4) row.CalcIndex = 1;
return next;
}
async function run(state) {
const name = prompt("새 내역서 이름", `${state.project.name || "내역서"} (새 내역서)`)?.trim();
if (!name) return;
const result = await EstApp.api("/api/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(build(state.project, name)),
});
EstApp.download(`${result.name}.estweb.json`, JSON.stringify(result, null, 2));
state.notice = `새 내역서 「${result.name}」를 따로 저장했습니다. 지금 화면은 그대로입니다.`;
EstApp.render();
}
function handle(name, state) {
if (name !== "save-new-estimate") return false;
run(state).catch((error) => {
state.notice = error.message;
EstApp.render();
});
return true;
}
return { build, handle };
})();

Some files were not shown because too many files have changed in this diff Show More