feat(stmate-test): 3차 화면·데이터 마무리 — 보호표시 · 공종번호 · 직종·노임 · 제비율표 편집 · 차수 칸 저장 · 계약 변환등록 = 울진 계약 STC · 장부 87.6%

- 시험 615 통과 · 브라우저 화면 13개·메뉴 90개 오류 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 21:00:14 +09:00
co-authored by Claude Opus 5.5
parent e8b76dd65b
commit ffc2401870
36 changed files with 1907 additions and 78 deletions
+27 -26
View File
@@ -20,6 +20,7 @@
- `test_stmate_smt_lang.py` · `test_stmate_qty_sheet.py` — 식 함수·변수·문법 검사 · 수량산출서·QCODE
- `test_stmate_ui.py` — 메뉴·내역서 편집·도구 창 순수 계산
- `test_stmate_conv_*.py` · `test_stmate_file_pct.py` · `test_stmate_ui_code.py` — 엑셀·XML 가져오기 왕복 · 분리형 변경 STC · % 줄 저장 재계산 · 코드 관리 왕복
- `test_stmate_ui_protect.py` · `test_stmate_ui_round3.py` · `test_stmate_stage_slots.py` · `test_stmate_stage_register.py` · `test_stmate_report_link.py` — 보호표시 · 공종번호·직종·제비율표 · 차수 칸 저장 · 계약 변환등록 = 실제 울진 계약 STC · 목차·인쇄 옵션
- `test_stmate_file_*.py` — 실무 STC 7건 무손실 왕복(손대지 않은 저장 = 원본 바이트) · 고친 칸만 바뀜 · 새 공사·복사등록·해당분 압축
- `test_stmate_stage_*.py` — 설계변경(영덕·소광 변경 BOQ·엑셀 대조) · 계약 변환(울진 BOQ12 계약내역·총공사비 1원) · 기성·실행(앞뒤 맞춤만)
- `test_stmate_ilwi_eval.py` — 일위대가·중기 구성 재계산(원설계 5건 + 영덕 변경) 1원 · 연쇄 재계산(기초단가 → 원가계산서) 원설계 5건 전 단가·총공사비 그대로
@@ -28,15 +29,15 @@
## 진행률
- 합계: 전체 246 · 구현 173 · 부분 68 · 없음 5 → 진행률 84.1%((173 + 68×0.5) / 246) · 구현만 세면 70.3%
- 합계: 전체 246 · 구현 190 · 부분 51 · 없음 5 → 진행률 87.6%((190 + 51×0.5) / 246) · 구현만 세면 77.2%
### G1 화면·메뉴 (구현 37 · 부분 16 · 없음 1) — 83.3%
### G1 화면·메뉴 (구현 47 · 부분 6 · 없음 1) — 92.6%
- [x] F1-01 메인 메뉴·툴바·하단 금액 집계줄 — STmate.Cpm 메인 · 현재: 구현(원본 메뉴 구조·툴바 차례·단축키 12·체크 항목·최근 폴더(test_stmate_ui))
- [x] F1-02 공사목록표 목록·선택·불러오기 — wPrjList · 현재: 구현(공사목록표 · 다른 내역서 열기(저장 직접비 1원 일치))
- [ ] F1-03 프로젝트 신규 등록·삭제 — wM_Add_Prj · ST_U_DelPrj · 현재: 부분(stc_project 새 공사(빈 상태) · 삭제 화면 없음)
- [x] F1-03 프로젝트 신규 등록·삭제 — wM_Add_Prj · ST_U_DelPrj · 현재: 구현(내역서 삭제 = 고른 공사 행·내역 표만 · 마지막 공사 거부(test_delete_project_drops_row_and_table_only))
- [x] F1-04 프로젝트 등록정보 수정 — wPrjList · 현재: 구현(등록정보 수정)
- [ ] F1-05 복사등록(차수·단계 변환) — wPrjList · ST_PList · 현재: 부분(stc_project 복사등록 차수 인계(K_ORD·직공비·원가 설정) 실물 대조 · 계약 변환 실물 대조 없음)
- [x] F1-05 복사등록(차수·단계 변환) — wPrjList · ST_PList · 현재: 구현(REA K_ORD 전이표 7갈래 · 당초/변경 칸 옮김(test_stage_register_k_ord_matrix))
- [x] F1-06 프로젝트 DATA 검색(노무~내역서 8종) — wSrch_PL · 현재: 구현(등록 프로젝트 전수 검색·찾아가기·가져오기)
- [x] F1-07 공종목록표 계층 내역 그리드(T/E/C 짝 집계) — wBoq · 현재: 구현(view_prj_boq 37~145)
- [x] F1-08 내역 칸 편집 — wBoq · 현재: 구현(내역서 칸 편집·행 넣기·지우기·이동·복사·붙여넣기 · 직공비 증감 정확(test_stmate_ui))
@@ -48,8 +49,8 @@
- [x] F1-14 호표 코드 → 대가표·단가표 이동 — wBoq · 현재: 구현(코드 → 단가목록표·대가표 이동)
- [x] F1-15 공종 목록(코드·명칭·분류 정렬, 대공종 필터) — wKongJong · 현재: 구현(공종목록 코드 넣기 · 대공종 옵션)
- [x] F1-16 주공종 집계표(레벨별 계표시 확인) — wMBoq · 현재: 구현(레벨 거르기·계표시·레벨 올리기/내리기·주공종 집계)
- [ ] F1-17 공종번호 자동입력(머리·끝 글자·번호형식·자리수) — wITNum · 현재: 부분(공종번호 자동입력 · 원본 번호 빈 자리 앞까지 재현(봉화·영월))
- [ ] F1-18 내역 할증율 적용(전체/비목별) — wBoqRate · 현재: 부분(내역 할증율 계산 반영(calcBoq) · 근거 등급 C)
- [x] F1-17 공종번호 자동입력(머리·끝 글자·번호형식·자리수) — wITNum · 현재: 구현(하위 전체공종 반영 · 실무 7건 규칙 번호 255/304 일치 · 나머지는 손 입력(test_itnum_tree_reproduces_every_rule_number))
- [ ] F1-18 내역 할증율 적용(전체/비목별) — wBoqRate · 현재: 부분(원본 TwBoqRate 판독(0%=100% · 기준단가×율 · 보호 견적단가 제외) · 실물 표본 0)
- [x] F1-19 비목별 일괄 보정(절사/반올림 × 소수 0~4 · 범위) — wM_Rate · 현재: 구현(비목별 단가 끝전 · 줄 고정 단가(server fixedUnit)로 다시 불러도 유지)
- [x] F1-20 일위대가표 풀기 — wExpnd · 현재: 구현(일위대가 풀기 = 구성표 × 내역 수량)
- [x] F1-21 풀어넣기 비목별 끝전·가산비목 지정 — wM_Expnd · wM_Expnd10 · 현재: 구현(풀어넣기 비목별 끝전·할증·손료 비율 줄 · 손료 줄 금액 7건 대조)
@@ -57,16 +58,16 @@
- [x] F1-23 내역보관함 (1)(2)(3) — wBoq · 현재: 구현(보관함 (1)(2)(3))
- [x] F1-24 단가목록표 코드종류 필터(10종) — wM_JabPum cBoxCode · 현재: 구현(코드 종류 10종 · 그림코드 = I 코드(원본 화면 탭))
- [x] F1-25 단가 찾기(명칭·규격 / 산근내용) — wM_JabPum cBox_IFORM · 현재: 구현(단가 찾기·사용처 찾기)
- [ ] F1-26 기초단가 직접 수정(재·노·경) — wM_JabPum · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨)
- [x] F1-26 기초단가 직접 수정(재·노·경) — wM_JabPum · 현재: 구현(보호(UC=T · M·L·S·W 행) 제외 · 보호 열(test_stmate_ui_protect) · 실물 표본 0)
- [x] F1-27 코드 신규 등록·삭제 — wM_JabPum · ST_U_DelCode · 현재: 구현(ui_code 신규등록(복사)·삭제 · 저장·다시 불러 참조 개수·직공비 그대로 7건(test_stmate_ui_code))
- [x] F1-28 코드 변경·치환(일괄·선택) — ST_U_ChgCode · 현재: 구현(코드변경 선택·일괄·+α · 항목교체 · 7건 왕복)
- [ ] F1-29 기준단가 비율 일괄수정(보호표시·0원 제외) — wM_Edt_Hal · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨)
- [x] F1-29 기준단가 비율 일괄수정(보호표시·0원 제외) — wM_Edt_Hal · 현재: 구현(비율 일괄수정 보호·0원 제외 · 보호해제 자동할증(test_stmate_ui_protect))
- [x] F1-30 할증 금액·율 두 갈래 편집 — ST_Jabpum Edt_Hal_Amt/Rate · 현재: 구현(할증 율·금액(기준단가 기준 · 두 번 해도 같은 값))
- [ ] F1-31 가격지 후보 비교(1~5단가·최소단가) — wM_Boxa RG_MComp · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨)
- [x] F1-31 가격지 후보 비교(1~5단가·최소단가) — wM_Boxa RG_MComp · 현재: 구현(가격지 후보 비교·채택 보호 행 제외(test_stmate_ui_protect))
- [x] F1-32 자재·공종 분류 필터, 불러간 목록 — wM_Boxa rg_FLT · wM_Box_H · 현재: 구현(속성·명칭·불러간 목록 필터)
- [x] F1-33 대가표 목록·선택(B/D/X) — wM_iLWi_San · 현재: 구현(view_dan_ilwi 236~)
- [x] F1-34 일위대가 구성행 표 편집·노임계수·저장비목 선택 — wM_Edit_iLWi · 현재: 구현(구성행 편집 · 저장 = BDQTY 바뀐 호표만 암호로 다시 씀(stc_save_extra))
- [ ] F1-35 단가산출근거 편집 + 계산(F9) — wM_Edit_San · 현재: 부분(글 편집·즉시 계산, 함수·문법검사 없음)
- [x] F1-35 단가산출근거 편집 + 계산(F9) — wM_Edit_San · 현재: 구현(산근 편집 + F9 · 함수·문법 검사(test_stmate_smt_lang))
- [ ] F1-36 손료 계산(F6)·시간당 손료계수 수정 — wM_Edit_San · wM_Edit_XRate · 현재: 부분(손료계수(수량 = 계 × 10⁻⁴ 정수 확인) · calc_xrate · 반올림 방향 미확정)
- [x] F1-37 사용자 단가산출식 편집 — wM_Edit_USER · 현재: 구현(사용자문자 편집 목록)
- [ ] F1-38 실적단가 → 일식단가 변환등록 — wM_Conv_A2W · 현재: 부분(calc_a2w 실적→일식 · 7건 실적단가 자료 없음)
@@ -77,17 +78,17 @@
- [x] F1-43 단가 일괄변경·물가변동 — ST_U_ChgAmt · 현재: 구현(ui_code_amt 일괄변경·물가변동 · 당초⇔변경 단가 옮기기)
- [x] F1-44 초기값 입력 및 수정 — wM_Set_Sys · 현재: 구현(초기값 화면 전체 항목 · 저장 = Start8x.Ini 바뀐 키)
- [x] F1-45 프로젝트 계산 초기값 — wM_Set_Init · 현재: 구현(프로젝트 계산 초기값 5칸 × 8종)
- [ ] F1-46 직종·노임 설정 — wM_Set_JLab · 현재: 부분(조건 직종 코드 입력만)
- [ ] F1-47 제잡비 계산 화면(4형식 탭) — wM_KanJub · 현재: 부분(차수·방식·공사종류·기간·고용등급·퇴직 6칸 처리 없음)
- [ ] F1-48 제비율표 수정·취소·저장·출력 — wM_KAN_RATE · 현재: 부분(보기만, 고칠 칸 없음 view_kanjub_prt 152)
- [ ] F1-49 요율표 불러오기·선택 — wM_KAN_RATE · 현재: 부분(RATE_R 탭만 바뀜, 원가 요율 미연결 app.js 510)
- [x] F1-46 직종·노임 설정 — wM_Set_JLab · 현재: 구현(직종·노임 적용코드 창 · 저장 시 Start8x.Ini 해당 키만(test_jlab_codes_hourly_and_save))
- [ ] F1-47 제잡비 계산 화면(4형식 탭) — wM_KanJub · 현재: 부분(요율 축 8개 바꾸면 재조회·재계산(test_rate_axis_combos_recalculate) · 계약형식·낙찰방식·건물종류 계산 근거 없음)
- [x] F1-48 제비율표 수정·취소·저장·출력 — wM_KAN_RATE · 현재: 구현(제비율표 수정·취소·저장·출력(test_rate_table_edit_saves_only_changed_cell))
- [x] F1-49 요율표 불러오기·선택 — wM_KAN_RATE · 현재: 구현(요율표 선택·기준 요율표 열기·콤보 바꾸면 자동 재조회)
- [x] F1-50 잡비 금액 불러오기(타 프로젝트 금액을 밑수로) — wM_GetAMT · 현재: 구현(잡비 금액 불러오기 → 제잡비 직접입력(C_ 칸))
- [x] F1-51 인쇄 메뉴 옵션(글꼴·코드필드·수량위치·양식·방향) — PM_Prt · 현재: 구현(인쇄 옵션 바꾸면 결과물 바뀜(test_stmate_report_link))
- [x] F1-52 보고서 인쇄순서·인쇄 공종 집계수준 선택 — PM_Prn_Ord · PM_Sel_St · 현재: 구현(인쇄순서로 원본 3건 시트 차례 재현)
- [x] F1-53 인쇄영역 선택(당초/변경/분할·레벨·변경 1~8차) — wM_Sel_Prn · 현재: 구현(인쇄영역 당초/변경/분할·레벨·차수 반영)
- [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 부분(특수문자·메모 · 그림 넣기 없음)
- [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 부분(특수문자·메모·그림코드 넣기 · 그림 저장 자리 근거 없음)
### G2 계산 엔진 (구현 51 · 부분 9 · 없음 1) — 91.0%
### G2 계산 엔진 (구현 53 · 부분 7 · 없음 1) — 92.6%
- [x] F2-01 노임 일→시간 환산식 좌→우 순차 — 18번 §2.1 · Start8x RXXNM · 현재: 구현(calc.js 84)
- [ ] F2-02 노임 환산 갈래 H0~H5(터널·준설선) — wM_Set_Sys cmBx_Sel_RX · 30번 §4 · 현재: 부분(노임 환산 갈래 연결 · 실무 표본 기본 갈래만)
@@ -103,8 +104,8 @@
- [x] F2-12 일위대가 B코드 구성 조립·성분 절사 — 17번 §2·§5 · 현재: 구현(BDQTY 구성 · 행 소수1 버림 · CHBNM 총액우선 = 행 합계액 기준 · 7건 대조)
- [x] F2-13 호표 참조 그래프·위상순서·순환 오류 — 19번 §6.5 · 09번 §3 · 현재: 구현(X·D·B 참조 순서 · 순환 경로 알림(실무 7건 순환 0))
- [x] F2-14 공구손료·잡재료 % 가산 — 07번 §4 · 현재: 구현(PR 줄 = PRPT 표시 행 밑수 PRIN → PROUT · % 단위 자원 = 누계 · C* 뒤 % = 전체 비율)
- [ ] F2-15 수량 할증과 금액 가산 분리 — 07번 §4 · 현재: 부분(calc.js 215 일위 할증만, 박힌 기본 상태 전용)
- [ ] F2-16 TAMT=M+L+S·코드종류별 성분 검사 — 10번 §3 · 현재: 부분(합산만, L/M/S 종류별 검사 없음)
- [x] F2-15 수량 할증과 금액 가산 분리 — 07번 §4 · 현재: 구현(금액 가산(% 줄)은 별도 행 계산 · X_QTY·X_HAL = 손료계수 칸(REA 정정) · 실무 7건 1원)
- [x] F2-16 TAMT=M+L+S·코드종류별 성분 검사 — 10번 §3 · 현재: 구현(성분 검사 · 실무 7건 어긋남 0 · 일부러 어긋낸 칸만 잡힘(test_component_check_clean_on_real_and_catches_broken))
- [x] F2-17 내역 행 ROUNDDOWN(수량×단가) — 16번 · 06번 §3 · 현재: 구현(calc.js 249)
- [x] F2-18 비목우선/총액우선 차액 보정(OPTNM) — REA 15단계 · 현재: 구현(CHNNM 비목우선 = 실무 7건 차수별 내역 줄 금액 1원 · 총액우선 = OPTIMIZ 끝전 배분(손셈 대조) · 비율 줄(test_stmate_kanjub_boq))
- [x] F2-19 계층 합산(SUM_TOT·T/E 짝) — 16번 §4.1 · 현재: 구현(같은 코드 여러 줄이면 짝 오류 위험 view_prj_boq 66)
@@ -151,7 +152,7 @@
- [x] F2-60 연쇄 재계산(L/M/S→X→D→B→내역→원가) — 08번 §1 · 현재: 구현(기초단가부터 원설계 5건 전 단가·총공사비 1원 · 변경설계 일부 미대조)
- [ ] F2-61 표준시장단가 적용(ACTTYP·ACTOPT 노무비 끝전 보정) — 22번 §3.3 · 현재: 없음
### G3 수량산출·식언어 (구현 23 · 부분 5 · 없음 1) — 87.9%
### G3 수량산출·식언어 (구현 25 · 부분 3 · 없음 1) — 91.4%
- [x] F3-01 COSTN.SMT 메모 블록 읽기(IFORM 위치) — 21번 §1 · 23번 · 현재: 구현(smt_parser 150)
- [x] F3-02 헤더 변수 정의(;; 줄)·변수 대입 — 23번 · 현재: 구현(smt_engine 112)
@@ -168,9 +169,9 @@
- [ ] F3-13 강제금액(직접 지정 금액) — 28번 §4 · 현재: 없음(옛 '재료비: 숫자' 짐작 처리 걷음 · 원본 문법 근거 대기)
- [x] F3-14 수량기호 Q·A·T·N — wM_Edit_San · 현재: 구현(Q·A·T·N 줄 · 결과적용 비목 4택·0원 비목(test_code_insert_lines_zero_skip_and_result_strip))
- [x] F3-15 식 문법 검사(줄 단위 오류 메시지) — 28번 §4 · 현재: 구현(줄 단위 문법 오류 · 실무 산근 오류 0(test_section_markers_exclusion_and_grammar_errors))
- [ ] F3-16 문법 강조·문법설명·함수/연산 입력 단추 — 26번 §3 · 현재: 부분(문법 강조·설명·함수 단추 화면)
- [x] F3-16 문법 강조·문법설명·함수/연산 입력 단추 — 26번 §3 · 현재: 구현(함수 단추·강조 낱말 = 원본 화면 정의(test_editor_function_list_and_highlight_follow_dfm))
- [x] F3-17 결과적용 비목 4택·0원 비목 입력안함 — 26번 §3 · 현재: 구현(Q·A·T·N 줄 · 결과적용 비목 4택·0원 비목(test_code_insert_lines_zero_skip_and_result_strip))
- [ ] F3-18 결과값 낙찰율 적용·결과금액조정·계약단가 — 26번 §3 · 현재: 부분(낙찰률 단가 = 원단가×율 버림 · 7건 127개 대조 · 화면 입력 없음)
- [x] F3-18 결과값 낙찰율 적용·결과금액조정·계약단가 — 26번 §3 · 현재: 구현(산근 편집기 낙찰율·결과값 적용·계약단가 · 울진 34개 코드 1·2칸 일치(test_editor_bid_rate_input_matches_contract_codes))
- [x] F3-19 Q식(3600qKfE/Cm 등 10꼴) 평가 — 23번 §3 · 현재: 구현(식 그대로 평가 · 변수 대소문자 무시 · 282개 대조)
- [x] F3-20 Cm 사이클식(t1+…·L/V1+L/V2+0.25·선회각 초) — 26번 §1 · 현재: 구현(사칙 대입 · 따옴표 주석 걷음 · 282개 대조)
- [x] F3-21 표준중기공식 등록·수정·삭제 — wM_Edit_EQ · 28번 §1.1 · 현재: 구현(표준중기공식 등록·수정·삭제(test_qcode_read_write_round_trip))
@@ -255,9 +256,9 @@
- [x] F5-35 금액 한글 표기(AMTTOHAN) — 24번 §2.4 · 현재: 구현(금액 한글 표기 · 원본 한글 금액 11건 전부 일치)
- [x] F5-36 SET_REP 데이터 기반 열·폭·제목 — 20번 §3 · 현재: 구현(SET_REP 서식 제목·시트 이름 적용 · 보고서 시험 포함)
### G6 설계변경·계약·실행·기성 (구현 14 · 부분 22 · 없음 0) — 69.4%
### G6 설계변경·계약·실행·기성 (구현 17 · 부분 19 · 없음 0) — 73.6%
- [ ] F6-01 차수 4벌(원설계+3차변경) 칸 관리 — 08번 §2 · 24번 §7 · 현재: 부분(계산 = 차수별 수량·단가·원가계산서 · 저장은 2·3 칸 덮어씀 view_prj_boq 560)
- [x] F6-01 차수 4벌(원설계+3차변경) 칸 관리 — 08번 §2 · 24번 §7 · 현재: 구현(2·3·4차수 칸 고침이 저장 후 유지(test_slot_2_3_4_edits_persist))
- [ ] F6-02 통합형 BOQ11+BOQ12에서 BOQ12 현재안 — 16번 §4.5 · 현재: 부분(울진 BOQ12 = 계약내역(STMATE 착공내역서) 판명 · contract_calc 로 BOQ11 에서 1원 재현)
- [x] F6-03 분리형 변경 STC 생성·차수 인계 — 16번 §4.6 · 현재: 구현(stc_change 소광 원설계 → 실제 변경1차 차수1 말단 47행·직접비·요율 41키 일치(test_stmate_conv_change))
- [x] F6-04 변경 수량 입력 — 설계변경 · 현재: 구현(change_calc · 영덕·소광 변경 BOQ11 1·2차수 수량·단가·금액 일치)
@@ -274,12 +275,12 @@
- [x] F6-15 설계변경증감대비표(0648)·금액대비표 — CHANGE_AMT · 현재: 구현(증감대비표·금액대비표 · 영덕 엑셀 행 차례·금액 일치 · 인쇄·엑셀)
- [ ] F6-16 변경총괄·굴착·시도 변경내역서(0661~0666) — CHANGE_* · 현재: 부분(변경총괄 = 영덕 엑셀 총괄내역서 일치 · 굴착·시도 양식 없음)
- [x] F6-17 계약 변환 비목별 단가적용율(노·재·경) — wM_Mk_Cont · 현재: 구현(contract_calc 노·재·경 적용율 버림 · 울진 BOQ12 계약내역 1원)
- [ ] F6-18 계약 변환 옵션(기초단가·일위/산근 생성·동일코드·노무비율·비과세) — 27번 §2.2 · 현재: 부분(옵션 6개 중 동일코드 개별생성·0% 공내역 반영)
- [ ] F6-18 계약 변환 옵션(기초단가·일위/산근 생성·동일코드·노무비율·비과세) — 27번 §2.2 · 현재: 부분(옵션 6개 동작 · 켬 갈래 실물 일치 · 끔 갈래·비과세 표본 없음)
- [x] F6-19 적용율 0% → W코드 공내역 — 27번 §2.2 · 현재: 구현(0% 공내역 W코드 · 울진 대조)
- [ ] F6-20 적용제외 공정 선택(레벨 9단계) — 27번 §2.3 · 현재: 부분(적용제외 공정 선택 · 머리 단위 실물 대조 없음)
- [x] F6-21 예상적용율 찾기(목표금액 역산)·미리계산 — 27번 §2.3 · 현재: 구현(목표금액 → 적용율 87.745% 역산 · 미리계산)
- [ ] F6-22 설계 → 계약자료 변환등록(당초/변경·레벨) — wM_Conv_Mode · 현재: 부분(변환등록 단추 = /api/file/copy-register)
- [ ] F6-23 낙찰율 열(NAGCD)·계약단가 저장 — 26번 §4 · 현재: 부분(산근 재계산 시 2칸 원단가 → 1칸 낙찰 단가 · 저장은 MAMT3 87.745% 일괄 덮어씀)
- [x] F6-22 설계 → 계약자료 변환등록(당초/변경·레벨) — wM_Conv_Mode · 현재: 구현(소광 원설계 → 변환등록·저장 = 실제 울진 계약 STC 일치(test_stmate_stage_register))
- [x] F6-23 낙찰율 열(NAGCD)·계약단가 저장 — 26번 §4 · 현재: 구현(새 코드 낙찰율·단가·산근 글·구성표 저장 = 울진 계약 STC 일치(test_stmate_stage_register))
- [x] F6-24 계약 원가계산(계약 제잡비) — 27번 · 현재: 구현(계약 원가계산 · 울진 총공사비 333,315,000원 일치)
- [ ] F6-25 계약·실행내역서(0623·0624) — PM_Nae_Cont · 현재: 부분(계약·실행내역서 화면 · 실물 표본 없음)
- [ ] F6-26 실행예산 작업(실행단가·실행수량) — wBoq 실행예산작업 · 현재: 부분(exec_calc 실행단가·수량 · 표본 없음)
+23 -1
View File
@@ -17,6 +17,7 @@
from __future__ import annotations
import pathlib
import re
from dbf_rw import DbfTable
from fastapi import APIRouter, HTTPException
@@ -133,12 +134,33 @@ def _boq_codes(files: dict, req: dict, log: list) -> None:
files[name] = bt.to_bytes()
def _rates(files: dict, req: dict, log: list) -> None:
"""제비율표 수정(wM_KAN_RATE) — rate_tables{표 이름: 행[_recno · RATE_n 칸]} 중 바뀐 칸만."""
for name, rows in (req.get("rate_tables") or {}).items():
name = str(name).upper()
if not re.fullmatch(r"RATE[_A-Z0-9]*\.DBF", name) or name not in files:
continue
tbl = DbfTable(files[name])
for r in rows or []:
i = int(r.get("_recno", -1)) if str(r.get("_recno", "")).isdigit() else -1
if not 0 <= i < len(tbl):
continue
for k, v in r.items():
if k.startswith("_") or k.endswith("_HEX") or tbl.fields.get(k) is None:
continue
if tbl.fields[k].type in ("N", "F"):
_put(tbl, i, k, v, log, name)
if tbl.dirty:
files[name] = tbl.to_bytes()
def apply_struct(files: dict[str, bytes], req: dict) -> tuple[dict[str, bytes], list]:
"""구조 손질만 — 새 files 와 바뀐 목록(코드 칸·끼움·지움)."""
"""구조 손질만 — 새 files 와 바뀐 목록(코드 칸·끼움·지움 · 제비율표 칸)."""
out = dict(files)
log: list = []
_costn(out, req, log)
_boq_codes(out, req, log)
_rates(out, req, log)
return out, log
+80
View File
@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
"""설계 → 계약자료 변환등록(wM_Conv_Mode · wM_Mk_Cont) 끼워 넣기 API.
근거: 27_계약_실행_기성_단계.md §2(계약 변환 옵션 · 적용제외) · 26 §4(NAGCD 낙찰율 열 · 계약단가) ·
16 §4.5(통합형 BOQ11 설계 + BOQ12 계약) · ui_form_catalog wM_Conv_Mode(당초/변경 수량·단가 · 레벨)
실증(울진 2025): 소광 원설계 STC → 계약 STC = PRJLST 새 행(PCODE 다음 번호 · CCODE = 설계 PCODE · PCLASS 4)
+ BOQ1m = 설계 내역 복사 위 계약 공종(새 코드 · 비목 단가 버림) + COSTN 새 코드 행(산근 글·구성표 그대로)
흐름: 화면(contract_calc.registerRows)이 계약 행을 만들고 서버는
stc_project.copy_register(그대로 복사) → stc_save.apply_save(새 표 행 · 새 COSTN · 산근 · 구성표 · PRJLST) 만 함.
"""
from __future__ import annotations
import pathlib
from bdqty import decode_bdqty
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from stc_project import copy_register, write_output
from stc_read import Source
from stc_save import apply_save
router = APIRouter()
CONTRACT_PCLASS = 4 # 계약 공사(울진 2025 계약 행 · REA K_ORD 표 PCLASS 4)
def _root() -> pathlib.Path:
import server # 실행 중 바뀔 수 있는 작업 폴더(시험은 임시 폴더로 바꿈)
return server.WORK_DBF_ROOT
class ContractRegReq(BaseModel):
source_path: str
src_pcode: str = "11"
pname: str = ""
pnum: str = ""
boq_rows: list[dict] = [] # 계약 공종 행 — {_recno, BDCODE1, 단가·금액 칸}
costn_rows: list[dict] = [] # 새 코드 COSTN 행
costn_memos: dict[str, str] = {} # 새 산근 코드 → 산출근거 글
bdqty_rows: list[dict] = [] # 새 대가 코드 구성행(decode_bdqty 꼴)
costw1: float | None = None # 계약 총공사비(화면 계약 원가계산서)
target_folder_name: str = ""
def contract_register(files: dict, req: ContractRegReq) -> tuple[dict, str, list]:
"""(새 files, 새 PCODE, 바뀐 칸 목록) — 쓰기 없음(시험용)."""
out, pcode, log0 = copy_register(files, req.src_pcode, "copy", req.pname)
prj_upd = {"CCODE": req.src_pcode, "PCLASS": CONTRACT_PCLASS}
if req.pnum:
prj_upd["PNUM"] = req.pnum
if req.costw1 is not None:
prj_upd["COSTW1"] = req.costw1
save = {
"boq_table": f"BOQ{pcode}.DBF",
"boq_updates": [dict(r) for r in req.boq_rows],
"costn_updates": [dict(r) for r in req.costn_rows],
"costn_memo_updates": dict(req.costn_memos),
"prjlst_updates": prj_upd,
}
if req.bdqty_rows and "BDQTY.DBF" in out:
save["bdqty_rows"] = decode_bdqty(out["BDQTY.DBF"]) + [dict(r) for r in req.bdqty_rows]
out2, log = apply_save(out, save)
return out2, pcode, log0 + log
@router.post("/api/ui/contract-register")
def api_contract_register(req: ContractRegReq):
try:
s = Source(req.source_path)
except (FileNotFoundError, ValueError) as e:
raise HTTPException(400, str(e))
try:
files, pcode, changes = contract_register(s.files, req)
except (ValueError, KeyError) as e:
raise HTTPException(400, str(e))
target = req.target_folder_name or f"{s.path.stem}_계약{pcode}"
names = dict(s.names, **{f"BOQ{pcode}.DBF": f"BOQ{pcode}.DBF"})
res = write_output(_root(), target, files, names, s.entries)
return {**res, "new_pcode": pcode, "change_count": len(changes)}
+158
View File
@@ -0,0 +1,158 @@
# -*- coding: utf-8 -*-
"""공사(내역서) 삭제·단계 등록(차수) 끼워 넣기 API — 공사목록표(wPrjList) 「내역서 삭제」(Act_Del_PrjExecute · ST_U_DelPrj Delete_Prj).
근거: REA 09 resource-TWPRJLIST 메뉴 「내역서 삭제」 · old/30_원자료 module_exports(ST_U_DelPrj: Delete_Prj)
· 02번(PRJLST 한 행 = 공사 · PCODE '1n' ↔ BOQ1n.DBF · PRJ_MEMO = 그 공사 원가 설정)
규칙: 고른 PCODE 의 PRJLST 행과 BOQ1n.DBF 를 뺌 · 다른 공사 행·표·메모는 바이트 그대로 · 마지막 한 공사는 지우지 않음
(원본 확인 문구·선택지는 근거 없음 — 화면은 확인 창 한 번)
단계 등록(NewProject → MkBoq · REA 31 Main_K_ORD_registration_transition_matrix · 최종보고서 §4.1):
원본 PCLASS × 등록(6 기성 · 7 설계변경) × 플래그(0 차수별 · 1 당초/변경) → 새 K_ORD(1 또는 K+1) · 상한 없음
새 PCLASS = 등록 번호(4 계약 · 6 기성 · 7 설계변경 갈래와 같은 수 — 추정)
K+1 = 다음 차수 칸 열기(stc_project change) · 1 = 원본 마지막 차수 칸을 1·2 칸(당초·변경)으로 옮겨 새로 시작
"""
from __future__ import annotations
import pathlib
from dbf_rw import DbfTable
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from stc_project import SLOT_COL, copy_register, safe_name, write_output
from stc_read import Source
from stc_save import SaveLog, recompute_boq
router = APIRouter()
def _root() -> pathlib.Path:
import server # 실행 중 바뀔 수 있는 작업 폴더(시험은 임시 폴더로 바꿈)
return server.WORK_DBF_ROOT
def delete_project(files: dict, pcode: str) -> tuple[dict, list]:
"""(새 files, 바뀐 목록) — PRJLST 행 · BOQ{pcode}.DBF 뺌."""
pcode = str(pcode).strip()
if "PRJLST.DBF" not in files:
raise ValueError("PRJLST.DBF 없음")
prj = DbfTable(files["PRJLST.DBF"])
idx = [i for i in range(len(prj)) if prj.get(i, "PCODE") == pcode]
if not idx:
raise ValueError(f"PRJLST 에 PCODE {pcode} 공사 없음")
if len(prj) - len(idx) < 1:
raise ValueError("마지막 공사는 지울 수 없음")
log = [
{"table": "PRJLST.DBF", "recno": i, "field": "_delete", "old": pcode, "new": ""}
for i in idx
]
prj.recs = [r for i, r in enumerate(prj.recs) if i not in set(idx)]
prj.dirty = True
out = dict(files)
out["PRJLST.DBF"] = prj.to_bytes()
tbl = f"BOQ{pcode}.DBF"
if out.pop(tbl, None) is not None:
log.append({"table": tbl, "recno": -1, "field": "_drop", "old": tbl, "new": ""})
return out, log
class DelPrjReq(BaseModel):
source_path: str
pcode: str
target_folder_name: str = ""
@router.post("/api/ui/project-delete")
def api_project_delete(req: DelPrjReq):
try:
s = Source(req.source_path)
files, log = delete_project(s.files, req.pcode)
except (FileNotFoundError, ValueError) as e:
raise HTTPException(400, str(e))
target = req.target_folder_name or s.path.stem
names = {k: v for k, v in s.names.items() if k in files}
res = write_output(_root(), safe_name(target), files, names, s.entries)
tbl = f"BOQ{str(req.pcode).strip()}.DBF"
left = pathlib.Path(res["saved_dir"]) / s.names.get(tbl, tbl) # 같은 작업본 폴더에 남은 옛 표
if tbl not in files and left.exists():
left.unlink()
return {**res, "deleted": req.pcode, "changes": log}
# 원본 PCLASS × 등록 × 플래그 → 새 K_ORD("1" | "K+1") — 표에 없는 원본(설계 0 등)은 다음 차수(K+1)
K_ORD_RULE = {
(4, 6, None): "1",
(6, 6, None): "K+1",
(7, 6, None): "1",
(4, 7, None): "1",
(6, 7, None): "1",
(7, 7, 0): "K+1",
(7, 7, 1): "1",
}
def new_k_ord(pclass: int, op: int, flag: int, k: int) -> int:
rule = K_ORD_RULE.get((pclass, op, flag)) or K_ORD_RULE.get((pclass, op, None)) or "K+1"
return k + 1 if rule == "K+1" else 1
def _move_slot(tbl: DbfTable, src: str, dst: tuple[str, ...]) -> None:
pairs = []
for c in tbl.order:
m = SLOT_COL.match(c)
if m and m.group(2) == src:
pairs += [(c, f"{m.group(1)}{d}") for d in dst if f"{m.group(1)}{d}" in tbl.fields]
for i in range(len(tbl)):
for a, b in pairs:
tbl.set_raw(i, b, tbl.raw(i, a))
def stage_register(files: dict, src_pcode: str, op: int, flag: int, pname: str = ""):
"""(새 files, 새 PCODE, 새 K_ORD) — 단계 등록."""
prj = DbfTable(files["PRJLST.DBF"])
si = next(i for i in range(len(prj)) if prj.get(i, "PCODE") == str(src_pcode))
num = lambda f: int(float(prj.get(si, f) or 0)) if f in prj.fields else 0 # noqa: E731
k, pclass = num("K_ORD"), num("PCLASS")
nk = new_k_ord(pclass, int(op), int(flag), k)
out, pcode, _log = copy_register(
files, str(src_pcode), "change" if nk == k + 1 else "copy", pname
)
prj2 = DbfTable(out["PRJLST.DBF"])
ni = next(i for i in range(len(prj2)) if prj2.get(i, "PCODE") == pcode)
prj2.set(ni, "K_ORD", nk)
if "PCLASS" in prj2.fields:
prj2.set(ni, "PCLASS", int(op))
if nk == 1 and k + 1 != 1: # 원본 마지막 차수(k+1) 칸 → 새 공사 당초·변경(1·2) 칸
name = f"BOQ{pcode}.DBF"
bt = DbfTable(out[name])
_move_slot(bt, str(k + 1), ("1", "2"))
direct = recompute_boq(bt, name, {"1", "2"}, SaveLog())
for s, vals in direct.items():
for c, v in zip(("MCOST", "LCOST", "SCOST", "TCOST"), vals):
if f"{c}{s}" in prj2.fields:
prj2.set(ni, f"{c}{s}", v)
out[name] = bt.to_bytes()
out["PRJLST.DBF"] = prj2.to_bytes()
return out, pcode, nk
class StageRegReq(BaseModel):
source_path: str
src_pcode: str = "11"
op: int = 7 # 6 기성 · 7 설계변경
flag: int = 0 # 0 차수별 · 1 당초/변경
pname: str = ""
target_folder_name: str = ""
@router.post("/api/ui/stage-register")
def api_stage_register(req: StageRegReq):
try:
s = Source(req.source_path)
files, pcode, nk = stage_register(s.files, req.src_pcode, req.op, req.flag, req.pname)
except (FileNotFoundError, ValueError, StopIteration) as e:
raise HTTPException(400, str(e) or "공사 없음")
target = req.target_folder_name or f"{s.path.stem}_등록{pcode}"
names = dict(s.names, **{f"BOQ{pcode}.DBF": f"BOQ{pcode}.DBF"})
res = write_output(_root(), safe_name(target), files, names, s.entries)
return {**res, "new_pcode": pcode, "k_ord": nk}
+9 -1
View File
@@ -175,7 +175,12 @@
function postSave(p) {
const UC = global.STMATE_UI_CODE;
if (UC && UC.needsUiSave(ctx.dbfData))
return postJson("/api/ui/save", Object.assign(p, UC.structPayload(ctx.dbfData)));
return postJson(
"/api/ui/save",
Object.assign(p, UC.structPayload(ctx.dbfData), {
rate_tables: ctx.dbfData.rate_dirty ? ctx.dbfData.rate_tables : {},
}),
);
return postJson("/api/save", p);
}
@@ -183,6 +188,9 @@
function decorateSavePayload(p) {
const d = ctx.dbfData;
if (!d) return p;
// 산근 편집기 계약단가 표시(BCONT) — 고친 코드만 COSTN 칸 덧붙임
const extra = ctx.uiState.costnExtra || {};
(p.costn_updates || []).forEach((u) => Object.assign(u, extra[String(u.CODE).trim()] || {}));
return Object.assign(p, { start_ini: d.start_ini || {}, prjlst_rows: d.prjlst_all || [] });
}
+1 -1
View File
@@ -373,7 +373,7 @@ const StmateCalc = (() => {
? Object.assign({}, lookupUnit(row.refId), { M: su[0], L: su[1], S: su[2] })
: lookupUnit(row.refId);
// 행 고정 단가(fixedUnit) · 내역 할증율(wBoqRate RATE/RM·RL·RS = 비목별 단가 가산, 원 단위 절사)
// — 수량 할증(BDQTY X_HAL)과 다른 금액 가산(07번 §4 · 34번 wBoqRate) · 저장 차수 단가는 이미 반영된 값이라 건너뜀
// — 금액 가산(BDQTY X_QTY·X_HAL 은 손료계수 칸 · REA 정정)(07번 §4 · 34번 wBoqRate) · 저장 차수 단가는 이미 반영된 값이라 건너뜀
if (!su && row.fixedUnit)
["M", "L", "S"].forEach((c) => row.fixedUnit[c] != null && (u[c] = num(row.fixedUnit[c])));
const hr = row.halRate;
+75 -6
View File
@@ -37,6 +37,10 @@ const StmateContract = (() => {
excludeGov: true,
zeroToW: true,
sameCodeSeparate: false,
baseRate: true, // 기초단가에 적용율 — □ = 기초단가(M·L·S) 코드·단가 그대로
makeIlwi: true, // 일위대가/산출근거 생성 — □ = 대가·산근·중기(B·D·X)는 계약단가 W코드로
taxFree: true, // 비과세자재대 — □ = taxFreeCodes(COSTN NOTA) 금액 그대로
taxFreeCodes: [],
};
// 머리글자별 기존 최대 코드 번호 — 새 계약 코드는 그 다음부터
@@ -83,10 +87,16 @@ const StmateContract = (() => {
if (nd.kind !== "leaf") return out;
const d = nd.st[stage] || {};
const excluded = ex.has(i);
const kind = String(nd.code || "")[0] || "";
// 그대로 둘 공종 — 적용제외 · 기초단가 적용율 끔 · 비과세자재대 그대로
const keep =
excluded ||
(o.baseRate === false && /[MLS]/.test(kind)) ||
(o.taxFree === false && (o.taxFreeCodes || []).includes(nd.code));
const unit = {};
for (const [c, , rk] of COMPS) {
const u = n0(d[c + "Unit"]);
unit[c] = excluded ? u : roundDown((u * n0(o[rk])) / 100, n0(o.digits));
unit[c] = keep ? u : roundDown((u * n0(o[rk])) / 100, n0(o.digits));
}
const qty = n0(d.qty);
const amt = {};
@@ -94,16 +104,19 @@ const StmateContract = (() => {
const tUnit = unit.m + unit.l + unit.s;
let newCode = nd.code;
const designT = n0(d.tUnit);
const zeroed = !excluded && o.zeroToW && designT !== 0 && tUnit === 0;
if (!excluded && counter && nd.code) {
const key = zeroed ? "W" + nd.code.slice(1) : nd.code;
const zeroed = !keep && o.zeroToW && designT !== 0 && tUnit === 0;
// 일위대가/산출근거 생성 끔 — 대가·산근·중기는 낙찰율 적용 계약단가(W코드)로
const asW = zeroed || (!keep && o.makeIlwi === false && /[BDX]/.test(kind));
if (!keep && counter && nd.code) {
const key = asW ? "W" + nd.code.slice(1) : nd.code;
if (!o.sameCodeSeparate && codeMap[key]) newCode = codeMap[key];
else {
newCode = nextCode(zeroed ? "W" + nd.code.slice(1) : nd.code);
newCode = nextCode(key);
codeMap[key] = newCode;
}
} else if (zeroed) newCode = "W" + nd.code.slice(1);
} else if (asW) newCode = "W" + nd.code.slice(1);
out.excluded = excluded;
out.kept = keep;
out.zeroed = zeroed;
out.newCode = newCode;
out.design = d;
@@ -252,8 +265,64 @@ const StmateContract = (() => {
return out;
}
/**
* 설계 → 계약자료 변환등록 저장 요청(wM_Conv_Mode · 26 §4 · 울진 2025 실증)
* 내역: 계약 공종 행 = 새 코드 · 비목 단가·금액(1칸) · 변경 수량 적용이면 QTY1 = 그 차수 수량
* 새 COSTN 행: 설계 코드의 명칭·규격·단위·분류 칸을 옮김
* 대가·산근(B·D·X) = 2칸 원단가(설계) · 1칸 계약단가 · NAGCD = 적용율 · UC(결과값에 낙찰율적용)·BCONT(계약단가) = T
* · 산출근거 글(COSTN.SMT)·구성표(BDQTY) 그대로 옮김
* 기초단가·계약단가 W = 1칸 = 2칸 = 계약단가(낙찰율 열 없음)
*/
const COPY_COLS = ["NAME", "SIZE", "UNIT", "BQCODE", "EQCODE", "JDCODE", "NAME2", "SIZE2"];
const COPY_MORE = ["SBMOK", "S_ADJ", "ADJ_M", "ADJ_L", "ADJ_S", "EDT_DATE", "WQCODE"];
function registerRows(conv, costnRows, memos = {}, bdqtyRows = []) {
const st = conv.stage;
const by = new Map((costnRows || []).map((r) => [String(r.CODE || "").trim(), r]));
const o = conv.opts;
const same = n0(o.rateL) === n0(o.rateM) && n0(o.rateL) === n0(o.rateS);
const boq = [],
costn = [],
memo = {},
bdqty = [],
made = new Set();
for (const nd of conv.nodes) {
if (nd.kind !== "leaf" || nd.recno == null || nd.kept) continue;
const s = nd.st[st];
const u = { _recno: nd.recno, BDCODE1: nd.newCode };
if (!nd.pct) {
Object.assign(u, { MAMT1: s.mUnit, LAMT1: s.lUnit, SAMT1: s.sUnit, TAMT1: s.tUnit });
Object.assign(u, { M_AMT1: s.mAmt, L_AMT1: s.lAmt, S_AMT1: s.sAmt, T_AMT1: s.tAmt });
if (st !== 1) u.QTY1 = s.qty;
}
boq.push(u);
if (made.has(nd.newCode) || by.has(nd.newCode) || nd.newCode === nd.code) continue;
made.add(nd.newCode);
const src = by.get(nd.code) || {};
const row = { CODE: nd.newCode };
COPY_COLS.forEach((k) => src[k] != null && (row[k] = src[k]));
const comp = /^[BDX]/.test(nd.newCode);
const d = nd.design || {};
const one = { M: s.mUnit, L: s.lUnit, S: s.sUnit, T: s.tUnit };
const two = comp ? { M: n0(d.mUnit), L: n0(d.lUnit), S: n0(d.sUnit), T: n0(d.tUnit) } : one;
for (const c of ["T", "M", "L", "S"])
Object.assign(row, { [`${c}AMT1`]: one[c], [`${c}AMT2`]: two[c] });
if (comp) {
COPY_MORE.forEach((k) => src[k] != null && (row[k] = src[k]));
Object.assign(row, { NAGCD: same ? n0(o.rateL) : 0, UC: "T", BCONT: "T" });
const m = memos[nd.code];
if (m && String(m.text || "").trim()) memo[nd.newCode] = m.text;
(bdqtyRows || [])
.filter((q) => String(q.BDCODE || "").trim() === nd.code)
.forEach((q) => bdqty.push(Object.assign({}, q, { BDCODE: nd.newCode })));
}
costn.push(row);
}
return { boq, costn, memos: memo, bdqty };
}
return {
CONTRACT_CARRY,
registerRows,
DEFAULT_OPTS,
codeBaseFromCostn,
convert,
+1
View File
@@ -67,6 +67,7 @@
<script src="ui_dan.js"></script>
<script src="ui_code.js"></script>
<script src="ui_code_amt.js"></script>
<script src="ui_code_check.js"></script>
<script src="ui_merge.js"></script>
<script src="plugins_manifest.js"></script>
<script src="app.js"></script>
+18 -3
View File
@@ -92,7 +92,11 @@ const StmateSmtLang = (() => {
.join("\r\n");
}
// 문법 강조 — 주석문 · Text(따옴표) · 숫자 · 변수·단가코드 · 계표시
// 문법 강조 낱말 — 원본 TWM_EDIT_SAN Syn1.KeyWords(함수·상수) · 대소문자 가리지 않음
const SYN_KEYWORDS =
"ABS COS CUT EXP FACT IIF INT LA LN LOG MA MOD PI POWER ROUND ROUNDDOWN SIN SQRT STMATE TAN";
const KEYS = new Set(SYN_KEYWORDS.split(" "));
// 문법 강조 — 주석문 · Text(따옴표) · 숫자 · 함수 낱말 · 변수·단가코드 · 계표시
function highlight(line) {
const s = String(line);
const out = [];
@@ -127,7 +131,8 @@ const StmateSmtLang = (() => {
continue;
}
if ((m = rest.match(/^[A-Za-z_][A-Za-z_0-9]*/))) {
out.push(`<span class="syn-var">${esc(m[0])}</span>`);
const cls = KEYS.has(m[0].toUpperCase()) ? "syn-key" : "syn-var";
out.push(`<span class="${cls}">${esc(m[0])}</span>`);
i += m[0].length;
continue;
}
@@ -155,7 +160,17 @@ const StmateSmtLang = (() => {
"함수 — ∏ INT SIN COS TAN SQRT √ ROUND ROUNDDOWN ROUNDUP CUT LOG LN ABS EXP MOD POWER FACT IIF(조건, 참, 거짓)",
];
return { MARK_NAMES, TRANS, marker, codeLines, stripResults, highlight, GRAMMAR_HELP, esc };
return {
MARK_NAMES,
TRANS,
marker,
codeLines,
stripResults,
highlight,
GRAMMAR_HELP,
SYN_KEYWORDS,
esc,
};
})();
if (typeof window !== "undefined") window.STMATE_SMT_LANG = StmateSmtLang;
+46 -2
View File
@@ -157,6 +157,15 @@
${sel("san-func", [["", "각종 지원함수"]].concat(X().FUNC_LIST.map((f) => [f, f])), "")}
${sel("san-mark", marks, "")}
${sel("san-qty", qopts, "")}
${sel(
"san-img",
[["", "그림코드(Ctrl+I)"]].concat(
((ctx.dbfData && ctx.dbfData.costn_rows) || [])
.filter((r) => /^I\d{5}$/.test(String(r.CODE).trim()))
.map((r) => [String(r.CODE).trim(), `${String(r.CODE).trim()} ${r.NAME || ""}`]),
),
"",
)}
${sel("san-eq", [["", eq.length ? "중기공식 불러오기" : "등록된 중기공식 없음"]].concat(eq.map((e, i) => [String(i), `${e.name} (${e.ins_x ? "공식+코드" : "공식"})`])), "")}
</div>
<div class="win-subbar" style="flex-wrap:wrap;gap:4px;">
@@ -182,6 +191,9 @@
<label><input type="checkbox" id="san-skip0" ${u.sanSkip0 ? "checked" : ""}> 단가가 0원인 비목은 입력안함</label>
<button class="win-btn" id="san-ins">코드 삽입</button>
<span style="margin-left:auto;">결과적용 비목 ${sel("san-sbmok", SB, String(row.SBMOK || "").trim())}</span>
<span title="Ed_NAGCD — 낙찰율(%) · 0 = 없음">낙찰율 <input id="san-nag" type="number" step="0.001" style="width:70px" value="${num(row.NAGCD) || ""}">%</span>
<label title="소수점을 정리한 결과값에 낙찰율을 적용하여 금액을 산출"><input type="checkbox" id="san-uc" ${String(row.UC || "").trim() === "T" ? "checked" : ""}> 결과값에 낙찰율적용</label>
<label title="인쇄시 낙찰율이 표시되지 않고 『계약단가』로 인쇄"><input type="checkbox" id="san-cont" ${String(row.BCONT || "").trim() === "T" ? "checked" : ""}> 계약단가</label>
<label><input type="checkbox" id="san-adj" ${adjOn ? "checked" : ""}> 결과금액조정</label>
${adjOn ? ["M", "L", "S"].map((k) => `${k}:x <input class="san-adjv" data-k="${k}" type="number" step="0.00001" style="width:64px" value="${num(row[`ADJ_${k}`]) || 1}">`).join(" ") : ""}
</div>
@@ -218,6 +230,22 @@
</div>`;
}
/**
* 낙찰율(Ed_NAGCD) · 결과값에 낙찰율적용(Ck_UC → COSTN UC) — 26 §3·§4 · REA 30 TWM_EDIT_SAN
* 처음 걸 때 지금 1칸 단가를 2칸(원단가) 자리로 옮기고, 지우면 2칸 원단가를 1칸으로 되돌림
* 다시 셈(계산완료)은 smt_engine — 2칸 = 산근 결과 · 1칸 = 비목별 원단가 × 낙찰율 버림(실무 7건 UC=T ⇔ NAGCD>0)
*/
function setBidRate(row, rate, uc = true) {
const r = num(rate),
had = num(row.NAGCD) > 0;
const cols = ["T", "M", "L", "S"];
if (r > 0 && !had) cols.forEach((k) => (row[`${k}AMT2`] = String(row[`${k}AMT1`] || "0")));
if (!(r > 0) && had) cols.forEach((k) => (row[`${k}AMT1`] = String(row[`${k}AMT2`] || "0")));
row.NAGCD = String(r > 0 ? r : 0);
row.UC = r > 0 && uc ? "T" : "F";
return row;
}
// 순환 — 이 산근이 부르는 코드가 다시 이 산근에 닿는지(REA 30 mode1 순환참조 검사)
function cycleErrors(ctx, code) {
const C = COMP();
@@ -273,6 +301,7 @@
const redo = () => (keep(), app.renderWorkspace());
area.onkeydown = (e) => {
if (e.key === "F9") (e.preventDefault(), $("san-calc").click());
if (e.ctrlKey && (e.key === "i" || e.key === "I")) (e.preventDefault(), $("san-img").focus());
};
const pick = (id, fn) =>
$(id) &&
@@ -285,6 +314,8 @@
pick("san-func", (v) => insertAt(area, v));
pick("san-mark", (v) => insertLines(area, [v]));
pick("san-qty", (v) => insertAt(area, v));
// 그림 넣기 — 「IMG(I00001)」 한 줄(문법설명 (5) · 그림코드는 한 줄에 하나)
pick("san-img", (v) => insertLines(area, [`IMG(${v})`]));
pick("san-eq", (v) => {
const it = (u.eqItems || [])[Number(v)];
if (!it) return;
@@ -335,6 +366,18 @@
);
const row = ENG().costnLookup(ctx).byCode[code] || {};
$("san-sbmok").onchange = (e) => ((row.SBMOK = e.target.value), redo());
const bid = () => (
keep(),
setBidRate(row, $("san-nag").value, $("san-uc").checked),
app.cascadeAndRender()
);
$("san-nag").onchange = bid;
$("san-uc").onchange = bid;
// 계약단가 표시(BCONT) — 인쇄용 · 저장 묶음에 덧붙임(app.js decorateSavePayload)
$("san-cont").onchange = (e) => {
row.BCONT = e.target.checked ? "T" : "F";
(u.costnExtra = u.costnExtra || {})[code] = { BCONT: row.BCONT };
};
$("san-adj").onchange = (e) => {
row.S_ADJ = e.target.checked ? "T" : "F";
for (const k of "MLS") if (!num(row[`ADJ_${k}`])) row[`ADJ_${k}`] = "1";
@@ -374,5 +417,6 @@
}
P.screens.wM_Edit_iLWi = { render, bind };
global.STMATE_EDIT_SAN = { render, bind, cycleErrors, insertLines };
})(window);
global.STMATE_EDIT_SAN = { render, bind, cycleErrors, insertLines, setBidRate };
if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_EDIT_SAN;
})(typeof window !== "undefined" ? window : globalThis);
+34
View File
@@ -277,6 +277,37 @@
return out;
}
const RATE_AXES = [
"I_KONG_TYPE",
"I_KONG_KIKAN",
"I_ANJUN_TYPE",
"I_KOBO_TYPE",
"I_HWAN",
"I_EQIP",
"I_TBUGUM",
"I_ILBAN",
];
// 제비율표 다시 조회 — 마지막에 적용한 표(없으면 공사 RATE_R) · 수동 확정이면 그대로(scr_kanjub_rate)
function reRate(ctx, api, cfg) {
const R = global.StmateRate,
u = ctx.uiState;
const rows =
u.kjRateRows ||
(ctx.dbfData && ctx.dbfData.rate_tables && ctx.dbfData.rate_tables["RATE_R.DBF"]);
if (!R || !rows || !rows.length || u.kjAutoRate === false) return null;
const n = stageCfg(ctx).n;
const calc = () => (
api.recompute(),
(ctx.computed.stages && ctx.computed.stages[n] && ctx.computed.stages[n].kanjub) ||
ctx.computed.kanjubComputed
);
const res = R.applyWithLoopCheck(cfg, R.matrixFromRows(rows), calc, {
withTbugum: true,
});
u.kjMsg = res.loop ? res.msg : res.manual ? "요율 수동 확정 상태 — 요율 그대로" : "";
return res;
}
function bind(ctx, api) {
const re = () => api.renderWorkspace();
const { cfg, st } = stageCfg(ctx);
@@ -291,6 +322,8 @@
switchScheme(cfg, schemeOf(k2));
}
setKey(cfg, key, val);
// 요율 축(공사종류·기간·안전공종·고용등급·환경·건설기계·퇴직공제·일반관리비) — 제비율표 다시 조회 후 다시 셈
if (RATE_AXES.includes(key)) reRate(ctx, api, cfg);
re();
});
api.bindEach(".kjx-fix", "onchange", (el) => {
@@ -348,6 +381,7 @@
let rows = ctx.dbfData && ctx.dbfData.rate_tables && ctx.dbfData.rate_tables["RATE_R.DBF"];
if (p) rows = (await api.postJson("/api/load-jbr", { jbr_path: p })).rows;
if (!R || !rows) return alert("제비율표(RATE_R) 없음");
u.kjRateRows = rows; // 요율 축 콤보를 바꿀 때 다시 씀
const m = R.matrixFromRows(rows),
n = stageCfg(ctx).n;
const calc = () => (
+41 -3
View File
@@ -145,7 +145,12 @@ const StmateRate = (() => {
// 순환 점검 — 규모 밑수를 계산 결과에서 다시 잡을 때 묶음이 왕복하면 멈추고 알림(06 §2 계약 ①~③)
// 수동 확정(cfg.rateConfirm.manual)이 있으면 다시 고르지 않음 · 왕복이면 오간 조회 결과(choices)를 돌려줌
function applyWithLoopCheck(cfg, m, calcFn, { scaleOf = (kj) => kj.dT, maxIter = 6 } = {}) {
function applyWithLoopCheck(
cfg,
m,
calcFn,
{ scaleOf = (kj) => kj.dT, maxIter = 6, withTbugum = false } = {},
) {
let kj = calcFn(cfg);
if (cfg.rateConfirm && cfg.rateConfirm.manual) return { cfg, kj, loop: false, manual: true };
const seen = [],
@@ -162,7 +167,7 @@ const StmateRate = (() => {
if (seen[seen.length - 1] === sig) return { cfg, kj, loop: false };
seen.push(sig);
found[sig] = res;
applyToCfg(cfg, res);
applyToCfg(cfg, res, { withTbugum });
kj = calcFn(cfg);
}
return {
@@ -349,7 +354,10 @@ if (typeof module !== "undefined" && module.exports) module.exports = StmateRate
return `<div class="win-form"><div class="win-form-header"><span>원가계산 제비율표 [TWM_KAN_RATE · ${esc(src)} ${m.length}행×${m[0] ? m[0].length : 0}열]</span></div>
<div class="win-subbar"><label>표</label><select id="kr-src" class="win-select">${srcOpts}</select>
<select id="kr-jbr" class="win-select" style="width:200px">${jbr.map((f) => `<option value="${esc(f.path)}">${esc(f.name)}</option>`).join("")}</select>
<button class="win-btn" id="kr-load">기준 JB_R 열기</button><button class="win-btn" id="kr-back">제잡비 계산으로</button></div>
<button class="win-btn" id="kr-load">기준 JB_R 열기</button><button class="win-btn" id="kr-back">제잡비 계산으로</button>
<button class="win-btn" id="kr-undo" ${u.krOrig && u.krOrig[src] ? "" : "disabled"}>수정 취소</button>
<button class="win-btn" id="kr-print">출력</button>
<span class="stm-note">${ctx.dbfData && ctx.dbfData.rate_dirty ? "고친 칸은 [계산완료·저장] 때 공사 STC 의 이 표에 씀" : ""}</span></div>
${lookupPanel(ctx, m)}<div class="win-subbar">${tabs}</div><div style="overflow:auto;max-height:520px">${body}</div></div>`;
}
@@ -371,6 +379,14 @@ if (typeof module !== "undefined" && module.exports) module.exports = StmateRate
const r = n0(el.getAttribute("data-r")),
c = n0(el.getAttribute("data-c"));
if (!m[r]) return;
// 처음 고칠 때 원래 표를 남김(수정 취소)
const rows0 = ctx.dbfData && ctx.dbfData.rate_tables && ctx.dbfData.rate_tables[src];
u.krOrig = u.krOrig || {};
if (!u.krOrig[src])
u.krOrig[src] = {
m: JSON.parse(JSON.stringify(m)),
rows: rows0 && JSON.parse(JSON.stringify(rows0)),
};
m[r][c - 1] = n0(el.value);
// 공사 안 표면 원본 행(RATE_xx 칸)에도 적음
const rows = ctx.dbfData && ctx.dbfData.rate_tables && ctx.dbfData.rate_tables[src];
@@ -379,9 +395,31 @@ if (typeof module !== "undefined" && module.exports) module.exports = StmateRate
.filter((k) => /^RATE_?\d+$/.test(k))
.sort((a, b) => n0(a.replace(/\D/g, "")) - n0(b.replace(/\D/g, "")));
if (keys[c - 1]) rows[r][keys[c - 1]] = String(n0(el.value));
ctx.dbfData.rate_dirty = true; // 저장 길 = /api/ui/save rate_tables(api_ui_code._rates)
}
re();
});
api.onClick("kr-undo", () => {
const o = u.krOrig && u.krOrig[src];
if (!o) return;
u.krMats[src] = o.m;
if (o.rows && ctx.dbfData && ctx.dbfData.rate_tables) ctx.dbfData.rate_tables[src] = o.rows;
delete u.krOrig[src];
if (ctx.dbfData) ctx.dbfData.rate_dirty = Object.keys(u.krOrig).length > 0;
re();
});
api.onClick("kr-print", () => {
const w = global.open("", "_blank");
if (!w) return;
const t = m
.map((row, i) => `<tr><th>${i}</th>${row.map((v) => `<td>${v}</td>`).join("")}</tr>`)
.join("");
w.document.write(
`<html><head><meta charset="utf-8"><title>원가계산 제비율표 ${esc(src)}</title><style>td,th{border:1px solid #999;padding:2px 4px;font:11px monospace;text-align:right}table{border-collapse:collapse}</style></head><body><h3>원가계산 제비율표 — ${esc(src)}</h3><table>${t}</table></body></html>`,
);
w.document.close();
w.print();
});
api.onClick("kr-apply", () => {
const S = global.STMATE_KANJUB_SCREEN,
{ n, cfg } = S.stageCfg(ctx);
+24 -9
View File
@@ -11,11 +11,10 @@
const S = (global.STMATE_STAGE = global.STMATE_STAGE || { tabs: [] });
const n0 = (v) => Number(v) || 0;
const OPTIONS = [
["basicApply", "기초단가(재,노,경,일식)에 단가적용율 적용하기"],
["baseRate", "기초단가(재,노,경,일식)에 단가적용율 적용하기"],
["makeIlwi", "적용율 적용된 일위대가/산출근거 생성하기"],
["sameCodeSeparate", "계약내역내 동일코드의 계약단가 개별생성"],
["laborRatio", "노무비율 적용"],
["taxFreeApply", "비과세자재대(원가계산)에 단가적용율 적용하기"],
["taxFree", "비과세자재대(원가계산)에 단가적용율 적용하기"],
["zeroToW", '단가적용율이 0% 이면 계약단가(W코드)를 "0"化 (공내역 생성)'],
];
const LEVELS = [
@@ -53,6 +52,13 @@
excludeGov: true,
zeroToW: true,
sameCodeSeparate: false,
baseRate: true,
makeIlwi: true,
taxFree: true,
// 비과세자재대 = COSTN NOTA 표시 코드(실무 표본 0건)
taxFreeCodes: ((ctx.dbfData && ctx.dbfData.costn_rows) || [])
.filter((r) => String(r.NOTA || "").trim() === "T")
.map((r) => String(r.CODE).trim()),
level: 0,
stage: 1,
target: "",
@@ -204,6 +210,7 @@
)
.join("")}</select>
<span>단가 적용율 【노】</span>${inp("ct-rl", o.rateL)}<span>%【재】</span>${inp("ct-rm", o.rateM)}<span>%【경】</span>${inp("ct-rs", o.rateS)}<span>%</span>
<button class="win-btn" id="ct-labor" title="재료비, 경비의 단가적용율을 노무비 단가적용율과 동일하게 설정">노무비율 적용</button>
<span>단가 절사 자리</span>${inp("ct-dg", o.digits, 40)}
<label><input type="checkbox" id="ct-gov" ${o.excludeGov ? "checked" : ""}/> 관급(C 구역) 제외</label>
<button class="win-btn primary" id="ct-reg">계약자료 변환등록</button>
@@ -315,20 +322,28 @@
o.rateL = o.rateM = o.rateS = o.found.rate;
re();
});
api.onClick("ct-labor", () => ((o.rateM = o.rateS = o.rateL), re()));
// 설계 → 계약자료 변환등록(wM_Conv_Mode) — 고른 차수(당초/변경) 수량·단가 · 새 PCODE 공사 · 새 코드 COSTN(NAGCD)
api.onClick("ct-reg", async () => {
if (!ctx.currentSourcePath) return alert("먼저 공사를 불러옴");
const c = compute(ctx);
const ex = c.conv.nodes.filter((nd) => nd.kind === "leaf" && nd.excluded && nd.recno != null);
const d = ctx.dbfData;
const reg = K.registerRows(c.conv, d.costn_rows, d.costn_memos || {}, d.bdqty_rows || []);
try {
const r = await api.postJson("/api/file/copy-register", {
const r = await api.postJson("/api/ui/contract-register", {
source_path: ctx.currentSourcePath,
src_pcode: String((ctx.state.projectMeta && ctx.state.projectMeta.pcode) || "11"),
mode: "contract",
pname: `${S.pname(ctx)} (계약)`,
rates: { M: o.rateM, L: o.rateL, S: o.rateS },
exclude_recnos: ex.map((nd) => nd.recno),
pnum: `${o.rateL}% 적용`,
boq_rows: reg.boq,
costn_rows: reg.costn,
costn_memos: reg.memos,
bdqty_rows: reg.bdqty,
costw1: c.kj.grandTotal,
});
alert(`계약자료 변환등록 — ${JSON.stringify(r).slice(0, 300)}`);
alert(
`계약자료 변환등록 — 새 공사 PCODE ${r.new_pcode} · 새 코드 ${reg.costn.length}건 · ${r.saved_stc || ""}`,
);
} catch (e) {
alert("변환등록 실패: " + e.message);
}
+26 -7
View File
@@ -302,8 +302,17 @@
],
"none",
)}</select></div>
<div class="stm-row"><label>번호 초기화(cBx_Bl_Lev)</label><select id="it-reset" class="win-select"><option value="">바로 윗 레벨 머리마다</option>${[2, 3, 4, 5, 6, 7, 8, 9].map((n) => `<option value="${n}">${n} 레벨(${esc(B.dispName(ini, n))}) 이상 머리마다</option>`).join("")}<option value="0">초기화 안 함(이어서)</option></select></div>
<p class="stm-note">예) 4 레벨 · 끝 「.」 → 1. 2. … / 2 레벨 · 첫 「.」 · 끝 공란 · 직전공종 번호 → 1.1 1.2 …</p>
<div class="stm-row"><label>번호 초기화(cBx_Bl_Lev)</label><select id="it-reset" class="win-select"><option value="tree">하위 전체공종 반영(1 · 1.1 · 1.1.1)</option><option value="" selected>바로 윗 레벨 머리마다</option>${[2, 3, 4, 5, 6, 7, 8, 9].map((n) => `<option value="${n}">${n} 레벨(${esc(B.dispName(ini, n))}) 이상 머리마다</option>`).join("")}<option value="0">초기화 안 함(이어서)</option></select></div>
<div class="stm-row"><label>번호 마디 한도</label><select id="it-depth" class="win-select">${opt(
[
[0, "제한 없음"],
[2, "2 마디(1.1)"],
[3, "3 마디(1.1.1)"],
[4, "4 마디"],
],
0,
)}</select><span class="stm-note">하위 전체공종 반영일 때만</span></div>
<p class="stm-note">예) 4 레벨 · 끝 「.」 → 1. 2. … / 2 레벨 · 첫 「.」 · 끝 공란 · 직전공종 번호 → 1.1 1.2 … / 하위 전체공종 반영 = 고른 레벨 머리 아래 공종·단가코드를 묶음마다 1.1 · 1.1.1</p>
<div class="stm-row"><button class="win-btn primary" id="it-ok">공종번호 입력</button></div></div>`,
);
$("it-ok").onclick = () => {
@@ -322,7 +331,9 @@
});
Object.assign(o, {
prefix: val("it-prefix"),
resetLevel: rs === "" ? undefined : Number(rs),
resetLevel: rs === "" || rs === "tree" ? undefined : Number(rs),
tree: rs === "tree",
maxDepth: Number(val("it-depth")) || 0,
});
const ix = x.selIdx(ctx);
if (val("it-range") === "sel") {
@@ -348,8 +359,9 @@
],
"all",
)}</select></div>
<div class="stm-row"><label>전체 할증율(%)</label><input id="br-all" type="number" class="win-input" value="0" /></div>
<div class="stm-row"><label>재료비 · 노무비 · 경비(%)</label><input id="br-m" type="number" class="win-input" value="0" style="width:70px;" /><input id="br-l" type="number" class="win-input" value="0" style="width:70px;" /><input id="br-s" type="number" class="win-input" value="0" style="width:70px;" /></div>
<div class="stm-row"><label>전체 할증율(%)</label><input id="br-all" type="number" step="0.001" class="win-input" value="0" /><span class="stm-note">※ 재료비,노무비,경비에 동일한 할증율이 적용됩니다. ※</span></div>
<div class="stm-row"><label>재료비 · 노무비 · 경비(%)</label><input id="br-m" type="number" step="0.001" class="win-input" value="0" style="width:70px;" /><input id="br-l" type="number" step="0.001" class="win-input" value="0" style="width:70px;" /><input id="br-s" type="number" step="0.001" class="win-input" value="0" style="width:70px;" /></div>
<div class="stm-row"><label><input type="checkbox" id="br-notuc" checked /> 보호된 견적단가 수정안함</label></div>
<div class="stm-row"><label>범위</label><select id="br-range" class="win-select">${opt(
[
["sel", "선택된 항목만"],
@@ -357,7 +369,7 @@
],
"sel",
)}</select></div>
<p class="stm-warn">내역 행 RATE·RM·RL·RS 칸과 비고에 기록 — 금액 반영은 계산 엔진(calc.js calcBoq)의 할증 처리 대기</p>
<p class="stm-note">※ 할증율이 0%인경우 100%로 적용됨 · 단가 = 기준단가(할증전 단가) × 할증율 · 내역 행 RATE·RM·RL·RS 칸에 기록</p>
<div class="stm-row"><button class="win-btn primary" id="br-ok">적용</button></div></div>`,
);
$("br-ok").onclick = () => {
@@ -368,9 +380,16 @@
recs,
{ all: val("br-all"), M: val("br-m"), L: val("br-l"), S: val("br-s") },
val("br-mode"),
{
notUC: $("br-notuc").checked,
base: (c) => {
const r = (ctx.dbfData.costn_rows || []).find((x) => String(x.CODE).trim() === c);
return r && { M: B.num(r.MAMT1), L: B.num(r.LAMT1), S: B.num(r.SAMT1) };
},
},
);
api.closeModal();
ctx.uiState.boqMsg = `할증율 ${res.changed}행 기록`;
ctx.uiState.boqMsg = `할증율 ${res.changed}행 기록${res.kept ? ` · 보호 견적 ${res.kept}행 그대로` : ""}`;
x.after(ctx, api, { ok: true }, true);
};
}
+48 -6
View File
@@ -97,6 +97,7 @@
})
.join("")}
<td class="center">${ad ? esc(slotName(ad)) : base && U.isUserEdited(r) ? "직접" : ""}</td>
<td class="center">${/^[MLSW]/.test(c) ? `<input type="checkbox" class="dan-prot" data-code="${c}" ${U.isProtected(r) ? "checked" : ""} title="보호(UC) — 일괄 손질에서 빠짐" />` : ""}</td>
<td class="center">${esc(r.BQCODE || "")}</td><td class="center">${esc(r.EQCODE || "")}</td>
<td class="center">${/^[BDX]/.test(c) ? `<button class="win-btn dan-ilwi" data-code="${c}">대가표</button>` : ""}</td>
</tr>`;
@@ -124,7 +125,7 @@
${toolBar(ctx, s)}
<div class="split-vert"><div class="pane-box" style="flex:1;"><div class="grid-scroll" id="dan-grid"><table class="dbgrid"><thead><tr>
<th>No</th><th>코드</th><th>명칭</th><th>규격</th><th>단위</th><th>재료비</th><th>노무비</th><th>경비</th><th>${esc(slots.applied)}</th>
${slots.list.map((x) => `<th title="${esc(x.name)}">${esc(x.name)}</th>`).join("")}<th>채택</th><th>분류번호</th><th>시설코드</th><th></th>
${slots.list.map((x) => `<th title="${esc(x.name)}">${esc(x.name)}</th>`).join("")}<th>채택</th><th title="보호표시(√) — 일괄 손질 제외">보호</th><th>분류번호</th><th>시설코드</th><th></th>
</tr></thead><tbody>${body}</tbody></table></div></div></div>
</div>`;
}
@@ -137,10 +138,11 @@
const names = UA.nameList(ctx.dbfData.costn_rows, s.kind === "ALL" ? "" : s.kind, "NAME");
const b = (id, t, on) =>
`<button class="win-btn" id="${id}" ${on === false ? "disabled" : ""}>${t}</button>`;
return `<div class="win-subbar">${b("dan-new", "신규등록(A)")}${b("dan-del", "현재코드 삭제", Boolean(s.code))}${b("dan-chg", "코드번호 변경", Boolean(s.code))}${b("dan-rp", "현재코드 항목교체(내역포함)", Boolean(s.code))}${b("dan-jd", "호환규정코드 검색")}${b("dan-hal", "적용단가 일괄할증")}${b("dan-chgamt", "단가 일괄변경")}${b("dan-xr", "손료계수(F6)")}
return `<div class="win-subbar">${b("dan-new", "신규등록(A)")}${b("dan-del", "현재코드 삭제", Boolean(s.code))}${b("dan-chg", "코드번호 변경", Boolean(s.code))}${b("dan-rp", "현재코드 항목교체(내역포함)", Boolean(s.code))}${b("dan-jd", "호환규정코드 검색")}${b("dan-hal", "적용단가 일괄할증")}${b("dan-chgamt", "단가 일괄변경")}${b("dan-xr", "손료계수(F6)")}${b("dan-check", "성분 검사(TAMT=재+노+경)")}
${pp.length ? `<select id="dan-pp" class="win-select"><option value="">속성: 모 두 보 임</option>${pp.map((x) => `<option ${s.pp === x ? "selected" : ""}>${esc(x)}</option>`).join("")}</select>` : ""}
<select id="dan-nm" class="win-select" style="max-width:180px;"><option value="">명칭 추려보기: 모두보기</option>${names.map(([n, k]) => `<option value="${esc(n)}" ${s.nm === n ? "selected" : ""}>${esc(n)} (${k})</option>`).join("")}</select>
<label><input type="checkbox" id="dan-called" ${s.called ? "checked" : ""} /> 불러간 목록만</label></div>`;
<label><input type="checkbox" id="dan-called" ${s.called ? "checked" : ""} /> 불러간 목록만</label>
<label title="Chk_Auto_Hal — 보호를 풀면 마지막 일괄할증 설정을 그 행에 다시 걺"><input type="checkbox" id="dan-autohal" ${s.autoHal === false ? "" : "checked"} /> 보호해제시 적용단가 자동할증</label></div>`;
}
function boxa(ctx, api) {
@@ -166,7 +168,8 @@
keepUser: scope !== "one" && $("bx-keep").checked,
});
ch.forEach((c) => syncRes(ctx, c.code, c.to, `slot${c.slot}`));
ctx.uiState.danMsg = `${ch.length}건 채택 바꿈`;
const kept = rows.filter(U.isProtected).length;
ctx.uiState.danMsg = `${ch.length}건 채택 바꿈${kept ? ` · 보호 ${kept}건 그대로` : ""}`;
api.closeModal();
api.cascadeAndRender();
};
@@ -180,7 +183,7 @@
<div class="stm-row"><label>소수</label><select id="eh-dot" class="win-select">${[0, 1, 2].map((d) => `<option value="${d}">${d} 자리 미만</option>`).join("")}<option value="-1">1원 단위 미만(10원)</option></select><select id="eh-up" class="win-select"><option value="trunc">절사</option><option value="round">반올림</option></select></div>
<div class="stm-row"><label>대상</label><select id="eh-kind" class="win-select"><option value="M">재료비</option><option value="L">노무비</option><option value="S">경비</option><option value="MLS">기초단가 전체</option>${s.code ? `<option value="one">선택 코드 ${esc(s.code)}</option>` : ""}</select></div>
<p class="stm-note">※ 단【 적용단가 】가 "0" 원인것은 수정안됨 · 기준단가 = 채택 물가지 단가(채택 없으면 지금 적용단가)</p>
<p class="stm-warn">보호표시 칸은 COSTN 자료에서 확인되지 않아 걸러내지 못함</p>
<p class="stm-note">※ 보호표시(√)된 단가도 수정안됨</p>
<div class="stm-row"><button class="win-btn primary" id="eh-ok">수정</button></div></div>`,
);
$("eh-ok").onclick = () => {
@@ -193,12 +196,37 @@
mode: $("eh-up").value,
});
ch.forEach((c) => syncRes(ctx, c.code, c.to, "manual"));
ctx.uiState.danMsg = `기준단가 수정 ${ch.length}건`;
const kept = ctx.dbfData.costn_rows.filter(U.isProtected).length;
ctx.uiState.danMsg = `기준단가 수정 ${ch.length}건${kept ? ` · 보호 ${kept}건 제외` : ""}`;
api.closeModal();
api.cascadeAndRender();
};
}
// 성분 검사(10 §3) — COSTN · 지금 내역 표 · 어긋난 칸 목록
function checkAll(ctx, api) {
const CK = global.STMATE_UI_CODE_CHECK;
if (!CK) return;
const bad = CK.checkComponents(ctx.dbfData.costn_rows).concat(
CK.checkBoq(ctx.dbfData.boq_rows),
);
const { fmt } = api;
api.openModal(
`성분 검사 — 어긋남 ${bad.length}건`,
`<div class="stm-form"><p class="stm-note">TAMT = 재료비 + 노무비 + 경비 · L/M/S 코드는 자기 비목만 · 차수 칸 1~4 · 내역은 단가·금액 칸</p>
${
bad.length
? `<div class="grid-scroll" style="max-height:320px;"><table class="dbgrid"><thead><tr><th>자리</th><th>코드</th><th>차수</th><th>내용</th><th>계</th><th>재</th><th>노</th><th>경</th></tr></thead><tbody>${bad
.map(
(x) =>
`<tr><td>${esc(x.where)}</td><td>${esc(x.code)}</td><td class="center">${x.slot}</td><td>${esc(x.msg)}</td>${["T", "M", "L", "S"].map((k) => `<td class="num">${fmt(x.v[k])}</td>`).join("")}</tr>`,
)
.join("")}</tbody></table></div>`
: "<p>어긋난 칸 없음</p>"
}</div>`,
);
}
function findUse(ctx, api) {
const s = st(ctx),
f = U.findUse(s.code, ctx.dbfData);
@@ -237,9 +265,23 @@
on("dan-chgamt", () => T2.openChgAmt(ctx, api));
on("dan-xr", () => T2.openXRate(ctx, api, s.code));
}
on("dan-check", () => checkAll(ctx, api));
if ($("dan-pp")) $("dan-pp").onchange = (e) => ((s.pp = e.target.value), rr());
if ($("dan-nm")) $("dan-nm").onchange = (e) => ((s.nm = e.target.value), rr());
if ($("dan-called")) $("dan-called").onchange = (e) => ((s.called = e.target.checked), rr());
if ($("dan-autohal")) $("dan-autohal").onchange = (e) => (s.autoHal = e.target.checked);
document.querySelectorAll("#dan-form .dan-prot").forEach(
(el) =>
(el.onchange = () => {
const c = el.getAttribute("data-code");
const r = ctx.dbfData.costn_rows.find((x) => U.codeOf(x) === c);
const hal = s.autoHal !== false && ctx.uiState.danHal;
const res = U.setProtect(r, el.checked, { autoHal: hal || null });
if (res && res.hal) syncRes(ctx, c, res.hal.to, "manual");
ctx.uiState.danMsg = `${c} 보호${el.checked ? "" : " 해제"}${res && res.hal ? ` · 자동할증 ${res.hal.to}` : ""}`;
api.cascadeAndRender();
}),
);
const grid = $("dan-grid");
if (s.scroll) grid.scrollTop = s.scroll;
grid.onscroll = () => (s.scroll = grid.scrollTop);
+8
View File
@@ -126,6 +126,7 @@
rate: 100,
digits: 4,
skipZero: false,
force: true, // 차수 칸 옮김은 보호표시와 무관하게 전부
});
if (to === 1)
syncState(
@@ -171,6 +172,13 @@
);
$("hl-ok").onclick = () => {
const k = val("hl-k");
// 마지막 할증 설정 — 보호해제시 자동할증(Chk_Auto_Hal)이 다시 씀
ctx.uiState.danHal = {
how: val("hl-how"),
value: num(val("hl-v")),
digits: Number(val("hl-dot")),
mode: val("hl-up"),
};
const ch = UA.halAll(ctx.dbfData.costn_rows, {
kinds: k === "one" ? c[0] : k,
codes: k === "one" ? [c] : null,
+46 -3
View File
@@ -163,6 +163,30 @@
api.renderWorkspace();
};
// 단계 등록(기성 6 · 설계변경 7 / 차수별 0 · 당초/변경 1) — 원본 PCLASS·K_ORD 로 새 K_ORD(api_ui_project.py) → 새 공사 열고 단계 탭
const stageReg = (op, flag, act) => async (ctx, api) => {
const r = selRow(ctx);
if (!r || !ctx.currentSourcePath) return stage(act)(ctx, api);
const what = op === 6 ? "기성내역" : flag ? "설계변경(당초/변경)" : "차수별 설계변경";
if (!confirm(`[${String(r.PCODE).trim()}] ${r.PNAME} 을(를) ${what} 으로 등록할까요?`))
return stage(act)(ctx, api);
try {
const res = await api.postJson("/api/ui/stage-register", {
source_path: ctx.currentSourcePath,
src_pcode: String(r.PCODE).trim(),
op,
flag,
pname: `${r.PNAME} (${what})`,
});
await api.loadProjectFromServer(res.saved_stc, "wPrjList");
ctx.uiState.prjSel = res.new_pcode;
ctx.uiState.boqMsg = `${what} 등록 — PCODE ${res.new_pcode} · K_ORD ${res.k_ord}`;
stage(act)(ctx, api);
} catch (err) {
alert(`${what} 등록 실패: ${err.message}`);
}
};
// 다른 내역서 열기 — 고른 공사 행의 BOQ{PCODE}.DBF 로 바꿔 불러옴(/api/ui/load-boq · api_ui_code.py)
async function openBoq(ctx, api, pcode) {
const d = ctx.dbfData;
@@ -232,9 +256,9 @@
Act_Prn: (ctx, api) => go(ctx, api, "PM_Prt"),
ActMkCont00: stage("Act_Stg_Contract"),
Act_MkCont41: stage("Act_Stg_Exec"),
ActMkCont10: stage("Act_Stg_Prog"),
ActMkCont21: stage("Act_Stg_Change"),
ActMkCont20: stage("Act_Stg_Change"),
ActMkCont10: stageReg(6, 0, "Act_Stg_Prog"),
ActMkCont21: stageReg(7, 1, "Act_Stg_Change"),
ActMkCont20: stageReg(7, 0, "Act_Stg_Change"),
Act_St8_XML: (ctx, api) => go(ctx, api, "PM_Prt"),
Act_St8_Jodal1: (ctx, api) => go(ctx, api, "PM_Prt"),
Act_Find: (ctx, api) => {
@@ -254,6 +278,25 @@
Act_Indx_PCODE: sortBy("PCODE"),
Act_Indx_PNAME: sortBy("PNAME"),
Act_Indx_PNUM: sortBy("PNUM"),
// 내역서 삭제(Act_Del_PrjExecute) — 고른 공사 행·BOQ1n 을 뺀 작업본 STC 를 쓰고 그것을 다시 엶(api_ui_project.py)
Act_Del_Prj: async (ctx, api) => {
const r = selRow(ctx);
if (!r || !ctx.currentSourcePath) return;
const pc = String(r.PCODE).trim();
if (prjRows(ctx).length < 2) return alert("마지막 공사는 지울 수 없음");
if (!confirm(`[${pc}] ${r.PNAME} 내역서를 삭제할까요? (작업본 STC 에 반영 · 원본은 그대로)`))
return;
try {
const res = await api.postJson("/api/ui/project-delete", {
source_path: ctx.currentSourcePath,
pcode: pc,
});
ctx.uiState.prjSel = "";
await api.loadProjectFromServer(res.saved_stc, "wPrjList");
} catch (err) {
alert("내역서 삭제 실패: " + err.message);
}
},
Act_List_UP: moveSel(-1),
Act_List_Down: moveSel(1),
};
+38
View File
@@ -78,6 +78,7 @@
<div class="stm-row"><label>기타 노임산출계수</label><select class="win-select" id="sys-rx-sel">${S.RX_SETS.map((s) => `<option value="${s.id}" ${s.id === rx.id ? "selected" : ""}>${esc(s.cap)}</option>`).join("")}</select></div>
<div class="stm-row"><label>${esc(rx.btn)}</label>${field({ key: rx.rx, type: "text" }, d)}</div>
<div class="stm-row"><label> └ 적용코드</label>${field({ key: rx.con, type: "codes" }, d)}</div>
<div class="stm-row"><label>시간당 노임산출계수 적용코드</label><button class="win-btn sys-jlab" data-k="CONLD">C 기본(일반구간)</button><button class="win-btn sys-jlab" data-k="${rx.con}">${esc(rx.cap.split(";")[1] || rx.con)}</button></div>
</fieldset>
<fieldset class="stm-group"><legend>소 수 점 적 용 기 준</legend>${S.FIELDS.digits.map((f) => row(f, d)).join("")}</fieldset>
<fieldset class="stm-group"><legend>계 산 방 식</legend>${S.FIELDS.calc.map((f) => row(f, d)).join("")}
@@ -118,8 +119,45 @@
ctx.uiState.boqMsg = `초기값 ${changed.length}칸 반영(${changed.join(", ")})`;
}
// 직종·노임 설정(wM_Set_JLab) — 노무비 코드 목록에서 고른 코드를 적용코드 목록으로(추가·삭제) · 적용단가 미리보기
function openJLab(ctx, api, conKey, orig) {
const d = draft(ctx),
$ = (id) => document.getElementById(id);
const ev = global.STMATE_SMT_EVAL;
const hourly = ev && ev.hourlyWage;
const ini = Object.assign({}, ctx.dbfData.start_ini, d);
const j = S.jlab(ctx.dbfData.costn_rows, ini, conKey, hourly);
const fmt = api.fmt;
const tr = (r, cls) =>
`<tr><td class="center"><input type="checkbox" class="${cls}" value="${esc(r.code)}" /></td><td>${esc(r.code)}</td><td>${esc(r.name)}</td><td>${esc(r.size)}</td><td class="center">${esc(r.unit)}</td>${cls === "jl-del" ? `<td class="num">${r.hourly == null ? "" : fmt(r.hourly)}</td>` : ""}<td class="num">${fmt(r.price)}</td></tr>`;
api.openModal(
`시간당 노임산출계수 적용코드 [wM_Set_JLab] — ${conKey} · 환산식 ${j.formula || "(없음)"}`,
`<div class="stm-form"><p class="stm-note">노무비 코드를 목록에서 선택하십시요 — 선택코드 추가 · 선택코드삭제 · 적용 / 취소</p>
<div style="display:flex;gap:8px;">
<div style="flex:1;"><strong>노무비 목록(Grd_BDLst)</strong><div class="grid-scroll" style="max-height:300px;"><table class="dbgrid"><thead><tr><th></th><th>코드번호</th><th>명칭</th><th>규격</th><th>단위</th><th>단가</th></tr></thead><tbody>${j.source.map((r) => tr(r, "jl-add")).join("")}</tbody></table></div></div>
<div style="flex:1;"><strong>적용될 코드목록(Grd_CONLTD)</strong><div class="grid-scroll" style="max-height:300px;"><table class="dbgrid"><thead><tr><th></th><th>코드번호</th><th>명칭</th><th>규격</th><th>단위</th><th>적용단가</th><th>단가</th></tr></thead><tbody>${j.target.map((r) => tr(r, "jl-del")).join("")}</tbody></table></div></div>
</div>
<div class="stm-row"><button class="win-btn" id="jl-add">선택코드 추가 ▶</button><button class="win-btn" id="jl-del">◀ 선택코드삭제</button><button class="win-btn primary" id="jl-ok">적 용</button><button class="win-btn" id="jl-cancel">취 소</button></div></div>`,
);
let cur = String(d[conKey] != null ? d[conKey] : ini[conKey] || "");
const back = orig == null ? cur : orig;
const picked = (cls) => [...document.querySelectorAll(`.${cls}:checked`)].map((x) => x.value);
const apply = (next) => (
(d[conKey] = next),
api.closeModal(),
openJLab(ctx, api, conKey, back)
);
$("jl-add").onclick = () => apply((cur = S.jlabEdit(cur, picked("jl-add"), [])));
$("jl-del").onclick = () => apply((cur = S.jlabEdit(cur, [], picked("jl-del"))));
$("jl-ok").onclick = () => (api.closeModal(), api.renderWorkspace());
$("jl-cancel").onclick = () => ((d[conKey] = back), api.closeModal(), api.renderWorkspace());
}
function bind(ctx, api) {
if (!ctx.dbfData) return;
document
.querySelectorAll("#sys-form .sys-jlab")
.forEach((b) => (b.onclick = () => openJLab(ctx, api, b.getAttribute("data-k"))));
const d = draft(ctx),
u = ctx.uiState,
$ = (id) => document.getElementById(id);
+89 -5
View File
@@ -43,6 +43,7 @@
* range: [시작 행, 끝 행] 선택 범위(없으면 전체) }
*/
function autoItnum(rows, opts) {
if (opts && opts.tree) return treeItnum(rows, opts);
const o = Object.assign(
{ level: 2, target: "group", first: "", last: ".", type: "num", pad: 1, margin: 0 },
{ prefix: "none" },
@@ -89,6 +90,66 @@
return { changed };
}
/**
* 하위 전체공종 반영(cBx_Bl_Lev 첫 선택) — 대상 레벨 머리(뿌리)부터 그 안의 공종(계표시)·단가코드를 묶음마다 한 차례로
* 뿌리 = 여백 + 첫 글자 + 번호 + 끝 글자 · 번호는 바로 위 머리마다 처음부터
* 하위 = 직전공종 번호(끝 「.」 뗌) + 「.」 + 번호(cBx_itNum_B 직전공종의 공종번호) · maxDepth = 번호 마디 수 한도
* o.range = [시작 행, 끝 행] 선택 범위 — 범위 밖 행은 그대로(번호 차례는 범위 안에서만 셈)
*/
function treeItnum(rows, opts) {
const o = Object.assign(
{ level: 3, target: "both", first: "", last: "", type: "num", pad: 1, margin: 0 },
opts || {},
);
const lo = o.range ? o.range[0] : 0,
hi = o.range ? o.range[1] : rows.length - 1;
const links = B.linkPairs(rows);
const idx = new Map(rows.map((r, i) => [r, i]));
const open = [];
const cnt = new Map(); // 부모 머리(또는 뿌리 위 머리) → 지금 번호
const depth = new Map(); // 번호 붙은 머리 → 마디 수
const wantGroup = o.target !== "leaf",
wantLeaf = o.target !== "group";
const lv = Number(o.level);
let changed = 0;
for (let i = 0; i < rows.length; i++) {
const r = rows[i],
k = rowKind(r);
while (open.length && idx.get(links.get(open[open.length - 1])) < i) open.pop();
const isT = k.kind === "T";
if (i >= lo && i <= hi && (isT || k.kind === "LEAF")) {
const root = [...open].reverse().find((h) => rowKind(h).n === lv);
const par = open.length ? open[open.length - 1] : null;
const isRoot = isT && k.n === lv;
const inTree = !isRoot && root && depth.has(par);
const want = isT ? wantGroup : wantLeaf;
if ((isRoot || inTree) && (want || isT)) {
const key = isRoot ? par || "top" : par;
const n = (cnt.get(key) || 0) + 1;
const d = isRoot ? 1 : depth.get(par) + 1;
if (!o.maxDepth || d <= Number(o.maxDepth)) {
cnt.set(key, n);
const seq = seqText(n, o.type, o.pad);
const text = isRoot
? " ".repeat(Number(o.margin) || 0) + (o.first || "") + seq + (o.last || "")
: String(par.ITNUM || "")
.trim()
.replace(/\.$/, "") +
"." +
seq;
if (want) {
r.ITNUM = text;
changed += 1;
}
if (isT) depth.set(r, d);
}
}
}
if (isT && links.get(r)) open.push(r);
}
return { changed };
}
// 반올림·절사 — 소수 d 자리
function roundTo(v, d, mode) {
const f = Math.pow(10, Number(d) || 0);
@@ -235,13 +296,24 @@
return { changed };
}
// 할증율 적용(wBoqRate) — 행 칸 RATE(전체) 또는 RM·RL·RS(비목별) · 계산 원천 항목에도 halRate 로 둠
function applyHalRate(model, recnos, rates, mode) {
/**
* 할증율 적용(wBoqRate · ST_Boq.CPM TwBoqRate DFM) — 행 칸 RATE(전체) 또는 RM·RL·RS(비목별, N7.3)
* 할증율 = 기준단가(할증전 단가)에 곱할 백분율 · 「할증율이 0%인경우 100%로 적용됨」 → 0 = 그대로
* CBoxNotUC 「보호된 견적단가 수정안함」(기본 켬) — 견적단가(W) 행 중 보호(UC=T) 행은 건너뜀
* 계산 원천 halRate = 할증율 − 100(calc.js 가산식 × (1 + p/100) = × 할증율/100)
* o.base(코드) = 기준단가 {M,L,S}(COSTN 1칸) — 주면 행 1칸 단가도 비목별 원 미만 버림으로 씀(저장 반영)
*/
function applyHalRate(model, recnos, rates, mode, o) {
const opt = Object.assign({ notUC: true }, o || {});
const set = recnos ? new Set(recnos.map(num)) : null;
const byRec = new Map(model.items.map((it) => [num(it._recno), it]));
let changed = 0;
const add = (v) => (num(v) > 0 ? num(v) - 100 : 0);
let changed = 0,
kept = 0;
model.rows.forEach((r) => {
if (!isLeaf(r) || (set && !set.has(num(r._recno)))) return;
if (opt.notUC && codeOf(r)[0] === "W" && String(r.UC || "").trim() === "T")
return void kept++;
const all = num(rates.all),
m = mode === "all" ? all : num(rates.M),
l = mode === "all" ? all : num(rates.L),
@@ -253,15 +325,27 @@
RS: String(s),
});
const it = byRec.get(num(r._recno));
if (it) it.halRate = { M: m, L: l, S: s };
if (it) it.halRate = { M: add(m), L: add(l), S: add(s) };
const b = opt.base && opt.base(codeOf(r));
if (b) {
const u = { M: m, L: l, S: s };
["M", "L", "S"].forEach(
(c) =>
(r[`${c}AMT1`] = String(
Math.trunc((num(b[c]) * (u[c] > 0 ? u[c] : 100)) / 100 + 1e-9),
)),
);
r.TAMT1 = String(num(r.MAMT1) + num(r.LAMT1) + num(r.SAMT1));
}
changed += 1;
});
return { changed };
return { changed, kept };
}
global.STMATE_UI_BOQ_TOOLS = {
seqText,
autoItnum,
treeItnum,
roundTo,
expandIlwi,
roundQty,
+1 -1
View File
@@ -459,7 +459,7 @@
// 구성표(bdqty_rows)는 기본 저장 묶음이 함께 보냄(stc_save_extra.save_bdqty)
return { costn_struct, costn_deleted: d.costn_deleted || [], boq_code_rows };
}
const needsUiSave = (d) => Boolean(d && d.ui_dirty);
const needsUiSave = (d) => Boolean(d && (d.ui_dirty || d.rate_dirty));
global.STMATE_UI_CODE = {
CODE_RE,
+5
View File
@@ -17,6 +17,8 @@
};
const codeOf = (r) => txt(r && r.CODE);
const OWN = { M: "MAMT", L: "LAMT", S: "SAMT" };
// 보호표시(wM_Boxa 「보호」 열 = 기초·견적단가 UC) — 일괄 손질에서 건너뜀(ui_dan.isProtected 와 같은 판정)
const isProtected = (r) => /^[MLSW]/.test(codeOf(r)) && txt(r.UC) === "T";
// 반올림·절사 — 소수 d 자리(음수 = 10^-d 원 단위)
function roundTo(v, d, mode) {
@@ -51,6 +53,7 @@
const c = codeOf(r);
if (!c || !opt.kinds.includes(c[0]) || (only && !only.has(c))) return;
if (num(r.NAGCD) > 0 && (Number(opt.from) === 2 || Number(opt.to) === 2)) return;
if (isProtected(r) && !opt.force) return;
const src = slotVals(r, opt.from);
const T0 = src.M + src.L + src.S;
if (opt.skipZero && !(T0 > 0)) return;
@@ -81,6 +84,7 @@
(rows || []).forEach((r) => {
const c = codeOf(r);
if (!c || !opt.kinds.includes(c[0]) || (only && !only.has(c))) return;
if (isProtected(r)) return;
const cur = slotVals(r, 1);
const curT = cur.M + cur.L + cur.S;
const base = num(r.AMT_61) || curT;
@@ -310,6 +314,7 @@
}
global.STMATE_UI_CODE_AMT = {
isProtected,
roundTo,
chgAmt,
halAll,
+62
View File
@@ -0,0 +1,62 @@
/**
* STMATE w 8.0 — 단가 성분 검사 (ui_code_check.js) · 화면·DOM 없음(node 시험 가능)
* 근거: 지식DB STmate/old/20_분석 10 §3(검증 조건식 `TAMT1 <> MAMT1 + LAMT1 + SAMT1` 세 모듈 · TAMT = 총계)
* · 코드종류별 성분 — L → LAMT = TAMT · M → MAMT = TAMT · S → SAMT = TAMT · 복합/W → TAMT = M + L + S
* 차수 칸 1~4 마다 · 내역(BOQ) 단가코드 행은 단가 칸(TAMTn)과 금액 칸(T_AMTn) 둘 다
*/
(function (global) {
"use strict";
const txt = (v) => String(v == null ? "" : v).trim();
const num = (v) => {
const n = parseFloat(txt(v).replace(/,/g, ""));
return Number.isFinite(n) ? n : 0;
};
const OWN = { M: "M", L: "L", S: "S" };
const STRUCT = /^(T\d|E\d|C\d|:)$/;
const near = (a, b) => Math.abs(a - b) < 1e-6;
// 한 칸 무리 검사 — pre(단가 "" · 금액 "_")
function checkSlot(r, code, s, pre, where) {
const out = [];
const f = (k) => `${k}${pre}AMT${s}`;
if (!(f("T") in r)) return out;
const v = { M: num(r[f("M")]), L: num(r[f("L")]), S: num(r[f("S")]), T: num(r[f("T")]) };
if (!near(v.T, v.M + v.L + v.S))
out.push({ where, code, slot: s, rule: "sum", msg: `${f("T")} ≠ 재+노+경`, v });
const own = OWN[code[0]];
if (own && ["M", "L", "S"].some((k) => k !== own && v[k] !== 0))
out.push({ where, code, slot: s, rule: "own", msg: `${own} 코드에 다른 비목 금액`, v });
return out;
}
/** COSTN 행 — 차수 칸 1~4 · [{ where, code, slot, rule: sum|own, msg, v }] */
function checkComponents(rows) {
const out = [];
for (const r of rows || []) {
const c = txt(r.CODE).toUpperCase();
if (!c) continue;
for (const s of [1, 2, 3, 4]) out.push(...checkSlot(r, c, s, "", "COSTN"));
}
return out;
}
/** 내역 단가코드 행 — 단가(TAMTn) · 금액(T_AMTn) · % 줄은 금액 칸만 */
function checkBoq(rows) {
const out = [];
for (const r of rows || []) {
const c = txt(r.BDCODE1).toUpperCase();
if (!c || STRUCT.test(c)) continue;
const pct = txt(r.UNIT1).endsWith("%");
for (const s of [1, 2, 3, 4]) {
if (!pct) out.push(...checkSlot(r, c, s, "", `BOQ#${r._recno}`));
out.push(...checkSlot(r, c, s, "_", `BOQ#${r._recno}`));
}
}
return out;
}
const api = { checkComponents, checkBoq };
global.STMATE_UI_CODE_CHECK = api;
if (typeof module !== "undefined" && module.exports) module.exports = api;
})(typeof window !== "undefined" ? window : globalThis);
+19 -1
View File
@@ -4,6 +4,10 @@
* · 22_UI_폼_분석 §3.1 wM_Edt_Hal(적용단가 = 기준단가 × % · 소수 자리 미만 절사/반올림 · 적용단가 0원 제외)
* · STC_필드사전_핵심(AMT_11~51 후보 5칸 · PG_1 = 채택 출처 · PG_11~51 페이지) · Start8x.Ini JMOK1~5·JMK10~50·JEMNM
* 그림코드 = I 코드(DFM uM_ChgCode·uM_DelCode·wM_Boxa 의 Tab_I 「그림코드(Ctrl+I)」 · Act_Sel_I) — 실자료 표본에는 없음
* 보호표시 = wM_Boxa 단가 격자 「보호」 열(√/공란 = True/False · 논리 칸) → 기초·견적단가(M·L·S·W) 행의 COSTN UC 로 읽음
* · 일괄 손질(가격지 채택·기준단가 수정·일괄할증·일괄변경)은 보호 행을 건너뜀(22 §3.1 안내문)
* · 보호해제시 적용단가 자동할증(Chk_Auto_Hal) = 풀 때 그 행에만 일괄할증을 다시 걺
* · B·D 행의 UC 는 산근 편집기 「결과값에 낙찰율적용」(Ck_UC) 자리 — 실무 7건 UC=T ⇔ NAGCD>0 이라 보호로 보지 않음
*/
(function (global) {
"use strict";
@@ -32,6 +36,17 @@
];
const OWN = { M: "MAMT", L: "LAMT", S: "SAMT" };
const isBase = (code) => /^[MLS]/.test(code);
const isProtected = (r) => /^[MLSW]/.test(codeOf(r)) && String(r.UC || "").trim() === "T";
// 보호 켜기·끄기 — 끌 때 autoHal(일괄할증 설정)이 있으면 그 행만 다시 할증(STMATE_UI_CODE_AMT.halAll)
function setProtect(r, on, o) {
if (!/^[MLSW]/.test(codeOf(r))) return null;
r.UC = on ? "T" : "F";
const UA = global.STMATE_UI_CODE_AMT;
if (on || !(o && o.autoHal) || !UA) return { code: codeOf(r), on: Boolean(on), hal: null };
const kind = codeOf(r)[0];
const hal = UA.halAll([r], Object.assign({}, o.autoHal, { kinds: kind, codes: [codeOf(r)] }));
return { code: codeOf(r), on: false, hal: hal[0] || null };
}
// 가격지 이름 — 프로젝트 초기값(JMOK1~5 · 줄임 JMK10~50 · 표시 차례 JEMNM)
function slotNames(ini) {
@@ -109,7 +124,7 @@
function adopt(r, how, o) {
const opt = Object.assign({ skipZero: true }, o || {});
const c = codeOf(r);
if (!isBase(c) || how === "none") return null;
if (!isBase(c) || how === "none" || isProtected(r)) return null;
const cand = candidates(r);
let slot = Number(how),
v = cand[slot - 1];
@@ -168,6 +183,7 @@
rows.forEach((r) => {
const c = codeOf(r);
if (!c || !isBase(c) || !opt.kinds.includes(c[0]) || (only && !only.has(c))) return;
if (isProtected(r)) return;
const cur = appliedPrice(r);
if (!(cur > 0)) return;
const s = adoptedSlot(r),
@@ -216,6 +232,8 @@
num,
codeOf,
isBase,
isProtected,
setProtect,
slotNames,
candidates,
pages,
+24
View File
@@ -197,3 +197,27 @@ table.dbgrid tr.boq-new td.rowhead {
font-size: 14px;
color: var(--text-sub);
}
/* 산출근거 문법 강조(scr_edit_lang highlight · 원본 TWM_EDIT_SAN Syn1) — 주석 · Text · 단가코드 · 계표시 · 숫자 · 함수 */
.syn-cmt {
color: #15803d;
font-style: italic;
}
.syn-txt {
color: #9a3412;
}
.syn-code {
color: #1d4ed8;
font-weight: 700;
}
.syn-mark {
color: #7e22ce;
font-weight: 700;
}
.syn-num {
color: #0f766e;
}
.syn-key {
color: #1e3a8a;
font-weight: 700;
}
+48
View File
@@ -222,7 +222,55 @@
return a.join("");
}
/**
* 시간당 노임산출계수 적용코드(wM_Set_JLab · Grd_BDLst → Grd_CONLTD) — 노무비(L) 코드 목록 고르기
* conKey = CONLD|CONTD|CON1D~4D · 짝 환산식 = RXXNM|RXTNM|RX1NM~4NM(빈 칸이면 RXXNM)
* 적용단가 = 일 노임(LAMT1) × 환산식(왼쪽부터) · JCDNM 자리 미만 버림(smt_eval.hourlyWage)
* 돌려줌 { formula, target: [{code,name,size,unit,price,hourly}], source: [고를 수 있는 L 코드 행] }
*/
const rxOf = (conKey) =>
conKey === "CONLD" ? "RXXNM" : (RX_SETS.find((x) => x.con === conKey) || {}).rx || "RXXNM";
function jlab(costnRows, ini, conKey, hourly) {
const i = ini || {};
const formula = String(i[rxOf(conKey)] || i.RXXNM || "").trim();
const digits = Number(String(i.JCDNM || "0").trim()) || 0;
const by = new Map((costnRows || []).map((r) => [String(r.CODE || "").trim(), r]));
const list = splitCodes(i[conKey]);
const rowOf = (c) => {
const r = by.get(c) || {};
const price = Number(r.LAMT1) || 0;
const h = hourly ? hourly(price, formula, digits) : null;
return {
code: c,
name: r.NAME || "",
size: r.SIZE || "",
unit: r.UNIT || "",
price,
hourly: h,
};
};
const inList = new Set(list);
const source = (costnRows || [])
.map((r) => String(r.CODE || "").trim())
.filter((c) => /^L\d{5}$/.test(c) && !inList.has(c))
.map(rowOf);
return { formula, target: list.map(rowOf), source };
}
// 선택코드 추가·삭제 — 차례 유지 · 겹침 없음 · 결과 = 이어 붙인 글(Start8x.Ini 값)
function jlabEdit(cur, add, del) {
const gone = new Set((del || []).map((c) => String(c).trim().toUpperCase()));
const out = splitCodes(cur).filter((c) => !gone.has(c));
for (const c of add || []) {
const k = String(c).trim().toUpperCase();
if (/^[A-Z]\d{5}$/.test(k) && !out.includes(k)) out.push(k);
}
return joinCodes(out);
}
global.STMATE_UI_SYS = {
jlab,
jlabEdit,
rxOf,
FIELDS,
RX_SETS,
XML_PRESET,