72 Commits
Author SHA1 Message Date
eomsangdon d54d52b6eb Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_1 2026-09-15 03:33:27 +09:00
eomsangdon 9f1adeab49 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-15 03:33:26 +09:00
eomsangdonandClaude Opus 5 05e88998c1 chore(plan): 임시 창별 계획서 — PLAN_데스크탑서브.md
까닭 — 2026-09-15 시놀로지가 두 PC 의 PLAN.md 를 번갈아 덮어 계획서를 거듭 잃음.
PLAN.md 가 git 밖이라 이력이 없어 되살릴 곳이 없었음. 창마다 제 파일을 git 에 두면
PC 사이를 git 이 건너가고 이력이 남으며 파일이 달라 서로 안 지움(브레인 지시).

⚠ 사용자 지침(PLAN.md 는 git 밖 · 한 파일로만 운영)을 어기는 임시 조치임.
   파일 머리에 「임시 · 사용자 확인 전」을 적어 둠 · .gitignore 는 안 건드림 ·
   합친 뒤 지움.

담은 것 — 닫은 일곱(커밋 해시째) · 재기만 한 셋([저장]·[확정] · BOX암거 셋 가름 ·
12-19 밑수) · 남은 것(UnitPrice.py 1403줄 쪼개기 · 판정 대기 넷).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-15 03:31:33 +09:00
eomsangdonandClaude Opus 5 0781e6c379 chore(plan): 임시 창별 계획서 — 랩탑 서브(시놀로지 덮어쓰기 때문 · 사용자 확인 전 · 브레인 지시)
공유 PLAN.md 가 03:22:27 데스크탑 판으로 덮여 사라진 줄을 대화 기록에서 옮김 · PLAN.md 는 읽기만

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-15 03:31:18 +09:00
eomsangdon a3950a189b Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-15 03:31:10 +09:00
eomsangdonandClaude Opus 5 387af3d888 chore(plan): 임시 창별 계획서 — 랩탑 메인(PLAN.md 가 시놀로지로 세 번 덮여 1장 줄을 잃음 · 사용자 확인 전)
· PLAN.md 는 읽기만 · 1장 랩탑 메인 몫(남은 것 · 검증완료 줄 · 커밋 해시)을 이 파일로 옮김
· git 에 두는 까닭 — PC 사이를 건너가고 이력이 남아 덮여도 되살릴 수 있음(브레인 지시)
· ⚠ 사용자 지침 「PLAN.md 는 git 밖 · 한 파일로만」 과 어긋나는 임시 조치 — 사용자가 확인한 뒤 브레인이 합쳐 PLAN.md 한 벌로 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 03:30:58 +09:00
eomsangdonandClaude Opus 5 42e8b4f0a9 feat(b09): ㉡-3 시간 줄 판정표 — 14-1 입목뿌리 밑막이 · 14-2 근주이식이 섬 · 막힌 셋(물푸기·모노레일·케이블크레인)은 옛 값이 왜 틀렸는지 사유
· 판정표 모양 hour_rows — 장비 h 그대로 · 인력 h ÷ 8(총칙 「일일 작업시간 8시간」) · ÷ 10본
· 원문 「0.8㎥ 굴착기」 가 형식을 안 적어 무한궤도 0201-0080 · 타이어 0211-0080 두 갈래 · 제안 없음(브레인 판정 ①)
· 14-1 우드그랩 ↔ 카탈로그 부착용 집게 7206-0070(규격 0.6∼0.8) — 이름이 다름을 사유에(②) · 조합이라 본체 #조합
· 옛 14-2 11,356원은 0.66h 를 인으로 셈(8배) + 굴착기 빠짐 → 무한궤도 4,431 · 타이어 4,631원/본 · 14-1 8,603 → 3,192 · 3,316
· 막힌 셋 사유(③): 12-26 가동시간 원문 없음 · 옛 값 보통인부 1인뿐 / 10-7-4 운전 단가표 보통인부 2 는 적재·짐부리기 2인과 같은 사람 · 옛 값 두 번 센 4인 / 10-8-3 ℓ/kWh 원문 없음 · 옛 값 특별인부 1인뿐
· 마스터 재생성 — 14-1 · 14-2 갈래 키만

잴 시험 넷 빨강→초록 · 끄기 둘 빨강 · 전체 시험 1721 + 392 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 03:29:21 +09:00
eomsangdon d64becb12c Merge remote-tracking branch 'origin/dev' into sub_desktop_1 2026-09-15 03:19:47 +09:00
eomsangdonandClaude Opus 5 e0c5e36433 refactor(b09): 700줄 넘은 모듈 둘 쪼갬 — 순수 분리, 그 김에 아무것도 안 고침
· BillOfQuantities.py 733 → 597 · 새 _BillOfQuantities_Input.py 162
  (인계 자료형 HandoffWorkItem·HandoffMaterial · parse_handoff · _decimal · BillError ·
   공급구분·미확정 상수)
· Engine_Cost.py 725 → 609 · 새 _Engine_Cost_Input.py 136 (CostInput 한 벌 · DEFAULT_ITEMS)
  반올림 두 함수(floor_won·ceil_thousand)는 엔진 것이라 도로 옮겨 둠
· 쓰던 이름은 그대로 — 본 모듈이 다시 내보내(noqa: E402,F401)
  from …BillOfQuantities import HandoffWorkItem · from …Engine_Cost import CostInput 가 종전대로 돎
· ruff — 새 파일 둘 다 깨끗 · 본 모듈 둘은 HEAD 와 같은 수(옮기며 안 늘음)

⚠ UnitPrice.py 1403 은 아직 남음 — 따로 배정 필요.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-15 03:19:40 +09:00
eomsangdonandClaude Opus 5 15551b9ea1 fix(b08): BOX 비계 단위 ㎡→㎥(공㎥) — 원단위 성분·묶음 조각 FP-12-19 · 사유 「비계 폭 1.0m 관측(울진)」(브레인 승인)
울진 단위 칸 「공/m3」 · 시종점 식의 ×1.00 은 연장일 수 없어 폭 1.0m 로 역산 · 값은 그대로(면 × 1.0) · B09 12-19 쪽은 안 건드림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-15 03:14:25 +09:00
eomsangdon ea81b7644e Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-15 03:02:39 +09:00
eomsangdonandClaude Opus 5 e40f4c9fd3 feat(b05·b08): BOX암거 구체 터파기 — 평균 터파기고 칸 하나 · 토공 축으로만 · 기초잡석 사유(브레인 판정 ①③)
- 등록부 trench_depth_m(평균 터파기고 · 기본값 없음 · 비면 사유)
- 터파기 = (밑폭 버림 폭 + 여유 0.5×2 + 윗폭 1:0.5) ÷ 2 × 깊이 × 연장(울진 「암거구체 토공(2×2)」) · 되메우기 = 터파기 − 든 것(잡석·버림·구체) · 잔토처리 = 든 것 — destination earthwork(묶음에 안 듦 · 토공집계로만)
- 울진 원문은 잔토에 구체 전체 · 되메우기 한쪽만 — 옹벽 식(터파기 − 든 것)으로 맞추고 사유에 적음
- 기초잡석 사유 「근거 둘(예제·울진 집계표)에는 잡석 줄이 없음 — 사용자 확정 3차 ② 로 세움」

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-15 02:59:51 +09:00
eomsangdon b9df49d1dd Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-15 02:48:55 +09:00
eomsangdonandClaude Opus 5 9563d0604a feat(b09): ㉡ 유인헬기방제 8-6-1 소요재료·양수기 손료 — 2장 표 읽는 한 벌에 면적·일 꼴(AreaSet) 더함 · 지상방제는 막힌 채 사유
· 양수기 1대 1일 호표 = 휘발유 10ℓ(2-1-8) + 잡품 주재료비 95% + 손료 0.0084(2-2-6) × 구입가(칸)
· 갈래 「(160ha당)」·「(400ha당)」 = 1일 작업량으로 나눔([주]① 대형도 휘발유 10ℓ) · 깃발은 표의 「20ha당 1개」 를 읽어 ha당 · 헬기 임차료는 견적 칸
· [주]② 소방관서 급수 지원이면 휘발유 미반영 — 사유 한 줄
· 연료값 읽기를 「ℓ 은 첫 수 · % 는 % 가 붙은 칸」 으로 넓힘(체인톱 값 그대로)
· 자원 목록 이름을 「유인헬기방제 양수기·깃발」 로 — 「양수기」 로 두면 12-26 물푸기 양수기 줄 풀이가 바뀌는 것을 잰 뒤 피함
· 8-6-3 지상방제는 밑수 없음으로 막힌 채 사유 둘(1일 작업량이 원문에 없음 · 15ha 는 연료 설명 / 1톤 방제차량은 카탈로그에 1톤 트럭이 없음)

오른 제목: 8-6-1 소형 +227원/ha · 대형 +90 · 나머지 그대로 · 잴 시험 다섯 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1712 + 388 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 02:47:59 +09:00
eomsangdonandClaude Opus 5 e15e9fc223 fix(b09): 2단 머리 표가 밑수로 안 나누던 것 — 갈래 이름의 「(Nha당)」 밑수로 나눔(8-6-1 유인헬기 160배·400배 부풂)
· _match_two_row_table 이 표 밑수로 아예 안 나눠 제목 단위 ha 에 160ha·400ha 몫이 그대로 앉아 있었음
· 표 밑수가 한 벌뿐이라 갈래마다 다른 밑수(소형 160ha · 대형 400ha)도 못 가르던 병 → 갈래 이름이 제 밑수를 적으면 그것으로, 없으면 표 밑수로
· 그 길을 지나는 표 다섯 중 밑수가 1 이 아닌 것 8-6-1 하나 · 전체에서 갈래마다 밑수가 다른 표도 8-6-1 하나

8-6-1 소형 1,591,855 → 9,949원/ha · 대형 2,780,567 → 6,951 · 나머지 제목 그대로 · 잴 시험 빨강→초록 · 끄기 빨강 · 전체 시험 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 02:47:59 +09:00
eomsangdon 957d727a24 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_desktop_1 2026-09-15 02:39:43 +09:00
eomsangdonandClaude Opus 5 a314ac12fd feat(b05·b06·b08): BOX암거 수량식 — 등록부 두께·연장 칸 + m당 × 연장 전개식 · 세월교·날개벽은 근거 없음 사유로 닫음(12장 D · 브레인 판정 ①~⑥)
- 등록부 box_culvert: 연장(기본값 없음) · 측벽 0.3 · 상·저판 0.25 · 헌치 0.2 · 버림 0.1 — 제안값 = 산림과임업기술(임도) 5장 수량산출 예제 · KDS 44 90 00 300㎜ 병기(판 0.25 는 못 미침을 나란히)
- B06 BOX 세트(파이썬·TS 짝)가 두께 칸을 읽음 — 종전 「세월교 값 승계」(벽 0.2·판 0.3) 걷음 · 그림 따라감
- B08 전개식 `_UnitQuantity_Box`: 콘크리트 · 버림 · 유로폼(외벽·내벽·상판 밑·귀면) · 버림 옆 합판 · 동바리 · 비계(시종점 두 면은 개소당 한 번) · 철근은 울진 2×2 관측(D13 179·D16 157kg/m)만, 나머지 크기는 「크기별 식 없음」
- 울진 기번3 「암거수량집계표(2×2)」 m당과 맞음: 레미콘 2.84 · 버림 0.28 · 유로폼 11.131 · 합판 0.2 · 동바리 3.92 · 비계 벽체 5.2
- 치수 칸을 안 적은 BOX 는 제안값으로 서되 미확정(금액 밖) · 연장이 비면 안 섬
- 묶음 조각 12-01-01·12-38·12-04(6회)·12-03·12-20·12-19·12-25 · 동바리 대상 표시 · 세월교 「수량 근거 없음」

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-15 02:36:25 +09:00
eomsangdonandClaude Opus 5 852ad6c7d8 feat(b09): ㉡ 2장 소요재료·기계손료 표를 읽는 한 벌 — 체인톱부터(4-2-2 단목베기 · 6-5 어린나무가꾸기)
· 장비 한 벌(Equipment)에 「어느 표 줄을 읽을지」 만 적고 값은 2장 표에서 읽음 — 나머지 소형 장비도 같은 길로 더함
· 체인톱 1대 1일 호표 = 휘발유 5.6ℓ(2-1-1) + 잡품 주연료비 40% + 손료 0.0084(2-2-1) × 구입가(「자재 단가」 칸 · 없으면 사유)
· 쓰는 공종에 표가 이름으로 밝힌 인원 줄 수량만큼(4-2-2 「벌목부」 · 6-5 「특별인부 (체인톱 사용)」 · 넓히지 않음)
· 체인오일 일반/친환경은 「자재 단가」 에 넣은 쪽 × 인원 × 2.1ℓ · 둘 다면 사유(브레인 판정 ①②③)

오른 제목: 4-2-2 +31·+41·+53원(+3.6%) · 6-5 +43,821원(+4.29%) · 나머지 그대로 · 프로젝트 내역 지장목제거 잡관목제거 +548,652(본체 109,122,948 → 109,671,600) · 잴 시험 넷 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1712 + 381 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 02:15:48 +09:00
eomsangdon 6152437113 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-15 01:49:15 +09:00
eomsangdonandClaude Opus 5 2f9d42ac35 fix(b09): ㉠ 자원 축 줄 읽기가 말없이 버리던 기계·연료 줄을 「못 맞춤 + 일부만」 으로 — 인력만으로 싸게 서던 공종 막기
· 버리던 세 자리: 규격이 이름 앞인 장비 칸(「0.8㎥ 굴착기 | h」 가 머리글로 걸러짐) · 값·이름 둘 다 못 읽은 줄(「디젤엔진(15HP) | 11.19kW」 · 「연료비 | ps × 0.253ℓ × 6h」) · 수량 빈 연료 줄(「연료비 | 경유 | L」)
· 연료·휘발유·경유·엔진·기관·양수기·발전기 이름이거나 시간·대·kW·HP 단위인 줄만 — 머리 줄·설명 줄은 그대로
· 사유 「기계·연료 줄이 안 붙음」 · 파쇄 모듈은 제가 쓰는 파쇄기 줄의 사유를 걷음
· ⚠ 처음 승인받은 「_names_a_machine 을 모든 공종으로」 는 재어 보니 0 을 막아 정정 보고 뒤 이 자리로

새로 사유가 붙은 줄 8 · 사라진 줄 0 · 새로 막힌 공종 5(12-26 물푸기 · 14-1 · 14-2 근주이식 · 10-8-3 케이블크레인 운전 · 10-7-4 모노레일) · 제목 금액 변화 0 · 프로젝트 내역 그대로 · 끄기 둘 빨강 · 전체 시험 1712 + 374 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 01:48:34 +09:00
eomsangdon 80b2669638 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-15 01:37:44 +09:00
eomsangdonandClaude Opus 5 7dd5dda98b fix(b08·b09): 내역 표시 흠 묶음 — 운반 줄 장비 키 · 덤프 거리 14자리 · 규격 갈래 두 번(㉰㉵)
- 운반 줄 이름: 「dozer 운반」 → 「불도저 운반」(매핑 master_name) · 「dump_truck 운반」 → 「덤프 운반」 · 「free_haul 운반」 → 「무대(종방향유용토)」(토공집계 이름)
- 덤프 규격·호표 규격 L: 「L=137.13474461798225m」 → 「L=137.13m」 — 보이기만 둘째 자리 · 호표 열쇠·운반 식은 받은 거리 그대로
- 규격 칸 갈래: 이미 적힌 갈래는 한 번만(`spec_with_variant` · 숫자 경계) — 「Ø800 800」 → 「Ø800」
- 936be972 내역: 흠 줄 6 → 0 · 금액 불변 161,514,889

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-15 01:34:52 +09:00
eomsangdon f672c5bee6 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-15 01:10:13 +09:00
eomsangdonandClaude Opus 5 0fc64fa704 feat(b09): ㉰ 이동식 임목 파쇄 8-11 일위대가 — 운전경비표에 값이 없던 파쇄기 기계 층을 8-11 [주]⑤·비고로 세움
· 건설 8-4 운전경비표 7205-0125 93.25㎾ 줄은 연료·잡품 칸이 「-」 → 레코드가 없어 기계 층도 일위대가도 조용히 없었음
· 8-11 [주]⑤ 연료 13.5ℓ(경유) · 비고 잡재료 주연료비 16% · [주]② 기계운전자 1인이 유일한 값이라 그대로(브레인 판정)
· B-FP-08-11 ㎥당 = 파쇄기 1/Q(3.5) + 보통인부 2인 ÷ 8시간 ÷ Q · 기계운전자는 기계 층 조종원으로 한 번만
· 파쇄기날(자원 목록 새 칸) — 「자재 단가」 에 넣으면 0.00125개/hr ÷ Q, 아니면 사유 · [주]③ 운반비·④ 우드그랩·⑥ 규격 외 견적은 사유 한 줄
· 영월 호표는 옛 판(8-2-35 Q=6)이라 대조 대상 아님

새 제목 B 51,029원/㎥ · 바뀐 제목 0 · 프로젝트 내역 그대로(파쇄 기본 끔) · 잴 시험 넷 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1711 + 371 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 01:08:56 +09:00
eomsangdonandClaude Opus 5 490cfe1d08 feat(b08): 품셈 공종 없는 부대시설 셋째 상태 「품셈 공종 없음 — 단가를 직접 넣어야 함」 · 「자재 단가」 탭 통로(브레인 판정)
- 개소를 넣어도 공종이 없어 단가가 안 서는 줄(국가지점번호판·안내판·차단기·수방대책 자재) — 「입력이 필요함」이면 넣고 기다려도 안 섬
- 개소가 선 줄은 자재총괄 사급 줄로 감 → 「자재 단가」 탭에 칸이 섬(화약류·치즐과 같은 통로) · 인계 공종 줄은 막힘 아님(두 번 안 셈)
- 개소 안 넣은 줄은 종전대로 「입력이 필요함」
- 936be972: 국가지점번호판 3개소 넣음 → 셋째 상태 · 자재 단가 탭 「국가지점번호판 개소 3.0」 · 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-15 01:08:43 +09:00
eomsangdon 08ff9458ea Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-15 00:46:45 +09:00
eomsangdon c73654be66 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-15 00:45:59 +09:00
eomsangdonandClaude Opus 5 16d3461573 fix(b09): ㉱ 조립(묶음) 줄이 조각의 「일부만」·「밑수 없음」 을 올림 — 조각 제목만 있으면 온전한 금액처럼 서던 구조 결함
· 조각마다 보통 줄과 같은 두 검사 — 단가가 일부 몫만 섰거나(붙은 몫 N%) 밑수를 모르는 표면 묶음 금액을 안 세우고 어느 조각이 왜인지 사유
· 700줄 넘던 줄 만들기 파일에서 자재 줄(_material_row)을 자재 모듈로 그대로 옮김(순수 나누기 · 부르는 쪽 한 줄)

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 00:45:40 +09:00
eomsangdonandClaude Opus 5 364c28a700 fix(b08): 준비공 상태 칸 「입력이 필요함」과 「값을 낼 근거가 없음」을 가름 · 인계 막힘 갈래도 상태를 따름(㉴)
- 입력이면 서는 줄(표토 두께·거리 · 임목폐기물 조사값 · 부대시설 개소 · 임목파쇄 부피 · 앞 단계 횡단·사면표) → 「입력이 필요함」 · 인계 input_missing
- 자료·산식이 없는 줄(뿌리 운반 부피 · 사방 원단위 · 사면표에 성토고 칸 없음) → 「근거 없음」 · 인계 unit_data_missing(종전 input_missing = 반대 방향 거짓 사유)
- 사면표가 섰는데 비탈 규준틀 0 개소 → 「해당 없음」
- 품셈 공종이 없는 부대시설은 개소를 넣어도 인계 input_missing 그대로(별도 단가)
- 표 머리 「입력이 필요한 줄 N · 근거 없는 줄 M」 · 700줄 — 규준틀 재료를 `_Preparation_FrameMaterial` 로 뗌(719 → 651)
- 936be972: 15줄 중 입력 7 · 근거 없음 1(뿌리 운반) · 내역 「원단위가 없습니다(우리가 만들 것)」 · 금액 불변 155,207,971

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 19:22:08 +09:00
eomsangdonandClaude Opus 5 b23db36c78 feat(b08): 옹벽 조립 조각 합판거푸집이 사용횟수 갈래로(「FP-12-04#3회」 · 성분의 reuse_count 그대로) · 집수정 거푸집 6회 → 4회(12-15 표 「합판4회」 정본 · 사유 문구도 그 표) — 화면 자재 표·인계 성분 한 벌(브레인 ㉯ ①②)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 19:05:55 +09:00
162 changed files with 6887 additions and 844 deletions
@@ -40,6 +40,8 @@ export interface StructureOptionField {
warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 칸 이름 옆 「표에 안 쓰임」 + 툴팁 사유. */
not_in_table?: string | null;
/** 기본값의 뜻(도메인 확정값이 아닐 때) — 칸 밑 근거 한 줄. */
default_basis?: string | null;
}
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
+60 -1
View File
@@ -240,6 +240,65 @@
"default": 2.0,
"required": false
},
{
"key": "length_m",
"label": "연장(계류 방향)",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"empty_means": "비우면 수량이 안 섬 — 구체 길이는 설계자 입력(기본값 없음 · 2026-09-15 브레인 판정 ③)"
},
{
"key": "wall_thickness_m",
"label": "측벽 두께",
"input": "number",
"unit": "m",
"default": 0.3,
"default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 벽 0.3 · KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ · 바꿀 수 있음",
"required": false,
"phase": "detail"
},
{
"key": "slab_thickness_m",
"label": "상·저판 두께",
"input": "number",
"unit": "m",
"default": 0.25,
"default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 판 0.25 — ⚠ KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ 에 못 미침(임도 예제 0.25 · KDS 도로 최소 300㎜) · 설계자가 고름",
"required": false,
"phase": "detail"
},
{
"key": "haunch_m",
"label": "헌치(모따기) 크기",
"input": "number",
"unit": "m",
"default": 0.2,
"default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 0.2×0.2 · 실무 울진 기번3 구조도 2×2 도 0.2 · 바꿀 수 있음",
"required": false,
"phase": "detail"
},
{
"key": "blinding_thickness_m",
"label": "기초(버림) 두께",
"input": "number",
"unit": "m",
"default": 0.1,
"default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 기초 0.1(양쪽 0.1 여유) · 실무 울진 기번3 구조도 2×2 버림 0.1 · 바꿀 수 있음",
"required": false,
"phase": "detail"
},
{
"key": "trench_depth_m",
"label": "평균 터파기고",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 구체 터파기·되메우기가 안 섬 — 원지반에서 터파기 바닥(기초잡석 밑)까지 · 토공집계로 감(2026-09-15 브레인 판정 ③)"
},
{
"key": "wing_in",
"label": "날개벽(유입)",
@@ -727,7 +786,7 @@
"input": "number",
"unit": "m",
"default": 2.0,
"default_basis": "제안값 2.0 — 관측 원단위 자료가 반중력식 H=2.0 한 벌뿐이라 그 값(2026-09-14 A4 · 옛 2.5 는 자료 없음)",
"default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음",
"required": false,
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
@@ -361,10 +361,17 @@ export function createFacilityOptionsForm(
emit();
});
// 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정).
const saveNote = document.createElement("p");
saveNote.className = "b05-structure__owner-note";
saveNote.textContent =
"[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀";
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
root.append(
grid(suggest),
saveNote,
pipeRow,
wingRow,
ford.widthRow,
@@ -9,12 +9,23 @@
import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station";
export function field(labelText: string, input: HTMLElement): HTMLLabelElement {
export function field(
labelText: string,
input: HTMLElement,
basis?: string | null,
): HTMLLabelElement {
const wrapper = document.createElement("label");
wrapper.className = "b05-route__field";
const caption = document.createElement("span");
caption.textContent = labelText;
wrapper.append(caption, input);
// 칸 밑 근거 한 줄 — 등록부 `default_basis`(기본값의 뜻 · 10-A 2026-09-14 사용자 확정).
if (basis) {
const note = document.createElement("small");
note.className = "b05-structure__basis";
note.textContent = basis;
wrapper.append(note);
}
return wrapper;
}
@@ -45,6 +56,8 @@ interface OptionShape {
warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 툴팁이 이 사유를 먼저 보임. */
not_in_table?: string | null;
/** 기본값의 뜻 — 칸 밑 근거 한 줄. */
default_basis?: string | null;
}
/**
@@ -332,7 +332,7 @@ export function createStructuresSection(
input.classList.add("is-locked");
input.title = "지금은 고를 수 없는 항목입니다.";
}
optionRow.append(field(label, input));
optionRow.append(field(label, input, option.default_basis));
input.addEventListener("change", () => liveCommit());
optionInputs.push({
key: option.key,
@@ -214,6 +214,18 @@
line-height: 1.5;
}
/* 칸 밑 근거 한 줄 — 등록부 `default_basis`(10-A). 회색 작은 글씨. */
.b05-structure__basis {
color: var(--color-text-muted, #9aa1ad);
font-size: var(--text-caption, 12px);
line-height: 1.3;
}
/* 근거 줄로 옆 칸이 길어져도 [제안값 넣기]가 세로로 늘지 않게. */
.b05-structure__suggest {
align-self: start;
}
/* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치
* (2026-08-17 사용자 지시 2). */
.b05-structure__position-row {
+7 -4
View File
@@ -110,8 +110,9 @@ FORD_DEFAULT_WIDTH_M = 10.0
# 지식DB 폭 수치 근거 없음).
FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0
# BOX암거 부재 두께(m) — 지식DB에 암거 부재 두께 기준이 없어 세월교 값을 승계한
# (2026-08-25 사용자 확정). 측벽·상판·저판은 각각 벽 두께·바닥판 두께를 그대로 쓴다.
# BOX암거 부재 두께 — **등록부 칸**(`wall_thickness_m`·`slab_thickness_m`)이 정본이
# (2026-09-15 브레인 판정 ① · 제안값 = 산림과임업기술(임도) 예제 벽 0.3·판 0.25 · KDS 300㎜ 병기).
# 종전 「세월교 값 승계」(2026-08-25 사용자 확정)는 근거 없는 값이라 걷음 — 수량·그림이 한 칸을 읽음.
# ⚠ 교차 참조 ④ 암거 위 복토(m) — 별표2 교량·암거 "복토 시 흙 두께 50㎝ 이상".
BOX_COVER_M = 0.5
@@ -451,8 +452,10 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
values = dict(options or {})
inner_width = _number(values.get("body_width_m"), _number(defaults.get("body_width_m"), 2.0))
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
wall = _number(values.get("wall_thickness_m"), _number(defaults.get("wall_thickness_m"), None))
slab = _number(values.get("slab_thickness_m"), _number(defaults.get("slab_thickness_m"), None))
wall = wall if wall and wall > 0 else FORD_WALL_THICKNESS_M # 등록부가 비었을 때만(옛 판)
slab = slab if slab and slab > 0 else FORD_SLAB_THICKNESS_M
wing_in = _wing_spec(values, defaults, "in")
wing_out = _wing_spec(values, defaults, "out")
return {
@@ -0,0 +1,190 @@
"""미교차 측점의 지반 샘플을 **지표면 끝까지** 넓힘 — ㉳ (가) (2026-09-14 브레인 판정).
반폭(기본 20m) 샘플 끝에서 사면이 원지반과 안 만나면 면적이 거기서 잘려 성토가 **작게** 섰다
(936be972 미교차 12곳 중 11곳이 넓히면 닫힘 · 성토 +2,559.9㎥). ⇒ **열린 쪽만** +5m 씩 확정 지표면에서
다시 떠 계산하고, 닫히면 멈춘다. 새 걸음에 지표면 밖(무효) 샘플이 섞이면 그 걸음은 안 붙이고 멈춘다 —
그 측점은 「지표면 끝까지 넓혀도 안 만남」으로 미교차가 남는다.
⚠ 상한 숫자를 두지 않는다 — 지표면이 실제로 있는 끝이라 근거가 필요 없음(인위 기본값 금지).
⚠ 716 판정 「미교차 측점만 반폭 +5m 한 번」을 갈음(그것으론 4곳만 닫혔음).
⚠ 계산식은 안 바뀐다 — 샘플(입력)만 넓어진다. 넓힌 샘플은 횡단 파일에 남겨 화면(TS)·Node·B08 이
같은 지반을 본다(한 벌).
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any, Callable
import numpy as np
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
from B06_Section.B06_Section_Engine_Design import (
_SLOPE_CLOSE_TOLERANCE_M,
compute_cross_design,
curve_widening_args,
)
from common_util.common_util_json import atomic_write_json
logger = logging.getLogger(__name__)
#: 한 걸음 — 브레인 판정 「+5m 씩」.
EXTEND_STEP_M = 5.0
#: 지표면 끝까지 넓혀도 안 닫힌 측점의 사유(경고가 그대로 씀).
SURFACE_END_REASON = "지표면 끝까지 넓혀도 성토 비탈이 원지반과 안 만남"
def _valid_sorted(samples: list[dict[str, Any]]) -> list[tuple[float, float]]:
return sorted(
(float(s["offset_m"]), float(s["elevation_m"]))
for s in samples
if s.get("valid") is not False and s.get("elevation_m") is not None
)
def _open_ends(samples: list[dict[str, Any]], design: dict[str, Any]) -> tuple[bool, bool]:
"""(우 끝 열림, 좌 끝 열림) — 샘플 끝의 지반과 설계선 높이차가 허용오차를 넘나."""
ground = _valid_sorted(samples)
line = sorted(
(float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or []
)
if not ground or not line:
return False, False
right = abs(ground[0][1] - line[0][1]) > _SLOPE_CLOSE_TOLERANCE_M
left = abs(ground[-1][1] - line[-1][1]) > _SLOPE_CLOSE_TOLERANCE_M
return right, left
def extend_unclosed(
samples: list[dict[str, Any]],
frame: dict[str, Any],
compute: Callable[[list[dict[str, Any]]], dict[str, Any]],
sampler: Any,
step_m: float = EXTEND_STEP_M,
) -> tuple[list[dict[str, Any]], dict[str, Any]] | None:
"""넓힌 샘플과 `{left_m, right_m, surface_end}` — 이미 닫혔으면 `None`."""
design = compute(samples)
if not design.get("slope_unclosed"):
return None
offsets = [offset for offset, _z in _valid_sorted(samples)]
spacing = min(b - a for a, b in zip(offsets, offsets[1:]) if b - a > 1e-9)
count = max(int(round(step_m / spacing)), 1)
origin = np.array([float(frame["origin"]["x"]), float(frame["origin"]["y"])])
left_axis = np.array([float(v) for v in frame["left_xy"]])
info: dict[str, Any] = {"left_m": 0.0, "right_m": 0.0, "surface_end": False}
samples = list(samples)
while design.get("slope_unclosed"):
right_open, left_open = _open_ends(samples, design)
added: list[dict[str, Any]] = []
for side, is_open, sign in (("right", right_open, -1.0), ("left", left_open, 1.0)):
if not is_open:
continue
edge = min(offsets) if sign < 0 else max(offsets)
new_offsets = np.array([edge + sign * spacing * k for k in range(1, count + 1)])
xy = origin[None, :] + left_axis[None, :] * new_offsets[:, None]
z, valid = sampler.sample_xy(xy)
if not np.all(valid):
info["surface_end"] = True
continue
added.extend(
{
"offset_m": round(float(o), 6),
"x": round(float(p[0]), 6),
"y": round(float(p[1]), 6),
"z": round(float(e), 6),
"elevation_m": round(float(e), 6),
"valid": True,
}
for o, p, e in zip(new_offsets, xy, z)
)
info[f"{side}_m"] += step_m
if not added:
break
samples = sorted(samples + added, key=lambda s: float(s["offset_m"]))
offsets = [offset for offset, _z in _valid_sorted(samples)]
design = compute(samples)
return samples, info
def extend_unclosed_sections(
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
project_root: Path,
standard: dict[str, Any] | None,
sampler: Any,
) -> int:
"""저장 설계가 미교차인 측점을 넓혀 샘플·설계를 자리에서 갈고 횡단 파일에 남긴다. 넓힌 수."""
from B06_Section.B06_Section_Router_Design import (
USER_TOUCHED_KEYS,
ford_drop_at,
ford_surface_drops,
stored_berm,
stored_cut_slope,
)
from common_util.common_util_route_profile import design_elevation_from_longitudinal
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
if sampler is None:
return 0
drops = ford_surface_drops(project_root)
cross_dir = project_root / "B06_Section" / "cross_sections"
changed = 0
for section in cross_sections:
design = section.get("design")
if not isinstance(design, dict) or not design.get("slope_unclosed"):
continue
chainage = float(section.get("chainage_m", 0.0))
def compute(samples: list[dict[str, Any]], design=design, chainage=chainage) -> dict:
return compute_cross_design(
samples,
design_elevation_from_longitudinal(longitudinal, chainage),
ground_type=str(design.get("ground_type") or "ripping_rock"),
section_mode=str(design.get("section_mode") or "left_cut"),
ditch_side=design.get("ditch_side"),
ditch_type=str(design.get("ditch_type") or "standard"),
paved=bool(design.get("paved", False)),
standard=standard,
rock_boundary_offset_m=design.get(
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
),
two_stage_slope=bool(design.get("two_stage_slope", True)),
cut_slope_ratio=stored_cut_slope(design),
ditch_enabled=design.get("ditch_enabled"),
ditch_choice=design.get("ditch_choice"),
surface_drop_m=ford_drop_at(chainage, drops),
berm=stored_berm(design),
**curve_widening_args(section),
)
try:
result = extend_unclosed(
section.get("samples") or [], section["frame"], compute, sampler
)
except (ValueError, KeyError):
continue
if result is None:
continue
samples, info = result
if not info["left_m"] and not info["right_m"]:
continue # 첫 걸음부터 지표면 밖 — 샘플이 그대로라 갈 것이 없음
recomputed = compute(samples)
for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS):
if design.get(key) is not None:
recomputed[key] = design[key]
section["samples"] = samples
section["design"] = recomputed
cross_path = cross_dir / cross_filename(chainage)
if cross_path.is_file():
stored = json.loads(cross_path.read_text(encoding="utf-8"))
atomic_write_json(cross_path, {**stored, "samples": samples})
logger.info(
"미교차 샘플 넓힘: 측점 %.3f 좌 +%sm 우 +%sm 지표면 끝 %s",
chainage,
info["left_m"],
info["right_m"],
info["surface_end"],
)
changed += 1
return changed
+22 -11
View File
@@ -83,6 +83,8 @@ if (input.haul_plan_for) {
structure_spoil_points: input.context?.structure_spoil_points ?? null,
// 잔토는 자연상태로 오고 곡선은 다짐상태다 — 담기 전에 ×C 하는 데 쓴다.
conversion: input.context?.earthwork_conversion ?? null,
// 화면이 그리는 계획 — 잔진동을 거른다(수량은 저장 정본 `haul_plan` 이 따로 낸다).
drawing: true,
});
writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null }));
process.exit(0);
@@ -104,18 +106,27 @@ const result = conversion
)
: null;
// 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06).
const plan = result
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? null,
conversion: conversion ?? null,
})
: null;
// 두 벌을 남긴다 — `haul_plan` 은 **거르지 않은** 수량 정본(B08), `haul_plan_drawing` 은
// 잔진동을 거른 그림(B07 토적도). 거르기가 수량에 닿으면 운반이 사라진다(2026-09-14 브레인 ①).
const planFor = (drawing: boolean) =>
result
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? null,
conversion: conversion ?? null,
drawing,
})
: null;
const plan = planFor(false);
const drawingPlan = planFor(true);
const massHaul = result
? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null)
? massHaulPayload(result, {
...(plan ? { haul_plan: haulPlanPayload(plan) } : {}),
...(drawingPlan ? { haul_plan_drawing: haulPlanPayload(drawingPlan) } : {}),
})
: null;
// 선 다단 벽 목록(④) — 관 연장처럼 기하가 세운 결과를 정본에 남겨 B08 이 줄을 세움.
@@ -39,11 +39,12 @@ from B06_Section.B06_Section_Repository import (
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from common_util.common_util_node_bundle import run_bundle_json
from common_util.common_util_project_settings import (
earthwork_conversion_factors,
haul_equipment_limits,
mixed_conversion_factors,
quantity_settings,
)
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from config.config_db import get_db_pool, run_with_connection
from config.config_system import (
EARTHWORK_CONVERSION_FACTORS,
@@ -96,7 +97,8 @@ async def conversion_factors_for(project_id: Any) -> dict[str, dict[str, float]]
except Exception:
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 계수로 진행: project_id=%s", project_id)
return {kind: dict(entry) for kind, entry in EARTHWORK_CONVERSION_FACTORS.items()}
return earthwork_conversion_factors(quantity_settings(root))
# 암은 구성비 가중 C(㉱ (나)) — 토적표·운반표와 같은 함수.
return mixed_conversion_factors(quantity_settings(root))
async def haul_limits_for(project_id: Any) -> list[tuple[str, float | None]]:
@@ -153,23 +155,43 @@ def _mass_haul_context(
}
def _surface_sampler(project_root: Path, params: dict[str, Any] | None) -> Any:
"""확정 지표면 표고 조회기 — 못 열면 `None`(넓힘을 건너뛰고 종전대로 · 비치명)."""
from common_util.common_util_surface_sampler import build_surface_sampler
try:
return build_surface_sampler(
project_root / "B04_PreProcess" / "models",
str((params or {})["source_filter"]),
str((params or {})["method"]),
bool((params or {})["smooth"]),
)
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
logger.warning("서버 재계산: 확정 지표면을 못 열어 미교차 넓힘을 건너뜀 — %s", exc)
return None
def _enforce_stored_designs(
longitudinal: dict[str, Any],
sections: list[dict[str, Any]],
project_root: Path,
standard: dict[str, Any] | None,
sampler: Any = None,
) -> None:
"""저장분 설계를 **쓰는 시점에** 바로잡는다 — 포장 구간·세월교 노면 하강.
예전에는 상세를 **읽을 때마다** 돌려 화면이 볼 때만 맞았다(저장분은 낡은 채로).
2026-09-06 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다.
"""
from B06_Section.B06_Section_Engine_SampleExtend import extend_unclosed_sections
from B06_Section.B06_Section_Engine_SpoilFill import enforce_spoil_fills
from B06_Section.B06_Section_Router_Design import (
enforce_ford_surface_drops,
enforce_pavement_ranges,
)
# ⚠ 미교차 샘플 넓힘이 **맨 앞**이다 — 뒤의 보정들이 넓힌 지반 위에서 다시 계산해야 한다(㉳ (가)).
extend_unclosed_sections(longitudinal, sections, project_root, standard, sampler)
enforce_pavement_ranges(longitudinal, sections, project_root, standard)
enforce_ford_surface_drops(longitudinal, sections, project_root, standard)
# ⚠ 사토장은 **맨 뒤**다 — 앞의 두 보정이 설계를 다시 계산하면서 사토장 칸을 지운다.
@@ -197,11 +219,12 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
pool = get_db_pool()
# 구조물 몫(채집석 공제·구조물 잔토)도 함께 받아 온다 — **B08 이 낸 값**이고, 안 넘기면
# 통로만 있고 값이 안 흐른다(2026-09-09 실측: 공제가 늘 `None` 이라 사토가 안 줄었다).
response, stored_path, longitudinal_row, haul_inputs = await asyncio.gather(
response, stored_path, longitudinal_row, haul_inputs, surface = await asyncio.gather(
get_section_detail(project_uuid, route_id),
run_with_connection(get_project_storage_relative_path, project_uuid),
run_with_connection(get_longitudinal_section, project_uuid, route_id),
haul_inputs_for(project_uuid),
run_with_connection(get_surface_confirmation_params, str(project_uuid)),
)
marks.append(("종횡단 상세+DB 조회(병렬)", time.perf_counter()))
payload = getattr(response, "model_dump", None)
@@ -216,8 +239,16 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
standard = stored_standard_cross_section(longitudinal_row)
# 포장 구간·세월교 보정 — 고쳐진 설계 위에서 면적·유토곡선이 나와야 한다.
before = [json.dumps(item.get("design"), sort_keys=True, default=str) for item in sections]
# 확정 지표면 — 미교차 측점이 있을 때만 연다(샘플 넓힘 ㉳ (가) · 없으면 여는 비용을 안 씀).
unclosed = any((item.get("design") or {}).get("slope_unclosed") for item in sections)
sampler = await asyncio.to_thread(_surface_sampler, project_root, surface) if unclosed else None
await asyncio.to_thread(
_enforce_stored_designs, detail.get("longitudinal") or {}, sections, project_root, standard
_enforce_stored_designs,
detail.get("longitudinal") or {},
sections,
project_root,
standard,
sampler,
)
fixed = [
item
@@ -235,7 +266,7 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
"detail": detail,
"context": _mass_haul_context(
haul_inputs,
earthwork_conversion_factors(quantity_settings(project_root)),
mixed_conversion_factors(quantity_settings(project_root)),
haul_equipment_limits(quantity_settings(project_root)),
),
},
@@ -0,0 +1,49 @@
/* =============================================================================
* B06_Section_UI_Cross_FillSlope_Notice.ts
* 성토사면 5m 초과 **경고 줄** — 횡단 카드 목록 위 요약 한 줄 + 펼치면 측점 목록
* (2026-09-14 브레인 승인 (나)). 판정은 `_Cross_FillSlope_Warn`, 여기는 그리기만.
* 측점을 누르면 그 카드로 간다. 경고만 — 구조물을 세우거나 값을 바꾸지 않는다.
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
import { fillSlopeLengths } from "./B06_Section_UI_Cross_Fit";
import { FILL_SLOPE_WARN_TEXT, fillSlopeWarnings } from "./B06_Section_UI_Cross_FillSlope_Warn";
import { L, stationLabel } from "./B06_Section_UI_Section_Common";
export interface FillSlopeNotice {
root: HTMLDetailsElement;
/** 측점 설계가 바뀔 때마다 부른다 — 펼침 상태는 그대로 둔다. */
update: (sections: ReadonlyArray<CrossSection>, stationInterval: number) => void;
}
export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice {
const root = document.createElement("details");
root.className = "b06-section__notice";
root.hidden = true;
const summary = document.createElement("summary");
const list = document.createElement("div");
list.className = "b06-section__notice-list";
root.append(summary, list);
return {
root,
update(sections, stationInterval) {
const warnings = fillSlopeWarnings(sections, fillSlopeLengths);
root.hidden = !warnings.length;
summary.textContent = `${FILL_SLOPE_WARN_TEXT} · ${warnings.length}측점`;
list.replaceChildren(
...warnings.map(({ section, sides }) => {
const button = document.createElement("button");
button.type = "button";
const parts = sides.map(({ side, lengthM, open }) => {
const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right");
return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`;
});
button.textContent = `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`;
button.addEventListener("click", () => onPick(section.station_id));
return button;
}),
);
},
};
}
@@ -0,0 +1,88 @@
/* =============================================================================
* B06_Section_UI_Cross_FillSlope_Warn.ts
* 성토사면 길이 5m 초과 **경고** 판정(2026-09-14 브레인 승인 (나)) — 값만 가리고 그리지 않는다.
*
* 근거 — 산림자원법 시행규칙 별표2 Ⅰ.2.차.(3).(나) · 임도설치 규정 별표7 2.차.(3).(나)
* 「성토사면 길이 5m 초과 시 옹벽·석축」. 실무 표본(오솔길 W열 성토면 거리)에서도 흔해
* (영월 63% · 봉화 49%) **경고까지만** — 구조물을 자동으로 세우지 않는다(설계자 판단).
*
* 벽이 선 쪽은 뺀다 — 기슭막이·옹벽이 사면을 끊은 쪽은 이미 조치된 자리다. **좌·우를 갈라**
* 한쪽에만 벽이 서면 반대쪽은 그대로 경고한다.
* 길이는 `fillSlopeLengths`(카드 머리 「성토사면」 칸과 같은 값)를 받아 쓴다 — 여기서 다시 재지 않는다.
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
import { FILL_SLOPE_MAX_LENGTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
/** 브레인 승인 문구 그대로(2026-09-14) — 고치면 승인을 다시 받을 것. */
export const FILL_SLOPE_WARN_TEXT =
"성토사면 길이 5m 초과 — 법령상 옹벽·석축 설치 대상 (산림자원법 시행규칙 별표2 Ⅰ.2.차.(3).(나) · 임도설치 규정 별표7 2.차.(3).(나)) ※ 실무 표본에서도 흔함(영월 63% · 봉화 49%) — 설치 여부는 설계자 판단";
export type FillSlopeSideName = "left" | "right";
export interface FillSlopeSideLength {
lengthM: number;
/** 원지반을 못 만나 거기까지만 잰 하한값. */
open: boolean;
}
export interface FillSlopeWarning {
section: CrossSection;
sides: Array<{ side: FillSlopeSideName } & FillSlopeSideLength>;
}
/** 벽이 서서 성토사면을 끊는 쪽 — 배관 기슭막이 · 독립 기슭막이 · 세월교·BOX암거 측벽. */
export function wallSides(section: CrossSection): Set<FillSlopeSideName> {
const sides = new Set<FillSlopeSideName>();
if (section.ford || section.box) return new Set(["left", "right"]);
const culvert = section.culvert;
if (culvert?.hidden_pipe) {
// 독립 기슭막이 설치 측 — 좌 = +offset(`restrictToSide`) · 양쪽·미지정은 둘 다.
if (culvert.side !== "우") sides.add("left");
if (culvert.side !== "좌") sides.add("right");
} else if (culvert) {
// 유입 = 상단측(미상이면 좌) · 집수정은 벽이 아니다.
const inlet: FillSlopeSideName = (section.uphill_side ?? "left") === "left" ? "left" : "right";
if (culvert.inlet.structure !== "집수정") sides.add(inlet);
if (culvert.outlet.structure !== "집수정") sides.add(inlet === "left" ? "right" : "left");
}
const revetment = section.revetment;
if (revetment) {
// 설치 측이 비면 성토가 나는 쪽(`computeRevetmentLayout` 과 같은 규칙).
const mode = section.design?.section_mode;
const side =
revetment.side === "우"
? "right"
: revetment.side === "좌"
? "left"
: mode === "left_cut"
? "right"
: mode === "right_cut" || mode === "both_fill"
? "left"
: null;
if (side) sides.add(side);
}
return sides;
}
/** 5m 를 **넘는** 성토사면(벽 선 쪽 뺌)이 있는 측점만 — 측점 순서 그대로. */
export function fillSlopeWarnings(
sections: ReadonlyArray<CrossSection>,
lengthsOf: (section: CrossSection) => Record<FillSlopeSideName, FillSlopeSideLength | null>,
): FillSlopeWarning[] {
const warnings: FillSlopeWarning[] = [];
for (const section of sections) {
const lengths = lengthsOf(section);
const walls = wallSides(section);
const sides = (["left", "right"] as const)
.filter((side) => !walls.has(side))
.flatMap((side) => {
const length = lengths[side];
return length && length.lengthM > FILL_SLOPE_MAX_LENGTH_M + 1e-6
? [{ side, ...length }]
: [];
});
if (sides.length) warnings.push({ section, sides });
}
return warnings;
}
@@ -379,7 +379,7 @@ export function createBoxControls(deps: FordControlDeps): {
// 캐시 먼저 — 구체 길이(`span_m`)는 백엔드 `_box_set`과 같은 식으로 다시 잡는다.
if (patch.body_width_m) {
spec.inner_width_m = patch.body_width_m;
spec.span_m = boxSpanM(patch.body_width_m);
spec.span_m = boxSpanM(patch.body_width_m, spec.wall_thickness_m);
}
if (patch.body_height_m) spec.inner_height_m = patch.body_height_m;
}
+9 -48
View File
@@ -20,7 +20,6 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import type {
CrossSection,
EarthworkConversion,
HaulEquipmentLimit,
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
@@ -81,53 +80,10 @@ import {
inferStationInterval,
L,
} from "./B06_Section_UI_Section_Common";
import type { SectionViewController } from "./B06_Section_UI_Section_View_Types";
import { createFillSlopeNotice } from "./B06_Section_UI_Cross_FillSlope_Notice";
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
/**
* 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**.
*
* 평소에는 쓰지 않는다 — 그래프 몫은 `chartWrap`을 직접 재서 정한다(어림값이 실제보다 크면
* 유토곡선 아래에 빈 공간이 남는다). 화면에 붙기 전이라 잴 수 없는 첫 렌더에서만 쓰는
* 출발값이고, 최소 패널 높이 계산의 기준이기도 하다.
*/
export interface SectionViewController {
root: HTMLElement;
render: (
detail: SectionDetailResponse,
verticalExaggeration: number,
crossHalfWidth?: number,
stationInterval?: number,
earthworkConversion?: EarthworkConversion,
haulEquipmentLimits?: HaulEquipmentLimit[],
/** balloon 위치 캐시를 가르는 키(프로젝트+경로). 노선이 다르면 위치가 섞이면 안 된다. */
balloonScope?: string,
/** 자연방토 판정 경사(config). 못 받으면 자연방토 없음으로 본다. */
naturalSpoilMinSlope?: number,
) => void;
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
refreshCard: (chainageM: number) => void;
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
refreshCards: (chainages: ReadonlyArray<number>) => void;
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */
focusStation: (stationId: string) => void;
/** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을
* 올린다(2026-08-29 일원화). null = 선택 해제. */
setStationSelectListener: (listener: (stationId: string | null) => void) => void;
clear: () => void;
/** 종단 아래 구조물 알약 레인에 쓸 목록 — B05 와 같은 부품·같은 표기(2026-09-07 사용자
* 지시 4 「구조물 표시 통일」). 좌측 「구조물 배치」가 목록을 받을 때마다 넘겨준다. */
setStructureMarks: (
structures: ReadonlyArray<StructureInstance>,
types: ReadonlyArray<StructureType>,
) => void;
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */
setStructureEdit: (edit: SectionStructureEdit | null) => void;
/** 계획선 편집 ▲/▼ — 그릴 때마다 불러 선형·편집 함수를 받는다(null = 버튼 없음). */
setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void;
dispose: () => void;
}
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl, SectionViewController };
export function createSectionView(
onDesignChange?: DesignChangeHandler,
@@ -236,6 +192,10 @@ export function createSectionView(
},
});
panel.append(panelResizer.root);
// 성토사면 5m 초과 경고 줄(2026-09-14 브레인 (나)) — 패널과 카드 사이 · 측점 누르면 그 카드로.
const fillSlopeNotice = createFillSlopeNotice((id) =>
selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true),
);
/**
* 패널 **어디에서든** 굴린 휠은 페이지가 아니라 그래프를 좌우로 민다(2026-08-02 사용자 지시).
@@ -489,6 +449,7 @@ export function createSectionView(
// 보던 자리가 맨 앞으로 튀면 못 쓴다. 위치를 잡아 뒀다 되돌린다.
const keepScrollLeft = chartWrap.scrollLeft;
const detail = currentDetail;
fillSlopeNotice.update(detail.cross_sections, cachedStationInterval); // 카드 갱신도 여기를 거침
// 그래프 몫은 **추정하지 않고 잰다**. `chartWrap`은 `flex: 1 / min-height: 0`이라 높이가
// 내용이 아니라 패널에서 정해지므로, 재서 쓰면 되먹임 없이 한 번에 수렴한다.
// 화면에 붙기 전(detached)에는 잴 수 없으니 그때만 `PANEL_CHROME_PX` 추정치로 시작한다.
@@ -599,7 +560,7 @@ export function createSectionView(
}
// panel은 이미 root의 자식이라 replaceChildren이 떼었다 붙이면서 스크롤을 잃는다.
const keepScrollLeft = chartWrap.scrollLeft;
root.replaceChildren(panel, grid);
root.replaceChildren(panel, fillSlopeNotice.root, grid);
chartWrap.scrollLeft = keepScrollLeft;
};
@@ -0,0 +1,59 @@
/* =============================================================================
* B06_Section_UI_Section_View_Types.ts
* `createSectionView` 가 돌려주는 조종기 모양 — 700줄 제한으로 뷰 본체에서 떼어 냄(2026-09-14).
* 부르는 쪽은 종전대로 `_UI_Section_View` 에서 가져간다(거기서 다시 내보냄).
* ========================================================================== */
import type {
EarthworkConversion,
HaulEquipmentLimit,
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
import type { LongitudinalPanelInput } from "./B06_Section_UI_Section_View_Draw";
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
/**
* 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**.
*
* 평소에는 쓰지 않는다 — 그래프 몫은 `chartWrap`을 직접 재서 정한다(어림값이 실제보다 크면
* 유토곡선 아래에 빈 공간이 남는다). 화면에 붙기 전이라 잴 수 없는 첫 렌더에서만 쓰는
* 출발값이고, 최소 패널 높이 계산의 기준이기도 하다.
*/
export interface SectionViewController {
root: HTMLElement;
render: (
detail: SectionDetailResponse,
verticalExaggeration: number,
crossHalfWidth?: number,
stationInterval?: number,
earthworkConversion?: EarthworkConversion,
haulEquipmentLimits?: HaulEquipmentLimit[],
/** balloon 위치 캐시를 가르는 키(프로젝트+경로). 노선이 다르면 위치가 섞이면 안 된다. */
balloonScope?: string,
/** 자연방토 판정 경사(config). 못 받으면 자연방토 없음으로 본다. */
naturalSpoilMinSlope?: number,
) => void;
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
refreshCard: (chainageM: number) => void;
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
refreshCards: (chainages: ReadonlyArray<number>) => void;
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */
focusStation: (stationId: string) => void;
/** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을
* 올린다(2026-08-29 일원화). null = 선택 해제. */
setStationSelectListener: (listener: (stationId: string | null) => void) => void;
clear: () => void;
/** 종단 아래 구조물 알약 레인에 쓸 목록 — B05 와 같은 부품·같은 표기(2026-09-07 사용자
* 지시 4 「구조물 표시 통일」). 좌측 「구조물 배치」가 목록을 받을 때마다 넘겨준다. */
setStructureMarks: (
structures: ReadonlyArray<StructureInstance>,
types: ReadonlyArray<StructureType>,
) => void;
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */
setStructureEdit: (edit: SectionStructureEdit | null) => void;
/** 계획선 편집 ▲/▼ — 그릴 때마다 불러 선형·편집 함수를 받는다(null = 버튼 없음). */
setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void;
dispose: () => void;
}
@@ -195,6 +195,29 @@
gap: var(--spacing-16);
}
/* 성토사면 5m 초과 경고 줄(2026-09-14) — 요약 한 줄, 펼치면 측점 단추 목록. */
.b06-section__notice {
margin-bottom: var(--spacing-8);
color: var(--color-warning);
font-size: var(--text-caption);
}
.b06-section__notice > summary {
cursor: pointer;
}
.b06-section__notice-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
padding-top: var(--spacing-4);
}
.b06-section__notice-list > button {
font-size: var(--text-caption);
white-space: nowrap;
}
.b06-cross-card {
cursor: pointer;
transition:
@@ -520,7 +520,9 @@ def build_mass_haul_drawing(
if curve_entity:
entities.append(curve_entity)
plan = mass_haul.get("haul_plan")
# 그림은 잔진동을 거른 계획을 그린다 — 수량 정본(`haul_plan`)은 거르지 않아 balloon 이
# 너무 많다(2026-09-14 브레인 ①). 옛 저장분은 그림용이 없어 `haul_plan` 을 그린다.
plan = mass_haul.get("haul_plan_drawing") or mass_haul.get("haul_plan")
if isinstance(plan, dict):
entities.extend(_band_entities(drawing_id, plan, curve, mm_h))
entities.extend(_residual_entities(drawing_id, plan, curve, interval_m, mm_h))
+13 -31
View File
@@ -43,7 +43,6 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import (
_invalidate_drawing,
_read_drawing,
_read_json,
_recompute_confirmed_design,
_store_confirmed_drawing,
landuse_source,
lidar_source,
@@ -405,7 +404,7 @@ async def confirm_design_drawing(
) -> DesignDrawingConfirmResponse | JSONResponse:
"""현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다.
횡단도 확정 B06 지정 잠정치를 동일 엔진으로 재계산해 확정치로 승격·저장한다.
횡단도 확정 담긴 측점 설계의 **상태만** 확정으로 올린다(값은 B06 정본 그대로).
"""
try:
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
@@ -444,9 +443,10 @@ async def confirm_design_drawing(
quantity_tables or None,
)
# 횡단도면이면 확정 단면적을 재계산한다 (재계산 실패는 도면 확정을 막지 않음).
# 장은 담긴 측점 전부를 함께 확정한다.
recomputed: list[tuple[int, dict[str, Any]]] = []
# 횡단도면이면 담긴 측점 설계를 확정으로 올린다 — **상태만** 바꾼다. 장은 담긴 측점 전부.
# B07 CAD 에는 설계를 고치는 자리가 없어 덮을 값이 없다. 예전에는 입력 셋(지반·단면·측구
# 쪽)만으로 단면적을 다시 계산해 설계를 통째로 덮어, 암선·절토경사·표준 횡단·구조물
# 트림과 사용자 입력(측구 끔)이 사라졌다(2026-09-14 936be972 실측 62측점 · 브레인 ②).
cross_match = _CROSS_ID.fullmatch(drawing_id)
pool = get_db_pool()
targets: list[int] = []
@@ -454,39 +454,21 @@ async def confirm_design_drawing(
targets = list(sheet["chainages"])
elif item.kind == "cross" and cross_match:
targets = [int(cross_match.group(1))]
for chainage_int in targets:
designation = designs.get(chainage_int)
if not designation:
continue
try:
recomputed.append(
(
chainage_int,
await asyncio.to_thread(
_recompute_confirmed_design,
longitudinal_path,
f"cross_{chainage_int:05d}m",
designation,
),
)
)
except (ValueError, KeyError, FileNotFoundError, OSError):
logger.warning(
"B07 확정 단면적 재계산 실패 (도면 확정은 유지): drawing_id=%s 측점=%s",
drawing_id,
chainage_int,
exc_info=True,
)
confirmed_designs = [
(chainage_int, {**designs[chainage_int], "status": "confirmed"})
for chainage_int in targets
if designs.get(chainage_int)
]
async with pool.acquire() as connection:
await connection.begin()
try:
for chainage_int, confirmed_design in recomputed:
for chainage_int, _design in confirmed_designs:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
chainage_int=chainage_int,
patch=confirmed_design,
patch={"status": "confirmed"},
)
async with connection.cursor() as cursor:
if all_confirmed:
@@ -502,7 +484,7 @@ async def confirm_design_drawing(
id=drawing_id,
confirmed=True,
all_confirmed=all_confirmed,
design=recomputed[0][1] if len(recomputed) == 1 else None,
design=confirmed_designs[0][1] if len(confirmed_designs) == 1 else None,
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
@@ -646,32 +646,3 @@ def _invalidate_drawing(project_root: Path, drawing_id: str) -> None:
if entry:
entry["confirmed"] = False
_write_manifest(project_root, manifest)
def _recompute_confirmed_design(
longitudinal_path: Path, cross_stem: str, designation: dict[str, Any]
) -> dict[str, Any]:
"""B06 지정값과 현재 계획고로 절·성토 단면적을 재계산해 확정치(status=confirmed)로 만든다.
B07 CAD에는 아직 편집 가능한 설계선이 없으므로, 저장된 지정값(지반유형·단면유형·
측구위치) 계획고로 동일 엔진을 재실행해 확정 시점 값을 고정한다.
"""
longitudinal = _read_json(longitudinal_path)
cross_path = longitudinal_path.parent.parent / "cross_sections" / f"{cross_stem}.json"
source = _read_json(cross_path)
samples = source.get("samples")
if not isinstance(samples, list):
raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.")
design_elevation = design_elevation_from_longitudinal(
longitudinal, float(source.get("chainage_m", 0.0))
)
design = compute_cross_design(
samples,
design_elevation,
ground_type=designation["ground_type"],
section_mode=designation["section_mode"],
ditch_side=designation.get("ditch_side"),
**curve_widening_args(source),
)
design["status"] = "confirmed"
return design
+1 -1
View File
@@ -86,7 +86,7 @@ class DesignDrawingConfirmResponse(BaseModel):
id: str
confirmed: bool
all_confirmed: bool
# 횡단도 확정 시 재계산된 확정 설계(status=confirmed). 종단도·재계산 불가 시 None.
# 횡단도 확정 시 저장된 설계(상태만 status=confirmed). 종단도·장·설계 없음이면 None.
design: dict[str, Any] | None = None
+1 -1
View File
@@ -418,7 +418,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
// **기다린다**: 안 기다리면 오버레이가 먼저 걷혀, 버튼은 [수정]인데 CAD는 아직
// 편집이 열린 어긋난 순간이 생긴다.
await loadDrawing(currentDrawing, currentIndex);
// 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다.
// 확정한 설계(B06 정본 그대로 · 상태만 확정)로 지반/계획 정보 패널을 갱신한다.
if (currentDrawing.kind === "cross") {
infoPanelHost.replaceChildren(
buildDesignInfoPanel(
@@ -181,7 +181,7 @@ function infoRow(label: string, value: string): HTMLElement {
}
/**
* / 2 (, B07 ).
* / 2 (B06 · B07 ).
*
* **( ) ** `design`
* (2026-09-03 ).
@@ -648,8 +648,10 @@ def build() -> dict[str, Any]:
basis_found += 1
if basis_quantity_is_grouped(basis_qty):
basis_grouped += 1
elif form in ("requirement", "productivity"):
elif form in ("requirement", "productivity") and not crew_table(table):
# 참조·계수표는 곱할 값이 아니므로 목록에 넣지 않는다 — 잡음이 되면 안 본다.
# 작업조 표도 뺌 — 밑수가 시공량 열(1단위당 = 인원 ÷ 시공량 · B09 CrewOutput)이라
# 「N㎡당」 문구가 없어도 곱셈이 안 틀림(12-38-3 설치·해체가 여기 걸려 막혀 있었음 · 2026-09-14 301).
basis_missing.append(
{
"pum_table_id": table["table_id"],
@@ -110,6 +110,11 @@ FORM_JUDGMENTS: dict[str, tuple[str, str, str]] = {
# 절 제목으로 읽어 12-2 에 붙어 있던 표(빌더가 앞 절 이어받기로 고침). 형태 판정은 그대로.
"F0358": ("12-17-1", "reference", "시설유형 Type-Ⅰ~Ⅳ 적용 기준 설명"),
"F0360": ("12-17-1", "reference", "현장조건 Type-Ⅰ~Ⅲ 적용 기준 설명"),
"F0385": (
"12-34-1",
"requirement",
"인력(인)·기계(대, Q=5.4㎥/hr) 소요량 표 — 「별도계상」 은 레미콘 자재 줄 비고일 뿐",
),
"F0388": ("12-34-4", "reference", "「재료비 JOINT FILLER」 항목만 — 값이 없는 구성 안내"),
"F0390": ("12-36", "reference", "제작비·운송비·설치비 「견적처리」"),
"F0392": ("12-38-1", "coefficient", "사용횟수별 잔존율(12회 25%·25회 10%)"),
@@ -11,7 +11,7 @@
가른 표는 **빈칸** 자원을 맞춘 · 계수 · 참조 · 미판정 표에 갈래를 지어내지 않는다.
공종마다 `variant_keys` 표들의 갈래 + 원문이 정했으나 자원 줄로 서는 갈래
(불도저 운반 공식 갈래 · 합산형 단계 잎의 암질 · 9-4-1 [] 평균) · 합산형 부모는 단계 갈래.
(불도저 운반 공식 갈래 · 합산형 단계 잎의 암질 · 9-4-1 [] 평균 · 유로폼 12-38-2 부자재 요율 머리) · 합산형 부모는 단계 갈래.
"""
from __future__ import annotations
@@ -28,6 +28,9 @@ def _add(labels: list[str], seen: set[str], label: str, normalize: Any) -> None:
def attach_variant_keys(nodes: list[dict[str, Any]], edition: str) -> None:
"""표마다 `variant_key`, 공종마다 `variant_keys` 를 채운다(자리에서)."""
from B09_Estimation.B09_Estimation_Euroform import CODE as EUROFORM_CODE
from B09_Estimation.B09_Estimation_Euroform import RATE_TABLE as EUROFORM_RATE_TABLE
from B09_Estimation.B09_Estimation_Euroform import rates as euroform_rates
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import extract_dozer_factors
from B09_Estimation.B09_Estimation_ParentSteps import AVERAGE_BASIS, AVERAGE_VARIANT, rock_rows
from B09_Estimation.B09_Estimation_ResourceAxis import build_resource_axis
@@ -62,6 +65,9 @@ def attach_variant_keys(nodes: list[dict[str, Any]], edition: str) -> None:
if code in steps:
for rock, _, _ in rock_rows({"tables": [table]}):
_add(labels, table_seen, rock, normalize)
if code == EUROFORM_CODE and table.get("pum_table_id") == EUROFORM_RATE_TABLE:
for head in euroform_rates({"tables": [table]}): # 부자재 요율 머리 갈래
_add(labels, table_seen, head, normalize)
table["variant_key"] = labels
for label in labels:
_add(keys, seen, label, normalize)
@@ -52,6 +52,9 @@ FACE_DRESSING_FILL_SUGGESTED = (
"영월 실무 설계내역 「성토사면고르기 06M3 B/H」(백호 = 무한궤도 굴착기) · 임도는 산지라"
" 타이어식이 잘 안 들어감 — 타이어식도 고를 수 있음(2026-09-14 브레인 ②)",
)
#: 초류종자살포 비탈면 토질 — 품셈 5-24 씨앗뿜어붙이기의 잎 둘(5-24-1 일반 · 5-24-2 마사토) 이름 그대로.
#: 매핑 `leaf_from` 이 잎 코드로 잇고 비면 금액 없이 사유 · 제안값 없음(2026-09-14 브레인 ㉮).
SEED_SPRAY_GROUNDS = ("일반", "마사토")
#: 지장목제거 뿌리뽑기(9-21 제근) 굴착기 크기 — 품셈 9-21 표 갈래 0.2·0.7(무한궤도). 등급과 한 갈래.
#: ⚠ 제안값은 칸 곁에만(스스로 안 고름 · 비면 금액 없이 사유 — 2026-09-14 브레인 판정).
ROOT_REMOVAL_EXCAVATOR_SIZES = ("0.2", "0.7")
@@ -116,6 +119,17 @@ class SummaryInput:
subgrade_compaction_enabled: bool = False
# 면고르기 면적 덮어쓰기 `{fill, cut}`(㎡) — 비우면 파종 면적(판정 Ⓐ).
face_dressing_area_m2: dict[str, float | None] = field(default_factory=dict)
# 토취(반입토) — 유토곡선이 낸 성토 부족분(다짐상태 ㎥)과 구간(2026-09-14 브레인 ①).
borrow_m3: float = 0.0
borrow_sites: list[dict[str, Any]] = field(default_factory=list)
#: 토취(반입토) 줄 — 수량만 서고 금액은 사유(「줄은 서고 금액은 안 섬」).
BORROW_NAME = "토취(반입토)"
BORROW_REASON = (
"유토곡선이 성토 부족분으로 낸 수량(다짐상태) — 토취장 거리·재료 미정이라 금액이 서지 않음"
" · 반입 재료를 몰라 자연상태로 되돌리지 않음"
)
#: 노체다짐 줄 — ⭐ 2026-09-13 판정 「별도 줄 · 칸으로 켜고 끔 · 기본 꺼짐」.
@@ -188,6 +202,16 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
)
)
if source.borrow_m3 > 0:
sites = " · ".join(
f"{float(s['from_m']):g}~{float(s['to_m']):g}m {float(s['volume_m3']):,.2f}"
for s in source.borrow_sites
)
note = f"{BORROW_REASON} · 구간 {sites}" if sites else BORROW_REASON
rows.append(
SummaryRow(group=BORROW_NAME, amount=source.borrow_m3, note=note, in_bill=False)
)
# ── 운반 — 수단별. 무대는 집계에 오르되 내역 줄이 아니다 ────────
rows.extend(_haul_rows(source))
@@ -373,7 +397,7 @@ def _haul_rows(source: SummaryInput) -> list[SummaryRow]:
for item in source.haul_rows:
key = str(item.get("equipment") or "")
label = HAUL_LABELS.get(key, key or "운반")
ground = str(item.get("ground") or "")
ground = str(item.get("rock_class") or item.get("ground") or "")
distance = item.get("average_distance_m")
note = f"평균운반거리 {float(distance):.2f} m" if isinstance(distance, (int, float)) else ""
# ⚠ **자연상태로 싣는다** — 「운반거리 산정은 다짐상태, 내역서 수량은 자연상태」
+4 -2
View File
@@ -105,15 +105,17 @@ def annotate(
component["reuse_note"] = NOTE_REUSE_MISSING
continue
count = entry.get("reuse_count")
# 근거 — 대개 1-7-1 분류 · 그 공종 표가 직접 적었으면 그 표(집수정 12-15 · 2026-09-14).
basis = str(entry.get("basis") or "품셈 1-7-1")
for component in targets:
component["reuse_count"] = count
component["reuse_note"] = (
f"품셈 1-7-1 {count}회 — 「{entry.get('matched_example')}"
f"{basis} {count}회 — 「{entry.get('matched_example')}"
if count
else NOTE_NOT_APPLICABLE
)
if count:
notes.append(f"{structure.get('name') or type_id} 거푸집 {count}회 (품셈 1-7-1)")
notes.append(f"{structure.get('name') or type_id} 거푸집 {count}회 ({basis})")
else:
missing.append(type_id)
return notes, sorted(set(missing))
@@ -116,6 +116,7 @@ def build_handoff(
face_dressing_cut_class: str | None = None,
face_dressing_fill_class: str | None = None,
root_removal_excavator_m3: str | None = None,
seed_spray_ground: str | None = None,
priced_sheets: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.
@@ -137,6 +138,8 @@ def build_handoff(
"face_dressing_fill_class": face_dressing_fill_class,
# 제근 굴착기 크기 — 등급과 「크기·등급」 한 갈래로(매핑 `variant_template` · 09-14).
"root_removal_excavator_m3": root_removal_excavator_m3,
# 초류종자살포 비탈면 토질 — 부모 5-24 의 잎(일반·마사토)을 고름(매핑 `leaf_from` · 09-14 ㉮).
"seed_spray_ground": seed_spray_ground,
}
rows, misses = _earthwork_rows(
summary_table, table, methods, bench_cut_depth_m, variant_inputs
@@ -38,11 +38,11 @@ def _pickers(structure: dict[str, Any], mapping: WorkItemMapping) -> list[tuple[
"""이 구조물의 성분을 **이름으로 집는** 공종 줄 — (자리, 이름들). 집는 조건은 각 빌더와 같다."""
type_id = str(structure.get("type_id") or "")
entry = mapping.for_structure(type_id) or {}
composite = mapping.composite_for(type_id)
composite = mapping.composite_for(type_id, structure)
found: list[tuple[str, set[str]]] = []
if entry.get("billing_component"):
found.append(("구조물 줄", {str(entry["billing_component"])}))
if composite and not entry.get("work_item_code"):
if composite: # 걸린 묶음은 곧장 잇는 코드보다 위(빌더와 같은 조건)
for part in composite.get("parts") or []:
if isinstance(part, dict):
label = f"묶음 조각 「{part.get('name') or part.get('code')}"
@@ -146,10 +146,16 @@ BLOCKED_UNCONFIRMED = "unconfirmed"
#: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다
#: (지장목제거의 「뿌리뽑기·잡관목제거」). 갈래로 읽으면 「시공법 미지정」이라는 **틀린 사유**가
#: 붙는다(2026-09-09 실측). 정의처는 `EarthworkSummary` 이고 여기서 그대로 가져다 쓴다.
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
BORROW_NAME,
)
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
GROUND_SPLIT_GROUPS as GROUND_SPLIT_GROUPS,
)
#: 코드 없이 **수량만** 서는 집계 줄 — 막힘 사유는 집계 비고 그대로(토취 · 2026-09-14 브레인 ①).
SUMMARY_ONLY_GROUPS = frozenset({BORROW_NAME})
SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"})
#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다.
@@ -271,13 +277,20 @@ class WorkItemMapping:
return row
return None
def composite_for(self, type_id: str) -> dict[str, Any] | None:
def composite_for(
self, type_id: str, structure: dict[str, Any] | None = None
) -> dict[str, Any] | None:
"""품셈에 그 이름의 공종이 없어 **여러 공종을 묶는** 자리인가.
코드로 두면 매핑을 찾은 구별이 된다 묶음을 적어 구별한다.
"""
options = (structure or {}).get("options") or {}
for row in self.composite.get("items") or []:
if row.get("type_id") == type_id:
# `when` — 같은 종류라도 그 제원일 때만 묶음(콘크리트 집수정만 12-15 조립 · 09-14 ⑸).
when = row.get("when") or {}
if row.get("type_id") == type_id and all(
str(options.get(key) or "") == str(value) for key, value in when.items()
):
return row
return None
@@ -382,6 +395,30 @@ def composite_quantities(
entry["not_ready"] = True
entry["why"] = why
missing.append({"code": spec.get("code"), "reason": why})
if suffix == "formwork_reuse":
# 12-4 사용횟수 갈래 — B08 이 성분에 단 횟수(`Formwork.annotate`) 그대로(한 벌 · 09-14).
counts = {
component.get("reuse_count")
for component in structure.get("components") or []
if str(component.get("name") or "").strip() in found
}
count = next(iter(counts)) if len(counts) == 1 else None
if isinstance(count, int) and count > 0:
entry["kind"] = f"{count}"
entry["kind_basis"] = next(
(
str(component.get("reuse_note") or "")
for component in structure.get("components") or []
if str(component.get("name") or "").strip() in found
),
"",
)
entry["code"] = f"{spec.get('code')}#{count}"
elif found:
why = "거푸집 사용횟수가 없거나 둘 이상이라 12-4 갈래를 못 고름"
entry["not_ready"] = True
entry["why"] = why
missing.append({"code": spec.get("code"), "reason": why})
if suffix == "rebar_complexity":
# 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다.
complexity, why = rebar_complexity(
@@ -459,10 +496,12 @@ def rebar_complexity(
continue
if row.get("form") == form:
if row.get("class"):
return str(row["class"]), f"품셈 12-3 [주]①{row.get('matched')}"
basis = row.get("basis") or "품셈 12-3 [주]①"
return str(row["class"]), f"{basis}{row.get('matched')}"
return None, str(row.get("why") or "원문 예시에 없음")
if fallback and fallback.get("class"):
return str(fallback["class"]), f"품셈 12-3 [주]①{fallback.get('matched')}"
basis = fallback.get("basis") or "품셈 12-3 [주]①" # 그 공종 표가 직접 적으면 그 표(12-15)
return str(fallback["class"]), f"{basis}{fallback.get('matched')}"
from B08_Quantity.B08_Quantity_Wording import type_label
detail = f"({form})" if form else "(형식이 아직 입력되지 않음)"
@@ -11,6 +11,7 @@ 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_EarthworkSummary import HAUL_LABELS
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_FORMULA_MISSING,
BLOCKED_INPUT_MISSING,
@@ -28,6 +29,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
ORIGIN_STRUCTURE,
SLOPE_GROUPS,
SUBTOTAL_GROUPS,
SUMMARY_ONLY_GROUPS,
WorkItemMapping,
composite_quantities,
masonry_class,
@@ -164,15 +166,18 @@ def _earthwork_rows(
template_ready = all(inputs.get(name) for name in needs)
if template and template_ready:
variant_value = template.format(**inputs)
leaf_from = str((entry or {}).get("leaf_from") or "")
leaf_codes = (entry or {}).get("leaf_codes") or {}
leaf = leaf_codes.get(inputs.get(leaf_from)) if leaf_from else None
code = leaf or code
missing = not leaf if leaf_from else (not variant_value or not template_ready)
# 매핑이 「이 칸이 비면 못 고름」이라 적은 갈래 — 금액 없이 입력 사유(면고르기 · 09-14 Ⓒ).
if (
code
and (not variant_value or not template_ready)
and (entry or {}).get("variant_missing_reason")
):
if code and missing and (entry or {}).get("variant_missing_reason"):
blocked_kind = blocked_kind or BLOCKED_INPUT_MISSING
blocked_reason = blocked_reason or str(entry["variant_missing_reason"])
if code is None and not is_subtotal:
if group in SUMMARY_ONLY_GROUPS: # 토취 — 코드 없이 수량만 · 사유는 집계 비고(브레인 ①)
blocked_kind, blocked_reason = BLOCKED_INPUT_MISSING, str(row.get("note") or "")
elif code is None and not is_subtotal:
label = f"{group}({ground})" if ground else group
unmatched.append(f"{label}{method_note}" if method_note else label)
if blocked_kind is None:
@@ -239,13 +244,15 @@ def _haul_rows(
in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True)
if code is None and in_bill:
unmatched.append(f"운반({equipment})")
# ⚠ 코드가 없으면 **줄에 막힘 표시를 단다**(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 ""
# ⚠ 코드가 없으면 **줄에 막힘 표시를 단다**(2026-09-09) — 목록에만 실으면 금액이 조용히
# 빠진다. ⚠ `in_bill` 이 False 인 무대 줄은 **막힌 것이 아니다** — 품에 포함이라 안 세움.
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
# 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는
@@ -269,8 +276,10 @@ def _haul_rows(
rows.append(
{
"work_item_code": code,
"name": f"{equipment} 운반",
"spec": str(row.get("ground") or ""),
"name": entry.get("master_name") or HAUL_LABELS.get(equipment, f"{equipment} 운반"),
"spec": " · ".join(
dict.fromkeys(filter(None, (row.get("rock_class"), row.get("ground"))))
),
"unit": "",
"quantity": quantity,
# 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다).
@@ -433,7 +442,11 @@ def _structure_rows(
# m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는
# 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다.
billing = _component_billing(structure, entry)
composite = mapping.composite_for(type_id) if code is None else None
composite = mapping.composite_for(type_id, structure)
if composite:
code = None # 묶음이 걸리면 곧장 잇는 코드보다 위(콘크리트 집수정 12-15 조립 · 09-14)
if composite.get("outside_note"):
class_basis = " · ".join(p for p in (class_basis, composite["outside_note"]) if p)
kind = structure_kind(structure) if composite else None
parts: list[dict[str, Any]] | None = None
parts_missing: list[dict[str, Any]] = []
@@ -600,7 +613,7 @@ def _placing_rows(
# 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 —
# 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.**
# ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다.
if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get(
if mapping.composite_for(str(structure.get("type_id") or ""), structure) or structure.get(
"unconfirmed"
):
continue # 기본값으로 선 구조물도 — 금액에 안 듦
@@ -21,6 +21,12 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
STATUS_NEEDS_INPUT as PREP_NEEDS_INPUT,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
STATUS_NO_WORK_ITEM as PREP_NO_WORK_ITEM,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE,
)
@@ -105,13 +111,20 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]
def _prep_blocked_kind(status: str) -> str | None:
"""준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 모르는 상태는 `None`."""
if status == PREP_PENDING:
"""준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 모르는 상태는 단가 자료 없음.
근거 없음 `input_missing` 으로 보내면 사방 원단위처럼 **우리가 만들 ** B09
입력이 필요합니다 뜬다 사용자가 넣을 칸을 찾아 헤맨다(2026-09-14 브레인 ).
"""
if status == PREP_NEEDS_INPUT:
return BLOCKED_INPUT_MISSING
if status == PREP_PENDING:
return BLOCKED_UNIT_DATA_MISSING
if status == PREP_COUNTED_ELSEWHERE:
# 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다.
return None
if status == PREP_NOT_APPLICABLE:
if status in (PREP_NOT_APPLICABLE, PREP_NO_WORK_ITEM):
# 공종 없는 줄은 자재총괄 줄로 금액이 섬(「자재 단가」 탭) — 여기서 막힘으로 세면 두 벌.
return None
return BLOCKED_UNIT_DATA_MISSING
@@ -24,6 +24,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
WorkItemMapping,
)
from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import LOADING_EQUIPMENT
from B08_Quantity.B08_Quantity_Engine_RockSplit import split_amounts
SPOIL_NAME = "사토 운반"
#: ⚠ **사토장 사면 물량은 안 센다**(2026-09-09 세 창 확인). 교본 6장 3절은 「완료 구간 비탈면을
@@ -98,20 +99,28 @@ def spoil_haul_rows(
unknown = float(spoil.get("ground_unknown_m3") or 0.0)
if by_ground or unknown > 0:
rows: list[dict[str, Any]] = []
for label, amount in sorted(by_ground.items()):
leg = by_distance.get(label)
# 암은 운반표와 같은 구성비 몫으로(자리표시 리핑암을 값으로 안 씀 · ㉱ `RockSplit`).
shares = (haul_table or {}).get("rock_shares")
for label, amount, share_reason, rock_class, source in split_amounts(by_ground, shares):
leg = by_distance.get(source)
leg_blocked = blocked if leg is None else False
rows.append(
_spoil_row(
code,
amount,
distance if leg is None else leg,
leg_blocked,
reason if leg_blocked else "",
leg_blocked or bool(share_reason),
share_reason or (reason if leg_blocked else ""),
note,
ground=label,
extra=" · ".join(
(DISTANCE_FROM_SETTING if leg is None else DISTANCE_FROM_SITE, state_note)
part
for part in (
f"암 갈래 {rock_class}" if rock_class else "",
DISTANCE_FROM_SETTING if leg is None else DISTANCE_FROM_SITE,
state_note,
)
if part
),
)
)
@@ -166,7 +166,7 @@ def rubble_base_rows(
total = 0.0
bases: list[str] = []
for structure in unit_quantity_table.get("structures") or []:
if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get(
if mapping.composite_for(str(structure.get("type_id") or ""), structure) or structure.get(
"unconfirmed"
):
continue # 묶음 조각이 품음 · 기본값으로 선 구조물은 금액에 안 듦
@@ -254,6 +254,8 @@ def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]:
{
"equipment": row["equipment"],
"ground": row["ground"],
# 암 갈래(구성비로 가른 줄 · `RockSplit`) — 집계표 공종 칸이 흙깎기와 같은 이름을 씀.
"rock_class": row.get("rock_class"),
"volume_m3": row["volume_m3"],
"volume_basis": row.get("volume_basis") or "compacted",
"natural_m3": row.get("natural_m3"),
@@ -264,6 +266,22 @@ def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]:
]
def borrow_of(plan: dict[str, Any] | None) -> dict[str, Any] | None:
"""토취(반입토) — 유토곡선 잔량의 부족분과 구간(2026-09-14 브레인 ①). 없으면 `None`.
**다짐상태** 그대로 반입 재료를 몰라 자연상태로 되돌릴 계수가 없음(지어내지 않음).
"""
volume = float((plan or {}).get("borrow_m3") or 0.0)
if volume <= 0:
return None
sites = [
{"from_m": r.get("from_m"), "to_m": r.get("to_m"), "volume_m3": float(r["volume_m3"])}
for r in (plan or {}).get("residuals") or []
if r.get("kind") == "borrow" and float(r.get("volume_m3") or 0.0) > 0
]
return {"volume_m3": volume, "volume_basis": "compacted", "sites": sites}
@dataclass(slots=True)
class HaulCheck:
"""검산 — 무대를 안 내면 이 대조가 죽는다(PLAN 8-7 ㉡)."""
+34 -1
View File
@@ -369,6 +369,36 @@ def _extra_walls_at(extra_walls: dict[float, list[dict]] | None, chainage: float
return extra_walls[best] if abs(best - chainage) <= SECTION_MATCH_TOLERANCE_M else []
#: BOX암거 치수 칸 — 안 적으면 등록부 기본값(제안값)으로 서되 **미확정**(2026-09-14 브레인 판정 ·
#: 2026-09-15 12장 D). 연장은 기본값이 없어 여기 없음 — 비면 전개식이 사유를 냄.
BOX_DIMENSIONS = (
("body_width_m", "본체 폭"),
("body_height_m", "본체 높이"),
("wall_thickness_m", "측벽 두께"),
("slab_thickness_m", "상·저판 두께"),
("haunch_m", "헌치"),
("blinding_thickness_m", "기초(버림) 두께"),
)
UNCONFIRMED_BOX = "BOX암거 치수({filled})가 시설 지점에 없어 기본값으로 섰음 — 적으면 금액이 섬"
def _box_row(base: dict[str, Any], registry: dict[str, Any]) -> dict[str, Any]:
"""BOX암거 줄 — 빈 치수 칸에 등록부 제안값을 채우고 그 사실을 미확정 사유로 붙임."""
options = dict(base["options"])
filled = []
for key, label in BOX_DIMENSIONS:
if _blank(options.get(key)) and registry.get(key) is not None:
options[key] = registry[key]
filled.append(label)
reason = UNCONFIRMED_BOX.format(filled=" · ".join(filled)) if filled else ""
return {
**base,
"options": options,
"unconfirmed": reason,
"notes": [f"{reason}"] if reason else [],
}
def facility_structures(
points: list[dict[str, Any]],
extra_walls: dict[float, list[dict]] | None = None,
@@ -405,8 +435,11 @@ def facility_structures(
"end_m": point.get("end_m") if point.get("end_m") is not None else chainage,
"options": options,
}
if facility == "box_culvert":
rows.append(_box_row(base, defaults(facility)))
continue
if facility not in (FACILITY_PIPE, "revetment"):
rows.append(base) # BOX암거·물넘이·세월교 — 전개식·관측값이 없으면 사유로 섬
rows.append(base) # 물넘이·세월교 — 전개식·관측값이 없으면 사유로 섬
continue
legacy = facility == "revetment" # 독립 기슭막이 — 벽 칸 밖의 제원(뒷길이 등)도 벽이 씀
wall_defaults = defaults(facility)
+25 -84
View File
@@ -50,15 +50,21 @@ from B08_Quantity.B08_Quantity_Engine_Preparation_Ancillary import ( # noqa: E4
ANCILLARY_ITEMS,
ancillary_rows,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_FrameMaterial import ( # noqa: E402
FRAME_MATERIAL_SUGGESTED,
frame_material_rows,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( # noqa: E402
STATUS_COUNTED_ELSEWHERE,
STATUS_NEEDS_INPUT,
STATUS_NOT_APPLICABLE,
STATUS_PENDING,
STATUS_READY,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_TreeWaste import tree_waste_rows # noqa: E402
__all__ = ["ANCILLARY_ITEMS", "ancillary_rows"] # 갈라 나간 뒤에도 여기서 읽을 수 있게
# 갈라 나간 뒤에도 여기서 읽을 수 있게(부대시설 2026-09-09 · 규준틀 재료 2026-09-14).
__all__ = ["ANCILLARY_ITEMS", "FRAME_MATERIAL_SUGGESTED", "ancillary_rows", "frame_material_rows"]
def batter_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int, list[str]]:
@@ -170,7 +176,7 @@ def _topsoil_row(
return {
**base,
"amount": None,
"status": STATUS_PENDING,
"status": STATUS_NEEDS_INPUT,
"reason": (
"노면 면적을 못 셉니다 — 횡단 설계에 노체 끝(노면 폭)이 없는 측점이 있음. "
f"대상은 노면 + 절토대상지(별표2) · 절토 사면 {cut:,.1f}㎡ 만으로는 세우지 않음"
@@ -184,7 +190,7 @@ def _topsoil_row(
return {
**base,
"amount": None,
"status": STATUS_PENDING,
"status": STATUS_NEEDS_INPUT,
"reason": (
"대상 면적이 0 ㎡ 입니다 — 횡단·사면표가 아직 서지 않았습니다. "
"0 ㎡ 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다"
@@ -309,7 +315,7 @@ def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) -
"amount": area if area > 0 else None,
# ⭐ 2026-09-13 판정 Ⓑ — 셈은 토공집계 「지장목제거 · 뿌리뽑기」(FP-09-21)가 한다.
# 여기는 **보이되 안 실린다**(같은 면적 · 같은 작업 — 또 세면 이중계상).
"status": STATUS_COUNTED_ELSEWHERE if area > 0 else STATUS_PENDING,
"status": STATUS_COUNTED_ELSEWHERE if area > 0 else STATUS_NEEDS_INPUT,
"reason": " · ".join(reasons),
"reference_amount": area,
"work_item_code": None,
@@ -364,7 +370,7 @@ def chipping_rows(enabled: Any, volume_m3: Any) -> list[dict[str, Any]]:
"item": CHIPPING_ITEM,
"unit": "",
"amount": amount,
"status": STATUS_READY if amount and amount > 0 else STATUS_PENDING,
"status": STATUS_READY if amount and amount > 0 else STATUS_NEEDS_INPUT,
"reason": CHIPPING_ON_NOTE if not amount else "산출 조건에서 넣은 부피 (확정 5차 5번)",
"work_item_code": CHIPPING_CODE,
}
@@ -382,7 +388,7 @@ def _root_steps_rows(
"item": "뿌리 적재",
"unit": "",
"amount": area if area > 0 else None,
"status": STATUS_READY if area > 0 else STATUS_PENDING,
"status": STATUS_READY if area > 0 else STATUS_NEEDS_INPUT,
"reason": (
f"{ROOT_STEPS_NOTE} · {ROOT_REMOVAL_BASIS}"
" · ⚠ **품셈 9-20-2 는 「10주당」이라 밑수 축이 다름** — 면적 축으로 내고"
@@ -434,7 +440,7 @@ def _topsoil_haul_row(
"item": "표토 운반·적치",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"status": STATUS_NEEDS_INPUT,
"reason": f"{TOPSOIL_HAUL_LAW} · 제거 면적이 아직 안 서서 운반도 못 셈",
"work_item_code": None,
}
@@ -444,7 +450,7 @@ def _topsoil_haul_row(
"item": "표토 운반·적치",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"status": STATUS_NEEDS_INPUT,
"reason": (
f"{TOPSOIL_HAUL_LAW} · 운반 부피 = 제거 면적 × 표토 두께 — 두께가 아직 입력되지"
f" 않았습니다. {TOPSOIL_ORIGINAL_APPLIED} (제거 면적 {float(area):,.1f}㎡)"
@@ -472,7 +478,7 @@ def _topsoil_haul_row(
"item": "표토 운반·적치",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"status": STATUS_NEEDS_INPUT,
"reason": (
f"{TOPSOIL_HAUL_LAW} · 운반거리가 아직 입력되지 않았습니다 — 「최고 홍수위보다"
f" 높은 장소」는 현장에서 정하는 자리라 품셈이 거리를 주지 않습니다"
@@ -505,88 +511,17 @@ def _topsoil_haul_row(
}
#: 규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리**(2026-09-09).
#: 품셈 11-2·11-3 [주]④ 「재료량은 **설계수량에 따른다**」 ⇒ 품셈이 값을 안 주는 것이지
#: 「안 센다」가 아니다. 그래서 **제안값을 보이고 사용자가 고치는** 모양으로 둔다
#: (확정 ⑨·⑩ 과 같은 틀 — 「가는 기본값이고 나 선택처럼 동작할 수 있어야 함」).
#: ⚠ **제안값은 실무 관측값이지 법정 기준이 아니다** — 울진 소광 원단위 라이브러리 §8
#: 「규준틀 수평 | 개소 | 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏」.
#: ⚠ **비탈 규준틀 값은 그 시트에 없다** — 수평 값을 준용하고 그 사실을 사유에 적는다.
#: ⚠ **손율은 원문에 있다** — 품셈 11-2 [주]③ 비탈 **50%** · 11-3 [주]③ 수평 **80%**.
FRAME_MATERIAL_SUGGESTED = {
"각재 50×50": (0.0044, ""),
"판재 T12": (0.0029, ""),
"": (0.03, ""),
}
FRAME_MATERIAL_SOURCE = (
"⚠ 실무 관측값(울진 소광 원단위 라이브러리 §8 규준틀 수평) — **법정 기준 아님**."
" 품셈 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(
frame_rows: list[dict[str, Any]], overrides: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
"""규준틀 재료 — 개소 × 개소당 수량. **자재 축으로 보낸다.**
개소가 서면 재료도 선다(밑수가 줄이다).
값은 **제안값**이고 산출 조건에서 덮어쓸 있다 사실이 사유에 적힌다.
"""
given = overrides or {}
rows: list[dict[str, Any]] = []
for frame in frame_rows:
count = frame.get("amount")
if not count:
continue
loss = FRAME_LOSS_RATE.get(str(frame.get("item")), None)
for name, (default, unit) in FRAME_MATERIAL_SUGGESTED.items():
raw = given.get(name)
try:
per_ea = (
float(raw) if raw is not None and str(raw).strip() != "" else float(default)
)
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": material,
"spec": spec,
"unit": unit,
"amount": float(count) * per_ea,
"destination": "material",
"source": str(frame.get("item") or "규준틀"),
"basis": (
f"{frame.get('item')} {float(count):g}개소 × {per_ea:g}{unit}/개소"
f" ({picked}) · {FRAME_MATERIAL_SOURCE}"
# ⚠ 준용이라는 사실이 상수 주석에만 있고 **화면 근거에는 없던**
# 자리다 — 값이 서면 어디서 온 값인지 안 보인다(2026-09-09 감사).
+ (
" · ⚠ 비탈 규준틀 재료량은 그 시트에 없어 **수평 값을 준용**함"
if str(frame.get("item")) == "비탈 규준틀"
else ""
)
+ (f" · 손율 {loss}%(품셈 [주]③)" if loss else "")
),
}
)
return rows
def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
"""비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다."""
count, notes = batter_frame_count(slope_rows)
# 사면표가 있는데 0 개소면 **이 노선엔 필요 없는 것**이다 — 「근거 없음」이 아니다(㉴).
idle = STATUS_NOT_APPLICABLE if slope_rows else STATUS_NEEDS_INPUT
return {
"group": "준비공",
"item": "비탈 규준틀",
"unit": "개소",
"amount": float(count) if count else None,
"status": STATUS_READY if count else STATUS_PENDING,
"status": STATUS_READY if count else idle,
"reason": (
"; ".join(notes)
+ " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」 —"
@@ -604,7 +539,12 @@ def _level_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
"item": "수평 규준틀",
"unit": "개소",
"amount": float(count) if count is not None else None,
"status": STATUS_READY if count is not None else STATUS_PENDING,
# 사면표가 안 섰으면 앞 단계 몫(입력) · 섰는데 성토고 칸이 없으면 우리 자료가 없는 것.
"status": STATUS_READY
if count is not None
else STATUS_PENDING
if slope_rows
else STATUS_NEEDS_INPUT,
"reason": "; ".join(notes)
+ (
" · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」 —"
@@ -705,6 +645,7 @@ def build_table(
"columns": ["구분", "공종", "단위", "수량", "상태", "사유"],
"rows": rows,
"ready_count": sum(1 for row in rows if row["status"] == STATUS_READY),
"input_count": sum(1 for row in rows if row["status"] == STATUS_NEEDS_INPUT),
"pending_count": sum(1 for row in rows if row["status"] == STATUS_PENDING),
"row_count": len(rows),
}
@@ -11,10 +11,14 @@ from typing import Any
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
REASON_NO_WORK_ITEM,
STATUS_PENDING,
STATUS_NEEDS_INPUT,
STATUS_NO_WORK_ITEM,
STATUS_READY,
)
#: 공종 없는 줄에 개소가 들어온 뒤 할 일 — 자재총괄(사급) 줄로 가서 「자재 단가」 탭에 칸이 섬.
NO_WORK_ITEM_PRICE_PATH = "「자재 단가」 탭에 단가를 넣으면 자재총괄(사급) 줄로 금액이 섬"
#: 부대시설·가설공사 — **법이 요구하는데 우리가 안 내던 다섯 줄**(2026-09-09 사용자 확정 ⑬).
#: `key` 는 설정의 `ancillary_counts` 칸 이름, `code` 는 품셈 공종(없으면 `None`).
#: ⚠ 다섯 중 **품셈에 공종이 있는 것은 가설창고 하나뿐**이다(마스터 전수 확인).
@@ -94,9 +98,12 @@ def ancillary_rows(counts: dict[str, Any] | None = None) -> list[dict[str, Any]]
reasons.append(REASON_NO_WORK_ITEM)
if amount is None:
reasons.append("개소가 아직 입력되지 않았습니다 — 넣으면 물량이 섭니다")
status = STATUS_PENDING
status = STATUS_NEEDS_INPUT
elif spec["code"]:
status = STATUS_READY
else:
status = STATUS_READY if spec["code"] else STATUS_PENDING
reasons.append(NO_WORK_ITEM_PRICE_PATH)
status = STATUS_NO_WORK_ITEM
rows.append(
{
"group": "부대시설",
@@ -110,3 +117,22 @@ def ancillary_rows(counts: dict[str, Any] | None = None) -> list[dict[str, Any]]
}
)
return rows
def ancillary_material_rows(preparation_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""공종 없는 부대시설 중 개소가 선 줄 → 자재총괄 성분(사급 기본) — 「자재 단가」 탭 통로.
인계 공종 줄은 막힘 없이 `in_bill: False` 가므로 **여기 곳에서만** 금액이 선다.
"""
return [
{
"name": row["item"],
"spec": "",
"unit": row["unit"],
"amount": float(row["amount"]),
"destination": "material",
"source": str(row.get("group") or "부대시설"),
}
for row in preparation_rows
if row.get("status") == STATUS_NO_WORK_ITEM and row.get("amount")
]
@@ -0,0 +1,81 @@
"""준비공 — 규준틀 재료 줄 (`B08_Quantity_Engine_Preparation` 에서 갈라냄 · 2026-09-14 700줄 제한).
내용·규칙은 그대로 옮겼다 개소 × 개소당 수량을 자재 축으로 보내고,
값은 제안값이며 산출 조건이 이긴다.
"""
from __future__ import annotations
from typing import Any
#: 규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리**(2026-09-09).
#: 품셈 11-2·11-3 [주]④ 「재료량은 **설계수량에 따른다**」 ⇒ 품셈이 값을 안 주는 것이지
#: 「안 센다」가 아니다. 그래서 **제안값을 보이고 사용자가 고치는** 모양으로 둔다
#: (확정 ⑨·⑩ 과 같은 틀 — 「가는 기본값이고 나 선택처럼 동작할 수 있어야 함」).
#: ⚠ **제안값은 실무 관측값이지 법정 기준이 아니다** — 울진 소광 원단위 라이브러리 §8
#: 「규준틀 수평 | 개소 | 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏」.
#: ⚠ **비탈 규준틀 값은 그 시트에 없다** — 수평 값을 준용하고 그 사실을 사유에 적는다.
#: ⚠ **손율은 원문에 있다** — 품셈 11-2 [주]③ 비탈 **50%** · 11-3 [주]③ 수평 **80%**.
FRAME_MATERIAL_SUGGESTED = {
"각재 50×50": (0.0044, ""),
"판재 T12": (0.0029, ""),
"": (0.03, ""),
}
FRAME_MATERIAL_SOURCE = (
"⚠ 실무 관측값(울진 소광 원단위 라이브러리 §8 규준틀 수평) — **법정 기준 아님**."
" 품셈 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(
frame_rows: list[dict[str, Any]], overrides: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
"""규준틀 재료 — 개소 × 개소당 수량. **자재 축으로 보낸다.**
개소가 서면 재료도 선다(밑수가 줄이다).
값은 **제안값**이고 산출 조건에서 덮어쓸 있다 사실이 사유에 적힌다.
"""
given = overrides or {}
rows: list[dict[str, Any]] = []
for frame in frame_rows:
count = frame.get("amount")
if not count:
continue
loss = FRAME_LOSS_RATE.get(str(frame.get("item")), None)
for name, (default, unit) in FRAME_MATERIAL_SUGGESTED.items():
raw = given.get(name)
try:
per_ea = (
float(raw) if raw is not None and str(raw).strip() != "" else float(default)
)
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": material,
"spec": spec,
"unit": unit,
"amount": float(count) * per_ea,
"destination": "material",
"source": str(frame.get("item") or "규준틀"),
"basis": (
f"{frame.get('item')} {float(count):g}개소 × {per_ea:g}{unit}/개소"
f" ({picked}) · {FRAME_MATERIAL_SOURCE}"
# ⚠ 준용이라는 사실이 상수 주석에만 있고 **화면 근거에는 없던**
# 자리다 — 값이 서면 어디서 온 값인지 안 보인다(2026-09-09 감사).
+ (
" · ⚠ 비탈 규준틀 재료량은 그 시트에 없어 **수평 값을 준용**함"
if str(frame.get("item")) == "비탈 규준틀"
else ""
)
+ (f" · 손율 {loss}%(품셈 [주]③)" if loss else "")
),
}
)
return rows
@@ -8,7 +8,17 @@
from __future__ import annotations
STATUS_READY = "값 있음"
#: 사람이 넣으면 서는 줄 — 산출 조건 칸, 또는 앞 단계(횡단·사면표)를 마치면 섬.
#: 인계 `input_missing`.
#: ⚠ 「근거 없음」과 가른다 — 한 낱말로 덮으면 입력만 넣으면 서는 줄이 「못 세움」으로 읽힌다
#: (2026-09-14 브레인 ㉴ · 인계는 반대로 사방 원단위까지 「입력이 필요합니다」로 보냈다).
STATUS_NEEDS_INPUT = "입력이 필요함"
#: 자료·산식이 우리에게 없는 줄 — 입력으로는 안 풀림. 인계 `unit_data_missing`.
STATUS_PENDING = "값을 낼 근거가 없음"
#: 수량은 섰는데 **품셈에 공종이 없어** 단가가 영영 안 서는 줄 — 「자재 단가」 탭에 단가를 넣음
#: (화약류·치즐과 같은 통로 · 자재총괄 사급 줄로 감). 인계는 막힘이 아님(자재 줄로 셈).
#: ⚠ 「입력이 필요함」으로 두면 개소를 넣고 기다려도 안 섬(2026-09-14 브레인 판정).
STATUS_NO_WORK_ITEM = "품셈 공종 없음 — 단가를 직접 넣어야 함"
STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬"
STATUS_NOT_APPLICABLE = "해당 없음"
@@ -27,7 +27,7 @@ from __future__ import annotations
import math
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import STATUS_PENDING, STATUS_READY
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import STATUS_NEEDS_INPUT, STATUS_READY
TREE_WASTE_ITEM = "임목폐기물 처리"
STEM_FORM_FACTOR = 0.5 # k
@@ -139,7 +139,7 @@ def tree_waste_rows(
{
**base,
"amount": None,
"status": STATUS_PENDING,
"status": STATUS_NEEDS_INPUT, # 조사값 칸 · 앞 단계 사면표 — 둘 다 사람 몫
"reason": f"{why}. 산식: {TREE_WASTE_BASIS} · {root_basis}",
"reference_amount": area,
}
@@ -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
@@ -180,9 +180,12 @@ def save_personal(
overrides: dict[str, Any] | None,
unit_price_rows: list[dict[str, Any]] | None = None,
tier: str = "personal",
name: str | None = None,
) -> str:
"""[내 라이브러리에 저장]·발행 — 양식 + 고친 식·줄 조합을 **그 단에 한 벌**로 씀. 코드.
`name` 이름표(화면이 종류 + 제원 요약 제안 · 사용자가 고침 · 10-A ). 비면 양식 이름.
반대 방향(작업본 라이브러리)이라 프로젝트는 바꿈(브레인 판정 ).
같은 종류가 있으면 ** 코드로 덮어씀** 지금은 종류당 하나(판정 ).
`tier` 회사(MASTER)·프로그램 기본(SYSTEM_ADMIN) 발행도 같은 모양(2026-09-14 브레인 승인).
@@ -199,6 +202,8 @@ def save_personal(
for row in overridden_rows(template, overrides)
]
item = {k: v for k, v in template.items() if k != "imported_from"}
if name and name.strip():
item["name"] = name.strip()
if tier == "program" and isinstance(item.get("origin"), dict):
# 모든 회사로 가는 발행본 — 원문 공사명·파일명은 안 실음(원본·회사 단은 그대로 · 브레인 ②③).
kept = {k: v for k, v in item["origin"].items() if k not in ORIGIN_MASKED_KEYS}
@@ -116,6 +116,8 @@ EXPANDERS = {
"soil_guard": lambda h, l, o, f=None, r="": _soil_guard()(o),
# 바닥막이는 **돌붙임 ㎡당**이라 높이·연장이 아니라 **면적**이 밑수다(정본 「돌붙임L3=…」).
"bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o),
# BOX암거 — m당 × 연장(산림과임업기술 예제 · 울진 2×2 · 2026-09-15 브레인 12장 D).
"box_culvert": lambda h, l, o, f=None, r="": _box_culvert()(o),
# ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견).
# 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.**
# 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝).
@@ -126,6 +128,13 @@ EXPANDERS = {
}
def _box_culvert():
"""BOX암거 전개 함수를 늦게 가져온다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Box import box_culvert
return box_culvert
def _soil_guard():
"""흙막이 사유 함수를 늦게 가져온다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import soil_guard
@@ -162,7 +171,13 @@ def _revetment():
#: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다.
EXPANDER_WITHHELD: dict[str, str] = {}
EXPANDER_WITHHELD: dict[str, str] = {
# 2026-09-15 브레인 판정 ⑥ — 찾아봤으나 없음(「아직 안 만듦」과 다름).
"ford_bridge": (
"세월교 수량 근거 없음 — 법령·임도기술교본은 설치 조건만(그림 3-18 은 배치 모식도)"
" · 실무 원본에 세월교 시트·도면 없음 · 관은 배수관 줄로 따로 섬"
),
}
#: 전개식이 **개소당·㎡당**으로 값을 내는 종류의 내역 단위와 수량(2026-09-14 구조물도 그림에서 잡음).
#: ⚠ 안 적으면 「m · 연장」으로 읽혀 — 골막이·바닥막이(점 배치라 연장 0)는 **수량 0 이라 내역에 안 서고**
@@ -171,6 +186,7 @@ EXPANDER_BILLING: dict[str, Any] = {
"erosion_check": lambda options: ("개소", 1.0), # 정본 「골막이(찰)(치수조서연결)」 개소당
"soil_guard": lambda options: ("개소", 1.0), # 정본 「떼흙막이」 개소당(평균 붙박이)
"bed_sill": lambda options: ("", _num(options.get("area_m2"), 0.0)), # 정본 돌붙임 ㎡당
"box_culvert": lambda options: ("m", _num(options.get("length_m"), 0.0)), # 점 배치라 연장 칸
}
@@ -328,6 +344,15 @@ def expand(
return result
def _shoring_of(quantities: Iterable[StructureQuantity]) -> dict[str, Any]:
"""동바리 상태 — 전개식이 동바리 줄을 낸 구조물이 있으면 대상(2026-09-15 BOX암거)."""
status = shoring_status()
found = sorted({q.name for q in quantities if any(c.name == "동바리" for c in q.components)})
if found:
status.update(applicable=True, reason=f"슬래브 받침 동바리(12-20) — {' · '.join(found)}")
return status
def verify_no_mix_components(quantities: Iterable[StructureQuantity]) -> list[str]:
"""⚠ 배합 성분이 산출물에 섞이면 알린다 (㉢ 이중계상 방어).
@@ -495,6 +520,21 @@ def _append_section_trench(
quantity.components.extend(Component(**row) for row in rows)
def _append_box_trench(
quantity: StructureQuantity, structure: dict[str, Any], thickness_m: float | None
) -> None:
"""BOX암거 구체 터파기(토공 축) — 기초잡석과 **같은 두께**로 든 것을 뺌(2026-09-15)."""
if quantity.type_id != "box_culvert" or not quantity.components:
return
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Box import box_trench
thickness = RUBBLE_BASE_THICKNESS_M if thickness_m is None else float(thickness_m)
found = box_trench(structure.get("options") or {}, max(thickness, 0.0))
if found is not None:
rows, quantity.trench_depth_m = found
quantity.components.extend(rows)
def build_table(
structures: Iterable[dict[str, Any]],
names: dict[str, str] | None = None,
@@ -530,6 +570,7 @@ def build_table(
if rubble is not None:
quantity.components.append(rubble)
_append_section_trench(quantity, item, observed, rubble_base_thickness_m)
_append_box_trench(quantity, item, rubble_base_thickness_m)
quantity.unconfirmed = str(item.get("unconfirmed") or "") or missing_height_reason(item)
quantities.append(quantity)
if use_templates:
@@ -612,8 +653,8 @@ def build_table(
"structures": payload_structures,
"formwork_notes": formwork_notes,
"formwork_reuse_missing": formwork_missing,
# 동바리 — 대상이 없으면 0 이 아니라 「없음」이라고 말한다.
"shoring": shoring_status(),
# 동바리 — 대상이 없으면 0 이 아니라 「없음」이라고 말한다(BOX암거 동바리 줄이 서면 대상).
"shoring": _shoring_of(quantities),
# ⚠ 값을 바꾸는 설계 조건인데 우리 제원에 칸이 없는 것 — 화면에 드러낸다.
# 「무엇을 정해야 하는지」만으로는 부족하고 **「정하면 얼마나 달라지는지」**까지.
"pending_choices": (observed.pending_choices or {}).get("items") or [],
@@ -58,6 +58,10 @@ DESTINATION = {
"유로폼": "unit_price", # 거푸집 계열(품셈 12-38) — 설치·해체 품
"면목": "material",
"": "material", # 사면 떼(자재총괄 extra)와 같은 자리 · 할증 10%(1-3-1)
# ⭐ 2026-09-15 BOX암거 — 버림 옆면 합판 · 동바리(12-20)·비계(12-19)는 설치 품이라 묶음 조각으로.
"합판거푸집": "unit_price",
"동바리": "unit_price",
"비계": "unit_price",
}
#: ⛔ **갈 곳 기본값 금지**(명세 13장 「빠진 것이 조용해짐」) — 표에 없는 이름은 막고 사유를 냄.
@@ -0,0 +1,184 @@
"""BOX암거 전개식 — **m당 × 연장** (2026-09-15 브레인 12장 D 판정 ①~⑤).
근거 구조가 같다
산림과임업기술(임도) 5 ) 구조물도에 의한 수량산출() · 그림 5-2-70
Box(2.0×1.5×2×6.03m) 구체 = (외곽 유수구 + 헌치) × L · 기초 = × 0.1 × L ·
거푸집 = 외벽·유수구·받침판·귀면 · 동바리 = 유수구 단면 × L · 철근 상세도에서( 없음)
실무 울진 기번3 `5. 구조도(기번3).xlsx` 암거수량집계표(2×2) m당 레미콘 2.84 ·
버림 0.28 · 유로폼 11.132 · 합판6회 0.2 · 철근 SD40 H13 0.179 + H16 0.157t · 동바리 3.92 ·
비계 18.72
두께 제안값은 등록부 (예제 0.3 · 0.25 · 헌치 0.2 · 버림 0.1 · KDS 300 병기).
1련만 등록부에 련수 칸이 없음(예제 식은 련수로 늘어나나 그림·칸이 1).
철근은 **크기별 식이 없다** 울진 2×2 관측값만 선다(판정 ).
비계 시종점 면은 **개소당 ** 원문은 m당 칸에 넣어 연장을 곱하면 부풀어 오름.
"""
from __future__ import annotations
import math
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import Component, _num, routed
#: 버림 폭 여유(m, 한쪽) — 예제 5.1 = 외곽 4.9 + 0.1×2 · 울진 2.8 = 외곽 2.6 + 0.1×2 (둘이 같음).
BLINDING_EXTRA_M = 0.1
#: 철근 관측값(kg/m) — 울진 기번3 「암거수량집계표(2×2)」 SD40 · 내공 2.0×2.0 · 벽·판 0.3.
REBAR_OBSERVED_2X2 = (("D13", 179.0), ("D16", 157.0))
REBAR_SIZE_ONLY = (
"철근 — 크기별 식 없음 · 관측값은 2×2 뿐(울진 기번3 구조도 SD40 H13 0.179 + H16 0.157t/m ·"
" 벽·판 0.3) — 이 크기는 철근이 안 섬(2026-09-15 브레인 판정 ②)"
)
SOURCES = (
"산식 = 산림과임업기술(임도) 5장 수량산출 예제(그림 5-2-70) · 실무 울진 기번3 구조도"
" 「암거수량집계표(2×2)」"
)
NOTE_FORMWORK = (
"거푸집 — 본체 유로폼(외벽 · 내벽 헌치 뺀 높이 · 상판 밑면 · 귀면 넷) · 버림 옆면 합판"
" (울진 2×2 모양 · 예제는 합판 3회)"
)
NOTE_SHORING_SCAFFOLD = (
"동바리(유수구 단면 − 헌치)·비계(외벽 양면 + 시종점 두 면)는 **실무 관측 모양**"
"(울진 2×2 「강재 3개월」) — 품셈 12-20 강관동바리 · 12-19 강관비계로 이음 ·"
" 시종점 두 면은 개소당 한 번(원문은 m당 칸)"
)
#: 비계는 공㎥(품셈 12-19 밑수) = 면 × 폭 — 울진 단위 칸 「공/m3」 · 시종점 식 ×1.00 은 연장일 수 없음.
SCAFFOLD_NOTE = "비계 폭 1.0m 관측(울진)"
NOTE_WING = "날개벽 수량 근거 없음(교본·실무 모두 · 판정 ⑤) — 날개벽 줄 안 섬"
NOTE_RUBBLE = (
"기초잡석 — 근거 둘(예제·울진 집계표)에는 잡석 줄이 없음 — 사용자 확정 3차 ② 로 세움"
"(버림이 선 구조물엔 잡석 · 옹벽과 같은 규칙)"
)
#: 구체 터파기 — 울진 「암거구체 토공(2×2)」: 밑폭 = 버림 폭 + 여유 0.5×2 · 옆면 1:0.5.
TRENCH_CLEARANCE_M = 0.5
TRENCH_SIDE_SLOPE = 0.5
NOTE_TRENCH_MISSING = (
"평균 터파기고 칸이 비어 구체 터파기·되메우기가 안 섬 — 칸을 적으면 토공으로 섬"
)
NOTE_TRENCH = (
"구체 터파기 — 밑폭·옆면은 울진 「암거구체 토공(2×2)」(여유 0.5 · 1:0.5)"
" · 깊이 = 평균 터파기고 칸 · 되메우기 = 터파기 − 든 것(기초잡석·버림·구체)"
" — 원문은 잔토에 구체 전체 · 되메우기 한쪽만이라 옹벽 식(터파기 − 든 것)으로 맞춤"
" · 토공집계로만 감(이중계상 방지)"
)
def box_culvert(options: dict[str, Any]) -> tuple[list[Component], list[str]]:
"""BOX암거 한 개소 — (성분, 사유). 연장이 없으면 성분 없이 사유만."""
length = _num(options.get("length_m"))
if length <= 0:
return [], ["BOX암거 연장(계류 방향)이 없어 수량이 안 섬 — 연장 칸을 적을 것(기본값 없음)"]
width, height = _num(options.get("body_width_m")), _num(options.get("body_height_m"))
wall, slab = _num(options.get("wall_thickness_m")), _num(options.get("slab_thickness_m"))
haunch, blinding = _num(options.get("haunch_m")), _num(options.get("blinding_thickness_m"))
if min(width, height, wall, slab) <= 0:
return [], ["BOX암거 본체 폭·높이·두께 중 빈 칸이 있어 수량이 안 섬"]
outer_w, outer_h = width + 2 * wall, height + 2 * slab
chamfer = math.sqrt(2) * haunch
per_m: list[tuple[str, str, float, str, str]] = [
(
"콘크리트",
"",
outer_w * outer_h - width * height + haunch**2 / 2 * 4,
f"{outer_w:g}×{outer_h:g} {width:g}×{height:g} + {haunch:g}²÷2×4",
"",
),
(
"버림콘크리트",
"",
(outer_w + 2 * BLINDING_EXTRA_M) * blinding,
f"({outer_w:g} + {BLINDING_EXTRA_M:g}×2) × {blinding:g}",
"",
),
(
"유로폼",
"",
2 * outer_h + 2 * (height - 2 * haunch) + (width - 2 * haunch) + 4 * chamfer,
f"외벽 {outer_h:g}×2 + 내벽 ({height:g} {haunch:g}×2)×2"
f" + 상판 밑 ({width:g} {haunch:g}×2) + 귀면 √({haunch:g}²+{haunch:g}²)×4",
"",
),
("합판거푸집", "", 2 * blinding, f"버림 옆면 {blinding:g}×2", ""),
(
"동바리",
"",
width * height - 2 * haunch**2,
f"{width:g}×{height:g} {haunch:g}²÷2×4",
"",
),
("비계", "", 2 * outer_h, f"외벽 양면 {outer_h:g}×2 × 폭 1.0({SCAFFOLD_NOTE})", ""),
]
notes: list[str] = [SOURCES, NOTE_FORMWORK, NOTE_SHORING_SCAFFOLD, NOTE_WING, NOTE_RUBBLE]
if math.isclose(width, 2.0) and math.isclose(height, 2.0):
per_m += [
("이형철근", "kg", kg, "울진 기번3 구조도 2×2 관측(SD40)", size)
for size, kg in REBAR_OBSERVED_2X2
]
notes.append("철근은 관측값(울진 2×2 · 벽·판 0.3) — 두께를 바꿔도 따라 안 바뀜")
else:
notes.append(REBAR_SIZE_ONLY)
components = [
Component(
name,
unit,
amount * length,
destination,
f"{basis} = m당 {amount:.4g} × 연장 {length:g}m",
spec=spec,
)
for name, unit, amount, basis, spec in per_m
if (destination := routed(name, notes))
]
ends = 2 * outer_w * outer_h
for component in components:
if component.name == "비계":
component.amount += ends
component.basis += (
f" + 시종점 두 면 {outer_w:g}×{outer_h:g}×2 = {ends:.4g}(개소당 한 번)"
)
if _num(options.get("trench_depth_m")) <= 0:
notes.append(NOTE_TRENCH_MISSING)
return components, notes
def box_trench(
options: dict[str, Any], rubble_thickness_m: float
) -> tuple[list[Component], float] | None:
"""구체 터파기·되메우기·잔토(토공 축) — 평균 터파기고가 비면 `None`.
기초잡석 두께는 원단위표와 **같은 ** 받음(`build_table` 두께) 벌로 .
"""
depth, length = _num(options.get("trench_depth_m")), _num(options.get("length_m"))
outer_w = _num(options.get("body_width_m")) + 2 * _num(options.get("wall_thickness_m"))
outer_h = _num(options.get("body_height_m")) + 2 * _num(options.get("slab_thickness_m"))
blinding = _num(options.get("blinding_thickness_m"))
if depth <= 0 or length <= 0 or outer_w <= 0:
return None
base_w = outer_w + 2 * BLINDING_EXTRA_M
bottom = base_w + 2 * TRENCH_CLEARANCE_M
top = bottom + 2 * TRENCH_SIDE_SLOPE * depth
excavation = (bottom + top) / 2 * depth
body = min(max(depth - rubble_thickness_m - blinding, 0.0), outer_h)
filled = base_w * (rubble_thickness_m + blinding) + outer_w * body
notes: list[str] = []
rows = [
(
"터파기",
excavation,
f"(밑폭 {bottom:.2f}(버림 폭 {base_w:g} + 여유 {TRENCH_CLEARANCE_M:g}×2) + 윗폭"
f" {top:.2f}(1:{TRENCH_SIDE_SLOPE:g})) ÷ 2 × 깊이 {depth:g} · {NOTE_TRENCH}",
),
(
"되메우기",
excavation - filled,
f"터파기 든 것 {filled:.3f}(버림 폭 × (잡석 {rubble_thickness_m:g}"
f" + 버림 {blinding:g}) + 구체 폭 {outer_w:g} × {body:.2f})",
),
("잔토처리", filled, "터파기 되메우기"),
]
return [
Component(name, "", amount * length, destination, f"{basis} × 연장 {length:g}m")
for name, amount, basis in rows
if (destination := routed(name, notes))
], depth
@@ -422,7 +422,7 @@ def stone_masonry(
notes.append(
f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · "
f"{backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다"
"(소광리는 같은 식에 0.30/0.60 을 씁니다)"
"(소광리는 같은 식에 0.30/0.60 을 씁니다) · 칸을 새로 두는 것은 B05 등록부 몫"
)
# ⚠ 근거 구간 밖은 **값이 서되 그 사실이 보여야 한다** — 값은 계속 나오므로 사유가
# 없으면 아무도 못 본다(2026-09-09 그물 침).
+39 -5
View File
@@ -36,13 +36,23 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import (
FACE_DRESSING_FILL_SUGGESTED,
ROOT_REMOVAL_EXCAVATOR_SIZES,
ROOT_REMOVAL_EXCAVATOR_SUGGESTED,
SEED_SPRAY_GROUNDS,
SummaryInput,
)
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table
from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table
from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping
from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan, summary_input_rows
from B08_Quantity.B08_Quantity_Engine_HaulSummary import (
borrow_of,
check_against_plan,
summary_input_rows,
)
from B08_Quantity.B08_Quantity_Engine_RockSplit import apply_rock_split
from B08_Quantity.B08_Quantity_Router_Earthwork_HaulPlan import HAUL_PLAN_KEYS
from B08_Quantity.B08_Quantity_Router_Earthwork_HaulPlan import (
recompute_haul_plan as _recompute_haul_plan,
)
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table
from B08_Quantity.B08_Quantity_Engine_SlopeLength import road_surface_area, station_slopes
@@ -53,10 +63,11 @@ from common_util.common_util_project_settings import (
application_ratio,
concrete_placing_method,
earthwork_conversion_choices,
earthwork_conversion_factors,
mixed_conversion_factors,
haul_limit_choice,
quantity_settings,
rock_classes,
rock_method,
save_section,
topsoil_target,
)
@@ -95,7 +106,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
)
# ⚠ 설정을 **먼저** 읽는다 — 토량환산계수를 프로젝트가 골랐으면 표가 그 값으로 서야 한다.
settings, project_root = await _project_settings(project_id)
factors = earthwork_conversion_factors(settings)
# 암은 구성비 가중 C(㉱ (나)) — 유토곡선(B06)·운반표와 같은 함수.
factors = mixed_conversion_factors(settings)
table = build_table(_stations(designs), factors)
# 화면이 「무엇을 골랐나 · 품셈 범위 안인가」를 보이는 데 쓴다. 계산에는 안 들어간다.
table["conversion_factor_choices"] = earthwork_conversion_choices(settings)
@@ -114,10 +126,15 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
plan = await _stored_haul_plan(project_id, route_id)
haul = build_haul_table(plan, factors)
# ㉱ (가) 암은 흙깎기와 같은 구성비·시공법으로 가름 — B06 리핑암은 자리표시(8-1 · 2026-09-14 브레인).
classes = rock_classes(settings)
methods = {name: rock_method(settings, name) for name in classes}
apply_rock_split(haul, classes, settings.get("rock_ratios_pct") or {}, methods)
# 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다).
# 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다.
# ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다.
haul["spoil"] = _spoil_of(plan, settings, _spoil_sites(designs))
haul["borrow"] = borrow_of(plan) # 토취(반입토) — 수량만 · 금액은 사유(브레인 ①)
# 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.**
# 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다.
table["pipe_lengths"] = [
@@ -150,6 +167,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
earthwork_totals=table.get("totals") or {},
slope_totals=slope.get("totals") or {},
haul_rows=summary_input_rows(haul),
borrow_m3=(haul["borrow"] or {}).get("volume_m3") or 0.0,
borrow_sites=(haul["borrow"] or {}).get("sites") or [],
rock_classes=rock_classes(settings),
rock_ratios_pct=settings.get("rock_ratios_pct") or {},
application_ratios={
@@ -183,6 +202,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
"basis": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[1],
},
}
# 초류종자살포 비탈면 토질 — 5-24 잎 둘(서버 한 곳) · 제안값 없음(2026-09-14 브레인 ㉮).
table["seed_spray_choices"] = {"choices": list(SEED_SPRAY_GROUNDS)}
# 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨).
structures = await _route_structures(project_id)
table["preparation"] = build_preparation_table(
@@ -237,7 +258,7 @@ _GROUND_KIND_OF = {"ea_m3": "soil", "rr_m3": "ripping_rock", "br_m3": "blasting_
def _compacted_factor(settings: dict[str, Any]) -> dict[str, float]:
"""갈래 칸 ↔ 다짐 환산계수 `C` — 프로젝트가 고른 값이 있으면 그것이 선다."""
factors = earthwork_conversion_factors(settings)
factors = mixed_conversion_factors(settings)
return {key: float(factors[kind]["compacted"]) for key, kind in _GROUND_KIND_OF.items()}
@@ -490,6 +511,8 @@ class QuantitySettingsBody(BaseModel):
face_dressing_cut_class: str | None = None
# 제근 굴착기 크기 — "0.2"·"0.7"(품셈 9-21 갈래). `""` 는 「안 정함」.
root_removal_excavator_m3: str | None = None
# 초류종자살포 비탈면 토질 — "일반"·"마사토"(품셈 5-24 잎). `""` 는 「안 정함」.
seed_spray_ground: str | None = None
face_dressing_fill_class: str | None = None
# 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ).
face_dressing_fill_area_m2: float | None = None
@@ -569,6 +592,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES),
("face_dressing_fill_class", FACE_DRESSING_FILL_CLASSES),
("root_removal_excavator_m3", ROOT_REMOVAL_EXCAVATOR_SIZES),
("seed_spray_ground", SEED_SPRAY_GROUNDS),
):
if key in values and values[key] not in choices:
values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침
@@ -613,6 +637,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
for name, method in values["rock_methods"].items()
if method in ROCK_METHODS
}
before = {key: quantity_settings(root).get(key) for key in HAUL_PLAN_KEYS}
try:
# ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면
# 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리).
@@ -640,7 +665,16 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
status_code=500,
content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."},
)
return JSONResponse(content={"status": "success", "quantity": saved.get("quantity") or {}})
after = quantity_settings(root)
changed = any(before[key] != after.get(key) for key in HAUL_PLAN_KEYS)
recomputed = await _recompute_haul_plan(project_id) if changed else False
return JSONResponse(
content={
"status": "success",
"quantity": saved.get("quantity") or {},
"haul_plan_recomputed": recomputed,
}
)
def _save_quantity(
@@ -0,0 +1,40 @@
"""산출 조건 [저장] 뒤 **운반계획 다시 세우기** — 2026-09-14 브레인 ②(지침 5장).
구성비·시공법·계수·도쟈 한계거리는 B06 유토곡선(운반계획) 밑수다. B08 [저장] 계획을 다시 세우면
B06 다시 저장하기 전까지 토적표( 계수) 운반표( 계획) 갈린다(계수 칸은 전부터 같은 ).
칸이 **바뀐 저장** B06 [저장] 같은 서버 재계산을 부른다(무거워서 다른 저장은 부름).
라우터(`B08_Quantity_Router_Earthwork.py`) 700줄에 닿아 파일로 .
"""
from __future__ import annotations
import logging
from uuid import UUID
from B06_Section.B06_Section_Repository import get_workflow_route_context
from config.config_db import run_with_connection
logger = logging.getLogger(__name__)
#: 운반계획의 밑수가 되는 산출 조건 칸.
HAUL_PLAN_KEYS = (
"rock_class_set",
"rock_classes",
"rock_ratios_pct",
"rock_methods",
"conversion_factors_override",
"dozer_haul_limit_m",
)
async def recompute_haul_plan(project_id: UUID) -> bool:
"""B06 [저장]과 같은 서버 재계산 — 못 하면 저장은 살리고 거짓(화면이 옛 계획임을 앎)."""
from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side
try:
context = await run_with_connection(get_workflow_route_context, project_id)
route_id = int((context or {}).get("route_id") or 0)
return bool(route_id) and await recompute_server_side(project_id, route_id) >= 0
except Exception:
logger.exception("B08 저장 뒤 운반계획 재계산 실패: project_id=%s", project_id)
return False
+6 -1
View File
@@ -47,6 +47,7 @@ from common_util.common_util_project_settings import (
)
from B08_Quantity.B08_Quantity_Engine_HaulInputs import haul_inputs
from B08_Quantity.B08_Quantity_Engine_Preparation import frame_material_rows
from B08_Quantity.B08_Quantity_Engine_Preparation_Ancillary import ancillary_material_rows
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_structure_lengths import structure_lengths
from config.config_db import run_with_connection
@@ -172,7 +173,9 @@ def material_table_for(
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 {}),
extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {})
# 공종 없는 부대시설 개소 — 「자재 단가」 탭에 단가를 넣는 통로(2026-09-14 브레인 판정).
+ ancillary_material_rows((preparation_table or {}).get("rows") or []),
)
@@ -499,6 +502,8 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
face_dressing_fill_class=settings.get("face_dressing_fill_class") or None,
# 제근 굴착기 크기(0.2·0.7) — 비면 뿌리뽑기 줄이 입력 사유로 막힘(제안 0.7 은 칸 곁에만).
root_removal_excavator_m3=settings.get("root_removal_excavator_m3") or None,
# 초류종자살포 비탈면 토질 — 부모 5-24 의 잎을 고름. 비면 그 줄이 입력 사유로 막힘(09-14 ㉮).
seed_spray_ground=settings.get("seed_spray_ground") or None,
# 구조물도 양식 일위대가로 셀 장 — 그 구조물은 호표 `AX-ST` 줄 하나로(PLAN 6장 ②).
priced_sheets=_priced_sheets(project_root, unit_table, modes, settings),
)
@@ -417,6 +417,7 @@ class LibrarySaveRequest(BaseModel):
sheet_key: str
tier: Literal["personal", "company", "program"] = "personal"
name: str | None = Field(default=None, max_length=200) # 이름표(10-A ⑫) · 비면 양식 이름
@router.put("/{project_id}/quantity/structure-sheets/library/personal")
@@ -430,10 +431,7 @@ async def put_structure_library_personal(
프로젝트 작업본은 바꿈 반대 방향(작업본 개인 )이라(판정 ).
"""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
project_templates,
save_personal,
)
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates, save_personal
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY, template_of
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
from common_util.common_util_project_settings import quantity_settings
@@ -461,7 +459,8 @@ async def put_structure_library_personal(
overrides = (settings.get(OVERRIDES_KEY) or {}).get(type_id)
# ⛔ 수동 단가(`MANUAL_KEY`)는 안 넘김 — 프로젝트의 값(브레인 판정).
rows = (settings.get(ROWS_KEY) or {}).get(type_id)
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows, payload.tier)
tier, name = payload.tier, payload.name
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows, tier, name)
return JSONResponse(content={"status": "success", "code": code, "edited": len(overrides or {})})
@@ -100,6 +100,10 @@ export interface QuantitySettings {
ancillary_counts?: Record<string, number | null>;
/** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */
stand_volume_class?: string | null;
/** 제근 굴착기 크기 — `"0.2"`·`"0.7"`(서버 선택지 안) · 비면 「안 정함」. */
root_removal_excavator_m3?: string | null;
/** 초류종자살포 비탈면 토질 — `"일반"`·`"마사토"`(5-24 잎) · 비면 「안 정함」. */
seed_spray_ground?: string | null;
/** 면고르기 갈래(서버 선택지 안) · 면적 덮어쓰기(㎡, 없음 = 파종 면적). */
face_dressing_cut_class?: string | null;
face_dressing_fill_class?: string | null;
@@ -162,6 +166,13 @@ export interface EarthworkTable {
conversion_factor_choices?: Record<string, ConversionFactorChoice>;
/** 면고르기 갈래 선택지 — 품셈 9-19-1 원문 표 두 벌(서버 한 곳). 화면은 그대로 보임. */
face_dressing_choices?: { cut: string[]; fill: string[] };
/** 초류종자살포 비탈면 토질 선택지(5-24 잎 둘) — 서버 한 곳 · 제안값 없음. */
seed_spray_choices?: { choices: string[] };
/** 제근 굴착기 크기 선택지·제안(회색 · [제안값 넣기]) — 서버 한 곳. */
root_removal_excavator_choices?: {
choices: string[];
suggested?: { value: string; basis: string };
};
/** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */
conversion_factor_pumsem_ranges?: PumsemRange[];
/** 도쟈 한계거리 — 지금 값·기본값·근거(서버가 정본). 종무대 20 m 는 규정이라 값만 보인다. */
+20 -7
View File
@@ -42,6 +42,8 @@ import { renderStructureSheets } from "./B08_Quantity_UI_StructureSheet";
import { renderStructureSummary } from "./B08_Quantity_UI_StructureSummary";
import { appendTreeWasteFields } from "./B08_Quantity_UI_Side_TreeWaste";
import { appendFaceDressingFields } from "./B08_Quantity_UI_Side_FaceDressing";
import { appendRootRemovalFields } from "./B08_Quantity_UI_Side_RootRemoval";
import { appendSeedSprayField } from "./B08_Quantity_UI_Side_SeedSpray";
import { appendBenchCutFields } from "./B08_Quantity_UI_Side_BenchCut";
/** locale 헬퍼 */
@@ -109,6 +111,8 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
// `""` 는 기본(노면 + 절토)으로 되돌림 — 서버가 None 으로 둔다.
topsoil_target: draft.topsoil_target,
stand_volume_class: draft.stand_volume_class,
root_removal_excavator_m3: draft.root_removal_excavator_m3,
seed_spray_ground: draft.seed_spray_ground,
// 면고르기 — 갈래 `""`·면적 `null` 도 그대로(「안 정함」·「파종 면적 그대로」로 되돌리는 길).
face_dressing_cut_class: draft.face_dressing_cut_class,
face_dressing_fill_class: draft.face_dressing_fill_class,
@@ -307,6 +311,10 @@ interface DraftSettings {
ancillary_counts: Record<string, number | null>;
// 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①).
stand_volume_class: string;
// 제근 굴착기 크기 — "0.2"·"0.7"(서버 선택지) · `""` 는 「안 정함」. 칸은 `_Side_RootRemoval`.
root_removal_excavator_m3: string;
// 초류종자살포 비탈면 토질 — "일반"·"마사토"(5-24 잎) · `""` 는 「안 정함」. 칸은 `_Side_SeedSpray`.
seed_spray_ground: string;
// 면고르기 — 갈래 둘(서버 선택지) · 면적 덮어쓰기 둘(`null` = 파종 면적). 칸은 `_Side_FaceDressing`.
face_dressing_cut_class: string;
face_dressing_fill_class: string;
@@ -521,13 +529,10 @@ function buildQuantitySidePanel(
}
}
// ── 면고르기 — 밑수가 파종 면적이라 반영률 바로 밑. 선택지는 서버 목록 그대로(칸은 따로 뺀 파일).
appendFaceDressingFields(panel, draft, table?.face_dressing_choices, {
field,
optionalNumberField,
selectField,
hintRow,
});
// ── 초류종자살포 토질 · 면고르기 — 밑수가 파종 면적이라 반영률 바로 밑. 선택지는 서버 목록 그대로.
const sideHelpers = { field, optionalNumberField, selectField, hintRow };
appendSeedSprayField(panel, draft, table?.seed_spray_choices, sideHelpers);
appendFaceDressingFields(panel, draft, table?.face_dressing_choices, sideHelpers);
// ── 표토 두께 — 표토 운반 부피(제거 ㎡ × T)에 씀 · 제거 줄은 ㎡ 라 안 곱함(2026-09-13 「실무대로」) ──
// ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.**
@@ -637,6 +642,12 @@ function buildQuantitySidePanel(
),
);
panel.append(hintRow(L("B08_Quantity_Side_StandVolume_Hint")));
appendRootRemovalFields(panel, draft, table?.root_removal_excavator_choices, {
field,
optionalNumberField,
selectField,
hintRow,
});
// ── 부대시설 개소 — ⚠ **산식으로 만들지 않는다**(확정 13). 넣어야 줄이 선다 ──
panel.append(field(L("B08_Quantity_Side_Ancillary"), ""));
@@ -1021,6 +1032,8 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null,
topsoil_target: (stored.topsoil_target as string) ?? "",
stand_volume_class: (stored.stand_volume_class as string) ?? "",
root_removal_excavator_m3: (stored.root_removal_excavator_m3 as string) ?? "",
seed_spray_ground: (stored.seed_spray_ground as string) ?? "",
face_dressing_cut_class: (stored.face_dressing_cut_class as string) ?? "",
face_dressing_fill_class: (stored.face_dressing_fill_class as string) ?? "",
face_dressing_fill_area_m2: (stored.face_dressing_fill_area_m2 as number | null) ?? null,
@@ -0,0 +1,59 @@
/* =============================================================================
* B08_Quantity_UI_Side_RootRemoval.ts
* (PLAN 10-A · 9-21 64a52df6).
*
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* · ** **(`root_removal_excavator_choices`) .
* . 0.7
* + [ ] ** **( ).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton } from "@ui/ui_template_elements";
import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export interface RootRemovalChoices {
choices: string[];
suggested?: { value: string; basis: string };
}
export function appendRootRemovalFields(
panel: HTMLElement,
draft: { root_removal_excavator_m3: string; dirty: boolean },
choices: RootRemovalChoices | undefined,
h: SideFieldHelpers,
): void {
if (!choices) return;
const row = h.selectField(
L("B08_Quantity_Side_RootRemoval_Label"),
draft.root_removal_excavator_m3,
[
{ value: "", label: L("B08_Quantity_RootRemoval_Unset") },
...choices.choices.map((size) => ({ value: size, label: `${size}` })),
],
(picked) => {
draft.root_removal_excavator_m3 = picked;
draft.dirty = true;
},
);
panel.append(row);
const suggested = choices.suggested;
if (!suggested?.value) return;
panel.append(
h.hintRow(`${L("B08_Quantity_RootRemoval_Suggest")} ${suggested.value}㎥ — ${suggested.basis}`),
createButton({
label: L("B08_Quantity_RootRemoval_FillSuggested"),
variant: "ghost",
onClick: () => {
const select = row.querySelector("select");
if (select) select.value = suggested.value;
draft.root_removal_excavator_m3 = suggested.value;
draft.dirty = true;
},
}),
);
}
@@ -0,0 +1,40 @@
/* =============================================================================
* B08_Quantity_UI_Side_SeedSpray.ts
* · (2026-09-14 ).
*
* (5-24 · ) (·)
* . ( `leaf_from`).
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* ** **(`seed_spray_choices`) · .
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export function appendSeedSprayField(
panel: HTMLElement,
draft: { seed_spray_ground: string; dirty: boolean },
choices: { choices: string[] } | undefined,
h: SideFieldHelpers,
): void {
if (!choices) return;
panel.append(
h.selectField(
L("B08_Quantity_Side_SeedSpray_Label"),
draft.seed_spray_ground,
[
{ value: "", label: L("B08_Quantity_SeedSpray_Unset") },
...choices.choices.map((name) => ({ value: name, label: name })),
],
(picked) => {
draft.seed_spray_ground = picked;
draft.dirty = true;
},
),
h.hintRow(L("B08_Quantity_Side_SeedSpray_Hint")),
);
}
@@ -262,7 +262,14 @@ function sheetBody(
: "양식 없음(지금 전개)"),
),
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
el("span", "b08-grid__caption", sheet.unit_label),
el(
"span",
"b08-grid__caption",
// 10-A ⑯ 양식은 L=1 로 풂(`replace_with_templates` · m당 양식만) — 규칙을 머리에 드러냄.
sheet.library_item && sheet.unit_label === "m당"
? `m당 — 반올림은 m당 값에 걸고 수량 = m당 × 연장`
: sheet.unit_label,
),
);
// ㉱ 규격 다름 — 미확정과 같은 급(빨간 테두리 + 배지). 금액은 서고 막지 않음.
if (sheet.spec_mismatch) {
@@ -425,6 +432,7 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
typeId: sheet.library_item.type_id,
currentCode: sheet.library_item.code ?? null,
originProject: sheet.library_item.origin_project,
defaultName: sheet.title,
isDirty: () => dirty,
confirmTake: () => {
const edited = editedRows(sheet);
@@ -171,7 +171,7 @@ export function formulaTable(
saveButton.disabled = !dirty;
discardButton.disabled = !dirty;
status.textContent = dirty
? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림)"
? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림 · 고친 식은 이 양식·프로젝트에 묶여 제원(높이 등)을 바꿔도 따라감)"
: "";
onDirty(dirty);
};
@@ -18,6 +18,8 @@ const TIER_LABELS: Record<string, string> = {
};
/** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */
const KIND_LABELS: Record<string, string> = { form: "양식형", fixed: "고정형" };
/** 이름표 — 「종류 + 제원 요약」을 미리 채워 두고 고치게 함(10-A ⑫ · 코드는 난수라 이름이 흔들려도 안전). */
const NAME_TAG_HINT = "이름표(종류 + 제원 요약 · 고칠 수 있음 · 비우면 양식 이름):";
interface LibraryItem {
tier: string;
@@ -59,6 +61,8 @@ export interface LibraryPanelOptions {
onImported: (notes: string[]) => Promise<void>;
/** 이 장 양식이 뽑아 온 원문 공사명 — 프로그램 기본 발행 확인창에 「빼고 발행」을 알림. */
originProject?: string | null;
/** 저장·발행 이름표 제안 — 장 이름(종류 + 제원 요약). */
defaultName: string;
}
/** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */
@@ -272,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}줄 포함` : ""}`;
@@ -307,19 +311,17 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
tier === "program" && options.originProject
? `\n이 항목은 「${options.originProject}」에서 뽑은 것 — 공사명은 빼고 발행됩니다`
: "";
if (
!window.confirm(
`이 장의 양식과 고친 식을 ${whom}에 발행 — 같은 종류가 있으면 덮어씀${masked}`,
)
) {
return "";
}
const tag = window.prompt(
`이 장의 양식과 고친 식을 ${whom}에 발행 — 같은 종류가 있으면 덮어씀${masked}\n${NAME_TAG_HINT}`,
options.defaultName,
);
if (tag === null) return "";
await readJson(
await fetch(libraryUrl(projectId, "/publish"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sheet_key: sheetKey, tier }),
body: JSON.stringify({ sheet_key: sheetKey, tier, name: tag }),
}),
);
return `${whom}에 발행함`;
@@ -196,7 +196,11 @@ export function buildStandardSpecPanel(
slope,
judgedSlope ? `비우면 자동 — 지금 판정값 1:${judgedSlope}` : "비우면 자동으로 판정합니다.",
),
field("야면석 계수", coeff, "비우면 품셈 열을 씁니다."),
field(
"야면석 계수",
coeff,
"비우면 품셈 열을 씁니다. 계수 열을 바꿔도 돌 종류는 그대로(다른 칸)입니다.",
),
field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."),
field("버림 콘크리트", blinding, "비우면 넣습니다(두께 100㎜) — 빼려면 「안 넣음」."),
);
@@ -123,7 +123,7 @@ function render(table: UnitPriceTable): HTMLElement[] {
const head = el(
"p",
"b08-sheet__head",
`일위대가 ${table.code} · ${table.unit}${total} (미리보기 — 내역 금액은 원가계산이 셈)`,
`일위대가 ${table.code} · ${table.unit}${total} (미리보기 — 내역 금액은 원가계산이 셈 · 하위 일위대가는 5단까지 풀고 더 깊거나 돌면 막힘)`,
);
if (table.unconfirmed) {
head.append(el("span", "b08-unit__badge", `미확정 ${table.unconfirmed}`));
+3 -1
View File
@@ -243,6 +243,8 @@ export interface PreparationRow {
export interface PreparationTable {
columns: string[];
rows: PreparationRow[];
/** 입력하면 서는 줄 수 — 「근거 없음」(`pending_count`)과 갈라 셈(2026-09-14). */
input_count?: number;
pending_count: number;
row_count: number;
}
@@ -261,7 +263,7 @@ export function renderPreparationGrid(
const caption = document.createElement("p");
caption.className = "b08-grid__caption";
caption.textContent = `${table.row_count}줄 · 값을 낼 근거가 아직 없는 줄 ${table.pending_count}`;
caption.textContent = `${table.row_count}줄 · 입력이 필요한 줄 ${table.input_count ?? 0}개 · 값을 낼 근거가 없는 줄 ${table.pending_count}`;
const unconfirmed = table.rows.reduce((sum, row) => sum + (row.unconfirmed ?? 0), 0);
if (unconfirmed) {
const badge = document.createElement("span");
@@ -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:
+13 -149
View File
@@ -45,28 +45,30 @@ from B09_Estimation.B09_Estimation_UnitPrice import (
_ZERO = Decimal(0)
# ⬇ 인계 읽기는 `_Input` 으로 옮겼다(2026-09-15 700줄 분리) — 쓰던 이름이 그대로 살게 다시 내보낸다.
from B09_Estimation.B09_Estimation_BillOfQuantities_Input import ( # noqa: E402,F401
BLOCKED_UNCONFIRMED,
SUPPLY_OWNER,
SUPPLY_UNKNOWN,
BillError,
HandoffMaterial,
HandoffWorkItem,
_decimal,
parse_handoff,
)
#: 총 절취량으로 셀 공종 — 9-3 토사깎기 · 9-4 암절취 · 9-5 발파암(인계 대응표 「흙깎기」 셋).
#: ⚠ 판에 묶인 절 번호임(명세 17장) — 판이 바뀌면 대응표와 함께 고칠 것.
CUT_CODE_PREFIXES = ("FP-09-03", "FP-09-04", "FP-09-05")
#: 구조물도 양식 호표 코드 머리(명세 2장 자체 확장) — 금액은 B08 일위대가 엔진을 B09 단가표로 셈.
STRUCTURE_PRICE_PREFIX = "AX-ST-"
#: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인).
#: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다.
SUPPLY_UNKNOWN = "unknown"
#: 관급 — 총원가 밖 별도 표기라 사급과 **가는 자리가 다르다**(PLAN 8-2).
SUPPLY_OWNER = "owner_supplied"
#: 막힘 갈래를 사람 말로. **할 일이 다르므로 화면에서 갈라 보인다.**
#: ⚠ `blocked_kind` 가 **`None` 인데 `in_bill=False`** 면 **막힌 줄이 아니다** —
#: 「다른 표에서 이미 섬」(벌목·지장목제거)이거나 「이 노선엔 없음」(사방 시설)이다.
#: 그것을 「우리가 만들어야 하는 것」에 얹으면 **결국 이중계상으로 간다**(㉠~㉦ 규칙).
_NOT_OUR_ROW = "not_our_row"
#: B08 `BLOCKED_UNCONFIRMED` 와 같은 글 — 치수 없이 기본값으로 선 줄.
BLOCKED_UNCONFIRMED = "unconfirmed"
_BLOCKED_LABELS = {
_NOT_OUR_ROW: "여기서 세지 않는 줄",
BLOCKED_UNCONFIRMED: "미확정 — 금액에 안 들어감",
@@ -76,84 +78,6 @@ _BLOCKED_LABELS = {
}
class BillError(ValueError):
"""내역서를 세울 수 없는 경우. 빈 표를 돌려주지 않고 멈춘다."""
@dataclass(frozen=True)
class HandoffWorkItem:
"""B08 인계 공종 한 줄. **수량은 B08 것이 정본이다** — 여기서 다시 세지 않는다."""
work_item_code: str | None
name: str
spec: str
unit: str
quantity: Decimal
in_bill: bool
#: 운반 줄에만 있다 — 거리(m)와 수단. 무대(20 m 이내)는 `free_haul` 로 온다.
haul_distance_m: Decimal | None = None
haul_equipment: str | None = None
in_bill_reason: str = ""
origin: str = ""
ground_class: str = ""
#: 반영률(%) — B08 이 이미 곱했으면 산출근거에만 적고 **여기서 또 곱하지 않는다**.
application_ratio_pct: Decimal | None = None
#: 반영률 적용 **전** 수량. 산출근거에만 쓴다.
quantity_gross: Decimal | None = None
#: 성·절토처럼 율이 갈리는 경우의 몫별 율·수량 — 문장 파싱 없이 그대로 그린다.
application_ratio_breakdown: dict | None = None
quantity_breakdown: dict | None = None
#: 묶음 줄(옹벽처럼 품셈에 그 공종이 없는 것) — 무엇으로 이루어지는지.
composite_parts: tuple = ()
#: 묶음인데 아직 못 채운 조각 — 「단가 없음」과 「물량 없음」을 갈라 적는다.
composite_not_ready: tuple = ()
structure_kind: str = ""
#: B08 이 적어 보낸 막힘 사유 — **문구는 B08 것을 그대로 쓴다**(두 벌로 짜지 않는다).
blocked_reason: str = ""
#: 막힘 갈래 — `input_missing`(사용자가 입력하면 풀림) /
#: `unit_data_missing`·`formula_missing`(우리가 만들어야 함). 할 일이 다르므로 가른다.
blocked_kind: str = ""
#: 갈래 축·원본값 — 「stone_cm」·「60~80」. **키 문자열은 우리가 만든다**(두 창 합의).
variant_axis: str = ""
variant_value: str = ""
#: 규격 갈래를 B08 이 판정해 보낸 것(「철근구조물」)과 그 근거 문구.
#: **근거는 산출근거 칸에 그대로 적는다** — 우리가 다시 지어내지 않는다.
spec_class: str = ""
spec_class_basis: str = ""
#: 이 줄의 공종 코드가 본 품셈 판 — 마스터 판과 다르면 값을 안 씀(명세 17장).
pum_edition: str = ""
@property
def display_name(self) -> str:
return f"{self.name} {self.spec}".strip()
@property
def unconfirmed(self) -> bool:
"""B08 이 치수 없이 기본값으로 세운 줄(2026-09-14 브레인 판정) — 내역 제자리에
** 금액 + 빨간 테두리** 서고 합계에 · 머리에 미확정 N건 금액에 들어감."""
return self.blocked_kind == BLOCKED_UNCONFIRMED
@dataclass(frozen=True)
class HandoffMaterial:
"""B08 인계 자재 한 줄. `work_item_code` 칸이 **아예 없는** 별도 벌이다(계약 확정)."""
material_name: str
spec: str
unit: str
net_amount: Decimal
total_amount: Decimal
supply_type: str
surcharge_pct: Decimal | None = None
surcharge_note: str = ""
install_by: str | None = None
source_structure: tuple[str, ...] = ()
@property
def display_name(self) -> str:
return f"{self.material_name} {self.spec}".strip()
@dataclass
class BillRow:
"""내역서 한 줄. 머리(그룹)줄은 `is_group=True` 이고 수량·단가가 없다."""
@@ -308,66 +232,6 @@ class BillResult:
return sum((r.amount_krw or _ZERO for r in self.rows if not r.is_group), _ZERO)
def _decimal(value: Any, default: Decimal | None = _ZERO) -> Decimal | None:
if value is None or value == "":
return default
return Decimal(str(value))
def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[HandoffMaterial]]:
"""인계 응답을 우리 자료형으로 옮긴다. **모르는 칸을 채우지 않는다.**"""
if "work_items" not in payload or "materials" not in payload:
raise BillError("인계 응답에 `work_items`·`materials` 두 벌이 다 있어야 합니다.")
work_items = [
HandoffWorkItem(
work_item_code=row.get("work_item_code"),
name=row.get("name", ""),
spec=row.get("spec") or "",
unit=row.get("unit") or "",
quantity=_decimal(row.get("quantity")) or _ZERO,
in_bill=bool(row.get("in_bill", True)),
in_bill_reason=row.get("in_bill_reason") or "",
origin=row.get("origin") or "",
ground_class=row.get("ground_class") or "",
haul_distance_m=_decimal(row.get("haul_distance_m"), None),
haul_equipment=row.get("haul_equipment"),
# ⚠ 있으면 **적기만** 한다 — 곱하기는 B08 한 곳에서만(2026-09-08 이견 ①).
application_ratio_pct=_decimal(row.get("application_ratio_pct"), None),
quantity_gross=_decimal(row.get("quantity_gross"), None),
application_ratio_breakdown=row.get("application_ratio_breakdown"),
quantity_breakdown=row.get("quantity_breakdown"),
composite_parts=tuple(row.get("composite_parts") or ()),
composite_not_ready=tuple(row.get("composite_not_ready") or ()),
structure_kind=row.get("structure_kind") or "",
blocked_reason=row.get("blocked_reason") or "",
blocked_kind=row.get("blocked_kind") or "",
variant_axis=row.get("variant_axis") or "",
variant_value=str(row.get("variant_value") or ""),
spec_class=row.get("spec_class") or "",
spec_class_basis=row.get("spec_class_basis") or "",
pum_edition=str(row.get("pum_edition") or ""),
)
for row in payload["work_items"]
]
materials = [
HandoffMaterial(
material_name=row.get("material_name", ""),
spec=row.get("spec") or "",
unit=row.get("unit") or "",
net_amount=_decimal(row.get("net_amount")) or _ZERO,
total_amount=_decimal(row.get("total_amount")) or _ZERO,
supply_type=row.get("supply_type") or SUPPLY_UNKNOWN,
surcharge_pct=_decimal(row.get("surcharge_pct"), None),
surcharge_note=row.get("surcharge_note") or "",
install_by=row.get("install_by"),
source_structure=tuple(row.get("source_structure") or ()),
)
for row in payload["materials"]
]
return work_items, materials
@dataclass(frozen=True)
class _MasterNode:
code: str
@@ -414,11 +278,11 @@ from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import ( # noqa: E402
_composite_row,
_excluded_row,
_leaf_row,
_material_row,
_structure_price_row,
_sum_groups,
bill_line, # noqa: F401 — 내역 줄 성분별 절사(골든셋 시험이 여기서 부름)
)
from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import _material_row # noqa: E402
def build_bill(
@@ -0,0 +1,165 @@
"""B09 원가계산 — ④ 예산내역서 **인계 읽기**(자료형 · 파싱).
`B09_Estimation_BillOfQuantities` 700줄에 닿아 **그대로 옮겨 자리**
(2026-09-15 · 순수 분리 글자 고쳤다). 조판·계층은 그쪽에 그대로 있다.
**모르는 칸을 채우지 않는다** B08 보낸 값은 `None`· 문자열로 남긴다.
쓰던 자리가 바뀌도록 모듈이 다시 내보낸다
`from ...BillOfQuantities import HandoffWorkItem` 그대로 돈다.
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
_ZERO = Decimal(0)
#: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인).
#: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다.
SUPPLY_UNKNOWN = "unknown"
#: 관급 — 총원가 밖 별도 표기라 사급과 **가는 자리가 다르다**(PLAN 8-2).
SUPPLY_OWNER = "owner_supplied"
#: B08 `BLOCKED_UNCONFIRMED` 와 같은 글 — 치수 없이 기본값으로 선 줄.
BLOCKED_UNCONFIRMED = "unconfirmed"
class BillError(ValueError):
"""내역서를 세울 수 없는 경우. 빈 표를 돌려주지 않고 멈춘다."""
@dataclass(frozen=True)
class HandoffWorkItem:
"""B08 인계 공종 한 줄. **수량은 B08 것이 정본이다** — 여기서 다시 세지 않는다."""
work_item_code: str | None
name: str
spec: str
unit: str
quantity: Decimal
in_bill: bool
#: 운반 줄에만 있다 — 거리(m)와 수단. 무대(20 m 이내)는 `free_haul` 로 온다.
haul_distance_m: Decimal | None = None
haul_equipment: str | None = None
in_bill_reason: str = ""
origin: str = ""
ground_class: str = ""
#: 반영률(%) — B08 이 이미 곱했으면 산출근거에만 적고 **여기서 또 곱하지 않는다**.
application_ratio_pct: Decimal | None = None
#: 반영률 적용 **전** 수량. 산출근거에만 쓴다.
quantity_gross: Decimal | None = None
#: 성·절토처럼 율이 갈리는 경우의 몫별 율·수량 — 문장 파싱 없이 그대로 그린다.
application_ratio_breakdown: dict | None = None
quantity_breakdown: dict | None = None
#: 묶음 줄(옹벽처럼 품셈에 그 공종이 없는 것) — 무엇으로 이루어지는지.
composite_parts: tuple = ()
#: 묶음인데 아직 못 채운 조각 — 「단가 없음」과 「물량 없음」을 갈라 적는다.
composite_not_ready: tuple = ()
structure_kind: str = ""
#: B08 이 적어 보낸 막힘 사유 — **문구는 B08 것을 그대로 쓴다**(두 벌로 짜지 않는다).
blocked_reason: str = ""
#: 막힘 갈래 — `input_missing`(사용자가 입력하면 풀림) /
#: `unit_data_missing`·`formula_missing`(우리가 만들어야 함). 할 일이 다르므로 가른다.
blocked_kind: str = ""
#: 갈래 축·원본값 — 「stone_cm」·「60~80」. **키 문자열은 우리가 만든다**(두 창 합의).
variant_axis: str = ""
variant_value: str = ""
#: 규격 갈래를 B08 이 판정해 보낸 것(「철근구조물」)과 그 근거 문구.
#: **근거는 산출근거 칸에 그대로 적는다** — 우리가 다시 지어내지 않는다.
spec_class: str = ""
spec_class_basis: str = ""
#: 이 줄의 공종 코드가 본 품셈 판 — 마스터 판과 다르면 값을 안 씀(명세 17장).
pum_edition: str = ""
@property
def display_name(self) -> str:
return f"{self.name} {self.spec}".strip()
@property
def unconfirmed(self) -> bool:
"""B08 이 치수 없이 기본값으로 세운 줄(2026-09-14 브레인 판정) — 내역 제자리에
** 금액 + 빨간 테두리** 서고 합계에 · 머리에 미확정 N건 금액에 들어감."""
return self.blocked_kind == BLOCKED_UNCONFIRMED
@dataclass(frozen=True)
class HandoffMaterial:
"""B08 인계 자재 한 줄. `work_item_code` 칸이 **아예 없는** 별도 벌이다(계약 확정)."""
material_name: str
spec: str
unit: str
net_amount: Decimal
total_amount: Decimal
supply_type: str
surcharge_pct: Decimal | None = None
surcharge_note: str = ""
install_by: str | None = None
source_structure: tuple[str, ...] = ()
@property
def display_name(self) -> str:
return f"{self.material_name} {self.spec}".strip()
def _decimal(value: Any, default: Decimal | None = _ZERO) -> Decimal | None:
if value is None or value == "":
return default
return Decimal(str(value))
def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[HandoffMaterial]]:
"""인계 응답을 우리 자료형으로 옮긴다. **모르는 칸을 채우지 않는다.**"""
if "work_items" not in payload or "materials" not in payload:
raise BillError("인계 응답에 `work_items`·`materials` 두 벌이 다 있어야 합니다.")
work_items = [
HandoffWorkItem(
work_item_code=row.get("work_item_code"),
name=row.get("name", ""),
spec=row.get("spec") or "",
unit=row.get("unit") or "",
quantity=_decimal(row.get("quantity")) or _ZERO,
in_bill=bool(row.get("in_bill", True)),
in_bill_reason=row.get("in_bill_reason") or "",
origin=row.get("origin") or "",
ground_class=row.get("ground_class") or "",
haul_distance_m=_decimal(row.get("haul_distance_m"), None),
haul_equipment=row.get("haul_equipment"),
# ⚠ 있으면 **적기만** 한다 — 곱하기는 B08 한 곳에서만(2026-09-08 이견 ①).
application_ratio_pct=_decimal(row.get("application_ratio_pct"), None),
quantity_gross=_decimal(row.get("quantity_gross"), None),
application_ratio_breakdown=row.get("application_ratio_breakdown"),
quantity_breakdown=row.get("quantity_breakdown"),
composite_parts=tuple(row.get("composite_parts") or ()),
composite_not_ready=tuple(row.get("composite_not_ready") or ()),
structure_kind=row.get("structure_kind") or "",
blocked_reason=row.get("blocked_reason") or "",
blocked_kind=row.get("blocked_kind") or "",
variant_axis=row.get("variant_axis") or "",
variant_value=str(row.get("variant_value") or ""),
spec_class=row.get("spec_class") or "",
spec_class_basis=row.get("spec_class_basis") or "",
pum_edition=str(row.get("pum_edition") or ""),
)
for row in payload["work_items"]
]
materials = [
HandoffMaterial(
material_name=row.get("material_name", ""),
spec=row.get("spec") or "",
unit=row.get("unit") or "",
net_amount=_decimal(row.get("net_amount")) or _ZERO,
total_amount=_decimal(row.get("total_amount")) or _ZERO,
supply_type=row.get("supply_type") or SUPPLY_UNKNOWN,
surcharge_pct=_decimal(row.get("surcharge_pct"), None),
surcharge_note=row.get("surcharge_note") or "",
install_by=row.get("install_by"),
source_structure=tuple(row.get("source_structure") or ()),
)
for row in payload["materials"]
]
return work_items, materials
@@ -15,7 +15,13 @@ from __future__ import annotations
from decimal import Decimal
from typing import Any, Callable
from B09_Estimation.B09_Estimation_BillOfQuantities import BillResult, BillRow
from B09_Estimation.B09_Estimation_BillOfQuantities import (
SUPPLY_OWNER,
SUPPLY_UNKNOWN,
BillResult,
BillRow,
HandoffMaterial,
)
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceKind
from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, UnitPriceBuild
@@ -24,6 +30,71 @@ DOUBLE_COUNT_SUSPECT = "double_count_suspect"
_ZERO = Decimal(0)
def _material_row(
material: HandoffMaterial, result: BillResult, manual: dict | None = None
) -> BillRow:
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.
`manual` 자재 단가 수동 단가( 이름 규격). 사급 줄에 값이 있으면 빠진 목록에 올림
(금액은 본체 자재(사급) 줄이 `BillOfQuantities_Materials`).
"""
row = BillRow(
item_no="",
level=1,
code=None,
name=material.material_name,
spec=material.spec,
unit=material.unit,
quantity=material.total_amount,
)
# 할증 사유는 **수량**에 닿는다 — 할증이 곱해진 뒤의 수량이기 때문이다.
row.add_note("quantity", material.surcharge_note)
if material.supply_type == SUPPLY_UNKNOWN:
row.add_note(
"", "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다."
)
result.missing.append(
{
"name": material.display_name,
"unit": material.unit,
"quantity": str(material.total_amount),
"reason": "공급 구분 미정(unknown)",
}
)
return row
# ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이
# `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도
# 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지).
if material.supply_type == SUPPLY_OWNER:
row.add_note(
"unit_price_krw",
"관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. "
"관급자재대(총원가 밖 별도 표기)로 갑니다.",
)
reason = "관급 자재 단가 없음"
elif f"{material.material_name} {material.spec}".strip() in (manual or {}):
row.add_note(
"unit_price_krw", "⚠ 사급 자재 수동 단가(미확정) — 본체 「자재(사급)」 줄로 섬"
)
return row
else:
row.add_note(
"unit_price_krw", "사급 자재 단가 미확보 — 「자재 단가」 탭에서 수동 입력 대기."
)
reason = "사급 자재 단가 없음(미결 No.18)"
result.missing.append(
{
"name": material.display_name,
"unit": material.unit,
"quantity": str(material.total_amount),
"reason": reason,
"supply_type": material.supply_type,
}
)
return row
def _flat(text: Any) -> str:
return "".join(str(text or "").split())
@@ -9,14 +9,12 @@
from __future__ import annotations
import re
from decimal import Decimal
from B09_Estimation.B09_Estimation_BillOfQuantities import (
SUPPLY_OWNER,
SUPPLY_UNKNOWN,
BillResult,
BillRow,
HandoffMaterial,
HandoffWorkItem,
_BLOCKED_LABELS,
_MasterNode,
@@ -25,6 +23,7 @@ from B09_Estimation.B09_Estimation_BillOfQuantities import (
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import (
DUMP_PARENT,
LOADING_EQUIPMENT,
distance_label,
dump_child_for,
dump_title_code,
loading_title_code,
@@ -40,6 +39,13 @@ from B09_Estimation.B09_Estimation_UnitPrice import (
)
def spec_with_variant(spec: str, variant: str) -> str:
"""규격에 갈래를 한 번만 — 적힌 갈래(Ø800·리핑암)는 안 붙임(㉵) · 규격 글로 시작하면 갈음."""
if re.search(rf"(?<![\d.]){re.escape(variant)}(?![\d.])", spec): # 숫자 경계: Ø1800 ≠ 800
return spec
return variant if variant.startswith(spec) else f"{spec} {variant}"
def bill_line(unit: Money3, quantity) -> Money3:
"""내역 줄 금액 — **성분마다** `절사(수량 × 성분 단가)`, 줄 합계는 셋의 합(명세 7장).
@@ -112,6 +118,7 @@ def _composite_row(
in_bill=item.in_bill,
)
missing_parts: list[str] = []
reasons: list[str] = []
money = None
for part in item.composite_parts:
code = str(part.get("code") or "")
@@ -119,12 +126,24 @@ def _composite_row(
if not code or amount is None or f"B-{code}" not in unit_prices.book.titles:
missing_parts.append(code or str(part.get("name") or "이름 없음"))
continue
# 조각도 보통 줄과 같은 두 검사 — 일부 몫만 선 단가·밑수 모르는 표를 묶음에 더하면
# 묶음 줄만 온전한 금액처럼 섬(2026-09-14 ㉱ 구조 결함).
plain = code.split("#", 1)[0]
covered = unit_prices.partial_ratio.get(plain)
basis = unit_prices.basis_missing.get(plain)
if covered is not None or basis:
missing_parts.append(code)
reasons.append(
f"{code}: 단가 일부만 섬(붙은 몫 {covered}%)"
if covered is not None
else f"{code}: 밑수 미확보 — 원문 {basis}"
)
continue
# 묶음도 호표 한 장 — 조각 줄 0.1원 · 성분 소계 원 미만 절사(아래 `floored`, 명세 7장).
scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount).floored(Decimal("0.1"))
money = scaled if money is None else money + scaled
row.parts.append((f"B-{code}", amount))
reasons: list[str] = []
for pending in item.composite_not_ready:
# 「단가 없음」과 「물량 없음」을 가른다 — 사유가 다르면 할 일도 다르다.
if isinstance(pending, str):
@@ -362,7 +381,7 @@ def _leaf_row(
return row
price_code = wanted
if not loading:
row.spec = f"{row.spec} L={item.haul_distance_m}m".strip()
row.spec = f"{row.spec} {distance_label(item.haul_distance_m)}".strip()
if price_code not in unit_prices.book.titles:
# B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다.
# 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다.
@@ -374,8 +393,7 @@ def _leaf_row(
default = unit_prices.default_variants.get(node.code)
if picked is not None:
price_code = picked
variant = str(item.variant_value) # 규격 글로 시작하는 갈래는 한 번만(면고르기)
row.spec = variant if variant.startswith(row.spec) else f"{row.spec} {variant}"
row.spec = spec_with_variant(row.spec, str(item.variant_value))
elif default is not None:
# 표에 없거나 안 준 암질(풍화암·암) — **원문이 정한 갈래**로만 선다(9-4-1 [주]① 평균).
price_code = f"{price_code}#{default[0]}"
@@ -444,6 +462,8 @@ def _leaf_row(
row.add_note(
"unit_price_krw", "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다."
)
if form_judgment_note(node.code): # 사람이 가른 표 형태 까닭(10-A ⑭)
row.add_note("unit_price_krw", form_judgment_note(node.code))
reason = "일위대가 없음"
# 관경이 표 밖이면 **무엇을 정해야 하는지**까지 가리킨다.
diameter_note = pipe_diameter_note(node.code, item.variant_value) if children else ""
@@ -595,6 +615,7 @@ _PENDING_FORMULA: dict[str, str] = {}
from B09_Estimation.B09_Estimation_KnownGaps import ( # noqa: E402
form_judgment_note,
known_gap_note,
pipe_diameter_note,
)
@@ -610,71 +631,6 @@ def pending_formula_note(code: str | None) -> str:
return ""
def _material_row(
material: HandoffMaterial, result: BillResult, manual: dict | None = None
) -> BillRow:
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.
`manual` 자재 단가 수동 단가( 이름 규격). 사급 줄에 값이 있으면 빠진 목록에 올림
(금액은 본체 자재(사급) 줄이 `BillOfQuantities_Materials`).
"""
row = BillRow(
item_no="",
level=1,
code=None,
name=material.material_name,
spec=material.spec,
unit=material.unit,
quantity=material.total_amount,
)
# 할증 사유는 **수량**에 닿는다 — 할증이 곱해진 뒤의 수량이기 때문이다.
row.add_note("quantity", material.surcharge_note)
if material.supply_type == SUPPLY_UNKNOWN:
row.add_note(
"", "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다."
)
result.missing.append(
{
"name": material.display_name,
"unit": material.unit,
"quantity": str(material.total_amount),
"reason": "공급 구분 미정(unknown)",
}
)
return row
# ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이
# `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도
# 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지).
if material.supply_type == SUPPLY_OWNER:
row.add_note(
"unit_price_krw",
"관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. "
"관급자재대(총원가 밖 별도 표기)로 갑니다.",
)
reason = "관급 자재 단가 없음"
elif f"{material.material_name} {material.spec}".strip() in (manual or {}):
row.add_note(
"unit_price_krw", "⚠ 사급 자재 수동 단가(미확정) — 본체 「자재(사급)」 줄로 섬"
)
return row
else:
row.add_note(
"unit_price_krw", "사급 자재 단가 미확보 — 「자재 단가」 탭에서 수동 입력 대기."
)
reason = "사급 자재 단가 없음(미결 No.18)"
result.missing.append(
{
"name": material.display_name,
"unit": material.unit,
"quantity": str(material.total_amount),
"reason": reason,
"supply_type": material.supply_type,
}
)
return row
#: 같은 단위의 다른 표기 — 표기만 다르고 뜻이 같은 것을 「다르다」고 하면 멀쩡한 줄이 멈춘다.
_UNIT_ALIASES = {
"": "m3",
@@ -0,0 +1,284 @@
"""B09 원가계산 — **2장 소요재료·기계손료 표를 읽는 한 벌** (2026-09-15 브레인 ㉡ · 판정 ①②③).
산림품셈 2장은 소형 장비(체인톱·예취기·윈치·천공기 ) 1 1 소모(연료·잡품·오일) 손료계수를
표로 주고, 공종 표는 **인원** 적음(벌목부·특별인부 (체인톱 사용)). 대수 규칙이 인원에 붙어
장비 몫을 세우는데 길이 없어 공종이 인력만으로 싸게 있었음( 7 · 4-2-2 부모 표에만 장비 ).
장비 (`Equipment`) = 소요재료 · 손료 · 고르는 (체인오일 일반/친환경) · 쓰는 공종과 인원
값은 표에서 읽음(연료 // · 잡품 % · 오일 · 손료계수) 여기엔 **어느 줄을 읽을지** 적음
`X-<AR-X>` 1 1 호표 = 연료 × + 잡품(주연료비 %) + 손료(구입가 × 계수 · 구입가가 들면)
공종 제목에 장비 호표 × 인원 수량(대수 = 인원 × 100%) · 고르는 오일은 넣은 하나 × 인원 ×
인원 줄은 **표가 이름으로 밝힌 줄만**() 비슷한 인원 넓히지 않음.
구입가·오일 단가는 카탈로그가 없어 자재 단가 + 사유() · 휘발유는 유가 판으로 바로 .
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
@dataclass(frozen=True)
class Equipment:
key: str
machine: str # AR-X — 1대 1일 호표
price: str # AR-M — 구입가(손료 밑수) 칸
fuel: tuple[str, str, str] # (2-1 공종, 표, 주연료 줄 이름)
loss: tuple[str, str] # (2-2 공종, 표)
choices: tuple[tuple[str, str], ...] = () # 넣은 쪽 하나 — (AR-M, 2-1 표 줄 이름)
users: dict[str, str] = field(default_factory=dict) # 공종 → 표가 밝힌 인원 줄 이름
basis: str = ""
EQUIPMENTS: tuple[Equipment, ...] = (
Equipment(
key="체인톱",
machine="AR-X-61d1681d",
price="AR-M-5649cf3f",
fuel=("FP-02-01-01", "F0042", "보통휘발유 (주연료)"),
loss=("FP-02-02-01", "F0064"),
choices=(
("AR-M-fa7fbf6d", "체인오일 (일반오일)"),
("AR-M-de2fe662", "체인오일 (친환경오일)"),
),
# 2-1-1 [주]① 「숲가꾸기(작업로설치, 어린나무가꾸기, 단목베기) 및 수확베기, 병해충방제」 중
# 지금 제목이 서는 둘 · 표가 이름으로 밝힌 인원 줄(2026-09-15 브레인 ③).
users={"FP-04-02-02": "벌목부", "FP-06-05": "특별인부 (체인톱 사용)"},
basis="산림품셈 2-1-1 「체인톱 대수는 산출된 벌목부 또는 특별인부의 100% 적용」 · 2-2-1 손료",
),
)
@dataclass(frozen=True)
class AreaSet:
"""면적·일 꼴 — 표의 「160ha당」 이 1일 작업량인 공종(8-6-1 유인헬기 · 2026-09-15 브레인 ①③)."""
work_item: str
machine: Equipment # 1대 1일 호표(양수기) — 갈래 면적으로 나눔
per_area: tuple[tuple[str, str], ...] = () # (AR-M, 2-1 표 줄 이름) — 「20ha당 1개」 를 읽음
per_day: tuple[tuple[str, str], ...] = () # (AR-M, 사유 이름) — 1일 1대 값 ÷ 갈래 면적
AREA_SETS: tuple[AreaSet, ...] = (
AreaSet(
work_item="FP-08-06-01",
machine=Equipment(
key="양수기",
machine="AR-X-ea09a419",
price="AR-M-cfc70216",
fuel=("FP-02-01-08", "F0058", "휘발유 (양수기)"),
loss=("FP-02-02-06", "F0069"),
basis="산림품셈 2-1-8 유인 헬기(160ha당) · [주]① 대형헬기도 휘발유 10ℓ · 2-2-6 양수기 손료",
),
per_area=(("AR-M-a79b4d20", "깃 발"),),
per_day=(("AR-M-08db9ecc", "유인헬기 임차료(1일)"),),
),
)
#: 막힌 채 사유만 보태는 공종(밑수 없음 — 성분을 얹으면 밑수가 서는 날 두 번 셈 · 브레인 ②).
BLOCKED_NOTES: dict[str, tuple[str, ...]] = {
"FP-08-06-03": (
"경유(차량살포) 1.3ℓ/ha · 잡품 5% · 동력분무기 45HP 손료 0.0084 — 인력 표의 1일 작업량이 원문에 없음"
"(15ha 는 연료 설명) → 밑수가 없어 안 붙임",
"1톤 방제차량은 건설품셈 적산기준에 준하나 카탈로그에 1톤 트럭이 없음(덤프 2.5톤~·크레인 2톤~)",
),
}
_RE_AREA = re.compile(r"\(\s*(\d[\d,]*(?:\.\d+)?)\s*ha\s*당\s*\)")
_RE_PER_AREA = re.compile(r"(\d+(?:\.\d+)?)\s*ha\s*당\s*(\d+(?:\.\d+)?)\s*개")
_RE_NUMBER = re.compile(r"\d+(?:\.\d+)?")
def _tight(text: Any) -> str:
return re.sub(r"\s", "", str(text or ""))
def _table_rows(nodes: dict[str, dict[str, Any]], code: str, table_id: str) -> list[list[str]]:
node = nodes.get(code) or {}
table = next((t for t in node.get("tables") or [] if t.get("pum_table_id") == table_id), {})
return [[str(c) for c in row] for row in table.get("raw_row") or []]
def _row_numbers(rows: list[list[str]], name: str) -> list[Decimal]:
"""그 이름 줄의 수 칸들 — 이름 칸 뒤에서 차례로(빈 칸 건너뜀)."""
row = next((r for r in rows if r and _tight(r[0]) == _tight(name)), None)
if row is None:
return []
return [
Decimal(m.group()) for c in row[1:] if (m := _RE_NUMBER.fullmatch(_tight(c).rstrip("%")))
]
def _fuel_values(rows: list[list[str]], name: str) -> tuple[Decimal, Decimal] | None:
"""(주연료 , 잡품 %) — ℓ 은 이름 다음 칸의 첫 수 · % 는 「%」 가 붙은 칸의 수(「주재료비의 95%」)."""
row = next((r for r in rows if r and _tight(r[0]) == _tight(name)), None)
if row is None or len(row) < 2:
return None
liters = _RE_NUMBER.search(row[1])
misc = next((m for c in row[2:] if "%" in c and (m := _RE_NUMBER.search(c))), None)
if liters is None or misc is None:
return None
return Decimal(liters.group()), Decimal(misc.group())
def _fuel_title(book: Any, kind: str, fuel_region: str | None) -> str:
from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price
from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle
from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, _slots
code = f"{FUEL_CODE_PREFIX}{kind}"
if code not in book.titles:
price, meta = load_fuel_price(region=fuel_region, kind=kind)
spec = f"{meta.get('region_name')} 공시가" if meta.get("region_name") else "전국 공시가"
book.add_title(PriceTitle(code, PriceKind.MATERIAL, kind, spec, "L", slots=_slots(price)))
return code
def _machine_title(build: Any, nodes: dict, eq: Equipment, fuel_region: str | None) -> list[str]:
"""`X-<AR-X>` 1대 1일 — 표에서 연료·잡품·손료계수를 읽음. 돌려주는 값 = 못 붙은 사유들."""
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle
from B09_Estimation.B09_Estimation_UnitPrice import _misc_row, _slots
book = build.book
code = f"X-{eq.machine}"
fuel = _fuel_values(_table_rows(nodes, eq.fuel[0], eq.fuel[1]), eq.fuel[2])
loss_rows = _table_rows(nodes, *eq.loss)
loss = _row_numbers(loss_rows, loss_rows[0][0]) if loss_rows else []
if fuel is None or not loss:
return [f"{eq.key} — 2장 표 칸이 달라져 장비 몫을 못 읽음"]
kind = "휘발유" if "휘발유" in eq.fuel[2] else "경유"
liters, misc = fuel
if code in book.titles:
return [] if eq.price in book.titles else [f"{eq.key}가격 — 손료({loss[0]} × 구입가) 칸"]
book.add_title(PriceTitle(code, PriceKind.MACHINE_HOURLY, eq.key, "1대 1일", "대·일"))
book.add_detail(
PriceDetail(code, _fuel_title(book, kind, fuel_region), liters, note=f"주연료 {kind}")
)
book.add_detail(_misc_row(code, misc))
reasons = []
if eq.price in book.titles:
base = f"S-{eq.machine}"
price = book.titles[eq.price].slots[-1]
book.add_title(
PriceTitle(
base, PriceKind.MACHINE_BASE, eq.key, "손료", "대·일", slots=_slots(price * loss[0])
)
)
book.add_detail(PriceDetail(code, base, Decimal(1), note=f"손료 = 구입가 × {loss[0]}"))
else:
reasons.append(f"{eq.key}가격 — 손료({loss[0]} × 구입가) 칸 · 「자재 단가」 에 넣으면 붙음")
return reasons
def attach_consumables(
build: Any, nodes: dict[str, dict[str, Any]], fuel_region: str | None = None
) -> None:
"""장비마다 1대 1일 호표를 세우고, 쓰는 공종 제목에 인원 줄 수량만큼 붙임."""
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
book = build.book
for eq in EQUIPMENTS:
for material in (eq.price, *(c for c, _ in eq.choices)):
uses = build.material_uses.setdefault(material, [])
uses.extend(c for c in eq.users if c not in uses)
machine_reasons = _machine_title(build, nodes, eq, fuel_region)
fuel_rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1])
picked = [(c, name) for c, name in eq.choices if c in book.titles]
for work_item, user_row in eq.users.items():
node_rows = [
r
for t in (nodes.get(work_item) or {}).get("tables") or []
for r in t.get("raw_row") or []
]
# 이름 칸이 뭉친 표(「벌목부 보통인부」)도 낱말로 봄 — 그 이름이 없으면 넓히지 않음(③).
if not any(
r and (_tight(r[0]) == _tight(user_row) or user_row in str(r[0]).split())
for r in node_rows
):
continue
labor_name = _tight(user_row).split("(")[0]
reasons = list(machine_reasons)
for title in [
t for t in book.titles if t == f"B-{work_item}" or t.startswith(f"B-{work_item}#")
]:
if f"X-{eq.machine}" not in book.titles:
break
users = [
r
for r in book.details.get(title, [])
if book.titles.get(r.ref_code)
and _tight(book.titles[r.ref_code].name) == labor_name
]
if not users:
continue
count = users[0].quantity
note = f"{eq.key} {count}대(인원 「{user_row}× 100%) — {eq.basis}"
book.add_detail(PriceDetail(title, f"X-{eq.machine}", count, note=note))
if len(picked) == 1:
code, name = picked[0]
liters = _row_numbers(fuel_rows, name)[0]
book.add_detail(
PriceDetail(title, code, count * liters, note=f"{name} {liters}/대/일")
)
if len(picked) > 1:
reasons.append(
f"{eq.choices[0][1]}·{eq.choices[1][1]} 가 둘 다 들어옴 — 하나만 넣을 것"
)
elif not picked and eq.choices:
reasons.append(
f"{' 또는 '.join(name for _, name in eq.choices)} — 시중가격 칸(설계자 선택) · 넣은 쪽이 붙음"
)
labels = build.unattached.setdefault(work_item, [])
labels.extend(r for r in reasons if r not in labels)
for area in AREA_SETS:
_attach_area(build, nodes, area, fuel_region)
for code, notes in BLOCKED_NOTES.items():
labels = build.unattached.setdefault(code, [])
labels.extend(n for n in notes if n not in labels)
def _attach_area(build: Any, nodes: dict, area: AreaSet, fuel_region: str | None) -> None:
"""갈래 이름의 「(Nha당)」 = 1일 작업량 — 1일 몫(양수기·임차료)은 ÷ N · 「20ha당 1개」 는 ha당."""
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
book = build.book
eq = area.machine
for material in (eq.price, *(c for c, _ in area.per_area), *(c for c, _ in area.per_day)):
uses = build.material_uses.setdefault(material, [])
if area.work_item not in uses:
uses.append(area.work_item)
reasons = _machine_title(build, nodes, eq, fuel_region)
rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1])
for title in [t for t in book.titles if t.startswith(f"B-{area.work_item}#")]:
found = _RE_AREA.search(title)
if found is None or f"X-{eq.machine}" not in book.titles:
continue
hectares = Decimal(found.group(1).replace(",", ""))
per_day = Decimal(1) / hectares
note = f"1일 1대 ÷ 1일 살포면적 {hectares}ha — {eq.basis}"
book.add_detail(PriceDetail(title, f"X-{eq.machine}", per_day, note=note))
for code, name in area.per_area:
row = next((r for r in rows if r and _tight(r[0]) == _tight(name)), [])
rule = next((m for c in row if (m := _RE_PER_AREA.search(c))), None)
if rule is None:
reasons.append(f"{_tight(name)} — 표의 「N ha당 N개」 를 못 읽음")
elif code in book.titles:
amount = Decimal(rule.group(2)) / Decimal(rule.group(1))
book.add_detail(
PriceDetail(title, code, amount, note=f"{_tight(name)} {rule.group(0)}")
)
else:
reasons.append(
f"{_tight(name)} — 단가 칸 · 넣으면 {rule.group(0)} 로 붙음(2-1-8 [주]③)"
)
for code, name in area.per_day:
if code in book.titles:
book.add_detail(PriceDetail(title, code, per_day, note=f"{name} ÷ {hectares}ha"))
else:
reasons.append(
f"{name} — 견적 칸 · 넣으면 ÷ 1일 살포면적으로 붙음(품셈에 사용료 표 없음)"
)
labels = build.unattached.setdefault(area.work_item, [])
labels.extend(r for r in reasons if r not in labels)
+4 -2
View File
@@ -64,7 +64,8 @@ FIELD_HINTS: dict[str, str] = {
"자동 = 요율 데이터 적용 하한(추정금액 1억 이상)일 때 적용 · STmate 의 토목·준설·건축·기타"
" 구분은 현행 제비율(2026-04-13)에서 율이 같아(2.3%) 칸으로 두지 않음"
),
"overhead_class": "임도가 (주)공사·전문공사 어느 쪽인지 정한 규정 없음 — 기본 (주)공사",
# 10-A ② 문구 못박음(2026-09-14 사용자 확정 — SW 규칙).
"overhead_class": "임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음",
"cut_basis": (
"기본 「총공사비 1,000원 미만 버림」 — 근거: 산림청고시 제2025-82호 「금액의 단위표준」"
"(설계서의 총액 · 원 · 1,000 · 미만버림) · 실무 6건이 여섯 다 000 으로 끝남 ·"
@@ -99,7 +100,8 @@ FIELD_HINTS: dict[str, str] = {
"waste_placement": (
"법 문언(기본): 예정가격작성기준 제19조③18호 — 폐기물처리비는 경비라 일반관리비·이윤"
" 밑수에 듦 · 실무 관행: 울진소광 원가계산서(이윤 뒤·총원가 안)·임목폐기물처리 실정보고"
"(공급가액 뒤)는 승률 밖 — 금액이 크게 갈려 설계자가 고름"
"(공급가액 뒤)는 승률 밖 — 금액이 크게 갈려 설계자가 고름 ·"
" 어느 자리든 법정경비 밑수(직접공사비·노무비)에는 안 넣음"
),
}
_GRADE_OPTIONS = [
+59 -137
View File
@@ -18,14 +18,12 @@ from __future__ import annotations
from dataclasses import dataclass, field, replace
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
from typing import Any
from B09_Estimation.B09_Estimation_Engine_Cost_Options import (
CUT_BASES,
VAT_MODES,
cut_gap,
overhead_base,
profit_cut,
vat_base,
)
from B09_Estimation.B09_Estimation_RateOverride import apply_overrides
@@ -46,9 +44,12 @@ from B09_Estimation.B09_Estimation_Statutory import (
_ZERO = Decimal(0)
_HUNDRED = Decimal(100)
#: 기본으로 켜는 비목 = **그 해 요율 데이터에 있는 것 전부**.
#: 사용자 확정(2026-09-07, PLAN 8-14): 실무 서류에 없다고 빼지 않는다.
DEFAULT_ITEMS = "ALL_AVAILABLE"
# ⬇ 입력 한 벌은 `_Input` 으로 옮겼다(2026-09-15 700줄 분리) —
# 쓰던 이름이 그대로 살게 다시 내보낸다.
from B09_Estimation.B09_Estimation_Engine_Cost_Input import ( # noqa: E402,F401
DEFAULT_ITEMS,
CostInput,
)
def floor_won(value: Decimal) -> Decimal:
@@ -61,119 +62,6 @@ def ceil_thousand(value: Decimal) -> Decimal:
return (value / 1000).quantize(Decimal(1), rounding=ROUND_CEILING) * 1000
@dataclass
class CostInput:
"""원가계산 입력. 금액은 전부 원 단위 `Decimal`."""
direct_material_krw: Decimal
direct_labor_krw: Decimal
direct_expense_krw: Decimal
indirect_material_krw: Decimal = _ZERO
#: 구간 판정용 공종·기간. `work_type` 값은 요율 데이터의 표기를 그대로 쓴다.
work_type_indirect_labor: str = "civil"
work_type_safety: str = "civil"
duration_days: int = 183
pension_year: int = 2026
#: 관급자재 — **순자재대**와 조달수수료를 나눠 받는다(순환 정의 방지, 원가계산_체계 §1).
#: ⚠ `owner_supplied_material_krw` 는 **수수료를 뺀 순자재대**다. 실무 서류의
#: 「관급자재대」는 이미 `순자재대 + 수수료` 를 천원 올림한 값이므로 그대로 넣으면 안 된다
#: (2026-09-07 실측 정정 — 울진 순자재대 69,474,220 + 수수료 375,160 = 69,849,380 →
#: 천원 올림 69,850,000). 안전관리비 관급항도 **순자재대만** 쓴다.
owner_supplied_material_krw: Decimal = _ZERO
procurement_fee_krw: Decimal = _ZERO
include_fee_in_owner_material_total: bool = True
#: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액.
owner_supplied_for_safety_krw: Decimal | None = None
#: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다(규정: 부가세 제외 기준).
owner_supplied_includes_vat: bool = True
#: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다.
#: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조).
#: 근거 — 국가계약법 시행령 제7조 1호 「공사계약의 경우에는 관급자재로 공급될
#: 부분의 가격을 제외한 금액」. 우리 계산의 그 값은 **총원가**(부가세 전, 관급 밖).
estimated_price_krw: Decimal | None = None
#: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다.
profit_adjustment_krw: Decimal = _ZERO
#: 조정액 줄 산식 칸 — 비면 「설계자 명시 입력」. 절사 자동보정이 제 이름을 적는 자리.
profit_adjustment_label: str = ""
#: 폐기물처리비 — 요율이 아니라 **실비**(수량 × 처리단가).
#: ⭐ 2026-09-14 판정 — **비목은 경비**(예정가격작성기준 제19조③18호)라 순공사원가에 들고
#: **일반관리비·이윤 밑수에 든다.** 빠지면 총액이 조용히 작아진다.
#: ⚠ 법정경비 밑수(직접공사비·노무비)에는 안 넣는다 — 그 밑수는 요율 데이터가 정한 합이다.
waste_disposal_krw: Decimal = _ZERO
#: 분리발주 — 켜면 공사와 따로 발주하는 용역이라 **총원가 밖**, 총공사비에만 더한다
#: (거창 원가계산서 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 모양). 기본 꺼짐.
waste_separate_order: bool = False
#: 폐기물처리비 자리 — `expense`(기본 · 법 문언: 경비 · 일반관리비·이윤 밑수 안) ·
#: `after_profit`(실무 관행: 이윤 뒤 · 총원가 안 · 부가세 안 · 승률 밖 — 울진소광·실정보고).
#: 분리발주(`waste_separate_order`)가 켜지면 그쪽이 먼저(총원가 밖).
waste_placement: str = "expense"
#: ── 기준 입력 10·11·12 (규칙은 `Engine_Cost_Options`) — 기본값은 종전 계산과 같음 ──
#: 원가계산 형식 — 일반관리비 밑수가 형식마다 다름(지금은 `general` 만).
form: str = "general"
#: 10. 일반관리비 — 요율 표 이름((주)공사 / 전문공사).
overhead_class: str = "civil_landscape_industrial"
#: 일반 형식 일반관리비 밑수에 더하는 관리품목 자재대.
overhead_managed_material_krw: Decimal = _ZERO
#: 12. 부가세 방식(`VAT_MODES`) · 산림조합-면세품이면 면세품 금액.
vat_mode: str = "supply"
tax_exempt_material_krw: Decimal = _ZERO
#: 11. 절사 — `grand_total`(총공사비에서 조정) · `supply`(공급가액) · `none`·빈 값은 안 자름.
#: ⭐ 2026-09-14 **기본이 켜짐** — 근거 산림청고시 제2025-82호 「금액의 단위표준」
#: 「설계서의 총액 · 원 · 1,000 · 미만버림」.
#: 실무 6건이 여섯 다 `000` 으로 끝나는 것이 증거.
#: ⚠ 2026-09-08 「조용히 깎지 않음」 합의의 정신은 살린다 — 깎은 몫과
#: **까닭(고시 번호)** 을 이윤 조정액 줄과 결과 메모에 적고,
#: 설계자가 `none` 으로 **끌 수 있게** 둔다.
cut_basis: str = "grand_total"
cut_unit_krw: int = 1000
#: 4. 고용보험 등급 — `auto`(추정금액 구간) · `none`(없음) · `1`~`7`(등급 직접).
employment_insurance_grade: str = "auto"
#: 5. 퇴직공제부금비 — `auto`(추정금액 1억 이상) · `apply`(적용) · `none`(미적용).
#: ⚠ STmate 는 토목·준설·건축·기타로 가르나 현행 제비율(2026-04-13)은 공종 구분 없이 2.3% —
#: 율이 안 갈리는 구분은 칸으로 안 세움.
retirement_mutual_aid_mode: str = "auto"
#: 13. 사급비 위치 — `material`(재료비에 포함) 뿐. 사급은 이미 내역 재료비에 들어 오므로
#: 계산에 안 씀(실무 거창·영월 · STmate 기본). 다른 자리는 사급 금액 칸이 설 때 엶.
private_material_position: str = "material"
#: 14. 낙찰방식 — `not_comprehensive`(종합심사 외) · `comprehensive`(종합심사 대상) · `turnkey`.
#: 하도급대금 지급보증 요율 줄만 고름.
bid_method: str = "not_comprehensive"
#: 하도급대금 지급보증수수료 — `off`(기본) · `on`. 실무 여섯 건에 줄 없음 · 건설산업기본법
#: 제34조 대상이면 켬(「대상 아님」인지 「안 적음」인지는 실무로 못 가름).
subcontract_guarantee: str = "off"
#: 15. 이행보증 — `general`(일반계약: 추정가격 300억 이상만) ·
#: `lowest_price_tech`(최저가·기술제안: 규모 무관).
performance_guarantee_mode: str = "general"
#: 16. 적용기준 — `national`·`local`·`moi`. 계산에 안 씀: 현행 조달청 표 한 벌뿐 · 국가/지방
#: 구간 차이(예정가격작성기준 제20조 표)는 원문 이미지라 미확인 · 행자부 표 없음. 칸만 받음.
contract_law_basis: str = ""
#: 프로젝트 요율 덮어쓰기(`RateOverride` 한 줄씩, 사유 포함) — 발주처별 별도요율.
rate_overrides: tuple[dict[str, Any], ...] = ()
#: 환경보전비 공종 (`rate_environment.all_work_types` 의 값).
#: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정.
#: 잠정 = 도로(0.9 %). 요율 데이터가 `pending` 을 달고 있어 결과 줄에 경고가 붙는다.
environment_work_type: str = "civil_road"
#: 건설기계대여대금 지급보증 공종.
equipment_guarantee_work_type: str = "civil_general"
#: 하도급대금 지급보증 — 30억 이상 구간이 공종으로 갈린다(토목·산업설비 / 건축).
subcontract_guarantee_variant: str = "integrated_civil_or_industrial"
#: 켤 비목. 기본은 「그 해 요율 데이터에 있는 것 전부」.
enabled_items: tuple[str, ...] | str = DEFAULT_ITEMS
#: 요율 데이터 파일명. **연도를 갈아끼우는 자리.**
rate_file_name: str = "rates_2026.json"
#: 매니페스트 밖 요율 파일(옛 연도 재현 검산 전용). 주면 이쪽이 우선.
rate_file_path: str | None = None
@dataclass
class CostLine:
"""원가계산서 한 줄 — 화면이 「비목·금액·요율·산출근거」를 다 보이므로 넷을 다 든다."""
@@ -365,9 +253,9 @@ def calculate_cost(data: CostInput) -> CostResult:
)
#: 절사 뒤 잔차 맞춤 반복 상한. 실무 6건은 **두 걸음 안에** 앉음
#: (다섯 건 한 걸음 · 울진 신설 두 걸음).
_CUT_MAX_PASSES = 3
#: 절사 목표를 몇 칸까지 내려 볼 것인가. 못 밟는 1,000 배수를 만나면 한 칸 내린다 —
#: 실측(거창 꼴 40 자리)에서는 **한 칸이면 다 앉았다**. 여유로 셋까지 본다.
_CUT_DESCENT_MAX = 3
#: 11. 절사 끔 — 고르개가 보내는 값. 빈 값도 같이 받는다(저장 안 된 옛 프로젝트).
CUT_OFF = ("", "none")
@@ -383,30 +271,64 @@ def _calculate_with_scale(
**걸음마다 ÷1.1 해야 한다**(2026-09-14 고침). 이윤을 1 깎으면 부가세가 따라 줄어
총공사비는 **1.1** 줄므로, 잔차를 그대로 빼면 경계를 **지나쳐** 진동한다
울진 신설 실측 `654 999 900 910 909 ` 앉았다. ÷1.1 하면 `654 999 0`.
** 밟는 배수가 있다**(2026-09-14 둘째 고침). 걸음 낙차가 1원일 때도 2원일 때도 있어
(부가세가 버림이라 걸음에 번쯤 2) 어떤 1,000 배수는 **건너뛴다**. 어림으로 좇으면
지나쳐 놓고 돌아온다 거창 40 자리 16 끝자리 999 남았다. 그래서
**목표를 박고 가장 작은 보정액을 이분으로 찾는다**(총공사비는 보정액에 대해 비증가).
배수를 밟으면 ** 아래 배수** 목표를 내려 다시 찾는다(뒷받침일 실측 거창
40 자리에서는 칸도 내려갔다). 이윤은 **가장 적게** 깎인다 실무 (÷1.1 걸음)
앉는 자리에서는 같은 값이 나온다(봉화 339 · 영월 632).
"""
result = _calculate_once(data, dataset, scale, notes)
if data.cut_basis in CUT_OFF or data.cut_unit_krw <= 0:
return result
key = "grand_total" if data.cut_basis == "grand_total" else "total_cost"
label = (
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만"
" 절사 자동보정(산림청고시 2025-82호)"
+ (" + 설계자 입력" if data.profit_adjustment_krw else "")
)
def attempt(extra: Decimal) -> CostResult:
"""이윤을 `extra` 만큼 더 깎아 한 번 셈. `0` 이면 안 자른 결과 그대로."""
if extra == 0:
return result
return _calculate_once(
replace(
data,
profit_adjustment_krw=data.profit_adjustment_krw + extra,
cut_basis="none",
profit_adjustment_label=label,
),
dataset,
scale,
notes,
)
raw = result.totals[key]
target = raw - cut_gap(raw, data.cut_unit_krw)
automatic = _ZERO
gap = _ZERO
for _ in range(_CUT_MAX_PASSES):
gap = cut_gap(result.totals[key], data.cut_unit_krw)
landed = result
for _ in range(_CUT_DESCENT_MAX):
# 목표 이하로 내려가는 **가장 작은** 보정액을 이분으로 찾는다. 한 걸음이 적어도 1원을
# 깎으므로 `raw - target` 이면 반드시 목표 아래로 간다.
low, high = _ZERO, raw - target
while low < high:
middle = (low + high) // 2
if attempt(middle).totals[key] <= target:
high = middle
else:
low = middle + 1
landed = attempt(low)
gap = landed.totals[key] - target
if gap == 0:
automatic = low
break
# 실무 식 — 총공사비 절사 + 부가세가 공급가액 비례면 ÷1.1(잔차 걸음도 같음).
automatic += profit_cut(gap, data.cut_basis, data.vat_mode)
adjusted = replace(
data,
profit_adjustment_krw=data.profit_adjustment_krw + automatic,
cut_basis="none",
profit_adjustment_label=(
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만"
" 절사 자동보정(산림청고시 2025-82호)"
+ (" + 설계자 입력" if data.profit_adjustment_krw else "")
),
)
result = _calculate_once(adjusted, dataset, scale, notes)
# 지나쳤다 = 그 배수는 못 밟는 자리. 한 칸 아래를 노린다.
target -= data.cut_unit_krw
result = landed
result.notes.append(
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만 절사 —"
f" 이윤에서 {automatic:,}원 자동보정."
@@ -416,7 +338,7 @@ def _calculate_with_scale(
if gap:
# 조용히 남기지 않는다 — 안 앉았으면 끝자리가 남았다는 것을 화면에 보인다.
result.notes.append(
f"⚠ 절사가 {_CUT_MAX_PASSES}걸음 안에 안 앉음 —"
f"⚠ 절사가 {_CUT_DESCENT_MAX} 안에 안 앉음 —"
f" 끝자리 {gap:,}원이 남음(설계자 확인 필요)"
)
return result
@@ -0,0 +1,136 @@
"""B09 원가계산 — ⑤ 공사원가계산서 **입력 한 벌**(`CostInput`).
`B09_Estimation_Engine_Cost` 700줄에 닿아 **그대로 옮겨 자리**
(2026-09-15 · 순수 분리 글자 고쳤다). 계산 사슬은 그쪽에 그대로 있다.
쓰던 자리가 바뀌도록 엔진이 다시 내보낸다
`from ...Engine_Cost import CostInput` 그대로 돈다.
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
_ZERO = Decimal(0)
#: 기본으로 켜는 비목 = **그 해 요율 데이터에 있는 것 전부**.
#: 사용자 확정(2026-09-07, PLAN 8-14): 실무 서류에 없다고 빼지 않는다.
DEFAULT_ITEMS = "ALL_AVAILABLE"
@dataclass
class CostInput:
"""원가계산 입력. 금액은 전부 원 단위 `Decimal`."""
direct_material_krw: Decimal
direct_labor_krw: Decimal
direct_expense_krw: Decimal
indirect_material_krw: Decimal = _ZERO
#: 구간 판정용 공종·기간. `work_type` 값은 요율 데이터의 표기를 그대로 쓴다.
work_type_indirect_labor: str = "civil"
work_type_safety: str = "civil"
duration_days: int = 183
pension_year: int = 2026
#: 관급자재 — **순자재대**와 조달수수료를 나눠 받는다(순환 정의 방지, 원가계산_체계 §1).
#: ⚠ `owner_supplied_material_krw` 는 **수수료를 뺀 순자재대**다. 실무 서류의
#: 「관급자재대」는 이미 `순자재대 + 수수료` 를 천원 올림한 값이므로 그대로 넣으면 안 된다
#: (2026-09-07 실측 정정 — 울진 순자재대 69,474,220 + 수수료 375,160 = 69,849,380 →
#: 천원 올림 69,850,000). 안전관리비 관급항도 **순자재대만** 쓴다.
owner_supplied_material_krw: Decimal = _ZERO
procurement_fee_krw: Decimal = _ZERO
include_fee_in_owner_material_total: bool = True
#: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액.
owner_supplied_for_safety_krw: Decimal | None = None
#: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다.
#: ⚠ 근거는 **실무**다 — 고시(산업안전보건관리비 계상 및 사용기준) 제4조① 단서는 「해당
#: 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다. ÷1.1 은 부가세 제외
#: 환산이며, 실무 원가계산서 **6건이 모두** 「관급재/1.1」로 적었다(2026-09-14 전수 확인).
owner_supplied_includes_vat: bool = True
#: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다.
#: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조).
#: 근거 — 국가계약법 시행령 제7조 1호 「공사계약의 경우에는 관급자재로 공급될
#: 부분의 가격을 제외한 금액」. 우리 계산의 그 값은 **총원가**(부가세 전, 관급 밖).
estimated_price_krw: Decimal | None = None
#: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다.
profit_adjustment_krw: Decimal = _ZERO
#: 조정액 줄 산식 칸 — 비면 「설계자 명시 입력」. 절사 자동보정이 제 이름을 적는 자리.
profit_adjustment_label: str = ""
#: 폐기물처리비 — 요율이 아니라 **실비**(수량 × 처리단가).
#: ⭐ 2026-09-14 판정 — **비목은 경비**(예정가격작성기준 제19조③18호)라 순공사원가에 들고
#: **일반관리비·이윤 밑수에 든다.** 빠지면 총액이 조용히 작아진다.
#: ⚠ 법정경비 밑수(직접공사비·노무비)에는 안 넣는다 — 그 밑수는 요율 데이터가 정한 합이다.
waste_disposal_krw: Decimal = _ZERO
#: 분리발주 — 켜면 공사와 따로 발주하는 용역이라 **총원가 밖**, 총공사비에만 더한다
#: (거창 원가계산서 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 모양). 기본 꺼짐.
waste_separate_order: bool = False
#: 폐기물처리비 자리 — `expense`(기본 · 법 문언: 경비 · 일반관리비·이윤 밑수 안) ·
#: `after_profit`(실무 관행: 이윤 뒤 · 총원가 안 · 부가세 안 · 승률 밖 — 울진소광·실정보고).
#: 분리발주(`waste_separate_order`)가 켜지면 그쪽이 먼저(총원가 밖).
waste_placement: str = "expense"
#: ── 기준 입력 10·11·12 (규칙은 `Engine_Cost_Options`) — 기본값은 종전 계산과 같음 ──
#: 원가계산 형식 — 일반관리비 밑수가 형식마다 다름(지금은 `general` 만).
form: str = "general"
#: 10. 일반관리비 — 요율 표 이름((주)공사 / 전문공사).
overhead_class: str = "civil_landscape_industrial"
#: 일반 형식 일반관리비 밑수에 더하는 관리품목 자재대.
overhead_managed_material_krw: Decimal = _ZERO
#: 12. 부가세 방식(`VAT_MODES`) · 산림조합-면세품이면 면세품 금액.
vat_mode: str = "supply"
tax_exempt_material_krw: Decimal = _ZERO
#: 11. 절사 — `grand_total`(총공사비에서 조정) · `supply`(공급가액) · `none`·빈 값은 안 자름.
#: ⭐ 2026-09-14 **기본이 켜짐** — 근거 산림청고시 제2025-82호 「금액의 단위표준」
#: 「설계서의 총액 · 원 · 1,000 · 미만버림」.
#: 실무 6건이 여섯 다 `000` 으로 끝나는 것이 증거.
#: ⚠ 2026-09-08 「조용히 깎지 않음」 합의의 정신은 살린다 — 깎은 몫과
#: **까닭(고시 번호)** 을 이윤 조정액 줄과 결과 메모에 적고,
#: 설계자가 `none` 으로 **끌 수 있게** 둔다.
cut_basis: str = "grand_total"
cut_unit_krw: int = 1000
#: 4. 고용보험 등급 — `auto`(추정금액 구간) · `none`(없음) · `1`~`7`(등급 직접).
employment_insurance_grade: str = "auto"
#: 5. 퇴직공제부금비 — `auto`(추정금액 1억 이상) · `apply`(적용) · `none`(미적용).
#: ⚠ STmate 는 토목·준설·건축·기타로 가르나 현행 제비율(2026-04-13)은 공종 구분 없이 2.3% —
#: 율이 안 갈리는 구분은 칸으로 안 세움.
retirement_mutual_aid_mode: str = "auto"
#: 13. 사급비 위치 — `material`(재료비에 포함) 뿐. 사급은 이미 내역 재료비에 들어 오므로
#: 계산에 안 씀(실무 거창·영월 · STmate 기본). 다른 자리는 사급 금액 칸이 설 때 엶.
private_material_position: str = "material"
#: 14. 낙찰방식 — `not_comprehensive`(종합심사 외) · `comprehensive`(종합심사 대상) · `turnkey`.
#: 하도급대금 지급보증 요율 줄만 고름.
bid_method: str = "not_comprehensive"
#: 하도급대금 지급보증수수료 — `off`(기본) · `on`. 실무 여섯 건에 줄 없음 · 건설산업기본법
#: 제34조 대상이면 켬(「대상 아님」인지 「안 적음」인지는 실무로 못 가름).
subcontract_guarantee: str = "off"
#: 15. 이행보증 — `general`(일반계약: 추정가격 300억 이상만) ·
#: `lowest_price_tech`(최저가·기술제안: 규모 무관).
performance_guarantee_mode: str = "general"
#: 16. 적용기준 — `national`·`local`·`moi`. 계산에 안 씀: 현행 조달청 표 한 벌뿐 · 국가/지방
#: 구간 차이(예정가격작성기준 제20조 표)는 원문 이미지라 미확인 · 행자부 표 없음. 칸만 받음.
contract_law_basis: str = ""
#: 프로젝트 요율 덮어쓰기(`RateOverride` 한 줄씩, 사유 포함) — 발주처별 별도요율.
rate_overrides: tuple[dict[str, Any], ...] = ()
#: 환경보전비 공종 (`rate_environment.all_work_types` 의 값).
#: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정.
#: 잠정 = 도로(0.9 %). 요율 데이터가 `pending` 을 달고 있어 결과 줄에 경고가 붙는다.
environment_work_type: str = "civil_road"
#: 건설기계대여대금 지급보증 공종.
equipment_guarantee_work_type: str = "civil_general"
#: 하도급대금 지급보증 — 30억 이상 구간이 공종으로 갈린다(토목·산업설비 / 건축).
subcontract_guarantee_variant: str = "integrated_civil_or_industrial"
#: 켤 비목. 기본은 「그 해 요율 데이터에 있는 것 전부」.
enabled_items: tuple[str, ...] | str = DEFAULT_ITEMS
#: 요율 데이터 파일명. **연도를 갈아끼우는 자리.**
rate_file_name: str = "rates_2026.json"
#: 매니페스트 밖 요율 파일(옛 연도 재현 검산 전용). 주면 이쪽이 우선.
rate_file_path: str | None = None
+148
View File
@@ -0,0 +1,148 @@
"""B09 원가계산 — **유로폼 사용수량** 12-38-2 (2026-09-14 브레인 301 판정 ①~⑥ · ③′).
정본 = 산림 12-38-2(10 패널 0.89 · 내부 패널 0.03 · 부자재 주자재비 간단 24 · 보통 52 · 복잡 79% ·
소모자재 5%) · 건설 공통 6-3-3 참고(고시 총칙 부문과 유사한 공종은 품셈 우선).
원문 자재비는 거래형태 등을 고려하여 **임대료 또는 손료** 산정 둘을 나란히 주므로 설계자가 고름(제안값 없음).
고르는 자리 자재 단가 (화약류와 같은 통로) 넣은 쪽으로
손료 패널·내부 패널 단가 × 수량 곧장(실무 봉화 31,500 × 0.89 / 10) ·
부자재·소모자재 = (패널 + 내부 패널) × %(봉화 2,866.5 × 52%)
임대료 유로폼 임대료 (설계자가 임대기간 반영해 ) · % 원문이 적어
· 반쯤 · 아무것도 + 사유(부모 12-38 까지 그대로 올라감)
12-38-1 잔존율(12 25%) 곱하지 않음 수량이 이미 몫으로 보임. 곱하면 나눔(패널 1/16).
가드(이중계상 )에서 12-38-02 까닭은 `B09_Estimation_Guards.SURCHARGE_INCLUDED_ITEMS` 곁에.
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
CODE = "FP-12-38-02"
PANEL = "AR-M-10aba455"
INNER = "AR-M-5b89b294"
RENT = "AR-M-d00a6b1a"
QUANTITY_TABLE = "F0393"
RATE_TABLE = "F0394"
CHOICE_MISSING = (
"자재비 — 임대료 또는 손료 설계자 선택(12-38-2 「거래형태 등을 고려하여 임대료 또는 손료로 산정」)"
" · 「자재 단가」 탭에 패널·내부 패널 단가(손료) 또는 유로폼 임대료 ㎡당(임대료) 중 하나를 넣으면 섬"
)
CHOICE_BOTH = "자재비 — 손료(패널 단가)와 임대료가 둘 다 들어옴 · 하나만 넣을 것"
LOSS_HALF = "자재비 손료 — {name} 단가 없음 · 패널·내부 패널 둘 다 넣어야 섬"
REUSE_NOTE = (
"12-38-2 표 수량 곧장(실무 넷 실증) · 12-38-1 잔존율은 안 곱함 — 표 수량이 이미 12회·잔존율 25% 몫으로"
" 보임(우리 역산: 10㎡ ÷ 0.72㎡ = 13.9매 × 0.75 ÷ 12 = 0.87 ≈ 0.89 · 원문이 적지는 않음) · 차 2.5%"
" [주]① 「할증 및 손율 포함」으로 봄(추정) · 「25회 10%」 는 표에 수량이 없어 안 세움"
)
RENT_NOTE = "설계자 임대료(12-38-2 「임대료는 시중 물가지 등을 참고하여 결정」) · 부자재·소모자재 % 는 원문이 임대료에 안 적어 안 걺"
_RE_PERCENT = re.compile(r"(\d+(?:\.\d+)?)\s*%")
def _tight(text: Any) -> str:
return re.sub(r"\s", "", str(text or ""))
def _table(node: dict[str, Any], table_id: str) -> dict[str, Any]:
return next((t for t in node.get("tables") or [] if t.get("pum_table_id") == table_id), {})
def rates(node: dict[str, Any]) -> dict[str, Decimal]:
"""부자재 요율 표(F0394) — 머리 갈래(원문 표기 「간 단」…) → %. 마스터 갈래 키도 이것을 씀."""
table = _table(node, RATE_TABLE)
heads = table.get("condition_note") or []
for row in table.get("raw_row") or []:
found = {str(h): _RE_PERCENT.search(str(c)) for h, c in zip(heads[1:], row[1:])}
return {h: Decimal(m.group(1)) for h, m in found.items() if m}
return {}
def _quantities(node: dict[str, Any]) -> tuple[Decimal | None, Decimal | None, Decimal | None]:
"""(패널 매/㎡, 내부 패널 매/㎡, 소모자재 %) — 표 F0393 10㎡당을 1㎡당으로."""
from B09_Estimation.B09_Estimation_ResourceAxis import parse_amount
table = _table(node, QUANTITY_TABLE)
per = Decimal(str(table.get("basis_quantity") or 0))
panel = inner = consumable = None
for row in table.get("raw_row") or []:
name, value = _tight(row[0]), str(row[-1])
if name == "패널" and parse_amount(value) and per:
panel = parse_amount(value) / per
elif name == "내부패널" and parse_amount(value) and per:
inner = parse_amount(value) / per
elif name.startswith("소모자재") and _RE_PERCENT.search(value):
consumable = Decimal(_RE_PERCENT.search(value).group(1))
return panel, inner, consumable
def attach_euroform(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None:
"""「자재 단가」 칸 셋을 세우고, 넣은 쪽으로 12-38-02 를 세움 — 부모 합산(`attach_parent_steps`) 앞."""
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle
node = nodes_by_code.get(CODE) or {}
for material in (PANEL, INNER, RENT):
uses = build.material_uses.setdefault(material, [])
if CODE not in uses:
uses.append(CODE)
book = build.book
loss = [m for m in (PANEL, INNER) if m in book.titles]
panel, inner, consumable = _quantities(node)
table_rates = rates(node)
reason = ""
if loss and RENT in book.titles:
reason = CHOICE_BOTH
elif RENT in book.titles:
title_code = f"B-{CODE}"
book.add_title(
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name="유로폼 사용수량",
spec="임대료",
unit="",
)
)
book.add_detail(PriceDetail(title_code, RENT, Decimal(1), note=RENT_NOTE))
elif len(loss) == 2 and panel and inner and consumable is not None and table_rates:
for head, rate in table_rates.items():
variant = _tight(head)
title_code = f"B-{CODE}#{variant}"
book.add_title(
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name=f"유로폼 사용수량 ({variant})",
spec=variant,
unit="",
)
)
book.add_detail(PriceDetail(title_code, PANEL, panel, note=REUSE_NOTE))
book.add_detail(
PriceDetail(title_code, INNER, inner, note="12-38-2 내부 패널 표 수량 곧장")
)
for label, percent in (
("부자재(웨지핀·플랫타이·강관파이프·훅)", rate),
("소모자재(박리제 등)", consumable),
):
book.add_detail(
PriceDetail(
title_code,
title_code,
Decimal(0),
note=f"{label} — 주자재비(패널 + 내부 패널)의 {percent}% (12-38-2)",
percent_of_material=percent,
)
)
build.variants.setdefault(CODE, []).append(variant)
elif loss:
missing = "내부 패널" if PANEL in loss else "패널"
reason = LOSS_HALF.format(name=missing)
else:
reason = CHOICE_MISSING
if reason:
build.component_gaps[CODE] = reason
build.unattached[CODE] = [reason]
@@ -0,0 +1,85 @@
"""B09 원가계산 — **발파 화약류 자재**(9-5-1 · 2026-09-14 브레인 300 판정).
F0243 폭약 kg 0.35 · 뇌관 1.0 · 비트 0.008 **규격을 적음** 자원 목록(`AR-M`)
항목은 있으나 조인 규칙(규격이 같아야 · 후보 하나여도 자동 고름) 걸려 붙었음.
치즐과 같은 모양: 자재 단가 탭에 칸이 서고 **설계자가 규격·단가를 넣으면** 수량으로 붙음.
넣으면 규격 미정 사유로 붙은 줄에 남음(임의로 규격을 고르지 않음).
잡재료비 주재료의 5%(폭약 비고) 아직 사유에 적음.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
#: 공종 → (표 이름, 자원 목록 코드). 표 이름은 공백을 지운 글.
EXPLOSIVES: dict[str, tuple[tuple[str, str], ...]] = {
"FP-09-05-01": (
("폭약", "AR-M-6fc2930f"),
("뇌관", "AR-M-0965627d"),
("비트", "AR-M-a1a18ec4"),
),
}
EXPLOSIVE_MISSING = (
"{name} — 규격 미정(원문 표가 규격을 안 적음) · 「자재 단가」 탭에서 규격·단가를 넣으면 붙음"
)
MISC_NOTE = "ⓘ 폭약 줄 비고 「잡재료비: 주재료의 5%」 는 아직 안 걺"
#: 착암기 — 건설품셈 8-3-6 (5205) 공기압축기 손료표 [주]① 「부수물(호스포함)은 별도 계상한다」 ·
#: 부수물 관계표에 「래그 해머 2.7㎥/min」 · 래그해머 손료표는 고시 없음(2026-09-14 안티그래비티 ·
#: 원문 L2646~2683). 압축기 손료에 든다고 **정하지 않음** — 원문이 「별도」.
LEG_HAMMER = "착암기2.7㎥/min"
LEG_HAMMER_NO_LOSS = (
"착암기 2.7㎥/min — 공기압축기의 부수물 「래그 해머」(건설품셈 8-3-6 (5205) [주]① 「부수물은"
" 별도 계상」)이라 압축기 손료에 안 듦 · 래그해머 손료표는 원문에 고시 없음 → 손료를 못 셈"
)
def _table_amount(node: dict[str, Any], name: str) -> Decimal | None:
"""표에서 그 이름 줄의 첫 수 — 없으면 `None`."""
from B09_Estimation.B09_Estimation_ResourceAxis import parse_amount
for table in node.get("tables") or []:
for row in table.get("raw_row") or []:
cells = [str(cell) for cell in row]
names = ["".join(cell.split()) for cell in cells]
if name not in names:
continue
index = names.index(name)
value = next((parse_amount(c) for c in cells[index + 1 :] if parse_amount(c)), None)
if value is not None:
return value
return None
def attach_explosives(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None:
"""칸을 세우고(`material_uses`) · 단가가 든 것은 붙이고 · 안 든 것은 사유로 갈아 끼움."""
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
for code, items in EXPLOSIVES.items():
node = nodes_by_code.get(code) or {}
labels = list(build.unattached.get(code) or [])
for name, material in items:
build.material_uses.setdefault(material, [])
if code not in build.material_uses[material]:
build.material_uses[material].append(code)
amount = _table_amount(node, name)
labels = [label for label in labels if "".join(label.split()) != name]
if amount is None:
continue
titles = [
t for t in build.book.titles if t == f"B-{code}" or t.startswith(f"B-{code}#")
]
if material in build.book.titles and titles:
for title in titles:
build.book.add_detail(
PriceDetail(title, material, amount, note=f"{name} — 설계자 규격·단가")
)
else:
labels.append(EXPLOSIVE_MISSING.format(name=name))
if MISC_NOTE not in labels:
labels.append(MISC_NOTE)
labels = [
LEG_HAMMER_NO_LOSS if "".join(label.split()) == LEG_HAMMER else label
for label in labels
]
build.unattached[code] = labels
+5 -2
View File
@@ -231,10 +231,13 @@ def check_handoff_boundaries(violations: list[str] | None) -> None:
#: 품셈 [주]가 「재료량에 할증 포함」이라 적은 공종 — 그 재료가 일위대가 재료비로 붙으면
#: **할증 뒤 값**이 들어가 자재총괄에서 한 번 더 붙는다(㉠). 원문 , 코드는 마스터가 붙인 자리.
#: **할증 뒤 값**이 들어가 자재총괄에서 한 번 더 붙는다(㉠). 원문 , 코드는 마스터가 붙인 자리.
#: ⚠ 유로폼 사용수량 12-38-02([주]① 「할증 및 손율이 포함」)는 **뺐음**(2026-09-14 브레인 301 ④) —
#: 이 가드의 전제 「자재총괄에서 할증이 한 번 더」가 안 섬: 패널은 B08 자재총괄에 줄이 없음(유로폼 ㎡ 갈 곳
#: `unit_price`) · 손료 수량이라 할증 전 값이 원문에 없음 · 실무 넷도 일위대가에 곧장 붙임.
#: 유로폼·패널이 자재총괄로 가는 날 되돌릴 것 — `test_b09_euroform` 이 그 자리에서 빨강.
SURCHARGE_INCLUDED_ITEMS: dict[str, str] = {
"FP-12-02": "용적 배합 콘크리트 참고표 「재료량에는 할증률이 포함」(마스터가 12-2 에 붙임)",
"FP-12-38-02": "유로폼 사용수량 [주]① 「재료량에는 재료의 할증 및 손율이 포함」",
"FP-13-11-04": "돌망태 사각형 [주]① 「자재비에는 재료의 할증을 포함」",
"AX-WK-c0842a0d": "모르타르 배합 참고자료 ※ 「위 재료량은 할증이 포함된 것이다」",
}
@@ -55,6 +55,58 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = {
"(제안 무한궤도 — 영월 실무 「06M3 B/H」 · 2026-09-14 브레인 ②).",
),
# 2026-09-14 ㉮ — 사용횟수 갈래로 푼 뒤 남는 원문 몫. 값을 짓지 않고 말만.
# 봉상후렉시블 셋의 표 나머지 줄은 판정표 자동 목록(`_unread_rows`)이 맡음 — 줄이 아닌 [주] 만 여기.
"FP-12-12": ("원문 [주]", "ⓘ [주] 「성토부 날개벽 설치시 인건비 30% 할증」 은 안 걺(선택)."),
# ㉡-3(2026-09-15) — 시간 줄 표 둘 · 막힌 셋은 옛 값이 왜 틀렸는지(풀리는 날 두 번 세지 않게 · 브레인 ③).
"FP-14-01": (
"원문 표",
"ⓘ 원문 「0.8㎥ 굴착기(우드그랩 부착)」 가 굴착기 형식을 안 적어 무한궤도·타이어 두 갈래 · 우드그랩 ↔ 카탈로그"
" 「부착용 집게 7206-0070(0.60.8)」 — 이름이 다름 · 인력 「h」 는 시간이라 총칙 일일 작업시간 8시간으로 ÷8.",
),
"FP-14-02": (
"원문 표",
"ⓘ 원문 「0.8㎥ 굴착기」 가 형식을 안 적어 무한궤도·타이어 두 갈래 · 인력 「h」 는 시간이라 총칙 일일 작업시간"
" 8시간으로 ÷8 · 옛 11,356원은 0.66h 를 인으로 셈(8배) + 굴착기 줄이 빠진 값.",
),
"FP-12-26": (
"막힘 까닭",
"ⓘ 양수기(150㎜)·디젤엔진(15HP 11.19kW) 가동시간이 원문 표에 없음 · 카탈로그 양수기는 1.49㎾ 뿐 · 엔진(디젤)"
" 손료계수 없음 → 금액 안 세움 · 옛 172,068원은 보통인부 1인 값뿐(기계·목도 4인 빠짐).",
),
"FP-10-07-04": (
"막힘 까닭",
"ⓘ 운전 단가표(1일) 보통인부 2 는 적재·짐부리기 보통인부 2인(「운전조작」 포함)과 같은 사람 — 두 번 세지 말 것 ·"
" 연료 「ps × 0.253ℓ × 6h」 의 ps 입력 없음 · 본기계·레일은 임대료·견적 → 금액 안 세움 · 옛 688,272원은"
" 보통인부를 두 번 센 4인 값.",
),
"FP-10-08-03": (
"막힘 까닭",
"ⓘ 연료 = 윈치 기관 출력([주]② 36~73kW) × 운전시간([주]① 4.3~6.7h) × /kWh — /kWh 가 원문에 없음 · 보통인부는"
" 10-8-1 짐내리기 품 · 운반기구 손료 별도 → 금액 안 세움 · 옛 226,122원은 특별인부 1인 값뿐.",
),
"FP-08-06-01": (
"원문 [주]",
"ⓘ 2-1-8 [주]② 「소방관서의 급수 지원을 받은 경우에는 휘발유(양수기)는 미반영」 — 해당하면 양수기 몫을"
" 뺄 것 · 8-6-1 [주]③ 실제 사용하지 않는 품은 제외.",
),
"FP-08-11": (
"원문 [주]",
"ⓘ [주]③ 장비 운반비는 별도 계상(기계 수송비 칸) · [주]④ 우드그랩은 원목 규격에 따라 별도 · [주]② 추가"
" 인력(파쇄 후 마대담기 등)은 조사해 반영 · [주]⑥ 이 규격 외 파쇄기는 견적 — 이 일위대가엔 안 넣음.",
),
# 원문 대 실무 어긋남 기록(2026-09-15 브레인 규칙 — 원문이 또렷하면 원문 · 어긋남은 늘 기록).
"FP-09-15-02": (
"실무 어긋남",
"ⓘ 실무 영월 「표토제거 답외구간」 호표 Q=576.95 는 E 자리에 e(0.96)를 넣은 값으로 역산됨"
"(60×3.07×0.77×0.96÷(1.18×0.2)) — 원문 9-15-2 가 E=0.4 로 또렷해 원문으로 셈 · 다른 실무(봉화·대흥·"
"소광·거창)엔 이 호표 없음.",
),
"FP-12-34-01": (
"원문 머리",
"ⓘ 원문 머리 「(단위: 개소당)」 ↔ 인력 콘크리트공 0.17·보통인부 0.29 가 12-16 맨홀 ㎥당(0.17인/㎥"
" · 0.29인/㎥)과 같고 기계도 Q=5.4㎥/hr ⇒ ㎥당으로 읽음(고른 쪽 ㎥당 · 버린 쪽 개소당) ·"
" 봉상후렉시블(45mm) 대 2 는 엔진식 진동기의 봉이라 두 번 안 셈(2026-09-14 브레인).",
),
"FP-12-04": (
"원문 [주]",
"ⓘ 「사용고재 평가기준 23%(합판과 각재의 설계단가 기준)」 은 원문이 셈을 안 줘 값으로 안 씀"
@@ -110,6 +162,41 @@ def known_gap_note(code: str | None) -> str:
return " / ".join(parts)
#: 표 형태 이름 — 판정 까닭 한 줄에 붙임.
_FORM_LABELS = {
"reference": "참조표",
"coefficient": "계수표",
"productivity": "생산량형",
"requirement": "소요량형",
}
def form_judgment_note(code: str | None) -> str:
"""사람이 가른 표 형태 까닭 — 원문 근거 없는 **SW 규칙**이라 화면에 드러냄(10-A ⑭ · 09-14).
정본은 B08 마스터 빌더의 판정표 (`_Forms.FORM_JUDGMENTS`) 여기서 다시 적지 않고 읽음.
"""
plain = str(code or "").split("#")[0]
if not plain:
return ""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import FORM_JUDGMENTS
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
node = next(
(n for n in load_work_item_master()["work_items"] if n.get("work_item_code") == plain),
{},
)
parts = []
for table in node.get("tables") or []:
found = FORM_JUDGMENTS.get(str(table.get("pum_table_id")))
if found:
_section, form, why = found
parts.append(f"{table['pum_table_id']} {_FORM_LABELS.get(form, form)}: {why}")
if not parts:
return ""
return "ⓘ 표 형태는 사람이 가름(원문 근거 없음 · SW 규칙) — " + " / ".join(parts)
#: 관부설 품셈 표가 다루는 관경 — 그 밖은 **표에 없는 것**이지 값이 틀린 것이 아니다.
PIPE_TABLE_DIAMETERS_MM = (800, 1000, 1200)
@@ -12,8 +12,10 @@
(1-4-1 어린나무가꾸기에 한하여 · 1-4-2 줄베기 · 1-4-9 숲가꾸기 병해충방제
작업로). **임도 토공에 붙이라는 지시가 원문에 없다.** 그래서 켜는 것은 사용자 몫이고,
계열의 **[] 원문을 화면에 그대로** 띄워 어디에 쓰라는 표인지 보이게 한다.
**여럿을 고를 합산인가 곱인가** 원문에 없다. 지금은 **합산**으로 두고 사실을
화면 근거에 적는다(실무 서식이 대개 합산이나 원문 근거는 아니다).
**여럿을 고를 합산인가 곱인가** 산림품셈 1-4 에는 없고 **건설 공통 1-4-2 할증의
중복가산요령** 정함(교차 참조 · 2026-09-14 브레인 672): W = 기본품 × (1 + a1 + + an)
· , 동일성격의 품할증요소의 이중적용은 불가 **합산**. 동일성격 어느 계열끼리인지는
원문이 정해 막지 않고 단서를 화면에 보임(설계자가 가림).
**기본은 고름** 계열도 고르면 금액이 원도 움직인다.
@@ -46,13 +48,14 @@ _NOTE_LOOKAHEAD = 12
_RE_PERCENT = re.compile(r"^-?\d+(?:\.\d+)?%$")
_RE_SECTION = re.compile(r"^(1-4-\d+)\.\s*(.+)$")
#: ⚠ **여럿을 고를 때 어떻게 셈하나 — 원문이 안 정한 자리다.**
#: 지금은 「합산」이고 **여기 한 곳만 갈아 끼우면 바뀐다**(코드 깊이 박지 않는다).
#: 여럿을 고를 때 셈법 — 건설 공통 1-4-2 「W = 기본품 × (1 + a1 + … + an)」 합산(교차 참조 · 672).
#: **여기 한 곳**이 정한다(코드 깊이 박지 않는다).
#: `"sum"` = 10% + 5% = 15% · `"product"` = 1.10 × 1.05 1 = 15.5%
COMBINE_RULE = "sum"
COMBINE_NOTE = (
"여럿을 고르면 더합니다 — 원문이 합산인지 곱인지 안 정해 우리가 그렇게 두었습니다"
" (사용자 확정 대기)."
"여럿을 고르면 합산 — 건설 공통 1-4-2 「W = 기본품 × (1 + a1 + a2 + … + an)」"
"(산림품셈 1-4 에 겹침 규정이 없어 교차 참조) · ⚠ 같은 조 단서 「동일성격의 품할증요소의"
" 이중적용은 불가」 — 어느 계열끼리 동일성격인지는 원문이 안 정해 설계자가 가림"
)
SEAT_NOTE = (
"품 할인·할증은 품(인력) 줄에 붙습니다 — 물량에 곱하면 자재·기계까지 부풀어"
@@ -145,6 +145,15 @@ GLUED_MACHINE_FIXES: dict[str, tuple[str, str]] = {
"7120-0746": ("버킷식준설기", "7.46kW"),
"7995-0050": ("배관파이프", "ø50-2.6m"),
}
#: ⚠ **한 칸에 두 줄이 뭉친 표** (2026-09-14 661 뒤 ① 브레인) — 원천이 (4611) 콘크리트 진동기 표의
#: 두 기종(「4611-0075 0350」 · 규격·계수가 한 칸에 둘씩)을 못 갈라 **규격이 비고 손료계수가 없음**.
#: 규격·시간당 계는 **건설공사 표준품셈 제8장 (4611)** 원문(L2504) 그대로 — 판정 없음.
#: 상각 3,000 + 정비 1,167 + 관리 768 = 4,935 · 3,000 + 1,333 + 768 = 5,101 (표의 「계」와 같음)
#: ⚠ 원천이 바로 실으면 이 표는 지운다(위 표와 같은 약속). 취득가는 원천 값 그대로.
MERGED_ROW_FIXES: dict[str, tuple[str, Decimal]] = {
"4611-0075": ("전기식 플렉시블형 ø45(0.75㎾)", Decimal("0.0004935")),
"4611-0350": ("엔진식 플렉시블형 ø45(2.6㎾)", Decimal("0.0005101")),
}
#: 이름이 빈 분류 — 분류번호(코드 앞 넷) → 원문 이름. 규격은 원천 값 그대로.
EMPTY_NAME_BY_GROUP: dict[str, str] = {
"0240": "유압식 진동콤팩터(굴착기 부착용)",
@@ -234,6 +243,10 @@ def load_machine_catalog(file_name: str = "mach_base_2026.json") -> MachineCatal
elif code in GLUED_MACHINE_FIXES:
name, spec = GLUED_MACHINE_FIXES[code]
row = {**row, "machine_name": name, "specification": spec}
elif code in MERGED_ROW_FIXES and "loss_coefficient_per_hour" not in coefficient:
spec, merged_coefficient = MERGED_ROW_FIXES[code]
row = {**row, "specification": spec}
coefficient = {**coefficient, "loss_coefficient_per_hour": merged_coefficient}
elif not str(row.get("machine_name") or "").strip() and code[:4] in EMPTY_NAME_BY_GROUP:
row = {**row, "machine_name": EMPTY_NAME_BY_GROUP[code[:4]]}
catalog.machines[code] = MachineSpec(
@@ -88,7 +88,6 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
catalog = load_machine_catalog()
operating = {row.machine_code: row for row in load_operating_records().records}
wages = load_operator_wages()
fuel_price, fuel_meta = load_fuel_price()
loss = _loss_records()
sheets: list[dict[str, Any]] = []
@@ -101,11 +100,32 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
continue
record = operating.get(machine_code)
raw = loss.get(machine_code) or {}
if variant == "암석":
# 암석 손료보정(8-1-7 1) — 장도 보정한 상각·정비로 보여야 「계」와 맞음(661 뒤처리).
from B09_Estimation.B09_Estimation_RockLoss import rock_parts
parts = rock_parts(machine_code)
if parts is not None:
keys = ("depreciation", "maintenance", "management", "source")
# 화면 JSON 은 수 — Decimal 을 그대로 실으면 응답이 안 섬
raw = {
**raw,
**{f"{k}_coefficient_1e_minus_7": float(v) for k, v in zip(keys, parts)},
}
loss_per_hour = (
Decimal(str(int(raw["source_coefficient_1e_minus_7"]))) * Decimal("1e-7")
if variant == "암석" and "source_coefficient_1e_minus_7" in raw
else machine.loss_coefficient_per_hour
)
money = build.book.resolve(code)
gaps: list[str] = []
attachment = machine_code.startswith(_ATTACHMENT_PREFIXES)
fuel_liters = getattr(record, "fuel_liters_per_hour", None)
# 연료 종류대로 그 유가(휘발유 기계가 경유값으로 보이던 자리 · 661 뒤 ②).
fuel_price, fuel_meta = (
load_fuel_price(kind=record.fuel_kind) if fuel_liters is not None else (None, {})
)
misc_percent = getattr(record, "misc_material_percent", None)
occupation = getattr(record, "operator_occupation_code", "") or ""
wage = wages.get(occupation)
@@ -136,8 +156,8 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
"management_coefficient": raw.get("management_coefficient_1e_minus_7"),
"loss_coefficient": raw.get("source_coefficient_1e_minus_7"),
"loss_krw_per_hour": _money(
machine.price_thousand_krw * _THOUSAND * machine.loss_coefficient_per_hour
if machine.loss_coefficient_per_hour is not None
machine.price_thousand_krw * _THOUSAND * loss_per_hour
if loss_per_hour is not None
else None
),
# ② 운전경비
@@ -145,7 +165,7 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
"fuel_price_per_liter": _money(fuel_price),
"fuel_scope": fuel_meta.get("region_name") or "전국 공시가",
"misc_material_percent": (
str(COMBINED_MISC_PERCENT) if variant else _money(misc_percent)
str(COMBINED_MISC_PERCENT) if variant == "조합" else _money(misc_percent)
),
"operator_code": occupation,
"operator_daily_wage": _money(wage),
@@ -316,9 +316,14 @@ def write_operating_records(
#: 시도별 유가 판 — 품셈 8-1-7 5호 「유류가격은 **해당지역의 가격**으로 한다」.
#: ⚠ **파일이 있을 때만 지역을 고를 수 있다** — 없으면 전국평균 한 벌로 돈다(코드로 막지 않음).
REGIONAL_OIL_FILE = "oil_regional_2026-09-09.json"
#: 운전경비표 연료 종류 → 유가 판 변수. ⚠ 종류를 안 읽고 경유로 때우면 휘발유 기계가 싸게 섬
#: (2026-09-14 661 뒤 ② — 플레이트 콤팩터·진동기·믹서·래머·커터가 경유값이었음).
FUEL_VARIABLES = {"경유": "oil_diesel", "휘발유": "oil_gasoline"}
def load_regional_fuel_table(oil_file: str = REGIONAL_OIL_FILE) -> tuple[dict[str, dict], dict]:
def load_regional_fuel_table(
oil_file: str = REGIONAL_OIL_FILE, kind: str = "경유"
) -> tuple[dict[str, dict], dict]:
"""(시도코드 → {이름·값}, 판 신원). 판이 없으면 **빈 표**를 돌려준다.
원문에 있는 코드 `00`(전국) **지역 선택지에서 뺀다** 자리는 전국평균 판이
@@ -328,7 +333,7 @@ def load_regional_fuel_table(oil_file: str = REGIONAL_OIL_FILE) -> tuple[dict[st
payload = _read_json(*_CATALOG_SUBPATH, oil_file)
except FileNotFoundError:
return {}, {}
diesel = payload["variables"]["oil_diesel"]
diesel = payload["variables"][FUEL_VARIABLES[kind]]
table = {
str(record["sido_code"]): {
"name": str(record["sido_name"]),
@@ -346,7 +351,7 @@ def load_regional_fuel_table(oil_file: str = REGIONAL_OIL_FILE) -> tuple[dict[st
def load_fuel_price(
oil_file: str = "oil_2026-08-14.json", region: str | None = None
oil_file: str = "oil_2026-08-14.json", region: str | None = None, kind: str = "경유"
) -> tuple[Decimal, dict[str, str]]:
"""경유 단가와 그 판의 신원. `region`(시도코드)을 주면 **그 지역 값**으로 선다.
@@ -355,7 +360,7 @@ def load_fuel_price(
지역이 판에 없으면 **조용히 전국평균으로 눕지 않고** 사실을 신원에 적는다.
"""
payload = _read_json(*_CATALOG_SUBPATH, oil_file)
diesel = payload["variables"]["oil_diesel"]
diesel = payload["variables"][FUEL_VARIABLES[kind]]
meta = {
"dataset_id": payload.get("dataset_id", ""),
"effective_date": payload.get("effective_date", ""),
@@ -366,7 +371,7 @@ def load_fuel_price(
if not region:
return Decimal(str(diesel["value"])), meta
table, region_meta = load_regional_fuel_table()
table, region_meta = load_regional_fuel_table(kind=kind)
picked = table.get(str(region))
if picked is None:
meta["region"] = str(region)
@@ -429,10 +434,12 @@ def hourly_cost_of(machine_code: str, *, region: str | None = None):
if record is None:
return hourly_machine_cost(machine)
fuel_price, _ = load_fuel_price(region=region)
wages = load_operator_wages()
liters = record.fuel_liters_per_hour
fuel_price = (
None if liters is None else load_fuel_price(region=region, kind=record.fuel_kind)[0]
)
if liters is not None and record.misc_material_percent is not None:
# 잡재료는 **주연료의 %** 라 유가와 같이 움직인다(PLAN 8-18 유가 민감분).
liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100))
@@ -170,6 +170,15 @@ def dump_title_code(work_item_code: str, distance_m: Decimal) -> str:
return f"B-{work_item_code}#L{distance_m.normalize():f}m"
def distance_label(distance_m: Decimal) -> str:
"""보이는 운반거리 「L=137.13m」 — 소수 둘째 자리까지(㉰ 2026-09-14 · 가중평균이 14자리로 떴음).
**보이기만** 줄임 호표 열쇠(`dump_title_code`)·운반 식은 받은 거리 그대로 .
"""
shown = Decimal(distance_m).quantize(Decimal("0.01")).normalize()
return f"L={shown:f}m"
def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Decimal, ...]) -> None:
"""거리마다 덤프 운반 일위대가를 세운다 — X(덤프트럭 15ton) → D → B.
@@ -226,7 +235,7 @@ def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Dec
kind=PriceKind.UNIT_PRICE,
name=f"{names.get(DUMP_PARENT) or '덤프운반'} "
f"{names.get(code) or material.label}",
spec=f"L={distance.normalize():f}m",
spec=distance_label(distance),
unit="",
)
)
+3 -1
View File
@@ -186,7 +186,9 @@ def _assemble(build: Any, node: dict[str, Any], names: dict[str, str]) -> None:
return
per_step.append((step, weight, titles))
varying = [item for item in per_step if set(item[2]) - {""}]
if len(varying) > 1:
# 갈래가 같은 이름으로 여러 단계에 걸치면 갈래끼리 합산(유로폼 12-38 = 사용수량 + 설치·해체
# 둘 다 간단·보통·복잡 · 2026-09-14 301). 갈래 벌이 다르면 짝을 못 지어 종전대로 막음.
if len({frozenset(set(ts) - {""}) for _, _, ts in varying}) > 1:
build.component_gaps[parent] = "갈래가 두 단계 이상에 걸쳐 조립하지 않았습니다"
return
units = {build.book.titles[t].unit for _, _, ts in per_step for t in ts.values()} - {""}
@@ -226,6 +226,28 @@ def _tidy_resource_name(cell: str) -> str:
_MACHINE_UNITS = ("시간", "hr", "h", "", "시 간")
#: 조용히 빠지던 기계·연료 줄의 사유(2026-09-15 ㉠ — 인력만으로 싸게 서던 7공종).
SILENT_MACHINE = "기계·연료 줄이 안 붙음 — 이 공종 단가는 일부만 섬(인력만으로 싸게 서지 않게 막음)"
#: 규격이 이름 앞에 온 장비 칸 — 「0.8㎥ 굴착기」.
_RE_SPEC_FIRST = re.compile(r"^\d+(?:\.\d+)?(?:㎥|m3|ton|톤|㎾|kW)\s*[가-힣]{2,}")
#: 연료·기관 줄 이름 — 값이 비었거나 식이라도 기계 몫(연료비 · 휘발유 · 경유 · 엔진 · 기관).
_RE_FUEL_OR_ENGINE = re.compile(r"연료|휘발유|경유|엔진|기관|양수기|발전기|원동기")
_POWER_UNITS = ("kW", "", "HP", "PS", "마력")
def _silent_machine_row(name_cell: str, value_cells: list[str], has_digit: bool) -> bool:
"""값을 못 읽고 이름도 안 풀린 줄이 **기계·연료 몫**인가 — 머리 줄·설명 줄은 아님."""
name = _normalize(name_cell)
if not name or is_non_resource_label(name_cell) and not _RE_FUEL_OR_ENGINE.search(name):
return False
if _RE_FUEL_OR_ENGINE.search(name):
return True
joined = " ".join(value_cells)
return has_digit and (
_is_machine_like_row(value_cells) or any(unit in joined for unit in _POWER_UNITS)
)
def _is_machine_like_row(cells: list[str]) -> bool:
"""그 줄이 **장비 몫**인가 — 단위 칸이 시간·대수인지로 본다."""
for cell in cells:
@@ -480,6 +502,17 @@ def match_table(
reason="자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.",
)
)
elif _silent_machine_row(name_cell, value_cells, has_digit):
# 이름도 값도 못 읽은 기계·연료 줄 — 조용히 넘기면 인력만으로 싸게 섬(2026-09-15 ㉠).
code = node.get("work_item_code", "")
result.partial_items[code] = (
f"{_normalize(name_cell)[:20]} (기계·연료 줄)이 안 붙음"
)
result.unmatched.append(
UnmatchedRow(
code, str(table.get("pum_table_id", "")), name_cell, SILENT_MACHINE
)
)
continue
# ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때
@@ -491,6 +524,17 @@ def match_table(
name_cell = apply_scoped_alias(catalog, name_cell, node["work_item_code"], value_cells)
entry = entry or _resolve_cell(catalog, name_cell, [name_cell, *value_cells])
if entry is None:
if _RE_SPEC_FIRST.match(_normalize(name_cell)) and _is_machine_like_row(value_cells):
# 「0.8㎥ 굴착기 | h | 0.25」 — 규격이 이름 앞이라 머리글로 걸러지던 장비 줄(14-2 · ㉠).
result.partial_items[node["work_item_code"]] = (
f"{_normalize(name_cell)[:20]} (기계·연료 줄)이 안 붙음"
)
result.unmatched.append(
UnmatchedRow(
node["work_item_code"], table["pum_table_id"], name_cell, SILENT_MACHINE
)
)
continue
if is_non_resource_label(name_cell):
continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다
# 「규격 미정 — 후보 N」 · 「같은 이름 여럿」 · 「카탈로그에 없는 이름」을 가른다.
@@ -55,7 +55,113 @@ JUDGED_TABLES: dict[str, dict[str, Any]] = {
"prefix": "합판거푸집",
"why": "원문 L6187 「기준수량(1회사용) · 사용횟수별기준수량에대한 비율(%) 재료별·노무비」",
},
"F0353": {
"code": "FP-12-15",
"shape": "remark_labor",
"prefix": "집수정",
# 구체콘크리트는 바로 아래 다짐기 줄과 한 갈래 — 다짐기가 안 풀리면 갈래를 안 세움(⑴).
"needs_machine": {"구체콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6460 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
# 12-15 와 같은 모양 셋 — 같은 봉상후렉시블 줄 하나가 셋을 막고 있었음(2026-09-14 브레인 · 672 다음).
"F0350": {
"code": "FP-12-12",
"shape": "remark_labor",
"prefix": "날개벽",
"needs_machine": {"콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6419 12-12 「콘크리트(레미콘) ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
"F0351": {
"code": "FP-12-13",
"shape": "remark_labor",
"prefix": "면벽",
"needs_machine": {"콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6436 12-13 「콘크리트(레미콘) ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
"F0354": {
"code": "FP-12-16",
"shape": "remark_labor",
"prefix": "맨홀",
# 칸이 하나 밀려 비고가 끝 칸이 아님(「구체콘크리트 | 철근 | ㎥ | | 비고 | 」).
"needs_machine": {"구체콘크리트": "봉상후렉시블(45mm)"},
"why": "원문 L6474 12-16 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
# 12-34-1 — 머리 「(단위: 개소당)」 이나 인력 0.17·0.29 가 12-16 맨홀 ㎥당과 같고 기계가 Q ㎥/hr
# ⇒ ㎥당으로 읽음 · 「봉상후렉시블 대 2」 는 엔진식 진동기(엔진+플렉시블 한 대)의 봉(2026-09-14 브레인).
"F0385": {
"code": "FP-12-34-01",
"shape": "per_m3_rows",
"prefix": "콘크리트 타설",
# 엔진식 진동기(건설품셈 8-3 (4611) 엔진+플렉시블 한 대)의 봉 — 「진동기(3.5HP) 대 2」 로 셈.
"same_machine": ("봉상후렉시블(45mm)",),
"why": "원문 L6839 12-34-1 「인력 콘크리트공·보통인부 인 · 기계 대 (Q=5.4㎥/hr)」",
},
# 시간 줄 표 — 장비·인력이 다 「h」(2026-09-15 브레인 ㉡-3). 굴착기 형식을 원문이 안 적어 두 갈래 · 제안 없음(①).
"F0450": {
"code": "FP-14-01",
"shape": "hour_rows",
"prefix": "입목뿌리 밑막이",
"forms": (("무한궤도", "0201-0080"), ("타이어", "0211-0080")),
# 원문 「우드그랩 부착」 ↔ 카탈로그 「부착용 집게 0.6∼0.8」 — 이름이 다름(②).
"attachment": "7206-0070",
"why": "원문 L7798 14-1 「0.8㎥ 굴착기(우드그랩 부착) h · 보통인부 h」(10본당)",
},
"F0451": {
"code": "FP-14-02",
"shape": "hour_rows",
"prefix": "근주이식",
"forms": (("무한궤도", "0201-0080"), ("타이어", "0211-0080")),
"why": "원문 L7809 14-2 「0.8㎥ 굴착기 h · 보통인부 h」(10본당)",
},
}
#: 총칙 L530 「본 품셈에서 제시된 품은 일일 작업시간 8시간을 기준」 — 인력 시간 ÷ 8 = 인.
HOURS_PER_DAY = Decimal(8)
_RE_SPEC_FIRST_MACHINE = re.compile(r"^\d+(?:\.\d+)?㎥굴착기")
#: 줄 첫 칸이 분류 딱지인 표(12-34-1 「자재 | 콘크리트(레미콘)」) — 이름은 다음 칸.
_ROW_CATEGORIES = ("자재", "인력", "기계")
#: 자원이 아닌 머리 줄(12-04 「횟수별 | 재료별(%) | 노무비(%)」).
_HEADER_ROWS = ("횟수별", "구분")
UNREAD_REASON = "판정표가 안 읽은 줄 — 이 일위대가에 안 넣음(자동 · 2026-09-14)"
def _loose(text: str) -> str:
"""겹침 비교용 — 빈칸·괄호·가운뎃점·쉼표를 뺌(「적사(굴착기 0.7㎥)」 ↔ 「적사 굴착기 0.7㎥」)."""
return re.sub(r"[\s()·,:]", "", str(text))
def _unread_rows(code, table_id, judged, rows, staged) -> list:
"""읽힌 줄 밖의 줄을 「못 붙은 줄」 로 — 표를 넣을 때마다 손으로 사유를 안 달아도 안 샘.
빼는 : 읽힌 (`raw_row_index`) · 이미 맞춤으로 이름 · 다른 갈래가 기계
(`needs_machine`·`same_machine`) · 자원 머리(`header_row` · 횟수별) ·
사유(`known_gap_note`) 이미 적은 이름은 올림 같은 말이 뜨게
"""
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
read = {item.raw_row_index for item in staged if isinstance(item, ResourceRow)}
taken = {_loose(item.cell) for item in staged if isinstance(item, UnmatchedRow)}
taken |= {_loose(name) for name in judged.get("needs_machine", {}).values()}
taken |= {_loose(name) for name in judged.get("same_machine", ())}
hand = _loose(known_gap_note(code))
unread: list = []
for index, cells in enumerate(rows):
cells = [c for c in cells]
if index in read or not any(cells) or (judged["shape"] == "header_row" and index == 0):
continue
name = cells[1] if cells[0] in _ROW_CATEGORIES and len(cells) > 1 else cells[0]
key = _loose(name)
if not key or key in _HEADER_ROWS or key in taken or key in hand:
continue
if key == "비고":
name = f"비고 — {' '.join(' '.join(cells[1:]).split())[:40]}"
unread.append(UnmatchedRow(code, table_id, " ".join(name.split()), UNREAD_REASON))
taken.add(key)
return unread
#: 비고 칸 인력 — 「콘크리트공0.24인/㎥, 보통인부 0.42인/㎥」.
_RE_REMARK_LABOR = re.compile(r"([가-힣]+)\s*(\d+(?:\.\d+)?)\s*인/㎥")
_RE_Q = re.compile(r"Q\s*=\s*(\d+(?:\.\d+)?)")
#: 비율 줄 — 「1회사용시 2회사용시 …」 칸.
_RE_USE_COUNT = re.compile(r"(\d+)회사용시")
#: 값으로 안 읽는 줄 — 사용고재 평가기준(원문이 셈을 안 줌 · 사유는 `KNOWN_GAPS`) · 비고.
@@ -111,16 +217,24 @@ def match_judged_table(
result.partial_items[code] = why
return True
if basis_quantity in (None, 0):
# 비고·Q 가 ㎥당을 적는 모양은 표 머리 밑수를 안 씀(12-12 날개벽은 「개소당」 머리조차 없음).
if basis_quantity in (None, 0) and judged["shape"] not in ("remark_labor", "per_m3_rows"):
return block("판정 표에 밑수가 없습니다")
if judged["shape"] == "header_row":
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit)
elif judged["shape"] == "use_count":
staged = _use_count(code, table, rows, catalog, basis_quantity, unit)
elif judged["shape"] == "remark_labor":
staged = _remark_labor(code, table, judged, rows, catalog)
elif judged["shape"] == "hour_rows":
staged = _hour_rows(code, table, judged, rows, catalog, basis_quantity)
elif judged["shape"] == "per_m3_rows":
staged = _per_m3_rows(code, table, judged, rows, catalog)
else:
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
if isinstance(staged, str):
return block(f"{staged} — 판정({judged['why']})과 칸이 달라 안 읽음")
staged = [*staged, *_unread_rows(code, table_id, judged, rows, staged)]
for item in staged:
if isinstance(item, UnmatchedRow):
result.unmatched.append(item)
@@ -219,3 +333,105 @@ def _use_count(code, table, rows, catalog, basis, unit) -> list | str:
amount = base * ratio / Decimal(100) / basis
staged.append(_row(code, table, entry, amount, unit, index, f"{count}"))
return staged
def _remark_labor(code, table, judged, rows, catalog) -> list | str:
"""㎥ 줄 비고 칸의 인력(인/㎥)으로 갈래 — 다짐기가 딸린 갈래는 그 기계가 풀려야 세움."""
table_id = str(table.get("pum_table_id", ""))
by_name = {"".join(cells[0].split()): cells for cells in rows if cells}
staged: list = []
for index, cells in enumerate(rows):
labors = _RE_REMARK_LABOR.findall(" ".join(cells[3:])) # 비고가 끝 칸이 아닌 표(12-16)
if len(cells) < 3 or cells[2] != "" or not labors:
continue
variant = cells[0].split("(")[0].strip()
pieces = []
for name, amount in labors:
entry = _entry(catalog, name, code)
if entry is None:
return f"{variant} 인력 「{name}"
pieces.append((entry, Decimal(amount)))
machine_name = judged.get("needs_machine", {}).get(variant)
if machine_name:
machine_cells = by_name.get("".join(machine_name.split())) or []
found_q = _RE_Q.search(" ".join(machine_cells))
entry = _entry(catalog, machine_name, code) if machine_cells else None
if entry is None or found_q is None:
reason = unmatched_reason(catalog, machine_name)
staged.append(UnmatchedRow(code, table_id, machine_name, reason))
why = (
f"다짐기 「{machine_name}」 가 안 풀려 갈래를 안 세움 — 인력만이면 조립 줄이"
" 조용히 싸짐(2026-09-14 ㉯ ⑴)"
)
staged.append(UnmatchedRow(code, table_id, variant, why))
continue
pieces.append((entry, Decimal(1) / Decimal(found_q.group(1))))
for entry, amount in pieces:
staged.append(_row(code, table, entry, amount, "", index, variant))
return staged
def _per_m3_rows(code, table, judged, rows, catalog) -> list | str:
"""「인」 칸 앞 이름 · 뒤 수(인/㎥) · 「대」 칸 앞 이름 · 뒤 대수 ÷ Q — 칸이 밀린 줄도 단위 칸으로 찾음."""
staged: list = []
for index, cells in enumerate(rows):
unit = next((i for i, c in enumerate(cells) if c in ("", "") and i > 0), None)
if unit is None:
continue
name = cells[unit - 1]
if "".join(name.split()) in judged.get("same_machine", {}):
continue # 같은 기계 두 번 안 셈 — 까닭은 공종 사유 한 줄(`KnownGaps`)이 화면에 보임
amount = next((parse_amount(c) for c in cells[unit + 1 :] if parse_amount(c)), None)
entry = _entry(catalog, name, code)
if amount is None or entry is None:
return f"{name}"
if cells[unit] == "":
found_q = _RE_Q.search(" ".join(cells))
if found_q is None:
return f"{name} Q"
amount = amount / Decimal(found_q.group(1))
staged.append(_row(code, table, entry, amount, "", index, ""))
return staged
def _hour_rows(code, table, judged, rows, catalog, basis_quantity) -> list | str:
"""장비·인력이 다 시간인 표 — 장비 h 그대로 · 인력 h ÷ 8 · ÷ 밑수 · 굴착기 형식마다 갈래."""
entries = {entry.code: entry for entry in catalog.entries}
unit = str(table.get("basis_unit") or "")
machine: tuple[int, Decimal] | None = None
labor: list = []
for index, cells in enumerate(rows):
if len(cells) < 3 or "".join(cells[1].split()) != "h":
continue
hours = parse_amount(cells[2])
if hours is None:
return f"{cells[0]} 시간"
if _RE_SPEC_FIRST_MACHINE.match("".join(cells[0].split())):
machine = (index, hours)
continue
entry = _entry(catalog, cells[0], code)
if entry is None:
return f"{cells[0]}"
labor.append((index, entry, hours / HOURS_PER_DAY))
if machine is None:
return "장비 줄"
staged: list = []
for form, machine_code in judged["forms"]:
codes = [machine_code, *([judged["attachment"]] if judged.get("attachment") else [])]
if any(c not in entries for c in codes):
return f"{form} 기종 코드"
for machine_code_ in codes:
staged.append(
_row(
code,
table,
entries[machine_code_],
machine[1] / basis_quantity,
unit,
machine[0],
form,
)
)
for index, entry, amount in labor:
staged.append(_row(code, table, entry, amount / basis_quantity, unit, index, form))
return staged
@@ -46,6 +46,8 @@ def _normalize_label(text: str) -> str:
#: 「계」 열 — **가공 + 조립을 이미 더한 값**이다. 같이 읽으면 두 번 센다(㉤ 열 방향).
_SUM_GROUP_LABELS = ("", "합계", "소계", "총계")
#: 갈래 이름에 적힌 제 밑수 — 「대형헬기 (400ha당)」.
_RE_VARIANT_BASIS = re.compile(r"\(\s*(\d[\d,]*(?:\.\d+)?)\s*(?:ha|㏊|㎡|㎥|m|본|개소)\s*당\s*\)")
def _sum_group_positions(headers: list, resource_count: int) -> set:
@@ -143,6 +145,7 @@ def _match_two_row_table(
unit: str,
ordinal: list,
skip_rows: int,
basis_quantity: Decimal | None = None,
) -> bool:
"""2단 표 — **숫자 칸을 순서대로** 자원에 맞춘다.
@@ -180,9 +183,15 @@ def _match_two_row_table(
)
)
continue
# ⚠ 밑수로 나눔 — 갈래 이름이 「(400ha당)」 처럼 제 밑수를 적으면 그것으로(8-6-1 유인헬기가
# 160배·400배 부풀어 있던 자리 · 2026-09-15). 표 밑수가 한 벌뿐이라 갈래 밑수를 못 가르던 병.
found = _RE_VARIANT_BASIS.search(variant)
divisor = Decimal(found.group(1).replace(",", "")) if found else basis_quantity
for (order, entry, blocked), amount in zip(ordinal, numbers):
if blocked:
continue # 「계」 묶음 — 이미 더한 값이다
if divisor not in (None, 0, Decimal(1)):
amount = amount / divisor
result.rows.append(
ResourceRow(
work_item_code=work_item_code,
@@ -497,7 +506,9 @@ def match_transposed_table(
# 자원 이름이 **둘째 줄**에 오는 2단 표일 수 있다.
ordinal, skip_rows = second_row_columns(table, catalog)
if ordinal:
return _match_two_row_table(node, table, catalog, result, unit, ordinal, skip_rows)
return _match_two_row_table(
node, table, catalog, result, unit, ordinal, skip_rows, basis_quantity
)
if not columns:
return False
+169
View File
@@ -0,0 +1,169 @@
"""B09 원가계산 — **암석 작업 기계손료 보정** (건설품셈 8-1-7 1 · 2026-09-14 브레인 661 ①②).
원문: 다음 건설기계가 암석굴착, 암석적재, 암석운반 등의 가혹한 작업에 사용되는 경우에는
손료(관리비 제외) 다음과 같이 보정 가산한다 불도저(19 이상 제외) 25 · 굴착기(무한궤도)
로더(무한궤도) 20 · 덤프트럭 25 (%) · [] 전용덤프트럭(18 이상) 불도저(19 이상)
보정하지 않는다(타이어·습지 불도저는 보정). 율은 `mach_base` `mach_rock_adj`(원문 파싱) .
암석 손료계수 = (상각 + 정비) × (1 + 가산) + 관리 1e-7 정수 아래 버림
실무 봉화 2024 (암석) 셋이 그대로 역산됨(굴착기 1.0 0.2405 · 덤프 2.5 0.3533 · 덤프 15 0.2679)
거는 자리 공종(자기·부모 이름이나 갈래가 연암·보통암·경암·발파암·파쇄암·암절취·암석)
대상 기계 줄만 기계 호표를 암석 세워 부름(봉화와 같은 모양)
거는 브레이커 조합 본체(`#조합`) — 봉화 「굴삭기 0.7 브레이커조합」 손료가 비암석 23,128 + 브레이커
풍화암·호박돌 섞인 토사 원문 암석작업(연암·보통암·경암)
전석섞인토사 10% 혼입율(0.5 이상 전석 30% 이상) 입력이 없어 판정 ( · 만듦)
"""
from __future__ import annotations
import re
from dataclasses import replace
from decimal import ROUND_FLOOR, Decimal
from functools import lru_cache
from typing import Any
ROCK_SUFFIX = "#암석"
_ROCK_WORDS = re.compile(r"연암|보통암|경암|발파암|파쇄암|암절취|암석")
_E7 = Decimal("1e-7")
NOT_CORRECTED = "8-1-7 [주]① {what} 은 암석 손료보정 안 함"
def _tight(text: Any) -> str:
return re.sub(r"\s", "", str(text or ""))
@lru_cache(maxsize=1)
def _sources() -> tuple[dict[str, dict[str, Any]], dict[str, int]]:
"""(기계 코드 → 손료 성분 레코드, 규칙 이름 → 암석 가산 %)."""
from B09_Estimation.B09_Estimation_MachineCost import _read_json
variables = _read_json("mach_base_2026.json")["variables"]
records = {r["machine_code"]: r for r in variables["mach_loss_coef"]["records"]}
rules = {r["machine_group"]: int(r["rock_work"]) for r in variables["mach_rock_adj"]["rules"]}
return records, rules
def rock_rate(code: str) -> tuple[int | None, str]:
"""(가산 %, 안 거는 까닭) — 표에 없는 기종은 `(None, "")`."""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
machine = load_machine_catalog().machines.get(code)
if machine is None:
return None, ""
_, rules = _sources()
name = _tight(machine.name)
size = re.match(r"\d+(?:\.\d+)?", machine.specification.replace(",", ""))
tons = Decimal(size.group()) if size else Decimal(0)
if name in ("불도저(타이어)", "습지불도저"):
return rules["bulldozer_under_19_ton"], ""
if name == "불도저(무한궤도)":
if tons >= 19:
return None, NOT_CORRECTED.format(what="불도저 19톤 이상")
return rules["bulldozer_under_19_ton"], ""
if name in ("굴착기(무한궤도)", "로더(무한궤도)"):
return rules["crawler_excavator_or_loader"], ""
if name == "덤프트럭":
if tons >= 18:
return None, NOT_CORRECTED.format(what="덤프트럭 18톤 이상")
return rules["dump_truck"], ""
return None, ""
def rock_parts(code: str) -> tuple[Decimal, Decimal, Decimal, int] | None:
"""(보정 상각, 보정 정비, 관리, 계) — 1e-7 단위 · 계는 정수 아래 버림(봉화 3533.75 → 3533)."""
rate, _ = rock_rate(code)
record = _sources()[0].get(code)
if rate is None or record is None:
return None
depreciation, maintenance, management = (
Decimal(str(record[f"{key}_coefficient_1e_minus_7"]))
for key in ("depreciation", "maintenance", "management")
)
factor = 1 + Decimal(rate) / 100
raised = (depreciation * factor, maintenance * factor, management)
return (*raised, int(sum(raised).quantize(Decimal(1), rounding=ROUND_FLOOR)))
def rock_coefficient(code: str) -> Decimal | None:
"""암석 손료계수(원당) = (상각 + 정비) × (1 + 가산) + 관리 — `rock_parts` 의 계 × 1e-7."""
parts = rock_parts(code)
return None if parts is None else parts[3] * _E7
def _rock_hourly(book: Any, code: str) -> str | None:
"""`X-<코드>#암석` — 손료만 보정 계수로 바꾼 호표(연료·조종원·잡품은 본 호표 그대로)."""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle
from B09_Estimation.B09_Estimation_UnitPrice import _slots
hourly, rock = f"X-{code}", f"X-{code}{ROCK_SUFFIX}"
if rock in book.titles:
return rock
coefficient = rock_coefficient(code)
if coefficient is None or hourly not in book.titles:
return None
machine = load_machine_catalog().machines[code]
rate, _ = rock_rate(code)
base, rock_base = f"S-{code}", f"S-{code}{ROCK_SUFFIX}"
plain = book.titles[base]
book.add_title(
replace(
plain, code=rock_base, slots=_slots(machine.price_thousand_krw * 1000 * coefficient)
)
)
title = book.titles[hourly]
book.add_title(
PriceTitle(
code=rock,
kind=PriceKind.MACHINE_HOURLY,
name=title.name,
spec=f"{title.spec} · 암석".strip(" ·"),
unit=title.unit,
)
)
note = f"암석 작업 손료보정 — (상각 + 정비) × {100 + rate}% + 관리 = {coefficient} (건설품셈 8-1-7 1)"
for detail in book.details.get(hourly, []):
ref = {base: rock_base, hourly: rock}.get(detail.ref_code, detail.ref_code)
book.add_detail(
replace(
detail,
parent_code=rock,
ref_code=ref,
note=note if detail.ref_code == base else detail.note,
)
)
return rock
def attach_rock_loss(build: Any, master: dict[str, Any]) -> int:
"""암 공종의 대상 기계 줄을 암석 호표로 바꿔 닮 — 바꾼 줄 수. 조합 16% 바꿔 달기 **뒤**에 부름."""
nodes = {str(n.get("work_item_code")): n for n in master.get("work_items", [])}
book = build.book
changed = 0
for title_code in [code for code in book.titles if code.startswith("B-")]:
work_item, _, variant = title_code[2:].partition("#")
node = nodes.get(work_item) or {}
parent = nodes.get(str(node.get("parent_code"))) or {}
title = book.titles[title_code]
text = " ".join(map(str, (node.get("name"), parent.get("name"), title.name, variant)))
if not _ROCK_WORDS.search(text):
continue
own = book.details.get(title_code) or []
owners = [title_code, *(d.ref_code for d in own if d.ref_code.startswith("D-"))]
for owner in owners:
details = book.details.get(owner) or []
for index, detail in enumerate(details):
ref = detail.ref_code
if not ref.startswith("X-") or "#" in ref:
continue
rate, why = rock_rate(ref[2:])
if rate is None:
if why and why not in detail.note:
details[index] = replace(detail, note=f"{detail.note} · {why}".strip(" ·"))
continue
rock = _rock_hourly(book, ref[2:])
if rock:
details[index] = replace(detail, ref_code=rock)
changed += 1
return changed
@@ -110,7 +110,9 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
from B09_Estimation.B09_Estimation_Transport import BASIS_TEXT as TRANSPORT_BASIS
from B09_Estimation.B09_Estimation_Transport import ROAD_CLASSES as TRANSPORT_ROADS
from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS
from B09_Estimation.B09_Estimation_Transport import TRIPS_NOTE as TRANSPORT_TRIPS_NOTE
from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE as TRANSPORT_CODE
from B09_Estimation.B09_Estimation_Transport import parse_trips, transport_amount
# 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다.
# 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다.
@@ -133,10 +135,19 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
book = prices.book
transport_notes = list(prices.transport_notes)
transport_prices = {}
transport_amounts = {}
# 회수는 품셈이 안 정하는 설계 입력 — 비면 금액을 안 세우고 사유만 남긴다.
transport_trips = parse_trips(settings.get("transport_trips"))
for variant in TRANSPORT_VARIANTS:
code = f"B-{TRANSPORT_CODE}#{variant['key']}"
if code in book.titles:
transport_prices[variant["key"]] = f"{book.resolve(code).total:,.0f}"
unit_price = book.resolve(code).total
transport_prices[variant["key"]] = f"{unit_price:,.0f}"
amount = transport_amount(unit_price, transport_trips)
if amount is not None:
transport_amounts[variant["key"]] = f"{amount:,.0f}"
if transport_prices and transport_trips is None:
transport_notes.append(TRANSPORT_TRIPS_NOTE)
with_material = sum(
1
for unit_code, unit_title in book.titles.items()
@@ -174,6 +185,8 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
"transport": {
"distance_km": str(settings.get("transport_distance_km") or ""),
"road": str(settings.get("transport_road") or ""),
"trips": str(settings.get("transport_trips") or ""),
"trips_note": TRANSPORT_TRIPS_NOTE,
"roads": [
{"key": row["key"], "label": row["label"]} for row in TRANSPORT_ROADS
],
@@ -182,6 +195,7 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
"key": variant["key"],
"label": variant["label"],
"unit_price_krw": transport_prices.get(variant["key"], ""),
"amount_krw": transport_amounts.get(variant["key"], ""),
}
for variant in TRANSPORT_VARIANTS
],
@@ -236,6 +250,7 @@ class FactorChoiceBody(BaseModel):
#: 기계 수송 거리(㎞)·도로 구분 — **비면 수송비 줄이 안 선다.**
transport_distance_km: str | None = None
transport_road: str | None = None
transport_trips: str | None = None
#: 품 할인·할증(1-4) — 계열코드 → 고른 행. **빈 값이면 그 계열을 끄는 것.**
labor_surcharge: dict[str, str] | None = None
@@ -312,6 +327,14 @@ async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONRe
content={"status": "error", "message": f"원문에 없는 도로 구분입니다: {road}"},
)
values["transport_road"] = road
if body.transport_trips is not None:
from B09_Estimation.B09_Estimation_Transport import parse_trips
try:
trips = parse_trips(body.transport_trips)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
values["transport_trips"] = "" if trips is None else str(trips)
if body.labor_surcharge is not None:
from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices
@@ -199,10 +199,22 @@ def safety_management_cost(
variable = dataset.variable("rate_safety_pct")
brackets = variable["brackets"]
# 제3조(적용범위) — 「총공사금액 2천만 원 이상인 공사에 적용」. 하한은 요율 데이터가 든다.
# ⚠ 견주는 값은 **규모 기준액**(설계자가 준 추정가격 · 없으면 수렴한 총원가)이다. 고시의
# 「총공사금액」과 딱 같은 말은 아니나(관급·부가세 자리가 다름) 계산 차례상 안전관리비
# 앞에 설 수 있는 값이 그것뿐이라 같은 축으로 쓴다 — 보건관리자 문턱도 같은 축이다.
if not _threshold_met(
dataset, "rate_safety_pct", "minimum_total_construction_amount_krw", ctx.scale_reference
):
return _ZERO # 대상 아님 — 줄 자체를 만들지 않는다(0 원으로 채우지 않음)
owner_supplied = data.owner_supplied_for_safety_krw
if owner_supplied is None:
owner_supplied = data.owner_supplied_material_krw
if data.owner_supplied_includes_vat:
# 제4조① 단서는 「해당 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다.
# ÷1.1 은 **부가세 제외 환산**이며 근거는 실무다 — 실무 원가계산서 **6건이 모두**
# 「(직노+직재+간재+관급재/1.1) × 율」로 적었다(2026-09-14 골든셋 전수 확인).
owner_supplied = owner_supplied / _VAT_DIVISOR
base_with = ctx.material_cost + ctx.direct_labor_cost + owner_supplied
@@ -255,6 +267,9 @@ def safety_management_cost(
# 1.2배의 대상은 1·2호로 **산정이 끝난 금액**이다. 종전엔 1.2 를 곱한 뒤 한 번만 버려
# 영월 2024 B 줄이 20,330,639 로 원본(20,330,638)보다 1원 컸다(골든셋 실증).
# A(배수 1)는 어느 차례로 해도 같은 값이다.
# ⚠ 안 고른 갈래 — 거창 2025 원본은 `버림(밑수 × 율 × 1.2)` 로 1원 위다. 그 서류는
# 시트 이름·줄 차례가 달라 **STmate 출력이 아니며**, 우리 기준은 STmate 재현이라
# 사유로만 남기고 채택하지 않는다(브레인 판정 2026-09-14).
return floor_won(base * percent / _HUNDRED + flat) * multiplier, percent, flat
raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)")
@@ -0,0 +1,112 @@
"""B09 원가계산 — **표토제거 답외구간** 9-15-2 (2026-09-14 브레인 ㉰ 첫째).
B08 준비공 표토제거 줄이 부르는 코드인데 표가 계수표(T·L·E·q0·e·f·V1·V2·t) 일위대가가 섰음.
q = q0 × e · = L/V1 + L/V2 + t · Q1 = 60 × q × f × E / (/hr) · Q = Q1 / T (/hr)
[] 무한궤도 불도저(19ton) · 건설품셈 8-2-1 불도저 참조 불도저 (`dozer_hourly_output`) 그대로 + T 나눔
기종은 표의 q0·V1·V2(1) 8-2-1 표에서 되짚음(`resolve_dozer`) [] 19ton 맞는지 시험이
실무 영월 호표 Q=576.95 E 자리에 e(0.96) 넣은 원문이 또렷해 원문 E (까닭은 `KnownGaps`).
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
CODE = "FP-09-15-02"
TABLE = "F0282"
_RE_SYMBOL = re.compile(r"^([A-Za-z]\d?)\s*\(")
_RE_GEAR = re.compile(r"(\d+)\s*단")
def _factors(node: dict[str, Any]):
"""(불도저 계수, T) — 칸이 모자라거나 기종이 안 좁혀지면 까닭 글."""
from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import DozerFactors, resolve_dozer
table = next((t for t in node.get("tables") or [] if t.get("pum_table_id") == TABLE), {})
values: dict[str, Decimal] = {}
gear = 1
for row in table.get("raw_row") or []:
found = _RE_SYMBOL.match(str(row[0]).strip()) if row else None
value = parse_measure(str(row[1])) if found and len(row) > 1 else None
if value is not None:
values[found.group(1)] = value
shift = _RE_GEAR.search(str(row[1]))
gear = int(shift.group(1)) if shift else gear
missing = [k for k in ("T", "L", "E", "q0", "e", "f", "V1", "V2") if k not in values]
if missing:
return f"9-15-2 표 칸 없음: {', '.join(missing)}"
machine = resolve_dozer(values["q0"], values["V1"], values["V2"], gear)
if machine is None:
return f"삽날 {values['q0']}㎥ · {values['V1']}/{values['V2']}m/분({gear}단) 으로 불도저가 안 좁혀짐"
factors = DozerFactors(
work_item_code=CODE,
blade_capacity_m3=values["q0"],
distance_factor=values["e"],
volume_factor=values["f"],
efficiency=values["E"],
haul_distance_m=values["L"],
forward_speed_m_min=values["V1"],
reverse_speed_m_min=values["V2"],
machine_code=machine[0],
machine_name=machine[1],
)
return factors, values["T"]
def topsoil_output(node: dict[str, Any] | None = None) -> tuple[Decimal, Decimal]:
"""(Q1 ㎥/hr, Q ㎡/hr) — 둘 다 소수 2자리로 확정한 뒤 씀(명세 7장)."""
from B09_Estimation.B09_Estimation_MachineProductivity import fix2
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import dozer_hourly_output
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
if node is None:
node = next(n for n in load_work_item_master()["work_items"] if n["work_item_code"] == CODE)
found = _factors(node)
if isinstance(found, str):
raise ValueError(found)
factors, thickness = found
q1 = dozer_hourly_output(factors)
return q1, fix2(q1 / thickness)
def attach_topsoil_removal(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None:
"""`B-FP-09-15-02` — 불도저 1/Q hr/㎡ 한 줄(D). 기계 층이 없거나 표가 달라지면 까닭만."""
from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle
node = nodes_by_code.get(CODE)
title_code = f"B-{CODE}"
if node is None or title_code in build.book.titles:
return
found = _factors(node)
if isinstance(found, str):
build.component_gaps[CODE] = found
return
factors, thickness = found
hourly = f"X-{factors.machine_code}"
if hourly not in build.book.titles:
build.component_gaps[CODE] = f"{factors.machine_name} 시간당 사용료가 안 섬"
return
q1, q = topsoil_output(node)
build.book.add_title(
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name=str(node.get("name") or CODE),
spec="표토제거",
unit="",
)
)
build.book.add_output_detail(
title_code,
hourly,
Decimal(1) / q,
f"{factors.formula_text} → Q = Q1 {q1} ÷ T {thickness}m = {q} ㎡/hr"
" (산림품셈 9-15-2 [주]②③ · 건설 8-2-1)",
output=q,
)
if CODE in build.skipped:
build.skipped.remove(CODE)
+30 -2
View File
@@ -24,8 +24,10 @@
**거리는 설계 입력이다** 넣으면 **줄이 선다**(사토장 운반거리와 같은 자리).
임의 거리를 넣으면 금액이 조용히 서므로 **비면 사유만 남긴다.**
**회수( 대를 나르나) 여기서 정한다** 단가는 회당이고, 회수는 수량
(설계 입력)이다. 품셈 대수·회수를 정해 주지 않는다.
**회수( 대를 나르나) 설계 입력이다** (2026-09-14 브레인 판정으로 닫음).
원문 전수 확인 산림품셈 10-4 · 건설품셈 8-1-3 **회당 단가 산출식만** 주고
대수·왕복 횟수를 정하는 공식이 **없다**. 그래서 `transport_trips` 칸을 두고,
비면 금액을 세우고 사유만 남긴다. **회수 × 회당 단가** 곱하기 하나뿐이다.
"""
from __future__ import annotations
@@ -86,6 +88,12 @@ ASSUMPTION_TEXT = (
"⚠ 원문이 안 정해 우리가 정한 둘 — ㉠ 운반시간의 속도는 8-1-6의 2 나 이동속도표에서 가져옴"
"(그 표는 자주식 이동표라 쓰임이 꼭 같지는 않음) · ㉡ 운반시간을 왕복으로 봄."
)
#: 회수 칸 사유 — 화면이 「왜 설계자가 넣나」를 읽는 자리.
TRIPS_NOTE = (
"회수(대수 × 왕복)는 **설계 입력**입니다 — 산림품셈 10-4 · 건설품셈 8-1-3 은 **회당 단가"
" 산출식만** 주고 대수·횟수를 정하는 공식이 원문에 없습니다(2026-09-14 전수 확인)."
" 비워 두면 수송비 금액이 서지 않습니다."
)
def parse_distance_km(raw: Any) -> Decimal | None:
@@ -102,6 +110,26 @@ def parse_distance_km(raw: Any) -> Decimal | None:
return value
def parse_trips(raw: Any) -> Decimal | None:
"""설정 칸의 **회수**(대수 × 왕복). 비거나 0 이면 `None` — 금액이 안 선다.
품셈이 정하는 값이라 **우리가 기본값을 두지 않는다**(1 회로 때우지 않음).
"""
text = str(raw or "").strip().rstrip("").strip()
if not text:
return None
try:
value = Decimal(text)
except (ArithmeticError, ValueError):
raise ValueError(f"수송 회수를 숫자로 못 읽었습니다: {raw!r}") from None
return value if value > 0 else None
def transport_amount(unit_price_krw: Decimal, trips: Decimal | None) -> Decimal | None:
"""수송비 = 회당 단가 × 회수. 회수가 없으면 `None`(0 원으로 안 채운다)."""
return None if trips is None else unit_price_krw * trips
def road_class(key: str | None) -> dict[str, Any] | None:
"""도로 구분. 못 고르면 `None` — **기본 도로를 우리가 정하지 않는다.**"""
return _ROAD_BY_KEY.get(str(key or ""))
@@ -175,6 +175,7 @@ function drawDetail(
}
if (detail.unattached_note) box.append(hint(detail.unattached_note.replace(/\*\*/g, ""), true));
if (detail.known_gap_note) box.append(hint(detail.known_gap_note, true));
if (detail.form_basis_note) box.append(hint(detail.form_basis_note, true));
}
/** 본표 한 장을 `box` 에 — 자취를 쌓고 서버 본표를 받아 그림. */
+15 -9
View File
@@ -58,7 +58,9 @@ interface TransportRow {
distance_km: string;
road: string;
roads: Array<{ key: string; label: string }>;
variants: Array<{ key: string; label: string; unit_price_krw: string }>;
trips: string;
trips_note: string;
variants: Array<{ key: string; label: string; unit_price_krw: string; amount_krw: string }>;
basis: string[];
notes: string[];
}
@@ -105,6 +107,7 @@ export async function saveFactorChoices(
fuel_region?: string;
transport_distance_km?: string;
transport_road?: string;
transport_trips?: string;
labor_surcharge?: Record<string, string>;
},
): Promise<void> {
@@ -251,22 +254,25 @@ export function drawFactorChoices(
body.append(
picker("수송 도로 구분", roadOptions, transport.road, (key) => save({ transport_road: key })),
);
body.append(
percentBox("기계 수송 회수 (대수 × 왕복)", transport.trips, "비움", (text) =>
save({ transport_trips: text }),
),
);
for (const variant of transport.variants) {
body.append(
note(
variant.unit_price_krw
? `${variant.label}회당 ${variant.unit_price_krw}`
: `${variant.label} — 아직 안 섬`,
!variant.unit_price_krw
? `${variant.label}아직 안 섬`
: variant.amount_krw
? `${variant.label} — 회당 ${variant.unit_price_krw}× ${transport.trips}회 = ${variant.amount_krw}`
: `${variant.label} — 회당 ${variant.unit_price_krw}원 (회수를 넣으면 금액이 섭니다)`,
),
);
}
for (const line of transport.notes) body.append(note(`${line}`));
for (const line of transport.basis) body.append(note(line));
body.append(
note(
"⚠ 단가는 「회당」입니다 — 몇 대를 몇 번 나르는지(회수)는 설계 입력이라 여기서 안 정합니다.",
),
);
body.append(note(transport.trips_note));
}
const surcharge = data.labor_surcharge;
@@ -175,6 +175,8 @@ export interface DetailDto {
rows: DetailRowDto[];
unattached_note: string;
known_gap_note: string;
/** 사람이 가른 표 형태 까닭(원문 근거 없는 SW 규칙 · 10-A ⑭). 없으면 빈 문자열. */
form_basis_note?: string;
/** 구성행·Q 식을 고칠 수 있는 본표(B·D)인가 · 줄 더하기에 쓸 다음 칸 이름. */
editable?: boolean;
next_add_key?: string;
@@ -246,6 +246,8 @@ function drawBill(ctx: B09TabContext, bill: BillDto, reload: () => void): void {
"b09s-hint b09s-hint--warn",
`${L("B09_Sheet_Missing")} ${bill.summary.missing.length}${L("B09_Sheet_Count")}`,
),
// 10-A ⑳ 까닭은 그대로 올림(2026-09-14 브레인 규칙) — 규칙을 목록 머리에 드러냄.
hint(L("B09_Sheet_Missing_Passed")),
);
for (const item of bill.summary.missing) {
details.append(hint(`${item.name}${item.reason}`));
+64 -19
View File
@@ -295,28 +295,41 @@ def _add_machine_layers(
"""
catalog = load_machine_catalog()
operating = {r.machine_code: r for r in load_operating_records().records}
fuel_price, fuel_meta = load_fuel_price(region=fuel_region)
# 8-4 칸이 「-」 라 레코드가 없는 이동식 임목파쇄기 93.25 — 8-11 [주]⑤·비고가 유일한 값(2026-09-15).
from B09_Estimation.B09_Estimation_WoodChipping import MACHINE as CHIPPER
from B09_Estimation.B09_Estimation_WoodChipping import operating_record as chipper_record
if CHIPPER in machine_codes and CHIPPER not in operating:
chipper = chipper_record()
if chipper is not None:
operating[CHIPPER] = chipper
wages = load_operator_wages()
incomplete: list[str] = []
fuel_code = f"{FUEL_CODE_PREFIX}경유"
if fuel_code not in book.titles:
book.add_title(
PriceTitle(
code=fuel_code,
kind=PriceKind.MATERIAL,
name="경유",
# ⚠ 어느 판으로 섰는지 **줄에 남긴다** — 지역 값과 전국평균은 리터당
# 수십 원이 갈려 단가가 조용히 달라지는 자리다.
spec=(
f"{fuel_meta.get('region_name')} 공시가"
if fuel_meta.get("region_name")
else "전국 공시가"
),
unit="L",
slots=_slots(fuel_price),
def fuel_title(kind: str) -> str:
"""연료 종류마다 자재 제목 한 벌(`M-FUEL-경유`·`M-FUEL-휘발유`) — 운전경비표 종류대로."""
code = f"{FUEL_CODE_PREFIX}{kind}"
if code not in book.titles:
price, meta = load_fuel_price(region=fuel_region, kind=kind)
book.add_title(
PriceTitle(
code=code,
kind=PriceKind.MATERIAL,
name=kind,
# ⚠ 어느 판으로 섰는지 **줄에 남긴다** — 지역 값과 전국평균은 리터당
# 수십 원이 갈려 단가가 조용히 달라지는 자리다.
spec=(
f"{meta.get('region_name')} 공시가"
if meta.get("region_name")
else "전국 공시가"
),
unit="L",
slots=_slots(price),
)
)
)
return code
fuel_title("경유")
for code in sorted(machine_codes):
machine = catalog.machines.get(code)
@@ -391,6 +404,7 @@ def _add_machine_layers(
liters = record.fuel_liters_per_hour
if liters is not None:
fuel_code = fuel_title(record.fuel_kind)
book.add_detail(PriceDetail(hourly_code, fuel_code, liters, note="주연료"))
if record.misc_material_percent is not None:
# 잡품은 **주연료비 × 율의 가산 행**(명세 7장 · STmate 17번 §3 「잡품 단가 칸 = 연료
@@ -790,6 +804,9 @@ def build_unit_prices(
if dump_haul_m:
machine_codes.add(DUMP_TRUCK_CODE)
from B09_Estimation.B09_Estimation_WoodChipping import MACHINE as CHIPPER
machine_codes.add(CHIPPER) # 8-11 표가 자원 줄로 안 읽혀(Q 칸) 공종 모듈이 부름
build.operator_wage_digits = operator_wage_digits
build.incomplete_machines = _add_machine_layers(
build.book, machine_codes, fuel_region, operator_wage_digits
@@ -893,9 +910,13 @@ def build_unit_prices(
# 예외 = 사용자가 다른 품셈 밑수를 확정한 자리(9-21 「1,000㎡당」 · ÷ 는 ChooseOne 이 검) —
# 마스터 목록은 원문 사실이라 그대로 두고 여기서만 걷음(2026-09-14 브레인 · 사유는 비고).
borrowed = work_item_code.startswith(tuple(BORROWED_BASIS_PER))
from B09_Estimation.B09_Estimation_ResourceAxis_JudgedTable import JUDGED_TABLES
for row in rows:
section = missing_basis.get(str(row.pum_table_id))
if section and not borrowed:
# 비고가 「인/㎥」 로 밑수를 적은 판정표(12-12 날개벽 「개소당」 머리 없음)는 밑수가 ㎥ 로 섬.
per_remark = JUDGED_TABLES.get(str(row.pum_table_id), {}).get("shape") == "remark_labor"
if section and not borrowed and not per_remark:
build.basis_missing[work_item_code] = section
break
@@ -1056,6 +1077,10 @@ def build_unit_prices(
covered += machine_share
if covered < Decimal(100):
build.partial_ratio[work_item_code] = covered
# 유로폼 사용수량(12-38-2) — 임대료 또는 손료 설계자 단가로 섬(2026-09-14 301). 부모 합산 앞.
from B09_Estimation.B09_Estimation_Euroform import attach_euroform
attach_euroform(build, nodes_by_code)
# 단계 합산형 부모(9-4 암절취 = 암파쇄 + 집토) — 잎이 다 선 뒤, 조합 16% 바꿔 달기 전(명세 2장).
from B09_Estimation.B09_Estimation_ParentSteps import attach_parent_steps
@@ -1077,11 +1102,31 @@ def build_unit_prices(
# 덤프 운반 — 운반거리(B08 유토곡선·사토장)마다 한 벌(산림품셈 10-12 「2. 운반」).
attach_dump_hauls(build, master, dump_haul_m)
# 발파 화약류(9-5-1) — 규격 미정이라 설계자 단가가 들면 붙고 아니면 사유(2026-09-14 300).
from B09_Estimation.B09_Estimation_Explosives import attach_explosives
attach_explosives(build, nodes_by_code)
# 표토제거 답외구간(9-15-2) — 계수표를 불도저 식 + T 로(2026-09-14 ㉰).
from B09_Estimation.B09_Estimation_TopsoilRemoval import attach_topsoil_removal
attach_topsoil_removal(build, nodes_by_code)
# 이동식 임목 파쇄(8-11) — 파쇄기 1/Q · 보통인부 2인/8h/Q · 파쇄기날(2026-09-15 ㉰).
from B09_Estimation.B09_Estimation_WoodChipping import attach_wood_chipping
attach_wood_chipping(build, nodes_by_code)
# 2장 소요재료·기계손료 표(체인톱 …) — 인원 줄에 장비 몫을 붙임(2026-09-15 ㉡).
from B09_Estimation.B09_Estimation_Consumables import attach_consumables
attach_consumables(build, nodes_by_code, fuel_region)
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
build.combined_swapped = _apply_combined_misc_rate(
build.book, [code for code in build.book.titles if code.startswith("B-")]
)
# 암석 작업 기계손료 보정(건설품셈 8-1-7 1 · 2026-09-14 661) — 조합 바꿔 달기 뒤(`#조합` 은 안 걺).
from B09_Estimation.B09_Estimation_RockLoss import attach_rock_loss
attach_rock_loss(build, master)
build.labor_reliability = _labor_reliability_in_use(build.book)
# ③ 할증 포함 재료량을 준 공종에 자재가 재료비로 붙지 않았는가(명세 6장 · ㉠).
from B09_Estimation.B09_Estimation_Guards import check_materials_before_surcharge
@@ -174,7 +174,7 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
money = build.book.resolve(code)
# 코드에서 공종을 도로 뽑는다 — 「B-FP-09-11-01#갈래」의 갈래는 떼고 본다.
work_item_code = code[2:].split("#")[0] if code.startswith("B-") else ""
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
from B09_Estimation.B09_Estimation_KnownGaps import form_judgment_note, known_gap_note
unattached = list(build.unattached.get(work_item_code, []))
rows: list[dict] = []
@@ -317,6 +317,8 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
"unattached": unattached,
# ⚠ 원문에는 있는데 못 실린 몫 — 이름을 못 찾은 줄과 **다른 갈래**다.
"known_gap_note": known_gap_note(work_item_code),
# 사람이 가른 표 형태 까닭(SW 규칙 · 10-A ⑭) — 칸 밑 회색 한 줄.
"form_basis_note": form_judgment_note(work_item_code),
"unattached_note": (
f"⚠ 품셈 표에 있는 {len(unattached)}줄이 아직 안 붙었습니다 — "
f"{', '.join(unattached[:4])}"
@@ -0,0 +1,121 @@
"""B09 원가계산 — **이동식 임목 파쇄** 8-11 (2026-09-15 브레인 판정 · ㉰ 둘째).
원문 L4651 : 이동식 임목 파쇄기 93.25KW · Q = 3.5 /hr · 비고 잡재료 : 주연료비의 16% · 소모품비(파쇄기날)
0.00125/hr · [] 1 8시간 1 기계운전자 1·보통인부 2 장비 운반비 별도 우드그랩 별도
연료 10.8 + 16.3 / 2 = 13.5 (디젤).
건설품셈 운전경비표(8-4 L3458) 7205-0125 93.25 줄은 연료·잡품 칸이 - 운전경비 레코드가 없어
기계 층이 서고 일위대가도 조용히 없었음. 8-11 []·비고가 **유일한 **(부딪히는 원문 없음)이라 그대로 .
기계운전자는 기계 조종원(건설기계운전사 · 8-4 잠정 규칙과 같은 직종)으로 번만 .
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
CODE = "FP-08-11"
MACHINE = "7205-0125"
BLADE = "AR-M-249d0a01"
TABLE = "F0237"
#: [주]⑤ — 표 칸이 아니라 [주] 에만 있어 마스터 원문 줄에 안 실림. 원문 그대로 한 곳.
FUEL_LITERS_PER_HOUR = Decimal("13.5")
#: [주]① 1일 8시간 · [주]② 1일 보통인부 2인(기계운전자 1인은 기계 층 조종원).
HOURS_PER_DAY = Decimal(8)
LABORERS_PER_DAY = Decimal(2)
LABORER_CODE = "1002" # 보통인부
BLADE_MISSING = "파쇄기날 — 규격·단가 없음 · 「자재 단가」 탭에서 넣으면 0.00125개/hr ÷ Q 로 붙음"
_RE_Q = re.compile(r"Q\s*=\s*(\d+(?:\.\d+)?)")
_RE_MISC = re.compile(r"주연료비의\s*(\d+(?:\.\d+)?)\s*%")
_RE_BLADE = re.compile(r"(\d+(?:\.\d+)?)\s*개\s*/\s*hr")
def _table_values(node: dict[str, Any]) -> tuple[Decimal, Decimal, Decimal] | None:
"""(Q ㎥/hr, 잡재료 %, 파쇄기날 개/hr) — 표 한 줄에서. 칸이 달라지면 `None`."""
table = next((t for t in node.get("tables") or [] if t.get("pum_table_id") == TABLE), {})
text = " ".join(str(c) for row in table.get("raw_row") or [] for c in row)
found = [pattern.search(text) for pattern in (_RE_Q, _RE_MISC, _RE_BLADE)]
if not all(found):
return None
return tuple(Decimal(m.group(1)) for m in found) # type: ignore[return-value]
def operating_record(node: dict[str, Any] | None = None):
"""7205-0125 운전경비 — 8-11 [주]⑤ 연료 · 비고 잡재료 · [주]② 기계운전자 1인."""
from B09_Estimation.B09_Estimation_MachineOperating import (
_CATALOG_SUBPATH,
OperatingRecord,
_operator_code,
_read_json,
)
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
if node is None:
node = next(n for n in load_work_item_master()["work_items"] if n["work_item_code"] == CODE)
values = _table_values(node)
if values is None:
return None
aliases = _read_json(*_CATALOG_SUBPATH, "labor_const_2026-01-01.json")["variables"]["aliases"]
return OperatingRecord(
machine_code=MACHINE,
machine_name="이동식 임목파쇄기",
specification="93.25",
fuel_liters_per_hour=FUEL_LITERS_PER_HOUR,
fuel_kind="경유",
misc_material_percent=values[1],
operator_person_days=Decimal(1),
operator_occupation_code=_operator_code("이동식 임목파쇄기", aliases),
)
def attach_wood_chipping(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None:
"""`B-FP-08-11` ㎥당 — 파쇄기 1/Q(D) · 보통인부 2인 ÷ 8시간 ÷ Q · 파쇄기날(단가가 들면)."""
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle
node = nodes_by_code.get(CODE)
values = _table_values(node or {})
book = build.book
uses = build.material_uses.setdefault(BLADE, [])
if CODE not in uses:
uses.append(CODE)
if node is None or values is None or f"X-{MACHINE}" not in book.titles:
build.component_gaps[CODE] = "8-11 표 칸이 달라졌거나 파쇄기 기계 층이 안 섬"
return
q, _misc, blade_per_hour = values
title = f"B-{CODE}"
book.add_title(
PriceTitle(code=title, kind=PriceKind.UNIT_PRICE, name=str(node.get("name")), unit="")
)
book.add_output_detail(
title, f"X-{MACHINE}", Decimal(1) / q, f"Q = {q} ㎥/hr (산림품셈 8-11)", output=q
)
book.add_detail(
PriceDetail(
title,
LABORER_CODE,
LABORERS_PER_DAY / HOURS_PER_DAY / q,
note=f"[주]② 1일 보통인부 {LABORERS_PER_DAY}인 ÷ [주]① {HOURS_PER_DAY}시간 ÷ Q {q}",
)
)
# 표의 파쇄기 줄은 이 모듈이 씀 — 자원 축이 「기계·연료 줄이 안 붙음」 으로 올린 것을 걷음.
labels = [
label
for label in build.unattached.get(CODE, [])
if "파쇄기날" not in label and "".join(label.split()) != "이동식임목파쇄기"
]
if "파쇄기" in (build.component_gaps.get(CODE) or ""):
build.component_gaps.pop(CODE)
build.partial_ratio.pop(CODE, None)
if BLADE in book.titles:
book.add_detail(
PriceDetail(
title, BLADE, blade_per_hour / q, note=f"파쇄기날 {blade_per_hour}개/hr ÷ Q"
)
)
else:
labels.append(BLADE_MISSING)
build.unattached[CODE] = labels
if CODE in build.skipped:
build.skipped.remove(CODE)
@@ -20,9 +20,10 @@ WORK_ITEMS: dict[str, dict[str, Any]] = {
"name": "모르타르 배합",
"spec": "1:3",
"unit": "",
# 10-A ③ 문구 못박음(2026-09-14 사용자 확정 — SW 규칙) · 교차 참조 명시(지침 2장).
"basis": (
"건설공사 표준품셈(2026) [건축부문] 9-1-1 모르타르 배합(㎥당)"
"산림 품셈엔 배합 절이 없음 · 배합비 1:3 은 돌쌓기 시방(시멘트:잔골재 부피비) · 실무 울진 대흥(2024)·소광(2025)"
"산림품셈에 배합 절이 없어 건설품셈 [건축] 9-1-1 을 씀(교차 참조)"
"건설공사 표준품셈(2026) [건축부문] 9-1-1 모르타르 배합(㎥당) · 배합비 1:3 은 돌쌓기 시방(시멘트:잔골재 부피비) · 실무 울진 대흥(2024)·소광(2025)"
" 「모르타르배합 1:3」 과 같은 짜임"
),
# (노임 코드, 수량, 칸) — 원문 [주]② 「배합이 포함된 것이며, 비빔은 제외」.
+85
View File
@@ -0,0 +1,85 @@
# PLAN — 데스크탑 서브 (B09 원가계산서)
> **임시 · 2026-09-15 시놀로지 덮어쓰기 때문 · 사용자 확인 전.**
> `PLAN.md` 는 두 PC 가 번갈아 덮어 창마다 제 파일에 적기로 함(브레인 지시).
> 나중에 브레인이 넷을 합쳐 `PLAN.md` 한 벌로 되돌림. `PLAN.md`**읽기만** 함.
>
> ⚠ **사용자 지침을 어기는 자리**임 — 지침은 「`PLAN.md` 는 git 밖」·「한 파일로만 운영」인데
> 이 파일은 **git 안**이고 창마다 따로임. 오늘 밤 계획서를 거듭 잃은 까닭이 **git 밖이라 이력이
> 없어서**라 임시로 이렇게 둠. 사용자가 깨면 「PLAN 을 아예 git 으로 옮길지」를 브레인이 물음 —
> 합치고 나면 이 파일은 지움. `.gitignore` 는 사용자 파일이라 **안 건드림**.
## 닫은 것 — 2026-09-14 ~ 15
- [X] **골든셋을 원가계산서까지 넓힘** (`deac8200`) — 새 `resources/tester/test_b09_golden_cost_sheet.py`.
실무 6건 「공사원가계산서」 시트를 우리 엔진으로 되풂 · **요율은 원본 비고에서 뽑아 그 원본 전용 판**을 지어
「요율이 달라서 틀림」을 없앰 · 제비율 줄마다 **54자리 전수 일치**(안전관리비 A·B·채택 포함) ·
사슬은 온전한 셋(봉화·영월·울진 공통) **33자리 일치** · 이윤은 자동절사가 맞춘 둘(봉화 339·영월 632)과
설계자 조정 하나(울진 905,980) · 영월 도급공사비 차 762,000 = 원본에만 있는 국가지점번호판검증수수료.
검증완료(전체 1782)
- [X] **총액 1,000원 미만 버림을 기본으로 켬 · 절사 진동 고침 · 고정값 벌 이관** (`aeb18266`) —
근거 산림청고시 제2025-82호 「금액의 단위표준」 · 실무 6건이 여섯 다 `000` ·
**조용히 안 깎음**(이윤 조정액 줄·메모에 고시 번호와 「11 에서 끌 수 있음」) ·
끄는 값을 `none` 으로 바꿈(빈 값은 저장이 안 돼 끌 수가 없었음) ·
진동은 걸음마다 ÷1.1 로 고침 · `tmp/tests/test_b09_cost_fixtures.py``resources/tester/` 이관(사본은 안 지움).
검증완료(전체 1782)
- [X] **안전관리비 B 절사 자리를 STmate 실증본 쪽으로 · SA 안전관리비 별도내역 · 고시 대조 벌** (`00919e86`) —
1.2배의 대상을 **호별 산정이 끝난 금액**으로(고시 제4조① 단서 문언 + 영월 2024 B 20,330,639 → **20,330,638**) ·
SA 「확인 대기」 → 별도내역 · 새 `test_b09_safety_notice.py`(별표1 표를 **원문 md 에서 읽어** 16칸 전수 대조).
**두 실증본이 갈림** — 거창 2025(STmate 출력 아님)는 옛 셈이라 B 가 1원 위, 사유로만 남기고 채택 안 함.
검증완료(전체 1911)
- [X] **제3조 적용 하한을 엔진이 읽게 함 · 1.2배 차례와 관급 ÷1.1 사유 명기** (`5a016979`) —
「총공사금액 2천만 원 이상」 미달이면 **줄 자체를 안 만듦**(A·B 곁줄도 · 0 원으로 안 채움) ·
견주는 값은 규모 기준액(고시의 「총공사금액」과 딱 같은 말은 아니나 계산 차례상 그것뿐) ·
관급 ÷1.1 은 고시에 없고 **실무 6건 공통**임을 두 자리에 적음. 검증완료(전체 1952)
- [X] **기계 수송비 회수(대수 × 왕복) 설계 입력 칸** (`1400b7b7`) —
산림품셈 10-4 · 건설품셈 8-1-3 이 **회당 단가 산출식만** 주고 대수·횟수를 안 정함(전수 확인) ·
`parse_trips`(비거나 0 이면 없음 · 기본값 안 둠) · `transport_amount`(곱 하나뿐) · `TRIPS_NOTE` ·
도는 자리 넷(저장·산출조건·화면·못 채운 자리). 검증완료(전체 1976)
- [X] **절사 자동보정을 이분으로 앉힘** (`47196780`) —
이윤 1원이 총공사비 1~2원이라 ÷1.1 어림이 지나쳐 거창 꼴 40 자리 중 16 이 끝자리 999 로 남았음 ·
목표를 못 박고 **가장 작은 보정액을 이분으로** 찾음 · 못 밟는 배수면 한 칸 아래(실측 0건) ·
40/40 앉음 · 깎인 몫 356~423원으로 늘 한 칸 안쪽 · 실무 6건 보정액 그대로. 검증완료(전체 2043)
- [X] **B09 700줄 넘은 모듈 둘 쪼갬** (`e0c5e364`) — **순수 분리**, 그 김에 아무것도 안 고침 ·
`BillOfQuantities.py` **733 → 597** + 새 `_BillOfQuantities_Input.py` 162 ·
`Engine_Cost.py` **725 → 609** + 새 `_Engine_Cost_Input.py` 136 ·
쓰던 이름은 본 모듈이 다시 내보내 그대로 돎. 검증완료(전체 **2102** · 두 번 돌려 같음 · B09 부분 398)
## 재기만 한 것 (고치는 것은 배정 대기)
- [X] **[저장]·[확정] 때 B09 서버 재계산** — 큰 그림은 규칙대로임. **저장물이 없고** 쓰기 열 자리가 전부
**입력만** 저장, 금액은 GET 마다 서버가 다시 계산. 브라우저 금액을 받아 적는 자리 **없음**.
- ⭐ **결함 하나**`GET /estimation/cost-sheet``settings` 가 **엔진이 푼 값**이고 화면이 그대로
되돌려 보내 [저장] 한 번에 **23칸 중 22칸**이 박힘(안 고른 `cut_basis` · `vat_mode` · `duration_days` 까지).
기본값 판정이 바뀌어도 그 프로젝트엔 **안 닿음**. 계약·실행·기성은 `stored` 를 줘서 이 병이 없음.
고치는 법 두 갈래 — ㉠ GET 이 `stored` 를 draft 로 · ㉡ 화면이 기본값과 같은 칸을 안 보냄. **판정 대기**
- ⚠ **[확정]은 아무것도 다시 계산 안 함**(stage 만 COMPLETE) — 「반만 계산해 덮음」 병은 없으나
**확정 시점 값이 안 박힘**. 둘 것인지 박을 것인지 **판정 대기**
- [X] **BOX암거 막힘 셋 가름****정말 막힌 것은 하나뿐**.
- 12-01-01 — 막힘 아님. 호표가 **세 갈래 다 섬**(무근 58,635 · 철근 65,826 · 소형 117,270 원/㎥).
갈래 없이 부르면 제목이 없어 「일위대가 없음」으로 적힌 것
- 12-38 유로폼 — 막힘 아님. 설치·해체는 이미 섬 · 안 선 자리는 `12-38-02` 자재비 하나 ·
「자재 단가」 탭에 **패널+내부 패널(손료)** 또는 **임대료 ㎡당** 중 **하나**만 넣으면 섬
- ⭐ 12-19 강관비계 — 정말 막힘(아래)
- [X] **12-19 강관비계 밑수** — 원문에 **이미 적혀 있었고 못 읽은 쪽은 우리**였음.
- ① 고시 6612줄 `(단위: 공㎥당)` — 파서가 그 줄 꼴은 읽으나 **「공㎥」를 공종 단위로 안 받아**
(`_QUANTITY_UNITS``㎥` 만) `unit_of('FP-12-19')=None` · 원문 전체에서 「공㎥」는 **이 한 절뿐**
- ② **부피 못 냄** — B08 이 비계를 **㎡** 로 보냄(외벽 양면 + 시종점 두 면 · 울진 18.72㎡/m).
동바리는 이미 ㎥ 이고 12-20 도 `(단위: ㎥당)` 이라 **그쪽은 맞음**
- ③ 12-19 [주]는 「공사기간 손율」뿐 **폭 규정 없음** ⇒ 설계 입력 칸 필요
- **갈래 판정 대기** — ㉠ B08 이 공㎥로 보냄 / ㉡ B09 가 「비계 폭(m)」 칸으로 **단가**를 환산
(수량은 B08 정본 그대로 · `BORROWED_BASIS_PER` 꼴)
- 곁 — 12-19·12-20 [주] **「공사기간에 따른 손율」을 지금 안 걸고 있음**(유로폼 사용횟수와 다른 자리)
## 남은 것
- [ ] **`UnitPrice.py` 1403줄** — 700줄 규칙 밖. 쪼개기 **따로 배정 필요**
- [ ] 위 「판정 대기」 넷 — 기준 입력판 기본값 박힘 · 확정 못 박기 · 12-19 갈래 · 12-19/12-20 공사기간 손율
+39
View File
@@ -0,0 +1,39 @@
# PLAN — 랩탑 메인 (1장 `[랩탑_메인]` 몫)
> ⚠ 임시 · 2026-09-15 시놀로지 덮어쓰기 때문 · 사용자 확인 전(브레인 지시). `PLAN.md` 는 읽기만 · 나중에 브레인이 창별 파일을 합쳐 `PLAN.md` 한 벌로 되돌림.
> git 에 명시 커밋으로 올림(PC 사이를 건너가고 이력이 남게 · 사용자 지침 「PLAN.md 는 git 밖 · 한 파일로만」 과 어긋나는 임시 조치).
## 1. 축 세우기 — 랩탑 메인 차례
### 남은 것
- [ ] ⚠ 자원 이름이 겹치면 남의 공종 풀이가 바뀜(명세 2장 이름 맞춤의 부작용) — 자원 목록에 「양수기」 를 넣자 12-26 물푸기 「양수기(150m/m)」 줄 풀이가 바뀌어 제목이 사라짐 → 지금은 「유인헬기방제 양수기」 로 피함(임시) · 언젠가 공종 범위·코드로 맞출 자리
- [ ] ㉯ 나머지 — 제목 없음 32(4-1 수확베기·4-2 부모·6-2 풀베기(예취기)·13-10-2 말뚝박기 등 · 표 읽기에서 먼저 막혀 드러남) · 2장 표 읽는 한 벌(`B09_Estimation_Consumables`)로 더할 자리
- [ ] 막힌 셋 풀기(원문에 값이 없음 · 입력·견적 칸이 서야 함): 12-26 물푸기 가동시간 · 10-7-4 모노레일 ps·임대료·운반거리 · 10-8-3 케이블크레인 ℓ/kWh · 8-6-3 지상방제 1일 작업량·1톤 트럭
- [ ] 손료계수 없는 기계 224종 가르기 — 파서가 놓친 것(크롤러드릴(공기식) 5401 1,548·2,506 · 크레인(무한궤도) 15 등)과 원문에 없는 것이 섞임
- [ ] 초류종자살포 B09 둘 — 트럭 4.5ton 카탈로그 없음(덤프·크레인만) · 종자 규격 미정 후보 넷(화약류 모양 설계자 선택 칸 + 사유)
- [ ] 10-7-4 모노레일 제목의 보통인부 두 번 셈(4인) — 막힌 채 사유만 적음 · 풀리는 날 운전 단가표 2인을 빼고 셀 것
- [ ] 소형 장비 1일 소모 표(㉯) 가운데 2-1-2 경유 표(집재장비) · 천공기 · 예취기 — 쓰는 공종이 풀릴 때 같은 한 벌로
### 검증완료(2026-09-14 밤 ~ 09-15 · 커밋 해시)
- [X] ㉡-3 ㉠ 으로 막은 다섯 표 읽기(브레인 판정 ①②③) — 판정표 「시간 줄」(`hour_rows` · F0450·F0451): 장비 h 그대로 · 인력 h ÷ 8(총칙 L530) · ÷10본 · 굴착기 0.8㎥ 무한궤도·타이어 두 갈래(제안 없음) · 14-1 은 부착용 집게 7206-0070 과 조합(본체 `#조합`) · 사유(이름 다름 · 시간 단위 · 옛 값 8배) · 막힌 셋은 옛 값 까닭 사유(12-26 보통인부 1인뿐 · 10-7-4 같은 사람 두 번 셈 · 10-8-3 특별인부 1인뿐). 옛↔새(본당): 14-2 11,356 → 무한궤도 4,431 · 타이어 4,631 · 14-1 8,603 → 3,192 · 3,316 · 12-26 172,068 · 10-7-4 688,272 · 10-8-3 226,122 은 막힌 채 · 마스터 14-1·14-2 갈래 키만 · 전체 1721 + 392. 검증완료(42e8b4f0)
- [X] 갈래마다 밑수가 다른 표 — `_match_two_row_table` 이 밑수로 안 나눠 8-6-1 유인헬기 160배·400배 부풂 → 갈래 「(Nha당)」 밑수 · 그 길 표 다섯 중 밑수 1 아닌 것 8-6-1 하나 · 전체에서 갈래마다 밑수 다른 표도 8-6-1 하나 · 소형 1,591,855 → 9,949원/ha · 대형 2,780,567 → 6,951. 검증완료(e15e9fc2)
- [X] ㉡-2 유인헬기방제 8-6-1 — `AreaSet`: 양수기 1대1일 호표(휘발유 10ℓ + 잡품 95% + 손료 칸) ÷ 1일 면적 · 깃발 20ha당 1개(칸) · 헬기 임차료(견적 칸) · 소방관서 급수 단서 · 자원 이름 「유인헬기방제 양수기·깃발」 · 소형 +227원/ha · 대형 +90 · 8-6-3 은 막힌 채 사유 둘(1일 작업량 없음 · 15ha 는 연료 설명 / 1톤 트럭 카탈로그 없음). 검증완료(9563d060)
- [X] ㉡-1 체인톱 — `B09_Estimation_Consumables`(2장 표 읽는 한 벌): 체인톱 1대1일 호표 = 휘발유 5.6 + 잡품 40% + 손료 0.0084 × 구입가(칸) · 4-2-2 「벌목부」·6-5 「특별인부 (체인톱 사용)」 인원만큼 · 체인오일 넣은 쪽 · 4-2-2 +31·+41·+53원/㎡ · 6-5 +43,821원/ha · 프로젝트 지장목제거 +548,652(본체 109,122,948 → 109,671,600). 검증완료(852ad6c7)
- [X] ㉠ 말없이 버려지던 기계·연료 줄 — 자원 축 줄 읽기 세 자리(규격 앞 장비 이름 · 값·이름 둘 다 못 읽음 · 수량 빈 연료 줄) → 「못 맞춤 + 일부만」 · 새로 사유 붙은 줄 8 · 새로 막힌 공종 5 · 처음 승인받은 `_names_a_machine` 넓히기는 재어 보니 0 을 막아 정정. 검증완료(2f9d42ac)
- [X] ㉯ 셈 — 소형 장비·연료 이름 나오는 공종 41 · 제목 선 9 모두 기계·연료 줄 0 · 금액까지 서던 7 · 4-2-2 는 부모 표에만 체인톱(제목 선 자식은 그 하나)
- [X] ㉰ 이동식 임목 파쇄 8-11 — 8-4 운전경비 「-」 라 없던 파쇄기 기계 층을 8-11 [주]⑤ 13.5ℓ·잡재료 16%·운전자 1인으로 · ㎥당 = 1/3.5 + 보통인부 2인÷8h÷Q · 파쇄기날 칸 · 51,029원/㎥. 검증완료(0fc64fa7)
- [X] ㉱ 조립 줄이 조각 「일부만」·「밑수 없음」 올림 · `_material_row` 순수 이동(710→658). 검증완료(16d34615)
- [X] ㉰ 표토제거 답외구간 9-15-2 — 불도저 식 Q1 47.92 ÷ T 0.2 = 239.60 ㎡/hr · 618원/㎡ · 영월 호표 E 자리에 e(0.96) 기록(원문 또렷하면 원문) · 프로젝트 +6,151,768(102,971,180 → 109,122,948). 검증완료(ac66a25c)
- [X] 판정표 안 읽은 줄 자동 목록(`_unread_rows`) — 9표 늘어난 줄 0·+1·+4·+8·+6·+7·+1. 검증완료(9deaf479)
- [X] 봉상후렉시블 셋 + 12-34-1 — 판정표 F0350·F0351·F0354·F0385 · 별칭 넷 → 엔진식 4611-0350 · 실무 영월 「콘크리트타설(5-5)」 같은 모양 · 새 제목 다섯. 검증완료(15d91be7)
- [X] 672 품 할증 겹침 — 합산 근거 건설 공통 1-4-2(교차 참조) · 동일성격 단서 화면. 검증완료(8dddc768)
- [X] 진동기 + 연료 종류 — (4611) 원문값 되살림 · 12-15 → 엔진식 · `fuel_kind` 대로 휘발유 · 중기경비 장 `#암석` 뒤처리 · 휘발유 기계 10종 전수 대조. 검증완료(7ea25982)
- [X] 661 암석 손료보정(8-1-7 1) — `B09_Estimation_RockLoss` · 봉화 암석 계수 셋 재현 · 바뀐 제목 17 · E1~E3 원문에 없음 · 착암기 8-3 에도 손료 줄 없음. 검증완료(a8427666)
- [X] 301 유로폼 12-38-2 — `B09_Estimation_Euroform` · 손료 표 수량 곧장 · 임대료 한 줄 · 가드 ③ 에서 12-38-02 뺌 · 봉화 재료비 4,500·3,697 일치. 검증완료(efaf2403)
- [X] 300 발파 9-5-1 — 화약류 자재 단가 칸 · 착암기 부수물 손료 고시 없음 사유. 검증완료(09247fd1)
- [X] ⑭ `form_basis` 화면 — 판정표 까닭 회색 한 줄. 검증완료(c7f34b8c)
### 규칙(오늘 세운 것)
- 원문이 또렷하면 원문 · 애매하면 실무 역산 · 어긋남은 늘 기록
- 버릴 때는 사유를 남길 것(읽는 자리에서 조용히 버리지 않음)
- 빨간 시험은 구현까지 한 호흡에 · autopush 전 git status
+39
View File
@@ -0,0 +1,39 @@
# PLAN — 랩탑 서브
임시 · 2026-09-15 시놀로지 덮어쓰기 때문 · 사용자 확인 전 (브레인 지시 · git 에 명시 커밋 · 브레인이 나중에 PLAN.md 한 벌로 합침 · PLAN.md 는 읽기만)
## 12장 — B08 훑기 고칠 차례 이어 (PLAN.md 12장 「고칠 차례(브레인 2026-09-14)」)
- [X] ㉳ (가) 미교차 측점 지반 샘플을 지표면 끝까지 넓힘(+5m 씩 · 상한 = 지표면 끝) 8bf24c26. 검증완료
- ⚠ 사용자에게 올릴 거리 — 금액 129,498,518 → 154,857,626(+19.6%) · 미교차를 고쳐 성토가 늘어난 것(방향은 맞음)
- [X] ㉳ (나) 성토사면 길이 5m 초과 경고 줄 — 요약 한 줄 + 측점 목록 · 벽 선 쪽만 뺌(좌·우 갈라) · 경고만(브레인 승인 문구) b34eb546. 검증완료
- [X] 브레인 ① 유토 배분 수량은 거르지 않은 계획 · 잔진동 거르기는 그림에만(그림 손질이 수량에 닿지 않게) 20597071. 검증완료
- [X] 브레인 ② B07 횡단도 [확정]은 설계 상태만 올림 — 다시 계산한 값으로 정본을 덮던 것 걷음 · 62측점 전후 같음 41914890. 검증완료
- [X] ㉴ 준비공 상태 칸 「입력이 필요함」과 「값을 낼 근거가 없음」을 가름 · 인계 막힘 갈래도 상태를 따름 364c28a7. 검증완료
- [X] 부대시설 셋째 상태 「품셈 공종 없음 — 단가를 직접 넣어야 함」 · 「자재 단가」 탭 통로 490cfe1d. 검증완료
- 국가지점번호판 개소 — 임도규정 §26⑤ 500m 제안 · 사유에 「임도기술교본 11-6 은 1㎞」 병기 → 12장 D 표지로 묶음
- [X] ㉰㉵ 내역 표시 흠 — 운반 줄 장비 키 · 덤프 거리 14자리 · 규격 갈래 두 번 7dd5dda9. 검증완료
## 12장 D — 표 식 자체 없음 이어 (PLAN.md 12장 「D 표 식 자체 없음」)
- [X] BOX암거·세월교 (브레인 판정 ①~⑥) — ⚠ 교본 그림 3-18~3-20 은 배치 모식도(치수 없음)
- 등록부 두께·연장 칸(예제 벽 0.3 · 판 0.25 · KDS 300㎜ 병기) · m당 × 연장 전개식(산림과임업기술 5장 예제 · 울진 기번3 2×2 집계표) · 철근은 2×2 관측만 · 세월교·날개벽은 근거 없음 사유 a314ac12. 검증완료
- 구체 터파기 — 평균 터파기고 칸 하나 · 토공 축으로만 · 기초잡석 사유 「근거 둘에는 잡석 줄이 없음 — 사용자 확정 3차 ② 로 세움」 e40f4c9f. 검증완료
- 원문끼리 다름(울진은 잔토에 구체 전체 · 되메우기 한쪽) — 옹벽 식(터파기 − 든 것)으로 통일
- 비계 ㎡ → ㎥(공㎥) — 울진 단위 칸 「공/m3」 · 시종점 식 ×1.00 으로 폭 1.0m 역산 · 사유 「비계 폭 1.0m 관측(울진)」 · B08 만 15551b9e
- 남은 막힘(B09 · 데스크탑 서브 몫) — 12-01-01 호표 없음 · 12-19 밑수 공㎥ · 12-38 은 유로폼 패널 단가가 들어가면 섬
- ⚠ 사용자에게 알릴 것 — BOX 두께 「세월교 승계」(2026-08-25 사용자 확정) 걷고 등록부 칸으로 옮김
- [ ] 포장 둘 — 임도기술교본 3-2 p.54(콘크리트 폭 3.0m·두께 0.2m) · 10-1 p.203~207(쇄석 사전터파기 0.10m·다짐후 0.20m 이하) (브레인 승인)
- 판정 ①~⑨ (브레인 2026-09-15) — 원문(교본)이 기준 · 실무는 사유로 병기 · 다른 데서 아는 값은 자동으로 잇고 칸으로 덮어씀
① 폭 — B06 노폭을 따라감이 기본 · 교본 3.0 은 그 아래 제안 · 사유 「실무 관측 3.5~4.0」
② 두께 제안 20㎝(교본) ③ 수축줄눈 간격 제안 6m(교본 부록 4~6m · 소광 관측 6m)
④ 확폭 — B06 곡선 확폭 자동 + 칸 덮어쓰기(실무 「별산」은 표시 방식)
⑤ 비닐·철망 — 고르기(있음/없음) · 기본값 없음 · 안 고르면 사유 · 소광 「철망포함」 관측 사유
⑥ 혼합석 면적 — B06 비포장 측점 자동 + 칸
⑦ 혼합석 두께 — 교본 「다짐 후 0.10」 완성 두께 · 자재는 ÷C×L 환산 · 소광 「다짐전 0.10」 사유 병기
⑧ 쇄석 사전 터파기 0.10 — 토공 축으로만
⑨ 다짐 품 — 원문 확인 먼저 · 없으면 사유(지어내지 않음)
- [ ] 녹화 — 교본 7-1 · 그림 7-8~7-12 「표준도」가 모식도인지 먼저
- [ ] 소단 — 임도 조문(사면길이 2~3m 마다 폭 50~100㎝) · 사방 기준 아님
- [ ] 개소형 — 대피소·작업장·차돌림곳·노출형 횡단수로
- [ ] 부대 — 가드레일·반사경·표지(그림 11-6~11-8 표준도 확인 · 번호판 500m 제안)
+9 -6
View File
@@ -211,9 +211,9 @@ export function pipeDiameterM(diameterMm: number | null | undefined): number {
return round((num(diameterMm, 1000.0) ?? 1000.0) / 1000.0, 3);
}
/** BOX암거 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장. */
export function boxSpanM(innerWidthM: number): number {
return innerWidthM + 2 * FORD_WALL_THICKNESS_M;
/** BOX암거 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장(두께는 등록부 칸 · 2026-09-15). */
export function boxSpanM(innerWidthM: number, wallThicknessM: number): number {
return innerWidthM + 2 * wallThicknessM;
}
/** 날개벽이 만드는 바닥판 연장량 = 길이 × cos(벌어짐각). 안 세우면 0. */
@@ -351,8 +351,11 @@ function boxSet(
const defaults = registry.box_culvert ?? {};
const innerWidth = num(values.body_width_m, num(defaults.body_width_m, 2.0));
const innerHeight = num(values.body_height_m, num(defaults.body_height_m, 2.0));
const wall = FORD_WALL_THICKNESS_M;
const slab = FORD_SLAB_THICKNESS_M;
// 두께는 등록부 칸이 정본(2026-09-15 브레인 12장 D ①) — 파이썬 `_box_set` 과 같은 폴백.
const wallValue = num(values.wall_thickness_m, num(defaults.wall_thickness_m, null));
const slabValue = num(values.slab_thickness_m, num(defaults.slab_thickness_m, null));
const wall = wallValue && wallValue > 0 ? wallValue : FORD_WALL_THICKNESS_M;
const slab = slabValue && slabValue > 0 ? slabValue : FORD_SLAB_THICKNESS_M;
const wingIn = wingSpec(values, defaults, "in");
const wingOut = wingSpec(values, defaults, "out");
return {
@@ -363,7 +366,7 @@ function boxSet(
slab_thickness_m: slab,
top_thickness_m: slab,
cover_m: BOX_COVER_M,
span_m: boxSpanM(innerWidth || 2.0),
span_m: boxSpanM(innerWidth || 2.0, wall),
wing_in: wingIn,
wing_out: wingOut,
defaulted: [

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