From 3028801cd890e8b6d3d66de80bd74af97920b9a4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 14:10:04 +0900 Subject: [PATCH 1/4] =?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/4] =?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/4] =?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/4] =?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,