39 Commits
Author SHA1 Message Date
eomsangdon b516ffed66 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 23:34:02 +09:00
eomsangdonandClaude Opus 5 15d91be7b0 feat(b09): 봉상후렉시블 셋(12-12 날개벽 · 12-13 면벽 · 12-16 맨홀) + 12-34-1 — 같은 진동기 줄 하나가 막던 네 공종을 판정표로 세움
· 12-12·12-13·12-16 — 12-15 집수정과 같은 모양(㎥ 줄 비고 인력 + 다짐기 줄) · 판정표 한 벌에 더함 · 맨홀은 비고가 끝 칸이 아니라 단위 뒤 칸 전체에서 찾음
· 비고가 인/㎥ 인 판정표는 밑수 없음 목록을 안 봄(12-12 는 「개소당」 머리조차 없음)
· 12-34-1 — 형태 판정 참조→소요량(「별도계상」 은 레미콘 줄 비고일 뿐) · 새 모양 per_m3_rows · 진동기(3.5HP) 대 2 → 4611-0350 × 2/5.4 · 봉상후렉시블 대 2 는 엔진식 진동기의 봉이라 안 셈 · 머리 개소당 ↔ 인력 0.17·0.29(12-16 ㎥당과 같음)·Q ㎥/hr ⇒ ㎥당으로 읽고 고른 쪽·버린 쪽을 사유에 나란히(브레인 판정)
· 범위 별칭 넷 → 엔진식 4611-0350 · 표의 나머지 줄(거푸집·철근·기초잡석·원형거푸집·설치비 등)은 원문 그대로 사유 한 줄(조용히 안 사라지게)
· 실무 영월 「콘크리트타설(임도품셈 5-5)」 호표가 같은 모양 — 인력 인/㎥ + 진동기 엔진식 1/Q · 손료 252천원×0.5101 · 노무 0
· 마스터 재생성 — 네 공종 갈래 키 · F0385 형태만

새 제목 다섯 · 바뀐 제목 0 · 프로젝트 내역 그대로 · 잴 시험 넷 빨강→초록 · 끄기 다섯 빨강 · 전체 시험 1699 + 337 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 23:33:47 +09:00
eomsangdon 0fa6cd0898 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_1 2026-09-14 23:16:10 +09:00
eomsangdon 0989471901 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 23:16:09 +09:00
eomsangdonandClaude Opus 5 6ea73956e5 chore(tester): 잘못 올라간 pytest 임시 폴더 걷어냄(auto 커밋 8ed4be85 에 섞임 · 시험 산출물)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 23:14:42 +09:00
eomsangdon 8ed4be855b auto: 2026-09-14 23:13 (ESD_LAPTOP) 2026-09-14 23:13:49 +09:00
eomsangdonandClaude Opus 5 20597071bd fix(b06): 유토 배분 수량은 거르지 않은 계획 · 잔진동 거르기는 그림에만(브레인 ①)
- computeHaulPlan 기본 = 거르지 않음, drawing: true 일 때만 진폭 2% 거르기
- 서버 정본 두 벌 — haul_plan(수량·B08) · haul_plan_drawing(토적도) · 화면 선반입은 그림용
- B07 토적도는 그림용 먼저, 옛 저장분은 haul_plan
- 936be972 실측: 운반 0 → 245.81㎥(7블록 15띠) · 토취 11,100.86 그대로 · 내역 154,857,626 → 155,207,971

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 23:13:48 +09:00
eomsangdonandClaude Opus 5 47196780b2 fix(b09): 절사 자동보정을 이분으로 앉힘 — 못 밟는 끝자리에서 999 가 남던 자리
까닭 — 이윤 1원을 깎으면 총공사비는 1원이나 2원 준다(부가세가 버림). ÷1.1 어림이 한 칸
아래로 지나치고 3걸음 안에 못 돌아와 거창 꼴 40 자리 중 16 이 끝자리 999 로 남았음.

고친 법 — 노릴 배수를 못 박고 목표 이하로 내려가는 **가장 작은 보정액을 이분으로** 찾는다
(총공사비는 보정액에 대해 비증가). 못 밟는 배수면 한 칸 아래로 내려 다시 찾는다(뒷받침 —
실측에서는 한 칸도 안 내려감). _CUT_MAX_PASSES → _CUT_DESCENT_MAX 로 뜻이 바뀜.

판정 — 거창 꼴 40/40 앉음 · 깎인 몫 356~423원으로 늘 한 칸 안쪽 ·
실무 6건 자동보정액 그대로(봉화 339 · 영월 632, 골든셋이 지킴).

잴 시험 먼저 빨강 10 자리 확인한 뒤 고침. 전체 시험 2043 통과 · 28 건너뜀 · 1 xfail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 23:04:31 +09:00
eomsangdon fd0ea82975 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 22:56:28 +09:00
eomsangdonandClaude Opus 5 8dddc76839 feat(b09): 672 품 할증 여럿 고르면 합산 — 근거를 건설 공통 1-4-2 「W = 기본품 × (1 + a1 + … + an)」 로(교차 참조)
· 산림품셈 1-4 에 겹침 규정이 없어 건설 공통 1-4-2 「할증의 중복가산요령」 을 따름 — 「우리가 둔 합산 · 확정 대기」 문구를 원문 근거로 바꿈
· 같은 조 단서 「동일성격의 품할증요소의 이중적용은 불가」 — 어느 계열끼리 동일성격인지 원문이 안 정해 막지 않고 화면 근거에 단서로 보임
· 셈법(합산) 자체는 그대로라 금액 변화 없음

잴 시험 둘 빨강→초록 · 전체 시험 1695 + 332 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 22:55:45 +09:00
eomsangdon edc8148515 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_desktop_1 2026-09-14 22:43:55 +09:00
eomsangdonandClaude Opus 5 8bf24c268f fix(b06): 미교차 측점 지반 샘플을 지표면 끝까지 넓힘(㉳ (가) · 브레인 판정 「+5m 씩 · 상한 = 지표면 끝」 · 716 「+5m 한 번」 갈음)
- 서버 재계산([저장]·[확정]·자동설계 체인) 맨 앞에서 미교차 측점만 열린 쪽을 +5m 씩 확정 지표면에서 다시 떠 계산 · 닫히면 멈춤 · 지표면 밖 샘플이 섞이면 그 걸음은 안 붙이고 미교차로 남김
- 넓힌 샘플은 횡단 파일에 남겨 화면·Node·B08 이 같은 지반을 봄 · 계산식은 안 바뀜(입력만 넓어짐) · 뒤 보정(포장·세월교·사토장)은 넓힌 지반 위에서
- 종전엔 반폭 20m 끝에서 잘려 성토가 작게 섰음
- 936be972 서버 재계산 전→후: 미교차 12 → 1(840 지표면 끝) · 성토 단면 460 19.87→35.11 · 480 48.04→84.35 · 500 37.15→52.47 · 700 24.74→54.84 · 1000 56.30→80.40 · 840 41.60→110.35 · 258.12 25.11→31.44 · 유토곡선 성토 13,350.64 → 17,248.52㎥ · 토취 7,259.84 → 11,100.86㎥ · 내역 본체 129,498,518 → 154,857,626원

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 22:43:10 +09:00
eomsangdonandClaude Opus 5 7ea2598263 fix(b09): 휘발유 기계에 경유값이 붙던 것 — 운전경비표 연료 종류대로 유가 · 콘크리트 진동기(4611) 원문값 되살려 집수정 구체콘크리트 갈래를 세움
· 연료 — `fuel_kind` 를 조립이 안 읽어 휘발유 기계 10종(래머·플레이트 콤팩터·믹서·커터·진동기)이 경유값이었음 → `M-FUEL-휘발유` · 시도 판도 종류대로 · 기계 하나 사용료·중기경비 장도 같은 값
· 진동기 — 건설품셈 8-3 (4611) 두 줄이 한 칸에 뭉쳐 규격·손료계수가 비었음 → 원문값 되살림(전기식 ø45 0.75㎾ 4,935 · 엔진식 ø45 2.6㎾ 5,101 · 판정 없음)
· 12-15 「봉상후렉시블(45mm)」 → 엔진식 4611-0350 범위 별칭 — 12-15 는 전기·엔진을 안 적음 · 12-34-1 「3.5HP」 = 2.6㎾ · 운전경비표에 엔진식만(브레인 승인) → 구체콘크리트 갈래가 진동기 1/5.4 hr/㎥ 와 함께 섬
· 661 뒤처리 — 중기경비 장의 `#암석` 이 조합 16%·비암석 계수로 보이던 것을 보정 상각·정비·계와 본 잡품률로
· 마스터 재생성 — 12-15 갈래 키만(구체콘크리트 더함)

휘발유 기계 전수 대조: 재료비 +13~113원/hr · 조립에 선 것은 플레이트 콤팩터(+20원/hr → 9-14-02 +5원)와 진동기 · 나머지 제목 그대로 · 프로젝트 내역 그대로 · 잴 시험 일곱 빨강→초록 · 끄기 다섯 빨강 · 전체 시험 1695 + 330 통과
(시험 파일은 앞선 autopush 가 먼저 쓸어 올린 빨강본 — 이 커밋이 초록으로 맞춤)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 22:40:49 +09:00
eomsangdon 142b100a09 auto: 2026-09-14 22:18 (ESD_LAPTOP) 2026-09-14 22:18:38 +09:00
eomsangdon c51722ae8b Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 22:07:59 +09:00
eomsangdonandClaude Opus 5 a842766658 feat(b09): 661 암석 작업 기계손료 보정(건설품셈 8-1-7 1) — 암 공종의 굴착기(무한궤도)·로더·불도저·덤프 줄을 「암석」 호표로
· 암석 손료계수 = (상각 + 정비) × (1 + 가산) + 관리 · 1e-7 정수 아래 버림 · 가산율은 mach_rock_adj(원문 파싱) 한 벌
· 실무 봉화 2024 암석 계수 셋 재현 — 굴착기 1.0 0.2405 · 덤프 2.5 0.3533 · 덤프 15 0.2679
· 거는 자리 — 자기·부모 이름이나 갈래가 연암·보통암·경암·발파암·파쇄암·암절취·암석인 일위대가의 대상 기계 줄
· 안 거는 것 — [주]① 19톤 이상 불도저·18톤 이상 덤프(줄 비고에 까닭) · 브레이커 조합 본체(봉화 조합 손료가 비암석) · 풍화암·호박돌 · 전석섞인토사(혼입율 입력 없음 · 칸 안 만듦)
· 손료율 원천은 이미 8-3 원천표(387건) — 봉화 25 중 보통 조건 16종 전부 일치라 「실측을 기본으로」 물음은 닫힘

바뀐 제목 17(암 집토·암 면고르기·암 덤프 운반/적재 · 2~3.4% 오름) · 나머지 458 그대로 · 프로젝트 내역 그대로 · 잴 시험 넷 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1691 + 323 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 22:07:48 +09:00
eomsangdon eb1a78f9e1 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 21:53:28 +09:00
eomsangdonandClaude Opus 5 05d542add3 feat(b08): 토취(반입토) 줄 — 유토곡선이 낸 성토 부족분을 수량만 세우고 금액은 사유(브레인 ①)
- B06 운반계획의 borrow_m3·구간(residuals)을 운반표에 싣고 토공집계에 「토취(반입토)」 줄 · 인계는 코드 없이 수량만 · 막힘 사유 「토취장 거리·재료 미정」(집계 비고 그대로) · 다짐상태 그대로(반입 재료를 몰라 되돌리지 않음)
- 종전엔 어느 탭·인계에도 안 옮겨 수량이 통째로 빠졌음
- 936be972: 토공집계 7,259.84㎥(246.54~529.33m 3,494.69 · 720~1,078.01m 3,765.15) · 내역 「금액을 못 세운 줄」 · 본체 금액 변화 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 21:38:53 +09:00
eomsangdonandClaude Opus 5 d57e97a1cd fix(b08): 산출 조건 [저장]이 운반계획을 다시 세움(브레인 ② · 지침 5장 「[저장]·[확정]은 서버가 정본으로 다시 계산」)
- 운반계획 밑수 칸(암 갈래 세트·구성비·시공법·계수·도쟈 한계거리)이 바뀐 저장만 B06 [저장]과 같은 서버 재계산을 부름 · 다른 칸 저장은 안 부름 · 못 하면 저장은 살리고 haul_plan_recomputed 거짓
- 종전엔 B06 을 다시 저장하기 전까지 토적표(새 계수) ↔ 운반표(옛 계획)가 갈렸음 — 계수 고르기 칸의 옛 틈도 함께 닫힘
- 936be972 화면에서 연암 60 리핑/보통암 40 발파 [저장](2.0초) → 유토곡선 깎기 6,296.8 = 토적표 6,296.81 · 토취 7,259.84 → 7,053.83 · 되돌림(설정 파일 · 재계산 원래 값)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 21:32:21 +09:00
eomsangdon ac68232d28 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 21:29:14 +09:00
eomsangdonandClaude Opus 5 efaf2403a0 feat(b09): 301 유로폼 사용수량 12-38-2 — 임대료 또는 손료를 「자재 단가」 탭에서 설계자가 넣은 쪽으로 세움
· 손료 = 자재가 × 표 수량 곧장(패널 0.089 · 내부 패널 0.003매/㎡) + 부자재 24/52/79% · 소모자재 5%(주자재비 = 패널 + 내부 패널) — 봉화 2024 호표 재료비 보통 4,500 · 간단 3,697 과 일치
· 임대료 = 「유로폼 임대료 ㎡당」 한 줄 · 부자재 % 는 원문이 안 적어 안 걺
· 둘 다 · 한쪽만 반쯤 · 아무것도 — 안 세우고 사유(부모 12-38 까지 그대로)
· 12-38-1 잔존율은 안 곱함 — 표 수량이 이미 12회·25% 몫으로 보임(우리 역산 · 차 2.5% 는 추정이라 비고에 밝힘)
· 이중계상 ③ 가드에서 12-38-02 만 뺌(패널이 자재총괄에 줄 없음) · 유로폼이 자재총괄로 가면 빨강 시험
· 합산형 부모 — 갈래 벌이 같으면 여러 단계 갈래끼리 합산(12-38 = 사용수량 + 설치·해체)
· 작업조 표 7 은 밑수 없음 목록에서 뺌(1단위당 = 인원 ÷ 시공량) — 12-38-3 설치·해체가 막혀 있던 자리
· 마스터 재생성 — 12-38 · 12-38-02 갈래 키만 바뀜

기본 조립 제목 금액 변화 0 · 프로젝트 내역 변화 0 · 잴 시험 일곱 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1677 + 318 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 21:29:04 +09:00
eomsangdonandClaude Opus 5 8f4ec48759 feat(b06·b08): ㉱ (나) 암 환산계수를 구성비 가중으로 — 토적표·유토곡선·운반표가 한 함수(mixed_conversion_factors)
- B06 암은 한 종류 자리표시라 토적표 보정량·유토곡선이 리핑암 C 하나로 쌓았음 · 구성비·갈래별 시공법이 다 서면 암 두 칸을 Σ몫×C(시공법)로 · 비면 종전 값(인계가 막힘 사유)
- 계수를 내는 곳 넷(B06 서버 재계산·곡선 문맥 · B08 토적표·사토 계수)이 같은 함수 · 화면 「무엇을 골랐나」는 갈래별 값 그대로
- 936be972 연암 60 리핑/보통암 40 발파 · 서버 재계산: 토적표 깎기 보정량 6,296.81 = 유토곡선 6,296.8 · 암 보정량 4,167.31 → 4,384.74 · 토취 7,259.84 → 7,053.83 · 사토 55.31 → 58.2 · 운반 검산 0 · 되돌려 재계산 원래 값 그대로

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 21:11:44 +09:00
eomsangdonandClaude Opus 5 27e068aa12 feat(b08): ㉱ (가) 암 운반량을 구성비로 가름 — B06 리핑암은 자리표시(8-1 사용자 확정 · 브레인 판정 「구성비가 정본」)
- 운반표를 만든 한 곳(Router_Earthwork)에서 암 줄을 흙깎기와 같은 구성비(_split_by_rock)·갈래별 시공법으로 가름 → 운반거리·토공집계 운반 줄·인계·사토가 한 값
- 구성비가 비면 「암」 한 줄로 막음(깎기와 같은 사유) · 시공법 안 고른 갈래만 막음 · 무대(내역 밖) 줄은 안 막음
- 유토곡선 거리·다짐 부피는 그대로(몫대로 나눔 · 검산 차이 0) — C 까지 구성비로 맞추는 것은 (나) 차례
- 936be972: 구성비 없음 → 운반·적재·사토 암 전부 입력 사유(종전 리핑암 금액 섬) · 연암 60 리핑/보통암 40 발파 → 흙깎기 2,060.043/1,373.362 · 도자 38.123/25.416 · 덤프 112.195/74.797 · 사토 28.858/19.238 같은 몫 · 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 20:54:28 +09:00
eomsangdon b6d5c9acc8 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 20:47:02 +09:00
eomsangdon 1e791b487d Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 20:32:32 +09:00
eomsangdonandClaude Opus 5 09247fd16f feat(b09): 300 발파 9-5-1 — 화약류(폭약·뇌관·비트) 자재 단가 칸 · 설계자 단가가 들면 표 수량으로 붙고 아니면 「규격 미정」 사유 · 착암기는 공기압축기 부수물(건설품셈 8-3-6 (5205) [주]① 「부수물은 별도 계상」)이라 압축기 손료에 안 들고 래그해머 손료 고시 없음 사유 · 잡재료 5% 사유(브레인 판정)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 20:32:30 +09:00
eomsangdonandClaude Opus 5 3ecc23ac93 feat(b08): 초류종자살포 잎 고르기 칸(브레인 ㉮) — 매핑이 부모 5-24(갈래 고르기형)라 고를 칸이 없어 금액이 영영 안 서던 자리
- 산출 조건 「초류종자살포 비탈면 토질」(5-24-1 기계/일반 · 5-24-2 기계/마사토 · 서버 선택지 · 제안값 없음) · 비면 금액 없이 입력 사유
- 매핑 `leaf_from`·`leaf_codes` 가 설정값으로 잎 코드를 고름(인계 한 곳 · 갈래 없는 매핑은 종전과 같음)
- 936be972 일반 고름 → 인계 FP-05-24-01 막힘 풀림 · B09 는 다음 막힘 「트럭 4.5ton(장비 줄) 카탈로그에 없음 · 종자 규격 미정」으로 아직 금액 없음 · 면고르기 성토면 12,350.32㎡ 40,373,196원 · 절토면 3,087.51㎡ 7,731,123원(파종 면적 그대로 따라감) · 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 20:25:41 +09:00
eomsangdon 2799cad714 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_1 2026-09-14 20:15:55 +09:00
eomsangdonandClaude Opus 5 1400b7b761 feat(b09): 기계 수송비 회수(대수 × 왕복) 설계 입력 칸 — 원문에 공식이 없어 사유와 함께 엶
산림품셈 10-4 · 건설품셈 8-1-3 은 회당 단가 산출식만 주고 대수·횟수를 정하지 않음(전수 확인).
지어내지 않고 설계 입력으로 닫음.

· parse_trips — 비거나 0 이면 없음. 기본값을 두지 않음(1 회로 안 때움)
· transport_amount — 회수 × 회당 단가. 회수가 없으면 None(0 원으로 안 채움)
· TRIPS_NOTE — 「원문에 공식 없음 · 설계 입력 · 비우면 금액이 안 섬」 사유
· 도는 자리 넷 — 저장(PUT estimation/factors) · 산출 조건 ② · 산출 조건 화면(회당 N원 × M회) ·
  비면 transport_notes 에 사유가 붙어 못 채운 자리로 뜸
· 화면에 있던 「회수는 여기서 안 정합니다」 안내는 칸으로 바뀌어 걷음

잴 시험 먼저 빨강 확인한 뒤 고침. 전체 시험 1976 통과 · 28 건너뜀 · 1 xfail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 20:13:25 +09:00
eomsangdonandClaude Opus 5 c7f34b8c4b feat(b09): 사람이 가른 표 형태 66표 까닭을 화면에 — 일위대가 상세 회색 한 줄(form_basis_note) · 일위대가 없는 공종은 내역 비고 곁 · 판정표 한 벌(_Forms.FORM_JUDGMENTS)을 읽음(10-A ⑭ · 원문 근거 없는 SW 규칙)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 20:12:09 +09:00
eomsangdon 027c305e16 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 19:44:57 +09:00
eomsangdonandClaude Opus 5 475ad7875e feat(b08·b05·b09): 10-A 우리가 정한 SW 규칙을 화면에 드러냄 — 훑기 21건 중 안 보이거나 반만 보이던 13건 채움 + 9-21 제근 굴착기 크기 칸
- 문구 못박음 셋: ① 옹벽 높이 칸 밑 「기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음」(등록부 default_basis 를 모든 칸 밑 회색 한 줄로) · ② 일반관리비 「임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음」 · ③ 모르타르 배합 「산림품셈에 배합 절이 없어 건설품셈 [건축] 9-1-1 을 씀(교차 참조)」
- B08: 막자갈 비고 「칸을 새로 두는 것은 B05 등록부 몫」 · 일위대가 머리 5단 한도 · 야면석 계수 칸 밑 「돌 종류는 그대로」 · 식 저장 전 상태 줄 「제원을 바꿔도 따라감」 · 양식 장 머리 「반올림은 m당 값에 걸고 수량 = m당 × 연장」
- 산출 조건 제근 굴착기 크기 칸(임목축적 등급 밑 · 「안 정함」 · 회색 제안 0.7㎥ + 근거 + [제안값 넣기] 누른 때만) — 936be972 소림 + 0.7 → 뿌리뽑기 55원/㎡ × 17,873.80㎡ = 983,057원(되돌림)
- 라이브러리 이름표: [내 라이브러리에 저장]·[발행] 창이 장 이름(종류 + 제원 요약)을 채워 묻고 고친 이름으로 저장 · 취소면 안 씀 · 비우면 양식 이름
- B05 계곡 통과 시설 폼 머리 「[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸은 그대로 둠」 · B09 폐기물처리비 자리 칸 밑 「법정경비 밑수에는 안 넣음」 · 내역서 「금액을 못 세운 줄」 첫 줄 「사유는 아래 단계가 낸 것을 그대로 옮김」
- 남은 하나(표 형태 66표 까닭 form_basis 화면)는 자원 축 파일을 거쳐야 해 브레인 물음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 19:44:18 +09:00
eomsangdon af609a381e Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1 2026-09-14 19:38:29 +09:00
eomsangdonandClaude Opus 5 7c9f75ca12 feat(b08,b09): 12-15 집수정 조립 — 콘크리트 집수정만 묶음(when) · 조각 구체콘크리트 ㎥ · 버림콘크리트(성분 없음 막힘) · 합판거푸집 #4회 · 철근 #보통(12-15 표 정본 · 간단 추정 걷음) · 판정표 비고 칸 인력으로 #버림콘크리트(87,489원/㎥) · 구체는 다짐기 없어 안 세우고 사유 · 뚜껑·설치비 사유 · 돌집수정 종전대로 · 묶음은 타설·잡석 줄이 또 안 셈 · 마스터 재생성(바뀐 공종 12-15 하나)
금액은 아직 안 서고 막힌 까닭이 드러나는 일감(브레인 ㉯ ⑴~⑸) · 배정 프로젝트 변화 0 · 전체 1962 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 19:38:27 +09:00
eomsangdonandClaude Opus 5 5a016979c4 fix(b09): 안전관리비 제3조 적용 하한을 엔진이 읽게 함 · 1.2배 차례와 관급 ÷1.1 사유 명기
3. 제3조(적용범위) 「총공사금액 2천만 원 이상」 — 요율 데이터의
   minimum_total_construction_amount_krw 를 규모 기준액과 견주고 미달이면 줄 자체를 안 만듦
   (A·B 곁줄도 안 섬 · 0 원으로 안 채움). 잴 시험 먼저 빨강 확인한 뒤 고침.
   ⚠ 견주는 값은 규모 기준액(추정가격 · 없으면 수렴한 총원가) — 고시의 「총공사금액」과 딱 같은
     말은 아니나 계산 차례상 안전관리비 앞에 설 수 있는 값이 그것뿐. 보건관리자 문턱과 같은 축.

1. 1.2배 차례 = STmate 쪽 확정(브레인 판정) — 안 고른 갈래의 사유를 코드에 남김.
   거창 2025 원본은 버림(밑수 × 율 × 1.2) 로 1원 위지만 STmate 출력이 아니라 채택하지 않음.

4. 관급 ÷1.1 = 실무 따름 — 근거를 정확히 적음. 고시 제4조① 단서는 「해당 재료비를 대상액에
   포함」까지만이고 부가세를 말하지 않음 · ÷1.1 은 부가세 제외 환산으로 실무 6건 공통.

전체 시험 1952 통과 · 28 건너뜀 · 1 xfail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 19:22:08 +09:00
eomsangdonandClaude Opus 5 b23db36c78 feat(b08): 옹벽 조립 조각 합판거푸집이 사용횟수 갈래로(「FP-12-04#3회」 · 성분의 reuse_count 그대로) · 집수정 거푸집 6회 → 4회(12-15 표 「합판4회」 정본 · 사유 문구도 그 표) — 화면 자재 표·인계 성분 한 벌(브레인 ㉯ ①②)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 19:05:55 +09:00
eomsangdon 3ca1a45b2b Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 18:37:03 +09:00
eomsangdonandClaude Opus 5 0bb14ce2b3 feat(b08): 라이브러리 공유 — [동료에게 보내기] 같은 회사 동료의 「받음」 단으로 복사(개인 단을 안 덮음 · 보낸 사람 기록 · 출처 공사명 그대로) · 받음 단 항목도 가져오기·복제 가능 · 회사 밖·나에게·내 것 아닌 항목은 막음(브레인 판정 ①)
화면: 복제로 만든 개인 항목 선택 → 동료 목록 받음 → 확인창 「받는 쪽 목록에 받음으로 뜸 · 그 사람 것은 안 덮음」 · 공용 storage 라 취소(동료 폴더 안 생김 확인) · 개인 항목 지워 되돌림 · 쓰기는 임시 storage 시험

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 18:25:22 +09:00
eomsangdonandClaude Opus 5 ecd25ae9ee fix(b08): 프로그램 기본 발행본엔 원문 공사명·파일명을 안 실음(브레인 ②) — 출처에 「원문에서 뽑음 — 공사명은 발행 시 가림」 · 원본(개인 단)·회사 단 발행은 그대로(③) · 발행 확인창에 「이 항목은 「○○」에서 뽑은 것 — 공사명은 빼고 발행됩니다」
화면(936be972 · 봉화 호표 가져온 장): 확인창 문구 확인 · 공용 storage 라 취소(쓰기 없음) · 가림은 임시 storage 시험 · 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 18:21:24 +09:00
104 changed files with 3717 additions and 244 deletions
@@ -40,6 +40,8 @@ export interface StructureOptionField {
warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 칸 이름 옆 「표에 안 쓰임」 + 툴팁 사유. */
not_in_table?: string | null;
/** 기본값의 뜻(도메인 확정값이 아닐 때) — 칸 밑 근거 한 줄. */
default_basis?: string | null;
}
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
+1 -1
View File
@@ -727,7 +727,7 @@
"input": "number",
"unit": "m",
"default": 2.0,
"default_basis": "제안값 2.0 — 관측 원단위 자료가 반중력식 H=2.0 한 벌뿐이라 그 값(2026-09-14 A4 · 옛 2.5 는 자료 없음)",
"default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음",
"required": false,
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
@@ -361,10 +361,17 @@ export function createFacilityOptionsForm(
emit();
});
// 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정).
const saveNote = document.createElement("p");
saveNote.className = "b05-structure__owner-note";
saveNote.textContent =
"[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀";
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
root.append(
grid(suggest),
saveNote,
pipeRow,
wingRow,
ford.widthRow,
@@ -9,12 +9,23 @@
import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station";
export function field(labelText: string, input: HTMLElement): HTMLLabelElement {
export function field(
labelText: string,
input: HTMLElement,
basis?: string | null,
): HTMLLabelElement {
const wrapper = document.createElement("label");
wrapper.className = "b05-route__field";
const caption = document.createElement("span");
caption.textContent = labelText;
wrapper.append(caption, input);
// 칸 밑 근거 한 줄 — 등록부 `default_basis`(기본값의 뜻 · 10-A 2026-09-14 사용자 확정).
if (basis) {
const note = document.createElement("small");
note.className = "b05-structure__basis";
note.textContent = basis;
wrapper.append(note);
}
return wrapper;
}
@@ -45,6 +56,8 @@ interface OptionShape {
warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 툴팁이 이 사유를 먼저 보임. */
not_in_table?: string | null;
/** 기본값의 뜻 — 칸 밑 근거 한 줄. */
default_basis?: string | null;
}
/**
@@ -332,7 +332,7 @@ export function createStructuresSection(
input.classList.add("is-locked");
input.title = "지금은 고를 수 없는 항목입니다.";
}
optionRow.append(field(label, input));
optionRow.append(field(label, input, option.default_basis));
input.addEventListener("change", () => liveCommit());
optionInputs.push({
key: option.key,
@@ -214,6 +214,18 @@
line-height: 1.5;
}
/* 칸 밑 근거 한 줄 — 등록부 `default_basis`(10-A). 회색 작은 글씨. */
.b05-structure__basis {
color: var(--color-text-muted, #9aa1ad);
font-size: var(--text-caption, 12px);
line-height: 1.3;
}
/* 근거 줄로 옆 칸이 길어져도 [제안값 넣기]가 세로로 늘지 않게. */
.b05-structure__suggest {
align-self: start;
}
/* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치
* (2026-08-17 사용자 지시 2). */
.b05-structure__position-row {
@@ -0,0 +1,190 @@
"""미교차 측점의 지반 샘플을 **지표면 끝까지** 넓힘 — ㉳ (가) (2026-09-14 브레인 판정).
반폭(기본 20m) 샘플 끝에서 사면이 원지반과 안 만나면 면적이 거기서 잘려 성토가 **작게** 섰다
(936be972 미교차 12곳 중 11곳이 넓히면 닫힘 · 성토 +2,559.9㎥). ⇒ **열린 쪽만** +5m 씩 확정 지표면에서
다시 떠 계산하고, 닫히면 멈춘다. 새 걸음에 지표면 밖(무효) 샘플이 섞이면 그 걸음은 안 붙이고 멈춘다 —
그 측점은 「지표면 끝까지 넓혀도 안 만남」으로 미교차가 남는다.
⚠ 상한 숫자를 두지 않는다 — 지표면이 실제로 있는 끝이라 근거가 필요 없음(인위 기본값 금지).
⚠ 716 판정 「미교차 측점만 반폭 +5m 한 번」을 갈음(그것으론 4곳만 닫혔음).
⚠ 계산식은 안 바뀐다 — 샘플(입력)만 넓어진다. 넓힌 샘플은 횡단 파일에 남겨 화면(TS)·Node·B08 이
같은 지반을 본다(한 벌).
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any, Callable
import numpy as np
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
from B06_Section.B06_Section_Engine_Design import (
_SLOPE_CLOSE_TOLERANCE_M,
compute_cross_design,
curve_widening_args,
)
from common_util.common_util_json import atomic_write_json
logger = logging.getLogger(__name__)
#: 한 걸음 — 브레인 판정 「+5m 씩」.
EXTEND_STEP_M = 5.0
#: 지표면 끝까지 넓혀도 안 닫힌 측점의 사유(경고가 그대로 씀).
SURFACE_END_REASON = "지표면 끝까지 넓혀도 성토 비탈이 원지반과 안 만남"
def _valid_sorted(samples: list[dict[str, Any]]) -> list[tuple[float, float]]:
return sorted(
(float(s["offset_m"]), float(s["elevation_m"]))
for s in samples
if s.get("valid") is not False and s.get("elevation_m") is not None
)
def _open_ends(samples: list[dict[str, Any]], design: dict[str, Any]) -> tuple[bool, bool]:
"""(우 끝 열림, 좌 끝 열림) — 샘플 끝의 지반과 설계선 높이차가 허용오차를 넘나."""
ground = _valid_sorted(samples)
line = sorted(
(float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or []
)
if not ground or not line:
return False, False
right = abs(ground[0][1] - line[0][1]) > _SLOPE_CLOSE_TOLERANCE_M
left = abs(ground[-1][1] - line[-1][1]) > _SLOPE_CLOSE_TOLERANCE_M
return right, left
def extend_unclosed(
samples: list[dict[str, Any]],
frame: dict[str, Any],
compute: Callable[[list[dict[str, Any]]], dict[str, Any]],
sampler: Any,
step_m: float = EXTEND_STEP_M,
) -> tuple[list[dict[str, Any]], dict[str, Any]] | None:
"""넓힌 샘플과 `{left_m, right_m, surface_end}` — 이미 닫혔으면 `None`."""
design = compute(samples)
if not design.get("slope_unclosed"):
return None
offsets = [offset for offset, _z in _valid_sorted(samples)]
spacing = min(b - a for a, b in zip(offsets, offsets[1:]) if b - a > 1e-9)
count = max(int(round(step_m / spacing)), 1)
origin = np.array([float(frame["origin"]["x"]), float(frame["origin"]["y"])])
left_axis = np.array([float(v) for v in frame["left_xy"]])
info: dict[str, Any] = {"left_m": 0.0, "right_m": 0.0, "surface_end": False}
samples = list(samples)
while design.get("slope_unclosed"):
right_open, left_open = _open_ends(samples, design)
added: list[dict[str, Any]] = []
for side, is_open, sign in (("right", right_open, -1.0), ("left", left_open, 1.0)):
if not is_open:
continue
edge = min(offsets) if sign < 0 else max(offsets)
new_offsets = np.array([edge + sign * spacing * k for k in range(1, count + 1)])
xy = origin[None, :] + left_axis[None, :] * new_offsets[:, None]
z, valid = sampler.sample_xy(xy)
if not np.all(valid):
info["surface_end"] = True
continue
added.extend(
{
"offset_m": round(float(o), 6),
"x": round(float(p[0]), 6),
"y": round(float(p[1]), 6),
"z": round(float(e), 6),
"elevation_m": round(float(e), 6),
"valid": True,
}
for o, p, e in zip(new_offsets, xy, z)
)
info[f"{side}_m"] += step_m
if not added:
break
samples = sorted(samples + added, key=lambda s: float(s["offset_m"]))
offsets = [offset for offset, _z in _valid_sorted(samples)]
design = compute(samples)
return samples, info
def extend_unclosed_sections(
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
project_root: Path,
standard: dict[str, Any] | None,
sampler: Any,
) -> int:
"""저장 설계가 미교차인 측점을 넓혀 샘플·설계를 자리에서 갈고 횡단 파일에 남긴다. 넓힌 수."""
from B06_Section.B06_Section_Router_Design import (
USER_TOUCHED_KEYS,
ford_drop_at,
ford_surface_drops,
stored_berm,
stored_cut_slope,
)
from common_util.common_util_route_profile import design_elevation_from_longitudinal
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
if sampler is None:
return 0
drops = ford_surface_drops(project_root)
cross_dir = project_root / "B06_Section" / "cross_sections"
changed = 0
for section in cross_sections:
design = section.get("design")
if not isinstance(design, dict) or not design.get("slope_unclosed"):
continue
chainage = float(section.get("chainage_m", 0.0))
def compute(samples: list[dict[str, Any]], design=design, chainage=chainage) -> dict:
return compute_cross_design(
samples,
design_elevation_from_longitudinal(longitudinal, chainage),
ground_type=str(design.get("ground_type") or "ripping_rock"),
section_mode=str(design.get("section_mode") or "left_cut"),
ditch_side=design.get("ditch_side"),
ditch_type=str(design.get("ditch_type") or "standard"),
paved=bool(design.get("paved", False)),
standard=standard,
rock_boundary_offset_m=design.get(
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
),
two_stage_slope=bool(design.get("two_stage_slope", True)),
cut_slope_ratio=stored_cut_slope(design),
ditch_enabled=design.get("ditch_enabled"),
ditch_choice=design.get("ditch_choice"),
surface_drop_m=ford_drop_at(chainage, drops),
berm=stored_berm(design),
**curve_widening_args(section),
)
try:
result = extend_unclosed(
section.get("samples") or [], section["frame"], compute, sampler
)
except (ValueError, KeyError):
continue
if result is None:
continue
samples, info = result
if not info["left_m"] and not info["right_m"]:
continue # 첫 걸음부터 지표면 밖 — 샘플이 그대로라 갈 것이 없음
recomputed = compute(samples)
for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS):
if design.get(key) is not None:
recomputed[key] = design[key]
section["samples"] = samples
section["design"] = recomputed
cross_path = cross_dir / cross_filename(chainage)
if cross_path.is_file():
stored = json.loads(cross_path.read_text(encoding="utf-8"))
atomic_write_json(cross_path, {**stored, "samples": samples})
logger.info(
"미교차 샘플 넓힘: 측점 %.3f 좌 +%sm 우 +%sm 지표면 끝 %s",
chainage,
info["left_m"],
info["right_m"],
info["surface_end"],
)
changed += 1
return changed
+22 -11
View File
@@ -83,6 +83,8 @@ if (input.haul_plan_for) {
structure_spoil_points: input.context?.structure_spoil_points ?? null,
// 잔토는 자연상태로 오고 곡선은 다짐상태다 — 담기 전에 ×C 하는 데 쓴다.
conversion: input.context?.earthwork_conversion ?? null,
// 화면이 그리는 계획 — 잔진동을 거른다(수량은 저장 정본 `haul_plan` 이 따로 낸다).
drawing: true,
});
writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null }));
process.exit(0);
@@ -104,18 +106,27 @@ const result = conversion
)
: null;
// 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06).
const plan = result
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? null,
conversion: conversion ?? null,
})
: null;
// 두 벌을 남긴다 — `haul_plan` 은 **거르지 않은** 수량 정본(B08), `haul_plan_drawing` 은
// 잔진동을 거른 그림(B07 토적도). 거르기가 수량에 닿으면 운반이 사라진다(2026-09-14 브레인 ①).
const planFor = (drawing: boolean) =>
result
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? null,
conversion: conversion ?? null,
drawing,
})
: null;
const plan = planFor(false);
const drawingPlan = planFor(true);
const massHaul = result
? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null)
? massHaulPayload(result, {
...(plan ? { haul_plan: haulPlanPayload(plan) } : {}),
...(drawingPlan ? { haul_plan_drawing: haulPlanPayload(drawingPlan) } : {}),
})
: null;
// 선 다단 벽 목록(④) — 관 연장처럼 기하가 세운 결과를 정본에 남겨 B08 이 줄을 세움.
@@ -39,11 +39,12 @@ from B06_Section.B06_Section_Repository import (
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from common_util.common_util_node_bundle import run_bundle_json
from common_util.common_util_project_settings import (
earthwork_conversion_factors,
haul_equipment_limits,
mixed_conversion_factors,
quantity_settings,
)
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from config.config_db import get_db_pool, run_with_connection
from config.config_system import (
EARTHWORK_CONVERSION_FACTORS,
@@ -96,7 +97,8 @@ async def conversion_factors_for(project_id: Any) -> dict[str, dict[str, float]]
except Exception:
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 계수로 진행: project_id=%s", project_id)
return {kind: dict(entry) for kind, entry in EARTHWORK_CONVERSION_FACTORS.items()}
return earthwork_conversion_factors(quantity_settings(root))
# 암은 구성비 가중 C(㉱ (나)) — 토적표·운반표와 같은 함수.
return mixed_conversion_factors(quantity_settings(root))
async def haul_limits_for(project_id: Any) -> list[tuple[str, float | None]]:
@@ -153,23 +155,43 @@ def _mass_haul_context(
}
def _surface_sampler(project_root: Path, params: dict[str, Any] | None) -> Any:
"""확정 지표면 표고 조회기 — 못 열면 `None`(넓힘을 건너뛰고 종전대로 · 비치명)."""
from common_util.common_util_surface_sampler import build_surface_sampler
try:
return build_surface_sampler(
project_root / "B04_PreProcess" / "models",
str((params or {})["source_filter"]),
str((params or {})["method"]),
bool((params or {})["smooth"]),
)
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
logger.warning("서버 재계산: 확정 지표면을 못 열어 미교차 넓힘을 건너뜀 — %s", exc)
return None
def _enforce_stored_designs(
longitudinal: dict[str, Any],
sections: list[dict[str, Any]],
project_root: Path,
standard: dict[str, Any] | None,
sampler: Any = None,
) -> None:
"""저장분 설계를 **쓰는 시점에** 바로잡는다 — 포장 구간·세월교 노면 하강.
예전에는 상세를 **읽을 때마다** 돌려 화면이 볼 때만 맞았다(저장분은 낡은 채로).
2026-09-06 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다.
"""
from B06_Section.B06_Section_Engine_SampleExtend import extend_unclosed_sections
from B06_Section.B06_Section_Engine_SpoilFill import enforce_spoil_fills
from B06_Section.B06_Section_Router_Design import (
enforce_ford_surface_drops,
enforce_pavement_ranges,
)
# ⚠ 미교차 샘플 넓힘이 **맨 앞**이다 — 뒤의 보정들이 넓힌 지반 위에서 다시 계산해야 한다(㉳ (가)).
extend_unclosed_sections(longitudinal, sections, project_root, standard, sampler)
enforce_pavement_ranges(longitudinal, sections, project_root, standard)
enforce_ford_surface_drops(longitudinal, sections, project_root, standard)
# ⚠ 사토장은 **맨 뒤**다 — 앞의 두 보정이 설계를 다시 계산하면서 사토장 칸을 지운다.
@@ -197,11 +219,12 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
pool = get_db_pool()
# 구조물 몫(채집석 공제·구조물 잔토)도 함께 받아 온다 — **B08 이 낸 값**이고, 안 넘기면
# 통로만 있고 값이 안 흐른다(2026-09-09 실측: 공제가 늘 `None` 이라 사토가 안 줄었다).
response, stored_path, longitudinal_row, haul_inputs = await asyncio.gather(
response, stored_path, longitudinal_row, haul_inputs, surface = await asyncio.gather(
get_section_detail(project_uuid, route_id),
run_with_connection(get_project_storage_relative_path, project_uuid),
run_with_connection(get_longitudinal_section, project_uuid, route_id),
haul_inputs_for(project_uuid),
run_with_connection(get_surface_confirmation_params, str(project_uuid)),
)
marks.append(("종횡단 상세+DB 조회(병렬)", time.perf_counter()))
payload = getattr(response, "model_dump", None)
@@ -216,8 +239,16 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
standard = stored_standard_cross_section(longitudinal_row)
# 포장 구간·세월교 보정 — 고쳐진 설계 위에서 면적·유토곡선이 나와야 한다.
before = [json.dumps(item.get("design"), sort_keys=True, default=str) for item in sections]
# 확정 지표면 — 미교차 측점이 있을 때만 연다(샘플 넓힘 ㉳ (가) · 없으면 여는 비용을 안 씀).
unclosed = any((item.get("design") or {}).get("slope_unclosed") for item in sections)
sampler = await asyncio.to_thread(_surface_sampler, project_root, surface) if unclosed else None
await asyncio.to_thread(
_enforce_stored_designs, detail.get("longitudinal") or {}, sections, project_root, standard
_enforce_stored_designs,
detail.get("longitudinal") or {},
sections,
project_root,
standard,
sampler,
)
fixed = [
item
@@ -235,7 +266,7 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
"detail": detail,
"context": _mass_haul_context(
haul_inputs,
earthwork_conversion_factors(quantity_settings(project_root)),
mixed_conversion_factors(quantity_settings(project_root)),
haul_equipment_limits(quantity_settings(project_root)),
),
},
@@ -520,7 +520,9 @@ def build_mass_haul_drawing(
if curve_entity:
entities.append(curve_entity)
plan = mass_haul.get("haul_plan")
# 그림은 잔진동을 거른 계획을 그린다 — 수량 정본(`haul_plan`)은 거르지 않아 balloon 이
# 너무 많다(2026-09-14 브레인 ①). 옛 저장분은 그림용이 없어 `haul_plan` 을 그린다.
plan = mass_haul.get("haul_plan_drawing") or mass_haul.get("haul_plan")
if isinstance(plan, dict):
entities.extend(_band_entities(drawing_id, plan, curve, mm_h))
entities.extend(_residual_entities(drawing_id, plan, curve, interval_m, mm_h))
@@ -648,8 +648,10 @@ def build() -> dict[str, Any]:
basis_found += 1
if basis_quantity_is_grouped(basis_qty):
basis_grouped += 1
elif form in ("requirement", "productivity"):
elif form in ("requirement", "productivity") and not crew_table(table):
# 참조·계수표는 곱할 값이 아니므로 목록에 넣지 않는다 — 잡음이 되면 안 본다.
# 작업조 표도 뺌 — 밑수가 시공량 열(1단위당 = 인원 ÷ 시공량 · B09 CrewOutput)이라
# 「N㎡당」 문구가 없어도 곱셈이 안 틀림(12-38-3 설치·해체가 여기 걸려 막혀 있었음 · 2026-09-14 301).
basis_missing.append(
{
"pum_table_id": table["table_id"],
@@ -110,6 +110,11 @@ FORM_JUDGMENTS: dict[str, tuple[str, str, str]] = {
# 절 제목으로 읽어 12-2 에 붙어 있던 표(빌더가 앞 절 이어받기로 고침). 형태 판정은 그대로.
"F0358": ("12-17-1", "reference", "시설유형 Type-Ⅰ~Ⅳ 적용 기준 설명"),
"F0360": ("12-17-1", "reference", "현장조건 Type-Ⅰ~Ⅲ 적용 기준 설명"),
"F0385": (
"12-34-1",
"requirement",
"인력(인)·기계(대, Q=5.4㎥/hr) 소요량 표 — 「별도계상」 은 레미콘 자재 줄 비고일 뿐",
),
"F0388": ("12-34-4", "reference", "「재료비 JOINT FILLER」 항목만 — 값이 없는 구성 안내"),
"F0390": ("12-36", "reference", "제작비·운송비·설치비 「견적처리」"),
"F0392": ("12-38-1", "coefficient", "사용횟수별 잔존율(12회 25%·25회 10%)"),
@@ -11,7 +11,7 @@
⚠ 못 가른 표는 **빈칸** — 자원을 못 맞춘 표 · 계수 · 참조 · 미판정 표에 갈래를 지어내지 않는다.
공종마다 `variant_keys` — 표들의 갈래 + 원문이 정했으나 자원 줄로 안 서는 갈래
(불도저 운반 공식 갈래 · 합산형 단계 잎의 암질 행 · 9-4-1 [주]① 평균) · 합산형 부모는 단계 갈래.
(불도저 운반 공식 갈래 · 합산형 단계 잎의 암질 행 · 9-4-1 [주]① 평균 · 유로폼 12-38-2 부자재 요율 머리) · 합산형 부모는 단계 갈래.
"""
from __future__ import annotations
@@ -28,6 +28,9 @@ def _add(labels: list[str], seen: set[str], label: str, normalize: Any) -> None:
def attach_variant_keys(nodes: list[dict[str, Any]], edition: str) -> None:
"""표마다 `variant_key`, 공종마다 `variant_keys` 를 채운다(자리에서)."""
from B09_Estimation.B09_Estimation_Euroform import CODE as EUROFORM_CODE
from B09_Estimation.B09_Estimation_Euroform import RATE_TABLE as EUROFORM_RATE_TABLE
from B09_Estimation.B09_Estimation_Euroform import rates as euroform_rates
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import extract_dozer_factors
from B09_Estimation.B09_Estimation_ParentSteps import AVERAGE_BASIS, AVERAGE_VARIANT, rock_rows
from B09_Estimation.B09_Estimation_ResourceAxis import build_resource_axis
@@ -62,6 +65,9 @@ def attach_variant_keys(nodes: list[dict[str, Any]], edition: str) -> None:
if code in steps:
for rock, _, _ in rock_rows({"tables": [table]}):
_add(labels, table_seen, rock, normalize)
if code == EUROFORM_CODE and table.get("pum_table_id") == EUROFORM_RATE_TABLE:
for head in euroform_rates({"tables": [table]}): # 부자재 요율 머리 갈래
_add(labels, table_seen, head, normalize)
table["variant_key"] = labels
for label in labels:
_add(keys, seen, label, normalize)
@@ -52,6 +52,9 @@ FACE_DRESSING_FILL_SUGGESTED = (
"영월 실무 설계내역 「성토사면고르기 06M3 B/H」(백호 = 무한궤도 굴착기) · 임도는 산지라"
" 타이어식이 잘 안 들어감 — 타이어식도 고를 수 있음(2026-09-14 브레인 ②)",
)
#: 초류종자살포 비탈면 토질 — 품셈 5-24 씨앗뿜어붙이기의 잎 둘(5-24-1 일반 · 5-24-2 마사토) 이름 그대로.
#: 매핑 `leaf_from` 이 잎 코드로 잇고 비면 금액 없이 사유 · 제안값 없음(2026-09-14 브레인 ㉮).
SEED_SPRAY_GROUNDS = ("일반", "마사토")
#: 지장목제거 뿌리뽑기(9-21 제근) 굴착기 크기 — 품셈 9-21 표 갈래 0.2·0.7(무한궤도). 등급과 한 갈래.
#: ⚠ 제안값은 칸 곁에만(스스로 안 고름 · 비면 금액 없이 사유 — 2026-09-14 브레인 판정).
ROOT_REMOVAL_EXCAVATOR_SIZES = ("0.2", "0.7")
@@ -116,6 +119,17 @@ class SummaryInput:
subgrade_compaction_enabled: bool = False
# 면고르기 면적 덮어쓰기 `{fill, cut}`(㎡) — 비우면 파종 면적(판정 Ⓐ).
face_dressing_area_m2: dict[str, float | None] = field(default_factory=dict)
# 토취(반입토) — 유토곡선이 낸 성토 부족분(다짐상태 ㎥)과 구간(2026-09-14 브레인 ①).
borrow_m3: float = 0.0
borrow_sites: list[dict[str, Any]] = field(default_factory=list)
#: 토취(반입토) 줄 — 수량만 서고 금액은 사유(「줄은 서고 금액은 안 섬」).
BORROW_NAME = "토취(반입토)"
BORROW_REASON = (
"유토곡선이 성토 부족분으로 낸 수량(다짐상태) — 토취장 거리·재료 미정이라 금액이 서지 않음"
" · 반입 재료를 몰라 자연상태로 되돌리지 않음"
)
#: 노체다짐 줄 — ⭐ 2026-09-13 판정 「별도 줄 · 칸으로 켜고 끔 · 기본 꺼짐」.
@@ -188,6 +202,16 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
)
)
if source.borrow_m3 > 0:
sites = " · ".join(
f"{float(s['from_m']):g}~{float(s['to_m']):g}m {float(s['volume_m3']):,.2f}"
for s in source.borrow_sites
)
note = f"{BORROW_REASON} · 구간 {sites}" if sites else BORROW_REASON
rows.append(
SummaryRow(group=BORROW_NAME, amount=source.borrow_m3, note=note, in_bill=False)
)
# ── 운반 — 수단별. 무대는 집계에 오르되 내역 줄이 아니다 ────────
rows.extend(_haul_rows(source))
@@ -373,7 +397,7 @@ def _haul_rows(source: SummaryInput) -> list[SummaryRow]:
for item in source.haul_rows:
key = str(item.get("equipment") or "")
label = HAUL_LABELS.get(key, key or "운반")
ground = str(item.get("ground") or "")
ground = str(item.get("rock_class") or item.get("ground") or "")
distance = item.get("average_distance_m")
note = f"평균운반거리 {float(distance):.2f} m" if isinstance(distance, (int, float)) else ""
# ⚠ **자연상태로 싣는다** — 「운반거리 산정은 다짐상태, 내역서 수량은 자연상태」
+4 -2
View File
@@ -105,15 +105,17 @@ def annotate(
component["reuse_note"] = NOTE_REUSE_MISSING
continue
count = entry.get("reuse_count")
# 근거 — 대개 1-7-1 분류 · 그 공종 표가 직접 적었으면 그 표(집수정 12-15 · 2026-09-14).
basis = str(entry.get("basis") or "품셈 1-7-1")
for component in targets:
component["reuse_count"] = count
component["reuse_note"] = (
f"품셈 1-7-1 {count}회 — 「{entry.get('matched_example')}"
f"{basis} {count}회 — 「{entry.get('matched_example')}"
if count
else NOTE_NOT_APPLICABLE
)
if count:
notes.append(f"{structure.get('name') or type_id} 거푸집 {count}회 (품셈 1-7-1)")
notes.append(f"{structure.get('name') or type_id} 거푸집 {count}회 ({basis})")
else:
missing.append(type_id)
return notes, sorted(set(missing))
@@ -116,6 +116,7 @@ def build_handoff(
face_dressing_cut_class: str | None = None,
face_dressing_fill_class: str | None = None,
root_removal_excavator_m3: str | None = None,
seed_spray_ground: str | None = None,
priced_sheets: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.
@@ -137,6 +138,8 @@ def build_handoff(
"face_dressing_fill_class": face_dressing_fill_class,
# 제근 굴착기 크기 — 등급과 「크기·등급」 한 갈래로(매핑 `variant_template` · 09-14).
"root_removal_excavator_m3": root_removal_excavator_m3,
# 초류종자살포 비탈면 토질 — 부모 5-24 의 잎(일반·마사토)을 고름(매핑 `leaf_from` · 09-14 ㉮).
"seed_spray_ground": seed_spray_ground,
}
rows, misses = _earthwork_rows(
summary_table, table, methods, bench_cut_depth_m, variant_inputs
@@ -38,11 +38,11 @@ def _pickers(structure: dict[str, Any], mapping: WorkItemMapping) -> list[tuple[
"""이 구조물의 성분을 **이름으로 집는** 공종 줄 — (자리, 이름들). 집는 조건은 각 빌더와 같다."""
type_id = str(structure.get("type_id") or "")
entry = mapping.for_structure(type_id) or {}
composite = mapping.composite_for(type_id)
composite = mapping.composite_for(type_id, structure)
found: list[tuple[str, set[str]]] = []
if entry.get("billing_component"):
found.append(("구조물 줄", {str(entry["billing_component"])}))
if composite and not entry.get("work_item_code"):
if composite: # 걸린 묶음은 곧장 잇는 코드보다 위(빌더와 같은 조건)
for part in composite.get("parts") or []:
if isinstance(part, dict):
label = f"묶음 조각 「{part.get('name') or part.get('code')}"
@@ -146,10 +146,16 @@ BLOCKED_UNCONFIRMED = "unconfirmed"
#: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다
#: (지장목제거의 「뿌리뽑기·잡관목제거」). 갈래로 읽으면 「시공법 미지정」이라는 **틀린 사유**가
#: 붙는다(2026-09-09 실측). 정의처는 `EarthworkSummary` 이고 여기서 그대로 가져다 쓴다.
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
BORROW_NAME,
)
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
GROUND_SPLIT_GROUPS as GROUND_SPLIT_GROUPS,
)
#: 코드 없이 **수량만** 서는 집계 줄 — 막힘 사유는 집계 비고 그대로(토취 · 2026-09-14 브레인 ①).
SUMMARY_ONLY_GROUPS = frozenset({BORROW_NAME})
SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"})
#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다.
@@ -271,13 +277,20 @@ class WorkItemMapping:
return row
return None
def composite_for(self, type_id: str) -> dict[str, Any] | None:
def composite_for(
self, type_id: str, structure: dict[str, Any] | None = None
) -> dict[str, Any] | None:
"""품셈에 그 이름의 공종이 없어 **여러 공종을 묶는** 자리인가.
코드로 두면 매핑을 찾은 구별이 된다 묶음을 적어 구별한다.
"""
options = (structure or {}).get("options") or {}
for row in self.composite.get("items") or []:
if row.get("type_id") == type_id:
# `when` — 같은 종류라도 그 제원일 때만 묶음(콘크리트 집수정만 12-15 조립 · 09-14 ⑸).
when = row.get("when") or {}
if row.get("type_id") == type_id and all(
str(options.get(key) or "") == str(value) for key, value in when.items()
):
return row
return None
@@ -382,6 +395,30 @@ def composite_quantities(
entry["not_ready"] = True
entry["why"] = why
missing.append({"code": spec.get("code"), "reason": why})
if suffix == "formwork_reuse":
# 12-4 사용횟수 갈래 — B08 이 성분에 단 횟수(`Formwork.annotate`) 그대로(한 벌 · 09-14).
counts = {
component.get("reuse_count")
for component in structure.get("components") or []
if str(component.get("name") or "").strip() in found
}
count = next(iter(counts)) if len(counts) == 1 else None
if isinstance(count, int) and count > 0:
entry["kind"] = f"{count}"
entry["kind_basis"] = next(
(
str(component.get("reuse_note") or "")
for component in structure.get("components") or []
if str(component.get("name") or "").strip() in found
),
"",
)
entry["code"] = f"{spec.get('code')}#{count}"
elif found:
why = "거푸집 사용횟수가 없거나 둘 이상이라 12-4 갈래를 못 고름"
entry["not_ready"] = True
entry["why"] = why
missing.append({"code": spec.get("code"), "reason": why})
if suffix == "rebar_complexity":
# 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다.
complexity, why = rebar_complexity(
@@ -459,10 +496,12 @@ def rebar_complexity(
continue
if row.get("form") == form:
if row.get("class"):
return str(row["class"]), f"품셈 12-3 [주]①{row.get('matched')}"
basis = row.get("basis") or "품셈 12-3 [주]①"
return str(row["class"]), f"{basis}{row.get('matched')}"
return None, str(row.get("why") or "원문 예시에 없음")
if fallback and fallback.get("class"):
return str(fallback["class"]), f"품셈 12-3 [주]①{fallback.get('matched')}"
basis = fallback.get("basis") or "품셈 12-3 [주]①" # 그 공종 표가 직접 적으면 그 표(12-15)
return str(fallback["class"]), f"{basis}{fallback.get('matched')}"
from B08_Quantity.B08_Quantity_Wording import type_label
detail = f"({form})" if form else "(형식이 아직 입력되지 않음)"
@@ -28,6 +28,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
ORIGIN_STRUCTURE,
SLOPE_GROUPS,
SUBTOTAL_GROUPS,
SUMMARY_ONLY_GROUPS,
WorkItemMapping,
composite_quantities,
masonry_class,
@@ -164,15 +165,18 @@ def _earthwork_rows(
template_ready = all(inputs.get(name) for name in needs)
if template and template_ready:
variant_value = template.format(**inputs)
leaf_from = str((entry or {}).get("leaf_from") or "")
leaf_codes = (entry or {}).get("leaf_codes") or {}
leaf = leaf_codes.get(inputs.get(leaf_from)) if leaf_from else None
code = leaf or code
missing = not leaf if leaf_from else (not variant_value or not template_ready)
# 매핑이 「이 칸이 비면 못 고름」이라 적은 갈래 — 금액 없이 입력 사유(면고르기 · 09-14 Ⓒ).
if (
code
and (not variant_value or not template_ready)
and (entry or {}).get("variant_missing_reason")
):
if code and missing and (entry or {}).get("variant_missing_reason"):
blocked_kind = blocked_kind or BLOCKED_INPUT_MISSING
blocked_reason = blocked_reason or str(entry["variant_missing_reason"])
if code is None and not is_subtotal:
if group in SUMMARY_ONLY_GROUPS: # 토취 — 코드 없이 수량만 · 사유는 집계 비고(브레인 ①)
blocked_kind, blocked_reason = BLOCKED_INPUT_MISSING, str(row.get("note") or "")
elif code is None and not is_subtotal:
label = f"{group}({ground})" if ground else group
unmatched.append(f"{label}{method_note}" if method_note else label)
if blocked_kind is None:
@@ -242,10 +246,13 @@ def _haul_rows(
# ⚠ 코드가 없으면 **줄에 막힘 표시를 단다**(2026-09-09) — 목록에만 실으면 줄 단위로
# 보는 쪽이 「멀쩡한 줄」로 읽어 금액이 조용히 빠진다(도자운반·덤프운반이 그랬다).
# ⚠ `in_bill` 이 False 인 무대 줄은 **막힌 것이 아니다** — 품에 포함이라 안 세우는 것.
haul_blocked = BLOCKED_UNIT_DATA_MISSING if (code is None and in_bill) else None
haul_blocked_reason = (
f"운반({equipment})의 품셈 공종을 아직 못 이었습니다" if haul_blocked else ""
no_code = code is None and in_bill # 암 줄 막힘은 운반표가 구성비로 가르며 단 것(㉱)
haul_blocked = (
BLOCKED_UNIT_DATA_MISSING if no_code else in_bill and row.get("blocked_kind") or None
)
haul_blocked_reason = str(in_bill and row.get("blocked_reason") or "")
if no_code:
haul_blocked_reason = f"운반({equipment})의 품셈 공종을 아직 못 이었습니다"
# ⚠⚠ **내역서 수량은 자연상태다** — 유토곡선은 다짐상태로 쌓고(운반거리를 그 기준으로
# 재야 맞는다) 내역에 오르는 수량은 되돌린 값이다(`config_system_design` 5-4-3
# 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는
@@ -270,7 +277,9 @@ def _haul_rows(
{
"work_item_code": code,
"name": f"{equipment} 운반",
"spec": str(row.get("ground") or ""),
"spec": " · ".join(
dict.fromkeys(filter(None, (row.get("rock_class"), row.get("ground"))))
),
"unit": "",
"quantity": quantity,
# 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다).
@@ -433,7 +442,11 @@ def _structure_rows(
# m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는
# 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다.
billing = _component_billing(structure, entry)
composite = mapping.composite_for(type_id) if code is None else None
composite = mapping.composite_for(type_id, structure)
if composite:
code = None # 묶음이 걸리면 곧장 잇는 코드보다 위(콘크리트 집수정 12-15 조립 · 09-14)
if composite.get("outside_note"):
class_basis = " · ".join(p for p in (class_basis, composite["outside_note"]) if p)
kind = structure_kind(structure) if composite else None
parts: list[dict[str, Any]] | None = None
parts_missing: list[dict[str, Any]] = []
@@ -600,7 +613,7 @@ def _placing_rows(
# 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 —
# 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.**
# ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다.
if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get(
if mapping.composite_for(str(structure.get("type_id") or ""), structure) or structure.get(
"unconfirmed"
):
continue # 기본값으로 선 구조물도 — 금액에 안 듦
@@ -24,6 +24,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
WorkItemMapping,
)
from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import LOADING_EQUIPMENT
from B08_Quantity.B08_Quantity_Engine_RockSplit import split_amounts
SPOIL_NAME = "사토 운반"
#: ⚠ **사토장 사면 물량은 안 센다**(2026-09-09 세 창 확인). 교본 6장 3절은 「완료 구간 비탈면을
@@ -98,20 +99,28 @@ def spoil_haul_rows(
unknown = float(spoil.get("ground_unknown_m3") or 0.0)
if by_ground or unknown > 0:
rows: list[dict[str, Any]] = []
for label, amount in sorted(by_ground.items()):
leg = by_distance.get(label)
# 암은 운반표와 같은 구성비 몫으로(자리표시 리핑암을 값으로 안 씀 · ㉱ `RockSplit`).
shares = (haul_table or {}).get("rock_shares")
for label, amount, share_reason, rock_class, source in split_amounts(by_ground, shares):
leg = by_distance.get(source)
leg_blocked = blocked if leg is None else False
rows.append(
_spoil_row(
code,
amount,
distance if leg is None else leg,
leg_blocked,
reason if leg_blocked else "",
leg_blocked or bool(share_reason),
share_reason or (reason if leg_blocked else ""),
note,
ground=label,
extra=" · ".join(
(DISTANCE_FROM_SETTING if leg is None else DISTANCE_FROM_SITE, state_note)
part
for part in (
f"암 갈래 {rock_class}" if rock_class else "",
DISTANCE_FROM_SETTING if leg is None else DISTANCE_FROM_SITE,
state_note,
)
if part
),
)
)
@@ -166,7 +166,7 @@ def rubble_base_rows(
total = 0.0
bases: list[str] = []
for structure in unit_quantity_table.get("structures") or []:
if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get(
if mapping.composite_for(str(structure.get("type_id") or ""), structure) or structure.get(
"unconfirmed"
):
continue # 묶음 조각이 품음 · 기본값으로 선 구조물은 금액에 안 듦
@@ -254,6 +254,8 @@ def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]:
{
"equipment": row["equipment"],
"ground": row["ground"],
# 암 갈래(구성비로 가른 줄 · `RockSplit`) — 집계표 공종 칸이 흙깎기와 같은 이름을 씀.
"rock_class": row.get("rock_class"),
"volume_m3": row["volume_m3"],
"volume_basis": row.get("volume_basis") or "compacted",
"natural_m3": row.get("natural_m3"),
@@ -264,6 +266,22 @@ def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]:
]
def borrow_of(plan: dict[str, Any] | None) -> dict[str, Any] | None:
"""토취(반입토) — 유토곡선 잔량의 부족분과 구간(2026-09-14 브레인 ①). 없으면 `None`.
**다짐상태** 그대로 반입 재료를 몰라 자연상태로 되돌릴 계수가 없음(지어내지 않음).
"""
volume = float((plan or {}).get("borrow_m3") or 0.0)
if volume <= 0:
return None
sites = [
{"from_m": r.get("from_m"), "to_m": r.get("to_m"), "volume_m3": float(r["volume_m3"])}
for r in (plan or {}).get("residuals") or []
if r.get("kind") == "borrow" and float(r.get("volume_m3") or 0.0) > 0
]
return {"volume_m3": volume, "volume_basis": "compacted", "sites": sites}
@dataclass(slots=True)
class HaulCheck:
"""검산 — 무대를 안 내면 이 대조가 죽는다(PLAN 8-7 ㉡)."""
@@ -0,0 +1,110 @@
"""암 운반량을 **구성비로 가르기** — ㉱ (가) (2026-09-14 브레인 판정 · 8-1 사용자 확정).
B06 암은 ** 종류 자리표시** 토사 토글을 끄면 저장값이 `ripping_rock` 되지만 뜻은
이고 갈라 넣는 것은 설계내역 (`B06_Section_UI_Cross_Design.ts` 머리 주석). 그런데 운반표·사토가
이름(리핑암) **값처럼** 넘겨, 깎기 암은 구성비가 비어 막히는데 운반 암은 금액이 섰다.
운반표를 만든 ** **(`Router_Earthwork`)에서 줄을 흙깎기와 **같은 구성비**(`_split_by_rock`)
갈래별 시공법으로 가른다. 운반거리 ·토공집계 운반 ·인계·사토가 모두 표를 읽어 값이 된다.
유토곡선 거리·다짐 부피는 그대로(자리표시 C 쌓은 ) 부피를 몫대로 나눌 뿐이라 검산이 흔들림.
C 까지 구성비로 맞추는 것은 () 차례.
구성비가 비면 줄로 **막는다** 깎기와 같은 사유(지어낸 갈래로 금액을 세우지 않음).
"""
from __future__ import annotations
from typing import Any, Iterable
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput, _split_by_rock
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_INPUT_MISSING,
METHOD_TO_GROUND,
NOTE_METHOD_MISSING,
NOTE_ROCK_RATIO_MISSING,
)
#: 유토곡선이 넘기는 암 이름 — 둘 다 자리표시로 본다(옛 자료 `blasting_rock` 도 「암」).
ROCK_GROUNDS = ("리핑암", "발파암")
#: 몫대로 나누는 수량 칸 — 운반표 줄(`HaulSummary.build_table`).
SHARED_KEYS = ("volume_m3", "natural_m3", "work_m3m")
def rock_shares(
classes: Iterable[str], ratios_pct: dict[str, Any], methods: dict[str, str | None]
) -> list[dict[str, Any]]:
"""갈래별 몫 — `{rock_class, fraction, ground, blocked_reason, note}`. 흙깎기와 같은 안분."""
source = SummaryInput(rock_classes=list(classes), rock_ratios_pct=dict(ratios_pct or {}))
shares = []
for name, fraction, note in _split_by_rock(1.0, source):
ground = "" if name == "" else METHOD_TO_GROUND.get(methods.get(name) or "")
if name == "":
reason = NOTE_ROCK_RATIO_MISSING
else:
reason = "" if ground else NOTE_METHOD_MISSING
shares.append(
{
"rock_class": name,
"fraction": fraction,
"ground": ground or name,
"blocked_reason": reason,
"note": note,
}
)
return shares
def split_rows(
rows: Iterable[dict[str, Any]], shares: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""암 줄을 몫마다 한 줄로 — 토사 줄은 그대로."""
out: list[dict[str, Any]] = []
for row in rows:
if row.get("ground") not in ROCK_GROUNDS:
out.append(row)
continue
for share in shares:
numbers = {
key: row[key] * share["fraction"]
for key in SHARED_KEYS
if isinstance(row.get(key), (int, float))
}
out.append(
{
**row,
**numbers,
"ground": share["ground"],
"rock_class": share["rock_class"],
"rock_split_note": share["note"],
"blocked_kind": BLOCKED_INPUT_MISSING if share["blocked_reason"] else None,
"blocked_reason": share["blocked_reason"],
}
)
return out
def split_amounts(
by_ground: dict[str, float], shares: list[dict[str, Any]] | None
) -> list[tuple[str, float, str, str | None, str]]:
"""갈래별 물량(사토) → `(갈래, 물량, 막힘 사유, 암 갈래, 원래 이름)`. 몫이 없으면 그대로."""
out = []
for label, amount in sorted(by_ground.items()):
if shares and label in ROCK_GROUNDS:
out.extend(
(s["ground"], amount * s["fraction"], s["blocked_reason"], s["rock_class"], label)
for s in shares
)
else:
out.append((label, amount, "", None, label))
return out
def apply_rock_split(
haul: dict[str, Any],
classes: Iterable[str],
ratios_pct: dict[str, Any],
methods: dict[str, str | None],
) -> None:
"""운반표(`rows`)를 자리에서 가르고 사토가 같은 몫을 쓰게 `rock_shares` 를 싣는다."""
shares = rock_shares(classes, ratios_pct, methods)
haul["rows"] = split_rows(haul.get("rows") or [], shares)
haul["rock_shares"] = shares
@@ -19,8 +19,9 @@ from typing import Any
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import TEMPLATE_DIR
from config.config_system import STORAGE_BASE_DIR
#: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터).
TIERS = ("personal", "company", "program")
#: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터). `received` = 동료가 보낸 것(공유 · 개인 단을 안 덮음).
TIERS = ("personal", "received", "company", "program")
RECEIVED_SUBDIR = "library_received"
#: 항목 코드 — 파일 이름이 되므로 모양을 먼저 봄(경로 벗어남 막이).
CODE_PATTERN = re.compile(r"^AX-ST-[0-9a-f]{8}$")
LIBRARY_SUBDIR = "library"
@@ -28,6 +29,9 @@ LIBRARY_SUBDIR = "library"
#: 프로그램 기본 작업본 자리 — 회사 번호 폴더와 안 겹치게 밑줄 이름.
PROGRAM_SUBDIR = "_program"
#: 프로그램 기본 발행본에서 뺄 출처 칸 — 어느 공사인지(2026-09-14 브레인 판정 ②).
ORIGIN_MASKED_KEYS = ("project", "file")
ORIGIN_MASKED_NOTE = "원문에서 뽑음 — 공사명은 발행 시 가림"
def program_library_dir() -> Path:
@@ -52,6 +56,7 @@ def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]:
company = Path(STORAGE_BASE_DIR).resolve() / str(company_id)
if user_id is not None:
dirs["personal"] = company / str(user_id) / LIBRARY_SUBDIR
dirs["received"] = company / str(user_id) / RECEIVED_SUBDIR
dirs["company"] = company / LIBRARY_SUBDIR
dirs["program"] = program_library_dir()
return dirs
@@ -175,9 +180,12 @@ def save_personal(
overrides: dict[str, Any] | None,
unit_price_rows: list[dict[str, Any]] | None = None,
tier: str = "personal",
name: str | None = None,
) -> str:
"""[내 라이브러리에 저장]·발행 — 양식 + 고친 식·줄 조합을 **그 단에 한 벌**로 씀. 코드.
`name` 이름표(화면이 종류 + 제원 요약 제안 · 사용자가 고침 · 10-A ). 비면 양식 이름.
반대 방향(작업본 라이브러리)이라 프로젝트는 바꿈(브레인 판정 ).
같은 종류가 있으면 ** 코드로 덮어씀** 지금은 종류당 하나(판정 ).
`tier` 회사(MASTER)·프로그램 기본(SYSTEM_ADMIN) 발행도 같은 모양(2026-09-14 브레인 승인).
@@ -194,6 +202,12 @@ def save_personal(
for row in overridden_rows(template, overrides)
]
item = {k: v for k, v in template.items() if k != "imported_from"}
if name and name.strip():
item["name"] = name.strip()
if tier == "program" and isinstance(item.get("origin"), dict):
# 모든 회사로 가는 발행본 — 원문 공사명·파일명은 안 실음(원본·회사 단은 그대로 · 브레인 ②③).
kept = {k: v for k, v in item["origin"].items() if k not in ORIGIN_MASKED_KEYS}
item["origin"] = {**kept, "masked": ORIGIN_MASKED_NOTE}
if unit_price_rows is not None:
item["unit_price"] = {**(item.get("unit_price") or {}), "rows": unit_price_rows}
_write(folder, {**item, "code": code, "library_tier": tier, "rows": rows})
@@ -223,3 +237,13 @@ def pin_program_templates(project_root: str | Path) -> int:
import_item(project_root, item, "program")
count += 1
return count
def share_item(item: dict[str, Any], to_folder: Path, sender: dict[str, Any]) -> str:
"""[동료에게 보내기] — 내 개인 단 항목을 동료의 **받음 단**에 복사(개인 단을 안 덮음 · 브레인 판정 ①).
코드는 그대로(다시 보내면 같은 자리를 새로 ) · 출처 공사명도 그대로(같은 회사 · 판정 ).
"""
body = {k: v for k, v in item.items() if k != "imported_from"}
_write(to_folder, {**body, "library_tier": "received", "received_from": sender})
return str(body["code"])
@@ -441,6 +441,8 @@ def apply_templates(
"name": template.get("name"),
"code": template.get("code"),
"imported_from": template.get("imported_from"),
# 발행 확인창이 「○○공사에서 뽑은 것 — 공사명은 빼고 발행」을 보이게(브레인 ②).
"origin_project": (template.get("origin") or {}).get("project"),
}
sheet["formula_sheet"] = body
# 실무 관측값 같은 「대안 후보」 — 값을 바꾸지 않고 칸 옆에 보이기만(판정 Ⓑ).
@@ -422,7 +422,7 @@ def stone_masonry(
notes.append(
f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · "
f"{backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다"
"(소광리는 같은 식에 0.30/0.60 을 씁니다)"
"(소광리는 같은 식에 0.30/0.60 을 씁니다) · 칸을 새로 두는 것은 B05 등록부 몫"
)
# ⚠ 근거 구간 밖은 **값이 서되 그 사실이 보여야 한다** — 값은 계속 나오므로 사유가
# 없으면 아무도 못 본다(2026-09-09 그물 침).
+39 -5
View File
@@ -36,13 +36,23 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import (
FACE_DRESSING_FILL_SUGGESTED,
ROOT_REMOVAL_EXCAVATOR_SIZES,
ROOT_REMOVAL_EXCAVATOR_SUGGESTED,
SEED_SPRAY_GROUNDS,
SummaryInput,
)
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table
from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table
from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping
from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan, summary_input_rows
from B08_Quantity.B08_Quantity_Engine_HaulSummary import (
borrow_of,
check_against_plan,
summary_input_rows,
)
from B08_Quantity.B08_Quantity_Engine_RockSplit import apply_rock_split
from B08_Quantity.B08_Quantity_Router_Earthwork_HaulPlan import HAUL_PLAN_KEYS
from B08_Quantity.B08_Quantity_Router_Earthwork_HaulPlan import (
recompute_haul_plan as _recompute_haul_plan,
)
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table
from B08_Quantity.B08_Quantity_Engine_SlopeLength import road_surface_area, station_slopes
@@ -53,10 +63,11 @@ from common_util.common_util_project_settings import (
application_ratio,
concrete_placing_method,
earthwork_conversion_choices,
earthwork_conversion_factors,
mixed_conversion_factors,
haul_limit_choice,
quantity_settings,
rock_classes,
rock_method,
save_section,
topsoil_target,
)
@@ -95,7 +106,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
)
# ⚠ 설정을 **먼저** 읽는다 — 토량환산계수를 프로젝트가 골랐으면 표가 그 값으로 서야 한다.
settings, project_root = await _project_settings(project_id)
factors = earthwork_conversion_factors(settings)
# 암은 구성비 가중 C(㉱ (나)) — 유토곡선(B06)·운반표와 같은 함수.
factors = mixed_conversion_factors(settings)
table = build_table(_stations(designs), factors)
# 화면이 「무엇을 골랐나 · 품셈 범위 안인가」를 보이는 데 쓴다. 계산에는 안 들어간다.
table["conversion_factor_choices"] = earthwork_conversion_choices(settings)
@@ -114,10 +126,15 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
plan = await _stored_haul_plan(project_id, route_id)
haul = build_haul_table(plan, factors)
# ㉱ (가) 암은 흙깎기와 같은 구성비·시공법으로 가름 — B06 리핑암은 자리표시(8-1 · 2026-09-14 브레인).
classes = rock_classes(settings)
methods = {name: rock_method(settings, name) for name in classes}
apply_rock_split(haul, classes, settings.get("rock_ratios_pct") or {}, methods)
# 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다).
# 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다.
# ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다.
haul["spoil"] = _spoil_of(plan, settings, _spoil_sites(designs))
haul["borrow"] = borrow_of(plan) # 토취(반입토) — 수량만 · 금액은 사유(브레인 ①)
# 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.**
# 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다.
table["pipe_lengths"] = [
@@ -150,6 +167,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
earthwork_totals=table.get("totals") or {},
slope_totals=slope.get("totals") or {},
haul_rows=summary_input_rows(haul),
borrow_m3=(haul["borrow"] or {}).get("volume_m3") or 0.0,
borrow_sites=(haul["borrow"] or {}).get("sites") or [],
rock_classes=rock_classes(settings),
rock_ratios_pct=settings.get("rock_ratios_pct") or {},
application_ratios={
@@ -183,6 +202,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
"basis": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[1],
},
}
# 초류종자살포 비탈면 토질 — 5-24 잎 둘(서버 한 곳) · 제안값 없음(2026-09-14 브레인 ㉮).
table["seed_spray_choices"] = {"choices": list(SEED_SPRAY_GROUNDS)}
# 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨).
structures = await _route_structures(project_id)
table["preparation"] = build_preparation_table(
@@ -237,7 +258,7 @@ _GROUND_KIND_OF = {"ea_m3": "soil", "rr_m3": "ripping_rock", "br_m3": "blasting_
def _compacted_factor(settings: dict[str, Any]) -> dict[str, float]:
"""갈래 칸 ↔ 다짐 환산계수 `C` — 프로젝트가 고른 값이 있으면 그것이 선다."""
factors = earthwork_conversion_factors(settings)
factors = mixed_conversion_factors(settings)
return {key: float(factors[kind]["compacted"]) for key, kind in _GROUND_KIND_OF.items()}
@@ -490,6 +511,8 @@ class QuantitySettingsBody(BaseModel):
face_dressing_cut_class: str | None = None
# 제근 굴착기 크기 — "0.2"·"0.7"(품셈 9-21 갈래). `""` 는 「안 정함」.
root_removal_excavator_m3: str | None = None
# 초류종자살포 비탈면 토질 — "일반"·"마사토"(품셈 5-24 잎). `""` 는 「안 정함」.
seed_spray_ground: str | None = None
face_dressing_fill_class: str | None = None
# 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ).
face_dressing_fill_area_m2: float | None = None
@@ -569,6 +592,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES),
("face_dressing_fill_class", FACE_DRESSING_FILL_CLASSES),
("root_removal_excavator_m3", ROOT_REMOVAL_EXCAVATOR_SIZES),
("seed_spray_ground", SEED_SPRAY_GROUNDS),
):
if key in values and values[key] not in choices:
values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침
@@ -613,6 +637,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
for name, method in values["rock_methods"].items()
if method in ROCK_METHODS
}
before = {key: quantity_settings(root).get(key) for key in HAUL_PLAN_KEYS}
try:
# ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면
# 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리).
@@ -640,7 +665,16 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
status_code=500,
content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."},
)
return JSONResponse(content={"status": "success", "quantity": saved.get("quantity") or {}})
after = quantity_settings(root)
changed = any(before[key] != after.get(key) for key in HAUL_PLAN_KEYS)
recomputed = await _recompute_haul_plan(project_id) if changed else False
return JSONResponse(
content={
"status": "success",
"quantity": saved.get("quantity") or {},
"haul_plan_recomputed": recomputed,
}
)
def _save_quantity(
@@ -0,0 +1,40 @@
"""산출 조건 [저장] 뒤 **운반계획 다시 세우기** — 2026-09-14 브레인 ②(지침 5장).
구성비·시공법·계수·도쟈 한계거리는 B06 유토곡선(운반계획) 밑수다. B08 [저장] 계획을 다시 세우면
B06 다시 저장하기 전까지 토적표( 계수) 운반표( 계획) 갈린다(계수 칸은 전부터 같은 ).
칸이 **바뀐 저장** B06 [저장] 같은 서버 재계산을 부른다(무거워서 다른 저장은 부름).
라우터(`B08_Quantity_Router_Earthwork.py`) 700줄에 닿아 파일로 .
"""
from __future__ import annotations
import logging
from uuid import UUID
from B06_Section.B06_Section_Repository import get_workflow_route_context
from config.config_db import run_with_connection
logger = logging.getLogger(__name__)
#: 운반계획의 밑수가 되는 산출 조건 칸.
HAUL_PLAN_KEYS = (
"rock_class_set",
"rock_classes",
"rock_ratios_pct",
"rock_methods",
"conversion_factors_override",
"dozer_haul_limit_m",
)
async def recompute_haul_plan(project_id: UUID) -> bool:
"""B06 [저장]과 같은 서버 재계산 — 못 하면 저장은 살리고 거짓(화면이 옛 계획임을 앎)."""
from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side
try:
context = await run_with_connection(get_workflow_route_context, project_id)
route_id = int((context or {}).get("route_id") or 0)
return bool(route_id) and await recompute_server_side(project_id, route_id) >= 0
except Exception:
logger.exception("B08 저장 뒤 운반계획 재계산 실패: project_id=%s", project_id)
return False
@@ -499,6 +499,8 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
face_dressing_fill_class=settings.get("face_dressing_fill_class") or None,
# 제근 굴착기 크기(0.2·0.7) — 비면 뿌리뽑기 줄이 입력 사유로 막힘(제안 0.7 은 칸 곁에만).
root_removal_excavator_m3=settings.get("root_removal_excavator_m3") or None,
# 초류종자살포 비탈면 토질 — 부모 5-24 의 잎을 고름. 비면 그 줄이 입력 사유로 막힘(09-14 ㉮).
seed_spray_ground=settings.get("seed_spray_ground") or None,
# 구조물도 양식 일위대가로 셀 장 — 그 구조물은 호표 `AX-ST` 줄 하나로(PLAN 6장 ②).
priced_sheets=_priced_sheets(project_root, unit_table, modes, settings),
)
@@ -121,7 +121,7 @@ class LibraryCloneRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
type_id: str = Field(min_length=1, max_length=100)
tier: Literal["company", "program"]
tier: Literal["received", "company", "program"]
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
@@ -149,3 +149,71 @@ async def clone_library_item(
body["cloned_from"] = {"tier": payload.tier, "code": payload.code, "name": item.get("name")}
code = await asyncio.to_thread(save_item_personal, folder, body)
return JSONResponse(content={"status": "success", "code": code, "name": item.get("name")})
async def _company_members(company_id: int) -> list[dict[str, Any]]:
"""같은 회사 구성원 — B01 구성원 저장소를 읽기만(시험이 갈아 끼움)."""
from B01_Dashboard.B01_Dashboard_Repository_Members import list_company_members
return await list_company_members(company_id)
@router.get("/{project_id}/quantity/structure-sheets/library/colleagues")
async def list_library_colleagues(
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
) -> JSONResponse:
"""④ 보낼 동료 — 같은 회사에서 나를 뺀 사람(이름만)."""
company_id = session.get("company_id")
if company_id is None:
return _error(403, "회사에 속한 사용자만 보냅니다.")
members = await _company_members(int(company_id))
colleagues = [
{"id": m["id"], "name": m.get("name") or m.get("email") or ""}
for m in members
if m["id"] != session.get("user_id")
]
return JSONResponse(content={"status": "success", "colleagues": colleagues})
class LibraryShareRequest(BaseModel):
"""내 개인 단 항목 하나를 같은 회사 동료에게."""
model_config = ConfigDict(extra="forbid")
type_id: str = Field(min_length=1, max_length=100)
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
to_user_id: int
@router.put("/{project_id}/quantity/structure-sheets/library/share")
async def share_library_item(
project_id: UUID,
payload: LibraryShareRequest,
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""④ [동료에게 보내기] — 받는 쪽 「받음」 단에 복사(PLAN 4장 공유 · 브레인 판정 ①)."""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
RECEIVED_SUBDIR,
find_item,
share_item,
tier_dirs,
)
company_id = session.get("company_id")
dirs = tier_dirs(company_id, session.get("user_id"))
if "personal" not in dirs:
return _error(403, "회사에 속한 사용자만 보냅니다.")
if payload.to_user_id == session.get("user_id"):
return _error(400, "나에게는 보내지 않습니다.")
members = {m["id"]: m for m in await _company_members(int(company_id))}
target = members.get(payload.to_user_id)
if target is None:
return _error(404, "같은 회사에서 받는 사람을 찾지 못했습니다.")
item = await asyncio.to_thread(find_item, dirs, "personal", payload.code)
if item is None or item.get("type_id") != payload.type_id:
return _error(404, "보낼 내 항목을 찾지 못했습니다.")
me = members.get(session.get("user_id")) or {}
sender = {"user_id": session.get("user_id"), "name": me.get("name") or me.get("email") or ""}
to_folder = dirs["personal"].parents[1] / str(payload.to_user_id) / RECEIVED_SUBDIR
code = await asyncio.to_thread(share_item, item, to_folder, sender)
return JSONResponse(content={"status": "success", "code": code, "to": target.get("name") or ""})
@@ -327,7 +327,7 @@ class LibraryImportRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
type_id: str = Field(min_length=1, max_length=100)
tier: Literal["personal", "company", "program"]
tier: Literal["personal", "received", "company", "program"]
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
@@ -417,6 +417,7 @@ class LibrarySaveRequest(BaseModel):
sheet_key: str
tier: Literal["personal", "company", "program"] = "personal"
name: str | None = Field(default=None, max_length=200) # 이름표(10-A ⑫) · 비면 양식 이름
@router.put("/{project_id}/quantity/structure-sheets/library/personal")
@@ -430,10 +431,7 @@ async def put_structure_library_personal(
프로젝트 작업본은 바꿈 반대 방향(작업본 개인 )이라(판정 ).
"""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
project_templates,
save_personal,
)
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates, save_personal
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY, template_of
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
from common_util.common_util_project_settings import quantity_settings
@@ -461,7 +459,8 @@ async def put_structure_library_personal(
overrides = (settings.get(OVERRIDES_KEY) or {}).get(type_id)
# ⛔ 수동 단가(`MANUAL_KEY`)는 안 넘김 — 프로젝트의 값(브레인 판정).
rows = (settings.get(ROWS_KEY) or {}).get(type_id)
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows, payload.tier)
tier, name = payload.tier, payload.name
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows, tier, name)
return JSONResponse(content={"status": "success", "code": code, "edited": len(overrides or {})})
@@ -100,6 +100,10 @@ export interface QuantitySettings {
ancillary_counts?: Record<string, number | null>;
/** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */
stand_volume_class?: string | null;
/** 제근 굴착기 크기 — `"0.2"`·`"0.7"`(서버 선택지 안) · 비면 「안 정함」. */
root_removal_excavator_m3?: string | null;
/** 초류종자살포 비탈면 토질 — `"일반"`·`"마사토"`(5-24 잎) · 비면 「안 정함」. */
seed_spray_ground?: string | null;
/** 면고르기 갈래(서버 선택지 안) · 면적 덮어쓰기(㎡, 없음 = 파종 면적). */
face_dressing_cut_class?: string | null;
face_dressing_fill_class?: string | null;
@@ -162,6 +166,13 @@ export interface EarthworkTable {
conversion_factor_choices?: Record<string, ConversionFactorChoice>;
/** 면고르기 갈래 선택지 — 품셈 9-19-1 원문 표 두 벌(서버 한 곳). 화면은 그대로 보임. */
face_dressing_choices?: { cut: string[]; fill: string[] };
/** 초류종자살포 비탈면 토질 선택지(5-24 잎 둘) — 서버 한 곳 · 제안값 없음. */
seed_spray_choices?: { choices: string[] };
/** 제근 굴착기 크기 선택지·제안(회색 · [제안값 넣기]) — 서버 한 곳. */
root_removal_excavator_choices?: {
choices: string[];
suggested?: { value: string; basis: string };
};
/** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */
conversion_factor_pumsem_ranges?: PumsemRange[];
/** 도쟈 한계거리 — 지금 값·기본값·근거(서버가 정본). 종무대 20 m 는 규정이라 값만 보인다. */
+20 -7
View File
@@ -42,6 +42,8 @@ import { renderStructureSheets } from "./B08_Quantity_UI_StructureSheet";
import { renderStructureSummary } from "./B08_Quantity_UI_StructureSummary";
import { appendTreeWasteFields } from "./B08_Quantity_UI_Side_TreeWaste";
import { appendFaceDressingFields } from "./B08_Quantity_UI_Side_FaceDressing";
import { appendRootRemovalFields } from "./B08_Quantity_UI_Side_RootRemoval";
import { appendSeedSprayField } from "./B08_Quantity_UI_Side_SeedSpray";
import { appendBenchCutFields } from "./B08_Quantity_UI_Side_BenchCut";
/** locale 헬퍼 */
@@ -109,6 +111,8 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
// `""` 는 기본(노면 + 절토)으로 되돌림 — 서버가 None 으로 둔다.
topsoil_target: draft.topsoil_target,
stand_volume_class: draft.stand_volume_class,
root_removal_excavator_m3: draft.root_removal_excavator_m3,
seed_spray_ground: draft.seed_spray_ground,
// 면고르기 — 갈래 `""`·면적 `null` 도 그대로(「안 정함」·「파종 면적 그대로」로 되돌리는 길).
face_dressing_cut_class: draft.face_dressing_cut_class,
face_dressing_fill_class: draft.face_dressing_fill_class,
@@ -307,6 +311,10 @@ interface DraftSettings {
ancillary_counts: Record<string, number | null>;
// 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①).
stand_volume_class: string;
// 제근 굴착기 크기 — "0.2"·"0.7"(서버 선택지) · `""` 는 「안 정함」. 칸은 `_Side_RootRemoval`.
root_removal_excavator_m3: string;
// 초류종자살포 비탈면 토질 — "일반"·"마사토"(5-24 잎) · `""` 는 「안 정함」. 칸은 `_Side_SeedSpray`.
seed_spray_ground: string;
// 면고르기 — 갈래 둘(서버 선택지) · 면적 덮어쓰기 둘(`null` = 파종 면적). 칸은 `_Side_FaceDressing`.
face_dressing_cut_class: string;
face_dressing_fill_class: string;
@@ -521,13 +529,10 @@ function buildQuantitySidePanel(
}
}
// ── 면고르기 — 밑수가 파종 면적이라 반영률 바로 밑. 선택지는 서버 목록 그대로(칸은 따로 뺀 파일).
appendFaceDressingFields(panel, draft, table?.face_dressing_choices, {
field,
optionalNumberField,
selectField,
hintRow,
});
// ── 초류종자살포 토질 · 면고르기 — 밑수가 파종 면적이라 반영률 바로 밑. 선택지는 서버 목록 그대로.
const sideHelpers = { field, optionalNumberField, selectField, hintRow };
appendSeedSprayField(panel, draft, table?.seed_spray_choices, sideHelpers);
appendFaceDressingFields(panel, draft, table?.face_dressing_choices, sideHelpers);
// ── 표토 두께 — 표토 운반 부피(제거 ㎡ × T)에 씀 · 제거 줄은 ㎡ 라 안 곱함(2026-09-13 「실무대로」) ──
// ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.**
@@ -637,6 +642,12 @@ function buildQuantitySidePanel(
),
);
panel.append(hintRow(L("B08_Quantity_Side_StandVolume_Hint")));
appendRootRemovalFields(panel, draft, table?.root_removal_excavator_choices, {
field,
optionalNumberField,
selectField,
hintRow,
});
// ── 부대시설 개소 — ⚠ **산식으로 만들지 않는다**(확정 13). 넣어야 줄이 선다 ──
panel.append(field(L("B08_Quantity_Side_Ancillary"), ""));
@@ -1021,6 +1032,8 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null,
topsoil_target: (stored.topsoil_target as string) ?? "",
stand_volume_class: (stored.stand_volume_class as string) ?? "",
root_removal_excavator_m3: (stored.root_removal_excavator_m3 as string) ?? "",
seed_spray_ground: (stored.seed_spray_ground as string) ?? "",
face_dressing_cut_class: (stored.face_dressing_cut_class as string) ?? "",
face_dressing_fill_class: (stored.face_dressing_fill_class as string) ?? "",
face_dressing_fill_area_m2: (stored.face_dressing_fill_area_m2 as number | null) ?? null,
@@ -0,0 +1,59 @@
/* =============================================================================
* B08_Quantity_UI_Side_RootRemoval.ts
* (PLAN 10-A · 9-21 64a52df6).
*
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* · ** **(`root_removal_excavator_choices`) .
* . 0.7
* + [ ] ** **( ).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton } from "@ui/ui_template_elements";
import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export interface RootRemovalChoices {
choices: string[];
suggested?: { value: string; basis: string };
}
export function appendRootRemovalFields(
panel: HTMLElement,
draft: { root_removal_excavator_m3: string; dirty: boolean },
choices: RootRemovalChoices | undefined,
h: SideFieldHelpers,
): void {
if (!choices) return;
const row = h.selectField(
L("B08_Quantity_Side_RootRemoval_Label"),
draft.root_removal_excavator_m3,
[
{ value: "", label: L("B08_Quantity_RootRemoval_Unset") },
...choices.choices.map((size) => ({ value: size, label: `${size}` })),
],
(picked) => {
draft.root_removal_excavator_m3 = picked;
draft.dirty = true;
},
);
panel.append(row);
const suggested = choices.suggested;
if (!suggested?.value) return;
panel.append(
h.hintRow(`${L("B08_Quantity_RootRemoval_Suggest")} ${suggested.value}㎥ — ${suggested.basis}`),
createButton({
label: L("B08_Quantity_RootRemoval_FillSuggested"),
variant: "ghost",
onClick: () => {
const select = row.querySelector("select");
if (select) select.value = suggested.value;
draft.root_removal_excavator_m3 = suggested.value;
draft.dirty = true;
},
}),
);
}
@@ -0,0 +1,40 @@
/* =============================================================================
* B08_Quantity_UI_Side_SeedSpray.ts
* · (2026-09-14 ).
*
* (5-24 · ) (·)
* . ( `leaf_from`).
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* ** **(`seed_spray_choices`) · .
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export function appendSeedSprayField(
panel: HTMLElement,
draft: { seed_spray_ground: string; dirty: boolean },
choices: { choices: string[] } | undefined,
h: SideFieldHelpers,
): void {
if (!choices) return;
panel.append(
h.selectField(
L("B08_Quantity_Side_SeedSpray_Label"),
draft.seed_spray_ground,
[
{ value: "", label: L("B08_Quantity_SeedSpray_Unset") },
...choices.choices.map((name) => ({ value: name, label: name })),
],
(picked) => {
draft.seed_spray_ground = picked;
draft.dirty = true;
},
),
h.hintRow(L("B08_Quantity_Side_SeedSpray_Hint")),
);
}
+12 -1
View File
@@ -86,6 +86,8 @@ export interface StructureSheet extends StandardSheetSpec {
name: string;
code?: string | null;
imported_from?: string | null;
/** 뽑아 온 원문 공사명(STmate 고정형) — 프로그램 기본 발행 때 가림. */
origin_project?: string | null;
};
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
formula_sheet?: FormulaSheet;
@@ -260,7 +262,14 @@ function sheetBody(
: "양식 없음(지금 전개)"),
),
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
el("span", "b08-grid__caption", sheet.unit_label),
el(
"span",
"b08-grid__caption",
// 10-A ⑯ 양식은 L=1 로 풂(`replace_with_templates` · m당 양식만) — 규칙을 머리에 드러냄.
sheet.library_item && sheet.unit_label === "m당"
? `m당 — 반올림은 m당 값에 걸고 수량 = m당 × 연장`
: sheet.unit_label,
),
);
// ㉱ 규격 다름 — 미확정과 같은 급(빨간 테두리 + 배지). 금액은 서고 막지 않음.
if (sheet.spec_mismatch) {
@@ -422,6 +431,8 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
sheetKey: sheet.key,
typeId: sheet.library_item.type_id,
currentCode: sheet.library_item.code ?? null,
originProject: sheet.library_item.origin_project,
defaultName: sheet.title,
isDirty: () => dirty,
confirmTake: () => {
const edited = editedRows(sheet);
@@ -171,7 +171,7 @@ export function formulaTable(
saveButton.disabled = !dirty;
discardButton.disabled = !dirty;
status.textContent = dirty
? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림)"
? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림 · 고친 식은 이 양식·프로젝트에 묶여 제원(높이 등)을 바꿔도 따라감)"
: "";
onDirty(dirty);
};
@@ -10,9 +10,16 @@
import { API_BASE_URL } from "@config/config_frontend";
import { buildStmatePanel } from "./B08_Quantity_UI_StructureSheet_Stmate";
const TIER_LABELS: Record<string, string> = { personal: "개인", company: "회사", program: "기본" };
const TIER_LABELS: Record<string, string> = {
personal: "개인",
received: "받음",
company: "회사",
program: "기본",
};
/** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */
const KIND_LABELS: Record<string, string> = { form: "양식형", fixed: "고정형" };
/** 이름표 — 「종류 + 제원 요약」을 미리 채워 두고 고치게 함(10-A ⑫ · 코드는 난수라 이름이 흔들려도 안전). */
const NAME_TAG_HINT = "이름표(종류 + 제원 요약 · 고칠 수 있음 · 비우면 양식 이름):";
interface LibraryItem {
tier: string;
@@ -52,6 +59,10 @@ export interface LibraryPanelOptions {
/** 저장 안 한 식이 있으면 [내 라이브러리에 저장]을 막음 — 저장된 식만 개인 단으로 감. */
isDirty: () => boolean;
onImported: (notes: string[]) => Promise<void>;
/** 이 장 양식이 뽑아 온 원문 공사명 — 프로그램 기본 발행 확인창에 「빼고 발행」을 알림. */
originProject?: string | null;
/** 저장·발행 이름표 제안 — 장 이름(종류 + 제원 요약). */
defaultName: string;
}
/** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */
@@ -88,10 +99,63 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
clone.hidden = true;
/** 목록을 다시 받은 뒤 보일 한 줄 — 받는 동안 상태 줄이 지워져 복제 결과가 사라지지 않게. */
let afterLoad = "";
// 공유 — 내 개인 단 항목을 같은 회사 동료의 「받음」 단으로(브레인 판정 ①). 동료는 누를 때 받음.
const send = document.createElement("button");
send.type = "button";
send.className = "b08-quantity__tab";
send.textContent = "동료에게 보내기";
send.hidden = true;
const who = document.createElement("select");
who.className = "b08-spec__input";
who.hidden = true;
const syncClone = (): void => {
const [tier] = list.value.split("|");
clone.disabled = tier === "personal";
send.disabled = tier !== "personal";
};
send.addEventListener("click", () => {
const [tier, code] = list.value.split("|");
if (tier !== "personal" || !code) return;
void (async () => {
send.disabled = true;
try {
if (who.hidden) {
const { colleagues } = await readJson<{ colleagues: { id: number; name: string }[] }>(
await fetch(libraryUrl(projectId, "/colleagues"), { credentials: "include" }),
);
who.replaceChildren(...colleagues.map((c) => new Option(c.name, String(c.id))));
who.hidden = colleagues.length === 0;
status.textContent = colleagues.length
? "받을 동료를 고르고 한 번 더 누를 것"
: "보낼 동료가 없음";
return;
}
const label = list.selectedOptions[0]?.textContent ?? "";
const name = who.selectedOptions[0]?.textContent ?? "";
if (
!window.confirm(
`${label}」을 ${name}에게 보냄 — 받는 쪽 목록에 「받음」으로 뜸 · 그 사람 것은 안 덮음`,
)
) {
return;
}
await readJson(
await fetch(libraryUrl(projectId, "/share"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type_id: typeId, code, to_user_id: Number(who.value) }),
}),
);
who.hidden = true;
status.textContent = `${name}에게 보냄`;
} catch (error) {
status.textContent = error instanceof Error ? error.message : "보내지 못함";
} finally {
syncClone();
}
})();
});
list.addEventListener("change", syncClone);
clone.addEventListener("click", () => {
const [tier, code] = list.value.split("|");
@@ -147,7 +211,7 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
return option;
}),
);
list.hidden = take.hidden = clone.hidden = items.length === 0;
list.hidden = take.hidden = clone.hidden = send.hidden = items.length === 0;
syncClone();
// 발행 단추 — 서버가 준 권한대로만 보임(회사 = 마스터 · 기본 = 시스템 관리자).
toCompany.hidden = !canPublish?.company;
@@ -212,17 +276,17 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
};
const save = personal("내 라이브러리에 저장", async () => {
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
if (
!window.confirm("이 장의 양식과 고친 식을 내 라이브러리에 저장 — 같은 종류가 있으면 덮어씀")
) {
return "";
}
const tag = window.prompt(
`이 장의 양식과 고친 식을 내 라이브러리에 저장 — 같은 종류가 있으면 덮어씀\n${NAME_TAG_HINT}`,
options.defaultName,
);
if (tag === null) return "";
const result = await readJson<{ edited: number }>(
await fetch(libraryUrl(projectId, "/personal"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sheet_key: sheetKey }),
body: JSON.stringify({ sheet_key: sheetKey, name: tag }),
}),
);
return `내 라이브러리에 저장함${result.edited ? ` · 고친 식 ${result.edited}줄 포함` : ""}`;
@@ -243,15 +307,21 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
const button = personal(label, async () => {
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
const whom = tier === "program" ? "모든 회사가 쓰는 프로그램 기본" : "우리 회사 라이브러리";
if (!window.confirm(`이 장의 양식과 고친 식을 ${whom}에 발행 — 같은 종류가 있으면 덮어씀`)) {
return "";
}
const masked =
tier === "program" && options.originProject
? `\n이 항목은 「${options.originProject}」에서 뽑은 것 — 공사명은 빼고 발행됩니다`
: "";
const tag = window.prompt(
`이 장의 양식과 고친 식을 ${whom}에 발행 — 같은 종류가 있으면 덮어씀${masked}\n${NAME_TAG_HINT}`,
options.defaultName,
);
if (tag === null) return "";
await readJson(
await fetch(libraryUrl(projectId, "/publish"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sheet_key: sheetKey, tier }),
body: JSON.stringify({ sheet_key: sheetKey, tier, name: tag }),
}),
);
return `${whom}에 발행함`;
@@ -265,7 +335,7 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
mine.className = "b08-sheet__actions";
mine.append(save, remove, toCompany, toProgram);
panel.append(title, scope, load, list, take, clone, mine, status);
panel.append(title, scope, load, list, take, clone, send, who, mine, status);
// 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장).
panel.append(
buildStmatePanel({
@@ -196,7 +196,11 @@ export function buildStandardSpecPanel(
slope,
judgedSlope ? `비우면 자동 — 지금 판정값 1:${judgedSlope}` : "비우면 자동으로 판정합니다.",
),
field("야면석 계수", coeff, "비우면 품셈 열을 씁니다."),
field(
"야면석 계수",
coeff,
"비우면 품셈 열을 씁니다. 계수 열을 바꿔도 돌 종류는 그대로(다른 칸)입니다.",
),
field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."),
field("버림 콘크리트", blinding, "비우면 넣습니다(두께 100㎜) — 빼려면 「안 넣음」."),
);
@@ -123,7 +123,7 @@ function render(table: UnitPriceTable): HTMLElement[] {
const head = el(
"p",
"b08-sheet__head",
`일위대가 ${table.code} · ${table.unit}${total} (미리보기 — 내역 금액은 원가계산이 셈)`,
`일위대가 ${table.code} · ${table.unit}${total} (미리보기 — 내역 금액은 원가계산이 셈 · 하위 일위대가는 5단까지 풀고 더 깊거나 돌면 막힘)`,
);
if (table.unconfirmed) {
head.append(el("span", "b08-unit__badge", `미확정 ${table.unconfirmed}`));
@@ -85,6 +85,7 @@ def chosen_conditions(settings: dict[str, Any] | None) -> list[dict[str, str]]:
("fuel_region", "유가 지역(시도코드)"),
("transport_distance_km", "기계 수송 거리(편도 ㎞)"),
("transport_road", "수송 도로 구분"),
("transport_trips", "기계 수송 회수(대수 × 왕복)"),
):
value = str(picked.get(key) or "").strip()
if value:
@@ -444,6 +444,8 @@ def _leaf_row(
row.add_note(
"unit_price_krw", "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다."
)
if form_judgment_note(node.code): # 사람이 가른 표 형태 까닭(10-A ⑭)
row.add_note("unit_price_krw", form_judgment_note(node.code))
reason = "일위대가 없음"
# 관경이 표 밖이면 **무엇을 정해야 하는지**까지 가리킨다.
diameter_note = pipe_diameter_note(node.code, item.variant_value) if children else ""
@@ -595,6 +597,7 @@ _PENDING_FORMULA: dict[str, str] = {}
from B09_Estimation.B09_Estimation_KnownGaps import ( # noqa: E402
form_judgment_note,
known_gap_note,
pipe_diameter_note,
)
+4 -2
View File
@@ -64,7 +64,8 @@ FIELD_HINTS: dict[str, str] = {
"자동 = 요율 데이터 적용 하한(추정금액 1억 이상)일 때 적용 · STmate 의 토목·준설·건축·기타"
" 구분은 현행 제비율(2026-04-13)에서 율이 같아(2.3%) 칸으로 두지 않음"
),
"overhead_class": "임도가 (주)공사·전문공사 어느 쪽인지 정한 규정 없음 — 기본 (주)공사",
# 10-A ② 문구 못박음(2026-09-14 사용자 확정 — SW 규칙).
"overhead_class": "임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음",
"cut_basis": (
"기본 「총공사비 1,000원 미만 버림」 — 근거: 산림청고시 제2025-82호 「금액의 단위표준」"
"(설계서의 총액 · 원 · 1,000 · 미만버림) · 실무 6건이 여섯 다 000 으로 끝남 ·"
@@ -99,7 +100,8 @@ FIELD_HINTS: dict[str, str] = {
"waste_placement": (
"법 문언(기본): 예정가격작성기준 제19조③18호 — 폐기물처리비는 경비라 일반관리비·이윤"
" 밑수에 듦 · 실무 관행: 울진소광 원가계산서(이윤 뒤·총원가 안)·임목폐기물처리 실정보고"
"(공급가액 뒤)는 승률 밖 — 금액이 크게 갈려 설계자가 고름"
"(공급가액 뒤)는 승률 밖 — 금액이 크게 갈려 설계자가 고름 ·"
" 어느 자리든 법정경비 밑수(직접공사비·노무비)에는 안 넣음"
),
}
_GRADE_OPTIONS = [
+57 -21
View File
@@ -25,7 +25,6 @@ from B09_Estimation.B09_Estimation_Engine_Cost_Options import (
VAT_MODES,
cut_gap,
overhead_base,
profit_cut,
vat_base,
)
from B09_Estimation.B09_Estimation_RateOverride import apply_overrides
@@ -87,7 +86,10 @@ class CostInput:
#: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액.
owner_supplied_for_safety_krw: Decimal | None = None
#: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다(규정: 부가세 제외 기준).
#: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다.
#: ⚠ 근거는 **실무**다 — 고시(산업안전보건관리비 계상 및 사용기준) 제4조① 단서는 「해당
#: 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다. ÷1.1 은 부가세 제외
#: 환산이며, 실무 원가계산서 **6건이 모두** 「관급재/1.1」로 적었다(2026-09-14 전수 확인).
owner_supplied_includes_vat: bool = True
#: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다.
#: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조).
@@ -365,9 +367,9 @@ def calculate_cost(data: CostInput) -> CostResult:
)
#: 절사 뒤 잔차 맞춤 반복 상한. 실무 6건은 **두 걸음 안에** 앉음
#: (다섯 건 한 걸음 · 울진 신설 두 걸음).
_CUT_MAX_PASSES = 3
#: 절사 목표를 몇 칸까지 내려 볼 것인가. 못 밟는 1,000 배수를 만나면 한 칸 내린다 —
#: 실측(거창 꼴 40 자리)에서는 **한 칸이면 다 앉았다**. 여유로 셋까지 본다.
_CUT_DESCENT_MAX = 3
#: 11. 절사 끔 — 고르개가 보내는 값. 빈 값도 같이 받는다(저장 안 된 옛 프로젝트).
CUT_OFF = ("", "none")
@@ -383,30 +385,64 @@ def _calculate_with_scale(
**걸음마다 ÷1.1 해야 한다**(2026-09-14 고침). 이윤을 1 깎으면 부가세가 따라 줄어
총공사비는 **1.1** 줄므로, 잔차를 그대로 빼면 경계를 **지나쳐** 진동한다
울진 신설 실측 `654 999 900 910 909 ` 앉았다. ÷1.1 하면 `654 999 0`.
** 밟는 배수가 있다**(2026-09-14 둘째 고침). 걸음 낙차가 1원일 때도 2원일 때도 있어
(부가세가 버림이라 걸음에 번쯤 2) 어떤 1,000 배수는 **건너뛴다**. 어림으로 좇으면
지나쳐 놓고 돌아온다 거창 40 자리 16 끝자리 999 남았다. 그래서
**목표를 박고 가장 작은 보정액을 이분으로 찾는다**(총공사비는 보정액에 대해 비증가).
배수를 밟으면 ** 아래 배수** 목표를 내려 다시 찾는다(뒷받침일 실측 거창
40 자리에서는 칸도 내려갔다). 이윤은 **가장 적게** 깎인다 실무 (÷1.1 걸음)
앉는 자리에서는 같은 값이 나온다(봉화 339 · 영월 632).
"""
result = _calculate_once(data, dataset, scale, notes)
if data.cut_basis in CUT_OFF or data.cut_unit_krw <= 0:
return result
key = "grand_total" if data.cut_basis == "grand_total" else "total_cost"
label = (
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만"
" 절사 자동보정(산림청고시 2025-82호)"
+ (" + 설계자 입력" if data.profit_adjustment_krw else "")
)
def attempt(extra: Decimal) -> CostResult:
"""이윤을 `extra` 만큼 더 깎아 한 번 셈. `0` 이면 안 자른 결과 그대로."""
if extra == 0:
return result
return _calculate_once(
replace(
data,
profit_adjustment_krw=data.profit_adjustment_krw + extra,
cut_basis="none",
profit_adjustment_label=label,
),
dataset,
scale,
notes,
)
raw = result.totals[key]
target = raw - cut_gap(raw, data.cut_unit_krw)
automatic = _ZERO
gap = _ZERO
for _ in range(_CUT_MAX_PASSES):
gap = cut_gap(result.totals[key], data.cut_unit_krw)
landed = result
for _ in range(_CUT_DESCENT_MAX):
# 목표 이하로 내려가는 **가장 작은** 보정액을 이분으로 찾는다. 한 걸음이 적어도 1원을
# 깎으므로 `raw - target` 이면 반드시 목표 아래로 간다.
low, high = _ZERO, raw - target
while low < high:
middle = (low + high) // 2
if attempt(middle).totals[key] <= target:
high = middle
else:
low = middle + 1
landed = attempt(low)
gap = landed.totals[key] - target
if gap == 0:
automatic = low
break
# 실무 식 — 총공사비 절사 + 부가세가 공급가액 비례면 ÷1.1(잔차 걸음도 같음).
automatic += profit_cut(gap, data.cut_basis, data.vat_mode)
adjusted = replace(
data,
profit_adjustment_krw=data.profit_adjustment_krw + automatic,
cut_basis="none",
profit_adjustment_label=(
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만"
" 절사 자동보정(산림청고시 2025-82호)"
+ (" + 설계자 입력" if data.profit_adjustment_krw else "")
),
)
result = _calculate_once(adjusted, dataset, scale, notes)
# 지나쳤다 = 그 배수는 못 밟는 자리. 한 칸 아래를 노린다.
target -= data.cut_unit_krw
result = landed
result.notes.append(
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만 절사 —"
f" 이윤에서 {automatic:,}원 자동보정."
@@ -416,7 +452,7 @@ def _calculate_with_scale(
if gap:
# 조용히 남기지 않는다 — 안 앉았으면 끝자리가 남았다는 것을 화면에 보인다.
result.notes.append(
f"⚠ 절사가 {_CUT_MAX_PASSES}걸음 안에 안 앉음 —"
f"⚠ 절사가 {_CUT_DESCENT_MAX} 안에 안 앉음 —"
f" 끝자리 {gap:,}원이 남음(설계자 확인 필요)"
)
return result
+148
View File
@@ -0,0 +1,148 @@
"""B09 원가계산 — **유로폼 사용수량** 12-38-2 (2026-09-14 브레인 301 판정 ①~⑥ · ③′).
정본 = 산림 12-38-2(10 패널 0.89 · 내부 패널 0.03 · 부자재 주자재비 간단 24 · 보통 52 · 복잡 79% ·
소모자재 5%) · 건설 공통 6-3-3 참고(고시 총칙 부문과 유사한 공종은 품셈 우선).
원문 자재비는 거래형태 등을 고려하여 **임대료 또는 손료** 산정 둘을 나란히 주므로 설계자가 고름(제안값 없음).
고르는 자리 자재 단가 (화약류와 같은 통로) 넣은 쪽으로
손료 패널·내부 패널 단가 × 수량 곧장(실무 봉화 31,500 × 0.89 / 10) ·
부자재·소모자재 = (패널 + 내부 패널) × %(봉화 2,866.5 × 52%)
임대료 유로폼 임대료 (설계자가 임대기간 반영해 ) · % 원문이 적어
· 반쯤 · 아무것도 + 사유(부모 12-38 까지 그대로 올라감)
12-38-1 잔존율(12 25%) 곱하지 않음 수량이 이미 몫으로 보임. 곱하면 나눔(패널 1/16).
가드(이중계상 )에서 12-38-02 까닭은 `B09_Estimation_Guards.SURCHARGE_INCLUDED_ITEMS` 곁에.
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
CODE = "FP-12-38-02"
PANEL = "AR-M-10aba455"
INNER = "AR-M-5b89b294"
RENT = "AR-M-d00a6b1a"
QUANTITY_TABLE = "F0393"
RATE_TABLE = "F0394"
CHOICE_MISSING = (
"자재비 — 임대료 또는 손료 설계자 선택(12-38-2 「거래형태 등을 고려하여 임대료 또는 손료로 산정」)"
" · 「자재 단가」 탭에 패널·내부 패널 단가(손료) 또는 유로폼 임대료 ㎡당(임대료) 중 하나를 넣으면 섬"
)
CHOICE_BOTH = "자재비 — 손료(패널 단가)와 임대료가 둘 다 들어옴 · 하나만 넣을 것"
LOSS_HALF = "자재비 손료 — {name} 단가 없음 · 패널·내부 패널 둘 다 넣어야 섬"
REUSE_NOTE = (
"12-38-2 표 수량 곧장(실무 넷 실증) · 12-38-1 잔존율은 안 곱함 — 표 수량이 이미 12회·잔존율 25% 몫으로"
" 보임(우리 역산: 10㎡ ÷ 0.72㎡ = 13.9매 × 0.75 ÷ 12 = 0.87 ≈ 0.89 · 원문이 적지는 않음) · 차 2.5%"
" [주]① 「할증 및 손율 포함」으로 봄(추정) · 「25회 10%」 는 표에 수량이 없어 안 세움"
)
RENT_NOTE = "설계자 임대료(12-38-2 「임대료는 시중 물가지 등을 참고하여 결정」) · 부자재·소모자재 % 는 원문이 임대료에 안 적어 안 걺"
_RE_PERCENT = re.compile(r"(\d+(?:\.\d+)?)\s*%")
def _tight(text: Any) -> str:
return re.sub(r"\s", "", str(text or ""))
def _table(node: dict[str, Any], table_id: str) -> dict[str, Any]:
return next((t for t in node.get("tables") or [] if t.get("pum_table_id") == table_id), {})
def rates(node: dict[str, Any]) -> dict[str, Decimal]:
"""부자재 요율 표(F0394) — 머리 갈래(원문 표기 「간 단」…) → %. 마스터 갈래 키도 이것을 씀."""
table = _table(node, RATE_TABLE)
heads = table.get("condition_note") or []
for row in table.get("raw_row") or []:
found = {str(h): _RE_PERCENT.search(str(c)) for h, c in zip(heads[1:], row[1:])}
return {h: Decimal(m.group(1)) for h, m in found.items() if m}
return {}
def _quantities(node: dict[str, Any]) -> tuple[Decimal | None, Decimal | None, Decimal | None]:
"""(패널 매/㎡, 내부 패널 매/㎡, 소모자재 %) — 표 F0393 10㎡당을 1㎡당으로."""
from B09_Estimation.B09_Estimation_ResourceAxis import parse_amount
table = _table(node, QUANTITY_TABLE)
per = Decimal(str(table.get("basis_quantity") or 0))
panel = inner = consumable = None
for row in table.get("raw_row") or []:
name, value = _tight(row[0]), str(row[-1])
if name == "패널" and parse_amount(value) and per:
panel = parse_amount(value) / per
elif name == "내부패널" and parse_amount(value) and per:
inner = parse_amount(value) / per
elif name.startswith("소모자재") and _RE_PERCENT.search(value):
consumable = Decimal(_RE_PERCENT.search(value).group(1))
return panel, inner, consumable
def attach_euroform(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None:
"""「자재 단가」 칸 셋을 세우고, 넣은 쪽으로 12-38-02 를 세움 — 부모 합산(`attach_parent_steps`) 앞."""
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle
node = nodes_by_code.get(CODE) or {}
for material in (PANEL, INNER, RENT):
uses = build.material_uses.setdefault(material, [])
if CODE not in uses:
uses.append(CODE)
book = build.book
loss = [m for m in (PANEL, INNER) if m in book.titles]
panel, inner, consumable = _quantities(node)
table_rates = rates(node)
reason = ""
if loss and RENT in book.titles:
reason = CHOICE_BOTH
elif RENT in book.titles:
title_code = f"B-{CODE}"
book.add_title(
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name="유로폼 사용수량",
spec="임대료",
unit="",
)
)
book.add_detail(PriceDetail(title_code, RENT, Decimal(1), note=RENT_NOTE))
elif len(loss) == 2 and panel and inner and consumable is not None and table_rates:
for head, rate in table_rates.items():
variant = _tight(head)
title_code = f"B-{CODE}#{variant}"
book.add_title(
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name=f"유로폼 사용수량 ({variant})",
spec=variant,
unit="",
)
)
book.add_detail(PriceDetail(title_code, PANEL, panel, note=REUSE_NOTE))
book.add_detail(
PriceDetail(title_code, INNER, inner, note="12-38-2 내부 패널 표 수량 곧장")
)
for label, percent in (
("부자재(웨지핀·플랫타이·강관파이프·훅)", rate),
("소모자재(박리제 등)", consumable),
):
book.add_detail(
PriceDetail(
title_code,
title_code,
Decimal(0),
note=f"{label} — 주자재비(패널 + 내부 패널)의 {percent}% (12-38-2)",
percent_of_material=percent,
)
)
build.variants.setdefault(CODE, []).append(variant)
elif loss:
missing = "내부 패널" if PANEL in loss else "패널"
reason = LOSS_HALF.format(name=missing)
else:
reason = CHOICE_MISSING
if reason:
build.component_gaps[CODE] = reason
build.unattached[CODE] = [reason]
@@ -0,0 +1,85 @@
"""B09 원가계산 — **발파 화약류 자재**(9-5-1 · 2026-09-14 브레인 300 판정).
F0243 폭약 kg 0.35 · 뇌관 1.0 · 비트 0.008 **규격을 적음** 자원 목록(`AR-M`)
항목은 있으나 조인 규칙(규격이 같아야 · 후보 하나여도 자동 고름) 걸려 붙었음.
치즐과 같은 모양: 자재 단가 탭에 칸이 서고 **설계자가 규격·단가를 넣으면** 수량으로 붙음.
넣으면 규격 미정 사유로 붙은 줄에 남음(임의로 규격을 고르지 않음).
잡재료비 주재료의 5%(폭약 비고) 아직 사유에 적음.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
#: 공종 → (표 이름, 자원 목록 코드). 표 이름은 공백을 지운 글.
EXPLOSIVES: dict[str, tuple[tuple[str, str], ...]] = {
"FP-09-05-01": (
("폭약", "AR-M-6fc2930f"),
("뇌관", "AR-M-0965627d"),
("비트", "AR-M-a1a18ec4"),
),
}
EXPLOSIVE_MISSING = (
"{name} — 규격 미정(원문 표가 규격을 안 적음) · 「자재 단가」 탭에서 규격·단가를 넣으면 붙음"
)
MISC_NOTE = "ⓘ 폭약 줄 비고 「잡재료비: 주재료의 5%」 는 아직 안 걺"
#: 착암기 — 건설품셈 8-3-6 (5205) 공기압축기 손료표 [주]① 「부수물(호스포함)은 별도 계상한다」 ·
#: 부수물 관계표에 「래그 해머 2.7㎥/min」 · 래그해머 손료표는 고시 없음(2026-09-14 안티그래비티 ·
#: 원문 L2646~2683). 압축기 손료에 든다고 **정하지 않음** — 원문이 「별도」.
LEG_HAMMER = "착암기2.7㎥/min"
LEG_HAMMER_NO_LOSS = (
"착암기 2.7㎥/min — 공기압축기의 부수물 「래그 해머」(건설품셈 8-3-6 (5205) [주]① 「부수물은"
" 별도 계상」)이라 압축기 손료에 안 듦 · 래그해머 손료표는 원문에 고시 없음 → 손료를 못 셈"
)
def _table_amount(node: dict[str, Any], name: str) -> Decimal | None:
"""표에서 그 이름 줄의 첫 수 — 없으면 `None`."""
from B09_Estimation.B09_Estimation_ResourceAxis import parse_amount
for table in node.get("tables") or []:
for row in table.get("raw_row") or []:
cells = [str(cell) for cell in row]
names = ["".join(cell.split()) for cell in cells]
if name not in names:
continue
index = names.index(name)
value = next((parse_amount(c) for c in cells[index + 1 :] if parse_amount(c)), None)
if value is not None:
return value
return None
def attach_explosives(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None:
"""칸을 세우고(`material_uses`) · 단가가 든 것은 붙이고 · 안 든 것은 사유로 갈아 끼움."""
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
for code, items in EXPLOSIVES.items():
node = nodes_by_code.get(code) or {}
labels = list(build.unattached.get(code) or [])
for name, material in items:
build.material_uses.setdefault(material, [])
if code not in build.material_uses[material]:
build.material_uses[material].append(code)
amount = _table_amount(node, name)
labels = [label for label in labels if "".join(label.split()) != name]
if amount is None:
continue
titles = [
t for t in build.book.titles if t == f"B-{code}" or t.startswith(f"B-{code}#")
]
if material in build.book.titles and titles:
for title in titles:
build.book.add_detail(
PriceDetail(title, material, amount, note=f"{name} — 설계자 규격·단가")
)
else:
labels.append(EXPLOSIVE_MISSING.format(name=name))
if MISC_NOTE not in labels:
labels.append(MISC_NOTE)
labels = [
LEG_HAMMER_NO_LOSS if "".join(label.split()) == LEG_HAMMER else label
for label in labels
]
build.unattached[code] = labels
+5 -2
View File
@@ -231,10 +231,13 @@ def check_handoff_boundaries(violations: list[str] | None) -> None:
#: 품셈 [주]가 「재료량에 할증 포함」이라 적은 공종 — 그 재료가 일위대가 재료비로 붙으면
#: **할증 뒤 값**이 들어가 자재총괄에서 한 번 더 붙는다(㉠). 원문 , 코드는 마스터가 붙인 자리.
#: **할증 뒤 값**이 들어가 자재총괄에서 한 번 더 붙는다(㉠). 원문 , 코드는 마스터가 붙인 자리.
#: ⚠ 유로폼 사용수량 12-38-02([주]① 「할증 및 손율이 포함」)는 **뺐음**(2026-09-14 브레인 301 ④) —
#: 이 가드의 전제 「자재총괄에서 할증이 한 번 더」가 안 섬: 패널은 B08 자재총괄에 줄이 없음(유로폼 ㎡ 갈 곳
#: `unit_price`) · 손료 수량이라 할증 전 값이 원문에 없음 · 실무 넷도 일위대가에 곧장 붙임.
#: 유로폼·패널이 자재총괄로 가는 날 되돌릴 것 — `test_b09_euroform` 이 그 자리에서 빨강.
SURCHARGE_INCLUDED_ITEMS: dict[str, str] = {
"FP-12-02": "용적 배합 콘크리트 참고표 「재료량에는 할증률이 포함」(마스터가 12-2 에 붙임)",
"FP-12-38-02": "유로폼 사용수량 [주]① 「재료량에는 재료의 할증 및 손율이 포함」",
"FP-13-11-04": "돌망태 사각형 [주]① 「자재비에는 재료의 할증을 포함」",
"AX-WK-c0842a0d": "모르타르 배합 참고자료 ※ 「위 재료량은 할증이 포함된 것이다」",
}
@@ -55,6 +55,33 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = {
"(제안 무한궤도 — 영월 실무 「06M3 B/H」 · 2026-09-14 브레인 ②).",
),
# 2026-09-14 ㉮ — 사용횟수 갈래로 푼 뒤 남는 원문 몫. 값을 짓지 않고 말만.
# 봉상후렉시블 셋(2026-09-14) — 판정표가 콘크리트 ㎥ 줄만 읽음 · 표의 나머지 줄이 조용히 안 사라지게 원문 그대로.
"FP-12-12": (
"표의 다른 줄",
"ⓘ 이 일위대가는 콘크리트 ㎥(콘크리트공·보통인부 + 진동기) 몫만 — 표의 거푸집(합판4회) · 철근(SD300 D13"
" · 철근가공조립 간단) · 기초잡석 운반·부설·다짐(소할 30% 브레이커 · 적사 굴착기 0.7㎥ · 운반 덤프트럭"
" 15톤 · 부설다짐 보통인부 0.6인/㎥) · 바닥정리는 안 넣음(따로 공종) · [주] 성토부 날개벽 인건비 30%"
" 할증은 안 걺.",
),
"FP-12-13": (
"표의 다른 줄",
"ⓘ 이 일위대가는 콘크리트 ㎥(콘크리트공·보통인부 + 진동기) 몫만 — 표의 거푸집(합판4회) · 기초잡석"
" 운반·부설·다짐(소할 30% 브레이커 · 적사 굴착기 0.7㎥ · 운반 덤프트럭 15톤 · 부설다짐 보통인부"
" 0.6인/㎥)은 안 넣음(따로 공종).",
),
"FP-12-16": (
"표의 다른 줄",
"ⓘ 이 일위대가는 구체·버림콘크리트 ㎥ 몫만 — 표의 원형거푸집(PE10회 · 각 1조/10) · 설치비(기초 및"
" 슬라브 특별인부 0.16·보통인부 0.45인/㎡ · 벽체 0.20·0.60인/㎡) · 거푸집(합판4회) · 철근(가공조립"
" 보통) · 설치비 재료비의 5% · 사다리설치(철근가공조립 간단 준용) · 인력 보통인부 0.004 는 안 넣음"
"(따로 공종·조립 몫).",
),
"FP-12-34-01": (
"원문 머리",
"ⓘ 원문 머리 「(단위: 개소당)」 ↔ 인력 콘크리트공 0.17·보통인부 0.29 가 12-16 맨홀 ㎥당(0.17인/㎥"
" · 0.29인/㎥)과 같고 기계도 Q=5.4㎥/hr ⇒ ㎥당으로 읽음(고른 쪽 ㎥당 · 버린 쪽 개소당) ·"
" 봉상후렉시블(45mm) 대 2 는 엔진식 진동기의 봉이라 두 번 안 셈(2026-09-14 브레인).",
),
"FP-12-04": (
"원문 [주]",
"ⓘ 「사용고재 평가기준 23%(합판과 각재의 설계단가 기준)」 은 원문이 셈을 안 줘 값으로 안 씀"
@@ -110,6 +137,41 @@ def known_gap_note(code: str | None) -> str:
return " / ".join(parts)
#: 표 형태 이름 — 판정 까닭 한 줄에 붙임.
_FORM_LABELS = {
"reference": "참조표",
"coefficient": "계수표",
"productivity": "생산량형",
"requirement": "소요량형",
}
def form_judgment_note(code: str | None) -> str:
"""사람이 가른 표 형태 까닭 — 원문 근거 없는 **SW 규칙**이라 화면에 드러냄(10-A ⑭ · 09-14).
정본은 B08 마스터 빌더의 판정표 (`_Forms.FORM_JUDGMENTS`) 여기서 다시 적지 않고 읽음.
"""
plain = str(code or "").split("#")[0]
if not plain:
return ""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import FORM_JUDGMENTS
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
node = next(
(n for n in load_work_item_master()["work_items"] if n.get("work_item_code") == plain),
{},
)
parts = []
for table in node.get("tables") or []:
found = FORM_JUDGMENTS.get(str(table.get("pum_table_id")))
if found:
_section, form, why = found
parts.append(f"{table['pum_table_id']} {_FORM_LABELS.get(form, form)}: {why}")
if not parts:
return ""
return "ⓘ 표 형태는 사람이 가름(원문 근거 없음 · SW 규칙) — " + " / ".join(parts)
#: 관부설 품셈 표가 다루는 관경 — 그 밖은 **표에 없는 것**이지 값이 틀린 것이 아니다.
PIPE_TABLE_DIAMETERS_MM = (800, 1000, 1200)
@@ -12,8 +12,10 @@
(1-4-1 어린나무가꾸기에 한하여 · 1-4-2 줄베기 · 1-4-9 숲가꾸기 병해충방제
작업로). **임도 토공에 붙이라는 지시가 원문에 없다.** 그래서 켜는 것은 사용자 몫이고,
계열의 **[] 원문을 화면에 그대로** 띄워 어디에 쓰라는 표인지 보이게 한다.
**여럿을 고를 합산인가 곱인가** 원문에 없다. 지금은 **합산**으로 두고 사실을
화면 근거에 적는다(실무 서식이 대개 합산이나 원문 근거는 아니다).
**여럿을 고를 합산인가 곱인가** 산림품셈 1-4 에는 없고 **건설 공통 1-4-2 할증의
중복가산요령** 정함(교차 참조 · 2026-09-14 브레인 672): W = 기본품 × (1 + a1 + + an)
· , 동일성격의 품할증요소의 이중적용은 불가 **합산**. 동일성격 어느 계열끼리인지는
원문이 정해 막지 않고 단서를 화면에 보임(설계자가 가림).
**기본은 고름** 계열도 고르면 금액이 원도 움직인다.
@@ -46,13 +48,14 @@ _NOTE_LOOKAHEAD = 12
_RE_PERCENT = re.compile(r"^-?\d+(?:\.\d+)?%$")
_RE_SECTION = re.compile(r"^(1-4-\d+)\.\s*(.+)$")
#: ⚠ **여럿을 고를 때 어떻게 셈하나 — 원문이 안 정한 자리다.**
#: 지금은 「합산」이고 **여기 한 곳만 갈아 끼우면 바뀐다**(코드 깊이 박지 않는다).
#: 여럿을 고를 때 셈법 — 건설 공통 1-4-2 「W = 기본품 × (1 + a1 + … + an)」 합산(교차 참조 · 672).
#: **여기 한 곳**이 정한다(코드 깊이 박지 않는다).
#: `"sum"` = 10% + 5% = 15% · `"product"` = 1.10 × 1.05 1 = 15.5%
COMBINE_RULE = "sum"
COMBINE_NOTE = (
"여럿을 고르면 더합니다 — 원문이 합산인지 곱인지 안 정해 우리가 그렇게 두었습니다"
" (사용자 확정 대기)."
"여럿을 고르면 합산 — 건설 공통 1-4-2 「W = 기본품 × (1 + a1 + a2 + … + an)」"
"(산림품셈 1-4 에 겹침 규정이 없어 교차 참조) · ⚠ 같은 조 단서 「동일성격의 품할증요소의"
" 이중적용은 불가」 — 어느 계열끼리 동일성격인지는 원문이 안 정해 설계자가 가림"
)
SEAT_NOTE = (
"품 할인·할증은 품(인력) 줄에 붙습니다 — 물량에 곱하면 자재·기계까지 부풀어"
@@ -145,6 +145,15 @@ GLUED_MACHINE_FIXES: dict[str, tuple[str, str]] = {
"7120-0746": ("버킷식준설기", "7.46kW"),
"7995-0050": ("배관파이프", "ø50-2.6m"),
}
#: ⚠ **한 칸에 두 줄이 뭉친 표** (2026-09-14 661 뒤 ① 브레인) — 원천이 (4611) 콘크리트 진동기 표의
#: 두 기종(「4611-0075 0350」 · 규격·계수가 한 칸에 둘씩)을 못 갈라 **규격이 비고 손료계수가 없음**.
#: 규격·시간당 계는 **건설공사 표준품셈 제8장 (4611)** 원문(L2504) 그대로 — 판정 없음.
#: 상각 3,000 + 정비 1,167 + 관리 768 = 4,935 · 3,000 + 1,333 + 768 = 5,101 (표의 「계」와 같음)
#: ⚠ 원천이 바로 실으면 이 표는 지운다(위 표와 같은 약속). 취득가는 원천 값 그대로.
MERGED_ROW_FIXES: dict[str, tuple[str, Decimal]] = {
"4611-0075": ("전기식 플렉시블형 ø45(0.75㎾)", Decimal("0.0004935")),
"4611-0350": ("엔진식 플렉시블형 ø45(2.6㎾)", Decimal("0.0005101")),
}
#: 이름이 빈 분류 — 분류번호(코드 앞 넷) → 원문 이름. 규격은 원천 값 그대로.
EMPTY_NAME_BY_GROUP: dict[str, str] = {
"0240": "유압식 진동콤팩터(굴착기 부착용)",
@@ -234,6 +243,10 @@ def load_machine_catalog(file_name: str = "mach_base_2026.json") -> MachineCatal
elif code in GLUED_MACHINE_FIXES:
name, spec = GLUED_MACHINE_FIXES[code]
row = {**row, "machine_name": name, "specification": spec}
elif code in MERGED_ROW_FIXES and "loss_coefficient_per_hour" not in coefficient:
spec, merged_coefficient = MERGED_ROW_FIXES[code]
row = {**row, "specification": spec}
coefficient = {**coefficient, "loss_coefficient_per_hour": merged_coefficient}
elif not str(row.get("machine_name") or "").strip() and code[:4] in EMPTY_NAME_BY_GROUP:
row = {**row, "machine_name": EMPTY_NAME_BY_GROUP[code[:4]]}
catalog.machines[code] = MachineSpec(
@@ -88,7 +88,6 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
catalog = load_machine_catalog()
operating = {row.machine_code: row for row in load_operating_records().records}
wages = load_operator_wages()
fuel_price, fuel_meta = load_fuel_price()
loss = _loss_records()
sheets: list[dict[str, Any]] = []
@@ -101,11 +100,32 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
continue
record = operating.get(machine_code)
raw = loss.get(machine_code) or {}
if variant == "암석":
# 암석 손료보정(8-1-7 1) — 장도 보정한 상각·정비로 보여야 「계」와 맞음(661 뒤처리).
from B09_Estimation.B09_Estimation_RockLoss import rock_parts
parts = rock_parts(machine_code)
if parts is not None:
keys = ("depreciation", "maintenance", "management", "source")
# 화면 JSON 은 수 — Decimal 을 그대로 실으면 응답이 안 섬
raw = {
**raw,
**{f"{k}_coefficient_1e_minus_7": float(v) for k, v in zip(keys, parts)},
}
loss_per_hour = (
Decimal(str(int(raw["source_coefficient_1e_minus_7"]))) * Decimal("1e-7")
if variant == "암석" and "source_coefficient_1e_minus_7" in raw
else machine.loss_coefficient_per_hour
)
money = build.book.resolve(code)
gaps: list[str] = []
attachment = machine_code.startswith(_ATTACHMENT_PREFIXES)
fuel_liters = getattr(record, "fuel_liters_per_hour", None)
# 연료 종류대로 그 유가(휘발유 기계가 경유값으로 보이던 자리 · 661 뒤 ②).
fuel_price, fuel_meta = (
load_fuel_price(kind=record.fuel_kind) if fuel_liters is not None else (None, {})
)
misc_percent = getattr(record, "misc_material_percent", None)
occupation = getattr(record, "operator_occupation_code", "") or ""
wage = wages.get(occupation)
@@ -136,8 +156,8 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
"management_coefficient": raw.get("management_coefficient_1e_minus_7"),
"loss_coefficient": raw.get("source_coefficient_1e_minus_7"),
"loss_krw_per_hour": _money(
machine.price_thousand_krw * _THOUSAND * machine.loss_coefficient_per_hour
if machine.loss_coefficient_per_hour is not None
machine.price_thousand_krw * _THOUSAND * loss_per_hour
if loss_per_hour is not None
else None
),
# ② 운전경비
@@ -145,7 +165,7 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
"fuel_price_per_liter": _money(fuel_price),
"fuel_scope": fuel_meta.get("region_name") or "전국 공시가",
"misc_material_percent": (
str(COMBINED_MISC_PERCENT) if variant else _money(misc_percent)
str(COMBINED_MISC_PERCENT) if variant == "조합" else _money(misc_percent)
),
"operator_code": occupation,
"operator_daily_wage": _money(wage),
@@ -316,9 +316,14 @@ def write_operating_records(
#: 시도별 유가 판 — 품셈 8-1-7 5호 「유류가격은 **해당지역의 가격**으로 한다」.
#: ⚠ **파일이 있을 때만 지역을 고를 수 있다** — 없으면 전국평균 한 벌로 돈다(코드로 막지 않음).
REGIONAL_OIL_FILE = "oil_regional_2026-09-09.json"
#: 운전경비표 연료 종류 → 유가 판 변수. ⚠ 종류를 안 읽고 경유로 때우면 휘발유 기계가 싸게 섬
#: (2026-09-14 661 뒤 ② — 플레이트 콤팩터·진동기·믹서·래머·커터가 경유값이었음).
FUEL_VARIABLES = {"경유": "oil_diesel", "휘발유": "oil_gasoline"}
def load_regional_fuel_table(oil_file: str = REGIONAL_OIL_FILE) -> tuple[dict[str, dict], dict]:
def load_regional_fuel_table(
oil_file: str = REGIONAL_OIL_FILE, kind: str = "경유"
) -> tuple[dict[str, dict], dict]:
"""(시도코드 → {이름·값}, 판 신원). 판이 없으면 **빈 표**를 돌려준다.
원문에 있는 코드 `00`(전국) **지역 선택지에서 뺀다** 자리는 전국평균 판이
@@ -328,7 +333,7 @@ def load_regional_fuel_table(oil_file: str = REGIONAL_OIL_FILE) -> tuple[dict[st
payload = _read_json(*_CATALOG_SUBPATH, oil_file)
except FileNotFoundError:
return {}, {}
diesel = payload["variables"]["oil_diesel"]
diesel = payload["variables"][FUEL_VARIABLES[kind]]
table = {
str(record["sido_code"]): {
"name": str(record["sido_name"]),
@@ -346,7 +351,7 @@ def load_regional_fuel_table(oil_file: str = REGIONAL_OIL_FILE) -> tuple[dict[st
def load_fuel_price(
oil_file: str = "oil_2026-08-14.json", region: str | None = None
oil_file: str = "oil_2026-08-14.json", region: str | None = None, kind: str = "경유"
) -> tuple[Decimal, dict[str, str]]:
"""경유 단가와 그 판의 신원. `region`(시도코드)을 주면 **그 지역 값**으로 선다.
@@ -355,7 +360,7 @@ def load_fuel_price(
지역이 판에 없으면 **조용히 전국평균으로 눕지 않고** 사실을 신원에 적는다.
"""
payload = _read_json(*_CATALOG_SUBPATH, oil_file)
diesel = payload["variables"]["oil_diesel"]
diesel = payload["variables"][FUEL_VARIABLES[kind]]
meta = {
"dataset_id": payload.get("dataset_id", ""),
"effective_date": payload.get("effective_date", ""),
@@ -366,7 +371,7 @@ def load_fuel_price(
if not region:
return Decimal(str(diesel["value"])), meta
table, region_meta = load_regional_fuel_table()
table, region_meta = load_regional_fuel_table(kind=kind)
picked = table.get(str(region))
if picked is None:
meta["region"] = str(region)
@@ -429,10 +434,12 @@ def hourly_cost_of(machine_code: str, *, region: str | None = None):
if record is None:
return hourly_machine_cost(machine)
fuel_price, _ = load_fuel_price(region=region)
wages = load_operator_wages()
liters = record.fuel_liters_per_hour
fuel_price = (
None if liters is None else load_fuel_price(region=region, kind=record.fuel_kind)[0]
)
if liters is not None and record.misc_material_percent is not None:
# 잡재료는 **주연료의 %** 라 유가와 같이 움직인다(PLAN 8-18 유가 민감분).
liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100))
+3 -1
View File
@@ -186,7 +186,9 @@ def _assemble(build: Any, node: dict[str, Any], names: dict[str, str]) -> None:
return
per_step.append((step, weight, titles))
varying = [item for item in per_step if set(item[2]) - {""}]
if len(varying) > 1:
# 갈래가 같은 이름으로 여러 단계에 걸치면 갈래끼리 합산(유로폼 12-38 = 사용수량 + 설치·해체
# 둘 다 간단·보통·복잡 · 2026-09-14 301). 갈래 벌이 다르면 짝을 못 지어 종전대로 막음.
if len({frozenset(set(ts) - {""}) for _, _, ts in varying}) > 1:
build.component_gaps[parent] = "갈래가 두 단계 이상에 걸쳐 조립하지 않았습니다"
return
units = {build.book.titles[t].unit for _, _, ts in per_step for t in ts.values()} - {""}
@@ -55,7 +55,51 @@ JUDGED_TABLES: dict[str, dict[str, Any]] = {
"prefix": "합판거푸집",
"why": "원문 L6187 「기준수량(1회사용) · 사용횟수별기준수량에대한 비율(%) 재료별·노무비」",
},
"F0353": {
"code": "FP-12-15",
"shape": "remark_labor",
"prefix": "집수정",
# 구체콘크리트는 바로 아래 다짐기 줄과 한 갈래 — 다짐기가 안 풀리면 갈래를 안 세움(⑴).
"needs_machine": {"구체콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6460 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
# 12-15 와 같은 모양 셋 — 같은 봉상후렉시블 줄 하나가 셋을 막고 있었음(2026-09-14 브레인 · 672 다음).
"F0350": {
"code": "FP-12-12",
"shape": "remark_labor",
"prefix": "날개벽",
"needs_machine": {"콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6419 12-12 「콘크리트(레미콘) ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
"F0351": {
"code": "FP-12-13",
"shape": "remark_labor",
"prefix": "면벽",
"needs_machine": {"콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6436 12-13 「콘크리트(레미콘) ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
"F0354": {
"code": "FP-12-16",
"shape": "remark_labor",
"prefix": "맨홀",
# 칸이 하나 밀려 비고가 끝 칸이 아님(「구체콘크리트 | 철근 | ㎥ | | 비고 | 」).
"needs_machine": {"구체콘크리트": "봉상후렉시블(45mm)"},
"why": "원문 L6474 12-16 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
# 12-34-1 — 머리 「(단위: 개소당)」 이나 인력 0.17·0.29 가 12-16 맨홀 ㎥당과 같고 기계가 Q ㎥/hr
# ⇒ ㎥당으로 읽음 · 「봉상후렉시블 대 2」 는 엔진식 진동기(엔진+플렉시블 한 대)의 봉(2026-09-14 브레인).
"F0385": {
"code": "FP-12-34-01",
"shape": "per_m3_rows",
"prefix": "콘크리트 타설",
# 엔진식 진동기(건설품셈 8-3 (4611) 엔진+플렉시블 한 대)의 봉 — 「진동기(3.5HP) 대 2」 로 셈.
"same_machine": ("봉상후렉시블(45mm)",),
"why": "원문 L6839 12-34-1 「인력 콘크리트공·보통인부 인 · 기계 대 (Q=5.4㎥/hr)」",
},
}
#: 비고 칸 인력 — 「콘크리트공0.24인/㎥, 보통인부 0.42인/㎥」.
_RE_REMARK_LABOR = re.compile(r"([가-힣]+)\s*(\d+(?:\.\d+)?)\s*인/㎥")
_RE_Q = re.compile(r"Q\s*=\s*(\d+(?:\.\d+)?)")
#: 비율 줄 — 「1회사용시 2회사용시 …」 칸.
_RE_USE_COUNT = re.compile(r"(\d+)회사용시")
#: 값으로 안 읽는 줄 — 사용고재 평가기준(원문이 셈을 안 줌 · 사유는 `KNOWN_GAPS`) · 비고.
@@ -111,12 +155,17 @@ def match_judged_table(
result.partial_items[code] = why
return True
if basis_quantity in (None, 0):
# 비고·Q 가 ㎥당을 적는 모양은 표 머리 밑수를 안 씀(12-12 날개벽은 「개소당」 머리조차 없음).
if basis_quantity in (None, 0) and judged["shape"] not in ("remark_labor", "per_m3_rows"):
return block("판정 표에 밑수가 없습니다")
if judged["shape"] == "header_row":
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit)
elif judged["shape"] == "use_count":
staged = _use_count(code, table, rows, catalog, basis_quantity, unit)
elif judged["shape"] == "remark_labor":
staged = _remark_labor(code, table, judged, rows, catalog)
elif judged["shape"] == "per_m3_rows":
staged = _per_m3_rows(code, table, judged, rows, catalog)
else:
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
if isinstance(staged, str):
@@ -219,3 +268,63 @@ def _use_count(code, table, rows, catalog, basis, unit) -> list | str:
amount = base * ratio / Decimal(100) / basis
staged.append(_row(code, table, entry, amount, unit, index, f"{count}"))
return staged
def _remark_labor(code, table, judged, rows, catalog) -> list | str:
"""㎥ 줄 비고 칸의 인력(인/㎥)으로 갈래 — 다짐기가 딸린 갈래는 그 기계가 풀려야 세움."""
table_id = str(table.get("pum_table_id", ""))
by_name = {"".join(cells[0].split()): cells for cells in rows if cells}
staged: list = []
for index, cells in enumerate(rows):
labors = _RE_REMARK_LABOR.findall(" ".join(cells[3:])) # 비고가 끝 칸이 아닌 표(12-16)
if len(cells) < 3 or cells[2] != "" or not labors:
continue
variant = cells[0].split("(")[0].strip()
pieces = []
for name, amount in labors:
entry = _entry(catalog, name, code)
if entry is None:
return f"{variant} 인력 「{name}"
pieces.append((entry, Decimal(amount)))
machine_name = judged.get("needs_machine", {}).get(variant)
if machine_name:
machine_cells = by_name.get("".join(machine_name.split())) or []
found_q = _RE_Q.search(" ".join(machine_cells))
entry = _entry(catalog, machine_name, code) if machine_cells else None
if entry is None or found_q is None:
reason = unmatched_reason(catalog, machine_name)
staged.append(UnmatchedRow(code, table_id, machine_name, reason))
why = (
f"다짐기 「{machine_name}」 가 안 풀려 갈래를 안 세움 — 인력만이면 조립 줄이"
" 조용히 싸짐(2026-09-14 ㉯ ⑴)"
)
staged.append(UnmatchedRow(code, table_id, variant, why))
continue
pieces.append((entry, Decimal(1) / Decimal(found_q.group(1))))
for entry, amount in pieces:
staged.append(_row(code, table, entry, amount, "", index, variant))
return staged
def _per_m3_rows(code, table, judged, rows, catalog) -> list | str:
"""「인」 칸 앞 이름 · 뒤 수(인/㎥) · 「대」 칸 앞 이름 · 뒤 대수 ÷ Q — 칸이 밀린 줄도 단위 칸으로 찾음."""
table_id = str(table.get("pum_table_id", ""))
staged: list = []
for index, cells in enumerate(rows):
unit = next((i for i, c in enumerate(cells) if c in ("", "") and i > 0), None)
if unit is None:
continue
name = cells[unit - 1]
if "".join(name.split()) in judged.get("same_machine", {}):
continue # 같은 기계 두 번 안 셈 — 까닭은 공종 사유 한 줄(`KnownGaps`)이 화면에 보임
amount = next((parse_amount(c) for c in cells[unit + 1 :] if parse_amount(c)), None)
entry = _entry(catalog, name, code)
if amount is None or entry is None:
return f"{name}"
if cells[unit] == "":
found_q = _RE_Q.search(" ".join(cells))
if found_q is None:
return f"{name} Q"
amount = amount / Decimal(found_q.group(1))
staged.append(_row(code, table, entry, amount, "", index, ""))
return staged
+169
View File
@@ -0,0 +1,169 @@
"""B09 원가계산 — **암석 작업 기계손료 보정** (건설품셈 8-1-7 1 · 2026-09-14 브레인 661 ①②).
원문: 다음 건설기계가 암석굴착, 암석적재, 암석운반 등의 가혹한 작업에 사용되는 경우에는
손료(관리비 제외) 다음과 같이 보정 가산한다 불도저(19 이상 제외) 25 · 굴착기(무한궤도)
로더(무한궤도) 20 · 덤프트럭 25 (%) · [] 전용덤프트럭(18 이상) 불도저(19 이상)
보정하지 않는다(타이어·습지 불도저는 보정). 율은 `mach_base` `mach_rock_adj`(원문 파싱) .
암석 손료계수 = (상각 + 정비) × (1 + 가산) + 관리 1e-7 정수 아래 버림
실무 봉화 2024 (암석) 셋이 그대로 역산됨(굴착기 1.0 0.2405 · 덤프 2.5 0.3533 · 덤프 15 0.2679)
거는 자리 공종(자기·부모 이름이나 갈래가 연암·보통암·경암·발파암·파쇄암·암절취·암석)
대상 기계 줄만 기계 호표를 암석 세워 부름(봉화와 같은 모양)
거는 브레이커 조합 본체(`#조합`) — 봉화 「굴삭기 0.7 브레이커조합」 손료가 비암석 23,128 + 브레이커
풍화암·호박돌 섞인 토사 원문 암석작업(연암·보통암·경암)
전석섞인토사 10% 혼입율(0.5 이상 전석 30% 이상) 입력이 없어 판정 ( · 만듦)
"""
from __future__ import annotations
import re
from dataclasses import replace
from decimal import ROUND_FLOOR, Decimal
from functools import lru_cache
from typing import Any
ROCK_SUFFIX = "#암석"
_ROCK_WORDS = re.compile(r"연암|보통암|경암|발파암|파쇄암|암절취|암석")
_E7 = Decimal("1e-7")
NOT_CORRECTED = "8-1-7 [주]① {what} 은 암석 손료보정 안 함"
def _tight(text: Any) -> str:
return re.sub(r"\s", "", str(text or ""))
@lru_cache(maxsize=1)
def _sources() -> tuple[dict[str, dict[str, Any]], dict[str, int]]:
"""(기계 코드 → 손료 성분 레코드, 규칙 이름 → 암석 가산 %)."""
from B09_Estimation.B09_Estimation_MachineCost import _read_json
variables = _read_json("mach_base_2026.json")["variables"]
records = {r["machine_code"]: r for r in variables["mach_loss_coef"]["records"]}
rules = {r["machine_group"]: int(r["rock_work"]) for r in variables["mach_rock_adj"]["rules"]}
return records, rules
def rock_rate(code: str) -> tuple[int | None, str]:
"""(가산 %, 안 거는 까닭) — 표에 없는 기종은 `(None, "")`."""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
machine = load_machine_catalog().machines.get(code)
if machine is None:
return None, ""
_, rules = _sources()
name = _tight(machine.name)
size = re.match(r"\d+(?:\.\d+)?", machine.specification.replace(",", ""))
tons = Decimal(size.group()) if size else Decimal(0)
if name in ("불도저(타이어)", "습지불도저"):
return rules["bulldozer_under_19_ton"], ""
if name == "불도저(무한궤도)":
if tons >= 19:
return None, NOT_CORRECTED.format(what="불도저 19톤 이상")
return rules["bulldozer_under_19_ton"], ""
if name in ("굴착기(무한궤도)", "로더(무한궤도)"):
return rules["crawler_excavator_or_loader"], ""
if name == "덤프트럭":
if tons >= 18:
return None, NOT_CORRECTED.format(what="덤프트럭 18톤 이상")
return rules["dump_truck"], ""
return None, ""
def rock_parts(code: str) -> tuple[Decimal, Decimal, Decimal, int] | None:
"""(보정 상각, 보정 정비, 관리, 계) — 1e-7 단위 · 계는 정수 아래 버림(봉화 3533.75 → 3533)."""
rate, _ = rock_rate(code)
record = _sources()[0].get(code)
if rate is None or record is None:
return None
depreciation, maintenance, management = (
Decimal(str(record[f"{key}_coefficient_1e_minus_7"]))
for key in ("depreciation", "maintenance", "management")
)
factor = 1 + Decimal(rate) / 100
raised = (depreciation * factor, maintenance * factor, management)
return (*raised, int(sum(raised).quantize(Decimal(1), rounding=ROUND_FLOOR)))
def rock_coefficient(code: str) -> Decimal | None:
"""암석 손료계수(원당) = (상각 + 정비) × (1 + 가산) + 관리 — `rock_parts` 의 계 × 1e-7."""
parts = rock_parts(code)
return None if parts is None else parts[3] * _E7
def _rock_hourly(book: Any, code: str) -> str | None:
"""`X-<코드>#암석` — 손료만 보정 계수로 바꾼 호표(연료·조종원·잡품은 본 호표 그대로)."""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle
from B09_Estimation.B09_Estimation_UnitPrice import _slots
hourly, rock = f"X-{code}", f"X-{code}{ROCK_SUFFIX}"
if rock in book.titles:
return rock
coefficient = rock_coefficient(code)
if coefficient is None or hourly not in book.titles:
return None
machine = load_machine_catalog().machines[code]
rate, _ = rock_rate(code)
base, rock_base = f"S-{code}", f"S-{code}{ROCK_SUFFIX}"
plain = book.titles[base]
book.add_title(
replace(
plain, code=rock_base, slots=_slots(machine.price_thousand_krw * 1000 * coefficient)
)
)
title = book.titles[hourly]
book.add_title(
PriceTitle(
code=rock,
kind=PriceKind.MACHINE_HOURLY,
name=title.name,
spec=f"{title.spec} · 암석".strip(" ·"),
unit=title.unit,
)
)
note = f"암석 작업 손료보정 — (상각 + 정비) × {100 + rate}% + 관리 = {coefficient} (건설품셈 8-1-7 1)"
for detail in book.details.get(hourly, []):
ref = {base: rock_base, hourly: rock}.get(detail.ref_code, detail.ref_code)
book.add_detail(
replace(
detail,
parent_code=rock,
ref_code=ref,
note=note if detail.ref_code == base else detail.note,
)
)
return rock
def attach_rock_loss(build: Any, master: dict[str, Any]) -> int:
"""암 공종의 대상 기계 줄을 암석 호표로 바꿔 닮 — 바꾼 줄 수. 조합 16% 바꿔 달기 **뒤**에 부름."""
nodes = {str(n.get("work_item_code")): n for n in master.get("work_items", [])}
book = build.book
changed = 0
for title_code in [code for code in book.titles if code.startswith("B-")]:
work_item, _, variant = title_code[2:].partition("#")
node = nodes.get(work_item) or {}
parent = nodes.get(str(node.get("parent_code"))) or {}
title = book.titles[title_code]
text = " ".join(map(str, (node.get("name"), parent.get("name"), title.name, variant)))
if not _ROCK_WORDS.search(text):
continue
own = book.details.get(title_code) or []
owners = [title_code, *(d.ref_code for d in own if d.ref_code.startswith("D-"))]
for owner in owners:
details = book.details.get(owner) or []
for index, detail in enumerate(details):
ref = detail.ref_code
if not ref.startswith("X-") or "#" in ref:
continue
rate, why = rock_rate(ref[2:])
if rate is None:
if why and why not in detail.note:
details[index] = replace(detail, note=f"{detail.note} · {why}".strip(" ·"))
continue
rock = _rock_hourly(book, ref[2:])
if rock:
details[index] = replace(detail, ref_code=rock)
changed += 1
return changed
@@ -110,7 +110,9 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
from B09_Estimation.B09_Estimation_Transport import BASIS_TEXT as TRANSPORT_BASIS
from B09_Estimation.B09_Estimation_Transport import ROAD_CLASSES as TRANSPORT_ROADS
from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS
from B09_Estimation.B09_Estimation_Transport import TRIPS_NOTE as TRANSPORT_TRIPS_NOTE
from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE as TRANSPORT_CODE
from B09_Estimation.B09_Estimation_Transport import parse_trips, transport_amount
# 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다.
# 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다.
@@ -133,10 +135,19 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
book = prices.book
transport_notes = list(prices.transport_notes)
transport_prices = {}
transport_amounts = {}
# 회수는 품셈이 안 정하는 설계 입력 — 비면 금액을 안 세우고 사유만 남긴다.
transport_trips = parse_trips(settings.get("transport_trips"))
for variant in TRANSPORT_VARIANTS:
code = f"B-{TRANSPORT_CODE}#{variant['key']}"
if code in book.titles:
transport_prices[variant["key"]] = f"{book.resolve(code).total:,.0f}"
unit_price = book.resolve(code).total
transport_prices[variant["key"]] = f"{unit_price:,.0f}"
amount = transport_amount(unit_price, transport_trips)
if amount is not None:
transport_amounts[variant["key"]] = f"{amount:,.0f}"
if transport_prices and transport_trips is None:
transport_notes.append(TRANSPORT_TRIPS_NOTE)
with_material = sum(
1
for unit_code, unit_title in book.titles.items()
@@ -174,6 +185,8 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
"transport": {
"distance_km": str(settings.get("transport_distance_km") or ""),
"road": str(settings.get("transport_road") or ""),
"trips": str(settings.get("transport_trips") or ""),
"trips_note": TRANSPORT_TRIPS_NOTE,
"roads": [
{"key": row["key"], "label": row["label"]} for row in TRANSPORT_ROADS
],
@@ -182,6 +195,7 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
"key": variant["key"],
"label": variant["label"],
"unit_price_krw": transport_prices.get(variant["key"], ""),
"amount_krw": transport_amounts.get(variant["key"], ""),
}
for variant in TRANSPORT_VARIANTS
],
@@ -236,6 +250,7 @@ class FactorChoiceBody(BaseModel):
#: 기계 수송 거리(㎞)·도로 구분 — **비면 수송비 줄이 안 선다.**
transport_distance_km: str | None = None
transport_road: str | None = None
transport_trips: str | None = None
#: 품 할인·할증(1-4) — 계열코드 → 고른 행. **빈 값이면 그 계열을 끄는 것.**
labor_surcharge: dict[str, str] | None = None
@@ -312,6 +327,14 @@ async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONRe
content={"status": "error", "message": f"원문에 없는 도로 구분입니다: {road}"},
)
values["transport_road"] = road
if body.transport_trips is not None:
from B09_Estimation.B09_Estimation_Transport import parse_trips
try:
trips = parse_trips(body.transport_trips)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
values["transport_trips"] = "" if trips is None else str(trips)
if body.labor_surcharge is not None:
from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices
@@ -199,10 +199,22 @@ def safety_management_cost(
variable = dataset.variable("rate_safety_pct")
brackets = variable["brackets"]
# 제3조(적용범위) — 「총공사금액 2천만 원 이상인 공사에 적용」. 하한은 요율 데이터가 든다.
# ⚠ 견주는 값은 **규모 기준액**(설계자가 준 추정가격 · 없으면 수렴한 총원가)이다. 고시의
# 「총공사금액」과 딱 같은 말은 아니나(관급·부가세 자리가 다름) 계산 차례상 안전관리비
# 앞에 설 수 있는 값이 그것뿐이라 같은 축으로 쓴다 — 보건관리자 문턱도 같은 축이다.
if not _threshold_met(
dataset, "rate_safety_pct", "minimum_total_construction_amount_krw", ctx.scale_reference
):
return _ZERO # 대상 아님 — 줄 자체를 만들지 않는다(0 원으로 채우지 않음)
owner_supplied = data.owner_supplied_for_safety_krw
if owner_supplied is None:
owner_supplied = data.owner_supplied_material_krw
if data.owner_supplied_includes_vat:
# 제4조① 단서는 「해당 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다.
# ÷1.1 은 **부가세 제외 환산**이며 근거는 실무다 — 실무 원가계산서 **6건이 모두**
# 「(직노+직재+간재+관급재/1.1) × 율」로 적었다(2026-09-14 골든셋 전수 확인).
owner_supplied = owner_supplied / _VAT_DIVISOR
base_with = ctx.material_cost + ctx.direct_labor_cost + owner_supplied
@@ -255,6 +267,9 @@ def safety_management_cost(
# 1.2배의 대상은 1·2호로 **산정이 끝난 금액**이다. 종전엔 1.2 를 곱한 뒤 한 번만 버려
# 영월 2024 B 줄이 20,330,639 로 원본(20,330,638)보다 1원 컸다(골든셋 실증).
# A(배수 1)는 어느 차례로 해도 같은 값이다.
# ⚠ 안 고른 갈래 — 거창 2025 원본은 `버림(밑수 × 율 × 1.2)` 로 1원 위다. 그 서류는
# 시트 이름·줄 차례가 달라 **STmate 출력이 아니며**, 우리 기준은 STmate 재현이라
# 사유로만 남기고 채택하지 않는다(브레인 판정 2026-09-14).
return floor_won(base * percent / _HUNDRED + flat) * multiplier, percent, flat
raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)")
+30 -2
View File
@@ -24,8 +24,10 @@
**거리는 설계 입력이다** 넣으면 **줄이 선다**(사토장 운반거리와 같은 자리).
임의 거리를 넣으면 금액이 조용히 서므로 **비면 사유만 남긴다.**
**회수( 대를 나르나) 여기서 정한다** 단가는 회당이고, 회수는 수량
(설계 입력)이다. 품셈 대수·회수를 정해 주지 않는다.
**회수( 대를 나르나) 설계 입력이다** (2026-09-14 브레인 판정으로 닫음).
원문 전수 확인 산림품셈 10-4 · 건설품셈 8-1-3 **회당 단가 산출식만** 주고
대수·왕복 횟수를 정하는 공식이 **없다**. 그래서 `transport_trips` 칸을 두고,
비면 금액을 세우고 사유만 남긴다. **회수 × 회당 단가** 곱하기 하나뿐이다.
"""
from __future__ import annotations
@@ -86,6 +88,12 @@ ASSUMPTION_TEXT = (
"⚠ 원문이 안 정해 우리가 정한 둘 — ㉠ 운반시간의 속도는 8-1-6의 2 나 이동속도표에서 가져옴"
"(그 표는 자주식 이동표라 쓰임이 꼭 같지는 않음) · ㉡ 운반시간을 왕복으로 봄."
)
#: 회수 칸 사유 — 화면이 「왜 설계자가 넣나」를 읽는 자리.
TRIPS_NOTE = (
"회수(대수 × 왕복)는 **설계 입력**입니다 — 산림품셈 10-4 · 건설품셈 8-1-3 은 **회당 단가"
" 산출식만** 주고 대수·횟수를 정하는 공식이 원문에 없습니다(2026-09-14 전수 확인)."
" 비워 두면 수송비 금액이 서지 않습니다."
)
def parse_distance_km(raw: Any) -> Decimal | None:
@@ -102,6 +110,26 @@ def parse_distance_km(raw: Any) -> Decimal | None:
return value
def parse_trips(raw: Any) -> Decimal | None:
"""설정 칸의 **회수**(대수 × 왕복). 비거나 0 이면 `None` — 금액이 안 선다.
품셈이 정하는 값이라 **우리가 기본값을 두지 않는다**(1 회로 때우지 않음).
"""
text = str(raw or "").strip().rstrip("").strip()
if not text:
return None
try:
value = Decimal(text)
except (ArithmeticError, ValueError):
raise ValueError(f"수송 회수를 숫자로 못 읽었습니다: {raw!r}") from None
return value if value > 0 else None
def transport_amount(unit_price_krw: Decimal, trips: Decimal | None) -> Decimal | None:
"""수송비 = 회당 단가 × 회수. 회수가 없으면 `None`(0 원으로 안 채운다)."""
return None if trips is None else unit_price_krw * trips
def road_class(key: str | None) -> dict[str, Any] | None:
"""도로 구분. 못 고르면 `None` — **기본 도로를 우리가 정하지 않는다.**"""
return _ROAD_BY_KEY.get(str(key or ""))
@@ -175,6 +175,7 @@ function drawDetail(
}
if (detail.unattached_note) box.append(hint(detail.unattached_note.replace(/\*\*/g, ""), true));
if (detail.known_gap_note) box.append(hint(detail.known_gap_note, true));
if (detail.form_basis_note) box.append(hint(detail.form_basis_note, true));
}
/** 본표 한 장을 `box` 에 — 자취를 쌓고 서버 본표를 받아 그림. */
+15 -9
View File
@@ -58,7 +58,9 @@ interface TransportRow {
distance_km: string;
road: string;
roads: Array<{ key: string; label: string }>;
variants: Array<{ key: string; label: string; unit_price_krw: string }>;
trips: string;
trips_note: string;
variants: Array<{ key: string; label: string; unit_price_krw: string; amount_krw: string }>;
basis: string[];
notes: string[];
}
@@ -105,6 +107,7 @@ export async function saveFactorChoices(
fuel_region?: string;
transport_distance_km?: string;
transport_road?: string;
transport_trips?: string;
labor_surcharge?: Record<string, string>;
},
): Promise<void> {
@@ -251,22 +254,25 @@ export function drawFactorChoices(
body.append(
picker("수송 도로 구분", roadOptions, transport.road, (key) => save({ transport_road: key })),
);
body.append(
percentBox("기계 수송 회수 (대수 × 왕복)", transport.trips, "비움", (text) =>
save({ transport_trips: text }),
),
);
for (const variant of transport.variants) {
body.append(
note(
variant.unit_price_krw
? `${variant.label}회당 ${variant.unit_price_krw}`
: `${variant.label} — 아직 안 섬`,
!variant.unit_price_krw
? `${variant.label}아직 안 섬`
: variant.amount_krw
? `${variant.label} — 회당 ${variant.unit_price_krw}× ${transport.trips}회 = ${variant.amount_krw}`
: `${variant.label} — 회당 ${variant.unit_price_krw}원 (회수를 넣으면 금액이 섭니다)`,
),
);
}
for (const line of transport.notes) body.append(note(`${line}`));
for (const line of transport.basis) body.append(note(line));
body.append(
note(
"⚠ 단가는 「회당」입니다 — 몇 대를 몇 번 나르는지(회수)는 설계 입력이라 여기서 안 정합니다.",
),
);
body.append(note(transport.trips_note));
}
const surcharge = data.labor_surcharge;
@@ -175,6 +175,8 @@ export interface DetailDto {
rows: DetailRowDto[];
unattached_note: string;
known_gap_note: string;
/** 사람이 가른 표 형태 까닭(원문 근거 없는 SW 규칙 · 10-A ⑭). 없으면 빈 문자열. */
form_basis_note?: string;
/** 구성행·Q 식을 고칠 수 있는 본표(B·D)인가 · 줄 더하기에 쓸 다음 칸 이름. */
editable?: boolean;
next_add_key?: string;
@@ -246,6 +246,8 @@ function drawBill(ctx: B09TabContext, bill: BillDto, reload: () => void): void {
"b09s-hint b09s-hint--warn",
`${L("B09_Sheet_Missing")} ${bill.summary.missing.length}${L("B09_Sheet_Count")}`,
),
// 10-A ⑳ 까닭은 그대로 올림(2026-09-14 브레인 규칙) — 규칙을 목록 머리에 드러냄.
hint(L("B09_Sheet_Missing_Passed")),
);
for (const item of bill.summary.missing) {
details.append(hint(`${item.name}${item.reason}`));
+41 -19
View File
@@ -295,28 +295,33 @@ def _add_machine_layers(
"""
catalog = load_machine_catalog()
operating = {r.machine_code: r for r in load_operating_records().records}
fuel_price, fuel_meta = load_fuel_price(region=fuel_region)
wages = load_operator_wages()
incomplete: list[str] = []
fuel_code = f"{FUEL_CODE_PREFIX}경유"
if fuel_code not in book.titles:
book.add_title(
PriceTitle(
code=fuel_code,
kind=PriceKind.MATERIAL,
name="경유",
# ⚠ 어느 판으로 섰는지 **줄에 남긴다** — 지역 값과 전국평균은 리터당
# 수십 원이 갈려 단가가 조용히 달라지는 자리다.
spec=(
f"{fuel_meta.get('region_name')} 공시가"
if fuel_meta.get("region_name")
else "전국 공시가"
),
unit="L",
slots=_slots(fuel_price),
def fuel_title(kind: str) -> str:
"""연료 종류마다 자재 제목 한 벌(`M-FUEL-경유`·`M-FUEL-휘발유`) — 운전경비표 종류대로."""
code = f"{FUEL_CODE_PREFIX}{kind}"
if code not in book.titles:
price, meta = load_fuel_price(region=fuel_region, kind=kind)
book.add_title(
PriceTitle(
code=code,
kind=PriceKind.MATERIAL,
name=kind,
# ⚠ 어느 판으로 섰는지 **줄에 남긴다** — 지역 값과 전국평균은 리터당
# 수십 원이 갈려 단가가 조용히 달라지는 자리다.
spec=(
f"{meta.get('region_name')} 공시가"
if meta.get("region_name")
else "전국 공시가"
),
unit="L",
slots=_slots(price),
)
)
)
return code
fuel_title("경유")
for code in sorted(machine_codes):
machine = catalog.machines.get(code)
@@ -391,6 +396,7 @@ def _add_machine_layers(
liters = record.fuel_liters_per_hour
if liters is not None:
fuel_code = fuel_title(record.fuel_kind)
book.add_detail(PriceDetail(hourly_code, fuel_code, liters, note="주연료"))
if record.misc_material_percent is not None:
# 잡품은 **주연료비 × 율의 가산 행**(명세 7장 · STmate 17번 §3 「잡품 단가 칸 = 연료
@@ -893,9 +899,13 @@ def build_unit_prices(
# 예외 = 사용자가 다른 품셈 밑수를 확정한 자리(9-21 「1,000㎡당」 · ÷ 는 ChooseOne 이 검) —
# 마스터 목록은 원문 사실이라 그대로 두고 여기서만 걷음(2026-09-14 브레인 · 사유는 비고).
borrowed = work_item_code.startswith(tuple(BORROWED_BASIS_PER))
from B09_Estimation.B09_Estimation_ResourceAxis_JudgedTable import JUDGED_TABLES
for row in rows:
section = missing_basis.get(str(row.pum_table_id))
if section and not borrowed:
# 비고가 「인/㎥」 로 밑수를 적은 판정표(12-12 날개벽 「개소당」 머리 없음)는 밑수가 ㎥ 로 섬.
per_remark = JUDGED_TABLES.get(str(row.pum_table_id), {}).get("shape") == "remark_labor"
if section and not borrowed and not per_remark:
build.basis_missing[work_item_code] = section
break
@@ -1056,6 +1066,10 @@ def build_unit_prices(
covered += machine_share
if covered < Decimal(100):
build.partial_ratio[work_item_code] = covered
# 유로폼 사용수량(12-38-2) — 임대료 또는 손료 설계자 단가로 섬(2026-09-14 301). 부모 합산 앞.
from B09_Estimation.B09_Estimation_Euroform import attach_euroform
attach_euroform(build, nodes_by_code)
# 단계 합산형 부모(9-4 암절취 = 암파쇄 + 집토) — 잎이 다 선 뒤, 조합 16% 바꿔 달기 전(명세 2장).
from B09_Estimation.B09_Estimation_ParentSteps import attach_parent_steps
@@ -1077,11 +1091,19 @@ def build_unit_prices(
# 덤프 운반 — 운반거리(B08 유토곡선·사토장)마다 한 벌(산림품셈 10-12 「2. 운반」).
attach_dump_hauls(build, master, dump_haul_m)
# 발파 화약류(9-5-1) — 규격 미정이라 설계자 단가가 들면 붙고 아니면 사유(2026-09-14 300).
from B09_Estimation.B09_Estimation_Explosives import attach_explosives
attach_explosives(build, nodes_by_code)
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
build.combined_swapped = _apply_combined_misc_rate(
build.book, [code for code in build.book.titles if code.startswith("B-")]
)
# 암석 작업 기계손료 보정(건설품셈 8-1-7 1 · 2026-09-14 661) — 조합 바꿔 달기 뒤(`#조합` 은 안 걺).
from B09_Estimation.B09_Estimation_RockLoss import attach_rock_loss
attach_rock_loss(build, master)
build.labor_reliability = _labor_reliability_in_use(build.book)
# ③ 할증 포함 재료량을 준 공종에 자재가 재료비로 붙지 않았는가(명세 6장 · ㉠).
from B09_Estimation.B09_Estimation_Guards import check_materials_before_surcharge
@@ -174,7 +174,7 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
money = build.book.resolve(code)
# 코드에서 공종을 도로 뽑는다 — 「B-FP-09-11-01#갈래」의 갈래는 떼고 본다.
work_item_code = code[2:].split("#")[0] if code.startswith("B-") else ""
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
from B09_Estimation.B09_Estimation_KnownGaps import form_judgment_note, known_gap_note
unattached = list(build.unattached.get(work_item_code, []))
rows: list[dict] = []
@@ -317,6 +317,8 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
"unattached": unattached,
# ⚠ 원문에는 있는데 못 실린 몫 — 이름을 못 찾은 줄과 **다른 갈래**다.
"known_gap_note": known_gap_note(work_item_code),
# 사람이 가른 표 형태 까닭(SW 규칙 · 10-A ⑭) — 칸 밑 회색 한 줄.
"form_basis_note": form_judgment_note(work_item_code),
"unattached_note": (
f"⚠ 품셈 표에 있는 {len(unattached)}줄이 아직 안 붙었습니다 — "
f"{', '.join(unattached[:4])}"
@@ -20,9 +20,10 @@ WORK_ITEMS: dict[str, dict[str, Any]] = {
"name": "모르타르 배합",
"spec": "1:3",
"unit": "",
# 10-A ③ 문구 못박음(2026-09-14 사용자 확정 — SW 규칙) · 교차 참조 명시(지침 2장).
"basis": (
"건설공사 표준품셈(2026) [건축부문] 9-1-1 모르타르 배합(㎥당)"
"산림 품셈엔 배합 절이 없음 · 배합비 1:3 은 돌쌓기 시방(시멘트:잔골재 부피비) · 실무 울진 대흥(2024)·소광(2025)"
"산림품셈에 배합 절이 없어 건설품셈 [건축] 9-1-1 을 씀(교차 참조)"
"건설공사 표준품셈(2026) [건축부문] 9-1-1 모르타르 배합(㎥당) · 배합비 1:3 은 돌쌓기 시방(시멘트:잔골재 부피비) · 실무 울진 대흥(2024)·소광(2025)"
" 「모르타르배합 1:3」 과 같은 짜임"
),
# (노임 코드, 수량, 칸) — 원문 [주]② 「배합이 포함된 것이며, 비빔은 제외」.
+9 -4
View File
@@ -220,8 +220,10 @@ export interface HaulPlan {
}
/**
* . balloon이
* . .
* **** .
* balloon이 . .
* 0
* (2026-09-14 936be972 474.14 0 · ).
*/
const MIN_SWING_RATIO = 0.02;
@@ -603,14 +605,17 @@ export function computeHaulPlan(
/** () **** .
* ( ). */
conversion?: EarthworkConversion | null;
/** 그림용 — 잔진동을 거른다(`MIN_SWING_RATIO`). 안 오면 **거르지 않는다**(수량 계획). */
drawing?: boolean;
},
): HaulPlan | null {
const points = result.points;
if (points.length < 2) return null;
const range = Math.max(result.max_cumulative_m3 - result.min_cumulative_m3, 0);
const minSwing = Math.max(range * MIN_SWING_RATIO, 1);
const extrema = pruneExtrema(points, extremaIndices(points), minSwing);
const extrema = options?.drawing
? pruneExtrema(points, extremaIndices(points), Math.max(range * MIN_SWING_RATIO, 1))
: extremaIndices(points);
const tiers = sortedLimits(limits);
const blocks: HaulBlock[] = [];
@@ -323,6 +323,36 @@ def earthwork_conversion_factors(settings: dict[str, Any]) -> dict[str, dict[str
return resolved
#: 암 시공법 → 환산계수 갈래 이름(`EARTHWORK_CONVERSION_FACTORS` 키).
ROCK_METHOD_KINDS = {ROCK_METHOD_RIPPING: "ripping_rock", ROCK_METHOD_BLASTING: "blasting_rock"}
def mixed_conversion_factors(settings: dict[str, Any]) -> dict[str, dict[str, float]]:
"""토적표·유토곡선·운반표가 **계산에** 쓸 계수 — 암은 구성비 가중 C (㉱ (나) · 2026-09-14).
B06 암은 종류 **자리표시**(`ripping_rock` · 자료 `blasting_rock`)이고 암질은 설계내역에서
구성비로 정함(8-1 사용자 확정). 그래서 구성비와 갈래별 시공법이 ** 서면** 칸을
Σ몫 × C(시공법) 갈음 몫은 흙깎기와 같은 안분( 비율끼리 · 합이 100 아니어도 안분).
구성비가 비거나 시공법이 빠진 갈래가 있으면 **종전 ** 인계가 막힘 사유를 내고, 지어낸 계수를 .
화면 무엇을 골랐나(`earthwork_conversion_choices`) 갈래별 그대로 여기 값을 보이면 .
"""
resolved = earthwork_conversion_factors(settings)
ratios = settings.get("rock_ratios_pct") or {}
shares = {
name: float(ratios.get(name) or 0)
for name in rock_classes(settings)
if name != "토사" and float(ratios.get(name) or 0) > 0
}
kinds = {name: ROCK_METHOD_KINDS.get(rock_method(settings, name) or "") for name in shares}
if not shares or not all(kinds.values()):
return resolved
total = sum(shares.values())
mixed = sum(shares[name] / total * resolved[kind]["compacted"] for name, kind in kinds.items())
for kind in ROCK_METHOD_KINDS.values():
resolved[kind]["compacted"] = mixed
return resolved
def earthwork_conversion_choices(settings: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""갈래별 「무엇을 골랐나」 — 화면이 기본값과 고른 값을 갈라 보이는 데 쓴다.
@@ -43,6 +43,46 @@
"pum_edition": "2026-01-01",
"basis": "9-19-1 토사면 고르기 절토면 표(원문 L5414)가 자원 머리에 규격을 안 적음 — [주]① 「공기압축기는 3.5㎥/분, 소형브레이커는 1㎥/분, 굴착기는 0.7㎥를 기준한 것이다」 → 굴착기(무한궤도) 0.7(카탈로그 0.7 은 무한궤도뿐). 칸이 규격을 적은 줄(성토면 굴착기 0.6㎥)은 안 덮음. 2026-09-14 브레인 승인(⑤ Ⓐ)"
},
{
"axis": "resource",
"from": "다짐:봉상후렉시블(45mm)",
"to": "4611-0350",
"scope": "FP-12-15",
"pum_edition": "2026-01-01",
"basis": "12-15 집수정 표(원문 L6460)는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 건설품셈 8-3 (4611) 은 전기식 플렉시블형 ø45(0.75㎾)·엔진식 플렉시블형 ø45(2.6㎾) 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표(8-4)에 엔진식 4611-0350(휘발유 1.0L)만 있음 → 엔진식. 전기식이 필요하면 그때 엶. 2026-09-14 브레인 승인(661 뒤 ①)"
},
{
"axis": "resource",
"from": "다짐:봉상후렉시블(45mm)",
"to": "4611-0350",
"scope": "FP-12-12",
"pum_edition": "2026-01-01",
"basis": "12-12 날개벽(원문 L6419) 표는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 12-15 집수정과 같은 줄 · 같은 근거(건설품셈 8-3 (4611) 전기식·엔진식 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표에 엔진식 4611-0350 만) → 엔진식. 2026-09-14 브레인(672 다음 · 봉상후렉시블 셋)"
},
{
"axis": "resource",
"from": "다짐:봉상후렉시블(45mm)",
"to": "4611-0350",
"scope": "FP-12-13",
"pum_edition": "2026-01-01",
"basis": "12-13 면벽(원문 L6436) 표는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 12-15 집수정과 같은 줄 · 같은 근거(건설품셈 8-3 (4611) 전기식·엔진식 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표에 엔진식 4611-0350 만) → 엔진식. 2026-09-14 브레인(672 다음 · 봉상후렉시블 셋)"
},
{
"axis": "resource",
"from": "봉상후렉시블(45mm)",
"to": "4611-0350",
"scope": "FP-12-16",
"pum_edition": "2026-01-01",
"basis": "12-16 맨홀(원문 L6474) 표는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 12-15 집수정과 같은 줄 · 같은 근거(건설품셈 8-3 (4611) 전기식·엔진식 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표에 엔진식 4611-0350 만) → 엔진식. 2026-09-14 브레인(672 다음 · 봉상후렉시블 셋)"
},
{
"axis": "resource",
"from": "콘크리트 진동기(3.5HP)",
"to": "4611-0350",
"scope": "FP-12-34-01",
"pum_edition": "2026-01-01",
"basis": "12-34-1 「콘크리트 진동기(3.5HP)」 — 3.5HP = 2.6㎾ · 건설품셈 8-3 (4611) 엔진식 플렉시블형 ø45(2.6㎾) · 운전경비표에 엔진식만 · 실무 영월 중기목록 「콘크리트 진동기 45φ(2.6㎾)엔진식플렉시블형」. 2026-09-14 브레인"
},
{
"axis": "variant",
"from": "0.2·소림",
@@ -67,9 +67,10 @@
},
{
"type_id": "pipe_inlet_basin",
"reuse_count": 6,
"matched_example": "보호공 기초",
"note": "관보호공 집수정 — 원문 6회 줄의 「호안 및 보호공의 기초」에 해당. ⚠ 벽체까지 6회로 볼지는 확인 필요"
"reuse_count": 4,
"matched_example": "거푸집 합판4회",
"note": "그 공종 표가 직접 적은 「거푸집 | 합판4회」 가 정본 — 1-7-1 분류(「호안 및 보호공의 기초」 6회)보다 위(2026-09-14 브레인 ㉯ ②). 앞서 6회로 봤음.",
"basis": "품셈 12-15 집수정 표"
},
{
"type_id": "ford_pavement",
@@ -51,9 +51,10 @@
},
{
"type_id": "pipe_inlet_basin",
"class": "간단",
"matched": "간단한 기초",
"note": "관보호공 집수정 — 원문의 「간단한 기초」에 해당. ⚠ 벽체까지 간단으로 볼지는 확인 필요"
"class": "보통",
"matched": "철근가공조립(보통)",
"basis": "품셈 12-15 집수정 표",
"note": "그 공종 표가 직접 적은 「철근 | 철근가공조립(보통)」 이 정본 — 12-3 [주]① 「간단한 기초」 추정보다 위(2026-09-14 브레인 ㉯ ⑶ · 거푸집 4회와 같은 원칙). 앞서 간단으로 봤음."
}
],
"price_hint_krw_per_ton": {
@@ -342,6 +342,45 @@
"M00118"
]
}
},
{
"code": "AR-M-10aba455",
"kind": "material",
"name": "유로폼 패널",
"spec": "600 x 1,200mm",
"unit": "매",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0393"
]
}
},
{
"code": "AR-M-5b89b294",
"kind": "material",
"name": "유로폼 내부 패널",
"spec": "(200+200) x 1,200mm",
"unit": "매",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0393"
]
}
},
{
"code": "AR-M-d00a6b1a",
"kind": "material",
"name": "유로폼 임대료",
"spec": "㎡당(임대기간 반영 · 설계자 셈)",
"unit": "㎡",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0393"
]
}
}
]
}
@@ -130,7 +130,11 @@
"work_item_code": "FP-05-24",
"master_name": "씨앗뿜어붙이기",
"basis_unit": "㎡",
"basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L2769·L2794 — 5-24-1·5-24-2 절 머리 바로 아래 「(㎡당)」. ⚠ 「단위:」 글자 없이 **괄호만** 적힌 모양이라 마스터가 못 읽은 자리(2026-09-08 원문 대조)."
"basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L2769·L2794 — 5-24-1·5-24-2 절 머리 바로 아래 「(㎡당)」. ⚠ 「단위:」 글자 없이 **괄호만** 적힌 모양이라 마스터가 못 읽은 자리(2026-09-08 원문 대조).",
"leaf_from": "seed_spray_ground",
"leaf_codes": {"일반": "FP-05-24-01", "마사토": "FP-05-24-02"},
"variant_missing_reason": "초류종자살포 비탈면 토질(일반·마사토)이 아직 입력되지 않았습니다 — 산출 조건에서 고르면 단가가 섭니다(품셈 5-24 씨앗뿜어붙이기: 5-24-1 기계/일반 · 5-24-2 기계/마사토)",
"leaf_note": "2026-09-14 브레인 ㉮ — 매핑이 부모(갈래 고르기형)라 B09 가 「잎 미선택」으로 막는데 고를 칸이 없었음 · 산출 조건 칸이 잎 코드를 고름 · 제안값 없음."
},
{
"group": "되메우기",
@@ -327,6 +331,53 @@
"composite": {
"note": "품셈에 **그 이름의 공종이 없어** 여러 공종을 묶어 일위대가로 세우는 자리. 코드 하나로 못 적으므로 묶음을 적어 둔다 — 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다.",
"items": [
{
"type_id": "pipe_inlet_basin",
"when": {
"inlet_basin_material": "콘크리트"
},
"note": "2026-09-14 브레인 ㉯ ⑴~⑸ — 원문 L6460 12-15 집수정 표는 조립형(구체·버림 인력 · 다짐기 · 거푸집 합판4회 · 철근가공조립(보통)). 콘크리트 집수정만 묶음 · 돌집수정은 종전대로 12-15 곧장(⑸).",
"outside_note": "ⓘ 12-15 표의 집수정 뚜껑(스틸그레이팅 개)·설치비(재료비의 5%)는 조각 모양이 달라 묶음에 안 넣음(⑷ · 2026-09-14)",
"parts": [
{
"code": "FP-12-15#구체콘크리트",
"name": "구체콘크리트",
"unit": "㎥",
"from_components": [
"콘크리트"
]
},
{
"code": "FP-12-15#버림콘크리트",
"name": "버림콘크리트",
"unit": "㎥",
"from_components": [
"버림콘크리트"
],
"why": "□형 원단위(콘크리트 2.84㎥ = 벽 + 바닥기초)에 버림 성분이 없음 — 「설계에 없음 = 0」 으로 짓지 않고 막음(⑵)"
},
{
"code": "FP-12-04",
"name": "합판거푸집",
"unit": "㎡",
"from_components": [
"합판거푸집"
],
"kind_suffix": "formwork_reuse"
},
{
"code": "FP-12-03",
"name": "철근 현장가공 및 조립",
"unit": "ton",
"from_components": [
"이형철근 D13",
"이형철근 D16"
],
"unit_from": "kg",
"kind_suffix": "rebar_complexity"
}
]
},
{
"type_id": "retaining_wall",
"parts": [
@@ -346,7 +397,8 @@
"unit": "㎡",
"from_components": [
"합판거푸집"
]
],
"kind_suffix": "formwork_reuse"
},
{
"code": "FP-12-38",
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-14T18:26:57+09:00",
"generated_at": "2026-09-14T23:14:39+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": {
"dataset_id": "pum_forest",
@@ -12,8 +12,8 @@
"files": [
{
"file": "work_item_master_2026-01-01.json",
"sha256": "fa4d9bc80627d33a46b203f8910f9cc802c1249f2c6c8f09d2e818fce4332287",
"size_bytes": 838421
"sha256": "708a6a80d016c42d3402bd2a6cb5266fa18e1e2b5301bac823e6ff79d17fb2b6",
"size_bytes": 838969
},
{
"file": "form_undetermined_2026-01-01.json",
@@ -22,8 +22,8 @@
},
{
"file": "basis_missing_2026-01-01.json",
"sha256": "f850742268dcfb1bcc878e8b062ef44bc6a11d773074163ebb8ba1dec21a6d0d",
"size_bytes": 16790
"sha256": "d57f26c736829ef40c50dc616d96358db785295d1aa0d5959aa7bc9eaa9be842",
"size_bytes": 15845
}
]
}
@@ -172,12 +172,6 @@
"pum_form": "requirement",
"line": 2689
},
{
"pum_table_id": "F0132",
"section": "5-22-4. 평떼 시비",
"pum_form": "requirement",
"line": 2731
},
{
"pum_table_id": "F0143",
"section": "5-27. 식재면 관리",
@@ -190,12 +184,6 @@
"pum_form": "requirement",
"line": 2919
},
{
"pum_table_id": "F0145",
"section": "5-28-2. 방초매트 및 야자섬유매트 포장",
"pum_form": "requirement",
"line": 2929
},
{
"pum_table_id": "F0153",
"section": "6-1. 비료주기",
@@ -628,24 +616,6 @@
"pum_form": "requirement",
"line": 6217
},
{
"pum_table_id": "F0339",
"section": "12-7-1. 포장절단",
"pum_form": "requirement",
"line": 6254
},
{
"pum_table_id": "F0340",
"section": "12-7-2. 줄눈설치",
"pum_form": "requirement",
"line": 6269
},
{
"pum_table_id": "F0341",
"section": "12-8. 콘크리트 포장 거푸집",
"pum_form": "requirement",
"line": 6280
},
{
"pum_table_id": "F0350",
"section": "12-12. 날개벽",
@@ -676,12 +646,6 @@
"pum_form": "requirement",
"line": 6775
},
{
"pum_table_id": "F0395",
"section": "12-38-3. 설치 및 해체",
"pum_form": "requirement",
"line": 6943
},
{
"pum_table_id": "F0401",
"section": "13-2-4. 야면석 채집(인력)",
@@ -729,12 +693,6 @@
"section": "13-15-2. 목책 설치",
"pum_form": "requirement",
"line": 7745
},
{
"pum_table_id": "F0449",
"section": "13-16-2. 통기성매트",
"pum_form": "requirement",
"line": 7788
}
]
}
@@ -3,7 +3,7 @@
"dataset_id": "work_item_master_forest",
"effective_date": "2026-01-01",
"pum_edition": "2026-01-01",
"generated_at": "2026-09-14T18:26:57+09:00",
"generated_at": "2026-09-14T23:14:39+09:00",
"dataset_version": {
"dataset_id": "pum_forest",
"effective_date": "2026-01-01",
@@ -23,7 +23,7 @@
"tables_orphan": 17,
"form_undetermined": 14,
"basis_found": 204,
"basis_missing": 122,
"basis_missing": 115,
"basis_grouped": 48
},
"orphan_tables": [
@@ -32888,7 +32888,9 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"콘크리트"
],
"condition_note": [
"구 분",
"규 격",
@@ -32970,7 +32972,9 @@
]
}
],
"variant_keys": []
"variant_keys": [
"콘크리트"
]
},
{
"work_item_code": "FP-12-13",
@@ -32997,7 +33001,9 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"콘크리트"
],
"condition_note": [
"구 분",
"규 격",
@@ -33065,7 +33071,9 @@
]
}
],
"variant_keys": []
"variant_keys": [
"콘크리트"
]
},
{
"work_item_code": "FP-12-14",
@@ -33148,7 +33156,10 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"구체콘크리트",
"버림콘크리트"
],
"condition_note": [
"구 분",
"규 격",
@@ -33209,7 +33220,10 @@
]
}
],
"variant_keys": []
"variant_keys": [
"구체콘크리트",
"버림콘크리트"
]
},
{
"work_item_code": "FP-12-16",
@@ -33236,7 +33250,10 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"구체콘크리트",
"버림콘크리트"
],
"condition_note": [
"구 분",
"단위",
@@ -33343,7 +33360,10 @@
]
}
],
"variant_keys": []
"variant_keys": [
"구체콘크리트",
"버림콘크리트"
]
},
{
"work_item_code": "FP-12-17",
@@ -35252,8 +35272,8 @@
"pum_table_id": "F0385",
"section": "12-34-1. 콘크리트 타설(철근 진동기포함)",
"source_line": 6843,
"pum_form": "reference",
"form_basis": "'별도계상' — 값이 아니라 참조 지시",
"pum_form": "requirement",
"form_basis": "사람 판정 — 인력(인)·기계(대, Q=5.4㎥/hr) 소요량 표 — 「별도계상」 은 레미콘 자재 줄 비고일 뿐",
"basis_quantity": 1.0,
"basis_unit": "개소",
"basis_source": "본문",
@@ -35673,9 +35693,9 @@
],
"steps_basis": "품셈 12-38 유로폼 = 12-38-2 사용수량(자재) + 12-38-3 설치 및 해체(품) — 12-38-1 사용횟수는 금액 단계가 아니라 사용수량의 잔존율 조건",
"variant_keys": [
"복 잡",
"간 단",
"보 통",
"간 단"
"복 잡"
]
},
{
@@ -35799,7 +35819,11 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"간 단",
"보 통",
"복 잡"
],
"condition_note": [
"구 분",
"간 단",
@@ -35816,7 +35840,11 @@
]
}
],
"variant_keys": []
"variant_keys": [
"간 단",
"보 통",
"복 잡"
]
},
{
"work_item_code": "FP-12-38-03",
@@ -0,0 +1,83 @@
"""㉳ (가) 미교차 측점 지반 샘플을 지표면 끝까지 넓힘 — 2026-09-14 브레인 판정.
반폭 20m 끝에서 성토 비탈이 원지반과 만나면 면적이 잘려 성토가 작게 섰음(936be972 12 11).
열린 쪽만 +5m 확정 지표면에서 다시 계산 · 닫히면 멈춤 · 지표면 (무효 샘플)이면 멈추고 미교차 그대로.
상한 숫자 없음(지표면이 실제로 있는 ) · 716 미교차만 +5m 갈음.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
from B06_Section.B06_Section_Engine_SampleExtend import extend_unclosed # noqa: E402
from common_util.common_util_surface_sampler import CallableSurfaceSampler # noqa: E402
FRAME = {"origin": {"x": 0.0, "y": 0.0}, "left_xy": [1.0, 0.0]}
def _ground(x: np.ndarray) -> np.ndarray:
"""좌(+)는 30m 까지 1:1 로 떨어지다 완만 · 우(−)는 오르막(절토가 금방 닫힘)."""
x = np.asarray(x, dtype=float)
left = np.where(x < 30.0, 100.0 - x, 70.0 - 0.1 * (x - 30.0))
return np.where(x >= 0, left, 100.0 - 0.8 * x)
def _samples(half: float = 20.0) -> list[dict]:
offsets = np.arange(-half, half + 0.25, 0.5)
return [
{"offset_m": float(o), "elevation_m": float(z), "valid": True}
for o, z in zip(offsets, _ground(offsets))
]
def _compute(samples: list[dict]) -> dict:
return compute_cross_design(samples, 100.0, ground_type="soil", section_mode="right_cut")
def _sampler(limit: float | None = None) -> CallableSurfaceSampler:
def function(xy: np.ndarray) -> np.ndarray:
values = _ground(xy[:, 0])
if limit is not None:
values = np.where(xy[:, 0] > limit, np.nan, values)
return values
return CallableSurfaceSampler(function)
def test_열린_쪽만_5m_씩_넓혀_닫히면_멈춤() -> None:
before = _compute(_samples())
assert before["slope_unclosed"], "기준 단면이 20m 에서 안 닫혀야 시험이 뜻이 있음"
samples, info = extend_unclosed(_samples(), FRAME, _compute, _sampler())
after = _compute(samples)
assert not after["slope_unclosed"]
assert info["left_m"] > 0 and info["left_m"] % 5 == 0 and info["right_m"] == 0
assert info["surface_end"] is False
assert max(s["offset_m"] for s in samples) == pytest.approx(20.0 + info["left_m"])
assert min(s["offset_m"] for s in samples) == pytest.approx(-20.0) # 닫힌 쪽은 안 넓힘
assert after["fill_area_m2"] > before["fill_area_m2"] # 잘린 성토가 제 크기로
def test_지표면_끝이면_멈추고_미교차_그대로() -> None:
samples, info = extend_unclosed(_samples(), FRAME, _compute, _sampler(limit=32.0))
assert info["surface_end"] is True
assert max(s["offset_m"] for s in samples) == pytest.approx(30.0) # 무효가 섞인 걸음은 안 붙임
assert _compute(samples)["slope_unclosed"]
def test_닫힌_측점은_그대로() -> None:
closed = _samples(60.0)
assert extend_unclosed(closed, FRAME, _compute, _sampler()) is None
def test_서버_재계산이_부름() -> None:
source = (ROOT / "B06_Section" / "B06_Section_Server_Calc_Prebuild.py").read_text("utf-8")
assert "extend_unclosed_sections(" in source
@@ -0,0 +1,85 @@
"""12-15 집수정 조립 — 2026-09-14 브레인 ㉯ 남은 몫 판정 ⑴~⑸.
원문 L6460 12-15 표는 조립형(구체·버림 인력 비고 · 다짐기 · 거푸집 합판4회 ·
철근가공조립(보통) · 뚜껑 · 설치비 5%). B08 집수정을 12-15 곧장(개소) 이어 단가가 섰음.
다짐기(카탈로그 없음) 빠진 구체 갈래는 세움 인력만이면 조립 줄이 조용히 싸짐
원단위에 버림 성분 없음 성분 미확보 막음(0 지어냄)
철근 갈래는 12-15 보통(B08 추정 간단 걷음 · 4회와 같은 원칙)
뚜껑·설치비는 조각 모양이 달라 조립 · 사유만
돌집수정은 콘크리트 집수정 표가 아니라 종전대로
목적: 금액을 세워도 **조용히 사라지던 것이 사유로 드러남**.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
CONCRETE = {"structure_id": "b1", "type_id": "pipe_inlet_basin", "start_m": 50.0, "end_m": 50.0,
"options": {"inlet_basin_form": "□형(기본형)", "inlet_basin_material": "콘크리트",
"pipe_diameter_mm": "800"}} # fmt: skip
STONE = {"structure_id": "b2", "type_id": "pipe_inlet_basin", "start_m": 60.0, "end_m": 60.0,
"options": {"inlet_basin_form": "돌집수정 ㄷ형"}} # fmt: skip
def _basin(structure: dict) -> dict:
unit = build_table([structure], {"pipe_inlet_basin": "집수정"})
handoff = build_handoff(unit_quantity_table=unit)
return next(r for r in handoff["work_items"] if r["origin"] == "structure")
def test_버림_갈래는_서고_구체_갈래는_다짐기가_풀려야_섬() -> None:
"""⑴ 다짐기 빠진 구체 갈래는 안 세움 — 2026-09-14 661 뒤 ① 진동기(엔진식 4611-0350)가 풀려 이제 섬."""
from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of
build = cached_build()
assert "B-FP-12-15#구체콘크리트" in build.book.titles
rows = detail_of(build, "B-FP-12-15#버림콘크리트")["rows"]
assert {r["ref_code"]: float(r["quantity"]) for r in rows} == {"1013": 0.15, "1002": 0.27}
body = {r["ref_code"] for r in detail_of(build, "B-FP-12-15#구체콘크리트")["rows"]}
assert "X-4611-0350" in body, body # 인력만으로 서지 않음(⑴ 까닭 그대로)
left = " ".join(build.unattached.get("FP-12-15", []))
assert "구체콘크리트" not in left and "봉상후렉시블" not in left, left
def test_콘크리트_집수정은_조립_조각으로_가고_막힌_까닭이_드러남() -> None:
row = _basin(CONCRETE)
assert row["work_item_code"] is None, row
codes = {p["code"]: p for p in row["composite_parts"]}
assert codes["FP-12-15#구체콘크리트"]["quantity"] > 0
assert codes["FP-12-04#4회"]["quantity"] > 0
assert "FP-12-03#보통" in codes, list(codes) # ⑶ 표가 적은 갈래
reasons = " ".join(str(m.get("reason")) for m in row["composite_not_ready"] or [])
assert "FP-12-15#버림콘크리트" in {str(m.get("code")) for m in row["composite_not_ready"]}
assert reasons, row
assert "뚜껑" in row["spec_class_basis"] and "설치비" in row["spec_class_basis"] # ⑷
def test_돌집수정은_종전대로_12_15_곧장() -> None:
row = _basin(STONE)
assert row["work_item_code"] == "FP-12-15" and not row["composite_parts"], row
def test_콘크리트_집수정_콘크리트는_타설_줄이_또_세지_않음() -> None:
unit = build_table([CONCRETE], {"pipe_inlet_basin": "집수정"})
handoff = build_handoff(unit_quantity_table=unit)
placing = [
r for r in handoff["work_items"] if str(r["work_item_code"] or "").startswith("FP-12-01")
]
assert not placing, placing # 구체 조각이 셈 — 두 번 안 셈
def test_내역_조립_줄은_금액_없이_사유() -> None:
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
unit = build_table([CONCRETE], {"pipe_inlet_basin": "집수정"})
bill = build_bill(build_handoff(unit_quantity_table=unit))
line = next(r for r in bill.rows if r.code is None and "집수정" in r.name)
assert line.amount_krw is None and "묶음 조각" in line.note, line.note
+64
View File
@@ -0,0 +1,64 @@
"""토취(반입토) 줄 — 2026-09-14 브레인 ① 승인(수량만 · 금액은 사유).
B06 유토곡선이 성토 부족분을 토취로 (936be972 `haul_plan.borrow_m3` 7,259.84 · 구간)인데 B08 어느 ·인계에도
옮겨 수량이 통째로 빠졌음. 운반표에 싣고 · 토공집계에 · 인계에 막힌 (토취장 거리·재료 미정).
수량은 유토곡선이 쌓은 **다짐상태** 그대로 반입 재료를 몰라 자연상태로 되돌릴 계수가 없음(지어내지 않음).
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
BORROW_NAME,
SummaryInput,
build_rows,
)
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_HaulSummary import borrow_of # noqa: E402
PLAN = {
"borrow_m3": 7259.84,
"residuals": [
{"kind": "borrow", "volume_m3": 3494.69, "from_m": 246.54, "to_m": 529.33},
{"kind": "borrow", "volume_m3": 3765.15, "from_m": 720, "to_m": 1078.01},
{"kind": "spoil", "volume_m3": 4.57, "from_m": 205, "to_m": 205},
],
}
def test_운반계획의_토취를_구간과_함께_싣는다() -> None:
borrow = borrow_of(PLAN)
assert borrow["volume_m3"] == pytest.approx(7259.84) and borrow["volume_basis"] == "compacted"
assert [s["volume_m3"] for s in borrow["sites"]] == [3494.69, 3765.15]
assert borrow_of({"borrow_m3": 0}) is None and borrow_of(None) is None
def test_토공집계에_수량만_선다() -> None:
rows = [r for r in build_rows(SummaryInput(borrow_m3=7259.84)) if r.group == BORROW_NAME]
assert len(rows) == 1 and rows[0].amount == pytest.approx(7259.84)
assert rows[0].in_bill is False and "토취장 거리·재료 미정" in rows[0].note
def test_인계는_막힌_줄_하나_금액_없음() -> None:
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table
borrow = borrow_of(PLAN)
summary = build_table(SummaryInput(borrow_m3=borrow["volume_m3"], borrow_sites=borrow["sites"]))
handoff = build_handoff(summary_table=summary, haul_table={"rows": [], "borrow": borrow})
rows = [r for r in handoff["work_items"] if r["name"] == BORROW_NAME]
assert len(rows) == 1, rows # 한 줄만
row = rows[0]
assert row["quantity"] == pytest.approx(7259.84) and row["in_bill"] is False
assert (
row["blocked_kind"] == "input_missing" and "토취장 거리·재료 미정" in row["blocked_reason"]
)
assert "246.54~529.33m 3,494.69㎥" in row["blocked_reason"]
assert not any(BORROW_NAME in str(item) for item in handoff["unmatched_work_items"])
@@ -0,0 +1,45 @@
"""거푸집 사용횟수 → 12-4 갈래 잇기 · 집수정 4회 — 2026-09-14 브레인 ㉯ ①②.
옹벽 조립 조각 FP-12-04 합판거푸집 꼬리가 없어 12-4 사용횟수 갈래(#1~6회)를 못 고름 →
B08 성분에 사용횟수(`reuse_count`, 품셈 1-7-1 옹벽 3) 꼬리로 붙임(값은 ).
집수정은 **12-15 표가 직접 적은 거푸집 합판4회 정본** 1-7-1 분류로 6회는 걷음.
화면(자재 · 인계) 표가 같은 값이어야 ( 벌이 값으로 갈리지 않게).
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Formwork import FORMWORK_NAMES # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
WALL = {"structure_id": "w1", "type_id": "retaining_wall", "start_m": 100.0, "end_m": 110.0,
"options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}} # fmt: skip
BASIN = {"structure_id": "b1", "type_id": "pipe_inlet_basin", "start_m": 50.0, "end_m": 50.0,
"options": {"inlet_basin_form": "□형(기본형)", "inlet_basin_material": "콘크리트",
"pipe_diameter_mm": "800"}} # fmt: skip
def test_옹벽_조각_합판거푸집이_사용횟수_갈래로_이어져_단가가_섬() -> None:
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
unit = build_table([WALL], {"retaining_wall": "옹벽"})
row = next(
r for r in build_handoff(unit_quantity_table=unit)["work_items"] if r["composite_parts"]
)
part = next(p for p in row["composite_parts"] if str(p["code"]).startswith("FP-12-04"))
assert part["code"] == "FP-12-04#3회" and not part.get("not_ready"), part
assert "B-FP-12-04#3회" in cached_build().book.titles
def test_집수정_거푸집은_12_15_표의_4회_화면과_인계가_같은_값() -> None:
unit = build_table([BASIN], {"pipe_inlet_basin": "집수정"})
forms = [c for s in unit["structures"] for c in s["components"] if c["name"] in FORMWORK_NAMES]
assert forms and all(c["reuse_count"] == 4 for c in forms), forms
assert all("12-15" in c["reuse_note"] for c in forms), forms
@@ -153,6 +153,8 @@ def test_집계표_입력으로_줄임() -> None:
"volume_basis",
# 쓴 계수도 함께 온다 — 받는 쪽이 되짚을 수 있어야 한다.
"conversion_c",
# 2026-09-14 ㉱ — 구성비로 가른 암 갈래(안 가른 줄은 None) · 집계표 공종 칸이 씀.
"rock_class",
}
for row in rows
)
@@ -0,0 +1,56 @@
"""10-A ⑫ 라이브러리 항목 이름표 = 「종류 + 제원 요약」 자동 · 사용자가 고칠 수 있음 (2026-09-14 사용자 확정).
코드는 난수라 이름이 흔들려도 안전 이름은 고르개에 보이는 글자일 .
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
from pydantic import ValidationError
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402
import B08_Quantity.B08_Quantity_Router_StructureSheet as router_module # noqa: E402
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402
def _saved(folder: Path, code: str) -> dict:
return json.loads((folder / f"{code}.json").read_text(encoding="utf-8"))
def test_저장_때_적은_이름표가_실린다(tmp_path: Path) -> None:
template = load_template("masonry_wet")
code = library_module.save_personal(
tmp_path, template, None, None, "personal", name=" 돌쌓기(찰) H=2.5 1:0.3 봉화 "
)
assert _saved(tmp_path, code)["name"] == "돌쌓기(찰) H=2.5 1:0.3 봉화"
assert template["name"] == load_template("masonry_wet")["name"] # 원본 안 건드림
def test_이름표를_비우면_양식_이름_그대로(tmp_path: Path) -> None:
template = load_template("masonry_wet")
code = library_module.save_personal(tmp_path, template, None, None, "personal", name=" ")
assert _saved(tmp_path, code)["name"] == template["name"]
def test_요청은_이름표를_받고_길이를_막는다() -> None:
request = router_module.LibrarySaveRequest.model_validate({"sheet_key": "k", "name": ""})
assert request.name == ""
with pytest.raises(ValidationError):
router_module.LibrarySaveRequest.model_validate({"sheet_key": "k", "name": "" * 201})
source = (ROOT / "B08_Quantity" / "B08_Quantity_Router_StructureSheet.py").read_text("utf-8")
assert "payload.name" in source
def test_저장_발행_창이_종류_제원_요약을_이름표로_제안한다() -> None:
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text("utf-8")
assert ui.count("window.prompt(") == 2 # [내 라이브러리에 저장] · [발행]
assert "options.defaultName" in ui and "name: tag" in ui
sheet_ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet.ts").read_text("utf-8")
assert "defaultName: sheet.title" in sheet_ui
+108
View File
@@ -0,0 +1,108 @@
"""라이브러리 공유 — 같은 회사 동료에게 내 항목을 복사해 보냄 (PLAN 4장 · 2026-09-14 브레인 판정 ①).
받는 쪽이 모르게 들어가지 않음 받은 항목은 사람 개인 단을 덮지 않고 **받음 ** 따로
(개인 단은 종류당 하나라 그대로 넣으면 동료가 고친 것이 조용히 덮임). 받은 가져오기·복제는 받는 사람 .
같은 회사 안만 · 출처 공사명은 그대로(같은 회사라 공사를 · 판정 ).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402
import B08_Quantity.B08_Quantity_Router_StmateLibrary as router_module # noqa: E402
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402
from common_util.common_util_auth import verify_session # noqa: E402
LIB = "/api/projects/33333333-3333-3333-3333-333333333333/quantity/structure-sheets/library"
MINE = "AX-ST-0000aaaa"
@pytest.fixture()
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
monkeypatch.setattr(library_module, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
item = {
**load_template("masonry_wet"),
"code": MINE,
"library_tier": "personal",
"name": "내 돌쌓기",
"origin": {"project": "봉화 임도"},
}
folder = tmp_path / "storage" / "7" / "42" / "library"
folder.mkdir(parents=True)
(folder / f"{MINE}.json").write_text(json.dumps(item), encoding="utf-8")
# 받는 사람(43)도 같은 종류 내 것이 있음 — 덮이면 안 됨
theirs = tmp_path / "storage" / "7" / "43" / "library"
theirs.mkdir(parents=True)
(theirs / "AX-ST-0000bbbb.json").write_text(
json.dumps({**item, "code": "AX-ST-0000bbbb", "name": "동료 것"}), encoding="utf-8"
)
async def members(company_id: int) -> list[dict]:
assert company_id == 7
return [
{"id": 42, "name": "", "email": "me@x"},
{"id": 43, "name": "동료", "email": "you@x"},
]
monkeypatch.setattr(router_module, "_company_members", members)
app = FastAPI()
app.include_router(router_module.router)
app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 42}
return TestClient(app)
def test_동료_목록은_같은_회사에서_나를_뺀다(client: TestClient) -> None:
body = client.get(f"{LIB}/colleagues").json()
assert body["colleagues"] == [{"id": 43, "name": "동료"}]
def test_보내면_받는_쪽_받음_단에_서고_그_사람_개인_단은_안_덮인다(
client: TestClient, tmp_path: Path
) -> None:
sent = client.put(
f"{LIB}/share", json={"type_id": "masonry_wet", "code": MINE, "to_user_id": 43}
)
assert sent.status_code == 200, sent.text
received = tmp_path / "storage" / "7" / "43" / "library_received" / f"{MINE}.json"
saved = json.loads(received.read_text(encoding="utf-8"))
assert saved["library_tier"] == "received"
assert saved["received_from"]["name"] == "" and saved["origin"]["project"] == "봉화 임도"
theirs = tmp_path / "storage" / "7" / "43" / "library" / "AX-ST-0000bbbb.json"
assert json.loads(theirs.read_text(encoding="utf-8"))["name"] == "동료 것"
dirs = library_module.tier_dirs(7, 43)
listed = library_module.list_items(dirs, "masonry_wet")
assert [i["tier"] for i in listed][:2] == ["personal", "received"]
def test_회사_밖이나_내_것_아닌_항목은_못_보낸다(client: TestClient) -> None:
stranger = client.put(
f"{LIB}/share", json={"type_id": "masonry_wet", "code": MINE, "to_user_id": 99}
)
assert stranger.status_code == 404
missing = client.put(
f"{LIB}/share", json={"type_id": "masonry_wet", "code": "AX-ST-0000cccc", "to_user_id": 43}
)
assert missing.status_code == 404
myself = client.put(
f"{LIB}/share", json={"type_id": "masonry_wet", "code": MINE, "to_user_id": 42}
)
assert myself.status_code == 400
def test_화면에_보내기_단추와_받음_표시() -> None:
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
encoding="utf-8"
)
assert "/share" in ui and "/colleagues" in ui and "동료에게 보내기" in ui
assert 'received: "받음"' in ui
@@ -0,0 +1,106 @@
"""㉱ (가) 암 운반량을 구성비로 가르기 — 2026-09-14 브레인 판정(구성비가 정본 · 8-1 사용자 확정).
B06 토사 토글 = 저장값 `ripping_rock` **자리표시**(갈라 넣는 것은 설계내역 )인데
운반·사토가 이름을 값처럼 써서 깎기 암은 구성비가 비어 막히는데 운반 암은 리핑암으로 금액이 .
운반표 곳에서 줄을 구성비·시공법으로 가름 · 비면 줄로 막음(깎기와 같은 사유).
유토곡선 거리·다짐 부피는 그대로 검산이 흔들림(() 차례).
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput # noqa: E402
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as summary # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( # noqa: E402
NOTE_METHOD_MISSING,
NOTE_ROCK_RATIO_MISSING,
)
from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table, check_against_plan # noqa: E402
from B08_Quantity.B08_Quantity_Engine_RockSplit import apply_rock_split # noqa: E402
CLASSES = ["토사", "풍화암", "연암", "보통암", "경암"]
PLAN = {
"blocks": [
{
"bands": [
{"equipment": "dozer", "haul_distance_m": 40.0, "haul_from_m": 0.0,
"haul_to_m": 40.0, "ea_m3": 90.0, "rr_m3": 115.0, "br_m3": 0.0},
]
}
],
"transfers": [],
"hauled_m3": 205.0,
"transferred_m3": 0.0,
} # fmt: skip
SPOIL = {"volume_m3": 23.0, "distance_m": 300.0, "by_ground_m3": {"rr_m3": 23.0},
"natural_m3_by_ground": {"rr_m3": 20.0}} # fmt: skip
def _haul(ratios: dict, methods: dict) -> dict:
haul = build_table(PLAN)
haul["spoil"] = dict(SPOIL)
apply_rock_split(haul, CLASSES, ratios, methods)
return haul
def _rock(haul: dict) -> list[dict]:
return [r for r in haul["rows"] if r["ground"] != "토사"]
def test_구성비가_비면_운반_암도_한_줄_암으로_막힘() -> None:
haul = _haul({}, {})
(rock,) = _rock(haul)
assert rock["ground"] == "" and rock["natural_m3"] == pytest.approx(100.0)
assert rock["blocked_reason"] == NOTE_ROCK_RATIO_MISSING
items = build_handoff(haul_table=haul)["work_items"]
dozer = next(r for r in items if r["name"].endswith("운반") and r["ground_class"] == "")
assert (
dozer["blocked_kind"] == "input_missing"
and dozer["blocked_reason"] == NOTE_ROCK_RATIO_MISSING
)
spoil = next(r for r in items if r["name"] == "사토 운반")
assert (
spoil["blocked_kind"] == "input_missing"
and NOTE_ROCK_RATIO_MISSING in spoil["blocked_reason"]
)
def test_구성비와_시공법대로_갈리고_검산은_그대로() -> None:
ratios = {"연암": 60.0, "보통암": 40.0}
methods = {"연암": "ripping", "보통암": "blasting"}
haul = _haul(ratios, methods)
got = [(r["rock_class"], r["ground"], round(r["natural_m3"], 6)) for r in _rock(haul)]
assert got == [("연암", "리핑암", 60.0), ("보통암", "발파암", 40.0)]
assert check_against_plan(haul, PLAN).difference_m3 == pytest.approx(0.0)
items = build_handoff(haul_table=haul)["work_items"]
dozer = [r for r in items if r["haul_equipment"] == "dozer" and r["ground_class"] != "토사"]
assert [(r["spec"], r["variant_value"], r["blocked_kind"]) for r in dozer] == [
("연암 · 리핑암", "리핑암", None),
("보통암 · 발파암", "발파암", None),
]
spoil = [r for r in items if r["name"] == "사토 운반"]
assert [(r["variant_value"], round(r["quantity"], 3)) for r in spoil] == [
("리핑암", 12.0),
("발파암", 8.0),
]
# 깎기와 같은 몫 — 흙깎기 암 1000 이 같은 구성비로 600 · 400
rows = summary(SummaryInput(earthwork_totals={"cut_rock_volume_m3": 1000.0},
rock_classes=CLASSES, rock_ratios_pct=ratios))["rows"] # fmt: skip
cut = {r["item"]: r["amount"] for r in rows if r["group"] == "흙깎기" and r["item"] != "토사"}
assert cut == {"연암": 600.0, "보통암": 400.0}
def test_시공법을_안_고른_갈래만_막힘() -> None:
haul = _haul({"연암": 50.0, "경암": 50.0}, {"연암": "ripping"})
rows = {r["rock_class"]: r for r in _rock(haul)}
assert not rows["연암"]["blocked_reason"]
assert rows["경암"]["blocked_reason"] == NOTE_METHOD_MISSING
@@ -0,0 +1,66 @@
"""초류종자살포 잎 고르기 — 2026-09-14 브레인 차례 ㉮(B08 나머지 탭 훑기).
매핑이 부모(FP-05-24 씨앗뿜어붙이기 · 갈래 고르기형) 가리켜 B09 미선택으로 막는데 고를 칸이 없었음
금액이 영영 (면고르기와 같은 ). 산출 조건 초류종자살포 비탈면 토질(5-24-1 일반 · 5-24-2 마사토)
· 매핑 `leaf_from`·`leaf_codes` 코드로 잇고 · 비면 금액 없이 입력 사유 · 제안값 없음(지어내지 않음).
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SEED_SPRAY_GROUNDS # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
SUMMARY = {"rows": [{"group": "초류종자살포", "item": "", "spec": "씨드스프레이", "unit": "",
"amount": 1500.0, "application_ratio_pct": 100.0}]} # fmt: skip
def _row(**inputs) -> dict:
rows = build_handoff(summary_table=SUMMARY, **inputs)["work_items"]
return next(r for r in rows if r["name"] == "초류종자살포")
def test_토질을_고르면_잎_코드로_감() -> None:
assert _row(seed_spray_ground="일반")["work_item_code"] == "FP-05-24-01"
row = _row(seed_spray_ground="마사토")
assert row["work_item_code"] == "FP-05-24-02" and not row["blocked_kind"], row
def test_토질이_비면_부모_코드에_금액_없이_입력_사유() -> None:
row = _row()
assert row["work_item_code"] == "FP-05-24"
assert row["blocked_kind"] == "input_missing", row
assert "일반·마사토" in row["blocked_reason"] and "산출 조건" in row["blocked_reason"]
def test_선택지는_마스터_잎_이름과_같음() -> None:
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
leaves = {
n["work_item_code"]: n["name"].split("/")[-1]
for n in load_work_item_master()["work_items"]
if n.get("parent_code") == "FP-05-24"
}
assert leaves == {"FP-05-24-01": "일반", "FP-05-24-02": "마사토"}
assert set(SEED_SPRAY_GROUNDS) == set(leaves.values())
def test_산출_조건이_받고_선택지_밖은_안_정함으로() -> None:
source = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Earthwork.py").read_text("utf-8")
assert '("seed_spray_ground", SEED_SPRAY_GROUNDS)' in source
assert 'table["seed_spray_choices"]' in source
material = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Material.py").read_text("utf-8")
assert 'seed_spray_ground=settings.get("seed_spray_ground") or None' in material
def test_화면_칸이_있고_저장_읽기에_실림() -> None:
page = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.ts").read_text("utf-8")
assert "seed_spray_ground: draft.seed_spray_ground" in page
assert "seed_spray_ground: (stored.seed_spray_ground" in page
assert "appendSeedSprayField(" in page
@@ -0,0 +1,64 @@
"""산출 조건 [저장]이 운반계획을 다시 세움 — 2026-09-14 브레인 ②(지침 5장 「[저장]·[확정]은 서버가 정본으로 다시 계산」).
구성비·시공법·계수·도쟈 한계거리는 B06 유토곡선(운반계획) 밑수인데 B08 [저장] 운반계획을 다시
B06 다시 저장하기 전까지 토적표( 계수) 운반표( 계획) 갈렸음(계수 칸은 전부터 같은 ).
칸이 **바뀐 저장** 서버 재계산(`recompute_server_side`) 부름 · 다른 저장은 부름(무거움).
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
import B08_Quantity.B08_Quantity_Router_Earthwork as earthwork_router # noqa: E402
PROJECT_ID = "56565656-5656-5656-5656-565656565656"
URL = f"/api/projects/{PROJECT_ID}/quantity/settings"
@pytest.fixture()
def calls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[TestClient, list]:
recorded: list = []
async def fake_run(func, *args):
return "project"
async def fake_recompute(project_id):
recorded.append(str(project_id))
return True
monkeypatch.setattr(earthwork_router, "run_with_connection", fake_run)
monkeypatch.setattr(earthwork_router, "resolve_stored_project_path", lambda _p: str(tmp_path))
monkeypatch.setattr(earthwork_router, "_recompute_haul_plan", fake_recompute)
app = FastAPI()
app.include_router(earthwork_router.router)
return TestClient(app), recorded
def test_운반계획_밑수가_바뀐_저장만_재계산(calls) -> None:
client, recorded = calls
ratios = {"rock_ratios_pct": {"연암": 60, "보통암": 40}}
first = client.put(URL, json=ratios)
assert first.status_code == 200 and first.json()["haul_plan_recomputed"] is True
assert recorded == [PROJECT_ID]
# 같은 값을 다시 저장 — 안 부름
assert client.put(URL, json=ratios).json()["haul_plan_recomputed"] is False
# 운반계획과 무관한 칸 — 안 부름
assert (
client.put(URL, json={"topsoil_thickness_m": 0.2}).json()["haul_plan_recomputed"] is False
)
assert len(recorded) == 1
for body in (
{"rock_methods": {"연암": "ripping"}},
{"conversion_factors_override": {"soil": {"compacted": 0.85}}},
{"dozer_haul_limit_m": 70},
):
assert client.put(URL, json=body).json()["haul_plan_recomputed"] is True, body
assert len(recorded) == 4
@@ -261,6 +261,41 @@ def test_발행은_권한대로_회사_기본_단에_같은_모양으로(client:
]
def test_프로그램_기본_발행본엔_원문_공사명_파일명을_안_싣는다(tmp_path: Path) -> None:
"""2026-09-14 브레인 ② — 모든 회사로 가는 발행본에서만 가림 · 원본·회사 단은 그대로(③)."""
origin = {
"kind": "stmate_xlsx",
"file": "봉화.xlsx",
"project": "2024년 봉화 임도",
"hopyo_no": 6,
}
template = {**load_template("masonry_wet"), "origin": origin}
program = library_module.save_personal(tmp_path / "program", template, None, None, "program")
company = library_module.save_personal(tmp_path / "company", template, None, None, "company")
published = json.loads((tmp_path / "program" / f"{program}.json").read_text(encoding="utf-8"))
kept = json.loads((tmp_path / "company" / f"{company}.json").read_text(encoding="utf-8"))
assert "project" not in published["origin"] and "file" not in published["origin"]
assert published["origin"]["masked"] == "원문에서 뽑음 — 공사명은 발행 시 가림"
assert published["origin"]["kind"] == "stmate_xlsx"
assert kept["origin"] == origin
assert template["origin"] == origin # 원본은 안 건드림
def test_발행_확인창이_가릴_공사명을_보인다() -> None:
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
encoding="utf-8"
)
assert "originProject" in ui and "공사명은 빼고 발행됩니다" in ui
sheet_ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet.ts").read_text(
encoding="utf-8"
)
assert "originProject: sheet.library_item.origin_project" in sheet_ui
template_py = (ROOT / "B08_Quantity" / "B08_Quantity_Engine_StructureTemplate.py").read_text(
encoding="utf-8"
)
assert '"origin_project"' in template_py
def test_회사_없는_사람은_개인_단을_못_쓴다(client: TestClient) -> None:
client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1}
sheet = _sheet(client)
+39
View File
@@ -231,3 +231,42 @@ def test_하도급대금_지급보증은_기본_꺼짐() -> None:
)
> 0
)
#: 실무 거창 2025 꼴 — 안전관리비 한 줄만 서고 관급이 큰 모양. 총공사비 끝자리가
#: **건너뛰는** 자리가 생긴다(이윤 1원이 총공사비 1~2원이라 어떤 배수는 못 밟는다).
_SKIPPING = CostInput(
direct_material_krw=Decimal(80_165_010),
direct_labor_krw=Decimal(243_648_150),
direct_expense_krw=Decimal(0),
owner_supplied_material_krw=Decimal(74_634_214),
enabled_items=("safety_management_cost",),
cut_basis="grand_total",
cut_unit_krw=1000,
)
@pytest.mark.parametrize("delta", [0, 1, 6, 13, 18, 24, 26, 31, 38, 39])
def test_절사는_끝자리를_건너뛰는_자리에서도_앉는다(delta) -> None:
"""⭐ 이윤 1원을 깎으면 총공사비는 **1원이나 2원** 줄어 어떤 1,000 배수는 못 밟는다.
종전엔 ÷1.1 어림이 아래로 지나친 3걸음 안에 돌아와 끝자리 999 남았다
(거창 40 자리 16). 밟는 배수면 ** 아래 배수로 내려가** 앉아야 한다.
"""
result = calculate_cost(replace(_SKIPPING, direct_labor_krw=Decimal(243_648_150 + delta)))
assert result.totals["grand_total"] % 1000 == 0, delta
assert not [n for n in result.notes if "안 앉음" in n], result.notes
@pytest.mark.parametrize("delta", [0, 1, 6, 13, 18, 24, 26, 31, 38, 39])
def test_절사는_깎을_수_있는_가장_적은_몫만_깎는다(delta) -> None:
"""버림이므로 깎인 몫은 **한 칸(1,000원) 안쪽**이어야 한다.
줄이 깨지면 밟는 배수를 만나 내려갔다 뜻이다 실측(거창 40 자리)에서는
칸도 내려갔다. 깨지는 자리가 생기면 자체가 봐야 소식이다.
"""
data = replace(_SKIPPING, direct_labor_krw=Decimal(243_648_150 + delta))
plain = calculate_cost(replace(data, cut_basis="none"))
cut = calculate_cost(data)
drop = plain.totals["grand_total"] - cut.totals["grand_total"]
assert 0 <= drop < 1000, (delta, drop)
+124
View File
@@ -0,0 +1,124 @@
"""유로폼 사용수량 12-38-2 — 2026-09-14 브레인 301 판정 ①~⑥ · ③′.
산림 12-38-2 정본 임대료 또는 손료 설계자 선택 · 제안값 없음
손료 = 자재가 × 수량 곧장(실무 봉화 31,500 × 0.89 / 10) · 12-38-1 잔존율은 곱함
이중계상 가드에서 12-38-02 패널이 B08 자재총괄에 줄이 없어서. 되살아날 자리를 시험으로
고르는 자리 = 자재 단가 (넣은 쪽으로 · 다면 ) 임대료 갈래엔 %
"""
from __future__ import annotations
from decimal import Decimal
from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of
PANEL = "AR-M-10aba455"
INNER = "AR-M-5b89b294"
RENT = "AR-M-d00a6b1a"
LOSS = ((PANEL, "31500", "봉화 2024"), (INNER, "21000", "봉화 2024"))
def _titles(build, code: str) -> list[str]:
return [t for t in build.book.titles if t == f"B-{code}" or t.startswith(f"B-{code}#")]
def test_아무것도_안_넣으면_안_서고_설계자_선택_사유() -> None:
build = cached_build()
assert not _titles(build, "FP-12-38-02")
reason = build.component_gaps.get("FP-12-38-02", "")
assert "임대료 또는 손료" in reason, reason
assert "임대료 또는 손료" in build.component_gaps.get("FP-12-38", "") # 부모까지 까닭이 올라감
for material in (PANEL, INNER, RENT):
assert "FP-12-38-02" in build.material_uses.get(material, []), material
def test_손료는_표_수량_곧장_부자재_소모자재는_주자재비의_퍼센트() -> None:
"""봉화 2024 호표 「유로폼 설치 및 해체」 재료비 보통 4,500 · 간단 3,697 과 같은 값 — 실무 실증."""
build = cached_build(material_prices=LOSS)
assert sorted(_titles(build, "FP-12-38-02")) == [
"B-FP-12-38-02#간단",
"B-FP-12-38-02#보통",
"B-FP-12-38-02#복잡",
]
detail = detail_of(build, "B-FP-12-38-02#보통")["rows"]
rows = {r["ref_code"]: Decimal(r["quantity"]) for r in detail if r.get("ref_code")}
assert rows[PANEL] == Decimal("0.089") and rows[INNER] == Decimal("0.003"), rows
assert build.book.resolve("B-FP-12-38-02#보통").material == Decimal(4500)
assert build.book.resolve("B-FP-12-38-02#간단").material == Decimal(3697) # 봉화 간단 3,697
# 부모 12-38 = 사용수량 + 설치 및 해체 — 두 단계 갈래가 같으면 갈래끼리 합산
parent = {r["ref_code"] for r in detail_of(build, "B-FP-12-38#보통")["rows"]}
assert parent == {"B-FP-12-38-02#보통", "B-FP-12-38-03#보통"}, parent
def test_작업조_표는_밑수가_시공량이라_밑수_없음이_아님() -> None:
"""12-38-3 「(단위: 일 당) 형틀목공 4인 · 시공량 35㎡」 — 1㎡당 = 인원 ÷ 시공량(CrewOutput).
밑수 N 문구가 없다고 `basis_missing` 들어 설치·해체가 통째로 막혀 있었음(작업조 ).
"""
build = cached_build()
crew = ("FP-05-28-02", "FP-12-07-02", "FP-12-38-03", "FP-13-16-02")
assert not [code for code in crew if code in build.basis_missing]
rows = {
r["name"]: Decimal(r["quantity"]) for r in detail_of(build, "B-FP-12-38-03#보통")["rows"]
}
assert rows["형틀목공"] == Decimal(4) / Decimal(35), rows
def test_잔존율은_안_곱하고_역산은_추정이라_밝힘() -> None:
build = cached_build(material_prices=LOSS)
note = next(
r["note"]
for r in detail_of(build, "B-FP-12-38-02#보통")["rows"]
if r.get("ref_code") == PANEL
)
assert "우리 역산" in note and "추정" in note and "25회" in note, note
def test_임대료는_한_줄만_퍼센트_안_걺() -> None:
build = cached_build(material_prices=((RENT, "5000", "견적"),))
assert _titles(build, "FP-12-38-02") == ["B-FP-12-38-02"]
rows = detail_of(build, "B-FP-12-38-02")["rows"]
assert [r["ref_code"] for r in rows] == [RENT], rows
assert build.book.resolve("B-FP-12-38-02").material == Decimal(5000)
assert "B-FP-12-38#보통" in build.book.titles
def test_둘_다_넣거나_한쪽만_반쯤_넣으면_안_섬() -> None:
both = cached_build(material_prices=(*LOSS, (RENT, "5000", "견적")))
assert not _titles(both, "FP-12-38-02")
assert "둘 다" in both.component_gaps["FP-12-38-02"]
half = cached_build(material_prices=(LOSS[0],))
assert not _titles(half, "FP-12-38-02")
assert "내부 패널" in half.component_gaps["FP-12-38-02"]
def test_유로폼이_자재총괄에_줄로_서면_가드를_되살릴_것() -> None:
"""④ 12-38-02 를 가드에서 뺀 전제 — 패널·유로폼이 B08 자재총괄(`material`)에 줄이 없음.
누가 유로폼을 자재총괄로 보내는 할증이 붙음 여기서 빨강 · 가드에 12-38-02 되돌릴 .
"""
import json
from pathlib import Path
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import DESTINATION
from B09_Estimation.B09_Estimation_Guards import SURCHARGE_INCLUDED_ITEMS
assert "FP-12-38-02" not in SURCHARGE_INCLUDED_ITEMS
words = ("유로폼", "패널")
assert all(DESTINATION[n] != "material" for n in DESTINATION if any(w in n for w in words))
root = Path(__file__).resolve().parents[2] / "resources" / "data_structure_unit"
for path in root.glob("*.json"):
text = path.read_text(encoding="utf-8")
def walk(node):
if isinstance(node, dict):
name = str(node.get("name") or "")
if any(w in name for w in words):
assert node.get("destination") != "material", (path.name, node)
for value in node.values():
walk(value)
elif isinstance(node, list):
for value in node:
walk(value)
walk(json.loads(text))
+28
View File
@@ -0,0 +1,28 @@
"""발파 화약류(9-5-1) — 2026-09-14 브레인 300: 규격 미정은 설계자 선택 + 사유 · 값은 수동 단가."""
from __future__ import annotations
from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of
def test_단가가_없으면_규격_미정_사유와_자재_단가_칸() -> None:
build = cached_build()
labels = build.unattached.get("FP-09-05-01") or []
assert any("뇌관 — 규격 미정" in label for label in labels), labels
assert not any(label.replace(" ", "") == "뇌관" for label in labels), labels
assert "FP-09-05-01" in build.material_uses.get("AR-M-0965627d", [])
def test_설계자_단가가_들면_표_수량으로_붙음() -> None:
build = cached_build(material_prices=(("AR-M-0965627d", "500", "견적"),))
rows = {r["ref_code"]: float(r["quantity"]) for r in detail_of(build, "B-FP-09-05-01")["rows"]}
assert rows.get("AR-M-0965627d") == 1.0, rows
labels = build.unattached.get("FP-09-05-01") or []
assert not any("뇌관" in label for label in labels), labels
def test_착암기는_압축기_부수물이라_손료_고시_없음_사유() -> None:
"""건설품셈 8-3-6 (5205) [주]① 「부수물은 별도 계상」 — 압축기 손료에 든다고 안 정함."""
labels = cached_build().unattached.get("FP-09-05-01") or []
note = next((label for label in labels if label.startswith("착암기")), "")
assert "부수물" in note and "고시 없음" in note, labels
@@ -0,0 +1,85 @@
"""봉상후렉시블 셋 + 12-34-1 — 2026-09-14 브레인(672 다음).
같은 봉상후렉시블(45mm) Q=5.4/hr 하나가 12-12 날개벽 · 12-13 면벽 · 12-16 맨홀을 막고 있었음.
표는 12-15 집수정과 같은 모양( 비고 인력 + 다짐기 ) 판정표 벌에 더하고 엔진식 4611-0350 이음.
실무 영월 콘크리트타설(임도품셈 5-5) 호표가 같은 모양: 콘크리트공 0.15·보통인부 0.27 / + 진동기 1/Q(5.4)
· 진동기 45φ(2.6)엔진식플렉시블형 손료 252천원 × 0.5101 · 노무 0 엔진식·계수·조종원 없음이 실무 실증.
12-34-1 콘크리트 타설(철근 진동기 포함) 실무 호표 없음 브레인 판정
콘크리트 진동기(3.5HP) 2 4611-0350 × 2 · 봉상후렉시블(45mm) 2 같은 기계의 봉이라
머리 (단위: 개소당) 인력 0.17·0.29 12-16 맨홀 당과 같고 기계도 Q /hr 당으로 읽고 사유에 나란히
"""
from __future__ import annotations
from decimal import Decimal
from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of
VIBRATOR = "X-4611-0350"
PER_Q = Decimal(1) / Decimal("5.4")
def _rows(build, code: str) -> dict[str, Decimal]:
return {
r["ref_code"]: Decimal(r["quantity"])
for r in detail_of(build, code)["rows"]
if r.get("ref_code")
}
def test_날개벽_면벽은_콘크리트_갈래가_진동기와_함께_섬() -> None:
build = cached_build()
for code in ("B-FP-12-12#콘크리트", "B-FP-12-13#콘크리트"):
rows = _rows(build, code)
assert rows[VIBRATOR] == PER_Q and rows["1013"] == Decimal("0.15"), (code, rows)
assert rows["1002"] == Decimal("0.27"), (code, rows)
assert build.book.titles[code].unit == ""
assert (
"FP-12-12" not in build.basis_missing
) # 비고가 인/㎥ — 「개소당」 머리가 없어도 밑수는 ㎥
def test_맨홀은_비고가_끝_칸이_아니어도_구체_버림_갈래() -> None:
build = cached_build()
body = _rows(build, "B-FP-12-16#구체콘크리트")
assert (
body[VIBRATOR] == PER_Q
and body["1013"] == Decimal("0.17")
and body["1002"] == Decimal("0.29")
)
lean = _rows(build, "B-FP-12-16#버림콘크리트")
assert lean == {"1013": Decimal("0.15"), "1002": Decimal("0.27")}, lean
for code in ("FP-12-12", "FP-12-13", "FP-12-16"):
assert "봉상후렉시블" not in " ".join(build.unattached.get(code, [])), code
def test_12_34_1_진동기_두_대만_세고_봉은_두_번_안_셈() -> None:
build = cached_build()
rows = _rows(build, "B-FP-12-34-01")
assert rows[VIBRATOR] == 2 * PER_Q, rows
assert rows["1013"] == Decimal("0.17") and rows["1002"] == Decimal("0.29"), rows
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
assert "봉상후렉시블" in known_gap_note("FP-12-34-01") and "두 번 안 셈" in known_gap_note(
"FP-12-34-01"
)
refs = [r.get("ref_code") for r in detail_of(build, "B-FP-12-34-01")["rows"]]
assert refs.count(VIBRATOR) == 1, refs # 봉 줄이 기계로 또 안 섬(한 줄 · 2대 몫)
def test_12_34_1_은_개소당_머리를_세제곱미터당으로_읽은_까닭을_보임() -> None:
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
build = cached_build()
assert build.book.titles["B-FP-12-34-01"].unit == ""
note = known_gap_note("FP-12-34-01")
assert "개소당" in note and "㎥당" in note and "12-16" in note, note
def test_표의_나머지_줄은_조용히_안_사라지고_사유에_원문_그대로() -> None:
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
assert "원형거푸집" in known_gap_note("FP-12-16") and "0.16" in known_gap_note("FP-12-16")
assert "기초잡석" in known_gap_note("FP-12-12") and "30%" in known_gap_note("FP-12-12")
assert "기초잡석" in known_gap_note("FP-12-13")
@@ -0,0 +1,33 @@
"""10-A ⑭ 표 형태 66표 까닭을 화면에 — 2026-09-14 사용자 확정 「SW 규칙 · 화면에 드러낼 것」.
사람이 가른 형태(`_Forms.FORM_JUDGMENTS`) 원문 근거가 없는 우리 규칙
마스터엔 있으나 화면에 번도 닿았음. 모양은 서브 10-A 채움과 같게 ** 회색 **:
일위대가 상세(`detail_of` 화면 hint) · 일위대가가 없는 공종은 내역 일위대가 없음 비고.
"""
from __future__ import annotations
from B09_Estimation.B09_Estimation_KnownGaps import form_judgment_note
from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of
def test_판정표가_딸린_일위대가_상세에_까닭_한_줄() -> None:
build = cached_build()
code = next(c for c in build.book.titles if c.startswith("B-FP-13-04-04"))
note = detail_of(build, code)["form_basis_note"]
assert "F0412" in note and "참조표" in note and "뒷길이 표준" in note, note
assert "SW 규칙" in note
def test_판정표_없는_공종은_빈_칸() -> None:
assert form_judgment_note("FP-09-03-02") == ""
def test_일위대가_없는_공종은_내역_비고에_까닭() -> None:
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
item = {"work_item_code": "FP-12-38-01", "name": "사용횟수", "spec": "", "unit": "",
"quantity": "1", "in_bill": True} # fmt: skip
rows = build_bill({"work_items": [item], "materials": []}).rows
line = next(r for r in rows if r.code == "FP-12-38-01")
assert "F0392" in line.note and "계수표" in line.note, line.note
@@ -0,0 +1,103 @@
"""연료 종류(휘발유) · 콘크리트 진동기 — 2026-09-14 브레인 661 뒤 ①②.
운전경비표 `fuel_kind` 조립이 읽어 **휘발유 기계에 경유값** 붙고 있었음(플레이트 콤팩터 ·
진동기 · 믹서 · 래머 · 커터) 종류대로 유가(전국·시도 ) .
콘크리트 진동기 원문 8-3 (4611) 줄이 칸에 뭉쳐 규격·손료계수가 비었음 원문값 되살림
(전기식 플렉시블형 ø45(0.75) 4,935 · 엔진식 플렉시블형 ø45(2.6) 5,101). 산림 12-15 봉상후렉시블(45mm)
Q=5.4/hr 전기·엔진을 적음 12-34-1 콘크리트 진동기(3.5HP)(= 2.6) · 운전경비표에 엔진식만
있는 것을 근거로 엔진식 4611-0350. 집수정 구체콘크리트 갈래가 이제 ( 조건이 풀림).
"""
from __future__ import annotations
import json
from decimal import Decimal
from pathlib import Path
from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of
ROOT = Path(__file__).resolve().parents[2]
DATA = ROOT / "resources" / "data_cost_input_value"
def _oil(file: str, key: str) -> dict:
return json.loads((DATA / file).read_text(encoding="utf-8"))["variables"][key]
def test_휘발유_기계는_휘발유값_경유_기계는_경유값() -> None:
book = cached_build().book
fuels = {r.ref_code for r in book.details["X-1730-0015"] if r.ref_code.startswith("M-FUEL-")}
assert fuels == {"M-FUEL-휘발유"}, fuels
gasoline = Decimal(str(_oil("oil_2026-08-14.json", "oil_gasoline")["value"]))
assert (
book.titles["M-FUEL-휘발유"].slots[-1] == gasoline
or gasoline in book.titles["M-FUEL-휘발유"].slots
)
diesel_rows = {
r.ref_code for r in book.details["X-0201-0070"] if r.ref_code.startswith("M-FUEL-")
}
assert diesel_rows == {"M-FUEL-경유"}
def test_시도를_고르면_휘발유도_그_시도값() -> None:
records = _oil("oil_regional_2026-09-09.json", "oil_gasoline")["records"]
seoul = next(Decimal(str(r["value"])) for r in records if r["sido_code"] == "01")
book = cached_build(fuel_region="01").book
assert seoul in book.titles["M-FUEL-휘발유"].slots
assert "서울" in book.titles["M-FUEL-휘발유"].spec
def test_기계_하나_시간당_사용료도_연료_종류대로() -> None:
from B09_Estimation.B09_Estimation_MachineOperating import hourly_cost_of, load_fuel_price
gasoline, _ = load_fuel_price(kind="휘발유")
diesel, _ = load_fuel_price()
assert gasoline != diesel
cost = hourly_cost_of("1730-0015") # 휘발유 1.0 L/hr · 잡품 20%
assert cost.money.material == Decimal("1.0") * Decimal("1.2") * gasoline, cost
def test_진동기_원문값_되살림() -> None:
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
machines = load_machine_catalog().machines
assert machines["4611-0350"].specification == "엔진식 플렉시블형 ø45(2.6㎾)"
assert machines["4611-0350"].loss_coefficient_per_hour == Decimal("0.0005101")
assert machines["4611-0075"].specification == "전기식 플렉시블형 ø45(0.75㎾)"
assert machines["4611-0075"].loss_coefficient_per_hour == Decimal("0.0004935")
def test_집수정_구체콘크리트_갈래가_엔진식_진동기로_섬() -> None:
build = cached_build()
rows = {
r["ref_code"]: Decimal(r["quantity"])
for r in detail_of(build, "B-FP-12-15#구체콘크리트")["rows"]
if r.get("ref_code")
}
assert rows["X-4611-0350"] == Decimal(1) / Decimal("5.4"), rows
left = " ".join(build.unattached.get("FP-12-15", []))
assert "봉상후렉시블" not in left and "구체콘크리트" not in left, left
def test_진동기_별칭_사유에_12_15_는_전기_엔진을_안_적음() -> None:
from common_util.common_util_aliases import load_aliases
row = next(r for r in load_aliases("resource") if r["to"] == "4611-0350")
assert row["scope"] == "FP-12-15"
assert "전기·엔진을 안 적음" in row["basis"] and "12-34-1" in row["basis"], row
def test_중기경비_장은_갈래대로_잡품과_손료계수() -> None:
"""661 뒤처리 — `#암석` 장이 조합 16%·비암석 계수로 보이던 자리."""
from B09_Estimation.B09_Estimation_MachineExpenseSheet import machine_expense_sheets
build = cached_build(dump_haul_m=("164.23",))
sheets = {s["code"]: s for s in machine_expense_sheets(build)}
rock, combined = sheets["X-0201-0070#암석"], sheets["X-0201-0070#조합"]
assert rock["misc_material_percent"] == sheets["X-0201-0070"]["misc_material_percent"]
assert combined["misc_material_percent"] == "16"
assert rock["loss_coefficient"] == 2405.0 and sheets["X-0201-0070"]["loss_coefficient"] == 2085
gasoline = next(s for s in sheets.values() if s["machine_code"] == "1730-0015")
assert Decimal(gasoline["fuel_price_per_liter"]) == Decimal(
str(_oil("oil_2026-08-14.json", "oil_gasoline")["value"])
)
@@ -0,0 +1,26 @@
"""품 할증 겹침 — 2026-09-14 브레인 672: 건설 공통 1-4-2 「할증의 중복가산요령」.
원문: W = 기본품 × (1 + a1 + a2 + a3 + + an) · , 동일성격의 품할증요소의 이중적용은 불가함
산림품셈 1-4 에는 겹침 규정이 없어 **교차 참조** 건설 규정을 따름 합산은 이제 우리가 아니라 원문.
동일성격 어느 계열끼리인지는 원문이 정함 막지 않고 설계자에게 단서를 그대로 보임.
"""
from __future__ import annotations
from decimal import Decimal
import B09_Estimation.B09_Estimation_LaborSurcharge as LS
def test_여럿을_고르면_합산이고_근거는_건설_1_4_2() -> None:
assert LS.COMBINE_RULE == "sum"
picks = LS.parse_choices({"1-4-1": "1-4-1:0", "1-4-5": "1-4-5:1"}) # 10% · 5%
assert LS.total_percent(picks)[0] == Decimal(15)
note = LS.COMBINE_NOTE
assert "1-4-2" in note and "1 + a1 + a2" in note and "교차 참조" in note, note
assert "확정 대기" not in note and "우리가 그렇게" not in note, note
def test_동일성격_이중적용_불가_단서를_보임() -> None:
assert "동일성격" in LS.COMBINE_NOTE and "이중적용" in LS.COMBINE_NOTE
assert "설계자" in LS.COMBINE_NOTE # 어느 계열이 동일성격인지는 원문이 안 정함
+1 -1
View File
@@ -57,7 +57,7 @@ def test_단계가_못_서면_부모를_안_세우고_사유를_남긴다():
build = _build()
assert not any(code.startswith("B-FP-09-05#") for code in build.book.titles)
assert "FP-09-05-01" in build.component_gaps["FP-09-05"] # 발파 착암기 층 없음
assert "FP-12-38-02" in build.component_gaps["FP-12-38"] # 사용수량 미구현
assert "FP-12-38-02" in build.component_gaps["FP-12-38"] # 사용수량 — 임대료·손료 단가 안 넣음
# 깎기 표엔 기계 이름이 없음 — [주] 원문의 기종으로 섬(부모에서 물려받지 않음)
assert "[주]" in build.book.details["D-FP-09-05-02#경암"][0].note # Q 줄은 D 에
+79
View File
@@ -0,0 +1,79 @@
"""암석 작업 기계손료 보정 — 건설품셈 8-1-7 1 (2026-09-14 브레인 661 ①②).
원문: 암석굴착, 암석적재, 암석운반 등의 가혹한 작업에 사용되는 경우에는 손료(관리비 제외) 보정 가산
불도저(19 이상 제외) 25 · 굴착기(무한궤도) 로더(무한궤도) 20 · 덤프트럭 25 (%)
[] 전용덤프트럭(18 이상) 불도저(19 이상) 보정하지 않음(타이어·습지 불도저는 보정)
실무 봉화 2024 중기목록 (암석) 셋이 (상각 + 정비) × (1 + 가산) + 관리 역산됨
굴착기 1.0 0.2405 · 덤프 2.5 0.3533 · 덤프 15 0.2679. B09 보정을 번도 걸고 있었음.
브레이커 조합 본체(`#조합`)는 안 걺 — 봉화 「굴삭기 0.7 브레이커조합」 손료 = 비암석 23,128 + 브레이커.
전석섞인토사 10% 혼입율 입력이 없어 ( · 칸도 만듦).
"""
from __future__ import annotations
from decimal import ROUND_FLOOR, Decimal
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
HAUL = ("164.23",)
def _rows(book, code: str) -> list:
"""그 제목과 제 단가산출(D) 줄."""
own = book.details.get(code) or []
return [
*own,
*(r for d in own if d.ref_code.startswith("D-") for r in book.details[d.ref_code]),
]
def _machines(book, code: str) -> set[str]:
"""그 제목과 제 단가산출(D) 줄이 부르는 기계 호표."""
return {r.ref_code for r in _rows(book, code) if r.ref_code.startswith("X-")}
def test_실무_암석_손료계수_셋이_원천표로_역산됨() -> None:
from B09_Estimation.B09_Estimation_RockLoss import rock_coefficient
# 실무 표기는 취득가 천원당 — 우리 계수는 원당(× 1000 이 실무 값)
assert rock_coefficient("0201-0100") * 1000 == Decimal("0.2405") # 굴착기 1.0 (900+700)×1.2+485
assert rock_coefficient("0602-0025") * 1000 == Decimal("0.3533") # 덤프 2.5 3533.75 버림
assert rock_coefficient("0602-0150") * 1000 == Decimal("0.2679") # 덤프 15
assert rock_coefficient("0101-0019") is None # 불도저 19톤 — [주]① 보정 안 함
assert rock_coefficient("0602-0240") is None # 덤프 24톤 — [주]① 18톤 이상
assert rock_coefficient("0211-0060") is None # 굴착기(타이어) — 표에 없음
def test_암_공종의_대상_기계는_암석_호표를_부름() -> None:
book = cached_build(dump_haul_m=HAUL).book
assert "X-0201-0070#암석" in _machines(book, "B-FP-10-12-02#적재")
assert "X-0602-0150#암석" in _machines(book, "B-FP-10-12-02#L164.23m")
assert "X-0602-0150#암석" in _machines(book, "B-FP-10-12-03#L164.23m")
assert "X-0201-0070#암석" in _machines(book, "B-FP-09-04-02") # 암절취 집토
assert "X-0201-0070#암석" in _machines(book, "B-FP-09-19-02") # 비탈면 면고르기(암절취)
def test_토사_공종과_브레이커_조합_본체는_그대로() -> None:
book = cached_build(dump_haul_m=HAUL).book
assert _machines(book, "B-FP-10-12-01#적재") == {"X-0201-0070"}
assert "X-0602-0150" in _machines(book, "B-FP-10-12-01#L164.23m")
assert "X-0201-0070#조합" in _machines(book, "B-FP-09-04-01#연암")
assert not any(m.endswith("#암석") for m in _machines(book, "B-FP-09-19-01#절토면·풍화암"))
def test_불도저_19톤_발파암_운반은_보정_안_하고_까닭을_남김() -> None:
book = cached_build(dump_haul_m=HAUL).book
rows = [r for r in _rows(book, "B-FP-10-11#발파암") if r.ref_code == "X-0101-0019"]
assert rows and "8-1-7" in rows[0].note and "19톤" in rows[0].note, rows
def test_암석_호표_손료는_취득가_곱하기_보정_계수() -> None:
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
book = cached_build(dump_haul_m=HAUL).book
price = load_machine_catalog().machines["0201-0070"].price_thousand_krw * 1000
rock = book.resolve("X-0201-0070#암석").expense
plain = book.resolve("X-0201-0070").expense
assert rock == (price * Decimal("0.0002405")).quantize(Decimal(1), rounding=ROUND_FLOOR)
assert plain == (price * Decimal("0.0002085")).quantize(Decimal(1), rounding=ROUND_FLOOR)
assert book.resolve("X-0201-0070#암석").labor == book.resolve("X-0201-0070").labor
@@ -172,3 +172,35 @@ def test_대상액은_직재_간재_직노_그리고_발주자_제공_재료다(
assert owner.line("safety_management_cost_a").base_amount_krw == base + 30_000_000
# 관급 제외 밑수(B)는 관급이 들어도 안 움직임.
assert owner.line("safety_management_cost_b").base_amount_krw == base
def _scaled(estimated_price: Decimal) -> object:
"""총공사금액(규모 기준액)만 갈아 끼워 돌림 — 대상액은 구간이 안 갈리게 작게 둠."""
return calculate_cost(
CostInput(
direct_material_krw=Decimal(5_000_000),
direct_labor_krw=Decimal(5_000_000),
direct_expense_krw=Decimal(0),
enabled_items=("safety_management_cost",),
estimated_price_krw=estimated_price,
cut_basis="none",
)
)
def test_제3조_총공사금액_2천만원_미만이면_줄이_안_선다() -> None:
"""제3조 — 「총공사금액 2천만 원 이상인 공사에 적용」. 하한은 요율 데이터가 들고 있음.
우리가 견주는 값은 **규모 기준액**(설계자가 추정가격 · 없으면 수렴한 총원가).
고시의 총공사금액 같은 말은 아니나(관급·부가세 자리가 다름) 계산 차례상
안전관리비 앞에 있는 값이 그것뿐이라 같은 축으로 보건관리자 문턱도 같은 .
"""
minimum = json.load(io.open(RATES, encoding="utf-8"))["variables"]["rate_safety_pct"].get(
"minimum_total_construction_amount_krw"
)
assert minimum == 20_000_000, minimum
assert not _scaled(Decimal(minimum) - 1).has("safety_management_cost")
assert _scaled(Decimal(minimum)).has("safety_management_cost")
# 안 서면 A·B 곁줄도 안 선다 — 0 원으로 채우지 않음.
below = _scaled(Decimal(minimum) - 1)
assert not below.has("safety_management_cost_a") and not below.has("safety_management_cost_b")
+30
View File
@@ -29,10 +29,13 @@ from B09_Estimation.B09_Estimation_MachineOperating import ( # noqa: E402
)
from B09_Estimation.B09_Estimation_Transport import ( # noqa: E402
TRANSPORT_VARIANTS,
TRIPS_NOTE,
cycle_minutes,
hours_per_trip,
parse_distance_km,
parse_trips,
road_class,
transport_amount,
)
from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices # noqa: E402
@@ -114,3 +117,30 @@ def test_앞자리_이어받기는_이어받을_것이_있을_때만() -> None:
assert expand_codes(["0080", "0100"]) == []
assert expand_codes(["0080", "0100"], "2101") == ["2101-0080", "2101-0100"]
assert expand_codes(["2101-0010", "0015"]) == ["2101-0010", "2101-0015"]
# ── 회수(대수 × 왕복) — 품셈이 안 정하는 자리 (2026-09-14 브레인 판정) ──
def test_회수는_설계_입력_칸이고_비우면_없음이다() -> None:
"""품셈 원문에 **회수 공식이 없음** — 산림품셈 10-4·건설품셈 8-1-3 은 회당 단가 산출식뿐.
대수·왕복은 **설계자가 넣는 **이다. 비면 `None` 이고 금액이 선다(지어내지 않음).
"""
assert parse_trips("") is None and parse_trips(None) is None
assert parse_trips("0") is None # 0 회는 「안 나른다」 — 줄이 안 섬
assert parse_trips("3") == Decimal(3)
assert parse_trips(" 2.5 ") == Decimal("2.5") # 반 회(편도 한 번)도 설계자가 넣을 수 있음
with pytest.raises(ValueError):
parse_trips("두 번")
def test_회수를_넣으면_회당_단가에_곱해진다() -> None:
"""회수 × 회당 단가 = 수송비. **곱하기 하나뿐** — 품셈이 안 준 규칙을 끼워 넣지 않는다."""
assert transport_amount(Decimal(12_345), None) is None
assert transport_amount(Decimal(12_345), Decimal(3)) == Decimal(37_035)
def test_회수_사유가_늘_붙는다() -> None:
"""화면이 「왜 설계자가 넣나」를 읽을 수 있어야 함 — 근거 문구에 원문 없음이 적혀 있음."""
assert "회수" in TRIPS_NOTE and "설계" in TRIPS_NOTE
@@ -0,0 +1,59 @@
"""10-A — 우리가 정한 SW 규칙을 화면에 드러내기(B05·B09 몫) · 2026-09-14 사용자 확정 · PLAN 12장.
번호는 PLAN 10 사용자 확정 SW 규칙 차례. 문구 못박을 () 글자 그대로 .
"""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def _read(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def test_11_일반관리비_문구_못박음() -> None:
from B09_Estimation.B09_Estimation_CostSheet import FIELD_HINTS
assert FIELD_HINTS["overhead_class"] == (
"임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음"
)
def test_13_모르타르_배합_교차_참조_문구() -> None:
from B09_Estimation.B09_Estimation_WorkItems_AX import MORTAR_MIX, WORK_ITEMS
assert WORK_ITEMS[MORTAR_MIX]["basis"].startswith(
"산림품셈에 배합 절이 없어 건설품셈 [건축] 9-1-1 을 씀(교차 참조)"
)
def test_15_폐기물처리비는_법정경비_밑수에_안_넣음() -> None:
from B09_Estimation.B09_Estimation_CostSheet import FIELD_HINTS
assert "법정경비 밑수(직접공사비·노무비)에는 안 넣음" in FIELD_HINTS["waste_placement"]
def test_17_옹벽_높이_기본값_문구와_칸_밑_근거() -> None:
types = json.loads(_read("B05_Profile/B05_Profile_Structure_Types.json"))
wall = next(t for t in types["types"] if t["type_id"] == "retaining_wall")
height = next(o for o in wall["options"] if o["key"] == "height_m")
assert height["default_basis"] == "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음"
# 칸 밑 근거 한 줄 — 등록부 폼이 `default_basis` 를 칸 밑에 그림
assert "field(label, input, option.default_basis)" in _read(
"B05_Profile/B05_Profile_UI_Structures_Panel.ts"
)
def test_19_시설_저장은_폼에_없는_칸을_그대로_둠() -> None:
assert "폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠" in _read(
"B05_Profile/B05_Profile_UI_Drainage_Facility.ts"
)
def test_20_막힌_사유는_아래_단계_것을_그대로() -> None:
assert 'hint(L("B09_Sheet_Missing_Passed"))' in _read(
"B09_Estimation/B09_Estimation_UI_Tab_Bill.ts"
)
assert "그대로 옮김 — 여기서 새로 짓지 않음" in _read("ui_template/ui_template_locale_b3.ts")
@@ -0,0 +1,46 @@
"""10-A — 우리가 정한 SW 규칙을 화면에 드러내기(B08 몫) · 2026-09-14 사용자 확정 · PLAN 12장.
훑기(21) 결과 보이거나 반만 보이던 자리만 채움 근거 · 사유 문구 · 제안 회색.
번호는 PLAN 10 사용자 확정 SW 규칙 차례(PLAN 4 10-A 절에 목록).
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
B08 = ROOT / "B08_Quantity"
def _read(name: str) -> str:
return (B08 / name).read_text(encoding="utf-8")
def test_3_뒷채움_폭_칸_신설은_B05_등록부_몫() -> None:
assert "칸을 새로 두는 것은 B05 등록부 몫" in _read(
"B08_Quantity_Engine_UnitQuantity_Masonry.py"
)
def test_4_하위_일위대가_깊이_한도를_미리_보인다() -> None:
assert "하위 일위대가는 5단까지" in _read("B08_Quantity_UI_StructureSheet_UnitPrice.ts")
def test_5_계수_열과_돌_종류는_다른_축() -> None:
assert "돌 종류는 그대로" in _read("B08_Quantity_UI_StructureSheet_Spec.ts")
def test_9_고친_식은_양식과_프로젝트에_묶임() -> None:
assert "제원(높이 등)을 바꿔도 따라감" in _read("B08_Quantity_UI_StructureSheet_Formula.ts")
def test_16_양식_반올림은_m당_값에() -> None:
assert "반올림은 m당 값에 걸고" in _read("B08_Quantity_UI_StructureSheet.ts")
def test_22_제근_굴착기_크기_칸() -> None:
page = _read("B08_Quantity_UI_Page.ts")
side = _read("B08_Quantity_UI_Side_RootRemoval.ts")
assert "root_removal_excavator_m3: draft.root_removal_excavator_m3" in page
assert "root_removal_excavator_m3: (stored.root_removal_excavator_m3" in page
assert "appendRootRemovalFields(" in page
assert "choices.suggested" in side and "createButton(" in side
assert side.count("= suggested.value") == 2 # 단추 안에서만 넣음(비우면 「안 정함」)
@@ -112,9 +112,10 @@ def test_자재_합계는_할증_한_번() -> None:
def test_할증_포함_재료량_공종에_자재가_재료비로_붙으면_오류() -> None:
book = PriceBook()
book.add_title(PriceTitle(code="M-시험", kind=PriceKind.MATERIAL, name="패널", unit=""))
book.add_title(PriceTitle(code="B-FP-12-38-02", kind=PriceKind.UNIT_PRICE, name="사용수량"))
book.add_detail(PriceDetail("B-FP-12-38-02", "M-시험", Decimal("0.089")))
# 유로폼 12-38-02 는 가드에서 뺐음(2026-09-14 301 ④ · 자재총괄에 줄 없음) — 돌망태 사각형으로 잼
book.add_title(PriceTitle(code="M-시험", kind=PriceKind.MATERIAL, name="철망태", unit=""))
book.add_title(PriceTitle(code="B-FP-13-11-04", kind=PriceKind.UNIT_PRICE, name="사각형"))
book.add_detail(PriceDetail("B-FP-13-11-04", "M-시험", Decimal("1.0")))
# 금액을 세우지 않고도 걸림 — 줄이 붙는 순간이 어긴 자리
with pytest.raises(DoubleCountError):
check_materials_before_surcharge(book)

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