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

This commit is contained in:
2026-09-17 16:40:25 +09:00
9 changed files with 330 additions and 3 deletions
@@ -53,6 +53,9 @@ WORK_ITEM_KEYS = {
"origin",
# 공종 코드가 본 품셈 판 — B09 가 마스터 판과 다르면 값을 안 씀(명세 17장 · 2026-09-13 추가).
"pum_edition",
# 불변 열쇠(FW-·AX-) — 목차 번호가 개정으로 밀려도 안 바뀜(2026-09-17 8-6 이행 길목 ·
# 받는 쪽 B09 `parse_handoff` 가 읽음). 코드 없는 줄은 None.
"work_item_key",
}
#: **자재 줄**이 내보내는 칸. ⚠ 여기에 `work_item_code` 가 들어가면 안 된다(축이 다르다).
+139
View File
@@ -0,0 +1,139 @@
"""공종 불변 열쇠 **길목** — PLAN_공종축 8-6 이행(2026-09-17 브레인 · 코덱스 조사).
잣대 = **금액 불변.** 열쇠를 더하는 일이지 값을 바꾸는 일이 아님 —
같은 인계로 세운 내역서가 열쇠가 있든(새 인계) 없든(옛 인계) **한 원도** 안 달라야 함.
길목은 경계 두 곳뿐(B08 인계 출구 · B09 인계 입구) · `work_item_code` 는 목차 번호로 그대로 남음.
"""
from __future__ import annotations
import copy
import json
import re
from functools import lru_cache
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill
from B09_Estimation.B09_Estimation_BillOfQuantities_Input import parse_handoff
from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices
from common_util.common_util_work_item_key import MASTER_DIR, attach_keys, work_item_key
MAPPING = MASTER_DIR.parent / "data_work_item_mapping" / "work_item_mapping_2026-01-01.json"
def _strip_keys(value):
"""옛 인계 — 길목이 더한 칸을 뗌."""
if isinstance(value, dict):
return {
k: _strip_keys(v)
for k, v in value.items()
if k not in ("work_item_key", "unkeyed_work_item_codes")
}
if isinstance(value, list):
return [_strip_keys(v) for v in value]
return value
@lru_cache(maxsize=1)
def _handoff() -> dict:
"""토공·운반·구조물·자재가 다 선 인계 — 금액이 실제로 붙는 줄이 여럿."""
unit = build_unit_table(
[
{
"structure_id": "s1",
"type_id": "masonry_wet",
"start_m": 35.0,
"end_m": 45.0,
"options": {"height_m": 1.5, "length_m": 10.0},
}
]
)
rows = [
{"group": "흙깎기", "item": "토사", "spec": "", "unit": "㎥", "amount": 1234.5},
{"group": "측구터파기", "item": "토사", "spec": "", "unit": "㎥", "amount": 77.0},
{"group": "층따기", "item": "", "spec": "", "unit": "㎥", "amount": 12.0},
]
haul = {
"rows": [
{
"equipment": "dump_truck",
"ground": "토사",
"volume_m3": 500.0,
"average_distance_m": 1200.0,
"in_bill": True,
}
]
}
return build_handoff(
summary_table={"rows": rows},
haul_table=haul,
unit_quantity_table=unit,
material_table=build_material_table(unit),
)
def test_목차_코드는_불변_열쇠로_난수_코드는_그대로() -> None:
forest = json.loads(
(MASTER_DIR / "work_item_master_2026-01-01.json").read_text(encoding="utf-8")
)
for item in forest["work_items"]:
assert work_item_key(item["work_item_code"], forest["pum_edition"]) == item["work_item_key"]
assert work_item_key("FP-09-03-02") == work_item_key("FP-09-03-02", "2026-01-01")
assert re.fullmatch(r"CW-\d{5}", work_item_key("CP-01-03-02-01") or "")
assert work_item_key("AX-WK-c0842a0d") == "AX-WK-c0842a0d" # 난수 8자리 — 이미 불변
assert work_item_key("AX-ST-0123abcd") == "AX-ST-0123abcd"
assert work_item_key("FP-09-03-02", "1999-01-01") is None # 판이 다르면 안 줌
assert work_item_key("FP-99-99") is None and work_item_key(None) is None
def test_매핑의_코드가_다_열쇠를_받음() -> None:
"""B08 이 인계에 싣는 코드의 샘 — 하나라도 못 받으면 그 줄은 열쇠 없이 감."""
text = MAPPING.read_text(encoding="utf-8")
codes = set(re.findall(r'"((?:FP|CP|AX-WK|AX-ST)-[0-9A-Za-z-]+)"', text))
assert len(codes) > 50
assert [c for c in sorted(codes) if work_item_key(c, "2026-01-01") is None] == []
def test_B08_인계_출구에서_열쇠가_붙고_목차_코드는_그대로() -> None:
handoff = _handoff()
coded = [row for row in handoff["work_items"] if row.get("work_item_code")]
assert len(coded) >= 5
for row in coded:
assert row["work_item_key"] == work_item_key(row["work_item_code"], row["pum_edition"])
assert row["work_item_code"].startswith(("FP-", "AX-")) # 사람이 보는 번호는 남음
assert handoff["unkeyed_work_item_codes"] == []
assert all("work_item_key" not in row for row in handoff["materials"]) # 자재는 공종 축 아님
def test_못_찾은_코드는_지어내지_않고_목록() -> None:
rows = [
{"work_item_code": "FP-99-99"},
{"work_item_code": None},
{"work_item_code": "FP-09-18"},
]
assert attach_keys(rows) == ["FP-99-99"]
assert rows[0]["work_item_key"] is None and rows[1]["work_item_key"] is None
assert rows[2]["work_item_key"].startswith("FW-")
def test_B09_입구는_옛_인계에도_같은_열쇠() -> None:
new, _ = parse_handoff(_handoff())
old, _ = parse_handoff(_strip_keys(copy.deepcopy(_handoff())))
assert [w.work_item_key for w in old] == [w.work_item_key for w in new]
assert any(w.work_item_key.startswith("FW-") for w in new)
def test_금액_불변_열쇠가_있든_없든_내역서가_한_원도_안_다름() -> None:
"""⭐ 이번 일감의 잣대(브레인) — 새 인계(열쇠 있음)와 옛 인계(열쇠 없음)로 세운 내역서가 같음."""
build = build_unit_prices()
new = build_bill(copy.deepcopy(_handoff()), build=build)
old = build_bill(_strip_keys(copy.deepcopy(_handoff())), build=build)
assert bill_summary(new) == bill_summary(old)
assert [row.as_dict() for row in new.rows] == [row.as_dict() for row in old.rows]
assert [row.as_dict() for row in new.material_rows] == [
row.as_dict() for row in old.material_rows
]
priced = [row for row in new.rows if not row.is_group and row.amount_krw]
assert len(priced) >= 3 and new.body_total_krw > 0 # 금액이 실제로 선 줄로 잼
+36
View File
@@ -113,3 +113,39 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
json={"values": {"name": "바꿈"}},
)
assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"]
def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestClient) -> None:
"""화면(서브 b890fba0)이 `filters` 를 받으면 상자를 세우고 고른 값만 보냄 — 판정은 서버 한 곳.
건설 부문 다섯 · 줄 구실(이름은 axis_policy.json). ⚠ 「임도가 쓰는 것」은 잣대가 없어 안 냄."""
const = _get(client, "work_item_const", size=1)
by_key = {f["key"]: f for f in const["filters"]}
assert set(by_key) == {"division", "axis_role"}
division = by_key["division"]
assert division["default"] == ""
assert division["options"][0] == {"value": "", "label": "전부", "rows": const["total"]}
assert [o["value"] for o in division["options"][1:]] == [
"공통부문",
"토목부문",
"건축부문",
"기계설비부문",
"유지관리부문",
]
assert sum(o["rows"] for o in division["options"][1:]) == const["total"]
assert {o["value"]: o["label"] for o in by_key["axis_role"]["options"]}[
"general_provision"
] == "총칙"
civil = _get(client, "work_item_const", size=500, division="토목부문")
assert civil["total"] == next(
o["rows"] for o in division["options"] if o["value"] == "토목부문"
)
assert all(r["name"].startswith("토목부문 › ") for r in civil["rows"])
rules = _get(
client, "work_item_const", size=500, division="공통부문", axis_role="general_provision"
)
assert rules["total"] > 0
assert all(r["name"].startswith("공통부문 › 적용기준") for r in rules["rows"])
assert [f["key"] for f in _get(client, "work_item_forest", size=1)["filters"]] == ["axis_role"]
assert _get(client, "labor", size=1, division="토목부문")["total"] == 261 # 다른 표는 안 거름