Merge remote-tracking branch 'origin/dev' into CODEX
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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` 가 들어가면 안 된다(축이 다르다).
|
||||
|
||||
@@ -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']}"
|
||||
|
||||
@@ -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 # 금액이 실제로 선 줄로 잼
|
||||
@@ -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 91 → 92 — 공종 마스터가 `toc_duplicate_rows`(목차 번호가 겹친 줄) 를 새로 냄.
|
||||
assert sum(len(t) for t in files.values()) == LABELS["counts"]["tables"] == 92
|
||||
# 2026-09-17 92 → 100 — 공종 축 산출 일곱을 이름표에 올림.
|
||||
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"]]:
|
||||
|
||||
@@ -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"])
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user