Author SHA1 Message Date
eomsangdonandClaude Opus 5 16d3461573 fix(b09): ㉱ 조립(묶음) 줄이 조각의 「일부만」·「밑수 없음」 을 올림 — 조각 제목만 있으면 온전한 금액처럼 서던 구조 결함
· 조각마다 보통 줄과 같은 두 검사 — 단가가 일부 몫만 섰거나(붙은 몫 N%) 밑수를 모르는 표면 묶음 금액을 안 세우고 어느 조각이 왜인지 사유
· 700줄 넘던 줄 만들기 파일에서 자재 줄(_material_row)을 자재 모듈로 그대로 옮김(순수 나누기 · 부르는 쪽 한 줄)

프로젝트 내역 그대로 · 잴 시험 둘 빨강→초록(온전한 조각 시험은 처음부터 초록) · 전체 시험 1705 + 367 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 00:45:40 +09:00
eomsangdon 4d33f8d61c Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-15 00:24:08 +09:00
eomsangdonandClaude Opus 5 ac66a25cc7 feat(b09): ㉰ 표토제거 답외구간 9-15-2 일위대가 — 계수표를 불도저 식 + T 로 · 영월 호표 어긋남(E 자리에 e) 기록
· 계수표(T·L·E·q0·e·f·V1·V2·t) 칸을 기호로 읽어 불도저 식(8-2-1 · dozer_hourly_output) 그대로 Q1 47.92 ㎥/hr · Q = Q1 ÷ T 0.2 = 239.60 ㎡/hr
· 기종은 q0 3.2 · V1 40 · V2 46(1단)로 8-2-1 표에서 되짚음 → 무한궤도 19ton([주]① 과 맞음) · D 줄 1/Q hr/㎡
· 원문 대 실무 어긋남 기록 — 영월 호표 Q=576.95 는 E 자리에 e(0.96)를 넣은 값으로 정확히 역산됨 · 다른 실무엔 이 호표 없음 → 원문이 또렷해 원문 E=0.4 로 셈(공종 사유 한 줄)

새 제목 B·D 618원/㎡ · 바뀐 제목 0 · 프로젝트 내역 표토제거 9,954.32㎡ 가 금액으로 섬(+6,151,768) · 잴 시험 둘 빨강→초록 · 끄기 셋 빨강 · 전체 시험 1703 + 364 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 00:23:33 +09:00
eomsangdon cd6c7f3fed Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-15 00:16:45 +09:00
eomsangdonandClaude Opus 5 b34eb546f9 feat(b06): 성토사면 길이 5m 초과 경고 줄 — 요약 한 줄 + 측점 목록 · 벽 선 쪽만 뺌(좌·우 갈라) · 경고만(㉳ (나) · 브레인 승인 문구)
- 판정 `_Cross_FillSlope_Warn` — 길이는 카드 머리 「성토사면」 칸과 같은 fillSlopeLengths · 5m 를 넘는 쪽만
- 벽 = 배관 유입(상단측)·유출 기슭막이(집수정은 아님) · 독립 기슭막이 설치 측 · 세월교·BOX암거 양쪽
- 카드 목록 위 <details> — 펼치면 측점 단추, 누르면 그 카드로 · 구조물 자동 배치 없음
- 700줄 — 뷰 조종기 타입을 `_Section_View_Types` 로 뗌(706 → 669)
- 936be972: 37측점 경고 · 5m 넘는 48측점 중 벽 선 11곳 빠짐(80·258.1·260·320·352.1 세월교·440·534·620·720·804.2·982.6)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-15 00:15:11 +09:00
eomsangdon 2ad20e5d02 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-15 00:04:52 +09:00
eomsangdonandClaude Opus 5 9deaf479d0 feat(b09): 판정표가 안 읽은 줄을 자동으로 「못 붙은 줄」 목록에 — 표를 넣을 때마다 손 사유를 안 달아도 안 샘
· 판정표 공통 한 곳(_unread_rows) — 읽힌 줄 밖의 줄을 올림
· 넘치지 않게: 읽힌 줄 · 이미 못 맞춤 이름 · 다른 갈래가 쓴 기계 줄 · 자원 머리(첫 줄 머리 모양 · 횟수별) · 빈 줄은 뺌 · 분류 딱지 줄(자재·인력·기계)은 이름 칸으로 · 비고는 글 앞머리와 함께
· 손 사유와 안 겹치게: 공종 사유 한 줄에 이미 적힌 이름은 안 올림 · 오늘 손으로 단 날개벽·면벽·맨홀 줄 목록 사유는 걷고 [주] 30% 할증만 남김
· 새던 셋이 함께 닫힘 — 12-15 거푸집·철근·뚜껑·설치비 · 12-04 비고(수직고 7m 할증) · 12-34-1 레미콘 별도계상

판정표 9표 늘어난 줄: 9-19-1 0 · 12-04 +1 · 12-15 +4 · 12-12 +8 · 12-13 +6 · 12-16 +7 · 12-34-1 +1 · 제목 금액·막힘·일부만 그대로 · 잴 시험 넷 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1702 + 362 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 00:04:41 +09:00
eomsangdonandClaude Opus 5 419148901b fix(b07): 횡단도 [확정]은 설계 상태만 올림 — 입력 셋으로 다시 계산한 값으로 정본을 덮던 것 걷음(브레인 ②)
- 옛 길: 지반·단면·측구 쪽 셋만으로 compute_cross_design 을 돌려 설계를 통째로 update
  → 암선·절토경사·표준 횡단·구조물 트림·사용자 입력(측구 끔)이 사라짐
- 936be972 실측(읽기만): 62측점 전부 단면적 바뀜 · 절토 5,868.92 → 4,650.76㎥(−20.8%) · 성토 +5.8% · 종점 1078.01 측구 끔→켬
- 지금: patch = {status: confirmed} 뿐 · 응답 설계는 저장값 그대로 · _recompute_confirmed_design 지움
- 확인: 실제 라우터로 23장 전부 [확정] → 62측점 상태 뺀 값 차이 0 · 상태 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 23:43:58 +09:00
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 72ec6c2135 feat(b09): 12-4 합판거푸집 사용횟수 1~6회 갈래 — 기준수량(1회) × 원문 비율(재료 줄 재료 % · 인력 줄 노무비 %) · 설계로 막아 둔 표 풀림 · 사용고재 23%·동바리·소형구조물 할증은 값 안 짓고 사유 · 재료는 카탈로그 없음/규격 미정으로 못 붙은 줄(인력 품만) · 마스터 갈래 키 재생성(바뀐 공종 12-04 하나)
일위대가 새 제목 6(1회 81,321 → 4회 32,528 → 6회 26,023원/㎡) · 나머지 462 변화 0 (브레인 ㉮)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 18:27:36 +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
eomsangdon 1550c7caf6 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 18:18:07 +09:00
eomsangdonandClaude Opus 5 99a79e8e0b feat(b08): 라이브러리 관리자 발행 — 프로그램 기본도 storage 작업본(git resources 는 씨앗 · 없는 코드만 · 덮어쓰기 금지) · [회사 라이브러리에 발행](마스터·시스템 관리자) · [프로그램 기본으로 발행](시스템 관리자) — [내 라이브러리에 저장]과 같은 모양 · 권한은 서버가 can_publish 로 내리고 화면은 그대로만 보임
화면(시스템 관리자 계정): 두 단추 보임 · 확인창 「모든 회사가 쓰는 프로그램 기본에 발행 — 같은 종류가 있으면 덮어씀」 · 공용 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:06:11 +09:00
eomsangdon 60d7c005bd Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 18:01:36 +09:00
eomsangdonandClaude Opus 5 26d9092a9b fix(b09): 원가계산 나머지 세 형식 사유를 요율표 셋 결론으로 — 실적형 「임도 미적용 — 100억 미만(예정가격작성기준 §37②)」 · 수공 세부 경비 「법정 요율 없음 — 값을 넣어 주십시오(§34①)」 · 실적 안전표는 고시 편입이나 실적형 미적용 · 탭 말풍선도 같은 말
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 18:01:34 +09:00
eomsangdon 29a0e64b30 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 17:57:43 +09:00
eomsangdonandClaude Opus 5 64e43463f4 feat(b08): 라이브러리 [복제해서 내 것으로] — 기본·회사 항목을 개인 단에 베낌(출처 cloned_from 기록 · 새 코드 · 프로젝트 작업본 안 바꿈 · 개인 단 항목에선 안 눌림 · 같은 종류 내 것은 덮어씀을 먼저 물음)
화면(936be972 돌쌓기(찰) 장): 기본 항목 복제 → 목록 「개인 · [양식형] 돌쌓기(찰)」 + 「내 라이브러리에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]」 · 프로젝트 라이브러리 폴더 안 생김 · [내 것 지우기]로 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 17:45:46 +09:00
eomsangdon 254007573f Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 17:42:51 +09:00
eomsangdonandClaude Opus 5 64a52df680 feat(b08): 9-21 제근 굴착기 크기 칸 — 크기(0.2·0.7)·임목축적 등급을 매핑 variant_template 로 한 갈래 값(「0.7·소림」)으로 엮고 범위 별칭(FP-09-21)이 B09 갈래로 이음 · 제안 0.7㎥(산림품셈 10-12-1 [주]①)은 칸 곁에만 · 비면 금액 없이 입력 사유(브레인 판정)
배정 프로젝트: 뿌리뽑기 줄만 사유가 「하위 후보 6건」→「입력이 필요합니다」 · 소림·0.7 넣으면 55원/㎡ × 17,698㎡ = 973,414(되돌림) · 전체 1935 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 17:42:49 +09:00
eomsangdonandClaude Opus 5 a744388276 fix(b08): 고르개 후보 0 이면 다음 길을 적음 — 「낱말·갈래를 바꿔 찾거나 수동 단가(미확정)」 · 조용히 「없음」으로 막히지 않게(936be972 PVC파이프는 단가표에 품셈·자원 모두 0)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 17:42:04 +09:00
eomsangdon 70d9329fb6 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 17:39:54 +09:00
eomsangdonandClaude Opus 5 42909a01b9 feat(b08): 고정형 장 사유(㉰) · 규격 다름(㉱) — 전개 사유를 걷고 항목 사유를 띄움(남긴 전개 줄에 걸린 사유만 머리 달아 둠) · 항목 제목 H 와 장 제원 H 가 다르면 빨간 테두리 + 「규격 다름」 배지 + 「벽 수량은 항목 박힌 값 · 토공·사토 공제는 장 제원으로 셈」 사유(금액은 섬 · 막지 않음)
936be972 화면: 돌쌓기(찰) H=2.5 장에 H=2.0 호표 → 배지 「규격 다름 H2 ↔ H2.5」 · 테두리 · 사유 첫 줄 규격 다름 + 원문 시점 수량 · 뒷길이·돌 종류 사유 걷힘 · 되돌려 전부 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 17:27:53 +09:00
eomsangdon 5d2e1edbde Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 17:25:36 +09:00
eomsangdonandClaude Opus 5 8dd452d8f4 fix(b08): 관 벽·돌쌓기 줄 인계에 실제로 쓴 뒷길이 + 출처 — 안 고른 벽도 [주]⑩ 표준표 하한 값으로 13-04-02/05 갈래를 고름 · 고른 값은 원본 그대로(출처 저장 제원)
배정 프로젝트: 인계 줄 18(관 유입·유출부 기슭막이) 갈래 값·판정 근거만 바뀜 · 본체 105,512,060 그대로(모두 미확정) · 전체 1928 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 17:24:32 +09:00
eomsangdonandClaude Opus 5 31decd1558 feat(b08): 남길 성분 목록에 기초잡석·채집석 더함(②) — 실무 벽·돌쌓기 호표 57 전수: 기초잡석 0/57 · 채집석(사토 공제 부피)은 호표 채집 줄(품 · 41/57)과 다른 것
936be972 실측: 고정형 가져오기 → 기초잡석이 호표 밖 몫으로 따로 섬(내역 6-3 3.5 → 7.7㎥ · +515,466) · 채집석 17.76㎥ 사토 공제 그대로 · 되돌려 전부 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 17:10:26 +09:00
eomsangdonandClaude Opus 5 9e240d886d fix(b08): 고르개 후보를 두 글자 이상 겹친 조각으로 넓힘(①) — 낱말 전부 든 항목 먼저, 그다음 겹친 글자 수 순 · 조각 비교는 글자·숫자만(괄호 안 셈) · 고르는 것은 사람(별칭표로 안 맞춤)
실측(936be972 단가표): 고정형 줄 이름 그대로 찾기 — 종전 8줄 전부 후보 0 → 깬잡석채집 50건(야면석 채집…) · 고임돌채집 14(막돌 채집…) · 레미콘타설(장비) 22(레디믹스트콘크리트 타설 먼저) · 기초다짐 및 뒤채움 16 · PVC파이프 0(자재라 자원 갈래) · 두 번째부터 40~80ms

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 17:08:14 +09:00
eomsangdon 9022640b5f Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1 2026-09-14 17:01:15 +09:00
eomsangdonandClaude Opus 5 00919e86e2 fix(b09): 안전관리비 B 절사 자리를 STmate 실증본 쪽으로 · SA 안전관리비 별도내역 확정
④ 1.2배의 대상을 「호별 산정이 끝난 금액」으로 바꿈 — evaluate 가
   버림(밑수 × 율 + 기초액) 을 먼저 맺고 배수를 곱함(종전엔 곱한 뒤 한 번만 버림).
   근거 둘이 같은 쪽 — 고시 제4조① 단서 문언 · 골든셋 실증(영월 2024 B 20,330,639 → 20,330,638).
   A(배수 1)는 어느 차례로 해도 같은 값.
   ⚠ 두 실증본이 갈림 — 거창 2025 원본(STmate 출력 아님)은 옛 규칙이라 B 가 1원 위.
     목표가 STmate 골든셋 재현이라 STmate 쪽으로 맞춤. 고정값 벌에 갈림을 적어 둠.

⑤ SA(실적수공) 안전관리비 「확인 대기」 → 별도내역. TA 와 같은 자리가 됨.
   PENDING_SAFETY 상수를 걷고 파일 머리 문구도 고침. 금액엔 안 닿음.

⑥ 고시 대조를 상시 벌로 세움 — test_b09_safety_notice.py 여덟 자리.
   별표1 표를 원문 md 에서 읽어 요율 데이터 16칸 전수 대조(값을 시험에 안 박음) ·
   제4조①1·2호 구간 경계 넷 · 단서 A·B·1.2배 · 제2조2호 대상액 구성 · 1.2배 차례 회귀.

잴 시험 먼저 빨강 확인한 뒤 고침. 전체 시험 1911 통과 · 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 16:59:47 +09:00
eomsangdonandClaude Opus 5 b95ad5e838 feat(b08): 면고르기 성토면 회색 제안 + [제안값 넣기] — 서버 fill_suggested 그대로 · 누른 때만 칸에 들어가고 비워 두면 「안 정함」 · 메인 976e5fd9
화면: 칸 빈 값 · 회색 「제안(비우면 안 정함): 성토면 · 기계 · 무한궤도 — 영월 실무…」 · 단추 누르면 칸만 바뀌고 저장값은 그대로 빈 값(저장 안 함)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 16:53:25 +09:00
eomsangdon 9d59a32898 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 16:50:40 +09:00
eomsangdon eff340aad3 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 16:49:33 +09:00
eomsangdonandClaude Opus 5 ef03daa482 refactor(b09): ResourceAxis.py 713→620줄 — 셀 거르기·물결표 목록·셀 정규화를 _Labels 로 순수 분리(옮기기만 · 바깥 이름 그대로 다시 내보냄)
옮긴 글 원문과 같음 · 자원 축 출력·일위대가 462 제목 금액 전후 같음 · 린트 종전과 같음 · 전체 1909 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 16:49:31 +09:00
eomsangdonandClaude Opus 5 fc825dc9d3 feat(b08): 양식이 안 품은 토공·버림 성분은 전개 값을 남김(㉯) — 남길 목록을 코드에 못박음(터파기·되메우기·잔토처리·버림콘크리트) · 목록 밖은 양식으로 갈음 · 목록 안이라도 양식이 그 줄을 품으면 양식 값 · 구조물도 장에도 남긴 줄을 사유와 함께 보임
936be972 실측: 고정형(봉화 제6호표) 가져오기 → 구조물터파기·되메우기·잔토·버림 타설 전과 같음(전엔 −6,037,712원) · 본체 차이는 미완으로 금액 밖이 된 돌쌓기 2줄 −4,062,480 뿐 · 되돌려 전부 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 16:38:48 +09:00
142 changed files with 6085 additions and 622 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)),
),
},
@@ -0,0 +1,49 @@
/* =============================================================================
* B06_Section_UI_Cross_FillSlope_Notice.ts
* 5m ** ** +
* (2026-09-14 ()). `_Cross_FillSlope_Warn`, .
* . .
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
import { fillSlopeLengths } from "./B06_Section_UI_Cross_Fit";
import { FILL_SLOPE_WARN_TEXT, fillSlopeWarnings } from "./B06_Section_UI_Cross_FillSlope_Warn";
import { L, stationLabel } from "./B06_Section_UI_Section_Common";
export interface FillSlopeNotice {
root: HTMLDetailsElement;
/** 측점 설계가 바뀔 때마다 부른다 — 펼침 상태는 그대로 둔다. */
update: (sections: ReadonlyArray<CrossSection>, stationInterval: number) => void;
}
export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice {
const root = document.createElement("details");
root.className = "b06-section__notice";
root.hidden = true;
const summary = document.createElement("summary");
const list = document.createElement("div");
list.className = "b06-section__notice-list";
root.append(summary, list);
return {
root,
update(sections, stationInterval) {
const warnings = fillSlopeWarnings(sections, fillSlopeLengths);
root.hidden = !warnings.length;
summary.textContent = `${FILL_SLOPE_WARN_TEXT} · ${warnings.length}측점`;
list.replaceChildren(
...warnings.map(({ section, sides }) => {
const button = document.createElement("button");
button.type = "button";
const parts = sides.map(({ side, lengthM, open }) => {
const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right");
return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`;
});
button.textContent = `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`;
button.addEventListener("click", () => onPick(section.station_id));
return button;
}),
);
},
};
}
@@ -0,0 +1,88 @@
/* =============================================================================
* B06_Section_UI_Cross_FillSlope_Warn.ts
* 5m **** (2026-09-14 ()) .
*
* 2 .2..(3).() · 7 2..(3).()
* 5m ·. ( W열 )
* ( 63% · 49%) **** ( ).
*
* · . **· **
* .
* `fillSlopeLengths`( ) .
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
import { FILL_SLOPE_MAX_LENGTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
/** 브레인 승인 문구 그대로(2026-09-14) — 고치면 승인을 다시 받을 것. */
export const FILL_SLOPE_WARN_TEXT =
"성토사면 길이 5m 초과 — 법령상 옹벽·석축 설치 대상 (산림자원법 시행규칙 별표2 Ⅰ.2.차.(3).(나) · 임도설치 규정 별표7 2.차.(3).(나)) ※ 실무 표본에서도 흔함(영월 63% · 봉화 49%) — 설치 여부는 설계자 판단";
export type FillSlopeSideName = "left" | "right";
export interface FillSlopeSideLength {
lengthM: number;
/** 원지반을 못 만나 거기까지만 잰 하한값. */
open: boolean;
}
export interface FillSlopeWarning {
section: CrossSection;
sides: Array<{ side: FillSlopeSideName } & FillSlopeSideLength>;
}
/** 벽이 서서 성토사면을 끊는 쪽 — 배관 기슭막이 · 독립 기슭막이 · 세월교·BOX암거 측벽. */
export function wallSides(section: CrossSection): Set<FillSlopeSideName> {
const sides = new Set<FillSlopeSideName>();
if (section.ford || section.box) return new Set(["left", "right"]);
const culvert = section.culvert;
if (culvert?.hidden_pipe) {
// 독립 기슭막이 설치 측 — 좌 = +offset(`restrictToSide`) · 양쪽·미지정은 둘 다.
if (culvert.side !== "우") sides.add("left");
if (culvert.side !== "좌") sides.add("right");
} else if (culvert) {
// 유입 = 상단측(미상이면 좌) · 집수정은 벽이 아니다.
const inlet: FillSlopeSideName = (section.uphill_side ?? "left") === "left" ? "left" : "right";
if (culvert.inlet.structure !== "집수정") sides.add(inlet);
if (culvert.outlet.structure !== "집수정") sides.add(inlet === "left" ? "right" : "left");
}
const revetment = section.revetment;
if (revetment) {
// 설치 측이 비면 성토가 나는 쪽(`computeRevetmentLayout` 과 같은 규칙).
const mode = section.design?.section_mode;
const side =
revetment.side === "우"
? "right"
: revetment.side === "좌"
? "left"
: mode === "left_cut"
? "right"
: mode === "right_cut" || mode === "both_fill"
? "left"
: null;
if (side) sides.add(side);
}
return sides;
}
/** 5m 를 **넘는** 성토사면(벽 선 쪽 뺌)이 있는 측점만 — 측점 순서 그대로. */
export function fillSlopeWarnings(
sections: ReadonlyArray<CrossSection>,
lengthsOf: (section: CrossSection) => Record<FillSlopeSideName, FillSlopeSideLength | null>,
): FillSlopeWarning[] {
const warnings: FillSlopeWarning[] = [];
for (const section of sections) {
const lengths = lengthsOf(section);
const walls = wallSides(section);
const sides = (["left", "right"] as const)
.filter((side) => !walls.has(side))
.flatMap((side) => {
const length = lengths[side];
return length && length.lengthM > FILL_SLOPE_MAX_LENGTH_M + 1e-6
? [{ side, ...length }]
: [];
});
if (sides.length) warnings.push({ section, sides });
}
return warnings;
}
+9 -48
View File
@@ -20,7 +20,6 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import type {
CrossSection,
EarthworkConversion,
HaulEquipmentLimit,
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
@@ -81,53 +80,10 @@ import {
inferStationInterval,
L,
} from "./B06_Section_UI_Section_Common";
import type { SectionViewController } from "./B06_Section_UI_Section_View_Types";
import { createFillSlopeNotice } from "./B06_Section_UI_Cross_FillSlope_Notice";
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
/**
* ·· · ****.
*
* `chartWrap` (
* ).
* , .
*/
export interface SectionViewController {
root: HTMLElement;
render: (
detail: SectionDetailResponse,
verticalExaggeration: number,
crossHalfWidth?: number,
stationInterval?: number,
earthworkConversion?: EarthworkConversion,
haulEquipmentLimits?: HaulEquipmentLimit[],
/** balloon 위치 캐시를 가르는 키(프로젝트+경로). 노선이 다르면 위치가 섞이면 안 된다. */
balloonScope?: string,
/** 자연방토 판정 경사(config). 못 받으면 자연방토 없음으로 본다. */
naturalSpoilMinSlope?: number,
) => void;
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
refreshCard: (chainageM: number) => void;
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
refreshCards: (chainages: ReadonlyArray<number>) => void;
/**
* (2026-08-29 B05/B06 일원화: 목록 ·). */
focusStation: (stationId: string) => void;
/** ()
* (2026-08-29 ). null = . */
setStationSelectListener: (listener: (stationId: string | null) => void) => void;
clear: () => void;
/** B05 · (2026-09-07
* 4 ). . */
setStructureMarks: (
structures: ReadonlyArray<StructureInstance>,
types: ReadonlyArray<StructureType>,
) => void;
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */
setStructureEdit: (edit: SectionStructureEdit | null) => void;
/** 계획선 편집 ▲/▼ — 그릴 때마다 불러 선형·편집 함수를 받는다(null = 버튼 없음). */
setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void;
dispose: () => void;
}
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl, SectionViewController };
export function createSectionView(
onDesignChange?: DesignChangeHandler,
@@ -236,6 +192,10 @@ export function createSectionView(
},
});
panel.append(panelResizer.root);
// 성토사면 5m 초과 경고 줄(2026-09-14 브레인 (나)) — 패널과 카드 사이 · 측점 누르면 그 카드로.
const fillSlopeNotice = createFillSlopeNotice((id) =>
selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true),
);
/**
* **** (2026-08-02 ).
@@ -489,6 +449,7 @@ export function createSectionView(
// 보던 자리가 맨 앞으로 튀면 못 쓴다. 위치를 잡아 뒀다 되돌린다.
const keepScrollLeft = chartWrap.scrollLeft;
const detail = currentDetail;
fillSlopeNotice.update(detail.cross_sections, cachedStationInterval); // 카드 갱신도 여기를 거침
// 그래프 몫은 **추정하지 않고 잰다**. `chartWrap`은 `flex: 1 / min-height: 0`이라 높이가
// 내용이 아니라 패널에서 정해지므로, 재서 쓰면 되먹임 없이 한 번에 수렴한다.
// 화면에 붙기 전(detached)에는 잴 수 없으니 그때만 `PANEL_CHROME_PX` 추정치로 시작한다.
@@ -599,7 +560,7 @@ export function createSectionView(
}
// panel은 이미 root의 자식이라 replaceChildren이 떼었다 붙이면서 스크롤을 잃는다.
const keepScrollLeft = chartWrap.scrollLeft;
root.replaceChildren(panel, grid);
root.replaceChildren(panel, fillSlopeNotice.root, grid);
chartWrap.scrollLeft = keepScrollLeft;
};
@@ -0,0 +1,59 @@
/* =============================================================================
* B06_Section_UI_Section_View_Types.ts
* `createSectionView` 700 (2026-09-14).
* `_UI_Section_View` ( ).
* ========================================================================== */
import type {
EarthworkConversion,
HaulEquipmentLimit,
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
import type { LongitudinalPanelInput } from "./B06_Section_UI_Section_View_Draw";
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
/**
* ·· · ****.
*
* `chartWrap` (
* ).
* , .
*/
export interface SectionViewController {
root: HTMLElement;
render: (
detail: SectionDetailResponse,
verticalExaggeration: number,
crossHalfWidth?: number,
stationInterval?: number,
earthworkConversion?: EarthworkConversion,
haulEquipmentLimits?: HaulEquipmentLimit[],
/** balloon 위치 캐시를 가르는 키(프로젝트+경로). 노선이 다르면 위치가 섞이면 안 된다. */
balloonScope?: string,
/** 자연방토 판정 경사(config). 못 받으면 자연방토 없음으로 본다. */
naturalSpoilMinSlope?: number,
) => void;
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
refreshCard: (chainageM: number) => void;
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
refreshCards: (chainages: ReadonlyArray<number>) => void;
/**
* (2026-08-29 B05/B06 일원화: 목록 ·). */
focusStation: (stationId: string) => void;
/** ()
* (2026-08-29 ). null = . */
setStationSelectListener: (listener: (stationId: string | null) => void) => void;
clear: () => void;
/** B05 · (2026-09-07
* 4 ). . */
setStructureMarks: (
structures: ReadonlyArray<StructureInstance>,
types: ReadonlyArray<StructureType>,
) => void;
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */
setStructureEdit: (edit: SectionStructureEdit | null) => void;
/** 계획선 편집 ▲/▼ — 그릴 때마다 불러 선형·편집 함수를 받는다(null = 버튼 없음). */
setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void;
dispose: () => void;
}
@@ -195,6 +195,29 @@
gap: var(--spacing-16);
}
/* 성토사면 5m 초과 경고 줄(2026-09-14) — 요약 한 줄, 펼치면 측점 단추 목록. */
.b06-section__notice {
margin-bottom: var(--spacing-8);
color: var(--color-warning);
font-size: var(--text-caption);
}
.b06-section__notice > summary {
cursor: pointer;
}
.b06-section__notice-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
padding-top: var(--spacing-4);
}
.b06-section__notice-list > button {
font-size: var(--text-caption);
white-space: nowrap;
}
.b06-cross-card {
cursor: pointer;
transition:
@@ -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))
+13 -31
View File
@@ -43,7 +43,6 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import (
_invalidate_drawing,
_read_drawing,
_read_json,
_recompute_confirmed_design,
_store_confirmed_drawing,
landuse_source,
lidar_source,
@@ -405,7 +404,7 @@ async def confirm_design_drawing(
) -> DesignDrawingConfirmResponse | JSONResponse:
"""현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다.
횡단도 확정 B06 지정 잠정치를 동일 엔진으로 재계산해 확정치로 승격·저장한다.
횡단도 확정 담긴 측점 설계의 **상태만** 확정으로 올린다(값은 B06 정본 그대로).
"""
try:
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
@@ -444,9 +443,10 @@ async def confirm_design_drawing(
quantity_tables or None,
)
# 횡단도면이면 확정 단면적을 재계산한다 (재계산 실패는 도면 확정을 막지 않음).
# 장은 담긴 측점 전부를 함께 확정한다.
recomputed: list[tuple[int, dict[str, Any]]] = []
# 횡단도면이면 담긴 측점 설계를 확정으로 올린다 — **상태만** 바꾼다. 장은 담긴 측점 전부.
# B07 CAD 에는 설계를 고치는 자리가 없어 덮을 값이 없다. 예전에는 입력 셋(지반·단면·측구
# 쪽)만으로 단면적을 다시 계산해 설계를 통째로 덮어, 암선·절토경사·표준 횡단·구조물
# 트림과 사용자 입력(측구 끔)이 사라졌다(2026-09-14 936be972 실측 62측점 · 브레인 ②).
cross_match = _CROSS_ID.fullmatch(drawing_id)
pool = get_db_pool()
targets: list[int] = []
@@ -454,39 +454,21 @@ async def confirm_design_drawing(
targets = list(sheet["chainages"])
elif item.kind == "cross" and cross_match:
targets = [int(cross_match.group(1))]
for chainage_int in targets:
designation = designs.get(chainage_int)
if not designation:
continue
try:
recomputed.append(
(
chainage_int,
await asyncio.to_thread(
_recompute_confirmed_design,
longitudinal_path,
f"cross_{chainage_int:05d}m",
designation,
),
)
)
except (ValueError, KeyError, FileNotFoundError, OSError):
logger.warning(
"B07 확정 단면적 재계산 실패 (도면 확정은 유지): drawing_id=%s 측점=%s",
drawing_id,
chainage_int,
exc_info=True,
)
confirmed_designs = [
(chainage_int, {**designs[chainage_int], "status": "confirmed"})
for chainage_int in targets
if designs.get(chainage_int)
]
async with pool.acquire() as connection:
await connection.begin()
try:
for chainage_int, confirmed_design in recomputed:
for chainage_int, _design in confirmed_designs:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
chainage_int=chainage_int,
patch=confirmed_design,
patch={"status": "confirmed"},
)
async with connection.cursor() as cursor:
if all_confirmed:
@@ -502,7 +484,7 @@ async def confirm_design_drawing(
id=drawing_id,
confirmed=True,
all_confirmed=all_confirmed,
design=recomputed[0][1] if len(recomputed) == 1 else None,
design=confirmed_designs[0][1] if len(confirmed_designs) == 1 else None,
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
@@ -646,32 +646,3 @@ def _invalidate_drawing(project_root: Path, drawing_id: str) -> None:
if entry:
entry["confirmed"] = False
_write_manifest(project_root, manifest)
def _recompute_confirmed_design(
longitudinal_path: Path, cross_stem: str, designation: dict[str, Any]
) -> dict[str, Any]:
"""B06 지정값과 현재 계획고로 절·성토 단면적을 재계산해 확정치(status=confirmed)로 만든다.
B07 CAD에는 아직 편집 가능한 설계선이 없으므로, 저장된 지정값(지반유형·단면유형·
측구위치) 계획고로 동일 엔진을 재실행해 확정 시점 값을 고정한다.
"""
longitudinal = _read_json(longitudinal_path)
cross_path = longitudinal_path.parent.parent / "cross_sections" / f"{cross_stem}.json"
source = _read_json(cross_path)
samples = source.get("samples")
if not isinstance(samples, list):
raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.")
design_elevation = design_elevation_from_longitudinal(
longitudinal, float(source.get("chainage_m", 0.0))
)
design = compute_cross_design(
samples,
design_elevation,
ground_type=designation["ground_type"],
section_mode=designation["section_mode"],
ditch_side=designation.get("ditch_side"),
**curve_widening_args(source),
)
design["status"] = "confirmed"
return design
+1 -1
View File
@@ -86,7 +86,7 @@ class DesignDrawingConfirmResponse(BaseModel):
id: str
confirmed: bool
all_confirmed: bool
# 횡단도 확정 시 재계산된 확정 설계(status=confirmed). 종단도·재계산 불가 시 None.
# 횡단도 확정 시 저장된 설계(상태만 status=confirmed). 종단도·장·설계 없음이면 None.
design: dict[str, Any] | None = None
+1 -1
View File
@@ -418,7 +418,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
// **기다린다**: 안 기다리면 오버레이가 먼저 걷혀, 버튼은 [수정]인데 CAD는 아직
// 편집이 열린 어긋난 순간이 생긴다.
await loadDrawing(currentDrawing, currentIndex);
// 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다.
// 확정한 설계(B06 정본 그대로 · 상태만 확정)로 지반/계획 정보 패널을 갱신한다.
if (currentDrawing.kind === "cross") {
infoPanelHost.replaceChildren(
buildDesignInfoPanel(
@@ -181,7 +181,7 @@ function infoRow(label: string, value: string): HTMLElement {
}
/**
* / 2 (, B07 ).
* / 2 (B06 · B07 ).
*
* **( ) ** `design`
* (2026-09-03 ).
@@ -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,17 @@ 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")
ROOT_REMOVAL_EXCAVATOR_SUGGESTED = (
"0.7",
"산림품셈 10-12-1 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — 같은 고시가 임도 표준"
" 장비를 0.7 로 적은 자리 · 0.2㎥ 도 고를 수 있음",
)
#: ⭐ 2026-09-14 브레인 판정 Ⓐ — 밑수 = 초류종자살포(파종) 면적. 반영률 칸을 따로 두면 같은 값을
#: 두 곳에서 관리하게 됨. 실무도 그렇게 움직임(거창 파종 성토 50%·절토 100% · 영월 성토 50%).
FACE_DRESSING_NOTE = (
@@ -108,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 판정 「별도 줄 · 칸으로 켜고 끔 · 기본 꺼짐」.
@@ -180,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))
@@ -365,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))
@@ -115,6 +115,8 @@ def build_handoff(
stand_volume_class: str | None = None,
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 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.
@@ -134,6 +136,10 @@ def build_handoff(
"stand_volume_class": stand_volume_class,
"face_dressing_cut_class": face_dressing_cut_class,
"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 "(형식이 아직 입력되지 않음)"
@@ -7,6 +7,7 @@
from __future__ import annotations
import string
from typing import Any
from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_code
@@ -27,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,
@@ -151,19 +153,30 @@ def _earthwork_rows(
# 보는 쪽을 고쳤으면 **다는 쪽도** 빠짐없이 달아야 한다.
blocked_kind = mismatch[1] if mismatch else None
blocked_reason = mismatch[2] if mismatch else ""
inputs = {**(variant_inputs or {}), "ground_class": ground}
variant_value = (
(entry or {}).get("variant_value")
or {
**(variant_inputs or {}),
"ground_class": ground,
}.get(str((entry or {}).get("variant_from") or ""))
or inputs.get(str((entry or {}).get("variant_from") or ""))
or None
)
# 입력 둘을 한 갈래 값으로 엮는 매핑(9-21 「크기·등급」 · 09-14) — 하나라도 비면 안 엮음.
template = str((entry or {}).get("variant_template") or "")
needs = [name for _, name, _, _ in string.Formatter().parse(template) if name]
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 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:
@@ -233,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
# 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는
@@ -261,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 이 아니다).
@@ -424,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]] = []
@@ -441,6 +463,15 @@ def _structure_rows(
# 갈래 축과 **저장 제원 원본값**. 가공하지 않는다.
variant_axis = str(entry.get("variant_axis") or "") or None
variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None
if variant_axis == "back_len_cm":
# 뒷길이만은 **수량표·구조물도가 실제로 쓴 값**과 출처를 싣는다 — 안 고른 벽(관 유입·
# 유출부 기슭막이는 늘 빔)도 [주]⑩ 하한으로 물량이 섰는데 갈래만 못 골랐음(09-14).
from B08_Quantity.B08_Quantity_Engine_StructureFigure import back_length_cm
back, default_note = back_length_cm(structure)
variant_value = str(back) if variant_value in (None, "") else variant_value
used = f"뒷길이 {back}㎝ — {default_note or '저장 제원'}"
class_basis = " · ".join(part for part in (class_basis, used) if part)
secondary_axes = [
{"axis": axis, "value": (structure.get("options") or {}).get(axis)}
for axis in entry.get("secondary_axes") or []
@@ -582,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,13 +19,36 @@ 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"
#: 프로그램 기본 작업본 자리 — 회사 번호 폴더와 안 겹치게 밑줄 이름.
PROGRAM_SUBDIR = "_program"
#: 프로그램 기본 발행본에서 뺄 출처 칸 — 어느 공사인지(2026-09-14 브레인 판정 ②).
ORIGIN_MASKED_KEYS = ("project", "file")
ORIGIN_MASKED_NOTE = "원문에서 뽑음 — 공사명은 발행 시 가림"
def program_library_dir() -> Path:
"""프로그램 기본 **작업본**(시스템 관리자가 화면에서 고친 것) — storage(회사·개인 단과 같은 결).
git `resources/library_structure`(`TEMPLATE_DIR`) **씨앗** 쓰기 금지(데이터 3층의 초기값 자리).
"""
return Path(STORAGE_BASE_DIR).resolve() / PROGRAM_SUBDIR / LIBRARY_SUBDIR
def program_items() -> list[dict[str, Any]]:
"""프로그램 기본 = 작업본 + 씨앗 중 **작업본에 없는 코드만**(관리자가 고친 것이 배포에 안 덮임)."""
stored = _items(program_library_dir())
codes = {item.get("code") for item in stored}
return [*stored, *(item for item in _items(TEMPLATE_DIR) if item.get("code") not in codes)]
def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]:
"""로그인한 사람의 3단 폴더. 회사가 없으면(시스템 관리자) 개인·회사 단은 없음."""
dirs: dict[str, Path] = {}
@@ -33,8 +56,9 @@ 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"] = TEMPLATE_DIR
dirs["program"] = program_library_dir()
return dirs
@@ -44,6 +68,13 @@ def _items(folder: Path) -> list[dict[str, Any]]:
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(folder.glob("*.json"))]
def _tier_items(dirs: dict[str, Path], tier: str) -> list[dict[str, Any]]:
"""단의 항목 — 프로그램 기본은 작업본 + 씨앗, 나머지는 그 폴더."""
if tier == "program":
return program_items()
return _items(dirs[tier]) if tier in dirs else []
def item_kind(item: dict[str, Any]) -> str:
"""양식형(`form`) · 고정형(`fixed`) — 명세 13장: 칸은 같고 **식이 한 줄이라도 있나**로만 가름.
저장된 `item_kind` 표시는 믿음 줄과 어긋나면 배지가 거짓이 ."""
@@ -66,16 +97,15 @@ def list_items(dirs: dict[str, Path], type_id: str) -> list[dict[str, Any]]:
}
for tier in TIERS
if tier in dirs
for item in _items(dirs[tier])
for item in _tier_items(dirs, tier)
if item.get("type_id") == type_id and item.get("code")
]
def find_item(dirs: dict[str, Path], tier: str, code: str) -> dict[str, Any] | None:
folder = dirs.get(tier)
if folder is None:
if tier not in dirs:
return None
return next((item for item in _items(folder) if item.get("code") == code), None)
return next((item for item in _tier_items(dirs, tier) if item.get("code") == code), None)
def project_library_dir(project_root: str | Path) -> Path:
@@ -98,7 +128,7 @@ def available_templates(project_root: str | Path | None) -> list[dict[str, Any]]
개인·회사 단은 넣음 표를 그릴 라이브러리를 매번 읽지 않음(판정 ).
"""
return [*_items(TEMPLATE_DIR), *project_templates(project_root).values()]
return [*program_items(), *project_templates(project_root).values()]
def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> None:
@@ -127,9 +157,13 @@ def _write(folder: Path, item: dict[str, Any]) -> None:
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
def _personal_code(folder: Path, type_id: Any) -> str:
"""개인 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로."""
same = [item for item in _items(folder) if item.get("type_id") == type_id]
def _personal_code(folder: Path, type_id: Any, existing: list[dict[str, Any]] | None = None) -> str:
""" 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로.
프로그램 기본은 씨앗까지 (`existing`) 씨앗 코드로 덮어써야 작업본이 씨앗을 이김.
"""
items = _items(folder) if existing is None else existing
same = [item for item in items if item.get("type_id") == type_id]
return str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
@@ -145,16 +179,22 @@ def save_personal(
template: dict[str, Any],
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 브레인 승인).
수동 단가는 실음 프로젝트의 값이라 양식에 실으면 남의 프로젝트로 (브레인 판정).
"""
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows
code = _personal_code(folder, template.get("type_id"))
existing = program_items() if tier == "program" else None
code = _personal_code(folder, template.get("type_id"), existing)
# 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
dropped = {"default_formula", "default_rounding"}
rows = [
@@ -162,9 +202,15 @@ 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": "personal", "rows": rows})
_write(folder, {**item, "code": code, "library_tier": tier, "rows": rows})
return code
@@ -186,8 +232,18 @@ def pin_program_templates(project_root: str | Path) -> int:
"""
pinned = project_templates(project_root)
count = 0
for item in _items(TEMPLATE_DIR):
for item in program_items():
if item.get("type_id") and item.get("code") and item["type_id"] not in pinned:
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"])
@@ -12,7 +12,7 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
@@ -28,11 +28,13 @@ OVERRIDES_KEY = "structure_formula_overrides"
def load_template(type_id: str) -> dict[str, Any] | None:
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬."""
path = TEMPLATE_DIR / f"{type_id}.json"
if not path.is_file():
return None
return json.loads(path.read_text(encoding="utf-8"))
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬.
storage 작업본(시스템 관리자가 고친 ) 먼저, 없으면 git 씨앗(`TEMPLATE_DIR`) 2026-09-14 관리자 UI.
"""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import program_items
return next((item for item in program_items() if item.get("type_id") == type_id), None)
def template_of(type_id: str, templates: dict[str, Any] | None) -> dict[str, Any] | None:
@@ -153,6 +155,65 @@ def template_sheet(
#: 양식이 m당으로 풀리는 단위 — 연장 L=1 로 풀면 곧 단위당 값(모든 줄이 L 에 비례).
_PER_LENGTH_UNITS = frozenset({"m"})
#: ㉯ 양식에 **그 이름 줄이 없으면 전개 값을 남기는** 성분(2026-09-14 브레인 판정 · 목록으로 못박음).
#: STmate 호표(고정형)는 토공·버림을 안 품음 — 통째로 갈음하면 구조물터파기 85.25→25㎥(6,037,712원 실측).
#: 목록 밖 성분은 양식으로 갈음 · 목록 안이라도 양식이 그 줄을 품으면 양식 값(겹쳐 세지 않음).
#: ② 실무 엑셀 벽·돌쌓기 호표 57 전수 — 기초잡석 0/57(「기초다짐 및 뒤채움」 4 은 다른 것) ·
#: 채집석 = 사토에서 빼는 돌 부피(haul_deduction)라 호표의 채집 줄(품 · 41/57)과 다른 것.
KEEP_ENGINE_COMPONENTS = ("터파기", "되메우기", "잔토처리", "버림콘크리트", "기초잡석", "채집석")
KEPT_ROW_REASON = "전개 값 그대로 — 양식이 이 줄을 안 품음(토공·버림은 전개가 셈)"
def _kept(rows: list[Any], template_rows: list[dict[str, Any]], name_of: Any) -> list[Any]:
"""목록 이름 중 양식에 없는 것만 — 줄(dict)·성분(Component) 둘 다 이름 꺼내는 손잡이로."""
names = {str(row.get("name") or "") for row in template_rows}
return [
row for row in rows if name_of(row) in KEEP_ENGINE_COMPONENTS and name_of(row) not in names
]
#: ㉰ 고정형 장·구조물 사유 머리 — 남긴 전개 줄에 걸린 전개 사유 / ㉱ 규격 다름.
KEPT_NOTE_HEAD = "(남긴 전개 줄) "
MISMATCH_HEAD = "⚠ 규격 다름"
#: ㉱ 항목 제목에 적힌 높이 규격 「H=2.0m」 — 원문 제목의 값을 읽음(없으면 비교 안 함 · 추측 아님).
_SPEC_HEIGHT = re.compile(r"H\s*=\s*([0-9]+(?:\.[0-9]+)?)")
def _is_fixed(template: dict[str, Any]) -> bool:
"""고정형 — 식이 한 줄도 없음(명세 13장 · `StructureLibrary.item_kind` 와 같은 가름)."""
rows = template.get("rows") or []
return bool(rows) and not any(str(row.get("formula") or "").strip() for row in rows)
def fixed_notes(
template: dict[str, Any], engine_notes: list[str], height: Any
) -> tuple[list[str], dict[str, float] | None]:
"""㉰㉱ 고정형의 사유 — 항목 사유 + **남긴 전개 줄에 걸린** 전개 사유만 + 규격 다름(맨 앞).
전개 사유(뒷길이 표준· 종류) 고정형 줄과 맞아 거짓 사유 걷음. 남긴 (터파기·버림)
말하는 것만 머리를 달아 . 불려도(원단위 구조물도) 같은 값이 나오게 .
"""
item = [part for part in str(template.get("note") or "").split(" · ") if part]
kept: list[str] = []
for note in engine_notes:
if note in item or note.startswith(MISMATCH_HEAD):
continue
if note.startswith(KEPT_NOTE_HEAD):
kept.append(note)
elif any(name in note for name in KEEP_ENGINE_COMPONENTS):
kept.append(KEPT_NOTE_HEAD + note)
notes = list(dict.fromkeys(item + kept))
found = _SPEC_HEIGHT.search(str(template.get("name") or ""))
if not found or height is None or abs(float(found.group(1)) - float(height)) < 1e-6:
return notes, None
item_h, sheet_h = float(found.group(1)), float(height)
notes.insert(
0,
f"{MISMATCH_HEAD} — 항목 H={found.group(1)}m ↔ 장 H={sheet_h:g}m: 벽 수량은 항목 박힌 값"
f"(H={found.group(1)}m) · 토공·사토 공제는 장 제원(H={sheet_h:g}m)으로 셈",
)
return notes, {"item_height_m": item_h, "sheet_height_m": sheet_h}
def _library_rows(
body: dict[str, Any], solved: list[dict[str, Any]], billing: float
@@ -295,7 +356,11 @@ def replace_with_templates(
spec=str(result.get("spec") or ""),
)
)
quantity.components = components
quantity.components = components + _kept(
quantity.components, body["rows"], lambda component: component.name
)
if _is_fixed(template):
quantity.notes, _mismatch = fixed_notes(template, quantity.notes, quantity.height_m)
# 「양식 있음/없음」을 화면이 가리게 — 조용히 섞이면 왜 값이 다른지 못 찾음.
quantity.library_item = str(template.get("name") or quantity.type_id)
@@ -343,18 +408,41 @@ def apply_templates(
continue
members = sheet.get("members") or []
billing = float(members[0].get("billing_quantity") or 0.0) if members else 0.0
sheet["rows"] = _library_rows(body, solved[index], billing)
library_rows = _library_rows(body, solved[index], billing)
start = max((row["no"] for row in library_rows), default=0)
kept = _kept(sheet.get("rows") or [], body["rows"], lambda row: row.get("name"))
sheet["rows"] = library_rows + [
{
**row,
"no": start + offset,
"formula": "",
"default_formula": "",
"rounding": None,
"default_rounding": None,
"skipped": False,
"reason": KEPT_ROW_REASON,
"error": "",
}
for offset, row in enumerate(kept, start=1)
]
sheet["unpriced_rows"] = [
row["name"]
for row in sheet["rows"]
if row["unit_amount"] is None and not row["skipped"]
]
if _is_fixed(template):
# ㉱ 규격 다름 — 금액은 서되 미확정 급으로 보임(막지 않음 · 사용자가 일부러 고른 것일 수 있음).
sheet["notes"], sheet["spec_mismatch"] = fixed_notes(
template, sheet.get("notes") or [], sheet.get("height_m")
)
# 「어느 단에서 가져왔나」 — 안 가져왔으면 `imported_from` 없음(= 기본 · 가져오기 전).
sheet["library_item"] = {
"type_id": template.get("type_id"),
"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
# 실무 관측값 같은 「대안 후보」 — 값을 바꾸지 않고 칸 옆에 보이기만(판정 Ⓑ).
@@ -16,6 +16,7 @@
from __future__ import annotations
import re
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from decimal import Decimal
@@ -362,32 +363,64 @@ def save_manual(
return merged
#: 조각 후보의 최소 겹침 글자 수(2026-09-14 브레인 판정 ① — 「채집」·「타설」이 걸리게).
MIN_FRAGMENT = 2
def _longest_common(a: str, b: str) -> int:
"""두 글의 가장 긴 공통 조각 길이."""
best = 0
previous = [0] * (len(b) + 1)
for char in a:
current = [0]
for index, other in enumerate(b):
current.append(previous[index] + 1 if char == other else 0)
best = max(best, max(current))
previous = current
return best
def search_titles(book: Any, query: str, kind: str, limit: int = 50) -> list[dict[str, Any]]:
"""고르개 — 단가표에서 낱말이 **모두** 든(코드·이름·규격) 항목. 단가가 섰는지도 함께."""
"""고르개 — 단가표 항목 후보. 단가가 섰는지도 함께.
낱말이 **모두** (코드·이름·규격) 항목이 먼저, 그다음 ** 글자 이상 조각이 겹친** 항목을
겹친 글자 순으로(). 고정형 이름(깬잡석채집) 단가표 제목(막돌 채집) 낱말이 달라
전부 포함 방식으로는 후보가 0 이었음. 후보만 고르는 것은 사람(명세 2 · 별칭표로 맞춤).
"""
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
kinds = SEARCH_KINDS[kind]
words = query.lower().split()
found: list[dict[str, Any]] = []
for title in book.titles.values() if words else ():
if len(found) >= limit:
break
# 조각 비교는 글자·숫자만 — 괄호 「(장비)」 같은 기호가 겹친 글자 수를 부풀리지 않게.
compact = re.sub(r"[\W_]+", "", query.lower())
scored: list[tuple[int, int, int, Any]] = []
for order, title in enumerate(book.titles.values() if words else ()):
if title.kind.value not in kinds:
continue
text = f"{title.code} {title.name} {title.spec}".lower()
if all(word in text for word in words):
try:
money = book.resolve(title.code)
total: float | None = float(money.material + money.labor + money.expense)
except PriceBookError:
total = None
found.append(
{
"code": title.code,
"name": title.name,
"spec": title.spec,
"unit": _unit(title.unit),
"price": total,
}
)
scored.append((1, len(compact), order, title))
continue
overlap = _longest_common(
compact, re.sub(r"[\W_]+", "", f"{title.name}{title.spec}".lower())
)
if overlap >= MIN_FRAGMENT:
scored.append((0, overlap, order, title))
scored.sort(key=lambda item: (-item[0], -item[1], item[2]))
found: list[dict[str, Any]] = []
for _whole, _overlap, _order, title in scored[:limit]:
try:
money = book.resolve(title.code)
total: float | None = float(money.material + money.labor + money.expense)
except PriceBookError:
total = None
found.append(
{
"code": title.code,
"name": title.name,
"spec": title.spec,
"unit": _unit(title.unit),
"price": total,
}
)
return found
@@ -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 그물 침).
+52 -5
View File
@@ -34,13 +34,25 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import (
FACE_DRESSING_CUT_CLASSES,
FACE_DRESSING_FILL_CLASSES,
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
@@ -51,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,
)
@@ -93,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)
@@ -112,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"] = [
@@ -148,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={
@@ -173,6 +194,16 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
"basis": FACE_DRESSING_FILL_SUGGESTED[1],
},
}
# 제근 굴착기 크기 — 선택지·제안(회색 · [제안값 넣기])은 서버 한 곳(2026-09-14 브레인).
table["root_removal_excavator_choices"] = {
"choices": list(ROOT_REMOVAL_EXCAVATOR_SIZES),
"suggested": {
"value": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[0],
"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(
@@ -227,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()}
@@ -478,6 +509,10 @@ class QuantitySettingsBody(BaseModel):
stand_volume_class: str | None = None
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질(9-19-1 원문 표). `""` 는 「안 정함」.
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
@@ -556,6 +591,8 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
for key, choices in (
("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] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침
@@ -600,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 화면에서 걸린 자리).
@@ -627,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
@@ -497,6 +497,10 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질. 비면 그 줄이 입력 사유로 막힘.
face_dressing_cut_class=settings.get("face_dressing_cut_class") or None,
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),
)
@@ -6,15 +6,18 @@
[넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 서버가 **파일을 다시 읽어** 개인 단에 .
브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5) · 개인 단만(판정 ) · 종류당 하나라 같은 종류 것은
덮어씀(판정 ) · 종류는 사용자가 고름 이름으로 자동으로 붙임(판정 ).
[복제해서 것으로](PLAN 4) 기본·회사 항목을 개인 단에 베낌. 프로젝트 작업본은 바꿈.
(구조물도 라우터가 700줄에 닿아 개인 단으로 넣는 창구를 파일에 모음.)
"""
import asyncio
import io
from typing import Any
from typing import Any, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, File, Form, UploadFile
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from common_util.common_util_auth import verify_session
@@ -110,3 +113,107 @@ async def save_stmate_recipe(
"note": item["note"],
}
)
class LibraryCloneRequest(BaseModel):
"""베낄 항목 — 단과 코드(이름은 겹칠 수 있음). 개인 단 것은 이미 내 것이라 안 받음."""
model_config = ConfigDict(extra="forbid")
type_id: str = Field(min_length=1, max_length=100)
tier: Literal["received", "company", "program"]
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
@router.put("/{project_id}/quantity/structure-sheets/library/clone")
async def clone_library_item(
project_id: UUID,
payload: LibraryCloneRequest,
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""③ 기본·회사 항목을 **로그인한 사람 개인 단**에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]할 길을 한 번에."""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
find_item,
save_item_personal,
tier_dirs,
)
dirs = tier_dirs(session.get("company_id"), session.get("user_id"))
folder = dirs.get("personal")
if folder is None:
return _error(403, "개인 라이브러리는 회사에 속한 사용자만 씁니다.")
item = await asyncio.to_thread(find_item, dirs, payload.tier, payload.code)
if item is None or item.get("type_id") != payload.type_id:
return _error(404, "베낄 항목을 찾지 못했습니다.")
body = {k: v for k, v in item.items() if k not in ("code", "imported_from", "library_tier")}
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 ""})
@@ -316,6 +316,7 @@ async def get_structure_library(
"status": "success",
"items": await asyncio.to_thread(list_items, dirs, type_id),
"current": {"code": current.get("code"), "imported_from": current.get("imported_from")},
"can_publish": {tier: _publish_dir(session, tier) is not None for tier in PUBLISH},
}
)
@@ -326,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}$")
@@ -394,15 +395,33 @@ def _no_personal() -> JSONResponse:
)
#: 발행 단 — 회사는 마스터·시스템 관리자, 프로그램 기본은 시스템 관리자(2026-09-14 브레인 승인).
PUBLISH = ("company", "program")
def _publish_dir(session: dict[str, Any], tier: str) -> Path | None:
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import program_library_dir, tier_dirs
admin = session.get("role") == "SYSTEM_ADMIN"
if tier == "program":
return program_library_dir() if admin else None
if tier == "company" and (admin or session.get("is_master")):
return tier_dirs(session.get("company_id"), None).get("company")
return _personal_dir(session) if tier == "personal" else None
class LibrarySaveRequest(BaseModel):
"""[내 라이브러리에 저장] — 어느 장의 양식을 쓸지."""
"""[내 라이브러리에 저장]·[발행] — 어느 장의 양식을 어느 단에 쓸지(같은 모양)."""
model_config = ConfigDict(extra="forbid")
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")
@router.put("/{project_id}/quantity/structure-sheets/library/publish")
async def put_structure_library_personal(
project_id: UUID,
payload: LibrarySaveRequest,
@@ -412,17 +431,18 @@ 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
folder = _personal_dir(session)
folder = _publish_dir(session, payload.tier)
if folder is None:
return _no_personal()
if payload.tier == "personal":
return _no_personal()
return JSONResponse(
status_code=403, content={"status": "error", "message": "발행 권한이 없습니다."}
)
project_root = await _project_root(project_id)
if project_root is None:
return _not_found()
@@ -439,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)
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,
@@ -5,11 +5,13 @@
*
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* ** **(`face_dressing_choices`) ( ).
* ( ) .
* ( ) .
* + [ ] ** **( 976e5fd9 `fill_suggested`).
* ( × ) .
* ========================================================================== */
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 {
@@ -29,6 +31,8 @@ export interface FaceDressingDraft {
export interface FaceDressingChoices {
cut: string[];
fill: string[];
/** 성토면 제안(서버 한 곳 · 실무 관측) — 회색으로 보이고 [제안값 넣기]를 **누른 때만** 들어감. */
fill_suggested?: { value: string; basis: string };
}
export function appendFaceDressingFields(
@@ -76,11 +80,36 @@ export function appendFaceDressingFields(
draft.face_dressing_cut_class = v;
}),
);
panel.append(
pick("B08_Quantity_FaceDressing_Fill", draft.face_dressing_fill_class, choices.fill, (v) => {
const fillRow = pick(
"B08_Quantity_FaceDressing_Fill",
draft.face_dressing_fill_class,
choices.fill,
(v) => {
draft.face_dressing_fill_class = v;
}),
},
);
panel.append(fillRow);
// 성토면 제안 — 칸은 비워 둔 채 회색 근거 + 단추(층따기와 같은 모양). 비우면 「안 정함」 그대로.
const suggested = choices.fill_suggested;
if (suggested?.value) {
panel.append(
h.hintRow(
`${L("B08_Quantity_FaceDressing_Suggest")} ${suggested.value}${suggested.basis}`,
),
);
panel.append(
createButton({
label: L("B08_Quantity_FaceDressing_FillSuggested"),
variant: "ghost",
onClick: () => {
const select = fillRow.querySelector("select");
if (select) select.value = suggested.value;
draft.face_dressing_fill_class = suggested.value;
draft.dirty = true;
},
}),
);
}
panel.append(
area("B08_Quantity_FaceDressing_FillArea", draft.face_dressing_fill_area_m2, (v) => {
draft.face_dressing_fill_area_m2 = v;
@@ -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")),
);
}
+20 -1
View File
@@ -86,9 +86,13 @@ export interface StructureSheet extends StandardSheetSpec {
name: string;
code?: string | null;
imported_from?: string | null;
/** 뽑아 온 원문 공사명(STmate 고정형) — 프로그램 기본 발행 때 가림. */
origin_project?: string | null;
};
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
formula_sheet?: FormulaSheet;
/** ㉱ 고정형 항목 규격(H)이 장 제원과 다름 — 금액은 서되 미확정 급(빨간 테두리·배지). */
spec_mismatch?: { item_height_m: number; sheet_height_m: number } | null;
/** 제원 칸의 대안 후보(실무 관측값 등) — 값은 안 바꾸고 보이기만. */
var_candidates?: {
name: string;
@@ -258,8 +262,21 @@ 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) {
const { item_height_m: item, sheet_height_m: own } = sheet.spec_mismatch;
head.append(el("span", "b08-unit__badge", `규격 다름 H${item} ↔ H${own}`));
main.classList.add("b08-unit__manual");
}
// 한 장 = 상단 그림 + 하단 표(PLAN 3장 한 장의 짜임) — 못 그리는 장은 까닭.
main.append(head, figureSection(sheet.figure, sheet.figure_reason));
if (!sheet.rows.length) {
@@ -414,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;
}
/** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */
@@ -80,13 +91,112 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
take.className = "b08-spec__save";
take.textContent = "가져오기";
take.hidden = true;
// 복제 — 기본·회사 항목을 개인 단에 베낌(PLAN 4장). 개인 단 것은 이미 내 것이라 안 눌림.
const clone = document.createElement("button");
clone.type = "button";
clone.className = "b08-quantity__tab";
clone.textContent = "복제해서 내 것으로";
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("|");
const label = list.selectedOptions[0]?.textContent ?? "";
if (!tier || !code || tier === "personal") return;
if (
!window.confirm(
`${label}」을 내 라이브러리로 베낌 — 같은 종류 내 것이 있으면 덮어씀 · 프로젝트 값은 안 바뀜`,
)
) {
return;
}
void (async () => {
clone.disabled = true;
try {
await readJson(
await fetch(libraryUrl(projectId, "/clone"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type_id: typeId, tier, code }),
}),
);
afterLoad = "내 라이브러리에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]";
load.click();
} catch (error) {
status.textContent = error instanceof Error ? error.message : "복제 못함";
syncClone();
}
})();
});
load.addEventListener("click", () => {
void (async () => {
load.disabled = true;
status.textContent = "목록 받는 중…";
try {
const { items } = await readJson<{ items: LibraryItem[] }>(
const { items, can_publish: canPublish } = await readJson<{
items: LibraryItem[];
can_publish?: { company: boolean; program: boolean };
}>(
await fetch(libraryUrl(projectId, `?type_id=${encodeURIComponent(typeId)}`), {
credentials: "include",
}),
@@ -101,8 +211,13 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
return option;
}),
);
list.hidden = take.hidden = items.length === 0;
status.textContent = items.length ? "" : "가져올 항목이 없음";
list.hidden = take.hidden = clone.hidden = send.hidden = items.length === 0;
syncClone();
// 발행 단추 — 서버가 준 권한대로만 보임(회사 = 마스터 · 기본 = 시스템 관리자).
toCompany.hidden = !canPublish?.company;
toProgram.hidden = !canPublish?.program;
status.textContent = items.length ? afterLoad : "가져올 항목이 없음";
afterLoad = "";
} catch (error) {
status.textContent = error instanceof Error ? error.message : "목록을 받지 못함";
} finally {
@@ -161,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}줄 포함` : ""}`;
@@ -187,11 +302,40 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
);
return result.deleted ? "내 라이브러리에서 지움" : "지울 내 양식이 없음";
});
// 발행 — [내 라이브러리에 저장]과 같은 모양으로 회사·프로그램 기본 단에(2026-09-14 브레인 승인).
const publish = (label: string, tier: "company" | "program"): HTMLButtonElement => {
const button = personal(label, async () => {
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
const whom = tier === "program" ? "모든 회사가 쓰는 프로그램 기본" : "우리 회사 라이브러리";
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, name: tag }),
}),
);
return `${whom}에 발행함`;
});
button.hidden = true;
return button;
};
const toCompany = publish("회사 라이브러리에 발행", "company");
const toProgram = publish("프로그램 기본으로 발행", "program");
const mine = document.createElement("div");
mine.className = "b08-sheet__actions";
mine.append(save, remove);
mine.append(save, remove, toCompany, toProgram);
panel.append(title, scope, load, list, take, 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}`));
@@ -233,7 +233,16 @@ export function unitPriceEditor(options: EditorOptions): HTMLElement {
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
const items = payload.items ?? [];
results.replaceChildren(
...(items.length ? items.map(choice) : [el("span", "b08-grid__caption", "없음")]),
...(items.length
? items.map(choice)
: [
// 후보 0 — 조용히 막히지 않게 다음 길을 적음(갈래 바꿔 찾기 · 수동 단가는 미확정으로 셈).
el(
"span",
"b08-grid__caption",
"단가표에 후보 없음 — 낱말·갈래(품셈/자원)를 바꿔 찾거나, 이 줄에 수동 단가를 넣을 것(미확정으로 셈)",
),
]),
);
} catch (error) {
results.replaceChildren(
@@ -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:
@@ -414,11 +414,11 @@ from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import ( # noqa: E402
_composite_row,
_excluded_row,
_leaf_row,
_material_row,
_structure_price_row,
_sum_groups,
bill_line, # noqa: F401 — 내역 줄 성분별 절사(골든셋 시험이 여기서 부름)
)
from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import _material_row # noqa: E402
def build_bill(
@@ -15,7 +15,13 @@ from __future__ import annotations
from decimal import Decimal
from typing import Any, Callable
from B09_Estimation.B09_Estimation_BillOfQuantities import BillResult, BillRow
from B09_Estimation.B09_Estimation_BillOfQuantities import (
SUPPLY_OWNER,
SUPPLY_UNKNOWN,
BillResult,
BillRow,
HandoffMaterial,
)
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceKind
from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, UnitPriceBuild
@@ -24,6 +30,71 @@ DOUBLE_COUNT_SUSPECT = "double_count_suspect"
_ZERO = Decimal(0)
def _material_row(
material: HandoffMaterial, result: BillResult, manual: dict | None = None
) -> BillRow:
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.
`manual` 자재 단가 수동 단가( 이름 규격). 사급 줄에 값이 있으면 빠진 목록에 올림
(금액은 본체 자재(사급) 줄이 `BillOfQuantities_Materials`).
"""
row = BillRow(
item_no="",
level=1,
code=None,
name=material.material_name,
spec=material.spec,
unit=material.unit,
quantity=material.total_amount,
)
# 할증 사유는 **수량**에 닿는다 — 할증이 곱해진 뒤의 수량이기 때문이다.
row.add_note("quantity", material.surcharge_note)
if material.supply_type == SUPPLY_UNKNOWN:
row.add_note(
"", "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다."
)
result.missing.append(
{
"name": material.display_name,
"unit": material.unit,
"quantity": str(material.total_amount),
"reason": "공급 구분 미정(unknown)",
}
)
return row
# ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이
# `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도
# 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지).
if material.supply_type == SUPPLY_OWNER:
row.add_note(
"unit_price_krw",
"관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. "
"관급자재대(총원가 밖 별도 표기)로 갑니다.",
)
reason = "관급 자재 단가 없음"
elif f"{material.material_name} {material.spec}".strip() in (manual or {}):
row.add_note(
"unit_price_krw", "⚠ 사급 자재 수동 단가(미확정) — 본체 「자재(사급)」 줄로 섬"
)
return row
else:
row.add_note(
"unit_price_krw", "사급 자재 단가 미확보 — 「자재 단가」 탭에서 수동 입력 대기."
)
reason = "사급 자재 단가 없음(미결 No.18)"
result.missing.append(
{
"name": material.display_name,
"unit": material.unit,
"quantity": str(material.total_amount),
"reason": reason,
"supply_type": material.supply_type,
}
)
return row
def _flat(text: Any) -> str:
return "".join(str(text or "").split())
@@ -12,11 +12,8 @@ from __future__ import annotations
from decimal import Decimal
from B09_Estimation.B09_Estimation_BillOfQuantities import (
SUPPLY_OWNER,
SUPPLY_UNKNOWN,
BillResult,
BillRow,
HandoffMaterial,
HandoffWorkItem,
_BLOCKED_LABELS,
_MasterNode,
@@ -112,6 +109,7 @@ def _composite_row(
in_bill=item.in_bill,
)
missing_parts: list[str] = []
reasons: list[str] = []
money = None
for part in item.composite_parts:
code = str(part.get("code") or "")
@@ -119,12 +117,24 @@ def _composite_row(
if not code or amount is None or f"B-{code}" not in unit_prices.book.titles:
missing_parts.append(code or str(part.get("name") or "이름 없음"))
continue
# 조각도 보통 줄과 같은 두 검사 — 일부 몫만 선 단가·밑수 모르는 표를 묶음에 더하면
# 묶음 줄만 온전한 금액처럼 섬(2026-09-14 ㉱ 구조 결함).
plain = code.split("#", 1)[0]
covered = unit_prices.partial_ratio.get(plain)
basis = unit_prices.basis_missing.get(plain)
if covered is not None or basis:
missing_parts.append(code)
reasons.append(
f"{code}: 단가 일부만 섬(붙은 몫 {covered}%)"
if covered is not None
else f"{code}: 밑수 미확보 — 원문 {basis}"
)
continue
# 묶음도 호표 한 장 — 조각 줄 0.1원 · 성분 소계 원 미만 절사(아래 `floored`, 명세 7장).
scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount).floored(Decimal("0.1"))
money = scaled if money is None else money + scaled
row.parts.append((f"B-{code}", amount))
reasons: list[str] = []
for pending in item.composite_not_ready:
# 「단가 없음」과 「물량 없음」을 가른다 — 사유가 다르면 할 일도 다르다.
if isinstance(pending, str):
@@ -444,6 +454,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 +607,7 @@ _PENDING_FORMULA: dict[str, str] = {}
from B09_Estimation.B09_Estimation_KnownGaps import ( # noqa: E402
form_judgment_note,
known_gap_note,
pipe_diameter_note,
)
@@ -610,71 +623,6 @@ def pending_formula_note(code: str | None) -> str:
return ""
def _material_row(
material: HandoffMaterial, result: BillResult, manual: dict | None = None
) -> BillRow:
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.
`manual` 자재 단가 수동 단가( 이름 규격). 사급 줄에 값이 있으면 빠진 목록에 올림
(금액은 본체 자재(사급) 줄이 `BillOfQuantities_Materials`).
"""
row = BillRow(
item_no="",
level=1,
code=None,
name=material.material_name,
spec=material.spec,
unit=material.unit,
quantity=material.total_amount,
)
# 할증 사유는 **수량**에 닿는다 — 할증이 곱해진 뒤의 수량이기 때문이다.
row.add_note("quantity", material.surcharge_note)
if material.supply_type == SUPPLY_UNKNOWN:
row.add_note(
"", "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다."
)
result.missing.append(
{
"name": material.display_name,
"unit": material.unit,
"quantity": str(material.total_amount),
"reason": "공급 구분 미정(unknown)",
}
)
return row
# ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이
# `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도
# 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지).
if material.supply_type == SUPPLY_OWNER:
row.add_note(
"unit_price_krw",
"관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. "
"관급자재대(총원가 밖 별도 표기)로 갑니다.",
)
reason = "관급 자재 단가 없음"
elif f"{material.material_name} {material.spec}".strip() in (manual or {}):
row.add_note(
"unit_price_krw", "⚠ 사급 자재 수동 단가(미확정) — 본체 「자재(사급)」 줄로 섬"
)
return row
else:
row.add_note(
"unit_price_krw", "사급 자재 단가 미확보 — 「자재 단가」 탭에서 수동 입력 대기."
)
reason = "사급 자재 단가 없음(미결 No.18)"
result.missing.append(
{
"name": material.display_name,
"unit": material.unit,
"quantity": str(material.total_amount),
"reason": reason,
"supply_type": material.supply_type,
}
)
return row
#: 같은 단위의 다른 표기 — 표기만 다르고 뜻이 같은 것을 「다르다」고 하면 멀쩡한 줄이 멈춘다.
_UNIT_ALIASES = {
"": "m3",
+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 = [
@@ -3,10 +3,13 @@
근거: STmate 분석 `12_원가계산_4형식과_RATE표_복원.md` §4~6 (DFM 복원 서식) ·
`35_형식과_단계의_공통과_차이.md` §1·§2 (공통 사슬 · 형식별 차이 · 12 정정 반영).
**금액을 낸다** 요율 데이터가 없다(2026-09-14 확인: 수공 세부 경비 율표 `RateB` ·
표준시장단가 제비율 `RATE_RA`·`RATE_RA1`·실적 안전표 `An_A`). 줄마다 무엇이 막혔는지만 적는다.
**금액을 낸다** 2026-09-14 요율 결론(PLAN 7): 실적형 제비율 `RATE_RA`·`RATE_RA1`
**임도 미적용**(100 미만 · 예정가격작성기준 §37) · 수공 세부 경비 `RateB` **법정 요율 없음**
(업체 실측 대상 §34 설계자 입력 ) · 실적 안전표 `An_A` 고시 편입(제4조·별표1)이나 실적형
자체가 임도 미적용. 줄마다 무엇이 막혔는지만 적는다.
**0 원으로 채우지 않는다** 0 원은 없다 아니라 공짜 읽혀 총액이 조용히 작아진다.
35 §5 미확인(실적형 이윤 밑수 · 실적수공 안전관리비 존재) 확인 대기 세운.
35 §5 미확인 확인 대기 세운다 지금 남은 것은 **실적형 이윤 밑수** 하나.
실적수공 안전관리비는 2026-09-14 코덱스가 STmate 원문에서 **있음** 확인해 별도내역으로 내렸다.
"""
from __future__ import annotations
@@ -16,12 +19,20 @@ from typing import Any
#: 줄 상태 — 화면이 그대로 보인다.
COMMON = "공통 요율 — 일반형식과 같은 표(형식 전체가 서면 금액이 섬)"
SEPARATE = "별도내역 — 요율이 아니라 따로 산출한 금액"
RATE_B = "요율표 미확보 — 수공 세부 경비 율표(STmate RateB)"
RATE_RA = "요율표 미확보 — 표준시장단가(실적공사비) 제비율(STmate RATE_RA·RATE_RA1)"
RATE_AN_A = "요율표 미확보 — 실적 산업안전보건관리비 표(STmate An_A·An_A1)"
RATE_B = (
"법정 요율 없음 — 값을 넣어 주십시오(업체 실측 대상 · 예정가격작성기준 §34①) · 수공 세부 경비"
"(STmate RateB) · 입력 칸은 수공형식 금액 셈과 함께 섬"
)
RATE_RA = (
"임도 미적용 — 100억 미만(예정가격작성기준 §37②) · 표준시장단가(실적공사비) 제비율"
"(STmate RATE_RA·RATE_RA1)"
)
RATE_AN_A = (
"임도 미적용 — 실적형 형식 전체가 100억 미만 임도에 안 씀(§37②) · 요율 자체는 고시 편입"
"(건설업 산업안전보건관리비 계상 및 사용기준 제4조·별표1)"
)
TOTAL = "합계 — 위 줄이 다 서야 섬"
PENDING_PROFIT = "확인 대기 — 실적형 이윤 밑수가 복원식과 화면 문구가 충돌(35번 §5-1)"
PENDING_SAFETY = "확인 대기 — 실적수공에 안전관리비가 실제로 있는지 미확인(35번 §5-2)"
DIRECT = "설계내역서 합계에서 옴"
_SUGONG_EXPENSES = (
@@ -115,7 +126,8 @@ def _actual(sugong: bool) -> list[dict[str, Any]]:
_r("6)", "연금보험료", "직접노무비 ×", COMMON, 1),
_r("7)", "퇴직공제부금비", "직접노무비 ×", COMMON, 1),
_r("8)", "산업안전보건관리비", "(직접공사비+도급자관급자재) ×", RATE_AN_A, 1),
_r("9)", "안전관리비", "", PENDING_SAFETY if sugong else SEPARATE, 1),
# TA·SA 둘 다 별도내역 — SA 도 있음이 확인됨(2026-09-14 코덱스, 35번 §5-2 풀림).
_r("9)", "안전관리비", "", SEPARATE, 1),
_r("10)", "품질관리비", "", SEPARATE, 1),
]
if sugong:
+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": "모르타르 배합 참고자료 ※ 「위 재료량은 할증이 포함된 것이다」",
}
+59 -7
View File
@@ -54,6 +54,29 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = {
"ⓘ 성토면 · 기계: 원문이 굴착기 0.6㎥ 형식을 안 적어 무한궤도·타이어 두 갈래로 세움"
"(제안 무한궤도 — 영월 실무 「06M3 B/H」 · 2026-09-14 브레인 ②).",
),
# 2026-09-14 ㉮ — 사용횟수 갈래로 푼 뒤 남는 원문 몫. 값을 짓지 않고 말만.
# 봉상후렉시블 셋의 표 나머지 줄은 판정표 자동 목록(`_unread_rows`)이 맡음 — 줄이 아닌 [주] 만 여기.
"FP-12-12": ("원문 [주]", "ⓘ [주] 「성토부 날개벽 설치시 인건비 30% 할증」 은 안 걺(선택)."),
# 원문 대 실무 어긋남 기록(2026-09-15 브레인 규칙 — 원문이 또렷하면 원문 · 어긋남은 늘 기록).
"FP-09-15-02": (
"실무 어긋남",
"ⓘ 실무 영월 「표토제거 답외구간」 호표 Q=576.95 는 E 자리에 e(0.96)를 넣은 값으로 역산됨"
"(60×3.07×0.77×0.96÷(1.18×0.2)) — 원문 9-15-2 가 E=0.4 로 또렷해 원문으로 셈 · 다른 실무(봉화·대흥·"
"소광·거창)엔 이 호표 없음.",
),
"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%(합판과 각재의 설계단가 기준)」 은 원문이 셈을 안 줘 값으로 안 씀"
" — [주]① 2회 이상 사용 고재량은 재료비 비율에 기포함. "
"ⓘ 재료(합판·못 카탈로그 없음 · 각재·철선·박리제 규격 미정)는 못 붙은 줄 — 지금 값은"
" **인력 품만** · [주]③ 동바리 별도 · [주]⑥ 소형구조물 인력품 30% 할증(선택)은 안 걺.",
),
"FP-12-25": (
"운반거리 미정",
"⚠ 이 값에는 **운반 몫이 빠져 있습니다** — 품셈 12-25 는 「운반 | 덤프트럭(15ton)」 줄을 "
@@ -76,13 +99,7 @@ CONDITIONAL_INCLUDED: dict[str, str] = {
#: (표 읽기 사유 「박리제 줄을 못 풀었습니다」는 사람을 엉뚱한 데로 보낸다).
#: ⚠ 이 구현이 서면 그 줄을 지운다.
BLOCKED_BY_DESIGN: dict[str, str] = {
# 2026-09-13 브레인 판정 — B09 일위대가 일감(B08 Formwork 머리말 「횟수별 재료 환산은 여기서
# 하지 않는다」). 표를 그대로 풀면 1회 사용 값(재료·노무 100 %)으로 조용히 비싸진다.
"FP-12-04": (
"거푸집 **사용횟수별 비율**(12-4 「1회 100 % · 4회 40 %…」)을 일위대가에 거는 셈이"
" 아직 없어"
" 풀지 않았습니다 — 풀면 1회 사용 값으로 비싸게 섭니다"
),
# (비어 있음) 12-4 합판거푸집은 2026-09-14 사용횟수 갈래로 풀려 걷음(`_JudgedTable`).
}
@@ -108,6 +125,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()} - {""}
+11 -104
View File
@@ -27,6 +27,16 @@ from dataclasses import dataclass, field, replace
from decimal import Decimal, InvalidOperation
from typing import Any
# 셀 거르기 · 물결표 목록 · 셀 정규화 — 700줄 제한으로 `_Labels` 에 둠(순수 분리 2026-09-14).
# 바깥 파일이 여기서 가져가는 이름은 그대로 다시 내보낸다.
from B09_Estimation.B09_Estimation_ResourceAxis_Labels import (
_RE_RANGE_CELL, # noqa: F401
RANGE_DASH_CLASS, # noqa: F401
RANGE_DASHES, # noqa: F401
_normalize,
is_non_resource_label,
)
#: 자원 축을 붙일 수 있는 표 형태. 나머지는 값을 쓰지 않는다.
USABLE_FORMS = frozenset({"productivity", "requirement"})
#: 공종이 아닌 표 — 일위대가 항목으로 세우지 않는다.
@@ -48,45 +58,6 @@ _RE_NUMBER = re.compile(r"^-?\d+(?:,\d{3})*(?:\.\d+)?$")
#: 보통인부 한 줄만 남았다).
_GROUP_LABELS = ("자재", "장비", "인력", "노무", "재료", "기계")
#: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다.
#: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다.
#: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`·
#: `계장공` 이, 「작업」을 잡으면 `작업반장` 이, 「인력」을 잡으면 `인력운반공` 이
#: 통째로 사라진다(2026-09-07 실측 — 정상 자원 **70/745** 가 걸리고 있었음).
#: 그래서 **셀 전체가 그 말과 같을 때만** 머리글로 본다.
_NON_RESOURCE_WORDS = (
"구분",
"합계",
"소계",
"",
"단위",
"비고",
"규격",
"명칭",
"품명",
"종류",
"항목",
"적용",
"기준",
"산출",
"비율",
"할증",
"할인",
"직접노무비",
"재료비",
"경비",
"위치",
"면적",
"수량",
"공종",
"작업",
"내역",
"총계",
"인력",
"장비",
"기계",
)
class ResourceAxisError(ValueError):
"""자원 축을 붙일 수 없는 경우. 조용히 넘기지 않는다."""
@@ -151,71 +122,6 @@ class ResourceCatalog:
return narrowed[0] if len(narrowed) == 1 else None
#: 첫 칸이 자원 이름이 **아닌** 표가 많다 — 규격 구간표(「10∼12」), 기호표(「f」·「E」),
#: 치수표 등. 그런 셀을 못 맞춘 목록에 넣으면 목록이 못 쓰게 되므로 먼저 거른다.
#: ⚠ **물결표·붙임표 목록은 여기 한 벌뿐이다.** 네 파일에 따로 적어 두었더니 서로
#: 달라졌다(2026-09-08 메인 창 교차검토 — 어떤 목록엔 `~`, 어떤 목록엔 `〜` 가 빠졌음).
#: 지금 물리는 것은 없었으나 **같은 목록이 네 벌이면 언젠가 하나만 고쳐진다.**
#: 갈래 키 정규화(`B09_Estimation_UnitPrice.normalize_variant_key`)도 이 목록을 쓴다.
RANGE_DASHES = "∼~〜~-–‐"
#: ⚠ **문자클래스에 그대로 넣지 말 것** — `~-–` 이 **범위 연산자**로 읽혀 거의 모든
#: 글자가 걸린다(2026-09-08 실측: 「0.7㎥」·「15톤」이 구간으로 잡혔음). 반드시 이 쪽을 쓴다.
RANGE_DASH_CLASS = "".join(re.escape(ch) for ch in RANGE_DASHES)
#: 구간 셀에는 **단위 꼬리**가 붙기도 한다 — 「51~100m」·「1214㎝」(2026-09-08 실측 39줄).
#: ⚠ **단위가 붙었다고 다 구간이 아니다** — 「굴착기 0.7㎥」는 규격이고 자원 이름의 일부다.
#: 그래서 **수 ~ 수 + 단위**라는 모양 전체가 맞을 때만 구간으로 본다(앞에 이름이 없어야 한다).
_RANGE_UNITS = "a-zA-Z㎝㎜㎥㎡㎞mm톤"
_RE_RANGE_CELL = re.compile(
rf"^\d+(?:\.\d+)?\s*[{RANGE_DASH_CLASS}]\s*\d+(?:\.\d+)?\s*[{_RANGE_UNITS}]+$"
)
_RE_HANGUL = re.compile(r"[가-힣]")
def is_non_resource_label(cell: str) -> bool:
"""표 머리글·소계 행이거나, 애초에 자원 이름이 올 자리가 아닌 셀인가.
맞춘 목록에 이런 것이 섞이면 목록 자체가 쓰게 된다. 여기서 먼저 걷어낸다.
"""
text = _normalize(cell)
if not text:
return True
if text.startswith(("", "<", "(", "-", "", "·")):
return True
if _RE_RANGE_CELL.match(text): # 규격 구간표의 첫 칸
return True
# 자원 이름은 숫자로 시작하지 않는다 — 「50이상」·「100m이하」·「2.집재」는 구간·절번호다.
if text[0].isdigit():
return True
# 자원 이름은 한글 두 자 이상이다. 기호(`f`·`E`)·숫자·단위만 있는 칸은 자원이 아니다.
if len(_RE_HANGUL.findall(text)) < 2:
return True
# ⚠ **정확 일치만** — 부분일치는 정상 자원을 통째로 지운다(위 주석).
if text in _NON_RESOURCE_WORDS:
return True
# 머리글 조각이 이어 붙은 칸(「단위작업별」·「위치및면적」)도 머리글이다.
return _is_header_composite(text)
def _is_header_composite(text: str) -> bool:
"""머리글 낱말만으로 이루어진 칸인가 — 「단위작업별」·「위치및면적」 같은 것.
낱말을 차례로 벗겨 아무것도 남으면 머리글로 본다. 자원 이름은 낱말을 벗기면
반드시 무언가 남는다(`건설기계운전사` `건설`·`운전사`).
"""
rest = text
for word in sorted(_NON_RESOURCE_WORDS, key=len, reverse=True):
rest = rest.replace(word, "")
rest = rest.replace("", "").replace("", "").strip()
return rest == ""
def _normalize(text: str) -> str:
"""표 셀의 공백·개행 흔들림을 지운다. 「경 암」·「연 암」 같은 것."""
return re.sub(r"\s+", "", str(text or "")).strip()
@dataclass
class ResourceRow:
"""자원 축 한 줄 — 공종(표) 하나에 붙는 자원 하나."""
@@ -442,6 +348,7 @@ def match_table(
# 사람이 모양을 읽어 둔 표(9-19-1 절토면·성토면 · 2026-09-14 판정) — 적어 둔 표만.
from B09_Estimation.B09_Estimation_ResourceAxis_JudgedTable import match_judged_table
if match_judged_table(node, table, catalog, result, basis_quantity, unit):
return
@@ -5,6 +5,8 @@
F0288 1. 절토면 고르기 자원 이름이 ** 자료 **(보통인부 () ) · 규격은 [] 에만
· · = 토질엔 자원 없음
F0289 2. 성토면 고르기 병합 (시공) 탓에 둘째 줄이 앞당겨
F0336 12-4 합판거푸집 기준수량(1) × 사용횟수별 비율 재료 줄은 재료 %, 인력 줄은 노무비 %
(원문 L6187 · 2026-09-14 브레인 비율 셈이 없어 막아 뒀던 )
일반 보정으로 넓히지 않음 밀린 고쳐 읽기는 목재틀흙막이 503만원 전례(`_Transposed` 머리말).
**적어 표만** 읽고, 칸이 판정과 다르면 줄도 세우고 막음.
@@ -47,7 +49,103 @@ JUDGED_TABLES: dict[str, dict[str, Any]] = {
"form_split": {"굴착기": ("무한궤도", "타이어")},
"why": "원문 L5430 「2. 성토면 고르기 (단위: 10㎡당)」 — 시공 칸 병합으로 둘째 줄 앞당김",
},
"F0336": {
"code": "FP-12-04",
"shape": "use_count",
"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)」",
},
}
#: 줄 첫 칸이 분류 딱지인 표(12-34-1 「자재 | 콘크리트(레미콘)」) — 이름은 다음 칸.
_ROW_CATEGORIES = ("자재", "인력", "기계")
#: 자원이 아닌 머리 줄(12-04 「횟수별 | 재료별(%) | 노무비(%)」).
_HEADER_ROWS = ("횟수별", "구분")
UNREAD_REASON = "판정표가 안 읽은 줄 — 이 일위대가에 안 넣음(자동 · 2026-09-14)"
def _loose(text: str) -> str:
"""겹침 비교용 — 빈칸·괄호·가운뎃점·쉼표를 뺌(「적사(굴착기 0.7㎥)」 ↔ 「적사 굴착기 0.7㎥」)."""
return re.sub(r"[\s()·,:]", "", str(text))
def _unread_rows(code, table_id, judged, rows, staged) -> list:
"""읽힌 줄 밖의 줄을 「못 붙은 줄」 로 — 표를 넣을 때마다 손으로 사유를 안 달아도 안 샘.
빼는 : 읽힌 (`raw_row_index`) · 이미 맞춤으로 이름 · 다른 갈래가 기계
(`needs_machine`·`same_machine`) · 자원 머리(`header_row` · 횟수별) ·
사유(`known_gap_note`) 이미 적은 이름은 올림 같은 말이 뜨게
"""
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
read = {item.raw_row_index for item in staged if isinstance(item, ResourceRow)}
taken = {_loose(item.cell) for item in staged if isinstance(item, UnmatchedRow)}
taken |= {_loose(name) for name in judged.get("needs_machine", {}).values()}
taken |= {_loose(name) for name in judged.get("same_machine", ())}
hand = _loose(known_gap_note(code))
unread: list = []
for index, cells in enumerate(rows):
cells = [c for c in cells]
if index in read or not any(cells) or (judged["shape"] == "header_row" and index == 0):
continue
name = cells[1] if cells[0] in _ROW_CATEGORIES and len(cells) > 1 else cells[0]
key = _loose(name)
if not key or key in _HEADER_ROWS or key in taken or key in hand:
continue
if key == "비고":
name = f"비고 — {' '.join(' '.join(cells[1:]).split())[:40]}"
unread.append(UnmatchedRow(code, table_id, " ".join(name.split()), UNREAD_REASON))
taken.add(key)
return unread
#: 비고 칸 인력 — 「콘크리트공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`) · 비고.
_NOT_RESOURCE_ROWS = ("사용고재평가기준", "비고", "횟수별")
#: 「보통인부 (인)」 의 단위 꼬리 — 규격이 아님.
_UNIT_TAIL = re.compile(r"\s*[(](?:인|시간)[)]\s*$")
@@ -99,14 +197,22 @@ 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):
return block(f"{staged} — 판정({judged['why']})과 칸이 달라 안 읽음")
staged = [*staged, *_unread_rows(code, table_id, judged, rows, staged)]
for item in staged:
if isinstance(item, UnmatchedRow):
result.unmatched.append(item)
@@ -176,3 +282,91 @@ def _merged_first(code, table, judged, rows, catalog, basis, unit) -> list | str
named = f"{variant} · {form}" if form else variant
staged.append(_row(code, table, entry, amount / basis, unit, index, named))
return staged
def _use_count(code, table, rows, catalog, basis, unit) -> list | str:
"""[이름, 단위, 기준수량, …] + 비율 줄 하나 → 사용횟수마다 갈래(재료 % · 노무비 %)."""
ratio_row = next((cells for cells in rows if _RE_USE_COUNT.search(" ".join(cells))), None)
if ratio_row is None:
return "사용횟수 비율 줄"
counts = _RE_USE_COUNT.findall(" ".join(ratio_row))
number = re.compile(r"\d+(?:\.\d+)?")
material = [Decimal(x) for x in number.findall(ratio_row[4] if len(ratio_row) > 4 else "")]
labor = [Decimal(x) for x in number.findall(ratio_row[5] if len(ratio_row) > 5 else "")]
if not counts or not (len(counts) == len(material) == len(labor)):
return f"비율 칸 수(횟수 {len(counts)} · 재료 {len(material)} · 노무비 {len(labor)})"
staged: list = []
for index, cells in enumerate(rows):
name = "".join(cells[0].split()) if cells else ""
base = parse_amount(cells[2]) if len(cells) > 2 else None
if not name or name in _NOT_RESOURCE_ROWS or base is None:
continue
entry = _entry(catalog, cells[0], code)
if entry is None:
reason = unmatched_reason(catalog, cells[0])
staged.append(UnmatchedRow(code, str(table.get("pum_table_id", "")), cells[0], reason))
continue
ratios = labor if entry.kind == "labor" else material
for count, ratio in zip(counts, ratios):
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 — 칸이 밀린 줄도 단위 칸으로 찾음."""
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
@@ -0,0 +1,113 @@
"""B09 원가계산 — 자원 축 **셀 거르기 · 물결표 목록 · 셀 정규화** (`_ResourceAxis` 보조).
`B09_Estimation_ResourceAxis` 700 제한에 걸려 떼어 파일이다(2026-09-14 순수 분리
내용은 옮기기만 ). 바깥 파일은 여태처럼 `B09_Estimation_ResourceAxis` 에서 가져간다.
"""
from __future__ import annotations
import re
#: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다.
#: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다.
#: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`·
#: `계장공` 이, 「작업」을 잡으면 `작업반장` 이, 「인력」을 잡으면 `인력운반공` 이
#: 통째로 사라진다(2026-09-07 실측 — 정상 자원 **70/745** 가 걸리고 있었음).
#: 그래서 **셀 전체가 그 말과 같을 때만** 머리글로 본다.
_NON_RESOURCE_WORDS = (
"구분",
"합계",
"소계",
"",
"단위",
"비고",
"규격",
"명칭",
"품명",
"종류",
"항목",
"적용",
"기준",
"산출",
"비율",
"할증",
"할인",
"직접노무비",
"재료비",
"경비",
"위치",
"면적",
"수량",
"공종",
"작업",
"내역",
"총계",
"인력",
"장비",
"기계",
)
#: 첫 칸이 자원 이름이 **아닌** 표가 많다 — 규격 구간표(「10∼12」), 기호표(「f」·「E」),
#: 치수표 등. 그런 셀을 못 맞춘 목록에 넣으면 목록이 못 쓰게 되므로 먼저 거른다.
#: ⚠ **물결표·붙임표 목록은 여기 한 벌뿐이다.** 네 파일에 따로 적어 두었더니 서로
#: 달라졌다(2026-09-08 메인 창 교차검토 — 어떤 목록엔 `~`, 어떤 목록엔 `〜` 가 빠졌음).
#: 지금 물리는 것은 없었으나 **같은 목록이 네 벌이면 언젠가 하나만 고쳐진다.**
#: 갈래 키 정규화(`B09_Estimation_UnitPrice.normalize_variant_key`)도 이 목록을 쓴다.
RANGE_DASHES = "∼~〜~-–‐"
#: ⚠ **문자클래스에 그대로 넣지 말 것** — `~-–` 이 **범위 연산자**로 읽혀 거의 모든
#: 글자가 걸린다(2026-09-08 실측: 「0.7㎥」·「15톤」이 구간으로 잡혔음). 반드시 이 쪽을 쓴다.
RANGE_DASH_CLASS = "".join(re.escape(ch) for ch in RANGE_DASHES)
#: 구간 셀에는 **단위 꼬리**가 붙기도 한다 — 「51~100m」·「1214㎝」(2026-09-08 실측 39줄).
#: ⚠ **단위가 붙었다고 다 구간이 아니다** — 「굴착기 0.7㎥」는 규격이고 자원 이름의 일부다.
#: 그래서 **수 ~ 수 + 단위**라는 모양 전체가 맞을 때만 구간으로 본다(앞에 이름이 없어야 한다).
_RANGE_UNITS = "a-zA-Z㎝㎜㎥㎡㎞mm톤"
_RE_RANGE_CELL = re.compile(
rf"^\d+(?:\.\d+)?\s*[{RANGE_DASH_CLASS}]\s*\d+(?:\.\d+)?\s*[{_RANGE_UNITS}]+$"
)
_RE_HANGUL = re.compile(r"[가-힣]")
def is_non_resource_label(cell: str) -> bool:
"""표 머리글·소계 행이거나, 애초에 자원 이름이 올 자리가 아닌 셀인가.
맞춘 목록에 이런 것이 섞이면 목록 자체가 쓰게 된다. 여기서 먼저 걷어낸다.
"""
text = _normalize(cell)
if not text:
return True
if text.startswith(("", "<", "(", "-", "", "·")):
return True
if _RE_RANGE_CELL.match(text): # 규격 구간표의 첫 칸
return True
# 자원 이름은 숫자로 시작하지 않는다 — 「50이상」·「100m이하」·「2.집재」는 구간·절번호다.
if text[0].isdigit():
return True
# 자원 이름은 한글 두 자 이상이다. 기호(`f`·`E`)·숫자·단위만 있는 칸은 자원이 아니다.
if len(_RE_HANGUL.findall(text)) < 2:
return True
# ⚠ **정확 일치만** — 부분일치는 정상 자원을 통째로 지운다(위 주석).
if text in _NON_RESOURCE_WORDS:
return True
# 머리글 조각이 이어 붙은 칸(「단위작업별」·「위치및면적」)도 머리글이다.
return _is_header_composite(text)
def _is_header_composite(text: str) -> bool:
"""머리글 낱말만으로 이루어진 칸인가 — 「단위작업별」·「위치및면적」 같은 것.
낱말을 차례로 벗겨 아무것도 남으면 머리글로 본다. 자원 이름은 낱말을 벗기면
반드시 무언가 남는다(`건설기계운전사` `건설`·`운전사`).
"""
rest = text
for word in sorted(_NON_RESOURCE_WORDS, key=len, reverse=True):
rest = rest.replace(word, "")
rest = rest.replace("", "").replace("", "").strip()
return rest == ""
def _normalize(text: str) -> str:
"""표 셀의 공백·개행 흔들림을 지운다. 「경 암」·「연 암」 같은 것."""
return re.sub(r"\s+", "", str(text or "")).strip()
+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
@@ -87,8 +87,8 @@ def cost_from_bill(root: str, bill: dict[str, Any], direct: dict[str, Decimal]):
async def get_cost_sheet(project_id: UUID, form: str = "general") -> JSONResponse:
"""원가계산서 한 장 — 서식 줄 · 기준 입력 선택지 · 상태줄.
`form` 일반형식만 금액을 . 수공·실적일반·실적수공은 **서식 차례만**(요율표 미확보 줄은
막고 사유 · 0 원으로 채움).
`form` 일반형식만 금액을 . 수공·실적일반·실적수공은 **서식 차례만**(실적형 임도 미적용 ·
수공 세부 경비 법정 요율 없음 줄은 막고 사유 · 0 원으로 채움).
"""
from B09_Estimation.B09_Estimation_Router import get_bill
from common_util.common_util_project_settings import estimation_settings
@@ -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
+23 -3
View File
@@ -194,13 +194,27 @@ def safety_management_cost(
대상액이 **다른 구간에 떨어질 있어** A 항상 작지는 않다. 줄을 남겨
화면이 나란히 보이게 한다(실무 `안전관리비검토` 시트와 같은 서식).
"""
from B09_Estimation.B09_Estimation_Engine_Cost import floor_won
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
@@ -248,7 +262,15 @@ def safety_management_cost(
)
percent = rate_percent(row, label=label)
flat = base_amount(row)
return (base * percent / _HUNDRED + flat) * multiplier, percent, flat
# ⭐ 2026-09-14 고침 — **호별 산정액을 먼저 원 단위로 맺고** 1.2 를 곱한다.
# 고시 제4조① 단서 「… 대상액에서 제외하고 **산출한 산업안전보건관리비**의 1.2배」 —
# 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(관급 포함)")
raw_b, percent_b, flat_b = evaluate(
@@ -257,8 +279,6 @@ def safety_management_cost(
# 어느 쪽이 채택인지 먼저 정해 두 줄에 표시를 단다 — 화면이 나란히 보이고
# 채택 줄이 눈에 띄어야 한다(PLAN 8-12 실무 `안전관리비검토` 시트 서식).
from B09_Estimation.B09_Estimation_Engine_Cost import floor_won
adopted = "A" if floor_won(raw_a) <= floor_won(raw_b) else "B"
amount_a = emit(
@@ -0,0 +1,112 @@
"""B09 원가계산 — **표토제거 답외구간** 9-15-2 (2026-09-14 브레인 ㉰ 첫째).
B08 준비공 표토제거 줄이 부르는 코드인데 표가 계수표(T·L·E·q0·e·f·V1·V2·t) 일위대가가 섰음.
q = q0 × e · = L/V1 + L/V2 + t · Q1 = 60 × q × f × E / (/hr) · Q = Q1 / T (/hr)
[] 무한궤도 불도저(19ton) · 건설품셈 8-2-1 불도저 참조 불도저 (`dozer_hourly_output`) 그대로 + T 나눔
기종은 표의 q0·V1·V2(1) 8-2-1 표에서 되짚음(`resolve_dozer`) [] 19ton 맞는지 시험이
실무 영월 호표 Q=576.95 E 자리에 e(0.96) 넣은 원문이 또렷해 원문 E (까닭은 `KnownGaps`).
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
CODE = "FP-09-15-02"
TABLE = "F0282"
_RE_SYMBOL = re.compile(r"^([A-Za-z]\d?)\s*\(")
_RE_GEAR = re.compile(r"(\d+)\s*단")
def _factors(node: dict[str, Any]):
"""(불도저 계수, T) — 칸이 모자라거나 기종이 안 좁혀지면 까닭 글."""
from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import DozerFactors, resolve_dozer
table = next((t for t in node.get("tables") or [] if t.get("pum_table_id") == TABLE), {})
values: dict[str, Decimal] = {}
gear = 1
for row in table.get("raw_row") or []:
found = _RE_SYMBOL.match(str(row[0]).strip()) if row else None
value = parse_measure(str(row[1])) if found and len(row) > 1 else None
if value is not None:
values[found.group(1)] = value
shift = _RE_GEAR.search(str(row[1]))
gear = int(shift.group(1)) if shift else gear
missing = [k for k in ("T", "L", "E", "q0", "e", "f", "V1", "V2") if k not in values]
if missing:
return f"9-15-2 표 칸 없음: {', '.join(missing)}"
machine = resolve_dozer(values["q0"], values["V1"], values["V2"], gear)
if machine is None:
return f"삽날 {values['q0']}㎥ · {values['V1']}/{values['V2']}m/분({gear}단) 으로 불도저가 안 좁혀짐"
factors = DozerFactors(
work_item_code=CODE,
blade_capacity_m3=values["q0"],
distance_factor=values["e"],
volume_factor=values["f"],
efficiency=values["E"],
haul_distance_m=values["L"],
forward_speed_m_min=values["V1"],
reverse_speed_m_min=values["V2"],
machine_code=machine[0],
machine_name=machine[1],
)
return factors, values["T"]
def topsoil_output(node: dict[str, Any] | None = None) -> tuple[Decimal, Decimal]:
"""(Q1 ㎥/hr, Q ㎡/hr) — 둘 다 소수 2자리로 확정한 뒤 씀(명세 7장)."""
from B09_Estimation.B09_Estimation_MachineProductivity import fix2
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import dozer_hourly_output
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
if node is None:
node = next(n for n in load_work_item_master()["work_items"] if n["work_item_code"] == CODE)
found = _factors(node)
if isinstance(found, str):
raise ValueError(found)
factors, thickness = found
q1 = dozer_hourly_output(factors)
return q1, fix2(q1 / thickness)
def attach_topsoil_removal(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None:
"""`B-FP-09-15-02` — 불도저 1/Q hr/㎡ 한 줄(D). 기계 층이 없거나 표가 달라지면 까닭만."""
from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle
node = nodes_by_code.get(CODE)
title_code = f"B-{CODE}"
if node is None or title_code in build.book.titles:
return
found = _factors(node)
if isinstance(found, str):
build.component_gaps[CODE] = found
return
factors, thickness = found
hourly = f"X-{factors.machine_code}"
if hourly not in build.book.titles:
build.component_gaps[CODE] = f"{factors.machine_name} 시간당 사용료가 안 섬"
return
q1, q = topsoil_output(node)
build.book.add_title(
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name=str(node.get("name") or CODE),
spec="표토제거",
unit="",
)
)
build.book.add_output_detail(
title_code,
hourly,
Decimal(1) / q,
f"{factors.formula_text} → Q = Q1 {q1} ÷ T {thickness}m = {q} ㎡/hr"
" (산림품셈 9-15-2 [주]②③ · 건설 8-2-1)",
output=q,
)
if CODE in build.skipped:
build.skipped.remove(CODE)
+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}`));
@@ -73,6 +73,12 @@ const FORMS = [
];
/** DFM 본문 아래 탭 이름 그대로. */
const FORM_TABS = ["general", "sugong", "actual_general", "actual_sugong"];
/** 탭 말풍선 — 금액이 안 서는 까닭(2026-09-14 요율표 셋 결론 · 줄 사유는 서버 문구). */
const FORM_TAB_NOTES: Record<string, string> = {
sugong: "서식 차례만 — 세부 경비 법정 요율 없음(값 입력 대기 · 예정가격작성기준 §34①)",
actual_general: "서식 차례만 — 임도 미적용(100억 미만 · 예정가격작성기준 §37②)",
actual_sugong: "서식 차례만 — 임도 미적용(100억 미만 · 예정가격작성기준 §37②)",
};
/** 프로젝트별 고른 형식 탭 — 일반형식만 금액이 섬. */
const formOf = new Map<string, string>();
@@ -320,7 +326,7 @@ function drawBody(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void):
label.append(el("span", "", row.label));
if (row.formula) label.append(el("span", "b09cs__formula", `<${row.formula}>`));
label.title = [row.formula, row.note].filter(Boolean).join(" · ");
// 금액 없는 줄 — 「-」 + 막힌 까닭(요율표 미확보 · 확인 대기). 0 원으로 안 보임.
// 금액 없는 줄 — 「-」 + 막힌 까닭(임도 미적용 · 법정 요율 없음 · 확인 대기). 0 원으로 안 보임.
if (row.amount_krw === null && row.blocked) label.append(el("span", "b09cs__warn", row.note));
line.append(
label,
@@ -334,7 +340,7 @@ function drawBody(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void):
for (const key of FORM_TABS) {
const tab = el("button", "b09cs__tab", sheet.form_labels[key] ?? key);
tab.setAttribute("aria-selected", String(key === sheet.form_key));
if (key !== "general") tab.title = "서식 차례만 — 요율표 미확보로 금액 안 섬";
if (FORM_TAB_NOTES[key]) tab.title = FORM_TAB_NOTES[key];
tab.addEventListener("click", () => {
formOf.set(ctx.projectId as string, key);
reload();
+45 -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,23 @@ 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)
# 표토제거 답외구간(9-15-2) — 계수표를 불도저 식 + T 로(2026-09-14 ㉰).
from B09_Estimation.B09_Estimation_TopsoilRemoval import attach_topsoil_removal
attach_topsoil_removal(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,94 @@
"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·소림",
"to": "굴착기(무한궤도) 0.2 · 소",
"scope": "FP-09-21",
"pum_edition": "2026-01-01",
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
},
{
"axis": "variant",
"from": "0.2·중림",
"to": "굴착기(무한궤도) 0.2 · 중",
"scope": "FP-09-21",
"pum_edition": "2026-01-01",
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
},
{
"axis": "variant",
"from": "0.2·밀림",
"to": "굴착기(무한궤도) 0.2 · 밀",
"scope": "FP-09-21",
"pum_edition": "2026-01-01",
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
},
{
"axis": "variant",
"from": "0.7·소림",
"to": "굴착기(무한궤도) 0.7 · 소",
"scope": "FP-09-21",
"pum_edition": "2026-01-01",
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
},
{
"axis": "variant",
"from": "0.7·중림",
"to": "굴착기(무한궤도) 0.7 · 중",
"scope": "FP-09-21",
"pum_edition": "2026-01-01",
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
},
{
"axis": "variant",
"from": "0.7·밀림",
"to": "굴착기(무한궤도) 0.7 · 밀",
"scope": "FP-09-21",
"pum_edition": "2026-01-01",
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
},
{
"axis": "variant",
"from": "리핑암",
@@ -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": "되메우기",
@@ -173,6 +177,9 @@
"basis_source": "⚠ 교차 참조 — 산림품셈 9-21 표에 밑수 표기가 없어 건설공사 표준품셈 3-9-2 뿌리뽑기 「1,000㎡당」 면적 축을 빌려 씀(사용자 확정 5차 6번). 마스터 밑수는 비어 있음(basis_missing F0294).",
"variant_axis": "stand_volume_class",
"variant_from": "stand_volume_class",
"variant_template": "{root_removal_excavator_m3}·{stand_volume_class}",
"variant_missing_reason": "제근 굴착기 크기·임목축적 등급이 아직 다 입력되지 않았습니다 — 산출 조건에서 고르면 단가가 섭니다(품셈 9-21: 굴착기 0.2·0.7 × 소림·중림·밀림 · 크기 제안 0.7㎥ 산림품셈 10-12-1 [주]①)",
"variant_note": "2026-09-14 브레인 판정 — 크기·등급 두 칸을 템플릿으로 한 값(「0.7·소림」)으로 엮고 B09 갈래 표기는 범위 별칭(FP-09-21)이 앎. 둘 중 하나라도 비면 등급만 싣고 입력 사유.",
"note": "2026-09-13 브레인 판정 Ⓑ — 제근은 토공 줄(토공_수량.md:30 「뿌리다듬기·적재·제근 | 9-20~21 | 벌개제근 연동」)이라 여기서 셈. 준비공 「제근·뿌리다듬기」는 같은 면적이라 참조로만 보임(이중계상 막이). 품은 임목축적 등급(소림·중림·밀림, 9-21 [주]①)으로 갈려 등급을 갈래로 넘김."
}
],
@@ -324,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": [
@@ -343,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-14T16:09:37+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": "a942f7ad412441ba0c7ad3f8149fd143b10502dae5d20053e5d98029ab31c6b4",
"size_bytes": 838243
"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-14T16:09:37+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": [
@@ -31615,7 +31615,14 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"1회",
"2회",
"3회",
"4회",
"5회",
"6회"
],
"condition_note": [
"구 분",
"단위",
@@ -31717,7 +31724,14 @@
]
}
],
"variant_keys": []
"variant_keys": [
"1회",
"2회",
"3회",
"4회",
"5회",
"6회"
]
},
{
"work_item_code": "FP-12-05",
@@ -32874,7 +32888,9 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"콘크리트"
],
"condition_note": [
"구 분",
"규 격",
@@ -32956,7 +32972,9 @@
]
}
],
"variant_keys": []
"variant_keys": [
"콘크리트"
]
},
{
"work_item_code": "FP-12-13",
@@ -32983,7 +33001,9 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"콘크리트"
],
"condition_note": [
"구 분",
"규 격",
@@ -33051,7 +33071,9 @@
]
}
],
"variant_keys": []
"variant_keys": [
"콘크리트"
]
},
{
"work_item_code": "FP-12-14",
@@ -33134,7 +33156,10 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"구체콘크리트",
"버림콘크리트"
],
"condition_note": [
"구 분",
"규 격",
@@ -33195,7 +33220,10 @@
]
}
],
"variant_keys": []
"variant_keys": [
"구체콘크리트",
"버림콘크리트"
]
},
{
"work_item_code": "FP-12-16",
@@ -33222,7 +33250,10 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"구체콘크리트",
"버림콘크리트"
],
"condition_note": [
"구 분",
"단위",
@@ -33329,7 +33360,10 @@
]
}
],
"variant_keys": []
"variant_keys": [
"구체콘크리트",
"버림콘크리트"
]
},
{
"work_item_code": "FP-12-17",
@@ -35238,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": "본문",
@@ -35659,9 +35693,9 @@
],
"steps_basis": "품셈 12-38 유로폼 = 12-38-2 사용수량(자재) + 12-38-3 설치 및 해체(품) — 12-38-1 사용횟수는 금액 단계가 아니라 사용수량의 잔존율 조건",
"variant_keys": [
"복 잡",
"간 단",
"보 통",
"간 단"
"복 잡"
]
},
{
@@ -35785,7 +35819,11 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"간 단",
"보 통",
"복 잡"
],
"condition_note": [
"구 분",
"간 단",
@@ -35802,7 +35840,11 @@
]
}
],
"variant_keys": []
"variant_keys": [
"간 단",
"보 통",
"복 잡"
]
},
{
"work_item_code": "FP-12-38-03",
@@ -0,0 +1,155 @@
"""성토사면 길이 5m 초과 경고 — 벽 선 쪽만 빼고(좌·우 갈라) 경고만 (2026-09-14 브레인 (나)).
5m **넘는** 쪽만 5.00 아님 · 원지반을 만난 하한값() 5m 이하면 아님
벽이 쪽은 뺀다 배관 유입(상단측)·유출(반대측) 기슭막이, 집수정은 아님
한쪽에만 반대쪽은 그대로 경고 · 독립 기슭막이 설치 (//양쪽)
세월교·BOX암거는 양쪽 측벽
문구는 브레인 승인 그대로
TS 실제로 돌린다(파이썬 짝이 없는 화면 판정).
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
SOURCE = PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Cross_FillSlope_Warn.ts"
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { FILL_SLOPE_WARN_TEXT, fillSlopeWarnings } from "./B06_Section_UI_Cross_FillSlope_Warn.js";
const [inputPath, outputPath] = process.argv.slice(2);
const cases = JSON.parse(readFileSync(inputPath, "utf8"));
const warnings = fillSlopeWarnings(cases.map((c) => c.section), (section) =>
cases.find((c) => c.section.station_id === section.station_id).lengths,
);
writeFileSync(outputPath, JSON.stringify({
text: FILL_SLOPE_WARN_TEXT,
warnings: warnings.map((w) => [w.section.station_id, w.sides.map((s) => s.side)]),
}));
"""
LONG = {"lengthM": 7.0, "open": False}
BOTH = {"left": LONG, "right": LONG}
def _culvert(inlet: str = "기슭막이", outlet: str = "기슭막이", **extra: object) -> dict:
return {"inlet": {"structure": inlet}, "outlet": {"structure": outlet}, **extra}
CASES = [
{"section": {"station_id": "plain"}, "lengths": BOTH},
{
"section": {"station_id": "edge"},
"lengths": {
"left": {"lengthM": 5.0, "open": False},
"right": {"lengthM": 4.2, "open": True},
},
},
# 상단측 좌 → 유입(좌) 기슭막이 · 유출(우) 기슭막이 — 양쪽 다 벽.
{
"section": {"station_id": "pipe", "uphill_side": "left", "culvert": _culvert()},
"lengths": BOTH,
},
# 유입이 집수정 → 좌(유입측)는 벽 없음 → 좌만 경고.
{
"section": {"station_id": "basin", "uphill_side": "left", "culvert": _culvert("집수정")},
"lengths": BOTH,
},
# 상단측 우 → 유출은 좌 · 유출이 집수정이면 좌만 경고.
{
"section": {
"station_id": "right_up",
"uphill_side": "right",
"culvert": _culvert(outlet="집수정"),
},
"lengths": BOTH,
},
{
"section": {"station_id": "own_left", "culvert": _culvert(hidden_pipe=True, side="")},
"lengths": BOTH,
},
{
"section": {
"station_id": "revet_auto",
"revetment": {"side": None},
"design": {"section_mode": "left_cut"},
},
"lengths": {"left": None, "right": LONG},
},
{"section": {"station_id": "ford", "ford": {}}, "lengths": BOTH},
]
def _run(tmp_path: Path) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(SOURCE),
# 실행에 드는 import 는 이것 하나 — 나머지는 타입 import 라 지워진다.
str(SOURCE.with_name("B06_Section_UI_Cross_Culvert_Const.ts")),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
# 타입 줄기가 별칭(@util 등)으로 번져 단독 컴파일로는 못 푼다 — 검사는 typecheck 몫.
"--noCheck",
"--noResolve",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
for emitted in out.glob("*.js"):
text = emitted.read_text(encoding="utf-8")
emitted.write_text(
re.sub(r'(from "\./[^"]+?)(")', lambda m: m.group(1) + ".js" + m.group(2), text),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
payload, result = tmp_path / "input.json", tmp_path / "output.json"
payload.write_text(json.dumps(CASES, ensure_ascii=False), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(payload), str(result)],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_벽_선_쪽만_빼고_5m_넘는_쪽을_경고한다(tmp_path: Path) -> None:
got = dict(_run(tmp_path)["warnings"])
assert got == {
"plain": ["left", "right"],
"basin": ["left"],
"right_up": ["left"],
"own_left": ["right"],
}
# 5.00 · 하한값 4.2(≥) · 양쪽 벽 · 자동 설치 측 벽 · 세월교는 경고 없음.
assert not {"edge", "pipe", "revet_auto", "ford"} & set(got)
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_문구는_승인된_그대로(tmp_path: Path) -> None:
assert _run(tmp_path)["text"] == (
"성토사면 길이 5m 초과 — 법령상 옹벽·석축 설치 대상 "
"(산림자원법 시행규칙 별표2 Ⅰ.2.차.(3).(나) · 임도설치 규정 별표7 2.차.(3).(나)) "
"※ 실무 표본에서도 흔함(영월 63% · 봉화 49%) — 설치 여부는 설계자 판단"
)
@@ -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,104 @@
"""B07 횡단도 [확정]은 설계 **상태만** 올린다 — 정본 설계값을 덮지 않는다 (2026-09-14 브레인 ②).
실측(936be972 · 읽기만): [확정] 입력 (지반·단면·측구 )만으로 단면적을 다시 계산해
설계를 통째로 덮었다 62측점 전부 단면적이 바뀌고(절토 20.8% · 성토 +5.8%) 암선·절토경사·
표준 횡단·구조물 트림이 빠졌으며, 종점 1078.01 **사용자가 측구가 켜졌다**.
B07 CAD 에는 설계를 고치는 자리가 없으므로 덮을 값이 없다 반만 계산할 거면 반만 덮는다.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from types import SimpleNamespace
from uuid import UUID
import B07_DesignDetail.B07_DesignDetail_Router as router
from B07_DesignDetail.B07_DesignDetail_Schema import DesignDrawingConfirmRequest
STORED = {
"status": "provisional",
"ground_type": "ripping_rock",
"section_mode": "left_cut",
"ditch_side": "left",
"ditch_enabled": False,
"rock_boundary_offset_m": 0.8,
"cut_slope_ratio": 0.5,
"cut_area_m2": 48.894,
"fill_area_m2": 54.84,
}
class _Cursor:
async def __aenter__(self) -> _Cursor:
return self
async def __aexit__(self, *_: object) -> bool:
return False
class _Connection:
async def begin(self) -> None: ...
async def commit(self) -> None: ...
async def rollback(self) -> None: ...
def cursor(self) -> _Cursor:
return _Cursor()
class _Acquire:
async def __aenter__(self) -> _Connection:
return _Connection()
async def __aexit__(self, *_: object) -> bool:
return False
def test_확정은_상태만_올리고_설계값을_안_덮는다(monkeypatch, tmp_path: Path) -> None:
patches: list[dict] = []
async def source(_project_id: UUID) -> tuple[int, Path, Path, bool]:
return 182, tmp_path, tmp_path / "longitudinal.json", False
async def designs(_route_id: int) -> dict[int, dict]:
return {700: dict(STORED)}
async def merge(_connection: object, **kwargs: object) -> bool:
patches.append(dict(kwargs["patch"])) # type: ignore[arg-type]
return True
async def stage(*_: object) -> None: ...
monkeypatch.setattr(router, "_confirmed_source", source)
monkeypatch.setattr(router, "_designs_by_chainage", designs)
monkeypatch.setattr(
router, "_drawing_list", lambda *_: [SimpleNamespace(id="cross_00700m", kind="cross")]
)
monkeypatch.setattr(router, "extract_quantity_table", lambda *_: None)
monkeypatch.setattr(router, "_store_confirmed_drawing", lambda *_: False)
monkeypatch.setattr(router, "get_db_pool", lambda: SimpleNamespace(acquire=_Acquire))
monkeypatch.setattr(router, "merge_cross_section_design_by_round", merge)
monkeypatch.setattr(router, "start_stage", stage)
monkeypatch.setattr(router, "complete_stage", stage)
# 옛 길 — 입력 셋으로 다시 계산한 값(암 절토경사가 빠져 절토가 줄어든 모양)을 흉내 낸다.
monkeypatch.setattr(
router,
"_recompute_confirmed_design",
lambda *_: {**STORED, "ditch_enabled": True, "cut_area_m2": 7.527, "status": "confirmed"},
raising=False,
)
response = asyncio.run(
router.confirm_design_drawing(
UUID("936be972-11bc-46c2-8bf3-b15d8de7df0d"),
"cross_00700m",
DesignDrawingConfirmRequest(drawing={}),
)
)
# 덮는 것은 상태 하나뿐 — 단면적·사용자 입력(측구 끔)은 그대로.
assert patches == [{"status": "confirmed"}]
# 화면 정보 패널이 받는 설계도 저장값 그대로(상태만 확정).
assert response.design == {**STORED, "status": "confirmed"}
@@ -0,0 +1,66 @@
"""관 벽 · 돌쌓기 줄 뒷길이 — 인계가 **B08 이 실제로 쓴 뒷길이 + 출처**를 싣는다(2026-09-14 브레인).
종전: 인계는 저장 원본 `back_len_cm` 실어, 고른 ( 유입·유출부 기슭막이는 )
`variant_value=None` B09 13-04-02/05 뒷길이 갈래(#35·55·75cm이하)를 못 고름. 그런데 수량표·
구조물도는 이미 품셈 13-4-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 as build_unit # noqa: E402
def _row(type_id: str, options: dict) -> dict:
unit = build_unit(
[
{
"structure_id": "x",
"type_id": type_id,
"start_m": 0.0,
"end_m": 10.0,
"options": options,
}
],
{type_id: type_id},
)
handoff = build_handoff(unit_quantity_table=unit)
return next(r for r in handoff["work_items"] if r["variant_axis"] == "back_len_cm")
def test_안_고른_뒷길이는_표준표_하한을_출처와_함께_보냄() -> None:
row = _row("masonry_wet", {"height_m": 1.5, "length_m": 10.0})
assert row["variant_value"] == "25", row # 찰쌓기 ≤1.5m 하한 25㎝
assert "뒷길이 25㎝" in row["spec_class_basis"] and "[주]⑩" in row["spec_class_basis"]
def test_고른_뒷길이는_원본값_그대로_출처는_저장_제원() -> None:
row = _row("masonry_wet", {"height_m": 1.5, "length_m": 10.0, "back_len_cm": "45"})
assert row["variant_value"] == "45"
assert "[주]⑩" not in row["spec_class_basis"] and "저장 제원" in row["spec_class_basis"]
def test_기슭막이_메쌓기도_하한으로_갈래가_서서_내역_금액이_섬() -> None:
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
options = {"height_m": 2.5, "length_m": 10.0, "form": "돌쌓기(메)"}
row = _row("revetment", options)
assert row["variant_value"] == "45", row # 메쌓기 ~3m 하한 36 → 규격 45
assert row["work_item_code"] == "FP-13-04-02" and not row["blocked_kind"], row
unit = build_unit(
[{"structure_id": "x", "type_id": "revetment", "start_m": 0.0, "end_m": 10.0,
"options": options}],
{"revetment": "revetment"},
) # fmt: skip
bill = build_bill(build_handoff(unit_quantity_table=unit))
line = next(r for r in bill.rows if r.code == "FP-13-04-02")
assert line.amount_krw and int(line.amount_krw) > 0, line.note
assert line.price_code.endswith("#55cm이하"), line.price_code # 45㎝ 를 담는 가장 좁은 구간

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