107 Commits
Author SHA1 Message Date
eomsangdonandClaude Opus 5 329bdc5c90 chore(plan): 랩탑 메인 — 조종원 직종 어긋남 잼(틀림 12 · 조립에 선 것 2 · 거창 실무 원문과 같음)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 04:36:58 +09:00
eomsangdonandClaude Opus 5 ab8a010985 chore(plan): 랩탑 메인 — ㉯ 제목 없음 가르기(참고 표 19 · 부모 2 · 예취기·분무기 한 모양 4 · 사유 없이 버려지는 표 6)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 04:32:29 +09:00
eomsangdon 30fc514f3f Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-15 04:19:57 +09:00
eomsangdonandClaude Opus 5 cbd3b87ed5 chore(plan): 랩탑 메인 — 초류종자살포 둘 검증완료 · 224종 가르기 결과(미룸) · 조종원 직종 어긋남 새로 봄
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 04:19:56 +09:00
eomsangdonandClaude Opus 5 3d896dec50 feat(b09): 초류종자살포 5-24 둘이 섬 — 트럭 → 덤프트럭 4.5 · 종자살포기 → 취부기 11.94㎾ 손료만(8-4 줄 없음 · 거창 실무) · 종자 후보 넷 칸 · 어긋남 사유 나란히(브레인 판정 ①②′④)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 04:19:23 +09:00
eomsangdon 527c1929cb Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-15 04:13:14 +09:00
eomsangdonandClaude Opus 5 0667274733 chore(plan): 랩탑 서브 계획서 — 콘크리트 포장 완료 · 혼합석 판정 다섯 · 화면 확인 못 한 까닭
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-15 04:13:11 +09:00
eomsangdonandClaude Opus 5 078af37d33 fix(b09): 물탱크(살수차) 운전경비 되살림 — 8-4-8 셀 병합으로 줄째 버려지던 7204 다섯을 원문 값으로 · 살수차 조종원은 8-1-2 5호 화물차운전사(초류종자살포 ③)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 04:11:25 +09:00
eomsangdonandClaude Opus 5 3cfe3ef58a feat(b05·b08): 콘크리트 포장 수량식 — 폭·확폭·두께 B06 잇기 + 칸 덮어쓰기 · 면적×두께 · 거푸집 2L · 수축줄눈 6m · 비닐·철망 고르기(12장 D 포장 둘 · 브레인 판정 ①~⑤·ⓐ)
- 등록부: 두께 칸 필수 걷고 「비면 B06 포장층 두께」 · 폭·확폭 면적 칸(비면 B06) · 줄눈 간격 제안 6m(교본 부록 4-7 4-6m · 소광) · 비닐·철망 고르기(기본값 없음)
- 연장은 구간(끝 − 시작) — 물넘이로 나뉜 조각에 길이 칸이 복사되는 자리
- 묶음 12-06#두께 · 12-08 · 12-07-01(12-7-2 줄눈설치는 안 씀 — 소광 실무가 컷팅)

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 18:27:36 +09:00
eomsangdonandClaude Opus 5 0bb14ce2b3 feat(b08): 라이브러리 공유 — [동료에게 보내기] 같은 회사 동료의 「받음」 단으로 복사(개인 단을 안 덮음 · 보낸 사람 기록 · 출처 공사명 그대로) · 받음 단 항목도 가져오기·복제 가능 · 회사 밖·나에게·내 것 아닌 항목은 막음(브레인 판정 ①)
화면: 복제로 만든 개인 항목 선택 → 동료 목록 받음 → 확인창 「받는 쪽 목록에 받음으로 뜸 · 그 사람 것은 안 덮음」 · 공용 storage 라 취소(동료 폴더 안 생김 확인) · 개인 항목 지워 되돌림 · 쓰기는 임시 storage 시험

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 18:21:24 +09:00
eomsangdon 1550c7caf6 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 18:18:07 +09:00
eomsangdonandClaude Opus 5 99a79e8e0b feat(b08): 라이브러리 관리자 발행 — 프로그램 기본도 storage 작업본(git resources 는 씨앗 · 없는 코드만 · 덮어쓰기 금지) · [회사 라이브러리에 발행](마스터·시스템 관리자) · [프로그램 기본으로 발행](시스템 관리자) — [내 라이브러리에 저장]과 같은 모양 · 권한은 서버가 can_publish 로 내리고 화면은 그대로만 보임
화면(시스템 관리자 계정): 두 단추 보임 · 확인창 「모든 회사가 쓰는 프로그램 기본에 발행 — 같은 종류가 있으면 덮어씀」 · 공용 storage 라 취소로 멈춤(쓰기 없음 확인) · 쓰기는 시험(임시 storage)으로 잼

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 16:59:47 +09:00
eomsangdonandClaude Opus 5 b95ad5e838 feat(b08): 면고르기 성토면 회색 제안 + [제안값 넣기] — 서버 fill_suggested 그대로 · 누른 때만 칸에 들어가고 비워 두면 「안 정함」 · 메인 976e5fd9
화면: 칸 빈 값 · 회색 「제안(비우면 안 정함): 성토면 · 기계 · 무한궤도 — 영월 실무…」 · 단추 누르면 칸만 바뀌고 저장값은 그대로 빈 값(저장 안 함)

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 16:38:48 +09:00
191 changed files with 9415 additions and 1056 deletions
@@ -40,6 +40,8 @@ export interface StructureOptionField {
warn_message?: string | null; warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 칸 이름 옆 「표에 안 쓰임」 + 툴팁 사유. */ /** 원단위 표가 아직 안 읽는 칸 — 칸 이름 옆 「표에 안 쓰임」 + 툴팁 사유. */
not_in_table?: string | null; not_in_table?: string | null;
/** 기본값의 뜻(도메인 확정값이 아닐 때) — 칸 밑 근거 한 줄. */
default_basis?: string | null;
} }
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세 /** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
+111 -2
View File
@@ -240,6 +240,65 @@
"default": 2.0, "default": 2.0,
"required": false "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", "key": "wing_in",
"label": "날개벽(유입)", "label": "날개벽(유입)",
@@ -727,7 +786,7 @@
"input": "number", "input": "number",
"unit": "m", "unit": "m",
"default": 2.0, "default": 2.0,
"default_basis": "제안값 2.0 — 관측 원단위 자료가 반중력식 H=2.0 한 벌뿐이라 그 값(2026-09-14 A4 · 옛 2.5 는 자료 없음)", "default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음",
"required": false, "required": false,
"phase": "b05", "phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
@@ -2473,8 +2532,58 @@
"input": "number", "input": "number",
"unit": "cm", "unit": "cm",
"default": null, "default": null,
"required": true, "required": false,
"phase": "detail",
"empty_means": "비우면 B06 포장층 두께(표준 횡단면 설정 · 기본 0.2m = 임도기술교본 3-2 「두께 0.2m」)를 이음 — 적으면 적은 값이 이김"
},
{
"key": "width_m",
"label": "포장 폭",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 B06 노폭(구간 측점의 차도 표준 폭 · 교본 3-2 「포장 폭 3.0m」 · 실무 관측 3.5~4.0)을 이음 — 적으면 적은 값이 이김"
},
{
"key": "widening_area_m2",
"label": "확폭 면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 B06 곡선부 확폭을 측점 사이 평균 × 거리로 더함(교본 3-2 「곡선부에는 확폭」) — 확폭이 없으면 0 을 적을 것"
},
{
"key": "joint_spacing_m",
"label": "수축줄눈 간격",
"input": "number",
"unit": "m",
"default": 6,
"default_basis": "제안값 · 임도기술교본 부록 4-7 7-1 3.5.1 「수축줄눈의 간격은 4-6m를 기준」 · 실무 소광 6m 관측 · 바꿀 수 있음",
"phase": "detail" "phase": "detail"
},
{
"key": "separation_sheet",
"label": "비닐(깔기·양생)",
"input": "select",
"choices": ["있음", "없음"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 비닐 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「양생 재료비(비닐 등)는 별도 계상」이라 설계자가 고름(기본값 없음)"
},
{
"key": "wire_mesh",
"label": "철망",
"input": "select",
"choices": ["있음", "없음"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 철망 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에 따라」 · 실무 소광 「철망포함」 관측"
} }
] ]
}, },
@@ -361,10 +361,17 @@ export function createFacilityOptionsForm(
emit(); 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) — // 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다. // 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
root.append( root.append(
grid(suggest), grid(suggest),
saveNote,
pipeRow, pipeRow,
wingRow, wingRow,
ford.widthRow, ford.widthRow,
@@ -9,12 +9,23 @@
import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station"; 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"); const wrapper = document.createElement("label");
wrapper.className = "b05-route__field"; wrapper.className = "b05-route__field";
const caption = document.createElement("span"); const caption = document.createElement("span");
caption.textContent = labelText; caption.textContent = labelText;
wrapper.append(caption, input); 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; return wrapper;
} }
@@ -45,6 +56,8 @@ interface OptionShape {
warn_message?: string | null; warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 툴팁이 이 사유를 먼저 보임. */ /** 원단위 표가 아직 안 읽는 칸 — 툴팁이 이 사유를 먼저 보임. */
not_in_table?: string | null; not_in_table?: string | null;
/** 기본값의 뜻 — 칸 밑 근거 한 줄. */
default_basis?: string | null;
} }
/** /**
@@ -332,7 +332,7 @@ export function createStructuresSection(
input.classList.add("is-locked"); input.classList.add("is-locked");
input.title = "지금은 고를 수 없는 항목입니다."; input.title = "지금은 고를 수 없는 항목입니다.";
} }
optionRow.append(field(label, input)); optionRow.append(field(label, input, option.default_basis));
input.addEventListener("change", () => liveCommit()); input.addEventListener("change", () => liveCommit());
optionInputs.push({ optionInputs.push({
key: option.key, key: option.key,
@@ -214,6 +214,18 @@
line-height: 1.5; 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행. 한 행은 [라벨][측점][+거리] 가로 배치 /* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치
* (2026-08-17 사용자 지시 2). */ * (2026-08-17 사용자 지시 2). */
.b05-structure__position-row { .b05-structure__position-row {
+7 -4
View File
@@ -110,8 +110,9 @@ FORD_DEFAULT_WIDTH_M = 10.0
# 지식DB 폭 수치 근거 없음). # 지식DB 폭 수치 근거 없음).
FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0 FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0
# BOX암거 부재 두께(m) — 지식DB에 암거 부재 두께 기준이 없어 세월교 값을 승계한 # BOX암거 부재 두께 — **등록부 칸**(`wall_thickness_m`·`slab_thickness_m`)이 정본이
# (2026-08-25 사용자 확정). 측벽·상판·저판은 각각 벽 두께·바닥판 두께를 그대로 쓴다. # (2026-09-15 브레인 판정 ① · 제안값 = 산림과임업기술(임도) 예제 벽 0.3·판 0.25 · KDS 300㎜ 병기).
# 종전 「세월교 값 승계」(2026-08-25 사용자 확정)는 근거 없는 값이라 걷음 — 수량·그림이 한 칸을 읽음.
# ⚠ 교차 참조 ④ 암거 위 복토(m) — 별표2 교량·암거 "복토 시 흙 두께 50㎝ 이상". # ⚠ 교차 참조 ④ 암거 위 복토(m) — 별표2 교량·암거 "복토 시 흙 두께 50㎝ 이상".
BOX_COVER_M = 0.5 BOX_COVER_M = 0.5
@@ -451,8 +452,10 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
values = dict(options or {}) values = dict(options or {})
inner_width = _number(values.get("body_width_m"), _number(defaults.get("body_width_m"), 2.0)) 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)) inner_height = _number(values.get("body_height_m"), _number(defaults.get("body_height_m"), 2.0))
wall = FORD_WALL_THICKNESS_M wall = _number(values.get("wall_thickness_m"), _number(defaults.get("wall_thickness_m"), None))
slab = FORD_SLAB_THICKNESS_M 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_in = _wing_spec(values, defaults, "in")
wing_out = _wing_spec(values, defaults, "out") wing_out = _wing_spec(values, defaults, "out")
return { 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, structure_spoil_points: input.context?.structure_spoil_points ?? null,
// 잔토는 자연상태로 오고 곡선은 다짐상태다 — 담기 전에 ×C 하는 데 쓴다. // 잔토는 자연상태로 오고 곡선은 다짐상태다 — 담기 전에 ×C 하는 데 쓴다.
conversion: input.context?.earthwork_conversion ?? null, conversion: input.context?.earthwork_conversion ?? null,
// 화면이 그리는 계획 — 잔진동을 거른다(수량은 저장 정본 `haul_plan` 이 따로 낸다).
drawing: true,
}); });
writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null })); writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null }));
process.exit(0); process.exit(0);
@@ -104,18 +106,27 @@ const result = conversion
) )
: null; : null;
// 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06). // 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06).
const plan = result // 두 벌을 남긴다 — `haul_plan` 은 **거르지 않은** 수량 정본(B08), `haul_plan_drawing` 은
? computeHaulPlan(result, input.context?.haul_equipment_limits, { // 잔진동을 거른 그림(B07 토적도). 거르기가 수량에 닿으면 운반이 사라진다(2026-09-14 브레인 ①).
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null, const planFor = (drawing: boolean) =>
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null, result
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null, ? computeHaulPlan(result, input.context?.haul_equipment_limits, {
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null, collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? null, collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
conversion: conversion ?? null, collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
}) structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
: 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 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; : null;
// 선 다단 벽 목록(④) — 관 연장처럼 기하가 세운 결과를 정본에 남겨 B08 이 줄을 세움. // 선 다단 벽 목록(④) — 관 연장처럼 기하가 세운 결과를 정본에 남겨 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 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_node_bundle import run_bundle_json
from common_util.common_util_project_settings import ( from common_util.common_util_project_settings import (
earthwork_conversion_factors,
haul_equipment_limits, haul_equipment_limits,
mixed_conversion_factors,
quantity_settings, quantity_settings,
) )
from common_util.common_util_storage import resolve_stored_project_path 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_db import get_db_pool, run_with_connection
from config.config_system import ( from config.config_system import (
EARTHWORK_CONVERSION_FACTORS, EARTHWORK_CONVERSION_FACTORS,
@@ -96,7 +97,8 @@ async def conversion_factors_for(project_id: Any) -> dict[str, dict[str, float]]
except Exception: except Exception:
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 계수로 진행: project_id=%s", project_id) logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 계수로 진행: project_id=%s", project_id)
return {kind: dict(entry) for kind, entry in EARTHWORK_CONVERSION_FACTORS.items()} 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]]: 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( def _enforce_stored_designs(
longitudinal: dict[str, Any], longitudinal: dict[str, Any],
sections: list[dict[str, Any]], sections: list[dict[str, Any]],
project_root: Path, project_root: Path,
standard: dict[str, Any] | None, standard: dict[str, Any] | None,
sampler: Any = None,
) -> None: ) -> None:
"""저장분 설계를 **쓰는 시점에** 바로잡는다 — 포장 구간·세월교 노면 하강. """저장분 설계를 **쓰는 시점에** 바로잡는다 — 포장 구간·세월교 노면 하강.
예전에는 상세를 **읽을 때마다** 돌려 화면이 볼 때만 맞았다(저장분은 낡은 채로). 예전에는 상세를 **읽을 때마다** 돌려 화면이 볼 때만 맞았다(저장분은 낡은 채로).
2026-09-06 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다. 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_Engine_SpoilFill import enforce_spoil_fills
from B06_Section.B06_Section_Router_Design import ( from B06_Section.B06_Section_Router_Design import (
enforce_ford_surface_drops, enforce_ford_surface_drops,
enforce_pavement_ranges, enforce_pavement_ranges,
) )
# ⚠ 미교차 샘플 넓힘이 **맨 앞**이다 — 뒤의 보정들이 넓힌 지반 위에서 다시 계산해야 한다(㉳ (가)).
extend_unclosed_sections(longitudinal, sections, project_root, standard, sampler)
enforce_pavement_ranges(longitudinal, sections, project_root, standard) enforce_pavement_ranges(longitudinal, sections, project_root, standard)
enforce_ford_surface_drops(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() pool = get_db_pool()
# 구조물 몫(채집석 공제·구조물 잔토)도 함께 받아 온다 — **B08 이 낸 값**이고, 안 넘기면 # 구조물 몫(채집석 공제·구조물 잔토)도 함께 받아 온다 — **B08 이 낸 값**이고, 안 넘기면
# 통로만 있고 값이 안 흐른다(2026-09-09 실측: 공제가 늘 `None` 이라 사토가 안 줄었다). # 통로만 있고 값이 안 흐른다(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), get_section_detail(project_uuid, route_id),
run_with_connection(get_project_storage_relative_path, project_uuid), run_with_connection(get_project_storage_relative_path, project_uuid),
run_with_connection(get_longitudinal_section, project_uuid, route_id), run_with_connection(get_longitudinal_section, project_uuid, route_id),
haul_inputs_for(project_uuid), haul_inputs_for(project_uuid),
run_with_connection(get_surface_confirmation_params, str(project_uuid)),
) )
marks.append(("종횡단 상세+DB 조회(병렬)", time.perf_counter())) marks.append(("종횡단 상세+DB 조회(병렬)", time.perf_counter()))
payload = getattr(response, "model_dump", None) 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) standard = stored_standard_cross_section(longitudinal_row)
# 포장 구간·세월교 보정 — 고쳐진 설계 위에서 면적·유토곡선이 나와야 한다. # 포장 구간·세월교 보정 — 고쳐진 설계 위에서 면적·유토곡선이 나와야 한다.
before = [json.dumps(item.get("design"), sort_keys=True, default=str) for item in sections] 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( 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 = [ fixed = [
item item
@@ -235,7 +266,7 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
"detail": detail, "detail": detail,
"context": _mass_haul_context( "context": _mass_haul_context(
haul_inputs, haul_inputs,
earthwork_conversion_factors(quantity_settings(project_root)), mixed_conversion_factors(quantity_settings(project_root)),
haul_equipment_limits(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`과 같은 식으로 다시 잡는다. // 캐시 먼저 — 구체 길이(`span_m`)는 백엔드 `_box_set`과 같은 식으로 다시 잡는다.
if (patch.body_width_m) { if (patch.body_width_m) {
spec.inner_width_m = 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; 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 { import type {
CrossSection, CrossSection,
EarthworkConversion, EarthworkConversion,
HaulEquipmentLimit,
SectionDetailResponse, SectionDetailResponse,
} from "./B06_Section_Api_Fetch"; } from "./B06_Section_Api_Fetch";
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
@@ -81,53 +80,10 @@ import {
inferStationInterval, inferStationInterval,
L, L,
} from "./B06_Section_UI_Section_Common"; } 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 }; export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl, SectionViewController };
/**
* ·· · ****.
*
* `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 function createSectionView( export function createSectionView(
onDesignChange?: DesignChangeHandler, onDesignChange?: DesignChangeHandler,
@@ -236,6 +192,10 @@ export function createSectionView(
}, },
}); });
panel.append(panelResizer.root); panel.append(panelResizer.root);
// 성토사면 5m 초과 경고 줄(2026-09-14 브레인 (나)) — 패널과 카드 사이 · 측점 누르면 그 카드로.
const fillSlopeNotice = createFillSlopeNotice((id) =>
selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true),
);
/** /**
* **** (2026-08-02 ). * **** (2026-08-02 ).
@@ -489,6 +449,7 @@ export function createSectionView(
// 보던 자리가 맨 앞으로 튀면 못 쓴다. 위치를 잡아 뒀다 되돌린다. // 보던 자리가 맨 앞으로 튀면 못 쓴다. 위치를 잡아 뒀다 되돌린다.
const keepScrollLeft = chartWrap.scrollLeft; const keepScrollLeft = chartWrap.scrollLeft;
const detail = currentDetail; const detail = currentDetail;
fillSlopeNotice.update(detail.cross_sections, cachedStationInterval); // 카드 갱신도 여기를 거침
// 그래프 몫은 **추정하지 않고 잰다**. `chartWrap`은 `flex: 1 / min-height: 0`이라 높이가 // 그래프 몫은 **추정하지 않고 잰다**. `chartWrap`은 `flex: 1 / min-height: 0`이라 높이가
// 내용이 아니라 패널에서 정해지므로, 재서 쓰면 되먹임 없이 한 번에 수렴한다. // 내용이 아니라 패널에서 정해지므로, 재서 쓰면 되먹임 없이 한 번에 수렴한다.
// 화면에 붙기 전(detached)에는 잴 수 없으니 그때만 `PANEL_CHROME_PX` 추정치로 시작한다. // 화면에 붙기 전(detached)에는 잴 수 없으니 그때만 `PANEL_CHROME_PX` 추정치로 시작한다.
@@ -599,7 +560,7 @@ export function createSectionView(
} }
// panel은 이미 root의 자식이라 replaceChildren이 떼었다 붙이면서 스크롤을 잃는다. // panel은 이미 root의 자식이라 replaceChildren이 떼었다 붙이면서 스크롤을 잃는다.
const keepScrollLeft = chartWrap.scrollLeft; const keepScrollLeft = chartWrap.scrollLeft;
root.replaceChildren(panel, grid); root.replaceChildren(panel, fillSlopeNotice.root, grid);
chartWrap.scrollLeft = keepScrollLeft; 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); 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 { .b06-cross-card {
cursor: pointer; cursor: pointer;
transition: transition:
@@ -520,7 +520,9 @@ def build_mass_haul_drawing(
if curve_entity: if curve_entity:
entities.append(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): if isinstance(plan, dict):
entities.extend(_band_entities(drawing_id, plan, curve, mm_h)) entities.extend(_band_entities(drawing_id, plan, curve, mm_h))
entities.extend(_residual_entities(drawing_id, plan, curve, interval_m, 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, _invalidate_drawing,
_read_drawing, _read_drawing,
_read_json, _read_json,
_recompute_confirmed_design,
_store_confirmed_drawing, _store_confirmed_drawing,
landuse_source, landuse_source,
lidar_source, lidar_source,
@@ -405,7 +404,7 @@ async def confirm_design_drawing(
) -> DesignDrawingConfirmResponse | JSONResponse: ) -> DesignDrawingConfirmResponse | JSONResponse:
"""현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다. """현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다.
횡단도 확정 B06 지정 잠정치를 동일 엔진으로 재계산해 확정치로 승격·저장한다. 횡단도 확정 담긴 측점 설계의 **상태만** 확정으로 올린다(값은 B06 정본 그대로).
""" """
try: try:
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id) 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, quantity_tables or None,
) )
# 횡단도면이면 확정 단면적을 재계산한다 (재계산 실패는 도면 확정을 막지 않음). # 횡단도면이면 담긴 측점 설계를 확정으로 올린다 — **상태만** 바꾼다. 장은 담긴 측점 전부.
# 장은 담긴 측점 전부를 함께 확정한다. # B07 CAD 에는 설계를 고치는 자리가 없어 덮을 값이 없다. 예전에는 입력 셋(지반·단면·측구
recomputed: list[tuple[int, dict[str, Any]]] = [] # 쪽)만으로 단면적을 다시 계산해 설계를 통째로 덮어, 암선·절토경사·표준 횡단·구조물
# 트림과 사용자 입력(측구 끔)이 사라졌다(2026-09-14 936be972 실측 62측점 · 브레인 ②).
cross_match = _CROSS_ID.fullmatch(drawing_id) cross_match = _CROSS_ID.fullmatch(drawing_id)
pool = get_db_pool() pool = get_db_pool()
targets: list[int] = [] targets: list[int] = []
@@ -454,39 +454,21 @@ async def confirm_design_drawing(
targets = list(sheet["chainages"]) targets = list(sheet["chainages"])
elif item.kind == "cross" and cross_match: elif item.kind == "cross" and cross_match:
targets = [int(cross_match.group(1))] targets = [int(cross_match.group(1))]
for chainage_int in targets: confirmed_designs = [
designation = designs.get(chainage_int) (chainage_int, {**designs[chainage_int], "status": "confirmed"})
if not designation: for chainage_int in targets
continue if designs.get(chainage_int)
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,
)
async with pool.acquire() as connection: async with pool.acquire() as connection:
await connection.begin() await connection.begin()
try: try:
for chainage_int, confirmed_design in recomputed: for chainage_int, _design in confirmed_designs:
await merge_cross_section_design_by_round( await merge_cross_section_design_by_round(
connection, connection,
route_id=route_id, route_id=route_id,
chainage_int=chainage_int, chainage_int=chainage_int,
patch=confirmed_design, patch={"status": "confirmed"},
) )
async with connection.cursor() as cursor: async with connection.cursor() as cursor:
if all_confirmed: if all_confirmed:
@@ -502,7 +484,7 @@ async def confirm_design_drawing(
id=drawing_id, id=drawing_id,
confirmed=True, confirmed=True,
all_confirmed=all_confirmed, 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: except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(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: if entry:
entry["confirmed"] = False entry["confirmed"] = False
_write_manifest(project_root, manifest) _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 id: str
confirmed: bool confirmed: bool
all_confirmed: bool all_confirmed: bool
# 횡단도 확정 시 재계산된 확정 설계(status=confirmed). 종단도·재계산 불가 시 None. # 횡단도 확정 시 저장된 설계(상태만 status=confirmed). 종단도·장·설계 없음이면 None.
design: dict[str, Any] | None = None design: dict[str, Any] | None = None
+1 -1
View File
@@ -418,7 +418,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
// **기다린다**: 안 기다리면 오버레이가 먼저 걷혀, 버튼은 [수정]인데 CAD는 아직 // **기다린다**: 안 기다리면 오버레이가 먼저 걷혀, 버튼은 [수정]인데 CAD는 아직
// 편집이 열린 어긋난 순간이 생긴다. // 편집이 열린 어긋난 순간이 생긴다.
await loadDrawing(currentDrawing, currentIndex); await loadDrawing(currentDrawing, currentIndex);
// 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다. // 확정한 설계(B06 정본 그대로 · 상태만 확정)로 지반/계획 정보 패널을 갱신한다.
if (currentDrawing.kind === "cross") { if (currentDrawing.kind === "cross") {
infoPanelHost.replaceChildren( infoPanelHost.replaceChildren(
buildDesignInfoPanel( buildDesignInfoPanel(
@@ -181,7 +181,7 @@ function infoRow(label: string, value: string): HTMLElement {
} }
/** /**
* / 2 (, B07 ). * / 2 (B06 · B07 ).
* *
* **( ) ** `design` * **( ) ** `design`
* (2026-09-03 ). * (2026-09-03 ).
@@ -648,8 +648,10 @@ def build() -> dict[str, Any]:
basis_found += 1 basis_found += 1
if basis_quantity_is_grouped(basis_qty): if basis_quantity_is_grouped(basis_qty):
basis_grouped += 1 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( basis_missing.append(
{ {
"pum_table_id": table["table_id"], "pum_table_id": table["table_id"],
@@ -110,6 +110,11 @@ FORM_JUDGMENTS: dict[str, tuple[str, str, str]] = {
# 절 제목으로 읽어 12-2 에 붙어 있던 표(빌더가 앞 절 이어받기로 고침). 형태 판정은 그대로. # 절 제목으로 읽어 12-2 에 붙어 있던 표(빌더가 앞 절 이어받기로 고침). 형태 판정은 그대로.
"F0358": ("12-17-1", "reference", "시설유형 Type-Ⅰ~Ⅳ 적용 기준 설명"), "F0358": ("12-17-1", "reference", "시설유형 Type-Ⅰ~Ⅳ 적용 기준 설명"),
"F0360": ("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」 항목만 — 값이 없는 구성 안내"), "F0388": ("12-34-4", "reference", "「재료비 JOINT FILLER」 항목만 — 값이 없는 구성 안내"),
"F0390": ("12-36", "reference", "제작비·운송비·설치비 「견적처리」"), "F0390": ("12-36", "reference", "제작비·운송비·설치비 「견적처리」"),
"F0392": ("12-38-1", "coefficient", "사용횟수별 잔존율(12회 25%·25회 10%)"), "F0392": ("12-38-1", "coefficient", "사용횟수별 잔존율(12회 25%·25회 10%)"),
@@ -11,7 +11,7 @@
가른 표는 **빈칸** 자원을 맞춘 · 계수 · 참조 · 미판정 표에 갈래를 지어내지 않는다. 가른 표는 **빈칸** 자원을 맞춘 · 계수 · 참조 · 미판정 표에 갈래를 지어내지 않는다.
공종마다 `variant_keys` 표들의 갈래 + 원문이 정했으나 자원 줄로 서는 갈래 공종마다 `variant_keys` 표들의 갈래 + 원문이 정했으나 자원 줄로 서는 갈래
(불도저 운반 공식 갈래 · 합산형 단계 잎의 암질 · 9-4-1 [] 평균) · 합산형 부모는 단계 갈래. (불도저 운반 공식 갈래 · 합산형 단계 잎의 암질 · 9-4-1 [] 평균 · 유로폼 12-38-2 부자재 요율 머리) · 합산형 부모는 단계 갈래.
""" """
from __future__ import annotations 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: def attach_variant_keys(nodes: list[dict[str, Any]], edition: str) -> None:
"""표마다 `variant_key`, 공종마다 `variant_keys` 를 채운다(자리에서).""" """표마다 `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_MachineProductivity_Dozer import extract_dozer_factors
from B09_Estimation.B09_Estimation_ParentSteps import AVERAGE_BASIS, AVERAGE_VARIANT, rock_rows from B09_Estimation.B09_Estimation_ParentSteps import AVERAGE_BASIS, AVERAGE_VARIANT, rock_rows
from B09_Estimation.B09_Estimation_ResourceAxis import build_resource_axis from B09_Estimation.B09_Estimation_ResourceAxis import build_resource_axis
@@ -62,6 +65,9 @@ def attach_variant_keys(nodes: list[dict[str, Any]], edition: str) -> None:
if code in steps: if code in steps:
for rock, _, _ in rock_rows({"tables": [table]}): for rock, _, _ in rock_rows({"tables": [table]}):
_add(labels, table_seen, rock, normalize) _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 table["variant_key"] = labels
for label in labels: for label in labels:
_add(keys, seen, label, normalize) _add(keys, seen, label, normalize)
@@ -52,6 +52,17 @@ FACE_DRESSING_FILL_SUGGESTED = (
"영월 실무 설계내역 「성토사면고르기 06M3 B/H」(백호 = 무한궤도 굴착기) · 임도는 산지라" "영월 실무 설계내역 「성토사면고르기 06M3 B/H」(백호 = 무한궤도 굴착기) · 임도는 산지라"
" 타이어식이 잘 안 들어감 — 타이어식도 고를 수 있음(2026-09-14 브레인 ②)", " 타이어식이 잘 안 들어감 — 타이어식도 고를 수 있음(2026-09-14 브레인 ②)",
) )
#: 초류종자살포 비탈면 토질 — 품셈 5-24 씨앗뿜어붙이기의 잎 둘(5-24-1 일반 · 5-24-2 마사토) 이름 그대로.
#: 매핑 `leaf_from` 이 잎 코드로 잇고 비면 금액 없이 사유 · 제안값 없음(2026-09-14 브레인 ㉮).
SEED_SPRAY_GROUNDS = ("일반", "마사토")
#: 지장목제거 뿌리뽑기(9-21 제근) 굴착기 크기 — 품셈 9-21 표 갈래 0.2·0.7(무한궤도). 등급과 한 갈래.
#: ⚠ 제안값은 칸 곁에만(스스로 안 고름 · 비면 금액 없이 사유 — 2026-09-14 브레인 판정).
ROOT_REMOVAL_EXCAVATOR_SIZES = ("0.2", "0.7")
ROOT_REMOVAL_EXCAVATOR_SUGGESTED = (
"0.7",
"산림품셈 10-12-1 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — 같은 고시가 임도 표준"
" 장비를 0.7 로 적은 자리 · 0.2㎥ 도 고를 수 있음",
)
#: ⭐ 2026-09-14 브레인 판정 Ⓐ — 밑수 = 초류종자살포(파종) 면적. 반영률 칸을 따로 두면 같은 값을 #: ⭐ 2026-09-14 브레인 판정 Ⓐ — 밑수 = 초류종자살포(파종) 면적. 반영률 칸을 따로 두면 같은 값을
#: 두 곳에서 관리하게 됨. 실무도 그렇게 움직임(거창 파종 성토 50%·절토 100% · 영월 성토 50%). #: 두 곳에서 관리하게 됨. 실무도 그렇게 움직임(거창 파종 성토 50%·절토 100% · 영월 성토 50%).
FACE_DRESSING_NOTE = ( FACE_DRESSING_NOTE = (
@@ -108,6 +119,17 @@ class SummaryInput:
subgrade_compaction_enabled: bool = False subgrade_compaction_enabled: bool = False
# 면고르기 면적 덮어쓰기 `{fill, cut}`(㎡) — 비우면 파종 면적(판정 Ⓐ). # 면고르기 면적 덮어쓰기 `{fill, cut}`(㎡) — 비우면 파종 면적(판정 Ⓐ).
face_dressing_area_m2: dict[str, float | None] = field(default_factory=dict) 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 판정 「별도 줄 · 칸으로 켜고 끔 · 기본 꺼짐」. #: 노체다짐 줄 — ⭐ 2026-09-13 판정 「별도 줄 · 칸으로 켜고 끔 · 기본 꺼짐」.
@@ -180,6 +202,16 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
) )
) )
if source.borrow_m3 > 0:
sites = " · ".join(
f"{float(s['from_m']):g}~{float(s['to_m']):g}m {float(s['volume_m3']):,.2f}"
for s in source.borrow_sites
)
note = f"{BORROW_REASON} · 구간 {sites}" if sites else BORROW_REASON
rows.append(
SummaryRow(group=BORROW_NAME, amount=source.borrow_m3, note=note, in_bill=False)
)
# ── 운반 — 수단별. 무대는 집계에 오르되 내역 줄이 아니다 ──────── # ── 운반 — 수단별. 무대는 집계에 오르되 내역 줄이 아니다 ────────
rows.extend(_haul_rows(source)) rows.extend(_haul_rows(source))
@@ -365,7 +397,7 @@ def _haul_rows(source: SummaryInput) -> list[SummaryRow]:
for item in source.haul_rows: for item in source.haul_rows:
key = str(item.get("equipment") or "") key = str(item.get("equipment") or "")
label = HAUL_LABELS.get(key, key 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") distance = item.get("average_distance_m")
note = f"평균운반거리 {float(distance):.2f} m" if isinstance(distance, (int, float)) else "" 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 component["reuse_note"] = NOTE_REUSE_MISSING
continue continue
count = entry.get("reuse_count") 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: for component in targets:
component["reuse_count"] = count component["reuse_count"] = count
component["reuse_note"] = ( component["reuse_note"] = (
f"품셈 1-7-1 {count}회 — 「{entry.get('matched_example')}" f"{basis} {count}회 — 「{entry.get('matched_example')}"
if count if count
else NOTE_NOT_APPLICABLE else NOTE_NOT_APPLICABLE
) )
if count: 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: else:
missing.append(type_id) missing.append(type_id)
return notes, sorted(set(missing)) return notes, sorted(set(missing))
@@ -115,6 +115,8 @@ def build_handoff(
stand_volume_class: str | None = None, stand_volume_class: str | None = None,
face_dressing_cut_class: str | None = None, face_dressing_cut_class: str | None = None,
face_dressing_fill_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, priced_sheets: list[dict[str, Any]] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**. """B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.
@@ -134,6 +136,10 @@ def build_handoff(
"stand_volume_class": stand_volume_class, "stand_volume_class": stand_volume_class,
"face_dressing_cut_class": face_dressing_cut_class, "face_dressing_cut_class": face_dressing_cut_class,
"face_dressing_fill_class": face_dressing_fill_class, "face_dressing_fill_class": face_dressing_fill_class,
# 제근 굴착기 크기 — 등급과 「크기·등급」 한 갈래로(매핑 `variant_template` · 09-14).
"root_removal_excavator_m3": root_removal_excavator_m3,
# 초류종자살포 비탈면 토질 — 부모 5-24 의 잎(일반·마사토)을 고름(매핑 `leaf_from` · 09-14 ㉮).
"seed_spray_ground": seed_spray_ground,
} }
rows, misses = _earthwork_rows( rows, misses = _earthwork_rows(
summary_table, table, methods, bench_cut_depth_m, variant_inputs 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 "") type_id = str(structure.get("type_id") or "")
entry = mapping.for_structure(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]]] = [] found: list[tuple[str, set[str]]] = []
if entry.get("billing_component"): if entry.get("billing_component"):
found.append(("구조물 줄", {str(entry["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 []: for part in composite.get("parts") or []:
if isinstance(part, dict): if isinstance(part, dict):
label = f"묶음 조각 「{part.get('name') or part.get('code')}" label = f"묶음 조각 「{part.get('name') or part.get('code')}"
@@ -146,10 +146,16 @@ BLOCKED_UNCONFIRMED = "unconfirmed"
#: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다 #: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다
#: (지장목제거의 「뿌리뽑기·잡관목제거」). 갈래로 읽으면 「시공법 미지정」이라는 **틀린 사유**가 #: (지장목제거의 「뿌리뽑기·잡관목제거」). 갈래로 읽으면 「시공법 미지정」이라는 **틀린 사유**가
#: 붙는다(2026-09-09 실측). 정의처는 `EarthworkSummary` 이고 여기서 그대로 가져다 쓴다. #: 붙는다(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 from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
GROUND_SPLIT_GROUPS as GROUND_SPLIT_GROUPS, GROUND_SPLIT_GROUPS as GROUND_SPLIT_GROUPS,
) )
#: 코드 없이 **수량만** 서는 집계 줄 — 막힘 사유는 집계 비고 그대로(토취 · 2026-09-14 브레인 ①).
SUMMARY_ONLY_GROUPS = frozenset({BORROW_NAME})
SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"}) SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"})
#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다. #: 집계 합계 줄 — 내역 줄이 아니라 검산용이다.
@@ -271,13 +277,20 @@ class WorkItemMapping:
return row return row
return None 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 []: 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 row
return None return None
@@ -382,6 +395,42 @@ def composite_quantities(
entry["not_ready"] = True entry["not_ready"] = True
entry["why"] = why entry["why"] = why
missing.append({"code": spec.get("code"), "reason": 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 == "pavement_thickness":
# 12-6 갈래는 포장두께 20·30·40㎝ — 원단위가 쓴 두께(칸 또는 B06) 그대로(2026-09-15).
thickness = (structure.get("options") or {}).get("thickness_cm")
label = f"{thickness:g}" if isinstance(thickness, (int, float)) else ""
if label in ("20㎝", "30㎝", "40㎝"):
entry["kind"] = label
entry["code"] = f"{spec.get('code')}#{label}"
elif found:
why = f"12-6 표는 포장두께 20·30·40㎝ 갈래뿐 — 두께 {label or '없음'} 은 못 고름"
entry["not_ready"] = True
entry["why"] = why
missing.append({"code": spec.get("code"), "reason": why})
if suffix == "rebar_complexity": if suffix == "rebar_complexity":
# 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다. # 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다.
complexity, why = rebar_complexity( complexity, why = rebar_complexity(
@@ -459,10 +508,12 @@ def rebar_complexity(
continue continue
if row.get("form") == form: if row.get("form") == form:
if row.get("class"): 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 "원문 예시에 없음") return None, str(row.get("why") or "원문 예시에 없음")
if fallback and fallback.get("class"): 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 from B08_Quantity.B08_Quantity_Wording import type_label
detail = f"({form})" if form else "(형식이 아직 입력되지 않음)" detail = f"({form})" if form else "(형식이 아직 입력되지 않음)"
@@ -7,9 +7,11 @@
from __future__ import annotations from __future__ import annotations
import string
from typing import Any from typing import Any
from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_code 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 ( from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_FORMULA_MISSING, BLOCKED_FORMULA_MISSING,
BLOCKED_INPUT_MISSING, BLOCKED_INPUT_MISSING,
@@ -27,6 +29,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
ORIGIN_STRUCTURE, ORIGIN_STRUCTURE,
SLOPE_GROUPS, SLOPE_GROUPS,
SUBTOTAL_GROUPS, SUBTOTAL_GROUPS,
SUMMARY_ONLY_GROUPS,
WorkItemMapping, WorkItemMapping,
composite_quantities, composite_quantities,
masonry_class, masonry_class,
@@ -151,19 +154,30 @@ def _earthwork_rows(
# 보는 쪽을 고쳤으면 **다는 쪽도** 빠짐없이 달아야 한다. # 보는 쪽을 고쳤으면 **다는 쪽도** 빠짐없이 달아야 한다.
blocked_kind = mismatch[1] if mismatch else None blocked_kind = mismatch[1] if mismatch else None
blocked_reason = mismatch[2] if mismatch else "" blocked_reason = mismatch[2] if mismatch else ""
inputs = {**(variant_inputs or {}), "ground_class": ground}
variant_value = ( variant_value = (
(entry or {}).get("variant_value") (entry or {}).get("variant_value")
or { or inputs.get(str((entry or {}).get("variant_from") or ""))
**(variant_inputs or {}),
"ground_class": ground,
}.get(str((entry or {}).get("variant_from") or ""))
or None or None
) )
# 입력 둘을 한 갈래 값으로 엮는 매핑(9-21 「크기·등급」 · 09-14) — 하나라도 비면 안 엮음.
template = str((entry or {}).get("variant_template") or "")
needs = [name for _, name, _, _ in string.Formatter().parse(template) if name]
template_ready = all(inputs.get(name) for name in needs)
if template and template_ready:
variant_value = template.format(**inputs)
leaf_from = str((entry or {}).get("leaf_from") or "")
leaf_codes = (entry or {}).get("leaf_codes") or {}
leaf = leaf_codes.get(inputs.get(leaf_from)) if leaf_from else None
code = leaf or code
missing = not leaf if leaf_from else (not variant_value or not template_ready)
# 매핑이 「이 칸이 비면 못 고름」이라 적은 갈래 — 금액 없이 입력 사유(면고르기 · 09-14 Ⓒ). # 매핑이 「이 칸이 비면 못 고름」이라 적은 갈래 — 금액 없이 입력 사유(면고르기 · 09-14 Ⓒ).
if code and not variant_value and (entry or {}).get("variant_missing_reason"): if code and missing and (entry or {}).get("variant_missing_reason"):
blocked_kind = blocked_kind or BLOCKED_INPUT_MISSING blocked_kind = blocked_kind or BLOCKED_INPUT_MISSING
blocked_reason = blocked_reason or str(entry["variant_missing_reason"]) 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 label = f"{group}({ground})" if ground else group
unmatched.append(f"{label}{method_note}" if method_note else label) unmatched.append(f"{label}{method_note}" if method_note else label)
if blocked_kind is None: if blocked_kind is None:
@@ -230,13 +244,15 @@ def _haul_rows(
in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True) in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True)
if code is None and in_bill: if code is None and in_bill:
unmatched.append(f"운반({equipment})") unmatched.append(f"운반({equipment})")
# ⚠ 코드가 없으면 **줄에 막힘 표시를 단다**(2026-09-09) — 목록에만 실으면 줄 단위로 # ⚠ 코드가 없으면 **줄에 막힘 표시를 단다**(2026-09-09) — 목록에만 실으면 금액이 조용히
# 보는 쪽이 「멀쩡한 줄」로 읽어 금액이 조용히 빠진다(도자운반·덤프운반이 그랬다). # 빠진다. ⚠ `in_bill` 이 False 인 무대 줄은 **막힌 것이 아니다** — 품에 포함이라 안 세움.
# ⚠ `in_bill` 이 False 인 무대 줄은 **막힌 것이 아니다** — 품에 포함이라 안 세우는 것. no_code = code is None and in_bill # 암 줄 막힘은 운반표가 구성비로 가르며 단 것(㉱)
haul_blocked = BLOCKED_UNIT_DATA_MISSING if (code is None and in_bill) else None haul_blocked = (
haul_blocked_reason = ( BLOCKED_UNIT_DATA_MISSING if no_code else in_bill and row.get("blocked_kind") or None
f"운반({equipment})의 품셈 공종을 아직 못 이었습니다" if haul_blocked else ""
) )
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 # 재야 맞는다) 내역에 오르는 수량은 되돌린 값이다(`config_system_design` 5-4-3
# 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는 # 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는
@@ -260,8 +276,10 @@ def _haul_rows(
rows.append( rows.append(
{ {
"work_item_code": code, "work_item_code": code,
"name": f"{equipment} 운반", "name": entry.get("master_name") or HAUL_LABELS.get(equipment, f"{equipment} 운반"),
"spec": str(row.get("ground") or ""), "spec": " · ".join(
dict.fromkeys(filter(None, (row.get("rock_class"), row.get("ground"))))
),
"unit": "", "unit": "",
"quantity": quantity, "quantity": quantity,
# 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다). # 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다).
@@ -424,7 +442,11 @@ def _structure_rows(
# m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는 # m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는
# 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다. # 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다.
billing = _component_billing(structure, entry) 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 kind = structure_kind(structure) if composite else None
parts: list[dict[str, Any]] | None = None parts: list[dict[str, Any]] | None = None
parts_missing: list[dict[str, Any]] = [] parts_missing: list[dict[str, Any]] = []
@@ -441,6 +463,15 @@ def _structure_rows(
# 갈래 축과 **저장 제원 원본값**. 가공하지 않는다. # 갈래 축과 **저장 제원 원본값**. 가공하지 않는다.
variant_axis = str(entry.get("variant_axis") or "") or None variant_axis = str(entry.get("variant_axis") or "") or None
variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None
if variant_axis == "back_len_cm":
# 뒷길이만은 **수량표·구조물도가 실제로 쓴 값**과 출처를 싣는다 — 안 고른 벽(관 유입·
# 유출부 기슭막이는 늘 빔)도 [주]⑩ 하한으로 물량이 섰는데 갈래만 못 골랐음(09-14).
from B08_Quantity.B08_Quantity_Engine_StructureFigure import back_length_cm
back, default_note = back_length_cm(structure)
variant_value = str(back) if variant_value in (None, "") else variant_value
used = f"뒷길이 {back}㎝ — {default_note or '저장 제원'}"
class_basis = " · ".join(part for part in (class_basis, used) if part)
secondary_axes = [ secondary_axes = [
{"axis": axis, "value": (structure.get("options") or {}).get(axis)} {"axis": axis, "value": (structure.get("options") or {}).get(axis)}
for axis in entry.get("secondary_axes") or [] for axis in entry.get("secondary_axes") or []
@@ -582,7 +613,7 @@ def _placing_rows(
# 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 — # 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 —
# 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.** # 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 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" "unconfirmed"
): ):
continue # 기본값으로 선 구조물도 — 금액에 안 듦 continue # 기본값으로 선 구조물도 — 금액에 안 듦
@@ -21,6 +21,12 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE, 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 ( from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE, 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: 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 return BLOCKED_INPUT_MISSING
if status == PREP_PENDING:
return BLOCKED_UNIT_DATA_MISSING
if status == PREP_COUNTED_ELSEWHERE: if status == PREP_COUNTED_ELSEWHERE:
# 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다. # 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다.
return None return None
if status == PREP_NOT_APPLICABLE: if status in (PREP_NOT_APPLICABLE, PREP_NO_WORK_ITEM):
# 공종 없는 줄은 자재총괄 줄로 금액이 섬(「자재 단가」 탭) — 여기서 막힘으로 세면 두 벌.
return None return None
return BLOCKED_UNIT_DATA_MISSING return BLOCKED_UNIT_DATA_MISSING
@@ -24,6 +24,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
WorkItemMapping, WorkItemMapping,
) )
from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import LOADING_EQUIPMENT from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import LOADING_EQUIPMENT
from B08_Quantity.B08_Quantity_Engine_RockSplit import split_amounts
SPOIL_NAME = "사토 운반" SPOIL_NAME = "사토 운반"
#: ⚠ **사토장 사면 물량은 안 센다**(2026-09-09 세 창 확인). 교본 6장 3절은 「완료 구간 비탈면을 #: ⚠ **사토장 사면 물량은 안 센다**(2026-09-09 세 창 확인). 교본 6장 3절은 「완료 구간 비탈면을
@@ -98,20 +99,28 @@ def spoil_haul_rows(
unknown = float(spoil.get("ground_unknown_m3") or 0.0) unknown = float(spoil.get("ground_unknown_m3") or 0.0)
if by_ground or unknown > 0: if by_ground or unknown > 0:
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
for label, amount in sorted(by_ground.items()): # 암은 운반표와 같은 구성비 몫으로(자리표시 리핑암을 값으로 안 씀 · ㉱ `RockSplit`).
leg = by_distance.get(label) 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 leg_blocked = blocked if leg is None else False
rows.append( rows.append(
_spoil_row( _spoil_row(
code, code,
amount, amount,
distance if leg is None else leg, distance if leg is None else leg,
leg_blocked, leg_blocked or bool(share_reason),
reason if leg_blocked else "", share_reason or (reason if leg_blocked else ""),
note, note,
ground=label, ground=label,
extra=" · ".join( 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 total = 0.0
bases: list[str] = [] bases: list[str] = []
for structure in unit_quantity_table.get("structures") or []: 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" "unconfirmed"
): ):
continue # 묶음 조각이 품음 · 기본값으로 선 구조물은 금액에 안 듦 continue # 묶음 조각이 품음 · 기본값으로 선 구조물은 금액에 안 듦
@@ -254,6 +254,8 @@ def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]:
{ {
"equipment": row["equipment"], "equipment": row["equipment"],
"ground": row["ground"], "ground": row["ground"],
# 암 갈래(구성비로 가른 줄 · `RockSplit`) — 집계표 공종 칸이 흙깎기와 같은 이름을 씀.
"rock_class": row.get("rock_class"),
"volume_m3": row["volume_m3"], "volume_m3": row["volume_m3"],
"volume_basis": row.get("volume_basis") or "compacted", "volume_basis": row.get("volume_basis") or "compacted",
"natural_m3": row.get("natural_m3"), "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) @dataclass(slots=True)
class HaulCheck: class HaulCheck:
"""검산 — 무대를 안 내면 이 대조가 죽는다(PLAN 8-7 ㉡).""" """검산 — 무대를 안 내면 이 대조가 죽는다(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 [] 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( def facility_structures(
points: list[dict[str, Any]], points: list[dict[str, Any]],
extra_walls: dict[float, list[dict]] | None = None, 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, "end_m": point.get("end_m") if point.get("end_m") is not None else chainage,
"options": options, "options": options,
} }
if facility == "box_culvert":
rows.append(_box_row(base, defaults(facility)))
continue
if facility not in (FACILITY_PIPE, "revetment"): if facility not in (FACILITY_PIPE, "revetment"):
rows.append(base) # BOX암거·물넘이·세월교 — 전개식·관측값이 없으면 사유로 섬 rows.append(base) # 물넘이·세월교 — 전개식·관측값이 없으면 사유로 섬
continue continue
legacy = facility == "revetment" # 독립 기슭막이 — 벽 칸 밖의 제원(뒷길이 등)도 벽이 씀 legacy = facility == "revetment" # 독립 기슭막이 — 벽 칸 밖의 제원(뒷길이 등)도 벽이 씀
wall_defaults = defaults(facility) 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_ITEMS,
ancillary_rows, 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 from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( # noqa: E402
STATUS_COUNTED_ELSEWHERE, STATUS_COUNTED_ELSEWHERE,
STATUS_NEEDS_INPUT,
STATUS_NOT_APPLICABLE, STATUS_NOT_APPLICABLE,
STATUS_PENDING, STATUS_PENDING,
STATUS_READY, STATUS_READY,
) )
from B08_Quantity.B08_Quantity_Engine_Preparation_TreeWaste import tree_waste_rows # noqa: E402 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]]: def batter_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int, list[str]]:
@@ -170,7 +176,7 @@ def _topsoil_row(
return { return {
**base, **base,
"amount": None, "amount": None,
"status": STATUS_PENDING, "status": STATUS_NEEDS_INPUT,
"reason": ( "reason": (
"노면 면적을 못 셉니다 — 횡단 설계에 노체 끝(노면 폭)이 없는 측점이 있음. " "노면 면적을 못 셉니다 — 횡단 설계에 노체 끝(노면 폭)이 없는 측점이 있음. "
f"대상은 노면 + 절토대상지(별표2) · 절토 사면 {cut:,.1f}㎡ 만으로는 세우지 않음" f"대상은 노면 + 절토대상지(별표2) · 절토 사면 {cut:,.1f}㎡ 만으로는 세우지 않음"
@@ -184,7 +190,7 @@ def _topsoil_row(
return { return {
**base, **base,
"amount": None, "amount": None,
"status": STATUS_PENDING, "status": STATUS_NEEDS_INPUT,
"reason": ( "reason": (
"대상 면적이 0 ㎡ 입니다 — 횡단·사면표가 아직 서지 않았습니다. " "대상 면적이 0 ㎡ 입니다 — 횡단·사면표가 아직 서지 않았습니다. "
"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, "amount": area if area > 0 else None,
# ⭐ 2026-09-13 판정 Ⓑ — 셈은 토공집계 「지장목제거 · 뿌리뽑기」(FP-09-21)가 한다. # ⭐ 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), "reason": " · ".join(reasons),
"reference_amount": area, "reference_amount": area,
"work_item_code": None, "work_item_code": None,
@@ -364,7 +370,7 @@ def chipping_rows(enabled: Any, volume_m3: Any) -> list[dict[str, Any]]:
"item": CHIPPING_ITEM, "item": CHIPPING_ITEM,
"unit": "", "unit": "",
"amount": amount, "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번)", "reason": CHIPPING_ON_NOTE if not amount else "산출 조건에서 넣은 부피 (확정 5차 5번)",
"work_item_code": CHIPPING_CODE, "work_item_code": CHIPPING_CODE,
} }
@@ -382,7 +388,7 @@ def _root_steps_rows(
"item": "뿌리 적재", "item": "뿌리 적재",
"unit": "", "unit": "",
"amount": area if area > 0 else None, "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": ( "reason": (
f"{ROOT_STEPS_NOTE} · {ROOT_REMOVAL_BASIS}" f"{ROOT_STEPS_NOTE} · {ROOT_REMOVAL_BASIS}"
" · ⚠ **품셈 9-20-2 는 「10주당」이라 밑수 축이 다름** — 면적 축으로 내고" " · ⚠ **품셈 9-20-2 는 「10주당」이라 밑수 축이 다름** — 면적 축으로 내고"
@@ -434,7 +440,7 @@ def _topsoil_haul_row(
"item": "표토 운반·적치", "item": "표토 운반·적치",
"unit": "", "unit": "",
"amount": None, "amount": None,
"status": STATUS_PENDING, "status": STATUS_NEEDS_INPUT,
"reason": f"{TOPSOIL_HAUL_LAW} · 제거 면적이 아직 안 서서 운반도 못 셈", "reason": f"{TOPSOIL_HAUL_LAW} · 제거 면적이 아직 안 서서 운반도 못 셈",
"work_item_code": None, "work_item_code": None,
} }
@@ -444,7 +450,7 @@ def _topsoil_haul_row(
"item": "표토 운반·적치", "item": "표토 운반·적치",
"unit": "", "unit": "",
"amount": None, "amount": None,
"status": STATUS_PENDING, "status": STATUS_NEEDS_INPUT,
"reason": ( "reason": (
f"{TOPSOIL_HAUL_LAW} · 운반 부피 = 제거 면적 × 표토 두께 — 두께가 아직 입력되지" f"{TOPSOIL_HAUL_LAW} · 운반 부피 = 제거 면적 × 표토 두께 — 두께가 아직 입력되지"
f" 않았습니다. {TOPSOIL_ORIGINAL_APPLIED} (제거 면적 {float(area):,.1f}㎡)" f" 않았습니다. {TOPSOIL_ORIGINAL_APPLIED} (제거 면적 {float(area):,.1f}㎡)"
@@ -472,7 +478,7 @@ def _topsoil_haul_row(
"item": "표토 운반·적치", "item": "표토 운반·적치",
"unit": "", "unit": "",
"amount": None, "amount": None,
"status": STATUS_PENDING, "status": STATUS_NEEDS_INPUT,
"reason": ( "reason": (
f"{TOPSOIL_HAUL_LAW} · 운반거리가 아직 입력되지 않았습니다 — 「최고 홍수위보다" f"{TOPSOIL_HAUL_LAW} · 운반거리가 아직 입력되지 않았습니다 — 「최고 홍수위보다"
f" 높은 장소」는 현장에서 정하는 자리라 품셈이 거리를 주지 않습니다" 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]: def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
"""비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다.""" """비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다."""
count, notes = batter_frame_count(slope_rows) count, notes = batter_frame_count(slope_rows)
# 사면표가 있는데 0 개소면 **이 노선엔 필요 없는 것**이다 — 「근거 없음」이 아니다(㉴).
idle = STATUS_NOT_APPLICABLE if slope_rows else STATUS_NEEDS_INPUT
return { return {
"group": "준비공", "group": "준비공",
"item": "비탈 규준틀", "item": "비탈 규준틀",
"unit": "개소", "unit": "개소",
"amount": float(count) if count else None, "amount": float(count) if count else None,
"status": STATUS_READY if count else STATUS_PENDING, "status": STATUS_READY if count else idle,
"reason": ( "reason": (
"; ".join(notes) "; ".join(notes)
+ " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」 —" + " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」 —"
@@ -604,7 +539,12 @@ def _level_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
"item": "수평 규준틀", "item": "수평 규준틀",
"unit": "개소", "unit": "개소",
"amount": float(count) if count is not None else None, "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) "reason": "; ".join(notes)
+ ( + (
" · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」 —" " · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」 —"
@@ -705,6 +645,7 @@ def build_table(
"columns": ["구분", "공종", "단위", "수량", "상태", "사유"], "columns": ["구분", "공종", "단위", "수량", "상태", "사유"],
"rows": rows, "rows": rows,
"ready_count": sum(1 for row in rows if row["status"] == STATUS_READY), "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), "pending_count": sum(1 for row in rows if row["status"] == STATUS_PENDING),
"row_count": len(rows), "row_count": len(rows),
} }
@@ -11,10 +11,14 @@ from typing import Any
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
REASON_NO_WORK_ITEM, REASON_NO_WORK_ITEM,
STATUS_PENDING, STATUS_NEEDS_INPUT,
STATUS_NO_WORK_ITEM,
STATUS_READY, STATUS_READY,
) )
#: 공종 없는 줄에 개소가 들어온 뒤 할 일 — 자재총괄(사급) 줄로 가서 「자재 단가」 탭에 칸이 섬.
NO_WORK_ITEM_PRICE_PATH = "「자재 단가」 탭에 단가를 넣으면 자재총괄(사급) 줄로 금액이 섬"
#: 부대시설·가설공사 — **법이 요구하는데 우리가 안 내던 다섯 줄**(2026-09-09 사용자 확정 ⑬). #: 부대시설·가설공사 — **법이 요구하는데 우리가 안 내던 다섯 줄**(2026-09-09 사용자 확정 ⑬).
#: `key` 는 설정의 `ancillary_counts` 칸 이름, `code` 는 품셈 공종(없으면 `None`). #: `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) reasons.append(REASON_NO_WORK_ITEM)
if amount is None: if amount is None:
reasons.append("개소가 아직 입력되지 않았습니다 — 넣으면 물량이 섭니다") reasons.append("개소가 아직 입력되지 않았습니다 — 넣으면 물량이 섭니다")
status = STATUS_PENDING status = STATUS_NEEDS_INPUT
elif spec["code"]:
status = STATUS_READY
else: else:
status = STATUS_READY if spec["code"] else STATUS_PENDING reasons.append(NO_WORK_ITEM_PRICE_PATH)
status = STATUS_NO_WORK_ITEM
rows.append( rows.append(
{ {
"group": "부대시설", "group": "부대시설",
@@ -110,3 +117,22 @@ def ancillary_rows(counts: dict[str, Any] | None = None) -> list[dict[str, Any]]
} }
) )
return rows 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 from __future__ import annotations
STATUS_READY = "값 있음" STATUS_READY = "값 있음"
#: 사람이 넣으면 서는 줄 — 산출 조건 칸, 또는 앞 단계(횡단·사면표)를 마치면 섬.
#: 인계 `input_missing`.
#: ⚠ 「근거 없음」과 가른다 — 한 낱말로 덮으면 입력만 넣으면 서는 줄이 「못 세움」으로 읽힌다
#: (2026-09-14 브레인 ㉴ · 인계는 반대로 사방 원단위까지 「입력이 필요합니다」로 보냈다).
STATUS_NEEDS_INPUT = "입력이 필요함"
#: 자료·산식이 우리에게 없는 줄 — 입력으로는 안 풀림. 인계 `unit_data_missing`.
STATUS_PENDING = "값을 낼 근거가 없음" STATUS_PENDING = "값을 낼 근거가 없음"
#: 수량은 섰는데 **품셈에 공종이 없어** 단가가 영영 안 서는 줄 — 「자재 단가」 탭에 단가를 넣음
#: (화약류·치즐과 같은 통로 · 자재총괄 사급 줄로 감). 인계는 막힘이 아님(자재 줄로 셈).
#: ⚠ 「입력이 필요함」으로 두면 개소를 넣고 기다려도 안 섬(2026-09-14 브레인 판정).
STATUS_NO_WORK_ITEM = "품셈 공종 없음 — 단가를 직접 넣어야 함"
STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬" STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬"
STATUS_NOT_APPLICABLE = "해당 없음" STATUS_NOT_APPLICABLE = "해당 없음"
@@ -27,7 +27,7 @@ from __future__ import annotations
import math import math
from typing import Any 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 = "임목폐기물 처리" TREE_WASTE_ITEM = "임목폐기물 처리"
STEM_FORM_FACTOR = 0.5 # k STEM_FORM_FACTOR = 0.5 # k
@@ -139,7 +139,7 @@ def tree_waste_rows(
{ {
**base, **base,
"amount": None, "amount": None,
"status": STATUS_PENDING, "status": STATUS_NEEDS_INPUT, # 조사값 칸 · 앞 단계 사면표 — 둘 다 사람 몫
"reason": f"{why}. 산식: {TREE_WASTE_BASIS} · {root_basis}", "reason": f"{why}. 산식: {TREE_WASTE_BASIS} · {root_basis}",
"reference_amount": area, "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
@@ -19,13 +19,36 @@ from typing import Any
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import TEMPLATE_DIR from B08_Quantity.B08_Quantity_Engine_StructureTemplate import TEMPLATE_DIR
from config.config_system import STORAGE_BASE_DIR from config.config_system import STORAGE_BASE_DIR
#: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터). #: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터). `received` = 동료가 보낸 것(공유 · 개인 단을 안 덮음).
TIERS = ("personal", "company", "program") TIERS = ("personal", "received", "company", "program")
RECEIVED_SUBDIR = "library_received"
#: 항목 코드 — 파일 이름이 되므로 모양을 먼저 봄(경로 벗어남 막이). #: 항목 코드 — 파일 이름이 되므로 모양을 먼저 봄(경로 벗어남 막이).
CODE_PATTERN = re.compile(r"^AX-ST-[0-9a-f]{8}$") CODE_PATTERN = re.compile(r"^AX-ST-[0-9a-f]{8}$")
LIBRARY_SUBDIR = "library" LIBRARY_SUBDIR = "library"
#: 프로그램 기본 작업본 자리 — 회사 번호 폴더와 안 겹치게 밑줄 이름.
PROGRAM_SUBDIR = "_program"
#: 프로그램 기본 발행본에서 뺄 출처 칸 — 어느 공사인지(2026-09-14 브레인 판정 ②).
ORIGIN_MASKED_KEYS = ("project", "file")
ORIGIN_MASKED_NOTE = "원문에서 뽑음 — 공사명은 발행 시 가림"
def program_library_dir() -> Path:
"""프로그램 기본 **작업본**(시스템 관리자가 화면에서 고친 것) — storage(회사·개인 단과 같은 결).
git `resources/library_structure`(`TEMPLATE_DIR`) **씨앗** 쓰기 금지(데이터 3층의 초기값 자리).
"""
return Path(STORAGE_BASE_DIR).resolve() / PROGRAM_SUBDIR / LIBRARY_SUBDIR
def program_items() -> list[dict[str, Any]]:
"""프로그램 기본 = 작업본 + 씨앗 중 **작업본에 없는 코드만**(관리자가 고친 것이 배포에 안 덮임)."""
stored = _items(program_library_dir())
codes = {item.get("code") for item in stored}
return [*stored, *(item for item in _items(TEMPLATE_DIR) if item.get("code") not in codes)]
def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]: def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]:
"""로그인한 사람의 3단 폴더. 회사가 없으면(시스템 관리자) 개인·회사 단은 없음.""" """로그인한 사람의 3단 폴더. 회사가 없으면(시스템 관리자) 개인·회사 단은 없음."""
dirs: dict[str, Path] = {} dirs: dict[str, Path] = {}
@@ -33,8 +56,9 @@ def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]:
company = Path(STORAGE_BASE_DIR).resolve() / str(company_id) company = Path(STORAGE_BASE_DIR).resolve() / str(company_id)
if user_id is not None: if user_id is not None:
dirs["personal"] = company / str(user_id) / LIBRARY_SUBDIR dirs["personal"] = company / str(user_id) / LIBRARY_SUBDIR
dirs["received"] = company / str(user_id) / RECEIVED_SUBDIR
dirs["company"] = company / LIBRARY_SUBDIR dirs["company"] = company / LIBRARY_SUBDIR
dirs["program"] = TEMPLATE_DIR dirs["program"] = program_library_dir()
return dirs return dirs
@@ -44,6 +68,13 @@ def _items(folder: Path) -> list[dict[str, Any]]:
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(folder.glob("*.json"))] return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(folder.glob("*.json"))]
def _tier_items(dirs: dict[str, Path], tier: str) -> list[dict[str, Any]]:
"""단의 항목 — 프로그램 기본은 작업본 + 씨앗, 나머지는 그 폴더."""
if tier == "program":
return program_items()
return _items(dirs[tier]) if tier in dirs else []
def item_kind(item: dict[str, Any]) -> str: def item_kind(item: dict[str, Any]) -> str:
"""양식형(`form`) · 고정형(`fixed`) — 명세 13장: 칸은 같고 **식이 한 줄이라도 있나**로만 가름. """양식형(`form`) · 고정형(`fixed`) — 명세 13장: 칸은 같고 **식이 한 줄이라도 있나**로만 가름.
저장된 `item_kind` 표시는 믿음 줄과 어긋나면 배지가 거짓이 .""" 저장된 `item_kind` 표시는 믿음 줄과 어긋나면 배지가 거짓이 ."""
@@ -66,16 +97,15 @@ def list_items(dirs: dict[str, Path], type_id: str) -> list[dict[str, Any]]:
} }
for tier in TIERS for tier in TIERS
if tier in dirs if tier in dirs
for item in _items(dirs[tier]) for item in _tier_items(dirs, tier)
if item.get("type_id") == type_id and item.get("code") if item.get("type_id") == type_id and item.get("code")
] ]
def find_item(dirs: dict[str, Path], tier: str, code: str) -> dict[str, Any] | None: def find_item(dirs: dict[str, Path], tier: str, code: str) -> dict[str, Any] | None:
folder = dirs.get(tier) if tier not in dirs:
if folder is None:
return None return None
return next((item for item in _items(folder) if item.get("code") == code), None) return next((item for item in _tier_items(dirs, tier) if item.get("code") == code), None)
def project_library_dir(project_root: str | Path) -> Path: def project_library_dir(project_root: str | Path) -> Path:
@@ -98,7 +128,7 @@ def available_templates(project_root: str | Path | None) -> list[dict[str, Any]]
개인·회사 단은 넣음 표를 그릴 라이브러리를 매번 읽지 않음(판정 ). 개인·회사 단은 넣음 표를 그릴 라이브러리를 매번 읽지 않음(판정 ).
""" """
return [*_items(TEMPLATE_DIR), *project_templates(project_root).values()] return [*program_items(), *project_templates(project_root).values()]
def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> None: def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> None:
@@ -127,9 +157,13 @@ def _write(folder: Path, item: dict[str, Any]) -> None:
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8") (folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
def _personal_code(folder: Path, type_id: Any) -> str: def _personal_code(folder: Path, type_id: Any, existing: list[dict[str, Any]] | None = None) -> str:
"""개인 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로.""" """ 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로.
same = [item for item in _items(folder) if item.get("type_id") == type_id]
프로그램 기본은 씨앗까지 (`existing`) 씨앗 코드로 덮어써야 작업본이 씨앗을 이김.
"""
items = _items(folder) if existing is None else existing
same = [item for item in items if item.get("type_id") == type_id]
return str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}" return str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
@@ -145,16 +179,22 @@ def save_personal(
template: dict[str, Any], template: dict[str, Any],
overrides: dict[str, Any] | None, overrides: dict[str, Any] | None,
unit_price_rows: list[dict[str, Any]] | None = None, unit_price_rows: list[dict[str, Any]] | None = None,
tier: str = "personal",
name: str | None = None,
) -> str: ) -> str:
"""[내 라이브러리에 저장] — 양식 + 고친 식·줄 조합을 **개인 단에 한 벌**로 씀. 코드. """[내 라이브러리에 저장]·발행 — 양식 + 고친 식·줄 조합을 ** 단에 한 벌**로 씀. 코드.
반대 방향(작업본 개인 )이라 프로젝트는 바꿈(브레인 판정 ). `name` 이름표(화면이 종류 + 제원 요약 제안 · 사용자가 고침 · 10-A ). 비면 양식 이름.
개인 단에 같은 종류가 있으면 ** 코드로 덮어씀** 지금은 종류당 하나(판정 ).
반대 방향(작업본 라이브러리)이라 프로젝트는 바꿈(브레인 판정 ).
같은 종류가 있으면 ** 코드로 덮어씀** 지금은 종류당 하나(판정 ).
`tier` 회사(MASTER)·프로그램 기본(SYSTEM_ADMIN) 발행도 같은 모양(2026-09-14 브레인 승인).
수동 단가는 실음 프로젝트의 값이라 양식에 실으면 남의 프로젝트로 (브레인 판정). 수동 단가는 실음 프로젝트의 값이라 양식에 실으면 남의 프로젝트로 (브레인 판정).
""" """
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows
code = _personal_code(folder, template.get("type_id")) existing = program_items() if tier == "program" else None
code = _personal_code(folder, template.get("type_id"), existing)
# 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄. # 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
dropped = {"default_formula", "default_rounding"} dropped = {"default_formula", "default_rounding"}
rows = [ rows = [
@@ -162,9 +202,15 @@ def save_personal(
for row in overridden_rows(template, overrides) for row in overridden_rows(template, overrides)
] ]
item = {k: v for k, v in template.items() if k != "imported_from"} item = {k: v for k, v in template.items() if k != "imported_from"}
if name and name.strip():
item["name"] = name.strip()
if tier == "program" and isinstance(item.get("origin"), dict):
# 모든 회사로 가는 발행본 — 원문 공사명·파일명은 안 실음(원본·회사 단은 그대로 · 브레인 ②③).
kept = {k: v for k, v in item["origin"].items() if k not in ORIGIN_MASKED_KEYS}
item["origin"] = {**kept, "masked": ORIGIN_MASKED_NOTE}
if unit_price_rows is not None: if unit_price_rows is not None:
item["unit_price"] = {**(item.get("unit_price") or {}), "rows": unit_price_rows} item["unit_price"] = {**(item.get("unit_price") or {}), "rows": unit_price_rows}
_write(folder, {**item, "code": code, "library_tier": "personal", "rows": rows}) _write(folder, {**item, "code": code, "library_tier": tier, "rows": rows})
return code return code
@@ -186,8 +232,18 @@ def pin_program_templates(project_root: str | Path) -> int:
""" """
pinned = project_templates(project_root) pinned = project_templates(project_root)
count = 0 count = 0
for item in _items(TEMPLATE_DIR): for item in program_items():
if item.get("type_id") and item.get("code") and item["type_id"] not in pinned: if item.get("type_id") and item.get("code") and item["type_id"] not in pinned:
import_item(project_root, item, "program") import_item(project_root, item, "program")
count += 1 count += 1
return count return count
def share_item(item: dict[str, Any], to_folder: Path, sender: dict[str, Any]) -> str:
"""[동료에게 보내기] — 내 개인 단 항목을 동료의 **받음 단**에 복사(개인 단을 안 덮음 · 브레인 판정 ①).
코드는 그대로(다시 보내면 같은 자리를 새로 ) · 출처 공사명도 그대로(같은 회사 · 판정 ).
"""
body = {k: v for k, v in item.items() if k != "imported_from"}
_write(to_folder, {**body, "library_tier": "received", "received_from": sender})
return str(body["code"])
@@ -12,7 +12,7 @@
from __future__ import annotations from __future__ import annotations
import json import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -28,11 +28,13 @@ OVERRIDES_KEY = "structure_formula_overrides"
def load_template(type_id: str) -> dict[str, Any] | None: def load_template(type_id: str) -> dict[str, Any] | None:
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬.""" """프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬.
path = TEMPLATE_DIR / f"{type_id}.json"
if not path.is_file(): storage 작업본(시스템 관리자가 고친 ) 먼저, 없으면 git 씨앗(`TEMPLATE_DIR`) 2026-09-14 관리자 UI.
return None """
return json.loads(path.read_text(encoding="utf-8")) from B08_Quantity.B08_Quantity_Engine_StructureLibrary import program_items
return next((item for item in program_items() if item.get("type_id") == type_id), None)
def template_of(type_id: str, templates: dict[str, Any] | None) -> dict[str, Any] | None: def template_of(type_id: str, templates: dict[str, Any] | None) -> dict[str, Any] | None:
@@ -153,6 +155,65 @@ def template_sheet(
#: 양식이 m당으로 풀리는 단위 — 연장 L=1 로 풀면 곧 단위당 값(모든 줄이 L 에 비례). #: 양식이 m당으로 풀리는 단위 — 연장 L=1 로 풀면 곧 단위당 값(모든 줄이 L 에 비례).
_PER_LENGTH_UNITS = frozenset({"m"}) _PER_LENGTH_UNITS = frozenset({"m"})
#: ㉯ 양식에 **그 이름 줄이 없으면 전개 값을 남기는** 성분(2026-09-14 브레인 판정 · 목록으로 못박음).
#: STmate 호표(고정형)는 토공·버림을 안 품음 — 통째로 갈음하면 구조물터파기 85.25→25㎥(6,037,712원 실측).
#: 목록 밖 성분은 양식으로 갈음 · 목록 안이라도 양식이 그 줄을 품으면 양식 값(겹쳐 세지 않음).
#: ② 실무 엑셀 벽·돌쌓기 호표 57 전수 — 기초잡석 0/57(「기초다짐 및 뒤채움」 4 은 다른 것) ·
#: 채집석 = 사토에서 빼는 돌 부피(haul_deduction)라 호표의 채집 줄(품 · 41/57)과 다른 것.
KEEP_ENGINE_COMPONENTS = ("터파기", "되메우기", "잔토처리", "버림콘크리트", "기초잡석", "채집석")
KEPT_ROW_REASON = "전개 값 그대로 — 양식이 이 줄을 안 품음(토공·버림은 전개가 셈)"
def _kept(rows: list[Any], template_rows: list[dict[str, Any]], name_of: Any) -> list[Any]:
"""목록 이름 중 양식에 없는 것만 — 줄(dict)·성분(Component) 둘 다 이름 꺼내는 손잡이로."""
names = {str(row.get("name") or "") for row in template_rows}
return [
row for row in rows if name_of(row) in KEEP_ENGINE_COMPONENTS and name_of(row) not in names
]
#: ㉰ 고정형 장·구조물 사유 머리 — 남긴 전개 줄에 걸린 전개 사유 / ㉱ 규격 다름.
KEPT_NOTE_HEAD = "(남긴 전개 줄) "
MISMATCH_HEAD = "⚠ 규격 다름"
#: ㉱ 항목 제목에 적힌 높이 규격 「H=2.0m」 — 원문 제목의 값을 읽음(없으면 비교 안 함 · 추측 아님).
_SPEC_HEIGHT = re.compile(r"H\s*=\s*([0-9]+(?:\.[0-9]+)?)")
def _is_fixed(template: dict[str, Any]) -> bool:
"""고정형 — 식이 한 줄도 없음(명세 13장 · `StructureLibrary.item_kind` 와 같은 가름)."""
rows = template.get("rows") or []
return bool(rows) and not any(str(row.get("formula") or "").strip() for row in rows)
def fixed_notes(
template: dict[str, Any], engine_notes: list[str], height: Any
) -> tuple[list[str], dict[str, float] | None]:
"""㉰㉱ 고정형의 사유 — 항목 사유 + **남긴 전개 줄에 걸린** 전개 사유만 + 규격 다름(맨 앞).
전개 사유(뒷길이 표준· 종류) 고정형 줄과 맞아 거짓 사유 걷음. 남긴 (터파기·버림)
말하는 것만 머리를 달아 . 불려도(원단위 구조물도) 같은 값이 나오게 .
"""
item = [part for part in str(template.get("note") or "").split(" · ") if part]
kept: list[str] = []
for note in engine_notes:
if note in item or note.startswith(MISMATCH_HEAD):
continue
if note.startswith(KEPT_NOTE_HEAD):
kept.append(note)
elif any(name in note for name in KEEP_ENGINE_COMPONENTS):
kept.append(KEPT_NOTE_HEAD + note)
notes = list(dict.fromkeys(item + kept))
found = _SPEC_HEIGHT.search(str(template.get("name") or ""))
if not found or height is None or abs(float(found.group(1)) - float(height)) < 1e-6:
return notes, None
item_h, sheet_h = float(found.group(1)), float(height)
notes.insert(
0,
f"{MISMATCH_HEAD} — 항목 H={found.group(1)}m ↔ 장 H={sheet_h:g}m: 벽 수량은 항목 박힌 값"
f"(H={found.group(1)}m) · 토공·사토 공제는 장 제원(H={sheet_h:g}m)으로 셈",
)
return notes, {"item_height_m": item_h, "sheet_height_m": sheet_h}
def _library_rows( def _library_rows(
body: dict[str, Any], solved: list[dict[str, Any]], billing: float body: dict[str, Any], solved: list[dict[str, Any]], billing: float
@@ -295,7 +356,11 @@ def replace_with_templates(
spec=str(result.get("spec") or ""), spec=str(result.get("spec") or ""),
) )
) )
quantity.components = components quantity.components = components + _kept(
quantity.components, body["rows"], lambda component: component.name
)
if _is_fixed(template):
quantity.notes, _mismatch = fixed_notes(template, quantity.notes, quantity.height_m)
# 「양식 있음/없음」을 화면이 가리게 — 조용히 섞이면 왜 값이 다른지 못 찾음. # 「양식 있음/없음」을 화면이 가리게 — 조용히 섞이면 왜 값이 다른지 못 찾음.
quantity.library_item = str(template.get("name") or quantity.type_id) quantity.library_item = str(template.get("name") or quantity.type_id)
@@ -343,18 +408,41 @@ def apply_templates(
continue continue
members = sheet.get("members") or [] members = sheet.get("members") or []
billing = float(members[0].get("billing_quantity") or 0.0) if members else 0.0 billing = float(members[0].get("billing_quantity") or 0.0) if members else 0.0
sheet["rows"] = _library_rows(body, solved[index], billing) library_rows = _library_rows(body, solved[index], billing)
start = max((row["no"] for row in library_rows), default=0)
kept = _kept(sheet.get("rows") or [], body["rows"], lambda row: row.get("name"))
sheet["rows"] = library_rows + [
{
**row,
"no": start + offset,
"formula": "",
"default_formula": "",
"rounding": None,
"default_rounding": None,
"skipped": False,
"reason": KEPT_ROW_REASON,
"error": "",
}
for offset, row in enumerate(kept, start=1)
]
sheet["unpriced_rows"] = [ sheet["unpriced_rows"] = [
row["name"] row["name"]
for row in sheet["rows"] for row in sheet["rows"]
if row["unit_amount"] is None and not row["skipped"] if row["unit_amount"] is None and not row["skipped"]
] ]
if _is_fixed(template):
# ㉱ 규격 다름 — 금액은 서되 미확정 급으로 보임(막지 않음 · 사용자가 일부러 고른 것일 수 있음).
sheet["notes"], sheet["spec_mismatch"] = fixed_notes(
template, sheet.get("notes") or [], sheet.get("height_m")
)
# 「어느 단에서 가져왔나」 — 안 가져왔으면 `imported_from` 없음(= 기본 · 가져오기 전). # 「어느 단에서 가져왔나」 — 안 가져왔으면 `imported_from` 없음(= 기본 · 가져오기 전).
sheet["library_item"] = { sheet["library_item"] = {
"type_id": template.get("type_id"), "type_id": template.get("type_id"),
"name": template.get("name"), "name": template.get("name"),
"code": template.get("code"), "code": template.get("code"),
"imported_from": template.get("imported_from"), "imported_from": template.get("imported_from"),
# 발행 확인창이 「○○공사에서 뽑은 것 — 공사명은 빼고 발행」을 보이게(브레인 ②).
"origin_project": (template.get("origin") or {}).get("project"),
} }
sheet["formula_sheet"] = body sheet["formula_sheet"] = body
# 실무 관측값 같은 「대안 후보」 — 값을 바꾸지 않고 칸 옆에 보이기만(판정 Ⓑ). # 실무 관측값 같은 「대안 후보」 — 값을 바꾸지 않고 칸 옆에 보이기만(판정 Ⓑ).
@@ -16,6 +16,7 @@
from __future__ import annotations from __future__ import annotations
import re
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
@@ -362,32 +363,64 @@ def save_manual(
return merged return merged
#: 조각 후보의 최소 겹침 글자 수(2026-09-14 브레인 판정 ① — 「채집」·「타설」이 걸리게).
MIN_FRAGMENT = 2
def _longest_common(a: str, b: str) -> int:
"""두 글의 가장 긴 공통 조각 길이."""
best = 0
previous = [0] * (len(b) + 1)
for char in a:
current = [0]
for index, other in enumerate(b):
current.append(previous[index] + 1 if char == other else 0)
best = max(best, max(current))
previous = current
return best
def search_titles(book: Any, query: str, kind: str, limit: int = 50) -> list[dict[str, Any]]: def search_titles(book: Any, query: str, kind: str, limit: int = 50) -> list[dict[str, Any]]:
"""고르개 — 단가표에서 낱말이 **모두** 든(코드·이름·규격) 항목. 단가가 섰는지도 함께.""" """고르개 — 단가표 항목 후보. 단가가 섰는지도 함께.
낱말이 **모두** (코드·이름·규격) 항목이 먼저, 그다음 ** 글자 이상 조각이 겹친** 항목을
겹친 글자 순으로(). 고정형 이름(깬잡석채집) 단가표 제목(막돌 채집) 낱말이 달라
전부 포함 방식으로는 후보가 0 이었음. 후보만 고르는 것은 사람(명세 2 · 별칭표로 맞춤).
"""
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
kinds = SEARCH_KINDS[kind] kinds = SEARCH_KINDS[kind]
words = query.lower().split() words = query.lower().split()
found: list[dict[str, Any]] = [] # 조각 비교는 글자·숫자만 — 괄호 「(장비)」 같은 기호가 겹친 글자 수를 부풀리지 않게.
for title in book.titles.values() if words else (): compact = re.sub(r"[\W_]+", "", query.lower())
if len(found) >= limit: scored: list[tuple[int, int, int, Any]] = []
break for order, title in enumerate(book.titles.values() if words else ()):
if title.kind.value not in kinds: if title.kind.value not in kinds:
continue continue
text = f"{title.code} {title.name} {title.spec}".lower() text = f"{title.code} {title.name} {title.spec}".lower()
if all(word in text for word in words): if all(word in text for word in words):
try: scored.append((1, len(compact), order, title))
money = book.resolve(title.code) continue
total: float | None = float(money.material + money.labor + money.expense) overlap = _longest_common(
except PriceBookError: compact, re.sub(r"[\W_]+", "", f"{title.name}{title.spec}".lower())
total = None )
found.append( if overlap >= MIN_FRAGMENT:
{ scored.append((0, overlap, order, title))
"code": title.code, scored.sort(key=lambda item: (-item[0], -item[1], item[2]))
"name": title.name, found: list[dict[str, Any]] = []
"spec": title.spec, for _whole, _overlap, _order, title in scored[:limit]:
"unit": _unit(title.unit), try:
"price": total, money = book.resolve(title.code)
} total: float | None = float(money.material + money.labor + money.expense)
) except PriceBookError:
total = None
found.append(
{
"code": title.code,
"name": title.name,
"spec": title.spec,
"unit": _unit(title.unit),
"price": total,
}
)
return found return found
@@ -116,6 +116,10 @@ EXPANDERS = {
"soil_guard": lambda h, l, o, f=None, r="": _soil_guard()(o), "soil_guard": lambda h, l, o, f=None, r="": _soil_guard()(o),
# 바닥막이는 **돌붙임 ㎡당**이라 높이·연장이 아니라 **면적**이 밑수다(정본 「돌붙임L3=…」). # 바닥막이는 **돌붙임 ㎡당**이라 높이·연장이 아니라 **면적**이 밑수다(정본 「돌붙임L3=…」).
"bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o), "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),
# 콘크리트 포장 — 면적 × 두께 · 거푸집 2L · 수축줄눈(교본 3-2 · 부록 4-7 · 소광 · 2026-09-15).
"pavement_concrete": lambda h, l, o, f=None, r="": _pavement().concrete_pavement(l, o),
# ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견). # ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견).
# 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.** # 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.**
# 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝). # 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝).
@@ -126,6 +130,20 @@ EXPANDERS = {
} }
def _box_culvert():
"""BOX암거 전개 함수를 늦게 가져온다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Box import box_culvert
return box_culvert
def _pavement():
"""콘크리트 포장 모듈을 늦게 가져온다."""
from B08_Quantity import B08_Quantity_Engine_UnitQuantity_Pavement
return B08_Quantity_Engine_UnitQuantity_Pavement
def _soil_guard(): def _soil_guard():
"""흙막이 사유 함수를 늦게 가져온다.""" """흙막이 사유 함수를 늦게 가져온다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import soil_guard from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import soil_guard
@@ -162,17 +180,29 @@ def _revetment():
#: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다. #: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다.
EXPANDER_WITHHELD: dict[str, str] = {} EXPANDER_WITHHELD: dict[str, str] = {
# 2026-09-15 브레인 판정 ⑥ — 찾아봤으나 없음(「아직 안 만듦」과 다름).
"ford_bridge": (
"세월교 수량 근거 없음 — 법령·임도기술교본은 설치 조건만(그림 3-18 은 배치 모식도)"
" · 실무 원본에 세월교 시트·도면 없음 · 관은 배수관 줄로 따로 섬"
),
}
#: 전개식이 **개소당·㎡당**으로 값을 내는 종류의 내역 단위와 수량(2026-09-14 구조물도 그림에서 잡음). #: 전개식이 **개소당·㎡당**으로 값을 내는 종류의 내역 단위와 수량(2026-09-14 구조물도 그림에서 잡음).
#: ⚠ 안 적으면 「m · 연장」으로 읽혀 — 골막이·바닥막이(점 배치라 연장 0)는 **수량 0 이라 내역에 안 서고** #: ⚠ 안 적으면 「m · 연장」으로 읽혀 — 골막이·바닥막이(점 배치라 연장 0)는 **수량 0 이라 내역에 안 서고**
#: 구조물도가 전 줄 「단위당 못 냄」, 떼흙막이는 개소당 값이 연장으로 나뉘어 **떼 0.139/m** 로 보였음. #: 구조물도가 전 줄 「단위당 못 냄」, 떼흙막이는 개소당 값이 연장으로 나뉘어 **떼 0.139/m** 로 보였음.
EXPANDER_BILLING: dict[str, Any] = { EXPANDER_BILLING: dict[str, Any] = {
"erosion_check": lambda options: ("개소", 1.0), # 정본 「골막이(찰)(치수조서연결)」 개소당 "erosion_check": lambda options, length: ("개소", 1.0), # 정본 「골막이(찰)」 개소당
"soil_guard": lambda options: ("개소", 1.0), # 정본 「떼흙막이」 개소당(평균 붙박이) "soil_guard": lambda options, length: ("개소", 1.0), # 정본 「떼흙막이」 개소당(평균 붙박이)
"bed_sill": lambda options: ("", _num(options.get("area_m2"), 0.0)), # 정본 돌붙임 ㎡당 "bed_sill": lambda options, length: ("", _num(options.get("area_m2"), 0.0)), # 돌붙임 ㎡당
"box_culvert": lambda options, length: ("m", _num(options.get("length_m"), 0.0)), # 점 배치
"pavement_concrete": lambda options, length: _pavement().billing(length, options), # 면적 ㎡
} }
#: 연장을 **구간(끝 − 시작)** 에서 읽는 종류 — 포장은 물넘이로 나뉘면 길이 칸(10)이 조각마다
#: 그대로 복사돼 칸 값이 구간과 안 맞음(`B05_Profile_UI_Structures_Panel_Commit` 나누기).
SPAN_LENGTH_TYPES = frozenset({"pavement_concrete"})
#: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다 #: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다
#: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다. #: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다.
@@ -272,6 +302,11 @@ def expand(
start = _num(structure.get("start_m")) start = _num(structure.get("start_m"))
end = _num(structure.get("end_m")) end = _num(structure.get("end_m"))
length = _num(options.get("length_m")) or abs(end - start) length = _num(options.get("length_m")) or abs(end - start)
if type_id in SPAN_LENGTH_TYPES and None not in (
structure.get("start_m"),
structure.get("end_m"),
):
length = abs(end - start)
height = _num(options.get("height_m")) height = _num(options.get("height_m"))
# ⚠ 레지스트리 이름이 없으면 **코드값(`retaining_wall`)이 그대로 내역에 뜬다** — # ⚠ 레지스트리 이름이 없으면 **코드값(`retaining_wall`)이 그대로 내역에 뜬다** —
# `_Wording.type_label` 이 이미 대비표를 들고 있으므로 그것을 쓴다(2026-09-09 감사). # `_Wording.type_label` 이 이미 대비표를 들고 있으므로 그것을 쓴다(2026-09-09 감사).
@@ -324,10 +359,19 @@ def expand(
result.notes.extend(notes) result.notes.extend(notes)
billing = EXPANDER_BILLING.get(type_id) billing = EXPANDER_BILLING.get(type_id)
if billing is not None and result.components: if billing is not None and result.components:
result.billing_unit, result.billing_quantity = billing(options) result.billing_unit, result.billing_quantity = billing(options, length)
return result 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]: def verify_no_mix_components(quantities: Iterable[StructureQuantity]) -> list[str]:
"""⚠ 배합 성분이 산출물에 섞이면 알린다 (㉢ 이중계상 방어). """⚠ 배합 성분이 산출물에 섞이면 알린다 (㉢ 이중계상 방어).
@@ -495,6 +539,21 @@ def _append_section_trench(
quantity.components.extend(Component(**row) for row in rows) 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( def build_table(
structures: Iterable[dict[str, Any]], structures: Iterable[dict[str, Any]],
names: dict[str, str] | None = None, names: dict[str, str] | None = None,
@@ -530,6 +589,7 @@ def build_table(
if rubble is not None: if rubble is not None:
quantity.components.append(rubble) quantity.components.append(rubble)
_append_section_trench(quantity, item, observed, rubble_base_thickness_m) _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) quantity.unconfirmed = str(item.get("unconfirmed") or "") or missing_height_reason(item)
quantities.append(quantity) quantities.append(quantity)
if use_templates: if use_templates:
@@ -612,8 +672,8 @@ def build_table(
"structures": payload_structures, "structures": payload_structures,
"formwork_notes": formwork_notes, "formwork_notes": formwork_notes,
"formwork_reuse_missing": formwork_missing, "formwork_reuse_missing": formwork_missing,
# 동바리 — 대상이 없으면 0 이 아니라 「없음」이라고 말한다. # 동바리 — 대상이 없으면 0 이 아니라 「없음」이라고 말한다(BOX암거 동바리 줄이 서면 대상).
"shoring": shoring_status(), "shoring": _shoring_of(quantities),
# ⚠ 값을 바꾸는 설계 조건인데 우리 제원에 칸이 없는 것 — 화면에 드러낸다. # ⚠ 값을 바꾸는 설계 조건인데 우리 제원에 칸이 없는 것 — 화면에 드러낸다.
# 「무엇을 정해야 하는지」만으로는 부족하고 **「정하면 얼마나 달라지는지」**까지. # 「무엇을 정해야 하는지」만으로는 부족하고 **「정하면 얼마나 달라지는지」**까지.
"pending_choices": (observed.pending_choices or {}).get("items") or [], "pending_choices": (observed.pending_choices or {}).get("items") or [],
@@ -58,6 +58,15 @@ DESTINATION = {
"유로폼": "unit_price", # 거푸집 계열(품셈 12-38) — 설치·해체 품 "유로폼": "unit_price", # 거푸집 계열(품셈 12-38) — 설치·해체 품
"면목": "material", "면목": "material",
"": "material", # 사면 떼(자재총괄 extra)와 같은 자리 · 할증 10%(1-3-1) "": "material", # 사면 떼(자재총괄 extra)와 같은 자리 · 할증 10%(1-3-1)
# ⭐ 2026-09-15 BOX암거 — 버림 옆면 합판 · 동바리(12-20)·비계(12-19)는 설치 품이라 묶음 조각으로.
"합판거푸집": "unit_price",
"동바리": "unit_price",
"비계": "unit_price",
# ⭐ 2026-09-15 콘크리트 포장 — 거푸집(12-8)·수축줄눈(12-7-1 절단)은 품 · 비닐·철망은 12-6 [주]② 재료 별도.
"포장거푸집": "unit_price",
"수축줄눈": "unit_price",
"비닐": "material",
"철망": "material",
} }
#: ⛔ **갈 곳 기본값 금지**(명세 13장 「빠진 것이 조용해짐」) — 표에 없는 이름은 막고 사유를 냄. #: ⛔ **갈 곳 기본값 금지**(명세 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( notes.append(
f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · " f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · "
f"{backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다" f"{backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다"
"(소광리는 같은 식에 0.30/0.60 을 씁니다)" "(소광리는 같은 식에 0.30/0.60 을 씁니다) · 칸을 새로 두는 것은 B05 등록부 몫"
) )
# ⚠ 근거 구간 밖은 **값이 서되 그 사실이 보여야 한다** — 값은 계속 나오므로 사유가 # ⚠ 근거 구간 밖은 **값이 서되 그 사실이 보여야 한다** — 값은 계속 나오므로 사유가
# 없으면 아무도 못 본다(2026-09-09 그물 침). # 없으면 아무도 못 본다(2026-09-09 그물 침).
@@ -0,0 +1,171 @@
"""콘크리트 포장 전개식 — **면적 × 두께 · 거푸집 양면 · 수축줄눈** (2026-09-15 브레인 포장 둘).
근거
임도기술교본 3-2 기본설계 포장 폭의 3.0m, 두께 0.2m를 기준으로 곡선부에는 확폭
임도기술교본 부록 4-7 7-1 3.5.1 수축줄눈의 간격은 4-6m를 기준
산림품셈 12-6 콘크리트 포장(인력) 비닐·철망깔기·포설·양생 포함 · 거푸집·줄눈 제외 ·
[] 비닐·양생재·철망 재료 별도 · 12-7-1 포장절단 · 12-8 콘크리트 포장 거푸집(연장 m)
실무 울진소광 `02-1-공종수량산출` 포장및난간개거 면적 = B × L · 거푸집(1면기준) = 2L ·
수축줄눈 = 내림(L÷6) × B · 포장확폭 면적 = 거리 × 확폭 · 신축줄눈 = 면적 ÷ 6
·확폭·두께는 **B06 이미 ** 칸만 B06 에서 잇고 칸이 이김(판정 · 원칙).
"""
from __future__ import annotations
import math
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import Component, _num, routed
SOURCES = (
"산식 = 임도기술교본 3-2(폭·두께·곡선부 확폭) · 부록 4-7 7-1(수축줄눈 4-6m) · 실무 울진소광"
" 「포장및난간개거」·「포장확폭」(면적 B×L · 거푸집 2L · 줄눈 내림(L÷간격)×B"
" · 확폭 줄눈 면적÷간격)"
)
NOTE_JOINT = (
"수축줄눈 = 12-7-1 포장절단(m)으로 이음 — 12-7-2 줄눈설치는 안 씀 — 소광 실무가 컷팅"
"(「수축줄눈(컷팅)」) · 줄눈재를 넣는 공법이면 12-7-2 로 물을 것"
)
NOTE_FORM = (
"포장거푸집 = 양쪽 가장자리 2 × 연장(소광 「1면기준」) · 12-8 이 강재 3m·핀폴·20회를 품에 둠"
)
SHEET_UNCHOSEN = (
"비닐(깔기·양생)을 안 고름 — 품셈 12-6 [주]② 「양생에 필요한 재료비(비닐, 양생재 등)는 별도"
" 계상」이라 설계자가 고를 것(기본값 없음)"
)
MESH_UNCHOSEN = (
"철망을 안 고름 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에"
" 따라」 · 실무 소광은 「철망포함」 관측 — 설계자가 고를 것(기본값 없음)"
)
MISSING = "콘크리트 포장 {what} — 칸이 비고 B06 에서도 못 읽어 수량이 안 섬 · 칸에 적을 것"
JOINT = "joint_spacing_m"
def _stations(designs: list[dict[str, Any]] | None) -> list[tuple[float, dict[str, Any]]]:
rows = [
(_num(item.get("chainage_m")), item.get("design"))
for item in designs or ()
if isinstance(item, dict) and isinstance(item.get("design"), dict)
]
return sorted(rows, key=lambda row: row[0])
def _widening(design: dict[str, Any]) -> float:
return _num(design.get("widening_left_m")) + _num(design.get("widening_right_m"))
def _widening_at(stations: list[tuple[float, dict[str, Any]]], at: float) -> float:
"""그 자리의 확폭 — 앞뒤 측점 사이 직선 보간 · 끝 밖이면 가장 가까운 측점."""
if at <= stations[0][0]:
return _widening(stations[0][1])
for (s0, d0), (s1, d1) in zip(stations, stations[1:]):
if s0 <= at <= s1:
t = 0.0 if s1 == s0 else (at - s0) / (s1 - s0)
return _widening(d0) + (_widening(d1) - _widening(d0)) * t
return _widening(stations[-1][1])
def pavement_from_designs(
structure: dict[str, Any], designs: list[dict[str, Any]] | None
) -> dict[str, Any]:
"""빈 칸(폭·확폭·두께)을 B06 구간 측점에서 채운 **새** 구조물 — 칸이 차 있으면 그대로."""
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
options = dict(structure.get("options") or {})
notes = list(structure.get("notes") or [])
# 상세 칸은 배치 폼에 없어 제안값이 저장에 안 들어감 — 등록부 한 벌에서 채움(값 두 벌 금지).
spacing = next(o for o in structure_type_map()["pavement_concrete"].options if o.key == JOINT)
if options.get(JOINT) in (None, "") and spacing.default is not None:
options[JOINT] = spacing.default
notes.append(f"수축줄눈 간격 {spacing.default:g}m — {spacing.default_basis}")
stations = _stations(designs)
start, end = sorted((_num(structure.get("start_m")), _num(structure.get("end_m"))))
inside = [design for chainage, design in stations if start <= chainage <= end]
if stations and not inside:
inside = [min(stations, key=lambda row: abs(row[0] - (start + end) / 2))[1]]
if options.get("width_m") in (None, "") and inside:
widths = {round(_num(d.get("carriageway_standard_width_m")), 3) for d in inside} - {0.0}
if len(widths) == 1:
options["width_m"] = widths.pop()
notes.append(f"포장 폭 {options['width_m']:g}m — B06 노폭(구간 측점 {len(inside)}곳)")
elif widths:
notes.append(f"포장 폭 — 구간 측점의 B06 노폭이 갈림({sorted(widths)}) · 칸에 적을 것")
if options.get("thickness_cm") in (None, "") and inside:
thick = {round(_num(d.get("pavement_thickness_m")), 4) for d in inside} - {0.0}
if len(thick) == 1:
options["thickness_cm"] = round(thick.pop() * 100, 2)
notes.append(f"포장 두께 {options['thickness_cm']:g}㎝ — B06 포장층 두께")
if options.get("widening_area_m2") in (None, "") and stations and end > start:
cuts = [start, *[s for s, _d in stations if start < s < end], end]
area = sum(
(_widening_at(stations, a) + _widening_at(stations, b)) / 2 * (b - a)
for a, b in zip(cuts, cuts[1:])
)
options["widening_area_m2"] = round(area, 4)
notes.append(f"확폭 면적 {area:.2f}㎡ — B06 곡선부 확폭 × 측점 사이 거리(평균)")
return {**structure, "options": options, "notes": notes}
def concrete_pavement(length: float, options: dict[str, Any]) -> tuple[list[Component], list[str]]:
"""콘크리트 포장 한 구간 — (성분, 사유). 면적 밑수가 없으면 성분 없이 사유만."""
width = _num(options.get("width_m"))
thickness = _num(options.get("thickness_cm")) / 100
widening = options.get("widening_area_m2")
missing = [
what
for what, value in (
("연장", length),
("", width),
("두께", thickness),
("확폭 면적", 1.0 if isinstance(widening, (int, float)) else 0.0),
)
if value <= 0
]
if missing:
return [], [MISSING.format(what="·".join(missing))]
spacing = _num(options.get("joint_spacing_m"))
widening = float(widening)
area = width * length + widening
rows: list[tuple[str, str, float, str]] = [
(
"콘크리트",
"",
area * thickness,
f"(폭 {width:g} × 연장 {length:g} + 확폭 {widening:g}) × 두께 {thickness:g}",
),
("포장거푸집", "m", 2 * length, f"양쪽 2 × 연장 {length:g}"),
]
notes = [SOURCES, NOTE_FORM]
if spacing > 0:
joints = math.floor(length / spacing) * width + widening / spacing
rows.append(
(
"수축줄눈",
"m",
joints,
f"내림({length:g} ÷ {spacing:g}) ×{width:g} + 확폭 {widening:g} ÷ {spacing:g}",
)
)
notes.append(NOTE_JOINT)
for key, name, unchosen in (
("separation_sheet", "비닐", SHEET_UNCHOSEN),
("wire_mesh", "철망", MESH_UNCHOSEN),
):
choice = options.get(key)
if choice == "있음":
rows.append((name, "", area, f"포장 면적 {area:.4g}"))
elif choice != "없음":
notes.append(unchosen)
components = [
Component(name, unit, amount, destination, basis)
for name, unit, amount, basis in rows
if (destination := routed(name, notes))
]
return components, notes
def billing(length: float, options: dict[str, Any]) -> tuple[str, float]:
"""내역 줄 — 포장 면적 ㎡(소광 「콘크리트포장 T=20 ㎡」)."""
widening = options.get("widening_area_m2")
extra = float(widening) if isinstance(widening, (int, float)) else 0.0
return "", _num(options.get("width_m")) * length + extra
+52 -5
View File
@@ -34,13 +34,25 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import (
FACE_DRESSING_CUT_CLASSES, FACE_DRESSING_CUT_CLASSES,
FACE_DRESSING_FILL_CLASSES, FACE_DRESSING_FILL_CLASSES,
FACE_DRESSING_FILL_SUGGESTED, FACE_DRESSING_FILL_SUGGESTED,
ROOT_REMOVAL_EXCAVATOR_SIZES,
ROOT_REMOVAL_EXCAVATOR_SUGGESTED,
SEED_SPRAY_GROUNDS,
SummaryInput, SummaryInput,
) )
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table 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_EarthworkTable import StationArea, build_table
from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping 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 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_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_SlopeArea import build_table as build_slope_table
from B08_Quantity.B08_Quantity_Engine_SlopeLength import road_surface_area, station_slopes from B08_Quantity.B08_Quantity_Engine_SlopeLength import road_surface_area, station_slopes
@@ -51,10 +63,11 @@ from common_util.common_util_project_settings import (
application_ratio, application_ratio,
concrete_placing_method, concrete_placing_method,
earthwork_conversion_choices, earthwork_conversion_choices,
earthwork_conversion_factors, mixed_conversion_factors,
haul_limit_choice, haul_limit_choice,
quantity_settings, quantity_settings,
rock_classes, rock_classes,
rock_method,
save_section, save_section,
topsoil_target, topsoil_target,
) )
@@ -93,7 +106,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
) )
# ⚠ 설정을 **먼저** 읽는다 — 토량환산계수를 프로젝트가 골랐으면 표가 그 값으로 서야 한다. # ⚠ 설정을 **먼저** 읽는다 — 토량환산계수를 프로젝트가 골랐으면 표가 그 값으로 서야 한다.
settings, project_root = await _project_settings(project_id) 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 = build_table(_stations(designs), factors)
# 화면이 「무엇을 골랐나 · 품셈 범위 안인가」를 보이는 데 쓴다. 계산에는 안 들어간다. # 화면이 「무엇을 골랐나 · 품셈 범위 안인가」를 보이는 데 쓴다. 계산에는 안 들어간다.
table["conversion_factor_choices"] = earthwork_conversion_choices(settings) table["conversion_factor_choices"] = earthwork_conversion_choices(settings)
@@ -112,10 +126,15 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
plan = await _stored_haul_plan(project_id, route_id) plan = await _stored_haul_plan(project_id, route_id)
haul = build_haul_table(plan, factors) 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`)이고 없으면 막힌다. # ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다.
haul["spoil"] = _spoil_of(plan, settings, _spoil_sites(designs)) haul["spoil"] = _spoil_of(plan, settings, _spoil_sites(designs))
haul["borrow"] = borrow_of(plan) # 토취(반입토) — 수량만 · 금액은 사유(브레인 ①)
# 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.** # 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.**
# 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다. # 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다.
table["pipe_lengths"] = [ table["pipe_lengths"] = [
@@ -148,6 +167,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
earthwork_totals=table.get("totals") or {}, earthwork_totals=table.get("totals") or {},
slope_totals=slope.get("totals") or {}, slope_totals=slope.get("totals") or {},
haul_rows=summary_input_rows(haul), 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_classes=rock_classes(settings),
rock_ratios_pct=settings.get("rock_ratios_pct") or {}, rock_ratios_pct=settings.get("rock_ratios_pct") or {},
application_ratios={ application_ratios={
@@ -173,6 +194,16 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
"basis": FACE_DRESSING_FILL_SUGGESTED[1], "basis": FACE_DRESSING_FILL_SUGGESTED[1],
}, },
} }
# 제근 굴착기 크기 — 선택지·제안(회색 · [제안값 넣기])은 서버 한 곳(2026-09-14 브레인).
table["root_removal_excavator_choices"] = {
"choices": list(ROOT_REMOVAL_EXCAVATOR_SIZES),
"suggested": {
"value": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[0],
"basis": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[1],
},
}
# 초류종자살포 비탈면 토질 — 5-24 잎 둘(서버 한 곳) · 제안값 없음(2026-09-14 브레인 ㉮).
table["seed_spray_choices"] = {"choices": list(SEED_SPRAY_GROUNDS)}
# 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨). # 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨).
structures = await _route_structures(project_id) structures = await _route_structures(project_id)
table["preparation"] = build_preparation_table( table["preparation"] = build_preparation_table(
@@ -227,7 +258,7 @@ _GROUND_KIND_OF = {"ea_m3": "soil", "rr_m3": "ripping_rock", "br_m3": "blasting_
def _compacted_factor(settings: dict[str, Any]) -> dict[str, float]: def _compacted_factor(settings: dict[str, Any]) -> dict[str, float]:
"""갈래 칸 ↔ 다짐 환산계수 `C` — 프로젝트가 고른 값이 있으면 그것이 선다.""" """갈래 칸 ↔ 다짐 환산계수 `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()} return {key: float(factors[kind]["compacted"]) for key, kind in _GROUND_KIND_OF.items()}
@@ -478,6 +509,10 @@ class QuantitySettingsBody(BaseModel):
stand_volume_class: str | None = None stand_volume_class: str | None = None
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질(9-19-1 원문 표). `""` 는 「안 정함」. # 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질(9-19-1 원문 표). `""` 는 「안 정함」.
face_dressing_cut_class: str | None = None 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 face_dressing_fill_class: str | None = None
# 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ). # 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ).
face_dressing_fill_area_m2: float | None = None face_dressing_fill_area_m2: float | None = None
@@ -556,6 +591,8 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
for key, choices in ( for key, choices in (
("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES), ("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES),
("face_dressing_fill_class", FACE_DRESSING_FILL_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: if key in values and values[key] not in choices:
values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침 values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침
@@ -600,6 +637,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
for name, method in values["rock_methods"].items() for name, method in values["rock_methods"].items()
if method in ROCK_METHODS if method in ROCK_METHODS
} }
before = {key: quantity_settings(root).get(key) for key in HAUL_PLAN_KEYS}
try: try:
# ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면 # ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면
# 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리). # 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리).
@@ -627,7 +665,16 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
status_code=500, status_code=500,
content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."}, 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( 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
+13 -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_HaulInputs import haul_inputs
from B08_Quantity.B08_Quantity_Engine_Preparation import frame_material_rows 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_storage import resolve_stored_project_path
from common_util.common_util_structure_lengths import structure_lengths from common_util.common_util_structure_lengths import structure_lengths
from config.config_db import run_with_connection from config.config_db import run_with_connection
@@ -96,6 +97,11 @@ def _collect_structures(
if definition.reference_only: if definition.reference_only:
skipped.append(f"{definition.name}: 전문 상세설계 대상 — 배치까지만") skipped.append(f"{definition.name}: 전문 상세설계 대상 — 배치까지만")
continue continue
if type_id == "pavement_concrete":
# 폭·확폭·두께는 B06 이 앎 — 빈 칸만 이음(2026-09-15 브레인 포장 둘 ①④).
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Pavement import pavement_from_designs
payload = pavement_from_designs(payload, designs)
if definition.group == "B": if definition.group == "B":
# ⚠ B군(종단배수)은 **연장표**로 간다 — `common_util_structure_lengths` 가 # ⚠ B군(종단배수)은 **연장표**로 간다 — `common_util_structure_lengths` 가
# 겹친 구간을 합쳐 주기 때문이다. 구조물별로 세면 겹친 구간을 두 번 센다. # 겹친 구간을 합쳐 주기 때문이다. 구조물별로 세면 겹친 구간을 두 번 센다.
@@ -172,7 +178,9 @@ def material_table_for(
surcharge_overrides=settings.get("material_surcharge") or {}, surcharge_overrides=settings.get("material_surcharge") or {},
# 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥). # 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥).
concrete_placing_method=settings.get("concrete_placing_method"), 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 []),
) )
@@ -497,6 +505,10 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질. 비면 그 줄이 입력 사유로 막힘. # 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질. 비면 그 줄이 입력 사유로 막힘.
face_dressing_cut_class=settings.get("face_dressing_cut_class") or None, face_dressing_cut_class=settings.get("face_dressing_cut_class") or None,
face_dressing_fill_class=settings.get("face_dressing_fill_class") or None, face_dressing_fill_class=settings.get("face_dressing_fill_class") or None,
# 제근 굴착기 크기(0.2·0.7) — 비면 뿌리뽑기 줄이 입력 사유로 막힘(제안 0.7 은 칸 곁에만).
root_removal_excavator_m3=settings.get("root_removal_excavator_m3") or None,
# 초류종자살포 비탈면 토질 — 부모 5-24 의 잎을 고름. 비면 그 줄이 입력 사유로 막힘(09-14 ㉮).
seed_spray_ground=settings.get("seed_spray_ground") or None,
# 구조물도 양식 일위대가로 셀 장 — 그 구조물은 호표 `AX-ST` 줄 하나로(PLAN 6장 ②). # 구조물도 양식 일위대가로 셀 장 — 그 구조물은 호표 `AX-ST` 줄 하나로(PLAN 6장 ②).
priced_sheets=_priced_sheets(project_root, unit_table, modes, settings), priced_sheets=_priced_sheets(project_root, unit_table, modes, settings),
) )
@@ -6,15 +6,18 @@
[넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 서버가 **파일을 다시 읽어** 개인 단에 . [넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 서버가 **파일을 다시 읽어** 개인 단에 .
브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5) · 개인 단만(판정 ) · 종류당 하나라 같은 종류 것은 브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5) · 개인 단만(판정 ) · 종류당 하나라 같은 종류 것은
덮어씀(판정 ) · 종류는 사용자가 고름 이름으로 자동으로 붙임(판정 ). 덮어씀(판정 ) · 종류는 사용자가 고름 이름으로 자동으로 붙임(판정 ).
[복제해서 것으로](PLAN 4) 기본·회사 항목을 개인 단에 베낌. 프로젝트 작업본은 바꿈.
(구조물도 라우터가 700줄에 닿아 개인 단으로 넣는 창구를 파일에 모음.)
""" """
import asyncio import asyncio
import io import io
from typing import Any from typing import Any, Literal
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, File, Form, UploadFile from fastapi import APIRouter, Depends, File, Form, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from common_util.common_util_auth import verify_session from common_util.common_util_auth import verify_session
@@ -110,3 +113,107 @@ async def save_stmate_recipe(
"note": item["note"], "note": item["note"],
} }
) )
class LibraryCloneRequest(BaseModel):
"""베낄 항목 — 단과 코드(이름은 겹칠 수 있음). 개인 단 것은 이미 내 것이라 안 받음."""
model_config = ConfigDict(extra="forbid")
type_id: str = Field(min_length=1, max_length=100)
tier: Literal["received", "company", "program"]
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
@router.put("/{project_id}/quantity/structure-sheets/library/clone")
async def clone_library_item(
project_id: UUID,
payload: LibraryCloneRequest,
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""③ 기본·회사 항목을 **로그인한 사람 개인 단**에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]할 길을 한 번에."""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
find_item,
save_item_personal,
tier_dirs,
)
dirs = tier_dirs(session.get("company_id"), session.get("user_id"))
folder = dirs.get("personal")
if folder is None:
return _error(403, "개인 라이브러리는 회사에 속한 사용자만 씁니다.")
item = await asyncio.to_thread(find_item, dirs, payload.tier, payload.code)
if item is None or item.get("type_id") != payload.type_id:
return _error(404, "베낄 항목을 찾지 못했습니다.")
body = {k: v for k, v in item.items() if k not in ("code", "imported_from", "library_tier")}
body["cloned_from"] = {"tier": payload.tier, "code": payload.code, "name": item.get("name")}
code = await asyncio.to_thread(save_item_personal, folder, body)
return JSONResponse(content={"status": "success", "code": code, "name": item.get("name")})
async def _company_members(company_id: int) -> list[dict[str, Any]]:
"""같은 회사 구성원 — B01 구성원 저장소를 읽기만(시험이 갈아 끼움)."""
from B01_Dashboard.B01_Dashboard_Repository_Members import list_company_members
return await list_company_members(company_id)
@router.get("/{project_id}/quantity/structure-sheets/library/colleagues")
async def list_library_colleagues(
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
) -> JSONResponse:
"""④ 보낼 동료 — 같은 회사에서 나를 뺀 사람(이름만)."""
company_id = session.get("company_id")
if company_id is None:
return _error(403, "회사에 속한 사용자만 보냅니다.")
members = await _company_members(int(company_id))
colleagues = [
{"id": m["id"], "name": m.get("name") or m.get("email") or ""}
for m in members
if m["id"] != session.get("user_id")
]
return JSONResponse(content={"status": "success", "colleagues": colleagues})
class LibraryShareRequest(BaseModel):
"""내 개인 단 항목 하나를 같은 회사 동료에게."""
model_config = ConfigDict(extra="forbid")
type_id: str = Field(min_length=1, max_length=100)
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
to_user_id: int
@router.put("/{project_id}/quantity/structure-sheets/library/share")
async def share_library_item(
project_id: UUID,
payload: LibraryShareRequest,
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""④ [동료에게 보내기] — 받는 쪽 「받음」 단에 복사(PLAN 4장 공유 · 브레인 판정 ①)."""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
RECEIVED_SUBDIR,
find_item,
share_item,
tier_dirs,
)
company_id = session.get("company_id")
dirs = tier_dirs(company_id, session.get("user_id"))
if "personal" not in dirs:
return _error(403, "회사에 속한 사용자만 보냅니다.")
if payload.to_user_id == session.get("user_id"):
return _error(400, "나에게는 보내지 않습니다.")
members = {m["id"]: m for m in await _company_members(int(company_id))}
target = members.get(payload.to_user_id)
if target is None:
return _error(404, "같은 회사에서 받는 사람을 찾지 못했습니다.")
item = await asyncio.to_thread(find_item, dirs, "personal", payload.code)
if item is None or item.get("type_id") != payload.type_id:
return _error(404, "보낼 내 항목을 찾지 못했습니다.")
me = members.get(session.get("user_id")) or {}
sender = {"user_id": session.get("user_id"), "name": me.get("name") or me.get("email") or ""}
to_folder = dirs["personal"].parents[1] / str(payload.to_user_id) / RECEIVED_SUBDIR
code = await asyncio.to_thread(share_item, item, to_folder, sender)
return JSONResponse(content={"status": "success", "code": code, "to": target.get("name") or ""})
@@ -316,6 +316,7 @@ async def get_structure_library(
"status": "success", "status": "success",
"items": await asyncio.to_thread(list_items, dirs, type_id), "items": await asyncio.to_thread(list_items, dirs, type_id),
"current": {"code": current.get("code"), "imported_from": current.get("imported_from")}, "current": {"code": current.get("code"), "imported_from": current.get("imported_from")},
"can_publish": {tier: _publish_dir(session, tier) is not None for tier in PUBLISH},
} }
) )
@@ -326,7 +327,7 @@ class LibraryImportRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
type_id: str = Field(min_length=1, max_length=100) type_id: str = Field(min_length=1, max_length=100)
tier: Literal["personal", "company", "program"] tier: Literal["personal", "received", "company", "program"]
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$") code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
@@ -394,15 +395,33 @@ def _no_personal() -> JSONResponse:
) )
#: 발행 단 — 회사는 마스터·시스템 관리자, 프로그램 기본은 시스템 관리자(2026-09-14 브레인 승인).
PUBLISH = ("company", "program")
def _publish_dir(session: dict[str, Any], tier: str) -> Path | None:
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import program_library_dir, tier_dirs
admin = session.get("role") == "SYSTEM_ADMIN"
if tier == "program":
return program_library_dir() if admin else None
if tier == "company" and (admin or session.get("is_master")):
return tier_dirs(session.get("company_id"), None).get("company")
return _personal_dir(session) if tier == "personal" else None
class LibrarySaveRequest(BaseModel): class LibrarySaveRequest(BaseModel):
"""[내 라이브러리에 저장] — 어느 장의 양식을 쓸지.""" """[내 라이브러리에 저장]·[발행] — 어느 장의 양식을 어느 단에 쓸지(같은 모양)."""
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
sheet_key: str sheet_key: str
tier: Literal["personal", "company", "program"] = "personal"
name: str | None = Field(default=None, max_length=200) # 이름표(10-A ⑫) · 비면 양식 이름
@router.put("/{project_id}/quantity/structure-sheets/library/personal") @router.put("/{project_id}/quantity/structure-sheets/library/personal")
@router.put("/{project_id}/quantity/structure-sheets/library/publish")
async def put_structure_library_personal( async def put_structure_library_personal(
project_id: UUID, project_id: UUID,
payload: LibrarySaveRequest, payload: LibrarySaveRequest,
@@ -412,17 +431,18 @@ async def put_structure_library_personal(
프로젝트 작업본은 바꿈 반대 방향(작업본 개인 )이라(판정 ). 프로젝트 작업본은 바꿈 반대 방향(작업본 개인 )이라(판정 ).
""" """
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import ( from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates, save_personal
project_templates,
save_personal,
)
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY, template_of from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY, template_of
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
from common_util.common_util_project_settings import quantity_settings from common_util.common_util_project_settings import quantity_settings
folder = _personal_dir(session) folder = _publish_dir(session, payload.tier)
if folder is None: if folder is None:
return _no_personal() if payload.tier == "personal":
return _no_personal()
return JSONResponse(
status_code=403, content={"status": "error", "message": "발행 권한이 없습니다."}
)
project_root = await _project_root(project_id) project_root = await _project_root(project_id)
if project_root is None: if project_root is None:
return _not_found() return _not_found()
@@ -439,7 +459,8 @@ async def put_structure_library_personal(
overrides = (settings.get(OVERRIDES_KEY) or {}).get(type_id) overrides = (settings.get(OVERRIDES_KEY) or {}).get(type_id)
# ⛔ 수동 단가(`MANUAL_KEY`)는 안 넘김 — 프로젝트의 값(브레인 판정). # ⛔ 수동 단가(`MANUAL_KEY`)는 안 넘김 — 프로젝트의 값(브레인 판정).
rows = (settings.get(ROWS_KEY) or {}).get(type_id) rows = (settings.get(ROWS_KEY) or {}).get(type_id)
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows) tier, name = payload.tier, payload.name
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows, tier, name)
return JSONResponse(content={"status": "success", "code": code, "edited": len(overrides or {})}) return JSONResponse(content={"status": "success", "code": code, "edited": len(overrides or {})})
@@ -100,6 +100,10 @@ export interface QuantitySettings {
ancillary_counts?: Record<string, number | null>; ancillary_counts?: Record<string, number | null>;
/** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */ /** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */
stand_volume_class?: string | null; 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_cut_class?: string | null;
face_dressing_fill_class?: string | null; face_dressing_fill_class?: string | null;
@@ -162,6 +166,13 @@ export interface EarthworkTable {
conversion_factor_choices?: Record<string, ConversionFactorChoice>; conversion_factor_choices?: Record<string, ConversionFactorChoice>;
/** 면고르기 갈래 선택지 — 품셈 9-19-1 원문 표 두 벌(서버 한 곳). 화면은 그대로 보임. */ /** 면고르기 갈래 선택지 — 품셈 9-19-1 원문 표 두 벌(서버 한 곳). 화면은 그대로 보임. */
face_dressing_choices?: { cut: string[]; fill: string[] }; 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[]; conversion_factor_pumsem_ranges?: PumsemRange[];
/** 도쟈 한계거리 — 지금 값·기본값·근거(서버가 정본). 종무대 20 m 는 규정이라 값만 보인다. */ /** 도쟈 한계거리 — 지금 값·기본값·근거(서버가 정본). 종무대 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 { renderStructureSummary } from "./B08_Quantity_UI_StructureSummary";
import { appendTreeWasteFields } from "./B08_Quantity_UI_Side_TreeWaste"; import { appendTreeWasteFields } from "./B08_Quantity_UI_Side_TreeWaste";
import { appendFaceDressingFields } from "./B08_Quantity_UI_Side_FaceDressing"; 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"; import { appendBenchCutFields } from "./B08_Quantity_UI_Side_BenchCut";
/** locale 헬퍼 */ /** locale 헬퍼 */
@@ -109,6 +111,8 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
// `""` 는 기본(노면 + 절토)으로 되돌림 — 서버가 None 으로 둔다. // `""` 는 기본(노면 + 절토)으로 되돌림 — 서버가 None 으로 둔다.
topsoil_target: draft.topsoil_target, topsoil_target: draft.topsoil_target,
stand_volume_class: draft.stand_volume_class, stand_volume_class: draft.stand_volume_class,
root_removal_excavator_m3: draft.root_removal_excavator_m3,
seed_spray_ground: draft.seed_spray_ground,
// 면고르기 — 갈래 `""`·면적 `null` 도 그대로(「안 정함」·「파종 면적 그대로」로 되돌리는 길). // 면고르기 — 갈래 `""`·면적 `null` 도 그대로(「안 정함」·「파종 면적 그대로」로 되돌리는 길).
face_dressing_cut_class: draft.face_dressing_cut_class, face_dressing_cut_class: draft.face_dressing_cut_class,
face_dressing_fill_class: draft.face_dressing_fill_class, face_dressing_fill_class: draft.face_dressing_fill_class,
@@ -307,6 +311,10 @@ interface DraftSettings {
ancillary_counts: Record<string, number | null>; ancillary_counts: Record<string, number | null>;
// 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①). // 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①).
stand_volume_class: string; 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`. // 면고르기 — 갈래 둘(서버 선택지) · 면적 덮어쓰기 둘(`null` = 파종 면적). 칸은 `_Side_FaceDressing`.
face_dressing_cut_class: string; face_dressing_cut_class: string;
face_dressing_fill_class: string; face_dressing_fill_class: string;
@@ -521,13 +529,10 @@ function buildQuantitySidePanel(
} }
} }
// ── 면고르기 — 밑수가 파종 면적이라 반영률 바로 밑. 선택지는 서버 목록 그대로(칸은 따로 뺀 파일). // ── 초류종자살포 토질 · 면고르기 — 밑수가 파종 면적이라 반영률 바로 밑. 선택지는 서버 목록 그대로.
appendFaceDressingFields(panel, draft, table?.face_dressing_choices, { const sideHelpers = { field, optionalNumberField, selectField, hintRow };
field, appendSeedSprayField(panel, draft, table?.seed_spray_choices, sideHelpers);
optionalNumberField, appendFaceDressingFields(panel, draft, table?.face_dressing_choices, sideHelpers);
selectField,
hintRow,
});
// ── 표토 두께 — 표토 운반 부피(제거 ㎡ × T)에 씀 · 제거 줄은 ㎡ 라 안 곱함(2026-09-13 「실무대로」) ── // ── 표토 두께 — 표토 운반 부피(제거 ㎡ × T)에 씀 · 제거 줄은 ㎡ 라 안 곱함(2026-09-13 「실무대로」) ──
// ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.** // ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.**
@@ -637,6 +642,12 @@ function buildQuantitySidePanel(
), ),
); );
panel.append(hintRow(L("B08_Quantity_Side_StandVolume_Hint"))); panel.append(hintRow(L("B08_Quantity_Side_StandVolume_Hint")));
appendRootRemovalFields(panel, draft, table?.root_removal_excavator_choices, {
field,
optionalNumberField,
selectField,
hintRow,
});
// ── 부대시설 개소 — ⚠ **산식으로 만들지 않는다**(확정 13). 넣어야 줄이 선다 ── // ── 부대시설 개소 — ⚠ **산식으로 만들지 않는다**(확정 13). 넣어야 줄이 선다 ──
panel.append(field(L("B08_Quantity_Side_Ancillary"), "")); 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_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null,
topsoil_target: (stored.topsoil_target as string) ?? "", topsoil_target: (stored.topsoil_target as string) ?? "",
stand_volume_class: (stored.stand_volume_class 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_cut_class: (stored.face_dressing_cut_class as string) ?? "",
face_dressing_fill_class: (stored.face_dressing_fill_class as string) ?? "", face_dressing_fill_class: (stored.face_dressing_fill_class as string) ?? "",
face_dressing_fill_area_m2: (stored.face_dressing_fill_area_m2 as number | null) ?? null, face_dressing_fill_area_m2: (stored.face_dressing_fill_area_m2 as number | null) ?? null,
@@ -5,11 +5,13 @@
* *
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4). * (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* ** **(`face_dressing_choices`) ( ). * ** **(`face_dressing_choices`) ( ).
* ( ) . * ( ) .
* + [ ] ** **( 976e5fd9 `fill_suggested`).
* ( × ) . * ( × ) .
* ========================================================================== */ * ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; 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"; import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste";
function L(key: keyof typeof ui_locales): string { function L(key: keyof typeof ui_locales): string {
@@ -29,6 +31,8 @@ export interface FaceDressingDraft {
export interface FaceDressingChoices { export interface FaceDressingChoices {
cut: string[]; cut: string[];
fill: string[]; fill: string[];
/** 성토면 제안(서버 한 곳 · 실무 관측) — 회색으로 보이고 [제안값 넣기]를 **누른 때만** 들어감. */
fill_suggested?: { value: string; basis: string };
} }
export function appendFaceDressingFields( export function appendFaceDressingFields(
@@ -76,11 +80,36 @@ export function appendFaceDressingFields(
draft.face_dressing_cut_class = v; draft.face_dressing_cut_class = v;
}), }),
); );
panel.append( const fillRow = pick(
pick("B08_Quantity_FaceDressing_Fill", draft.face_dressing_fill_class, choices.fill, (v) => { "B08_Quantity_FaceDressing_Fill",
draft.face_dressing_fill_class,
choices.fill,
(v) => {
draft.face_dressing_fill_class = v; draft.face_dressing_fill_class = v;
}), },
); );
panel.append(fillRow);
// 성토면 제안 — 칸은 비워 둔 채 회색 근거 + 단추(층따기와 같은 모양). 비우면 「안 정함」 그대로.
const suggested = choices.fill_suggested;
if (suggested?.value) {
panel.append(
h.hintRow(
`${L("B08_Quantity_FaceDressing_Suggest")} ${suggested.value}${suggested.basis}`,
),
);
panel.append(
createButton({
label: L("B08_Quantity_FaceDressing_FillSuggested"),
variant: "ghost",
onClick: () => {
const select = fillRow.querySelector("select");
if (select) select.value = suggested.value;
draft.face_dressing_fill_class = suggested.value;
draft.dirty = true;
},
}),
);
}
panel.append( panel.append(
area("B08_Quantity_FaceDressing_FillArea", draft.face_dressing_fill_area_m2, (v) => { area("B08_Quantity_FaceDressing_FillArea", draft.face_dressing_fill_area_m2, (v) => {
draft.face_dressing_fill_area_m2 = v; draft.face_dressing_fill_area_m2 = v;
@@ -0,0 +1,59 @@
/* =============================================================================
* B08_Quantity_UI_Side_RootRemoval.ts
* (PLAN 10-A · 9-21 64a52df6).
*
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* · ** **(`root_removal_excavator_choices`) .
* . 0.7
* + [ ] ** **( ).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton } from "@ui/ui_template_elements";
import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export interface RootRemovalChoices {
choices: string[];
suggested?: { value: string; basis: string };
}
export function appendRootRemovalFields(
panel: HTMLElement,
draft: { root_removal_excavator_m3: string; dirty: boolean },
choices: RootRemovalChoices | undefined,
h: SideFieldHelpers,
): void {
if (!choices) return;
const row = h.selectField(
L("B08_Quantity_Side_RootRemoval_Label"),
draft.root_removal_excavator_m3,
[
{ value: "", label: L("B08_Quantity_RootRemoval_Unset") },
...choices.choices.map((size) => ({ value: size, label: `${size}` })),
],
(picked) => {
draft.root_removal_excavator_m3 = picked;
draft.dirty = true;
},
);
panel.append(row);
const suggested = choices.suggested;
if (!suggested?.value) return;
panel.append(
h.hintRow(`${L("B08_Quantity_RootRemoval_Suggest")} ${suggested.value}㎥ — ${suggested.basis}`),
createButton({
label: L("B08_Quantity_RootRemoval_FillSuggested"),
variant: "ghost",
onClick: () => {
const select = row.querySelector("select");
if (select) select.value = suggested.value;
draft.root_removal_excavator_m3 = suggested.value;
draft.dirty = true;
},
}),
);
}
@@ -0,0 +1,40 @@
/* =============================================================================
* B08_Quantity_UI_Side_SeedSpray.ts
* · (2026-09-14 ).
*
* (5-24 · ) (·)
* . ( `leaf_from`).
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* ** **(`seed_spray_choices`) · .
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export function appendSeedSprayField(
panel: HTMLElement,
draft: { seed_spray_ground: string; dirty: boolean },
choices: { choices: string[] } | undefined,
h: SideFieldHelpers,
): void {
if (!choices) return;
panel.append(
h.selectField(
L("B08_Quantity_Side_SeedSpray_Label"),
draft.seed_spray_ground,
[
{ value: "", label: L("B08_Quantity_SeedSpray_Unset") },
...choices.choices.map((name) => ({ value: name, label: name })),
],
(picked) => {
draft.seed_spray_ground = picked;
draft.dirty = true;
},
),
h.hintRow(L("B08_Quantity_Side_SeedSpray_Hint")),
);
}
+20 -1
View File
@@ -86,9 +86,13 @@ export interface StructureSheet extends StandardSheetSpec {
name: string; name: string;
code?: string | null; code?: string | null;
imported_from?: string | null; imported_from?: string | null;
/** 뽑아 온 원문 공사명(STmate 고정형) — 프로그램 기본 발행 때 가림. */
origin_project?: string | null;
}; };
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */ /** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
formula_sheet?: FormulaSheet; formula_sheet?: FormulaSheet;
/** ㉱ 고정형 항목 규격(H)이 장 제원과 다름 — 금액은 서되 미확정 급(빨간 테두리·배지). */
spec_mismatch?: { item_height_m: number; sheet_height_m: number } | null;
/** 제원 칸의 대안 후보(실무 관측값 등) — 값은 안 바꾸고 보이기만. */ /** 제원 칸의 대안 후보(실무 관측값 등) — 값은 안 바꾸고 보이기만. */
var_candidates?: { var_candidates?: {
name: string; name: string;
@@ -258,8 +262,21 @@ function sheetBody(
: "양식 없음(지금 전개)"), : "양식 없음(지금 전개)"),
), ),
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음). // 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
el("span", "b08-grid__caption", sheet.unit_label), el(
"span",
"b08-grid__caption",
// 10-A ⑯ 양식은 L=1 로 풂(`replace_with_templates` · m당 양식만) — 규칙을 머리에 드러냄.
sheet.library_item && sheet.unit_label === "m당"
? `m당 — 반올림은 m당 값에 걸고 수량 = m당 × 연장`
: sheet.unit_label,
),
); );
// ㉱ 규격 다름 — 미확정과 같은 급(빨간 테두리 + 배지). 금액은 서고 막지 않음.
if (sheet.spec_mismatch) {
const { item_height_m: item, sheet_height_m: own } = sheet.spec_mismatch;
head.append(el("span", "b08-unit__badge", `규격 다름 H${item} ↔ H${own}`));
main.classList.add("b08-unit__manual");
}
// 한 장 = 상단 그림 + 하단 표(PLAN 3장 한 장의 짜임) — 못 그리는 장은 까닭. // 한 장 = 상단 그림 + 하단 표(PLAN 3장 한 장의 짜임) — 못 그리는 장은 까닭.
main.append(head, figureSection(sheet.figure, sheet.figure_reason)); main.append(head, figureSection(sheet.figure, sheet.figure_reason));
if (!sheet.rows.length) { if (!sheet.rows.length) {
@@ -414,6 +431,8 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
sheetKey: sheet.key, sheetKey: sheet.key,
typeId: sheet.library_item.type_id, typeId: sheet.library_item.type_id,
currentCode: sheet.library_item.code ?? null, currentCode: sheet.library_item.code ?? null,
originProject: sheet.library_item.origin_project,
defaultName: sheet.title,
isDirty: () => dirty, isDirty: () => dirty,
confirmTake: () => { confirmTake: () => {
const edited = editedRows(sheet); const edited = editedRows(sheet);
@@ -171,7 +171,7 @@ export function formulaTable(
saveButton.disabled = !dirty; saveButton.disabled = !dirty;
discardButton.disabled = !dirty; discardButton.disabled = !dirty;
status.textContent = dirty status.textContent = dirty
? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림)" ? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림 · 고친 식은 이 양식·프로젝트에 묶여 제원(높이 등)을 바꿔도 따라감)"
: ""; : "";
onDirty(dirty); onDirty(dirty);
}; };
@@ -10,9 +10,16 @@
import { API_BASE_URL } from "@config/config_frontend"; import { API_BASE_URL } from "@config/config_frontend";
import { buildStmatePanel } from "./B08_Quantity_UI_StructureSheet_Stmate"; import { buildStmatePanel } from "./B08_Quantity_UI_StructureSheet_Stmate";
const TIER_LABELS: Record<string, string> = { personal: "개인", company: "회사", program: "기본" }; const TIER_LABELS: Record<string, string> = {
personal: "개인",
received: "받음",
company: "회사",
program: "기본",
};
/** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */ /** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */
const KIND_LABELS: Record<string, string> = { form: "양식형", fixed: "고정형" }; const KIND_LABELS: Record<string, string> = { form: "양식형", fixed: "고정형" };
/** 이름표 — 「종류 + 제원 요약」을 미리 채워 두고 고치게 함(10-A ⑫ · 코드는 난수라 이름이 흔들려도 안전). */
const NAME_TAG_HINT = "이름표(종류 + 제원 요약 · 고칠 수 있음 · 비우면 양식 이름):";
interface LibraryItem { interface LibraryItem {
tier: string; tier: string;
@@ -52,6 +59,10 @@ export interface LibraryPanelOptions {
/** 저장 안 한 식이 있으면 [내 라이브러리에 저장]을 막음 — 저장된 식만 개인 단으로 감. */ /** 저장 안 한 식이 있으면 [내 라이브러리에 저장]을 막음 — 저장된 식만 개인 단으로 감. */
isDirty: () => boolean; isDirty: () => boolean;
onImported: (notes: string[]) => Promise<void>; onImported: (notes: string[]) => Promise<void>;
/** 이 장 양식이 뽑아 온 원문 공사명 — 프로그램 기본 발행 확인창에 「빼고 발행」을 알림. */
originProject?: string | null;
/** 저장·발행 이름표 제안 — 장 이름(종류 + 제원 요약). */
defaultName: string;
} }
/** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */ /** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */
@@ -80,13 +91,112 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
take.className = "b08-spec__save"; take.className = "b08-spec__save";
take.textContent = "가져오기"; take.textContent = "가져오기";
take.hidden = true; take.hidden = true;
// 복제 — 기본·회사 항목을 개인 단에 베낌(PLAN 4장). 개인 단 것은 이미 내 것이라 안 눌림.
const clone = document.createElement("button");
clone.type = "button";
clone.className = "b08-quantity__tab";
clone.textContent = "복제해서 내 것으로";
clone.hidden = true;
/** 목록을 다시 받은 뒤 보일 한 줄 — 받는 동안 상태 줄이 지워져 복제 결과가 사라지지 않게. */
let afterLoad = "";
// 공유 — 내 개인 단 항목을 같은 회사 동료의 「받음」 단으로(브레인 판정 ①). 동료는 누를 때 받음.
const send = document.createElement("button");
send.type = "button";
send.className = "b08-quantity__tab";
send.textContent = "동료에게 보내기";
send.hidden = true;
const who = document.createElement("select");
who.className = "b08-spec__input";
who.hidden = true;
const syncClone = (): void => {
const [tier] = list.value.split("|");
clone.disabled = tier === "personal";
send.disabled = tier !== "personal";
};
send.addEventListener("click", () => {
const [tier, code] = list.value.split("|");
if (tier !== "personal" || !code) return;
void (async () => {
send.disabled = true;
try {
if (who.hidden) {
const { colleagues } = await readJson<{ colleagues: { id: number; name: string }[] }>(
await fetch(libraryUrl(projectId, "/colleagues"), { credentials: "include" }),
);
who.replaceChildren(...colleagues.map((c) => new Option(c.name, String(c.id))));
who.hidden = colleagues.length === 0;
status.textContent = colleagues.length
? "받을 동료를 고르고 한 번 더 누를 것"
: "보낼 동료가 없음";
return;
}
const label = list.selectedOptions[0]?.textContent ?? "";
const name = who.selectedOptions[0]?.textContent ?? "";
if (
!window.confirm(
`${label}」을 ${name}에게 보냄 — 받는 쪽 목록에 「받음」으로 뜸 · 그 사람 것은 안 덮음`,
)
) {
return;
}
await readJson(
await fetch(libraryUrl(projectId, "/share"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type_id: typeId, code, to_user_id: Number(who.value) }),
}),
);
who.hidden = true;
status.textContent = `${name}에게 보냄`;
} catch (error) {
status.textContent = error instanceof Error ? error.message : "보내지 못함";
} finally {
syncClone();
}
})();
});
list.addEventListener("change", syncClone);
clone.addEventListener("click", () => {
const [tier, code] = list.value.split("|");
const label = list.selectedOptions[0]?.textContent ?? "";
if (!tier || !code || tier === "personal") return;
if (
!window.confirm(
`${label}」을 내 라이브러리로 베낌 — 같은 종류 내 것이 있으면 덮어씀 · 프로젝트 값은 안 바뀜`,
)
) {
return;
}
void (async () => {
clone.disabled = true;
try {
await readJson(
await fetch(libraryUrl(projectId, "/clone"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type_id: typeId, tier, code }),
}),
);
afterLoad = "내 라이브러리에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]";
load.click();
} catch (error) {
status.textContent = error instanceof Error ? error.message : "복제 못함";
syncClone();
}
})();
});
load.addEventListener("click", () => { load.addEventListener("click", () => {
void (async () => { void (async () => {
load.disabled = true; load.disabled = true;
status.textContent = "목록 받는 중…"; status.textContent = "목록 받는 중…";
try { try {
const { items } = await readJson<{ items: LibraryItem[] }>( const { items, can_publish: canPublish } = await readJson<{
items: LibraryItem[];
can_publish?: { company: boolean; program: boolean };
}>(
await fetch(libraryUrl(projectId, `?type_id=${encodeURIComponent(typeId)}`), { await fetch(libraryUrl(projectId, `?type_id=${encodeURIComponent(typeId)}`), {
credentials: "include", credentials: "include",
}), }),
@@ -101,8 +211,13 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
return option; return option;
}), }),
); );
list.hidden = take.hidden = items.length === 0; list.hidden = take.hidden = clone.hidden = send.hidden = items.length === 0;
status.textContent = items.length ? "" : "가져올 항목이 없음"; syncClone();
// 발행 단추 — 서버가 준 권한대로만 보임(회사 = 마스터 · 기본 = 시스템 관리자).
toCompany.hidden = !canPublish?.company;
toProgram.hidden = !canPublish?.program;
status.textContent = items.length ? afterLoad : "가져올 항목이 없음";
afterLoad = "";
} catch (error) { } catch (error) {
status.textContent = error instanceof Error ? error.message : "목록을 받지 못함"; status.textContent = error instanceof Error ? error.message : "목록을 받지 못함";
} finally { } finally {
@@ -161,17 +276,17 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
}; };
const save = personal("내 라이브러리에 저장", async () => { const save = personal("내 라이브러리에 저장", async () => {
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저"; if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
if ( const tag = window.prompt(
!window.confirm("이 장의 양식과 고친 식을 내 라이브러리에 저장 — 같은 종류가 있으면 덮어씀") `이 장의 양식과 고친 식을 내 라이브러리에 저장 — 같은 종류가 있으면 덮어씀\n${NAME_TAG_HINT}`,
) { options.defaultName,
return ""; );
} if (tag === null) return "";
const result = await readJson<{ edited: number }>( const result = await readJson<{ edited: number }>(
await fetch(libraryUrl(projectId, "/personal"), { await fetch(libraryUrl(projectId, "/personal"), {
method: "PUT", method: "PUT",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sheet_key: sheetKey }), body: JSON.stringify({ sheet_key: sheetKey, name: tag }),
}), }),
); );
return `내 라이브러리에 저장함${result.edited ? ` · 고친 식 ${result.edited}줄 포함` : ""}`; return `내 라이브러리에 저장함${result.edited ? ` · 고친 식 ${result.edited}줄 포함` : ""}`;
@@ -187,11 +302,40 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
); );
return result.deleted ? "내 라이브러리에서 지움" : "지울 내 양식이 없음"; return result.deleted ? "내 라이브러리에서 지움" : "지울 내 양식이 없음";
}); });
// 발행 — [내 라이브러리에 저장]과 같은 모양으로 회사·프로그램 기본 단에(2026-09-14 브레인 승인).
const publish = (label: string, tier: "company" | "program"): HTMLButtonElement => {
const button = personal(label, async () => {
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
const whom = tier === "program" ? "모든 회사가 쓰는 프로그램 기본" : "우리 회사 라이브러리";
const masked =
tier === "program" && options.originProject
? `\n이 항목은 「${options.originProject}」에서 뽑은 것 — 공사명은 빼고 발행됩니다`
: "";
const tag = window.prompt(
`이 장의 양식과 고친 식을 ${whom}에 발행 — 같은 종류가 있으면 덮어씀${masked}\n${NAME_TAG_HINT}`,
options.defaultName,
);
if (tag === null) return "";
await readJson(
await fetch(libraryUrl(projectId, "/publish"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sheet_key: sheetKey, tier, name: tag }),
}),
);
return `${whom}에 발행함`;
});
button.hidden = true;
return button;
};
const toCompany = publish("회사 라이브러리에 발행", "company");
const toProgram = publish("프로그램 기본으로 발행", "program");
const mine = document.createElement("div"); const mine = document.createElement("div");
mine.className = "b08-sheet__actions"; mine.className = "b08-sheet__actions";
mine.append(save, remove); mine.append(save, remove, toCompany, toProgram);
panel.append(title, scope, load, list, take, mine, status); panel.append(title, scope, load, list, take, clone, send, who, mine, status);
// 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장). // 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장).
panel.append( panel.append(
buildStmatePanel({ buildStmatePanel({
@@ -196,7 +196,11 @@ export function buildStandardSpecPanel(
slope, slope,
judgedSlope ? `비우면 자동 — 지금 판정값 1:${judgedSlope}` : "비우면 자동으로 판정합니다.", judgedSlope ? `비우면 자동 — 지금 판정값 1:${judgedSlope}` : "비우면 자동으로 판정합니다.",
), ),
field("야면석 계수", coeff, "비우면 품셈 열을 씁니다."), field(
"야면석 계수",
coeff,
"비우면 품셈 열을 씁니다. 계수 열을 바꿔도 돌 종류는 그대로(다른 칸)입니다.",
),
field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."), field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."),
field("버림 콘크리트", blinding, "비우면 넣습니다(두께 100㎜) — 빼려면 「안 넣음」."), field("버림 콘크리트", blinding, "비우면 넣습니다(두께 100㎜) — 빼려면 「안 넣음」."),
); );
@@ -123,7 +123,7 @@ function render(table: UnitPriceTable): HTMLElement[] {
const head = el( const head = el(
"p", "p",
"b08-sheet__head", "b08-sheet__head",
`일위대가 ${table.code} · ${table.unit}${total} (미리보기 — 내역 금액은 원가계산이 셈)`, `일위대가 ${table.code} · ${table.unit}${total} (미리보기 — 내역 금액은 원가계산이 셈 · 하위 일위대가는 5단까지 풀고 더 깊거나 돌면 막힘)`,
); );
if (table.unconfirmed) { if (table.unconfirmed) {
head.append(el("span", "b08-unit__badge", `미확정 ${table.unconfirmed}`)); head.append(el("span", "b08-unit__badge", `미확정 ${table.unconfirmed}`));
@@ -233,7 +233,16 @@ export function unitPriceEditor(options: EditorOptions): HTMLElement {
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
const items = payload.items ?? []; const items = payload.items ?? [];
results.replaceChildren( results.replaceChildren(
...(items.length ? items.map(choice) : [el("span", "b08-grid__caption", "없음")]), ...(items.length
? items.map(choice)
: [
// 후보 0 — 조용히 막히지 않게 다음 길을 적음(갈래 바꿔 찾기 · 수동 단가는 미확정으로 셈).
el(
"span",
"b08-grid__caption",
"단가표에 후보 없음 — 낱말·갈래(품셈/자원)를 바꿔 찾거나, 이 줄에 수동 단가를 넣을 것(미확정으로 셈)",
),
]),
); );
} catch (error) { } catch (error) {
results.replaceChildren( results.replaceChildren(
+3 -1
View File
@@ -243,6 +243,8 @@ export interface PreparationRow {
export interface PreparationTable { export interface PreparationTable {
columns: string[]; columns: string[];
rows: PreparationRow[]; rows: PreparationRow[];
/** 입력하면 서는 줄 수 — 「근거 없음」(`pending_count`)과 갈라 셈(2026-09-14). */
input_count?: number;
pending_count: number; pending_count: number;
row_count: number; row_count: number;
} }
@@ -261,7 +263,7 @@ export function renderPreparationGrid(
const caption = document.createElement("p"); const caption = document.createElement("p");
caption.className = "b08-grid__caption"; 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); const unconfirmed = table.rows.reduce((sum, row) => sum + (row.unconfirmed ?? 0), 0);
if (unconfirmed) { if (unconfirmed) {
const badge = document.createElement("span"); const badge = document.createElement("span");
@@ -85,6 +85,7 @@ def chosen_conditions(settings: dict[str, Any] | None) -> list[dict[str, str]]:
("fuel_region", "유가 지역(시도코드)"), ("fuel_region", "유가 지역(시도코드)"),
("transport_distance_km", "기계 수송 거리(편도 ㎞)"), ("transport_distance_km", "기계 수송 거리(편도 ㎞)"),
("transport_road", "수송 도로 구분"), ("transport_road", "수송 도로 구분"),
("transport_trips", "기계 수송 회수(대수 × 왕복)"),
): ):
value = str(picked.get(key) or "").strip() value = str(picked.get(key) or "").strip()
if value: if value:
+13 -149
View File
@@ -45,28 +45,30 @@ from B09_Estimation.B09_Estimation_UnitPrice import (
_ZERO = Decimal(0) _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 발파암(인계 대응표 「흙깎기」 셋). #: 총 절취량으로 셀 공종 — 9-3 토사깎기 · 9-4 암절취 · 9-5 발파암(인계 대응표 「흙깎기」 셋).
#: ⚠ 판에 묶인 절 번호임(명세 17장) — 판이 바뀌면 대응표와 함께 고칠 것. #: ⚠ 판에 묶인 절 번호임(명세 17장) — 판이 바뀌면 대응표와 함께 고칠 것.
CUT_CODE_PREFIXES = ("FP-09-03", "FP-09-04", "FP-09-05") CUT_CODE_PREFIXES = ("FP-09-03", "FP-09-04", "FP-09-05")
#: 구조물도 양식 호표 코드 머리(명세 2장 자체 확장) — 금액은 B08 일위대가 엔진을 B09 단가표로 셈. #: 구조물도 양식 호표 코드 머리(명세 2장 자체 확장) — 금액은 B08 일위대가 엔진을 B09 단가표로 셈.
STRUCTURE_PRICE_PREFIX = "AX-ST-" 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`** 면 **막힌 줄이 아니다** — #: ⚠ `blocked_kind` 가 **`None` 인데 `in_bill=False`** 면 **막힌 줄이 아니다** —
#: 「다른 표에서 이미 섬」(벌목·지장목제거)이거나 「이 노선엔 없음」(사방 시설)이다. #: 「다른 표에서 이미 섬」(벌목·지장목제거)이거나 「이 노선엔 없음」(사방 시설)이다.
#: 그것을 「우리가 만들어야 하는 것」에 얹으면 **결국 이중계상으로 간다**(㉠~㉦ 규칙). #: 그것을 「우리가 만들어야 하는 것」에 얹으면 **결국 이중계상으로 간다**(㉠~㉦ 규칙).
_NOT_OUR_ROW = "not_our_row" _NOT_OUR_ROW = "not_our_row"
#: B08 `BLOCKED_UNCONFIRMED` 와 같은 글 — 치수 없이 기본값으로 선 줄.
BLOCKED_UNCONFIRMED = "unconfirmed"
_BLOCKED_LABELS = { _BLOCKED_LABELS = {
_NOT_OUR_ROW: "여기서 세지 않는 줄", _NOT_OUR_ROW: "여기서 세지 않는 줄",
BLOCKED_UNCONFIRMED: "미확정 — 금액에 안 들어감", 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 @dataclass
class BillRow: class BillRow:
"""내역서 한 줄. 머리(그룹)줄은 `is_group=True` 이고 수량·단가가 없다.""" """내역서 한 줄. 머리(그룹)줄은 `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) 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) @dataclass(frozen=True)
class _MasterNode: class _MasterNode:
code: str code: str
@@ -414,11 +278,11 @@ from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import ( # noqa: E402
_composite_row, _composite_row,
_excluded_row, _excluded_row,
_leaf_row, _leaf_row,
_material_row,
_structure_price_row, _structure_price_row,
_sum_groups, _sum_groups,
bill_line, # noqa: F401 — 내역 줄 성분별 절사(골든셋 시험이 여기서 부름) bill_line, # noqa: F401 — 내역 줄 성분별 절사(골든셋 시험이 여기서 부름)
) )
from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import _material_row # noqa: E402
def build_bill( 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 decimal import Decimal
from typing import Any, Callable 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_PriceBook import Money3, PriceKind
from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, UnitPriceBuild from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, UnitPriceBuild
@@ -24,6 +30,71 @@ DOUBLE_COUNT_SUSPECT = "double_count_suspect"
_ZERO = Decimal(0) _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: def _flat(text: Any) -> str:
return "".join(str(text or "").split()) return "".join(str(text or "").split())
@@ -9,14 +9,12 @@
from __future__ import annotations from __future__ import annotations
import re
from decimal import Decimal from decimal import Decimal
from B09_Estimation.B09_Estimation_BillOfQuantities import ( from B09_Estimation.B09_Estimation_BillOfQuantities import (
SUPPLY_OWNER,
SUPPLY_UNKNOWN,
BillResult, BillResult,
BillRow, BillRow,
HandoffMaterial,
HandoffWorkItem, HandoffWorkItem,
_BLOCKED_LABELS, _BLOCKED_LABELS,
_MasterNode, _MasterNode,
@@ -25,6 +23,7 @@ from B09_Estimation.B09_Estimation_BillOfQuantities import (
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( from B09_Estimation.B09_Estimation_MachineProductivity_Dump import (
DUMP_PARENT, DUMP_PARENT,
LOADING_EQUIPMENT, LOADING_EQUIPMENT,
distance_label,
dump_child_for, dump_child_for,
dump_title_code, dump_title_code,
loading_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: def bill_line(unit: Money3, quantity) -> Money3:
"""내역 줄 금액 — **성분마다** `절사(수량 × 성분 단가)`, 줄 합계는 셋의 합(명세 7장). """내역 줄 금액 — **성분마다** `절사(수량 × 성분 단가)`, 줄 합계는 셋의 합(명세 7장).
@@ -112,6 +118,7 @@ def _composite_row(
in_bill=item.in_bill, in_bill=item.in_bill,
) )
missing_parts: list[str] = [] missing_parts: list[str] = []
reasons: list[str] = []
money = None money = None
for part in item.composite_parts: for part in item.composite_parts:
code = str(part.get("code") or "") 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: 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 "이름 없음")) missing_parts.append(code or str(part.get("name") or "이름 없음"))
continue 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장). # 묶음도 호표 한 장 — 조각 줄 0.1원 · 성분 소계 원 미만 절사(아래 `floored`, 명세 7장).
scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount).floored(Decimal("0.1")) scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount).floored(Decimal("0.1"))
money = scaled if money is None else money + scaled money = scaled if money is None else money + scaled
row.parts.append((f"B-{code}", amount)) row.parts.append((f"B-{code}", amount))
reasons: list[str] = []
for pending in item.composite_not_ready: for pending in item.composite_not_ready:
# 「단가 없음」과 「물량 없음」을 가른다 — 사유가 다르면 할 일도 다르다. # 「단가 없음」과 「물량 없음」을 가른다 — 사유가 다르면 할 일도 다르다.
if isinstance(pending, str): if isinstance(pending, str):
@@ -362,7 +381,7 @@ def _leaf_row(
return row return row
price_code = wanted price_code = wanted
if not loading: 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: if price_code not in unit_prices.book.titles:
# B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다. # B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다.
# 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다. # 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다.
@@ -374,8 +393,7 @@ def _leaf_row(
default = unit_prices.default_variants.get(node.code) default = unit_prices.default_variants.get(node.code)
if picked is not None: if picked is not None:
price_code = picked price_code = picked
variant = str(item.variant_value) # 규격 글로 시작하는 갈래는 한 번만(면고르기) row.spec = spec_with_variant(row.spec, str(item.variant_value))
row.spec = variant if variant.startswith(row.spec) else f"{row.spec} {variant}"
elif default is not None: elif default is not None:
# 표에 없거나 안 준 암질(풍화암·암) — **원문이 정한 갈래**로만 선다(9-4-1 [주]① 평균). # 표에 없거나 안 준 암질(풍화암·암) — **원문이 정한 갈래**로만 선다(9-4-1 [주]① 평균).
price_code = f"{price_code}#{default[0]}" price_code = f"{price_code}#{default[0]}"
@@ -444,6 +462,8 @@ def _leaf_row(
row.add_note( row.add_note(
"unit_price_krw", "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다." "unit_price_krw", "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다."
) )
if form_judgment_note(node.code): # 사람이 가른 표 형태 까닭(10-A ⑭)
row.add_note("unit_price_krw", form_judgment_note(node.code))
reason = "일위대가 없음" reason = "일위대가 없음"
# 관경이 표 밖이면 **무엇을 정해야 하는지**까지 가리킨다. # 관경이 표 밖이면 **무엇을 정해야 하는지**까지 가리킨다.
diameter_note = pipe_diameter_note(node.code, item.variant_value) if children else "" 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 from B09_Estimation.B09_Estimation_KnownGaps import ( # noqa: E402
form_judgment_note,
known_gap_note, known_gap_note,
pipe_diameter_note, pipe_diameter_note,
) )
@@ -610,71 +631,6 @@ def pending_formula_note(code: str | None) -> str:
return "" 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 = { _UNIT_ALIASES = {
"": "m3", "": "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 의 토목·준설·건축·기타" "자동 = 요율 데이터 적용 하한(추정금액 1억 이상)일 때 적용 · STmate 의 토목·준설·건축·기타"
" 구분은 현행 제비율(2026-04-13)에서 율이 같아(2.3%) 칸으로 두지 않음" " 구분은 현행 제비율(2026-04-13)에서 율이 같아(2.3%) 칸으로 두지 않음"
), ),
"overhead_class": "임도가 (주)공사·전문공사 어느 쪽인지 정한 규정 없음 — 기본 (주)공사", # 10-A ② 문구 못박음(2026-09-14 사용자 확정 — SW 규칙).
"overhead_class": "임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음",
"cut_basis": ( "cut_basis": (
"기본 「총공사비 1,000원 미만 버림」 — 근거: 산림청고시 제2025-82호 「금액의 단위표준」" "기본 「총공사비 1,000원 미만 버림」 — 근거: 산림청고시 제2025-82호 「금액의 단위표준」"
"(설계서의 총액 · 원 · 1,000 · 미만버림) · 실무 6건이 여섯 다 000 으로 끝남 ·" "(설계서의 총액 · 원 · 1,000 · 미만버림) · 실무 6건이 여섯 다 000 으로 끝남 ·"
@@ -99,7 +100,8 @@ FIELD_HINTS: dict[str, str] = {
"waste_placement": ( "waste_placement": (
"법 문언(기본): 예정가격작성기준 제19조③18호 — 폐기물처리비는 경비라 일반관리비·이윤" "법 문언(기본): 예정가격작성기준 제19조③18호 — 폐기물처리비는 경비라 일반관리비·이윤"
" 밑수에 듦 · 실무 관행: 울진소광 원가계산서(이윤 뒤·총원가 안)·임목폐기물처리 실정보고" " 밑수에 듦 · 실무 관행: 울진소광 원가계산서(이윤 뒤·총원가 안)·임목폐기물처리 실정보고"
"(공급가액 뒤)는 승률 밖 — 금액이 크게 갈려 설계자가 고름" "(공급가액 뒤)는 승률 밖 — 금액이 크게 갈려 설계자가 고름 ·"
" 어느 자리든 법정경비 밑수(직접공사비·노무비)에는 안 넣음"
), ),
} }
_GRADE_OPTIONS = [ _GRADE_OPTIONS = [
@@ -3,10 +3,13 @@
근거: STmate 분석 `12_원가계산_4형식과_RATE표_복원.md` §4~6 (DFM 복원 서식) · 근거: STmate 분석 `12_원가계산_4형식과_RATE표_복원.md` §4~6 (DFM 복원 서식) ·
`35_형식과_단계의_공통과_차이.md` §1·§2 (공통 사슬 · 형식별 차이 · 12 정정 반영). `35_형식과_단계의_공통과_차이.md` §1·§2 (공통 사슬 · 형식별 차이 · 12 정정 반영).
**금액을 낸다** 요율 데이터가 없다(2026-09-14 확인: 수공 세부 경비 율표 `RateB` · **금액을 낸다** 2026-09-14 요율 결론(PLAN 7): 실적형 제비율 `RATE_RA`·`RATE_RA1`
표준시장단가 제비율 `RATE_RA`·`RATE_RA1`·실적 안전표 `An_A`). 줄마다 무엇이 막혔는지만 적는다. **임도 미적용**(100 미만 · 예정가격작성기준 §37) · 수공 세부 경비 `RateB` **법정 요율 없음**
(업체 실측 대상 §34 설계자 입력 ) · 실적 안전표 `An_A` 고시 편입(제4조·별표1)이나 실적형
자체가 임도 미적용. 줄마다 무엇이 막혔는지만 적는다.
**0 원으로 채우지 않는다** 0 원은 없다 아니라 공짜 읽혀 총액이 조용히 작아진다. **0 원으로 채우지 않는다** 0 원은 없다 아니라 공짜 읽혀 총액이 조용히 작아진다.
35 §5 미확인(실적형 이윤 밑수 · 실적수공 안전관리비 존재) 확인 대기 세운. 35 §5 미확인 확인 대기 세운다 지금 남은 것은 **실적형 이윤 밑수** 하나.
실적수공 안전관리비는 2026-09-14 코덱스가 STmate 원문에서 **있음** 확인해 별도내역으로 내렸다.
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,12 +19,20 @@ from typing import Any
#: 줄 상태 — 화면이 그대로 보인다. #: 줄 상태 — 화면이 그대로 보인다.
COMMON = "공통 요율 — 일반형식과 같은 표(형식 전체가 서면 금액이 섬)" COMMON = "공통 요율 — 일반형식과 같은 표(형식 전체가 서면 금액이 섬)"
SEPARATE = "별도내역 — 요율이 아니라 따로 산출한 금액" SEPARATE = "별도내역 — 요율이 아니라 따로 산출한 금액"
RATE_B = "요율표 미확보 — 수공 세부 경비 율표(STmate RateB)" RATE_B = (
RATE_RA = "요율표 미확보 — 표준시장단가(실적공사비) 제비율(STmate RATE_RA·RATE_RA1)" "법정 요율 없음 — 값을 넣어 주십시오(업체 실측 대상 · 예정가격작성기준 §34①) · 수공 세부 경비"
RATE_AN_A = "요율표 미확보 — 실적 산업안전보건관리비 표(STmate An_A·An_A1)" "(STmate RateB) · 입력 칸은 수공형식 금액 셈과 함께 섬"
)
RATE_RA = (
"임도 미적용 — 100억 미만(예정가격작성기준 §37②) · 표준시장단가(실적공사비) 제비율"
"(STmate RATE_RA·RATE_RA1)"
)
RATE_AN_A = (
"임도 미적용 — 실적형 형식 전체가 100억 미만 임도에 안 씀(§37②) · 요율 자체는 고시 편입"
"(건설업 산업안전보건관리비 계상 및 사용기준 제4조·별표1)"
)
TOTAL = "합계 — 위 줄이 다 서야 섬" TOTAL = "합계 — 위 줄이 다 서야 섬"
PENDING_PROFIT = "확인 대기 — 실적형 이윤 밑수가 복원식과 화면 문구가 충돌(35번 §5-1)" PENDING_PROFIT = "확인 대기 — 실적형 이윤 밑수가 복원식과 화면 문구가 충돌(35번 §5-1)"
PENDING_SAFETY = "확인 대기 — 실적수공에 안전관리비가 실제로 있는지 미확인(35번 §5-2)"
DIRECT = "설계내역서 합계에서 옴" DIRECT = "설계내역서 합계에서 옴"
_SUGONG_EXPENSES = ( _SUGONG_EXPENSES = (
@@ -115,7 +126,8 @@ def _actual(sugong: bool) -> list[dict[str, Any]]:
_r("6)", "연금보험료", "직접노무비 ×", COMMON, 1), _r("6)", "연금보험료", "직접노무비 ×", COMMON, 1),
_r("7)", "퇴직공제부금비", "직접노무비 ×", COMMON, 1), _r("7)", "퇴직공제부금비", "직접노무비 ×", COMMON, 1),
_r("8)", "산업안전보건관리비", "(직접공사비+도급자관급자재) ×", RATE_AN_A, 1), _r("8)", "산업안전보건관리비", "(직접공사비+도급자관급자재) ×", RATE_AN_A, 1),
_r("9)", "안전관리비", "", PENDING_SAFETY if sugong else SEPARATE, 1), # TA·SA 둘 다 별도내역 — SA 도 있음이 확인됨(2026-09-14 코덱스, 35번 §5-2 풀림).
_r("9)", "안전관리비", "", SEPARATE, 1),
_r("10)", "품질관리비", "", SEPARATE, 1), _r("10)", "품질관리비", "", SEPARATE, 1),
] ]
if sugong: if sugong:
+59 -137
View File
@@ -18,14 +18,12 @@ from __future__ import annotations
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
from typing import Any
from B09_Estimation.B09_Estimation_Engine_Cost_Options import ( from B09_Estimation.B09_Estimation_Engine_Cost_Options import (
CUT_BASES, CUT_BASES,
VAT_MODES, VAT_MODES,
cut_gap, cut_gap,
overhead_base, overhead_base,
profit_cut,
vat_base, vat_base,
) )
from B09_Estimation.B09_Estimation_RateOverride import apply_overrides from B09_Estimation.B09_Estimation_RateOverride import apply_overrides
@@ -46,9 +44,12 @@ from B09_Estimation.B09_Estimation_Statutory import (
_ZERO = Decimal(0) _ZERO = Decimal(0)
_HUNDRED = Decimal(100) _HUNDRED = Decimal(100)
#: 기본으로 켜는 비목 = **그 해 요율 데이터에 있는 것 전부**. # ⬇ 입력 한 벌은 `_Input` 으로 옮겼다(2026-09-15 700줄 분리) —
#: 사용자 확정(2026-09-07, PLAN 8-14): 실무 서류에 없다고 빼지 않는다. # 쓰던 이름이 그대로 살게 다시 내보낸다.
DEFAULT_ITEMS = "ALL_AVAILABLE" from B09_Estimation.B09_Estimation_Engine_Cost_Input import ( # noqa: E402,F401
DEFAULT_ITEMS,
CostInput,
)
def floor_won(value: Decimal) -> Decimal: 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 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 @dataclass
class CostLine: class CostLine:
"""원가계산서 한 줄 — 화면이 「비목·금액·요율·산출근거」를 다 보이므로 넷을 다 든다.""" """원가계산서 한 줄 — 화면이 「비목·금액·요율·산출근거」를 다 보이므로 넷을 다 든다."""
@@ -365,9 +253,9 @@ def calculate_cost(data: CostInput) -> CostResult:
) )
#: 절사 뒤 잔차 맞춤 반복 상한. 실무 6건은 **두 걸음 안에** 앉음 #: 절사 목표를 몇 칸까지 내려 볼 것인가. 못 밟는 1,000 배수를 만나면 한 칸 내린다 —
#: (다섯 건 한 걸음 · 울진 신설 두 걸음). #: 실측(거창 꼴 40 자리)에서는 **한 칸이면 다 앉았다**. 여유로 셋까지 본다.
_CUT_MAX_PASSES = 3 _CUT_DESCENT_MAX = 3
#: 11. 절사 끔 — 고르개가 보내는 값. 빈 값도 같이 받는다(저장 안 된 옛 프로젝트). #: 11. 절사 끔 — 고르개가 보내는 값. 빈 값도 같이 받는다(저장 안 된 옛 프로젝트).
CUT_OFF = ("", "none") CUT_OFF = ("", "none")
@@ -383,30 +271,64 @@ def _calculate_with_scale(
**걸음마다 ÷1.1 해야 한다**(2026-09-14 고침). 이윤을 1 깎으면 부가세가 따라 줄어 **걸음마다 ÷1.1 해야 한다**(2026-09-14 고침). 이윤을 1 깎으면 부가세가 따라 줄어
총공사비는 **1.1** 줄므로, 잔차를 그대로 빼면 경계를 **지나쳐** 진동한다 총공사비는 **1.1** 줄므로, 잔차를 그대로 빼면 경계를 **지나쳐** 진동한다
울진 신설 실측 `654 999 900 910 909 ` 앉았다. ÷1.1 하면 `654 999 0`. 울진 신설 실측 `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) result = _calculate_once(data, dataset, scale, notes)
if data.cut_basis in CUT_OFF or data.cut_unit_krw <= 0: if data.cut_basis in CUT_OFF or data.cut_unit_krw <= 0:
return result return result
key = "grand_total" if data.cut_basis == "grand_total" else "total_cost" 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 automatic = _ZERO
gap = _ZERO gap = _ZERO
for _ in range(_CUT_MAX_PASSES): landed = result
gap = cut_gap(result.totals[key], data.cut_unit_krw) 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: if gap == 0:
automatic = low
break break
# 실무 식 — 총공사비 절사 + 부가세가 공급가액 비례면 ÷1.1(잔차 걸음도 같음). # 지나쳤다 = 그 배수는 못 밟는 자리. 한 칸 아래를 노린다.
automatic += profit_cut(gap, data.cut_basis, data.vat_mode) target -= data.cut_unit_krw
adjusted = replace( result = landed
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)
result.notes.append( result.notes.append(
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만 절사 —" f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만 절사 —"
f" 이윤에서 {automatic:,}원 자동보정." f" 이윤에서 {automatic:,}원 자동보정."
@@ -416,7 +338,7 @@ def _calculate_with_scale(
if gap: if gap:
# 조용히 남기지 않는다 — 안 앉았으면 끝자리가 남았다는 것을 화면에 보인다. # 조용히 남기지 않는다 — 안 앉았으면 끝자리가 남았다는 것을 화면에 보인다.
result.notes.append( result.notes.append(
f"⚠ 절사가 {_CUT_MAX_PASSES}걸음 안에 안 앉음 —" f"⚠ 절사가 {_CUT_DESCENT_MAX} 안에 안 앉음 —"
f" 끝자리 {gap:,}원이 남음(설계자 확인 필요)" f" 끝자리 {gap:,}원이 남음(설계자 확인 필요)"
) )
return result 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] = { SURCHARGE_INCLUDED_ITEMS: dict[str, str] = {
"FP-12-02": "용적 배합 콘크리트 참고표 「재료량에는 할증률이 포함」(마스터가 12-2 에 붙임)", "FP-12-02": "용적 배합 콘크리트 참고표 「재료량에는 할증률이 포함」(마스터가 12-2 에 붙임)",
"FP-12-38-02": "유로폼 사용수량 [주]① 「재료량에는 재료의 할증 및 손율이 포함」",
"FP-13-11-04": "돌망태 사각형 [주]① 「자재비에는 재료의 할증을 포함」", "FP-13-11-04": "돌망태 사각형 [주]① 「자재비에는 재료의 할증을 포함」",
"AX-WK-c0842a0d": "모르타르 배합 참고자료 ※ 「위 재료량은 할증이 포함된 것이다」", "AX-WK-c0842a0d": "모르타르 배합 참고자료 ※ 「위 재료량은 할증이 포함된 것이다」",
} }
+105 -7
View File
@@ -54,6 +54,75 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = {
"ⓘ 성토면 · 기계: 원문이 굴착기 0.6㎥ 형식을 안 적어 무한궤도·타이어 두 갈래로 세움" "ⓘ 성토면 · 기계: 원문이 굴착기 0.6㎥ 형식을 안 적어 무한궤도·타이어 두 갈래로 세움"
"(제안 무한궤도 — 영월 실무 「06M3 B/H」 · 2026-09-14 브레인 ②).", "(제안 무한궤도 — 영월 실무 「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%(합판과 각재의 설계단가 기준)」 은 원문이 셈을 안 줘 값으로 안 씀"
" — [주]① 2회 이상 사용 고재량은 재료비 비율에 기포함. "
"ⓘ 재료(합판·못 카탈로그 없음 · 각재·철선·박리제 규격 미정)는 못 붙은 줄 — 지금 값은"
" **인력 품만** · [주]③ 동바리 별도 · [주]⑥ 소형구조물 인력품 30% 할증(선택)은 안 걺.",
),
"FP-05-24": (
"실무 역산",
"ⓘ 원문이 애매해 실무(거창 「초류종자살포(씨드스프레이)」)를 따른 자리 둘 — 뒤집을 수 있게 어긋남을"
" 나란히: ① 「트럭 4.5ton」 → 덤프트럭 4.5(원문은 트럭 · 카탈로그에 트럭 없음 · 거창이 덤프트럭 4.5Ton)"
" ② 「종자살포기 2,500-3,000ℓ」 → 취부기 11.94㎾(원문 규격은 ℓ 탱크 용량 · 카탈로그는 ㎾ · 건설 4-1-3"
"·거창이 취부기) · 취부기는 손료만(8-4 운전경비표에 줄 없음 · 거창도 손료만) · ⚠ 거창은 디젤엔진 11.19㎾"
" 를 따로 세움 — 산림 5-24 · 건설 4-1-3 표 둘 다에 없어 안 넣음 · 물탱크 조종원은 화물차운전사(8-1-2 5호"
" 살수차) · [주]⑦ 물주기(인력) 보통인부 0.0005인은 필요시라 안 걺(2026-09-15 브레인).",
),
"FP-12-25": ( "FP-12-25": (
"운반거리 미정", "운반거리 미정",
"⚠ 이 값에는 **운반 몫이 빠져 있습니다** — 품셈 12-25 는 「운반 | 덤프트럭(15ton)」 줄을 " "⚠ 이 값에는 **운반 몫이 빠져 있습니다** — 품셈 12-25 는 「운반 | 덤프트럭(15ton)」 줄을 "
@@ -76,13 +145,7 @@ CONDITIONAL_INCLUDED: dict[str, str] = {
#: (표 읽기 사유 「박리제 줄을 못 풀었습니다」는 사람을 엉뚱한 데로 보낸다). #: (표 읽기 사유 「박리제 줄을 못 풀었습니다」는 사람을 엉뚱한 데로 보낸다).
#: ⚠ 이 구현이 서면 그 줄을 지운다. #: ⚠ 이 구현이 서면 그 줄을 지운다.
BLOCKED_BY_DESIGN: dict[str, str] = { BLOCKED_BY_DESIGN: dict[str, str] = {
# 2026-09-13 브레인 판정 — B09 일위대가 일감(B08 Formwork 머리말 「횟수별 재료 환산은 여기서 # (비어 있음) 12-4 합판거푸집은 2026-09-14 사용횟수 갈래로 풀려 걷음(`_JudgedTable`).
# 하지 않는다」). 표를 그대로 풀면 1회 사용 값(재료·노무 100 %)으로 조용히 비싸진다.
"FP-12-04": (
"거푸집 **사용횟수별 비율**(12-4 「1회 100 % · 4회 40 %…」)을 일위대가에 거는 셈이"
" 아직 없어"
" 풀지 않았습니다 — 풀면 1회 사용 값으로 비싸게 섭니다"
),
} }
@@ -108,6 +171,41 @@ def known_gap_note(code: str | None) -> str:
return " / ".join(parts) 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) PIPE_TABLE_DIAMETERS_MM = (800, 1000, 1200)
@@ -12,8 +12,10 @@
(1-4-1 어린나무가꾸기에 한하여 · 1-4-2 줄베기 · 1-4-9 숲가꾸기 병해충방제 (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_PERCENT = re.compile(r"^-?\d+(?:\.\d+)?%$")
_RE_SECTION = re.compile(r"^(1-4-\d+)\.\s*(.+)$") _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% #: `"sum"` = 10% + 5% = 15% · `"product"` = 1.10 × 1.05 1 = 15.5%
COMBINE_RULE = "sum" COMBINE_RULE = "sum"
COMBINE_NOTE = ( COMBINE_NOTE = (
"여럿을 고르면 더합니다 — 원문이 합산인지 곱인지 안 정해 우리가 그렇게 두었습니다" "여럿을 고르면 합산 — 건설 공통 1-4-2 「W = 기본품 × (1 + a1 + a2 + … + an)」"
" (사용자 확정 대기)." "(산림품셈 1-4 에 겹침 규정이 없어 교차 참조) · ⚠ 같은 조 단서 「동일성격의 품할증요소의"
" 이중적용은 불가」 — 어느 계열끼리 동일성격인지는 원문이 안 정해 설계자가 가림"
) )
SEAT_NOTE = ( SEAT_NOTE = (
"품 할인·할증은 품(인력) 줄에 붙습니다 — 물량에 곱하면 자재·기계까지 부풀어" "품 할인·할증은 품(인력) 줄에 붙습니다 — 물량에 곱하면 자재·기계까지 부풀어"
@@ -145,6 +145,15 @@ GLUED_MACHINE_FIXES: dict[str, tuple[str, str]] = {
"7120-0746": ("버킷식준설기", "7.46kW"), "7120-0746": ("버킷식준설기", "7.46kW"),
"7995-0050": ("배관파이프", "ø50-2.6m"), "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] = { EMPTY_NAME_BY_GROUP: dict[str, str] = {
"0240": "유압식 진동콤팩터(굴착기 부착용)", "0240": "유압식 진동콤팩터(굴착기 부착용)",
@@ -234,6 +243,10 @@ def load_machine_catalog(file_name: str = "mach_base_2026.json") -> MachineCatal
elif code in GLUED_MACHINE_FIXES: elif code in GLUED_MACHINE_FIXES:
name, spec = GLUED_MACHINE_FIXES[code] name, spec = GLUED_MACHINE_FIXES[code]
row = {**row, "machine_name": name, "specification": spec} 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: 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]]} row = {**row, "machine_name": EMPTY_NAME_BY_GROUP[code[:4]]}
catalog.machines[code] = MachineSpec( catalog.machines[code] = MachineSpec(
@@ -81,6 +81,7 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
"""**내역에 실제로 선 기종만** 한 장씩. 안 쓰는 613 기종을 다 뿌리지 않는다.""" """**내역에 실제로 선 기종만** 한 장씩. 안 쓰는 613 기종을 다 뿌리지 않는다."""
from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price
from B09_Estimation.B09_Estimation_MachineOperating import ( from B09_Estimation.B09_Estimation_MachineOperating import (
LOSS_ONLY_MACHINES,
load_operating_records, load_operating_records,
load_operator_wages, load_operator_wages,
) )
@@ -88,7 +89,6 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
catalog = load_machine_catalog() catalog = load_machine_catalog()
operating = {row.machine_code: row for row in load_operating_records().records} operating = {row.machine_code: row for row in load_operating_records().records}
wages = load_operator_wages() wages = load_operator_wages()
fuel_price, fuel_meta = load_fuel_price()
loss = _loss_records() loss = _loss_records()
sheets: list[dict[str, Any]] = [] sheets: list[dict[str, Any]] = []
@@ -101,11 +101,33 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
continue continue
record = operating.get(machine_code) record = operating.get(machine_code)
raw = loss.get(machine_code) or {} 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) money = build.book.resolve(code)
gaps: list[str] = [] gaps: list[str] = []
attachment = machine_code.startswith(_ATTACHMENT_PREFIXES) loss_only = LOSS_ONLY_MACHINES.get(machine_code, "") # 8-4 에 줄 없음 · 손료만(②′)
attachment = machine_code.startswith(_ATTACHMENT_PREFIXES) or bool(loss_only)
fuel_liters = getattr(record, "fuel_liters_per_hour", None) 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) misc_percent = getattr(record, "misc_material_percent", None)
occupation = getattr(record, "operator_occupation_code", "") or "" occupation = getattr(record, "operator_occupation_code", "") or ""
wage = wages.get(occupation) wage = wages.get(occupation)
@@ -136,8 +158,8 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
"management_coefficient": raw.get("management_coefficient_1e_minus_7"), "management_coefficient": raw.get("management_coefficient_1e_minus_7"),
"loss_coefficient": raw.get("source_coefficient_1e_minus_7"), "loss_coefficient": raw.get("source_coefficient_1e_minus_7"),
"loss_krw_per_hour": _money( "loss_krw_per_hour": _money(
machine.price_thousand_krw * _THOUSAND * machine.loss_coefficient_per_hour machine.price_thousand_krw * _THOUSAND * loss_per_hour
if machine.loss_coefficient_per_hour is not None if loss_per_hour is not None
else None else None
), ),
# ② 운전경비 # ② 운전경비
@@ -145,7 +167,7 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
"fuel_price_per_liter": _money(fuel_price), "fuel_price_per_liter": _money(fuel_price),
"fuel_scope": fuel_meta.get("region_name") or "전국 공시가", "fuel_scope": fuel_meta.get("region_name") or "전국 공시가",
"misc_material_percent": ( "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_code": occupation,
"operator_daily_wage": _money(wage), "operator_daily_wage": _money(wage),
@@ -161,7 +183,7 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
"expense_krw": _money(money.expense), "expense_krw": _money(money.expense),
"total_krw": _money(money.total), "total_krw": _money(money.total),
"attachment": attachment, "attachment": attachment,
"attachment_note": ATTACHMENT_NOTE if attachment else "", "attachment_note": loss_only or (ATTACHMENT_NOTE if attachment else ""),
"gaps": gaps, "gaps": gaps,
} }
) )
@@ -43,11 +43,38 @@ _RE_FUEL_WITH_KIND = re.compile(r"^(휘발유|중유|경유)?(\d+(?:\.\d+)?)$")
#: 조종원 직종 — 품셈 표는 「인/일」 수만 주고 직종명을 안 준다. #: 조종원 직종 — 품셈 표는 「인/일」 수만 주고 직종명을 안 준다.
#: TODO(미결 PLAN 9-6): 기종별 직종이 품셈 다른 장에 있다. 아래는 `aliases` 기반 **잠정**이며 #: TODO(미결 PLAN 9-6): 기종별 직종이 품셈 다른 장에 있다. 아래는 `aliases` 기반 **잠정**이며
#: 결과에 `operator_mapping_is_provisional: true` 로 드러난다. #: 결과에 `operator_mapping_is_provisional: true` 로 드러난다.
_OPERATOR_TRUCK_WORDS = ("덤프트럭", "트럭", "트레일러", "화물") #: 「살수차」 — 8-1-2 5호 화물차운전사 줄이 이름으로 적음(원문 또렷 · 물탱크(살수차) 7204 · 2026-09-15 ③).
_OPERATOR_TRUCK_WORDS = ("덤프트럭", "트럭", "트레일러", "화물", "살수차")
_OPERATOR_ALIAS_TRUCK = "labor_op_truck" _OPERATOR_ALIAS_TRUCK = "labor_op_truck"
_OPERATOR_ALIAS_CONSTRUCTION = "labor_op_const" _OPERATOR_ALIAS_CONSTRUCTION = "labor_op_const"
#: ⚠ **셀 병합으로 줄째 버려지는 표에서 원문 값을 되살린다** (2026-09-15 초류종자살포 ③ 브레인).
#: 8-4-8 기타기계(원문 L3458)는 36기종이 한 칸에 뭉치고 자동세륜기 「-」 하나가 두 기종 몫이라 잡재료 35 ·
#: 조종원 34 개로 짝이 안 맞아 줄째 버려짐. 양끝 맞추기는 우연 일치로 틀린 값이 붙어 못 씀 → 공종이 쓰는
#: 물탱크(살수차) 다섯만 손으로(주연료 칸 · 잡재료 30 · 조종원 1 — 앞 기종 수가 칸마다 같아 자리가 또렷).
#: 버려진 줄 셋 81기종 중 공종이 쓰는 것은 이 다섯뿐(나머지는 막는 공종 0 이라 미룸).
#: 거창 실무 5,500ℓ 호표 「경유 9.3ℓ · 잡품 30% · 화물차운전사 1인」 일치. ⚠ 원천이 바로 실으면 이 표는 지운다.
MERGED_CELL_OPERATING_FIXES: dict[str, str] = {
"7204-0018": "8.2",
"7204-0038": "8.6",
"7204-0055": "9.3",
"7204-0065": "9.4",
"7204-0160": "12.9",
}
#: **운전경비 줄이 원문에 없는 기계** — 손료만으로 층을 세움(부착 장비 길 · 2026-09-15 브레인 판정 ②′).
#: ⚠ 이 목록 밖으로 넓히지 말 것 — 연료가 빠진 채 조용히 싼 값이 섬. 줄마다 원문 없음 · 실무 실증을 적음.
LOSS_ONLY_MACHINES: dict[str, str] = {
"7750-0016": (
"손료만 — 건설품셈 8-4 운전경비표에 취부기(7750) 줄이 없음(원문 없음) · 거창 실무 「취부기(녹생토)"
" 11.94kW」 도 손료 20,295 만 · ⚠ 거창은 디젤엔진 11.19kW 를 따로 세움 — 산림 5-24 · 건설 4-1-3"
" 표 둘 다에 없어 안 넣음"
),
}
_WATER_TANK_MISC_PERCENT = Decimal(30)
_WATER_TANK_OPERATORS = Decimal(1)
class OperatingCostError(ValueError): class OperatingCostError(ValueError):
"""운전경비 표를 못 읽은 경우. 어긋난 채로 짝짓지 않는다.""" """운전경비 표를 못 읽은 경우. 어긋난 채로 짝짓지 않는다."""
@@ -278,6 +305,20 @@ def load_operating_records(
aliases = _read_json(*_CATALOG_SUBPATH, labor_file)["variables"].get("aliases", {}) aliases = _read_json(*_CATALOG_SUBPATH, labor_file)["variables"].get("aliases", {})
catalog = load_machine_catalog() catalog = load_machine_catalog()
parsed = parse_operating_tables(pum, aliases, set(catalog.machines)) parsed = parse_operating_tables(pum, aliases, set(catalog.machines))
read = {record.machine_code for record in parsed.records}
for code, liters in MERGED_CELL_OPERATING_FIXES.items():
if code not in read:
parsed.records.append(
OperatingRecord(
machine_code=code,
machine_name="",
specification="",
fuel_liters_per_hour=Decimal(liters),
fuel_kind="경유",
misc_material_percent=_WATER_TANK_MISC_PERCENT,
operator_person_days=_WATER_TANK_OPERATORS,
)
)
return enrich_with_catalog(parsed, catalog, aliases) return enrich_with_catalog(parsed, catalog, aliases)
@@ -316,9 +357,14 @@ def write_operating_records(
#: 시도별 유가 판 — 품셈 8-1-7 5호 「유류가격은 **해당지역의 가격**으로 한다」. #: 시도별 유가 판 — 품셈 8-1-7 5호 「유류가격은 **해당지역의 가격**으로 한다」.
#: ⚠ **파일이 있을 때만 지역을 고를 수 있다** — 없으면 전국평균 한 벌로 돈다(코드로 막지 않음). #: ⚠ **파일이 있을 때만 지역을 고를 수 있다** — 없으면 전국평균 한 벌로 돈다(코드로 막지 않음).
REGIONAL_OIL_FILE = "oil_regional_2026-09-09.json" 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`(전국) **지역 선택지에서 뺀다** 자리는 전국평균 판이 원문에 있는 코드 `00`(전국) **지역 선택지에서 뺀다** 자리는 전국평균 판이
@@ -328,7 +374,7 @@ def load_regional_fuel_table(oil_file: str = REGIONAL_OIL_FILE) -> tuple[dict[st
payload = _read_json(*_CATALOG_SUBPATH, oil_file) payload = _read_json(*_CATALOG_SUBPATH, oil_file)
except FileNotFoundError: except FileNotFoundError:
return {}, {} return {}, {}
diesel = payload["variables"]["oil_diesel"] diesel = payload["variables"][FUEL_VARIABLES[kind]]
table = { table = {
str(record["sido_code"]): { str(record["sido_code"]): {
"name": str(record["sido_name"]), "name": str(record["sido_name"]),
@@ -346,7 +392,7 @@ def load_regional_fuel_table(oil_file: str = REGIONAL_OIL_FILE) -> tuple[dict[st
def load_fuel_price( 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]]: ) -> tuple[Decimal, dict[str, str]]:
"""경유 단가와 그 판의 신원. `region`(시도코드)을 주면 **그 지역 값**으로 선다. """경유 단가와 그 판의 신원. `region`(시도코드)을 주면 **그 지역 값**으로 선다.
@@ -355,7 +401,7 @@ def load_fuel_price(
지역이 판에 없으면 **조용히 전국평균으로 눕지 않고** 사실을 신원에 적는다. 지역이 판에 없으면 **조용히 전국평균으로 눕지 않고** 사실을 신원에 적는다.
""" """
payload = _read_json(*_CATALOG_SUBPATH, oil_file) payload = _read_json(*_CATALOG_SUBPATH, oil_file)
diesel = payload["variables"]["oil_diesel"] diesel = payload["variables"][FUEL_VARIABLES[kind]]
meta = { meta = {
"dataset_id": payload.get("dataset_id", ""), "dataset_id": payload.get("dataset_id", ""),
"effective_date": payload.get("effective_date", ""), "effective_date": payload.get("effective_date", ""),
@@ -366,7 +412,7 @@ def load_fuel_price(
if not region: if not region:
return Decimal(str(diesel["value"])), meta 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)) picked = table.get(str(region))
if picked is None: if picked is None:
meta["region"] = str(region) meta["region"] = str(region)
@@ -429,10 +475,12 @@ def hourly_cost_of(machine_code: str, *, region: str | None = None):
if record is None: if record is None:
return hourly_machine_cost(machine) return hourly_machine_cost(machine)
fuel_price, _ = load_fuel_price(region=region)
wages = load_operator_wages() wages = load_operator_wages()
liters = record.fuel_liters_per_hour 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: if liters is not None and record.misc_material_percent is not None:
# 잡재료는 **주연료의 %** 라 유가와 같이 움직인다(PLAN 8-18 유가 민감분). # 잡재료는 **주연료의 %** 라 유가와 같이 움직인다(PLAN 8-18 유가 민감분).
liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100)) 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" 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: def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Decimal, ...]) -> None:
"""거리마다 덤프 운반 일위대가를 세운다 — X(덤프트럭 15ton) → D → B. """거리마다 덤프 운반 일위대가를 세운다 — 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, kind=PriceKind.UNIT_PRICE,
name=f"{names.get(DUMP_PARENT) or '덤프운반'} " name=f"{names.get(DUMP_PARENT) or '덤프운반'} "
f"{names.get(code) or material.label}", f"{names.get(code) or material.label}",
spec=f"L={distance.normalize():f}m", spec=distance_label(distance),
unit="", unit="",
) )
) )
+3 -1
View File
@@ -186,7 +186,9 @@ def _assemble(build: Any, node: dict[str, Any], names: dict[str, str]) -> None:
return return
per_step.append((step, weight, titles)) per_step.append((step, weight, titles))
varying = [item for item in per_step if set(item[2]) - {""}] 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] = "갈래가 두 단계 이상에 걸쳐 조립하지 않았습니다" build.component_gaps[parent] = "갈래가 두 단계 이상에 걸쳐 조립하지 않았습니다"
return return
units = {build.book.titles[t].unit for _, _, ts in per_step for t in ts.values()} - {""} units = {build.book.titles[t].unit for _, _, ts in per_step for t in ts.values()} - {""}
+55 -104
View File
@@ -27,6 +27,16 @@ from dataclasses import dataclass, field, replace
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from typing import Any from typing import Any
# 셀 거르기 · 물결표 목록 · 셀 정규화 — 700줄 제한으로 `_Labels` 에 둠(순수 분리 2026-09-14).
# 바깥 파일이 여기서 가져가는 이름은 그대로 다시 내보낸다.
from B09_Estimation.B09_Estimation_ResourceAxis_Labels import (
_RE_RANGE_CELL, # noqa: F401
RANGE_DASH_CLASS, # noqa: F401
RANGE_DASHES, # noqa: F401
_normalize,
is_non_resource_label,
)
#: 자원 축을 붙일 수 있는 표 형태. 나머지는 값을 쓰지 않는다. #: 자원 축을 붙일 수 있는 표 형태. 나머지는 값을 쓰지 않는다.
USABLE_FORMS = frozenset({"productivity", "requirement"}) USABLE_FORMS = frozenset({"productivity", "requirement"})
#: 공종이 아닌 표 — 일위대가 항목으로 세우지 않는다. #: 공종이 아닌 표 — 일위대가 항목으로 세우지 않는다.
@@ -48,45 +58,6 @@ _RE_NUMBER = re.compile(r"^-?\d+(?:,\d{3})*(?:\.\d+)?$")
#: 보통인부 한 줄만 남았다). #: 보통인부 한 줄만 남았다).
_GROUP_LABELS = ("자재", "장비", "인력", "노무", "재료", "기계") _GROUP_LABELS = ("자재", "장비", "인력", "노무", "재료", "기계")
#: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다.
#: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다.
#: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`·
#: `계장공` 이, 「작업」을 잡으면 `작업반장` 이, 「인력」을 잡으면 `인력운반공` 이
#: 통째로 사라진다(2026-09-07 실측 — 정상 자원 **70/745** 가 걸리고 있었음).
#: 그래서 **셀 전체가 그 말과 같을 때만** 머리글로 본다.
_NON_RESOURCE_WORDS = (
"구분",
"합계",
"소계",
"",
"단위",
"비고",
"규격",
"명칭",
"품명",
"종류",
"항목",
"적용",
"기준",
"산출",
"비율",
"할증",
"할인",
"직접노무비",
"재료비",
"경비",
"위치",
"면적",
"수량",
"공종",
"작업",
"내역",
"총계",
"인력",
"장비",
"기계",
)
class ResourceAxisError(ValueError): class ResourceAxisError(ValueError):
"""자원 축을 붙일 수 없는 경우. 조용히 넘기지 않는다.""" """자원 축을 붙일 수 없는 경우. 조용히 넘기지 않는다."""
@@ -151,71 +122,6 @@ class ResourceCatalog:
return narrowed[0] if len(narrowed) == 1 else None return narrowed[0] if len(narrowed) == 1 else None
#: 첫 칸이 자원 이름이 **아닌** 표가 많다 — 규격 구간표(「10∼12」), 기호표(「f」·「E」),
#: 치수표 등. 그런 셀을 못 맞춘 목록에 넣으면 목록이 못 쓰게 되므로 먼저 거른다.
#: ⚠ **물결표·붙임표 목록은 여기 한 벌뿐이다.** 네 파일에 따로 적어 두었더니 서로
#: 달라졌다(2026-09-08 메인 창 교차검토 — 어떤 목록엔 `~`, 어떤 목록엔 `〜` 가 빠졌음).
#: 지금 물리는 것은 없었으나 **같은 목록이 네 벌이면 언젠가 하나만 고쳐진다.**
#: 갈래 키 정규화(`B09_Estimation_UnitPrice.normalize_variant_key`)도 이 목록을 쓴다.
RANGE_DASHES = "∼~〜~-–‐"
#: ⚠ **문자클래스에 그대로 넣지 말 것** — `~-–` 이 **범위 연산자**로 읽혀 거의 모든
#: 글자가 걸린다(2026-09-08 실측: 「0.7㎥」·「15톤」이 구간으로 잡혔음). 반드시 이 쪽을 쓴다.
RANGE_DASH_CLASS = "".join(re.escape(ch) for ch in RANGE_DASHES)
#: 구간 셀에는 **단위 꼬리**가 붙기도 한다 — 「51~100m」·「1214㎝」(2026-09-08 실측 39줄).
#: ⚠ **단위가 붙었다고 다 구간이 아니다** — 「굴착기 0.7㎥」는 규격이고 자원 이름의 일부다.
#: 그래서 **수 ~ 수 + 단위**라는 모양 전체가 맞을 때만 구간으로 본다(앞에 이름이 없어야 한다).
_RANGE_UNITS = "a-zA-Z㎝㎜㎥㎡㎞mm톤"
_RE_RANGE_CELL = re.compile(
rf"^\d+(?:\.\d+)?\s*[{RANGE_DASH_CLASS}]\s*\d+(?:\.\d+)?\s*[{_RANGE_UNITS}]+$"
)
_RE_HANGUL = re.compile(r"[가-힣]")
def is_non_resource_label(cell: str) -> bool:
"""표 머리글·소계 행이거나, 애초에 자원 이름이 올 자리가 아닌 셀인가.
맞춘 목록에 이런 것이 섞이면 목록 자체가 쓰게 된다. 여기서 먼저 걷어낸다.
"""
text = _normalize(cell)
if not text:
return True
if text.startswith(("", "<", "(", "-", "", "·")):
return True
if _RE_RANGE_CELL.match(text): # 규격 구간표의 첫 칸
return True
# 자원 이름은 숫자로 시작하지 않는다 — 「50이상」·「100m이하」·「2.집재」는 구간·절번호다.
if text[0].isdigit():
return True
# 자원 이름은 한글 두 자 이상이다. 기호(`f`·`E`)·숫자·단위만 있는 칸은 자원이 아니다.
if len(_RE_HANGUL.findall(text)) < 2:
return True
# ⚠ **정확 일치만** — 부분일치는 정상 자원을 통째로 지운다(위 주석).
if text in _NON_RESOURCE_WORDS:
return True
# 머리글 조각이 이어 붙은 칸(「단위작업별」·「위치및면적」)도 머리글이다.
return _is_header_composite(text)
def _is_header_composite(text: str) -> bool:
"""머리글 낱말만으로 이루어진 칸인가 — 「단위작업별」·「위치및면적」 같은 것.
낱말을 차례로 벗겨 아무것도 남으면 머리글로 본다. 자원 이름은 낱말을 벗기면
반드시 무언가 남는다(`건설기계운전사` `건설`·`운전사`).
"""
rest = text
for word in sorted(_NON_RESOURCE_WORDS, key=len, reverse=True):
rest = rest.replace(word, "")
rest = rest.replace("", "").replace("", "").strip()
return rest == ""
def _normalize(text: str) -> str:
"""표 셀의 공백·개행 흔들림을 지운다. 「경 암」·「연 암」 같은 것."""
return re.sub(r"\s+", "", str(text or "")).strip()
@dataclass @dataclass
class ResourceRow: class ResourceRow:
"""자원 축 한 줄 — 공종(표) 하나에 붙는 자원 하나.""" """자원 축 한 줄 — 공종(표) 하나에 붙는 자원 하나."""
@@ -320,6 +226,28 @@ def _tidy_resource_name(cell: str) -> str:
_MACHINE_UNITS = ("시간", "hr", "h", "", "시 간") _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: def _is_machine_like_row(cells: list[str]) -> bool:
"""그 줄이 **장비 몫**인가 — 단위 칸이 시간·대수인지로 본다.""" """그 줄이 **장비 몫**인가 — 단위 칸이 시간·대수인지로 본다."""
for cell in cells: for cell in cells:
@@ -442,6 +370,7 @@ def match_table(
# 사람이 모양을 읽어 둔 표(9-19-1 절토면·성토면 · 2026-09-14 판정) — 적어 둔 표만. # 사람이 모양을 읽어 둔 표(9-19-1 절토면·성토면 · 2026-09-14 판정) — 적어 둔 표만.
from B09_Estimation.B09_Estimation_ResourceAxis_JudgedTable import match_judged_table from B09_Estimation.B09_Estimation_ResourceAxis_JudgedTable import match_judged_table
if match_judged_table(node, table, catalog, result, basis_quantity, unit): if match_judged_table(node, table, catalog, result, basis_quantity, unit):
return return
@@ -573,6 +502,17 @@ def match_table(
reason="자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", 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 continue
# ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때 # ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때
@@ -584,6 +524,17 @@ def match_table(
name_cell = apply_scoped_alias(catalog, name_cell, node["work_item_code"], value_cells) 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]) entry = entry or _resolve_cell(catalog, name_cell, [name_cell, *value_cells])
if entry is None: 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): if is_non_resource_label(name_cell):
continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다 continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다
# 「규격 미정 — 후보 N」 · 「같은 이름 여럿」 · 「카탈로그에 없는 이름」을 가른다. # 「규격 미정 — 후보 N」 · 「같은 이름 여럿」 · 「카탈로그에 없는 이름」을 가른다.
@@ -128,14 +128,20 @@ def scoped_alias_entry(
흐려짐 별칭이 코드를 적었으면 코드가 (2026-09-14 · 9-19-3 소형브레이커). 흐려짐 별칭이 코드를 적었으면 코드가 (2026-09-14 · 9-19-3 소형브레이커).
별칭은 ** 곳을 채움** 칸이 규격을 적었으면 덮음(같은 9-19-1 성토면 굴착기 | 별칭은 ** 곳을 채움** 칸이 규격을 적었으면 덮음(같은 9-19-1 성토면 굴착기 |
0.6 절토면 [] 0.7 덮이던 자리 · 2026-09-14 판정 ). 0.6 절토면 [] 0.7 덮이던 자리 · 2026-09-14 판정 ).
, 대상 규격이 규격과 **같으면** 덮는 것이 아님 이름만 이음(5-24 트럭 | 4.5ton
덤프트럭 4.5 · 2026-09-15 판정 ).
""" """
if _writes_spec(side_cells):
return None
wanted = _normalize(name_cell) wanted = _normalize(name_cell)
for row in catalog.scoped_aliases: for row in catalog.scoped_aliases:
if _normalize(row["from"]) != wanted or not in_scope(work_item_code, row["scope"]): if _normalize(row["from"]) != wanted or not in_scope(work_item_code, row["scope"]):
continue continue
return next((entry for entry in catalog.entries if entry.code == row["to"]), None) entry = next((entry for entry in catalog.entries if entry.code == row["to"]), None)
if entry is not None and _writes_spec(side_cells):
same = any(
_RE_SIDE_SPEC.match(_normalize(c)) and same_spec(entry.spec, c) for c in side_cells
)
return entry if same else None
return entry
return None return None
@@ -5,6 +5,8 @@
F0288 1. 절토면 고르기 자원 이름이 ** 자료 **(보통인부 () ) · 규격은 [] 에만 F0288 1. 절토면 고르기 자원 이름이 ** 자료 **(보통인부 () ) · 규격은 [] 에만
· · = 토질엔 자원 없음 · · = 토질엔 자원 없음
F0289 2. 성토면 고르기 병합 (시공) 탓에 둘째 줄이 앞당겨 F0289 2. 성토면 고르기 병합 (시공) 탓에 둘째 줄이 앞당겨
F0336 12-4 합판거푸집 기준수량(1) × 사용횟수별 비율 재료 줄은 재료 %, 인력 줄은 노무비 %
(원문 L6187 · 2026-09-14 브레인 비율 셈이 없어 막아 뒀던 )
일반 보정으로 넓히지 않음 밀린 고쳐 읽기는 목재틀흙막이 503만원 전례(`_Transposed` 머리말). 일반 보정으로 넓히지 않음 밀린 고쳐 읽기는 목재틀흙막이 503만원 전례(`_Transposed` 머리말).
**적어 표만** 읽고, 칸이 판정과 다르면 줄도 세우고 막음. **적어 표만** 읽고, 칸이 판정과 다르면 줄도 세우고 막음.
@@ -47,7 +49,123 @@ JUDGED_TABLES: dict[str, dict[str, Any]] = {
"form_split": {"굴착기": ("무한궤도", "타이어")}, "form_split": {"굴착기": ("무한궤도", "타이어")},
"why": "원문 L5430 「2. 성토면 고르기 (단위: 10㎡당)」 — 시공 칸 병합으로 둘째 줄 앞당김", "why": "원문 L5430 「2. 성토면 고르기 (단위: 10㎡당)」 — 시공 칸 병합으로 둘째 줄 앞당김",
}, },
"F0336": {
"code": "FP-12-04",
"shape": "use_count",
"prefix": "합판거푸집",
"why": "원문 L6187 「기준수량(1회사용) · 사용횟수별기준수량에대한 비율(%) 재료별·노무비」",
},
"F0353": {
"code": "FP-12-15",
"shape": "remark_labor",
"prefix": "집수정",
# 구체콘크리트는 바로 아래 다짐기 줄과 한 갈래 — 다짐기가 안 풀리면 갈래를 안 세움(⑴).
"needs_machine": {"구체콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6460 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
# 12-15 와 같은 모양 셋 — 같은 봉상후렉시블 줄 하나가 셋을 막고 있었음(2026-09-14 브레인 · 672 다음).
"F0350": {
"code": "FP-12-12",
"shape": "remark_labor",
"prefix": "날개벽",
"needs_machine": {"콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6419 12-12 「콘크리트(레미콘) ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
"F0351": {
"code": "FP-12-13",
"shape": "remark_labor",
"prefix": "면벽",
"needs_machine": {"콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6436 12-13 「콘크리트(레미콘) ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
"F0354": {
"code": "FP-12-16",
"shape": "remark_labor",
"prefix": "맨홀",
# 칸이 하나 밀려 비고가 끝 칸이 아님(「구체콘크리트 | 철근 | ㎥ | | 비고 | 」).
"needs_machine": {"구체콘크리트": "봉상후렉시블(45mm)"},
"why": "원문 L6474 12-16 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
# 12-34-1 — 머리 「(단위: 개소당)」 이나 인력 0.17·0.29 가 12-16 맨홀 ㎥당과 같고 기계가 Q ㎥/hr
# ⇒ ㎥당으로 읽음 · 「봉상후렉시블 대 2」 는 엔진식 진동기(엔진+플렉시블 한 대)의 봉(2026-09-14 브레인).
"F0385": {
"code": "FP-12-34-01",
"shape": "per_m3_rows",
"prefix": "콘크리트 타설",
# 엔진식 진동기(건설품셈 8-3 (4611) 엔진+플렉시블 한 대)의 봉 — 「진동기(3.5HP) 대 2」 로 셈.
"same_machine": ("봉상후렉시블(45mm)",),
"why": "원문 L6839 12-34-1 「인력 콘크리트공·보통인부 인 · 기계 대 (Q=5.4㎥/hr)」",
},
# 시간 줄 표 — 장비·인력이 다 「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`) · 비고.
_NOT_RESOURCE_ROWS = ("사용고재평가기준", "비고", "횟수별")
#: 「보통인부 (인)」 의 단위 꼬리 — 규격이 아님. #: 「보통인부 (인)」 의 단위 꼬리 — 규격이 아님.
_UNIT_TAIL = re.compile(r"\s*[(](?:인|시간)[)]\s*$") _UNIT_TAIL = re.compile(r"\s*[(](?:인|시간)[)]\s*$")
@@ -99,14 +217,24 @@ def match_judged_table(
result.partial_items[code] = why result.partial_items[code] = why
return True 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("판정 표에 밑수가 없습니다") return block("판정 표에 밑수가 없습니다")
if judged["shape"] == "header_row": if judged["shape"] == "header_row":
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit) 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: else:
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit) staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
if isinstance(staged, str): if isinstance(staged, str):
return block(f"{staged} — 판정({judged['why']})과 칸이 달라 안 읽음") return block(f"{staged} — 판정({judged['why']})과 칸이 달라 안 읽음")
staged = [*staged, *_unread_rows(code, table_id, judged, rows, staged)]
for item in staged: for item in staged:
if isinstance(item, UnmatchedRow): if isinstance(item, UnmatchedRow):
result.unmatched.append(item) result.unmatched.append(item)
@@ -176,3 +304,134 @@ def _merged_first(code, table, judged, rows, catalog, basis, unit) -> list | str
named = f"{variant} · {form}" if form else variant named = f"{variant} · {form}" if form else variant
staged.append(_row(code, table, entry, amount / basis, unit, index, named)) staged.append(_row(code, table, entry, amount / basis, unit, index, named))
return staged return staged
def _use_count(code, table, rows, catalog, basis, unit) -> list | str:
"""[이름, 단위, 기준수량, …] + 비율 줄 하나 → 사용횟수마다 갈래(재료 % · 노무비 %)."""
ratio_row = next((cells for cells in rows if _RE_USE_COUNT.search(" ".join(cells))), None)
if ratio_row is None:
return "사용횟수 비율 줄"
counts = _RE_USE_COUNT.findall(" ".join(ratio_row))
number = re.compile(r"\d+(?:\.\d+)?")
material = [Decimal(x) for x in number.findall(ratio_row[4] if len(ratio_row) > 4 else "")]
labor = [Decimal(x) for x in number.findall(ratio_row[5] if len(ratio_row) > 5 else "")]
if not counts or not (len(counts) == len(material) == len(labor)):
return f"비율 칸 수(횟수 {len(counts)} · 재료 {len(material)} · 노무비 {len(labor)})"
staged: list = []
for index, cells in enumerate(rows):
name = "".join(cells[0].split()) if cells else ""
base = parse_amount(cells[2]) if len(cells) > 2 else None
if not name or name in _NOT_RESOURCE_ROWS or base is None:
continue
entry = _entry(catalog, cells[0], code)
if entry is None:
reason = unmatched_reason(catalog, cells[0])
staged.append(UnmatchedRow(code, str(table.get("pum_table_id", "")), cells[0], reason))
continue
ratios = labor if entry.kind == "labor" else material
for count, ratio in zip(counts, ratios):
amount = base * ratio / Decimal(100) / basis
staged.append(_row(code, table, entry, amount, unit, index, f"{count}"))
return staged
def _remark_labor(code, table, judged, rows, catalog) -> list | str:
"""㎥ 줄 비고 칸의 인력(인/㎥)으로 갈래 — 다짐기가 딸린 갈래는 그 기계가 풀려야 세움."""
table_id = str(table.get("pum_table_id", ""))
by_name = {"".join(cells[0].split()): cells for cells in rows if cells}
staged: list = []
for index, cells in enumerate(rows):
labors = _RE_REMARK_LABOR.findall(" ".join(cells[3:])) # 비고가 끝 칸이 아닌 표(12-16)
if len(cells) < 3 or cells[2] != "" or not labors:
continue
variant = cells[0].split("(")[0].strip()
pieces = []
for name, amount in labors:
entry = _entry(catalog, name, code)
if entry is None:
return f"{variant} 인력 「{name}"
pieces.append((entry, Decimal(amount)))
machine_name = judged.get("needs_machine", {}).get(variant)
if machine_name:
machine_cells = by_name.get("".join(machine_name.split())) or []
found_q = _RE_Q.search(" ".join(machine_cells))
entry = _entry(catalog, machine_name, code) if machine_cells else None
if entry is None or found_q is None:
reason = unmatched_reason(catalog, machine_name)
staged.append(UnmatchedRow(code, table_id, machine_name, reason))
why = (
f"다짐기 「{machine_name}」 가 안 풀려 갈래를 안 세움 — 인력만이면 조립 줄이"
" 조용히 싸짐(2026-09-14 ㉯ ⑴)"
)
staged.append(UnmatchedRow(code, table_id, variant, why))
continue
pieces.append((entry, Decimal(1) / Decimal(found_q.group(1))))
for entry, amount in pieces:
staged.append(_row(code, table, entry, amount, "", index, variant))
return staged
def _per_m3_rows(code, table, judged, rows, catalog) -> list | str:
"""「인」 칸 앞 이름 · 뒤 수(인/㎥) · 「대」 칸 앞 이름 · 뒤 대수 ÷ Q — 칸이 밀린 줄도 단위 칸으로 찾음."""
staged: list = []
for index, cells in enumerate(rows):
unit = next((i for i, c in enumerate(cells) if c in ("", "") and i > 0), None)
if unit is None:
continue
name = cells[unit - 1]
if "".join(name.split()) in judged.get("same_machine", {}):
continue # 같은 기계 두 번 안 셈 — 까닭은 공종 사유 한 줄(`KnownGaps`)이 화면에 보임
amount = next((parse_amount(c) for c in cells[unit + 1 :] if parse_amount(c)), None)
entry = _entry(catalog, name, code)
if amount is None or entry is None:
return f"{name}"
if cells[unit] == "":
found_q = _RE_Q.search(" ".join(cells))
if found_q is None:
return f"{name} Q"
amount = amount / Decimal(found_q.group(1))
staged.append(_row(code, table, entry, amount, "", index, ""))
return staged
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
@@ -0,0 +1,113 @@
"""B09 원가계산 — 자원 축 **셀 거르기 · 물결표 목록 · 셀 정규화** (`_ResourceAxis` 보조).
`B09_Estimation_ResourceAxis` 700 제한에 걸려 떼어 파일이다(2026-09-14 순수 분리
내용은 옮기기만 ). 바깥 파일은 여태처럼 `B09_Estimation_ResourceAxis` 에서 가져간다.
"""
from __future__ import annotations
import re
#: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다.
#: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다.
#: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`·
#: `계장공` 이, 「작업」을 잡으면 `작업반장` 이, 「인력」을 잡으면 `인력운반공` 이
#: 통째로 사라진다(2026-09-07 실측 — 정상 자원 **70/745** 가 걸리고 있었음).
#: 그래서 **셀 전체가 그 말과 같을 때만** 머리글로 본다.
_NON_RESOURCE_WORDS = (
"구분",
"합계",
"소계",
"",
"단위",
"비고",
"규격",
"명칭",
"품명",
"종류",
"항목",
"적용",
"기준",
"산출",
"비율",
"할증",
"할인",
"직접노무비",
"재료비",
"경비",
"위치",
"면적",
"수량",
"공종",
"작업",
"내역",
"총계",
"인력",
"장비",
"기계",
)
#: 첫 칸이 자원 이름이 **아닌** 표가 많다 — 규격 구간표(「10∼12」), 기호표(「f」·「E」),
#: 치수표 등. 그런 셀을 못 맞춘 목록에 넣으면 목록이 못 쓰게 되므로 먼저 거른다.
#: ⚠ **물결표·붙임표 목록은 여기 한 벌뿐이다.** 네 파일에 따로 적어 두었더니 서로
#: 달라졌다(2026-09-08 메인 창 교차검토 — 어떤 목록엔 `~`, 어떤 목록엔 `〜` 가 빠졌음).
#: 지금 물리는 것은 없었으나 **같은 목록이 네 벌이면 언젠가 하나만 고쳐진다.**
#: 갈래 키 정규화(`B09_Estimation_UnitPrice.normalize_variant_key`)도 이 목록을 쓴다.
RANGE_DASHES = "∼~〜~-–‐"
#: ⚠ **문자클래스에 그대로 넣지 말 것** — `~-–` 이 **범위 연산자**로 읽혀 거의 모든
#: 글자가 걸린다(2026-09-08 실측: 「0.7㎥」·「15톤」이 구간으로 잡혔음). 반드시 이 쪽을 쓴다.
RANGE_DASH_CLASS = "".join(re.escape(ch) for ch in RANGE_DASHES)
#: 구간 셀에는 **단위 꼬리**가 붙기도 한다 — 「51~100m」·「1214㎝」(2026-09-08 실측 39줄).
#: ⚠ **단위가 붙었다고 다 구간이 아니다** — 「굴착기 0.7㎥」는 규격이고 자원 이름의 일부다.
#: 그래서 **수 ~ 수 + 단위**라는 모양 전체가 맞을 때만 구간으로 본다(앞에 이름이 없어야 한다).
_RANGE_UNITS = "a-zA-Z㎝㎜㎥㎡㎞mm톤"
_RE_RANGE_CELL = re.compile(
rf"^\d+(?:\.\d+)?\s*[{RANGE_DASH_CLASS}]\s*\d+(?:\.\d+)?\s*[{_RANGE_UNITS}]+$"
)
_RE_HANGUL = re.compile(r"[가-힣]")
def is_non_resource_label(cell: str) -> bool:
"""표 머리글·소계 행이거나, 애초에 자원 이름이 올 자리가 아닌 셀인가.
맞춘 목록에 이런 것이 섞이면 목록 자체가 쓰게 된다. 여기서 먼저 걷어낸다.
"""
text = _normalize(cell)
if not text:
return True
if text.startswith(("", "<", "(", "-", "", "·")):
return True
if _RE_RANGE_CELL.match(text): # 규격 구간표의 첫 칸
return True
# 자원 이름은 숫자로 시작하지 않는다 — 「50이상」·「100m이하」·「2.집재」는 구간·절번호다.
if text[0].isdigit():
return True
# 자원 이름은 한글 두 자 이상이다. 기호(`f`·`E`)·숫자·단위만 있는 칸은 자원이 아니다.
if len(_RE_HANGUL.findall(text)) < 2:
return True
# ⚠ **정확 일치만** — 부분일치는 정상 자원을 통째로 지운다(위 주석).
if text in _NON_RESOURCE_WORDS:
return True
# 머리글 조각이 이어 붙은 칸(「단위작업별」·「위치및면적」)도 머리글이다.
return _is_header_composite(text)
def _is_header_composite(text: str) -> bool:
"""머리글 낱말만으로 이루어진 칸인가 — 「단위작업별」·「위치및면적」 같은 것.
낱말을 차례로 벗겨 아무것도 남으면 머리글로 본다. 자원 이름은 낱말을 벗기면
반드시 무언가 남는다(`건설기계운전사` `건설`·`운전사`).
"""
rest = text
for word in sorted(_NON_RESOURCE_WORDS, key=len, reverse=True):
rest = rest.replace(word, "")
rest = rest.replace("", "").replace("", "").strip()
return rest == ""
def _normalize(text: str) -> str:
"""표 셀의 공백·개행 흔들림을 지운다. 「경 암」·「연 암」 같은 것."""
return re.sub(r"\s+", "", str(text or "")).strip()
@@ -46,6 +46,8 @@ def _normalize_label(text: str) -> str:
#: 「계」 열 — **가공 + 조립을 이미 더한 값**이다. 같이 읽으면 두 번 센다(㉤ 열 방향). #: 「계」 열 — **가공 + 조립을 이미 더한 값**이다. 같이 읽으면 두 번 센다(㉤ 열 방향).
_SUM_GROUP_LABELS = ("", "합계", "소계", "총계") _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: def _sum_group_positions(headers: list, resource_count: int) -> set:
@@ -143,6 +145,7 @@ def _match_two_row_table(
unit: str, unit: str,
ordinal: list, ordinal: list,
skip_rows: int, skip_rows: int,
basis_quantity: Decimal | None = None,
) -> bool: ) -> bool:
"""2단 표 — **숫자 칸을 순서대로** 자원에 맞춘다. """2단 표 — **숫자 칸을 순서대로** 자원에 맞춘다.
@@ -180,9 +183,15 @@ def _match_two_row_table(
) )
) )
continue 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): for (order, entry, blocked), amount in zip(ordinal, numbers):
if blocked: if blocked:
continue # 「계」 묶음 — 이미 더한 값이다 continue # 「계」 묶음 — 이미 더한 값이다
if divisor not in (None, 0, Decimal(1)):
amount = amount / divisor
result.rows.append( result.rows.append(
ResourceRow( ResourceRow(
work_item_code=work_item_code, work_item_code=work_item_code,
@@ -497,7 +506,9 @@ def match_transposed_table(
# 자원 이름이 **둘째 줄**에 오는 2단 표일 수 있다. # 자원 이름이 **둘째 줄**에 오는 2단 표일 수 있다.
ordinal, skip_rows = second_row_columns(table, catalog) ordinal, skip_rows = second_row_columns(table, catalog)
if ordinal: 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: if not columns:
return False 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
@@ -87,8 +87,8 @@ def cost_from_bill(root: str, bill: dict[str, Any], direct: dict[str, Decimal]):
async def get_cost_sheet(project_id: UUID, form: str = "general") -> JSONResponse: async def get_cost_sheet(project_id: UUID, form: str = "general") -> JSONResponse:
"""원가계산서 한 장 — 서식 줄 · 기준 입력 선택지 · 상태줄. """원가계산서 한 장 — 서식 줄 · 기준 입력 선택지 · 상태줄.
`form` 일반형식만 금액을 . 수공·실적일반·실적수공은 **서식 차례만**(요율표 미확보 줄은 `form` 일반형식만 금액을 . 수공·실적일반·실적수공은 **서식 차례만**(실적형 임도 미적용 ·
막고 사유 · 0 원으로 채움). 수공 세부 경비 법정 요율 없음 줄은 막고 사유 · 0 원으로 채움).
""" """
from B09_Estimation.B09_Estimation_Router import get_bill from B09_Estimation.B09_Estimation_Router import get_bill
from common_util.common_util_project_settings import estimation_settings from common_util.common_util_project_settings import estimation_settings
@@ -110,7 +110,9 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
from B09_Estimation.B09_Estimation_Transport import BASIS_TEXT as TRANSPORT_BASIS from B09_Estimation.B09_Estimation_Transport import BASIS_TEXT as TRANSPORT_BASIS
from B09_Estimation.B09_Estimation_Transport import ROAD_CLASSES as TRANSPORT_ROADS 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 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 WORK_ITEM_CODE as TRANSPORT_CODE
from B09_Estimation.B09_Estimation_Transport import parse_trips, transport_amount
# 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다. # 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다.
# 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다. # 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다.
@@ -133,10 +135,19 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
book = prices.book book = prices.book
transport_notes = list(prices.transport_notes) transport_notes = list(prices.transport_notes)
transport_prices = {} transport_prices = {}
transport_amounts = {}
# 회수는 품셈이 안 정하는 설계 입력 — 비면 금액을 안 세우고 사유만 남긴다.
transport_trips = parse_trips(settings.get("transport_trips"))
for variant in TRANSPORT_VARIANTS: for variant in TRANSPORT_VARIANTS:
code = f"B-{TRANSPORT_CODE}#{variant['key']}" code = f"B-{TRANSPORT_CODE}#{variant['key']}"
if code in book.titles: 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( with_material = sum(
1 1
for unit_code, unit_title in book.titles.items() for unit_code, unit_title in book.titles.items()
@@ -174,6 +185,8 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
"transport": { "transport": {
"distance_km": str(settings.get("transport_distance_km") or ""), "distance_km": str(settings.get("transport_distance_km") or ""),
"road": str(settings.get("transport_road") or ""), "road": str(settings.get("transport_road") or ""),
"trips": str(settings.get("transport_trips") or ""),
"trips_note": TRANSPORT_TRIPS_NOTE,
"roads": [ "roads": [
{"key": row["key"], "label": row["label"]} for row in TRANSPORT_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"], "key": variant["key"],
"label": variant["label"], "label": variant["label"],
"unit_price_krw": transport_prices.get(variant["key"], ""), "unit_price_krw": transport_prices.get(variant["key"], ""),
"amount_krw": transport_amounts.get(variant["key"], ""),
} }
for variant in TRANSPORT_VARIANTS for variant in TRANSPORT_VARIANTS
], ],
@@ -236,6 +250,7 @@ class FactorChoiceBody(BaseModel):
#: 기계 수송 거리(㎞)·도로 구분 — **비면 수송비 줄이 안 선다.** #: 기계 수송 거리(㎞)·도로 구분 — **비면 수송비 줄이 안 선다.**
transport_distance_km: str | None = None transport_distance_km: str | None = None
transport_road: str | None = None transport_road: str | None = None
transport_trips: str | None = None
#: 품 할인·할증(1-4) — 계열코드 → 고른 행. **빈 값이면 그 계열을 끄는 것.** #: 품 할인·할증(1-4) — 계열코드 → 고른 행. **빈 값이면 그 계열을 끄는 것.**
labor_surcharge: dict[str, str] | None = None 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}"}, content={"status": "error", "message": f"원문에 없는 도로 구분입니다: {road}"},
) )
values["transport_road"] = 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: if body.labor_surcharge is not None:
from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices
@@ -0,0 +1,57 @@
"""B09 원가계산 — **초류종자살포 5-24 종자**(2026-09-15 브레인 판정 ④ · 화약류 길).
종자 kg 0.025 규격을 적음 자원 목록에 종자 후보가 (생태복원형 초본류·목본류 · 초본류 · 목본류)이라
조인 규칙(규격이 같아야) 걸려 붙었음. 배합은 [] 현장 판단(도로비탈면 녹화 지침 · 방향·고도·계절)으로 넘김.
모두 자재 단가 탭에 칸이 서고 **설계자가 넣은 하나** 수량으로 붙음 · 넣으면 규격 미정 ·
이상 넣으면 고르지 않고 사유(임의로 규격을 고르지 않음).
"""
from __future__ import annotations
from typing import Any
CODES = ("FP-05-24-01", "FP-05-24-02")
NAME = "종자"
#: (자원 목록 코드, 규격) — 자원 목록 `resource_catalog_ext` 의 종자 넷.
SEEDS: tuple[tuple[str, str], ...] = (
("AR-M-f89c57d4", "생태복원형,초본류"),
("AR-M-d52b8fc1", "생태복원형,목본류"),
("AR-M-8f852514", "초본류"),
("AR-M-e39ee36e", "목본류"),
)
SEED_MISSING = (
"종자 — 규격 미정(원문 표가 규격을 안 적음 · 후보 넷 "
+ " · ".join(spec for _, spec in SEEDS)
+ ") · 「자재 단가」 탭에서 하나를 넣으면 붙음"
)
SEED_TWO = "종자 — 후보가 둘 이상 들어옴({specs}) · 하나만 넣을 것"
def attach_seed_spray(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None:
"""칸 넷을 세우고(`material_uses`) · 넣은 하나만 붙이고 · 없거나 둘이면 사유로 갈아 끼움."""
from B09_Estimation.B09_Estimation_Explosives import _table_amount
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
book = build.book
for code in CODES:
for material, _ in SEEDS:
uses = build.material_uses.setdefault(material, [])
if code not in uses:
uses.append(code)
labels = [
label for label in build.unattached.get(code) or [] if "".join(label.split()) != NAME
]
amount = _table_amount(nodes_by_code.get(code) or {}, NAME)
picked = [(material, spec) for material, spec in SEEDS if material in book.titles]
titles = [t for t in book.titles if t == f"B-{code}" or t.startswith(f"B-{code}#")]
if len(picked) == 1 and amount is not None and titles:
material, spec = picked[0]
for title in titles:
book.add_detail(
PriceDetail(title, material, amount, note=f"종자 {spec} — 설계자 선택")
)
elif len(picked) > 1:
labels.append(SEED_TWO.format(specs=" · ".join(spec for _, spec in picked)))
else:
labels.append(SEED_MISSING)
build.unattached[code] = labels
+23 -3
View File
@@ -194,13 +194,27 @@ def safety_management_cost(
대상액이 **다른 구간에 떨어질 있어** A 항상 작지는 않다. 줄을 남겨 대상액이 **다른 구간에 떨어질 있어** A 항상 작지는 않다. 줄을 남겨
화면이 나란히 보이게 한다(실무 `안전관리비검토` 시트와 같은 서식). 화면이 나란히 보이게 한다(실무 `안전관리비검토` 시트와 같은 서식).
""" """
from B09_Estimation.B09_Estimation_Engine_Cost import floor_won
variable = dataset.variable("rate_safety_pct") variable = dataset.variable("rate_safety_pct")
brackets = variable["brackets"] 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 owner_supplied = data.owner_supplied_for_safety_krw
if owner_supplied is None: if owner_supplied is None:
owner_supplied = data.owner_supplied_material_krw owner_supplied = data.owner_supplied_material_krw
if data.owner_supplied_includes_vat: if data.owner_supplied_includes_vat:
# 제4조① 단서는 「해당 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다.
# ÷1.1 은 **부가세 제외 환산**이며 근거는 실무다 — 실무 원가계산서 **6건이 모두**
# 「(직노+직재+간재+관급재/1.1) × 율」로 적었다(2026-09-14 골든셋 전수 확인).
owner_supplied = owner_supplied / _VAT_DIVISOR owner_supplied = owner_supplied / _VAT_DIVISOR
base_with = ctx.material_cost + ctx.direct_labor_cost + owner_supplied base_with = ctx.material_cost + ctx.direct_labor_cost + owner_supplied
@@ -248,7 +262,15 @@ def safety_management_cost(
) )
percent = rate_percent(row, label=label) percent = rate_percent(row, label=label)
flat = base_amount(row) flat = base_amount(row)
return (base * percent / _HUNDRED + flat) * multiplier, percent, flat # ⭐ 2026-09-14 고침 — **호별 산정액을 먼저 원 단위로 맺고** 1.2 를 곱한다.
# 고시 제4조① 단서 「… 대상액에서 제외하고 **산출한 산업안전보건관리비**의 1.2배」 —
# 1.2배의 대상은 1·2호로 **산정이 끝난 금액**이다. 종전엔 1.2 를 곱한 뒤 한 번만 버려
# 영월 2024 B 줄이 20,330,639 로 원본(20,330,638)보다 1원 컸다(골든셋 실증).
# A(배수 1)는 어느 차례로 해도 같은 값이다.
# ⚠ 안 고른 갈래 — 거창 2025 원본은 `버림(밑수 × 율 × 1.2)` 로 1원 위다. 그 서류는
# 시트 이름·줄 차례가 달라 **STmate 출력이 아니며**, 우리 기준은 STmate 재현이라
# 사유로만 남기고 채택하지 않는다(브레인 판정 2026-09-14).
return floor_won(base * percent / _HUNDRED + flat) * multiplier, percent, flat
raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)") raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)")
raw_b, percent_b, flat_b = evaluate( raw_b, percent_b, flat_b = evaluate(
@@ -257,8 +279,6 @@ def safety_management_cost(
# 어느 쪽이 채택인지 먼저 정해 두 줄에 표시를 단다 — 화면이 나란히 보이고 # 어느 쪽이 채택인지 먼저 정해 두 줄에 표시를 단다 — 화면이 나란히 보이고
# 채택 줄이 눈에 띄어야 한다(PLAN 8-12 실무 `안전관리비검토` 시트 서식). # 채택 줄이 눈에 띄어야 한다(PLAN 8-12 실무 `안전관리비검토` 시트 서식).
from B09_Estimation.B09_Estimation_Engine_Cost import floor_won
adopted = "A" if floor_won(raw_a) <= floor_won(raw_b) else "B" adopted = "A" if floor_won(raw_a) <= floor_won(raw_b) else "B"
amount_a = emit( amount_a = emit(
@@ -0,0 +1,112 @@
"""B09 원가계산 — **표토제거 답외구간** 9-15-2 (2026-09-14 브레인 ㉰ 첫째).
B08 준비공 표토제거 줄이 부르는 코드인데 표가 계수표(T·L·E·q0·e·f·V1·V2·t) 일위대가가 섰음.
q = q0 × e · = L/V1 + L/V2 + t · Q1 = 60 × q × f × E / (/hr) · Q = Q1 / T (/hr)
[] 무한궤도 불도저(19ton) · 건설품셈 8-2-1 불도저 참조 불도저 (`dozer_hourly_output`) 그대로 + T 나눔
기종은 표의 q0·V1·V2(1) 8-2-1 표에서 되짚음(`resolve_dozer`) [] 19ton 맞는지 시험이
실무 영월 호표 Q=576.95 E 자리에 e(0.96) 넣은 원문이 또렷해 원문 E (까닭은 `KnownGaps`).
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
CODE = "FP-09-15-02"
TABLE = "F0282"
_RE_SYMBOL = re.compile(r"^([A-Za-z]\d?)\s*\(")
_RE_GEAR = re.compile(r"(\d+)\s*단")
def _factors(node: dict[str, Any]):
"""(불도저 계수, T) — 칸이 모자라거나 기종이 안 좁혀지면 까닭 글."""
from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import DozerFactors, resolve_dozer
table = next((t for t in node.get("tables") or [] if t.get("pum_table_id") == TABLE), {})
values: dict[str, Decimal] = {}
gear = 1
for row in table.get("raw_row") or []:
found = _RE_SYMBOL.match(str(row[0]).strip()) if row else None
value = parse_measure(str(row[1])) if found and len(row) > 1 else None
if value is not None:
values[found.group(1)] = value
shift = _RE_GEAR.search(str(row[1]))
gear = int(shift.group(1)) if shift else gear
missing = [k for k in ("T", "L", "E", "q0", "e", "f", "V1", "V2") if k not in values]
if missing:
return f"9-15-2 표 칸 없음: {', '.join(missing)}"
machine = resolve_dozer(values["q0"], values["V1"], values["V2"], gear)
if machine is None:
return f"삽날 {values['q0']}㎥ · {values['V1']}/{values['V2']}m/분({gear}단) 으로 불도저가 안 좁혀짐"
factors = DozerFactors(
work_item_code=CODE,
blade_capacity_m3=values["q0"],
distance_factor=values["e"],
volume_factor=values["f"],
efficiency=values["E"],
haul_distance_m=values["L"],
forward_speed_m_min=values["V1"],
reverse_speed_m_min=values["V2"],
machine_code=machine[0],
machine_name=machine[1],
)
return factors, values["T"]
def topsoil_output(node: dict[str, Any] | None = None) -> tuple[Decimal, Decimal]:
"""(Q1 ㎥/hr, Q ㎡/hr) — 둘 다 소수 2자리로 확정한 뒤 씀(명세 7장)."""
from B09_Estimation.B09_Estimation_MachineProductivity import fix2
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import dozer_hourly_output
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
if node is None:
node = next(n for n in load_work_item_master()["work_items"] if n["work_item_code"] == CODE)
found = _factors(node)
if isinstance(found, str):
raise ValueError(found)
factors, thickness = found
q1 = dozer_hourly_output(factors)
return q1, fix2(q1 / thickness)
def attach_topsoil_removal(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None:
"""`B-FP-09-15-02` — 불도저 1/Q hr/㎡ 한 줄(D). 기계 층이 없거나 표가 달라지면 까닭만."""
from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle
node = nodes_by_code.get(CODE)
title_code = f"B-{CODE}"
if node is None or title_code in build.book.titles:
return
found = _factors(node)
if isinstance(found, str):
build.component_gaps[CODE] = found
return
factors, thickness = found
hourly = f"X-{factors.machine_code}"
if hourly not in build.book.titles:
build.component_gaps[CODE] = f"{factors.machine_name} 시간당 사용료가 안 섬"
return
q1, q = topsoil_output(node)
build.book.add_title(
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name=str(node.get("name") or CODE),
spec="표토제거",
unit="",
)
)
build.book.add_output_detail(
title_code,
hourly,
Decimal(1) / q,
f"{factors.formula_text} → Q = Q1 {q1} ÷ T {thickness}m = {q} ㎡/hr"
" (산림품셈 9-15-2 [주]②③ · 건설 8-2-1)",
output=q,
)
if CODE in build.skipped:
build.skipped.remove(CODE)
+30 -2
View File
@@ -24,8 +24,10 @@
**거리는 설계 입력이다** 넣으면 **줄이 선다**(사토장 운반거리와 같은 자리). **거리는 설계 입력이다** 넣으면 **줄이 선다**(사토장 운반거리와 같은 자리).
임의 거리를 넣으면 금액이 조용히 서므로 **비면 사유만 남긴다.** 임의 거리를 넣으면 금액이 조용히 서므로 **비면 사유만 남긴다.**
**회수( 대를 나르나) 여기서 정한다** 단가는 회당이고, 회수는 수량 **회수( 대를 나르나) 설계 입력이다** (2026-09-14 브레인 판정으로 닫음).
(설계 입력)이다. 품셈 대수·회수를 정해 주지 않는다. 원문 전수 확인 산림품셈 10-4 · 건설품셈 8-1-3 **회당 단가 산출식만** 주고
대수·왕복 횟수를 정하는 공식이 **없다**. 그래서 `transport_trips` 칸을 두고,
비면 금액을 세우고 사유만 남긴다. **회수 × 회당 단가** 곱하기 하나뿐이다.
""" """
from __future__ import annotations from __future__ import annotations
@@ -86,6 +88,12 @@ ASSUMPTION_TEXT = (
"⚠ 원문이 안 정해 우리가 정한 둘 — ㉠ 운반시간의 속도는 8-1-6의 2 나 이동속도표에서 가져옴" "⚠ 원문이 안 정해 우리가 정한 둘 — ㉠ 운반시간의 속도는 8-1-6의 2 나 이동속도표에서 가져옴"
"(그 표는 자주식 이동표라 쓰임이 꼭 같지는 않음) · ㉡ 운반시간을 왕복으로 봄." "(그 표는 자주식 이동표라 쓰임이 꼭 같지는 않음) · ㉡ 운반시간을 왕복으로 봄."
) )
#: 회수 칸 사유 — 화면이 「왜 설계자가 넣나」를 읽는 자리.
TRIPS_NOTE = (
"회수(대수 × 왕복)는 **설계 입력**입니다 — 산림품셈 10-4 · 건설품셈 8-1-3 은 **회당 단가"
" 산출식만** 주고 대수·횟수를 정하는 공식이 원문에 없습니다(2026-09-14 전수 확인)."
" 비워 두면 수송비 금액이 서지 않습니다."
)
def parse_distance_km(raw: Any) -> Decimal | None: def parse_distance_km(raw: Any) -> Decimal | None:
@@ -102,6 +110,26 @@ def parse_distance_km(raw: Any) -> Decimal | None:
return value 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: def road_class(key: str | None) -> dict[str, Any] | None:
"""도로 구분. 못 고르면 `None` — **기본 도로를 우리가 정하지 않는다.**""" """도로 구분. 못 고르면 `None` — **기본 도로를 우리가 정하지 않는다.**"""
return _ROAD_BY_KEY.get(str(key or "")) 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.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.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` 에 — 자취를 쌓고 서버 본표를 받아 그림. */ /** 본표 한 장을 `box` 에 — 자취를 쌓고 서버 본표를 받아 그림. */

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