diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index 174c5315..ffeb5b8c 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -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 {}), } diff --git a/Z01_MasterData/Z01_MasterData_Router.py b/Z01_MasterData/Z01_MasterData_Router.py index 7179fef1..b9306473 100644 --- a/Z01_MasterData/Z01_MasterData_Router.py +++ b/Z01_MasterData/Z01_MasterData_Router.py @@ -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 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬) diff --git a/Z01_MasterData/Z01_MasterData_WorkItems.py b/Z01_MasterData/Z01_MasterData_WorkItems.py new file mode 100644 index 00000000..43ee4c7a --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_WorkItems.py @@ -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), + }, + } diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json index e73e3729..dd072480 100644 --- a/resources/data_master_labels/labels_2026-01-01.json +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -64,8 +64,8 @@ "summary": "**얼마나 드나** — 품셈·법령이 정해 둔 계수와 갈래(토량환산·할증·거푸집·철근·돌쌓기 따위)." }, "work_item": { - "name_ko": "공종", - "summary": "**무슨 일인가** — 품셈 목차가 세운 공종 나무. 줄의 열쇠는 `work_item_key`(FW·CW), 목차 번호는 칸이다." + "name_ko": "공종 축", + "summary": "**무엇을 하나** — 품셈 목차에서 일위대가가 붙는 잎 공종을 평평하게 편 목록(산림·건설). 읽기만." } }, "merged_tables": [ @@ -1358,6 +1358,69 @@ "note": "원문이 값을 안 준 자리와 우리가 고른 까닭." } ] + }, + { + "key": "work_item_forest", + "name_ko": "산림품셈 기준", + "summary": "산림 품셈 공종 축의 잎 — 일위대가가 붙는 단위. 이름은 전체 경로, 부모 마디 계산 규칙은 표 수준 `rules`.", + "sources": [ + "work_item_master::work_items" + ], + "rows_note": "잎 358 줄 · 갈래 펴면 618", + "columns": [ + { + "key": "work_item_code", + "name_ko": "공종코드", + "unit": "", + "visible": true, + "note": "품셈 목차 코드(FP-). ⚠ 불변 열쇠 아님 — 개정 때 번호가 밀림. 열쇠는 `@id`(FW-)." + }, + { + "key": "number", + "name_ko": "품셈 번호", + "unit": "", + "visible": true + }, + { + "key": "name", + "name_ko": "이름", + "unit": "", + "visible": true, + "note": "뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」) — 잎 이름만으론 겹침." + }, + { + "key": "variant_keys", + "name_ko": "갈래 키", + "unit": "", + "visible": true + }, + { + "key": "pum_tables", + "name_ko": "딸린 품셈 표", + "unit": "", + "visible": true + }, + { + "key": "rule", + "name_ko": "계산 규칙", + "unit": "", + "visible": true, + "note": "부모 마디 방식 — `choose_one`(아래 중 하나) · `sum_steps`(아래를 무게대로 더해야 한 단위)." + }, + { + "key": "step_weight", + "name_ko": "단계 무게", + "unit": "", + "visible": true, + "note": "`sum_steps` 일 때만 — 발파암 절취 0.1 · 깍기 0.9 · 집토 1." + }, + { + "key": "effective_date", + "name_ko": "적용 시작일", + "unit": "", + "visible": true + } + ] } ], "machine_cost_chain": { @@ -1692,6 +1755,15 @@ "work_item_code" ], "checked": "id 칸이 없다. **표마다 축이 다르다** — 속도표는 `table+track+tonnage_ton+gear`, 삽날표는 `table+track+tonnage_ton`, 낱값은 `table+key`, 재료별 계수는 `table+work_item_code`. 서버가 줄마다 `@axis` 로 무엇이 축인지 알려 준다." + }, + { + "table": "work_item_forest", + "name_ko": "산림품셈 기준", + "verdict": "있음(임시) — 지금은 목차 코드 · 불변 열쇠 오면 그 칸", + "key": [ + "work_item_code" + ], + "checked": "잎 358 줄 공종코드 겹침 0(전수 · `test_z01_work_items`). ⚠ 목차 번호라 품셈 개정으로 밀림 — 데스크탑 서브가 붙이는 불변 열쇠(FW-)가 오면 서버가 그 칸을 `@id` 로 씀(`Z01_MasterData_WorkItems.KEY_FIELD`)." } ], "null_is_real": { @@ -1785,8 +1857,8 @@ } }, "counts": { - "merged_tables": 15, - "merged_columns": 156, + "merged_tables": 16, + "merged_columns": 164, "files": 34, "tables": 92, "columns": 472, @@ -8584,124 +8656,92 @@ "name_ko": "갈래 키 계약", "summary": "갈래 키 문자열을 두 창이 각자 조립하지 않기로 한 약속." }, - "work_item_master::work_item": { - "name_ko": "공종 — 품셈 표가 붙은 줄" - }, - "work_item_master::group": { - "name_ko": "묶는 마디 — 표는 없고 아래를 고르는 자리(계산 규칙을 지닌다)" - }, - "work_item_master::general_provision": { - "name_ko": "총칙 — 문서 구역이나 값표가 붙어 B09 가 읽는다" + "work_item_master::choose_one": { + "name_ko": "아래 하나를 고름" }, "work_item_master::empty": { "name_ko": "빈 잎 — 표도 아래도 없는 자리" }, - "work_item_master::choose_one": { - "name_ko": "아래 하나를 고름" + "work_item_master::general_provision": { + "name_ko": "총칙 — 문서 구역이나 값표가 붙어 B09 가 읽는다" + }, + "work_item_master::group": { + "name_ko": "묶는 마디 — 표는 없고 아래를 고르는 자리(계산 규칙을 지닌다)" }, "work_item_master::sum_steps": { "name_ko": "아래를 단계로 다 더함" + }, + "work_item_master::work_item": { + "name_ko": "공종 — 품셈 표가 붙은 줄" } }, "unknown": [], - "work_item_axis": { - "note": "공종 축(불변 열쇠 FW·CW) 화면 이름표. 기초데이터 열넷 셈(`merged_tables`)에 섞이지 않게 여기 따로 둔다. Z01 이 이 구획을 읽어 상자 이름·열 이름을 뜨면 된다.", - "group": "work_item", - "kinds": [ - { - "key": "work_item_forest", - "name_ko": "산림 공종", - "summary": "산림사업 표준품셈 목차가 세운 공종 나무.", - "source": "resources/data_work_item_master/work_item_master_2026-01-01.json", - "key_prefix": "FW" - }, + "pending_merged_tables": { + "note": "**서버가 아직 안 내는 kind** 의 이름표를 미리 지어 둔 자리. `merged_tables` 는 서버 kind 와 한 짝이어야 해서(묵은 열 시험) 여기 둔다. 서버가 kind 를 내는 날 **그대로 `merged_tables` 로 옮기고** `test_master_labels_cover.py` 의 `MERGED_KEYS` 에 한 줄 더할 것.", + "items": [ { "key": "work_item_const", - "name_ko": "건설 공종", - "summary": "건설공사 표준품셈 목차가 세울 공종 나무(아직 안 뽑음).", - "source": "resources/data_cost_input_value/pum_const_2026.json", - "key_prefix": "CW" + "name_ko": "건설품셈 기준", + "summary": "건설공사 표준품셈 공종 축의 잎 — 일위대가가 붙는 단위. 산림과 같은 칸·같은 규칙이다. ⚠ 자리만 먼저 냄 — 뽑는 일은 랩탑 메인, 덜어낼 잣대는 `axis_policy.json`.", + "sources": [ + "work_item_master_const::work_items" + ], + "rows_note": "아직 안 뽑음 — 원문 표 2,192 · 목차 줄 2,000 안팎 예상", + "columns": [ + { + "key": "work_item_code", + "name_ko": "공종코드", + "unit": "", + "visible": true, + "note": "품셈 목차 코드(FP-). ⚠ 불변 열쇠 아님 — 개정 때 번호가 밀림. 열쇠는 `@id`(FW-)." + }, + { + "key": "number", + "name_ko": "품셈 번호", + "unit": "", + "visible": true + }, + { + "key": "name", + "name_ko": "이름", + "unit": "", + "visible": true, + "note": "뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」) — 잎 이름만으론 겹침." + }, + { + "key": "variant_keys", + "name_ko": "갈래 키", + "unit": "", + "visible": true + }, + { + "key": "pum_tables", + "name_ko": "딸린 품셈 표", + "unit": "", + "visible": true + }, + { + "key": "rule", + "name_ko": "계산 규칙", + "unit": "", + "visible": true, + "note": "부모 마디 방식 — `choose_one`(아래 중 하나) · `sum_steps`(아래를 무게대로 더해야 한 단위)." + }, + { + "key": "step_weight", + "name_ko": "단계 무게", + "unit": "", + "visible": true, + "note": "`sum_steps` 일 때만 — 발파암 절취 0.1 · 깍기 0.9 · 집토 1." + }, + { + "key": "effective_date", + "name_ko": "적용 시작일", + "unit": "", + "visible": true + } + ] } - ], - "columns": [ - { - "key": "work_item_key", - "name_ko": "공종 열쇠", - "unit": "", - "visible": true - }, - { - "key": "path_name", - "name_ko": "전체 경로 이름", - "unit": "", - "visible": true - }, - { - "key": "name", - "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": "level", - "name_ko": "단계", - "unit": "", - "visible": true - }, - { - "key": "axis_role", - "name_ko": "줄 구실", - "unit": "", - "visible": true - }, - { - "key": "pum_tables", - "name_ko": "딸린 품셈 표", - "unit": "", - "visible": true - }, - { - "key": "rule", - "name_ko": "계산 규칙", - "unit": "", - "visible": true - }, - { - "key": "step_weight", - "name_ko": "단계 비중", - "unit": "", - "visible": true - } - ], - "values": { - "axis_role": { - "work_item": "공종 — 품셈 표가 붙은 줄", - "group": "묶는 마디 — 표는 없고 아래를 고르는 자리(계산 규칙을 지닌다)", - "general_provision": "총칙 — 문서 구역이나 값표가 붙어 B09 가 읽는다", - "empty": "빈 잎 — 표도 아래도 없는 자리" - }, - "rule": { - "choose_one": "아래 하나를 고름", - "sum_steps": "아래를 단계로 다 더함" - } - } + ] } } diff --git a/resources/data_work_item_master/_manifest.json b/resources/data_work_item_master/_manifest.json index 6f29b993..be446792 100644 --- a/resources/data_work_item_master/_manifest.json +++ b/resources/data_work_item_master/_manifest.json @@ -1,7 +1,7 @@ { "schema_version": "1.0", "dataset_id": "data_work_item_master_manifest", - "generated_at": "2026-09-17T10:24:52+09:00", + "generated_at": "2026-09-17T12:15:05+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": "6aad7aa1e526d306e7b7ab70229e364d24b0d236671e4e7b3ad1dac5a57e4b3b", - "size_bytes": 954644 + "sha256": "762f18dc42a4e6d5512287127e9a21ee0348425aad62b040e29a97be2ec00cdd", + "size_bytes": 954642 }, { "file": "form_undetermined_2026-01-01.json", diff --git a/resources/data_work_item_master/axis_policy.json b/resources/data_work_item_master/axis_policy.json new file mode 100644 index 00000000..23f40deb --- /dev/null +++ b/resources/data_work_item_master/axis_policy.json @@ -0,0 +1,96 @@ +{ + "schema_version": "1.0", + "dataset_id": "work_item_axis_policy", + "effective_date": "2026-01-01", + "note": "공종 축의 줄 구실(axis_role) 잣대. 코드에 박지 않고 여기 둔다 — 기관이 정하는 범위라 시스템관리자가 고칠 값이다.", + "policy": { + "never_delete": true, + "why": "표 없는 마디 가운데 상당수가 `parent_mode`(계산 규칙)를 지니고, 총칙 장에는 원가가 실제로 읽는 값표가 붙어 있다. 지우면 계산 규칙과 값이 함께 사라진다(2026-09-17 산림에서 확인 · 브레인 승인).", + "flat_list_filter": "pum_tables > 0", + "flat_list_why": "「잎」으로 거르면 부모 마디에 붙은 표가 통째로 안 뜬다(산림 5건)." + }, + "roles": { + "work_item": { + "name_ko": "공종", + "rule": "품셈 표가 붙은 줄", + "keep": true + }, + "group": { + "name_ko": "묶는 마디", + "rule": "표는 없고 아래를 묶는 줄", + "keep": true, + "why": "`parent_mode`(choose_one·sum_steps)가 여기 붙는다 — 계산 규칙을 지닌 줄이다." + }, + "general_provision": { + "name_ko": "총칙", + "rule": "`general_provision` 뿌리 아래의 줄", + "keep": true, + "why": "문서 구역이나 값표가 붙어 원가가 읽는다." + }, + "empty": { + "name_ko": "빈 잎", + "rule": "표도 아래도 없는 잎", + "keep": true, + "why": "원문에 표가 안 붙은 자리. 지우면 「있었는지 없었는지」가 안 남는다." + } + }, + "decision_order": [ + "general_provision", + "work_item", + "group", + "empty" + ], + "axes": { + "forest": { + "name_ko": "산림 공종", + "settled": true, + "source": "resources/data_cost_input_value/pum_forest_2026.json", + "toc_source": "목차표 F0001 — 원문이 목차를 표로 싣는다", + "code_prefix": "FP", + "key_prefix": "FW", + "general_provision_roots": [ + "FP-01", + "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": { + "rows": 477, + "work_item": 304, + "group": 82, + "general_provision": 78, + "empty": 13 + } + }, + "const": { + "name_ko": "건설 공종", + "settled": false, + "source": "resources/data_cost_input_value/pum_const_2026.json", + "toc_source": "⚠ 목차표가 없다 — 장 파일 46개(`source_file` 칸)가 유일한 계층 근거다", + "code_prefix": "CP", + "key_prefix": "CW", + "code_shape": "CP-<부문2>-<장2>-<절…>", + "code_shape_why": "⚠ **부문마다 장 번호가 1부터 다시 시작한다** — 01_공통부문 제1장은 적용기준, 02_토목부문 제1장은 도로포장공사다. 장 번호만으로 코드를 지으면 부문이 다른 공종끼리 덮는다. 산림에서 목차 번호 겹침 둘(12-17-2·12-24-1)이 실제로 서로 덮고 있었다.", + "general_provision_roots": [ + "01_공통부문/제1장_적용기준.md" + ], + "general_provision_basis": "산림 FP-01 적용기준과 짝. 단위표준·토질·할증 같은 기준표 46개.", + "general_provision_pending": [ + { + "chapter": "01_공통부문/제8장_건설기계.md", + "tables": 351, + "why": "산림 FP-02 「소요재료 및 기계손료」와 짝으로 보이나, 기계 시공능력·손료 파라미터라 공종으로 볼 여지도 있음. ⬜ 안티그래비티 조사 대기.", + "already_extracted": "C0426·C0427 은 `resources/data_machine_productivity/` 로 이미 꺼냈음" + }, + { + "chapter": "05_유지관리부문/제1장_공통.md", + "tables": 40, + "why": "이름이 「공통」 — 기준표 장인지 공종 장인지 원문 확인 필요. ⬜ 안티그래비티 조사 대기." + } + ], + "known_defects": [ + "표 2,192 중 883(40 %)이 절 이름이 어긋남 — 파서가 글 속 제목을 못 잡음(PLAN 데스크탑서브 「빠진 표 일곱」 절)", + "`section` 이 절 번호가 아닌 표 289 — 그 가운데 11 은 값이 박힘" + ] + } + } +} diff --git a/resources/data_work_item_master/work_item_master_2026-01-01.json b/resources/data_work_item_master/work_item_master_2026-01-01.json index d2d9d91c..18d2eff4 100644 --- a/resources/data_work_item_master/work_item_master_2026-01-01.json +++ b/resources/data_work_item_master/work_item_master_2026-01-01.json @@ -4,7 +4,7 @@ "effective_date": "2026-01-01", "pum_edition": "2026-01-01", "toc_edition": "산림청고시제2025-82호", - "generated_at": "2026-09-17T10:24:52+09:00", + "generated_at": "2026-09-17T12:15:05+09:00", "dataset_version": { "dataset_id": "pum_forest", "effective_date": "2026-01-01", @@ -34,7 +34,7 @@ "work_item": 304, "empty": 13 }, - "keys_newly_issued": 477, + "keys_newly_issued": 0, "toc_duplicate_rows": 2 }, "toc_duplicate_rows": [ diff --git a/resources/tester/test_master_labels_cover.py b/resources/tester/test_master_labels_cover.py index dc363718..b13c32e3 100644 --- a/resources/tester/test_master_labels_cover.py +++ b/resources/tester/test_master_labels_cover.py @@ -470,6 +470,7 @@ MERGED_KEYS = ( "stone_kind", "masonry_class", "machine_productivity", + "work_item_forest", # 2026-09-17 공종 축 산림(건설은 자료가 오면) ) diff --git a/resources/tester/test_z01_base_tables.py b/resources/tester/test_z01_base_tables.py index d65819fa..dd876dac 100644 --- a/resources/tester/test_z01_base_tables.py +++ b/resources/tester/test_z01_base_tables.py @@ -23,6 +23,7 @@ from fastapi.testclient import TestClient from Z01_MasterData import Z01_MasterData_BasePrices as base_prices from Z01_MasterData import Z01_MasterData_Overrides as overrides from Z01_MasterData import Z01_MasterData_Router as router_module +from Z01_MasterData import Z01_MasterData_WorkItems as work_items NEW_KINDS = ( "coef", @@ -60,7 +61,10 @@ def _ids(table: dict) -> list[str]: def test_아홉이_기초단가와_같은_길로_섬(client: TestClient) -> None: - assert base_prices.KINDS == ("labor", "machine", "material", "oil", "rate") + NEW_KINDS + assert ( + base_prices.KINDS + == ("labor", "machine", "material", "oil", "rate") + NEW_KINDS + work_items.KINDS + ) for kind in NEW_KINDS: table = _get(client, kind, size=500) assert table["total"] == len(table["rows"]) > 0, kind @@ -286,7 +290,7 @@ def test_kind_목록을_서버가_줌_새_kind_도_저절로( assert [i["kind"] for i in items] == list(base_prices.KINDS) groups = {i["kind"]: i["group"] for i in items} assert groups["labor"] == "base_price" and groups["machine_productivity"] == "pumsem_basis" - assert {i["group"] for i in items} == {"base_price", "pumsem_basis"} + assert {i["group"] for i in items} == {"base_price", "pumsem_basis", "work_item"} labor = next(i for i in items if i["kind"] == "labor") assert labor["label"] == "노임" and labor["rows"] == 261 # 이름은 이름표에서 assert next(i for i in items if i["kind"] == "machine_productivity")["rows"] == 47 diff --git a/resources/tester/test_z01_work_items.py b/resources/tester/test_z01_work_items.py new file mode 100644 index 00000000..908467eb --- /dev/null +++ b/resources/tester/test_z01_work_items.py @@ -0,0 +1,106 @@ +"""Z01 공종 축 API 틀 — 산림·건설 두 kind 를 기초단가와 같은 길로(2026-09-17 브레인 · PLAN_공종축 8-4). + +못박는 것: 평평한 잎 목록 · 전체 경로 이름(겹침 없음) · 계산 규칙(choose_one 91 · sum_steps 3) · +불변 열쇠가 오면 `@id` 가 그것으로 · 건설은 자료가 있어야 상자가 섬 · 고치기는 막힘. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from Z01_MasterData import Z01_MasterData_BasePrices as base_prices +from Z01_MasterData import Z01_MasterData_Overrides as overrides +from Z01_MasterData import Z01_MasterData_Router as router_module +from Z01_MasterData import Z01_MasterData_WorkItems as work_items + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + from common_util.common_util_auth import verify_session + + monkeypatch.setattr(overrides, "OVERRIDE_DIR", tmp_path / "data_master_override") + app = FastAPI() + app.include_router(router_module.router) + app.dependency_overrides[verify_session] = lambda: {"user_id": 7, "role": "SYSTEM_ADMIN"} + return TestClient(app) + + +def _get(client: TestClient, kind: str, **params) -> dict: + res = client.get(f"/api/master-data/base-prices/{kind}", params=params) + assert res.status_code == 200, res.text + return res.json() + + +def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> None: + listed = client.get("/api/master-data/base-prices").json()["kinds"] + forest = next(i for i in listed if i["kind"] == "work_item_forest") + assert forest["group"] == "work_item" and forest["rows"] == 358 + + table = _get(client, "work_item_forest", size=500) + assert table["total"] == 358 and table["stats"] == {"leaves": 358, "units": 618} + assert table["editable"] == [] and set(table["locked"]) == set(table["sortable"]) + rows = table["rows"] + assert len({r["@id"] for r in rows}) == 358 + assert len({r["name"] for r in rows}) == 358 # 잎 이름 35줄 겹침 — 경로로 풀림 + by_code = {r["work_item_code"]: r for r in rows} + assert by_code["FP-09-03-02"]["name"] == "토공 › 토사깍기 › 기계" + + modes = [r["mode"] for r in table["rules"]] + assert modes.count("choose_one") == 91 and modes.count("sum_steps") == 3 + blast = next(r for r in table["rules"] if r["work_item_code"] == "FP-09-05") + assert [s["weight"] for s in blast["steps"]] == ["0.1", "0.9", "1"] + assert by_code["FP-09-05-02"]["rule"] == "sum_steps" + assert by_code["FP-09-05-02"]["step_weight"] == "0.9" + # 2026-09-17 FW- 가 옴(데스크탑 서브) — 알림이 사라지는 것이 곧 열쇠가 섰다는 증거. + assert not any("불변 열쇠" in line for line in table["notice"]) + + found = _get(client, "work_item_forest", q="발파암 ›") + assert found["total"] == 3 + + +def test_불변_열쇠가_오면_id_로_씀_건설도_같은_길( + client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + if work_items.doc("work_item_construction") is None: # 빈 상자 안 세움 + assert "work_item_construction" not in base_prices.KINDS + + folder = tmp_path / "data_work_item_master" + folder.mkdir() + fake = { + "dataset_version": {"dataset_id": "pum_const"}, + "effective_date": "2026-01-01", + "work_items": [ + {"work_item_code": "CP-01", "work_item_key": "CW-00001", "number": "1", + "name": "토공", "parent_code": None, "tables": [], "parent_mode": "sum_steps", + "steps": [{"code": "CP-01-01", "weight": "0.5"}], "steps_basis": "시험", "variant_keys": []}, + {"work_item_code": "CP-01-01", "work_item_key": "CW-00002", "number": "1-1", + "name": "인력", "parent_code": "CP-01", "tables": [{"pum_table_id": "C0001"}], + "variant_keys": ["갑", "을"]}, + ], + } # fmt: skip + (folder / "work_item_master_const_2026-01-01.json").write_text( + json.dumps(fake, ensure_ascii=False), encoding="utf-8" + ) + monkeypatch.setattr(work_items, "FOLDER", folder) + monkeypatch.setattr(work_items, "KINDS", tuple(work_items.SOURCE_OF)) + monkeypatch.setattr(base_prices, "KINDS", (*base_prices.KINDS, "work_item_construction")) + + table = _get(client, "work_item_construction") + assert [r["@id"] for r in table["rows"]] == ["CW-00002"] + assert table["rows"][0]["work_item_code"] == "CP-01-01" # 목차 코드는 칸으로 남음 + assert table["rows"][0]["name"] == "토공 › 인력" + assert table["stats"] == {"leaves": 1, "units": 2} + assert table["rules"][0]["steps"] == [{"@id": "CW-00002", "weight": "0.5"}] + assert not any("불변 열쇠" in line for line in table["notice"]) + assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음 + + res = client.put( + "/api/master-data/base-prices/work_item_construction/CW-00002", + json={"values": {"name": "바꿈"}}, + ) + assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"]