Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -583,6 +583,10 @@ def basis_quantity_is_grouped(quantity: float | None) -> bool:
|
||||
|
||||
def build() -> dict[str, Any]:
|
||||
data = json.loads(SOURCE.read_text(encoding="utf-8"))
|
||||
# 사람 판정 선언(표 형태·부모 모양)은 절 번호로 적혀 판에 묶임 — 판이 다르면 멈춤(명세 17장).
|
||||
from common_util.common_util_pum_edition import require_code_edition
|
||||
|
||||
require_code_edition(data["effective_date"], "공종 마스터 빌드 — 표 형태·부모 모양 판정")
|
||||
tables = data["variables"]["pum"]["tables"]
|
||||
# 원문을 함께 연다 — 밑수가 표 밖(본문)에 있기 때문이다. 못 열면 밑수 없이 간다.
|
||||
source_lines: list[str] = []
|
||||
@@ -711,6 +715,8 @@ def build() -> dict[str, Any]:
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"dataset_id": "work_item_master_forest",
|
||||
"effective_date": data["effective_date"],
|
||||
# 공종 코드(`FP-*` 절 번호)가 묶인 품셈 판 — 자원 축·인계 줄이 이 값을 싣는다.
|
||||
"pum_edition": data["effective_date"],
|
||||
"generated_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||||
"dataset_version": src_meta,
|
||||
"policy": {
|
||||
|
||||
@@ -231,6 +231,10 @@ def build_handoff(
|
||||
(unit_quantity_table or {}).get("collected_stone_deduction_m3") or 0.0
|
||||
)
|
||||
result["placing_notes"] = placing_notes
|
||||
# ⚠ 품셈 판(명세 17장) — 줄마다 코드가 본 판을 싣는다. 판이 다르면 B09 가 값을 안 씀.
|
||||
result["pum_edition"] = table.pum_edition or None
|
||||
for row in work_items:
|
||||
row.setdefault("pum_edition", table.pum_edition or None)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -192,6 +192,8 @@ class WorkItemMapping:
|
||||
"""수량 줄 → 공종 마스터 코드. 못 찾으면 `None` 을 돌려주고 부른 쪽이 목록에 남긴다."""
|
||||
|
||||
effective_date: str = ""
|
||||
#: 매핑의 공종 코드가 본 품셈 판(명세 17장) — 인계 줄마다 이 값이 `pum_edition` 으로 실린다.
|
||||
pum_edition: str = ""
|
||||
earthwork: list[dict[str, Any]] = field(default_factory=list)
|
||||
haul: list[dict[str, Any]] = field(default_factory=list)
|
||||
structure: list[dict[str, Any]] = field(default_factory=list)
|
||||
@@ -284,6 +286,7 @@ def load_mapping(path: Path | None = None) -> WorkItemMapping:
|
||||
variant_aliases = [row for row in load_aliases("variant") if row["pum_edition"] == edition]
|
||||
return WorkItemMapping(
|
||||
effective_date=str(payload.get("effective_date") or ""),
|
||||
pum_edition=edition,
|
||||
earthwork=list(payload.get("earthwork") or []),
|
||||
haul=list(payload.get("haul") or []),
|
||||
structure=list(payload.get("structure") or []),
|
||||
|
||||
@@ -34,6 +34,7 @@ from B09_Estimation.B09_Estimation_Guards import (
|
||||
check_material_surcharge_once,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
|
||||
from common_util.common_util_pum_edition import edition_waiting_reason
|
||||
from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity
|
||||
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import (
|
||||
@@ -115,6 +116,8 @@ class HandoffWorkItem:
|
||||
#: **근거는 산출근거 칸에 그대로 적는다** — 우리가 다시 지어내지 않는다.
|
||||
spec_class: str = ""
|
||||
spec_class_basis: str = ""
|
||||
#: 이 줄의 공종 코드가 본 품셈 판 — 마스터 판과 다르면 값을 안 씀(명세 17장).
|
||||
pum_edition: str = ""
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
@@ -295,6 +298,7 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[
|
||||
variant_value=str(row.get("variant_value") or ""),
|
||||
spec_class=row.get("spec_class") or "",
|
||||
spec_class_basis=row.get("spec_class_basis") or "",
|
||||
pum_edition=str(row.get("pum_edition") or ""),
|
||||
)
|
||||
for row in payload["work_items"]
|
||||
]
|
||||
@@ -381,7 +385,8 @@ def build_bill(
|
||||
"""
|
||||
work_items, materials = parse_handoff(payload)
|
||||
unit_prices = build or cached_build()
|
||||
index = _master_index(master or load_work_item_master())
|
||||
master = master or load_work_item_master()
|
||||
index = _master_index(master)
|
||||
result = BillResult()
|
||||
|
||||
# ── 1) 쓰인 공종의 조상만 남긴 가지치기 나무 ────────────────────────────────
|
||||
@@ -414,6 +419,19 @@ def build_bill(
|
||||
# 구조물도 호표(PLAN 6장 ②) — 공종 마스터에 없는 자체 확장 코드라 나무에 안 끼움.
|
||||
templated.append(item)
|
||||
continue
|
||||
# 품셈 판이 다른 줄 — 절 번호가 같아도 같은 공종이라는 보장이 없어 값을 안 씀(명세 17장 규칙 4).
|
||||
waiting = edition_waiting_reason(item.pum_edition, str(master.get("effective_date", "")))
|
||||
if waiting:
|
||||
result.missing.append(
|
||||
{
|
||||
"name": item.display_name,
|
||||
"unit": item.unit,
|
||||
"quantity": str(item.quantity),
|
||||
"reason": waiting,
|
||||
"blocked_kind": "edition_waiting",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if (item.composite_parts or item.composite_not_ready) and not item.work_item_code:
|
||||
# 묶음 줄 — 품셈에 그 공종이 없어 **조각을 합쳐** 한 줄로 세운다
|
||||
# (옹벽 = 타설 + 거푸집 + 철근 + 잡석). 「코드 없음」으로 세면 안 된다.
|
||||
|
||||
@@ -240,6 +240,8 @@ class ResourceRow:
|
||||
#: ⚠ **이 값을 안 보면 단가가 조용히 틀린다** — 인력 몫 원단위를 전량에 곱하게 된다
|
||||
#: (2026-09-08 실측: 측구터파기 39,575.6원/㎥ 이 인력 10 % 몫만이었다).
|
||||
group_ratio_pct: Decimal | None = None
|
||||
#: 이 줄을 읽어 낸 품셈 판 — `pum_table_id`·`work_item_code` 가 판에 묶임(명세 17장).
|
||||
pum_edition: str = ""
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -258,6 +260,7 @@ class ResourceRow:
|
||||
None if self.alternative_amount is None else str(self.alternative_amount)
|
||||
),
|
||||
"group_ratio_pct": None if self.group_ratio_pct is None else str(self.group_ratio_pct),
|
||||
"pum_edition": self.pum_edition,
|
||||
}
|
||||
|
||||
|
||||
@@ -666,6 +669,8 @@ def build_resource_axis(master: dict[str, Any], catalog: ResourceCatalog) -> Axi
|
||||
for node in master.get("work_items", []):
|
||||
for table in node.get("tables", []):
|
||||
match_table(node, table, catalog, result)
|
||||
for row in result.rows:
|
||||
row.pum_edition = edition
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -583,6 +583,12 @@ def build_unit_prices(
|
||||
)
|
||||
|
||||
master = load_work_item_master()
|
||||
# 합산 단계·표 읽기 선언·가드 목록이 절 번호로 박혀 판에 묶임 — 판이 다르면 조립 안 함(명세 17장).
|
||||
from common_util.common_util_pum_edition import require_code_edition
|
||||
|
||||
require_code_edition(
|
||||
str(master.get("effective_date", "")), "B09 단가 조립 — 코드에 박힌 절 번호"
|
||||
)
|
||||
if factor_choices is None:
|
||||
factor_choices = chosen_values(scan_range_factors(master))
|
||||
# 「…와 동일」 참조로 이어 온 계수 — **사용자가 고른 값이 있으면 그것이 이긴다.**
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""품셈 판(`pum_edition`) — `FP-*` 참조가 **어느 판을 보고 적혔나** (명세 17장 규칙 3·4).
|
||||
|
||||
`FP-*` 는 절 번호라 **판 안에서만 유일**하다. 판이 바뀌면 같은 번호가 다른 공종일 수 있다.
|
||||
|
||||
공종 마스터 `effective_date` = 그 판
|
||||
자원 축 줄 · 인계 줄 `pum_edition` 칸 — 그 줄이 본 판
|
||||
코드에 박힌 절 번호 `CODE_PUM_EDITION` 한 곳 — 합산 단계·표 형태 판정·표 읽기 선언·가드 목록
|
||||
|
||||
⛔ 판이 다르면 **값을 쓰지 않는다** — 줄은 「확인 대기」로 세우고, 코드에 박힌 선언은 조립을 멈춘다.
|
||||
조용히 이웃 절에 붙으면 품이 통째로 달라진다(규칙 4). 판을 넘어 잇는 것은 대응표 몫이다.
|
||||
⚠ 라이브러리 항목의 `pum_edition` 은 랩탑 서브가 붙였다(구조물도 양식).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: 코드 안에 박힌 `FP-*` 가 본 판 — 산림사업 표준품셈(산림청고시 제2025-82호, 2026-01-01 시행).
|
||||
#: ⚠ 판을 올리는 날 이 값만 바꾸지 말 것 — 박힌 선언을 대응표로 옮긴 **뒤에** 바꿀 것.
|
||||
CODE_PUM_EDITION = "2026-01-01"
|
||||
|
||||
|
||||
class PumEditionError(RuntimeError):
|
||||
"""코드에 박힌 절 번호의 판과 공종 마스터의 판이 다를 때. 값을 쓰지 않고 멈춘다."""
|
||||
|
||||
|
||||
def require_code_edition(master_edition: str, where: str) -> None:
|
||||
"""코드에 박힌 선언을 쓰기 전에 부른다 — 판이 다르면 멈춘다."""
|
||||
if str(master_edition) != CODE_PUM_EDITION:
|
||||
raise PumEditionError(
|
||||
f"{where}: 코드에 박힌 절 번호는 {CODE_PUM_EDITION} 판을 보고 적혔는데 공종 마스터는 "
|
||||
f"{master_edition or '(판 없음)'} 판입니다 — 판 대응표로 옮기기 전에는 쓰지 않습니다 "
|
||||
"(명세 17장 규칙 4)"
|
||||
)
|
||||
|
||||
|
||||
def edition_waiting_reason(row_edition: str | None, master_edition: str) -> str:
|
||||
"""판이 다른 줄의 「확인 대기」 사유. 어느 한쪽이 판을 안 적었거나 같으면 빈 글."""
|
||||
if not row_edition or not master_edition or str(row_edition) == str(master_edition):
|
||||
return ""
|
||||
return (
|
||||
f"품셈 판 확인 대기 — 이 줄의 공종 코드는 {row_edition} 판,"
|
||||
f" 공종 마스터는 {master_edition} 판 · 절 번호가 같아도"
|
||||
" 같은 공종이라는 보장이 없어 값을 안 씀(명세 17장)"
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "data_work_item_master_manifest",
|
||||
"generated_at": "2026-09-13T21:50:44+09:00",
|
||||
"generated_at": "2026-09-13T22:33:42+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": "9da259311e5b24c92c8423b423d2b0f4b0b2bb3e7fc2a4eb41aec9cae25b6dc0",
|
||||
"size_bytes": 862856
|
||||
"sha256": "4e7790cd9d288e6026e7ccd81ce0c81320b395c84575655ff2dce443f44d65a4",
|
||||
"size_bytes": 862887
|
||||
},
|
||||
{
|
||||
"file": "form_undetermined_2026-01-01.json",
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "work_item_master_forest",
|
||||
"effective_date": "2026-01-01",
|
||||
"generated_at": "2026-09-13T21:50:44+09:00",
|
||||
"pum_edition": "2026-01-01",
|
||||
"generated_at": "2026-09-13T22:33:42+09:00",
|
||||
"dataset_version": {
|
||||
"dataset_id": "pum_forest",
|
||||
"effective_date": "2026-01-01",
|
||||
|
||||
@@ -51,6 +51,8 @@ WORK_ITEM_KEYS = {
|
||||
"in_bill",
|
||||
"in_bill_reason",
|
||||
"origin",
|
||||
# 공종 코드가 본 품셈 판 — B09 가 마스터 판과 다르면 값을 안 씀(명세 17장 · 2026-09-13 추가).
|
||||
"pum_edition",
|
||||
}
|
||||
|
||||
#: **자재 줄**이 내보내는 칸. ⚠ 여기에 `work_item_code` 가 들어가면 안 된다(축이 다르다).
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""품셈 판 `pum_edition` — 자원·공종 쪽 (명세 17장 규칙 3·4 · 2026-09-13).
|
||||
|
||||
지키는 것
|
||||
① 공종 마스터·자원 축 줄·인계 줄이 모두 자기가 본 판을 싣는다
|
||||
② 판이 다른 인계 줄은 내역서에서 값을 안 쓰고 「확인 대기」로 선다
|
||||
③ 코드에 박힌 절 번호의 판과 마스터 판이 다르면 조립을 멈춘다
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
|
||||
from B09_Estimation.B09_Estimation_ResourceAxis import build_resource_axis, load_work_item_master
|
||||
from B09_Estimation.B09_Estimation_ResourceAxis_Sources import load_combined_catalog
|
||||
from common_util.common_util_pum_edition import (
|
||||
CODE_PUM_EDITION,
|
||||
PumEditionError,
|
||||
require_code_edition,
|
||||
)
|
||||
|
||||
|
||||
def test_마스터_자원_인계가_모두_판을_싣는다() -> None:
|
||||
master = load_work_item_master()
|
||||
assert master["pum_edition"] == master["effective_date"] == CODE_PUM_EDITION
|
||||
axis = build_resource_axis(master, load_combined_catalog())
|
||||
assert axis.rows and {row.as_dict()["pum_edition"] for row in axis.rows} == {CODE_PUM_EDITION}
|
||||
handoff = build_handoff(
|
||||
summary_table={"rows": [{"group": "흙깎기", "item": "토사", "unit": "㎥", "amount": 5.0}]}
|
||||
)
|
||||
assert handoff["pum_edition"] == CODE_PUM_EDITION
|
||||
assert all(row["pum_edition"] == CODE_PUM_EDITION for row in handoff["work_items"])
|
||||
|
||||
|
||||
def test_판이_다른_인계_줄은_값을_안_쓰고_확인_대기() -> None:
|
||||
handoff = build_handoff(
|
||||
summary_table={"rows": [{"group": "흙깎기", "item": "토사", "unit": "㎥", "amount": 5.0}]}
|
||||
)
|
||||
same = build_bill(handoff)
|
||||
assert any(row.code and row.amount_krw for row in same.rows if not row.is_group)
|
||||
for row in handoff["work_items"]:
|
||||
row["pum_edition"] = "2027-01-01"
|
||||
moved = build_bill(handoff)
|
||||
assert not [row for row in moved.rows if not row.is_group]
|
||||
assert any(m.get("blocked_kind") == "edition_waiting" for m in moved.missing)
|
||||
|
||||
|
||||
def test_코드에_박힌_절_번호는_판이_다르면_멈춘다() -> None:
|
||||
require_code_edition(CODE_PUM_EDITION, "시험")
|
||||
with pytest.raises(PumEditionError):
|
||||
require_code_edition("2027-01-01", "시험")
|
||||
Reference in New Issue
Block a user