From 875ceec7f7fc84f38c6564d23212e5912ec57258 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:05:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EC=BC=9C=EC=8C=93=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=EC=9C=A8=20=E2=80=94=20=EA=B3=A8=EC=8C=93?= =?UTF-8?q?=EA=B8=B0=20=EA=B8=B0=EC=A4=80=20=EB=A1=9C=EC=A7=81=20=EC=84=B8?= =?UTF-8?q?=EB=B6=80=ED=95=AD=EB=AA=A9=20=EC=88=98=EB=9F=89=20=C3=97=20?= =?UTF-8?q?=EC=9C=A8=20=C2=B7=20=EB=AC=B8=EC=84=9C=20=C2=B7=20=EC=82=AC?= =?UTF-8?q?=EB=B3=B8=20=C2=B7=20=EC=84=9C=EA=B3=A0=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=C2=B7=20=EC=98=B5=EC=85=98=20=EC=B9=B8=20?= =?UTF-8?q?=C2=B7=20=EC=A4=84=20=EC=95=84=EB=9E=98=20=EA=B8=80(85-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- M02_MasterTemplete/M02_Item_Prices.py | 9 +- M02_MasterTemplete/M02_Labor_Doc.py | 51 ++++++- M02_MasterTemplete/M02_Labor_Inputs.py | 15 +- M02_MasterTemplete/M02_Labor_Ref.py | 8 +- .../M02_MasterTemplete_Api_Fetch.ts | 2 + .../M02_MasterTemplete_Labor_Api.ts | 3 + .../M02_MasterTemplete_Labor_Detail.ts | 9 +- .../M02_MasterTemplete_Labor_Input.ts | 50 ++++++- .../M02_MasterTemplete_Labor_RefEdit.ts | 3 + .../M02_MasterTemplete_Labor_Rows.ts | 4 +- .../M02_MasterTemplete_Labor_Text.ts | 6 + resources/tester/test_m02_labor_layer.py | 132 ++++++++++++++++++ 12 files changed, 277 insertions(+), 15 deletions(-) create mode 100644 resources/tester/test_m02_labor_layer.py diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index 56ad2f407..6e4c49b2f 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -280,8 +280,15 @@ def calc( snapshot = load_prices(prices) gaps = missing_inputs(snapshot, labor, values) blocked = {gap["로직"] for gap in gaps} + from M02_MasterTemplete import M02_Labor_Doc as labor_doc + sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0)) lines, stuck = [], [] + rates = { # 켜쌓기율(85-2) — 문서 계산과 같은 `layered` + one.get("로직"): one.get("켜쌓기율") + for one in (labor.get("줄") or {}).get("담은로직") or [] + if isinstance(one, dict) + } for at, key in enumerate(_logic_keys(labor), start=1): head: dict[str, Any] = {"차례": at, "로직": key} if key in blocked: @@ -292,7 +299,7 @@ def calc( given = {name: one.get("값") for name, one in (values.get(key) or {}).items()} try: with mf.mp.choosing((picks or {}).get(key)): - got = mf.run(snapshot, key, given) + got = labor_doc.layered(mf.run(snapshot, key, given), rates.get(key)) except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as error: lines.append({**head, "ok": False, "까닭": str(error) or type(error).__name__}) stuck.append(key) diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index d89f26d66..9640e799d 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -1,6 +1,6 @@ """M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장). -문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량} +문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}], 자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠). 수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}` @@ -8,6 +8,8 @@ 금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) · 기준단가가 된 칸 이름(`기준칸`)도 실음(43-3). +켜쌓기율(85-2) = 표 주문 「골쌓기 기준」 로직만 · % · 없음 = 100 · 그 로직 세부항목 줄 전부 수량 × 율 +(`layered` · `run_line` 한 곳 → 문서 · 프로젝트 사본 · 서고 같은 함수). 참조 줄(단가승격 · 사전조합 · 50-3) = 기준이 가리킨 문서 1 단위 값(펼침 `M02_Labor_Ref.Expand`) · 분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만. 짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` — @@ -22,6 +24,7 @@ from typing import Any 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_MasterTemplete_Store as store @@ -105,6 +108,7 @@ def clean(doc: Any) -> dict[str, Any]: if not isinstance(given, dict): raise _bad(f"{where} · 입력값이 묶음 아님") memo, alias = one.get("메모"), _alias(one.get("별칭"), where) + rate = _rate(one.get("켜쌓기율"), code, where) lines.append( { "로직": code, @@ -112,6 +116,7 @@ def clean(doc: Any) -> dict[str, Any]: "메모": str(memo) if memo else None, "입력값": {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 {}), } ) mats, codes = [], set() @@ -157,6 +162,42 @@ def _alias(alias: Any, where: str) -> str | None: return alias or None +def _rate(value: Any, code: str, where: str) -> int | float | None: + """켜쌓기율(85-2) — 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖 로직은 None(안 둠).""" + if value is None or value == "": + return None + rate = _number(value) + if rate is None or not Decimal(0) < rate <= Decimal(200): + raise _bad(f"{where} · 켜쌓기율은 0 초과 200 이하 %") + if rate != rate.quantize(Decimal("0.1")): + raise _bad(f"{where} · 켜쌓기율은 소수 한 자리까지") + if rate == 100 or code not in labor_inputs.layered(): + return None + return int(rate) if rate == rate.to_integral_value() else float(rate) + + +def layered(got: dict[str, Any], rate: Any) -> dict[str, Any]: + """엔진 답 × 켜쌓기율(85-2) — 세부항목 줄 전부 수량 · 금액 · 비목 · 비목 합 · 계 · 단가는 그대로.""" + if rate in (None, 100) or "결과" in got: + return got + times = Decimal(str(rate)) / 100 + + def mul(value: Any) -> Any: + return value * times if isinstance(value, Decimal) else value + + lines = [ + { + **x, + "수량": mul(x.get("수량")), + "금액": mul(x.get("금액")), + "비목": {k: mul(v) for k, v in (x.get("비목") or {}).items()}, + } + for x in got.get("줄") or [] + ] + sums = {k: mul(got[k]) for k in COSTS} + return {**got, "줄": lines, **sums, "계": sum(sums.values())} + + def from_combo(key: str) -> dict[str, Any]: """옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 — 담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키). @@ -331,7 +372,7 @@ 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("입력값"))}) + lines.append({**line, **run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"))}) mats = [ { "코드": one["코드"], @@ -344,13 +385,13 @@ 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) -> dict[str, Any]: - """로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값) — `{기준, 까닭}` · +def run_line(whole: Any, code: str, given: Any, rate: Any = None) -> dict[str, Any]: + """로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율) — `{기준, 까닭}` · 돈 아닌 로직은 `{결과, 기준: 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 = mf.run(whole, code, {**auto, **m01._dec(given or {})}) + 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: diff --git a/M02_MasterTemplete/M02_Labor_Inputs.py b/M02_MasterTemplete/M02_Labor_Inputs.py index fd18f0489..c782955ac 100644 --- a/M02_MasterTemplete/M02_Labor_Inputs.py +++ b/M02_MasterTemplete/M02_Labor_Inputs.py @@ -15,6 +15,7 @@ from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store _SPEC = ("이름", "설명", "단위", "고르기", "범위") +_LAYERED = "골쌓기 기준" # 85-2 — 표 주문에 이 글이 있으면 켜쌓기율 대상 로직 def _num(value: Any) -> Any: @@ -40,6 +41,16 @@ def _logics(codes: list[str]) -> tuple[list[dict], list[str]]: return found, missing +def layered() -> set[str]: + """켜쌓기율(85-2) 대상 로직 키 — 표 `주` 에 「골쌓기 기준」 · 그 표 `용도.로직키`(자동 판정).""" + out: set[str] = set() + for data in m01.loaded()[0].values(): + for row in data.get("표") or []: + if any(_LAYERED in str(note) for note in row.get("주") or []): + out.update((row.get("용도") or {}).get("로직키") or []) + return out + + def _labels(whole: Any, spec: dict) -> dict[str, str]: """고르기 원문번호 → 보이는 글(85-1) — 기계 「이름 규격」 · 직종(입력 이름에 「직종」) 「이름」 · 모르는 번호는 뺌.""" table = "LB" if "직종" in str(spec.get("이름") or "") else "EQ" @@ -53,8 +64,9 @@ def _labels(whole: Any, spec: dict) -> dict[str, str]: def _listed(logics: list[dict]) -> list[dict[str, Any]]: - """로직 줄마다 `{로직, 이름, 결과단위, 출처, 입력: [{이름, 설명, 단위, 고르기?, 고르기이름?, 범위?, 자동값}]}`.""" + """로직 줄마다 `{로직, 이름, 결과단위, 출처, 켜쌓기, 입력: [{이름, 설명, 단위, 고르기?, 고르기이름?, 범위?, 자동값}]}`.""" whole = m01.loaded()[1] + layers = layered() out = [] for logic in logics: auto = m01.mau.values(whole, logic) @@ -73,6 +85,7 @@ def _listed(logics: list[dict]) -> list[dict[str, Any]]: "이름": logic.get("이름"), "결과단위": logic.get("결과단위"), "출처": logic.get("출처"), + "켜쌓기": logic.get("키") in layers, "입력": specs, } ) diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index a1c43145e..7ccecac25 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -152,7 +152,7 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]: 이름=name or pill.get("이름") or line["로직"], 단위=unit or pill.get("단위") or None, 배=pill.get("배") or 1, # ponytail: 저장 때 적음 · 마스터 결과단위가 바뀌면 다시 저장 - 담은로직=[{"로직": line["로직"], "입력값": line["입력값"]}], + 담은로직=[{k: line[k] for k in ("로직", "입력값", "켜쌓기율") if k in line}], **origin(line["로직"]), ) else: @@ -200,6 +200,7 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: head = {"참조": one["참조"]} if "참조" in one else {"로직": one["로직"]} if "로직" in one: head["입력값"] = one["입력값"] + head.update({k: one[k] for k in ("켜쌓기율",) if k in one}) lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]}) at_ = here() me = {"층": at_[0], "주인": at_[1], "키": key} @@ -368,7 +369,8 @@ class Expand: from M02_MasterTemplete import M02_Labor_Doc as labor line = (doc.get("담은로직") or [{}])[0] - got = labor.run_line(self.whole, str(line.get("로직") or ""), line.get("입력값")) + code = str(line.get("로직") or "") + got = labor.run_line(self.whole, code, line.get("입력값"), line.get("켜쌓기율")) times = labor._number(doc.get("배")) or Decimal(1) base = got.get("기준") why = got.get("까닭") or (None if base else "돈 아닌 로직") @@ -395,7 +397,7 @@ class Expand: from M02_MasterTemplete import M02_Labor_Doc as labor pill = pill or {} - got = labor.run_line(self.whole, one["로직"], one.get("입력값")) + got = labor.run_line(self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율")) times = labor._number(pill.get("배")) or Decimal(1) base = got.get("기준") return { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index c6e60c267..3016f98cc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -331,6 +331,8 @@ export interface ItemLogic { 결과단위?: string; /** 품셈 출처 글(47-4 · 원문 패널) */ 출처?: string | null; + /** 켜쌓기율 대상(85-2 · 표 주문 「골쌓기 기준」) */ + 켜쌓기?: boolean; 입력: ItemInput[]; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index c896cb70a..67b1d3931 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -129,6 +129,8 @@ export interface LaborRow { 메모: string | null; 입력값: Record; 수량: LaborQty; + /** 켜쌓기 적용율 %(85-2 · 대상 로직만 · 없음 = 100) */ + 켜쌓기율?: number | null; } export interface LaborMat { 코드: string; @@ -164,6 +166,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc { 입력값: r.입력값 ?? {}, 수량: r.수량 ?? null, ...(r.참조 ? { 참조: r.참조 } : {}), + ...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}), })), 자재: (d.자재 ?? []).map((m) => ({ 코드: m.코드, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 236f211c6..1b4bb5333 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -347,13 +347,18 @@ export function mountLaborDetail( logic, values: row.입력값, memo: row.메모, + rate: row.켜쌓기율 ?? null, readOnly: !edit, done: (next) => { const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; - const same = JSON.stringify(values) === JSON.stringify(row.입력값); + const same = + JSON.stringify(values) === JSON.stringify(row.입력값) && + next.켜쌓기율 === (row.켜쌓기율 ?? null); row.입력값 = values; row.메모 = next.메모; - changed(same); // 입력값이 그대로면(메모만) 서버 안 부름 + if (next.켜쌓기율 === null) delete row.켜쌓기율; + else row.켜쌓기율 = next.켜쌓기율; + changed(same); // 입력값 · 켜쌓기율이 그대로면(메모만) 서버 안 부름 }, }); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts index f60fe5c0d..bdeabdc1a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts @@ -39,6 +39,17 @@ function footer( export interface LaborInputResult { 입력값: LaborValues; 메모: string | null; + /** 켜쌓기 적용율 %(85-2 · 대상 로직만 · 빈 칸 · 100 = null) */ + 켜쌓기율: number | null; +} + +/** 켜쌓기율 칸 글 → 값 — 빈 글 · 100 = null · 0 초과 200 이하 · 소수 한 자리 밖 = NaN */ +export function layerRate(raw: string): number | null { + if (raw.trim() === "") return null; + const v = Number(raw); + const ok = + Number.isFinite(v) && v > 0 && v <= 200 && Math.abs(v * 10 - Math.round(v * 10)) < 1e-9; + return !ok ? NaN : v === 100 ? null : v; } export interface LaborInputOptions { @@ -48,6 +59,8 @@ export interface LaborInputOptions { logic: ItemLogic | null; values: LaborValues; memo: string | null; + /** 켜쌓기율(85-2) — 로직이 대상일 때만 칸 */ + rate?: number | null; readOnly: boolean; done: (next: LaborInputResult) => void; /** 머리 한 줄 알림(51-14 · 서고 원본 고침) */ @@ -100,9 +113,27 @@ export function openLaborInput(opt: LaborInputOptions): void { }) as HTMLInputElement; memo.value = opt.memo ?? ""; memo.readOnly = opt.readOnly; + const rate = opt.logic?.켜쌓기 + ? (el("input", { + className: "ui-input m02-inputval__control", + attrs: { + type: "number", + min: "0.1", + max: "200", + step: "0.1", + placeholder: "100", + "data-labor-layer": "", + }, + }) as HTMLInputElement) + : null; + if (rate) { + rate.value = opt.rate == null ? "" : String(opt.rate); + rate.readOnly = opt.readOnly; + } const ok = (): void => { - if (bad.size) return showToast(tc("In_Bad"), "error"); - opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null }); + const layer = rate ? layerRate(rate.value) : null; + if (bad.size || Number.isNaN(layer)) return showToast(tc("In_Bad"), "error"); + opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null, 켜쌓기율: layer }); close(); }; body.append( @@ -129,6 +160,21 @@ export function openLaborInput(opt: LaborInputOptions): void { ...(rows.length ? rows : [el("p", { className: "m02-labin__muted", text: tc("In_None") })]), + ...(rate + ? [ + el("div", { + className: "m02-inputval__row", + children: [ + el("span", { + className: "m02-inputval__name", + text: tc("In_Layer"), + attrs: { title: tc("In_LayerHint") }, + }), + rate, + ], + }), + ] + : []), el("div", { className: "m02-inputval__row", children: [ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts index 4ecb9e78f..f94d73223 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts @@ -56,12 +56,15 @@ export async function openRefEdit(opt: RefEditOptions): Promise { logic, values: row.입력값, memo: row.메모, + rate: row.켜쌓기율 ?? null, readOnly: opt.readOnly, notice, okLabel: tc("Save"), done: (next) => { row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; row.메모 = next.메모; + if (next.켜쌓기율 === null) delete row.켜쌓기율; + else row.켜쌓기율 = next.켜쌓기율; void save(doc) .catch((error) => fail(error, "Lib_SaveFailed")) .finally(opt.closed); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index bcdd9122b..4a9d61940 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -307,7 +307,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement // 참조 줄 — 키 · 이름 · 단위 = 가리킨 문서 것(calc 답) · 넣을 값 없음(승격 때 고정) const unit = row.참조 ? (line?.분모 ?? "") : (logic?.결과단위 ?? ""); const values = valueLine(logic?.입력 ?? [], row.입력값); - const note = values.text; + const layer = + row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : ""; + const note = [values.text, layer].filter(Boolean).join(" · "); const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null); const small = ( label: string, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 64eebefb1..28d5b4286 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -112,6 +112,12 @@ const TEXT = { In_Title: ["넣을 값 — {value}", "Inputs — {value}"], In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"], In_Memo: ["메모", "Memo"], + In_Layer: ["켜쌓기 적용율 (%)", "Layered-course rate (%)"], + In_LayerHint: [ + "0 초과 200 이하 · 소수 한 자리 · 빈 칸 = 100", + "0 < rate ≤ 200 · 1 decimal · empty = 100", + ], + Labor_Layer: ["켜쌓기 {n}%", "Layered {n}%"], In_QtyCell: ["면적산출 칸", "Area sheet cell"], In_QtyEmpty: ["빈 칸", "empty cell"], In_QtyHand: ["손 입력", "Manual"], diff --git a/resources/tester/test_m02_labor_layer.py b/resources/tester/test_m02_labor_layer.py new file mode 100644 index 000000000..dbe9a852e --- /dev/null +++ b/resources/tester/test_m02_labor_layer.py @@ -0,0 +1,132 @@ +"""켜쌓기율(85-2) — 표 주문 「골쌓기 기준」 로직만 · 세부항목 줄 전부 수량 × 율 · 문서 · 사본 같은 함수. + +못박는 것 + - 대상 = 표 주문 자동 판정(지금 GC000260 · GC000261) · 입력 목록에 「켜쌓기」 표시. + - GC000260 90% = 100% × 0.9(비목 · 계 · 줄 수량 · 금액) · 단가는 그대로. + - clean = 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖은 안 둠. + - 프로젝트 사본 계산(`M02_Item_Prices.calc`)도 같은 율 · 같은 값. + - 서고(단가승격)도 율을 담고 같은 값. +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Item_Prices as prices +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +CODE = "GC000260" + + +def _d(value) -> Decimal: + return Decimal(str(value)) + + +def test_대상은_골쌓기_기준_주문_로직() -> None: + assert labor_inputs.layered() == {"GC000260", "GC000261"} + got = labor_inputs.logic_inputs([CODE, "GF000219"])["로직"] + assert [x["켜쌓기"] for x in got] == [True, False] + + +def test_90퍼센트는_100퍼센트_곱하기_0점9() -> None: + whole = m01.loaded()[1] + full = labor.run_line(whole, CODE, {}) + part = labor.run_line(whole, CODE, {}, 90) + for k in (*labor.COSTS, "계"): + assert part["기준"][k] == pytest.approx(full["기준"][k] * Decimal("0.9")) + for a, b in zip(full["줄"], part["줄"]): + assert b["단가"] == a["단가"] + assert b["수량"] == pytest.approx(a["수량"] * Decimal("0.9")) + assert b["금액"] == pytest.approx(a["금액"] * Decimal("0.9")) + for k, v in a["비목"].items(): + assert b["비목"][k] == pytest.approx(v * Decimal("0.9")) + assert labor.run_line(whole, CODE, {}, 100) == full + + +@pytest.mark.parametrize("bad", [0, -5, 200.1, 90.55, "abc"]) +def test_율_범위_밖은_400(bad) -> None: + with pytest.raises(store.StoreError): + labor.clean({"담은로직": [{"로직": CODE, "켜쌓기율": bad}]}) + + +def test_율_정리() -> None: + def rate(code: str, value): + return labor.clean({"담은로직": [{"로직": code, "켜쌓기율": value}]})["담은로직"][0].get( + "켜쌓기율" + ) + + assert rate(CODE, "90") == 90 + assert rate(CODE, 87.5) == 87.5 + assert rate(CODE, 200) == 200 + assert rate(CODE, 100) is None # 100 = 없음 + assert rate(CODE, None) is None + assert rate("GF000219", 90) is None # 대상 밖은 안 둠 + + +def test_문서_계산과_프로젝트_사본_같음() -> None: + doc = labor.clean({"단위": "㎡", "담은로직": [{"로직": CODE, "켜쌓기율": 90}]}) + whole = m01.loaded()[1] + auto = m01.mau.values(whole, whole.logic(CODE)) + values = {CODE: {k: {"값": labor._json(v), "확인": True} for k, v in auto.items()}} + copy = {"줄": doc, "알약": labor.pills(doc)} + got = prices.calc(copy, prices.to_json(prices.collect([CODE])), values, {}) + origin = labor.calc(doc, {}) + (line,) = got["줄"] + assert line["계"] == pytest.approx(origin["줄"][0]["기준"]["계"]) + full = labor.calc({**doc, "담은로직": [{**doc["담은로직"][0], "켜쌓기율": None}]}, {}) + assert _d(line["계"]) == pytest.approx(_d(full["줄"][0]["기준"]["계"]) * Decimal("0.9")) + + +def test_서고_단가승격도_율() -> None: + doc = refs.clean_doc( + "promote", {"키": "SP-900001", "담은로직": [{"로직": CODE, "켜쌓기율": 90}]} + ) + assert doc["담은로직"][0]["켜쌓기율"] == 90 + whole = m01.loaded()[1] + expand = refs.Expand(whole, lambda ref: doc) + got = expand({"층": "system", "주인": None, "키": "SP-900001"}) + full = labor.run_line(whole, CODE, {}) + times = _d(doc["배"]) + assert got["기준"]["계"] == pytest.approx(full["기준"]["계"] * Decimal("0.9") / times) + + +RATE_SCRIPT = r""" +const fs = require("fs"); +const path = require("path"); +const root = process.argv[1]; +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : () => undefined) }); +const f = path.join(root, "M02_MasterTemplete", "M02_MasterTemplete_Labor_Input.ts"); +const js = ts.transpileModule(fs.readFileSync(f, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => stub); +const m = box.exports; +console.log(JSON.stringify(["", "90", "87.5", "100", "200", "0", "200.1", "90.55", "x"].map( + (v) => { const r = m.layerRate(v); return Number.isNaN(r) ? "bad" : r; }))); +""" + + +def test_화면_율_칸_검사() -> None: + """옵션 모달 켜쌓기율 칸 — 서버 `_rate` 와 같은 범위(빈 칸 · 100 = 없음).""" + import json + import shutil + import subprocess + from pathlib import Path + + node = shutil.which("node") + if not node: + pytest.skip("node 없음") + root = Path(__file__).resolve().parents[2] + out = subprocess.run( + [node, "-e", RATE_SCRIPT, str(root)], capture_output=True, text=True, encoding="utf-8" + ) + assert out.returncode == 0, out.stderr + assert json.loads(out.stdout) == [None, 90, 87.5, None, 200, "bad", "bad", "bad", "bad"]