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

This commit is contained in:
2026-09-17 16:50:37 +09:00
13 changed files with 1080 additions and 21 deletions
@@ -249,6 +249,11 @@ def build_handoff(
result["pum_edition"] = table.pum_edition or None
for row in work_items:
row.setdefault("pum_edition", table.pum_edition or None)
# 불변 열쇠 길목(2026-09-17 8-6 이행) — 줄마다 `work_item_key`(FW-·AX-) 를 더함.
# `work_item_code` 는 사람이 보는 목차 번호로 그대로 · 못 찾은 코드는 목록으로(지어내지 않음).
from common_util.common_util_work_item_key import attach_keys
result["unkeyed_work_item_codes"] = attach_keys(work_items)
return result
@@ -14,6 +14,8 @@ from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from common_util.common_util_work_item_key import work_item_key
_ZERO = Decimal(0)
#: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인).
@@ -78,6 +80,9 @@ class HandoffWorkItem:
spec_class_basis: str = ""
#: 이 줄의 공종 코드가 본 품셈 판 — 마스터 판과 다르면 값을 안 씀(명세 17장).
pum_edition: str = ""
#: 불변 열쇠(FW-·AX-) — 목차 번호(`work_item_code`)가 개정으로 밀려도 안 바뀜(8-6 이행 길목).
#: ⚠ 금액 셈은 아직 `work_item_code` 로 함 — 이 칸은 더하기만(630곳 안 고침).
work_item_key: str = ""
@property
def display_name(self) -> str:
@@ -149,6 +154,10 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[
spec_class=row.get("spec_class") or "",
spec_class_basis=row.get("spec_class_basis") or "",
pum_edition=str(row.get("pum_edition") or ""),
# 옛 인계(열쇠 없음)도 같은 길목으로 채움
work_item_key=row.get("work_item_key")
or work_item_key(row.get("work_item_code"), row.get("pum_edition") or None)
or "",
)
for row in payload["work_items"]
]
@@ -511,6 +511,7 @@ def table(
q: str = "",
sort: str = "",
desc: bool = False,
filters: dict[str, str] | None = None,
) -> dict[str, Any]:
all_rows = rows(kind)
columns = _columns(kind, all_rows)
@@ -524,6 +525,8 @@ def table(
if needle
else all_rows
)
if kind in work_items.KINDS: # 서버가 준 거르기(부문·줄 구실) — 고른 값만 옴
hits = work_items.apply_filters(kind, hits, filters or {})
if sort: # ⚠ 거른 뒤에 세움 — 거꾸로 하면 차례가 깨짐(브레인 ③)
if sort not in columns:
raise SortError(f"없는 열로 세울 수 없음: {sort}")
+9 -3
View File
@@ -3,7 +3,7 @@
GET /api/master-data/tree 갈래 → 파일 → 표
GET /api/master-data/rows?file=&table=&page=&size=&q= 표 줄(쪽 나누기 · 검색)
GET /api/master-data/base-prices 표 목록(갈래·kind·이름·줄 수 · 공종 축 산림·건설 포함)
GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc= 한 표(기초단가 다섯 · 품셈 기준 열)
GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc=[&거르기=값] 한 표(기초단가 다섯 · 품셈 기준 열 · 공종 축 둘)
PUT /api/master-data/base-prices/{kind}/{row_id} {values:{열:값}} → 덮개에만 씀 · null = 되돌리기
GET /api/master-data/overrides 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬)
⚠ 권한은 등록하는 쪽(`main.py` · 랩탑 서브)이 `dependencies=[verify_session, require_system_admin]` 로 붙임.
@@ -13,13 +13,14 @@ from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
from Z01_MasterData import Z01_MasterData_Tables as tables
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
router = APIRouter(prefix="/api/master-data", tags=["Z01 MasterData"])
@@ -58,14 +59,19 @@ def get_base_price_kinds() -> dict:
@router.get("/base-prices/{kind}")
def get_base_prices(
kind: str,
request: Request,
page: int = 1,
size: int = tables.DEFAULT_PAGE_SIZE,
q: str = "",
sort: str = "",
desc: int = 0,
) -> dict:
# 거르기 — 표가 준 `filters` 의 key 만 받음(공종 축 부문·줄 구실)
picked = {k: v for k, v in request.query_params.items() if k in work_items.FILTER_KEYS}
try:
return base_prices.table(_kind(kind), page=page, size=size, q=q, sort=sort, desc=bool(desc))
return base_prices.table(
_kind(kind), page=page, size=size, q=q, sort=sort, desc=bool(desc), filters=picked
)
except base_prices.SortError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@@ -144,10 +144,74 @@ def notice(kind: str) -> list[str]:
return lines
#: 거르기 — **자료에 있는 가름만**(건설 부문 · 줄 구실). 어느 줄을 거를지는 서버 한 곳 · 화면은 고른 값만 보냄.
#: ⚠ 「임도가 쓰는 것」 같은 쓰임 가름은 잣대가 아직 없어 안 냄(지어내지 않음).
FILTER_KEYS = ("division", "axis_role")
ALL = ""
#: 값을 안 보냈을 때 거는 값 — 총칙 줄은 기본 감춤 · 「전부」를 고르면 보임(2026-09-17 브레인)
DEFAULTS = {"axis_role": "work_item"}
def _facets(kind: str) -> dict[str, dict[str, str]]:
found = doc(kind)
items = found[0].get("work_items") or [] if found else []
return {_key(i): {key: str(i.get(key) or "") for key in FILTER_KEYS} for i in items}
def _role_names() -> dict[str, str]:
"""줄 구실 이름 — `axis_policy.json`(데스크탑 서브 잣대) 한 곳에서."""
path = FOLDER / "axis_policy.json"
if not path.is_file():
return {}
roles = _read(str(path), path.stat().st_mtime_ns).get("roles") or {}
return {key: str(role.get("name_ko") or key) for key, role in roles.items()}
def filters(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""거르기 상자 — 값이 둘 이상 갈리는 가름만 · 기본은 `DEFAULTS`(그 값이 표에 있을 때) 아니면 「전부」."""
facets = _facets(kind)
labels = {"division": ("부문", {}), "axis_role": ("줄 구실", _role_names())}
out = []
for key in FILTER_KEYS:
counts: dict[str, int] = {}
for row in rows:
value = facets.get(row["@id"], {}).get(key, ALL)
counts[value] = counts.get(value, 0) + 1
values = [v for v in counts if v != ALL]
if len(values) < 2:
continue
label, names = labels[key]
default = DEFAULTS.get(key, ALL)
out.append(
{
"key": key,
"label": label,
"options": [{"value": ALL, "label": "전부", "rows": len(rows)}]
+ [{"value": v, "label": names.get(v, v), "rows": counts[v]} for v in values],
"default": default if default in values else ALL,
}
)
return out
def apply_filters(
kind: str, rows: list[dict[str, Any]], picked: dict[str, str]
) -> list[dict[str, Any]]:
facets = _facets(kind)
# 안 보낸 가름은 상자 기본값으로 — 상자에 보이는 값과 걸린 값이 같게(찾기 결과가 아니라 표 전체로 정함)
defaults = {f["key"]: f["default"] for f in filters(kind, base_rows(kind))}
for key in FILTER_KEYS:
value = picked.get(key, defaults.get(key, ALL))
if value != ALL:
rows = [r for r in rows if facets.get(r["@id"], {}).get(key) == value]
return rows
def extra(kind: str) -> dict[str, Any]:
"""표 수준 덧붙임 — 계산 규칙 · 줄 수 · 갈래 편 수(쪽 나누기와 무관하게 전체)."""
rows = base_rows(kind)
return {
"filters": filters(kind, rows),
"rules": rules(kind),
"stats": {
"work_items": len(rows),
+68
View File
@@ -0,0 +1,68 @@
"""공종 불변 열쇠 **길목** — 목차 코드(`FP-`·`CP-`) → 불변 열쇠(`FW-`·`CW-`) (2026-09-17 PLAN_공종축 8-6 이행).
코덱스 조사(FP- 참조 630곳 · 77파일)의 결론대로 **경계 두 곳에서만** 부름 — 630곳은 안 고침.
B08 인계 출구 `B08_Quantity_Engine_Handoff.build_handoff` 줄마다 `work_item_key` 를 더함
B09 인계 입구 `B09_Estimation_BillOfQuantities_Input.parse_handoff` 옛 인계(열쇠 없음)에도 채움
`work_item_code` 는 **그대로 남김** — 사람이 보는 목차 번호(화면·로그에 보이는 것이 값짐).
정본은 공종 마스터 파일의 줄(`work_item_code` ↔ `work_item_key` · 판 `pum_edition`) — 장부를 따로 안 읽음
(마스터가 장부로 지은 결과라 두 벌이 안 됨).
⚠ `AX-WK-`·`AX-ST-` 는 난수 8자리(명세 2장 ④ · 동등 비교만)라 **이미 불변** — 그대로 열쇠.
⚠ 못 찾은 코드는 `None` — 지어내지 않음. 받는 쪽이 목록으로 드러냄(`unkeyed_work_item_codes`).
⚠ 한 판 안에서 같은 목차 코드가 두 줄이면(목차 오기) 어느 열쇠인지 못 가름 → `None`.
"""
from __future__ import annotations
import json
import re
from functools import lru_cache
from pathlib import Path
from typing import Any, Iterable
MASTER_DIR = Path(__file__).resolve().parent.parent / "resources" / "data_work_item_master"
#: 우리가 세운 공종·구조물 — 난수 코드가 곧 불변 열쇠
_STABLE_CODE = re.compile(r"^AX-(?:WK|ST)-[0-9a-f]{8}$")
@lru_cache(maxsize=8)
def _read(path: str, _mtime_ns: int) -> dict[tuple[str, str], str | None]:
doc = json.loads(Path(path).read_text(encoding="utf-8"))
edition = str(doc.get("pum_edition") or doc.get("effective_date") or "")
keys: dict[tuple[str, str], str | None] = {}
for item in doc.get("work_items") or []:
slot = (edition, str(item.get("work_item_code") or ""))
keys[slot] = None if slot in keys else item.get("work_item_key")
return keys
def _keys() -> dict[tuple[str, str], str | None]:
out: dict[tuple[str, str], str | None] = {}
for path in sorted(MASTER_DIR.glob("*work_item_master_*.json")): # 산림 + 건설(`const_`)
out.update(_read(str(path), path.stat().st_mtime_ns))
return out
def work_item_key(code: str | None, pum_edition: str | None = None) -> str | None:
"""목차 코드 → 불변 열쇠. 판을 모르면 코드가 한 판에만 있을 때만 줌."""
if not code:
return None
if _STABLE_CODE.match(code):
return code
keys = _keys()
if pum_edition:
return keys.get((pum_edition, code))
found = {key for (_, each), key in keys.items() if each == code}
return found.pop() if len(found) == 1 else None
def attach_keys(rows: Iterable[dict[str, Any]]) -> list[str]:
"""인계 줄마다 `work_item_key` 를 더함(코드 없는 줄은 `None` — 칸은 늘 있음 · 인계 계약) ·
코드는 있는데 열쇠를 못 찾은 코드 목록을 돌려줌."""
missing: set[str] = set()
for row in rows:
code = row.get("work_item_code")
row["work_item_key"] = work_item_key(code, row.get("pum_edition"))
if code and row["work_item_key"] is None:
missing.add(str(code))
return sorted(missing)
@@ -1931,13 +1931,13 @@
"counts": {
"merged_tables": 17,
"merged_columns": 172,
"files": 34,
"tables": 92,
"columns": 471,
"value_groups": 177,
"files": 41,
"tables": 100,
"columns": 532,
"value_groups": 202,
"unknown": 0,
"value_groups_by_kind": {
"doc": 111,
"doc": 136,
"value": 66
}
},
@@ -7038,6 +7038,675 @@
"kind": "doc"
}
]
},
{
"file_id": "work_item_master_const",
"path": "resources/data_work_item_master/const_work_item_master_2026-01-01.json",
"name_ko": "건설 공종 마스터",
"summary": "건설공사 표준품셈 목차를 나무로 세운 벌. 줄의 열쇠는 `work_item_key`(CW-…)이고 목차 코드(CP-…)·번호·판은 칸이다.",
"kind": "logic",
"tables": [
{
"key": "work_items",
"name_ko": "공종",
"summary": "건설 공종 나무. 부문 다섯이 뿌리이고, 부문마다 장 번호가 1부터 다시 시작한다.",
"shape": "list",
"rows": 1367,
"columns": [
{
"key": "work_item_code",
"name_ko": "공종코드",
"unit": "",
"visible": true
},
{
"key": "number",
"name_ko": "품셈 번호",
"unit": "",
"visible": true
},
{
"key": "name",
"name_ko": "이름",
"unit": "",
"visible": true
},
{
"key": "division",
"name_ko": "부문",
"unit": "",
"visible": true
},
{
"key": "level",
"name_ko": "단계",
"unit": "",
"visible": true
},
{
"key": "parent_code",
"name_ko": "상위 공종코드",
"unit": "",
"visible": true
},
{
"key": "sort_order",
"name_ko": "정렬 차례",
"unit": "",
"visible": false
},
{
"key": "tables",
"name_ko": "딸린 품셈 표",
"unit": "",
"visible": true
},
{
"key": "parent_mode",
"name_ko": "하위 고르는 방식",
"unit": "",
"visible": true
},
{
"key": "variant_keys",
"name_ko": "갈래 키",
"unit": "",
"visible": true
},
{
"key": "work_item_key",
"name_ko": "공종 열쇠",
"unit": "",
"visible": true
},
{
"key": "toc_code",
"name_ko": "그 판 목차 코드",
"unit": "",
"visible": true
},
{
"key": "toc_number",
"name_ko": "그 판 목차 번호",
"unit": "",
"visible": true
},
{
"key": "toc_edition",
"name_ko": "목차 판(고시)",
"unit": "",
"visible": false
},
{
"key": "path_name",
"name_ko": "전체 경로 이름",
"unit": "",
"visible": true
},
{
"key": "axis_role",
"name_ko": "줄 구실",
"unit": "",
"visible": true
}
]
}
],
"value_groups": [
{
"key": "toc_edition",
"name_ko": "목차 판(고시)",
"summary": "목차 번호가 묶인 고시 판. 값이 아니라 **판을 가리키는 표**다.",
"kind": "doc"
},
{
"key": "policy",
"name_ko": "방침",
"summary": "이 벌을 다룰 때 지킬 것 — 지어내지 않기·빈칸 안 두기 같은 것.",
"kind": "doc"
},
{
"key": "stats",
"name_ko": "집계",
"summary": "몇 줄이 섰고 몇 줄이 빠졌는지 센 것.",
"kind": "doc"
},
{
"key": "toc_corrections",
"name_ko": "목차 오기 바로잡음",
"summary": "목차 번호가 본문과 다른 자리를 본문 번호로 고친 기록.",
"kind": "doc"
},
{
"key": "toc_duplicate_rows",
"name_ko": "목차 번호가 겹친 줄",
"summary": "목차가 같은 번호를 두 번 적었는데 본문 번호로도 못 가른 자리. 비어 있어야 정상 — 줄이 생기면 표가 딴 줄에 붙는다.",
"kind": "doc"
},
{
"key": "orphan_tables",
"name_ko": "공종에 못 붙인 표",
"summary": "목차 어디에도 못 붙인 품셈 표.",
"kind": "doc"
}
]
},
{
"file_id": "const_form_undetermined",
"path": "resources/data_work_item_master/const_form_undetermined_2026-01-01.json",
"name_ko": "건설 형태 미판정 표",
"summary": "생산량형인지 소요량형인지 못 정한 건설 표. 사람이 보고 확정할 자리다.",
"kind": "byproduct",
"tables": [
{
"key": "items",
"name_ko": "형태 미판정 표",
"summary": "생산량형인지 소요량형인지 못 정한 표.",
"shape": "list",
"rows": 881,
"columns": [
{
"key": "pum_table_id",
"name_ko": "품셈 표 번호",
"unit": "",
"visible": true
},
{
"key": "section",
"name_ko": "품셈 절",
"unit": "",
"visible": true
},
{
"key": "headers",
"name_ko": "표 머리",
"unit": "",
"visible": true
},
{
"key": "first_rows",
"name_ko": "첫 줄들",
"unit": "",
"visible": true
},
{
"key": "reason",
"name_ko": "사유",
"unit": "",
"visible": true
}
]
}
],
"value_groups": [
{
"key": "note",
"name_ko": "설명",
"summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.",
"kind": "doc"
}
]
},
{
"file_id": "const_basis_missing",
"path": "resources/data_work_item_master/const_basis_missing_2026-01-01.json",
"name_ko": "건설 밑수 미확보 표",
"summary": "「10㎡당」 같은 기준 수량을 못 찾은 건설 표. ⚠ 1 단위당으로 단정하면 곱셈이 10배·100배 틀린다.",
"kind": "byproduct",
"tables": [
{
"key": "items",
"name_ko": "밑수 미확보 표",
"summary": "기준 수량을 못 찾은 표.",
"shape": "list",
"rows": 447,
"columns": [
{
"key": "pum_table_id",
"name_ko": "품셈 표 번호",
"unit": "",
"visible": true
},
{
"key": "section",
"name_ko": "품셈 절",
"unit": "",
"visible": true
},
{
"key": "pum_form",
"name_ko": "품셈 표 형태",
"unit": "",
"visible": true
},
{
"key": "line",
"name_ko": "원문 줄 번호",
"unit": "",
"visible": false
}
]
}
],
"value_groups": [
{
"key": "note",
"name_ko": "설명",
"summary": "이 벌이 무엇이고 어떻게 쓰는지 적은 글.",
"kind": "doc"
}
]
},
{
"file_id": "work_item_keys",
"path": "resources/data_work_item_master/work_item_keys.json",
"name_ko": "산림 공종 열쇠 장부",
"summary": "산림 공종의 불변 열쇠(FW-…) 장부. 판이 바뀌어도 이 열쇠는 그 공종 것이다.",
"kind": "logic",
"tables": [
{
"key": "keys",
"name_ko": "열쇠 발급 내역",
"summary": "열쇠마다 처음 낸 판·날짜·목차 코드·이름.",
"shape": "map",
"rows": 477,
"columns": [
{
"key": "@key",
"name_ko": "산림 공종 열쇠",
"unit": "",
"visible": true,
"is_row_key": true
},
{
"key": "issued_edition",
"name_ko": "처음 낸 판",
"unit": "",
"visible": true
},
{
"key": "issued_on",
"name_ko": "처음 낸 날",
"unit": "",
"visible": true
},
{
"key": "first_toc_code",
"name_ko": "처음 목차 코드",
"unit": "",
"visible": true
},
{
"key": "first_toc_number",
"name_ko": "처음 목차 번호",
"unit": "",
"visible": true
},
{
"key": "first_name",
"name_ko": "처음 이름",
"unit": "",
"visible": true
},
{
"key": "relinked",
"name_ko": "손으로 이은 자리",
"unit": "",
"visible": true
}
]
}
],
"value_groups": [
{
"key": "note",
"name_ko": "메모",
"summary": "이 벌이 무엇인지 적어 둔 글.",
"kind": "doc"
},
{
"key": "policy",
"name_ko": "방침",
"summary": "이 벌을 다룰 때 지킬 것.",
"kind": "doc"
},
{
"key": "key_prefix",
"name_ko": "열쇠 앞꼬리",
"summary": "산림 FW · 건설 CW.",
"kind": "doc"
},
{
"key": "key_digits",
"name_ko": "열쇠 자릿수",
"summary": "다섯 자리.",
"kind": "doc"
},
{
"key": "next_serial",
"name_ko": "다음 일련번호",
"summary": "다음에 낼 열쇠 번호.",
"kind": "doc"
},
{
"key": "editions",
"name_ko": "판별 자리→열쇠",
"summary": "고시 판마다 목차 자리와 열쇠를 이은 사전. 낱값이 아니라 이음표다.",
"kind": "doc"
}
]
},
{
"file_id": "work_item_keys_const",
"path": "resources/data_work_item_master/work_item_keys_const.json",
"name_ko": "건설 공종 열쇠 장부",
"summary": "건설 공종의 불변 열쇠(CW-…) 장부. 산림 장부와 일련번호를 섞지 않는다.",
"kind": "logic",
"tables": [
{
"key": "keys",
"name_ko": "열쇠 발급 내역",
"summary": "열쇠마다 처음 낸 판·날짜·목차 코드·이름.",
"shape": "map",
"rows": 1367,
"columns": [
{
"key": "@key",
"name_ko": "건설 공종 열쇠",
"unit": "",
"visible": true,
"is_row_key": true
},
{
"key": "issued_edition",
"name_ko": "처음 낸 판",
"unit": "",
"visible": true
},
{
"key": "issued_on",
"name_ko": "처음 낸 날",
"unit": "",
"visible": true
},
{
"key": "first_toc_code",
"name_ko": "처음 목차 코드",
"unit": "",
"visible": true
},
{
"key": "first_toc_number",
"name_ko": "처음 목차 번호",
"unit": "",
"visible": true
},
{
"key": "first_name",
"name_ko": "처음 이름",
"unit": "",
"visible": true
}
]
}
],
"value_groups": [
{
"key": "note",
"name_ko": "메모",
"summary": "이 벌이 무엇인지 적어 둔 글.",
"kind": "doc"
},
{
"key": "policy",
"name_ko": "방침",
"summary": "이 벌을 다룰 때 지킬 것.",
"kind": "doc"
},
{
"key": "key_prefix",
"name_ko": "열쇠 앞꼬리",
"summary": "산림 FW · 건설 CW.",
"kind": "doc"
},
{
"key": "key_digits",
"name_ko": "열쇠 자릿수",
"summary": "다섯 자리.",
"kind": "doc"
},
{
"key": "next_serial",
"name_ko": "다음 일련번호",
"summary": "다음에 낼 열쇠 번호.",
"kind": "doc"
},
{
"key": "editions",
"name_ko": "판별 자리→열쇠",
"summary": "고시 판마다 목차 자리와 열쇠를 이은 사전. 낱값이 아니라 이음표다.",
"kind": "doc"
}
]
},
{
"file_id": "work_item_axis_policy",
"path": "resources/data_work_item_master/axis_policy.json",
"name_ko": "공종 축 줄 구실 잣대",
"summary": "어느 줄이 공종이고 어느 줄이 총칙·묶는 마디인지 가르는 잣대. ⚠ 지우는 잣대가 아니라 **가르는** 잣대다.",
"kind": "logic",
"tables": [
{
"key": "roles",
"name_ko": "줄 구실 넷",
"summary": "공종·묶는 마디·총칙·빈 잎 — 각각 무엇이고 남기는지.",
"shape": "map",
"rows": 4,
"columns": [
{
"key": "@key",
"name_ko": "구실 이름",
"unit": "",
"visible": true,
"is_row_key": true
},
{
"key": "name_ko",
"name_ko": "한글 이름",
"unit": "",
"visible": true
},
{
"key": "rule",
"name_ko": "가르는 규칙",
"unit": "",
"visible": true
},
{
"key": "keep",
"name_ko": "남기나",
"unit": "",
"visible": true
},
{
"key": "why",
"name_ko": "까닭",
"unit": "",
"visible": true
}
]
},
{
"key": "axes",
"name_ko": "갈래별 잣대",
"summary": "산림·건설 각각의 코드 꼴·열쇠 앞꼬리·총칙 뿌리.",
"shape": "map",
"rows": 2,
"columns": [
{
"key": "@key",
"name_ko": "갈래 이름",
"unit": "",
"visible": true,
"is_row_key": true
},
{
"key": "name_ko",
"name_ko": "한글 이름",
"unit": "",
"visible": true
},
{
"key": "settled",
"name_ko": "확정됐나",
"unit": "",
"visible": true
},
{
"key": "source",
"name_ko": "출처",
"unit": "",
"visible": true
},
{
"key": "toc_source",
"name_ko": "계층 근거",
"unit": "",
"visible": true
},
{
"key": "code_prefix",
"name_ko": "목차 코드 앞꼬리",
"unit": "",
"visible": true
},
{
"key": "key_prefix",
"name_ko": "열쇠 앞꼬리",
"unit": "",
"visible": true
},
{
"key": "general_provision_roots",
"name_ko": "총칙 뿌리",
"unit": "",
"visible": true
},
{
"key": "general_provision_basis",
"name_ko": "총칙 뿌리 근거",
"unit": "",
"visible": true
},
{
"key": "code_shape",
"name_ko": "목차 코드 꼴",
"unit": "",
"visible": true
},
{
"key": "code_shape_why",
"name_ko": "코드 꼴 까닭",
"unit": "",
"visible": true
},
{
"key": "general_provision_pending",
"name_ko": "총칙 미확정",
"unit": "",
"visible": true
},
{
"key": "known_defects",
"name_ko": "아는 흠",
"unit": "",
"visible": true
},
{
"key": "roots_are",
"name_ko": "뿌리를 적는 꼴",
"unit": "",
"visible": true
},
{
"key": "counts",
"name_ko": "줄 구실 셈",
"unit": "",
"visible": true
}
]
}
],
"value_groups": [
{
"key": "note",
"name_ko": "메모",
"summary": "이 벌이 무엇인지 적어 둔 글.",
"kind": "doc"
},
{
"key": "policy",
"name_ko": "방침",
"summary": "이 벌을 다룰 때 지킬 것.",
"kind": "doc"
},
{
"key": "decision_order",
"name_ko": "가르는 차례",
"summary": "총칙 → 공종 → 묶는 마디 → 빈 잎 차례로 본다.",
"kind": "doc"
}
]
},
{
"file_id": "manifest_work_item_master_const",
"path": "resources/data_work_item_master/_manifest_const.json",
"name_ko": "건설 공종 축 폴더 지문",
"summary": "건설 공종 축을 어느 판·어느 원문으로 지었는지 못박는 표.",
"kind": "fingerprint",
"tables": [
{
"key": "files",
"name_ko": "실린 파일",
"summary": "이 폴더에 든 파일과 지문.",
"shape": "list",
"rows": 3,
"columns": [
{
"key": "file",
"name_ko": "파일 이름",
"unit": "",
"visible": true
},
{
"key": "sha256",
"name_ko": "파일 지문(SHA-256)",
"unit": "",
"visible": false
},
{
"key": "size_bytes",
"name_ko": "파일 크기",
"unit": "byte",
"visible": false
}
]
}
],
"value_groups": [
{
"key": "built_by",
"name_ko": "만든 자리",
"summary": "이 벌을 지은 코드나 사람.",
"kind": "doc"
},
{
"key": "source",
"name_ko": "출처",
"summary": "어느 원문에서 왔는지.",
"kind": "doc"
}
]
}
],
"columns": {
@@ -53,7 +53,7 @@
"FP-02"
],
"general_provision_basis": "FP-01 적용기준 51줄 · FP-02 소요재료 및 기계손료 27줄. ⚠ 지우면 안 됨 — `B09_Estimation_Consumables.py` 가 FP-02-01-01·FP-02-01-08(연료 F0042·F0043·F0058)과 FP-02-02-01/02/04/05/06(손료계수 F0064~F0069)을 읽는다.",
"counts_2025_82": {
"counts": {
"rows": 477,
"work_item": 304,
"group": 82,
@@ -96,15 +96,15 @@
"밑수 미확보 447 — 곱하면 안 되는 줄이라 받는 쪽이 가려야 함",
"갈래 키 비어 있음 — 랩탑 메인이 뒤로 미룸"
],
"counts_2026": {
"roots_are": "**장 파일 이름**(「01_공통부문/제1장_적용기준.md」). 뽑는 코드가 목차 코드(`CP-01-01`)로 바꿔 준다 — 관리자가 부문 번호를 셀 필요가 없다.",
"counts": {
"rows": 1367,
"work_item": 997,
"group": 270,
"general_provision": 0,
"empty": 100,
"note": "총칙 표시 전 셈(랩탑 메인 첫 산출). 뿌리를 붙이면 `general_provision` 으로 옮겨 간다 — 줄이 줄지는 않는다."
},
"roots_are": "**장 파일 이름**(「01_공통부문/제1장_적용기준.md」). 뽑는 코드가 목차 코드(`CP-01-01`)로 바꿔 준다 — 관리자가 부문 번호를 셀 필요가 없다."
}
}
}
}
@@ -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` 가 들어가면 안 된다(축이 다르다).
+36 -2
View File
@@ -11,6 +11,7 @@
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
@@ -21,7 +22,13 @@ LABELS_PATH = ROOT / "resources" / "data_master_labels" / "labels_2026-01-01.jso
#: 브레인 갈래 나눔표(2026-09-15). 폴더 장부(_manifest)는 그 31 밖이라 따로 센다.
#: 2026-09-16 사용자 판정 — `structure_unit_observed`(울진소광 한 현장 관찰값)와
#: `revetment_sabang`(교본값에 설명 글 섞임)은 기초데이터가 아니라 **참고 사례**다.
BRAIN_KIND_COUNTS = {"logic": 15, "base_value": 9, "byproduct": 4, "seed": 1, "reference": 2}
BRAIN_KIND_COUNTS = {
"logic": 19,
"base_value": 9,
"byproduct": 6,
"reference": 2,
"seed": 1,
}
MANIFEST_PREFIX = "manifest_"
META_KEYS = {
@@ -130,7 +137,9 @@ def test_갈래_나눔이_브레인_표와_같다(labels):
continue
counted[entry["kind"]] = counted.get(entry["kind"], 0) + 1
assert counted == BRAIN_KIND_COUNTS
assert sum(counted.values()) == 31
# 2026-09-17 31 → 37 — 공종 축 산출 일곱을 이름표에 올림(건설 마스터·미판정·밑수 · 열쇠 장부 둘 ·
# 구실 잣대 · 건설 폴더 지문). 지문은 manifest 라 이 셈에서 빠진다.
assert sum(counted.values()) == 37
def test_갈래_이름이_다_풀려_있다(labels):
@@ -192,6 +201,31 @@ def test_표_이름이_비지_않았다(labels):
assert not blank, f"한글 이름이 빈 표: {blank}"
def test_이름이_영문으로_떨어진_칸이_없다(labels):
"""⚠ 빈칸이 아니라 **영문 키가 그대로 이름 자리에 앉은** 것을 잡는다(2026-09-17 데스크탑 서브).
파일을 이름표에 올릴 이름을 지으면 키를 그대로 베끼기 쉬운데, 그러면 화면에
`general_provision_roots` 같은 영문이 뜬다. 한글이 글자도 없으면 이름을 지은 것이다.
"""
hangul = re.compile(r"[가-힣]")
bad = []
for entry in labels["files"]:
for table in entry["tables"]:
for column in table["columns"]:
if not hangul.search(column["name_ko"]):
bad.append(f"{entry['file_id']}::{table['key']}/{column['key']}")
if not hangul.search(table["name_ko"]):
bad.append(f"{entry['file_id']}::{table['key']}")
for group in entry["value_groups"]:
if not hangul.search(group["name_ko"]):
bad.append(f"{entry['file_id']}::{group['key']}")
for merged in labels["merged_tables"]:
for column in merged["columns"]:
if not hangul.search(column["name_ko"]):
bad.append(f"{merged['key']}/{column['key']}")
assert not bad, f"영문으로 뜨는 칸: {bad}"
def test_값_묶음_이름이_비지_않았다(labels):
blank = [
f"{entry['file_id']}::{group['key']}"
+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 # 금액이 실제로 선 줄로 잼
+6 -5
View File
@@ -50,12 +50,13 @@ def test_갈래는_이름표_차례_파일_34_강우_캐시는_없음(client: Te
assert [g["key"] for g in groups] == [k for k in LABELS["kinds"]]
counts = {g["key"]: len(g["files"]) for g in groups}
# 2026-09-16 사용자 판정 — 한 현장 관찰값·교본 설명 섞임 둘은 참고 사례로 옮겼다.
# 2026-09-17 공종 축 산출 일곱을 올림 — logic 15 → 19 · byproduct 4 → 6 · fingerprint 3 → 4
assert counts == {
"logic": 15,
"logic": 19,
"base_value": 9,
"byproduct": 4,
"byproduct": 6,
"reference": 2,
"fingerprint": 3,
"fingerprint": 4,
"seed": 1,
}
assert {g["key"]: g["label"] for g in groups}["base_value"] == "기초값"
@@ -83,8 +84,8 @@ def test_트리_표_id_와_이름표_표_id_는_양쪽으로_같음(client: Test
label_only += [f"{entry['file_id']}::{i}" for i in sorted(label_ids - tree_ids)]
assert not_labelled == [], not_labelled
assert label_only == [], label_only
# 2026-09-17 9192 — 공종 마스터가 `toc_duplicate_rows`(목차 번호가 겹친 줄) 를 새로 냄.
assert sum(len(t) for t in files.values()) == LABELS["counts"]["tables"] == 92
# 2026-09-17 92100 — 공종 축 산출 일곱을 이름표에 올림.
assert sum(len(t) for t in files.values()) == LABELS["counts"]["tables"] == 100
for entry in LABELS["files"]: # 이름이 실제로 붙음 · 줄 수도 이름표 셈과 같음
named = {t["key"]: t for t in entry["tables"]}
for t in files[entry["file_id"]]:
+60 -2
View File
@@ -40,7 +40,7 @@ def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> Non
forest = next(i for i in listed if i["kind"] == "work_item_forest")
assert forest["group"] == "work_item" and forest["rows"] == 365
table = _get(client, "work_item_forest", size=500)
table = _get(client, "work_item_forest", size=500, axis_role="") # 「전부」 — 총칙까지
assert table["total"] == 365 and table["stats"] == {"work_items": 365, "units": 627}
assert table["editable"] == [] and set(table["locked"]) == set(table["sortable"])
rows = table["rows"]
@@ -67,7 +67,7 @@ def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> Non
def test_건설_공종_축도_같은_길(client: TestClient) -> None:
"""건설(2026-09-17 뽑음) — 부문이 뿌리라 경로가 부문부터 · 열쇠 CW- · 계산 규칙은 전부 choose_one."""
table = _get(client, "work_item_const", size=500)
table = _get(client, "work_item_const", size=500, axis_role="")
assert table["total"] == table["stats"]["work_items"] > 900
rows = table["rows"]
assert all(r["@id"].startswith("CW-") for r in rows)
@@ -113,3 +113,61 @@ 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, axis_role="")
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="토목부문", axis_role="")
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 # 다른 표는 안 거름
def test_총칙_줄은_기본_감춤_전부를_고르면_보임(client: TestClient) -> None:
"""2026-09-17 브레인 — 값을 안 보내면 상자 기본값(공종)으로 거름 · 화면은 그 기본값을 상자에 보임."""
for kind in ("work_item_forest", "work_item_const"):
shown = _get(client, kind, size=500)
role = next(f for f in shown["filters"] if f["key"] == "axis_role")
assert role["default"] == "work_item"
counts = {o["value"]: o["rows"] for o in role["options"]}
assert counts["general_provision"] > 0
# 기본 = 공종만 · 찾기 결과로 기본이 흔들리지 않음(총칙만 걸리는 낱말도 기본은 감춤)
assert shown["total"] == counts["work_item"]
assert (
_get(client, kind, size=500, q="적용기준")["total"]
< _get(client, kind, size=500, q="적용기준", axis_role="")["total"]
)
# 「전부」(빈 값을 보냄) → 총칙까지
assert _get(client, kind, size=1, axis_role="")["total"] == counts[""]
assert (
_get(client, kind, size=1, axis_role="general_provision")["total"]
== (counts["general_provision"])
)