Merge remote-tracking branch 'origin/dev' into sub_laptop_1
# Conflicts: # resources/knowledge/original/원가계산/산림_표준품셈/본문/제04장_나무베기/4-06_벌목부_작업안전_보조.md
This commit is contained in:
@@ -5,12 +5,12 @@ PDF쪽: 63
|
||||
인쇄쪽: 51
|
||||
시작표지: 4-6. 벌목부 작업안전 보조
|
||||
끝표지: 제5장 식재 및 녹화
|
||||
상태: 검증대기
|
||||
상태: 확정
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
검증: sub_laptop_3
|
||||
원천: 글자층
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문: 578b6539037cdfe4e41314fc02cfb862cc25403fd3af290ee7a50344633ffd87
|
||||
확정지문: f920d4aa3a93ed51c6ef4e524dfe3adf88c4ef3a0611656724179bbd6f2fd60e
|
||||
---
|
||||
|
||||
## 4-6. 벌목부 작업안전 보조
|
||||
|
||||
@@ -9,7 +9,7 @@ PDF쪽: 64
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ PDF쪽: 64~66
|
||||
인쇄쪽: 52~54
|
||||
시작표지: 5-1. 굴취작업
|
||||
끝표지: 5-2. 뿌리돌림
|
||||
상태: 검증대기
|
||||
상태: 확정
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
검증: sub_laptop_3
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
확정지문:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문: 5df7616cea2cf66fa738d38c91f65b1dd3feb5754d1fa9e9729a5705bf8f503b
|
||||
---
|
||||
|
||||
## 5-1. 굴취작업
|
||||
|
||||
@@ -5,12 +5,12 @@ PDF쪽: 73~74
|
||||
인쇄쪽: 61~62
|
||||
시작표지: 5-11. 사초심기
|
||||
끝표지: 5-12. 떼붙임(재배잔디)
|
||||
상태: 검증대기
|
||||
상태: 확정
|
||||
작성: sub_laptop_2
|
||||
검증:
|
||||
검증: sub_laptop_4
|
||||
원천: 글자층
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
확정지문: 5a7ced17f934551f6835b729efbbca64c49255527dd30e659b36398a5247f64b
|
||||
---
|
||||
|
||||
## 5-11. 사초심기
|
||||
|
||||
@@ -5,12 +5,12 @@ PDF쪽: 74
|
||||
인쇄쪽: 62
|
||||
시작표지: 5-12. 떼붙임(재배잔디)
|
||||
끝표지: 5-13. 떼심기
|
||||
상태: 검증대기
|
||||
상태: 확정
|
||||
작성: sub_laptop_2
|
||||
검증:
|
||||
검증: sub_laptop_4
|
||||
원천: 글자층
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
확정지문: 3caa9728cf251862784010b5d354a8ee32b3760c0515c559ca27a3c412b0d507
|
||||
---
|
||||
|
||||
## 5-12. 떼붙임(재배잔디)
|
||||
|
||||
@@ -5,12 +5,12 @@ PDF쪽: 74~75
|
||||
인쇄쪽: 62~63
|
||||
시작표지: 5-13. 떼심기
|
||||
끝표지: 5-14. 새심기
|
||||
상태: 검증대기
|
||||
상태: 확정
|
||||
작성: sub_laptop_2
|
||||
검증:
|
||||
검증: sub_laptop_4
|
||||
원천: 글자층
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
확정지문: 704e86bbc40d9419027ff16381efc4d172de112f650c8d3f4e7cd715ede7400b
|
||||
---
|
||||
|
||||
## 5-13. 떼심기
|
||||
|
||||
@@ -5,12 +5,12 @@ PDF쪽: 75
|
||||
인쇄쪽: 63
|
||||
시작표지: 5-14. 새심기
|
||||
끝표지: 5-15. 바자얽기
|
||||
상태: 검증대기
|
||||
상태: 확정
|
||||
작성: sub_laptop_2
|
||||
검증:
|
||||
검증: sub_laptop_4
|
||||
원천: 글자층
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
확정지문: 8bbd0471eac31be61aa7c441112992e0b972a0f530c84bf9cce9077d398775b6
|
||||
---
|
||||
|
||||
## 5-14. 새심기
|
||||
|
||||
@@ -5,12 +5,12 @@ PDF쪽: 75~76
|
||||
인쇄쪽: 63~64
|
||||
시작표지: 5-15. 바자얽기
|
||||
끝표지: 5-16. 선떼붙이기공
|
||||
상태: 검증대기
|
||||
상태: 확정
|
||||
작성: sub_laptop_2
|
||||
검증:
|
||||
검증: sub_laptop_4
|
||||
원천: 글자층
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
확정지문: ad33c43714431682f3a1295ca6c8ba8627ee39e0eeac60800cb34aefc9b18930
|
||||
---
|
||||
|
||||
## 5-15. 바자얽기
|
||||
|
||||
@@ -5,12 +5,12 @@ PDF쪽: 76
|
||||
인쇄쪽: 64
|
||||
시작표지: 5-16. 선떼붙이기공
|
||||
끝표지: 5-17. 조공
|
||||
상태: 검증대기
|
||||
상태: 확정
|
||||
작성: sub_laptop_2
|
||||
검증:
|
||||
검증: sub_laptop_4
|
||||
원천: 글자층
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
확정지문: f591ab94c6fa59fa75432d7d8534c7c032dd3ecbcdfba07ed9e6cb0067b99248
|
||||
---
|
||||
|
||||
## 5-16. 선떼붙이기공
|
||||
|
||||
@@ -5,12 +5,12 @@ PDF쪽: 77
|
||||
인쇄쪽: 65
|
||||
시작표지: 5-17. 조공
|
||||
끝표지: 5-18. 씨뿌리기(줄)
|
||||
상태: 검증대기
|
||||
상태: 확정
|
||||
작성: sub_laptop_2
|
||||
검증:
|
||||
검증: sub_laptop_4
|
||||
원천: 글자층
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
확정지문: b52381a9a84d85323dd601db56f4f1f77abf6eabf16805620f16f233bfeb6236
|
||||
---
|
||||
|
||||
## 5-17. 조공
|
||||
|
||||
@@ -5,12 +5,12 @@ PDF쪽: 77~78
|
||||
인쇄쪽: 65~66
|
||||
시작표지: 5-18. 씨뿌리기(줄)
|
||||
끝표지: 5-19. 표토 절취 및 정지
|
||||
상태: 검증대기
|
||||
상태: 확정
|
||||
작성: sub_laptop_2
|
||||
검증:
|
||||
검증: sub_laptop_4
|
||||
원천: 글자층
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
확정지문: 2e152984431398dec1dd5ff1c02d276f748d53271e56f83d2f71979b63670f52
|
||||
---
|
||||
|
||||
## 5-18. 씨뿌리기(줄)
|
||||
|
||||
@@ -5,12 +5,12 @@ PDF쪽: 78
|
||||
인쇄쪽: 66
|
||||
시작표지: 5-19. 표토 절취 및 정지
|
||||
끝표지: 5-20. 표토관리
|
||||
상태: 검증대기
|
||||
상태: 확정
|
||||
작성: sub_laptop_2
|
||||
검증:
|
||||
검증: sub_laptop_4
|
||||
원천: 글자층
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
확정지문: 461a24d9a252d751166c753b3c08153e6390c1c98192616a86a4d3497ee711c8
|
||||
---
|
||||
|
||||
## 5-19. 표토 절취 및 정지
|
||||
|
||||
@@ -5,12 +5,12 @@ PDF쪽: 78
|
||||
인쇄쪽: 66
|
||||
시작표지: 5-20. 표토관리
|
||||
끝표지: 5-21. 표토이식
|
||||
상태: 검증대기
|
||||
상태: 확정
|
||||
작성: sub_laptop_2
|
||||
검증:
|
||||
검증: sub_laptop_4
|
||||
원천: 글자층
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
확정지문: 1b2ce1aaae7b7e38d580415e4205305671d3eb1baa432b6588edfd9008cef188
|
||||
---
|
||||
|
||||
## 5-20. 표토관리
|
||||
|
||||
@@ -5,10 +5,28 @@ PDF쪽: 79
|
||||
인쇄쪽: 67
|
||||
시작표지: 5-21. 표토이식
|
||||
끝표지: 5-22. 평떼
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
## 5-21. 표토이식
|
||||
|
||||
(인/㎡)
|
||||
|
||||
| 구분 | 보통인부(인) | 비고 |
|
||||
|---|---|---|
|
||||
| 채취 | 0.34 | |
|
||||
| 운반 | 0.26 | |
|
||||
| 붙이기 | 0.47 | |
|
||||
|
||||
[주]
|
||||
① 사방사업에서 기존 표토를 채취하여 보관한 후 이식하여 주변 식생과 조화를 이루게 하는 지역에 적용한다.
|
||||
② 채취품은 1.2m 이하의 관목류와 초본류를 채취하는데 사용되며 그 이상은 별도 품을 적용한다.
|
||||
③ 표토의 두께는 0.2m를 기준으로 한다. 그 이상은 장비와 병용하여 사용한다.
|
||||
④ 운반 30m를 기준으로 한 품이며 그 이상은 무게(떼 무게 적용)를 감안한 소운반을 별도 계상한다.
|
||||
⑤ 붙이기 품에는 복토 및 식재지 면고르기, 잡관목 제거품은 포함되어 있지 않다.
|
||||
⑥ 표토의 채취 시 채취지역을 분산시켜 자연식생의 훼손을 최소화한다.
|
||||
|
||||
@@ -5,10 +5,54 @@ PDF쪽: 79~80
|
||||
인쇄쪽: 67~68
|
||||
시작표지: 5-22. 평떼
|
||||
끝표지: 5-23. 줄떼
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
## 5-22. 평떼
|
||||
|
||||
### 5-22-1. 떼 구입 및 운반 : 별도 계상
|
||||
|
||||
\- 규격 및 소요 매수 : 0.3×0.3, 11매/㎡
|
||||
|
||||
### 5-22-2. 평떼 붙임
|
||||
|
||||
(㎡당)
|
||||
|
||||
| 구분 | 단위 | 적용 | 비고 |
|
||||
|---|---|---|---|
|
||||
| 조경공 | 인 | 0.0099 | |
|
||||
| 보통인부 | 인 | 0.0231 | |
|
||||
|
||||
### 5-22-3. 떼꽂이 제작 및 설치
|
||||
|
||||
(㎡당)
|
||||
|
||||
| 구분 | 단위 | 적용 | 비고 |
|
||||
|---|---|---|---|
|
||||
| 보통인부 | 인 | 0.011 | 1일1인 1,000개,<br>㎡당 3.3개 기준 |
|
||||
|
||||
[주]
|
||||
평떼(보통인부 0.06인)품에는 떼꽂이 제작 및 설치가 포함되어 있어 비교ㆍ검토 후 적용한다.
|
||||
|
||||
### 5-22-4. 평떼 시비
|
||||
|
||||
(일당)
|
||||
|
||||
| 구분 | 규격 | 단위 | 수량 | 시공량(㎡) |
|
||||
|---|---|---|---|---|
|
||||
| 조경공 | | 인 | 2 | 22,500 |
|
||||
| 보통인부 | | | 1 | |
|
||||
| 트럭 | 2.5ton | 대 | 1 | |
|
||||
<!-- 병합: 2열 1~2행 빈 칸 · 3열 1~2행 「인」 · 5열 1~3행 「22,500」 -->
|
||||
|
||||
[주]
|
||||
① 본 품은 화학비료의 살포가 300~700kg/10,000㎡인 경우 기준이다.
|
||||
② 현장조건, 살포조건에 따라 살포량이 다를 때는 본 품의 20%범위 내에서 증감할 수 있다.
|
||||
③ 비료량은 별도 계상한다.
|
||||
④ 「건설공사 표준품셈 유지관리부문, 1-2-5 관목 전정(2025)」 참고한다.
|
||||
<!-- 원문 그대로: 시비 품의 참고로 「관목 전정」 절을 적음 -->
|
||||
|
||||
@@ -5,10 +5,33 @@ PDF쪽: 80
|
||||
인쇄쪽: 68
|
||||
시작표지: 5-23. 줄떼
|
||||
끝표지: 5-24. 씨앗뿜어붙이기
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
## 5-23. 줄떼
|
||||
|
||||
### 5-23-1. 떼 구입 및 운반 : 별도 계상
|
||||
|
||||
\- 규격 및 소요 매수 : 0.3×0.3(30㎝ 간격), 3.33매/㎡
|
||||
|
||||
### 5-23-2. 떼붙임
|
||||
|
||||
(㎡당)
|
||||
|
||||
| 구분 | 단위 | 적용 | 비고 |
|
||||
|---|---|---|---|
|
||||
| 조경공 | 인 | 0.0084 | |
|
||||
| 보통인부 | 인 | 0.0196 | |
|
||||
|
||||
### 5-23-3. 떼꽂이 제작 및 설치
|
||||
|
||||
(㎡당)
|
||||
|
||||
| 구분 | 단위 | 적용 | 비고 |
|
||||
|---|---|---|---|
|
||||
| 보통인부 | 인 | 0.0033 | 1일1인 1,000개,<br>㎡당 3.3개 기준 |
|
||||
|
||||
@@ -5,10 +5,113 @@ PDF쪽: 81~83
|
||||
인쇄쪽: 69~71
|
||||
시작표지: 5-24. 씨앗뿜어붙이기
|
||||
끝표지: 5-25. 거적덮기
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
## 5-24. 씨앗뿜어붙이기
|
||||
|
||||
### 5-24-1. 뿜어붙이기-기계/일반
|
||||
|
||||
(㎡당)
|
||||
|
||||
| 구분 | | 규격 | 단위 | 적용수량 | 비고 |
|
||||
|---|---|---|---|---|---|
|
||||
| 자재 | 종자 | | kg | 0.025 | |
|
||||
| | 비료 | 복합비료 | kg | 0.1 | |
|
||||
| | 피복제 | 화이버 | kg | 0.18 | |
|
||||
| | 침식안정제 | 합성접착제 | kg | 0.1 | |
|
||||
| | 색소 | 색소 | kg | 0.002 | |
|
||||
| 장비 | 종자살포기 | 2,500-3,000ℓ | 시간 | 0.0024 | |
|
||||
| | 트럭 | 4.5ton | 시간 | 0.0024 | |
|
||||
| | 물탱크 | 5,500ℓ | 시간 | 0.0036 | |
|
||||
| 인력 | 조경공 | | 인 | 0.0007 | |
|
||||
| | 보통인부 | | 인 | 0.0004 | |
|
||||
<!-- 병합: 머리 1~2열 「구분」 · 1열 1~5행 「자재」 · 1열 6~8행 「장비」 · 1열 9~10행 「인력」 -->
|
||||
|
||||
[주]
|
||||
① 씨앗의 배합은「도로비탈면 녹화공사의 설계및 시공 지침(2009)」 2.7 종자배합설계에 의한다.
|
||||
② 외래종은 생태계에 미치는 영향을 고려하여 신중히 결정한다.
|
||||
③ 배합 비율은 환경(비탈면 방향, 고도 등) 및 공사시기(계절) 등을 종합적으로 고려하여 판단한다.
|
||||
④ 비탈면 구간은 살수차에 의한 살수가 불가능하나 살수차에 의한 관수의 인력품을 적용한다.
|
||||
⑤ 「건설공사 표준품셈 공통부문, 4-1-2 초류종자 살포(2025)」참조한다.
|
||||
⑥ 시드스프레이(seed spray) 공법이라 칭한다.
|
||||
⑦ 물주기(인력) 필요시 보통인부 0.0005인 적용한다. 「건설공사 표준품셈 유지관리부문, 1-2-8 인력관수(2025)「」, 「건설공사 표준품셈 유지관리부문, 1-2-9 살수차관수(2025)」 참조한다.
|
||||
<!-- 원문 그대로: ⑦ 「인력관수(2025)」 뒤 낫표가 「「」」 로 찍힘 -->
|
||||
|
||||
### 5-24-2. 뿜어붙이기-기계/마사토
|
||||
|
||||
(㎡당)
|
||||
|
||||
| 구분 | | 규격 | 단위 | 적용수량 | 비고 |
|
||||
|---|---|---|---|---|---|
|
||||
| 자재 | 종자 | | kg | 0.025 | |
|
||||
| | 비료 | 복합비료 | kg | 0.1 | |
|
||||
| | 피복제 | 화이버 | kg | 0.18 | |
|
||||
| | 침식안정제 | 합성접착제 | kg | 0.1 | |
|
||||
| | 색소 | 색소 | kg | 0.002 | |
|
||||
| 장비 | 종자살포기 | 2,500-3,000ℓ | 시간 | 0.0024 | |
|
||||
| | 트럭 | 4.5ton | 시간 | 0.0024 | |
|
||||
| | 물탱크 | 5,500ℓ | 시간 | 0.0036 | |
|
||||
| 인력 | 조경공 | 뿜어붙이기인력 | 인 | 0.0007 | |
|
||||
| | 보통인부 | 뿜어붙이기인력 | 인 | 0.0004 | |
|
||||
<!-- 병합: 머리 1~2열 「구분」 · 1열 1~5행 「자재」 · 1열 6~8행 「장비」 · 1열 9~10행 「인력」 -->
|
||||
|
||||
[주]
|
||||
① 물주기(인력) 필요시 보통인부 0.0005인 적용한다.
|
||||
② 골파기 : 1.0m간격/10㎡(3×3.33), A=0.3m/줄×2줄×3.33m=2.0㎡
|
||||
보통인부 0.02인×A=0.04인
|
||||
<!-- 원문: ② 의 둘째 줄(보통인부 …)은 PDF 가 한 줄 내려 찍은 이어진 줄 -->
|
||||
|
||||
<자연경관복원형 적용지역 종자배합비율 조견표>
|
||||
|
||||
| 복원목표 | 식생구분 | 종자배합비율(%) |
|
||||
|---|---|---|
|
||||
| 초본 위주형 | 관목류 | 20~40 |
|
||||
| | 초본, 야생화류 | 40~80 |
|
||||
| | 외래초종(양잔디류) | 0~10 |
|
||||
| | 합계 | 100 |
|
||||
| 초본ㆍ관목<br>혼합형 | 관목류 | 30~50 |
|
||||
| | 초본, 야생화류 | 45~70 |
|
||||
| | 외래초종(양잔디류) | 0~5 |
|
||||
| | 합계 | 100 |
|
||||
| 목본 군락형 | 교목류, 아교목류, 관목류 | 40~70 |
|
||||
| | 초본, 야생화류 | 30~70 |
|
||||
| | 외래초종(양잔디류) | 0 |
|
||||
| | 합계 | 100 |
|
||||
<!-- 병합: 1열 1~4행 「초본 위주형」 · 1열 5~8행 「초본ㆍ관목 혼합형」 · 1열 9~12행 「목본 군락형」 -->
|
||||
|
||||
<기타 적용지역 종자배합비율 조견표>
|
||||
|
||||
| 복원목표 | 식생구분 | 종자배합비율(%) |
|
||||
|---|---|---|
|
||||
| 초본 위주형 | 관목류 | 10∼40 |
|
||||
| | 초본, 야생화류 | 40∼80 |
|
||||
| | 외래초종(양잔디류) | 10∼20 |
|
||||
| | 합계 | 100 |
|
||||
| 초본·관목<br>혼합형 | 관목류, 아교목류 | 30∼50 |
|
||||
| | 초본, 야생화류 | 40∼70 |
|
||||
| | 외래초종(양잔디류) | 5∼15 |
|
||||
| | 합계 | 100 |
|
||||
| 목본 군락형 | 교목류, 아교목류, 관목류 | 35∼60 |
|
||||
| | 초본, 야생화류 | 35∼65 |
|
||||
| | 외래초종(양잔디류) | 3∼10 |
|
||||
| | 합계 | 100 |
|
||||
<!-- 병합: 1열 1~4행 「초본 위주형」 · 1열 5~8행 「초본·관목 혼합형」 · 1열 9~12행 「목본 군락형」 -->
|
||||
<!-- 원문 그대로: 두 조견표의 물결표(~ · ∼)와 가운뎃점(ㆍ · ·)이 서로 다른 글자 -->
|
||||
|
||||
[주]
|
||||
① 「도로비탈면 녹화공사의 설계및 시공 지침 2.7 종자배합설계(2009)」 참조한다.
|
||||
② 기타 적용지역이란 자연경관복원형의 복원목표가 적용되지 않는 지역을 말한다.
|
||||
③ 해안생태계지역에서는 해안에 적합한 종자배합을 하고, 외래도입초종이 10%를 상회하지 않도록 한다.
|
||||
|
||||
> 【적용 시 참고사항】
|
||||
> 가. 난지형초종인 weeping lovegrass 사용을 최대한 억제하며, 싸리와 낭아초는 20% 이하의 비율로 배합하여 지나치게 우점하지 않도록 한다.
|
||||
> 나. 숫자는 중량배합비율을 의미하며 종자배합 시 외래초종의 중량배합비율을 우선 정한 다음 초본, 야생화류, 관목류 등 전체 종자 배합량의 합이 100이 되도록 정한다.
|
||||
> 다. 복원목표가 자연경관 복원형인 경우 녹화식물은 그늘사초, 큰기름새, 대사초, 참억새, 새, 솔새, 개솔새, 양지꽃, 노루오줌, 구절초, 참취, 큰까치수영, 뚝갈 등의 초본류와 생강나무, 진달래, 철쭉, 개옻나무, 붉나무, 국수나무, 산초나무, 쥐똥나무, 개암나무, 호랑버들, 병꽃나무, 찔레나무, 산딸기, 복분자딸기, 노린재나무, 호랑버들, 소나무 등의 목본류가 비탈면 녹화용 자생종(재래초본, 재래목본)으로 사용될 수 있으며, 이들 외에도 부록에서 제시된 식물들을 추가로 사용할 수 있다.
|
||||
> 라. 녹화용 식물은 현지에 서식하는 식물을 주로 활용하고, 중국산인 경우 산지를 확인하여 우리나라 기후 및 풍토와 유사한 지역인지를 확인한다.
|
||||
> 마. 목본 군락형의 경우 교목류와 아교목류는 비탈면 상부의 토심이 깊고 경사도가 완만하고 비탈면 안정에 영향이 없으며 시거에 지장이 없는 구간에 적용이 가능하며, 성장 후 키가 낮은 식물을 우선 적용할 수 있다.
|
||||
|
||||
@@ -5,10 +5,25 @@ PDF쪽: 83
|
||||
인쇄쪽: 71
|
||||
시작표지: 5-25. 거적덮기
|
||||
끝표지: 5-26. 흙갈이
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
## 5-25. 거적덮기
|
||||
|
||||
(㎡당)
|
||||
|
||||
| 구분 | | 단위 | 수량 | 비고 |
|
||||
|---|---|---|---|---|
|
||||
| 자재 | 거적 | ㎡ | 1.1 | |
|
||||
| 인력 | 조경공 | 인 | 0.002 | |
|
||||
| | 보통인부 | 인 | 0.0007 | |
|
||||
<!-- 병합: 머리 1~2열 「구분」 · 1열 2~3행 「인력」 -->
|
||||
|
||||
[주]
|
||||
① 거적덮기에 필요한 앙카핀, 착지핀, 고정판, 고정끈 등 주자재에 부속된 자재는 별도 계상한다.
|
||||
② 「건설공사 표준품셈 공통부문, 4-1-4 거적덮기(2025)」 참조한다.
|
||||
|
||||
@@ -5,10 +5,55 @@ PDF쪽: 83~84
|
||||
인쇄쪽: 71~72
|
||||
시작표지: 5-26. 흙갈이
|
||||
끝표지: 5-27. 식재면 관리
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
## 5-26. 흙갈이
|
||||
|
||||
### 5-26-1. 막갈이
|
||||
|
||||
(인/992㎡당)
|
||||
|
||||
| 토성 | 막갈이깊이(cm) | | | | |
|
||||
|---|---|---|---|---|---|
|
||||
| | 9 | 12 | 15 | 18 | 21 |
|
||||
| 사토 | 5 | 7 | 9 | 11 | 13 |
|
||||
| 양토 | 6 | 8 | 11 | 13 | 15 |
|
||||
| 식토 | 8 | 11 | 13 | 15 | 18 |
|
||||
<!-- 머리: 몸 1행까지 머리 · 병합: 머리 2~6열 「막갈이깊이(cm)」 · 1열 머리~몸 1행 -->
|
||||
|
||||
[주]
|
||||
① 본 품은 답압된 토양의 구조적 개량을 위해 적용한다.
|
||||
② 면고르기는 포함되어 있지 않다.
|
||||
|
||||
### 5-26-2. 흙부수기
|
||||
|
||||
(인/992㎡당)
|
||||
|
||||
| 토성 | 막갈이깊이(cm) | | | | |
|
||||
|---|---|---|---|---|---|
|
||||
| | 9 | 12 | 15 | 18 | 21 |
|
||||
| 사토 | 3 | 4 | 5 | 6 | 7 |
|
||||
| 양토 | 4 | 5 | 6 | 7 | 8 |
|
||||
| 식토 | 5 | 6 | 7 | 8 | 9 |
|
||||
<!-- 머리: 몸 1행까지 머리 · 병합: 머리 2~6열 「막갈이깊이(cm)」 · 1열 머리~몸 1행 -->
|
||||
|
||||
[주]
|
||||
본 품은 고르기를 포함한 것이다.
|
||||
|
||||
### 5-26-3. 돌자갈치우기
|
||||
|
||||
(인/992㎡당)
|
||||
|
||||
| 토성 | 경토깊이(cm) | | |
|
||||
|---|---|---|---|
|
||||
| | 10% 이내 | 10∼30% | 30% 이상 |
|
||||
| 개답 | 2 | 6 | 17 |
|
||||
| 개전 | 0.5 | 3.5 | 6.5 |
|
||||
<!-- 머리: 몸 1행까지 머리 · 병합: 머리 2~4열 「경토깊이(cm)」 · 1열 머리~몸 1행 -->
|
||||
<!-- 원문 그대로: 머리는 「경토깊이(cm)」 이나 아랫머리 값은 % -->
|
||||
|
||||
@@ -5,10 +5,25 @@ PDF쪽: 84
|
||||
인쇄쪽: 72
|
||||
시작표지: 5-27. 식재면 관리
|
||||
끝표지: 5-28. 비탈덮기
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
## 5-27. 식재면 관리
|
||||
|
||||
(인/10㎡당)
|
||||
|
||||
| 구분 | 수량 | 비고 |
|
||||
|---|---|---|
|
||||
| 조경공 | 0.01 | |
|
||||
| 보통인부 | 0.08 | |
|
||||
|
||||
[주]
|
||||
① 본 품은 흙 쌓기가 발생한 지역을 그 대상으로 한다.
|
||||
② 본 품은 부토 및 면고르기가 완료된 상태에서 인력으로 잔돌제거 등 식재면을 정비하는 기준이다.
|
||||
③ 본 품은 식재면고르기가 필요한 공종에 별도 계상한다.
|
||||
④ 매토종자 이용 시 토양의 단면구조 보전 여부에 따라, 단면구조를 보전하지 않을 경우 토공을, 단면구조를 보전할 경우 다음의 표토관리 품과 운반공을 조합하여 반영한다.
|
||||
|
||||
@@ -5,10 +5,42 @@ PDF쪽: 84~85
|
||||
인쇄쪽: 72~73
|
||||
시작표지: 5-28. 비탈덮기
|
||||
끝표지: 5-29. 복사이식
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
## 5-28. 비탈덮기
|
||||
|
||||
### 5-28-1. 짚망
|
||||
|
||||
(인/100㎡당)
|
||||
|
||||
| 구분 | 수량 | 비고 |
|
||||
|---|---|---|
|
||||
| 특별인부 | 0.19 | |
|
||||
| 보통인부 | 0.06 | |
|
||||
|
||||
[주]
|
||||
① 본품은 성토 또는 절토사면에 짚, 망, 거적, 섶 등을 덮어 설치하는 기준이다.
|
||||
② 재료량은 설계수량에 따라 별도 계상한다.
|
||||
|
||||
### 5-28-2. 방초매트 및 야자섬유매트 포장
|
||||
|
||||
(일당)
|
||||
|
||||
| 구분 | 단위 | 수량 | 시공량(㎡) | |
|
||||
|---|---|---|---|---|
|
||||
| | | | 폭 1.5m 이하 | 폭 2.0m 이하 |
|
||||
| 조경공 | 인 | 2 | 90 | 130 |
|
||||
| 보통인부 | 인 | 1 | | |
|
||||
<!-- 머리: 몸 1행까지 머리 · 병합: 머리 4~5열 「시공량(㎡)」 · 1~3열 머리~몸 1행 · 4열 2~3행 「90」 · 5열 2~3행 「130」 -->
|
||||
|
||||
[주]
|
||||
① 본 품은 설치 위치의 토공사가 완료된 상태에서 야자섬유 매트로 포장하는 기준이다
|
||||
<!-- 원문 그대로: 끝 마침표 없음 -->
|
||||
② 매트 포장면 정리, 야자섬유 매트 및 고정핀 설치, 매트 연결 및 고정, 마무리 작업을 포함한다.
|
||||
③ 설치 위치의 토공 작업은 필요시 별도 계상한다.
|
||||
|
||||
@@ -5,10 +5,32 @@ PDF쪽: 85
|
||||
인쇄쪽: 73
|
||||
시작표지: 5-29. 복사이식
|
||||
끝표지: 5-30. 표시봉설치
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
## 5-29. 복사이식
|
||||
|
||||
| 구분 | 적용 | 비고 |
|
||||
|---|---|---|
|
||||
| K(버킷계수) | 0.9 | |
|
||||
| f(체적환산계수) | 1/1.3 | |
|
||||
| E(작업효율) | 0.70 | |
|
||||
| Cm(1회 사이클시간) | 20(135°)sec | |
|
||||
<!-- 원문 그대로: 이 표는 밑수 줄 없음 -->
|
||||
|
||||
[주]
|
||||
① 교목부터 초본 전체와 토양까지 단기간 내 이식하여 복원하는 기업이다.
|
||||
<!-- 원문 그대로: 「기업」(기법으로 보임) -->
|
||||
② 복사이식 들어내기 및 되메우기에 반영한다.
|
||||
③ 무한궤도형 굴착기(1.0㎥) 적용한다. 0.2㎥ 또는 0.4㎥ 용량의 굴착기를 사용하는 경우에는 「건설공사 표준품셈 공통부문. 8-2-3 굴착기(2025)」를 참조하여 적용계수를 달리 적용하도록 한다.
|
||||
④ Q1=3,600×q×K×f×E/Cm=㎥/시간
|
||||
⑤ Q=Q1*T=㎥/시간
|
||||
여기서 Q1 : 시간당 작업량(㎥/hr)
|
||||
Q : 시간당 작업량(㎥/hr)
|
||||
T : 표토두께(m)
|
||||
<!-- 원문: 「여기서 …」 세 줄은 ⑤ 아래 딸린 줄 -->
|
||||
|
||||
@@ -5,10 +5,23 @@ PDF쪽: 85
|
||||
인쇄쪽: 73
|
||||
시작표지: 5-30. 표시봉설치
|
||||
끝표지: 5-31. 지주목설치
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
## 5-30. 표시봉설치
|
||||
|
||||
(1,000본당)
|
||||
|
||||
| 구분 | 규격 | 소요인력 | 인력구분 |
|
||||
|---|---|---|---|
|
||||
| 표시봉 설치 | 길이 1.0m 이상 | 0.70 | 보통인부 |
|
||||
|
||||
[주]
|
||||
① 본 품은 표시봉 소운반과 설치비를 포함한다.
|
||||
② 표시봉은 길이 1.0m 이상의 대나무 등 친환경 소재를 사용하며, 상부 쪽에 친환경성 흰색 수성페인트를 0.5m 이상 표시한다.
|
||||
③ 표시봉의 가격은 시장가격을 조사하여 적용한다.
|
||||
|
||||
@@ -5,10 +5,23 @@ PDF쪽: 86
|
||||
인쇄쪽: 74
|
||||
시작표지: 5-31. 지주목설치
|
||||
끝표지: 5-32. 대절작업
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
## 5-31. 지주목설치
|
||||
|
||||
(1,000본당)
|
||||
|
||||
| 구분 | 규격 | 소요인력 | 인력구분 |
|
||||
|---|---|---|---|
|
||||
| 지주목 설치 | 길이 1.0m 이상 | 1.00 | 보통인부 |
|
||||
|
||||
[주]
|
||||
① 본 품은 지주목 소운반과 설치비를 포함한다.
|
||||
② 지주목은 대묘식재만 적용하며, 묘목 1본당 지주목 1개를 적용한다.
|
||||
③ 지주목은 길이 1.0m 이상의 목재로 0.3m 이상 땅속에 박히도록 설치하여야 한다.
|
||||
|
||||
@@ -5,10 +5,22 @@ PDF쪽: 86
|
||||
인쇄쪽: 74
|
||||
시작표지: 5-32. 대절작업
|
||||
끝표지: 5-33. 묘목 가식작업
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
## 5-32. 대절작업
|
||||
|
||||
(1,000본당)
|
||||
|
||||
| 구분 | 사용도구 | 소요인력 | 인력구분 |
|
||||
|---|---|---|---|
|
||||
| 대절 | 전정가위 | 0.50 | 특별인부 |
|
||||
|
||||
[주]
|
||||
① 초두부 고사방지를 위하여 식재 후 지상부 줄기에서 눈(芽)이 1~2개 남겨질 수 있도록 지상부에서 3~5㎝ 정도 위치를 전정가위로 잘라주는 것을 말한다.
|
||||
② 백합나무, 오동나무 등 조림목을 대상으로 실시한다.
|
||||
|
||||
@@ -5,10 +5,24 @@ PDF쪽: 86
|
||||
인쇄쪽: 74
|
||||
시작표지: 5-33. 묘목 가식작업
|
||||
끝표지: 5-34. 묘목 소운반
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
## 5-33. 묘목 가식작업
|
||||
|
||||
(1,000본당)
|
||||
|
||||
| 구분 | 소요인력 | 인력구분 |
|
||||
|---|---|---|
|
||||
| 소묘, 중묘 | 0.20 | 보통인부 |
|
||||
| 대묘 | 0.30 | |
|
||||
<!-- 병합: 3열 1~2행 「보통인부」 -->
|
||||
|
||||
[주]
|
||||
본 품은 가식장 준비, 차광망 설치와 물주기를 포함한다
|
||||
<!-- 원문 그대로: 끝 마침표 없음 -->
|
||||
|
||||
@@ -5,10 +5,69 @@ PDF쪽: 86~88
|
||||
인쇄쪽: 74~76
|
||||
시작표지: 5-34. 묘목 소운반
|
||||
끝표지: 제6장 생육보조
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_4
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
## 5-34. 묘목 소운반
|
||||
|
||||
(1,000본당)
|
||||
|
||||
| 구분 | 소요인력 | 인력구분 |
|
||||
|---|---|---|
|
||||
| 소묘 | 0.15 | 보통인부 |
|
||||
| 중묘 | 0.20 | |
|
||||
| 대묘 | 3.40 | |
|
||||
<!-- 병합: 3열 1~3행 「보통인부」 -->
|
||||
|
||||
[주]
|
||||
① 묘목 소운반이란, 묘목을 가식장 또는 차량에서 식재지점까지 운반하는 것을 말한다.
|
||||
② 대묘 중 용기묘 대묘의 경우 본 품의 50%를 계상한다.
|
||||
③ 묘목의 대운반비는 2개 이상의 견적을 받아 적용하거나, 다음 자료를 참고하여 소운반비와 별도로 산출한다.
|
||||
|
||||
<묘목의 규격별, 차량별 적재량 : ((사)한국양묘협회 제공자료)>
|
||||
|
||||
| 규격 | 수종 및 묘령(형태) | 적재량 | | 비고 |
|
||||
|---|---|---|---|---|
|
||||
| | | 2.5톤 차량 | 5.0톤 차량 | |
|
||||
| 소묘 | 소나무 1-1(노지묘) | 110곤포 | 200곤포 | 500본/곤포 |
|
||||
| 중묘 | 편백 2-0(용기묘) | 150박스 | 300박스 | 100본/박스 |
|
||||
| 대묘 | 해송 1-1-2(노지묘) | 1,800본 | 3,000본 | 분뜨기묘 1본 |
|
||||
| | 소 2-2 (용기묘) | 300곤포 | 800곤포 | 10본/곤포 |
|
||||
| ※ 묘목 1,000본당 무게<br>- 소나무 노지묘 1-1 (45㎏)<br>- 낙엽송, 편백 용기묘 2-0 (200㎏)<br>- 상수리 용기묘 1-0 (200㎏) | | | | |
|
||||
<!-- 머리: 몸 1행까지 머리 · 병합: 머리 3~4열 「적재량」 · 1·2·5열 머리~몸 1행 · 1열 4~5행 「대묘」 · 6행 1~5열 「※ 묘목 1,000본당 무게 …」(표 테두리 안 마지막 줄) -->
|
||||
|
||||
> 【대운반비 단가산출 예시】
|
||||
> (1) 10ha에 식재할 소묘 30,000본을 100㎞ 운반할 경우의 대운반비 산출
|
||||
> ∙ 화물차별 묘목 적재량은 양묘협회에서 제공해 준 자료 활용. 차량 2.5톤에 적재할 수 있는 소묘 수량은 55,000본.
|
||||
> ∙ 화물차 운송비는 거리별 구역화물 고시금액(2009년) 적용, 2.5톤 화물차 100㎞ 운송비는 91,810원.
|
||||
> ∙ 소묘 1,000본당 대운반비를 산출하면
|
||||
> \- 차량 소요대수는 30,000본/55,000본 = 0.55대
|
||||
> \- 소묘 30,000본 운송비 : 91,810원 × 0.55대 = 50,495원.
|
||||
> \- 소묘 1,000본당 대운반비 : 50,495원×(1,000본/30,000본) = 1,683원
|
||||
> (2) 20ha에 식재할 용기묘(편백2-0) 60,000본을 80㎞ 운반할 경우의 대운반비 산출
|
||||
> ∙ 차량 2.5톤에 적재할 수 있는 용기묘 수량은 15,000본. 5.0톤에 적재할 수 있는 용기묘 수량은 30,000본.
|
||||
> ∙ 2.5톤 화물차 80㎞ 운송비는 84,620원. 5.0톤 화물차 80㎞ 운송비는 115,890원.
|
||||
> ∙ 용기묘 운반차량은 5.0톤으로 결정
|
||||
> ∙ 용기묘 1,000본당 대운반비를 산출하면;
|
||||
> \- 차량 소요대수는 60,000본/30,000본 = 2.0대(5톤 화물차)
|
||||
> \- 용기묘 60,000본 운송비 : 115,890원 × 2대 = 231,780원.
|
||||
> \- 용기묘 1,000본당 대운반비 : 231,780원×(1,000본/60,000본) = 3,863원
|
||||
> (3) 10ha에 식재할 대묘(해송 1-1-2 분뜨기묘) 15,000본을 80㎞ 운반할 경우의 대운반비 산출
|
||||
> ∙ 차량 2.5톤에 적재할 수 있는 대묘 수량은 1,800본. 5.0톤에 적재할 수 있는 대묘 수량은 3,000본.
|
||||
> ∙ 2.5톤 화물차 80㎞ 운송비는 84,620원. 5.0톤 화물차 80㎞ 운송비는 115,890원.
|
||||
> ∙ 대묘 운반차량은 5.0톤으로 할 경우, 대묘 1,000본당 대운반비를 산출하면
|
||||
> \- 차량 소요대수는 15,000본/3,000본 = 5.0대(5톤 화물차)
|
||||
> \- 대묘 15,000본 운송비 : 115,890원 × 5대 = 579,450원.
|
||||
> \- 대묘 1,000본당 대운반비 : 579,450원×(1,000본/15,000본) = 38,630원
|
||||
> ∙ 대묘 운반차량은 2.5톤으로 할 경우, 대묘 1,000본당 대운반비를 산출하면
|
||||
> \- 차량 소요대수는 15,000본/1,800본 = 8.33대(2.5톤 화물차)
|
||||
> \- 대묘 15,000본 운송비 : 84,620원 × 8.33대 = 704,885원.
|
||||
> \- 대묘 1,000본당 대운반비 : 704,885원×(1,000본/15,000본) = 46,992원
|
||||
> ※ 상기와 같은 대묘 운반은 5.0톤으로 적용함이 타당함.
|
||||
> (4) 묘목본수는 본으로 하고 차량대수는 소수점 셋째자리에서 반올림하여 둘째자리까지 계산한다.
|
||||
> (5) 운송비 및 대운반비 등 비용은 1원단위 미만 절사한다.
|
||||
|
||||
@@ -5,10 +5,12 @@ PDF쪽: 97
|
||||
인쇄쪽: 85
|
||||
시작표지: 제7장 집재 및 운재
|
||||
끝표지: 7-1. 인력집재
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_3
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
# 제7장 집재 및 운재
|
||||
|
||||
@@ -5,10 +5,79 @@ PDF쪽: 97~98
|
||||
인쇄쪽: 85~86
|
||||
시작표지: 7-1. 인력집재
|
||||
끝표지: 7-2. 수라집재
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_3
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
## 7-1. 인력집재
|
||||
|
||||
### 7-1-1. 수확
|
||||
|
||||
#### 1. 천연림 모두베기
|
||||
|
||||
| 구분 | 어려움<br>(15˚ 미만) | 중<br>(30˚ 초과) | 쉬움<br>(15˚~30˚) |
|
||||
|---|---|---|---|
|
||||
| | ㎥ | ㎥ | ㎥ |
|
||||
| 100m이하 | 1.9 | 3.1 | 4.3 |
|
||||
| 200m이하 | 1.4 | 2.3 | 3.3 |
|
||||
| 300m이하 | 1.1 | 1.9 | 2.5 |
|
||||
|
||||
[주]
|
||||
ha당 원목재적 150㎥까지의 경우이며, 초과 시 “인공림 모두베기”를 적용한다.
|
||||
|
||||
#### 2. 인공림 모두베기
|
||||
|
||||
| 경사 | 어려움(15˚ 미만) | | 중(30˚ 초과) | | 쉬움(15˚ ~30˚) | |
|
||||
|---|---|---|---|---|---|---|
|
||||
| ha당원목재적<br>평균거리 | 50㎥미만 | 50㎥이상 | 50㎥미만 | 50㎥이상 | 50㎥미만 | 50㎥이상 |
|
||||
| | ㎥ | ㎥ | ㎥ | ㎥ | ㎥ | ㎥ |
|
||||
| 100m이하 | 1.8 | 2.0 | 2.9 | 3.3 | 4.1 | 4.6 |
|
||||
| 200m이하 | 1.3 | 1.5 | 2.1 | 2.4 | 3.0 | 3.4 |
|
||||
| 300m이하 | 1.0 | 1.1 | 1.8 | 1.9 | 2.4 | 2.7 |
|
||||
<!-- 머리: 몸 1행까지 머리 · 병합: 머리 2~3열 「어려움(15˚ 미만)」 · 머리 4~5열 「중(30˚ 초과)」 · 머리 6~7열 「쉬움(15˚ ~30˚)」 · 빗금 칸: 1열 머리 「경사」 · 1열 몸 1행 「ha당원목재적」 / 「평균거리」 -->
|
||||
|
||||
#### 3. 골라베기
|
||||
|
||||
| 경사 | 어려움(15˚미만) | | | 중(30˚초과) | | | 쉬움(15˚~30˚) | | |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| ha당원목재적<br>평균거리 | 15㎥<br>이하 | 15∼<br>30㎥ | 30㎥<br>초과 | 15㎥<br>미만 | 15∼<br>30㎥ | 30㎥<br>초과 | 15㎥<br>이하 | 15∼<br>30㎥ | 30㎥<br>초과 |
|
||||
| | ㎥ | ㎥ | ㎥ | ㎥ | ㎥ | ㎥ | ㎥ | ㎥ | ㎥ |
|
||||
| 100m이하 | 1.3 | 1.5 | 1.8 | 2.1 | 2.5 | 2.9 | 2.9 | 3.5 | 4.1 |
|
||||
| 200m이하 | 1.0 | 1.1 | 1.8 | 1.5 | 1.8 | 2.1 | 2.1 | 2.5 | 2.9 |
|
||||
| 300m이하 | 0.8 | 0.9 | 1.0 | 1.3 | 1.5 | 1.7 | 1.7 | 2.1 | 2.3 |
|
||||
<!-- 머리: 몸 1행까지 머리 · 병합: 머리 2~4열 「어려움(15˚미만)」 · 머리 5~7열 「중(30˚초과)」 · 머리 8~10열 「쉬움(15˚~30˚)」 · 빗금 칸: 1열 머리 「경사」 · 1열 몸 1행 「ha당원목재적」 / 「평균거리」 -->
|
||||
<!-- 원문 그대로: 몸 1행 5열 「15㎥ 미만」(다른 두 무리는 「15㎥ 이하」) -->
|
||||
|
||||
[주]
|
||||
경사도는 1개 기번 전임지의 평균경사도다.
|
||||
|
||||
### 7-1-2. 숲가꾸기, 병해충방제
|
||||
|
||||
(㎥/1인, 1일)
|
||||
|
||||
| 집재<br>거리 | 구분 | 간벌재의 직경(cm) | | | | | | | | 적용인부 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| | | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22이상 | |
|
||||
| 10m이하 | 숲가꾸기 | 2.02 | 2.84 | 3.51 | 4.08 | 4.82 | 5.81 | 6.95 | | 보통인부 |
|
||||
| | 병해충방제 | 2.65 | 4.00 | 5.00 | 5.70 | 6.65 | 8.00 | 9.50 | 11.00 | |
|
||||
| 20m이하 | 숲가꾸기 | 1.40 | 1.89 | 2.32 | 2.76 | 3.36 | 3.88 | 4.53 | | |
|
||||
| | 병해충방제 | 1.75 | 2.50 | 3.10 | 3.65 | 4.45 | 5.00 | 5.70 | 6.35 | |
|
||||
| 30m이하 | 숲가꾸기 | 1.10 | 1.49 | 1.84 | 2.27 | 2.71 | 3.06 | 3.51 | | |
|
||||
| | 병해충방제 | 1.40 | 2.00 | 2.50 | 3.10 | 3.65 | 4.00 | 4.45 | 5.05 | |
|
||||
| 40m이하 | 숲가꾸기 | 0.88 | 1.18 | 1.45 | 1.72 | 2.09 | 2.36 | 2.80 | | |
|
||||
| | 병해충방제 | 1.15 | 1.60 | 2.00 | 2.35 | 2.85 | 3.10 | 3.65 | 4.25 | |
|
||||
| 50m이하 | 숲가꾸기 | 0.74 | 0.99 | 1.22 | 1.47 | 1.76 | 1.94 | 2.30 | | |
|
||||
| | 병해충방제 | 1.00 | 1.40 | 1.75 | 2.10 | 2.50 | 2.65 | 3.10 | 3.70 | |
|
||||
<!-- 머리: 몸 1행까지 머리 · 병합: 1열 머리~몸 1행 「집재<br>거리」 · 2열 머리~몸 1행 「구분」 · 머리 3~10열 「간벌재의 직경(cm)」 · 11열 머리~몸 1행 「적용인부」 · 1열 2~3행 「10m이하」 · 1열 4~5행 「20m이하」 · 1열 6~7행 「30m이하」 · 1열 8~9행 「40m이하」 · 1열 10~11행 「50m이하」 · 11열 2~11행 「보통인부」 -->
|
||||
|
||||
[주]
|
||||
① 본 품은 인력으로 원목(수간부)을 집재장까지 집재하는데 소요되는 품이며, 숲가꾸기는 간벌재 평균직경이 20㎝ 이하인 경우만 적용한다.
|
||||
② 집재거리는 평균 집재거리(집재구간 최대거리의 1/2)를 적용한다.
|
||||
③ 간벌재의 직경은 표준지조사 결과의 제거목 평균직경을 적용한다.
|
||||
④ 산불예방 숲가꾸기 등 재해우려구역의 가지와 자투리나무 등 간벌목을 전량 수집할 경우는 제거목의 입목재적과 가지재적을 합산한 수량을 적용. 이 경우도 간벌재의 직경은 제거목 평균직경을 적용한다.
|
||||
③ 할인․할증은 경사도(1-4-5), 장애물의 정도(1-4-8)를 반영한다. 다만 소나무재선충병방제 작업에만 장애물의 정도(1-4-8), 집재방향(1-4-14)을 반영한다.
|
||||
<!-- 원문 그대로: ④ 다음 번호가 ③ -->
|
||||
|
||||
@@ -5,10 +5,32 @@ PDF쪽: 98
|
||||
인쇄쪽: 86
|
||||
시작표지: 7-2. 수라집재
|
||||
끝표지: 7-3. 아키야윈치(임업용 윈치) 집재
|
||||
상태: 대기
|
||||
작성:
|
||||
상태: 검증대기
|
||||
작성: sub_laptop_3
|
||||
검증:
|
||||
원천: 글자층
|
||||
기계검사:
|
||||
기계검사: 통과 · ①수 결손 0 허구 0 · ②글자 빠짐 0 더함 0
|
||||
확정지문:
|
||||
---
|
||||
|
||||
## 7-2. 수라집재
|
||||
|
||||
(단위 : ㎥)
|
||||
|
||||
| 집재거리 | ha당 집재재적 | | | | | 소요인력 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| | 20이하㎥ | 21~30㎥ | 31~40㎥ | 41~50㎥ | 50초과㎥ | |
|
||||
| 0~100m | 1.94 | 2.20 | 2.39 | 2.56 | 2.70 | 특별인부 |
|
||||
| 0~150m | 1.64 | 1.91 | 2.12 | 2.29 | 2.45 | |
|
||||
| 0~200m | 1.42 | 1.68 | 1.89 | 2.07 | 2.23 | |
|
||||
| 0~250m | 1.24 | 1.50 | 1.70 | 1.88 | 2.04 | |
|
||||
<!-- 머리: 몸 1행까지 머리 · 병합: 1열 머리~몸 1행 「집재거리」 · 머리 2~6열 「ha당 집재재적」 · 7열 머리~몸 1행 「소요인력」 · 7열 2~5행 「특별인부」 -->
|
||||
|
||||
[주]
|
||||
① 본 품은 설치․해체공정이 포함되었으며 산지경사가 20~65%일 경우 적용 가능하다.
|
||||
② 산림병해충 및 소나무재선충병 방제 산물의 수집량은 34%를 가산하여 산출한다.
|
||||
|
||||
> 【수집량 산출 예시】집재거리 200m이하, ha당 집재재적 21~30㎥인 경우 수집량 1.68㎥+(1.68㎥×34%)=2.25㎥
|
||||
|
||||
③ 할인․할증은 경사도(1-4-5), 횡단운반거리(1-4-15)를 반영한다
|
||||
<!-- 원문 그대로: 끝 마침표 없음 -->
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Z01 기초단가 저장소 가짜 — DB 없이 API 시험(PLAN 1-2 · 2026-09-18).
|
||||
|
||||
`install(monkeypatch)` 가 `Z01_MasterData_Repository_BasePrices` 의 함수 다섯을 바꿔 끼움.
|
||||
주입 = 파일 원본 줄(`base_rows`) 그대로 · ⚠ '@' 칸(`@axis`·`@source`)은 **빼고** 담음 —
|
||||
주입기가 안쪽 칸을 빼도 서버가 서는지 함께 잼. 판번호 규칙(다르면 409 · 쓰면 +1)은 진짜와 같게.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
|
||||
from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo
|
||||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
def __init__(self) -> None:
|
||||
self.tables: dict[str, list[dict[str, Any]]] = {}
|
||||
self.revisions: dict[str, int] = {}
|
||||
self.saved_by: list[Any] = []
|
||||
|
||||
def table(self, kind: str) -> list[dict[str, Any]]:
|
||||
if kind not in self.tables:
|
||||
self.tables[kind] = []
|
||||
for row in base_prices.base_rows(kind):
|
||||
data = {k: v for k, v in row.items() if not k.startswith("@")}
|
||||
self.tables[kind].append(
|
||||
{"row_key": row["@id"], "data": data, "seed": dict(data), "deleted": False}
|
||||
)
|
||||
return self.tables[kind]
|
||||
|
||||
def by_key(self, kind: str) -> dict[str, dict[str, Any]]:
|
||||
return {r["row_key"]: r for r in self.table(kind)}
|
||||
|
||||
async def load(self, kind: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"row_key": r["row_key"], "data": dict(r["data"]), "seed": r["seed"]}
|
||||
for r in self.table(kind)
|
||||
if not r["deleted"]
|
||||
]
|
||||
|
||||
async def revision(self, kind: str) -> int:
|
||||
return self.revisions.get(kind, 0)
|
||||
|
||||
async def counts(self) -> dict[str, int]:
|
||||
return {
|
||||
kind: sum(not r["deleted"] for r in self.table(kind))
|
||||
for kind in base_prices.KINDS
|
||||
if kind not in work_items.KINDS
|
||||
}
|
||||
|
||||
async def save(self, kind, base_revision, changed, added, deleted, by) -> int:
|
||||
current = self.revisions.get(kind, 0)
|
||||
if current != base_revision:
|
||||
raise repo.RevisionConflict(current)
|
||||
rows = self.by_key(kind)
|
||||
for key, data in changed.items():
|
||||
if not rows[key]["deleted"]:
|
||||
rows[key]["data"] = dict(data)
|
||||
for key, data in added.items():
|
||||
self.table(kind).append(
|
||||
{"row_key": key, "data": dict(data), "seed": None, "deleted": False}
|
||||
)
|
||||
for key in deleted:
|
||||
rows[key]["deleted"] = True
|
||||
self.revisions[kind] = current + 1
|
||||
self.saved_by.append(by)
|
||||
return current + 1
|
||||
|
||||
async def reset(self, kind, row_keys, by) -> int:
|
||||
rows = self.by_key(kind)
|
||||
missing = sorted(set(row_keys) - set(rows))
|
||||
if missing:
|
||||
raise repo.UnknownRows(missing)
|
||||
for key in row_keys:
|
||||
row = rows[key]
|
||||
if row["seed"] is None:
|
||||
row["deleted"] = True
|
||||
else:
|
||||
row["data"], row["deleted"] = dict(row["seed"]), False
|
||||
self.revisions[kind] = self.revisions.get(kind, 0) + 1
|
||||
return self.revisions[kind]
|
||||
|
||||
|
||||
def install(monkeypatch: pytest.MonkeyPatch) -> FakeRepo:
|
||||
fake = FakeRepo()
|
||||
for name in ("load", "revision", "counts", "save", "reset"):
|
||||
monkeypatch.setattr(repo, name, getattr(fake, name))
|
||||
return fake
|
||||
@@ -10,12 +10,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from helper_z01_fake_repo import install
|
||||
|
||||
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
|
||||
|
||||
@@ -30,13 +28,11 @@ def labels() -> dict:
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def shown() -> dict[str, list[dict]]:
|
||||
"""서버가 실제로 내보내는 열 — 이름표 파일이 아니라 **응답**을 잰다(DB 는 파일을 주입한 가짜)."""
|
||||
"""서버가 실제로 내보내는 열 — 이름표 파일이 아니라 **응답**을 잰다."""
|
||||
out = {}
|
||||
with pytest.MonkeyPatch.context() as patch:
|
||||
install(patch)
|
||||
for kind in base_prices.KINDS:
|
||||
columns = base_prices._columns(kind, asyncio.run(base_prices.rows(kind)))
|
||||
out[kind] = [base_prices.column_meta(kind, c) for c in columns]
|
||||
for kind in base_prices.KINDS:
|
||||
columns = base_prices._columns(kind, base_prices.rows(kind))
|
||||
out[kind] = [base_prices.column_meta(kind, c) for c in columns]
|
||||
return out
|
||||
|
||||
|
||||
@@ -103,8 +99,8 @@ def test_요율_축이_한_칸으로_안_합쳐졌다(shown):
|
||||
|
||||
|
||||
def test_요율_줄_열쇠에_축이_살아_있다():
|
||||
"""`@id` 가 `{변수}[/{목록}/{축=값;…}]` 꼴 — 축이 죽으면 주입 열쇠(row_key)가 흔들린다."""
|
||||
rows = base_prices.base_rows("rate")
|
||||
"""`@id` 가 `{변수}[/{목록}/{축=값;…}]` 꼴 — 축이 죽으면 고친 값이 주인을 잃는다."""
|
||||
rows = base_prices.rows("rate")
|
||||
multi = [r for r in rows if "/" in str(r["@id"])]
|
||||
assert multi, "축이 든 줄 열쇠가 하나도 없다"
|
||||
assert any("=" in str(r["@id"]) for r in multi)
|
||||
@@ -113,7 +109,7 @@ def test_요율_줄_열쇠에_축이_살아_있다():
|
||||
|
||||
def test_기계_시간당_단가는_계산값으로_잠긴다(shown):
|
||||
"""밑값만 고칠 수 있다 — 판정은 서버 `spec()` 한 곳(이름표가 따로 안 정한다)."""
|
||||
meta = base_prices.table("machine", base_prices.base_rows("machine"), page=1, size=1)
|
||||
meta = base_prices.table("machine", page=1, size=1)
|
||||
assert set(meta["formula"]) == {
|
||||
"hourly_loss_krw",
|
||||
"hourly_fuel_krw",
|
||||
@@ -159,15 +155,15 @@ def test_단위가_붙어야_할_열에_단위가_있다(shown):
|
||||
|
||||
|
||||
def test_줄_열쇠가_서버_축과_같다(labels):
|
||||
"""⭐ 줄을 붙드는 끈 — 파일 원본이 줄마다 `@axis` 로 내는 축이 곧 열쇠(주입 row_key)다.
|
||||
"""⭐ 덮개가 줄을 붙드는 끈 — 서버가 줄마다 `@axis` 로 내는 축이 곧 열쇠다.
|
||||
|
||||
이름표가 축을 빠뜨리면 새 판 주입 때 같은 줄을 못 알아본다.
|
||||
이름표가 축을 빠뜨리면 고친 값이 갱신 뒤 주인을 잃는다.
|
||||
"""
|
||||
declared = {r["table"]: set(r["key"]) for r in labels["row_keys"]}
|
||||
bad = []
|
||||
for kind in base_prices.KINDS:
|
||||
axes = set()
|
||||
for row in base_prices.base_rows(kind):
|
||||
for row in base_prices.rows(kind):
|
||||
axes |= set(row.get("@axis") or ())
|
||||
if not axes: # 다섯 표는 `@id` 한 칸이라 축을 안 낸다
|
||||
continue
|
||||
@@ -178,13 +174,13 @@ def test_줄_열쇠가_서버_축과_같다(labels):
|
||||
|
||||
|
||||
def test_구간_이름은_원문_표기_그대로다():
|
||||
"""⚠ `~1.5`·`7이상` 을 예쁘게 고치면 줄 열쇠가 바뀌어 주입한 줄을 못 알아본다."""
|
||||
"""⚠ `~1.5`·`7이상` 을 예쁘게 고치면 줄 열쇠가 바뀌어 덮개가 주인을 잃는다."""
|
||||
for kind in ("masonry_slope", "masonry_back_length"):
|
||||
brackets = {str(r.get("height_bracket")) for r in base_prices.base_rows(kind)}
|
||||
brackets = {str(r.get("height_bracket")) for r in base_prices.rows(kind)}
|
||||
assert brackets == {"~1.5", "~3", "~5", "~7", "7이상"}, (kind, sorted(brackets))
|
||||
classes = {
|
||||
str(r.get("class"))
|
||||
for r in base_prices.base_rows("masonry_class")
|
||||
for r in base_prices.rows("masonry_class")
|
||||
if r.get("table") == "boulder_diameter"
|
||||
}
|
||||
assert classes == {"40~60", "60~80", "80~100"}, sorted(classes)
|
||||
@@ -202,13 +198,13 @@ def test_축_칸은_못_고친다():
|
||||
"""축을 고치면 줄이 다른 줄이 된다 — 서버가 잠가야 한다."""
|
||||
bad = []
|
||||
for kind in base_prices.KINDS:
|
||||
rows = base_prices.base_rows(kind)
|
||||
rows = base_prices.rows(kind)
|
||||
axes = set()
|
||||
for row in rows:
|
||||
axes |= set(row.get("@axis") or ())
|
||||
if not axes:
|
||||
continue
|
||||
meta = base_prices.table(kind, rows, page=1, size=1)
|
||||
meta = base_prices.table(kind, page=1, size=1)
|
||||
for axis in axes:
|
||||
if axis in meta["editable"] and f"{kind}/{axis}" not in AXIS_ALSO_VALUE:
|
||||
bad.append(f"{kind}/{axis} 가 고칠 수 있는 칸으로 섰다")
|
||||
@@ -232,11 +228,10 @@ def test_기초단가_상자_이름이_이름표에_있다(labels):
|
||||
assert named["pumsem_basis"]["name_ko"] == "품셈 기준"
|
||||
|
||||
|
||||
def test_상자마다_kind_가_있다(monkeypatch):
|
||||
def test_상자마다_kind_가_있다():
|
||||
"""빈 상자가 서면 화면에 이름만 뜨고 아무것도 안 열린다."""
|
||||
install(monkeypatch)
|
||||
counted: dict[str, int] = {}
|
||||
for entry in asyncio.run(base_prices.kinds()):
|
||||
for entry in base_prices.kinds():
|
||||
counted[entry["group"]] = counted.get(entry["group"], 0) + 1
|
||||
assert entry["label"] != entry["kind"], f"{entry['kind']} 이름이 영문 그대로다"
|
||||
assert entry["rows"] > 0, entry["kind"]
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
"""Z01 기초단가 다섯(노임·기계·자재·유가·요율) — DB 정본 + [저장] 일괄 저장(2026-09-18 PLAN 1-0 · 1-2).
|
||||
"""Z01 기초단가 다섯(노임·기계·자재·유가·요율) — 고칠 수 있게 · 2026-09-15 브레인 계약(사용자 지시).
|
||||
|
||||
틀: 정본 = DB `master_base_price`(data = 지금 값 · seed = 초기값) · 원본 파일 `resources/data_*` 는 **주입 재료로 읽기만** ·
|
||||
[저장] 한 번 = {base_revision, edits, added, deleted} → 판이 다르면 409 · 하나라도 틀리면 아무것도 안 씀 ·
|
||||
줄마다 changed_columns(초기값과 다른 칸)·is_added(관리자 추가 줄) · [초기값으로] = data := seed(추가 줄은 지움).
|
||||
틀(브레인 못박음): 원본 `resources/data_*` 는 **읽기만** · 고친 값은 덮개
|
||||
`resources/data_master_override/{kind}.json`(주기가 달라 kind 마다 한 파일)에만 · 읽을 때 원본 위에 얹음 ·
|
||||
덮개 줄마다 original(원래 값)·source(원본 파일) — 원본이 바뀌면 「원본 바뀜」 · 줄이 사라지면 「주인 없음」(버리지 않음) ·
|
||||
기계 시간당 단가는 계산값 — editable 에서 빼고 왜 못 고치나(locked) · 밑값(취득가·손료계수·노임·유가)을 고치면 따라 바뀜.
|
||||
DB 는 `helper_z01_fake_repo` 가짜로 — 저장소 SQL 흐름(잠금·되돌림)은 맨 아래 가짜 커넥션으로 잼.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from helper_z01_fake_repo import FakeRepo, install
|
||||
|
||||
from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo
|
||||
from Z01_MasterData import Z01_MasterData_Overrides as overrides
|
||||
from Z01_MasterData import Z01_MasterData_Router as router_module
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -40,12 +36,14 @@ SOURCES = [
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake(monkeypatch: pytest.MonkeyPatch) -> FakeRepo:
|
||||
return install(monkeypatch)
|
||||
def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
folder = tmp_path / "data_master_override"
|
||||
monkeypatch.setattr(overrides, "OVERRIDE_DIR", folder)
|
||||
return folder
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(fake: FakeRepo) -> TestClient:
|
||||
def client(store: Path) -> TestClient:
|
||||
from common_util.common_util_auth import verify_session
|
||||
|
||||
app = FastAPI()
|
||||
@@ -65,20 +63,8 @@ def _row(client: TestClient, kind: str, row_id: str) -> dict:
|
||||
return next(r for r in rows if r["@id"] == row_id)
|
||||
|
||||
|
||||
def _save(client: TestClient, kind: str, edits=(), added=(), deleted=(), base=None):
|
||||
if base is None:
|
||||
base = _get(client, kind, size=1)["revision"]
|
||||
body = {
|
||||
"base_revision": base,
|
||||
"edits": [{"row_key": k, "column": c, "value": v} for k, c, v in edits],
|
||||
"added": [{"data": d} for d in added],
|
||||
"deleted": list(deleted),
|
||||
}
|
||||
return client.put(f"/api/master-data/base-prices/{kind}", json=body)
|
||||
|
||||
|
||||
def _edit(client: TestClient, kind: str, row_id: str, column: str, value):
|
||||
return _save(client, kind, edits=[(row_id, column, value)])
|
||||
def _put(client: TestClient, kind: str, row_id: str, values: dict):
|
||||
return client.put(f"/api/master-data/base-prices/{kind}/{row_id}", json={"values": values})
|
||||
|
||||
|
||||
def _sha(path: Path) -> str:
|
||||
@@ -95,15 +81,13 @@ def test_다섯_표는_제_열쇠와_고칠_칸을_냄(client: TestClient) -> No
|
||||
for kind, (total, editable) in expected.items():
|
||||
table = _get(client, kind, size=500)
|
||||
assert table["total"] == total and table["editable"] == editable, kind
|
||||
assert table["status"] == "success" and table["revision"] == 0, kind
|
||||
ids = [r["@id"] for r in _get(client, kind, size=500)["rows"]]
|
||||
assert len(ids) == len(set(ids)), kind
|
||||
# 안 고친 줄 — 고친 칸 없음 · 추가 줄 아님 · 안쪽 칸(@data 따위)은 안 나감
|
||||
assert all(r["changed_columns"] == [] and r["is_added"] is False for r in table["rows"])
|
||||
assert not {k for r in table["rows"] for k in r if k in ("@data", "@added", "@changed")}
|
||||
assert all("@overrides" not in r for r in table["rows"]), (
|
||||
kind
|
||||
) # 안 고친 줄엔 칸 자체가 없음
|
||||
columns = {c["key"] for c in table["columns"]}
|
||||
assert not {k for k in columns if k.startswith("@")}, kind # 줄 수준 칸은 열 목록 밖
|
||||
assert not columns & {"changed_columns", "is_added"}, kind
|
||||
assert set(table["locked"]) <= columns and not set(table["locked"]) & set(editable), kind
|
||||
assert _row(client, "labor", "labor_const/1001")["daily_wage_krw"] == 215907
|
||||
assert _row(client, "labor", "labor_mfg/1")["occupation_name"] == "CAD설계사(기계)"
|
||||
@@ -132,8 +116,8 @@ def test_기계는_시간당_단가를_계산값으로_잠그고_산출근거를
|
||||
"hourly_total_krw" in row["@formula"]
|
||||
and f"{int(row['hourly_total_krw']):,}" in row["@formula"]["hourly_total_krw"]
|
||||
)
|
||||
res = _edit(client, "machine", "0101-0007", "hourly_total_krw", 1)
|
||||
assert res.status_code == 400 and table["locked"]["hourly_total_krw"] in res.json()["message"]
|
||||
res = _put(client, "machine", "0101-0007", {"hourly_total_krw": 1})
|
||||
assert res.status_code == 400 and table["locked"]["hourly_total_krw"] in res.text
|
||||
|
||||
|
||||
def test_기계_계산값은_B09_시간당_사용료와_같음(client: TestClient) -> None:
|
||||
@@ -162,23 +146,38 @@ def test_기계_계산값은_B09_시간당_사용료와_같음(client: TestClien
|
||||
assert checked >= 5
|
||||
|
||||
|
||||
def test_저장은_DB_에만_쓰고_원본_파일은_안_건드림(client: TestClient, fake: FakeRepo) -> None:
|
||||
def test_고친_값은_덮개에만_쓰고_원본은_안_건드림(client: TestClient, store: Path) -> None:
|
||||
before = {p: _sha(p) for p in SOURCES}
|
||||
res = _save(
|
||||
client,
|
||||
"labor",
|
||||
edits=[("labor_const/1001", "daily_wage_krw", 230000)],
|
||||
base=0,
|
||||
)
|
||||
res = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000})
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json() == {"status": "success", "revision": 1, "added": []}
|
||||
row = _row(client, "labor", "labor_const/1001")
|
||||
assert row["daily_wage_krw"] == 230000 and row["changed_columns"] == ["daily_wage_krw"]
|
||||
stored = fake.by_key("labor")["labor_const/1001"]
|
||||
assert stored["data"]["daily_wage_krw"] == 230000 and stored["seed"]["daily_wage_krw"] == 215907
|
||||
assert fake.saved_by == [42] # 누가 고쳤나
|
||||
assert _get(client, "labor", size=1)["revision"] == 1
|
||||
assert _get(client, "oil", size=1)["revision"] == 0 # 판번호는 표마다
|
||||
row = res.json()
|
||||
assert row["daily_wage_krw"] == 230000
|
||||
assert row["@overrides"] == {
|
||||
"daily_wage_krw": {
|
||||
"original": 215907,
|
||||
"value": 230000,
|
||||
"state": overrides.EDITED,
|
||||
"current": 215907,
|
||||
}
|
||||
}
|
||||
assert (
|
||||
_row(client, "labor", "labor_const/1001")["@overrides"]["daily_wage_krw"]["value"] == 230000
|
||||
)
|
||||
assert (
|
||||
_put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 1900}).status_code
|
||||
== 200
|
||||
)
|
||||
assert sorted(p.name for p in store.iterdir()) == [
|
||||
"labor.json",
|
||||
"oil.json",
|
||||
] # kind 마다 한 파일
|
||||
saved = json.loads((store / "labor.json").read_text(encoding="utf-8"))
|
||||
entry = saved["overrides"]["labor_const/1001"]["daily_wage_krw"]
|
||||
assert (
|
||||
entry["original"] == 215907
|
||||
and entry["source"] == "labor_const_2026-01-01.json"
|
||||
and entry["by"] == 42
|
||||
)
|
||||
assert {p: _sha(p) for p in SOURCES} == before
|
||||
|
||||
|
||||
@@ -186,113 +185,139 @@ def test_밑값을_고치면_기계_계산값이_따라_바뀜(client: TestClien
|
||||
base = _row(client, "machine", "0101-0007")
|
||||
operator = base["operator_occupation_code"]
|
||||
wage = _row(client, "labor", f"labor_const/{operator}")["daily_wage_krw"]
|
||||
assert _edit(
|
||||
client, "labor", f"labor_const/{operator}", "daily_wage_krw", wage + 80000
|
||||
).is_success
|
||||
assert _edit(client, "oil", "national_average/oil_diesel", "price_krw_per_l", 2000).is_success
|
||||
price = base["price_thousand_krw"]
|
||||
assert _edit(client, "machine", "0101-0007", "price_thousand_krw", price * 2).is_success
|
||||
_put(client, "labor", f"labor_const/{operator}", {"daily_wage_krw": wage + 80000})
|
||||
_put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 2000})
|
||||
_put(client, "machine", "0101-0007", {"price_thousand_krw": base["price_thousand_krw"] * 2})
|
||||
after = _row(client, "machine", "0101-0007")
|
||||
assert after["hourly_operator_krw"] > base["hourly_operator_krw"]
|
||||
assert after["hourly_fuel_krw"] > base["hourly_fuel_krw"]
|
||||
assert after["hourly_loss_krw"] == pytest.approx(base["hourly_loss_krw"] * 2)
|
||||
# 노임·유가 고친 칸은 그 표 줄에 서고 기계 줄은 계산만 바뀜
|
||||
assert after["changed_columns"] == ["price_thousand_krw"]
|
||||
price = base["price_thousand_krw"]
|
||||
assert after["@overrides"] == {
|
||||
"price_thousand_krw": {
|
||||
"original": price,
|
||||
"value": price * 2,
|
||||
"state": overrides.EDITED,
|
||||
"current": price,
|
||||
}
|
||||
} # 노임·유가 덮개는 그 표 줄에 서고 기계 줄은 계산만 바뀜
|
||||
|
||||
|
||||
def test_계산_칸은_DB_에_안_씀(client: TestClient, fake: FakeRepo) -> None:
|
||||
"""기계 줄을 고쳐도 저장 값은 입력 칸만 — 계산 칸이 굳어 들어가면 노임을 고쳐도 안 따라옴."""
|
||||
_edit(client, "machine", "0101-0007", "loss_coefficient_per_hour", 0.001)
|
||||
data = fake.by_key("machine")["0101-0007"]["data"]
|
||||
assert data["loss_coefficient_per_hour"] == 0.001
|
||||
assert not {"hourly_total_krw", "hourly_note", "fuel_price_krw_per_l"} & set(data)
|
||||
def test_null_은_되돌리기_같은_값도_덮개를_안_남김(client: TestClient, store: Path) -> None:
|
||||
_put(client, "material", "10023392", {"price_krw": 30})
|
||||
reverted = _put(client, "material", "10023392", {"price_krw": None}).json()
|
||||
assert reverted["price_krw"] == 22 and "@overrides" not in reverted
|
||||
assert _put(client, "material", "10023392", {"price_krw": 22}).status_code == 200
|
||||
saved = json.loads((store / "material.json").read_text(encoding="utf-8"))
|
||||
assert saved["overrides"] == {}
|
||||
|
||||
|
||||
def test_하나라도_틀리면_아무것도_안_씀(client: TestClient, fake: FakeRepo) -> None:
|
||||
good = ("labor_const/1001", "daily_wage_krw", 230000)
|
||||
for bad, status in (
|
||||
(("labor_const/1002", "occupation_name", "x"), 400), # 잠긴 칸
|
||||
(("labor_const/1002", "daily_wage_krw", -1), 400), # 나쁜 값
|
||||
(("labor_const/0000", "daily_wage_krw", 1), 404), # 없는 줄
|
||||
):
|
||||
res = _save(client, "labor", edits=[good, bad])
|
||||
assert res.status_code == status, (bad, res.text)
|
||||
assert res.json()["status"] == "error"
|
||||
res = _save(client, "labor", edits=[good], deleted=["labor_const/0000"])
|
||||
assert res.status_code == 404
|
||||
assert fake.revisions == {} and fake.saved_by == []
|
||||
assert _row(client, "labor", "labor_const/1001")["daily_wage_krw"] == 215907
|
||||
|
||||
|
||||
def test_낡은_판으로_저장하면_409(
|
||||
client: TestClient, fake: FakeRepo, monkeypatch: pytest.MonkeyPatch
|
||||
def test_원본이_바뀐_덮개는_덮개_값을_쓰고_세게_드러냄_주인_없는_덮개도_남김(
|
||||
client: TestClient, store: Path
|
||||
) -> None:
|
||||
assert _save(
|
||||
client, "labor", edits=[("labor_const/1001", "daily_wage_krw", 1)], base=0
|
||||
).is_success
|
||||
stale = _save(client, "labor", edits=[("labor_const/1002", "daily_wage_krw", 2)], base=0)
|
||||
assert stale.status_code == 409 and "다시 불러와" in stale.json()["message"]
|
||||
assert _row(client, "labor", "labor_const/1002")["changed_columns"] == []
|
||||
# 앞 검사를 지난 뒤 트랜잭션 안에서 판이 갈려도 409(저장소가 잠그고 다시 봄)
|
||||
|
||||
async def old_revision(kind: str) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(repo, "revision", old_revision)
|
||||
raced = _save(client, "labor", edits=[("labor_const/1002", "daily_wage_krw", 2)], base=0)
|
||||
assert raced.status_code == 409 and fake.revisions["labor"] == 1
|
||||
|
||||
|
||||
def test_줄_추가_고치기_지우기(client: TestClient, fake: FakeRepo) -> None:
|
||||
data = {"specification": "ZZ시험 규격, 특수", "unit": "개", "price_krw": 1234}
|
||||
res = _save(client, "material", added=[data])
|
||||
assert res.status_code == 200, res.text
|
||||
(key,) = res.json()["added"]
|
||||
assert key.startswith("added/") and res.json()["revision"] == 1
|
||||
table = _get(client, "material", size=1, page=7000)
|
||||
assert table["total"] == 7000
|
||||
row = table["rows"][0] # 추가 줄은 맨 끝(넣은 차례)
|
||||
assert (row["@id"], row["is_added"], row["changed_columns"]) == (key, True, [])
|
||||
assert row["price_krw"] == 1234 and row["specification"] == "ZZ시험 규격, 특수"
|
||||
assert _get(client, "material", q="ZZ시험")["total"] == 1
|
||||
# 추가 줄은 글자 칸도 고침(사람이 넣은 이름) · 원래 줄의 글자 칸은 못 고침
|
||||
assert _edit(client, "material", key, "specification", "시험 규격, 고침").is_success
|
||||
assert _edit(client, "material", "10023392", "specification", "x").status_code == 400
|
||||
for bad in ({"없는칸": 1}, {"price_krw": "비쌈"}, {"price_krw": -5}, {"unit": {"a": 1}}):
|
||||
assert _save(client, "material", added=[bad]).status_code == 400, bad
|
||||
assert _save(client, "material", added=[{}]).status_code == 422 # 빈 줄
|
||||
gone = _save(client, "material", deleted=[key, "10023392"])
|
||||
assert gone.status_code == 200
|
||||
ids = {r["@id"] for r in _get(client, "material", q="육각볼트", size=500)["rows"]}
|
||||
assert "10023392" not in ids and _get(client, "material", size=1)["total"] == 6998
|
||||
assert fake.by_key("material")["10023392"]["deleted"] is True # 지운 표시만(되살릴 수 있게)
|
||||
|
||||
|
||||
def test_초기값으로_되돌리기(client: TestClient, fake: FakeRepo) -> None:
|
||||
_edit(client, "labor", "labor_const/1001", "daily_wage_krw", 230000)
|
||||
added = _save(client, "labor", added=[{"occupation_name": "시험 직종", "daily_wage_krw": 1}])
|
||||
(key,) = added.json()["added"]
|
||||
_save(client, "labor", deleted=["labor_const/1002"])
|
||||
res = client.post(
|
||||
"/api/master-data/base-prices/labor/reset",
|
||||
json={"row_keys": ["labor_const/1001", key, "labor_const/1002"]},
|
||||
store.mkdir()
|
||||
(store / "labor.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"kind": "labor",
|
||||
"overrides": {
|
||||
"labor_const/1001": {
|
||||
"daily_wage_krw": {
|
||||
"value": 199000,
|
||||
"original": 200000,
|
||||
"source": "labor_const_2025-07-01.json",
|
||||
}
|
||||
},
|
||||
"labor_const/9999": {
|
||||
"daily_wage_krw": {
|
||||
"value": 1,
|
||||
"original": 1,
|
||||
"source": "labor_const_2025-07-01.json",
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert res.status_code == 200 and res.json() == {"status": "success", "revision": 4}
|
||||
back = _row(client, "labor", "labor_const/1001")
|
||||
assert back["daily_wage_krw"] == 215907 and back["changed_columns"] == []
|
||||
assert _row(client, "labor", "labor_const/1002")["daily_wage_krw"] # 지운 줄도 되살림
|
||||
assert _get(client, "labor", size=1)["total"] == 261 # 추가 줄은 지움
|
||||
missing = client.post(
|
||||
"/api/master-data/base-prices/labor/reset", json={"row_keys": ["labor_const/0000"]}
|
||||
# 브레인 ③ — 자동으로 원본을 쓰면 금액이 말없이 바뀜 → 사람이 고친 값 유지 · 갈렸다는 것만 또렷이
|
||||
row = _row(client, "labor", "labor_const/1001")
|
||||
assert row["daily_wage_krw"] == 199000
|
||||
mark = row["@overrides"]["daily_wage_krw"]
|
||||
assert mark == {
|
||||
"original": 200000,
|
||||
"value": 199000,
|
||||
"state": overrides.SOURCE_CHANGED,
|
||||
"current": 215907,
|
||||
}
|
||||
_put(client, "labor", "labor_const/1002", {"daily_wage_krw": 170000})
|
||||
listed = client.get("/api/master-data/overrides").json()
|
||||
assert [
|
||||
(i["row_id"], i["state"]) for i in listed["items"]
|
||||
] == [ # 서버가 정렬 — 원본 바뀜이 맨 위
|
||||
("labor_const/1001", overrides.SOURCE_CHANGED),
|
||||
("labor_const/9999", overrides.ORPHAN),
|
||||
("labor_const/1002", overrides.EDITED),
|
||||
]
|
||||
first = listed["items"][0]
|
||||
assert set(first) == {
|
||||
"kind",
|
||||
"row_id",
|
||||
"row_label",
|
||||
"column",
|
||||
"column_label",
|
||||
"original",
|
||||
"current",
|
||||
"value",
|
||||
"state",
|
||||
"unit",
|
||||
}
|
||||
assert first["row_label"] == "작업반장" and (
|
||||
first["original"],
|
||||
first["current"],
|
||||
first["value"],
|
||||
) == (200000, 215907, 199000)
|
||||
assert listed["items"][1]["current"] is None
|
||||
saved = json.loads((store / "labor.json").read_text(encoding="utf-8"))["overrides"]
|
||||
assert "labor_const/9999" in saved # 다른 줄을 고쳐도 주인 없는 덮개는 안 지움
|
||||
# 같은 값을 다시 넣으면 「지금 원본을 보고 고침」 으로 굳음 · null 이면 새 원본 값으로 받기
|
||||
kept = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 199000}).json()
|
||||
assert kept["@overrides"]["daily_wage_krw"] == {
|
||||
"original": 215907,
|
||||
"value": 199000,
|
||||
"state": overrides.EDITED,
|
||||
"current": 215907,
|
||||
}
|
||||
taken = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": None}).json()
|
||||
assert taken["daily_wage_krw"] == 215907 and "@overrides" not in taken
|
||||
|
||||
|
||||
def test_빈_칸에_새로_넣은_값도_덮개_원본에_값이_생기면_원본_바뀜(
|
||||
client: TestClient, store: Path
|
||||
) -> None:
|
||||
blank = next(r for r in _get(client, "labor", size=500)["rows"] if r["daily_wage_krw"] is None)
|
||||
row = _put(client, "labor", blank["@id"], {"daily_wage_krw": 150000}).json()
|
||||
assert row["@overrides"]["daily_wage_krw"] == {
|
||||
"original": None,
|
||||
"value": 150000,
|
||||
"state": overrides.EDITED,
|
||||
"current": None,
|
||||
}
|
||||
saved = json.loads((store / "labor.json").read_text(encoding="utf-8"))
|
||||
saved["overrides"][blank["@id"]]["daily_wage_krw"]["original"] = (
|
||||
140000 # 옛 판엔 값이 있었다고 치면
|
||||
)
|
||||
(store / "labor.json").write_text(json.dumps(saved), encoding="utf-8")
|
||||
assert (
|
||||
_row(client, "labor", blank["@id"])["@overrides"]["daily_wage_krw"]["state"]
|
||||
== overrides.SOURCE_CHANGED
|
||||
)
|
||||
assert missing.status_code == 404 and "labor_const/0000" in missing.json()["message"]
|
||||
empty = client.post("/api/master-data/base-prices/labor/reset", json={"row_keys": []})
|
||||
assert empty.status_code == 422 # 통째로 되돌리기 막음
|
||||
|
||||
|
||||
def test_요율_표는_법정값_경고를_실음(client: TestClient) -> None:
|
||||
rate = _get(client, "rate", size=1)
|
||||
assert any("법이 정한 값" in line and "고시" in line for line in rate["notice"])
|
||||
assert any("갱신" in line and "주인" in line for line in rate["notice"])
|
||||
for kind in ("labor", "machine", "material", "oil", "rate"): # 브레인 ③ — 문구는 서버가 한 벌로
|
||||
notice = _get(client, kind, size=1)["notice"]
|
||||
assert any("새로 만드는 프로젝트" in line and "이미 만든" in line for line in notice), kind
|
||||
@@ -300,18 +325,16 @@ def test_요율_표는_법정값_경고를_실음(client: TestClient) -> None:
|
||||
|
||||
|
||||
def test_막는_자리(client: TestClient) -> None:
|
||||
assert _save(client, "wage", base=0).status_code == 404
|
||||
assert _save(client, "work_item_forest", base=0).status_code == 400 # 공종은 /work-items 몫
|
||||
locked = _edit(client, "labor", "labor_const/1001", "hours_per_day", 9) # 숫자여도 잠긴 칸
|
||||
assert locked.status_code == 400 and "공표 원문" in locked.json()["message"]
|
||||
assert _put(client, "wage", "x", {"daily_wage_krw": 1}).status_code == 404
|
||||
assert _put(client, "labor", "labor_const/0000", {"daily_wage_krw": 1}).status_code == 404
|
||||
assert _put(client, "labor", "labor_const/1001", {"occupation_name": "x"}).status_code == 400
|
||||
locked = _put(client, "labor", "labor_const/1001", {"hours_per_day": 9}) # 숫자여도 잠긴 칸
|
||||
assert locked.status_code == 400 and "공표 원문" in locked.text
|
||||
for bad in (True, -1, "220000", float("nan")):
|
||||
body = {
|
||||
"base_revision": 0,
|
||||
"edits": [{"row_key": "labor_const/1001", "column": "daily_wage_krw", "value": bad}],
|
||||
}
|
||||
body = json.dumps({"values": {"daily_wage_krw": bad}}).encode()
|
||||
res = client.put(
|
||||
"/api/master-data/base-prices/labor",
|
||||
content=json.dumps(body).encode(),
|
||||
"/api/master-data/base-prices/labor/labor_const/1001",
|
||||
content=body,
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
assert res.status_code in (400, 422), bad
|
||||
@@ -321,20 +344,9 @@ def test_막는_자리(client: TestClient) -> None:
|
||||
if r["@id"].startswith("rate_performance_guarantee_fee/brackets/")
|
||||
)
|
||||
assert (
|
||||
_edit(client, "rate", fee["@id"], "rate_percent", 1).status_code == 400
|
||||
) # 그 줄엔 요율 칸 없음
|
||||
_put(client, "rate", fee["@id"], {"rate_percent": 1}).status_code == 400
|
||||
) # 그 줄엔 요율 칸이 없음
|
||||
assert Decimal(str(_row(client, "rate", "rate_sanjae")["rate_percent"])) == Decimal("3.56")
|
||||
many = [("labor_const/1001", "daily_wage_krw", 1)] * 501
|
||||
assert _save(client, "labor", edits=many).status_code == 422 # 한 번에 500 칸까지
|
||||
extra = client.put(
|
||||
"/api/master-data/base-prices/labor", json={"base_revision": 0, "values": {}}
|
||||
)
|
||||
assert extra.status_code == 422 # 모르는 칸은 거절(옛 모양으로 보내면 조용히 무시 안 함)
|
||||
old = client.put(
|
||||
"/api/master-data/base-prices/labor/labor_const/1001", json={"values": {"x": 1}}
|
||||
)
|
||||
assert old.status_code in (404, 405) # 옛 칸 단위 저장은 없어짐
|
||||
assert client.get("/api/master-data/overrides").status_code == 404
|
||||
|
||||
|
||||
def test_합친_표는_줄마다_판_기준일을_실음(client: TestClient) -> None:
|
||||
@@ -352,6 +364,61 @@ def test_합친_표는_줄마다_판_기준일을_실음(client: TestClient) ->
|
||||
assert no_record["operating_effective_date"] is None and no_record["hourly_total_krw"] is None
|
||||
|
||||
|
||||
def test_주인_없는_덮개는_모아서_한꺼번에_다룸(client: TestClient, store: Path) -> None:
|
||||
"""브레인 ② — 자재·기계는 품목 퇴출이 상시라 갱신 한 번에 여럿이 주인을 잃음."""
|
||||
store.mkdir()
|
||||
gone = {
|
||||
f"9999000{i}": {"price_krw": {"value": 1, "original": 1, "source": "old.json"}}
|
||||
for i in range(3)
|
||||
}
|
||||
(store / "material.json").write_text(
|
||||
json.dumps({"schema_version": "1.0", "kind": "material", "overrides": gone}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_put(client, "material", "10023392", {"price_krw": 30})
|
||||
_put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000})
|
||||
listed = client.get(
|
||||
"/api/master-data/overrides", params={"state": overrides.ORPHAN, "kind": "material"}
|
||||
).json()
|
||||
assert listed["total"] == 3 and {i["state"] for i in listed["items"]} == {overrides.ORPHAN}
|
||||
assert listed["counts"] == {
|
||||
"material": {overrides.ORPHAN: 3, overrides.EDITED: 1},
|
||||
"labor": {overrides.EDITED: 1},
|
||||
}
|
||||
paged = client.get("/api/master-data/overrides", params={"size": 2, "page": 2}).json()
|
||||
assert paged["total"] == 5 and len(paged["items"]) == 2
|
||||
everything = client.get("/api/master-data/overrides").json()[
|
||||
"items"
|
||||
] # labor 가 kind 차례로 앞이어도
|
||||
assert [i["state"] for i in everything] == [overrides.ORPHAN] * 3 + [overrides.EDITED] * 2
|
||||
res = client.post(
|
||||
"/api/master-data/overrides/clear", json={"kind": "material", "state": overrides.ORPHAN}
|
||||
)
|
||||
assert res.status_code == 200 and res.json()["removed"] == 3
|
||||
left = client.get("/api/master-data/overrides").json()
|
||||
assert [(i["kind"], i["state"]) for i in left["items"]] == [ # 같은 state 안은 kind 차례
|
||||
("labor", overrides.EDITED),
|
||||
("material", overrides.EDITED),
|
||||
]
|
||||
assert (
|
||||
client.post("/api/master-data/overrides/clear", json={"kind": "material"}).status_code
|
||||
== 422
|
||||
) # 거름 없이 통째로 못 비움
|
||||
|
||||
|
||||
def test_줄은_있는데_칸이_사라진_덮개도_주인_없음(client: TestClient, store: Path) -> None:
|
||||
rates = _get(client, "rate", size=500)["rows"]
|
||||
fee = next(r for r in rates if r["@id"].startswith("rate_performance_guarantee_fee/brackets/"))
|
||||
store.mkdir()
|
||||
entry = {"rate_percent": {"value": 1, "original": 0.5, "source": "old.json"}}
|
||||
(store / "rate.json").write_text(
|
||||
json.dumps({"overrides": {fee["@id"]: entry}}), encoding="utf-8"
|
||||
)
|
||||
items = client.get("/api/master-data/overrides", params={"kind": "rate"}).json()["items"]
|
||||
assert [(i["row_id"], i["state"]) for i in items] == [(fee["@id"], overrides.ORPHAN)]
|
||||
assert "@overrides" not in _row(client, "rate", fee["@id"]) # 없는 칸을 새로 만들지 않음
|
||||
|
||||
|
||||
SOURCE_KEYS = {
|
||||
"source_id",
|
||||
"name",
|
||||
@@ -409,6 +476,12 @@ def test_우리_판은_출처표_글자가_아니라_파일에서_읽음(
|
||||
assert _get(client, "rate", size=1)["source"] == [] # 출처표가 없으면 빈 목록(모양은 그대로)
|
||||
|
||||
|
||||
def test_모아_보기_항목에_단위(client: TestClient) -> None:
|
||||
_put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000})
|
||||
(item,) = client.get("/api/master-data/overrides").json()["items"]
|
||||
assert item["unit"] == "원" and item["column_label"] == "일 노임"
|
||||
|
||||
|
||||
def test_열_이름은_이름표에서만_옴(client: TestClient) -> None:
|
||||
"""브레인 ② — 이름·판정은 한 곳에만. 합친 표 열 이름은 이름표 `merged_tables` 가 정본."""
|
||||
labels = json.loads(
|
||||
@@ -467,8 +540,8 @@ def test_빈_칸은_어느_쪽으로_세워도_끝(client: TestClient) -> None:
|
||||
assert hourly[0] is not None and hourly[-1] is None # 계산 열도 세워짐
|
||||
|
||||
|
||||
def test_고친_값으로_세움(client: TestClient) -> None:
|
||||
_edit(client, "labor", "labor_const/1003", "daily_wage_krw", 9_000_000)
|
||||
def test_덮개_얹은_값으로_세움(client: TestClient) -> None:
|
||||
_put(client, "labor", "labor_const/1003", {"daily_wage_krw": 9_000_000})
|
||||
top = _get(client, "labor", sort="daily_wage_krw", desc=1, size=1)["rows"][0]
|
||||
assert top["@id"] == "labor_const/1003" and top["daily_wage_krw"] == 9_000_000
|
||||
|
||||
@@ -478,107 +551,54 @@ def test_sort_가_없으면_원본_차례(client: TestClient) -> None:
|
||||
assert _get(client, "machine", size=1)["rows"][0]["@id"] == "0101-0007"
|
||||
|
||||
|
||||
def test_추가한_기계_줄도_셈이_섬(client: TestClient) -> None:
|
||||
"""카탈로그에 없는 기종 — 제원은 줄 값에서 · 운전경비 원단위가 없으니 손료만 서고 합계는 안 냄."""
|
||||
data = {
|
||||
"machine_name": "시험 굴삭기",
|
||||
"price_thousand_krw": 100000,
|
||||
"loss_coefficient_per_hour": 0.0001,
|
||||
}
|
||||
(key,) = _save(client, "machine", added=[data]).json()["added"]
|
||||
row = _get(client, "machine", size=1, page=614)["rows"][0]
|
||||
assert row["@id"] == key and row["hourly_loss_krw"] == pytest.approx(10000)
|
||||
assert row["hourly_total_krw"] is None and row["hourly_note"]
|
||||
assert _save(client, "machine", added=[{"hourly_total_krw": 1}]).status_code == 400 # 계산 칸
|
||||
def test_줄의_뜻이_바뀌면_넷째_낱말(client: TestClient, store: Path) -> None:
|
||||
"""브레인 — 같은 코드에 다른 기준이 붙음(노임 3001~ 문화재→국가유산 · 자재 KS 개정 규격).
|
||||
|
||||
값만 보면 안 잡힘 → 덮개를 쓸 때 **값이 아닌 글자 칸**(이름·규격·단위…)을 함께 적어 두고 견줌.
|
||||
"""
|
||||
saved = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}).json()
|
||||
assert saved["@overrides"]["daily_wage_krw"]["state"] == overrides.EDITED
|
||||
entry = json.loads((store / "labor.json").read_text(encoding="utf-8"))["overrides"]
|
||||
meaning = entry["labor_const/1001"]["daily_wage_krw"]["meaning"]
|
||||
assert meaning["occupation_name"] == "작업반장" # 이름·규격 같은 글자 칸만
|
||||
assert "daily_wage_krw" not in meaning and "hours_per_day" not in meaning # 값 칸은 안 적음
|
||||
assert "effective_date" not in meaning # 판 날짜는 갱신마다 바뀜 — 뜻 바뀜과 구별이 안 됨
|
||||
_put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 1900})
|
||||
oil = json.loads((store / "oil.json").read_text(encoding="utf-8"))["overrides"]
|
||||
assert "date" not in oil["national_average/oil_diesel"]["price_krw_per_l"]["meaning"]
|
||||
entry["labor_const/1001"]["daily_wage_krw"]["meaning"]["occupation_name"] = "옛 직종 이름"
|
||||
(store / "labor.json").write_text(
|
||||
json.dumps({"schema_version": "1.0", "kind": "labor", "overrides": entry}), encoding="utf-8"
|
||||
)
|
||||
mark = _row(client, "labor", "labor_const/1001")["@overrides"]["daily_wage_krw"]
|
||||
assert mark["state"] == overrides.MEANING_CHANGED # 값은 그대로인데 줄의 뜻이 바뀜
|
||||
# 이름 칸이 여럿인 자료가 있어 **서버가 한 줄로 합쳐** 냄(화면이 합치면 판정이 두 벌)
|
||||
assert mark["was"] == "labor_const · 1001 · 옛 직종 이름 · KRW/day · published"
|
||||
assert mark["now"] == "labor_const · 1001 · 작업반장 · KRW/day · published"
|
||||
listed = client.get("/api/master-data/overrides").json()
|
||||
top = listed["items"][0]
|
||||
assert top["state"] == overrides.MEANING_CHANGED # 값 틀림보다 더 세게
|
||||
assert (top["was"], top["now"]) == (mark["was"], mark["now"]) # 모아 보기에도 같은 둘
|
||||
assert overrides.STATE_ORDER[0] == overrides.MEANING_CHANGED
|
||||
assert all(i.get("was") for i in listed["items"] if i["state"] == overrides.MEANING_CHANGED)
|
||||
|
||||
|
||||
# ── 저장소 SQL 흐름 — 가짜 커넥션(DB 없이) ──────────────────────────────
|
||||
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self, db: SimpleNamespace) -> None:
|
||||
self.db = db
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
async def execute(self, sql: str, params=()) -> None:
|
||||
self.db.sql.append(" ".join(sql.split()))
|
||||
|
||||
async def fetchone(self):
|
||||
return (self.db.revision,)
|
||||
|
||||
async def fetchall(self):
|
||||
return [(key,) for key in self.db.keys]
|
||||
|
||||
|
||||
class _Connection:
|
||||
def __init__(self, db: SimpleNamespace) -> None:
|
||||
self.db = db
|
||||
|
||||
def cursor(self, *_):
|
||||
return _Cursor(self.db)
|
||||
|
||||
async def begin(self) -> None:
|
||||
self.db.log.append("begin")
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.db.log.append("commit")
|
||||
|
||||
async def rollback(self) -> None:
|
||||
self.db.log.append("rollback")
|
||||
|
||||
|
||||
class _Pool:
|
||||
def __init__(self, db: SimpleNamespace) -> None:
|
||||
self.db = db
|
||||
|
||||
def acquire(self):
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
return _Connection(self.db)
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
|
||||
def _writes(db: SimpleNamespace) -> list[str]:
|
||||
return [
|
||||
s
|
||||
for s in db.sql
|
||||
if s.startswith(("UPDATE master_base_price", "INSERT INTO master_base_price"))
|
||||
]
|
||||
|
||||
|
||||
def test_저장소는_판을_잠그고_보며_틀리면_되돌림(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = SimpleNamespace(sql=[], log=[], revision=4, keys=[])
|
||||
monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(db))
|
||||
with pytest.raises(repo.RevisionConflict):
|
||||
asyncio.run(repo.save("labor", 3, {"a": {"x": 1}}, {}, ["b"], 7))
|
||||
assert db.log == ["begin", "rollback"] and not _writes(db)
|
||||
db.sql.clear()
|
||||
db.log.clear()
|
||||
assert asyncio.run(repo.save("labor", 4, {"a": {"x": 1}}, {"added/1": {"y": 2}}, ["b"], 7)) == 5
|
||||
assert db.log == ["begin", "commit"]
|
||||
lock = next(i for i, s in enumerate(db.sql) if s.endswith("FOR UPDATE"))
|
||||
bump = next(i for i, s in enumerate(db.sql) if s.startswith("UPDATE master_revision"))
|
||||
writes = [db.sql.index(s) for s in _writes(db)]
|
||||
assert len(writes) == 3 and lock < min(writes) and max(writes) < bump == len(db.sql) - 1
|
||||
assert "SET is_deleted = 1" in _writes(db)[-1] # 지우기는 표시만
|
||||
|
||||
|
||||
def test_저장소_되돌리기는_없는_줄이면_아무것도_안_씀(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = SimpleNamespace(sql=[], log=[], revision=2, keys=["a"])
|
||||
monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(db))
|
||||
with pytest.raises(repo.UnknownRows):
|
||||
asyncio.run(repo.reset("labor", ["a", "없음"], 7))
|
||||
assert db.log == ["begin", "rollback"] and not _writes(db)
|
||||
db.sql.clear()
|
||||
db.log.clear()
|
||||
assert asyncio.run(repo.reset("labor", ["a", "a"], 7)) == 3
|
||||
assert db.log == ["begin", "commit"]
|
||||
assert [s.split(" SET ")[1][:13] for s in _writes(db)] == ["data = seed, ", "is_deleted = "]
|
||||
def test_뜻_칸은_이름표가_가림_옛_덮개는_그대로(client: TestClient, store: Path) -> None:
|
||||
_put(client, "machine", "0101-0007", {"price_thousand_krw": 80000})
|
||||
meaning = json.loads((store / "machine.json").read_text(encoding="utf-8"))["overrides"][
|
||||
"0101-0007"
|
||||
]["price_thousand_krw"]["meaning"]
|
||||
assert meaning["machine_name"] == "불도저(무한궤도)" and meaning["specification"] == "7"
|
||||
assert not [k for k in meaning if k in ("price_thousand_krw", "loss_coefficient_per_hour")]
|
||||
store.mkdir(
|
||||
exist_ok=True
|
||||
) # 뜻 칸이 없던 옛 덮개 — 그대로 edited 로 둠(없는 것을 지어내지 않음)
|
||||
(store / "material.json").write_text(
|
||||
json.dumps(
|
||||
{"overrides": {"10023392": {"price_krw": {"value": 30, "original": 22, "source": "x"}}}}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert (
|
||||
_row(client, "material", "10023392")["@overrides"]["price_krw"]["state"] == overrides.EDITED
|
||||
)
|
||||
|
||||
@@ -19,9 +19,9 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from helper_z01_fake_repo import install
|
||||
|
||||
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
|
||||
from Z01_MasterData import Z01_MasterData_Overrides as overrides
|
||||
from Z01_MasterData import Z01_MasterData_Router as router_module
|
||||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||||
|
||||
@@ -40,10 +40,10 @@ NEW_KINDS = (
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
from common_util.common_util_auth import verify_session
|
||||
|
||||
install(monkeypatch) # DB 가짜 — 파일 원본을 초기값으로 주입한 모양
|
||||
monkeypatch.setattr(overrides, "OVERRIDE_DIR", tmp_path / "data_master_override")
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router)
|
||||
app.dependency_overrides[verify_session] = lambda: {"user_id": 7, "role": "SYSTEM_ADMIN"}
|
||||
@@ -69,13 +69,13 @@ def test_아홉이_기초단가와_같은_길로_섬(client: TestClient) -> None
|
||||
table = _get(client, kind, size=500)
|
||||
assert table["total"] == len(table["rows"]) > 0, kind
|
||||
ids = _ids(table)
|
||||
assert len(ids) == len(set(ids)), kind # ⭐ 열쇠가 겹치면 (kind, row_key) 한 줄이 둘을 덮음
|
||||
assert len(ids) == len(set(ids)), kind # ⭐ 열쇠가 겹치면 덮개가 엉뚱한 줄에 붙음
|
||||
assert set(table["editable"]) <= {c["key"] for c in table["columns"]}, kind
|
||||
assert table["editable"] or kind == "rebar_complexity", kind # 철근은 값이 다 계산·글
|
||||
assert set(table["locked"]) | set(table["editable"]) == set(table["sortable"]), kind
|
||||
axis = {a for r in base_prices.base_rows(kind) for a in r.get("@axis", ())}
|
||||
assert {table["locked"][c] for c in axis if c in table["locked"]} == {
|
||||
"자료 열쇠 — 바꾸면 다른 표가 이 줄을 못 찾음"
|
||||
"자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음"
|
||||
}, kind # 축 칸은 「왜 못 고치나」 가 원문 칸과 달라야 함
|
||||
assert any("새로 만드는 프로젝트" in line for line in table["notice"]), kind
|
||||
assert table["source"] == [], kind # 출처표엔 아직 이 아홉이 없음 — 빈 목록(모양은 그대로)
|
||||
@@ -165,39 +165,35 @@ def test_아홉의_고칠_칸은_값_칸(client: TestClient) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _save(client: TestClient, kind: str, row_key: str, column: str, value):
|
||||
revision = _get(client, kind, size=1)["revision"]
|
||||
return client.put(
|
||||
f"/api/master-data/base-prices/{kind}",
|
||||
json={
|
||||
"base_revision": revision,
|
||||
"edits": [{"row_key": row_key, "column": column, "value": value}],
|
||||
},
|
||||
def test_고치기도_같은_길(client: TestClient, tmp_path: Path) -> None:
|
||||
res = client.put(
|
||||
"/api/master-data/base-prices/stone_kind/wedge_stone_m3_per_m2/깬잡석/25",
|
||||
json={"values": {"m3_per_m2": 0.1}},
|
||||
)
|
||||
|
||||
|
||||
def test_고치기도_같은_길(client: TestClient) -> None:
|
||||
res = _save(client, "stone_kind", "wedge_stone_m3_per_m2/깬잡석/25", "m3_per_m2", 0.1)
|
||||
assert res.status_code == 200, res.text
|
||||
rows = {r["@id"]: r for r in _get(client, "stone_kind", size=500)["rows"]}
|
||||
row = rows["wedge_stone_m3_per_m2/깬잡석/25"]
|
||||
assert row["m3_per_m2"] == 0.1 and row["changed_columns"] == ["m3_per_m2"]
|
||||
assert [k for k, r in rows.items() if r["changed_columns"]] == [
|
||||
"wedge_stone_m3_per_m2/깬잡석/25"
|
||||
]
|
||||
blocked = _save(client, "stone_kind", "wedge_stone_m3_per_m2/깬잡석/25", "stone_kind", "딴 돌")
|
||||
row = res.json()
|
||||
assert row["m3_per_m2"] == 0.1
|
||||
assert row["@overrides"]["m3_per_m2"]["original"] == 0.09
|
||||
assert (tmp_path / "data_master_override" / "stone_kind.json").is_file()
|
||||
listed = client.get("/api/master-data/overrides", params={"kind": "stone_kind"}).json()
|
||||
assert [i["row_id"] for i in listed["items"]] == ["wedge_stone_m3_per_m2/깬잡석/25"]
|
||||
blocked = client.put(
|
||||
"/api/master-data/base-prices/stone_kind/wedge_stone_m3_per_m2/깬잡석/25",
|
||||
json={"values": {"stone_kind": "딴 돌"}},
|
||||
)
|
||||
assert blocked.status_code == 400 and "자료 열쇠" in blocked.text # 축 칸은 못 고침
|
||||
assert not {"@axis", "@source", "@data"} & set(row) # 안쪽 칸은 안 내보냄
|
||||
assert "@axis" not in row and "@source" not in row # 안쪽 칸은 안 내보냄
|
||||
# 같은 칸이 어느 줄에선 값이고 어느 줄에선 축임(사용횟수) — 축인 줄에선 막혀야 함
|
||||
# ⚠ 주입 data 에 `@axis` 가 없어도 막힘(축은 파일 원본에서 읽음 · 가짜 주입은 '@' 칸을 뺌)
|
||||
axis_edit = _save(
|
||||
client, "formwork_reuse", "reuse_ratio_pct/plywood_material_pct/2", "reuse_count", 3
|
||||
axis_edit = client.put(
|
||||
"/api/master-data/base-prices/formwork_reuse/reuse_ratio_pct/plywood_material_pct/2",
|
||||
json={"values": {"reuse_count": 3}},
|
||||
)
|
||||
assert axis_edit.status_code == 400 and "자료 열쇠" in axis_edit.text
|
||||
value_edit = _save(client, "formwork_reuse", "reuse_by_class/복잡한 구조", "reuse_count", 3)
|
||||
assert value_edit.status_code == 200
|
||||
reuse = {r["@id"]: r for r in _get(client, "formwork_reuse", size=500)["rows"]}
|
||||
assert reuse["reuse_by_class/복잡한 구조"]["reuse_count"] == 3
|
||||
value_edit = client.put(
|
||||
"/api/master-data/base-prices/formwork_reuse/reuse_by_class/복잡한 구조",
|
||||
json={"values": {"reuse_count": 3}},
|
||||
)
|
||||
assert value_edit.status_code == 200 and value_edit.json()["reuse_count"] == 3
|
||||
|
||||
|
||||
def test_코드에_박혔던_기계_밑값이_마스터로_나옴(client: TestClient) -> None:
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
"""Z01 고친 것 초안 — [저장] 전까지 sessionStorage 에만 · 판이 다르면 버림 (PLAN 1-0 · 1-4 · 2026-09-18).
|
||||
|
||||
① 기초단가 — 서버 값과 같아지면 고침에서 빠짐 · 지울 줄의 칸 고침은 안 보냄 · 새 줄은 data 만 보냄
|
||||
② 같은 판이면 탭을 다시 열어도 초안이 살아남 · 판이 다르면 비고 저장소에서도 지움
|
||||
③ 공종 — 보낼 몸은 지울 줄을 빼고 화면 표시 칸(tmp·deleted)을 뗌(빠지면 삭제 = 전체 줄 보내기)
|
||||
④ 고친 것이 없으면 dirty 아님 · 고치면 dirty · 새 줄은 「added」 · 고친 칸만 짚음
|
||||
⑤ 갈래는 처음 나온 차례로 묶음 · 새 줄은 제 갈래 끝 바로 뒤(보낸 차례 = sort_order)
|
||||
⑥ 보낼 줄은 id + 계약 칸 열하나만 — GET 전용 칸(sort_order·is_added·changed_columns)이 실리면 422
|
||||
TS 를 실제로 돌린다(`test_z01_master_cells` 와 같은 방식).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Draft.ts"
|
||||
|
||||
_RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
// 브라우저 sessionStorage 대역
|
||||
const store = new Map();
|
||||
globalThis.sessionStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
};
|
||||
const {
|
||||
BaseDraft, addRow, changedFields, freshBody, groupByVariant, isDirty, workItemPayload,
|
||||
} = await import("./Z01_MasterData_UI_Draft.js");
|
||||
|
||||
// ① 기초단가
|
||||
const base = new BaseDraft("base:labor", 7);
|
||||
base.setEdit("labor_const/1001", "daily_wage_krw", 250000, 215907);
|
||||
base.setEdit("labor_const/1002", "daily_wage_krw", 100, 100); // 서버 값과 같음 — 안 쌓임
|
||||
base.setEdit("labor_const/1003", "daily_wage_krw", 1, 2);
|
||||
base.setEdit("labor_const/1003", "daily_wage_krw", 2, 2); // 되돌림 — 빠짐
|
||||
base.setEdit("labor_const/9999", "daily_wage_krw", 5, 4);
|
||||
base.toggleDelete("labor_const/9999");
|
||||
const added = base.addRow();
|
||||
base.setAdded(added.tmp, "occupation_name", "새 직종");
|
||||
base.setAdded(added.tmp, "daily_wage_krw", 300000);
|
||||
base.setAdded(added.tmp, "daily_wage_krw", null); // 비우면 칸을 뺌
|
||||
const payload = base.payload();
|
||||
const count = base.count;
|
||||
const stored = JSON.parse(store.get("z01-draft:base:labor"));
|
||||
|
||||
// ② 같은 판이면 살아남 · 다른 판이면 비고 저장소에서 지움
|
||||
const again = new BaseDraft("base:labor", 7);
|
||||
const restored = again.payload();
|
||||
const newer = new BaseDraft("base:labor", 8);
|
||||
const dropped = { count: newer.count, left: store.has("z01-draft:base:labor") };
|
||||
const toggled = new BaseDraft("base:oil", 1);
|
||||
toggled.toggleDelete("x");
|
||||
toggled.toggleDelete("x");
|
||||
const emptied = { count: toggled.count, left: store.has("z01-draft:base:oil") };
|
||||
|
||||
// ③④⑤ 공종
|
||||
const row = (id, variant, extra = {}) => ({
|
||||
id, variant, resource_kind: "labor", resource_key: "labor_const/1001", resource_name: "보통인부",
|
||||
resource_spec: "", amount: 0.5, amount_unit: "인", alternative_amount: null,
|
||||
group_ratio_pct: null, raw_cell: null, pum_table_id: "T-1",
|
||||
// GET 전용 칸 — PUT 에 실리면 422(extra=forbid)
|
||||
sort_order: id, is_added: false, changed_columns: [], ...extra,
|
||||
});
|
||||
const detail = {
|
||||
item: { work_item_key: "FW-00001", axis: "forest", code: null, number: "1-1", name: "벌목",
|
||||
path_name: "벌목", parent_key: null, parent_mode: null, basis_quantity: 1,
|
||||
basis_unit: "ha", status: "needs_input", edition: "2026", revision: 3 },
|
||||
rows: [row(1, "갑"), row(2, "을"), row(3, "갑")],
|
||||
source: [], notes: [],
|
||||
};
|
||||
const body = freshBody(detail);
|
||||
const clean = isDirty(detail, body);
|
||||
body.rows[0].amount = 0.7;
|
||||
body.rows[1].deleted = true;
|
||||
addRow(body, "을", "new-1");
|
||||
body.item.status = "reviewed";
|
||||
const sent = workItemPayload(detail.item.revision, body);
|
||||
|
||||
writeFileSync(process.argv[2], JSON.stringify({
|
||||
payload, count, stored,
|
||||
restored, dropped, emptied,
|
||||
clean,
|
||||
dirty: isDirty(detail, body),
|
||||
sent,
|
||||
changed: [...changedFields(body.rows[0], detail.rows)],
|
||||
addedRow: changedFields(body.rows[2], detail.rows),
|
||||
untouched: [...changedFields(body.rows[3], detail.rows)],
|
||||
groups: groupByVariant(body.rows),
|
||||
freshUntouched: freshBody(detail).rows[0].amount,
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def test_초안_쌓기와_보낼_몸(tmp_path: Path) -> None:
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(SOURCE),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--ignoreConfig",
|
||||
"--noCheck",
|
||||
"--noResolve",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
result = tmp_path / "output.json"
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(result)],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
got = json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
# ① 서버 값과 같은 고침은 안 쌓임 · 지울 줄(9999)의 칸 고침은 안 보냄 · 새 줄은 data 만
|
||||
assert got["payload"] == {
|
||||
"base_revision": 7,
|
||||
"edits": [
|
||||
{"row_key": "labor_const/1001", "column": "daily_wage_krw", "value": 250000},
|
||||
],
|
||||
"added": [{"data": {"occupation_name": "새 직종"}}],
|
||||
"deleted": ["labor_const/9999"],
|
||||
}
|
||||
# 고친 칸 2(1001·9999) + 새 줄 1 + 지울 줄 1
|
||||
assert got["count"] == 4
|
||||
assert got["stored"]["revision"] == 7
|
||||
# ② 같은 판이면 그대로 살아남 · 판이 바뀌면 비고 저장소에서도 지움 · 다 되돌리면 저장소에서 빠짐
|
||||
assert got["restored"] == got["payload"]
|
||||
assert got["dropped"] == {"count": 0, "left": False}
|
||||
assert got["emptied"] == {"count": 0, "left": False}
|
||||
# ③ 지울 줄(id 2) 빠짐 · 새 줄은 id 없이 · 화면 표시 칸 없음 · 머리 상태 실림
|
||||
assert got["clean"] is False
|
||||
assert got["dirty"] is True
|
||||
sent = got["sent"]
|
||||
assert sent["base_revision"] == 3
|
||||
assert sent["item"] == {
|
||||
"name": "벌목",
|
||||
"basis_quantity": 1,
|
||||
"basis_unit": "ha",
|
||||
"status": "reviewed",
|
||||
}
|
||||
assert [r.get("id") for r in sent["rows"]] == [1, None, 3]
|
||||
# ⑥ id + 계약 칸만(새 줄은 id 없음) · 글자 칸은 null 아닌 빈 글자
|
||||
fields = {
|
||||
"variant",
|
||||
"resource_kind",
|
||||
"resource_key",
|
||||
"resource_name",
|
||||
"resource_spec",
|
||||
"amount",
|
||||
"amount_unit",
|
||||
"alternative_amount",
|
||||
"group_ratio_pct",
|
||||
"raw_cell",
|
||||
"pum_table_id",
|
||||
}
|
||||
assert [set(r) for r in sent["rows"]] == [fields | {"id"}, fields, fields | {"id"}]
|
||||
assert sent["rows"][1]["variant"] == "을" and sent["rows"][1]["pum_table_id"] == ""
|
||||
# ④ 고친 칸만 · 새 줄은 줄째 · 안 고친 줄은 빈 목록 · 서버 값(detail)은 안 건드림
|
||||
assert got["changed"] == ["amount"]
|
||||
assert got["addedRow"] == "added"
|
||||
assert got["untouched"] == []
|
||||
assert got["freshUntouched"] == 0.5
|
||||
# ⑤ 갈래는 처음 나온 차례(갑 → 을) · 새 줄은 제 갈래로
|
||||
assert got["groups"] == [
|
||||
{"variant": "갑", "indexes": [0, 3]},
|
||||
{"variant": "을", "indexes": [1, 2]},
|
||||
]
|
||||
@@ -1,235 +0,0 @@
|
||||
"""Z01 공종 편집 — 규칙(순수 함수) · 요청 모델 · 저장 흐름(409·422·전부 아니면 전무) · 등록.
|
||||
|
||||
(2026-09-18 브레인 PLAN 1-0 계약 · sub_laptop_3) DB 없이 — 저장 흐름은 가짜 커넥션·저장소로 훑음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from contextlib import asynccontextmanager
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.routing import APIRoute
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
|
||||
from common_util.common_util_auth import require_system_admin, verify_session
|
||||
from Z01_MasterData import Z01_MasterData_Router_WorkItems as router_module
|
||||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||||
|
||||
SEED_ROW = {"resource_kind": "labor", "resource_key": "L-001", "amount": 1.5, "sort_order": 0}
|
||||
|
||||
|
||||
def _row(row_id, seed=SEED_ROW, **values):
|
||||
base = {"id": row_id, "variant": "", "resource_kind": "labor", "resource_key": "L-001"}
|
||||
return {**base, "amount": Decimal("1.500000"), "sort_order": 0, "seed": seed, **values}
|
||||
|
||||
|
||||
# ── 규칙 ──
|
||||
|
||||
|
||||
def test_고친_칸은_초기값과_값으로_비교함() -> None:
|
||||
assert work_items.changed_columns(_row(1), SEED_ROW, work_items.ROW_FIELDS) == []
|
||||
changed = _row(1, amount=Decimal("2"))
|
||||
assert work_items.changed_columns(changed, SEED_ROW, work_items.ROW_FIELDS) == ["amount"]
|
||||
assert work_items.changed_columns(_row(1, seed=None), None, work_items.ROW_FIELDS) == []
|
||||
|
||||
|
||||
def test_검사는_남의_줄_겹친_줄_없는_자원을_막음() -> None:
|
||||
rows = [
|
||||
{"id": 1, "resource_key": "L-001"},
|
||||
{"id": 1, "resource_key": None},
|
||||
{"id": 99, "resource_key": None},
|
||||
{"id": None, "resource_key": "없는열쇠"},
|
||||
]
|
||||
errors = work_items.check_rows(rows, existing_ids={1, 2}, known_keys={"L-001"})
|
||||
assert len(errors) == 3
|
||||
assert "두 번" in errors[0] and "id 99" in errors[1] and "없는열쇠" in errors[2]
|
||||
assert work_items.check_rows(rows[:1], {1}, {"L-001"}) == []
|
||||
|
||||
|
||||
def test_전체_줄_보내기는_바뀐_줄만_쓰고_빠진_줄은_숨기거나_지움() -> None:
|
||||
existing = [_row(1), _row(2, sort_order=1), _row(3, seed=None, sort_order=2)]
|
||||
same = {f: existing[0].get(f) for f in work_items.ROW_FIELDS} | {"id": 1, "amount": 1.5}
|
||||
new = {"id": None, "resource_kind": "material", "resource_key": None, "amount": None}
|
||||
plan = work_items.plan_rows(existing, [same, new])
|
||||
assert plan["updates"] == [] # 1.500000 = 1.5 · 순서도 그대로
|
||||
assert plan["inserts"][0]["resource_kind"] == "material"
|
||||
assert plan["inserts"][0]["sort_order"] == 1
|
||||
assert plan["hide"] == [2] # 초기값 줄 — 숨김(되살릴 수 있게)
|
||||
assert plan["drop"] == [3] # 관리자 추가 줄 — 지움
|
||||
|
||||
moved = work_items.plan_rows(existing[:2], [{**same, "id": 2}, same])
|
||||
assert [u["id"] for u in moved["updates"]] == [2, 1] # 순서만 바뀌어도 sort_order 를 씀
|
||||
|
||||
|
||||
def test_초기값으로는_seed_칸만_살리고_추가_줄은_지움() -> None:
|
||||
head = {"id": 7, "name": "고친 이름", "seed": {"name": "원 이름", "status": "partial"}}
|
||||
rows = [_row(1, amount=Decimal("9")), _row(2, is_deleted=1), _row(3, seed=None)]
|
||||
plan = work_items.reset_plan(head, rows)
|
||||
assert plan["head"] == {"name": "원 이름", "status": "partial"}
|
||||
assert [u["id"] for u in plan["updates"]] == [1, 2]
|
||||
assert plan["updates"][0]["amount"] == 1.5
|
||||
assert plan["drop"] == [3] and plan["inserts"] == [] and plan["hide"] == []
|
||||
|
||||
|
||||
def test_상세는_seed_를_안_내보내고_고친_칸만_알림() -> None:
|
||||
head = {"id": 7, "work_item_key": "FW-00001", "name": "새", "status": "partial"}
|
||||
head |= {"seed": {"name": "옛"}, "source": [{"pum_table_id": "T1"}], "notes": ["[주] 1"]}
|
||||
out = work_items.detail(head, [_row(1), _row(2, seed=None)], revision=4)
|
||||
assert out["item"]["revision"] == 4 and out["item"]["changed_columns"] == ["name"]
|
||||
assert "seed" not in out["item"] and "id" not in out["item"]
|
||||
assert out["source"] == [{"pum_table_id": "T1"}] and out["notes"] == ["[주] 1"]
|
||||
assert [r["is_added"] for r in out["rows"]] == [False, True]
|
||||
assert out["rows"][0]["id"] == 1 and "seed" not in out["rows"][0]
|
||||
|
||||
|
||||
# ── 요청 모델 ──
|
||||
|
||||
|
||||
def _body(**row):
|
||||
item = {"name": "공종", "status": "reviewed"}
|
||||
return {"base_revision": 3, "item": item, "rows": [{"resource_kind": "labor", **row}]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"row",
|
||||
[{"amount": -1}, {"resource_key": ""}, {"resource_kind": "oil"}, {"unknown": 1}],
|
||||
)
|
||||
def test_요청_모델은_음수_빈열쇠_모르는_종류_모르는_칸을_막음(row) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
router_module.WorkItemSave.model_validate(_body(**row))
|
||||
|
||||
|
||||
# ── 저장 흐름(가짜 커넥션) ──
|
||||
|
||||
|
||||
class _Connection:
|
||||
def __init__(self) -> None:
|
||||
self.log: list[str] = []
|
||||
|
||||
async def begin(self) -> None:
|
||||
self.log.append("begin")
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.log.append("commit")
|
||||
|
||||
async def rollback(self) -> None:
|
||||
self.log.append("rollback")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
connection = _Connection()
|
||||
|
||||
class _Pool:
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield connection
|
||||
|
||||
head = {"id": 7, "work_item_key": "FW-00001", "name": "공종", "status": "partial"}
|
||||
head |= {"seed": {"name": "공종"}, "source": [], "notes": []}
|
||||
|
||||
async def fetch_item(_c, key, lock=False):
|
||||
return dict(head) if key == "FW-00001" else None
|
||||
|
||||
async def fetch_rows(_c, _item_id, with_deleted=False):
|
||||
return [_row(1)]
|
||||
|
||||
async def revision(_c, _scope, lock=False):
|
||||
return 3
|
||||
|
||||
async def known_resource_keys(_c, keys):
|
||||
return keys & {"L-001"}
|
||||
|
||||
async def write(_c, *args):
|
||||
connection.log.append("write")
|
||||
|
||||
async def bump_revision(_c, _scope):
|
||||
connection.log.append("bump")
|
||||
|
||||
async def list_items(_c, **picked):
|
||||
connection.log.append(picked["status"])
|
||||
return {"items": [], "total": 2, "counts": {"partial": 2}}
|
||||
|
||||
monkeypatch.setattr(router_module, "get_db_pool", lambda: _Pool())
|
||||
for name, fake in {
|
||||
"list_items": list_items,
|
||||
"fetch_item": fetch_item,
|
||||
"fetch_rows": fetch_rows,
|
||||
"revision": revision,
|
||||
"known_resource_keys": known_resource_keys,
|
||||
"write": write,
|
||||
"bump_revision": bump_revision,
|
||||
}.items():
|
||||
monkeypatch.setattr(router_module.repo, name, fake)
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router)
|
||||
app.dependency_overrides[verify_session] = lambda: {"user_id": 42}
|
||||
test_client = TestClient(app)
|
||||
test_client.log = connection.log
|
||||
return test_client
|
||||
|
||||
|
||||
URL = "/api/master-data/work-items/FW-00001"
|
||||
|
||||
|
||||
def test_낡은_revision_은_409_이고_아무것도_안_씀(client) -> None:
|
||||
response = client.put(URL, json={**_body(id=1, resource_key="L-001"), "base_revision": 2})
|
||||
assert response.status_code == 409
|
||||
assert client.log == ["begin", "rollback"]
|
||||
|
||||
|
||||
def test_없는_자원_열쇠는_422_이고_아무것도_안_씀(client) -> None:
|
||||
response = client.put(URL, json=_body(resource_key="없는열쇠"))
|
||||
assert response.status_code == 422 and "없는열쇠" in response.json()["detail"]
|
||||
assert client.log == ["begin", "rollback"]
|
||||
|
||||
|
||||
def test_맞는_저장은_쓰고_revision_올리고_새_상세를_줌(client) -> None:
|
||||
response = client.put(URL, json=_body(id=1, resource_key="L-001", amount=2))
|
||||
assert response.status_code == 200, response.text
|
||||
assert client.log == ["begin", "write", "bump", "commit"]
|
||||
assert response.json()["status"] == "success" and response.json()["item"]["revision"] == 3
|
||||
|
||||
|
||||
def test_목록은_상태별_개수를_다_채우고_모르는_상태는_막음(client) -> None:
|
||||
body = client.get("/api/master-data/work-items?status=").json()
|
||||
assert body["counts"] == {s: 2 if s == "partial" else 0 for s in work_items.STATUSES}
|
||||
assert client.get("/api/master-data/work-items?status=needs_input").status_code == 200
|
||||
assert client.get("/api/master-data/work-items?status=bogus").status_code == 422
|
||||
assert client.log == ["", "needs_input"]
|
||||
|
||||
|
||||
def test_초기값으로와_없는_공종(client) -> None:
|
||||
assert client.post(URL + "/reset").status_code == 200
|
||||
assert client.log == ["begin", "write", "bump", "commit"]
|
||||
assert client.get("/api/master-data/work-items/FW-99999").status_code == 404
|
||||
|
||||
|
||||
# ── 등록 ──
|
||||
|
||||
|
||||
def _calls(dependant) -> set:
|
||||
found = set()
|
||||
for sub in dependant.dependencies:
|
||||
found.add(sub.call)
|
||||
found |= _calls(sub)
|
||||
return found
|
||||
|
||||
|
||||
def test_공종_편집_경로는_시스템_관리자만() -> None:
|
||||
app = importlib.import_module("main").app
|
||||
routes = {(r.path, m) for r in app.routes if isinstance(r, APIRoute) for m in r.methods}
|
||||
wanted = {
|
||||
("/api/master-data/work-items", "GET"),
|
||||
("/api/master-data/work-items/{key}", "GET"),
|
||||
("/api/master-data/work-items/{key}", "PUT"),
|
||||
("/api/master-data/work-items/{key}/reset", "POST"),
|
||||
}
|
||||
assert wanted <= routes
|
||||
for route in app.routes:
|
||||
if isinstance(route, APIRoute) and route.path.startswith("/api/master-data/work-items"):
|
||||
assert {verify_session, require_system_admin} <= _calls(route.dependant)
|
||||
@@ -12,17 +12,17 @@ from pathlib import Path
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from helper_z01_fake_repo import install
|
||||
|
||||
from Z01_MasterData import Z01_MasterData_Overrides as overrides
|
||||
from Z01_MasterData import Z01_MasterData_Router as router_module
|
||||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
from common_util.common_util_auth import verify_session
|
||||
|
||||
install(monkeypatch) # 기초단가 표 목록·줄 수가 DB 를 읽음 — 가짜로
|
||||
monkeypatch.setattr(overrides, "OVERRIDE_DIR", tmp_path / "data_master_override")
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router)
|
||||
app.dependency_overrides[verify_session] = lambda: {"user_id": 7, "role": "SYSTEM_ADMIN"}
|
||||
@@ -131,15 +131,10 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
|
||||
assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음
|
||||
|
||||
res = client.put(
|
||||
"/api/master-data/base-prices/work_item_const",
|
||||
json={
|
||||
"base_revision": 0,
|
||||
"edits": [{"row_key": "CW-00002#01", "column": "name", "value": "바꿈"}],
|
||||
},
|
||||
"/api/master-data/base-prices/work_item_const/CW-00002%2301",
|
||||
json={"values": {"name": "바꿈"}},
|
||||
)
|
||||
assert (
|
||||
res.status_code == 400 and "고칠 수 없는 표" in res.json()["message"]
|
||||
) # 공종은 /work-items 몫
|
||||
assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"]
|
||||
|
||||
|
||||
def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestClient) -> None:
|
||||
|
||||
Reference in New Issue
Block a user