From f79078f9c93121153ebb94c0a38037feab50d426 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 16:18:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(m02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=9E=90=EC=9E=AC=EB=AA=A9=EB=A1=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=9E=90=EC=9E=AC=20=EA=B8=B0=EB=B3=B8=20=EC=A4=84?= =?UTF-8?q?=20=C2=B7=20[=EB=B0=94=EA=BE=B8=EA=B8=B0]=20=EA=B3=A0=EB=A6=84?= =?UTF-8?q?=20=C2=B7=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EB=B6=84=EB=A7=8C=20=EC=82=AD=EC=A0=9C(103-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 문서 담은로직[].고름 {호표 차례: MT키} 정리 · 검사(재료 줄 · M01 자재품목만) - run_line 을 엔진 choosing(고름)으로 감쌈 · 로직 자재 줄(차례 · 품목 · 단가 · 금액 · 가격 · 걸림) 내보냄 - 로직 자재 금액은 합 · 알약에 안 더함 · 사용자 자재와 같은 품목은 저장 때 400 - 단가승격 · 사전조합 안 로직도 고름 따름(없으면 지금 값) - 프로젝트 사본 고른자재 씨앗 = 문서 고름 · 단가 묶음에 고른 품목 줄 - 화면: 자재목록 표 앞에 로직 자재 줄(삭제 없음 · [바꾸기] · 바꾼 줄 [기본]) · 입력 바뀌면 걸린 고름 풂 · 로직 자재와 같은 품목 추가 막음 - 시험 test_m02_labor_logic_mats.py(서버 · Node 짝) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M02_MasterTemplete/M02_Item_Snapshot.py | 17 +- M02_MasterTemplete/M02_Item_Source.py | 6 +- M02_MasterTemplete/M02_Labor_Doc.py | 95 ++++++++-- .../M02_Labor_LogicMats_Model.ts | 53 ++++++ M02_MasterTemplete/M02_Labor_Ref.py | 8 +- .../M02_MasterTemplete_Labor.css | 10 ++ .../M02_MasterTemplete_Labor_Api.ts | 3 + .../M02_MasterTemplete_Labor_Calc.ts | 3 + .../M02_MasterTemplete_Labor_Detail.ts | 47 ++++- .../M02_MasterTemplete_Labor_LogicMats.ts | 144 ++++++++++++++++ .../M02_MasterTemplete_Labor_Rows.ts | 9 +- .../M02_MasterTemplete_Labor_Text.ts | 10 ++ .../M02_MasterTemplete_Labor_View.ts | 6 +- .../tester/helper_m02_labor_logic_mats.cjs | 15 ++ resources/tester/test_m02_labor_logic_mats.py | 162 ++++++++++++++++++ 15 files changed, 566 insertions(+), 22 deletions(-) create mode 100644 M02_MasterTemplete/M02_Labor_LogicMats_Model.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_LogicMats.ts create mode 100644 resources/tester/helper_m02_labor_logic_mats.cjs create mode 100644 resources/tester/test_m02_labor_logic_mats.py diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index 9df1bb0cb..2270275bb 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -109,11 +109,22 @@ def seed_values(labor: Any) -> dict[str, Any]: return out +def seed_picks(labor: Any) -> dict[str, Any]: + """일위대가 문서 고름 → 프로젝트 고른자재 첫 값 `{로직: {호표 차례: MT키}}`(103-1).""" + doc = labor.get("줄") if isinstance(labor, dict) else None + return { + str(line["로직"]): dict(line["고름"]) + for line in (doc or {}).get("담은로직") or [] + if isinstance(line, dict) and line.get("로직") and isinstance(line.get("고름"), dict) + } + + def build_snapshot(item: dict[str, Any]) -> dict[str, Any]: - """원본 내용 + 프로젝트 값(입력값 첫 값 · 빈 고른자재) — `{파일 이름: 내용}` · SS 줄이 없으면 - LookupError.""" + """원본 내용 + 프로젝트 값(입력값 · 고른자재 첫 값 = 일위대가 문서 것) — `{파일 이름: 내용}` · + SS 줄이 없으면 LookupError.""" files = build_source(item) - return {**files, VALUES_FILE: seed_values(files.get(LABOR_FILE)), PICKS_FILE: {}} + labor = files.get(LABOR_FILE) + return {**files, VALUES_FILE: seed_values(labor), PICKS_FILE: seed_picks(labor)} def source_files(files: dict[str, Any]) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index ccd9c7af0..5f219ca70 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -150,7 +150,11 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])] logic_keys += refs.frozen_logics((labor or {}).get("참조사본") or {}) # 굳힌 단가승격 로직 - materials = [str(m.get("코드")) for m in ((labor or {}).get("줄") or {}).get("자재") or []] + doc = (labor or {}).get("줄") or {} + materials = [str(m.get("코드")) for m in doc.get("자재") or []] + materials += [ # 고른 로직 자재(103-1) 품목 줄도 — 복사본 계산이 그 단가를 씀 + str(key) for one in doc.get("담은로직") or [] for key in (one.get("고름") or {}).values() + ] wanted = logic_keys + [code for code in materials if code] files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} return files diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 9640e799d..1eef8d95f 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -1,7 +1,7 @@ """M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장). -문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?} -| {참조: {층, 주인, 키}, 별칭, 메모, 수량}], +문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, +담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}], 자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠). 수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}` (손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값). @@ -14,11 +14,15 @@ 분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만. 짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` — 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. +로직 자재(103-1) = 담은 로직 맨 위 층 호표 재료 줄(`logic_mats` · 줄마다 `재료`) · +고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와 +같은 품목 두 번은 [저장] 때 400. """ from __future__ import annotations import json +import re from decimal import Decimal, InvalidOperation from typing import Any @@ -34,6 +38,7 @@ COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비 MATERIAL_HEAD = "자재|" _MAT_COLS = ("코드", "이름", "규격", "단위") ALIAS_MAX = 40 # 별칭(44-6) 글자 수 상한 — 화면 칸과 같음 +_BRACE = re.compile(r"\{([^{}]+)\}") # 품목 조건의 {입력} def _bad(text: str) -> store.StoreError: @@ -109,6 +114,7 @@ def clean(doc: Any) -> dict[str, Any]: raise _bad(f"{where} · 입력값이 묶음 아님") memo, alias = one.get("메모"), _alias(one.get("별칭"), where) rate = _rate(one.get("켜쌓기율"), code, where) + picks = _picks(one.get("고름"), logic, whole, where) lines.append( { "로직": code, @@ -117,6 +123,7 @@ def clean(doc: Any) -> dict[str, Any]: "입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")}, "수량": _qty(one.get("수량"), where), **({"켜쌓기율": rate} if rate is not None else {}), + **({"고름": picks} if picks else {}), } ) mats, codes = [], set() @@ -162,6 +169,28 @@ def _alias(alias: Any, where: str) -> str | None: return alias or None +def _picks(value: Any, logic: dict, whole: Any, where: str) -> dict[str, str] | None: + """고름(103-1) = `{호표 차례: MT키}` — 맨 위 층 재료 줄 · M01 자재품목 키만 · 빈 것 = None.""" + if not value: + return None + if not isinstance(value, dict): + raise _bad(f"{where} · 고름은 {{호표 차례: MT키}}") + rows = logic.get("호표") or [] + out = {} + for at, key in value.items(): + n = int(at) if str(at).isdigit() else -1 + if not 0 <= n < len(rows) or rows[n].get("종류") != "재료": + raise _bad(f"{where} · 고름 {at}째는 재료 줄 아님") + try: + ok = str(key)[:2] == mm.MARKET and whole.get(str(key)) is not None + except mf.FormulaError: + ok = False + if not ok: + raise _bad(f"{where} · 고름 「{key}」 M01 자재품목에 없음") + out[str(n)] = str(key) + return out + + def _rate(value: Any, code: str, where: str) -> int | float | None: """켜쌓기율(85-2) — 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖 로직은 None(안 둠).""" if value is None or value == "": @@ -372,7 +401,8 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: "분모": pill.get("단위"), "배": pill.get("배") or 1, } - lines.append({**line, **run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"))}) + got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름")) + lines.append({**line, **got}) mats = [ { "코드": one["코드"], @@ -385,19 +415,58 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: return settle(_json({"줄": lines, "자재": mats}), doc, have) -def run_line(whole: Any, code: str, given: Any, rate: Any = None) -> dict[str, Any]: - """로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율) — `{기준, 까닭}` · - 돈 아닌 로직은 `{결과, 기준: None}`(반영 없음).""" +def run_line( + whole: Any, code: str, given: Any, rate: Any = None, picks: Any = None +) -> dict[str, Any]: + """로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율 · 고른 재료 = 엔진 + `choosing`) — `{기준, 줄, 재료, 까닭}` · 돈 아닌 로직은 `{결과, 기준: None}`(반영 없음).""" try: logic = whole.logic(code) auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None} - got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate) + with mf.mp.choosing(picks): + got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate) except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: return {"기준": None, "까닭": str(e) or type(e).__name__} if "결과" in got: return {"결과": got["결과"], "기준": None, "까닭": None} # 「줄」 = 그 로직 재료 · 노무 · 경비 줄(줄 누름 상세 · 50-4) - return {"기준": {k: got[k] for k in (*COSTS, "계")}, "줄": got["줄"], "까닭": None} + money = {k: got[k] for k in (*COSTS, "계")} + mats = logic_mats(whole, logic, got["줄"], picks) + return {"기준": money, "줄": got["줄"], "재료": mats, "까닭": None} + + +def logic_mats(whole: Any, logic: dict, lines: list[dict], picks: Any) -> list[dict[str, Any]]: + """로직 자재(103-1) — 맨 위 층 호표 재료 줄마다 엔진 답 줄(같은 차례) 그대로 · 금액은 로직 + 재료비 안(합에 안 더함) · `걸림` = 품목 조건에 든 입력 이름(중간값이면 입력 전부 — 그 입력이 + 바뀌면 화면이 고름을 풂 · 짝 `M02_Labor_LogicMats_Model.ts` `releaseTied`).""" + if isinstance(logic.get("흐름"), dict): + return [] # 흐름 로직 재료 자리는 입력 이름 — 이번엔 안 보임 + names = {str(spec.get("이름")) for spec in _specs(logic)} + out = [] + for n, item in enumerate(logic.get("호표") or []): + if item.get("종류") != "재료" or n >= len(lines): + continue + line, goods = lines[n], lines[n].get("품목") + try: + row = (whole.get(goods["키"]) if goods else None) or {} + except mf.FormulaError: + row = {} + cond = item.get("요소") + texts = cond.values() if isinstance(cond, dict) else [cond] + tied = {w for t in texts if isinstance(t, str) for w in _BRACE.findall(t)} + out.append( + { + "차례": n, + "이름": item.get("이름") or "", + "단위": item.get("단위") or "", + **{k: line.get(k) for k in ("수량", "단가", "금액", "까닭")}, + "품목": goods, + "가격": {slot: row.get(slot) for slot in mm.SLOTS}, + "고름": str(n) in (picks or {}), + "걸림": sorted(tied if tied <= names else names) if tied else [], + } + ) + return out def ref_line(ref: dict, got: dict[str, Any]) -> dict[str, Any]: @@ -421,6 +490,12 @@ def _json(value: Any) -> Any: def server(doc: Any) -> dict[str, Any]: - """[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림).""" + """[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림) · + 사용자 자재가 로직 자재 품목과 같으면 400(103-1 · 같은 품목 두 번 셈).""" got = clean(doc) - return {**got, "계산값": calc(got)} + out = calc(got) + inside = {x["품목"]["키"] for one in out["줄"] for x in one.get("재료") or [] if x.get("품목")} + twice = sorted({m["코드"] for m in got["자재"]} & inside) + if twice: + raise _bad(f"자재 「{', '.join(twice)}」 는 로직 자재에 있음 — 같은 품목 두 번 담음") + return {**got, "계산값": out} diff --git a/M02_MasterTemplete/M02_Labor_LogicMats_Model.ts b/M02_MasterTemplete/M02_Labor_LogicMats_Model.ts new file mode 100644 index 000000000..8da43bf83 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_LogicMats_Model.ts @@ -0,0 +1,53 @@ +/* ============================================================================= + * M02_Labor_LogicMats_Model.ts + * 로직 자재(103-1) 자료 · 규칙(화면 없음) — 서버 calc `줄[].재료`(짝 `M02_Labor_Doc.logic_mats`) · + * 입력이 바뀌면 그 입력에 걸린 고름을 풂(`releaseTied`) · 사용자 자재와 같은 품목 막기(`insideKeys`). + * 시험 `resources/tester/test_m02_labor_logic_mats.py`(Node 헬퍼). + * ========================================================================== */ + +/** 잡힌 품목 — 엔진 답 `품목`(값칸 = 단가가 나온 값 열) */ +export interface LogicMatItem { + 키: string; + 이름?: string | null; + 규격?: string | null; + 단위?: string | null; + 값칸?: string | null; +} + +/** 로직 자재 한 줄 — 맨 위 층 호표 재료 줄(금액은 로직 재료비 안 · 합에 안 더함) */ +export interface LogicMat { + 차례: number; + 이름: string; + 단위: string; + 수량: number | null; + 단가: number | null; + 금액: number | null; + 까닭?: string | null; + 품목: LogicMatItem | null; + /** M01 자재품목 값 열 다섯(빈 값 null) */ + 가격?: Record; + 고름: boolean; + /** 품목 조건에 든 입력 이름 — 그 입력이 바뀌면 고름을 풂 */ + 걸림: string[]; +} + +/** 입력값이 `before` 에서 바뀐 이름에 걸린 고름을 지움(다 풀리면 `고름` 칸째) — `row` 를 고쳐 돌려줌 */ +export function releaseTied< + T extends { 입력값: Record; 고름?: Record }, +>(row: T, before: Record, mats: readonly LogicMat[]): T { + if (!row.고름) return row; + const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v)); + const keys = new Set([...Object.keys(before), ...Object.keys(row.입력값)]); + const changed = [...keys].filter((k) => text(before[k]) !== text(row.입력값[k])); + for (const mat of mats) + if (mat.걸림.some((n) => changed.includes(n))) delete row.고름[String(mat.차례)]; + if (!Object.keys(row.고름).length) delete row.고름; + return row; +} + +/** calc 답의 로직 자재 품목 키 전부 — 사용자 자재 추가 막기 */ +export function insideKeys(lines: readonly { 재료?: LogicMat[] }[] | undefined): Set { + return new Set( + (lines ?? []).flatMap((l) => (l.재료 ?? []).flatMap((m) => (m.품목 ? [m.품목.키] : []))), + ); +} diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 7ccecac25..9c5228fe3 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -370,7 +370,9 @@ class Expand: line = (doc.get("담은로직") or [{}])[0] code = str(line.get("로직") or "") - got = labor.run_line(self.whole, code, line.get("입력값"), line.get("켜쌓기율")) + got = labor.run_line( + self.whole, code, line.get("입력값"), line.get("켜쌓기율"), line.get("고름") + ) times = labor._number(doc.get("배")) or Decimal(1) base = got.get("기준") why = got.get("까닭") or (None if base else "돈 아닌 로직") @@ -397,7 +399,9 @@ class Expand: from M02_MasterTemplete import M02_Labor_Doc as labor pill = pill or {} - got = labor.run_line(self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율")) + got = labor.run_line( + self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름") + ) times = labor._number(pill.get("배")) or Decimal(1) base = got.get("기준") return { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index e40258c2b..a5970a536 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -53,6 +53,16 @@ font-size: var(--text-caption); } +/* 로직 자재 줄(103-1) — 금액은 로직 단가 안(합에 안 더함 · 흐리게) · 바꾼 줄 = 소속 글 강조 */ +.m02-labrow__logic-mat .m02-labrow__money { + color: var(--color-text-secondary); +} + +.m02-labrow__logic-mat.is-picked-mat .m02-labrow__key { + color: var(--color-accent); + font-weight: 600; +} + .m02-labrow__money, .m02-labrow__num { text-align: right; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 67b1d3931..47275de84 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -131,6 +131,8 @@ export interface LaborRow { 수량: LaborQty; /** 켜쌓기 적용율 %(85-2 · 대상 로직만 · 없음 = 100) */ 켜쌓기율?: number | null; + /** 고른 로직 자재(103-1) — {호표 차례: MT키} · 없으면 엔진 기본 */ + 고름?: Record; } export interface LaborMat { 코드: string; @@ -167,6 +169,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc { 수량: r.수량 ?? null, ...(r.참조 ? { 참조: r.참조 } : {}), ...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}), + ...(r.고름 && Object.keys(r.고름).length ? { 고름: { ...r.고름 } } : {}), })), 자재: (d.자재 ?? []).map((m) => ({ 코드: m.코드, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index 54e3b3e2e..2024f610a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -7,6 +7,7 @@ * 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */ import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import type { LogicMat } from "./M02_Labor_LogicMats_Model"; export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const; export type Money = Record<(typeof MONEY_KEYS)[number], number>; @@ -36,6 +37,8 @@ export interface CalcLine { 결과?: unknown; /** 그 로직 재료 · 노무 · 경비 줄(M01 시험계산 표와 같은 모양 · 50-4) */ 줄?: LogicLine[]; + /** 로직 자재(103-1) — 맨 위 층 재료 줄 · 금액은 `기준` 안(여기서 안 더함) */ + 재료?: LogicMat[]; /** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④ · * 참조 줄 = `참조` · 로직 줄(51-11) = `로직`) */ 이름?: string | null; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index cf2d81160..8f2f7f632 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -7,6 +7,8 @@ * [확인] = 화면 캐시만 · 저장은 [저장] * · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈 * · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨 + * · 자재목록 = 로직 자재 줄(103-1 · `Labor_LogicMats` · [바꾸기] → `담은로직[].고름` · 입력이 바뀌면 걸린 고름 풂) + + * 사용자 자재 줄([삭제]) · 로직 자재와 같은 품목은 사용자 자재로 못 담음 * · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문 * · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) · * 정보 칸 구분 · 상세구분(51-12 · 승격 = 읽기만 + 연결 · 사전조합 = 고름) · @@ -58,8 +60,10 @@ import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input"; import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal"; import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats"; import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model"; import { keepKnown } from "./M02_Labor_Values_Model"; import { pickMaterial } from "./M02_Material_Pick"; import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick"; @@ -103,6 +107,8 @@ export function mountLaborDetail( const promote = lib === "promote"; const logics = new Map(); let calc: LaborCalc | null = null; + /** 마지막으로 받은 calc — 다시 세는 동안 로직 자재 줄을 그대로 둠(줄이 사라졌다 나타나지 않게) */ + let last: LaborCalc | null = null; let fail: string | null = null; let seq = 0; let timer = 0; @@ -202,7 +208,18 @@ export function mountLaborDetail( drawLogic(); matHost.replaceChildren( matTable( - { mats: cur.자재, calc, fail, cells: cells() }, + { + mats: cur.자재, + calc, + fail, + cells: cells(), + lead: logicMatRows( + cur.담은로직, + calc ?? last, + !calc && !fail, + edit ? { swap: swapMat, reset: resetMat } : undefined, + ), + }, edit ? { open: openMat, remove: removeMat } : { open: openMat }, ), ); @@ -222,7 +239,7 @@ export function mountLaborDetail( // 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음) const got = await calcLabor(opt.layer, opt.column, cur, areaDirty ? given() : undefined); if (!alive || mine !== seq) return; - calc = got; + calc = last = got; pills = new Map( (got.알약 as { 키: string; 이름?: string; 단위?: string }[]).map((p) => [p.키, p]), ); @@ -343,6 +360,25 @@ export function mountLaborDetail( cur.자재.splice(i, 1); changed(); } + /** 로직 자재 [바꾸기](103-1) — 사용자 자재와 같은 품목은 막음 */ + function swapMat(i: number, at: number, key: string): void { + const own = cur.자재.find((m) => m.코드 === key); + if (own) + return showToast( + tc("Labor_AlreadyMat").replace("{value}", `${key} ${own.이름 ?? ""}`), + "error", + ); + const row = cur.담은로직[i]; + row.고름 = { ...row.고름, [String(at)]: key }; + changed(); + } + function resetMat(i: number, at: number): void { + const row = cur.담은로직[i]; + if (!row.고름) return; + delete row.고름[String(at)]; + if (!Object.keys(row.고름).length) delete row.고름; + changed(); + } function openRow(i: number): void { const row: LaborRow = cur.담은로직[i]; @@ -359,7 +395,9 @@ export function mountLaborDetail( const same = JSON.stringify(values) === JSON.stringify(row.입력값) && next.켜쌓기율 === (row.켜쌓기율 ?? null); + const before = row.입력값; row.입력값 = values; + releaseTied(row, before, lineOf(row.로직)?.재료 ?? []); // 바뀐 입력에 걸린 고름 = 기본(103-1) row.메모 = next.메모; if (next.켜쌓기율 === null) delete row.켜쌓기율; else row.켜쌓기율 = next.켜쌓기율; @@ -434,6 +472,11 @@ export function mountLaborDetail( variant: "filled", onClick: () => pickMaterial((m) => { + if (insideKeys((calc ?? last)?.줄).has(m.코드)) + return showToast( + tc("Labor_InLogicMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`), + "error", + ); if (cur.자재.some((x) => x.코드 === m.코드)) return showToast( tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_LogicMats.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_LogicMats.ts new file mode 100644 index 000000000..90cea47f5 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_LogicMats.ts @@ -0,0 +1,144 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_LogicMats.ts + * 자재목록 표의 로직 자재 줄(103-1) — 담은 로직 맨 위 층 재료 줄(서버 calc `줄[].재료`) · 사용자 자재 줄과 같은 칸 + * (코드 · 이름 · 규격 · 수량 · 단위 · 가격 다섯 · 단가 / 금액) · 사용자 자재 앞. + * · 삭제 없음 · [바꾸기] = M01 자재품목 표에서 고름(문서 `담은로직[].고름` · 엔진 `choosing`) · 바꾼 줄만 [기본](고름 풂) + * · 금액은 로직 단가 안이라 합에 다시 안 더함(보기만) · 수량은 로직 것(누름 · 알약 없음) + * · 하위 로직 안 재료는 안 보임(엔진이 맨 위 층만 고름) + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import { openPickTable } from "../M01_MasterData/M01_MasterData_UI_Logic_PickTable"; +import { headWord } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Modal"; +import type { LaborCalc, LaborRow } from "./M02_MasterTemplete_Labor_Api"; +import { lineId } from "./M02_MasterTemplete_Labor_Calc"; +import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import type { LogicMat } from "./M02_Labor_LogicMats_Model"; + +export interface LogicMatHandlers { + /** [바꾸기]로 고른 품목 키 — `row` = 담은 로직 차례 · `at` = 호표 차례 */ + swap: (row: number, at: number, key: string) => void; + /** [기본] — 그 줄 고름 풂 */ + reset: (row: number, at: number) => void; +} + +const num = (v: number | null): string => + v === null + ? "—" + : Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); + +/** 담은 로직 차례대로 그 줄 로직 자재 — calc 답이 없으면 빈 것(답이 오면 다시 그림) */ +export function logicMatRows( + rows: LaborRow[], + calc: LaborCalc | null, + wait: boolean, + on?: LogicMatHandlers, +): HTMLElement[] { + return rows.flatMap((row, r) => { + const line = row.참조 ? undefined : calc?.줄.find((l) => lineId(l) === lineId(row)); + return (line?.재료 ?? []).map((mat) => matRow(row, r, mat, wait, on)); + }); +} + +function matRow( + row: LaborRow, + r: number, + mat: LogicMat, + wait: boolean, + on?: LogicMatHandlers, +): HTMLElement { + const item = mat.품목; + const name = item?.이름 ?? mat.이름; + const owner = `${row.별칭 ?? row.로직} · ${mat.이름}`; + const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => { + const base = !!item?.값칸 && item.값칸 === slot; + const v = mat.가격?.[slot]; + return el("td", { + className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`, + text: wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v), + attrs: { "data-label": slot, "data-price-slot": slot }, + }); + }; + const money = moneyCell(mat.단가, mat.금액, wait, tc("Labor_AppliedSum")); + money.title = tc("LogicMat_Inside"); + const tools = on + ? [ + el("td", { + className: "m02-labrow__move", + children: [ + el("div", { + className: "ui-btn-group", + children: [ + createButton({ + label: tc("LogicMat_Swap"), + variant: "ghost", + onClick: (e) => { + e.stopPropagation(); + openPickTable({ + kind: "재료", + title: `${tc("LogicMat_PickTitle")} · ${mat.이름}`, + seed: headWord(name), + alt: [headWord(mat.이름)], + isCurrent: (x) => String(x["키"]) === item?.키, + onPick: (x) => on.swap(r, mat.차례, String(x["키"])), + }); + }, + }), + ...(mat.고름 + ? [ + createButton({ + label: tc("LogicMat_Reset"), + variant: "ghost", + onClick: (e) => { + e.stopPropagation(); + on.reset(r, mat.차례); + }, + }), + ] + : []), + ], + }), + ], + }), + ] + : []; + return el("tr", { + className: `m02-labrow__row m02-labrow__logic-mat${mat.고름 ? " is-picked-mat" : ""}`, + attrs: { "data-labor-logic-mat": `${row.로직}|${mat.차례}` }, + children: [ + el("td", { className: "m02-labrow__code", text: item?.키 ?? "—" }), + el("td", { + className: "m02-labrow__name", + children: [ + el("strong", { text: name, attrs: { title: name } }), + el("small", { + className: "m02-labrow__key", + text: mat.고름 ? `${owner} · ${tc("LogicMat_Picked")}` : owner, + attrs: { title: owner }, + }), + ...(mat.까닭 ? [el("small", { className: "m02-labrow__empty", text: mat.까닭 })] : []), + ], + }), + el("td", { + className: "m02-labrow__spec", + text: item?.규격 ?? "", + attrs: { title: item?.규격 ?? "" }, + }), + el("td", { + className: "m02-labrow__qty", + text: wait ? "…" : num(mat.수량), + attrs: { "data-label": tc("Labor_QtyCol") }, + }), + el("td", { + className: "m02-labrow__unit", + text: mat.단위, + attrs: { "data-label": tc("Labor_Unit") }, + }), + ...PRICE_SLOTS.map(priceCell), + money, + ...tools, + ], + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 262c577cd..aca16a2af 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -121,7 +121,7 @@ function qtyCell( } /** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */ -function moneyCell( +export function moneyCell( base: number | null | undefined, applied: number | null | undefined, wait: boolean, @@ -516,6 +516,8 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement export interface MatTableData { mats: LaborMat[]; + /** 로직 자재 줄(103-1 · `Labor_LogicMats`) — 사용자 자재 앞 · 같은 칸 */ + lead?: HTMLElement[]; calc: LaborCalc | null; fail?: string | null; cells?: ReadonlyMap; @@ -609,7 +611,8 @@ export function matTable(data: MatTableData, on?: Omit): HT `${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`, ...(remove ? [tc("Col_Move")] : []), ]; - return data.mats.length - ? table(heads, trs, []) + const lead = data.lead ?? []; + return data.mats.length || lead.length + ? table(heads, [...lead, ...trs], []) : el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") }); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index abdc2f6bc..1af48b9f1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -110,6 +110,16 @@ const TEXT = { Labor_Spec: ["규격", "Spec"], Labor_Already: ["이미 담은 단가산출 로직 — {value}", "Already added — {value}"], Labor_AlreadyMat: ["이미 담은 자재 — {value}", "Already added — {value}"], + // 로직 자재(103-1) + Labor_InLogicMat: ["로직 자재에 있는 품목 — {value}", "Already a logic material — {value}"], + LogicMat_Swap: ["바꾸기", "Change"], + LogicMat_Reset: ["기본", "Default"], + LogicMat_PickTitle: ["로직 자재 바꾸기", "Change logic material"], + LogicMat_Inside: [ + "로직 단가 안 금액 — 합계에 다시 안 더함", + "Inside the logic price — not added again", + ], + LogicMat_Picked: ["바꾼 품목", "Changed item"], In_Title: ["넣을 값 — {value}", "Inputs — {value}"], In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"], In_Memo: ["메모", "Memo"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts index d5ec07a01..0f7f4e689 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts @@ -20,6 +20,7 @@ import { type LaborDoc, } from "./M02_MasterTemplete_Labor_Api"; import { lineId, settle } from "./M02_MasterTemplete_Labor_Calc"; +import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats"; import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import "./M02_MasterTemplete_Labor.css"; @@ -92,7 +93,10 @@ export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewH onRow ? { open: logicOpen } : undefined, ), el("h4", { className: "m02-labview__title", text: tc("Labor_Mats") }), - matTable({ mats: d.자재, calc, fail, cells }, onRow ? { open: matOpen } : undefined), + matTable( + { mats: d.자재, calc, fail, cells, lead: logicMatRows(d.담은로직, calc, !calc && !fail) }, + onRow ? { open: matOpen } : undefined, + ), ); }; diff --git a/resources/tester/helper_m02_labor_logic_mats.cjs b/resources/tester/helper_m02_labor_logic_mats.cjs new file mode 100644 index 000000000..3219be1fe --- /dev/null +++ b/resources/tester/helper_m02_labor_logic_mats.cjs @@ -0,0 +1,15 @@ +/* M02 일위대가 로직 자재(`M02_Labor_LogicMats_Model.ts` `releaseTied`) 헬퍼 — + * 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {row, before, mats} · stdout = 고친 row JSON. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_Labor_LogicMats_Model.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +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.releaseTied(input.row, input.before, input.mats))); diff --git a/resources/tester/test_m02_labor_logic_mats.py b/resources/tester/test_m02_labor_logic_mats.py new file mode 100644 index 000000000..e2c39830e --- /dev/null +++ b/resources/tester/test_m02_labor_logic_mats.py @@ -0,0 +1,162 @@ +"""일위대가 로직 자재(PLAN 103-1) — 담은 로직 맨 위 층 재료 줄 · 고름 · 합 이중 계산 없음. + +- 로직 줄 계산값 `재료` = 호표 재료 줄(차례 · 품목 · 단가 · 금액 · 걸림) · 재료 없는 로직은 빈 목록 +- 고름 `{호표 차례: MT키}` → 그 줄 품목 · 단가 · 로직 재료비가 바뀜(엔진 `choosing`) · + 고름 없으면 지금 값 +- 합 · 알약에 로직 자재 금액을 다시 안 더함 · 저장 뒤 고름 남음 · 틀린 고름 400 +- 사용자 자재와 같은 품목 = [저장] 400 · 입력에 걸린 고름은 입력이 바뀌면 풂(화면 짝 · Node) +- 프로젝트 사본 씨앗 = 문서 고름 +""" + +import json +import os +import subprocess + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Item_Snapshot as snap +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_m02_structure import write_sample_table + +HERE = os.path.dirname(os.path.abspath(__file__)) +NET = "GC000337" # 낙석방지망 — 재료 줄 3 · 4 · 5(철망 MT006697 · 결속선 MT000194 · 에폭시) +BORING = "GC000431" # 보링 천공 — 재료 줄 4~8 조건에 {규격} · 9 = MT000008(입력 안 걸림) +BARE = "GC000994" # 재료 줄 없는 돈 로직 +WIRE = "MT000194" +NET_ITEM = "MT006697" + + +def _whole(): + return m01.loaded()[1] + + +def _doc(**extra): + return {"열": "rv", "담은로직": [], "자재": [], **extra} + + +def test_로직_자재는_맨_위_층_재료_줄_재료_없는_로직은_빈_목록(): + got = labor.run_line(_whole(), NET, {}) + assert [(m["차례"], m["품목"]["키"]) for m in got["재료"]] == [ + (3, NET_ITEM), + (4, WIRE), + (5, "MT007705"), + ] + assert all(m["고름"] is False and m["걸림"] == [] for m in got["재료"]) + assert labor.run_line(_whole(), BARE, {})["재료"] == [] + + +def test_조건에_든_입력은_걸림(): + mats = {m["차례"]: m for m in labor.run_line(_whole(), BORING, {})["재료"]} + assert all(mats[n]["걸림"] == ["규격"] for n in range(4, 9)) + assert mats[9]["걸림"] == [] + + +def test_고름이_단가와_로직_재료비를_바꿈_고름_없으면_지금_값(): + plain = labor.run_line(_whole(), NET, {}) + again = labor.run_line(_whole(), NET, {}, None, None) + assert again["기준"] == plain["기준"] # 고름 없음 = 지금 길 그대로(회귀) + picked = labor.run_line(_whole(), NET, {}, None, {"3": WIRE}) + row = picked["재료"][0] + assert (row["품목"]["키"], row["고름"]) == (WIRE, True) + assert row["단가"] == picked["재료"][1]["단가"] != plain["재료"][0]["단가"] + gap = (plain["재료"][0]["단가"] - row["단가"]) * row["수량"] + assert plain["기준"]["재료비"] - picked["기준"]["재료비"] == pytest.approx(gap) + assert picked["기준"]["노무비"] == plain["기준"]["노무비"] + + +def test_합_알약에_로직_자재_금액_다시_안_더함(): + doc = labor.clean(_doc(담은로직=[{"로직": NET, "수량": {"손": 2}}])) + got = labor.calc(doc, {}) + line = got["줄"][0] + assert line["재료"] and got["자재"] == [] + assert got["기준합"]["계"] == pytest.approx(line["기준"]["계"]) + assert got["반영합"]["계"] == pytest.approx(line["기준"]["계"] * 2) + assert not [p for p in labor.pills(doc) if p["키"].startswith(labor.MATERIAL_HEAD)] + + +def test_고름_정리_틀리면_400(): + got = labor.clean(_doc(담은로직=[{"로직": NET, "고름": {3: WIRE}}, {"로직": BARE, "고름": {}}])) + assert got["담은로직"][0]["고름"] == {"3": WIRE} and "고름" not in got["담은로직"][1] + for bad in ({"0": WIRE}, {"3": "MT999999"}, {"3": "LB000001"}, {"가": WIRE}, ["3"]): + with pytest.raises(store.StoreError) as e: + labor.clean(_doc(담은로직=[{"로직": NET, "고름": bad}])) + assert e.value.status == 400 + + +def test_사용자_자재가_로직_자재와_같으면_저장_400(): + with pytest.raises(store.StoreError) as e: + labor.server(_doc(담은로직=[{"로직": NET}], 자재=[{"코드": WIRE}])) + assert e.value.status == 400 and WIRE in str(e.value.detail) + # 고른 품목도 같음 — 결속선 줄을 다른 품목으로 바꾸면 사용자 자재로 담을 수 있음 + with pytest.raises(store.StoreError): + labor.server( + _doc(담은로직=[{"로직": NET, "고름": {"3": "MT000008"}}], 자재=[{"코드": "MT000008"}]) + ) + ok = labor.server( + _doc(담은로직=[{"로직": NET, "고름": {"4": NET_ITEM}}], 자재=[{"코드": WIRE}]) + ) + assert ok["자재"][0]["코드"] == WIRE + + +@pytest.fixture() +def system(tmp_path, monkeypatch): + write_sample_table(tmp_path) + monkeypatch.setattr(store, "FOLDER", tmp_path) + store.create_structure("rv") + return tmp_path + + +def test_저장_뒤_고름_남고_계산값에_반영(system): + have = store.read("labor", "rv") + sent = {**have["문서"], "담은로직": [{"로직": NET, "고름": {"3": WIRE}, "수량": None}]} + store.write("labor", "rv", have["판"], sent) + doc = store.read("labor", "rv")["문서"] + assert doc["담은로직"][0]["고름"] == {"3": WIRE} + assert doc["계산값"]["줄"][0]["재료"][0]["품목"]["키"] == WIRE + + +def test_단가승격도_고름을_따름_없으면_지금_값(): + expand = refs.Expand(_whole()) + plain = expand._promote({"담은로직": [{"로직": NET}], "배": 1}) + picked = expand._promote({"담은로직": [{"로직": NET, "고름": {"3": WIRE}}], "배": 1}) + assert plain["기준"] == labor.run_line(_whole(), NET, {})["기준"] + assert picked["기준"]["재료비"] < plain["기준"]["재료비"] + + +def test_사본_씨앗은_문서_고름(): + labor_file = { + "줄": { + "담은로직": [ + {"로직": NET, "고름": {"3": WIRE}}, + {"로직": BARE}, + {"참조": {"층": "system", "주인": None, "키": "SP-000001"}, "로직": ""}, + ] + } + } + assert snap.seed_picks(labor_file) == {NET: {"3": WIRE}} + assert snap.seed_picks(None) == {} + + +def _release(row: dict, before: dict, mats: list) -> dict: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_labor_logic_mats.cjs")], + input=json.dumps({"row": row, "before": before, "mats": mats}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(proc.stdout) + + +def test_입력이_바뀌면_걸린_고름만_풂_화면_짝(): + mats = labor._json(labor.run_line(_whole(), BORING, {})["재료"]) + picks = {"4": "MT007895", "9": WIRE} + row = {"로직": BORING, "입력값": {"규격": "NX"}, "고름": picks} + assert _release(row, {"규격": "NX"}, mats)["고름"] == picks # 그대로 + assert _release(row, {"규격": "BX"}, mats)["고름"] == {"9": WIRE} # 규격에 걸린 줄만 + row = {**row, "고름": {"4": "MT007895"}} + assert "고름" not in _release(row, {}, mats) # 다 풀리면 칸째 지움