26 Commits
Author SHA1 Message Date
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
42 changed files with 1810 additions and 217 deletions
@@ -52,6 +52,14 @@ FACE_DRESSING_FILL_SUGGESTED = (
"영월 실무 설계내역 「성토사면고르기 06M3 B/H」(백호 = 무한궤도 굴착기) · 임도는 산지라"
" 타이어식이 잘 안 들어감 — 타이어식도 고를 수 있음(2026-09-14 브레인 ②)",
)
#: 지장목제거 뿌리뽑기(9-21 제근) 굴착기 크기 — 품셈 9-21 표 갈래 0.2·0.7(무한궤도). 등급과 한 갈래.
#: ⚠ 제안값은 칸 곁에만(스스로 안 고름 · 비면 금액 없이 사유 — 2026-09-14 브레인 판정).
ROOT_REMOVAL_EXCAVATOR_SIZES = ("0.2", "0.7")
ROOT_REMOVAL_EXCAVATOR_SUGGESTED = (
"0.7",
"산림품셈 10-12-1 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — 같은 고시가 임도 표준"
" 장비를 0.7 로 적은 자리 · 0.2㎥ 도 고를 수 있음",
)
#: ⭐ 2026-09-14 브레인 판정 Ⓐ — 밑수 = 초류종자살포(파종) 면적. 반영률 칸을 따로 두면 같은 값을
#: 두 곳에서 관리하게 됨. 실무도 그렇게 움직임(거창 파종 성토 50%·절토 100% · 영월 성토 50%).
FACE_DRESSING_NOTE = (
@@ -115,6 +115,7 @@ def build_handoff(
stand_volume_class: str | None = None,
face_dressing_cut_class: str | None = None,
face_dressing_fill_class: str | None = None,
root_removal_excavator_m3: str | None = None,
priced_sheets: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.
@@ -134,6 +135,8 @@ def build_handoff(
"stand_volume_class": stand_volume_class,
"face_dressing_cut_class": face_dressing_cut_class,
"face_dressing_fill_class": face_dressing_fill_class,
# 제근 굴착기 크기 — 등급과 「크기·등급」 한 갈래로(매핑 `variant_template` · 09-14).
"root_removal_excavator_m3": root_removal_excavator_m3,
}
rows, misses = _earthwork_rows(
summary_table, table, methods, bench_cut_depth_m, variant_inputs
@@ -7,6 +7,7 @@
from __future__ import annotations
import string
from typing import Any
from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_code
@@ -151,16 +152,24 @@ def _earthwork_rows(
# 보는 쪽을 고쳤으면 **다는 쪽도** 빠짐없이 달아야 한다.
blocked_kind = mismatch[1] if mismatch else None
blocked_reason = mismatch[2] if mismatch else ""
inputs = {**(variant_inputs or {}), "ground_class": ground}
variant_value = (
(entry or {}).get("variant_value")
or {
**(variant_inputs or {}),
"ground_class": ground,
}.get(str((entry or {}).get("variant_from") or ""))
or inputs.get(str((entry or {}).get("variant_from") or ""))
or None
)
# 입력 둘을 한 갈래 값으로 엮는 매핑(9-21 「크기·등급」 · 09-14) — 하나라도 비면 안 엮음.
template = str((entry or {}).get("variant_template") or "")
needs = [name for _, name, _, _ in string.Formatter().parse(template) if name]
template_ready = all(inputs.get(name) for name in needs)
if template and template_ready:
variant_value = template.format(**inputs)
# 매핑이 「이 칸이 비면 못 고름」이라 적은 갈래 — 금액 없이 입력 사유(면고르기 · 09-14 Ⓒ).
if code and not variant_value and (entry or {}).get("variant_missing_reason"):
if (
code
and (not variant_value or not template_ready)
and (entry or {}).get("variant_missing_reason")
):
blocked_kind = blocked_kind or BLOCKED_INPUT_MISSING
blocked_reason = blocked_reason or str(entry["variant_missing_reason"])
if code is None and not is_subtotal:
@@ -441,6 +450,15 @@ def _structure_rows(
# 갈래 축과 **저장 제원 원본값**. 가공하지 않는다.
variant_axis = str(entry.get("variant_axis") or "") or None
variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None
if variant_axis == "back_len_cm":
# 뒷길이만은 **수량표·구조물도가 실제로 쓴 값**과 출처를 싣는다 — 안 고른 벽(관 유입·
# 유출부 기슭막이는 늘 빔)도 [주]⑩ 하한으로 물량이 섰는데 갈래만 못 골랐음(09-14).
from B08_Quantity.B08_Quantity_Engine_StructureFigure import back_length_cm
back, default_note = back_length_cm(structure)
variant_value = str(back) if variant_value in (None, "") else variant_value
used = f"뒷길이 {back}㎝ — {default_note or '저장 제원'}"
class_basis = " · ".join(part for part in (class_basis, used) if part)
secondary_axes = [
{"axis": axis, "value": (structure.get("options") or {}).get(axis)}
for axis in entry.get("secondary_axes") or []
@@ -19,13 +19,36 @@ from typing import Any
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import TEMPLATE_DIR
from config.config_system import STORAGE_BASE_DIR
#: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터).
TIERS = ("personal", "company", "program")
#: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터). `received` = 동료가 보낸 것(공유 · 개인 단을 안 덮음).
TIERS = ("personal", "received", "company", "program")
RECEIVED_SUBDIR = "library_received"
#: 항목 코드 — 파일 이름이 되므로 모양을 먼저 봄(경로 벗어남 막이).
CODE_PATTERN = re.compile(r"^AX-ST-[0-9a-f]{8}$")
LIBRARY_SUBDIR = "library"
#: 프로그램 기본 작업본 자리 — 회사 번호 폴더와 안 겹치게 밑줄 이름.
PROGRAM_SUBDIR = "_program"
#: 프로그램 기본 발행본에서 뺄 출처 칸 — 어느 공사인지(2026-09-14 브레인 판정 ②).
ORIGIN_MASKED_KEYS = ("project", "file")
ORIGIN_MASKED_NOTE = "원문에서 뽑음 — 공사명은 발행 시 가림"
def program_library_dir() -> Path:
"""프로그램 기본 **작업본**(시스템 관리자가 화면에서 고친 것) — storage(회사·개인 단과 같은 결).
git `resources/library_structure`(`TEMPLATE_DIR`)는 **씨앗** — 쓰기 금지(데이터 3층의 초기값 자리).
"""
return Path(STORAGE_BASE_DIR).resolve() / PROGRAM_SUBDIR / LIBRARY_SUBDIR
def program_items() -> list[dict[str, Any]]:
"""프로그램 기본 = 작업본 + 씨앗 중 **작업본에 없는 코드만**(관리자가 고친 것이 배포에 안 덮임)."""
stored = _items(program_library_dir())
codes = {item.get("code") for item in stored}
return [*stored, *(item for item in _items(TEMPLATE_DIR) if item.get("code") not in codes)]
def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]:
"""로그인한 사람의 3단 폴더. 회사가 없으면(시스템 관리자) 개인·회사 단은 없음."""
dirs: dict[str, Path] = {}
@@ -33,8 +56,9 @@ def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]:
company = Path(STORAGE_BASE_DIR).resolve() / str(company_id)
if user_id is not None:
dirs["personal"] = company / str(user_id) / LIBRARY_SUBDIR
dirs["received"] = company / str(user_id) / RECEIVED_SUBDIR
dirs["company"] = company / LIBRARY_SUBDIR
dirs["program"] = TEMPLATE_DIR
dirs["program"] = program_library_dir()
return dirs
@@ -44,6 +68,13 @@ def _items(folder: Path) -> list[dict[str, Any]]:
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(folder.glob("*.json"))]
def _tier_items(dirs: dict[str, Path], tier: str) -> list[dict[str, Any]]:
"""단의 항목 — 프로그램 기본은 작업본 + 씨앗, 나머지는 그 폴더."""
if tier == "program":
return program_items()
return _items(dirs[tier]) if tier in dirs else []
def item_kind(item: dict[str, Any]) -> str:
"""양식형(`form`) · 고정형(`fixed`) — 명세 13장: 칸은 같고 **식이 한 줄이라도 있나**로만 가름.
저장된 `item_kind` 표시는 안 믿음 — 줄과 어긋나면 배지가 거짓이 됨."""
@@ -66,16 +97,15 @@ def list_items(dirs: dict[str, Path], type_id: str) -> list[dict[str, Any]]:
}
for tier in TIERS
if tier in dirs
for item in _items(dirs[tier])
for item in _tier_items(dirs, tier)
if item.get("type_id") == type_id and item.get("code")
]
def find_item(dirs: dict[str, Path], tier: str, code: str) -> dict[str, Any] | None:
folder = dirs.get(tier)
if folder is None:
if tier not in dirs:
return None
return next((item for item in _items(folder) if item.get("code") == code), None)
return next((item for item in _tier_items(dirs, tier) if item.get("code") == code), None)
def project_library_dir(project_root: str | Path) -> Path:
@@ -98,7 +128,7 @@ def available_templates(project_root: str | Path | None) -> list[dict[str, Any]]
⛔ 개인·회사 단은 안 넣음 — 표를 그릴 때 라이브러리를 매번 읽지 않음(판정 Ⓑ).
"""
return [*_items(TEMPLATE_DIR), *project_templates(project_root).values()]
return [*program_items(), *project_templates(project_root).values()]
def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> None:
@@ -127,9 +157,13 @@ def _write(folder: Path, item: dict[str, Any]) -> None:
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
def _personal_code(folder: Path, type_id: Any) -> str:
"""개인 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로."""
same = [item for item in _items(folder) if item.get("type_id") == type_id]
def _personal_code(folder: Path, type_id: Any, existing: list[dict[str, Any]] | None = None) -> str:
""" 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로.
프로그램 기본은 씨앗까지 봄(`existing`) — 씨앗 코드로 덮어써야 작업본이 씨앗을 이김.
"""
items = _items(folder) if existing is None else existing
same = [item for item in items if item.get("type_id") == type_id]
return str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
@@ -145,16 +179,19 @@ def save_personal(
template: dict[str, Any],
overrides: dict[str, Any] | None,
unit_price_rows: list[dict[str, Any]] | None = None,
tier: str = "personal",
) -> str:
"""[내 라이브러리에 저장] — 양식 + 고친 식·줄 조합을 **개인 단에 한 벌**로 씀. 코드.
"""[내 라이브러리에 저장]·발행 — 양식 + 고친 식·줄 조합을 ** 단에 한 벌**로 씀. 코드.
⚠ 반대 방향(작업본 → 개인 단)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ).
개인 단에 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ).
⚠ 반대 방향(작업본 → 라이브러리)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ).
⚠ 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ).
⚠ `tier` — 회사(MASTER)·프로그램 기본(SYSTEM_ADMIN) 발행도 같은 모양(2026-09-14 브레인 승인).
⛔ 수동 단가는 안 실음 — 프로젝트의 값이라 양식에 실으면 남의 프로젝트로 감(브레인 판정).
"""
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows
code = _personal_code(folder, template.get("type_id"))
existing = program_items() if tier == "program" else None
code = _personal_code(folder, template.get("type_id"), existing)
# 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
dropped = {"default_formula", "default_rounding"}
rows = [
@@ -162,9 +199,13 @@ def save_personal(
for row in overridden_rows(template, overrides)
]
item = {k: v for k, v in template.items() if k != "imported_from"}
if tier == "program" and isinstance(item.get("origin"), dict):
# 모든 회사로 가는 발행본 — 원문 공사명·파일명은 안 실음(원본·회사 단은 그대로 · 브레인 ②③).
kept = {k: v for k, v in item["origin"].items() if k not in ORIGIN_MASKED_KEYS}
item["origin"] = {**kept, "masked": ORIGIN_MASKED_NOTE}
if unit_price_rows is not None:
item["unit_price"] = {**(item.get("unit_price") or {}), "rows": unit_price_rows}
_write(folder, {**item, "code": code, "library_tier": "personal", "rows": rows})
_write(folder, {**item, "code": code, "library_tier": tier, "rows": rows})
return code
@@ -186,8 +227,18 @@ def pin_program_templates(project_root: str | Path) -> int:
"""
pinned = project_templates(project_root)
count = 0
for item in _items(TEMPLATE_DIR):
for item in program_items():
if item.get("type_id") and item.get("code") and item["type_id"] not in pinned:
import_item(project_root, item, "program")
count += 1
return count
def share_item(item: dict[str, Any], to_folder: Path, sender: dict[str, Any]) -> str:
"""[동료에게 보내기] — 내 개인 단 항목을 동료의 **받음 단**에 복사(개인 단을 안 덮음 · 브레인 판정 ①).
코드는 그대로(다시 보내면 같은 자리를 새로 씀) · 출처 공사명도 그대로(같은 회사 · 판정 ③).
"""
body = {k: v for k, v in item.items() if k != "imported_from"}
_write(to_folder, {**body, "library_tier": "received", "received_from": sender})
return str(body["code"])
@@ -12,7 +12,7 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
@@ -28,11 +28,13 @@ OVERRIDES_KEY = "structure_formula_overrides"
def load_template(type_id: str) -> dict[str, Any] | None:
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬."""
path = TEMPLATE_DIR / f"{type_id}.json"
if not path.is_file():
return None
return json.loads(path.read_text(encoding="utf-8"))
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬.
storage 작업본(시스템 관리자가 고친 것)이 먼저, 없으면 git 씨앗(`TEMPLATE_DIR`) — 2026-09-14 관리자 UI.
"""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import program_items
return next((item for item in program_items() if item.get("type_id") == type_id), None)
def template_of(type_id: str, templates: dict[str, Any] | None) -> dict[str, Any] | None:
@@ -153,6 +155,65 @@ def template_sheet(
#: 양식이 m당으로 풀리는 단위 — 연장 L=1 로 풀면 곧 단위당 값(모든 줄이 L 에 비례).
_PER_LENGTH_UNITS = frozenset({"m"})
#: ㉯ 양식에 **그 이름 줄이 없으면 전개 값을 남기는** 성분(2026-09-14 브레인 판정 · 목록으로 못박음).
#: STmate 호표(고정형)는 토공·버림을 안 품음 — 통째로 갈음하면 구조물터파기 85.25→25㎥(6,037,712원 실측).
#: 목록 밖 성분은 양식으로 갈음 · 목록 안이라도 양식이 그 줄을 품으면 양식 값(겹쳐 세지 않음).
#: ② 실무 엑셀 벽·돌쌓기 호표 57 전수 — 기초잡석 0/57(「기초다짐 및 뒤채움」 4 은 다른 것) ·
#: 채집석 = 사토에서 빼는 돌 부피(haul_deduction)라 호표의 채집 줄(품 · 41/57)과 다른 것.
KEEP_ENGINE_COMPONENTS = ("터파기", "되메우기", "잔토처리", "버림콘크리트", "기초잡석", "채집석")
KEPT_ROW_REASON = "전개 값 그대로 — 양식이 이 줄을 안 품음(토공·버림은 전개가 셈)"
def _kept(rows: list[Any], template_rows: list[dict[str, Any]], name_of: Any) -> list[Any]:
"""목록 이름 중 양식에 없는 것만 — 줄(dict)·성분(Component) 둘 다 이름 꺼내는 손잡이로."""
names = {str(row.get("name") or "") for row in template_rows}
return [
row for row in rows if name_of(row) in KEEP_ENGINE_COMPONENTS and name_of(row) not in names
]
#: ㉰ 고정형 장·구조물 사유 머리 — 남긴 전개 줄에 걸린 전개 사유 / ㉱ 규격 다름.
KEPT_NOTE_HEAD = "(남긴 전개 줄) "
MISMATCH_HEAD = "⚠ 규격 다름"
#: ㉱ 항목 제목에 적힌 높이 규격 「H=2.0m」 — 원문 제목의 값을 읽음(없으면 비교 안 함 · 추측 아님).
_SPEC_HEIGHT = re.compile(r"H\s*=\s*([0-9]+(?:\.[0-9]+)?)")
def _is_fixed(template: dict[str, Any]) -> bool:
"""고정형 — 식이 한 줄도 없음(명세 13장 · `StructureLibrary.item_kind` 와 같은 가름)."""
rows = template.get("rows") or []
return bool(rows) and not any(str(row.get("formula") or "").strip() for row in rows)
def fixed_notes(
template: dict[str, Any], engine_notes: list[str], height: Any
) -> tuple[list[str], dict[str, float] | None]:
"""㉰㉱ 고정형의 사유 — 항목 사유 + **남긴 전개 줄에 걸린** 전개 사유만 + 규격 다름(맨 앞).
전개 사유(뒷길이 표준·돌 종류…)는 고정형 줄과 안 맞아 거짓 사유 → 걷음. 남긴 줄(터파기·버림…)을
말하는 것만 머리를 달아 둠. 두 번 불려도(원단위 → 구조물도) 같은 값이 나오게 함.
"""
item = [part for part in str(template.get("note") or "").split(" · ") if part]
kept: list[str] = []
for note in engine_notes:
if note in item or note.startswith(MISMATCH_HEAD):
continue
if note.startswith(KEPT_NOTE_HEAD):
kept.append(note)
elif any(name in note for name in KEEP_ENGINE_COMPONENTS):
kept.append(KEPT_NOTE_HEAD + note)
notes = list(dict.fromkeys(item + kept))
found = _SPEC_HEIGHT.search(str(template.get("name") or ""))
if not found or height is None or abs(float(found.group(1)) - float(height)) < 1e-6:
return notes, None
item_h, sheet_h = float(found.group(1)), float(height)
notes.insert(
0,
f"{MISMATCH_HEAD} — 항목 H={found.group(1)}m ↔ 장 H={sheet_h:g}m: 벽 수량은 항목 박힌 값"
f"(H={found.group(1)}m) · 토공·사토 공제는 장 제원(H={sheet_h:g}m)으로 셈",
)
return notes, {"item_height_m": item_h, "sheet_height_m": sheet_h}
def _library_rows(
body: dict[str, Any], solved: list[dict[str, Any]], billing: float
@@ -295,7 +356,11 @@ def replace_with_templates(
spec=str(result.get("spec") or ""),
)
)
quantity.components = components
quantity.components = components + _kept(
quantity.components, body["rows"], lambda component: component.name
)
if _is_fixed(template):
quantity.notes, _mismatch = fixed_notes(template, quantity.notes, quantity.height_m)
# 「양식 있음/없음」을 화면이 가리게 — 조용히 섞이면 왜 값이 다른지 못 찾음.
quantity.library_item = str(template.get("name") or quantity.type_id)
@@ -343,18 +408,41 @@ def apply_templates(
continue
members = sheet.get("members") or []
billing = float(members[0].get("billing_quantity") or 0.0) if members else 0.0
sheet["rows"] = _library_rows(body, solved[index], billing)
library_rows = _library_rows(body, solved[index], billing)
start = max((row["no"] for row in library_rows), default=0)
kept = _kept(sheet.get("rows") or [], body["rows"], lambda row: row.get("name"))
sheet["rows"] = library_rows + [
{
**row,
"no": start + offset,
"formula": "",
"default_formula": "",
"rounding": None,
"default_rounding": None,
"skipped": False,
"reason": KEPT_ROW_REASON,
"error": "",
}
for offset, row in enumerate(kept, start=1)
]
sheet["unpriced_rows"] = [
row["name"]
for row in sheet["rows"]
if row["unit_amount"] is None and not row["skipped"]
]
if _is_fixed(template):
# ㉱ 규격 다름 — 금액은 서되 미확정 급으로 보임(막지 않음 · 사용자가 일부러 고른 것일 수 있음).
sheet["notes"], sheet["spec_mismatch"] = fixed_notes(
template, sheet.get("notes") or [], sheet.get("height_m")
)
# 「어느 단에서 가져왔나」 — 안 가져왔으면 `imported_from` 없음(= 기본 · 가져오기 전).
sheet["library_item"] = {
"type_id": template.get("type_id"),
"name": template.get("name"),
"code": template.get("code"),
"imported_from": template.get("imported_from"),
# 발행 확인창이 「○○공사에서 뽑은 것 — 공사명은 빼고 발행」을 보이게(브레인 ②).
"origin_project": (template.get("origin") or {}).get("project"),
}
sheet["formula_sheet"] = body
# 실무 관측값 같은 「대안 후보」 — 값을 바꾸지 않고 칸 옆에 보이기만(판정 Ⓑ).
@@ -16,6 +16,7 @@
from __future__ import annotations
import re
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from decimal import Decimal
@@ -362,32 +363,64 @@ def save_manual(
return merged
#: 조각 후보의 최소 겹침 글자 수(2026-09-14 브레인 판정 ① — 「채집」·「타설」이 걸리게).
MIN_FRAGMENT = 2
def _longest_common(a: str, b: str) -> int:
"""두 글의 가장 긴 공통 조각 길이."""
best = 0
previous = [0] * (len(b) + 1)
for char in a:
current = [0]
for index, other in enumerate(b):
current.append(previous[index] + 1 if char == other else 0)
best = max(best, max(current))
previous = current
return best
def search_titles(book: Any, query: str, kind: str, limit: int = 50) -> list[dict[str, Any]]:
"""고르개 — 단가표에서 낱말이 **모두** 든(코드·이름·규격) 항목. 단가가 섰는지도 함께."""
"""고르개 — 단가표 항목 후보. 단가가 섰는지도 함께.
낱말이 **모두** 든(코드·이름·규격) 항목이 먼저, 그다음 **두 글자 이상 조각이 겹친** 항목을
겹친 글자 수 순으로(①). 고정형 줄 이름(깬잡석채집)과 단가표 제목(막돌 채집)은 낱말이 달라
전부 포함 방식으로는 후보가 0 이었음. ⚠ 후보만 — 고르는 것은 사람(명세 2장 · 별칭표로 안 맞춤).
"""
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
kinds = SEARCH_KINDS[kind]
words = query.lower().split()
found: list[dict[str, Any]] = []
for title in book.titles.values() if words else ():
if len(found) >= limit:
break
# 조각 비교는 글자·숫자만 — 괄호 「(장비)」 같은 기호가 겹친 글자 수를 부풀리지 않게.
compact = re.sub(r"[\W_]+", "", query.lower())
scored: list[tuple[int, int, int, Any]] = []
for order, title in enumerate(book.titles.values() if words else ()):
if title.kind.value not in kinds:
continue
text = f"{title.code} {title.name} {title.spec}".lower()
if all(word in text for word in words):
try:
money = book.resolve(title.code)
total: float | None = float(money.material + money.labor + money.expense)
except PriceBookError:
total = None
found.append(
{
"code": title.code,
"name": title.name,
"spec": title.spec,
"unit": _unit(title.unit),
"price": total,
}
)
scored.append((1, len(compact), order, title))
continue
overlap = _longest_common(
compact, re.sub(r"[\W_]+", "", f"{title.name}{title.spec}".lower())
)
if overlap >= MIN_FRAGMENT:
scored.append((0, overlap, order, title))
scored.sort(key=lambda item: (-item[0], -item[1], item[2]))
found: list[dict[str, Any]] = []
for _whole, _overlap, _order, title in scored[:limit]:
try:
money = book.resolve(title.code)
total: float | None = float(money.material + money.labor + money.expense)
except PriceBookError:
total = None
found.append(
{
"code": title.code,
"name": title.name,
"spec": title.spec,
"unit": _unit(title.unit),
"price": total,
}
)
return found
@@ -34,6 +34,8 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import (
FACE_DRESSING_CUT_CLASSES,
FACE_DRESSING_FILL_CLASSES,
FACE_DRESSING_FILL_SUGGESTED,
ROOT_REMOVAL_EXCAVATOR_SIZES,
ROOT_REMOVAL_EXCAVATOR_SUGGESTED,
SummaryInput,
)
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table
@@ -173,6 +175,14 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
"basis": FACE_DRESSING_FILL_SUGGESTED[1],
},
}
# 제근 굴착기 크기 — 선택지·제안(회색 · [제안값 넣기])은 서버 한 곳(2026-09-14 브레인).
table["root_removal_excavator_choices"] = {
"choices": list(ROOT_REMOVAL_EXCAVATOR_SIZES),
"suggested": {
"value": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[0],
"basis": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[1],
},
}
# 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨).
structures = await _route_structures(project_id)
table["preparation"] = build_preparation_table(
@@ -478,6 +488,8 @@ class QuantitySettingsBody(BaseModel):
stand_volume_class: str | None = None
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질(9-19-1 원문 표). `""` 는 「안 정함」.
face_dressing_cut_class: str | None = None
# 제근 굴착기 크기 — "0.2"·"0.7"(품셈 9-21 갈래). `""` 는 「안 정함」.
root_removal_excavator_m3: str | None = None
face_dressing_fill_class: str | None = None
# 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ).
face_dressing_fill_area_m2: float | None = None
@@ -556,6 +568,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
for key, choices in (
("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES),
("face_dressing_fill_class", FACE_DRESSING_FILL_CLASSES),
("root_removal_excavator_m3", ROOT_REMOVAL_EXCAVATOR_SIZES),
):
if key in values and values[key] not in choices:
values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침
@@ -497,6 +497,8 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질. 비면 그 줄이 입력 사유로 막힘.
face_dressing_cut_class=settings.get("face_dressing_cut_class") or None,
face_dressing_fill_class=settings.get("face_dressing_fill_class") or None,
# 제근 굴착기 크기(0.2·0.7) — 비면 뿌리뽑기 줄이 입력 사유로 막힘(제안 0.7 은 칸 곁에만).
root_removal_excavator_m3=settings.get("root_removal_excavator_m3") or None,
# 구조물도 양식 일위대가로 셀 장 — 그 구조물은 호표 `AX-ST` 줄 하나로(PLAN 6장 ②).
priced_sheets=_priced_sheets(project_root, unit_table, modes, settings),
)
@@ -6,15 +6,18 @@
[넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 서버가 **파일을 다시 읽어** 개인 단에 .
브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5) · 개인 단만(판정 ) · 종류당 하나라 같은 종류 것은
덮어씀(판정 ) · 종류는 사용자가 고름 이름으로 자동으로 붙임(판정 ).
[복제해서 것으로](PLAN 4) 기본·회사 항목을 개인 단에 베낌. 프로젝트 작업본은 바꿈.
(구조물도 라우터가 700줄에 닿아 개인 단으로 넣는 창구를 파일에 모음.)
"""
import asyncio
import io
from typing import Any
from typing import Any, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, File, Form, UploadFile
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from common_util.common_util_auth import verify_session
@@ -110,3 +113,107 @@ async def save_stmate_recipe(
"note": item["note"],
}
)
class LibraryCloneRequest(BaseModel):
"""베낄 항목 — 단과 코드(이름은 겹칠 수 있음). 개인 단 것은 이미 내 것이라 안 받음."""
model_config = ConfigDict(extra="forbid")
type_id: str = Field(min_length=1, max_length=100)
tier: Literal["received", "company", "program"]
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
@router.put("/{project_id}/quantity/structure-sheets/library/clone")
async def clone_library_item(
project_id: UUID,
payload: LibraryCloneRequest,
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""③ 기본·회사 항목을 **로그인한 사람 개인 단**에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]할 길을 한 번에."""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
find_item,
save_item_personal,
tier_dirs,
)
dirs = tier_dirs(session.get("company_id"), session.get("user_id"))
folder = dirs.get("personal")
if folder is None:
return _error(403, "개인 라이브러리는 회사에 속한 사용자만 씁니다.")
item = await asyncio.to_thread(find_item, dirs, payload.tier, payload.code)
if item is None or item.get("type_id") != payload.type_id:
return _error(404, "베낄 항목을 찾지 못했습니다.")
body = {k: v for k, v in item.items() if k not in ("code", "imported_from", "library_tier")}
body["cloned_from"] = {"tier": payload.tier, "code": payload.code, "name": item.get("name")}
code = await asyncio.to_thread(save_item_personal, folder, body)
return JSONResponse(content={"status": "success", "code": code, "name": item.get("name")})
async def _company_members(company_id: int) -> list[dict[str, Any]]:
"""같은 회사 구성원 — B01 구성원 저장소를 읽기만(시험이 갈아 끼움)."""
from B01_Dashboard.B01_Dashboard_Repository_Members import list_company_members
return await list_company_members(company_id)
@router.get("/{project_id}/quantity/structure-sheets/library/colleagues")
async def list_library_colleagues(
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
) -> JSONResponse:
"""④ 보낼 동료 — 같은 회사에서 나를 뺀 사람(이름만)."""
company_id = session.get("company_id")
if company_id is None:
return _error(403, "회사에 속한 사용자만 보냅니다.")
members = await _company_members(int(company_id))
colleagues = [
{"id": m["id"], "name": m.get("name") or m.get("email") or ""}
for m in members
if m["id"] != session.get("user_id")
]
return JSONResponse(content={"status": "success", "colleagues": colleagues})
class LibraryShareRequest(BaseModel):
"""내 개인 단 항목 하나를 같은 회사 동료에게."""
model_config = ConfigDict(extra="forbid")
type_id: str = Field(min_length=1, max_length=100)
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
to_user_id: int
@router.put("/{project_id}/quantity/structure-sheets/library/share")
async def share_library_item(
project_id: UUID,
payload: LibraryShareRequest,
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""④ [동료에게 보내기] — 받는 쪽 「받음」 단에 복사(PLAN 4장 공유 · 브레인 판정 ①)."""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
RECEIVED_SUBDIR,
find_item,
share_item,
tier_dirs,
)
company_id = session.get("company_id")
dirs = tier_dirs(company_id, session.get("user_id"))
if "personal" not in dirs:
return _error(403, "회사에 속한 사용자만 보냅니다.")
if payload.to_user_id == session.get("user_id"):
return _error(400, "나에게는 보내지 않습니다.")
members = {m["id"]: m for m in await _company_members(int(company_id))}
target = members.get(payload.to_user_id)
if target is None:
return _error(404, "같은 회사에서 받는 사람을 찾지 못했습니다.")
item = await asyncio.to_thread(find_item, dirs, "personal", payload.code)
if item is None or item.get("type_id") != payload.type_id:
return _error(404, "보낼 내 항목을 찾지 못했습니다.")
me = members.get(session.get("user_id")) or {}
sender = {"user_id": session.get("user_id"), "name": me.get("name") or me.get("email") or ""}
to_folder = dirs["personal"].parents[1] / str(payload.to_user_id) / RECEIVED_SUBDIR
code = await asyncio.to_thread(share_item, item, to_folder, sender)
return JSONResponse(content={"status": "success", "code": code, "to": target.get("name") or ""})
@@ -316,6 +316,7 @@ async def get_structure_library(
"status": "success",
"items": await asyncio.to_thread(list_items, dirs, type_id),
"current": {"code": current.get("code"), "imported_from": current.get("imported_from")},
"can_publish": {tier: _publish_dir(session, tier) is not None for tier in PUBLISH},
}
)
@@ -326,7 +327,7 @@ class LibraryImportRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
type_id: str = Field(min_length=1, max_length=100)
tier: Literal["personal", "company", "program"]
tier: Literal["personal", "received", "company", "program"]
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
@@ -394,15 +395,32 @@ def _no_personal() -> JSONResponse:
)
#: 발행 단 — 회사는 마스터·시스템 관리자, 프로그램 기본은 시스템 관리자(2026-09-14 브레인 승인).
PUBLISH = ("company", "program")
def _publish_dir(session: dict[str, Any], tier: str) -> Path | None:
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import program_library_dir, tier_dirs
admin = session.get("role") == "SYSTEM_ADMIN"
if tier == "program":
return program_library_dir() if admin else None
if tier == "company" and (admin or session.get("is_master")):
return tier_dirs(session.get("company_id"), None).get("company")
return _personal_dir(session) if tier == "personal" else None
class LibrarySaveRequest(BaseModel):
"""[내 라이브러리에 저장] — 어느 장의 양식을 쓸지."""
"""[내 라이브러리에 저장]·[발행] — 어느 장의 양식을 어느 단에 쓸지(같은 모양)."""
model_config = ConfigDict(extra="forbid")
sheet_key: str
tier: Literal["personal", "company", "program"] = "personal"
@router.put("/{project_id}/quantity/structure-sheets/library/personal")
@router.put("/{project_id}/quantity/structure-sheets/library/publish")
async def put_structure_library_personal(
project_id: UUID,
payload: LibrarySaveRequest,
@@ -420,9 +438,13 @@ async def put_structure_library_personal(
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
from common_util.common_util_project_settings import quantity_settings
folder = _personal_dir(session)
folder = _publish_dir(session, payload.tier)
if folder is None:
return _no_personal()
if payload.tier == "personal":
return _no_personal()
return JSONResponse(
status_code=403, content={"status": "error", "message": "발행 권한이 없습니다."}
)
project_root = await _project_root(project_id)
if project_root is None:
return _not_found()
@@ -439,7 +461,7 @@ async def put_structure_library_personal(
overrides = (settings.get(OVERRIDES_KEY) or {}).get(type_id)
# ⛔ 수동 단가(`MANUAL_KEY`)는 안 넘김 — 프로젝트의 값(브레인 판정).
rows = (settings.get(ROWS_KEY) or {}).get(type_id)
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows)
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows, payload.tier)
return JSONResponse(content={"status": "success", "code": code, "edited": len(overrides or {})})
@@ -5,11 +5,13 @@
*
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* ** **(`face_dressing_choices`) ( ).
* ( ) .
* ( ) .
* + [ ] ** **( 976e5fd9 `fill_suggested`).
* ( × ) .
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton } from "@ui/ui_template_elements";
import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste";
function L(key: keyof typeof ui_locales): string {
@@ -29,6 +31,8 @@ export interface FaceDressingDraft {
export interface FaceDressingChoices {
cut: string[];
fill: string[];
/** 성토면 제안(서버 한 곳 · 실무 관측) — 회색으로 보이고 [제안값 넣기]를 **누른 때만** 들어감. */
fill_suggested?: { value: string; basis: string };
}
export function appendFaceDressingFields(
@@ -76,11 +80,36 @@ export function appendFaceDressingFields(
draft.face_dressing_cut_class = v;
}),
);
panel.append(
pick("B08_Quantity_FaceDressing_Fill", draft.face_dressing_fill_class, choices.fill, (v) => {
const fillRow = pick(
"B08_Quantity_FaceDressing_Fill",
draft.face_dressing_fill_class,
choices.fill,
(v) => {
draft.face_dressing_fill_class = v;
}),
},
);
panel.append(fillRow);
// 성토면 제안 — 칸은 비워 둔 채 회색 근거 + 단추(층따기와 같은 모양). 비우면 「안 정함」 그대로.
const suggested = choices.fill_suggested;
if (suggested?.value) {
panel.append(
h.hintRow(
`${L("B08_Quantity_FaceDressing_Suggest")} ${suggested.value}${suggested.basis}`,
),
);
panel.append(
createButton({
label: L("B08_Quantity_FaceDressing_FillSuggested"),
variant: "ghost",
onClick: () => {
const select = fillRow.querySelector("select");
if (select) select.value = suggested.value;
draft.face_dressing_fill_class = suggested.value;
draft.dirty = true;
},
}),
);
}
panel.append(
area("B08_Quantity_FaceDressing_FillArea", draft.face_dressing_fill_area_m2, (v) => {
draft.face_dressing_fill_area_m2 = v;
@@ -86,9 +86,13 @@ export interface StructureSheet extends StandardSheetSpec {
name: string;
code?: string | null;
imported_from?: string | null;
/** 뽑아 온 원문 공사명(STmate 고정형) — 프로그램 기본 발행 때 가림. */
origin_project?: string | null;
};
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
formula_sheet?: FormulaSheet;
/** ㉱ 고정형 항목 규격(H)이 장 제원과 다름 — 금액은 서되 미확정 급(빨간 테두리·배지). */
spec_mismatch?: { item_height_m: number; sheet_height_m: number } | null;
/** 제원 칸의 대안 후보(실무 관측값 등) — 값은 안 바꾸고 보이기만. */
var_candidates?: {
name: string;
@@ -260,6 +264,12 @@ function sheetBody(
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
el("span", "b08-grid__caption", sheet.unit_label),
);
// ㉱ 규격 다름 — 미확정과 같은 급(빨간 테두리 + 배지). 금액은 서고 막지 않음.
if (sheet.spec_mismatch) {
const { item_height_m: item, sheet_height_m: own } = sheet.spec_mismatch;
head.append(el("span", "b08-unit__badge", `규격 다름 H${item} ↔ H${own}`));
main.classList.add("b08-unit__manual");
}
// 한 장 = 상단 그림 + 하단 표(PLAN 3장 한 장의 짜임) — 못 그리는 장은 까닭.
main.append(head, figureSection(sheet.figure, sheet.figure_reason));
if (!sheet.rows.length) {
@@ -414,6 +424,7 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
sheetKey: sheet.key,
typeId: sheet.library_item.type_id,
currentCode: sheet.library_item.code ?? null,
originProject: sheet.library_item.origin_project,
isDirty: () => dirty,
confirmTake: () => {
const edited = editedRows(sheet);
@@ -10,7 +10,12 @@
import { API_BASE_URL } from "@config/config_frontend";
import { buildStmatePanel } from "./B08_Quantity_UI_StructureSheet_Stmate";
const TIER_LABELS: Record<string, string> = { personal: "개인", company: "회사", program: "기본" };
const TIER_LABELS: Record<string, string> = {
personal: "개인",
received: "받음",
company: "회사",
program: "기본",
};
/** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */
const KIND_LABELS: Record<string, string> = { form: "양식형", fixed: "고정형" };
@@ -52,6 +57,8 @@ export interface LibraryPanelOptions {
/** 저장 안 한 식이 있으면 [내 라이브러리에 저장]을 막음 — 저장된 식만 개인 단으로 감. */
isDirty: () => boolean;
onImported: (notes: string[]) => Promise<void>;
/** 이 장 양식이 뽑아 온 원문 공사명 — 프로그램 기본 발행 확인창에 「빼고 발행」을 알림. */
originProject?: string | null;
}
/** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */
@@ -80,13 +87,112 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
take.className = "b08-spec__save";
take.textContent = "가져오기";
take.hidden = true;
// 복제 — 기본·회사 항목을 개인 단에 베낌(PLAN 4장). 개인 단 것은 이미 내 것이라 안 눌림.
const clone = document.createElement("button");
clone.type = "button";
clone.className = "b08-quantity__tab";
clone.textContent = "복제해서 내 것으로";
clone.hidden = true;
/** 목록을 다시 받은 뒤 보일 한 줄 — 받는 동안 상태 줄이 지워져 복제 결과가 사라지지 않게. */
let afterLoad = "";
// 공유 — 내 개인 단 항목을 같은 회사 동료의 「받음」 단으로(브레인 판정 ①). 동료는 누를 때 받음.
const send = document.createElement("button");
send.type = "button";
send.className = "b08-quantity__tab";
send.textContent = "동료에게 보내기";
send.hidden = true;
const who = document.createElement("select");
who.className = "b08-spec__input";
who.hidden = true;
const syncClone = (): void => {
const [tier] = list.value.split("|");
clone.disabled = tier === "personal";
send.disabled = tier !== "personal";
};
send.addEventListener("click", () => {
const [tier, code] = list.value.split("|");
if (tier !== "personal" || !code) return;
void (async () => {
send.disabled = true;
try {
if (who.hidden) {
const { colleagues } = await readJson<{ colleagues: { id: number; name: string }[] }>(
await fetch(libraryUrl(projectId, "/colleagues"), { credentials: "include" }),
);
who.replaceChildren(...colleagues.map((c) => new Option(c.name, String(c.id))));
who.hidden = colleagues.length === 0;
status.textContent = colleagues.length
? "받을 동료를 고르고 한 번 더 누를 것"
: "보낼 동료가 없음";
return;
}
const label = list.selectedOptions[0]?.textContent ?? "";
const name = who.selectedOptions[0]?.textContent ?? "";
if (
!window.confirm(
`${label}」을 ${name}에게 보냄 — 받는 쪽 목록에 「받음」으로 뜸 · 그 사람 것은 안 덮음`,
)
) {
return;
}
await readJson(
await fetch(libraryUrl(projectId, "/share"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type_id: typeId, code, to_user_id: Number(who.value) }),
}),
);
who.hidden = true;
status.textContent = `${name}에게 보냄`;
} catch (error) {
status.textContent = error instanceof Error ? error.message : "보내지 못함";
} finally {
syncClone();
}
})();
});
list.addEventListener("change", syncClone);
clone.addEventListener("click", () => {
const [tier, code] = list.value.split("|");
const label = list.selectedOptions[0]?.textContent ?? "";
if (!tier || !code || tier === "personal") return;
if (
!window.confirm(
`${label}」을 내 라이브러리로 베낌 — 같은 종류 내 것이 있으면 덮어씀 · 프로젝트 값은 안 바뀜`,
)
) {
return;
}
void (async () => {
clone.disabled = true;
try {
await readJson(
await fetch(libraryUrl(projectId, "/clone"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type_id: typeId, tier, code }),
}),
);
afterLoad = "내 라이브러리에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]";
load.click();
} catch (error) {
status.textContent = error instanceof Error ? error.message : "복제 못함";
syncClone();
}
})();
});
load.addEventListener("click", () => {
void (async () => {
load.disabled = true;
status.textContent = "목록 받는 중…";
try {
const { items } = await readJson<{ items: LibraryItem[] }>(
const { items, can_publish: canPublish } = await readJson<{
items: LibraryItem[];
can_publish?: { company: boolean; program: boolean };
}>(
await fetch(libraryUrl(projectId, `?type_id=${encodeURIComponent(typeId)}`), {
credentials: "include",
}),
@@ -101,8 +207,13 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
return option;
}),
);
list.hidden = take.hidden = items.length === 0;
status.textContent = items.length ? "" : "가져올 항목이 없음";
list.hidden = take.hidden = clone.hidden = send.hidden = items.length === 0;
syncClone();
// 발행 단추 — 서버가 준 권한대로만 보임(회사 = 마스터 · 기본 = 시스템 관리자).
toCompany.hidden = !canPublish?.company;
toProgram.hidden = !canPublish?.program;
status.textContent = items.length ? afterLoad : "가져올 항목이 없음";
afterLoad = "";
} catch (error) {
status.textContent = error instanceof Error ? error.message : "목록을 받지 못함";
} finally {
@@ -187,11 +298,42 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
);
return result.deleted ? "내 라이브러리에서 지움" : "지울 내 양식이 없음";
});
// 발행 — [내 라이브러리에 저장]과 같은 모양으로 회사·프로그램 기본 단에(2026-09-14 브레인 승인).
const publish = (label: string, tier: "company" | "program"): HTMLButtonElement => {
const button = personal(label, async () => {
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
const whom = tier === "program" ? "모든 회사가 쓰는 프로그램 기본" : "우리 회사 라이브러리";
const masked =
tier === "program" && options.originProject
? `\n이 항목은 「${options.originProject}」에서 뽑은 것 — 공사명은 빼고 발행됩니다`
: "";
if (
!window.confirm(
`이 장의 양식과 고친 식을 ${whom}에 발행 — 같은 종류가 있으면 덮어씀${masked}`,
)
) {
return "";
}
await readJson(
await fetch(libraryUrl(projectId, "/publish"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sheet_key: sheetKey, tier }),
}),
);
return `${whom}에 발행함`;
});
button.hidden = true;
return button;
};
const toCompany = publish("회사 라이브러리에 발행", "company");
const toProgram = publish("프로그램 기본으로 발행", "program");
const mine = document.createElement("div");
mine.className = "b08-sheet__actions";
mine.append(save, remove);
mine.append(save, remove, toCompany, toProgram);
panel.append(title, scope, load, list, take, mine, status);
panel.append(title, scope, load, list, take, clone, send, who, mine, status);
// 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장).
panel.append(
buildStmatePanel({
@@ -233,7 +233,16 @@ export function unitPriceEditor(options: EditorOptions): HTMLElement {
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
const items = payload.items ?? [];
results.replaceChildren(
...(items.length ? items.map(choice) : [el("span", "b08-grid__caption", "없음")]),
...(items.length
? items.map(choice)
: [
// 후보 0 — 조용히 막히지 않게 다음 길을 적음(갈래 바꿔 찾기 · 수동 단가는 미확정으로 셈).
el(
"span",
"b08-grid__caption",
"단가표에 후보 없음 — 낱말·갈래(품셈/자원)를 바꿔 찾거나, 이 줄에 수동 단가를 넣을 것(미확정으로 셈)",
),
]),
);
} catch (error) {
results.replaceChildren(
@@ -3,10 +3,13 @@
근거: STmate 분석 `12_원가계산_4형식과_RATE표_복원.md` §4~6 (DFM 복원 서식) ·
`35_형식과_단계의_공통과_차이.md` §1·§2 (공통 사슬 · 형식별 차이 · 12 정정 반영).
**금액을 낸다** 요율 데이터가 없다(2026-09-14 확인: 수공 세부 경비 율표 `RateB` ·
표준시장단가 제비율 `RATE_RA`·`RATE_RA1`·실적 안전표 `An_A`). 줄마다 무엇이 막혔는지만 적는다.
**금액을 낸다** 2026-09-14 요율 결론(PLAN 7): 실적형 제비율 `RATE_RA`·`RATE_RA1`
**임도 미적용**(100 미만 · 예정가격작성기준 §37) · 수공 세부 경비 `RateB` **법정 요율 없음**
(업체 실측 대상 §34 설계자 입력 ) · 실적 안전표 `An_A` 고시 편입(제4조·별표1)이나 실적형
자체가 임도 미적용. 줄마다 무엇이 막혔는지만 적는다.
**0 원으로 채우지 않는다** 0 원은 없다 아니라 공짜 읽혀 총액이 조용히 작아진다.
35 §5 미확인(실적형 이윤 밑수 · 실적수공 안전관리비 존재) 확인 대기 세운.
35 §5 미확인 확인 대기 세운다 지금 남은 것은 **실적형 이윤 밑수** 하나.
실적수공 안전관리비는 2026-09-14 코덱스가 STmate 원문에서 **있음** 확인해 별도내역으로 내렸다.
"""
from __future__ import annotations
@@ -16,12 +19,20 @@ from typing import Any
#: 줄 상태 — 화면이 그대로 보인다.
COMMON = "공통 요율 — 일반형식과 같은 표(형식 전체가 서면 금액이 섬)"
SEPARATE = "별도내역 — 요율이 아니라 따로 산출한 금액"
RATE_B = "요율표 미확보 — 수공 세부 경비 율표(STmate RateB)"
RATE_RA = "요율표 미확보 — 표준시장단가(실적공사비) 제비율(STmate RATE_RA·RATE_RA1)"
RATE_AN_A = "요율표 미확보 — 실적 산업안전보건관리비 표(STmate An_A·An_A1)"
RATE_B = (
"법정 요율 없음 — 값을 넣어 주십시오(업체 실측 대상 · 예정가격작성기준 §34①) · 수공 세부 경비"
"(STmate RateB) · 입력 칸은 수공형식 금액 셈과 함께 섬"
)
RATE_RA = (
"임도 미적용 — 100억 미만(예정가격작성기준 §37②) · 표준시장단가(실적공사비) 제비율"
"(STmate RATE_RA·RATE_RA1)"
)
RATE_AN_A = (
"임도 미적용 — 실적형 형식 전체가 100억 미만 임도에 안 씀(§37②) · 요율 자체는 고시 편입"
"(건설업 산업안전보건관리비 계상 및 사용기준 제4조·별표1)"
)
TOTAL = "합계 — 위 줄이 다 서야 섬"
PENDING_PROFIT = "확인 대기 — 실적형 이윤 밑수가 복원식과 화면 문구가 충돌(35번 §5-1)"
PENDING_SAFETY = "확인 대기 — 실적수공에 안전관리비가 실제로 있는지 미확인(35번 §5-2)"
DIRECT = "설계내역서 합계에서 옴"
_SUGONG_EXPENSES = (
@@ -115,7 +126,8 @@ def _actual(sugong: bool) -> list[dict[str, Any]]:
_r("6)", "연금보험료", "직접노무비 ×", COMMON, 1),
_r("7)", "퇴직공제부금비", "직접노무비 ×", COMMON, 1),
_r("8)", "산업안전보건관리비", "(직접공사비+도급자관급자재) ×", RATE_AN_A, 1),
_r("9)", "안전관리비", "", PENDING_SAFETY if sugong else SEPARATE, 1),
# TA·SA 둘 다 별도내역 — SA 도 있음이 확인됨(2026-09-14 코덱스, 35번 §5-2 풀림).
_r("9)", "안전관리비", "", SEPARATE, 1),
_r("10)", "품질관리비", "", SEPARATE, 1),
]
if sugong:
+9 -7
View File
@@ -54,6 +54,14 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = {
"ⓘ 성토면 · 기계: 원문이 굴착기 0.6㎥ 형식을 안 적어 무한궤도·타이어 두 갈래로 세움"
"(제안 무한궤도 — 영월 실무 「06M3 B/H」 · 2026-09-14 브레인 ②).",
),
# 2026-09-14 ㉮ — 사용횟수 갈래로 푼 뒤 남는 원문 몫. 값을 짓지 않고 말만.
"FP-12-04": (
"원문 [주]",
"ⓘ 「사용고재 평가기준 23%(합판과 각재의 설계단가 기준)」 은 원문이 셈을 안 줘 값으로 안 씀"
" — [주]① 2회 이상 사용 고재량은 재료비 비율에 기포함. "
"ⓘ 재료(합판·못 카탈로그 없음 · 각재·철선·박리제 규격 미정)는 못 붙은 줄 — 지금 값은"
" **인력 품만** · [주]③ 동바리 별도 · [주]⑥ 소형구조물 인력품 30% 할증(선택)은 안 걺.",
),
"FP-12-25": (
"운반거리 미정",
"⚠ 이 값에는 **운반 몫이 빠져 있습니다** — 품셈 12-25 는 「운반 | 덤프트럭(15ton)」 줄을 "
@@ -76,13 +84,7 @@ CONDITIONAL_INCLUDED: dict[str, str] = {
#: (표 읽기 사유 「박리제 줄을 못 풀었습니다」는 사람을 엉뚱한 데로 보낸다).
#: ⚠ 이 구현이 서면 그 줄을 지운다.
BLOCKED_BY_DESIGN: dict[str, str] = {
# 2026-09-13 브레인 판정 — B09 일위대가 일감(B08 Formwork 머리말 「횟수별 재료 환산은 여기서
# 하지 않는다」). 표를 그대로 풀면 1회 사용 값(재료·노무 100 %)으로 조용히 비싸진다.
"FP-12-04": (
"거푸집 **사용횟수별 비율**(12-4 「1회 100 % · 4회 40 %…」)을 일위대가에 거는 셈이"
" 아직 없어"
" 풀지 않았습니다 — 풀면 1회 사용 값으로 비싸게 섭니다"
),
# (비어 있음) 12-4 합판거푸집은 2026-09-14 사용횟수 갈래로 풀려 걷음(`_JudgedTable`).
}
+11 -104
View File
@@ -27,6 +27,16 @@ from dataclasses import dataclass, field, replace
from decimal import Decimal, InvalidOperation
from typing import Any
# 셀 거르기 · 물결표 목록 · 셀 정규화 — 700줄 제한으로 `_Labels` 에 둠(순수 분리 2026-09-14).
# 바깥 파일이 여기서 가져가는 이름은 그대로 다시 내보낸다.
from B09_Estimation.B09_Estimation_ResourceAxis_Labels import (
_RE_RANGE_CELL, # noqa: F401
RANGE_DASH_CLASS, # noqa: F401
RANGE_DASHES, # noqa: F401
_normalize,
is_non_resource_label,
)
#: 자원 축을 붙일 수 있는 표 형태. 나머지는 값을 쓰지 않는다.
USABLE_FORMS = frozenset({"productivity", "requirement"})
#: 공종이 아닌 표 — 일위대가 항목으로 세우지 않는다.
@@ -48,45 +58,6 @@ _RE_NUMBER = re.compile(r"^-?\d+(?:,\d{3})*(?:\.\d+)?$")
#: 보통인부 한 줄만 남았다).
_GROUP_LABELS = ("자재", "장비", "인력", "노무", "재료", "기계")
#: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다.
#: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다.
#: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`·
#: `계장공` 이, 「작업」을 잡으면 `작업반장` 이, 「인력」을 잡으면 `인력운반공` 이
#: 통째로 사라진다(2026-09-07 실측 — 정상 자원 **70/745** 가 걸리고 있었음).
#: 그래서 **셀 전체가 그 말과 같을 때만** 머리글로 본다.
_NON_RESOURCE_WORDS = (
"구분",
"합계",
"소계",
"",
"단위",
"비고",
"규격",
"명칭",
"품명",
"종류",
"항목",
"적용",
"기준",
"산출",
"비율",
"할증",
"할인",
"직접노무비",
"재료비",
"경비",
"위치",
"면적",
"수량",
"공종",
"작업",
"내역",
"총계",
"인력",
"장비",
"기계",
)
class ResourceAxisError(ValueError):
"""자원 축을 붙일 수 없는 경우. 조용히 넘기지 않는다."""
@@ -151,71 +122,6 @@ class ResourceCatalog:
return narrowed[0] if len(narrowed) == 1 else None
#: 첫 칸이 자원 이름이 **아닌** 표가 많다 — 규격 구간표(「10∼12」), 기호표(「f」·「E」),
#: 치수표 등. 그런 셀을 못 맞춘 목록에 넣으면 목록이 못 쓰게 되므로 먼저 거른다.
#: ⚠ **물결표·붙임표 목록은 여기 한 벌뿐이다.** 네 파일에 따로 적어 두었더니 서로
#: 달라졌다(2026-09-08 메인 창 교차검토 — 어떤 목록엔 `~`, 어떤 목록엔 `〜` 가 빠졌음).
#: 지금 물리는 것은 없었으나 **같은 목록이 네 벌이면 언젠가 하나만 고쳐진다.**
#: 갈래 키 정규화(`B09_Estimation_UnitPrice.normalize_variant_key`)도 이 목록을 쓴다.
RANGE_DASHES = "∼~〜~-–‐"
#: ⚠ **문자클래스에 그대로 넣지 말 것** — `~-–` 이 **범위 연산자**로 읽혀 거의 모든
#: 글자가 걸린다(2026-09-08 실측: 「0.7㎥」·「15톤」이 구간으로 잡혔음). 반드시 이 쪽을 쓴다.
RANGE_DASH_CLASS = "".join(re.escape(ch) for ch in RANGE_DASHES)
#: 구간 셀에는 **단위 꼬리**가 붙기도 한다 — 「51~100m」·「1214㎝」(2026-09-08 실측 39줄).
#: ⚠ **단위가 붙었다고 다 구간이 아니다** — 「굴착기 0.7㎥」는 규격이고 자원 이름의 일부다.
#: 그래서 **수 ~ 수 + 단위**라는 모양 전체가 맞을 때만 구간으로 본다(앞에 이름이 없어야 한다).
_RANGE_UNITS = "a-zA-Z㎝㎜㎥㎡㎞mm톤"
_RE_RANGE_CELL = re.compile(
rf"^\d+(?:\.\d+)?\s*[{RANGE_DASH_CLASS}]\s*\d+(?:\.\d+)?\s*[{_RANGE_UNITS}]+$"
)
_RE_HANGUL = re.compile(r"[가-힣]")
def is_non_resource_label(cell: str) -> bool:
"""표 머리글·소계 행이거나, 애초에 자원 이름이 올 자리가 아닌 셀인가.
맞춘 목록에 이런 것이 섞이면 목록 자체가 쓰게 된다. 여기서 먼저 걷어낸다.
"""
text = _normalize(cell)
if not text:
return True
if text.startswith(("", "<", "(", "-", "", "·")):
return True
if _RE_RANGE_CELL.match(text): # 규격 구간표의 첫 칸
return True
# 자원 이름은 숫자로 시작하지 않는다 — 「50이상」·「100m이하」·「2.집재」는 구간·절번호다.
if text[0].isdigit():
return True
# 자원 이름은 한글 두 자 이상이다. 기호(`f`·`E`)·숫자·단위만 있는 칸은 자원이 아니다.
if len(_RE_HANGUL.findall(text)) < 2:
return True
# ⚠ **정확 일치만** — 부분일치는 정상 자원을 통째로 지운다(위 주석).
if text in _NON_RESOURCE_WORDS:
return True
# 머리글 조각이 이어 붙은 칸(「단위작업별」·「위치및면적」)도 머리글이다.
return _is_header_composite(text)
def _is_header_composite(text: str) -> bool:
"""머리글 낱말만으로 이루어진 칸인가 — 「단위작업별」·「위치및면적」 같은 것.
낱말을 차례로 벗겨 아무것도 남으면 머리글로 본다. 자원 이름은 낱말을 벗기면
반드시 무언가 남는다(`건설기계운전사` `건설`·`운전사`).
"""
rest = text
for word in sorted(_NON_RESOURCE_WORDS, key=len, reverse=True):
rest = rest.replace(word, "")
rest = rest.replace("", "").replace("", "").strip()
return rest == ""
def _normalize(text: str) -> str:
"""표 셀의 공백·개행 흔들림을 지운다. 「경 암」·「연 암」 같은 것."""
return re.sub(r"\s+", "", str(text or "")).strip()
@dataclass
class ResourceRow:
"""자원 축 한 줄 — 공종(표) 하나에 붙는 자원 하나."""
@@ -442,6 +348,7 @@ def match_table(
# 사람이 모양을 읽어 둔 표(9-19-1 절토면·성토면 · 2026-09-14 판정) — 적어 둔 표만.
from B09_Estimation.B09_Estimation_ResourceAxis_JudgedTable import match_judged_table
if match_judged_table(node, table, catalog, result, basis_quantity, unit):
return
@@ -5,6 +5,8 @@
F0288 1. 절토면 고르기 자원 이름이 ** 자료 **(보통인부 () ) · 규격은 [] 에만
· · = 토질엔 자원 없음
F0289 2. 성토면 고르기 병합 (시공) 탓에 둘째 줄이 앞당겨
F0336 12-4 합판거푸집 기준수량(1) × 사용횟수별 비율 재료 줄은 재료 %, 인력 줄은 노무비 %
(원문 L6187 · 2026-09-14 브레인 비율 셈이 없어 막아 뒀던 )
일반 보정으로 넓히지 않음 밀린 고쳐 읽기는 목재틀흙막이 503만원 전례(`_Transposed` 머리말).
**적어 표만** 읽고, 칸이 판정과 다르면 줄도 세우고 막음.
@@ -47,7 +49,17 @@ JUDGED_TABLES: dict[str, dict[str, Any]] = {
"form_split": {"굴착기": ("무한궤도", "타이어")},
"why": "원문 L5430 「2. 성토면 고르기 (단위: 10㎡당)」 — 시공 칸 병합으로 둘째 줄 앞당김",
},
"F0336": {
"code": "FP-12-04",
"shape": "use_count",
"prefix": "합판거푸집",
"why": "원문 L6187 「기준수량(1회사용) · 사용횟수별기준수량에대한 비율(%) 재료별·노무비」",
},
}
#: 비율 줄 — 「1회사용시 2회사용시 …」 칸.
_RE_USE_COUNT = re.compile(r"(\d+)회사용시")
#: 값으로 안 읽는 줄 — 사용고재 평가기준(원문이 셈을 안 줌 · 사유는 `KNOWN_GAPS`) · 비고.
_NOT_RESOURCE_ROWS = ("사용고재평가기준", "비고", "횟수별")
#: 「보통인부 (인)」 의 단위 꼬리 — 규격이 아님.
_UNIT_TAIL = re.compile(r"\s*[(](?:인|시간)[)]\s*$")
@@ -103,6 +115,8 @@ def match_judged_table(
return block("판정 표에 밑수가 없습니다")
if judged["shape"] == "header_row":
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit)
elif judged["shape"] == "use_count":
staged = _use_count(code, table, rows, catalog, basis_quantity, unit)
else:
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
if isinstance(staged, str):
@@ -176,3 +190,32 @@ def _merged_first(code, table, judged, rows, catalog, basis, unit) -> list | str
named = f"{variant} · {form}" if form else variant
staged.append(_row(code, table, entry, amount / basis, unit, index, named))
return staged
def _use_count(code, table, rows, catalog, basis, unit) -> list | str:
"""[이름, 단위, 기준수량, …] + 비율 줄 하나 → 사용횟수마다 갈래(재료 % · 노무비 %)."""
ratio_row = next((cells for cells in rows if _RE_USE_COUNT.search(" ".join(cells))), None)
if ratio_row is None:
return "사용횟수 비율 줄"
counts = _RE_USE_COUNT.findall(" ".join(ratio_row))
number = re.compile(r"\d+(?:\.\d+)?")
material = [Decimal(x) for x in number.findall(ratio_row[4] if len(ratio_row) > 4 else "")]
labor = [Decimal(x) for x in number.findall(ratio_row[5] if len(ratio_row) > 5 else "")]
if not counts or not (len(counts) == len(material) == len(labor)):
return f"비율 칸 수(횟수 {len(counts)} · 재료 {len(material)} · 노무비 {len(labor)})"
staged: list = []
for index, cells in enumerate(rows):
name = "".join(cells[0].split()) if cells else ""
base = parse_amount(cells[2]) if len(cells) > 2 else None
if not name or name in _NOT_RESOURCE_ROWS or base is None:
continue
entry = _entry(catalog, cells[0], code)
if entry is None:
reason = unmatched_reason(catalog, cells[0])
staged.append(UnmatchedRow(code, str(table.get("pum_table_id", "")), cells[0], reason))
continue
ratios = labor if entry.kind == "labor" else material
for count, ratio in zip(counts, ratios):
amount = base * ratio / Decimal(100) / basis
staged.append(_row(code, table, entry, amount, unit, index, f"{count}"))
return staged
@@ -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()
@@ -87,8 +87,8 @@ def cost_from_bill(root: str, bill: dict[str, Any], direct: dict[str, Decimal]):
async def get_cost_sheet(project_id: UUID, form: str = "general") -> JSONResponse:
"""원가계산서 한 장 — 서식 줄 · 기준 입력 선택지 · 상태줄.
`form` 일반형식만 금액을 . 수공·실적일반·실적수공은 **서식 차례만**(요율표 미확보 줄은
막고 사유 · 0 원으로 채움).
`form` 일반형식만 금액을 . 수공·실적일반·실적수공은 **서식 차례만**(실적형 임도 미적용 ·
수공 세부 경비 법정 요율 없음 줄은 막고 사유 · 0 원으로 채움).
"""
from B09_Estimation.B09_Estimation_Router import get_bill
from common_util.common_util_project_settings import estimation_settings
+8 -3
View File
@@ -194,6 +194,8 @@ def safety_management_cost(
대상액이 **다른 구간에 떨어질 있어** A 항상 작지는 않다. 줄을 남겨
화면이 나란히 보이게 한다(실무 `안전관리비검토` 시트와 같은 서식).
"""
from B09_Estimation.B09_Estimation_Engine_Cost import floor_won
variable = dataset.variable("rate_safety_pct")
brackets = variable["brackets"]
@@ -248,7 +250,12 @@ def safety_management_cost(
)
percent = rate_percent(row, label=label)
flat = base_amount(row)
return (base * percent / _HUNDRED + flat) * multiplier, percent, flat
# ⭐ 2026-09-14 고침 — **호별 산정액을 먼저 원 단위로 맺고** 1.2 를 곱한다.
# 고시 제4조① 단서 「… 대상액에서 제외하고 **산출한 산업안전보건관리비**의 1.2배」 —
# 1.2배의 대상은 1·2호로 **산정이 끝난 금액**이다. 종전엔 1.2 를 곱한 뒤 한 번만 버려
# 영월 2024 B 줄이 20,330,639 로 원본(20,330,638)보다 1원 컸다(골든셋 실증).
# A(배수 1)는 어느 차례로 해도 같은 값이다.
return floor_won(base * percent / _HUNDRED + flat) * multiplier, percent, flat
raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)")
raw_b, percent_b, flat_b = evaluate(
@@ -257,8 +264,6 @@ def safety_management_cost(
# 어느 쪽이 채택인지 먼저 정해 두 줄에 표시를 단다 — 화면이 나란히 보이고
# 채택 줄이 눈에 띄어야 한다(PLAN 8-12 실무 `안전관리비검토` 시트 서식).
from B09_Estimation.B09_Estimation_Engine_Cost import floor_won
adopted = "A" if floor_won(raw_a) <= floor_won(raw_b) else "B"
amount_a = emit(
@@ -73,6 +73,12 @@ const FORMS = [
];
/** DFM 본문 아래 탭 이름 그대로. */
const FORM_TABS = ["general", "sugong", "actual_general", "actual_sugong"];
/** 탭 말풍선 — 금액이 안 서는 까닭(2026-09-14 요율표 셋 결론 · 줄 사유는 서버 문구). */
const FORM_TAB_NOTES: Record<string, string> = {
sugong: "서식 차례만 — 세부 경비 법정 요율 없음(값 입력 대기 · 예정가격작성기준 §34①)",
actual_general: "서식 차례만 — 임도 미적용(100억 미만 · 예정가격작성기준 §37②)",
actual_sugong: "서식 차례만 — 임도 미적용(100억 미만 · 예정가격작성기준 §37②)",
};
/** 프로젝트별 고른 형식 탭 — 일반형식만 금액이 섬. */
const formOf = new Map<string, string>();
@@ -320,7 +326,7 @@ function drawBody(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void):
label.append(el("span", "", row.label));
if (row.formula) label.append(el("span", "b09cs__formula", `<${row.formula}>`));
label.title = [row.formula, row.note].filter(Boolean).join(" · ");
// 금액 없는 줄 — 「-」 + 막힌 까닭(요율표 미확보 · 확인 대기). 0 원으로 안 보임.
// 금액 없는 줄 — 「-」 + 막힌 까닭(임도 미적용 · 법정 요율 없음 · 확인 대기). 0 원으로 안 보임.
if (row.amount_krw === null && row.blocked) label.append(el("span", "b09cs__warn", row.note));
line.append(
label,
@@ -334,7 +340,7 @@ function drawBody(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void):
for (const key of FORM_TABS) {
const tab = el("button", "b09cs__tab", sheet.form_labels[key] ?? key);
tab.setAttribute("aria-selected", String(key === sheet.form_key));
if (key !== "general") tab.title = "서식 차례만 — 요율표 미확보로 금액 안 섬";
if (FORM_TAB_NOTES[key]) tab.title = FORM_TAB_NOTES[key];
tab.addEventListener("click", () => {
formOf.set(ctx.projectId as string, key);
reload();
@@ -43,6 +43,54 @@
"pum_edition": "2026-01-01",
"basis": "9-19-1 토사면 고르기 절토면 표(원문 L5414)가 자원 머리에 규격을 안 적음 — [주]① 「공기압축기는 3.5㎥/분, 소형브레이커는 1㎥/분, 굴착기는 0.7㎥를 기준한 것이다」 → 굴착기(무한궤도) 0.7(카탈로그 0.7 은 무한궤도뿐). 칸이 규격을 적은 줄(성토면 굴착기 0.6㎥)은 안 덮음. 2026-09-14 브레인 승인(⑤ Ⓐ)"
},
{
"axis": "variant",
"from": "0.2·소림",
"to": "굴착기(무한궤도) 0.2 · 소",
"scope": "FP-09-21",
"pum_edition": "2026-01-01",
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
},
{
"axis": "variant",
"from": "0.2·중림",
"to": "굴착기(무한궤도) 0.2 · 중",
"scope": "FP-09-21",
"pum_edition": "2026-01-01",
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
},
{
"axis": "variant",
"from": "0.2·밀림",
"to": "굴착기(무한궤도) 0.2 · 밀",
"scope": "FP-09-21",
"pum_edition": "2026-01-01",
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
},
{
"axis": "variant",
"from": "0.7·소림",
"to": "굴착기(무한궤도) 0.7 · 소",
"scope": "FP-09-21",
"pum_edition": "2026-01-01",
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
},
{
"axis": "variant",
"from": "0.7·중림",
"to": "굴착기(무한궤도) 0.7 · 중",
"scope": "FP-09-21",
"pum_edition": "2026-01-01",
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
},
{
"axis": "variant",
"from": "0.7·밀림",
"to": "굴착기(무한궤도) 0.7 · 밀",
"scope": "FP-09-21",
"pum_edition": "2026-01-01",
"basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)"
},
{
"axis": "variant",
"from": "리핑암",
@@ -173,6 +173,9 @@
"basis_source": "⚠ 교차 참조 — 산림품셈 9-21 표에 밑수 표기가 없어 건설공사 표준품셈 3-9-2 뿌리뽑기 「1,000㎡당」 면적 축을 빌려 씀(사용자 확정 5차 6번). 마스터 밑수는 비어 있음(basis_missing F0294).",
"variant_axis": "stand_volume_class",
"variant_from": "stand_volume_class",
"variant_template": "{root_removal_excavator_m3}·{stand_volume_class}",
"variant_missing_reason": "제근 굴착기 크기·임목축적 등급이 아직 다 입력되지 않았습니다 — 산출 조건에서 고르면 단가가 섭니다(품셈 9-21: 굴착기 0.2·0.7 × 소림·중림·밀림 · 크기 제안 0.7㎥ 산림품셈 10-12-1 [주]①)",
"variant_note": "2026-09-14 브레인 판정 — 크기·등급 두 칸을 템플릿으로 한 값(「0.7·소림」)으로 엮고 B09 갈래 표기는 범위 별칭(FP-09-21)이 앎. 둘 중 하나라도 비면 등급만 싣고 입력 사유.",
"note": "2026-09-13 브레인 판정 Ⓑ — 제근은 토공 줄(토공_수량.md:30 「뿌리다듬기·적재·제근 | 9-20~21 | 벌개제근 연동」)이라 여기서 셈. 준비공 「제근·뿌리다듬기」는 같은 면적이라 참조로만 보임(이중계상 막이). 품은 임목축적 등급(소림·중림·밀림, 9-21 [주]①)으로 갈려 등급을 갈래로 넘김."
}
],
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-14T16:09:37+09:00",
"generated_at": "2026-09-14T18:26:57+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": {
"dataset_id": "pum_forest",
@@ -12,8 +12,8 @@
"files": [
{
"file": "work_item_master_2026-01-01.json",
"sha256": "a942f7ad412441ba0c7ad3f8149fd143b10502dae5d20053e5d98029ab31c6b4",
"size_bytes": 838243
"sha256": "fa4d9bc80627d33a46b203f8910f9cc802c1249f2c6c8f09d2e818fce4332287",
"size_bytes": 838421
},
{
"file": "form_undetermined_2026-01-01.json",
@@ -3,7 +3,7 @@
"dataset_id": "work_item_master_forest",
"effective_date": "2026-01-01",
"pum_edition": "2026-01-01",
"generated_at": "2026-09-14T16:09:37+09:00",
"generated_at": "2026-09-14T18:26:57+09:00",
"dataset_version": {
"dataset_id": "pum_forest",
"effective_date": "2026-01-01",
@@ -31615,7 +31615,14 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"1회",
"2회",
"3회",
"4회",
"5회",
"6회"
],
"condition_note": [
"구 분",
"단위",
@@ -31717,7 +31724,14 @@
]
}
],
"variant_keys": []
"variant_keys": [
"1회",
"2회",
"3회",
"4회",
"5회",
"6회"
]
},
{
"work_item_code": "FP-12-05",
@@ -0,0 +1,66 @@
"""관 벽 · 돌쌓기 줄 뒷길이 — 인계가 **B08 이 실제로 쓴 뒷길이 + 출처**를 싣는다(2026-09-14 브레인).
종전: 인계는 저장 원본 `back_len_cm` 실어, 고른 ( 유입·유출부 기슭막이는 )
`variant_value=None` B09 13-04-02/05 뒷길이 갈래(#35·55·75cm이하)를 못 고름. 그런데 수량표·
구조물도는 이미 품셈 13-4-4 [] 표준표 하한으로 섰음(두께식·고임돌·채움콘크리트가 ).
값이 뒷길이를 갈래 값으로 · 출처(저장 / [] 하한) 판정 근거 칸에.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit # noqa: E402
def _row(type_id: str, options: dict) -> dict:
unit = build_unit(
[
{
"structure_id": "x",
"type_id": type_id,
"start_m": 0.0,
"end_m": 10.0,
"options": options,
}
],
{type_id: type_id},
)
handoff = build_handoff(unit_quantity_table=unit)
return next(r for r in handoff["work_items"] if r["variant_axis"] == "back_len_cm")
def test_안_고른_뒷길이는_표준표_하한을_출처와_함께_보냄() -> None:
row = _row("masonry_wet", {"height_m": 1.5, "length_m": 10.0})
assert row["variant_value"] == "25", row # 찰쌓기 ≤1.5m 하한 25㎝
assert "뒷길이 25㎝" in row["spec_class_basis"] and "[주]⑩" in row["spec_class_basis"]
def test_고른_뒷길이는_원본값_그대로_출처는_저장_제원() -> None:
row = _row("masonry_wet", {"height_m": 1.5, "length_m": 10.0, "back_len_cm": "45"})
assert row["variant_value"] == "45"
assert "[주]⑩" not in row["spec_class_basis"] and "저장 제원" in row["spec_class_basis"]
def test_기슭막이_메쌓기도_하한으로_갈래가_서서_내역_금액이_섬() -> None:
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
options = {"height_m": 2.5, "length_m": 10.0, "form": "돌쌓기(메)"}
row = _row("revetment", options)
assert row["variant_value"] == "45", row # 메쌓기 ~3m 하한 36 → 규격 45
assert row["work_item_code"] == "FP-13-04-02" and not row["blocked_kind"], row
unit = build_unit(
[{"structure_id": "x", "type_id": "revetment", "start_m": 0.0, "end_m": 10.0,
"options": options}],
{"revetment": "revetment"},
) # fmt: skip
bill = build_bill(build_handoff(unit_quantity_table=unit))
line = next(r for r in bill.rows if r.code == "FP-13-04-02")
assert line.amount_krw and int(line.amount_krw) > 0, line.note
assert line.price_code.endswith("#55cm이하"), line.price_code # 45㎝ 를 담는 가장 좁은 구간
@@ -24,6 +24,16 @@ def test_저장에_네_키를_싣고_불러온다() -> None:
assert f"{key}: (stored.{key}" in PAGE, f"불러오기에 {key} 없음"
def test_성토면_제안은_회색_근거와_단추로만_넣는다() -> None:
"""메인 976e5fd9 — `fill_suggested` {value, basis}. 비워 두면 「안 정함」 그대로(스스로 안 고름)."""
side = SIDE.read_text(encoding="utf-8")
assert "choices.fill_suggested" in side and "suggested.basis" in side
button = side.split("createButton(")[1]
assert "draft.face_dressing_fill_class = suggested.value" in button # 누른 때만 넣음
assert side.count("= suggested.value") == 2 # 단추 안의 칸·초안 둘뿐
assert "BenchCut" not in side # 층따기 문구(「KDS 토사 0.5 m」)를 빌려 쓰지 않음
def test_선택지는_서버_목록_그대로() -> None:
side = SIDE.read_text(encoding="utf-8")
assert "appendFaceDressingFields(" in PAGE and "face_dressing_choices" in PAGE
@@ -0,0 +1,96 @@
"""㉰ 고정형 장 사유 · ㉱ 규격 다름 (2026-09-14 브레인 판정).
고정형을 가져온 장에 **전개 사유(뒷길이 표준· 종류) 그대로 남아** 고정형 줄과 맞음 = 거짓 사유
항목 사유(원문 시점 수량·가산 ) 띄우고 전개 사유는 **남긴 전개 (토공·버림·기초잡석·채집석) 걸린 것만** .
H=2.0 호표를 H=2.5 벽에 쓰면 수량은 박힌 (H2.0) · 토공·사토 공제는 제원(H2.5) 밑수가 갈림
미확정과 같은 급으로 보임(빨간 테두리·규격 다름 배지) · 금액은 · 막지 않음.
"""
from __future__ import annotations
from pathlib import Path
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item
from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
ROOT = Path(__file__).resolve().parents[2]
HOPYO = {
"no": 6,
"source_code": "B00010",
"name": "기슭막이(깬잡석,찰쌓기 L3=45m)",
"spec": "H=2.0m, 채집",
"unit": "M",
"basis": [],
"contract_rows": 0,
"rows": [
{
"name": "깬잡석찰쌓기",
"spec": "L3=45",
"amount": 2.09,
"unit": "M2",
"remark": "",
"source_code": "",
},
{
"name": "공구손료",
"spec": "노무비의 %",
"amount": 2,
"unit": "%",
"remark": "",
"source_code": "",
},
],
}
NAMES = {"masonry_wet": "돌쌓기(찰)"}
def _sheet(height: float) -> dict:
wall = StructureInstance.model_validate(
{
"structure_id": "a",
"type_id": "masonry_wet",
"placement": "interval",
"start_m": 0.0,
"end_m": 10.0,
"options": {"height_m": height, "foundation": "기초유"},
}
).model_dump()
templates = {
"masonry_wet": {
**recipe_item(HOPYO, type_id="masonry_wet", file_name="a", project="p"),
"code": "AX-ST-0000abcd",
}
}
table = build_table([wall], NAMES, {}, {}, None, structure_templates=templates)
payload = build_standard_sheets(table, {})
apply_templates(payload, {}, templates)
return payload["sheets"][0]
def test_고정형_장은_항목_사유를_띄우고_남의_사유를_걷는다() -> None:
notes = " / ".join(_sheet(2.0)["notes"])
assert "원문 시점 수량" in notes and "가산 행 1줄" in notes
assert "돌 종류를 안 골라" not in notes and "뒷채움 폭" not in notes
def test_규격이_다르면_미확정_급으로_밑수_갈림을_알린다() -> None:
sheet = _sheet(2.5)
mismatch = sheet["spec_mismatch"]
assert (mismatch["item_height_m"], mismatch["sheet_height_m"]) == (2.0, 2.5)
assert "규격 다름" in sheet["notes"][0]
assert "토공·사토 공제는 장 제원(H=2.5m)" in sheet["notes"][0]
assert any(row["amount"] for row in sheet["rows"]) # 금액·수량은 막지 않음
def test_규격이_같으면_아무것도_안_뜬다() -> None:
sheet = _sheet(2.0)
assert not sheet.get("spec_mismatch")
assert not any("규격 다름" in note for note in sheet["notes"])
def test_화면이_규격_다름을_빨간_배지로() -> None:
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet.ts").read_text(encoding="utf-8")
assert "spec_mismatch" in ui and "규격 다름" in ui and "b08-unit__badge" in ui
+108
View File
@@ -0,0 +1,108 @@
"""라이브러리 공유 — 같은 회사 동료에게 내 항목을 복사해 보냄 (PLAN 4장 · 2026-09-14 브레인 판정 ①).
받는 쪽이 모르게 들어가지 않음 받은 항목은 사람 개인 단을 덮지 않고 **받음 ** 따로
(개인 단은 종류당 하나라 그대로 넣으면 동료가 고친 것이 조용히 덮임). 받은 가져오기·복제는 받는 사람 .
같은 회사 안만 · 출처 공사명은 그대로(같은 회사라 공사를 · 판정 ).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402
import B08_Quantity.B08_Quantity_Router_StmateLibrary as router_module # noqa: E402
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402
from common_util.common_util_auth import verify_session # noqa: E402
LIB = "/api/projects/33333333-3333-3333-3333-333333333333/quantity/structure-sheets/library"
MINE = "AX-ST-0000aaaa"
@pytest.fixture()
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
monkeypatch.setattr(library_module, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
item = {
**load_template("masonry_wet"),
"code": MINE,
"library_tier": "personal",
"name": "내 돌쌓기",
"origin": {"project": "봉화 임도"},
}
folder = tmp_path / "storage" / "7" / "42" / "library"
folder.mkdir(parents=True)
(folder / f"{MINE}.json").write_text(json.dumps(item), encoding="utf-8")
# 받는 사람(43)도 같은 종류 내 것이 있음 — 덮이면 안 됨
theirs = tmp_path / "storage" / "7" / "43" / "library"
theirs.mkdir(parents=True)
(theirs / "AX-ST-0000bbbb.json").write_text(
json.dumps({**item, "code": "AX-ST-0000bbbb", "name": "동료 것"}), encoding="utf-8"
)
async def members(company_id: int) -> list[dict]:
assert company_id == 7
return [
{"id": 42, "name": "", "email": "me@x"},
{"id": 43, "name": "동료", "email": "you@x"},
]
monkeypatch.setattr(router_module, "_company_members", members)
app = FastAPI()
app.include_router(router_module.router)
app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 42}
return TestClient(app)
def test_동료_목록은_같은_회사에서_나를_뺀다(client: TestClient) -> None:
body = client.get(f"{LIB}/colleagues").json()
assert body["colleagues"] == [{"id": 43, "name": "동료"}]
def test_보내면_받는_쪽_받음_단에_서고_그_사람_개인_단은_안_덮인다(
client: TestClient, tmp_path: Path
) -> None:
sent = client.put(
f"{LIB}/share", json={"type_id": "masonry_wet", "code": MINE, "to_user_id": 43}
)
assert sent.status_code == 200, sent.text
received = tmp_path / "storage" / "7" / "43" / "library_received" / f"{MINE}.json"
saved = json.loads(received.read_text(encoding="utf-8"))
assert saved["library_tier"] == "received"
assert saved["received_from"]["name"] == "" and saved["origin"]["project"] == "봉화 임도"
theirs = tmp_path / "storage" / "7" / "43" / "library" / "AX-ST-0000bbbb.json"
assert json.loads(theirs.read_text(encoding="utf-8"))["name"] == "동료 것"
dirs = library_module.tier_dirs(7, 43)
listed = library_module.list_items(dirs, "masonry_wet")
assert [i["tier"] for i in listed][:2] == ["personal", "received"]
def test_회사_밖이나_내_것_아닌_항목은_못_보낸다(client: TestClient) -> None:
stranger = client.put(
f"{LIB}/share", json={"type_id": "masonry_wet", "code": MINE, "to_user_id": 99}
)
assert stranger.status_code == 404
missing = client.put(
f"{LIB}/share", json={"type_id": "masonry_wet", "code": "AX-ST-0000cccc", "to_user_id": 43}
)
assert missing.status_code == 404
myself = client.put(
f"{LIB}/share", json={"type_id": "masonry_wet", "code": MINE, "to_user_id": 42}
)
assert myself.status_code == 400
def test_화면에_보내기_단추와_받음_표시() -> None:
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
encoding="utf-8"
)
assert "/share" in ui and "/colleagues" in ui and "동료에게 보내기" in ui
assert 'received: "받음"' in ui
@@ -0,0 +1,61 @@
"""고르개 후보 — 두 글자 이상 조각이 겹치면 띄움 (2026-09-14 브레인 판정 ①).
실측(936be972): 고정형 이름 그대로(깬잡석채집·고임돌채집) 찾으면 **후보 0** 단가표 제목
(막돌 채집·레디믹스트콘크리트 타설) 낱말이 달라 낱말 전부 포함 방식으로는 영영 맞음.
겹친 조각(채집·타설)으로 넓게 띄우고 **겹친 글자 **. 고르는 것은 사람(별칭표로 맞춤 · 명세 2).
"""
from __future__ import annotations
from decimal import Decimal
from types import SimpleNamespace
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import search_titles
from B09_Estimation.B09_Estimation_PriceBook import Money3
def _title(code: str, name: str, spec: str, unit: str) -> SimpleNamespace:
return SimpleNamespace(
code=code, name=name, spec=spec, unit=unit, kind=SimpleNamespace(value="unit_price")
)
class Book:
titles = {
t.code: t
for t in (
_title("B-1", "막돌 채집", "㎡당", ""),
_title("B-2", "레디믹스트콘크리트 타설", "무근구조물", ""),
_title("B-3", "고임돌 채집", "기계", ""),
_title("B-4", "견치돌 찰쌓기", "뒷길이 35㎝", ""),
_title("B-5", "표토 제거", "", ""),
)
}
def resolve(self, code: str) -> Money3:
return Money3(Decimal(1), Decimal(0), Decimal(0))
def _codes(query: str) -> list[str]:
return [item["code"] for item in search_titles(Book(), query, "work")]
def test_이름_그대로도_겹친_조각으로_후보가_뜬다() -> None:
assert _codes("깬잡석채집")[:1] and set(_codes("깬잡석채집")) >= {"B-1", "B-3"}
assert _codes("레미콘타설(장비)") == ["B-2"]
assert _codes("깬잡석찰쌓기") == ["B-4"]
def test_겹친_글자_수_순() -> None:
# 「고임돌채집」 — 고임돌 채집(5자 겹침) · 막돌 채집(「돌채집」 3자) 차례
assert _codes("고임돌채집")[:2] == ["B-3", "B-1"]
def test_한_글자만_겹치면_안_띄운다() -> None:
assert "B-5" not in _codes("깬잡석채집") # 겹침 없음
assert "B-4" not in _codes("고임돌채집") # 「돌」 한 글자만 겹침
def test_종전_낱말_검색도_그대로() -> None:
assert _codes("찰쌓기 35") == ["B-4"]
assert set(_codes("")) == {"B-1", "B-3", "B-4"} # 낱말이 제목에 든 것은 종전대로
@@ -0,0 +1,70 @@
"""9-21 제근 굴착기 크기 칸 — 2026-09-14 브레인 판정(산출 조건 칸 · 제안 0.7㎥ · 비면 사유).
9-21 품은 굴착기 0.2·0.7 × 임목축적 등급(··) 여섯 갈래인데 B08 등급만 보내 B09 고름.
매핑 `variant_template` 크기·등급 입력을 (0.7·소림)으로 엮고, 범위 별칭(FP-09-21)
값을 B09 갈래(굴착기(무한궤도) 0.7 · ) 잇는다 갈래 표기는 별칭표 곳만 .
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
ROOT_REMOVAL_EXCAVATOR_SIZES,
ROOT_REMOVAL_EXCAVATOR_SUGGESTED,
)
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Preparation import STAND_VOLUME_CLASSES # noqa: E402
def _row(**inputs) -> dict:
summary = {"rows": [{"group": "지장목제거", "item": "뿌리뽑기", "spec": "", "unit": "",
"amount": 1000.0, "application_ratio_pct": 100.0}]} # fmt: skip
rows = build_handoff(summary_table=summary, **inputs)["work_items"]
return next(r for r in rows if r["work_item_code"] == "FP-09-21")
def test_크기와_등급을_한_갈래_값으로_엮어_내역_금액이_섬() -> None:
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
inputs = {"stand_volume_class": "소림", "root_removal_excavator_m3": "0.7"}
row = _row(**inputs)
assert row["variant_value"] == "0.7·소림" and not row["blocked_kind"], row
summary = {"rows": [{"group": "지장목제거", "item": "뿌리뽑기", "spec": "", "unit": "",
"amount": 1000.0}]} # fmt: skip
bill = build_bill(build_handoff(summary_table=summary, **inputs))
line = next(r for r in bill.rows if r.code == "FP-09-21")
assert line.price_code == "B-FP-09-21#굴착기(무한궤도)0.7·소", line.note
assert line.amount_krw and int(line.amount_krw) > 0, line.note
def test_크기가_비면_금액_없이_입력_사유_등급은_그대로_실음() -> None:
row = _row(stand_volume_class="중림")
assert row["variant_value"] == "중림" # 판정 Ⓑ — 등급 갈래를 잃지 않음
assert row["blocked_kind"] == "input_missing" and "굴착기" in row["blocked_reason"], row
assert _row()["blocked_kind"] == "input_missing"
def test_선택지는_마스터_갈래_키의_크기_제안은_0_7_근거_10_12_1() -> None:
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["work_item_code"] == "FP-09-21"
)
sizes = {key.split(")")[1].split("·")[0].strip() for key in node["variant_keys"]}
assert sizes == set(ROOT_REMOVAL_EXCAVATOR_SIZES)
value, basis = ROOT_REMOVAL_EXCAVATOR_SUGGESTED
assert value == "0.7" and "10-12-1" in basis
# 크기 × 등급 여섯이 모두 별칭으로 B09 갈래에 닿음
from common_util.common_util_aliases import alias_target, load_aliases
rows = load_aliases("variant")
for size in ROOT_REMOVAL_EXCAVATOR_SIZES:
for grade in STAND_VOLUME_CLASSES:
target = alias_target(rows, f"{size}·{grade}", "FP-09-21", "2026-01-01")
assert target in node["variant_keys"], (size, grade, target)
@@ -105,6 +105,8 @@ def test_찾기는_코드_없는_줄_이름으로_후보만_띄운다() -> None:
encoding="utf-8"
)
assert "query.value = row.name" in ui and "!row.ref_code && !row.work_item_code" in ui
# 후보 0 이면 다음 길(갈래 바꾸기 · 수동 단가)을 적음 — 조용히 「없음」으로 막히지 않게(2026-09-14 브레인)
assert "수동 단가를 넣을 것" in ui
def test_사용자가_코드를_고르면_금액이_선다() -> None:
@@ -200,6 +200,102 @@ def test_내_라이브러리에_저장은_고친_식을_담고_프로젝트는_
assert _mortar(_sheet(client))["unit_amount"] == pytest.approx(amount)
def test_프로그램_기본은_storage_작업본이_씨앗을_이긴다(storage: Path) -> None:
"""관리자 UI(2026-09-14 브레인) — 프로그램 기본도 storage 작업본 · git 파일은 씨앗(없는 코드만)."""
seed = load_template("masonry_wet")
assert library_module.program_items()[0]["code"] == seed["code"] # 작업본 없으면 씨앗
edited = {**seed, "name": "돌쌓기(찰) 관리자 고침"}
folder = library_module.program_library_dir()
folder.mkdir(parents=True)
(folder / f"{seed['code']}.json").write_text(json.dumps(edited), encoding="utf-8")
items = library_module.program_items()
assert [i["name"] for i in items if i["type_id"] == "masonry_wet"] == ["돌쌓기(찰) 관리자 고침"]
assert load_template("masonry_wet")["name"] == "돌쌓기(찰) 관리자 고침"
assert str(folder).startswith(str(storage)) # 씨앗(git) 파일은 안 건드림
def test_발행은_권한대로_회사_기본_단에_같은_모양으로(client: TestClient, storage: Path) -> None:
sheet = _sheet(client)
denied = client.put(
f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"}
)
assert denied.status_code == 403 # 시스템 관리자만
assert client.get(f"{SHEETS}/library", params={"type_id": "masonry_wet"}).json()[
"can_publish"
] == {"company": False, "program": False}
client.app.dependency_overrides[verify_session] = lambda: {
"company_id": 7,
"user_id": 42,
"is_master": True,
"role": "ADMIN",
}
company = client.put(
f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "company"}
)
assert company.status_code == 200, company.text
saved = json.loads(
(storage / "7" / "library" / f"{company.json()['code']}.json").read_text("utf-8")
)
assert saved["library_tier"] == "company"
assert (
client.put(
f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"}
).status_code
== 403
)
client.app.dependency_overrides[verify_session] = lambda: {
"company_id": None,
"user_id": 1,
"role": "SYSTEM_ADMIN",
}
program = client.put(
f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"}
)
assert program.status_code == 200, program.text
assert program.json()["code"] == load_template("masonry_wet")["code"] # 씨앗 코드로 덮어씀
assert (library_module.program_library_dir() / f"{program.json()['code']}.json").is_file()
assert client.get(f"{SHEETS}/library", params={"type_id": "masonry_wet"}).json()["can_publish"][
"program"
]
def test_프로그램_기본_발행본엔_원문_공사명_파일명을_안_싣는다(tmp_path: Path) -> None:
"""2026-09-14 브레인 ② — 모든 회사로 가는 발행본에서만 가림 · 원본·회사 단은 그대로(③)."""
origin = {
"kind": "stmate_xlsx",
"file": "봉화.xlsx",
"project": "2024년 봉화 임도",
"hopyo_no": 6,
}
template = {**load_template("masonry_wet"), "origin": origin}
program = library_module.save_personal(tmp_path / "program", template, None, None, "program")
company = library_module.save_personal(tmp_path / "company", template, None, None, "company")
published = json.loads((tmp_path / "program" / f"{program}.json").read_text(encoding="utf-8"))
kept = json.loads((tmp_path / "company" / f"{company}.json").read_text(encoding="utf-8"))
assert "project" not in published["origin"] and "file" not in published["origin"]
assert published["origin"]["masked"] == "원문에서 뽑음 — 공사명은 발행 시 가림"
assert published["origin"]["kind"] == "stmate_xlsx"
assert kept["origin"] == origin
assert template["origin"] == origin # 원본은 안 건드림
def test_발행_확인창이_가릴_공사명을_보인다() -> None:
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
encoding="utf-8"
)
assert "originProject" in ui and "공사명은 빼고 발행됩니다" in ui
sheet_ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet.ts").read_text(
encoding="utf-8"
)
assert "originProject: sheet.library_item.origin_project" in sheet_ui
template_py = (ROOT / "B08_Quantity" / "B08_Quantity_Engine_StructureTemplate.py").read_text(
encoding="utf-8"
)
assert '"origin_project"' in template_py
def test_회사_없는_사람은_개인_단을_못_쓴다(client: TestClient) -> None:
client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1}
sheet = _sheet(client)
@@ -216,6 +312,41 @@ def test_확정_때_기본_양식을_박는다(project: Path) -> None:
assert library_module.pin_program_templates(project) == 0 # 이미 박힌 것은 그대로
def test_복제는_고른_항목을_개인_단에_베끼고_프로젝트는_안_바꾼다(
client: TestClient, project: Path, storage: Path
) -> None:
"""PLAN 4장 「복제해서 내 것 만들기」 — 기본(또는 회사) 항목을 개인 단으로 한 번에 베낌."""
import B08_Quantity.B08_Quantity_Router_StmateLibrary as extra_router
client.app.include_router(extra_router.router)
base = load_template("masonry_wet")
folder = storage / "7" / "42" / "library"
for path in folder.glob("*.json"):
path.unlink() # 개인 단을 비워 새로 생기는지 봄
response = client.put(
f"{SHEETS}/library/clone",
json={"type_id": "masonry_wet", "tier": "program", "code": base["code"]},
)
assert response.status_code == 200, response.text
code = response.json()["code"]
saved = json.loads((folder / f"{code}.json").read_text(encoding="utf-8"))
assert saved["library_tier"] == "personal" and saved["code"] != base["code"]
assert saved["cloned_from"] == {"tier": "program", "code": base["code"], "name": base["name"]}
assert saved["rows"] == base["rows"]
assert not (project / "B08_Quantity" / "library").exists() # 프로젝트 작업본은 그대로
missing = client.put(
f"{SHEETS}/library/clone",
json={"type_id": "masonry_wet", "tier": "program", "code": "AX-ST-00000000"},
)
assert missing.status_code == 404
client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1}
denied = client.put(
f"{SHEETS}/library/clone",
json={"type_id": "masonry_wet", "tier": "program", "code": base["code"]},
)
assert denied.status_code == 403
def test_항목마다_양식형_고정형_종류가_붙는다(tmp_path: Path) -> None:
"""PLAN 4장 「항목마다 종류 배지」 — 명세 13장: 칸은 같고 `formula` 유무로만 갈림."""
folder = tmp_path / "personal"
@@ -236,6 +367,17 @@ def test_항목마다_양식형_고정형_종류가_붙는다(tmp_path: Path) ->
assert "양식형" in ui and "고정형" in ui and "item.kind" in ui
def test_복제_단추는_개인_단_항목에선_안_눌린다() -> None:
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
encoding="utf-8"
)
assert "/clone" in ui and "복제해서 내 것으로" in ui
assert 'clone.disabled = tier === "personal"' in ui
# 발행 단추 — 서버 권한(can_publish)대로만 보임
assert "/publish" in ui and "toCompany.hidden = !canPublish?.company" in ui
assert "toProgram.hidden = !canPublish?.program" in ui
def test_코드_모양이_아니면_박지_않는다(project: Path) -> None:
with pytest.raises(ValueError):
library_module.import_item(project, {"type_id": "masonry_wet", "code": "../x"}, "personal")
@@ -0,0 +1,93 @@
"""㉯ 양식이 안 품은 토공·버림 성분은 전개 값을 남김 (2026-09-14 브레인 판정 · 목록으로 못박음).
실측(936be972): STmate 호표(고정형) 가져오니 전개 성분이 통째로 갈음돼 구조물터파기 85.2525
(6,037,712) · 되메우기·잔토·버림 타설도 줄었음. STmate 호표는 원래 토공을 품음(토공 따로 ).
규칙 `KEEP_ENGINE_COMPONENTS` 목록에 있는 이름은 **양식에 이름 줄이 없을 ** 전개 값을 보존,
목록에 없는 성분은 양식으로 갈음. 양식형(돌쌓기 ) 줄을 스스로 품어 겹치지 않음.
"""
from __future__ import annotations
from collections import Counter
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item
from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import (
KEEP_ENGINE_COMPONENTS,
apply_templates,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
HOPYO = {
"no": 6,
"source_code": "B00010",
"name": "기슭막이",
"spec": "H=2.0m",
"unit": "M",
"basis": [],
"contract_rows": 0,
"rows": [
{
"name": "깬잡석찰쌓기",
"spec": "L3=45",
"amount": 2.09,
"unit": "M2",
"remark": "",
"source_code": "",
},
],
}
WALL = StructureInstance.model_validate(
{
"structure_id": "a",
"type_id": "masonry_wet",
"placement": "interval",
"start_m": 0.0,
"end_m": 10.0,
"options": {"height_m": 2.5, "back_len_cm": 45, "foundation": "기초유"},
}
).model_dump()
NAMES = {"masonry_wet": "돌쌓기(찰)"}
def _fixed() -> dict:
return {
**recipe_item(HOPYO, type_id="masonry_wet", file_name="a", project="p"),
"code": "AX-ST-0000abcd",
}
def test_목록이_코드에_못박혀_있다() -> None:
assert set(KEEP_ENGINE_COMPONENTS) >= {"터파기", "되메우기", "잔토처리", "버림콘크리트"}
# ② 원문 빈도(벽·돌쌓기 호표 57) — 기초잡석 0/57 · 채집석(사토 공제 부피)은 호표 채집 줄(품)과 다른 것
assert {"기초잡석", "채집석"} <= set(KEEP_ENGINE_COMPONENTS)
def test_고정형을_가져와도_토공_버림_성분은_남는다() -> None:
engine = build_table([WALL], NAMES, {}, {}, None, use_templates=False)["structures"][0]
fixed = build_table([WALL], NAMES, {}, {}, None, structure_templates={"masonry_wet": _fixed()})
components = {c["name"]: c for c in fixed["structures"][0]["components"]}
engine_names = {c["name"] for c in engine["components"]}
assert {"기초잡석", "채집석"} <= engine_names # 시험 벽에서 둘 다 서야 보존을 잼
for name in ("터파기", "되메우기", "잔토처리", "버림콘크리트", "기초잡석", "채집석"):
before = next(c for c in engine["components"] if c["name"] == name)
assert components[name]["amount"] == before["amount"], name
assert "깬잡석찰쌓기" in components and "" not in components # 목록 밖은 양식으로 갈음
def test_양식형은_그_줄을_스스로_품어_겹치지_않는다() -> None:
table = build_table([WALL], NAMES, {}, {}, None)
counts = Counter(c["name"] for c in table["structures"][0]["components"])
assert all(count == 1 for count in counts.values()), counts
def test_구조물도_장에도_남긴_줄이_보인다() -> None:
templates = {"masonry_wet": _fixed()}
table = build_table([WALL], NAMES, {}, {}, None, structure_templates=templates)
payload = build_standard_sheets(table, {})
apply_templates(payload, {}, templates)
names = [row["name"] for row in payload["sheets"][0]["rows"]]
assert names[0] == "깬잡석찰쌓기" and "터파기" in names and "버림콘크리트" in names
numbers = [row["no"] for row in payload["sheets"][0]["rows"]]
assert len(numbers) == len(set(numbers)) # 남긴 줄 차례가 양식 줄과 안 겹침
+16 -5
View File
@@ -158,11 +158,14 @@ def test_uljin_current_rates_safety_18_586_091():
"""현행 요율로 돌리면 안전관리비가 18,586,091 이 된다 (PLAN 8-10).
A 대상액 604,193,353 × 2.53 % + 3,300,000 = 18,586,091
B 대상액 541,034,971 × 3.15 % × 1.2 = 20,385,821 A 채택
B 버림(대상액 541,034,971 × 3.15 %) × 1.2 = 20,385,820 A 채택
2026-09-14 B 1 내려감 1.2배의 대상을 **호별 산정이 끝난 금액**으로 바꿨음
(고시 제4조① 단서 문언 · STmate 영월 실증). 여기 값은 현행 요율이라 실무 대조본이 없음.
"""
r = calculate_cost(_uljin())
assert r.amount("safety_management_cost_a") == _won(18_586_091)
assert r.amount("safety_management_cost_b") == _won(20_385_821)
assert r.amount("safety_management_cost_b") == _won(20_385_820)
assert r.amount("safety_management_cost") == _won(18_586_091)
@@ -193,7 +196,15 @@ def test_current_rates_add_items_absent_in_2024():
def test_geochang_adopts_b():
"""거창(2025) 실측 — B 채택. `안전관리비검토` 시트 원문 「적은금액 적용」."""
"""거창(2025) 실측 — B 채택. `안전관리비검토` 시트 원문 「적은금액 적용」.
** 실증본이 갈림**(2026-09-14) 1.2배를 언제 곱하나.
STmate(영월 2024) `버림(밑수 × ) × 1.2` 버림 영월 B 20,330,638 = 원본
거창 원본(다른 프로그램) `버림(밑수 × × 1.2)` 거창 B 12,240,137
거창 원가계산서는 시트 이름이 원가계산서 **STmate 출력이 아님**( 차례도 다름).
우리 목표가 STmate 골든셋 재현이라 **STmate 쪽으로 맞춤** 그래서 거창 B 1 내려감.
발주처가 거창 셈을 요구하면 갈래를 둬야 . **판정 대기(브레인)**.
"""
r = calculate_cost(
CostInput(
direct_material_krw=_won(80_165_010),
@@ -204,8 +215,8 @@ def test_geochang_adopts_b():
)
)
assert r.amount("safety_management_cost_a") == _won(12_337_367)
assert r.amount("safety_management_cost_b") == _won(12_240_137)
assert r.amount("safety_management_cost") == _won(12_240_137)
assert r.amount("safety_management_cost_b") == _won(12_240_136) # 거창 원본은 12,240,137
assert r.amount("safety_management_cost") == _won(12_240_136)
def test_safety_formula_text_is_per_row():
+18 -3
View File
@@ -15,9 +15,9 @@ sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_CostSheet_Forms import ( # noqa: E402
PENDING_PROFIT,
PENDING_SAFETY,
RATE_B,
RATE_RA,
SEPARATE,
form_rows,
)
@@ -37,11 +37,16 @@ def test_수공은_세부_경비_여덟이_율표_미확보() -> None:
assert "기타경비" not in [row["label"] for row in rows]
def test_실적형은_이윤_확인_대기_실적수공은_안전관리비도() -> None:
def test_실적형은_이윤_확인_대기_안전관리비는_둘_다_별도내역() -> None:
"""SA 안전관리비 「확인 대기」를 **별도내역**으로 내림 —
코덱스가 STmate 원문에서 확인(2026-09-14).
TA 처음부터 별도내역이었음. 요율이 아니라 따로 산출한 금액이라 **금액엔 닿음**.
"""
general = {row["label"]: row["note"] for row in form_rows("actual_general")}
sugong = {row["label"]: row["note"] for row in form_rows("actual_sugong")}
assert general["이 윤"] == PENDING_PROFIT and sugong["이 윤"] == PENDING_PROFIT
assert sugong["안전관리비"] == PENDING_SAFETY
assert general["안전관리비"] == SEPARATE and sugong["안전관리비"] == SEPARATE
assert general["간접노무비"] == RATE_RA and general["기타경비"] == RATE_RA
@@ -55,3 +60,13 @@ def test_실적일반_보증_밑수는_직접공사비_환경은_재료직노산
def test_모르는_형식은_멈춘다() -> None:
with pytest.raises(ValueError):
form_rows("general")
@pytest.mark.parametrize("form", ["sugong", "actual_general", "actual_sugong"])
def test_사유는_요율표_미확보가_아니라_실제_결론(form) -> None:
"""2026-09-14 요율표 셋 결론(PLAN 7장) — 「자료를 못 구함」 이 아니라 실적형은 **임도 미적용**
(예정가격작성기준 §37 100 미만) · 수공 세부 경비는 **법정 요율 없음**(§34 업체 실측)."""
notes = [row["note"] for row in form_rows(form)]
assert not any("요율표 미확보" in note for note in notes), notes
assert "임도 미적용" in RATE_RA and "§37②" in RATE_RA
assert "법정 요율 없음" in RATE_B and "값을 넣어 주십시오" in RATE_B and "§34①" in RATE_B
@@ -0,0 +1,55 @@
"""합판거푸집 12-4 사용횟수별 비율 — 2026-09-14 브레인 ㉮ 승인.
원문 L6187~6211: 기준수량(1회사용) × 사용횟수별 기준수량 비율(%) 재료별
100·57.0·46.1·40.1·37.1·34.7 · 노무비 100·60.0·47.1·40.0·34.2·32.0 (1~6).
종전엔 비율 셈이 없어 풀면 1 값으로 비싸게 서므로 일부러 막아 뒀음(BLOCKED_BY_DESIGN).
사람 판정표로 1~6 갈래를 세우고 재료 줄엔 재료 비율 · 인력 줄엔 노무비 비율.
사용고재 평가기준 23% 원문이 셈을 ([] 2 이상 비율에 기포함) 값으로 .
"""
from __future__ import annotations
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of
def _labor(code: str) -> dict[str, float]:
rows = detail_of(cached_build(), code)["rows"]
return {r["ref_code"]: float(r["quantity"]) for r in rows if r.get("kind") == "labor"}
def test_사용횟수_여섯_갈래가_서고_인력은_노무비_비율() -> None:
titles = cached_build().book.titles
variants = sorted(c for c in titles if c.startswith("B-FP-12-04#"))
assert variants == [f"B-FP-12-04#{n}" for n in range(1, 7)], variants
assert _labor("B-FP-12-04#1회") == {"1007": 0.22, "1002": 0.12}
assert _labor("B-FP-12-04#4회") == {"1007": 0.088, "1002": 0.048} # 40.0 %
assert _labor("B-FP-12-04#6회") == {"1007": 0.0704, "1002": 0.0384} # 32.0 %
def test_재료는_재료_비율로_읽고_못_붙은_재료는_사유() -> None:
from B09_Estimation.B09_Estimation_ResourceAxis import (
build_resource_axis,
load_combined_catalog,
load_work_item_master,
)
axis = build_resource_axis(load_work_item_master(), load_combined_catalog())
left = {u.cell for u in axis.unmatched if u.work_item_code == "FP-12-04"}
# 합판·못 은 카탈로그 없음 · 각재·철선·박리제는 규격 미정 — 값을 짓지 않고 못 붙은 줄로
assert {"합판", "", "각재", "철선", "박리제"} <= {c.replace(" ", "") for c in left}, left
build = cached_build()
assert "FP-12-04" not in build.component_gaps and "FP-12-04" not in build.partial_ratio
note = known_gap_note("FP-12-04")
assert "사용고재" in note and "23" in note, note
def test_거푸집_갈래가_내역에서_금액이_섬() -> None:
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
item = {"work_item_code": "FP-12-04", "name": "합판거푸집", "spec": "", "unit": "",
"quantity": "10", "in_bill": True, "variant_axis": "use_count",
"variant_value": "4회"} # fmt: skip
line = next(r for r in build_bill({"work_items": [item], "materials": []}).rows
if r.code == "FP-12-04") # fmt: skip
assert line.price_code == "B-FP-12-04#4회" and line.amount_krw, line.note
@@ -264,14 +264,13 @@ def test_원가계산서_법정경비_줄마다_실무_원본_재현(tmp_path) -
· 요율 데이터가 다름 요율을 원본에서 뽑아 쓰므로 ** 생김**
· 원본이 식을 보임 비고 (건강·노인장기·연금·산업안전). 차수 값을 그대로
물려 적은 자리라 되풀 없음 울진 신설·영덕·소광 4, 세어만 두고 대조 .
· 절사 자리가 다름 안전관리비 **B ** 1(영월). 아래 `rounding` 따로 담음.
· 절사 자리가 다름 아래 `rounding` 따로 담음. **지금은 비어 있어야 **.
**고칠 자리 하나 안전관리비 B 절사 자리**(2026-09-14 실측, 브레인 보고 대상).
우리 엔진 `버림((밑수 × + 기초액) × 1.2)` 영월 20,330,639
STmate `버림(밑수 × + 기초액) × 1.2` 버림 영월 20,330,638
여섯 원본 영월에서만 갈림(봉화·울진은 셈법 같은 ).
**미채택 줄이라 총액엔 닿음**
영월도 A 채택함. 벌은 재기만 하므로 엔진은 그대로 두고 차를 세어만 .
**안전관리비 B 절사 자리 2026-09-14 실증본 쪽으로 맞춤**(브레인 배정).
고침 `버림((밑수 × + 기초액) × 1.2)` 영월 20,330,639
고침 `버림(밑수 × + 기초액) × 1.2` 버림 영월 20,330,638 = 원본
고시 제4조① 단서 문언도 이쪽임 1.2배의 대상은 **1·2호로 산정 끝난 금액**.
여섯 원본 영월에서만 갈렸음(봉화·울진은 셈법이 같은 ).
"""
sheets = _cost_sheets()
if not sheets:
@@ -308,8 +307,8 @@ def test_원가계산서_법정경비_줄마다_실무_원본_재현(tmp_path) -
assert checked >= 54, checked
assert blank == 12, blank # 원본이 식을 안 보인 줄 — 3건 × 4줄
assert not misses, misses
# 절사 자리 차 — 지금 아는 것은 영월 안전관리비 B 한 줄뿐. 늘면 여기서 드러남.
assert len(rounding) == 1 and "영월" in rounding[0][0], rounding
# 절사 자리 차 — 실증본 쪽으로 맞춘 뒤로는 **하나도 없어야** 함(영월 B 1원이 마지막이었음).
assert not rounding, rounding
def test_원가계산서_총공사비까지_사슬_재현(tmp_path) -> None:
+174
View File
@@ -0,0 +1,174 @@
"""산업안전보건관리비 — **고시 원문과 직접 대조**(2026-09-14 편입).
원문 `resources/knowledge/original/행정규칙/건설업 산업안전보건관리비 계상 사용기준/`
`현행_20250212.md`(제3조·제4조) · `별표/별표1_계상기준표.md`
값을 여기서 짓지 않음 **별표1 표를 원문에서 읽어** 우리 요율 데이터와 견줌.
요율표가 개정되면 원문과 데이터가 함께 바뀌어야 벌이 초록으로 남음.
아직 (2026-09-14 실측 · 배정 대기) 여기서 ** 재는 자리**임을 밝혀
· 제3조 총공사금액 2천만원 이상 적용 하한을 엔진이 읽음
(요율 데이터엔 `minimum_total_construction_amount_krw` 있음 · 임도에선 걸림)
· 제4조①3호 대상액이 명확하지 않은 경우 = 총공사금액의 10분의 7 미구현
· 관급 ÷1.1 고시에 없는 **실무 해석**(실무 원본 6건이 관급재/1.1 적음)
· 보건관리자 선임 문턱(800·토목 1,000) 고시 (산업안전보건법 시행령 별표5, 원문 미보유)
"""
from __future__ import annotations
import io
import json
import pathlib
from decimal import ROUND_FLOOR, Decimal
import pytest
from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost
ROOT = pathlib.Path(__file__).resolve().parents[2]
NOTICE = (
ROOT
/ "resources"
/ "knowledge"
/ "original"
/ "행정규칙"
/ "건설업 산업안전보건관리비 계상 및 사용기준"
)
RATES = ROOT / "resources" / "data_cost_input_value" / "rates_2026.json"
#: 별표1 행 이름 → 요율 데이터 공종 키.
_WORK_TYPES = {
"건 축 공 사": "building",
"토 목 공 사": "civil",
"중 건 설 공 사": "heavy_construction",
"특 수 건 설 공 사": "special_construction",
}
#: 별표1 칸 차례 → 요율 데이터 구간 이름. 기초액은 **가운데 구간에만** 붙음(제4조①1·2호).
_BRACKETS = (
"lt_500_million",
"500_million_to_5_billion",
"gte_5_billion_below_manager_threshold",
"gte_5_billion_at_or_above_manager_threshold",
)
def _table() -> dict[str, tuple[list[Decimal], Decimal]]:
"""별표1 표 → `{공종: ([네 구간 율], 기초액)}`. 원문이 없으면 빈 표."""
path = NOTICE / "별표" / "별표1_공사종류 및 규모별 산업안전보건관리비 계상기준표.md"
if not path.exists():
return {}
rows: dict[str, tuple[list[Decimal], Decimal]] = {}
for line in io.open(path, encoding="utf-8").read().splitlines():
cells = [c.strip() for c in line.strip().strip("|").split("|")]
if cells and cells[0] in _WORK_TYPES:
percents = [Decimal(cells[i].replace("%", "")) for i in (1, 2, 4, 5)]
rows[_WORK_TYPES[cells[0]]] = (
percents,
Decimal(cells[3].replace("", "").replace(",", "")),
)
return rows
def _floor(value: Decimal) -> Decimal:
return value.quantize(Decimal(1), rounding=ROUND_FLOOR)
def _run(material: Decimal, labor: Decimal, **over) -> object:
"""안전관리비 한 줄만 세워 돌림 — 규모 구간은 못 박아 이 벌 밖으로 뺌."""
fields = dict(
direct_material_krw=material,
direct_labor_krw=labor,
direct_expense_krw=Decimal(0),
owner_supplied_includes_vat=False,
enabled_items=("safety_management_cost",),
estimated_price_krw=Decimal(1_000_000_000),
cut_basis="none",
)
fields.update(over)
return calculate_cost(CostInput(**fields))
def test_별표1_요율표가_고시_원문과_전수_같다() -> None:
"""별표1 **16칸**(4 공종 × 4 열) — 율과 기초액이 원문과 한 자도 안 갈림."""
table = _table()
if not table:
pytest.skip("고시 원문이 없음")
ours = {
(row["work_type"], row["target_amount_bracket"]): (
Decimal(str(row["rate_percent"])),
Decimal(str(row.get("base_amount_krw", 0))),
)
for row in json.load(io.open(RATES, encoding="utf-8"))["variables"]["rate_safety_pct"][
"brackets"
]
}
misses = []
for work_type, (percents, flat) in table.items():
for index, bracket in enumerate(_BRACKETS):
# 제4조①1호 — 5억 미만·50억 이상은 **기초액 없이** 율만 곱함.
want = (percents[index], flat if bracket == "500_million_to_5_billion" else Decimal(0))
if ours.get((work_type, bracket)) != want:
misses.append((work_type, bracket, want, ours.get((work_type, bracket))))
assert len(table) == 4 and not misses, misses
@pytest.mark.parametrize(
("label", "target", "percent", "flat"),
[
("5억 바로 아래", Decimal(499_999_999), Decimal("3.15"), Decimal(0)),
("정확히 5억", Decimal(500_000_000), Decimal("2.53"), Decimal(3_300_000)),
("50억 바로 아래", Decimal(4_999_999_999), Decimal("2.53"), Decimal(3_300_000)),
("정확히 50억", Decimal(5_000_000_000), Decimal("2.60"), Decimal(0)),
],
)
def test_제4조_구간_경계가_고시대로다(label, target, percent, flat) -> None:
"""제4조①1·2호 — 「5억 미만 **또는** 50억 이상」은 기초액이 없고, 그 사이만 기초액을 더함."""
result = _run(target / 2, target / 2)
line = result.line("safety_management_cost_a")
assert (line.rate_percent, line.flat_amount_krw) == (percent, flat), label
assert result.amount("safety_management_cost") == _floor(target * percent / 100 + flat), label
def test_제4조_단서_A와_B의_1점2배_중_작은_쪽() -> None:
"""단서 — 관급 포함(A)과 관급 제외 × 1.2(B) 를 견줘 **작은 쪽**.
A·B **서로 다른 구간** 떨어지는 자리로 A 대상액 5(2) · B 대상액 4(1).
"""
result = _run(
Decimal(200_000_000), Decimal(200_000_000), owner_supplied_material_krw=Decimal(100_000_000)
)
want_a = _floor(Decimal(500_000_000) * Decimal("2.53") / 100 + 3_300_000)
want_b = _floor(_floor(Decimal(400_000_000) * Decimal("3.15") / 100) * Decimal("1.2"))
got = (result.amount("safety_management_cost_a"), result.amount("safety_management_cost_b"))
assert got == (want_a, want_b)
assert result.amount("safety_management_cost") == min(got)
def test_1점2배는_호별_산정액을_먼저_원_단위로_맺은_뒤_곱한다() -> None:
"""⭐ 단서 문언 — 1.2배의 대상은 **1·2호로 산정이 끝난 금액**임(2026-09-14 고침).
골든셋 실증 영월 2024 B 줄이 종전 20,330,639(먼저 곱하고 버림) ·
원본·고침 20,330,638. 끝자리가 살아 있는 밑수로 셈법이 갈리는 것을 여기서 .
"""
base = Decimal(123_456_789) # × 3.15 % = 3,888,888.85… — 버림 자리가 살아 있음
result = _run(base / 2, base / 2)
raw = base * Decimal("3.15") / 100
assert _floor(raw) * Decimal("1.2") != raw * Decimal("1.2") # 두 셈법이 실제로 갈리는 밑수
assert result.amount("safety_management_cost_b") == _floor(_floor(raw) * Decimal("1.2"))
def test_대상액은_직재_간재_직노_그리고_발주자_제공_재료다() -> None:
"""제2조2호 — 대상액 = 직접재료비 + 간접재료비 + 직접노무비(발주자 제공 재료 포함)."""
plain = _run(Decimal(100_000_000), Decimal(100_000_000))
indirect = _run(
Decimal(100_000_000), Decimal(100_000_000), indirect_material_krw=Decimal(50_000_000)
)
owner = _run(
Decimal(100_000_000), Decimal(100_000_000), owner_supplied_material_krw=Decimal(30_000_000)
)
base = plain.line("safety_management_cost_a").base_amount_krw
assert base == Decimal(200_000_000)
assert indirect.line("safety_management_cost_a").base_amount_krw == base + 50_000_000
assert owner.line("safety_management_cost_a").base_amount_krw == base + 30_000_000
# 관급 제외 밑수(B)는 관급이 들어도 안 움직임.
assert owner.line("safety_management_cost_b").base_amount_krw == base
+2 -2
View File
@@ -100,8 +100,8 @@ def test_단목베기_5m_미만이_실무값_근처로_선다():
build = build_unit_prices()
assert "FP-04-02-02" not in build.partial_ratio
# 합판거푸집 — 일부러 안 푼 표는 **그 까닭**이 사유로 뜬다(사용횟수 비율 미구현)
assert "사용횟수" in (build.component_gaps.get("FP-12-04") or "")
# 합판거푸집 — 2026-09-14 사용횟수 갈래로 풀림(`test_b09_formwork_use_count`) · 막힘 사유 없음
assert "FP-12-04" not in build.component_gaps
total = build.book.resolve("B-FP-04-02-02#5m미만").total
# 영월 설계내역 1.9.2 「잡관목제거 벌목(5m미만)」 @882 — 노임 연도 차이로 ±5 % 안이면 같은 읽기
assert Decimal("838") <= total <= Decimal("926"), total
+2
View File
@@ -753,6 +753,8 @@ export const ui_locales_b2 = {
B08_Quantity_FaceDressing_FillArea: ["성토면 면적(㎡)", "Fill face area (㎡)"],
B08_Quantity_FaceDressing_CutArea: ["절토면 면적(㎡)", "Cut face area (㎡)"],
B08_Quantity_FaceDressing_Area_Placeholder: ["파종 면적 그대로", "Same as seeding area"],
B08_Quantity_FaceDressing_Suggest: ["제안(비우면 안 정함):", "Suggested (blank = not set):"],
B08_Quantity_FaceDressing_FillSuggested: ["제안값 넣기", "Fill suggested"],
B08_Quantity_Side_FaceDressing_Hint: [
"품셈 9-19-1 원문 표 두 벌(절토면 토질 6 · 성토면 시공·토질) — 제안값 없음, 안 고르면 내역 줄이 입력 사유로 섭니다. 면적은 비우면 초류종자살포 면적(× 반영률) 그대로 · 넣으면 그 값으로 덮어씀",
"Standard estimate 9-19-1 tables (cut face soil · fill face method/soil) — no suggested value; unset leaves the bill row with an input reason. Area left blank uses the seeding area (× ratio); a value overrides it",