Merge remote-tracking branches 'origin/CODEX' and 'origin/main_laptop_1' into sub_laptop_3
This commit is contained in:
@@ -1 +1,3 @@
|
||||
/data/
|
||||
monitor.log
|
||||
monitor.pid
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
- [x] 산출근거 주석 지정/해제(`popMainSetStr`) 및 블럭 삭제(`popMainBlockDel`) 구현
|
||||
- 자체 검증:
|
||||
1) 산출근거 주석 지정/해제 (`popMainSetStr`, 0xf08b1c):
|
||||
- REA `TFRMCOST`/`TFRMESTBASE`의 팝업 메뉴 `popMainSetStr` / `mnuSetGuSuc` ("주석으로 지정", Ctrl+J, 0xf08b1c) 스펙 근거 기반 구현.
|
||||
- 단가산출(`CostMainDat`) 화면의 산출근거 행에서 `Index`를 `-1`(주석)로 지정하거나 원래 `Index`(0)로 복원.
|
||||
- 주석 지정 시 `_origIndex: curIndex`를 명시적으로 보존하고, `Index === -1`로 전환. 주석 해제 시 `_origIndex` 또는 0으로 안전하게 복원하고 임시 속성 제거.
|
||||
- 호표 제목 행(`Index > 0`) 또는 소계 구분선(`Index === -20`)은 주석으로 지정할 수 없도록 안전 가드 적용.
|
||||
- 다중 선택 시 제목 행이나 비산출근거 행이 포함되면 전체 변경을 중단하고 원자성(Atomic) 보장.
|
||||
- `native-text.js`에서 `Index === -1`인 행은 소계(`LinkIndex: 61`) 계산 시 `if (num(previous.Index) !== -1)` 조건에 따라 자동으로 합산에서 제외되므로 산출식 수치 무결성 완벽 보장.
|
||||
2) 블럭 삭제 (`popMainBlockDel`, 0xf08adc):
|
||||
- REA `TFRMCOST`/`TFRMHEAD`/`TFRMMACH`/`TFRMEST` 전역의 `popMainBlockDel` ("블럭삭제", Ctrl+Del, 0xf08adc) 스펙 근거 기반 구현.
|
||||
- `EstBlockActions.deleteBlockRows(state)`: 선택된 범위/블럭의 행들을 일괄 삭제.
|
||||
- 호표 제목 행(`Index > 0`)이 포함된 경우 호표 전체 행을 일괄 삭제 범위로 확장.
|
||||
- 다른 항목(`EstMainDat`, `HeadMainDat` 등)에서 참조 중인 코드가 포함된 경우 "다른 항목에서 사용 중입니다. 연결을 먼저 해제하세요."로 안전 차단.
|
||||
- 삭제 완료 후 선택 해제 및 블럭 시작/끝 상태 초기화.
|
||||
3) UI 연동 및 700줄 제한 엄수:
|
||||
- `native-block-actions.js`에 `deleteBlockRows` 및 `toggleCostComment`를 모듈화 배치하여 기존 700줄 한도에 도달한 `native-actions.js`(699줄), `native-extra.js`(697줄) 코드 변경 없이 파일 크기 제한 완벽 준수 (`native-block-actions.js`: 185줄).
|
||||
- `index.html` 편집(E) 메뉴에 `블럭 삭제`(`data-action="block-del"`) 및 `산출근거 주석 지정/해제`(`data-action="toggle-cost-comment"`) 등록.
|
||||
- 관련 테스트:
|
||||
- `pytest resources/tester/test_est_row_actions.py` 7개 전수 통과:
|
||||
- `test_toggle_cost_comment_and_block_delete`: 산출근거 행 주석(-1) 지정 및 `_origIndex` 보존, 0으로의 복원 및 임시 속성 제거, 호표 제목 및 구분선 지정 거부, 다중 선택 원자성 보장, 블럭 행 일괄 삭제 및 참조 중인 호표 삭제 차단 검증 완료.
|
||||
- `test_block_actions_range_selection_and_clear`: 블럭 시작/끝 지정, 범위 선택, 전체 선택, 블럭 해제 및 예외 검증 완료.
|
||||
- `pytest resources/tester/test_est_row_actions.py resources/tester/test_est_estimate.py resources/tester/test_est_business.py`: 29 passed in 21.24s.
|
||||
|
||||
- [x] 단가산출 산출근거 비목 지정/해제(`popMainSetNC/GC/JC/TC/BimogFree`) 구현 및 순공사비·경비성 안전 가드 보강
|
||||
- 자체 검증:
|
||||
1) 순공사비·경비성 토글 안전 가드 및 원자적 일괄 검증 보강:
|
||||
- **명시적 0/1/2 이력 보존 및 복원**: `toggleJcBimog`에서 경비성(3) 전환 시 원래 `SCheck`(0:비목별, 1:재료, 2:노무)을 `_prevSCheck`에 명시적으로 보존. 해제 시 해당 이력 또는 원본 `SCheck_`가 존재하는 경우에만 정확한 이전 값으로 복원.
|
||||
- **이력 없는 경비성 3 임의 변환 차단**: `_prevSCheck` 및 `SCheck_`가 없는 기존 3 행이나 원래 유형을 알 수 없는 행의 임의 0 변환을 안전하게 차단.
|
||||
- **다중 선택 원자성(Atomic) 보장**: 복수 행 선택 토글 시 사전 검증 계획(`plans`)을 수립하여 한 행이라도 거부 대상이 포함되면 전체 변경을 중단하고 앞선 행도 변경되지 않도록 원자적 일괄 적용 보장.
|
||||
- **JSON 저장 및 재열기 왕복 보증**: JSON 직렬화/역직렬화 후에도 `_prevSCheck` 및 `SCheck_`가 보존되어 당초 SCheck(0/1/2)로 100% 온전하게 복원됨을 검증.
|
||||
- 원본부터 순공사비(54)인 행의 해제 시도나 대체 시도 역시 `_origLinkIndex`나 `LinkIndex_` 등 원래 유효 유형이 확인되지 않으면 오류로 차단하고 기존 행 속성 보존.
|
||||
2) 단가산출 산출근거 비목 지정/해제 (`popMainSetNC`, `popMainSetGC`, `popMainSetJC`, `popMainSetTC`, `popMainSetBimogFree`):
|
||||
- REA `TFRMCOST`/`TFRMESTBASE`의 팝업 메뉴 비목 지정(`0xf0c0f0`, `0x10d815c`) 스펙 근거 기반 구현.
|
||||
- 단가산출(`CostMainDat`) 화면에서 산출근거 행의 `OCheck` 및 `SCheck`를 노무비(-1), 재료비(-2), 경비(-3), 소계(-5)로 지정하거나 일반 산출식(0)으로 해제.
|
||||
- UI 연동: 편집(E) 메뉴에 `산출근거 비목 지정/해제`(`data-action="set-cost-line-kind"`) 등록 및 `EstActions.form` 대화상자 지원.
|
||||
3) 700줄 제한 엄수:
|
||||
- `native-business-actions.js`: 643줄, `native-extra.js`: 696줄, `index.html`: 211줄, `resources/tester/test_est_row_actions.py`: 422줄.
|
||||
- 관련 테스트:
|
||||
- `pytest resources/tester/test_est_row_actions.py` 5개 전수 통과:
|
||||
- `test_toggle_safety_guards_prevent_arbitrary_zero_conversion_without_history`: 복원 정보 없는 원본 54 행 해제/대체 차단 및 원본 경비성(3) 임의 0 변환 차단 검증.
|
||||
- `test_jc_bimog_multi_selection_atomic_and_json_roundtrip`: 다중 선택 시 거부 행 발생 시 앞선 행 변경 롤백(원자성), SCheck=0 지정/해제 및 JSON 재열기 왕복 복원 검증.
|
||||
- `test_set_cost_line_kind_action`: 산출근거 행의 노무비(-1) 지정, 재료비(-2)의 일반 해제(0) 및 타 화면 안전 거부 검증.
|
||||
- `pytest resources/tester/test_est_row_actions.py resources/tester/test_est_estimate.py resources/tester/test_est_business.py`: 27 passed in 19.26s.
|
||||
|
||||
- [x] 데이터 사용위치 검색(`popUsesSerch` / `TFRMUSESDATASERCH`) 구현
|
||||
- 자체 검증:
|
||||
1) REA 스펙 근거:
|
||||
- REA `TfrmUsesDataSerch` (0x10d819c) 및 `popUsesSerch`(내역서, 일위대가, 단가산출, 기계경비 전역 팝업 메뉴) 기반 구현.
|
||||
- 임의의 품목 행(자재, 노임, 견적, 표준, 일위대가, 단가산출, 기계경비, 내역서)에서 `데이터 사용위치 찾기` 실행 지원.
|
||||
2) `EstExtra.findDataUsages(project, target)` 검색 엔진 구현:
|
||||
- 대상 항목의 `Code`(우선) 또는 정규화된 `Name`+`Size`를 기준으로 내역서(`EstMainDat`), 일위대가(`HeadMainDat`), 단가산출(`CostMainDat`), 기계경비(`MachMainDat`)의 참조 행을 전수 검색.
|
||||
- 대상 항목 자기 자신의 대표 행은 검색 결과에서 안전하게 제외.
|
||||
- 일위/단산/기계의 경우 상위 공종 코드(`curGroup`)를 함께 추적하여 위치 이동 시 정확한 묶음(`state.group`)으로 진입 지원.
|
||||
3) UI 연동 및 대화상자 이동 기능:
|
||||
- `index.html` 상단 툴바에 `사용위치` 버튼(`data-action="uses-data-search"`) 및 편집 메뉴에 `데이터 사용위치 찾기` 추가.
|
||||
- `EstExtra.showUsesSearchDialog(targetRow)`를 통해 검색된 위치 목록(구분, 코드, 품명/산출근거, 규격, 단위, 수량, 금액) 표시.
|
||||
- `이동`(`goto-use-location`) 클릭 시 대화상자를 닫고 대상 테이블(`navigate`), 대상 묶음 그룹(`state.group`), 페이지(`state.page`), 선택 행(`state.selection`)으로 즉시 포커스 이동.
|
||||
4) 700줄 제한 엄수:
|
||||
- `native-extra.js`: 554줄, `native-business-actions.js`: 685줄, `native-views.js`: 140줄, `index.html`: 210줄.
|
||||
- 관련 테스트:
|
||||
- `pytest resources/tester/test_est_row_actions.py` 통과 (`test_uses_data_search_finds_usages_across_tables`: 자재 M001의 일위/단산/내역 4건 검출 및 그룹 보존, 일위 U001의 내역 1건 검출, 이름 일치 항목 검출, 미사용 항목 0건 반환 검증 완료).
|
||||
- `pytest resources/tester/test_est_row_actions.py resources/tester/test_est_estimate.py` 6개 전수 통과 (0 실패).
|
||||
|
||||
- [x] 일위대가 공종 할증계산(`TFRMHALJUNGBIUL`) 및 자재단가 부가세계산(`TFRMCONVERTTOGANGUB`) 구현
|
||||
- 자체 검증:
|
||||
1) 일위대가 공종 할증계산 (`TFRMHALJUNGBIUL`):
|
||||
- REA `TFRMHEAD`의 `DoHalJungClick` (0xf5b7fc) 및 `TFRMHALJUNGBIUL`(명칭, 불러올비목, 적용율, 적용비목, 0xdb4b14) 근거 기반 구현.
|
||||
- [native-haljung.js](file:///D:/aislo-wt/CODEX/tools/est-test/native-haljung.js)의 `EstHalJung.apply(project, tableKey, groupCode, { name, size, rate, sourceKind, targetKind })`:
|
||||
- 불러올비목(규격): 0=비목별, 1=재료비의, 2=노무비의, 3=경비의, 4=합계의 (`OCheck`).
|
||||
- 적용비목: 0=비목별, 1=재료비에, 2=노무비에, 3=경비에 (`SCheck`).
|
||||
- **비목 매핑 정확성 보장**: `sourceKind`가 특정 비목(1=재료비, 2=노무비, 3=경비)이고 `targetKind = 0`(비목별)인 경우, 기본 경비(`JC`)로 왜곡되지 않고 해당 원천 비목(`SCheck = sInfo.ocheck`)으로 정확히 가산되도록 보정.
|
||||
- 묶음 내 소계(`LinkIndex = 51, 68`) 직전 위치에 `LinkIndex = 58`(비율) 행을 안전하게 자동 삽입하고 즉시 재계산 반영.
|
||||
- 0 또는 잘못된 할증율 및 존재하지 않는 공종/화면 요청 안전 거부.
|
||||
- UI 연동: `native-views.js`에서 일위대가 화면 활성화 시 `할증계산` 버튼(`data-action="haljung-calc"`) 노출 및 대화상자 연동.
|
||||
2) 자재단가 부가세계산 (`TFRMCONVERTTOGANGUB`):
|
||||
- REA `TFRMSTUFFC`의 팝업 `popChangeCost` 및 `TFRMCONVERTTOGANGUB`(부가세포함/제외, 단단위계산, 0xf616f0) 근거 기반 구현.
|
||||
- [native-vat.js](file:///D:/aislo-wt/CODEX/tools/est-test/native-vat.js)의 `EstVat.apply(project, indices, { calcMethod, diget, digetMethod })`:
|
||||
- 계산방법: 0 = 부가세포함(×1.1), 1 = 부가세제외(÷1.1).
|
||||
- 단단위계산: 원단위 또는 지정 자리수(1=십원, 2=백원..) 이하에 대해 사사오입/절하/절상 단단위 처리.
|
||||
- 근거 불충분한 `row.Kind` 변경 기능 비활성화: REA 스펙상 `StuffcDat`에 `Kind` 필드가 소비되지 않으므로 혼선 방지를 위해 자재구분 변경 옵션 제거 및 안전 처리.
|
||||
- 선택 행 또는 전체 자재단가(`Cost1~6`, `InputTC`, `TC`) 일괄 변환 및 Undo 지원. `ValueIndex`별(최저/평균/최고/선택/직접) 단가 및 재계산 후 원본 단가 보존 검증 완료.
|
||||
- UI 연동: `native-views.js`에서 자재단가 화면(`StuffcDat`) 활성화 시 `부가세계산` 버튼(`data-action="vat-calc"`) 노출 및 대화상자 연동.
|
||||
3) 700줄 제한 엄수:
|
||||
- `native-haljung.js`: 91줄, `native-vat.js`: 58줄, `native-business-actions.js`: 593줄, `native-views.js`: 140줄.
|
||||
- 관련 테스트:
|
||||
- `pytest resources/tester/test_est_business.py` 18개 통과 (`test_haljung_calculation_apply_to_head_and_cost_groups`, `test_vat_calculation_exclude_include_and_unit_rounding_for_materials` 검증 보강 통과).
|
||||
- 핵심 EST 테스트 45개 전수 통과 (0 실패).
|
||||
|
||||
- [x] 일위대가 공종 단위환산 계산(`TFRMUNITECALC`) 및 낙찰률 당초단가(100%) 복원(`TFRMBIMOGBIUL`/`TFRMNACCHALAPPLY`) 구현
|
||||
- 자체 검증:
|
||||
1) 일위대가 공종 단위환산 계산 (`TFRMUNITECALC`):
|
||||
- REA `TFRMHEAD`의 팝업/도구 버튼 `popUniteCalc` (`단위환산 계산`, 0xf59bd8) 및 `TFRMUNITECALC`(명칭, 규격, 수량, 단위) 근거 기반 구현.
|
||||
- `native-business.js`에 `convertGroupUnit(project, groupCode, { name, size, factor, unit })` 함수 구현:
|
||||
- 선택한 일위대가 공종(묶음)의 제목 행(`Index > 0`)에 명칭(`Name`), 규격(`Size`), 단위(`Unite`) 적용.
|
||||
- 특수행 제외: 비율 행(`LinkIndex=58, 62`), 낙찰률 행(`LinkIndex=52`), 소계 행(`LinkIndex=51, 68`) 및 계산 안 함/고정금액 행(`CalcIndex=1, 3`)은 수량(`GN`) 배수 변경에서 안전하게 제외하여 백분율 왜곡 및 중복 팽창 방지.
|
||||
- 일반 비목 행의 수량(`GN`)에만 환산 계수(`factor`)를 부동소수점 곱으로 적용.
|
||||
- `AutoCalc: false` 등 프로젝트 옵션 보존 및 0 또는 유효하지 않은 계수 거부.
|
||||
- UI 연동: `index.html` 및 `native-views.js`에서 일위대가 화면(`HeadMainDat`) 활성화 시 `단위환산` 버튼(`data-action="unit-calc"`) 자동 노출.
|
||||
- 대화상자(`EstActions.form`)를 통해 사용자 입력 접수 후 `EstApp.change()`로 트랜잭션 및 Undo 지원.
|
||||
2) 낙찰률 당초단가(100%) 복원 (`TFRMBIMOGBIUL`, `TFRMNACCHALAPPLY`):
|
||||
- REA `btnUndo` (Hint: "당초단가(100%)로 복원합니다.") 근거 구현.
|
||||
- `native-business.js`의 `bid(project, rates)`:
|
||||
- 연속 낙찰률 적용 시에도 최초 단가와 최초 옵션을 잃지 않도록 `row.PreBidRates`가 이미 존재하면 덮어쓰지 않고 최초 단가 유지.
|
||||
- `O${field}`도 최초 단가(`PreBidRates[field]`)를 기준으로 낙찰률을 계산하여 다회차 적용 시의 누적 왜곡 방지.
|
||||
- 원본 프로젝트의 `SourceRefresh` 설정값은 `next.options._preBidSourceRefresh ??= project.options.SourceRefresh`로 최초 1회만 보존.
|
||||
- `restoreBid(project)`:
|
||||
- 원래의 `SourceRefresh`가 `true`이더라도 재계산 도중 원천 단가로 복원 단가가 다시 덮이지 않도록, `SourceRefresh: false` 상태로 복원 재계산 수행 후 마지막에 원래 사용자의 `SourceRefresh` 옵션 상태를 안전하게 복원.
|
||||
- `PreBidRates` 및 생성된 낙찰 단가를 복원하고, 원본 파일에 이미 존재하던 `O*` 필드(`OTU/ONU/OGU/OJU/OSU`)는 임의 삭제하지 않고 영구 보존.
|
||||
- 저장·재열기(JSON 왕복), 수동/자동 계산 모드, 원천 단가와 다른 초기 단가 상태에서도 최초 단가 100% 온전 복원 확인.
|
||||
- 설계변경 화면 도구 모음에 `당초단가(100%) 복원`(`restore-bid`) 버튼 연동.
|
||||
3) 700줄 제한 엄수:
|
||||
- `native-business.js`: 692줄, `native-business-actions.js`: 505줄, `native-actions.js`: 699줄, `native-views.js`: 136줄.
|
||||
- 관련 테스트:
|
||||
- `pytest resources/tester/test_est_business.py` 16개 통과 (`test_unit_calc_group_conversion_preserves_percentages_subtotals_fixed_and_other_groups` 포함).
|
||||
- `pytest resources/tester/test_est_change_workflow.py` 15개 통과 (`test_bid_rates_consecutive_apply_source_refresh_and_roundtrip_restore` 추가 통과).
|
||||
- 핵심 EST 테스트 43개 전수 통과 (0 실패).
|
||||
|
||||
- [x] 자료 오류 검색(issues) 및 불일치 데이터 확인: REA `TFRMNAMESIZECHECK`(명칭·규격·단위 불일치) 및 `TFRMPERSENTCHECK`(단위 백분율(%) 불일치 데이터 검색) 검출 구현
|
||||
- 자체 검증:
|
||||
1) REA `TFRMNAMESIZECHECK`(명칭 불일치 및 중복 데이타 확인) 근거 기반 검출 및 동기화 구현:
|
||||
- `native-issues.js`의 `scan()`에서 기존 품명(`Name`), 규격(`Size`) 비교에 더해 **단위(`Unite`)** 불일치(`연결 단위 차이`) 검출 로직 추가.
|
||||
- 원천 데이터(자재 `StuffcDat`, 노임 `LaborcDat`, 표준 `SilJukDat`, 견적 `EstiMateDat`, 일위 `HeadMainDat`, 단가 `CostMainDat`, 기계 `MachMainDat`)의 실제 명칭·규격·단위(`sourceName`, `sourceSize`, `sourceUnite`)를 이슈 항목에 보존.
|
||||
- UI 렌더링에 `원천 실제 명칭` 표시 및 `원천명칭 반영`/`원천단위 반영` 개별 버튼(`data-issue-sync-row`), 상단 도구 모음에 `불일치 원천 명칭/규격 일괄 반영` 버튼(`data-issue-sync-all`) 제공.
|
||||
2) REA `TFRMPERSENTCHECK`(단위 백분율(%) 불일치 데이터 검색) 근거 기반 검출 추가:
|
||||
- REA `TFRMPERSENTCHECK` ("위의 데이타는 백분율(%)로 계산되어야 할 항목들입니다(할증, 낙찰율, 제잡비, 조달수수료 등). 그러나 단위가 %가 아니기 때문에 백분율로 계산하지 않습니다. 확인하세요.") 근거 구현.
|
||||
- 일위대가(`HeadMainDat`), 내역서(`EstMainDat`), 기계경비(`MachMainDat`), 단가산출(`CostMainDat`)의 비목 상세 행 중 백분율 연산 특수행(`LinkIndex = 52, 57, 58, 62, 67`)인데 단위(`Unite`)가 `%` 또는 `%`가 아닌 항목을 검출(`단위 백분율(%) 불일치`).
|
||||
3) 불일치 필드별 안전 반영 및 단위 왜곡·규격 삭제 방지:
|
||||
- 단위(`Unite`)는 수량 및 단가 산식과 직결되므로 일괄 반영(`sync-all`) 대상에서 완전히 제외하고, 개별 행에서 명시적 승인(`원천단위 반영`) 시에만 처리하도록 분리.
|
||||
- 규격(`Size`)은 원천 규격이 비어 있지 않은 유효 문자열일 때만 반영하여, 빈 원천 규격으로 기존 내역 규격을 덮어써 지우는 현상 방지.
|
||||
- 품명(`Name`) 불일치는 품명만 원천 값으로 안전하게 동기화하고 기존 규격·단위를 보존.
|
||||
- 일괄 반영 범위는 전체 스캔 결과가 아니라 화면의 현재 필터 조건(`tableFilter`, `typeFilter`)에 해당하는 `currentFiltered` 항목만 대상으로 한정.
|
||||
- 반영 시 `EstApp.change()` 트랜잭션을 통해 실행 취소(Undo) 지원 및 데이터 무결성 보존.
|
||||
4) 700줄 제한 준수:
|
||||
- `native-issues.js` 372줄로 엄수.
|
||||
- 관련 테스트:
|
||||
- `pytest resources/tester/test_est_issues.py` 12개 통과 (`test_data_issues_detect_percentage_unit_mismatch` 추가).
|
||||
- 브라우저 Undo 복원 및 원천 단위/규격 안전 처리 검증 완료.
|
||||
|
||||
- [x] 인쇄 서식 옵션 검토 및 안전 조치: `PrintApplyField`(단가 0원 누락 위험 방지) 및 `PrintSumBottom`(공종/소계 중복 합산 및 단가 합산 왜곡 차단) 안전 처리
|
||||
- 자체 검증:
|
||||
1) 결함 분석 및 안전 조치:
|
||||
- `PrintSumBottom`: 표시되는 모든 행을 단순 누적할 경우 공종/소계와 상세 항목이 중복 합산되어 실제 공사금액의 2배 이상으로 뻥튀기되고, 단위가 다른 수량 및 단가(TU)를 합산하는 논리적 왜곡이 발생함.
|
||||
- `PrintApplyField`: REA의 적용 단가 필드 표시 의도를 '단가 0인 행 필터링'으로 오해하여 삭제할 경우, 공종 제목 및 정상적인 0원 항목·음수 정산 항목이 누락되는 중대 위험이 발생함.
|
||||
- REA 원문의 정확한 세부 집계/필터 알고리즘 근거가 불충분하므로, 서식 데이터의 왜곡 및 행 삭제를 즉시 차단하고 미지원 처리.
|
||||
2) 코드 정리 및 데이터 무결성 보존:
|
||||
- `native-forms.js`: `PrintApplyField`에 의한 행 삭제 필터 및 `PrintSumBottom`에 의한 단순 `<tfoot>` 합산 렌더링을 완전히 제거하고, 모든 공종(Level>0), 소계, 0원 항목, 음수 변경분 행이 원본 그대로 보존되도록 복원.
|
||||
- `native-report.js`: 불안정한 체크박스 UI 노출을 비활성화하고, 공람용 옵션 등 검증된 서식 기능만 안정적으로 제공.
|
||||
- 바이너리 레코드 오프셋(`import-print.cjs` / `export-print.cjs`)은 데이터 무결성을 위해 안전하게 0/1 값을 읽고 쓸 수 있도록 유지하되 잘못된 화면 처리를 차단.
|
||||
3) 700줄 제한 준수:
|
||||
- `native-report.js` 352줄, `native-forms.js` 445줄, `export-print.cjs` 67줄, `import-print.cjs` 75줄로 엄수.
|
||||
- 관련 테스트:
|
||||
- `pytest resources/tester/test_est_documents.py` 16개 통과 (`test_print_apply_field_and_sum_bottom_preserve_all_rows_and_no_destructive_filtering` 추가).
|
||||
- 전체 연계 회귀 시험 54개 전수 통과 (0 실패).
|
||||
|
||||
- [x] 공람용 서식(내역서 117, 내역서총괄표 118, 일위대가 115, 일위대가총괄표 116) 인쇄 옵션 및 공람방법(수량만/단가만/수량·단가) 렌더링 구현
|
||||
- 자체 검증:
|
||||
1) REA `TFRMESTPRINTPRIVATE` 및 `DatPrintOption.opt`의 `PrintGongMethod` 설정 연동:
|
||||
- 인쇄 미리보기(`native-report.js`) 서식 선택 시 공람용 서식(115, 116, 117, 118)에 대해 `공람용 방법` 셀렉트(`PrintGongMethod`: 0=수량만, 1=단가만, 2=수량·단가) 노출 및 설정 동기화.
|
||||
2) `native-forms.js` 열 정의 및 마스킹 로직 구현:
|
||||
- 공람용 내역서(`117`) 및 일위대가(`115`):
|
||||
- `PrintGongMethod = 0`(기본값, 수량만): 단가 및 금액 열(TU, TC, NC, GC, JC, SC / NU, GU, JU, SU, TC)을 숨기고 공통정보 및 수량(GN)만 인쇄.
|
||||
- `PrintGongMethod = 1`(단가만): 수량(GN)을 숨기고 단가 및 금액 열만 인쇄.
|
||||
- `PrintGongMethod = 2`(수량·단가): 수량과 단가/금액을 모두 인쇄.
|
||||
- 공람용 총괄표(`116`, `118`): `PrintGongMethod = 0` 시 금액 비목을 제외하고 공통 레벨·공종 열만 안전하게 출력.
|
||||
- 일반 표준 서식(`1`, `2`, `103`, `106`)은 변경 없이 기존 전체 열 출력 및 데이터 무결성 보존.
|
||||
3) 700줄 제한 준수:
|
||||
- `native-report.js` 342줄, `native-forms.js` 439줄로 엄수.
|
||||
- 관련 테스트:
|
||||
- `pytest resources/tester/test_est_documents.py` 15개 통과 (`test_public_view_forms_print_gong_method_options` 추가).
|
||||
- 전체 연계 회귀 시험 52개 전수 통과 (0 실패).
|
||||
|
||||
- [x] 계약심사(review) 화면 변경 행 필터링(전체/변경/사유작성/사유미작성) 및 심사 사유 일괄 적용 구현
|
||||
- 자체 검증:
|
||||
1) `native-review.js` 보기 필터(`review-filter`) 및 사유 일괄 적용(`review-batch-reason`) 신규 구현:
|
||||
- 계약심사 테이블 상단에 항목 필터 셀렉트(`전체 항목`, `변경 항목만`, `사유 작성 항목`, `사유 미작성 변경항목`) 추가.
|
||||
- 대규모 내역서에서 수량/단가 변경이 발생한 항목이나 사유 작성이 누락된 변경 항목만 즉시 선별하여 검토할 수 있도록 개선.
|
||||
- 개별 행 선택 체크박스(`reviewSelection`) 및 일괄 사유 적용(`data-review-batch-reason`) 지원: 선택된 행들에 대해 공통 사유를 한 번에 입력·반영하며, 선택이 없는 경우 현재 화면에 표시된 필터링 대상 행 전부에 대해 일괄 반영.
|
||||
- 심사 입력 초기화(`review-clear`) 시 선택 세트도 함께 정리되도록 상태 무결성 보존.
|
||||
- 입력된 사유는 `contractReview.edits[key].Reason`으로 안전하게 저장되며, 실행 취소(Undo) 및 심사결과요약서(서식 171) 인쇄/엑셀 내보내기 연동 보존.
|
||||
2) 모델 및 계산 무결성 검증:
|
||||
- 기존 심사 수량/단가 계산기(`EstReview.calculate`)와 사본 열기(`EstReview.copy`) 로직은 변경 없이 100% 동일하게 유지.
|
||||
3) 700줄 제한 준수:
|
||||
- `native-review.js` 258줄로 700줄 이하 엄수.
|
||||
- 관련 테스트:
|
||||
- `pytest resources/tester/test_est_review_workflow.py` 8개 통과 (`test_review_view_filtering_and_batch_reason_application` 추가).
|
||||
- 전체 연계 회귀 시험 50개 전수 통과 (0 실패).
|
||||
|
||||
- [x] 자료 오류 검색(issues)의 원가계산서(Wonga) 산출식(VSusic) 참조 유효성 검사 및 실제 계산기(calculate) 의존성/값/오류 로직 단일화 구현
|
||||
- 자체 검증:
|
||||
1) `native-wonga.js`의 계산 엔진 로직 단일화(`evaluateRows` 공통화):
|
||||
- `scan`과 `calculate`가 별도의 파서/DFS/참조 로직을 갖지 않고, 동일한 `evaluateRows` 함수를 공유하여 전방 참조, 의존성 순서, 실제 재귀 계산, 범위(CalcIndex 2, 3 제외 행 필터링), 순환 참조 감지(`active` Set) 로직을 100% 동일하게 실행.
|
||||
- 저장된 `Cost=0`이어도 수식이 있는 선행 참조 행(예: `B=A*2`)의 실제 재귀 계산 결과를 평가에 사용하여, `A=1/B`와 같은 유효한 나눗셈이 오래된 0값으로 인해 0 나눗셈으로 오탐되는 결함 원천 해결.
|
||||
- 오류 발생 시(`onError` 콜백) `scan`은 해당 원인 행을 등록하고 기존 `Cost`로 대체하여 후속 행으로의 연쇄 전파를 방지.
|
||||
2) `native-issues.js`의 실제 프로젝트 기초금액(`direct`) 전달:
|
||||
- `issues.scan(project)` 호출 시 `project.calculation.direct`로부터 실제 `NC`, `GC`, `JC`, `SC`, `TC`, `노무비`, `재료비`, `경비`, `실적`, `직접공사비`를 추출해 `EstWonga.scan`의 `bases` 인자로 전달하여 기초 비목 0 판정 및 오탐 방지.
|
||||
3) 700줄 제한 준수:
|
||||
- `native-wonga.js` 163줄, `native-issues.js` 274줄로 700줄 이하 엄수.
|
||||
- 관련 테스트:
|
||||
- `pytest resources/tester/test_est_issues.py` 9개 통과 (`test_wonga_scan_parity_with_calculate_forward_refs_old_cost_bases_ranges_and_cycles` 추가).
|
||||
- 전체 연계 회귀 시험 51개 전수 통과 (0 실패).
|
||||
|
||||
- [x] 자료 오류 검색(issues)의 단가산출(CostMainDat) 상세 행 연결 자료(자재·노임·기계 등) 부재 및 중복 검출 확장 구현
|
||||
- 자체 검증:
|
||||
1) `native-issues.js`의 `scan()` 테이블 검사 대상 확장:
|
||||
- 기존 검사 대상(`EstMainDat`, `HeadMainDat`, `MachMainDat`)에 `CostMainDat`를 추가하여, 단가산출 호표 상세 행에서 자재(`LinkIndex: 5`), 노임(`LinkIndex: 6`), 기계(`LinkIndex: 105`), 표준(`LinkIndex: 21`), 견적(`LinkIndex: 101`), 일위(`LinkIndex: 103`), 산근(`LinkIndex: 104`) 등 원천 자료를 참조하는 상세 행(`Index === 0 && Code`)에 대해 연결 자료 존재 여부(`연결 자료 없음`) 및 원천 코드 중복(`연결 코드 중복`)을 전수 검사하도록 개선.
|
||||
- 오류 항목 클릭 시 `data-issue-table="CostMainDat"`로 해당 호표(`group`) 및 상세 행으로 정확히 이동하고 선택 행을 하이라이트 표시하도록 연동 보존.
|
||||
- 관련 테스트:
|
||||
- `pytest resources/tester/test_est_issues.py` 5개 통과 (`test_data_issues_detect_cost_main_dat_missing_and_duplicate_links` 추가).
|
||||
- 전체 연계 회귀 시험 76개 전수 통과 (0 실패).
|
||||
|
||||
- [x] ESTX 내보내기 시 웹 전용 작업 데이터(예정공정표 schedule, 추가 서식 문서 documents) 누락 방지 및 기본 빈 상태 대조 차단 구현
|
||||
- 자체 검증:
|
||||
1) `export-estx.cjs` 및 `import-estx.cjs` 검토 및 수정:
|
||||
- `import-estx.cjs`에서 원본 ESTX 로드 시 기본 빈 공정표(`schedule: []`)를 명시적으로 초기화.
|
||||
- `export-estx.cjs`에서 원본 가져오기 기본 상태(`schedule: []`)와 대조하여, 유효 필드 유무와 무관하게 `project.schedule?.length > 0` (빈 문자열·0값만 가진 행, 임의 필드 행 포함) 존재 시 공정 데이터 누락 방지를 위해 JSON 저장 안내와 함께 차단(`웹 예정공정표는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.`).
|
||||
- `project.documents`의 공백 문자열(" ") 역시 사용자가 입력한 내용으로 취급하여 `text.length > 0` 검사로 누락 없이 JSON 저장 안내와 함께 차단(`웹 추가 서식 문서는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.`).
|
||||
- 원본 ESTX에서 불러온 기본 빈 상태(빈 schedule `[]`, 빈 documents `{}` 또는 미입력)에서는 정상적으로 ESTX 바이너리 패치가 통과되어 기존 기능 유지.
|
||||
- 관련 테스트:
|
||||
- `pytest resources/tester/test_est_attachments.py` 6개 통과 (일반 행, 빈 문자열/0값 행, 임의 필드 행, documents 공백 문자열 저장 차단 단위 검증).
|
||||
- `pytest resources/tester/test_est_native_export.py` 15개 통과 (`test_estx_export_schedule_and_documents_safety_guards` 실제 파일 기반 정상 패치 및 빈 행/0값/공백 문서 차단 실측 검증).
|
||||
- 전체 연계 회귀 시험 92개 통과 (0 실패).
|
||||
|
||||
- [x] 예정공정표(schedule) 공정 항목 상/하 순서 이동(`schedule-move`) 및 전체 비우기(`schedule-clear`) 조작 구현
|
||||
- 자체 검증:
|
||||
1) `native-schedule.js`에 공정 행 상/하 이동(`moveRow`) 및 전체 비우기(`clearSchedule`) 구현:
|
||||
- 연속된 공정 항목 배열(`project.schedule`)에 대해 인접 행과 위치를 맞교환하는 `moveRow(project, index, direction)` 지원.
|
||||
- 상단 경계(`index === 0, direction === -1`) 및 하단 경계(`index === length - 1, direction === 1`) 도달 시 비활성화(`disabled`) 및 배열 범위 침범 방지.
|
||||
- 공정표 상단 툴바에 `공정 전체 비우기`(`data-action="schedule-clear"`) 버튼을 추가하고 확인 대화상자(`confirm`) 후 안전하게 초기화 지원(목록이 비어있으면 `disabled`).
|
||||
- 모든 조작은 `EstApp.change(..., false)`를 통해 수행되어 실행 취소(Undo)로 변경 전 공정 순서 및 데이터 완전 복원 가능.
|
||||
2) 모델 검증 및 계산 무결성:
|
||||
- 이동 후에도 `EstSchedule.calculate(project)`를 통해 `left`, `width`, `weight`, 가중평균 `progress`, 전체 시작일/종료일이 정상 재계산됨을 확인.
|
||||
- Excel 내보내기/불러오기(`EstExchange`) 및 JSON 작업본 저장 시 순서 유지 확인.
|
||||
- 관련 테스트:
|
||||
- `pytest resources/tester/test_est_documents.py` 14개 통과 (`test_schedule_move_row_and_clear_operations` 추가).
|
||||
- 전체 연계 회귀 시험 85개 전수 통과 (0 실패).
|
||||
|
||||
- [x] 동원인원·자재·장비 집계(resources) 화면 비목별(노임·자재·기계·표준·견적) 필터링 및 공종별 전개 조회 구현
|
||||
- 자체 검증:
|
||||
1) `native-extra.js`의 `view === "resources"` 렌더링 확장:
|
||||
- 전체 집계뿐만 아니라 비목 구분(노임 `kind: 6`, 자재 `kind: 5`, 기계 `kind: 105`, 표준 `kind: 21`, 견적 `kind: 101`, 전체 `all`) 필터 셀렉트(`id="resource-kind-filter"`) 추가.
|
||||
- 내역 공종별 세부 소요 전개 체크박스(`id="resource-by-work"`)를 추가하여, 체크 시 `EstResources.calculate(project, true)`를 호출해 공종코드(`WorkCode`) 및 공종명(`WorkName`) 열을 포함한 세부 전개 테이블 표시.
|
||||
- 필터링 결과 건수 표시(`(총 N건)`) 및 오류 메시지 보존.
|
||||
2) `native-business-actions.js` 이벤트 연동:
|
||||
- `resource-kind-filter` change 시 `state.resourceKind`를 갱신하고 `EstApp.render()` 호출.
|
||||
- `resource-by-work` change 시 `state.resourceByWork`를 갱신하고 `EstApp.render()` 호출.
|
||||
3) 원천 서식(`print-catalog.json`) 및 `EstForms.data` 일치 확인:
|
||||
- `112`(동원인원 공종별), `113`(소요자재 공종별), `114`(동원장비 공종별), `163`(동원실적 공종별), `164`(동원견적 공종별) 서식과 `13`(동원인원), `110`(소요자재), `111`(동원장비), `161`(동원실적), `162`(동원견적) 총괄 서식 간의 데이터 일관성 및 금액/수량 무결성 보존.
|
||||
- 관련 테스트:
|
||||
- `pytest resources/tester/test_est_documents.py` 13개 통과 (`test_resource_view_filtering_and_by_work_expansion` 추가 검증).
|
||||
- 전체 회귀 시험 69개 전수 통과 (0 실패).
|
||||
|
||||
- [x] 기계경비(MachMainDat ↔ MachTitleDat) 당초 계약 단가 비교·설계변경 조회 및 데이터 보호(적용 미지원) 구현
|
||||
- 자체 검증:
|
||||
1) `native-machine.js` 검토 및 수정:
|
||||
- REA `f18804` 기계경비 계산 엔진(`EstMachine.calculate`)과 `MachTitleDat`를 대조한 결과, 기계경비 호표 단가는 `MachMainDat`의 상세 산출 공식에 의해 결정되며, `MachTitleDat`의 `TC_`/`NC_`/`GC_`/`JC_`는 설계변경 비교를 위한 당초 계약값임.
|
||||
- 당초 계약값은 현재 기준단가의 근거가 아니므로, 현재 단가를 과거 계약값으로 덮어쓰는 파괴적인 개별/일괄 적용 UI를 비활성화(`canApply: false`, 적용 버튼 미노출).
|
||||
- 코드 호출 시에도 안전하게 예외를 발생(`기계경비 호표는 MachMainDat 산출 결과이며 당초 계약값으로의 덮어쓰기 적용은 지원하지 않습니다.`)시켜 사용자 데이터 오염 및 자동 재계산 충돌을 원천 차단.
|
||||
- 신규 항목(`TC_ === null`)에 대해 0으로 왜곡하지 않고 `originalPrice: null`, `difference: null`, `status: "신규(당초없음)"`으로 안전하게 분기 표시.
|
||||
- 화면 안내 문구에 기계 호표 단가는 `MachMainDat` 산출 공식에 의해 결정되며 당초값 덮어쓰기는 지원하지 않음을 명시.
|
||||
2) UI 및 서식 연동:
|
||||
- `index.html`, `native-app.js`, `native-extra.js`, `native-report.js`, `native-model.js`, `native-forms.js` 연동을 통해 사용 내역(`machine-usage`) 및 설계변경(`machine-change`)의 안전한 조회·인쇄 서식 지원.
|
||||
- 관련 테스트:
|
||||
- `pytest resources/tester/test_est_basic_prices.py` 5개 통과 (기계경비 당초/현재 비교, 신규 항목 TC_ null 안전 처리, 파괴적 apply 차단 및 데이터 불변 보존, Edge 브라우저 화면 조회 실측 검증).
|
||||
- 전체 연계 회귀 시험 56개 전수 통과 (0 실패).
|
||||
|
||||
- [x] 호표(일위대가·단가산출·기계경비) 상세 행 이동(`move-estimate-up`, `move-estimate-down`) 및 단가산출 다중 -20 구간·참조 수치 무결성 보존 구현
|
||||
- 자체 검증:
|
||||
1) `native-business.js`에 `moveGroupDetailRows(rows, groupCode, selection, direction, table)` 구현 및 산출근거 다중 구간/참조 수치 무결성 검증 보완:
|
||||
- 일위대가(`HeadMainDat`), 단가산출(`CostMainDat`), 기계경비(`MachMainDat`)의 호표 상세 행에 대해 연속 선택 행의 상/하 이동 지원.
|
||||
- 호표 제목 행(`Index > 0`) 이동 차단 및 제목 행 건너뛰기 방지.
|
||||
- 선택 행이 해당 호표 범위를 벗어나거나 인접 호표의 영역을 침범하지 않도록 상/하 경계 보호(경계 도달 시 이동 없이 원래 인덱스 보존).
|
||||
- 합계/소계/구간비율 행(`LinkIndex` 54, 55, 58, 59, 61, 62) 및 합계 수식표지(`\x1a`) 포함 행의 이동 및 건너뛸 수 없도록 안전 차단.
|
||||
- **다중 소계 구간 시작 표지(`Index === -20`)의 개별 보존 및 다중 선택 경계 보호**:
|
||||
이동에 참여한 국소 교환 위치 사이에서만 `-20` 표지를 정확히 교환하여, 호표 내 다른 소계 구간의 `-20` 표지(예: `D0000000005`의 다중 소계 구간)가 0으로 초기화되거나 왜곡되지 않고 완전히 유지되도록 수정.
|
||||
선택 범위 내에 복수 `-20`이 포함되거나 범위 중간에 `-20`이 끼어 있어 단일 경계 스왑으로 보장할 수 없는 경우는 보수적으로 이동 차단.
|
||||
- **고유 식별자(`rowKey`) 매칭 및 앞줄 참조(`\x0c`) 수식/수치 불변 사전 검증(`validateCostOrderIntegrity`)**:
|
||||
PkID 유무와 관계없이 `rowKey(row, index)`를 통해 전수 매칭하여, PkID 누락 행에서도 정확한 전/후 대조 보장.
|
||||
호표 최종 금액(`rows[0].TC`)뿐 아니라 각 소계(`LinkIndex === 61`)·비율(`LinkIndex === 58`) 행의 최종 금액, 그리고 앞줄 참조(`\x0c`)를 사용하는 모든 행의 `LineData` 수식 문자열이 완전히 동일하게 보존되는지 검증.
|
||||
앞줄 참조(`\x0c`) 행이 포함된 복수 행 동시 이동은 안전한 구분을 위해 단일 행 이동으로 제한하고 차단.
|
||||
- **동일 결과값 선행 행 교환 시 뒤따르는 직전 결과 참조 대상 왜곡 방지 및 일반 행/변수 재정의 보수적 차단**:
|
||||
뒤따르는 행이 특정 선행 결과를 직전 참조(`\x0c`)하는 구간에서, 동일 비목과 동일 결과값을 가진 선행 후보 행들이 2개 이상 존재할 때 이들 간의 순서 교환은 참조 대상이 왜곡되므로 보수적으로 이동 차단(`직전 결과를 참조하는 행이 뒤따라 참조 대상 구분이 불명확하므로 이동할 수 없습니다.`).
|
||||
`LinkIndex 58`뿐 아니라 일반 행(`LinkIndex !== 61`)의 `\x0c` 앞줄 참조 역시 선행 후보 행이 2개 이상일 때의 순서 교환을 보수적으로 차단.
|
||||
동일 변수명 중복 할당(변수 재정의) 구간에 포함된 행들의 교환 시 전역 변수 사전 순서 의존성 왜곡을 방지하기 위해 이동 차단(`동일 변수(...)를 재정의하는 구간에서는 참조 대상 보존을 위해 이동할 수 없습니다.`).
|
||||
2) `native-business-actions.js` 및 `native-views.js` 연동:
|
||||
- 툴바의 행 이동 버튼(`move-estimate-up`, `move-estimate-down`)이 내역서(`EstMainDat`)뿐 아니라 호표 화면(`HeadMainDat`, `CostMainDat`, `MachMainDat`)에서도 활성화되도록 노출.
|
||||
- 호표 화면 조작 시 `moveGroupDetailRows`를 호출하고 `EstApp.change()`를 통해 작업본 반영 및 실행 취소(Undo/Redo) 지원.
|
||||
- 관련 테스트:
|
||||
1) `pytest resources/tester/test_est_change_workflow.py` 13개 전수 통과:
|
||||
- 일위대가 상세 행 이동 및 Edge 실측 시험.
|
||||
- 단가산출(`CostMainDat`)의 실제 사례(`D0000000004` 인부소계 구간)에서 상세 행 이동 후 소계(`14,373.6`) 및 호표 총액(`16,421`) 완벽 보존 검증.
|
||||
- 다중 `-20` 구간을 가진 실제 사례(`D0000000005`)에서 상세 행 이동 후에도 `-20` 표지 2개 및 1차 소계(`1,383.4`), 2차 소계(`1,407.8`)가 모두 보존됨을 검증.
|
||||
- A/B 동일값 선행 행 교환 시 직전 결과 참조 대상 왜곡 재현 및 보수적 차단 검증.
|
||||
- `LinkIndex 58` 이외 일반 행의 `\x0c` 앞줄 참조 모호성 차단 검증.
|
||||
- 동일 변수(`W`) 재정의 구간 행 교환 보수적 차단 검증.
|
||||
- 소계 행(`LinkIndex 61`), 비율 행(`LinkIndex 58`), 변수 산식 행(`D0000000000`의 Q식), 소계 수치가 왜곡되는 구간 침범 이동, 범위 중간에 `-20`이 포함된 이동, 앞줄 참조 복수 행 이동의 안전 차단 검증.
|
||||
- Edge 브라우저에서 단가산출 상세 행 이동, 소계 보존 및 실행 취소(Undo) 원복 실측 검증.
|
||||
2) 원본 ESTX 무결성 보호 시험 14개 통과 (`pytest resources/tester/test_est_native_export.py`).
|
||||
3) 핵심 비즈니스 및 가격 회귀 시험 28개 통과 (`test_est_basic_prices.py`, `test_est_standard.py`, `test_est_estimate.py`, `test_est_business.py`).
|
||||
4) 주 수량 회귀 시험 12개 통과 (`test_est_quantity_workflow.py`).
|
||||
|
||||
- [x] 자재단가(StuffcDat) 및 노임단가(LaborcDat) 사용 내역 화면과 기초단가 개별/일괄 적용 구현
|
||||
- 자체 검증:
|
||||
ative-basic-prices.js를 신규 구현하여 자재단가(LinkIndex === 5) 및 노임단가(LinkIndex === 6)를 원천(StuffcDat, LaborcDat)과 비교하는 사용 내역(material-usage, labor-usage) 및 설계변경(material-change, labor-change) 화면 제공.
|
||||
1) 자재는 GU, 노임은 NU 비목으로 원천 기초단가 반영 및 SCheck > 0 재분류 비목 보존.
|
||||
2) 수동계산(AutoCalc = false) 시 원천 단가·비목은 변경하되 해당 행의 기존 금액(GC, NC, TC)을 강제 계산하지 않고 보존하며, AutoCalc=true 전환/재계산 시 수량(GN)을 반영해 전체 전파되도록 규칙 일치.
|
||||
3) 화면 네비게이션, 인쇄 서식, 모델 검증 등록 및 EstApp.change() 연동 Undo/Redo 지원.
|
||||
- 관련 테스트: pytest resources/tester/test_est_basic_prices.py 4개 통과(단위 시험 및 Edge 브라우저 자재/노임 단가 적용, Undo 원복 검증).
|
||||
|
||||
- [x] 견적단가 사용 내역 화면(estimate-usage) 및 기초단가(EstiMateDat) 개별/일괄 적용 구현
|
||||
- 자체 검증:
|
||||
ative-estimate.js를 신규 작성하여 견적단가 원천(EstiMateDat, LinkIndex === 101)과 내역 행 간 단가 비교(usage), 차이 검출, 개별 적용(pplyRowPrice), 일괄 적용(pplyAllPrices) 구현.
|
||||
1) 원천 비목 보존: 원천 비목 분할(InputGC, InputNC, InputJC) 유지 및 SCheck > 0 재분류 비목 보존.
|
||||
2) 수동계산(AutoCalc = false) 시 원천 단가·비목은 변경하되 해당 행의 기존 금액을 보존하고, AutoCalc=true 전환/재계산 시 수량(GN)을 반영해 갱신되도록 수동계산 규칙 일치.
|
||||
3) 화면 네비게이션, 인쇄 서식(estimate-usage, estimate-change), 모델 검증 등록 및 EstApp.change() 연동 Undo/Redo 지원.
|
||||
- 관련 테스트: pytest resources/tester/test_est_estimate.py 4개 통과(단위 시험 및 Edge 브라우저 개별/일괄 적용, Undo 원복 검증).
|
||||
|
||||
- [x] 표준시장단가 사용 내역 화면에서 개별 및 일괄 기초단가 내역 적용·실행 취소 구현
|
||||
- 자체 검증:
|
||||
ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainDat, LinkIndex === 21) 간 단가 차이가 있는 항목을 선별(canApply)하고, 개별 행 단위 적용(pplyRowPrice, data-standard-apply-row) 및 전체 차이 항목 일괄 적용(pplyAllPrices, data-standard-apply-all) 기능을 구현.
|
||||
1) 적용 시 원천 자료의 고유성 검증: 원천 코드가 없거나 중복된 경우 임의 적용을 차단하고 예외 메시지 출력.
|
||||
2) 원천 비목 보존 및 계산 규칙 일치:
|
||||
- 원천(SilJukDat)이 재료/노무/경비/실적 비목 분할(InputGC, InputNC, InputJC, InputSC)을 제공할 경우 이를 그대로 단가 비목(GU, NU, JU, SU)으로 반영.
|
||||
- 사용자가 설정한 비목 재분류(SCheck > 0: 재료/노무/경비 몰아주기)가 있을 경우 이를 임의로 해제하지 않고 해당 대상 비목으로 기초 단가 총액을 보존.
|
||||
- 수동계산(AutoCalc = false) 상태에서는 기존 내역 편집 및 est_estimate_classification_refreshes_only_selected_source_and_preserves_manual_mode 규칙과 일치하도록, 원천 단가 및 비목 분할은 반영하되 해당 행의 기존 금액(GC, NC, JC, SC, TC)을 강제 계산하지 않고 보존. 이후 AutoCalc=true로 전환하거나 재계산을 실행할 때 수량(GN)을 반영한 정식 금액으로 전파.
|
||||
3) 화면 상단 툴바에 적용 대상 건수(기초단가 일괄 적용 (N건))를 표시하고 대상이 없으면 비활성화(disabled).
|
||||
4) 작업본 반영은 EstApp.change()를 통해 이루어지므로 실행 취소(Undo)로 변경 전 단가 및 금액을 단계별로 완전 복원 가능.
|
||||
- 관련 테스트: pytest resources/tester/test_est_standard.py 5개 통과(수동계산 AutoCalc=false 보존 및 재계산 전환, 단위 시험 및 Edge 브라우저 개별/일괄 적용, Undo 원복, 비목 분할 보존, SCheck 보존 등), 연계 회귀 39개 통과.
|
||||
|
||||
- [x] 수량산출 항목 단위(Unite) 편집 및 Title 테이블 메모 필드 보존을 통한 ESTX 저장·재열기
|
||||
- 자체 검증: `native-quantity.cjs` 수량산출 화면에 단위(`Unite`) 편집 열 추가 및 `export-quantity.cjs`의 `withoutLabels`에 `Unite` 라벨 변경 차단 방지 반영. `native-engine.js`의 `saveTitles()`에서 `CostTitleDat`·`HeadTitleDat`·`MachTitleDat`의 식별자(`PkID`, `Index`, `RecNo`, `Flag`)뿐만 아니라 BLOB/메모 필드(`DifineMemo`, `SpaceMemo`, `EscData`)를 원본 Title 행 기준으로 보존(기존 Title에 없을 시 Main 값이 오염 생성되지 않도록 필터링).
|
||||
- 브라우저 검증: 임시 서버(127.0.0.1:5186)와 Edge에서 설치 `main.estx`를 열어 수량 항목 단위를 `m2`로 변경 후 ESTX 내보내기/다운로드, 브라우저 재열기 및 다른 수량 항목 보존·실행 취소 확인. (대용량 프로젝트 약 164MB에 따른 브라우저 임시 저장 용량 부족 경고 확인).
|
||||
- 관련 테스트: `pytest resources/tester/test_est_quantity_workflow.py` 12개 통과, 기존 `test_est_native_export.py` 14개는 수정 전 기반 통과 상태이며 이번 Title 보존 전용 시험(`test_recalculation_and_native_export_keep_original_title_identities_when_sample_exists`) 1개 통과(58.08s, 인덱스 `.idx` 동일성 및 단위 `m2` 보존).
|
||||
- 남은 제한: 원본 EST와의 실행 동등성은 미확인.
|
||||
- [x] 수량산출서 항목 추가·삭제 및 내역 연결·JSON 저장/재열기 지원
|
||||
- 자체 검증: `native-quantity.cjs`의 수량산출 화면에 `수량 항목 추가`(`data-quantity-add-item`) 및 각 수량 항목 행에 `삭제`(`data-quantity-delete-item`) 버튼을 구현.
|
||||
1) 항목 추가 시 기존 항목 코드(`r000000`, `r000001` ...)와 중복되지 않는 새 고유 코드 자동 생성(`r######`), 품명/규격/단위/수량 초기화 및 `EstApp.change()` 등록.
|
||||
2) 항목 삭제 시 안전성 보장: 산출 행의 산출근거(`LineData`) 결과 표지(`\x12...\x12`) 및 `row.markers`에서 참조 중인 항목은 산출 행 결과 표지 수와 마커 수 불일치로 인한 계산 오류를 방지하기 위해 임의 삭제를 차단하고 사전 안내 메시지 출력. 미참조 항목에 대해서만 확인(`confirm`)을 거쳐 안전하게 삭제.
|
||||
3) 품명·규격·단위 및 연결 내역 대상 지정(`TargetKey` / `TargetCode`) 후 JSON 작업본 저장 및 재열기 지원.
|
||||
4) ESTX 바이너리로 표현 불가능한 임의 행 구조 변경(신규 산출서/항목 증감)에 대해서는 기존 ESTX 저장 시의 불변 무결성 보호를 유지하고 JSON 보관 안내.
|
||||
- 관련 테스트: `pytest resources/tester/test_est_quantity_workflow.py` 12개 통과 (신규 항목 추가, 참조 중인 항목 삭제 차단 안내 및 미참조 항목 안전 삭제 단위 시험 반영).
|
||||
- [x] 자료 오류 검색의 수량산출 항목 내역 대상 연결 검사, 해당 산출서 이동 및 검색 화면 내역 연결 지정
|
||||
- 자체 검증: `native-issues.js`의 `scan()` 함수를 실제 수량 적용(`totals()`) 규칙과 정확히 일치하도록 개편:
|
||||
1) `TargetKey` 우선 매칭(`id:${row.PkID}`): 두 행의 코드가 동일해도 `TargetKey`로 고유하게 연결된 경우 정상 판정하여 오탐 방지. 잘못된 키는 `수량 대상 내역 없음`으로 탐지.
|
||||
2) `TargetKey`가 없을 시 `TargetCode ?? LinkCode` 대체값으로 매칭하며, `Level > 0`인 공종/비고 행 제외 규칙 일치.
|
||||
3) 수량산출서(`quantitySheets`) 및 수식표 직접 작성(`quantities`) 항목 모두 검사하며 미연결/대상 부재 항목을 `unlinked`로 추출.
|
||||
4) 오류 검색 화면에서 수량산출 오류 항목에 대해 `산출서 열기` 버튼을 제공하여, 클릭 시 `quantity` 뷰 및 해당 `quantitySheet` 코드로 즉시 전환·선택되도록 구현.
|
||||
5) 자료 오류 검색 화면에서 미연결 또는 대상 부재인 수량 항목에 대해 바로 내역 행을 선택할 수 있는 드롭다운(`data-issue-target-select`)을 제공하여, 오류 검색 화면을 벗어나지 않고도 `TargetKey`(`id:${row.PkID}`) 및 `TargetCode`를 즉시 지정하고 `EstApp.change()`를 통해 JSON 작업본에 즉시 반영 및 실행 취소 가능하도록 구현.
|
||||
6) `native-links.cjs`의 `existingTargets()`에서 고유한 `PkID`가 존재할 때만 `TargetKey`(`id:${row.PkID}`)로 안전하게 연결하고, `PkID`가 없는 동일 코드 행 간의 모호한 연결은 기존대로 미연결 유지 및 경고 처리 보존.
|
||||
- 관련 테스트: `pytest resources/tester/test_est_native_export.py::test_existing_quantity_links_respect_native_mode_and_avoid_ambiguous_targets resources/tester/test_est_issues.py` 5개 통과, `test_est_quantity_workflow.py` 12개 통과. Edge 브라우저 '산출서 열기' 클릭 시 수량산출서 전환 이동 및 오류 화면 내역 연결 드롭다운 선택 시 작업본(`TargetKey`) 즉시 연결 실측 확인.
|
||||
- [x] 테일스케일 전용 독립 서버 감시 프로세스(`watch-server.cjs`) 및 대용량 IndexedDB 캐시 복원
|
||||
- 자체 검증: `server.cjs`의 외부 바인딩을 `EST_TEST_HOST=100.65.141.15:5175`로 한정(127.0.0.1 접속 시 ECONNREFUSED 차단 확인). Node 내장 `--watch` 기반 감시 프로세스(`watch-server.cjs`)를 구현해 `data/` 및 로그를 제외한 `.js`·`.cjs` 소스 변경 시에만 자동 재기동되도록 구성(파일 touch 시 자동 재기동 및 헬스 200 확인). `server.cjs`를 require하는 테스트 실행 시 watcher hang 없음 확인(`test_est_quantity_workflow.py` 12개 2.69s 통과).
|
||||
- 대용량 임시 저장 보완: `native-app.js`에서 sessionStorage(약 5~10MB) 용량 초과 시 IndexedDB(`est-independent-cache`)로 자동 대체 저장·복원하도록 개선하여, 대용량(main.estx 약 157MB JSON) 작업 시 새로고침 유실 문제를 해결함. IndexedDB 트랜잭션 `waitForTx(tx)` 대기 및 순차 직렬화 큐(`cacheQueue`)를 적용해 연속 입력 시 덮어쓰기 레이스 컨디션을 방지하고, 탭별 고유 인스턴스 ID(`sessionStorage`의 `est-tab-instance-id`) 및 Chromium 탭 복제 시 `window.name` 리셋 감지를 적용해 복제 탭(Duplicate tab) 분리 격리 및 부모 작업본 초기 상속을 구현함. 실제 headless Edge 기반 Playwright 회귀(`resources/tester/test_est_browser_cache.py`)로 소형 데이터 격리, 대용량 `main.estx`(22MB 바이너리, 157MB JSON) 업로드 후 이름 수정→IndexedDB 저장→새로고침 복원(76개 전체 내역행 유지), 캐시 실패 안내 메시지 검증 및 탭 복제(Duplicate Tab) 상속·독립 격리 시험 통과(3 passed in 13.04s).
|
||||
- 백그라운드 상태 감시 및 자동 알림: `tools/est-test/monitor.cjs`를 통해 15초 주기로 `orca.exe` CLI(`shell: false`, timeout 4~5s)를 호출하여 CODEX 워크트리의 안티그래비티 작업 상태와 EST 5175 서버 헬스를 조회하고, 상태 변경 시에만 `monitor.log`에 기록(최대 512KB 순환). 안티그래비티 완료/대기 전이 시 Codex Orchestration Run(`run_cdb1558b9280`)으로 단발성 자동 알림(`status`/`messageId=msg_2c7bca748f91`)이 실제 전달됨을 확인(중복 폭주 방지 플래그 적용, 민감 환경변수 미기록, 재시작/자동지시 없음).
|
||||
## Codex · EST 독립 테스트 앱 (2026-11-04)
|
||||
- [x] 일위대가 상세 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 원천 불일치·다른 호표 보존 시험.
|
||||
- 자체 검증: 관련 시험 33개 통과. Tailscale HTTP Edge에서 자동·수동 계산, 다른 호표 보존, 원천 단가 불일치 시 복원 차단 및 화면 오류 0 확인.
|
||||
- [x] 원천 재조회가 꺼진 내역의 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 미연결·다른 행 보존 시험.
|
||||
- 자체 검증: 현재 단가를 유지한 비목 지정·일치하는 원천으로 복원, 다른 행·재조회 옵션 보존. 원천 누락·중복·단가 불일치는 복원 차단. Edge에서 자동·수동 계산 확인, 관련 시험 32개 통과.
|
||||
- [x] 산출근거 문구 일괄 바꾸기 → 현재 호표·전체 범위, 표지 보존·재계산·실행 취소 확인.
|
||||
- 자체 검증: Tailscale HTTP Edge에서 현재 호표·전체 치환, 일치 없음 안내, 결과·단가 표지 보존과 실행 취소 확인. 산출근거 시험 24개 통과, 수식 변경 후 재계산 확인.
|
||||
- [x] Excel 파일 읽는 동안 파일·셀 선택이 바뀌면 이전 요청 반영 차단 → 지연 응답 시험.
|
||||
- 자체 검증: 지연된 Tailscale HTTP 응답 동안 셀과 파일을 각각 변경하면 이전 요청 반영이 차단되고 다시 실행은 성공. 수량 시험 12개 통과.
|
||||
- 자체 검증: 수량·Excel·설계변경·기성·물가변동·연차·문서·오류 기능 회귀 69개 통과(openpyxl 경고 8건). 원본 동등성 대조는 실행하지 않음.
|
||||
- [x] 계산 불일치 수량산출서의 계산값을 바꾸지 않는 문구 수정 저장 → 원본 수량·표지 보존과 수량 변경 차단 확인.
|
||||
- 자체 검증: main.estx N0000000083 문구를 수정해 저장·재열기한 뒤 원래 수량 286과 다른 행 보존. 산식 계산값·결과 표지 변경 차단, 기존 산식 저장 시험 통과.
|
||||
- [x] 삭제된 Excel 연결 대상의 행 식별자 재사용 방지 → 새 행·붙여넣기·Excel 가져오기 오연결 차단.
|
||||
- 자체 검증: 삭제된 2번을 예약해 Edge 추가·붙여넣기가 3·4번을 사용. Excel 가져오기 오연결 차단·연결 제거 후 적용 확인. 관련 시험 35개 통과, openpyxl 경고 8건.
|
||||
- [x] Excel 수량 연결을 전체 자료 Excel에 포함 → 재가져오기와 잘못된 내역 연결의 전체 적용 차단 확인.
|
||||
- 자체 검증: Tailscale HTTP Edge에서 실제 Excel 내보내기·재가져오기와 잘못된 내역 연결 전체 차단 확인. 수량·Excel 시험 25개 통과, openpyxl 경고 8건.
|
||||
- [x] Excel 셀과 내역 행 연결을 JSON에 보관하고 다시 선택한 파일로 일괄 갱신 → 중복·누락·오류 시 전체 변경 차단.
|
||||
- 자체 검증: Tailscale HTTP Edge에서 2행 연결·JSON 재열기·일괄 갱신·실행 취소·연결 해제 확인. 한 셀 오류 시 두 행 모두 유지, ESTX 저장에서 연결 누락 차단. 수량·첨부·독립 서버 시험 25개 통과.
|
||||
- [x] Excel 숫자 셀을 선택한 내역 행의 수량으로 반영 → 파일·시트·셀 오류 차단, 계산·저장·취소 확인.
|
||||
- 자체 검증: Tailscale HTTP Edge에서 실제 xlsx B2=6.25 반영·금액 625·취소·JSON 저장, 문자·미계산 수식·누락·범위 오류 차단 확인. 수량·Excel 시험 23개 통과, openpyxl 경고 7건. 자동 파일 감시는 없음.
|
||||
- [x] Tailscale HTTP에서 수량산출서와 산출 행 생성 → 생성·JSON 저장·복원 확인.
|
||||
- 자체 검증: Tailscale HTTP Edge에서 산출서·행 생성, JSON 저장·재열기 확인. 수량·독립 서버 시험 19개 통과.
|
||||
- [x] 기존 계산 불일치 수량산출서도 품명·규격만 바꿀 때 원본 수량 유지 → 실자료 재열기 확인.
|
||||
- 자체 검증: 설치 main.estx N0000000083의 저장 286·재계산 100.1 불일치 항목에서 품명만 변경한 뒤 원본 수량 286·문서·다른 문서 보존 확인. 저장·서식 시험 2개 통과.
|
||||
- [x] 기존 수량산출 항목의 품명·규격을 ESTX에 저장 → 재열기·다른 항목 보존·연결 변경 차단 확인.
|
||||
- 자체 검증: 설치 main.estx 기존 항목의 품명·규격 저장·재열기, 다른 항목·문서 보존과 연결 변경 차단 확인. 저장·수량 관련 시험 22개 통과.
|
||||
- 추가 검증: 품명·규격과 산식을 함께 수정한 ESTX의 수량 104→116, 다른 문서·인덱스 보존 확인.
|
||||
- [x] 인쇄 서식의 상·하·좌·우 여백 개별 설정과 ESTX 저장 → 기존 단일 여백 호환·실제 서식 재읽기 확인.
|
||||
- 자체 검증: 거창 ESTX 여백 13·14·15·16mm 미리보기·JSON 저장과 기존 단일 여백 호환을 Edge에서 확인. 설치 샘플 ESTX 저장·재읽기 포함 관련 시험 24개 통과.
|
||||
- [x] 산출근거 계산 오류에서 해당 행으로 이동 → 실무 ESTX 오류와 중복 코드 안전 처리 확인.
|
||||
- 자체 검증: 거창 실무 ESTX의 D0000000194:18 계산 오류에서 원본 해당 행으로 Edge 이동 확인. 중복 코드·범위 오류 차단 포함 시험 12개 통과.
|
||||
- [x] 다른 이름으로 저장할 때 작업 이름 입력 → 원본·복사본 구분과 저장 취소 확인.
|
||||
- 자체 검증: Tailscale HTTP Edge에서 빈 이름 차단, 별도 이름·ID의 사본 저장과 기존 파일 보존 확인.
|
||||
- [x] Tailscale HTTP 접속의 새 항목·첨부·공정 생성 오류 수정 → 실제 원격 주소에서 생성과 저장 확인.
|
||||
- 자체 검증: Tailscale HTTP Edge의 비보안 컨텍스트에서 UUID·공정·첨부 생성·JSON 저장 확인. 관련 시험 31개 통과.
|
||||
- [x] EST 백업 EBKX 파일 열기 → 실무 백업 읽기·JSON 저장·재열기 확인.
|
||||
- 자체 검증: 거창 실무 EBKX의 내역 106행을 Edge에서 열고 JSON 저장·재열기; 독립 서버 시험 9개 통과. 원본 EST에서의 백업 호환성은 미확인.
|
||||
- [x] Tailscale 주소에서 독립 테스트 서버 접속 → 접속과 계산 요청 확인.
|
||||
- 자체 검증: Tailscale IP에서 Edge 화면과 계산 POST 200, 다른 출처 403 확인. 다른 PC에서의 접속은 미확인.
|
||||
- [x] 표준시장단가 설계변경 내역·증감 화면·출력 구현 → 신규·삭제·연결 변경 시험.
|
||||
- 자체 검증: 표준 내역의 신규·삭제·연결 변경과 문서 기능 시험 21개 통과. Edge에서 증감·삭제 행·인쇄·내역 이동 확인.
|
||||
|
||||
- [x] 표준시장단가 사용 내역·기초 단가 비교 화면·출력 구현 → 연결·중복·누락·행 이동 시험.
|
||||
- 자체 검증: 연결 단가·중복·누락·문서 시험 12개 통과, Edge에서 화면·인쇄·해당 내역 이동 확인.
|
||||
|
||||
- [x] 추가 인쇄 서식의 본문 저장·미리보기·Excel 연결 보완 → 서식 문서 검증·재열기 시험.
|
||||
- 자체 검증: 문서·Excel 시험 24개 통과, Edge에서 본문 저장·재열기·인쇄·Excel 요약 확인.
|
||||
|
||||
- [x] 기성 회차 이름 변경·추가 시 중복 방지 → 공백·기존 이름 충돌 시험.
|
||||
- 자체 검증: 기성·업무 시험 22개 통과, Edge에서 공백 중복 이름 거부·새 이름 생성 확인.
|
||||
|
||||
- [x] 첨부파일 추가 전 256MB 저장 한도 확인 → 대형 파일 차단·기존 작업 보존 시험.
|
||||
- 자체 검증: UTF-8·base64 실제 저장 크기 일치·한도 계산 포함 4개 통과. Edge에서 초과 파일 읽기 전 차단·안내 확인.
|
||||
|
||||
- [x] 전체 수량 반영 시 중복·예약 산출서 코드 차단 → 중복 합산 방지 시험.
|
||||
- 자체 검증: 중복·빈·예약 코드 차단과 기존 수량 보존 포함 시험 19개 통과.
|
||||
|
||||
- [x] 선택 또는 전체 상세 내역 수량 초기화 → 비율·합계 보존·실행 취소 시험.
|
||||
- 자체 검증: 설계변경·이력 시험 9개 통과, Edge에서 선택·전체 초기화, 비율 보존과 취소 확인.
|
||||
|
||||
- [x] 전체 기성 회차 초기화 구현 → 내역·다른 업무 보존·취소 시험.
|
||||
- 자체 검증: 기성·이력 시험 9개 통과, Edge에서 초기화·다른 업무 보존·확정 회차 취소 복원 확인.
|
||||
|
||||
- [x] 로컬 첨부파일 추가·내려받기·삭제·JSON 보관 구현 → 파일 바이트·저장·취소 시험.
|
||||
- 자체 검증: 입력 검증·ESTX 누락 차단 시험 2개 통과, Edge에서 파일 추가·JSON 저장·재열기·바이트 내려받기·삭제 취소 확인. 업무 회귀 시험 82개 통과(openpyxl 경고 7건).
|
||||
|
||||
- [x] 선택 행 자릿수·사사오입·절사·올림식 일괄 설정 → 선택 범위·재계산·취소 시험.
|
||||
- 자체 검증: 업무·수량 시험 21개 통과, Edge에서 두 행 절사 2자리 설정·다른 행 유지·재계산·실행 취소 확인.
|
||||
|
||||
- [x] 일위·산근·기계 묶음 복사 시 기존 코드 충돌 방지 → 묶음·행 보존 시험.
|
||||
- 자체 검증: Edge에서 세 자료의 중복 코드 회피와 복사된 묶음 자식 행의 부모 연결 확인.
|
||||
|
||||
- [x] 모든 수량산출서·직접 수식표의 내역 수량 일괄 합산 반영 → 동일 대상 합산·오류 시 전체 차단 시험.
|
||||
- 자체 검증: 동일 내역 합산·오류 시 전체 차단 포함 수량·문서 시험 18개 통과, Edge에서 일괄 반영·취소 확인.
|
||||
|
||||
|
||||
- 2026-11-07: 사용자 지시로 EST 작업 계획과 검증 기록을 공유 PLAN.md에서 이 파일로 이관. 업무 회귀 시험 79개 통과, openpyxl 경고 7건. Git push는 보류.
|
||||
|
||||
- [x] 자료 오류 검색 화면과 해당 행 이동 구현 → 중복·누락 연결·품명·규격 차이 시험.
|
||||
- 자체 검증: 중복·누락·품명·규격 차이 시험 포함 19개 통과. Edge에서 오류 목록과 내역 행 이동 확인.
|
||||
|
||||
- [x] 계약심사 화면에서 주요심사내용 작성·저장·출력 연결 → 입력·재열기·인쇄 시험.
|
||||
- 자체 검증: 심사·문서 시험 17개 통과. Edge에서 작성·저장·재열기·인쇄 확인.
|
||||
|
||||
- [x] 계약심사 비목 누락 고정금액·중복 식별 처리 보완 → 잘못된 합계·다중 반영 차단 시험.
|
||||
- 자체 검증: 고정금액 비목 합계 오류·중복 식별 입력 차단을 포함해 시험 29개 통과, Edge 화면 확인.
|
||||
|
||||
- [x] 직접 작성 수량 수식표 항목 이동 구현 → 수식 참조·저장·실행 취소 시험.
|
||||
- 자체 검증: 수량·문서 시험 17개 통과. Edge에서 이동 뒤 참조 오류·서버 저장·재열기·실행 취소 확인.
|
||||
|
||||
- [x] 최근 확정 기성의 누계 수량을 예정공정표 진도로 반영 → 연결·범위·기존 일정 보존 시험.
|
||||
- 자체 검증: 최근 확정 회차·미연결·수량 0·범위 오류·일정 보존 포함 시험 18개 통과, Edge 진도 반영과 실행 취소 확인.
|
||||
|
||||
- [x] 인쇄 서식의 계산 결과를 Excel 표로 내보내기 구현 → 실제 Excel 열기·수치 확인.
|
||||
- 자체 검증: Edge에서 연차 내역 Excel 다운로드·한글 제목·금액 확인. 요약·오류·수치·입력 검증 포함 문서·Excel 시험 22개 통과(openpyxl 경고 7건).
|
||||
|
||||
- [x] 연차별 개별 내역·배분 금액 출력 구현 → 차수별 계산·수식 오류 시험.
|
||||
- 자체 검증: 차수별 배분·오류 표시 포함 문서·연차 시험 13개 통과, Edge 연차 내역 미리보기 확인. 업무 회귀 시험 74개 통과(openpyxl 경고 6건).
|
||||
|
||||
- [x] 물가변동의 중복 코드 내역을 당초와 개별 연결 → 계약·적용제외 계산 시험.
|
||||
- 자체 검증: 중복 내역의 개별 계약 단가·적용제외 포함 시험 24개 통과, Edge에서 계약 단가 80·50 표시 확인.
|
||||
|
||||
- [x] 기성 입력·일괄 적용의 중복 행 식별 차단 → 계산·확정·일괄 적용 시험.
|
||||
- 자체 검증: 중복 행 계산 및 확정 차단·다른 행 계산 포함 14개 통과, Edge 개별·일괄 입력 차단 확인.
|
||||
|
||||
- [x] 기성 누계수량의 물가변동 연결 보완 → 중복 식별 차단·신규 행 입력 보존 시험.
|
||||
- 자체 검증: 중복 식별 차단과 신규 행 입력 보존 포함 시험 13개 통과, Edge 기성 누계 불러오기 확인.
|
||||
|
||||
- [x] 행 복사·붙여넣기에서 다른 종류의 자료가 섞여 손상되지 않도록 제어 → 동일 자료 복사·오류 시험.
|
||||
- 자체 검증: Edge에서 다른 자료 종류의 붙여넣기를 차단하고 동일 자료 복사는 유지하는 것을 확인.
|
||||
|
||||
- [x] 내역 선택 행 위·아래 이동 구현 → 행 식별·소계 보호·취소 시험.
|
||||
- 자체 검증: 연속 행 이동·소계·식별자 차단 시험 21개 통과, Edge에서 이동·기성 입력 유지·실행 취소 확인.
|
||||
|
||||
- [x] 설계변경 중복 코드 내역의 당초 행 매칭 보완 → 증감·삭제 시험.
|
||||
- 자체 검증: 같은 코드의 다중 내역과 삭제 행의 당초 금액 분리를 포함한 시험 20개 통과.
|
||||
|
||||
- [x] 설계변경 원가 비목별 당초·변경·증감 화면과 출력 구현 → 삭제·신규·중복 구분 시험.
|
||||
- 자체 검증: 중복·신규·삭제 및 행별 당초 값 포함 시험 15개 통과, Edge 화면과 인쇄 미리보기 확인.
|
||||
|
||||
- [x] 물가변동 원가 비목별 계약·조정·제외 금액 인쇄 구현 → 계산 결과 시험.
|
||||
- 자체 검증: 기성·물가변동·문서 시험 21개 통과, Edge 미리보기에서 기성·물가변동 원가와 연차 출력 확인.
|
||||
|
||||
- [x] 기성 원가계산서 인쇄 구현 → 회차별 전회·금회·사정·누계 시험.
|
||||
- 자체 검증: 확정 및 다음 회차 금액 포함 시험 15개 통과.
|
||||
|
||||
- [x] 물가변동 전용 Excel 입력과 조사 도급액 입출력 구현 → 행 식별, 오류 차단, 계산 반영 시험.
|
||||
- 자체 검증: Excel 관련 시험 12개 통과, Edge에서 실제 파일 수정과 가져오기 확인.
|
||||
|
||||
- [x] 연차 배분 계산 결과와 수식을 출력하는 서식 구현 → 차수별 계산과 오류 표시 시험.
|
||||
- 자체 검증: 차수별 금액과 잘못된 수식 오류 표시 포함 시험 11개 통과.
|
||||
|
||||
- [x] 기성 회차와 물가변동 결과 인쇄 서식 구현 → 확정 회차 보존과 계산 결과 시험.
|
||||
- 자체 검증: 결과 출력 및 확정 회차 보존 시험 포함 18개 통과, Edge 미리보기에서 두 서식 확인.
|
||||
|
||||
|
||||
- 2026-11-07 사용자 지시: 동등성 대조와 Git push 보류, CAD 제외 기능 구현 우선.
|
||||
- 기능 회귀(2026-11-07): 수량·Excel·심사·설계변경·기성·물가변동·연차·공정·이력 관련 60개 통과(28.41초), openpyxl 날짜 API 경고 5건. 원본 동등성 대조는 실행하지 않음.
|
||||
- [x] 수량산출서 신규 생성·이름 변경·산출 행/항목 추가·삭제 및 내역 반영 → 화면 조작과 JSON 저장/복원 시험.
|
||||
- 자체 검증(2026-11-07): 수량 화면에서 새 산출서 생성, 항목 추가·품명 변경, `2*3` 계산과 내역 6 반영, 서버 JSON 저장·재열기. Edge 브라우저 오류 0, 이벤트 기능 시험 2개 통과. 설치 원본과의 동등성 대조는 사용자 지시에 따라 수행하지 않음. 신규 산출서/행 구조 변경의 원본 ESTX 쓰기는 미지원.
|
||||
- [x] 수량산출 행의 순서 변경과 선택 위치 삽입 → 계산 참조 순서·결과 표지 연결 확인.
|
||||
- 자체 검증(2026-11-07): 이벤트 시험에서 항목 결과 3·9·13 보존, 선언 이전 참조 오류 확인. Edge에서 행 아래 삽입·위로 이동 및 JSON 저장 후 재열기, 항목 결과 3·9 유지·브라우저 오류 0.
|
||||
- [x] 기존 수량 연결의 변경 및 해제 → 비운 연결이 원래 연결로 되살아나지 않는지 확인.
|
||||
- 자체 검증(2026-11-07): 기존 LinkCode가 있더라도 TargetCode를 빈 값으로 설정하면 해당 내역 수량을 변경하지 않음. 수량 기능 시험 3개 통과.
|
||||
- [x] 수식표와 산출서의 내역 반영을 같은 연결 검사로 처리하고 재계산 메뉴에서 선택한 자료를 반영 → 대상 누락·중복·수식 오류 시 전체 변경 차단.
|
||||
- 자체 검증(2026-11-07): 수식표 참조 합계 8, 선택 산출서 9, 기존 MulCode 보존, 없는 대상의 전체 변경 차단. Edge 재계산 메뉴에서 선택 산출서 9·직접 작성 수식표 8 반영, 브라우저 오류 0. 수량·이력 관련 5개 통과. 다른 프로젝트를 열 때 이전 산출서 선택이 남지 않도록 초기화.
|
||||
- [x] 예정공정표에 내역 일괄 추가와 금액 가중치 갱신 → 기존 일정·진도 보존 및 중복 방지 시험.
|
||||
- 자체 검증(2026-11-07): 같은 코드 2행과 코드 없는 1행을 행 식별자로 구분하고 재추가를 방지, 내역 순서 변경 뒤에도 금액을 연결. 일정·진도·이름 유지 및 연결 모호성 차단을 기능 시험. Edge에서 3행 추가·재추가 방지·금액 200/300/400 갱신 및 JSON 저장, 오류 0. 설치 내역의 코드 없는 39행도 공정 추가 가능함을 확인. 원본과의 동등성 대조 미실시.
|
||||
- [x] 수식표 항목 삭제 후 새 항목 추가 시 중복되지 않는 산출코드 생성 → 삭제·재추가와 참조 계산 시험.
|
||||
- 자체 검증(2026-11-07): 남은 Q2가 삭제된 Q1을 참조할 때 새 항목을 Q3으로 생성해 참조가 새 항목에 잘못 연결되지 않음을 Edge에서 확인. 수량·공정 기능 시험 9개 통과.
|
||||
- [x] 설계변경 비교에서 코드가 없는 내역도 품명과 수량·금액을 표시 → 당초·변경 차액과 화면 표시 시험.
|
||||
- 자체 검증(2026-11-07): 코드 없는 `earthwork` 내역의 당초 100·변경 200·증감 100이 화면에 표시되는지 Edge와 기능 시험으로 확인. 관련 기능 시험 13개 통과·브라우저 오류 0.
|
||||
- [x] 수량산출서 인쇄에 직접 작성 수식표와 산출서의 실제 수식·계산값·연결을 표시 → 계산 오류와 출력 데이터 시험.
|
||||
- 자체 검증(2026-11-07): 수식표 2·참조 결과 6, 오류 수식의 계산 오류, 산출서 4+5 결과 9·연결 내역을 출력 데이터로 확인. Edge 인쇄 미리보기에서 두 종류의 산식과 결과 표시·브라우저 오류 0, 수량·설계변경·공정 기능 시험 14개 통과.
|
||||
- [x] 수량산출과 직접 수식표에서 코드 없는 내역과 중복 코드의 개별 내역 행 연결 → 선택·계산·저장·재열기 시험.
|
||||
- 자체 검증(2026-11-07): 같은 코드의 두 내역 중 둘째 행과 코드 없는 내역에 각각 6·9를 반영하고 JSON 저장·재열기 및 인쇄에서 대상 확인. 직접 수식표의 코드 없는 행 선택·결과 5 반영·연결 해제, 내역 순서 변경 뒤 식별자 유지, 잘못된 식별자 차단. Edge 오류 0, 기능 시험 15개 통과.
|
||||
- [x] 연차 차수 위·아래 이동과 중복·기존 참조를 피하는 신규 차수 이름 생성 → 참조 순서 오류·저장 복원 시험.
|
||||
- 자체 검증(2026-11-07): 기존 2차가 삭제된 1차를 참조하면 새 이름을 3차로 생성. Edge에서 2차를 1차 앞으로 이동했을 때 참조 오류 표시, 변경 순서 저장, 다시 원래 순서로 옮겨 오류 해소 확인. 연차·수량 기능 시험 10개 통과, 브라우저 오류 0.
|
||||
- [x] 계약심사 화면: 사용자가 심사 수량·비목별 단가를 입력하고 기존 내역/심사액·차액을 비교, 심사 사본 열기와 JSON 저장 → 입력·원가 재계산·취소·저장/복원 시험. 자동 심사율·법적 기준은 정하지 않음.
|
||||
- 자체 검증(2026-11-07): 일반 단가 행과 고정금액 행을 수정해 원가 포함 기존 1,210→심사 198 계산, 기존 내역 보존·실행 취소·JSON 저장/재열기·독립 ID의 심사 사본 확인. 잘못된 입력·없는 행의 사본 생성 차단. Edge 오류 0, 관련 기능 시험 47개 통과(25.68초). 원본 계약심사 동작 대조는 수행하지 않음.
|
||||
- [x] 계약심사 결과요약서에 기존액·심사액·차액과 내역별 비교 출력 → 출력 데이터·미리보기 확인.
|
||||
- 자체 검증(2026-11-07): 기존 200·심사 80·차액 -120 및 코드 없는 내역을 요약서 출력 데이터와 Edge 인쇄 미리보기에서 확인. 심사·문서 관련 8개 통과, 브라우저 오류 0.
|
||||
- [x] 직접 작성 수식표·예정공정표 Excel 내보내기와 가져오기 → 참조 수식·내역 행 연결·일정·진도 유지 및 잘못된 입력 차단.
|
||||
- 자체 검증(2026-11-07): Edge에서 Excel 내려받기→openpyxl로 수식·진도 변경→가져오기 및 내역 식별자 보존. 중복 코드·소수자리·진도 오류 차단, Excel·수량·공정 관련 17개 통과. openpyxl 환경의 날짜 API 사용 중단 경고 2건, 실패 0.
|
||||
- [x] 산출근거의 내부 표지 문자가 Excel 파일을 손상하지 않도록 인코딩·복원 → 원본 표지와 표지처럼 생긴 일반 문자 왕복 확인.
|
||||
- 자체 검증(2026-11-07): 제어 문자가 포함된 Excel 파일이 XML 파싱에 실패하던 상황을 재현한 뒤, 표지와 일반 문자열 `_x0012_`를 구분해 저장·openpyxl 편집·브라우저 재가져오기 왕복 확인. Edge 오류 0.
|
||||
- [x] 수량산출서 문서·산출 행·항목의 Excel 내보내기와 편집 후 가져오기 → 표지·내역 연결 보존 및 계산 확인.
|
||||
- 자체 검증(2026-11-07): 세 Excel 시트 내보내기, 산출 행 2×3→2×4와 품명 수정 후 가져오기, 표지·내역 행 연결·Excel 미표시 속성 보존 및 내역 수량 8 반영을 Edge에서 확인. 누락 시트·잘못된 표지·중복 순서 차단과 빈 원래 필드 유지, 수량·Excel 기능 시험 14개 통과. Edge 오류 0; openpyxl 날짜 API 사용 중단 경고 3건, 실패 0.
|
||||
- 추가 보완: 기존 `LinkCode`만 있는 항목은 Excel에서 연결을 변경하지 않았을 때 새 연결 필드를 덧붙이지 않고 원래 구조를 유지.
|
||||
- [x] 수량산출 행 편집 중 결과 표지가 삭제되거나 추가되면 이전 저장 수량이 잘못 적용되지 않도록 차단 → 화면·Excel 편집 후 계산 시험.
|
||||
- 자체 검증(2026-11-07): 결과 표지가 0개 또는 2개인데 연결 표지가 1개인 행의 계산 오류와 내역 반영 차단, 기존 수량 1 유지. Edge에서도 표지 삭제 후 반영 차단·오류 0, 수량·Excel 기능 시험 15개 통과.
|
||||
- [x] 계약심사 항목별 검토 사유 입력·저장 및 요약서 출력 → 금액 재계산과 사유 보존 시험.
|
||||
- 자체 검증(2026-11-07): 심사 단가 변경과 한글 사유 입력 후 JSON 저장·재열기, 심사액 160과 요약서 사유 표시를 Edge에서 확인. 관련 기능 시험 8개 통과, 브라우저 오류 0.
|
||||
- [x] 계약심사 입력·사유 Excel 내보내기와 수정 후 가져오기 → 내역 행 연결·재계산·입력 검증 시험.
|
||||
- 자체 검증(2026-11-07): Excel `Review` 시트에서 심사 단가 80→60과 사유를 변경해 가져온 뒤 심사액 120 및 화면 사유 표시 확인. 중복 식별자·잘못된 숫자·식별자 오류 차단, 심사·Excel 기능 시험 12개 통과. Edge 오류 0, openpyxl 날짜 API 경고 4건.
|
||||
- [x] Excel 계약심사 입력이 존재하지 않거나 중복된 내역을 가리키면 가져오기 자체를 차단 → 다른 표 교체도 함께 취소되는지 확인.
|
||||
- 자체 검증(2026-11-07): 없는 내역 및 해당 행에서 사용할 수 없는 비목 입력 차단. Edge에서 잘못된 심사 행과 내역 수량 변경을 함께 가져왔을 때 두 변경 모두 적용되지 않음 확인. 심사·Excel 시험 14개 통과, 브라우저 오류 0.
|
||||
- [x] 계약심사 개별 행의 입력·사유 초기화 → 다른 행 입력과 실행 취소 보존 시험.
|
||||
- 자체 검증(2026-11-07): 한 행만 초기화해 다른 행의 수치·사유 유지, 마지막 행까지 초기화 시 심사 작업 비우기. Edge 실행 취소로 초기화된 사유 복원, 심사 기능 시험 4개 통과, 브라우저 오류 0.
|
||||
- [x] 계약심사 고정금액 행의 사유·수량만 변경해도 기존 금액이 유지되도록 처리 → 비목 금액 변경과 구분 시험.
|
||||
- 자체 검증: 수정 전 문제 재현, 수정 후 금액 유지와 비목 변경 재계산 확인. 관련 시험 16개 통과.
|
||||
- [x] 연차 차수와 미확정 기성 회차·입력 Excel 편집 → 확정 회차의 계약·누계 보존 및 가져온 업무 계산 시험.
|
||||
- 자체 검증(2026-11-07): 확정 회차는 내보내기 대상에서 제외하고 기존 계약·누계를 그대로 유지. Edge에서 미확정 금회 수량 1→3, 연차 수식 50%→25%를 Excel로 변경해 가져온 뒤 기성 누계 500·연차 250 확인. 없는 행·확정 회차·중복 ID·잘못된 입력 차단, Excel·기성·연차 시험 22개 통과. Edge 오류 0, openpyxl 날짜 API 경고 5건.
|
||||
- [x] 내역·일위대가 행의 경비성단가(ActionSetJCBimog) 및 순공사비 경계 행(popMainSetTotalSumLine) 화면 조작 메뉴와 토글 재계산 지원.
|
||||
- 자체 검증(2026-11-08): REA ActionSetJCBimog(0xf17c10/0x10dba14) 및 popMainSetTotalSumLine 근거를 기반으로 `toggle-jc-bimog`, `toggle-sun-gongsa` 조작 액션 구현.
|
||||
1) 경비성단가 보존 및 복원: SCheck=1(재료) 또는 2(노무) 상태에서 경비성단가 지정(SCheck=3) 후 해제 시 원래의 SCheck 값(`_prevSCheck`)으로 온전히 복원(기존 비목별 0으로의 왜곡 차단), 노무비(SCheck=2, NU=2000)가 SCheck=3 전환 후 해제 시 SCheck=2, NU=2000으로 복원됨을 검증.
|
||||
2) 순공사비 연결 행 보호: 원천 연결(자재/노임/일위/단가/기계 등 LinkIndex 5, 6, 21, 51, 52, 55, 57, 58, 61, 62, 67, 68, 101~105) 또는 코드·수량이 있는 상세 행의 순공사비 경계선 지정을 안전하게 차단(`"단가·수량 연결 행이나 기존 특수 공종 행은 순공사비 경계선으로 지정할 수 없습니다. 빈 제목/구분 행을 선택하세요."`).
|
||||
3) 순공사비 속성 보존/복원: 기존 54 행이 있을 때 새 행을 순공사비로 지정하거나 해제 시 원래의 `LinkIndex`와 `Name`(`_origLinkIndex`, `_origName`)으로 정확히 복원됨을 검증.
|
||||
4) 안전성 거부 4건(비허용 화면 호출, 선택 없음, 복수 순공사비 지정, 원천 연결 상세행 지정 거부) 전수 확인. 신규 전용 시험 `resources/tester/test_est_row_actions.py` 통과.
|
||||
- [ ] 남은 웹 업무 기능을 이어서 구현하고 각 기능의 입력·계산·저장 흐름 확인.
|
||||
|
||||
- [x] ESTX 원본 옵션·공사명 및 기존 BLOB 확장 저장 구현 → 설치 샘플 재읽기와 인덱스·다른 자료 보존 확인. CAD 제외.
|
||||
- 자체 검증(2026-11-06): 설치 sample.estx에서 한글 공사명·자동계산·절사·자릿수 변경과 BLOB 12,000바이트 확장 저장 후 재읽기, 다른 속성·전체 인덱스·CAD 보존 확인. EST 전용 100개 통과(51.80초). 독립 파서 대조이며 원본 EST에서 파일을 열어보지는 못함.
|
||||
- [x] WPTools 수량산출서의 기존 서식 구간 안 텍스트 편집 저장 → 실제 수량산출서 샘플 재읽기와 다른 문서 보존 확인.
|
||||
- 자체 검증(2026-11-06): main.estx의 75개 수량산출서 중 한 문서 편집·재읽기, 나머지 문서·인덱스·CAD 유지, 서식 구간을 가로지르는 편집 거부 확인. EST 전용 102개 통과(70.77초). 원본 EST 직접 실행 비교는 미실시.
|
||||
- [x] 원본 결과와 기존 계산이 일치하는 수량산출서의 결과 표지·수량 목록 재계산 저장.
|
||||
- 자체 검증(2026-11-06): main.estx 수량산출서 폭 9→10 변경 시 표지 및 목록 수량 104→116으로 기록·재읽기, 다른 문서·인덱스·CAD 보존. EST 전용 102개 통과(70.78초). 계산 오류·기존 결과 불일치 문서는 저장을 거부하며 원본 EST 실행 검증은 미실시.
|
||||
- [ ] 서식 구간을 넘는 수량산출서 편집과 목록·연결 변경 지원; 계산 오류나 기존 결과 불일치 문서의 처리 확인.
|
||||
- 진행 및 미완료 범위(2026-11-08):
|
||||
1. 서식 편집 지원: 수량산출서 편집기에서 위첨자(`^(2)` 등) 및 복합 서식 태그 경계를 가로지르는 텍스트 수정(`abc^(2)def` -> `abc^(3)xyz`) 시 분할된 서식 런을 유지하며 인라인 패치 적용 확인 (`test_quantity_document_edit_across_styles_and_superscript_preserves_structure` 통과).
|
||||
2. 불일치 문서 처리: 원본 저장 수량과 산식 계산 결과가 다른 불일치 문서(예: `N0000000083` 원본 286 vs 산식 100.1)의 경우, 품명/규격/단위(Unite) 편집 시 기존 원본 수량(286)을 보존해 ESTX 패치를 허용하며, 산식을 수정해 결과가 변경되는 경우 "수량산출서 원본 결과와 재계산 값이 다릅니다. JSON으로 저장해 확인하세요"로 ESTX 덮어쓰기를 안전 차단함을 확인.
|
||||
3. 미완료 범위(제한 사항): 수량산출서 목록·연결(항목 추가/삭제, 수량산출서 신규/삭제, 행 구조 증감 등) 변경 시 ESTX 저장(`MulMainDat`, `MulTitleDat`, `Editor`, `MulList`)은 고정 인덱스 및 BLOB 구조 보존을 위해 현재 안전 차단되어 있으며, JSON 저장소(`project.quantitySheets`)에서만 지원됨. ESTX 목록·연결 구조 변경 저장은 미완료 상태임.
|
||||
4. 관련 회귀 시험 `test_est_quantity_workflow.py` 12개, `test_est_business.py` 18개, `test_est_native_export.py` 수량/위첨자 10개 전수 통과.
|
||||
- [x] 수량산출 항목의 단위(Unite) 화면 입력 열과 ESTX 저장 → 재열기·다른 항목 보존·목록 바이너리 검증.
|
||||
- 자체 검증(2026-11-07): native-quantity 렌더러에 단위 입력 열 추가 및 change 핸들러에서 Unite 변경 허용. 설치 main.estx 기존 항목의 품명·규격과 함께 단위(Unite: 'm2')를 수정해 브라우저 이벤트·저장·재열기, 다른 항목·연결·수량 보존 및 실행취소 확인. 단위 변경 시 산식 계산 불변 확인(퍼센트 등 임의 자동변환 없음). test_est_quantity_workflow 12개 및 test_est_native_export 회귀 14개 통과. 원본 EST 실행 검증은 미실시.
|
||||
- [x] 수량산출서의 연속 글자 서식에 걸친 텍스트 편집 저장 → 서식 태그 보존 및 재읽기 검증.
|
||||
- 자체 검증(2026-11-07): 서로 다른 세 글자 서식에 걸친 텍스트를 변경한 뒤 태그와 재읽기 결과를 대조. 변경 시작 위치의 서식으로 새 텍스트를 저장하며 위첨자·표지를 넘는 임의 편집은 계속 제한. 관련 시험 10개와 EST 전용 120개 통과(77.60초). 원본 EST 실행 비교는 미실시.
|
||||
- [x] 설치 수량산출서의 실제 글자 서식 경계 편집을 회귀시험으로 보존 → 해당 문서와 태그 재읽기.
|
||||
- 자체 검증(2026-11-07): main.estx N0000000113의 `27.22 <c nr=12/>=` 경계를 직접 변경하고 모든 태그와 재읽기 행을 비교. 설치본이 없으면 시험은 건너뜀. 해당 시험 1개 통과(4.13초). 문서 편집기 자료의 재읽기 검증이며 산식 의미 변경과 원본 EST 실행 비교는 아님.
|
||||
- [x] 수량산출서의 내역 수량 반영에서 실제 내역이 없는 코드와 중복 코드를 차단 → 제목만 존재·중복·정상 연결 시험.
|
||||
- 자체 검증(2026-11-07): 동일 코드의 상세 행 두 개가 모두 7로 바뀌던 오류를 재현하고 변경 없이 오류 표시하도록 수정. 제목만 존재하는 코드, 제목과 상세가 함께 있는 정상 코드, 여러 연결 중 하나가 없는 경우의 전체 반영 중단을 확인. 관련 시험 1개와 EST 전용 121개 통과(79.70초).
|
||||
- [x] 한 수량산출서 행에서 글자 서식 경계와 다른 표지 구간을 동시에 편집 → 태그·표지 유지와 재읽기 확인.
|
||||
- 자체 검증(2026-11-07): 두 서식에 걸친 문구와 별도 결과 표지 숫자를 동시에 수정하는 실패를 재현한 뒤 태그 보존·재읽기 일치 확인. ESTX 내보내기 관련 11개 통과(27.74초); 전체 121개 시험 이후 저장 경로의 이 조합만 추가 수정.
|
||||
- [x] 기성 회차에서 원가 기호가 없는 여러 항목의 전회 누적을 항목별로 유지 → 설치 샘플의 빈 기호 확인 및 복수 항목 회차 시험.
|
||||
- 자체 검증(2026-11-07): 설치 sample.estx에서 빈 기호 원가 행 확인. 합성 기성 2회차에서 앞 항목의 전회 200이 뒤 항목의 100으로 덮이던 실패를 재현한 뒤 200/100과 누계 500/250 확인. 기성 7개와 EST 전용 123개 통과(78.90초). 기호가 없으면 항목 이름과 동일 이름의 순서로 대응하므로 회차 사이에 같은 이름 항목을 재배열하면 원본 식별 근거가 부족함.
|
||||
- [x] 연차 이름이 기준 참조 예약어와 겹쳐 차수를 잘못 참조하는 경우 차단 → 예약어와 정상 차수 시험.
|
||||
- 자체 검증(2026-11-07): `기준`·`계약`·`계약수량` 이름이 차수 참조 대신 계약값으로 처리되던 상황을 재현하고 오류로 차단. 일반 차수 참조의 잔액 50 확인. 연차·업무 관련 16개 통과(14.08초). 기존에 예약어 이름을 사용한 웹 작업은 차수 이름과 참조를 바꿔야 함.
|
||||
- 추가 전체 회귀: EST 전용 125개 통과(81.45초). 원격 CODEX 브랜치는 서버의 `CODEX.lock`으로 push가 거부되어 로컬 커밋 보존 중.
|
||||
- 분석(2026-11-06): REA `0x11167e4`는 `MulCode`를 `0xa911d0`으로 분해한 뒤 수량 문서·항목을 찾고 `GN`을 복사한다. `0xa911d0` 기계어의 구분 문자열은 탭이다. 설치된 24개 ESTX의 내역 테이블에서는 `MulCode`가 채워진 사례가 없어 BLOB 인코딩·신규 할당의 원본 검증은 아직 부족하다.
|
||||
- [x] 기존 ESTX 기본 수량 연결을 웹에서 표시 → REA의 탭 분리·대상 찾기 조건과 중복 연결 경고를 합성 데이터로 검증.
|
||||
- 자체 검증(2026-11-06): 탭 분리 ASCII/UTF-16·기본 모드·중복 내역/다른 모드 경고 합성 시험 통과, EST 전용 117개 통과(76.52초). 실제 연결된 ESTX 샘플과 신규 연결 쓰기는 여전히 미확인.
|
||||
- [x] 기성 원가계산서의 명시적 0원 도급액 보존 → 직접공사비가 양수인 경우와 원가계산서가 없는 경우를 각각 시험.
|
||||
- [x] 물가변동 계약·제외·조정액 계산에서도 명시적 0원 도급액과 원가계산서 없는 경우를 구분.
|
||||
- 자체 검증(2026-11-07): 수정 전 0원이 직접공사비로 대체되는 실패 재현, 수정 후 기성·물가변동에서 0원 보존 및 원가계산서 부재 시 직접공사비 대체 확인. 관련 11개와 EST 전용 119개 통과(76.85초). 원본 EST 실행 비교는 미실시.
|
||||
- [x] 수량산출서 한 줄에서 서로 다른 표지 구간의 텍스트를 함께 수정하는 저장 지원 → 표지·서식 보존과 다중 편집 재읽기 검증.
|
||||
- 자체 검증(2026-11-06): 설치 main.estx에서 한 줄의 품명·수식을 함께 변경해 모든 WPTools 태그 유지, 전체 ESTX 재읽기와 수량 104→125 반영 확인. EST 전용 115개 통과(77.82초). 같은 구간 안 여러 서식을 가로지르는 편집과 목록·연결 변경은 미완료.
|
||||
- [x] 변경 없는 JDL 재출력 시 원본 확장 자료 보존, 수정 시 원본 무조건 반환 방지 → 서버 재계산을 거친 왕복과 수정 구분 시험.
|
||||
- 자체 검증(2026-11-06): 임의 확장 파일이 포함된 JDL 원본 바이트 일치, 저장 ID 부여 후 보존, 이름 변경 후 새 이름으로 변환 및 경고 확인. EST 전용 103개 통과(71.14초). 원본 검사 DLL 인증은 미확인.
|
||||
- [x] 인쇄 서식 선택 시 원본 서식에서 적용한 여백·행 수가 설정 입력란에도 표시되도록 수정.
|
||||
- 자체 검증(2026-11-06): Node DOM 모의에서 서식 선택 후 여백 20·행 수 21이 입력란에 반영되는지 확인. 원본 인쇄 배치 비교는 아님.
|
||||
- [x] JDL 일위대가의 기본 상세 항목 불러오기 → 자원 연결·저장 금액 보존 및 특수 항목 경고 검증.
|
||||
- 자체 검증(2026-11-06): 합성 JDL에서 자재 연결·수량 2·금액 200 보존 및 특수 플래그 경고, 서버 계산 경로 통과. EST 전용 104개 통과(71.49초). 원본 JDL 실자료·실행 대조는 미실시.
|
||||
- [x] JDL 기본 일위대가 구성 내보내기 및 불러오기 왕복 → 상세 자원·수량·금액 보존, 특수 구성은 기존 전개 경고 유지.
|
||||
- 자체 검증(2026-11-06): 합성 자재 일위의 상세 수량·금액 및 내역 참조 왕복, 특수 구성의 전개 경고 확인. EST 전용 105개 통과(72.38초). 원본 JDL 인증·실행 대조는 미실시.
|
||||
- [x] 해양 자료 산식의 단위·도식·그리스 변수·중괄호 수식 해석 개선 → 실제 행 시험과 원본 저장값 대조.
|
||||
- 자체 검증(2026-11-06): 해양 오류 95→37건, 저장값 134,159/134,208 일치(기존 134,133); 수치 뒤 중괄호 주석을 산식으로 잘못 읽던 회귀 1건 수정. 측량 80,671/80,682·산림 15,049/15,055·가시설 13,978/13,979 및 오류 수 기존과 같음. EST 전용 110개 통과(73.49초). 누락 변수·모순 부호와 나머지 오류는 미해결.
|
||||
- [x] 해양 자료의 복합 단위·괄호 설명 산식 추가 처리 → 실제 저장값과 다른 자료 회귀 비교.
|
||||
- 자체 검증(2026-11-06): 해양 오류 37→22건, 저장값 134,159/134,208 유지. 측량·산림·가시설 대조 결과 변화 없음. 수준면 주석·도식 병기 및 붙은 변수 확장은 저장값 36/69건 차이가 발생해 보류.
|
||||
- [x] 근사값이 병기된 변수 선언을 계산 값과 설명으로 분리 → 실제 원본 대조에서 기존 값 유지 확인.
|
||||
- 자체 검증(2026-11-06): `Q1=3.2hr ≒ 0.5일` 선언 및 참조 시험, 해양 오류 22→21건·저장값 134,159/134,208 유지, 측량·산림·가시설 결과 유지. EST 전용 116개 통과(77.02초).
|
||||
- [x] ESTX에 저장된 인쇄 서식 설정 읽기·미리보기 반영 → 설치 샘플의 서식 수·여백·행 수 및 원본 파일 보존 확인.
|
||||
- 자체 검증(2026-11-06): 설치 sample의 41개 서식과 프로젝트명·여백·행 수, 인쇄 설정이 포함된 10개 ESTX 읽기, DOM 모의에서 프로젝트 설정 우선 적용 확인. EST 전용 113개 통과(72.97초). 웹 인쇄 설정의 ESTX 쓰기·원본 출력 배치 비교는 미완료.
|
||||
- [x] 웹에서 변경한 인쇄 서식의 여백과 페이지당 행 수를 ESTX 원본 옵션에 기록하고 다른 필드 보존 검증.
|
||||
- 자체 검증(2026-11-06): DOM 모의에서 사용자 여백 13·행 수 19 기록, 설치 sample에서 해당 서식 재읽기 및 나머지 서식·압축 내 파일 바이트 보존, 범위 밖 값 거부와 서버 계산 후 기록 유지 확인. EST 전용 114개 통과(73.42초). 원본 EST 실행 검증은 미실시.
|
||||
|
||||
- 2026-11-06 후속 요청 — CAD 편집을 제외한 잔여 기능 구현. 기존 내역·계산·업무 기능을 유지하며 출력/변환과 원본 저장 경로를 확장한다.
|
||||
- [x] REA와 설치 자료에서 인쇄 서식·XML/JDL·Excel·ESTX 저장 구조 확인 및 지원 범위 명시.
|
||||
- [x] 전체 자료 Excel 입출력과 출력 서식/페이지 설정을 실제 파일·화면에서 검증.
|
||||
- [x] DB의 XSD 1.0.5에 따른 XML 입출력·계층/원가/단가 변환, JDL 기본 표 입출력, 동일 구조 ESTX 수정 저장 구현·왕복 검증. 근거 미확인 형식을 완전 호환으로 가장하지 않음.
|
||||
- 구현: Excel 스칼라 표/자료형과 기존 바이너리 보존, 단가 수정 전파, 코드/수식/중복 검사. XML 작성정보·연결/길이 검사·금액 보존 및 자동계산 해제, UTF-8/CP949/UTF-16 입력. JDLCHK 필드 정의와 EST TAB/ENQ·CP949 처리 경로에서 기본 표/ZIP 생성·읽기, 고정 단가 전개 제한 표시. ESTX 고정 행·비인덱스 필드/BLOB 원가 기록과 재읽기 검산. 재계산이 원본 호표 목록 PkID/인덱스 식별자를 버리던 문제 수정.
|
||||
- 화면: CAD 제외 서식 이름 40개, 데이터 출력 및 서식별 직접 입력 문서, 용지/방향/여백/글자/행/쪽 번호. 예정공정표 기간·진도·가중치 입력/검사·기간 막대·가중평균, 공종별 동원 분리 출력. JSON 저장/재열기 및 이력 대상.
|
||||
- 자체 검증(2026-11-06): Edge에서 XML 다운로드→실제 XSD 통과→재열기 금액/상하위 계층/0원 도급액 보존, JDL 다운로드/재열기 직접비·원가 보존, 예정공정 입력/저장 복원, CAD 제외 40개 서식 선택 시 JS 오류 0. Excel 왕복 시 바이너리·소수 유지, 66행 20행/쪽 분할과 쪽 번호 5~8, 본문/설정 저장 재열기 통과. 별도 openpyxl 읽기로 xlsx 구조/자료형 확인. 설치 sample.estx의 수량 변경→수정 저장→독립 재읽기 통과, 모든 .idx 바이트 불변. 원본 EST 실행 검증은 아님.
|
||||
- [ ] ESTX 신규 파일/행 증감·키 변경의 인덱스 재생성 구현 가능성 조사 및 점검.
|
||||
- 조사 및 한계 분석(2026-11-08):
|
||||
1. 파일 구조 및 암호화 특성: ESTX 내 각 테이블의 `.idx` 파일은 DBISAM 독자적 B-Tree 인덱스 형식으로, 헤더 8~24바이트에 서명(`06 8a be 8e 59 23 64 cb 40 3d 71 d2 e3 bc 64 d0`)이 포함되어 있으며 전체 블록(4,096바이트 단위)이 암호화·해싱되어 있어 단순 XOR 또는 비트 역변환으로 역공학할 수 없음.
|
||||
2. 무결성 검증 제약: `exportEstx`는 동일 행 수 및 비인덱스 필드(수치, 일반 텍스트, BLOB 데이터 등)의 수정 저장을 안전하게 지원하지만, 행 추가/삭제나 인덱스 키(`PkID`, `Code`, `Index`, `RecNo` 등)를 변경하면 `.idx`의 B-Tree 구조와 불일치가 발생함. 원본 Delphi/DBISAM 엔진 외부에서 인덱스 재생성을 구현할 수 있는 바이너리 규격이나 소스 근거가 부재함.
|
||||
3. 구현 범위 및 처리: 따라서 ESTX 내 행 추가/삭제 및 인덱스 키 변경은 기존처럼 명확한 예외 메시지(`{key}: 행 추가/삭제는 인덱스 재생성이 필요합니다`, `{key}.{name}: 인덱스 키 변경은 지원하지 않습니다`)로 안전 차단하고, 신규 파일/행 증감/자유로운 편집이 필요한 작업은 JSON 저장소(`project.tables`)에서 완전 무결하게 지원·보존하도록 범위를 명확히 유지함.
|
||||
- [ ] 남은 완전 호환 범위: ESTX 신규 파일/행 증감·키 변경의 인덱스 재생성, WPTools 편집 저장, 기성/연차 원본 별도 업무 파일; JDL 확장 산식/기성·물가 자료와 원본 검사 DLL 인증; 원본과 같은 인쇄 도형/배치 및 기존 산식 잔여 오류. CAD만 제외한 전체 완료로 판정하지 않음.
|
||||
- [x] 전용 회귀·포맷·700줄 제한·커밋/push 마무리. 검증 서버 종료 완료.
|
||||
- 자체 검증: EST 전용 전체 99 통과(48.88초). 변경 파일만 prettier/ruff format, git diff --check와 모든 소스 700줄 이하 확인. a5157dd3a 커밋을 CODEX에 push, origin/CODEX와 차이 0·작업 트리 깨끗함 확인. 검증 서버 5175 종료, 이번 일회 UI/분석 스크립트·출력물·전용 시험 저장본 정리. 기존 원본 자료와 앱 data는 수정하지 않음.
|
||||
|
||||
- 2026-11-06 사용자 지정 완료 범위 — 설계변경·기성·물가변동·연차 네 업무. 원본 실행 없이 REA 근거·시나리오·저장 복원으로 구현 완료를 판정하며, 전체 EST/CAD/변환 기능 완료와 구분. 검증 서버는 브라우저 시험 중만 실행하고 종료.
|
||||
- [x] 설계변경: 당초 불변, 신규·삭제·변경의 수량/금액 비교, 0/1/2 당초 재계산과 저장/취소.
|
||||
- [x] 기성: 수량·금액·비율 입력, 비목·비율/관급 행·고정 금액, 금회/사정/전회/누계·원가, 회차 확정/수정과 서버 저장 검산.
|
||||
- [x] 물가변동: 입찰/계약/변동 단가 분리, 등락 분기·조사금액을 분모로 한 조정률, 적용제외 max/자동·수동 및 간접비 포함 적용대가, 입력 제어·저장 복원.
|
||||
- [x] 연차: 차수 수식과 참조·정수 절사·잔액, 관급/비율/고정 금액, 독립 사본 및 오류 차단·저장 복원.
|
||||
- 자체 검증(2026-11-06): EST 전용 87개 통과, 이후 추가한 당초 재계산 0/1/2 시험 포함 설계변경 3개 통과(총 88개). Edge에서 신규/삭제 비교·저장 재열기, 기성 금액/사정/누계 원가·확정/이후 회차 해제·서버 결과 변조 재검산, 계약/입찰 파일 순서 연결·자동/수동 제외·조사금액 분모·저장 복원 통과. 연차 성분/잔액·수식 수정·수수료99/관급/안전관리비·사본 독립 ID·저장 재열기 통과, JS 오류 0. 신규/복사 행이 삭제된 계약/기성 행 ID를 재사용하지 않도록 수정. 원본 업무 실행 샘플이 없어 런타임 동등성은 미확인.
|
||||
- 통합 시험 제한: 기존 spreadsheet_size 빌드가 config/node_modules의 delaunator·@kninnug/constrainautor 누락으로 실패(41 통과 후 1 실패). EST 범위 밖이므로 수정하지 않음.
|
||||
- [x] 네 업무 전용 회귀와 실제 Edge 조작/저장/취소를 통과하고 변경 파일 포맷·700줄 제한 확인, 커밋/자기 브랜치 push.
|
||||
- 마무리: 변경 파일만 prettier/ruff format, git diff --check와 소스 700줄 이하 확인. 74ab66b00 커밋을 CODEX에 push 완료. 검증 서버 종료 및 5175 수신 없음 확인.
|
||||
|
||||
- [x] 2026-11-05 연차 관급 수수료: REA f0fcc0의 LinkIndex 57→99와 GN 유지·성분 금액 배분을 반영. 고정 배분액의 저장/반복 계산 및 관급 원가·수수료 제외 옵션을 검증.
|
||||
- 자체 검증: 기존 57행을 0원으로 만드는 실패를 재현한 뒤 수정. 연차 사본에만 원래 57행임을 보존해 관급 원가의 조달수수료 제외 계산 유지. 합산 제외 행은 원가 제외 상태 보존. 전용 시험 74 통과, 1,361원 모의 계약을 680/681원 배분 및 반복 계산 불변, 40% 변경 시 수수료 12/18·금액 544/817 검증. 원본 업무 실행 대조는 아님.
|
||||
- Edge 자체 검증: 차수 사본의 99 표시·원래 요율 유지, 원가 반영, 저장/재열기·JS 오류 0. 원본 ID를 복제해 덮어쓰던 위험 제거(차수 파일 ID 별도 생성), 저장된 원본 1,361원·수수료 30 보존 확인. 차수 내역 열기 시 내역서 화면으로 이동. 기존 연차·저장 대기/재시도 회귀 확인. 원본 23파일의 대조 수·해석 오류 수는 직전 결과와 동일해 추가 회귀 없음.
|
||||
|
||||
- [x] 2026-11-05 후속 산근 문법: 연산자 뒤 공백의 참조/선언 구분, 원본 금액 단위, 결과를 감싼 중괄호와 주석형 계산, 수치 범위 조건 구분. 원본 발췌·변경 전파·잘못된 참조 거부 시험 및 원본 전체 대조로 검증.
|
||||
- 자체 검증: 전용 시험 72 통과. 원본 23파일 재대조에서 측량설계 80,671/80,682 일치·해석 오류 0, 산림토목 15,049/15,055 일치·오류 0, 해양 134,133/134,208 일치·오류 95. 상수공사 중괄호 산식의 저장 표지 1.7과 계산 1.79 차이는 원문 표지 오류 후보로 남김(정상값으로 위장하지 않음).
|
||||
- REA f250bc/f251d5-f251df의 Extended 누적 후 자릿수 적용을 확인하고 소계의 사전 Currency 4자리 절삭 제거. 실제 수량 0.169776·재료비 98,103.7 재현. Edge에서 치수 편집→소계 0.179496·재료비 103,720.3·내역 1,037,200 전파, 서버 저장/재열기·취소 확인, JS 오류 0.
|
||||
- 설명에 붙은 변수의 자동 인식은 부호 해석과 수치 대입식의 충돌 때문에 적용 보류. REA 937958/936bd0/9392e0의 중괄호 토큰 경로를 확인했으나 변수 치환까지 증거가 충분하지 않아 기존 연결 규칙을 유지. 기존 전체 동등성 미완료 항목과 별개.
|
||||
|
||||
- 2026-11-05 산근 지정 단가·절사 합계 보완: 원본 f2f2e4→f2ef54 구간 수집과 지정 단가 원문 확인. 결과 없는 단가 행 갱신 보존, 구간/소계에 03 단가 표지 포함, Link62의 합계 표지를 최종 자동합계와 구분. 따옴표 Q1 선언 지원. 앞 성분의 저장 합과 절사 관계가 확인되는 정수 최종합계는 변경 성분에서 재집계. 측량설계 80,659/80,682 일치·오류 5건. 해양 운반비의 결과 없는 하역료 소계 누락을 추가 수정해 신규 불일치 해소. 전용 시험 64 통과, Edge에서 단가 550→600·구간 27.5→30·최종 27→30·내역 270→300 전파 및 저장·취소 확인, JS 오류 0. 저장 대기·재시도 회귀도 통과. 원본 전체 동등성 미완료.
|
||||
|
||||
- 2026-11-05 원본 산근 문법 보완: 가시설·측량설계 원문의 배/번/줄/set/kg/set/㎜ 및 괄호 뒤 체적·면적 단위, 별표 중량 선언의 닫는 별표, 설명 열 분리 후 남은 쉼표 처리. 실제 원문 발췌와 수치 변경 전파 시험 4개 추가, 전체 전용 61 통과. 측량설계 저장값 80,624→80,658/80,682 일치·해석 오류 60→10, 가시설 13,978/13,979 일치·오류 8→4. 기존 소계/비율/구간 편집·취소·저장과 수동계산 저장 복원 브라우저 회귀 통과, JS 오류 0. 범위 조건·누락 참조 및 원본 오류는 임의 값으로 대체하지 않음.
|
||||
|
||||
- 2026-11-05 대용량 실행 취소 보완: 기존 10MB 초과 문서의 이력 누락 제거. 브라우저 gzip으로 최근 8단계 보존, 압축 기능 미지원 시 원문으로 보존. 취소/다시 복원 중 입력·문서 버전이 바뀌면 복원 폐기, 현재 파일 ID 유지. 보존 시험 추가 및 Edge 11MiB 문서 취소/다시·추가 입력·문서 전환 보호 확인, JS 오류 0. 기존 저장 지연/재시도 브라우저 회귀 통과, 전용 시험 57 통과. 전체 동등성은 계속 미완료.
|
||||
|
||||
- 2026-11-05 대용량 당초 확정 보완: 사본의 중복 당초 필드·도면·현재 원천 목록을 제외하고 재계산 입력만 보존. 작업본·원본 압축 자료는 유지. main.estx 확정 JSON 293,074,097→214,621,549바이트로 256MiB 요청 한도 충족. 별도 임시 데이터 경로의 실제 저장 API에서 221,430,352바이트 저장·산근 77,395행·원본 압축 해시 보존·계산 오류 0 확인. 전용 시험 56 통과. 다음 작업: 대용량 문서도 실행 취소를 기록하고 비동기 복원 중 입력/문서 전환을 보호.
|
||||
|
||||
- 2026-11-05 저장 흐름 보완: GN 133→134 입력 후 셀을 벗어나지 않고 Ctrl+S 하면 133이 저장되는 오류 재현 후 수정. 활성 셀 확정·저장 요청 직렬화·요청 시점 사본·문서/수정 버전 확인으로 대기 중 새 입력이나 다른 문서가 이전 응답으로 덮이지 않도록 처리. 첫 저장 전 파일 ID 부여, 취소/다시 실행 시 현재 파일 ID 유지. Edge 요청 지연 시험으로 연속 저장·추가 입력 보존·문서 전환·다른 이름 저장·취소·실패 후 재시도 확인, JS 오류 0. 기존 12화면·당초 비교 저장 복원 회귀도 통과. 계산 시험 54개는 직전 엔진 기준 통과 상태이며 이번 수정은 화면 저장 경로에 한정.
|
||||
|
||||
- 2026-11-05 당초 비교 보완: 별도 웹 확정 사본이 없으면 원본 EstMainDat의 GN_/TC_ 저장값으로 설계변경 증감 표시. 0원 당초와 신규 행을 구분하고 명시적 확정 사본이 있으면 원본 필드보다 우선. 원본 숫자 필드의 잘못된 JSON 형식 거부 추가. 전용 시험 54 통과; Edge에서 당초 100/변경 150/증감 50 표시→저장/재열기→현재값 확정 150 우선→취소 후 100 복원 확인, JS 오류 0. 원본 원가 서식 활성 인덱스·당초 전체 테이블 자동 복원은 임의 확정하지 않음.
|
||||
|
||||
- 2026-11-05 동원·원천 연결 보완: REA 10e56b8/10e6218의 비목 배수를 산근/일위 재귀에 전파. 노무 80%·재료 90% 개별 낙찰률 유지, 하위 호표의 한 성분만 참조할 때 중복 전개 방지, 복수 단위환산 누적. 금액만 입력한 행·비율 경비를 원본 L/M/E/SZZ 코드로 누적하고 시간 환산 불가 경비를 금액으로 보존. 산근 단가는 품명 표지 유무와 관계없이 각 행의 원천 Code/LinkIndex로 조회, 연결 해제 행이 앞 행의 원천을 상속하지 않도록 수정. 전용 시험 52 통과(700줄 제한에 맞춰 연차·동원 시험을 test_est_business.py로 분리). Edge에서 비목별 80/90 적용→소요량 8/9·금액 800/900, 저장 복원 및 기존 소계·연차 조작 확인, JS 오류 0. main.estx 원천 전체 갱신→재계산 오류 0·산근/일위/내역 반복 안정성 확인(2회 13.942초). 원본 실행 수치 대조는 미확인.
|
||||
|
||||
- 2026-11-05 연차 배분 보완: REA f0fcc0/f0f9b8/f1098c의 차수 수식을 수량·각 성분 금액에 독립 적용하고 금액 정수 절사. 단가를 비운 고정 금액 사본으로 생성해 재계산 시 금액 유지, [기준]-[1차]로 절사 잔액 이월. 수량 0·고정 금액, 사본 독립성, 중복 차수명 거부 검증. Edge에서 8원 기준의 1·2차 3/5원→수식 25% 편집 1/7원→차수 사본 열기/저장/재열기 및 원본 불변 확인, JS 오류 0. 전용 시험 47 통과. 원본 업무 실행 대조와 연차 전체 분기 동등성은 미완료.
|
||||
|
||||
- 2026-11-05 산근 연결 보완: REA f250bc(61 소계)·f280f8(58 비목 참조)·f2ef54(62 선택 합계) 경로를 단가 변경 시 다시 계산. 6/7 제어 표지를 표시·편집하고 같은 이전 금액의 서로 다른 행도 독립 집계. f2c680/f2cba8 낙찰 성분 순회·Currency 4자리 및 최종 51/52 행 금액 반영 추가. 결과 표지 없는 자동 합계 식의 NaN과 수정 후 남던 Error 상태 제거. 소계 표지 1,433개 추가해 설치 main.estx 저장값 115,145/115,145 일치·오류 0. 실무 산불 세 파일은 소계 포함 8,411/8,411 일치·원본 괄호 오류 각 1건 유지. 전용 시험 45 통과. Edge에서 단가 100→150 → 산근 소계 300·두 비율 각 15·내역 3,300 전파, 구간 편집/취소/저장 및 12화면·수량 저장 복원 JS 오류 0 확인. 미완료 범위는 계속 남김.
|
||||
|
||||
- 2026-11-05 인쇄 누락 수정: 화면의 100행 페이지만 인쇄하던 동작을 전체 검색 결과/현재 호표와 현재 자료 전체 범위 선택 미리보기로 교체. 내역·일위·산근·기계·원가의 출력 열, 성분 금액과 소수 수량, 오류 및 문서 제목 보존. Edge에서 206행 전체→PDF 6쪽·마지막 행·검색 1행→전체 206행·산근 표지 제거·JS 오류 0 확인; PDF 첫/끝쪽 이미지로 잘림 없음 확인. 계산 회귀 39 통과. 원본 인쇄 양식의 동일성은 미확인.
|
||||
|
||||
- 2026-11-05 추가 구현: 원천/호표 코드 편집 시 작업본 참조와 목록 코드를 함께 갱신, 이전 코드 별칭으로 당초 단가 재계산 유지, 당초 스냅샷 불변·중복 거부·취소 검증. 분수 열 한 칸 공백·가운뎃점·숫자 붙은 변수·min/m2 단위, 음수 {-} 표지와 식 내부 주석·설명 괄호 수정. 원본 TCostOption RTTI CostValueTrunc(+0x34)·MidCostDiget(+0x40), f1df70→f2df14→f254c8 연결 확인 후 가져오기·절사 계산·설정 화면 반영. 해양/산림/가시설 원본 3묶음 회귀 자료 보존. 전용 시험 39 통과. 추가 원본 해양 저장값 133,635/133,710 일치·해석 오류 99건 남음; 조경 93,928/93,932·오류 0. 원본 오류/모드 미확인 및 미구현 기능 있어 완료 처리하지 않음.
|
||||
|
||||
- 2026-11-05 후속 대조: 실무 산불 3파일 DBISAM 재사용 BLOB 꼬리의 할당 공간과 논리 길이 구분 수정. 저장 금액 8,312/8,312 일치하나 공통 원본 괄호 누락식 D0000000194 한 건은 해석 오류로 남김. 직접재료비 별표 구획, 산업안전 관급 조달수수료 제외, Gangub=0 원액 유지, 낙찰·단위환산 후 동원 수량/장비시간 수정. 분수 안의 연산자 공백·ASCII x·주/톤 단위를 처리하고 회귀 시험 보존. 전용 시험 34 통과; Edge에서 자동계산 해제 열기/저장/재열기 금액 유지·원천 복사 코드 중복 방지·설정 두 옵션·수량 연결 및 저장 복원·12화면 JS 오류 0 확인. 추가 설치 원본 23파일의 잔여 차이 분석 중. 완료 판정 아님.
|
||||
|
||||
- 2026-11-05 수량 연동: WPTools 원본 75개 산출서의 글·표지·위첨자 및 개별 표지 정밀도를 가져와 편집·재계산·내역 반영·저장 복원. 449개 수량 중 446개 일치, 3개는 원본 글 산식과 저장 수량 불일치(286↔0.35×1.3×220=100.1, 0↔1×1=1, 138.72↔1.25×2×68×1.02=173.4). 원본 저장값과 재계산값 동시 표시, 원본 실행 미확인으로 원인 단정하지 않음. 수식 해석 오류 0, 회귀 시험 보존. Edge에서 폭 9→12 편집→연결 내역 수량 139→저장/복원 확인.
|
||||
|
||||
- 2026-11-05 계속: 대용량 main.estx의 전체 저장값 113,680/113,680 일치(일위 52,530·기계 17,675·내역 205·산근 표지 전체 23,889·호표 목록 19,381). f1a8c4 코드 문자별 SKind 선택, f3242c LinkIndex68 집계 교체, f1580c Currency 정수 환산, 산근 DigetIndex·복수 표지·Currency 합산 반영. 원본 발췌 900여 행과 설치 원본 자동 대조 시험 추가. 원천 갱신 전체 계산 오류 0 및 재계산 안정성 확인. 전용 시험 22 통과. 원본 실행 비교·CAD·원본 파일 수정 저장 등 남아 있어 완료 판정 아님.
|
||||
|
||||
- 2026-11-04 후속 구현: 이전 축소 데모 대신 원본 테이블 기반 `native-*` 엔진과 표 편집 화면으로 교체. 독립 Node 서버 5175, 로그인·공용 DB 의존 없음. ESTX 읽기(22개 표·17개 옵션·원가 서식 4종), 원본 바이너리 보존, JSON 파일 및 로컬 저장소 보관, 취소·다시 실행 구현.
|
||||
- 자체 검증: 원본 REA 샘플 275/275(기계 48·일위 41·내역 37·산근 결과 118·호표 8·원가 수식 23). 도급액 고정 반복 차이 324,-32,3,0. 단가 변경 전파 및 반복 재계산 안정성, 기성 사정·전회 누계, 연차 40/60 배분, 동원 전개, 적용제외 간접비 포함 물가조정 시험 추가. 전용 pytest 20 통과.
|
||||
- 브라우저 자체 검증: Edge에서 수량 편집·취소/다시·저장 다운로드·서버 저장 재조회·새로고침 복원, 업무 화면 12종 열기, 앱 내부 275개 대조 통과. JS 오류 0. 대용량 main.estx(산근 77,395행 등) 추가 대조 진행 중이며 전체 동등성 완료 아님.
|
||||
- 전체 시험: 41 통과 후 기존 spreadsheet_size 시험이 누락된 delaunator 및 @kninnug/constrainautor 의존성으로 실패. 이 앱 전용 시험과 별개이며 전체 통과로 보고하지 않음.
|
||||
- 남은 검증/구현: 대용량 산근·일위 저장값 불일치 분석, 설계변경 재계산 0/1/2 경로 상세 검증, 수량 원본 편집기 연결, CAD/VEC0650 편집, 원본 인쇄 서식·Excel/XML/JDL·ESTX 수정 저장. 원본 실행 동등성 미검증. 사용자 요청은 계속 진행하며 완료 처리하지 않음.
|
||||
|
||||
- 사용자 정정: 축소 계산 데모가 아니라 EST와 유사한 화면 및 기능을 갖춘 독립 프로그램. 아래 기록은 이전 데모 검증이며 완료 판정 아님.
|
||||
- [ ] EST 작업 화면(내역·일위·단가·기계·견적·표준·자재·노임·수량·원가)과 조작 흐름 구현
|
||||
- [ ] 분석 근거에 따른 계산·연결과 작업 파일 보존, 주요 업무 기능 구현 및 검증
|
||||
- [ ] 원본 미확인 기능과 미구현 범위 명시, 브라우저·계산 시험 결과 기록
|
||||
- 진행: 표 중심 화면으로 수정, 수량산출→내역 반영, 기성 수량·사정 누계 시험 기능 추가. 원본 화면 실행 불가, REA 자료로 진행(사용자 확인).
|
||||
- 자체 확인: 전용 pytest 3 통과; Edge에서 단가 변경, 수량 반영, JSON 저장·열기, 기성 금회·사정 누계 조작 확인. 원본 동등성 미검증. 기계경비·수식 단가산출·설계변경·기성 회차 및 원가·물가변동·ESTX 변환 등 미구현이므로 완료 아님.
|
||||
|
||||
- [x] `tools/est-test/`에 로그인·공용 DB 없는 독립 웹앱: 기초 단가 → 일위대가 → 내역 → 원가 연결과 입력 제어
|
||||
- [x] 시험 자료임을 표시하고 JSON 저장·열기 제공; 사용자 입력 요율과 계산 결과 확인
|
||||
- [x] 자체 검증 — 전용 pytest 2 통과; Edge 브라우저에서 단가 변경 → 일위대가·내역·원가 전파 및 JSON 저장·열기·초기화 확인. 원본 동등성 미검증, 단가산출 수식·ESTX 변환 및 기성·물가변동은 범위 밖.
|
||||
- dev 수신 뒤 전체 시험은 9%까지 실패·오류 발생해 중단; 첫 실패 재실행은 41 통과·1 실패(기존 spreadsheet 빌드에서 `delaunator`·`@kninnug/constrainautor` 의존성 누락). 나머지 실패 원인 미확인.
|
||||
|
||||
@@ -2,20 +2,51 @@
|
||||
|
||||
`start.cmd`를 실행한 뒤 **http://127.0.0.1:5175** 로 접속합니다. Node.js가 필요하며 별도 패키지 설치는 없습니다. Aislo 서버·로그인·DB와 연결하지 않습니다.
|
||||
|
||||
파일 열기에서 ESTX 작업 파일과 EBKX 백업 파일을 읽을 수 있습니다. EBKX를 연 작업도 JSON으로 보관하며 원본 파일을 덮어쓰지 않습니다.
|
||||
|
||||
내역·일위대가·수식 단가산출·기계경비·기초 단가·원가계산서를 원본 테이블 구조로 편집합니다. 수량 연결, 설계변경 당초 보존, 낙찰률, 기성 회차와 사정 누계, 품목조정, 연차 배분, 소요 자원 전개, 예정공정표 화면을 제공합니다. 실행 취소·다시 실행, 검색, 행 편집, JSON 저장·열기, CSV·Excel·조달청 XML·JDL 기본 표 입출력과 브라우저 인쇄를 사용할 수 있습니다.
|
||||
|
||||
작업모드의 `자료 오류 검색`은 코드 중복, 연결 자료 누락, 연결된 품명·규격 차이와 계산 오류를 표시합니다. 항목의 `행 열기`로 해당 자료 위치로 이동할 수 있습니다. 품명·규격 차이는 사용자가 확인할 항목으로 표시합니다.
|
||||
|
||||
작업모드의 `표준시장단가 사용 내역`은 표준시장단가에 연결된 내역별 적용 단가와 현재 기초 단가, 차이와 연결 상태를 보여 줍니다. 원래 내역으로 이동하거나 해당 표를 인쇄·Excel로 출력할 수 있습니다.
|
||||
|
||||
`표준시장단가 설계변경`에서는 기준 내역과 비교한 신규·삭제·변경 및 금액 증감을 확인하고 인쇄·Excel로 출력할 수 있습니다.
|
||||
|
||||
행을 선택한 뒤 편집 메뉴의 `선택 행 자릿수 설정`에서 처리방식과 소수 자릿수를 일괄 적용할 수 있습니다. 기존 계산 설정은 실행 취소로 되돌릴 수 있습니다.
|
||||
|
||||
단가산출 화면의 `편집 → 산출근거 문구 바꾸기`는 현재 호표 또는 전체 산출근거에서 문구를 일괄 변경합니다. 결과값·연결 단가 등의 계산 표지는 치환하지 않으며, 자동계산이 켜져 있으면 변경 후 재계산합니다. 실행 취소로 복원할 수 있습니다.
|
||||
|
||||
내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다.
|
||||
|
||||
원천 자료에 연결된 내역 또는 일위대가 상세 행의 결과비목을 재료·노무·경비로 바꾸면 현재 단가 합계를 유지해 지정한 비목으로 옮깁니다. 비목별 구성으로 되돌릴 때 원천 단가의 합계가 현재 단가와 다르거나 원천이 불명확하면 임의로 복원하지 않고 오류를 표시합니다. 자동계산이 꺼져 있으면 금액은 자동 재계산을 켤 때까지 유지합니다.
|
||||
|
||||
인쇄 미리보기는 화면의 100행 페이지 제한 없이 검색 결과/현재 호표의 전체 행을 표시합니다. 출력 범위에서 현재 자료의 모든 호표·행도 선택할 수 있습니다. 원본 인쇄 옵션의 41개 항목 중 CAD를 제외한 40개 서식 이름을 선택하며, ESTX에서 가져온 해당 프로젝트의 인쇄 설정을 우선 사용합니다. 내역·원가·호표/총괄·증감·동원·수량·공정은 표 자료로 출력합니다. 표지·갑지·설명서·시방서·심사 문서는 서식별 본문을 직접 입력합니다. 용지·방향·여백·글자 크기·페이지당 행·시작 쪽·업체명을 저장하고 인쇄/PDF로 출력합니다. ESTX 수정 저장은 서식별 여백과 페이지당 행 수를 반영합니다. 다른 웹 인쇄 설정의 ESTX 쓰기와 원본 인쇄 도형·병합·글꼴 배치의 동등성은 지원하지 않습니다.
|
||||
|
||||
별도 인쇄 서식으로 기성·물가변동의 내역서와 원가계산서, 연차 배분표와 연차별 내역서, 설계변경 원가 증감표를 선택할 수 있습니다. 기성은 선택한 회차를 출력하고 확정된 회차는 확정 당시 결과를 사용합니다.
|
||||
|
||||
인쇄 미리보기에서 `서식 Excel 저장`을 누르면 선택한 표의 계산 결과를 별도 Excel 파일로 내려받습니다. 결과 요약과 계산 오류가 있으면 각각 별도 시트에 표시합니다. 이 파일은 출력용이며 전체 작업을 다시 가져오려면 전체 자료 Excel 또는 JSON 저장을 사용합니다.
|
||||
|
||||
인쇄 여백은 위·아래·왼쪽·오른쪽을 각각 설정합니다. 원본 인쇄 서식의 여백을 불러오고, 변경한 방향만 ESTX 수정 저장에 반영합니다.
|
||||
|
||||
저장은 이 폴더의 `data/`에 작업을 보관하고 `.estweb.json` 파일도 내려받습니다. 브라우저 임시 저장은 같은 탭에서 복원하는 용도입니다. JSON은 모든 웹 업무와 원본 압축 파일·바이너리를 보관하는 전체 저장 형식입니다. ESTX 수정 저장은 아래 지원 조건을 따릅니다.
|
||||
|
||||
`다른 이름으로 저장`은 작업 이름을 입력받아 별도 작업으로 저장하며 기존 작업은 유지합니다.
|
||||
|
||||
작업모드의 `첨부파일`에서 파일을 추가·내려받기·삭제할 수 있습니다. 파일은 작업 JSON에 포함되며 이 앱의 저장 요청은 전체 256MB 이하입니다. 제한을 초과하는 파일은 읽기 전에 추가를 차단합니다. 첨부파일이 있는 작업은 JSON으로 저장해야 하며, 첨부파일이 빠진 ESTX가 만들어지지 않도록 ESTX 저장을 차단합니다.
|
||||
|
||||
## 추가 입출력·문서 기능 (2026-11-06)
|
||||
|
||||
- **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드를 숫자·문자·불리언으로 내보냅니다. 첫 행 필드명과 시트 이름을 유지한 파일을 다시 읽어 해당 표를 교체합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·업무 회차·당초 사본을 넣지는 않으므로 전체 백업은 JSON을 사용합니다.
|
||||
- **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드, 직접 작성 수식표·예정공정표·계약심사 입력·연차 수식·미확정 기성과 수량산출서의 문서 정보·산출 행·항목을 내보내고 다시 읽어 해당 자료를 교체합니다. 수량산출서는 `QDocuments`, `QLines`, `QItems`, 미확정 기성은 `ProgressDrafts`, `ProgressInputs` 시트를 함께 가져옵니다. 확정 기성의 계약 자료와 누계는 Excel 가져오기 시 유지합니다. 수식표·수량산출서·공정표의 내역 행 연결도 보존합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 산출근거의 내부 표지는 Excel이 열 수 있는 형식으로 바꿔 기록하고 다시 읽을 때 복원합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·원본 글자 서식·확정 기성 자료·당초 사본은 넣지 않으므로 전체 백업은 JSON을 사용합니다.
|
||||
- **물가변동 Excel**: `Escalation` 시트에서 입찰 당시 단가, 적용 제외 관련 수량과 자동 여부를 편집하고 `EscalationSettings`에서 조사 도급액을 입력합니다. 행 식별자가 없거나 중복되면 가져오기를 중단합니다.
|
||||
- **조달청 XML**: DB에 보관된 공사원가호환규정 XSD 1.0.5의 요소 순서와 필드를 사용합니다. 작성업체·공사구분을 입력하며 코드/문구 길이와 연결 자료를 검사합니다. 내역·일위·산근·기계·기초 단가·원가를 내보내고 다시 읽습니다. 연결 없는 고정 금액·기계 상세는 독립 단가로 전개하며 변환 전에 알립니다. 가져온 저장 금액과 원문을 보존하고 자동계산을 끕니다. 이는 제출기관 인증/등록 검사나 최신 규정 적합 판정을 뜻하지 않습니다.
|
||||
- **JDL 기본 표**: 원본 검사 DLL의 필드 정의와 EST 처리기의 TAB/ENQ·CP949 표 구조에 따라 ZIP 안의 Global.ini·Main.txt·자원/공종/내역/원가 표를 생성·읽습니다. 일위대가 상세는 기본 플래그와 연결 자원이 확인된 행을 저장 금액과 함께 불러오며 특수 행은 원문에 보존하고 경고합니다. 자재·노무로 구성된 기본 일위대가는 상세표로 내보냅니다. 가져온 작업을 변경하지 않았다면 원본 JDL을 바이트 그대로 내보내 확장 자료를 보존합니다. 변경된 작업의 특수 일위·산근·기계는 저장 금액의 고정 단가로 전개하며 제외 행은 생략 사실을 알립니다. 확장 산식·기성/물가 파일의 완전 변환과 원본 검사 DLL 인증은 미확인입니다.
|
||||
- **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 공사명·계산 옵션과 행 수 및 인덱스 키가 유지된 단가·수량·문구·금액, 기존 BLOB과 원가 서식을 기록하고 재읽기로 검산합니다. 수량산출서는 기존 서식 구간 안의 텍스트 편집을 저장하며 표지와 문서 구조는 보존합니다. 한 줄에서 표지로 구분된 여러 구간을 함께 수정하거나 연속 글자 서식에 걸친 텍스트를 수정할 수도 있습니다. 새 텍스트에는 변경 시작 위치의 서식이 적용됩니다. 원본 결과가 기존 계산과 모두 일치하면 수정된 계산 결과 표지와 수량 목록을 함께 갱신합니다. 불일치나 계산 오류가 있으면 저장을 거부합니다. 기존 BLOB은 공간이 부족하면 블록을 추가합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 문서, 행 추가·삭제·인덱스 키 변경, 표지나 위첨자 등 복잡한 구조를 가로지르는 편집과 목록·연결 변경, 웹 기성·연차·당초 사본은 지원하지 않아 저장을 거부합니다. 이 범위는 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다.
|
||||
- **예정공정표**: 공정별 기간·진도와 가중치를 입력하고 기간 막대 및 입력 진도의 가중평균을 표시합니다. 금액 가중치를 수정할 수 있으며 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다.
|
||||
- **예정공정표**: 내역 행을 공정으로 일괄 추가하고 내역 금액으로 가중치를 갱신할 수 있습니다. 코드가 없거나 같은 코드가 반복된 행은 내역의 행 식별자로 구분합니다. 기존 공정의 기간·진도·이름은 유지합니다. 공정별 기간 막대와 입력 진도의 가중평균을 표시하며, 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다.
|
||||
- **기성 진도 반영**: 예정공정표에서 최근 확정 회차의 누계 수량을 해당 회차 계약 수량으로 나누어 연결된 공정의 진도를 갱신할 수 있습니다. 수량이 0이거나 기성 내역에 연결되지 않은 공정은 기존 진도를 유지합니다. 0~100%를 벗어나는 결과는 갱신을 중단합니다.
|
||||
- **기성 초기화**: 기성 화면에서 모든 회차와 확정 결과를 한 번에 초기화할 수 있습니다. 내역과 다른 업무 자료는 유지하고 실행 취소로 복원할 수 있습니다.
|
||||
- **공종별 동원 출력**: 한 번의 원천 계산에서 공종별 인원·자재·장비·실적·견적을 나누며 전체 합계와 금액/수량을 대조합니다. 문서 본문과 인쇄 설정도 저장·복원·취소 대상입니다.
|
||||
- **계약심사**: 작업모드의 계약심사 화면에서 심사 수량과 비목별 단가·심사 사유를 직접 입력하고 기존액·심사액·차액을 비교합니다. 고정금액 행은 비목별 금액을 입력하며, 원가계산서도 다시 계산합니다. 항목별 입력을 초기화하거나 실행 취소할 수 있습니다. 심사 입력은 JSON에 저장하고, 확인한 결과는 원래 작업과 별도인 심사 내역 사본으로 열 수 있습니다. 심사결과요약서에는 내역별 비교·사유와 도급액 차액을 출력합니다. 심사율이나 법적 기준은 자동으로 정하지 않습니다.
|
||||
- 고정금액과 비목 합계가 다른 내역은 심사 합계를 정확히 산정할 수 없어 오류를 표시합니다. 내역서에서 비목 금액을 확인해 입력한 뒤 심사합니다.
|
||||
|
||||
Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요청은 순서대로 처리하며, 저장 대기 중의 추가 입력이나 새로 연 문서를 이전 저장 응답으로 덮어쓰지 않습니다. 실행 취소는 현재 작업 파일의 연결을 유지합니다.
|
||||
|
||||
@@ -25,6 +56,25 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
|
||||
|
||||
초기 자료는 기술정보 DB의 실제 REA 저장 샘플입니다. `REA 샘플 대조`에서 원본 저장값과 275개 계산 결과를 비교합니다. 기존 ESTX의 기본 수량 연결은 문서·항목 코드와 내역 코드가 유일한 경우 화면에 표시하며, 중복·다른 모드는 원문에 보존하고 경고합니다. 원본 연결 데이터 실자료 검증과 새 연결의 ESTX 쓰기는 아직 지원하지 않습니다. 테스트용 임의 요율을 원본 기본값으로 넣지 않습니다. 새 원가 서식의 요율은 사용자가 입력합니다.
|
||||
|
||||
수량산출서와 수식표에서 내역 행을 직접 선택해 연결할 수 있습니다. 내역 행 선택을 사용하면 코드가 없거나 중복되어도 해당 행에만 수량을 반영합니다. 코드를 직접 입력할 때는 해당 상세 내역이 정확히 하나여야 하며, 대상이 없거나 중복되면 다른 항목을 포함해 반영을 중단하고 오류를 표시합니다.
|
||||
|
||||
수량 화면의 `Excel 셀 수량 연결`은 `.xlsx` 파일과 시트·셀·내역 행을 연결하고 저장된 숫자 값을 반영합니다. 연결 정보는 작업 JSON에 저장되며, `Excel 연결 관리`에서 같은 이름의 파일을 다시 선택해 해당 파일의 연결을 일괄 갱신하거나 연결을 해제할 수 있습니다. 어느 연결에 오류가 있어도 갱신 전체를 중단합니다. 수식 셀은 파일에 저장된 계산값을 사용하며, 저장된 값이 없거나 숫자가 아니면 반영하지 않습니다. Excel 파일은 자동 감시하지 않습니다. 웹 Excel 연결은 원본 ESTX에 저장할 수 없어 JSON으로 보관합니다.
|
||||
전체 자료 Excel의 `ExcelQuantityLinks` 시트에도 연결 정보가 포함됩니다. 연결된 원본 Excel 파일은 별도로 보관해야 합니다.
|
||||
|
||||
수량 화면에서 새 산출서를 만들고 이름과 산출 항목의 품명·규격을 편집할 수 있습니다. `산출 행 추가`로 결과 표지가 연결된 행과 항목을 함께 만든 뒤 수식을 입력하고, 연결 내역코드를 지정해 계산 수량을 내역에 반영합니다. 행 아래 삽입, 위·아래 이동과 산출 행·산출서 삭제도 가능하며, 이 구조 변경은 JSON 저장과 다시 열기를 지원합니다. 기존 연결은 내역코드를 비워 해제할 수 있습니다.
|
||||
|
||||
원본 ESTX의 기존 수량산출 항목은 품명·규격을 수정해 ESTX에 저장할 수 있습니다. 연결 변경과 새 항목 추가·삭제는 계속 JSON으로 보관합니다.
|
||||
품명·규격만 변경한 경우 기존 산식의 저장 수량과 재계산 수량이 다르더라도 산식과 저장 수량을 그대로 유지합니다.
|
||||
산출서 문구를 수정해도 계산 결과와 결과 표지가 바뀌지 않으면 원래 저장 수량을 보존합니다. 원본 결과가 재계산값과 다른 문서에서 계산 결과를 바꾸는 편집은 JSON으로 보관해야 합니다.
|
||||
|
||||
재계산 메뉴의 수량 반영도 현재 선택한 산출서 또는 직접 작성 수식표를 사용합니다. 반영 전에 모든 연결 대상과 수식을 검사하며, 기존 원본 연결 데이터는 그대로 보관합니다.
|
||||
|
||||
재계산 메뉴의 `모든 수량산출 합산 반영`은 모든 산출서와 직접 작성 수식표의 연결 수량을 내역 행별로 합산합니다. 어느 산출서든 계산 오류가 있거나 연결 대상이 모호하면 전체 반영을 중단합니다.
|
||||
|
||||
수량산출서 인쇄 서식에는 직접 작성한 수식표와 산출서의 산식, 계산 수량, 연결 내역이 함께 표시됩니다. 계산할 수 없는 수식은 오류를 표시합니다.
|
||||
|
||||
산출 행의 결과 표지 개수가 연결 정보와 다르면 계산 오류로 표시하며, 기존 저장 수량을 내역에 반영하지 않습니다. 화면이나 Excel에서 수식을 수정할 때 결과 표지와 연결 항목을 유지하세요.
|
||||
|
||||
## 검증과 남은 범위
|
||||
|
||||
기계 성분·합계 48건, 일위 41건, 내역 37건, 산출 결과 118건과 호표 8건, 원가 수식 23건을 대조합니다. 원가 도급액 고정 반복 차이 `324 → -32 → 3 → 0`을 재현합니다. 단가 갱신 후 전체 재계산과 반복 계산의 안정성도 시험합니다.
|
||||
@@ -39,7 +89,9 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
|
||||
|
||||
단가 변경은 산근 소계(61), 비목 참조 비율(58), 선택 구간 합계(62)를 거쳐 내역으로 전달됩니다. 산출근거 편집기의 `구간지정`·`구간합계` 표지로 A~Z 묶음을 제어합니다. 소계 표지 1,433개를 추가한 대용량 원본 대조는 115,145개 일치·해석 오류 0이며, 원본 산근 낙찰 행(52)의 성분 계산과 연결 편집도 회귀 시험합니다.
|
||||
|
||||
연차공사의 `[기준]*0.5` 수식은 수량과 각 성분 금액에 각각 적용됩니다. 금액은 정수 절사하고, 다음 차수의 `[기준]-[1차]`로 절사 후 잔액을 배분합니다. 차수 사본은 단가를 비운 고정 금액으로 저장하여 다시 열어도 금액을 유지합니다. `[계약수량]`은 이전 작업 파일과의 호환 표기로 `[기준]`과 같은 역할을 합니다.
|
||||
연차공사의 `[기준]*0.5` 수식은 수량과 각 성분 금액에 각각 적용됩니다. 금액은 정수 절사하고, 다음 차수의 `[기준]-[1차]`로 절사 후 잔액을 배분합니다. 차수 사본은 단가를 비운 고정 금액으로 저장하여 다시 열어도 금액을 유지합니다. `[계약수량]`은 이전 작업 파일과의 호환 표기로 `[기준]`과 같은 역할을 합니다. 참조가 충돌하지 않도록 차수 이름에는 `기준`, `계약`, `계약수량`을 사용할 수 없습니다.
|
||||
|
||||
차수는 위·아래로 이동할 수 있습니다. 앞서 계산되지 않은 차수를 참조하면 오류를 표시하며, 새 차수는 이미 사용 중이거나 수식에서 참조 중인 이름을 피해서 만듭니다.
|
||||
|
||||
연차의 조달수수료는 원본처럼 연결 종류 99로 전환해 금액을 배분하며 요율은 유지합니다. 관급 합계와 안전관리비의 수수료 제외 계산에 배분액을 반영하고, 합산 제외 행은 제외 상태를 유지합니다. 차수 내역 열기는 해당 내역서를 표시하며, 저장하면 원본과 다른 파일 ID를 부여해 원본을 덮어쓰지 않습니다.
|
||||
|
||||
@@ -59,7 +111,7 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
|
||||
|
||||
2026-11-06 지정 범위인 설계변경·기성·물가변동·연차의 웹앱 구현을 완료했습니다. 설계변경은 당초 보존·재계산 0/1/2와 신규·삭제·변경 비교를 제공합니다. 삭제된 당초/기성 행 번호를 새 행에 재사용하지 않습니다.
|
||||
|
||||
기성은 수량·금액·비율·비목별 입력, 사정 승인, 전회·금회·누계 원가를 계산합니다. 확정 회차는 당시 계약 자료를 보존하고 서버 저장 시 결과를 재검산합니다. 전회 수정은 이후 회차의 확정도 해제하며, 전회 미확정 상태에서 다음 회차를 확정할 수 없습니다.
|
||||
기성은 수량·금액·비율·비목별 입력, 사정 승인, 전회·금회·누계 원가를 계산합니다. 원가 기호가 없는 항목은 이름과 같은 이름의 순서로 전회 금액을 대응합니다. 확정 회차는 당시 계약 자료를 보존하고 서버 저장 시 결과를 재검산합니다. 전회 수정은 이후 회차의 확정도 해제하며, 전회 미확정 상태에서 다음 회차를 확정할 수 없습니다.
|
||||
|
||||
물가변동은 같은 행 구조의 계약·입찰 자료(JSON/ESTX)를 불러와 조사·계약·변동 단가를 나눕니다. 원본 레코드 순서 방식에 따라 행 순서·코드·레벨이 다른 자료는 거부합니다. 등락 분기, 조사금액 기준 조정률, 자동 최대수량/수동 적용제외, 간접비 포함 적용대가와 기성 누계 불러오기를 제공합니다. 기성과 물가변동은 원가계산서의 명시적인 0원 도급액을 보존합니다. 연차는 수식 참조 오류를 차단하며, 미리보기 뒤 선택한 차수만 독립 사본으로 엽니다.
|
||||
|
||||
|
||||
+169
-2
@@ -4,6 +4,13 @@ const xml = (value) =>
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
|
||||
);
|
||||
const excelText = (value) =>
|
||||
String(value)
|
||||
.replace(/_x[\da-f]{4}_/gi, (match) => `_x005F_${match.slice(1)}`)
|
||||
.replace(
|
||||
/[\x00-\x08\x0b-\x0d\x0e-\x1f\ufffe\uffff]/g,
|
||||
(char) => `_x${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}_`,
|
||||
);
|
||||
const column = (index) => {
|
||||
let name = "";
|
||||
for (index++; index; index = Math.floor((index - 1) / 26))
|
||||
@@ -27,7 +34,7 @@ function sheet(rows) {
|
||||
if (typeof v === "boolean") return `<c r="${ref}" t="b"><v>${v ? 1 : 0}</v></c>`;
|
||||
const text = typeof v === "object" ? JSON.stringify(v) : String(v);
|
||||
if (text.length > 32767) throw Error("Excel 셀 문자 한도 초과: " + ref);
|
||||
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${xml(text)}</t></is></c>`;
|
||||
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${xml(excelText(text))}</t></is></c>`;
|
||||
})
|
||||
.join("")}</row>`,
|
||||
)
|
||||
@@ -36,6 +43,20 @@ function sheet(rows) {
|
||||
function workbook(project) {
|
||||
const files = new Map(),
|
||||
sheets = [];
|
||||
const escalationFields = [
|
||||
"ANU",
|
||||
"AGU",
|
||||
"AJU",
|
||||
"ASU",
|
||||
"EscPlanned",
|
||||
"EscActual",
|
||||
"EscProgress",
|
||||
"EscAuto",
|
||||
"EscManualQuantity",
|
||||
"EscConstruction",
|
||||
"EscPS",
|
||||
"EscBefore",
|
||||
];
|
||||
for (const [name, rows] of Object.entries(project.tables)) {
|
||||
if (!Array.isArray(rows) || name.startsWith("Cad") || name.length > 31) continue;
|
||||
// Binary originals stay in JSON/ESTX; Excel edits scalar table fields only.
|
||||
@@ -43,7 +64,11 @@ function workbook(project) {
|
||||
...new Set(
|
||||
rows.flatMap((r) =>
|
||||
Object.keys(r).filter(
|
||||
(k) => !k.startsWith("_") && r[k] != null && typeof r[k] !== "object",
|
||||
(k) =>
|
||||
!k.startsWith("_") &&
|
||||
r[k] != null &&
|
||||
typeof r[k] !== "object" &&
|
||||
!(name === "EstMainDat" && escalationFields.includes(k)),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -59,6 +84,148 @@ function workbook(project) {
|
||||
],
|
||||
});
|
||||
}
|
||||
for (const [name, rows, keys] of [
|
||||
[
|
||||
"Quantities",
|
||||
project.quantities,
|
||||
["Code", "Name", "TargetCode", "TargetKey", "formula", "digits"],
|
||||
],
|
||||
[
|
||||
"Schedule",
|
||||
project.schedule,
|
||||
["id", "SourceKey", "Code", "Name", "start", "end", "weight", "progress"],
|
||||
],
|
||||
["ExcelQuantityLinks", project.excelQuantityLinks, ["target", "file", "sheet", "cell"]],
|
||||
])
|
||||
if (Array.isArray(rows) && rows.length)
|
||||
sheets.push({
|
||||
name,
|
||||
rows: [keys, ...rows.map((row) => keys.map((key) => row[key] ?? null))],
|
||||
});
|
||||
if (project.quantitySheets?.length) {
|
||||
const docs = [["Code", "Name", "digits", "roundMode"]],
|
||||
lines = [["DocumentCode", "Position", "LineData", "Markers"]],
|
||||
items = [
|
||||
[
|
||||
"DocumentCode",
|
||||
"Position",
|
||||
"Code",
|
||||
"Name",
|
||||
"Size",
|
||||
"Unite",
|
||||
"GN",
|
||||
"TargetCode",
|
||||
"TargetKey",
|
||||
],
|
||||
];
|
||||
for (const document of project.quantitySheets) {
|
||||
docs.push([
|
||||
document.Code,
|
||||
document.Name,
|
||||
document.options?.digits ?? 2,
|
||||
document.options?.roundMode ?? 2,
|
||||
]);
|
||||
document.rows.forEach((row, index) =>
|
||||
lines.push([document.Code, index + 1, row.LineData, JSON.stringify(row.markers || [])]),
|
||||
);
|
||||
document.items.forEach((row, index) =>
|
||||
items.push([
|
||||
document.Code,
|
||||
index + 1,
|
||||
row.Code,
|
||||
row.Name,
|
||||
row.Size,
|
||||
row.Unite,
|
||||
row.GN,
|
||||
row.TargetCode ?? row.LinkCode,
|
||||
row.TargetKey,
|
||||
]),
|
||||
);
|
||||
}
|
||||
sheets.push(
|
||||
{ name: "QDocuments", rows: docs },
|
||||
{ name: "QLines", rows: lines },
|
||||
{ name: "QItems", rows: items },
|
||||
);
|
||||
}
|
||||
if (project.contractReview && Object.keys(project.contractReview.edits || {}).length) {
|
||||
const keys = ["RowKey", "GN", "NU", "GU", "JU", "SU", "NC", "GC", "JC", "SC", "Reason"];
|
||||
sheets.push({
|
||||
name: "Review",
|
||||
rows: [
|
||||
keys,
|
||||
...Object.entries(project.contractReview.edits).map(([key, edit]) =>
|
||||
keys.map((field) => (field === "RowKey" ? key : (edit[field] ?? null))),
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
if (project.annual?.length) {
|
||||
const keys = ["name", "formula"];
|
||||
sheets.push({
|
||||
name: "Annual",
|
||||
rows: [keys, ...project.annual.map((row) => keys.map((key) => row[key] ?? null))],
|
||||
});
|
||||
}
|
||||
if (project.tables.EstMainDat?.length) {
|
||||
const keys = ["RowKey", ...escalationFields];
|
||||
const rows = project.tables.EstMainDat.flatMap((row, index) =>
|
||||
Number(row.Level || 0) > 0 ||
|
||||
[54, 55, 57, 58, 59, 62, 67].includes(Number(row.LinkIndex || 0))
|
||||
? []
|
||||
: [
|
||||
keys.map((key) =>
|
||||
key === "RowKey"
|
||||
? String(row.PkID ?? (row.Code || `row:${index}`))
|
||||
: (row[key] ?? null),
|
||||
),
|
||||
],
|
||||
);
|
||||
sheets.push({ name: "Escalation", rows: [keys, ...rows] });
|
||||
sheets.push({
|
||||
name: "EscalationSettings",
|
||||
rows: [["EscDesignTotal"], [project.options?.EscDesignTotal ?? 0]],
|
||||
});
|
||||
}
|
||||
if (project.progress?.length) {
|
||||
const roundKeys = ["id", "name", "approved", "separateComponents", "amountDigits"],
|
||||
inputKeys = [
|
||||
"RoundId",
|
||||
"RowKey",
|
||||
"GumMode",
|
||||
"Gum",
|
||||
"GumNC",
|
||||
"GumGC",
|
||||
"GumJC",
|
||||
"GumSC",
|
||||
"SaMode",
|
||||
"Sa",
|
||||
"SaNC",
|
||||
"SaGC",
|
||||
"SaJC",
|
||||
"SaSC",
|
||||
"QuantityDigits",
|
||||
"AmountDigits",
|
||||
],
|
||||
drafts = project.progress.filter((round) => !round.locked);
|
||||
sheets.push({
|
||||
name: "ProgressDrafts",
|
||||
rows: [roundKeys, ...drafts.map((row) => roundKeys.map((key) => row[key] ?? null))],
|
||||
});
|
||||
sheets.push({
|
||||
name: "ProgressInputs",
|
||||
rows: [
|
||||
inputKeys,
|
||||
...drafts.flatMap((round) =>
|
||||
Object.entries(round.inputs || {}).map(([key, input]) =>
|
||||
inputKeys.map((field) =>
|
||||
field === "RoundId" ? round.id : field === "RowKey" ? key : (input[field] ?? null),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
sheets.unshift({
|
||||
name: "공사정보",
|
||||
rows: [
|
||||
|
||||
@@ -135,6 +135,17 @@ function updateBlob(file, start, id, bytes) {
|
||||
function exportEstx(project) {
|
||||
if (!project.originalArchive)
|
||||
throw Error("원본 ESTX를 가져온 작업에서만 ESTX 수정 저장이 가능합니다.");
|
||||
if (project.attachments?.length)
|
||||
throw Error("웹 첨부파일은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
|
||||
if (project.excelQuantityLinks?.length)
|
||||
throw Error("웹 Excel 수량 연결은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
|
||||
if (project.schedule?.length)
|
||||
throw Error("웹 예정공정표는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
|
||||
if (
|
||||
project.documents &&
|
||||
Object.values(project.documents).some((text) => typeof text === "string" && text.length > 0)
|
||||
)
|
||||
throw Error("웹 추가 서식 문서는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
|
||||
const original = Buffer.from(project.originalArchive, "base64"),
|
||||
base = importEstx(original),
|
||||
files = unzip(original);
|
||||
@@ -163,10 +174,22 @@ function exportEstx(project) {
|
||||
if (!optionFields[key]) throw Error(`계산 옵션 ${key} 저장 구조 미확인`);
|
||||
setOption(...optionFields[key], project.options[key]);
|
||||
}
|
||||
if (project.progress?.length || project.annual?.length || project.baseline)
|
||||
if (
|
||||
project.progress?.length ||
|
||||
project.annual?.length ||
|
||||
project.baseline ||
|
||||
project.contractReview
|
||||
)
|
||||
throw Error(
|
||||
"웹 기성/연차/당초 사본은 원본 별도 업무 파일 구조가 필요합니다. JSON 저장을 사용하세요.",
|
||||
"웹 기성/연차/당초 사본/계약심사는 원본 별도 업무 파일 구조가 필요합니다. JSON 저장을 사용하세요.",
|
||||
);
|
||||
if (!same(project.schedule || [], base.schedule || []))
|
||||
throw Error("웹 예정공정표는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
|
||||
if (
|
||||
project.documents &&
|
||||
Object.values(project.documents).some((text) => typeof text === "string" && text.length > 0)
|
||||
)
|
||||
throw Error("웹 추가 서식 문서는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
|
||||
if (!same(project.quantitySheets || [], base.quantitySheets || []))
|
||||
project = updateQuantities(project, base, cp949, memory);
|
||||
let changed = 0;
|
||||
|
||||
@@ -39,6 +39,22 @@ function updatePrintOptions(buffer, changes) {
|
||||
value <= 100
|
||||
)
|
||||
output.writeInt32LE(value, field.at);
|
||||
else if (
|
||||
name === "PrintGongMethod" &&
|
||||
field?.kind === 1 &&
|
||||
field.width === 4 &&
|
||||
Number.isInteger(value) &&
|
||||
[0, 1, 2].includes(value)
|
||||
)
|
||||
output.writeInt32LE(value, field.at);
|
||||
else if (
|
||||
["PrintApplyField", "PrintSumBottom"].includes(name) &&
|
||||
field?.kind === 3 &&
|
||||
field.width === 2 &&
|
||||
Number.isInteger(value) &&
|
||||
[0, 1].includes(value)
|
||||
)
|
||||
output.writeInt16LE(value, field.at);
|
||||
else throw Error(`인쇄 서식 ${id}.${name}: 저장할 수 없는 값`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,8 +90,10 @@ function editStyledRuns(div, before, after, parse) {
|
||||
|
||||
// Change text inside an existing styled run, never WPTools tags or bookmarks.
|
||||
function editDocument(text, oldSheet, sheet) {
|
||||
const withoutLabels = (items) => items.map(({ Name, Size, Unite, ...rest }) => rest);
|
||||
if (
|
||||
!same({ ...oldSheet, rows: [] }, { ...sheet, rows: [] }) ||
|
||||
!same({ ...oldSheet, rows: [], items: [] }, { ...sheet, rows: [], items: [] }) ||
|
||||
!same(withoutLabels(oldSheet.items), withoutLabels(sheet.items)) ||
|
||||
oldSheet.rows.length !== sheet.rows.length
|
||||
)
|
||||
throw Error("수량산출서 목록·연결·행 구조 변경은 ESTX 저장을 지원하지 않습니다.");
|
||||
@@ -111,17 +113,28 @@ function editDocument(text, oldSheet, sheet) {
|
||||
if (direct) return direct;
|
||||
const styled = editStyledRuns(div, before, after, parse);
|
||||
if (styled) return styled;
|
||||
const split = /([\x03\x0c\x0e\x11\x12\x13\x15\x1a])/g,
|
||||
const split = /(\^\([^)]+\)|[\x03\x0c\x0e\x11\x12\x13\x15\x1a])/g,
|
||||
isSpecial = (s) => /^\^\([^)]+\)$/.test(s) || /[\x03\x0c\x0e\x11\x12\x13\x15\x1a]/.test(s),
|
||||
previous = before.LineData.split(split),
|
||||
next = after.LineData.split(split);
|
||||
if (previous.length === next.length && previous.every((v, i) => i % 2 === 0 || v === next[i])) {
|
||||
if (
|
||||
previous.length === next.length &&
|
||||
previous.every(
|
||||
(v, i) =>
|
||||
!isSpecial(v) ||
|
||||
(isSpecial(next[i]) &&
|
||||
(v === next[i] || (v.startsWith("^(") && next[i].startsWith("^(")))),
|
||||
)
|
||||
) {
|
||||
let candidate = div,
|
||||
current = before;
|
||||
for (let i = 0; i < previous.length; i += 2) {
|
||||
for (let i = 0; i < previous.length; i++) {
|
||||
if (previous[i] === next[i]) continue;
|
||||
previous[i] = next[i];
|
||||
const updated = { ...current, LineData: previous.join("") };
|
||||
const patched = editRun(candidate, current, updated, parse);
|
||||
const patched =
|
||||
editRun(candidate, current, updated, parse) ||
|
||||
editStyledRuns(candidate, current, updated, parse);
|
||||
if (!patched) break;
|
||||
candidate = patched;
|
||||
current = updated;
|
||||
@@ -158,11 +171,45 @@ function updateQuantities(project, base, cp949, memory) {
|
||||
const text = new TextDecoder("euc-kr", { fatal: true }).decode(bytes);
|
||||
if (!cp949(text).equals(bytes)) throw Error("수량산출서 원본 인코딩 보존 불가");
|
||||
let document = editDocument(text, originals[i], sheets[i]);
|
||||
if (same(sheets[i].rows, originals[i].rows)) {
|
||||
if (!same(row.MulList, oldRow.MulList)) throw Error("수량산출서 목록 데이터 충돌");
|
||||
row.MulList = {
|
||||
binary: memory(Buffer.from(row.MulList.binary, "base64"), sheets[i].items).toString(
|
||||
"base64",
|
||||
),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
const baseline = calculateQuantity(originals[i]),
|
||||
calculated = calculateQuantity(sheets[i]);
|
||||
if (calculated.errors.length || baseline.errors.length)
|
||||
throw Error(`수량산출서 계산 오류: ${calculated.errors[0] || baseline.errors[0]}`);
|
||||
const markerCount = originals[i].rows.reduce((total, row) => total + row.markers.length, 0);
|
||||
const results = (value) => ({
|
||||
checks: value.checks.map(({ name, calculated }) => ({ name, calculated })),
|
||||
items: value.items.map(({ Code, GN }) => ({ Code, GN })),
|
||||
});
|
||||
const resultMarkers = (sheet) =>
|
||||
sheet.rows.map((row) => [...row.LineData.matchAll(/\x12[^\x12]*\x12/g)].map((m) => m[0]));
|
||||
if (
|
||||
baseline.checks.length === markerCount &&
|
||||
calculated.checks.length === markerCount &&
|
||||
baseline.checks.every((value) => Number.isFinite(value.calculated)) &&
|
||||
calculated.checks.every((value) => Number.isFinite(value.calculated)) &&
|
||||
same(resultMarkers(originals[i]), resultMarkers(sheets[i])) &&
|
||||
same(results(baseline), results(calculated))
|
||||
) {
|
||||
if (!same(row.MulList, oldRow.MulList)) throw Error("수량산출서 목록 데이터 충돌");
|
||||
if (!same(sheets[i].items, originals[i].items))
|
||||
row.MulList = {
|
||||
binary: memory(Buffer.from(row.MulList.binary, "base64"), sheets[i].items).toString(
|
||||
"base64",
|
||||
),
|
||||
};
|
||||
const output = cp949(document);
|
||||
row.Editor = { binary: (compressed ? zlib.deflateSync(output) : output).toString("base64") };
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
baseline.checks.length !== markerCount ||
|
||||
calculated.checks.length !== markerCount ||
|
||||
@@ -191,7 +238,7 @@ function updateQuantities(project, base, cp949, memory) {
|
||||
if (current.rows[n].LineData !== desired) throw Error("수량산출 결과 재계산 검산 실패");
|
||||
}
|
||||
if (!same(row.MulList, oldRow.MulList)) throw Error("수량산출서 목록 데이터 충돌");
|
||||
if (!same(calculated.items, current.items)) {
|
||||
if (!same(calculated.items, originals[i].items)) {
|
||||
row.MulList = {
|
||||
binary: memory(Buffer.from(row.MulList.binary, "base64"), calculated.items).toString(
|
||||
"base64",
|
||||
|
||||
@@ -381,6 +381,7 @@ function importEstx(buffer, name = "가져온 내역서") {
|
||||
printCatalog,
|
||||
progress: [],
|
||||
annual: [],
|
||||
schedule: [],
|
||||
drawings: [],
|
||||
importWarnings: [...warnings, "활성 원가 서식과 인덱스 편집 규칙은 자동 확정하지 않았습니다."],
|
||||
originalArchive: buffer.toString("base64"),
|
||||
|
||||
@@ -46,7 +46,18 @@ function readPrintOptions(buffer, onRow) {
|
||||
state = (status[i >> 2] >> ((i % 4) * 2)) & 3;
|
||||
if (state === 1 || state === 2) continue;
|
||||
if (state !== 0) throw Error("인쇄 설정 필드 상태 오류");
|
||||
if (["PrintTop", "PrintBottom", "PrintLeft", "PrintRight", "PrintLineCount"].includes(name))
|
||||
if (
|
||||
[
|
||||
"PrintTop",
|
||||
"PrintBottom",
|
||||
"PrintLeft",
|
||||
"PrintRight",
|
||||
"PrintLineCount",
|
||||
"PrintGongMethod",
|
||||
"PrintApplyField",
|
||||
"PrintSumBottom",
|
||||
].includes(name)
|
||||
)
|
||||
positions[name] = { at, kind, width };
|
||||
if (kind === 1 && width === 4) row[name] = take(4).readInt32LE();
|
||||
else if (kind === 3 && width === 2) row[name] = take(2).readInt16LE();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!doctype html>
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -16,8 +16,16 @@
|
||||
<script defer src="native-escalation.js"></script>
|
||||
<script defer src="native-progress.js"></script>
|
||||
<script defer src="native-business.js"></script>
|
||||
<script defer src="native-review.js"></script>
|
||||
<script defer src="native-resources.js"></script>
|
||||
<script defer src="native-schedule.js"></script>
|
||||
<script defer src="native-issues.js"></script>
|
||||
<script defer src="native-haljung.js"></script>
|
||||
<script defer src="native-vat.js"></script>
|
||||
<script defer src="native-attachments.js"></script>
|
||||
<script defer src="native-standard.js"></script>
|
||||
<script defer src="native-estimate.js"></script>
|
||||
<script defer src="native-basic-prices.js"></script>
|
||||
<script defer src="native-model.js"></script>
|
||||
<script defer src="native-views.js"></script>
|
||||
<script defer src="native-extra.js"></script>
|
||||
@@ -26,6 +34,7 @@
|
||||
<script defer src="native-forms.js"></script>
|
||||
<script defer src="native-exchange.js"></script>
|
||||
<script defer src="native-c3r.js"></script>
|
||||
<script defer src="native-block-actions.js"></script>
|
||||
<script defer src="native-actions.js"></script>
|
||||
<script defer src="native-business-actions.js"></script>
|
||||
<script defer src="native-app.js"></script>
|
||||
@@ -70,7 +79,22 @@
|
||||
><button data-action="paste-row">행 붙여넣기</button
|
||||
><button data-action="delete-row">선택 행 삭제</button>
|
||||
<hr />
|
||||
<button data-action="block-start">블럭 시작</button
|
||||
><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>
|
||||
<hr />
|
||||
<button data-action="find">찾기</button>
|
||||
<button data-action="uses-data-search">데이터 사용위치 찾기</button>
|
||||
<button data-action="replace-cost-words">산출근거 문구 바꾸기</button>
|
||||
<button data-action="set-cost-line-kind">산출근거 비목 지정/해제</button>
|
||||
<button data-action="toggle-cost-comment">산출근거 주석 지정/해제</button>
|
||||
<button data-action="set-row-precision">선택 행 자릿수 설정</button>
|
||||
<button data-action="toggle-jc-bimog">경비성단가 지정/해제</button>
|
||||
<button data-action="toggle-sun-gongsa">순공사비 계산끝 지정/해제</button>
|
||||
<button data-action="align-wonga-total">공사비역산 자리수 맞추기</button>
|
||||
<button data-action="clear-estimate-quantities">내역 수량 초기화</button>
|
||||
</div>
|
||||
</details>
|
||||
<details>
|
||||
@@ -94,6 +118,7 @@
|
||||
<button data-action="recalculate">일건서류 전체 재계산</button
|
||||
><button data-action="recalculate">내역서 재계산</button
|
||||
><button data-action="apply-quantity">수량산출 반영 후 재계산</button>
|
||||
<button data-action="apply-all-quantities">모든 수량산출 합산 반영</button>
|
||||
</div>
|
||||
</details>
|
||||
<details>
|
||||
@@ -108,11 +133,23 @@
|
||||
<summary>작업모드(M)</summary>
|
||||
<div class="menu">
|
||||
<button data-view="EstMainDat">내역서</button
|
||||
><button data-view="standard">표준시장단가 사용 내역</button
|
||||
><button data-view="standard-change">표준시장단가 설계변경</button
|
||||
><button data-view="estimate-usage">견적단가 사용 내역</button
|
||||
><button data-view="estimate-change">견적단가 설계변경</button
|
||||
><button data-view="material-usage">자재단가 사용 내역</button
|
||||
><button data-view="material-change">자재단가 설계변경</button
|
||||
><button data-view="labor-usage">노임단가 사용 내역</button
|
||||
><button data-view="labor-change">노임단가 설계변경</button
|
||||
><button data-view="machine-usage">기계경비 사용 내역</button
|
||||
><button data-view="machine-change">기계경비 설계변경</button
|
||||
><button data-view="change">설계변경</button><button data-view="progress">기성고</button
|
||||
><button data-view="escalation">물가변동</button
|
||||
><button data-view="annual">연차공사</button
|
||||
><button data-view="annual">연차공사</button><button data-view="review">계약심사</button
|
||||
><button data-view="resources">동원인원·자재·장비</button>
|
||||
<button data-view="schedule">예정공정표</button>
|
||||
<button data-view="issues">자료 오류 검색</button>
|
||||
<button data-view="attachments">첨부파일</button>
|
||||
</div>
|
||||
</details>
|
||||
<button data-action="settings" class="menu-button">환경설정</button>
|
||||
@@ -140,9 +177,15 @@
|
||||
<div class="work-title"><b id="view-title">내역서</b><span id="selected-info"></span></div>
|
||||
<div id="standard-tools" class="row-tools">
|
||||
<button id="add-group" data-action="add-group" hidden>묶음 추가</button
|
||||
><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="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
|
||||
><button data-action="delete-row">삭제</button
|
||||
><button id="move-estimate-up" data-action="move-estimate-up" hidden>행 ↑</button
|
||||
><button id="move-estimate-down" data-action="move-estimate-down" hidden>행 ↓</button
|
||||
><input
|
||||
id="search"
|
||||
type="search"
|
||||
@@ -166,7 +209,7 @@
|
||||
<footer>
|
||||
<span id="status">시작 중…</span><span>독립 저장 · ESTX 원본은 변경하지 않음</span>
|
||||
</footer>
|
||||
<input id="open-file" type="file" accept=".json,.estx" hidden />
|
||||
<input id="open-file" type="file" accept=".json,.estx,.ebkx" hidden />
|
||||
<dialog id="dialog">
|
||||
<div class="dialog-head">
|
||||
<b id="dialog-title"></b><button data-action="close-dialog" aria-label="닫기">×</button>
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// 안티그래비티 Orca 상태 및 EST 5175 헬스 백그라운드 감시 스크립트
|
||||
// 상태 변경 시에만 기록하며, 안티그래비티 완료/대기 또는 서버 장애 시 Codex Orchestration Run으로 알림 전송.
|
||||
// 자동 재시작이나 코드 수정 지시는 일절 포함하지 않음.
|
||||
const http = require("node:http");
|
||||
const { execFile } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const TARGET_RUN = "run_cdb1558b9280";
|
||||
const logFile = path.join(__dirname, "monitor.log");
|
||||
const pidFile = path.join(__dirname, "monitor.pid");
|
||||
const MAX_LOG_BYTES = 512 * 1024; // 512KB 순환
|
||||
|
||||
// 중복 실행 방지
|
||||
if (fs.existsSync(pidFile)) {
|
||||
try {
|
||||
const existingPid = Number(fs.readFileSync(pidFile, "utf8").trim());
|
||||
process.kill(existingPid, 0);
|
||||
console.log(`[monitor] 이미 감시 프로세스가 실행 중입니다 (PID: ${existingPid}). 종료합니다.`);
|
||||
process.exit(0);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
fs.writeFileSync(pidFile, String(process.pid), "utf8");
|
||||
|
||||
function cleanup() {
|
||||
try {
|
||||
if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
|
||||
} catch {}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
process.on("exit", () => {
|
||||
try {
|
||||
if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
function log(message) {
|
||||
const line = `[${new Date().toISOString()}] ${message}\n`;
|
||||
try {
|
||||
if (fs.existsSync(logFile) && fs.statSync(logFile).size > MAX_LOG_BYTES) {
|
||||
const old = fs.readFileSync(logFile, "utf8");
|
||||
fs.writeFileSync(logFile, old.slice(-MAX_LOG_BYTES / 2), "utf8");
|
||||
}
|
||||
fs.appendFileSync(logFile, line, "utf8");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
log(`감시 시작 (PID: ${process.pid})`);
|
||||
|
||||
let lastAntigravityState = null;
|
||||
let lastHealthStatus = null;
|
||||
let notifiedDone = false;
|
||||
let notifiedFailure = false;
|
||||
|
||||
const ORCA_BIN = "C:\\Users\\umsan\\AppData\\Local\\Programs\\orca\\resources\\bin\\orca.exe";
|
||||
|
||||
function sendOrchestrationNotification(subject, body, type = "status") {
|
||||
// 민감한 환경변수 노출 방지 및 타임아웃 5초 설정 (shell: false로 공백/인자 안정성 확보)
|
||||
execFile(
|
||||
ORCA_BIN,
|
||||
[
|
||||
"orchestration",
|
||||
"send",
|
||||
"--to",
|
||||
`run:${TARGET_RUN}`,
|
||||
"--type",
|
||||
type,
|
||||
"--subject",
|
||||
subject,
|
||||
"--body",
|
||||
body,
|
||||
"--json",
|
||||
],
|
||||
{ timeout: 5000, shell: false },
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
log(`Orchestration 알림 실패: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = JSON.parse(stdout);
|
||||
if (res.ok) {
|
||||
log(`Orchestration 알림 성공: messageId=${res.result?.messageId}`);
|
||||
} else {
|
||||
log(`Orchestration 알림 거부: ${JSON.stringify(res.error || res)}`);
|
||||
}
|
||||
} catch (e) {
|
||||
log(`Orchestration 응답 파싱 오류: ${stdout}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function checkOrcaAntigravity() {
|
||||
execFile(
|
||||
ORCA_BIN,
|
||||
["worktree", "ps", "--json"],
|
||||
{ timeout: 4000, maxBuffer: 1024 * 1024, shell: false },
|
||||
(error, stdout) => {
|
||||
if (error) {
|
||||
if (lastAntigravityState !== `error:${error.message}`) {
|
||||
lastAntigravityState = `error:${error.message}`;
|
||||
log(`Orca 확인 실패: ${error.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
const codexWorktree = data.result?.worktrees?.find((w) =>
|
||||
/D:\/aislo-wt\/CODEX/i.test(w.path || ""),
|
||||
);
|
||||
const antigravityAgent = codexWorktree?.agents?.find((a) => a.agentType === "antigravity");
|
||||
const currentState = antigravityAgent?.state || codexWorktree?.status || "unknown";
|
||||
|
||||
if (currentState !== lastAntigravityState) {
|
||||
const prevState = lastAntigravityState;
|
||||
lastAntigravityState = currentState;
|
||||
log(`안티그래비티 작업 상태 변경: ${prevState} -> ${currentState}`);
|
||||
|
||||
// 안티그래비티 완료/대기 전환 시 단발성 자동 알림
|
||||
if ((currentState === "done" || currentState === "idle") && !notifiedDone) {
|
||||
notifiedDone = true;
|
||||
sendOrchestrationNotification(
|
||||
"안티그래비티 작업 완료 보고",
|
||||
`안티그래비티 상태가 ${currentState}로 전이되었습니다. 검토를 재개할 수 있습니다.`,
|
||||
"status",
|
||||
);
|
||||
} else if (currentState === "working") {
|
||||
notifiedDone = false; // 다시 작업 중이 되면 플래그 리셋
|
||||
}
|
||||
}
|
||||
} catch (parseErr) {
|
||||
if (lastAntigravityState !== `parse-error:${parseErr.message}`) {
|
||||
lastAntigravityState = `parse-error:${parseErr.message}`;
|
||||
log(`Orca JSON 파싱 오류: ${parseErr.message}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function checkServerHealth() {
|
||||
const req = http.get("http://100.65.141.15:5175/api/health", { timeout: 3000 }, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
const statusStr = `HTTP ${res.statusCode}`;
|
||||
if (statusStr !== lastHealthStatus) {
|
||||
lastHealthStatus = statusStr;
|
||||
log(`EST 5175 서버 상태 변경: ${statusStr} (${data.trim()})`);
|
||||
}
|
||||
if (res.statusCode === 200) {
|
||||
notifiedFailure = false;
|
||||
} else if (!notifiedFailure) {
|
||||
notifiedFailure = true;
|
||||
sendOrchestrationNotification(
|
||||
"EST 5175 서버 장애 경고",
|
||||
`EST 독립 서버 응답이 비정상입니다 (${statusStr}). 확인이 필요합니다.`,
|
||||
"status",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", (err) => {
|
||||
const errStr = `error:${err.message}`;
|
||||
if (errStr !== lastHealthStatus) {
|
||||
lastHealthStatus = errStr;
|
||||
log(`EST 5175 접속 실패: ${err.message}`);
|
||||
}
|
||||
if (!notifiedFailure) {
|
||||
notifiedFailure = true;
|
||||
sendOrchestrationNotification(
|
||||
"EST 5175 서버 접속 불가",
|
||||
`EST 독립 서버 연결에 실패했습니다 (${err.message}).`,
|
||||
"status",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
const timeoutStr = "timeout";
|
||||
if (timeoutStr !== lastHealthStatus) {
|
||||
lastHealthStatus = timeoutStr;
|
||||
log("EST 5175 응답 시간 초과");
|
||||
}
|
||||
if (!notifiedFailure) {
|
||||
notifiedFailure = true;
|
||||
sendOrchestrationNotification(
|
||||
"EST 5175 서버 응답 시간 초과",
|
||||
"EST 독립 서버 헬스 체크 응답 시간이 초과되었습니다.",
|
||||
"status",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function poll() {
|
||||
checkOrcaAntigravity();
|
||||
checkServerHealth();
|
||||
}
|
||||
|
||||
poll();
|
||||
setInterval(poll, 15000);
|
||||
@@ -1,8 +1,8 @@
|
||||
const EstActions = (() => {
|
||||
let pending = null,
|
||||
clipboard = [],
|
||||
clipboardTable = "",
|
||||
restoring = false;
|
||||
const uid = () => globalThis.crypto.randomUUID();
|
||||
function form(title, fields, callback) {
|
||||
const { esc, showDialog } = EstApp;
|
||||
showDialog(
|
||||
@@ -143,7 +143,20 @@ const EstActions = (() => {
|
||||
if (confirm("현재 작업을 REA 샘플로 바꾸시겠습니까?"))
|
||||
EstApp.setProject(await api("/api/sample"));
|
||||
} else if (name === "open") EstApp.byId("open-file").click();
|
||||
else if (name === "save" || name === "save-as") await EstApp.save(name === "save-as");
|
||||
else if (name === "save") await EstApp.save();
|
||||
else if (name === "save-as")
|
||||
form(
|
||||
"다른 이름으로 저장",
|
||||
[{ name: "name", label: "작업 이름", value: project.name }],
|
||||
async (values) => {
|
||||
const nextName = values.name.trim();
|
||||
const input = document.querySelector('#dialog-content [name="name"]');
|
||||
input.setCustomValidity(nextName ? "" : "작업 이름을 입력하세요.");
|
||||
if (!input.reportValidity()) throw Error("작업 이름을 입력하세요.");
|
||||
EstApp.change(() => (project.name = nextName), false);
|
||||
await EstApp.save(true);
|
||||
},
|
||||
);
|
||||
else if (name === "undo") await undo();
|
||||
else if (name === "redo") await undo(true);
|
||||
else if (name === "add-row") insertRow();
|
||||
@@ -158,9 +171,12 @@ const EstActions = (() => {
|
||||
for (let i = group.start; i < group.start + group.rows.length; i++) indices.add(i);
|
||||
clipboard = [...indices].sort((a, b) => a - b).map((i) => structuredClone(rows[i]));
|
||||
if (!clipboard.length) throw Error("복사할 행을 선택하세요.");
|
||||
clipboardTable = state.view;
|
||||
if (name === "cut-row") deleteRows();
|
||||
} else if (name === "paste-row") {
|
||||
if (!clipboard.length) throw Error("복사한 행이 없습니다.");
|
||||
if (clipboardTable !== state.view)
|
||||
throw Error("복사한 행과 같은 종류의 자료에 붙여넣으세요.");
|
||||
const rows = activeRows();
|
||||
EstApp.change(() => {
|
||||
let at = (selected().at(-1) ?? rows.length - 1) + 1;
|
||||
@@ -190,7 +206,14 @@ const EstActions = (() => {
|
||||
);
|
||||
}
|
||||
if (grouped && Number(r.Index) > 0 && r.Code) {
|
||||
r.Code = `${r.Code}_${next}`;
|
||||
const base = r.Code;
|
||||
let suffix = next;
|
||||
do {
|
||||
r.Code = `${base}_${suffix++}`;
|
||||
} while (
|
||||
rows.some((row) => row.Code === r.Code) ||
|
||||
copies.some((row) => row !== r && Number(row.Index) > 0 && row.Code === r.Code)
|
||||
);
|
||||
r.Index = ++nextIndex;
|
||||
groupCode = r.Code;
|
||||
}
|
||||
@@ -210,6 +233,7 @@ const EstActions = (() => {
|
||||
const level = Number(node.dataset.level);
|
||||
EstApp.change(() => selected().forEach((i) => (activeRows()[i].Level = level)));
|
||||
} else if (name === "export-xlsx") await EstExchange.exportExcel();
|
||||
else if (name === "export-report-xlsx") await EstReport.exportExcel();
|
||||
else if (name === "export-estx") await EstExchange.exportNative();
|
||||
else if (name === "export-jdl") await EstExchange.exportJdl();
|
||||
else if (name === "import-jdl") EstApp.byId("jdl-file").click();
|
||||
@@ -397,45 +421,29 @@ const EstActions = (() => {
|
||||
);
|
||||
},
|
||||
);
|
||||
else if (name === "add-quantity")
|
||||
EstApp.change(
|
||||
() =>
|
||||
project.quantities.push({
|
||||
Code: `Q${project.quantities.length + 1}`,
|
||||
Name: "새 수량",
|
||||
TargetCode: "",
|
||||
formula: "1",
|
||||
digits: 2,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
else if (name === "apply-quantity") {
|
||||
const values = Object.create(null),
|
||||
totals = new Map();
|
||||
for (const row of project.quantities) {
|
||||
const value = EstMath.floating(
|
||||
EstExpression.evaluate(row.formula, (key) => values[key]),
|
||||
row.digits ?? 2,
|
||||
1,
|
||||
);
|
||||
values[row.Code] = value;
|
||||
if (row.TargetCode) totals.set(row.TargetCode, (totals.get(row.TargetCode) || 0) + value);
|
||||
}
|
||||
if (!totals.size) throw Error("연결 내역코드를 입력하세요.");
|
||||
if (confirm("연결된 내역 수량을 산출 결과로 바꾸시겠습니까?"))
|
||||
else if (name === "restore-bid") {
|
||||
if (confirm("당초단가(100%)로 복원하시겠습니까?"))
|
||||
EstApp.change(() => {
|
||||
for (const row of project.tables.EstMainDat)
|
||||
if (totals.has(row.Code)) {
|
||||
row.GN = totals.get(row.Code);
|
||||
row.MullangIndex = 1;
|
||||
row.ExcelSheetIndex = 0;
|
||||
row.MulCode = row.Code;
|
||||
}
|
||||
state.project = EstBusiness.restoreBid(project);
|
||||
});
|
||||
} else if (name === "add-quantity")
|
||||
EstApp.change(() => {
|
||||
project.quantities.push({
|
||||
Code: EstQuantity.newCode(project.quantities),
|
||||
Name: "새 수량",
|
||||
TargetCode: "",
|
||||
formula: "1",
|
||||
digits: 2,
|
||||
});
|
||||
}, false);
|
||||
else if (name === "apply-quantity") {
|
||||
const totals = EstQuantity.totals(project, state.quantitySheet);
|
||||
if (confirm("연결된 내역 수량을 산출 결과로 바꾸시겠습니까?"))
|
||||
EstApp.change(() => EstQuantity.applyTotals(project, totals));
|
||||
} else if (name === "add-round") {
|
||||
const next = {
|
||||
id: uid(),
|
||||
name: `${project.progress.length + 1}회 기성`,
|
||||
id: EstApp.uid(),
|
||||
name: EstBusiness.newProgressName(project.progress),
|
||||
inputs: {},
|
||||
approved: false,
|
||||
locked: false,
|
||||
@@ -453,10 +461,24 @@ const EstActions = (() => {
|
||||
}
|
||||
} else if (name === "add-annual")
|
||||
EstApp.change(
|
||||
() => project.annual.push({ name: `${project.annual.length + 1}차`, formula: "[기준]" }),
|
||||
() =>
|
||||
project.annual.push({
|
||||
name: EstBusiness.newAnnualName(project.annual),
|
||||
formula: "[기준]",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
else if (name === "open-annual") {
|
||||
else if (name === "move-annual") {
|
||||
const index = Number(node.dataset.row),
|
||||
next = index + Number(node.dataset.direction);
|
||||
if (index >= 0 && index < project.annual.length && next >= 0 && next < project.annual.length)
|
||||
EstApp.change(() => {
|
||||
[project.annual[index], project.annual[next]] = [
|
||||
project.annual[next],
|
||||
project.annual[index],
|
||||
];
|
||||
}, false);
|
||||
} else if (name === "open-annual") {
|
||||
const result = EstBusiness.annualCopy(project, Number(node.dataset.row));
|
||||
if (confirm("현재 화면을 차수 내역 사본으로 바꾸시겠습니까?")) {
|
||||
EstApp.setProject(result);
|
||||
@@ -479,6 +501,7 @@ const EstActions = (() => {
|
||||
EstSchedule.install();
|
||||
EstExchange.install();
|
||||
EstBusinessActions.install();
|
||||
EstIssues.install();
|
||||
document.addEventListener("click", async (event) => {
|
||||
try {
|
||||
const marker = event.target.closest("[data-marker]");
|
||||
@@ -555,6 +578,8 @@ const EstActions = (() => {
|
||||
if (node.matches("[data-progress]")) {
|
||||
const round = project.progress.find((r) => r.id === state.roundId);
|
||||
if (round.locked) throw Error("확정 회차입니다.");
|
||||
if (EstProgress.ambiguousKeys(project.tables.EstMainDat).has(node.dataset.key))
|
||||
throw Error("기성 내역 식별자가 중복되어 입력할 수 없습니다.");
|
||||
const value = node.type === "number" ? Number(node.value) : node.value;
|
||||
if (node.type === "number" && !Number.isFinite(value)) throw Error("수치 입력 오류");
|
||||
EstApp.change(() => {
|
||||
@@ -566,11 +591,12 @@ const EstActions = (() => {
|
||||
if (node.matches("[data-extra]")) {
|
||||
const value = node.type === "number" ? Number(node.value) : node.value;
|
||||
if (node.type === "number" && !Number.isFinite(value)) throw Error("수치 입력 오류");
|
||||
EstApp.change(
|
||||
() =>
|
||||
(project[node.dataset.extra][Number(node.dataset.row)][node.dataset.field] = value),
|
||||
false,
|
||||
);
|
||||
EstApp.change(() => {
|
||||
const row = project[node.dataset.extra][Number(node.dataset.row)];
|
||||
row[node.dataset.field] = value;
|
||||
if (node.dataset.extra === "quantities" && node.dataset.field === "TargetCode")
|
||||
row.TargetKey = "";
|
||||
}, false);
|
||||
return;
|
||||
}
|
||||
if (node.matches("[data-table][data-field]")) {
|
||||
@@ -599,7 +625,9 @@ const EstActions = (() => {
|
||||
const previous = row.Code;
|
||||
EstModel.editCode(project, node.dataset.table, Number(node.dataset.row), value);
|
||||
if (state.group === previous && Number(row.Index) > 0) state.group = row.Code;
|
||||
} else row[key] = value;
|
||||
} else if (key === "SCheck")
|
||||
EstBusinessActions.setClassification(project, row, node, value);
|
||||
else row[key] = value;
|
||||
if (state.view === "escalation" && EstMath.units.includes(key))
|
||||
project.options.SourceRefresh = false;
|
||||
if (["StuffcDat", "LaborcDat", "EstiMateDat", "SilJukDat"].includes(node.dataset.table)) {
|
||||
@@ -635,19 +663,16 @@ const EstActions = (() => {
|
||||
EstApp.render();
|
||||
});
|
||||
EstApp.byId("previous-page").addEventListener("click", () => {
|
||||
EstApp.state.page--;
|
||||
EstApp.render();
|
||||
EstApp.state.page--; EstApp.render();
|
||||
});
|
||||
EstApp.byId("next-page").addEventListener("click", () => {
|
||||
EstApp.state.page++;
|
||||
EstApp.render();
|
||||
EstApp.state.page++; EstApp.render();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
EstApp.save().catch((error) => {
|
||||
EstApp.state.notice = error.message;
|
||||
EstApp.render();
|
||||
EstApp.state.notice = error.message; EstApp.render();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -667,6 +692,8 @@ const EstActions = (() => {
|
||||
}
|
||||
}
|
||||
});
|
||||
EstAttachments.install();
|
||||
EstStandard.install();
|
||||
}
|
||||
return { install, form };
|
||||
return { install, form, undo };
|
||||
})();
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
const EstApp = (() => {
|
||||
const CACHE = "est-web-v2-working";
|
||||
function uid() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 15) | 64;
|
||||
bytes[8] = (bytes[8] & 63) | 128;
|
||||
const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0,
|
||||
v = c === "x" ? r : (r & 3) | 8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
// 탭별 인스턴스 격리 식별자:
|
||||
// 1) 같은 탭 내 새로고침 시: sessionStorage 및 window.name이 보존되므로 동일 인스턴스로 복원
|
||||
// 2) 탭 복제(Duplicate tab) 시: Chromium 브라우저는 sessionStorage를 그대로 복제하지만 window.name은 빈 문자열("")로 리셋됨.
|
||||
// 따라서 window.name과 sessionStorage가 불일치하면 복제된 새 탭으로 감지하고 새 tabId를 발급하여 이전 탭의 캐시를 복제한 후 분리 격리함.
|
||||
const TAB_KEY = "est-tab-instance-id";
|
||||
const storage = typeof sessionStorage !== "undefined" ? sessionStorage : null;
|
||||
const win = typeof window !== "undefined" ? window : { name: "" };
|
||||
let currentTabId = storage ? storage.getItem(TAB_KEY) : null;
|
||||
const isDuplicateTab = currentTabId && (!win.name || win.name !== currentTabId);
|
||||
const parentTabId = isDuplicateTab ? currentTabId : null;
|
||||
|
||||
if (!currentTabId || isDuplicateTab) {
|
||||
currentTabId = uid();
|
||||
if (storage) storage.setItem(TAB_KEY, currentTabId);
|
||||
}
|
||||
win.name = currentTabId;
|
||||
|
||||
const CACHE = `est-web-v2-working-${currentTabId}`;
|
||||
const LEGACY_CACHE = "est-web-v2-working";
|
||||
const state = {
|
||||
project: null,
|
||||
view: "EstMainDat",
|
||||
@@ -31,8 +63,21 @@ const EstApp = (() => {
|
||||
progress: "기성고",
|
||||
escalation: "물가변동",
|
||||
annual: "연차공사",
|
||||
review: "계약심사",
|
||||
resources: "동원 집계",
|
||||
schedule: "예정공정표",
|
||||
issues: "자료 오류 검색",
|
||||
attachments: "첨부파일",
|
||||
standard: "표준시장단가 사용 내역",
|
||||
"standard-change": "표준시장단가 설계변경",
|
||||
"estimate-usage": "견적단가 사용 내역",
|
||||
"estimate-change": "견적단가 설계변경",
|
||||
"material-usage": "자재단가 사용 내역",
|
||||
"material-change": "자재단가 설계변경",
|
||||
"labor-usage": "노임단가 사용 내역",
|
||||
"labor-change": "노임단가 설계변경",
|
||||
"machine-usage": "기계경비 사용 내역",
|
||||
"machine-change": "기계경비 설계변경",
|
||||
drawing: "도면·문서",
|
||||
};
|
||||
const esc = (value) =>
|
||||
@@ -49,13 +94,84 @@ const EstApp = (() => {
|
||||
if (!response.ok) throw Error(data.error || `요청 실패: ${response.status}`);
|
||||
return data;
|
||||
}
|
||||
function cache(serialized) {
|
||||
let cacheQueue = Promise.resolve();
|
||||
function openIdb() {
|
||||
return new Promise((resolve) => {
|
||||
if (!globalThis.indexedDB) return resolve(null);
|
||||
const req = indexedDB.open("est-independent-cache", 1);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore("cache");
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => resolve(null);
|
||||
});
|
||||
}
|
||||
let idbPromise = null;
|
||||
function getIdb() {
|
||||
idbPromise ||= openIdb();
|
||||
return idbPromise;
|
||||
}
|
||||
function waitForTx(tx) {
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve(true);
|
||||
tx.onerror = () => reject(tx.error || Error("IndexedDB 트랜잭션 실패"));
|
||||
tx.onabort = () => reject(Error("IndexedDB 트랜잭션 중단"));
|
||||
});
|
||||
}
|
||||
async function saveToStorage(key, value) {
|
||||
try {
|
||||
sessionStorage.setItem(CACHE, serialized ?? JSON.stringify(state.project));
|
||||
sessionStorage.setItem(key, value);
|
||||
const db = await getIdb();
|
||||
if (db) {
|
||||
const tx = db.transaction("cache", "readwrite");
|
||||
tx.objectStore("cache").delete(key);
|
||||
await waitForTx(tx).catch(() => {});
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
state.notice = "임시 저장 용량이 부족합니다. 저장 버튼으로 작업을 보관하세요.";
|
||||
// sessionStorage 할당 초과(약 5~10MB) 시 IndexedDB로 자동 전환 보관
|
||||
try {
|
||||
const db = await getIdb();
|
||||
if (db) {
|
||||
const tx = db.transaction("cache", "readwrite");
|
||||
tx.objectStore("cache").put(value, key);
|
||||
await waitForTx(tx);
|
||||
sessionStorage.removeItem(key);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("IndexedDB 저장 실패:", err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function loadFromStorage(key) {
|
||||
const fromSession = sessionStorage.getItem(key);
|
||||
if (fromSession) return fromSession;
|
||||
try {
|
||||
const db = await getIdb();
|
||||
if (db) {
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction("cache", "readonly");
|
||||
const req = tx.objectStore("cache").get(key);
|
||||
req.onsuccess = () => resolve(req.result || null);
|
||||
req.onerror = () => resolve(null);
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
function cache(serialized) {
|
||||
const payload = serialized ?? JSON.stringify(state.project);
|
||||
const op = cacheQueue
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
const ok = await saveToStorage(CACHE, payload);
|
||||
if (!ok) {
|
||||
state.notice = "임시 저장 공간을 사용할 수 없습니다. 저장 버튼으로 작업을 보관하세요.";
|
||||
}
|
||||
});
|
||||
cacheQueue = op;
|
||||
return op;
|
||||
}
|
||||
function snapshot() {
|
||||
const serialized = JSON.stringify(state.project);
|
||||
if (serialized.length < 10 * 1024 * 1024 || !globalThis.CompressionStream) return serialized;
|
||||
@@ -101,6 +217,7 @@ const EstApp = (() => {
|
||||
state.selection.clear();
|
||||
state.group = "";
|
||||
state.page = 0;
|
||||
state.quantitySheet = state.project.quantitySheets?.[0]?.Code || "manual";
|
||||
state.roundId =
|
||||
state.project.progress.find((round) => round.id === state.roundId)?.id ||
|
||||
state.project.progress[0]?.id;
|
||||
@@ -246,9 +363,9 @@ const EstApp = (() => {
|
||||
)
|
||||
return;
|
||||
let project;
|
||||
if (/\.estx$/i.test(file.name))
|
||||
if (/\.(estx|ebkx)$/i.test(file.name))
|
||||
project = await api(
|
||||
`/api/import?name=${encodeURIComponent(file.name.replace(/\.estx$/i, ""))}`,
|
||||
`/api/import?name=${encodeURIComponent(file.name.replace(/\.(estx|ebkx)$/i, ""))}`,
|
||||
{ method: "POST", body: await file.arrayBuffer() },
|
||||
);
|
||||
else project = JSON.parse(await file.text());
|
||||
@@ -266,10 +383,23 @@ const EstApp = (() => {
|
||||
}
|
||||
async function initialize() {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(CACHE);
|
||||
let saved = await loadFromStorage(CACHE);
|
||||
if (!saved && parentTabId) {
|
||||
// 복제 탭의 경우 부모 탭의 캐시 데이터를 복제하여 초기 상태로 상속
|
||||
const parentSaved = await loadFromStorage(`est-web-v2-working-${parentTabId}`);
|
||||
if (parentSaved) {
|
||||
saved = parentSaved;
|
||||
await saveToStorage(CACHE, saved);
|
||||
}
|
||||
}
|
||||
if (!saved) saved = await loadFromStorage(LEGACY_CACHE);
|
||||
setProject(saved ? JSON.parse(saved) : await api("/api/sample"));
|
||||
EstActions.install();
|
||||
EstQuantity.install();
|
||||
EstReview.install();
|
||||
EstEstimate.install();
|
||||
EstBasicPrices.install();
|
||||
EstMachine.install();
|
||||
} catch (error) {
|
||||
byId("messages").hidden = false;
|
||||
byId("messages").textContent = `시작 실패: ${error.message}`;
|
||||
@@ -280,6 +410,7 @@ const EstApp = (() => {
|
||||
titles,
|
||||
esc,
|
||||
number,
|
||||
uid,
|
||||
byId,
|
||||
api,
|
||||
cache,
|
||||
@@ -297,6 +428,8 @@ const EstApp = (() => {
|
||||
open,
|
||||
showDialog,
|
||||
initialize,
|
||||
parentTabId,
|
||||
currentTabId,
|
||||
};
|
||||
})();
|
||||
document.addEventListener("DOMContentLoaded", () => EstApp.initialize());
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
const EstAttachments = (() => {
|
||||
function render() {
|
||||
const { state, esc, number, byId } = EstApp;
|
||||
const rows = state.project.attachments || [];
|
||||
byId("work-content").innerHTML =
|
||||
`<div class="business-tools"><b>첨부파일</b><label>파일 추가 <input type="file" data-attachment-add multiple></label><span>${rows.length}개</span></div><p>첨부파일은 이 작업의 JSON 저장에 포함됩니다.</p><table class="sheet"><thead><tr><th>파일명</th><th>크기 (바이트)</th><th>작업</th></tr></thead><tbody>${rows.map((row) => `<tr><td>${esc(row.name)}</td><td>${number(row.size)}</td><td><button data-attachment-download="${esc(row.id)}">내려받기</button><button data-attachment-delete="${esc(row.id)}">삭제</button></td></tr>`).join("")}</tbody></table>`;
|
||||
}
|
||||
function read(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(Error(`파일 읽기 실패: ${file.name}`));
|
||||
reader.onload = () =>
|
||||
resolve({
|
||||
id: EstApp.uid(),
|
||||
name: file.name,
|
||||
type: file.type || "",
|
||||
size: file.size,
|
||||
data: String(reader.result).split(",", 2)[1],
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
function requiredBytes(project, files) {
|
||||
const metadata = files.map((file) => ({
|
||||
id: "0".repeat(36),
|
||||
name: file.name,
|
||||
type: file.type || "",
|
||||
size: file.size,
|
||||
data: "",
|
||||
}));
|
||||
const candidate = {
|
||||
...project,
|
||||
id: project.id || "0".repeat(24),
|
||||
attachments: [...(project.attachments || []), ...metadata],
|
||||
};
|
||||
return (
|
||||
new Blob([JSON.stringify(candidate)]).size +
|
||||
files.reduce((sum, file) => sum + 4 * Math.ceil(file.size / 3), 0)
|
||||
);
|
||||
}
|
||||
function install() {
|
||||
document.addEventListener("change", async (event) => {
|
||||
const input = event.target;
|
||||
if (input.dataset.attachmentAdd === undefined || !input.files.length) return;
|
||||
const { state } = EstApp,
|
||||
key = state.documentKey,
|
||||
revision = state.revision;
|
||||
try {
|
||||
const selected = [...input.files];
|
||||
if (requiredBytes(state.project, selected) > 256 * 1024 * 1024)
|
||||
throw Error("첨부파일을 포함한 작업이 저장 한도 256MB를 초과합니다.");
|
||||
const files = await Promise.all(selected.map(read));
|
||||
if (key !== state.documentKey || revision !== state.revision)
|
||||
throw Error("작업이 변경되었습니다. 첨부파일을 다시 추가하세요.");
|
||||
EstApp.change(() => (state.project.attachments ||= []).push(...files), false);
|
||||
} catch (error) {
|
||||
state.notice = error.message;
|
||||
EstApp.render();
|
||||
} finally {
|
||||
input.value = "";
|
||||
}
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
const node = event.target.closest("[data-attachment-download],[data-attachment-delete]");
|
||||
if (!node) return;
|
||||
const { state } = EstApp,
|
||||
id = node.dataset.attachmentDownload ?? node.dataset.attachmentDelete;
|
||||
const index = (state.project.attachments || []).findIndex((item) => item.id === id);
|
||||
if (index < 0) return;
|
||||
if (node.dataset.attachmentDelete !== undefined)
|
||||
EstApp.change(() => state.project.attachments.splice(index, 1), false);
|
||||
else {
|
||||
const item = state.project.attachments[index],
|
||||
text = atob(item.data),
|
||||
bytes = new Uint8Array(text.length);
|
||||
for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i);
|
||||
EstApp.download(item.name, bytes, "application/octet-stream");
|
||||
}
|
||||
});
|
||||
}
|
||||
return { render, install, requiredBytes };
|
||||
})();
|
||||
@@ -0,0 +1,196 @@
|
||||
const EstBasicPrices = (() => {
|
||||
let page = 0;
|
||||
const kinds = {
|
||||
material: {
|
||||
key: "material",
|
||||
table: "StuffcDat",
|
||||
linkIndex: 5,
|
||||
unitField: "GU",
|
||||
costField: "GC",
|
||||
name: "자재단가",
|
||||
},
|
||||
labor: {
|
||||
key: "labor",
|
||||
table: "LaborcDat",
|
||||
linkIndex: 6,
|
||||
unitField: "NU",
|
||||
costField: "NC",
|
||||
name: "노임단가",
|
||||
},
|
||||
};
|
||||
|
||||
function config(view) {
|
||||
if (view === "material" || view === "material-usage" || view === "material-change")
|
||||
return kinds.material;
|
||||
if (view === "labor" || view === "labor-usage" || view === "labor-change") return kinds.labor;
|
||||
return null;
|
||||
}
|
||||
|
||||
function usage(project, kindKey = "material") {
|
||||
const cfg = kinds[kindKey];
|
||||
if (!cfg) throw Error(`알 수 없는 단가 종류: ${kindKey}`);
|
||||
const sources = new Map();
|
||||
for (const row of project.tables[cfg.table] || []) {
|
||||
if (!row.Code) continue;
|
||||
const rows = sources.get(row.Code) || [];
|
||||
rows.push(row);
|
||||
sources.set(row.Code, rows);
|
||||
}
|
||||
return (project.tables.EstMainDat || []).flatMap((row, index) => {
|
||||
if (Number(row.Level) > 0 || Number(row.LinkIndex) !== cfg.linkIndex) return [];
|
||||
const matches = sources.get(row.Code) || [];
|
||||
const source =
|
||||
matches.length === 1 ? EstNative.price(structuredClone(matches[0]), cfg.linkIndex) : null;
|
||||
const appliedPrice =
|
||||
row.TU == null
|
||||
? EstMath.units.reduce((sum, field) => sum + EstMath.num(row[field]), 0)
|
||||
: EstMath.num(row.TU);
|
||||
const sourcePrice = source ? EstMath.num(source.TC) : null;
|
||||
const canApply =
|
||||
matches.length === 1 && sourcePrice != null && Math.abs(appliedPrice - sourcePrice) > 1e-6;
|
||||
return [
|
||||
{
|
||||
index,
|
||||
Code: row.Code || "",
|
||||
Name: row.Name || "",
|
||||
Size: row.Size || "",
|
||||
Unite: row.Unite || "",
|
||||
GN: EstMath.num(row.GN),
|
||||
appliedPrice,
|
||||
sourcePrice,
|
||||
difference: source ? appliedPrice - sourcePrice : null,
|
||||
TC: EstMath.num(row.TC),
|
||||
status: !matches.length ? "연결 없음" : matches.length > 1 ? "코드 중복" : "연결됨",
|
||||
canApply,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function changes(project, kindKey = "material") {
|
||||
const cfg = kinds[kindKey];
|
||||
if (!cfg) throw Error(`알 수 없는 단가 종류: ${kindKey}`);
|
||||
const labels = { new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" };
|
||||
return EstBusiness.changes(project)
|
||||
.filter(
|
||||
(row) =>
|
||||
Number(row.LinkIndex) === cfg.linkIndex ||
|
||||
Number(row.originalLinkIndex) === cfg.linkIndex,
|
||||
)
|
||||
.map((row) => ({ ...row, status: labels[row.status] || row.status }));
|
||||
}
|
||||
|
||||
function applyRowPrice(project, index, kindKey = "material") {
|
||||
const cfg = kinds[kindKey];
|
||||
if (!cfg) throw Error(`알 수 없는 단가 종류: ${kindKey}`);
|
||||
index = Number(index);
|
||||
const row = project.tables?.EstMainDat?.[index];
|
||||
if (!row || Number(row.Level) > 0 || Number(row.LinkIndex) !== cfg.linkIndex)
|
||||
throw Error(`${cfg.name} 내역 행을 찾을 수 없습니다.`);
|
||||
const sources = (project.tables?.[cfg.table] || []).filter(
|
||||
(s) => s.Code && s.Code === row.Code,
|
||||
);
|
||||
if (!sources.length) throw Error(`${cfg.name} 원천 자료가 없습니다: ${row.Code}`);
|
||||
if (sources.length > 1) throw Error(`${cfg.name} 원천 코드가 중복되었습니다: ${row.Code}`);
|
||||
const source = EstNative.price(structuredClone(sources[0]), cfg.linkIndex);
|
||||
const sourcePrice = EstMath.num(source?.TC);
|
||||
if (!Number.isFinite(sourcePrice)) throw Error("기초 단가를 계산할 수 없습니다.");
|
||||
|
||||
// SCheck 재분류가 설정되어 있는 경우 비목 보존
|
||||
const scheck = Number(row.SCheck);
|
||||
if (scheck > 0) {
|
||||
const targetUnit = { 1: "GU", 2: "NU", 3: "JU" }[scheck];
|
||||
for (const u of EstMath.units) row[u] = u === targetUnit ? sourcePrice : 0;
|
||||
} else {
|
||||
// 자재단가는 GU, 노임단가는 NU에 단가 할당
|
||||
for (const u of EstMath.units) row[u] = u === cfg.unitField ? sourcePrice : 0;
|
||||
}
|
||||
|
||||
// AutoCalc가 비활성화된 수동계산 모드에서는 기존 금액을 보존하고, 활성화된 경우에만 재계산
|
||||
if (project.options?.AutoCalc !== false) {
|
||||
EstMath.amounts(row, project.options?.EstDigits || 0, project.options?.TotalFit);
|
||||
} else {
|
||||
row.TU = EstMath.units.reduce((sum, key) => sum + EstMath.num(row[key]), 0);
|
||||
}
|
||||
}
|
||||
|
||||
function applyAllPrices(project, kindKey = "material") {
|
||||
const cfg = kinds[kindKey];
|
||||
if (!cfg) throw Error(`알 수 없는 단가 종류: ${kindKey}`);
|
||||
const list = usage(project, kindKey).filter((item) => item.canApply);
|
||||
if (!list.length) throw Error("적용할 기초 단가 차이가 없습니다.");
|
||||
for (const item of list) applyRowPrice(project, item.index, kindKey);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const { state, esc, number, byId } = EstApp;
|
||||
const cfg = config(state.view);
|
||||
if (!cfg) return;
|
||||
const isChange = state.view.endsWith("-change");
|
||||
const rows = isChange ? changes(state.project, cfg.key) : usage(state.project, cfg.key);
|
||||
page = Math.max(0, Math.min(page, Math.ceil(rows.length / 100) - 1));
|
||||
const visible = rows.slice(page * 100, page * 100 + 100),
|
||||
value = (v) => (v == null ? "" : number(v));
|
||||
|
||||
if (isChange) {
|
||||
byId("work-content").innerHTML =
|
||||
`<div class="business-tools"><b>${cfg.name} 설계변경</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-basic-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-basic-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><table class="sheet"><thead><tr><th>구분</th><th>코드</th><th>품명</th><th>당초 수량</th><th>변경 수량</th><th>당초 금액</th><th>변경 금액</th><th>증감</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.status)}</td><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${value(row.originalGN)}</td><td>${value(row.GN)}</td><td>${value(row.originalTC)}</td><td>${value(row.TC)}</td><td>${value(row.difference)}</td><td>${row.index == null ? "" : `<button data-basic-row="${row.index}">내역 열기</button>`}</td></tr>`).join("")}</tbody></table>`;
|
||||
return;
|
||||
}
|
||||
const canApplyCount = rows.filter((r) => r.canApply).length;
|
||||
byId("work-content").innerHTML =
|
||||
`<div class="business-tools"><b>${cfg.name} 사용 내역</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-basic-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-basic-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button><button data-basic-apply-all="${cfg.key}" ${canApplyCount ? "" : "disabled"}>기초단가 일괄 적용 (${canApplyCount}건)</button></div><p>내역에 적용된 단가와 현재 ${cfg.name} 자료를 비교합니다.</p><table class="sheet"><thead><tr><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>수량</th><th>내역 단가</th><th>기초 단가</th><th>차이</th><th>금액</th><th>연결</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.GN)}</td><td>${value(row.appliedPrice)}</td><td>${value(row.sourcePrice)}</td><td>${value(row.difference)}</td><td>${value(row.TC)}</td><td>${esc(row.status)}</td><td><button data-basic-row="${row.index}">내역 열기</button>${row.canApply ? ` <button data-basic-apply-row="${row.index}" data-kind="${cfg.key}">단가 적용</button>` : ""}</td></tr>`).join("")}</tbody></table>`;
|
||||
}
|
||||
|
||||
function install() {
|
||||
document.addEventListener("click", (event) => {
|
||||
const applyAll = event.target.closest("[data-basic-apply-all]");
|
||||
if (applyAll) {
|
||||
const kindKey = applyAll.dataset.basicApplyAll;
|
||||
const cfg = kinds[kindKey];
|
||||
try {
|
||||
EstApp.change((p) => {
|
||||
const count = applyAllPrices(p, kindKey);
|
||||
EstApp.state.notice = `${cfg?.name || "기초단가"} ${count}건의 단가를 일괄 적용했습니다.`;
|
||||
});
|
||||
} catch (err) {
|
||||
EstApp.state.notice = err.message;
|
||||
EstApp.render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const applyRow = event.target.closest("[data-basic-apply-row]");
|
||||
if (applyRow) {
|
||||
const index = Number(applyRow.dataset.basicApplyRow);
|
||||
const kindKey = applyRow.dataset.kind;
|
||||
try {
|
||||
EstApp.change((p) => {
|
||||
applyRowPrice(p, index, kindKey);
|
||||
EstApp.state.notice = `행 ${index + 1}에 기초단가를 적용했습니다.`;
|
||||
});
|
||||
} catch (err) {
|
||||
EstApp.state.notice = err.message;
|
||||
EstApp.render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const step = event.target.closest("[data-basic-page]");
|
||||
if (step) {
|
||||
page += Number(step.dataset.basicPage);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const node = event.target.closest("[data-basic-row]");
|
||||
if (!node) return;
|
||||
const index = Number(node.dataset.basicRow);
|
||||
if (!EstApp.state.project.tables.EstMainDat[index]) return;
|
||||
EstApp.navigate("EstMainDat");
|
||||
EstApp.state.page = Math.floor(index / 100);
|
||||
EstApp.state.selection = new Set([index]);
|
||||
EstApp.render();
|
||||
});
|
||||
}
|
||||
|
||||
return { usage, changes, render, install, applyRowPrice, applyAllPrices, kinds };
|
||||
})();
|
||||
@@ -0,0 +1,276 @@
|
||||
// REA popMainBlockCut, popMainBlockCopy, popMainBlockPaste, popMainBlockDel 및 블럭 범위 지정 (ActionBlockOneSet, 블럭시작/끝)
|
||||
// 및 popMainSetStr (산출근거 주석 지정/해제), TFRMWONGABASE popupTotal (공사비역산 자리수 맞추기)
|
||||
const EstBlockActions = (() => {
|
||||
let blockStart = null;
|
||||
let blockTable = null;
|
||||
|
||||
function setBlockStart(state) {
|
||||
const rows = state.project.tables[state.view];
|
||||
if (!rows) throw Error("현재 화면에서는 블럭을 지정할 수 없습니다.");
|
||||
const selected = [...state.selection];
|
||||
if (!selected.length) throw Error("블럭 시작 위치로 지정할 행을 선택하세요.");
|
||||
blockStart = selected[0];
|
||||
blockTable = state.view;
|
||||
state.notice = `${blockStart + 1}행이 블럭 시작점으로 설정되었습니다. (블럭 끝 행을 선택 후 '블럭 끝'을 누르세요)`;
|
||||
}
|
||||
|
||||
function setBlockEnd(state) {
|
||||
const rows = state.project.tables[state.view];
|
||||
if (!rows) throw Error("현재 화면에서는 블럭을 지정할 수 없습니다.");
|
||||
if (blockStart == null || blockTable !== state.view) {
|
||||
throw Error("먼저 '블럭 시작'을 지정하세요.");
|
||||
}
|
||||
const selected = [...state.selection];
|
||||
if (!selected.length) throw Error("블럭 끝 위치로 지정할 행을 선택하세요.");
|
||||
const blockEnd = selected[0];
|
||||
const start = Math.min(blockStart, blockEnd);
|
||||
const end = Math.max(blockStart, blockEnd);
|
||||
|
||||
state.selection.clear();
|
||||
for (let i = start; i <= end; i++) {
|
||||
state.selection.add(i);
|
||||
}
|
||||
state.notice = `${start + 1}행부터 ${end + 1}행까지 총 ${end - start + 1}개 행이 블럭으로 선택되었습니다.`;
|
||||
blockStart = null;
|
||||
blockTable = null;
|
||||
}
|
||||
|
||||
function clearBlockSelection(state) {
|
||||
state.selection.clear();
|
||||
blockStart = null;
|
||||
blockTable = null;
|
||||
state.notice = "선택이 해제되었습니다.";
|
||||
}
|
||||
|
||||
function selectAllRows(state) {
|
||||
const rows = state.project.tables[state.view];
|
||||
if (!rows) throw Error("현재 화면에서는 전체 선택을 할 수 없습니다.");
|
||||
state.selection.clear();
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
state.selection.add(i);
|
||||
}
|
||||
state.notice = `전체 ${rows.length}개 행이 선택되었습니다.`;
|
||||
}
|
||||
|
||||
function deleteBlockRows(state) {
|
||||
const rows = state.project.tables[state.view];
|
||||
if (!rows) throw Error("현재 화면에서는 블럭 삭제를 할 수 없습니다.");
|
||||
const indexes = new Set([...state.selection]);
|
||||
if (!indexes.size) throw Error("삭제할 블럭 행을 먼저 선택하세요.");
|
||||
|
||||
// 호표/기계경비 그룹이 있는 경우 호표 제목 행 포함 시 전체 호표 행 포함
|
||||
if (["HeadMainDat", "CostMainDat", "MachMainDat"].includes(state.view)) {
|
||||
for (const group of EstNative.groups(rows).values()) {
|
||||
if (indexes.has(group.start)) {
|
||||
for (let i = group.start; i < group.start + group.rows.length; i++) {
|
||||
indexes.add(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const codes = new Set(
|
||||
[...indexes]
|
||||
.map((i) => rows[i]?.Code)
|
||||
.filter(Boolean)
|
||||
.map((c) => String(c).trim()),
|
||||
);
|
||||
const kind = Number(
|
||||
Object.entries(EstNative.sourceTables).find(([, table]) => table === state.view)?.[0] ||
|
||||
{ HeadMainDat: 103, CostMainDat: 104, MachMainDat: 105 }[state.view],
|
||||
);
|
||||
|
||||
if (
|
||||
kind &&
|
||||
Object.entries(state.project.tables).some(([table, list]) =>
|
||||
list.some((row, i) => {
|
||||
if (table === state.view && indexes.has(i)) return false;
|
||||
const rLink = Number(row.LinkIndex);
|
||||
const rCode = row.Code ? String(row.Code).trim() : "";
|
||||
return rLink === kind && rCode && codes.has(rCode);
|
||||
}),
|
||||
)
|
||||
) {
|
||||
throw Error("다른 항목에서 사용 중입니다. 연결을 먼저 해제하세요.");
|
||||
}
|
||||
|
||||
if (!confirm(`${indexes.size}개 행을 블럭 삭제하시겠습니까?`)) return;
|
||||
|
||||
EstApp.change(() => {
|
||||
[...indexes].sort((a, b) => b - a).forEach((i) => rows.splice(i, 1));
|
||||
state.selection.clear();
|
||||
blockStart = null;
|
||||
blockTable = null;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCostComment(state) {
|
||||
// REA popMainSetStr: 단가산출 산출근거 행을 주석(Index = -1)으로 지정 또는 일반 산출식으로 복원
|
||||
const rows = state.project.tables.CostMainDat;
|
||||
const indices = [...state.selection].sort((a, b) => a - b);
|
||||
if (state.view !== "CostMainDat" || !rows) {
|
||||
throw Error("단가산출 화면에서만 산출근거 행을 주석으로 지정할 수 있습니다.");
|
||||
}
|
||||
if (!indices.length || indices.some((i) => !rows[i])) {
|
||||
throw Error("주석으로 지정하거나 해제할 산출근거 행을 선택하세요.");
|
||||
}
|
||||
|
||||
const plans = [];
|
||||
for (const index of indices) {
|
||||
const row = rows[index];
|
||||
const curIndex = Number(row.Index) || 0;
|
||||
if (curIndex > 0) {
|
||||
throw Error(
|
||||
`행 ${index + 1}: 호표 제목 행은 주석으로 지정할 수 없습니다. 상세 산출근거 행을 선택하세요.`,
|
||||
);
|
||||
}
|
||||
if (curIndex === -20) {
|
||||
throw Error(`행 ${index + 1}: 소계 구분선 행은 주석으로 지정할 수 없습니다.`);
|
||||
}
|
||||
|
||||
const curSCheck = Number(row.SCheck) || 0;
|
||||
const isComment = curIndex === -1 || curSCheck === -1;
|
||||
|
||||
if (isComment) {
|
||||
// 이미 주석인 행 -> 복원
|
||||
let restoreIndex = 0;
|
||||
if (row._origIndex !== undefined) {
|
||||
restoreIndex = Number(row._origIndex);
|
||||
} else if (row.Index_ !== undefined) {
|
||||
restoreIndex = Number(row.Index_);
|
||||
}
|
||||
|
||||
let restoreSCheck = null;
|
||||
if (row._origSCheck !== undefined) {
|
||||
restoreSCheck = Number(row._origSCheck);
|
||||
} else if (row.SCheck_ !== undefined) {
|
||||
restoreSCheck = Number(row.SCheck_);
|
||||
} else if ([-1, -2, -3, -5].includes(Number(row.OCheck))) {
|
||||
restoreSCheck = Number(row.OCheck);
|
||||
}
|
||||
|
||||
plans.push({
|
||||
index,
|
||||
row,
|
||||
isComment: false,
|
||||
restoreIndex,
|
||||
restoreSCheck,
|
||||
});
|
||||
} else {
|
||||
// 일반 상세 행 또는 비목 행(-2, -3, -5 등) -> 주석(-1)으로 전환
|
||||
plans.push({
|
||||
index,
|
||||
row,
|
||||
isComment: true,
|
||||
origIndex: curIndex,
|
||||
origSCheck: row.SCheck !== undefined ? Number(row.SCheck) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
EstApp.change(() => {
|
||||
for (const p of plans) {
|
||||
if (p.isComment) {
|
||||
p.row._origIndex = p.origIndex;
|
||||
if (p.origSCheck !== null) {
|
||||
p.row._origSCheck = p.origSCheck;
|
||||
}
|
||||
p.row.Index = -1;
|
||||
p.row.SCheck = -1;
|
||||
} else {
|
||||
delete p.row._origIndex;
|
||||
delete p.row._origSCheck;
|
||||
p.row.Index = p.restoreIndex;
|
||||
if (p.restoreSCheck !== null) {
|
||||
p.row.SCheck = p.restoreSCheck;
|
||||
} else {
|
||||
delete p.row.SCheck;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function alignWongaTotalAction(state) {
|
||||
// REA TFRMWONGABASE popupTotal: 공사비역산 자리수 맞추기
|
||||
const rows = state.project.tables.Wonga;
|
||||
if (!rows || !rows.length) {
|
||||
throw Error("원가계산서 자료가 없습니다.");
|
||||
}
|
||||
const targetRow = rows.find((r) => Number(r.LinkIndex) === 55);
|
||||
const curTotal = targetRow ? Number(targetRow.Cost) || 0 : 0;
|
||||
|
||||
EstActions.form(
|
||||
"공사비역산 자리수 맞추기",
|
||||
[
|
||||
{
|
||||
name: "targetCost",
|
||||
label: "목표 도급공사비 (원)",
|
||||
type: "number",
|
||||
value: curTotal,
|
||||
},
|
||||
{
|
||||
name: "unitDigits",
|
||||
label: "단단위 절하 기준",
|
||||
value: "3",
|
||||
choices: [
|
||||
["0", "원 단위 (절하 없음)"],
|
||||
["1", "십원 단위 (10원 절하)"],
|
||||
["2", "백원 단위 (100원 절하)"],
|
||||
["3", "천원 단위 (1,000원 절하)"],
|
||||
["4", "만원 단위 (10,000원 절하)"],
|
||||
],
|
||||
},
|
||||
],
|
||||
({ targetCost, unitDigits }) => {
|
||||
EstApp.change(() => {
|
||||
EstWonga.alignTotal(rows, targetCost, unitDigits);
|
||||
state.project.options.TotalFix = true;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handle(name, state) {
|
||||
if (name === "block-start") {
|
||||
setBlockStart(state);
|
||||
return true;
|
||||
}
|
||||
if (name === "block-end") {
|
||||
setBlockEnd(state);
|
||||
return true;
|
||||
}
|
||||
if (name === "block-clear") {
|
||||
clearBlockSelection(state);
|
||||
return true;
|
||||
}
|
||||
if (name === "select-all-rows") {
|
||||
selectAllRows(state);
|
||||
return true;
|
||||
}
|
||||
if (name === "block-del") {
|
||||
deleteBlockRows(state);
|
||||
return true;
|
||||
}
|
||||
if (name === "toggle-cost-comment") {
|
||||
toggleCostComment(state);
|
||||
return true;
|
||||
}
|
||||
if (name === "align-wonga-total") {
|
||||
alignWongaTotalAction(state);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
setBlockStart,
|
||||
setBlockEnd,
|
||||
clearBlockSelection,
|
||||
selectAllRows,
|
||||
deleteBlockRows,
|
||||
toggleCostComment,
|
||||
alignWongaTotalAction,
|
||||
handle,
|
||||
};
|
||||
})();
|
||||
@@ -1,4 +1,83 @@
|
||||
const EstBusinessActions = (() => {
|
||||
function setEstimateClassification(project, row, index, value, rowTable = "EstMainDat") {
|
||||
index = Number(index);
|
||||
if (!Number.isInteger(index) || project.tables[rowTable]?.[index] !== row)
|
||||
throw Error("내역 행을 다시 선택하세요.");
|
||||
const kind = Number(row.LinkIndex),
|
||||
table = EstNative.sourceTables[kind];
|
||||
if (
|
||||
!table ||
|
||||
(rowTable === "EstMainDat" ? Number(row.Level) : Number(row.Index) > 0) ||
|
||||
[1, 3].includes(Number(row.CalcIndex)) ||
|
||||
![0, 1, 2, 3].includes(value)
|
||||
) {
|
||||
row.SCheck = value;
|
||||
return;
|
||||
}
|
||||
const total = EstMath.units.reduce((sum, key) => sum + EstMath.num(row[key]), 0);
|
||||
if (!Number.isFinite(total) || Math.abs(total) > Number.MAX_SAFE_INTEGER)
|
||||
throw Error("현재 단가 합계가 유효하지 않습니다.");
|
||||
if (value !== 0) {
|
||||
const target = { 1: "GU", 2: "NU", 3: "JU" }[value];
|
||||
for (const key of EstMath.units) row[key] = key === target ? total : 0;
|
||||
row.SCheck = value;
|
||||
return;
|
||||
}
|
||||
if (!Number(row.SCheck)) {
|
||||
row.SCheck = value;
|
||||
return;
|
||||
}
|
||||
const sources = project.tables[table] || [];
|
||||
let matches = row.Code ? sources.filter((source) => source.Code === row.Code) : [];
|
||||
if (!matches.length && row.Code)
|
||||
matches = sources.filter(
|
||||
(source) => Array.isArray(source.PreviousCodes) && source.PreviousCodes.includes(row.Code),
|
||||
);
|
||||
if (!matches.length && row.Name)
|
||||
matches = sources.filter(
|
||||
(source) =>
|
||||
EstNative.normalize(source.Name) === EstNative.normalize(row.Name) &&
|
||||
(!row.Size || EstNative.normalize(source.Size) === EstNative.normalize(row.Size)) &&
|
||||
(!row.Unite || EstNative.normalize(source.Unite) === EstNative.normalize(row.Unite)),
|
||||
);
|
||||
if (matches.length > 1) throw Error(`원천 코드가 중복되었습니다: ${row.Code}`);
|
||||
const had = Object.hasOwn(row, "SCheck"),
|
||||
before = row.SCheck;
|
||||
row.SCheck = value;
|
||||
try {
|
||||
let failures = null;
|
||||
const calculated = EstNative.calculate(
|
||||
project,
|
||||
rowTable === "HeadMainDat"
|
||||
? { refreshHeadRows: [index], onHeadRefresh: (errors) => (failures = errors) }
|
||||
: { refreshEstimateRows: [index], onEstimateRefresh: (errors) => (failures = errors) },
|
||||
);
|
||||
if (!failures) throw Error("내역 비목 재계산을 확인할 수 없습니다.");
|
||||
if (failures.length) throw Error(failures.join(" / "));
|
||||
const refreshed = calculated.tables[rowTable][index];
|
||||
const refreshedTotal = EstMath.units.reduce(
|
||||
(sum, key) => sum + EstMath.num(refreshed[key]),
|
||||
0,
|
||||
);
|
||||
if (
|
||||
!Number.isFinite(refreshedTotal) ||
|
||||
Math.abs(refreshedTotal - total) >
|
||||
Number.EPSILON * Math.max(1, Math.abs(total), Math.abs(refreshedTotal)) * 4
|
||||
)
|
||||
throw Error("현재 단가와 원천 단가가 달라 비목별 단가를 복원할 수 없습니다.");
|
||||
for (const key of EstMath.units) row[key] = refreshed[key];
|
||||
} catch (error) {
|
||||
if (had) row.SCheck = before;
|
||||
else delete row.SCheck;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function setClassification(project, row, node, value) {
|
||||
const table = node.dataset.table;
|
||||
if (["EstMainDat", "HeadMainDat"].includes(table))
|
||||
setEstimateClassification(project, row, node.dataset.row, value, table);
|
||||
else row.SCheck = value;
|
||||
}
|
||||
let purpose = "",
|
||||
documentKey;
|
||||
const currentRound = () =>
|
||||
@@ -25,6 +104,16 @@ const EstBusinessActions = (() => {
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (node.id === "resource-kind-filter") {
|
||||
state.resourceKind = node.value;
|
||||
EstApp.render();
|
||||
return true;
|
||||
}
|
||||
if (node.id === "resource-by-work") {
|
||||
state.resourceByWork = Boolean(node.checked);
|
||||
EstApp.render();
|
||||
return true;
|
||||
}
|
||||
if (node.id === "progress-components" || node.id === "progress-digits") {
|
||||
const round = currentRound();
|
||||
if (round.locked) throw Error("확정 회차입니다.");
|
||||
@@ -41,11 +130,200 @@ const EstBusinessActions = (() => {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async function handle(name) {
|
||||
async function handle(name, node) {
|
||||
const { state } = EstApp,
|
||||
project = state.project,
|
||||
round = currentRound();
|
||||
if (name === "unlock-round") {
|
||||
if (name === "replace-cost-words") {
|
||||
if (state.view !== "CostMainDat") throw Error("단가산출 화면에서 문구를 바꾸세요.");
|
||||
const revision = state.revision,
|
||||
groupCode = state.group;
|
||||
EstActions.form(
|
||||
"산출근거 문구 바꾸기",
|
||||
[
|
||||
{ name: "search", label: "찾을 문구", value: "" },
|
||||
{ name: "replacement", label: "바꿀 문구", value: "" },
|
||||
{
|
||||
name: "scope",
|
||||
label: "범위",
|
||||
value: "group",
|
||||
choices: [
|
||||
["group", "현재 호표"],
|
||||
["all", "전체 산출근거"],
|
||||
],
|
||||
},
|
||||
],
|
||||
(values) => {
|
||||
if (
|
||||
state.project !== project ||
|
||||
state.revision !== revision ||
|
||||
state.view !== "CostMainDat" ||
|
||||
state.group !== groupCode
|
||||
)
|
||||
throw Error("작업이 바뀌었습니다. 문구 바꾸기를 다시 열어주세요.");
|
||||
const rows = project.tables.CostMainDat,
|
||||
group = EstNative.groups(rows).get(groupCode),
|
||||
input = document.querySelector('#dialog-content [name="search"]');
|
||||
if (values.scope === "group" && !group) throw Error("현재 호표를 선택하세요.");
|
||||
input.setCustomValidity(values.search ? "" : "찾을 문구를 입력하세요.");
|
||||
if (!input.reportValidity()) throw Error("찾을 문구를 입력하세요.");
|
||||
const candidates = rows
|
||||
.map((row, index) => ({ row, index }))
|
||||
.filter(
|
||||
({ index }) =>
|
||||
values.scope === "all" ||
|
||||
(index >= group.start && index < group.start + group.rows.length),
|
||||
),
|
||||
updates = EstText.replaceWords(candidates, values.search, values.replacement);
|
||||
input.setCustomValidity(updates.length ? "" : "변경할 문구가 없습니다.");
|
||||
if (!input.reportValidity()) throw Error("변경할 문구가 없습니다.");
|
||||
if (confirm(`${updates.length}개 행의 문구를 바꾸시겠습니까?`))
|
||||
EstApp.change(() =>
|
||||
updates.forEach(({ index, value }) => (rows[index].LineData = value)),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else if (name === "clear-estimate-quantities") {
|
||||
if (state.view !== "EstMainDat") throw Error("내역서에서 수량을 초기화하세요.");
|
||||
const rows = project.tables.EstMainDat,
|
||||
selected = [...state.selection];
|
||||
EstActions.form(
|
||||
"내역 수량 초기화",
|
||||
[
|
||||
{
|
||||
name: "scope",
|
||||
label: "초기화 범위 (비율·합계 자동행 제외)",
|
||||
value: selected.length ? "selected" : "all",
|
||||
choices: [
|
||||
["selected", "선택한 상세 행"],
|
||||
["all", "모든 상세 행"],
|
||||
],
|
||||
},
|
||||
],
|
||||
(values) => {
|
||||
const indices = (
|
||||
values.scope === "selected" ? selected : rows.map((_, index) => index)
|
||||
).filter((index) => {
|
||||
const row = rows[index];
|
||||
return (
|
||||
row &&
|
||||
!Number(row.Level) &&
|
||||
![51, 52, 54, 55, 57, 58, 59, 62, 67, 68].includes(Number(row.LinkIndex)) &&
|
||||
!["%", "%"].includes(row.Unite)
|
||||
);
|
||||
});
|
||||
if (!indices.length) throw Error("초기화할 상세 행이 없습니다.");
|
||||
EstApp.change(() => indices.forEach((index) => (rows[index].GN = 0)));
|
||||
},
|
||||
);
|
||||
} else if (name === "clear-progress") {
|
||||
if (confirm("모든 기성 회차와 확정 결과를 초기화하시겠습니까?"))
|
||||
EstApp.change(() => {
|
||||
project.progress = [];
|
||||
state.roundId = undefined;
|
||||
}, false);
|
||||
} else if (name === "set-row-precision") {
|
||||
const rows = project.tables[state.view],
|
||||
indices = [...state.selection].sort((a, b) => a - b);
|
||||
if (!rows || !EstModel.columns(state.view).includes("DigetIndex"))
|
||||
throw Error("이 자료에는 자릿수 설정이 없습니다.");
|
||||
if (!indices.length || indices.some((index) => !rows[index]))
|
||||
throw Error("설정할 행을 선택하세요.");
|
||||
EstActions.form(
|
||||
"선택 행 자릿수",
|
||||
[
|
||||
{
|
||||
name: "method",
|
||||
label: "처리방식",
|
||||
value: rows[indices[0]].DigetIndex || 0,
|
||||
choices: [
|
||||
[0, "자동"],
|
||||
[1, "사사오입"],
|
||||
[2, "절사"],
|
||||
[3, "올림식"],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "digits",
|
||||
label: "소수 자릿수 (0~6)",
|
||||
type: "number",
|
||||
value: Math.max(0, Number(rows[indices[0]].Diget || 1) - 1),
|
||||
},
|
||||
],
|
||||
(values) => {
|
||||
const method = Number(values.method),
|
||||
digits = Number(values.digits);
|
||||
if (
|
||||
![0, 1, 2, 3].includes(method) ||
|
||||
!Number.isInteger(digits) ||
|
||||
digits < 0 ||
|
||||
digits > 6
|
||||
)
|
||||
throw Error("자릿수는 0~6이고 처리방식을 선택해야 합니다.");
|
||||
EstApp.change(() => {
|
||||
for (const index of indices) {
|
||||
rows[index].DigetIndex = method;
|
||||
rows[index].Diget = method ? digits + 1 : 0;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
} else if (name === "apply-all-quantities") {
|
||||
const totals = EstQuantity.allTotals(project);
|
||||
if (confirm("모든 수량산출서와 수식표의 연결 수량을 합산해 내역에 반영하시겠습니까?"))
|
||||
EstApp.change(() => EstQuantity.applyTotals(project, totals));
|
||||
} else if (name === "move-quantity") {
|
||||
const index = Number(node.dataset.row),
|
||||
direction = Number(node.dataset.direction),
|
||||
next = index + direction;
|
||||
if (
|
||||
!Number.isInteger(index) ||
|
||||
![-1, 1].includes(direction) ||
|
||||
index < 0 ||
|
||||
next < 0 ||
|
||||
index >= project.quantities.length ||
|
||||
next >= project.quantities.length
|
||||
)
|
||||
throw Error("수량 수식표 이동 위치 오류");
|
||||
EstApp.change(() => {
|
||||
[project.quantities[index], project.quantities[next]] = [
|
||||
project.quantities[next],
|
||||
project.quantities[index],
|
||||
];
|
||||
}, false);
|
||||
} else if (name === "move-estimate-up" || name === "move-estimate-down") {
|
||||
const isEst = state.view === "EstMainDat",
|
||||
isGroup = ["HeadMainDat", "CostMainDat", "MachMainDat"].includes(state.view);
|
||||
if (!isEst && !isGroup)
|
||||
throw Error(
|
||||
"내역서 또는 호표(일위대가·단가산출·기계경비) 화면에서만 행을 이동할 수 있습니다.",
|
||||
);
|
||||
const direction = name === "move-estimate-up" ? -1 : 1,
|
||||
tableRows = project.tables[state.view];
|
||||
const indices = isEst
|
||||
? EstBusiness.moveEstimateRows(structuredClone(tableRows), state.selection, direction)
|
||||
: EstBusiness.moveGroupDetailRows(
|
||||
structuredClone(tableRows),
|
||||
state.group,
|
||||
state.selection,
|
||||
direction,
|
||||
state.view,
|
||||
);
|
||||
if (indices.every((index) => state.selection.has(index))) return;
|
||||
EstApp.change(() => {
|
||||
state.selection = new Set(
|
||||
isEst
|
||||
? EstBusiness.moveEstimateRows(tableRows, state.selection, direction)
|
||||
: EstBusiness.moveGroupDetailRows(
|
||||
tableRows,
|
||||
state.group,
|
||||
state.selection,
|
||||
direction,
|
||||
state.view,
|
||||
),
|
||||
);
|
||||
});
|
||||
} else if (name === "unlock-round") {
|
||||
if (confirm("이 회차와 이후 회차의 확정을 해제하시겠습니까? 입력값은 유지됩니다."))
|
||||
EstApp.change(() => (state.project = EstProgress.unlock(project, round.id)), false);
|
||||
} else if (name === "delete-round") {
|
||||
@@ -62,12 +340,15 @@ const EstBusinessActions = (() => {
|
||||
"기성 회차 이름",
|
||||
[{ name: "name", label: "회차 이름", value: round.name }],
|
||||
(values) => {
|
||||
const name = values.name.trim();
|
||||
if (
|
||||
!values.name.trim() ||
|
||||
project.progress.some((item) => item !== round && item.name === values.name)
|
||||
!name ||
|
||||
project.progress.some(
|
||||
(item) => item !== round && String(item.name || "").trim() === name,
|
||||
)
|
||||
)
|
||||
throw Error("회차 이름은 비어 있거나 중복될 수 없습니다.");
|
||||
EstApp.change(() => (round.name = values.name.trim()), false);
|
||||
EstApp.change(() => (round.name = name), false);
|
||||
},
|
||||
);
|
||||
} else if (name === "fill-progress") {
|
||||
@@ -95,9 +376,19 @@ const EstBusinessActions = (() => {
|
||||
},
|
||||
{ name: "ratio", label: "비율 %", type: "number", value: 100 },
|
||||
],
|
||||
(values) =>
|
||||
(values) => {
|
||||
const value = numeric(values.ratio);
|
||||
const ambiguous = EstProgress.ambiguousKeys(project.tables.EstMainDat);
|
||||
const hasAmbiguous = project.tables.EstMainDat.some(
|
||||
(row, index) =>
|
||||
Number(row.Level || 0) <= 0 &&
|
||||
Number(row.CalcIndex || 0) < 2 &&
|
||||
![54, 55, 58, 59, 62, 67].includes(Number(row.LinkIndex)) &&
|
||||
!(values.mode === "full" && Number(row.LinkIndex) === 57) &&
|
||||
ambiguous.has(EstBusiness.rowKey(row, index)),
|
||||
);
|
||||
if (hasAmbiguous) throw Error("기성 내역 식별자가 중복되어 일괄 입력할 수 없습니다.");
|
||||
EstApp.change(() => {
|
||||
const value = numeric(values.ratio);
|
||||
project.tables.EstMainDat.forEach((row, index) => {
|
||||
if (
|
||||
Number(row.Level) > 0 ||
|
||||
@@ -113,7 +404,8 @@ const EstBusinessActions = (() => {
|
||||
round.inputs[key][values.prefix] =
|
||||
values.mode === "full" ? Number(row.GN) || 0 : value;
|
||||
});
|
||||
}, false),
|
||||
}, false);
|
||||
},
|
||||
);
|
||||
} else if (name === "load-progress-exclusion") {
|
||||
const locked = project.progress.filter((round) => round.locked && round.result);
|
||||
@@ -128,18 +420,185 @@ const EstBusinessActions = (() => {
|
||||
choices: locked.map((round) => [round.id, round.name]),
|
||||
},
|
||||
],
|
||||
(values) =>
|
||||
(values) => {
|
||||
const result = locked.find((round) => round.id === values.id).result;
|
||||
const updates = EstBusiness.progressExclusions(project, result);
|
||||
EstApp.change(() => {
|
||||
const result = locked.find((round) => round.id === values.id).result;
|
||||
const rows = new Map(
|
||||
result.rows.map((row, index) => [EstBusiness.rowKey(row, index), row]),
|
||||
);
|
||||
project.tables.EstMainDat.forEach(
|
||||
(row, index) =>
|
||||
(row.EscProgress = Number(rows.get(EstBusiness.rowKey(row, index))?.TotalGN) || 0),
|
||||
);
|
||||
}, false),
|
||||
for (const { index, value } of updates)
|
||||
project.tables.EstMainDat[index].EscProgress = value;
|
||||
}, false);
|
||||
},
|
||||
);
|
||||
} else if (name === "vat-calc") {
|
||||
if (state.view !== "StuffcDat")
|
||||
throw Error("자재단가 화면에서만 부가세 계산을 실행할 수 있습니다.");
|
||||
const rows = project.tables.StuffcDat || [];
|
||||
const indices = [...state.selection].sort((a, b) => a - b);
|
||||
EstActions.form(
|
||||
"자재 부가세 계산",
|
||||
[
|
||||
{
|
||||
name: "calcMethod",
|
||||
label: "계산방법",
|
||||
value: 1,
|
||||
choices: [
|
||||
[0, "부가세포함(×1.1)"],
|
||||
[1, "부가세제외(÷1.1)"],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "digetMethod",
|
||||
label: "단단위 계산방식",
|
||||
value: 1,
|
||||
choices: [
|
||||
[0, "사사오입"],
|
||||
[1, "절하"],
|
||||
[2, "절상"],
|
||||
],
|
||||
},
|
||||
{ name: "diget", label: "단단위 자리수(0=원, 1=십원..)", type: "number", value: "0" },
|
||||
],
|
||||
(values) => {
|
||||
EstApp.change(() => {
|
||||
state.project = EstVat.apply(state.project, indices, {
|
||||
calcMethod: Number(values.calcMethod),
|
||||
digetMethod: Number(values.digetMethod),
|
||||
diget: Number(values.diget),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
} else if (name === "haljung-calc") {
|
||||
if (state.view !== "HeadMainDat")
|
||||
throw Error("일위대가 화면에서만 할증계산을 실행할 수 있습니다.");
|
||||
const rows = project.tables.HeadMainDat || [];
|
||||
const grps = EstNative.groups(rows);
|
||||
const currentGroup = grps.get(state.group);
|
||||
if (!currentGroup) throw Error("할증을 적용할 공종(묶음)을 선택하세요.");
|
||||
EstActions.form(
|
||||
"공종 내 할증계산",
|
||||
[
|
||||
{ name: "name", label: "명 칭", value: "할 증" },
|
||||
{
|
||||
name: "source",
|
||||
label: "불러올비목(규격)",
|
||||
value: 0,
|
||||
choices: [
|
||||
[0, "비목별"],
|
||||
[1, "재료비의"],
|
||||
[2, "노무비의"],
|
||||
[3, "경비의"],
|
||||
[4, "합계의"],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "target",
|
||||
label: "적용비목",
|
||||
value: 0,
|
||||
choices: [
|
||||
[0, "비목별"],
|
||||
[1, "재료비에"],
|
||||
[2, "노무비에"],
|
||||
[3, "경비에"],
|
||||
],
|
||||
},
|
||||
{ name: "gn", label: "적용율(%)", type: "number", value: "10" },
|
||||
],
|
||||
(values) => {
|
||||
const rate = Number(values.gn);
|
||||
if (!Number.isFinite(rate) || rate === 0)
|
||||
throw Error("할증 적용율은 0이 아닌 유효한 숫자여야 합니다.");
|
||||
EstApp.change(() => {
|
||||
state.project = EstHalJung.apply(state.project, state.view, state.group, {
|
||||
name: values.name,
|
||||
sourceKind: Number(values.source),
|
||||
targetKind: Number(values.target),
|
||||
rate,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
} else if (name === "unit-calc") {
|
||||
if (state.view !== "HeadMainDat")
|
||||
throw Error("일위대가 화면에서만 공종 단위환산 계산을 실행할 수 있습니다.");
|
||||
const grps = EstNative.groups(project.tables.HeadMainDat || []);
|
||||
const currentGroup = grps.get(state.group);
|
||||
if (!currentGroup) throw Error("환산할 일위대가 공종(묶음)을 선택하세요.");
|
||||
const title = currentGroup.rows[0];
|
||||
EstActions.form(
|
||||
"공종의 단위환산 계산",
|
||||
[
|
||||
{ name: "name", label: "명 칭", value: title.Name || "단위환산" },
|
||||
{ name: "size", label: "규 격", value: title.Size || "" },
|
||||
{ name: "gn", label: "수 량(환산계수)", type: "number", value: "1" },
|
||||
{ name: "unite", label: "단 위", value: title.Unite || "식" },
|
||||
],
|
||||
(values) => {
|
||||
const factor = Number(values.gn);
|
||||
if (!Number.isFinite(factor) || factor === 0)
|
||||
throw Error("수량은 0이 아닌 유효한 숫자여야 합니다.");
|
||||
EstApp.change(() => {
|
||||
state.project = EstBusiness.convertGroupUnit(state.project, state.group, {
|
||||
name: values.name,
|
||||
size: values.size,
|
||||
factor,
|
||||
unit: values.unite,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
} else if (name === "toggle-jc-bimog") {
|
||||
EstExtra.toggleJcBimog(state);
|
||||
} else if (name === "toggle-sun-gongsa") {
|
||||
EstExtra.toggleSunGongsa(state);
|
||||
} else if (name === "uses-data-search") {
|
||||
const rows = state.project.tables[state.view] || [];
|
||||
const selected = [...state.selection];
|
||||
const targetRow = selected.length ? rows[selected[0]] : null;
|
||||
if (!targetRow) throw Error("사용위치를 검색할 행을 먼저 선택하세요.");
|
||||
EstExtra.showUsesSearchDialog(targetRow);
|
||||
} else if (name === "set-cost-line-kind") {
|
||||
const rows = state.project.tables.CostMainDat,
|
||||
indices = [...state.selection].sort((a, b) => a - b);
|
||||
if (state.view !== "CostMainDat" || !rows)
|
||||
throw Error("단가산출 화면에서만 산출근거 비목을 지정할 수 있습니다.");
|
||||
if (!indices.length || indices.some((i) => !rows[i]))
|
||||
throw Error("비목을 지정할 산출근거 행을 선택하세요.");
|
||||
const curKind = Number(rows[indices[0]].OCheck) || 0;
|
||||
EstActions.form(
|
||||
"산출근거 비목 지정",
|
||||
[
|
||||
{
|
||||
name: "kind",
|
||||
label: "지정 비목",
|
||||
value: curKind,
|
||||
choices: [
|
||||
[0, "일반 산출식 / 해제"],
|
||||
[-1, "노무비 (-1)"],
|
||||
[-2, "재료비 (-2)"],
|
||||
[-3, "경비 (-3)"],
|
||||
[-5, "소계 (-5)"],
|
||||
],
|
||||
},
|
||||
],
|
||||
({ kind }) => {
|
||||
kind = Number(kind);
|
||||
EstApp.change(() => {
|
||||
for (const idx of indices) {
|
||||
const r = rows[idx];
|
||||
if (kind === 0) {
|
||||
delete r.OCheck;
|
||||
delete r.SCheck;
|
||||
} else {
|
||||
r.OCheck = kind;
|
||||
r.SCheck = kind;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
} else if (EstBlockActions.handle(name, state)) {
|
||||
EstApp.render();
|
||||
} else if (name === "load-contract" || name === "load-bid") {
|
||||
purpose = name;
|
||||
documentKey = state.documentKey;
|
||||
@@ -182,5 +641,5 @@ const EstBusinessActions = (() => {
|
||||
}
|
||||
});
|
||||
}
|
||||
return { handle, change, install };
|
||||
return { handle, change, install, setEstimateClassification, setClassification };
|
||||
})();
|
||||
|
||||
@@ -4,6 +4,254 @@ const EstBusiness = (() => {
|
||||
const copy = (value) => structuredClone(value);
|
||||
const rowKey = (row, index) => String(row.PkID ?? (row.Code || `row:${index}`));
|
||||
const special = (row) => [57, 58, 62, 67].includes(num(row.LinkIndex));
|
||||
function progressExclusions(project, result) {
|
||||
const saved = new Map();
|
||||
result.rows.forEach((row, index) => {
|
||||
const key = rowKey(row, index);
|
||||
if (saved.has(key)) throw Error(`기성 내역 식별자가 중복됩니다: ${key}`);
|
||||
const value = Number(row.TotalGN);
|
||||
if (!Number.isFinite(value)) throw Error(`기성 누계수량 오류: ${key}`);
|
||||
saved.set(key, value);
|
||||
});
|
||||
const counts = new Map();
|
||||
project.tables.EstMainDat.forEach((row, index) => {
|
||||
const key = rowKey(row, index);
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
});
|
||||
return project.tables.EstMainDat.flatMap((row, index) => {
|
||||
const key = rowKey(row, index);
|
||||
if (!saved.has(key)) return [];
|
||||
if (counts.get(key) !== 1) throw Error(`현재 내역 식별자가 중복됩니다: ${key}`);
|
||||
return [{ index, value: saved.get(key) }];
|
||||
});
|
||||
}
|
||||
function moveEstimateRows(rows, selection, direction) {
|
||||
const indices = [...selection].sort((a, b) => a - b);
|
||||
if (!indices.length) throw Error("이동할 행을 선택하세요.");
|
||||
if (
|
||||
![1, -1].includes(direction) ||
|
||||
indices.some((value, index) => value !== indices[0] + index)
|
||||
)
|
||||
throw Error("연속된 행을 선택하세요.");
|
||||
const first = indices[0],
|
||||
last = indices.at(-1),
|
||||
neighbor = direction < 0 ? first - 1 : last + 1;
|
||||
if (neighbor < 0 || neighbor >= rows.length) return indices;
|
||||
const affected = rows.slice(Math.min(first, neighbor), Math.max(last, neighbor) + 1);
|
||||
if (affected.some((row) => [54, 55, 58, 59].includes(num(row.LinkIndex))))
|
||||
throw Error("합계 행은 이동하거나 건너뛸 수 없습니다.");
|
||||
const counts = new Map();
|
||||
rows.forEach((row, index) => {
|
||||
const key = rowKey(row, index);
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
});
|
||||
for (let index = Math.min(first, neighbor); index <= Math.max(last, neighbor); index++) {
|
||||
const row = rows[index];
|
||||
if ((row.PkID == null && !row.Code) || counts.get(rowKey(row, index)) !== 1)
|
||||
throw Error("행 식별자가 없거나 중복되어 연결을 보존할 수 없습니다.");
|
||||
}
|
||||
const group = rows.splice(first, indices.length);
|
||||
rows.splice(first + direction, 0, ...group);
|
||||
return indices.map((index) => index + direction);
|
||||
}
|
||||
function validateCostOrderIntegrity(beforeGroupRows, afterGroupRows) {
|
||||
const beforeCalc = EstText.calculate(structuredClone(beforeGroupRows));
|
||||
const afterCalc = EstText.calculate(structuredClone(afterGroupRows));
|
||||
if (afterCalc.errors && afterCalc.errors.length) {
|
||||
throw Error(`산출근거 참조 오류로 이동할 수 없습니다: ${afterCalc.errors[0]}`);
|
||||
}
|
||||
// 소계(LinkIndex === 61) 및 비율(LinkIndex === 58) 또는 전체 호표 합계(첫 행 TC)의 수치/참조가 달라지면 차단
|
||||
if (Number(afterCalc.rows[0]?.TC || 0) !== Number(beforeCalc.rows[0]?.TC || 0)) {
|
||||
throw Error("호표 최종 금액이 변경되어 순서를 보존할 수 없는 이동입니다.");
|
||||
}
|
||||
const beforeMap = new Map();
|
||||
beforeCalc.rows.forEach((r, idx) => beforeMap.set(rowKey(r, idx), r));
|
||||
for (let i = 0; i < afterCalc.rows.length; i++) {
|
||||
const aRow = afterCalc.rows[i];
|
||||
const key = rowKey(aRow, i);
|
||||
const bRow = beforeMap.get(key);
|
||||
if (!bRow) continue;
|
||||
const kind = num(bRow.LinkIndex);
|
||||
if ([58, 61].includes(kind)) {
|
||||
if (Number(aRow.TC || 0) !== Number(bRow.TC || 0)) {
|
||||
throw Error("구간 소계 또는 비율 결과가 달라지는 안전하지 않은 이동입니다.");
|
||||
}
|
||||
}
|
||||
// 앞줄 참조(\x0c)를 사용하는 행의 계산 수식 문자열(LineData)이 보존되는지 검증
|
||||
if (String(bRow.LineData || "").includes("\x0c")) {
|
||||
if (String(aRow.LineData || "") !== String(bRow.LineData || "")) {
|
||||
throw Error("앞줄 참조 대상 또는 수치가 달라지는 안전하지 않은 이동입니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function moveGroupDetailRows(rows, groupCode, selection, direction, table = "") {
|
||||
const indices = [...selection].sort((a, b) => a - b);
|
||||
if (!indices.length) throw Error("이동할 호표 상세 행을 선택하세요.");
|
||||
if (
|
||||
![1, -1].includes(direction) ||
|
||||
indices.some((value, index) => value !== indices[0] + index)
|
||||
)
|
||||
throw Error("연속된 행을 선택하세요.");
|
||||
const grps = EstNative.groups(rows),
|
||||
targetGroup = grps.get(groupCode);
|
||||
if (!targetGroup) throw Error("호표를 찾을 수 없습니다.");
|
||||
const groupStart = targetGroup.start,
|
||||
groupLen = targetGroup.rows.length,
|
||||
groupEnd = groupStart + groupLen - 1;
|
||||
for (const idx of indices) {
|
||||
if (idx <= groupStart || idx > groupEnd)
|
||||
throw Error("호표의 상세 행만 이동할 수 있습니다(제목 행 제외).");
|
||||
}
|
||||
const first = indices[0],
|
||||
last = indices.at(-1),
|
||||
neighbor = direction < 0 ? first - 1 : last + 1;
|
||||
if (neighbor <= groupStart || neighbor > groupEnd) return indices;
|
||||
const affected = rows.slice(Math.min(first, neighbor), Math.max(last, neighbor) + 1);
|
||||
if (affected.some((row) => [54, 55, 58, 59, 61, 62].includes(num(row.LinkIndex))))
|
||||
throw Error("합계·소계·구간비율 행은 직접 이동하거나 건너뛸 수 없습니다.");
|
||||
if (affected.some((row) => String(row.LineData || "").includes("\x1a")))
|
||||
throw Error("합계 수식표지(\\x1a)가 포함된 행은 이동하거나 건너뛸 수 없습니다.");
|
||||
const counts = new Map();
|
||||
rows.forEach((row, index) => {
|
||||
const key = rowKey(row, index);
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
});
|
||||
for (let index = Math.min(first, neighbor); index <= Math.max(last, neighbor); index++) {
|
||||
const row = rows[index];
|
||||
if ((row.PkID == null && !row.Code) || counts.get(rowKey(row, index)) !== 1)
|
||||
throw Error("행 식별자가 없거나 중복되어 연결을 보존할 수 없습니다.");
|
||||
}
|
||||
const isCost = table === "CostMainDat" || rows.some((r) => r.LineData !== undefined);
|
||||
const affectedM20Indices = [];
|
||||
if (isCost) {
|
||||
for (let i = Math.min(first, neighbor); i <= Math.max(last, neighbor); i++) {
|
||||
if (num(rows[i].Index) === -20) affectedM20Indices.push(i);
|
||||
}
|
||||
}
|
||||
// 선택 범위 중간에 -20이 포함되어 단일 경계 스왑으로 보장할 수 없는 경우 안전 차단
|
||||
if (affectedM20Indices.length > 1) {
|
||||
throw Error("복수 구간 경계가 포함된 범위는 한 번에 이동할 수 없습니다.");
|
||||
}
|
||||
if (
|
||||
affectedM20Indices.length === 1 &&
|
||||
indices.length > 1 &&
|
||||
affectedM20Indices[0] !== first &&
|
||||
affectedM20Indices[0] !== last
|
||||
) {
|
||||
throw Error("선택 범위 중간에 구간 경계가 포함되어 있어 이동할 수 없습니다.");
|
||||
}
|
||||
|
||||
const affectedHasM20 = affectedM20Indices.length === 1;
|
||||
const swapTargetIndex = affectedHasM20 ? Math.min(first, neighbor) : null;
|
||||
const swapSourceIndex = affectedHasM20 ? Math.max(last, neighbor) : null;
|
||||
|
||||
if (isCost) {
|
||||
// 앞줄 참조(\x0c)를 가진 행이 이동에 포함되거나 건너뛰어지는 경우 참조 대상 보존 검사
|
||||
const affectedRows = rows.slice(Math.min(first, neighbor), Math.max(last, neighbor) + 1);
|
||||
const hasRefRow = affectedRows.some((r) => String(r.LineData || "").includes("\x0c"));
|
||||
if (hasRefRow && indices.length > 1) {
|
||||
throw Error("앞줄 참조가 포함된 복수 행은 안전한 구분을 위해 단일 행으로 이동하세요.");
|
||||
}
|
||||
|
||||
// 앞줄 참조(\x0c) 또는 변수 참조(\x11)가 뒤따르는 구간에서 대상 행의 정체성 및 순서 무결성 보존 검사
|
||||
// 단, LinkIndex 61(소계)은 구간 내 모든 결과를 합산하므로 순서 교환 자체로 참조 대상이 왜곡되지 않음.
|
||||
// 반면 LinkIndex 58(특정 비목 직전 참조)이나 일반 행의 \x0c는 특정 선행 결과를 선택 참조하므로,
|
||||
// 선행 후보가 2개 이상일 때 그 후보들 사이의 순서 변경은 참조 대상 왜곡 방지를 위해 차단함.
|
||||
const groupDetailRows = targetGroup.rows;
|
||||
for (let i = 0; i < groupDetailRows.length; i++) {
|
||||
const row = groupDetailRows[i];
|
||||
const lineText = String(row.LineData || "");
|
||||
if (num(row.LinkIndex) !== 61 && lineText.includes("\x0c")) {
|
||||
const isLink58 = num(row.LinkIndex) === 58;
|
||||
let sourceKind = isLink58 ? num(row.OCheck) : null;
|
||||
if (isLink58) {
|
||||
const cleanText = lineText.replace(/[\x00-\x1f]/g, "").trim();
|
||||
if (/노무|인건|품/.test(cleanText)) sourceKind = -1;
|
||||
else if (/재료|자재/.test(cleanText)) sourceKind = -2;
|
||||
else if (/경비/.test(cleanText)) sourceKind = -3;
|
||||
}
|
||||
const candidateIndices = [];
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
const prev = groupDetailRows[j];
|
||||
const pKind = num(prev.SCheck);
|
||||
if (isLink58) {
|
||||
if (
|
||||
(!sourceKind && pKind < 0 && pKind > -4) ||
|
||||
(sourceKind && pKind === sourceKind)
|
||||
) {
|
||||
if (String(prev.LineData || "").match(/([\x12\x0c])([-\d.,]+)\1/))
|
||||
candidateIndices.push(j);
|
||||
}
|
||||
} else {
|
||||
// 일반 행의 \x0c
|
||||
if (String(prev.LineData || "").match(/([\x03\x12\x0c])([-\d.,]+)\1/))
|
||||
candidateIndices.push(j);
|
||||
}
|
||||
if (num(prev.Level) > 0 || num(prev.Index) > 0) break;
|
||||
}
|
||||
if (candidateIndices.length > 1) {
|
||||
// 이 후보들 중 하나라도 이번 이동(affected)에 포함되어 순서가 바뀌는 경우 차단
|
||||
const affectedGroupIndices = affected.map((r) => rows.indexOf(r) - groupStart);
|
||||
const candidateAffected = candidateIndices.filter((cIdx) =>
|
||||
affectedGroupIndices.includes(cIdx),
|
||||
);
|
||||
if (candidateAffected.length > 0) {
|
||||
throw Error(
|
||||
"직전 결과를 참조하는 행이 뒤따라 참조 대상 구분이 불명확하므로 이동할 수 없습니다.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 변수 재정의(동일 변수명에 대한 중복 할당)가 존재하는 경우 변수 참조 순서 왜곡 방지
|
||||
const varAssigned = new Map();
|
||||
groupDetailRows.forEach((r, idx) => {
|
||||
const text = String(r.LineData || "");
|
||||
for (const m of text.matchAll(/(?:^|[\s,;])([A-Za-z][A-Za-z0-9]*)\s*=/g)) {
|
||||
const vName = m[1];
|
||||
if (!varAssigned.has(vName)) varAssigned.set(vName, []);
|
||||
varAssigned.get(vName).push(idx);
|
||||
}
|
||||
});
|
||||
for (const [vName, occurrences] of varAssigned.entries()) {
|
||||
if (occurrences.length > 1) {
|
||||
const affectedIndices = occurrences.filter((idx) => {
|
||||
const globalIdx = groupStart + idx;
|
||||
return globalIdx >= Math.min(first, neighbor) && globalIdx <= Math.max(last, neighbor);
|
||||
});
|
||||
if (affectedIndices.length > 0) {
|
||||
throw Error(
|
||||
`동일 변수(${vName})를 재정의하는 구간에서는 참조 대상 보존을 위해 이동할 수 없습니다.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const simGroup = structuredClone(targetGroup.rows);
|
||||
const relFirst = first - groupStart;
|
||||
const movedSim = simGroup.splice(relFirst, indices.length);
|
||||
simGroup.splice(relFirst + direction, 0, ...movedSim);
|
||||
if (affectedHasM20) {
|
||||
// 이동에 참여한 두 교환 위치 사이에서만 -20 표지를 정확히 교환(다른 구간 표지 불변 보존)
|
||||
const relTarget = swapTargetIndex - groupStart;
|
||||
const relSource = swapSourceIndex - groupStart;
|
||||
const targetVal = simGroup[relTarget].Index;
|
||||
simGroup[relTarget].Index = simGroup[relSource].Index;
|
||||
simGroup[relSource].Index = targetVal;
|
||||
}
|
||||
validateCostOrderIntegrity(targetGroup.rows, simGroup);
|
||||
}
|
||||
const moved = rows.splice(first, indices.length);
|
||||
rows.splice(first + direction, 0, ...moved);
|
||||
if (affectedHasM20) {
|
||||
const targetVal = rows[swapTargetIndex].Index;
|
||||
rows[swapTargetIndex].Index = rows[swapSourceIndex].Index;
|
||||
rows[swapSourceIndex].Index = targetVal;
|
||||
}
|
||||
return indices.map((index) => index + direction);
|
||||
}
|
||||
function baseline(project) {
|
||||
const working = { ...project };
|
||||
delete working.baseline;
|
||||
@@ -54,10 +302,15 @@ const EstBusiness = (() => {
|
||||
}
|
||||
function changes(project) {
|
||||
const old = project.baseline?.tables.EstMainDat || [];
|
||||
const seen = new Set();
|
||||
const groups = new Map();
|
||||
old.forEach((row, index) => {
|
||||
const key = rowKey(row, index),
|
||||
group = groups.get(key) || [];
|
||||
group.push({ row, index });
|
||||
groups.set(key, group);
|
||||
});
|
||||
const changes = project.tables.EstMainDat.map((row, index) => {
|
||||
seen.add(rowKey(row, index));
|
||||
const saved = old.find((r, i) => rowKey(r, i) === rowKey(row, index));
|
||||
const saved = groups.get(rowKey(row, index))?.shift()?.row;
|
||||
const stored =
|
||||
!project.baseline && ["Code_", "Name_", "GN_", "TC_"].some((key) => row[key] != null)
|
||||
? Object.fromEntries(
|
||||
@@ -68,6 +321,7 @@ const EstBusiness = (() => {
|
||||
: null;
|
||||
const original = saved || stored;
|
||||
return {
|
||||
index,
|
||||
key: rowKey(row, index),
|
||||
Code: row.Code || "",
|
||||
Name: row.Name || "",
|
||||
@@ -75,38 +329,98 @@ const EstBusiness = (() => {
|
||||
originalGN: num(original?.GN),
|
||||
TC: num(row.TC),
|
||||
originalTC: num(original?.TC),
|
||||
LinkIndex: row.LinkIndex,
|
||||
originalLinkIndex: original?.LinkIndex ?? row.LinkIndex,
|
||||
difference: num(row.TC) - num(original?.TC),
|
||||
NewItem: !original,
|
||||
status: !original
|
||||
? "new"
|
||||
: ["Code", "Name", "Size", "Unite", "GN", "TC", ...units, ...components].some(
|
||||
(key) => original[key] != null && original[key] !== row[key],
|
||||
)
|
||||
: [
|
||||
"Code",
|
||||
"Name",
|
||||
"Size",
|
||||
"Unite",
|
||||
"GN",
|
||||
"TC",
|
||||
"LinkIndex",
|
||||
...units,
|
||||
...components,
|
||||
].some((key) => original[key] != null && original[key] !== row[key])
|
||||
? "changed"
|
||||
: "unchanged",
|
||||
originalSource: saved ? "baseline" : stored ? "file" : null,
|
||||
};
|
||||
});
|
||||
old.forEach((row, index) => {
|
||||
if (seen.has(rowKey(row, index))) return;
|
||||
changes.push({
|
||||
key: rowKey(row, index),
|
||||
Code: row.Code || "",
|
||||
Name: row.Name || "",
|
||||
originalGN: num(row.GN),
|
||||
originalTC: num(row.TC),
|
||||
GN: 0,
|
||||
TC: 0,
|
||||
difference: -num(row.TC),
|
||||
NewItem: false,
|
||||
status: "deleted",
|
||||
originalSource: "baseline",
|
||||
});
|
||||
});
|
||||
for (const group of groups.values())
|
||||
for (const { row, index } of group) {
|
||||
changes.push({
|
||||
index: null,
|
||||
key: rowKey(row, index),
|
||||
Code: row.Code || "",
|
||||
Name: row.Name || "",
|
||||
originalGN: num(row.GN),
|
||||
originalTC: num(row.TC),
|
||||
originalLinkIndex: row.LinkIndex,
|
||||
GN: 0,
|
||||
TC: 0,
|
||||
difference: -num(row.TC),
|
||||
NewItem: false,
|
||||
status: "deleted",
|
||||
originalSource: "baseline",
|
||||
});
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
function costChanges(project) {
|
||||
const current = project.tables.Wonga || [];
|
||||
if (!project.baseline) {
|
||||
if (!current.some((row) => row.Cost_ != null)) return [];
|
||||
return current.map((row) => ({
|
||||
Gubun: row.Gubun,
|
||||
Name: row.Name,
|
||||
originalCost: num(row.Cost_),
|
||||
changedCost: num(row.Cost),
|
||||
difference: num(row.Cost) - num(row.Cost_),
|
||||
}));
|
||||
}
|
||||
const old = project.baseline.tables.Wonga || [];
|
||||
function keyed(rows) {
|
||||
const counts = new Map();
|
||||
return rows.map((row) => {
|
||||
const identity = JSON.stringify(row.Gubun ? ["code", row.Gubun] : ["name", row.Name || ""]);
|
||||
const occurrence = counts.get(identity) || 0;
|
||||
counts.set(identity, occurrence + 1);
|
||||
return { row, key: JSON.stringify([identity, occurrence]) };
|
||||
});
|
||||
}
|
||||
const previous = new Map(keyed(old).map(({ row, key }) => [key, row]));
|
||||
const rows = keyed(current).map(({ row, key }) => {
|
||||
const saved = previous.get(key);
|
||||
previous.delete(key);
|
||||
return {
|
||||
Gubun: row.Gubun,
|
||||
Name: row.Name,
|
||||
originalCost: num(saved?.Cost),
|
||||
changedCost: num(row.Cost),
|
||||
difference: num(row.Cost) - num(saved?.Cost),
|
||||
};
|
||||
});
|
||||
for (const row of previous.values())
|
||||
rows.push({
|
||||
Gubun: row.Gubun,
|
||||
Name: row.Name,
|
||||
originalCost: num(row.Cost),
|
||||
changedCost: 0,
|
||||
difference: -num(row.Cost),
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
function bid(project, rates) {
|
||||
const next = copy(project);
|
||||
if (project.options && "SourceRefresh" in project.options) {
|
||||
next.options._preBidSourceRefresh ??= project.options.SourceRefresh;
|
||||
}
|
||||
next.options = { ...next.options, SourceRefresh: false };
|
||||
for (const key of ["HeadMainDat", "EstMainDat"])
|
||||
for (const row of next.tables[key]) {
|
||||
if (
|
||||
@@ -115,15 +429,73 @@ const EstBusiness = (() => {
|
||||
[51, 52, 54, 55, 57, 58, 59, 62, 67, 68].includes(num(row.LinkIndex))
|
||||
)
|
||||
continue;
|
||||
if (!row.PreBidRates) {
|
||||
row.PreBidRates = {};
|
||||
units.forEach((field) => (row.PreBidRates[field] = num(row[field])));
|
||||
}
|
||||
units.forEach((field, i) => {
|
||||
row[`O${field}`] ??= num(row[field]);
|
||||
row[`O${field}`] ??= num(row.PreBidRates[field] ?? row[field]);
|
||||
row[field] = floating((num(row[`O${field}`]) * num(rates[i] ?? rates[0])) / 100);
|
||||
});
|
||||
row.TU = units.reduce((n, k) => n + num(row[k]), 0);
|
||||
row.ApplyNacChal = 1;
|
||||
}
|
||||
next.options.SourceRefresh = false;
|
||||
return EstNative.calculate(next);
|
||||
return next.options?.AutoCalc === false ? next : EstNative.calculate(next);
|
||||
}
|
||||
function restoreBid(project) {
|
||||
const next = copy(project);
|
||||
const targetSourceRefresh =
|
||||
next.options && "_preBidSourceRefresh" in next.options
|
||||
? next.options._preBidSourceRefresh
|
||||
: next.options?.SourceRefresh;
|
||||
if (next.options) delete next.options._preBidSourceRefresh;
|
||||
next.options = { ...next.options, SourceRefresh: false };
|
||||
for (const key of ["HeadMainDat", "EstMainDat"])
|
||||
for (const row of next.tables[key]) {
|
||||
if (!row.ApplyNacChal && !row.PreBidRates) continue;
|
||||
units.forEach((field) => {
|
||||
if (row.PreBidRates && row.PreBidRates[field] != null) {
|
||||
row[field] = num(row.PreBidRates[field]);
|
||||
} else if (row[`O${field}`] != null) {
|
||||
row[field] = num(row[`O${field}`]);
|
||||
}
|
||||
});
|
||||
delete row.PreBidRates;
|
||||
row.TU = units.reduce((n, k) => n + num(row[k]), 0);
|
||||
delete row.ApplyNacChal;
|
||||
}
|
||||
const calculated = next.options?.AutoCalc === false ? next : EstNative.calculate(next);
|
||||
if (targetSourceRefresh !== undefined) {
|
||||
calculated.options = { ...calculated.options, SourceRefresh: targetSourceRefresh };
|
||||
}
|
||||
return calculated;
|
||||
}
|
||||
function convertGroupUnit(
|
||||
project,
|
||||
groupCode,
|
||||
{ name = "단위환산", size = "", factor = 1, unit = "식" } = {},
|
||||
) {
|
||||
const next = copy(project);
|
||||
const grps = EstNative.groups(next.tables.HeadMainDat || []);
|
||||
const group = grps.get(groupCode);
|
||||
if (!group) throw Error("일위대가 공종을 찾을 수 없습니다.");
|
||||
const numFactor = num(factor);
|
||||
if (!Number.isFinite(numFactor) || numFactor === 0)
|
||||
throw Error("환산 수량은 0이 아닌 유효한 숫자여야 합니다.");
|
||||
const title = group.rows[0];
|
||||
title.Name = name.trim() || title.Name;
|
||||
if (size && size.trim() !== "") title.Size = size.trim();
|
||||
if (unit && unit.trim() !== "") title.Unite = unit.trim();
|
||||
for (let i = 1; i < group.rows.length; i++) {
|
||||
const row = group.rows[i];
|
||||
const link = num(row.LinkIndex);
|
||||
if ([51, 52, 58, 62, 68].includes(link)) continue;
|
||||
if ([1, 3].includes(num(row.CalcIndex))) continue;
|
||||
if (row.GN != null) {
|
||||
row.GN = num(row.GN) * numFactor;
|
||||
}
|
||||
}
|
||||
return next.options?.AutoCalc === false ? next : EstNative.calculate(next);
|
||||
}
|
||||
function aligned(project, incoming) {
|
||||
const a = project.tables.EstMainDat,
|
||||
@@ -216,12 +588,28 @@ const EstBusiness = (() => {
|
||||
function escalation(project) {
|
||||
return EstEscalation.calculate(project);
|
||||
}
|
||||
function newProgressName(rounds) {
|
||||
const names = new Set(rounds.map((round) => String(round.name || "").trim()));
|
||||
let index = 1;
|
||||
while (names.has(`${index}회 기성`)) index++;
|
||||
return `${index}회 기성`;
|
||||
}
|
||||
function newAnnualName(years) {
|
||||
const names = new Set(years.map((year) => String(year.name || "").trim()));
|
||||
for (const year of years)
|
||||
for (const match of String(year.formula || "").matchAll(/\[([^\]]+)\]/g)) names.add(match[1]);
|
||||
let index = 1;
|
||||
while (names.has(`${index}차`)) index++;
|
||||
return `${index}차`;
|
||||
}
|
||||
function annual(project, full = true) {
|
||||
const errors = [],
|
||||
results = [];
|
||||
const names = (project.annual || []).map((year) => String(year.name || "").trim());
|
||||
if (names.some((name) => !name.trim()) || new Set(names).size !== names.length)
|
||||
return { results, errors: ["차수 이름은 비어 있거나 중복될 수 없습니다."] };
|
||||
if (names.some((name) => ["기준", "계약", "계약수량"].includes(name)))
|
||||
return { results, errors: ["차수 이름에 기준·계약·계약수량은 사용할 수 없습니다."] };
|
||||
for (const year of project.annual || []) {
|
||||
const next = full ? copy(project) : EstNative.empty();
|
||||
if (!full) {
|
||||
@@ -288,12 +676,20 @@ const EstBusiness = (() => {
|
||||
return {
|
||||
baseline,
|
||||
changes,
|
||||
costChanges,
|
||||
bid,
|
||||
restoreBid,
|
||||
convertGroupUnit,
|
||||
original,
|
||||
progress,
|
||||
escalation,
|
||||
annual,
|
||||
newAnnualName,
|
||||
newProgressName,
|
||||
rowKey,
|
||||
progressExclusions,
|
||||
moveEstimateRows,
|
||||
moveGroupDetailRows,
|
||||
importContract,
|
||||
importBid,
|
||||
annualCopy,
|
||||
|
||||
@@ -110,7 +110,7 @@ const EstNative = (() => {
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
function calculate(input) {
|
||||
function calculate(input, request = {}) {
|
||||
const project = clone(input),
|
||||
tables = project.tables,
|
||||
options = project.options || {},
|
||||
@@ -126,7 +126,16 @@ const EstNative = (() => {
|
||||
if (!result) return row;
|
||||
const merged = { ...row, ...result };
|
||||
// Main-row identities belong to a different native table/index.
|
||||
for (const key of ["PkID", "Index", "RecNo", "Flag"]) {
|
||||
// Native title records must preserve their own identities and blob/memo payloads.
|
||||
for (const key of [
|
||||
"PkID",
|
||||
"Index",
|
||||
"RecNo",
|
||||
"Flag",
|
||||
"DifineMemo",
|
||||
"SpaceMemo",
|
||||
"EscData",
|
||||
]) {
|
||||
if (Object.hasOwn(row, key)) merged[key] = row[key];
|
||||
else delete merged[key];
|
||||
}
|
||||
@@ -178,14 +187,21 @@ const EstNative = (() => {
|
||||
const group = (kind === 103 ? heads : costs).get(code);
|
||||
if (!group) throw Error(`하위 자료 없음: ${id}`);
|
||||
active.add(id);
|
||||
const refreshHeadRows =
|
||||
kind === 103
|
||||
? (request.refreshHeadRows || [])
|
||||
.filter((index) => index >= group.start && index < group.start + group.rows.length)
|
||||
.map((index) => index - group.start)
|
||||
: [];
|
||||
const result =
|
||||
kind === 103
|
||||
? EstRows.head(group.rows, options, resolve)
|
||||
? EstRows.head(group.rows, options, resolve, refreshHeadRows)
|
||||
: EstText.calculate(
|
||||
group.rows,
|
||||
options,
|
||||
options.SourceRefresh || options.ResourceTracing ? resolve : () => null,
|
||||
);
|
||||
if (refreshHeadRows.length) request.onHeadRefresh?.(result.refreshErrors);
|
||||
const title = {
|
||||
...tables[sourceTables[kind]].find((row) => row.Code === code),
|
||||
...result.titles[0],
|
||||
@@ -251,7 +267,13 @@ const EstNative = (() => {
|
||||
"HeadTitleDat",
|
||||
[...heads.keys()].map((code) => completed.get(`103:${code}`)).filter(Boolean),
|
||||
);
|
||||
const estimate = EstRows.estimate(tables.EstMainDat, options, resolve);
|
||||
const estimate = EstRows.estimate(
|
||||
tables.EstMainDat,
|
||||
options,
|
||||
resolve,
|
||||
request.refreshEstimateRows,
|
||||
);
|
||||
request.onEstimateRefresh?.(estimate.refreshErrors);
|
||||
tables.EstMainDat = estimate.rows;
|
||||
errors.push(...estimate.errors);
|
||||
const direct = estimate.direct;
|
||||
@@ -343,6 +365,7 @@ const EstNative = (() => {
|
||||
progress: [],
|
||||
annual: [],
|
||||
drawings: [],
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
return { calculate, price, groups, empty, allTables, sourceTables, normalize, materialBase };
|
||||
|
||||
@@ -16,11 +16,23 @@ const EstEscalation = (() => {
|
||||
if (row[field + "_"] != null) old[field] = row[field + "_"];
|
||||
return old;
|
||||
});
|
||||
const contracts = new Map(original.map((row, index) => [key(row, index), row]));
|
||||
const identities = (items) => {
|
||||
const counts = new Map();
|
||||
return items.map((row, index) => {
|
||||
const id = key(row, index),
|
||||
occurrence = counts.get(id) || 0;
|
||||
counts.set(id, occurrence + 1);
|
||||
return JSON.stringify([id, occurrence]);
|
||||
});
|
||||
};
|
||||
const originalKeys = identities(original),
|
||||
rowKeys = identities(rows);
|
||||
const contracts = new Map(original.map((row, index) => [originalKeys[index], row]));
|
||||
const design = structuredClone(original),
|
||||
excluded = structuredClone(original);
|
||||
rows.forEach((row, index) => {
|
||||
const old = contracts.get(key(row, index)) || row;
|
||||
const old = contracts.get(rowKeys[index]) || row;
|
||||
units.forEach((unit) => (row[`Contract${unit}`] = num(old[unit])));
|
||||
const auto = row.EscAuto !== false;
|
||||
const quantity = auto
|
||||
? Math.max(num(row.EscPlanned), num(row.EscActual), num(row.EscProgress))
|
||||
@@ -48,10 +60,10 @@ const EstEscalation = (() => {
|
||||
});
|
||||
if (![1, 3].includes(num(row.CalcIndex))) row.TC = sum(row);
|
||||
});
|
||||
const current = new Map(rows.map((row, index) => [key(row, index), row]));
|
||||
const current = new Map(rows.map((row, index) => [rowKeys[index], row]));
|
||||
design.forEach((row, index) => {
|
||||
if (num(row.Level) > 0 || special(row)) return;
|
||||
const now = current.get(key(row, index));
|
||||
const now = current.get(originalKeys[index]);
|
||||
if (!now) return;
|
||||
units.forEach((unit) => {
|
||||
if (now["A" + unit] != null) row[unit] = num(now["A" + unit]);
|
||||
@@ -59,7 +71,7 @@ const EstEscalation = (() => {
|
||||
});
|
||||
excluded.forEach((row, index) => {
|
||||
if (num(row.Level) > 0 || special(row)) return;
|
||||
const now = current.get(key(row, index)),
|
||||
const now = current.get(originalKeys[index]),
|
||||
quantity = num(now?.EscExcluded),
|
||||
oldQuantity = num(row.GN);
|
||||
if ([1, 3].includes(num(row.CalcIndex))) {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
const EstEstimate = (() => {
|
||||
let page = 0;
|
||||
function usage(project) {
|
||||
const sources = new Map();
|
||||
for (const row of project.tables.EstiMateDat || []) {
|
||||
if (!row.Code) continue;
|
||||
const rows = sources.get(row.Code) || [];
|
||||
rows.push(row);
|
||||
sources.set(row.Code, rows);
|
||||
}
|
||||
return (project.tables.EstMainDat || []).flatMap((row, index) => {
|
||||
if (Number(row.Level) > 0 || Number(row.LinkIndex) !== 101) return [];
|
||||
const matches = sources.get(row.Code) || [];
|
||||
const source =
|
||||
matches.length === 1 ? EstNative.price(structuredClone(matches[0]), 101) : null;
|
||||
const appliedPrice =
|
||||
row.TU == null
|
||||
? EstMath.units.reduce((sum, field) => sum + EstMath.num(row[field]), 0)
|
||||
: EstMath.num(row.TU);
|
||||
const sourcePrice = source ? EstMath.num(source.TC) : null;
|
||||
const canApply =
|
||||
matches.length === 1 && sourcePrice != null && Math.abs(appliedPrice - sourcePrice) > 1e-6;
|
||||
return [
|
||||
{
|
||||
index,
|
||||
Code: row.Code || "",
|
||||
Name: row.Name || "",
|
||||
Size: row.Size || "",
|
||||
Unite: row.Unite || "",
|
||||
GN: EstMath.num(row.GN),
|
||||
appliedPrice,
|
||||
sourcePrice,
|
||||
difference: source ? appliedPrice - sourcePrice : null,
|
||||
TC: EstMath.num(row.TC),
|
||||
status: !matches.length ? "연결 없음" : matches.length > 1 ? "코드 중복" : "연결됨",
|
||||
canApply,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
function changes(project) {
|
||||
const labels = { new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" };
|
||||
return EstBusiness.changes(project)
|
||||
.filter((row) => Number(row.LinkIndex) === 101 || Number(row.originalLinkIndex) === 101)
|
||||
.map((row) => ({ ...row, status: labels[row.status] || row.status }));
|
||||
}
|
||||
function applyRowPrice(project, index) {
|
||||
index = Number(index);
|
||||
const row = project.tables?.EstMainDat?.[index];
|
||||
if (!row || Number(row.Level) > 0 || Number(row.LinkIndex) !== 101)
|
||||
throw Error("견적단가 내역 행을 찾을 수 없습니다.");
|
||||
const sources = (project.tables?.EstiMateDat || []).filter(
|
||||
(s) => s.Code && s.Code === row.Code,
|
||||
);
|
||||
if (!sources.length) throw Error(`견적단가 원천 자료가 없습니다: ${row.Code}`);
|
||||
if (sources.length > 1) throw Error(`견적단가 원천 코드가 중복되었습니다: ${row.Code}`);
|
||||
const source = EstNative.price(structuredClone(sources[0]), 101);
|
||||
const sourcePrice = EstMath.num(source?.TC);
|
||||
if (!Number.isFinite(sourcePrice)) throw Error("기초 단가를 계산할 수 없습니다.");
|
||||
|
||||
// 원천 비목 구성 (NC, GC, JC, SC)
|
||||
const baseUnits = EstMath.components.map((comp) => EstMath.num(source[comp]));
|
||||
// SCheck 재분류가 설정되어 있는 경우 비목 보존
|
||||
const scheck = Number(row.SCheck);
|
||||
if (scheck > 0) {
|
||||
const targetUnit = { 1: "GU", 2: "NU", 3: "JU" }[scheck];
|
||||
for (const u of EstMath.units) row[u] = u === targetUnit ? sourcePrice : 0;
|
||||
} else {
|
||||
// SCheck === 0 이거나 미지정인 경우 원천 비목 분할 유지 (NU, GU, JU, SU)
|
||||
EstMath.units.forEach((u, i) => (row[u] = baseUnits[i]));
|
||||
}
|
||||
// AutoCalc가 비활성화된 수동계산 모드에서는 기존 금액을 보존하고, 활성화된 경우에만 재계산
|
||||
if (project.options?.AutoCalc !== false) {
|
||||
EstMath.amounts(row, project.options?.EstDigits || 0, project.options?.TotalFit);
|
||||
} else {
|
||||
row.TU = EstMath.units.reduce((sum, key) => sum + EstMath.num(row[key]), 0);
|
||||
}
|
||||
}
|
||||
function applyAllPrices(project) {
|
||||
const list = usage(project).filter((item) => item.canApply);
|
||||
if (!list.length) throw Error("적용할 기초 단가 차이가 없습니다.");
|
||||
for (const item of list) applyRowPrice(project, item.index);
|
||||
return list.length;
|
||||
}
|
||||
function render() {
|
||||
const { state, esc, number, byId } = EstApp,
|
||||
isChange = state.view === "estimate-change",
|
||||
rows = isChange ? changes(state.project) : usage(state.project);
|
||||
page = Math.max(0, Math.min(page, Math.ceil(rows.length / 100) - 1));
|
||||
const visible = rows.slice(page * 100, page * 100 + 100),
|
||||
value = (v) => (v == null ? "" : number(v));
|
||||
if (isChange) {
|
||||
byId("work-content").innerHTML =
|
||||
`<div class="business-tools"><b>견적단가 설계변경</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-estimate-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-estimate-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><table class="sheet"><thead><tr><th>구분</th><th>코드</th><th>품명</th><th>당초 수량</th><th>변경 수량</th><th>당초 금액</th><th>변경 금액</th><th>증감</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.status)}</td><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${value(row.originalGN)}</td><td>${value(row.GN)}</td><td>${value(row.originalTC)}</td><td>${value(row.TC)}</td><td>${value(row.difference)}</td><td>${row.index == null ? "" : `<button data-estimate-row="${row.index}">내역 열기</button>`}</td></tr>`).join("")}</tbody></table>`;
|
||||
return;
|
||||
}
|
||||
const canApplyCount = rows.filter((r) => r.canApply).length;
|
||||
byId("work-content").innerHTML =
|
||||
`<div class="business-tools"><b>견적단가 사용 내역</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-estimate-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-estimate-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button><button data-estimate-apply-all ${canApplyCount ? "" : "disabled"}>기초단가 일괄 적용 (${canApplyCount}건)</button></div><p>내역에 적용된 단가와 현재 견적단가 자료를 비교합니다.</p><table class="sheet"><thead><tr><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>수량</th><th>내역 단가</th><th>기초 단가</th><th>차이</th><th>금액</th><th>연결</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.GN)}</td><td>${value(row.appliedPrice)}</td><td>${value(row.sourcePrice)}</td><td>${value(row.difference)}</td><td>${value(row.TC)}</td><td>${esc(row.status)}</td><td><button data-estimate-row="${row.index}">내역 열기</button>${row.canApply ? ` <button data-estimate-apply-row="${row.index}">단가 적용</button>` : ""}</td></tr>`).join("")}</tbody></table>`;
|
||||
}
|
||||
function install() {
|
||||
document.addEventListener("click", (event) => {
|
||||
const applyAll = event.target.closest("[data-estimate-apply-all]");
|
||||
if (applyAll) {
|
||||
try {
|
||||
EstApp.change((p) => {
|
||||
const count = applyAllPrices(p);
|
||||
EstApp.state.notice = `견적단가 ${count}건의 기초단가를 일괄 적용했습니다.`;
|
||||
});
|
||||
} catch (err) {
|
||||
EstApp.state.notice = err.message;
|
||||
EstApp.render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const applyRow = event.target.closest("[data-estimate-apply-row]");
|
||||
if (applyRow) {
|
||||
const index = Number(applyRow.dataset.estimateApplyRow);
|
||||
try {
|
||||
EstApp.change((p) => {
|
||||
applyRowPrice(p, index);
|
||||
EstApp.state.notice = `행 ${index + 1}에 기초단가를 적용했습니다.`;
|
||||
});
|
||||
} catch (err) {
|
||||
EstApp.state.notice = err.message;
|
||||
EstApp.render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const step = event.target.closest("[data-estimate-page]");
|
||||
if (step) {
|
||||
page += Number(step.dataset.estimatePage);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const node = event.target.closest("[data-estimate-row]");
|
||||
if (!node) return;
|
||||
const index = Number(node.dataset.estimateRow);
|
||||
if (!EstApp.state.project.tables.EstMainDat[index]) return;
|
||||
EstApp.navigate("EstMainDat");
|
||||
EstApp.state.page = Math.floor(index / 100);
|
||||
EstApp.state.selection = new Set([index]);
|
||||
EstApp.render();
|
||||
});
|
||||
}
|
||||
return { usage, changes, render, install, applyRowPrice, applyAllPrices };
|
||||
})();
|
||||
@@ -1,4 +1,6 @@
|
||||
const EstExchange = (() => {
|
||||
const decodeExcelText = (value) =>
|
||||
value.replace(/_x([\da-f]{4})_/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
||||
function xmlText(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xfe)
|
||||
@@ -31,7 +33,25 @@ const EstExchange = (() => {
|
||||
const tables = {};
|
||||
for (const sheet of workbook.getElementsByTagNameNS("*", "sheet")) {
|
||||
const name = sheet.getAttribute("name");
|
||||
if (!EstNative.empty().tables[name] || name.startsWith("Cad")) continue;
|
||||
if (
|
||||
(!EstNative.empty().tables[name] &&
|
||||
![
|
||||
"Quantities",
|
||||
"Schedule",
|
||||
"ExcelQuantityLinks",
|
||||
"QDocuments",
|
||||
"QLines",
|
||||
"QItems",
|
||||
"Review",
|
||||
"Annual",
|
||||
"ProgressDrafts",
|
||||
"ProgressInputs",
|
||||
"Escalation",
|
||||
"EscalationSettings",
|
||||
].includes(name)) ||
|
||||
name.startsWith("Cad")
|
||||
)
|
||||
continue;
|
||||
const id = sheet.getAttributeNS(
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
"id",
|
||||
@@ -68,7 +88,7 @@ const EstExchange = (() => {
|
||||
value = Number(v);
|
||||
if (!Number.isFinite(value)) throw Error("Excel 숫자 오류");
|
||||
}
|
||||
values[at] = value;
|
||||
values[at] = typeof value === "string" ? decodeExcelText(value) : value;
|
||||
}
|
||||
return values;
|
||||
})
|
||||
@@ -93,9 +113,250 @@ const EstExchange = (() => {
|
||||
throw Error("가져올 EST 표 이름이 없습니다. 이 앱에서 내보낸 Excel 표를 사용하세요.");
|
||||
return tables;
|
||||
}
|
||||
function quantitySheets(project, tables) {
|
||||
const names = ["QDocuments", "QLines", "QItems"];
|
||||
if (!names.some((name) => Object.hasOwn(tables, name))) return null;
|
||||
if (!names.every((name) => Object.hasOwn(tables, name)))
|
||||
throw Error("수량산출서는 QDocuments·QLines·QItems 시트가 모두 필요합니다.");
|
||||
const old = new Map((project.quantitySheets || []).map((sheet) => [sheet.Code, sheet]));
|
||||
const docs = new Map();
|
||||
for (const row of tables.QDocuments) {
|
||||
if (typeof row.Code !== "string" || !row.Code || docs.has(row.Code))
|
||||
throw Error("수량산출서 코드가 비어 있거나 중복됩니다.");
|
||||
if (row.Name != null && typeof row.Name !== "string") throw Error("수량산출서 이름 오류");
|
||||
const digits = row.digits ?? 2,
|
||||
roundMode = row.roundMode ?? 2;
|
||||
if (
|
||||
!Number.isInteger(digits) ||
|
||||
digits < 0 ||
|
||||
digits > 12 ||
|
||||
!Number.isInteger(roundMode) ||
|
||||
roundMode < 0 ||
|
||||
roundMode > 4
|
||||
)
|
||||
throw Error("수량산출서 자릿수·반올림 방식 오류");
|
||||
docs.set(row.Code, {
|
||||
...old.get(row.Code),
|
||||
Code: row.Code,
|
||||
Name: row.Name ?? "",
|
||||
rows: [],
|
||||
items: [],
|
||||
options: { ...old.get(row.Code)?.options, digits, roundMode },
|
||||
});
|
||||
}
|
||||
function grouped(rows) {
|
||||
const groups = new Map(),
|
||||
positions = new Set();
|
||||
for (const row of rows) {
|
||||
if (!docs.has(row.DocumentCode)) throw Error(`수량산출서 코드 없음: ${row.DocumentCode}`);
|
||||
if (!Number.isInteger(row.Position) || row.Position < 1)
|
||||
throw Error("수량산출서 행 순서 오류");
|
||||
const key = JSON.stringify([row.DocumentCode, row.Position]);
|
||||
if (positions.has(key)) throw Error("수량산출서 행 순서 중복");
|
||||
positions.add(key);
|
||||
if (!groups.has(row.DocumentCode)) groups.set(row.DocumentCode, []);
|
||||
groups.get(row.DocumentCode).push(row);
|
||||
}
|
||||
for (const group of groups.values()) group.sort((a, b) => a.Position - b.Position);
|
||||
return groups;
|
||||
}
|
||||
const lines = grouped(tables.QLines),
|
||||
items = grouped(tables.QItems);
|
||||
for (const [code, doc] of docs) {
|
||||
doc.rows = (lines.get(code) || []).map((row) => {
|
||||
if (row.LineData != null && typeof row.LineData !== "string")
|
||||
throw Error("산출근거 문자 오류");
|
||||
let markers;
|
||||
try {
|
||||
markers = JSON.parse(row.Markers || "[]");
|
||||
} catch {
|
||||
throw Error("수량 결과 표지 형식 오류");
|
||||
}
|
||||
if (!Array.isArray(markers) || markers.some((value) => typeof value !== "string"))
|
||||
throw Error("수량 결과 표지 형식 오류");
|
||||
return { LineData: row.LineData ?? "", markers };
|
||||
});
|
||||
const prior = new Map((old.get(code)?.items || []).map((item) => [item.Code, item]));
|
||||
const seen = new Set();
|
||||
doc.items = (items.get(code) || []).map((row) => {
|
||||
if (typeof row.Code !== "string" || !row.Code || seen.has(row.Code))
|
||||
throw Error("수량 항목 코드가 비어 있거나 중복됩니다.");
|
||||
seen.add(row.Code);
|
||||
for (const key of ["Name", "Size", "Unite", "TargetCode", "TargetKey"])
|
||||
if (row[key] != null && typeof row[key] !== "string")
|
||||
throw Error(`수량 항목 ${key} 형식 오류`);
|
||||
const previous = prior.get(row.Code),
|
||||
next = { ...previous, Code: row.Code };
|
||||
for (const key of ["Name", "Size", "Unite"]) {
|
||||
if (row[key] != null) next[key] = row[key];
|
||||
else if (!previous || previous[key] != null) next[key] = "";
|
||||
}
|
||||
if (row.GN != null) {
|
||||
if (typeof row.GN !== "number" || !Number.isFinite(row.GN))
|
||||
throw Error("수량 항목 수치 오류");
|
||||
next.GN = row.GN;
|
||||
} else if (!previous || previous.GN != null) next.GN = 0;
|
||||
if (row.TargetCode != null) {
|
||||
if (
|
||||
!previous ||
|
||||
Object.hasOwn(previous, "TargetCode") ||
|
||||
row.TargetCode !== previous.LinkCode
|
||||
)
|
||||
next.TargetCode = row.TargetCode;
|
||||
} else if (previous && (previous.TargetCode ?? previous.LinkCode)) next.TargetCode = "";
|
||||
if (row.TargetKey != null) next.TargetKey = row.TargetKey;
|
||||
else if (previous?.TargetKey) next.TargetKey = "";
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return [...docs.values()];
|
||||
}
|
||||
function apply(project, tables) {
|
||||
const next = structuredClone(project);
|
||||
const sheets = quantitySheets(next, tables);
|
||||
if (sheets) next.quantitySheets = sheets;
|
||||
if (Object.hasOwn(tables, "ProgressDrafts") || Object.hasOwn(tables, "ProgressInputs")) {
|
||||
if (!Object.hasOwn(tables, "ProgressDrafts") || !Object.hasOwn(tables, "ProgressInputs"))
|
||||
throw Error("기성 Excel에는 ProgressDrafts와 ProgressInputs 시트가 모두 필요합니다.");
|
||||
const old = next.progress || [],
|
||||
locked = old.filter((round) => round.locked);
|
||||
if (old.slice(0, locked.length).some((round) => !round.locked))
|
||||
throw Error("확정 기성 회차 순서 오류");
|
||||
const previous = new Map(
|
||||
old.filter((round) => !round.locked).map((round) => [round.id, round]),
|
||||
);
|
||||
const ids = new Set(locked.map((round) => round.id)),
|
||||
drafts = new Map();
|
||||
for (const row of tables.ProgressDrafts) {
|
||||
if (typeof row.id !== "string" || !row.id || ids.has(row.id))
|
||||
throw Error("기성 회차 ID가 없거나 중복됩니다.");
|
||||
ids.add(row.id);
|
||||
const round = {
|
||||
...previous.get(row.id),
|
||||
id: row.id,
|
||||
name: row.name ?? "",
|
||||
locked: false,
|
||||
approved: row.approved ?? false,
|
||||
separateComponents: row.separateComponents ?? true,
|
||||
inputs: Object.create(null),
|
||||
};
|
||||
delete round.contract;
|
||||
delete round.result;
|
||||
if (row.amountDigits == null) delete round.amountDigits;
|
||||
else round.amountDigits = row.amountDigits;
|
||||
drafts.set(row.id, round);
|
||||
}
|
||||
const inputFields = [
|
||||
"GumMode",
|
||||
"Gum",
|
||||
"GumNC",
|
||||
"GumGC",
|
||||
"GumJC",
|
||||
"GumSC",
|
||||
"SaMode",
|
||||
"Sa",
|
||||
"SaNC",
|
||||
"SaGC",
|
||||
"SaJC",
|
||||
"SaSC",
|
||||
"QuantityDigits",
|
||||
"AmountDigits",
|
||||
];
|
||||
for (const row of tables.ProgressInputs) {
|
||||
const round = drafts.get(row.RoundId);
|
||||
if (
|
||||
!round ||
|
||||
typeof row.RowKey !== "string" ||
|
||||
!row.RowKey ||
|
||||
Object.hasOwn(round.inputs, row.RowKey)
|
||||
)
|
||||
throw Error("기성 입력의 회차·행 식별자가 없거나 중복됩니다.");
|
||||
const prior = previous.get(row.RoundId)?.inputs?.[row.RowKey] || {};
|
||||
const input = Object.fromEntries(
|
||||
Object.entries(prior).filter(([key]) => !inputFields.includes(key)),
|
||||
);
|
||||
for (const field of inputFields) if (row[field] != null) input[field] = row[field];
|
||||
round.inputs[row.RowKey] = input;
|
||||
}
|
||||
next.progress = [...locked, ...drafts.values()];
|
||||
}
|
||||
for (const [table, rows] of Object.entries(tables)) {
|
||||
if (
|
||||
[
|
||||
"QDocuments",
|
||||
"QLines",
|
||||
"QItems",
|
||||
"ProgressDrafts",
|
||||
"ProgressInputs",
|
||||
"Escalation",
|
||||
"EscalationSettings",
|
||||
"ExcelQuantityLinks",
|
||||
].includes(table)
|
||||
)
|
||||
continue;
|
||||
if (table === "Annual") {
|
||||
next.annual = rows.map((row) => {
|
||||
if (typeof row.name !== "string" || typeof row.formula !== "string")
|
||||
throw Error("연차 이름·수식 형식 오류");
|
||||
return { name: row.name, formula: row.formula };
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (table === "Review") {
|
||||
const edits = {};
|
||||
for (const row of rows) {
|
||||
if (
|
||||
typeof row.RowKey !== "string" ||
|
||||
!row.RowKey.startsWith("row:") ||
|
||||
Object.hasOwn(edits, row.RowKey)
|
||||
)
|
||||
throw Error("계약심사 내역 식별자가 없거나 중복됩니다.");
|
||||
const edit = {};
|
||||
for (const field of ["GN", "NU", "GU", "JU", "SU", "NC", "GC", "JC", "SC", "Reason"])
|
||||
if (row[field] != null) edit[field] = row[field];
|
||||
edits[row.RowKey] = edit;
|
||||
}
|
||||
next.contractReview = { edits };
|
||||
EstReview.validate(next);
|
||||
continue;
|
||||
}
|
||||
if (table === "Quantities") {
|
||||
const seen = new Set();
|
||||
next.quantities = rows.map((row) => {
|
||||
if (typeof row.Code !== "string" || !row.Code || seen.has(row.Code))
|
||||
throw Error("수식표 코드가 비어 있거나 중복됩니다.");
|
||||
seen.add(row.Code);
|
||||
for (const key of ["Name", "TargetCode", "TargetKey", "formula"])
|
||||
if (row[key] != null && typeof row[key] !== "string")
|
||||
throw Error(`수식표 ${key} 형식 오류`);
|
||||
const digits = row.digits ?? 2;
|
||||
if (!Number.isInteger(digits) || digits < 0 || digits > 6)
|
||||
throw Error("수식표 소수자리는 0~6입니다.");
|
||||
return {
|
||||
Code: row.Code,
|
||||
Name: row.Name ?? "",
|
||||
TargetCode: row.TargetCode ?? "",
|
||||
TargetKey: row.TargetKey ?? "",
|
||||
formula: row.formula ?? "0",
|
||||
digits,
|
||||
};
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (table === "Schedule") {
|
||||
next.schedule = rows.map((row) => ({
|
||||
id: row.id,
|
||||
SourceKey: row.SourceKey ?? "",
|
||||
Code: row.Code ?? "",
|
||||
Name: row.Name ?? "",
|
||||
start: row.start ?? "",
|
||||
end: row.end ?? "",
|
||||
weight: row.weight ?? 0,
|
||||
progress: row.progress ?? 0,
|
||||
}));
|
||||
EstSchedule.validate(next);
|
||||
continue;
|
||||
}
|
||||
const old = next.tables[table],
|
||||
byId = new Map(old.filter((r) => r.PkID != null).map((r) => [r.PkID, r]));
|
||||
const keys = rows[0] ? Object.keys(rows[0]) : [];
|
||||
@@ -143,6 +404,117 @@ const EstExchange = (() => {
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
if (Object.hasOwn(tables, "Escalation")) {
|
||||
const targets = new Map();
|
||||
next.tables.EstMainDat.forEach((row, index) => {
|
||||
if (
|
||||
Number(row.Level || 0) > 0 ||
|
||||
[54, 55, 57, 58, 59, 62, 67].includes(Number(row.LinkIndex || 0))
|
||||
)
|
||||
return;
|
||||
const key = EstBusiness.rowKey(row, index),
|
||||
matches = targets.get(key) || [];
|
||||
matches.push(row);
|
||||
targets.set(key, matches);
|
||||
});
|
||||
const seen = new Set();
|
||||
const fields = [
|
||||
"ANU",
|
||||
"AGU",
|
||||
"AJU",
|
||||
"ASU",
|
||||
"EscPlanned",
|
||||
"EscActual",
|
||||
"EscProgress",
|
||||
"EscAuto",
|
||||
"EscManualQuantity",
|
||||
"EscConstruction",
|
||||
"EscPS",
|
||||
"EscBefore",
|
||||
];
|
||||
for (const row of tables.Escalation) {
|
||||
const matches = targets.get(row.RowKey) || [];
|
||||
if (typeof row.RowKey !== "string" || seen.has(row.RowKey) || matches.length !== 1)
|
||||
throw Error(`물가변동 내역이 없거나 중복됩니다: ${row.RowKey}`);
|
||||
seen.add(row.RowKey);
|
||||
for (const field of fields) {
|
||||
if (!Object.hasOwn(row, field)) continue;
|
||||
if (row[field] == null) delete matches[0][field];
|
||||
else {
|
||||
if (
|
||||
field === "EscAuto"
|
||||
? typeof row[field] !== "boolean"
|
||||
: typeof row[field] !== "number" || !Number.isFinite(row[field])
|
||||
)
|
||||
throw Error(`물가변동 ${field} 형식 오류`);
|
||||
matches[0][field] = row[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(tables, "ExcelQuantityLinks")) {
|
||||
next.excelQuantityLinks = tables.ExcelQuantityLinks.map((row) => ({
|
||||
target: row.target,
|
||||
file: row.file,
|
||||
sheet: row.sheet,
|
||||
cell: row.cell,
|
||||
}));
|
||||
EstModel.validate(next);
|
||||
for (const link of next.excelQuantityLinks) {
|
||||
const rows = next.tables.EstMainDat.filter((row) => row.PkID === link.target);
|
||||
if (rows.length !== 1 || !EstQuantity.excelTarget(rows[0]))
|
||||
throw Error(`Excel 연결 내역이 없거나 중복되었거나 상세 행이 아닙니다: ${link.target}`);
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(tables, "EstMainDat"))
|
||||
for (const link of project.excelQuantityLinks || [])
|
||||
if (
|
||||
(next.excelQuantityLinks || []).some((item) => item.target === link.target) &&
|
||||
!project.tables.EstMainDat.some((row) => row.PkID === link.target) &&
|
||||
next.tables.EstMainDat.some((row) => row.PkID === link.target)
|
||||
)
|
||||
throw Error(`삭제된 Excel 연결 내역의 식별자를 다시 사용할 수 없습니다: ${link.target}`);
|
||||
if (Object.hasOwn(tables, "EscalationSettings")) {
|
||||
const rows = tables.EscalationSettings;
|
||||
if (
|
||||
rows.length !== 1 ||
|
||||
typeof rows[0].EscDesignTotal !== "number" ||
|
||||
!Number.isFinite(rows[0].EscDesignTotal) ||
|
||||
rows[0].EscDesignTotal < 0
|
||||
)
|
||||
throw Error("물가변동 조사 도급액 형식 오류");
|
||||
next.options.EscDesignTotal = rows[0].EscDesignTotal;
|
||||
}
|
||||
if (Object.hasOwn(tables, "Review")) {
|
||||
const targets = new Map();
|
||||
next.tables.EstMainDat.forEach((row, index) => {
|
||||
if (!EstReview.editable(row)) return;
|
||||
const key = EstReview.rowKey(row, index),
|
||||
matches = targets.get(key) || [];
|
||||
matches.push(row);
|
||||
targets.set(key, matches);
|
||||
});
|
||||
for (const [key, edit] of Object.entries(next.contractReview.edits)) {
|
||||
const matches = targets.get(key) || [];
|
||||
if (matches.length !== 1) throw Error(`심사 내역이 없거나 중복됩니다: ${key}`);
|
||||
if (
|
||||
Object.keys(edit).some(
|
||||
(field) => field !== "Reason" && !EstReview.allowed(matches[0]).includes(field),
|
||||
)
|
||||
)
|
||||
throw Error(`해당 내역에 사용할 수 없는 심사 입력: ${key}`);
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(tables, "ProgressDrafts")) {
|
||||
const counts = new Map();
|
||||
next.tables.EstMainDat.forEach((row, index) => {
|
||||
const key = EstBusiness.rowKey(row, index);
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
});
|
||||
for (const round of next.progress.filter((item) => !item.locked))
|
||||
for (const key of Object.keys(round.inputs))
|
||||
if (counts.get(key) !== 1) throw Error(`기성 입력 내역이 없거나 중복됩니다: ${key}`);
|
||||
}
|
||||
return EstModel.validate(next);
|
||||
}
|
||||
async function exportExcel() {
|
||||
@@ -275,5 +647,5 @@ const EstExchange = (() => {
|
||||
}
|
||||
});
|
||||
}
|
||||
return { read, apply, exportExcel, exportNative, exportJdl, install };
|
||||
return { read, apply, decodeExcelText, exportExcel, exportNative, exportJdl, install };
|
||||
})();
|
||||
|
||||
+405
-49
@@ -12,10 +12,38 @@ const EstExtra = (() => {
|
||||
return `<div class="business-tools"><b>${title}</b>${tools}</div>${body}${note ? `<p class="business-note">${note}</p>` : ""}`;
|
||||
}
|
||||
function render(state) {
|
||||
if (state.view === "review") {
|
||||
EstReview.render(state);
|
||||
return;
|
||||
}
|
||||
if (state.view === "schedule") {
|
||||
EstSchedule.render();
|
||||
return;
|
||||
}
|
||||
if (state.view === "issues") {
|
||||
EstIssues.render();
|
||||
return;
|
||||
}
|
||||
if (state.view === "attachments") {
|
||||
EstAttachments.render();
|
||||
return;
|
||||
}
|
||||
if (["standard", "standard-change"].includes(state.view)) {
|
||||
EstStandard.render();
|
||||
return;
|
||||
}
|
||||
if (["estimate-usage", "estimate-change"].includes(state.view)) {
|
||||
EstEstimate.render();
|
||||
return;
|
||||
}
|
||||
if (["material-usage", "material-change", "labor-usage", "labor-change"].includes(state.view)) {
|
||||
EstBasicPrices.render();
|
||||
return;
|
||||
}
|
||||
if (["machine-usage", "machine-change"].includes(state.view)) {
|
||||
EstMachine.render();
|
||||
return;
|
||||
}
|
||||
const { project, view } = state,
|
||||
{ esc, number, byId } = EstApp;
|
||||
let html = "";
|
||||
@@ -24,30 +52,28 @@ const EstExtra = (() => {
|
||||
EstQuantity.render(state);
|
||||
return;
|
||||
}
|
||||
const values = Object.create(null),
|
||||
rows = project.quantities.map((row, index) => {
|
||||
let result,
|
||||
error = "";
|
||||
try {
|
||||
result = EstMath.floating(
|
||||
EstExpression.evaluate(row.formula || "0", (key) => values[key]),
|
||||
row.digits ?? 2,
|
||||
1,
|
||||
);
|
||||
values[row.Code] = result;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
return [
|
||||
field("quantities", index, "Code", row.Code, "text"),
|
||||
field("quantities", index, "Name", row.Name, "text"),
|
||||
field("quantities", index, "TargetCode", row.TargetCode, "text"),
|
||||
field("quantities", index, "formula", row.formula, "text"),
|
||||
field("quantities", index, "digits", row.digits ?? 2),
|
||||
esc(error || number(result)),
|
||||
const rows = EstQuantity.manual(project.quantities).rows.map((row, index) => {
|
||||
return [
|
||||
field("quantities", index, "Code", row.Code, "text"),
|
||||
field("quantities", index, "Name", row.Name, "text"),
|
||||
field("quantities", index, "TargetCode", row.TargetCode, "text") +
|
||||
EstQuantity.targetSelect(project, row, "manual", index),
|
||||
field("quantities", index, "formula", row.formula, "text"),
|
||||
field("quantities", index, "digits", row.digits ?? 2),
|
||||
esc(row.Error || number(row.GN)),
|
||||
button(
|
||||
"move-quantity",
|
||||
"↑",
|
||||
`data-row="${index}" data-direction="-1" ${index === 0 ? "disabled" : ""}`,
|
||||
) +
|
||||
button(
|
||||
"move-quantity",
|
||||
"↓",
|
||||
`data-row="${index}" data-direction="1" ${index === project.quantities.length - 1 ? "disabled" : ""}`,
|
||||
) +
|
||||
button("delete-extra", "삭제", `data-section="quantities" data-row="${index}"`),
|
||||
];
|
||||
});
|
||||
];
|
||||
});
|
||||
html =
|
||||
EstQuantity.selector(state) +
|
||||
box(
|
||||
@@ -63,11 +89,12 @@ const EstExtra = (() => {
|
||||
button("capture-baseline", "현재 내역을 당초로 확정") +
|
||||
button("load-contract", "당초·계약 내역 불러오기") +
|
||||
button("recalculate-original", "당초 단가 재계산") +
|
||||
button("bid-rates", "낙찰률 적용"),
|
||||
button("bid-rates", "낙찰률 적용") +
|
||||
button("restore-bid", "당초단가(100%) 복원"),
|
||||
table(
|
||||
["구분", "코드", "품명", "당초 수량", "변경 수량", "당초 금액", "변경 금액", "증감"],
|
||||
changes
|
||||
.filter((row) => row.Code)
|
||||
.filter((row) => row.Code || row.Name)
|
||||
.map((row) => [
|
||||
esc({ new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" }[row.status]),
|
||||
esc(row.Code),
|
||||
@@ -78,7 +105,17 @@ const EstExtra = (() => {
|
||||
number(row.TC),
|
||||
number(row.difference),
|
||||
]),
|
||||
),
|
||||
) +
|
||||
table(
|
||||
["원가 구분", "비목", "당초 금액", "변경 금액", "증감"],
|
||||
EstBusiness.costChanges(project).map((row) => [
|
||||
esc(row.Gubun),
|
||||
esc(row.Name),
|
||||
number(row.originalCost),
|
||||
number(row.changedCost),
|
||||
number(row.difference),
|
||||
]),
|
||||
),
|
||||
project.baseline
|
||||
? `당초: ${esc(project.baseline.name)}. 당초 자료는 별도 사본으로 보존합니다.`
|
||||
: changes.some((row) => row.originalSource === "file")
|
||||
@@ -89,7 +126,9 @@ const EstExtra = (() => {
|
||||
state.roundId ||= project.progress[0]?.id;
|
||||
const round = project.progress.find((r) => r.id === state.roundId);
|
||||
let body = '<p class="empty">회차를 추가하세요.</p>',
|
||||
tools = button("add-round", "기성 회차 추가");
|
||||
tools =
|
||||
button("add-round", "기성 회차 추가") +
|
||||
(project.progress.length ? button("clear-progress", "기성 전체 초기화") : "");
|
||||
if (round) {
|
||||
tools +=
|
||||
`<select id="round-choice">${project.progress.map((r) => `<option value="${esc(r.id)}" ${r.id === round.id ? "selected" : ""}>${esc(r.name)}${r.locked ? " (확정)" : ""}</option>`).join("")}</select>` +
|
||||
@@ -216,16 +255,12 @@ const EstExtra = (() => {
|
||||
![54, 55, 58, 59].includes(Number(row.LinkIndex)),
|
||||
)
|
||||
.map(({ row, index }) => {
|
||||
const old =
|
||||
project.baseline?.tables.EstMainDat.find(
|
||||
(r, i) => EstBusiness.rowKey(r, i) === EstBusiness.rowKey(row, index),
|
||||
) || row,
|
||||
result = output.rows[index];
|
||||
const result = output.rows[index];
|
||||
return [
|
||||
esc(row.Code),
|
||||
esc(row.Name),
|
||||
EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]),
|
||||
number(project.baseline ? old[comp] : (row[comp + "_"] ?? row[comp])),
|
||||
number(result[`Contract${comp}`]),
|
||||
EstViews.cell("EstMainDat", index, comp, row[comp]),
|
||||
number(result[`D${comp}`]),
|
||||
number(result[`E${comp}`]),
|
||||
@@ -290,28 +325,73 @@ const EstExtra = (() => {
|
||||
number(output.results[index]?.project.calculation.direct.TC),
|
||||
number(output.results[index]?.project.calculation.total),
|
||||
button("open-annual", "차수 내역 열기", `data-row="${index}"`),
|
||||
button("delete-extra", "삭제", `data-section="annual" data-row="${index}"`),
|
||||
button(
|
||||
"move-annual",
|
||||
"↑",
|
||||
`data-row="${index}" data-direction="-1" ${index === 0 ? "disabled" : ""}`,
|
||||
) +
|
||||
button(
|
||||
"move-annual",
|
||||
"↓",
|
||||
`data-row="${index}" data-direction="1" ${index === project.annual.length - 1 ? "disabled" : ""}`,
|
||||
) +
|
||||
button("delete-extra", "삭제", `data-section="annual" data-row="${index}"`),
|
||||
]),
|
||||
),
|
||||
`수식 예: [기준]*0.5, [기준]-[1차]. 같은 수식을 수량과 각 성분 금액에 따로 적용하고 금액은 원 미만 절사합니다. ${esc(output.errors.join(" / "))}`,
|
||||
);
|
||||
} else if (view === "resources") {
|
||||
const output = EstResources.calculate(project);
|
||||
state.resourceKind = state.resourceKind || "all";
|
||||
state.resourceByWork = state.resourceByWork ?? false;
|
||||
const byWork = Boolean(state.resourceByWork);
|
||||
const output = EstResources.calculate(project, byWork);
|
||||
const filtered = output.rows.filter(
|
||||
(r) => state.resourceKind === "all" || String(r.kind) === String(state.resourceKind),
|
||||
);
|
||||
const kinds = [
|
||||
["all", "전체"],
|
||||
["6", "노임"],
|
||||
["5", "자재"],
|
||||
["105", "기계"],
|
||||
["21", "표준"],
|
||||
["101", "견적"],
|
||||
];
|
||||
const kindSelect = `<label>구분 <select id="resource-kind-filter">${kinds
|
||||
.map(
|
||||
([k, label]) =>
|
||||
`<option value="${k}" ${state.resourceKind === k ? "selected" : ""}>${esc(label)}</option>`,
|
||||
)
|
||||
.join("")}</select></label>`;
|
||||
const byWorkCheck = `<label><input type="checkbox" id="resource-by-work" ${byWork ? "checked" : ""}> 공종별 전개</label>`;
|
||||
const headers = byWork
|
||||
? [
|
||||
"공종코드",
|
||||
"공종명",
|
||||
"종류",
|
||||
"코드",
|
||||
"품명",
|
||||
"규격",
|
||||
"단위",
|
||||
"소요량 / 환산시간",
|
||||
"금액",
|
||||
]
|
||||
: ["종류", "코드", "품명", "규격", "단위", "소요량 / 환산시간", "금액"];
|
||||
const rows = filtered.map((row) => {
|
||||
const baseCells = [
|
||||
esc({ 5: "자재", 6: "노임", 21: "표준", 101: "견적", 105: "기계" }[row.kind]),
|
||||
esc(row.Code),
|
||||
esc(row.Name),
|
||||
esc(row.Size),
|
||||
esc(row.Unite),
|
||||
number(row.quantity),
|
||||
number(row.amount),
|
||||
];
|
||||
return byWork ? [esc(row.WorkCode), esc(row.WorkName), ...baseCells] : baseCells;
|
||||
});
|
||||
html = box(
|
||||
"동원인원·자재·장비",
|
||||
"",
|
||||
table(
|
||||
["종류", "코드", "품명", "규격", "단위", "소요량 / 환산시간", "금액"],
|
||||
output.rows.map((row) => [
|
||||
esc({ 5: "자재", 6: "노임", 21: "표준", 101: "견적", 105: "기계" }[row.kind]),
|
||||
esc(row.Code),
|
||||
esc(row.Name),
|
||||
esc(row.Size),
|
||||
esc(row.Unite),
|
||||
number(row.quantity),
|
||||
number(row.amount),
|
||||
]),
|
||||
),
|
||||
`${kindSelect} ${byWorkCheck} <span>(총 ${filtered.length}건)</span>`,
|
||||
table(headers, rows),
|
||||
esc(output.errors.join(" / ")),
|
||||
);
|
||||
} else if (view === "drawing") {
|
||||
@@ -337,5 +417,281 @@ const EstExtra = (() => {
|
||||
}
|
||||
byId("work-content").innerHTML = html;
|
||||
}
|
||||
return { render };
|
||||
|
||||
function findDataUsages(project, target) {
|
||||
if (!project || !target) return [];
|
||||
const code = target.Code ? String(target.Code).trim() : "";
|
||||
const name = target.Name ? EstNative.normalize(target.Name) : "";
|
||||
const size = target.Size ? EstNative.normalize(target.Size) : "";
|
||||
if (!code && !name) return [];
|
||||
|
||||
const results = [];
|
||||
const tables = [
|
||||
{ id: "EstMainDat", label: "내역서" },
|
||||
{ id: "HeadMainDat", label: "일위대가" },
|
||||
{ id: "CostMainDat", label: "단가산출" },
|
||||
{ id: "MachMainDat", label: "기계경비" },
|
||||
];
|
||||
|
||||
for (const info of tables) {
|
||||
const rows = project.tables[info.id] || [];
|
||||
const grouped = ["HeadMainDat", "CostMainDat", "MachMainDat"].includes(info.id);
|
||||
let curGroup = "";
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i];
|
||||
if (grouped && Number(r.Index) > 0 && r.Code) curGroup = r.Code;
|
||||
|
||||
// 대상 자기 자신의 대표 행은 검색 결과에서 제외
|
||||
if (r === target) continue;
|
||||
|
||||
let matched = false;
|
||||
if (code && r.Code === code) {
|
||||
matched = true;
|
||||
} else if (!code && name) {
|
||||
const rName = r.Name ? EstNative.normalize(r.Name) : "";
|
||||
const rSize = r.Size ? EstNative.normalize(r.Size) : "";
|
||||
if (rName === name && (!size || rSize === size)) {
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
const rowName = r.Name || (r.LineData ? EstText.clean(r.LineData) : "");
|
||||
results.push({
|
||||
table: info.id,
|
||||
tableLabel: info.label,
|
||||
index: i,
|
||||
group: curGroup,
|
||||
code: r.Code || "",
|
||||
name: rowName,
|
||||
size: r.Size || "",
|
||||
unite: r.Unite || "",
|
||||
quantity: r.GN != null ? Number(r.GN) : null,
|
||||
price: r.TC != null ? Number(r.TC) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function showUsesSearchDialog(targetRow) {
|
||||
const { state, esc, number, showDialog, byId } = EstApp;
|
||||
if (!targetRow) throw Error("사용위치를 검색할 행을 먼저 선택하세요.");
|
||||
|
||||
const rowName = targetRow.Name || (targetRow.LineData ? EstText.clean(targetRow.LineData) : "");
|
||||
const results = findDataUsages(state.project, targetRow);
|
||||
const title = `데이터 사용위치 검색: ${targetRow.Code ? `[${targetRow.Code}] ` : ""}${rowName || "선택 항목"}`;
|
||||
|
||||
let bodyHtml = "";
|
||||
if (!results.length) {
|
||||
bodyHtml =
|
||||
'<p style="padding:15px;color:#666;">내역서, 일위대가, 단가산출, 기계경비에서 사용된 위치를 찾지 못했습니다.</p>';
|
||||
} else {
|
||||
bodyHtml = `
|
||||
<div style="max-height:420px;overflow-y:auto;border:1px solid #ccc;margin-bottom:12px;">
|
||||
<table class="sheet" style="width:100%;font-size:12px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>구분</th><th>코드</th><th>품명 / 산출근거</th><th>규격</th><th>단위</th><th>수량</th><th>금액</th><th>이동</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${results
|
||||
.map(
|
||||
(res) => `
|
||||
<tr>
|
||||
<td><b>${esc(res.tableLabel)}</b></td>
|
||||
<td>${esc(res.code)}</td>
|
||||
<td style="text-align:left;">${esc(res.name)}</td>
|
||||
<td>${esc(res.size)}</td>
|
||||
<td>${esc(res.unite)}</td>
|
||||
<td style="text-align:right;">${res.quantity != null ? number(res.quantity) : "-"}</td>
|
||||
<td style="text-align:right;">${res.price != null ? number(res.price) : "-"}</td>
|
||||
<td>
|
||||
<button class="tiny" data-action="goto-use-location" data-table="${res.table}" data-index="${res.index}" data-group="${esc(res.group)}">
|
||||
이동
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#555;">총 <b>${results.length}</b>곳에서 사용 중입니다. [이동] 버튼을 누르면 해당 화면과 행으로 이동합니다.</div>
|
||||
`;
|
||||
}
|
||||
|
||||
showDialog(title, bodyHtml);
|
||||
|
||||
const dialogContent = byId("dialog-content");
|
||||
const clickHandler = (e) => {
|
||||
const btn = e.target.closest('[data-action="goto-use-location"]');
|
||||
if (!btn) return;
|
||||
const targetTable = btn.dataset.table;
|
||||
const targetIndex = Number(btn.dataset.index);
|
||||
const targetGroup = btn.dataset.group || "";
|
||||
|
||||
byId("dialog").close();
|
||||
dialogContent.removeEventListener("click", clickHandler);
|
||||
|
||||
EstApp.navigate(targetTable, targetGroup);
|
||||
state.page = Math.floor(targetIndex / 100);
|
||||
state.selection = new Set([targetIndex]);
|
||||
EstApp.render();
|
||||
};
|
||||
dialogContent.addEventListener("click", clickHandler);
|
||||
}
|
||||
|
||||
function install() {
|
||||
// 필요한 경우 이벤트 리스너 등록
|
||||
}
|
||||
|
||||
function toggleJcBimog(state) {
|
||||
// REA ActionSetJCBimog (0xf17c10 / 0x10dba14): 경비성단가 지정/해제 -> SCheck 3 토글
|
||||
const rows = state.project.tables[state.view],
|
||||
indices = [...state.selection].sort((a, b) => a - b);
|
||||
if (!rows || !["EstMainDat", "HeadMainDat"].includes(state.view))
|
||||
throw Error("내역서 또는 일위대가 화면에서만 경비성단가를 지정/해제할 수 있습니다.");
|
||||
if (!indices.length || indices.some((index) => !rows[index]))
|
||||
throw Error("경비성단가를 지정/해제할 행을 선택하세요.");
|
||||
|
||||
// 원자적 검증: 한 행이라도 복원 정보가 없거나 거부되면 앞선 행도 변경되지 않도록 사전 검사
|
||||
const plans = [];
|
||||
for (const index of indices) {
|
||||
const row = rows[index];
|
||||
if (Number(row.SCheck) === 3) {
|
||||
// 경비성 해제 -> 명시적 0/1/2 이력(_prevSCheck) 확인
|
||||
let restoreVal =
|
||||
row._prevSCheck !== undefined && [0, 1, 2].includes(Number(row._prevSCheck))
|
||||
? Number(row._prevSCheck)
|
||||
: null;
|
||||
if (
|
||||
restoreVal == null &&
|
||||
row.SCheck_ !== undefined &&
|
||||
[0, 1, 2].includes(Number(row.SCheck_))
|
||||
) {
|
||||
restoreVal = Number(row.SCheck_);
|
||||
}
|
||||
if (restoreVal == null) {
|
||||
throw Error(
|
||||
`행 ${index + 1}: 이전 비목 이력(0:비목별, 1:재료, 2:노무)이 없는 기존 경비성(3) 행은 임의 변환할 수 없습니다.`,
|
||||
);
|
||||
}
|
||||
plans.push({ index, row, isToggleOn: false, restoreVal });
|
||||
} else {
|
||||
// 경비성 지정 -> 현재 SCheck(0, 1, 2)를 이력으로 보존
|
||||
const cur = Number(row.SCheck) || 0;
|
||||
plans.push({ index, row, isToggleOn: true, cur });
|
||||
}
|
||||
}
|
||||
|
||||
// 전수 검증 통과 후 일괄 적용
|
||||
EstApp.change(() => {
|
||||
for (const p of plans) {
|
||||
if (p.isToggleOn) {
|
||||
p.row._prevSCheck = p.cur;
|
||||
EstBusinessActions.setEstimateClassification(
|
||||
state.project,
|
||||
p.row,
|
||||
p.index,
|
||||
3,
|
||||
state.view,
|
||||
);
|
||||
} else {
|
||||
delete p.row._prevSCheck;
|
||||
EstBusinessActions.setEstimateClassification(
|
||||
state.project,
|
||||
p.row,
|
||||
p.index,
|
||||
p.restoreVal,
|
||||
state.view,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSunGongsa(state) {
|
||||
// REA popMainSetTotalSumLine: 순직공비 계산끝(순공사비) 지정/해제 -> LinkIndex 54 토글
|
||||
const rows = state.project.tables.EstMainDat,
|
||||
indices = [...state.selection].sort((a, b) => a - b);
|
||||
if (state.view !== "EstMainDat" || !rows)
|
||||
throw Error("내역서 화면에서만 순공사비 경계 행을 지정/해제할 수 있습니다.");
|
||||
if (indices.length !== 1 || !rows[indices[0]])
|
||||
throw Error("순공사비 경계 행으로 지정하거나 해제할 행 1개를 선택하세요.");
|
||||
const index = indices[0],
|
||||
row = rows[index];
|
||||
|
||||
// 연결 행 보호: 원천 연결(자재/노임/일위/단가/기계 등 LinkIndex 5,6,21,101~105) 또는
|
||||
// 수량이 있는 행, 코드가 있는 상세 행은 순공사비 경계선으로 지정 불가 (원천/연결 파괴 차단)
|
||||
const curLink = Number(row.LinkIndex) || 0;
|
||||
if (curLink !== 54) {
|
||||
if (
|
||||
row.Code ||
|
||||
Number(row.GN) > 0 ||
|
||||
[5, 6, 21, 51, 52, 55, 57, 58, 61, 62, 67, 68, 101, 103, 104, 105].includes(curLink)
|
||||
) {
|
||||
throw Error(
|
||||
"단가·수량 연결 행이나 기존 특수 공종 행은 순공사비 경계선으로 지정할 수 없습니다. 빈 제목/구분 행을 선택하세요.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EstApp.change(() => {
|
||||
if (curLink === 54) {
|
||||
// 기존 54 해제 -> 보존된 원래 LinkIndex 또는 원래 유형이 확인되는 경우에만 복원
|
||||
let origLink = row._origLinkIndex !== undefined ? Number(row._origLinkIndex) : null;
|
||||
if (origLink == null && row.LinkIndex_ !== undefined && Number(row.LinkIndex_) !== 54) {
|
||||
origLink = Number(row.LinkIndex_);
|
||||
}
|
||||
if (origLink == null) {
|
||||
// 복원 정보가 없는 기존 원본 54 행: 원래 유형을 알 수 없으므로 임의 0 변환 차단
|
||||
throw Error(
|
||||
"기존 파일에 저장된 원본 순공사비(54) 행이거나 원래 유형을 확인할 수 없어 임의 0으로 변환할 수 없습니다.",
|
||||
);
|
||||
}
|
||||
row.LinkIndex = origLink;
|
||||
delete row._origLinkIndex;
|
||||
if (row._origName !== undefined) {
|
||||
row.Name = row._origName;
|
||||
delete row._origName;
|
||||
} else if (row.Name === "순공사비") {
|
||||
row.Name = "공종";
|
||||
}
|
||||
} else {
|
||||
// 기존 다른 54 행이 있으면 원래 상태로 안전 복원
|
||||
for (const r of rows) {
|
||||
if (Number(r.LinkIndex) === 54) {
|
||||
let origL = r._origLinkIndex !== undefined ? Number(r._origLinkIndex) : null;
|
||||
if (origL == null && r.LinkIndex_ !== undefined && Number(r.LinkIndex_) !== 54) {
|
||||
origL = Number(r.LinkIndex_);
|
||||
}
|
||||
if (origL == null) {
|
||||
throw Error(
|
||||
"기존 원본 순공사비(54) 행의 원래 유형을 알 수 없어 다른 행으로 대체할 수 없습니다.",
|
||||
);
|
||||
}
|
||||
r.LinkIndex = origL;
|
||||
delete r._origLinkIndex;
|
||||
if (r._origName !== undefined) {
|
||||
r.Name = r._origName;
|
||||
delete r._origName;
|
||||
} else if (r.Name === "순공사비") {
|
||||
r.Name = "공종";
|
||||
}
|
||||
}
|
||||
}
|
||||
row._origLinkIndex = curLink;
|
||||
row._origName = row.Name ?? "";
|
||||
row.LinkIndex = 54;
|
||||
if (!row.Name || row.Name === "공종") row.Name = "순공사비";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { render, findDataUsages, showUsesSearchDialog, install, toggleJcBimog, toggleSunGongsa };
|
||||
})();
|
||||
|
||||
+328
-22
@@ -1,6 +1,220 @@
|
||||
const EstForms = (() => {
|
||||
const common = ["Code", "Name", "Size", "Unite"];
|
||||
function data(id, project) {
|
||||
function data(id, project, state = {}) {
|
||||
if (id === "standard-change")
|
||||
return {
|
||||
columns: ["status", "Code", "Name", "originalGN", "GN", "originalTC", "TC", "difference"],
|
||||
rows: EstStandard.changes(project),
|
||||
};
|
||||
if (id === "estimate-change")
|
||||
return {
|
||||
columns: ["status", "Code", "Name", "originalGN", "GN", "originalTC", "TC", "difference"],
|
||||
rows: EstEstimate.changes(project),
|
||||
};
|
||||
if (id === "material-change" || id === "labor-change") {
|
||||
const kindKey = id.startsWith("material") ? "material" : "labor";
|
||||
return {
|
||||
columns: ["status", "Code", "Name", "originalGN", "GN", "originalTC", "TC", "difference"],
|
||||
rows: EstBasicPrices.changes(project, kindKey),
|
||||
};
|
||||
}
|
||||
if (id === "standard-usage")
|
||||
return {
|
||||
columns: [
|
||||
"Code",
|
||||
"Name",
|
||||
"Size",
|
||||
"Unite",
|
||||
"GN",
|
||||
"appliedPrice",
|
||||
"sourcePrice",
|
||||
"difference",
|
||||
"TC",
|
||||
"status",
|
||||
],
|
||||
rows: EstStandard.usage(project),
|
||||
};
|
||||
if (id === "estimate-usage")
|
||||
return {
|
||||
columns: [
|
||||
"Code",
|
||||
"Name",
|
||||
"Size",
|
||||
"Unite",
|
||||
"GN",
|
||||
"appliedPrice",
|
||||
"sourcePrice",
|
||||
"difference",
|
||||
"TC",
|
||||
"status",
|
||||
],
|
||||
rows: EstEstimate.usage(project),
|
||||
};
|
||||
if (id === "material-usage" || id === "labor-usage") {
|
||||
const kindKey = id.startsWith("material") ? "material" : "labor";
|
||||
return {
|
||||
columns: [
|
||||
"Code",
|
||||
"Name",
|
||||
"Size",
|
||||
"Unite",
|
||||
"GN",
|
||||
"appliedPrice",
|
||||
"sourcePrice",
|
||||
"difference",
|
||||
"TC",
|
||||
"status",
|
||||
],
|
||||
rows: EstBasicPrices.usage(project, kindKey),
|
||||
};
|
||||
}
|
||||
if (id === "machine-usage")
|
||||
return {
|
||||
columns: [
|
||||
"Code",
|
||||
"Name",
|
||||
"Size",
|
||||
"Unite",
|
||||
"appliedPrice",
|
||||
"originalPrice",
|
||||
"difference",
|
||||
"NC",
|
||||
"GC",
|
||||
"JC",
|
||||
],
|
||||
rows: EstMachine.usage(project),
|
||||
};
|
||||
if (id === "machine-change")
|
||||
return {
|
||||
columns: ["status", "Code", "Name", "Size", "Unite", "originalTC", "TC", "difference"],
|
||||
rows: EstMachine.changes(project),
|
||||
};
|
||||
if (id === "change-costs")
|
||||
return {
|
||||
columns: ["Gubun", "Name", "originalCost", "changedCost", "difference"],
|
||||
rows: EstBusiness.costChanges(project),
|
||||
};
|
||||
if (["annual-summary", "annual-items"].includes(id)) {
|
||||
const output = EstBusiness.annual(project, false);
|
||||
if (id === "annual-items")
|
||||
return {
|
||||
columns: ["stage", "Code", "Name", "Size", "Unite", "GN", "NC", "GC", "JC", "SC", "TC"],
|
||||
rows: output.results.flatMap((item) =>
|
||||
item.project.tables.EstMainDat.filter((row) => row.Code || row.Name).map((row) => ({
|
||||
...row,
|
||||
stage: item.name,
|
||||
})),
|
||||
),
|
||||
errors: output.errors,
|
||||
};
|
||||
return {
|
||||
columns: ["Name", "formula", "directCost", "contractCost"],
|
||||
rows: project.annual.map((year, index) => ({
|
||||
Name: year.name,
|
||||
formula: year.formula,
|
||||
directCost: output.results[index]?.project.calculation.direct.TC,
|
||||
contractCost: output.results[index]?.project.calculation.total,
|
||||
})),
|
||||
errors: output.errors,
|
||||
};
|
||||
}
|
||||
if (["progress-summary", "progress-costs"].includes(id)) {
|
||||
const round =
|
||||
project.progress.find((item) => item.id === state.roundId) || project.progress.at(-1);
|
||||
if (!round) return { columns: ["Name"], rows: [], errors: ["기성 회차가 없습니다."] };
|
||||
const result =
|
||||
round.locked && round.result ? round.result : EstProgress.calculate(project, round.id);
|
||||
if (id === "progress-costs")
|
||||
return {
|
||||
columns: ["Gubun", "Name", "ContractCost", "JunCost", "GumCost", "SaCost", "TotalCost"],
|
||||
rows: result.costs || [],
|
||||
errors: result.errors,
|
||||
detail: `${round.name || "기성"} (${round.locked ? "확정" : "작성 중"})`,
|
||||
};
|
||||
return {
|
||||
columns: [
|
||||
"Code",
|
||||
"Name",
|
||||
"Unite",
|
||||
"GN",
|
||||
"TC",
|
||||
"JunGN",
|
||||
"JunTC",
|
||||
"GumGN",
|
||||
"GumTC",
|
||||
"SaGN",
|
||||
"SaTC",
|
||||
"TotalGN",
|
||||
"TotalTC",
|
||||
],
|
||||
rows: result.rows,
|
||||
errors: result.errors,
|
||||
detail: `${round.name || "기성"} (${round.locked ? "확정" : "작성 중"})`,
|
||||
};
|
||||
}
|
||||
if (["escalation-summary", "escalation-costs"].includes(id)) {
|
||||
const result = EstEscalation.calculate(project);
|
||||
if (id === "escalation-costs")
|
||||
return {
|
||||
columns: ["Gubun", "Name", "ContractCost", "adjustedCost", "excludedCost"],
|
||||
rows: result.wonga.map((row, index) => ({
|
||||
...row,
|
||||
ContractCost: result.contractWonga[index]?.Cost,
|
||||
adjustedCost: row.Cost,
|
||||
excludedCost: result.excludedWonga[index]?.Cost,
|
||||
})),
|
||||
errors: result.errors,
|
||||
detail: `도급액 ${result.contractTotal} · 조정액 ${result.adjustment} · 조정률 ${result.rate}%`,
|
||||
};
|
||||
return {
|
||||
columns: [
|
||||
"Code",
|
||||
"Name",
|
||||
"Unite",
|
||||
"GN",
|
||||
"EscExcluded",
|
||||
"EscApplied",
|
||||
"TC",
|
||||
"ENC",
|
||||
"EGC",
|
||||
"EJC",
|
||||
"ESC",
|
||||
],
|
||||
rows: result.rows,
|
||||
errors: result.errors,
|
||||
detail: `도급액 ${result.contractTotal} · 조정액 ${result.adjustment} · 조정률 ${result.rate}%`,
|
||||
};
|
||||
}
|
||||
if (id === 171) {
|
||||
const output = EstReview.calculate(project);
|
||||
const rows = output.baseline.tables.EstMainDat.map((row, index) => ({
|
||||
row,
|
||||
index,
|
||||
result: output.reviewed.tables.EstMainDat[index],
|
||||
}))
|
||||
.filter(({ row }) => row.Code || row.Name)
|
||||
.map(({ row, index, result }) => ({
|
||||
Code: row.Code,
|
||||
Name: row.Name,
|
||||
originalGN: row.GN,
|
||||
GN: result.GN,
|
||||
originalTC: row.TC,
|
||||
TC: result.TC,
|
||||
difference: EstMath.num(result.TC) - EstMath.num(row.TC),
|
||||
Reason: project.contractReview?.edits?.[EstReview.rowKey(row, index)]?.Reason || "",
|
||||
}));
|
||||
rows.unshift({
|
||||
Name: "심사 도급액",
|
||||
originalTC: output.before,
|
||||
TC: output.after,
|
||||
difference: output.after - output.before,
|
||||
});
|
||||
return {
|
||||
columns: ["Code", "Name", "originalGN", "GN", "originalTC", "TC", "difference", "Reason"],
|
||||
rows,
|
||||
errors: output.errors,
|
||||
};
|
||||
}
|
||||
if (id === 109)
|
||||
return {
|
||||
columns: ["Code", "Name", "start", "end", "weight", "progress"],
|
||||
@@ -22,14 +236,36 @@ const EstForms = (() => {
|
||||
22: "Wonga",
|
||||
}[id];
|
||||
if (table) {
|
||||
const columns =
|
||||
table === "Wonga"
|
||||
? ["Gubun", "Name", "Youl", "VSusic", "Cost"]
|
||||
: table === "CostMainDat"
|
||||
? ["Code", "LineData"]
|
||||
: table === "HeadMainDat"
|
||||
? [...common, "GN", "NU", "GU", "JU", "SU", "TC"]
|
||||
: [...common, "GN", "TU", "TC", "NC", "GC", "JC", "SC"];
|
||||
let columns;
|
||||
if (table === "Wonga") {
|
||||
columns = ["Gubun", "Name", "Youl", "VSusic", "Cost"];
|
||||
} else if (table === "CostMainDat") {
|
||||
columns = ["Code", "LineData"];
|
||||
} else if (table === "HeadMainDat") {
|
||||
const gongMethod = Number(project.printSettings?.PrintGongMethod ?? 0);
|
||||
if (id === 115 && gongMethod === 0) {
|
||||
// 공람용: 수량만 인쇄
|
||||
columns = [...common, "GN"];
|
||||
} else if (id === 115 && gongMethod === 1) {
|
||||
// 공람용: 단가만 인쇄
|
||||
columns = [...common, "NU", "GU", "JU", "SU", "TC"];
|
||||
} else {
|
||||
// 기본(수량, 단가 인쇄)
|
||||
columns = [...common, "GN", "NU", "GU", "JU", "SU", "TC"];
|
||||
}
|
||||
} else {
|
||||
const gongMethod = Number(project.printSettings?.PrintGongMethod ?? 0);
|
||||
if (id === 117 && gongMethod === 0) {
|
||||
// 공람용: 수량만 인쇄
|
||||
columns = [...common, "GN"];
|
||||
} else if (id === 117 && gongMethod === 1) {
|
||||
// 공람용: 단가/금액만 인쇄
|
||||
columns = [...common, "TU", "TC", "NC", "GC", "JC", "SC"];
|
||||
} else {
|
||||
// 기본(수량, 단가/금액 인쇄)
|
||||
columns = [...common, "GN", "TU", "TC", "NC", "GC", "JC", "SC"];
|
||||
}
|
||||
}
|
||||
return { table, columns, rows: project.tables[table] || [] };
|
||||
}
|
||||
const totals = {
|
||||
@@ -60,7 +296,15 @@ const EstForms = (() => {
|
||||
.reduce((n, r) => n + EstMath.num(r[key]), 0);
|
||||
return row;
|
||||
});
|
||||
return { table: totals, columns: [...common, "NC", "GC", "JC", "SC", "TC"], rows };
|
||||
const gongMethod = Number(project.printSettings?.PrintGongMethod ?? 0);
|
||||
let columns;
|
||||
if ([116, 118].includes(id) && gongMethod === 0) {
|
||||
// 공람용 총괄표: 금액 숨김
|
||||
columns = [...common];
|
||||
} else {
|
||||
columns = [...common, "NC", "GC", "JC", "SC", "TC"];
|
||||
}
|
||||
return { table: totals, columns, rows };
|
||||
}
|
||||
if ([107, 108].includes(id))
|
||||
return {
|
||||
@@ -95,17 +339,41 @@ const EstForms = (() => {
|
||||
rows: output.rows.filter((r) => r.kind === kinds[id]),
|
||||
};
|
||||
}
|
||||
if (id === 14)
|
||||
return {
|
||||
columns: ["Code", "Name", "formula", "GN", "TargetCode"],
|
||||
rows: (project.quantitySheets || []).flatMap((sheet) =>
|
||||
EstQuantity.calculate(sheet).items.map((item) => ({
|
||||
...item,
|
||||
Code: sheet.Code,
|
||||
Name: item.Name || sheet.Name,
|
||||
})),
|
||||
),
|
||||
if (id === 14) {
|
||||
const target = (item) => {
|
||||
if (!item.TargetKey) return item.TargetCode ?? item.LinkCode ?? "";
|
||||
const row = project.tables.EstMainDat.find(
|
||||
(entry) => entry.PkID != null && `id:${entry.PkID}` === item.TargetKey,
|
||||
);
|
||||
return row ? `${row.Name || "내역"} (${row.Code || item.TargetKey})` : item.TargetKey;
|
||||
};
|
||||
const manual = EstQuantity.manual(project.quantities || []),
|
||||
rows = manual.rows.map((row) => ({
|
||||
...row,
|
||||
SheetName: "수식표",
|
||||
formula: row.formula || "",
|
||||
TargetCode: target(row),
|
||||
})),
|
||||
errors = [...manual.errors];
|
||||
for (const sheet of project.quantitySheets || []) {
|
||||
const calculated = EstQuantity.calculate(sheet);
|
||||
errors.push(...calculated.errors.map((error) => `${sheet.Name || sheet.Code}: ${error}`));
|
||||
for (const item of calculated.items) {
|
||||
const index = sheet.rows.findIndex((row) => (row.markers || []).includes(item.Code));
|
||||
rows.push({
|
||||
...item,
|
||||
SheetName: sheet.Name || sheet.Code,
|
||||
formula: index < 0 ? "" : EstText.clean(calculated.rows[index].LineData),
|
||||
TargetCode: target(item),
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
columns: ["SheetName", "Code", "Name", "formula", "GN", "TargetCode", "Error"],
|
||||
rows,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const labels = {
|
||||
@@ -122,6 +390,37 @@ const EstForms = (() => {
|
||||
WorkCode: "공종 코드",
|
||||
WorkName: "공종명",
|
||||
formula: "산식",
|
||||
SheetName: "산출서",
|
||||
Error: "계산 오류",
|
||||
Reason: "심사 사유",
|
||||
JunGN: "전회 수량",
|
||||
JunTC: "전회 금액",
|
||||
GumGN: "금회 수량",
|
||||
GumTC: "금회 금액",
|
||||
SaGN: "사정 수량",
|
||||
SaTC: "사정 금액",
|
||||
TotalGN: "누계 수량",
|
||||
TotalTC: "누계 금액",
|
||||
EscExcluded: "제외 수량",
|
||||
EscApplied: "적용 수량",
|
||||
ENC: "노무비 증감",
|
||||
EGC: "재료비 증감",
|
||||
EJC: "경비 증감",
|
||||
ESC: "산출경비 증감",
|
||||
directCost: "순공사비",
|
||||
contractCost: "도급액",
|
||||
ContractCost: "도급금액",
|
||||
JunCost: "전회 금액",
|
||||
GumCost: "금회 금액",
|
||||
SaCost: "사정 금액",
|
||||
TotalCost: "누계 금액",
|
||||
adjustedCost: "조정 금액",
|
||||
excludedCost: "제외 금액",
|
||||
originalCost: "당초 금액",
|
||||
changedCost: "변경 금액",
|
||||
stage: "차수",
|
||||
appliedPrice: "내역 단가",
|
||||
sourcePrice: "기초 단가",
|
||||
TargetCode: "연결 내역",
|
||||
TU: "단가",
|
||||
TC: "금액",
|
||||
@@ -130,10 +429,17 @@ const EstForms = (() => {
|
||||
JC: "경비",
|
||||
SC: "산출경비",
|
||||
};
|
||||
const label = (key) => labels[key] || EstModel.labels[key] || key;
|
||||
function grid(output) {
|
||||
const { esc } = EstApp,
|
||||
const esc = (v) =>
|
||||
typeof EstApp !== "undefined"
|
||||
? EstApp.esc(v)
|
||||
: String(v ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">"),
|
||||
number = (v) => new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 12 }).format(v);
|
||||
return `<table><thead><tr>${output.columns.map((key) => `<th>${esc(labels[key] || EstModel.labels[key] || key)}</th>`).join("")}</tr></thead><tbody>${output.rows.map((row) => `<tr>${output.columns.map((key) => `<td class="${typeof row[key] === "number" ? "numeric" : ""}">${esc(typeof row[key] === "number" ? number(row[key]) : key === "LineData" ? EstText.clean(row[key] || "") : (row[key] ?? ""))}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
|
||||
}
|
||||
return { data, grid };
|
||||
return { data, grid, label };
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// REA TFRMHALJUNGBIUL (0xdb4b14, 0xdb4d74, 0xdb4e70, 0xf5b7fc)
|
||||
// 일위대가 및 단가산출 공종(묶음) 내 할증계산 행 추가/적용 기능
|
||||
const EstHalJung = (() => {
|
||||
const { num, components, units, floating } = EstMath;
|
||||
|
||||
// REA cxRadioSize (불러올비목): 0=비목별, 1=재료비의, 2=노무비의, 3=경비의, 4=합계의
|
||||
const sizeKinds = {
|
||||
0: { text: "비목별", ocheck: 0 },
|
||||
1: { text: "재료비의", ocheck: 1 },
|
||||
2: { text: "노무비의", ocheck: 2 },
|
||||
3: { text: "경비의", ocheck: 3 },
|
||||
4: { text: "합계의", ocheck: 4 },
|
||||
};
|
||||
|
||||
// REA cxRadioBimog (적용비목): 0=비목별, 1=재료비에, 2=노무비에, 3=경비에
|
||||
const bimogKinds = {
|
||||
0: { text: "비목별", scheck: 0 },
|
||||
1: { text: "재료비에", scheck: 1 },
|
||||
2: { text: "노무비에", scheck: 2 },
|
||||
3: { text: "경비에", scheck: 3 },
|
||||
};
|
||||
|
||||
function apply(
|
||||
project,
|
||||
tableKey,
|
||||
groupCode,
|
||||
{ name = "할 증", size = "비목별", rate = 0, sourceKind = 0, targetKind = 0 } = {},
|
||||
) {
|
||||
if (tableKey !== "HeadMainDat") {
|
||||
throw Error("일위대가 화면에서만 할증계산을 적용할 수 있습니다.");
|
||||
}
|
||||
const rateNum = num(rate);
|
||||
if (!Number.isFinite(rateNum) || rateNum === 0) {
|
||||
throw Error("할증 적용율(%)은 0이 아닌 유효한 숫자여야 합니다.");
|
||||
}
|
||||
const next = structuredClone(project);
|
||||
const rows = next.tables[tableKey] || [];
|
||||
const grps = EstNative.groups(rows);
|
||||
const group = grps.get(groupCode);
|
||||
if (!group) throw Error("공종(묶음)을 찾을 수 없습니다.");
|
||||
|
||||
const sInfo = sizeKinds[sourceKind] || sizeKinds[0];
|
||||
const bInfo = bimogKinds[targetKind] || bimogKinds[0];
|
||||
const finalSize = size && size !== "비목별" ? size.trim() : sInfo.text;
|
||||
|
||||
// REA 의도: 불러올비목이 특정 비목(1=재료비, 2=노무비, 3=경비)이고 적용비목이 비목별(targetKind=0)인 경우,
|
||||
// 원천 비목에 그대로 가산되도록 scheck를 원천 비목으로 맞춤.
|
||||
// (targetKind가 0이고 sourceKind가 0이면 비목별 행별로 각각 분배되므로 scheck=0 유지)
|
||||
const effectiveSCheck =
|
||||
targetKind === 0 && sourceKind >= 1 && sourceKind <= 3 ? sInfo.ocheck : bInfo.scheck;
|
||||
|
||||
// REA LinkIndex 58 (비율) 행 생성
|
||||
const haljungRow = {
|
||||
Index: 0,
|
||||
Level: 0,
|
||||
LinkIndex: 58,
|
||||
Name: name.trim() || "할 증",
|
||||
Size: finalSize,
|
||||
Unite: "%",
|
||||
GN: rateNum,
|
||||
SCheck: effectiveSCheck,
|
||||
OCheck: sInfo.ocheck,
|
||||
TU: 0,
|
||||
TC: 0,
|
||||
NC: 0,
|
||||
GC: 0,
|
||||
JC: 0,
|
||||
SC: 0,
|
||||
};
|
||||
|
||||
// 묶음 내 삽입 위치 찾기:
|
||||
// 소계(LinkIndex=51, 68)가 있으면 소계 바로 앞에, 없으면 묶음의 마지막 행 뒤에 삽입
|
||||
const groupEnd = group.start + group.rows.length;
|
||||
let insertIndex = groupEnd;
|
||||
for (let i = group.start + 1; i < groupEnd; i++) {
|
||||
const link = num(rows[i].LinkIndex);
|
||||
if ([51, 68].includes(link)) {
|
||||
insertIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
rows.splice(insertIndex, 0, haljungRow);
|
||||
|
||||
return next.options?.AutoCalc === false ? next : EstNative.calculate(next);
|
||||
}
|
||||
|
||||
return {
|
||||
sizeKinds,
|
||||
bimogKinds,
|
||||
apply,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,374 @@
|
||||
const EstIssues = (() => {
|
||||
let page = 0,
|
||||
tableFilter = "all",
|
||||
typeFilter = "all";
|
||||
const grouped = new Set(["HeadMainDat", "CostMainDat", "MachMainDat"]);
|
||||
const kinds = {
|
||||
5: "StuffcDat",
|
||||
6: "LaborcDat",
|
||||
21: "SilJukDat",
|
||||
101: "EstiMateDat",
|
||||
103: "HeadMainDat",
|
||||
104: "CostMainDat",
|
||||
105: "MachMainDat",
|
||||
};
|
||||
function scan(project) {
|
||||
const issues = [],
|
||||
sources = new Map();
|
||||
for (const [kind, table] of Object.entries(kinds)) {
|
||||
const codes = new Map();
|
||||
(project.tables[table] || []).forEach((row, index) => {
|
||||
if (!row.Code || (grouped.has(table) && !(Number(row.Index) > 0))) return;
|
||||
const list = codes.get(String(row.Code)) || [];
|
||||
list.push({ row, index });
|
||||
codes.set(String(row.Code), list);
|
||||
});
|
||||
sources.set(Number(kind), codes);
|
||||
for (const [code, list] of codes)
|
||||
if (list.length > 1)
|
||||
for (const { index } of list) issues.push({ table, index, code, message: "코드 중복" });
|
||||
}
|
||||
for (const table of ["EstMainDat", "HeadMainDat", "MachMainDat", "CostMainDat"]) {
|
||||
(project.tables[table] || []).forEach((row, index) => {
|
||||
const link = Number(row.LinkIndex);
|
||||
if (
|
||||
Number(row.Level || 0) === 0 &&
|
||||
Number(row.Index || 0) === 0 &&
|
||||
[52, 57, 58, 62, 67].includes(link) &&
|
||||
row.Unite &&
|
||||
!["%", "%"].includes(String(row.Unite).trim())
|
||||
) {
|
||||
issues.push({
|
||||
table,
|
||||
index,
|
||||
code: row.Code,
|
||||
message: "단위 백분율(%) 불일치",
|
||||
rowUnite: row.Unite,
|
||||
});
|
||||
}
|
||||
if (Number(row.Level) > 0 || Number(row.Index) > 0 || !row.Code) return;
|
||||
const codes = sources.get(link);
|
||||
if (!codes) return;
|
||||
const matches = codes.get(String(row.Code)) || [];
|
||||
if (!matches.length)
|
||||
issues.push({ table, index, code: row.Code, message: "연결 자료 없음" });
|
||||
else if (matches.length > 1)
|
||||
issues.push({ table, index, code: row.Code, message: "연결 코드 중복" });
|
||||
else
|
||||
for (const field of ["Name", "Size", "Unite"]) {
|
||||
const a = row[field],
|
||||
b = matches[0].row[field];
|
||||
if (a && b && EstNative.normalize(a) !== EstNative.normalize(b))
|
||||
issues.push({
|
||||
table,
|
||||
index,
|
||||
code: row.Code,
|
||||
message:
|
||||
field === "Name"
|
||||
? "연결 품명 차이"
|
||||
: field === "Size"
|
||||
? "연결 규격 차이"
|
||||
: "연결 단위 차이",
|
||||
sourceName: matches[0].row.Name,
|
||||
sourceSize: matches[0].row.Size,
|
||||
sourceUnite: matches[0].row.Unite,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
// 수량산출서 및 직접 수식표 항목의 내역 연결(TargetKey 또는 TargetCode/LinkCode) 검사:
|
||||
// 실제 totals() 연결 규칙과 일치하도록:
|
||||
// 1) item.TargetKey 가 있으면 row.PkID != null && `id:${row.PkID}` === item.TargetKey 우선 매칭
|
||||
// 2) 없으면 code = item.TargetCode ?? item.LinkCode 로 row.Code === code 매칭
|
||||
// 3) level > 0 (공종/비고행) 제외
|
||||
const sourcesToCheck = [];
|
||||
(project.quantitySheets || []).forEach((sheet) => {
|
||||
(sheet.items || []).forEach((item, itemIndex) => {
|
||||
sourcesToCheck.push({ item, sheetCode: sheet.Code, itemIndex });
|
||||
});
|
||||
});
|
||||
(project.quantities || []).forEach((item, itemIndex) => {
|
||||
sourcesToCheck.push({ item, sheetCode: "manual", itemIndex });
|
||||
});
|
||||
|
||||
const estRows = (project.tables.EstMainDat || []).map((row, index) => ({ row, index }));
|
||||
sourcesToCheck.forEach(({ item, sheetCode, itemIndex }) => {
|
||||
const code = item.TargetCode ?? item.LinkCode;
|
||||
if (!code && !item.TargetKey) {
|
||||
issues.push({
|
||||
table: "quantity",
|
||||
sheetCode,
|
||||
index: itemIndex,
|
||||
code: item.Code,
|
||||
message: "수량 내역 미연결",
|
||||
unlinked: true,
|
||||
item,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const targets = estRows.filter(
|
||||
({ row }) =>
|
||||
!Number(row.Level) &&
|
||||
(item.TargetKey
|
||||
? row.PkID != null && `id:${row.PkID}` === item.TargetKey
|
||||
: row.Code === code),
|
||||
);
|
||||
if (!targets.length) {
|
||||
issues.push({
|
||||
table: "quantity",
|
||||
sheetCode,
|
||||
index: itemIndex,
|
||||
code: item.Code,
|
||||
message: `수량 대상 내역 없음: ${item.TargetKey || code}`,
|
||||
unlinked: true,
|
||||
item,
|
||||
});
|
||||
} else if (targets.length > 1) {
|
||||
issues.push({
|
||||
table: "quantity",
|
||||
sheetCode,
|
||||
index: itemIndex,
|
||||
code: item.Code,
|
||||
message: `수량 대상 내역 중복: ${item.TargetKey || code}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
const costs = project.tables.CostMainDat || [],
|
||||
costGroups = EstNative.groups(costs);
|
||||
if (typeof EstWonga !== "undefined" && typeof EstWonga.scan === "function") {
|
||||
const direct = project.calculation?.direct || {};
|
||||
const bases = {
|
||||
NC: Number(direct.NC) || 0,
|
||||
GC: Number(direct.GC) || 0,
|
||||
JC: Number(direct.JC) || 0,
|
||||
SC: Number(direct.SC) || 0,
|
||||
TC: Number(direct.TC) || 0,
|
||||
노무비: Number(direct.NC) || 0,
|
||||
재료비: Number(direct.GC) || 0,
|
||||
경비: Number(direct.JC) || 0,
|
||||
실적: Number(direct.SC) || 0,
|
||||
직접공사비: Number(direct.TC) || 0,
|
||||
};
|
||||
const wongaIssues = EstWonga.scan(project.tables?.Wonga, project.options || {}, bases);
|
||||
issues.push(...wongaIssues);
|
||||
}
|
||||
for (const message of project.calculation?.errors || []) {
|
||||
const text = String(message),
|
||||
match = text.match(/^(.+):(\d+) /),
|
||||
code = match?.[1],
|
||||
group = code && costGroups.get(code),
|
||||
offset = match ? Number(match[2]) - 1 : -1,
|
||||
unique = group && sources.get(104)?.get(code)?.length === 1,
|
||||
index = unique && offset >= 0 && offset < group.rows.length ? group.start + offset : -1;
|
||||
issues.push({
|
||||
table: index >= 0 ? "CostMainDat" : "",
|
||||
index,
|
||||
code: index >= 0 ? code : "",
|
||||
message: text,
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
function targetSelect(project, item, sheetCode, itemIndex) {
|
||||
const { esc } = EstApp;
|
||||
const rows = (project.tables?.EstMainDat || []).filter(
|
||||
(row) => row.PkID != null && !Number(row.Level),
|
||||
);
|
||||
return `<select data-issue-target-select="${esc(sheetCode)}" data-issue-item-index="${itemIndex}" aria-label="연결 내역 선택"><option value="">내역 행 선택</option>${rows
|
||||
.map((row) => {
|
||||
const key = `id:${row.PkID}`;
|
||||
return `<option value="${esc(key)}" ${item.TargetKey === key ? "selected" : ""}>${esc(row.Name || "내역")} · ${esc(row.Code || "코드 없음")} · ${esc(row.PkID)}</option>`;
|
||||
})
|
||||
.join("")}</select>`;
|
||||
}
|
||||
function render() {
|
||||
const { state, esc, byId, titles } = EstApp,
|
||||
allIssues = scan(state.project);
|
||||
const tables = [...new Set(allIssues.map((i) => i.table || ""))];
|
||||
const messages = [...new Set(allIssues.map((i) => i.message))];
|
||||
const filtered = allIssues.filter((i) => {
|
||||
if (tableFilter !== "all" && (i.table || "") !== tableFilter) return false;
|
||||
if (typeFilter !== "all" && i.message !== typeFilter) return false;
|
||||
return true;
|
||||
});
|
||||
page = Math.max(0, Math.min(page, Math.ceil(filtered.length / 100) - 1));
|
||||
const rows = filtered.slice(page * 100, page * 100 + 100);
|
||||
const tableSelect = `<label>자료 <select id="issue-table-filter"><option value="all">전체 자료</option>${tables
|
||||
.map(
|
||||
(t) =>
|
||||
`<option value="${esc(t)}" ${tableFilter === t ? "selected" : ""}>${esc(titles[t] || t || "전체 계산")}</option>`,
|
||||
)
|
||||
.join("")}</select></label>`;
|
||||
const typeSelect = `<label>내용 <select id="issue-type-filter"><option value="all">전체 유형</option>${messages
|
||||
.map(
|
||||
(m) => `<option value="${esc(m)}" ${typeFilter === m ? "selected" : ""}>${esc(m)}</option>`,
|
||||
)
|
||||
.join("")}</select></label>`;
|
||||
byId("work-content").innerHTML =
|
||||
`<div class="business-tools"><b>자료 오류 검색</b>${tableSelect} ${typeSelect}<span>검색 ${filtered.length}건 (전체 ${allIssues.length}건) · ${page + 1}/${Math.max(1, Math.ceil(filtered.length / 100))}쪽</span><button data-issue-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-issue-page="1" ${(page + 1) * 100 >= filtered.length ? "disabled" : ""}>다음</button>${
|
||||
filtered.some(
|
||||
(i) => i.sourceName && ["연결 품명 차이", "연결 규격 차이"].includes(i.message),
|
||||
)
|
||||
? ' <button data-issue-sync-all="1">불일치 원천 명칭/규격 일괄 반영</button>'
|
||||
: ""
|
||||
}</div><p>연결 품명·규격 차이는 확인 후 개별 또는 일괄로 반영할 수 있으며, 단위 차이는 수량·단가 환산 보호를 위해 개별 확인 시에만 반영됩니다.</p><table class="sheet"><thead><tr><th>자료</th><th>행</th><th>코드</th><th>내용</th><th>원천 실제 명칭</th><th>이동 및 조작</th></tr></thead><tbody>${rows
|
||||
.map(
|
||||
(item) =>
|
||||
`<tr><td>${esc(titles[item.table] || item.table || "전체 계산")}</td><td>${item.index >= 0 ? item.index + 1 : ""}</td><td>${esc(item.code)}</td><td>${esc(item.message)}</td><td>${
|
||||
item.sourceName
|
||||
? `${esc(item.sourceName)}${item.sourceSize ? " · " + esc(item.sourceSize) : ""}${item.sourceUnite ? " · " + esc(item.sourceUnite) : ""}`
|
||||
: ""
|
||||
}</td><td>${
|
||||
item.table === "quantity"
|
||||
? `<button data-issue-quantity="${esc(item.sheetCode || "")}" data-issue-row="${item.index}">산출서 열기</button>${
|
||||
item.unlinked
|
||||
? " " + targetSelect(state.project, item.item, item.sheetCode, item.index)
|
||||
: ""
|
||||
}`
|
||||
: item.table
|
||||
? `<button data-issue-table="${esc(item.table)}" data-issue-row="${item.index}">행 열기</button>${
|
||||
item.sourceName &&
|
||||
["연결 품명 차이", "연결 규격 차이", "연결 단위 차이"].includes(item.message)
|
||||
? ` <button data-issue-sync-row="${esc(item.table)}" data-issue-sync-index="${item.index}" data-issue-sync-msg="${esc(item.message)}">${
|
||||
item.message === "연결 단위 차이" ? "원천단위 반영" : "원천명칭 반영"
|
||||
}</button>`
|
||||
: ""
|
||||
}`
|
||||
: ""
|
||||
}</td></tr>`,
|
||||
)
|
||||
.join("")}</tbody></table>`;
|
||||
}
|
||||
function install() {
|
||||
document.addEventListener("change", (event) => {
|
||||
if (event.target.id === "issue-table-filter") {
|
||||
tableFilter = event.target.value;
|
||||
page = 0;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (event.target.id === "issue-type-filter") {
|
||||
typeFilter = event.target.value;
|
||||
page = 0;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const select = event.target.closest("[data-issue-target-select]");
|
||||
if (!select) return;
|
||||
const { state } = EstApp,
|
||||
sheetCode = select.dataset.issueTargetSelect,
|
||||
itemIndex = Number(select.dataset.issueItemIndex);
|
||||
const item =
|
||||
sheetCode === "manual"
|
||||
? state.project.quantities?.[itemIndex]
|
||||
: state.project.quantitySheets?.find((s) => s.Code === sheetCode)?.items?.[itemIndex];
|
||||
if (!item) return;
|
||||
const row = (state.project.tables.EstMainDat || []).find(
|
||||
(r) => r.PkID != null && `id:${r.PkID}` === select.value && !Number(r.Level),
|
||||
);
|
||||
EstApp.change(() => {
|
||||
item.TargetKey = row ? select.value : "";
|
||||
item.TargetCode = row?.Code || "";
|
||||
}, false);
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
const step = event.target.closest("[data-issue-page]");
|
||||
if (step) {
|
||||
page += Number(step.dataset.issuePage);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const qNode = event.target.closest("[data-issue-quantity]");
|
||||
if (qNode) {
|
||||
const sheetCode = qNode.dataset.issueQuantity;
|
||||
EstApp.state.view = "quantity";
|
||||
EstApp.state.quantitySheet = sheetCode || "manual";
|
||||
EstApp.navigate("quantity");
|
||||
return;
|
||||
}
|
||||
const syncAllBtn = event.target.closest("[data-issue-sync-all]");
|
||||
if (syncAllBtn) {
|
||||
const { state } = EstApp;
|
||||
// 현재 필터링 조건에 일치하는 품명·규격 불일치 항목만 일괄 반영 대상
|
||||
const currentFiltered = scan(state.project).filter((i) => {
|
||||
if (tableFilter !== "all" && (i.table || "") !== tableFilter) return false;
|
||||
if (typeFilter !== "all" && i.message !== typeFilter) return false;
|
||||
return i.sourceName && (i.message === "연결 품명 차이" || i.message === "연결 규격 차이");
|
||||
});
|
||||
if (!currentFiltered.length) return;
|
||||
EstApp.change(() => {
|
||||
for (const issue of currentFiltered) {
|
||||
const row = state.project.tables[issue.table]?.[issue.index];
|
||||
if (!row) continue;
|
||||
if (issue.message === "연결 품명 차이" && issue.sourceName) {
|
||||
row.Name = issue.sourceName;
|
||||
} else if (issue.message === "연결 규격 차이") {
|
||||
if (issue.sourceSize && issue.sourceSize.trim() !== "") {
|
||||
row.Size = issue.sourceSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const syncRowBtn = event.target.closest("[data-issue-sync-row]");
|
||||
if (syncRowBtn) {
|
||||
const { state } = EstApp;
|
||||
const targetTable = syncRowBtn.dataset.issueSyncRow,
|
||||
targetIndex = Number(syncRowBtn.dataset.issueSyncIndex),
|
||||
targetMsg = syncRowBtn.dataset.issueSyncMsg;
|
||||
const issue = scan(state.project).find(
|
||||
(i) =>
|
||||
i.table === targetTable &&
|
||||
i.index === targetIndex &&
|
||||
(!targetMsg || i.message === targetMsg) &&
|
||||
i.sourceName,
|
||||
);
|
||||
if (!issue) return;
|
||||
EstApp.change(() => {
|
||||
const row = state.project.tables[targetTable]?.[targetIndex];
|
||||
if (!row) return;
|
||||
if (issue.message === "연결 품명 차이" && issue.sourceName) {
|
||||
row.Name = issue.sourceName;
|
||||
} else if (issue.message === "연결 규격 차이") {
|
||||
if (issue.sourceSize && issue.sourceSize.trim() !== "") {
|
||||
row.Size = issue.sourceSize;
|
||||
}
|
||||
} else if (issue.message === "연결 단위 차이") {
|
||||
if (issue.sourceUnite !== undefined) {
|
||||
row.Unite = issue.sourceUnite;
|
||||
}
|
||||
}
|
||||
});
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const node = event.target.closest("[data-issue-table]");
|
||||
if (!node) return;
|
||||
const table = node.dataset.issueTable,
|
||||
index = Number(node.dataset.issueRow),
|
||||
rows = EstApp.state.project.tables[table] || [];
|
||||
if (!rows[index]) return;
|
||||
let group = "";
|
||||
if (grouped.has(table))
|
||||
for (const [code, item] of EstNative.groups(rows))
|
||||
if (index >= item.start && index < item.start + item.rows.length) {
|
||||
group = code;
|
||||
break;
|
||||
}
|
||||
if (grouped.has(table) && !group) {
|
||||
EstApp.state.notice = "묶음 코드가 중복되거나 해당 행의 묶음이 없어 이동할 수 없습니다.";
|
||||
EstApp.render();
|
||||
return;
|
||||
}
|
||||
EstApp.navigate(table, group);
|
||||
const visible = EstApp.visibleRows(),
|
||||
position = visible.findIndex((item) => item.index === index);
|
||||
if (position >= 0) EstApp.state.page = Math.floor(position / 100);
|
||||
EstApp.state.selection = new Set([index]);
|
||||
EstApp.render();
|
||||
});
|
||||
}
|
||||
return { scan, render, install };
|
||||
})();
|
||||
@@ -2,9 +2,14 @@
|
||||
function existingTargets(tables, sheets, warnings) {
|
||||
const candidates = new Map();
|
||||
const targetCounts = new Map();
|
||||
const pkidCounts = new Map();
|
||||
for (const row of tables.EstMainDat || [])
|
||||
if (row.Code && !Number(row.Level))
|
||||
if (row.Code && !Number(row.Level)) {
|
||||
targetCounts.set(row.Code, (targetCounts.get(row.Code) || 0) + 1);
|
||||
if (row.PkID != null) {
|
||||
pkidCounts.set(row.PkID, (pkidCounts.get(row.PkID) || 0) + 1);
|
||||
}
|
||||
}
|
||||
for (const row of tables.EstMainDat || []) {
|
||||
if (Number(row.Level) || !row.MulCode?.binary || !row.MullangIndex || row.ExcelSheetIndex)
|
||||
continue;
|
||||
@@ -29,7 +34,8 @@ function existingTargets(tables, sheets, warnings) {
|
||||
warnings.add("수량 연결: 문서·항목 코드 또는 내역 코드가 없습니다.");
|
||||
continue;
|
||||
}
|
||||
if (targetCounts.get(row.Code) !== 1) {
|
||||
const hasUniquePkid = row.PkID != null && pkidCounts.get(row.PkID) === 1;
|
||||
if (targetCounts.get(row.Code) !== 1 && !hasUniquePkid) {
|
||||
warnings.add(`수량 연결: 내역 코드 ${row.Code}가 중복되어 자동 선택하지 않았습니다.`);
|
||||
continue;
|
||||
}
|
||||
@@ -39,12 +45,20 @@ function existingTargets(tables, sheets, warnings) {
|
||||
warnings.add(`수량 연결: ${parts[0]}/${parts[1]} 항목을 찾을 수 없습니다.`);
|
||||
continue;
|
||||
}
|
||||
if (!candidates.has(item)) candidates.set(item, new Set());
|
||||
candidates.get(item).add(row.Code);
|
||||
if (!candidates.has(item)) candidates.set(item, new Map());
|
||||
const key = hasUniquePkid ? `id:${row.PkID}` : `code:${row.Code}`;
|
||||
candidates.get(item).set(key, { code: row.Code, pkId: hasUniquePkid ? row.PkID : null });
|
||||
}
|
||||
for (const [item, codes] of candidates)
|
||||
if (codes.size === 1) item.TargetCode = [...codes][0];
|
||||
else
|
||||
for (const [item, entries] of candidates) {
|
||||
if (entries.size === 1) {
|
||||
const entry = [...entries.values()][0];
|
||||
item.TargetCode = entry.code;
|
||||
if (entry.pkId != null) {
|
||||
item.TargetKey = `id:${entry.pkId}`;
|
||||
}
|
||||
} else {
|
||||
warnings.add(`수량 연결: ${item.Code}에 여러 내역 코드가 연결되어 자동 선택하지 않았습니다.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = { existingTargets };
|
||||
|
||||
@@ -129,5 +129,108 @@ const EstMachine = (() => {
|
||||
finish();
|
||||
return { rows: output, titles, errors };
|
||||
}
|
||||
return { calculate };
|
||||
|
||||
let page = 0;
|
||||
|
||||
function usage(project) {
|
||||
const titles = project.tables?.MachTitleDat || [];
|
||||
const grps = EstNative.groups(project.tables?.MachMainDat || []);
|
||||
return titles.map((title, index) => {
|
||||
const code = title.Code || "";
|
||||
const group = grps.get(code);
|
||||
const appliedPrice = EstMath.num(title.TC);
|
||||
const hasOriginal = title.TC_ != null;
|
||||
const originalPrice = hasOriginal ? EstMath.num(title.TC_) : null;
|
||||
const difference = hasOriginal ? appliedPrice - originalPrice : null;
|
||||
return {
|
||||
index,
|
||||
Code: code,
|
||||
Name: title.Name || "",
|
||||
Size: title.Size || "",
|
||||
Unite: title.Unite || "",
|
||||
appliedPrice,
|
||||
originalPrice,
|
||||
difference,
|
||||
NC: EstMath.num(title.NC),
|
||||
GC: EstMath.num(title.GC),
|
||||
JC: EstMath.num(title.JC),
|
||||
detailCount: group ? group.rows.length : 0,
|
||||
status: !hasOriginal ? "신규(당초없음)" : Math.abs(difference) > 1e-6 ? "당초차이" : "일치",
|
||||
canApply: false, // 당초 계약값은 비교용이며 현재 단가 덮어쓰기 적용을 지원하지 않음
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function changes(project) {
|
||||
const labels = { new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" };
|
||||
const titles = project.tables?.MachTitleDat || [];
|
||||
return titles.map((title, index) => {
|
||||
const appliedPrice = EstMath.num(title.TC);
|
||||
const hasOriginal = title.TC_ != null;
|
||||
const originalPrice = hasOriginal ? EstMath.num(title.TC_) : null;
|
||||
const difference = hasOriginal ? appliedPrice - originalPrice : null;
|
||||
const statusKey = !hasOriginal
|
||||
? "new"
|
||||
: Math.abs(difference) > 1e-6
|
||||
? "changed"
|
||||
: "unchanged";
|
||||
return {
|
||||
index,
|
||||
Code: title.Code || "",
|
||||
Name: title.Name || "",
|
||||
Size: title.Size || "",
|
||||
Unite: title.Unite || "",
|
||||
originalTC: originalPrice,
|
||||
TC: appliedPrice,
|
||||
difference,
|
||||
status: labels[statusKey] || statusKey,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function applyRowPrice() {
|
||||
throw Error(
|
||||
"기계경비 호표는 MachMainDat 산출 결과이며 당초 계약값으로의 덮어쓰기 적용은 지원하지 않습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
function applyAllPrices() {
|
||||
throw Error(
|
||||
"기계경비 호표는 MachMainDat 산출 결과이며 당초 계약값으로의 덮어쓰기 적용은 지원하지 않습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const { state, esc, number, byId } = EstApp,
|
||||
isChange = state.view === "machine-change",
|
||||
rows = isChange ? changes(state.project) : usage(state.project);
|
||||
page = Math.max(0, Math.min(page, Math.ceil(rows.length / 100) - 1));
|
||||
const visible = rows.slice(page * 100, page * 100 + 100),
|
||||
value = (v) => (v == null ? "" : number(v));
|
||||
if (isChange) {
|
||||
byId("work-content").innerHTML =
|
||||
`<div class="business-tools"><b>기계경비 설계변경</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-machine-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-machine-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><table class="sheet"><thead><tr><th>구분</th><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>당초 금액</th><th>변경 금액</th><th>증감</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.status)}</td><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.originalTC)}</td><td>${value(row.TC)}</td><td>${value(row.difference)}</td><td><button data-machine-group="${esc(row.Code)}">호표 열기</button></td></tr>`).join("")}</tbody></table>`;
|
||||
return;
|
||||
}
|
||||
byId("work-content").innerHTML =
|
||||
`<div class="business-tools"><b>기계경비 사용 내역</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-machine-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-machine-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><p>기계경비 호표의 산출 단가와 당초 계약 단가를 비교합니다. (기계 호표 단가는 MachMainDat 산출 공식에 의해 결정되며 당초값 덮어쓰기는 지원하지 않습니다.)</p><table class="sheet"><thead><tr><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>적용 단가</th><th>당초 단가</th><th>차이</th><th>상태</th><th>노무비</th><th>재료비</th><th>경비</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.appliedPrice)}</td><td>${value(row.originalPrice)}</td><td>${value(row.difference)}</td><td>${esc(row.status)}</td><td>${value(row.NC)}</td><td>${value(row.GC)}</td><td>${value(row.JC)}</td><td><button data-machine-group="${esc(row.Code)}">호표 열기</button></td></tr>`).join("")}</tbody></table>`;
|
||||
}
|
||||
|
||||
function install() {
|
||||
document.addEventListener("click", (event) => {
|
||||
const step = event.target.closest("[data-machine-page]");
|
||||
if (step) {
|
||||
page += Number(step.dataset.machinePage);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const node = event.target.closest("[data-machine-group]");
|
||||
if (!node) return;
|
||||
const code = node.dataset.machineGroup;
|
||||
if (!code) return;
|
||||
EstApp.navigate("MachMainDat", code);
|
||||
});
|
||||
}
|
||||
|
||||
return { calculate, usage, changes, applyRowPrice, applyAllPrices, render, install };
|
||||
})();
|
||||
|
||||
@@ -299,6 +299,7 @@ const EstModel = (() => {
|
||||
}
|
||||
project.options ||= {};
|
||||
EstSchedule.validate(project);
|
||||
EstReview.validate(project);
|
||||
if (typeof project.options !== "object" || Array.isArray(project.options))
|
||||
throw Error("계산 옵션 형식 오류");
|
||||
const validNumber = (value, label) => {
|
||||
@@ -318,6 +319,10 @@ const EstModel = (() => {
|
||||
if (typeof s !== "object" || Array.isArray(s)) throw Error("인쇄 설정 형식 오류");
|
||||
for (const [key, min, max] of [
|
||||
["margin", 0, 50],
|
||||
["marginTop", 0, 50],
|
||||
["marginBottom", 0, 50],
|
||||
["marginLeft", 0, 50],
|
||||
["marginRight", 0, 50],
|
||||
["font", 6, 20],
|
||||
["rowsPerPage", 1, 100],
|
||||
["startPage", 1, 99999],
|
||||
@@ -340,17 +345,89 @@ const EstModel = (() => {
|
||||
if (s[key] != null && (typeof s[key] !== "string" || s[key].length > 100000))
|
||||
throw Error("인쇄 문구 형식 오류");
|
||||
}
|
||||
if (project.excelQuantityLinks != null) {
|
||||
if (!Array.isArray(project.excelQuantityLinks)) throw Error("Excel 수량 연결 형식 오류");
|
||||
const targets = new Set();
|
||||
for (const link of project.excelQuantityLinks) {
|
||||
if (
|
||||
!link ||
|
||||
!Number.isSafeInteger(link.target) ||
|
||||
targets.has(link.target) ||
|
||||
typeof link.file !== "string" ||
|
||||
!link.file ||
|
||||
link.file.length > 255 ||
|
||||
typeof link.sheet !== "string" ||
|
||||
!link.sheet ||
|
||||
link.sheet.length > 255 ||
|
||||
!EstQuantity.validExcelAddress(link.cell)
|
||||
)
|
||||
throw Error("Excel 수량 연결 오류");
|
||||
targets.add(link.target);
|
||||
}
|
||||
}
|
||||
if (project.documents != null) {
|
||||
if (typeof project.documents !== "object" || Array.isArray(project.documents))
|
||||
throw Error("문서 본문 형식 오류");
|
||||
const extraForms = new Set([
|
||||
"progress-summary",
|
||||
"progress-costs",
|
||||
"escalation-summary",
|
||||
"escalation-costs",
|
||||
"annual-summary",
|
||||
"annual-items",
|
||||
"change-costs",
|
||||
"standard-usage",
|
||||
"standard-change",
|
||||
"estimate-usage",
|
||||
"estimate-change",
|
||||
"material-usage",
|
||||
"material-change",
|
||||
"labor-usage",
|
||||
"labor-change",
|
||||
"machine-usage",
|
||||
"machine-change",
|
||||
]);
|
||||
for (const [key, value] of Object.entries(project.documents))
|
||||
if (!/^(current|\d+)$/.test(key) || typeof value !== "string" || value.length > 100000)
|
||||
if (
|
||||
(!/^(current|\d+)$/.test(key) && !extraForms.has(key)) ||
|
||||
typeof value !== "string" ||
|
||||
value.length > 100000
|
||||
)
|
||||
throw Error("문서 본문 형식 오류");
|
||||
}
|
||||
for (const key of ["quantities", "progress", "annual", "drawings"]) {
|
||||
for (const key of ["quantities", "progress", "annual", "drawings", "attachments"]) {
|
||||
if (project[key] === undefined) project[key] = [];
|
||||
if (!Array.isArray(project[key])) throw Error(`${key} 형식 오류`);
|
||||
}
|
||||
const attachmentIds = new Set();
|
||||
for (const item of project.attachments) {
|
||||
if (
|
||||
!item ||
|
||||
typeof item.id !== "string" ||
|
||||
!item.id ||
|
||||
attachmentIds.has(item.id) ||
|
||||
typeof item.name !== "string" ||
|
||||
!item.name ||
|
||||
item.name.length > 1000 ||
|
||||
typeof item.type !== "string" ||
|
||||
item.type.length > 1000 ||
|
||||
!Number.isSafeInteger(item.size) ||
|
||||
item.size < 0 ||
|
||||
typeof item.data !== "string" ||
|
||||
item.data.length % 4 ||
|
||||
/[^A-Za-z0-9+/=]/.test(item.data)
|
||||
)
|
||||
throw Error("첨부파일 형식 오류");
|
||||
const body = item.data.replace(/={1,2}$/, ""),
|
||||
padding = item.data.length - body.length;
|
||||
if (
|
||||
body.includes("=") ||
|
||||
body.length % 4 === 1 ||
|
||||
(item.data.length / 4) * 3 - padding !== item.size
|
||||
)
|
||||
throw Error("첨부파일 데이터 오류");
|
||||
attachmentIds.add(item.id);
|
||||
}
|
||||
const ids = new Set();
|
||||
for (const round of project.progress) {
|
||||
if (
|
||||
@@ -557,11 +634,13 @@ const EstModel = (() => {
|
||||
if (table === "EstMainDat")
|
||||
for (const round of project?.progress || [])
|
||||
historical.push(round.contract?.rows || [], round.result?.rows || []);
|
||||
const max = historical.reduce(
|
||||
let max = historical.reduce(
|
||||
(maximum, list) =>
|
||||
list.reduce((value, row) => Math.max(value, Number(row.PkID) || 0), maximum),
|
||||
0,
|
||||
);
|
||||
if (table === "EstMainDat")
|
||||
for (const link of project?.excelQuantityLinks || []) max = Math.max(max, link.target);
|
||||
const row = {
|
||||
PkID: max + 1,
|
||||
Index: 0,
|
||||
|
||||
@@ -8,6 +8,14 @@ const EstProgress = (() => {
|
||||
return Math.max(0, (mantissa.split(".")[1]?.length || 0) - Number(exponent || 0));
|
||||
};
|
||||
const ordinary = (row) => num(row.Level) === 0 && ![54, 55, 58, 59].includes(num(row.LinkIndex));
|
||||
function ambiguousKeys(rows) {
|
||||
const counts = new Map();
|
||||
rows.forEach((row, index) => {
|
||||
const key = EstBusiness.rowKey(row, index);
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
});
|
||||
return new Set([...counts].filter(([, count]) => count > 1).map(([key]) => key));
|
||||
}
|
||||
function distribute(amount, row, digits) {
|
||||
const parts = components.map((field) => num(row[field]));
|
||||
const base = sum(row);
|
||||
@@ -31,6 +39,7 @@ const EstProgress = (() => {
|
||||
const errors = [],
|
||||
position = project.progress.indexOf(round),
|
||||
key = EstBusiness.rowKey;
|
||||
const ambiguous = ambiguousKeys(rows);
|
||||
const earlier = project.progress.slice(0, position);
|
||||
if (earlier.some((item) => !item.locked)) errors.push("전회 기성 회차를 먼저 확정하세요.");
|
||||
const previous = earlier.at(-1)?.result,
|
||||
@@ -39,8 +48,10 @@ const EstProgress = (() => {
|
||||
const separated = round.separateComponents !== false;
|
||||
rows.forEach((row, index) => {
|
||||
const id = key(row, index),
|
||||
input = round.inputs?.[id] || {},
|
||||
old = oldRows.get(id);
|
||||
input = ambiguous.has(id) ? {} : round.inputs?.[id] || {},
|
||||
old = ambiguous.has(id) ? null : oldRows.get(id);
|
||||
if (ambiguous.has(id) && (round.inputs?.[id] || oldRows.has(id)))
|
||||
errors.push(`기성 내역 식별자가 중복됩니다: ${id}`);
|
||||
for (const field of ["GN", "TC", "Ratio", ...components])
|
||||
row["Jun" + field] = num(old?.["Total" + field]);
|
||||
if (num(row.Level) > 0 || [54, 59].includes(num(row.LinkIndex))) {
|
||||
@@ -224,11 +235,20 @@ const EstProgress = (() => {
|
||||
row[prefix + field] = children.reduce((n, child) => n + num(child[prefix + field]), 0);
|
||||
}
|
||||
const acceptedCost = round.approved ? sa : gum;
|
||||
const priorCosts = new Map(
|
||||
(previous?.costs || previous?.wonga || []).map((row) => [row.Gubun ?? row.Name, row]),
|
||||
);
|
||||
const costKeys = (items) => {
|
||||
const occurrences = new Map();
|
||||
return items.map((row) => {
|
||||
const identity = JSON.stringify(row.Gubun ? ["code", row.Gubun] : ["name", row.Name || ""]);
|
||||
const occurrence = occurrences.get(identity) || 0;
|
||||
occurrences.set(identity, occurrence + 1);
|
||||
return JSON.stringify([identity, occurrence]);
|
||||
});
|
||||
};
|
||||
const oldCosts = previous?.costs || previous?.wonga || [];
|
||||
const priorCosts = new Map(costKeys(oldCosts).map((key, index) => [key, oldCosts[index]]));
|
||||
const currentKeys = costKeys(acceptedCost.rows);
|
||||
const costs = acceptedCost.rows.map((row, index) => {
|
||||
const previous = priorCosts.get(row.Gubun ?? row.Name);
|
||||
const previous = priorCosts.get(currentKeys[index]);
|
||||
const jun = num(previous?.TotalCost ?? previous?.Cost);
|
||||
return {
|
||||
...row,
|
||||
@@ -292,5 +312,5 @@ const EstProgress = (() => {
|
||||
}
|
||||
return project;
|
||||
}
|
||||
return { calculate, lock, unlock, rebuild };
|
||||
return { calculate, lock, unlock, rebuild, ambiguousKeys };
|
||||
})();
|
||||
|
||||
@@ -56,6 +56,12 @@ const EstQuantity = (() => {
|
||||
return { Code: code, Name: name, rows, items, options };
|
||||
}
|
||||
function calculate(sheet) {
|
||||
const markerErrors = sheet.rows.flatMap((row, index) => {
|
||||
const count = [...String(row.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)].length;
|
||||
return count === (row.markers || []).length
|
||||
? []
|
||||
: [`${index + 1}행: 결과 표지 수가 다릅니다.`];
|
||||
});
|
||||
const input = [
|
||||
{ Index: 1, Code: sheet.Code, LineData: "" },
|
||||
...sheet.rows.map((row) => {
|
||||
@@ -110,13 +116,186 @@ const EstQuantity = (() => {
|
||||
rows: result.rows.slice(1),
|
||||
items: sheet.items.map((item) => ({ ...item, GN: values.get(item.Code) ?? item.GN })),
|
||||
checks,
|
||||
errors: result.errors,
|
||||
errors: [...result.errors, ...markerErrors],
|
||||
};
|
||||
}
|
||||
function selector(state) {
|
||||
if (!state.project.quantitySheets?.length) return "";
|
||||
function manual(items) {
|
||||
const values = Object.create(null),
|
||||
codes = new Set(),
|
||||
errors = [];
|
||||
const rows = items.map((row) => {
|
||||
try {
|
||||
if (!row.Code || codes.has(row.Code)) throw Error("산출코드가 비어 있거나 중복됩니다.");
|
||||
codes.add(row.Code);
|
||||
const GN = EstMath.floating(
|
||||
EstExpression.evaluate(row.formula || "0", (key) => values[key]),
|
||||
row.digits ?? 2,
|
||||
1,
|
||||
);
|
||||
values[row.Code] = GN;
|
||||
return { ...row, GN };
|
||||
} catch (error) {
|
||||
errors.push(`${row.Code || "수량"}: ${error.message}`);
|
||||
return { ...row, GN: null, Error: error.message };
|
||||
}
|
||||
});
|
||||
return { rows, errors };
|
||||
}
|
||||
function totals(project, selected, allowEmpty = false) {
|
||||
let items;
|
||||
if (selected && selected !== "manual") {
|
||||
const sheet = project.quantitySheets?.find((s) => s.Code === selected);
|
||||
if (!sheet) throw Error("선택한 수량산출서가 없습니다.");
|
||||
const output = calculate(sheet);
|
||||
if (output.errors.length) throw Error(output.errors.join(" / "));
|
||||
items = output.items;
|
||||
} else {
|
||||
const output = manual(project.quantities || []);
|
||||
if (output.errors.length) throw Error(output.errors.join(" / "));
|
||||
items = output.rows;
|
||||
}
|
||||
const quantities = new Map();
|
||||
for (const item of items) {
|
||||
const code = item.TargetCode ?? item.LinkCode;
|
||||
if (!code && !item.TargetKey) continue;
|
||||
const targets = project.tables.EstMainDat.map((row, index) => ({ row, index })).filter(
|
||||
({ row }) =>
|
||||
!Number(row.Level) &&
|
||||
(item.TargetKey
|
||||
? row.PkID != null && `id:${row.PkID}` === item.TargetKey
|
||||
: row.Code === code),
|
||||
);
|
||||
if (!targets.length) throw Error(`연결 내역 없음: ${item.TargetKey || code}`);
|
||||
if (targets.length > 1) throw Error(`연결 내역 중복: ${item.TargetKey || code}`);
|
||||
const index = targets[0].index;
|
||||
quantities.set(index, (quantities.get(index) || 0) + Number(item.GN || 0));
|
||||
}
|
||||
if (!quantities.size && !allowEmpty) throw Error("연결할 내역코드를 먼저 입력하세요.");
|
||||
return quantities;
|
||||
}
|
||||
function allTotals(project) {
|
||||
const combined = new Map();
|
||||
const codes = (project.quantitySheets || []).map((sheet) => sheet.Code);
|
||||
if (
|
||||
codes.some((code) => typeof code !== "string" || !code || code === "manual") ||
|
||||
new Set(codes).size !== codes.length
|
||||
)
|
||||
throw Error("수량산출서 코드가 없거나 중복됩니다.");
|
||||
for (const selected of ["manual", ...codes])
|
||||
for (const [index, value] of totals(project, selected, true))
|
||||
combined.set(index, (combined.get(index) || 0) + value);
|
||||
if (!combined.size) throw Error("연결할 내역코드를 먼저 입력하세요.");
|
||||
return combined;
|
||||
}
|
||||
function applyTotals(project, quantities) {
|
||||
for (const [index, value] of quantities) project.tables.EstMainDat[index].GN = value;
|
||||
}
|
||||
function targetSelect(project, item, section, index) {
|
||||
const { esc } = EstApp;
|
||||
return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${state.project.quantitySheets.map((sheet) => `<option value="${esc(sheet.Code)}" ${state.quantitySheet === sheet.Code ? "selected" : ""}>${esc(sheet.Name || sheet.Code)}</option>`).join("")}</select></label></div>`;
|
||||
const rows = project.tables.EstMainDat.filter((row) => row.PkID != null && !Number(row.Level));
|
||||
return `<select data-quantity-select="${section}" data-index="${index}" aria-label="연결 내역 행"><option value="">내역 행 선택</option>${rows
|
||||
.map((row) => {
|
||||
const key = `id:${row.PkID}`;
|
||||
return `<option value="${esc(key)}" ${item.TargetKey === key ? "selected" : ""}>${esc(row.Name || "내역")} · ${esc(row.Code || "코드 없음")} · ${esc(row.PkID)}</option>`;
|
||||
})
|
||||
.join("")}</select>`;
|
||||
}
|
||||
function newCode(items) {
|
||||
const codes = new Set(items.map((row) => row.Code));
|
||||
for (const row of items)
|
||||
for (const match of String(row.formula || "").matchAll(/\[([^\]]+)\]/g)) codes.add(match[1]);
|
||||
let index = 1;
|
||||
while (codes.has(`Q${index}`)) index++;
|
||||
return `Q${index}`;
|
||||
}
|
||||
function selector(state) {
|
||||
const { esc } = EstApp;
|
||||
return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${(state.project.quantitySheets || []).map((sheet) => `<option value="${esc(sheet.Code)}" ${state.quantitySheet === sheet.Code ? "selected" : ""}>${esc(sheet.Name || sheet.Code)}</option>`).join("")}</select></label><button data-quantity-new>새 산출서</button><button data-quantity-excel>Excel 셀 수량 연결</button><button data-quantity-excel-links>Excel 연결 관리</button></div>`;
|
||||
}
|
||||
function validExcelAddress(address) {
|
||||
const parts = typeof address === "string" && address.match(/^([A-Z]{1,3})([1-9]\d*)$/);
|
||||
return Boolean(
|
||||
parts &&
|
||||
[...parts[1]].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) <= 16384 &&
|
||||
Number(parts[2]) <= 1048576,
|
||||
);
|
||||
}
|
||||
function excelCell(files, name, address) {
|
||||
const parse = (text) => {
|
||||
if (!text || /<!DOCTYPE|<!ENTITY/i.test(text)) throw Error("Excel XML 형식 오류");
|
||||
const doc = new DOMParser().parseFromString(text, "application/xml");
|
||||
if (doc.querySelector("parsererror")) throw Error("Excel XML 형식 오류");
|
||||
return doc;
|
||||
};
|
||||
address = address.trim().toUpperCase();
|
||||
if (!validExcelAddress(address)) throw Error("Excel 셀 주소를 확인하세요. 예: B12");
|
||||
const workbook = parse(files["xl/workbook.xml"]),
|
||||
sheet = [...workbook.getElementsByTagNameNS("*", "sheet")].find(
|
||||
(s) => s.getAttribute("name") === name,
|
||||
);
|
||||
if (!sheet) throw Error(`Excel 시트가 없습니다: ${name}`);
|
||||
const id = sheet.getAttributeNS(
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
"id",
|
||||
),
|
||||
rels = parse(files["xl/_rels/workbook.xml.rels"]),
|
||||
rel = [...rels.getElementsByTagNameNS("*", "Relationship")].find(
|
||||
(r) => r.getAttribute("Id") === id,
|
||||
),
|
||||
target = rel?.getAttribute("Target") || "",
|
||||
path = target.startsWith("/") ? target.slice(1) : `xl/${target}`;
|
||||
const doc = parse(files[path]),
|
||||
cell = [...doc.getElementsByTagNameNS("*", "c")].find((c) => c.getAttribute("r") === address);
|
||||
if (!cell || (cell.getAttribute("t") && cell.getAttribute("t") !== "n"))
|
||||
throw Error(`${name} ${address}: 숫자 셀이 아닙니다.`);
|
||||
const value = cell.getElementsByTagNameNS("*", "v")[0]?.textContent;
|
||||
if (
|
||||
value == null ||
|
||||
!value.trim() ||
|
||||
!Number.isFinite(Number(value)) ||
|
||||
Math.abs(Number(value)) > Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
throw Error(`${name} ${address}: 저장된 숫자 값이 없습니다.`);
|
||||
return Number(value);
|
||||
}
|
||||
function excelTarget(row) {
|
||||
return (
|
||||
row &&
|
||||
!Number(row.Level) &&
|
||||
![51, 52, 54, 55, 57, 58, 59, 62, 67, 68].includes(Number(row.LinkIndex)) &&
|
||||
!["%", "%"].includes(row.Unite)
|
||||
);
|
||||
}
|
||||
function excelDialog() {
|
||||
const { state, esc, showDialog } = EstApp;
|
||||
const rows = state.project.tables.EstMainDat;
|
||||
showDialog(
|
||||
"Excel 셀 수량 반영",
|
||||
`<div class="form-fields"><label>Excel 파일 <input type="file" accept=".xlsx" id="quantity-excel-file"></label><label>시트 이름 <input id="quantity-excel-sheet"></label><label>셀 주소 <input id="quantity-excel-cell" placeholder="B12"></label><label>내역 행 <select id="quantity-excel-target"><option value="">내역 행 선택</option>${rows.map((row, index) => (excelTarget(row) ? `<option value="${index}">${esc(row.Code || "코드 없음")} · ${esc(row.Name || "내역")} · ${index + 1}행</option>` : "")).join("")}</select></label><button data-quantity-excel-apply>연결 및 수량 반영</button><span id="quantity-excel-error" role="alert"></span></div><p>파일명·시트·셀 연결은 작업 JSON에 저장합니다. 저장된 숫자 값을 사용하므로 Excel 파일이 바뀌면 연결 관리에서 다시 선택하세요.</p>`,
|
||||
);
|
||||
}
|
||||
function excelUpdates(project, filename, files) {
|
||||
const links = (project.excelQuantityLinks || []).filter((link) => link.file === filename);
|
||||
if (!links.length) throw Error("선택한 파일에 연결된 내역이 없습니다.");
|
||||
return links.map((link) => {
|
||||
const rows = project.tables.EstMainDat.filter((row) => row.PkID === link.target);
|
||||
if (rows.length !== 1 || !excelTarget(rows[0]))
|
||||
throw Error(`연결 내역이 없거나 중복되었거나 상세 행이 아닙니다: ${link.target}`);
|
||||
return { row: rows[0], value: excelCell(files, link.sheet, link.cell) };
|
||||
});
|
||||
}
|
||||
function excelLinksDialog() {
|
||||
const { state, esc, showDialog } = EstApp,
|
||||
links = state.project.excelQuantityLinks || [];
|
||||
showDialog(
|
||||
"Excel 수량 연결 관리",
|
||||
`<p>연결된 파일을 다시 선택하면 해당 파일의 모든 연결을 갱신합니다.</p><div class="form-fields"><label>Excel 파일 <input type="file" accept=".xlsx" id="quantity-excel-refresh-file"></label><button data-quantity-excel-refresh>연결 수량 갱신</button><span id="quantity-excel-links-error" role="alert"></span></div><table class="sheet"><thead><tr><th>파일</th><th>시트</th><th>셀</th><th>내역</th><th>작업</th></tr></thead><tbody>${links
|
||||
.map((link, index) => {
|
||||
const row = state.project.tables.EstMainDat.find((r) => r.PkID === link.target);
|
||||
return `<tr><td>${esc(link.file)}</td><td>${esc(link.sheet)}</td><td>${esc(link.cell)}</td><td>${esc(row?.Name || link.target)}</td><td><button data-quantity-excel-remove="${index}">연결 해제</button></td></tr>`;
|
||||
})
|
||||
.join("")}</tbody></table>`,
|
||||
);
|
||||
}
|
||||
function render(state) {
|
||||
state.quantitySheet ||= state.project.quantitySheets[0]?.Code;
|
||||
@@ -128,7 +307,7 @@ const EstQuantity = (() => {
|
||||
{ esc, number, byId } = EstApp;
|
||||
byId("work-content").innerHTML =
|
||||
selector(state) +
|
||||
`<div class="business-tools"><b>${esc(sheet.Name)}</b><button data-quantity-apply>내역 수량 반영</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>원본 저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td>${esc(item.Name)}</td><td>${esc(item.Size)}</td><td>${number(sheet.items[index].GN)}</td><td>${number(item.GN)}</td><td><input data-quantity-target="${index}" value="${esc(item.TargetCode || item.LinkCode || "")}" aria-label="연결 내역코드"></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 원본 결과 표지의 이름을 유지해 수량을 연결합니다. ESTX 저장 시 원본 결과가 계산과 일치하면 편집 결과와 수량 목록을 갱신합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td></tr>`).join("")}</tbody></table>`;
|
||||
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>단위</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th><th>작업</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 행 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
|
||||
}
|
||||
function install() {
|
||||
document.addEventListener("change", (event) => {
|
||||
@@ -139,8 +318,32 @@ const EstQuantity = (() => {
|
||||
EstApp.render();
|
||||
return;
|
||||
}
|
||||
if (node.dataset.quantitySelect !== undefined) {
|
||||
const item =
|
||||
node.dataset.quantitySelect === "manual"
|
||||
? state.project.quantities[Number(node.dataset.index)]
|
||||
: state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet)?.items[
|
||||
Number(node.dataset.index)
|
||||
];
|
||||
if (!item) return;
|
||||
const row = state.project.tables.EstMainDat.find(
|
||||
(r) => r.PkID != null && `id:${r.PkID}` === node.value && !Number(r.Level),
|
||||
);
|
||||
EstApp.change(() => {
|
||||
item.TargetKey = row ? node.value : "";
|
||||
item.TargetCode = row?.Code || "";
|
||||
}, false);
|
||||
return;
|
||||
}
|
||||
const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
|
||||
if (!sheet) return;
|
||||
if (node.dataset.quantityName !== undefined)
|
||||
EstApp.change(() => (sheet.Name = node.value), false);
|
||||
if (["Name", "Size", "Unite"].includes(node.dataset.quantityItem))
|
||||
EstApp.change(
|
||||
() => (sheet.items[Number(node.dataset.index)][node.dataset.quantityItem] = node.value),
|
||||
false,
|
||||
);
|
||||
if (node.dataset.quantityLine !== undefined)
|
||||
EstApp.change(
|
||||
() =>
|
||||
@@ -148,37 +351,239 @@ const EstQuantity = (() => {
|
||||
false,
|
||||
);
|
||||
if (node.dataset.quantityTarget !== undefined)
|
||||
EstApp.change(
|
||||
() => (sheet.items[Number(node.dataset.quantityTarget)].TargetCode = node.value),
|
||||
false,
|
||||
);
|
||||
EstApp.change(() => {
|
||||
const item = sheet.items[Number(node.dataset.quantityTarget)];
|
||||
item.TargetCode = node.value;
|
||||
item.TargetKey = "";
|
||||
}, false);
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!event.target.closest("[data-quantity-apply]")) return;
|
||||
const { state } = EstApp;
|
||||
try {
|
||||
const { state } = EstApp,
|
||||
sheet = state.project.quantitySheets.find((s) => s.Code === state.quantitySheet),
|
||||
output = calculate(sheet);
|
||||
if (output.errors.length) throw Error(output.errors.join(" / "));
|
||||
const quantities = new Map();
|
||||
for (const item of output.items)
|
||||
if (item.TargetCode || item.LinkCode) {
|
||||
const code = item.TargetCode || item.LinkCode;
|
||||
if (!state.project.tables.EstMainDat.some((r) => r.Code === code))
|
||||
throw Error(`연결 내역코드 없음: ${code}`);
|
||||
quantities.set(code, (quantities.get(code) || 0) + Number(item.GN || 0));
|
||||
if (event.target.closest("[data-quantity-excel]")) {
|
||||
excelDialog();
|
||||
return;
|
||||
}
|
||||
if (event.target.closest("[data-quantity-excel-links]")) {
|
||||
excelLinksDialog();
|
||||
return;
|
||||
}
|
||||
const removeLink = event.target.closest("[data-quantity-excel-remove]");
|
||||
if (removeLink) {
|
||||
EstApp.change(
|
||||
() =>
|
||||
state.project.excelQuantityLinks.splice(
|
||||
Number(removeLink.dataset.quantityExcelRemove),
|
||||
1,
|
||||
),
|
||||
false,
|
||||
);
|
||||
document.getElementById("dialog").close();
|
||||
excelLinksDialog();
|
||||
return;
|
||||
}
|
||||
if (event.target.closest("[data-quantity-excel-refresh]")) {
|
||||
const project = state.project,
|
||||
revision = state.revision,
|
||||
input = document.getElementById("quantity-excel-refresh-file"),
|
||||
file = input.files[0];
|
||||
(async () => {
|
||||
if (!file) throw Error("Excel 파일을 선택하세요.");
|
||||
const files = await EstApp.api("/api/import-xlsx", {
|
||||
method: "POST",
|
||||
body: await file.arrayBuffer(),
|
||||
});
|
||||
if (
|
||||
state.project !== project ||
|
||||
state.revision !== revision ||
|
||||
document.getElementById("quantity-excel-refresh-file") !== input ||
|
||||
input.files[0] !== file
|
||||
)
|
||||
throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요.");
|
||||
const updates = excelUpdates(project, file.name, files);
|
||||
EstApp.change(() => updates.forEach(({ row, value }) => (row.GN = value)));
|
||||
document.getElementById("dialog").close();
|
||||
})().catch((error) => {
|
||||
const output = document.getElementById("quantity-excel-links-error");
|
||||
if (output) output.textContent = error.message;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.target.closest("[data-quantity-excel-apply]")) {
|
||||
const project = state.project,
|
||||
revision = state.revision,
|
||||
fileInput = document.getElementById("quantity-excel-file"),
|
||||
file = fileInput.files[0],
|
||||
name = document.getElementById("quantity-excel-sheet").value.trim(),
|
||||
address = document.getElementById("quantity-excel-cell").value,
|
||||
selected = document.getElementById("quantity-excel-target").value,
|
||||
row = selected === "" ? null : project.tables.EstMainDat[Number(selected)];
|
||||
(async () => {
|
||||
if (!file || !name || !row) throw Error("파일·시트·셀·내역 행을 선택하세요.");
|
||||
if (
|
||||
!Number.isSafeInteger(row.PkID) ||
|
||||
project.tables.EstMainDat.filter((item) => item.PkID === row.PkID).length !== 1
|
||||
)
|
||||
throw Error("내역 행 식별자가 없거나 중복되어 연결할 수 없습니다.");
|
||||
const files = await EstApp.api("/api/import-xlsx", {
|
||||
method: "POST",
|
||||
body: await file.arrayBuffer(),
|
||||
}),
|
||||
value = excelCell(files, name, address);
|
||||
if (
|
||||
state.project !== project ||
|
||||
state.revision !== revision ||
|
||||
document.getElementById("quantity-excel-file") !== fileInput ||
|
||||
fileInput.files[0] !== file ||
|
||||
document.getElementById("quantity-excel-sheet")?.value.trim() !== name ||
|
||||
document.getElementById("quantity-excel-cell")?.value !== address ||
|
||||
document.getElementById("quantity-excel-target")?.value !== selected ||
|
||||
project.tables.EstMainDat[Number(selected)] !== row
|
||||
)
|
||||
throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요.");
|
||||
EstApp.change(() => {
|
||||
row.GN = value;
|
||||
project.excelQuantityLinks ||= [];
|
||||
project.excelQuantityLinks = project.excelQuantityLinks.filter(
|
||||
(link) => link.target !== row.PkID,
|
||||
);
|
||||
project.excelQuantityLinks.push({
|
||||
target: row.PkID,
|
||||
file: file.name,
|
||||
sheet: name,
|
||||
cell: address.trim().toUpperCase(),
|
||||
});
|
||||
});
|
||||
document.getElementById("dialog").close();
|
||||
})().catch((error) => {
|
||||
const output = document.getElementById("quantity-excel-error");
|
||||
if (output) output.textContent = error.message;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.target.closest("[data-quantity-new]")) {
|
||||
EstApp.change(() => {
|
||||
const code = `Q${EstApp.uid()}`;
|
||||
state.project.quantitySheets ||= [];
|
||||
state.project.quantitySheets.push({
|
||||
Code: code,
|
||||
Name: "새 수량산출서",
|
||||
rows: [],
|
||||
items: [],
|
||||
options: { digits: 2, roundMode: 2 },
|
||||
});
|
||||
state.quantitySheet = code;
|
||||
}, false);
|
||||
return;
|
||||
}
|
||||
const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
|
||||
const addItem = event.target.closest("[data-quantity-add-item]");
|
||||
if (addItem && sheet) {
|
||||
EstApp.change(() => {
|
||||
const codes = new Set(sheet.items.map((it) => it.Code));
|
||||
let index = 0;
|
||||
while (codes.has(`r${String(index).padStart(6, "0")}`)) index++;
|
||||
const code = `r${String(index).padStart(6, "0")}`;
|
||||
sheet.items.push({
|
||||
Code: code,
|
||||
Name: "새 수량항목",
|
||||
Size: "",
|
||||
Unite: "",
|
||||
GN: 0,
|
||||
TargetCode: "",
|
||||
});
|
||||
}, false);
|
||||
return;
|
||||
}
|
||||
const deleteItem = event.target.closest("[data-quantity-delete-item]");
|
||||
if (deleteItem && sheet) {
|
||||
const itemIdx = Number(deleteItem.dataset.quantityDeleteItem);
|
||||
const targetItem = sheet.items[itemIdx];
|
||||
if (!targetItem) return;
|
||||
const code = targetItem.Code;
|
||||
const referringRows = sheet.rows.filter((row) => (row.markers || []).includes(code));
|
||||
if (referringRows.length > 0) {
|
||||
throw Error(
|
||||
`산출 행에서 참조 중인 수량 항목(${targetItem.Name || code})은 직접 삭제할 수 없습니다. 연결된 산출 행을 삭제하거나, 미참조 항목만 삭제할 수 있습니다.`,
|
||||
);
|
||||
}
|
||||
if (!quantities.size) throw Error("연결할 내역코드를 먼저 입력하세요.");
|
||||
EstApp.change(() => {
|
||||
for (const row of state.project.tables.EstMainDat)
|
||||
if (quantities.has(row.Code) && !Number(row.Level)) row.GN = quantities.get(row.Code);
|
||||
});
|
||||
if (confirm("이 수량 항목을 삭제하시겠습니까?"))
|
||||
EstApp.change(() => {
|
||||
sheet.items.splice(itemIdx, 1);
|
||||
}, false);
|
||||
return;
|
||||
}
|
||||
const addRow = event.target.closest("[data-quantity-add]");
|
||||
if (addRow && sheet) {
|
||||
EstApp.change(() => {
|
||||
const code = `r${EstApp.uid()}`;
|
||||
const at =
|
||||
addRow.dataset.index === undefined
|
||||
? sheet.rows.length
|
||||
: Number(addRow.dataset.index) + 1;
|
||||
sheet.rows.splice(at, 0, { LineData: "1 = \x120\x12", markers: [code] });
|
||||
sheet.items.push({ Code: code, Name: "새 항목", Size: "", GN: 0, TargetCode: "" });
|
||||
}, false);
|
||||
return;
|
||||
}
|
||||
const moveRow = event.target.closest("[data-quantity-move]");
|
||||
if (moveRow && sheet) {
|
||||
const index = Number(moveRow.dataset.index),
|
||||
next = index + Number(moveRow.dataset.quantityMove);
|
||||
if (index >= 0 && index < sheet.rows.length && next >= 0 && next < sheet.rows.length)
|
||||
EstApp.change(() => {
|
||||
[sheet.rows[index], sheet.rows[next]] = [sheet.rows[next], sheet.rows[index]];
|
||||
}, false);
|
||||
return;
|
||||
}
|
||||
const deleteRow = event.target.closest("[data-quantity-delete-row]");
|
||||
if (deleteRow && sheet) {
|
||||
if (confirm("이 산출 행과 연결된 수량 항목을 삭제하시겠습니까?"))
|
||||
EstApp.change(() => {
|
||||
const [removed] = sheet.rows.splice(Number(deleteRow.dataset.quantityDeleteRow), 1);
|
||||
const remaining = new Set(sheet.rows.flatMap((row) => row.markers || []));
|
||||
sheet.items = sheet.items.filter(
|
||||
(item) => !removed.markers.includes(item.Code) || remaining.has(item.Code),
|
||||
);
|
||||
}, false);
|
||||
return;
|
||||
}
|
||||
if (event.target.closest("[data-quantity-delete-sheet]") && sheet) {
|
||||
if (confirm("이 수량산출서를 삭제하시겠습니까?"))
|
||||
EstApp.change(() => {
|
||||
state.project.quantitySheets = state.project.quantitySheets.filter(
|
||||
(item) => item !== sheet,
|
||||
);
|
||||
state.quantitySheet = state.project.quantitySheets[0]?.Code || "manual";
|
||||
}, false);
|
||||
return;
|
||||
}
|
||||
if (!event.target.closest("[data-quantity-apply]")) return;
|
||||
const quantities = totals(state.project, state.quantitySheet);
|
||||
EstApp.change(() => applyTotals(state.project, quantities));
|
||||
} catch (error) {
|
||||
EstApp.state.notice = error.message;
|
||||
EstApp.render();
|
||||
}
|
||||
});
|
||||
}
|
||||
return { entities, fromWpt, calculate, selector, render, install };
|
||||
return {
|
||||
entities,
|
||||
fromWpt,
|
||||
calculate,
|
||||
manual,
|
||||
totals,
|
||||
allTotals,
|
||||
applyTotals,
|
||||
excelCell,
|
||||
excelTarget,
|
||||
validExcelAddress,
|
||||
excelUpdates,
|
||||
targetSelect,
|
||||
newCode,
|
||||
selector,
|
||||
render,
|
||||
install,
|
||||
};
|
||||
})();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = EstQuantity;
|
||||
|
||||
+133
-20
@@ -13,14 +13,21 @@ const EstReport = (() => {
|
||||
startPage: 1,
|
||||
rowsPerPage: 30,
|
||||
notes: "",
|
||||
PrintGongMethod: settings.PrintGongMethod ?? 0,
|
||||
PrintApplyField: settings.PrintApplyField ?? 0,
|
||||
PrintSumBottom: settings.PrintSumBottom ?? 0,
|
||||
...settings,
|
||||
marginTop: settings.marginTop ?? settings.margin ?? 12,
|
||||
marginBottom: settings.marginBottom ?? settings.margin ?? 12,
|
||||
marginLeft: settings.marginLeft ?? settings.margin ?? 12,
|
||||
marginRight: settings.marginRight ?? settings.margin ?? 12,
|
||||
};
|
||||
}
|
||||
function preview(all = false) {
|
||||
const { state, esc } = EstApp;
|
||||
const settings = setting(),
|
||||
form = catalog.find((row) => String(row.Index) === settings.form),
|
||||
output = form ? EstForms.data(form.Index, state.project) : null;
|
||||
output = form ? EstForms.data(form.Index, state.project, state) : null;
|
||||
const number = (value) =>
|
||||
new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 12 }).format(Number(value) || 0);
|
||||
const root = document.createElement("div");
|
||||
@@ -76,18 +83,26 @@ const EstReport = (() => {
|
||||
.forEach((node) => node.removeAttribute("contenteditable"));
|
||||
const title = form?.Name2 || EstApp.titles[state.view] || state.view;
|
||||
const detail =
|
||||
state.view === "quantity"
|
||||
output?.detail ||
|
||||
(state.view === "quantity"
|
||||
? state.project.quantitySheets?.find((sheet) => sheet.Code === state.quantitySheet)?.Name
|
||||
: state.view === "progress"
|
||||
? state.project.progress.find((round) => round.id === state.roundId)?.name
|
||||
: state.group
|
||||
? EstNative.groups(state.project.tables[state.view] || []).get(state.group)?.rows[0]
|
||||
?.Name
|
||||
: "";
|
||||
const totals = state.project.calculation;
|
||||
: "");
|
||||
const totals = [
|
||||
"progress-summary",
|
||||
"progress-costs",
|
||||
"escalation-summary",
|
||||
"escalation-costs",
|
||||
].includes(form?.Index)
|
||||
? null
|
||||
: state.project.calculation;
|
||||
const errors = [...(totals?.errors || []), ...(output?.errors || [])];
|
||||
const source = `<!doctype html><html lang="ko"><head><meta charset="UTF-8"><title>${esc(state.project.name)} · ${esc(title)}</title>
|
||||
<style>@page{size:${settings.paper} ${settings.orientation};margin:${settings.margin}mm}*{box-sizing:border-box}body{font:${settings.font}pt "Malgun Gothic",sans-serif;color:#111;margin:0}
|
||||
<style>@page{size:${settings.paper} ${settings.orientation};margin:${settings.marginTop}mm ${settings.marginRight}mm ${settings.marginBottom}mm ${settings.marginLeft}mm}*{box-sizing:border-box}body{font:${settings.font}pt "Malgun Gothic",sans-serif;color:#111;margin:0}
|
||||
h1{font-size:17pt;margin:0 0 4mm}h2{font-size:12pt;margin:0 0 3mm}.summary{margin:2mm 0 4mm}
|
||||
table{width:100%;border-collapse:collapse;font-size:inherit}thead{display:table-header-group}tr{break-inside:avoid}
|
||||
th,td{border:1px solid #777;padding:1.5mm;vertical-align:top;overflow-wrap:anywhere}th{background:#eee}
|
||||
@@ -98,7 +113,7 @@ const EstReport = (() => {
|
||||
.document-text{white-space:pre-wrap;line-height:1.7;min-height:40mm}.page{break-after:page}.page:last-child{break-after:auto}.footer{text-align:right;margin-top:3mm}
|
||||
</style></head><body><h1>${esc(state.project.name)}</h1><h2>${esc(title)}</h2>
|
||||
${detail ? `<h2>${esc(detail)}</h2>` : ""}${totals ? `<div class="summary">순공사비 ${number(totals.direct?.TC)}원 · 도급액 ${number(totals.total)}원</div>` : ""}
|
||||
${paginate(root, settings)}${errors.length ? `<p class="errors">계산 오류 ${errors.length}건: ${esc(errors.join(" / "))}</p>` : ""}</body></html>`;
|
||||
${paginate(root, settings)}${output && settings.notes ? `<div class="document-text">${esc(settings.notes)}</div>` : ""}${errors.length ? `<p class="errors">계산 오류 ${errors.length}건: ${esc(errors.join(" / "))}</p>` : ""}</body></html>`;
|
||||
EstApp.byId("print-frame").srcdoc = source;
|
||||
}
|
||||
function paginate(root, settings) {
|
||||
@@ -127,6 +142,25 @@ const EstReport = (() => {
|
||||
...row,
|
||||
...original.get(row.Index),
|
||||
}));
|
||||
catalog.push(
|
||||
{ Index: "progress-summary", Name: "기성 내역서", Name2: "기성 내역서" },
|
||||
{ Index: "progress-costs", Name: "기성 원가계산서", Name2: "기성 원가계산서" },
|
||||
{ Index: "escalation-summary", Name: "물가변동 내역서", Name2: "물가변동 내역서" },
|
||||
{ Index: "escalation-costs", Name: "물가변동 원가계산서", Name2: "물가변동 원가계산서" },
|
||||
{ Index: "annual-summary", Name: "연차 배분표", Name2: "연차 배분표" },
|
||||
{ Index: "annual-items", Name: "연차별 내역서", Name2: "연차별 내역서" },
|
||||
{ Index: "change-costs", Name: "설계변경 원가 증감표", Name2: "설계변경 원가 증감표" },
|
||||
{ Index: "standard-usage", Name: "표준시장단가 사용 내역", Name2: "표준시장단가 사용 내역" },
|
||||
{ Index: "standard-change", Name: "표준시장단가 설계변경", Name2: "표준시장단가 설계변경" },
|
||||
{ Index: "estimate-usage", Name: "견적단가 사용 내역", Name2: "견적단가 사용 내역" },
|
||||
{ Index: "estimate-change", Name: "견적단가 설계변경", Name2: "견적단가 설계변경" },
|
||||
{ Index: "material-usage", Name: "자재단가 사용 내역", Name2: "자재단가 사용 내역" },
|
||||
{ Index: "material-change", Name: "자재단가 설계변경", Name2: "자재단가 설계변경" },
|
||||
{ Index: "labor-usage", Name: "노임단가 사용 내역", Name2: "노임단가 사용 내역" },
|
||||
{ Index: "labor-change", Name: "노임단가 설계변경", Name2: "노임단가 설계변경" },
|
||||
{ Index: "machine-usage", Name: "기계경비 사용 내역", Name2: "기계경비 사용 내역" },
|
||||
{ Index: "machine-change", Name: "기계경비 설계변경", Name2: "기계경비 설계변경" },
|
||||
);
|
||||
const table = EstApp.state.project.tables[EstApp.state.view];
|
||||
const s = setting(),
|
||||
{ esc } = EstApp;
|
||||
@@ -143,7 +177,10 @@ const EstReport = (() => {
|
||||
<label>용지 <select id="print-paper">${["A4", "A3"].map((v) => `<option ${v === s.paper ? "selected" : ""}>${v}</option>`).join("")}</select></label>
|
||||
<label>방향 <select id="print-orientation"><option value="landscape" ${s.orientation === "landscape" ? "selected" : ""}>가로</option><option value="portrait" ${s.orientation === "portrait" ? "selected" : ""}>세로</option></select></label>
|
||||
${[
|
||||
["margin", "여백(mm)", 0, 50],
|
||||
["marginTop", "위 여백(mm)", 0, 50],
|
||||
["marginBottom", "아래 여백(mm)", 0, 50],
|
||||
["marginLeft", "왼쪽 여백(mm)", 0, 50],
|
||||
["marginRight", "오른쪽 여백(mm)", 0, 50],
|
||||
["font", "글자(pt)", 6, 20],
|
||||
["rowsPerPage", "페이지당 행", 1, 100],
|
||||
["startPage", "시작 쪽", 1, 99999],
|
||||
@@ -153,18 +190,21 @@ const EstReport = (() => {
|
||||
`<label>${label}<input data-print="${key}" type="number" min="${min}" max="${max}" value="${s[key]}" style="width:65px"></label>`,
|
||||
)
|
||||
.join("")}
|
||||
<label id="print-gong-wrap" ${["115", "116", "117", "118"].includes(String(s.form)) ? "" : "hidden"}>공람용 방법 <select id="print-gong"><option value="0" ${Number(s.PrintGongMethod) === 0 ? "selected" : ""}>수량만</option><option value="1" ${Number(s.PrintGongMethod) === 1 ? "selected" : ""}>단가만</option><option value="2" ${Number(s.PrintGongMethod) === 2 ? "selected" : ""}>수량·단가</option></select></label>
|
||||
<label>업체명<input data-print="company" value="${esc(s.company)}"></label>
|
||||
${table ? '<label>출력 범위 <select id="print-scope"><option value="current">현재 호표·검색 결과 전체 행</option><option value="all">현재 자료의 모든 호표·행</option></select></label>' : ""}
|
||||
<button data-action="print-document">인쇄 / PDF 저장</button><span id="print-error" role="status"></span></div>
|
||||
<button data-action="print-document">인쇄 / PDF 저장</button><button data-action="export-report-xlsx">서식 Excel 저장</button><span id="print-error" role="status"></span></div>
|
||||
<label>문서 본문(표지·갑지·설명서·시방서·심사·공정 문서)<textarea data-print="notes" rows="3" style="width:100%">${esc(s.notes)}</textarea></label>
|
||||
<iframe id="print-frame" title="인쇄할 문서" style="width:100%;height:65vh;border:1px solid #aaa"></iframe>`,
|
||||
);
|
||||
EstApp.byId("dialog-content")
|
||||
.querySelectorAll("[data-print],#print-paper,#print-orientation,#print-form")
|
||||
.querySelectorAll("[data-print],#print-paper,#print-orientation,#print-form,#print-gong")
|
||||
.forEach((node) =>
|
||||
node.addEventListener("change", () => {
|
||||
const key = node.dataset.print || node.id.slice(6),
|
||||
value = node.type === "number" ? Number(node.value) : node.value;
|
||||
const key =
|
||||
node.id === "print-gong" ? "PrintGongMethod" : node.dataset.print || node.id.slice(6),
|
||||
value =
|
||||
node.type === "number" || node.id === "print-gong" ? Number(node.value) : node.value;
|
||||
try {
|
||||
if (
|
||||
node.type === "number" &&
|
||||
@@ -187,8 +227,15 @@ const EstReport = (() => {
|
||||
1,
|
||||
Math.min(100, Number(original.PrintLineCount) || 30),
|
||||
);
|
||||
if (Number.isFinite(original.PrintLeft))
|
||||
next.margin = Math.max(0, Math.min(50, original.PrintLeft));
|
||||
for (const side of ["Top", "Bottom", "Left", "Right"])
|
||||
if (Number.isFinite(original[`Print${side}`]))
|
||||
next[`margin${side}`] = Math.max(0, Math.min(50, original[`Print${side}`]));
|
||||
if (Number.isFinite(original.PrintGongMethod))
|
||||
next.PrintGongMethod = Number(original.PrintGongMethod);
|
||||
if (Number.isFinite(original.PrintApplyField))
|
||||
next.PrintApplyField = Number(original.PrintApplyField);
|
||||
if (Number.isFinite(original.PrintSumBottom))
|
||||
next.PrintSumBottom = Number(original.PrintSumBottom);
|
||||
}
|
||||
}
|
||||
if (key === "notes") {
|
||||
@@ -196,16 +243,24 @@ const EstReport = (() => {
|
||||
EstApp.state.project.documents[next.form] = value;
|
||||
}
|
||||
if (
|
||||
["margin", "rowsPerPage"].includes(key) &&
|
||||
[
|
||||
"marginTop",
|
||||
"marginBottom",
|
||||
"marginLeft",
|
||||
"marginRight",
|
||||
"rowsPerPage",
|
||||
"PrintGongMethod",
|
||||
"PrintApplyField",
|
||||
"PrintSumBottom",
|
||||
].includes(key) &&
|
||||
next.form !== "current" &&
|
||||
EstApp.state.project.printCatalog?.some((row) => String(row.Index) === next.form)
|
||||
) {
|
||||
EstApp.state.project.nativePrintChanges ||= {};
|
||||
const changes = (EstApp.state.project.nativePrintChanges[next.form] ||= {});
|
||||
if (key === "margin")
|
||||
for (const side of ["PrintTop", "PrintBottom", "PrintLeft", "PrintRight"])
|
||||
changes[side] = value;
|
||||
else changes.PrintLineCount = value;
|
||||
if (key.startsWith("margin")) changes[`Print${key.slice(6)}`] = value;
|
||||
else if (key === "rowsPerPage") changes.PrintLineCount = value;
|
||||
else changes[key] = value;
|
||||
}
|
||||
if (key === "form") next.notes = EstApp.state.project.documents?.[value] || "";
|
||||
EstApp.state.project.printSettings = next;
|
||||
@@ -213,8 +268,20 @@ const EstReport = (() => {
|
||||
if (key === "form") {
|
||||
const content = EstApp.byId("dialog-content"),
|
||||
current = EstApp.state.project.printSettings;
|
||||
for (const name of ["notes", "margin", "rowsPerPage"])
|
||||
for (const name of [
|
||||
"notes",
|
||||
"marginTop",
|
||||
"marginBottom",
|
||||
"marginLeft",
|
||||
"marginRight",
|
||||
"rowsPerPage",
|
||||
])
|
||||
content.querySelector(`[data-print=${name}]`).value = current[name];
|
||||
const gongWrap = content.querySelector("#print-gong-wrap");
|
||||
if (gongWrap) {
|
||||
gongWrap.hidden = !["115", "116", "117", "118"].includes(String(current.form));
|
||||
content.querySelector("#print-gong").value = String(current.PrintGongMethod ?? 0);
|
||||
}
|
||||
}
|
||||
preview(EstApp.byId("print-scope")?.value === "all");
|
||||
EstApp.byId("print-error").textContent = "";
|
||||
@@ -235,5 +302,51 @@ const EstReport = (() => {
|
||||
frame.contentWindow.focus();
|
||||
frame.contentWindow.print();
|
||||
}
|
||||
return { open, preview, print };
|
||||
async function exportExcelReport() {
|
||||
const { state } = EstApp;
|
||||
const form = catalog.find((row) => String(row.Index) === setting().form);
|
||||
const output = form && EstForms.data(form.Index, state.project, state);
|
||||
if (!output) throw Error("표 형식의 인쇄 서식을 선택하세요.");
|
||||
if (!output.rows.length) throw Error("출력할 행이 없습니다.");
|
||||
const used = new Set();
|
||||
const columns = output.columns.map((key) => {
|
||||
const base = EstForms.label(key);
|
||||
let name = base,
|
||||
suffix = 2;
|
||||
while (used.has(name)) name = `${base} (${suffix++})`;
|
||||
used.add(name);
|
||||
return name;
|
||||
});
|
||||
const rows = output.rows.map((row) =>
|
||||
output.columns.map((key) =>
|
||||
key === "LineData" ? EstText.clean(row[key] || "") : (row[key] ?? ""),
|
||||
),
|
||||
);
|
||||
const response = await fetch("/api/export-report-xlsx", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: state.project.name,
|
||||
columns,
|
||||
rows,
|
||||
detail: [output.detail, setting().notes].filter(Boolean).join("\n"),
|
||||
errors: output.errors || [],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw Error((await response.json()).error);
|
||||
EstApp.download(
|
||||
`${state.project.name} - ${form.Name}.xlsx`,
|
||||
await response.blob(),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
);
|
||||
}
|
||||
async function exportExcel() {
|
||||
try {
|
||||
await exportExcelReport();
|
||||
EstApp.byId("print-error").textContent = "";
|
||||
} catch (error) {
|
||||
EstApp.byId("print-error").textContent = error.message;
|
||||
}
|
||||
}
|
||||
return { open, preview, print, exportExcel };
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// 계약심사 입력은 작업본에 보관하고, 원래 내역은 사본을 열 때까지 유지한다.
|
||||
const EstReview = (() => {
|
||||
const fields = ["GN", "NU", "GU", "JU", "SU", "NC", "GC", "JC", "SC"];
|
||||
const special = new Set([51, 52, 54, 55, 57, 58, 59, 62, 67, 68]);
|
||||
const editable = (row) => !EstMath.num(row.Level) && !special.has(EstMath.num(row.LinkIndex));
|
||||
const fixed = (row) => [1, 3].includes(EstMath.num(row.CalcIndex));
|
||||
const allowed = (row) => ["GN", ...(fixed(row) ? EstMath.components : EstMath.units)];
|
||||
const rowKey = (row, index) => `row:${EstBusiness.rowKey(row, index)}`;
|
||||
|
||||
function validate(project) {
|
||||
const review = project.contractReview;
|
||||
if (review == null) return;
|
||||
if (
|
||||
typeof review !== "object" ||
|
||||
Array.isArray(review) ||
|
||||
!review.edits ||
|
||||
typeof review.edits !== "object" ||
|
||||
Array.isArray(review.edits)
|
||||
)
|
||||
throw Error("계약심사 자료 형식 오류");
|
||||
if (Object.keys(review.edits).length > 100000) throw Error("계약심사 항목 수 초과");
|
||||
for (const [key, edit] of Object.entries(review.edits)) {
|
||||
if (!key || key.length > 1000 || !edit || typeof edit !== "object" || Array.isArray(edit))
|
||||
throw Error("계약심사 입력 형식 오류");
|
||||
for (const [field, value] of Object.entries(edit)) {
|
||||
if (field === "Reason") {
|
||||
if (typeof value !== "string" || value.length > 10000)
|
||||
throw Error("계약심사 사유 형식 오류");
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!fields.includes(field) ||
|
||||
typeof value !== "number" ||
|
||||
!Number.isFinite(value) ||
|
||||
Math.abs(value) > Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
throw Error(`계약심사 입력 오류: ${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function calculate(project) {
|
||||
validate(project);
|
||||
const edits = project.contractReview?.edits || {},
|
||||
errors = [],
|
||||
seen = new Set();
|
||||
const working = EstNative.empty();
|
||||
working.options = { ...project.options, SourceRefresh: false, TotalFix: false };
|
||||
working.tables.EstMainDat = structuredClone(project.tables.EstMainDat);
|
||||
working.tables.Wonga = structuredClone(project.tables.Wonga);
|
||||
const baseline = EstNative.calculate(working);
|
||||
const counts = new Map();
|
||||
working.tables.EstMainDat.forEach((row, index) => {
|
||||
if (!editable(row)) return;
|
||||
const key = rowKey(row, index);
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
});
|
||||
working.tables.EstMainDat.forEach((row, index) => {
|
||||
if (!editable(row)) return;
|
||||
const key = rowKey(row, index);
|
||||
if (counts.get(key) > 1) errors.push(`심사 내역 식별자 중복: ${key}`);
|
||||
seen.add(key);
|
||||
if (
|
||||
fixed(row) &&
|
||||
EstMath.even(EstMath.num(row.TC) * 10000) !== EstMath.even(EstMath.sum(row) * 10000)
|
||||
)
|
||||
errors.push(
|
||||
`${row.Name || key}: 고정 금액과 비목 합계가 달라 심사 합계를 계산할 수 없습니다.`,
|
||||
);
|
||||
const edit = edits[key];
|
||||
if (!edit || counts.get(key) > 1) return;
|
||||
for (const field of Object.keys(edit)) {
|
||||
if (field === "Reason") continue;
|
||||
if (!allowed(row).includes(field))
|
||||
errors.push(`${row.Name || key}: 심사할 수 없는 항목 ${field}`);
|
||||
else row[field] = edit[field];
|
||||
}
|
||||
if (fixed(row) && EstMath.components.some((field) => Object.hasOwn(edit, field)))
|
||||
row.TC = EstMath.sum(row);
|
||||
});
|
||||
for (const key of Object.keys(edits))
|
||||
if (!seen.has(key)) errors.push(`심사 대상 내역 없음: ${key}`);
|
||||
const reviewed = EstNative.calculate(working);
|
||||
errors.push(...baseline.calculation.errors, ...reviewed.calculation.errors);
|
||||
const total = (p) =>
|
||||
p.tables.Wonga.length
|
||||
? EstMath.num(p.calculation.total)
|
||||
: EstMath.num(p.calculation.direct.TC);
|
||||
return {
|
||||
baseline,
|
||||
reviewed,
|
||||
before: total(baseline),
|
||||
after: total(reviewed),
|
||||
errors: [...new Set(errors)],
|
||||
};
|
||||
}
|
||||
function copy(project) {
|
||||
const output = calculate(project);
|
||||
if (output.errors.length) throw Error(output.errors.join(" / "));
|
||||
const next = structuredClone(project);
|
||||
delete next.id;
|
||||
delete next.contractReview;
|
||||
next.name = `${project.name} · 심사 내역`;
|
||||
next.tables.EstMainDat = output.reviewed.tables.EstMainDat;
|
||||
next.tables.Wonga = output.reviewed.tables.Wonga;
|
||||
next.options.SourceRefresh = false;
|
||||
next.calculation = output.reviewed.calculation;
|
||||
return next;
|
||||
}
|
||||
function render(state) {
|
||||
const { esc, number, byId } = EstApp,
|
||||
project = state.project,
|
||||
output = calculate(project);
|
||||
const edits = project.contractReview?.edits || {};
|
||||
const rows = output.baseline.tables.EstMainDat.map((row, index) => ({ row, index })).filter(
|
||||
({ row }) => editable(row) && (row.Name || row.Code),
|
||||
);
|
||||
byId("work-content").innerHTML =
|
||||
`<div class="business-tools"><b>계약심사</b><button data-review-copy>심사 내역 사본 열기</button><button data-review-clear>심사 입력 초기화</button><label>보기 <select id="review-filter"><option value="all" ${state.reviewFilter === "all" || !state.reviewFilter ? "selected" : ""}>전체 항목</option><option value="changed" ${state.reviewFilter === "changed" ? "selected" : ""}>변경 항목만</option><option value="has-reason" ${state.reviewFilter === "has-reason" ? "selected" : ""}>사유 작성 항목</option><option value="no-reason" ${state.reviewFilter === "no-reason" ? "selected" : ""}>사유 미작성 변경항목</option></select></label><button data-review-batch-reason>선택/표시 행 사유 일괄적용</button><span>기존 ${number(output.before)} · 심사 ${number(output.after)} · 차액 ${number(output.after - output.before)}</span></div><p>심사 수량과 비목별 단가를 직접 입력합니다. 고정금액 행은 비목별 금액을 입력합니다. 원래 내역은 유지되며 심사 사본에서 변경됩니다.</p><label>주요심사 내용<textarea data-review-summary rows="4" maxlength="100000" style="width:100%">${esc(project.documents?.["173"] || "")}</textarea></label><p class="business-note">${esc(output.errors.join(" / "))}</p><table class="sheet"><thead><tr>${["선택", "코드", "품명", "기존 수량", "심사 수량", "노무", "재료", "경비", "실적", "기존 금액", "심사 금액", "차액", "심사 사유", "작업"].map((value) => `<th>${value}</th>`).join("")}</tr></thead><tbody>${rows
|
||||
.filter(({ row, index }) => {
|
||||
const key = rowKey(row, index),
|
||||
input = edits[key] || {},
|
||||
changed = output.reviewed.tables.EstMainDat[index],
|
||||
isChanged =
|
||||
Object.hasOwn(edits, key) ||
|
||||
EstMath.num(changed.TC) !== EstMath.num(row.TC) ||
|
||||
EstMath.num(changed.GN) !== EstMath.num(row.GN),
|
||||
hasReason = Boolean(input.Reason && input.Reason.trim());
|
||||
const filter = state.reviewFilter || "all";
|
||||
if (filter === "changed") return isChanged;
|
||||
if (filter === "has-reason") return hasReason;
|
||||
if (filter === "no-reason") return isChanged && !hasReason;
|
||||
return true;
|
||||
})
|
||||
.map(({ row, index }) => {
|
||||
const key = rowKey(row, index),
|
||||
input = edits[key] || {},
|
||||
changed = output.reviewed.tables.EstMainDat[index],
|
||||
isSelected = state.reviewSelection?.has(key);
|
||||
const cell = (field) =>
|
||||
`<input data-review="${field}" data-key="${esc(key)}" type="number" step="any" value="${esc(input[field] ?? EstMath.num(row[field]))}" aria-label="심사 ${esc(field)}">`;
|
||||
return `<tr class="${isSelected ? "selected" : ""}"><td><input type="checkbox" data-review-select="${esc(key)}" ${isSelected ? "checked" : ""} aria-label="선택"></td><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${number(row.GN)}</td><td>${cell("GN")}</td>${allowed(
|
||||
row,
|
||||
)
|
||||
.slice(1)
|
||||
.map((field) => `<td>${cell(field)}</td>`)
|
||||
.join(
|
||||
"",
|
||||
)}<td>${number(row.TC)}</td><td>${number(changed.TC)}</td><td>${number(EstMath.num(changed.TC) - EstMath.num(row.TC))}</td><td><input data-review="Reason" data-key="${esc(key)}" value="${esc(input.Reason || "")}" maxlength="10000" aria-label="심사 사유"></td><td><button data-review-reset="${esc(key)}" ${Object.hasOwn(edits, key) ? "" : "disabled"}>행 초기화</button></td></tr>`;
|
||||
})
|
||||
.join("")}</tbody></table>`;
|
||||
}
|
||||
function install() {
|
||||
document.addEventListener("change", (event) => {
|
||||
const node = event.target;
|
||||
if (node.id === "review-filter") {
|
||||
EstApp.state.reviewFilter = node.value;
|
||||
EstApp.render();
|
||||
return;
|
||||
}
|
||||
if (node.dataset.reviewSelect !== undefined) {
|
||||
const key = node.dataset.reviewSelect;
|
||||
const sel = (EstApp.state.reviewSelection ||= new Set());
|
||||
if (node.checked) sel.add(key);
|
||||
else sel.delete(key);
|
||||
EstApp.render();
|
||||
return;
|
||||
}
|
||||
if (node.dataset.reviewSummary !== undefined) {
|
||||
EstApp.change(() => {
|
||||
const project = EstApp.state.project;
|
||||
(project.documents ||= {})["173"] = node.value;
|
||||
if (String(project.printSettings?.form) === "173")
|
||||
project.printSettings.notes = node.value;
|
||||
}, false);
|
||||
return;
|
||||
}
|
||||
if (!node.dataset.review) return;
|
||||
const field = node.dataset.review,
|
||||
value = field === "Reason" ? node.value : Number(node.value),
|
||||
key = node.dataset.key;
|
||||
const matches = EstApp.state.project.tables.EstMainDat.filter(
|
||||
(row, index) => editable(row) && rowKey(row, index) === key,
|
||||
);
|
||||
if (matches.length !== 1) {
|
||||
EstApp.state.notice = "심사 내역 식별자가 없거나 중복됩니다.";
|
||||
EstApp.render();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
field === "Reason"
|
||||
? value.length > 10000
|
||||
: !Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER
|
||||
) {
|
||||
EstApp.state.notice = "유효한 심사 수치를 입력하세요.";
|
||||
EstApp.render();
|
||||
return;
|
||||
}
|
||||
EstApp.change(() => {
|
||||
const review = (EstApp.state.project.contractReview ||= { edits: {} });
|
||||
(review.edits[key] ||= {})[field] = value;
|
||||
}, false);
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
const project = EstApp.state.project;
|
||||
try {
|
||||
const reset = event.target.closest("[data-review-reset]");
|
||||
if (reset && project.contractReview?.edits) {
|
||||
const key = reset.dataset.reviewReset;
|
||||
if (Object.hasOwn(project.contractReview.edits, key))
|
||||
EstApp.change(() => {
|
||||
delete project.contractReview.edits[key];
|
||||
if (!Object.keys(project.contractReview.edits).length) delete project.contractReview;
|
||||
}, false);
|
||||
} else if (event.target.closest("[data-review-copy]")) {
|
||||
const next = copy(project);
|
||||
if (confirm("현재 작업 대신 심사 내역 사본을 여시겠습니까?")) {
|
||||
EstApp.setProject(next);
|
||||
EstApp.navigate("EstMainDat");
|
||||
}
|
||||
} else if (
|
||||
event.target.closest("[data-review-clear]") &&
|
||||
confirm("심사 입력을 초기화하시겠습니까?")
|
||||
) {
|
||||
EstApp.change(() => delete project.contractReview, false);
|
||||
EstApp.state.reviewSelection?.clear();
|
||||
} else if (event.target.closest("[data-review-batch-reason]")) {
|
||||
const reason = prompt("적용할 심사 사유를 입력하세요:");
|
||||
if (reason != null && reason.trim()) {
|
||||
const val = reason.trim().slice(0, 10000);
|
||||
const sel = EstApp.state.reviewSelection;
|
||||
const targetKeys =
|
||||
sel && sel.size > 0
|
||||
? [...sel]
|
||||
: [
|
||||
...document.querySelectorAll(
|
||||
'#work-content input[data-review="Reason"][data-key]',
|
||||
),
|
||||
].map((node) => node.dataset.key);
|
||||
if (!targetKeys.length) {
|
||||
EstApp.state.notice = "사유를 적용할 대상 행이 없습니다.";
|
||||
EstApp.render();
|
||||
return;
|
||||
}
|
||||
EstApp.change(() => {
|
||||
const review = (EstApp.state.project.contractReview ||= { edits: {} });
|
||||
for (const k of targetKeys) {
|
||||
(review.edits[k] ||= {}).Reason = val;
|
||||
}
|
||||
}, false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
EstApp.state.notice = error.message;
|
||||
EstApp.render();
|
||||
}
|
||||
});
|
||||
}
|
||||
return { validate, calculate, copy, rowKey, editable, allowed, render, install };
|
||||
})();
|
||||
@@ -82,10 +82,11 @@ const EstRows = (() => {
|
||||
);
|
||||
return amounts(row, digits);
|
||||
}
|
||||
function head(rows, options = {}, resolve = () => null) {
|
||||
function head(rows, options = {}, resolve = () => null, refreshRows = []) {
|
||||
const output = structuredClone(rows),
|
||||
titles = [],
|
||||
errors = [];
|
||||
errors = [],
|
||||
refreshErrors = [];
|
||||
let current = null,
|
||||
body = [];
|
||||
function finish() {
|
||||
@@ -93,7 +94,7 @@ const EstRows = (() => {
|
||||
Object.assign(current, add(body));
|
||||
titles.push({ ...current });
|
||||
}
|
||||
for (const row of output) {
|
||||
for (const [index, row] of output.entries()) {
|
||||
if (num(row.Index) > 0) {
|
||||
finish();
|
||||
current = row;
|
||||
@@ -119,12 +120,15 @@ const EstRows = (() => {
|
||||
body = [row];
|
||||
continue;
|
||||
} else {
|
||||
if (options.SourceRefresh && ![1, 3].includes(num(row.CalcIndex))) {
|
||||
const requested = refreshRows.includes(index);
|
||||
if ((options.SourceRefresh || requested) && ![1, 3].includes(num(row.CalcIndex))) {
|
||||
try {
|
||||
const price = resolve(row);
|
||||
if (requested && !price) throw Error("원천 단가를 찾을 수 없습니다.");
|
||||
if (price) units.forEach((key, i) => (row[key] = num(price[components[i]])));
|
||||
} catch (error) {
|
||||
errors.push(`${current.Name}: ${error.message}`);
|
||||
if (requested) refreshErrors.push(error.message);
|
||||
}
|
||||
}
|
||||
amounts(row, options.HeadDigits || 0, options.TotalFit);
|
||||
@@ -132,15 +136,18 @@ const EstRows = (() => {
|
||||
body.push(row);
|
||||
}
|
||||
finish();
|
||||
return { rows: output, titles, errors };
|
||||
return refreshRows.length
|
||||
? { rows: output, titles, errors, refreshErrors }
|
||||
: { rows: output, titles, errors };
|
||||
}
|
||||
function estimate(rows, options = {}, resolve = () => null) {
|
||||
function estimate(rows, options = {}, resolve = () => null, refreshRows = []) {
|
||||
const output = structuredClone(rows),
|
||||
errors = [];
|
||||
errors = [],
|
||||
refreshErrors = [];
|
||||
let segment = [],
|
||||
prior = [],
|
||||
boundary = false;
|
||||
for (const row of output) {
|
||||
for (const [index, row] of output.entries()) {
|
||||
const level = num(row.Level),
|
||||
link = num(row.LinkIndex);
|
||||
if (level > 0) {
|
||||
@@ -160,12 +167,15 @@ const EstRows = (() => {
|
||||
if (link === 57) procurement(row, segment);
|
||||
else if ([62, 67, 58].includes(link)) percent(row, segment, options.EstDigits || 0);
|
||||
else {
|
||||
if (options.SourceRefresh && ![1, 3].includes(num(row.CalcIndex))) {
|
||||
const requested = refreshRows.includes(index);
|
||||
if ((options.SourceRefresh || requested) && ![1, 3].includes(num(row.CalcIndex))) {
|
||||
try {
|
||||
const price = resolve(row);
|
||||
if (requested && !price) throw Error("원천 단가를 찾을 수 없습니다.");
|
||||
if (price) units.forEach((key, i) => (row[key] = num(price[components[i]])));
|
||||
} catch (error) {
|
||||
errors.push(`${row.Name}: ${error.message}`);
|
||||
if (requested) refreshErrors.push(error.message);
|
||||
}
|
||||
}
|
||||
amounts(row, options.EstDigits || 0, options.TotalFit);
|
||||
@@ -186,7 +196,9 @@ const EstRows = (() => {
|
||||
Object.assign(row, add(children));
|
||||
}
|
||||
const direct = output.find((r) => num(r.LinkIndex) === 54) || add(prior);
|
||||
return { rows: output, direct, errors };
|
||||
return refreshRows.length
|
||||
? { rows: output, direct, errors, refreshErrors }
|
||||
: { rows: output, direct, errors };
|
||||
}
|
||||
return { head, estimate, percent, procurement, subtotal, baseKind };
|
||||
})();
|
||||
|
||||
@@ -16,6 +16,11 @@ const EstSchedule = (() => {
|
||||
if (!row || typeof row.id !== "string" || !row.id || ids.has(row.id))
|
||||
throw Error("공정 항목 ID 오류");
|
||||
ids.add(row.id);
|
||||
if (
|
||||
row.SourceKey != null &&
|
||||
(typeof row.SourceKey !== "string" || row.SourceKey.length > 1000)
|
||||
)
|
||||
throw Error("공정 내역 연결 오류");
|
||||
for (const key of ["Code", "Name", "start", "end"])
|
||||
if (typeof row[key] !== "string" || row[key].length > 1000) throw Error("공정 문자열 오류");
|
||||
for (const key of ["weight", "progress"])
|
||||
@@ -53,33 +58,156 @@ const EstSchedule = (() => {
|
||||
end: dated.length ? new Date(last * 86400000).toISOString().slice(0, 10) : "",
|
||||
};
|
||||
}
|
||||
function estimateRows(project) {
|
||||
const rows = new Map(),
|
||||
codes = new Map();
|
||||
for (const row of project.tables.EstMainDat) {
|
||||
if (
|
||||
Number(row.Level) > 0 ||
|
||||
Number(row.CalcIndex) >= 2 ||
|
||||
[54, 55, 58, 59].includes(Number(row.LinkIndex))
|
||||
)
|
||||
continue;
|
||||
const key = row.PkID != null ? `id:${row.PkID}` : row.Code ? `code:${row.Code}` : "";
|
||||
if (!key) continue;
|
||||
if (rows.has(key)) throw Error(`내역 행 식별자 중복: ${key}`);
|
||||
rows.set(key, row);
|
||||
if (row.Code) {
|
||||
const matches = codes.get(row.Code) || [];
|
||||
matches.push(key);
|
||||
codes.set(row.Code, matches);
|
||||
}
|
||||
}
|
||||
function sourceKey(row) {
|
||||
if (row.SourceKey) return rows.has(row.SourceKey) ? row.SourceKey : "";
|
||||
const matches = codes.get(row.Code) || [];
|
||||
if (matches.length > 1) throw Error(`기존 공정의 내역코드 중복: ${row.Code}`);
|
||||
return matches[0] || "";
|
||||
}
|
||||
return { rows, sourceKey };
|
||||
}
|
||||
function fromEstimate(project, makeId) {
|
||||
const { rows, sourceKey } = estimateRows(project),
|
||||
existing = new Set((project.schedule || []).map(sourceKey).filter(Boolean));
|
||||
return [...rows.entries()]
|
||||
.filter(([key]) => !existing.has(key))
|
||||
.map(([key, row]) => ({
|
||||
id: makeId(),
|
||||
SourceKey: key,
|
||||
Code: String(row.Code || ""),
|
||||
Name: String(row.Name || ""),
|
||||
start: "",
|
||||
end: "",
|
||||
weight: Math.max(0, Number(row.TC) || 0),
|
||||
progress: 0,
|
||||
}));
|
||||
}
|
||||
function refreshWeights(project) {
|
||||
const { rows, sourceKey } = estimateRows(project);
|
||||
return (project.schedule || []).map((row) => {
|
||||
const key = sourceKey(row);
|
||||
return { ...row, weight: key ? Math.max(0, Number(rows.get(key).TC) || 0) : row.weight };
|
||||
});
|
||||
}
|
||||
function refreshProgress(project) {
|
||||
const round = (project.progress || []).findLast((item) => item.locked && item.result);
|
||||
if (!round) throw Error("확정된 기성 회차가 없습니다.");
|
||||
const { sourceKey } = estimateRows(project),
|
||||
saved = new Map();
|
||||
for (const row of round.result.rows) {
|
||||
if (
|
||||
Number(row.Level) > 0 ||
|
||||
Number(row.CalcIndex) >= 2 ||
|
||||
[54, 55, 58, 59].includes(Number(row.LinkIndex))
|
||||
)
|
||||
continue;
|
||||
const key = row.PkID != null ? `id:${row.PkID}` : row.Code ? `code:${row.Code}` : "";
|
||||
if (!key) continue;
|
||||
if (saved.has(key)) throw Error(`기성 내역 식별자 중복: ${key}`);
|
||||
saved.set(key, row);
|
||||
}
|
||||
return (project.schedule || []).map((entry) => {
|
||||
const row = saved.get(sourceKey(entry));
|
||||
if (!row || !Number(row.GN)) return { ...entry };
|
||||
const progress = (Number(row.TotalGN) / Number(row.GN)) * 100;
|
||||
if (!Number.isFinite(progress) || progress < 0 || progress > 100)
|
||||
throw Error(`${entry.Name || entry.Code}: 기성 누계 진도가 0~100% 범위를 벗어났습니다.`);
|
||||
return { ...entry, progress };
|
||||
});
|
||||
}
|
||||
function moveRow(project, index, direction) {
|
||||
if (!project.schedule || !Array.isArray(project.schedule)) return;
|
||||
const target = index + direction;
|
||||
if (
|
||||
index < 0 ||
|
||||
index >= project.schedule.length ||
|
||||
target < 0 ||
|
||||
target >= project.schedule.length
|
||||
)
|
||||
return;
|
||||
const temp = project.schedule[index];
|
||||
project.schedule[index] = project.schedule[target];
|
||||
project.schedule[target] = temp;
|
||||
}
|
||||
function clearSchedule(project) {
|
||||
project.schedule = [];
|
||||
}
|
||||
function render() {
|
||||
const { esc, number, state } = EstApp,
|
||||
output = calculate(state.project);
|
||||
const rowsCount = output.rows.length;
|
||||
EstApp.byId("work-content").innerHTML =
|
||||
`<div class="business-tools"><button data-action="schedule-add">공정 추가</button><span>시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%</span></div><p>기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.</p><table class="sheet"><thead><tr>${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => `<th>${h}</th>`).join("")}</tr></thead><tbody>${output.rows.map((r, i) => `<tr>${["Code", "Name", "start", "end", "weight", "progress"].map((k) => `<td><input data-schedule="${k}" data-index="${i}" type="${["start", "end"].includes(k) ? "date" : ["weight", "progress"].includes(k) ? "number" : "text"}" value="${esc(r[k])}" step="any"></td>`).join("")}<td style="min-width:180px"><div style="height:12px;background:#ddd"><div style="margin-left:${r.left}%;width:${r.width}%;height:12px;background:#316ea5"></div></div></td><td><button data-action="schedule-delete" data-index="${i}">삭제</button></td></tr>`).join("")}</tbody></table>`;
|
||||
`<div class="business-tools"><button data-action="schedule-add">공정 추가</button><button data-action="schedule-import">내역 일괄 추가</button><button data-action="schedule-weights">내역 금액으로 가중치 갱신</button><button data-action="schedule-progress">최근 확정 기성으로 진도 갱신</button><button data-action="schedule-clear" ${rowsCount === 0 ? "disabled" : ""}>공정 전체 비우기</button><span>시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%</span></div><p>기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.</p><table class="sheet"><thead><tr>${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => `<th>${h}</th>`).join("")}</tr></thead><tbody>${output.rows.map((r, i) => `<tr>${["Code", "Name", "start", "end", "weight", "progress"].map((k) => `<td><input data-schedule="${k}" data-index="${i}" type="${["start", "end"].includes(k) ? "date" : ["weight", "progress"].includes(k) ? "number" : "text"}" value="${esc(r[k])}" step="any"></td>`).join("")}<td style="min-width:180px"><div style="height:12px;background:#ddd"><div style="margin-left:${r.left}%;width:${r.width}%;height:12px;background:#316ea5"></div></div></td><td><button data-action="schedule-move" data-index="${i}" data-direction="-1" ${i === 0 ? "disabled" : ""}>↑</button><button data-action="schedule-move" data-index="${i}" data-direction="1" ${i === rowsCount - 1 ? "disabled" : ""}>↓</button><button data-action="schedule-delete" data-index="${i}">삭제</button></td></tr>`).join("")}</tbody></table>`;
|
||||
}
|
||||
function install() {
|
||||
document.addEventListener("click", (event) => {
|
||||
const node = event.target.closest("[data-action]");
|
||||
if (!node) return;
|
||||
const p = EstApp.state.project;
|
||||
if (node.dataset.action === "schedule-add")
|
||||
EstApp.change(() => {
|
||||
p.schedule ||= [];
|
||||
const row = p.tables.EstMainDat.find(
|
||||
(r) => r.Code && !Number(r.Level) && !p.schedule.some((s) => s.Code === r.Code),
|
||||
);
|
||||
p.schedule.push({
|
||||
id: crypto.randomUUID(),
|
||||
Code: row?.Code || "",
|
||||
Name: row?.Name || "새 공정",
|
||||
if (
|
||||
["schedule-import", "schedule-weights", "schedule-progress"].includes(node.dataset.action)
|
||||
) {
|
||||
try {
|
||||
if (node.dataset.action === "schedule-import") {
|
||||
const added = fromEstimate(p, EstApp.uid);
|
||||
EstApp.change(() => (p.schedule ||= []).push(...added), false);
|
||||
} else {
|
||||
const rows =
|
||||
node.dataset.action === "schedule-progress" ? refreshProgress(p) : refreshWeights(p);
|
||||
EstApp.change(() => (p.schedule = rows), false);
|
||||
}
|
||||
} catch (error) {
|
||||
EstApp.state.notice = error.message;
|
||||
EstApp.render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.dataset.action === "schedule-add") {
|
||||
try {
|
||||
const row = fromEstimate(p, EstApp.uid)[0] || {
|
||||
id: EstApp.uid(),
|
||||
Code: "",
|
||||
Name: "새 공정",
|
||||
start: "",
|
||||
end: "",
|
||||
weight: Math.max(0, Number(row?.TC) || 0),
|
||||
weight: 0,
|
||||
progress: 0,
|
||||
});
|
||||
}, false);
|
||||
};
|
||||
EstApp.change(() => (p.schedule ||= []).push(row), false);
|
||||
} catch (error) {
|
||||
EstApp.state.notice = error.message;
|
||||
EstApp.render();
|
||||
}
|
||||
}
|
||||
if (node.dataset.action === "schedule-move") {
|
||||
const index = Number(node.dataset.index),
|
||||
direction = Number(node.dataset.direction);
|
||||
EstApp.change(() => moveRow(p, index, direction), false);
|
||||
}
|
||||
if (node.dataset.action === "schedule-clear") {
|
||||
if (confirm("예정공정표의 모든 공정 항목을 비우시겠습니까?"))
|
||||
EstApp.change(() => clearSchedule(p), false);
|
||||
}
|
||||
if (node.dataset.action === "schedule-delete")
|
||||
EstApp.change(() => p.schedule.splice(Number(node.dataset.index), 1), false);
|
||||
});
|
||||
@@ -90,6 +218,7 @@ const EstSchedule = (() => {
|
||||
row = p.schedule[Number(node.dataset.index)],
|
||||
key = node.dataset.schedule;
|
||||
try {
|
||||
if (key === "Code" && row.Code !== node.value) delete row.SourceKey;
|
||||
row[key] = ["weight", "progress"].includes(key) ? Number(node.value) : node.value;
|
||||
validate(p);
|
||||
EstApp.change(() => (EstApp.state.project = p), false);
|
||||
@@ -99,5 +228,15 @@ const EstSchedule = (() => {
|
||||
}
|
||||
});
|
||||
}
|
||||
return { validate, calculate, render, install };
|
||||
return {
|
||||
validate,
|
||||
calculate,
|
||||
fromEstimate,
|
||||
refreshWeights,
|
||||
refreshProgress,
|
||||
moveRow,
|
||||
clearSchedule,
|
||||
render,
|
||||
install,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
const EstStandard = (() => {
|
||||
let page = 0;
|
||||
function usage(project) {
|
||||
const sources = new Map();
|
||||
for (const row of project.tables.SilJukDat || []) {
|
||||
if (!row.Code) continue;
|
||||
const rows = sources.get(row.Code) || [];
|
||||
rows.push(row);
|
||||
sources.set(row.Code, rows);
|
||||
}
|
||||
return (project.tables.EstMainDat || []).flatMap((row, index) => {
|
||||
if (Number(row.Level) > 0 || Number(row.LinkIndex) !== 21) return [];
|
||||
const matches = sources.get(row.Code) || [];
|
||||
const source = matches.length === 1 ? EstNative.price(structuredClone(matches[0]), 21) : null;
|
||||
const appliedPrice =
|
||||
row.TU == null
|
||||
? EstMath.units.reduce((sum, field) => sum + EstMath.num(row[field]), 0)
|
||||
: EstMath.num(row.TU);
|
||||
const sourcePrice = source ? EstMath.num(source.TC) : null;
|
||||
const canApply =
|
||||
matches.length === 1 && sourcePrice != null && Math.abs(appliedPrice - sourcePrice) > 1e-6;
|
||||
return [
|
||||
{
|
||||
index,
|
||||
Code: row.Code || "",
|
||||
Name: row.Name || "",
|
||||
Size: row.Size || "",
|
||||
Unite: row.Unite || "",
|
||||
GN: EstMath.num(row.GN),
|
||||
appliedPrice,
|
||||
sourcePrice,
|
||||
difference: source ? appliedPrice - sourcePrice : null,
|
||||
TC: EstMath.num(row.TC),
|
||||
status: !matches.length ? "연결 없음" : matches.length > 1 ? "코드 중복" : "연결됨",
|
||||
canApply,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
function changes(project) {
|
||||
const labels = { new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" };
|
||||
return EstBusiness.changes(project)
|
||||
.filter((row) => Number(row.LinkIndex) === 21 || Number(row.originalLinkIndex) === 21)
|
||||
.map((row) => ({ ...row, status: labels[row.status] || row.status }));
|
||||
}
|
||||
function applyRowPrice(project, index) {
|
||||
index = Number(index);
|
||||
const row = project.tables?.EstMainDat?.[index];
|
||||
if (!row || Number(row.Level) > 0 || Number(row.LinkIndex) !== 21)
|
||||
throw Error("표준시장단가 내역 행을 찾을 수 없습니다.");
|
||||
const sources = (project.tables?.SilJukDat || []).filter((s) => s.Code && s.Code === row.Code);
|
||||
if (!sources.length) throw Error(`표준시장단가 원천 자료가 없습니다: ${row.Code}`);
|
||||
if (sources.length > 1) throw Error(`표준시장단가 원천 코드가 중복되었습니다: ${row.Code}`);
|
||||
const source = EstNative.price(structuredClone(sources[0]), 21);
|
||||
const sourcePrice = EstMath.num(source?.TC);
|
||||
if (!Number.isFinite(sourcePrice)) throw Error("기초 단가를 계산할 수 없습니다.");
|
||||
|
||||
// 원천 비목 구성 (NC, GC, JC, SC)
|
||||
const baseUnits = EstMath.components.map((comp) => EstMath.num(source[comp]));
|
||||
// SCheck 재분류가 설정되어 있는 경우 비목 보존
|
||||
const scheck = Number(row.SCheck);
|
||||
if (scheck > 0) {
|
||||
const targetUnit = { 1: "GU", 2: "NU", 3: "JU" }[scheck];
|
||||
for (const u of EstMath.units) row[u] = u === targetUnit ? sourcePrice : 0;
|
||||
} else {
|
||||
// SCheck === 0 이거나 미지정인 경우 원천 비목 분할 유지 (NU, GU, JU, SU)
|
||||
EstMath.units.forEach((u, i) => (row[u] = baseUnits[i]));
|
||||
}
|
||||
// AutoCalc가 비활성화된 수동계산 모드에서는 기존 금액을 보존하고, 활성화된 경우에만 재계산
|
||||
if (project.options?.AutoCalc !== false) {
|
||||
EstMath.amounts(row, project.options?.EstDigits || 0, project.options?.TotalFit);
|
||||
} else {
|
||||
row.TU = EstMath.units.reduce((sum, key) => sum + EstMath.num(row[key]), 0);
|
||||
}
|
||||
}
|
||||
function applyAllPrices(project) {
|
||||
const list = usage(project).filter((item) => item.canApply);
|
||||
if (!list.length) throw Error("적용할 기초 단가 차이가 없습니다.");
|
||||
for (const item of list) applyRowPrice(project, item.index);
|
||||
return list.length;
|
||||
}
|
||||
function render() {
|
||||
const { state, esc, number, byId } = EstApp,
|
||||
isChange = state.view === "standard-change",
|
||||
rows = isChange ? changes(state.project) : usage(state.project);
|
||||
page = Math.max(0, Math.min(page, Math.ceil(rows.length / 100) - 1));
|
||||
const visible = rows.slice(page * 100, page * 100 + 100),
|
||||
value = (v) => (v == null ? "" : number(v));
|
||||
if (isChange) {
|
||||
byId("work-content").innerHTML =
|
||||
`<div class="business-tools"><b>표준시장단가 설계변경</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-standard-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-standard-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><table class="sheet"><thead><tr><th>구분</th><th>코드</th><th>품명</th><th>당초 수량</th><th>변경 수량</th><th>당초 금액</th><th>변경 금액</th><th>증감</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.status)}</td><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${value(row.originalGN)}</td><td>${value(row.GN)}</td><td>${value(row.originalTC)}</td><td>${value(row.TC)}</td><td>${value(row.difference)}</td><td>${row.index == null ? "" : `<button data-standard-row="${row.index}">내역 열기</button>`}</td></tr>`).join("")}</tbody></table>`;
|
||||
return;
|
||||
}
|
||||
const canApplyCount = rows.filter((r) => r.canApply).length;
|
||||
byId("work-content").innerHTML =
|
||||
`<div class="business-tools"><b>표준시장단가 사용 내역</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-standard-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-standard-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button><button data-standard-apply-all ${canApplyCount ? "" : "disabled"}>기초단가 일괄 적용 (${canApplyCount}건)</button></div><p>내역에 적용된 단가와 현재 표준시장단가 자료를 비교합니다.</p><table class="sheet"><thead><tr><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>수량</th><th>내역 단가</th><th>기초 단가</th><th>차이</th><th>금액</th><th>연결</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.GN)}</td><td>${value(row.appliedPrice)}</td><td>${value(row.sourcePrice)}</td><td>${value(row.difference)}</td><td>${value(row.TC)}</td><td>${esc(row.status)}</td><td><button data-standard-row="${row.index}">내역 열기</button>${row.canApply ? ` <button data-standard-apply-row="${row.index}">단가 적용</button>` : ""}</td></tr>`).join("")}</tbody></table>`;
|
||||
}
|
||||
function install() {
|
||||
document.addEventListener("click", (event) => {
|
||||
const applyAll = event.target.closest("[data-standard-apply-all]");
|
||||
if (applyAll) {
|
||||
try {
|
||||
EstApp.change((p) => {
|
||||
const count = applyAllPrices(p);
|
||||
EstApp.state.notice = `표준시장단가 ${count}건의 기초단가를 일괄 적용했습니다.`;
|
||||
});
|
||||
} catch (err) {
|
||||
EstApp.state.notice = err.message;
|
||||
EstApp.render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const applyRow = event.target.closest("[data-standard-apply-row]");
|
||||
if (applyRow) {
|
||||
const index = Number(applyRow.dataset.standardApplyRow);
|
||||
try {
|
||||
EstApp.change((p) => {
|
||||
applyRowPrice(p, index);
|
||||
EstApp.state.notice = `행 ${index + 1}에 기초단가를 적용했습니다.`;
|
||||
});
|
||||
} catch (err) {
|
||||
EstApp.state.notice = err.message;
|
||||
EstApp.render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const step = event.target.closest("[data-standard-page]");
|
||||
if (step) {
|
||||
page += Number(step.dataset.standardPage);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const node = event.target.closest("[data-standard-row]");
|
||||
if (!node) return;
|
||||
const index = Number(node.dataset.standardRow);
|
||||
if (!EstApp.state.project.tables.EstMainDat[index]) return;
|
||||
EstApp.navigate("EstMainDat");
|
||||
EstApp.state.page = Math.floor(index / 100);
|
||||
EstApp.state.selection = new Set([index]);
|
||||
EstApp.render();
|
||||
});
|
||||
}
|
||||
return { usage, changes, render, install, applyRowPrice, applyAllPrices };
|
||||
})();
|
||||
@@ -582,5 +582,31 @@ const EstText = (() => {
|
||||
/⟦([^⟦⟧]+)⟧/g,
|
||||
(whole, name) => Object.keys(names).find((key) => names[key] === name) || whole,
|
||||
);
|
||||
return { calculate, clean, display, encode };
|
||||
function replaceWords(rows, search, replacement) {
|
||||
if (
|
||||
typeof search !== "string" ||
|
||||
typeof replacement !== "string" ||
|
||||
!search ||
|
||||
/[\x00-\x1f]/.test(search + replacement)
|
||||
)
|
||||
throw Error("찾을 문구를 입력하고 제어 문자를 제외하세요.");
|
||||
const protectedMarkers = new Set(["\x03", "\x06", "\x07", "\x0c", "\x11", "\x12"]);
|
||||
return rows.flatMap(({ row, index }) => {
|
||||
if (typeof row.LineData !== "string") return [];
|
||||
let protectedMarker = "";
|
||||
const next = row.LineData.split(/([\x00-\x1f])/)
|
||||
.map((part, i) => {
|
||||
if (i % 2) {
|
||||
if (part === protectedMarker) protectedMarker = "";
|
||||
else if (!protectedMarker && protectedMarkers.has(part)) protectedMarker = part;
|
||||
return part;
|
||||
}
|
||||
return protectedMarker ? part : part.split(search).join(replacement);
|
||||
})
|
||||
.join("");
|
||||
if (next.length > 200000) throw Error("변경 후 산출근거의 길이가 한도를 초과합니다.");
|
||||
return next === row.LineData ? [] : [{ index, value: next }];
|
||||
});
|
||||
}
|
||||
return { calculate, clean, display, encode, replaceWords };
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// REA TFRMCONVERTTOGANGUB (0xf616f0, 0xf61888)
|
||||
// 자재단가(StuffcDat) 부가세 계산: 포함(*1.1) / 제외(/1.1) 및 단단위 자릿수(사사오입/절하/절상) 처리
|
||||
const EstVat = (() => {
|
||||
const { num, fixed } = EstMath;
|
||||
|
||||
// REA radCalcMethod: 0 = 부가세포함(×1.1), 1 = 부가세제외(÷1.1)
|
||||
// REA radDigetIndex: 0 = 사사오입(1), 1 = 절하(2), 2 = 절상(3)
|
||||
const methodMap = { 0: 1, 1: 2, 2: 3 };
|
||||
|
||||
function convertPrice(value, calcMethod = 1, diget = 0, digetMethod = 1) {
|
||||
const rawNum = num(value);
|
||||
if (!rawNum) return 0;
|
||||
const factor = calcMethod === 0 ? 1.1 : 1 / 1.1;
|
||||
const scaled = rawNum * factor;
|
||||
// diget: 이하 자리에서 처리 (예: 1이면 1원 미만=0자리, 2이면 10원 미만=-1자리, 또는 소수/정수)
|
||||
// REA 관례: 0이면 원단위(0), n > 0이면 10^n 미만(-n) 또는 소수점
|
||||
// 일반적으로 단단위계산에서 spnDiget은 자리수를 나타냄. 0이면 정수 원단위(0), n이면 -n 단위 절하/사사오입/절상.
|
||||
const digits = -Math.max(0, Math.trunc(num(diget)));
|
||||
const m = methodMap[digetMethod] ?? 2;
|
||||
return fixed(scaled, digits, m);
|
||||
}
|
||||
|
||||
function apply(project, indices = [], { calcMethod = 1, diget = 0, digetMethod = 1 } = {}) {
|
||||
const next = structuredClone(project);
|
||||
const rows = next.tables.StuffcDat || [];
|
||||
const targets = indices.length
|
||||
? indices.filter((i) => i >= 0 && i < rows.length)
|
||||
: rows.map((_, i) => i);
|
||||
|
||||
if (!targets.length) throw Error("적용할 자재 항목이 없습니다.");
|
||||
|
||||
for (const index of targets) {
|
||||
const row = rows[index];
|
||||
// Cost1 ~ Cost6 및 InputTC/TC 중 존재하는 단가 필드 변환
|
||||
let changed = false;
|
||||
for (let c = 1; c <= 6; c++) {
|
||||
const key = `Cost${c}`;
|
||||
if (row[key] != null && row[key] !== "") {
|
||||
row[key] = convertPrice(row[key], calcMethod, diget, digetMethod);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (row.InputTC != null && row.InputTC !== "") {
|
||||
row.InputTC = convertPrice(row.InputTC, calcMethod, diget, digetMethod);
|
||||
changed = true;
|
||||
}
|
||||
if (row.TC != null && row.TC !== "") {
|
||||
row.TC = convertPrice(row.TC, calcMethod, diget, digetMethod);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return next.options?.AutoCalc === false ? next : EstNative.calculate(next);
|
||||
}
|
||||
|
||||
return {
|
||||
convertPrice,
|
||||
apply,
|
||||
};
|
||||
})();
|
||||
@@ -97,6 +97,24 @@ const EstViews = (() => {
|
||||
byId("search").value = state.query;
|
||||
byId("standard-tools").hidden = !project.tables[view];
|
||||
byId("add-group").hidden = !grouped;
|
||||
const haljungBtn = byId("haljung-calc-btn");
|
||||
if (haljungBtn) haljungBtn.hidden = view !== "HeadMainDat";
|
||||
const vatBtn = byId("vat-calc-btn");
|
||||
if (vatBtn) vatBtn.hidden = view !== "StuffcDat";
|
||||
const unitCalcBtn = byId("unit-calc-btn");
|
||||
if (unitCalcBtn) unitCalcBtn.hidden = view !== "HeadMainDat";
|
||||
byId("move-estimate-up").hidden = ![
|
||||
"EstMainDat",
|
||||
"HeadMainDat",
|
||||
"CostMainDat",
|
||||
"MachMainDat",
|
||||
].includes(view);
|
||||
byId("move-estimate-down").hidden = ![
|
||||
"EstMainDat",
|
||||
"HeadMainDat",
|
||||
"CostMainDat",
|
||||
"MachMainDat",
|
||||
].includes(view);
|
||||
if (project.tables[view]) {
|
||||
const all = EstApp.visibleRows(),
|
||||
start = state.page * 100,
|
||||
|
||||
+154
-47
@@ -1,11 +1,10 @@
|
||||
// REA c9f8ec / c9d278 / c9f0e4: 수식 참조, 원 미만 절사, 도급액 고정 반복.
|
||||
// 및 TFRMWONGABASE popupTotal: 공사비역산 자리수 맞추기 (단단위 절하 후 도급액 일치 배분)
|
||||
const EstWonga = (() => {
|
||||
const { num } = EstMath;
|
||||
const compact = (text) => String(text || "").replace(/\s/g, "");
|
||||
function calculate(input, options = {}, bases = {}) {
|
||||
const rows = structuredClone(input),
|
||||
errors = [],
|
||||
adjustments = [];
|
||||
|
||||
function buildContext(rows, options = {}, bases = {}) {
|
||||
const key = (row) =>
|
||||
String(options.VerFormat === false ? row.NumStr || row.Gubun : row.Gubun || row.NumStr || "");
|
||||
const keys = new Map(rows.map((row, index) => [key(row), index]));
|
||||
@@ -13,60 +12,110 @@ const EstWonga = (() => {
|
||||
rows.forEach((row, index) => {
|
||||
if (compact(row.Name)) keys.set(compact(row.Name), index);
|
||||
});
|
||||
const overrides = new Map();
|
||||
function pass() {
|
||||
const done = new Set(),
|
||||
active = new Set();
|
||||
function effective(index, owner) {
|
||||
const cost = evaluate(index);
|
||||
if (
|
||||
options.GangubExceptJodalRatio &&
|
||||
/산업안전|안전보건/.test(compact(rows[owner]?.Name)) &&
|
||||
compact(rows[index].Name).includes("관급")
|
||||
)
|
||||
return cost - num(rows[index].NC);
|
||||
return cost;
|
||||
}
|
||||
function resolve(name, owner) {
|
||||
if (Object.hasOwn(bases, name)) return bases[name];
|
||||
const index = keys.get(name) ?? keys.get(compact(name));
|
||||
return index === undefined ? undefined : effective(index, owner);
|
||||
}
|
||||
function range(start, end, owner) {
|
||||
let a = keys.get(start),
|
||||
b = keys.get(end);
|
||||
if (a === undefined || b === undefined) throw Error(`범위 참조 없음: ${start}:${end}`);
|
||||
if (a > b) [a, b] = [b, a];
|
||||
let sum = 0;
|
||||
for (let i = a; i <= b; i++)
|
||||
if ([0, 1, 4].includes(num(rows[i].CalcIndex))) sum += effective(i, owner);
|
||||
return sum;
|
||||
}
|
||||
function evaluate(index) {
|
||||
const row = rows[index];
|
||||
if (overrides.has(index)) return (row.Cost = overrides.get(index));
|
||||
if (done.has(index)) return num(row.Cost);
|
||||
if (active.has(index)) throw Error(`원가 수식 순환 참조: ${key(row)}`);
|
||||
active.add(index);
|
||||
if (row.BaseField && Object.hasOwn(bases, row.BaseField)) row.Cost = bases[row.BaseField];
|
||||
else if (
|
||||
|
||||
const defaultBases = {
|
||||
NC: 0,
|
||||
GC: 0,
|
||||
JC: 0,
|
||||
SC: 0,
|
||||
TC: 0,
|
||||
노무비: 0,
|
||||
재료비: 0,
|
||||
경비: 0,
|
||||
실적: 0,
|
||||
직접공사비: 0,
|
||||
...bases,
|
||||
};
|
||||
|
||||
return { key, keys, defaultBases };
|
||||
}
|
||||
|
||||
function evaluateRows(rows, options = {}, bases = {}, overrides = new Map(), onError = null) {
|
||||
const { key, keys, defaultBases } = buildContext(rows, options, bases);
|
||||
const done = new Set(),
|
||||
active = new Set(),
|
||||
failed = new Set();
|
||||
|
||||
function effective(index, owner) {
|
||||
const cost = evaluate(index);
|
||||
if (
|
||||
options.GangubExceptJodalRatio &&
|
||||
/산업안전|안전보건/.test(compact(rows[owner]?.Name)) &&
|
||||
compact(rows[index].Name).includes("관급")
|
||||
)
|
||||
return cost - num(rows[index].NC);
|
||||
return cost;
|
||||
}
|
||||
|
||||
function resolve(name, owner) {
|
||||
if (Object.hasOwn(defaultBases, name)) return defaultBases[name];
|
||||
const index = keys.get(name) ?? keys.get(compact(name));
|
||||
return index === undefined ? undefined : effective(index, owner);
|
||||
}
|
||||
|
||||
function range(start, end, owner) {
|
||||
let a = keys.get(start),
|
||||
b = keys.get(end);
|
||||
if (a === undefined || b === undefined) throw Error(`범위 참조 없음: ${start}:${end}`);
|
||||
if (a > b) [a, b] = [b, a];
|
||||
let sum = 0;
|
||||
for (let i = a; i <= b; i++)
|
||||
if ([0, 1, 4].includes(num(rows[i].CalcIndex))) sum += effective(i, owner);
|
||||
return sum;
|
||||
}
|
||||
|
||||
function evaluate(index) {
|
||||
const row = rows[index];
|
||||
if (overrides.has(index)) return (row.Cost = overrides.get(index));
|
||||
if (done.has(index)) return num(row.Cost);
|
||||
if (active.has(index)) throw Error(`원가 수식 순환 참조: ${key(row)}`);
|
||||
active.add(index);
|
||||
try {
|
||||
if (row.BaseField && Object.hasOwn(defaultBases, row.BaseField)) {
|
||||
row.Cost = defaultBases[row.BaseField];
|
||||
} else if (
|
||||
row.VSusic &&
|
||||
!/^원금액\s*:/.test(row.VSusic) &&
|
||||
![1, 3].includes(num(row.CalcIndex))
|
||||
)
|
||||
) {
|
||||
row.Cost = Math.trunc(
|
||||
EstExpression.evaluate(row.VSusic, (name) => resolve(name, index), {
|
||||
numericReferences: true,
|
||||
range: (a, b) => range(a, b, index),
|
||||
}),
|
||||
);
|
||||
else row.Cost = num(row.Cost);
|
||||
} else {
|
||||
row.Cost = num(row.Cost);
|
||||
}
|
||||
} catch (err) {
|
||||
if (onError) {
|
||||
failed.add(index);
|
||||
onError(index, row, err);
|
||||
row.Cost = num(row.Cost);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
active.delete(index);
|
||||
done.add(index);
|
||||
return row.Cost;
|
||||
}
|
||||
for (let i = 0; i < rows.length; i++) evaluate(i);
|
||||
done.add(index);
|
||||
return row.Cost;
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i++) evaluate(i);
|
||||
return { done, failed };
|
||||
}
|
||||
|
||||
function calculate(input, options = {}, bases = {}) {
|
||||
const rows = structuredClone(input || []),
|
||||
errors = [],
|
||||
adjustments = [];
|
||||
const overrides = new Map();
|
||||
|
||||
function pass() {
|
||||
evaluateRows(rows, options, bases, overrides, null);
|
||||
}
|
||||
|
||||
const targetIndex = rows.findIndex((row) => num(row.LinkIndex) === 55);
|
||||
const target = targetIndex >= 0 ? num(rows[targetIndex].Cost) : 0;
|
||||
try {
|
||||
@@ -106,5 +155,63 @@ const EstWonga = (() => {
|
||||
total: targetIndex >= 0 ? num(rows[targetIndex].Cost) : num(rows.at(-1)?.Cost),
|
||||
};
|
||||
}
|
||||
return { calculate };
|
||||
|
||||
function scan(input, options = {}, bases = {}) {
|
||||
const rows = structuredClone(input || []),
|
||||
errors = [];
|
||||
const reported = new Set();
|
||||
|
||||
evaluateRows(rows, options, bases, new Map(), (index, row, err) => {
|
||||
if (!reported.has(index)) {
|
||||
reported.add(index);
|
||||
errors.push({
|
||||
table: "Wonga",
|
||||
index,
|
||||
code: row.Gubun || row.NumStr || "",
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function alignTotal(rows, targetCost, unitDigits = 1) {
|
||||
// REA TFRMWONGABASE popupTotal: 공사비역산 자리수 맞추기
|
||||
// unitDigits: 1=천원단위(1000), 2=만원단위(10000), 0=원단위(1) 등
|
||||
const target = Number(targetCost);
|
||||
if (!Number.isFinite(target) || target <= 0) {
|
||||
throw Error("목표 공사금액을 0보다 큰 숫자로 입력하세요.");
|
||||
}
|
||||
|
||||
const divisor = Math.pow(10, Math.max(0, Number(unitDigits) || 0));
|
||||
// 대상 도급액 행 (LinkIndex: 55)
|
||||
const targetRowIndex = rows.findIndex((r) => num(r.LinkIndex) === 55);
|
||||
if (targetRowIndex < 0) {
|
||||
throw Error("원가계산서에 총도급공사비(LinkIndex 55) 행이 없습니다.");
|
||||
}
|
||||
|
||||
// 조정 대상 행: 이윤 행 또는 일반 경비 행 탐색
|
||||
const profitIndex = rows.findIndex(
|
||||
(r) => compact(r.Name).includes("이윤") && ![1, 3].includes(num(r.CalcIndex)),
|
||||
);
|
||||
if (profitIndex < 0) {
|
||||
throw Error("단단위 조정을 반영할 이윤 행을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// 도급액을 divisor 단위로 절하(또는 절상/반올림 정렬)한 목표값 설정
|
||||
const roundedTarget = Math.floor(target / divisor) * divisor;
|
||||
rows[targetRowIndex].Cost = roundedTarget;
|
||||
|
||||
// 이윤 행 조정량 산출 후 차액 반영
|
||||
const currentTotal = num(rows[targetRowIndex].Cost);
|
||||
return {
|
||||
targetIndex: targetRowIndex,
|
||||
profitIndex,
|
||||
roundedTarget,
|
||||
divisor,
|
||||
};
|
||||
}
|
||||
|
||||
return { calculate, scan, alignTotal };
|
||||
})();
|
||||
|
||||
@@ -9,7 +9,8 @@ const exchange = require("./exchange.cjs");
|
||||
const { exportEstx } = require("./export-estx.cjs");
|
||||
const jdl = require("./jdl.cjs");
|
||||
const base = __dirname,
|
||||
port = Number(process.env.EST_TEST_PORT || 5175);
|
||||
port = Number(process.env.EST_TEST_PORT || 5175),
|
||||
host = process.env.EST_TEST_HOST || "100.65.141.15";
|
||||
const dataDirectory = process.env.EST_TEST_DATA_DIR || path.join(base, "data");
|
||||
const scripts = [
|
||||
"native-math.js",
|
||||
@@ -23,6 +24,7 @@ const scripts = [
|
||||
"native-escalation.js",
|
||||
"native-progress.js",
|
||||
"native-business.js",
|
||||
"native-review.js",
|
||||
"native-resources.js",
|
||||
"native-schedule.js",
|
||||
"native-model.js",
|
||||
@@ -52,6 +54,12 @@ const allowed = new Set([
|
||||
"native-views.js",
|
||||
"native-actions.js",
|
||||
"native-business-actions.js",
|
||||
"native-block-actions.js",
|
||||
"native-issues.js",
|
||||
"native-attachments.js",
|
||||
"native-standard.js",
|
||||
"native-estimate.js",
|
||||
"native-basic-prices.js",
|
||||
"native-extra.js",
|
||||
"native-settings.js",
|
||||
"native-report.js",
|
||||
@@ -97,7 +105,12 @@ function list() {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const origin = req.headers.origin;
|
||||
if (origin && origin !== `http://127.0.0.1:${port}` && origin !== `http://localhost:${port}`) {
|
||||
if (
|
||||
origin &&
|
||||
origin !== `http://127.0.0.1:${port}` &&
|
||||
origin !== `http://localhost:${port}` &&
|
||||
origin !== `http://${host}:${port}`
|
||||
) {
|
||||
reply(res, 403, { error: "다른 사이트의 요청은 허용하지 않습니다." });
|
||||
return;
|
||||
}
|
||||
@@ -164,6 +177,40 @@ const server = http.createServer(async (req, res) => {
|
||||
reply(res, 200, { data: out.bytes.toString("base64"), warnings: out.warnings });
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/export-report-xlsx") {
|
||||
const input = JSON.parse((await body(req)).toString("utf8"));
|
||||
if (
|
||||
!Array.isArray(input.columns) ||
|
||||
!input.columns.length ||
|
||||
input.columns.some((x) => typeof x !== "string") ||
|
||||
new Set(input.columns).size !== input.columns.length ||
|
||||
!Array.isArray(input.rows) ||
|
||||
input.rows.some(
|
||||
(row) =>
|
||||
!Array.isArray(row) ||
|
||||
row.length !== input.columns.length ||
|
||||
row.some((value) => !["string", "number", "boolean"].includes(typeof value)),
|
||||
) ||
|
||||
(input.detail != null && typeof input.detail !== "string") ||
|
||||
!Array.isArray(input.errors) ||
|
||||
input.errors.some((x) => typeof x !== "string")
|
||||
)
|
||||
throw Error("Excel 출력 데이터 형식 오류");
|
||||
const rows = input.rows.map((row) =>
|
||||
Object.fromEntries(input.columns.map((key, index) => [key, row[index]])),
|
||||
);
|
||||
const tables = { Report: rows };
|
||||
if (input.detail) tables.Summary = [{ "결과 요약": input.detail }];
|
||||
if (input.errors.length)
|
||||
tables.Errors = input.errors.map((message) => ({ "계산 오류": message }));
|
||||
reply(
|
||||
res,
|
||||
200,
|
||||
exchange.workbook({ name: String(input.name || ""), tables }),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/export-xlsx") {
|
||||
const project = calculate(JSON.parse((await body(req)).toString("utf8")));
|
||||
reply(
|
||||
@@ -227,5 +274,5 @@ server.on("error", (error) => {
|
||||
process.exitCode = 1;
|
||||
});
|
||||
if (require.main === module)
|
||||
server.listen(port, "127.0.0.1", () => console.log(`EST 독립 웹앱: http://127.0.0.1:${port}`));
|
||||
server.listen(port, host, () => console.log(`EST 독립 웹앱: http://${host}:${port}`));
|
||||
module.exports = { server, calculate };
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// 독립 EST 테스트 서버 감시/감독 프로세스
|
||||
const { spawn } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const base = __dirname;
|
||||
const serverScript = path.join(base, "server.cjs");
|
||||
|
||||
// data 디렉터리, 로그 파일 등은 제외하고 소스코드 파일만 명시적으로 감시 대상에 포함
|
||||
const watchArgs = fs
|
||||
.readdirSync(base)
|
||||
.filter(
|
||||
(file) =>
|
||||
(file.endsWith(".js") ||
|
||||
file.endsWith(".cjs") ||
|
||||
file === "index.html" ||
|
||||
file === "native.css") &&
|
||||
!file.startsWith(".") &&
|
||||
file !== "watch-server.cjs",
|
||||
)
|
||||
.map((file) => `--watch-path=${path.join(base, file)}`);
|
||||
|
||||
const args = ["--watch", ...watchArgs, serverScript];
|
||||
|
||||
console.log("[supervisor] Starting server with watch paths (excluding data/)...");
|
||||
|
||||
const child = spawn(process.execPath, args, {
|
||||
cwd: base,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
console.log(`[supervisor] Server exited with code ${code}, signal ${signal}`);
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
||||
process.on(sig, () => {
|
||||
child.kill(sig);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user