From 3028801cd890e8b6d3d66de80bd74af97920b9a4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 14:10:04 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=20=EA=B7=9C=EA=B2=A9=20=EC=97=B4):=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=E3=80=8C=EA=B7=9C=EA=B2=A9=E3=80=8D=20=EC=B9=B8=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=C2=B7=20settle=20=EC=97=B4=20=EB=81=BC=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0=20=EC=A7=9D=20=C2=B7=20=EC=97=B4=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=ED=95=A9=20(PLAN=20704-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Labor_Spec 새 파일 — 규격 칸 정리 · 열 끼우기 · 사본 굳히기 · 쓰는 사본 찾기 - settle(파이썬 · TS) 에 col — 그 열 수량이 있는 줄 = 손 자리 · 규격 없음 = 그대로 - 계산값 규격합 = 열마다 반영합(고르기 창 열 단추) - 거울 시험 — 규격 열 · 모르는 열 = 기본 · 꼴 틀림 400 · 열 없는 옛 문서 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Labor_Doc.py | 22 ++- M02_MasterTemplete/M02_Labor_Spec.py | 163 ++++++++++++++++++ .../M02_MasterTemplete_Labor_Calc.ts | 52 +++++- resources/tester/helper_m02_labor_calc.cjs | 4 +- .../tester/test_m02_labor_calc_mirror.py | 81 +++++++++ 5 files changed, 312 insertions(+), 10 deletions(-) create mode 100644 M02_MasterTemplete/M02_Labor_Spec.py diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 655ec4775..ff593a728 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -16,6 +16,7 @@ 분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만. 짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` — 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. +규격 열(704-4) = 문서 `규격?` 칸 · 열마다 줄 수량(`M02_Labor_Spec` · settle `col`) · 계산값 `규격합`. 로직 자재(103-1) = 담은 로직 맨 위 층 호표 재료 줄(`logic_mats` · 줄마다 `재료`) · 고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와 같은 품목 두 번은 [저장] 때 400. @@ -40,6 +41,7 @@ from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Labor_Spec as spec from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Material_Own as own @@ -181,7 +183,11 @@ def clean(doc: Any) -> dict[str, Any]: } if doc.get("일위대가번"): out["일위대가번"] = doc["일위대가번"] - return out + try: # 규격 열(704-4 · `M02_Labor_Spec`) — 없으면 칸 없음(옛 문서) + cols = spec.clean(doc.get("규격"), {spec.line_key(x) for x in [*lines, *mats]}) + except spec.SpecError as e: + raise _bad(str(e)) from e + return {**out, "규격": cols} if cols else out def _alias(alias: Any, where: str) -> str | None: @@ -411,9 +417,10 @@ def line_id(one: dict) -> Any: return refs.ref_id(one["참조"]) if _is_ref(one) else one.get("로직") -def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: +def settle(result: dict, doc: dict, given: dict[str, Any], col: Any = None) -> dict[str, Any]: """기준이 선 계산 답에 수량 · 반영 · 합을 채움 — 수량만 바뀌면 이것만 다시 - (짝: `Labor_Calc.ts` `settle` · 자재 = 재료비).""" + (짝: `Labor_Calc.ts` `settle` · 자재 = 재료비 · `col` = 규격 열 · 없으면 기본 · 704-4).""" + doc = spec.at(doc, col) have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None} hands = {line_id(one): one.get("수량") for one in doc.get("담은로직") or []} mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []} @@ -642,7 +649,14 @@ def server(doc: Any) -> dict[str, Any]: got = clean(doc) out = calc(got) no_twice(got, out) - return {**got, "계산값": out} + return {**got, "계산값": {**out, **spec_sums(got, out)}} + + +def spec_sums(doc: dict, out: dict) -> dict[str, Any]: + """규격 열마다 반영합(704-4 · 고르기 창 열 단추) `{규격합: {열: 합}}` — 규격 없음 = 빈 것.""" + cols = (doc.get("규격") or {}).get("열") or [] + have = spec.given_of(out) + return {"규격합": {c: settle(out, doc, have, c)["반영합"] for c in cols}} if cols else {} def no_twice(got: dict, out: dict) -> None: diff --git a/M02_MasterTemplete/M02_Labor_Spec.py b/M02_MasterTemplete/M02_Labor_Spec.py new file mode 100644 index 000000000..ec5ccbdd8 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Spec.py @@ -0,0 +1,163 @@ +"""일위대가 규격 열(PLAN 704-4) — 문서 `규격` 칸 검사 · 열 끼우기 · 사본 굳히기 · 쓰는 사본 찾기. + +규격 = `{이름: "H", 열: ["1.5", "2.0"], 기본: "2.0", 수량: {줄 id: {열: 수}}}` — +줄 id = 로직 키 · 참조 `ref_id`(`M02_Labor_Doc.line_id`) · 자재 코드. +규격 수량이 있는 줄 = 고른 열 값이 `{손}` 자리 · 없는 줄 · 빈 열 = 문서 `수량` 그대로(공통). +규격 없음 = 옛 문서(열 하나 · 셈 그대로 · 옮김 없음). +짝(CLAUDE.md 5장 ①): `at` ↔ `M02_MasterTemplete_Labor_Calc.ts` `atSpec` — +거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. +사본(`M02_Item_Source.build_source`) = 설계가 고른 열(summary_item `spec`)을 `{손}` 으로 굳히고 +`규격` 은 그 열 하나만 남김(`freeze`). +""" + +from __future__ import annotations + +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any + +from M02_MasterTemplete import M02_Labor_Ref as refs + +NAME_MAX = 20 # 규격 이름 · 열 이름 글자 수 상한 — 화면 칸과 같음 +COLS_MAX = 20 + + +class SpecError(ValueError): + """규격 칸 꼴이 틀림 — 부르는 쪽이 400 으로.""" + + +def _num(raw: Any) -> int | float | None: + if raw is None or isinstance(raw, bool): + return None + try: + got = Decimal(str(raw).replace(",", "").strip()) + except InvalidOperation: + return None + if not got.is_finite(): + return None + return int(got) if got == got.to_integral_value() else float(got) + + +def _text(raw: Any, what: str) -> str: + text = str(raw).strip() if isinstance(raw, (str, int, float)) and raw is not True else "" + if not text or len(text) > NAME_MAX: + raise SpecError(f"규격 {what}은 1~{NAME_MAX}자 글") + return text + + +def line_key(one: dict) -> Any: + """줄 id — 참조 줄 = `ref_id` · 로직 줄 = 로직 키 · 자재 줄 = 코드.""" + if isinstance(one.get("참조"), dict): + return refs.ref_id(one["참조"]) + return one.get("로직") or one.get("코드") + + +def clean(raw: Any, ids: set[Any]) -> dict[str, Any] | None: + """받은 `규격` → 저장 꼴 — 열이 없으면 None(규격 없음) · 이름 · 열 이름이 비거나 겹치면 · + 수량이 수 아니면 SpecError · 없는 줄 · 없는 열 수량은 버림(지운 줄).""" + if raw is None: + return None + if not isinstance(raw, dict): + raise SpecError("규격은 묶음") + cols: list[str] = [] + for one in raw.get("열") or []: + col = _text(one, "열 이름") + if col in cols: + raise SpecError(f"같은 규격 열 두 번 「{col}」") + cols.append(col) + if not cols: + return None + if len(cols) > COLS_MAX: + raise SpecError(f"규격 열은 {COLS_MAX}개까지") + qty: dict[str, dict[str, int | float]] = {} + for lid, row in (raw.get("수량") or {}).items(): + if lid not in ids or not isinstance(row, dict): + continue + kept = {} + for col, value in row.items(): + if col not in cols or value in (None, ""): + continue + got = _num(value) + if got is None: + raise SpecError(f"규격 수량 「{lid} · {col}」 은 수") + kept[col] = got + if kept: + qty[str(lid)] = kept + return { + "이름": _text(raw.get("이름"), "이름"), + "열": cols, + "기본": raw.get("기본") if raw.get("기본") in cols else cols[0], + "수량": qty, + } + + +def column(doc: Any, col: Any = None) -> str | None: + """쓸 열 — 고른 열이 규격에 있으면 그것 · 없으면 기본 · 규격 없음 = None.""" + spec = doc.get("규격") if isinstance(doc, dict) else None + if not isinstance(spec, dict) or not spec.get("열"): + return None + return col if col in spec["열"] else spec.get("기본") or spec["열"][0] + + +def at(doc: dict, col: Any = None) -> dict: + """열 끼우기 — 그 열 규격 수량이 있는 줄 · 자재의 `수량` 을 `{손: 값}` 으로 + (규격 없음 = 그대로).""" + use = column(doc, col) + if use is None: + return doc + qty = doc["규격"].get("수량") or {} + + def put(one: Any) -> Any: + if not isinstance(one, dict): + return one + value = (qty.get(line_key(one)) or {}).get(use) + return {**one, "수량": {"손": value}} if value is not None else one + + return { + **doc, + "담은로직": [put(one) for one in doc.get("담은로직") or []], + "자재": [put(one) for one in doc.get("자재") or []], + } + + +def given_of(result: dict) -> dict[str, Any]: + """셈 답에서 칸 값 되살림 `{알약: 수량}` — 열마다 다시 셀 때 면적 칸 값 + (문서 · 층을 다시 안 읽음).""" + rows = [*(result.get("줄") or []), *(result.get("자재") or [])] + return {r["알약"]: r["수량"] for r in rows if r.get("수량출처") == "칸" and r.get("알약")} + + +def freeze(doc: dict, col: Any = None) -> dict: + """사본 굳히기 — 고른 열(없거나 지운 열 = 기본)을 `{손}` 으로 끼우고 규격은 그 열 하나만.""" + use = column(doc, col) + if use is None: + return doc + got = at(doc, use) + return {**got, "규격": {"이름": doc["규격"].get("이름"), "열": [use], "기본": use, "수량": {}}} + + +def users(layer_root: Path, keys: set[str], cols: set[str]) -> list[str]: + """그 층 항목(`keys`) · 그 규격 열(`cols`)을 쓰는 프로젝트 사본 — + `프로젝트 폴더/사본 폴더` 목록. + + ponytail: 저장소 프로젝트 사본 manifest 를 통째로 훑음 — 열 지우기 · 이름 바꾸기 [저장] 때만 · + 느리면 사본 색인을 둠.""" + from M02_MasterTemplete import M02_Template_Layers as layers + from M02_MasterTemplete.M02_Item_Source import layer_root as root_of + from M02_MasterTemplete.M02_Item_Source import read_json + + found = [] + here = Path(layer_root).resolve() + for path in layers.storage_root().glob("*/*/*/templates/structures/*/manifest.json"): + origin = (read_json(path) or {}).get("원본") + if not isinstance(origin, dict) or origin.get("key") not in keys: + continue + if origin.get("spec") not in cols: + continue + try: + same = root_of(origin).resolve() == here + except (KeyError, ValueError, TypeError): + same = False + if same: + found.append(f"{path.parents[3].name}/{path.parent.name}") + return found diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index cea35ce81..85ada6592 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -5,7 +5,8 @@ * 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에 * 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. * 별도계상 자재(222-1 · 빈 칸 = 기본 별도계상) = 반영은 보이되 합에 안 더함(짝 `apart`). - * 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */ + * 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). + * 규격 열(704-4) = 문서 `규격` · 그 열 수량이 있는 줄 = `{손}` 자리(`atSpec` · 짝 `M02_Labor_Spec.at`). */ import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import type { LogicMat } from "./M02_Labor_LogicMats_Model"; @@ -77,10 +78,19 @@ export interface LaborCalc { 반영합?: Money; } -/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */ +/** 규격 열(704-4) — 이름(H · Φ) · 열(화면 차례) · 기본 · 줄 id 마다 열 수량 */ +export interface LaborSpec { + 이름: string; + 열: string[]; + 기본: string; + 수량: Record>; +} + +/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 · 규격 열 */ export interface LaborQtyDoc { 담은로직?: { 로직?: string; 참조?: LaborRef; 수량?: Qty }[]; 자재?: { 코드: string; 수량?: Qty; 별도계상?: string }[]; + 규격?: LaborSpec | null; } function num(raw: unknown): number | null { @@ -95,6 +105,37 @@ export function lineId(one: { 로직?: string; 참조?: LaborRef }): string | un return one.로직; } +/** 쓸 규격 열 — 고른 열이 있으면 그것 · 없으면 기본 · 규격 없음 = null(짝 `M02_Labor_Spec.column`) */ +export function specColumn(doc: LaborQtyDoc, col?: string | null): string | null { + const cols = doc.규격?.열 ?? []; + if (!cols.length) return null; + return col && cols.includes(col) ? col : doc.규격?.기본 || cols[0]; +} + +/** 열 끼우기 — 그 열 규격 수량이 있는 줄 · 자재의 수량을 `{손}` 으로(짝 `M02_Labor_Spec.at`) */ +export function atSpec(doc: D, col?: string | null): D { + const use = specColumn(doc, col); + if (use === null) return doc; + const qty = doc.규격?.수량 ?? {}; + const put = (one: T, id: string | undefined): T => { + const v = id !== undefined ? qty[id]?.[use] : undefined; + return v !== undefined && v !== null ? { ...one, 수량: { 손: v } } : one; + }; + return { + ...doc, + 담은로직: (doc.담은로직 ?? []).map((one) => put(one, lineId(one))), + 자재: (doc.자재 ?? []).map((one) => put(one, one.코드)), + }; +} + +/** 셈 답에서 칸 값 되살림 `{알약: 수량}` — 열마다 다시 셀 때(짝 `M02_Labor_Spec.given_of`) */ +export function givenOf(result: LaborCalc): Record { + const out: Record = {}; + for (const r of [...result.줄, ...result.자재]) + if (r.수량출처 === "칸" && r.알약 && r.수량 != null) out[r.알약] = r.수량; + return out; +} + function zero(): Money { return { 노무비: 0, 재료비: 0, 경비: 0, 계: 0 }; } @@ -118,12 +159,15 @@ export function reflect(base: Money, qty: number | null, times: number): Money | return out; } -/** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 */ +/** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 · + * `col` = 규격 열(없으면 기본 · 704-4) */ export function settle( result: LaborCalc, - doc: LaborQtyDoc, + whole: LaborQtyDoc, given: Record, + col?: string | null, ): LaborCalc { + const doc = atSpec(whole, col); const have: Record = {}; for (const [k, raw] of Object.entries(given)) { const v = num(raw); diff --git a/resources/tester/helper_m02_labor_calc.cjs b/resources/tester/helper_m02_labor_calc.cjs index 939a86397..bdc7c2957 100644 --- a/resources/tester/helper_m02_labor_calc.cjs +++ b/resources/tester/helper_m02_labor_calc.cjs @@ -1,5 +1,5 @@ /* M02 일위대가 수량 · 반영 · 합(`M02_MasterTemplete_Labor_Calc.ts` `settle`) 거울 헬퍼 — - * 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {답, 문서, 값} · stdout = settle 결과 JSON. */ + * 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {답, 문서, 값, 열?} · stdout = settle 결과 JSON. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); @@ -18,4 +18,4 @@ const box = { exports: {} }; new Function("exports", "module", "require", js)(box.exports, box, require); const input = JSON.parse(fs.readFileSync(0, "utf8")); -process.stdout.write(JSON.stringify(box.exports.settle(input.답, input.문서, input.값))); +process.stdout.write(JSON.stringify(box.exports.settle(input.답, input.문서, input.값, input.열))); diff --git a/resources/tester/test_m02_labor_calc_mirror.py b/resources/tester/test_m02_labor_calc_mirror.py index ab8a53680..8f5396863 100644 --- a/resources/tester/test_m02_labor_calc_mirror.py +++ b/resources/tester/test_m02_labor_calc_mirror.py @@ -69,3 +69,84 @@ def test_파이썬과_TS_가_같은_답(values): py = labor.settle(answer, doc, values) _same(py, _ts(answer, doc, values)) assert py == labor.calc(doc, values) # settle 만 다시 = 처음부터 다시 + + +# ── 규격 열(704-4) — 열마다 줄 수량 · 열 없는 옛 문서는 위 시험 그대로 ── +SPEC_DOC = { + "열": "rv", + "담은로직": [ + {"로직": TEN, "수량": None}, + {"로직": THOUSAND, "수량": {"손": 4}}, + {"로직": PLAIN, "수량": None}, + ], + "자재": [{"코드": MAT, "수량": {"손": 1}, "별도계상": "직접계산"}], + "규격": { + "이름": "H", + "열": ["1.5", "2.0", "2.5"], + "기본": "2.0", + "수량": { + THOUSAND: {"1.5": 3, "2.0": "4.5", "2.5": 6}, + MAT: {"1.5": 0.8, "2.5": 1.4}, # 2.0 빈 열 = 공통 손 1 + "없는줄": {"1.5": 9}, # 버림 + }, + }, +} + + +@pytest.mark.parametrize("col", [None, "1.5", "2.0", "2.5", "모름"]) +def test_규격_열_파이썬과_TS_가_같은_답(col): + doc = labor.clean(SPEC_DOC) + assert doc["규격"]["수량"] == { + THOUSAND: {"1.5": 3, "2.0": 4.5, "2.5": 6}, + MAT: {"1.5": 0.8, "2.5": 1.4}, + } + answer = labor.calc(doc, {}) + values = {"사석 수상고르기|㎡": 2.5} + py = labor.settle(answer, doc, values, col) + _same(py, _ts(answer, doc, values) if col is None else _ts_col(answer, doc, values, col)) + use = col if col in ("1.5", "2.0", "2.5") else "2.0" # 모름 = 기본 + line = next(x for x in py["줄"] if x["로직"] == THOUSAND) + assert line["수량"] == {"1.5": 3, "2.0": 4.5, "2.5": 6}[use] and line["수량출처"] == "손" + mat = py["자재"][0] + assert mat["수량"] == {"1.5": 0.8, "2.0": 1, "2.5": 1.4}[use] + + +def _ts_col(answer: dict, doc: dict, values: dict, col: str) -> dict: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")], + input=json.dumps({"답": answer, "문서": doc, "값": values, "열": col}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(proc.stdout) + + +def test_규격합_은_열마다_다시_센_반영합(): + doc = labor.clean(SPEC_DOC) + out = labor.calc(doc, {"사석 수상고르기|㎡": 2.5}) + sums = labor.spec_sums(doc, out)["규격합"] + assert list(sums) == ["1.5", "2.0", "2.5"] + for col, total in sums.items(): + assert total == labor.settle(out, doc, {"사석 수상고르기|㎡": 2.5}, col)["반영합"] + assert sums["1.5"]["계"] < sums["2.0"]["계"] < sums["2.5"]["계"] + + +@pytest.mark.parametrize( + "bad", + [ + {"이름": "", "열": ["1.5"]}, + {"이름": "H", "열": ["1.5", "1.5"]}, + {"이름": "H", "열": ["1.5"], "수량": {THOUSAND: {"1.5": "글"}}}, + ], +) +def test_규격_칸_꼴이_틀리면_400(bad): + with pytest.raises(labor.store.StoreError) as got: + labor.clean({**SPEC_DOC, "규격": bad}) + assert got.value.status == 400 + + +def test_열_없는_규격_은_옛_문서(): + assert "규격" not in labor.clean({**SPEC_DOC, "규격": {"이름": "H", "열": []}}) + assert "규격" not in labor.clean({k: v for k, v in SPEC_DOC.items() if k != "규격"}) From 425a4a9dfb26c7a852a14f1840960e8289791b37 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 14:16:06 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(=EA=B7=9C=EA=B2=A9=20=EC=97=B4=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0):=20summary=5Fitem=20spec=20=C2=B7?= =?UTF-8?q?=20=EC=82=AC=EB=B3=B8=EC=9D=B4=20=EA=B3=A0=EB=A5=B8=20=EC=97=B4?= =?UTF-8?q?=20=EA=B5=B3=ED=9E=98=20=C2=B7=20=EA=B0=99=EC=9D=80=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=3D=20=EC=97=B4=EA=B9=8C=EC=A7=80=20=C2=B7=20B07=20?= =?UTF-8?q?=ED=95=9C=20=EC=9E=A5=20=3D=20=ED=95=AD=EB=AA=A9=20+=20?= =?UTF-8?q?=EC=97=B4=20=C2=B7=20=EC=93=B0=EB=8A=94=20=EC=97=B4=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80=20409=20(PLAN=20704-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - summary_item spec(파이썬 · TS 짝) — 없으면 기본 열 · 모르는 키 거절 그대로 - 사본 = 고른 열 수량을 손으로 굳힘 · manifest 규격 · same_item 에 spec(열 바꿈 = 새 사본 · 덮개 열쇠 S:폴더 그대로) - B07 구조물 도면 키 = 항목 + 열 해시 — 열이 다르면 따로 장 - M02 일위대가 [저장] — 쓰는 사본 있는 열 지움 · 이름 바꿈 409 - 항목 목록 labor.규격(열 · 열마다 합계) — 고르기 창 열 단추 - 시험 test_704_4_spec 7(열 바꿈 뒤 덮개 남음 · 짝 잃음 0) Co-Authored-By: Claude Opus 5.5 --- ...7_DesignDetail_Router_Support_Structure.py | 6 +- M02_MasterTemplete/M02_Item_Snapshot.py | 6 +- M02_MasterTemplete/M02_Item_Source.py | 7 +- M02_MasterTemplete/M02_Labor_Spec.py | 23 ++- .../M02_MasterTemplete_Items.py | 8 + .../M02_MasterTemplete_Store.py | 4 + common_util/common_util_summary_item.py | 17 +- common_util/common_util_summary_item.ts | 9 +- resources/tester/test_704_4_spec.py | 170 ++++++++++++++++++ 9 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 resources/tester/test_704_4_spec.py diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Structure.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Structure.py index 549d036c0..4a719ffa3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Structure.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Structure.py @@ -1,12 +1,14 @@ """B07 구조물 도면(376 · 요구 241-1) — B05 · B06 배치에 연결된 구조물 조합 도면 목록 · 단건. -조합 키 = 항목 사본 폴더 이름(`snapshot_folder_name`) — 같은 조합을 여러 배치가 써도 한 장 · +조합 키 = 항목 사본 폴더 이름(`snapshot_folder_name`) + 규격 열(704-4 · `-s해시`) — 같은 조합 · +같은 열을 여러 배치가 써도 한 장 · 열이 다르면 따로(표제 = 항목 이름 「… (H=2.0)」) · 더한장은 장마다 · id `structure_{키}[_{장id}]` · 차례 = 도번 · 도면 = 프로젝트 사본 `도면.json` + 얹는 도각(M02 `structure_frame` · 도번 · 도면명). 고치기는 M02 · B08 — 여기는 읽기만. """ from __future__ import annotations +import hashlib import logging import re from pathlib import Path @@ -38,6 +40,8 @@ def structure_sheets(project_root: Path) -> list[dict[str, Any]]: if "도면" not in copy["부분"] or not item.get("key"): continue key = snapshot_folder_name(item) + if item.get("spec"): # 규격 열(704-4) — 열 이름은 아무 글이라 id 에는 해시만 + key += f"-s{hashlib.sha1(str(item['spec']).encode()).hexdigest()[:8]}" if key in docs: continue doc = read_json(snapshot.structures_dir(project_root) / copy["폴더"] / "도면.json") diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index 629c2cc1d..1f7244744 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -86,10 +86,11 @@ def now_text() -> str: def same_item(manifest: Any, item: dict[str, Any]) -> bool: + """같은 항목 · 같은 규격 열(704-4 · 열이 바뀌면 다른 사본 · 덮개는 폴더 열쇠라 그대로 남음).""" origin = manifest.get("원본") if isinstance(manifest, dict) else None if not isinstance(origin, dict): return False - return all(origin.get(k) == item.get(k) for k in ("layer", "owner", "key")) + return all(origin.get(k) == item.get(k) for k in ("layer", "owner", "key", "spec")) def origin_of(item: dict[str, Any]) -> dict[str, Any]: @@ -142,6 +143,9 @@ def write_snapshot(target: Path, item: dict[str, Any], owner_id: str, extra: dic atomic_write_json(target / name, content) stamp = now_text() source = version_of(source_files(files)) + spec = ((files.get(LABOR_FILE) or {}).get("줄") or {}).get("규격") # 굳힌 규격 열(704-4) + if isinstance(spec, dict): + extra = {**extra, "규격": {"이름": spec.get("이름"), "열": spec.get("기본")}} manifest = { "원본": origin_of(item), "판": source, diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 597cb086c..ede035547 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -2,7 +2,8 @@ 모으는 것 = `{파일 이름: 내용}`: `집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA · - `일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄) + `일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄 · + 규격 열 = summary_item `spec` 열 수량을 손으로 굳힘 · 704-4) + 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) + 참조사본(단가승격 · 사전조합을 값으로 굳힘) · `단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT) @@ -146,7 +147,9 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: files[name] = part doc = read_json(root / LABOR_KIND / f"{column_id}.json") if column_id else None if isinstance(doc, dict): - labor = labor_doc_rows(doc) + from M02_MasterTemplete import M02_Labor_Spec as labor_spec + + labor = labor_doc_rows(labor_spec.freeze(doc, item.get("spec"))) # 고른 규격 열(704-4) else: labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None if labor is not None: diff --git a/M02_MasterTemplete/M02_Labor_Spec.py b/M02_MasterTemplete/M02_Labor_Spec.py index ec5ccbdd8..a7d3b168f 100644 --- a/M02_MasterTemplete/M02_Labor_Spec.py +++ b/M02_MasterTemplete/M02_Labor_Spec.py @@ -132,7 +132,7 @@ def freeze(doc: dict, col: Any = None) -> dict: use = column(doc, col) if use is None: return doc - got = at(doc, use) + got = {k: v for k, v in at(doc, use).items() if k != "계산값"} # 기본 열 계산값은 버림 return {**got, "규격": {"이름": doc["규격"].get("이름"), "열": [use], "기본": use, "수량": {}}} @@ -161,3 +161,24 @@ def users(layer_root: Path, keys: set[str], cols: set[str]) -> list[str]: if same: found.append(f"{path.parents[3].name}/{path.parent.name}") return found + + +def guard(path: Path, new: Any, column: str) -> None: + """[저장] 때 빠진 규격 열(지움 · 이름 바꿈)을 쓰는 프로젝트 사본이 있으면 409 — + 지금 층(`store.root`) 그 열 항목(SS 키 · 옛 키) 기준.""" + from M02_MasterTemplete import M02_MasterTemplete_Store as store + from M02_MasterTemplete.M02_Item_Source import read_json + + old = (read_json(path) or {}).get("규격") if path.is_file() else None + gone = set((old or {}).get("열") or []) - set(((new or {}).get("규격") or {}).get("열") or []) + if not gone: + return + rows = [r for r in store.read_combos().get("줄", []) if str(r.get("열")) == column] + keys = {str(k) for r in rows for k in [r.get("키"), *(r.get("옛키") or [])] if k} + used = users(store.root(), keys, gone) + if used: + raise store.StoreError( + 409, + f"규격 열 「{', '.join(sorted(gone))}」 을 쓰는 프로젝트 사본 {len(used)}개 — 지울 수 없음 " + f"({', '.join(used[:3])}{' …' if len(used) > 3 else ''})", + ) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index 317d4698d..ed92edf04 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -119,6 +119,14 @@ def _list_extras(folder: Path, col: str) -> dict[str, Any]: "기준": _number(calc.get("기준합")), # 수량 없는 일위대가 = 단가 합(39-8 B2) "단위": doc.get("단위"), # 일위대가 문서 단위(구조물 열 단위 아님 · B3) } + spec = doc.get("규격") if isinstance(doc.get("규격"), dict) else None + if spec and spec.get("열"): # 규격 열(704-4) — 고르기 창 열 단추 · 열마다 합계 + sums = calc.get("규격합") or {} + labor["규격"] = { + "이름": spec.get("이름"), + "열": [{"이름": c, "합계": _number(sums.get(c))} for c in spec["열"]], + "기본": spec.get("기본"), + } return {"수정일": stamps, "labor": labor} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 06c289772..99b52145d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -166,6 +166,10 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> if kind == "table" and name == STRUCTURE_TABLE: doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기) doc = _server(kind, doc) + if kind == "labor": # 쓰는 규격 열 지움 막음(704-4) + from M02_MasterTemplete import M02_Labor_Spec as labor_spec + + labor_spec.guard(path, doc, name) fresh: list[dict[str, Any]] = [] with _LOCK: have = version_of(path.read_bytes()) if path.is_file() else "" diff --git a/common_util/common_util_summary_item.py b/common_util/common_util_summary_item.py index 542681d3c..4aa4068bf 100644 --- a/common_util/common_util_summary_item.py +++ b/common_util/common_util_summary_item.py @@ -1,7 +1,10 @@ """구조물 집계표 항목 참조 `summary_item` — 모양 검사 · 스냅샷 폴더 이름 (PLAN 21-1). summary_item = {layer: "system"|"company"|"personal", - owner: null|"{회사}"|"{회사}/{사용자}", key: "SS000004", name: "…"} + owner: null|"{회사}"|"{회사}/{사용자}", key: "SS000004", name: "…", + spec?: "2.0"} + +`spec`(PLAN 704-4) = 그 항목 일위대가 규격 열 이름 — 없으면 기본 열(옛 기록 · 규격 없는 항목). 항목 키(PLAN 26-4a) — 마스터 `SS000004` · 회사 `SC{회사}-000001` · 개인 `SP{사용자}-000001` (머리의 번호 = owner 의 회사 · 사용자). 옛 `SS` 키는 어느 층이든 그대로 받음 — 회사 · 개인 층이 새 @@ -28,15 +31,17 @@ _OWNER = { "personal": re.compile(r"^\d+/\d+$"), } _NAME_MAX = 200 +_SPEC_MAX = 20 # 규격 열 이름 상한(`M02_Labor_Spec.NAME_MAX`) def parse_summary_item(value: Any) -> dict[str, Any] | None: - """모양이 맞으면 `{layer, owner, key, name}` 새 dict · 빈 값은 None · 틀리면 ValueError.""" + """모양이 맞으면 `{layer, owner, key, name, spec?}` 새 dict · 빈 값은 None · + 틀리면 ValueError.""" if value is None: return None if not isinstance(value, dict): raise ValueError("집계 항목(summary_item)은 객체여야 합니다.") - extra = set(value) - {"layer", "owner", "key", "name"} + extra = set(value) - {"layer", "owner", "key", "name", "spec"} if extra: raise ValueError(f"집계 항목에 모르는 칸이 있습니다: {', '.join(sorted(extra))}") layer = value.get("layer") @@ -59,7 +64,11 @@ def parse_summary_item(value: Any) -> dict[str, Any] | None: name = value.get("name") if not isinstance(name, str) or not name.strip() or len(name) > _NAME_MAX: raise ValueError(f"집계 항목 name 은 1~{_NAME_MAX}자 글입니다.") - return {"layer": layer, "owner": owner, "key": key, "name": name} + spec = value.get("spec") + if spec is not None and (not isinstance(spec, str) or not 0 < len(spec.strip()) <= _SPEC_MAX): + raise ValueError(f"집계 항목 spec(규격 열)은 1~{_SPEC_MAX}자 글입니다.") + out = {"layer": layer, "owner": owner, "key": key, "name": name} + return {**out, "spec": spec.strip()} if spec is not None else out #: 옵션 그룹 id(PLAN 46 · `M02_SO_Groups.GROUP_ID` 와 같은 규칙) diff --git a/common_util/common_util_summary_item.ts b/common_util/common_util_summary_item.ts index d30d08b72..c448daffc 100644 --- a/common_util/common_util_summary_item.ts +++ b/common_util/common_util_summary_item.ts @@ -3,7 +3,9 @@ * 구조물 집계표 항목 참조(PLAN 21-1) — 구조물 · 관 지점이 「어느 집계표 항목」인지 가리키는 값. * * summary_item: { layer: "system"|"company"|"personal", owner: null|"{회사}"|"{회사}/{사용자}", - * key: "SS000004", name: "돌기슭막이 메쌓기 1.2m" } + * key: "SS000004", name: "돌기슭막이 메쌓기 1.2m", spec?: "2.0" } + * + * spec(PLAN 704-4) = 그 항목 일위대가 규격 열 이름 — 없으면 기본 열(서버 `parse_summary_item` 과 짝). * * 없으면 null(= 미지정 · 집계표에 안 들어감). 옵션(options) 밖의 칸이다. * import 없음 — 시험이 이 파일만 옮겨 돌림. @@ -16,6 +18,8 @@ export interface SummaryItem { owner: string | null; key: string; name: string; + /** 규격 열 이름(704-4) — 없으면 기본 열 */ + spec?: string; } const LAYERS: readonly string[] = ["system", "company", "personal"]; @@ -30,7 +34,8 @@ export function isSummaryItem(value: unknown): value is SummaryItem { (item.owner === null || typeof item.owner === "string") && typeof item.key === "string" && item.key !== "" && - typeof item.name === "string" + typeof item.name === "string" && + (item.spec === undefined || typeof item.spec === "string") ); } diff --git a/resources/tester/test_704_4_spec.py b/resources/tester/test_704_4_spec.py new file mode 100644 index 000000000..1743d615c --- /dev/null +++ b/resources/tester/test_704_4_spec.py @@ -0,0 +1,170 @@ +"""704-4 규격 열 — 설계 고르기 = 열(summary_item `spec`) · 사본이 고른 열을 굳힘 · +열 바꿈 뒤 B09 덮개 남음 · B07 한 장 판정 = 항목 + 열 · 열 지움 막음(쓰는 사본). +임시 저장소(실제 마스터 · storage 안 씀 · M01 읽기만). +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any + +import pytest + +from B07_DesignDetail.B07_DesignDetail_Router_Support_Structure import structure_sheets +from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs +from common_util.common_util_summary_item import parse_summary_item +from config import config_system +from M02_MasterTemplete import M02_Item_Snapshot as snap +from M02_MasterTemplete import M02_Labor_Spec as spec +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.helper_m02_master_sample import SAMPLE + +MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"} +SPEC = { + "이름": "H", + "열": ["1.5", "2.5"], + "기본": "1.5", + "수량": {"GF000219": {"1.5": 2, "2.5": 3}}, +} +LABOR = { + "종류": "일위대가", + "열": "ms", + "단위": "m", + "담은로직": [ + {"로직": "GF000219", "수량": {"손": 2}}, # 메쌓기 — 열마다 수량 + {"로직": "GF000119", "수량": {"손": 0.5}}, # 노체포설 — 공통 + ], + "자재": [], + "규격": SPEC, +} + + +# ── summary_item spec ────────────────────────────────────────────────────── +def test_summary_item_spec_있음_없음(): + assert parse_summary_item({**MS, "spec": " 2.5 "}) == {**MS, "spec": "2.5"} + assert parse_summary_item(MS) == MS # 옛 기록 = 칸 없음 그대로 + with pytest.raises(ValueError, match="모르는 칸"): + parse_summary_item({**MS, "열": "2.5"}) + for bad in ("", "x" * 21, 2.5): + with pytest.raises(ValueError, match="spec"): + parse_summary_item({**MS, "spec": bad}) + + +# ── 굳히기 ───────────────────────────────────────────────────────────────── +def test_굳히기_고른_열_지운_열_옛_문서(): + got = spec.freeze({**LABOR, "계산값": {"x": 1}}, "2.5") + assert got["담은로직"][0]["수량"] == {"손": 3} and got["담은로직"][1]["수량"] == {"손": 0.5} + assert got["규격"] == {"이름": "H", "열": ["2.5"], "기본": "2.5", "수량": {}} + assert "계산값" not in got + assert spec.freeze(LABOR, "지운열")["담은로직"][0]["수량"] == {"손": 2} # = 기본 + old = {k: v for k, v in LABOR.items() if k != "규격"} + assert spec.freeze(old, "2.5") is old + + +# ── 임시 저장소 사본 ─────────────────────────────────────────────────────── +@pytest.fixture +def world(tmp_path: Path, monkeypatch) -> dict[str, Any]: + system = tmp_path / "master" + system.mkdir() + shutil.copy2(SAMPLE / layers.COMBO_FILE, system / layers.COMBO_FILE) + for kind in ("table", "structure", "area"): + shutil.copytree(SAMPLE / kind, system / kind) + (system / "labor").mkdir() + (system / "labor" / "ms.json").write_text(json.dumps(LABOR), encoding="utf-8") + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + storage = tmp_path / "storage" + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + root = storage / "3" / "12" / "proj" + (root / "B05_Profile/route").mkdir(parents=True) + (root / "B04_PreProcess/drainage/edits").mkdir(parents=True) + return {"system": system, "root": root} + + +def _save(root: Path, specs: list[str | None]) -> dict[str, list[str]]: + rows = [ + { + "structure_id": f"st{n}", + "type_id": "masonry_dry", + "placement": "interval", + "chainage_m": 85 + n * 100, + "start_m": 80 + n * 100, + "end_m": 90 + n * 100, + "options": {"height_m": 2.0, "length_m": 10}, + "summary_item": {**MS, **({"spec": s} if s else {})}, + } + for n, s in enumerate(specs) + ] + (root / "B05_Profile/route/structures.json").write_text( + json.dumps({"revision": 1, "structures": rows}, ensure_ascii=False), encoding="utf-8" + ) + (root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text( + '{"points": []}', encoding="utf-8" + ) + return snap.sync_snapshots(root) + + +def _block(root: Path, owner: str) -> tuple[dict, dict]: + got = daega_inputs(root) + return got, next(b for b in got["blocks"] if b["id"] == got["owners"][owner]) + + +def test_사본이_고른_열을_굳힘_manifest_규격(world): + root = world["root"] + assert _save(root, ["2.5"])["만듦"] == ["st0"] + folder = snap.structures_dir(root) / "st0" + doc = json.loads((folder / "일위대가.json").read_text(encoding="utf-8"))["줄"] + assert [x["수량"] for x in doc["담은로직"]] == [{"손": 3}, {"손": 0.5}] + manifest = json.loads((folder / snap.MANIFEST_NAME).read_text(encoding="utf-8")) + assert manifest["규격"] == {"이름": "H", "열": "2.5"} and manifest["원본"]["spec"] == "2.5" + assert _save(root, ["2.5"])["만듦"] == [] # 같은 항목 · 같은 열 = 그대로 + + +def test_열_바꿈_뒤_덮개_남음(world): + root = world["root"] + _save(root, ["1.5"]) + _, before = _block(root, "st0") + cids = [ln["cid"] for ln in before["lines"]] + assert before["cover"] == "S:st0" and [ln["qty"] for ln in before["lines"]] == [2, 0.5] + cover = {before["cover"]: {cids[1]: {"qty": 0.7}}} # 공통 줄 품을 고침 + got = _save(root, ["2.5"]) + assert got["지움"] == ["st0"] and got["만듦"] == ["st0"] # 열 바꿈 = 새 사본 + after_all, after = _block(root, "st0") + assert after["cover"] == before["cover"] and [ln["cid"] for ln in after["lines"]] == cids + sheets = {"labor": [], "material": [], "machine": [], "gaps": []} + out = compute_labor(after_all, sheets, {}, cover) + lines = next(b for b in out["daega_blocks"] if b["id"] == after["id"])["lines"] + assert lines[0]["qty"] == 3 # 새 열 수량 + assert lines[1]["qty"] == 0.7 and lines[1]["covered"] == ["qty"] # 덮개 그대로 + assert out["daega_orphans"] == [] + + +def test_B07_한_장_판정_항목_더하기_열(world): + root = world["root"] + _save(root, ["1.5", "1.5", "2.5"]) + ids = [s["id"] for s in structure_sheets(root) if s["id"].startswith("structure_system")] + assert len(set(ids)) == 2 # 같은 열 둘 = 한 장 · 다른 열 = 따로 + + +def test_열_지움_쓰는_사본_찾기(world): + root = world["root"] + _save(root, ["2.5", None]) + assert spec.users(world["system"], {"SS000022"}, {"2.5"}) == ["proj/st0"] + assert spec.users(world["system"], {"SS000022"}, {"1.5"}) == [] + assert spec.users(world["system"], {"SS000001"}, {"2.5"}) == [] + + +def test_쓰는_열_지움_409_안_쓰는_열_됨(world): + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + root = world["root"] + _save(root, ["2.5"]) + path = world["system"] / "labor" / "ms.json" + with store.at(world["system"]): + with pytest.raises(store.StoreError) as got: + spec.guard(path, {"규격": {**SPEC, "열": ["1.5"]}}, "ms") + assert got.value.status == 409 and "proj/st0" in str(got.value.detail) + spec.guard(path, {"규격": {**SPEC, "열": ["2.5", "3.0"]}}, "ms") # 1.5 = 안 씀 + spec.guard(path, {"규격": SPEC}, "ms") From 56e2c0b5154f2d365b6abb01e0cc7cb24295cc9b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 14:23:46 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat(=EA=B7=9C=EA=B2=A9=20=ED=91=9C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4):=20=EA=B2=A9=EC=9E=90=20=EA=B7=9C=EA=B2=A9?= =?UTF-8?q?=20=EC=97=B4=20=C2=B7=20M02=20=EA=B7=9C=EA=B2=A9=20=ED=91=9C=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=C2=B7=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=B0=BD=20=EC=97=B4=20=EB=8B=A8=EC=B6=94=20(PLAN=20704-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 격자 — 첫 호표 specs 면 수량 칸을 열마다(머리 「H 열」 · 기본 표시) · 합계 줄 그 칸 = 열마다 1 단위 합계 - M02 일위대가 카드 · 상세 — 격자 위 규격 줄(이름 · 열 이름 · 기본 · 열 지움 · [+ 열]) · 격자 수량 칸 열마다 고침(짝 셈 바로) - 새 파일 M02_MasterTemplete_Labor_Spec.ts — 상세(700 줄)는 한 줄만 바꿈 - 고르기 창 — 규격 열 둘 이상이면 열 단추(열마다 합계) · [적용] = spec 붙은 참조 · 이름 뒤 「(H=열)」 · 열 하나 = 단추 없음 - 시험 704-3 격자 시험 두 줄 고침(laborGrid 넷째 인자 · Labor_Spec 이어 붙임) · 704-4 화면 자료 시험 둘 Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_Labor_Api.ts | 4 +- .../M02_MasterTemplete_Labor_Detail.ts | 4 +- .../M02_MasterTemplete_Labor_Grid.ts | 17 +- .../M02_MasterTemplete_Labor_Spec.ts | 189 ++++++++++++++++++ resources/tester/test_704_3_hopyo_grid.py | 3 +- resources/tester/test_704_4_spec.py | 56 ++++++ ui_template/ui_template_hopyo_grid.ts | 39 +++- ui_template/ui_template_item_picker.ts | 44 +++- ui_template/ui_template_item_picker_data.ts | 33 ++- 9 files changed, 377 insertions(+), 12 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 82d64ec84..6005b80f2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -11,7 +11,7 @@ import { type LogicSummary, type SubBrief, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import type { LaborCalc, LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import type { LaborCalc, LaborRef, LaborSpec } from "./M02_MasterTemplete_Labor_Calc"; import { call, getActiveLayer, @@ -238,6 +238,8 @@ export interface LaborDoc { 담은로직: LaborRow[]; 자재: LaborMat[]; 계산값?: unknown; + /** 규격 열(704-4 · `Labor_Spec`) — 없으면 옛 문서(열 하나) */ + 규격?: LaborSpec | null; } /** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */ export function normalizeLabor(doc: unknown, column: string): LaborDoc { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index dfdcf42fc..5c7b65d42 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -13,7 +13,7 @@ * · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 자재목록(175-1) · 원문(면적 · 다이어그램 없음) · * 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1) * · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음) - * · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만 + * · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만 · 규격 표(704-4 · `Labor_Spec`) * ========================================================================== */ import { @@ -218,7 +218,7 @@ export function mountLaborDetail( ), ); const draw = (): void => { - gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics)); + gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && !lib && changed)); drawLogic(); matHost.replaceChildren( matTable( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts index c2a4b7b9e..b2addf083 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts @@ -6,6 +6,7 @@ * 로직 줄 = 이름(별칭 앞섬) · 규격(옵션 값 · 켜쌓기) · 수량 · 분모 · 단가 = 기준 ÷ 배 · 금액 = 반영 · 꼬리표 품셈 * 자재 줄 = 이름 · 규격 · 수량 · 단위 · 재료비 단가 = 기준단가 · 금액 = 반영 · 별도계상은 비고 「합계 밖」 * 빈 줄(금액 모름 · 수량 없음) = 합계 줄 「빈 줄 n」 붉게 — 701-4 경고와 같은 줄 + * 규격 열(704-4 · `Labor_Spec`) = 수량 칸 열마다 · 고칠 때 격자 위 규격 줄 + 수량 칸 고침 * ========================================================================== */ import { @@ -20,6 +21,7 @@ import type { LaborCalc, LaborCost, LaborDoc } from "./M02_MasterTemplete_Labor_ import { lineId } from "./M02_MasterTemplete_Labor_Calc"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { valueLine } from "./M02_Labor_Values_Model"; +import { specBar, specCells, withSpecs, type SpecEdit } from "./M02_MasterTemplete_Labor_Spec"; const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney => m @@ -77,7 +79,7 @@ export function laborBlock( }; }); const lines = [...logicLines, ...matLines]; - return { + const block: HopyoBlock = { key: doc.열, name: doc.일위대가번 || doc.열, unit: doc.단위 ?? "", @@ -85,11 +87,20 @@ export function laborBlock( lines, ...(calc ? {} : { blank: 0 }), }; + return withSpecs(block, doc, calc); } -/** 격자 하나(읽기 전용) */ +/** 격자 하나 — `edit` 있으면 위에 규격 줄 · 규격 수량 칸 고침(704-4) */ export const laborGrid = ( doc: LaborDoc, calc: LaborCalc | null, logics: ReadonlyMap, -): HTMLElement => createHopyoGrid([laborBlock(doc, calc, logics)]).root; + edit?: SpecEdit | false, +): HTMLElement => { + const block = laborBlock(doc, calc, logics); + if (!edit) return createHopyoGrid([block]).root; + const grid = createHopyoGrid([block], specCells(block, doc, edit)).root; + const box = document.createElement("div"); + box.append(specBar(doc, calc, edit), grid); + return box; +}; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts new file mode 100644 index 000000000..fd31eb32a --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts @@ -0,0 +1,189 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Spec.ts + * 일위대가 규격 표(PLAN 704-4) — 704-3 격자에 규격 열(H=1.5 | 2.0 …)을 폄 · 고칠 때 격자 위 규격 줄. + * 문서 `규격` = {이름, 열, 기본, 수량: {줄 id: {열: 수}}}(짝 `M02_Labor_Spec.py`) · 화면 캐시만(저장은 [저장]) + * 규격 줄 = 규격 이름 · 열마다 이름 칸 · [기본] · [×] · [+ 열](새 열 수량 = 지금 기본 열 값 그대로) + * 격자 수량 칸(열마다) 고침 = 그 줄 · 그 열 수량(빈 글 = 공통 수량을 따름) + * 열마다 수량 · 1 단위 합계 = 짝 셈 `settle(…, 열)`(서버 안 부름) · 금액 칸 = 기본 열 + * 쓰는 사본이 있는 열 지움 · 이름 바꿈은 [저장] 때 서버가 막음(409 까닭 그대로) + * ========================================================================== */ + +import { createButton, createInputField, el } from "@ui/ui_template_elements"; +import type { DataTableOptions } from "@ui/ui_template_data_table"; +import { hopyoGrid, hopyoSpecKey, type HopyoBlock } from "@ui/ui_template_hopyo_grid"; +import { createToolbar } from "@ui/ui_template_toolbar"; +import { createText } from "@ui/ui_template_text"; +import type { LaborCalc, LaborDoc } from "./M02_MasterTemplete_Labor_Api"; +import { givenOf, lineId, settle, specColumn } from "./M02_MasterTemplete_Labor_Calc"; + +/** 고칠 때 — 규격이 바뀌면 `true`(수량만 · 기준 그대로 · 짝 셈으로 바로) */ +export type SpecEdit = (qtyOnly: boolean) => void; + +const TEXT = { + none: "규격 열 없음 — [+ 열] = H 같은 규격마다 줄 수량만 다른 열", + add: "+ 열", + name: "규격 이름", + base: "기본", + remove: "열 지움", + first: "1", + next: "새 열", +}; + +/** 줄 id 차례 — 격자 줄 차례(로직 줄 → 자재 줄)와 같음 */ +const lineIds = (doc: LaborDoc): string[] => [ + ...doc.담은로직.map((r) => lineId(r) ?? ""), + ...doc.자재.map((m) => m.코드), +]; + +/** 열마다 셈 → 블록 `specs` · 줄 `qtys`(규격 없음 = 그대로) */ +export function withSpecs(block: HopyoBlock, doc: LaborDoc, calc: LaborCalc | null): HopyoBlock { + const spec = doc.규격; + if (!spec?.열.length) return block; + const per = calc ? spec.열.map((c) => settle(calc, doc, givenOf(calc), c)) : []; + const ids = lineIds(doc); + const logics = doc.담은로직.length; + const qtyOf = (p: LaborCalc, n: number): number | null => + (n < logics + ? p.줄.find((l) => lineId(l) === ids[n])?.수량 + : p.자재.find((m) => m.코드 === ids[n])?.수량) ?? null; + return { + ...block, + specs: { + name: spec.이름, + cols: spec.열, + pick: specColumn(doc), + totals: per.map((p) => p.반영합?.계 ?? null), + }, + lines: block.lines.map((ln, n) => ({ ...ln, qtys: per.map((p) => qtyOf(p, n)) })), + }; +} + +/** 격자 수량 칸 고침(열마다) — 공용 표 옵션 덧 */ +export function specCells( + block: HopyoBlock, + doc: LaborDoc, + edit: SpecEdit, +): Partial { + const spec = doc.규격; + if (!spec?.열.length) return {}; + const g = hopyoGrid([block]); + const ids = lineIds(doc); + const col = (key: string): string | undefined => spec.열.find((_, n) => hopyoSpecKey(n) === key); + return { + editable: (i, key) => g.at.has(i) && col(key) !== undefined, + onEdit: (i, key, text) => { + const id = ids[g.at.get(i)?.[1] ?? -1]; + const name = col(key); + if (id === undefined || name === undefined) return; + const v = Number(text.replace(/,/g, "").trim()); + const row = { ...(spec.수량[id] ?? {}) }; + if (text.trim() === "" || !Number.isFinite(v)) delete row[name]; + else row[name] = v; + if (Object.keys(row).length) spec.수량[id] = row; + else delete spec.수량[id]; + edit(true); + }, + }; +} + +/** 새 열 이름 — 겹치면 뒤에 번호 */ +const fresh = (cols: string[], want: string): string => { + let out = want; + for (let n = 2; cols.includes(out); n++) out = `${want} ${n}`; + return out; +}; + +/** [+ 열] — 규격이 없으면 지금 수량 열(1) + 새 열 · 새 열 수량 = 기본 열 손 값 그대로 */ +function addColumn(doc: LaborDoc, calc: LaborCalc | null): void { + const spec = (doc.규격 ??= { 이름: "H", 열: [TEXT.first], 기본: TEXT.first, 수량: {} }); + const col = fresh(spec.열, TEXT.next); + const base = specColumn(doc) ?? spec.열[0]; + spec.열.push(col); + const ids = lineIds(doc); + const logics = doc.담은로직.length; + ids.forEach((id, n) => { + const own = spec.수량[id]?.[base]; + const line = + n < logics ? calc?.줄.find((l) => lineId(l) === id) : calc?.자재.find((m) => m.코드 === id); + const v = own ?? (line?.수량출처 === "손" ? line.수량 : null); + if (v != null) spec.수량[id] = { ...(spec.수량[id] ?? {}), [col]: v }; + }); +} + +function renameColumn(doc: LaborDoc, from: string, to: string): boolean { + const spec = doc.규격; + const name = to.trim(); + if (!spec || !name || name.length > 20 || name === from || spec.열.includes(name)) return false; + spec.열 = spec.열.map((c) => (c === from ? name : c)); + if (spec.기본 === from) spec.기본 = name; + for (const row of Object.values(spec.수량)) + if (from in row) { + row[name] = row[from]; + delete row[from]; + } + return true; +} + +function removeColumn(doc: LaborDoc, col: string): void { + const spec = doc.규격; + if (!spec) return; + spec.열 = spec.열.filter((c) => c !== col); + for (const [id, row] of Object.entries(spec.수량)) { + delete row[col]; + if (!Object.keys(row).length) delete spec.수량[id]; + } + if (!spec.열.length) + doc.규격 = null; // 마지막 열 = 규격 없음(옛 문서 꼴) + else if (spec.기본 === col) spec.기본 = spec.열[0]; +} + +/** 격자 위 규격 줄(고칠 때만) */ +export function specBar(doc: LaborDoc, calc: LaborCalc | null, edit: SpecEdit): HTMLElement { + const add = createButton({ + label: TEXT.add, + variant: "ghost", + onClick: () => { + addColumn(doc, calc); + edit(true); + }, + }); + const spec = doc.규격; + if (!spec?.열.length) + return createToolbar({ items: [createText(TEXT.none, "muted"), add], label: TEXT.name }); + const name = createInputField({ value: spec.이름, compact: true, placeholder: TEXT.name }); + name.input.title = TEXT.name; + name.input.addEventListener("change", () => { + const v = name.input.value.trim(); + if (v && v.length <= 20) spec.이름 = v; + edit(true); + }); + const cols = spec.열.flatMap((col) => { + const box = createInputField({ value: col, compact: true }); + box.input.setAttribute("data-spec-col", col); + box.input.addEventListener("change", () => { + renameColumn(doc, col, box.input.value); + edit(true); + }); + const base = createButton({ + label: TEXT.base, + variant: col === spec.기본 ? "filled" : "ghost", + size: "sm", + onClick: () => { + spec.기본 = col; + edit(true); + }, + }); + const drop = createButton({ + label: "×", + variant: "ghost", + size: "sm", + onClick: () => { + removeColumn(doc, col); + edit(true); + }, + }); + drop.title = TEXT.remove; + return [el("span", { children: [box.root] }), base, drop, "divider" as const]; + }); + return createToolbar({ items: [name.root, "divider", ...cols, add], label: TEXT.name }); +} diff --git a/resources/tester/test_704_3_hopyo_grid.py b/resources/tester/test_704_3_hopyo_grid.py index bb9b3e521..935a63bcd 100644 --- a/resources/tester/test_704_3_hopyo_grid.py +++ b/resources/tester/test_704_3_hopyo_grid.py @@ -95,6 +95,7 @@ CALC = {"줄": [{"로직": "GF1", "분모": "㎡", "배": 10, "수량": 2.09, def test_M02_문서_calc_를_격자_줄로(): files = [GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts", + "M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts", "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip logics = [ ["GF1", {"이름": "돌쌓기 찰쌓기", "입력": [], "결과단위": "㎡"}], @@ -134,7 +135,7 @@ def test_M02_문서_calc_를_격자_줄로(): def test_세_곳_같은_부품(): detail = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8") - assert "laborGrid(cur, calc ?? last, logics)" in detail # 카드 · 상세 = 같은 Labor_Detail + assert "laborGrid(cur, calc ?? last, logics, " in detail # 카드 · 상세 = 같은 Labor_Detail m02 = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts").read_text("utf-8") b09 = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8") assert '"@ui/ui_template_hopyo_grid"' in m02 and '"@ui/ui_template_hopyo_grid"' in b09 diff --git a/resources/tester/test_704_4_spec.py b/resources/tester/test_704_4_spec.py index 1743d615c..805e17604 100644 --- a/resources/tester/test_704_4_spec.py +++ b/resources/tester/test_704_4_spec.py @@ -168,3 +168,59 @@ def test_쓰는_열_지움_409_안_쓰는_열_됨(world): assert got.value.status == 409 and "proj/st0" in str(got.value.detail) spec.guard(path, {"규격": {**SPEC, "열": ["2.5", "3.0"]}}, "ms") # 1.5 = 안 씀 spec.guard(path, {"규격": SPEC}, "ms") + + +# ── 화면 자료(TS · Node) — 격자 규격 열 · 고르기 창 spec ───────────────────── +def test_격자_규격_열_수량_칸_열마다(): + from resources.tester import test_704_3_hopyo_grid as g + + files = [g.GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts", + "M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts", + "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip + doc = {**g.DOC, "규격": {"이름": "H", "열": ["1.5", "2.0"], "기본": "2.0", + "수량": {"GF1": {"1.5": 1.5, "2.0": 2.09}, "MT1": {"1.5": 4}}}} # fmt: skip + doc["자재"] = [{**doc["자재"][0], "수량": {"손": 5}}, {**doc["자재"][1], "수량": {"손": 2}}] + out = g._node(files, ( + f"\nconst b = laborBlock({json.dumps(doc, ensure_ascii=False)}, {json.dumps(g.CALC, ensure_ascii=False)}, new Map());" + "\nconst grid = hopyoGrid([b]);" + "\nconsole.log(JSON.stringify({ cols: grid.columns.map((c) => [c.key, c.label, c.group])," + " rows: grid.rows, specs: b.specs }));" + ), g.STUBS) # fmt: skip + cols = [c for c in out["cols"] if c[0].startswith("qty")] + assert cols == [["qty_0", "1.5", ["H 열"]], ["qty_1", "2.0 (기본)", ["H 열"]]] + line = out["rows"][1] # 첫 구성 줄 + assert line["qty_0"] == 1.5 and line["qty_1"] == 2.09 + mt1 = next(r for r in out["rows"] if r["name"] == "ㄴ 큰돌") + assert mt1["qty_0"] == 4 and mt1["qty_1"] == 5 # 2.0 빈 열 = 공통 수량 + t15, t20 = out["specs"]["totals"] + assert t20 == pytest.approx(112559.55) # 기본 열 = 금액 칸 합 + assert t15 == pytest.approx(529950 * 1.5 / 10 + 1800) # 열 수량 · 별도계상 MT1 = 합 밖 + + +def test_고르기_창_spec_돌려줌(): + import subprocess + + data = Path(__file__).resolve().parents[2] / "ui_template/ui_template_item_picker_data.ts" + body = ( + data.read_text("utf-8") + + """ +const row = { key: "SS000022", 열: "ms", 경로: ["석축", "메쌓기"], + labor: { 규격: { 이름: "H", 기본: "2.0", 열: [{ 이름: "1.5", 합계: 1 }, { 이름: "2.0", 합계: 2 }] } } }; +const one = { ...row, labor: { 규격: { 이름: "H", 기본: "1.5", 열: [{ 이름: "1.5", 합계: 1 }] } } }; +const plain = { key: "SS000001", 열: "rv" }; +const cur = { layer: "system", owner: null, key: "SS000022", name: "x", spec: "1.5" }; +console.log(JSON.stringify([toSummary(row, "1.5"), toSummary(one, "1.5"), toSummary(plain, "1.5"), + startSpec(row, cur), startSpec(row, null), startSpec(row, { ...cur, spec: "지운" }), startSpec(plain, cur)])); +""" + ) + got = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=body, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert got.returncode == 0, got.stderr + two, single, plain, *starts = json.loads(got.stdout) + assert two == {"layer": "system", "owner": None, "key": "SS000022", + "name": "석축 메쌓기 (H=1.5)", "spec": "1.5"} # fmt: skip + assert single["name"] == "석축 메쌓기" and single["spec"] == "1.5" # 열 하나 = 이름 그대로 + assert "spec" not in plain # 규격 없는 옛 항목 + assert starts == ["1.5", "2.0", "2.0", None] diff --git a/ui_template/ui_template_hopyo_grid.ts b/ui_template/ui_template_hopyo_grid.ts index af7320113..a3a7658da 100644 --- a/ui_template/ui_template_hopyo_grid.ts +++ b/ui_template/ui_template_hopyo_grid.ts @@ -8,6 +8,8 @@ * 구성 줄 = 「ㄴ 품명」 · 규격 · 품/수량 · 단위 · 합계 · 노무비 · 재료비 · 경비(단가 / 금액 쌍) · 비고 · 구분(줄 꼬리표) * 합계 줄 = 비목 금액 합 · 빈 줄(금액 모름 · 수량 없음) 있으면 비고 「빈 줄 n」 붉게 — 합계는 아는 줄만 * 줄 꼬리표 = 「품셈」(우리 로직이 품을 셈) · 「고정」 갈래는 705 몫(자리만 · 같은 칸) + * 규격 열(704-4 · 첫 호표 `specs`) = 수량 칸이 열 수만큼(`qty_n` · 머리 「H 열」) · 합계 줄 그 칸 = 열마다 1 단위 합계 · + * 금액 칸 = 기본 열 값 * 값은 부르는 쪽이 준 것 그대로(서버 계산 값) — 여기서 셈하지 않음 * * const g = createHopyoGrid([block]); host.append(g.root); g.set([block2]); @@ -44,6 +46,16 @@ export interface HopyoLine { note?: string | null; /** 줄 꼬리표 — 「품셈」 · 「고정」(705) · 빈 값 = 없음 */ tag?: string | null; + /** 규격 열마다 품 · 수량(704-4 · 블록 `specs.cols` 차례) — 없으면 `qty` */ + qtys?: (number | null)[]; +} + +/** 규격 열(704-4) — 이름(H) · 열 이름 · 기본 열 · 열마다 1 단위 합계 */ +export interface HopyoSpecs { + name: string; + cols: string[]; + pick?: string | null; + totals: (number | null)[]; } export interface HopyoBlock { @@ -63,6 +75,8 @@ export interface HopyoBlock { /** 빈 줄 수 · 이름 — 없으면 금액 모르는 줄에서 셈 */ blank?: number; blank_names?: string[]; + /** 규격 열(704-4) — 첫 호표 것만 열로 폄 */ + specs?: HopyoSpecs | null; } /** 줄 꼬리표 낱말 */ @@ -106,6 +120,23 @@ export const HOPYO_COLUMNS: DataColumn[] = [ hopyoText("tag", "구분", 56), ]; +/** 규격 열 수량 칸 열쇠(704-4) */ +export const hopyoSpecKey = (n: number): string => `qty_${n}`; + +/** 열 — 첫 호표에 규격 열이 있으면 수량 칸을 열마다 폄(기본 열 = 「(기본)」) */ +export function hopyoColumns(blocks: HopyoBlock[]): DataColumn[] { + const specs = blocks[0]?.specs; + if (!specs?.cols.length) return HOPYO_COLUMNS; + const qty = HOPYO_COLUMNS.find((c) => c.key === "qty") as DataColumn; + const cols = specs.cols.map((col, n): DataColumn => ({ + ...qty, + key: hopyoSpecKey(n), + label: col === specs.pick ? `${col} (기본)` : col, + group: [`${specs.name} 열`], + })); + return HOPYO_COLUMNS.flatMap((c) => (c.key === "qty" ? cols : [c])); +} + export interface HopyoGrid { columns: DataColumn[]; rows: DataRow[]; @@ -126,6 +157,7 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid { const rows: DataRow[] = []; const titles = new Set(); const at = new Map(); + const specCount = blocks[0]?.specs?.cols.length ?? 0; blocks.forEach((b, bi) => { titles.add(rows.length); rows.push({ @@ -153,6 +185,7 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid { note: ln.note ?? "", tag: ln.tag ?? "", }; + for (let k = 0; k < specCount; k++) row[hopyoSpecKey(k)] = ln.qtys?.[k] ?? ln.qty; for (const [key] of HOPYO_COSTS) { row[`${key}_price`] = ln.prices[key] ?? null; row[key] = ln.amounts[key] ?? null; @@ -161,7 +194,11 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid { }); const blank = hopyoBlank(b); titles.add(rows.length); + const sums = Object.fromEntries( + Array.from({ length: specCount }, (_, k) => [hopyoSpecKey(k), b.specs?.totals[k] ?? null]), + ); rows.push({ + ...sums, key: `${b.key}|합계`, name: "합 계", sum: b.total.sum ?? null, @@ -173,7 +210,7 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid { blank_names: blank.names, }); }); - return { columns: HOPYO_COLUMNS, rows, titles, at }; + return { columns: hopyoColumns(blocks), rows, titles, at }; } /** 칸 꼴 — 꼬리표 · 빈 줄 알림 */ diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index 3d6461087..d80b39ab3 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -11,6 +11,8 @@ * 224-1 — 맨 위 = M02 구조물조합 상단 표(구조물집계표 · 보기 전용 · 고른 항목의 열이 골라짐). * 173-2 — 나무 = M02 나무 꼴: 층 단추 셋(하나만 보임 · 뿌리 줄 없음) · 개인 맨 위 「새 항목」 = 빈 자리 오른쪽 클릭. * 280-2 — 위 표 · 나무 · 오른쪽 카드 = 한 상태: 위 표 = 보이는 층 표 · 표에서 열을 고르면 나무(펼쳐 굴림) · 카드도 그 항목. + * 704-4 — 고른 항목에 규격 열이 둘 이상이면 표 아래 열 단추(열마다 1 단위 합계) · [적용] = `spec` 붙은 참조 · + * 처음 = 지금 지정 열(같은 항목) · 아니면 기본 · 열 하나 = 단추 없음(그 열로). * ========================================================================== */ import "./ui_template_item_picker.css"; @@ -25,6 +27,8 @@ import { ownerOf, pathIds, rowId, + specOf, + startSpec, tableKey, toSummary, type PickNode, @@ -47,6 +51,8 @@ import { projectItems, type CreatedItem } from "./ui_template_item_picker_projec import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail"; import { createEditView, type EditView } from "./ui_template_item_picker_edit"; import { createComboTable } from "./ui_template_item_picker_table"; +import { createToolbar } from "./ui_template_toolbar"; +import { createText } from "./ui_template_text"; export type { SummaryItem } from "./ui_template_item_picker_data"; @@ -166,6 +172,10 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { + if (chosen && rowId(chosen) !== specFor) { + specFor = rowId(chosen); + specPick = startSpec(chosen, opts.current); + } + const spec = chosen ? specOf(chosen) : null; + specHost.hidden = !spec || spec.열.length < 2; + if (!spec || specHost.hidden) return void specHost.replaceChildren(); + const sum = (n: number | null): string => + n === null ? "" : ` · ${n.toLocaleString("ko-KR", { maximumFractionDigits: 0 })}`; + const buttons = spec.열.map((col) => { + const button = createButton({ + label: `${spec.이름}=${col.이름}${sum(col.합계)}`, + variant: col.이름 === specPick ? "filled" : "ghost", + size: "sm", + onClick: () => { + specPick = col.이름; + paintSpec(); + }, + }); + button.setAttribute("aria-pressed", String(col.이름 === specPick)); + return button; + }); + const head = createText(`${spec.이름} 규격 열`, "muted"); + specHost.replaceChildren(createToolbar({ items: [head, ...buttons], label: spec.이름 })); + }; + const paintDetail = (): void => { detailView.show(chosen); paintTable(); + paintSpec(); const layer = chosen ? layerOf(chosen) : null; applyButton.disabled = !chosen; clearButton.disabled = !opts.current; @@ -337,7 +375,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise chosen && finish({ action: "apply", item: toSummary(chosen) }), + () => chosen && finish({ action: "apply", item: toSummary(chosen, specPick) }), ); clearButton.addEventListener("click", () => finish({ action: "clear" })); manageButton.addEventListener("click", () => { @@ -407,6 +445,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { - body.append(tableWrap, cols, editHost, ...(opts.projectId ? [actions] : [])); + body.append(tableWrap, specHost, cols, editHost, ...(opts.projectId ? [actions] : [])); paintDetail(); void load(null); }, diff --git a/ui_template/ui_template_item_picker_data.ts b/ui_template/ui_template_item_picker_data.ts index 16aaf25e4..14f39e468 100644 --- a/ui_template/ui_template_item_picker_data.ts +++ b/ui_template/ui_template_item_picker_data.ts @@ -2,6 +2,7 @@ * ui_template_item_picker_data.ts * 항목 고르기의 자료 쪽(화면 없음) — 항목 목록 → 나무 · 찾기 · 폴더 줄 · 돌려줄 `summary_item`. * 모양은 PLAN.md 21-1 약속 그대로: 항목 목록 API 한 줄 = `PickerRow`, 참조 = `SummaryItem`. + * 규격 열(704-4) = 줄 `labor.규격` · 돌려줄 값에 `spec`(열 둘 이상이면 이름 뒤 「(H=2.0)」). * ========================================================================== */ export type PickerLayer = "system" | "company" | "personal"; @@ -12,6 +13,15 @@ export interface SummaryItem { owner: string | null; key: string; name: string; + /** 규격 열 이름(704-4) — 없으면 기본 열 */ + spec?: string; +} + +/** 규격 열(704-4 · 서버 목록 `labor.규격`) — 열마다 1 단위 합계 */ +export interface PickerSpec { + 이름: string; + 열: { 이름: string; 합계: number | null }[]; + 기본: string; } /** 항목 줄이 가리키는 키 칸 — 서버가 `names` 로 이름을 함께 주면 이름을 보임 */ @@ -36,6 +46,8 @@ export interface PickerRow { names?: Partial>; layer?: PickerLayer; owner?: string | null; + /** 일위대가 문서 요약(39-5) — 규격 열만 씀 */ + labor?: { 규격?: PickerSpec } | null; } /** 그룹 차례 = 마스터 · 회사 · 개인 */ @@ -88,8 +100,25 @@ export const rowId = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) /** 상단 표(224-1)가 보이는 집계표 = 고른 항목이 속한 층(+ 주인) 것 — 같은 키면 표는 그대로 두고 열만 옮김 */ export const tableKey = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}`; -export function toSummary(row: PickerRow): SummaryItem { - return { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: itemName(row) }; +/** 그 항목 규격 열 — 없으면 null */ +export const specOf = (row: PickerRow): PickerSpec | null => + row.labor?.규격?.열?.length ? row.labor.규격 : null; + +/** 처음 고른 열 — 지금 지정이 같은 항목이고 그 열이 있으면 그것 · 아니면 기본 */ +export function startSpec(row: PickerRow, current: SummaryItem | null): string | null { + const spec = specOf(row); + if (!spec) return null; + const want = isCurrent(current, row) ? current?.spec : undefined; + return spec.열.some((c) => c.이름 === want) ? (want as string) : spec.기본; +} + +/** 돌려줄 참조 — 규격 열이 있으면 `spec`(열 둘 이상 = 이름 뒤 「(H=열)」) */ +export function toSummary(row: PickerRow, col: string | null = null): SummaryItem { + const out = { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: itemName(row) }; + const spec = specOf(row); + if (!spec || col === null) return out; + const name = spec.열.length > 1 ? `${out.name} (${spec.이름}=${col})` : out.name; + return { ...out, name, spec: col }; } export function isCurrent(current: SummaryItem | null, row: PickerRow): boolean { From 90273507c5184b6807e980f9787e77f553250b58 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 14:41:13 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix(=EA=B7=9C=EA=B2=A9=20=ED=91=9C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4):=20=EC=97=B4=20=ED=95=A9=EA=B3=84=20?= =?UTF-8?q?=ED=95=9C=20=EC=9E=90=EB=A6=AC=20=C2=B7=20=EA=B7=9C=EA=B2=A9=20?= =?UTF-8?q?=EC=A4=84=20=EC=B9=B8=20=ED=8F=AD=20=C2=B7=20B08=20=C2=B7=20B09?= =?UTF-8?q?=20=EA=B8=88=EC=95=A1=20=3D=20=EC=97=B4=20=EC=88=98=EB=9F=89=20?= =?UTF-8?q?=C3=97=20=EB=8B=A8=EA=B0=80=20=EC=8B=9C=ED=97=98=20(PLAN=20704-?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 격자 합계 줄 규격 칸 = 금액 칸처럼 소수 한 자리 - 규격 이름 · 열 이름 칸 6em — 좁은 카드에서 한 줄 - 임시 storage 시험 — 사본 셈 반영합 = 층 문서 그 열 셈(B08) · 대가 줄 금액 = 열 수량 × 단가(B09) Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_Labor_Spec.ts | 2 ++ resources/tester/test_704_4_spec.py | 23 +++++++++++++++++++ ui_template/ui_template_hopyo_grid.ts | 5 +++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts index fd31eb32a..3a903b9ed 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts @@ -152,6 +152,7 @@ export function specBar(doc: LaborDoc, calc: LaborCalc | null, edit: SpecEdit): return createToolbar({ items: [createText(TEXT.none, "muted"), add], label: TEXT.name }); const name = createInputField({ value: spec.이름, compact: true, placeholder: TEXT.name }); name.input.title = TEXT.name; + name.root.style.width = "6em"; // 짧은 글 칸 — 열 이름 칸도 같은 폭(카드 한 줄에) name.input.addEventListener("change", () => { const v = name.input.value.trim(); if (v && v.length <= 20) spec.이름 = v; @@ -160,6 +161,7 @@ export function specBar(doc: LaborDoc, calc: LaborCalc | null, edit: SpecEdit): const cols = spec.열.flatMap((col) => { const box = createInputField({ value: col, compact: true }); box.input.setAttribute("data-spec-col", col); + box.root.style.width = "6em"; box.input.addEventListener("change", () => { renameColumn(doc, col, box.input.value); edit(true); diff --git a/resources/tester/test_704_4_spec.py b/resources/tester/test_704_4_spec.py index 805e17604..7d88c72a0 100644 --- a/resources/tester/test_704_4_spec.py +++ b/resources/tester/test_704_4_spec.py @@ -224,3 +224,26 @@ console.log(JSON.stringify([toSummary(row, "1.5"), toSummary(one, "1.5"), toSumm assert single["name"] == "석축 메쌓기" and single["spec"] == "1.5" # 열 하나 = 이름 그대로 assert "spec" not in plain # 규격 없는 옛 항목 assert starts == ["1.5", "2.0", "2.0", None] + + +def test_B08_B09_금액_열_수량_곱하기_단가(world): + """B08 = 사본 1 단위 값(사본 셈 반영합) = 층 문서 그 열 셈 · B09 = 대가 줄 금액 = 열 수량 × 단가.""" + from M02_MasterTemplete import M02_Item_Prices as item_prices + from M02_MasterTemplete import M02_Labor_Doc as labor + + root = world["root"] + _save(root, ["2.5"]) + folder = snap.structures_dir(root) / "st0" + read = lambda name: json.loads((folder / name).read_text(encoding="utf-8")) # noqa: E731 + copy = item_prices.calc(read("일위대가.json"), read("단가.json"), read("입력값.json"), {}, None) + layer = labor.clean(LABOR) + want = labor.settle(labor.calc(layer, {}), layer, {}, "2.5")["반영합"] + assert copy["반영합"]["계"] == pytest.approx(want["계"]) # B08 1 단위 값 = 2.5 열 + first = labor.settle(labor.calc(layer, {}), layer, {}, "1.5")["반영합"] + assert want["계"] != pytest.approx(first["계"]) + got, block = _block(root, "st0") + price = {ln["key"]: {"labor": 1000.0, "material": 0.0, "expense": 0.0} for ln in block["lines"]} + sheets = {"labor": [], "material": [], "machine": [], "gaps": []} + out = compute_labor(got, sheets, price, {}) + lines = next(b for b in out["daega_blocks"] if b["id"] == block["id"])["lines"] + assert [ln["amounts"]["labor"] for ln in lines] == [3000, 500] # 3 × 1000 · 공통 0.5 × 1000 diff --git a/ui_template/ui_template_hopyo_grid.ts b/ui_template/ui_template_hopyo_grid.ts index a3a7658da..8d5eedc4a 100644 --- a/ui_template/ui_template_hopyo_grid.ts +++ b/ui_template/ui_template_hopyo_grid.ts @@ -195,7 +195,10 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid { const blank = hopyoBlank(b); titles.add(rows.length); const sums = Object.fromEntries( - Array.from({ length: specCount }, (_, k) => [hopyoSpecKey(k), b.specs?.totals[k] ?? null]), + Array.from({ length: specCount }, (_, k) => { + const sum = b.specs?.totals[k]; + return [hopyoSpecKey(k), sum == null ? null : Math.round(sum * 10) / 10]; // 금액 칸처럼 한 자리 + }), ); rows.push({ ...sums, From 4ed0509c7a9b1c5fc47b80da76fc0bb4a86420cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 14:47:08 +0900 Subject: [PATCH 5/9] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20513=20=EC=9B=90=EB=AC=B8=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=E2=80=94=20=EC=82=B0=EB=A6=BC=EC=B2=AD=20map.forest.go.kr=20?= =?UTF-8?q?=EC=A3=BC=EC=A0=9C=EB=8F=84=20API=20=ED=99=95=EC=9D=B8=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9 --- docs/raw/요구_1025/사용자요구_1025.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1025/사용자요구_1025.md b/docs/raw/요구_1025/사용자요구_1025.md index 722e64430..162dd83f8 100644 --- a/docs/raw/요구_1025/사용자요구_1025.md +++ b/docs/raw/요구_1025/사용자요구_1025.md @@ -1017,3 +1017,7 @@ CPU는 더늘리기는 힘들어보이고 GPU 사용을 위해 부품사용에 ## 요구 512 (10-31 · 사용자) 하는 동안에 나와대화해보자. 브이월드나 공공데이터포털에 정보가 있을것같은데.임상도라고 알아? + +## 요구 513 (10-31 · 사용자) + +내가 조사를 해봤는데 산림청에 데이터가 있는데 API 있는지는 모르겠어. (산림청 map.forest.go.kr — 산길(임도망도) · 나무지도(대축적 임상도) · 산림토양도 · 산사태위험지도 · 경제림육성단지) 이런 정보들이 있는데 확인만 해봐. From 74352ec6b8e9e8b44138b1d027cfaba5046d6767 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 14:52:30 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat(=ED=9A=8C=EC=82=AC=EC=97=90=20?= =?UTF-8?q?=EC=98=AC=EB=A6=AC=EA=B8=B0):=20=EC=9A=94=EC=B2=AD=20=EC=9E=A5?= =?UTF-8?q?=EB=B6=80=20=C2=B7=20=EC=9A=94=EC=B2=AD=20=C2=B7=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=EC=8A=B9=EC=9D=B8=20=C2=B7=20=EA=B1=B0?= =?UTF-8?q?=EC=A0=88=20=C2=B7=20=EC=95=8C=EB=A6=BC=20=EB=B4=84=20=EA=B8=B8?= =?UTF-8?q?=20+=20=EC=9E=84=EC=8B=9C=20storage=20=EC=8B=9C=ED=97=98=20(PLA?= =?UTF-8?q?N=20706-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회사 층 폴더 올리기요청.json 하나 · DB 안 바꿈 - 승인 = 「회사로 복사…」와 같은 길(구조물 = 붙여넣기 · 사전조합 = 서고 복사 + 폴더 자리) · 원본 개인 것 그대로 - 권한 = M02_Layer_Access 재사용 · 일반 승인 403 · 다른 회사 장부 안 봄 · 같은 것 다시 요청 409 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Share_Request.py | 243 +++++++++++++++++++ main.py | 5 + resources/tester/test_706_1_share_request.py | 129 ++++++++++ 3 files changed, 377 insertions(+) create mode 100644 M02_MasterTemplete/M02_Share_Request.py create mode 100644 resources/tester/test_706_1_share_request.py diff --git a/M02_MasterTemplete/M02_Share_Request.py b/M02_MasterTemplete/M02_Share_Request.py new file mode 100644 index 000000000..d2ce53c8f --- /dev/null +++ b/M02_MasterTemplete/M02_Share_Request.py @@ -0,0 +1,243 @@ +"""회사에 올리기 요청 · 승인(PLAN 706-1 · 요구서 4장 6) — 일반 사용자가 개인 층 항목 · +사전조합을 회사 층에 올려 달라고 요청 · 회사 관리자가 승인(= 「회사로 복사…」와 같은 길 · +원본은 그대로) · 거절. + +장부 = 회사 층 폴더 `올리기요청.json` 하나(DB 안 씀) · `{줄: [요청 …]}`. +요청 = `{id, 종류(item · precombo), 키, 이름, 경로(항목 폴더), 주인 C/U, 요청자, 요청자이름, +글, 날짜, 상태(요청 · 승인 · 거절), 처리자, 처리날짜, 사유, 사본, 봄}` · `봄` = 요청자가 결과 +알림을 닫음. 권한 = `M02_Layer_Access` 한 벌 — 요청 = 자기 개인 층 · 목록 = 회사 관리자는 +그 회사 전부 · 일반은 자기 것 · +승인 · 거절 = 회사 층 쓰기(일반 403) · 다른 회사 장부는 안 봄(시스템 관리자만 `company`). +같은 항목(주인 · 종류 · 키)이 「요청」 상태면 다시 요청 = 409 「요청 중」. +⚠ 등록은 `main.py`(로그인만). +""" + +from __future__ import annotations + +import json +import threading +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Literal + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from common_util.common_util_auth import verify_session +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_Item_Paste as paste_store +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Precombo_Folders as pre_folders +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import ( + NotedRoute, + Session, + _call, + _root, +) + +router = APIRouter(prefix="/api/m02", tags=["M02 Share Request"], route_class=NotedRoute) + +FILE = "올리기요청.json" +# ponytail: 한 프로세스 잠금 — 서버를 여럿 띄우면 파일 잠금으로 +_LOCK = threading.Lock() + + +class AskBody(BaseModel): + 종류: Literal["item", "precombo"] + 키: str + 글: str = "" + + +class ApproveBody(BaseModel): + 경로: list[str] = [] + + +class RejectBody(BaseModel): + 사유: str = "" + + +def _company(session: Session, company: str | None) -> str: + """장부 회사 — 시스템 관리자만 `company` 로 남의 회사 · 그 밖은 자기 회사(없으면 409).""" + if company and access.is_system_admin(session): + return str(company) + own = session.get("company_id") + if own is None: + raise HTTPException(status_code=409, detail=access.NO_COMPANY) + return str(own) + + +def _path(company: str) -> Path: + return access.folder(access.Grant("company", company, False, True)) / FILE + + +def _read(company: str) -> list[dict[str, Any]]: + path = _path(company) + if not path.is_file(): + return [] + return list(json.loads(path.read_bytes()).get("줄") or []) + + +def _write(company: str, rows: list[dict[str, Any]]) -> None: + atomic_write_json(_path(company), {"줄": rows}) + + +def _now() -> str: + return datetime.now().isoformat(timespec="seconds") + + +def _source(session: Session, kind: str, key: str) -> dict[str, Any]: + """자기 개인 층 원본 — `{이름, 경로}`(없으면 404).""" + folder = _root(session, "personal") + if kind == "item": + found = next((r for r in items_store.items(folder) if r.get("key") == key), None) + if found is None: + raise HTTPException(status_code=404, detail=f"없는 항목 「{key}」") + path = list(found.get("경로") or []) + return {"이름": path[-1] if path else key, "경로": path[:-1]} + owner = access.own_owner(session, "personal") + doc = ( + refs.read_ref({"층": "personal", "주인": owner, "키": key}) + if refs._KEY.match(key) + else None + ) + if doc is None or refs.kind_of(key) != "precombo": + raise HTTPException(status_code=404, detail=f"없는 사전조합 「{key}」") + return {"이름": str(doc.get("이름") or key), "경로": []} + + +@router.post("/share-requests") +def ask(body: AskBody, session: Session = Depends(verify_session)) -> dict[str, Any]: + """요청 — 자기 개인 층 것만 · 같은 것이 「요청」 이면 409 「요청 중」.""" + owner = access.own_owner(session, "personal") + if owner is None: + raise HTTPException(status_code=409, detail=access.NO_COMPANY) + src = _source(session, body.종류, body.키) + company = owner.split("/")[0] + row = { + "id": uuid.uuid4().hex[:12], + "종류": body.종류, + "키": body.키, + **src, + "주인": owner, + "요청자": session.get("user_id"), + "요청자이름": session.get("name") or "", + "글": body.글.strip()[:200], + "날짜": _now(), + "상태": "요청", + } + with _LOCK: + rows = _read(company) + same = (owner, body.종류, body.키) + if any((r["주인"], r["종류"], r["키"]) == same and r["상태"] == "요청" for r in rows): + raise HTTPException(status_code=409, detail="요청 중 — 회사 관리자가 아직 안 봄") + _write(company, [*rows, row]) + return row + + +@router.get("/share-requests") +def listing( + company: str | None = None, session: Session = Depends(verify_session) +) -> dict[str, Any]: + """목록 — 회사를 맡은 사람 = 그 회사 전부 · 그 밖 = 자기 요청만 · `관리` = 승인할 수 있나.""" + at = _company(session, company) + rows = _read(at) + boss = access.manages(session, at) + if not boss: + me = session.get("user_id") + rows = [r for r in rows if r.get("요청자") == me] + return {"회사": at, "관리": boss, "줄": rows} + + +def _settle(session: Session, company: str | None, rid: str, done: dict[str, Any]) -> dict: + """요청 한 줄을 끝냄 — 회사 층 쓰기 권한(일반 403) · 「요청」 상태만(아니면 409).""" + at = _company(session, company) + access.require(session, "company", at, write=True) + with _LOCK: + rows = _read(at) + row = next((r for r in rows if r.get("id") == rid), None) + if row is None: + raise HTTPException(status_code=404, detail="없는 요청") + if row["상태"] != "요청": + raise HTTPException(status_code=409, detail=f"이미 {row['상태']}") + extra = done.pop("run", None) + if extra: + done["사본"] = extra(row, at) + row.update({**done, "처리자": session.get("name") or "", "처리날짜": _now(), "봄": False}) + _write(at, rows) + return row + + +def _copy(session: Session, dest: list[str]): + """승인 = 「회사로 복사…」와 같은 길 — 구조물 항목 = 붙여넣기 · + 사전조합 = 서고 복사(+ 폴더 자리).""" + + def run(row: dict[str, Any], company: str) -> str: + target = _root(session, "company", company, write=True) + source = _root(session, "personal", row["주인"]) + if row["종류"] == "item": + ref = [{"경로": row.get("경로") or [], "키": row["키"]}] + origin = {"layer": "personal", "owner": row["주인"]} + try: + note = paste_store.paste(source, target, ref, dest, origin) + except store.StoreError as e: + raise HTTPException(status_code=e.status, detail=e.detail) from e + return str(note["열"][0]) + ref = {"층": "personal", "주인": row["주인"], "키": row["키"]} + doc = refs.read_ref(ref) + if doc is None: + raise HTTPException(status_code=404, detail=f"원본이 없음 「{row['키']}」") + + def make() -> dict[ + str, Any + ]: # 못 읽는 참조 같이 복사 = 받는 층 안에서(`new_library` 와 같음) + return refs.create("precombo", refs.carry(refs.origin_mark(doc, ref), here)) + + here = ("personal", row["주인"]) + made = _call(target, make) + if dest: + view = _call(target, pre_folders.view) + move = {"op": "move", "키": made["이름"], "새경로": dest, "판": view["판"]} + _call(target, pre_folders.edit, move) + return str(made["이름"]) + + return run + + +@router.post("/share-requests/{rid}/approve") +def approve( + rid: str, + body: ApproveBody, + company: str | None = None, + session: Session = Depends(verify_session), +) -> dict[str, Any]: + """승인 — 회사 층 `경로` 폴더에 사본(새 키 · 같은 이름 「(1)」) · 원본 개인 것은 그대로.""" + return _settle(session, company, rid, {"상태": "승인", "run": _copy(session, body.경로)}) + + +@router.post("/share-requests/{rid}/reject") +def reject( + rid: str, + body: RejectBody, + company: str | None = None, + session: Session = Depends(verify_session), +) -> dict[str, Any]: + return _settle(session, company, rid, {"상태": "거절", "사유": body.사유.strip()[:200]}) + + +@router.post("/share-requests/seen") +def seen(session: Session = Depends(verify_session)) -> dict[str, int]: + """요청자가 결과 알림 줄을 닫음 — 자기 요청 가운데 끝난 것 `봄` = true.""" + at = _company(session, None) + me = session.get("user_id") + with _LOCK: + rows = _read(at) + hit = [r for r in rows if r.get("요청자") == me and r.get("봄") is False] + for r in hit: + r["봄"] = True + if hit: + _write(at, rows) + return {"봄": len(hit)} diff --git a/main.py b/main.py index aef378cd7..07bca3100 100644 --- a/main.py +++ b/main.py @@ -547,6 +547,11 @@ app.include_router(m02_items_router, dependencies=protected) app.include_router(m02_combos_router, dependencies=protected) app.include_router(m02_drawing_router, dependencies=protected) app.include_router(m02_labor_router, dependencies=protected) +# 회사에 올리기 요청 · 승인(PLAN 706-1) — 로그인만 · 층 권한은 길 안에서 +app.include_router( + __import__("M02_MasterTemplete.M02_Share_Request", fromlist=["router"]).router, + dependencies=protected, +) # 프로젝트 집계 항목 복사본(PLAN 25-2b) — 로그인만 · 프로젝트 권한은 길 안에서. app.include_router(m02_project_items_router, dependencies=protected) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). diff --git a/resources/tester/test_706_1_share_request.py b/resources/tester/test_706_1_share_request.py new file mode 100644 index 000000000..edb36215f --- /dev/null +++ b/resources/tester/test_706_1_share_request.py @@ -0,0 +1,129 @@ +"""회사에 올리기 요청 · 승인(PLAN 706-1 · `M02_Share_Request`) — 임시 storage. + +① 일반 사용자 요청(구조물 항목 · 사전조합) → 장부 한 줄 · 같은 것 다시 = 409 「요청 중」. +② 회사 관리자 승인 = 회사 층 고른 폴더에 사본(원본 개인 것 그대로) · 거절 = 사유 · 요청자 알림 → 봄. +③ 권한 — 일반 승인 403 · 다른 회사 관리자는 그 요청을 못 봄(404) · 남의 회사 장부 안 봄 · + 목록은 자기 것만. +""" + +from __future__ import annotations + +import json +from typing import Any + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Labor_Router as labor_router +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router +from M02_MasterTemplete import M02_Share_Request as share +from resources.tester import test_m02_tree as base +from resources.tester.test_m02_tree import Tree + +world = base.world # 같은 임시 storage 판 + +URL = "/api/m02/share-requests" + + +def _client(world: dict[str, Any]) -> TestClient: + app = FastAPI() + for one in (layers_router, items_router, labor_router, share): + app.include_router(one.router) + app.dependency_overrides[verify_session] = lambda: world["session"] + return TestClient(app) + + +def _as(world: dict[str, Any], role: str = "USER", user: int = 42, company: int = 7) -> None: + world["session"].update(role=role, user_id=user, company_id=company, name=f"사람{user}") + + +def _mine(world: dict[str, Any], client: TestClient) -> tuple[str, str]: + """일반 사용자 42 개인 층 — 항목 「배수/sub7임시」 · 사전조합 하나 → (항목 키, 사전조합 키).""" + _as(world) + t = Tree(client, "personal") + t.do(op="add-folder", 경로=[], 이름="배수") + t.do(op="add-item", 경로=["배수"], 이름="sub7임시", 단위="m") + doc = { + "문서": {"이름": "sub7임시 사전", "구분": "C 사면안정", "상세구분": "옹벽", "담은로직": []} + } + pre = client.post("/api/m02/layers/personal/library/precombo", json=doc).json()["이름"] + return t.item(["배수", "sub7임시"])["key"], pre + + +def test_요청_승인_거절_알림(world): + client = _client(world) + item, pre = _mine(world, client) + got = client.post(URL, json={"종류": "item", "키": item, "글": " 회사 공용으로 "}) + assert got.status_code == 200, got.text + one = got.json() + assert (one["이름"], one["경로"], one["주인"], one["글"]) == ( + "sub7임시", + ["배수"], + "7/42", + "회사 공용으로", + ) + assert client.post(URL, json={"종류": "item", "키": item}).status_code == 409 # 요청 중 + two = client.post(URL, json={"종류": "precombo", "키": pre}).json() + assert client.post(URL, json={"종류": "item", "키": "SP42-999999"}).status_code == 404 + assert client.post(URL, json={"종류": "precombo", "키": "SC-999999"}).status_code == 404 + mine = client.get(URL).json() + assert mine["관리"] is False and [r["id"] for r in mine["줄"]] == [one["id"], two["id"]] + + _as(world, "ADMIN", user=50) # 회사 7 관리자 + company = Tree(client, "company") + company.do(op="add-folder", 경로=[], 이름="공용") + boss = client.get(URL).json() + assert boss["관리"] is True and len(boss["줄"]) == 2 + ok = client.post(f"{URL}/{one['id']}/approve", json={"경로": ["공용"]}) + assert ok.status_code == 200, ok.text + assert ok.json()["상태"] == "승인" and ok.json()["봄"] is False + company = Tree(client, "company") + assert ["공용", "sub7임시"] in company.paths() # 고른 폴더에 사본 + again = client.post(f"{URL}/{one['id']}/approve", json={"경로": []}) + assert again.status_code == 409 # 이미 승인 + no = client.post(f"{URL}/{two['id']}/reject", json={"사유": "이름 다시"}) + assert no.json()["상태"] == "거절" and no.json()["사유"] == "이름 다시" + + _as(world) # 요청자 — 원본 그대로 · 결과 둘 · 봄 뒤 0 + assert ["배수", "sub7임시"] in Tree(client, "personal").paths() + rows = client.get(URL).json()["줄"] + assert [(r["상태"], r["봄"]) for r in rows] == [("승인", False), ("거절", False)] + assert client.post(f"{URL}/seen").json() == {"봄": 2} + assert client.post(f"{URL}/seen").json() == {"봄": 0} + # 거절된 것은 다시 요청할 수 있음 + assert client.post(URL, json={"종류": "precombo", "키": pre}).status_code == 200 + + +def test_사전조합_승인_폴더_자리(world): + client = _client(world) + _, pre = _mine(world, client) + rid = client.post(URL, json={"종류": "precombo", "키": pre}).json()["id"] + _as(world, "ADMIN", user=50) + tree_url = "/api/m02/layers/company/precombo-tree" + now = client.get(tree_url).json() + client.post(tree_url, json={"op": "add-folder", "경로": [], "이름": "옹벽", "판": now["판"]}) + made = client.post(f"{URL}/{rid}/approve", json={"경로": ["옹벽"]}).json()["사본"] + assert client.get(tree_url).json()["자리"] == {made: ["옹벽"]} + doc = client.get(f"/api/m02/layers/company/templates/precombo/{made}").json()["문서"] + assert doc["이름"] == "sub7임시 사전" + assert doc["본뜬"]["주인"] == "7/42" # 원본 표시 + + +def test_권한_일반_승인_403_다른_회사(world): + client = _client(world) + item, _ = _mine(world, client) + rid = client.post(URL, json={"종류": "item", "키": item}).json()["id"] + assert client.post(f"{URL}/{rid}/approve", json={}).status_code == 403 # 일반 = 승인 못 함 + assert client.post(f"{URL}/{rid}/reject", json={}).status_code == 403 + _as(world, "USER", user=43) # 같은 회사 다른 일반 — 남의 요청 안 보임 + assert client.get(URL).json()["줄"] == [] + _as(world, "ADMIN", user=90, company=9) # 다른 회사 관리자 + assert client.get(URL).json()["줄"] == [] + assert client.post(f"{URL}/{rid}/approve", json={}).status_code == 404 # 자기 장부에 없음 + assert client.get(URL, params={"company": "7"}).json()["회사"] == "9" # 남의 회사 고르기 무시 + _as(world, "SYSTEM_ADMIN", user=1, company=None) + assert len(client.get(URL, params={"company": "7"}).json()["줄"]) == 1 + ledger = world["storage"] / "7" / "templates" / share.FILE + assert json.loads(ledger.read_bytes())["줄"][0]["상태"] == "요청" From f0be3cd766cab34cb6d618509cf2791367e79ad8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 14:55:46 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat(=ED=9A=8C=EC=82=AC=EC=97=90=20?= =?UTF-8?q?=EC=98=AC=EB=A6=AC=EA=B8=B0=20=ED=99=94=EB=A9=B4):=20=EB=82=98?= =?UTF-8?q?=EB=AC=B4=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=C2=B7=20=EC=9A=94=EC=B2=AD=20=EC=A4=91=20=C2=B7=20=ED=9A=8C?= =?UTF-8?q?=EC=82=AC=20=EC=B8=B5=20=E3=80=8C=EC=98=AC=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20n=E3=80=8D=20=EB=9D=A0=20=C2=B7=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=AF=B8=EB=A6=AC=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=8A=B9=EC=9D=B8=20=C2=B7=20=EA=B1=B0=EC=A0=88=20?= =?UTF-8?q?=C2=B7=20=EA=B2=B0=EA=B3=BC=20=EB=9D=A0=20(PLAN=20706-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_ShareRequest.ts | 260 ++++++++++++++++++ .../M02_MasterTemplete_TreePanel.ts | 2 + .../M02_MasterTemplete_UI_Page.ts | 31 ++- .../M02_MasterTemplete_UI_Side.ts | 2 +- .../M02_MasterTemplete_UI_Side_Precombo.ts | 5 +- 5 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts b/M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts new file mode 100644 index 000000000..086a735b3 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts @@ -0,0 +1,260 @@ +/* ============================================================================= + * M02_MasterTemplete_ShareRequest.ts + * 회사에 올리기 요청 · 승인(PLAN 706-1) — 서버 `M02_Share_Request.py` 짝(장부 = 회사 층 파일 하나). + * 일반 사용자 = 자기 개인 층 구조물 조합 · 사전조합 나무 오른쪽 클릭 「회사에 올리기 요청」 → 글(선택) · + * 같은 것 요청이 남아 있으면 「요청 중」(흐림) · 결과는 다음에 M02 열 때 위 띠(닫으면 봄) + * 회사 관리자 = 회사 층 위 띠 「올리기 요청 n」 → 목록(요청자 · 항목 · 날짜 · 글) · [미리 보기] · + * [승인](폴더 고름 = 「회사로 복사…」와 같은 길 · 원본 그대로) · [거절](사유 한 줄) + * 관리자(회사 층을 고칠 수 있는 사람)는 「회사로 복사…」가 있어 요청 메뉴를 안 둠. + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createBanner } from "@ui/ui_template_banner"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { createEmpty } from "@ui/ui_template_empty"; +import { createListRow } from "@ui/ui_template_list_row"; +import type { TreeMenuItem } from "@ui/ui_template_tree"; +import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { call, fetchPrecomboTree, fetchTree, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { accessFor, ownOwner } from "./M02_MasterTemplete_Layer"; +import { askCopyFolder } from "./M02_MasterTemplete_TreePanel_CopyTo"; +import { askFolder, askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; +import type { Target } from "./M02_MasterTemplete_TreePanel_Pick"; + +type ShareKind = "item" | "precombo"; + +export interface ShareRow { + id: string; + 종류: ShareKind; + 키: string; + 이름: string; + 경로: string[]; + 주인: string; + 요청자: number; + 요청자이름: string; + 글: string; + 날짜: string; + 상태: "요청" | "승인" | "거절"; + 처리자?: string; + 사유?: string; + 사본?: string; + 봄?: boolean; +} + +const KIND_NAME: Record = { item: "구조물 조합", precombo: "사전조합" }; +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); +const post = (path: string, body: object = {}): Promise => + call(path, { method: "POST", body: JSON.stringify(body) }); + +export interface ShareOptions { + user: SessionUser | null; + /** 볼 회사(시스템 관리자는 고른 회사) */ + company: string | null; + /** 지금 페이지 층 — 회사 층이면 관리자 띠 */ + layer: Layer; + owner: string | null; + showView: (label: string, node: HTMLElement) => void; + /** 사전조합 미리 보기 — 편집 칸에 읽기만으로 */ + openPrecombo: (row: ShareRow) => void; + /** 구조물 조합 미리 보기 — 요청자 개인 층으로 */ + openItem: (row: ShareRow) => void; + /** 승인 뒤 — 나무 · 좌측 다시 읽기 */ + changed: () => void; +} + +export interface ShareHandle { + /** 편집 칸 맨 위 띠 자리(관리자 「올리기 요청 n」 · 요청자 결과) */ + top: HTMLElement; + /** 구조물 조합 나무 줄 덧메뉴 — 자기 개인 층 항목만 */ + itemMenu: (target: Target) => TreeMenuItem[]; + /** 사전조합 나무 줄 덧메뉴 — 자기 개인 층만(`mine`) */ + precomboMenu: (mine: boolean, key: string, name: string) => TreeMenuItem[]; +} + +export function createShare(opt: ShareOptions): ShareHandle { + const top = el("div", { attrs: { "data-share": "" } }); + const companyOwner = opt.company ?? ownOwner("company", opt.user); + // 요청할 사람 = 회사가 있고 회사 층을 못 고침(고칠 수 있으면 「회사로 복사…」) + const asker = + !!ownOwner("personal", opt.user) && !accessFor("company", companyOwner, opt.user).canWrite; + const boss = + opt.layer === "company" && + !!companyOwner && + accessFor("company", companyOwner, opt.user).canWrite; + const q = opt.company ? `?company=${encodeURIComponent(opt.company)}` : ""; + let rows: ShareRow[] = []; + let listHost: HTMLElement | null = null; + + const pending = (kind: ShareKind, key: string): boolean => + rows.some((r) => r.종류 === kind && r.키 === key && r.상태 === "요청"); + + const refresh = async (): Promise => { + if (!asker && !boss) return; + rows = (await call<{ 줄: ShareRow[] }>(`/share-requests${q}`).catch(() => ({ 줄: [] }))).줄; + paintTop(); + if (listHost?.isConnected) paintList(listHost); + }; + + function paintTop(): void { + if (boss) { + const n = rows.filter((r) => r.상태 === "요청").length; + top.replaceChildren( + ...(rows.length + ? [ + createBanner({ + tone: n ? "warning" : "info", + text: n + ? `회사에 올리기 요청 ${n} 건 — 승인하면 회사 층에 사본` + : "올리기 요청 없음", + actions: [ + createButton({ label: `올리기 요청 ${n}`, size: "sm", onClick: openList }), + ], + }), + ] + : []), + ); + return; + } + const done = rows.filter((r) => r.봄 === false); + top.replaceChildren( + ...(done.length + ? [ + createBanner({ + tone: done.some((r) => r.상태 === "거절") ? "warning" : "success", + title: "회사에 올리기 결과", + text: done + .map((r) => `「${r.이름}」 ${r.상태}${r.사유 ? `(${r.사유})` : ""}`) + .join(" · "), + onClose: () => void post(`/share-requests/seen`).catch(() => undefined), + }), + ] + : []), + ); + } + + async function ask(kind: ShareKind, key: string, name: string): Promise { + const got = await askText( + `회사에 올리기 요청 — ${name}`, + [{ key: "글", label: "요청 글(선택)", optional: true }], + "요청", + ); + if (!got) return; + try { + await post("/share-requests", { 종류: kind, 키: key, 글: got.글 }); + showToast("요청함 — 회사 관리자가 승인하면 회사 층에 올라감", "success"); + } catch (error) { + showToast(`요청 못 함 — ${why(error)}`, "error"); + } + await refresh(); + } + + const menu = (kind: ShareKind, key: string, name: string): TreeMenuItem[] => + pending(kind, key) + ? [ + { + label: "회사에 올리기 요청 중", + run: () => {}, + disabledReason: "회사 관리자가 아직 안 봄", + }, + ] + : [{ label: "회사에 올리기 요청", run: () => void ask(kind, key, name) }]; + + async function approve(row: ShareRow): Promise { + const title = `회사에 올리기 승인 — ${row.이름}`; + const dest = + row.종류 === "item" + ? await askCopyFolder(fetchTree, { layer: "company", owner: companyOwner, label: title }, 1) + : await askFolder( + title, + (await fetchPrecomboTree("company", companyOwner).catch(() => null))?.폴더 ?? [], + () => false, + [], + "넣을 폴더", + ); + if (!dest) return; + try { + await post(`/share-requests/${row.id}/approve${q}`, { 경로: dest }); + showToast(`「${row.이름}」 회사 층에 올림 — ${dest.join(" / ") || "맨 위"}`, "success"); + opt.changed(); + } catch (error) { + showToast(`승인 못 함 — ${why(error)}`, "error"); + } + await refresh(); + } + + async function reject(row: ShareRow): Promise { + const got = await askText( + `거절 — ${row.이름}`, + [{ key: "사유", label: "사유(선택)", optional: true }], + "거절", + ); + if (!got) return; + try { + await post(`/share-requests/${row.id}/reject${q}`, { 사유: got.사유 }); + } catch (error) { + showToast(`거절 못 함 — ${why(error)}`, "error"); + } + await refresh(); + } + + function paintList(host: HTMLElement): void { + const line = (r: ShareRow): HTMLElement => { + const tools = + r.상태 === "요청" + ? el("span", { + className: "ui-toolbar", + children: [ + createButton({ + label: "미리 보기", + variant: "ghost", + size: "sm", + onClick: () => (r.종류 === "item" ? opt.openItem(r) : opt.openPrecombo(r)), + }), + createButton({ label: "승인", size: "sm", onClick: () => void approve(r) }), + createButton({ + label: "거절", + variant: "danger-ghost", + size: "sm", + onClick: () => void reject(r), + }), + ], + }) + : createBadge( + `${r.상태}${r.사유 ? ` · ${r.사유}` : ""}`, + r.상태 === "승인" ? "success" : "danger", + ); + const sub = [KIND_NAME[r.종류], r.요청자이름, r.날짜.slice(0, 10).replace(/-/g, "."), r.글]; + return createListRow({ + label: r.이름, + sub: sub.filter(Boolean).join(" · "), + trailing: tools, + size: 36, + }); + }; + // 기다리는 것 먼저 · 그다음 최근 처리 + const order = [...rows].sort( + (a, b) => Number(b.상태 === "요청") - Number(a.상태 === "요청") || (a.날짜 < b.날짜 ? 1 : -1), + ); + host.replaceChildren( + ...(order.length + ? order.map(line) + : [createEmpty({ text: "올리기 요청 없음", small: true })]), + ); + } + + function openList(): void { + listHost = el("div", { className: "ui-sidebar-list", attrs: { "data-share-list": "" } }); + paintList(listHost); + opt.showView("회사에 올리기 요청", listHost); + } + + void refresh(); + return { + top, + itemMenu: (t) => + asker && opt.layer === "personal" && !opt.owner && t.kind === "item" + ? menu("item", t.item.key, t.item.경로[t.item.경로.length - 1] ?? t.item.key) + : [], + precomboMenu: (mine, key, name) => (asker && mine ? menu("precombo", key, name) : []), + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 384e588c5..f8fa81004 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -85,6 +85,7 @@ export interface TreePanelOptions { }; /** 「〇〇로 복사…」 — 그 층 폴더를 골라 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로 · 39-6) */ copyTo?: CopyTarget[]; + more?: (target: Target) => TreeMenuItem[]; // 줄 하나 덧메뉴(706-1 회사에 올리기 요청) /** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */ canEdit: boolean; /** 항목을 누름 — 표에서 그 열을 고름 */ @@ -226,6 +227,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { return [ ...(opt.canEdit ? menuOf(target) : []), ...copyItem([target]), + ...(opt.more?.(target) ?? []), ...(target.kind === "folder" ? pasteItem(target.path) : []), ]; }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 7cb89166b..e64672796 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -40,6 +40,7 @@ import { STRUCTURE_TABLE, type StructureColumn, } from "./M02_MasterTemplete_Structure"; +import { createShare } from "./M02_MasterTemplete_ShareRequest"; import { createTreePanel } from "./M02_MasterTemplete_TreePanel"; import { companyOwner, @@ -91,10 +92,32 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise + void main.confirmLeave().then((ok) => ok && main.showView(label, node)), + openPrecombo: (r) => + void main.open({ + layer: "personal", + owner: r.주인, + canWrite: false, + projectId: null, + kind: "precombo", + name: r.키, + label: r.이름, + }), + openItem: (r) => void go("personal", r.주인, "구조물조합"), + changed: () => void Promise.all([side.refresh(), tree.refresh()]), + }); const tree = createTreePanel({ layer, owner, copyTo, + more: share.itemMenu, canEdit: canEdit(layer, isAdmin), onPick: (item) => void main.openTable(layer, item.열), settle: () => main.settle(), @@ -138,7 +161,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void Promise.all([side.refresh(), tree.refresh()]), () => side.paint(), { - top: readOnlyBar?.root, + top: el("div", { children: [...(readOnlyBar ? [readOnlyBar.root] : []), share.top] }), // 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5) ...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => void openNew()) } : {}), onColumn: (column) => { @@ -222,7 +245,11 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer)), peopleTree("구조물조합", tree.root), ], - precombo: { scope: () => pre, bar: preBar }, + precombo: { + scope: () => pre, + bar: preBar, + more: (key, name) => share.precomboMenu(pre.root === "personal" && !pre.owner, key, name), + }, }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index a530445ff..b6b5fc63e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -69,7 +69,7 @@ export interface SideOptions { /** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */ tree?: HTMLElement[]; /** 「일위대가 사전조합」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */ - precombo: Pick; + precombo: Pick; } export interface SideHandle { diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts index 5eb4936a0..e109cc347 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts @@ -86,6 +86,8 @@ export interface PrecomboOptions { confirmLeave: () => Promise; onOpen: (sel: Selection | null) => void; getOpen: () => Selection | null; + /** 문서 줄 덧메뉴(706-1 회사에 올리기 요청) — 없거나 빈 목록이면 메뉴 없음 */ + more?: (key: string, name: string) => TreeMenuItem[]; } export interface PrecomboHandle { @@ -237,7 +239,8 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { const label = row.제목 || row.이름; const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label }; targets.set(id, { kind: "item", item }); - return { id, label, tip: row.이름, count: null, run: () => void open(row) }; + const menu = () => opt.more?.(row.이름, label) ?? []; + return { id, label, tip: row.이름, count: null, run: () => void open(row), menu }; }; const folderNode = (node: PrecomboFolder): TreeNode => { const id = folderIdOf(node.path); From bd4d421668ff5ca84ade2b834ce10a0e41cb14ce Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 14:58:42 +0900 Subject: [PATCH 8/9] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20513=20=EC=A1=B0=EC=82=AC=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=E2=80=94=20=EC=82=B0=EB=A6=BC=EC=B2=AD=20=EC=A3=BC=EC=A0=9C?= =?UTF-8?q?=EB=8F=84=20=EB=8B=A4=EC=84=AF=20=EB=B0=9B=EB=8A=94=20=EA=B8=B8?= =?UTF-8?q?=20=C2=B7=20=EC=9D=B4=EC=9A=A9=20=EC=A1=B0=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9 --- .../요구_1025/_조사_513_산림청_주제도_API.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/raw/요구_1025/_조사_513_산림청_주제도_API.md diff --git a/docs/raw/요구_1025/_조사_513_산림청_주제도_API.md b/docs/raw/요구_1025/_조사_513_산림청_주제도_API.md new file mode 100644 index 000000000..7134c122a --- /dev/null +++ b/docs/raw/요구_1025/_조사_513_산림청_주제도_API.md @@ -0,0 +1,91 @@ +# 513 산림청 주제도 — 받는 길 · 이용 조건 조사 (2026-10-31 · 웹 공개 페이지만) + +## 결론 + +- 산림청 다섯 주제도 중 공개 **지도 API(WMS·WFS)** = 산사태위험지도 하나(행안부 생활안전지도 WMS) · 그마저 **공공누리 4유형(상업 이용·변경 금지)**. +- 나머지 넷 = **파일 내려받기**(SHP·GDB)가 길 · 공공데이터포털(data.go.kr) 표기는 모두 「이용허락범위 제한 없음」. +- 같은 자료가 다른 창구에서 **상충** — 브이월드 국가중점 = CC BY-NC-ND(비영리·변경 금지) · FGIS 자료신청 = 서약(해당 업무에만 · 외부 유출 금지) · 산림청 누리집 약관 = 가공·판매 등 상업 이용 금지. +- 임도망도 1:5,000 · 산사태위험지도 1:25,000 = FGIS 에서 **공공·연구기관 전용**(회사 신청 불가). +- map.forest.go.kr 자체 지도 서버(WMS 등) 공개 안내 = **없음**. +- 상용 탑재 가능 여부 = **확인 못 함** — 산림청 서면 확인 필요(산림디지털담당관실 042-481-4285 · 산림빅데이터팀 042-481-8809). + +## 1. 요약 표 + +| 주제도 | API | 파일 | 형식 · 좌표계 | 축척 | 갱신 · 기준 | 이용 조건 | 상용 탑재 | 출처 | +|---|---|---|---|---|---|---|---|---| +| 임상도(나무지도 대축척) | 없음 | ① FGIS 무료신청(일반인) ② 브이월드 국가중점 전국 ③ 산림빅데이터플랫폼 전국 ④ data.go.kr = FGIS 경로 안내만 | ① SHP · UTM-K(EPSG:5179) ② GDB 3,182 MB · EPSG:5179 ③ SHP zip 7개(각 약 2 GB) · EPSG:5179 | 1:5,000 | 2차 현행화 2020~2024 완료 · 브이월드 연간 · 2026-10-06 · 빅데이터 2022년 기준 | data.go.kr 제한 없음 · 브이월드 **CC BY-NC-ND** · FGIS 서약 · 빅데이터 확인 못 함 | 확인 못 함(상충) | [D1](https://www.data.go.kr/data/15093362/fileData.do) [V1](https://www.vworld.kr/dtmk/dtmk_ntads_s002.do?svcCde=MK&dsId=30136) [B1](https://www.data.go.kr/data/95990/linkedData.do) [F2](https://forest.go.kr/newkfsweb/html/HtmlPage.do?pg=%2Ffgis%2FUI_KFS_5002_020100.html&mn=KFS_02_04_03_04_01) | +| 임상도(소축척) | 없음 | data.go.kr 파일 · FGIS 일반 제공 | SHP · 5차 파일 TM중부(GRS80) | 1:25,000 | 5차 2006~2010 · 수정 2025-11-20 | data.go.kr 제한 없음 | 확인 못 함 | [D2](https://www.data.go.kr/data/3045619/fileData.do) [F5](https://www.forest.go.kr/kfsweb/opda/dataMng/pblicDataPrusContView.do?pblicDataId=PBD0000059&mn=NKFS_01_01&subTitle=%EC%9E%84%EC%83%81%EB%8F%84) | +| 임도망도(산길) | 없음 | ① data.go.kr 파일 ② 산림빅데이터플랫폼 전국 ③ FGIS = **공공·연구기관 전용** | ① SHP ② SHP(polyline) · EPSG:5179 | ① 미표기 ③ 1:5,000 | ① 수정 2025-11-20 ② 2019년 기준 | data.go.kr 제한 없음 · 빅데이터 확인 못 함 · FGIS 비영리 공공·학술 | 확인 못 함 | [D3](https://www.data.go.kr/data/3045621/fileData.do) [B2](https://www.data.go.kr/data/95799/linkedData.do) [F1](https://www.forest.go.kr/newkfsweb/kfi/kfs/fgis/selectAvailMapList.do?mn=KFS_02_04_02_02&orgId=fgis) | +| 산림입지토양도(산림토양도) | 토양도 자체 없음 · 참고: 브이월드 WMS `lt_c_fsdifrsts`(산림입지도) | ① data.go.kr 1:25,000 파일 ② 브이월드 국가중점 1:5,000 ③ 산림빅데이터플랫폼 1:5,000 ④ FGIS 일반 제공(1:25,000 · 1:5,000) ⑤ data.go.kr 1:5,000 = PDF 안내 | ① SHP ② GDB 4,599 MB · TM중부(EPSG:5186) ③ SHP(polygon) · EPSG:5179 | 1:25,000 · 1:5,000 | ① 수정 2025-11-20 ② 변경 시 · 2024-08-28 ③ 2020년 기준 · 제주 미구축 | data.go.kr 제한 없음 · 브이월드 「라이선스 미지정」 · 빅데이터 확인 못 함 | 확인 못 함 | [D4](https://www.data.go.kr/data/3058167/fileData.do) [D5](https://www.data.go.kr/data/15093364/fileData.do) [V2](https://www.vworld.kr/dtmk/dtmk_ntads_s002.do?svcCde=MK&dsId=30133) [B3](https://www.data.go.kr/data/95578/linkedData.do) [VW](https://www.vworld.kr/dev/v4dv_wmsguide2_s001.do) | +| 산사태위험지도 | **생활안전지도 WMS** — `safemap.go.kr/openApi2/IF_0046_WMS` · 레이어 `A2SM_SANSATAI` · 예제 EPSG:4326 · 3857 · 생활안전지도 인증키 | ① data.go.kr 파일 ② FGIS = **공공·연구기관 전용** · 브이월드 국가중점 = 목록에 없음 | ① XML · 10 m 격자 | ② 1:25,000 | WMS 갱신 1년 · 수정 2025-05-30 · 파일 수정 2026-09-03 | WMS **공공누리 4유형**(출처표시 · 상업 금지 · 변경 금지) — data.go.kr 같은 WMS 는 「제한 없음」 표기(상충) · 파일 제한 없음 | WMS 불가(4유형) · 파일 확인 못 함 | [S1](https://www.safemap.go.kr/opna/data/dataViewRenew.do?objtId=125) [D6](https://www.data.go.kr/data/15149602/openapi.do) [D7](https://www.data.go.kr/data/15074817/fileData.do) | +| 경제림육성단지 | data.go.kr `산림청_경제림육성단지구역도_GW` — REST · XML · `apis.data.go.kr/1400000/fsInfoService` · 공공데이터포털 인증키 · 도형 포함 여부 확인 못 함 | ① 브이월드 국가중점 국유림(30126) · 사유림(30127) ② FGIS 일반 제공 | ① POLYGON zip 10 MB · TM중부(EPSG:5186) | 1:25,000 | ① 변경 시 · 2023-07-26 · API 실시간 | API 제한 없음 · 브이월드 **CC BY-NC-ND**(30126 확인) | 확인 못 함 | [D8](https://www.data.go.kr/data/15158975/openapi.do) [V3](https://www.vworld.kr/dtmk/dtmk_ntads_s002.do?svcCde=MK&dsId=30126) [F1](https://www.forest.go.kr/newkfsweb/kfi/kfs/fgis/selectAvailMapList.do?mn=KFS_02_04_02_02&orgId=fgis) | + +- 「화면 보기만」 = map.forest.go.kr 지도 안의 모든 주제도 + 나무지도 GeoPDF 검색 · 1:5,000 임상 · 영급 · 경급 · 임종 · 소나무 분포도 PDF([M1](https://map.forest.go.kr/forest/) · [F2](https://forest.go.kr/newkfsweb/html/HtmlPage.do?pg=%2Ffgis%2FUI_KFS_5002_020100.html&mn=KFS_02_04_03_04_01)). +- 산림청 GW 오픈API(fsInfoService 계열) 목록에 임상도 · 임도망도 · 토양도 · 산사태위험지도 없음([data.go.kr 검색 「산림공간정보」 오픈API 19건](https://www.data.go.kr/tcs/dss/selectDataSetList.do?keyword=%EC%82%B0%EB%A6%BC%EA%B3%B5%EA%B0%84%EC%A0%95%EB%B3%B4&dType=API)). +- 브이월드 WMS 목록에 산사태 · 경제림 · 임도 레이어 없음([VW](https://www.vworld.kr/dev/v4dv_wmsguide2_s001.do)). + +## 2. 속성 칸 + +| 주제도 | 속성(공개 문서 기준) | 출처 | +|---|---|---| +| 임상도 | 임종(천연림·인공림) · 임상(침엽수·활엽수 등) · 주요 수종(51종) · 경급 · 영급 · 수관밀도 · 5차 1:25,000 칸 이름 예 `FIFTH_FRTP_CD`(도엽) · `DMCLS_CD`(경급) · `AGCLS_CD`(영급) · `DNST_CD`(밀도) · `SMBL_CD`(심볼) · 1:5,000 칸 이름 확인 못 함 | D1 · D2 · B1 · F5 | +| 임도망도 | 임도명 · 임도시설기간 · 임도시설거리 · 임도개설기간(빅데이터 판) · 노선 종류(간선·작업 등) 칸 확인 못 함 | B2 | +| 산림입지토양도 | 모암 · 기후 · 지형 · 입지표고 · 경사도 · 사면 · 경사형 · 토심 · 토성 · 토양형 · 칸 이름 확인 못 함 | D4 · D5 | +| 산사태위험지도 | 등급 1~5(1 = 가장 위험) · 10 m 격자 | D7 · S1 | +| 경제림육성단지 | 단지명 · 국유림/사유림 구분 · 관리소 · 시도·시군 행정구역(API 설명 기준) · 응답 칸 이름 확인 못 함 | D8 | + +## 3. 이용 조건 — 창구별 상충 + +| 창구 | 표기 | 상용 의미 | 출처 | +|---|---|---|---| +| 공공데이터포털 | 「이용허락범위 제한 없음」 · 무료 | 공공데이터법 제3조 ④ 「영리적 이용인 경우에도 이를 금지 또는 제한하여서는 아니 된다」(다른 법률 특별 규정 · 제28조 ① 예외) · 제3자 권리 포함 데이터는 권리자 허락 필요 | [P1](https://www.data.go.kr/ugs/selectPortalPolicyView.do) [L1](https://ko.wikisource.org/wiki/%EA%B3%B5%EA%B3%B5%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%9D%98_%EC%A0%9C%EA%B3%B5_%EB%B0%8F_%EC%9D%B4%EC%9A%A9_%ED%99%9C%EC%84%B1%ED%99%94%EC%97%90_%EA%B4%80%ED%95%9C_%EB%B2%95%EB%A5%A0) [P2](https://www.data.go.kr/odmc/trublMdat/faq/index.do) | +| 브이월드 국가중점데이터 | 임상도 1:5,000 · 경제림 = CC BY-NC-ND · 토양도 1:5,000 = 미지정 | 영리 · 변경 불가 | V1 · V3 · V2 | +| 생활안전지도 WMS | 공공누리 4유형 · 위치정보 사업화 시 허가·신고 안내 | 상업 · 변경 불가 | S1 | +| FGIS 자료신청 | 서약 — 「업무상 취득한 … 자료등을 외부에 누설 또는 유출하지 않을 것」 · 해당 업무에만 사용 · 공공·연구기관 = 비영리 공공사업·학술연구 · 활용 뒤 성과물 제출 · 로그인(Any-ID) | 상용 재배포와 충돌 가능 | [F3](https://forest.go.kr/newkfsweb/kfi/kfs/fgis/fgisTermsAgreePage.do?mn=KFS_02_04_02_03&orgId=fgis) [M1](https://map.forest.go.kr/forest/) | +| 산림청 누리집 이용약관 | 제15조 「정보를 가공, 판매하는 행위 등 … 상업적으로 사용할 수 없습니다」 · 제16조 승인 없이 수정·판매·배포 불가 | 상업 불가(누리집 서비스 자료) | [F4](https://www.forest.go.kr/kfsweb/kfi/kfs/cms/cmsView.do?mn=NKFS_07_02&cmsId=FC_000131) | +| 산림빅데이터플랫폼(한국임업진흥원) | 무료 · 이용 조건 확인 못 함(사이트 접속 거부) | 확인 못 함 | B1 · B2 · B3 | + +- 보안 등급 · 공개 제한 표기 = 확인 못 함(FGIS 서약 · 공공·연구기관 전용 구분만 확인). + +## 4. map.forest.go.kr 지도 서버 주소 + +- 공개 안내 **없음** — map.forest.go.kr 메뉴(공간자료 다운로드 · 내 산 위치 찾기 · 통합검색 · SHP 중첩 등)와 forest.go.kr 산림공간정보 메뉴(지도서비스 · 자료유통서비스 · 산림공간정보소개 · 열린마당)에 오픈API · WMS 안내 없음([M1](https://map.forest.go.kr/forest/) · [F6](https://forest.go.kr/newkfsweb/kfs/idx/SubIndex.do?orgId=fgis&mn=KFS_03_08_01)). +- 화면 뒤 주소 캐내기 · 호출 시도 안 함. + +## 5. 확인 못 함 + +- 상용 프로그램 탑재 허용 여부(모든 주제도) — 창구별 상충 · 산림청 서면 답 필요. +- data.go.kr 파일(3045619 · 3045621 · 3058167 · 15074817)의 첨부 파일 유무 · 크기 · 좌표계 — 페이지 글에 없음 · 1:5,000 임상도(15093362)는 FGIS 무료신청 경로 안내뿐. +- 임도망도 data.go.kr 판 축척 · 범위 · 노선 종류 칸. +- 경제림육성단지 GW API 응답에 도형(좌표) 포함 여부 · 심의 유형 · 트래픽. +- 산림빅데이터플랫폼 이용 조건(공공누리 유형). +- 브이월드 WMS 산림입지도(`lt_c_fsdifrsts`)가 산림입지토양도 1:25,000 과 같은 자료인지 · 브이월드 API 상용 조건(이전 조사 참고 · 이번 미확인). + +## 출처 목록 + +- D1 https://www.data.go.kr/data/15093362/fileData.do — 임상도 1:5,000(FGIS 무료신청 안내) +- D2 https://www.data.go.kr/data/3045619/fileData.do — 임상도 1:25,000 +- D3 https://www.data.go.kr/data/3045621/fileData.do — 임도망도 +- D4 https://www.data.go.kr/data/3058167/fileData.do — 산림입지토양도 1:25,000 +- D5 https://www.data.go.kr/data/15093364/fileData.do — 산림입지토양도 1:5,000(PDF) +- D6 https://www.data.go.kr/data/15149602/openapi.do — 행안부 산사태위험지도 WMS +- D7 https://www.data.go.kr/data/15074817/fileData.do — 산사태위험지도 파일 +- D8 https://www.data.go.kr/data/15158975/openapi.do — 경제림육성단지구역도 GW +- V1 https://www.vworld.kr/dtmk/dtmk_ntads_s002.do?svcCde=MK&dsId=30136 — 임상도 1:5,000 +- V2 https://www.vworld.kr/dtmk/dtmk_ntads_s002.do?svcCde=MK&dsId=30133 — 산림입지토양도 1:5,000 +- V3 https://www.vworld.kr/dtmk/dtmk_ntads_s002.do?svcCde=MK&dsId=30126 — 경제림육성단지 국유림 +- VW https://www.vworld.kr/dev/v4dv_wmsguide2_s001.do — 브이월드 WMS 레이어 목록 +- B1 https://www.data.go.kr/data/95990/linkedData.do → https://www.bigdata-forest.kr/product/FRT001001 +- B2 https://www.data.go.kr/data/95799/linkedData.do → https://www.bigdata-forest.kr/product/FRT002601 +- B3 https://www.data.go.kr/data/95578/linkedData.do → https://www.bigdata-forest.kr/product/FRT001301 +- S1 https://www.safemap.go.kr/opna/data/dataViewRenew.do?objtId=125 — 생활안전지도 산사태위험지도 WMS +- F1 https://www.forest.go.kr/newkfsweb/kfi/kfs/fgis/selectAvailMapList.do?mn=KFS_02_04_02_02&orgId=fgis — FGIS 자료유통현황 +- F2 https://forest.go.kr/newkfsweb/html/HtmlPage.do?pg=%2Ffgis%2FUI_KFS_5002_020100.html&mn=KFS_02_04_03_04_01 — 임상도 현황 +- F3 https://forest.go.kr/newkfsweb/kfi/kfs/fgis/fgisTermsAgreePage.do?mn=KFS_02_04_02_03&orgId=fgis — FGIS 자료신청 서약 +- F4 https://www.forest.go.kr/kfsweb/kfi/kfs/cms/cmsView.do?mn=NKFS_07_02&cmsId=FC_000131 — 산림청 이용약관 +- F5 https://www.forest.go.kr/kfsweb/opda/dataMng/pblicDataPrusContView.do?pblicDataId=PBD0000059 — 임상도 활용정보 +- F6 https://forest.go.kr/newkfsweb/kfs/idx/SubIndex.do?orgId=fgis&mn=KFS_03_08_01 — 산림공간정보 메뉴 +- M1 https://map.forest.go.kr/forest/ — 산림공간정보서비스 +- P1 https://www.data.go.kr/ugs/selectPortalPolicyView.do — 공공데이터포털 이용정책 +- P2 https://www.data.go.kr/odmc/trublMdat/faq/index.do — 분쟁조정 FAQ(영리 이용) +- L1 위키문헌 공공데이터법(법률 제13723호 판) 제3조 — 현행 조문은 law.go.kr 대조 필요 From fdb3eb9b77c58c613f83844f339d8c9c4f83f05c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 14:59:58 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat(B09):=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20?= =?UTF-8?q?=EC=9E=AC=EB=A3=8C=20=EC=B0=BD=EA=B3=A0=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=EB=82=9C=20=EA=B3=B5=EC=82=AC=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0(PLAN=20703-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설계내역서 표 오른쪽 서랍(공용 side_panels · 접고 펴기 · 좁은 화면 = 아래) — 찾기 하나 + 출처 탭 이 공사 · 내 것 · 회사 · 품셈(704-1 기준 목록 · 우클릭 계산서 보기) · 지난 공사 - 두 번 누름 · Enter = 고른 내역 줄에 단가연결 · 끌어 놓기 = 놓은 줄 · 연결 = 단가연결 모달과 같은 길(702-3 즉시 금액) - 품셈 = 넣을 값 창에 계산서 입력 전부(목록 바꿈은 기본과 다른 것만 — 석축헐기 뒷길이 60 이 0 으로 열리던 것) - 서버 B09_Estimation_Past + GET/POST /estimation/past — 같은 회사 다른 공사 구조물 사본 · 서고 사본 → 이 공사 서고 사본(폴더 통째 · 가져온곳 · 덮개째 · 단가 = 이 공사 책) · 남의 회사 403 - 표 높이 셈(spaceBelow)이 뒤 형제만 보므로 서랍 = DOM 앞 · CSS order 로 오른쪽 - 시험 test_703_1 6 · 셸 꼴 글 시험 200-2 · 202-1 = 표 칸 + 서랍 꼴로 고침(낡은 글) Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Past.py | 139 +++++++ B09_Estimation/B09_Estimation_Router.py | 66 ++++ B09_Estimation/B09_Estimation_UI_Page.css | 27 ++ B09_Estimation/B09_Estimation_UI_Shell.ts | 8 +- B09_Estimation/B09_Estimation_UI_Store.ts | 370 ++++++++++++++++++ B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 3 + .../B09_Estimation_UI_Tabs_Bill_Edit.ts | 6 + .../B09_Estimation_UI_Tabs_Bill_Level.ts | 3 + .../tester/test_200_2_b09_bill_level_bar.py | 6 +- .../tester/test_202_1_table_hint_bottom.py | 4 +- resources/tester/test_703_1_b09_past.py | 147 +++++++ 11 files changed, 774 insertions(+), 5 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Past.py create mode 100644 B09_Estimation/B09_Estimation_UI_Store.ts create mode 100644 resources/tester/test_703_1_b09_past.py diff --git a/B09_Estimation/B09_Estimation_Past.py b/B09_Estimation/B09_Estimation_Past.py new file mode 100644 index 000000000..96973070c --- /dev/null +++ b/B09_Estimation/B09_Estimation_Past.py @@ -0,0 +1,139 @@ +"""B09 재료 창고 「지난 공사」(PLAN 703-1) — 같은 회사 다른 공사 일위대가 → 이 공사 서고 사본. + +목록 = 그 공사 구조물 사본(`S:` · 일위대가 있는 것) · 서고 사본(`L:` · 사전조합 · 구조물조합) · + 고친 칸 수 = 그 공사 저장 덮개(블록 cover — 조합 `S:`/`L:폴더` · 사전조합 `C:참조` · 짝 + `Labor_Source.Builder`) · 같은 원본 · 같은 값 · 같은 덮개 = 한 줄 +가져옴 = 사본 폴더 통째 → 이 공사 `library/{종류}@{층}@{주인}@{키}~{공사 · 사본 해시}`(구조물 사본 = + 서고 구조물조합과 한 꼴) · manifest `가져온곳` = 그 공사 · 사본 · 다시 고르면 같은 사본 · + 덮개 = 그 공사 저장 칸 + 이 공사 블록 cover(조합 = `L:새 폴더` · 사전조합 = 같은 `C:참조`) → + 화면 캐시가 그 cover 가 비었을 때만 얹음([저장] 때 남음) · + 단가 = 이 공사 단가 책(셈이 이 공사 것) · 권한 = 부르는 쪽(같은 회사). +""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +from B09_Estimation import B09_Estimation_Bill_Library as library +from B09_Estimation import B09_Estimation_Store as store +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete.M02_Item_Snapshot import ( + FOLDER_PATTERN, + MANIFEST_NAME, + PICKS_FILE, + VALUES_FILE, + linked_snapshots, + now_text, + structures_dir, +) +from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, read_json + + +def _covers(root: Path) -> dict[str, Any]: + return store.read_user(root).get("daega_cover") or {} + + +def _cells(cover: Any) -> dict[str, Any]: + """덮개 칸만(복사본 표지 `@copy` 뺌).""" + return {k: v for k, v in (cover or {}).items() if not k.startswith("@")} + + +def cover_of(head: str, folder: Path, kind: str) -> str: + """사본 폴더의 대가 블록 cover — 사전조합 = 참조(공사 안 같은 사전조합 한 덮개).""" + if kind != "precombo": + return f"{head}:{folder.name}" + ref = ((read_json(folder / MANIFEST_NAME) or {}).get("원본") or {}).get("참조") or {} + return f"C:{refs.ref_id(ref)}" + + +def _row(folder: Path, key: str, kind: str, cover: dict[str, Any]) -> dict[str, Any]: + manifest = read_json(folder / MANIFEST_NAME) or {} + origin = manifest.get("원본") or {} + path = origin.get("경로") or [] + if kind == "precombo": + name, spec, unit = origin.get("name") or folder.name, "", origin.get("단위") or "" + else: # 짝 `Labor_Source.Builder.combo` 이름 · 규격 · 단위 + labor = read_json(folder / LABOR_FILE) or {} + name = origin.get("name") or " ".join(path) or folder.name + spec, unit = " › ".join(path), (labor.get("줄") or {}).get("단위") or "" + cells = _cells(cover.get(cover_of(key[0], folder, kind))) + sig = library.tag( + { + "원본": {k: origin.get(k) for k in ("layer", "owner", "key", "참조")}, + "값": [read_json(folder / f) for f in (VALUES_FILE, PICKS_FILE)], + "덮개": cells, + } + ) + kind_text = "사전조합" if kind == "precombo" else "구조물 조합" + return {"cover": key, "name": name, "spec": spec, "unit": unit, "kind": kind_text, + "covers": len(cells), "sig": sig} # fmt: skip + + +def rows_of(root: str | Path) -> list[dict[str, Any]]: + """한 공사 가져올 일위대가 — 구조물 사본 → 서고 사본 · 같은 것 한 줄.""" + root = Path(root) + cover = _covers(root) + out: list[dict[str, Any]] = [] + for info in sorted(linked_snapshots(root).values(), key=lambda i: i["폴더"]): + if "일위대가" in info.get("부분", []): + folder = structures_dir(root) / info["폴더"] + out.append(_row(folder, f"S:{folder.name}", "combo", cover)) + for folder, manifest in library.copies(root): + out.append(_row(folder, f"L:{folder.name}", str(manifest.get("종류")), cover)) + seen: set[str] = set() + return [r for r in out if not (r["sig"] in seen or seen.add(r["sig"]))] + + +def _source(other: Path, cover_key: str) -> tuple[Path, str, str, str | None, str]: + """덮개 열쇠 → (사본 폴더, 종류, 층, 주인, 키) · 틀린 모양 ValueError · 없음 LookupError.""" + head, _, rest = cover_key.partition(":") + if head == "S" and FOLDER_PATTERN.match(rest): + folder = structures_dir(other) / rest + origin = (read_json(folder / MANIFEST_NAME) or {}).get("원본") or {} + kind, layer, owner, key = ( + "combo", + origin.get("layer"), + origin.get("owner"), + origin.get("key"), + ) + elif head == "L" and library.NAME.match(rest): + folder = library.library_dir(other) / rest + kind, layer, owner, key = rest.split("~")[0].split("@") + owner = None if owner == "-" else owner.replace("-", "/") + else: + raise ValueError(f"가져올 일위대가 모양이 틀림 「{cover_key}」") + if not isinstance(read_json(folder / MANIFEST_NAME), dict) or not layer or not key: + raise LookupError(f"지난 공사에 그 일위대가가 없음 「{cover_key}」") + return folder, kind, str(layer), owner, str(key) + + +def import_past( + root: str | Path, + other: str | Path, + other_ref: dict[str, Any], + cover_key: str, + who: dict[str, Any] | None = None, +) -> dict[str, Any]: + """지난 공사 사본 하나 → 이 공사 서고 사본 — `{폴더, 만듦, 덮개, 덮개자리}` · + 덮개 = 그 공사 저장 칸 · 덮개자리 = 이 공사 블록 cover.""" + src, kind, layer, owner, key = _source(Path(other), cover_key) + tag = {"공사": str(other_ref.get("project_id")), "사본": cover_key} + name = library.folder_name(kind, layer, owner, key, tag) + target = library.library_dir(root) / name + cells = _cells(_covers(Path(other)).get(cover_of(cover_key[0], src, kind))) + mine = cover_of("L", src, kind) if kind == "precombo" else f"L:{name}" + out = {"폴더": name, "덮개": cells, "덮개자리": mine} + if isinstance(read_json(target / MANIFEST_NAME), dict): + return {**out, "만듦": False} + if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리 + shutil.rmtree(target) + shutil.copytree(src, target, ignore=shutil.ignore_patterns(MANIFEST_NAME)) + old = read_json(src / MANIFEST_NAME) or {} + manifest = {k: old[k] for k in ("원본", "값", "원값") if k in old} + manifest.update({"종류": kind, "가져온날": now_text(), "가져온이": who}) + manifest["가져온곳"] = {**other_ref, "사본": cover_key} + atomic_write_json(target / MANIFEST_NAME, manifest) # 마지막 — 있으면 다 쓴 폴더 + return {**out, "만듦": True} diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 96ca599a0..d15bd3353 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -35,6 +35,12 @@ 「내 자료집에 저장」(703-2) — 펼친 일위대가 → 개인 층 사전조합 (`B09_Estimation_Daega_Personal`) · user = 화면 캐시 덮개 · 같은 이름 = 409 {같은이름} → 덮어쓰기(overwrite = 키) · 새 이름 + GET /api/projects/{id}/estimation/past + 재료 창고 「지난 공사」(703-1) — 같은 회사 다른 공사의 일위대가 목록(`B09_Estimation_Past`) + POST /api/projects/{id}/estimation/past {project_id, cover} + 지난 공사 일위대가 → 이 공사 서고 사본(덮개째) → {폴더, 만듦, key, cover, 덮개, payload} · + 덮개 = 그 공사 저장 칸(화면 캐시 cover 가 비었을 때만 얹음 · 같은 꼴 블록이 먼저 있으면 빈 것) · + 같은 회사만(남의 회사 403) 운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`). 저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · @@ -66,6 +72,7 @@ from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milesto from B08_Quantity.B08_Quantity_Router_Milestone import site_info from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Daega_Personal as personal +from B09_Estimation import B09_Estimation_Past as past from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation import B09_Estimation_Refresh as refresh from B09_Estimation import B09_Estimation_Sangeun as sangeun @@ -82,10 +89,12 @@ from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials from common_util.common_util_auth import verify_session from common_util.common_util_project_settings import reset_section +from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow_state import mark_stage from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete.M02_Item_Snapshot import importer from M02_MasterTemplete.M02_MasterTemplete_Router_Items import _call, _root +from M02_MasterTemplete.M02_MasterTemplete_Router_Layers import _company_projects, _project try: # 252 공기 초안(draft 폴더 · 지우면 공기 탭 원천 없이) from B09_Estimation.draft import B09_Draft_Schedule_Source as schedule_draft @@ -488,6 +497,63 @@ async def save_personal( } +def _past_rows(projects: list[dict[str, Any]], here: str) -> list[dict[str, Any]]: + out = [] + for other in projects: + if other["project_id"] == here or not other.get("storage_path"): + continue + try: + rows = past.rows_of(Path(resolve_stored_project_path(other["storage_path"]))) + except (ValueError, OSError): # 못 읽는 공사 = 건너뜀 + continue + ref = {"project_id": other["project_id"], "project": other["name"]} + out += [{**ref, **row} for row in rows] + return out + + +@router.get("/{project_id}/estimation/past") +async def past_list( + project_id: UUID, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + here = await _project(session, str(project_id)) + projects = await _company_projects(int(here["company_id"])) + return {"rows": await asyncio.to_thread(_past_rows, projects, str(project_id))} + + +class PastBody(BaseModel): + project_id: str = Field(min_length=1, max_length=64) + cover: str = Field(min_length=3, max_length=200) + + +@router.post("/{project_id}/estimation/past") +async def past_import( + project_id: UUID, body: PastBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + here = await _project(session, str(project_id)) + if body.project_id == str(project_id): + raise HTTPException(status_code=400, detail="이 공사 것은 「이 공사」 탭에서 고름") + other = await _project(session, body.project_id) + if other["company_id"] != here["company_id"]: + raise HTTPException(status_code=403, detail="같은 회사 공사에서만 가져옴") + ref = {"project_id": body.project_id, "프로젝트": other.get("name")} + try: + made = await asyncio.to_thread( + past.import_past, here["root"], other["root"], ref, body.cover, importer(session) + ) + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + out = await payload(project_id, here["root"]) + daega = out["inputs"].get("daega") or {} + key = (daega.get("libs") or {}).get(made["폴더"]) + # 같은 꼴 블록이 이 공사에 먼저 있으면 그 블록(다른 cover) — 덮개 안 옮김(그 블록 덮개 그대로) + cover = next((b["cover"] for b in daega.get("blocks") or [] if b["id"] == key), None) + if cover != made.pop("덮개자리"): + made["덮개"] = {} + return {**made, "key": key, "cover": cover, "payload": out} + + # 166-4 견적서 파일 길 — 같은 머리 · 같은 권한 router.include_router(quotes_router) diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 82bc79315..88d873c46 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -274,3 +274,30 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child { .b09-popup .ui-data-table { max-height: none; } + +/* 703-1 재료 창고 — 오른쪽 서랍 몸(탭 · 찾기 목록 · 안내) 세로 쌓음 · 목록만 스크롤 */ +.b09-store { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + height: 100%; + min-height: 0; +} + +.b09-store .ui-find { + flex: 1 1 auto; + min-height: 0; +} + +/* 703-1 표 칸 + 오른쪽 창고 — 창고가 DOM 앞(표 높이 셈) · 보이는 차례는 표 먼저 */ +.b09-estimation__work { + display: flex; + flex: 1 1 auto; + flex-direction: column; + order: -1; + min-width: 0; +} + +.ui-side-panels-row > .ui-side-panels.b09-store__side { + flex: none; +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index b1550bc15..a315898fa 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -19,6 +19,7 @@ import { createText } from "@ui/ui_template_text"; import { createBanner } from "@ui/ui_template_banner"; import { createButton, + el, hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, @@ -290,7 +291,12 @@ export function renderB09Estimation(root: HTMLElement): void { const fold = bill?.fold?.(table.root); // 702-1 줄 펼침 = 표 아래 // 701-3 띠(원가계산서 「빈 줄 n · 미완성」) = 표 위 const banner = spec.banner ? [createBanner({ tone: "warning", text: spec.banner })] : []; - panel.replaceChildren(...banner, table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])); + const body = [table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])]; + const side = bill?.side?.(table.root); // 703-1 재료 창고 = 표 오른쪽 서랍 + const main = side ? el("div", { className: "b09-estimation__work", children: body }) : null; + // 서랍 = DOM 앞(표 높이 셈 `spaceBelow` 가 뒤 형제만 봄) · 보이는 차례 = CSS order + const row = main && el("div", { className: "ui-side-panels-row", children: [side!, main] }); + panel.replaceChildren(...banner, ...(row ? [row] : body)); if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강) }; diff --git a/B09_Estimation/B09_Estimation_UI_Store.ts b/B09_Estimation/B09_Estimation_UI_Store.ts new file mode 100644 index 000000000..81c62c5c6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Store.ts @@ -0,0 +1,370 @@ +/* ============================================================================= + * B09_Estimation_UI_Store.ts + * B09 설계내역서 오른쪽 재료 창고(PLAN 703-1) — 찾기 하나 + 출처 탭(이 공사 · 내 것 · 회사 · 품셈 · 지난 공사). + * + * 두 번 누름 · Enter = 고른 내역 줄(`…_Bill_Level` 고름)에 단가연결 · 끌어 놓기 = 놓은 줄에 단가연결 · + * 우클릭 = [고른 줄에 연결] · 품셈 = [계산서 보기](704-2 `Labor_Sheet` · [담기] = 연결) + * 연결 = 단가연결 모달과 같은 길(`…_Bill_Link` · 702-3 즉시 금액) — 호표 = 연결 · 조합 · 사전조합 = 사본 들여옴 · + * 품셈 = 넣을 값 창(대표 규격 채움) · 지난 공사 = 서버가 이 공사 서고 사본으로(`…_Past` · 덮개째 · + * 단가 = 이 공사 단가 책) → 받은 덮개를 그 cover 가 비었을 때만 화면 캐시에 얹음 + * 틀 = 공용 오른쪽 서랍(`ui_template_side_panels` · 접고 펴기 · 폭 끌기 · 좁은 화면 = 아래로) + * 창고 하나 = 페이지 한 벌(다시 그려도 찾기 글 · 탭 · 받은 목록 남음) · 이 공사 = 그릴 때마다 새로 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DataRow } from "@ui/ui_template_data_table"; +import { el, showToast } from "@ui/ui_template_elements"; +import { createFindList, type FindRow } from "@ui/ui_template_find_list"; +import { loadRows } from "@ui/ui_template_item_picker"; +import { itemName } from "@ui/ui_template_item_picker_data"; +import { matchLogic } from "@ui/ui_template_logic_find"; +import { createSidePanels } from "@ui/ui_template_side_panels"; +import { createTabs } from "@ui/ui_template_tabs"; +import { createText } from "@ui/ui_template_text"; +import { currentProjectId } from "../A00_Common/b_project_switch"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { + fetchLibrary, + fetchStandard, + fetchStandardSheet, + type StandardRow, +} from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; +import { openStandardSheet } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet"; +import { ownOwner } from "../M02_MasterTemplete/M02_MasterTemplete_Layer"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import type { TabContext } from "./B09_Estimation_UI_Tabs"; +import { applyAt, openMenuAt } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { askInputs, importLib, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link"; +import { pickedKey } from "./B09_Estimation_UI_Tabs_Bill_Level"; + +type Source = "here" | "mine" | "company" | "poomsem" | "past"; +const SOURCES: Array<[Source, string]> = [ + ["here", "이 공사"], + ["mine", "내 것"], + ["company", "회사"], + ["poomsem", "품셈"], + ["past", "지난 공사"], +]; +/** 한 번에 그리는 줄 — 품셈 1만 줄 · 찾기로 좁힘 */ +const SHOWN = 200; +const DRAG = "text/x-b09-store"; + +interface Item { + tag: string; + name: string; + sub: string; + unit: string; + hit: (q: string) => boolean; + go: (ctx: TabContext, apply: ApplyRef) => void; + sheet?: (put: () => void) => void; +} + +const words = (q: string): string[] => q.toLowerCase().split(/\s+/).filter(Boolean); +const has = (text: string) => (q: string) => words(q).every((w) => text.toLowerCase().includes(w)); +const onPayload = (ctx: TabContext) => (p: Row) => Object.assign(ctx.payload, p); + +function hereItems(ctx: TabContext): Item[] { + const of = (tab: "dg" | "sg", list: Row[] | undefined): Item[] => + (list || []).map((r) => { + const tag = tab === "dg" ? `일위대가 ${r.no}호` : `단가산출 ${r.no}호`; + const name = String(r.name || ""); + return { + tag, + name, + sub: String(r.spec || ""), + unit: String(r.unit || ""), + hit: has(`${tag} ${name} ${r.spec || ""}`), + go: (_c, apply) => apply({ tab, key: r.key }, r), + }; + }); + return [...of("dg", ctx.sheets.daega), ...of("sg", ctx.sheets.sg_list)]; +} + +/** 내 것 · 회사 — 구조물 조합(그 층) + 사전조합(그 층 일위대가) */ +async function layerItems(layer: "personal" | "company"): Promise { + const owner = ownOwner(layer, await fetchSessionUser().catch(() => null)); + const combos = (await loadRows().catch(() => [])).filter((r) => r.layer === layer); + const pres = owner ? await fetchLibrary({ 층: layer, 주인: owner }, "precombo") : []; + return [ + ...combos.map((r) => { + const name = itemName(r); + const pick = { kind: "combo" as const, layer, owner: r.owner ?? null, key: r.key }; + return { + tag: "구조물 조합", + name, + sub: "", + unit: String(r.단위 || ""), + hit: has(`구조물 조합 ${name}`), + go: (ctx: TabContext, apply: ApplyRef) => + void importLib({ apply, onPayload: onPayload(ctx) }, pick), + }; + }), + ...pres.map((r) => { + const name = r.제목 || r.이름; + const pick = { kind: "precombo" as const, layer, owner, key: r.이름 }; + return { + tag: "일위대가", + name, + sub: [r.구분, r.상세구분].filter(Boolean).join(" "), + unit: String(r.단위 || ""), + hit: has(`일위대가 ${name} ${r.구분 ?? ""} ${r.상세구분 ?? ""}`), + go: (ctx: TabContext, apply: ApplyRef) => + void importLib({ apply, onPayload: onPayload(ctx) }, pick), + }; + }), + ]; +} + +/** 넣을 값 창 — 대표 규격 = 계산서(704-2)가 셈에 쓴 입력 전부(목록 `바꿈` 은 기본과 다른 것만 · + * 기본을 다르게 보는 칸이 빠짐 — 석축헐기 뒷길이 60 · 0) · 못 받으면 `바꿈` */ +function askStandard(apply: ApplyRef, r: StandardRow): void { + const open = (given: Row): void => askInputs(apply, r.키, r.이름, { given }); + fetchStandardSheet(r).then( + (sheet) => open((sheet as { 입력?: Row }).입력 ?? r.바꿈), + () => open(r.바꿈), + ); +} + +/** 품셈 = 704-1 기준 목록(품명 · 규격 · 금액 · 실무 낱말 찾기) · 연결 = 넣을 값 창(대표 규격 채움) */ +async function poomsemItems(): Promise { + const list = await fetchStandard(); + return list.줄.map((r) => ({ + tag: r.공종 || "품셈", + name: r.이름, + sub: [r.규격, r.금액 === null ? "" : `${Math.round(r.금액).toLocaleString("ko-KR")}원`] + .filter(Boolean) + .join(" · "), + unit: r.단위, + hit: (q) => !q || matchLogic(q, { ...r, 이름: `${r.이름} ${r.규격}` }).ok, + go: (_c, apply) => askStandard(apply, r), + sheet: (put) => openStandardSheet(r, put), + })); +} + +async function call(path: string, body?: Row): Promise { + const projectId = currentProjectId(); + if (!projectId) throw new Error("프로젝트 없음"); + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/${path}`, { + method: body ? "POST" : "GET", + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...(body && { body: JSON.stringify(body) }), + }); + const got = (await response.json().catch(() => null)) as Row | null; + if (!response.ok || !got) { + const detail = got?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + return got; +} + +/** 지난 공사 가져옴 — 이 공사 서고 사본 · 덮개 = 그 cover 가 비었을 때만 얹음 */ +async function importPast(ctx: TabContext, apply: ApplyRef, r: Row): Promise { + showToast("지난 공사에서 가져오는 중…", "info"); + try { + const got = await call("past", { project_id: r.project_id, cover: r.cover }); + if (!got.key) throw new Error("사본은 들여왔으나 일위대가를 못 만듦"); + onPayload(ctx)(got.payload); + const from = ((got.payload.sheets?.daega as Row[]) || []).find((x) => x.key === got.key); + const cells: Row = got.덮개 || {}; + const n = Object.keys(cells).length; + let note = ""; + if (n && got.cover) { + const all: Row = { ...(ctx.user.daega_cover || {}) }; + if (Object.keys(all[got.cover] || {}).length) note = " · 이 공사에 고친 칸이 있어 그대로 둠"; + else { + all[got.cover] = cells; + ctx.user = { ...ctx.user, daega_cover: all }; // 아래 연결이 같은 캐시에 씀 + note = ` · 고친 칸 ${n} 함께 옮김`; + } + } + apply({ tab: "dg", key: got.key, lib: got.폴더 }, from); + showToast(`「${r.project}」 일위대가로 연결함${note} — [저장]해야 남음`, "success"); + } catch (error) { + showToast(`못 가져옴 — ${error instanceof Error ? error.message : error}`, "error"); + } +} + +async function pastItems(): Promise { + const got = await call("past"); + return ((got.rows as Row[]) || []).map((r) => ({ + tag: String(r.project || ""), + name: String(r.name || ""), + sub: [r.kind, r.covers ? `고친 칸 ${r.covers}` : ""].filter(Boolean).join(" · "), + unit: String(r.unit || ""), + hit: has(`${r.project} ${r.name} ${r.spec} ${r.kind}`), + go: (ctx, apply) => void importPast(ctx, apply, r), + })); +} + +const LOAD: Record, () => Promise> = { + mine: () => layerItems("personal"), + company: () => layerItems("company"), + poomsem: poomsemItems, + past: pastItems, +}; + +/** 페이지 한 벌 창고 */ +let store: { + root: HTMLElement; + bind: (ctx: TabContext, raw: DataRow[], table: HTMLElement) => void; +} | null = null; + +function makeStore(): NonNullable { + let ctx: TabContext | null = null; + let raw: DataRow[] = []; + let source: Source = "here"; + const got: Partial> = {}; + let shown: Item[] = []; + const finder = createFindList({ placeholder: "품명 · 규격 · 실무 낱말로 찾기" }); + const tabs = createTabs({ + tabs: SOURCES.map(([key, label]) => ({ key, label })), + value: source, + kind: "pill", + onChange: (key) => { + source = key as Source; + draw(); + }, + }); + const guide = createText( + "두 번 누름 = 고른 내역 줄에 단가연결 · 끌어서 내역 줄에 놓기 = 그 줄에 연결 · 우클릭 = 계산서 보기(품셈)", + ); + + /** 그 줄에 연결 — 줄 = 고른 내역 줄(index 없음) · 연결 못 하는 줄이면 알림 */ + const linkTo = (item: Item, at?: number): void => { + if (!ctx) return; + const key = pickedKey(); + const i = at ?? raw.findIndex((r) => String(r.key) === key); + const apply = i >= 0 ? applyAt(ctx, raw, i) : null; + if (!apply) + return void showToast("내역 항목 줄을 먼저 누름(자재 · 묶음 줄은 연결 없음)", "info"); + item.go(ctx, apply); + }; + + const draw = (): void => { + const now = source === "here" ? (ctx ? hereItems(ctx) : []) : got[source]; + if (source !== "here" && now === undefined) void load(source); + if (!Array.isArray(now)) { + finder.hint.textContent = now instanceof Error ? `못 읽음 — ${now.message}` : "읽는 중…"; + return finder.setRows([]); + } + const q = finder.search.value.trim(); + const found = now.filter((x) => x.hit(q)); + shown = found.slice(0, SHOWN); + const more = found.length > SHOWN ? ` · 앞 ${SHOWN} 줄만 — 찾기로 좁힘` : ""; + finder.hint.textContent = found.length + ? `${found.length} / ${now.length}${more}` + : now.length + ? "맞는 것 없음" + : source === "past" + ? "가져올 지난 공사 일위대가 없음(같은 회사 공사만)" + : "비었음"; + finder.setRows( + shown.map((x, i) => ({ + key: String(i), + tag: x.tag, + name: x.name, + sub: x.sub, + unit: x.unit, + onPick: () => linkTo(x), + })), + ); + for (const row of finder.root.querySelectorAll("[data-pick]")) { + row.draggable = true; + row.addEventListener("dragstart", (ev) => ev.dataTransfer?.setData(DRAG, row.dataset.pick!)); + } + for (const [key] of SOURCES) { + const list = key === "here" ? (ctx ? hereItems(ctx) : []) : got[key]; + tabs.setBadge(key, Array.isArray(list) ? list.length : 0); + } + }; + + const load = async (key: Exclude): Promise => { + got[key] = "loading"; + try { + got[key] = await LOAD[key](); + } catch (error) { + got[key] = error instanceof Error ? error : new Error(String(error)); + } + draw(); + }; + + finder.search.addEventListener("input", draw); + // 한 번 누름 = 고름만(두 번째 누름 · 키보드 = 연결) + finder.root.addEventListener( + "click", + (ev) => { + if ((ev.target as Element).closest("[data-pick]") && (ev as MouseEvent).detail === 1) + ev.stopPropagation(); + }, + true, + ); + finder.root.addEventListener("contextmenu", (ev) => { + const row = (ev.target as Element).closest("[data-pick]"); + const item = row ? shown[Number(row.dataset.pick)] : undefined; + if (!item) return; + ev.preventDefault(); + openMenuAt(ev.clientX, ev.clientY, [ + ["고른 내역 줄에 연결", () => linkTo(item)], + ...(item.sheet + ? [["계산서 보기", () => item.sheet?.(() => linkTo(item))] as [string, () => void]] + : []), + ]); + }); + + const body = el("div", { + className: "b09-store", + attrs: { "data-b09-store": "" }, + children: [tabs.root, finder.root, guide], + }); + const side = createSidePanels({ + prefKey: "b09-store", + panels: [{ id: "store", title: "재료 창고", body }], + defaultWidth: 360, + }); + + /** 내역 표 줄에 놓기 — 표를 다시 그리면 새 표에 다시 붙임 */ + const dropOn = (table: HTMLElement): void => { + let over: HTMLElement | null = null; + const mark = (tr: HTMLElement | null): void => { + over?.classList.remove("is-focus"); + over = tr; + over?.classList.add("is-focus"); + }; + table.addEventListener("dragover", (ev) => { + if (!ev.dataTransfer?.types.includes(DRAG)) return; + ev.preventDefault(); + mark((ev.target as Element).closest("tr[data-i]")); + }); + table.addEventListener("dragleave", (ev) => { + if (!table.contains(ev.relatedTarget as Node)) mark(null); + }); + table.addEventListener("drop", (ev) => { + const at = ev.dataTransfer?.getData(DRAG); + const tr = (ev.target as Element).closest("tr[data-i]"); + mark(null); + if (!at || !tr) return; + ev.preventDefault(); + const item = shown[Number(at)]; + if (item) linkTo(item, Number(tr.dataset.i)); + }); + }; + + side.root.classList.add("b09-store__side"); + return { + root: side.root, + bind: (next, rows, table) => { + ctx = next; + raw = rows; + dropOn(table); + draw(); + }, + }; +} + +/** 설계내역서 표 오른쪽 창고 — 표(root)에 놓기 붙이고 창고 서랍을 돌려줌 */ +export function attachBillStore(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement { + store ??= makeStore(); + store.bind(ctx, raw, table); + return store.root; +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index e53db9eb4..2bca9c6f8 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -11,6 +11,7 @@ * 단가연결 칸(램프) = 항목 줄(자재 · 조달수수료 빼고) · 램프 누름 = 우클릭 [연결] 과 같은 메뉴 * 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) · * 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력 + * 오른쪽 재료 창고(703-1) = `…_UI_Store`(두 번 누름 · 끌어 놓기 = 단가연결) * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -25,6 +26,7 @@ import { type BillTable, } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { attachBillFold } from "./B09_Estimation_UI_Tabs_Bill_Fold"; +import { attachBillStore } from "./B09_Estimation_UI_Store"; const TEXT_KEYS = ["name", "spec", "unit"]; /** 폴더 줄 수식 칸 — 자손 합(셈) */ @@ -128,6 +130,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { menu: (i) => billMenu(ctx, raw, i), drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before), fold: (table) => attachBillFold(ctx, raw, table), // 702-1 줄 펼침 · 고친 칸 덮개 + side: (table) => attachBillStore(ctx, raw, table), // 703-1 오른쪽 재료 창고 onCell: (i, key) => { if (key === "connect") openLinkMenu(ctx, raw, i); }, diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index ca6bf6e32..1f102dad6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -34,6 +34,8 @@ export type BillTable = TabTable & { drop?: (index: number, parent: string | null, before: string | null) => (() => void) | null; /** 줄 펼침(702-1) — 표 root 를 받아 표 아래 자리를 돌려줌 */ fold?: (table: HTMLElement) => HTMLElement; + /** 재료 창고(703-1) — 표 root 를 받아(놓기) 오른쪽 서랍을 돌려줌 */ + side?: (table: HTMLElement) => HTMLElement; }; const NEW_NAME = "새 줄"; @@ -222,6 +224,10 @@ function applyRef(ctx: TabContext, rows: DataRow[], i: number): ApplyRef { }; } +/** 재료 창고(703-1) — 그 줄에 연결 적용 · 연결 없는 줄(자재 · 묶음 · 조달수수료) = null */ +export const applyAt = (ctx: TabContext, rows: DataRow[], i: number): ApplyRef | null => + linkable(rows[i]) ? applyRef(ctx, rows, i) : null; + /** 줄 연결 메뉴 — 지금 연결 = 덮개(로직 입력값 등) 있으면 덮개 · 없으면 셈 줄 */ function linkFor(ctx: TabContext, rows: DataRow[], i: number) { const row = rows[i]; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts index f0b333bd9..656772592 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -45,6 +45,9 @@ let live: { } | null = null; let keysOn = false; +/** 고른 줄 키(703-1 재료 창고 두 번 누름 = 이 줄에 연결) */ +export const pickedKey = (): string | null => picked; + /** 고른 줄 class — Shell 줄 class 곁값 */ export const pickedClass = (row: DataRow | undefined): string | null => row && picked !== null && String(row.key) === picked ? "is-focus" : null; diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index c3ccb5dd5..23e35fbb5 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -202,9 +202,9 @@ def test_셸_배선(): ) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나) assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄 assert ( - "panel.replaceChildren(...banner, table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : []))" - in src - ) # 203 레벨 안내 + 탭 안내 = 한 영역 + "const body = [table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])];" in src + and "panel.replaceChildren(...banner, ...(row ? [row] : body));" in src + ) # 203 레벨 안내 + 탭 안내 = 한 영역 · 703-1 창고 있으면 표 칸 + 오른쪽 서랍 lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수 assert "b09-estimation__level" not in lvl diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index a953cf527..5d89460f6 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -106,7 +106,9 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에(): def test_잇기_쉘_안내_줄_아래_도구_비움(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") assert ( - "panel.replaceChildren(...banner, table.root, ...(foot ? [foot] : []))" in shell # 701-3 띠 = 표 위 + "const body = [table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])];" in shell + and "panel.replaceChildren(...banner, ...(row ? [row] : body));" + in shell # 701-3 띠 = 표 위 ) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 203 레벨 안내 + 탭 안내 = 한 영역(맨 아래) assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음 assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell) diff --git a/resources/tester/test_703_1_b09_past.py b/resources/tester/test_703_1_b09_past.py new file mode 100644 index 000000000..36ea3bbfc --- /dev/null +++ b/resources/tester/test_703_1_b09_past.py @@ -0,0 +1,147 @@ +"""703-1 B09 재료 창고 「지난 공사」 — 같은 회사 다른 공사 일위대가 → 이 공사 서고 사본(덮개째). + +- 목록 = 그 공사 서고 사본 · 구조물 사본 · 고친 칸 수(그 공사 저장 덮개) · 같은 것 한 줄 +- 가져옴 = 사본 폴더 통째 → `library/…~해시` · 가져온곳 · 덮개 = 그 공사 칸 · 다시 = 같은 사본 +- 새 호표(`libs`) · 같은 꼴 블록이 이 공사에 먼저 있으면 그 블록 cover +- 모양 틀림 400 · 없음 404 · 다른 회사 403 · 이 공사 400 +임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀. +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest +from fastapi import HTTPException + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402 +from B09_Estimation import B09_Estimation_Past as past # noqa: E402 +from B09_Estimation import B09_Estimation_Router as router # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store_b09 # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 +from config import config_system # noqa: E402 +from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402 +from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 +from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402 +from resources.tester.helper_m02_master_sample import copy_master_sample # noqa: E402 + +STONE = "GF000219" # 돌쌓기 메쌓기 원/㎡ + + +@pytest.fixture() +def world(tmp_path, monkeypatch): + system = copy_master_sample(tmp_path / "master") + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + with store.at(system): + key = refs.create( + "precombo", + {"이름": "지난 조합", "단위": "㎡", "담은로직": [{"로직": STONE, "수량": {"손": 2}}]}, + )["이름"] + a, b = (tmp_path / "storage" / "7" / "1" / n for n in ("past", "here")) + a.mkdir(parents=True) + b.mkdir(parents=True) + folder = library.import_copy(a, "precombo", "system", None, key)["폴더"] + ref = {"층": "system", "주인": None, "키": key} + cover = { + f"C:{refs.ref_id(ref)}": {"sangeun:GF#0": {"qty": 5}}, + "K:x": {"@copy": {"from": "S:y"}}, + } + store_b09.write_user(a, {"daega_cover": cover}) + return {"key": key, "a": a, "b": b, "folder": folder} + + +def test_목록_서고_사본_고친_칸_수_같은_것_한_줄(world): + a = world["a"] + rows = past.rows_of(a) + assert [(r["cover"], r["name"], r["unit"], r["kind"], r["covers"]) for r in rows] == [ + (f"L:{world['folder']}", "지난 조합", "㎡", "사전조합", 1) + ] + # 같은 원본 · 같은 값 · 덮개 없음 = 한 줄로 셈(값 해시 다른 사본) + library.import_copy(a, "precombo", "system", None, world["key"], haul={"given": {}}) + assert len(past.rows_of(a)) == 1 + + +def test_가져옴_사본_통째_덮개째_새_호표_다시는_같은_사본(world): + a, b, folder = world["a"], world["b"], world["folder"] + ref = {"project_id": "P-A", "프로젝트": "지난"} + made = past.import_past(b, a, ref, f"L:{folder}", {"이름": "나"}) + name = made["폴더"] + assert made["만듦"] and made["덮개"] == {"sangeun:GF#0": {"qty": 5}} + assert name.startswith(f"precombo@system@-@{world['key']}~") and library.NAME.match(name) + copy = library.library_dir(b) / name + src = library.library_dir(a) / folder + for f in ("일위대가.json", "단가.json"): + assert (copy / f).read_bytes() == (src / f).read_bytes() + manifest = json.loads((copy / "manifest.json").read_text("utf-8")) + assert manifest["가져온곳"] == {**ref, "사본": f"L:{folder}"} and manifest["종류"] == "precombo" + got = daega_inputs(b) + bid = got["libs"][name] + block = next(x for x in got["blocks"] if x["id"] == bid) + # 사전조합 덮개 = 참조(공사 안 같은 사전조합 한 덮개) — 덮개자리 = 그 cover + assert (block["name"], block["cover"]) == ("지난 조합", made["덮개자리"]) + assert made["덮개자리"] == "C:참조|system||" + world["key"] + again = past.import_past(b, a, ref, f"L:{folder}") + assert (again["폴더"], again["만듦"]) == (name, False) + + +def test_같은_꼴_블록이_먼저_있으면_그_cover(world): + a, b = world["a"], world["b"] + mine = library.import_copy(b, "precombo", "system", None, world["key"])["폴더"] + name = past.import_past(b, a, {"project_id": "P-A"}, f"L:{world['folder']}")["폴더"] + got = daega_inputs(b) + assert got["libs"][name] == got["libs"][mine] + block = next(x for x in got["blocks"] if x["id"] == got["libs"][name]) + assert block["cover"].startswith("C:") # 사전조합 = 같은 참조 한 덮개(화면이 빈 때만 얹음) + + +def test_구조물조합_사본_덮개자리_새_폴더(world): + a, b = world["a"], world["b"] + folder = library.import_copy(a, "combo", "system", None, "SS000011")["폴더"] + store_b09.write_user(a, {"daega_cover": {f"L:{folder}": {"x#0": {"qty": 2}}}}) + row = next(r for r in past.rows_of(a) if r["cover"] == f"L:{folder}") + assert (row["kind"], row["covers"]) == ("구조물 조합", 1) + made = past.import_past(b, a, {"project_id": "P-A"}, f"L:{folder}") + assert made["덮개자리"] == f"L:{made['폴더']}" and made["덮개"] == {"x#0": {"qty": 2}} + got = daega_inputs(b) + block = next(x for x in got["blocks"] if x["id"] == got["libs"][made["폴더"]]) + assert block["cover"] == made["덮개자리"] + + +def test_모양_틀림_없음(world): + a, b = world["a"], world["b"] + for bad in ("L:../x", "X:y", "S:../../z"): + with pytest.raises(ValueError): + past.import_past(b, a, {}, bad) + with pytest.raises(LookupError): + past.import_past(b, a, {}, "S:nope") + with pytest.raises(LookupError): + past.import_past(b, a, {}, "L:precombo@system@-@SC-000999") + + +def test_길_다른_회사_403_이_공사_400(world, monkeypatch): + rows = { + "here": {"company_id": 7, "root": world["b"]}, + "far": {"company_id": 8, "root": world["a"]}, + } + + async def project(_session, pid): + return {"name": pid, **rows["here" if pid.startswith("0") else "far"]} + + monkeypatch.setattr(router, "_project", project) + pid = router.UUID("00000000-0000-0000-0000-000000000001") + body = router.PastBody(project_id="far", cover=f"L:{world['folder']}") + with pytest.raises(HTTPException) as e: + asyncio.run(router.past_import(pid, body, {"user_id": 1})) + assert e.value.status_code == 403 + same = router.PastBody(project_id=str(pid), cover="L:x") + with pytest.raises(HTTPException) as e: + asyncio.run(router.past_import(pid, same, {"user_id": 1})) + assert e.value.status_code == 400 + assert not library.library_dir(world["b"]).exists() # 아무것도 안 씀