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

This commit is contained in:
2026-09-13 09:23:50 +09:00
6 changed files with 128 additions and 3 deletions
@@ -93,7 +93,7 @@ STONE_WEIGHT_BASIS = (
#: ⚠ **잠정이다** — 확정 문구가 「나중에 실무자 협의 후 명확히」이므로 화면 근거에 적는다.
STONE_PACKING_RATIO = 0.77 # 채움률 — 돌 사이 공극을 뺀 몫
STONE_DENSITY_TON_M3 = 2.65 # 석재 비중
#: 계산식이 안 맞아 **관측표**를 쓰는 종류. 안 고른 경우도 이쪽(지금까지 쓰던 값이 그것).
#: 계산식이 안 맞아 **관측표**를 쓰는 종류. 안 고른 경우는 **계산식**으로 선다(`stone_masonry` 주석).
STONE_WEIGHT_OBSERVED_KINDS = frozenset({"야면석·호박돌", "야면석"})
STONE_WEIGHT_FORMULA_BASIS = (
"⭐ 확정 5차 — 뒷길이 × 0.77(채움률) × 2.65(비중). ⚠ 잠정 — 실무자 협의 후 명확히 할 값"
@@ -22,6 +22,7 @@ import os
from typing import Any
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
from B09_Estimation.B09_Estimation_ResourceAxis_UnitBasis import similar_occupation_note
_MANIFEST = ("resources", "data_cost_input_value", "_manifest.json")
_MASTER_DIR = ("resources", "data_work_item_master")
@@ -118,6 +119,9 @@ def work_item_basis(build: Any) -> list[dict[str, Any]]:
source = build.factor_sources.get(work_item_code)
if source and source not in notes:
notes.append(str(source))
similar = similar_occupation_note(work_item_code)
if similar and similar not in notes:
notes.append(similar)
if not notes:
continue
rows.append(
@@ -43,6 +43,21 @@ _RE_BASIS = re.compile(r"^(㎡|㎥|㏊|m|㎝|개|본|주|ton|톤|kg|㎏)\s*당$"
#: 밑수 표기 → 그 갈래가 갖는 단위.
_BASIS_UNIT = {"": "ha", "": "ton", "": "kg"}
#: 품셈이 **노임표에 없는 직종명**을 쓴 자리 → 유사 직종
#: (2026-09-09 사용자 확정 8-1 「바꿔쓰기 허용」).
#: 근거 — 「미조사 직종은 유사 직종 단가에 준하여 적용」(재경원 회계 45101-45).
#: ⚠ **공종 단위로만 건다** — 「인부」를 전역으로 읽으면 확정 밖 공종까지 조용히 붙는다.
SIMILAR_OCCUPATION = {("FP-13-02-04", "인부"): "보통인부"}
def similar_occupation_note(work_item_code: str) -> str:
"""그 공종이 유사 직종으로 바꿔 쓴 자리면 산출근거 문구, 아니면 빈 문자열."""
return " · ".join(
f"품셈 「{written}」 → {used} 준용(유사 직종 · 재경원 회계 45101-45 · 사용자 확정 8-1)"
for (code, written), used in SIMILAR_OCCUPATION.items()
if code == work_item_code
)
def _tight(text: str) -> str:
return "".join(str(text or "").split())
@@ -93,7 +108,8 @@ def match_unit_basis_table(
continue
if basis_at > 0:
# 이름이 앞에 붙은 줄 — 「인 부 | ㎡당 | 0.11 | …」. 이름을 여기서 갱신한다.
found = _resolve(catalog, cells[0])
name_cell = SIMILAR_OCCUPATION.get((work_item_code, _tight(cells[0])), cells[0])
found = _resolve(catalog, name_cell)
if found is None:
result.unmatched.append(
UnmatchedRow(
@@ -147,7 +147,7 @@
"not_here": {
"note": "이 표가 정하지 않는 것.",
"items": [
"돌중량(ton/㎡) — 우리 값 0.575/0.88/1.10 은 **실무 관측**(울진 라이브러리)이다. 품셈은 「돌의 중량은 형상·종류·부피를 고려하고 건설품셈 1-3-3 재료의 단위중량을 참고하여 계상한다」로만 두어 **표를 안 준다.** 돌 종류 축과 짝이 맞는지 사용자 확정 대기.",
"돌중량(ton/㎡) — 품셈은 「돌의 중량은 형상·종류·부피를 고려하고 건설품셈 1-3-3 재료의 단위중량을 참고하여 계상한다」로만 두어 **표를 안 준다.** ⭐ 사용자 확정 5차 큰 것 7(2026-09-09) — **계산식 `뒷길이 × 0.77 × 2.65` 이 기본**이고 야면석만 실무 관측(울진 라이브러리 0.575/0.88/1.10)으로 갈림. 식과 종류 갈래는 `B08_Quantity_Engine_UnitQuantity.stone_weight_per_m2` 한 벌. ⚠ 잠정 — 「실무자 협의 후 명확히」.",
"전면면적이 정면적인지 비탈면적인지 — 2026 개정안 [주] 「시공량은 석재의 **전면면적**(㎡)을 기준한다」로 용어만 확인됐다."
]
},
@@ -0,0 +1,57 @@
"""PLAN 7장 V-12 — 준비공 줄이 B08 인계에서 **B09 내역서까지 하나도 안 새나** (2026-09-13).
하면 표토제거 2,359.03 다시 통째로 사라짐 자리. 보내는 (B08) 받는 (B09)
**마주 걸어** 본다 한쪽만 보면 양쪽 조용히 틀린다(PLAN 7 머리 규칙).
겨누는
준비공·부대시설 줄은 **내역 아니면 제외 ** 반드시 선다 어디에도 없는 줄이 0
금액이 서는 (`in_bill`) **수량째** 내역 본체에 선다 표토제거 2,359.035
단가가 붙는지는 여기서 본다 표토 (시트 9)·임목축적 입력이 정해져야 서는 자리다.
"""
from __future__ import annotations
import sys
from decimal import Decimal
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_prep # noqa: E402
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill # noqa: E402
SLOPE = {
"face_dressing_fill": 9000.0,
"face_dressing_cut": 6726.9,
"tree_removal_fill": 9000.0,
"tree_removal_cut": 6726.9,
"bench_cut_fill": 9000.0,
}
def _handoff_and_bill():
handoff = build_handoff(preparation_table=build_prep(SLOPE, [], [], 0.15))
return handoff, build_bill(handoff)
def test_준비공_줄은_내역_아니면_제외로_반드시_선다() -> None:
handoff, bill = _handoff_and_bill()
sent = [r for r in handoff["work_items"] if r["origin"] == "preparation"]
assert len(sent) >= 6
body = [r.name for r in bill.rows if not r.is_group]
excluded = [r.name for r in bill.excluded]
for row in sent:
landed = body if row["in_bill"] else excluded
assert row["name"] in landed, (row["name"], row["in_bill"])
def test_금액이_서는_줄은_수량째_본체에_선다() -> None:
handoff, bill = _handoff_and_bill()
topsoil = next(r for r in bill.rows if not r.is_group and r.name == "표토제거")
assert Decimal(str(topsoil.quantity)).quantize(Decimal("0.01")) == Decimal("2359.04")
in_bill = [
r["name"] for r in handoff["work_items"] if r["origin"] == "preparation" and r["in_bill"]
]
assert {"표토제거", "제근·뿌리다듬기", "뿌리 적재"} <= set(in_bill)
@@ -0,0 +1,48 @@
"""유사 직종 바꿔쓰기 — 야면석 채집 「인 부」 → 보통인부 (2026-09-13 · 사용자 확정 8-1).
겨누는
야면석 채집(13-2-4) ** 붙은 줄에서 빠지고** 보통인부 노임으로 붙는다
바꿔쓰기는 ** 공종에만** 걸린다 전역 별칭이 아니다
산출기초에 **준용 근거** 함께 뜬다(조용히 바꾸지 않음)
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_BasisSheet import work_item_basis # noqa: E402
from B09_Estimation.B09_Estimation_ResourceAxis_UnitBasis import ( # noqa: E402
SIMILAR_OCCUPATION,
similar_occupation_note,
)
from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402
CODE = "FP-13-02-04"
def test_인부가_보통인부로_붙는다() -> None:
build = cached_build()
assert "인 부" not in build.unattached.get(CODE, [])
titles = [code for code in build.book.titles if code.startswith(f"B-{CODE}#")]
# 뒷길이 다섯 × 밑수 둘(㎡당·㎥당)
assert len(titles) == 10, titles
for code in titles:
names = {build.book.title(d.ref_code).name for d in build.book.details.get(code, [])}
assert "보통인부" in names, (code, names)
assert build.book.resolve(code).labor > 0
def test_바꿔쓰기는_그_공종에만_걸린다() -> None:
assert {code for code, _ in SIMILAR_OCCUPATION} == {CODE}
assert similar_occupation_note("FP-12-10") == ""
def test_산출기초에_준용_근거가_뜬다() -> None:
rows = [row for row in work_item_basis(cached_build()) if row["code"].startswith(f"B-{CODE}#")]
assert rows
for row in rows:
assert any("보통인부 준용" in note and "45101-45" in note for note in row["notes"]), row