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 != "규격"})