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

이름표 충돌 해소 — 같은 자리는 랩탑 쪽(`merged_tables` 의 `work_item_forest`)을 취하고,
내 쪽에서 빠진 것만 되살림:

- `work_item_master` 새 표 `toc_duplicate_rows` · 새 값 묶음 `toc_edition`
- `work_items` 새 칸 여섯(work_item_key·toc_code·toc_number·toc_edition·path_name·axis_role)
- 공용 `columns` 여덟 — 랩탑엔 없어 영문으로 뜨던 것
- `value_overrides` — axis_role 넷 · rule 둘 낱말
- 내 `work_item_axis` 구획은 버림(두 곳에 같은 이야기를 두지 않음)

시험 둘 손질 — 열쇠가 실제로 오면서 뜻이 뒤집힌 자리:
- `test_z01_work_items.py` 「불변 열쇠 없음」 알림이 **사라지는 것**이 곧 열쇠가 섰다는 증거
- `test_z01_master_data.py` 표 수 91 → 92 (`toc_duplicate_rows`)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsGw1Dz9HmhuAxGisxmDPF
This commit is contained in:
2026-09-17 12:16:45 +09:00
co-authored by Claude Opus 5
10 changed files with 531 additions and 120 deletions
+14 -2
View File
@@ -20,9 +20,10 @@ from Z01_MasterData import Z01_MasterData_BasePrices_Machine as machine
from Z01_MasterData import Z01_MasterData_BasePrices_Tables as base_tables
from Z01_MasterData import Z01_MasterData_Overrides as overrides
from Z01_MasterData import Z01_MasterData_Tables as tables
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
#: 기초단가 다섯 + 기초데이터 아홉(품셈 근거 8 · 돌쌓기 갈래 1) — 이름은 자료 이름 그대로
KINDS = ("labor", "machine", "material", "oil", "rate", *base_tables.SPEC)
#: 기초단가 다섯 + 기초데이터 열 + 공종 축 둘(산림·건설) — 이름은 자료 이름 그대로
KINDS = ("labor", "machine", "material", "oil", "rate", *base_tables.SPEC, *work_items.KINDS)
SOURCES_DIR = (
tables.RESOURCES / "data_master_sources"
) # 출처표(데스크탑 서브) — 어디서 받고 최신이 무엇인지
@@ -276,6 +277,8 @@ def base_rows(kind: str) -> list[dict[str, Any]]:
if kind == "machine":
doc, name = _doc("mach_base")
return machine.base_rows(name, edition_of(doc))
if kind in work_items.KINDS:
return work_items.base_rows(kind)
spec_of = base_tables.SPEC.get(kind)
if spec_of:
doc, name = _doc(spec_of["file"])
@@ -360,6 +363,8 @@ _EXTRA_NOTICE = {
def notice(kind: str) -> list[str]:
"""표 수준 알림 — 화면이 따로 가지면 문구가 두 벌이 됨(브레인 ③)."""
if kind in work_items.KINDS:
return work_items.notice(kind)
return [_SCOPE_NOTICE] + ([_EXTRA_NOTICE[kind]] if kind in _EXTRA_NOTICE else [])
@@ -367,6 +372,8 @@ def spec(kind: str, columns: list[str]) -> dict[str, Any]:
"""표 수준 — editable · locked · formula."""
if kind == "machine":
return machine.spec(columns)
if kind in work_items.KINDS:
return work_items.spec(columns)
if kind in base_tables.SPEC:
spec_of = base_tables.SPEC[kind]
editable = [c for c in spec_of["editable"] if c in columns]
@@ -408,6 +415,8 @@ BASE_PRICE_GROUP, PUMSEM_GROUP = "base_price", "pumsem_basis"
def kind_group(kind: str) -> str:
if kind in work_items.KINDS:
return work_items.GROUP
return PUMSEM_GROUP if kind in base_tables.SPEC else BASE_PRICE_GROUP
@@ -439,6 +448,8 @@ def row_label(kind: str, row: dict[str, Any] | None) -> str:
return ""
if kind == "labor":
return str(row.get("occupation_name") or "")
if kind in work_items.KINDS:
return str(row.get("name") or "")
if kind == "machine":
return f"{row.get('machine_name') or ''} {row.get('specification') or ''}".strip()
if kind == "material":
@@ -528,6 +539,7 @@ def table(
**spec(kind, columns),
"notice": notice(kind),
"source": sources(kind),
**(work_items.extra(kind) if kind in work_items.KINDS else {}),
}
+1 -1
View File
@@ -2,7 +2,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·이름·줄 수 · 공종 축 산림·건설 포함)
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 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬)
+152
View File
@@ -0,0 +1,152 @@
"""Z01 공종 축 — 산림·건설 두 kind 를 기초단가와 **같은 계약**으로(2026-09-17 브레인 · PLAN_공종축 8-4).
줄 = **잎**(아래 마디 없고 품셈 표가 붙은 마디) — 일위대가가 붙는 단위. 갈래(`variant_keys`)는 칸으로(편 수 = `stats.units`).
이름 = 뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」) — 잎 이름만으론 겹침(산림 358 중 35줄).
계산 규칙 = 표 수준 `rules`(부모 마디 choose_one · sum_steps) + 줄 칸 `rule`·`step_weight`
— 빠지면 발파암(절취 0.1 + 깍기 0.9 + 집토 1) 금액이 틀어짐.
`@id` = 불변 열쇠(FW-·CW- · 데스크탑 서브가 붙이는 중) — 아직 없는 줄은 목차 코드(FP-)로 서고 알림에 드러냄.
⚠ 읽기만 — 고칠 칸 없음(원문 값 풀기 8-7 뒤). 원본 `work_item_code`(FP-)는 B08·B09 가 씀 — 안 건드림.
"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
from Z01_MasterData import Z01_MasterData_Tables as tables
GROUP = "work_item"
#: kind → 원본 품셈 dataset_id — 공종 축 파일 `dataset_version.dataset_id` 로 가림(파일 이름에 기대지 않음)
SOURCE_OF = {"work_item_forest": "pum_forest", "work_item_construction": "pum_const"}
FOLDER = tables.RESOURCES / "data_work_item_master"
#: 불변 열쇠 칸 — ⚠ 데스크탑 서브 자료가 오면 칸 이름을 맞출 것(여기 한 곳)
KEY_FIELD = "work_item_key"
PATH_JOIN = " "
_LOCKED = "공종 축 뼈대 — 품셈 원문으로만 바뀜(값 고치기는 원문 값 풀기 뒤)"
@lru_cache(maxsize=8)
def _read(path: str, _mtime_ns: int) -> dict[str, Any]:
return json.loads(Path(path).read_text(encoding="utf-8"))
def doc(kind: str) -> tuple[dict[str, Any], str] | None:
"""그 품셈의 공종 축 끝 판(문서 · 파일 이름) — 없으면 None(건설은 아직 없음)."""
found = []
for path in sorted(FOLDER.glob("work_item_master_*.json")):
d = _read(str(path), path.stat().st_mtime_ns)
if (d.get("dataset_version") or {}).get("dataset_id") == SOURCE_OF[kind]:
found.append((d, path.name))
return max(found, key=lambda each: each[0].get("effective_date") or "", default=None)
#: 자료가 있는 품셈만 kind 로 섬 — 빈 상자는 이름만 뜨고 안 열림 · 건설은 자료가 오면 서버 재시작 뒤 저절로
KINDS = tuple(kind for kind in SOURCE_OF if doc(kind))
def _key(item: dict[str, Any]) -> str:
return item.get(KEY_FIELD) or item["work_item_code"]
def _tree(d: dict[str, Any]) -> tuple[list[dict], dict[str, dict], dict[str, list[dict]]]:
items = d.get("work_items") or []
children: dict[str, list[dict]] = {}
for item in items:
if item.get("parent_code"):
children.setdefault(item["parent_code"], []).append(item)
return items, {i["work_item_code"]: i for i in items}, children
def _path(item: dict[str, Any] | None, by: dict[str, dict]) -> str:
names = []
while item:
names.append(item["name"])
item = by.get(item.get("parent_code"))
return PATH_JOIN.join(reversed(names))
def base_rows(kind: str) -> list[dict[str, Any]]:
found = doc(kind)
if found is None:
return []
d, name = found
items, by, children = _tree(d)
rows = []
for item in items:
if item["work_item_code"] in children or not item.get("tables"):
continue
parent = by.get(item.get("parent_code")) or {}
weight = next(
(s["weight"] for s in parent.get("steps") or [] if s["code"] == item["work_item_code"]),
None,
)
rows.append(
{
"@id": _key(item),
"@source": name,
"work_item_code": item["work_item_code"], # 목차 코드 — 열쇠 아님, 칸으로만
"number": item["number"],
"name": _path(item, by),
"variant_keys": item.get("variant_keys") or [],
"pum_tables": [t["pum_table_id"] for t in item["tables"]],
"rule": parent.get("parent_mode"),
"step_weight": weight,
"effective_date": d.get("pum_edition") or d.get("effective_date"),
}
)
return rows
def rules(kind: str) -> list[dict[str, Any]]:
"""부모 마디 계산 규칙 — choose_one(아래 중 하나) · sum_steps(아래를 무게대로 더해야 한 단위)."""
found = doc(kind)
if found is None:
return []
items, by, children = _tree(found[0])
out = []
for item in items:
mode = item.get("parent_mode")
if not mode:
continue
rule = {
"@id": _key(item),
"work_item_code": item["work_item_code"],
"name": _path(item, by),
"mode": mode,
"children": [_key(c) for c in children.get(item["work_item_code"], [])],
}
if mode == "sum_steps":
rule["steps"] = [
{"@id": _key(by[s["code"]]), "weight": s["weight"]} for s in item["steps"]
]
rule["basis"] = item.get("steps_basis")
out.append(rule)
return out
def spec(columns: list[str]) -> dict[str, Any]:
return {"editable": [], "locked": dict.fromkeys(columns, _LOCKED), "formula": {}}
def notice(kind: str) -> list[str]:
lines = ["공종 축은 읽기만 — 고칠 칸 없음(품셈 원문 값 풀기 뒤에 열림)"]
found = doc(kind)
if found and any(KEY_FIELD not in i for i in found[0].get("work_items") or []):
lines.append(
"불변 열쇠(FW-·CW-) 아직 없음 — 목차 코드(FP-)를 열쇠로 씀 · 품셈 개정으로 번호가 밀리면 딴 줄을 가리킬 수 있음"
)
return lines
def extra(kind: str) -> dict[str, Any]:
"""표 수준 덧붙임 — 계산 규칙 · 잎 수 · 갈래 편 수(쪽 나누기와 무관하게 전체)."""
leaves = base_rows(kind)
return {
"rules": rules(kind),
"stats": {
"leaves": len(leaves),
"units": sum(max(1, len(r["variant_keys"])) for r in leaves),
},
}