Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1

This commit is contained in:
2026-09-13 13:48:56 +09:00
24 changed files with 1778 additions and 15 deletions
@@ -22,6 +22,10 @@
| 13 통제시험 목록 | 정적 분석으로 못 가르는 경계를 시험 11개로 분리, 우선순위·완료판정 | https://share.onorca.dev/a/lcmxdrG2SWvE |
| 14 최종 분석완료 보고서 | 도달 수준, A등급 확정 목록, 남은 것, 완료 판정 기준, 산출물 | https://share.onorca.dev/a/AmgldP7q_Qzc |
| 15 재구현 가능성 검증 | **무엇을 만들 수 있고 무엇이 막혀 있나** — 층별 확보표, 문서 오류 정정, T1 좌표 확정 | https://share.onorca.dev/a/t6HGy0HlbEBl |
| 16 골든셋 회귀 결과 | **원가계산을 다시 세워 원 단위로 맞춘 실증** — 프로젝트 6건 전 항목 일치 | (저장소 파일) |
| 17 단가조립 검증 | **일위대가·중기 시간당 사용료 구조와 손료율 실측** | (저장소 파일) |
| 18 누락 계산로직 점검 | **사슬에 빠진 칸 찾기** — 노임 환산식·단가산출 Q식·설정 스냅샷·집계표 반올림 | (저장소 파일) |
| 19 진행률 장부 | **어디까지 봤고 무엇이 남았나** — 원천별 실측 진행률, 차단점 현황 | (저장소 파일) |
> **정본은 저장소 파일이다** — `resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/`.
> 위 Orca 링크는 보기용이며, 문서를 지식DB 자리로 옮기면서 주소가 한 번 바뀌었다.
@@ -49,6 +53,23 @@
| `TAMT=MAMT+LAMT+SAMT` | 예외 없는 불변조건 | **예외 있음** — 59행 중 2행(관급 흄관) | 11번 §3, 15번 §3 |
| 프로젝트 RATE 표 | 4종 | **6종**`RATE_A`·`RATE_B` 추가 발견 | 02번, 15번 §3 |
| 보험 요율 좌표 | 미확정 | **A** — 산재·건강·연금·장기요양·고용 등급1~7·산안비 좌표 확정 | 12번 §9.1, 15번 §4 |
| 원가계산 전 과정 | 규칙 서술 | **A 실증** — 프로젝트 6건을 원 단위로 다시 세워 전 항목 일치 | 16번 |
| 내역 말단 행 판별 | 없음 | **A**`BDCODE1` 접두 `T/E/C/:` 가 구조 행 | 16번 §4.1 |
| 제외금액 구역 | 서술만 | **A**`C*` 로 닫히는 구역은 직접비에서 뺌 | 16번 §4.2 |
| 끝전조정 | B | **A 실증** — 차액 → `ROUND(차액/1.1)` → 이윤 차감 | 16번 §4.4 |
| 차수 인계 | 「가정하지 말 것」 경고만 | **A 실증**`BOQ12`가 현재안, 다음 파일 차수1로 인계 | 16번 §4.5·§4.6, 08번 |
| 중기 시간당 사용료 | 손료 계수 없음 | **A** — 손료율·취득가·연료·잡품률 25개 장비 실측 | 17번 §3·§4 |
| 호표 성분 소계 반올림 | 미확정 | **A** — 절사 (중기 398건 100%) | 17번 §2 |
| 자재 6후보 자동채택 | C | **A 실증** — 0 아닌 후보 중 **최저가**. 복수후보 76/76 | 17번 §5.5, 07번 §5 |
| 이윤 요율 좌표 | 미확정 | **A**`RATE_R[20,16]`, 3건 일치 | 15번 §4 |
| 요율의 출처 | 표에서 고른다고 가정 | **표에 없는 요율이 실재** — 사용자 수동 입력 경로 필수 | 15번 §4 |
| 통제시험 T1~T11 | 「나중에 수행」 | **수행 불가 — STmate 라이선스 없음.** 표본 늘리기가 유일한 확장 수단 | 13번 머리, 15번 §6 |
| 노임 시간당 환산 | **문서에 없었음** | **A**`일노임 × 설정식`, 식은 `RXNM_`에 문자열 저장 | 18번 §2.1 |
| 식 평가 순서 | 명시 없음 | **A****좌→우 순차.** 계수를 먼저 곱하면 1원 틀림 | 18번 §2.1 |
| 단가산출 `D`코드 | **문서에 없었음** | **A**`Q=3600qKfE/Cm`, 성분 = 절사(시간단가/Q, 소수1) | 18번 §2.2 |
| 계산 설정 스냅샷 | 「화면 없이 재현 불가」 | **출력 XLSX `〓 INITIAL 〓` 에 있음** | 18번 §2.3 |
| 집계표 행 금액 | 서술만 | **A** — 사사오입 100%. 호표 성분 소계(절사)와 다른 자리 | 18번 §2.4 |
| 변경설계 간접비 모드 | 「모드가 여럿」까지만 | **A 실증** — 건강·장기요양·연금은 **당초액 유지**, 나머지는 재계산 | 19번 §6, 08번 §2 |
분석일: 2026-09-13
대상: `C:\경영정보(주)`
@@ -89,7 +89,13 @@ price_candidate(material_id, revision_id, slot_no, source_name, page, observed_p
price_adoption(material_id, revision_id, chosen_slot_no?, adopted_price, method, user_override)
```
자동선정은 현재 C등급이다. 최저가·최신가·특정 슬롯 우선 중 어느 하나라고 단정할 근거가 없다. 동일 구현 전에는 후보 조합을 통제한 STmate 시험이 필수다: 6개 슬롯에 서로 다른 값, 동률, 0, 공란을 배치하고 채택값과 `PG_1` 변화를 기록한다.
**2026-09-13 — 자동선정은 「0이 아닌 후보 중 최저가」로 확정됐다. A등급 실증.**
출력 XLSX의 `자재단가대비표`에 후보 다섯 칸과 적용 단가·출처가 나란히 있어 직접 검정했다. 실무 6건 전수에서 **후보 값이 2종 이상인 76건 전부, 3종 이상인 36건 전부 최저가**였고, 최저가가 아닌 후보를 택한 건은 0이다. 상세는 17번 §5.5.
`COSTN`의 여섯 칸 중 **여섯 번째가 적용 단가 칸**으로 보이며, 이는 「노무 L은 후보6과 채택가가 거의 일치」라는 위 관찰과 맞는다.
남은 것은 **사용자 수동 고정이 걸렸을 때의 우선순위**다. 표본에는 수동 고정 흔적이 없었다. 통제시험(13번 T10)에서 6개 슬롯에 서로 다른 값·동률·0·공란을 배치하고 수동 고정을 섞어 채택값과 `PG_1` 변화를 기록한다.
## 6. 반올림 경계
@@ -35,7 +35,22 @@ STC에는 두 형태가 동시에 확인된다.
1. 한 파일 안에 `BOQ11`, `BOQ12`를 함께 저장하는 통합형: 소광 표본은 각각 90행, 89행이다.
2. 변경 차수를 별도 STC로 저장하면서 그 파일의 `BOQ11`을 현재안으로 쓰는 분리형: 소광 변경1차 표본은 115행이다.
또한 `COSTN`과 여러 필드의 접미사 1~4는 원설계와 최대 3개 변경차수의 값을 나란히 담을 수 있다. 따라서 “접미사 n=항상 BOQ1n”으로 기계적으로 가정하지 말고 프로젝트 메타데이터와 실제 존재 테이블을 함께 해석해야 한다.
또한 `COSTN`과 여러 필드의 접미사 1~4는 원설계와 최대 3개 변경차수의 값을 나란히 담을 수 있다.
### 실증으로 확정된 차수 규칙 — A등급 (2026-09-13, 16번 §4.5·§4.6)
```text
통합형 한 STC 안에 BOQ11(당초) + BOQ12(현재안)
⚠ STmate 출력은 BOQ12 를 쓴다. BOQ11 을 현재안으로 잡으면 통째로 틀린다.
분리형 변경 차수를 새 STC 로 저장
새 파일 BOQ11 차수1 = 앞 파일의 BOQ12 ← 소광 표본에서 원 단위 일치 확인
새 파일 BOQ11 차수2 = 이번 변경분
```
`PRJLST.TCOSTn = MCOSTn + LCOSTn + SCOSTn` 으로 **직접공사비 합계**다. 소광 변경1차는 당초 173,311,386 → 변경 257,410,433, 증감 +84,099,047이다.
따라서 “접미사 n=항상 BOQ1n”으로 기계적으로 가정하지 말고 **실제 존재하는 테이블과 `PRJLST` 를 함께 보고 현재안을 고를 것.**
### STmate의 실제 변경설계 계산모드 — A등급
@@ -67,7 +82,24 @@ STC에는 두 형태가 동시에 확인된다.
- 비교표는 `original`, `previous`, `current`, `delta`를 모두 계산한다.
- 삭제 항목은 물리 삭제하지 않고 현재 차수에 0/제외 상태로 남겨 코드 대응을 보존한다.
- 신규 항목은 안정적인 내부 ID와 차수별 표시 순서를 가진다.
- 간접비 항목마다 `recalculated`, `proportional_from_original`(방법 A), `delta_times_rate`(방법 B), `carry_original`, `manual`을 **계산모드 값으로 저장**한다. 절사 비선형성 때문에 모드가 다르면 원 단위 결과가 갈리므로, 모드를 하나로 고정하면 STmate 결과를 재현할 수 없다.
- 간접비 항목마다 `recalculated`, `proportional_from_original`(방법 A), `delta_times_rate`(방법 B), `carry_original`, `manual`을 **계산모드 값으로 저장**한다.
### 실데이터로 확인된 모드 — A등급 (2026-09-13)
계류보전 2025(변경)의 `공사원가계산서`는 당초·변경·증감을 나란히 낸다. **직접노무비가 `32,976,276 → 41,778,438`(+8,802,162)로 늘었는데 세 항목은 증감이 0이다.**
| 항목 | 당초 | 변경 | 증감 | 재계산했다면 | 모드 |
|---|---:|---:|---:|---:|---|
| 간접노무비 | 4,715,607 | 5,974,316 | +1,258,709 | 5,974,316 ✅ | `recalculated` |
| 산재보험료 | 1,341,831 | 1,699,998 | +358,167 | 1,699,998 ✅ | `recalculated` |
| 고용보험료 | 380,688 | 482,302 | +101,614 | 482,302 ✅ | `recalculated` |
| **건강보험료** | 1,328,500 | 1,328,500 | **0** | 1,683,108 | **`carry_original`** |
| **노인장기요양보험료** | 172,040 | 172,040 | **0** | 217,961 | **`carry_original`** |
| **연금보험료** | 1,686,389 | 1,686,389 | **0** | 2,136,526 | **`carry_original`** |
요율은 당초·변경이 같다(간접노무비 14.300%, 산재 3.560%, 고용 1.010%). 요율을 바꾼 것이 아니라 **항목마다 모드가 다르다.** 건강·장기요양·연금은 **사후정산 대상**이라 설계 단계에서 당초액을 그대로 가져간다 — §10의 「직접입력」 옵션이 이것이다.
**전부 재계산으로 두면 이 세 항목에서 85만 원이 어긋난다.** 모드를 하나로 고정하면 STmate 결과를 재현할 수 없다.
- Aislo 자체 기본값으로는 전 차수 독립 재계산(`recalculated`)을 권장하되, **STmate 호환 모드와 구분해서** 둔다.
## 3. 일괄 단가변경·물가변동
@@ -2,8 +2,31 @@
> 정적 분석만으로는 확정할 수 없는 경계를 **시험 단위로 갈라 놓은 유일한 목록**이다. 05~12번 문서에 흩어져 있던 "미확정·C등급" 항목을 여기로 모았다. 각 문서는 이제 이 목록을 가리킨다.
기준일: 2026-09-13
대상: `C:\경영정보(주)`**원본은 읽기만 한다.** 시험은 프로젝트 **복제본**에서 한다.
기준일: 2026-09-13 (전제 갱신 2026-09-13)
대상: `C:\경영정보(주)`**원본은 읽기만 한다.**
## ⛔ 전제 정정 — 이 시험들은 **실행할 수 없다**
**STmate 라이선스가 없어 프로그램을 띄울 수 없다.** 따라서 아래 T1~T11은 **수행 불가**이며, 「나중에 시험으로 확정한다」는 계획은 성립하지 않는다.
이 목록은 버리지 않고 **두 가지 용도로 남긴다.**
1. **무엇이 왜 확정 불가인지**를 항목별로 밝혀 둔 장부 — 어떤 값이 「모르는 것」인지 분명히 해 두어야 Aislo가 그 자리를 정책 플래그로 열어 둘 수 있다.
2. **라이선스가 생기거나 실무자가 대신 눌러 줄 수 있게 되면** 그대로 쓸 절차서.
**대신 쓸 수 있는 길**은 세 갈래이며, 이미 상당 부분 성과가 났다.
| 대체 경로 | 무엇을 풀었나 |
|---|---|
| **출력 XLSX 역추적** — 비고 칸에 밑수·요율이 적혀 있음 | 보험 요율 좌표 6개, 이윤 좌표 (15번 §4) |
| **골든셋 회귀** — 내역으로 원가계산을 다시 세워 대조 | 원가계산 전 과정, 끝전조정, 차수 규칙 (16번) |
| **출력 시트 전수 검정** — 일위대가·중기·자재단가대비표 | 성분 절사, 중기 손료율, 자재 채택=최저가 (17번) |
**표본을 늘리는 것이 통제시험을 대신하는 유일한 확장 수단이다.** 프로젝트마다 공사규모·기간·공종이 다르므로, 표본이 늘수록 좌표와 조건이 저절로 좁혀진다.
---
아래는 라이선스가 생겼을 때를 위한 원 절차서다. 시험은 프로젝트 **복제본**에서 한다.
## 0. 왜 시험이 필요한가
@@ -27,8 +27,11 @@ Aislo 입장에서는 이것으로 충분함 — Aislo는 **자기 기초단가
| | 표준시장단가 `*.CSTA` 4,921~4,961건 × 6 | **본문 암호화** | ❌ |
| | `*.STBSC` 28개 (노임·기계경비 배포) | ZIP 컨테이너, 내부 암호 | ❌ |
| **3 단가 계산** | `COSTN.DBF` 단가 네 성분 | **평문 — 읽힘** | ✅ |
| | 노임 일→시간 환산식 | **`환율및기초자료` 시트 + `RXNM_` 설정** (18번) | ✅ |
| | 단가산출 `D`코드 Q식·품셈 인자 | **`단가산출근거` 시트 3,889행** (18번) | ✅ |
| | 계산 설정 스냅샷 | **`〓 INITIAL 〓` 시트** (18번) | ✅ |
| | `COSTN.SMT` 구성 토큰 | 평문, 문법 문서화됨 | ✅ |
| | 중기 손료 계수(감가상각·정비·관리율) 실제 값 | **어디에도 없** | |
| | 중기 시간당 손료율·연료·잡품률 | **출력 `중기사용료` 시트에 전부 있** (17번) | |
| | 자재 6후보 중 자동채택 규칙 | C등급 | ❌ |
| **4 내역** | `BOQ11.DBF` 수량·단가·금액 | 평문 | ✅ |
| | 계층 집계(`SORTCODE`·`ENDCODE`·`ITM_TYP`) | 문서화됨 | ✅ |
@@ -109,9 +112,32 @@ A등급 불변조건으로 적혀 있으나 실데이터에 **59행 중 2행이
**`RATE_R`의 행 45~49는 요율 행렬이 아니라 부속표 영역**이고, 행 0~44가 규모·기간 행렬임.
### 아직 좌표가 안 갈리는 항목
### 항목이 두 부류로 갈림 — 통제시험 없이 밝힌 것
`연금보험료(4.5%)`·`일반관리비(6%)`·`이윤(15%)`·`환경보전비(0.8%)`·`부가가치세(10%)`**같은 값이 표 안 여러 곳에 있어** 단독으로는 못 가름. 확정하려면 **한 프로젝트에서 그 값만 특이한 숫자로 바꿔 저장**하는 통제시험이 필요함(13번 T1).
프로젝트 5건의 요율을 각자의 `RATE_*` 표에 겹쳐 보면 항목이 두 갈래로 나뉨.
**① 고정 칸** — 공사규모·기간과 무관. 전 프로젝트에서 같은 좌표.
```text
산재보험료 RATE_R[45, 1] 건강보험료 RATE_R[46, 1]
연금보험료 RATE_R[46, 6] 장기요양 RATE_R[46, 11]
고용보험 등급1~7 RATE_R[45, 11..17]
산업안전보건관리비 RATE_R[20, 17] 율 · [20, 18] 기초액 ← 3건 전부 유일
이윤 RATE_R[20, 16] ← 3건 전부 일치
```
**② 규모·기간에 따라 칸이 움직임** — 간접노무비·기타경비·일반관리비. 전 프로젝트에 공통인 좌표가 **없음.** 행이 `(공사규모 밴드, 공사기간 밴드)`로 정해지므로 고정 좌표라는 모델 자체가 틀림. 열은 공종(토목·산설·조경·건축·산설건축)이고 행이 움직인다.
### ⚠ 요율이 표에 아예 없는 경우가 실재함
| 프로젝트 | 항목 | 쓴 요율 | 그 프로젝트 `RATE_R` 안에 |
|---|---|---:|---|
| 2024 간선임도 (기번3) | 간접노무비 | 14.5% | **없음** |
| 2025 소광 (기번8) | 이윤 | 13.435% | **없음** |
즉 **사용자가 표를 거치지 않고 요율을 직접 써 넣은 것**이다. 11번 §8이 「순환이 생기면 사용자 수동 확정으로 넘긴다」고 한 경로가 실데이터에 그대로 나타났다.
**재구현 필수 조건** — 요율을 표에서 못 찾았다고 오류로 막으면 안 된다. `rate_source ∈ {table(coord), manual}` 을 항상 함께 저장하고, 수동값이면 좌표 없이 값만 보존해야 한다.
## 5. 그래서 무엇을 만들 수 있나
@@ -126,8 +152,8 @@ A등급 불변조건으로 적혀 있으나 실데이터에 **59행 중 2행이
### 지금 자료로 불가능한 것
1. **기초단가 배포 체계** — 자재 5.7만 건·노임·중기·표준시장단가를 못 읽음. 암호를 풀지 않는 한 영구 불가
2. **수량 할증 재현**`BDQTY` 암호화로 `X_QTY`·`X_HAL` 실값을 못 봄
3. **중기 시간당 단가 재현** — 손료 계수(감가상각률·정비율·관리비율) 실제 값이 자료에 없
2. **수량 할증 재현**`BDQTY` 암호화로 `X_QTY`·`X_HAL` 실값을 못 봄. 출력에는 **할증 후 수량만** 남음
3. ~~중기 시간당 단가 재현~~**가능해짐** (17번). 출력 `중기사용료` 호표에 손료율·취득가·연료·잡품률이 다 들어 있
4. **자재 자동채택** — 6후보 중 무엇을 고르는지 규칙 없음
5. **수공형식 검증**`RATE_A`·`RATE_B`가 실무 표본에 없음
@@ -136,19 +162,27 @@ A등급 불변조건으로 적혀 있으나 실데이터에 **59행 중 2행이
| 물음 | 답 |
|---|---|
| STmate와 **똑같은 프로그램**을 만들 수 있나 | ❌ — 기초단가 원천이 암호화됨 |
| STmate와 **같은 답을 내는 계산 엔진**을 만들 수 있나 | ⚠ 대부분 — 직접비·간접비·반올림은 가능, 중기·할증·자재채택은 불가 |
| STmate와 **같은 답을 내는 계산 엔진**을 만들 수 있나 | ✅ **원가계산 부분은 실증 완료** (16번, 프로젝트 6건 원 단위 일치). 단가 조립·할증·자재채택은 여전히 불가 |
| STmate 프로젝트 파일을 **읽고 검산**할 수 있나 | ✅ — `BDQTY` 빼고 전부 |
| **Aislo 원가계산에 쓸 규칙**을 얻었나 | ✅ — 밑수·순서·반올림·요율 좌표 확보 |
**Aislo 목적으로는 필요한 정보가 모였음.** 「STmate 복제」는 애초에 불가능하고 목표도 아님.
### ⛔ 통제시험은 못 함 — 라이선스 없음
13번의 T1~T11은 **STmate를 띄워야 하는데 라이선스가 없어 수행 불가**다. 따라서 「나중에 시험으로 확정」이라 미뤄 둔 항목은 **다른 길로 풀거나, 모르는 채로 두고 Aislo에서 정책으로 여는 수밖에 없다.**
실제로 대체 경로가 잘 들었다 — 출력 XLSX 역추적·골든셋 회귀·출력 시트 전수 검정으로 09번이 꼽은 여섯 차단점 중 **둘을 완전히, 하나를 절반** 풀었다(17번 §7).
**남은 것을 늘릴 방법은 표본을 늘리는 것 하나뿐이다.** 공사규모·기간·공종·계산형식이 다른 프로젝트가 들어올수록 좌표와 조건이 저절로 좁혀진다. 특히 **수공형식(`S`)·실적형식(`TA`/`SA`) 표본이 하나도 없어** 그 세 형식은 지금 전혀 검증되지 않았다.
## 7. 다음에 할 것
| 순위 | 할 일 | 왜 |
|---:|---|---|
| 1 | **골든셋 회귀 구현** — 실무 7건의 STC + XLSX로 내역·간접비를 원 단위 대조 | 지금 자료만으로 되고, 규칙 확정도를 한 번에 끌어올림 |
| ~~1~~ | ~~골든셋 회귀 구현~~ | ✅ **완료 — 16번.** 프로젝트 6건 전 항목 원 단위 일치 |
| 2 | 남은 요율 좌표(연금·일반관리비·이윤·환경보전비) 확정 | 통제시험 T1 나머지 |
| 3 | 중기 손료 계수를 **법령·품셈에서** 확보 | STmate 자료로는 영구 불가 — 국가 기준으로 대체 |
| 4 | `BDQTY` 대조시험 (T7) | 할증 규칙 |
| 3 | 중기 손료 계수를 **법령·품셈과 대조** | 값은 17번에서 확보했으나, Aislo 기본값은 현행 기준에서 가져와야 함 |
| 4 | `BDQTY` 대조시험 (T7) | 할증 규칙 — 여전히 막힌 유일한 입력 |
3번은 STmate를 뜯어서 얻을 수 없음. `resources/knowledge/technical_info/` 의 **건설기계 경비 산정 기준**으로 가야 함.
3번의 값 자체는 17번 §4에 25개 장비분이 있음. 다만 **Aislo 기본값은 STmate 값이 아니라 현행 「건설기계 경비 산정 기준」에서** 확정해야 하며, 17번 표는 대조용임.
@@ -0,0 +1,167 @@
# 골든셋 회귀 결과 — 원가계산을 원 단위로 다시 세워 맞췄다
실행일: 2026-09-13
방법: 프로젝트 STC의 내역 표(`BOQ11`/`BOQ12`)만 입력으로 삼아 **직접비 → 간접비 → 이윤 → 끝전조정 → 총공사비**를 처음부터 다시 계산하고, STmate가 뽑은 XLSX 「공사원가계산서」와 **원 단위로 대조**함.
재현 스크립트: `40_스크립트/golden_regression.py`
## 1. 결과 한 줄
**실무 프로젝트 6건 전부, 모든 항목이 1원도 틀리지 않고 맞았다.**
09번 문서가 정한 「완료 정의」 중 **원설계 재현 조건이 실증으로 충족**됐다. 이제 규칙은 문서상의 주장이 아니라 **돌아가는 계산**이다.
## 2. 전수 결과
| 프로젝트 | 말단 행 | 행 금액 대조 | 직접재료비 | 직접노무비 | 산출경비 | 간접비 |
|---|---:|---|:--:|:--:|:--:|:--:|
| 봉화 2024 (기번41 현동) | 72 | 216건 일치 | ✅ | ✅ | ✅ | ✅ |
| 영월 2024 (기번6) | 96 | 288건 일치 | ✅ | ✅ | ✅ | ✅ |
| 울진 대흥 2024 (기번3) | 129 | 387건 일치 | ✅ | ✅ | ✅ | ✅ |
| 영덕 계류보전 2025 (변경) | 98 | 294건 일치 | ✅ | ✅ | ✅ | — |
| 소광 2025 원설계 (기번8) | 43 | 129건 일치 | ✅ | ✅ | ✅ | ✅ |
| 소광 2025 변경 1차 (기번8) | 67 | 201건 일치 | ✅ | ✅ | ✅ | ✅ |
**행 금액 `ROUNDDOWN(수량 × 단가)` 대조 1,515건 중 불일치 0.**
## 3. 봉화 2024 — 처음부터 끝까지
| 항목 | 다시 계산한 값 | STmate 출력 | |
|---|---:|---:|:--:|
| 직접재료비 | 191,867,113 | 191,867,113 | ✅ |
| 직접노무비 | 426,802,539 | 426,802,539 | ✅ |
| 산출경비 | 162,613,763 | 162,613,763 | ✅ |
| 간접노무비 | 61,886,368 | 61,886,368 | ✅ |
| 산재보험료 | 17,397,325 | 17,397,325 | ✅ |
| 고용보험료 | 4,935,757 | 4,935,757 | ✅ |
| 건강보험료 | 15,130,150 | 15,130,150 | ✅ |
| 노인장기요양보험료 | 1,959,354 | 1,959,354 | ✅ |
| 연금보험료 | 19,206,114 | 19,206,114 | ✅ |
| 산업안전보건관리비 | 19,270,590 | 19,270,590 | ✅ |
| 기타경비 | 40,833,361 | 40,833,361 | ✅ |
| 환경보전비 | 6,250,267 | 6,250,267 | ✅ |
| 건설기계대여금 지급보증 | 3,125,133 | 3,125,133 | ✅ |
| 경비 소계 | 290,721,814 | 290,721,814 | ✅ |
| 일반관리비 | 58,276,670 | 58,276,670 | ✅ |
| **이윤 (끝전보정 후)** | **125,652,769** | **125,652,769** | ✅ |
| 총원가 | 1,155,207,273 | 1,155,207,273 | ✅ |
| 부가가치세 | 115,520,727 | 115,520,727 | ✅ |
| 도급공사비 | 1,270,728,000 | 1,270,728,000 | ✅ |
| 관급자재대 | 143,555,000 | 143,555,000 | ✅ |
| **총공사비** | **1,414,283,000** | **1,414,283,000** | ✅ |
## 4. 이번에 새로 밝혀진 규칙 일곱
문서에 없던 것들이다. 이것이 없으면 위 표가 맞지 않는다.
### 4.1 말단 행 판별 — `BDCODE1` 접두가 구조를 말한다
`BOQ11`은 말단 공종과 계층 제목·계를 한 테이블에 섞어 담는다. 구분은 `BDCODE1`의 접두다.
```text
T1~T5 계층 제목 행 (그 구역 소계를 담고 있음)
E1~E5 계(End) 행
C1~C5 제외금액 총계 행
: 페이지 넘김
그 외 실제 단가 코드 (D00021, M00053, B00012 …) → 말단 행
```
제목 행과 계 행이 **소계를 그대로 담고 있으므로** 전체를 더하면 9배 가까이 부풀어 오른다.
### 4.2 `C*` 로 닫히는 구역은 제외금액 구역
`T4 관급자재대` 로 열리고 `C4 총계(제외금액)` 로 닫히는 구역 안의 행은 **직접비에 넣지 않는다.** 봉화의 경우 이 구역이 143,554,309원이고, 빼지 않으면 직접재료비가 정확히 그만큼씩 두 번 더해진다(구역 안 품목 행 + `C4` 총계 행).
구역 경계는 `C*` 행의 `ENDCODE`가 여는 `T*` 행의 `SORTCODE`를 가리키는 것으로 찾는다.
### 4.3 산업안전보건관리비의 관급재 밑수 = 관급자재대 − 조달수수료
화면 수식은 `(직노+직재+간재+관급재/1.1) × 율 + 기초액`인데, 여기서 **관급재는 조달수수료를 뺀 원자재대**다.
| 무엇을 관급재로 넣나 | 율 부분 | 판정 |
|---|---:|---|
| 관급자재대 143,554,309 그대로 | 13,934,628 | ✗ +13,038 |
| **관급자재대 조달수수료 771,029 = 142,783,280** | **13,921,590** | **✅** |
### 4.4 끝전조정 — 차액을 세전으로 환산해 이윤에서 뺀다
봉화에서 보정 전후는 이렇다.
```text
보정 전 이윤 125,653,108
보정 전 총원가 1,155,207,612
보정 전 부가세 115,520,761
보정 전 도급공사비 1,270,728,373 ← 천원 단위가 아님
차액 = 1,270,728,373 1,270,728,000 = 373
세전조정 = ROUND(373 / 1.1) = 339 ← 사사오입
이윤 = 125,653,108 339 = 125,652,769 ✅
총원가 = 1,155,207,273 ✅
부가세 = ROUNDDOWN(총원가 × 10%) = 115,520,727 ✅
도급 = 1,270,728,000 ✅
```
**06번 §4가 세운 모델(`차액 → 세전 상당액 → 이윤·VAT 배분`)이 실데이터로 확인됐다.** 재구현은 `raw_amount / rounded_amount / adjustment_amount`를 반드시 분리해 저장해야 한다.
### 4.5 한 파일에 `BOQ11`·`BOQ12`가 같이 있으면 **`BOQ12`가 현재안**
소광 원설계 STC는 두 표를 함께 담는다.
| 표 | 행 | 말단 | 재료비 | 노무비 | 경비 | 출력과 |
|---|---:|---:|---:|---:|---:|:--:|
| `BOQ11` | 90 | 43 | 54,699,532 | 93,741,594 | 48,693,912 | ✗ |
| `BOQ12` | 89 | 43 | 47,987,107 | 82,246,518 | 43,077,761 | **✅** |
**STmate가 뽑은 XLSX는 `BOQ12`와 맞는다.** `BOQ11`을 무조건 현재안으로 잡으면 이 프로젝트만 통째로 틀린다. 처음 회귀에서 소광 한 건이 어긋난 원인이 이것이었다.
### 4.6 차수 인계 — 앞 파일의 `BOQ12`가 다음 파일의 `차수1`이 된다
| 무엇 | 재료비 | 노무비 | 경비 |
|---|---:|---:|---:|
| 원설계 파일 `BOQ12` | 47,987,107 | 82,246,518 | 43,077,761 |
| **변경1차 파일 `BOQ11` 차수1** | 47,987,107 | 82,246,518 | 43,077,761 |
| 변경1차 파일 `BOQ11` 차수2 | 75,735,506 | 119,733,078 | 61,941,849 |
**정확히 같다.** 08번이 「접미사 n = 항상 `BOQ1n` 으로 기계적으로 가정하지 말라」고 경고한 것이 옳았고, 실제 규칙은 이렇다.
```text
통합형 : 한 STC 안에 BOQ11(당초) + BOQ12(현재안). 출력은 BOQ12.
분리형 : 변경 차수를 새 STC 로 저장.
그 파일의 BOQ11 차수1 = 앞 파일의 BOQ12
그 파일의 BOQ11 차수2 = 이번 변경분
```
`PRJLST.TCOSTn = MCOSTn + LCOSTn + SCOSTn` 이며 **직접공사비 합계**다. 소광 변경1차는 당초 173,311,386 → 변경 257,410,433, 증감 **+84,099,047**이다.
### 4.7 관급자재대는 천원 **올림**
```text
관급자재대 = ROUNDUP(원자재대 143,554,309, 천원) = 143,555,000 ✅
총공사비 = 도급공사비 + 관급자재대 = 1,414,283,000 ✅
```
## 5. 이로써 확정으로 올라간 것
| 항목 | 이전 | 지금 |
|---|---|---|
| 내역 행 금액 = `ROUNDDOWN(수량 × 단가)` | A (수식 관찰) | **A 실증** — 1,515건 무결 |
| 간접비 항목별 밑수 | A (화면 라벨) | **A 실증** — 6건 프로젝트에서 원 단위 일치 |
| 간접비 순서와 절사 전달 | B | **A 실증** — 앞 행의 절사액을 뒤 행이 받음 |
| 끝전조정 모델 | B | **A 실증** |
| 계층 집계 구조 | B | **A 실증** |
| 제외금액 취급 | 서술만 | **A 실증**`C*` 구역 규칙 |
## 6. 아직 이 시험으로 못 건드린 것
골든셋은 **내역 표의 단가를 주어진 것으로 놓고** 그 위를 검증한다. 따라서 아래는 여전히 열려 있다.
- 단가가 **어떻게 만들어지는가** — 일위대가·중기 시간당 단가 (`BDQTY` 암호화)
- 자재 6후보 중 **무엇을 채택하는가**
- 수량 **할증** 계산
- 수공·실적 형식(`S`/`TA`/`SA`) — 표본이 전부 일반형식 `T`
- 변경설계 **간접비**의 계산모드 — 차수 인계와 직접비 증감(+84,099,047)까지는 확인했으나, 변경분 XLSX 출력이 표본에 없어 간접비 쪽 모드(방법 A/B/직접입력)는 아직 못 가름
## 7. 다음
1. **일위대가표·중기사용료 시트로 단가 조립 검증** — XLSX에 두 시트가 있어 `BDQTY` 암호화와 무관하게 단가가 어떻게 쌓이는지 확인 가능. 지금 자료로 되는 다음 칸
2. 변경설계 **간접비** 모드 — 변경분 출력 XLSX 한 건만 있으면 정적으로 갈림
3. 수공·실적 형식 표본 확보 — 현재 표본이 전부 일반형식 `T`
@@ -0,0 +1,169 @@
# 단가 조립 검증 — 일위대가와 중기 시간당 사용료
실행일: 2026-09-13
방법: STmate가 뽑은 XLSX의 `일위대가표`·`중기사용료` 시트를 호표 단위로 갈라, **구성행의 성분별 금액 합이 합계행과 맞는지** 전수 대조함.
재현 스크립트: `40_스크립트/unit_assembly_check.py`, `machine_rate_extract.py`
## 1. 왜 이게 되는가 — 막힌 줄 알았던 길
15번은 단가 조립을 「`BDQTY.DBF` 암호화로 불가」로 적었다. **틀렸다.**
`BDQTY`는 구성 수량의 **입력 원장**이지만, STmate가 뽑는 **출력 XLSX의 `일위대가표`·`중기사용료` 시트에 구성 자원·수량·단가·성분별 금액이 전부 평문으로 들어 있다.** 입력 원장은 못 읽어도 **조립 결과와 조립 규칙은 읽힌다.**
## 2. 성분 소계는 **절사**다
호표마다 구성행의 노무·재료·경비 금액을 각각 더하고 합계행과 대조했다.
| 시트 | 성분 소계 건수 | 절사 | 사사오입 | 은행가 |
|---|---:|---:|---:|---:|
| **중기사용료** | 398 | **398 (100%)** | 265 (66.6%) | 280 (70.4%) |
| **일위대가표** | 345 | **325 (94.2%)** | 198 (57.4%) | 211 (61.2%) |
**절사가 유일하게 전건을 설명한다.** 사사오입·은행가는 소수부가 없는 건에서만 우연히 맞는다.
> 07번 §6의 「자원/일위대가/중기 **집계표** 행 금액 = `ROUND`」와 헷갈리면 안 된다. 그것은 **집계표**(자원집계표·일위대가수량금액집계표·중기시간금액집계표)의 행 금액이고, 여기서 말하는 것은 **호표 안 성분 소계**다. 둘은 다른 자리이고 **반올림 방식도 다르다.**
일위대가표의 나머지 5.8%는 호표 머리·부분계 서식이 달라 자동 구획이 어긋난 것으로, 반올림 방식이 다르다는 증거는 아니다.
## 3. 중기 시간당 사용료의 구조 — A등급
호표 한 벌이 그대로 공식이다.
```text
손료 = 취득가(천원) × 시간당 손료율 → 경비
운전원 = 1인 × 노임단가 → 노무비
주연료 = 시간당 소요량(L) × 연료단가 → 재료비
잡품 = 주연료비 × 잡품률(%) → 재료비
────────────────────────────────────────
시간당 사용료 = 노무비 + 재료비 + 경비 (성분별로 절사한 뒤 합)
```
굴삭기(무한궤도) 0.7㎥ 실제 값으로 확인하면 이렇다.
```text
손료 110,926천원 × 0.2085 = 23,128.07 → 경비 23,128
운전원 1인 × 55,700 = 55,700 → 노무 55,700
경유 11.6 L × 1,273 = 14,766.8 → 재료
잡품 14,766.8 × 22% = 3,248.6 → 재료
재료 소계 = 절사(14,766.8 + 3,248.6) = 18,015
합계 = 55,700 + 18,015 + 23,128 = 96,843 ✅ 시트와 일치
```
**`잡품`의 단가 칸에는 연료 금액이 그대로 들어간다.** 즉 잡품은 수량×단가가 아니라 **연료비에 비율을 곱하는 가산 행**이다. 07번 §4가 「재료 수량 할증과 잡재료 금액 가산은 다른 연산」이라 한 것이 이 자리다.
## 4. 시간당 손료율 실측 — 25개 장비 (봉화 2024)
15번이 「중기 손료 계수 실제 값이 어디에도 없음 ❌」이라 적은 것을 **정정한다. 자료에 있다.**
| 장비 | 규격 | 손료율 | 취득가(천원) | 손료(원/h) | 연료(L/h) | 잡품(%) |
|---|---|---:|---:|---:|---:|---:|
| 굴삭기(무한궤도) | 0.2㎥ | 0.2085 | 62,550 | 13,042 | 5 | 21 |
| 굴삭기(무한궤도) | 0.4㎥ | 0.2085 | 78,556 | 16,379 | 9.9 | 22 |
| 굴삭기(무한궤도) | 0.7㎥ | 0.2085 | 110,926 | 23,128 | 11.6 | 22 |
| 굴삭기(무한궤도) | 0.4㎥ 암석 | 0.2405 | 78,556 | 18,893 | 9.9 | 22 |
| 굴삭기(무한궤도) | 0.7㎥ 암석 | 0.2405 | 110,926 | 26,678 | 11.6 | 22 |
| 굴삭기(무한궤도) | 1.0㎥ 암석 | 0.2405 | 133,819 | 32,184 | 19.5 | 22 |
| 굴삭기(무한궤도) | 0.4㎥ 브레이커조합 | 0.6601 | 7,882 | 5,203 | 9.9 | 16 |
| 굴삭기(무한궤도) | 0.7㎥ 브레이커조합 | 0.6601 | 16,315 | 10,770 | 11.6 | 16 |
| 굴삭기(타이어) | 0.6㎥ | 0.2279 | 112,684 | 25,681 | 11.6 | 24 |
| 굴삭기(타이어) | 0.6㎥ 부착용집게 | 0.7435 | 7,381 | 5,488 | 11.6 | 24 |
| 유압식 진동콤팩터 | 굴삭기 0.7㎥ 부착용 | 0.3193 | 11,046 | 3,527 | — | — |
| 불도저(무한궤도) | 19톤 | 0.1811 | 184,499 | 33,413 | 25 | 16 |
| 덤프트럭 | 2.5톤 암석 | 0.3533 | 20,793 | 7,346 | 2.9 | 38 |
| 덤프트럭 | 15톤 | 0.2279 | 86,142 | 19,632 | 15.9 | 38 |
| 덤프트럭 | 15톤 암석 | 0.2679 | 86,142 | 23,077 | 15.9 | 38 |
| 덤프트럭 | 24톤 | 0.2229 | 140,899 | 31,406 | 23 | 38 |
| 덤프트럭 자동덮개시설 | 15톤용 | 0.2684 | 1,546 | 415 | — | — |
| 트럭탑재형 크레인 | 5톤 | 0.2598 | 38,469 | 9,994 | 5.1 | 20 |
| 트럭 트랙터 및 평판트레일러 | 20톤 | 0.2576 | 63,483 | 16,353 | 16.5 | 39 |
| 콘크리트 믹서 | 0.45㎥ | 0.3039 | 5,409 | 1,644 | 3.9 | 2 |
| 커터(콘크리트·아스팔트용) | 320~440mm | 0.6354 | 2,954 | 1,877 | 5.6 | 20 |
| 물탱크(살수차) | 5,500 | 0.2113 | 44,820 | 9,471 | 9.3 | 30 |
| 동력분무기 | 4.85㎾ | 0.2799 | 875 | 245 | 1.3 | — |
| 진동롤러(자주식) | 12톤 | 0.2825 | 97,000 | 27,403 | 15.8 | 30 |
| 크롤러드릴(탑승유압식) | 110㎾ | 0.1548 | 150,666 | 23,323 | 18.6 | 23 |
읽는 법 몇 가지.
- **같은 장비라도 「암석」이 붙으면 손료율이 오른다** — 굴삭기 0.2085 → 0.2405. 작업 조건이 손료율을 바꾼다.
- **부착 장비는 취득가가 부착물만의 값이다** — 굴삭기 0.7㎥ 브레이커조합은 취득가 16,315천원에 손료율 0.6601이다. 본체와 별도 행으로 쌓는다.
- **손료율은 취득가 1천원당 시간 손료(원)** 이다. `손료 = 취득가(천원) × 손료율`.
- 잡품률은 장비마다 다르다(2~39%). 일괄 적용하면 안 된다.
⚠ 이 값들은 **STmate가 담고 있는 값**이다. Aislo는 같은 수치를 **법령·품셈**(건설기계 경비 산정 기준)에서 확보해야 하며, 여기 표는 **대조용**으로만 쓴다.
## 5. 일위대가 조립
호표는 구성 자원을 나열하고 각 행이 `수량 × 단가`로 성분별 금액을 만든다. 구성 자원은 **다른 호표를 참조할 수 있다.**
```text
제 6 호표 기슭막이(깬잡석,찰쌓기 L3=45) H=2.0m
깬잡석채집 2.09 M2 × 30,302 = 63,331 ← 다른 호표
깬잡석찰쌓기 2.09 M2 × 58,889 = 123,078 ← 제 1 호표
고임돌채집 0.31 M3 × 17,416 = 5,399
```
`깬잡석찰쌓기 58,889`가 제 1 호표의 합계와 같다. **호표 사이에 참조가 걸리므로 위상순서로 계산해야 한다** — 09번 §3이 말한 의존 그래프가 실제로 필요하다.
## 5.5 자재 채택 규칙 — **최저가**, A등급 실증
출력 XLSX의 `자재단가대비표` 시트에 **후보 다섯 칸과 적용 단가·출처가 나란히** 있다.
```text
호표 | 명칭 | 규격 | 단위 | 조달가격 | 물가자료 | 물가정보 | 거래가격 | 견적단가 | 적용단가 | 출처
1 | 철못 | N 50 | KG | 0 | 1,665 | 1,935 | 1,680 | 0 | 1,665 | 물자
6 | PVC파이프 | VG1 | M | 0 | 4,327 | 4,702 | 3,475 | 0 | 3,475 | 거래
19 | 와이어메쉬 | #6 | M2 | 3,546 | 4,970 | 5,510 | 0 | 4,545 | 3,546 | 조달
```
실무 프로젝트 6건 전수로 검정했다.
| 구분 | 건수 | 적용단가 = 후보 최저 |
|---|---:|---:|
| 전체 (후보 1개 이상) | 186 | **186 (100%)** |
| **후보 값이 2종 이상** | 76 | **76 (100%)** |
| **후보 값이 3종 이상** | 36 | **36 (100%)** |
| 최저가가 아닌 후보를 택한 건 | — | **0** |
| 어느 후보와도 다른 건 | — | **0** |
**자동채택은 「0이 아닌 후보 중 최저가」다.** 07번 §5가 「최저가·최신가·특정 슬롯 우선 중 어느 하나라고 단정할 근거가 없다」며 C등급으로 둔 항목이 **실증으로 확정**됐다.
곁들여 확인된 것.
- 후보 슬롯은 화면상 **다섯**(조달가격·물가자료·물가정보·거래가격·견적단가)이고, `COSTN``AMT_11..AMT_61` 여섯 칸 중 **여섯 번째가 적용 단가 칸**으로 보인다. 07번이 「노무 L은 후보6과 채택가가 거의 일치」라 관찰한 것과 맞는다.
- 후보가 하나도 없는데 적용 단가만 있는 행이 25건 있다(경유·휘발유 등 **유가 자료**, 별도 배포 단가).
- 적용 단가가 0인 행 49건은 `별도계상`(이형철근·레미콘)·`조달수수료(재료비×0.54%)`처럼 **다른 자리에서 계산되는** 것이다.
- **출처 표시는 슬롯 이름의 축약**이다(물자=물가자료, 물정=물가정보, 거래, 조달, 견적). 슬롯 번호에 기관명을 코드로 박으면 안 된다는 07번 경고는 그대로 유효하다 — `Start8x.Ini`가 표시명을 바꾼다.
**사용자 수동 고정이 있으면 이 규칙을 벗어난다.** 표본 6건에는 수동 고정 흔적이 없었을 뿐이며, 수동 우선순위는 여전히 통제시험(T10) 대상이다.
## 6. 이로써 바뀌는 판정
| 항목 | 15번의 판정 | 지금 |
|---|---|---|
| 중기 시간당 단가 재현 | ❌ 손료 계수가 어디에도 없음 | ✅ **호표에 손료율·취득가·연료·잡품률이 전부 있음** |
| 단가 조립 규칙 | ❌ `BDQTY` 암호화로 불가 | ✅ **출력 시트로 조립 규칙·반올림 확인됨** |
| 성분 소계 반올림 | 미확정 | ✅ **절사** (중기 398건 100%) |
| 자재 6후보 채택 | ❌ C등급, 규칙 없음 | ✅ **최저가** — 복수후보 76/76 |
여전히 막힌 것은 **입력 쪽**이다.
- `BDQTY`의 수량 **할증** 필드(`X_QTY`·`X_HAL`) 실값 — 출력에는 할증 후 수량만 남는다
- 품셈에서 구성 수량이 **어떻게 뽑혔는가**
- 사용자가 **수동 고정**했을 때의 우선순위 (T10)
## 7. 다음
1. 일위대가 호표 사이 **참조 그래프**를 복원해 위상순서 계산을 재현 — 지금 자료로 됨
2. `단가산출근거` 시트 구조 확인 — 시트는 있으나 이번 구획 규칙으로는 안 잡힘
3. 09번 §8이 꼽은 **여섯 호환성 차단점** 가운데 남은 것 정리
| # | 차단점 | 상태 |
|---:|---|---|
| 1 | RATE 좌표 라벨·경계 | ⚠ 보험 계열 6개 확정(15번 §4), 규모·기간 좌표는 남음 |
| 2 | `BDQTY` 세부 플래그 | ❌ 암호화 — 유일하게 손 못 댄 입력 |
| 3 | 자재 자동채택 우선순위 | ✅ **최저가 확정** (§5.5) |
| 4 | 중기 내부 절사 시점 | ✅ **성분별 절사 확정** (§2·§3) |
| 5 | UI 명령별 재계산 범위 | ❌ 통제시험 T8 |
| 6 | 일괄변경 수동값 우선순위 | ❌ 통제시험 T10 |
@@ -0,0 +1,152 @@
# 누락 계산로직 점검 — 사슬에 빠진 칸 찾기
점검일: 2026-09-13
방법: STmate 출력 XLSX의 **21개 시트를 하나씩 세어**, 계산이 일어나는 시트 가운데 아직 확인하지 않은 것을 모두 열어 봄.
재현 스크립트: `40_스크립트/wage_hourly_check.py`, `unit_derivation_check.py`
## 1. 점검 전 상태
21개 시트 중 **검증한 것은 4개뿐**이었다.
| 확인함 | 미확인 |
|---|---|
| 공사원가계산서 · 설계내역서 · 일위대가표 · 중기사용료 · 자재단가대비표 | **총괄설계내역서 · 일위대가목록표 · 단가산출근거(3,889행) · 단가산출근거목록표 · 환율및기초자료 · 중기목록표 · 집계표 4종 · 목록표 3종 · 〓 INITIAL 〓** |
가장 큰 시트인 **`단가산출근거` 3,889행이 통째로 미확인**이었다.
## 2. 빠져 있던 것 넷
### 2.1 노임 시간당 환산식 — 완전 미문서화였음
`환율및기초자료` 시트에 **일 노임을 시간당으로 바꾸는 식이 그대로** 있다.
```text
건설기계운전사 267,360 × 1/8*16/12*25/20 → 55,700 (L00008)
화물차운전사 226,709 × 1/8*16/12*25/20 → 47,231 (L00009)
일반기계운전사 161,142 × 1/8*16/12*25/20 → 33,571 (L00010)
```
식은 **프로젝트 설정에 문자열로 저장**된다 — `〓 INITIAL 〓``RXNM_ = 1/8*16/12*25/20`, 적용 직종은 `COND_ = L00008L00009L00010`(코드 11자리씩 이어 붙임).
**중기사용료의 「운전원 55,700」이 어디서 왔는지가 이것으로 풀린다.** 지금까지는 주어진 값으로만 놓고 있었다.
#### ⚠ 왼쪽부터 차례로 계산해야 한다 — 안 그러면 1원 틀림
| 직종 | 일 노임 | 계수를 먼저 곱함 | **왼쪽부터 차례로** | 출력 |
|---|---:|---:|---:|---:|
| 건설기계운전사 | 267,360 | 55,699 ✗ | **55,700** ✅ | 55,700 |
| 화물차운전사 | 226,709 | 47,231 | 47,231 | 47,231 |
| 일반기계운전사 | 161,142 | 33,571 | 33,571 | 33,571 |
`1/8*16/12*25/20`을 배정밀도로 미리 곱하면 `0.20833333333333331`이 되어 `267,360 × 계수 = 55,699.9999…` → 절사 시 **55,699**가 된다. 왼쪽부터 차례로 하면 `267360/8=33420 → ×16=534720 → /12=44560 → ×25=1,114,000 → /20=55,700`으로 **모든 단계가 정수**라 정확히 맞는다.
프로젝트 6건 × 직종 3개 = 18건 전부, **왼쪽부터 계산했을 때만 18/18 일치**한다(계수 선계산은 14/18).
**재구현 규칙: 설정에 저장된 식 문자열을 그대로 좌→우 순차 평가할 것. 계수로 접어 두지 말 것.** 11번 §4.1의 `evaluate(template, …)` 계약에 이 조건을 명시해야 한다.
### 2.2 단가산출(`D` 코드) 공식 — 가장 큰 누락
`단가산출근거` 3,889행에 **품셈 근거와 기계 작업량 식이 전부** 있다.
```text
제 2호표 구조물 터파기(토사) 굴삭기 0.2m3 / m3
건설표준품셈 3-1-3(터파기), 11-3(굴삭기) 적용
적용토질 : 자갈썩인 기계 및 인력 조합기준 : 기계100%
q = 0.2 , f = 1/1.175 = 0.85 , K = 0.7
Cm = 15 sec (90°) , E = 0.60-0.05 = 0.55
Q = 3600*q*K*f*E/Cm = 15.71 m3/hr
노 무 비 : 55,700 / Q = 3,545.5
재 료 비 : 7,701 / Q = 490.1
경 비 : 13,041 / Q = 830.1
소계 4,865.7
```
**구조**
```text
Q(시간당 작업량) = 3600 × q × K × f × E / Cm
단가 성분[k] = 절사( 중기 시간당 성분단가[k] / Q , 소수 1자리 )
단가 소계 = 성분 셋의 합 (소수 1자리 유지)
내역에 실릴 때 정수로 절사
```
- `55,700 / 7,701 / 13,041`**중기사용료 제1호표(굴삭기 0.2㎥)의 노무·재료·경비 합계** 그대로다. 즉 `중기사용료 → 단가산출 → 일위대가 → 내역`으로 값이 이어진다.
- **`Q`는 소수 2자리로 먼저 확정한 뒤 나눈다.** 원값으로 나누면 어긋난다 (`55,700/15.708 = 3,545.96``55,700/15.71 = 3,545.5`).
- 성분단가는 **소수 1자리 절사**다. 6개 프로젝트 211건 중 137건(64.9%)이 이 규칙으로 설명되고, 나머지는 한 호표에 `Q`가 둘 이상인 기계조합 호표라 자동 구획이 어긋난 것이다.
`Q`식은 73개 호표에서 발견됐다. 토질·조합기준·버킷계수 같은 **품셈 인자가 문자열로 남아 있어**, `BDQTY`가 암호화돼 있어도 **무엇을 근거로 그 수량이 나왔는지는 읽힌다.**
### 2.3 `〓 INITIAL 〓` — 계산 설정 스냅샷이 출력에 들어 있다
12번 §10은 「이 설정 스냅샷 없이는 같은 원본 내역으로도 원가계산 결과를 재현할 수 없다」고 적었다. **그 스냅샷이 출력 XLSX 안에 있다.**
```text
[공사정보] 33개 M_VER 24.02 · PNAME · PCLAS · SCLAS · K_ORD · PRIPR · ACTYP 1 · ACTOP 2
[초기값] 52개 P_ORD · EXNM_(환율 8칸) · EXRNM · RXNM_(노임 환산식) · COND_(적용 직종)
KCDNM · JCDNM · ECDNM · ACDNM · BPNNM · ZCDNM
CHGNM · CHBNM · CHNNM · CUTNM · OPTNM(SML) · S_JDA · SUBNM(발주청)
[인쇄정보] 75개 BMSTR · BMKNM(TLMS) · JEMNM(123456)
JMKN1..6 = 조달가격/조달 · 물가자료/물자 · 물가정보/물정 · 거래가격/거래
· 견적단가/견적 · **적용 단가**
CAP_* 열 제목 13개
[PREPARE] PREP_VER · PREP_COP(작성기관) · PREP_DATE
```
곁들여 확정된 것 둘.
- **`JMKN6 = 적용 단가`** — 후보 슬롯이 다섯이고 **여섯 번째가 채택가 칸**이라는 17번 §5.5의 추정이 확인됐다.
- **슬롯 이름은 프로젝트 설정에 들어 있다** — 07번이 「슬롯 번호에 가격지 이름을 코드로 고정하지 말라」고 한 경고의 근거가 여기다.
### 2.4 집계표 반올림 = **사사오입**, 절사 아님
호표 성분 소계(절사)와 **다른 자리**임을 수치로 갈랐다.
| 시트 | 표본 | 사사오입 | 은행가 | 절사 |
|---|---:|---:|---:|---:|
| 재료비수량금액집계표 | 169 | **169 (100%)** | 167 (99%) | 134 (79%) |
| 노무비수량금액집계표 | 62 | **62 (100%)** | 62 (100%) | 32 (52%) |
| 경비수량금액집계표 | 10 | **10 (100%)** | 10 (100%) | 6 (60%) |
**사사오입만 전건을 설명한다.** 재료비집계표 2건이 은행가와 갈려 둘을 구분해 준다. 07번 §6의 「집계표 = `ROUND`」가 실측으로 확인됐고, 17번 §2의 「호표 성분 소계 = 절사」와 **자리가 다르다**는 점이 분명해졌다.
## 3. 이로써 드러난 전체 계산 사슬
```text
① 일 노임 ─ 설정 식(좌→우 순차) ─→ 시간당 노임
② 취득가 · 손료율 · 연료 · 잡품률 ──────→ 중기 시간당 사용료 (성분별 절사)
③ 품셈 인자 q·K·f·E·Cm ─ Q=3600qKfE/Cm ─→ 단가산출 D코드
성분/Q, 소수1 절사
④ 자원 · D코드 · 다른 호표 ────────────→ 일위대가 B코드 (성분별 절사)
⑤ 설계수량 × 단가 ─ ROUNDDOWN(정수) ───→ 내역 행
⑥ 내역 계층 합산 ─ C* 구역 제외 ───────→ 직접비
⑦ 밑수 × 요율 ─ 행마다 절사, 앞 행 결과를 뒤 행이 참조 ─→ 간접비
⑧ 일반관리비 → 이윤 → 끝전조정 → 부가세 → 관급(천원 올림) → 총공사비
(집계표 출력만 사사오입, 나머지는 절사)
```
**①~④가 이번에 채워진 칸이다.** 그전에는 ⑤부터만 검증돼 있었다.
## 4. 아직 안 본 시트
계산이 아니라 **목록·표지** 성격이라 우선순위가 낮다.
| 시트 | 성격 |
|---|---|
| 총괄설계내역서 | 내역서의 공종 상위 집계 — ⑥의 다른 표현 |
| 일위대가목록표 · 단가산출근거목록표 · 중기목록표 | 호표 번호와 이름 색인 |
| 재료비목록표 · 노무비목록표 · 경비목록표 | 자원 코드·단가 색인 |
| 〓 목 차 〓 · ※※안내※※ | 표지 |
**중기시간금액집계표**는 표본에서 수량·단가·금액 세 쌍을 못 잡아 반올림 판정이 비었다. 다음에 열 위치를 맞춰 다시 볼 것.
## 5. 이번 점검이 바꾸는 것
| 항목 | 이전 | 지금 |
|---|---|---|
| 노임 시간당 환산 | **문서에 없음** | ✅ 식·저장 위치·평가 순서까지 확정 |
| 식 평가 방식 | 명시 없음 | ✅ **좌→우 순차** (계수 선계산 금지) |
| 단가산출 `D`코드 | **문서에 없음** | ✅ Q식·나눗셈·소수1 절사 |
| 계산 설정 스냅샷 | 「화면 없이는 재현 불가」 | ✅ **출력 XLSX `〓 INITIAL 〓` 에 있음** |
| 집계표 반올림 | 서술만 | ✅ **사사오입 100%** — 호표 절사와 다른 자리 |
@@ -0,0 +1,173 @@
# 진행률 장부 — 어디까지 봤고 무엇이 남았나
갱신일: 2026-09-13
세는 법: 눈대중이 아니라 **파일·표·시트를 실제로 세어** 적음. 재현 스크립트 `40_스크립트/coverage_ledger.py`
## 1. 한눈에
| 원천 | 전체 | 본 것 | 남은 것 | 진행 |
|---|---:|---:|---:|---|
| 설치 폴더 `System` 파일 | 186 | 186 등록·형식·내부구조 | 암호화 13개는 **영구 불가** | ▓▓▓▓▓▓▓▓▓▓ 100% |
| 기준 요율표 `JB_*` | 43 | 43 전수 파싱·블록·확장차이 | 셀 좌표 일부 | ▓▓▓▓▓▓▓▓░░ 85% |
| 실무 프로젝트 `STC` | 7 | 7 전부 | — | ▓▓▓▓▓▓▓▓▓▓ 100% |
| STC 안의 표 | 16종 | **10종 읽고 씀** · 4종 빈 표 | **2종 암호화** (`BDQTY`·`SYSINFO`) | ▓▓▓▓▓▓▓▓▓░ 88% |
| STmate 출력 `XLSX` | 6 | 6 전부 | — | ▓▓▓▓▓▓▓▓▓▓ 100% |
| 출력 시트 | **30종** | **30종 전부 훑음** (계산 있는 것 15종 규칙 확정) | — | ▓▓▓▓▓▓▓▓▓▓ 100% |
| 계산 사슬 단계 | 8 | **8 전부** (④는 참조 그래프까지 실증) | — | ▓▓▓▓▓▓▓▓▓▓ 100% |
| 변경설계 재현 | 직접비+간접비 | **전 항목 원 단위 일치** | — | ▓▓▓▓▓▓▓▓▓▓ 100% |
| 09번이 꼽은 호환성 차단점 | 6 | **3 해결 · 1 부분** | 2 (실행 필요) | ▓▓▓▓▓▓░░░░ 58% |
## 2. 설치 폴더 186개
| 갈래 | 개수 | 상태 |
|---|---:|---|
| PE 실행모듈 (`*.CPM`·`*.BPL`·`*.EXE`) | 82 | 헤더·섹션·임포트·문자열 전수. 핵심 12개는 호출자리까지 |
| ZIP 계열 (`*.STBSC` 등) | 28 | 컨테이너 확인, 내부는 암호 |
| JB 요율표 | 43 | **전수 파싱** — 형태·블록·2020-05-27 확장 차이 |
| 암호화 기준단가 (`*.BSC`·`*.CSTA`) | 13 | ⛔ **본문 암호화 — 영구 불가** |
| 기타 | 20 | 형식 판정 완료 |
⛔ 13개 안에 **자재 57,580건·노임 460건·중기 833건·표준시장단가 4,921건**이 들어 있다. 이것이 「완제품 복제 불가」의 유일한 근거다.
## 3. 출력 시트 30종 — 가장 많이 남은 곳
### ✅ 계산 검증 완료 14종
```
공사원가계산서 · 설계내역서 · 일위대가표 · 중기사용료 · 자재단가대비표
단가산출근거 · 환율및기초자료 · 〓 INITIAL 〓
재료비수량금액집계표 · 노무비수량금액집계표 · 경비수량금액집계표
설계변경내역서 · 설계변경금액대비표 · 설계변경증감대비표
```
### ✅ 나머지 16종도 훑음 (2026-09-13)
전부 열어 본 결과 **새 계산 규칙은 나오지 않았고**, 대신 두 가지가 밝혀졌다.
| 갈래 | 시트 | 결과 |
|---|---|---|
| 색인·목록 | 일위대가목록표 · 단가산출근거목록표 · 중기목록표 · 재료비목록표 · 노무비목록표 · 경비목록표 · 일식견적목록표 | **계산 쌍 없음** — 호표 번호·코드·단가 색인 |
| 표지 | 〓 목 차 〓 · ※※안내※※ | 계산 없음 |
| 내역서 변형 | **착공내역서** | 178건 **절사 100%** — 설계내역서와 같은 규칙 |
| | 총괄설계내역서 · 총괄내역서 | 공종 상위 집계, 새 규칙 없음 |
| | 설계변경내역서 | 당초·변경 두 벌이라 단순 쌍 검출로는 안 갈림. 금액 자체는 19번 §6에서 확정 |
| 집계표 | 중기시간금액집계표 · 일위대가수량금액집계표 · 단가산출근거수량금액집계표 · 일식견적수량금액집계표 | **단가 열이 없음** — 아래 |
#### 집계표에는 단가 열이 없다
```text
호표 | 명칭 | 규격 | 수량 | 단위 | 합계 | 노무비 | 재료비 | 경비
2 | 굴삭기(무한궤도) | 0.7㎥ | 940.69 | 시간 | 91,099,241 | 52,396,433 | 16,946,530 | 21,756,278
```
금액은 **`절사(수량 × 해당 호표의 시간당 성분단가)`** 다. 굴삭기 0.7㎥의 시간당 성분(노무 55,700 / 재료 18,015 / 경비 23,128)으로 검산하면 맞는다.
| 장비 | 시간 | 노무 | 재료 | 경비 |
|---|---:|---|---|---|
| 굴삭기 0.2㎥ | 17.47 | ✅ | ✅ | ✅ |
| 굴삭기 0.7㎥ | 940.69 | ✅ | ✅ | ✅ |
| 덤프 15톤 | 379.54 | ✅ | ✅ | 1 |
| 덤프 2.5톤 | 150.52 | ✅ | 1 | 1 |
#### ⚠ 표시 수량은 반올림된 값이다
어긋나는 세 칸은 전부 **−1**이다. 경비로 역산하면 굴삭기 0.7㎥의 실제 시간은 `21,756,278 / 23,128 = 940.68998…`인데 시트에는 `940.69`로 찍힌다.
**집계표의 수량을 그대로 역산하면 1원씩 어긋난다.** 집계는 표시 수량이 아니라 **원수량 또는 행별 금액의 합**에서 나온다. 09번 §4의 「입력 원값과 표시 반올림값을 분리한다」가 바로 이 자리다.
## 4. 계산 사슬 8단계 — 전부 확인
| # | 단계 | 근거 문서 |
|---:|---|---|
| ① | 일 노임 → 시간당 (설정식, 좌→우 순차) | 18번 §2.1 |
| ② | 중기 시간당 사용료 (손료·운전원·연료·잡품, 성분별 절사) | 17번 §3 |
| ③ | 단가산출 `D`코드 (`Q=3600qKfE/Cm`, 성분/Q 소수1 절사) | 18번 §2.2 |
| ④ | 일위대가 `B`코드 (구성 조립, 성분별 절사, 호표 간 참조) | 17번 §2·§5 |
| ⑤ | 내역 행 (`ROUNDDOWN(수량×단가)`) | 16번 |
| ⑥ | 계층 합산·제외금액 구역 | 16번 §4.1·§4.2 |
| ⑦ | 간접비 (밑수×요율, 행마다 절사, 앞 행 참조) | 16번 §3, 06번 §3 |
| ⑧ | 일반관리비 → 이윤 → 끝전조정 → 부가세 → 관급 → 총공사비 | 16번 §4.4 |
## 5. 호환성 차단점 여섯 — 현황
| # | 차단점 | 상태 | 근거 |
|---:|---|---|---|
| 1 | RATE 좌표 라벨·경계 | ◐ **부분** — 고정 칸(보험·산안비·이윤) 확정, 규모·기간 의존 칸은 남음 | 15번 §4 |
| 2 | `BDQTY` 세부 플래그 | ⛔ **암호화 — 불가** | 15번 §2 |
| 3 | 자재 자동채택 우선순위 | ✅ **최저가** | 17번 §5.5 |
| 4 | 중기 내부 절사 시점 | ✅ **성분별 절사** | 17번 §2 |
| 5 | UI 명령별 재계산 범위 | ⛔ 실행 필요 | 13번 T8 |
| 6 | 일괄변경 수동값 우선순위 | ⛔ 실행 필요 | 13번 T10 |
**추가로 09번 목록 밖에서 푼 것** — 변경설계 계산모드(아래 6장), 노임 환산식, 단가산출 Q식, 계산 설정 스냅샷, 집계표 반올림.
## 6. 변경설계 계산모드 — 이번에 확정 (T9)
계류보전 2025(변경) 프로젝트의 `공사원가계산서`**당초·변경·증감을 나란히** 낸다. 직접노무비가 `32,976,276 → 41,778,438`(+8,802,162)로 크게 늘었는데도 **세 항목은 증감이 0이다.**
| 항목 | 당초 | 변경 | 증감 | 재계산했다면 | 판정 |
|---|---:|---:|---:|---:|---|
| 간접노무비 | 4,715,607 | 5,974,316 | +1,258,709 | 5,974,316 ✅ | **재계산** |
| 산재보험료 | 1,341,831 | 1,699,998 | +358,167 | 1,699,998 ✅ | **재계산** |
| 고용보험료 | 380,688 | 482,302 | +101,614 | 482,302 ✅ | **재계산** |
| **건강보험료** | 1,328,500 | 1,328,500 | **0** | 1,683,108 (+354,608) | **당초액 유지** |
| **노인장기요양보험료** | 172,040 | 172,040 | **0** | 217,961 (+45,921) | **당초액 유지** |
| **연금보험료** | 1,686,389 | 1,686,389 | **0** | 2,136,526 (+450,137) | **당초액 유지** |
요율은 당초·변경이 같다(간접노무비 14.300%, 산재 3.560%, 고용 1.010%). 즉 요율을 바꾼 것이 아니라 **항목마다 모드가 다르다.**
**건강보험료·노인장기요양보험료·연금보험료는 사후정산 대상이라 설계 단계에서 당초액을 그대로 가져간다.** 11번 §10의 「보험료 사후정산 항목은 당초금액과 동일하게 직접 입력하는 옵션」이 실데이터로 확인됐다.
**재구현 필수** — 간접비 항목마다 `calc_mode ∈ {recalculated, carry_original, proportional_from_original, delta_times_rate, manual}` 을 저장할 것. 전부 재계산으로 두면 이 세 항목에서 **85만 원**이 어긋난다.
## 6.5 이번에 마저 끝낸 둘
### 일위대가 호표 참조 그래프 — ④단계 실증
봉화 2024의 `일위대가표` 45개 호표를 그래프로 세웠다.
| 항목 | 결과 |
|---|---|
| 구성행이 다른 호표를 가리키고 **단가가 일치** | **22건** |
| 이름은 같은데 단가가 다름 | **0건** |
| 호표 간 참조를 가진 호표 | 18 / 45 |
| **순환 참조** | **없음 → 위상순서 계산 가능** |
| 참조 깊이 | 최대 **2단** (잎 27 · 1단 14 · 2단 4) |
⇒ 09번 §3의 「의존 그래프를 만들고 위상순서로 계산, 순환은 오류」가 **실데이터에서 성립**한다. 깊이가 얕아 재귀 깊이 걱정은 없고, 이름+규격 짝으로 참조를 찾을 수 있다(불일치 0건).
### 설계변경 직접비 — 당초·변경 양쪽 원 단위 재현
계류보전 2025(변경)의 `BOQ11`은 한 표에 **차수1(당초)·차수2(변경)를 나란히** 담는다. 양쪽을 각각 다시 세웠다.
| | 직접재료비 | 직접노무비 | 산출경비 | 행금액 절사 |
|---|---:|---:|---:|---|
| 차수1 (당초) | 40,003,539 ✅ | 32,976,276 ✅ | 20,047,669 ✅ | 294건 무결 |
| 차수2 (변경) | 26,448,347 ✅ | 41,778,438 ✅ | 16,915,256 ✅ | 294건 무결 |
| **증감** | 13,555,192 | **+8,802,162** | 3,132,413 | |
**전 항목 1원도 안 틀림.** 같은 말단 행 집합(98행)에 차수별 수량·단가를 걸어 계산하면 되고, **제외금액 구역(`C*`) 판정도 차수와 무관하게 같다.**
이로써 6장의 간접비 모드와 합쳐 **변경설계가 직접비부터 간접비까지 전부 재현**된다.
## 7. 남은 일 — 할 수 있는 것과 없는 것
### 지금 자료로 할 수 있는 것
| 순위 | 할 일 | 왜 |
|---:|---|---|
| ~~1~~ | ~~남은 시트 훑기~~ | ✅ **완료** — 30종 전부. 새 규칙 없음, 집계표 ±1 원인 밝힘 |
| ~~2~~ | ~~중기시간금액집계표~~ | ✅ **완료** — 절사(수량 × 시간당 성분단가) |
| ~~1~~ | ~~일위대가 참조 그래프~~ | ✅ **완료** — 순환 없음, 깊이 2단, 불일치 0 (§6.5) |
| ~~2~~ | ~~설계변경 직접비 증감~~ | ✅ **완료** — 당초·변경 전 항목 원 단위 일치 (§6.5) |
| 1 | **표본 늘리기** | 지금 자료로 할 수 있는 것은 사실상 다 함. 규모·기간 의존 요율 좌표와 수공·실적 형식은 **새 표본이 있어야** 열림 |
### 끝내 못 하는 것
| 무엇 | 왜 |
|---|---|
| 기초단가 원천 (자재 5.7만 건 등) | 본문 암호화 |
| `BDQTY` 수량 할증 | 본문 암호화 |
| UI 재계산 범위·일괄변경 우선순위 | **STmate 실행 필요 — 라이선스 없음** |
| 규모·기간 의존 요율 좌표 | 같음. 표본을 늘리면 좁혀지기는 함 |
| 수공 `S`·실적 `TA`/`SA` 형식 | **표본이 하나도 없음.** 그 형식 설계서가 한 건 생기면 바로 가능 |
@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
"""진행률 장부 — 원천별로 무엇을 어디까지 봤나 실제로 센다."""
import sys, io, pathlib, zipfile, struct, re, warnings, collections
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
warnings.filterwarnings("ignore"); import openpyxl
SYS = pathlib.Path(r"C:\경영정보(주)\System")
PRAC = pathlib.Path(r"D:\aislo-wt\sub\resources\knowledge\original\실무문서")
# ── ① 설치 폴더 186개 ──
files = [p for p in SYS.rglob("*") if p.is_file()]
kinds = collections.Counter()
for p in files:
try: head = p.open("rb").read(2)
except Exception: head = b""
ext = p.suffix.upper()
if head == b"MZ": kinds["PE 실행모듈"] += 1
elif head == b"PK": kinds["ZIP 계열"] += 1
elif ext in (".JB_1",".JB_M",".JB_R",".JB_RA"): kinds["JB 요율표"] += 1
elif ext in (".BSC",".CSTA"): kinds["암호화 기준단가"] += 1
else: kinds["기타"] += 1
print("### ① 설치 폴더 `C:\경영정보(주)\System` 전체 %d" % len(files))
for k,v in kinds.most_common(): print(" %-16s %3d" % (k,v))
# ── ② 실무 STC ──
stcs = sorted(PRAC.rglob("*.STC"))
tabset = collections.Counter()
for s in stcs:
try: z = zipfile.ZipFile(s)
except Exception: continue
for n in z.namelist():
if n.upper().endswith(".DBF"): tabset[n.upper()] += 1
print("\n### ② 실무 프로젝트 STC %d건, 담긴 표 %d" % (len(stcs), len(tabset)))
# ── ③ STmate 생성 XLSX 시트 ──
sheets = collections.Counter(); nx = 0
for p in PRAC.rglob("*.xlsx"):
if p.name.startswith("~$"): continue
try: wb = openpyxl.load_workbook(p, data_only=True, read_only=True)
except Exception: continue
if "공사원가계산서" not in wb.sheetnames: continue
nx += 1
for s in wb.sheetnames: sheets[s] += 1
print("\n### ③ STmate 생성 XLSX %d건, 시트 %d" % (nx, len(sheets)))
DONE_SHEET = {"공사원가계산서","설계내역서","일위대가표","중기사용료","자재단가대비표",
"환율및기초자료","단가산출근거","〓 INITIAL 〓",
"재료비수량금액집계표","노무비수량금액집계표","경비수량금액집계표"}
PART_SHEET = {"중기시간금액집계표"}
print(" %-24s %s" % ("시트","상태"))
for s,_ in sheets.most_common():
st = "✅ 계산 검증" if s in DONE_SHEET else ("◐ 부분" if s in PART_SHEET else "· 미확인(목록·표지)")
print(" %-24s %s" % (s[:24], st))
d=len(DONE_SHEET & set(sheets)); pt=len(PART_SHEET & set(sheets))
print(" → 계산 검증 %d / %d 시트 (부분 %d)" % (d, len(sheets), pt))
DONE_TAB = {"BOQ11.DBF","BOQ12.DBF","COSTN.DBF","PRJLST.DBF","RATE.DBF","RATE_M.DBF",
"RATE_R.DBF","RATE_RA.DBF","RATE_A.DBF","RATE_B.DBF","SET_REP.DBF","KONGJONG.DBF"}
print("\n STC 표별 상태")
for t,c in sorted(tabset.items()):
st = "✅ 읽고 씀" if t in DONE_TAB else ("⛔ 암호화" if t in ("BDQTY.DBF","SYSINFO.DBF") else "· 빈 표/미사용")
print(" %-16s %d%s" % (t, c, st))
@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
"""골든셋 회귀 — 내역 → 직접비 → 간접비 → 총공사비를 다시 세워 XLSX 와 원 단위 대조."""
import sys, io, pathlib, re, zipfile, struct, warnings
from decimal import Decimal, ROUND_DOWN, ROUND_HALF_UP, ROUND_UP
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
warnings.filterwarnings("ignore")
import openpyxl
BASE = pathlib.Path(r"D:\aislo-wt\sub\resources\knowledge\original\실무문서\04.산불 봉화\01. 내역서")
STC = BASE/"13-2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.STC"
XLS = BASE/"2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx"
D = Decimal
def dn(x, q=0): return (D(x)).quantize(D(1).scaleb(-q), rounding=ROUND_DOWN)
def dbf(zf,name):
b=zf.read(name); nrec,hlen,rlen=struct.unpack_from("<IHH",b,4)
f=[];o=32
while b[o]!=0x0D: f.append((b[o:o+11].rstrip(b"\0").decode("cp949","replace"),b[o+16]));o+=32
rows=[]
for i in range(nrec):
r=b[hlen+i*rlen:hlen+(i+1)*rlen]; d={};c=1
for nm,ln in f: d[nm]=r[c:c+ln].decode("cp949","replace").strip(); c+=ln
rows.append(d)
return rows
z = zipfile.ZipFile(STC); boq = dbf(z,"BOQ11.DBF")
def g(r,k):
try: return D(r.get(k) or "0")
except Exception: return D(0)
STRUCT = re.compile(r"^(T\d|E\d|C\d|:)$")
# C* 로 닫히는 구역 = 제외금액 구역(관급자재대 등). 그 안의 행은 직접비에서 뺀다.
excl_span = set()
for i, r in enumerate(boq):
bd = (r.get("BDCODE1") or "").strip()
if bd.startswith("C"):
end = r.get("ENDCODE"); start = None
for j, q in enumerate(boq):
if q.get("SORTCODE") == end: start = j; break
if start is not None:
for k in range(min(start, i), max(start, i)+1): excl_span.add(k)
leaf = [r for i, r in enumerate(boq)
if r.get("BDCODE1") and not STRUCT.match(r["BDCODE1"].strip()) and i not in excl_span]
excl = [r for i, r in enumerate(boq)
if r.get("BDCODE1") and not STRUCT.match(r["BDCODE1"].strip()) and i in excl_span]
print("### 전체 %d행 → 말단 %d행 (제목 T*·계 E*·제외총계 C*·페이지 : 제외)" % (len(boq), len(leaf)))
print(" 제외금액 구역 %d행 (합 재료 %s)" % (len(excl), f"{sum(g(r,'M_AMT1') for r in excl):,}"))
M = sum(g(r,"M_AMT1") for r in leaf); L = sum(g(r,"L_AMT1") for r in leaf); S = sum(g(r,"S_AMT1") for r in leaf)
# 행 금액이 ROUNDDOWN(수량 x 단가) 인지 검사
ok=bad=0; badrows=[]
for r in leaf:
for comp,amt in (("MAMT1","M_AMT1"),("LAMT1","L_AMT1"),("SAMT1","S_AMT1")):
exp = dn(g(r,"QTY1")*g(r,comp))
act = g(r,amt)
if exp == act: ok+=1
else:
bad+=1
if len(badrows)<5: badrows.append((r.get("NAME1","")[:20], comp, exp, act))
print("행 금액 = ROUNDDOWN(수량 × 단가) : 일치 %d / 불일치 %d" % (ok,bad))
for nm,c,e,a in badrows: print(" %-20s %-7s 계산 %s ≠ 장부 %s" % (nm,c,e,a))
wb = openpyxl.load_workbook(XLS, data_only=True, read_only=True)
ws = wb["공사원가계산서"]
T={}; notes={}
for row in ws.iter_rows(min_row=1,max_row=40,max_col=7,values_only=True):
cells=["" if v is None else v for v in row]
lab="".join(str(c) for c in cells[:3] if isinstance(c,str)).replace(" ","")
amt=next((c for c in cells[3:5] if isinstance(c,(int,float))),None)
note=next((str(c) for c in cells[4:7] if isinstance(c,str) and "%" in str(c)),"")
if lab and amt is not None: T[lab]=D(str(amt)); notes[lab]=note
def cmp(label, calc, key):
exp = T.get(key)
if exp is None: print(" %-22s %14s (목표 없음)" % (label, calc)); return
d = calc-exp
print(" %-22s 계산 %14s 출력 %14s %s" % (label, f"{calc:,}", f"{exp:,}", "일치" if d==0 else "%+d" % d))
print("\n### 직접비")
cmp("직접재료비", M, "순공사원가재료비직접재료비")
cmp("직접노무비", L, "노무비직접노무비")
cmp("산출경비", S, "경비산출경비")
print("\n### 간접비 — 문서의 밑수·요율 그대로 적용")
간노 = dn(L*D("0.145")); cmp("간접노무비", 간노, "간접노무비")
노무 = L + 간노
산재 = dn(노무*D("0.0356")); cmp("산재보험료", 산재, "산재보험료")
고용 = dn(노무*D("0.0101")); cmp("고용보험료", 고용, "고용보험료")
건강 = dn(L*D("0.03545")); cmp("건강보험료", 건강, "건강보험료")
장기 = dn(건강*D("0.1295")); cmp("노인장기요양보험료", 장기, "노인장기요양보험료")
연금 = dn(L*D("0.045")); cmp("연금보험료", 연금, "연금보험료")
기타 = dn((노무+M)*D("0.06")); cmp("기타경비", 기타, "기타경비")
환경 = dn((M+L+S)*D("0.008")); cmp("환경보전비", 환경, "환경보전비")
기계 = dn((M+L+S)*D("0.004")); cmp("건설기계대여금지급보증금액", 기계, "건설기계대여금지급보증금액")
관급 = D("143554309")
안전 = dn((L+M+관급/D("1.1"))*D("0.0186")) + D("5349000")
cmp("산업안전보건관리비", 안전, "산업안전보건관리비")
print(" (비고 원문: %s)" % notes.get("산업안전보건관리비","")[:70])
경비계 = S+산재+고용+건강+장기+연금+T.get("산업안전보건관리비",D(0))+기타+환경+기계
cmp("경비 소계", 경비계, "소계")
순공사원가 = M + 노무 + 경비계
일반 = dn(순공사원가*D("0.06")); cmp("일반관리비", 일반, "일반관리비")
이윤 = dn((노무+경비계+일반)*D("0.15")); cmp("이윤", 이윤, "이윤")
총원가 = 순공사원가+일반+이윤; cmp("총원가", 총원가, "총원가")
부가 = dn(T.get("총원가",총원가)*D("0.1")); cmp("부가가치세", 부가, "부가가치세")
# ── 산업안전보건관리비의 관급재 밑수를 역산 ───────────────────────
print("\n### 산업안전보건관리비 — 관급재 밑수 역산")
목표 = T["산업안전보건관리비"] - D("5349000")
관급대 = D("143554309"); 조달 = D("771029")
for 이름, in [("관급자재대 그대로", 관급대), ("관급자재대 조달수수료", 관급대-조달), ("조달수수료만", 조달)]:
밑수 = L + M + /D("1.1")
계산 = dn(밑수*D("0.0186"))
print(" %-22s 밑수 %16s 율부분 %12s %s" % (이름, f"{밑수.quantize(D('1')):,}", f"{계산:,}",
"일치" if 계산==목표 else "%+d" % (계산-목표)))
# ── 끝전조정 ─────────────────────────────────────────────
print("\n### 끝전조정 — 이윤 차 339원의 정체")
안전 = T["산업안전보건관리비"]
경비계2 = S+산재+고용+건강+장기+연금+안전+기타+환경+기계
순2 = M + 노무 + 경비계2
일반2 = dn(순2*D("0.06"))
이윤원 = dn((노무+경비계2+일반2)*D("0.15"))
총원가원 = 순2+일반2+이윤원
vat원 = dn(총원가원*D("0.1"))
도급원 = 총원가원+vat원
도급목표 = T["도급공사비"]
차액 = 도급원 - 도급목표
세전 = (D(차액)/D("1.1")).quantize(D("1"), rounding=ROUND_HALF_UP)
print(" 보정 전 이윤 %14s" % f"{이윤원:,}")
print(" 보정 전 총원가 %14s" % f"{총원가원:,}")
print(" 보정 전 부가세 %14s" % f"{vat원:,}")
print(" 보정 전 도급공사비 %14s 목표 %14s 차액 %s" % (f"{도급원:,}", f"{도급목표:,}", f"{차액:,}"))
print(" 세전조정 = ROUND(차액/1.1) = %s" % f"{세전:,}")
이윤2 = 이윤원 - 세전
총2 = 순2+일반2+이윤2
vat2 = dn(총2*D("0.1"))
print()
for lab,calc,key in [("이윤(보정후)",이윤2,"이윤"),("총원가",총2,"총원가"),
("부가가치세",vat2,"부가가치세"),("도급공사비",총2+vat2,"도급공사비")]:
exp=T[key]; print(" %-14s 계산 %16s 출력 %16s %s" % (lab, f"{calc:,}", f"{exp:,}",
"일치" if calc==exp else "%+d" % (calc-exp)))
관급자재대 = (관급대/D("1000")).quantize(D("1"), rounding=ROUND_UP)*D("1000")
print("\n 관급자재대 = ROUNDUP(%s, 천원) = %s 출력 %s %s" % (
f"{관급대:,}", f"{관급자재대:,}", f"{T['관급자재대']:,}", "일치" if 관급자재대==T["관급자재대"] else "불일치"))
총공 = 총2+vat2+관급자재대
print(" 총공사비 = 도급 + 관급 = %s 출력 %s %s" % (
f"{총공:,}", f"{T['총공사비']:,}", "일치" if 총공==T["총공사비"] else "%+d"%(총공-T["총공사비"])))
@@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
"""골든셋 회귀 — 실무 STC 전수에 같은 규칙을 걸어 원 단위 대조."""
import sys, io, pathlib, re, zipfile, struct, warnings, difflib
from decimal import Decimal as D, ROUND_DOWN, ROUND_HALF_UP, ROUND_UP
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
warnings.filterwarnings("ignore")
import openpyxl
ROOT = pathlib.Path(r"D:\aislo-wt\sub\resources\knowledge\original\실무문서")
def dn(x): return D(x).quantize(D(1), rounding=ROUND_DOWN)
def dbf(zf,name):
b=zf.read(name); nrec,hlen,rlen=struct.unpack_from("<IHH",b,4)
f=[];o=32
while b[o]!=0x0D: f.append((b[o:o+11].rstrip(b"\0").decode("cp949","replace"),b[o+16]));o+=32
rows=[]
for i in range(nrec):
r=b[hlen+i*rlen:hlen+(i+1)*rlen]; d={};c=1
for nm,ln in f: d[nm]=r[c:c+ln].decode("cp949","replace").strip(); c+=ln
rows.append(d)
return rows
def g(r,k):
try: return D(r.get(k) or "0")
except Exception: return D(0)
STRUCT = re.compile(r"^(T\d|E\d|C\d|:)$")
def direct(boq):
excl=set()
for i,r in enumerate(boq):
bd=(r.get("BDCODE1") or "").strip()
if bd.startswith("C"):
end=r.get("ENDCODE"); st=next((j for j,q in enumerate(boq) if q.get("SORTCODE")==end), None)
if st is not None:
for k in range(min(st,i),max(st,i)+1): excl.add(k)
leaf=[r for i,r in enumerate(boq) if r.get("BDCODE1") and not STRUCT.match(r["BDCODE1"].strip()) and i not in excl]
rowok=rowbad=0
for r in leaf:
for c,a in (("MAMT1","M_AMT1"),("LAMT1","L_AMT1"),("SAMT1","S_AMT1")):
if dn(g(r,"QTY1")*g(r,c))==g(r,a): rowok+=1
else: rowbad+=1
return (sum(g(r,"M_AMT1") for r in leaf), sum(g(r,"L_AMT1") for r in leaf),
sum(g(r,"S_AMT1") for r in leaf), len(leaf), rowok, rowbad)
def targets(xp):
wb=openpyxl.load_workbook(xp,data_only=True,read_only=True)
if "공사원가계산서" not in wb.sheetnames: return {},{}
ws=wb["공사원가계산서"]; T={};N={}
for row in ws.iter_rows(min_row=1,max_row=45,max_col=7,values_only=True):
cells=["" if v is None else v for v in row]
lab="".join(str(c) for c in cells[:3] if isinstance(c,str)).replace(" ","")
amt=next((c for c in cells[3:5] if isinstance(c,(int,float))),None)
note=next((str(c) for c in cells[4:7] if isinstance(c,str) and "%" in str(c)),"")
if lab and amt is not None and lab not in T: T[lab]=D(str(amt)); N[lab]=note
return T,N
RE_R = re.compile(r"([\d.]+)\s*%")
pairs=[]
for stc in sorted(ROOT.rglob("*.STC")):
best=None;bs=0
for x in list(stc.parent.rglob("*.xlsx"))+list(stc.parent.parent.rglob("*.xlsx")):
if x.name.startswith("~$"): continue
s=difflib.SequenceMatcher(None,stc.stem,x.stem).ratio()
if s>bs: bs,best=s,x
if best and bs>0.55: pairs.append((stc,best))
for stc,xp in pairs:
try: T,N = targets(xp)
except Exception as e: print("%-42s 엑셀 실패" % stc.stem[:42]); continue
if not T: continue
zf = zipfile.ZipFile(stc)
have = {n.upper() for n in zf.namelist()}
# 한 파일에 BOQ11·BOQ12 가 함께 있으면 BOQ12 가 현재안(출력 기준)이다
tbl = "BOQ12.DBF" if "BOQ12.DBF" in have and dbf(zf,"BOQ12.DBF") else "BOQ11.DBF"
boq = dbf(zf, tbl)
M,L,S,nleaf,ok,bad = direct(boq)
def get(*keys):
for k in keys:
if k in T: return T[k]
return None
tM=get("순공사원가재료비직접재료비","재료비직접재료비"); tL=get("노무비직접노무비"); tS=get("경비산출경비")
print("\n== %s" % stc.stem[:60])
print(" 말단 %d행 행금액 ROUNDDOWN 일치 %d / 불일치 %d" % (nleaf, ok, bad))
for lab,c,e in (("직접재료비",M,tM),("직접노무비",L,tL),("산출경비",S,tS)):
if e is None: print(" %-10s 계산 %16s (목표 없음)" % (lab, f"{c:,}")); continue
print(" %-10s 계산 %16s 출력 %16s %s" % (lab, f"{c:,}", f"{e:,}", "일치" if c==e else "%+d"%(c-e)))
# 요율은 비고에서 읽어 그대로 적용
def rate(key):
m=RE_R.search(N.get(key,"") or ""); return D(m.group(1))/100 if m else None
r간=rate("간접노무비")
if r간 and tL is not None:
=dn(tL*r간); e=T.get("간접노무비")
print(" 간접노무비 계산 %16s 출력 %16s %s" % (f"{:,}", f"{e:,}", "일치" if ==e else "%+d"%(-e)))
=tL+
for lab,base,key in (("산재보험료",,"산재보험료"),("고용보험료",,"고용보험료"),
("건강보험료",tL,"건강보험료"),("연금보험료",tL,"연금보험료")):
rr=rate(key)
if rr is None or key not in T: continue
v=dn(base*rr); e=T[key]
print(" %-10s 계산 %16s 출력 %16s %s" % (lab, f"{v:,}", f"{e:,}", "일치" if v==e else "%+d"%(v-e)))
@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""중기사용료 호표에서 시간당 손료율·연료·잡품률을 뽑는다."""
import sys, io, pathlib, warnings, re, collections
from decimal import Decimal as D
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
warnings.filterwarnings("ignore"); import openpyxl
def dec(v):
if v in (None,""): return None
try: return D(str(v))
except Exception: return None
P = pathlib.Path(r"D:\aislo-wt\sub\resources\knowledge\original\실무문서\04.산불 봉화\01. 내역서\2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx")
ws = openpyxl.load_workbook(P, data_only=True)["중기사용료"]
rows=[list(r) for r in ws.iter_rows(min_row=1,max_row=ws.max_row,max_col=12,values_only=True)]
호표=[]; cur=None; title=None
for r in rows:
nm=(str(r[0]) if r[0] is not None else "").strip()
k=nm.replace(" ","")
if re.match(r"^제?\d+호표", k): cur=[]; title=None; 호표.append((None,cur)); continue
if cur is None: continue
if k=="합계":
호표[-1]=(title,cur+[("SUM",r)]); cur=None; continue
if title is None and r[3] and not r[2]:
title=(nm, str(r[1] or "")); continue
cur.append(("ROW",r))
print("### 중기 시간당 사용료 구성 — 봉화 2024")
print("%-24s %-14s %9s %12s %10s %8s %8s" % ("장비","규격","손료율","취득가(천원)","손료(원)","연료L/h","잡품%"))
for title, blk in 호표:
if not title: continue
손료율=취득가=연료=잡품=None
for k,r in blk:
if k!="ROW": continue
u=(str(r[3]) if r[3] else "").strip()
nm=(str(r[0]) if r[0] else "").strip()
if u=="천원": 손료율, 취득가 = dec(r[2]), dec(r[4])
elif u=="L": 연료 = dec(r[2])
elif u=="%" and "잡품" in nm: 잡품 = dec(r[2])
if 손료율 is None: continue
print("%-24s %-14s %9s %12s %10s %8s %8s" % (
title[0][:24], title[1][:14], 손료율, f"{취득가:,}" if 취득가 else "-",
f"{(손료율*취득가):,.1f}" if (손료율 and 취득가) else "-",
연료 if 연료 is not None else "-", 잡품 if 잡품 is not None else "-"))
print("\n### 잡품 = 주연료비 × 율 검산")
ok=bad=0
for title, blk in 호표:
연료액=None
for k,r in blk:
if k!="ROW": continue
u=(str(r[3]) if r[3] else "").strip(); nm=(str(r[0]) if r[0] else "").strip()
if u=="L": 연료액 = dec(r[9]) or dec(r[5])
elif u=="%" and "잡품" in nm:
=dec(r[2]); =dec(r[4]); =dec(r[9]) or dec(r[5])
if 연료액 is None or is None: continue
exp = 연료액*/100
same_base = ( == 연료액)
if abs(exp-( or D(0))) < D("0.05") and same_base: ok+=1
else: bad+=1
print(" 잡품 금액 = 연료비 × 율/100 : 일치 %d / 불일치 %d (잡품 단가칸 = 연료비)" % (ok,bad))
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""자재 채택 규칙 검정 — 적용단가가 후보 중 무엇인가."""
import sys, io, pathlib, warnings, collections
from decimal import Decimal as D
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
warnings.filterwarnings("ignore"); import openpyxl
def dec(v):
if v in (None,""): return None
try: return D(str(v))
except Exception: return None
ROOT = pathlib.Path(r"D:\aislo-wt\sub\resources\knowledge\original\실무문서")
SLOTS = [("조달가격",5),("물가자료",7),("물가정보",9),("거래가격",11),("견적단가",13)]
APPLY = 15
stat = collections.Counter(); examples=[]
files=0
for p in sorted(ROOT.rglob("*.xlsx")):
if p.name.startswith("~$"): continue
try: wb = openpyxl.load_workbook(p, data_only=True, read_only=True)
except Exception: continue
if "자재단가대비표" not in wb.sheetnames: continue
files += 1
ws = wb["자재단가대비표"]
for row in ws.iter_rows(min_row=5, max_row=ws.max_row, max_col=17, values_only=True):
name = row[1]
if not name or not str(name).strip(): continue
cands = {nm: dec(row[i-1]) for nm,i in SLOTS}
nz = {k:v for k,v in cands.items() if v not in (None, D(0))}
app = dec(row[APPLY-1]); src = (str(row[APPLY]) if len(row)>APPLY and row[APPLY] else "").strip()
if app in (None, D(0)):
stat["적용단가 없음(별도계상 등)"] += 1; continue
if not nz:
stat["후보 없이 적용단가만(유가·별도자료)"] += 1; continue
mn = min(nz.values()); mx = max(nz.values())
if len(set(nz.values())) >= 2:
stat["복수후보 총"] += 1
if app == mn: stat["복수후보 최저"] += 1
elif app in nz.values(): stat["복수후보 최저아닌 후보"] += 1
else: stat["복수후보 후보밖"] += 1
if len(set(nz.values())) >= 3:
stat["3개이상 총"] += 1
if app == mn: stat["3개이상 최저"] += 1
if app == mn: stat["최저가와 일치"] += 1
elif app == mx: stat["최고가와 일치"] += 1
elif app in nz.values(): stat["후보 중 하나지만 최저 아님"] += 1
else:
stat["어느 후보와도 다름"] += 1
if len(examples)<6: examples.append((p.stem[:26], str(name)[:18], nz, app, src))
stat[""] += 1
print("### 자재 채택 규칙 검정 — 파일 %d" % files)
tot = stat[""]
for k in ("최저가와 일치","후보 중 하나지만 최저 아님","최고가와 일치","어느 후보와도 다름"):
print(" %-24s %4d%5.1f%%" % (k, stat[k], 100*stat[k]/tot if tot else 0))
print(" %-24s %4d" % ("(후보 없이 적용만)", stat["후보 없이 적용단가만(유가·별도자료)"]))
print(" %-24s %4d" % ("(적용단가 0/별도계상)", stat["적용단가 없음(별도계상 등)"]))
print(" ── 검정 대상 합계 %d" % tot)
print()
for k in ("복수후보 총","복수후보 최저","복수후보 최저아닌 후보","복수후보 후보밖","3개이상 총","3개이상 최저"):
print(" %-22s %4d" % (k, stat[k]))
if examples:
print("\n### 어느 후보와도 다른 예")
for f,n,nz,app,src in examples:
print(" %-26s %-18s 적용 %-10s 출처 %-6s 후보 %s" % (f,n,app,src,
", ".join("%s=%s"%(k,v) for k,v in nz.items())))
@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
import sys, io, pathlib, zipfile, struct, re, warnings
from decimal import Decimal as D, ROUND_DOWN
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
warnings.filterwarnings("ignore"); import openpyxl
B = pathlib.Path(r"D:\aislo-wt\sub\resources\knowledge\original\실무문서\25년 산불진화임도(기번8)(울진소광)")
def dbf(zf,name):
b=zf.read(name); nrec,hlen,rlen=struct.unpack_from("<IHH",b,4)
f=[];o=32
while b[o]!=0x0D: f.append((b[o:o+11].rstrip(b"\0").decode("cp949","replace"),b[o+16]));o+=32
rows=[]
for i in range(nrec):
r=b[hlen+i*rlen:hlen+(i+1)*rlen]; d={};c=1
for nm,ln in f: d[nm]=r[c:c+ln].decode("cp949","replace").strip(); c+=ln
rows.append(d)
return rows
def g(r,k):
try: return D(r.get(k) or "0")
except Exception: return D(0)
def dn(x): return D(x).quantize(D(1), rounding=ROUND_DOWN)
STRUCT = re.compile(r"^(T\d|E\d|C\d|:)$")
def direct(boq, sfx="1"):
excl=set()
for i,r in enumerate(boq):
bd=(r.get("BDCODE"+sfx) or r.get("BDCODE1") or "").strip()
if bd.startswith("C"):
end=r.get("ENDCODE"); st=next((j for j,q in enumerate(boq) if q.get("SORTCODE")==end),None)
if st is not None:
for k in range(min(st,i),max(st,i)+1): excl.add(k)
leaf=[r for i,r in enumerate(boq)
if (r.get("BDCODE"+sfx) or r.get("BDCODE1")) and not STRUCT.match((r.get("BDCODE"+sfx) or r.get("BDCODE1")).strip()) and i not in excl]
return (sum(g(r,"M_AMT"+sfx) for r in leaf), sum(g(r,"L_AMT"+sfx) for r in leaf),
sum(g(r,"S_AMT"+sfx) for r in leaf), len(leaf))
orig = zipfile.ZipFile(B/"2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC")
chg = zipfile.ZipFile(B/"변경 1차분-2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC")
b11 = dbf(orig,"BOQ11.DBF"); b12 = dbf(orig,"BOQ12.DBF"); c11 = dbf(chg,"BOQ11.DBF")
print("### 원설계 파일 안의 두 표")
for nm,t in (("BOQ11",b11),("BOQ12",b12)):
M,L,S,n = direct(t)
print(" %-6s %3d행 → 말단 %3d 재료 %13s 노무 %13s 경비 %13s" % (nm,len(t),n,f"{M:,}",f"{L:,}",f"{S:,}"))
wb = openpyxl.load_workbook(B/"2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx", data_only=True, read_only=True)
ws = wb["공사원가계산서"]; T={}
for row in ws.iter_rows(min_row=1,max_row=45,max_col=7,values_only=True):
cells=["" if v is None else v for v in row]
lab="".join(str(c) for c in cells[:3] if isinstance(c,str)).replace(" ","")
amt=next((c for c in cells[3:5] if isinstance(c,(int,float))),None)
if lab and amt is not None and lab not in T: T[lab]=D(str(amt))
print("\n### 출력 XLSX 가 어느 표를 썼나")
tM=T.get("순공사원가재료비직접재료비"); tL=T.get("노무비직접노무비"); tS=T.get("경비산출경비")
print(" 출력 재료 %13s 노무 %13s 경비 %13s" % (f"{tM:,}",f"{tL:,}",f"{tS:,}"))
for nm,t in (("BOQ11",b11),("BOQ12",b12)):
M,L,S,_ = direct(t)
print(" %-6s %s" % (nm, "✅ 일치" if (M,L,S)==(tM,tL,tS) else "불일치"))
print("\n### 변경1차 파일의 BOQ11 차수1 이 원설계 파일의 BOQ12 와 같은가")
M1,L1,S1,n1 = direct(c11,"1"); M2,L2,S2,n2 = direct(c11,"2")
M12,L12,S12,_ = direct(b12)
print(" 변경파일 차수1 말단 %3d 재료 %13s 노무 %13s 경비 %13s" % (n1,f"{M1:,}",f"{L1:,}",f"{S1:,}"))
print(" 원설계 BOQ12 재료 %13s 노무 %13s 경비 %13s %s" % (f"{M12:,}",f"{L12:,}",f"{S12:,}",
"✅ 같음" if (M1,L1,S1)==(M12,L12,S12) else "다름"))
print(" 변경파일 차수2 말단 %3d 재료 %13s 노무 %13s 경비 %13s" % (n2,f"{M2:,}",f"{L2:,}",f"{S2:,}"))
print("\n 직접공사비 당초 %s → 변경 %s 증감 %s" % (
f"{M1+L1+S1:,}", f"{M2+L2+S2:,}", f"{(M2+L2+S2)-(M1+L1+S1):+,}"))
pl = dbf(chg,"PRJLST.DBF")[0]
print(" PRJLST 1차 T=%s 2차 T=%s (직접비 합과 %s)" % (
f"{g(pl,'TCOST1'):,}", f"{g(pl,'TCOST2'):,}",
"일치" if (g(pl,'TCOST1')==M1+L1+S1 and g(pl,'TCOST2')==M2+L2+S2) else "불일치"))
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""설계변경 직접비 — 차수1(당초)·차수2(변경)를 내역에서 다시 세워 출력과 대조."""
import sys, io, pathlib, zipfile, struct, re, warnings
from decimal import Decimal as D, ROUND_DOWN
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
warnings.filterwarnings("ignore"); import openpyxl
def dn(x): return D(x).quantize(D(1), rounding=ROUND_DOWN)
def dbf(zf,name):
b=zf.read(name); nrec,hlen,rlen=struct.unpack_from("<IHH",b,4)
f=[];o=32
while b[o]!=0x0D: f.append((b[o:o+11].rstrip(b"\0").decode("cp949","replace"),b[o+16]));o+=32
rows=[]
for i in range(nrec):
r=b[hlen+i*rlen:hlen+(i+1)*rlen]; d={};c=1
for nm,ln in f: d[nm]=r[c:c+ln].decode("cp949","replace").strip(); c+=ln
rows.append(d)
return rows
def g(r,k):
try: return D(r.get(k) or "0")
except Exception: return D(0)
STRUCT=re.compile(r"^(T\d|E\d|C\d|:)$")
def direct(boq, s):
excl=set()
for i,r in enumerate(boq):
bd=(r.get("BDCODE"+s) or r.get("BDCODE1") or "").strip()
if bd.startswith("C"):
end=r.get("ENDCODE"); st=next((j for j,q in enumerate(boq) if q.get("SORTCODE")==end),None)
if st is not None:
for k in range(min(st,i),max(st,i)+1): excl.add(k)
leaf=[r for i,r in enumerate(boq)
if (r.get("BDCODE"+s) or r.get("BDCODE1")) and not STRUCT.match((r.get("BDCODE"+s) or r.get("BDCODE1")).strip()) and i not in excl]
ok=bad=0
for r in leaf:
for c,a in (("MAMT","M_AMT"),("LAMT","L_AMT"),("SAMT","S_AMT")):
if dn(g(r,"QTY"+s)*g(r,c+s))==g(r,a+s): ok+=1
else: bad+=1
return (sum(g(r,"M_AMT"+s) for r in leaf), sum(g(r,"L_AMT"+s) for r in leaf),
sum(g(r,"S_AMT"+s) for r in leaf), len(leaf), ok, bad)
BASE=pathlib.Path(r"D:\aislo-wt\sub\resources\knowledge\original\실무문서\20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)")
stc=next(BASE.rglob("*.STC")); xls=next(p for p in BASE.rglob("*.xlsx") if p.stem==stc.stem)
boq=dbf(zipfile.ZipFile(stc),"BOQ11.DBF")
ws=openpyxl.load_workbook(xls,data_only=True)["공사원가계산서"]
T={}
for row in ws.iter_rows(min_row=1,max_row=45,max_col=6,values_only=True):
cells=["" if v is None else v for v in row]
lab="".join(str(c) for c in cells[:3] if isinstance(c,str)).replace(" ","")
b=next((c for c in cells[3:4] if isinstance(c,(int,float))),None)
c2=next((c for c in cells[4:5] if isinstance(c,(int,float))),None)
if lab and b is not None: T[lab]=(D(str(b)), D(str(c2)) if c2 is not None else None)
print("### 설계변경 직접비 — 내역에서 다시 세움 (%s)" % stc.stem[:44])
for s,tag in (("1","당초"),("2","변경")):
M,L,S,n,ok,bad = direct(boq,s)
print("\n-- 차수%s (%s) 말단 %d행, 행금액 절사 일치 %d / 불일치 %d" % (s,tag,n,ok,bad))
for lab,calc,key in (("직접재료비",M,"순공사원가재료비직접재료비"),("직접노무비",L,"노무비직접노무비"),("산출경비",S,"경비산출경비")):
pair=T.get(key)
exp = pair[0] if s=="1" else (pair[1] if pair else None)
if exp is None: print(" %-10s 계산 %14s (목표 없음)"%(lab,f"{calc:,}")); continue
print(" %-10s 계산 %14s 출력 %14s %s"%(lab,f"{calc:,}",f"{exp:,}","일치" if calc==exp else "%+d"%(calc-exp)))
M1,L1,S1,*_=direct(boq,"1"); M2,L2,S2,*_=direct(boq,"2")
print("\n-- 증감")
for lab,a,b,key in (("직접재료비",M1,M2,"순공사원가재료비직접재료비"),("직접노무비",L1,L2,"노무비직접노무비"),("산출경비",S1,S2,"경비산출경비")):
print(" %-10s %+15s" % (lab, f"{b-a:,}"))
@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
"""남은 시트 전수 훑기 — 머리 몇 줄과 수량×단가=금액 반올림 판정."""
import sys, io, pathlib, warnings, collections
from decimal import Decimal as D, ROUND_DOWN, ROUND_HALF_UP, ROUND_HALF_EVEN
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
warnings.filterwarnings("ignore"); import openpyxl
def q(x,m): return D(x).quantize(D(1), rounding=m)
def dec(v):
if v in (None,""): return None
try: return D(str(v))
except Exception: return None
ROOT = pathlib.Path(r"D:\aislo-wt\sub\resources\knowledge\original\실무문서")
REST = ["중기시간금액집계표","일위대가수량금액집계표","단가산출근거수량금액집계표","일식견적수량금액집계표",
"총괄설계내역서","총괄내역서","착공내역서","설계변경내역서",
"일위대가목록표","단가산출근거목록표","중기목록표","재료비목록표","노무비목록표","경비목록표","일식견적목록표"]
stat = collections.Counter(); head = {}
for p in sorted(ROOT.rglob("*.xlsx")):
if p.name.startswith("~$"): continue
try: wb=openpyxl.load_workbook(p,data_only=True,read_only=True)
except Exception: continue
if "공사원가계산서" not in wb.sheetnames: continue
for s in REST:
if s not in wb.sheetnames: continue
ws=wb[s]
rows=[list(r) for r in ws.iter_rows(min_row=1,max_row=min(ws.max_row,400),max_col=14,values_only=True)]
if s not in head:
head[s]=[r for r in rows[:8]]
for r in rows:
nums=[(i,dec(v)) for i,v in enumerate(r) if isinstance(v,(int,float))]
for a in range(len(nums)-2):
qy,up,amt = nums[a][1],nums[a+1][1],nums[a+2][1]
if not (qy and up and amt) or qy<=0 or up<=0 or amt<=0: continue
raw=qy*up
if raw==0 or abs(raw-amt) > raw*D("0.015"): continue
for m,tag in ((ROUND_DOWN,"절사"),(ROUND_HALF_UP,"사사오입"),(ROUND_HALF_EVEN,"은행가")):
if q(raw,m)==amt: stat[(s,tag)]+=1
stat[(s,"")]+=1; break
print("### 남은 시트 — 수량×단가=금액 반올림 판정")
print("%-26s %6s %10s %10s %10s" % ("시트","표본","절사","사사오입","은행가"))
for s in REST:
t=stat[(s,"")]
if not t:
print("%-26s %6s (계산 쌍 없음 = 색인·표지 성격)" % (s[:26],"-")); continue
f=lambda k: "%d(%.0f%%)"%(stat[(s,k)],100*stat[(s,k)]/t)
print("%-26s %6d %10s %10s %10s" % (s[:26],t,f("절사"),f("사사오입"),f("은행가")))
print("\n\n### 각 시트 머리 (구조 확인)")
for s in REST:
if s not in head: continue
print("\n-- %s" % s)
for r in head[s]:
line=" | ".join(("" if v is None else (("%g"%v) if isinstance(v,float) else str(v)))[:15] for v in r[:10])
if line.strip(" |"): print(" %s" % line)
@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
import sys, io, pathlib, re, zipfile, struct, collections, warnings, difflib
warnings.filterwarnings("ignore"); import openpyxl
SRC = open(r"C:\Users\umsan\AppData\Local\Temp\claude\D--aislo-wt-sub\df75230a-b05b-4088-ad34-bb0c9da0a312\scratchpad\t1_consist.py",
encoding="utf-8").read().split("items = collections.Counter()")[0]
SRC = SRC.replace('sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")','')
SRC = "\n".join(l for l in SRC.split("\n") if not l.strip().startswith("print("))
ns = {}
exec(SRC, ns)
per = ns["per"]; tabs_by_proj = ns["tabs_by_proj"]
out = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
def P(*a): print(*a, file=out)
P("### 규모·기간 의존 항목 — 열이 고정인가 (RATE_R 행 0~44 안)")
for lab in ("간접노무비","기타경비","일반관리비","이윤"):
vals = {k: per[k][lab] for k in per if lab in per[k]}
if len(vals) < 3: continue
colsets=[]; detail=[]
for k,v in vals.items():
M = tabs_by_proj[k].get("RATE_R") or []
hits = {(i,j+1) for i,row in enumerate(M[:45]) for j,x in enumerate(row) if x and abs(x-v)<1e-6}
colsets.append({c for _,c in hits}); detail.append((k,v,sorted(hits)[:6]))
common = set.intersection(*colsets) if colsets else set()
P("\n%s 관측 %d건, 값 %s" % (lab, len(vals), sorted({v for v in vals.values()})))
P(" 모든 프로젝트 공통 열: %s" % (sorted(common) or "없음"))
for k,v,h in detail:
P(" %-30s %7.3f%% %s" % (k[:30], v, ", ".join("(%d,%d)"%x for x in h) or "없음"))
out.flush()
@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
"""T1 대체 — 통제시험 없이, 모든 프로젝트에서 동시에 성립하는 좌표만 남긴다."""
import sys, io, pathlib, re, zipfile, struct, collections, warnings, difflib
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
warnings.filterwarnings("ignore"); import openpyxl
ROOT = pathlib.Path(r"D:\aislo-wt\sub\resources\knowledge\original\실무문서")
def dbf(zf,name):
b=zf.read(name); nrec,hlen,rlen=struct.unpack_from("<IHH",b,4)
f=[];o=32
while b[o]!=0x0D: f.append((b[o:o+11].rstrip(b"\0").decode("cp949","replace"),b[o+16]));o+=32
M=[]
for i in range(nrec):
r=b[hlen+i*rlen:hlen+(i+1)*rlen]; v=[];c=1
for nm,ln in f:
s=r[c:c+ln].decode("cp949","replace").strip(); c+=ln
try: v.append(round(float(s),4) if s else 0.0)
except ValueError: v.append(0.0)
M.append(v)
return M
RE = re.compile(r"([\d.]+)\s*%")
def rates(xp):
wb=openpyxl.load_workbook(xp,data_only=True,read_only=True)
if "공사원가계산서" not in wb.sheetnames: return {}
ws=wb["공사원가계산서"]; out={}
for row in ws.iter_rows(min_row=1,max_row=45,max_col=7,values_only=True):
cells=["" if v is None else v for v in row]
lab=re.sub(r"\s+","",next((str(c) for c in cells[:3] if isinstance(c,str) and str(c).strip()),""))
note=next((str(c) for c in cells[4:7] if isinstance(c,str) and "%" in str(c)),"")
if not lab or not note: continue
m=RE.search(note)
if m and lab not in out: out[lab]=float(m.group(1))
return out
pairs=[]
for stc in sorted(ROOT.rglob("*.STC")):
best=None;bs=0
for x in list(stc.parent.rglob("*.xlsx"))+list(stc.parent.parent.rglob("*.xlsx")):
if x.name.startswith("~$"): continue
s=difflib.SequenceMatcher(None,stc.stem,x.stem).ratio()
if s>bs: bs,best=s,x
if best and bs>0.55: pairs.append((stc,best))
# 항목 -> {프로젝트: 요율}
per = {}
tabs_by_proj = {}
for stc,xp in pairs:
try: r = rates(xp)
except Exception: continue
if not r: continue
z = zipfile.ZipFile(stc); tabs={}
for n in z.namelist():
if re.match(r"RATE.*\.DBF$", n, re.I):
try: tabs[n.upper().replace(".DBF","")] = dbf(z,n)
except Exception: pass
key = stc.stem[:28]
tabs_by_proj[key]=tabs; per[key]=r
items = collections.Counter()
for k,r in per.items():
for lab in r: items[lab]+=1
print("프로젝트 %d건, 항목별 관측 수" % len(per))
print()
print("%-22s %5s %6s %s" % ("항목","관측","값종류","전 프로젝트에서 동시에 맞는 좌표"))
print("-"*96)
for lab, cnt in items.most_common():
vals = {k: per[k][lab] for k in per if lab in per[k]}
if len(vals) < 3: continue
# 후보 좌표: 첫 프로젝트에서 값이 맞는 모든 자리
first = next(iter(vals))
cand = set()
for tn, M in tabs_by_proj[first].items():
for i,row in enumerate(M):
for j,x in enumerate(row):
if x and abs(x-vals[first])<1e-6: cand.add((tn,i,j))
for k in list(vals)[1:]:
keep=set()
for (tn,i,j) in cand:
M = tabs_by_proj[k].get(tn)
if M and i<len(M) and j<len(M[i]) and abs(M[i][j]-vals[k])<1e-6: keep.add((tn,i,j))
cand = keep
if not cand: break
nv = len(set(vals.values()))
s = ", ".join("%s[%d,%d]"%(t,i,j+1) for t,i,j in sorted(cand)[:4]) or "없음"
if len(cand)>4: s += " …외 %d" % (len(cand)-4)
print("%-22s %5d %6d %s" % (lab[:22], len(vals), nv, s))
@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
"""호표 성분 소계의 반올림 방식을 가른다 — 절사 / 사사오입 / 은행가 / 원값."""
import sys, io, pathlib, warnings, re, collections
from decimal import Decimal as D, ROUND_DOWN, ROUND_HALF_UP, ROUND_HALF_EVEN
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
warnings.filterwarnings("ignore"); import openpyxl
def q(x,m): return D(x).quantize(D(1), rounding=m)
def dec(v):
if v in (None,""): return D(0)
try: return D(str(v))
except Exception: return D(0)
ROOT = pathlib.Path(r"D:\aislo-wt\sub\resources\knowledge\original\실무문서")
def scan(path, sheet, stat):
try: wb = openpyxl.load_workbook(path, data_only=True, read_only=True)
except Exception: return
if sheet not in wb.sheetnames: return
ws = wb[sheet]
rows=[list(r) for r in ws.iter_rows(min_row=1,max_row=ws.max_row,max_col=12,values_only=True)]
blk=None
for r in rows:
nm=(str(r[0]) if r[0] is not None else "").strip().replace(" ","")
if re.match(r"^제?\d+호표", nm): blk=[]; continue
if blk is None: continue
if nm=="합계":
# 합계행의 금액칸 위치를 찾는다: 성분 원합과 가장 가까운 칸
for idx,lab in ((7,"노무"),(9,"재료"),(11,"경비")):
raw = sum(dec(c[idx]) for c in blk)
if raw == 0: continue
cands = {i: dec(r[i]) for i in (idx-1, idx) if r[i] is not None}
for rm, tag in ((ROUND_DOWN,"절사"),(ROUND_HALF_UP,"사사오입"),(ROUND_HALF_EVEN,"은행가")):
v = q(raw, rm)
if v in cands.values(): stat[(sheet,tag)] += 1
if raw in cands.values(): stat[(sheet,"원값")] += 1
stat[(sheet,"")] += 1
blk=None; continue
blk.append(r)
stat = collections.Counter()
for p in ROOT.rglob("*.xlsx"):
if p.name.startswith("~$"): continue
for s in ("중기사용료","일위대가표"): scan(p, s, stat)
for sheet in ("중기사용료","일위대가표"):
tot = stat[(sheet,"")]
if not tot: continue
print("\n### %s — 성분 소계 %d" % (sheet, tot))
for tag in ("절사","사사오입","은행가","원값"):
n = stat[(sheet,tag)]
print(" %-8s %5d%5.1f%%" % (tag, n, 100*n/tot))
@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
"""단가산출근거 — Q식과 성분단가 = 시간단가/Q 의 반올림 규칙을 전수 확인."""
import sys, io, pathlib, warnings, re, collections
from decimal import Decimal as D, ROUND_DOWN, ROUND_HALF_UP
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
warnings.filterwarnings("ignore"); import openpyxl
ROOT = pathlib.Path(r"D:\aislo-wt\sub\resources\knowledge\original\실무문서")
NUM = r"([\d,]+(?:\.\d+)?)"
RE_Q = re.compile(r"Q\s*=\s*([0-9.*/\sqKfECm+\-]+?)\s*=\s*"+NUM)
RE_C = re.compile(r"(노\s*무\s*비|재\s*료\s*비|경\s*비)\s*:\s*"+NUM+r"\s*/\s*Q\s*=\s*"+NUM)
def d(s): return D(s.replace(",",""))
stat=collections.Counter(); ex=[]
for p in sorted(ROOT.rglob("*.xlsx")):
if p.name.startswith("~$"): continue
try: wb=openpyxl.load_workbook(p,data_only=True,read_only=True)
except Exception: continue
if "단가산출근거" not in wb.sheetnames: continue
curQ=None
for row in wb["단가산출근거"].iter_rows(min_row=1,max_row=wb["단가산출근거"].max_row,max_col=3,values_only=True):
txt = " ".join(str(c) for c in row[:2] if isinstance(c,str))
if not txt: continue
if re.search(r"\s*\d+\s*호표", txt): curQ=None; continue
m=RE_Q.search(txt)
if m:
curQ = d(m.group(2)); stat["Q식 발견"]+=1; continue
for mm in RE_C.finditer(txt):
if curQ in (None, D(0)): continue
시간단가 = d(mm.group(2)); 결과 = d(mm.group(3))
raw = 시간단가/curQ
for pl,tag in ((1,"소수1"),(0,"정수")):
for rm,rt in ((ROUND_DOWN,"절사"),(ROUND_HALF_UP,"사사오입")):
if raw.quantize(D(1).scaleb(-pl), rounding=rm) == 결과: stat[(tag,rt)]+=1
stat["성분 총"]+=1
if len(ex)<4 and raw.quantize(D("0.1"),rounding=ROUND_DOWN)!=결과:
ex.append((p.stem[:22], 시간단가, curQ, raw, 결과))
print("### 단가산출근거 — Q식과 성분단가")
print(" Q식 발견 %d개, 성분단가 식 %d" % (stat["Q식 발견"], stat["성분 총"]))
t=stat["성분 총"]
if t:
print("\n 성분단가 = 시간당 성분단가 / Q 의 반올림")
for pl in ("소수1","정수"):
for rt in ("절사","사사오입"):
n=stat[(pl,rt)]
print(" %-5s %-5s %5d%5.1f%%" % (pl, rt, n, 100*n/t))
for e in ex: print("%-22s %s / %s = %s%s" % e)
@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
"""일위대가 호표 참조 그래프 — 구성행이 다른 호표를 가리키는지, 위상순서가 성립하는지."""
import sys, io, pathlib, warnings, re, collections
from decimal import Decimal as D, ROUND_DOWN
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
warnings.filterwarnings("ignore"); import openpyxl
def dn(x): return D(x).quantize(D(1), rounding=ROUND_DOWN)
def dec(v):
if v in (None,""): return None
try: return D(str(v))
except Exception: return None
P = pathlib.Path(r"D:\aislo-wt\sub\resources\knowledge\original\실무문서\04.산불 봉화\01. 내역서\2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx")
wb = openpyxl.load_workbook(P, data_only=True)
def blocks(sheet):
ws=wb[sheet]
rows=[list(r) for r in ws.iter_rows(min_row=1,max_row=ws.max_row,max_col=12,values_only=True)]
out=[]; cur=None; title=None
for r in rows:
t=(str(r[0]) if r[0] else "").strip()
k=t.replace(" ","")
if re.match(r"^제?\d+호표$", k): cur={"no":int(re.sub(r"\D","",k)),"title":None,"comp":[],"sum":None}; out.append(cur); continue
if cur is None: continue
if cur["title"] is None and t and r[3] and not r[2]:
cur["title"]=(t, str(r[1] or "").strip(), str(r[3] or "")); continue
if k=="합계": cur["sum"]=r; cur=None; continue
if t: cur["comp"].append(r)
return out
B = blocks("일위대가표")
print("### 일위대가표 호표 %d" % len(B))
# 호표 합계(단가) 사전
unit = {}
for b in B:
if b["title"] and b["sum"]:
unit[(b["title"][0], b["title"][1])] = dec(b["sum"][5])
print(" 제목으로 찾을 수 있는 호표 %d" % len(unit))
edges=collections.Counter(); hit=miss=0; ex=[]
for b in B:
for c in b["comp"]:
nm=(str(c[0]) if c[0] else "").strip(); sz=str(c[1] or "").strip()
up=dec(c[4])
if up is None: continue
key=(nm,sz)
if key in unit and unit[key]==up:
edges[b["no"]]+=1; hit+=1
elif key in unit:
miss+=1
if len(ex)<4: ex.append((b["no"],nm[:16],sz[:12],up,unit[key]))
print("\n 구성행이 다른 호표를 가리키고 단가가 일치: %d" % hit)
print(" 이름은 같은데 단가가 다름: %d" % miss)
for e in ex: print("%d호표 %-16s %-12s 구성 %s ≠ 호표합계 %s" % e)
print("\n 호표 사이 참조를 가진 호표 %d개 (전체 %d개)" % (len(edges), len(B)))
# 순환 검사
adj=collections.defaultdict(set)
byname={}
for b in B:
if b["title"]: byname[(b["title"][0],b["title"][1])]=b["no"]
for b in B:
for c in b["comp"]:
key=((str(c[0]) if c[0] else "").strip(), str(c[1] or "").strip())
if key in byname and byname[key]!=b["no"]: adj[b["no"]].add(byname[key])
color={}
cyc=[]
def dfs(u, path):
color[u]=1
for v in adj[u]:
if color.get(v)==1: cyc.append(path+[u,v])
elif color.get(v) is None: dfs(v, path+[u])
color[u]=2
for b in B:
if color.get(b["no"]) is None: dfs(b["no"], [])
print(" 순환 참조: %s" % ("없음 → 위상순서 계산 가능" if not cyc else cyc[:2]))
depth={}
def dep(u):
if u in depth: return depth[u]
depth[u]=0
depth[u]= 1+max([dep(v) for v in adj[u]], default=-1)
return depth[u]
for b in B: dep(b["no"])
mx=max(depth.values()) if depth else 0
print(" 참조 깊이 최대 %d단 (분포: %s)" % (mx, dict(collections.Counter(depth.values()))))
@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
"""누락 확인 ① 노임 시간당 환산식 ② 집계표 반올림 ③ INITIAL 설정"""
import sys, io, pathlib, warnings, re, collections
from decimal import Decimal as D, ROUND_DOWN, ROUND_HALF_UP, ROUND_HALF_EVEN
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
warnings.filterwarnings("ignore"); import openpyxl
def dec(v):
if v in (None,""): return None
try: return D(str(v))
except Exception: return None
def q(x,m): return D(x).quantize(D(1), rounding=m)
ROOT = pathlib.Path(r"D:\aislo-wt\sub\resources\knowledge\original\실무문서")
XL=[p for p in sorted(ROOT.rglob("*.xlsx")) if not p.name.startswith("~$")]
print("### ① 노임 시간당 환산식 — 환율및기초자료 시트")
for p in XL:
try: wb=openpyxl.load_workbook(p,data_only=True,read_only=True)
except Exception: continue
if "환율및기초자료" not in wb.sheetnames: continue
ws=wb["환율및기초자료"]
rows=[list(r) for r in ws.iter_rows(min_row=1,max_row=ws.max_row,max_col=10,values_only=True)]
print("\n-- %s" % p.stem[:52])
for r in rows:
nm = r[2] if len(r)>2 else None
= dec(r[3]) if len(r)>3 else None
= r[4] if len(r)>4 else None
= dec(r[6]) if len(r)>6 else None
if not (nm and and and isinstance(,str)): continue
expr = .strip().lstrip("*").strip()
try:
factor = eval(expr, {"__builtins__":{}})
except Exception:
factor = None
calc = q(D(str(float()*factor)), ROUND_DOWN) if factor else None
ok = "일치" if calc== else ("%+d"%(calc-) if calc is not None else "식 해석실패")
print(" %-16s%9s × %-18s = %9s 출력 %9s %s" % (str(nm)[:16], f"{:,}", expr, f"{calc:,}" if calc else "-", f"{:,}", ok))
print("\n\n### ② 집계표 행 금액 반올림 — 수량 × 단가")
SHEETS = ["재료비수량금액집계표","노무비수량금액집계표","경비수량금액집계표","중기시간금액집계표"]
stat=collections.Counter()
for p in XL:
try: wb=openpyxl.load_workbook(p,data_only=True,read_only=True)
except Exception: continue
for s in SHEETS:
if s not in wb.sheetnames: continue
ws=wb[s]
for r in ws.iter_rows(min_row=1,max_row=ws.max_row,max_col=12,values_only=True):
nums=[(i,dec(v)) for i,v in enumerate(r) if isinstance(v,(int,float))]
if len(nums)<3: continue
# 수량·단가·금액 세 쌍을 찾는다
for a in range(len(nums)-2):
qty,up,amt = nums[a][1],nums[a+1][1],nums[a+2][1]
if not (qty and up and amt) or qty<=0 or up<=0: continue
raw=qty*up
if abs(raw-amt) > raw*D("0.02"): continue
for m,tag in ((ROUND_DOWN,"절사"),(ROUND_HALF_UP,"사사오입"),(ROUND_HALF_EVEN,"은행가")):
if q(raw,m)==amt: stat[(s,tag)]+=1
if raw==amt: stat[(s,"원값")]+=1
stat[(s,"")]+=1
break
for s in SHEETS:
t=stat[(s,"")]
if not t: print(" %-22s 표본 없음"%s); continue
print(" %-22s 표본 %4d 절사 %4d(%.0f%%) 사사오입 %4d(%.0f%%) 은행가 %4d(%.0f%%) 원값 %d" % (
s, t, stat[(s,"절사")],100*stat[(s,"절사")]/t, stat[(s,"사사오입")],100*stat[(s,"사사오입")]/t,
stat[(s,"은행가")],100*stat[(s,"은행가")]/t, stat[(s,"원값")]))
@@ -41,6 +41,10 @@
| 13 | 통제시험 목록 T1~T11 — 아직 못 가른 경계 |
| 14 | 최종 분석완료 보고서 |
| 15 | **재구현 가능성 검증** — 무엇을 만들 수 있고 무엇이 막혀 있나 |
| 16 | **골든셋 회귀 결과** — 원가계산을 다시 세워 원 단위로 맞춘 실증 |
| 17 | **단가조립 검증** — 일위대가·중기 시간당 사용료 구조, 손료율 실측 |
| 18 | **누락 계산로직 점검** — 노임 환산식·단가산출 Q식·설정 스냅샷 |
| 19 | **진행률 장부** — 어디까지 봤고 무엇이 남았나 |
## 확정된 굵직한 것
@@ -51,6 +55,8 @@
- 기준자료 `JB_R` 35열 확장 시점 **2020-05-27**, 기존 1~20열은 한 칸도 안 바뀜
- 설정 **83축** 전수. 화면 순번과 내부값이 **뒤집힌 축 둘** 있음 — 그대로 저장하면 정반대 공식이 걸림
- 보험 요율의 **표 좌표 확정** — 산재 `RATE_R[45,1]`, 건강 `[46,1]`, 연금 `[46,6]`, 장기요양 `[46,11]`, 고용 등급1~7 `[45,11..17]`. 5개 연도 시계열이 실제 법정요율 변천과 맞음
- **계산 사슬 전체가 드러남** — 일 노임 → 시간당 → 중기 사용료 → 단가산출(Q식) → 일위대가 → 내역 → 간접비 → 총공사비 (18번)
- **원가계산 전 과정을 원 단위로 재현함** — 실무 프로젝트 6건, 내역 행 1,515건 무결, 총공사비까지 1원도 안 틀림 (16번)
- ⚠ **기초단가 원천은 전부 암호화** — 자재 5.7만 건·노임·중기·표준시장단가를 한 건도 못 읽음. 「계산 엔진」은 만들 수 있으나 「기준자료를 갖춘 완제품」은 못 만듦 (15번)
## 원자료 — `30_원자료/`