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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 19:22:08 +09:00
eomsangdonandClaude Opus 5 b23db36c78 feat(b08): 옹벽 조립 조각 합판거푸집이 사용횟수 갈래로(「FP-12-04#3회」 · 성분의 reuse_count 그대로) · 집수정 거푸집 6회 → 4회(12-15 표 「합판4회」 정본 · 사유 문구도 그 표) — 화면 자재 표·인계 성분 한 벌(브레인 ㉯ ①②)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 19:05:55 +09:00
eomsangdon 3ca1a45b2b Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 18:37:03 +09:00
eomsangdonandClaude Opus 5 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
eomsangdon fcb21eb945 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-14 16:45:03 +09:00
eomsangdon 9d8c3a6068 auto: 2026-09-14 16:44 (EOMSANGDON-HOME) 2026-09-14 16:44:53 +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
eomsangdon 33ff0c50f0 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 16:36:39 +09:00
eomsangdonandClaude Opus 5 3886f3bfc1 feat(b08): 고정형 항목도 일위대가 구성을 가짐 — 공종 코드는 비워 두고 사용자가 고르개로 고름(㉮) · 고르기 전엔 내역 줄이 서되 「미완 — 공종 코드 미정」 금액 밖 · 찾기는 코드 없는 줄 이름으로 후보만 띄움
936be972 실측: 봉화 제6호표 가져오기 → 내역 「기슭막이 H=2.0m 10m」 2줄이 「막힌 줄 9: 공종 코드 미정」으로 섬(전엔 줄이 사라짐) → 코드 3줄 고르고 6줄 수동 단가(검증용) → 일위대가 482,766원/m · 내역 4,827,660원 × 2줄 「수동 단가 6건 미확정」 · 되돌려 본체·인계·내역·원단위·장 전부 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 16:24:33 +09:00
eomsangdon 5c2ae377e0 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 16:20:52 +09:00
eomsangdonandClaude Opus 5 976e5fd992 feat(b09,b08): 9-19-1 성토면 기계 = 굴착기 0.6㎥ 형식 두 갈래(무한궤도 947원/㎡ · 타이어 1,014원/㎡) · 제안 무한궤도(영월 실무 「성토사면고르기 06M3 B/H」 · 브레인 ②) — 칸 곁 제안만, 스스로 안 고름 · 마스터 갈래 키 재생성(바뀐 공종 9-19-1 하나)
일위대가 나머지 460 제목 변화 0 · 9-19-1 못 붙은 줄(굴착기 0.6㎥) 걷힘

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 16:20:51 +09:00
eomsangdon 2745a41963 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 16:12:20 +09:00
eomsangdonandClaude Opus 5 91317295f5 feat(b08): 라이브러리 칸 [STmate 엑셀 읽기] → 호표 고르기 → [내 라이브러리에 넣기] — 읽은 호표 수·구성 줄 수를 보이고 넣기 전에 다시 물음(덮어씀·원문 시점 수량·단가 안 가져옴) · 넣기는 파일을 다시 보냄
배정 프로젝트 화면: 돌쌓기(찰) 장에서 봉화 엑셀 읽기 → 「호표 45개 · 구성 줄 209줄」 → 제6호표 기슭막이(깬잡석,찰쌓기) H=2.0m 구성 9줄 넣기 → 목록 「개인 · [고정형] …」 · [내 것 지우기]로 되돌림(목록 기본 한 줄)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 16:01:50 +09:00
eomsangdonandClaude Opus 5 38a5e785f2 feat(b08): STmate 엑셀 뽑기 창구 두 걸음 — [읽기]는 호표 수·구성 줄 수·못 읽은 사유만(안 씀) · [넣기]는 서버가 파일을 다시 읽어 고른 호표를 개인 단에 고정형으로(회사 없음 403 · 못 읽은 호표 404 · 모르는 종류 400) · main.py 에 창구 한 줄
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 15:59:11 +09:00
eomsangdonandClaude Opus 5 d6fd3c1796 feat(b08): STmate 출력 엑셀 「일위대가표」 읽개 + 고정형 항목 만들기 — 실무 6건 호표 170 전부 문제 0(코덱스 감사와 같음) · 봉화 45 · 기슭막이 H2.0 구성 9줄
머리 칸이 다르거나 수량이 수가 아니면 칸 주소를 짚은 사유로 그 호표를 통째로 뺌(추측 안 함) · 갈 곳은 구성 = unit_price · 별산자재 = material · % 가산 행 = reference + 항목 사유 · 계약단가·낙찰율 줄 안 뽑고 항목 사유 · 품셈 근거 줄은 formula_text 보존 · 원문 호표·자원 코드와 공사명은 출처로만(판정 Ⓑ~Ⓗ · ㉮㉯㉰)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 15:56:49 +09:00
eomsangdon 6f484eb6fa Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 15:55:45 +09:00
eomsangdonandClaude Opus 5 2e678ad511 fix(b09): 운전경비 표 규격 칸 괄호 딸림값을 규격 개수에 안 셈 — 공기압축기(5205) 여섯 등 63기종 운전경비가 섬(158→221 · 기존 158 값 변화 0) · 9-19-1 흙 갈래까지 막던 「일부만」(공기압축기 3.5) 풀림
일위대가 금액 바뀐 제목 4(9-5-1 · 9-19-1 연암·보통암ㆍ경암 · 9-19-3) · 나머지 456 변화 0 · 기계 층 미완성 6→4
배정 프로젝트: 면고르기 칸 비면 합계 그대로 · 칸 고르면 성토면 인력 점토 3,269원/㎡ · 절토면 호박돌 4,353원/㎡ 금액 섬(되돌림)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 15:55:44 +09:00
eomsangdonandClaude Opus 5 6c61f5d5ac feat(b08): 산출 조건 면고르기 칸 넷 — 절토면 토질 · 성토면 시공·토질 고르기(서버 선택지 그대로 · 제안값 없음) + 면적 덮어쓰기 둘(비우면 파종 면적) · 메인 ee9368ec
배정 프로젝트: 절토면 토질을 고르고 [저장] → 내역 3-8-2 사유 「입력이 필요합니다」 → 「단가가 일부만 — 공기압축기(이동식) 3.5」 · 「안 정함」으로 되돌려 저장(네 칸 빈 값)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 15:39:15 +09:00
eomsangdon 391301abf4 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 15:29:31 +09:00
eomsangdon 7b5f8bc5e2 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 15:25:04 +09:00
eomsangdonandClaude Opus 5 ee9368ec66 feat(b08,b09): 면고르기 줄 — 밑수 = 초류종자살포 면적(덮어쓰기 칸 · 파종 0 이면 0 + 사유) · 절토면은 사면 조각 토사/암으로 갈라 암은 시공법(9-19-2·3) · 절토면 토질 · 성토면 시공·토질 칸(제안값 없음 · 비면 입력 사유) · 성토면 기계는 규격 미정 사유(브레인 판정 Ⓐ~Ⓓ)
실무 대조: 오솔길 BOM 6벌 면적 셈법 ±0.11% · 거창 줄 없음 · 영월 성토면 × 50% · 원문 9-19-1 성토면·9-19-2·3 식재 조건부
배정 프로젝트: 인계 줄 +3(성토면 12,350㎡ · 절토면 토사 3,085.51 · 암 2,262.62) 외 변화 0 · 본체 합계 105,512,060 그대로 · 규격 글로 시작하는 갈래는 규격에 한 번만(도자 운반 「토사 토사」 → 「토사」 · 금액 불변)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 15:25:03 +09:00
eomsangdonandClaude Opus 5 b08b79bc6c feat(b08): 라이브러리 가져오기 목록에 항목 종류 배지 [양식형]·[고정형] — 명세 13장대로 식이 한 줄이라도 있나로만 가름(저장된 item_kind 표시는 안 믿음)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 15:16:55 +09:00
eomsangdonandClaude Opus 5 c7b9956bf3 feat(b06,b08): 관 기준벽도 벽 몸 ∩ 성토 폐회로 겹침을 줄 사유로 — 다단에만 뜨면 「다단만 문제」로 읽힘(㉡)
서버 Node 가 기준벽 역할별 넓이를 design.wall_fill_overlap 에 남기고 B08 이 다단 목록 통(base 표시 칸)으로 읽어 기준벽 줄 비고에 붙임 · 호출부(Router_Material) 안 늘림 · 936be972 빈 저장: 관 9 곳 유출 벽 258.12 1.417 · 720 1.403 · 804.18 1.166 · 982.64 0.807 · 620 0.136 · 440 0.046 · 533.98 0.004 · 85.05/900 0㎡ · 이 프로젝트 기준벽은 모두 미확정(금액 밖)이라 비고 안 뜸 · 벽 칸을 적으면 「1.42㎡ × 10m ≈ 14.2㎥」 · 금액 변화 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 15:02:43 +09:00
eomsangdonandClaude Opus 5 eb79091e00 feat(b06,b08): 다단 벽 몸이 성토 폐회로에도 든 겹침을 줄 사유로 드러냄 — 고치지 않음(2026-09-06 확정 「구조물 면적 안 뺌」 · 판정은 사용자)
서버 Node 가 벽 몸 ∩ 성토 폐회로 넓이를 재 design.extra_walls[].fill_overlap_m2 로 남기고 B08 다단 줄 비고에 「벽 몸 N㎡ × 연장 ≈ ㎥ 두 번 셈」 · 258.12 1단 1.335㎡ × 10m ≈ 13.3㎥ · 금액 변화 0 · 미확정 단은 안 붙임

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 14:47:38 +09:00
eomsangdon df547a90f8 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 14:39:58 +09:00
eomsangdonandClaude Opus 5 f4f9b77bdf chore(master): 9-19-1 갈래 키 8 — 표 읽기가 가른 절토면 6 · 성토면 인력 2 를 마스터에 실음(빌더 재생성 · 다른 공종 변화 0)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 14:37:47 +09:00
eomsangdonandClaude Opus 5 e9b8eacc90 test(b06,b08): 횡단도 벽 폭 ↔ 수량 벽 두께 대조 시험 — 지금 빨강(H2.0 메 · 윗폭 0.675/밑폭 1.375 ↔ 상부 0.75/하부 1.05) · 맞출 쪽 판정 전이라 xfail(strict)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 14:28:07 +09:00
eomsangdon ce8339f34a Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-14 14:19:10 +09:00
eomsangdon 52c7148927 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 14:19:09 +09:00
eomsangdonandClaude Opus 5 ae52a92753 feat(b09): 9-19-1 토사면 고르기 두 표를 갈래로 읽음 — 절토면 토질 6 · 성토면 인력 2(기계 굴착기 0.6㎥ 는 무한궤도·타이어 둘이라 규격 미정) · [주]① 규격은 범위 별칭 · 옆 칸이 규격을 적으면 별칭이 안 덮음 · 성토면 밀린 줄은 사람 판정표로만 · [주]②③ 는 비고(브레인 판정 Ⓐ~Ⓔ)
섞인 본표 B-FP-09-19-01(보통인부 한 줄 2,925원) 걷힘 · 일위대가 나머지 452 제목 금액 변화 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 14:19:08 +09:00
eomsangdon d8e6e52f75 auto: 2026-09-14 14:18 (ESD_LAPTOP) 2026-09-14 14:18:01 +09:00
eomsangdonandClaude Opus 5 8dfa68df32 fix(b06,b08): 높이 안 적은 다단 벽은 성토를 안 깎음 — 면적은 첫 미확정 단부터 아래를 없는 것으로 봄 · B08 도 그 아랫단을 「윗단(N단)이 미확정」 으로 같은 선에서 끊음(㉠)
258.12 실측 — 1단만 확정: 성토 감소 −60.36 → −52.50㎥(2단 몫 7.86㎥ 되살아남, +35,534원) · 1단 미확정: 성토·본공사비 다단 없을 때와 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 14:08:49 +09:00
eomsangdonandClaude Opus 5 d352774ce2 feat(b05): 원단위 표가 아직 안 읽는 칸에 「표에 안 쓰임」 — 골막이·바닥막이·큰돌쌓기·흙막이 23칸 · 값을 바꿔 돌려 보는 시험이 표시를 지킴(①)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 14:01:20 +09:00
eomsangdon 42d5566dc9 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-09-14 13:57:58 +09:00
eomsangdonandClaude Opus 5 c4a83dbb09 fix(b09): 9-21 제근 「밑수 미확보」 막힘 걷음 — 마스터 목록(F0294)은 원문 사실이라 두고 단가 조립이 사용자 확정 빌린 밑수 공종만 제외(÷1,000 은 ChooseOne) · 갈래 선 내역 줄 금액 섬 · 일위대가 453 금액 변화 0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 13:56:51 +09:00
eomsangdonandClaude Opus 5 c59dbba482 fix(b05): 옹벽 높이 제안값 2.5 → 2.0 — 관측 원단위 자료가 반중력식 H=2.0 한 벌뿐(A4)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 13:48:24 +09:00
eomsangdonandClaude Opus 5 5f3c55f32b fix(b08): _collect_structures 가 횡단 설계를 받아 다단 벽 목록을 넘김 — 인자 하나 + 호출부 전달만(④ · 메인 파일 손질 따로 뗌)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 13:37:15 +09:00
eomsangdonandClaude Opus 5 8a17d8308b feat(b06,b08): 다단 추가 기슭막이 수량 — 서버 재계산이 선 다단 벽 목록(design.extra_walls)을 남기고 B08 이 줄을 세움 · 높이 안 적은 단은 미확정(④)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 13:37:15 +09:00
eomsangdon 6b7c093e7e Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 13:23:19 +09:00
eomsangdon e26ef204fc Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 13:17:22 +09:00
eomsangdonandClaude Opus 5 b80eabf4f1 fix(b09): 9-19-1 토사면 고르기 — 두 표(절토면·성토면)가 섞여 보통인부 한 줄만 선 값이라 「불완전 — 쓰지 말 것」 사유를 비고·본표에 먼저(표 모양 본작업 전 · 브레인)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 13:17:20 +09:00
eomsangdonandClaude Opus 5 bba23376f1 fix(axis): 9-19-3 비탈면 고르기(발파암) 소형브레이커 = 5210-0010(1.0㎥/min) — 값 출처 9-19-1 표·[주]① 이 규격을 정함(브레인 승인) · 범위 별칭이 코드를 적으면 그 항목으로 곧장 이음 · 어어호스는 카탈로그 없어 그대로
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 13:15:09 +09:00
eomsangdonandClaude Opus 5 0e609e69a6 feat(b06): 횡단도 벽 전면 기울기도 품셈 13-4-4 [주]⑪ 표준경사 표 — 관 유입·유출·독립 기슭막이·다단·C군 벽 · TS 짝(common_util_masonry_slope) 거울 시험 · 번들이 표 JSON 도 봄(B5)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 13:14:20 +09:00
eomsangdonandClaude Opus 5 626f518064 fix(b09): 9-13-14·15 육상 발파암 들어내기 — 원문 「(1~2m)와 동일」 자기 참조를 0~1m(9-13-13)로 읽음(브레인 승인 · 고른 값·버린 값 근거 줄에) · 일부만 10% 풀림
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 13:11:41 +09:00
eomsangdonandClaude Opus 5 8a31f87412 fix(axis): 표 읽기 ② — 원본이 [주] 문장을 절 제목으로 읽은 표 14장은 앞 절을 이어받음(12-2 표면 마무리 가짜 표 7장·갈래 #설치·#철거 → 12-17-1) · 9-21 제근 안내는 못 붙은 줄이 아니라 비고로(빌린 밑수 교차참조)
마스터 전후 대조: 표가 옮겨 붙은 공종 8(12-02·12-17-01·01-01-02·05-34·08-02-03·09-07-01·09-11-01·14-02) · 일위대가 453 제목 금액 변화 0 · 12-02 가짜 갈래 둘이 12-17-01 로만 옮김

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 13:08:01 +09:00
eomsangdon e8cce87413 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 12:57:33 +09:00
eomsangdonandClaude Opus 5 e85ea2fa2c fix(b09): 9-4-1 평균 갈래 치즐 사유에 9-12-3 치즐 평균 선례(0.018)를 함께 적음 — 사용자가 유추 여부를 고르게
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 12:56:02 +09:00
eomsangdon 76b17098e3 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 12:55:04 +09:00
eomsangdonandClaude Opus 5 6b083ce3c5 feat(b09): 치즐 소모량 — 자원 목록 AR-M 치즐(본) · 대형브레이커 줄 옆 본/hr ÷ Q × 배분으로 같은 D 에 붙음(수동 단가가 있을 때만) · 칸을 채우면 치즐 13공종 온전 · 9-4-1 평균 갈래는 원문에 치즐 평균이 없어 사유
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 12:53:32 +09:00
eomsangdonandClaude Opus 5 ad63dd8f99 feat(b05,b06,b08): 독립 기슭막이 치수 한 벌(revetment_sabang) — 높이 설계자 입력·없으면 미확정 · B05 칸 밑 근거와 B08 사유가 같은 파일(사방교본 쪽·줄 번호) · 등록부 높이 2.5 걷음(B4)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 12:49:08 +09:00
eomsangdon db5822f4f5 Merge remote-tracking branch 'origin/dev' into sub_desktop_1 2026-09-14 12:48:28 +09:00
eomsangdonandClaude Opus 5 946ac82b5b docs(b09): 축C 명세 — 다른 창 편집분 반영
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 12:48:18 +09:00
eomsangdonandClaude Opus 5 330421fe94 feat(b05,b08): 메쌓기 높이 두 기준 사유(교본 7-3:27 · 품셈 L7192 · 칸 경고만) · 성토/절토 칸 face_role — 비우면 자동 판정(B2·B3)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 12:42:49 +09:00
eomsangdon b0355d5ae1 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 12:38:19 +09:00
eomsangdonandClaude Opus 5 7e036ddd51 feat(b08): 안 고른 돌쌓기 뒷길이를 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한으로 — 범위 안 첫 규격(메 ~3m 36→45) · 전개·양식·구조물도 한 벌 · 사유에 원문 줄 번호(B1)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 12:28:36 +09:00
eomsangdon 2b464138ae Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 12:24:17 +09:00
eomsangdonandClaude Opus 5 560df4ac83 fix(b08): 관측 원단위 줄도 갈 곳 기본값 금지 — destination 없는 줄은 막고 사유(종전 or material 로 조용히 자재행) · 표 전 줄 갈 곳 시험
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 12:22:35 +09:00
eomsangdon f9ccc8f6fb Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 12:12:53 +09:00
eomsangdon 95ce24d31c auto: 2026-09-14 12:12 (ESD_LAPTOP) 2026-09-14 12:12:30 +09:00
eomsangdon 2604e9e914 Merge remote-tracking branch 'origin/main_desktop_1' into main_laptop_1 2026-09-14 12:10:53 +09:00
eomsangdonandClaude Opus 5 acb377f5db fix(b08): 철근 이름·규격 가르기(이형철근 + D13·D16) — 대응표 조각은 이름+규격 키·이름 두 색인으로 집고 경계 검사도 두 키로 · 관측 원단위 spec 칸을 넘김 · 전후 물량 대조 시험(가드 없이 돌리면 빨강 확인)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 12:09:18 +09:00
eomsangdon beb30999e3 auto: 2026-09-14 11:57 (EOMSANGDON-HOME) 2026-09-14 11:57:37 +09:00
eomsangdonandClaude Opus 5 298133dce4 knowledge(축C 명세): 검산 계약에 「기준이 변경을 잴 수 있는지 먼저 볼 것」 보강
- 골든 금액 시험이 원본 수량을 bill_line 에 바로 넣어 새 길을 안 거침 — 초록이 증거가 아니던 자리
- 원본 321줄 자리 규칙 대조를 따로 만들어 철근 Ton 소수 3자리 4줄을 뽑아냄
- 재지 못한 것을 잰 척하지 않을 것

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-14 11:57:31 +09:00
eomsangdon ac801cedfe Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 11:57:09 +09:00
eomsangdonandClaude Opus 5 d6820db7b0 fix(b09): 내역 수량을 1-2-2 자리로 반올림해 확정한 뒤 금액(버림) — 표에 없는 종목 기본 2자리 · 철근 ton 소수 3자리(kg 정수 환산) · 단위 대소문자 흡수 · STmate 원본 수량 321줄 대조 시험
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 11:55:23 +09:00
eomsangdonandClaude Opus 5 ecf3b65b42 feat(b06): BOX·세월교·물넘이 기본값으로 그린 칸을 횡단 카드 머리에 「기본값(미확정)」 · 월류 높이 없어 파임 안 그리는 까닭(판정 ②③)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 11:50:31 +09:00
eomsangdonandClaude Opus 5 290eccbb92 fix(b05,b08): C군 벽 높이 없어도 놓임 — 높이가 밑수인 벽은 줄만 서고 미확정(판정 ①)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 11:44:01 +09:00
eomsangdonandClaude Opus 5 bd69aa4788 fix(b08): 자재총괄 화면·인계를 한 벌로 — material_table_for 가 규준틀 재료까지 얹고 두 라우터가 같이 부름(화면 6줄·인계 9줄 갈림 닫음)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 11:35:02 +09:00
eomsangdon 37791c5bfb Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-14 11:22:31 +09:00
eomsangdonandClaude Opus 5 a0e144aed5 fix(b08): 할증률 「이내」 칸 — 안 고른 칸은 비우고 회색 제안값(지금 붙는 표값)만 · 기본값을 값처럼 안 채움
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 11:22:26 +09:00
eomsangdonandClaude Opus 5 646081757c fix(b08): 산출 조건 타입에 material_surcharge 칸 — afea225a 뒤 typecheck 빨강 닫음
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 11:09:51 +09:00
eomsangdon 71831432d1 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-14 11:08:01 +09:00
eomsangdonandClaude Opus 5 c12a2bf826 fix(b08): 갈 곳 기본값 금지 — 표에 없는 성분 이름은 막고 사유(종전 quantity 로 떨어져 조용히 빠짐) · 드러난 넷(이형철근 D13·면목·떼 자재 · 유로폼 일위대가) 표에 올림
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 11:07:57 +09:00
eomsangdonandClaude Opus 5 afea225ae5 feat(b08): 자재총괄 할증률 「이내」 칸 초안 연결 — 불러오기·저장 본문·표 옵션(랩탑_메인 87083c33)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 10:54:37 +09:00
eomsangdonandClaude Opus 5 87083c3341 feat(b08): 자재총괄 할증률 「이내」 칸 — 표값 상한 안에서만 고름 · 비우면 표값(초안 연결은 Page 한 줄 대기)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 10:52:52 +09:00
eomsangdonandClaude Opus 5 e6e6f9b7d5 feat(b08,b09): 사급 자재총괄 줄이 수동 단가로 내역 본체 「자재(사급)」 줄이 됨 · 기본 사급 · 이중계상 가드(코드 우선) · 규준틀 재료 이름/규격 가름(할증 각재 5·판재 10) · 할증 보류·이름 없음 사유 · 할증 「이내」 칸 서버
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 10:41:19 +09:00
eomsangdonandClaude Opus 5 57a949c624 feat(b09): 자재 단가 탭 등록 — 자재단가대비표 옆(자재 수동 단가 · 랩탑_메인 273132f8)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 10:12:54 +09:00
eomsangdon aefb476577 Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1 2026-09-14 10:04:09 +09:00
eomsangdon fdb7e27bed Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 10:04:08 +09:00
eomsangdon 273132f8f6 auto: 2026-09-14 10:03 (ESD_LAPTOP) 2026-09-14 10:03:21 +09:00
eomsangdonandClaude Opus 5 aeb18266bd fix(b09): 총액 1,000원 미만 버림을 기본으로 켬 · 절사 자동보정 진동 고침
근거 — 산림청고시 제2025-82호 「금액의 단위표준」 「설계서의 총액 · 원 · 1,000 · 미만버림」.
실무 6건 원가계산서가 여섯 다 000 으로 끝나는 것이 증거.

· 기본 켬 — cut_basis="grand_total" · cut_unit_krw=1000
· 조용히 안 깎음(2026-09-08 합의의 정신) — 이윤 조정액 줄과 결과 메모에 고시 번호를 달고
  기준 입력판 11 에서 끌 수 있음을 함께 적음 · 칸 밑 안내도 고쳐 씀
· 끄는 값을 none 으로 바꿈 — 빈 값은 저장이 안 돼 기본으로 되돌아가 끌 수가 없었음
· 진동 고침 — 걸음마다 ÷1.1(종전엔 첫 걸음만). 이윤 1원이 총공사비 1.1원이라
  잔차를 그대로 빼면 경계를 지나침(울진 신설 654→999→900→910→909…)
· 안 앉으면 「끝자리 N원이 남음」을 메모로 보임
· 실무 6건 전수 000 판정을 새 벌로 지킴

곁 — tmp/tests/test_b09_cost_fixtures.py 를 resources/tester/ 로 옮김(픽스처 포함).
     tmp 쪽 사본은 안 지움 · 겹침은 파일 머리에 적음.
     기본이 바뀌어 깨진 남의 시험 6건은 「안 자른 값」 자리에 cut_basis="none" 을 넣어 고침.

전체 시험 1782 통과 · 28 건너뜀.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 09:40:24 +09:00
eomsangdonandClaude Opus 5 996300752d feat(b08): 사토 운반에도 「덤프 적재」 짝 줄 · 사토 줄이 갈래를 실어 보냄 — 종전 갈래 미지정으로 덤프 잎을 못 고르던 것
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 09:39:42 +09:00
eomsangdonandClaude Opus 5 d5c5fe7b40 fix(b06): 구조물 패널이 스펙 기본값을 관 옵션에 채우지 않음 — 조작·저장분만(폼 ④)
- 페이지를 열기만 해도 관종·관경·구조·벽 길이·전/후·집수정 길이 기본값이 옵션에 박혀 임시 저장·[저장]에 확정으로 굳던 자리
- 벽 형태·높이는 조정창에서 조작했을 때만 싣음(앞 조각 c4eccee5 auto 커밋에 먼저 쓸려 올라감)
- 세월교·BOX: 저장돼 있던 칸이거나 조정창 조작으로 등록부 기본값과 달라진 칸만 스펙 값을 싣음
- ORCA 936be972 B06: 관 폼 관종 「안 정함」 · 저장된 관경 1000 만 · 벽 칸 빔+제안 · 임시 저장에 기본값 안 생김

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 09:34:31 +09:00
eomsangdon c4eccee51f auto: 2026-09-14 09:30 (ESD_LAPTOP) 2026-09-14 09:30:59 +09:00
eomsangdonandClaude Opus 5 0a7f4a5008 test(b05): 폼 소스 시험을 새 규칙에 맞춤 — 고르기 칸은 늘 빈 보기 · 기본값은 제안으로만(폼 ①②)
- 옛 「기본값 있는 칸은 첫 보기」(2026-08-17)를 확인하던 줄을 브레인 판정(2026-09-14)대로 뒤집음
- 칸 만들기가 `_Structures_Fields.optionControl` 로 옮겨간 자리를 봄

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 09:30:59 +09:00
eomsangdonandClaude Opus 5 fc2ecc0f39 fix(b05): 계곡 통과 시설 폼이 기본값을 값으로 안 보냄 — 관종·관경·구조·집수정 길이·날개벽·BOX·월류·설치 측(폼 ③)
- 관종 파형강관·관경 1000 · 유입/유출 구조 기슭막이 · 집수정 길이 2 · 날개벽 있음·1·2·45 · BOX 2.0×2.0 ·
  월류 폭 5/10 · 월류 높이 필요 수심 · 독립 기슭막이 양쪽 — 전부 「안 정함 (제안 …)」·회색 글씨로만
- [제안값 넣기] 누른 때만 빈 칸에 제안값 · 적은 월류 높이가 필요 수심보다 낮으면 여전히 되돌림
- BOX암거 새로 놓을 때 기본값 싣던 자리도 걷음 · 비운 칸은 횡단도가 등록부 기본값으로 그림(저장만 안 함)
- 700줄 한계로 물넘이·세월교 칸 묶음을 `_Drainage_Facility_Ford.ts` 로 뗌
- ORCA 936be972: 관종 「안 정함」 · 저장된 관경 1000 은 그대로 · 날개벽 고친 요청에 관경·날개벽만 실림 · 캐시 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 09:28:06 +09:00
eomsangdon 0244749d29 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 09:21:31 +09:00
eomsangdonandClaude Opus 5 2744f1bcbb fix(b05,b08): 구조물 목록 폼이 등록부 기본값을 값으로 안 채움 — 제안으로만(폼 ②)
- 칸에는 저장된 값만 · 기본값은 회색 글씨(숫자)·빈 보기 이름(고르기)으로 제안 · [제안값 넣기] 누른 때만 값
- 새 구조물 추가도 기본값을 안 실음(defaultOptions 로 채우던 자리) · 잠긴 칸(enabled:false)만 프로그램 값 유지
- C군(범위 계산 타입)은 길이처럼 높이도 비면 안 놓음 — 비면 횡단도 벽이 조용히 안 서던 길
- B08: 돌 조달을 안 정하면 「채집으로 셈」 사유를 보임(조용히 채집 되지 않게)
- ORCA 936be972: 돌쌓기(메) 고르면 높이·길이·전·설치측 빔 + 제안 · 단추로 채워짐 확인(저장 안 함)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 09:21:27 +09:00
eomsangdon 48e3eeee08 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-14 09:17:35 +09:00
eomsangdon 7229c0ab4e Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 09:17:34 +09:00
eomsangdon 6a427e78f9 auto: 2026-09-14 09:14 (ESD_LAPTOP) 2026-09-14 09:14:57 +09:00
eomsangdonandClaude Opus 5 eb14ec1c69 refactor(b09): 갈래를 출처로 가름 — 식이 낸 갈래(formula_variants) · 짝 시험은 표에서 온 갈래만
- 제외 목록 대신 갈래마다 어디서 왔나를 한 칸: 덤프 운반·적재 10-12 제목 → 그 식(브레인 판정)
- 짝 시험: 표에서 온 갈래는 마스터 갈래 키에 있어야 함 · 식이 낸 갈래는 출처가 적혀 있고 마스터엔 없어야 함
- 마스터에는 「식 공종 갈래」 칸을 안 둠(표에서 뽑은 것만 담는 자리)
- 전체 1771 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 09:14:56 +09:00
eomsangdonandClaude Opus 5 7eecfacca3 fix(b05): 기본값 없는 고르기 칸은 「안 정함」이 실제 상태 — 첫 선택지를 미리 고르지 않음(폼 ①)
- 뿌리: 사용자가 고른 적 없는 첫 선택지가 저장돼 확정으로 굳음(옛 2026-08-17 「빈 보기 두지 않음」 걷음)
- 구조물 목록 일반 폼: 기본값 없는 고르기 칸은 늘 빈 보기(필수 「— 선택 —」 · 아니면 「— 안 정함 —」)
- 시설 폼 optionalSelect: 맨 앞 빈 보기(날개벽 「안 놓음」 등) — 기본값이 있는 칸은 다음 차례(②③)
- ORCA 936be972 B05 관 폼: 날개벽 「안 놓음」 빈 값으로 섬 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 09:14:50 +09:00
eomsangdon 4015c27c90 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 09:11:15 +09:00
eomsangdonandClaude Opus 5 8eb1189b4f fix(b05,b06,b08): 기슭막이 벽 높이를 관경 기준 한 값으로 · 폼은 기본값을 값으로 안 보냄
- 한 벽을 두고 값이 셋(등록부·표 H2.5 · 구조물도 H2.5 · 횡단도 관경 기준)이던 결함 — 근거 있는 관경 기준으로 맞춤
- 관 벽 높이 안 적으면 관경 + 여유 0.5 를 0.1m 로 올리고 근입 0.5 더함(Ø800 1.8 · Ø1000 2.0 · Ø1500 2.5)
- 파이썬(B06 엔진)·TS(세트 스펙·횡단 상수) 거울 시험 · 등록부 기본 2.5 걷고 empty_means
- 최소값이라 미확정·금액 밖은 그대로
- B05 벽 칸(관 유입·유출 · 독립 기슭막이)은 비워 두고 회색 제안값 · [제안값 넣기] 누른 때만 값 — 저장 한 번에 미확정이 확정으로 바뀌던 길을 막음
- 전체 시험: 받은 덤프 적재 커밋 몫 2개만 빨강(보고함)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 09:10:47 +09:00
eomsangdon f07ff0033f Merge remote-tracking branches 'origin/main_desktop_1' and 'origin/main_laptop_1' into sub_desktop_1 2026-09-14 09:02:25 +09:00
eomsangdonandClaude Opus 5 d5bdd2ee17 docs(b09): 축C 명세에 품셈 판 근거와 금액 단위표준 절 보탬 — 산림청고시 2025-82호
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 09:02:11 +09:00
eomsangdonandClaude Opus 5 9cd2920afc knowledge(축C 명세): 총액 1,000원 미만버림 실측 결과 반영
- 고시 다섯 줄 중 총액 줄만 우리가 안 따르고 있었음(기본값 꺼짐)
- 실무 6건 끝 세 자리 000 ↔ 우리 373·695·578·654·341·639
- 곁 버그 — 절사 자동보정이 둘째 걸음부터 ÷1.1 누락으로 진동
- 집계표 ROUND 는 설계서 소계와 다른 자리로 봄

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-14 09:01:37 +09:00
eomsangdon 60aac3892d Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 08:57:29 +09:00
eomsangdonandClaude Opus 5 a440b4f919 feat(b08): 덤프 운반마다 「덤프 적재」 짝 줄 — 10-12 「1. 적재」 · 거리 무관 · 수량은 운반량 그대로
- _haul_rows 한 곳: dump_truck 내역 줄 뒤에 haul_equipment dump_loading 짝 줄(갈래·수량 같음)
- 흙깎기 9-3-2(㎝ 20초·135°)에 싣기가 안 들어 있어 운반과 한 벌로 냄(브레인 판정)
- B09 운반토량 검산(운반 ≤ 절취)에서 적재 줄은 뺌 — 같은 흙을 두 번 세지 않게
- 시험 기대값: 운반표 줄 4 → 6(적재 둘) · 내역 줄 3 → 5 · 전체 1763 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 08:57:24 +09:00
eomsangdonandClaude Opus 5 a2b3ae698f fix(test): 덤프 적재 뒤 낡은 시험 둘 — 가드 뜻은 지키고 새 모양에 맞춤
- v16 「덤프 운반은 아직 안 선다」가 지키던 것 = 운반거리(설계 입력) 없이 운반이 조용히 금액을 갖지 않기
  → 「거리가 와야 선다」로: 거리 없는 기본 조립엔 운반(#L…m) 없음 · 거리 무관 적재(#적재)만 섬
- 갈래 제목 ⊂ 마스터 갈래 키: 10-12 는 표가 아니라 본문 식이라 마스터 표에 갈래가 없음
  (#L…m 는 프로젝트 거리마다 생겨 마스터에 둘 수도 없음) → 식이 내는 FP-10-12 갈래만 까닭 적고 제외
- 58afe8f2 를 일부 시험만 돌리고 올려 난 빨강 · 전체 1763 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 08:57:24 +09:00
eomsangdonandClaude Opus 5 a1b1182b9b fix(b05): 계곡 통과 시설 [저장]이 폼이 모르는 칸을 지우던 것 — 폼 칸만 갈아 끼움
- 저장이 옵션을 통째로 갈아 끼워 집계표·구조물도로 적은 칸(관 기슭막이 기초 등)이 조용히 지워졌음
- 같은 시설 종류면 폼이 적는 칸만 갈고 나머지는 이어 붙임 · 종류를 바꿀 때만 통째로
- 폼이 아는 칸을 폼이 비우면 지움(사용자가 폼에서 한 일)
- 폼 칸 목록 거울 시험 — 폼에 칸을 늘리고 목록에 안 넣으면 시험이 잡음
- ORCA 936be972: 집계표로 기초유 적고 B05 폼 고침 → 재계산 요청에 기초유 남음 확인 · 값·캐시 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 08:49:38 +09:00
eomsangdon 128106e76d Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 08:41:53 +09:00
eomsangdonandClaude Opus 5 0eebb50c03 feat(b05,b08): 등록부 칸 채움 A2·A3 — 개거 연장 · 물넘이포장 두께·노폭 방향 길이
- 표가 읽는데 등록부에 없어 영영 안 서던 칸 — 옛 저장본은 안 깨지고 비면 사유(어디서 적는지)
- 개거(점 배치) length_m · 물넘이포장 thickness_cm·length_m(detail · 구조물 집계표에서도 고침)
- B05 시설 칸에 물넘이 두께·길이 입력 — 시설 저장이 칸을 통째로 갈아 끼워 집계표로 적은 값이 지워지던 길을 막음
- 700줄 한계로 유입구 형식 목록을 _Fields 로 옮김(부르는 쪽 그대로)
- 사유 문구: 「open_ditch 연장」 키 노출 · 「연장·면적이 0」 → 무엇을 적으면 서는지

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 08:40:49 +09:00
eomsangdonandClaude Opus 5 58afe8f2d2 feat(b09): 덤프 적재 10-12 「1. 적재」 — 굴착기 0.7㎥ Q1 호표(#적재) · E0 토사 0.60(본문 0.75 버림을 사유에)
- Q1 = 3600×0.7×K×f×E0/22초 · 토사 47.63 · 암절취 16.32 · 발파암 13.67 ㎥/hr
- E0 는 운반 식 Es 와 다른 기호 — 토사 0.60(표 「임도」·10-12-3 [주]③) 채택 · 본문 0.75 버림(브레인 판정)
- 거리 무관이라 늘 세움 · 내역은 인계 수단 표지 dump_loading 줄을 잎 #적재 로(B08 짝 줄은 다음 커밋)
- 시험 1건 보탬

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 08:37:58 +09:00
eomsangdon 999aeac755 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 08:33:32 +09:00
eomsangdonandClaude Opus 5 139e505843 feat(b09): 덤프 운반 10-12 — 운반거리마다 호표(X 덤프트럭 15ton → D → B) · 원문 어긋남은 고른 값·버린 값을 사유에
- 원문 본문 식(채워 넣는 서식)을 데이터로: Qt = T/γt×L · n = Qt/(0.7×K) · ㎝t = t1+t2+t3+t4+t5 · Q = 60×Qt×f×E/㎝t
- 브레인 판정: n 의 Qt 계산값(원문 표기 10 버림) · 발파암 E 0.9(원문 누락) · Es 운반 줄 값 · 거리별 갈래
- 거리는 B08 인계(유토곡선·사토장)에서 — 내역이 조립에 넘기고, 호표 한 장은 코드 #L…m 에서 되읽음
- 거리 없는 줄은 0 원 아니라 「운반거리 미입력」 · 갈래는 범위 별칭 리핑암 → 암절취
- 배정 프로젝트 164.23m: 토사 3,249원/㎥ · 암절취 8,882원/㎥ · 같은 인계 대조로 바뀐 줄은 이 둘뿐(+1,437,981원)
- 시험 5건 · 전체 1761 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 08:30:20 +09:00
eomsangdon e3dbef9700 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-09-14 08:23:22 +09:00
eomsangdon 6eb651d054 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 08:23:21 +09:00
eomsangdonandClaude Opus 5 81286400bd fix(b08,b09): 기본값으로 선 구조물은 줄만 서고 금액 밖 — 「미확정 N건 — 금액에 안 들어감」
- 브레인 판정: 값 없음을 감추면 조용히 빠지거나 조용히 부풂 — 없으면 없다고 보이기
- 관 지점 벽 치수를 등록부 기본값으로 채운 줄 = 미확정: 원단위 합·자재·터파기·되메우기·기초잡석·타설·잔토·채집석에서 뺌
- 인계 막힘 갈래 넷째 값 unconfirmed(계약 칸은 안 늘림) · B09 는 내역 제자리에 빈 금액 + 빨간 테두리 · 머리 뱃지
- 구조물도 그림에 표와 같은 기본값 사유 줄 · 「횡단도와 같은 값」 문구는 틀려 고침(B06 관 벽 높이는 관경 기준)
- 936be972 내역 184,291,506 → 124,424,547 · 미확정 18건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 08:22:14 +09:00
eomsangdonandClaude Opus 5 5bbb0aa53b fix(b08,b09): 한 줄 「암」 막힘 까닭 바로잡음 — 「공종코드 없음」이 아니라 암 갈래 구성비 입력 필요
- B08: 갈래로 안 나뉜 암 총량은 시공법만 골라서는 안 풀림 → 사유 「암 갈래 구성비(%)가 아직 없어…」 · 시공법 안내 목록에서 뺌
- B09 내역: 공종을 못 고른 줄에 B08 막힘 까닭이 있으면 그 문구 그대로(입력 필요 ↔ 매핑 구멍 가름)
- 배정 프로젝트 흙깎기 암 3,104.57㎥ · 측구터파기 암 160.48㎥ 가 이 자리 · 시험 1건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 08:10:30 +09:00
eomsangdon 7b2affe119 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 08:07:08 +09:00
eomsangdon fd324ce531 auto: 2026-09-14 08:03 (ESD_LAPTOP) 2026-09-14 08:03:13 +09:00
eomsangdonandClaude Opus 5 f3630ab1e7 feat(b09): 규격이 열로 선 표(관부설 12-11) — 규격 칸·원문 비고를 읽어 줄은 서고 못 넣는 줄은 까닭
- 원문 비고 「별산」·「별도계산」·「설계수량」(〃·병합 칸 포함)·「필요시적용」은 안 넣는 줄로 까닭을 남김
- 규격 칸(1:2)과 관경 열(∅800mm)을 조인 키로 — 접합몰탈·고무링이 자재 줄로 섬(단가 층은 아직 없음)
- 크레인은 기종이 여럿이라 고르지 않음 — 「규격 미정 · 원문 규격 10ton/5ton」(브레인 판정 대기)
- 옆 칸 규격 「40.64㎝」 = 카탈로그 절단기 40.64(기계 운전 자료가 없어 층은 못 섬 — 사유로 드러남)
- 카탈로그 밖 자원 목록에 고무링 ∅800/1000/1200mm · 지수활제 보탬(원문 F0346 · 단가 없음)
- 범위 81공종 상태 전후 같음(못 붙은 줄의 까닭만 바뀜) · 시험 4건 · 전체 1749 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 08:03:12 +09:00
eomsangdon bd571f0454 Merge remote-tracking branches 'origin/main_desktop_1' and 'origin/main_laptop_1' into sub_laptop_1 2026-09-14 07:58:31 +09:00
eomsangdon 27192c739a Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 07:58:29 +09:00
eomsangdonandClaude Opus 5 b97cb4e2e6 feat(b08): 계곡 통과 시설도 원단위 전개로 — 관 유입·유출부 기슭막이·집수정·독립 기슭막이·물넘이 (A1)
- 전개가 structures.json 만 읽던 구멍 — pipe_points.json 도 읽음(관 자체는 관 줄이 셈)
- 관 부설(품셈 12-11 m당)에 기슭막이 몫 없음 → 기슭막이는 전개 한 곳에서만 · 사유에 적음
- 안 적힌 벽 칸은 등록부 기본값(횡단도 그림과 같은 값) + 「기본값으로 섰음」 사유
- 구조물 목록에 남은 관 지점 종류 옛 저장분은 안 셈(두 번 방지)
- 기슭막이 공종 = 형태로 돌쌓기 찰·메 코드 · 독립 기슭막이 한쪽 설치에 두 칸이 다르면 사유만
- 산출식 없는 세월교가 「터파기 줄 없음」 거짓 사유를 안 내게 · 구조물도 제원 저장이 관 지점 시설엔 안 먹힘을 알림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 07:52:55 +09:00
eomsangdonandClaude Opus 5 808cf0f289 fix(b09): 기계 카탈로그 이름 되살림 — 표 머리글 붙은 5건 · 빈 이름 29건(원천 쪽 넘김 파싱 병)
- 쪽 끝 기종 이름에 다음 쪽 머리글·다음 분류 이름이 붙고, 다음 분류 첫 기종들은 이름이 비던 병
- 이름·규격은 건설공사 표준품셈 제8장 기종 목록 줄에서 읽음 · 취득가·손료계수는 원천 그대로
- 대형 브레이커 되살림 표 옆에 같은 약속으로 둠(원천이 실으면 지움)
- 자원 축 781/386 · 범위 81공종 상태 전후 같음 · 시험 4건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 07:49:02 +09:00
eomsangdon b84ee14bea auto: 2026-09-14 07:48 (EOMSANGDON-HOME) 2026-09-14 07:48:39 +09:00
eomsangdonandClaude Opus 5 7be245db2b knowledge(축C 명세): 품셈 판 규칙·반올림 고시 근거 반영
- 2장 — FP- 는 산림사업 표준품셈 한 판, 건설품셈은 기계 층만. 고시 2025-82호 총칙 7 조문 근거
- 7장 — 금액의 단위표준 표 다섯 줄과 실측 대조. 넷은 맞고 「총액 1,000원 미만버림」만 미확인
- 7장 — 수량의 단위표준 [주] 셋. 「규정 없는 자리 소수 2자리」는 이 표에도 없는 자리에만
- 막히면 산림품셈을 먼저 볼 것 — 덤프 운반이 건설 8-2-8 만 보다 막혔던 자리

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-14 07:48:34 +09:00
eomsangdon e37a425b2e Merge remote-tracking branch 'origin/CODEX' into sub_desktop_1 2026-09-14 07:40:49 +09:00
eomsangdonandClaude Opus 5 81b51de517 feat(b08): 층따기 [제안값 넣기] 단추 — 누른 때만 KDS 표준치수 토사 0.5 m
- 자동으로 안 채움: 임도기술교본 6-4 「설계도서에 명시되어 있는 높이와 폭」(브레인 판정)
- 칸·근거 줄·단추 묶음을 B08_Quantity_UI_Side_BenchCut.ts 로 뺌(페이지 700줄 넘음)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 07:36:49 +09:00
eomsangdonandClaude Opus 5 1e9ff999a4 feat(b09): 준공 탭 등록 — 기성 뒤 맨 끝(설계 → 계약 → 실행 → 기성 → 준공 · 랩탑_메인 a7a1b246)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 07:10:29 +09:00
eomsangdon ee3c60f76c Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_1 2026-09-14 07:10:06 +09:00
eomsangdon c4ecf3bd2b Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 07:10:05 +09:00
eomsangdonandClaude Opus 5 7b6fe3873b feat(b08): 구조물도 그림 2조각 — 집수정(□형 Ø800) · 물넘이포장 · 날개벽·돌집수정은 까닭
- 집수정 □형 Ø800 — 관측 줄에 원문 치수(소광리 「집수정구조도(800)」 설계조건 칸·산출식)를 싣고 그 치수로 표 수량이 원문 식대로 다시 나오는지 시험
  원문 값으로: 콘크리트 2.84 → 2.83752 · 합판거푸집 21.28 → 21.2843 · 철근 D13 4.78 → 4.776 · 면목 12.67 → 12.6728(터파기·되메우기·잔토 그대로)
  그림: 긴 벽 정면(바닥기초·관·방수로·터파기) + 평면 · 관·방수로 자리는 원문 입체도 모식이라 적음
- 물넘이포장 — 슬래브 T(규격) · 와이어메쉬 2/3T(소광리 「포장」 그림) · 터파기 · 새 사유 「원문 둘이 다름: 소광리 포장 와이어매쉬 1.0·비닐 2㎡ ↔ 이 표(울진 1공구) 1.16·비닐 없음」을 표·그림이 같이 씀
- 날개벽 5종 — 원문 탭에 구조도 그림이 없고 날개 모양이 손으로 적은 수뿐이라 까닭 · 돌집수정 ㄷ·ㄴ형 — 집계표 관측값이라 치수 원문 없음 까닭
- 사유 문구(브레인 판정): 개거 150×200 「원문 둘이 다름(산출식 0.3×0.1 ↔ 그림 홈 150×200 + 밑 보 400×200)」 · 바닥막이 「옹벽은 버림·잡석까지 팜 — 바닥막이는 정본이 돌 두께만이라 다름」

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 07:09:53 +09:00
eomsangdonandClaude Opus 5 deac820044 test(b09): 골든셋을 원가계산서까지 넓힘 — 실무 6건 제비율 54자리·사슬 33자리 원 단위 재현
요율을 원본 「공사원가계산서」 비고에서 뽑아 그 원본 전용 판을 지어 엔진에 넣음 —
「요율 데이터가 달라서 틀림」을 없애고 남는 차이를 밑수·절사·사슬로 몰아 둠.

· 제비율 줄마다 54자리 전수 일치(안전관리비 A·B 와 작은 쪽 채택 포함)
· 사슬은 비고가 다 보이는 셋(봉화·영월·울진 공통) 순공사원가~총공사비 33자리 일치
· 이윤 — 자동절사가 맞춘 원본 둘(봉화 339 · 영월 632) · 설계자 명시 조정 하나(울진 905,980)
· 영월 도급공사비 차 762,000 = 원본에만 있는 국가지점번호판검증수수료 줄
· 고칠 자리 하나(배정 대기) — 안전관리비 B 절사 자리가 영월에서 1원 갈림(미채택 줄)
· 짝 파일이 700줄에 닿아 새 파일로 가름(원본 읽기는 공유)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 07:04:09 +09:00
eomsangdon fd344a5311 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 07:01:50 +09:00
eomsangdonandClaude Opus 5 a7a1b246b6 feat(b09): 준공 단계 — 계약금액 | 준공금액(기성 마지막 회차 누계를 옮기기만)
- 준공 별도 계산 규칙 미확인(35번 §3) — 규칙을 짓지 않고 기성 누계를 옮김 · 기성 한 장 불변
- 기성 회차가 없으면 준공금액 비움(0 원으로 안 채움)
- 기성 계산 조각(progress_for)을 떼어 준공이 같은 길로 받음
- 기성 간접재료비 확인 대기 닫음 — 예정가격작성기준 제17조·제39조② 근거(브레인 판정)
- 준공 탭 파일 · 사전 키(등록은 서브) · 시험 2건 · 전체 1730 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 07:01:46 +09:00
eomsangdonandClaude Opus 5 7d3b062c3e fix(b08): 층따기 칸 밑 — 「제안값(확정 아님)」 경고를 KDS 44 30 00 도로토공 2.3.4 (4) ② 근거 표시로
- 원문 표 그대로: 최소 높이 토사 500 mm · 암반 400 mm · 폭 1 m 이상(기계 작업 3 m 이상)
- 값·칸·자동 채움은 그대로 — 말만 고침(2026-09-14 브레인 정정)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 07:01:46 +09:00
eomsangdon a98689c966 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 06:55:25 +09:00
eomsangdon a88f9a625b auto: 2026-09-14 06:53 (ESD_LAPTOP) 2026-09-14 06:53:32 +09:00
eomsangdonandClaude Opus 5 951e39a923 feat(b08): 구조물도 그림 — 기슭막이 · 골막이 · 떼흙막이 · 바닥막이 · 개거 까닭
- 기슭막이(돌쌓기형) — 돌쌓기 그림 그대로 · 기울기 판정 대상에 넣고 찰/메는 형태에서(표와 한 벌)
- 골막이 — 정면 사다리꼴 · 방수로 관 · 바닥파기 띠 + 옆 단면 · 치수는 표와 같이 쓰는 erosion_check_section 한 벌로 뽑음(값 불변)
- 떼흙막이 — 정본 평균 붙박이 치수(dims)로 · 그 치수로 표 두 줄(떼 1.39 · 바닥파기 0.17)이 다시 나오는지 시험
- 바닥막이 — 돌 켜 · 버림 · 기초잡석(산출 조건) · 터파기 · 표 사유에 새 한 줄 「터파기는 돌 두께만 — 버림·기초잡석은 판 깊이에 안 들어감」을 그림도 같이 씀
- 표가 안 선 장은 표 사유를 그대로 까닭으로 · 개거 둘은 원문끼리 치수가 달라서/단면 그림이 없어서 까닭
- 화면 확인: 검증 프로젝트 936be972 에 넷을 잠깐 놓았다 되돌림(구조물 5개 원본과 같음 · 단계 상태 그대로 · 내역 124,424,547 그대로)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 06:53:31 +09:00
eomsangdon cdd3a29452 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 06:42:20 +09:00
eomsangdonandClaude Opus 5 efe55e9d34 feat(b09): 기성 2벌 — 계약잡비율 직접입력(사유·↺) · 단일율 곱 차이 표시 · 공급가액/총공사비 절사 9택 · 사정 칸
- 계약잡비율: 기본 역산값(도급액 ÷ 계약 직접공사비) · 계약서 값 직접입력이 이김(사유 필수)
- 줄별 절사 누적으로 단일율 곱과 벌어진 금회·누계 차이를 비고에 적음
- 공급가액 절사(총공사비에서 조정 + 10원~1억) · 총공사비 절사(이윤금액 직접입력 + 10원~1억)
  — 떨어지는 몫은 설계 원가계산서와 같은 식으로 이윤에서
- 사정: 뜻 미확인이라 칸만 받고 계산에 안 씀
- 시험 4건 보탬 · 전체 1723 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 06:42:16 +09:00
eomsangdon bb8b9405a4 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_desktop_1 2026-09-14 06:33:16 +09:00
eomsangdon 632e47a1ba Merge remote-tracking branch 'origin/dev' into sub_desktop_1 2026-09-14 06:33:12 +09:00
eomsangdonandClaude Opus 5 6454f4fefb docs(b09): 축C 명세 반올림 표에 중기시간금액집계표 줄 보탬 — 성분마다 반올림 후 합(131/144)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 06:32:56 +09:00
eomsangdonandClaude Opus 5 2c0ae48ac7 fix(b08): 구조물 표 구멍 넷 — 골막이·바닥막이 내역 수량 0 · 떼흙막이 m당 오표시 · 개거 L형 키 · 기슭막이 거짓 사유
구조물도 남은 종류 그림을 시작하며 시험 제원으로 표를 세워 보다 드러남:
- 골막이(개소당)·바닥막이(㎡당)가 「m · 연장 0」으로 읽혀 구조물도 전 줄 「단위당 못 냄」 · 인계 「물량이 0 이라 내역에 안 세움」 → 내역 단위 개소 1 / ㎡ 면적으로
- 떼흙막이 개소당 값이 연장으로 나뉘어 떼 0.139/m 로 보였음 → 개소당 1.39
- 개거 「L형수로 H=0.2」 — 엔진 키가 「콘크리트 L형수로 H=0.2」라 등록부에서 골라도 영영 안 섰음 → 등록부 글자로
- 기슭막이 사유 「돌 종류·뒷길이 칸이 없어」 — 칸이 선 뒤로 거짓 · 돌쌓기 한 줄과 같은 말 두 번 → 걷음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 06:32:14 +09:00
eomsangdonandClaude Opus 5 ba2c36e803 feat(b09): 기성 탭 마지막 회차 지우기 — 앞 회차는 뒤 회차 전회를 흔들어 마지막만
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 06:16:00 +09:00
eomsangdon c3060f9bb6 auto: 2026-09-14 06:12 (ESD_LAPTOP) 2026-09-14 06:12:19 +09:00
eomsangdon 0f5d7da1e2 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 06:11:51 +09:00
eomsangdonandClaude Opus 5 aba96f6116 fix(b08): 옹벽 기초 터파기 옆면 1:0.5 — 실무 식생옹벽블럭 기초 터파기와 같은 자리에서 기울기(브레인 판정 고침)
- 밑폭 2.2(기초 1.6 + 여유 0.3×2) · 윗폭 = 밑폭 + 0.5 × 판 깊이 × 2 = 2.9 · 깊이 0.7 → m당 터파기 1.54 → 1.785 · 되메우기 0.36 → 0.605 · 잔토 1.18 그대로
- 검증 프로젝트 내역 본체 124,202,681 → 124,424,547원(+221,866 = 구조물터파기 0~1m 15.4→17.85㎥ +213,375 · 되메우기 24.6→27.05㎥ +8,491)
- 그림 터파기 점선을 사다리꼴로 · 「밑폭 2.20 · 윗폭 2.90 × 깊이 0.70」 · 표 사유와 같은 말

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 06:11:47 +09:00
eomsangdon 2f2cc92d15 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 06:03:26 +09:00
eomsangdonandClaude Opus 5 1edde503fe feat(b09): 기성 단계 1벌 — 금회직접공사비 × 계약잡비율 · 계약/전회/금회/누계와 각 율 · 부가세 넷
- 계약내역·계약 원가계산서(같은 엔진에 계약 직접비)를 읽기만 해 파생 — 계약 불변
- 제잡비 줄마다 계약잡비율 = 도급액 ÷ 계약 직접공사비 (구조로 읽음, 확인 대기)
- 회차 목록 저장 · 기성(%) · 잔량 · 계약 수량 넘으면 알림
- 부가세: 직접입력 / 공급가액 / 재료비 / 재료비+산출경비 (설계 vat_base 같이 씀)
- 원가계산서 조립·계약본 받기 조각을 떼어 기성이 같은 길로 받음
- 기성 탭 파일 · 사전 키(등록은 서브) · 시험 6건 · 전체 1717 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 06:03:22 +09:00
eomsangdon ce5bfb7722 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-09-14 06:01:16 +09:00
eomsangdonandClaude Opus 5 650f660ed1 feat(b08): 옹벽 구멍 셋 — 터파기 기초분 · 원문 값 · 물빼기 칸이 표에 닿음(전후 대조)
① 터파기·되메우기·잔토 — 소광리 「옹벽2.0」에 줄이 없어 브레인 판정대로 기초 폭 + 양쪽 여유 0.3(같은 파일 식생옹벽블럭 기초 터파기)로 수직 · 깊이 기초 0.4 + 버림 0.1 + 기초잡석 · 비탈분 제외(지반선이 제원에 없음) · earthwork 로만
   m당 터파기 1.54 · 되메우기 0.36(터파기 − 기초·전단키·버림·잡석) · 잔토 1.18 · 인계 심도 구분은 판 깊이(0.7 m → 0~1m)
② 관측 값을 원문으로 — 콘크리트 1.35→1.345 · 버림 0.15→0.145 · 유로폼 3.2→3.205 ⇒ 기초잡석 0.30→0.29
③ 물구멍관 — 실무 관측 식(「옹벽2.0」 T31 벽 높이 ÷ 개소당 면적 × 관 0.4)에 제원 칸이 닿음 · 규격 Ø50 이 붙어 자재총괄에서 돌쌓기 물구멍관과 한 줄(33.924 + 3.2 → 37.124 m) · 관 길이는 관측값으로 표시
- 검증 프로젝트 내역 본체 122,848,989 → 124,202,681원(+1,353,692 = 구조물터파기 심도 0~1m 15.4㎥ 1,341,216 + 되메우기 21.0→24.6㎥ +12,476)
- 그림: 터파기 점선·「터파기 폭 2.20 × 깊이 0.70」 · 표 사유와 같은 말 · 옛 값을 박은 시험 8건을 원문 값으로(전 값은 주석)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 05:59:32 +09:00
eomsangdon 2ab3a65807 @
feat(b09): 실행예산 차액 보정 뜻을 화면에 — 기본보정은 「안 몰고 남김(뜻 확인 대기)」

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
@
2026-09-14 05:47:36 +09:00
eomsangdonandClaude Opus 5 87cab83c60 feat(b09): 실행예산 탭 등록 — 계약내역 뒤 맨 끝(설계 → 계약 → 실행 · 랩탑_메인 f1f8482e)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 05:38:33 +09:00
eomsangdon 914bbce74c Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-09-14 05:29:57 +09:00
eomsangdonandClaude Opus 5 f1f8482e7d feat(b09): 실행예산 단계 1벌 — 중기 실행단가(1단위당 사용료 ÷ 시간 · 성분 비율 · 절사 · 차액 보정) · 실행수량
- 단가표 복사본의 중기(X) 호표만 실행단가로 갈아 끼워 일위대가 다시 조립 — 설계·계약 불변
- 조립값과 다른 줄은 설계 단가 그대로 + 비고에 까닭
- 「기본보정」은 뜻 미확인 — 차액을 안 몰고 남김(확인 대기)
- 계약 모듈의 다시 조립·묶음 합 조각을 두 단계가 같이 씀
- 실행예산 탭 파일 · 사전 키(등록은 서브) · 시험 7건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 05:29:05 +09:00
eomsangdonandClaude Opus 5 5e983018a3 feat(b08): 구조물도 옹벽 단면 그림 — 관측 원단위 줄에 단면 치수 · 표 사유와 같은 말
- 반중력식 H=2.0 관측 줄에 단면(기초 1.6×0.4 · 벽체 1.6 상단 0.3 앞면 0.118 뒷면 0.032 · 전단키 0.35×0.3 · 버림 0.1 · 물빼기 관 0.4)을 실음 — 출처 소광리 07-구조도 「옹벽2.0」 산출식·일반도
- 그 치수로 표 수량이 다시 나오는지 시험(콘크리트 1.345→1.35 · 버림 0.145→0.15 · 유로폼 3.205→3.2 · 관 0.32)
- 그림: 기초·벽체·전단키 + 버림(키 자리 비움) + 기초잡석(산출 조건 두께 · 버림과 같은 폭) + 물구멍
- 표 사유에 새 한 줄 둘(그림도 같은 말): 관측 줄에 터파기·되메우기·잔토가 없음 · 물빼기 칸을 채워도 관측 값이 붙박이라 안 닿음
- 까닭은 표가 못 선 장만(형식 미정 · 자료에 없는 규격 · 단면 치수 없는 규격) — 표 사유와 같은 말

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 05:27:49 +09:00
eomsangdon bc1b2fdad0 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 05:10:51 +09:00
eomsangdonandClaude Opus 5 ff6672a13a feat(b09): 계약 단계 2벌 — 기초단가 적용(단가표 복사본 다시 조립) · 계약 일위대가/산출근거 W 호표 · 0% = 0 원
- 기초단가 적용: 자재·노임·중기 취득가 채택 단가에 적용률 — 복사본만, 설계 단가표 불변
- 조립값과 다른 줄(할증·수동 단가)·단가표 밖 코드는 성분 곱셈 + 비고에 까닭
- 계약 호표: 설계 본표와 같은 꼴(detail_of)로 W-B·W-D 따로 — 기초단가 적용과 함께만
- 0% 는 0 원(브레인 판정) · 공내역 생성은 그 줄 이름만 가름
- 시험 11건 · 전체 1699 통과(골든셋 포함)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 05:10:41 +09:00
eomsangdonandClaude Opus 5 31675aaf21 refactor(b08): 원단위 전개 파일 쪼개기 — UnitQuantity 1,646줄 → 네 파일(기능별 · 값 불변)
- _Base(110줄) 성분 계약·갈 곳(DESTINATION)·배합 성분(MIX_COMPONENTS) — 이중계상 경계
- _StoneSpec(525줄) 돌쌓기 제원·계수표·표준경사 판정·벽 두께·버림/기초잡석 상수
- _Masonry(523줄) 돌쌓기·큰돌쌓기 전개식(버림·터파기 줄)
- UnitQuantity(581줄) 종류→전개식 표·관측 원단위·딸린 줄·build_table — 옮긴 이름은 다시 내보내 부르는 쪽 44곳 그대로
- 코드 줄은 옮기기만 함 · 검증 프로젝트 응답 여섯(원단위·구조물 집계·구조물도·운반·인계·내역) 지문이 쪼개기 전과 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 05:06:53 +09:00
eomsangdonandClaude Opus 5 1b48a16a18 feat(b09): 계약내역 탭 등록 — 설계 뒤 계약 단계라 틀 맨 끝(랩탑_메인 26097d69)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 04:51:31 +09:00
eomsangdon 8646c56185 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 04:43:04 +09:00
eomsangdonandClaude Opus 5 a9ddcd5b1b feat(b08): 구조물도 그림 — 뒷길이 미정 장도 표가 선 기본값으로 그림 · 표 사유와 같은 말
- 뒷길이를 안 고른 돌쌓기 장: 표가 쓴 그 뒷길이(양식 장은 양식이 푼 값, 전개 장은 기본 45㎝)로 그림
- 「⚠ 뒷길이를 안 골라 기본 N㎝ 로 섰습니다」 한 줄을 한 곳에 두고 표 사유·그림이 같이 씀(골막이 문구도 같은 함수)
- 돌쌓기 표 사유에 그 한 줄이 새로 붙음 — 전에는 기본값인 줄 표에 안 드러났음
- 그림 까닭은 정말 못 그리는 장(큰돌쌓기 직경 축·옹벽)만

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 04:42:28 +09:00
eomsangdon 52771faefe Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 04:41:14 +09:00
eomsangdonandClaude Opus 5 26097d6922 feat(b09): 계약 단계 1벌 — 노·재·경 단가 적용률 · 행별 적용 제외 · 0% 공내역 · W 코드
- STmate wM_Mk_Cont(27번 §2) 본뜸: 단가 적용율 【노】【재】【경】 · 적용 옵션 여섯(화면 표기 그대로) · 적용제외 공정
- 설계 내역(/estimation/bill) 줄을 복사해 성분마다 적용률 — 절사는 설계 내역 규칙(bill_line) 그대로 · 설계 불변
- W 코드: 동일코드 한 벌(기본) / 개별생성 켜면 줄마다 · 적용 제외 줄은 설계 단가·설계 코드
- 0%: 「공내역 생성」 켜면 0 원 공내역 줄 · 끄면 적용 안 함(판정 대기)
- 뜻 미확인 옵션(노무비율)·아직 안 선 옵션(기초단가 적용·일위대가 생성·비과세자재)은 칸만 받고 까닭을 화면에
- GET/PUT /estimation/contract · 탭 파일 B09_Estimation_UI_Tab_Contract.ts(등록은 서브) · 사전 b4
- ⚠ 계약 표본 0건 — 구조가 서는지까지만 시험

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 04:41:10 +09:00
eomsangdon 415a76a97b Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 04:28:27 +09:00
eomsangdon 6926d7fd14 auto: 2026-09-14 04:26 (ESD_LAPTOP) 2026-09-14 04:26:48 +09:00
eomsangdonandClaude Opus 5 1d21253b7e feat(b08): 구조물도 상단 그림 — 돌쌓기 단면 SVG(읽기 전용) · 못 그리는 장은 까닭
- B07 표준도 그림 코드를 B08 로 옮겨 모양 목록(선·글자)으로 냄 · 화면이 SVG 로 그림(그림 6 : 표 4)
- 벽 두께를 수량표와 같은 함수로 — 상부·하부 두께를 넣어도 그림만 옛 두께이던 것 고침
- 터파기 점선을 수량식과 넓이가 같은 평행사변형으로(앞면 따라 기움)
- 큰돌쌓기(직경 축)·옹벽·뒷길이 미정 장은 그림 자리에 까닭 한 줄
- B07 CAD 표 조립은 같은 모양 목록을 CAD 엔티티로 옮겨 적음(CAD 편집 뒤 일감용)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 04:26:47 +09:00
eomsangdon 6a2c5aacae Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 04:18:58 +09:00
eomsangdonandClaude Opus 5 6683e85d94 feat(b09): 원가계산서 나머지 세 형식 서식 차례 — 수공·실적일반·실적수공 · 금액 없이 막힌 까닭
- 12번 §4~6 서식(35번 정정 반영) 차례·표기 · 줄마다 「공통 요율 / 요율표 미확보(RateB·RATE_RA·An_A) / 확인 대기」
- 확인 대기: 실적형 이윤 밑수(35번 §5-1) · 실적수공 안전관리비 존재(§5-2) — 값을 지어내지 않음
- 0 원으로 안 채움 — 금액 칸 「-」 + 까닭 · 머리에 「금액 미산출」
- 원가계산서 아래 형식 탭 넷을 눌러 바꿈(GET ?form=) · 기준 입력판은 그대로
- 시험: 금액·0원 없음 · 수공 경비 여덟 막힘 · 확인 대기 · 실적일반 보증·환경 밑수 · 모르는 형식 멈춤

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 04:18:54 +09:00
eomsangdon 63e715fd9f Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-09-14 04:08:04 +09:00
eomsangdon 0112f3fcab Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 04:08:03 +09:00
eomsangdonandClaude Opus 5 8046205039 fix(b09): 산업안전 50억 이상 구간 겹침 해소 · 하도급대금 지급보증수수료 칸(기본 꺼짐)
- 산업안전보건관리비: 고시 별표1 「보건관리자 선임 대상 건설공사의 적용비율」 열을 대상액이 아니라
  공사가 선임 대상인가(요율 데이터 manager_thresholds 800억 · 토목 1,000억)로 가름 — 두 줄이 겹쳐 멈추던 자리
- 하도급대금 지급보증수수료: 기본 꺼짐 — 실무 원가계산서 6건에 줄 없음 · 건설산업기본법 제34조 대상이면 켬
  (칸 밑에 「대상 아님인지 안 적음인지 못 가름」 사유)
- 시험: 50억 이상 2.60 / 선임 대상 2.73 · 하도급 꺼짐·켬 · 골든셋 초록

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 04:07:45 +09:00
eomsangdonandClaude Opus 5 b9d0ac0866 feat(b09): 2차 편집 ④ 내역 할증율·일괄보정 — 범위(선택 줄·내역서 전체) · 비목별 절사/반올림 · 소수 0~4 · 줄 ↺ · 할증 전부 되돌리기
- 서버가 내역 줄 성분 단가에 율을 얹어 다시 셈(줄 칸이 전체 칸보다 우선) · 수량은 그대로
- 공용 작은 버튼 글자색 상속 — 어두운 화면에서 검정 글씨로 묻히던 것

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 04:06:40 +09:00
eomsangdonandClaude Opus 5 8693cf0012 feat(b09): 원가계산서 기준 입력 13~16 — 사급비 위치 · 낙찰 · 이행보증 · 적용기준 · 이행보증 식 고침
- 13 사급비 위치: 재료비에 포함만(사급은 이미 내역 재료비 · 실무 거창·영월) — 칸만 받음
- 14 낙찰방식: 하도급대금 지급보증 요율 줄 — 종합심사 외(규모 구간) · 종합심사 대상(300억 이상 줄) · 턴키(0.084)
- 15 이행보증: 일반계약 = 추정가격 300억 이상만 공사이행보증수수료 · 최저가·기술제안 = 규모 무관
  (종전 늘 계산 — 제비율 기준 「소규모 통상 비대상」·실무 여섯 건 금액 없음과 어긋났음)
- 곁 고침: 70억 이상 구간 [기초액 + (직공비−기준액) × 율] × 공기 — 종전 백분율만 쓰고 기초액·기준액을 버렸음
- 16 적용기준: 칸만 받음(조달청 표 한 벌 · 제20조 국가 표 이미지 미확인 · 행자부 표 없음)
- 칸 밑 안내 · 시험: 300억 미만 안 섬 · 100억 식 965,000 · 요율 줄 셋 · 13·16 계산 불변 · 골든셋 초록

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 03:56:26 +09:00
eomsangdon d4ab146e02 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 03:38:00 +09:00
eomsangdonandClaude Opus 5 c3618fe872 feat(b09): 2차 편집 ② 일위대가·단산 구성행 수정 · ③ Q 식 편집 — 프로젝트 단위 · 서버가 다시 계산 · ↺
- 구성행(sheet_rows): 수량 고치기 · 줄 빼기(수량 0, 흐리게) · 줄 더하기(단가표 고르개) — 비율 줄·돌림 참조는 받지 않음
- Q 식(price_basis_q): 식은 명세 13장 식 언어 — B08 구조물도 풀이기(evaluate_sheets, Node 한 벌)를 그대로 부름 ·
  소수 2자리 사사오입 확정 → 새 수량 = 수량 × 옛 Q ÷ 새 Q · 비고에 「Q(사용자) = 식 = 값」
  · PriceDetail.output(시공능력 Q)을 Q 로 선 장비 줄 넷 자리(굴착기·도자·직접 작업량·암 잎)에서 실음 · 저장 모양에도 실음
- 따로 짰던 파이썬 식 셈(B09_Estimation_Expression)은 걷어냄 — 식 풀이 두 벌 금지(브레인 판정)
- 새 문: GET /estimation/edits/sheet/{code}(본표 + 줄마다 고친 값 표시) · GET /estimation/edits/search(줄 더하기 고르개)
- 화면: 본표 [편집] — 수량 칸 · ✕ · Q 식 칸 · 줄 더하기 · 고친 줄 「사용자」 + ↺
- 검증: 시험 1664 통과 · 골든셋 초록 · ORCA — 제 3 호표 보통인부 0.023→0.046 이면 5,652→9,610 · 내역 본체 122,848,989→122,857,198,
  ↺ 뒤 5,652 · 산근 2호표 Q 58.21→116.42 이면 1,695→847, ↺ 뒤 1,695 · 틀린 식 422 「모르는 이름: abc」 · 고친 값 {} 로 복구

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 03:37:18 +09:00
eomsangdonandClaude Opus 5 b02c51db71 refactor(b09): 라우터 쪼개기 — 산출 조건 문(GET/PUT /estimation/factors)을 Router_Factors 로 · Router.py 842 → 537줄
- get_bill · _project_root_of · _build_for 는 Router.py 에 그대로(랩탑_메인 CostSheet import 그대로)
- 새 파일은 본 라우터의 _project_root_of·_build_for 를 부를 때마다 빌려 씀 — 시험이 본 라우터 것을 바꿔 끼움
- 검증: 시험 1666 통과 · 골든셋 초록 · 재시작 뒤 B09 문 열하나(factors·base-data·price-sources·basis-sheet·machine-expense·
  design-doc-index·bill·unit-prices·edits·cost-sheet·PUT factors) 전부 200

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 03:24:53 +09:00
eomsangdon 35abb49d3c Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 03:24:36 +09:00
eomsangdonandClaude Opus 5 f704bc3bf8 feat(b09): 요율 덮어쓰기 — 프로젝트 단위 · 칸마다 사유 · 기본값↔고친 값 · ↺ 되돌리기
- estimation.rate_overrides 에 칸 주소(variable·table·match)·요율·사유·입력 시각을 한 줄씩 — 마스터 요율 파일은 안 건드림
- 엔진이 계산할 때 복사본에만 얹음 · 덮어쓰기 없으면 원본 그대로(골든셋 초록) · 원가계산서 비고에 「요율 덮어쓰기 N건 — 사유」
- 요율표: 칸마다 기본값·지금 값·주소 · [수 정]에서 입력 · 고친 칸 테두리 + (기본 X) · ↺ · 고친 요율 목록에 사유 칸(비면 저장 안 됨)
- PUT /estimation/rate-table: 사유 빈 줄·틀린 주소·같은 칸 두 번·음수면 아무것도 안 저장 · 값·사유 같으면 입력 시각 유지
- 판이 바뀌어 주소가 안 맞으면 조용히 버리지 않고 알림
- 시험: 마스터 불변 · 계산에 걸림 · 한 값짜리 · 거름 · 주소 틀리면 멈춤 · 화면 주소 왕복

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 03:24:33 +09:00
eomsangdon 6cfcafc2f4 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 03:16:14 +09:00
eomsangdonandClaude Opus 5 9cf01ab15c feat(b09): 2차 편집 ① 자재단가대비표 채택 바꾸기 — 프로젝트 단위 고친 값(estimation.edits) · 서버가 얹어 다시 계산 · ↺
- B09_Estimation_Edits: 고친 값 한 벌 — 기본 조립(cached_build) 복사본에 얹음(캐시 키에 고친 값) · 기본 벌은 그대로(골든셋 무관)
  · 얹지 못한 값은 edit_skipped 로 남김 · 값 없는 슬롯은 받지 않음(422 + 까닭)
- 새 라우터 B09_Estimation_Router_Edits(GET/PUT /estimation/edits) — Router.py 는 _build_for 가 고친 값을 얹게만 바꿈
- 화면: 줄마다 채택 슬롯 고르개 · 일괄(변동없음/1~5 단가/최소단가) · 고친 줄 「사용자」 + ↺(지우면 계산값으로)
- 곁: 사용자 식 셈(B09_Estimation_Expression — ROUND·ROUNDDOWN·INT·SQRT, eval 없음)과 본표 편집 칸(UI_DetailEdit)을 먼저 둠 — 서버 편집 문이 서기 전까지 잠자 있음
- 검증: 시험 1656 통과 · ORCA 채택 저장 → 「사용자」·↺ → ↺ 뒤 고친 값 {} 로 복구 · 값 없는 슬롯 422 「경유: 1번 원천에 값이 없어」

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 03:16:10 +09:00
eomsangdon 267bad1dfd Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 03:01:59 +09:00
eomsangdonandClaude Opus 5 72046265fc feat(b09): 원가계산서 기준 입력 4·5 — 고용보험 등급 · 퇴직공제 적용 · 폐기물 「승률 밖」 자리 · 칸 밑 안내
- 4 고용보험료: 자동(추정금액 구간 · 종전) · 없음 · 1~7등급 직접
- 5 퇴직공제부금비: 자동(추정금액 1억 이상 · 종전) · 적용 · 미적용 — 토목·준설·건축·기타는 현행 제비율에서 율이 같아 칸 안 둠
- 폐기물처리비 자리: 경비·승률 안(법 문언 · 기본) / 이윤 뒤·총원가 안·승률 밖(실무 관행 울진소광·실정보고)
- 칸 밑 안내: 절사 기본 「안 함」과 실무 3건(총공사비 천원 미만 + 이윤 자동보정) · 폐기물 법/실무 갈라 적음 · 주/전문 규정 없음
- 시험: 등급 1.57% · 없음 · 없는 등급 멈춤 · 퇴직공제 1억 미만 적용 · 승률 밖 자리 · 골든셋 초록

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 03:01:54 +09:00
eomsangdon 3a250fb055 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 02:58:02 +09:00
eomsangdonandClaude Opus 5 964375d2a7 refactor(b09): 옛 화면 파일 셋 지움 — UI_Page(1,561줄) · UI_BaseData(1,161줄) · UI_Tab_Legacy
- 옛 탭 여섯은 모두 새 틀 탭 파일로 옮겨짐(설계서 구성 · 산출기초 · 관급·사급 · 중기경비계산서 · 기초자료, 원가계산서는 랩탑_메인)
- 화면 확인(지운 뒤 13 탭 전부): 설계내역서 53줄 · 본체 122,848,989 · 일위대가 18 · 산근 10 · 중기 표 19·줄 171 ·
  관급·사급 13 · 기초자료 표 6·줄 737 · 설계서 구성 13 · 산출기초 표 3·줄 192 — 옮기기 전과 같음 · tsc 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 02:56:27 +09:00
eomsangdonandClaude Opus 5 ef48e6edd3 refactor(b09): 옛 탭 옮기기 ⑤ 기초자료 — 산출 조건(UI_Factors) · 목록표 셋 · 자재단가대비표 · 환율및기초자료를 새 틀 탭 파일로
- 저장하면 이 탭 자료와 설계내역서 한 벌(UI_Store forgetBill)을 함께 새로 받음 — 값이 다시 섬
- 화면 확인: 옮기기 전후 같음 — 표 6 · 줄 737 · 고르개 32 개 고른 값 전부 같음 · 숫자 칸 2 · 글 35,074 자
- 계산 입력 확인: 기계 작업효율 E 평균 0.50 → 상한 0.55 를 화면에서 고르면 「사용자가 고른 값」 · 제 2 호표 2,511 → 2,284 ·
  되돌린 뒤(기본값) 2,511 그대로 — 검증 프로젝트 설정 원래대로 복구

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 02:47:03 +09:00
eomsangdon fb2de4a679 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 02:43:18 +09:00
eomsangdonandClaude Opus 5 fb4eb59cdb feat(b09): 원가계산서 기준 입력 10·11·12 — 일반관리비 주/전문 · 절사 · 부가세 방식
- 10 일반관리비: (주)공사/전문공사 = 요율 구간표만 갈림 · 밑수는 형식 칸(일반 = 순공사원가 + 관리품목자재대)
- 11 절사: 총공사비/공급가액 N원 미만 — 고른 때만 이윤 자동보정(총공사비·공급가액 비례 부가세면 ÷1.1 반올림, 실무 봉화 373→339·영월 695→632, 산림조합은 차액 그대로) · 잔차 맞춤
- 12 부가세: 공급가액·재료비·없음·산림조합 형식·산림조합-면세품 (칸 이름은 기성 선택지를 받을 수 있게 문자열)
- 기본값은 종전 계산과 같음 · 자동보정 줄은 「… 절사 자동보정」으로 적음
- 원가계산서 탭: 10·11·12 고르개 · 관리품목 자재대 · 면세품 금액 칸
- 시험: 기본값 불변 · 밑수·표 갈림 · 실무 절사 식 · 끝자리 · 골든셋 초록

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 02:43:14 +09:00
eomsangdonandClaude Opus 5 71d643b456 refactor(b09): 옛 탭 옮기기 ④ 중기경비계산서 — 옛 BaseData 에서 UI_MachineExpense 로
- 화면 확인: 옮기기 전후 같음 — 중기 탭 표 19 · 줄 171 · 계산서 18 장(불도저 19 손료계수 1811 · 시간당 손료 35,884.965)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 02:05:34 +09:00
eomsangdonandClaude Opus 5 3327357f2e refactor(b09): 옛 탭 옮기기 ③ 관급·사급 — 새 틀 탭 파일로(내역 응답 한 벌을 봄)
- 화면 확인: 옮기기 전후 같음 — 사급 0 · 관급 0 · 안 갈린 것 13줄

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 02:03:39 +09:00
eomsangdonandClaude Opus 5 baeaa26702 refactor(b09): 옛 탭 옮기기 ② 산출기초 — 새 틀 탭 파일로
- 화면 확인: 옮기기 전후 같음 — 표 3 · 줄 192 · 판 12 · 근거 공종 92 · 못 채운 자리 88

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 02:01:18 +09:00
eomsangdonandClaude Opus 5 800cb73be0 refactor(b09): 옛 탭 옮기기 ① 설계서 구성 — 새 틀 탭 파일로 · 옛 표 도우미(head·note·표·근거 호버)를 UI_Table 로
- 화면 확인: 설계서 구성 13줄 · 요약·법 문구 그대로

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 01:59:50 +09:00
eomsangdon 4c6268ffe8 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 01:57:42 +09:00
eomsangdonandClaude Opus 5 9158a88642 knowledge(축C): 집계표 단가 열·중기 반올림 정정 — 골든셋 실측
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-14 01:57:00 +09:00
eomsangdonandClaude Opus 5 3ce997fa69 feat(b09): 표 화면 넷 — 중기(목록표·중기사용료) · 자재단가대비표 · 집계표 넷 · 목록표 일곱
- 서버: 내역 응답에 집계표 넷(resources)·목록표(lists) — 내역이 쓴 자원을 처음 쓰인 차례로 되모음
  (묶음 줄·구조물도 호표 줄도 구성까지 풀어 셈) · 재료비 집계표에 자재대(사급·관급) 줄 이어 붙임
- 중기시간금액집계표: 단가 열 없이 합계·노무·재료·경비 — 성분마다 반올림한 뒤 합(골든셋 131/144, 합계 한 번 반올림 107/144)
- 재료·노무·경비 집계표: 반올림(수량 × 단가) 골든셋 279/279 — 시험 한 벌 더함
- 중기사용료 호표: 잡품 줄은 수량 칸 율(%) · 단가 칸 밑수(주연료비) · 금액 — 실무 표 그대로. 제잡비·공구손료 줄도 밑수를 단가 칸에
- 자재단가대비표: 슬롯 1~6 단가·페이지 · 채택 칸 굵게 · 최소단가 표시(서버 min_slot)
- 목록표 일곱(일위대가·단가산출근거·중기·재료비·노무비·경비·일식견적): 색인만 — 서버 번호·단가 그대로, 일위대가·산근·중기 줄은 누르면 들어감
- 옛 중기 탭 줄을 새 탭으로 바꿔 끼움(중기경비계산서는 옛 표를 아래에 이어 보임)
- 검증: ORCA — 중기 목록 9 · 굴착기 0.7 호표 합계 109,704 · 잡품 22 % × 21,418.1 = 4,711.9 · 노무 집계 벌목부 9,686,529 · 중기 집계 성분 합 · 시험 1638 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 01:56:02 +09:00
eomsangdon 155903306e Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 01:42:16 +09:00
eomsangdonandClaude Opus 5 a68a5e0214 fix(b09): 원가계산서·요율표 탭 단추 variant 를 ghost 로 · 「금액을 못 세운 줄 N건」·「미확정 N건」 표현을 내역서와 한 벌로
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 01:42:12 +09:00
eomsangdonandClaude Opus 5 b7af20f76d feat(b09): 틀에 원가계산서·제비율 요율표 탭 등록 — 랩탑_메인 탭 파일(cb33e81d), 옛 원가계산서 줄 바꿔 끼움
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 01:40:16 +09:00
eomsangdon 3057a751da Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 01:33:45 +09:00
eomsangdonandClaude Opus 5 cb33e81d98 feat(b09): 원가계산서(일반형식)·제비율 요율표 탭 파일 — STmate 배치 · 값은 우리 엔진
- 서버: /estimation/cost-sheet(GET·PUT) · /estimation/rate-table — 새 라우터 파일
- 직접비 = 설계내역서 합계 · 기준 입력 = estimation.cost_sheet · 폐기물처리비 = 임목폐기물 톤 × 수동 단가
- 서식 줄: 나.순공사원가 … 바.총공사비 (DFM TWM_KANJUB 차례·표기) · 경비 세목 번호 차례
- 기준 입력판: 형식(일반만) · 2.공사의종류 · 3.공사기간 · 6.관/사급 · 7.산업안전 · 8.환경보전 · 9.건설기계 — 선택지는 요율 데이터에서
- 요율표: 첫 탭 묶음 10개(보기 전용 · 수정은 잠김)
- 탭 계약 파일은 서브 314c0040 과 같은 내용(blob 동일) · 사전은 새 벌 b4
- 시험: 서식 차례·엔진 값 일치·폐기물 자리·저장값 거름·구간 표기

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 01:32:30 +09:00
eomsangdon 243c6eb3a9 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 01:31:56 +09:00
eomsangdonandClaude Opus 5 85f7c9c6b3 feat(b09): 원가계산 화면 새 틀 — 설계내역서·일위대가·단가산출근거 탭(실무 서식 · 내역 → 호표 → 산근 들어가기)
- 틀 B09_Estimation_UI_Shell: 탭 줄과 등록만 · 탭마다 파일 하나(계약 _Shell_Types)
- 설계내역서: 실무 열(합계/노무/재료/경비 단가·금액) · 머리글 접기·레벨 고르개 · 줄 누르면 제 N 호표 · 단산 N 단추 · 미확정 빨간 테두리
- 일위대가·단가산출근거: 목록표(내역에 처음 쓰인 차례) + 본표 · 줄 누르면 하위 호표·산근·중기로 · 자취 눌러 되돌아감 · Q 식 글자 그대로
- 옛 탭 여섯(원가계산서·중기·관급사급·기초자료·설계서 구성·산출기초)은 옛 코드 그대로 이어 붙임 — 새 탭 파일이 서면 등록 한 줄씩 바꿈
- 본표 합계 줄 = 호표 성분 소계 원 미만 절사 값 · 비율 줄 금액도 0.1원 절사 표시
- 사전 ui_template_locale_b3 새 벌(b2 700줄 넘음)
- 검증: ORCA 검증 프로젝트 — 본체 122,848,989 · 지장목제거 865·15,460,837 · 제 9 호표 합계 1,708 = 산근 8호표 합계 1,708 · 옛 탭 여섯 다 뜸 · 접기 53→51·레벨1 11줄

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 01:30:01 +09:00
eomsangdonandClaude Opus 5 509660708b fix(b08): 뿌리분 직경 칸 이름에 「흉고직경 아님」 명시 — D 는 뿌리분 직경(2026-09-14 판정)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 01:10:58 +09:00
eomsangdon 3ab63eb29c auto: 2026-09-14 01:10 (ESD_LAPTOP) 2026-09-14 01:10:55 +09:00
eomsangdonandClaude Opus 5 314c00406e feat(b09): 화면 틀 ↔ 탭 파일 계약 — B09Tab · B09TabContext (탭마다 파일 하나 · 틀은 등록만)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 01:10:54 +09:00
eomsangdon 7b0a48f0cd Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 01:09:38 +09:00
eomsangdonandClaude Opus 5 35ba45f572 feat(b08): 임목폐기물 뿌리 산정법 고르개 · 지침 π 3.14 · 분리발주 대상 아님 안내
- 환경친화적인 도로건설 지침 부록1 확인: 뿌리분 체적 V = 0.3927D³ × 1,300㎏/㎥ — 고르개로 넣음(기본은 분배비 15/85)
- 뿌리분 직경 D 는 지침 표기 「뿌리분의 직경(DBH, m)」이 모호해 흉고직경과 따로 받음
- 지상부 식을 지침 원문대로 π 3.14 로 셈 — 실정보고 예 130.40㎥ 와 바로 맞음
- 분리발주 칸 밑·사유에 「임목폐기물은 분리발주 대상 아님(환경부 회신)」

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 01:09:23 +09:00
eomsangdon bdc08c3251 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 00:58:35 +09:00
eomsangdonandClaude Opus 5 1733bb6aac knowledge(축C): 작업 폴더에 먼저 들어온 명세 정정 반영 — dev 와 같은 내용(받기 막힘 풂)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 00:58:34 +09:00
eomsangdon ae9578a6dc Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 00:57:14 +09:00
eomsangdonandClaude Opus 5 7d856f37a3 feat(b08,b09): 폐기물처리비 — 경비 비목(일반관리비·이윤 밑수) · 임목폐기물 톤 · 분리발주 칸
- 원가계산서: 폐기물처리비를 경비 줄로(예정가격작성기준 제19조③18호) · 법정경비 뒤라 그 밑수엔 안 섞임
- 분리발주 칸(기본 아님) — 켜면 총원가 밖 · 총공사비에만 더함
- 준비공 「임목폐기물 처리」 톤: WA = 0.5·π·(B/2)²·h·1.3·W1·N · WR = WA × 15/85 (한국건설기술연구원 2012)
- 조사값 넷(1,000㎡당 본수·흉고직경·수고·단위체적중량) 칸 · 기본값 없음 · 5톤·100톤 경계 알림
- 수동 처리단가 빨간 테두리 + 「미확정 N건」 · 내역엔 안 서고 제외 사유 「경비」
- 시험: 승률 구조(경비·일반관리비·이윤 밑수 증가 · 법정경비 불변 · 분리발주) · 실정보고 부피 대조

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 00:55:41 +09:00
eomsangdon 54a17580da Merge remote-tracking branch 'origin/sub_laptop_1' into main_desktop_1 2026-09-14 00:55:31 +09:00
eomsangdon 49df2f10ef Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-14 00:55:27 +09:00
eomsangdonandClaude Opus 5 fc649316c6 feat(b09): 일위대가 호표 성분 소계 절사 · 내역 줄 성분별 절사 — 골든셋 네 벌 다 섬
- TRUNCATED_KINDS 에 UNIT_PRICE(B) 더함 · 비율 줄(공구손료·제잡비·품 할증)도 0.1원 절사
- 내역 줄 bill_line — 성분마다 절사(수량 × 성분 단가), 합계 = 셋의 합 · 직접비 밑수도 자른 성분
- 묶음 줄·구조물도 일위대가 표(하위 양식 포함)도 같은 규칙 — 성분 소계 원 미만 절사
- 골든셋 ① 일위대가표 성분 소계 477/510(93.5%) · 안 맞는 19 호표 = 영월 반올림 원본 17 · 영덕 적용률 줄 서식 2
- 골든셋 ① 설계내역서 줄 321 전수 일치
- 검증 프로젝트 내역 본체 122,870,975 → 122,848,989 · 전체 시험 1614 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 00:53:31 +09:00
eomsangdon 0cf8b41823 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 00:27:40 +09:00
eomsangdonandClaude Opus 5 d6916225de feat(b08): 노체다짐 칸 — 기본 꺼짐 · 켜면 토공집계 별도 줄(수량 = 성토량)
- 산출 조건 subgrade_compaction_enabled · 기본 False(9-16-2 [주]⑤ 「대규모 성토지로서 층다짐이 필요한 경우」 조건부)
- 토공집계 「노체다짐」 줄 · 규격 진동롤러(자주식 10ton) · 매핑 FP-09-16-02 · 갈래 없음
- [주]③ 굴착기+진동롤러 조합 중 굴착기는 성토(포설) 줄이 셈 — 두 번 안 셈(#조합 철회)
- 칸 밑에 [주]⑤ 근거

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 00:27:29 +09:00
eomsangdonandClaude Opus 5 af23a5d209 feat(b09): 단가산출 Q 식 — f·Q 소수 2자리 확정 뒤 나눔 · D 줄 0.1원·머리 원 미만 절사
- fix2(사사오입 0.01) — 굴착기·도자·다짐 작업량, 암 평균 Q, 표 직접 작업량
- TRUNCATED_KINDS 에 PRICE_BASIS(D) 더함 — B 는 다음 차례
- 골든셋 ③ 단가산출근거 실무 6건 208 호표 전수 일치(제외 소계·% 줄·산근 참조·계약단가 읽기) · 회귀 3,545.5 → 3,545
- 검증 프로젝트 내역 본체 122,924,846 → 122,870,975 · 전체 시험 1606 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 00:26:45 +09:00
eomsangdonandClaude Opus 5 9f72ad19b8 feat(b08): 표토제거 대상 고르개 — 노면 + 절토 사면(기본 · 법 문언) / 노면만
- 산출 조건 칸 topsoil_target · 기본 None = 별표2 Ⅰ.2.차.(6) 「노면·절토대상지」
- 노면만: 실무 영월 한 건(1m당 3.34㎡) 관측 — 사유에 법 문언이 아님을 함께 적음
- 칸 밑에 근거 두 줄 · 운반 부피도 고른 대상 따라감

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 00:14:46 +09:00
eomsangdon e5295dc181 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 00:03:10 +09:00
eomsangdonandClaude Opus 5 b876ba9c14 refactor(b09): 단가 층 차례를 X → D → B 로 — 금액 불변, 자원 집계 버림 결함 고침
- 시공능력 Q 로 선 기계 줄(굴착기·도자·직접 작업량·다짐·암 잎)은 D(단가산출)에 달고 B 는 D 를 수량 1 로 부름
- 종전 PriceBasis 의 껍데기 D(D → B) 걷음 — 「단산 N」은 D 를 품은 줄에만(갈래 줄도 실제 단가 코드로 찾음)
- 자원 집계가 B→B·B→D 참조를 끝까지 풀어 기계 사용료에 닿음(종전 조용히 버림) · 모르는 종류는 드러냄
- 조합 16% 바꿔 달기·공종 근거 문구가 D 줄도 봄
- 전후 대조: B·X 420 제목 금액 전부 같음 · 검증 프로젝트 내역 본체 122,924,846 그대로

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 00:01:30 +09:00
eomsangdonandClaude Opus 5 82755260e2 feat(b08): 표토제거 실무대로 — 노면 + 절토 사면 ㎡ · 운반은 20 m 넘는 몫만 · 층따기 제안값 경고
- 표토제거: 답외구간 FP-09-15-02 · 밑수 ㎡ · 대상 노면·절토대상지(성토 사면 안 셈) · 노면 폭이 빈 측점이 있으면 안 섬
- 표토 운반·적치: 제거 ㎡ × 두께 · 거리 − 압토 20 m · 20 m 이내면 제거 품에 듦
- 층따기: 「제안값(확정 아님)」 경고 줄 · 칸에 자동으로 안 넣음
- 시험: 옛 ㎥ 기대값 넷 갱신 · 노면 면적 평균단면적 시험

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 23:58:38 +09:00
eomsangdonandClaude Opus 5 dfa99ed594 knowledge(축C): D 는 Q 쓰는 자리에만 섬 · 내역 코드 축은 B 하나
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 23:39:02 +09:00
eomsangdon c919a982b4 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 23:34:39 +09:00
eomsangdonandClaude Opus 5 7bd1dc1e3a feat(b08): 도자 한계거리를 산출 조건 칸으로 — 기본 60 m 와 근거 셋을 화면에 같이 보임
유토곡선 장비 경계의 도자 한계거리가 config 붙박이였고 설정 칸 haul_limits_m_override 는 아무도 안 읽었음.
dozer_haul_limit_m 칸으로 갈음해 B06 유토곡선 문맥·배분·B08 산출 조건이 같은 값을 읽음(common_util 한 곳).
화면에 기본값 60 m · 근거(품셈 8-1-1 · 산림과임업기술 5장 · 실무 EARTH.DAT 전수)를 보이고, 종무대 20 m 는 규정이라 값만 보임.
종무대 이하 값은 저장에서 막음 · 비우면 기본값.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 23:33:10 +09:00
eomsangdonandClaude Opus 5 50c3fb65f5 fix(b09): 중기 호표 잡품을 주연료비 × 율 가산 행으로·성분 소계 절사 — 골든셋 중기 143 호표 전수
- 잡품을 연료 수량에 접지 않고 주연료비의 % 가산 행으로(STmate 잡품 단가 칸 = 연료 금액)
- 시간당 중기사용료(X) 호표 — 줄 금액 0.1원 미만 · 성분 소계 원 미만 절사(명세 7장, 봉화 0.7㎥ 96,843)
- 조종원 시간당 노임 자르는 자리를 프로젝트 설정 operator_wage_digits 로(기본 0, 화면은 뒤)
- 골든셋 ④ 벌 — 실무 6건 중기사용료 시트 143 호표를 우리 단가표 층으로 되풀어 전수 일치

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 23:29:16 +09:00
eomsangdon 7d7b9cf8fa Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 23:14:35 +09:00
eomsangdonandClaude Opus 5 219c7068a9 knowledge(축C): 노임 환산 자릿수는 프로젝트 설정 — 18/18 원 단위는 틀린 전제
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 23:14:13 +09:00
eomsangdonandClaude Opus 5 fd30227b73 fix(b09): 조종원 시간당 노임을 식 좌→우 순차 + 절사로 — 골든셋 시험 첫 벌
- 계수로 미리 접으면 267,360 → 55,699 로 1원 틀림 — 식 1/8*16/12*25/20 을 차례로 풀어 55,700
- 중기 호표 운전원 줄은 시간당 노임 제목(#시간) × 인수로 부름(STmate 운전원 1 × 55,700 모양)
- 골든셋 시험 test_b09_golden_stmate — 실무 6건 환율및기초자료 시간당 칸 전수 재현
  (다섯 건 원 미만 절사 · 2025 울진 소광 0.1원 미만 절사 — 자리는 설정값)
- 28자리 끝 반올림 차례만 다른 합 비교 시험을 허용 오차로

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 23:13:20 +09:00
eomsangdon f296b8c88c Merge remote-tracking branch 'origin/안티그래비티' into main_laptop_1 2026-09-13 23:13:12 +09:00
eomsangdonandClaude Opus 5 5e76850d3b feat(axis): 마스터 갈래 키를 표 모양대로 — 단가표 갈래 제목 294 중 294 가 키에 있음(종전 86)
종전 variant_key 는 표 첫 열 값 24개라 머리행 갈래·두 단 머리를 놓치고 첫 열이 자원 이름인 표를 갈래로 적었음.
표 모양 가르기는 B09 표 읽기에 이미 있어 두 벌을 안 짜고, 그 읽기를 메모리의 마스터에 돌려 표마다 가른 갈래를 원문 표기로 실음.
못 가른 표는 빈칸. 공종마다 variant_keys(불도저 공식 갈래·합산형 단계 암질·[주]① 평균 · 부모는 단계 갈래).
짝 시험 — 단가표 #갈래 제목이 전부 마스터 키에 있어야 함.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 23:11:42 +09:00
eomsangdonandClaude Opus 5 70885683eb knowledge(축C): 층 차례 정정 — D 가 먼저 서고 B 가 D 를 참조
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 22:57:57 +09:00
eomsangdon 61e743451f knowledge(원가): 예정가격작성기준·임목폐기물 산출근거 편입 2026-09-13 22:57:51 +09:00
eomsangdon edd9369e3b Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-13 22:56:11 +09:00
eomsangdon 0713dbefb0 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 22:56:10 +09:00
eomsangdonandClaude Opus 5 fb3442709f feat(axis): 이중계상 ④ — 양식 호표가 품은 성분을 모음 줄이 또 세면 멈춤
기초잡석 줄·콘크리트 타설 줄은 여러 구조물 성분을 이름으로 모아 한 줄로 세워 누구 것을 뺐는지 안 남김.
그래서 물량 보존으로 봄 — 모음 줄 + 양식 호표(B-AX-ST)가 품은 몫 + 묶음 조각이 원단위 합을 넘으면 오류.
빌더의 건너뛰기를 따라 짜지 않아, 다른 구조물에서 건너뛰기를 빠뜨리면 여기서 걸림. 배정 프로젝트 어긴 자리 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 22:55:43 +09:00
eomsangdonandClaude Opus 5 165bfed0a8 feat(b09): 갈래 느슨한 맞춤·수량 자리 못 맞춤을 로그로 — 조용히 다른 갈래에 붙는 자리 드러냄
- find_variant_code 가 글자 대신 수의 짝·구간으로 고른 자리를 한 번씩 로그(명세 14장 정정, 판정 (나))
- 수량 소수자리는 이름 그대로 두고 종목 이름을 못 맞춘 줄만 로그

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 22:55:27 +09:00
eomsangdonandClaude Opus 5 c34ab96763 knowledge(축C): 규정 없는 단수처리는 소수 2자리 기본값 — 법적 근거 없음 확인
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 22:54:34 +09:00
eomsangdon d56fa18971 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 22:47:35 +09:00
eomsangdonandClaude Opus 5 4ca757fadc knowledge(축C): 14장 정정 — variant_key 는 첫 열 24개뿐이라 정본으로 못 씀
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 22:46:56 +09:00
eomsangdonandClaude Opus 5 7bf5cbd1d1 fix(b09): 내역서·단가산출서도 프로젝트가 고른 단가표로 — 구조물도 미리보기와 한 벌
- 내역서 창구가 기본값 cached_build() 대신 _build_for(프로젝트) 를 씀 — 계수를 고른 프로젝트에서 화면과 내역이 갈리던 자리
- 단가산출서 한 장 창구도 같은 단가표

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 22:45:45 +09:00
eomsangdon 679f54853c Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 22:41:59 +09:00
eomsangdonandClaude Opus 5 66d57bb630 feat(axis): 품셈 판 pum_edition 칸 — 자원·공종 쪽, 판이 다르면 값을 안 씀
공종 마스터·자원 축 줄·B08 인계 줄마다 공종 코드가 본 판을 실음(명세 17장 규칙 3).
판이 다른 인계 줄은 내역서에서 「확인 대기」로 빠지고, 코드에 박힌 절 번호(합산 단계·표 형태 판정·가드 목록)는
마스터 판과 다르면 마스터 빌드·단가 조립을 멈춤(규칙 4). 판 기준은 common_util_pum_edition 한 곳.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 22:41:47 +09:00
eomsangdon e88877d81e Merge remote-tracking branches 'origin/CODEX' and 'origin/안티그래비티' into sub_laptop_1 2026-09-13 22:37:41 +09:00
eomsangdonandClaude Opus 5 41e0b5b7ea fix(b09): 구조물도 호표 내역 단가를 호표 계금으로 — 화면과 1원 갈림 없앰
- 내역 단가 = 구조물도 일위대가 표 계금(성분 칸 0.1원 절사 합의 1원 절사), 금액 = 성분 소계 × 연장
- 명세 7장 「호표 안 성분 소계는 절사」 — 안 자른 값을 쓰면 192,429 ↔ 192,430 으로 갈렸음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 22:35:41 +09:00
eomsangdon 846c4bba57 knowledge(법률): 폐기물 관계 법령 원문 편입 2026-09-13 22:30:15 +09:00
eomsangdon d1958d067b Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 22:20:33 +09:00
eomsangdonandClaude Opus 5 c2e3c4cda2 feat(b09): 구조물도 일위대가를 내역으로 받는 문 — 호표 B-AX-ST-<8hex>#<제원 키>
- 양식 일위대가로 셀 구조물도 장은 인계 줄 하나(코드 AX-ST · 갈래 = 제원 키 · 수량 연장 m)
- 양식이 품은 줄(기초잡석)은 따로 인계 안 함 — 이중계상 막음
- B09 내역이 자기 단가표로 B08 일위대가 엔진을 돌려 금액 · 못 받거나 막히면 사유
- 수동 단가도 씀 — 내역 요약에 미확정 N건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 22:20:26 +09:00
eomsangdon 6634e21713 Merge remote-tracking branches 'origin/CODEX' and 'origin/sub_laptop_1' into main_laptop_1 2026-09-13 22:19:58 +09:00
eomsangdonandClaude Opus 5 938eef94bd feat(axis): 이중계상 경계를 갈 곳 칸으로 판정 — 어기면 내역서가 멈춤
① 구조물 터파기·되메우기는 토공집계로만 — 토공·사토 공제·참고로 가는 성분을 구조물 줄·묶음 조각·타설·기초잡석이 또 집으면 오류.
② 시멘트·모래·자갈이 자재총괄에 뜨면 오류. ③ 원단위표가 할증을 붙였거나, 자재 합계가 순수량 × (1+율) 과 다르거나,
할증 포함 재료량을 준 공종에 자재가 일위대가 재료비로 붙으면 오류.
판정은 B08 인계(double_count_violations), 멈춤은 B09 build_bill 한 자리. 배정 프로젝트 실측 어긴 자리 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 22:18:00 +09:00
eomsangdonandClaude Opus 5 44b20ed21f fix(b09): 내역 받는 문 이름 맞춤 걷기 — 운반 줄 단가를 그 줄에서, 총 절취량을 코드로
- 운반 줄 단가를 이름으로 되찾던 자리를 인계 줄 → 내역 줄 짝으로(도자운반 토사·리핑암 같은 이름 실결함)
- 총 절취량 검산을 줄 이름 「깎기」·「절취」가 아니라 코드 9-3·9-4·9-5 로 셈

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 22:12:17 +09:00
eomsangdon fd4f00ab7e Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-09-13 21:57:22 +09:00
eomsangdon ec194ee05b Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 21:57:21 +09:00
eomsangdonandClaude Opus 5 cacbb08f10 fix(axis): 미판정 표 형태를 사람이 가름 — 66표 판정·까닭 한 줄, 찰쌓기(인력) 막힘 풀림
자동 판정이 못 가른 76표 중 64표와 오판 2표(13-4-4 뒷길이 표준·표준경사)를 표 머리·바로 위 본문 단위로 가름.
생산량형 8 · 소요량형 1 · 계수 23 · 참조 34. 못 가른 13표는 미판정으로 남김.
표 번호와 절 번호를 함께 대조해 원천 차례가 밀리면 빌드를 멈춤. 일위대가 금액 변화 없음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 21:56:39 +09:00
eomsangdonandClaude Opus 5 46b582762c feat(b08): 구조물 집계표 칸 고치기 — 정본에 바로 쓰고 손댄 칸을 표시
- 칸 조작은 캐시(sessionStorage)에, [저장] 때 structures.json·pipe_points.json 에 바로 씀
- 산출 조건에 손댄 칸 표(고치기 전 값) · ↺ 로 되돌리면 자동으로 돌아감
- 상세 칸만 고침 — 자리·길이·높이·관경은 시·종점과 한 벌이라 구조물 놓기(B05) 몫
- 틀린 값·놓기 칸·판번호 어긋남은 아무것도 안 씀 · 값 꼴은 구조물도 제원 저장과 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 21:55:50 +09:00
eomsangdon 644640ee59 Merge remote-tracking branch 'origin/CODEX' into main_laptop_1 2026-09-13 21:42:49 +09:00
eomsangdon e3c1119f5d Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 21:42:48 +09:00
eomsangdonandClaude Opus 5 1c79fc288c fix(axis): 표 읽기 — 콘크리트 포장 12-6 두께별 작업조 · 인력 돌쌓기 13-4-1/4 두 단 머리
12-6 은 두께 칸이 작업조 칸과 엇갈려 병합된 표라 두께 줄을 시공량 갈래로 읽음(20·30·40㎝, ㎥당).
후진 진입·경운기 칸은 「50%까지 감」 범위라 안 세움. 공종 단위가 빈 작업조 표는 시공량 열 머리 단위를 씀.
13-4-1·13-4-4 는 hwpx 병합대로 앞 세 돌 골·켜 두 잎 + 호박돌 및 야면석 한 잎으로 갈래를 세움(각 40 갈래).
13-4-4 는 같은 절의 표준경사 표 오판정으로 아직 막힘 — 형태 판정 일감으로 넘김.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 21:42:23 +09:00
eomsangdon d35c5082d9 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 20:13:10 +09:00
eomsangdonandClaude Opus 5 d9c84aaaf0 feat(b08): 구조물 집계표 탭 — 측점별 한 줄·종류별 표·칸마다 출처
- 정본 둘(structures.json·pipe_points.json)과 B06 관 연장을 읽어 종류별 표로 세움
- 한 줄 = 측점 + 실치수(제원 칸) + 개소·연장 · 표마다 개소·연장 합·평균치수
- 칸 출처 자동·사용자·라이브러리(양식 기본값)·빈칸 · 배수관과 세월교는 다른 표
- 이 표에서 고친 값을 B05 가 바꾸면 빨간 테두리와 알림(조용히 안 사라짐)
- 구조물도 탭 앞에 탭 등록

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 20:12:24 +09:00
eomsangdon be1dfcd3a1 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 20:00:38 +09:00
eomsangdonandClaude Opus 5 980c5440a0 fix(axis): 표 읽기 — 단끊기 5-16-1 공정 줄 합산 · 「(인/100m당)」 꼴 밑수 22표
단끊기는 공정 네 줄을 토질 갈래마다 더하고 [주]⑤ 작업반장 1인/보통인부 20인을 더함.
원문 합계 줄은 절취를 ㎥당으로 더한 값이라 안 씀(까닭을 적은 공종만 섬). 같은 모양 뭉기기 13-12-1 은 안 더함.
「단위:」 없이 분모만 적은 본문 밑수를 읽어 조림·시비·떼심기 22표가 밑수 없음에서 풀림.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 20:00:15 +09:00
eomsangdonandClaude Opus 5 9a758c4d27 feat(axis): 모르터 줄 공종 코드 AX-WK-c0842a0d 모르타르 배합 1:3 — 찰쌓기 일위대가 표의 막힌 줄이 열림
산림 품셈엔 배합 절이 없어 건설 품셈 [건축] 9-1-1 을 옮김 — 보통인부 0.66인/㎥(모래체가름 포함).
시멘트 510kg·모래 1.10㎥ 는 자재 단가 층이 없어 안 붙은 줄로 드러냄(원문 할증 포함 값).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 19:51:40 +09:00
eomsangdon d72f6ed13a Merge remote-tracking branch 'origin/CODEX' into sub_laptop_1 2026-09-13 19:47:35 +09:00
eomsangdonandClaude Opus 5 aab43d652c feat(b08): 구조물도 일위대가 줄 고치기 — 줄 더하기·빼기·고르개·단가 수동, 저장 자리 둘
- 줄 조합은 양식+프로젝트(종류별), [내 라이브러리에 저장] 때 양식에 실림
- 수동 단가는 프로젝트만(값·출처·넣은 날짜) — 빨간 테두리 + 「미확정 N건」
- 고르개는 이 프로젝트 단가표에서 품셈·자원을 낱말로 찾음
- 다른 양식을 가져오면 그 종류의 줄 조합·수동 단가도 비움

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 19:46:29 +09:00
506 changed files with 187915 additions and 28060 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
[ROUTES.B08_QUANTITY]: async () =>
(await import("../B08_Quantity/B08_Quantity_UI_Page")).renderB08Quantity,
[ROUTES.B09_ESTIMATION]: async () =>
(await import("../B09_Estimation/B09_Estimation_UI_Page")).renderB09Estimation,
(await import("../B09_Estimation/B09_Estimation_UI_Shell")).renderB09Estimation,
[ROUTES.B10_PAYMENT]: async () =>
(await import("../B10_Payment/B10_Payment_UI_Page")).renderB10Payment,
[ROUTES.B11_STATUS]: async () =>
@@ -35,6 +35,13 @@ export interface StructureOptionField {
* ⚠ 이 칸의 select 는 **빈 보기를 둔다** — 첫 보기를 슬쩍 고르면 기준값과 다른 값이
* 조용히 저장된다(2026-09-13: `fill_concrete_mpa` 가 180 으로 박혀 엔진 기준 210 과 갈렸다). */
empty_means?: string | null;
/** 이 값을 넘으면 칸이 경고색 + 툴팁(막지 않음) — 기준값과 까닭 한 줄. */
warn_above?: number | null;
warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 칸 이름 옆 「표에 안 쓰임」 + 툴팁 사유. */
not_in_table?: string | null;
/** 기본값의 뜻(도메인 확정값이 아닐 때) — 칸 밑 근거 한 줄. */
default_basis?: string | null;
}
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
+127 -35
View File
@@ -134,9 +134,10 @@
"label": "유입 기슭막이 높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)"
},
{
"key": "outlet_type",
@@ -186,9 +187,10 @@
"label": "유출 기슭막이 높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)"
},
{
"key": "revet_foundation",
@@ -332,6 +334,26 @@
"unit": "%",
"default": null,
"required": false
},
{
"key": "thickness_cm",
"label": "포장 두께",
"input": "number",
"unit": "㎝",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)"
},
{
"key": "length_m",
"label": "포장 길이(노폭 방향)",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)"
}
]
},
@@ -479,6 +501,15 @@
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎"
},
{
"key": "length_m",
"label": "연장",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"empty_means": "비우면 표가 안 서고 「연장을 적어야 섬」 사유가 뜸 — 개거는 m당 원단위라 연장이 곧 밑수(2026-09-14 A2)"
}
]
},
@@ -695,9 +726,11 @@
"label": "높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": 2.0,
"default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음",
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "length_m",
@@ -783,7 +816,8 @@
"unit": "m",
"default": 2.5,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "length_m",
@@ -888,6 +922,16 @@
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "face_role",
"label": "성토/절토",
"input": "select",
"choices": ["성토", "절토"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
@@ -956,7 +1000,10 @@
"unit": "m",
"default": 2.5,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)",
"warn_above": 2.0,
"warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)"
},
{
"key": "length_m",
@@ -1061,6 +1108,16 @@
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "face_role",
"label": "성토/절토",
"input": "select",
"choices": ["성토", "절토"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
@@ -1222,7 +1279,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "thickness_bottom_m",
@@ -1232,7 +1290,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "back_len_cm",
@@ -1242,7 +1301,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_kind",
@@ -1252,7 +1312,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
"empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_supply",
@@ -1262,7 +1323,8 @@
"default": "채집",
"required": false,
"phase": "detail",
"default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음"
"default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_coeff_basis",
@@ -1272,7 +1334,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "fill_concrete_mpa",
@@ -1282,7 +1345,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
"empty_means": "비우면 계산 쪽 기준 강도로 돎",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "face_slope_ratio",
@@ -1291,7 +1355,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "foundation",
@@ -1310,7 +1375,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "weep_hole_area_m2",
@@ -1320,7 +1386,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
}
]
},
@@ -1342,7 +1409,8 @@
"unit": "m",
"default": 2.5,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "length_m",
@@ -1396,7 +1464,8 @@
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
"phase": "detail",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_coeff_basis",
@@ -1406,7 +1475,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "fill_concrete_mpa",
@@ -1416,7 +1486,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
"empty_means": "비우면 계산 쪽 기준 강도로 돎",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 채움 콘크리트를 품에 포함 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "face_slope_ratio",
@@ -1452,7 +1523,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "weep_hole_area_m2",
@@ -1462,7 +1534,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "blinding_concrete",
@@ -1590,7 +1663,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "thickness_bottom_m",
@@ -1600,7 +1674,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "back_len_cm",
@@ -1618,7 +1693,8 @@
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
"phase": "detail",
"not_in_table": "아직 표에 안 쓰임 — 골막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_coeff_basis",
@@ -1628,7 +1704,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
"not_in_table": "아직 표에 안 쓰임 — 골막이는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "fill_concrete_mpa",
@@ -1739,7 +1816,8 @@
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
"phase": "detail",
"not_in_table": "아직 표에 안 쓰임 — 바닥막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_coeff_basis",
@@ -1749,7 +1827,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
"not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "fill_concrete_mpa",
@@ -1759,7 +1838,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
"empty_means": "비우면 계산 쪽 기준 강도로 돎",
"not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "face_slope_ratio",
@@ -1768,7 +1848,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 바닥막이는 평면적(돌붙임 ㎡)이라 기울기 몫이 없음 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "foundation",
@@ -1910,7 +1991,7 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
"empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
},
{
"key": "stone_kind",
@@ -1960,6 +2041,16 @@
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "face_role",
"label": "성토/절토",
"input": "select",
"choices": ["성토", "절토"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
@@ -1974,9 +2065,10 @@
"label": "높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": null,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181 · 사방(계류) 교본 기준). 비우면 줄만 서고 미확정이라 금액에 안 들어감 · 옛 기본 2.5 는 근거 없어 걷음(2026-09-14 브레인 판정)"
},
{
"key": "length_m",
@@ -60,6 +60,13 @@ class StructureOptionField(BaseModel):
# 기본값이 **도메인 확정값이 아닐 때** 그 뜻을 적는다(예: 「다단 없음」·「안 더함」).
# 법정·확정 수치면 비워 둔다 — 비어 있는 것이 「확정값」이라는 뜻이다.
default_basis: str | None = None
# 이 값을 **넘으면** 칸이 경고색 + 툴팁 — 막지 않음(2026-09-14 브레인 판정 ①·㉰ 「놓기를 막지 말 것」).
# 넘는지 보는 기준값과 그 까닭 한 줄(원문 쪽·줄 번호).
warn_above: float | None = None
warn_message: str | None = None
# 칸은 있는데 **원단위 표가 아직 안 읽는** 칸 — 넣어도 수량·금액이 안 바뀜을 칸 옆에 보임(2026-09-14 ①).
# 참말인지는 `test_b05_not_in_table_marks` 가 값을 바꿔 돌려 보고 지킴.
not_in_table: str | None = None
class StructureType(BaseModel):
+104 -194
View File
@@ -26,10 +26,6 @@
* 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다.
* ========================================================================== */
import {
FORD_BRIDGE_DEFAULT_WIDTH_M,
FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend";
import type {
DetailPipeInput,
PipeFacility,
@@ -42,10 +38,12 @@ import {
createRevetmentFields,
createRevetSideGroup,
createWingFields,
fordSection,
grid,
group,
INLET_KINDS,
INLET_REVET_KEYS,
inletKindOf,
type InletStructureKind,
labeled,
numberInput,
optionalSelect,
@@ -56,30 +54,10 @@ import {
WING_IN_KEYS,
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets";
import { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford";
/** B06 유입측 구조물 형식 — 조정창 드롭다운과 같은 값 집합(2026-08-29 병합).
* 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */
export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U";
/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한
* 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다).
* 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06
* 유입측 형식(`inlet_structure`). */
const INLET_KINDS: ReadonlyArray<{
label: string;
type: "기슭막이" | "집수정";
structure: InletStructureKind;
}> = [
{ label: "기슭막이", type: "기슭막이", structure: "revet" },
{ label: "집수정", type: "집수정", structure: "auto" },
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
{ label: "집수정 I형", type: "집수정", structure: "I" },
{ label: "집수정 ㄴ형", type: "집수정", structure: "L" },
{ label: "집수정 ㄷ형", type: "집수정", structure: "U" },
];
const inletKindOf = (label: string): (typeof INLET_KINDS)[number] =>
INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0];
export type { InletStructureKind }; // 목록은 700줄 한계로 `_Fields` 로 옮김 — 부르던 곳은 그대로
/** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을
* 가지면 저장한다. */
@@ -209,17 +187,13 @@ export function createFacilityOptionsForm(
root.hidden = true;
// ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ──────────────
// 관종 기본값 = 파형강관(2026-08-17 사용자 확정) — 빈 항목 없이 셋 중 하나.
const pipeMaterial = document.createElement("select");
pipeMaterial.replaceChildren(
...["흄관", "VR관", "파형강관"].map((value) => new Option(value, value)),
);
pipeMaterial.value = "파형강관";
// 관종 파형강관·관경 1000(사용자 확정 기본)은 **제안으로만** — 「안 정함」이 실제 상태(판정 ③).
const pipeMaterial = optionalSelect("— 안 정함 (제안 파형강관) —", ["흄관", "VR관", "파형강관"]);
const pipeDiameter = document.createElement("select");
pipeDiameter.replaceChildren(
new Option("— 안 정함 (제안 Ø1000) —", ""),
...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)),
);
pipeDiameter.value = "1000"; // 별표2 원칙값 + 사용자 확정 기본 (2026-08-17)
// 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다
// (2026-09-09, 계획서 4-13 · 소광리 원본 탭 다섯). 비우면 날개벽을 안 센다.
const wingWall = optionalSelect("안 놓음", ["A-TYPE", "C-TYPE", "A-TYPE+집수정"]);
@@ -228,11 +202,12 @@ export function createFacilityOptionsForm(
// ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ─────────────────
const inletGroup = group("유입구");
// 빈값(미지정)을 두면 하위 칸이 전부 숨어 기본값이 안 보인다 — 기슭막이를 기본으로
// 두고 저유량 지점에서 집수정으로 바꾼다(2026-08-17 사용자 지시).
// 「안 정함」이면 기슭막이 칸을 보이되(제안) 값은 안 실음 — 기본값을 몰래 확정으로 안 바꿈(판정 ③).
const inletType = document.createElement("select");
inletType.replaceChildren(...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)));
inletType.value = INLET_KINDS[0].label;
inletType.replaceChildren(
new Option("— 안 정함 (제안 기슭막이) —", ""),
...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)),
);
const basinForm = optionalSelect("선택", [
"동물이동형",
"□형(기본형)",
@@ -248,14 +223,16 @@ export function createFacilityOptionsForm(
]);
const basinMaterialField = labeled("형태", basinMaterial);
// 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자).
const basinLength = numberInput("0.1");
basinLength.value = "2";
const basinLength = numberInput("0.1", "0", "제안 2");
const basinRows = [
grid(labeled("형식", basinForm), labeled("길이 (m)", stepper(basinLength, 1))),
];
const height = (): string =>
String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000));
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(찰)",
height,
});
// 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다
// (2026-08-17 사용자 지시 3).
@@ -273,11 +250,11 @@ export function createFacilityOptionsForm(
// ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ──
const outletGroup = group("유출구");
const outletType = document.createElement("select");
outletType.replaceChildren(new Option("기슭막이", "기슭막이"));
const outletType = optionalSelect("— 안 정함 (제안 기슭막이) —", ["기슭막이"]);
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(메)",
height,
});
outletGroup.body.append(
grid(labeled("구조", outletType), outletRevet.formField),
@@ -299,8 +276,7 @@ export function createFacilityOptionsForm(
// 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다.
// 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만
// 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다.
const revetSide = optionalSelect("양쪽", ["양쪽", "좌", "우"]);
revetSide.value = "양쪽";
const revetSide = optionalSelect("— 안 정함 (제안 양쪽) —", ["양쪽", "좌", "우"]);
const revetTopRow = grid(labeled("설치 측", revetSide));
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)");
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)");
@@ -338,12 +314,12 @@ export function createFacilityOptionsForm(
// ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ────
// 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정).
const boxSize = optionalSelect("미지정", [
const boxSize = optionalSelect(`— 안 정함 (제안 ${BOX_SIZE_PRESETS[0][0]}) —`, [
...BOX_SIZE_PRESETS.map(([label]) => label),
BOX_SIZE_CUSTOM,
]);
const boxWidth = numberInput("0.1");
const boxHeight = numberInput("0.1");
const boxWidth = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][1]}`);
const boxHeight = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][2]}`);
const boxCustomRow = grid(labeled("본체 폭 (m)", boxWidth), labeled("본체 높이 (m)", boxHeight));
const boxWrap = document.createElement("div");
boxWrap.append(grid(labeled("본체 규격 (m)", boxSize)), boxCustomRow);
@@ -358,98 +334,50 @@ export function createFacilityOptionsForm(
boxWidth.value = String(preset[1]);
boxHeight.value = String(preset[2]);
}
boxCustomRow.hidden = !!preset;
// 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로(제안은 빈 보기 이름).
boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM;
}
// ── 세월교 — 구체 내 배관은 배수관과 같은 관종·관경 칸을 쓰고(2026-08-17 사용자
// 지시) 수량만 따로 받는다.
const fordCount = numberInput("1", "1", "련");
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음(2026-08-30 사용자 지시 3).
// 련은 정수라 소수 자릿수를 두지 않는다.
const fordRow = grid(labeled("수량 (련)", stepper(fordCount, 1, 0)));
// ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford` · 기본값은 제안으로만).
const ford = createFordFields(() => emit());
// ── 물넘이·세월교 개략 단면 — 월류 폭 + 월류 높이 한 행(2026-08-18 사용자 지시).
// 폭 기본값은 세월교 10m·물넘이 포장 5m(사용자 확정 — 지식DB 폭 수치 근거 없음).
// 높이는 설계유량·폭으로 되짚은 필요 수심을 자동으로 채우고, 사용자가 키울 수는
// 있으나 계산값(필요 최소 수심) 미만은 되돌린다.
const fordWidth = numberInput("0.1");
const fordHeight = numberInput("0.01");
const fordWidthRow = grid(
labeled("월류 폭 (m)", stepper(fordWidth, 0.1)),
labeled("월류 높이 (m)", stepper(fordHeight, 0.1)),
);
// 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정).
// 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다.
const fordSlope = numberInput("0.1");
fordSlope.placeholder = "노면 기울기";
const fordSlopeRow = grid(labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 0.1)));
const fordSummary = document.createElement("p");
fordSummary.className = "b05-drainage__facility-note";
/** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */
let designFlowM3s: number | null = null;
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
let fordMinDepthM: number | null = null;
/** 직전에 자동으로 채워 넣은 필요 수심(m) — 칸이 아직 그 값이면 "자동"으로 보고
* 월류 폭이 바뀔 때마다 계산값을 계속 따라가게 한다. 사용자가 다른 값을 넣으면
* 그때부터 그 값이 이긴다(2026-08-30 사용자: 폭을 바꿔도 높이가 안 따라온다). */
let fordAutoDepthM: number | null = null;
function syncFordSummary(): void {
const section =
designFlowM3s !== null
? fordSection(designFlowM3s, Number.parseFloat(fordWidth.value))
: null;
fordMinDepthM = section ? section.depthM : null;
if (section) {
// 표시 정밀도(0.01m)로 맞춘 최소값 — 비었거나, 그보다 작거나, 아직 직전
// 자동값 그대로면 계산값으로 다시 채운다.
const min = Number(section.depthM.toFixed(2));
const current = Number.parseFloat(fordHeight.value);
const untouched = fordAutoDepthM !== null && Math.abs(current - fordAutoDepthM) < 0.005;
if (!Number.isFinite(current) || current < min || untouched)
fordHeight.value = min.toFixed(2);
fordAutoDepthM = min;
}
if (designFlowM3s === null) {
fordSummary.textContent =
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
return;
}
const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`;
if (!section) {
fordSummary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`;
return;
}
// 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다.
fordSummary.textContent =
`${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` +
`필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` +
`월류 높이는 필요 수심 이상만 입력됩니다.`;
}
// 계산값 미만은 입력을 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다.
fordHeight.addEventListener("change", () => {
if (fordMinDepthM !== null) {
const min = Number(fordMinDepthM.toFixed(2));
const value = Number.parseFloat(fordHeight.value);
if (!Number.isFinite(value) || value < min) {
fordHeight.value = min.toFixed(2);
fordHeight.classList.add("is-invalid");
window.setTimeout(() => fordHeight.classList.remove("is-invalid"), 900);
}
}
// [제안값 넣기] — 누른 때만 빈 칸에 제안값(관종·관경·구조·집수정 길이·설치 측·BOX 규격·날개벽·월류).
// 벽 칸(기슭막이)은 벽마다 단추가 따로 있음. 기본값을 몰래 확정으로 바꾸지 않는다(2026-09-14 판정 ③).
const suggest = document.createElement("button");
suggest.type = "button";
suggest.className = "b05-structure__suggest";
suggest.textContent = "제안값 넣기";
suggest.addEventListener("click", () => {
if (!pipeMaterial.value) pipeMaterial.value = "파형강관";
if (!pipeDiameter.value) pipeDiameter.value = "1000";
if (!inletType.value) inletType.value = INLET_KINDS[0].label;
if (!outletType.value) outletType.value = "기슭막이";
if (!basinLength.value) basinLength.value = "2";
if (!revetSide.value) revetSide.value = "양쪽";
if (current === "box_culvert" && !boxSize.value) boxSize.value = BOX_SIZE_PRESETS[0][0];
wingInFields.fillSuggested();
wingOutFields.fillSuggested();
if (current === "ford_pavement" || current === "ford_bridge") ford.fillSuggested(current);
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,
fordWidthRow,
fordSlopeRow,
fordRow,
fordSummary,
ford.widthRow,
ford.slopeRow,
ford.countRow,
ford.summary,
inletGroup.root,
outletGroup.root,
extraGroup.root,
@@ -480,24 +408,23 @@ export function createFacilityOptionsForm(
wingInFields.root.hidden = !hasWing;
wingOutFields.root.hidden = !hasWing;
const isFord = current === "ford_pavement" || current === "ford_bridge";
fordRow.hidden = current !== "ford_bridge";
fordWidthRow.hidden = !isFord;
ford.countRow.hidden = current !== "ford_bridge";
ford.widthRow.hidden = !isFord;
// 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다.
fordSlopeRow.hidden = current !== "ford_pavement";
fordSummary.hidden = !isFord;
ford.slopeRow.hidden = current !== "ford_pavement";
ford.summary.hidden = !isFord;
const isRevet = current === "revetment";
revetTopRow.hidden = !isRevet;
// 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다.
revetInlet.root.hidden =
!isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.inlet);
revetOutlet.root.hidden =
!isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.outlet);
// 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다(안 정함 = 양쪽처럼 보임).
const side = revetSide.value || "양쪽";
revetInlet.root.hidden = !isRevet || (side !== "양쪽" && side !== revetSideLabels.inlet);
revetOutlet.root.hidden = !isRevet || (side !== "양쪽" && side !== revetSideLabels.outlet);
revetInlet.legend.textContent = `기슭막이 (${revetSideLabels.inlet})`;
revetOutlet.legend.textContent = `기슭막이 (${revetSideLabels.outlet})`;
// 추가 기슭막이 칸은 **내용이 있을 때만** 선다 — 고른 다단 벽의 행이 여기 오면 뜨고,
// 선택이 풀리면 스스로 사라진다.
extraGroup.root.hidden = extraSlot.childElementCount === 0;
if (isFord) syncFordSummary();
if (isFord) ford.sync();
if (isBox) syncBoxSize();
if (hasWing) {
wingInFields.syncVisibility();
@@ -516,12 +443,6 @@ export function createFacilityOptionsForm(
}
}
/** 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림). */
function putFordHeight(options: Record<string, string | number>): void {
const value = Number.parseFloat(fordHeight.value);
if (Number.isFinite(value) && value > 0) options.ford_height_m = Number(value.toFixed(2));
}
function emit(): void {
syncVisibility();
callbacks.onChange?.();
@@ -543,8 +464,7 @@ export function createFacilityOptionsForm(
boxHeight,
...wingInFields.inputs,
...wingOutFields.inputs,
fordCount,
fordWidth,
...ford.inputs,
].forEach((input) => input.addEventListener("change", emit));
/** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */
@@ -581,35 +501,36 @@ export function createFacilityOptionsForm(
},
setFacility(facility, options = {}, designFlow = null) {
current = facility;
designFlowM3s = designFlow ?? null;
ford.setDesignFlow(designFlow ?? null);
if (facility === null) {
syncVisibility();
return;
}
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
const isFord = facility === "ford_bridge";
pipeMaterial.value = text("pipe_kind") || "파형강관";
pipeDiameter.value = text("pipe_diameter_mm") || "1000";
// ⚠ 저장된 값만 칸에 — 없으면 「안 정함」 · 제안은 빈 보기 이름·회색 글씨(2026-09-14 판정 ③).
pipeMaterial.value = text("pipe_kind");
pipeDiameter.value = text("pipe_diameter_mm");
// 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다.
const savedType = text("inlet_type") || "기슭막이";
const savedType = text("inlet_type");
const savedStructure = text("inlet_structure");
inletType.value = (
INLET_KINDS.find(
(kind) =>
kind.type === savedType && (!savedStructure || kind.structure === savedStructure),
) ?? inletKindOf(savedType)
).label;
outletType.value = text("outlet_type") || "기슭막이";
inletType.value = savedType
? (
INLET_KINDS.find(
(kind) =>
kind.type === savedType && (!savedStructure || kind.structure === savedStructure),
) ?? inletKindOf(savedType)
).label
: "";
outletType.value = text("outlet_type");
wingWall.value = text("wing_wall_type");
basinForm.value = text("inlet_basin_form");
basinMaterial.value = text("inlet_basin_material");
basinLength.value = text("inlet_basin_length_m") || "2";
basinLength.value = text("inlet_basin_length_m");
inletRevet.write(options);
outletRevet.write(options);
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
// 값이 없으면 첫 프리셋(2.0×2.0)이 기본이다.
const [firstLabel, firstWidth, firstHeight] = BOX_SIZE_PRESETS[0];
// 값이 없으면 「안 정함」(첫 프리셋 2.0×2.0 은 제안으로만).
const savedWidth = Number.parseFloat(text("body_width_m"));
const savedHeight = Number.parseFloat(text("body_height_m"));
const hasSaved =
@@ -623,43 +544,38 @@ export function createFacilityOptionsForm(
Math.abs(width - savedWidth) < 0.05 && Math.abs(height - savedHeight) < 0.05,
)
: undefined;
boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : firstLabel;
boxWidth.value = String(hasSaved ? savedWidth : firstWidth);
boxHeight.value = String(hasSaved ? savedHeight : firstHeight);
boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : "";
boxWidth.value = hasSaved ? String(savedWidth) : "";
boxHeight.value = hasSaved ? String(savedHeight) : "";
wingInFields.write(options);
wingOutFields.write(options);
fordCount.value = isFord ? text("pipe_count") : "";
fordWidth.value = text("ford_width_m");
fordHeight.value = text("ford_height_m");
// 새 시설을 올리는 참이다 — 저장된 높이는 사용자 값으로 보고 자동 추적을 끊는다.
fordAutoDepthM = null;
fordSlope.value = text("ford_slope_pct");
revetSide.value = text("side") || "양쪽";
if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options);
revetSide.value = text("side");
const spread = legacyRevetOptions(options);
revetInlet.fields.write(spread);
revetOutlet.fields.write(spread);
// 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정, config 정의처).
if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) {
fordWidth.value = String(
facility === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M,
);
}
syncVisibility();
},
setDesignFlow(designFlow) {
designFlowM3s = designFlow ?? null;
ford.setDesignFlow(designFlow ?? null);
syncVisibility();
},
readOptions() {
const options: Record<string, string | number> = {};
// 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈).
const putPipe = (): void => {
if (pipeDiameter.value) options.pipe_diameter_mm = Number(pipeDiameter.value);
if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value;
};
if (current === "pipe") {
options.pipe_diameter_mm = Number(pipeDiameter.value);
options.pipe_kind = pipeMaterial.value;
putPipe();
const inletKind = inletKindOf(inletType.value);
if (wingWall.value) options.wing_wall_type = wingWall.value;
options.inlet_type = inletKind.type;
// B06 유입측 형식(조정창과 같은 값) — 병합 드롭다운이 함께 정한다(2026-08-29).
options.inlet_structure = inletKind.structure;
if (inletType.value) {
options.inlet_type = inletKind.type;
options.inlet_structure = inletKind.structure;
}
if (inletKind.type === "집수정") {
if (basinForm.value) options.inlet_basin_form = basinForm.value;
if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value;
@@ -667,7 +583,7 @@ export function createFacilityOptionsForm(
} else {
inletRevet.read(options);
}
options.outlet_type = outletType.value;
if (outletType.value) options.outlet_type = outletType.value;
outletRevet.read(options);
} else if (current === "box_culvert") {
// 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다.
@@ -676,20 +592,14 @@ export function createFacilityOptionsForm(
wingInFields.read(options);
wingOutFields.read(options);
} else if (current === "ford_pavement") {
putNumber(options, "ford_width_m", fordWidth.value);
putFordHeight(options);
putNumber(options, "ford_slope_pct", fordSlope.value);
ford.read(current, options);
} else if (current === "ford_bridge") {
options.pipe_kind = pipeMaterial.value;
options.pipe_diameter_mm = Number(pipeDiameter.value);
const count = Number.parseInt(fordCount.value, 10);
if (Number.isFinite(count) && count > 0) options.pipe_count = count;
putNumber(options, "ford_width_m", fordWidth.value);
putFordHeight(options);
putPipe();
ford.read(current, options);
wingInFields.read(options);
wingOutFields.read(options);
} else if (current === "revetment") {
options.side = revetSide.value;
if (revetSide.value) options.side = revetSide.value;
// 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고
// 조작은 [추가 기슭막이(단)] 행이 한다(2026-08-30 사용자: 중복이라 삭제).
// 좌·우가 각자 값을 갖는다 — 저장 채널은 유입/유출 키 그대로다(2026-08-30).
@@ -9,6 +9,33 @@
* ========================================================================== */
import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend";
import revetmentSabang from "../resources/data_masonry/revetment_sabang_2026-01-01.json";
/* ── 유입구 구조 ──────────────────────────────────────────────────────────── */
/** B06 유입측 구조물 형식 — 조정창 드롭다운과 같은 값 집합(2026-08-29 병합).
* 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */
export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U";
/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한
* 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다).
* 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06
* 유입측 형식(`inlet_structure`). */
export const INLET_KINDS: ReadonlyArray<{
label: string;
type: "기슭막이" | "집수정";
structure: InletStructureKind;
}> = [
{ label: "기슭막이", type: "기슭막이", structure: "revet" },
{ label: "집수정", type: "집수정", structure: "auto" },
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
{ label: "집수정 I형", type: "집수정", structure: "I" },
{ label: "집수정 ㄴ형", type: "집수정", structure: "L" },
{ label: "집수정 ㄷ형", type: "집수정", structure: "U" },
];
export const inletKindOf = (label: string): (typeof INLET_KINDS)[number] =>
INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0];
/* ── 물넘이·세월교 개략 단면 ────────────────────────────────────────────── */
@@ -127,14 +154,18 @@ export function putNumber(target: Record<string, string | number>, key: string,
if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1));
}
/** 선택지 셀렉트 — 빈("미지정") 항목을 두지 않는다. 첫 항목이 곧 기본값이고,
* 기본값은 구조물별로 따로 지정한다(2026-08-17 사용자 지시 1). */
/** 선택지 셀렉트 — **맨 앞에 빈 보기(「안 정함」)** 를 둠. 첫 보기를 미리 고르지 않음
* (2026-09-14 브레인 판정 · 옛 2026-08-17 「빈 항목 두지 않음」 걷음) — 고른 적 없는 첫 값이 저장돼
* 확정으로 굳지 않게. 기본값이 있는 칸은 부르는 쪽이 값을 넣음(그것은 따로 차례). */
export function optionalSelect(
_blankLabel: string,
blankLabel: string,
choices: ReadonlyArray<string>,
): HTMLSelectElement {
const element = document.createElement("select");
element.replaceChildren(...choices.map((choice) => new Option(choice, choice)));
element.replaceChildren(
new Option(blankLabel, ""),
...choices.map((choice) => new Option(choice, choice)),
);
return element;
}
@@ -175,16 +206,20 @@ export interface RevetmentKeys {
after?: string;
}
/** 부위별 기본 구성 — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m·높이 2.5m. */
/** 부위별 **제안값** — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m. 관 벽 높이는 관경 기준
* (함수로 받아 볼 때마다 셈).
* ⚠ 제안값은 **칸에 채우지 않고 회색 글씨로만** 보임 — 사용자가 적거나 [제안값 넣기]를 누른 때만
* 값이 됨(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」). 칸에 채워 두면 저장이
* 그 값을 적어 **미확정이 저장 한 번에 확정으로 바뀜** — 사용자가 한 일이 없는데 금액이 섬. */
export interface RevetmentDefaults {
form: string;
length: string;
height: string;
height: string | (() => string);
before: string;
after: string;
}
/** 부위와 무관한 공통 기본 치수 — 길이 10m(전 5·후 5)·높이 2.5m. */
/** 부위와 무관한 공통 제안 치수 — 길이 10m(전 5·후 5)·높이 2.5m(독립 기슭막이 · 관 벽은 관경 기준). */
export const REVET_COMMON_DEFAULTS = {
length: "10",
height: "2.5",
@@ -218,16 +253,43 @@ export interface RevetSideGroup {
slot: HTMLElement;
}
/** 독립 기슭막이 치수 근거 줄 — `revetment_sabang_*.json` 한 벌(B08 사유와 같은 파일 · 쪽·줄 번호).
* ⚠ 파일 판이 바뀌면 이 import 도 옮길 것 — 시험(`test_b05_revetment_sabang_basis`)이 최신 판을 대조. */
function sabangBasisLines(): string[] {
const { items, scope } = revetmentSabang;
const span = (values: number[], unit = ""): string => `${values[0]}~${values[1]}${unit}`;
return [
`높이 — ${items.height.rule}(${items.height.source}) · ${items.height.input}`,
`계획비탈 1:${span(items.face_slope.standard_ratio)}(${items.face_slope.source}) — 제안값은 품셈 표준경사`,
`둑마루 두께 ${span(items.crown_thickness.standard_m, "m")}(${items.crown_thickness.source}) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식`,
`뒷채움 조약돌 두께 — ${items.backfill_pebble.source}`,
scope,
];
}
export function createRevetSideGroup(keys: RevetmentKeys, formDefault: string): RevetSideGroup {
const box = group("기슭막이");
const kind = document.createElement("select");
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
const fields = createRevetmentFields(keys, { ...REVET_COMMON_DEFAULTS, form: formDefault });
// 높이는 제안 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정). 옛 2.5 는 근거 없어 걷음.
const fields = createRevetmentFields(keys, {
...REVET_COMMON_DEFAULTS,
form: formDefault,
height: "",
});
const basis = document.createElement("p");
basis.className = "b05-route__note";
basis.replaceChildren(
...sabangBasisLines().flatMap((line, index) =>
index ? [document.createElement("br"), line] : [line],
),
);
const slot = document.createElement("div");
slot.className = "b05-structure__adjust-slot";
box.body.append(
grid(labeled("구조", kind), fields.formField),
...fields.rows,
basis,
// 단 수는 여기 두지 않는다 — 조정창에서 옮겨 오는 [추가 기슭막이(단)] 행과
// 같은 값이라 두 벌이면 헷갈린다(2026-08-30 사용자).
slot,
@@ -258,26 +320,32 @@ export function createRevetmentFields(
keys: RevetmentKeys,
defaults: RevetmentDefaults,
): RevetmentFields {
// ⚠ 제안값은 **회색 글씨(placeholder)로만** — 칸은 비워 둠(`RevetmentDefaults` 주석).
const suggestHeight = (): string =>
typeof defaults.height === "function" ? defaults.height() : defaults.height;
const form = document.createElement("select");
form.replaceChildren(...REVET_FORMS.map((value) => new Option(value, value)));
form.value = defaults.form;
const length = keys.length ? numberInput("0.1") : null;
if (length) length.value = defaults.length;
const height = numberInput("0.1");
height.value = defaults.height;
form.replaceChildren(
new Option(`제안 ${defaults.form}`, ""),
...REVET_FORMS.map((value) => new Option(value, value)),
);
const length = keys.length ? numberInput("0.1", "0", `제안 ${defaults.length}`) : null;
// 제안이 없는 칸(독립 기슭막이 높이)은 「설계자 입력」 — 값을 지어내지 않음.
const heightHint = (): string => (suggestHeight() ? `제안 ${suggestHeight()}` : "설계자 입력");
const height = numberInput("0.1", "0", heightHint());
// 관 벽 높이 제안은 관경을 따라감 — 칸을 볼 때마다 다시 셈.
height.addEventListener("focus", () => (height.placeholder = heightHint()));
// 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동:
// 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다.
const before = keys.before && length ? numberInput("0.1") : null;
const after = keys.after && length ? numberInput("0.1") : null;
if (before) before.value = defaults.before;
if (after) after.value = defaults.after;
const before = keys.before && length ? numberInput("0.1", "0", `제안 ${defaults.before}`) : null;
const after = keys.after && length ? numberInput("0.1", "0", `제안 ${defaults.after}`) : null;
let lastSplitEdit: "before" | "after" = "before";
const readNumber = (input: HTMLInputElement | null): number => {
const value = input ? Number.parseFloat(input.value) : Number.NaN;
return Number.isFinite(value) && value >= 0 ? value : 0;
};
const syncSplit = (source: "length" | "before" | "after"): void => {
if (!length || !before || !after) return;
// 길이가 비었으면 전/후를 0 으로 채우지 않음 — 빈 칸이 「0 으로 적음」이 되면 안 됨.
if (!length || !before || !after || length.value === "") return;
if (source !== "length") lastSplitEdit = source;
const total = readNumber(length);
const keep = lastSplitEdit === "after" ? after : before;
@@ -293,10 +361,25 @@ export function createRevetmentFields(
const beforeField = before ? labeled("기준측점 전 (m)", stepper(before, 0.5)) : null;
const afterField = after ? labeled("기준측점 후 (m)", stepper(after, 0.5)) : null;
// [제안값 넣기] — **누른 때만** 빈 칸에 제안값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양.
const fill = document.createElement("button");
fill.type = "button";
fill.className = "b05-structure__suggest";
fill.textContent = "제안값 넣기";
fill.addEventListener("click", () => {
if (!form.value) form.value = defaults.form;
if (length && !length.value) length.value = defaults.length;
if (!height.value) height.value = suggestHeight();
if (before && !before.value) before.value = defaults.before;
if (after && !after.value) after.value = defaults.after;
form.dispatchEvent(new Event("change"));
});
// 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다.
const rows = [
grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)),
...(beforeField && afterField ? [grid(beforeField, afterField)] : []),
grid(fill),
];
return {
@@ -304,13 +387,15 @@ export function createRevetmentFields(
rows,
lengthInput: length,
write(options) {
// 저장된 값만 칸에 — 없으면 비워 두고 제안값은 회색 글씨로(몰래 채우지 않음).
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
form.value = text(keys.form) || defaults.form;
if (length && keys.length) length.value = text(keys.length) || defaults.length;
height.value = text(keys.height) || defaults.height;
if (before && keys.before) before.value = text(keys.before) || defaults.before;
if (after && keys.after) after.value = text(keys.after) || defaults.after;
form.value = text(keys.form);
if (length && keys.length) length.value = text(keys.length);
height.value = text(keys.height);
height.placeholder = heightHint();
if (before && keys.before) before.value = text(keys.before);
if (after && keys.after) after.value = text(keys.after);
syncSplit("length");
},
read(target) {
@@ -395,19 +480,18 @@ interface WingFields {
syncVisibility: () => void;
write: (options: Record<string, string | number>) => void;
read: (target: Record<string, string | number>) => void;
/** [제안값 넣기] — 빈 칸에만 기본 제원을 넣음. */
fillSuggested: () => void;
}
/** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각.
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴). */
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴).
* ⚠ 기본 제원(있음·1·2·45, 사용자 확정)은 **제안으로만** — 칸은 비워 둠(2026-09-14 판정 ③). */
export function createWingFields(title: string, keys: WingKeys): WingFields {
const install = optionalSelect("미지정", ["있음", "없음"]);
install.value = WING_DEFAULTS.install;
const height = numberInput("0.1");
height.value = WING_DEFAULTS.height;
const length = numberInput("0.1");
length.value = WING_DEFAULTS.length;
const angle = numberInput("1");
angle.value = WING_DEFAULTS.angle;
const install = optionalSelect(`— 안 정함 (제안 ${WING_DEFAULTS.install}) —`, ["있음", "없음"]);
const height = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.height}`);
const length = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.length}`);
const angle = numberInput("1", "0", `제안 ${WING_DEFAULTS.angle}`);
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음으로 맞춘다(2026-08-30 사용자).
const heightField = labeled("짧은쪽 높이 (m)", stepper(height, 0.1));
@@ -426,7 +510,6 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
dimsRow.hidden = off;
}
// 만들자마자 기본값("있음") 상태를 맞춘다.
syncVisibility();
return {
@@ -437,18 +520,25 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
write(options) {
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
install.value = text(keys.install) || WING_DEFAULTS.install;
height.value = text(keys.height) || WING_DEFAULTS.height;
length.value = text(keys.length) || WING_DEFAULTS.length;
angle.value = text(keys.angle) || WING_DEFAULTS.angle;
install.value = text(keys.install);
height.value = text(keys.height);
length.value = text(keys.length);
angle.value = text(keys.angle);
syncVisibility();
},
read(target) {
target[keys.install] = install.value;
if (install.value !== "음") return;
if (install.value) target[keys.install] = install.value;
if (install.value === "음") return;
putNumber(target, keys.height, height.value);
putNumber(target, keys.length, length.value);
putNumber(target, keys.angle, angle.value);
},
fillSuggested() {
if (!install.value) install.value = WING_DEFAULTS.install;
if (!height.value) height.value = WING_DEFAULTS.height;
if (!length.value) length.value = WING_DEFAULTS.length;
if (!angle.value) angle.value = WING_DEFAULTS.angle;
syncVisibility();
},
};
}
@@ -0,0 +1,151 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Facility_Ford.ts
* 물넘이포장·세월교 칸 묶음 — 월류 폭·높이·바닥 경사·포장 두께·길이·련 수 + 개략 단면 줄.
*
* 서브폼 본체(`_Drainage_Facility.ts`)가 700줄에 닿아 떼어 냄(2026-09-14).
* ⚠ 기본값을 칸에 안 채움(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」):
* 월류 폭 세월교 10m·물넘이 5m(사용자 확정)는 **회색 제안**, 월류 높이는 설계유량·폭으로 되짚은
* **필요 최소 수심을 회색 제안**으로만 — 사용자가 적거나 [제안값 넣기]를 누른 때만 값.
* 적은 높이가 필요 수심보다 낮으면 여전히 되돌림(계산값 미만은 안 받음).
* ========================================================================== */
import {
FORD_BRIDGE_DEFAULT_WIDTH_M,
FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend";
import {
fordSection,
grid,
labeled,
numberInput,
putNumber,
stepper,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
type FordKind = "ford_pavement" | "ford_bridge";
export interface FordFields {
widthRow: HTMLElement;
slopeRow: HTMLElement;
countRow: HTMLElement;
summary: HTMLElement;
/** 바뀌면 저장 흐름을 울릴 칸(높이는 자기 검사 뒤 스스로 울림). */
inputs: HTMLInputElement[];
setDesignFlow: (designFlow: number | null) => void;
sync: () => void;
write: (kind: FordKind, options: Record<string, string | number>) => void;
read: (kind: FordKind, target: Record<string, string | number>) => void;
fillSuggested: (kind: FordKind) => void;
}
export function createFordFields(emit: () => void): FordFields {
// 세월교 — 구체 내 배관 수량(련). 련은 정수라 소수 자릿수를 두지 않는다.
const count = numberInput("1", "1", "련");
const countRow = grid(labeled("수량 (련)", stepper(count, 1, 0)));
const width = numberInput("0.1");
const height = numberInput("0.01");
const widthRow = grid(
labeled("월류 폭 (m)", stepper(width, 0.1)),
labeled("월류 높이 (m)", stepper(height, 0.1)),
);
// 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정).
// 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다.
const slope = numberInput("0.1");
slope.placeholder = "노면 기울기";
// 포장 두께·노폭 방향 길이 — 수량(㎡ = 월류 폭 × 길이 · 두께로 원단위 고름)이 읽는 칸(A3).
const thickness = numberInput("1");
const length = numberInput("0.1");
const slopeRow = grid(
labeled("바닥 경사 유입→유출 (%)", stepper(slope, 0.1)),
labeled("포장 두께 (㎝)", stepper(thickness, 1, 0)),
labeled("포장 길이 노폭 방향 (m)", stepper(length, 0.1)),
);
const summary = document.createElement("p");
summary.className = "b05-drainage__facility-note";
let designFlowM3s: number | null = null;
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
let minDepthM: number | null = null;
const suggestedWidth = (kind: FordKind): number =>
kind === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M;
function sync(): void {
const widthM = Number.parseFloat(width.value || width.dataset.suggested || "");
const section = designFlowM3s !== null ? fordSection(designFlowM3s, widthM) : null;
minDepthM = section ? Number(section.depthM.toFixed(2)) : null;
height.placeholder = minDepthM !== null ? `제안 필요 수심 ${minDepthM.toFixed(2)}` : "";
if (designFlowM3s === null) {
summary.textContent =
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
return;
}
const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`;
if (!section) {
summary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`;
return;
}
// 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다.
summary.textContent =
`${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` +
`필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` +
`월류 높이는 필요 수심 이상만 입력됩니다.`;
}
// 적은 값이 계산값 미만이면 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다(빈 칸은 그대로 빈 칸).
height.addEventListener("change", () => {
const value = Number.parseFloat(height.value);
if (
minDepthM !== null &&
height.value !== "" &&
(!Number.isFinite(value) || value < minDepthM)
) {
height.value = minDepthM.toFixed(2);
height.classList.add("is-invalid");
window.setTimeout(() => height.classList.remove("is-invalid"), 900);
}
emit();
});
return {
widthRow,
slopeRow,
countRow,
summary,
inputs: [count, width],
setDesignFlow(designFlow) {
designFlowM3s = designFlow ?? null;
},
sync,
write(kind, options) {
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
count.value = kind === "ford_bridge" ? text("pipe_count") : "";
width.value = text("ford_width_m");
width.dataset.suggested = String(suggestedWidth(kind));
width.placeholder = `제안 ${suggestedWidth(kind)}`;
height.value = text("ford_height_m");
slope.value = text("ford_slope_pct");
thickness.value = text("thickness_cm");
length.value = text("length_m");
},
read(kind, target) {
putNumber(target, "ford_width_m", width.value);
// 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림).
const value = Number.parseFloat(height.value);
if (Number.isFinite(value) && value > 0) target.ford_height_m = Number(value.toFixed(2));
if (kind === "ford_pavement") {
putNumber(target, "ford_slope_pct", slope.value);
putNumber(target, "thickness_cm", thickness.value);
putNumber(target, "length_m", length.value);
return;
}
const pipes = Number.parseInt(count.value, 10);
if (Number.isFinite(pipes) && pipes > 0) target.pipe_count = pipes;
},
fillSuggested(kind) {
if (!width.value) width.value = String(suggestedWidth(kind));
sync();
if (!height.value && minDepthM !== null) height.value = minDepthM.toFixed(2);
},
};
}
@@ -0,0 +1,73 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Facility_Merge.ts
* 계곡 통과 시설 [저장] — **폼이 아는 칸만 갈아 끼우고, 폼이 모르는 칸은 건드리지 않음**
* (2026-09-14 브레인 판정).
*
* ⚠ 앞서 저장이 옵션을 **통째로** 갈아 끼웠음 — 구조물 집계표·구조물도로 적은 칸(관 기슭막이
* 기초 `revet_foundation` · 독립 기슭막이 `foundation`·뒷길이·돌 종류 …)이 B05 에서 그 시설을
* 한 번 더 저장하면 **조용히 지워졌음**. 「작업본 쓰기는 [저장]에서만」인데 그 [저장]이 옆 칸까지
* 지우던 자리.
* ⚠ 통째로 갈아 끼우는 것은 **시설 종류 자체를 바꿀 때만** — 그때는 옛 칸이 뜻을 잃음.
* ⚠ 폼이 아는 칸을 폼이 비워 보내면(값을 지움·유입구를 집수정 → 기슭막이로 바꿔 집수정 칸이 빠짐)
* 그 칸은 지워짐 — 사용자가 폼에서 한 일이므로 맞음.
* ⚠ 폼에 칸을 늘리면 아래 목록에도 넣을 것. 빠뜨리면 그 칸을 **폼에서 지워도 옛 값이 남는**
* 쪽으로 틀림(지워지는 쪽보다 덜 위험). 거울 시험 `test_b05_facility_options_merge`.
* ⚠ import 없음 — 시험이 이 파일만 옮겨 돌림.
* ========================================================================== */
const revetKeys = (side: "inlet" | "outlet"): string[] =>
["form", "length_m", "height_m", "before_m", "after_m"].map((key) => `${side}_revet_${key}`);
const wingKeys = (side: "in" | "out"): string[] => [
`wing_${side}`,
`wing_${side}_height_m`,
`wing_${side}_length_m`,
`wing_${side}_angle_deg`,
];
/** 시설 종류별로 **폼이 적는 칸** — `createFacilityOptionsForm().readOptions()` 가 쓰는 키 전부. */
export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
pipe: [
"pipe_diameter_mm",
"pipe_kind",
"wing_wall_type",
"inlet_type",
"inlet_structure",
"inlet_basin_form",
"inlet_basin_material",
"inlet_basin_length_m",
...revetKeys("inlet"),
"outlet_type",
...revetKeys("outlet"),
],
box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")],
ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"],
ford_bridge: [
"pipe_kind",
"pipe_diameter_mm",
"pipe_count",
"ford_width_m",
"ford_height_m",
...wingKeys("in"),
...wingKeys("out"),
],
revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")],
};
interface MergeableAttributes {
facility: string;
start_m?: number;
end_m?: number;
options?: Record<string, string | number>;
}
/** 저장할 시설 정보 — 같은 종류면 폼이 모르는 옛 칸을 이어 붙임. */
export function mergeFacilityOptions<T extends MergeableAttributes>(
previous: T | null,
next: T,
): T {
if (!previous || previous.facility !== next.facility) return next;
const managed = new Set(FORM_OPTION_KEYS[next.facility] ?? []);
const kept = Object.entries(previous.options ?? {}).filter(([key]) => !managed.has(key));
const options = { ...Object.fromEntries(kept), ...(next.options ?? {}) };
return { ...next, options: Object.keys(options).length ? options : undefined };
}
+4 -1
View File
@@ -30,6 +30,7 @@ import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
@@ -607,8 +608,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
pipeEditor.addAtChainage(chainageM);
},
updatePipeFacility(fromChainageM, toChainageM, attributes) {
// ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임.
const previous = facilityStore.get(fromChainageM);
facilityStore.set(fromChainageM, null);
facilityStore.set(toChainageM, attributes);
facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes));
if (Math.abs(fromChainageM - toChainageM) > 0.005) {
// 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고,
// attach가 새 위치로 시설 정보를 승계한다.
@@ -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;
}
@@ -32,6 +43,88 @@ export function select(options: ReadonlyArray<[string, string]>): HTMLSelectElem
return element;
}
/** 등록부 옵션 한 칸의 모양 — `StructureOptionField` 에서 이 칸이 쓰는 몫만. */
interface OptionShape {
input: "select" | "number" | "text";
choices: string[];
default: string | number | null;
required?: boolean;
/** 비워 두면 어떻게 되나 — 칸에 마우스를 올리면 보임. */
empty_means?: string | null;
/** 이 값을 넘으면 칸이 경고색 + 툴팁(막지 않음). */
warn_above?: number | null;
warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 툴팁이 이 사유를 먼저 보임. */
not_in_table?: string | null;
/** 기본값의 뜻 — 칸 밑 근거 한 줄. */
default_basis?: string | null;
}
/**
* 등록부 옵션 입력 한 칸 — **저장된 값만 칸에 넣고, 등록부 기본값은 제안으로만** 보임
* (숫자·글 칸은 회색 글씨, 고르기 칸은 빈 보기 이름). 2026-09-14 브레인 판정 「기본값을 몰래
* 확정으로 바꾸지 않는다」 — 칸에 채워 두면 저장이 그 값을 적어 사용자가 한 일 없이 확정이 됨.
* 고르기 칸은 늘 빈 보기를 둠 — 첫 보기를 미리 고르지 않음.
*/
export function optionControl(
option: OptionShape,
stored: string | number | undefined,
): HTMLInputElement | HTMLSelectElement {
const value = stored === undefined || stored === null ? "" : String(stored);
const suggested = option.default === null || option.default === "" ? "" : String(option.default);
// 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻.
const tip = option.not_in_table ?? option.empty_means ?? "";
if (option.input === "select") {
const blank = suggested
? `— 안 정함 (제안 ${suggested}) —`
: option.required
? "— 선택 —"
: "— 안 정함 —";
const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]);
element.value = value;
if (tip) element.title = tip;
return element;
}
const element =
option.input === "number" ? numberInput("0.1", "0") : document.createElement("input");
if (option.input !== "number") element.type = "text";
element.value = value;
element.placeholder = suggested ? `제안 ${suggested}` : option.required ? "필수 입력" : "";
if (tip) element.title = tip;
const limit = option.warn_above;
if (typeof limit === "number") {
// 기준을 넘으면 알리기만 — 막지 않음(2026-09-14 브레인 판정 ①·㉰).
const warn = (): void => {
const over = element.value.trim() !== "" && Number(element.value) > limit;
element.classList.toggle("is-outside-standard", over);
element.title = over ? (option.warn_message ?? "") : tip;
};
element.addEventListener("input", warn);
warn();
}
return element;
}
/** [제안값 넣기] — **누른 때만** 빈 칸에 등록부 기본값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양. */
export function suggestButton(
entries: ReadonlyArray<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }>,
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b05-structure__suggest";
button.textContent = "제안값 넣기";
button.addEventListener("click", () => {
const filled = entries.filter((entry) => entry.suggested && !entry.input.value);
filled.forEach((entry) => {
entry.input.value = entry.suggested;
entry.input.dispatchEvent(new Event("input", { bubbles: true }));
});
// 반영은 한 번만 — 칸마다 저장 흐름이 돌지 않게 마지막 칸만 change 를 울림.
filled.at(-1)?.input.dispatchEvent(new Event("change", { bubbles: true }));
});
return button;
}
/** 측점번호 + 잔여거리 두 칸 묶음. */
export interface StationFields {
wrap: HTMLElement;
+20 -35
View File
@@ -14,7 +14,6 @@
* ========================================================================== */
import {
defaultOptions,
isB05Option,
structureAnchorM,
type StructureInstance,
@@ -24,7 +23,7 @@ import {
} from "./B05_Profile_Api_Structures";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import { field, numberInput, select } from "./B05_Profile_UI_Structures_Fields";
import { field, optionControl, suggestButton } from "./B05_Profile_UI_Structures_Fields";
import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form";
import {
bindStructuresEvents,
@@ -316,42 +315,24 @@ export function createStructuresSection(
return;
}
optionRow.hidden = false;
const suggestions: Array<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }> =
[];
visible.forEach((option) => {
const preset = values[option.key] ?? option.default ?? "";
let input: HTMLInputElement | HTMLSelectElement;
if (option.input === "select") {
// 빈("선택하세요") 항목은 두지 않는다 — 첫 항목이 곧 기본값이고, 기본값은
// 구조물별로 레지스트리에서 지정한다(2026-08-17 사용자 지시 1).
const choices = option.choices.map((choice) => [choice, choice] as [string, string]);
// 기본값 없는 필수 항목은 **빈 칸으로** — 첫 항목을 슬쩍 고르면 근거 없는 값이 나간다.
const mustPick = option.required === true && (option.default ?? "") === "";
// **비워 두는 것이 뜻인 칸**(`empty_means`)도 같다 — 비면 계산 쪽이 기준값으로 도는데
// 첫 보기가 골라져 저장되면 기준과 다른 값이 조용히 나간다(2026-09-13:
// `fill_concrete_mpa` 가 180 으로 박혀 엔진 기준 210 과 갈렸다). 기본값이 **있는** 칸은
// 그대로 첫 보기를 쓴다 — 2026-08-17 「빈 보기 두지 않음」 지시가 그 자리다.
const meansEmpty = (option.empty_means ?? "") !== "" && (option.default ?? "") === "";
const keepEmpty = mustPick || meansEmpty;
if (keepEmpty) choices.unshift(["", mustPick ? "— 선택 —" : "— 안 정함 —"]);
input = select(choices);
input.value = String(preset || (keepEmpty ? "" : (option.choices[0] ?? "")));
} else if (option.input === "number") {
input = numberInput("0.1", "0");
input.value = String(preset ?? "");
if (option.required) (input as HTMLInputElement).placeholder = "필수 입력";
} else {
input = document.createElement("input");
(input as HTMLInputElement).type = "text";
input.value = String(preset ?? "");
}
const label = option.unit ? `${option.label} (${option.unit})` : option.label;
// `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다.
// ⭐ 저장된 값만 칸에 · 등록부 기본값은 제안(회색 글씨·빈 보기 이름)으로만 — 고르기 칸은
// 첫 보기를 미리 안 고름(2026-09-14 브레인 판정 ①②, `optionControl` 주석).
const input = optionControl(option, values[option.key]);
suggestions.push({ input, suggested: String(option.default ?? "") });
const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`;
// `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다
// (고를 수 없는 칸이라 제안이 아니라 프로그램 값).
const locked = option.enabled === false;
if (locked && !input.value) input.value = String(option.default ?? "");
if (locked) {
input.disabled = true;
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,
@@ -390,6 +371,9 @@ export function createStructuresSection(
input.addEventListener("change", () => refresh(true));
}
});
if (suggestions.some((entry) => entry.suggested)) {
optionRow.append(suggestButton(suggestions));
}
syncOptionLock();
syncRangeDisplay();
}
@@ -677,10 +661,11 @@ export function createStructuresSection(
endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step);
memoField.value = "";
syncPlacementFields();
renderOptionFields(type.managed_by ? undefined : defaultOptions(type));
// BOX암거는 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을 실어 폼을
// 연다 — [추가]만 눌러도 하류가 제원을 받는다(2026-08-17 사용자 확정 유지).
syncFacilityForm(typeId === "box_culvert" ? defaultOptions(type) : {});
// 기본값을 값으로 안 채움 — 칸은 비우고 제안으로만(브레인 판정 ②).
renderOptionFields();
// BOX암거도 기본값을 안 실음 — 제안으로만(판정 ③ · 옛 「[추가]만 눌러도 제원」 걷음).
// 비운 칸은 하류(횡단도)가 등록부 기본값으로 그림 — 저장만 안 함.
syncFacilityForm({});
// 임시 배치도 즉시 진행(A군).
if (type.managed_by) commitTempPipe();
syncButtons();
@@ -231,6 +231,8 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
ctx.optionInputs.find((entry) => entry.key === "length_m")?.input.focus();
return;
}
// 높이는 비워도 놓음 — 자리부터 잡고 치수를 뒤에 넣는 길을 막지 않음. 높이가 물량 밑수인
// 벽은 B08 에서 줄만 서고 미확정(금액 밖)으로 뜸(2026-09-14 브레인 판정 ①, `missing_height_reason`).
const before = ctx.readBeforeM();
if (anchor - before < -0.005) {
const beforeInput = ctx.optionInputs.find((entry) => entry.key === "before_m")?.input;
@@ -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 {
+9 -1
View File
@@ -207,8 +207,12 @@ export interface CulvertSideSpec {
/** 집수정 기준측점 전/후 몫(m) — 기슭막이와 같은 체계(2026-08-24). 기본 각 1m. */
basin_before_m?: number | null;
basin_after_m?: number | null;
/** 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3). */
/** 기슭막이 전면 기울기(1:n) 종전값 — 그림은 `revetWallSpec().lean`(표준경사 표)을 씀. */
face_slope?: number;
/** 독립 기슭막이 기울기 판정 칸(저장 원본) — 설치 측 · 성토/절토 · 전면 기울기(B08 과 같은 칸). */
face_side?: string | null;
face_role?: string | null;
face_slope_ratio?: number | string | null;
/** 보호공(물받이) 길이 = 낙차고 × 2 (사방교본 교차 참조, 2026-08-19 사용자 확정). */
apron_length_m?: number;
/** 보호공 두께 1.0m 내외 (사방교본 교차 참조). */
@@ -265,6 +269,8 @@ export interface FordSet {
min_cover_m: number;
wing_in: FordWingSpec;
wing_out: FordWingSpec;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
}
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
@@ -283,6 +289,8 @@ export interface BoxSet {
span_m: number;
wing_in: FordWingSpec;
wing_out: FordWingSpec;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
}
export interface CrossSection extends SectionStation {
+101 -12
View File
@@ -74,6 +74,26 @@ APRON_THICKNESS_M = 0.45
# 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다.
REVET_FACE_SLOPE = 0.3
# ── 관 벽 최소 높이 — ⚠ TS 짝: `B06_Section_UI_Cross_Culvert_Const.ts`
# (`REVET_FREEBOARD_M`·`REVET_HEIGHT_STEP_M`·`REVET_EMBED_DEPTH_M`·`pipeWallMinPureHeight`).
# 횡단도가 관 벽을 그리는 그 높이 — 구조물 표·구조물도 그림도 이 값을 씀(2026-09-14 브레인 판정
# 「그림 둘이 같은 벽을 다르게 그리면 결함 · 근거 있는 관경 기준으로 맞춤」). 거울 시험
# `test_b06_pipe_wall_height_mirror`.
REVET_FREEBOARD_M = 0.5 # 관 상단 ~ 벽 상단 여유고(잠정 · 토피 0.5 와 같은 값)
REVET_HEIGHT_STEP_M = 0.1 # 높이 눈금
REVET_EMBED_DEPTH_M = 0.5 # 근입 깊이
def pipe_wall_min_height_m(diameter_m: float) -> float:
"""관 벽 **순수 높이**(근입 포함) 최소값 — 관경 + 여유고를 0.1m 로 올린 뒤 근입을 더함.
⚠ 「최소」다 — 실제 설계값은 더 클 수 있어 이 값으로 선 수량은 미확정으로 둠(금액 밖).
"""
raw = float(diameter_m) + REVET_FREEBOARD_M
steps = math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9)
return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3)
# (2026-08-20 사용자 확정) 배관은 **수평도 가능** — 그림에서 경사를 강제로 만들지
# 않는다. config `DRAINAGE_PIPE_SLOPE_DEG`(10도)는 유효직경 수리계산 전용으로 남는다.
@@ -139,7 +159,9 @@ def _number(value: Any, fallback: float | None) -> float | None:
return fallback
def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
def _side_spec(
options: dict[str, Any], defaults: dict[str, Any], side: str, diameter_m: float = 1.0
) -> dict[str, Any]:
"""유입("inlet")·유출("outlet") 한쪽의 부속 제원.
구조가 집수정이면 기슭막이·보호공을 만들지 않는다 — 집수정 단면은 후속 작업이라
@@ -164,9 +186,10 @@ def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) ->
)
return spec
# 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(2026-09-14 · 옛 기본 2.5 걷음).
height = _number(
options.get(f"{side}_revet_height_m"),
_number(defaults.get(f"{side}_revet_height_m"), None),
_number(defaults.get(f"{side}_revet_height_m"), pipe_wall_min_height_m(diameter_m)),
)
length = _number(
options.get(f"{side}_revet_length_m"),
@@ -208,10 +231,11 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
values.get("pipe_diameter_mm"), _number(defaults.get("pipe_diameter_mm"), 1000.0)
)
kind = values.get("pipe_kind") or defaults.get("pipe_kind")
diameter_m = round((diameter_mm or 1000.0) / 1000.0, 3)
return {
"type": "pipe",
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
"diameter_m": diameter_m,
"min_cover_m": MIN_PIPE_COVER_M,
# 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다(2026-09-09). 한쪽만 기초유로
# 하는 일이 드물어 폼을 둘로 늘리지 않았다. 실무가 갈라 쓰면 그때 나눌 것.
@@ -219,8 +243,8 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
str(values.get("revet_foundation") or defaults.get("revet_foundation") or "").strip()
or None
),
"inlet": _side_spec(values, defaults, "inlet"),
"outlet": _side_spec(values, defaults, "outlet"),
"inlet": _side_spec(values, defaults, "inlet", diameter_m),
"outlet": _side_spec(values, defaults, "outlet", diameter_m),
}
@@ -249,6 +273,11 @@ def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]:
values.get(f"{role}_revet_after_m"), _number(values.get("after_m"), None)
),
"face_slope": REVET_FACE_SLOPE,
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본 그대로(2026-09-14 B5 · TS `revetSide`).
# 설치 측은 `side` 기본값(양쪽)을 붙이기 **전** 값이라야 B08(빈 칸 = 자동)과 같게 갈림.
"face_side": values.get("side"),
"face_role": values.get("face_role"),
"face_slope_ratio": values.get("face_slope_ratio"),
}
if height is not None and height > 0:
spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3)
@@ -308,6 +337,36 @@ def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> d
}
def _defaulted(values: dict[str, Any], fields: list[tuple[str, str, Any]]) -> list[str]:
"""정본에 안 적혀 **기본값으로 그린 칸** 「이름 값」 — 횡단도가 「기본값(미확정)」으로 적음.
2026-09-14 브레인 판정 ② — 저장만 안 하는 것으로는 모자람 · 그림이 설계값처럼 보이면 같은 병.
TS 짝 `defaulted`(`common_util_culvert_sets.ts`).
"""
found = []
for key, label, used in fields:
if used is None or values.get(key) not in (None, ""):
continue
found.append(f"{label} {used:g}" if isinstance(used, float) else f"{label} {used}")
return found
def _wing_defaulted(values: dict[str, Any], wing: dict[str, Any], side: str) -> list[str]:
"""날개벽 한쪽의 기본값 칸 — 안 세운 날개벽은 치수를 안 봄."""
name = "유입" if side == "in" else "유출"
prefix = f"wing_{side}"
fields: list[tuple[str, str, Any]] = [
(prefix, f"{name} 날개벽", "있음" if wing["installed"] else "없음")
]
if wing["installed"]:
fields += [
(f"{prefix}_height_m", f"{name} 날개벽 높이", wing["height_m"]),
(f"{prefix}_length_m", f"{name} 날개벽 길이", wing["length_m"]),
(f"{prefix}_angle_deg", f"{name} 날개벽 각도", wing["angle_deg"]),
]
return _defaulted(values, fields)
def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""세월교 1개소의 세트 제원(관 + 양측 측벽 + 바닥판 + 날개벽 연장).
@@ -323,12 +382,26 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
count = _number(values.get("pipe_count"), _number(defaults.get("pipe_count"), None))
depth = _number(values.get("ford_height_m"), None)
span = width if width and width > 0 else FORD_DEFAULT_WIDTH_M
pipe_count = max(int(count), 1) if count else 1
wing_in = _wing_spec(values, defaults, "in")
wing_out = _wing_spec(values, defaults, "out")
defaulted = _defaulted(
values,
[
("pipe_kind", "관종", str(kind) if kind else None),
("pipe_diameter_mm", "관경", diameter_mm),
("ford_width_m", "월류 폭", span),
("pipe_count", "배관 수량", pipe_count),
("ford_height_m", "월류 높이", 0.0),
],
)
return {
"type": "ford",
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
"pipe_count": max(int(count), 1) if count else 1,
"span_m": width if width and width > 0 else FORD_DEFAULT_WIDTH_M,
"pipe_count": pipe_count,
"span_m": span,
# 월류 높이 — 구체 위 노면은 이만큼 낮게 앉는다(단면은 월류부 가장 아래를 자른
# 자리다). 계획고를 통째로 내려 측벽·바닥판·절성토 면적이 함께 따라간다
# (2026-08-30 사용자 확정). 값이 없으면 0 = 내리지 않는다.
@@ -336,8 +409,11 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
"slab_thickness_m": FORD_SLAB_THICKNESS_M,
"wall_thickness_m": FORD_WALL_THICKNESS_M,
"min_cover_m": MIN_PIPE_COVER_M,
"wing_in": _wing_spec(values, defaults, "in"),
"wing_out": _wing_spec(values, defaults, "out"),
"wing_in": wing_in,
"wing_out": wing_out,
"defaulted": defaulted
+ _wing_defaulted(values, wing_in, "in")
+ _wing_defaulted(values, wing_out, "out"),
}
@@ -353,12 +429,14 @@ def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]:
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
depth = _number(values.get("ford_height_m"), None)
slope = _number(values.get("ford_slope_pct"), None)
span = width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M
return {
"type": "ford_pavement",
"span_m": width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M,
"span_m": span,
# 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다).
"depth_m": depth if depth and depth > 0 else None,
"slope_pct": slope,
"defaulted": _defaulted(values, [("ford_width_m", "월류 폭", span)]),
}
@@ -375,6 +453,8 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
inner_height = _number(values.get("body_height_m"), _number(defaults.get("body_height_m"), 2.0))
wall = FORD_WALL_THICKNESS_M
slab = FORD_SLAB_THICKNESS_M
wing_in = _wing_spec(values, defaults, "in")
wing_out = _wing_spec(values, defaults, "out")
return {
"type": "box",
"inner_width_m": inner_width or 2.0,
@@ -385,8 +465,17 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
"cover_m": BOX_COVER_M,
# 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장. 이 폭만큼 측점에 걸친다.
"span_m": (inner_width or 2.0) + 2 * wall,
"wing_in": _wing_spec(values, defaults, "in"),
"wing_out": _wing_spec(values, defaults, "out"),
"wing_in": wing_in,
"wing_out": wing_out,
"defaulted": _defaulted(
values,
[
("body_width_m", "본체 폭", inner_width or 2.0),
("body_height_m", "본체 높이", inner_height or 2.0),
],
)
+ _wing_defaulted(values, wing_in, "in")
+ _wing_defaulted(values, wing_out, "out"),
}
@@ -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
@@ -79,6 +79,9 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
"tiers": options.get("tiers"),
"lift_m": options.get("lift_m"),
"shift_m": options.get("shift_m"),
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키).
"face_role": options.get("face_role"),
"face_slope_ratio": options.get("face_slope_ratio"),
}
)
return found
+2 -3
View File
@@ -73,14 +73,13 @@ from B06_Section.B06_Section_Schema import (
SectionRegenerateRequest,
SectionSummaryResponse,
)
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for
from common_util.common_util_auth import verify_session
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import get_workflow_state
from config.config_db import get_db_pool, run_with_connection
from config.config_system import (
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
FOREST_ROAD_MIN_WIDTH_M,
NATURAL_SPOIL_MIN_GROUND_SLOPE,
SECTION_VERTICAL_EXAGGERATION,
@@ -149,7 +148,7 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON
earthwork_conversion=await conversion_factors_for(project_id),
haul_equipment_limits=[
HaulEquipmentLimit(key=key, max_distance_m=limit)
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
for key, limit in await haul_limits_for(project_id)
],
natural_spoil_min_ground_slope=NATURAL_SPOIL_MIN_GROUND_SLOPE,
)
+4 -1
View File
@@ -27,6 +27,7 @@ from B06_Section.B06_Section_Server_Calc_Prebuild import (
_mass_haul_context,
conversion_factors_for,
haul_inputs_for,
haul_limits_for,
)
from common_util.common_util_node_bundle import run_bundle_json
@@ -66,6 +67,8 @@ async def compute_haul_plan(
haul_inputs = await haul_inputs_for(project_id)
# 곡선이 쓰는 계수도 프로젝트가 고른 값으로 — 토적표·운반표와 같은 값이어야 한다.
factors = await conversion_factors_for(project_id)
# 장비 거리 경계도 프로젝트 값으로 — 도쟈 한계거리를 고쳤으면 배분이 그 값으로 선다.
limits = await haul_limits_for(project_id)
try:
output = await asyncio.to_thread(
run_bundle_json,
@@ -73,7 +76,7 @@ async def compute_haul_plan(
_NPM_SCRIPT,
{
"haul_plan_for": result,
"context": _mass_haul_context(haul_inputs, factors),
"context": _mass_haul_context(haul_inputs, factors, limits),
},
)
except Exception:
+34 -14
View File
@@ -14,8 +14,10 @@
* 실행: node <번들> <입력.json> <출력.json>
* 입력 { detail: 종횡단 상세(API와 같은 꼴), context: { earthwork_conversion,
* natural_spoil_min_ground_slope, haul_equipment_limits } }
* 출력 { areas: [{ chainage_m, cut_area_m2, … }], mass_haul: {…} | null }
* 출력 { areas: [{ chainage_m, cut_area_m2, … }], mass_haul: {…} | null,
* extra_walls: [{ chainage_m, extra_walls: [선 다단 벽…] }] }
* — areas 는 **구조물 트림이 있는 측점만**. 나머지는 표준 계산값이 이미 맞다.
* — extra_walls 는 관 주인 측점마다(빈 목록 포함) · B08 이 다단 기슭막이 줄을 세움(④).
* 끝 코드: 0 성공 / 2 인자 오류
* ========================================================================== */
@@ -23,7 +25,11 @@ import { readFileSync, writeFileSync } from "node:fs";
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
import { computeHaulPlan, haulPlanPayload } from "@util/common_util_mass_haul_balance";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
import {
applyStructureAreaRows,
extraWallRows,
structureAreaRows,
} from "./B06_Section_Structure_Layouts";
interface ServerCalcInput {
detail?: SectionDetailResponse;
@@ -77,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);
@@ -98,18 +106,30 @@ 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,
// 두 벌을 남긴다 — `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) } : {}),
...(drawingPlan ? { haul_plan_drawing: haulPlanPayload(drawingPlan) } : {}),
})
: null;
const massHaul = result
? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null)
: null;
writeFileSync(outputPath, JSON.stringify({ areas, mass_haul: massHaul }));
// 선 다단 벽 목록(④) — 관 연장처럼 기하가 세운 결과를 정본에 남겨 B08 이 줄을 세움.
const extraWalls = extraWallRows(sections);
writeFileSync(outputPath, JSON.stringify({ areas, mass_haul: massHaul, extra_walls: extraWalls }));
@@ -39,10 +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,
@@ -95,12 +97,25 @@ 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]]:
"""이 프로젝트가 쓸 운반장비 거리 경계(도쟈 한계거리를 고쳤으면 그 값). 못 읽으면 기본값."""
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
root = resolve_stored_project_path(stored_path)
except Exception:
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 경계로 진행: project_id=%s", project_id)
return list(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)
return haul_equipment_limits(quantity_settings(root))
def _mass_haul_context(
haul_inputs: dict[str, Any] | None = None,
factors: dict[str, dict[str, float]] | None = None,
limits: list[tuple[str, float | None]] | None = None,
) -> dict[str, Any]:
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.
@@ -118,9 +133,10 @@ def _mass_haul_context(
# 프로젝트가 고른 계수가 있으면 그것, 없으면 정본 기본값.
"earthwork_conversion": factors or EARTHWORK_CONVERSION_FACTORS,
"natural_spoil_min_ground_slope": NATURAL_SPOIL_MIN_GROUND_SLOPE,
# 프로젝트가 도쟈 한계거리를 고쳤으면 그 값, 없으면 정본 기본값.
"haul_equipment_limits": [
{"key": key, "max_distance_m": limit}
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
for key, limit in (limits or EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)
],
# ⚠ B08 이 아직 이 값을 내지 않는다(2026-09-09) — 그때까지 `None`(아직 안 옴)이다.
# 값을 내기 시작하면 여기에 실어 주기만 하면 통로가 이어진다.
@@ -139,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)
# ⚠ 사토장은 **맨 뒤**다 — 앞의 두 보정이 설계를 다시 계산하면서 사토장 칸을 지운다.
@@ -183,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)
@@ -202,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
@@ -220,7 +265,9 @@ 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))
haul_inputs,
mixed_conversion_factors(quantity_settings(project_root)),
haul_equipment_limits(quantity_settings(project_root)),
),
},
)
@@ -229,7 +276,20 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
output = {}
rows = output.get("areas")
mass_haul = output.get("mass_haul")
if not fixed and not rows and not mass_haul:
# 선 다단 벽 목록(④) — 목록째 얹음(수가 아니라 `_AREA_KEYS` 로는 못 거름). 주인 측점엔 빈 목록도.
# 관 기준벽 벽 몸 겹침(㉡) — 역할별 ㎡ 한 벌. 빈 dict 도 실어 옛 값을 지움.
extra_walls = [
(
float(row["chainage_m"]),
{
"extra_walls": row["extra_walls"],
"wall_fill_overlap": row.get("wall_fill_overlap") or {},
},
)
for row in output.get("extra_walls") or []
if isinstance(row, dict) and isinstance(row.get("extra_walls"), list)
]
if not fixed and not rows and not mass_haul and not extra_walls:
return 0
updated = 0
@@ -265,6 +325,10 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
updated = await merge_cross_section_designs(
connection, route_id=route_id, entries=area_entries, replace=False
)
if extra_walls:
await merge_cross_section_designs(
connection, route_id=route_id, entries=extra_walls, replace=False
)
if isinstance(mass_haul, dict):
await merge_longitudinal_section_data(
connection, route_id=route_id, data_patch={"mass_haul": mass_haul}
+103 -12
View File
@@ -10,12 +10,17 @@
* DOM·SVG 를 부르지 않는다. 브라우저에서도 Node 에서도 같은 결과가 나와야 한다.
* ========================================================================== */
import { computeStructureAreas } from "@util/common_util_cross_structure_areas";
import { computeStructureAreas, wallBodyInFillM2 } from "@util/common_util_cross_structure_areas";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import { computeCardCulvert, culvertLinkFor } from "./B06_Section_UI_Cross_Culvert_Wire";
import type { WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
import {
computeCardCulvert,
culvertLinkFor,
tierSpanOf,
} from "./B06_Section_UI_Cross_Culvert_Wire";
import type {
ExtraWallControl,
InletStructureControl,
@@ -144,6 +149,22 @@ export const STRUCTURE_ROW_KEYS = [
"pipe_length_m",
] as const;
/** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */
function areaInputOf(section: CrossSection, layouts: StoredLayouts) {
const trim = trimOfLayouts(layouts);
const designLine = layouts.design.design_line;
if (!trim || !Array.isArray(designLine) || designLine.length < 2) return null;
const ground = section.samples
.filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number")
.map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
return {
designLine: designLine as Array<{ offset_m: number; elevation_m: number }>,
ground,
trim,
};
}
/** 측점 하나의 폐회로 면적 — 구조물이 없으면 null(표준 계산값이 이미 맞다). */
function areaRowOf(
section: CrossSection,
@@ -176,17 +197,11 @@ function areaRowOf(
typeof pipeLengthM === "number" && pipeLengthM > 0
? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) }
: null;
const trim = trimOfLayouts(layouts);
const areaInput = areaInputOf(section, layouts);
if (!areaInput) return pipeRow;
const design = layouts.design;
if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return pipeRow;
const ground = section.samples
.filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number")
.map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
const areas = computeStructureAreas({
designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>,
ground,
trim,
...areaInput,
rockBoundaryOffsetM:
typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null,
});
@@ -219,6 +234,82 @@ export function structureAreaRows(
.filter((row): row is Record<string, number> => !!row);
}
/** 선 다단 벽 한 매 — B08 이 줄을 세우는 값(파이썬 `B08_Quantity_Engine_Pipe.facility_structures`). */
export interface BuiltExtraWall {
key: string;
side: "outlet" | "basin";
form: string;
/** 순수 높이(m) — 바닥~상단(근입 0.5 포함). 지형에 맞춰 선 값. */
height_m: number;
/** 사용자가 높이·형태를 적었나 — 안 적었으면 B08 이 미확정으로 셈. */
height_set: boolean;
form_set: boolean;
before_m: number;
after_m: number;
/** 벽 몸 중 성토 폐회로 안에 든 넓이(㎡) — 구조물 면적을 안 빼서(2026-09-06 확정) 성토에도 셈.
* B08 이 「두 번 셈」 사유로 올림(2026-09-14 브레인). 면적을 못 내는 측점은 null. */
fill_overlap_m2: number | null;
}
export interface ExtraWallRow {
chainage_m: number;
extra_walls: BuiltExtraWall[];
wall_fill_overlap: Record<string, number>;
}
/**
* 관(·독립 기슭막이) 주인 측점마다 **실제로 선** 다단 벽 목록(④, 2026-09-14).
*
* 단 수·높이는 지형이 정해 요청보다 적게 설 수 있음 — 그래서 요청 칸(`extra_wall_counts`)이 아니라
* 기하가 세운 결과를 남김(관 연장 `pipe_length_m` 과 같은 길). 주인 측점에는 빈 목록도 실어 옛 값을 지움.
* `wall_fill_overlap` — 관 기준벽 역할별 벽 몸 ∩ 성토 폐회로(㎡). B08 기준벽 줄 비고.
*/
export function extraWallRows(sections: readonly CrossSection[]): ExtraWallRow[] {
const rows: ExtraWallRow[] = [];
for (const section of sections) {
if (!section.culvert) continue;
const owner = pipeOwnerChainage(section, sections);
if (owner !== null && Math.abs(owner - section.chainage_m) > CHAINAGE_TOLERANCE_M) continue;
const layouts = computeStoredLayouts(section, sections);
const culvert = layouts?.culvert;
if (!layouts || !culvert) continue;
const areaInput = areaInputOf(section, layouts);
const walls: BuiltExtraWall[] = [];
const built: Array<["outlet" | "basin", WallLayout[], WallAdjust[]]> = [
["outlet", culvert.extraWalls, culvert.revetShift.extras],
["basin", culvert.basinExtras, culvert.revetShift.basinExtras],
];
for (const [side, list, applied] of built) {
list.forEach((wall, index) => {
const key = `${side === "basin" ? "bextra" : "extra"}${index}`;
const span = tierSpanOf(section, key);
walls.push({
key,
side,
form: wall.form ?? "",
height_m: Number((wall.height + REVET_EMBED_DEPTH_M).toFixed(3)),
height_set: applied[index]?.h != null,
form_set: section.design?.revet_adjust?.[key]?.m != null,
before_m: span.beforeM,
after_m: span.afterM,
fill_overlap_m2: areaInput
? Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3))
: null,
});
});
}
// 관 기준벽(유입·유출)도 같은 겹침 — 다단에만 사유가 뜨면 「다단만 문제」로 읽힘(㉡ 2026-09-14).
const wall_fill_overlap: Record<string, number> = {};
for (const wall of culvert.walls) {
if (areaInput && (wall.role === "inlet" || wall.role === "outlet")) {
wall_fill_overlap[wall.role] = Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3));
}
}
rows.push({ chainage_m: section.chainage_m, extra_walls: walls, wall_fill_overlap });
}
return rows;
}
/** 낸 면적을 측점 자료에 도로 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이도록. */
export function applyStructureAreaRows(
sections: readonly CrossSection[],
@@ -49,6 +49,40 @@ function fillSlopeInfo(section: CrossSection): HTMLElement | null {
return info;
}
/**
* ** ** `[보이는 글, 툴팁]` (2026-09-14 ).
* .
*/
function facilityNotes(section: CrossSection): Array<[string, string]> {
const notes: Array<[string, string]> = [];
const sets: Array<[string, string[] | undefined]> = [
["BOX암거", section.box?.defaulted],
["세월교", section.ford?.defaulted],
["물넘이포장", section.ford_pavement?.defaulted],
];
for (const [name, items] of sets) {
if (!items?.length) continue;
notes.push([
`${name} 기본값(미확정)`,
`B05 시설 칸에 안 적어 기본값으로 그림 — ${items.join(" · ")} · 적으면 그 값으로 그림`,
]);
}
const own = section.culvert?.hidden_pipe ? section.culvert : null;
if (own && [own.inlet, own.outlet].some((side) => !side.revet_height_m)) {
notes.push([
"⚠ 기슭막이 높이 없음 — 근입만 그림",
"독립 기슭막이 높이는 설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 비우면 수량도 미확정 · B05 시설 칸에 적으면 그림",
]);
}
if (section.ford_pavement && !section.ford_pavement.depth_m) {
notes.push([
"⚠ 월류 높이 없음 — 파임 안 그림",
"물넘이포장 월류 높이를 안 적어 파인 노면을 안 그림(수치를 지어내지 않음) — B05 시설 칸에 월류 높이를 적으면 그림",
]);
}
return notes;
}
/**
* ( ).
*
@@ -86,6 +120,13 @@ export function appendCardHeader(
openSlope.title = L("B06_Cross_SlopeUnclosed_Tip");
meta.append(openSlope);
}
for (const [text, tip] of facilityNotes(section)) {
const note = document.createElement("span");
note.className = "b06-cross-card__warning";
note.textContent = text;
note.title = tip;
meta.append(note);
}
const structureName = section.structure;
if (structureName) {
const structure = document.createElement("span");
+1 -2
View File
@@ -13,7 +13,6 @@
import {
REVET_EMBED_DEPTH_M,
FILL_SLOPE_RATIO_MIN,
REVET_LEAN_RATIO,
pipeWallThicknessM,
revetHeightLimit,
} from "./B06_Section_UI_Cross_Culvert_Geom";
@@ -183,7 +182,7 @@ export function appendCulvertOverlay(
`${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${(
wall.height + REVET_EMBED_DEPTH_M
).toFixed(1)}m` +
`(상단 = 사면선 접점, 전면 1:${REVET_LEAN_RATIO}` +
`(상단 = 사면선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사)` +
`, 높이 한계 ${revetHeightLimit(wall.form).toFixed(1)}m — 교본 7-3)` +
(wall.floatGapM > 0.01
? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 구조물 별도(추가 예정)`
@@ -16,7 +16,8 @@ export const MIN_PIPE_COVER_M = 0.5;
/** 기슭막이 벽 두께(m) — 실무 견치돌 뒷길이 관측치 45㎝(울진 L3=45). 표시용 형상 값. */
export const REVET_THICKNESS_M = 0.45;
/** 기슭막이 전면 기울기(1:n) — 돌쌓기 전면 1:0.3(교본 7-3). 벽이 사면 쪽으로 기운다. */
/** (1:n) **** (·) /
* . `B06_Section_UI_Cross_Lean.leanOf`( 13-4-4 [] ). */
export const REVET_LEAN_RATIO = 0.3;
/** 돌붙임 두께(m) — 실무 돌붙임 L3=45(울진 1공구 수량집계표). */
@@ -6,7 +6,7 @@
* (2026-08-22 ):
* · ** 1:1.2** .
* · **( ) **.
* · = ** +0.5m와 (1:0.3)
* · = ** +0.5m와 (1:n · )
* **( ).
* · ( 0.5m ) ** ** .
* ========================================================================== */
@@ -48,6 +48,8 @@ export interface OutletExtrasInput {
ownerForm?: string | null;
/** 1회성 등간격 배치(2026-08-22 사용자 ①) — 단별 d를 사면 구간이 같아지게 재계산. */
equalize?: boolean;
/** 단 벽의 전면 기울기 — 형태·순수 높이로 표준경사를 고름(없으면 종전 1:0.3). */
leanFor?: (form: string, pureHeightM: number) => number;
}
export interface OutletExtrasResult {
@@ -167,9 +169,9 @@ export function inletGroundConnector(
return null;
}
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이에 따라 커진다. */
function jointRunOf(height: number): number {
return 0.25 * REVET_THICKNESS_M + (REVET_LEAN_RATIO / 2) * height;
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이·기울기에 따라 커진다. */
function jointRunOf(height: number, lean: number): number {
return 0.25 * REVET_THICKNESS_M + (lean / 2) * height;
}
/** 끝 성토부선 — src에서 1:1.2로 내려가며 원지반을 만나면 끝(지반이 높으면 지반 따름). */
@@ -226,16 +228,17 @@ function buildExtraWall(
material: RevetMaterial,
floatGapM: number,
form: string | null = null,
lean: number = REVET_LEAN_RATIO,
): WallLayout {
const thickness = REVET_THICKNESS_M;
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const baseWidth = thickness * 1.5 + lean * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
const topFront = topJoint + outward * thickness;
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
topFront + outward * lean * (topElevation - elevation);
// 하단 = 기준선(anchor) 아래 근입 0.5m **고정** — 원지반에 묻혀도 바닥을 더
// 내리지 않는다. 벽 형상은 높이값으로만 정한다(2026-08-23 사용자 확정 —
// 종전 전면 발끝 지반 추적 수렴 삭제).
@@ -256,6 +259,7 @@ function buildExtraWall(
height,
floatGapM,
material,
lean,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
@@ -281,7 +285,6 @@ function buildExtraWall(
export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult {
const { outward, groundAt, limitOffset } = input;
const thickness = REVET_THICKNESS_M;
const heightDenominator = 1 - REVET_LEAN_RATIO / 2 / FILL_SLOPE_RATIO_MIN;
/** 다단 1회 전개 — plan: "user"(조작값) / "greedy"(등간격 1차 근사) / d 명시 배열. */
const cascadeOnce = (plan: "user" | "greedy" | number[]): OutletExtrasResult => {
const walls: WallLayout[] = [];
@@ -315,6 +318,9 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
/** ( )~ · .
* **** . */
let exposed = height - REVET_EMBED_DEPTH_M;
// 전면 기울기 — 형태·요청 높이로 표준경사(2026-09-14 B5). 자리 닫힌식의 분모도 따라감.
const lean = input.leanFor?.(form, height) ?? REVET_LEAN_RATIO;
const heightDenominator = 1 - lean / 2 / FILL_SLOPE_RATIO_MIN;
/** 자리 x(벽 하단 중점)에 지반 안착 + 상단이 성토선에 닿는 데 필요한 높이. */
const heightAt = (x: number): number => {
@@ -350,7 +356,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
}
if (autoOffset === null) autoOffset = bestOffset;
if (autoOffset === null) break; // 세울 만한 지형이 아니다 — 되는 만큼만.
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed);
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed, lean);
/** 자동 자리(선반 0·수직 0)의 벽 상단 표고 — 조작은 여기서부터 잰다. */
const autoTop = src.elevation - autoJointRun / FILL_SLOPE_RATIO_MIN;
// 관통 금지: 상단 ≤ 윗단 하단 → 그만큼은 반드시 내려간다.
@@ -378,7 +384,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const placeable = (xShift: number, vShift: number): boolean => {
const topE = autoTop - vShift;
const aX = autoOffset + outward * xShift;
const jointX = aX - outward * jointRunOf(exposed);
const jointX = aX - outward * jointRunOf(exposed, lean);
if (
Math.max(groundAt(jointX), groundAt(jointX + outward * REVET_THICKNESS_M)) >=
topE - 0.01
@@ -388,7 +394,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const bottom = bottomAt(aX, baseE);
const startE = bottom + REVET_EMBED_DEPTH_M;
const topFrontX = jointX + outward * REVET_THICKNESS_M;
const startX = topFrontX + outward * REVET_LEAN_RATIO * (topE - startE);
const startX = topFrontX + outward * lean * (topE - startE);
if (groundAt(startX) < startE + 0.05) return true;
// 매몰 — 3도 절토선(계류 쪽 내림)이 원지반과 다시 만나면 허용, 아니면 이동 금지.
return (
@@ -438,6 +444,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
material,
Math.max(0, base - groundBase),
form,
lean,
);
// 요청한 좌우 이동을 지형이 막았으면 그 양을 남긴다 — 「눌러도 안 움직인다」의 까닭을
// 툴팁으로 알리기 위함이다(2026-09-06 실측: 다섯 측점 중 넷이 1.0m 요청에 0.0m 이동).
@@ -479,8 +486,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
src = {
offset:
wall.points[2].offset +
outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - startElevation),
wall.points[2].offset + outward * wall.lean * (wall.topJoint.elevation - startElevation),
elevation: startElevation,
};
prevBottom = wall.bottomBack.elevation;
@@ -2,7 +2,7 @@
* B06_Section_UI_Cross_Culvert_Geom.ts
* (··) ** ** (`_Cross_Culvert.ts`) .
* `section.culvert` ( ).
* 1:0.3 , ,
* 1:n (n = · `leanOf`), ,
* (designTrim). = . `_Basin.ts`, · `_Extra.ts`.
* ========================================================================== */
@@ -24,6 +24,7 @@ import {
revetTargetHeight,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import { leanOf } from "./B06_Section_UI_Cross_Lean";
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type {
BasinLayout,
@@ -171,8 +172,19 @@ export function computeCulvertLayout(
const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) });
const adjInlet = adjustOf(revetShift?.inlet);
const adjOutlet = adjustOf(revetShift?.outlet);
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) =>
revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter);
// 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 형태 × 높이 — B08 과 한 벌, 2026-09-14 B5).
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => {
const wall = revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter);
const lean = leanOf({
form: wall.form,
height_m: wall.pureHeight,
section_mode: section.design?.section_mode,
side: culvert.hidden_pipe ? spec.face_side : null,
face_role: spec.face_role,
face_slope_ratio: spec.face_slope_ratio,
});
return { ...wall, lean };
};
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
// 조정창 선택지 가용성 — 판정은 Basin이 맡는다.
@@ -204,14 +216,21 @@ export function computeCulvertLayout(
edge: { offset_m: number },
outward: number,
wallHeight: number,
): number => edge.offset_m + outward * (fillWallBaseWidth(wallHeight) / 2 - REVET_TRAP_TOP_M);
lean: number,
): number =>
edge.offset_m + outward * (fillWallBaseWidth(wallHeight, lean) / 2 - REVET_TRAP_TOP_M);
if (!basinReason && designAt) {
const inletBaseAt = (offset: number): number =>
Math.min(groundAt(offset), invertCap(inletInfo.edge));
// 4축 배치는 공용 풀이(placeInletWall — Solve). 유입을 내리면 유출도 따라
// 내려가므로, 유출이 못 받으면 유입도 못 내려간다(outletGuard).
const placed = placeInletWall({
autoOffset: slopeZeroAnchor(inletInfo.edge, inletInfo.outward, inletWallSpec.height),
autoOffset: slopeZeroAnchor(
inletInfo.edge,
inletInfo.outward,
inletWallSpec.height,
inletWallSpec.lean,
),
outward: inletInfo.outward,
height: inletWallSpec.height,
baseElevation0: inletInfo.edge.elevation_m - inletWallSpec.height,
@@ -238,6 +257,7 @@ export function computeCulvertLayout(
outletInfo.limit,
),
limitOffset: outletInfo.limit,
lean: outletWallSpec.lean,
},
});
appliedAdjust.inlet.x = placed.x;
@@ -308,6 +328,7 @@ export function computeCulvertLayout(
vertical: WallVertical | null = null,
material: RevetMaterial = "dry",
formLabel: string | null = null,
lean: number = REVET_LEAN_RATIO,
): WallLayout | null => {
// spec의 "집수정"은 ruleReason에 이미 반영 — 여기서 되살리면 revet 선택이 깨진다.
const reason: BasinLayout["reason"] | null = forceBasinReason;
@@ -340,13 +361,13 @@ export function computeCulvertLayout(
}
return null;
}
// 형상: 배면 수직 + 계류측 1:0.3 평행사변형 띠. 높이는 vertical(계산용)이 들고 온다.
// 형상: 배면 수직 + 계류측 1:n 평행사변형 띠(n = 표준경사). 높이는 vertical(계산용)이 들고 온다.
const height =
vertical?.height ?? Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
if (!(height > 0.05)) return null;
const floatGapM = vertical?.floatGapM ?? 0;
// 자리 기준 = **하단선 중점**(2026-08-21) — anchor.elevation은 그 자리 관 invert.
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const baseWidth = thickness * 1.5 + lean * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
@@ -357,7 +378,7 @@ export function computeCulvertLayout(
// 원지반에 묻혀도 바닥을 더 내리지 않는다. 벽 형상은 높이값으로만 정한다
// (2026-08-23 사용자 확정 — 종전 전면 발끝 지반 추적 수렴 삭제).
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
topFront + outward * lean * (topElevation - elevation);
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
@@ -376,6 +397,7 @@ export function computeCulvertLayout(
height,
floatGapM,
material,
lean,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
@@ -411,6 +433,7 @@ export function computeCulvertLayout(
wallVertical.inlet,
inletWallSpec.material,
inletWallSpec.form,
inletWallSpec.lean,
);
// 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21).
const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation);
@@ -421,6 +444,7 @@ export function computeCulvertLayout(
outletInfo.edge,
outletInfo.outward,
outletWallSpec.height,
outletWallSpec.lean,
);
const outletZeroInvert = outletInfo.edge.elevation_m - outletWallSpec.height;
const outletLineInvertAt = (offset: number): number =>
@@ -461,6 +485,7 @@ export function computeCulvertLayout(
wallVertical.outlet,
outletWallSpec.material,
outletWallSpec.form,
outletWallSpec.lean,
);
// ── 관 축 확정 — 관 하단선은 시작점과 유출 벽 전면 기준선 교차점을 잇는다.
const pipeStart = basinPipeEnd ?? inlet;
@@ -471,8 +496,7 @@ export function computeCulvertLayout(
const reference = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
return {
offset:
wall.points[2].offset +
wall.outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - reference),
wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - reference),
elevation: reference,
};
};
@@ -527,6 +551,7 @@ export function computeCulvertLayout(
},
outletWallSpec.material,
outletWallSpec.form,
outletWallSpec.lean,
);
// 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①).
const face = outletWall ? outletPipeEnd(outletWall) : outletWallAnchor;
@@ -599,9 +624,18 @@ export function computeCulvertLayout(
)
: null;
// 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 형태·높이마다 표준경사(B08 은 다단을 안 셈).
const extraLean = (form: string, pureHeightM: number): number =>
leanOf({
form,
height_m: pureHeightM,
section_mode: section.design?.section_mode,
side: culvert.hidden_pipe ? culvert.inlet.face_side : null,
});
// 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로).
// 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다.
const extras = buildExtrasAt(outletWall ? pipeCorners.outlet.bottom : null, {
const outletStart = outletWall ? pipeCorners.outlet.bottom : null;
const outletInput = {
startBottomElevation: outletWall?.bottomBack.elevation ?? 0,
outward: outletInfo.outward,
groundAt,
@@ -609,23 +643,47 @@ export function computeCulvertLayout(
adjusts: (revetShift?.extras ?? []).map(adjustOf),
ownerForm: outletWallSpec.form,
equalize: equalizeExtras === true,
});
leanFor: extraLean,
};
const extras = buildExtrasAt(outletStart, outletInput);
// 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 —
// 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자).
const basinExtras = buildExtrasAt(
culvert.hidden_pipe ? (inletWall ? pipeCorners.inlet.bottom : null) : basinFillStart,
{
startBottomElevation: culvert.hidden_pipe
? (inletWall?.bottomBack.elevation ?? 0)
: basinFillBottom,
outward: inletInfo.outward,
groundAt,
limitOffset: inletInfo.limit,
adjusts: (revetShift?.basinExtras ?? []).map(adjustOf),
ownerForm: inletWallSpec.form,
const basinStart = culvert.hidden_pipe
? inletWall
? pipeCorners.inlet.bottom
: null
: basinFillStart;
const basinInput = {
startBottomElevation: culvert.hidden_pipe
? (inletWall?.bottomBack.elevation ?? 0)
: basinFillBottom,
outward: inletInfo.outward,
groundAt,
limitOffset: inletInfo.limit,
adjusts: (revetShift?.basinExtras ?? []).map(adjustOf),
ownerForm: inletWallSpec.form,
equalize: false,
leanFor: extraLean,
};
const basinExtras = buildExtrasAt(basinStart, basinInput);
/** ** ( ) ** (2026-09-14
* · ). .
* B08 (`facility_structures`) . */
const confirmedOnly = (
built: OutletExtrasResult,
start: OffsetPoint | null,
input: typeof outletInput,
): OutletExtrasResult => {
const firstUnset = built.appliedAdjusts.findIndex((applied) => applied.h == null);
if (firstUnset < 0) return built;
return buildExtrasAt(start, {
...input,
adjusts: input.adjusts.slice(0, firstUnset),
equalize: false,
},
);
});
};
const extrasForArea = confirmedOnly(extras, outletStart, outletInput);
const basinExtrasForArea = confirmedOnly(basinExtras, basinStart, basinInput);
// ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이
// 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다.
@@ -665,8 +723,8 @@ export function computeCulvertLayout(
trimMinSlope = { points: [...(trimMinSlope?.points ?? []), ...points] };
}
};
extendTrimSlope(drawnFillPoints(extras), outletInfo.outward);
extendTrimSlope(drawnFillPoints(basinExtras), inletInfo.outward);
extendTrimSlope(drawnFillPoints(extrasForArea), outletInfo.outward);
extendTrimSlope(drawnFillPoints(basinExtrasForArea), inletInfo.outward);
const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null;
const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null;
@@ -23,9 +23,10 @@ import type {
} from "./B06_Section_UI_Cross_Culvert_Types";
import { slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
/** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. */
export function fillWallBaseWidth(height: number): number {
return REVET_THICKNESS_M * 1.5 + REVET_LEAN_RATIO * height;
/** (m) . ( ) .
* `lean` = ( · `revetWallSpec().lean`). */
export function fillWallBaseWidth(height: number, lean: number = REVET_LEAN_RATIO): number {
return REVET_THICKNESS_M * 1.5 + lean * height;
}
/** 유효 지반 샘플 → offset 오름차순 보간기. 범위 밖은 끝값 클램프, 샘플 없으면 null. */
@@ -252,6 +253,7 @@ export function minShoulderWallOffset(
baseAt: (offset: number) => number,
toeOffset: number,
limitOffset: number,
lean: number = REVET_LEAN_RATIO,
): number | null {
const span = (limitOffset - edge.offset_m) * outward;
if (!(span > 0)) return null;
@@ -260,7 +262,7 @@ export function minShoulderWallOffset(
const offset = edge.offset_m + outward * ((span * i) / steps);
const rise = edge.elevation_m - (baseAt(offset) + height);
if (rise < FILL_MIN_RISE_M) continue;
const joint = offset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
const joint = offset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M);
if (((joint - edge.offset_m) * outward) / rise < FILL_SLOPE_RATIO_MIN) continue;
if ((offset - toeOffset) * outward > 1e-6) {
const toeRise = edge.elevation_m - (baseAt(toeOffset) + height);
@@ -296,13 +298,14 @@ export function solveWallVertical(
groundBase: number,
minHeight: number,
limitHeight: number,
lean: number = REVET_LEAN_RATIO,
): WallVertical {
const gridUp = (value: number): number =>
Math.ceil(value / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M;
let height = minHeight;
let desiredTop = groundBase + minHeight;
for (let i = 0; i < 4; i += 1) {
const joint = anchorOffset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
const joint = anchorOffset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M);
const run = Math.max(0, (joint - edge.offset_m) * outward);
desiredTop = edge.elevation_m - run / FILL_SLOPE_RATIO_MIN;
const next = Math.min(Math.max(gridUp(desiredTop - groundBase), minHeight), limitHeight);
@@ -438,6 +441,8 @@ export function outletReceivable(input: {
toeOffset: number;
limitOffset: number;
groundAt: (offset: number) => number;
/** 유출 벽 전면 기울기(표준경사). */
lean?: number;
}): boolean {
const invertAt = (offset: number): number =>
Math.min(input.groundAt(offset), input.inletElevation);
@@ -449,6 +454,7 @@ export function outletReceivable(input: {
invertAt,
input.toeOffset,
input.limitOffset,
input.lean,
) ?? input.toeOffset;
const invert = invertAt(auto);
if (invert >= input.groundAt(auto) - 0.01) return true;
@@ -486,6 +492,7 @@ export function placeInletWall(input: {
height: number;
toeOffset: number;
limitOffset: number;
lean?: number;
};
/** 매몰-무교차 자리 금지(유출 벽 규칙) — 독립 기슭막이는 유입도 같은 규칙을 탄다. */
requireCrossing?: boolean;
@@ -80,6 +80,8 @@ export interface WallLayout {
shiftFloorM?: number;
/** 재질(메/찰/콘크리트) — 높이 한계를 정한다(2026-08-22 사용자). */
material: RevetMaterial;
/** 전면 기울기 1:n 의 n — 품셈 13-4-4 [주]⑪ 표준경사(`wallLeanRatio`, B08 과 한 벌 · 2026-09-14). */
lean: number;
outward: number;
/** 합성 단면(하부 사다리꼴 + 상부 평행사변형) 꼭짓점 — 데이터 좌표. */
points: OffsetPoint[];
@@ -27,6 +27,8 @@ export interface FordPavementSpec {
depth_m: number | null;
/** 유입 → 유출 바닥 경사(%). 비우면 노면 횡단경사를 쓴다. */
slope_pct: number | null;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
}
interface Edge {
+18
View File
@@ -0,0 +1,18 @@
/* =============================================================================
* B06_Section_UI_Cross_Lean.ts
* 13-4-4 [] (`resources/data_masonry/masonry_slope_*`)
* (2026-09-14 B5 · B08 ). `common_util_masonry_slope.ts`( ).
*
* (JSON) Node
* . ** **,
* ( ··C군 ).
* import `test_b06_wall_lean_table` .
* ========================================================================== */
import { type WallLeanInput, wallLeanRatio } from "../common_util/common_util_masonry_slope";
import masonrySlope from "../resources/data_masonry/masonry_slope_2026-01-01.json";
/** 벽 전면 기울기 n — 표를 못 고르면 종전 0.3. */
export function leanOf(input: WallLeanInput): number {
return wallLeanRatio(masonrySlope, input);
}
+30 -3
View File
@@ -23,10 +23,10 @@ import {
PIPE_CONNECT_GRADE,
PIPE_WALL_DEFAULT_RUN_M,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
revetHeightLimit,
} from "./B06_Section_UI_Cross_Culvert_Const";
import { leanOf } from "./B06_Section_UI_Cross_Lean";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import { fillWallBaseWidth, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
import { buildExtrasAt, slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
@@ -61,8 +61,17 @@ export interface RevetmentSpec {
/** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */
lift_m?: number | null;
shift_m?: number | null;
/** 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 성토/절토 · 전면 기울기(2026-09-14 B5). */
face_role?: string | null;
face_slope_ratio?: number | string | null;
}
/** 표준경사 표를 읽는 벽 종류 → 형태. B08 이 돌쌓기 식으로 세는 것만(큰돌쌓기·옹벽은 종전 0.3). */
const TABLE_FORM_BY_TYPE: Record<string, string> = {
masonry_wet: "돌쌓기(찰)",
masonry_dry: "돌쌓기(메)",
};
/** 조정창 조작값 중 이 벽이 쓰는 축 — 배관 벽과 같은 형태(x 좌우·d 사면·h 높이). */
export interface RevetmentAdjust {
x?: number;
@@ -187,9 +196,19 @@ export function computeRevetmentLayout(
/** 기준선(근입 위)~상단 — 도형 계산은 종전대로 이 값으로 한다. */
const height = pureHeight - REVET_EMBED_DEPTH_M;
// 전면 기울기 — B08 이 그 벽을 셀 때와 같은 칸(종류·요청 높이·단면유형·설치 측·사용자 칸)으로
// 표준경사를 고름(2026-09-14 B5). 높이는 형태 한계로 자르기 **전** 값 — B08 은 저장 높이를 씀.
const lean = leanOf({
form: TABLE_FORM_BY_TYPE[spec.type_id] ?? (spec.type_id === "revetment" ? spec.form : null),
height_m: requestedHeight,
section_mode: design.section_mode,
side: spec.side,
face_role: spec.face_role,
face_slope_ratio: spec.face_slope_ratio,
});
const roadSlope = roadSlopePerOutward(design, side, outward);
const autoOffset =
edge.offset_m + outward * (fillWallBaseWidth(height) / 2 - REVET_THICKNESS_M / 2);
edge.offset_m + outward * (fillWallBaseWidth(height, lean) / 2 - REVET_THICKNESS_M / 2);
// 자동 자리 = 성토사면(1:1.2) 위 기본 지점 — 배관 유출 벽과 같은 기준값.
const autoRun = PIPE_WALL_DEFAULT_RUN_M;
/**
@@ -244,6 +263,7 @@ export function computeRevetmentLayout(
form: spec.form,
lengthM,
floatGapM,
lean,
});
// 넣은 좌우 이동이 **각도 하한**에 눌려 통째로 무시됐으면 그 하한을 남긴다(2026-09-07).
// 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났고,
@@ -267,6 +287,13 @@ export function computeRevetmentLayout(
// 그려졌다(2026-08-30 사용자: 형태별 모양이 누락되는 경우).
ownerForm: spec.form,
equalize: false,
leanFor: (form, pureHeightM) =>
leanOf({
form,
height_m: pureHeightM,
section_mode: design.section_mode,
side: spec.side,
}),
})
: { walls: [], segments: [], appliedAdjusts: [], addable: false };
@@ -375,7 +402,7 @@ export function appendRevetmentOverlay(
const keyId = index === 0 ? "own" : `own-extra${index - 1}`;
const tooltip =
`독립 기슭막이 ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` +
`(상단 = 성토선 접점, 전면 1:${REVET_LEAN_RATIO}, 높이 한계 ${revetHeightLimit(
`(상단 = 성토선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사), 높이 한계 ${revetHeightLimit(
wall.form,
).toFixed(1)}m 7-3)` +
(wall.floatGapM > 0.01
+6 -2
View File
@@ -55,6 +55,8 @@ export interface RevetWallGeometryInput {
thickness?: number;
floatGapM?: number;
extraIndex?: number;
/** 전면 기울기 1:n 의 n — 표준경사(`leanOf`). 없으면 종전 0.3. */
lean?: number;
}
/**
@@ -65,14 +67,15 @@ export interface RevetWallGeometryInput {
export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayout {
const { anchor, outward, height, material, role } = input;
const thickness = input.thickness ?? REVET_THICKNESS_M;
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const lean = input.lean ?? REVET_LEAN_RATIO;
const baseWidth = thickness * 1.5 + lean * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
const topFront = topJoint + outward * thickness;
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
topFront + outward * lean * (topElevation - elevation);
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
@@ -90,6 +93,7 @@ export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayou
height,
floatGapM: input.floatGapM ?? 0,
material,
lean,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
+4
View File
@@ -166,6 +166,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (!owner || !culvert) return null;
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
const adjust = stationControls.revetOffset.adjustFor(owner, role);
// 조작(조정창 높이)이 있을 때만 — 없으면 그려진 높이는 관경 기준 제안이라 옵션에 안 적음
// (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 안 바꿈」).
if (adjust?.h == null) return null;
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m)
.pureHeight;
},
@@ -180,6 +183,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (!owner || !culvert) return null;
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
const adjust = stationControls.revetOffset.adjustFor(owner, role);
if (!adjust?.m) return null; // 조작한 형태만 — 기본 형태는 제안(판정 ④)
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form;
},
// 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다.
@@ -36,6 +36,38 @@ function wingOptions(role: "inlet" | "outlet", patch: WingPatch): Record<string,
return options;
}
/** 옵션 키 → 「기본값(미확정)」 목록의 이름(파이썬 `_defaulted` 이름표와 같은 글). */
const DEFAULTED_LABELS: Record<string, string> = {
pipe_kind: "관종",
pipe_diameter_mm: "관경",
ford_width_m: "월류 폭",
pipe_count: "배관 수량",
body_width_m: "본체 폭",
body_height_m: "본체 높이",
};
for (const [side, name] of [
["in", "유입"],
["out", "유출"],
]) {
DEFAULTED_LABELS[`wing_${side}`] = `${name} 날개벽`;
DEFAULTED_LABELS[`wing_${side}_height_m`] = `${name} 날개벽 높이`;
DEFAULTED_LABELS[`wing_${side}_length_m`] = `${name} 날개벽 길이`;
DEFAULTED_LABELS[`wing_${side}_angle_deg`] = `${name} 날개벽 각도`;
}
/** ()
* (2026-09-14 ). ( ). */
function dropDefaulted(spec: { defaulted?: string[] } | undefined, options: object): void {
if (!spec?.defaulted) return;
const labels = Object.keys(options).flatMap((key) => DEFAULTED_LABELS[key] ?? []);
spec.defaulted = spec.defaulted.filter(
(item) =>
!labels.some(
(label) => item.startsWith(`${label} `) && !item.slice(label.length + 1).includes(" "),
),
);
}
/** 좌측 폼이 낸 옵션에서 그 측 날개벽 조작값을 읽는다(없는 항목은 빼고 돌려준다). */
function wingPatchFrom(patch: Record<string, number | string>, prefix: string): WingPatch {
const num = (key: string): number | undefined => {
@@ -157,6 +189,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
// 월류 폭 = 구체의 도로 진행 방향 길이(`span_m`) — 백엔드 `_ford_set`과 같은 자리.
if (patch.ford_width_m) spec.span_m = patch.ford_width_m;
}
dropDefaulted(spec, patch);
deps.queuePipeOptions(chainageM, patch);
deps.refreshCard(chainageM);
},
@@ -171,6 +204,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
if (patch.angle_deg !== undefined) wing.angle_deg = patch.angle_deg;
wing.slab_extend_m = wingSlabExtendM(wing.installed, wing.length_m, wing.angle_deg);
}
dropDefaulted(spec, wingOptions(role, patch));
deps.queuePipeOptions(chainageM, wingOptions(role, patch));
deps.refreshCard(chainageM);
},
@@ -349,6 +383,7 @@ export function createBoxControls(deps: FordControlDeps): {
}
if (patch.body_height_m) spec.inner_height_m = patch.body_height_m;
}
dropDefaulted(spec, patch);
deps.queuePipeOptions(chainageM, patch as Record<string, number | string>);
deps.refreshCard(chainageM);
},
@@ -364,6 +399,7 @@ export function createBoxControls(deps: FordControlDeps): {
? Math.max((wing.length_m ?? 0) * Math.cos(((wing.angle_deg ?? 45) * Math.PI) / 180), 0)
: 0;
}
dropDefaulted(spec, wingOptions(role, patch));
deps.queuePipeOptions(chainageM, wingOptions(role, patch));
deps.refreshCard(chainageM);
},
@@ -31,6 +31,7 @@ import {
writeStructurePick,
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { showToast } from "@ui/ui_template_elements";
import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend";
import {
adjustDockRoot,
refreshAdjustSlots,
@@ -125,105 +126,153 @@ function coversChainage(structure: StructureInstance, chainageM: number): boolea
return Math.abs(structureAnchorM(structure) - chainageM) < PIPE_MATCH_M;
}
type Merged = Record<string, string | number>;
/** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */
function registryDefaults(types: StructureType[], typeId: string): Record<string, unknown> {
const options = types.find((type) => type.type_id === typeId)?.options ?? [];
return Object.fromEntries(options.map((option) => [option.key, option.default]));
}
/**
* ** **(`section.ford`)
* ,
* (2026-08-30 사용자: 수량() 1).
* ** , .**
* ·[]
* (2026-09-14 ). .
*/
function keepSpec(
merged: Merged,
stored: Merged | undefined,
key: string,
spec: string | number | null | undefined,
fromDefault: unknown,
): void {
if (spec === null || spec === undefined) return;
if (stored?.[key] !== undefined || String(spec) !== String(fromDefault ?? "")) merged[key] = spec;
}
type WingSpec = {
installed: boolean;
height_m: number | null;
length_m: number | null;
angle_deg: number | null;
};
function keepWings(
merged: Merged,
stored: Merged | undefined,
defaults: Record<string, unknown>,
wings: ReadonlyArray<readonly [WingSpec, "wing_in" | "wing_out"]>,
): void {
for (const [wing, prefix] of wings) {
// 백엔드 `_wing_spec` 과 같은 채움 — 설치는 「없음」이 아니면 있음 · 치수는 기본 0/0/45.
const install = String(defaults[prefix] ?? "") === "없음" ? "없음" : "있음";
keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install);
keepSpec(
merged,
stored,
`${prefix}_height_m`,
wing.height_m,
defaults[`${prefix}_height_m`] ?? 0,
);
keepSpec(
merged,
stored,
`${prefix}_length_m`,
wing.length_m,
defaults[`${prefix}_length_m`] ?? 0,
);
keepSpec(
merged,
stored,
`${prefix}_angle_deg`,
wing.angle_deg,
defaults[`${prefix}_angle_deg`] ?? 45,
);
}
}
/**
* ** **(`section.ford`) · .
* (2026-08-30 사용자: 수량()
* 1) 1 ** ** ( ).
*/
function withFordSpec(
options: Record<string, string | number> | undefined,
options: Merged | undefined,
ford: NonNullable<SectionDetailResponse["cross_sections"][number]["ford"]>,
): Record<string, string | number> {
const merged: Record<string, string | number> = { ...(options ?? {}) };
if (ford.pipe_kind) merged.pipe_kind = ford.pipe_kind;
merged.pipe_diameter_mm = Math.round(ford.diameter_m * 1000);
merged.pipe_count = ford.pipe_count;
merged.ford_width_m = ford.span_m;
for (const [wing, prefix] of [
defaults: Record<string, unknown>,
): Merged {
const merged: Merged = { ...(options ?? {}) };
keepSpec(merged, options, "pipe_kind", ford.pipe_kind, defaults.pipe_kind);
const diameter = Math.round(ford.diameter_m * 1000);
keepSpec(merged, options, "pipe_diameter_mm", diameter, defaults.pipe_diameter_mm ?? 1000);
const count = Number(defaults.pipe_count ?? 0);
keepSpec(
merged,
options,
"pipe_count",
ford.pipe_count,
count > 0 ? Math.max(Math.trunc(count), 1) : 1,
);
keepSpec(
merged,
options,
"ford_width_m",
ford.span_m,
defaults.ford_width_m ?? FORD_BRIDGE_DEFAULT_WIDTH_M,
);
keepWings(merged, options, defaults, [
[ford.wing_in, "wing_in"],
[ford.wing_out, "wing_out"],
] as const) {
merged[prefix] = wing.installed ? "있음" : "없음";
if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m;
if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m;
if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg;
}
]);
return merged;
}
/**
* BOX암거 ** **(`section.box`)
* (2026-08-30 ). · , ·
* .
*/
/** BOX암거 폼 옵션 — 세월교와 같은 규칙(조작·저장분만). 구체 길이·표고는 조정 채널이라 여기 안 옴. */
function withBoxSpec(
options: Record<string, string | number> | undefined,
options: Merged | undefined,
box: NonNullable<SectionDetailResponse["cross_sections"][number]["box"]>,
): Record<string, string | number> {
const merged: Record<string, string | number> = { ...(options ?? {}) };
merged.body_width_m = box.inner_width_m;
merged.body_height_m = box.inner_height_m;
for (const [wing, prefix] of [
defaults: Record<string, unknown>,
): Merged {
const merged: Merged = { ...(options ?? {}) };
keepSpec(merged, options, "body_width_m", box.inner_width_m, defaults.body_width_m ?? 2);
keepSpec(merged, options, "body_height_m", box.inner_height_m, defaults.body_height_m ?? 2);
keepWings(merged, options, defaults, [
[box.wing_in, "wing_in"],
[box.wing_out, "wing_out"],
] as const) {
merged[prefix] = wing.installed ? "있음" : "없음";
if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m;
if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m;
if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg;
}
]);
return merged;
}
/**
* ** **
* (2026-08-29 ). ,
* ( ·) .
* ** ** (2026-08-29 ).
* (··· ·/· ) ** **
* (2026-09-14 ). .
*/
function withSpecDefaults(
options: Record<string, string | number> | undefined,
options: Merged | undefined,
detail: SectionDetailResponse | null,
chainageM: number,
types: StructureType[],
wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null,
wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null,
): Record<string, string | number> | undefined {
): Merged | undefined {
const owner = detail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M,
);
// 세월교는 폼과 조정창이 **같은 캐시**(`section.ford`)를 본다 — 어느 쪽에서 만졌든
// 그 스펙이 지금 그려진 값이라 저장된 옵션보다 앞선다(2026-08-30 사용자: 프론트는
// 구조물 배치 상세 UI, 로직·값은 조정창 것).
if (owner?.ford) return withFordSpec(options, owner.ford);
if (owner?.box) return withBoxSpec(options, owner.box);
// 세월교·BOX는 폼과 조정창이 **같은 캐시**를 봄 — 조작한 값은 저장된 옵션보다 앞섬(2026-08-30).
if (owner?.ford) return withFordSpec(options, owner.ford, registryDefaults(types, "ford_bridge"));
if (owner?.box) return withBoxSpec(options, owner.box, registryDefaults(types, "box_culvert"));
const culvert = owner?.culvert;
if (!culvert) return options;
const merged: Record<string, string | number> = { ...(options ?? {}) };
const put = (key: string, value: string | number | null | undefined): void => {
if (merged[key] === undefined && value !== null && value !== undefined) merged[key] = value;
};
put("pipe_kind", culvert.pipe_kind);
put("pipe_diameter_mm", Math.round(culvert.diameter_m * 1000));
for (const [side, prefix] of [
[culvert.inlet, "inlet"],
[culvert.outlet, "outlet"],
] as const) {
put(`${prefix}_type`, side.structure);
// 형태·높이는 **지금 그려진 값**이 이긴다 — 조정값(조작)이 저장된 옵션보다
// 앞서기 때문이다. 저장값을 그대로 두면 폼과 그림이 갈린다(2026-08-30 사용자:
// 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다).
const merged: Merged = { ...(options ?? {}) };
for (const prefix of ["inlet", "outlet"] as const) {
// 형태·높이는 **조작한 값**이 이긴다 — 조정값이 저장된 옵션보다 앞서기 때문(2026-08-30 사용자:
// 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다). 조작이 없으면 부르는 쪽이 null 을 줌.
const drawnForm = wallForm?.(chainageM, prefix);
if (drawnForm) merged[`${prefix}_revet_form`] = drawnForm;
else put(`${prefix}_revet_form`, side.revet_form);
const drawnHeight = wallHeight?.(chainageM, prefix);
if (drawnHeight != null) merged[`${prefix}_revet_height_m`] = Number(drawnHeight.toFixed(1));
else put(`${prefix}_revet_height_m`, side.revet_height_m);
put(`${prefix}_revet_length_m`, side.revet_length_m);
put(`${prefix}_revet_before_m`, side.revet_before_m);
put(`${prefix}_revet_after_m`, side.revet_after_m);
}
put("inlet_basin_length_m", culvert.inlet.basin_length_m);
put("inlet_basin_before_m", culvert.inlet.basin_before_m);
put("inlet_basin_after_m", culvert.inlet.basin_after_m);
return merged;
}
@@ -374,6 +423,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
pipe.options,
detail,
pipe.chainage_m,
types,
deps.wallHeight,
deps.wallForm,
),
@@ -418,6 +468,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
hit.options,
deps.detail?.() ?? null,
hit.chainage_m,
markTypes,
deps.wallHeight,
deps.wallForm,
);
@@ -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))
@@ -1,290 +0,0 @@
"""표준도 **위쪽 그림** — 돌쌓기 단면(2단계, 2026-09-09).
실무 원본 탭이 치수조서 + 아래 수량산출서인데, 아래 표는 1단계에서 섰고 여기가 위다.
**기울기를 값으로 받는다 0.3 박지 않는다.**
판정은 **B08 `face_slope_ratio()` ** 그대로 부른다(품셈 13-4-4 [] 표준경사
직고·메찰·성절토). 사용자가 정했으면 값이 이긴다(확정 ). 그림은 **수량이 바로
**으로 기울고, 근거 문구도 같이 받아 그림에 적는다 판정을 벌로 짜면 그림과
수량이 갈린다.
**수량이 쓰는 상수로 그린다** `B08_Quantity_Engine_UnitQuantity.STONE_MASONRY`
직접 읽는다. 치수를 여기서 다시 적으면 **그림과 표가 갈린다**(CLAUDE.md 5).
상부 두께 = 뒷길이 + 0.30 하부 두께 = 상부 + 0.30 × (H 1.0)
터파기 = 평균두께 + 0.2 기초 0.5×0.9 (기초유) · 0.1×0.7 (기초버림)
터파기 치수는 **`common_util_excavation` ** 읽는다 횡단도가 쓰는 상수다.
여기서 다시 적으면 도면이 다른 터파기를 그린다.
**뒷길이가 두께에 들어간다**(확정 2 , 실무 구조물도 ). 뒷길이가 다르면 벽이
두꺼워지고 그림도 그만큼 넓어진다 예전 (0.45+0.10H / 0.45+0.40H) 뒷길이를
아예 봐서 35 45 같은 그림이 나왔다.
뒷길이를 정한 장은 두께를 낸다 그때는 **그리지 않는다**(0 으로 때우면
거짓 그림이 된다).
**치수가 없는 것은 그리되 치수를 적지 않는다** 막자갈(뒷채움) 우리 식이 입적에서
몸통·고임돌을 이라 ·높이로 정의된 도형이 아니다. 자리만 보이고 값은 표를 가리킨다.
없는 치수를 그림에 적으면 **표와 다른 번째 정본** 생긴다.
"""
from __future__ import annotations
from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_line_entity,
_text_entity,
polyline_entity,
)
# 기울기 판정은 구조물도 엔진이 가진 한 벌 — 2026-09-13 B08 로 이관(PLAN 3장). 받아 쓰기만 함.
from B08_Quantity.B08_Quantity_Engine_StructureSheet import ( # noqa: F401
SLOPE_TYPE_IDS,
slope_of,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
STONE_MASONRY,
weep_hole_spec,
)
from common_util.common_util_excavation import (
WALL_BLINDING_DEPTH_M,
WALL_BLINDING_WIDTH_M,
WALL_FOUNDATION_DEPTH_M,
WALL_FOUNDATION_WIDTH_M,
WALL_TRENCH_CLEARANCE_M,
)
#: 그림 축척 — 1/25(1m = 40㎜). 표(줄 높이 9㎜) 위에 얹어도 한 면에 드는 크기다.
SCALE_MM_PER_M = 40.0
#: 이 그림을 그리는 종류. 옹벽·집수정처럼 단면이 다른 것은 아직 그리지 않는다.
#: ⚠ **큰돌쌓기(`boulder_masonry`)를 뺐다**(2026-09-09). 여기 있었지만 그림이 두께를
#: **뒷길이**에서 내는데 큰돌쌓기는 규격 축이 **직경**(`stone_cm`)이라 **영영 못 그렸다** —
#: 목록에만 있고 결과는 늘 빈 그림이었다. 품셈 **13-6(직경) ↔ 13-4(뒷길이)** 로 축이
#: 갈리는 자리이고 수량 쪽에서 이미 갈라 둔 그것이다(`EXPANDERS` 주석).
#: ⇒ 「직경에서 두께를 내는 식」은 **도메인 판단이라 사용자 몫** — 계획서 4-12 답 대기 줄.
FIGURE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry"})
#: 그림이 못 서는 장에 **까닭을 적는다** — 빈 자리를 그냥 두면 사용자가 「고장」으로 읽는다
#: (2026-09-09 사용자 지시). 「무엇을 받아야 서는지」까지 적는다.
_NO_FIGURE_REASONS: dict[str, str] = {
"boulder_masonry": (
"그림 없음 — 큰돌쌓기는 규격이 **직경**이라 두께를 낼 식이 아직 없습니다"
"(뒷길이로 내는 돌쌓기 식과 축이 다름 · 사용자 확정 대기)"
),
"retaining_wall": "그림 없음 — 옹벽은 단면이 달라 아직 그리지 않습니다(표준도 3단계)",
}
_NO_FIGURE_DEFAULT = "그림 없음 — 이 형식은 아직 단면을 그리지 않습니다"
def figure_reason(sheet: dict[str, Any]) -> str | None:
"""그림이 **안 서는 까닭** 한 줄. 서는 장이면 `None`.
무엇을 받아야 서는지까지 적는다 뒷길이만 고르면 서는 장과, 자체가 없는 장은
사용자가 일이 다르다.
"""
type_id = str(sheet.get("type_id") or "")
if type_id not in FIGURE_TYPE_IDS:
return _NO_FIGURE_REASONS.get(type_id, _NO_FIGURE_DEFAULT)
if float(sheet.get("height_m") or 0.0) <= 0:
return "그림 없음 — 높이가 없습니다(제원에서 높이를 넣으면 그림이 섭니다)"
if back_length_cm(sheet) is None:
return "그림 없음 — **뒷길이를 고르면 그림이 섭니다**(두께가 뒷길이에서 나옵니다)"
return None
_LABEL_FONT = 4.0
_DIM_FONT = 3.4
def wall_thickness(height_m: float, back_cm: float) -> tuple[float, float]:
"""(상부, 하부) 두께 — **수량이 쓰는 그 식**(`stone_masonry`)과 같은 상수를 읽는다."""
top_t = back_cm / 100.0 + STONE_MASONRY["thickness_top_add_m"]
bottom_t = top_t + STONE_MASONRY["thickness_slope_per_m"] * max(
height_m - STONE_MASONRY["thickness_height_base_m"], 0.0
)
return top_t, bottom_t
def back_length_cm(sheet: dict[str, Any]) -> float | None:
"""장의 뒷길이(㎝). 안 정했으면 `None` — 그때는 두께를 못 내므로 그리지 않는다."""
options = sheet.get("options") or {}
raw = options.get("back_len_cm") or options.get("stone_back_length_cm")
try:
return float(raw) if raw not in (None, "") else None
except (TypeError, ValueError):
return None
def section_points(
height_m: float, slope_ratio: float, back_cm: float
) -> list[tuple[float, float]]:
"""벽 단면 네 점(m 단위, 밑면 앞끝이 원점). 앞면이 뒤로 `n·H` 기운다."""
top_t, bottom_t = wall_thickness(height_m, back_cm)
lean = slope_ratio * height_m
return [
(0.0, 0.0),
(lean, height_m),
(lean + top_t, height_m),
(bottom_t, 0.0),
(0.0, 0.0),
]
def build_figure(
drawing_id: str,
sheet: dict[str, Any],
layer_id: str,
origin: tuple[float, float],
line_color: str,
label_color: str,
guide_color: str,
) -> tuple[list[dict[str, Any]], float]:
"""장 하나의 그림. `(엔티티, 그림이 차지한 높이 ㎜)` — 못 그리면 `([], 0)`."""
if str(sheet.get("type_id") or "") not in FIGURE_TYPE_IDS:
return [], 0.0
height_m = float(sheet.get("height_m") or 0.0)
if height_m <= 0:
return [], 0.0
back_cm = back_length_cm(sheet)
if back_cm is None:
# 뒷길이가 없으면 두께를 못 낸다 — 0 으로 때우지 않고 그리지 않는다.
return [], 0.0
slope, slope_note = slope_of(sheet)
scale = SCALE_MM_PER_M
ox, oy = origin
def mm(point: tuple[float, float]) -> tuple[float, float]:
return (ox + point[0] * scale, oy + point[1] * scale)
points = section_points(height_m, slope, back_cm)
top_t, bottom_t = wall_thickness(height_m, back_cm)
average_t = (top_t + bottom_t) / 2.0
dig_width = average_t + WALL_TRENCH_CLEARANCE_M
# 기초 몫 — 「기초유 / 기초버림」이 폭·깊이를 가른다(정본 탭 제목).
foundation = str((sheet.get("options") or {}).get("foundation") or "")
if foundation == "기초유":
base_w, base_d = WALL_FOUNDATION_WIDTH_M, WALL_FOUNDATION_DEPTH_M
elif foundation == "기초버림":
base_w, base_d = WALL_BLINDING_WIDTH_M, WALL_BLINDING_DEPTH_M
else:
base_w = base_d = 0.0 # 안 정한 장은 기초를 안 그린다 — 지어내지 않는다.
entities: list[dict[str, Any]] = []
wall = polyline_entity(
f"{drawing_id}:fig:wall", [mm(p) for p in points], layer_id, line_color, width=2
)
if wall is not None:
entities.append(wall)
# 터파기 — 우리 수량식(평균두께+0.2) × 높이 를 **그대로** 그린다. 점선.
dig = polyline_entity(
f"{drawing_id}:fig:dig",
[mm(p) for p in ((0.0, 0.0), (0.0, height_m), (dig_width, height_m), (dig_width, 0.0))],
layer_id,
guide_color,
dash=[4, 3],
)
if dig is not None:
entities.append(dig)
# 기초 — 벽 밑에 놓이는 칸. 「안 정함」이면 안 그린다.
if base_d > 0:
base = polyline_entity(
f"{drawing_id}:fig:base",
[mm(p) for p in ((0.0, 0.0), (0.0, -base_d), (base_w, -base_d), (base_w, 0.0))],
layer_id,
guide_color,
)
if base is not None:
entities.append(base)
# 물구멍 — 벽을 가로지르는 짧은 선 하나. 개소 간격은 글자로 적는다(면적당이라 그림에 못 씀).
# ⚠ 값은 **그 장의 제원**에서 읽는다 — 수량표와 같은 함수를 쓴다(2026-09-13). 기본 상수를
# 적으면 사용자가 지름·면적을 고쳐도 그림 주석만 옛 값으로 남아 표와 갈린다.
weep_area_m2, weep_diameter_mm, _weep_basis = weep_hole_spec(sheet.get("options") or {})
weep_y = height_m * 0.5
entities.append(
_line_entity(
f"{drawing_id}:fig:weep",
mm((slope * weep_y, weep_y)),
mm((slope * weep_y + top_t, weep_y)),
layer_id,
guide_color,
)
)
labels: list[tuple[str, tuple[float, float], str]] = [
(f"H = {height_m:g} m", (-0.28, height_m / 2.0), "right"),
(f"상부 {top_t:.2f} m", (slope * height_m + top_t / 2.0, height_m + 0.14), "center"),
(f"하부 {bottom_t:.2f} m", (bottom_t / 2.0, -0.30), "center"),
(f"1 : {slope:g}", (slope * height_m / 2.0 - 0.30, height_m * 0.72), "right"),
(
f"터파기 폭 {dig_width:.2f} m (평균두께 {average_t:.2f} + 0.20)",
(dig_width + 0.16, height_m * 0.92),
"left",
),
(
f"기초 {base_w:g} × {base_d:g} m ({foundation})"
if base_d > 0
else "기초 — 안 정함(기초유/기초버림)",
(dig_width + 0.16, -base_d / 2.0 if base_d else 0.0),
"left",
),
(
f"물구멍 Ø{weep_diameter_mm}{weep_area_m2:g}㎡당 1개소",
(slope * weep_y + top_t + 0.16, weep_y),
"left",
),
("막자갈(뒷채움) — 수량은 아래 표", (dig_width + 0.16, height_m * 0.55), "left"),
]
labels.append((f"뒷길이 ℓ₃ = {int(back_cm)}", (dig_width + 0.16, height_m * 0.72), "left"))
for index, (text, point, align) in enumerate(labels):
x, y = mm(point)
entities.append(
_text_entity(
f"{drawing_id}:fig:label:{index}",
text,
x,
y,
layer_id,
_DIM_FONT,
label_color,
align,
)
)
# 기울기 근거는 **그림 밑에** 한 줄 — 「사용자 지정」인지 「품셈 표준경사」인지 보여야 한다.
note_x, note_y = mm((0.0, -0.62))
entities.append(
_text_entity(
f"{drawing_id}:fig:slopenote",
f"전면 기울기 — {slope_note}",
note_x,
note_y,
layer_id,
_DIM_FONT,
label_color,
"left",
)
)
title_x, title_y = mm((0.0, height_m + 0.52))
entities.append(
_text_entity(
f"{drawing_id}:fig:title",
f"{sheet.get('title') or '돌쌓기'} (축척 1/{int(1000 / SCALE_MM_PER_M)})",
title_x,
title_y,
layer_id,
_LABEL_FONT,
label_color,
"left",
)
)
used_mm = (height_m + 0.95 + base_d) * scale
return entities, used_mm
@@ -23,19 +23,21 @@ from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DRAWING_FORMAT,
_text_entity,
TABLE_LABEL_COLOR,
TABLE_LINE_COLOR,
TABLE_VALUE_COLOR,
_format,
_layer,
_text_entity,
polyline_entity,
table_entity,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import (
from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox
from B08_Quantity.B08_Quantity_Engine_StructureFigure import (
SCALE_MM_PER_M as FIGURE_SCALE_MM_PER_M,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import build_figure, figure_reason
from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox
from B08_Quantity.B08_Quantity_Engine_StructureFigure import build_figure as figure_shapes
from B08_Quantity.B08_Quantity_Engine_StructureFigure import figure_reason
STANDARD_LAYER_ID = "standard"
@@ -66,6 +68,44 @@ def _figure_height(sheet: dict[str, Any]) -> float:
return (height_m + 0.95) * FIGURE_SCALE_MM_PER_M if height_m > 0 else 0.0
def build_figure(
drawing_id: str, sheet: dict[str, Any], origin: tuple[float, float]
) -> tuple[list[dict[str, Any]], float]:
"""B08 그림 모양 목록(m)을 CAD 엔티티(종이 ㎜)로. `(엔티티, 높이 ㎜)` — 못 그리면 `([], 0)`."""
shapes = figure_shapes(sheet) or []
ox, oy = origin
scale = FIGURE_SCALE_MM_PER_M
entities: list[dict[str, Any]] = []
for index, shape in enumerate(shapes):
x, y = (shape.get("at") or [0.0, 0.0])[:2]
if shape["kind"] == "text":
entities.append(
_text_entity(
f"{drawing_id}:fig:{index}",
shape["text"],
ox + x * scale,
oy + y * scale,
STANDARD_LAYER_ID,
shape["size"] * scale,
TABLE_LABEL_COLOR,
shape["align"],
)
)
continue
poly = polyline_entity(
f"{drawing_id}:fig:{index}",
[(ox + px * scale, oy + py * scale) for px, py in shape["points"]],
STANDARD_LAYER_ID,
TABLE_LINE_COLOR if shape["role"] == "wall" else TABLE_VALUE_COLOR,
dash=[4, 3] if shape["dash"] else None,
width=2 if shape["role"] == "wall" else 1,
)
if poly is not None:
entities.append(poly)
ys = [point[1] for shape in shapes for point in shape.get("points") or [shape["at"]]]
return entities, ((max(ys) - min(ys)) * scale if ys else 0.0)
def _cell(
text: str, color: str, *, align: str = "left", col_span: int = 1, bold: bool = False
) -> dict[str, Any]:
@@ -166,11 +206,7 @@ def build_standard_drawing(drawing_id: str, label: str, payload: dict[str, Any])
figure, used = build_figure(
drawing_id,
sheet,
STANDARD_LAYER_ID,
(x0 + _FIGURE_INSET, top - _FIGURE_TOP_PAD - _figure_height(sheet)),
TABLE_LINE_COLOR,
TABLE_LABEL_COLOR,
TABLE_VALUE_COLOR,
)
if figure:
entities.extend(figure)
@@ -33,6 +33,8 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import judged_form
ROOT = Path(__file__).resolve().parent.parent
SOURCE = ROOT / "resources" / "data_cost_input_value" / "pum_forest_2026.json"
OUT_DIR = ROOT / "resources" / "data_work_item_master"
@@ -176,6 +178,18 @@ def parse_toc(rows: list[list[str]]) -> list[dict[str, Any]]:
return nodes
def _is_section_title(section: str, by_number: dict[str, dict[str, Any]]) -> bool:
"""진짜 절 제목인가 — 「번호. 이름」, 또는 마침표 없이 번호 뒤 글이 목차 절 이름과 같음."""
if re.match(r"^\s*\d+(?:-\d+){0,3}\.\s", section):
return True
number = section_number(section)
node = by_number.get(number) if number else None
if node is None:
return False
tail = section.strip()[len(number) :]
return "".join(tail.split()) == "".join(str(node.get("name") or "").split())
def section_number(section: str) -> str | None:
"""`"9-3-1. 인력"` → `"9-3-1"`. 번호가 없으면 `None`."""
m = re.match(r"^\s*(\d+(?:-\d+){0,3})[.\s]", section + " ")
@@ -276,8 +290,9 @@ BASIS_RE = re.compile(r"(\d[\d,.]*)\s*(㎥|m3|㎡|m2|㏊|ha|km|㎞|m|인|본|개
# ⚠ **「당」 또는 「단위:」 가 있어야 밑수다.** 둘 다 없으면 규격일 뿐이다 —
# 만들다 실제로 걸렸다: `(무한궤도,0.7㎥)` 를 「0.7㎥당」으로 읽어 5건이 잘못 잡혔다.
#: ⚠ `(단위: 인/㎡당)` 꼴 — **분모가 밑수**다. 값의 단위(인)를 밑수로 읽으면 뜻이 뒤집힌다.
#: ⚠ 「단위:」 없이 `(인/100m당)` 으로만 적은 표가 22 곳이다(조림 5장·단끊기 5-16-1 등, 2026-09-13).
SOURCE_BASIS_RATIO_RE = re.compile(
r"[(]\s*단위\s*[:]\s*[^)/]+/\s*([\d,]*\.?\d*)\s*"
r"[(]\s*(?:단위\s*[:]\s*)?[^)/:]+/\s*([\d,]*\.?\d*)\s*"
r"(㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대)\s*당\s*[)]"
)
@@ -563,16 +578,6 @@ def capacity_formula_pending(table: dict[str, Any]) -> bool:
return bool((keys | cells) & CAPACITY_SYMBOLS)
def variant_axis(table: dict[str, Any]) -> list[str]:
"""행이 갈리는 축 — 표의 첫 열 값들(토질·암종·규격). 값 열은 뺀다."""
seen: list[str] = []
for row in table.get("rows", []):
key = norm(row[0]) if row else ""
if key and key not in seen:
seen.append(key)
return seen[:24]
def basis_quantity_is_grouped(quantity: float | None) -> bool:
"""「10㎡당」처럼 **묶음 기준**인가. 1 이 아니면 곱셈이 그만큼 갈린다."""
return quantity is not None and abs(quantity - 1.0) > 1e-9
@@ -580,6 +585,10 @@ def basis_quantity_is_grouped(quantity: float | None) -> bool:
def build() -> dict[str, Any]:
data = json.loads(SOURCE.read_text(encoding="utf-8"))
# 사람 판정 선언(표 형태·부모 모양)은 절 번호로 적혀 판에 묶임 — 판이 다르면 멈춤(명세 17장).
from common_util.common_util_pum_edition import require_code_edition
require_code_edition(data["effective_date"], "공종 마스터 빌드 — 표 형태·부모 모양 판정")
tables = data["variables"]["pum"]["tables"]
# 원문을 함께 연다 — 밑수가 표 밖(본문)에 있기 때문이다. 못 열면 밑수 없이 간다.
source_lines: list[str] = []
@@ -601,13 +610,24 @@ def build() -> dict[str, Any]:
orphans: list[dict[str, Any]] = []
undetermined: list[dict[str, Any]] = []
last_section = ""
for table in tables:
if table["table_id"] == "F0001":
continue # 목차 자신은 공종이 아니다.
section = norm(table.get("section"))
# ⚠ 원본 자료가 [주] 문장 조각을 절 제목으로 읽은 표가 있음 — 「12-2 표면 마무리를 따른다.」
# (12-17-1 펌프카 타설 [주]) 가 표 7장을 12-2 에 붙여 가짜 갈래가 섰음(2026-09-14).
# 절 제목은 「번호. 이름」 이거나(마침표 없는 원문 제목 「7-15-2 숲가꾸기, …」 도 있음)
# 번호 뒤 글이 **목차의 그 절 이름과 같음** — 둘 다 아니면 문장 조각이라 앞 절을 이어받음.
if _is_section_title(section, by_number):
last_section = section
elif last_section:
section = last_section
number = section_number(section)
chapter = number.split("-")[0] if number else None
form, why = detect_form(table, chapter)
# 사람이 가른 형태가 자동 판정을 이긴다 — 표마다 까닭 한 줄(미판정 표 판정, 2026-09-13).
form, why = judged_form(table["table_id"], number) or (form, why)
# ⚠ **본문이 정본이다.** 표 안을 긁는 쪽은 보조 — 비고에 적힌 다른 기준
# (「10㎡당」 같은 참고 문구)을 그 표의 밑수로 잘못 물어 온다.
# 실제로 13-3-1 이 본문 `(단위: ㎥당)` 인데 표 안 긁기가 `10㎡` 를 물어 왔다.
@@ -628,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"],
@@ -669,7 +691,8 @@ def build() -> dict[str, Any]:
"special_glyphs": special_glyphs(table),
# 공식 기호가 이 표에 직접 있는가 — 없으면 앞 표에서 물려받는 모양이다.
"capacity_formula_here": capacity_formula_pending(table),
"variant_key": variant_axis(table),
# 갈래 키 — 부모 모양을 단 뒤 표 읽기가 가른 대로 채움(`_Variants`). 못 가르면 빈칸.
"variant_key": [],
"condition_note": [norm(h) for h in table.get("headers", []) if norm(h)],
"raw_row": table.get("rows", []), # 원문 셀 — B09 자원 축이 읽는다.
}
@@ -694,6 +717,10 @@ def build() -> dict[str, Any]:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Parents import apply_parent_modes
apply_parent_modes(nodes)
# 갈래 키(명세 14장 정정) — 표 모양 가르기는 B09 표 읽기 한 벌을 그대로 돌려 씀.
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Variants import attach_variant_keys
attach_variant_keys(nodes, data["effective_date"])
src_meta = {
"dataset_id": data["dataset_id"],
@@ -706,6 +733,8 @@ def build() -> dict[str, Any]:
"schema_version": SCHEMA_VERSION,
"dataset_id": "work_item_master_forest",
"effective_date": data["effective_date"],
# 공종 코드(`FP-*` 절 번호)가 묶인 품셈 판 — 자원 축·인계 줄이 이 값을 싣는다.
"pum_edition": data["effective_date"],
"generated_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"dataset_version": src_meta,
"policy": {
@@ -0,0 +1,168 @@
"""공종 마스터 — **사람이 가른 표 형태** (2026-09-13 축 C 1장 · 미판정 표 판정).
자동 판정(`detect_form`) 가르거나(undetermined 76) 잘못 가른 표만 싣는다.
표마다 ** 그렇게 갈랐는지 ** 원문 `(산림청고시 제2025-82) 산림사업 표준품셈.md`.
뒤집히면 20 틀림 `productivity`(1÷) / `requirement`(÷밑수) ** 머리와 바로
본문의 단위 표현**으로만 가른다. 이름·비고의 직종 이름에 끌려 읽지 않는다.
** 가른 표는 싣지 않는다** undetermined 남는다(막갈이 깊이표·드론운반 인원 밑수 불명).
`coefficient`·`reference` 가른 표는 일위대가 줄로 읽힌다(자원 축이 건너뜀) 시공능력
공식 계수는 형태와 상관없이 공식 읽기가 표를 직접 본다.
번호는 원천 차례로 매긴 것이라 ** 번호를 함께 대조**한다 어긋나면 빌드를 멈춘다.
"""
from __future__ import annotations
#: 표 번호 → (절 번호, 형태, 까닭).
FORM_JUDGMENTS: dict[str, tuple[str, str, str]] = {
# ── 제2장 재료 기준 ─────────────────────────────────────────────
"F0044": (
"2-1-1",
"reference",
"기계별 주연료(/일·대)·잡품(%) — 행이 자원이 아니라 기계, 재료비 산출 기준",
),
"F0045": ("2-1-1", "reference", "위와 같은 모양(2드럼 케이블윈치)"),
"F0047": (
"2-1-2",
"reference",
"기계별 경유(/일·대)·잡품(%) — [주]③ 「1일당 소요량」, 행이 기계",
),
"F0048": ("2-1-3", "reference", "머리 「소요비용(원)」 — 품이 아니라 교체 1회당 금액"),
"F0054": ("2-1-5", "requirement", "본문 「(본당)」 + 행 「소금처리량(g)」 — 본당 재료 소요량"),
"F0063": (
"2-1-13",
"reference",
"「소모율 0.00125개/hr」 — 기계 시간당 소모품, 공종 밑수가 아님",
),
# ── 제5장 조림·녹화 ─────────────────────────────────────────────
"F0100": ("5-3-1", "reference", "수종별 묘목 규격(소·중·대묘) 표"),
"F0101": ("5-3-1", "reference", "간장·근원경 규격 기준 표"),
"F0105": ("5-3-4", "coefficient", "지주목 미설치 감률 「인력품의 10%·20%"),
"F0107": ("5-3-5", "coefficient", "위와 같은 감률(흉고직경 표)"),
"F0137": ("5-24-2", "reference", "머리 「종자배합비율(%)」 조견표"),
"F0138": ("5-24-2", "reference", "위와 같은 조견표(기타 적용지역)"),
"F0146": ("5-29", "coefficient", "행이 K(버킷계수)·f·E·Cm — 굴착기 작업량 공식 계수"),
# ── 제7장 수확·운재 ─────────────────────────────────────────────
"F0187": ("7-9-2", "coefficient", "본문 「(단위 : %)」 — 집재재적·거리별 수집량 증가율"),
"F0189": ("7-11", "productivity", "표 바로 위 「(㎥/1대, 1일)」 — 대당 일 작업량"),
"F0190": ("7-11", "productivity", "위와 같음(최대집재거리 80m 이하)"),
"F0194": ("7-13", "reference", "검척 산출 예시(말구직경×개수) 표"),
"F0196": (
"7-15-1",
"productivity",
"표 바로 위 「(㎥/1대, 1일)」 — 「운반량」 줄이 작업량, 「회수」 줄은 참고",
),
"F0197": (
"7-15-2",
"productivity",
"표 바로 위 「(㎥/1대, 1일)」 — 「운재량」 줄이 작업량, [주]① 1대 1일",
),
"F0198": ("7-16", "productivity", "표 바로 위 「(㎥/1대, 1일)」 — 「운반량」 줄이 작업량"),
"F0199": ("7-17", "productivity", "표 바로 위 「(㎥/1대, 1일)」 — 「운반량」 줄이 작업량"),
"F0200": ("7-18", "productivity", "표 바로 위 「(㎥/1대, 1일)」 — 「운반량」 줄이 작업량"),
# ── 제8장 방제 ──────────────────────────────────────────────────
"F0205": ("8-2-1", "reference", "약제 이름·제형 목록"),
"F0208": ("8-2-2", "reference", "선정약제 목록 + 원액 주입량(㎖/㎝) 기준 — 품 표는 따로 있음"),
"F0212": ("8-2-3", "reference", "위와 같은 선정약제 목록"),
"F0216": ("8-2-4", "reference", "위와 같은 선정약제 목록"),
"F0218": ("8-2-5", "reference", "위와 같은 선정약제 목록"),
"F0453": (
# 2026-09-14 절 8-2-3 → 14-2 — 원본이 [주] 「…8-2-3 굴착기(2025)」…」 를 절로 읽던 것을
# 앞 절 이어받기로 고치자 부록 앞 마지막 절(14-2)로 옮겨 감. 부록엔 절 번호가 없어 여전히
# 제자리가 아님 — 참고표라 값에 안 닿음.
"14-2",
"reference",
"〔부록 2〕 산림사업별 적용 가능 공종 목록 — 절 경계가 밀려 붙음",
),
"F0220": (
"8-3",
"productivity",
"머리 「벌목조재 (㎥/인)·무더기 훈증 (RM/인)·그루터기 박피 (본/인)」 — 인당 작업량",
),
"F0221": ("8-3", "coefficient", "본문 「(단위: %)」 — 수종·직경별 지조율"),
"F0222": ("8-3", "coefficient", "머리 「비율(수간재적의 %)」 — 근주재적 산정율"),
"F0227": ("8-6-1", "reference", "기종별 희석배수별 약제량(L, 1회) — 살포 계산표"),
"F0231": ("8-6-3", "reference", "희석배수별 약제소요량(㎖) — 물의 양 대비 계산표"),
# ── 제9장 토공 — 시공능력 공식 계수 ────────────────────────────
"F0281": ("9-15-1", "coefficient", "행이 K·f·E·Cm — [주]② Q1=3600×q×K×f×E/Cm"),
"F0282": ("9-15-2", "coefficient", "행이 T·L·E·q — 불도저 작업량 공식 계수"),
"F0284": ("9-16-2", "coefficient", "행이 V·W·E·D — [주]② Q=1000×V×W×E×D×f/N"),
"F0285": ("9-16-3", "coefficient", "흡입준비·운반·흡입·대기 시간 — 살수차 사이클 시간"),
"F0287": ("9-18", "coefficient", "행이 K·f·E·Cm — [주]① Q1=3600×q×K×f×E/Cm"),
"F0295": ("9-22", "coefficient", "행이 K·f·E·Cm — [주]③ Q1=3600×q×K×f×E/Cm"),
# ── 제10장 운반 ─────────────────────────────────────────────────
"F0300": ("10-3-3", "reference", "「운반비·하차비」 적용 항목 — 값이 없는 구성 안내"),
"F0301": ("10-4", "coefficient", "t1·t2·t3·t4 — 트레일러 운반 사이클 시간"),
"F0308": ("10-7-4", "coefficient", "1사이클당 운반량 q — 모노레일 공식 계수"),
"F0309": ("10-7-4", "coefficient", "본문 「(단위: 분/1회당)」 — 적재·짐부리기 시간"),
"F0316": ("10-9-1", "reference", "규격별 1회당 표준 운반량(kg·개·㎡) — 적재 기준"),
"F0319": ("10-11", "coefficient", "행이 L·E·q0 — [주]② q=3.2×e, Cm 공식 계수"),
"F0320": ("10-12-1", "coefficient", "행이 K·E0·Cm — 굴착기 적재 공식 계수"),
"F0321": ("10-12-1", "coefficient", "적재·t2·적하 시간 — 덤프트럭 사이클 공식 계수"),
"F0324": (
"10-13-2",
"reference",
"드론운반 단가산출 항목(준비시간·왕복비행시간 식) — [주] 일본 산출 예",
),
# ── 제11·12장 ───────────────────────────────────────────────────
"F0328": ("11-4", "coefficient", "행이 q·k·f·E — [주]① Q=3600×q×k×f×E/Cm"),
# 2026-09-14 절 12-2 → 12-17-1 — 원본이 펌프카 타설 [주] 「…12-2 표면 마무리를 따른다.」 를
# 절 제목으로 읽어 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%)"),
"F0394": ("12-38-2", "coefficient", "부자재 요율 「주자재비의 24%·52%·79%"),
"F0396": ("12-38-3", "reference", "설치 유형(복잡·보통·간단) 적용 시설 설명"),
# ── 제13장 구조물 ───────────────────────────────────────────────
"F0400": ("13-2-3", "reference", "큰돌 직경별 뒷길이 기준(㎝)"),
"F0406": (
"13-3",
"reference",
"〈돌쌓기의 개수 및 중량의 표준〉 — [주] 인력 돌쌓기·돌붙임 기준",
),
"F0408": (
"13-4-3",
"reference",
"고임돌 소요량(㎥/㎡) — 자재 원단위표(B08 고임돌표가 씀). 품으로 세우면 자재를 두 번 셈",
),
"F0410": (
"13-4-4",
"reference",
"[주]① 채움 콘크리트 소요량(㎥/㎡) — 자재 원단위표(B08 채움표가 씀). 품이면 두 번 셈",
),
"F0411": ("13-4-4", "reference", "[주]⑨ 성토 뒤채움 조약돌 두께(㎝) 기준"),
"F0412": (
"13-4-4",
"reference",
"[주]⑩ 뒷길이 표준(㎝) — 자동 판정이 「(m)」에 걸려 소요량형으로 오판",
),
"F0413": (
"13-4-4",
"reference",
"[주]⑪ 표준경사(1:n) — 자동 판정이 소요량형으로 오판, 찰쌓기(인력)를 막음",
),
"F0429": ("13-10-2", "coefficient", "[주]⑥ 관입률별 계수"),
"F0439": ("13-12-2", "coefficient", "[주]⑦ 높이·기울기 할증(%)"),
"F0446": ("13-15-2", "reference", "작업조건(어려운·쉬운) 구분 기준"),
}
def judged_form(table_id: str, number: str | None) -> tuple[str, str] | None:
"""사람이 가른 `(형태, 까닭)`. 없으면 `None`. 절 번호가 어긋나면 멈춘다."""
found = FORM_JUDGMENTS.get(table_id)
if found is None:
return None
section, form, why = found
if section != number:
raise ValueError(
f"표 형태 판정 {table_id} 의 절 {section} 이 원천의 {number} 와 다릅니다 — "
"원천 표 차례가 바뀌었으니 판정을 다시 대조할 것"
)
return form, f"사람 판정 — {why}"
@@ -0,0 +1,85 @@
"""공종 마스터 — **갈래 키(`variant_key`)를 표 읽기가 실제로 가른 대로** 싣는다 (명세 14장 정정).
종전 `variant_axis` 24개만 담아, 단가표 갈래 제목 294 86 맞았다
(2026-09-13 브레인 실측). 머리행 갈래(벌목 5m이상~8m미만) · 머리 · 열이 자원
이름인 표를 가렸다.
모양을 여기서 **다시 판정하지 않는다** · 열이 자원 · 뭉친 이름+갈래 · ·
머리 · 작업조 · 공정 합산 · 고르기형 모양 가르기는 B09 읽기에 이미 있다.
벌로 짜면 한쪽만 고쳐진다. 읽기를 **메모리의 마스터** 돌려 표마다 가른 갈래
이름을 원문 표기 그대로 싣는다( 비교는 받는 쪽이 `normalize_variant_key` ).
가른 표는 **빈칸** 자원을 맞춘 · 계수 · 참조 · 미판정 표에 갈래를 지어내지 않는다.
공종마다 `variant_keys` 표들의 갈래 + 원문이 정했으나 자원 줄로 서는 갈래
(불도저 운반 공식 갈래 · 합산형 단계 잎의 암질 · 9-4-1 [] 평균 · 유로폼 12-38-2 부자재 요율 머리) · 합산형 부모는 단계 갈래.
"""
from __future__ import annotations
from typing import Any
def _add(labels: list[str], seen: set[str], label: str, normalize: Any) -> None:
key = normalize(label)
if key and key not in seen:
seen.add(key)
labels.append(label)
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
from B09_Estimation.B09_Estimation_ResourceAxis_Sources import load_combined_catalog
from B09_Estimation.B09_Estimation_UnitPrice import normalize_variant_key as normalize
axis = build_resource_axis(
{"effective_date": edition, "work_items": nodes}, load_combined_catalog()
)
by_table: dict[tuple[str, str], tuple[list[str], set[str]]] = {}
for row in axis.rows:
if row.variant:
labels, seen = by_table.setdefault((row.work_item_code, row.pum_table_id), ([], set()))
_add(labels, seen, row.variant, normalize)
steps = {
str(step["code"])
for node in nodes
if node.get("parent_mode") == "sum_steps"
for step in node.get("steps") or []
}
by_code: dict[str, list[str]] = {}
for node in nodes:
code = str(node.get("work_item_code") or "")
keys: list[str] = []
seen: set[str] = set()
for table in node.get("tables") or []:
labels, table_seen = by_table.get((code, str(table.get("pum_table_id"))), ([], set()))
found = extract_dozer_factors(code, table)
for label in found if isinstance(found, dict) else {}:
_add(labels, table_seen, label, normalize)
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)
if code in AVERAGE_BASIS and keys:
_add(keys, seen, AVERAGE_VARIANT, normalize)
by_code[code] = keys
for node in nodes:
code = str(node.get("work_item_code") or "")
keys = list(by_code.get(code) or [])
if node.get("parent_mode") == "sum_steps":
seen = {normalize(k) for k in keys}
for step in node.get("steps") or []:
for label in by_code.get(str(step["code"])) or []:
_add(keys, seen, label, normalize)
node["variant_keys"] = keys
@@ -26,7 +26,50 @@ from dataclasses import dataclass, field
from typing import Any, Iterable
# 지반 구분이 붙는 공종 — 실무 시트가 이 셋을 각각 암 갈래만큼 늘려 적는다.
GROUND_SPLIT_GROUPS = ("흙깎기", "측구터파기", "구조물터파기")
# 면고르기는 절토면만 토사/암 갈래(성토면 줄은 `item` 이 비어 지반 없음 · 2026-09-14 Ⓑ).
GROUND_SPLIT_GROUPS = ("흙깎기", "측구터파기", "구조물터파기", "면고르기")
#: 면고르기 갈래 — 품셈 9-19-1 원문 표 두 벌 그대로(L5414 절토면 토질 6 · L5430 성토면 시공·토질).
#: B09 표 읽기(`_ResourceAxis_JudgedTable`)가 같은 이름으로 갈래를 세움 — 짝 시험이 마스터와 대조.
#: ⚠ 토질·시공은 제안값 없음(판정 Ⓒ) · 성토면 기계는 굴착기 0.6㎥ 형식 둘로 갈래(브레인 ②).
FACE_DRESSING_CUT_CLASSES = (
"절토면 · 모래ㆍ사질토ㆍ점토ㆍ점질토",
"절토면 · 연질토ㆍ불순자갈",
"절토면 · 호박돌 섞인 고결토ㆍ경질토",
"절토면 · 풍화암",
"절토면 · 연암",
"절토면 · 보통암ㆍ경암",
)
FACE_DRESSING_FILL_CLASSES = (
"성토면 · 인력 · 점토 또는 점질토",
"성토면 · 인력 · 모래 또는 사질토",
"성토면 · 기계 · 무한궤도",
"성토면 · 기계 · 타이어",
)
#: 성토면 제안 — 칸 곁 회색 제안일 뿐 스스로 고르지 않음(「기본값을 몰래 확정으로 안 바꿈」).
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 = (
"초류종자살포와 같은 면적(식재 대상) — 품셈 9-19-1 성토면 [주] 「식재를 위한 성토사면」 ·"
" 9-19-2·3 [주]① 「비탈면 식재기반 조성에만」"
)
FACE_DRESSING_ZERO = "파종 면적이 0 — 식재 대상 아님(면고르기 안 셈)"
# 반영률 키 ↔ 집계 공종. 값은 프로젝트 설정에서 온다(기본 100 %).
RATIO_OF_ROW = {
@@ -72,6 +115,33 @@ class SummaryInput:
rock_classes: list[str] = field(default_factory=list)
rock_ratios_pct: dict[str, float] = field(default_factory=dict)
application_ratios: dict[str, float] = field(default_factory=dict)
# 노체다짐 — 기본 꺼짐(2026-09-13 판정). 켜면 성토 밑에 별도 줄이 선다.
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 판정 「별도 줄 · 칸으로 켜고 끔 · 기본 꺼짐」.
#: 성토 단가(포설)에 묻지 않는다 — 묻으면 왜 그 금액인지 설명이 안 되고 켜고 끈 것이 안 보인다.
#: ⚠ [주]③ 「굴착기와 진동롤러 조합」은 작업 방식 설명 — 굴착기는 성토(포설 9-16-1) 줄이 이미 셈.
#: 이 줄에 굴착기를 또 넣으면 두 번 셈(2026-09-14 「#조합」 철회).
SUBGRADE_COMPACTION_SPEC = "진동롤러(자주식 10ton)"
SUBGRADE_COMPACTION_NOTE = (
"산출 조건에서 켬 — 품셈 9-16-2 [주]⑤ 「대규모 성토지로서 층다짐이 필요한 경우 적용」"
" · 수량 = 성토량(포설과 같은 밑수 ㎥)"
" · [주]③ 굴착기+진동롤러 조합 중 굴착기는 성토(포설) 줄이 셈"
)
def _ratio(source: SummaryInput, key: str) -> float:
@@ -121,6 +191,26 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
rows.append(SummaryRow(group="보정량계", amount=earth.get("adjusted_total_m3", 0.0)))
rows.append(SummaryRow(group="성토", amount=earth.get("fill_volume_m3", 0.0)))
if source.subgrade_compaction_enabled:
# ⚠ 끄면 줄 자체를 안 낸다 — 빈 줄은 「세야 함」으로 읽힌다(임목파쇄와 같은 자리).
rows.append(
SummaryRow(
group="노체다짐",
spec=SUBGRADE_COMPACTION_SPEC,
amount=earth.get("fill_volume_m3", 0.0),
note=SUBGRADE_COMPACTION_NOTE,
)
)
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))
@@ -169,6 +259,7 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
note=_seed_note(source),
)
)
rows.extend(_face_dressing_rows(source, fill_face, cut_face))
removal = slope.get("tree_removal_fill", 0.0) + slope.get("tree_removal_cut", 0.0)
# ⭐ 2026-09-09 **사용자 확정 5차 2번** — 지장목제거를 **두 줄로 가른다**(실무 서식).
# 영월 설계내역서 1.9 지장목제거가 두 줄이고 **같은 면적을 나눠 쓴다**:
@@ -225,6 +316,61 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
return rows
def _face_dressing_rows(
source: SummaryInput, fill_face: float, cut_face: float
) -> list[SummaryRow]:
"""면고르기 — 성토면 한 줄 · 절토면 토사 한 줄 + 암 갈래(판정 Ⓐ·Ⓑ). 밑수 = 파종 면적."""
rows: list[SummaryRow] = []
for face, key, gross, ratio_key in (
("성토면", "fill", fill_face, "seed_spray_fill"),
("절토면", "cut", cut_face, "seed_spray_cut"),
):
ratio = _ratio(source, ratio_key)
seeded = gross * ratio
given = source.face_dressing_area_m2.get(key)
amount = float(given) if isinstance(given, (int, float)) else seeded
if amount <= 0:
note = FACE_DRESSING_ZERO
elif isinstance(given, (int, float)):
note = f"덮어쓴 면적 {amount:,.2f}㎡ (파종 면적 {seeded:,.2f}㎡)"
else:
note = FACE_DRESSING_NOTE
common = {
"group": "면고르기",
"spec": face,
"unit": "",
"amount_gross": gross,
"application_ratio_pct": None if isinstance(given, (int, float)) else ratio * 100.0,
}
if face == "성토면":
rows.append(SummaryRow(**common, amount=amount, note=note, in_bill=amount > 0))
continue
# 절토면 — 사면 조각의 토사/암 몫(적용 전 면적)으로 나누고 같은 배율을 곱함.
# 못 가른 몫은 안 세고 드러냄. 몫마다 적용 전 값을 따로 실어 율 거울 검사가 맞게.
slope = source.slope_totals
soil = slope.get("face_dressing_cut_soil", 0.0)
rock = slope.get("face_dressing_cut_rock", 0.0)
scale = amount / cut_face if cut_face > 0 else 0.0
unknown = max(cut_face - soil - rock, 0.0) * scale
if unknown > 1e-6:
note += f" · ⚠ 토사/암을 못 가른 사면 {unknown:,.2f}㎡ 는 안 셈(측점 지반 프리셋 없음)"
parts = [("토사", soil, "")] + (_split_by_rock(rock, source) if rock > 0 else [])
for name, part_gross, split_note in parts:
part = part_gross * scale
# 토사 몫은 갈래 값(「절토면 · 모래…」)이 규격을 말함 · 암 몫은 규격에 암 갈래
spec = face if name == "토사" else f"절토면 · {name}"
rows.append(
SummaryRow(
**{**common, "amount_gross": part_gross, "spec": spec},
item=name,
amount=part,
note=" · ".join(text for text in (note, split_note) if text),
in_bill=part > 0,
)
)
return rows
def _ratio_note(source: SummaryInput, key: str, base: str) -> str:
ratio = _ratio(source, key)
return "" if abs(ratio - 1.0) < 1e-9 else f"{base} {ratio * 100:g} % 반영"
@@ -251,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))
+36 -5
View File
@@ -54,6 +54,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import (
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( # noqa: F401
BLOCKED_FORMULA_MISSING,
BLOCKED_INPUT_MISSING,
BLOCKED_UNCONFIRMED,
BLOCKED_UNIT_DATA_MISSING,
METHOD_TO_GROUND,
NOTE_METHOD_MISSING,
@@ -112,8 +113,17 @@ def build_handoff(
bench_cut_depth_m: float | None = None,
structure_trench_water: str | None = None,
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 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**."""
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.
`priced_sheets` 양식 일위대가로 구조물도 (`StructurePriceLink.priced_sheets`).
장의 구조물은 호표 `AX-ST` 하나로 가고, 일위대가가 품은 줄은 따로 .
"""
table = mapping or load_mapping()
work_items: list[dict[str, Any]] = []
unmatched: list[str] = []
@@ -121,7 +131,16 @@ def build_handoff(
methods = {key: value for key, value in (ground_methods or {}).items() if value}
if summary_table:
# 임목축적 등급 — 지장목제거 뿌리뽑기(9-21 제근)의 품 갈래(2026-09-13 판정 Ⓑ).
variant_inputs = {"stand_volume_class": stand_volume_class}
# 면고르기 절토면 토질 · 성토면 시공·토질 — 9-19-1 갈래(2026-09-14 판정 Ⓒ).
variant_inputs = {
"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
)
@@ -135,7 +154,7 @@ def build_handoff(
# 띠·이동에서만 운반이 만들어져 사토 잔량이 어디에도 안 실렸다.
work_items.extend(spoil_haul_rows(haul_table, table))
if unit_quantity_table:
rows, misses = _structure_rows(unit_quantity_table, table)
rows, misses = _structure_rows(unit_quantity_table, table, priced_sheets)
work_items.extend(rows)
unmatched.extend(misses)
# 구조물이 낸 터파기·되메우기·잔토 — **공종 축으로 올린다.**
@@ -145,7 +164,7 @@ def build_handoff(
work_items.extend(rows)
unmatched.extend(misses)
# 기초잡석 — 버림이 선 구조물에 함께 서는 공종(확정 3차 ②). 묶음 구조물은 제외한다.
rows, misses = rubble_base_rows(unit_quantity_table, table)
rows, misses = rubble_base_rows(unit_quantity_table, table, priced_sheets)
work_items.extend(rows)
unmatched.extend(misses)
@@ -180,7 +199,7 @@ def build_handoff(
str(row.get("ground_class"))
for row in work_items
if row.get("ground_class")
and row.get("ground_class") != "토사"
and row.get("ground_class") not in ("토사", "") # 「암」은 구성비가 빠진 것
and row.get("work_item_code") is None
and row.get("origin") == ORIGIN_EARTHWORK
}
@@ -208,6 +227,14 @@ def build_handoff(
result["ratio_math_warnings"] = verify_ratio_math(result)
result["material_code_warnings"] = verify_no_code_on_materials(result)
result["bill_flag_warnings"] = verify_bill_flags(result)
# ⚠ 이중계상 경계(명세 6장) — 갈 곳 칸으로 판정한 어긴 자리. B09 내역서가 여기서 멈춘다.
from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import (
verify_double_count_boundaries,
)
result["double_count_violations"] = verify_double_count_boundaries(
unit_quantity_table, material_table, table, priced_sheets, work_items
)
# ⚠ 보내는 단위가 **품셈 밑수**와 같은가 — 받는 쪽이 그대로 곱하는 자리다(2026-09-08).
result["basis_unit_warnings"] = verify_unit_matches_basis(
work_items, extra=table.declared_units()
@@ -218,6 +245,10 @@ def build_handoff(
(unit_quantity_table or {}).get("collected_stone_deduction_m3") or 0.0
)
result["placing_notes"] = placing_notes
# ⚠ 품셈 판(명세 17장) — 줄마다 코드가 본 판을 싣는다. 판이 다르면 B09 가 값을 안 씀.
result["pum_edition"] = table.pum_edition or None
for row in work_items:
row.setdefault("pum_edition", table.pum_edition or None)
return result
@@ -0,0 +1,162 @@
"""B08 → B09 인계 — **이중계상 경계를 갈 곳(`destination`) 칸으로 판정** (명세 6장 · 2026-09-13).
주석 경고뿐이던 규칙 셋을 수로 걸리는 검사로 세운다. 원문은 계획서 8-7(·) 명세 6.
구조물 터파기·되메우기는 **토공집계로만** 곳이 `earthwork` 성분을 다른 공종 줄이
집으면 같은 물량이 공종 코드에 붙음. 터파기·되메우기가 `earthwork` 밖으로 가도 오류
배합 성분(시멘트·모래·자갈) **B09 일위대가만 쪼갬** 자재총괄에 뜨면 오류()
자재 할증은 **자재총괄 ** 원단위표가 이미 할증을 붙였으면 오류().
자재총괄 합계의 할증 ·일위대가 재료비의 할증 전은 B09 가드가 금액 자리에서
얼개를 만들지 않는다 성분마다 붙은 `destination` , 인계 줄이 성분을
**이름으로 집는 자리**(구조물 `billing_component` · 묶음 조각 `from_components` ·
콘크리트 타설 · 기초잡석) 대조한다.
여기는 목록만 낸다. 멈추는 것은 B09 `build_bill`(`DoubleCountError`) 가드가 모인 자리.
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import WorkItemMapping
from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import PLACING_TARGET_NAMES
from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import RUBBLE_GROUP
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import verify_single_surcharge
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import MIX_COMPONENTS
#: 토공집계로만 가야 하는 성분 — 전개·양식·관측 원단위가 같은 이름을 쓴다.
EARTHWORK_ONLY = ("터파기", "되메우기")
#: 공종 줄이 값으로 매기면 안 되는 갈 곳 — 이미 다른 자리가 세거나(토공·사토 공제) 참고값이다.
NOT_BILLED_BY_WORK_ITEM = {
"earthwork": "토공집계",
"haul_deduction": "유토곡선 사토 공제",
"reference": "참고 줄",
}
def _pickers(structure: dict[str, Any], mapping: WorkItemMapping) -> list[tuple[str, set[str]]]:
"""이 구조물의 성분을 **이름으로 집는** 공종 줄 — (자리, 이름들). 집는 조건은 각 빌더와 같다."""
type_id = str(structure.get("type_id") or "")
entry = mapping.for_structure(type_id) or {}
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: # 걸린 묶음은 곧장 잇는 코드보다 위(빌더와 같은 조건)
for part in composite.get("parts") or []:
if isinstance(part, dict):
label = f"묶음 조각 「{part.get('name') or part.get('code')}"
found.append((label, {str(name) for name in part.get("from_components") or []}))
if not composite:
# 묶음 구조물은 타설·기초잡석 줄이 건너뛴다(조각이 이미 셈) — 빌더와 같은 조건.
found.append(("콘크리트 타설 줄", set(PLACING_TARGET_NAMES)))
found.append(("기초잡석 줄", {RUBBLE_GROUP}))
return found
def verify_double_count_boundaries(
unit_quantity_table: dict[str, Any] | None,
material_table: dict[str, Any] | None,
mapping: WorkItemMapping,
priced_sheets: list[dict[str, Any]] | None = None,
work_items: list[dict[str, Any]] | None = None,
) -> list[str]:
"""어긴 자리를 사람 말로 — 비면 경계가 지켜진 것."""
found: list[str] = []
for structure in (unit_quantity_table or {}).get("structures") or []:
label = str(structure.get("name") or structure.get("type_id") or "구조물")
destinations: dict[str, set[str]] = {}
for component in structure.get("components") or []:
if float(component.get("amount") or 0.0) <= 0:
continue
name = str(component.get("name") or "").strip()
destination = str(component.get("destination") or "")
destinations.setdefault(name, set()).add(destination)
# 집는 쪽은 이름+규격 키(「이형철근 D13」)로도 집음 — 두 키에 다 올려야 검사가 안 놂.
key = f"{name} {str(component.get('spec') or '').strip()}".strip()
destinations.setdefault(key, set()).add(destination)
if name in EARTHWORK_ONLY and destination != "earthwork":
found.append(
f"{label}{name}」 갈 곳이 {destination or '(없음)'}"
"구조물 터파기·되메우기는 토공집계(earthwork)로만 감"
)
for where, names in _pickers(structure, mapping):
for name in sorted(names):
for destination in sorted(
destinations.get(name, set()) & set(NOT_BILLED_BY_WORK_ITEM)
):
found.append(
f"{label}{name}」은 {NOT_BILLED_BY_WORK_ITEM[destination]}"
f"({destination})로 가는데 {where}이 또 셈 — 같은 물량이 두 자리에 붙음"
)
for row in (material_table or {}).get("rows") or []:
name = str(row.get("name") or "").strip()
if name in MIX_COMPONENTS:
found.append(
f"② 자재총괄에 배합 성분 「{name}」 — 시멘트·모래·자갈은 B09 일위대가만 쪼갬(㉢)"
)
found.extend(f"{warning} (㉠)" for warning in verify_single_surcharge(unit_quantity_table))
found.extend(_verify_conserved(unit_quantity_table, mapping, priced_sheets, work_items))
return found
#: 여러 구조물의 성분을 **이름으로 모아 한 줄**로 세우는 인계 줄 — (줄 이름, 모으는 성분 이름들).
#: ⚠ 이 줄들은 구조물마다 「누구 것을 뺐나」를 안 남긴다 — 그래서 물량 보존으로 본다.
SUMMED_ROWS = (
(RUBBLE_GROUP, frozenset({RUBBLE_GROUP})),
("콘크리트 타설", frozenset(PLACING_TARGET_NAMES)),
)
_TOLERANCE_M3 = 1e-6
def _verify_conserved(
unit_quantity_table: dict[str, Any] | None,
mapping: WorkItemMapping,
priced_sheets: list[dict[str, Any]] | None,
work_items: list[dict[str, Any]] | None,
) -> list[str]:
"""④ 양식 호표·묶음 조각이 품은 성분을 모음 줄이 또 셈 — **물량 보존**으로 판정(2026-09-13).
모음 + 호표가 품은 + 묶음 조각 원단위 성분 . 넘치면 같은 물량을 자리에서
(검증 프로젝트 찰쌓기: 호표가 품은 기초잡석이 인계 줄로도 나가 8.3 실제 3.5).
빌더마다 무엇을 건너뛰나 따라 짜지 않는다 건너뛰기를 빠뜨린 빌더를 잡는 검사다.
"""
if not unit_quantity_table or work_items is None:
return []
covered = {
sid: set(entry.get("covered") or [])
for entry in priced_sheets or []
for sid in entry.get("structure_ids") or []
}
found: list[str] = []
for row_name, names in SUMMED_ROWS:
available = held = 0.0
for structure in unit_quantity_table.get("structures") or []:
taken = covered.get(str(structure.get("structure_id")), set())
for component in structure.get("components") or []:
name = str(component.get("name") or "").strip()
if name not in names or component.get("unit") != "":
continue
amount = float(component.get("amount") or 0.0)
available += amount
held += amount if name in taken else 0.0
in_parts = sum(
float(part.get("quantity") or 0.0)
for row in work_items
for part in row.get("composite_parts") or []
if isinstance(part, dict)
and part.get("from_components")
and set(part["from_components"]) <= names
)
summed = sum(
float(row.get("quantity") or 0.0)
for row in work_items
if row.get("name") == row_name and not row.get("composite_parts")
)
if summed + held + in_parts > available + _TOLERANCE_M3:
found.append(
f"④ 「{row_name}」 줄 {summed:g}㎥ + 양식 호표가 품은 {held:g}㎥ + 묶음 조각 "
f"{in_parts:g}㎥ 가 원단위 합 {available:g}㎥ 를 넘음 — 호표·조각이 품은 성분을"
" 모음 줄이 또 셈"
)
return found
@@ -121,6 +121,12 @@ ORIGIN_PIPE = "pipe"
#: 갈래 이름(연암·보통암…)만으로는 공종을 못 고른다(2026-09-07 일감 9 실서버에서 드러남).
METHOD_TO_GROUND = {"ripping": "리핑암", "blasting": "발파암"}
NOTE_METHOD_MISSING = "시공법 미지정으로 공종을 못 고름"
#: 암 총량이 갈래로 안 나뉘어 한 줄 「암」으로 선 경우 — 시공법은 **갈래마다** 고르므로 이 줄은
#: 시공법만 골라서는 안 풀린다. 빠진 입력은 구성비다(2026-09-14 흙깎기·측구터파기 암).
NOTE_ROCK_RATIO_MISSING = (
"암 갈래 구성비(%)가 아직 없어 암 총량이 한 줄 「암」으로 섬 — 산출 조건 「암 갈래 구성비」를 "
"넣고 갈래마다 시공법(리핑·발파)을 고르면 공종이 섬"
)
#: 철근으로 보는 성분 이름 조각. **정확한 낱말이 아니라 앞머리**로 본다 —
#: 「이형철근 D13」·「원형철근」처럼 규격이 뒤에 붙기 때문이다. `철근콘크리트`는 성분 이름이
@@ -132,15 +138,24 @@ REBAR_PREFIXES = ("이형철근", "원형철근", "철근")
BLOCKED_INPUT_MISSING = "input_missing" # 저장 제원 칸이 비어 있음 — 입력하면 풀림
BLOCKED_UNIT_DATA_MISSING = "unit_data_missing" # 원단위·표준 물량 자료가 없음
BLOCKED_FORMULA_MISSING = "formula_missing" # 수량 산출식 자체가 없음
#: 치수가 없어 **등록부 기본값으로 선** 구조물 줄(2026-09-14 브레인 판정) — 수량은 보이되 금액·합계엔
#: 안 듦. B09 가 내역 제자리에 빈 금액 + 빨간 테두리로 세우고 「미확정 N건 — 금액에 안 들어감」.
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({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"})
#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다.
@@ -192,6 +207,8 @@ class WorkItemMapping:
"""수량 줄 → 공종 마스터 코드. 못 찾으면 `None` 을 돌려주고 부른 쪽이 목록에 남긴다."""
effective_date: str = ""
#: 매핑의 공종 코드가 본 품셈 판(명세 17장) — 인계 줄마다 이 값이 `pum_edition` 으로 실린다.
pum_edition: str = ""
earthwork: list[dict[str, Any]] = field(default_factory=list)
haul: list[dict[str, Any]] = field(default_factory=list)
structure: list[dict[str, Any]] = field(default_factory=list)
@@ -260,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
@@ -284,6 +308,7 @@ def load_mapping(path: Path | None = None) -> WorkItemMapping:
variant_aliases = [row for row in load_aliases("variant") if row["pum_edition"] == edition]
return WorkItemMapping(
effective_date=str(payload.get("effective_date") or ""),
pum_edition=edition,
earthwork=list(payload.get("earthwork") or []),
haul=list(payload.get("haul") or []),
structure=list(payload.get("structure") or []),
@@ -315,10 +340,18 @@ def composite_quantities(
note = "; ".join(structure.get("notes") or []) or "구조물 원단위가 없음"
return [], [{"code": None, "reason": note}]
amounts: dict[str, tuple[float, str]] = {}
for component in structure.get("components") or []:
# ⚠ 조각 이름은 **이름+규격 키**(「이형철근 D13」)로도, 이름만(「콘크리트」)으로도 옴 — 두
# 색인을 함께 둠. 이름 한 칸만 키로 쓰면 규격만 다른 성분(D13·D16)이 **서로 덮어씀**.
by_key: dict[str, float] = {}
by_name: dict[str, float] = {}
key_of: dict[int, str] = {}
for index, component in enumerate(structure.get("components") or []):
name = str(component.get("name") or "").strip()
amounts[name] = (float(component.get("amount") or 0.0), str(component.get("unit") or ""))
key = f"{name} {str(component.get('spec') or '').strip()}".strip()
amount = float(component.get("amount") or 0.0)
by_key[key] = by_key.get(key, 0.0) + amount
by_name[name] = by_name.get(name, 0.0) + amount
key_of[index] = key
kg_to_ton = float((mapping.unit_conversion or {}).get("kg_to_ton") or 0.001)
parts: list[dict[str, Any]] = []
@@ -328,14 +361,14 @@ def composite_quantities(
parts.append({"code": str(spec)})
continue
sources = list(spec.get("from_components") or [])
found = [name for name in sources if name in amounts]
total = sum(amounts[name][0] for name in found)
found = [name for name in sources if name in by_key or name in by_name]
total = sum(by_key[name] if name in by_key else by_name[name] for name in found)
if spec.get("unit_from") == "kg" and spec.get("unit") == "ton":
total *= kg_to_ton
kinds = {
component.get("basis_kind")
for component in structure.get("components") or []
if str(component.get("name") or "").strip() in found
for index, component in enumerate(structure.get("components") or [])
if key_of[index] in found or str(component.get("name") or "").strip() in found
}
suffix = spec.get("kind_suffix")
entry: dict[str, Any] = {
@@ -362,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(
@@ -439,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 "(형식이 아직 입력되지 않음)"
+106 -15
View File
@@ -7,24 +7,28 @@
from __future__ import annotations
import string
from typing import Any
from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_code
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_FORMULA_MISSING,
BLOCKED_INPUT_MISSING,
BLOCKED_UNCONFIRMED,
BLOCKED_UNIT_DATA_MISSING,
GROUND_SPLIT_GROUPS,
HAUL_SUMMARY_GROUPS,
METHOD_TO_GROUND,
NOTE_HAUL_IN_SUMMARY,
NOTE_METHOD_MISSING,
NOTE_ROCK_RATIO_MISSING,
ORIGIN_EARTHWORK,
ORIGIN_HAUL,
ORIGIN_SLOPE,
ORIGIN_STRUCTURE,
SLOPE_GROUPS,
SUBTOTAL_GROUPS,
SUMMARY_ONLY_GROUPS,
WorkItemMapping,
composite_quantities,
masonry_class,
@@ -33,6 +37,9 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
)
from B08_Quantity.B08_Quantity_Wording import type_label as wording_type_label
#: 기본값으로 선 구조물 줄 사유 머리 — B09 「미확정 N건 — 금액에 안 들어감」과 같은 말.
UNCONFIRMED_LABEL = "미확정 — 금액에 안 들어감"
def _spec_detail(structure: dict[str, Any]) -> str:
"""규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다."""
@@ -54,6 +61,8 @@ def _mapping_ground(ground: str | None, methods: dict[str, str | None]) -> tuple
"""
if ground is None or ground == "토사":
return ground, ""
if ground == "":
return None, NOTE_ROCK_RATIO_MISSING # 갈래로 안 나뉜 총량 — 시공법만으론 안 풀림
method = methods.get(ground)
mapped = METHOD_TO_GROUND.get(method or "")
if mapped:
@@ -144,7 +153,30 @@ def _earthwork_rows(
# 보는 쪽을 고쳤으면 **다는 쪽도** 빠짐없이 달아야 한다.
blocked_kind = mismatch[1] if mismatch else None
blocked_reason = mismatch[2] if mismatch else ""
if code is None and not is_subtotal:
inputs = {**(variant_inputs or {}), "ground_class": ground}
variant_value = (
(entry or {}).get("variant_value")
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 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 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:
@@ -178,11 +210,7 @@ def _earthwork_rows(
# 매핑이 갈래를 적은 작업 갈래(잡관목제거 → 단목베기 「5m 미만」)만 값을 싣는다.
"variant_axis": (entry or {}).get("variant_axis"),
# 암 갈래(연암·보통암·경암)는 줄 자신의 갈래를 넘김 — 9-4·9-5 단계 합산형(축 C Ⓐ).
"variant_value": (entry or {}).get("variant_value")
or {**(variant_inputs or {}), "ground_class": ground}.get(
str((entry or {}).get("variant_from") or "")
)
or None,
"variant_value": variant_value,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": "",
@@ -218,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
# 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는
@@ -246,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 이 아니다).
@@ -281,9 +314,28 @@ def _haul_rows(
"origin": ORIGIN_HAUL,
}
)
if equipment == "dump_truck" and in_bill:
# 덤프 운반은 **싣는 일이 따로** — 산림품셈 10-12 「1. 적재」
# (굴착기 0.7㎥ · ㎝ 22초·180°).
# 흙깎기 9-3-2(㎝ 20초·135°, 깎아 옆에 둠)에 안 들어 있어 운반과 한 벌로 짝 줄을 냄
# (2026-09-14 브레인 판정). 거리와 무관 · 수량은 운반량 그대로.
rows.append(
{
**rows[-1],
"name": "덤프 적재",
"haul_distance_m": None,
"haul_equipment": LOADING_EQUIPMENT,
"spec_detail": f"{state_note} · 덤프 운반량만큼 싣기(10-12 「1. 적재」)",
}
)
return rows, unmatched
#: 인계 「덤프 적재」 짝 줄의 수단 표지 —
#: B09 `MachineProductivity_Dump.LOADING_EQUIPMENT` 와 같은 글.
LOADING_EQUIPMENT = "dump_loading"
def blocked_of(
structure: dict[str, Any], class_basis: str = "", has_code: bool = False
) -> tuple[str | None, str]:
@@ -342,13 +394,19 @@ def _component_billing(
def _structure_rows(
unit_quantity_table: dict[str, Any], mapping: WorkItemMapping
unit_quantity_table: dict[str, Any],
mapping: WorkItemMapping,
priced_sheets: list[dict[str, Any]] | None = None,
) -> tuple[list[dict[str, Any]], list[str]]:
"""구조물 줄 — **작업 공종 하나**로 선다.
전개 성분(터파기·야면석·모르터) 여기 오지 않는다. 구조물 기가 내역 줄이고,
전개는 토공 합산·자재총괄·일위대가로 갈린다(8-2 이중계상 함정).
양식 일위대가로 (`priced_sheets`) 구조물은 **호표 `AX-ST` ** (PLAN 6 ).
"""
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import by_structure, template_row
priced = by_structure(priced_sheets or [])
rows: list[dict[str, Any]] = []
unmatched: list[str] = []
for structure in unit_quantity_table.get("structures") or []:
@@ -372,6 +430,11 @@ def _structure_rows(
"큰돌쌓기 쌓기 방식이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 "
"메쌓기·찰쌓기 중 하나를 고르면 공종이 정해집니다"
)
if entry.get("class_from") == "form":
# 기슭막이 — **형태**가 공종을 가름(돌쌓기 찰·메). 그 밖 형태는 전개 사유가 막음.
form = str((structure.get("options") or {}).get("form") or "").strip()
code = (entry.get("form_codes") or {}).get(form)
class_basis = f"형태 「{form}」 → {code}" if code else ""
if code and entry.get("class_from") == "back_length":
class_key, class_basis = masonry_class(structure.get("options") or {})
@@ -379,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]] = []
@@ -396,11 +463,24 @@ 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 []
]
if code is None and composite is None:
# 양식 일위대가로 셀 구조물 — 막힘은 일위대가 표가 가림(전개 갈래 사유는 안 셈).
sheet_entry = priced.get(str(structure.get("structure_id")))
if sheet_entry is not None:
pass
elif code is None and composite is None:
unmatched.append(f"{wording_type_label(type_id)} — 품셈 공종을 아직 못 이었습니다")
elif entry.get("class_from") in ("back_length", "bond") and class_key is None:
unmatched.append(f"{wording_type_label(type_id)}{class_basis}")
@@ -432,6 +512,13 @@ def _structure_rows(
if notes
else "물량이 0 이라 내역에 안 세움 — 저장 제원에서 치수·면적을 넣으면 값이 섭니다"
)
# ⭐ 치수가 없어 기본값으로 선 구조물(2026-09-14 브레인 판정) — **줄은 서되 금액은 안 듦.**
# B09 가 내역 제자리에 빈 금액 + 빨간 테두리로 세우고 「미확정 N건 — 금액에 안 들어감」.
unconfirmed = str(structure.get("unconfirmed") or "")
if unconfirmed:
in_bill = False
zero_reason = f"{UNCONFIRMED_LABEL}{unconfirmed}"
blocked_kind, blocked_reason = BLOCKED_UNCONFIRMED, zero_reason
rows.append(
{
"work_item_code": code,
@@ -476,6 +563,8 @@ def _structure_rows(
"origin": ORIGIN_STRUCTURE,
}
)
if sheet_entry is not None and not unconfirmed:
rows[-1] = template_row(rows[-1], structure, sheet_entry)
return rows, unmatched
@@ -524,8 +613,10 @@ def _placing_rows(
# 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 —
# 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.**
# ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다.
if mapping.composite_for(str(structure.get("type_id") or "")):
continue
if mapping.composite_for(str(structure.get("type_id") or ""), structure) or structure.get(
"unconfirmed"
):
continue # 기본값으로 선 구조물도 — 금액에 안 듦
# ⚠ 버림은 **따로 센다** — 실무 내역이 「레미콘타설(장비) **무근,버림**」으로 갈라
# 적는다(봉화 제50호표, 2026-09-09 데스크탑 보조 확인). 같은 공종·같은 단가라
# 금액은 안 움직이고 **이름만 맞추는 것**이다.
@@ -48,6 +48,8 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]
status = str(row.get("status") or "")
amount = row.get("amount")
ready = status == PREP_READY and amount is not None
# 값은 서도 **내역 줄이 아닌 줄**(경비 — 임목폐기물 처리). 막힘이 아니라 가는 곳이 다름.
billable = ready and row.get("bill") is not False
rows.append(
{
"work_item_code": row.get("work_item_code"),
@@ -88,8 +90,14 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]
"spec_class_basis": str(row.get("reason") or ""),
"composite_not_ready": None,
# 값이 없는 줄은 **내역에 세우지 않는다** — 0 원 줄을 만들면 더 나쁘다.
"in_bill": ready,
"in_bill_reason": "" if ready else str(row.get("reason") or status),
"in_bill": billable,
"in_bill_reason": (
""
if billable
else str(row.get("bill_reason") or "")
if ready
else str(row.get("reason") or status)
),
"origin": ORIGIN_PREPARATION,
}
)
@@ -23,6 +23,8 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
ORIGIN_HAUL,
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절은 「완료 구간 비탈면을
@@ -97,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
),
)
)
@@ -127,8 +137,33 @@ def spoil_haul_rows(
extra=GROUND_UNKNOWN,
)
)
return rows
return [_spoil_row(code, volume, distance, blocked, reason, note, None, GROUND_UNKNOWN)]
return _with_loading(rows)
return _with_loading(
[_spoil_row(code, volume, distance, blocked, reason, note, None, GROUND_UNKNOWN)]
)
def _with_loading(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""사토도 싣는 일이 따로 — 흙깎기 운반과 같은 「덤프 적재」 짝 줄(10-12 「1. 적재」).
내역에 서는 운반 줄에만 붙음 · 거리 무관 · 수량은 운반량 그대로(2026-09-14 브레인 판정).
운반토량 검산은 `dump_loading` 빼므로 .
"""
out: list[dict[str, Any]] = []
for row in rows:
out.append(row)
if row["in_bill"]:
out.append(
{
**row,
"name": "덤프 적재",
"spec": row["ground_class"] or "",
"haul_distance_m": None,
"haul_equipment": LOADING_EQUIPMENT,
"spec_detail": f"{row['spec_detail']} · 사토량만큼 싣기(10-12 「1. 적재」)",
}
)
return out
def _spoil_row(
@@ -165,8 +200,10 @@ def _spoil_row(
"structure_kind": None,
"blocked_kind": BLOCKED_INPUT_MISSING if blocked else None,
"blocked_reason": reason,
"variant_axis": None,
"variant_value": None,
# 갈래는 `variant_value` 한 통로로 — 덤프 운반·적재 단가가 토사·암으로 갈림
# (종전 None 이라 B09 가 10-12 잎을 못 골라 사토 운반이 「갈래 미지정」으로 막힘).
"variant_axis": "ground_class" if ground else None,
"variant_value": ground,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": "",
@@ -149,17 +149,29 @@ def _row(**fields: Any) -> dict[str, Any]:
def rubble_base_rows(
unit_quantity_table: dict[str, Any], mapping: WorkItemMapping
unit_quantity_table: dict[str, Any],
mapping: WorkItemMapping,
priced_sheets: list[dict[str, Any]] | None = None,
) -> tuple[list[dict[str, Any]], list[str]]:
"""기초잡석(품셈 12-25) — 구조물 전개가 낸 성분을 **한 줄로** 올린다.
**묶음으로 서는 구조물은 건너뛴다** 옹벽 묶음에 이미 `FP-12-25` 조각이 있어
여기서 세우면 같은 잡석을 센다(타설 줄에서 이미 겪은 자리).
**양식 일위대가가 기초잡석을 품은 구조물도 건너뛴다**(PLAN 6 브레인 판정)
호표 `AX-ST` 금액에 이미 들어 있음.
"""
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import by_structure
priced = by_structure(priced_sheets or [])
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 "")):
if mapping.composite_for(str(structure.get("type_id") or ""), structure) or structure.get(
"unconfirmed"
):
continue # 묶음 조각이 품음 · 기본값으로 선 구조물은 금액에 안 듦
sheet_entry = priced.get(str(structure.get("structure_id")))
if sheet_entry is not None and RUBBLE_GROUP in sheet_entry["covered"]:
continue
for component in structure.get("components") or []:
if str(component.get("name") or "") != RUBBLE_GROUP:
@@ -209,9 +221,13 @@ def structure_earthwork_rows(
# 사유도 안 났다). 관측 원단위로 가는 종류는 표에 터파기 줄이 없으면 이렇게 된다.
without_trench: list[str] = []
for structure in unit_quantity_table.get("structures") or []:
if structure.get("unconfirmed"):
continue # 기본값으로 선 구조물 — 제 줄에 사유가 섬, 토공 금액엔 안 듦
options = structure.get("options") or {}
height = float(structure.get("height_m") or options.get("height_m") or 0.0)
band = _depth_band(height + _foundation_depth(options))
# 단면으로 판 깊이가 있으면 그것(옹벽은 기초분만 팜 · 비탈분 제외) — 없으면 직고 + 기초 깊이.
dug = structure.get("trench_depth_m")
band = _depth_band(float(dug) if dug else height + _foundation_depth(options))
ground = structure.get("ground_type") or None
ground_basis = str(structure.get("ground_type_basis") or "")
for component in structure.get("components") or []:
@@ -231,7 +247,8 @@ def structure_earthwork_rows(
backfill += amount
elif name == "잔토처리":
spoil += amount
if not any(
# 성분이 아예 없는 구조물(세월교 등 산출식 없음)은 제 줄이 이미 사유로 막힘 — 여기 안 적음.
if structure.get("components") and not any(
str(component.get("name") or "") == "터파기"
and float(component.get("amount") or 0.0) > 0
for component in structure.get("components") or []
@@ -97,6 +97,8 @@ def haul_inputs(unit_quantity_table: dict[str, Any] | None) -> dict[str, Any]:
stone_by_ground: dict[str, float] = {}
stone_unknown = 0.0
for structure in unit_quantity_table.get("structures") or []:
if structure.get("unconfirmed"):
continue # 기본값으로 선 구조물 — 잔토·채집석이 사토 운반 금액으로 번지지 않게
amount = 0.0
stone = 0.0
for component in structure.get("components") or []:
@@ -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 ㉡)."""
@@ -92,6 +92,10 @@ SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable" # 자재는 있는데 율을
NOTE_RATE_MISSING = "할증률 미확보"
NOTE_INCLUDED = "품셈에 할증 포함 — 중복 적용 안 함"
#: ⚠ **안 정한 자재는 사급으로 선다**(2026-09-14 브레인 판정 ⓐ — 「기본 사급 · 관급은 프로젝트
#: 설정이 정함」 · 품셈 제1장 「관계규정이나 계약조건에 따른다」). 종전 `unknown` 은 어느 합계에도
#: 안 들어 자재비가 통째로 빠져 있었음. 명시적으로 `unknown` 을 고른 줄은 그대로 미정.
NOTE_SUPPLY_DEFAULT = "관급구분 기본 사급 — 관급이면 산출 조건에서 고름"
def _latest_dataset_path(directory: Path | None = None) -> Path | None:
@@ -109,6 +113,9 @@ class SurchargeTable:
effective_date: str = ""
source: dict[str, Any] = field(default_factory=dict)
rates: dict[str, dict[str, Any]] = field(default_factory=dict)
#: 율이 없는 **까닭** — 자재 → 사유. 보류(조건이 다름)와 이름 없음을 줄에 드러냄.
#: ⚠ 「할증률 미확보」만 적으면 「아직 안 찾음」과 「찾았으나 일부러 안 걺」이 안 갈림.
missing_reasons: dict[str, str] = field(default_factory=dict)
def rate_for(self, material: str, condition: str | None = None) -> tuple[float | None, str]:
"""(할증률 %, 근거). 표에 없으면 `(None, "")` — **0 을 돌려주지 않는다.**"""
@@ -138,10 +145,25 @@ def load_surcharge_table(path: Path | None = None) -> SurchargeTable:
for row in payload.get("rates_pct", [])
if row.get("material") is not None and row.get("rate") is not None
}
reasons = {
str(item.get("material")): (
f"조건이 다름 — 사용자 확정 후 옮길 것 · 원문 「{item.get('listed_condition')}"
f" {item.get('rate')}% · {item.get('why_not_applied')}"
)
for item in (payload.get("candidates_pending_user") or {}).get("items") or []
if item.get("material")
}
reasons.update(
{
str(name): "할증률표에 이름 없음(산림·건설 품셈 1-3-1 확인) — 임의로 안 정함"
for name in (payload.get("not_found") or {}).get("materials") or []
}
)
return SurchargeTable(
effective_date=str(payload.get("effective_date") or ""),
source=payload.get("source") or {},
rates=rates,
missing_reasons=reasons,
)
@@ -154,7 +176,10 @@ class MaterialRow:
spec: str = "" # 규격 — 돌 종류·콘크리트 강도처럼 같은 이름을 가르는 값(명세 13장 Ⓒ)
net_amount: float = 0.0 # 순수량 — 할증 전
surcharge_pct: float | None = None # None = 미확보
surcharge_cap: float | None = None # 할증률표 값(1-3-1 「이내」의 상한) — 고른 값의 천장
supply: str = SUPPLY_UNKNOWN
#: 산출 조건에 안 적어 **기본 사급**으로 선 줄 — 그 사실을 비고에 적음.
supply_default: bool = False
install_by: str | None = None # 관급 줄에만 — 사급은 비워 둔다
surcharge_included: bool = False # 품셈에 이미 포함
basis: str = ""
@@ -187,6 +212,8 @@ class MaterialRow:
parts.append(self.basis)
if self.supply == SUPPLY_OWNER and self.install_by is None:
parts.append(NOTE_INSTALL_BY_MISSING)
if self.supply_default:
parts.append(NOTE_SUPPLY_DEFAULT)
return " · ".join(parts)
@@ -286,6 +313,23 @@ def _supply_setting(supply: dict[str, Any], row: MaterialRow) -> Any:
return None
def _chosen_rate(cap: float | None, raw: Any) -> tuple[float | None, str]:
"""(쓸 할증률, 사유) — 고른 값은 0 ~ 표값(상한)만. 표에 없으면 고른 값도 안 씀(임의 금지)."""
if raw is None or str(raw).strip() == "" or cap is None:
return cap, ""
try:
value = float(raw)
except (TypeError, ValueError):
return cap, f"고른 할증률 「{raw}」 을 수로 못 읽어 표값 {cap:g}% 씀"
if not 0 <= value <= cap:
return cap, f"고른 할증률 {value:g}% 는 표값 {cap:g}% 이내가 아니라 안 씀(1-3-1 「이내」)"
return value, f"산출 조건에서 고른 값 {value:g}% (표 상한 {cap:g}% 이내)"
#: 건너뛴 까닭 칸 — 치수가 없어 기본값으로 선 구조물(구조물 수로 셈).
UNCONFIRMED_SKIP = "미확정(기본값으로 선 구조물)"
def _collect(
unit_quantity_table: dict[str, Any],
) -> tuple[dict[tuple[str, str, str], MaterialRow], dict[str, int]]:
@@ -293,6 +337,10 @@ def _collect(
rows: dict[tuple[str, str, str], MaterialRow] = {}
skipped: dict[str, int] = {}
for structure in unit_quantity_table.get("structures", []):
if structure.get("unconfirmed"):
# 기본값으로 선 구조물 — 자재에 안 듦(2026-09-14 브레인 판정 「없으면 없다고 보이기」).
skipped[UNCONFIRMED_SKIP] = skipped.get(UNCONFIRMED_SKIP, 0) + 1
continue
label = str(structure.get("name") or structure.get("type_id") or "")
for component in structure.get("components", []):
destination = str(component.get("destination") or "") or "(없음)"
@@ -312,11 +360,16 @@ def build_table(
supply_map: dict[str, Any] | None = None,
extra_materials: Iterable[dict[str, Any]] = (),
concrete_placing_method: str | None = None,
surcharge_overrides: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양.
`extra_materials` 구조물 전개 밖에서 오는 자재(·초류종자 사면 계열) 받는 자리다.
모양은 원단위 성분과 같다(`name`·`unit`·`amount`·`destination`).
`surcharge_overrides` `{이름 규격: %}` 산출 조건에서 고른 할증률. 품셈 1-3-1
표의 **이내** **0 ~ 표값만** 받음 · 표에 없는 자재·할증 포함 줄엔
(넘으면 쓰고 사유).
"""
table = surcharge_table or load_surcharge_table()
rows, skipped = _collect(unit_quantity_table)
@@ -333,9 +386,14 @@ def build_table(
missing_rate: list[str] = []
missing_supply: list[str] = []
missing_install_by: list[str] = []
chosen_rates = surcharge_overrides or {}
for row in rows.values():
name = row.name
row.supply, row.install_by = _supply_of(_supply_setting(supply, row))
setting = _supply_setting(supply, row)
row.supply, row.install_by = (
_supply_of(setting) if setting is not None else (SUPPLY_CONTRACTOR, None)
)
row.supply_default = setting is None
if row.supply == SUPPLY_UNKNOWN:
missing_supply.append(row.supply_key)
# ⚠ 설치 주체는 관급 줄에만 묻는다. 사급은 애초에 대상액 밖이라 비워 두는 것이 맞다.
@@ -345,8 +403,11 @@ def build_table(
continue
lookup, alias_note = surcharge_lookup_name(name, concrete_placing_method)
rate, basis = table.rate_for(lookup)
row.surcharge_cap = rate
rate, chosen_note = _chosen_rate(rate, chosen_rates.get(row.supply_key))
row.surcharge_pct = rate
row.basis = " · ".join(part for part in (basis, alias_note) if part)
reason = table.missing_reasons.get(lookup, "") if rate is None else ""
row.basis = " · ".join(part for part in (basis, alias_note, chosen_note, reason) if part)
if rate is None:
missing_rate.append(name)
@@ -371,6 +432,8 @@ def build_table(
"unit": row.unit,
"net_amount": row.net_amount,
"surcharge_pct": row.surcharge_pct,
# 고를 수 있는 천장(표값) — `None` 이면 칸이 안 섬(표에 없거나 할증 포함 줄).
"surcharge_cap": None if row.surcharge_included else row.surcharge_cap,
"total_amount": row.total_amount,
"supply": row.supply,
"supply_label": SUPPLY_LABELS.get(row.supply, row.supply),
@@ -151,6 +151,109 @@ def billing_of(
return str(found.get("unit") or "개소"), scale
#: 단면으로 터파기를 세운 줄의 사유 — 표 사유·구조물도 그림이 **같은 말**(브레인 판정 2026-09-14).
SECTION_TRENCH_NOTE = (
"터파기는 기초 폭 + 양쪽 여유 · 옆면 1:0.5(판 깊이 기준)"
" — 벽 높이 몫은 지반선이 제원에 없어 제외"
)
def earthwork_missing_note(entry: dict[str, Any]) -> str | None:
"""관측 줄에 토공 줄이 없고 단면으로도 못 세우면 한 줄 — 표 사유·구조물도 그림이 같은 말."""
if any(item.get("destination") == "earthwork" for item in entry.get("components") or []):
return None
if (entry.get("section") or {}).get("trench"):
return SECTION_TRENCH_NOTE
return "⚠ 터파기·되메우기·잔토가 안 섬 — 관측 원단위 표에 그 줄이 없음(값을 지어내지 않음)"
def weep_component(
entry: dict[str, Any], options: dict[str, Any], scale: float, scale_note: str
) -> dict[str, Any] | None:
"""물구멍관 — 단면이 있는 관측 줄은 **실무 식에 제원 칸을 넣어** 다시 셈(붙박이 값 대신).
` 높이 ÷ 개소당 벽면적 × 길이`(소광리 옹벽2.0 T31). 길이는 실무 관측값이라
`basis_kind` 그대로 `observed`. 규격 `Ø지름` 달아 돌쌓기 물구멍관과 (이름+규격)으로 합침.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import weep_hole_spec
section = entry.get("section") or {}
weep = section.get("weep")
if not weep:
return None
area, diameter, spec_basis = weep_hole_spec(options)
height, length = section["wall"]["height_m"], weep["pipe_length_m"]
per_unit = height / area * length
return {
"name": "물구멍관",
"unit": "m",
"amount": per_unit * scale,
"destination": "material",
"basis": (
f"관측 원단위 식(소광리 「옹벽2.0」 T31) 벽 높이 {height:g} ÷ {area:g}㎡/개소"
f" ×{length:g} m = {per_unit:.3f}/{entry.get('unit')} × {scale_note}"
f" · 관 길이는 관측값 · {spec_basis}"
),
"basis_kind": BASIS_OBSERVED,
"source": str(entry.get("source") or ""),
"spec": f"Ø{diameter}",
}
def section_trench_components(
entry: dict[str, Any], rubble_thickness_m: float, scale: float
) -> tuple[list[dict[str, Any]], float] | None:
"""단면이 있는 관측 줄의 터파기·되메우기·잔토(`destination: earthwork`)와 파는 깊이(m).
같은 파일 식생옹벽블럭 기초 터파기 밑폭 = 기초 + 양쪽 여유 0.3 · 옆면 1:0.5
(윗폭 = 밑폭 + 0.5 × 깊이 × 2) · 깊이 = 기초 + 버림 + 기초잡석 · 높이 몫은
(지반선이 제원에 없음). 브레인 판정 2026-09-14 처음 수직에서 1:0.5 고침.
되메우기 = 터파기 안에 (기초·전단키 콘크리트 + 버림 + 기초잡석) 잔토 = .
"""
section = entry.get("section") or {}
trench = section.get("trench")
if not trench:
return None
footing, key, blinding = section["footing"], section["key"], section["blinding"]
clearance, slope = trench["clearance_m"], trench.get("side_slope", 0.0)
width = footing["width_m"] + 2 * clearance
depth = footing["thickness_m"] + blinding["thickness_m"] + rubble_thickness_m
top_width = width + 2 * slope * depth
key_below = max(key["depth_m"] - blinding["thickness_m"] - rubble_thickness_m, 0.0)
excavation = (width + top_width) / 2 * depth + key["width_m"] * key_below
layer_width = footing["width_m"] + 2 * blinding["overhang_m"] - key["width_m"]
filled = (
footing["width_m"] * footing["thickness_m"]
+ key["width_m"] * key["depth_m"]
+ layer_width * (blinding["thickness_m"] + rubble_thickness_m)
)
backfill = excavation - filled
basis = (
f"(밑폭 {width:.2f}(기초 {footing['width_m']:g} + 여유 {clearance:g}×2)"
f" + 윗폭 {top_width:.2f}(1:{slope:g})) ÷ 2 × 깊이 {depth:.2f}"
f"(기초 {footing['thickness_m']:g} + 버림 {blinding['thickness_m']:g}"
f" + 기초잡석 {rubble_thickness_m:g})"
f" · {SECTION_TRENCH_NOTE} · 여유·기울기는 소광리 식생옹벽블럭 기초 터파기"
)
rows = [
("터파기", excavation, basis),
("되메우기", backfill, f"터파기 든 것 {filled:.3f}(기초·전단키 + 버림 + 기초잡석)"),
("잔토처리", filled, "터파기 되메우기"),
]
return [
{
"name": name,
"unit": "",
"amount": amount * scale,
"destination": "earthwork",
"basis": f"{text} = {amount:.3f}/{entry.get('unit')}",
"basis_kind": BASIS_DERIVED,
"source": str(entry.get("source") or ""),
}
for name, amount, text in rows
], depth
def expand_observed(
type_id: str,
spec: dict[str, Any],
@@ -170,23 +273,53 @@ def expand_observed(
if scale <= 0:
from B08_Quantity.B08_Quantity_Wording import type_label
return [], [f"{type_label(type_id)}의 연장·면적이 0 이라 물량을 내지 않았습니다"]
where = (
"면적(월류 폭 × 포장 길이)이 0 — 두 칸을 적으면 섬"
if found.get("unit") == ""
else "연장이 0 — 연장을 적으면 섬"
)
return [], [f"{type_label(type_id)}{where}"]
source_key = str(found.get("source") or "")
options = structure.get("options") or {}
components: list[dict[str, Any]] = []
blocked: list[str] = []
for item in found.get("components") or []:
note = str(item.get("basis_note") or "")
if not item.get("destination"):
# ⛔ 갈 곳 기본값 금지(명세 13장) — 종전 `or "material"` 은 줄이 늘 때 조용히 자재로 샘.
blocked.append(
f"{item['name']}」 관측 원단위 줄에 갈 곳이 없어 물량을 어느 축에도 안 보냄"
" — 표(`structure_unit_observed`)에 적을 것(명세 13장 갈 곳 기본값 금지)"
)
continue
weep = (
weep_component(found, options, scale, scale_note)
if item["name"] == "물구멍관"
else None
)
if weep is not None:
components.append(weep)
continue
components.append(
{
"name": item["name"],
"unit": item["unit"],
"amount": float(item["amount"]) * scale,
"destination": item.get("destination") or "material",
"destination": item["destination"],
# 근거를 값 옆에 붙인다 — 관측값임을 화면·인계에서 바로 알아야 한다.
"basis": f"관측 원단위 {item['amount']:g}/{found.get('unit')} × {scale_note}"
+ (f" ({note})" if note else ""),
"basis_kind": BASIS_OBSERVED,
"source": source_key,
# 규격은 이름과 따로(명세 13장 Ⓒ) — 「이형철근」 + 「D13」(2026-09-14 가르기).
"spec": str(item.get("spec") or ""),
}
)
return components, [f"관측 원단위 적용 — {found.get('source_note') or source_key}"]
notes = [f"관측 원단위 적용 — {found.get('source_note') or source_key}", *blocked]
# 줄에 붙은 사유(원문끼리 다름 등) — 구조물도 그림도 같은 글자를 씀.
notes.extend(str(note) for note in found.get("notes") or [])
earthwork_note = earthwork_missing_note(found)
if earthwork_note:
notes.append(earthwork_note)
return components, notes
+350 -3
View File
@@ -16,9 +16,9 @@
기슭막이가 같은 파일에 있다. `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다**
(실측: `5601e828` 11 2점이 `facility: ford_bridge`).
**유출·유입부 기슭막이는 여기서 않는다.**
옵션(`outlet_revet_*`) 정본이 구조물 목록에서는 빠졌(2026-08-28 이관).
구조물 으로 이중계상이다.
**유출·유입부 기슭막이는 줄에 않는다 `facility_structures` 원단위 전개로 .**
옵션(`outlet_revet_*`) 정본이다(2026-08-28 이관). 부설(품셈 12-11 m당: ·기초콘크리트·
거푸집) 기슭막이 몫이 없어 겹치지 않는다. 2026-09-14 까지는 **어느 있었다**(A1).
**터파기·되메우기를 줄에 붙이지 않는다.**
부설과 굴착이 각각 오면 **같은 굴착을 ** 센다(B09 가드와 같은 자리).
@@ -26,6 +26,9 @@
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
#: 배관으로 보는 `facility` 값. 그 밖(BOX암거·물넘이·세월교·독립 기슭막이)은 관이 아니다.
@@ -192,3 +195,347 @@ def build_rows(
"ready_count": sum(1 for row in rows if row["in_bill"]),
"length_total_m": round(sum(row["quantity"] for row in rows if row["in_bill"]), 3),
}
INLET_BASIN = "집수정"
#: 기슭막이 벽 칸 — 관·독립 기슭막이 모두 `{쪽}_revet_{칸}` · 독립의 옛 저장분은 `{칸}`(B06 `_revet_side`).
REVET_FIELDS = (
("form", "형태"),
("height_m", "높이"),
("length_m", "길이"),
("before_m", ""),
("after_m", ""),
)
#: (저장 채널, 관에 딸린 줄 이름 꼬리, 독립 기슭막이 줄 이름 꼬리)
REVET_ROLES = (
("inlet", "유입부 기슭막이", "유입 칸 벽"),
("outlet", "유출부 기슭막이", "유출 칸 벽"),
)
#: 관 벽 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(B06 `pipe_wall_min_height_m`,
#: TS 짝 `pipeWallMinPureHeight`)로 섬 — 등록부·구조물도·횡단도가 한 값(2026-09-14 브레인 판정).
#: ⚠ 「최소」라 실제 설계값이 더 클 수 있어 **미확정·금액 밖은 그대로**.
NOTE_DEFAULT_WALL = (
"⚠ 시설 지점에 {filled} 을 안 적어 기본값으로 섰음 — 미확정이라 금액에 안 들어감"
)
#: 꼬리 — 관 벽은 횡단도와 같은 높이.
NOTE_WALL_HEIGHT_PIPE = " · 높이는 횡단도와 같은 관경 기준 최소 높이(실제 설계값은 더 클 수 있음)"
#: 독립 기슭막이 치수 **한 벌**(`resources/data_masonry/revetment_sabang_*.json`) — B05 칸 밑 근거와
#: 이 사유가 같은 파일을 읽음(2026-09-14 브레인 판정). 높이는 **설계자 입력** — 등록부 기본 2.5 걷음.
REVET_SABANG_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry"
REVET_SABANG_PREFIX = "revetment_sabang_"
UNCONFIRMED_OWN_HEIGHT = "높이를 안 적음 — {rule}({source}) 설계자 입력 · 적으면 금액이 섬"
@lru_cache(maxsize=1)
def load_revetment_sabang() -> dict[str, Any]:
"""독립 기슭막이 치수 한 벌. 없으면 빈 표 — 사유가 근거 없이 짧아질 뿐 값은 안 바뀜."""
files = sorted(REVET_SABANG_DIR.glob(REVET_SABANG_PREFIX + "*.json"))
return json.loads(files[-1].read_text(encoding="utf-8")) if files else {}
def _span(values: list[float], unit: str = "") -> str:
return f"{values[0]:g}~{values[1]:g}{unit}"
def own_revetment_basis() -> str:
"""독립 기슭막이 근거 한 줄 — 높이·계획비탈·둑마루·뒷채움, 쪽·줄 번호 그대로."""
data = load_revetment_sabang()
items = data.get("items") or {}
if not items:
return ""
height, slope = items["height"], items["face_slope"]
crown, pebble = items["crown_thickness"], items["backfill_pebble"]
return (
f"ⓘ 독립 기슭막이 치수 근거 — 높이 {height['rule']}({height['source']})"
f" · 계획비탈 1:{_span(slope['standard_ratio'])}({slope['source']})"
f" · 둑마루 두께 {_span(crown['standard_m'], 'm')}({crown['source']} · 콘크리트 기준)"
f" · 뒷채움 조약돌 두께 {pebble['source']} · {data.get('scope', '')}"
)
def own_height_unconfirmed() -> str:
"""높이를 안 적은 독립 기슭막이의 미확정 까닭."""
height = (load_revetment_sabang().get("items") or {}).get("height") or {}
return UNCONFIRMED_OWN_HEIGHT.format(
rule=height.get("rule", "계획홍수위 + 0.5~0.7m"),
source=height.get("source", "사방기술교본"),
)
#: 금액에서 뺄 까닭(2026-09-14 브레인 판정) — 줄·표·그림은 서되 내역 합에는 안 듦.
UNCONFIRMED_WALL = "벽 치수({filled})가 시설 지점에 없어 기본값으로 섰음 — 적으면 금액이 섬"
NOTE_SIDE_UNKNOWN = (
"⚠ 설치 측 「{side}」인데 두 칸(유입·유출) 값이 달라 어느 칸이 그쪽인지 서버가 못 가림"
"(횡단 지형이 정함) — 값을 안 세움 · 두 칸을 같게 적으면 섬"
)
def _blank(value: Any) -> bool:
return value is None or value == ""
def _num_or_zero(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _revet_values(
options: dict[str, Any],
role: str,
defaults: dict[str, Any],
legacy: bool,
min_height_m: float | None = None,
) -> tuple[dict[str, Any], list[str]]:
"""벽 한쪽 제원과 기본값으로 채운 칸 — 등록부 기본값 · 관 벽 높이는 관경 기준 최소 높이."""
values: dict[str, Any] = {}
filled: list[str] = []
for name, label in REVET_FIELDS:
value = options.get(f"{role}_revet_{name}")
if _blank(value) and legacy:
value = options.get(name)
if _blank(value):
value = defaults.get(name if legacy else f"{role}_revet_{name}")
if value is None and name == "height_m" and min_height_m is not None:
value = min_height_m
label = f"{label}(관경 기준)"
if label and value is not None:
filled.append(f"{label} {value}")
values[name] = value
return values, filled
def _pipe_diameter_m(options: dict[str, Any], defaults: dict[str, Any]) -> float:
"""관경(m) — 저장값, 없으면 등록부 기본(B06 세트 스펙과 같은 채움)."""
raw = options.get("pipe_diameter_mm")
if _blank(raw):
raw = defaults.get("pipe_diameter_mm")
return _num_or_zero(raw) / 1000.0 or 1.0
#: 다단 추가 기슭막이(④, 2026-09-14) — 횡단 설계가 정본이고 **실제로 선** 단 수·높이는 지형이 정함.
#: 서버 Node 재계산(`B06_Section_Structure_Layouts.extraWallRows`)이 소유 측점 `design.extra_walls` 에
#: 「선 다단 벽 목록」을 남기고 여기서 읽음(관 연장 `pipe_length_m` 과 같은 길).
EXTRA_WALLS_KEY = "extra_walls"
EXTRA_SIDE_LABELS = {"outlet": "유출측", "basin": "유입측"}
#: 다단이 딸린 기준벽 — 유출측 다단은 유출 벽, 유입측(집수정 계류측) 다단은 유입 벽.
EXTRA_SIDE_ROLES = {"outlet": "outlet", "basin": "inlet"}
UNCONFIRMED_EXTRA_HEIGHT = (
"다단 높이를 안 적음 — 기본 1.5m 를 지반에 맞춰 그린 {height}m 로 섰음 · 적으면 금액이 섬"
)
UNCONFIRMED_EXTRA_FORM = "다단 형태를 안 정해 기준벽의 기본 형태 「{form}」로 섰음"
#: 첫 미확정 단 아래는 횡단도 면적도 없는 것으로 봄(B06 `confirmedOnly`) — 같은 선에서 끊음.
UNCONFIRMED_EXTRA_ABOVE = "윗단({above}단)이 미확정이라 이 단도 미확정 — 윗단 높이를 적으면 섬"
#: 벽 몸이 성토 폐회로에도 듦(2026-09-06 사용자 확정 「구조물 면적 안 뺌」) — 고치지 않고 사유로(2026-09-14 브레인).
#: 넓이는 서버 Node 가 횡단도 벽 몸 ∩ 성토 폐회로로 잼(`wallBodyInFillM2` · `fill_overlap_m2`).
NOTE_FILL_OVERLAP = (
"⚠ 벽 몸 자리가 성토 면적에도 들어 있음(2026-09-06 확정 「구조물 면적 안 뺌」) — 이 측점 횡단도 "
"벽 몸 {area:.2f}× 연장 {length:g}m ≈ {volume:.1f}㎥ 를 벽과 성토에 두 번 셈 · "
"벽 뒤 막자갈 자리도 성토선 아래라 더 겹침"
)
#: 관 기준벽(유입·유출) 벽 몸 겹침 ㎡ — `design.wall_fill_overlap` {inlet, outlet}(서버 Node 가 잼, ㉡ 2026-09-14).
#: 호출부(`Router_Material`)를 안 늘리려고 다단 목록 통에 `base` 표시 칸으로 실어 옴 — 다단 줄은 거름.
WALL_OVERLAP_KEY = "wall_fill_overlap"
def extra_walls_from_designs(designs: list[dict[str, Any]] | None) -> dict[float, list[dict]]:
"""측점별 「선 다단 벽 목록」 — 목록이 있는 측점만(빈 목록도 담음 — 「다단 없음」).
기준벽 겹침 값이 있으면 `{"base": 역할, "fill_overlap_m2": }` 칸을 뒤에 붙임."""
found: dict[float, list[dict]] = {}
for row in designs or []:
design = row.get("design") if isinstance(row, dict) else None
if not isinstance(design, dict):
continue
walls = design.get(EXTRA_WALLS_KEY)
overlaps = design.get(WALL_OVERLAP_KEY)
if not isinstance(walls, list) and not isinstance(overlaps, dict):
continue
entries = list(walls) if isinstance(walls, list) else []
if isinstance(overlaps, dict):
entries += [{"base": role, "fill_overlap_m2": area} for role, area in overlaps.items()]
found[round(float(row.get("chainage_m") or 0.0), 3)] = entries
return found
def _extra_walls_at(extra_walls: dict[float, list[dict]] | None, chainage: float) -> list[dict]:
"""관 자리의 주인 측점 목록 — 스냅 때문에 최대 0.5m 어긋남(`SECTION_MATCH_TOLERANCE_M`)."""
if not extra_walls:
return []
best = min(extra_walls, key=lambda value: abs(value - chainage))
return extra_walls[best] if abs(best - chainage) <= SECTION_MATCH_TOLERANCE_M else []
def facility_structures(
points: list[dict[str, Any]],
extra_walls: dict[float, list[dict]] | None = None,
) -> list[dict[str, Any]]:
"""계곡 통과 시설(`pipe_points.json`) → 원단위 전개가 읽는 구조물 줄 (A1, 2026-09-14).
자체는 `build_rows` 연장으로 .
다단 추가 기슭막이는 `extra_walls`(측점 목록, `extra_walls_from_designs`) 줄을 세움.
적힌 칸은 **등록부 기본값** 사실을 사유로 붙이고 `unconfirmed` 금액 합에서
(2026-09-14 브레인 판정 줄은 서되 금액은 실제 값이 있을 때만).
집수정·기슭막이 터파기·되메우기는 전개 성분(`destination: earthwork`)이라 토공집계로만 .
"""
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Engine_Culvert import pipe_wall_min_height_m
from B08_Quantity.B08_Quantity_Engine_StructureSummary import pipe_row_id
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of, child_row
types = structure_type_map()
def defaults(type_id: str) -> dict[str, Any]:
found = types.get(type_id)
return {option.key: option.default for option in found.options} if found else {}
rows: list[dict[str, Any]] = []
for point in points or []:
facility = str(point.get("facility") or FACILITY_PIPE)
chainage = float(point.get("chainage_m") or 0.0)
options = dict(point.get("options") or {})
base = {
"structure_id": pipe_row_id(point),
"type_id": facility,
"chainage_m": chainage,
"start_m": point.get("start_m") if point.get("start_m") is not None else chainage,
"end_m": point.get("end_m") if point.get("end_m") is not None else chainage,
"options": options,
}
if facility not in (FACILITY_PIPE, "revetment"):
rows.append(base) # BOX암거·물넘이·세월교 — 전개식·관측값이 없으면 사유로 섬
continue
legacy = facility == "revetment" # 독립 기슭막이 — 벽 칸 밖의 제원(뒷길이 등)도 벽이 씀
wall_defaults = defaults(facility)
roles = list(REVET_ROLES)
inlet_notes: list[str] = []
if not legacy:
parent = {**base, "type_id": "pipe"}
children = [r for r in attachments_of(parent) if r["type_id"] != "pipe_inlet_basin"]
inlet = str(options.get("inlet_type") or wall_defaults.get("inlet_type") or "")
if inlet == INLET_BASIN:
# 형식을 안 골랐어도 줄은 세움 — 관측표가 「형식을 골라야 섬」 사유를 냄.
children.append(child_row(parent, "pipe_inlet_basin", "유입부 집수정"))
roles = roles[1:]
elif options.get("inlet_basin_form"):
inlet_notes.append(
f"⚠ 집수정 형식({options['inlet_basin_form']})이 적혀 있으나 유입구 구조가 "
f"{inlet}」라 집수정은 안 셈 — 유입구를 「집수정」으로 바꾸면 섬"
)
rows.extend(children)
walls = []
min_height = (
None if legacy else pipe_wall_min_height_m(_pipe_diameter_m(options, wall_defaults))
)
for role, pipe_label, own_label in roles:
values, filled = _revet_values(options, role, wall_defaults, legacy, min_height)
notes = list(inlet_notes) if role == "inlet" else []
if filled:
tail = "" if legacy else NOTE_WALL_HEIGHT_PIPE
notes.append(NOTE_DEFAULT_WALL.format(filled=" · ".join(filled)) + tail)
if legacy:
# 독립 기슭막이 — 치수 한 벌 근거(사방 교본 기준 · 쪽·줄 번호)를 늘 붙임.
basis = own_revetment_basis()
if basis:
notes.append(basis)
walls.append([role, own_label if legacy else pipe_label, values, notes, False, filled])
side = str(options.get("side") or "") if legacy else ""
if side in ("", ""):
# 좌·우가 유입/유출 어느 칸인지는 **횡단 지형**이 정함 — 두 칸이 같을 때만 한 벽으로 셈.
same = walls[0][2] == walls[1][2]
walls = [[walls[0][0], f"{side}", walls[0][2], walls[0][3], not same, walls[0][5]]]
if not same:
walls[0][3].append(NOTE_SIDE_UNKNOWN.format(side=side))
foundation = options.get("foundation" if legacy else "revet_foundation")
kept = {k: v for k, v in options.items() if not k.startswith(("inlet_", "outlet_"))}
station_walls = _extra_walls_at(extra_walls, chainage)
base_overlap = {
str(entry["base"]): _num_or_zero(entry.get("fill_overlap_m2"))
for entry in station_walls
if entry.get("base")
}
for role, label, values, notes, withheld, filled in walls:
before, after = _num_or_zero(values["before_m"]), _num_or_zero(values["after_m"])
row = child_row(base, "revetment", label, f"{role}_revet")
reasons = [UNCONFIRMED_WALL.format(filled=" · ".join(filled))] if filled else []
if legacy and _blank(values["height_m"]):
# 높이는 설계자 입력(계획홍수위 + 여유고) — 없으면 줄만 서고 금액 밖.
reasons.append(own_height_unconfirmed())
# 좌·우 한 벽으로 합친 독립 기슭막이는 그 측점에 선 벽 몸 전부.
overlap = (
sum(base_overlap.values())
if legacy and len(walls) == 1
else base_overlap.get(role, 0.0)
)
if overlap > 0 and not reasons: # 미확정 벽은 금액에 안 듦 — 두 번 셀 금액이 없음
notes.append(
NOTE_FILL_OVERLAP.format(
area=overlap, length=before + after, volume=overlap * (before + after)
)
)
row.update(
start_m=chainage - before,
end_m=chainage + after,
options={**(kept if legacy else {}), **values, "foundation": foundation},
notes=notes,
withheld=withheld,
unconfirmed=" · ".join(reasons),
)
rows.append(row)
# 다단 — 기준벽의 기초·(독립이면) 제원 칸을 물려받고, 형태·높이·구간은 선 벽 목록 값.
parents = {role: (values, filled) for role, _label, values, _notes, _w, filled in walls}
counts = {"outlet": 0, "basin": 0}
first_unset: dict[str, int] = {}
for tier in station_walls:
if tier.get("base"):
continue
side = "basin" if str(tier.get("side")) == "basin" else "outlet"
counts[side] += 1
if not tier.get("height_set"):
first_unset.setdefault(side, counts[side])
parent_values, parent_filled = parents.get(
EXTRA_SIDE_ROLES[side], next(iter(parents.values()), ({}, []))
)
height = _num_or_zero(tier.get("height_m"))
form = str(tier.get("form") or parent_values.get("form") or "")
before, after = _num_or_zero(tier.get("before_m")), _num_or_zero(tier.get("after_m"))
reasons = []
if not tier.get("height_set"):
reasons.append(UNCONFIRMED_EXTRA_HEIGHT.format(height=f"{height:g}"))
elif first_unset.get(side, counts[side]) < counts[side]:
reasons.append(UNCONFIRMED_EXTRA_ABOVE.format(above=first_unset[side]))
if not tier.get("form_set") and any(str(f).startswith("형태") for f in parent_filled):
reasons.append(UNCONFIRMED_EXTRA_FORM.format(form=form))
label = f"{EXTRA_SIDE_LABELS[side]} 다단 기슭막이 {counts[side]}"
row = child_row(base, "revetment", label, str(tier.get("key") or label))
tier_notes = [
f"ⓘ 횡단도에 선 다단 벽 — 높이 {height:g}m · 형태 {form} · 전 {before:g}/후 {after:g}m"
]
overlap = _num_or_zero(tier.get("fill_overlap_m2"))
if overlap > 0 and not reasons: # 미확정 단은 면적에도 금액에도 안 듦
tier_notes.append(
NOTE_FILL_OVERLAP.format(
area=overlap, length=before + after, volume=overlap * (before + after)
)
)
row.update(
start_m=chainage - before,
end_m=chainage + after,
options={
**(kept if legacy else {}),
"form": form,
"height_m": height,
"length_m": before + after,
"before_m": before,
"after_m": after,
"foundation": foundation,
},
notes=tier_notes,
withheld=False,
unconfirmed=" · ".join(reasons),
)
rows.append(row)
return rows
+130 -63
View File
@@ -10,7 +10,7 @@
토공집계 지장목제거로 이미 이라고 가리키기만** 한다.
값이 없는 줄의 사유를 적는다
· 표토제거(9-15) 면적은 사면적에서 나오나 **두께·대상 구간이 설계로 정해져 있다**.
· 표토제거(9-15-2) **노면 + 절토 사면 ** · 노면 폭을 세면 (2026-09-13 실무대로).
· 제근·뿌리다듬기(9-20~21) 단위가 **(그루 )**인데 입목 본수를 우리가 든다.
· 규준틀(11-2) **개소** 산정 기준(구조물·절성토 구간별 개소) 정해져 있다.
@@ -56,6 +56,7 @@ from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( # noqa: E402
STATUS_PENDING,
STATUS_READY,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_TreeWaste import tree_waste_rows # noqa: E402
__all__ = ["ANCILLARY_ITEMS", "ancillary_rows"] # 갈라 나간 뒤에도 여기서 읽을 수 있게
@@ -110,13 +111,15 @@ def preparation_rows(
topsoil_thickness_m: float | None = None,
topsoil_haul_distance_m: float | None = None,
stand_volume_class: str | None = None,
road_surface_area_m2: float | None = None,
topsoil_road_only: bool = False,
) -> list[dict[str, Any]]:
"""준비공 줄 — 값이 서는 것과 안 서는 것을 **한 목록에** 낸다."""
slope = slope_totals or {}
tree_area = float(slope.get("tree_removal_fill", 0.0)) + float(
slope.get("tree_removal_cut", 0.0)
)
topsoil = _topsoil_row(slope, topsoil_thickness_m)
topsoil = _topsoil_row(slope, road_surface_area_m2, topsoil_road_only)
return [
{
"group": "준비공",
@@ -141,7 +144,7 @@ def preparation_rows(
},
topsoil,
# ⚠ 법이 요구하는 **운반·적치** — 제거 물량이 곧 밑수다(별표2).
_topsoil_haul_row(topsoil, topsoil_haul_distance_m),
_topsoil_haul_row(topsoil, topsoil_thickness_m, topsoil_haul_distance_m),
_root_removal_row(slope, stand_volume_class),
*_root_steps_rows(slope, stand_volume_class),
_batter_frame_row(list(slope_rows)),
@@ -149,53 +152,59 @@ def preparation_rows(
]
def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str, Any]:
"""표토제거 — **두께는 품셈이 아니라 설계가 정한다**(9-15 [주]② 「T : 표토두께(m)」).
def _topsoil_row(
slope: dict[str, float], road_area_m2: float | None, road_only: bool = False
) -> dict[str, Any]:
"""표토제거 — **밑수 ㎡ · 대상 · 20m 압토 셋이 한 묶음**(2026-09-13 판정 · PLAN 10장).
두께를 넣으면 **0 으로 때우지 않고** 물량을 낸다. 대상 면적은 ·성토 사면적을
쓴다(사면 계열의 면고르기 면적과 같은 자리).
밑수 **** 품셈 9-15-2 T=0.2 m ** 조건**으로 면적 (실무 영월 표토제거
답외구간 M2). 두께는 여기 곱한다 운반 부피에만 쓴다.
대상 **노면 + 절토대상지** 별표2 .2..(6) 문언. 성토대상지는 (7) 제거·정리 .
`road_only` 노면만(영월 관측 · 설계자 선택 · 2026-09-14 판정).
**20 m 압토는 품에 ** 운반 줄은 넘는 몫만(`_topsoil_haul_row`).
하나만 고치면 합계가 그럴듯해 보이면서 틀린다 셋을 함께 바꿨다.
"""
area = float(slope.get("face_dressing_fill", 0.0)) + float(slope.get("face_dressing_cut", 0.0))
# ⚠ 면적이 0 이면 **0 ㎥ 를 「값 있음」으로 내지 않는다** — 0 은 「없음」과 구별이 안 되고,
# 받는 쪽이 「표토가 없는 노선」으로 읽는다. 절·성토 사면적이 0 인 임도는 성립하지
# 않으므로 이 자리는 사실상 **사면표가 아직 안 선 것**이다(2026-09-09 감사에서 잡음).
cut = 0.0 if road_only else float(slope.get("face_dressing_cut", 0.0))
base = {"group": "준비공", "item": "표토제거", "unit": "", "work_item_code": TOPSOIL_CODE}
if road_area_m2 is None:
return {
**base,
"amount": None,
"status": STATUS_PENDING,
"reason": (
"노면 면적을 못 셉니다 — 횡단 설계에 노체 끝(노면 폭)이 없는 측점이 있음. "
f"대상은 노면 + 절토대상지(별표2) · 절토 사면 {cut:,.1f}㎡ 만으로는 세우지 않음"
),
"reference_amount": cut,
}
area = float(road_area_m2) + cut
# ⚠ 면적이 0 이면 **0 ㎡ 를 「값 있음」으로 내지 않는다** — 받는 쪽이 「표토가 없는 노선」으로
# 읽는다(2026-09-09 감사에서 잡음). 노면이 0 인 임도는 성립하지 않으므로 표가 안 선 것이다.
if area <= 0:
return {
"group": "준비공",
"item": "표토제거",
"unit": "",
**base,
"amount": None,
"status": STATUS_PENDING,
"reason": (
"대상 면적이 0 ㎡ 입니다 — 사면표(면고르기 면적)가 아직 서지 않았습니다. "
"0 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다"
"대상 면적이 0 ㎡ 입니다 — 횡단·사면표가 아직 서지 않았습니다. "
"0 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다"
),
"work_item_code": "FP-09-15",
}
if thickness_m is None or float(thickness_m) <= 0:
if road_only:
return {
"group": "준비공",
"item": "표토제거",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": (
"표토 두께가 아직 입력되지 않았습니다 — 두께는 설계가 정하는 값입니다. "
f"{TOPSOIL_ORIGINAL_APPLIED} "
f"산출 조건에서 두께를 넣으면 값이 섭니다 (대상 면적 {area:,.1f}㎡)"
),
"reference_amount": area,
"work_item_code": "FP-09-15",
**base,
"amount": area,
"status": STATUS_READY,
"reason": f"노면 {area:,.1f}㎡ (노면만 — 산출 조건에서 고름)"
f"{TOPSOIL_ROAD_ONLY_BASIS}",
}
thickness = float(thickness_m)
return {
"group": "준비공",
"item": "표토제거",
"unit": "",
"amount": area * thickness,
**base,
"amount": area,
"status": STATUS_READY,
"reason": (f"사면적 {area:,.1f}× 두께 {thickness:g}m (품셈 9-15) · {TOPSOIL_AREA_GAP}"),
"work_item_code": "FP-09-15",
"reason": (
f"노면 {float(road_area_m2):,.1f}㎡ + 절토 사면 {cut:,.1f}㎡ — {TOPSOIL_TARGET_BASIS}"
),
}
@@ -213,19 +222,22 @@ TOPSOIL_ORIGINAL_APPLIED = (
" — 값을 자동으로 넣지 않되, 원문에 있는 값이므로 참고할 것"
)
#: ⚠⚠ **대상 면적이 법 문언과 어긋난다**(2026-09-09 준비공 축 감사에서 잡음).
#: 별표2 .2.차.(6) 은 대상을 **「노면·절토대상지」**로 못박고, 성토대상지는 (7) 에서
#: 「표토 등은 **제거·정리**한다」로 따로 두어 **운반·적치 의무를 안 건다.**
#: 우리는 `face_dressing_cut + face_dressing_fill` 로 **절토·성토 사면적을 다 더하고**
#: **노면(연장 × 노폭)은 안 센다** — 두 방향으로 어긋난 셈이다.
#: ㉡ 계열(「또는」을 다 더함) — 성토 사면까지 운반·적치 물량에 실림
#: 빠짐 — 노면 표토가 아예 안 섬(법이 첫째로 든 대상)
#: ⚠ **값을 임의로 고치지 않는다** — 실무가 사면적 한 덩이로 잡는지, 노면을 따로 잡는지가
#: 사용자·실무자 확인 사항이다. 지금은 **사유로 드러내기만** 한다.
TOPSOIL_AREA_GAP = (
"⚠ 대상 면적 확인 필요 — 별표2 는 「노면·절토대상지」로 두는데 우리는 절토·성토"
" **사면적을 다 더하고 노면은 안 셈**. 성토대상지는 별표2 (7) 이 「제거·정리」로만 두어"
" 운반·적치 의무를 안 검. 실무 확인 뒤 대상을 확정할 것"
#: 표토제거 공종 — **답외구간**(9-15-2). 임도는 논(畓)이 아니고 실무 영월도 「답외구간」으로 셈.
TOPSOIL_CODE = "FP-09-15-02"
#: ⭐ 2026-09-13 판정 「실무대로」 — 대상·밑수·압토 셋의 근거(화면 사유에 그대로 보임).
#: 종전(2026-09-09 감사)엔 절토·성토 **사면적을 다 더하고 노면은 안 세** 법 문언과 두 방향으로
#: 어긋나 있었고 사유로만 드러내고 있었다.
TOPSOIL_TARGET_BASIS = (
"대상은 별표2 Ⅰ.2.차.(6) 「노면·절토대상지」 · 성토대상지는 (7) 「제거·정리」라 안 셈"
" · 밑수 ㎡(품셈 9-15-2 가 T=0.2m 를 품 조건으로 둔 면적 축"
" · 실무 영월 「표토제거 답외구간 M2」)"
" · 20m 압토는 제거 품에 듦(2026-09-13 판정 「실무대로」)"
)
#: 노면만 — 설계자가 고른 경우의 근거. ⚠ 법 문언이 아니라는 사실을 사유에 함께 적는다.
TOPSOIL_ROAD_ONLY_BASIS = (
"⚠ 법 문언(별표2 Ⅰ.2.차.(6))은 「노면·절토대상지」라 기본은 노면 + 절토"
" · 노면만은 실무 영월 한 건(1m당 3.34㎡) 관측이라 설계자 선택"
" · 밑수 ㎡ · 20m 압토는 제거 품에 듦"
)
#: 표토 운반·적치 — ⚠ **법이 요구하는데 우리가 제거만 세고 있던 자리**(2026-09-09).
@@ -237,10 +249,11 @@ TOPSOIL_AREA_GAP = (
TOPSOIL_HAUL_LAW = (
"⚠ 법정 의무 — 시행규칙 별표2 「표토는 전량 제거한 후 … 최고 홍수위보다 높은 장소로"
" 운반하고 쌓아두어야 한다」. 제거만 세면 운반이 빠짐"
" · ⚠⚠ **다만 제거 품(9-15-2)에 이미 L=20m 압토가 들어 있음** — 실무 내역서도"
" 「표토제거 답외구간 / M2」 한 줄뿐이고 운반 줄이 따로 없음. 이 줄은 **20m 를 넘는"
" 몫**일 때만 새 줄이며, 그 가름을 아직 안 함(확인 필요)"
" · 제거 품(9-15-2)에 L=20m 압토가 들어 있어 이 줄은 **20m 를 넘는 몫**만 셈"
" (실무 영월 「표토제거 답외구간 / M2」 한 줄 · 운반 줄 없음)"
)
#: 제거 품이 품은 압토 거리(m) — 품셈 9-15-2 원문 적용값 `L(운반거리) 20m`.
TOPSOIL_PUSH_INCLUDED_M = 20.0
#: 제근 — ⚠ **본수가 아니라 임목축적으로 갈린다**(2026-09-09 원문 확인).
@@ -401,26 +414,56 @@ def _root_steps_rows(
]
def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict[str, Any]:
"""표토 운반 — 물량은 제거 물량 그대로, 거리는 설계 입력.
def _topsoil_haul_row(
topsoil: dict[str, Any], thickness_m: float | None, distance_m: float | None
) -> dict[str, Any]:
"""표토 운반 — 부피는 **제거 면적 × 두께**, 거리는 설계 입력에서 **20m 압토를 뺀 몫**.
제거 줄이 (두께 미입력) 운반도 선다 밑수가 줄이기 때문이.
제거 줄이 거나 두께 비면 운반도 선다 0 으로 때우지 않는.
"""
amount = topsoil.get("amount")
area = topsoil.get("amount")
distance = None
if distance_m is not None:
try:
distance = float(distance_m)
except (TypeError, ValueError):
distance = None
if amount is None:
if area is None:
return {
"group": "준비공",
"item": "표토 운반·적치",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": f"{TOPSOIL_HAUL_LAW} · 제거 물량이 아직 안 서서 운반도 못 셈(두께 먼저)",
"reason": f"{TOPSOIL_HAUL_LAW} · 제거 면적이 아직 안 서서 운반도 못 셈",
"work_item_code": None,
}
if thickness_m is None or float(thickness_m) <= 0:
return {
"group": "준비공",
"item": "표토 운반·적치",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": (
f"{TOPSOIL_HAUL_LAW} · 운반 부피 = 제거 면적 × 표토 두께 — 두께가 아직 입력되지"
f" 않았습니다. {TOPSOIL_ORIGINAL_APPLIED} (제거 면적 {float(area):,.1f}㎡)"
),
"reference_amount": float(area),
"work_item_code": None,
}
amount = float(area) * float(thickness_m)
if distance is not None and 0 < distance <= TOPSOIL_PUSH_INCLUDED_M:
return {
"group": "준비공",
"item": "표토 운반·적치",
"unit": "",
"amount": None,
"status": STATUS_COUNTED_ELSEWHERE,
"reason": (
f"{TOPSOIL_HAUL_LAW} · 운반거리 {distance:g}m 는 제거 품의 20m 압토 안 — 따로 안 셈"
),
"reference_amount": amount,
"work_item_code": None,
}
if distance is None or distance <= 0:
@@ -438,19 +481,22 @@ def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict
"reference_amount": float(amount),
"work_item_code": None,
}
beyond = distance - TOPSOIL_PUSH_INCLUDED_M
return {
"group": "준비공",
"item": "표토 운반·적치",
"unit": "",
"amount": float(amount),
"amount": amount,
"status": STATUS_READY,
"reason": (
f"{TOPSOIL_HAUL_LAW} · 제거 물량 그대로 · 운반거리 {distance:g}m(설계 입력)"
f"{TOPSOIL_HAUL_LAW} · 제거 면적 {float(area):,.1f}× 두께 {float(thickness_m):g}m"
f" · 운반거리 {distance:g}m(설계 입력) 압토 {TOPSOIL_PUSH_INCLUDED_M:g}m"
f" = {beyond:g}m"
" · 갈래 「토사」로 보냄(표토는 토사임 — 덤프 운반 10-12 는 토사/암절취/발파암으로"
" 갈리고 부모 코드에는 품이 없음)"
),
"work_item_code": "FP-10-12",
"haul_distance_m": distance,
"haul_distance_m": beyond,
# ⚠ `FP-10-12` 는 **부모**다 — 품이 붙은 것은 잎(10-12-1 토사 · -2 암절취 · -3 발파암)
# 뿐이라 갈래를 안 보내면 B09 가 「후보 3건」만 보이고 금액이 안 선다. 표토가 토사인
# 것은 다툼이 없으므로 여기서 갈래를 실어 보낸다(2026-09-09 마스터 대조에서 잡음).
@@ -477,6 +523,9 @@ FRAME_MATERIAL_SOURCE = (
" 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둠. 산출 조건에서 고칠 수 있음"
)
FRAME_LOSS_RATE = {"비탈 규준틀": 50, "수평 규준틀": 80}
#: 자재 줄의 **이름 · 규격** — 이름 칸에 규격을 섞으면 할증률표(「각재」·「판재」)와 안 맞음
#: (2026-09-14 · 돌 줄 8210c2b7 과 같은 병). 산출 조건 키·관급구분 키(이름+규격)는 글자 그대로.
FRAME_MATERIAL_NAME_SPEC = {"각재 50×50": ("각재", "50×50"), "판재 T12": ("판재", "T12")}
def frame_material_rows(
@@ -503,9 +552,11 @@ def frame_material_rows(
except (TypeError, ValueError):
per_ea = float(default)
picked = "산출 조건에서 고른 값" if raw not in (None, "") else "제안값(기본)"
material, spec = FRAME_MATERIAL_NAME_SPEC.get(name, (name, ""))
rows.append(
{
"name": name,
"name": material,
"spec": spec,
"unit": unit,
"amount": float(count) * per_ea,
"destination": "material",
@@ -620,6 +671,12 @@ def build_table(
stand_volume_class: str | None = None,
chipping_enabled: Any = False,
chipping_volume_m3: Any = None,
road_surface_area_m2: float | None = None,
topsoil_road_only: bool = False,
tree_waste: dict[str, Any] | None = None,
tree_waste_unit_price_krw_per_ton: Any = None,
waste_separate_order: Any = False,
tree_waste_root_method: Any = None,
) -> dict[str, Any]:
"""화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**"""
rows = (
@@ -629,9 +686,19 @@ def build_table(
topsoil_thickness_m,
topsoil_haul_distance_m,
stand_volume_class,
road_surface_area_m2,
topsoil_road_only,
)
+ erosion_rows(structures, names)
+ chipping_rows(chipping_enabled, chipping_volume_m3)
# 임목폐기물 — 톤 · 비목은 경비(내역 줄 아님 · 2026-09-14 판정).
+ tree_waste_rows(
slope_totals,
tree_waste,
tree_waste_unit_price_krw_per_ton,
waste_separate_order,
tree_waste_root_method,
)
+ ancillary_rows(ancillary_counts)
)
return {
@@ -0,0 +1,181 @@
"""준비공 — 임목폐기물 처리 한 줄 (톤) · 2026-09-14 판정 「폐기물처리비」.
수량 산식 한국건설기술연구원 건설현장 임목폐기물의 재활용 자원화 기술개발(2012) 3.2
= 환경친화적인 도로건설 지침 부록1 문단 493~508 3) 임목폐기물(산출근거: 조경설계요람)
지상부 WA = k × 3.14 × (B/2)² × h × (1+p) × W1 × N (kg · 지침 식이 π 3.14 적음)
뿌리부 지침이 방법을 둔다. 고르는 .
분배비(기본) WR = WA × 15/85 (KICT 일반 수목류 2026-09-14 판정 유지)
뿌리분 체적 WR = 0.3927 × × 1,300 × N (지침 V = πD²(D/2 + RH2/3) = 0.3927
· UW1 뿌리부분의 단위중량 1,300kg/m3). D 지침이 뿌리분의 직경(DBH, m)으로
적었으나 2026-09-14 판정으로 **뿌리분 직경**(흉고직경 아님) 따로 받고 기본값 없음.
k 수간 형상계수 0.5 · p 지엽 보합률(임목 0.3) **산식 상수** 칸이 없다.
B 평균 흉고직경 · h 평균 수고 · W1 수목 단위체적중량 · N 본수
**현장 조사값**이라 칸으로 받는다.
N = 표본지 1,000 실측 본수 × 대상 면적 ÷ 1,000 (실정보고 134/1000 × 7,450 모양).
대상 면적 = 벌목·지장목제거 면적(같은 나무를 자리).
실무 실정보고(임목폐기물처리, 현장) 뿌리 14:86 · 무게 0.65/ ·
처리비를 공급가액 (승률 ) 두었다 판정은 문언·공인 자료 (PLAN 10).
발주처가 요구하면 칸으로 자리.
금액은 **처리단가가 없어** 선다(KCRA 중간처리단가 미확보) 수동 단가를 넣으면
금액이 서되 미확정으로 센다(빨간 테두리). 비목은 **경비** 내역 줄이 아니고
원가계산서 폐기물처리비 줄로 간다(예정가격작성기준 제19조③18호).
"""
from __future__ import annotations
import math
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import STATUS_PENDING, STATUS_READY
TREE_WASTE_ITEM = "임목폐기물 처리"
STEM_FORM_FACTOR = 0.5 # k
FOLIAGE_ALLOWANCE = 0.3 # p — 임목(고립목은 1.0)
PI_AS_WRITTEN = 3.14 # 지침 부록1 식 원문 · 실정보고도 3.14
ROOT_SHARE, ABOVE_SHARE = 15.0, 85.0 # 일반 수목류 분배비
ROOT_BALL_FACTOR = 0.3927 # V = 0.3927 D³ (지침 부록1)
ROOT_BALL_UNIT_WEIGHT = 1300.0 # ㎏/㎥ (지침 부록1 UW1)
ROOT_METHOD_RATIO, ROOT_METHOD_BALL = "ratio", "root_ball"
#: 칸 이름 · 화면 이름 · 단위 — 모두 채워야 수량이 선다.
TREE_WASTE_INPUTS: tuple[tuple[str, str], ...] = (
("stems_per_1000m2", "표본지 1,000㎡당 본수"),
("dbh_cm", "평균 흉고직경(㎝)"),
("height_m", "평균 수고(m)"),
("unit_weight_kg_m3", "단위체적중량(㎏/㎥)"),
)
TREE_WASTE_BASIS = (
"환경친화적인 도로건설 지침 부록1 · 한국건설기술연구원(2012) 3.2 — k 0.5 · p 0.3(임목) · π 3.14"
" · N = 표본지 1,000㎡ 본수 × 벌목·지장목제거 면적"
)
ROOT_RATIO_BASIS = "뿌리 WR = WA × 15/85(분배비 · 일반 수목류)"
ROOT_BALL_BASIS = "뿌리 WR = 0.3927 ×× 1,300㎏/㎥ × N(뿌리분 체적 · 지침 부록1)"
ROOT_BALL_LABEL = "뿌리분 직경 D(m, 흉고직경 아님 · 굴취 흙덩이 직경)"
#: 배출자 신고 경계 — 화면이 알릴 뿐 값을 바꾸지 않는다.
REPORT_THRESHOLD_TON = 5.0
NOT_SEPARATE_NOTE = (
"임목폐기물은 분리발주 대상 아님(환경부 질의회신 — 임목폐기물처리 실정보고 p4 ·"
" 건설폐기물 업무처리지침: 건설폐기물 아닌 사업장 일반폐기물)"
)
EXPENSE_NOTE = (
"비목은 경비(예정가격작성기준 제19조③18호) — 내역 줄이 아니라 원가계산서 폐기물처리비로 감"
)
def _positive(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number > 0 else None
def _missing(inputs: dict[str, Any], root_method: str) -> list[str]:
"""비어 있는 칸 이름 — 뿌리분 체적법이면 뿌리분 직경도 든다."""
missing = [label for key, label in TREE_WASTE_INPUTS if _positive(inputs.get(key)) is None]
if root_method == ROOT_METHOD_BALL and _positive(inputs.get("root_ball_diameter_m")) is None:
missing.append(ROOT_BALL_LABEL)
return missing
def tree_waste_tons(
area_m2: float, inputs: dict[str, Any], root_method: str = ROOT_METHOD_RATIO
) -> tuple[float, float] | None:
"""(지상부 톤, 뿌리부 톤). 칸이 하나라도 비거나 면적이 0 이면 `None`."""
if area_m2 <= 0 or _missing(inputs, root_method):
return None
stems = float(inputs["stems_per_1000m2"]) * area_m2 / 1000.0
diameter = float(inputs["dbh_cm"]) / 100.0
above_kg = (
STEM_FORM_FACTOR
* PI_AS_WRITTEN
* (diameter / 2.0) ** 2
* float(inputs["height_m"])
* (1.0 + FOLIAGE_ALLOWANCE)
* float(inputs["unit_weight_kg_m3"])
* stems
)
if root_method == ROOT_METHOD_BALL:
ball = float(inputs["root_ball_diameter_m"])
root_kg = ROOT_BALL_FACTOR * ball**3 * ROOT_BALL_UNIT_WEIGHT * stems
else:
root_kg = above_kg * ROOT_SHARE / ABOVE_SHARE
return above_kg / 1000.0, root_kg / 1000.0
def tree_waste_rows(
slope_totals: dict[str, float] | None,
inputs: dict[str, Any] | None,
unit_price_krw_per_ton: Any = None,
separate_order: Any = False,
root_method: Any = None,
) -> list[dict[str, Any]]:
"""임목폐기물 처리 한 줄 — 값이 없어도 줄은 선다(무엇을 넣으면 풀리는지 적음)."""
slope = slope_totals or {}
area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0))
inputs = inputs or {}
method = ROOT_METHOD_BALL if root_method == ROOT_METHOD_BALL else ROOT_METHOD_RATIO
root_basis = ROOT_BALL_BASIS if method == ROOT_METHOD_BALL else ROOT_RATIO_BASIS
base = {
"group": "준비공",
"item": TREE_WASTE_ITEM,
"unit": "",
"work_item_code": None,
# 경비 — 내역에 세우지 않는다(인계가 이 칸을 본다).
"bill": False,
"bill_reason": EXPENSE_NOTE,
"separate_order": bool(separate_order),
}
tons = tree_waste_tons(area, inputs, method)
if tons is None:
missing = _missing(inputs, method)
why = (
f"현장 조사값이 비어 있음 — {' · '.join(missing)}"
if missing
else "벌목·지장목제거 면적이 0 ㎡ — 사면표가 아직 안 섬"
)
return [
{
**base,
"amount": None,
"status": STATUS_PENDING,
"reason": f"{why}. 산식: {TREE_WASTE_BASIS} · {root_basis}",
"reference_amount": area,
}
]
above, root = tons
total = above + root
notes = [
f"지상부 WA {above:,.2f}톤 + 뿌리부 WR {root:,.2f}",
TREE_WASTE_BASIS,
root_basis,
EXPENSE_NOTE,
NOT_SEPARATE_NOTE,
]
if total >= REPORT_THRESHOLD_TON:
notes.append("5톤 이상 — 배출자 신고 대상 사업장(폐기물관리법 시행령 제2조8호)")
price = _positive(unit_price_krw_per_ton)
row = {
**base,
"amount": total,
"status": STATUS_READY,
"reference_amount": area,
"above_ground_ton": above,
"root_ton": root,
"unit_price_krw_per_ton": price,
"amount_krw": math.floor(total * price) if price else None,
# 수동 단가로 선 금액 — 화면 「미확정 N건」·빨간 테두리.
"unconfirmed": 1 if price else 0,
}
if price:
notes.append(
f"처리비 {total:,.2f}× {price:,.0f}원 = {math.floor(total * price):,}"
" (⚠ 수동 단가 — 미확정 · KCRA 중간처리단가 미확보)"
)
else:
notes.append(
"처리단가 없음(KCRA 중간처리단가 미확보) — 산출 조건에 수동으로 넣으면 금액이 섬"
)
row["reason"] = " · ".join(notes)
return [row]
@@ -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
+20 -1
View File
@@ -134,6 +134,24 @@ def totals(rows: list[SlopeAreaRow]) -> dict[str, float]:
return {key: sum(row.areas.get(key, 0.0) for row in rows) for key in keys}
def cut_material_totals(
slopes: Iterable[StationSlope], ratios: SlopeRatios | None = None
) -> dict[str, float]:
"""면고르기 절토면의 토사·암 몫 면적 — 같은 평균단면적법 · 같은 반영률(합 ≤ 절토면 전체)."""
rate = (ratios or SlopeRatios()).of("face_dressing")
ordered = sorted(slopes, key=lambda s: s.chainage_m)
result = {"face_dressing_cut_soil": 0.0, "face_dressing_cut_rock": 0.0}
for before, after in zip(ordered, ordered[1:]):
distance = after.chainage_m - before.chainage_m
for key, field_name in (
("face_dressing_cut_soil", "cut_soil_length_m"),
("face_dressing_cut_rock", "cut_rock_length_m"),
):
pair = getattr(before, field_name) + getattr(after, field_name)
result[key] += pair / 2.0 * distance * rate
return result
def unclosed_stations(rows: list[SlopeAreaRow]) -> list[float]:
"""사면이 원지반을 못 만나 **면적이 잘린** 측점 목록.
@@ -148,6 +166,7 @@ def build_table(
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양."""
rates = ratios or SlopeRatios()
slopes = list(slopes)
rows = build_rows(slopes, rates)
return {
"method": "average_end_area",
@@ -166,7 +185,7 @@ def build_table(
}
for row in rows
],
"totals": totals(rows),
"totals": {**totals(rows), **cut_material_totals(slopes, rates)},
"unclosed_stations": unclosed_stations(rows),
"station_count": len(rows),
}
@@ -58,6 +58,10 @@ class StationSlope:
chainage_m: float
cut_length_m: float = 0.0
fill_length_m: float = 0.0
#: 절토 사면길이의 토사·암 몫 — 면고르기가 9-19-1(토사)·9-19-2·3(암)으로 갈림(2026-09-14).
#: 둘의 합이 `cut_length_m` 보다 작으면 그 차이는 **못 가른 몫**(설계 지반 프리셋 없음).
cut_soil_length_m: float = 0.0
cut_rock_length_m: float = 0.0
#: 층따기 밑수 — **원지반 표면**의 경사길이(m). B06 설계가 측점마다 낸다
#: (`design.bench_cut_length_m`, 2026-09-09 랩탑 메인).
#: ⚠ **성토 비탈면 길이와 다른 면이다** — 층따기는 성토부 **아래 원지반**을 계단으로
@@ -69,6 +73,9 @@ class StationSlope:
# ⚠ 좌우가 다르면 **큰 쪽**을 쓴다. 「중심점 성토고 5m 이상」(품셈 11-3 [주]①) 판정은
# 가장 높은 쪽이 기준이고, 양쪽을 더하면 실제보다 두 배가 된다.
fill_height_m: float = 0.0
#: 노면 폭(m) — 노체 끝(`road_edges`) 좌우 사이. 표토제거 대상 「노면」 면적의 밑수(별표2).
#: 노체 끝이 저장에 없으면 `None` — 0 으로 때우지 않는다.
roadbed_width_m: float | None = None
segments: tuple[SlopeSegment, ...] = ()
# 사면이 샘플 범위 끝까지 원지반을 못 만나 **면적이 잘린** 측점.
# 설계 엔진이 `slope_unclosed` 로 이미 경고하는 값을 그대로 물고 온다. 잘린 측점은
@@ -207,6 +214,25 @@ def _material(design: dict[str, Any], ratio: float) -> str | None:
return "rock" if abs(ratio - rock) < abs(ratio - soil) else "soil"
def _roadbed_width(design: dict[str, Any]) -> float | None:
"""노면 폭 — 노체 끝 좌(+)·우(−) 오프셋 사이. 한쪽이라도 없으면 `None`."""
edges = design.get("road_edges") or {}
left = _num((edges.get("left") or {}).get("offset_m"))
right = _num((edges.get("right") or {}).get("offset_m"))
return None if left is None or right is None else abs(left - right)
def road_surface_area(slopes: Iterable[StationSlope]) -> float | None:
"""노면 면적(㎡) — 사면과 같은 평균단면적법. 노면 폭이 빈 측점이 하나라도 있으면 `None`."""
ordered = sorted(slopes, key=lambda s: s.chainage_m)
if not ordered or any(s.roadbed_width_m is None for s in ordered):
return None
return sum(
(float(a.roadbed_width_m) + float(b.roadbed_width_m)) / 2.0 * (b.chainage_m - a.chainage_m)
for a, b in zip(ordered, ordered[1:])
)
def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope:
"""측점 하나의 사면길이. 좌우를 합쳐 절토·성토 각각의 총 사면길이를 낸다."""
ratios = _ratios(design)
@@ -218,13 +244,23 @@ def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope:
side: sum(abs(s.rise_m) for s in segments if s.role == "fill" and s.side == side)
for side in ("left", "right")
}
# 2단 비탈은 경사비가 토사·암을 가름 · 1단은 그 측점 설계의 지반 프리셋(토사/암)이 비탈 전체
preset = {"soil": "soil", "rock": "rock"}.get(str(design.get("geometry_preset") or ""))
cut_by = {"soil": 0.0, "rock": 0.0}
for segment in segments:
material = segment.material or preset
if segment.role == "cut" and material in cut_by:
cut_by[material] += segment.length_m
return StationSlope(
chainage_m=float(chainage_m),
cut_length_m=sum(s.length_m for s in segments if s.role == "cut"),
cut_soil_length_m=cut_by["soil"],
cut_rock_length_m=cut_by["rock"],
fill_length_m=sum(s.length_m for s in segments if s.role == "fill"),
# ⚠ 없는 측점은 0 이다 — 성토 사면길이로 **대신 채우지 않는다**(면이 다름).
bench_cut_length_m=_num(design.get("bench_cut_length_m")) or 0.0,
fill_height_m=max(fill_by_side.values(), default=0.0),
roadbed_width_m=_roadbed_width(design),
berm_width_m=_num(berm.get("width_m")) or 0.0,
segments=tuple(segments),
unclosed=bool(design.get("slope_unclosed")),
@@ -0,0 +1,222 @@
"""STmate 출력 엑셀 「일위대가표」 읽개 → **고정형** 라이브러리 항목 (PLAN 4장 · 2026-09-14 브레인 판정).
엑셀인가 STC 호표(B) 구성이 난독화된 BDQTY 에만 있어 읽음(7파일 전수) · 경쟁사 보호 장치는
. 출력 엑셀은 평문이고 모양은 코덱스 `recipe_extract.py`(실무 6 누락 0) 그대로.
**모양이 다르면 억지로 읽지 않음** 머리 ·수량 칸이 어긋나면 어느 칸이 맞는지 사유로
돌려보내고 호표는 통째로 . 추측해서 맞추면 수량이 조용히 틀린 라이브러리에 들어가 계속 쓰임.
뽑는 것은 **수량만**(판정 ) 원문 단가·금액은 실음. 수량도 시점 품셈값이라 사유를 붙임.
단만(판정 ) 구성 줄이 하위 호표면 이름·수량만. 하위 전개는 우리 일위대가 .
원문 호표·자원 코드(B#####·M#####)는 파일 안 카운터라 **출처로 기록만**(판정 Ⓓ).
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, BinaryIO
SHEET = "일위대가표"
HEADER_ROW = 3
HEADER = ("명칭", "규격", "수량", "단위")
COLUMNS = "ABCD"
HOPYO = re.compile(r"^제(\d+)호표$")
ROW_CODE = re.compile(r"(?<!BD)COD\|([A-Z]\d{5})")
HOPYO_CODE = re.compile(r"BDCOD\|(B\d{5})")
#: 비고가 이것이면 별도계상 자재 — 자재총괄 자리(판정 ㉮). 그 밖의 비고로는 갈 곳을 안 가름.
SEPARATE_MATERIAL = ("별산자재", "별산M")
#: 착공·변경 내역서 판에만 섞이는 낙찰률 줄 — 설계 조합이 아니라 안 뽑음(판정 ㉰).
#: 실무 6건 전수에서 단위 없는 줄은 이 다섯 꼴뿐: 합계 · 계 · 소계 · 계약단가 · 계 x 낙찰율(88 %).
CONTRACT_ROWS = ("계약단가", "계x낙찰율")
NOTE_SOURCE_QTY = "원문 시점 수량 · 현행 품셈 대조 전"
NOTE_PERCENT = "원문에 가산 행 {count}줄 있었음({names}) — 우리 계산에는 안 듦"
NOTE_CONTRACT = "원문 계약단가·낙찰율 줄 {count}줄은 설계 조합이 아니라 안 뽑음"
def _text(value: Any) -> str:
return "" if value is None else str(value).strip()
def read_recipes(path: str | Path | BinaryIO) -> dict[str, Any]:
"""`{"project": 공사명, "hopyo": [호표…], "problems": [사유…]}` — 못 읽은 호표는 목록에 없고 사유만."""
import openpyxl
problems: list[str] = []
try:
book = openpyxl.load_workbook(path, data_only=True, read_only=True)
except Exception as error: # noqa: BLE001 — 어떤 파일이든 사유로 돌려보냄
return {"project": "", "hopyo": [], "problems": [f"엑셀을 못 엶 — {error}"]}
if SHEET not in book.sheetnames:
return {
"project": "",
"hopyo": [],
"problems": [f"시트 「{SHEET}」가 없음 — STmate 출력 엑셀이 아님"],
}
rows = [list(r) + [None] * 14 for r in book[SHEET].iter_rows(max_col=14, values_only=True)]
head = rows[HEADER_ROW - 1] if len(rows) >= HEADER_ROW else [None] * 14
for column, want, got in zip(COLUMNS, HEADER, head):
if _text(got).replace(" ", "") != want:
problems.append(
f"{column}{HEADER_ROW} 칸이 「{want}」이 아니라 「{_text(got)}」 — 이 모양은 못 읽음"
)
if problems:
return {"project": "", "hopyo": [], "problems": problems}
project = _text(rows[1][0]).split(":", 1)[-1].strip() if len(rows) > 1 else ""
hopyo: list[dict[str, Any]] = []
current: dict[str, Any] | None = None
def close() -> None:
if current and not current["broken"]:
if current["name"]:
hopyo.append({k: v for k, v in current.items() if k != "broken"})
else:
problems.append(f"{current['no']}호표 제목 줄이 없음 — 안 읽음")
for index, row in enumerate(rows[HEADER_ROW:], start=HEADER_ROW + 1):
name, spec, qty, unit = _text(row[0]), _text(row[1]), row[2], _text(row[3])
key = name.replace(" ", "")
found = HOPYO.match(key)
if found:
close()
code = HOPYO_CODE.search(_text(row[13]))
current = {
"no": int(found.group(1)),
"source_code": code.group(1) if code else "",
"name": "",
"spec": "",
"unit": "",
"rows": [],
"basis": [],
"contract_rows": 0,
"broken": False,
}
continue
if key.startswith("합계"):
close()
current = None
continue
if current is None or current["broken"] or not any(_text(c) for c in row[:4]):
continue
where = f"{current['no']}호표"
if not current["name"]:
if not name or qty not in (None, "") or not unit:
problems.append(
f"A{index}: {where} 제목 줄(명칭·단위 · 수량 빈칸) 모양이 아님 — 안 읽음"
)
current["broken"] = True
else:
current.update(name=name, spec=spec, unit=unit)
continue
if not unit and (key == "" or key.startswith("소계")):
continue # 착공·변경 판 소계 줄 — 구성 아님
if not unit and key.startswith(CONTRACT_ROWS):
current["contract_rows"] += 1
continue
if not unit and qty == 0 and not isinstance(qty, bool):
current["basis"].append(f"{name} {spec}".strip()) # 품셈 근거 줄 — 수량 0 · 단위 없음
continue
if isinstance(qty, bool) or not isinstance(qty, (int, float)):
problems.append(
f"C{index}: {where}{name}」 수량 「{_text(qty)}」이 수가 아님 — 안 읽음"
)
current["broken"] = True
continue
if not name or not unit:
empty = "명칭" if not name else "단위"
problems.append(
f"{'A' if not name else 'D'}{index}: {where}{name}{empty}이 빔 — 이 모양은 못 읽음"
)
current["broken"] = True
continue
code = ROW_CODE.search(_text(row[13]))
current["rows"].append(
{
"name": name,
"spec": spec,
"amount": qty,
"unit": unit,
"remark": _text(row[12]),
"source_code": code.group(1) if code else "",
}
)
close()
return {"project": project, "hopyo": hopyo, "problems": problems}
def recipe_item(
hopyo: dict[str, Any], *, type_id: str, file_name: str, project: str
) -> dict[str, Any]:
"""읽은 호표 하나 → 고정형 라이브러리 항목(명세 13장 칸 계약 · `formula` 빈칸 = 고정형). 코드는 저장이 발급."""
basis = " · ".join(hopyo.get("basis") or [])
rows = []
percent = []
for seq, source in enumerate(hopyo["rows"], start=1):
is_percent = source["unit"] == "%"
if is_percent:
percent.append(source["name"])
separate = any(mark in source["remark"].replace(" ", "") for mark in SEPARATE_MATERIAL)
rows.append(
{
"seq": seq,
"name": source["name"],
"spec": source["spec"],
"formula": "",
"formula_text": " · ".join(part for part in ("STmate 원문 수량", basis) if part),
"amount": str(source["amount"]),
"unit": source["unit"],
# 원문에서 구성 줄은 그 호표 단가 안으로 들어감(호표 = 일위대가) · 별도계상만 자재총괄(판정 ㉮)
"destination": "reference"
if is_percent
else "material"
if separate
else "unit_price",
"rounding": {"mode": "none", "digits": 0},
"source": "library",
"reason": "가산 행 — 수량 계산에 안 씀" if is_percent else NOTE_SOURCE_QTY,
}
)
# ㉮ 일위대가 구성 — 일위대가로 가는 줄마다 한 줄(수량 = 그 원단위 줄). **공종 코드는 비움**:
# 원문 코드는 파일 카운터라 못 쓰고 이름으로 확정도 안 함(명세 2장) → 사용자가 고르개로 고름.
# 고르기 전엔 그 장 금액이 「미완 — 공종 코드 미정」(조용한 0 아님 · 2026-09-14 판정).
unit_rows = [
{
"seq": index,
"name": row["name"],
"spec": row["spec"],
"unit": row["unit"],
"from_row": row["seq"],
}
for index, row in enumerate(
(row for row in rows if row["destination"] == "unit_price"), start=1
)
]
notes = [NOTE_SOURCE_QTY]
if percent:
notes.append(NOTE_PERCENT.format(count=len(percent), names=" · ".join(percent)))
if hopyo.get("contract_rows"):
notes.append(NOTE_CONTRACT.format(count=hopyo["contract_rows"]))
return {
"schema_version": 1,
"item_kind": "fixed",
"type_id": type_id,
"name": f"{hopyo['name']} {hopyo['spec']}".strip(),
"unit": hopyo["unit"],
"note": " · ".join(notes),
"vars": {},
"tables": {},
"rows": rows,
"unit_price": {
"unit": hopyo["unit"],
"note": "STmate 호표 구성 — 줄마다 공종 코드를 고르개로 골라야 금액이 섬(원문 코드는 안 씀)",
"rows": unit_rows,
},
# 출처 — 원문 공사명·파일명이 들어감. 공유 만들 때 뺄지·가릴지 판정(PLAN 4장).
"origin": {
"kind": "stmate_xlsx",
"file": file_name,
"project": project,
"hopyo_no": hopyo["no"],
"hopyo_code": hopyo["source_code"],
},
}
@@ -0,0 +1,456 @@
"""구조물도 **상단 그림** — 돌쌓기 단면 (PLAN 12장 · 3장 한 장의 짜임 「상단 그림」).
**2026-09-14 B07 에서 이관** 자리 `B07_DesignDetail_Engine_Cad_StandardFigure.py`.
CAD 엔티티 대신 **모양 목록(·글자, m 단위, 위가 +y)** 화면이 SVG 그리고(읽기 전용),
CAD 일감이 같은 목록을 받아 (B07 `StandardSheet` 이미 옮겨 적음).
**기울기를 값으로 받는다 0.3 박지 않는다.**
판정은 B08 `slope_of()` (품셈 13-4-4 [] 표준경사 직고·메찰·성절토). 사용자가 정했으면
값이 이긴다(확정 ). 그림은 **수량이 바로 **으로 기울고 근거 문구도 같이 적는다.
**두께는 수량표가 쓰는 함수**(`UnitQuantity.wall_thickness`) 사용자가 넣은 상부·하부 두께가
식을 이긴다. B07 그림은 식을 따로 적어 **두께를 넣어도 그림만 두께** 섰음(이관 잡음).
상부 두께 = 뒷길이 + 0.30 하부 두께 = 상부 + 0.30 × (H 1.0)
터파기 = 평균두께 + 0.2 기초 0.5×0.9 (기초유) · 0.1×0.7 (기초버림)
터파기 치수는 **`common_util_excavation` ** 읽는다 횡단도가 쓰는 상수다.
뒷길이를 정한 장은 **표가 기본값으로 그리고 사유와 같은 말을 적는다**
(2026-09-14 브레인 판정 표가 서면 그림도 ). 까닭은 정말 그리는 (큰돌쌓기 ).
**치수가 없는 것은 그리되 치수를 적지 않는다** 막자갈(뒷채움) 우리 식이 입적에서
몸통·고임돌을 이라 ·높이로 정의된 도형이 아니다. 자리만 보이고 값은 표를 가리킨다.
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Pipe import NOTE_DEFAULT_WALL
from B08_Quantity.B08_Quantity_Engine_StructureSheet import sheet_is_wet, slope_of
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
_back_length,
back_length_default_note,
wall_thickness,
weep_hole_spec,
)
from common_util.common_util_excavation import (
WALL_BLINDING_DEPTH_M,
WALL_BLINDING_WIDTH_M,
WALL_FOUNDATION_DEPTH_M,
WALL_FOUNDATION_WIDTH_M,
WALL_TRENCH_CLEARANCE_M,
)
#: 그림 축척 — 1/25(1m = 40㎜). CAD 로 옮길 때 글자 크기(㎜)를 m 로 바꾸는 데도 씀.
SCALE_MM_PER_M = 40.0
#: 이 그림을 그리는 종류. 집수정처럼 단면이 다른 것은 아직 그리지 않는다.
#: ⚠ **큰돌쌓기(`boulder_masonry`)를 뺐다**(2026-09-09). 그림이 두께를 **뒷길이**에서 내는데
#: 큰돌쌓기는 규격 축이 **직경**(`stone_cm`)이라 **영영 못 그린다** — 품셈 13-6(직경) ↔
#: 13-4(뒷길이). 「직경에서 두께를 내는 식」은 도메인 판단이라 사용자 몫.
#: ⓘ 옹벽(2026-09-14)은 **관측 원단위 줄에 실린 단면 치수**로 그림 — 표 수량이 나온 그 단면.
#: 기슭막이는 형태가 돌쌓기면 **돌쌓기 식 그대로**라 그림도 같음(표가 안 선 형태는 표 사유가 까닭).
MASONRY_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "revetment"})
#: 정본 개소당·㎡당 구조물 — `_StructureFigure_Small`(치수는 표가 쓴 한 벌).
SMALL_TYPE_IDS: frozenset[str] = frozenset({"erosion_check", "soil_guard", "bed_sill"})
#: 관측 원단위 줄의 `section`(표 수량이 나온 그 단면)으로 그리는 종류 — 단면 없는 규격은 까닭.
OBSERVED_TYPE_IDS: frozenset[str] = frozenset(
{"retaining_wall", "pipe_inlet_basin", "pipe_wing_wall", "ford_pavement"}
)
FIGURE_TYPE_IDS: frozenset[str] = MASONRY_TYPE_IDS | SMALL_TYPE_IDS | OBSERVED_TYPE_IDS
#: 개거 — 표는 서는데 그림을 못 정한 까닭(2026-09-14 원문 대조).
_OPEN_DITCH_REASONS: dict[str, str] = {
"콘크리트 개거 150×200": (
"그림 없음 — 원문 둘이 다름(산출식 0.3×0.1 ↔ 그림 홈 150×200 + 밑 보 400×200)"
" · 같은 탭 「개거(150-200)」 안에서 갈림"
" · 어느 쪽으로 그릴지 확인 대기(임의로 고르지 않음)"
),
"L형수로 H=0.2": (
"그림 없음 — 원문 「L형수로-(201)」 탭에 단면 그림이 없어"
" 산출식만으로 모양을 정하지 않습니다"
),
}
#: 그림이 못 서는 장에 **까닭을 적는다** — 빈 자리를 그냥 두면 사용자가 「고장」으로 읽는다
#: (2026-09-09 사용자 지시). 「무엇을 받아야 서는지」까지 적는다.
_NO_FIGURE_REASONS: dict[str, str] = {
"boulder_masonry": (
"그림 없음 — 큰돌쌓기는 규격이 **직경**이라 두께를 낼 식이 아직 없습니다"
"(뒷길이로 내는 돌쌓기 식과 축이 다름 · 사용자 확정 대기)"
),
}
_NO_FIGURE_DEFAULT = "그림 없음 — 이 형식은 아직 단면을 그리지 않습니다"
#: 글자 크기(m) — 종이 ㎜ 를 축척으로 나눈 값(옛 CAD 그림과 같은 크기).
_LABEL_SIZE = 4.0 / SCALE_MM_PER_M
_DIM_SIZE = 3.4 / SCALE_MM_PER_M
def back_length_cm(sheet: dict[str, Any]) -> tuple[int, str | None]:
"""(표가 쓴 뒷길이 ㎝, 기본으로 섰으면 표 사유와 같은 한 줄).
양식 장은 양식이 (`formula_sheet.vars.L3`), 전개 장은 `_back_length` 표와 .
"""
options = sheet.get("options") or {}
wet, height = sheet_is_wet(sheet), float(sheet.get("height_m") or 0.0)
solved = ((sheet.get("formula_sheet") or {}).get("vars") or {}).get("L3")
back = (
int(float(solved))
if isinstance(solved, (int, float))
else _back_length(options, wet=wet, height_m=height)
)
return back, back_length_default_note(options, back, wet=wet, height_m=height)
def figure_reason(sheet: dict[str, Any]) -> str | None:
"""그림이 **안 서는 까닭** 한 줄. 서는 장이면 `None`."""
type_id = str(sheet.get("type_id") or "")
if "rows" in sheet and not sheet["rows"]:
# 표가 안 선 장 — 표가 적은 그 사유를 그대로(표 = 그림 · 같은 말).
notes = [str(note).replace("**", "") for note in sheet.get("notes") or []]
return f"그림 없음 — {notes[0] if notes else '표가 서지 않았습니다'}"
if type_id == "open_ditch":
spec = str((sheet.get("options") or {}).get("ditch_spec") or "콘크리트 개거 150×200")
return _OPEN_DITCH_REASONS.get(spec, _NO_FIGURE_DEFAULT)
if type_id not in FIGURE_TYPE_IDS:
return _NO_FIGURE_REASONS.get(type_id, _NO_FIGURE_DEFAULT)
if type_id in SMALL_TYPE_IDS:
return None # 치수가 없으면 표가 안 서고, 그때는 위 「표가 안 선 장」이 사유를 냄
if type_id in OBSERVED_TYPE_IDS:
return _observed_section(sheet)[1]
if float(sheet.get("height_m") or 0.0) <= 0:
return "그림 없음 — 높이가 없습니다(제원에서 높이를 넣으면 그림이 섭니다)"
return None
def _observed_section(sheet: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]:
"""(관측 원단위 줄, 못 그리는 까닭) — 표가 줄을 꺼낸 **그 조회**(`OBSERVED_SPEC_KEYS`)를 씀.
표가 장은 사유와 같은 앞에 그림 없음 붙임.
"""
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import load_observed_table
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import OBSERVED_SPEC_KEYS
from B08_Quantity.B08_Quantity_Wording import option_missing, spec_missing
type_id = str(sheet.get("type_id") or "")
keys = OBSERVED_SPEC_KEYS.get(type_id) or ()
options = sheet.get("options") or {}
spec = {key: options[key] for key in keys if options.get(key) is not None}
table = load_observed_table()
if not spec:
return None, f"그림 없음 — {option_missing(keys[0], type_id)}"
entry = table.find(type_id, spec)
if entry is None:
return None, f"그림 없음 — {spec_missing(type_id, table.specs_for(type_id))}"
if not entry.get("section"):
why = _NO_SECTION_REASONS.get(type_id) or _NO_SECTION_BY_SOURCE.get(
str(entry.get("source") or ""), "이 규격은 자료에 수량만 있고 단면 치수가 없습니다"
)
return None, f"그림 없음 — {why}"
return entry, None
#: 관측 줄에 단면이 없는 까닭 — 종류별(원문을 봤는데 못 정한 자리).
_NO_SECTION_REASONS: dict[str, str] = {
"pipe_wing_wall": (
"원문 「날개벽(…)산출근거」 탭에 구조도 그림이 없고(「구조도 참조」만) 날개 모양이"
" 산출식에 손으로 적은 수로만 있어 모양을 정하지 않음 — 구조도 원본 확보 대기"
),
}
#: 출처별 — 수량 집계표 관측값은 치수 원문 자체가 없음.
_NO_SECTION_BY_SOURCE: dict[str, str] = {
"uljin_compare": "수량 집계표 관측값(울진 1공구)이라 구조도·치수 원문이 없습니다",
}
def _path(points: list[tuple[float, float]], role: str, *, dash: bool = False) -> dict[str, Any]:
return {"kind": "path", "points": [list(p) for p in points], "role": role, "dash": dash}
def _text(
text: str, at: tuple[float, float], align: str, size: float = _DIM_SIZE
) -> dict[str, Any]:
return {"kind": "text", "text": text, "at": list(at), "align": align, "size": size}
def build_figure(
sheet: dict[str, Any], rubble_thickness_m: float | None = None
) -> list[dict[str, Any]] | None:
"""장 하나의 그림 — 모양 목록(밑면 앞끝이 원점). 못 그리면 `None`(까닭은 `figure_reason`).
`rubble_thickness_m` 산출 조건 기초잡석 두께(표의 기초잡석 줄과 같은 · 비면 기본).
"""
if figure_reason(sheet) is not None:
return None
type_id = sheet.get("type_id")
if type_id == "retaining_wall":
entry, _reason = _observed_section(sheet)
return _wall_figure(sheet, entry or {}, rubble_thickness_m)
if type_id in ("pipe_inlet_basin", "ford_pavement"):
from B08_Quantity import B08_Quantity_Engine_StructureFigure_Small as small
entry, _reason = _observed_section(sheet)
if type_id == "pipe_inlet_basin":
return small.inlet_basin_figure(sheet, entry or {})
return small.pavement_figure(sheet, entry or {})
if type_id in SMALL_TYPE_IDS:
from B08_Quantity import B08_Quantity_Engine_StructureFigure_Small as small
if type_id == "bed_sill":
return small.bed_sill_figure(sheet, rubble_thickness_m)
if type_id == "erosion_check":
return small.erosion_check_figure(sheet)
return small.soil_guard_figure(sheet)
return _masonry_figure(sheet)
def _masonry_figure(sheet: dict[str, Any]) -> list[dict[str, Any]]:
"""돌쌓기 단면 — 두께는 수량표 함수 한 벌."""
options = sheet.get("options") or {}
height_m = float(sheet.get("height_m") or 0.0)
back_cm, default_note = back_length_cm(sheet)
slope, slope_note = slope_of(sheet)
top_t, bottom_t, _basis = wall_thickness(options, back_cm=back_cm, height_m=height_m)
lean = slope * height_m
average_t = (top_t + bottom_t) / 2.0
dig_width = average_t + WALL_TRENCH_CLEARANCE_M
# 기초 몫 — 「기초유 / 기초버림」이 폭·깊이를 가른다(정본 탭 제목). 안 정한 장은 안 그린다.
foundation = str(options.get("foundation") or "")
base_w, base_d = {
"기초유": (WALL_FOUNDATION_WIDTH_M, WALL_FOUNDATION_DEPTH_M),
"기초버림": (WALL_BLINDING_WIDTH_M, WALL_BLINDING_DEPTH_M),
}.get(foundation, (0.0, 0.0))
shapes = [
_path(
[(0.0, 0.0), (lean, height_m), (lean + top_t, height_m), (bottom_t, 0.0), (0.0, 0.0)],
"wall",
),
# 터파기 — 수량식 H × (평균두께 + 0.2) 와 **넓이가 같은** 평행사변형(앞면 따라 기움).
_path(
[
(0.0, 0.0),
(lean, height_m),
(lean + dig_width, height_m),
(dig_width, 0.0),
(0.0, 0.0),
],
"guide",
dash=True,
),
]
if base_d > 0:
shapes.append(
_path([(0.0, 0.0), (0.0, -base_d), (base_w, -base_d), (base_w, 0.0)], "guide")
)
# 물구멍 — 벽을 가로지르는 짧은 선. 값은 **그 장의 제원**에서(수량표와 같은 함수).
weep_area_m2, weep_diameter_mm, _weep_basis = weep_hole_spec(options)
weep_y = height_m * 0.5
shapes.append(_path([(slope * weep_y, weep_y), (slope * weep_y + top_t, weep_y)], "guide"))
right = max(lean + dig_width, base_w) + 0.16
shapes += [
_text(f"H = {height_m:g} m", (-0.28, height_m / 2.0), "right"),
_text(f"상부 {top_t:.2f} m", (lean + top_t / 2.0, height_m + 0.14), "center"),
_text(f"하부 {bottom_t:.2f} m", (bottom_t / 2.0, -0.30 - base_d), "center"),
_text(f"1 : {slope:g}", (lean / 2.0 - 0.30, height_m * 0.72), "right"),
_text(
f"터파기 폭 {dig_width:.2f} m "
f"(평균두께 {average_t:.2f} + {WALL_TRENCH_CLEARANCE_M:g})",
(right, height_m * 0.92),
"left",
),
_text(
f"뒷길이 ℓ₃ = {back_cm}{' (기본)' if default_note else ''}",
(right, height_m * 0.72),
"left",
),
_text("막자갈(뒷채움) — 수량은 아래 표", (right, height_m * 0.55), "left"),
_text(
f"물구멍 Ø{weep_diameter_mm}{weep_area_m2:g}㎡당 1개소",
(right, weep_y - 0.2),
"left",
),
_text(
f"기초 {base_w:g} × {base_d:g} m ({foundation})"
if base_d > 0
else "기초 — 안 정함(기초유/기초버림)",
(right, -base_d / 2.0 if base_d else 0.0),
"left",
),
# 기울기 근거는 그림 밑에 한 줄 — 「사용자 지정」인지 「품셈 표준경사」인지 보여야 한다.
_text(f"전면 기울기 — {slope_note}", (0.0, -0.62 - base_d), "left"),
_text(
f"{sheet.get('title') or '돌쌓기'} (축척 1/{int(1000 / SCALE_MM_PER_M)})",
(0.0, height_m + 0.52),
"left",
_LABEL_SIZE,
),
]
# 「이 값이 어디서 왔나」 — 표 사유 줄과 **글자까지 같게**(브레인 판정). 벽 치수 자체가
# 기본값인 관 지점 기슭막이는 그 줄도 — 그림이 설계값처럼 보이면 더 위험함(2026-09-14).
wall_prefix = NOTE_DEFAULT_WALL.split("{")[0]
lines = [default_note] + [
str(n) for n in sheet.get("notes") or [] if str(n).startswith(wall_prefix)
]
for index, line in enumerate(line for line in lines if line):
shapes.append(_text(line, (0.0, -0.80 - base_d - index * 0.18), "left"))
return shapes
def _wall_figure(
sheet: dict[str, Any], entry: dict[str, Any], rubble_thickness_m: float | None
) -> list[dict[str, Any]]:
"""옹벽 단면 — 관측 원단위 줄의 `section`(표 수량이 나온 그 단면)으로 그림.
x 앞굽 끝이 0(앞면 왼쪽), y 기초 밑면이 0. 버림·기초잡석은 기초 , 전단키가 뚫고 내려감.
기초잡석 두께는 표의 기초잡석 줄과 같은 산출 조건 (비면 기본) 폭은 버림과 같음.
"""
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import earthwork_missing_note
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import RUBBLE_BASE_THICKNESS_M
section = entry["section"]
footing, wall, key = section["footing"], section["wall"], section["key"]
blinding, weep = section["blinding"], section["weep"]
f_w, f_t, toe = footing["width_m"], footing["thickness_m"], footing["toe_m"]
w_h, w_top = wall["height_m"], wall["top_m"]
front, back = wall["front_batter_m"], wall["back_batter_m"]
w_bottom = w_top + front + back
heel = f_w - toe - w_bottom
k_w, k_d = key["width_m"], key["depth_m"]
k_left = f_w - key["from_heel_m"] - k_w
b_t, over = blinding["thickness_m"], blinding["overhang_m"]
rubble = RUBBLE_BASE_THICKNESS_M if rubble_thickness_m is None else float(rubble_thickness_m)
top = f_t + w_h
shapes = [
_path(
[
(0.0, 0.0),
(0.0, f_t),
(toe, f_t),
(toe + front, top),
(toe + front + w_top, top),
(toe + w_bottom, f_t),
(f_w, f_t),
(f_w, 0.0),
(k_left + k_w, 0.0),
(k_left + k_w, -k_d),
(k_left, -k_d),
(k_left, 0.0),
(0.0, 0.0),
],
"wall",
),
]
# 버림(키 자리는 비움) · 그 밑 기초잡석(같은 폭).
layers = [(0.0, b_t, False)] + ([(b_t, rubble, True)] if rubble > 0 else [])
for depth, thickness, dash in layers:
for x0, x1 in ((-over, k_left), (k_left + k_w, f_w + over)):
shapes.append(
_path(
[
(x0, -depth),
(x0, -depth - thickness),
(x1, -depth - thickness),
(x1, -depth),
],
"guide",
dash=dash,
)
)
weep_y = f_t + w_h * 0.5
ratio = (weep_y - f_t) / w_h
shapes.append(
_path([(toe + front * ratio, weep_y), (toe + w_bottom - back * ratio, weep_y)], "guide")
)
# 터파기 — 표 줄과 같은 식(기초 폭 + 양쪽 여유 · 수직 · 기초 윗면에서 기초잡석 밑까지). 점선.
trench = section.get("trench")
clearance = trench["clearance_m"] if trench else 0.0
side = (trench or {}).get("side_slope", 0.0) * (f_t + b_t + rubble)
dig_bottom = -(b_t + rubble)
if trench:
shapes.append(
_path(
[
(-clearance - side, f_t),
(-clearance, dig_bottom),
(f_w + clearance, dig_bottom),
(f_w + clearance + side, f_t),
],
"guide",
dash=True,
)
)
right = max(f_w + over, f_w + clearance + side) + 0.2
weep_area, weep_dia, _weep_basis = weep_hole_spec(sheet.get("options") or {})
blinding_width = f_w + 2 * over - k_w
shapes += [
_text(
f"{sheet.get('title') or '옹벽'} {sheet.get('options', {}).get('form') or ''}"
f" (축척 1/{int(1000 / SCALE_MM_PER_M)})",
(0.0, top + 0.52),
"left",
_LABEL_SIZE,
),
_text(f"H = {top:g} m", (-0.28, top / 2.0), "right"),
_text(f"상단 {w_top:.2f} m", (toe + front + w_top / 2.0, top + 0.14), "center"),
_text(
f"벽체 높이 {w_h:.2f} · 하단 {w_bottom:.2f} m (앞면 {front:g} · 뒷면 {back:g} 기움)",
(right, top * 0.9),
"left",
),
_text(
f"물구멍 Ø{weep_dia}{weep_area:g}㎡당 1개소"
f" · 관 {weep['pipe_length_m']:g} m(관측값)",
(right, weep_y),
"left",
),
_text(
f"기초 {f_w:.2f} × {f_t:.2f} m (앞굽 {toe:.2f} · 뒷굽 {heel:.2f})",
(right, f_t / 2.0),
"left",
),
_text(
f"버림 T={b_t:.2f} · 폭 {f_w + 2 * over:.2f} {k_w:.2f} = {blinding_width:.2f} m",
(right, -b_t / 2.0),
"left",
),
_text(f"전단키 {k_w:.2f} × {k_d:.2f} m", (k_left + k_w / 2.0, -k_d - 0.14), "center"),
]
if rubble > 0:
shapes.append(
_text(
f"기초잡석 T={rubble:g} m — 산출 조건 · 폭은 버림과 같음",
(right, -b_t - rubble / 2.0),
"left",
)
)
if trench:
shapes.append(
_text(
f"터파기 밑폭 {f_w + 2 * clearance:.2f} · 윗폭 {f_w + 2 * (clearance + side):.2f}"
f" × 깊이 {f_t - dig_bottom:.2f} m (기초 {f_w:g} + 여유 {clearance:g}×2"
f" · 옆면 1:{trench.get('side_slope', 0.0):g})",
(right, f_t + 0.25),
"left",
)
)
below = -max(k_d, b_t + rubble) - 0.36
notes = [
f"단면 — 관측 원단위 {entry.get('source_note') or ''}의 수량이 나온 그 단면",
earthwork_missing_note(entry),
]
for index, note in enumerate(note for note in notes if note):
shapes.append(_text(note, (0.0, below - index * 0.16), "left"))
return shapes
def attach_figures(payload: dict[str, Any], rubble_thickness_m: float | None = None) -> None:
"""장마다 `figure`(모양 목록 또는 `None`)와 `figure_reason` 을 실음 — 화면은 둘 중 하나."""
for sheet in payload.get("sheets") or []:
sheet["figure_reason"] = figure_reason(sheet)
sheet["figure"] = build_figure(sheet, rubble_thickness_m)
@@ -0,0 +1,321 @@
"""구조물도 상단 그림 — **골막이 · 떼흙막이 · 바닥막이** (정본 개소당·㎡당 구조물, PLAN 12장).
치수는 **표가 **에서 읽음 골막이 `erosion_check_section` ·
떼흙막이 `SOIL_GUARD_SOD.dims` · 바닥막이 `BED_SILL_FORMS` + 산출 조건 기초잡석 두께.
사유 글자도 사유와 같은 상수(`BED_SILL_TRENCH_NOTE` ).
좌표는 m · 위가 +y · 모양 목록 규약은 `B08_Quantity_Engine_StructureFigure` 같음.
"""
from __future__ import annotations
import math
from typing import Any
from B08_Quantity.B08_Quantity_Engine_StructureFigure import (
_LABEL_SIZE,
SCALE_MM_PER_M,
_path,
_text,
)
def _title(sheet: dict[str, Any], y: float) -> dict[str, Any]:
return _text(
f"{sheet.get('title') or ''} (축척 1/{int(1000 / SCALE_MM_PER_M)})",
(0.0, y),
"left",
_LABEL_SIZE,
)
def _front_with_trench(
top: float, bottom: float, height: float, depth: float, x0: float = 0.0
) -> list[dict[str, Any]]:
"""정면 사다리꼴(윗변 `top` · 밑변 `bottom` 가운데 정렬) + 사면 둘·밑변을 따라 판 띠(점선)."""
inset = (top - bottom) / 2
corners = [(x0, height), (x0 + inset, 0.0), (x0 + inset + bottom, 0.0), (x0 + top, height)]
shapes = [_path(corners + [corners[0]], "wall")]
if depth > 0:
# 사면 둘·밑변을 바깥으로 `depth` 만큼 민 선 — 이웃 변의 교점으로 잇음.
normals = []
for (ax, ay), (bx, by) in zip(corners[:3], corners[1:4]):
length = math.hypot(bx - ax, by - ay)
normals.append(((by - ay) / length, -(bx - ax) / length))
offset = []
for index, (px, py) in enumerate(corners):
used = [normals[i] for i in (index - 1, index) if 0 <= i < 3]
nx = sum(n[0] for n in used)
ny = sum(n[1] for n in used)
dot = used[0][0] * nx + used[0][1] * ny
offset.append((px + nx * depth / dot, py + ny * depth / dot))
shapes.append(_path(offset, "guide", dash=True))
return shapes
def _circle(cx: float, cy: float, r: float) -> list[tuple[float, float]]:
return [
(cx + r * math.cos(math.tau * i / 24), cy + r * math.sin(math.tau * i / 24))
for i in range(25)
]
def _rect(x0: float, y0: float, x1: float, y1: float) -> list[tuple[float, float]]:
return [(x0, y0), (x0, y1), (x1, y1), (x1, y0), (x0, y0)]
def inlet_basin_figure(sheet: dict[str, Any], entry: dict[str, Any]) -> list[dict[str, Any]]:
"""집수정(□형) — 긴 벽 정면(바닥기초 · 관 · 방수로 · 터파기) + 평면(바깥·안 벽).
치수는 관측 `section`( 수량이 나온 치수). ·방수로 **자리** 원문 입체도 모식.
"""
section = entry["section"]
inner, wall, base = section["inner"], section["wall_m"], section["base_m"]
spill, dig = section["spillway"], section["dig"]
length, width = inner["length_m"] + 2 * wall, inner["width_m"] + 2 * wall
height = inner["height_m"]
top = base + height
radius = section["pipe_d_m"] / 2
mid = length / 2
notch_half_top, notch_half_bottom = spill["top_m"] / 2, spill["bottom_m"] / 2
# 긴 벽 정면 — 윗변 가운데에 방수로 한 곳(모두 `count` 곳).
outline = [
(0.0, 0.0),
(0.0, top),
(mid - notch_half_top, top),
(mid - notch_half_bottom, top - spill["depth_m"]),
(mid + notch_half_bottom, top - spill["depth_m"]),
(mid + notch_half_top, top),
(length, top),
(length, 0.0),
(0.0, 0.0),
]
dig_len = length + dig["extra_length_m"]
shapes = [
_path(outline, "wall"),
_path([(0.0, base), (length, base)], "guide"),
_path(_circle(mid, base + radius, radius), "guide"),
_path(
_rect(-dig["extra_length_m"] / 2, 0.0, length + dig["extra_length_m"] / 2, top),
"guide",
dash=True,
),
]
# 평면 — 바깥 벽 · 안 벽 · 터파기 평면(점선).
px = length + 1.2
shapes += [
_path(_rect(px, 0.0, px + length, width), "wall"),
_path(_rect(px + wall, wall, px + length - wall, width - wall), "guide"),
_path(
_rect(
px - dig["extra_length_m"] / 2,
(width - dig["width_m"]) / 2,
px + length + dig["extra_length_m"] / 2,
(width + dig["width_m"]) / 2,
),
"guide",
dash=True,
),
]
right = px + length + 0.5
shapes += [
_title(sheet, max(top, dig["width_m"]) + 0.55),
_text("정면(긴 벽)", (mid, top + 0.15), "center"),
_text("평면", (px + length / 2, width + 0.15 + (dig["width_m"] - width) / 2), "center"),
_text(
f"{inner['length_m']:g}×{inner['width_m']:g}×{height:g} · 벽 {wall:g} · 바닥기초"
f" {length:g}×{width:g}×{base:g} m",
(right, top * 0.9),
"left",
),
_text(f"관 Ø{section['pipe_d_m']:g}", (right, top * 0.65), "left"),
_text(
f"방수로 {spill['count']}곳 (상장 {spill['top_m']:g} · 하장 {spill['bottom_m']:g}"
f" · 고 {spill['depth_m']:g})",
(right, top * 0.4),
"left",
),
_text(
f"터파기 {dig_len:g} × {dig['width_m']:g} × 깊이 {top:g} m",
(right, top * 0.15),
"left",
),
_text("관·방수로 자리는 원문 입체도 모식 — 치수는 산출식", (0.0, -0.3), "left"),
]
return shapes
def pavement_figure(sheet: dict[str, Any], entry: dict[str, Any]) -> list[dict[str, Any]]:
"""물넘이포장 — ㎡당 한 칸 단면: 슬래브 두께(규격) · 와이어메쉬(2/3T) · 터파기(두께만큼)."""
thickness = float(entry["spec"]["thickness_cm"]) / 100
mesh = thickness * entry["section"]["mesh_from_bottom_ratio"]
width = 1.0
shapes = [
_path(_rect(0.0, 0.0, width, thickness), "wall"),
_path([(0.05, mesh), (width - 0.05, mesh)], "guide", dash=True),
_path(_rect(-0.12, 0.0, width + 0.12, thickness), "guide", dash=True),
]
right = width + 0.4
shapes += [
_title(sheet, thickness + 0.45),
_text("폭 1 m 당(표는 ㎡당)", (width / 2, thickness + 0.12), "center"),
_text(f"콘크리트 T={thickness:g} m", (right, thickness * 0.8), "left"),
_text(f"와이어메쉬 — 밑에서 {mesh:.3f} m (2/3T)", (right, mesh - 0.02), "left"),
_text(f"터파기 깊이 {thickness:g} m (= 두께)", (right, 0.0), "left"),
]
for index, note in enumerate(entry.get("notes") or []):
shapes.append(_text(str(note), (0.0, -0.25 - index * 0.14), "left"))
return shapes
def erosion_check_figure(sheet: dict[str, Any]) -> list[dict[str, Any]]:
"""골막이 — 정면(사다리꼴 · 방수로 관 · 바닥파기 띠) + 옆 단면(상부·하부 두께)."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import erosion_check_section
geo = erosion_check_section(float(sheet.get("height_m") or 0.0), sheet.get("options") or {})
top, bottom, height = geo["top_m"], geo["bottom_m"], geo["height_m"]
shapes = _front_with_trench(top, bottom, height, geo["trench_depth"])
if geo["spillway"]:
shapes.append(_path(_circle(top / 2, geo["pipe_r"], geo["pipe_r"]), "guide"))
# 옆 단면 — 뒷면 수직 · 앞면이 하부 두께에서 상부 두께로 좁아짐(표의 두께식 그대로).
x0 = top + 1.2
shapes.append(
_path(
[
(x0, 0.0),
(x0, height),
(x0 + geo["top_t"], height),
(x0 + geo["bottom_t"], 0.0),
(x0, 0.0),
],
"wall",
)
)
right = x0 + geo["bottom_t"] + 0.3
shapes += [
_title(sheet, height + 0.55),
_text(f"상장 ⓐ {top:g} m", (top / 2, height + 0.14), "center"),
_text(f"하장 ⓑ {bottom:g} m", (top / 2, -geo["trench_depth"] - 0.18), "center"),
_text(f"H = {height:g} m", (-0.3 - geo["trench_depth"], height / 2), "right"),
_text(
f"상부 {geo['top_t']:.2f} · 하부 {geo['bottom_t']:.2f} · 평균 {geo['thickness']:.2f} m"
f" (뒷길이 {geo['back_cm']}㎝ + 0.1H / 0.4H)",
(right, height * 0.85),
"left",
),
_text(f"반수면 1:{geo['slope']:g}", (right, height * 0.62), "left"),
_text(
f"바닥파기 폭 {geo['trench_width']:.2f} × 깊이 {geo['trench_depth']:g} m"
f" (사면장 {geo['slant']:g} × 2 + 하장)",
(right, height * 0.39),
"left",
),
]
if geo["spillway"]:
shapes.append(
_text(
f"방수로 파형강관 Ø{geo['pipe_r'] * 2:g} — 정면적에서 뺌",
(right, height * 0.16),
"left",
)
)
return shapes
def soil_guard_figure(sheet: dict[str, Any]) -> list[dict[str, Any]]:
"""떼흙막이 — 정본 평균 붙박이 치수의 정면(떼 두께 · 바닥파기 띠)."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import SOIL_GUARD_SOD
d = SOIL_GUARD_SOD["dims"]
top, bottom, height = d["top_m"], d["bottom_m"], d["height_m"]
shapes = _front_with_trench(top, bottom, height, d["trench_depth_m"])
# 머리떼 — 윗변 위 떼 한 켜(두께 = 떼 폭 0.2 로 보임).
shapes.append(
_path(
[(0.0, height), (0.0, height + d["sod_m"]), (top, height + d["sod_m"]), (top, height)],
"guide",
)
)
right = top + 0.4
shapes += [
_title(sheet, height + d["sod_m"] + 0.45),
_text(f"상단 {top:g} m", (top / 2, height + d["sod_m"] + 0.12), "center"),
_text(f"하단 {bottom:g} m", (top / 2, -d["trench_depth_m"] - 0.15), "center"),
_text(f"H = {height:g} m", (-0.3, height / 2), "right"),
_text(
f"떼 20×20㎝ · 폭 {d['sod_m']:g} · 기슭(사면장) {d['slant_m']:g} m",
(right, height * 0.9),
"left",
),
_text(
f"바닥파기 폭 {d['trench_width_m']:g} × 깊이 {d['trench_depth_m']:g} m"
" (기슭 × 2 + 하단)",
(right, height * 0.45),
"left",
),
_text("치수 — 정본 「떼흙막이」 평균 붙박이(개소 제원을 안 봄)", (right, 0.0), "left"),
]
return shapes
def bed_sill_figure(
sheet: dict[str, Any], rubble_thickness_m: float | None
) -> list[dict[str, Any]]:
"""바닥막이(돌붙임) — ㎡당 한 칸의 단면: 돌 켜 · (찰) 버림 · 기초잡석 · 터파기(돌 두께만)."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import RUBBLE_BASE_THICKNESS_M
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import (
BED_SILL_FORMS,
BED_SILL_TRENCH_NOTE,
)
form = str((sheet.get("options") or {}).get("form") or "").strip()
table = BED_SILL_FORMS[form]
back = table["back_len_m"]
blinding = table["blinding_m3_per_m2"] or 0.0 # ㎥/㎡ = 두께(m)
rubble = RUBBLE_BASE_THICKNESS_M if rubble_thickness_m is None else float(rubble_thickness_m)
rubble = rubble if blinding > 0 else 0.0 # 표도 버림이 선 곳에만 기초잡석을 세움
width = 1.0
shapes = [_path([(0.0, 0.0), (0.0, back), (width, back), (width, 0.0), (0.0, 0.0)], "wall")]
if blinding > 0:
shapes.append(
_path([(0.0, 0.0), (0.0, -blinding), (width, -blinding), (width, 0.0)], "guide")
)
if rubble > 0:
shapes.append(
_path(
[
(0.0, -blinding),
(0.0, -blinding - rubble),
(width, -blinding - rubble),
(width, -blinding),
],
"guide",
dash=True,
)
)
# 터파기 — 표 식 그대로 면적 × 돌 두께(윗면에서 돌 켜 밑까지).
shapes.append(
_path(
[(-0.15, back), (-0.15, 0.0), (width + 0.15, 0.0), (width + 0.15, back)],
"guide",
dash=True,
)
)
right = width + 0.4
shapes += [
_title(sheet, back + 0.4),
_text(
f"{form} — 두께(뒷길이) {back:g} m · {table['stone_spec']}",
(right, back * 0.75),
"left",
),
_text("폭 1 m 당(표는 ㎡당)", (width / 2, back + 0.12), "center"),
_text(f"터파기 깊이 {back:g} m (면적 × 두께)", (right, back * 0.3), "left"),
]
if blinding > 0:
shapes.append(_text(f"버림 T={blinding:g} m", (right, -blinding / 2), "left"))
if rubble > 0:
shapes.append(
_text(f"기초잡석 T={rubble:g} m — 산출 조건", (right, -blinding - rubble / 2), "left")
)
shapes.append(_text(BED_SILL_TRENCH_NOTE, (0.0, -blinding - rubble - 0.25), "left"))
return shapes
@@ -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,22 +68,44 @@ 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` 표시는 믿음 줄과 어긋나면 배지가 거짓이 ."""
return (
"form"
if any(str(row.get("formula") or "").strip() for row in item.get("rows") or [])
else "fixed"
)
def list_items(dirs: dict[str, Path], type_id: str) -> list[dict[str, Any]]:
"""가져오기 고르개 — 단마다 그 종류의 항목(단·코드·이름)."""
"""가져오기 고르개 — 단마다 그 종류의 항목(단·코드·이름·종류)."""
return [
{"tier": tier, "code": item.get("code"), "name": item.get("name"), "type_id": type_id}
{
"tier": tier,
"code": item.get("code"),
"name": item.get("name"),
"type_id": type_id,
"kind": item_kind(item),
}
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:
@@ -82,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:
@@ -111,17 +157,44 @@ def _write(folder: Path, item: dict[str, Any]) -> None:
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
def save_personal(folder: Path, template: dict[str, Any], overrides: dict[str, Any] | None) -> str:
"""[내 라이브러리에 저장] — 양식 + 프로젝트에서 고친 식을 **개인 단에 한 벌**로 씀. 코드.
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)}"
def save_item_personal(folder: Path, item: dict[str, Any]) -> str:
"""이미 만든 항목(STmate 에서 뽑은 고정형 등)을 개인 단에 씀. 코드."""
code = _personal_code(folder, item.get("type_id"))
_write(folder, {**item, "code": code, "library_tier": "personal"})
return code
def save_personal(
folder: Path,
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
type_id = template.get("type_id")
same = [item for item in _items(folder) if item.get("type_id") == type_id]
code = str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
existing = program_items() if tier == "program" else None
code = _personal_code(folder, template.get("type_id"), existing)
# 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
dropped = {"default_formula", "default_rounding"}
rows = [
@@ -129,7 +202,15 @@ def save_personal(folder: Path, template: dict[str, Any], overrides: dict[str, A
for row in overridden_rows(template, overrides)
]
item = {k: v for k, v in template.items() if k != "imported_from"}
_write(folder, {**item, "code": code, "library_tier": "personal", "rows": rows})
if name and name.strip():
item["name"] = name.strip()
if tier == "program" and isinstance(item.get("origin"), dict):
# 모든 회사로 가는 발행본 — 원문 공사명·파일명은 안 실음(원본·회사 단은 그대로 · 브레인 ②③).
kept = {k: v for k, v in item["origin"].items() if k not in ORIGIN_MASKED_KEYS}
item["origin"] = {**kept, "masked": ORIGIN_MASKED_NOTE}
if unit_price_rows is not None:
item["unit_price"] = {**(item.get("unit_price") or {}), "rows": unit_price_rows}
_write(folder, {**item, "code": code, "library_tier": tier, "rows": rows})
return code
@@ -151,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"])
@@ -0,0 +1,175 @@
"""구조물도 일위대가 → B09 내역 **받는 문** (PLAN 6장 첫 일감 ② · 2026-09-13 브레인 판정).
종전엔 구조물도가 세운 일위대가(`B-AX-ST-*`) 인계로 ** 번도 나갔음** 돌쌓기는
`FP-13-04-05` 단가만 가고 모르터·기초잡석은 따로거나 빠졌음. 사슬의 진짜 빈칸.
벌로 두는 인계(B08) 내역(B09) **같은 목록·같은 **
양식 일위대가로 `priced_sheets` (장마다 코드 · 제원 · 구성원 · 품은 )
인계 `template_row` (코드 AX-ST · 갈래 = 제원 · 수량 = 연장 m)
내역 금액 `price_sheets` (B09 단가표로 B08 일위대가 엔진을 돌림 코드 )
호표 코드 `B-AX-ST-<8hex>#<키>` — 키는 **사람이 읽는 제목이 아니라** 양식 제원 칸 값을 칸 이름
차례로 이은 (연장 L ) + `normalize_variant_key`. 제목 글자가 바뀌어도 코드는 바뀜.
양식 일위대가가 품은 원단위 (돌쌓기·모르터·기초잡석) **따로 인계하지 않음** `covered`.
금액 정본은 B09 여기 `price_sheets` B09 넘긴 단가표로만 . 수동 단가도 입력으로 .
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from typing import Any
#: 인계 줄 갈래 축 이름 — 「구조물도 장 제원」.
VARIANT_AXIS = "structure_sheet"
def _key_value(value: Any) -> str:
if value is None:
return ""
if isinstance(value, (int, float)) and not isinstance(value, bool):
return format(float(value), ".6g")
return str(value).strip()
def sheet_variant_key(template: dict[str, Any], sheet: dict[str, Any]) -> str:
"""장의 **제원 키** — 양식 칸 이름 차례로 `이름=값` 을 `|` 로 이음(연장 칸 뺌: m당)."""
values = (sheet.get("formula_sheet") or {}).get("vars") or {}
specs = template.get("vars") or {}
return "|".join(
f"{name}={_key_value(values.get(name))}"
for name in sorted(specs)
if specs[name].get("source") != "length_m"
)
def structure_price_code(code: str, key: str) -> str:
"""호표 코드 `B-AX-ST-<8hex>#<정규화 키>` — 인계·내역이 이것 하나로 만남."""
from B09_Estimation.B09_Estimation_UnitPrice import normalize_variant_key
return f"B-{code}#{normalize_variant_key(key)}"
def priced_sheets(
sheets: Iterable[dict[str, Any]],
templates: dict[str, dict[str, Any]],
row_edits: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""양식 일위대가로 셀 장 — 양식(고친 줄 조합 얹음)에 일위대가 줄과 코드가 있는 장만."""
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import with_rows
found = []
for sheet in sheets:
type_id = str(sheet.get("type_id") or "")
base = templates.get(type_id)
if not base or not base.get("code") or not sheet.get("formula_sheet"):
continue
template = with_rows(base, (row_edits or {}).get(type_id))
rows = template["unit_price"]["rows"]
if not rows:
continue
names = {row.get("seq"): str(row.get("name") or "") for row in template.get("rows") or []}
found.append(
{
"type_id": type_id,
"code": str(template["code"]),
"name": str(template.get("name") or type_id),
"title": str(sheet.get("title") or ""),
"key": sheet_variant_key(template, sheet),
"structure_ids": [
str(member["structure_id"])
for member in sheet.get("members") or []
if member.get("structure_id")
],
# 일위대가가 품은 원단위 줄 이름 — 따로 인계하면 두 번 셈.
"covered": sorted(
{names[r["from_row"]] for r in rows if r.get("from_row") in names}
),
"template": template,
"sheet": sheet,
}
)
return found
def by_structure(entries: Iterable[dict[str, Any]]) -> dict[str, dict[str, Any]]:
return {sid: entry for entry in entries for sid in entry["structure_ids"]}
def template_row(row: dict[str, Any], structure: dict[str, Any], entry: dict[str, Any]) -> dict:
"""전개 인계 줄(같은 칸 29개 계약)을 **양식 일위대가 줄**로 바꿔 씀 — 코드·갈래·수량·단위만."""
length = float(structure.get("length_m") or 0.0)
in_bill = length > 0
return {
**row,
"work_item_code": entry["code"],
"name": entry["name"],
"spec": entry["title"],
"spec_detail": entry["title"],
"unit": "m",
"quantity": length,
"composite_parts": None,
"composite_not_ready": None,
"structure_kind": None,
"variant_axis": VARIANT_AXIS,
"variant_value": entry["key"],
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": f"구조물도 양식 일위대가 — 품은 줄: {'·'.join(entry['covered'])}",
# 막힘은 일위대가 표가 정함(B09 가 셈) — 전개 쪽 갈래 사유는 여기 안 실음.
"blocked_kind": None if in_bill else row.get("blocked_kind"),
"blocked_reason": "" if in_bill else row.get("blocked_reason") or "연장이 0 이라 안 세움",
"in_bill": in_bill,
"in_bill_reason": "" if in_bill else "연장이 0 이라 내역에 안 세움",
}
def price_sheets(
entries: Iterable[dict[str, Any]],
book: Any,
find_variant: Callable[[str, str], str | None],
library: Iterable[dict[str, Any]],
manual_all: dict[str, Any] | None = None,
) -> dict[str, dict[str, Any]]:
"""B09 내역이 쓸 호표 금액 `{B-AX-ST-…#키: {money, total, blocked, unconfirmed, reasons}}`.
`money` **호표 표의 성분 소계**(칸마다 0.1 절사해 더한 성분마다 1 절사) ·
`total` 명세 7 호표 성분 소계는 절사. 자른 값을 쓰면 구조물도
화면(192,429) 내역 단가(192,430) 1 갈림(2026-09-13 검증 프로젝트 실측).
"""
from decimal import Decimal
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_money
from B09_Estimation.B09_Estimation_PriceBook import Money3
prices: dict[str, dict[str, Any]] = {}
library = list(library)
for entry in entries:
assembled = unit_price_money(
entry["template"],
entry["sheet"],
book,
find_variant,
library,
(manual_all or {}).get(entry["type_id"]) or {},
)
if assembled is None:
continue
table, _exact = assembled
prices[structure_price_code(entry["code"], entry["key"])] = {
"money": Money3(*(Decimal(str(table[k])) for k in ("material", "labor", "expense"))),
"total": Decimal(str(table["total"])),
"blocked": table["blocked"],
"unconfirmed": table["unconfirmed"],
# 단위당 구성(단가표 코드, 수량) — B09 집계표가 자원까지 풂. 수동 단가 줄은 못 풂.
"parts": [
(str(row["ref_code"]), Decimal(str(row["quantity"])))
for row in table["rows"]
if row.get("ref_code") and "total" in row and not row.get("manual")
],
"reasons": [
f"{row['name']}: {row['reason']}"
for row in table["rows"]
if row["reason"] and not row["skipped"] and "total" not in row
],
}
return prices
@@ -96,23 +96,35 @@ def sheet_key(structure: dict[str, Any]) -> str:
#: 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다(품셈 13-6 「1:0.3 이상」).
#: 한 목록으로 묶어 두었더니 큰돌쌓기를 그림에서 뺄 때 **장 제목의 「1:0.3」까지
#: 사라졌다**(2026-09-09 화면 실측에서 잡음).
SLOPE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "boulder_masonry"})
SLOPE_TYPE_IDS: frozenset[str] = frozenset(
{"masonry_wet", "masonry_dry", "boulder_masonry", "revetment"}
)
#: 기슭막이는 **형태가 돌쌓기일 때만** 돌쌓기 식이 섬(`_UnitQuantity_Revetment.STONE_FORMS`).
REVETMENT_STONE_FORMS: dict[str, bool] = {"돌쌓기(찰)": True, "돌쌓기(메)": False}
def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
""" 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다.
def sheet_is_wet(sheet: dict[str, Any]) -> bool:
"""의 메/찰 — 표준경사·뒷길이 표준이 같은 갈래로 읽음.
/찰은 종류에서 온다. 큰돌쌓기는 `bond` (메쌓기/찰쌓기) 그것이고, 고르면
찰쌓기로 본다 품셈 13-6 1:0.3 **이상**이라 그림 기울기가 갈리지 않는다.
큰돌쌓기는 `bond` (메쌓기/찰쌓기) 그것이고, 고르면 찰쌓기로 본다 품셈 13-6
1:0.3 **이상**이라 그림 기울기가 갈리지 않는다.
"""
options = sheet.get("options") or {}
type_id = str(sheet.get("type_id") or "")
if type_id == "masonry_dry":
wet = False
elif type_id == "boulder_masonry":
wet = options.get("bond") != "메쌓기"
else:
wet = True
return False
if type_id == "boulder_masonry":
return options.get("bond") != "메쌓기"
if type_id == "revetment":
# 표(기슭막이 전개)가 넘긴 그 갈래 — 형태 「돌쌓기(메)」만 메.
return REVETMENT_STONE_FORMS.get(str(options.get("form") or "").strip(), True)
return True
def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
"""장 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다."""
options = sheet.get("options") or {}
wet = sheet_is_wet(sheet)
return face_slope_ratio(
options,
wet=wet,
@@ -127,8 +139,12 @@ def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
def _slope_or_none(structure: dict[str, Any]) -> float | None:
"""돌쌓기 계열이면 판정된 전면 기울기, 아니면 `None`."""
# ⚠ **그림 대상 목록을 쓰지 않는다** — 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다.
if str(structure.get("type_id") or "") not in SLOPE_TYPE_IDS:
type_id = str(structure.get("type_id") or "")
if type_id not in SLOPE_TYPE_IDS:
return None
form = str((structure.get("options") or {}).get("form") or "").strip()
if type_id == "revetment" and form not in REVETMENT_STONE_FORMS:
return None # 돌쌓기형이 아닌 기슭막이는 기울기 판정이 없음(표도 안 섬)
return slope_of(structure)[0]
@@ -196,7 +212,7 @@ def build_standard_sheets(
계산을 다시 하지 않는다 들어온 전개를 제원 조합으로 묶고 단위당으로 나눌 뿐이다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _section_mode_at
from common_util.common_util_structure_face_role import structure_face_role
from common_util.common_util_structure_face_role import structure_face_role_of
groups: dict[str, dict[str, Any]] = {}
for structure in unit_table.get("structures") or []:
@@ -204,7 +220,7 @@ def build_standard_sheets(
# 근거를 못 받아 종전값으로 떨어져 **표와 갈린다**(2026-09-09 실측: 표 1:0.35 ·
# 제목 1:0.3). 자리를 고르는 것도 B08 이 쓰는 그 함수를 쓴다.
mode = _section_mode_at(structure, section_modes)
face, face_reason = structure_face_role(mode, (structure.get("options") or {}).get("side"))
face, face_reason = structure_face_role_of(mode, structure.get("options"))
structure = {**structure, "face": face, "face_reason": face_reason}
key = sheet_key(structure)
sheet = groups.get(key)
@@ -0,0 +1,358 @@
"""B08 **구조물 집계표** — 측점별 구조물 한 줄 · 종류별 표 (PLAN 2장 · 사용자 확정 ④).
= 측점 + 종류 + 실치수(제원 ) + 개소·연장. 실무 -골막이치수 치수조서가
실치수를 들고 구조도 탭이 끌어 쓰는 모양을 ** 탭으로 겸함**.
값이 어디서 오나 칸마다 출처를 붙임
auto 정본에 적힌 `structures.json`(B05) · `pipe_points.json`(계곡 통과 시설) ·
연장은 B06 횡단 `design.pipe_length_m`
user 표에서 사람이 고쳐 **정본에 적은** 산출 조건에 손댄 표만 (브레인 판정)
library 정본 칸이 비어 **양식 기본값**으로 (구조물도 양식 `vars.*.default`)
empty 정본도 양식도 값이 없음 계산 쪽이 막히거나 기준값으로
사용자 값은 덮개층이 아니라 **정본에 ** 도면·수량이 (지침 5 쪼개지 않음).
손댄 칸의 정본 값이 뒤에 B05 에서 바뀌면 칸은 자동으로 돌아가되 **조용히 말고** 줄에 알림.
값을 여기서 짓지 않음 읽어 세우기만. 연장이 없는 관은 빈칸(0 으로 채움).
지점 정본 타입(`managed_by`) `structures.json` 있어도 `pipe_points.json` 주인.
고칠 있는 칸은 **상세 (`phase: detail`)** 자리·길이·높이·관경 같은 놓기 (`b05`)
·종점·횡단 설계와 ** 벌로 움직여**(B05 에서 길이 = + , ·종점이 따라 ) 여기서 칸만
고치면 쪼개짐(지침 5). 칸은 구조물 놓기(B05)에서 고침.
"""
from __future__ import annotations
import math
from collections.abc import Iterable
from typing import Any
SOURCE_AUTO = "auto"
SOURCE_USER = "user"
SOURCE_LIBRARY = "library"
SOURCE_EMPTY = "empty"
#: 산출 조건 자리 — 손댄 칸 `{줄 id: {칸: {"value": 적은 값, "was": 고치기 전 정본 값}}}`.
USER_CELLS_KEY = "structure_summary_user_cells"
#: 계곡 통과 시설 `facility` → 레지스트리 종류. 빈 값은 배관(`PipePoint` 기본).
FACILITY_TYPES = {
"pipe": "pipe",
"box_culvert": "box_culvert",
"ford_pavement": "ford_pavement",
"ford_bridge": "ford_bridge",
"revetment": "revetment",
}
def _blank(value: Any) -> bool:
return value is None or value == ""
def _number(value: Any) -> float | None:
if isinstance(value, bool) or _blank(value):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _defaults(template: dict[str, Any] | None) -> dict[str, Any]:
"""양식 제원 칸 기본값 `{옵션 키: 기본값}` — 정본 칸이 비었을 때 계산이 쓰는 값."""
return {
str(spec["option"]): spec["default"]
for spec in ((template or {}).get("vars") or {}).values()
if spec.get("option") and "default" in spec and not _blank(spec["default"])
}
def _same(left: Any, right: Any) -> bool:
"""정본 값과 적은 값이 같은가 — 숫자는 3.0 과 "3" 을 같게 봄."""
a, b = _number(left), _number(right)
return a == b if a is not None and b is not None else left == right
def _cells(
keys: Iterable[str],
options: dict[str, Any],
defaults: dict[str, Any],
marks: dict[str, Any],
) -> dict:
cells = {}
for key in keys:
current = None if _blank(options.get(key)) else options[key]
mark = marks.get(key)
if mark is not None and not _same(current, mark.get("value")):
# 사람이 적은 값을 B05 가 바꿈 — 자동으로 돌아가되 옛 값을 함께 실어 화면이 알림.
cells[key] = {
"value": current,
"source": SOURCE_EMPTY if current is None else SOURCE_AUTO,
"replaced_user_value": mark.get("value"),
}
elif mark is not None:
cells[key] = {"value": current, "source": SOURCE_USER, "was": mark.get("was")}
elif current is not None:
cells[key] = {"value": options[key], "source": SOURCE_AUTO}
elif key in defaults:
cells[key] = {"value": defaults[key], "source": SOURCE_LIBRARY}
else:
cells[key] = {"value": None, "source": SOURCE_EMPTY}
return cells
def build_summary(
structures: Iterable[dict[str, Any]],
pipe_points: Iterable[dict[str, Any]],
types: dict[str, Any],
templates: dict[str, dict[str, Any]] | None = None,
pipe_lengths: dict[float, float] | None = None,
user_cells: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""종류별 표 목록(레지스트리 차례) — 표마다 칸 정의·줄·개소·연장 합·평균치수.
`types` `structure_type_map()` · `templates` 종류별 양식(프로젝트에 박힌 기본) ·
`pipe_lengths` B06 횡단의 `{측점: 연장}`(`pipe_lengths_from_designs`) ·
`user_cells` 표에서 손댄 (`USER_CELLS_KEY` 모양).
"""
# 관 길이 찾기는 배수관 물량과 **같은 규칙**(허용 0.5m) — 두 표가 같은 관에 다른 연장을 안 적게.
from B08_Quantity.B08_Quantity_Engine_Pipe import _nearest
templates = templates or {}
lengths = pipe_lengths or {}
rows_by_type: dict[str, list[dict[str, Any]]] = {}
notes: list[str] = []
def add(type_id: str, row: dict[str, Any]) -> None:
definition = types.get(type_id)
if definition is None:
notes.append(f"{type_id}: 레지스트리에 없는 종류라 안 셈")
return
options = row.pop("options")
keys = [field.key for field in definition.options]
marks = (user_cells or {}).get(str(row["id"])) or {}
row["cells"] = _cells(keys, options, _defaults(templates.get(type_id)), marks)
replaced = [key for key, cell in row["cells"].items() if "replaced_user_value" in cell]
if replaced:
labels = {field.key: field.label for field in definition.options}
row["replaced"] = [labels[key] for key in replaced]
if definition.design_owner:
row["note"] = f"{definition.design_owner}가 수량을 셈 — 여기선 자리만"
elif definition.reference_only:
row["note"] = "전문 상세설계 대상 — 배치까지만"
rows_by_type.setdefault(type_id, []).append(row)
for item in structures:
type_id = str(item.get("type_id") or "")
definition = types.get(type_id)
if definition is not None and definition.managed_by:
continue # 관 지점 정본이 주인 — 옛 저장분이 남아 있어도 두 번 안 셈
options = dict(item.get("options") or {})
start, end = item.get("start_m"), item.get("end_m")
span = abs(float(end) - float(start)) if start is not None and end is not None else None
stated = _number(options.get("length_m"))
add(
type_id,
{
"id": item.get("structure_id"),
"origin": "structures",
"chainage_m": item.get("chainage_m"),
"start_m": start,
"end_m": end,
"count": 1,
# 원단위 전개와 같은 규칙 — 제원 연장이 있으면 그것, 없으면 시·종점 거리.
"length_m": stated if stated else span,
"length_basis": "제원 연장" if stated else ("시·종점" if span else ""),
"memo": item.get("memo") or "",
"options": options,
},
)
for point in pipe_points:
facility = str(point.get("facility") or "pipe")
type_id = FACILITY_TYPES.get(facility, facility)
chainage = float(point.get("chainage_m") or 0.0)
length = _nearest(lengths, chainage) if type_id == "pipe" else None
add(
type_id,
{
"id": pipe_row_id(point),
"origin": "pipe_points",
"chainage_m": chainage,
"start_m": point.get("start_m"),
"end_m": point.get("end_m"),
"count": 1,
"length_m": length,
"length_basis": "B06 횡단 관 연장" if length else "",
"memo": "",
"options": dict(point.get("options") or {}),
},
)
tables = []
for type_id, definition in types.items():
rows = rows_by_type.get(type_id)
if not rows:
continue
rows.sort(key=lambda row: float(row.get("chainage_m") or row.get("start_m") or 0.0))
columns = [
{
"key": f.key,
"label": f.label,
"unit": f.unit or "",
"input": f.input,
"choices": list(f.choices),
"editable": f.phase == "detail" and f.enabled,
}
for f in definition.options
]
averages = {}
for column in columns:
values = [_number(row["cells"][column["key"]]["value"]) for row in rows]
present = [v for v in values if v is not None]
if present and column["input"] == "number":
averages[column["key"]] = sum(present) / len(present)
replaced = sum(1 for row in rows if row.get("replaced"))
if replaced:
notes.append(
f"{definition.name} {replaced}줄 — 이 표에서 고친 값을 B05 가 바꿔 "
"자동값으로 돌아감"
)
measured = [row["length_m"] for row in rows if row["length_m"]]
# 연장이 뜻 있는 표만 빈 연장을 셈 — 반사경·표지판 같은 점 시설은 연장이 없는 것이 정상.
needs_length = definition.placement == "interval" or type_id == "pipe"
tables.append(
{
"type_id": type_id,
"name": definition.name,
"group": definition.group,
"placement": definition.placement,
"columns": columns,
"rows": rows,
"count": len(rows),
"length_total_m": sum(measured) if measured else None,
"length_missing": len(rows) - len(measured) if needs_length else 0,
"averages": averages,
}
)
return {"tables": tables, "notes": notes}
def pipe_row_id(point: dict[str, Any]) -> str:
"""계곡 통과 시설 줄 id — 관 지점엔 식별자가 없어 기준점(㎜ 자리)으로 가름."""
return f"pipe@{float(point.get('chainage_m') or 0.0):.3f}"
def _type_of(target: dict[str, Any], is_pipe: bool) -> str:
if is_pipe:
facility = str(target.get("facility") or "pipe")
return FACILITY_TYPES.get(facility, facility)
return str(target.get("type_id") or "")
def coerce(field: Any, value: Any, name: str) -> Any:
"""칸 값 꼴 맞추기 — B05 저장 관문과 같은 규칙(수 칸은 0 이상 수 · 고르기 칸은 보기 안).
(None·"") `None` = 칸을 지움. 틀리면 `ValueError`.
"""
if _blank(value):
return None
if field.input == "number":
number = _number(value)
if number is None or not math.isfinite(number) or number < 0:
raise ValueError(f"{name} {field.label}: 0 이상의 수여야 함 — {value!r}")
return int(number) if number.is_integer() else number
# 수로 온 보기 값(뒷길이 35 → 35.0)은 보기 글과 같은 꼴로 — 「35.0」은 보기 「35」와 안 맞음.
whole = isinstance(value, float) and value.is_integer()
text = str(int(value)) if whole else str(value).strip()
if field.input == "select" and field.choices and text not in field.choices:
raise ValueError(f"{name} {field.label}: 보기에 없는 값 — {text}")
return text
def apply_edits(
structures: list[dict[str, Any]],
points: list[dict[str, Any]],
types: dict[str, Any],
edits: Iterable[dict[str, Any]],
marks: dict[str, dict[str, Any]],
) -> tuple[list[str], dict[str, dict[str, Any]]]:
"""손 고침을 **정본 사본에 바로** 얹음(제자리 바꿈) — (바뀐 줄 id, 새 손댄 칸 표).
고치기 (`was`) 처음 손댄 때의 정본 값을 지킴 값으로 되돌리면 표에서 빠져 자동.
놓기 ·없는 ·틀린 값은 `LookupError`/`ValueError` 부르는 쪽이 아무것도 .
"""
from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import EDITABLE_KEYS as SHEET_KEYS
from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import clean_spec
rows = {str(item.get("structure_id")): (item, False) for item in structures}
rows |= {pipe_row_id(point): (point, True) for point in points}
marks = {row_id: dict(cells) for row_id, cells in marks.items()}
changed: list[str] = []
for edit in edits:
row_id, key = str(edit.get("id")), str(edit.get("key"))
if row_id not in rows:
raise LookupError(f"집계표 줄을 못 찾음: {row_id}")
target, is_pipe = rows[row_id]
definition = types.get(_type_of(target, is_pipe))
field = next((f for f in definition.options if f.key == key), None) if definition else None
if field is None:
raise ValueError(f"{row_id}: 그 종류에 없는 칸 — {key}")
if field.phase != "detail":
raise ValueError(
f"{definition.name} {field.label}: 자리·길이·높이 같은 놓기 칸은 "
"시·종점과 한 벌이라 구조물 놓기(B05)에서 고침"
)
value = coerce(field, edit.get("value"), definition.name)
if value is not None and key in SHEET_KEYS:
# 구조물도 [제원 저장]과 **같은 꼴**로 적음(뒷길이는 정수 등) — 두 길의 꼴이 같아야 함.
# ⚠ 검사(`coerce`)를 먼저 — `clean_spec` 은 못 읽은 값을 빼 버려 「지우기」로 읽힘.
value = clean_spec({key: value})[0].get(key, value)
options = dict(target.get("options") or {})
old = None if _blank(options.get(key)) else options[key]
if (value is None and old is None) or (value is not None and _same(old, value)):
continue
if value is None:
options.pop(key, None)
else:
options[key] = value
target["options"] = options
cells = marks.setdefault(row_id, {})
mark = cells.get(key)
# 옛 손 표가 정본과 같을 때만 그 「고치기 전」을 이어 씀 — B05 가 바꾼 뒤면 지금 값이 기준.
was = mark.get("was") if mark and _same(mark.get("value"), old) else old
if _same(was, value):
cells.pop(key, None)
else:
cells[key] = {"value": value, "was": was}
if not cells:
marks.pop(row_id)
if row_id not in changed:
changed.append(row_id)
return changed, marks
def prune_marks(
marks: dict[str, dict[str, Any]],
structures: Iterable[dict[str, Any]],
points: Iterable[dict[str, Any]],
) -> dict[str, dict[str, Any]]:
"""[저장] 때 손댄 칸 표 정리 — 없어진 줄과 **B05 가 이미 바꾼 칸**(알림을 본 뒤)은 뺌."""
current = {str(item.get("structure_id")): item for item in structures}
current |= {pipe_row_id(point): point for point in points}
kept: dict[str, dict[str, Any]] = {}
for row_id, cells in marks.items():
if row_id not in current:
continue
options = current[row_id].get("options") or {}
alive = {
key: mark
for key, mark in cells.items()
if _same(None if _blank(options.get(key)) else options[key], mark.get("value"))
}
if alive:
kept[row_id] = alive
return kept
def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, float]:
"""저장된 횡단 설계 → `{측점: 관 연장}` — 배수관 물량과 같은 읽기."""
from B08_Quantity.B08_Quantity_Engine_Pipe import _length_by_chainage
return _length_by_chainage(list(designs or []), "pipe_length_m")
@@ -12,11 +12,15 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import standard_back_length
ROOT = Path(__file__).resolve().parents[1]
#: 양식 칸 `default_from` — 빈 뒷길이를 뒷길이 표준표 하한으로(전개와 한 벌).
STANDARD_BACK_LENGTH = "standard_back_length"
TEMPLATE_DIR = ROOT / "resources" / "library_structure"
#: 사용자가 고친 식이 사는 칸 — B08 산출 조건 구획(`project_settings.json` 의 `quantity`).
@@ -24,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:
@@ -75,6 +81,12 @@ def template_vars(
raw = options.get(spec["option"])
else:
raw = (settings or {}).get(spec.get("setting"))
if raw in (None, "") and spec.get("default_from") == STANDARD_BACK_LENGTH:
# 안 고른 뒷길이 — 전개와 같은 표준표 하한(`standard_back_length`, 브레인 판정 ㉮).
wet = template.get("type_id") != "masonry_dry"
height = float(structure.get("height_m") or 0.0)
values[name] = float(standard_back_length(wet=wet, height_m=height)[0])
continue
values[name] = default if raw in (None, "") else _typed(raw, default)
return values
@@ -143,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
@@ -217,7 +288,7 @@ def replace_with_templates(
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _section_mode_at
from common_util.common_util_structure_face_role import structure_face_role
from common_util.common_util_structure_face_role import structure_face_role_of
settings = {"rubble_base_thickness_m": rubble_base_thickness_m}
targets: list[tuple[Any, dict[str, Any]]] = []
@@ -229,9 +300,7 @@ def replace_with_templates(
if (quantity.billing_unit or "m") not in _PER_LENGTH_UNITS:
continue
options = item.get("options") or {}
face, face_reason = structure_face_role(
_section_mode_at(item, section_modes), options.get("side")
)
face, face_reason = structure_face_role_of(_section_mode_at(item, section_modes), options)
sheet = {
"type_id": quantity.type_id,
"height_m": quantity.height_m,
@@ -287,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)
@@ -335,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
# 실무 관측값 같은 「대안 후보」 — 값을 바꾸지 않고 칸 옆에 보이기만(판정 Ⓑ).
@@ -2,16 +2,21 @@
값을 여기서 새로 짓지 않음 단가는 B09 `PriceBook.resolve`(읽기만), 수량은 원단위 .
줄은 0 아니라 **막힘 + 까닭** · 막힌 줄이 하나라도 있으면 합계는 미완.
반올림은 B09 자리 규칙 그대로 금액란 0.1 미만 버림 · 1 미만 버림(품셈 1-2-2).
하위 일위대가를 표에 넣을 ** 자른 ** B09 `resolve` 조립 중엔 자름.
반올림은 B09 자리 규칙 그대로 금액란 0.1 미만 버림 · 성분 1 미만 버림(명세 7).
하위 일위대가를 표에 넣을 **자른 성분 소계** B09 `resolve` B 호표와 같음.
**재귀** 줄이 `B-AX-ST-*`(다른 구조물 양식) 가리키면 양식을 제원(`sub_vars`)으로 풀어
일위대가를 먼저 세우고 단위당 금액을 . 레시피 150/350 2 이상(명세 16).
깊이 **5**까지(PLAN 10 판정) · 돌면 막힘. B-FP·X·L 재귀는 단가표가 이미 .
하위 양식은 **프로젝트에 박힌 것과 프로그램 기본**에서만 찾음 개인·회사 단은 읽음(4 ).
**저장 자리는 **(브레인 판정 2026-09-13) 더하기·빼기·고르개로 고친
** 조합은 양식+프로젝트**(`ROWS_KEY`, 종류별 · [ 라이브러리에 저장] 양식에 실림) ·
**수동 단가는 프로젝트만**(`MANUAL_KEY` · 양식에 실으면 단가가 남의 프로젝트로 ).
수동 단가 줄은 미확정으로 .
"""
from __future__ import annotations
import re
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from decimal import Decimal
@@ -21,6 +26,15 @@ from typing import Any
SUB_STRUCTURE_PREFIX = "B-AX-ST-"
#: 재귀 깊이 한도 — 맨 윗 표가 1단(PLAN 10장 「재귀 깊이 = 5단」).
MAX_DEPTH = 5
#: 산출 조건 자리 — 고친 줄 조합 `{type_id: [rows]}` · 수동 단가 `{type_id: {seq: 값}}`.
ROWS_KEY = "structure_unit_price_rows"
MANUAL_KEY = "structure_manual_prices"
#: 고르개 갈래 — 품셈(일위대가·단가산출) · 자원(자재·노임·시간당 중기).
SEARCH_KINDS = {
"work": ("unit_price", "price_basis"),
"resource": ("material", "labor", "machine_hourly"),
}
_MONEY_KEYS = ("material", "labor", "expense")
_UNIT_ALIASES = {"m2": "", "m3": "", "M2": "", "M3": "", "M": "m"}
@@ -34,6 +48,8 @@ class _Context:
book: Any
find_variant: Callable[[str, str], str | None]
library: dict[str, dict[str, Any]]
#: 맨 윗 표의 수동 단가 `{seq: 값}` — 하위 양식에는 안 씀(그 종류의 줄이 아님).
manual: dict[str, dict[str, Any]]
@dataclass
@@ -151,8 +167,8 @@ def _assemble(
values = (sheet.get("formula_sheet") or {}).get("vars") or {}
rows: list[dict[str, Any]] = []
sums = {"material": Decimal(0), "labor": Decimal(0), "expense": Decimal(0)}
exact = Money3()
blocked = 0
unconfirmed = 0
for row in spec.get("rows") or []:
out: dict[str, Any] = {
"seq": row.get("seq"),
@@ -165,6 +181,15 @@ def _assemble(
"reason": "",
}
source = sheet_rows.get(row.get("from_row")) if "from_row" in row else None
# 이중계상 경계 ①(명세 6장) — 토공집계·사토 공제·참고로 가는 줄을 일위대가에 또 넣지 않음.
from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import NOT_BILLED_BY_WORK_ITEM
owner = NOT_BILLED_BY_WORK_ITEM.get(str((source or {}).get("destination") or ""))
if owner:
out["reason"] = f"이중계상 — 원단위 줄 {row['from_row']}{owner}로 가는 줄"
blocked += 1
rows.append(out)
continue
if source is not None and (source.get("skipped") or source.get("error")):
# 원단위 줄이 안 선 장이면 일위대가 줄도 안 섬 — 막힘이 아님(버림 「안 넣음」 등).
out.update(skipped=bool(source.get("skipped")), reason=source.get("reason") or "")
@@ -193,7 +218,18 @@ def _assemble(
ref, why = _ref_of(row, values, ctx.find_variant)
out["ref_code"] = ref or ""
priced = None
if ref:
manual = ctx.manual.get(str(row.get("seq"))) if depth == 1 else None
if manual:
# 수동 단가가 단가표보다 이김 — 사람이 일부러 넣은 값. 대신 「미확정」으로 셈.
money = Money3(*(Decimal(str(manual.get(key) or 0)) for key in _MONEY_KEYS))
priced = _Priced(money, out["name"], out["spec"], out["unit"])
out.update(
manual=True,
manual_source=manual.get("source") or "",
manual_entered_at=manual.get("entered_at") or "",
)
unconfirmed += 1
elif ref:
try:
priced = _price(ref, row, ctx, depth, seen)
if out["unit"] and priced.unit and priced.unit != out["unit"]:
@@ -208,7 +244,6 @@ def _assemble(
continue
money = priced.money
exact = exact + money.scaled(quantity)
cells = {
"material": round_at(money.material * quantity, OutputPlace.UNIT_PRICE_ROW),
"labor": round_at(money.labor * quantity, OutputPlace.UNIT_PRICE_ROW),
@@ -233,13 +268,21 @@ def _assemble(
"name": template.get("name") or "",
"unit": sheet.get("billing_unit") or "",
"rows": rows,
**{key: float(value) for key, value in sums.items()},
# 계금 — 1원 미만 버림. ⚠ 막힌 줄이 있으면 이 값은 「미완」이라 화면이 그렇게 적음.
"total": float(round_at(sum(sums.values()), OutputPlace.UNIT_PRICE_TOTAL)),
# 성분 소계 — 성분마다 1원 미만 버림(명세 7장 · B09 일위대가 호표와 같은 규칙).
**{
key: float(round_at(value, OutputPlace.UNIT_PRICE_TOTAL)) for key, value in sums.items()
},
# 계금 = 자른 성분 소계의 합. ⚠ 막힌 줄이 있으면 이 값은 「미완」이라 화면이 그렇게 적음.
"total": float(
sum(round_at(value, OutputPlace.UNIT_PRICE_TOTAL) for value in sums.values())
),
"blocked": blocked,
"complete": blocked == 0,
#: 수동 단가로 선 줄 수 — 화면 「미확정 N건」 배지.
"unconfirmed": unconfirmed,
}
return table, exact
# 윗 표·내역이 쓸 단위당 3분할 = **자른 성분 소계**(하위 호표 합계를 그대로 부름 — 명세 7장).
return table, Money3(*(Decimal(str(table[key])) for key in _MONEY_KEYS))
def unit_price_table(
@@ -248,14 +291,136 @@ def unit_price_table(
book: Any,
find_variant: Callable[[str, str], str | None],
library: Iterable[dict[str, Any]] = (),
manual: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any] | None:
"""장 하나의 일위대가 표. 양식에 `unit_price` 가 없으면 `None`.
수량 `from_row`(원단위 차례 줄의 단위당 ) 또는 박힌 `quantity`.
코드 `ref_code`(단가표 코드 그대로) 또는 `work_item_code` + `variant_from`(제원 갈래).
`library` 하위 구조물 일위대가(`B-AX-ST-*`) 찾을 양식들(프로젝트에 박힌 + 프로그램 기본).
`manual` 프로젝트의 수동 단가 `{seq: {material, labor, expense, source, entered_at}}`.
"""
ctx = _Context(book, find_variant, {str(t["code"]): t for t in library if t.get("code")})
code = str(template.get("code") or "")
assembled = _assemble(template, sheet, ctx, 1, (code,) if code else ())
assembled = unit_price_money(template, sheet, book, find_variant, library, manual)
return assembled[0] if assembled else None
def unit_price_money(
template: dict[str, Any],
sheet: dict[str, Any],
book: Any,
find_variant: Callable[[str, str], str | None],
library: Iterable[dict[str, Any]] = (),
manual: dict[str, dict[str, Any]] | None = None,
) -> tuple[dict[str, Any], Any] | None:
"""`unit_price_table` 과 같되 **단위당 3분할**(자른 성분 소계)도 — B09 내역 줄이 이 값에 수량을 곱함."""
ctx = _Context(
book, find_variant, {str(t["code"]): t for t in library if t.get("code")}, manual or {}
)
code = str(template.get("code") or "")
return _assemble(template, sheet, ctx, 1, (code,) if code else ())
def with_rows(template: dict[str, Any], rows: list[dict[str, Any]] | None) -> dict[str, Any]:
"""고친 줄 조합을 얹은 양식 — 고친 적 없으면 양식 줄. 줄 없는 양식도 빈 표로 세움."""
spec = template.get("unit_price") or {}
picked = (spec.get("rows") or []) if rows is None else rows
return {**template, "unit_price": {**spec, "rows": picked}}
def save_rows(
current: dict[str, Any], type_id: str, template: dict[str, Any], rows: list[dict[str, Any]]
) -> tuple[dict[str, Any], bool]:
"""줄 조합 저장본 — 양식과 같으면 그 종류를 지움(양식대로). (새 저장본, 바뀜)."""
merged = {key: value for key, value in current.items() if key != type_id}
if rows != ((template.get("unit_price") or {}).get("rows") or []):
merged[type_id] = rows
return merged, merged != current
def save_manual(
current: dict[str, Any],
type_id: str,
rows: list[dict[str, Any]],
prices: dict[str, dict[str, Any]],
today: str,
) -> dict[str, Any]:
"""수동 단가 저장본 — **남은 줄의 것만** 둠(뺀 줄의 값이 같은 차례 새 줄에 붙지 않게).
넣은 날짜는 ·출처가 그대로면 날짜를 다시 저장했다고 날짜가 새로 서면 .
"""
seqs = {str(row.get("seq")) for row in rows}
before = current.get(type_id) or {}
kept = {}
for seq, price in prices.items():
if str(seq) not in seqs:
continue
old = before.get(str(seq)) or {}
same = all(old.get(key) == price.get(key) for key in (*_MONEY_KEYS, "source"))
kept[str(seq)] = {**price, "entered_at": old.get("entered_at") if same else today}
merged = {key: value for key, value in current.items() if key != type_id}
if kept:
merged[type_id] = kept
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()
# 조각 비교는 글자·숫자만 — 괄호 「(장비)」 같은 기호가 겹친 글자 수를 부풀리지 않게.
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):
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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,165 @@
"""구조물 원단위 — **성분 계약과 갈 곳**(이중계상 경계) · 2026-09-14 `UnitQuantity` 에서 가름.
파일의 `DESTINATION`·`MIX_COMPONENTS` 이중계상 경계다 규칙 셋은 `UnitQuantity` 머리글.
배합을 분해하지 않음(`MIX_COMPONENTS`) · 할증 붙임 ·
터파기·되메우기·잔토는 토공으로(`DESTINATION`).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import BASIS_DERIVED
#: 기초잡석 줄 이름 — 갈 곳 표(`DESTINATION`)가 이 이름을 키로 씀. 두께 상수는 `_StoneSpec`.
RUBBLE_BASE_NAME = "기초잡석"
DESTINATION = {
"터파기": "earthwork",
"되메우기": "earthwork",
"잔토처리": "earthwork",
"돌쌓기": "unit_price",
"돌붙임": "unit_price",
"깬돌": "material",
"고임돌": "material",
"막자갈": "material",
# ⭐ 2026-09-09 사용자 확정 3차 ⑥ — **콘크리트는 자재 축에 세운다**(사용자 명시).
# ⚠ 타설 줄(품셈 12-1)은 **품만** 주고 재료를 안 준다(서브 일위대가도 재료 0원).
# 그래서 자재로 안 보내면 **재료비가 통째로 빠진다** — B09 매김에서 드러난 자리다.
# ⚠ 배합을 분해하지 않는 규칙(㉢)은 그대로다 — 「콘크리트 ㎥」에서 멈춘다.
"콘크리트": "material",
"채움콘크리트": "material",
"버림콘크리트": "material",
"모르터": "unit_price",
"거푸집": "unit_price",
"물구멍관": "material",
# ⚠ 자재도 토공도 아닌 자리 — **유토곡선이 사토에서 뺄 밑수**다. 자재총괄은
# `material` 만 모으므로 여기 섞이지 않는다.
"채집석": "haul_deduction", # ⚠ 자재 카탈로그가 이름으로 찾는다 — 공백 없는 한 낱말(B09 규약)
# ⚠ **보여 주기만 하는 줄** — 자재도 토공도 일위대가도 아니다. 정본 계산표 좌측 열에
# 「입적」으로 실려 있어 그 이름 그대로 낸다(2026-09-08 확정 ⑦). 값은 이미 식 안에
# 있던 것을 줄로 꺼낸 것뿐이고, `material`·`earthwork` 어디에도 안 섞인다.
# ⚠ 「석적」은 정본에 없고 소광리 시트에만 있으나 **확정 5차로 세운다** — 같은
# 보여 주기 줄이고, 「소광리에만 있는 줄」임을 사유에 적는다.
"입적": "reference",
"석적": "reference",
# 돌 줄은 **이름 「돌」 + 규격(`spec`)에 종류**다(명세 13장 Ⓒ) — 정본 계산표 줄 이름 그대로
# (「돌 ℓ3=45cm」). 자재총괄이 (이름·규격·단위)로 묶으므로 종류 이름을 따로 두지 않는다.
"": "material",
# 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다.
# 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다.
RUBBLE_BASE_NAME: "unit_price",
# ⭐ 2026-09-14 — 갈 곳 기본값을 걷자 드러난 넷(L형수로·개거·떼흙막이 정본 줄).
# 관측 원단위(`structure_unit_observed`)가 같은 이름에 둔 갈 곳과 맞춤(두 벌이 안 갈리게).
# 철근은 이름 「이형철근」 + 규격 「D13·D16」(같은 날 가름 — 대응표 조각은 이름+규격 키로 집음).
"이형철근": "material",
"유로폼": "unit_price", # 거푸집 계열(품셈 12-38) — 설치·해체 품
"면목": "material",
"": "material", # 사면 떼(자재총괄 extra)와 같은 자리 · 할증 10%(1-3-1)
}
#: ⛔ **갈 곳 기본값 금지**(명세 13장 「빠진 것이 조용해짐」) — 표에 없는 이름은 막고 사유를 냄.
#: 종전엔 `DESTINATION.get(name, "quantity")` 로 떨어져 **아무 축에도 안 가고** 자재총괄
#: 건너뛴 수로만 셈(2026-09-14 L형수로 이형철근·떼흙막이 떼가 통째로 빠져 있던 자리).
DESTINATION_MISSING = (
"{name}」 갈 곳이 표(`DESTINATION`)에 없어 물량을 어느 축에도 안 보냄"
" — 표에 올릴 것(명세 13장 갈 곳 기본값 금지)"
)
def routed(name: str, notes: list[str]) -> str | None:
"""성분 이름의 갈 곳 — 표에 없으면 `None` 이고 사유 한 줄을 `notes` 에 더함(이름마다 한 번)."""
destination = DESTINATION.get(name)
if destination is None:
text = DESTINATION_MISSING.format(name=name)
if text not in notes:
notes.append(text)
return destination
# ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다.
# ⚠ **정확히 같은 이름**으로만 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 `자갈` 로
# 오탐된다 — 개발 중 실제로 걸렸던 자리다.
MIX_COMPONENTS = frozenset(
{"시멘트", "모래", "자갈", "친모래", "친자갈", "잔골재", "굵은골재", "부순돌"}
)
@dataclass(slots=True)
class Component:
"""전개 결과 한 성분. `basis` 는 어떤 식으로 나왔는지 사람이 읽는 근거다."""
name: str
unit: str
amount: float
destination: str
basis: str = ""
# ⚠ 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표).
# 두 근거가 한 표에 섞이므로 줄마다 단다. 안 적으면 나중에 못 되짚는다.
basis_kind: str = BASIS_DERIVED
source: str = ""
#: 규격 — 같은 이름이라도 단가가 갈리는 값(채움 콘크리트 강도 등). 받는 쪽이 읽는다.
spec: str = ""
@dataclass(slots=True)
class StructureQuantity:
"""구조물 하나의 원단위 전개 결과."""
structure_id: str | None
type_id: str
name: str
length_m: float = 0.0
height_m: float = 0.0
# 측점 — 내역 줄에 「어디부터 어디까지」를 적으려면 여기서 따라가야 한다(B09 인계).
start_m: float | None = None
end_m: float | None = None
# 저장된 제원 — **형식(반중력식…)처럼 뒤 단계가 봐야 하는 값**이 여기 있다.
# 치수를 다시 쓰라는 뜻이 아니라 **읽으라고** 실어 나른다(치수 정본은 여전히 하나).
options: dict[str, Any] = field(default_factory=dict)
components: list[Component] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
#: 내역 줄이 설 **단위와 그 단위로 센 수량**. 관측 원단위가 「개소당」·「㎡당」인
#: 종류는 연장(m)으로 세면 축이 어긋난다(2026-09-08 ㉕ 실증).
#: 비어 있으면 종전대로 「m · 연장」으로 선다.
billing_unit: str = ""
billing_quantity: float = 0.0
#: 구조물이 놓인 자리의 지반 갈래(`soil`·`ripping_rock`·`blasting_rock`)와 그 판정 근거.
#: 품셈 9-13 구조물터파기의 **토질 축**이 이 값으로 갈린다. 못 가르면 `None` 이다.
ground_type: str | None = None
ground_type_basis: str = ""
#: 파는 깊이(m) — 단면으로 터파기를 세운 종류만(옹벽 기초분). 비면 인계가 직고 + 기초 깊이로 봄.
trench_depth_m: float | None = None
#: 양식으로 성분을 세웠으면 그 양식 이름(PLAN 3장 ④-2). 비면 지금 전개 값.
library_item: str = ""
#: 치수가 정본에 없어 **등록부 기본값으로 선** 까닭(2026-09-14 브레인 판정) — 차 있으면 줄·표·그림은
#: 서되 **금액·자재·토공·운반 합에는 안 듦**(`is_unconfirmed`). 설계자가 치수를 적으면 비고 금액이 섬.
unconfirmed: str = ""
def is_unconfirmed(structure: dict[str, Any]) -> bool:
"""금액 합에서 뺄 구조물인가 — 합을 내는 자리마다 이 한 벌로 가림."""
return bool(structure.get("unconfirmed"))
#: 높이가 물량 밑수인 벽 — 높이를 안 적고 놓아도 줄은 서되 금액 밖(2026-09-14 브레인 판정 ①
#: 「설계자가 자리부터 잡고 치수를 뒤에 넣는 길을 막지 말 것」). 흙막이(떼 개소당)는 높이를 안 씀.
HEIGHT_DRIVEN_TYPES = frozenset({"retaining_wall", "masonry_wet", "masonry_dry", "boulder_masonry"})
UNCONFIRMED_NO_HEIGHT = "높이를 안 적고 놓았음 — 높이를 적으면 금액이 섬"
def missing_height_reason(structure: dict[str, Any]) -> str:
"""높이 없이 놓인 벽이면 미확정 까닭 한 줄, 아니면 빈 글."""
if structure.get("type_id") not in HEIGHT_DRIVEN_TYPES:
return ""
try:
height = float((structure.get("options") or {}).get("height_m") or 0.0)
except (TypeError, ValueError):
height = 0.0
return "" if height > 0 else UNCONFIRMED_NO_HEIGHT
def _num(value: Any, fallback: float = 0.0) -> float:
return float(value) if isinstance(value, (int, float)) else fallback
@@ -0,0 +1,532 @@
"""구조물 원단위 — **돌쌓기·큰돌쌓기 전개식**(버림·터파기 줄 포함) · 2026-09-14 가름.
식을 발명하지 않음 실무 정본 식을 옮긴 자리(`UnitQuantity` 머리글). 계수·판정은 `_StoneSpec`.
터파기·되메우기·잔토는 **토공으로 가는 **(`DESTINATION`) 내역 줄로 세지 않음.
"""
from __future__ import annotations
import math
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import DESTINATION, Component, _num
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import (
BLINDING_THICKNESS_M,
STONE_BACK_LENGTH_TABLE,
STONE_BACKFILL_VERIFIED_HEIGHT_M,
STONE_BACKFILL_WIDTH_M,
STONE_KIND_OPTION,
STONE_MASONRY,
STONE_PACKING_RATIO,
STONE_PILE_BASIS,
_back_length,
back_length_default_note,
dry_masonry_height_note,
face_slope_ratio,
fill_concrete_mpa,
STONE_SUPPLY_DEFAULT_NOTE,
STONE_SUPPLY_KEYS,
is_collected_stone,
load_stone_kind_table,
stone_coefficients,
stone_weight_per_m2,
wall_thickness,
wants_blinding,
weep_hole_spec,
)
from common_util.common_util_excavation import (
BASIS_WALL,
WALL_BLINDING_DEPTH_M,
WALL_FOUNDATION_DEPTH_M,
WALL_TRENCH_CLEARANCE_M,
wall_trench_area_m2,
wall_trench_width_m,
)
def _blinding_component(
base_width_m: float,
length_m: float,
options: dict[str, Any],
width_basis: str = "",
):
"""버림 콘크리트 한 줄. 넣지 않기로 했으면 `None`.
폭은 **잡석다짐 **(KCS 34 50 05)이고, 값은 확정 **구조물 하단 길이**.
부르는 쪽이 폭과 까닭(`width_basis`) 함께 넘긴다.
"""
if not wants_blinding(options) or base_width_m <= 0 or length_m <= 0:
return None
basis = (
f"기초 폭 {base_width_m:.2f}m × 연장 × 두께 {BLINDING_THICKNESS_M:.2f}m"
" · 두께는 KDS 44 90 00(100㎜) · 폭은 잡석다짐 폭(KCS 34 50 05)"
)
if width_basis:
basis = f"{basis} = {width_basis}"
return Component(
"버림콘크리트",
"",
base_width_m * length_m * BLINDING_THICKNESS_M,
DESTINATION["버림콘크리트"],
basis,
)
#: 큰돌쌓기(품셈 13-6) 직경 갈래 — **저장 제원 `stone_cm` 과 글자까지 같다**(레지스트리 확인).
#: ⚠ 돌쌓기(13-4)의 **뒷길이** 축과 섞지 않는다. 앞서 섞여 있어 직경 60~80 짜리가
#: 「뒷길이 45㎝」 계수로 돌던 자리다.
BOULDER_DIAMETERS = ("40~60", "60~80", "80~100")
#: 큰돌쌓기 전개 상수. **재료 원단위는 품셈에 없다** — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」.
#: 그래서 여기서 내는 것은 **면적과 터파기 계열까지**이고 큰돌 자체는 미확보로 둔다.
BOULDER_MASONRY = {
# ⚠ 검산 참고값 — 곱하지 않는다. 까닭은 `STONE_MASONRY` 의 같은 칸 주석을 볼 것.
# 큰돌쌓기는 전면 기울기가 「1:0.3 **이상**」이라 특히 하드코딩하면 안 된다.
"sheet_check_factor_at_0_3": 1.04,
# ⚠ 터파기 여유폭·되메우기 두께는 **공용 한 벌**로 옮겼다
# (`common_util/common_util_excavation.py` — 화면 B06 과 짝인 TS 가 있다).
# 여기서 다시 두면 그림과 물량이 갈린다.
}
def _boulder_diameter(options: dict[str, Any]) -> str | None:
"""저장 제원의 직경 갈래. 표에 없는 값이면 **지어내지 않고 `None`**."""
raw = options.get("stone_cm")
text = str(raw).strip() if raw is not None else ""
return text if text in BOULDER_DIAMETERS else None
def boulder_masonry(
height_m: float, length_m: float, options: dict[str, Any]
) -> tuple[list[Component], list[str]]:
"""큰돌쌓기(품셈 13-6) 전개 — **치수로 낼 수 있는 것까지만** 낸다.
재료 원단위가 품셈에 없다 13-6 [] 재료량은 설계수량을 적용한다.
돌쌓기(13-4)처럼 뒷길이별 돌중량·고임돌 표가 **없으므로** 큰돌·고임돌·막자갈을
내지 않는다. 대신 ** 내는지** 알림으로 남긴다.
고임돌·채움콘크리트 품은 **품에 포함** 있다(13-6-1 []·13-6-2 [])
따로 세우면 이중계상이다.
뒤채움(조약돌) **13-3 적용**한다(13-6 []) 표에서 찾지 않는다.
두께는 13-4-3 [] 준용하는데 **직고별 범위값**(상부 20~40 / 하부 30~140)이라
값으로 정한다. 미확보로 둔다.
"""
notes: list[str] = []
if height_m <= 0 or length_m <= 0:
return [], ["높이·연장이 없어 전개하지 않음"]
diameter = _boulder_diameter(options)
if diameter is None:
return [], [
"큰돌쌓기 돌 직경이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 "
f"{' · '.join(BOULDER_DIAMETERS)}㎝ 중 하나를 고르면 값이 섭니다"
]
# ⚠ 큰돌쌓기는 **표준경사표 대상이 아니다** — 교본이 「1:0.3 **이상**」으로만 두었다.
# 저장 제원에 값이 있으면 그것을 쓰고, 없으면 하한 0.3 으로 둔다.
slope_ratio = _num(options.get("face_slope_ratio"), 0.3)
face_area = height_m * length_m
# 기울기 몫은 **한 번만** — 13-4 와 같은 자리다(㉘).
masonry_area = face_area * math.hypot(1.0, slope_ratio)
components = [
Component(
"큰돌쌓기",
"",
masonry_area,
DESTINATION["돌쌓기"],
f"정면적 × √(1+{slope_ratio}²) · 직경 {diameter}㎝ (품셈 13-6)",
)
]
# 터파기·되메우기·잔토 — 치수에서 나온다. 두께는 **직경 갈래의 위 끝**을 벽 두께로 본다.
# ⚠ 품셈에 큰돌쌓기 터파기 폭 규정이 없어 **돌쌓기와 같은 방식**(벽 두께 + 여유 0.2m)으로
# 낸다. 근거 문구에 그 사실을 적어 되짚을 수 있게 한다.
upper_cm = float(str(diameter).split("~")[-1])
thickness = upper_cm / 100.0
# 버림 콘크리트 — 돌쌓기와 같은 자리(사용자 확정 ⑭). 빼려면 저장 제원에서 「안 넣음」.
blinding = _blinding_component(
thickness,
length_m,
options,
f"하단 길이(직경 위끝) {thickness:.2f}m — 확정 ⑪ 준용",
)
if blinding is not None:
components.append(blinding)
components.extend(
_trench_components(
height_m,
length_m,
thickness,
options,
notes,
"⚠ 큰돌쌓기 터파기 폭 규정이 품셈에 없어 돌쌓기 방식을 준용",
)
)
notes.append(
"재료(큰돌) 원단위 미확보 — 품셈 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」라 "
"돌쌓기(13-4)의 뒷길이별 돌중량 표에 해당하는 것이 없음"
)
notes.append("고임돌·채움콘크리트는 **품에 포함**(13-6 [주]①) — 따로 세우지 않음")
notes.append(
"뒤채움(조약돌)은 13-3 적용(13-6 [주]⑧). 두께는 13-4-3 [주]⑨ 준용인데 "
"직고별 범위값(상부 20~40㎝ / 하부 30~140㎝)이라 한 값으로 못 정함 — 미확보"
)
# ⚠ **채워져 있으면 그 사유를 붙이지 않는다**(2026-09-09). `bond` 는 레지스트리에 이미
# 있는 칸인데 조건 없이 붙이고 있어, 값을 고른 뒤에도 「못 고름」이 뜨고 있었다.
# ⚠ 얻는 것은 **노무 품 갈래뿐**이고 재료 원단위는 13-6 [주]⑦ 때문에 여전히 안 선다 —
# 그래도 고치는 까닭은 **사유가 거짓이면 사유 칸 전체를 못 믿게 되기 때문**이다.
if not str(options.get("bond") or "").strip():
notes.append("⚠ 메/찰 구분이 저장 제원에 없어 13-6-1(메)·13-6-2(찰) 중 어느 쪽인지 못 고름")
return components, notes
def _trench_components(
height_m: float,
length_m: float,
thickness: float,
options: dict[str, Any],
notes: list[str],
extra_basis: str = "",
) -> list[Component]:
"""터파기·되메우기·잔토 — **공용 단면 함수 한 벌**(`common_util_excavation`).
여기서 식을 다시 짜지 않는다 화면(B06 횡단도) 그리는 단면과 같은 코드라야
그림과 물량이 갈린다. 평균두께는 이쪽이 값을 **넘겨 준다**(저쪽은 두께를 모름).
기초유 / 기초버림 정본 제목이고 **기초분 0.45 0.07 이냐** 가른다.
고른 프로젝트는 **비탈분만** 세우고 사유를 남긴다 한쪽으로 찍으면 임의값이
금액으로 굳는다(기초분이 통째로 0.45/m ).
"""
foundation = str(options.get("foundation") or "").strip()
if foundation in ("기초유", "기초버림"):
has_foundation = foundation == "기초유"
area = wall_trench_area_m2(height_m, thickness, has_foundation=has_foundation)
base_depth = WALL_FOUNDATION_DEPTH_M if has_foundation else WALL_BLINDING_DEPTH_M
base_basis = f"기초분({foundation}) + "
else:
width = wall_trench_width_m(thickness)
area = None if width is None else height_m * width
base_depth = 0.0
base_basis = ""
notes.append(
"⚠ 기초 유/무(기초유·기초버림)를 안 정해 **기초 터파기 몫이 안 섰음** — 비탈분만 세움"
)
if area is None:
notes.append("⚠ 높이·평균두께가 없어 터파기가 서지 않음")
return []
excavation = area * length_m
backfill = (base_depth + height_m) * WALL_TRENCH_CLEARANCE_M * length_m
basis = (
f"{base_basis}H × (평균두께 {thickness:.2f} + {WALL_TRENCH_CLEARANCE_M}) × 연장"
f" · {BASIS_WALL}"
)
if extra_basis:
basis = f"{basis} · {extra_basis}"
return [
Component("터파기", "", excavation, DESTINATION["터파기"], basis),
Component(
"되메우기",
"",
backfill,
DESTINATION["되메우기"],
f"(기초깊이 {base_depth:g} + H) × {WALL_TRENCH_CLEARANCE_M} × 연장",
),
Component(
"잔토처리",
"",
excavation - backfill,
DESTINATION["잔토처리"],
"터파기 되메우기",
),
]
def stone_masonry(
height_m: float,
length_m: float,
options: dict[str, Any],
wet: bool,
face: str | None = None,
face_reason: str = "",
) -> tuple[list[Component], list[str]]:
"""돌쌓기(찰/메) 1구간 전개 — 실무 `기슭막이(찰쌓기, H=1.5, 기초무)` 시트의 식.
실측 대조(m당, H=1.5, 뒷길이 45, 기울기 1:0.3):
정면적 1.5 · 비탈면적 1.57 · 평균두께 0.83 · 입적 1.245
터파기 1.55 · 되메우기 0.30 · 잔토 1.25
"""
notes: list[str] = []
if height_m <= 0 or length_m <= 0:
return [], ["높이·연장이 없어 전개하지 않음"]
back_cm = _back_length(options, wet=wet, height_m=height_m)
if back_cm not in STONE_BACK_LENGTH_TABLE:
# ⚠ 접지 않는다 — 다른 규격 계수가 조용히 도는 것보다 「없다」가 낫다.
return [], [
f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 물량이 서지 않습니다"
]
table = dict(STONE_BACK_LENGTH_TABLE[back_cm])
default_note = back_length_default_note(options, back_cm, wet=wet, height_m=height_m)
if default_note:
notes.append(default_note)
height_note = None if wet else dry_masonry_height_note(height_m)
if height_note:
notes.append(height_note)
# ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3).
# 안 고르면 종전 값(건설품셈 참고자료)으로 돌되 그 사실을 알린다 — 값을 못 낸다고
# 멈추면 이미 저장된 프로젝트가 통째로 빈다.
picked, kind_note = stone_coefficients(options, back_cm)
if kind_note:
notes.append(kind_note)
# ⚠ **고른 종류의 빈 칸은 빈 칸으로 덮는다.** `None` 이라고 안 덮으면 종전 값(깬돌 계열)이
# 남아 「야면석 75㎝」처럼 **원문에 「-」인 칸에 값이 서는** 일이 생긴다(만들다 잡음).
if picked.get("kind"):
for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
table[key] = picked.get(key)
else:
for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
if picked.get(key) is not None:
table[key] = picked[key]
# ⚠ **교본의 「뒤채움 몫」(깬돌·잡석 1/2 · 야면석 1/3)은 이제 막자갈 밑수가 아니다** —
# ⭐ 확정 5차 작은 것 3 이 막자갈을 **뒷채움 폭 사다리꼴**로 못 박았다. 표는 그대로
# 두되(다른 자리에서 쓸 수 있다) 여기서는 안 쓴다.
kind_label = str(picked.get("kind") or "")
# ⭐ 2026-09-13 브레인 판정 — **계수 열 고르기와 돌종류는 다른 축.** 「실무 관행」은 계수 열만
# 바꾸고 종류는 지워선 안 됨(야면석이 「돌」·계산식 무게로 조용히 서던 결함). 돌 규격·무게는
# **저장 제원의 종류**를 따름 — 아는 종류일 때만(모르는 글은 종전대로 규격 빈칸).
chosen_kind = str(options.get(STONE_KIND_OPTION) or "").strip()
stone_kind_name = (
chosen_kind if chosen_kind in (load_stone_kind_table().get("kinds") or []) else kind_label
)
# ⚠ `face_slope_ratio` 는 **2026-09-09 에 칸이 생겼다**(돌쌓기 계열 여섯 종류 · 표준도
# 제원 폼). 빈 값이 「자동」의 뜻이라 비어 있으면 아래 표준경사표가 돌고, 채우면 그 값이
# 이긴다. (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것.)
# **기본 0.3 의 근거** — 교본 7-3 돌흙막이 기준: 「돌 찰쌓기 3.0m 이하 **1:0.3** /
# 돌 메쌓기 2.0m 이하 **1:0.3** / 큰돌쌓기 **1:0.3 이상**(전도 방지)」
# (`지식DB 02_상세설계/구조물/돌쌓기.md §1`, 값은 `data_masonry` 의 `face_slope`).
# 전면 기울기 — 품셈 표준경사표로 자동 판정하고, 저장 제원에 값이 있으면 그것이 이긴다.
slope_ratio, slope_basis = face_slope_ratio(
options, wet=wet, height_m=height_m, face=face, face_reason=face_reason
)
constants = STONE_MASONRY
face_area = height_m * length_m # 정면적
# 돌쌓기 면적 = 정면적 × √(1+n²) — 기울어진 만큼 길어진다. **기울기 몫은 한 번만.**
# 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04).
masonry_area = face_area * math.hypot(1.0, slope_ratio)
# 벽 두께 — 실무 구조물도 식(확정 2차 ②). **뒷길이가 들어간다.**
# ⚠ 표준도 제원에 상부·하부 두께 칸이 있으면 **그 값이 식을 이긴다**(확정 ② 후반).
back_m = back_cm / 100.0 # 석적(아래)이 뒷길이를 m 로 쓴다
top_thickness, bottom_thickness, thickness_basis = wall_thickness(
options, back_cm=back_cm, height_m=height_m
)
thickness = (top_thickness + bottom_thickness) / 2.0
volume = face_area * thickness # 입적
components = [
Component(
"돌쌓기",
"",
masonry_area,
DESTINATION["돌쌓기"],
f"정면적 × √(1+{slope_ratio:g}²) — 비탈면적 · {slope_basis}",
),
# 입적 — 정본 계산표 좌측 열의 이름 그대로다(확정 ⑦). 값을 새로 내는 것이 아니라
# 이미 쓰고 있던 밑수를 화면에 드러내는 줄이다.
Component(
"입적",
"",
volume,
DESTINATION["입적"],
f"정면적 × 평균두께 {thickness:.2f} · {thickness_basis}",
),
]
# ⚠ 고임돌 계수가 **원문에서 「-」**인 칸이 있다(견치돌 25·30 · 야면석 75 · 깬돌 25).
# 그 규격에 그 돌을 안 쓴다는 뜻이라 **0 줄을 만들지 않는다** — 0 은 「없음」과
# 구별이 안 되고, 받는 쪽이 「값이 0 인 자재」로 읽는다.
if table["wedge_stone_m3_per_m2"] is None:
notes.append(
f"고임돌 계수가 품셈 표에 없습니다 — 뒷길이 {back_cm}"
+ (f" · {kind_label}" if kind_label else "")
+ " 칸이 「-」입니다"
)
else:
components.append(
Component(
"고임돌",
"",
masonry_area * _num(table["wedge_stone_m3_per_m2"]),
DESTINATION["고임돌"],
f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)"
+ (f" · {kind_label}" if kind_label else ""),
)
)
# 석적 — ⭐ 확정 5차 작은 것 4. 「체적」은 위 **입적** 줄이 이미 그것이고, 이쪽만 새 줄이다.
stone_pile = face_area * back_m * STONE_PACKING_RATIO
components.append(
Component(
"석적",
"",
stone_pile,
DESTINATION["석적"],
f"정면적 × 뒷길이 {back_m:g}m × {STONE_PACKING_RATIO}(채움률) · {STONE_PILE_BASIS}",
)
)
# 돌 무게 — ⭐ 확정 5차 큰 것 7. **계산식이 기본**이고 야면석만 관측표다(헬퍼 한 벌).
# ⚠ 안 고른 경우도 **계산식**으로 선다 — 정본 여섯 탭이 종류를 안 적고 그 식을 쓰고,
# 그래야 정본 H=2.0 의 「1.92 톤」과 맞는다(관측표 0.88 로 서면 1.84 로 4 % 낮다).
stone_name, stone_ton, weight_tail, weight_source = stone_weight_per_m2(
back_cm, stone_kind_name, table["stone_ton_per_m2"]
)
weight_basis = f"돌쌓기 {weight_tail}" if weight_tail else ""
if not stone_kind_name:
notes.append(
"돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — "
"야면석이면 계산식이 안 맞아 관측표로 갈립니다"
)
if stone_ton is None:
# 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮).
notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 {stone_name}을 내지 못함")
else:
components.append(
Component(
"",
"ton",
masonry_area * stone_ton,
DESTINATION[""],
weight_basis,
source=weight_source,
spec=stone_kind_name,
)
)
# 막자갈(뒷채움) — ⭐ 확정 5차 작은 것 3 「(상 + 하) ÷ 2 × H」. **뒷채움 폭**이지 벽 두께가
# 아니다. 옛 식(입적 − 몸통 − 고임돌)에서 갈아탄 자리다.
backfill_top = STONE_BACKFILL_WIDTH_M["top"]
backfill_bottom = STONE_BACKFILL_WIDTH_M["bottom"]
rubble = (backfill_top + backfill_bottom) / 2.0 * height_m * length_m
components.append(
Component(
"막자갈",
"",
rubble,
DESTINATION["막자갈"],
f"(뒷채움 상 {backfill_top:g} + 하 {backfill_bottom:g}) ÷ 2 × H {height_m:g}m"
f" × 연장 {length_m:g}m · 정본 여섯 탭 공통값",
)
)
notes.append(
f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · "
f"{backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다"
"(소광리는 같은 식에 0.30/0.60 을 씁니다) · 칸을 새로 두는 것은 B05 등록부 몫"
)
# ⚠ 근거 구간 밖은 **값이 서되 그 사실이 보여야 한다** — 값은 계속 나오므로 사유가
# 없으면 아무도 못 본다(2026-09-09 그물 침).
if height_m > STONE_BACKFILL_VERIFIED_HEIGHT_M:
notes.append(
f"직고 {height_m:g}m 는 뒷채움 폭 정본 확인 구간"
f"(H {STONE_BACKFILL_VERIFIED_HEIGHT_M:g}m 이하) **밖**입니다 — 붙박이 폭이라 "
"직고가 커져도 두께가 안 늘어 품셈 13-4-4 [주]⑨ 뒤채움 조약돌 두께 범위보다 "
"얇아집니다(그 표는 직고와 함께 두꺼워짐). 값은 정본 식대로 세우되 "
"**높은 벽의 뒷채움 폭은 실무자 확인이 필요합니다**"
)
if wet:
mpa, mpa_basis = fill_concrete_mpa(options)
components.append(
Component(
"채움콘크리트",
"",
masonry_area * _num(table["fill_concrete_m3_per_m2"]),
DESTINATION["채움콘크리트"],
# ⚠ 돌 종류로 계수가 갈리는데(깬돌 0.2 · 야면석 0.15) 근거에 종류가 안
# 적히던 자리다 — 값은 바뀌는데 **왜 바뀌었는지가 안 보였다**(고임돌은
# 적고 있었다). 2026-09-09 맞춤.
f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)"
+ (f" · {kind_label}" if kind_label else "")
+ f" · {mpa_basis}",
spec=f"{mpa}",
)
)
components.append(
Component(
"모르터",
"",
masonry_area * constants["mortar_m3_per_m2"],
DESTINATION["모르터"],
f"돌쌓기 × {constants['mortar_m3_per_m2']} ㎥/㎡ (줄눈)",
)
)
# ⚠ 여기서 멈춘다 — 모르터를 시멘트·모래로 쪼개지 않는다(㉢).
# 물구멍 — 벽면적 2㎡당 1개소, 개소당 관 길이 = **평균두께**(관이 벽을 가로지름).
# ⚠ 이것은 **관(파이프) 자재**이지 공제 대상이 아니다. 품셈 1-2-1 이 「공제하지 않는다」고
# 말하는 물구멍은 **콘크리트 체적에서 뺄 구멍**이고, 여기 값은 그 구멍에 넣는 **관 길이**다.
# ⚠⚠ **㉥ 이중계상** — 품셈 13-6-2·13-7-2 [주]③ 은 제잡비 **윗단** 값에
# 「물빼기 파이프 설치에 관계되는 노무비, 재료비를 포함한다」고 한다. 그 쪽을 쓰면
# 이 줄과 겹친다. **우리 선택은 이 줄을 세우고 제잡비는 아랫단(미설치)** 이다
# (`structure_unit_observed` 의 `double_count_rules`).
# 지금 쓰는 13-4 계열에는 제잡비 행 자체가 없어 겹치지 않는다(전수 확인).
# ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면
# 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다.
# ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 —
# **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식).
# ⭐ 2026-09-13 브레인 판정(PLAN 10장) — 개소당 관 길이 = **평균두께**. 정본이 그렇게 잡고
# 관은 벽을 가로지르므로 이치에도 맞음. 옛 상수 0.5m 는 그만큼 짧게 섰음.
# ⚠ 골막이(`_Revetment`)는 그 정본이 0.5 라 그대로 둠 — 여기는 기슭막이 계열만.
hole_area, hole_dia, hole_basis = weep_hole_spec(options)
components.append(
Component(
"물구멍관",
"m",
masonry_area / hole_area * thickness,
DESTINATION["물구멍관"],
f"돌쌓기 ÷ {hole_area:g}㎡/개소 × 평균두께 {thickness:.2f} m/개소 · {hole_basis}",
spec=f"Ø{hole_dia}",
)
)
# 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.**
# ⚠ 밑수가 확정 5차로 바뀌었다 — 면석 몸통은 이제 **석적**(정면적×뒷길이×0.77)이고
# 막자갈은 **뒷채움 사다리꼴**이다. 옛 「입적 − 몸통 − 고임돌」 몫이 아니다.
if all(options.get(key) in (None, "") for key in STONE_SUPPLY_KEYS):
# 폼이 기본값을 값으로 안 보내게 된 뒤(2026-09-14) 빈 조달이 조용히 「채집」이 되지 않게.
notes.append(STONE_SUPPLY_DEFAULT_NOTE)
if is_collected_stone(options):
wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0
collected = max(stone_pile + wedge + rubble, 0.0)
if collected > 0:
components.append(
Component(
"채집석",
"",
collected,
DESTINATION["채집석"],
f"석적 {stone_pile:.3f} + 고임돌 {wedge:.3f} + 막자갈 {rubble:.3f}"
" · 현장 채집분 · ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌",
)
)
# 버림 콘크리트 — 기초 바닥에 까는 얇은 층. 빼려면 저장 제원에서 「안 넣음」으로 둔다.
# 잡석다짐 폭 = **하단 길이**(확정 ⑪) — 벽에서는 하부 두께다. 버림도 같은 폭을 쓴다.
base_width = bottom_thickness
blinding = _blinding_component(
base_width,
length_m,
options,
f"하단 길이(하부 두께) {bottom_thickness:.2f}m — 확정 ⑪ 「기준 구조물에서 유도」",
)
if blinding is not None:
components.append(blinding)
# ⓘ 기초잡석은 `build_table` 이 버림 뒤에 세움(확정 3차 ② 두께 0.2 · PLAN 10장 「계상함」).
# 옛 「두께가 없어 안 섬」 사유는 걷음 — 줄과 사유가 함께 떠 서로 어긋났음(2026-09-13 화면 실측).
# 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다).
components.extend(
_trench_components(height_m, length_m, thickness, options, notes, thickness_basis)
)
return components, notes
@@ -12,9 +12,9 @@
**형태가 돌쌓기가 아닌 갈래는 식이 없다** 콘크리트·돌망태·통나무/목재틀·바자.
지어내지 않고 ** 없는지** 줄로 남긴다.
**뒷길이· 종류 기슭막이 등록부에 아직 없다**(2026-09-09 실측). 돌쌓기 식은 둘로
계수가 갈리는데, 없으면 `_back_length` **조용히 45 돈다**. 나오므 아무도
알아채는 자리라 ** 사실을 드러낸다** 값이 있기는 하니 보이는 그것.
뒷길이· 종류 기슭막이 등록부에 섰음(`test_revetment_stone_fields`). 고른 장은
돌쌓기 식이 내는 (뒷길이를 골라 기본 45· 종류를 정해) 그대 드러냄.
2026-09-14 까지 붙이던 칸이 없어 **거짓 + 같은 **이라 걷음.
"""
from __future__ import annotations
@@ -23,13 +23,13 @@ import math
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
from B08_Quantity.B08_Quantity_Engine_StructureSheet import REVETMENT_STONE_FORMS
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _num, stone_masonry
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import routed
#: 형태 → 돌쌓기 갈래(찰이면 True). 여기 없는 형태는 전개식이 없다.
STONE_FORMS: dict[str, bool] = {
"돌쌓기(찰)": True,
"돌쌓기(메)": False,
}
#: ⚠ 구조물도 기울기·그림이 같은 표를 씀(`StructureSheet.REVETMENT_STONE_FORMS` 한 벌).
STONE_FORMS: dict[str, bool] = REVETMENT_STONE_FORMS
#: 식이 없는 형태와 **왜 없는지**. 품셈 장이 다르거나 원단위가 원문에 없다.
WITHHELD_FORMS: dict[str, str] = {
@@ -85,12 +85,6 @@ BED_SILL_FORMS: dict[str, dict[str, Any]] = {
},
}
#: 돌쌓기 식이 계수를 가르는 데 쓰는 칸 — 기슭막이 등록부에 아직 없는 것을 알린다.
STONE_SPEC_KEYS: tuple[tuple[str, str], ...] = (
("back_len_cm", "뒷길이"),
("stone_kind", "돌 종류"),
)
def revetment(
height_m: float,
@@ -110,17 +104,7 @@ def revetment(
"— 형태를 골라야 물량이 섭니다"
]
components, notes = stone_masonry(
height_m, length_m, options, STONE_FORMS[form], face, face_reason
)
# ⚠ 계수를 가르는 칸이 없으면 **기본값으로 조용히 돈다** — 그 사실을 드러낸다.
missing = [label for key, label in STONE_SPEC_KEYS if not options.get(key)]
if missing and components:
notes.append(
f"⚠ 기슭막이 제원에 {' · '.join(missing)} 칸이 없어 **돌쌓기 기본값으로 섰음** "
"— 그 값이 고임돌·야면석·채움콘크리트 계수를 가름"
)
return components, notes
return stone_masonry(height_m, length_m, options, STONE_FORMS[form], face, face_reason)
#: 돌골막이 원단위 — 소광리 정본 「골막이(찰)(치수조서연결)」, **개소당**.
@@ -201,6 +185,43 @@ def _ceil2(value: float) -> float:
return math.ceil(value * 100) / 100
def erosion_check_section(height_m: float, options: dict[str, Any]) -> dict[str, Any]:
"""골막이 한 개소의 치수 한 벌 — **표(`erosion_check_dam`)와 구조물도 그림이 같이 씀.**
상장·하장·높이 · 뒷길이 · 반수면 비탈 · 상부/하부/평균 두께 · 사면장 · 바닥파기 · 방수로 .
자름(`_floor2`) 표와 같게 그림 글자가 근거 글자와 같은 수를 보이게.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _back_length
const = EROSION_CHECK_DAM
top_m = _num(options.get("top_length_m"), 0.0)
bottom_m = _num(options.get("bottom_length_m"), 0.0)
back_cm = _back_length(options)
back_m = back_cm / 100.0
given = _num(options.get("face_slope_ratio"), 0.0)
slope = given if given > 0 else const["default_slope_ratio"]
top_raw = back_m + const["thickness_top_per_m"] * height_m
bottom_raw = back_m + const["thickness_bottom_per_m"] * height_m
thickness = _floor2((top_raw + bottom_raw) / 2)
return {
"top_m": top_m,
"bottom_m": bottom_m,
"height_m": height_m,
"back_cm": back_cm,
"back_m": back_m,
"slope": slope,
"slope_given": given > 0,
"top_t": _floor2(top_raw),
"bottom_t": bottom_raw,
"thickness": thickness,
"slant": _floor2(math.hypot((top_m - bottom_m) / 2, height_m)),
"trench_width": thickness + const["trench_extra_m"],
"trench_depth": const["trench_depth_m"],
"spillway": str(options.get("spillway") or "").strip() != "없음",
"pipe_r": const["spillway_pipe_r_m"],
}
def erosion_check_dam(
height_m: float, options: dict[str, Any]
) -> tuple[list[Component], list[str]]:
@@ -210,6 +231,7 @@ def erosion_check_dam(
STONE_BACK_LENGTH_TABLE,
STONE_MASONRY,
_back_length,
back_length_default_note,
fill_concrete_mpa,
stone_weight_per_m2,
weep_hole_spec,
@@ -240,19 +262,16 @@ def erosion_check_dam(
coeff = STONE_BACK_LENGTH_TABLE.get(back_cm)
if coeff is None:
return [], [f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다"]
if not any(options.get(key) is not None for key in ("back_len_cm", "stone_back_length_cm")):
notes.append(
f"⚠ 뒷길이를 안 골라 기본 {back_cm}㎝ 로 섰습니다 "
"— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
)
back_m = back_cm / 100.0
given = _num(options.get("face_slope_ratio"), 0.0)
if given > 0:
slope, slope_note = given, f"사용자 지정 1:{given:g}"
else:
slope = const["default_slope_ratio"]
slope_note = f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)"
default_note = back_length_default_note(options, back_cm)
if default_note:
notes.append(default_note)
geo = erosion_check_section(height_m, options)
back_m, slope = geo["back_m"], geo["slope"]
slope_note = (
f"사용자 지정 1:{slope:g}"
if geo["slope_given"]
else f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)"
)
# ① 정면적(사다리꼴) − ② 방수로 파형강관 단면
# ⚠ 「없음」이면 안 뺀다 — 정면적이 밑수라 **열한 줄이 통째로 움직인다.**
@@ -272,14 +291,7 @@ def erosion_check_dam(
)
masonry = _floor2(front_area * _round2(math.hypot(slope, 1.0)))
top_t = _floor2(back_m + const["thickness_top_per_m"] * height_m)
thickness = _floor2(
(
(back_m + const["thickness_top_per_m"] * height_m)
+ (back_m + const["thickness_bottom_per_m"] * height_m)
)
/ 2
)
top_t, thickness = geo["top_t"], geo["thickness"]
volume = _floor2(front_area * thickness)
facing = top_m * (top_t - back_m)
base_area = masonry + facing
@@ -340,9 +352,7 @@ def erosion_check_dam(
)
# 바닥파기 — 비탈 사면장 두 쪽 + 하장 한 쪽, 폭은 평균두께 + 0.3.
slant = _floor2(math.hypot((top_m - bottom_m) / 2, height_m))
width = thickness + const["trench_extra_m"]
depth = const["trench_depth_m"]
slant, width, depth = geo["slant"], geo["trench_width"], geo["trench_depth"]
trench = _floor2(slant * depth * width * 2 + bottom_m * depth * width)
spoil = _round2(trench - (slant * depth * width + bottom_m * depth * width))
rows.extend(
@@ -360,8 +370,9 @@ def erosion_check_dam(
)
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis, spec="".join(spec))
Component(name, unit, amount, destination, basis, spec="".join(spec))
for name, unit, amount, basis, *spec in rows
if (destination := routed(name, notes))
]
mpa, mpa_basis = fill_concrete_mpa(options)
fill_per = coeff["fill_concrete_m3_per_m2"]
@@ -415,7 +426,9 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
("면목", "m", 2.0 * 1.0, "2 × 1"),
),
},
"콘크리트 L형수로 H=0.2": {
# ⚠ 키는 **등록부 선택지 글자 그대로**(「L형수로 H=0.2」) — 2026-09-14 까지 앞에
# 「콘크리트 」가 붙어 있어 골라도 **영영 「원단위가 없습니다」**로 섰음.
"L형수로 H=0.2": {
"rows": (
("터파기", "", 0.31645, "[{(1.11+1.51)÷2×0.2} + {(0.33×0.33)÷2}] × 1.0"),
("되메우기", "", 0.129975, "(0.2+0.30)÷2×0.25×1 + (0.2+0.19)÷2×0.33×1.0"),
@@ -426,7 +439,7 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
0.1508,
"{(0.2+0.15)÷2×0.7} + (0.21×0.13)×1 + {(0.15+0.21)÷2×0.20}×1",
),
("이형철근 D13", "kg", 0.398, "0.2 × 2 × 0.995"),
("이형철근", "kg", 0.398, "0.2 × 2 × 0.995", "D13"), # 이름 · 규격 따로
("거푸집", "", 0.738, "0.2 + 0.33 + √(0.2² + 0.06²)"),
("면목", "m", 1.0, "1"),
),
@@ -446,6 +459,17 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
#: 수량은 여기서 제 것으로 낸다.** 그림이 같다고 기슭막이 원단위를 끌어오면 그 확정을 어긴다.
SOIL_GUARD_SOD: dict[str, Any] = {
"unit_label": "개소당",
#: 정본 평균 붙박이 치수(m) — 아래 줄 식의 수 그대로. 기슭 0.54 = 사면장 √(0.2²+0.5²).
#: 구조물도 그림이 이 치수로 그림(`test_b08_structure_figure_small` 이 줄 값과 대조).
"dims": {
"top_m": 1.5,
"bottom_m": 1.1,
"height_m": 0.5,
"sod_m": 0.2,
"slant_m": 0.54,
"trench_width_m": 0.4,
"trench_depth_m": 0.2,
},
"rows": (
(
"",
@@ -467,7 +491,6 @@ def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]:
치수가 **전부 평균 붙박이** 정본 표에 (평균)이라 적혀 있고 개소별 치수조서가
따로 없다. 그래서 높이·길이를 받지 않는다. 개소별로 갈리게 하려면 치수 칸이 먼저다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
form = str(options.get("form") or "").strip()
if not form:
@@ -479,13 +502,16 @@ def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]:
"— 흙막이는 앞면 1:0.3·뒷면 수직·천단 30㎝(교본)라 축이 다르고 근거가 없습니다"
]
notes: list[str] = []
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
Component(name, unit, amount, destination, basis)
for name, unit, amount, basis in SOIL_GUARD_SOD["rows"]
if (destination := routed(name, notes))
]
return components, [
*notes,
f"떼흙막이는 **{SOIL_GUARD_SOD['unit_label']}**이고 치수가 정본 평균 붙박이입니다"
"(상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54m) — 개소 제원을 안 봅니다"
"(상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54m) — 개소 제원을 안 봅니다",
]
@@ -494,10 +520,11 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component
규격을 고를 칸이 없어 지금은 **콘크리트 개거 150×200 붙박이**. 사실을 줄로 낸다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
if length_m <= 0:
return [], ["연장이 없어 전개하지 않음"]
from B08_Quantity.B08_Quantity_Wording import option_missing
return [], [option_missing("length_m", "open_ditch")]
spec = str(options.get("ditch_spec") or "콘크리트 개거 150×200").strip()
table = OPEN_DITCH_FORMS.get(spec)
if table is None:
@@ -505,17 +532,19 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component
f"개거 규격 「{spec}」 원단위가 없습니다 — 있는 것: {' · '.join(OPEN_DITCH_FORMS)}"
]
notes: list[str] = []
components = [
Component(
name,
unit,
per_m * length_m,
DESTINATION.get(name, "quantity"),
destination,
f"{basis} × 연장 {length_m:g}m (m당 {per_m:g})",
spec="".join(spec),
)
for name, unit, per_m, basis in table["rows"]
for name, unit, per_m, basis, *spec in table["rows"]
if (destination := routed(name, notes))
]
notes: list[str] = []
if not options.get("ditch_spec"):
notes.append(f"규격을 안 골라 「{spec}」으로 섰습니다 — 제원에서 고를 수 있습니다")
if spec == "콘크리트 개거 150×200":
@@ -538,7 +567,6 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
등록부 밑수도 `area_m2` 축이 그대로 맞는다.
기울기 몫이 **없다** 평면에 붙이는 것이라 (1+) 곱하지 않는다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
form = str(options.get("form") or "").strip()
table = BED_SILL_FORMS.get(form)
@@ -583,14 +611,26 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
# 터파기 — 정본은 「두께 × 1 × 1」이라 **면적 × 두께**와 같다.
rows.append(("터파기", "", area_m2 * back, f"면적 × 두께 {back:g}m"))
notes: list[str] = []
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis, spec="".join(spec))
Component(name, unit, amount, destination, basis, spec="".join(spec))
for name, unit, amount, basis, *spec in rows
if (destination := routed(name, notes))
]
notes = [f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다"]
notes.append(
f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다"
)
if form == "돌붙임(찰)":
notes.append(
"버림 콘크리트 0.1 ㎥/㎡ 포함 — 정본 주기 「바닥 10㎝ 이상 콘크리트(버림) 포설 후 "
"돌붙임」(KDS 44 90 00 의 100㎜ 와 같음)"
)
notes.append(BED_SILL_TRENCH_NOTE)
return components, notes
#: 바닥막이 터파기 사유 — 표 사유·구조물도 그림이 **같은 말**(2026-09-14 그림에서 드러남).
BED_SILL_TRENCH_NOTE = (
"⚠ 터파기는 정본대로 면적 × 돌 두께만 — 그 밑 버림·기초잡석 두께는 판 깊이에 안 들어감"
" · 옹벽은 버림·잡석까지 팜 — 바닥막이는 정본이 돌 두께만이라 다름"
)
@@ -0,0 +1,611 @@
"""구조물 원단위 — **돌쌓기 제원·계수표·판정** · 2026-09-14 `UnitQuantity` 에서 가름.
뒷길이 계수표 · 종류 계수 · 무게 · 물구멍 · 채움 강도 · 버림·기초잡석 상수 · 표준경사 판정 ·
두께. 성분을 세우지 않음 세우는 것은 `_Masonry`(전개식)·`UnitQuantity.build_table`.
"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import _num
# ── 계수표 — 식에 박지 않고 여기서 고른다 ─────────────────────────────
# 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」.
# ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮).
#: ⚠ **일곱 규격**이다 — 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 를 다 준다.
#: 앞서 네 칸(35·45·55·60)만 들고 25·30 을 35 로, 75 를 60 으로 **접고** 있었다.
#: `stone_ton_per_m2`(돌중량)는 **실무 관측값**이라 그 넷에만 있다 — 없는 칸은 `None`.
STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = {
25: {"fill_concrete_m3_per_m2": 0.11, "wedge_stone_m3_per_m2": None, "stone_ton_per_m2": None},
30: {"fill_concrete_m3_per_m2": 0.14, "wedge_stone_m3_per_m2": 0.10, "stone_ton_per_m2": None},
35: {"fill_concrete_m3_per_m2": 0.16, "wedge_stone_m3_per_m2": 0.12, "stone_ton_per_m2": 0.575},
45: {"fill_concrete_m3_per_m2": 0.20, "wedge_stone_m3_per_m2": 0.15, "stone_ton_per_m2": 0.88},
55: {"fill_concrete_m3_per_m2": 0.25, "wedge_stone_m3_per_m2": 0.18, "stone_ton_per_m2": 1.10},
60: {"fill_concrete_m3_per_m2": 0.27, "wedge_stone_m3_per_m2": 0.20, "stone_ton_per_m2": None},
75: {"fill_concrete_m3_per_m2": 0.34, "wedge_stone_m3_per_m2": 0.25, "stone_ton_per_m2": None},
}
DEFAULT_BACK_LENGTH_CM = 45
#: ⑤ 돌 중량이 어디서 온 값인가 (2026-09-08 사용자 확정 2차 ⑤ 「DB 확인 → 없으면 현재 유지,
#: 나중에 프로그램에서 확인할 수 있게 근거를 남길 것」).
#: ⚠ **품셈·교본에는 돌중량표가 없다** — 지식DB 전수 확인 결과 이 값이 나오는 곳은
#: 실무 원단위 라이브러리 한 곳뿐이다(기술문서에는 그 라이브러리를 인용한 줄만 있음).
#: 그래서 값을 바꾸지 않고 **어디서 온 값인지**를 줄마다 달아 화면에서 되짚게 한다.
STONE_WEIGHT_SOURCE = "uljin_library"
STONE_WEIGHT_BASIS = (
"⚠ 실무 관측값 — 울진 소광 원단위 라이브러리 「돌뒷길이별 원단위표」(35·45·55㎝만 있음)."
" 품셈·교본에는 돌중량표가 없음"
)
#: ⭐ 사용자 확정 5차 큰 것 7 — **돌 무게는 계산식**: `뒷길이 × 0.77 × 2.65`.
#: 정본 여섯 탭이 모두 그 식을 그대로 적어 둔다(「2.09 × 0.45 × 0.77 × 2.65 ton/m3」).
#: ⚠ **야면석은 그 식이 안 맞는다** — 공극이 커 ㎥당 무게가 달라 원문이 값을 직접 준다
#: (35→0.575 · 45→0.880 · 55→1.100 ton/㎡). 그래서 **종류로 갈래를 둔다.**
#: ⚠ **잠정이다** — 확정 문구가 「나중에 실무자 협의 후 명확히」이므로 화면 근거에 적는다.
STONE_PACKING_RATIO = 0.77 # 채움률 — 돌 사이 공극을 뺀 몫
STONE_DENSITY_TON_M3 = 2.65 # 석재 비중
#: 계산식이 안 맞아 **관측표**를 쓰는 종류. 안 고른 경우는 **계산식**으로 선다(`stone_masonry` 주석).
STONE_WEIGHT_OBSERVED_KINDS = frozenset({"야면석·호박돌", "야면석"})
STONE_WEIGHT_FORMULA_BASIS = (
"⭐ 확정 5차 — 뒷길이 × 0.77(채움률) × 2.65(비중). ⚠ 잠정 — 실무자 협의 후 명확히 할 값"
)
#: 물구멍 칸 — 2026-09-09 에 등록부에 생겼다(랩탑 메인 `0b0763ed`). **빈 칸이 기본**이다.
#: ⚠ 기본 Ø50 은 **국가기준**이고 2㎡당 1개소는 실무 관측값이다(법은 「2~3㎡당 1개소 이상」).
WEEP_HOLE_DIAMETER_KEY = "weep_hole_diameter_mm"
WEEP_HOLE_AREA_KEY = "weep_hole_area_m2"
WEEP_HOLE_DIAMETER_DEFAULT_MM = 50
def weep_hole_spec(options: dict[str, Any]) -> tuple[float, int, str]:
"""(개소당 벽면적 ㎡, 관 지름 ㎜, 근거). 안 정하면 기본으로 서고 그 사실이 근거에 남는다."""
area = _num(options.get(WEEP_HOLE_AREA_KEY), 0.0)
diameter = _num(options.get(WEEP_HOLE_DIAMETER_KEY), 0.0)
area_given = area > 0
dia_given = diameter > 0
if not area_given:
area = STONE_MASONRY["weep_hole_area_m2"]
if not dia_given:
diameter = WEEP_HOLE_DIAMETER_DEFAULT_MM
basis = (
f"{area:g}㎡당 1개소 · Ø{int(diameter)}"
+ ("" if area_given else " · ⚠ 면적 안 정함 — 실무 관측 2㎡(법은 2~3㎡당 1개소 이상)")
+ ("" if dia_given else " · ⚠ 지름 안 정함 — 국가기준 Ø50")
)
return area, int(diameter), basis
def stone_weight_per_m2(
back_cm: int, kind: str, observed: float | None
) -> tuple[str, float | None, str, str]:
"""(줄 이름, ton/㎡, 근거, source) — ⭐ 확정 5차 큰 것 7.
**계산식이 기본**이고 야면석만 관측표다. 돌쌓기·골막이가 같은 규칙을 쓰도록 여기 벌만 둔다.
"""
if kind in STONE_WEIGHT_OBSERVED_KINDS:
basis = (
f"× {observed} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}"
if observed is not None
else ""
)
return kind, observed, basis, STONE_WEIGHT_SOURCE
ton = back_cm / 100.0 * STONE_PACKING_RATIO * STONE_DENSITY_TON_M3
basis = (
f"× {ton:.3f} ton/㎡ = 뒷길이 {back_cm / 100.0:g} × {STONE_PACKING_RATIO}"
f" × {STONE_DENSITY_TON_M3}"
+ (f" · {kind}" if kind else " · ⚠ 돌 종류를 안 골랐음")
+ f" · {STONE_WEIGHT_FORMULA_BASIS}"
)
return kind or "", ton, basis, ""
#: 뒷채움(막자갈) 폭 — ⭐ 사용자 확정 5차 작은 것 3 「막자갈 = (상 + 하) ÷ 2 × H」.
#: ⚠⚠ **여기 상·하는 벽 두께가 아니라 뒷채움 폭이다.** 벽 두께(0.75/0.9)를 끌어다 쓰면
#: H=2.0 에서 실무 0.75 가 1.66(입적)으로 튄다 — 옛 식이 곧 그 자리였다.
#: 정본 `04.구조도(기슭막이).xls` **여섯 탭 전부** 상 0.30 · 하 0.45 로 같다
#: (H=1.0~3.0 · 메/찰 · 기초유/버림 전수 확인, 2026-09-09).
#: ⓘ 소광리는 같은 식에 **0.30 / 0.60** 을 쓴다(그래서 H=2.0 에서 0.900). 문서마다 폭이
#: 다르므로 **값이 적힌 구조물은 그 값**을 쓰고, 없으면 이 정본 값으로 서되 그 사실을 알린다.
STONE_BACKFILL_WIDTH_M = {"top": 0.30, "bottom": 0.45}
#: ⚠⚠ 정본 전수 확인이 **H=1.0~3.0** 까지다 — 그보다 높은 벽은 **근거가 없다.**
#: 그리고 그 자리에서 품셈 13-4-4 [주]⑨ <뒤채움 조약돌 두께> 범위와 **갈린다**:
#: H 3.0 두께 0.359 (품셈 0.33~0.58 안) · H 3.5 0.354 (품셈 0.40~0.70 **아래**)
#: 까닭은 이 식의 두께(= 막자갈 ÷ 비탈면적)가 `0.375 ÷ √(1+경사²)` 로 거의 붙박이인데,
#: 품셈 표는 직고와 함께 두꺼워지기 때문이다 — **방향이 반대다.**
#: ⚠ 교본 7-3 은 「찰 3.0m 이하 · 메 2.0m 이하」로 두지만 **코드가 높이를 막지 않고**
#: 표준경사표(13-4-4 [주]⑪)는 직고 7m 까지 칸을 준다 — 즉 **높은 벽이 실제로 설 수 있다.**
#: 값을 눅이거나 막지 않고 **그 사실을 사유로 드러낸다**(임의 확정 금지).
STONE_BACKFILL_VERIFIED_HEIGHT_M = 3.0
#: 석적 — ⭐ 확정 5차 작은 것 4. **정본에는 없고 소광리에만 있는 줄**이라 사유에 적는다.
#: 소광리 식 `H × ℓ3 × 0.77`(면적 × 뒷길이 × 채움률) 그대로.
STONE_PILE_BASIS = "⚠ 정본에 없는 줄 — 소광리 시트에만 있음(정면적 × 뒷길이 × 0.77)"
#: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3.
#: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다.
#: 그 값이 「깬돌」 계열이라, **자재로는 야면석을 내면서 계수는 깬돌**을 쓰는 어긋남이
#: 있었다(2026-09-08 지식DB 대조). 랩탑 창이 `stone_kind` 칸을 만들어 축이 생겼다.
STONE_KIND_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry"
STONE_KIND_PREFIX = "stone_kind_"
STONE_KIND_OPTION = "stone_kind"
@lru_cache(maxsize=1)
def load_stone_kind_table() -> dict[str, Any]:
"""돌 종류별 계수표. 파일이 없으면 **빈 표** — 그러면 종전 값으로 돈다."""
folder = STONE_KIND_DIR
if not folder.is_dir():
return {}
files = sorted(folder.glob(STONE_KIND_PREFIX + "*.json"))
if not files:
return {}
try:
return json.loads(files[-1].read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
#: ⑨ 계수를 **어느 열로 볼 것인가** (2026-09-08 사용자 확정 2차 ⑨).
#: 기본은 **품셈 열**(야면석 고임돌 0.11 · 채움 0.15)이고, 「실무 관행」으로 두면
#: 건설품셈 참고자료 한 벌(깬돌 계열 0.15 · 0.20)로 덮어쓴다.
#: ⚠ **빈 칸은 「안 정함」**이고 그때 기본(품셈)으로 선다 — 등록부 default 는 비어 있다.
#: 기본값을 등록부가 아니라 여기가 갖는 것은 `face_slope_ratio` 와 같은 결이다.
COEFF_BASIS_KEY = "stone_coeff_basis"
COEFF_BASIS_PRACTICE = {"실무 관행", "실무관행", "practice", "관행"}
def wants_practice_coefficients(options: dict[str, Any]) -> bool:
"""계수를 실무 관행 열로 볼 것인가. 빈 칸이면 **아니오**(= 품셈 열)."""
return str(options.get(COEFF_BASIS_KEY) or "").strip() in COEFF_BASIS_PRACTICE
def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, Any], str]:
"""(계수 한 벌, 알림). 돌 종류를 안 고르면 **종전 값**으로 돌되 그 사실을 알린다.
값을 낸다고 멈추지 않는다 이미 저장된 프로젝트가 통째로 비어 버린다.
표에 -( 규격에 돌을 ) **지어내지 않고** 사유를 낸다.
"""
table = load_stone_kind_table()
key = str(back_cm)
kind = str(options.get(STONE_KIND_OPTION) or "").strip()
if not table:
return {}, ""
if key not in [str(x) for x in (table.get("back_lengths_cm") or [])]:
return {}, (f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다")
practice = wants_practice_coefficients(options)
if not kind or practice:
fallback = table.get("fallback") or {}
message = (
f"{kind}」이지만 계수를 **실무 관행 열**(건설품셈 참고자료)로 덮어썼습니다 —"
" 기본은 품셈 열입니다(확정 ⑨)"
if practice and kind
else str(fallback.get("message") or "")
)
return {
"wedge_stone_m3_per_m2": (fallback.get("wedge_stone_m3_per_m2") or {}).get(key),
"fill_concrete_m3_per_m2": (fallback.get("fill_concrete_m3_per_m2") or {}).get(key),
"backfill_ratio": fallback.get("backfill_ratio_of_back_length"),
"kind": "",
}, message
if kind not in (table.get("kinds") or []):
return {}, f"{kind}」은(는) 아는 돌 종류가 아니라 계수를 못 골랐습니다"
wedge = ((table.get("wedge_stone_m3_per_m2") or {}).get(kind) or {}).get(key)
fill = ((table.get("fill_concrete_m3_per_m2") or {}).get(kind) or {}).get(key)
ratio = (table.get("backfill_ratio_of_back_length") or {}).get(kind)
note = ""
if wedge is None:
note = f"품셈 13-4-3 에 「{kind} · 뒷길이 {back_cm}㎝」 칸이 비어 있습니다 — 그 규격에 그 돌을 쓰지 않습니다"
return {
"wedge_stone_m3_per_m2": wedge,
"fill_concrete_m3_per_m2": fill,
"backfill_ratio": ratio,
"kind": kind,
}, note
# 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것.
#: ⚠⚠ **기슭막이 계열(돌쌓기 찰·메) 전용이다. 다른 구조물로 넓히지 말 것**(2026-09-09).
#: 골막이는 **더하는 몫이 다르다** — 소광리 숨김탭 「골막이(찰)(치수조서연결)」 C26:
#: 골막이 평균두께 = {(3 + 0.1×H) + (3 + 0.4×H)} ÷ 2
#: 기슭막이 상부 = ℓ3 + 0.30 · 하부 = 상부 + 0.30(H 1)
#: ⚠⚠ **둘 다 뒷길이(ℓ3) 기반이다** — C26 의 「0.45」는 상수가 아니라 `P6/100`(뒷길이 45㎝)을
#: 읽은 값이었다(2026-09-09 랩탑 보조가 셀 참조로 확인). 앞서 여기 「0.45 + 0.1H」로 적어
#: **상수처럼 보이게** 두었는데, 그렇게 굳으면 **뒷길이를 바꿔도 골막이 값이 안 움직이는**
#: 결함이 조용히 남는다.
#: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.**
#: ⚠ 골막이는 그 밖에도 셋이 더 다르다 — 밑수가 **돌쌓기 + 돌붙임**(기슭막이는 돌쌓기만) ·
#: 단위가 **개소당**(기슭막이는 m당) · 정면적이 **사다리꼴**.
STONE_MASONRY = {
# ⚠ **곱하는 값이 아니라 검산 참고값이다** (2026-09-08 ㉘ 에서 고침).
# 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가 **곧 기울기 몫**이다
# (1:0.3 → √(1+0.3²) = 1.0440 ≈ 1.04). 시트가 반올림해 적은 것을 우리가
# **별도 계수로 오해해 `hypot` 위에 또 곱하고 있었다** — 면적이 4 % 부풀었고
# 그 면적이 고임돌·야면석·채움콘크리트·모르터·물구멍 **전부의 밑수**였다.
# ⚠ 하드코딩하면 안 되는 값이다 — 큰돌쌓기는 「1:0.3 **이상**」이라 기울기가
# 바뀔 수 있고, 그때 1.04 는 틀린 값이 되지만 `hypot` 은 따라간다.
"sheet_check_factor_at_0_3": 1.04, # 1:0.3 에서 시트값과 맞는지 대조하는 자리
# ⭐ 2026-09-09 확정 2차 ② — **실무 구조물도 식**으로 갈았음(랩탑 보조가 소광리 원본
# 네 탭에서 찾아 실측과 소수점까지 대조).
# 상부 두께 = 뒷길이(3) + 0.30
# 하부 두께 = 상부 + 0.30 × (H 1.0)
# ⚠ **옛 식에는 뒷길이가 없었다**(0.45 + 0.10·H / 0.45 + 0.40·H) — 뒷길이를 35 로
# 바꿔도 두께가 안 바뀌어 돌 부피·막자갈·터파기가 다 같은 값으로 섰다.
# ⚠ 정본 xls 의 「상부 0.75 · 하부 0.90」은 **H=1.5 규칙값을 다른 높이 탭에 복사**한
# 흔적이라 그대로 쓰면 H=1.5 밖에서 틀린다.
# ⚠ 돌조공(H=0.5 · 0.55/0.65)은 이 규칙 밖 — 다른 시설이라 여기서 다루지 않는다.
"thickness_top_add_m": 0.30, # 상부 = 뒷길이 + 0.30
"thickness_slope_per_m": 0.30, # 하부 = 상부 + 0.30 × (H 1.0)
"thickness_height_base_m": 1.0, # 그 식의 기준 높이
# ⚠ 미결 — 법은 「2~3 ㎡당 1개소 **이상**」(구조물_수량.md §물구멍)이고 2.0 은 **실무 관측값**이다.
# 범위의 한쪽 끝을 쓰는 것이라 사용자 확정 전까지 잠정이다. 식이 아니라 여기 있으니 갈아끼우면 된다.
"weep_hole_area_m2": 2.0, # 물구멍 1개소당 벽면적
# ⚠ **구조물마다 다른 값이다** — 실무 관측: 반중력식 옹벽 0.32 m/m · 돌기슭막이 0.39 m ·
# 돌골막이 0.5 m/개소(2026-09-08 랩탑 보조). 여기 0.5 하나로 돌고 있으니 그 사실을
# 적어 둔다 — 관측 원단위가 있는 종류는 그 표가 이기고, 없는 종류만 이 값으로 선다.
# ⓘ 2026-09-13 — 돌쌓기(기슭막이)는 **평균두께**로 갈음(브레인 판정).
# 이 값은 골막이 정본용으로만 남음.
"weep_hole_length_m": 0.5, # 물구멍관 1개소당 관 길이 — 골막이
"mortar_m3_per_m2": 0.009, # 줄눈 모르터 (찰쌓기만)
# ⚠ 터파기 여유폭·되메우기 두께는 **공용 한 벌**로 옮겼다
# (`common_util/common_util_excavation.py` — 화면 B06 과 짝인 TS 가 있다).
# 여기서 다시 두면 그림과 물량이 갈린다.
}
# 성분이 어디로 가는가 — 이중계상을 막는 표시.
# `earthwork` = 토공 대분류로 합산(울진 토적집계 D12~D14 실증)
# `material` = 자재총괄로 감(할증은 거기서 한 번만)
# `unit_price` = 일위대가 재료비 구성으로 감(B09 가 배합을 분해)
#: 버림 콘크리트 — **빠뜨리고 있던 줄**이다(2026-09-09 사용자 확정 ⑭).
#: 두께 근거: KDS 44 90 00 도로암거구조설계기준 「기초시공시 기초지반 다짐을 시행하고
#: 구조물 시공이 원활하도록 **100 mm 두께의 버림콘크리트**를 타설하도록 한다」.
#: 폭 근거: KCS 34 50 05 「버림 콘크리트의 두께는 설계도서에 따르며, **폭은 잡석다짐의
#: 폭과 동일**하게 한다」.
#: ⭐ 그 잡석다짐 폭이 **정해졌다** — 「기준 구조물에서 유도, 같은 값을 공유」
#: (2026-09-08 사용자 확정 2차 ⑪ 「기슭막이면 **하단 길이** × 종단방향 폭 × 높이」).
#: ⇒ 벽에서 **하단 길이 = 하부 두께**다. 종전의 「터파기 폭 잠정」을 그 값으로 바꾼다.
#: ⚠ **기초잡석(품셈 12-25) 물량은 아직 못 센다** — 폭은 이제 있으나 **두께가 어느
#: 원문에도 없다**(품셈은 ㎥당 품만 줌). 실무 관측은 관보호공 날개벽 T=0.2 하나뿐이라
#: **다른 구조물 값을 옮겨 쓰지 않고** 사유로 드러낸다.
#: 돌을 **사 오나 캐나** — 기본은 「캔다」(2026-09-09 사용자 확정 ②).
#: 법이 그쪽을 권한다 — 별표2 「석축 등에 필요한 야면석 등은 **가급적 현장에서
#: 채취·사용**하도록 운반거리를 조사한다」. 실무 견적 다섯 권에도 야면석 **구입 단가가
#: 0건**이고 울진 일위대가는 「구입/채집」 두 벌을 갖고 있다.
#: ⚠ 구조물마다 바꿀 수 있다 — 저장 제원에 「구입」이라 적힌 구조물만 공제에서 빠진다.
STONE_SUPPLY_KEYS = ("stone_supply", "stone_source")
STONE_SUPPLY_PURCHASED = {"구입", "구입품", "사서", "purchase", "purchased", "buy"}
#: 조달을 안 정한 구조물의 사유 — 셈은 확정 ② 「기본은 캔다」대로 채집이되 **안 정한 사실**을 보임.
STONE_SUPPLY_DEFAULT_NOTE = "돌 조달(채집·구입)을 안 정해 「채집」으로 셈 — 사용자 확정 ② 기본 · 구조물 상세에서 고르면 바뀜"
#: ⚠⚠ **채집석 공제는 사토에서 한 번만 뺀다** (2026-09-09 세 창 확정 · 랩탑 메인의 통로에도
#: 같은 문장이 박혀 있다 — 두 곳이 같은 말이라야 나중에 누가 봐도 안 갈린다).
#:
#: 채집석 공제는 사토에서 한 번만 뺀다.
#: B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며,
#: 빼는 자리는 유토곡선의 사토뿐이다 —
#: 실어 내는 몫(spoil_m3 natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.
#:
#: ⚠ **부호를 넘기지 않는다.** 실무 시트가 `−274.66` 으로 적혀 있어 그대로 넘기면 두 번
#: 뒤집힌다. 우리는 **양수**로 주고, 빼는 것은 받는 쪽이 한다.
COLLECTED_STONE_KEY = "collected_stone_deduction_m3"
#: ⑩ 채움 콘크리트 강도 (2026-09-08 사용자 확정 2차 ⑩ 「기본 210, 고를 수 있게」).
#: ⚠ **빈 칸은 「안 정함」**이고 그때 210 으로 선다 — 등록부 default 는 비어 있다.
#: 180 은 국가기준 하한(돌쌓기 전용 값)이라 고르면 그 값이 이긴다.
FILL_CONCRETE_MPA_KEY = "fill_concrete_mpa"
FILL_CONCRETE_MPA_DEFAULT = "210"
FILL_CONCRETE_MPA_CHOICES = ("180", "210")
def fill_concrete_mpa(options: dict[str, Any]) -> tuple[str, str]:
"""(강도, 근거). 안 정했으면 기본 210 이고 그 사실이 근거에 적힌다."""
value = str(options.get(FILL_CONCRETE_MPA_KEY) or "").strip()
if value in FILL_CONCRETE_MPA_CHOICES:
return value, f"강도 {value} — 저장 제원에서 고른 값"
if value:
return (
FILL_CONCRETE_MPA_DEFAULT,
f"강도 {FILL_CONCRETE_MPA_DEFAULT} — ⚠ 저장값 「{value}」은 180·210 중에"
" 없어 기본으로 섬",
)
return (
FILL_CONCRETE_MPA_DEFAULT,
f"강도 {FILL_CONCRETE_MPA_DEFAULT} — 안 정해 기본값(확정 ⑩)",
)
BLINDING_THICKNESS_M = 0.10
#: 기초잡석 두께(m) — 2026-09-09 사용자 확정 3차 ② 「0.2 m」.
#: ⚠ 품셈 12-25 는 **㎥당 품만** 주고 두께를 정하지 않는다(전 출처 소진). 폭은 **버림 폭과
#: 같고**(KCS 34 50 05 「버림 콘크리트의 폭은 잡석다짐의 폭과 동일」) 그 폭은 확정 ⑪ 로
#: 하단 길이다. ⇒ **기초잡석 = 버림 × (잡석두께 ÷ 버림두께)** 로 나온다 — 같은 폭·연장이라
#: 두께 비만 곱하면 된다. 관측 원단위로 오는 구조물(옹벽)도 같은 식으로 선다
#: (버림 0.15㎥/m ÷ 0.1 = 폭 1.5m ⇒ 잡석 0.30㎥/m — 확정 3차 ② 의 그 값).
RUBBLE_BASE_THICKNESS_M = 0.2
#: 버림을 뺄 수 있는 칸 — 「기본은 넣고, 빼고 싶으면 뺀다」(사용자 확정 ⑭).
#: 저장 제원에 이 칸이 없으면 **넣는 쪽**이 기본이다.
BLINDING_OPTION_KEYS = ("blinding_concrete", "base_blinding")
#: 표준경사표 — 품셈 13-4-4 [주]⑪. 파일이 없으면 종전 기본값(0.3)으로 돈다.
SLOPE_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry"
SLOPE_PREFIX = "masonry_slope_"
#: 사용자가 덮어쓰는 칸 — 값이 있으면 **그 값이 이긴다**(확정 ⑨).
FACE_SLOPE_KEYS = ("face_slope_ratio",)
#: 성토/절토는 **측점 단면유형(`design.section_mode`) + 설치 측**으로 판정한다
#: (`common_util_structure_face_role` 한 벌). 못 가르는 자리(`both_cut` + 「자동」)만 남아
#: 그때 쓸 칸을 여기 한 곳에 둔다 — **눅여 쓰는 것이 아니라 종전값으로 서고 사유를 적는다.**
DEFAULT_SLOPE_FACE = "성토"
LEGACY_FACE_SLOPE_RATIO = 0.3
@lru_cache(maxsize=1)
def load_slope_table(path: str | None = None) -> dict[str, Any]:
"""표준경사표. 없으면 빈 표 — 그때는 종전 기본값으로 돈다(값을 지어내지 않는다)."""
target = Path(path) if path else None
if target is None:
files = sorted(SLOPE_DIR.glob(SLOPE_PREFIX + "*.json")) if SLOPE_DIR.is_dir() else []
target = files[-1] if files else None
if target is None or not target.is_file():
return {}
return json.loads(target.read_text(encoding="utf-8"))
def face_slope_ratio(
options: dict[str, Any],
*,
wet: bool,
height_m: float,
face: str | None = None,
face_reason: str = "",
table: dict[str, Any] | None = None,
) -> tuple[float, str]:
"""(기울기 1:n 의 n, 근거 문구).
**사용자가 정했으면 값이 이긴다**(확정 ). 정했으면 품셈 13-4-4 []
<표준경사>에서 **직고 · / · 성토/절토** 셋으로 고른다.
자동으로 고른 값을 **저장에 박지 않는다** 정한 없음 0.3 으로 정함
구별돼야 한다(오늘 패널 접힘에서 지킨 것과 같은 자리).
경계값은 ** **이다 원문이 `3` 이라 직고 3.0m `~3` 칸이다.
"""
for key in FACE_SLOPE_KEYS:
given = _num(options.get(key), 0.0)
if options.get(key) is not None and given > 0:
return given, f"사용자 지정 1:{given:g}"
found = table if table is not None else load_slope_table()
steps = found.get("steps_m") or []
bond = "찰쌓기" if wet else "메쌓기"
# ⚠ 성토/절토는 **판정 한 벌**(`common_util_structure_face_role`)이 준다. 못 가르면
# `None` 이 오는데, **성토로 눅이지 않는다** — 임의값이 금액으로 굳으면 안 된다.
# 그때는 종전값으로 서되 **왜 못 갈랐는지**를 근거에 적는다.
if not face:
why = face_reason or "성절토를 가를 근거 없음"
return (
LEGACY_FACE_SLOPE_RATIO,
f"{why} — 표준경사표를 못 골라 종전값 1:{LEGACY_FACE_SLOPE_RATIO} 로 섰음",
)
row = ((found.get("table") or {}).get(bond) or {}).get(face)
if not row:
return LEGACY_FACE_SLOPE_RATIO, f"표준경사표를 못 읽어 종전값 1:{LEGACY_FACE_SLOPE_RATIO}"
index = len(steps)
for i, limit in enumerate(steps):
if height_m <= float(limit):
index = i
break
ratio = float(row[min(index, len(row) - 1)])
label = f"직고 {height_m:g}m" + (f"{steps[index]:g}m" if index < len(steps) else " 7m 초과")
note = f"품셈 13-4-4 [주]⑪ 표준경사 · {bond} {face} · {label} → 1:{ratio:g}"
if face_reason:
note += f" · {face_reason}"
return ratio, note
def is_collected_stone(options: dict[str, Any]) -> bool:
"""이 구조물의 돌을 **캐서 쓰는가**. 정한 적이 없으면 「캔다」(확정 ②)."""
for key in STONE_SUPPLY_KEYS:
raw = options.get(key)
if raw is None:
continue
if str(raw).strip().lower() in STONE_SUPPLY_PURCHASED:
return False
return True
def wants_blinding(options: dict[str, Any]) -> bool:
"""버림 콘크리트를 넣을지. **정한 적이 없으면 넣는다**(사용자 확정 ⑭).
넣음·제외·`false` 적혀 있을 때만 뺀다 칸을 빼기 읽으면
저장해 없는 프로젝트에서 줄이 통째로 사라진다.
"""
for key in BLINDING_OPTION_KEYS:
raw = options.get(key)
if raw is None:
continue
text = str(raw).strip().lower()
if text in {"false", "0", "no", "제외", "안 넣음", "안넣음", "빼기"}:
return False
return True
#: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인).
#: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다**
#: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다.
#: 옛 이름도 함께 본다(다른 곳에서 그 이름으로 넣어 줄 수 있다).
BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm")
#: 메쌓기 높이 — **두 기준이 공존**(2026-09-14 브레인 판정 ㉰ · 지식DB 대조 정합):
#: 임도기술교본 7-3:27 「돌흙막이 높이는 원칙적으로 찰쌓기는 3.0m 이하, 메쌓기는 2.0m 이하」 ↔
#: 품셈 13-4-4 [주]⑪ 원문 L7192 「돌쌓기는 높이 3m 이상이면 전부 또는 하부를 찰쌓기로 한다」.
#: 막지 않고 사유만(판정 ① 「놓기를 막지 말 것」과 한 결).
DRY_MASONRY_GUIDE_M = 2.0
DRY_MASONRY_LIMIT_M = 3.0
def dry_masonry_height_note(height_m: float) -> str | None:
"""메쌓기 높이가 기준을 넘으면 사유 한 줄 — 두 기준을 다 적음."""
if height_m >= DRY_MASONRY_LIMIT_M:
return (
f"⚠ 메쌓기 높이 {height_m:g}m — 품셈 13-4-4 [주]⑪(원문 L7192) 「돌쌓기는 높이 3m 이상이면"
" 전부 또는 하부를 찰쌓기로 한다」 · 임도기술교본 7-3:27 은 메쌓기 2.0m 이하 — 막지 않음(설계자 확인)"
)
if height_m > DRY_MASONRY_GUIDE_M:
return (
f"⚠ 메쌓기 높이 {height_m:g}m — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」를 넘음"
" · 품셈 13-4-4 [주]⑪(원문 L7192)은 3m 이상부터 찰쌓기 — 두 기준이 다름 · 막지 않음"
)
return None
#: 뒷길이 표준표 — 품셈 13-4-4 [주]⑩. 안 고른 돌쌓기는 붙박이 45 가 아니라 이 표의 하한
#: (2026-09-14 브레인 판정 ㉮ 「범위 안에 드는 규격 중 가장 작은 것」). 파일이 없으면 종전 45.
BACK_LENGTH_PREFIX = "masonry_back_length_"
@lru_cache(maxsize=1)
def load_back_length_table() -> dict[str, Any]:
"""뒷길이 표준표. 없으면 빈 표 — 그때는 종전 기본 45㎝ 로 돎."""
files = sorted(SLOPE_DIR.glob(BACK_LENGTH_PREFIX + "*.json")) if SLOPE_DIR.is_dir() else []
return json.loads(files[-1].read_text(encoding="utf-8")) if files else {}
def standard_back_length(*, wet: bool, height_m: float) -> tuple[int, str]:
"""(제안 뒷길이 ㎝, 근거) — 높이·메/찰 칸 범위 안 품셈 규격 중 가장 작은 것.
하한이 규격(25·30·35·45·55·60·75) 없으면 ** 규격** 메쌓기 ~3m 하한 36 45.
내리면 품셈 범위 밖이 . 경계값은 표준경사표와 같이 ** **(높이 3.0m `~3`).
"""
table = load_back_length_table()
steps = table.get("steps_m") or []
bond = "찰쌓기" if wet else "메쌓기"
rows = (table.get("table_cm") or {}).get(bond) or []
if not rows:
return DEFAULT_BACK_LENGTH_CM, f"뒷길이 표준표를 못 읽어 종전 {DEFAULT_BACK_LENGTH_CM}"
index = next((i for i, limit in enumerate(steps) if height_m <= float(limit)), len(steps))
low, high = rows[min(index, len(rows) - 1)]
label = f"직고 {height_m:g}m" + (f"{steps[index]:g}m" if index < len(steps) else " 7m 초과")
span = f"{low}~{high}" if high is not None else f"{low}㎝ 이상"
picked = next(
(
size
for size in sorted(STONE_BACK_LENGTH_TABLE)
if size >= low and (high is None or size <= high)
),
None,
)
head = f"품셈 13-4-4 [주]⑩ 뒷길이 표준(원문 L7172~7181) · {bond} {label} {span}"
if picked is None:
return (
DEFAULT_BACK_LENGTH_CM,
f"{head} 안에 품셈 규격이 없어 종전 {DEFAULT_BACK_LENGTH_CM}",
)
tail = "" if picked == low else f"(하한 {low}㎝ 는 규격 일곱(원문 L7152)에 없어 그 위 첫 규격)"
return picked, f"{head} → 하한 {picked}{tail}"
def _back_length(
options: dict[str, Any], *, wet: bool | None = None, height_m: float | None = None
) -> int:
"""저장된 뒷길이(㎝)를 **그대로** 돌려준다. 안 정했으면 뒷길이 표준표 하한(메/찰·높이를 받으면),
받는 자리(골막이 ) 종전 기본 45.
**접지 않는다.** 품셈 13-4-3·13-4-4 [] 25·30·35·45·55·60·75 **일곱 규격**
주므로 접을 까닭이 없다. 밖의 (40 ) **계수가 없다고 드러낸다**
접으면 다른 규격 계수가 조용히 돈다.
"""
for key in BACK_LENGTH_KEYS:
raw = options.get(key)
if raw is None:
continue
try:
value = int(float(raw))
except (TypeError, ValueError):
continue
# ⚠ **접지 않는다.** 앞서 「가장 가까운 위 칸」으로 접고 있었는데, 그러면 40㎝ 가
# 45㎝ 계수로 **조용히** 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측).
# 표에 없으면 그 값을 그대로 돌려주고, 계수를 고르는 쪽이 「없다」고 드러낸다.
return value
if wet is None or height_m is None:
return DEFAULT_BACK_LENGTH_CM
return standard_back_length(wet=wet, height_m=height_m)[0]
def back_length_default_note(
options: dict[str, Any],
back_cm: int,
*,
wet: bool | None = None,
height_m: float | None = None,
) -> str | None:
"""뒷길이를 안 골라 기본으로 섰을 때의 한 줄 — 표 사유·구조물도 그림이 **같은 말**을 씀."""
if any(options.get(key) not in (None, "") for key in BACK_LENGTH_KEYS):
return None
if wet is not None and height_m is not None:
return (
f"⚠ 뒷길이를 안 골라 {standard_back_length(wet=wet, height_m=height_m)[1]} 로 섰습니다 "
"— 구조물 상세에서 고르면 바뀜 · 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
)
return (
f"⚠ 뒷길이를 안 골라 기본 {back_cm}㎝ 로 섰습니다 "
"— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
)
#: 벽 두께를 **사용자가 덮어쓰는 칸** — 비우면 실무 구조물도 식으로 돈다(확정 2차 ② 후반
#: 「사용자가 값을 바꿀 수 있게」). 야면석 계수·채움 강도와 같은 자리다.
WALL_THICKNESS_KEYS: tuple[str, str] = ("thickness_top_m", "thickness_bottom_m")
def wall_thickness(
options: dict[str, Any], *, back_cm: float, height_m: float
) -> tuple[float, float, str]:
"""(상부 두께 m, 하부 두께 m, 근거 문구) — **사용자가 넣은 값이 식을 이긴다.**
칸은 정함이다 자동으로 나온 값을 저장에 박지 않는다(`face_slope_ratio`
같은 태도). 상부만 넣으면 하부는 ** 상부 위에서** 식을 이어 간다 모양
(아래로 벌어지는 기울기) 사용자 위에 그대로 얹는 것이 자연스럽다.
"""
def given(key: str) -> float | None:
raw = options.get(key)
if raw in (None, ""):
return None
try:
value = float(raw)
except (TypeError, ValueError):
return None
return value if value > 0 else None
constants = STONE_MASONRY
back_m = back_cm / 100.0
top_given = given(WALL_THICKNESS_KEYS[0])
top = top_given if top_given is not None else back_m + constants["thickness_top_add_m"]
rise = constants["thickness_slope_per_m"] * max(
height_m - constants["thickness_height_base_m"], 0.0
)
bottom_given = given(WALL_THICKNESS_KEYS[1])
bottom = bottom_given if bottom_given is not None else top + rise
top_basis = (
f"상부 {top:.2f}(사용자 입력)"
if top_given is not None
else f"상부 {top:.2f}(뒷길이 {back_cm:g}㎝ + {constants['thickness_top_add_m']:g})"
)
bottom_basis = (
f"하부 {bottom:.2f}(사용자 입력)"
if bottom_given is not None
else (
f"하부 {bottom:.2f}(상부 + {constants['thickness_slope_per_m']:g}"
f"×(H{constants['thickness_height_base_m']:g}))"
)
)
return top, bottom, f"실무 구조물도 식 — {top_basis} · {bottom_basis}"
+152 -10
View File
@@ -30,16 +30,32 @@ from B06_Section.B06_Section_Repository import (
get_longitudinal_section,
get_workflow_route_context,
)
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput
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_HaulSummary import build_table as build_haul_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan
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 (
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_HaulSummary import summary_input_rows
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table
from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes
from B08_Quantity.B08_Quantity_Engine_SlopeLength import road_surface_area, station_slopes
from B08_Quantity.B08_Quantity_Provenance import quantity_provenance
from common_util.common_util_project_settings import (
CONCRETE_PLACING_METHODS,
@@ -47,16 +63,20 @@ 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,
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import run_with_connection
from config.config_system_design import (
EARTHWORK_CONVERSION_FACTORS,
EARTHWORK_CONVERSION_PUMSEM_C_RANGES,
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
)
logger = logging.getLogger(__name__)
@@ -86,10 +106,13 @@ 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)
# 도쟈 한계거리 — 지금 값·기본값·근거를 함께 보인다(유토곡선 장비 경계, 2026-09-13 판정).
table["haul_limit_choice"] = haul_limit_choice(settings)
# 품셈 암종별 범위 — **화면 안내용**이다. 정의처가 서버 한 곳이라 내려보내 쓴다
# (프론트에 다시 적으면 두 벌이 되어 갈린다).
table["conversion_factor_pumsem_ranges"] = [
@@ -97,15 +120,21 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
for name, low, high in EARTHWORK_CONVERSION_PUMSEM_C_RANGES
]
# 사면 계열은 저장된 설계선에서 유도한다.
slope = build_slope_table(station_slopes(designs))
slopes = station_slopes(designs)
slope = build_slope_table(slopes)
table["slope"] = slope
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"] = [
@@ -138,14 +167,43 @@ 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={
key: application_ratio(settings, key)
for key in (settings.get("application_ratios_pct") or {})
},
# 노체다짐 — 기본 꺼짐. 켠 프로젝트에서만 줄이 선다(2026-09-13 판정).
subgrade_compaction_enabled=bool(settings.get("subgrade_compaction_enabled")),
# 면고르기 면적 덮어쓰기 — 비우면 파종 면적(2026-09-14 판정 Ⓐ).
face_dressing_area_m2={
"fill": settings.get("face_dressing_fill_area_m2"),
"cut": settings.get("face_dressing_cut_area_m2"),
},
)
)
# 면고르기 갈래 고르기 — 선택지는 서버 한 곳(원문 표 두 벌) · 제안값 없음(판정 Ⓒ).
table["face_dressing_choices"] = {
"cut": list(FACE_DRESSING_CUT_CLASSES),
"fill": list(FACE_DRESSING_FILL_CLASSES),
# 성토면 제안(회색 · [제안값 넣기]) — 비워 두면 여전히 「안 정함」(브레인 ②).
"fill_suggested": {
"value": FACE_DRESSING_FILL_SUGGESTED[0],
"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(
@@ -163,6 +221,15 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
# 임목파쇄 — 기본 꺼짐. 켠 프로젝트에서만 줄이 선다(확정 5차 5번).
settings.get("wood_chipping_enabled"),
settings.get("wood_chipping_volume_m3"),
# 표토제거 대상 「노면」 면적 — 사면과 같은 측점 설계에서(2026-09-13 판정 「실무대로」).
road_surface_area(slopes),
# 대상 — 기본 노면 + 절토(별표2 문언) · 노면만은 설계자가 고름(2026-09-14 판정).
topsoil_target(settings) == "road_only",
# 임목폐기물 — 현장 조사값 · 수동 처리단가 · 분리발주(2026-09-14 판정).
settings.get("tree_waste") or {},
settings.get("tree_waste_unit_price_krw_per_ton"),
settings.get("waste_separate_order"),
settings.get("tree_waste_root_method"),
)
method, method_is_default = concrete_placing_method(settings)
# ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다.
@@ -191,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()}
@@ -416,6 +483,8 @@ class QuantitySettingsBody(BaseModel):
# 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`.
# 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정).
material_supply: dict[str, Any] | None = None
# 자재별 할증률 — `{이름 규격: %}`. 품셈 1-3-1 「표의 값 이내」라 0~표값만 씀(엔진이 거름).
material_surcharge: dict[str, Any] | None = None
# 콘크리트 타설 방식. `""` 는 「안 정함」으로 되돌리는 뜻이라 서버가 None 으로 만든다.
concrete_placing_method: str | None = None
# 표토제거 두께(m). 품셈이 정하는 값이 아니라 설계 입력이다(9-15 [주]②).
@@ -434,17 +503,39 @@ class QuantitySettingsBody(BaseModel):
structure_trench_water: str | None = None
# 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다.
topsoil_haul_distance_m: float | None = None
# 표토제거 대상 — "road_only" 는 노면만. `""` 는 기본(노면 + 절토, 별표2 문언)으로 되돌림.
topsoil_target: str | None = None
# 임목축적 등급 — "소림"·"중림"·"밀림"(품셈 9-21 [주]①). `""` 는 「안 정함」이다.
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
face_dressing_cut_area_m2: float | None = None
# 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다.
frame_material: dict[str, Any] | None = None
# 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜면 줄이 서고, 부피를 넣으면 값이 선다.
wood_chipping_enabled: bool | None = None
wood_chipping_volume_m3: float | None = None
# 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄.
subgrade_compaction_enabled: bool | None = None
# 임목폐기물 — 현장 조사값 넷(통째로 갈아 끼움) · 수동 처리단가 · 분리발주.
tree_waste: dict[str, float | None] | None = None
tree_waste_unit_price_krw_per_ton: float | None = None
waste_separate_order: bool | None = None
# 뿌리 산정법 — "root_ball"(뿌리분 체적 × 1,300) · `""` 는 기본(분배비 15/85)으로 되돌림.
tree_waste_root_method: str | None = None
# 토량환산계수(다짐) — `{갈래: {"compacted": C, "reason": 사유}}`.
# ⚠ **기본값을 복사해 넣지 않는다** — 안 고른 갈래는 키가 없어야 정본이 선다.
# 빈 dict 는 「전부 기본값으로 되돌림」이라 통째로 갈아 끼운다.
conversion_factors_override: dict[str, Any] | None = None
# 도쟈 한계거리(m) — `None` 은 기본값(60 m). 종무대 20 m 보다 커야 한다(도쟈 몫이 사라짐).
dozer_haul_limit_m: float | None = None
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
@@ -456,6 +547,10 @@ NULLABLE_SETTING_KEYS = (
"rubble_base_thickness_m",
"topsoil_haul_distance_m",
"wood_chipping_volume_m3",
"dozer_haul_limit_m",
"tree_waste_unit_price_krw_per_ton",
"face_dressing_fill_area_m2",
"face_dressing_cut_area_m2",
)
@@ -482,10 +577,43 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
for key in NULLABLE_SETTING_KEYS:
if key in body.model_fields_set:
values[key] = getattr(body, key)
dozer_limit = values.get("dozer_haul_limit_m")
free_haul = dict(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)["free_haul"] or 0.0
if dozer_limit is not None and dozer_limit <= free_haul:
# 조용히 기본값으로 돌리지 않는다 — 넣은 값이 안 쓰이는 줄 모른다.
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": f"도쟈 한계거리는 종무대 {free_haul:g} m 보다 커야 합니다.",
},
)
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] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침
if "concrete_placing_method" in values:
method = values["concrete_placing_method"]
# 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리).
values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS else None
if "tree_waste" in values:
# 양수만 남긴다 — 빈 칸은 키가 없어야 「안 넣음」으로 읽힌다.
values["tree_waste"] = {
key: float(value)
for key, value in (values["tree_waste"] or {}).items()
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0
}
if "tree_waste_root_method" in values:
method = values["tree_waste_root_method"]
values["tree_waste_root_method"] = method if method == "root_ball" else None
if "topsoil_target" in values:
# 기본(노면 + 절토)은 저장하지 않는다 — 「안 정함」과 같게 둬 법 문언이 선다.
target = values["topsoil_target"]
values["topsoil_target"] = target if target == "road_only" else None
if "conversion_factors_override" in values:
# 아는 갈래·양수만 남긴다. 사유는 값이 있을 때만 따라간다(계산에는 안 쓴다).
cleaned: dict[str, Any] = {}
@@ -509,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 화면에서 걸린 자리).
@@ -519,7 +648,11 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
(
"rock_methods",
"material_supply",
"material_surcharge",
"concrete_placing_method",
"topsoil_target",
"tree_waste",
"tree_waste_root_method",
"ancillary_counts",
# 고른 계수를 **기본값으로 되돌릴 길**이 있어야 한다 — 병합이면 못 지운다.
"conversion_factors_override",
@@ -532,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
+295 -31
View File
@@ -23,6 +23,7 @@ from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B06_Section.B06_Section_Repository import get_cross_section_designs
@@ -56,8 +57,19 @@ router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
def _collect_structures(
project_root: str,
designs: list[dict[str, Any]] | None = None,
) -> tuple[list[dict[str, Any]], dict[str, str], list[str]]:
"""전개 대상 구조물·타입명·건너뛴 사유를 함께 낸다."""
"""전개 대상 구조물·타입명·건너뛴 사유를 함께 낸다.
정본 둘을 읽음(A1, 2026-09-14) `structures.json` + `pipe_points.json`(계곡 통과 시설).
앞서 뒤엣것을 읽어 집수정·날개벽· 유입/유출 기슭막이·독립 기슭막이·물넘이포장이
원단위·인계·내역에 줄도 섰음.
지점 종류(`managed_by`) `structures.json` 저장분으로 남아 있어도
지점 정본이 주인이라 세지 않음(구조물 집계표와 같은 규칙).
"""
from B08_Quantity.B08_Quantity_Engine_Pipe import extra_walls_from_designs, facility_structures
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
_revision, items = load_structures(project_root)
types = structure_type_map()
targets: list[dict[str, Any]] = []
@@ -71,6 +83,11 @@ def _collect_structures(
skipped.append(f"{type_id}: 레지스트리에 없는 타입")
continue
names[type_id] = definition.name
if definition.managed_by:
skipped.append(
f"{definition.name}: 관 지점 정본이 주인 — 구조물 목록 옛 저장분은 안 셈"
)
continue
if definition.design_owner:
skipped.append(
f"{definition.name}: {definition.design_owner} 가 이미 셈 — 중복 계상 방지"
@@ -85,6 +102,14 @@ def _collect_structures(
# 여기서 빼지 않으면 **같은 시설이 두 줄로** 나간다.
continue
targets.append(payload)
points = read_pipe_points_file(pipe_points_path_in(Path(project_root)))
# 다단 추가 기슭막이(④) — 횡단 설계에 남은 「선 다단 벽 목록」도 줄로 세움.
extra_walls = extra_walls_from_designs(designs)
for row in facility_structures([point.as_dict() for point in points], extra_walls):
for type_id in (row["type_id"], row.get("attachment_parent_type")):
if type_id in types:
names[type_id] = types[type_id].name
targets.append(row)
return targets, names, sorted(set(skipped))
@@ -124,6 +149,33 @@ async def _section_modes(project_id: UUID) -> dict[float, str]:
return section_modes_from_designs(designs)
def material_table_for(
unit_table: dict[str, Any],
settings: dict[str, Any],
preparation_table: dict[str, Any] | None,
) -> dict[str, Any]:
"""자재총괄 **한 벌** — 화면(`material-summary`)·인계(`handoff`)가 같이 부름(브레인 판정).
규준틀 재료는 **준비공 개소가 뒤에야** 서므로 준비공 표를 받아 여기서 얹음. 종전엔 인계만
얹어 화면 6 · 인계 9줄로 갈림 화면에서 규준틀 재료의 관급구분·할증을 만짐.
화면 쪽에 따로 복사하지 벌이 되면 갈림.
값은 **제안값(실무 관측)**이고 산출 조건에서 고칠 있음 사실이 사유에 적힘.
"""
frame_rows = [
row
for row in ((preparation_table or {}).get("rows") or [])
if str(row.get("item") or "").endswith("규준틀")
]
return build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
surcharge_overrides=settings.get("material_surcharge") or {},
# 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥).
concrete_placing_method=settings.get("concrete_placing_method"),
extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}),
)
@router.get("/{project_id}/quantity/material-summary")
async def get_material_summary(project_id: UUID) -> JSONResponse:
"""구조물 원단위와 자재총괄을 **한 응답**으로 낸다.
@@ -141,7 +193,7 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
)
try:
structures, names, skipped = _collect_structures(project_root)
structures, names, skipped = _collect_structures(project_root, await _designs(project_id))
except Exception:
logger.exception("B08 구조물 정본 읽기 실패: project_id=%s", project_id)
return JSONResponse(
@@ -161,12 +213,9 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
# 프로젝트에 박힌 양식(PLAN 4장) — 같은 까닭.
structure_templates=project_templates(project_root),
)
material_table = build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
# 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥).
concrete_placing_method=settings.get("concrete_placing_method"),
)
# 인계와 같은 한 벌 — 규준틀 재료가 준비공 표를 따라 서므로 토공 표를 받음.
earthwork = await _earthwork_tables(project_id)
material_table = material_table_for(unit_table, settings, earthwork.get("preparation"))
# 묶음으로 서는 구조물의 조각을 화면에도 보인다 — 코드만으로는 사람이 검증 못 한다.
handoff = build_handoff(unit_quantity_table=unit_table)
composite = [
@@ -194,6 +243,161 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
return JSONResponse(content=body)
@router.get("/{project_id}/quantity/structure-summary")
async def get_structure_summary(project_id: UUID) -> JSONResponse:
"""**구조물 집계표**(PLAN 2장) — 측점별 한 줄 · 종류별 표 · 칸마다 출처(자동·사용자·라이브러리).
값을 셈하지 않음 정본 (`structures.json`·`pipe_points.json`) B06 연장을 읽기만.
"""
from B08_Quantity.B08_Quantity_Engine_StructureSummary import (
USER_CELLS_KEY,
build_summary,
pipe_lengths_from_designs,
)
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_of
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
project_root = resolve_stored_project_path(stored_path)
revision, items = load_structures(project_root)
points = read_pipe_points_file(pipe_points_path_in(Path(project_root)))
except Exception:
logger.exception("B08 구조물 집계표 — 정본 읽기 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "구조물 정본을 읽지 못했습니다."},
)
types = structure_type_map()
embedded = project_templates(project_root)
templates = {type_id: template_of(type_id, embedded) or {} for type_id in types}
body = build_summary(
[item.model_dump() for item in items],
[point.as_dict() for point in points],
types,
templates,
pipe_lengths_from_designs(await _designs(project_id)),
quantity_settings(project_root).get(USER_CELLS_KEY) or {},
)
return JSONResponse(
content={"status": "success", "revision": revision, "project_id": str(project_id), **body}
)
async def _designs(project_id: UUID) -> list[dict[str, Any]]:
"""현재 노선의 저장된 횡단 설계. 못 읽으면 빈 목록 — 관 연장이 빈칸으로 섬(0 아님)."""
try:
context = await run_with_connection(get_workflow_route_context, project_id)
route_id = int((context or {}).get("route_id") or 0)
return await run_with_connection(get_cross_section_designs, route_id) if route_id else []
except Exception:
logger.exception("B08 구조물 집계표 — 횡단 설계 조회 실패: project_id=%s", project_id)
return []
class StructureSummaryEdit(BaseModel):
"""집계표 칸 하나 — 빈 값(null·"")은 그 칸을 지움."""
model_config = ConfigDict(extra="forbid")
id: str = Field(min_length=1, max_length=200)
key: str = Field(min_length=1, max_length=100)
value: float | str | None = None
class StructureSummaryEditRequest(BaseModel):
"""[저장] 한 번에 보내는 손 고침 — 읽어 간 구조물 판번호와 함께."""
model_config = ConfigDict(extra="forbid")
base_revision: int = Field(ge=0)
edits: list[StructureSummaryEdit] = Field(max_length=500)
def _summary_error(status: int, message: str) -> JSONResponse:
return JSONResponse(status_code=status, content={"status": "error", "message": message})
@router.put("/{project_id}/quantity/structure-summary")
async def put_structure_summary(
project_id: UUID, payload: StructureSummaryEditRequest
) -> JSONResponse:
"""집계표 손 고침을 **정본에 바로 씀**(브레인 판정) + 산출 조건에 「손댄 칸」 표.
덮개층을 두지 않음 `structures.json`·`pipe_points.json` 도면·수량의 (지침 5).
맞춰 뒤에 칸이라도 틀리면 아무것도 (구조물 판번호가 어긋나도 409).
놓기 (자리·길이·높이·관경) 거절 ·종점과 벌이라 B05 .
"""
import asyncio
from B05_Profile.B05_Profile_Structures_Repository import (
StructureRevisionConflict,
save_structures,
)
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
from B08_Quantity.B08_Quantity_Engine_StructureSummary import (
USER_CELLS_KEY,
apply_edits,
pipe_row_id,
prune_marks,
)
from common_util.common_util_drainage_pipes import pipe_points_path_in
from common_util.common_util_json import atomic_write_json
from common_util.common_util_project_settings import save_section
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
project_root = resolve_stored_project_path(stored_path)
revision, items = load_structures(project_root)
path = pipe_points_path_in(Path(project_root))
document = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
except Exception:
logger.exception("B08 구조물 집계표 저장 — 정본 읽기 실패: project_id=%s", project_id)
return _summary_error(500, "구조물 정본을 읽지 못했습니다.")
if revision != payload.base_revision:
return _summary_error(409, "그 사이 구조물이 바뀌었습니다 — 표를 다시 불러와 고칠 것.")
structures = [item.model_dump() for item in items]
points = [point for point in document.get("points") or [] if isinstance(point, dict)]
settings = quantity_settings(project_root)
try:
changed, marks = apply_edits(
structures,
points,
structure_type_map(),
[edit.model_dump() for edit in payload.edits],
settings.get(USER_CELLS_KEY) or {},
)
instances = [StructureInstance.model_validate(item) for item in structures]
except (LookupError, ValueError) as exc:
return _summary_error(422, str(exc))
new_revision = revision
pipe_ids = {pipe_row_id(point) for point in points}
try:
if any(row_id not in pipe_ids for row_id in changed):
new_revision = await asyncio.to_thread(
save_structures, project_root, instances, base_revision=revision
)
if any(row_id in pipe_ids for row_id in changed):
# 관 지점 파일은 옵션만 바꿔 **그대로** 씀 — 노선 지문·좌표·다른 칸은 손대지 않음.
await asyncio.to_thread(atomic_write_json, path, {**document, "points": points})
except StructureRevisionConflict:
return _summary_error(409, "그 사이 구조물이 바뀌었습니다 — 표를 다시 불러와 고칠 것.")
except ValueError as exc:
return _summary_error(422, str(exc))
await asyncio.to_thread(
save_section,
project_root,
"quantity",
{USER_CELLS_KEY: prune_marks(marks, structures, points)},
replace_keys=[USER_CELLS_KEY],
)
return JSONResponse(
content={"status": "success", "revision": new_revision, "changed_rows": len(changed)}
)
async def project_haul_inputs(project_id: UUID) -> dict[str, Any]:
"""유토곡선(B06)이 받아야 할 **구조물 몫** — 채집석 공제 · 구조물 잔토.
@@ -204,7 +408,7 @@ async def project_haul_inputs(project_id: UUID) -> dict[str, Any]:
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
project_root = resolve_stored_project_path(stored_path)
structures, names, _skipped = _collect_structures(project_root)
structures, names, _skipped = _collect_structures(project_root, await _designs(project_id))
settings = quantity_settings(project_root)
unit_table = build_unit_table(
structures,
@@ -247,39 +451,22 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
structures, names, skipped = _collect_structures(project_root)
structures, names, skipped = _collect_structures(project_root, await _designs(project_id))
settings = quantity_settings(project_root)
modes = await _section_modes(project_id)
unit_table = build_unit_table(
structures,
names,
await _section_modes(project_id),
modes,
await _ground_types(project_id),
settings.get("rubble_base_thickness_m"),
structure_formulas=settings.get("structure_formula_overrides"),
structure_templates=project_templates(project_root),
)
material_table = build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
concrete_placing_method=settings.get("concrete_placing_method"),
)
# 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다.
earthwork = await _earthwork_tables(project_id)
# 규준틀 재료 — **개소가 선 뒤에야 설 수 있어** 준비공 표를 받은 다음 자재 축에 얹는다.
# ⚠ 값은 **제안값(실무 관측)**이고 산출 조건에서 고칠 수 있다 — 그 사실이 줄 사유에 적힌다.
frame_rows = [
row
for row in ((earthwork.get("preparation") or {}).get("rows") or [])
if str(row.get("item") or "").endswith("규준틀")
]
material_table = build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
concrete_placing_method=settings.get("concrete_placing_method"),
extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}),
)
# 자재총괄 — 화면과 같은 한 벌(규준틀 재료 포함).
material_table = material_table_for(unit_table, settings, earthwork.get("preparation"))
handoff = build_handoff(
summary_table=earthwork.get("summary"),
@@ -307,6 +494,15 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
structure_trench_water=settings.get("structure_trench_water"),
# 임목축적 등급 — 지장목제거 뿌리뽑기(제근 9-21)의 품 갈래. 안 넣으면 B09 가 후보를 보임.
stand_volume_class=settings.get("stand_volume_class"),
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질. 비면 그 줄이 입력 사유로 막힘.
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),
)
handoff["summary"] = summarize(handoff)
handoff["skipped_structures"] = skipped
@@ -314,6 +510,74 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
return JSONResponse(content=handoff)
def _priced_sheets(
project_root: str,
unit_table: dict[str, Any],
section_modes: dict[float, str] | None,
settings: dict[str, Any],
) -> list[dict[str, Any]]:
"""양식 일위대가로 셀 구조물도 장 — 구조물도 탭과 **같은 접기**(`build_standard_sheets` →
`apply_templates`) 세움. 인계와 내역 금액이 목록을 ."""
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import priced_sheets
from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates, template_of
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
embedded = project_templates(project_root)
payload = build_standard_sheets(unit_table, section_modes)
apply_templates(payload, settings, embedded)
sheets = payload.get("sheets") or []
templates = {
type_id: template
for type_id in {str(sheet.get("type_id") or "") for sheet in sheets}
if (template := template_of(type_id, embedded))
}
return priced_sheets(sheets, templates, settings.get(ROWS_KEY))
async def structure_bill_prices(project_id: UUID, build: Any) -> dict[str, dict[str, Any]]:
"""B09 내역이 부름 — 구조물도 호표 금액. **B09 가 넘긴 단가표**로 B08 일위대가 엔진을 돌림.
금액 정본은 B09 여기서 단가표를 따로 짓지 않음. 수동 단가(프로젝트 ) 입력으로 .
인계(`get_handoff`) **같은 전개** 요청이라 벌을 나눔.
"""
# ponytail: 인계와 전개를 두 번 돎 — 느려지면 인계 응답에 장 목록을 실어 한 번으로.
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import available_templates
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import price_sheets
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import MANUAL_KEY, ROWS_KEY, with_rows
from B09_Estimation.B09_Estimation_UnitPrice import find_variant_code
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
project_root = resolve_stored_project_path(stored_path)
structures, names, _skipped = _collect_structures(project_root, await _designs(project_id))
settings = quantity_settings(project_root)
modes = await _section_modes(project_id)
unit_table = build_unit_table(
structures,
names,
modes,
await _ground_types(project_id),
settings.get("rubble_base_thickness_m"),
structure_formulas=settings.get("structure_formula_overrides"),
structure_templates=project_templates(project_root),
)
entries = _priced_sheets(project_root, unit_table, modes, settings)
if not entries:
return {}
row_edits = settings.get(ROWS_KEY) or {}
library = [
with_rows(template, row_edits.get(template.get("type_id")))
for template in available_templates(project_root)
]
return price_sheets(
entries,
build.book,
lambda code, value: find_variant_code(code, value, build),
library,
settings.get(MANUAL_KEY) or {},
)
def _pipe_table(
project_root: str,
pipe_lengths: list[dict[str, Any]],
@@ -0,0 +1,219 @@
"""B08 라이브러리 — STmate 출력 엑셀에서 **고정형 항목 뽑아 넣기** (PLAN 4장 · 2026-09-14 브레인 판정).
걸음:
[읽기] 파일을 올리면 읽은 호표 ·구성 · 읽은 사유를 돌려줌 **아무것도 **.
사용자가 수를 보고 넣을지 정함(판정 읽은 수를 보이고 묻는 걸음).
[넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 서버가 **파일을 다시 읽어** 개인 단에 .
브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5) · 개인 단만(판정 ) · 종류당 하나라 같은 종류 것은
덮어씀(판정 ) · 종류는 사용자가 고름 이름으로 자동으로 붙임(판정 ).
[복제해서 것으로](PLAN 4) 기본·회사 항목을 개인 단에 베낌. 프로젝트 작업본은 바꿈.
(구조물도 라우터가 700줄에 닿아 개인 단으로 넣는 창구를 파일에 모음.)
"""
import asyncio
import io
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
router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
BASE = "/{project_id}/quantity/structure-sheets/library/stmate"
#: 출력 엑셀은 수백 KB — 넉넉히 두되 끝없이 받지 않음.
MAX_BYTES = 20 * 1024 * 1024
def _error(status: int, message: str, **extra: Any) -> JSONResponse:
return JSONResponse(
status_code=status, content={"status": "error", "message": message, **extra}
)
async def _read(file: UploadFile) -> dict[str, Any] | JSONResponse:
from B08_Quantity.B08_Quantity_Engine_StmateRecipe import read_recipes
data = await file.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
return _error(413, "파일이 너무 큼(20MB 넘음)")
return await asyncio.to_thread(read_recipes, io.BytesIO(data))
@router.post(BASE + "/read")
async def read_stmate_recipes(
project_id: UUID,
file: UploadFile = File(...),
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""① 읽기만 — 호표 목록(차례·명칭·규격·단위·구성 줄 수)과 못 읽은 사유."""
read = await _read(file)
if isinstance(read, JSONResponse):
return read
hopyo = [
{
"no": h["no"],
"name": h["name"],
"spec": h["spec"],
"unit": h["unit"],
"rows": len(h["rows"]),
"percent_rows": sum(1 for row in h["rows"] if row["unit"] == "%"),
"contract_rows": h["contract_rows"],
}
for h in read["hopyo"]
]
return JSONResponse(
content={
"status": "success",
"file_name": file.filename or "",
"project": read["project"],
"hopyo": hopyo,
"counts": {"hopyo": len(hopyo), "rows": sum(h["rows"] for h in hopyo)},
"problems": read["problems"],
}
)
@router.post(BASE + "/save")
async def save_stmate_recipe(
project_id: UUID,
file: UploadFile = File(...),
hopyo_no: int = Form(...),
type_id: str = Form(..., min_length=1, max_length=100),
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""② 넣기 — 서버가 다시 읽은 호표 하나를 고정형 항목으로 **로그인한 사람 개인 단**에 씀."""
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import save_item_personal, tier_dirs
folder = tier_dirs(session.get("company_id"), session.get("user_id")).get("personal")
if folder is None:
return _error(403, "개인 라이브러리는 회사에 속한 사용자만 씁니다.")
if type_id not in structure_type_map():
return _error(400, f"모르는 구조물 종류: {type_id}")
read = await _read(file)
if isinstance(read, JSONResponse):
return read
hopyo = next((h for h in read["hopyo"] if h["no"] == hopyo_no), None)
if hopyo is None:
return _error(404, f"{hopyo_no}호표를 읽지 못함", problems=read["problems"])
item = recipe_item(
hopyo, type_id=type_id, file_name=file.filename or "", project=read["project"]
)
code = await asyncio.to_thread(save_item_personal, folder, item)
return JSONResponse(
content={
"status": "success",
"code": code,
"name": item["name"],
"rows": len(item["rows"]),
"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 ""})
@@ -44,6 +44,7 @@ def project_structure_sheets(
project_root: str,
section_modes: dict[float, str] | None,
ground_types: dict[float, str] | None = None,
designs: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""구조물도 장 목록 — **원단위 탭(`material-summary`)과 같은 입력**으로 전개해 접는다.
@@ -52,6 +53,7 @@ def project_structure_sheets(
늦게 부른다(함수 import) B08 B05 부르고 B05 다시 B07 부를 있어
모듈 위에서 부르면 맞물린다.
"""
from B08_Quantity.B08_Quantity_Engine_StructureFigure import attach_figures
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates
from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates
@@ -59,7 +61,8 @@ def project_structure_sheets(
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
from common_util.common_util_project_settings import quantity_settings
structures, names, skipped = _collect_structures(project_root)
# 설계를 넘겨야 다단 추가 기슭막이 장이 원단위 탭과 같이 섬(④).
structures, names, skipped = _collect_structures(project_root, designs)
settings = quantity_settings(project_root)
templates = project_templates(project_root)
unit_table = build_unit_table(
@@ -74,6 +77,8 @@ def project_structure_sheets(
payload = build_standard_sheets(unit_table, section_modes)
# 양식이 있는 종류는 줄마다 식·설명·반올림·갈 곳을 실음(PLAN 3장 ④ · 명세 13장).
apply_templates(payload, settings, templates)
# 상단 그림(읽기 전용 SVG 자료) — 못 그리는 장은 까닭(PLAN 12장).
attach_figures(payload, settings.get("rubble_base_thickness_m"))
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
payload["skipped_structures"] = skipped
return payload
@@ -84,11 +89,12 @@ async def _sheets_of(project_id: UUID, project_root: str) -> dict[str, Any]:
단면유형·지반 갈래는 원단위 창구(`B08_Quantity_Router_Material`) 그대로 .
"""
from B08_Quantity.B08_Quantity_Router_Material import _ground_types, _section_modes
from B08_Quantity.B08_Quantity_Router_Material import _designs, _ground_types, _section_modes
modes = await _section_modes(project_id)
ground = await _ground_types(project_id)
return await asyncio.to_thread(project_structure_sheets, project_root, modes, ground)
designs = await _designs(project_id)
return await asyncio.to_thread(project_structure_sheets, project_root, modes, ground, designs)
def _not_found() -> JSONResponse:
@@ -256,6 +262,13 @@ async def put_structure_sheet_spec(
try:
revision, stored = await asyncio.to_thread(load_structures, project_root)
updated, changed = apply_spec(stored, member_ids, spec)
outside = member_ids - {str(item.structure_id) for item in stored}
if outside:
# 관 지점 시설(A1)은 `pipe_points.json` 이 정본 — 조용히 안 먹히지 않게 알림.
notes.append(
f"관 지점 시설 {len(outside)}개소(배수관 기슭막이 등)는 이 화면에서 제원을 못 적음"
" — B05 배수 시설·구조물 집계표에서 고칠 것"
)
new_revision = await asyncio.to_thread(
save_structures, project_root, updated, base_revision=payload.base_revision
)
@@ -303,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},
}
)
@@ -313,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}$")
@@ -323,9 +337,9 @@ async def put_structure_library_import(
payload: LibraryImportRequest,
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""고른 항목을 **프로젝트 작업본에 박고** 그 종류의 고친 식 비움.
"""고른 항목을 **프로젝트 작업본에 박고** 그 종류의 고친 식·줄 조합·수동 단가를 비움.
고친 식을 비우는 까닭 양식과 차례가 맞을 있음. 묻는 것은 화면 .
비우는 까닭 차례에 묶였는데 양식과 차례가 맞을 있음. 묻는 것은 화면 .
"""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
find_item,
@@ -333,6 +347,7 @@ async def put_structure_library_import(
tier_dirs,
)
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import MANUAL_KEY, ROWS_KEY
from common_util.common_util_project_settings import quantity_settings, save_section
project_root = await _project_root(project_id)
@@ -346,23 +361,22 @@ async def put_structure_library_import(
content={"status": "error", "message": "가져올 양식 항목을 찾지 못했습니다."},
)
await asyncio.to_thread(import_item, project_root, item, payload.tier)
current = quantity_settings(project_root).get(OVERRIDES_KEY) or {}
cleared = len(current.get(payload.type_id) or {})
if cleared:
rest = {key: rows for key, rows in current.items() if key != payload.type_id}
await asyncio.to_thread(
save_section,
project_root,
"quantity",
{OVERRIDES_KEY: rest},
replace_keys=[OVERRIDES_KEY],
)
settings = quantity_settings(project_root)
current = {key: settings.get(key) or {} for key in (OVERRIDES_KEY, ROWS_KEY, MANUAL_KEY)}
touched = [key for key, value in current.items() if payload.type_id in value]
if touched:
rest = {
key: {k: v for k, v in current[key].items() if k != payload.type_id} for key in touched
}
await asyncio.to_thread(save_section, project_root, "quantity", rest, replace_keys=touched)
return JSONResponse(
content={
"status": "success",
"code": payload.code,
"imported_from": payload.tier,
"cleared_formulas": cleared,
"cleared_formulas": len(current[OVERRIDES_KEY].get(payload.type_id) or {}),
"cleared_unit_price_rows": payload.type_id in current[ROWS_KEY],
"cleared_manual_prices": len(current[MANUAL_KEY].get(payload.type_id) or {}),
}
)
@@ -381,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,
@@ -399,16 +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()
@@ -421,8 +455,12 @@ async def put_structure_library_personal(
status_code=404,
content={"status": "error", "message": "양식이 있는 구조물도 장을 찾지 못했습니다."},
)
overrides = (quantity_settings(project_root).get(OVERRIDES_KEY) or {}).get(type_id)
code = await asyncio.to_thread(save_personal, folder, template, overrides)
settings = quantity_settings(project_root)
overrides = (settings.get(OVERRIDES_KEY) or {}).get(type_id)
# ⛔ 수동 단가(`MANUAL_KEY`)는 안 넘김 — 프로젝트의 값(브레인 판정).
rows = (settings.get(ROWS_KEY) or {}).get(type_id)
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 {})})
@@ -447,51 +485,193 @@ async def _price_build(project_id: UUID) -> Any:
return await _build_for(project_id)
async def _sheet_template(
project_id: UUID, project_root: str, sheet_key: str
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
"""장 하나와 그 양식(프로젝트에 박힌 것 → 프로그램 기본)."""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_of
sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
picked = next((s for s in sheets if s.get("key") == sheet_key), None)
if picked is None:
return None, None
return picked, template_of(str(picked.get("type_id") or ""), project_templates(project_root))
def _no_sheet() -> JSONResponse:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "그 구조물도 장을 찾지 못했습니다."},
)
async def _price_build_or_error(project_id: UUID) -> tuple[Any, JSONResponse | None]:
try:
return await _price_build(project_id), None
except Exception:
logger.exception("B08 구조물도 일위대가 — 단가표 조립 실패: project_id=%s", project_id)
return None, JSONResponse(
status_code=500,
content={"status": "error", "message": "단가표를 조립하지 못했습니다."},
)
@router.get("/{project_id}/quantity/structure-sheets/unit-price")
async def get_structure_unit_price(project_id: UUID, sheet_key: str) -> JSONResponse:
"""장 하나의 하단 **일위대가 표**(미리보기) — 줄 조합은 양식, 단가는 B09 단가표.
"""장 하나의 하단 **일위대가 표**(미리보기) — 줄 조합은 양식(+고친 것), 단가는 B09 단가표.
조회와 창구를 나눔 단가표 조립이 십여 초라 조회를 늦추지 않게.
값을 여기서 정본으로 적지 않음 보이기만(내역 금액은 B09 ).
`editor` 화면이 고칠 조합·양식 ·수동 단가. (금액) 서버 계산만 .
"""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
available_templates,
project_templates,
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import available_templates
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import (
MANUAL_KEY,
ROWS_KEY,
unit_price_table,
with_rows,
)
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_of
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_table
from B09_Estimation.B09_Estimation_UnitPrice import find_variant_code
from common_util.common_util_project_settings import quantity_settings
project_root = await _project_root(project_id)
if project_root is None:
return _not_found()
sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
picked = next((s for s in sheets if s.get("key") == sheet_key), None)
picked, template = await _sheet_template(project_id, project_root, sheet_key)
if picked is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "그 구조물도 장을 찾지 못했습니다."},
)
template = template_of(str(picked.get("type_id") or ""), project_templates(project_root))
if not template or not template.get("unit_price"):
return _no_sheet()
if not template:
return JSONResponse(content={"status": "success", "unit_price": None})
try:
build = await _price_build(project_id)
except Exception:
logger.exception("B08 구조물도 일위대가 — 단가표 조립 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "단가표를 조립하지 못했습니다."},
)
build, error = await _price_build_or_error(project_id)
if error:
return error
settings = quantity_settings(project_root)
row_edits = settings.get(ROWS_KEY) or {}
type_id = str(template.get("type_id") or "")
manual = (settings.get(MANUAL_KEY) or {}).get(type_id) or {}
effective = with_rows(template, row_edits.get(type_id))
# 하위 양식도 그 종류에서 고친 줄 조합으로 풂 — 수동 단가는 맨 윗 표만.
library = [
with_rows(t, row_edits.get(t.get("type_id"))) for t in available_templates(project_root)
]
table = await asyncio.to_thread(
unit_price_table,
template,
effective,
picked,
build.book,
lambda code, value: find_variant_code(code, value, build),
available_templates(project_root),
library,
manual,
)
return JSONResponse(content={"status": "success", "unit_price": table})
editor = {
"rows": effective["unit_price"]["rows"],
"default_rows": (template.get("unit_price") or {}).get("rows") or [],
"edited": type_id in row_edits,
"manual_prices": manual,
}
return JSONResponse(content={"status": "success", "unit_price": table, "editor": editor})
class UnitPriceRowEdit(BaseModel):
"""일위대가 줄 하나 — 수량은 원단위 줄(`from_row`) 또는 박힌 값, 코드는 고르개가 채움."""
model_config = ConfigDict(extra="forbid")
seq: int = Field(ge=1)
name: str | None = Field(default=None, max_length=200)
spec: str | None = Field(default=None, max_length=200)
unit: str | None = Field(default=None, max_length=20)
from_row: int | None = Field(default=None, ge=1)
quantity: float | None = None
ref_code: str | None = Field(default=None, max_length=100)
work_item_code: str | None = Field(default=None, max_length=100)
variant_from: str | None = Field(default=None, max_length=50)
sub_vars: dict[str, float | str] | None = None
class ManualPrice(BaseModel):
"""수동 단가 — 단위당 3분할과 출처. 넣은 날짜는 서버가 붙임."""
model_config = ConfigDict(extra="forbid")
material: float = Field(default=0, ge=0)
labor: float = Field(default=0, ge=0)
expense: float = Field(default=0, ge=0)
source: str = Field(default="", max_length=200)
class UnitPriceEditRequest(BaseModel):
"""한 화면에서 고친 일위대가 — **저장 자리는 둘**(줄 조합 = 양식+프로젝트 · 단가 = 프로젝트)."""
model_config = ConfigDict(extra="forbid")
sheet_key: str
rows: list[UnitPriceRowEdit] = Field(max_length=100)
manual_prices: dict[str, ManualPrice] = Field(default_factory=dict)
@router.put("/{project_id}/quantity/structure-sheets/unit-price")
async def put_structure_unit_price(project_id: UUID, payload: UnitPriceEditRequest) -> JSONResponse:
"""고친 줄 조합·수동 단가를 산출 조건에 저장. 금액은 화면이 다시 조회해 서버 계산으로 받음."""
from datetime import date
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import (
MANUAL_KEY,
ROWS_KEY,
save_manual,
save_rows,
)
from common_util.common_util_project_settings import quantity_settings, save_section
rows = [row.model_dump(exclude_none=True) for row in payload.rows]
if len({row["seq"] for row in rows}) != len(rows):
return JSONResponse(
status_code=422,
content={"status": "error", "message": "일위대가 줄 차례가 겹칩니다."},
)
project_root = await _project_root(project_id)
if project_root is None:
return _not_found()
picked, template = await _sheet_template(project_id, project_root, payload.sheet_key)
if picked is None or template is None:
return _no_sheet()
type_id = str(template.get("type_id") or "")
settings = quantity_settings(project_root)
merged_rows, changed = save_rows(settings.get(ROWS_KEY) or {}, type_id, template, rows)
prices = {seq: price.model_dump() for seq, price in payload.manual_prices.items()}
merged_manual = save_manual(
settings.get(MANUAL_KEY) or {}, type_id, rows, prices, date.today().isoformat()
)
await asyncio.to_thread(
save_section,
project_root,
"quantity",
{ROWS_KEY: merged_rows, MANUAL_KEY: merged_manual},
replace_keys=[ROWS_KEY, MANUAL_KEY],
)
return JSONResponse(
content={
"status": "success",
"changed_rows": changed,
"edited": type_id in merged_rows,
"manual_prices": len(merged_manual.get(type_id) or {}),
}
)
@router.get("/{project_id}/quantity/structure-sheets/price-search")
async def get_structure_price_search(
project_id: UUID, q: str, kind: Literal["work", "resource"] = "work"
) -> JSONResponse:
"""고르개 — 이 프로젝트 단가표에서 품셈(일위대가)·자원(자재·노임·중기)을 낱말로 찾음."""
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import search_titles
build, error = await _price_build_or_error(project_id)
if error:
return error
items = await asyncio.to_thread(search_titles, build.book, q[:100], kind)
return JSONResponse(content={"status": "success", "items": items})
@router.get("/{project_id}/quantity/structure-sheets")
@@ -78,6 +78,8 @@ export interface QuantitySettings {
rock_methods?: Record<string, string>;
/** 자재별 관급/사급 — 표 안에서 줄마다 고른 값. */
material_supply?: Record<string, { supply: string; install_by: string | null }>;
/** 자재별 할증률(%) — 표값 이내에서 고른 값만. `null`·없음이면 표값. */
material_surcharge?: Record<string, number | null>;
/** 콘크리트 타설 방식. `null`·없음이면 **아직 안 정한 것**이고 화면이 기본값 안내를 띄운다. */
concrete_placing_method?: string | null;
/** 표토 두께(m). `null`·없음이면 **안 정한 것**이라 표토제거 줄이 「근거 없음」으로 선다. */
@@ -92,16 +94,39 @@ export interface QuantitySettings {
structure_trench_water?: string | null;
/** 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다. */
topsoil_haul_distance_m?: number | null;
/** 도쟈 한계거리(m) — `null`·없음이면 기본값(서버 정본 60 m)이 선다. */
dozer_haul_limit_m?: number | null;
/** 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). */
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;
face_dressing_fill_area_m2?: number | null;
face_dressing_cut_area_m2?: number | null;
/** 표토제거 대상 — 없음이면 기본(노면 + 절토) · `"road_only"` 는 노면만. */
topsoil_target?: string | null;
/** 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. */
frame_material?: Record<string, number | null>;
/** 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜야 줄이 선다. */
wood_chipping_enabled?: boolean | null;
/** 파쇄 부피(㎥) — 켜도 이 값이 없으면 줄만 서고 사유가 남는다. */
wood_chipping_volume_m3?: number | null;
/** 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄. */
subgrade_compaction_enabled?: boolean | null;
/** 임목폐기물 조사값 넷 — 비면 톤이 안 선다. */
tree_waste?: Record<string, number | null>;
/** 처리단가(원/톤) — 수동 · 미확정. */
tree_waste_unit_price_krw_per_ton?: number | null;
/** 폐기물 분리발주 — 기본 아님. */
waste_separate_order?: boolean | null;
/** 뿌리 산정법 — 없음이면 분배비 15/85 · `"root_ball"` 은 뿌리분 체적 × 1,300. */
tree_waste_root_method?: string | null;
}
/** 갈래 하나의 「무엇을 골랐나」. 서버 `earthwork_conversion_choices` 와 짝이다. */
@@ -139,8 +164,26 @@ export interface EarthworkTable {
settings?: QuantitySettings;
/** 갈래별 토량환산계수 선택 상태 — 산출 조건 패널이 그린다. */
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 는 규정이라 값만 보인다. */
haul_limit_choice?: {
value: number;
default: number;
chosen: boolean;
basis: string;
free_haul_m: number;
free_haul_basis: string;
};
/** 근거 사전 — ⚠ **개발환경에서만** 실려 온다. 운영에서는 칸 자체가 없다. */
provenance?: ProvenancePayload;
}
+49 -1
View File
@@ -28,6 +28,8 @@ export interface MaterialRow {
unit: string;
net_amount: number;
surcharge_pct: number | null;
/** 할증률표 값 = 고를 수 있는 천장(품셈 1-3-1 「표의 값 이내」). `null` 이면 칸이 안 섬. */
surcharge_cap?: number | null;
total_amount: number;
supply: string;
supply_label: string;
@@ -241,9 +243,50 @@ export interface SupplyChoice {
export interface MaterialGridOptions {
/** 저장 전 변경분 — 고른 값은 여기 쌓이고 [저장]에서만 정본으로 간다. */
choices: Record<string, SupplyChoice>;
/** `{ : % | null}` `material_surcharge`. .
* `null` = . 0 ~ ( ). */
surcharge?: Record<string, number | null>;
onChange: () => void;
}
/** 할증률 칸 — 표값(상한) 안에서만. 범위를 넘으면 칸이 빨갛게 되고 값을 안 받음. */
function surchargeCell(
row: MaterialRow,
key: string,
picks: Record<string, number | null>,
onChange: () => void,
): HTMLTableCellElement {
const cap = row.surcharge_cap as number;
const td = document.createElement("td");
const input = document.createElement("input");
input.type = "number";
input.min = "0";
input.max = String(cap);
input.step = "0.1";
input.className = "b08-grid__select";
input.style.width = "4.5em";
const picked = picks[key];
// 안 고른 칸은 **비워 두고 회색 제안값**(지금 붙는 표값)만 — 기본값을 값처럼 채우지 않음(폼 ② 규칙).
input.value = picked === null || picked === undefined ? "" : String(picked);
input.placeholder = String(row.surcharge_pct ?? cap);
input.title = `표값 ${cap}% 이내(품셈 1-3-1) · 비우면 표값`;
input.addEventListener("change", () => {
const text = input.value.trim();
const value = Number(text);
if (text !== "" && !(Number.isFinite(value) && value >= 0 && value <= cap)) {
input.setCustomValidity(`0 ~ ${cap}% 만 받음`);
input.reportValidity();
return;
}
input.setCustomValidity("");
picks[key] = text === "" ? null : value;
td.classList.add("is-changed");
onChange();
});
td.append(input);
return td;
}
/** 표 안의 고르는 칸. 바꾼 줄은 **표시가 남는다** — 무엇을 만졌는지 보여야 한다. */
function choiceCell(
value: string,
@@ -337,7 +380,12 @@ export function renderMaterialGrid(
tr.append(textCell(row.unit, "b08-grid__unit"));
tr.append(textCell(num(row.net_amount, 2)));
// 미확보는 빈칸이 아니라 「-」 — 빈칸이면 0 % 로 오해된다.
tr.append(textCell(row.surcharge_pct === null ? "" : num(row.surcharge_pct, 0)));
// 표값이 있는 줄은 「이내」로 고를 칸(초안 `surcharge` 가 이어졌을 때만).
tr.append(
options?.surcharge && typeof row.surcharge_cap === "number"
? surchargeCell(row, key, options.surcharge, options.onChange)
: textCell(row.surcharge_pct === null ? "" : num(row.surcharge_pct, 0)),
);
tr.append(textCell(num(row.total_amount, 2)));
if (options) {
// 관급/사급은 **자재마다 갈리는 발주 결정**이라 줄에서 고른다(2026-09-07 확정).
+144 -13
View File
@@ -39,6 +39,12 @@ import {
type MaterialResponse,
} from "./B08_Quantity_UI_MaterialGrid";
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 헬퍼 */
function L(key: keyof typeof ui_locales): string {
@@ -92,6 +98,7 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
// 병합해 옛 값이 남아 되돌릴 길이 없다(화면에서 걸린 자리). 빈 값은 서버가 버린다.
rock_methods: draft.rock_methods,
material_supply: draft.material_supply,
material_surcharge: draft.material_surcharge,
concrete_placing_method: draft.concrete_placing_method,
// ⚠ `null` 도 그대로 보낸다 — 「안 정함」으로 되돌릴 길이 있어야 한다(시공법과 같은 규칙).
topsoil_thickness_m: draft.topsoil_thickness_m,
@@ -101,14 +108,31 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
spoil_site_distance_m: draft.spoil_site_distance_m,
structure_trench_water: draft.structure_trench_water,
topsoil_haul_distance_m: draft.topsoil_haul_distance_m,
// `""` 는 기본(노면 + 절토)으로 되돌림 — 서버가 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,
face_dressing_fill_area_m2: draft.face_dressing_fill_area_m2,
face_dressing_cut_area_m2: draft.face_dressing_cut_area_m2,
frame_material: draft.frame_material,
wood_chipping_enabled: draft.wood_chipping_enabled,
wood_chipping_volume_m3: draft.wood_chipping_volume_m3,
subgrade_compaction_enabled: draft.subgrade_compaction_enabled,
// 조사값은 **통째로** — 지운 칸까지 가야 되돌릴 길이 있다.
tree_waste: draft.tree_waste,
tree_waste_unit_price_krw_per_ton: draft.tree_waste_unit_price_krw_per_ton,
waste_separate_order: draft.waste_separate_order,
tree_waste_root_method: draft.tree_waste_root_method,
// 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다.
ancillary_counts: draft.ancillary_counts,
// 토량환산계수 — 고른 갈래만 담긴다. 빈 dict 는 「전부 기본값으로 되돌림」이다.
conversion_factors_override: conversionOverridePayload(draft.conversion_factors),
// 도쟈 한계거리 — `null` 도 보낸다(기본값으로 되돌리는 길).
dozer_haul_limit_m: draft.dozer_haul_limit_m,
}),
},
);
@@ -281,19 +305,41 @@ interface DraftSettings {
structure_trench_water: string;
// 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. `null` 은 「안 정함」.
topsoil_haul_distance_m: number | null;
// 표토제거 대상 — `""` 기본(노면 + 절토, 별표2 문언) · "road_only" 노면만(설계자 선택).
topsoil_target: string;
// 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13).
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;
face_dressing_fill_area_m2: number | null;
face_dressing_cut_area_m2: number | null;
// 규준틀 개소당 재료 — 비우면 **제안값(실무 관측)**이 선다. 값이 아니라 「고칠 수 있음」이 요점.
frame_material: Record<string, number | null>;
// 임목파쇄 — **기본 꺼짐**(확정 5차 5번). 켜야 줄이 선다. 근주이식은 칸 자체가 없다.
wood_chipping_enabled: boolean;
wood_chipping_volume_m3: number | null;
// 노체다짐 — **기본 꺼짐**(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄(수량 = 성토량).
subgrade_compaction_enabled: boolean;
// 임목폐기물 — 조사값 넷 · 수동 처리단가(미확정) · 분리발주(기본 아님).
tree_waste: Record<string, number | null>;
tree_waste_unit_price_krw_per_ton: number | null;
waste_separate_order: boolean;
tree_waste_root_method: string;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>;
// 자재별 할증률(%) — 표값 이내에서 고른 값만 · `null` 은 표값.
material_surcharge: Record<string, number | null>;
// 갈래별 토량환산계수(다짐) — `compacted` 가 `null` 이면 「안 고름」이라 기본값이 선다.
conversion_factors: Record<string, FactorDraft>;
// 도쟈 한계거리(m) — `null` 은 기본값(서버 정본 60 m)이 선다.
dozer_haul_limit_m: number | null;
dirty: boolean;
}
@@ -402,6 +448,30 @@ function buildQuantitySidePanel(
);
if (factorFields) panel.append(factorFields);
// ── 운반장비 거리 경계 — 도쟈 한계거리는 설계 조건이라 칸으로(2026-09-13 판정) ──
// ⚠ 기본값과 근거를 **서버가 준 그대로** 옆에 보인다 — 「이 값이 어디서 왔나」가 화면에 있어야 함.
const haul = table?.haul_limit_choice;
if (haul) {
panel.append(field(L("B08_Quantity_Side_HaulLimits"), ""));
const dozer = optionalNumberField(
L("B08_Quantity_Side_DozerLimit_Label"),
draft.dozer_haul_limit_m,
"5",
(value) => {
draft.dozer_haul_limit_m = value;
draft.dirty = true;
},
);
const input = dozer.querySelector("input");
if (input) input.placeholder = String(haul.default);
panel.append(dozer);
panel.append(hintRow(`${L("B08_Quantity_Factor_Default")} ${haul.default} m — ${haul.basis}`));
panel.append(
hintRow(`${L("B08_Quantity_Side_FreeHaul")} ${haul.free_haul_m} m — ${haul.free_haul_basis}`),
);
panel.append(hintRow(L("B08_Quantity_Side_HaulLimits_Reach")));
}
// ── 지반 구성비 — 갈래 수는 프로젝트 세트가 정한다(코드에 안 박음, PLAN 8-13) ──
const classes = [...(table?.summary?.rock_classes ?? [])];
// ⚠ 비율을 아직 안 넣었으면 집계가 **「암」 한 줄**로 나온다(갈래로 안 갈림). 그 줄에도
@@ -459,10 +529,31 @@ function buildQuantitySidePanel(
}
}
// ── 표토 두께 — 정해야 준비공 표토제거 줄이 선다(품셈 9-15 [주]② 의 「T : 표토두께(m)」) ──
// ── 초류종자살포 토질 · 면고르기 — 밑수가 파종 면적이라 반영률 바로 밑. 선택지는 서버 목록 그대로.
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 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.**
// 「죽은 칸」(넣어도 안 쓰임)의 반대 짝이다 — 쓰이는데 넣을 데가 없던 자리.
panel.append(field(L("B08_Quantity_Side_Topsoil"), ""));
// 대상 — 기본은 법 문언(노면 + 절토). 영월 한 건이 노면만이라 3배 차이 → 설계자가 고름(2026-09-14).
panel.append(
selectField(
L("B08_Quantity_Side_TopsoilTarget_Label"),
draft.topsoil_target,
[
{ value: "", label: L("B08_Quantity_TopsoilTarget_RoadAndCut") },
{ value: "road_only", label: L("B08_Quantity_TopsoilTarget_RoadOnly") },
],
(value) => {
draft.topsoil_target = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_TopsoilTarget_Hint")));
panel.append(
optionalNumberField(
L("B08_Quantity_Side_Topsoil_Label"),
@@ -480,18 +571,8 @@ function buildQuantitySidePanel(
// 지금 값과 근거를 보이고 바꿀 수 있게 할 것.** 정한 값이 화면에 안 보이면 다음 사람이
// 왜 그 값인지 모른다.
panel.append(field(L("B08_Quantity_Side_Structure"), ""));
panel.append(
optionalNumberField(
L("B08_Quantity_Side_BenchCut_Label"),
draft.bench_cut_depth_m,
"0.01",
(value) => {
draft.bench_cut_depth_m = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_BenchCut_Hint")));
// 층따기 칸 · KDS 근거 줄 · [제안값 넣기] — 누른 때만 채움(교본 6-4 「설계도서에 명시」).
appendBenchCutFields(panel, draft, { field, optionalNumberField, selectField, hintRow });
panel.append(
optionalNumberField(
L("B08_Quantity_Side_Rubble_Label"),
@@ -561,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"), ""));
@@ -629,6 +716,27 @@ function buildQuantitySidePanel(
);
panel.append(hintRow(L("B08_Quantity_Side_Chipping_Hint")));
// ── 노체다짐 — ⚠ **기본 꺼짐**(2026-09-13 판정). 9-16-2 [주]⑤ 조건부라 켜야 토공집계에 줄이 선다.
panel.append(field(L("B08_Quantity_Side_SubgradeCompaction"), ""));
panel.append(
selectField(
L("B08_Quantity_SubgradeCompaction_Label"),
draft.subgrade_compaction_enabled ? "on" : "",
[
{ value: "", label: L("B08_Quantity_Chipping_Off") },
{ value: "on", label: L("B08_Quantity_Chipping_On") },
],
(value) => {
draft.subgrade_compaction_enabled = value === "on";
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_SubgradeCompaction_Hint")));
// ── 임목폐기물 — 조사값 넷 · 수동 처리단가 · 분리발주(2026-09-14). 칸은 따로 뺀 파일에서.
appendTreeWasteFields(panel, draft, { field, optionalNumberField, selectField, hintRow });
// ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ──
panel.append(field(L("B08_Quantity_Side_Placing"), ""));
panel.append(
@@ -831,6 +939,11 @@ function buildQuantityBody(
},
// 구조물도 — 제원 조합 하나 = 한 장(PLAN 3장). 스스로 받아 오므로 표를 넘기지 않음.
// ⚠ 탭 차례 바꾸기·원단위 탭 흡수는 PLAN 5장 몫 — 여기서는 원단위 앞에 붙이기만 함.
// 구조물 집계표 — 측점별 한 줄(PLAN 2장). 구조물도의 앞 사슬이라 그 바로 앞에 둠.
{
label: L("B08_Quantity_Tab_StructureSummary"),
build: () => renderStructureSummary(projectId),
},
{ label: L("B08_Quantity_Tab_StructureSheet"), build: () => renderStructureSheets(projectId) },
{
label: L("B08_Quantity_Tab_UnitQuantity"),
@@ -845,6 +958,7 @@ function buildQuantityBody(
material.material,
{
choices: draft.material_supply,
surcharge: draft.material_surcharge,
onChange: () => {
draft.dirty = true;
},
@@ -916,14 +1030,30 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
spoil_site_distance_m: (stored.spoil_site_distance_m as number | null) ?? null,
structure_trench_water: (stored.structure_trench_water as string) ?? "",
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,
face_dressing_cut_area_m2: (stored.face_dressing_cut_area_m2 as number | null) ?? null,
frame_material: { ...((stored.frame_material ?? {}) as Record<string, number | null>) },
wood_chipping_enabled: Boolean(stored.wood_chipping_enabled),
wood_chipping_volume_m3: (stored.wood_chipping_volume_m3 as number | null) ?? null,
subgrade_compaction_enabled: Boolean(stored.subgrade_compaction_enabled),
tree_waste: { ...((stored.tree_waste ?? {}) as Record<string, number | null>) },
tree_waste_unit_price_krw_per_ton:
(stored.tree_waste_unit_price_krw_per_ton as number | null) ?? null,
waste_separate_order: Boolean(stored.waste_separate_order),
tree_waste_root_method: (stored.tree_waste_root_method as string) ?? "",
ancillary_counts: {
...((stored.ancillary_counts ?? {}) as Record<string, number | null>),
},
material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) },
material_surcharge: {
...((stored.material_surcharge ?? {}) as Record<string, number | null>),
},
// ⚠ 저장분에 있는 갈래만 담는다 — 기본값을 복사해 넣으면 「안 고름」이 사라진다.
conversion_factors: Object.fromEntries(
Object.entries(stored.conversion_factors_override ?? {}).map(([kind, entry]) => [
@@ -934,6 +1064,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
},
]),
),
dozer_haul_limit_m: (stored.dozer_haul_limit_m as number | null) ?? null,
dirty: false,
};
const reload = (): void => {
@@ -0,0 +1,56 @@
/* =============================================================================
* B08_Quantity_UI_Side_BenchCut.ts
* · · [ ] (2026-09-14 ).
*
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* 6-4 .
* . ** ** KDS 44 30 00
* 2.3.4 (4) ( 500 mm) · , [] .
* ========================================================================== */
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];
}
/** KDS 44 30 00 도로토공 2.3.4 (4) ② — 기초지반이 토사인 경우 최소 높이(임도 지반이 대개 토사). */
const KDS_SOIL_MIN_HEIGHT_M = 0.5;
export interface BenchCutDraft {
bench_cut_depth_m: number | null;
dirty: boolean;
}
export function appendBenchCutFields(
panel: HTMLElement,
draft: BenchCutDraft,
h: SideFieldHelpers,
): void {
const row = h.optionalNumberField(
L("B08_Quantity_Side_BenchCut_Label"),
draft.bench_cut_depth_m,
"0.01",
(value) => {
draft.bench_cut_depth_m = value;
draft.dirty = true;
},
);
panel.append(row, h.hintRow(L("B08_Quantity_Side_BenchCut_Hint")));
// 층따기 표준치수 — KDS 근거 표시(2026-09-14 브레인 정정 · 「제안값」 경고 아님).
panel.append(h.hintRow(L("B08_Quantity_Side_BenchCut_Suggest")));
panel.append(
createButton({
label: L("B08_Quantity_Side_BenchCut_Fill"),
variant: "ghost",
onClick: () => {
const input = row.querySelector("input");
if (input) input.value = String(KDS_SOIL_MIN_HEIGHT_M);
draft.bench_cut_depth_m = KDS_SOIL_MIN_HEIGHT_M;
draft.dirty = true;
},
}),
);
}
@@ -0,0 +1,124 @@
/* =============================================================================
* B08_Quantity_UI_Side_FaceDressing.ts
* · · +
* (2026-09-14 ~ · ee9368ec).
*
* (`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 {
return ui_locales[key][currentLanguageIndex];
}
export interface FaceDressingDraft {
/** `""` 는 「안 정함」 — 서버가 선택지 밖 값도 `""` 로 되돌림. */
face_dressing_cut_class: string;
face_dressing_fill_class: string;
/** `null` 은 「파종 면적 그대로」. */
face_dressing_fill_area_m2: number | null;
face_dressing_cut_area_m2: number | null;
dirty: boolean;
}
export interface FaceDressingChoices {
cut: string[];
fill: string[];
/** 성토면 제안(서버 한 곳 · 실무 관측) — 회색으로 보이고 [제안값 넣기]를 **누른 때만** 들어감. */
fill_suggested?: { value: string; basis: string };
}
export function appendFaceDressingFields(
panel: HTMLElement,
draft: FaceDressingDraft,
choices: FaceDressingChoices | undefined,
h: SideFieldHelpers,
): void {
if (!choices) return;
const pick = (
label: keyof typeof ui_locales,
value: string,
list: string[],
set: (value: string) => void,
): HTMLElement =>
h.selectField(
L(label),
value,
[
{ value: "", label: L("B08_Quantity_FaceDressing_Unset") },
...list.map((name) => ({ value: name, label: name })),
],
(picked) => {
set(picked);
draft.dirty = true;
},
);
const area = (
label: keyof typeof ui_locales,
value: number | null,
set: (v: number | null) => void,
) => {
const row = h.optionalNumberField(L(label), value, "0.01", (typed) => {
set(typed);
draft.dirty = true;
});
const input = row.querySelector("input");
if (input) input.placeholder = L("B08_Quantity_FaceDressing_Area_Placeholder");
return row;
};
panel.append(h.field(L("B08_Quantity_Side_FaceDressing"), ""));
panel.append(
pick("B08_Quantity_FaceDressing_Cut", draft.face_dressing_cut_class, choices.cut, (v) => {
draft.face_dressing_cut_class = 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;
}),
);
panel.append(
area("B08_Quantity_FaceDressing_CutArea", draft.face_dressing_cut_area_m2, (v) => {
draft.face_dressing_cut_area_m2 = v;
}),
);
panel.append(h.hintRow(L("B08_Quantity_Side_FaceDressing_Hint")));
}
@@ -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")),
);
}
@@ -0,0 +1,131 @@
/* =============================================================================
* B08_Quantity_UI_Side_TreeWaste.ts
* · · (2026-09-14 ).
*
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* [] (5).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export interface TreeWasteDraft {
tree_waste: Record<string, number | null>;
tree_waste_unit_price_krw_per_ton: number | null;
waste_separate_order: boolean;
/** 뿌리 산정법 — `""` 분배비 15/85(기본) · "root_ball" 뿌리분 체적 × 1,300. */
tree_waste_root_method: string;
dirty: boolean;
}
/** 페이지의 칸 만들기 도우미 — 같은 모양을 쓰려고 받아 씀. */
export interface SideFieldHelpers {
field(label: string, value: string): HTMLElement;
optionalNumberField(
label: string,
value: number | null,
step: string,
onInput: (value: number | null) => void,
): HTMLElement;
selectField(
label: string,
value: string,
options: { value: string; label: string }[],
onChange: (value: string) => void,
): HTMLElement;
hintRow(text: string): HTMLElement;
}
/** 조사값 칸 — 서버 `TREE_WASTE_INPUTS` 와 같은 키. */
const INPUTS: [string, keyof typeof ui_locales, string][] = [
["stems_per_1000m2", "B08_Quantity_TreeWaste_Stems", "1"],
["dbh_cm", "B08_Quantity_TreeWaste_Dbh", "0.1"],
["height_m", "B08_Quantity_TreeWaste_Height", "0.1"],
["unit_weight_kg_m3", "B08_Quantity_TreeWaste_UnitWeight", "10"],
];
/** 수동 단가 칸 — 값이 있으면 빨간 테두리(미확정). */
function markManual(row: HTMLElement, value: number | null): void {
const input = row.querySelector("input");
if (input) input.style.outline = value ? "2px solid var(--color-danger, #d9534f)" : "";
}
export function appendTreeWasteFields(
panel: HTMLElement,
draft: TreeWasteDraft,
h: SideFieldHelpers,
): void {
panel.append(h.field(L("B08_Quantity_Side_TreeWaste"), ""));
for (const [key, label, step] of INPUTS) {
panel.append(
h.optionalNumberField(L(label), draft.tree_waste[key] ?? null, step, (value) => {
draft.tree_waste[key] = value;
draft.dirty = true;
}),
);
}
panel.append(h.hintRow(L("B08_Quantity_Side_TreeWaste_Hint")));
// 뿌리 — 지침 부록1 이 두 방법을 둠. 뿌리분 직경은 흉고직경과 갈라 따로 받음(지침 표기가 모호).
panel.append(
h.selectField(
L("B08_Quantity_TreeWaste_RootMethod"),
draft.tree_waste_root_method,
[
{ value: "", label: L("B08_Quantity_TreeWaste_RootRatio") },
{ value: "root_ball", label: L("B08_Quantity_TreeWaste_RootBall") },
],
(value) => {
draft.tree_waste_root_method = value;
draft.dirty = true;
},
),
);
panel.append(
h.optionalNumberField(
L("B08_Quantity_TreeWaste_RootBallDiameter"),
draft.tree_waste["root_ball_diameter_m"] ?? null,
"0.01",
(value) => {
draft.tree_waste["root_ball_diameter_m"] = value;
draft.dirty = true;
},
),
);
panel.append(h.hintRow(L("B08_Quantity_TreeWaste_Root_Hint")));
const price = h.optionalNumberField(
L("B08_Quantity_TreeWaste_Price"),
draft.tree_waste_unit_price_krw_per_ton,
"1000",
(value) => {
draft.tree_waste_unit_price_krw_per_ton = value;
draft.dirty = true;
markManual(price, value);
},
);
markManual(price, draft.tree_waste_unit_price_krw_per_ton);
panel.append(price);
const priceHint = h.hintRow(L("B08_Quantity_TreeWaste_Price_Hint"));
priceHint.classList.add("b08-quantity__hint--warn");
panel.append(priceHint);
panel.append(
h.selectField(
L("B08_Quantity_TreeWaste_Separate"),
draft.waste_separate_order ? "on" : "",
[
{ value: "", label: L("B08_Quantity_TreeWaste_Separate_Off") },
{ value: "on", label: L("B08_Quantity_TreeWaste_Separate_On") },
],
(value) => {
draft.waste_separate_order = value === "on";
draft.dirty = true;
},
),
);
panel.append(h.hintRow(L("B08_Quantity_TreeWaste_Separate_Hint")));
}
@@ -0,0 +1,80 @@
/* =============================================================================
* B08_Quantity_UI_StructureFigure.ts
* ** ** (m, +y) SVG (PLAN 12).
*
* ( `B08_Quantity_Engine_StructureFigure` ). CAD .
* **** .
* = 6 : 4( ) 60% .
* ========================================================================== */
import { el } from "./B08_Quantity_UI_StructureSheet_Formula";
export type FigureShape =
| { kind: "path"; points: [number, number][]; role: "wall" | "guide"; dash: boolean }
| {
kind: "text";
text: string;
at: [number, number];
align: "left" | "center" | "right";
size: number;
};
const SVG_NS = "http://www.w3.org/2000/svg";
const ANCHOR = { left: "start", center: "middle", right: "end" } as const;
export const FIGURE_CSS = `
.b08-sheet__figure { height: 60vh; min-height: 16rem; border: 1px solid var(--color-border);
background: var(--color-surface); }
.b08-sheet__figure svg { display: block; width: 100%; height: 100%; }
.b08-sheet__figure--none { height: auto; min-height: 0; padding: 6px 10px; }
.b08-fig__wall, .b08-fig__guide { fill: none; vector-effect: non-scaling-stroke; }
.b08-fig__wall { stroke: var(--color-text); stroke-width: 2; }
.b08-fig__guide { stroke: var(--color-text-secondary, #888); stroke-width: 1; }
.b08-fig__guide.is-dash { stroke-dasharray: 6 4; }
.b08-sheet__figure text { fill: var(--color-text); }
`;
/** 그림 칸 — 모양이 없으면 까닭 한 줄. */
export function figureSection(
figure: FigureShape[] | null | undefined,
reason: string | null | undefined,
): HTMLElement {
const box = el("div", "b08-sheet__figure");
if (!figure?.length) {
box.classList.add("b08-sheet__figure--none");
box.append(el("p", "b08-quantity__message", (reason ?? "그림 없음").replace(/\*\*/g, "")));
return box;
}
const svg = document.createElementNS(SVG_NS, "svg");
const group = document.createElementNS(SVG_NS, "g");
for (const shape of figure) {
if (shape.kind === "text") {
const text = document.createElementNS(SVG_NS, "text");
text.setAttribute("x", String(shape.at[0]));
text.setAttribute("y", String(-shape.at[1]));
text.setAttribute("font-size", String(shape.size));
text.setAttribute("text-anchor", ANCHOR[shape.align]);
text.setAttribute("dominant-baseline", "middle");
text.textContent = shape.text;
group.append(text);
continue;
}
const line = document.createElementNS(SVG_NS, "polyline");
line.setAttribute("points", shape.points.map(([x, y]) => `${x},${-y}`).join(" "));
line.setAttribute("class", `b08-fig__${shape.role}${shape.dash ? " is-dash" : ""}`);
group.append(line);
}
svg.append(group);
box.append(svg);
// 보이는 틀은 **그려진 뒤 실제 글자 폭**으로 — 글자 폭을 서버가 짐작하지 않게.
requestAnimationFrame(() => {
if (!svg.isConnected) return;
const b = group.getBBox();
const pad = Math.max(b.width, b.height) * 0.04;
svg.setAttribute(
"viewBox",
`${b.x - pad} ${b.y - pad} ${b.width + pad * 2} ${b.height + pad * 2}`,
);
});
return box;
}
+40 -5
View File
@@ -5,7 +5,7 @@
* `B08_Quantity_UI_Page.ts` (PLAN 0 ).
* : `{ label: "구조물도", build: () => renderStructureSheets(projectId) }`
* (`/quantity/structure-sheets`) .
* · .
* = (`_StructureFigure`, SVG) + + .
* [ ] (`structures.json`) B07 .
* (PLAN 3 ) ** ** ( ).
* [ ] ** Node **( ). + .
@@ -33,6 +33,7 @@ import {
} from "./B08_Quantity_UI_StructureSheet_Spec";
import { buildLibraryPanel, libraryLabel } from "./B08_Quantity_UI_StructureSheet_Library";
import { unitPriceSection } from "./B08_Quantity_UI_StructureSheet_UnitPrice";
import { FIGURE_CSS, figureSection, type FigureShape } from "./B08_Quantity_UI_StructureFigure";
export interface StructureSheetRow {
no: number;
@@ -75,6 +76,9 @@ export interface StructureSheet extends StandardSheetSpec {
}[];
notes: string[];
unpriced_rows: string[];
/** 상단 그림 모양 목록 — 못 그리는 장은 `null` + `figure_reason`(PLAN 12장). */
figure?: FigureShape[] | null;
figure_reason?: string | null;
/** 양식으로 선 장이면 그 양식 — 없으면 지금 전개 줄(고정형 모양). */
/** `imported_from` 없음 = 프로그램 기본을 읽는 중(가져오기 전). */
library_item?: {
@@ -82,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;
@@ -124,6 +132,15 @@ const CSS = `
.b08-sheet__formula select.is-changed { outline: 1px solid var(--color-accent, #6c8ebf); }
.b08-sheet__formula .b08-sheet__digits { flex: 0 0 3.2rem; min-width: 3.2rem; }
.b08-sheet__actions { display: flex; gap: 8px; align-items: center; }
/* 수동 단가 — 빨간 테두리 + 「미확정 N건」 배지(PLAN 확정 ⑦). */
.b08-unit__manual { outline: 2px solid var(--color-danger, #d9534f); outline-offset: -2px; }
.b08-unit__badge { margin-left: 8px; padding: 0 6px; border-radius: 8px; color: #fff;
background: var(--color-danger, #d9534f); font-size: 12px; white-space: nowrap; }
.b08-unit-edit { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
.b08-unit-edit td { white-space: nowrap; }
.b08-unit-edit__picker { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
.b08-unit-edit__results { display: flex; flex-direction: column; align-items: flex-start;
gap: 2px; width: 100%; max-height: 14rem; overflow: auto; }
@media (max-width: 900px) {
.b08-sheet { flex-direction: column; }
.b08-sheet__aside { flex-basis: auto; width: 100%; }
@@ -135,7 +152,7 @@ function injectStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
style.textContent = CSS + FIGURE_CSS;
document.head.append(style);
}
@@ -245,9 +262,23 @@ 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,
),
);
main.append(head);
// ㉱ 규격 다름 — 미확정과 같은 급(빨간 테두리 + 배지). 금액은 서고 막지 않음.
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) {
main.append(
el("p", "b08-quantity__message", "이 제원은 원단위 줄이 서지 않음 — 아래 사유 참고"),
@@ -400,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);
@@ -430,7 +463,9 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
},
)
: null;
const unitPrice = sheet.library_item ? unitPriceSection(projectId, sheet.key) : null;
const unitPrice = sheet.library_item
? unitPriceSection(projectId, sheet.key, sheet.rows)
: null;
pane.replaceChildren(sheetBody(sheet, editor, unitPrice), aside);
};
sheets.forEach((sheet, index) => {
@@ -171,7 +171,7 @@ export function formulaTable(
saveButton.disabled = !dirty;
discardButton.disabled = !dirty;
status.textContent = dirty
? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림)"
? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림 · 고친 식은 이 양식·프로젝트에 묶여 제원(높이 등)을 바꿔도 따라감)"
: "";
onDirty(dirty);
};
@@ -8,13 +8,24 @@
* ========================================================================== */
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;
code: string;
name: string;
kind: string;
}
/** 장 머리의 양식 표시 — **어느 단에서 가져왔나** + **그 뒤 고쳤나**(지금 읽는 단이 아님). */
@@ -48,6 +59,10 @@ export interface LibraryPanelOptions {
/** 저장 안 한 식이 있으면 [내 라이브러리에 저장]을 막음 — 저장된 식만 개인 단으로 감. */
isDirty: () => boolean;
onImported: (notes: string[]) => Promise<void>;
/** 이 장 양식이 뽑아 온 원문 공사명 — 프로그램 기본 발행 확인창에 「빼고 발행」을 알림. */
originProject?: string | null;
/** 저장·발행 이름표 제안 — 장 이름(종류 + 제원 요약). */
defaultName: string;
}
/** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */
@@ -76,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",
}),
@@ -92,12 +206,18 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
const option = document.createElement("option");
option.value = `${item.tier}|${item.code}`;
const now = item.code === currentCode ? " (지금)" : "";
option.textContent = `${TIER_LABELS[item.tier] ?? item.tier} · ${item.name}${now}`;
const kind = KIND_LABELS[item.kind] ?? item.kind;
option.textContent = `${TIER_LABELS[item.tier] ?? item.tier} · [${kind}] ${item.name}${now}`;
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 {
@@ -156,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}줄 포함` : ""}`;
@@ -182,10 +302,49 @@ 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({
projectId,
typeId,
onSaved: () => {
if (!list.hidden) load.click();
},
}),
);
return panel;
}
@@ -196,7 +196,11 @@ export function buildStandardSpecPanel(
slope,
judgedSlope ? `비우면 자동 — 지금 판정값 1:${judgedSlope}` : "비우면 자동으로 판정합니다.",
),
field("야면석 계수", coeff, "비우면 품셈 열을 씁니다."),
field(
"야면석 계수",
coeff,
"비우면 품셈 열을 씁니다. 계수 열을 바꿔도 돌 종류는 그대로(다른 칸)입니다.",
),
field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."),
field("버림 콘크리트", blinding, "비우면 넣습니다(두께 100㎜) — 빼려면 「안 넣음」."),
);
@@ -0,0 +1,144 @@
/* =============================================================================
* B08_Quantity_UI_StructureSheet_Stmate.ts
* [STmate ] (PLAN 4 · 2026-09-14 ).
*
* : [] · · ( )
* [ ] ** **( ).
* ( ).
* (`b08-spec*`) .
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
interface ReadHopyo {
no: number;
name: string;
spec: string;
unit: string;
rows: number;
percent_rows: number;
contract_rows: number;
}
interface ReadResult {
project: string;
hopyo: ReadHopyo[];
counts: { hopyo: number; rows: number };
problems: string[];
}
async function postForm<T>(url: string, form: FormData): Promise<T> {
const response = await fetch(url, { method: "POST", credentials: "include", body: form });
const payload = (await response.json().catch(() => ({}))) as T & { message?: string };
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
}
export function buildStmatePanel(options: {
projectId: string;
typeId: string;
/** 넣은 뒤 — 목록을 다시 받게 함. */
onSaved: () => void;
}): HTMLElement {
const { projectId, typeId, onSaved } = options;
const base = `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/library`;
const box = document.createElement("div");
box.className = "b08-sheet__actions";
const input = document.createElement("input");
input.type = "file";
input.accept = ".xlsx";
input.className = "b08-spec__input";
input.title = "STmate 가 내보낸 내역 엑셀(일위대가표 시트)";
const read = document.createElement("button");
read.type = "button";
read.className = "b08-quantity__tab";
read.textContent = "STmate 엑셀 읽기";
const list = document.createElement("select");
list.className = "b08-spec__input";
list.hidden = true;
const put = document.createElement("button");
put.type = "button";
put.className = "b08-spec__save";
put.textContent = "내 라이브러리에 넣기";
put.hidden = true;
const status = document.createElement("p");
status.className = "b08-spec__scope";
let result: ReadResult | null = null;
input.addEventListener("change", () => {
result = null;
list.hidden = put.hidden = true;
status.textContent = "";
});
read.addEventListener("click", () => {
const file = input.files?.[0];
if (!file) {
status.textContent = "엑셀 파일을 먼저 고르세요";
return;
}
void (async () => {
read.disabled = true;
status.textContent = "읽는 중…";
try {
const form = new FormData();
form.append("file", file);
result = await postForm<ReadResult>(`${base}/stmate/read`, form);
list.replaceChildren(
...result.hopyo.map((h) => {
const option = document.createElement("option");
option.value = String(h.no);
const extra = h.percent_rows ? ` · 가산 행 ${h.percent_rows}` : "";
option.textContent = `${h.no}호표 ${h.name} ${h.spec} / ${h.unit} · 구성 ${h.rows}${extra}`;
return option;
}),
);
list.hidden = put.hidden = result.hopyo.length === 0;
const missed = result.problems.length
? ` · 못 읽은 것 ${result.problems.length}건 — ${result.problems.slice(0, 3).join(" / ")}`
: "";
status.textContent = `${result.project}」 호표 ${result.counts.hopyo}개 · 구성 줄 ${result.counts.rows}줄 읽음${missed}`;
} catch (error) {
status.textContent = error instanceof Error ? error.message : "읽지 못함";
} finally {
read.disabled = false;
}
})();
});
put.addEventListener("click", () => {
const file = input.files?.[0];
const picked = result?.hopyo.find((h) => String(h.no) === list.value);
if (!file || !result || !picked) return;
const question =
`읽은 것: 호표 ${result.counts.hopyo}개 · 구성 줄 ${result.counts.rows}\n` +
`넣을 것: 「${picked.name} ${picked.spec}」 구성 ${picked.rows}줄 → 이 장 종류로 내 라이브러리\n` +
"· 같은 종류 내 것이 있으면 덮어씀\n· 수량은 원문 시점값(현행 품셈 대조 전) · 단가는 안 가져옴";
if (!window.confirm(question)) return;
void (async () => {
put.disabled = true;
status.textContent = "넣는 중…";
try {
const form = new FormData();
form.append("file", file);
form.append("hopyo_no", String(picked.no));
form.append("type_id", typeId);
const saved = await postForm<{ code: string; name: string; note: string }>(
`${base}/stmate/save`,
form,
);
status.textContent = `내 라이브러리에 넣음 — 「${saved.name}」 [고정형] · ${saved.note}`;
onSaved();
} catch (error) {
status.textContent = error instanceof Error ? error.message : "넣지 못함";
} finally {
put.disabled = false;
}
})();
});
const wrap = document.createElement("div");
box.append(read, put);
wrap.append(input, box, list, status);
return wrap;
}
@@ -1,14 +1,19 @@
/* =============================================================================
* B08_Quantity_UI_StructureSheet_UnitPrice.ts
* ** **() PLAN 3 .
* ** **() PLAN 3 .
*
* (`…/structure-sheets/unit-price`) B09 .
* .
* 0 , .
* + N건 (PLAN ).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { el, num } from "./B08_Quantity_UI_StructureSheet_Formula";
import {
unitPriceEditor,
type UnitPriceEditorData,
} from "./B08_Quantity_UI_StructureSheet_UnitPriceEdit";
interface UnitPriceRow {
seq: number;
@@ -23,6 +28,9 @@ interface UnitPriceRow {
labor?: number;
expense?: number;
total?: number;
manual?: boolean;
manual_source?: string;
manual_entered_at?: string;
}
interface UnitPriceTable {
@@ -36,6 +44,7 @@ interface UnitPriceTable {
total: number;
blocked: number;
complete: boolean;
unconfirmed: number;
}
/** 금액 칸 — 0.1원 자리까지(금액란 규칙). 못 푼 줄은 빈칸. */
@@ -43,13 +52,17 @@ function won(value: number | undefined): string {
return value === undefined ? "" : num(value, 1);
}
/** 장 아래 일위대가 칸 — 받는 동안 안내를 두고, 오면 표로 갈음. */
export function unitPriceSection(projectId: string, sheetKey: string): HTMLElement {
/** 장 아래 일위대가 칸 — 받는 동안 안내를 두고, 오면 표로 갈음. 저장 뒤엔 다시 받음. */
export function unitPriceSection(
projectId: string,
sheetKey: string,
sheetRows: { no: number; name: string; unit: string }[],
): HTMLElement {
const wrap = el("div", "b08-grid");
wrap.append(
el("p", "b08-grid__caption", "일위대가(미리보기) 불러오는 중… 단가표 첫 조립은 십여 초"),
);
void (async () => {
const load = async (): Promise<void> => {
wrap.replaceChildren(
el("p", "b08-grid__caption", "일위대가(미리보기) 불러오는 중… 단가표 첫 조립은 십여 초"),
);
try {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/unit-price?sheet_key=${encodeURIComponent(sheetKey)}`,
@@ -57,15 +70,38 @@ export function unitPriceSection(projectId: string, sheetKey: string): HTMLEleme
);
const payload = (await response.json().catch(() => ({}))) as {
unit_price?: UnitPriceTable | null;
editor?: UnitPriceEditorData;
message?: string;
};
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
const table = payload.unit_price;
if (!table) {
const editor = payload.editor;
if (!table || !editor) {
wrap.replaceChildren(el("p", "b08-grid__caption", "이 양식에는 일위대가 줄이 아직 없음"));
return;
}
wrap.replaceChildren(...render(table));
const open = el("button", "", editor.edited ? "줄 고치기 (고친 조합)" : "줄 고치기");
open.type = "button";
open.style.alignSelf = "flex-start";
const view = table.rows.length
? render(table)
: [el("p", "b08-grid__caption", "일위대가 줄이 아직 없음 — [줄 고치기]로 더함")];
open.addEventListener("click", () => {
open.disabled = true;
const panel = unitPriceEditor({
projectId,
sheetKey,
sheetRows,
data: editor,
onSaved: () => void load(),
onClose: () => {
panel.remove();
open.disabled = false;
},
});
wrap.append(panel);
});
wrap.replaceChildren(...view, open);
} catch (error) {
wrap.replaceChildren(
el(
@@ -75,7 +111,8 @@ export function unitPriceSection(projectId: string, sheetKey: string): HTMLEleme
),
);
}
})();
};
void load();
return wrap;
}
@@ -86,8 +123,11 @@ 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}`));
}
const scroller = el("div", "b08-grid__scroll");
const grid = el("table", "b08-grid__table b08-grid__table--summary");
const headRow = document.createElement("tr");
@@ -109,19 +149,24 @@ function render(table: UnitPriceTable): HTMLElement[] {
const tbody = document.createElement("tbody");
for (const row of table.rows) {
const tr = document.createElement("tr");
const note = row.skipped ? `안 섬 — ${row.reason}` : row.reason ? `${row.reason}` : "";
const manual = row.manual
? `수동 단가 — ${row.manual_source || "출처 없음"} · ${row.manual_entered_at ?? ""}`
: "";
const note = row.skipped ? `안 섬 — ${row.reason}` : row.reason ? `${row.reason}` : manual;
// 단가표 이름에 갈래가 이미 들었거나 규격 칸이 코드 자체면 겹쳐 적지 않음.
const spec =
row.spec && !row.name.includes(row.spec) && !row.ref_code.includes(row.spec) ? row.spec : "";
const money = [row.material, row.labor, row.expense, row.total].map((value) => {
const cell = el("td", row.manual ? "b08-unit__manual" : "", won(value));
if (row.manual) cell.title = manual;
return cell;
});
tr.append(
el("td", "", spec ? `${row.name} (${spec})` : row.name),
el("td", "", row.ref_code),
el("td", "", row.quantity === null ? "" : num(row.quantity, 3)),
el("td", "", row.unit),
el("td", "", won(row.material)),
el("td", "", won(row.labor)),
el("td", "", won(row.expense)),
el("td", "", won(row.total)),
...money,
el("td", "", note),
);
tbody.append(tr);
@@ -0,0 +1,357 @@
/* =============================================================================
* B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts
* ** ** · · (· ) · . PLAN 3 .
*
* ( ) +, . []
* . [ ] .
* (B09 ) .
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { el } from "./B08_Quantity_UI_StructureSheet_Formula";
export interface UnitPriceSpecRow {
seq: number;
name?: string;
spec?: string;
unit?: string;
from_row?: number;
quantity?: number;
ref_code?: string;
work_item_code?: string;
variant_from?: string;
sub_vars?: Record<string, number | string>;
}
export interface ManualPrice {
material: number;
labor: number;
expense: number;
source: string;
entered_at?: string;
}
export interface UnitPriceEditorData {
rows: UnitPriceSpecRow[];
default_rows: UnitPriceSpecRow[];
edited: boolean;
manual_prices: Record<string, ManualPrice>;
}
interface SearchItem {
code: string;
name: string;
spec: string;
unit: string;
price: number | null;
}
interface EditorOptions {
projectId: string;
sheetKey: string;
sheetRows: { no: number; name: string; unit: string }[];
data: UnitPriceEditorData;
onSaved: () => void;
onClose: () => void;
}
const MONEY: [keyof Omit<ManualPrice, "source" | "entered_at">, string][] = [
["material", "재료비"],
["labor", "노무비"],
["expense", "경비"],
];
function sheetUrl(projectId: string, tail: string): string {
return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets${tail}`;
}
function input(value: string | number | undefined, width: string, type = "text"): HTMLInputElement {
const node = document.createElement("input");
node.type = type;
node.value = value === undefined ? "" : String(value);
node.style.width = width;
return node;
}
function codeText(row: UnitPriceSpecRow): string {
if (row.ref_code) return row.ref_code;
if (row.work_item_code) {
return row.variant_from
? `${row.work_item_code} (갈래: ${row.variant_from})`
: row.work_item_code;
}
return "코드 없음";
}
/** 줄 고치기 칸 — 로컬에서만 고치다 [저장]에 서버로. */
export function unitPriceEditor(options: EditorOptions): HTMLElement {
const { projectId, sheetKey, sheetRows, data, onSaved, onClose } = options;
let rows: UnitPriceSpecRow[] = structuredClone(data.rows);
const manual: Record<string, ManualPrice> = structuredClone(data.manual_prices);
let picking: number | null = null;
const wrap = el("div", "b08-unit-edit");
const tbody = document.createElement("tbody");
const picker = el("div", "b08-unit-edit__picker");
const status = el("span", "b08-grid__caption");
const render = (): void => {
tbody.replaceChildren(...rows.map((row, index) => rowOf(row, index)));
picker.hidden = picking === null;
};
const rowOf = (row: UnitPriceSpecRow, index: number): HTMLTableRowElement => {
const tr = document.createElement("tr");
const name = input(row.name, "9rem");
name.addEventListener("input", () => (row.name = name.value || undefined));
// 수량 — 원단위 줄을 따르거나 박힌 값.
const source = document.createElement("select");
source.append(new Option("박힌 값", ""));
for (const line of sheetRows) {
source.append(new Option(`원단위 ${line.no}. ${line.name} (${line.unit})`, String(line.no)));
}
source.value = row.from_row ? String(row.from_row) : "";
const fixed = input(row.quantity, "5rem", "number");
fixed.step = "any";
fixed.disabled = Boolean(row.from_row);
source.addEventListener("change", () => {
row.from_row = source.value ? Number(source.value) : undefined;
if (row.from_row) delete row.quantity;
fixed.disabled = Boolean(row.from_row);
});
fixed.addEventListener("input", () => {
row.quantity = fixed.value === "" ? undefined : Number(fixed.value);
});
const quantity = el("td", "");
quantity.append(source, fixed);
const unit = input(row.unit, "3rem");
unit.addEventListener("input", () => (row.unit = unit.value || undefined));
const code = el("td", "", codeText(row));
const find = el("button", "", "찾기");
find.type = "button";
find.title = "품셈·자원을 찾아 이 줄의 코드로 넣음";
find.addEventListener("click", () => {
picking = index;
render();
// 코드가 아직 없는 줄은 그 줄 이름으로 **후보만** 띄움 — 고르는 것은 사용자(명세 2장 · 이름 자동 확정 금지).
if (!row.ref_code && !row.work_item_code && row.name) {
query.value = row.name;
void search();
}
picker.querySelector("input")?.focus();
});
code.append(" ", find);
// 수동 단가 — 넣으면 빨간 테두리 · 표 머리에 「미확정」으로 셈. 프로젝트에만 저장됨.
const price = el("td", "");
const key = String(row.seq);
const on = document.createElement("input");
on.type = "checkbox";
on.checked = key in manual;
on.title = "수동 단가 — 이 프로젝트에만 저장(라이브러리로 안 감)";
price.append(on, " 수동");
const fields = MONEY.map(([field, label]) => {
const box = input(manual[key]?.[field], "5.5rem", "number");
box.min = "0";
box.step = "any";
box.placeholder = label;
box.title = `${label} 단가(단위당)`;
box.addEventListener("input", () => {
if (manual[key]) manual[key][field] = Number(box.value || 0);
});
return box;
});
const origin = input(manual[key]?.source, "7rem");
origin.placeholder = "출처";
origin.addEventListener("input", () => {
if (manual[key]) manual[key].source = origin.value;
});
const sync = (): void => {
for (const box of [...fields, origin]) {
box.disabled = !on.checked;
box.classList.toggle("b08-unit__manual", on.checked);
}
};
on.addEventListener("change", () => {
if (on.checked) {
manual[key] = { material: 0, labor: 0, expense: 0, source: "" };
fields.forEach((box, i) => (manual[key][MONEY[i][0]] = Number(box.value || 0)));
manual[key].source = origin.value;
} else {
delete manual[key];
}
sync();
});
sync();
price.append(...fields, origin);
if (manual[key]?.entered_at) price.append(` ${manual[key].entered_at}`);
const remove = el("button", "", "빼기");
remove.type = "button";
remove.addEventListener("click", () => {
rows = rows.filter((_, i) => i !== index);
delete manual[key];
picking = null;
render();
});
const actions = el("td", "");
actions.append(remove);
const nameCell = el("td", "");
nameCell.append(name);
const unitCell = el("td", "");
unitCell.append(unit);
tr.append(el("td", "", String(row.seq)), nameCell, quantity, unitCell, code, price, actions);
return tr;
};
// 고르개 — 이 프로젝트 단가표에서 낱말로 찾음.
const kind = document.createElement("select");
kind.append(new Option("품셈(일위대가)", "work"), new Option("자원(자재·노임·중기)", "resource"));
const query = input("", "12rem");
query.placeholder = "이름·코드·규격 낱말";
const go = el("button", "", "찾기");
go.type = "button";
const shut = el("button", "", "닫기");
shut.type = "button";
const results = el("div", "b08-unit-edit__results");
const search = async (): Promise<void> => {
if (!query.value.trim()) return;
results.replaceChildren(el("span", "b08-grid__caption", "찾는 중…"));
try {
const response = await fetch(
sheetUrl(
projectId,
`/price-search?q=${encodeURIComponent(query.value)}&kind=${kind.value}`,
),
{ credentials: "include" },
);
const payload = (await response.json()) as { items?: SearchItem[]; message?: string };
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
const items = payload.items ?? [];
results.replaceChildren(
...(items.length
? items.map(choice)
: [
// 후보 0 — 조용히 막히지 않게 다음 길을 적음(갈래 바꿔 찾기 · 수동 단가는 미확정으로 셈).
el(
"span",
"b08-grid__caption",
"단가표에 후보 없음 — 낱말·갈래(품셈/자원)를 바꿔 찾거나, 이 줄에 수동 단가를 넣을 것(미확정으로 셈)",
),
]),
);
} catch (error) {
results.replaceChildren(
el("span", "b08-grid__caption", `못 찾음 — ${error instanceof Error ? error.message : ""}`),
);
}
};
const choice = (item: SearchItem): HTMLElement => {
const price = item.price === null ? "단가 안 섬" : `${item.price.toLocaleString("ko-KR")}`;
const button = el(
"button",
"",
`${item.code} · ${item.name}${item.spec ? ` ${item.spec}` : ""} · ${item.unit} · ${price}`,
);
button.type = "button";
button.addEventListener("click", () => {
const row = picking === null ? undefined : rows[picking];
if (!row) return;
row.ref_code = item.code;
row.name = item.name;
row.spec = item.spec || undefined;
row.unit = item.unit || undefined;
delete row.work_item_code;
delete row.variant_from;
delete row.sub_vars;
picking = null;
render();
});
return button;
};
go.addEventListener("click", () => void search());
query.addEventListener("keydown", (event) => {
if (event.key === "Enter") void search();
});
shut.addEventListener("click", () => {
picking = null;
render();
});
picker.append(kind, query, go, shut, results);
const grid = el("table", "b08-grid__table");
const head = document.createElement("tr");
for (const label of ["차례", "이름", "수량", "단위", "코드", "단가 수동(단위당)", ""]) {
head.append(el("th", "", label));
}
const thead = document.createElement("thead");
thead.append(head);
grid.append(thead, tbody);
const scroller = el("div", "b08-grid__scroll");
scroller.append(grid);
const add = el("button", "", "줄 더하기");
add.type = "button";
add.addEventListener("click", () => {
// 양식 줄 차례와도 안 겹치게 — 뺀 양식 줄의 차례를 새 줄이 물려받지 않음.
const seq = Math.max(0, ...rows.map((r) => r.seq), ...data.default_rows.map((r) => r.seq)) + 1;
rows.push({ seq, name: "", quantity: 1 });
render();
});
const reset = el("button", "", "양식대로");
reset.type = "button";
reset.title = "줄 조합을 양식 원래대로 — [저장]해야 반영";
reset.addEventListener("click", () => {
rows = structuredClone(data.default_rows);
picking = null;
render();
});
const save = el("button", "b08-spec__save", "일위대가 저장");
save.type = "button";
save.addEventListener("click", () => {
void (async () => {
save.disabled = true;
status.textContent = "저장 중…";
try {
// 넣은 날짜는 서버가 붙임 — 값·출처만 보냄.
const prices = Object.fromEntries(
Object.entries(manual).map(([seq, { entered_at: _date, ...price }]) => [seq, price]),
);
const response = await fetch(sheetUrl(projectId, "/unit-price"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sheet_key: sheetKey, rows, manual_prices: prices }),
});
const payload = (await response.json().catch(() => ({}))) as { message?: string };
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
onSaved();
} catch (error) {
status.textContent = `저장 못 함 — ${error instanceof Error ? error.message : ""}`;
save.disabled = false;
}
})();
});
const close = el("button", "b08-quantity__tab", "고친 것 버리고 닫기");
close.type = "button";
close.addEventListener("click", onClose);
const actions = el("div", "b08-sheet__actions");
actions.append(add, save, reset, close, status);
wrap.append(
el(
"p",
"b08-grid__caption",
"줄 조합은 이 양식+프로젝트에 저장(내 라이브러리로 갈 수 있음) · 수동 단가는 이 프로젝트에만 저장",
),
scroller,
picker,
actions,
);
render();
return wrap;
}
@@ -0,0 +1,398 @@
/* =============================================================================
* B08_Quantity_UI_StructureSummary.ts
* ** ** · · (PLAN 2).
*
* (`…/quantity/structure-summary`) .
* () · ( ) · ( ) · .
* B05 ** ** .
* 3 (sessionStorage), [] **** .
* ··· · B05 ().
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid";
import { el, num } from "./B08_Quantity_UI_StructureSheet_Formula";
type Source = "auto" | "user" | "library" | "empty";
type CellValue = number | string | null;
interface SummaryCell {
value: CellValue;
source: Source;
was?: CellValue;
replaced_user_value?: CellValue;
}
interface SummaryRow {
id: string;
origin: "structures" | "pipe_points";
chainage_m: number | null;
start_m: number | null;
end_m: number | null;
count: number;
length_m: number | null;
length_basis: string;
memo: string;
note?: string;
replaced?: string[];
cells: Record<string, SummaryCell>;
}
interface SummaryColumn {
key: string;
label: string;
unit: string;
input: string;
choices: string[];
editable: boolean;
}
interface SummaryTable {
type_id: string;
name: string;
columns: SummaryColumn[];
rows: SummaryRow[];
count: number;
length_total_m: number | null;
length_missing: number;
averages: Record<string, number>;
}
interface SummaryResponse {
revision: number;
tables: SummaryTable[];
notes: string[];
message?: string;
}
interface Edit {
id: string;
key: string;
value: CellValue;
}
const SOURCE_LABELS: Record<Source, string> = {
auto: "자동 — 구조물 놓기(B05)·계곡 시설 정본 값",
user: "사용자 — 이 표에서 고친 값(정본에 적힘)",
library: "라이브러리 — 정본이 비어 양식 기본값으로 섬",
empty: "빈칸 — 정본도 양식도 값 없음",
};
const STYLE_ID = "b08-structure-summary-style";
const CSS = `
.b08-sum { display: flex; flex-direction: column; gap: 10px; min-height: 0; }
.b08-sum__legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; }
.b08-sum__cell--auto { box-shadow: inset 3px 0 0 var(--color-border, #888); }
.b08-sum__cell--user { box-shadow: inset 3px 0 0 var(--color-accent, #6c8ebf); }
.b08-sum__cell--library { box-shadow: inset 3px 0 0 var(--color-success, #5cb85c); }
.b08-sum__cell--empty { color: var(--color-text-muted, #999); }
.b08-sum__cell--replaced { outline: 2px solid var(--color-danger, #d9534f); outline-offset: -2px; }
.b08-sum td.is-changed { background: color-mix(in srgb, var(--color-accent, #6c8ebf) 18%, transparent); }
.b08-sum .b08-grid__table td { white-space: nowrap; }
.b08-sum td input, .b08-sum td select { font-size: 12px; max-width: 8rem; }
.b08-sum td input[type=number] { width: 5rem; }
.b08-sum td button { font-size: 11px; padding: 0 4px; margin-left: 2px; cursor: pointer; }
.b08-sum__actions { display: flex; gap: 8px; align-items: center; }
`;
function injectStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
document.head.append(style);
}
/** 칸 글 — 정수는 그대로(뒷길이 45), 소수는 둘째 자리까지. */
function cellText(value: CellValue | undefined): string {
if (value === null || value === undefined || value === "") return "";
return typeof value === "number" && !Number.isInteger(value) ? num(value, 2) : String(value);
}
function station(row: SummaryRow): string {
if (row.start_m !== null && row.end_m !== null) {
return `${stationLabel(row.start_m)} ~ ${stationLabel(row.end_m)}`;
}
return row.chainage_m === null ? "" : stationLabel(row.chainage_m);
}
/** 정본에 지금 적힌 값 — 라이브러리·빈칸은 정본이 빈 것. */
function storedValue(cell: SummaryCell | undefined): CellValue {
return cell && (cell.source === "auto" || cell.source === "user") ? cell.value : null;
}
function same(a: CellValue, b: CellValue): boolean {
return String(a ?? "") === String(b ?? "");
}
/** 고친 칸 캐시 — [저장] 전까지 이 탭(sessionStorage)에만 둠. 판번호가 다르면 버림. */
class Draft {
private readonly storageKey: string;
readonly edits = new Map<string, Edit>();
onChange: () => void = () => undefined;
constructor(
projectId: string,
readonly revision: number,
) {
this.storageKey = `b08-structure-summary-draft:${projectId}`;
try {
const saved = JSON.parse(sessionStorage.getItem(this.storageKey) ?? "null") as {
revision: number;
edits: Edit[];
} | null;
if (saved?.revision === revision) {
for (const edit of saved.edits) this.edits.set(`${edit.id}|${edit.key}`, edit);
}
} catch {
// 캐시를 못 읽으면 빈 초안 — 정본은 그대로.
}
}
get(id: string, key: string): Edit | undefined {
return this.edits.get(`${id}|${key}`);
}
set(edit: Edit, stored: CellValue): void {
const slot = `${edit.id}|${edit.key}`;
if (same(edit.value, stored)) this.edits.delete(slot);
else this.edits.set(slot, edit);
this.persist();
}
clear(): void {
this.edits.clear();
this.persist();
}
private persist(): void {
try {
if (this.edits.size) {
const body = { revision: this.revision, edits: [...this.edits.values()] };
sessionStorage.setItem(this.storageKey, JSON.stringify(body));
} else {
sessionStorage.removeItem(this.storageKey);
}
} catch {
// 저장소가 막혀도 화면 초안은 살아 있음.
}
this.onChange();
}
}
function editor(
column: SummaryColumn,
row: SummaryRow,
cell: SummaryCell | undefined,
draft: Draft,
td: HTMLTableCellElement,
): HTMLElement[] {
const stored = storedValue(cell);
const pending = draft.get(row.id, column.key);
const current = pending ? pending.value : stored;
let control: HTMLInputElement | HTMLSelectElement;
if (column.input === "select" && column.choices.length) {
const select = document.createElement("select");
select.append(new Option("—", ""));
for (const choice of column.choices) select.append(new Option(choice, choice));
select.value = current === null ? "" : String(current);
control = select;
} else {
const input = document.createElement("input");
input.type = column.input === "number" ? "number" : "text";
if (column.input === "number") {
input.min = "0";
input.step = "any";
}
input.value = current === null ? "" : String(current);
control = input;
}
if (cell?.source === "library") control.title = `비우면 양식 기본값 ${cellText(cell.value)}`;
if (control instanceof HTMLInputElement && cell?.source === "library") {
control.placeholder = cellText(cell.value);
}
const mark = (): void => {
td.classList.toggle("is-changed", Boolean(draft.get(row.id, column.key)));
};
control.addEventListener("change", () => {
const raw = control.value.trim();
const value = raw === "" ? null : column.input === "number" ? Number(raw) : raw;
draft.set({ id: row.id, key: column.key, value }, stored);
mark();
});
mark();
const parts: HTMLElement[] = [control];
if (cell?.source === "user" && cell.was !== undefined) {
const undo = el("button", "", "↺");
undo.type = "button";
undo.title = `고치기 전 값(${cellText(cell.was) || "빈칸"})으로 — [저장]하면 「자동」으로 돌아감`;
undo.addEventListener("click", () => {
control.value = cell.was === null || cell.was === undefined ? "" : String(cell.was);
control.dispatchEvent(new Event("change"));
});
parts.push(undo);
}
return parts;
}
function renderTable(table: SummaryTable, draft: Draft): HTMLElement {
const wrap = el("div", "b08-grid");
const length =
table.length_total_m === null ? "" : ` · 연장 합 ${num(table.length_total_m, 2)} m`;
const missing = table.length_missing ? ` · 연장 빈 줄 ${table.length_missing}` : "";
wrap.append(el("p", "b08-sheet__head", `${table.name}${table.count}개소${length}${missing}`));
const scroller = el("div", "b08-grid__scroll");
const grid = el("table", "b08-grid__table");
const head = document.createElement("tr");
head.append(el("th", "", "측점"), el("th", "", "연장(m)"));
for (const column of table.columns) {
const th = el("th", "", column.label);
if (!column.editable) th.title = "놓기 칸 — 시·종점과 한 벌이라 구조물 놓기(B05)에서 고침";
head.append(th);
}
head.append(el("th", "", "비고"));
const thead = document.createElement("thead");
thead.append(head);
const tbody = document.createElement("tbody");
for (const row of table.rows) {
const tr = document.createElement("tr");
const lengthCell = el("td", "", row.length_m === null ? "" : num(row.length_m, 2));
lengthCell.title = row.length_basis || "연장 없음";
tr.append(el("td", "", station(row)), lengthCell);
for (const column of table.columns) {
const cell = row.cells[column.key];
const td = el("td", `b08-sum__cell--${cell?.source ?? "empty"}`);
if (column.editable) td.append(...editor(column, row, cell, draft, td));
else td.textContent = cellText(cell?.value);
td.title = cell ? SOURCE_LABELS[cell.source] : "";
if (cell?.source === "user" && cell.was !== undefined) {
td.title += ` · 고치기 전 ${cellText(cell.was) || "빈칸"}`;
}
if (cell?.replaced_user_value !== undefined) {
td.classList.add("b08-sum__cell--replaced");
td.title = `이 표에서 ${cellText(cell.replaced_user_value) || "빈칸"}(으)로 고쳤으나 B05 가 바꿔 자동값으로 돌아감`;
}
tr.append(td);
}
const notes = [
row.replaced?.length ? `⚠ B05 가 바꿈: ${row.replaced.join("·")}` : "",
row.note ?? "",
row.memo,
].filter(Boolean);
tr.append(el("td", "", notes.join(" · ")));
tbody.append(tr);
}
const average = document.createElement("tr");
average.append(el("td", "", "평균치수"), el("td", "", ""));
for (const column of table.columns) {
const value = table.averages[column.key];
average.append(el("td", "", value === undefined ? "" : num(value, 2)));
}
average.append(el("td", "", ""));
tbody.append(average);
grid.append(thead, tbody);
scroller.append(grid);
wrap.append(scroller);
return wrap;
}
/** 탭 본문 — 받는 동안 안내, 오면 종류별 표. [저장] 뒤엔 다시 받음. */
export function renderStructureSummary(projectId: string | null): HTMLElement {
injectStyles();
const root = el("div", "b08-sum");
if (!projectId) {
root.append(el("p", "b08-quantity__message", "프로젝트를 먼저 고를 것"));
return root;
}
const url = `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-summary`;
let draft: Draft | null = null;
// 저장 안 한 칸이 조용히 사라지지 않게 — 나갈 때 물음(자동저장은 안 만듦, CLAUDE.md 5장).
window.addEventListener("beforeunload", (event) => {
if (!draft?.edits.size || !root.isConnected) return;
event.preventDefault();
event.returnValue = "저장 안 한 칸이 있음";
});
const load = async (): Promise<void> => {
root.replaceChildren(el("p", "b08-grid__caption", "구조물 집계표 불러오는 중…"));
try {
const response = await fetch(url, { credentials: "include" });
const payload = (await response.json().catch(() => ({}))) as SummaryResponse;
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
const active = new Draft(projectId, payload.revision);
draft = active;
const legend = el("div", "b08-sum__legend");
for (const source of ["auto", "user", "library", "empty"] as Source[]) {
legend.append(el("span", `b08-sum__cell--${source}`, ` ${SOURCE_LABELS[source]} `));
}
const save = el("button", "b08-spec__save", "저장");
save.type = "button";
const discard = el("button", "b08-quantity__tab", "고친 것 버리기");
discard.type = "button";
const status = el("span", "b08-grid__caption");
active.onChange = () => {
const count = active.edits.size;
save.disabled = count === 0;
discard.disabled = count === 0;
status.textContent = count
? `고친 칸 ${count} — [저장]해야 정본에 적힘(구조물도·원단위가 그 값을 씀)`
: "상세 칸만 고침 · 자리·길이·높이·관경은 구조물 놓기(B05)에서";
};
active.onChange();
save.addEventListener("click", () => {
void (async () => {
save.disabled = true;
status.textContent = "저장 중…";
try {
const result = await fetch(url, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
base_revision: active.revision,
edits: [...active.edits.values()],
}),
});
const body = (await result.json().catch(() => ({}))) as { message?: string };
if (!result.ok) throw new Error(body.message ?? `HTTP ${result.status}`);
active.clear();
await load();
} catch (error) {
status.textContent = `저장 못 함 — ${error instanceof Error ? error.message : ""}`;
save.disabled = false;
}
})();
});
discard.addEventListener("click", () => {
active.clear();
void load();
});
const actions = el("div", "b08-sum__actions");
actions.append(save, discard, status);
const total = payload.tables.reduce((sum, table) => sum + table.count, 0);
root.replaceChildren(
el(
"p",
"b08-sheet__head",
`구조물 집계표 · ${payload.tables.length}종 · ${total}개소 (측점별 실치수 — 구조물도가 이 값을 씀)`,
),
legend,
actions,
...payload.notes.map((note) => el("p", "b08-grid__caption b08-grid__caption--warn", note)),
...(payload.tables.length
? payload.tables.map((table) => renderTable(table, active))
: [el("p", "b08-grid__caption", "놓인 구조물이 없음")]),
);
} catch (error) {
root.replaceChildren(
el(
"p",
"b08-grid__caption b08-grid__caption--warn",
`구조물 집계표를 불러오지 못함 — ${error instanceof Error ? error.message : ""}`,
),
);
}
};
void load();
return root;
}
@@ -236,6 +236,8 @@ export interface PreparationRow {
reason: string;
reference_amount?: number;
work_item_code: string | null;
/** 수동 단가로 선 금액 수 — 「미확정 N건」·빨간 테두리(임목폐기물 처리). */
unconfirmed?: number;
}
export interface PreparationTable {
@@ -260,6 +262,14 @@ export function renderPreparationGrid(
const caption = document.createElement("p");
caption.className = "b08-grid__caption";
caption.textContent = `${table.row_count}줄 · 값을 낼 근거가 아직 없는 줄 ${table.pending_count}`;
const unconfirmed = table.rows.reduce((sum, row) => sum + (row.unconfirmed ?? 0), 0);
if (unconfirmed) {
const badge = document.createElement("span");
badge.style.cssText =
"margin-left:8px;padding:0 6px;border-radius:8px;color:#fff;background:var(--color-danger,#d9534f);font-size:12px";
badge.textContent = `미확정 ${unconfirmed}`;
caption.append(badge);
}
wrap.append(caption);
const scroller = document.createElement("div");
@@ -291,6 +301,8 @@ export function renderPreparationGrid(
if (row.reference_amount) {
note.append(document.createTextNode(` (참고 면적 ${num(row.reference_amount, 1)})`));
}
// 수동 단가로 선 금액이 든 줄 — 빨간 테두리(미확정).
if (row.unconfirmed) note.style.outline = "2px solid var(--color-danger, #d9534f)";
tr.append(note);
// 값을 못 세운 줄의 「수량」은 **막힘** — 근거가 오면 채워질 자리라 제외과 갈라 보인다.
markRow(tr, PREPARATION_KEYS, row.amount === null ? { 3: "blocked" } : undefined);
+3
View File
@@ -28,6 +28,7 @@ TYPE_LABELS = {
"pipe": "배수관",
"pipe_inlet_basin": "배수관 유입부 집수정",
"ford_pavement": "물넘이포장",
"open_ditch": "개거(겉도랑)",
"ford_bridge": "세월교",
"box_culvert": "BOX암거",
# 사방 시설 — 레지스트리 이름 그대로 옮긴 것(2026-09-08 확인). 지어낸 이름 아님.
@@ -44,6 +45,8 @@ OPTION_LABELS = {
"form": ("옹벽 형식", "구조물 상세 입력"),
"height_m": ("높이(m)", "구조물 배치"),
"length_m": ("연장(m)", "구조물 배치"),
# 물넘이포장(관 지점 시설) — B05 시설 칸 · 구조물 집계표에서 적음(2026-09-14 A3).
"thickness_cm": ("포장 두께(㎝)", "구조물 배치 물넘이포장 칸 · 구조물 집계표"),
# 2026-09-09 칸이 생겼음(`ebdf2988`) — 「아직 칸이 없음」이 거짓이 되어 고침.
# ⚠ 비워 두면 품셈 표준경사표로 자동 판정된다(확정 ⑨).
"face_slope_ratio": ("전면 기울기", "구조물 상세 입력 — 비우면 품셈 표준경사로 자동"),
+13 -4
View File
@@ -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:
@@ -110,11 +111,19 @@ def work_item_basis(build: Any) -> list[dict[str, Any]]:
for code, title in sorted(build.book.titles.items()):
if title.kind is not PriceKind.UNIT_PRICE:
continue
notes = [
detail.note.strip()
for detail in build.book.details.get(code, [])
if detail.note and detail.note.strip()
own = build.book.details.get(code, [])
# Q 식 문구는 층 차례 뒤 D(단가산출) 줄에 삶 — 그 일위대가가 부르는 D 의 줄 문구도 함께(명세 3장).
basis = [
detail
for ref in own
if ref.ref_code.startswith("D-")
for detail in build.book.details.get(ref.ref_code, [])
]
notes = []
for detail in [*own, *basis]:
text = detail.note.strip() if detail.note else ""
if text and text not in notes:
notes.append(text)
work_item_code = code[2:].split("#")[0]
source = build.factor_sources.get(work_item_code)
if source and source not in notes:

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