Merge remote-tracking branch 'origin/dev' into sub_laptop_1

This commit is contained in:
2026-09-13 22:20:33 +09:00
8 changed files with 496 additions and 0 deletions
@@ -213,6 +213,14 @@ def build_handoff(
result["ratio_math_warnings"] = verify_ratio_math(result)
result["material_code_warnings"] = verify_no_code_on_materials(result)
result["bill_flag_warnings"] = verify_bill_flags(result)
# ⚠ 이중계상 경계(명세 6장) — 갈 곳 칸으로 판정한 어긴 자리. B09 내역서가 여기서 멈춘다.
from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import (
verify_double_count_boundaries,
)
result["double_count_violations"] = verify_double_count_boundaries(
unit_quantity_table, material_table, table
)
# ⚠ 보내는 단위가 **품셈 밑수**와 같은가 — 받는 쪽이 그대로 곱하는 자리다(2026-09-08).
result["basis_unit_warnings"] = verify_unit_matches_basis(
work_items, extra=table.declared_units()
@@ -0,0 +1,94 @@
"""B08 → B09 인계 — **이중계상 경계를 갈 곳(`destination`) 칸으로 판정** (명세 6장 · 2026-09-13).
주석 경고뿐이던 규칙 셋을 수로 걸리는 검사로 세운다. 원문은 옛 계획서 8-7절(㉠·㉢)과 명세 6장.
① 구조물 터파기·되메우기는 **토공집계로만** — 갈 곳이 `earthwork` 인 성분을 다른 공종 줄이
또 집으면 같은 물량이 두 공종 코드에 붙음. 터파기·되메우기가 `earthwork` 밖으로 가도 오류
② 배합 성분(시멘트·모래·자갈)은 **B09 일위대가만 쪼갬** — 자재총괄에 뜨면 오류(㉢)
③ 자재 할증은 **자재총괄 한 곳** — 원단위표가 이미 할증을 붙였으면 오류(㉠).
자재총괄 합계의 할증 한 번·일위대가 재료비의 할증 전은 B09 가드가 금액 자리에서 봄
⚠ 새 얼개를 만들지 않는다 — 성분마다 붙은 `destination` 과, 인계 줄이 성분을
**이름으로 집는 자리**(구조물 줄 `billing_component` · 묶음 조각 `from_components` ·
콘크리트 타설 · 기초잡석)만 대조한다.
⚠ 여기는 목록만 낸다. 멈추는 것은 B09 `build_bill`(`DoubleCountError`) — 가드가 모인 한 자리.
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import WorkItemMapping
from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import PLACING_TARGET_NAMES
from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import RUBBLE_GROUP
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import verify_single_surcharge
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import MIX_COMPONENTS
#: 토공집계로만 가야 하는 성분 — 전개·양식·관측 원단위가 같은 이름을 쓴다.
EARTHWORK_ONLY = ("터파기", "되메우기")
#: 공종 줄이 값으로 매기면 안 되는 갈 곳 — 이미 다른 자리가 세거나(토공·사토 공제) 참고값이다.
NOT_BILLED_BY_WORK_ITEM = {
"earthwork": "토공집계",
"haul_deduction": "유토곡선 사토 공제",
"reference": "참고 줄",
}
def _pickers(structure: dict[str, Any], mapping: WorkItemMapping) -> list[tuple[str, set[str]]]:
"""이 구조물의 성분을 **이름으로 집는** 공종 줄 — (자리, 이름들). 집는 조건은 각 빌더와 같다."""
type_id = str(structure.get("type_id") or "")
entry = mapping.for_structure(type_id) or {}
composite = mapping.composite_for(type_id)
found: list[tuple[str, set[str]]] = []
if entry.get("billing_component"):
found.append(("구조물 줄", {str(entry["billing_component"])}))
if composite and not entry.get("work_item_code"):
for part in composite.get("parts") or []:
if isinstance(part, dict):
label = f"묶음 조각 「{part.get('name') or part.get('code')}"
found.append((label, {str(name) for name in part.get("from_components") or []}))
if not composite:
# 묶음 구조물은 타설·기초잡석 줄이 건너뛴다(조각이 이미 셈) — 빌더와 같은 조건.
found.append(("콘크리트 타설 줄", set(PLACING_TARGET_NAMES)))
found.append(("기초잡석 줄", {RUBBLE_GROUP}))
return found
def verify_double_count_boundaries(
unit_quantity_table: dict[str, Any] | None,
material_table: dict[str, Any] | None,
mapping: WorkItemMapping,
) -> list[str]:
"""어긴 자리를 사람 말로 — 비면 경계가 지켜진 것."""
found: list[str] = []
for structure in (unit_quantity_table or {}).get("structures") or []:
label = str(structure.get("name") or structure.get("type_id") or "구조물")
destinations: dict[str, set[str]] = {}
for component in structure.get("components") or []:
if float(component.get("amount") or 0.0) <= 0:
continue
name = str(component.get("name") or "").strip()
destination = str(component.get("destination") or "")
destinations.setdefault(name, set()).add(destination)
if name in EARTHWORK_ONLY and destination != "earthwork":
found.append(
f"{label}{name}」 갈 곳이 {destination or '(없음)'}"
"구조물 터파기·되메우기는 토공집계(earthwork)로만 감"
)
for where, names in _pickers(structure, mapping):
for name in sorted(names):
for destination in sorted(
destinations.get(name, set()) & set(NOT_BILLED_BY_WORK_ITEM)
):
found.append(
f"{label}{name}」은 {NOT_BILLED_BY_WORK_ITEM[destination]}"
f"({destination})로 가는데 {where}이 또 셈 — 같은 물량이 두 자리에 붙음"
)
for row in (material_table or {}).get("rows") or []:
name = str(row.get("name") or "").strip()
if name in MIX_COMPONENTS:
found.append(
f"② 자재총괄에 배합 성분 「{name}」 — 시멘트·모래·자갈은 B09 일위대가만 쪼갬(㉢)"
)
found.extend(f"{warning} (㉠)" for warning in verify_single_surcharge(unit_quantity_table))
return found
@@ -181,6 +181,15 @@ def _assemble(
"reason": "",
}
source = sheet_rows.get(row.get("from_row")) if "from_row" in row else None
# 이중계상 경계 ①(명세 6장) — 토공집계·사토 공제·참고로 가는 줄을 일위대가에 또 넣지 않음.
from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import NOT_BILLED_BY_WORK_ITEM
owner = NOT_BILLED_BY_WORK_ITEM.get(str((source or {}).get("destination") or ""))
if owner:
out["reason"] = f"이중계상 — 원단위 줄 {row['from_row']}{owner}로 가는 줄"
blocked += 1
rows.append(out)
continue
if source is not None and (source.get("skipped") or source.get("error")):
# 원단위 줄이 안 선 장이면 일위대가 줄도 안 섬 — 막힘이 아님(버림 「안 넣음」 등).
out.update(skipped=bool(source.get("skipped")), reason=source.get("reason") or "")
@@ -29,7 +29,9 @@ from B09_Estimation.B09_Estimation_Guards import (
check_drain_pipe_not_double_counted,
check_included_materials_not_listed,
check_free_haul_not_priced,
check_handoff_boundaries,
check_haul_volume_within_cut,
check_material_surcharge_once,
)
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity
@@ -503,6 +505,10 @@ def build_bill(
# ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ──────────────────────
check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded])
# 이중계상 경계 ①②③(명세 6장) — B08 이 갈 곳 칸으로 판정한 어긴 자리 · 자재 할증 한 번.
check_handoff_boundaries(payload.get("double_count_violations"))
check_material_surcharge_once(materials)
# ㉦ 큰돌쌓기 품에 포함된 자재(고임돌·채움콘크리트)를 따로 세지 않았는가.
check_included_materials_not_listed(
work_item_codes=[row.code or "" for row in result.rows],
+62
View File
@@ -20,6 +20,7 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any
_TOLERANCE = Decimal("0.5")
@@ -219,6 +220,67 @@ def check_excluded_rows_not_priced(
)
def check_handoff_boundaries(violations: list[str] | None) -> None:
"""이중계상 경계 ①②③(명세 6장) — B08 인계가 갈 곳 칸으로 판정해 보낸 어긴 자리.
판정은 성분 칸을 가진 B08 한 곳에서 한다(`B08_Quantity_Engine_Handoff_Boundaries`).
여기서는 **멈추기만** 한다 — 목록이 차 있는데 내역서를 세우면 같은 물량이 두 번 금액이 된다.
"""
if violations:
raise DoubleCountError("이중계상 경계를 어겼습니다 — " + " / ".join(violations))
#: 품셈 [주]가 「재료량에 할증 포함」이라 적은 공종 — 그 재료가 일위대가 재료비로 붙으면
#: **할증 뒤 값**이 들어가 자재총괄에서 한 번 더 붙는다(㉠). 원문 넷, 코드는 마스터가 붙인 자리.
SURCHARGE_INCLUDED_ITEMS: dict[str, str] = {
"FP-12-02": "용적 배합 콘크리트 참고표 「재료량에는 할증률이 포함」(마스터가 12-2 에 붙임)",
"FP-12-38-02": "유로폼 사용수량 [주]① 「재료량에는 재료의 할증 및 손율이 포함」",
"FP-13-11-04": "돌망태 사각형 [주]① 「자재비에는 재료의 할증을 포함」",
"AX-WK-c0842a0d": "모르타르 배합 참고자료 ※ 「위 재료량은 할증이 포함된 것이다」",
}
def check_materials_before_surcharge(book: Any) -> None:
"""③ 일위대가 재료비는 할증 전 — 할증 포함 재료량을 준 공종에 자재 줄이 붙으면 멈춘다.
⚠ 지금은 그 재료들이 자재 단가 층이 없어 **안 붙은 줄**로만 보인다 — 층이 서는 날 여기서 걸린다.
"""
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
for title_code, details in book.details.items():
code = title_code[2:].split("#", 1)[0] if title_code.startswith("B-") else ""
if code not in SURCHARGE_INCLUDED_ITEMS:
continue
for detail in details:
ref = book.titles.get(detail.ref_code)
if ref is not None and ref.kind is PriceKind.MATERIAL:
raise DoubleCountError(
f"{title_code}: 자재 「{ref.name}」이 일위대가 재료비로 붙었습니다 — "
f"{SURCHARGE_INCLUDED_ITEMS[code]}. 일위대가 재료비는 할증 전 값이고 "
"할증은 자재총괄 한 곳뿐입니다 (PLAN 8-7 ㉠)."
)
def check_material_surcharge_once(materials: list[Any]) -> None:
"""③ 자재 줄 합계 = 순수량 × (1 + 할증률) **한 번** — 율이 없거나 품셈 포함이면 순수량 그대로.
내역서 자재대는 `total_amount` 를 수량으로 쓴다. 인계 도중 어디서든 할증이 한 번 더 붙으면
그 값이 그대로 금액이 되므로, **금액을 만드는 자리**에서 순수량과 율로 되짚는다.
"""
for material in materials:
net = Decimal(str(material.net_amount))
included = "할증 포함" in str(material.surcharge_note or "")
rate = None if included else material.surcharge_pct
expected = net if rate is None else net * (Decimal(1) + Decimal(str(rate)) / Decimal(100))
total = Decimal(str(material.total_amount))
if abs(total - expected) > max(Decimal("1e-6"), abs(expected) * Decimal("1e-9")):
raise DoubleCountError(
f"자재 「{material.material_name}」: 합계 {total} 가 순수량 {net} × "
f"(1+{rate or 0}%) = {expected} 와 다릅니다 — 할증이 두 번 붙었거나 빠졌습니다 "
"(PLAN 8-7 ㉠ 할증은 자재총괄 한 번)."
)
#: 제잡비 「윗단」 값을 쓴다는 뜻 — 물빼기 파이프를 **설치하는** 경우다.
#: 품셈 13-6-2 [주]③ 「… 상단에는 물빼기 파이프 설치에 관계되는 노무비, 재료비를
#: 포함한다」. 그러므로 윗단을 쓰면 파이프를 **따로 세면 안 된다**.
@@ -950,6 +950,10 @@ def build_unit_prices(
build.book, [code for code in build.book.titles if code.startswith("B-")]
)
build.labor_reliability = _labor_reliability_in_use(build.book)
# ③ 할증 포함 재료량을 준 공종에 자재가 재료비로 붙지 않았는가(명세 6장 · ㉠).
from B09_Estimation.B09_Estimation_Guards import check_materials_before_surcharge
check_materials_before_surcharge(build.book)
return build
@@ -0,0 +1,185 @@
# STmate 원가·수량 화면 구조 정리
작성일: 2026-09-13
근거: `30_원자료/ui_form_catalog.txt` · `30_원자료/print_form_map.txt` · `30_원자료/TWM_KANJUB.dfm` · `30_원자료/TPM_KAN_RATE.dfm` · `30_원자료/TWM_KAN_RATE.dfm` · `20_분석/22_UI_폼_분석.md` · `20_분석/19_진행률_장부.md` §3
## 1. 범위와 판정
- 카탈로그의 STmate 고유 폼 150개 가운데 원가·수량 입력, 기초단가, 일위대가·단가산출, 제잡비, 출력에 직접 참여하는 **48개 화면**을 추렸다.
- `PM_*` 인쇄 폼은 실제 출력 시트와 이어지는 화면만 포함했다. 같은 출력의 세로·가로·수공 변형은 한 행에 묶었다.
- 계약·실행·기성 화면은 설계 원가 흐름과 맞닿은 것만 `보조`로 표시했다.
- 조림·방제 등 사업별 설계 화면, 유지보수·다운로드·복구·암호·정보창은 제외했다.
- 화면 이름과 선택지는 카탈로그 원문을 유지했다. 기능 또는 출력 연결이 원자료에서 확인되지 않은 곳은 `미확인`이다.
## 2. 화면 갈래
### 2.1 내역 입력 — 11개
| 화면 | 무엇을 하나 | 주요 입력 칸·선택지 |
|---|---|---|
| `wBoq` | 공종목록에서 내역서로 항목을 넣는 중심 화면 | `cBox01/02`: `= 이상`~`====== 이상`; `RGrpTo`: `내역서 / 열린 보관함` |
| `wKongJong` | 공종 목록 조회·선택 | `RdoLevel`: `소계(+=)`~`총대계(+======)`; `RdoIndex`: `코드번호 순 / 명칭 순 / 분류번호 순`; `RdoFilt`: `일위대가/산근전체 / 대공종 해당목록만` |
| `wMBoq` | 주공종 집계와 계표시 확인 | `CBox01`: `1레벨(소계) 이상`~`6레벨(총대계) 이상` |
| `wITNum` | 내역서 공종번호 자동입력 | 첫 글자 `공란 / . / ( / - / 추가입력`; 끝 글자 `공란 / . / )`; 번호형식 `가..하… / a..z…`; `Fmt_Typ`: `0 / 00 / 000` |
| `wBoqRate` | 내역 항목에 할증율 적용 | `Rdo`: `전체(재,노,경 같은 비율) / 비목별(재,노,경 각각)` |
| `wM_Rate` | 선택 범위의 수량·비목 금액 일괄 보정 | 범위 `내역서 전체 / 선택된 항목만`; 비목별 `절사 / 반올림`; `소수 0위`~`소수 4위값을 적용` |
| `wExpnd` | 일위대가를 내역 행으로 풀기 | `RGrpToT`: `소계(=) / 계(==) / 합계(===)`; `절삭하여 / 반올림하여`; `소수 4위`~`소수 0위값을 적용` |
| `wM_Expnd` | 일위대가와 단가산출근거를 내역에 풀어넣기 | 재료·노무·경비·일식 각각 `절사 / 반올림`, `소수 0위`~`소수 4위값을 적용` |
| `wM_Expnd10` | 일위대가 풀기와 가산 비목 지정 | `입력안함 / 소계(=) / 계(==) / 합계(===)`; 입력 비목 `재료비 / 노무비 / 경비 / 전체`; 결과 비목 `재료비 / 노무비 / 경비 / 해당비목` |
| `wM_From_XLS` | XLS 자료를 내역으로 불러오기 | 주요 입력 칸·선택지 `미확인` |
| `wSameCode` | 내역의 동일 코드 처리 | 주요 입력 칸·선택지 `미확인` |
### 2.2 수량 — 2개
| 화면 | 무엇을 하나 | 주요 입력 칸·선택지 |
|---|---|---|
| `wQtyList` | 별도 수량산출서에서 코드별 수량을 조회·연결 | `cBoxCode`: `모든단가 / 노무비 / 재료비 / 경비 / 일위대가 / 중기비 / 단가산출 / 견적단가 / 대가,산근` |
| `uM_ConQTY` | 수량 자료 변환·연결 | 구체적인 칸과 변환 방향 `미확인` |
### 2.3 일위대가·단가산출 — 7개
| 화면 | 무엇을 하나 | 주요 입력 칸·선택지 |
|---|---|---|
| `wM_Edit_iLWi` | 일위대가표 구성행 수정 | 노임계수 `초기값설정 개별적용 / 기본 / 기타 / 기타-1`~`기타-4 노임산출계수`; 저장비목 `각 해당비목 / 재료비 / 노무비 / 경비` |
| `wM_Edit_San` | 단가산출근거와 Q식 수정 | 블록 `열블럭 / 행블럭 / 일반`; 함수 `∏ / INT / SIN / COS / TAN / SQRT / √ / ROUND / ROUNDDOWN …`; 연산 `/ / *`; 수량기호 `Q / A / T / N`; 저장비목 4택 |
| `wM_Edit_EQ` | 표준중기공식 편집 | 삽입 `공식 / 공식+코드` |
| `wM_Edit_USER` | 사용자 단가산출식 편집 | 주요 입력 칸·선택지 `미확인` |
| `wM_iLWi_San` | 일위대가·단가산출 자료 조회/연결 | 구체적인 칸과 편집 범위 `미확인` |
| `wM_Conv_A2W` | 실적단가를 일식단가로 변환등록 | `비목율(단가 => 노,재,경) / 조달청 발표단가 변환적용`; 연결 `공종코드↔분류번호 / 공종코드↔시설코드 / 명칭·규격·단위` |
| `wM_Boq_ExecX` | 시간·단위당 중기 실행단가 계산·입력 | 절사 단위 `1원 / 10원 / 100원 / 1,000원 / 10,000원 / 100,000원`; 보정 `기본보정 / 노무비 / 재료비 / 경비` |
### 2.4 기준자료(단가·노임·중기) — 10개
| 화면 | 무엇을 하나 | 주요 입력 칸·선택지 |
|---|---|---|
| `wM_JabPum` | 기준 단가 목록 조회·선택 | 코드종류 `모든단가 / 노무비 / 재료비 / 경비 / 일위대가 / 중기비 / 단가산출 / 견적단가 / 대가,산근 / 그림코드`; 검색 `명칭,규격 / 산근내용` |
| `wM_Edt_Hal` | 기준단가를 비율로 일괄 수정 | `절사 / 반올림`; 화면 안내상 적용단가 0원과 보호표시 항목은 제외 |
| `wM_Edit_XRate` | 시간당 손료계수 수정 | `계 입력 / 각계수 입력` |
| `wM_Boxa` | 단가목록 조회·비교 | `변동없음 / 1 단가`~`5 단가 / 최소단가`; 자재·공종 분류 필터 |
| `wM_Box_H` | 불러간 단가 목록 확인 | `변동없음 / 1 단가`~`5 단가 / 최소단가` |
| `wM_JDCode` | 공사원가 호환규정코드 검색·입력 | 기준 `명칭 / 명칭·규격 / 분류번호 / 시설코드 / 선택안함`; 검색 `명칭·규격·단위 / 호환규정코드 / 군급목록번호 / 중기품셈코드 / 시설코드번호`; 종류 `기본 / 자재 / 경비 / 중기 / 실적·기타` |
| `wM_Mrg_BSC` | 새 기준자료로 프로젝트 자료 갱신 | 매칭 `호환규정코드 / 시설코드 / 명칭·규격·단위 / 호환+명+규+단 / 명칭·단위 / 설정안함`; 명칭·단가·조달코드별 복사처/복사원 적용 |
| `wM_JajeXLS` | 자재 기준자료를 XLS로 내보내거나 불러오기 | 방향과 주요 선택지 `미확인` |
| `PM_iLWi`·`PM_iLWi_S` | 일위대가표 인쇄 원장을 구성 | 인쇄 전용 폼; 입력 칸 `미확인` |
| `PM_JOONG`·`PM_JOONG2`·`PM_JOONG_L` | 중기목록·시간당중기사용료 인쇄 원장을 구성 | 일반·변형·가로형 구분 외 입력 칸 `미확인` |
### 2.5 원가계산·제비율 — 6개
| 화면 | 무엇을 하나 | 주요 입력 칸·선택지 |
|---|---|---|
| `wM_KanJub` | 설계 제잡비를 계산하고 원가계산서를 만든다 | `원설계 / 1차변경 / 2차변경 / 3차변경`; `제잡비 원가계산방식 / 제잡비 표준시장방식`; 간접비·총공사비 절사 단위; `당초/변경 대비인쇄 / 변경분만 인쇄` |
| `wM_KAN_RATE` | 제잡비 요율표를 조회·수정·저장·출력 | 상단 `수정 / 취소 / 저장 / 출력 / 종료 / 불러오기`; 요율표 선택 콤보; 실제 탭은 DFM §4 참조 |
| `PM_KAN_RATE` | 제잡비 요율표 인쇄 | 공사규모 구간별 기초액·요율, 일반관리비 등 출력; 입력 화면 아님 |
| `wM_GetAMT` | 내역에서 잡비 계산 기초금액을 불러온다 | `모두보기 / 소계(+=) / 계(+==) / 합계(+===) / 총계(+====) / 총합계(+=====)` |
| `wM_KanJub_K` | 기성 제잡비 계산 | 집계수준 `소계`~`총합계 이상`; 부가세 `(0) 직접입력 / (1) 공급가액의 10% / (2) 재료비의 10% / (3) 재료비+산출경비의 10%`; 절사 단위 |
| `PM_Won_A`·`PM_Won_A_C`·`PM_Won_A_L` | 공사원가계산서 출력 원장을 구성 | 일반·변경·가로형 구분 외 입력 칸 `미확인` |
### 2.6 인쇄·출력 — 7개
| 화면 | 무엇을 하나 | 주요 입력 칸·선택지 |
|---|---|---|
| `PM_Prt` | 전체 인쇄 메뉴와 출력 모양 설정 | 글꼴 `굴림체 / 궁서체 / 바탕체 / 돋움체`; 코드·호표·분류번호 등 인쇄 필드; `수량/단위 / 단위/수량 / 표준`; `일반 / 토개공 / 수자원공사`; `세로 / 가로`; `일반 / 산근` |
| `PM_Prn_Ord` | 보고서 인쇄순서 지정 | `당초 설계 / 변경 설계 / 분할 설계 / 실적 공사 / 할증 내역 / 견적 내역 / 계약 내역 / 실행 예산 / 기성 내역 / 설계 변경 / 굴착·복구 / 굴착 변경` |
| `PM_Sel_St` | 인쇄할 공종과 집계수준 선택 | `소계(+=) 이상`~`총합계(+=====)`; 방향 `아래로 / 위로` |
| `wM_Sel_Prn` | 선택 영역·설계차수·표준시장단가 보정 후 인쇄 | `당초설계 / 변경설계 / 분할설계`; `7레벨공종이하 내역전체 / 해당레벨`; 비노무비 처리 3택; 노무비 끝전 보정 3택; `1차변경`~`8차변경` |
| `PM_Nae*` 계열 | 총괄·설계·분할·관급·견적·계약 내역서 출력 | 일반·가로·변경·계약 등 변형; 세부 입력 칸 `미확인` |
| `PM_Col_Dan*`·`PM_Col_X*` | 재료·노무·경비·중기·일위·산근 수량금액 집계표 출력 | 일반·가로·X 변형; 세부 입력 칸 `미확인` |
| `PM_SanMok*`·`PM_Dan*` | 단가 목록·단가대비·단가산출 목록 출력 | 일반·가로·인쇄 변형; 세부 입력 칸 `미확인` |
### 2.7 설정 — 5개
| 화면 | 무엇을 하나 | 주요 입력 칸·선택지 |
|---|---|---|
| `wM_Set_Sys` | 계산 초기값과 표시 자리수 설정 | `KCD/ACD/JCD/ECD/BPN`: `0 / 1 / 2 자리`; `ZCD`: `2 / 3 자리`; 환율범위 `시간당중기사용료 단독 / 중기 / 대가 / 산근 / 내역 전체`; 노임계수 `터널 / 장대터널 / 펌프준설선 / GRAB준설선 / 버켓준설선` |
| `wM_Set_Init` | 프로젝트 계산 초기값 설정 | 구체적인 탭·칸 `미확인` |
| `wM_Set_JLab` | 직종·노임 관련 초기 설정 | 구체적인 칸 `미확인` |
| `uM_XML_Info` | 공사원가 호환규정 XML 변환 설정 | 공사유형 `토목 / 건축 / 설비 / 전기 / 통신 / 조경 / 소방 / 국가유산수리공사` |
| `wSrch_PL` | 프로젝트 DATA 검색 | 종류 `노무비 / 재료비 / 경비 / 중기비 / 일위대가 / 단가산출 / 일식·표준시장 / 내역서` |
## 3. 화면 ↔ 출력 시트 30종
`print_form_map.txt`의 export 연결과 19번 §3의 실제 시트명을 합쳤다. `PM_*`은 인쇄용 폼이며 편집 화면에서 직접 호출되는지는 정적 자료만으로 확인되지 않은 경우가 있다.
| 화면·폼 계열 | 실제 출력 시트 |
|---|---|
| `wM_KanJub``PM_Won_A*` | 공사원가계산서 |
| `wBoq``PM_Nae*` | 설계내역서 · 착공내역서 · 총괄설계내역서 · 총괄내역서 |
| `wBoq`/변경 내역 → `PM_Nae*` 또는 `CHANGE_*` | 설계변경내역서 · 설계변경금액대비표 · 설계변경증감대비표 |
| `wM_Edit_iLWi``PM_iLWi*` | 일위대가표 |
| `wM_Edit_San``Calq_San`·`Edit_SanKun` | 단가산출근거 |
| `wM_JabPum`/중기 원장 → `PM_JOONG*` | 중기사용료 |
| `wM_Boxa` 또는 단가비교 → `PM_Col_Dan*` | 자재단가대비표 |
| `wM_Set_Sys` 관련 설정 출력 | 환율및기초자료 · `〓 INITIAL 〓` — 정확한 `PM_*` 호출은 `미확인` |
| `wM_JabPum``PM_SanMok*` | 재료비목록표 · 노무비목록표 · 경비목록표 · 일식견적목록표 |
| 일위대가 원장 → `PM_SanMok*` | 일위대가목록표 |
| 단가산출 원장 → `PM_SanMok*` | 단가산출근거목록표 |
| 중기 원장 → `PM_JOONG*` | 중기목록표 |
| 내역 집계 → `PM_Col_Dan*` | 재료비수량금액집계표 · 노무비수량금액집계표 · 경비수량금액집계표 |
| 중기 집계 → `PM_Col_Dan*` | 중기시간금액집계표 |
| 일위대가 집계 → `PM_Col_Dan*` | 일위대가수량금액집계표 |
| 단가산출 집계 → `Calq_San`·`Edit_SanKun`·`PM_Col_Dan*` | 단가산출근거수량금액집계표 |
| 일식견적 집계 → `PM_Col_Dan*` | 일식견적수량금액집계표 |
| 인쇄 메뉴의 보고서 목록 | `〓 목 차 〓` · `※※안내※※` — 생성 폼 `미확인` |
30종은 위 대응에 모두 들어간다. 다만 `환율및기초자료`, `〓 INITIAL 〓`, `〓 목 차 〓`, `※※안내※※`의 정확한 생성 폼 **4개 연결은 미확인**이다.
## 4. DFM 3벌의 실제 배치
### 4.1 `TWM_KANJUB.dfm` — 제잡비 계산 입력 화면
- 상단 고정 패널 다음에 본문 `TPageControl(Pg_Ctrl)`이 놓인다.
- 상단 칸 차례는 기본값 설정, 차수 선택, 계산방식, 요율, 출력, 변경/계산, 종료 계열이다. 확인되는 콤보는 `Cmbx_PORD1_H``cBx_K_JeolSa``cBx_Chasu``cBx_W_Type` 순이다.
- `cBx_Chasu`: `원설계 / 1차변경 / 2차변경 / 3차변경`; `cBx_W_Type`: `제잡비 원가계산방식 / 제잡비 표준시장방식`.
- 본문 탭은 아래쪽 탭바(`TabPosition=tpBottom`)이고 여러 줄 탭(`MultiLine=True`)이다. DFM에서 `TbSh_T`, `TbSh_TA` 등 원가 형식별 탭 객체가 확인된다. 탭 캡션 전체와 탭별 항목 수는 `미확인`이다.
- 각 탭 안은 `TScrollBox`로 구성되어 긴 제잡비 항목을 세로로 스크롤한다.
### 4.2 `TWM_KAN_RATE.dfm` — 요율 조회·편집 화면
- 상단 고정 도구막대: `수정(&E)``취소(&C)``저장(&S)``출력(&P)``종료(&Q)` 순이며 `불러오기` 버튼과 요율표 선택 콤보가 함께 있다.
- 본문은 `TPageControl(Pg_Ctrl)`이며 탭바가 아래쪽에 있다. 확인되는 첫 탭 객체는 `Tab_Rn`이다.
- 탭 안은 흰색 `TScrollBox`와 여러 요율 그리드로 구성된다. 확인되는 묶음은 고용 관련 요율(`rGrd_Rate_Kobo`), 산업안전 관련 요율(`rGrd_Rate_Anjun`) 등이다.
- 그리드 열은 공사규모 구간과 `요율`, `기초액`, `% + 천원` 형식을 함께 가진다. 즉 한 화면에서 단순 단일요율뿐 아니라 규모별 기초액 가산식도 편집한다.
- 전체 탭 캡션과 모든 그리드의 화면상 정확한 순서는 바이너리 DFM 문자열만으로 복원되지 않아 `미확인`이다.
### 4.3 `TPM_KAN_RATE.dfm` — 요율표 인쇄 화면
- 편집 위젯이 아니라 `THRPage` 기반 보고서 한 장이다. `ReportHeader` 뒤에 요율표가 이어진다.
- 제목·구간 라벨 → 금액 구간 → 기초액 → 요율 순으로 열이 반복된다.
- 확인되는 구간 예시는 `10억~50억 미만`, `70억~120억`이며 표시 형식은 `00.00 %` 또는 `(#,0 천원 + …)`이다.
- `일반관리비` 등 항목별 표가 인쇄 좌표에 고정 배치된다. `TWM_KAN_RATE`의 편집 그리드를 종이용 규모구간 표로 펼친 구조다.
## 5. B08·B09 화면 구조에 주는 근거
```text
수량산출서(wQtyList/uM_ConQTY)
→ 내역 입력(wBoq)
→ 일위대가·단가산출 편집
→ 기준자료(단가·노임·중기)
→ 제잡비 계산(wM_KanJub)
→ 인쇄 메뉴(PM_Prt)에서 30종 시트 선택
```
- B08 근거: 수량산출서는 코드 종류로 필터한 별도 화면이고, 내역서와 분리되어 있다.
- B09 근거: 직접비 편집, 기초단가, 일위대가·단가산출, 제잡비 요율, 출력이 각각 별도 화면이며 마지막에 인쇄 메뉴가 묶는다.
- 제잡비 입력 화면과 요율 관리 화면도 분리된다. 요율 관리 화면은 수정·저장·출력의 독립 흐름을 가진다.
- 출력은 계산 화면을 그대로 인쇄하지 않고 `PM_*` 보고서 폼을 따로 거친다.
## 6. 미확인
화면 단위 미확인 **10개**:
1. `wM_From_XLS`의 열 매핑·불러오기 옵션
2. `wSameCode`의 동일 코드 처리 선택지
3. `uM_ConQTY`의 변환 방향·연결 키
4. `wM_Edit_USER`의 사용자식 편집 범위
5. `wM_iLWi_San`의 조회·편집 경계
6. `wM_JajeXLS`의 입력·출력 방향
7. `wM_Set_Init`의 탭과 칸
8. `wM_Set_JLab`의 직종·노임 설정 칸
9. `TWM_KANJUB`의 전체 탭 캡션·항목 수
10. `TWM_KAN_RATE`의 전체 탭 캡션·그리드 순서
출력 연결 미확인 **4개**: `환율및기초자료` · `〓 INITIAL 〓` · `〓 목 차 〓` · `※※안내※※`의 정확한 생성 폼.
이 문서는 화면 구조 근거만 정리한다. Aislo B08·B09의 구현안이나 화면 설계는 포함하지 않는다.
@@ -0,0 +1,128 @@
"""이중계상 경계를 축으로 (명세 6장 · 2026-09-13).
지키는 것 — 갈 곳(`destination`) 칸으로 판정하고 B09 내역서가 멈춘다
① 구조물 터파기·되메우기는 토공집계로만 — 다른 공종 줄이 또 집거나, earthwork 밖으로 가면 오류
② 배합 성분(시멘트·모래·자갈)이 자재총괄에 뜨면 오류
③ 할증은 자재총괄 한 번 — 원단위표가 붙였거나 · 자재 합계가 순수량 × (1+율) 과 다르거나 ·
할증 포함 재료량을 준 공종에 자재가 일위대가 재료비로 붙으면 오류
"""
from __future__ import annotations
from decimal import Decimal
from types import SimpleNamespace
import pytest
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping
from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import verify_double_count_boundaries
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_table
from B09_Estimation.B09_Estimation_BillOfQuantities import HandoffMaterial, build_bill
from B09_Estimation.B09_Estimation_Guards import (
DoubleCountError,
check_material_surcharge_once,
check_materials_before_surcharge,
)
from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceDetail, PriceKind, PriceTitle
def _structure(type_id: str, *components: tuple[str, str, float]) -> dict:
return {
"type_id": type_id,
"name": type_id,
"components": [
{"name": name, "unit": "", "amount": amount, "destination": destination}
for name, destination, amount in components
],
}
def test_경계를_지킨_구조물은_어긴_자리가_없다() -> None:
table = {
"structures": [
_structure(
"masonry_wet",
("돌쌓기", "unit_price", 26.1),
("터파기", "earthwork", 12.0),
("되메우기", "earthwork", 4.0),
("채집석", "haul_deduction", 3.0),
("입적", "reference", 9.0),
)
]
}
assert verify_double_count_boundaries(table, {"rows": []}, load_mapping()) == []
def test_토공으로_가는_성분을_공종_줄이_또_집으면_오류() -> None:
mapping = load_mapping()
# 옹벽 묶음 조각 「기초잡석」이 토공으로 가는 성분을 집음 — 같은 물량이 두 공종에 붙음
wall = _structure("retaining_wall", ("기초잡석", "earthwork", 0.3))
# 돌쌓기 줄이 세는 성분이 토공으로 감
masonry = _structure("masonry_wet", ("돌쌓기", "earthwork", 26.1))
# 터파기가 자재로 감 — 토공집계로만 가야 함
loose = _structure("masonry_dry", ("터파기", "material", 5.0))
found = verify_double_count_boundaries(
{"structures": [wall, masonry, loose]}, {"rows": []}, mapping
)
assert any("묶음 조각" in f and "기초잡석" in f for f in found)
assert any("구조물 줄" in f and "돌쌓기" in f for f in found)
assert any("「터파기」 갈 곳이 material" in f for f in found)
def test_배합_성분이_자재총괄에_뜨거나_원단위표가_할증을_붙이면_오류() -> None:
found = verify_double_count_boundaries(
{"structures": [], "surcharge_applied": True},
{"rows": [{"name": "시멘트"}, {"name": "막자갈"}]},
load_mapping(),
)
assert any(f.startswith("") and "시멘트" in f for f in found)
assert not any("막자갈" in f for f in found) # 뒤채움 재료 — 정확히 같은 이름만 봄
assert any(f.startswith("") for f in found)
def test_내역서는_어긴_자리가_있으면_멈춘다() -> None:
handoff = build_handoff(
summary_table={"rows": [{"group": "흙깎기", "item": "토사", "unit": "", "amount": 1.0}]}
)
assert handoff["double_count_violations"] == []
handoff["double_count_violations"] = ["① 시험 — 같은 물량이 두 자리에 붙음"]
with pytest.raises(DoubleCountError):
build_bill(handoff)
def _material(total: str, note: str = "", pct: str | None = "10") -> HandoffMaterial:
return HandoffMaterial(
material_name="고임돌",
spec="",
unit="",
net_amount=Decimal("100"),
total_amount=Decimal(total),
supply_type="contractor_supplied",
surcharge_pct=None if pct is None else Decimal(pct),
surcharge_note=note,
)
def test_자재_합계는_할증_한_번() -> None:
check_material_surcharge_once([_material("110"), _material("100", pct=None)])
check_material_surcharge_once([_material("100", note="품셈에 할증 포함 — 중복 적용 안 함")])
with pytest.raises(DoubleCountError):
check_material_surcharge_once([_material("121")]) # 두 번 붙음
def test_할증_포함_재료량_공종에_자재가_재료비로_붙으면_오류() -> None:
book = PriceBook()
book.add_title(PriceTitle(code="M-시험", kind=PriceKind.MATERIAL, name="패널", unit=""))
book.add_title(PriceTitle(code="B-FP-12-38-02", kind=PriceKind.UNIT_PRICE, name="사용수량"))
book.add_detail(PriceDetail("B-FP-12-38-02", "M-시험", Decimal("0.089")))
# 금액을 세우지 않고도 걸림 — 줄이 붙는 순간이 어긴 자리
with pytest.raises(DoubleCountError):
check_materials_before_surcharge(book)
def test_구조물도_일위대가는_토공으로_가는_줄을_안_넣는다() -> None:
template = {"code": "AX-ST-00000001", "unit_price": {"rows": [{"seq": 1, "from_row": 12}]}}
sheet = {"rows": [{"no": 12, "unit": "", "unit_amount": 1.2, "destination": "earthwork"}]}
book = SimpleNamespace()
table = unit_price_table(template, sheet, book, lambda code, value: None)
assert table["rows"][0]["reason"].startswith("이중계상") and table["complete"] is False