diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py index 97e3c01cf..6051bfd4a 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.py +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -10,7 +10,8 @@ 중기 줄 = 중기목록표(노무 · 재료 · 경비) 대가 줄 = 부른 블록 합계(노무 · 재료 · 경비) · 산근 줄 = 145-1 산근 단가(`sangeun` {키: 값}) 비고 = 대가 줄 「대가n」 · 산근 줄 「산근 n호표」(산근 목록 번호) - % 줄 = 밑수(그 블록 노무 줄 금액 합) × % / 100 + % 줄 = 밑수(그 블록 노무 줄 금액 합) × % / 100 · 율 더한줄(778-4) 밑수 = `bundle:` 묶음 줄 합 · + `rows:` 블록 몇째 줄 합(`_base`) 고정 줄(705-1) = 줄 단가(`own`) · 이음(`link`) 있으면 그 비목만 단가 책 값(노임 = 노무 · 자재 = 재료(관급 · 별도 0원) · 기계 = 중기 세 비목) · 책에 없으면 줄 단가 그대로 · 빈칸 안 됨 끝수 = 엑셀 자리 그대로 — 줄 금액 = ROUNDDOWN(단가 × 수량, 1) · @@ -90,12 +91,27 @@ def _fixed(line: dict[str, Any], books: dict[str, Any]) -> dict[str, float | Non return out -def _base(spec: str, lines: list[dict[str, Any]]) -> float | None: +def _base(spec: str, lines: list[dict[str, Any]], src: list) -> float | None: """% 줄 밑수 — `part:비목` 그 비목 줄 금액 합 · `lines:labor` 노무 줄 금액 합 · - `line:이름` 그 줄.""" + `line:이름` 그 줄 · `bundle:labor|material|machine` 그 종류 줄(+ 그 이음 고정 줄) 합 · + `rows:0,2` 블록 그 차례 줄(`src` cid) 합(778-4 · 짝 `.ts` `pctBase`).""" kind, _, name = spec.partition(":") if kind == "part": return total([ln["amounts"][name] for ln in lines]) + if kind == "rows": + cids = { + src[int(i)].get("cid") for i in name.split(",") if i.isdigit() and int(i) < len(src) + } + return total([total(list(ln["amounts"].values())) for ln in lines if ln.get("cid") in cids]) + if kind == "bundle": + return total( + [ + total(list(ln["amounts"].values())) + for ln in lines + if ln["kind"] == name + or (ln["kind"] == "fixed" and (ln.get("link") or {}).get(name)) + ] + ) if kind == "line": return next( (total(list(ln["amounts"].values())) for ln in lines if ln.get("name") == name), None @@ -145,7 +161,7 @@ def _block( for line in block["lines"]: if line["kind"] != "pct": continue - base = _base(line.get("base") or "lines:labor", lines) + base = _base(line.get("base") or "lines:labor", lines, block["lines"]) mine = own.get(line.get("cid")) or {} pct = num(mine["qty"]) if "qty" in mine else num(line.get("qty")) extra = {"base": {"qty": num(line.get("qty"))}, "covered": ["qty"]} if "qty" in mine else {} diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts index 54f5d4855..15b87330b 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.ts +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -88,12 +88,28 @@ function fixedPrices(line: Row, books: Row): Parts { return out; } -/** % 줄 밑수 — part:비목 그 비목 줄 금액 합 · lines:labor 노무 줄 금액 합 · line:이름 그 줄 */ -function pctBase(spec: string, lines: Row[]): Num { +/** % 줄 밑수 — part:비목 그 비목 줄 금액 합 · lines:labor 노무 줄 금액 합 · line:이름 그 줄 · + * bundle:labor|material|machine 그 종류 줄(+ 그 이음 고정 줄) 합 · rows:0,2 블록 그 차례 줄(src cid) 합 + * (778-4 · 짝 `.py` `_base`) */ +function pctBase(spec: string, lines: Row[], src: Row[]): Num { const at = spec.indexOf(":"); const kind = spec.slice(0, at); const name = spec.slice(at + 1); + const whole = (ln: Row): Num => total(PARTS.map((p) => ln.amounts[p])); if (kind === "part") return total(lines.map((ln) => ln.amounts[name])); + if (kind === "rows") { + const cids = new Set( + name + .split(",") + .filter((i) => /^\d+$/.test(i) && Number(i) < src.length) + .map((i) => src[Number(i)].cid), + ); + return total(lines.filter((ln) => cids.has(ln.cid)).map(whole)); + } + if (kind === "bundle") + return total( + lines.filter((ln) => ln.kind === name || (ln.kind === "fixed" && ln.link?.[name])).map(whole), + ); if (kind === "line") { const hit = lines.find((ln) => ln.name === name); return hit ? total(PARTS.map((p) => hit.amounts[p])) : null; @@ -133,7 +149,7 @@ function block(b: Row, books: Row, sums: Record, gaps: Gap[], own: } for (const line of b.lines) { if (line.kind !== "pct") continue; - const base = pctBase(line.base || "lines:labor", lines); + const base = pctBase(line.base || "lines:labor", lines, b.lines); const mine: Row = own[line.cid] || {}; const pct = "qty" in mine ? num(mine.qty) : num(line.qty); const extra = "qty" in mine ? { base: { qty: num(line.qty) }, covered: ["qty"] } : {}; diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index cc0764578..c65a61549 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -54,6 +54,7 @@ mf = item_prices.mf #: 엔진 비목 → 블록 비목 PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} +GROUP = {"인력": "labor", "재료": "material", "장비": "machine"} # 율 기준 묶음(778-4) #: % 덧줄 밑수 — `노무비 * …` · `줄.'이름' * …` · `(석공 * LB… + …) * …`(인력 줄) BASE_PART = re.compile(r"^\s*(노무비|재료비|경비)\s*\*") BASE_LINE = re.compile(r"^\s*줄\.'([^']+)'\s*\*") @@ -195,6 +196,7 @@ class Builder: except (sd.DocError, mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): return None one = {"로직": key, "입력값": got["입력값"], "켜쌓기율": got["켜쌓기율"]} + where = {c: n for n, c in enumerate(got["정체"])} # 율 기준 줄 목록(778-4) → 블록 차례 block = self._poomsem(calc._Over(whole, key, row), {**one, "고름": got["고름"]}) if block is None: return None @@ -208,7 +210,7 @@ class Builder: if isinstance(item.get("참조"), dict) and depth < refs.DEPTH: line = {**self.line_of(whole, item, qty, 1, head["name"], copies, depth), **head} elif isinstance(item.get("고정"), dict): - line = {**fixed_line(item["고정"], qty), **head} + line = {**fixed_line(item["고정"], qty, where), **head} elif qty and ("단가" in item or item.get("값묶음")): own = {PART[k]: _f(v) / qty for k, v in (x.get("비목") or {}).items() if k in PART} line = {**head, "kind": "fixed", "qty": qty, "own": own, "link": {}} @@ -508,13 +510,28 @@ def _Loose(whole: Any, read: Any) -> Any: return Loose(whole, read) -def fixed_line(fx: dict, qty: Any) -> dict[str, Any]: +def fixed_line(fx: dict, qty: Any, where: dict | None = None) -> dict[str, Any]: """고정 줄(705-1) → 블록 줄 `fixed` — 수량 그대로(배 1) · 이음 없는 비목 = 줄 단가 · - 비율 줄(705-3) = `pct`(밑 = 블록 그 비목 합 · 수량 = 율).""" + 비율 줄(705-3) = `pct`(밑 = 블록 그 비목 합 · 수량 = 율) · 율 기준(778-4) 묶음 = `bundle:` · + 줄 목록 = `rows:` 블록 차례(`where` {열쇠: 차례} · 단가산출 블록만 · 없으면 빈 줄).""" if rate := fx.get("비율"): name = {"name": fx.get("이름") or "", "spec": fx.get("규격") or "", "unit": "%"} - return {**name, "kind": "pct", "part": PART[rate["비목"]], - "base": f"part:{PART[rate['기준']]}", "qty": _f(qty)} # fmt: skip + basis = rate["기준"] + if isinstance(basis, list) and where is None: + return { + **name, + "kind": "blank", + "qty": _f(qty), + "why": "율 기준 줄 목록 — 대가로 못 셈", + } + base = ( + "rows:" + ",".join(str(where[k]) for k in basis if k in where) + if isinstance(basis, list) + else f"part:{PART[basis]}" + if basis in PART + else f"bundle:{GROUP[basis]}" + ) + return {**name, "kind": "pct", "part": PART[rate["비목"]], "base": base, "qty": _f(qty)} link = fx.get("이음") or {} return { "kind": "fixed", diff --git a/M02_MasterTemplete/M02_Labor_Expand.py b/M02_MasterTemplete/M02_Labor_Expand.py index 904b60a98..8a8b872f0 100644 --- a/M02_MasterTemplete/M02_Labor_Expand.py +++ b/M02_MasterTemplete/M02_Labor_Expand.py @@ -144,6 +144,7 @@ class Expand: for code in pill["로직"] } inner, rows, why, rid = [], [], None, trail[-1] if trail else "" + keyed: list[dict] = [] # 율 밑 줄(778-4 · `fixed.rate_base`) for at, one in enumerate(lines): ref = one.get("참조") if isinstance(ref, dict): # 줄 덮개(715-3) 있는 SK 참조 = 그 줄만 다시 셈 @@ -166,6 +167,8 @@ class Expand: why = why or f"「{sub.get('이름')}」 {NO_QTY}" continue rows.append(labor.mcut.row({k: v * qty for k, v in sub["기준"].items()})) + if not refs.is_rate(head): + keyed.append(fixed.rate_row(head, labor.line_id(head), rows[-1])) for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 수량(이은 값) if labor.apart(mat): # 별도계상 = 안 셈 · 안 줄에도 없음(222-1) continue @@ -190,11 +193,14 @@ class Expand: why = why or f"「{name}」 {inner[-1]['까닭']}" continue rows.append(labor.mcut.row({k: v * qty for k, v in base.items()})) - under = labor._sum(rows) - for one in inner: # 비율 고정 줄(705-3) — 밑 = 다른 줄 합 - if (one.get("고정") or {}).get("비율"): - one["기준"], row = fixed.ratio_row(one["고정"]["비율"], one["수량"], under) + keyed.append({"키": None, "묶음": "재료", "비목": rows[-1]}) + for one in inner: # 비율 고정 줄(705-3) — 밑 = `rate_base`(778-4) + if rate := (one.get("고정") or {}).get("비율"): + under = fixed.rate_base(rate["기준"], keyed) + one["기준"], row = fixed.ratio_row(rate, one["수량"], under) rows.append(row) + if row: + keyed.append(fixed.rate_row(one, labor.line_id(one), row, True)) return { "이름": doc.get("이름"), "단위": doc.get("단위"), diff --git a/M02_MasterTemplete/M02_Labor_Fixed.py b/M02_MasterTemplete/M02_Labor_Fixed.py index 9ff01ecd8..adb025277 100644 --- a/M02_MasterTemplete/M02_Labor_Fixed.py +++ b/M02_MasterTemplete/M02_Labor_Fixed.py @@ -7,8 +7,11 @@ (노임 새 판 때 따라감) · 품(수량)은 그대로. M02 셈(층 · 사본) = 노임 「값」 · 자재 기준단가로 이음 · 기계 = 줄 단가(기계 시간 단가 = B09 단가 책 몫). 출처 = `{파일, 호표, 줄, 판}`(글 · 수만) · 줄 가름 = 「고정|이름|규격|단위」(짝 `Labor_Calc.ts` `lineId`). -비율 줄(705-3 · 엑셀 % 줄) = `비율 {기준, 비목}` · 수량 = 율(%) — 반영[비목] = 다른 줄 반영합[기준] × 율 ÷ 100 -(`ratios` · 짝 `Labor_Calc.ts` `ratioLines` · B09 = `pct` 줄). +비율 줄(705-3 · 엑셀 % 줄) = `비율 {기준, 비목}` · 수량 = 율(%) — 반영[비목] = 밑 × 율 ÷ 100 +(`ratios` · 짝 `Labor_Calc.ts` `ratioLines` · B09 = `pct` 줄) · 단가산출(SK) 율 더한줄(778-4)도 이 꼴. +기준(778-4 · 밑 = `rate_base` · 짝 `Labor_Calc.ts` `rateBase`) = 비목(노무비 · 재료비 · 경비 = 율 줄 뺀 +그 비목 합) · 묶음(인력 · 재료 · 장비 = 그 묶음 줄 노 · 재 · 경 합 · 고정 줄 묶음 = 이음) · +줄 목록 [열쇠](그 줄 합 · 앞 율 줄 넣으면 율 위 율 · SC 열쇠 = 줄 가름 · SK = cid). B09 대가 갈래 `fixed` = `B09_Estimation_Labor_Source.fixed_line` · 셈 `B09_Estimation_Calc_Labor`. """ @@ -27,6 +30,8 @@ COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비 FIXED = "고정" LINKS = {"노임": "LB", "자재": "MT", "기계": "EQ"} SOURCE_KEYS = ("파일", "호표", "줄", "판") +GROUPS = {"노임": "인력", "자재": "재료", "기계": "장비"} # 율 기준 묶음(778-4) ← 고정 줄 이음 +BASES = (*COSTS, *GROUPS.values()) def _bad(text: str) -> Exception: @@ -82,9 +87,9 @@ def clean(fx: Any, where: str, whole: Any) -> dict[str, Any]: link[k] = code or None rate = fx.get("비율") if rate is not None and not ( - isinstance(rate, dict) and rate.get("기준") in COSTS and rate.get("비목") in COSTS + isinstance(rate, dict) and _basis(rate.get("기준")) and rate.get("비목") in COSTS ): - raise _bad(f"{where} · 고정 줄 비율은 {{기준, 비목}} — 노무비 · 재료비 · 경비") + raise _bad(f"{where} · 고정 줄 비율은 {{기준, 비목}} — 기준 = 비목 · 묶음 · 줄 목록") src = fx.get("출처") if isinstance(fx.get("출처"), dict) else {} kept = {k: v for k in SOURCE_KEYS if isinstance(v := src.get(k), (str, int, float))} out = {"이름": name, "규격": str(fx.get("규격") or ""), "단위": str(fx.get("단위") or "")} @@ -92,6 +97,42 @@ def clean(fx: Any, where: str, whole: Any) -> dict[str, Any]: return {**out, "비율": {k: rate[k] for k in ("기준", "비목")}} if rate else out +def _basis(raw: Any) -> bool: + """율 기준 — 비목 · 묶음 이름 · 줄 열쇠 목록(글 · 빈 목록 = 밑 0 · 기준 줄을 지운 뒤).""" + if isinstance(raw, list): + return all(isinstance(x, str) and x for x in raw) + return raw in BASES + + +def group_of(fx: dict) -> str | None: + """고정 줄 묶음 — 첫 이음(노임 · 자재 · 기계) · 이음 없음 = None.""" + link = fx.get("이음") or {} + return next((g for k, g in GROUPS.items() if link.get(k)), None) + + +def _all(money: dict | None) -> Decimal: + return sum((Decimal(str((money or {}).get(k) or 0)) for k in COSTS), Decimal(0)) + + +def rate_base(basis: Any, rows: list[dict]) -> Decimal: + """율 줄 밑(778-4 · 짝 `Labor_Calc.ts` `rateBase`) — `rows` [{키, 묶음, 비목 {노 · 재 · 경}, 율?}] · + 비목 = 율 줄 뺀 그 비목 합 · 묶음 = 율 줄 뺀 그 묶음 줄 셋 합 · 목록 = 그 열쇠 줄 셋 합.""" + if isinstance(basis, list): + keep = set(basis) + return sum((_all(r["비목"]) for r in rows if r.get("키") in keep), Decimal(0)) + rows = [r for r in rows if not r.get("율")] + if basis in COSTS: + return sum((Decimal(str((r["비목"] or {}).get(basis) or 0)) for r in rows), Decimal(0)) + return sum((_all(r["비목"]) for r in rows if r.get("묶음") == basis), Decimal(0)) + + +def rate_row(one: dict, key: Any, money: dict | None, rate: bool = False) -> dict[str, Any]: + """`rate_base` 줄 하나 — 고정 줄 묶음 = 이음 · 그 밖 = 줄 `묶음`(SK 엔진 줄 · 자재 = 재료).""" + fx = one.get(FIXED) + group = group_of(fx) if isinstance(fx, dict) else one.get("묶음") + return {"키": key, "묶음": group, "비목": money, "율": rate} + + def _has(whole: Any, code: str) -> bool: try: return bool(whole.get(code)) @@ -136,26 +177,35 @@ def line(whole: Any, fx: dict) -> dict[str, Any]: return {**head, "기준": money, "이은": used, "까닭": None} -def ratio_row(rate: dict, qty: Decimal | None, under: dict) -> tuple[dict, dict | None]: - """비율 줄 (기준, 반영) — 기준[비목] = 밑[기준] · 반영 = 기준 × 율 ÷ 100 · +def ratio_row(rate: dict, qty: Decimal | None, under: Decimal) -> tuple[dict, dict | None]: + """비율 줄 (기준, 반영) — 기준[비목] = 밑(`rate_base`) · 반영 = 기준 × 율 ÷ 100 · 0.1 원 버림(751-3) · 수량 없음 = None.""" base = dict.fromkeys((*COSTS, "계"), Decimal(0)) - base[rate["비목"]] = base["계"] = under[rate["기준"]] + base[rate["비목"]] = base["계"] = under return base, None if qty is None else mcut.row({k: base[k] * qty / 100 for k in COSTS}) def ratios(lines: list[dict], reflects: list[dict | None], bases: list[dict | None]) -> None: - """비율 줄(705-3) — `settle` 끝에 · 밑 = 비율 줄 뺀 반영합(줄 · 자재) · 배 100 · 기준합엔 안 셈 · - `lines` · `reflects` · `bases` 앞 줄 = 같은 차례(짝 `Labor_Calc.ts` `ratioLines`).""" - at = {i for i, x in enumerate(lines) if (x.get(FIXED) or {}).get("비율")} - under = { - k: sum((r[k] for i, r in enumerate(reflects) if r and i not in at), Decimal(0)) - for k in COSTS - } - for i in sorted(at): - base, done = ratio_row(lines[i][FIXED]["비율"], lines[i].get("수량"), under) + """비율 줄(705-3) — `settle` 끝에 · 밑 = `rate_base`(율 줄 뺀 반영(줄 · 자재) · 앞 율 줄은 목록만) · + 배 100 · 기준합엔 안 셈 · `lines` · `reflects` · `bases` 앞 줄 = 같은 차례(짝 `Labor_Calc.ts` + `ratioLines`) · 자재 줄 = 묶음 재료 · 열쇠 없음.""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + at = [i for i, x in enumerate(lines) if (x.get(FIXED) or {}).get("비율")] + rows = [ + rate_row(lines[i], labor.line_id(lines[i]), r) + if i < len(lines) + else {"키": None, "묶음": "재료", "비목": r} + for i, r in enumerate(reflects) + if r and i not in at + ] + for i in at: + rate = lines[i][FIXED]["비율"] + base, done = ratio_row(rate, lines[i].get("수량"), rate_base(rate["기준"], rows)) lines[i], reflects[i], bases[i] = ( {**lines[i], "기준": base, "배": 100, "반영": done}, done, None, ) + if done: + rows.append(rate_row(lines[i], labor.line_id(lines[i]), done, True)) diff --git a/M02_MasterTemplete/M02_Library_Flat.py b/M02_MasterTemplete/M02_Library_Flat.py index 9437f4e4e..2e5a26290 100644 --- a/M02_MasterTemplete/M02_Library_Flat.py +++ b/M02_MasterTemplete/M02_Library_Flat.py @@ -14,7 +14,9 @@ `{층: 사본, 주인: 몸 지문, 키: 본 SC, 몸}` 에 담음(`M02_Labor_Ref.copy_ref` · 원본 연결 없음 · 하위 호표 끝수 단계 · B09 하위 대가 블록 그대로 · 같은 기초 겹침도 블록 안) - 바탕 SK 참조 · 패치 있는 SK 참조 줄 = 위 덮개를 아래 덮개에 겹쳐 한 벌(`compose`) + 바탕 SK 참조 · 패치 있는 SK 참조 줄 = 위 덮개를 아래 덮개에 겹쳐 한 벌(`compose` · 율 기준 줄 열쇠 + `add:#k` 도 새 차례로 · 778-4) + 율 줄(778-4) 있는 바탕 없는 SK = 한 줄로 못 펼침(율 · 기준 묶음이 넣는 쪽 줄로 번짐) → 참조 그대로 못 펼친 줄 = 저장 · 읽기 · 옮기기에선 옛 참조 그대로(값 그대로) · 넣기 길(`strict`)에선 400 까닭. 초기화(`reset`) = 덮개 · 뺀줄 · 더한줄 단가 걷음(안쪽 넣은 몸 · 사본 블록까지) · 이음 기계 고정 줄 단가 = M01 기계 한 시간 값 다시 받음(736-3) · 항목 · 수량 · 입력값 · 고름 · 자재표시 · 켜쌓기율 · @@ -142,6 +144,8 @@ def sk_adds(y: dict, q: Decimal, whole: Any, tag: dict) -> list[dict]: adds = filled_adds(y, whole) code = (y.get("바탕") or {}).get("로직") + if not code and any(sd.is_rate(one) for one in adds): + raise sd.DocError("율 줄 있는 바탕 없는 단가산출은 펼쳐 넣지 못함 — 참조로 둠") if not code: return [_scaled(one, q, tag) for one in adds] row = whole.logic(code) @@ -291,13 +295,36 @@ def compose(under: dict, over: dict, whole: Any) -> dict: into[t[1]] = value rate = over.get("켜쌓기율", under.get("켜쌓기율")) out = {**over, "바탕": base, "덮개": covers, "뺀줄": drop, "고름": picks, "자재표시": marks} - out["더한줄"] = [a for k, a in enumerate(adds) if k not in gone] + list( - over.get("더한줄") or [] - ) + kept = [k for k in range(len(adds)) if k not in gone] + new = {f"add:#{k}": f"add:#{n}" for n, k in enumerate(kept)} # 율 기준 열쇠(778-4) + mine = [ + _rekey(a, lambda x: new.get(x) if ADD.match(x) else x) + for k, a in enumerate(adds) + if k not in gone + ] + + def up(cid: str) -> str | None: # 위 문서 열쇠 → 한 벌 열쇠 + hit = ADD.match(cid) + if hit: + return f"add:#{len(kept) + int(hit[1])}" + t = to(cid) + return cid if t is None else t[1] if t[0] == "base" else new.get(f"add:#{t[1]}") + + out["더한줄"] = mine + [_rekey(dict(a), up) for a in over.get("더한줄") or []] out["입력값"] = {**(under.get("입력값") or {}), **(over.get("입력값") or {})} return out | ({"켜쌓기율": rate} if rate is not None else {}) +def _rekey(one: dict, move: Any) -> dict: + """율 더한줄 기준 줄 목록 열쇠를 `move` 로(못 옮긴 열쇠 = 버림 · 778-4).""" + rate = ((one.get("고정") or {}).get("비율") or {}) if isinstance(one, dict) else {} + if not isinstance(rate.get("기준"), list): + return one + keys = [k for k in (move(x) for x in rate["기준"]) if k] + fx = {**one["고정"], "비율": {**rate, "기준": keys}} + return {**one, "고정": fx} + + # ── SC ──────────────────────────────────────────────────────────────── def sk_line(y: dict, one: dict, whole: Any, tag: dict) -> dict: """펼친 SK → 일위대가 로직 줄 하나(+ 패치) — 옛 참조 줄 `one` 의 별칭 · 메모 · 수량 · 패치 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index 9946ce8c6..231858c71 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -8,7 +8,9 @@ * 별도계상 자재(222-1 · 빈 칸 = 기본 별도계상) = 반영은 보이되 합에 안 더함(짝 `apart`). * 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`) · * 고정 줄(705-1)은 「고정|이름|규격|단위」(짝 `M02_Labor_Fixed.fixed_id`) · 기준 = 서버가 낸 줄 단가. - * 규격 열(704-4) = 문서 `규격` · 그 열 수량이 있는 줄 = `{손}` 자리(`atSpec` · 짝 `M02_Labor_Spec.at`). */ + * 규격 열(704-4) = 문서 `규격` · 그 열 수량이 있는 줄 = `{손}` 자리(`atSpec` · 짝 `M02_Labor_Spec.at`). + * 율 줄 밑(778-4) `rateBase` = 짝 `M02_Labor_Fixed.rate_base` — 일위대가 비율 줄 · 단가산출(SK) 율 더한줄 + * (서버 `M02_Sangeun_Calc._rated`) 한 규칙 · 거울 시험 `resources/tester/test_778_4_rate_line.py`. */ import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import type { LogicMat } from "./M02_Labor_LogicMats_Model"; @@ -36,10 +38,25 @@ export interface LaborFixed { 단가: Record<"노무비" | "재료비" | "경비", number>; 이음?: Record<"노임" | "자재" | "기계", string | null>; 출처?: Record; - /** 비율 줄(705-3 · 엑셀 % 줄) — 수량 = 율 · 반영[비목] = 다른 줄 반영합[기준] × 율 ÷ 100 */ - 비율?: { 기준: CostKey; 비목: CostKey }; + /** 비율 줄(705-3 · 엑셀 % 줄) — 수량 = 율 · 반영[비목] = 밑(`rateBase`) × 율 ÷ 100 */ + 비율?: { 기준: RateBasis; 비목: CostKey }; } type CostKey = "노무비" | "재료비" | "경비"; +/** 율 기준(778-4) — 비목 · 묶음(인력 · 재료 · 장비) · 줄 열쇠 목록 */ +export type RateBasis = CostKey | RateGroup | string[]; +export type RateGroup = "인력" | "재료" | "장비"; +/** 율 밑 줄 — 열쇠(SC 줄 가름 · SK cid) · 묶음 · 비목 · 율 줄인가 */ +export interface RateRow { + 키: string | null; + 묶음: string | null; + 비목: Partial> | null; + 율?: boolean; +} +const GROUPS: Record<"노임" | "자재" | "기계", RateGroup> = { + 노임: "인력", + 자재: "재료", + 기계: "장비", +}; export interface CalcLine { /** 참조 줄은 서버 답에 없음(빈 글로 다룸) */ @@ -234,27 +251,69 @@ export function settle( if (cost !== null && !aside.has(one.코드)) add(done, { ...zero(), 재료비: cost, 계: cost }); return { ...one, 수량: qty, 수량출처: source, 반영: cost }; }); - const lines = ratioLines(줄, done, base); + const lines = ratioLines( + 줄, + 자재.filter((m) => !aside.has(m.코드)), + done, + base, + ); return { 줄: lines, 자재, 기준합: base, 반영합: cutSums(done) }; } -/** 비율 고정 줄(705-3 · 짝 `M02_Labor_Fixed.ratios`) — 밑 = 비율 줄 뺀 반영합 · 기준[비목] = 밑[기준] · - * 배 100 · 반영 = 기준 × 율 ÷ 100 · `done` 에 더함 · 기준합(`base`)엔 안 셈 */ -function ratioLines(lines: CalcLine[], done: Money, base: Money): CalcLine[] { +/** 고정 줄 묶음 — 첫 이음(노임 · 자재 · 기계 · 짝 `M02_Labor_Fixed.group_of`) */ +export function groupOf(fx: LaborFixed | undefined): RateGroup | null { + const link = fx?.이음; + const hit = (Object.keys(GROUPS) as (keyof typeof GROUPS)[]).find((k) => link?.[k]); + return hit ? GROUPS[hit] : null; +} + +const all = (m: RateRow["비목"]): number => COSTS.reduce((s, k) => s + (m?.[k] ?? 0), 0); + +/** 율 줄 밑(778-4 · 짝 `M02_Labor_Fixed.rate_base`) — 비목 = 율 줄 뺀 그 비목 합 · + * 묶음 = 율 줄 뺀 그 묶음 줄 셋 합 · 목록 = 그 열쇠 줄 셋 합(앞 율 줄 됨) */ +export function rateBase(basis: RateBasis, rows: RateRow[]): number { + if (Array.isArray(basis)) { + const keep = new Set(basis); + return rows.reduce((s, r) => s + (r.키 !== null && keep.has(r.키) ? all(r.비목) : 0), 0); + } + const plain = rows.filter((r) => !r.율); + if ((COSTS as readonly string[]).includes(basis)) + return plain.reduce((s, r) => s + (r.비목?.[basis as CostKey] ?? 0), 0); + return plain.reduce((s, r) => s + (r.묶음 === basis ? all(r.비목) : 0), 0); +} + +/** 비율 고정 줄(705-3 · 짝 `M02_Labor_Fixed.ratios`) — 밑 = `rateBase`(율 줄 뺀 반영 줄 · 자재 = 묶음 재료) · + * 기준[비목] = 밑 · 배 100 · 반영 = 기준 × 율 ÷ 100 · `done` 에 더함 · 기준합(`base`)엔 안 셈 */ +function ratioLines( + lines: CalcLine[], + mats: { 반영?: number | null }[], + done: Money, + base: Money, +): CalcLine[] { for (const l of lines.filter((x) => x.고정?.비율)) { for (const k of MONEY_KEYS) { done[k] -= l.반영?.[k] ?? 0; base[k] -= l.기준?.[k] ?? 0; } } - const under = { ...done }; + const rows: RateRow[] = [ + ...lines + .filter((l) => !l.고정?.비율 && l.반영) + .map((l) => ({ 키: lineId(l) ?? null, 묶음: groupOf(l.고정), 비목: l.반영 ?? null })), + ...mats + .filter((m) => m.반영 != null) + .map((m) => ({ 키: null, 묶음: "재료", 비목: { 재료비: m.반영 } })), + ]; return lines.map((line) => { const rate = line.고정?.비율; if (!rate) return line; const own = zero(); - own[rate.비목] = own.계 = under[rate.기준]; + own[rate.비목] = own.계 = rateBase(rate.기준, rows); const got = reflect(own, line.수량 ?? null, 100); - if (got) for (const k of MONEY_KEYS) done[k] += got[k]; + if (got) { + for (const k of MONEY_KEYS) done[k] += got[k]; + rows.push({ 키: lineId(line) ?? null, 묶음: groupOf(line.고정), 비목: got, 율: true }); + } return { ...line, 기준: own, 배: 100, 반영: got }; }); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index 04549c6a8..df7b9bda4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -38,6 +38,7 @@ import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; import { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead"; import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover"; import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick"; +import { openRateLine } from "./M02_MasterTemplete_Sangeun_Rate"; import { createSangeunSide, tabOf, type SourceTab } from "./M02_MasterTemplete_Sangeun_Side"; import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit"; import { @@ -121,6 +122,18 @@ export function mountSangeunDetail( }), }); addButton.hidden = !edit; + // 778-4 율 줄 — 기준 줄 후보 = 지금 셈 답 줄(세부 줄 뺌) · 새 줄은 맨 끝(앞 줄만 가리킴) + const rateButton = createButton({ + label: L("M02_SkRateAdd"), + variant: "ghost", + size: 28, + onClick: () => + openRateLine( + (ans?.줄 ?? []).map((l) => ({ cid: l.cid, 이름: l.이름 })), + (line) => ((doc.더한줄 = [...(doc.더한줄 ?? []), withFirst(line)]), touch()), + ), + }); + rateButton.hidden = !edit; /** 둘째 기초(728-2) — 수량 처음값 = 바탕 기준단위 1 개 = 그 기초 기준단위 몇 개(같은 성격 · 다르면 1) */ async function addBase(line: { 로직: string; 수량: number }): Promise { let qty = 1; @@ -153,7 +166,7 @@ export function mountSangeunDetail( children: [ head, notes, - el("div", { className: "m02-sk__head", children: [views.root, addButton] }), + el("div", { className: "m02-sk__head", children: [views.root, addButton, rateButton] }), editor, ], }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Rate.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Rate.ts new file mode 100644 index 000000000..cfcf7e580 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Rate.ts @@ -0,0 +1,103 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_Rate.ts + * M02 단가산출(SK) 율 줄 넣기 창(PLAN 778-4 · 요구 623) — [율 줄 추가] → 이름 · 율 % · 기준 · 금액이 드는 칸. + * 기준 = 묶음(인력 · 재료 · 장비 줄 합) · 비목 전체(노무비 · 재료비 · 경비) · 줄 고르기(이 SK 줄 cid 목록 · + * 앞 율 줄 고르면 율 위 율) → 더한줄 `{고정: {이름, 단위 %, 단가 0, 이음 없음, 비율 {기준, 비목}}, 수량 = 율}` + * (꼴 = 서버 `M02_Sangeun_Doc` · 셈 = 서버 `M02_Sangeun_Calc._rated`) · 부품 상자만. + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_button"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { el } from "@ui/ui_template_elements"; +import { createFormGrid } from "@ui/ui_template_general_layout"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { t as L } from "@ui/ui_template_locale"; +import { openModal } from "@ui/ui_template_modal"; +import type { SkAdded } from "./logic/M02_Sangeun_Cover_Model"; + +const LINES = "줄"; + +/** 율 줄 넣기 — `rows` = 지금 셈 답 줄(기준 줄 고르기 후보) · `done` = 새 더한줄 한 줄 */ +export function openRateLine( + rows: { cid: string; 이름: string }[], + done: (line: SkAdded) => void, +): void { + const name = createInputField({ label: L("M02_SkRateName"), value: L("M02_SkRateDefault") }); + const rate = createInputField({ + label: L("M02_SkRateRate"), + type: "number", + value: "2", + unit: "%", + min: 0, + }); + const picks = rows.map((r) => ({ + cid: r.cid, + box: createCheckbox(r.이름 || r.cid, { chip: true, size: 28 }), + })); + const list = el("div", { + className: "m02-sk__tags", + attrs: { "data-sk-rate-lines": "" }, + children: picks.length + ? picks.map((p) => p.box.root) + : [el("span", { text: L("M02_SkRateNoLines") })], + }); + list.hidden = true; + const basis = createSelectField({ + label: L("M02_SkRateBasis"), + value: "인력", + options: [ + { value: "인력", text: L("M02_SkRateGroupLabor") }, + { value: "재료", text: L("M02_SkRateGroupMat") }, + { value: "장비", text: L("M02_SkRateGroupMach") }, + { value: "노무비", text: L("M02_SkRateCostLabor") }, + { value: "재료비", text: L("M02_SkRateCostMat") }, + { value: "경비", text: L("M02_SkRateCostExp") }, + { value: LINES, text: L("M02_SkRateBasisLines") }, + ], + onChange: (v) => (list.hidden = v !== LINES), + }); + const part = createSelectField({ + label: L("M02_SkRatePart"), + value: "경비", + options: [ + { value: "경비", text: L("M02_SkRatePartExp") }, + { value: "재료비", text: L("M02_SkRatePartMat") }, + { value: "노무비", text: L("M02_SkRatePartLabor") }, + ], + }); + openModal({ + title: L("M02_SkRateTitle"), + closeLabel: L("M02_SkRateClose"), + width: "40rem", + mount: (body, close, actions) => { + const ok = createButton({ + label: L("M02_SkRateOk"), + variant: "filled", + onClick: () => { + const label = name.input.value.trim(); + const q = Number(rate.input.value); + const keys = picks.filter((p) => p.box.input.checked).map((p) => p.cid); + const lines = basis.select.value === LINES; + name.setError(label ? undefined : L("M02_SkRateNeedName")); + rate.setError(rate.input.value.trim() && q >= 0 ? undefined : L("M02_SkRateBadRate")); + if (lines && !keys.length) return void rate.setError(L("M02_SkRateNeedLine")); + if (!label || !rate.input.value.trim() || !(q >= 0)) return; + const fx = { + 이름: label, + 단위: "%", + 단가: { 노무비: 0, 재료비: 0, 경비: 0 }, + 이음: { 노임: null, 자재: null, 기계: null }, + 비율: { 기준: lines ? keys : basis.select.value, 비목: part.select.value }, + }; + done({ 고정: fx, 이름: label, 단위: "%", 수량: q }); + close(); + }, + }); + ok.dataset.skRateOk = ""; + actions.append(ok); + const grid = createFormGrid([name.root, rate.root, basis.root, part.root]); + grid.dataset.skRate = ""; + body.append(grid, list); + }, + }); +} diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 809832716..449d2dee9 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -19,6 +19,9 @@ SK 단위 환산(728-2) = 배 = 바탕 기준단위(「100본」) → SK 단위( 일위대가 덮개 줄(`line` · `M02_Labor_Patch.run`)은 일위대가 기본(222-1 · 162-1) 그대로. 보기 넷 한 답(750-1) = 화면 셈 길 답에 `글`(읽는 식) · `중간`(`M02_Sangeun_Views`) · 일위대가 줄 모달 = 문서 `줄모드` true → `line`(일위대가 그 줄 셈과 같은 값). +율 더한줄(778-4 · `_rated`) = 엔진 한 번 돈 줄로 밑(`M02_Labor_Fixed.rate_base` · 짝 화면 +`M02_MasterTemplete_Labor_Calc.ts` `rateBase` · 거울 시험 `test_778_4_rate_line.py`) → 율 줄 `값묶음` = +{비목: 밑 ÷ 100} → 엔진 다시(율 줄 없는 문서 = 한 번만 · 값 그대로) · 줄 덧칸 `율` · `규격`. """ from __future__ import annotations @@ -350,6 +353,10 @@ def run( names = {str(i.get("이름")) for i in row.get("입력") or []} values = {k: v for k, v in {**got["입력값"], **(given or {})}.items() if k in names} out = labor.run_line(over, key, values, got["켜쌓기율"], got["고름"] or None) + rates = _rated(row, out.get("줄") or [], got["정체"], got["자재표시"], got["켜쌓기율"]) + if rates is not None: # 율 더한줄(778-4) — 밑을 넣고 다시 + head["짝잃음"] = head["짝잃음"] + rates + out = labor.run_line(over, key, values, got["켜쌓기율"], got["고름"] or None) eng = out.get("줄") or [] seen = ( views.text_of(over, key, values, got["고름"], got["정체"], eng) if detail and seen else {} @@ -365,6 +372,10 @@ def run( one = got["채운더한줄"][int(hit[1])] if hit else {} if detail and one.get("종류") == "로직" and at not in gaps: mark |= _detail(one, int(hit[1]), whole) + if sd.is_rate(one): # 율 줄 = 율 · 「기준의 %」(화면 % 줄 · `per_unit` 수량 그대로) + rate = one["고정"]["비율"] + group = {"노무비": "인력", "재료비": "재료", "경비": "경비"}[rate["비목"]] + mark |= {"율": Decimal(str(one["수량"])), "규격": _rate_text(rate), "묶음": group} lines.append( {**line, "cid": cid, **mark, **({"원값": mine["원값"]} if "원값" in mine else {})} ) @@ -380,6 +391,58 @@ def run( return {**out, **head, **seen, "1단위": unit, "빠진줄": gone, "꼬리표": tag} +def _group(item: dict, line: dict) -> str | None: + """율 밑 묶음(778-4) — 인력 · 재료 · 장비(기계경비 로직 · 기계 이음 고정 · 기계 줄) · 그 밖 None.""" + if isinstance(item.get("고정"), dict): + return fixed.group_of(item["고정"]) + if line.get("장비"): + return "장비" + return {"인력": "인력", "재료": "재료", "기계": "장비"}.get(str(item.get("종류"))) + + +def _rated(row: dict, eng: list, ids: list, marks: dict, stack: Any = None) -> list[dict] | None: + """율 더한줄(778-4) `값묶음` = {비목: 밑 ÷ 100} — 한 번 돈 엔진 줄(`eng` · 정체 `ids`)로 + `fixed.rate_base`(별도계상 재료 뺌 · 앞 율 줄 차례로 · 켜쌓기율 몫 뗌) · 제자리 · 율 줄 없음 = None · + 돌려줌 = 짝잃음(없는 기준 줄).""" + items = row["호표"] + at = [i for i, x in enumerate(items) if (x.get("고정") or {}).get("비율")] + if not at: + return None + rows = [ + {"키": cid, "묶음": _group(items[i] if i < len(items) else {}, x), "비목": x.get("비목")} + for i, (cid, x) in enumerate(zip(ids, eng)) + if i not in at + and not ( + i < len(items) and items[i].get("종류") == "재료" and labor.apart(marks.get(str(i))) + ) + ] + times = Decimal(str(stack)) / 100 if stack not in (None, 100) else Decimal(1) + lost = [] + for i in at: + rate, qty = items[i]["고정"]["비율"], Decimal(str(items[i]["수량"])) + if isinstance(rate["기준"], list): + lost += [ + {"cid": ids[i], "까닭": f"율 기준 줄 없음 「{k}」"} + for k in rate["기준"] or [""] + if k not in ids + ] + base = fixed.rate_base(rate["기준"], rows) / times + items[i] = { + **items[i], + "값묶음": {**dict.fromkeys(COSTS, Decimal(0)), rate["비목"]: base / 100}, + } + rows.append( + {"키": ids[i], "묶음": None, "비목": {rate["비목"]: base * qty / 100}, "율": True} + ) + return lost + + +def _rate_text(rate: dict) -> str: + """율 줄 규격 — 「인력의 %」 · 목록 = 「줄 n 개의 %」.""" + basis = rate["기준"] + return f"줄 {len(basis)} 개의 %" if isinstance(basis, list) else f"{basis}의 %" + + def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]: """펼침(`Expand`) 한 칸 — `{이름, 단위, 기준(1 단위), 까닭, 줄, 짝잃음}` · 못 셈 = 까닭.""" head = {"이름": doc.get("이름"), "단위": doc.get("단위")} diff --git a/M02_MasterTemplete/M02_Sangeun_Doc.py b/M02_MasterTemplete/M02_Sangeun_Doc.py index 890daff6b..fe274b615 100644 --- a/M02_MasterTemplete/M02_Sangeun_Doc.py +++ b/M02_MasterTemplete/M02_Sangeun_Doc.py @@ -11,6 +11,8 @@ | {종류: 로직, 로직, 입력값, 수량, 덮개?, 뺀줄?, 바탕판?, 고름? · 자재표시? · 켜쌓기율? · 더한줄?(넣은 SK 몸 · 740-1) · 본뜬?(출처)} | {참조 | 고정, 이름?, 단위?, 수량}], + (율 줄 778-4 = 고정 `비율 {기준, 비목}` · 수량 = 율 % · 단위 % · 기준 = 비목 · 묶음 인력 · 재료 · 장비 · + 줄 열쇠 목록 [cid | add:#k] — 더한줄 율 줄은 앞 줄만 · 셈 `M02_Sangeun_Calc._rated`) 뺀줄 [cid], 바탕판, 환산?} SK 단위 환산(728-2) = `환산` {계수: 양수}(성격 다름 · 바탕 앞 수 뗀 1 단위 = SK 몇 단위) | {단위중량: {종별, 형상}}(부피 ↔ 무게 · 표 CC000337) — 셈 = `M02_Sangeun_Calc.ratio_of`. @@ -168,8 +170,8 @@ def _added(raw: Any, where: str, whole: Any) -> dict: head = {"고정": fixed.clean(raw["고정"], where, whole) if whole else raw["고정"]} except Exception as e: # noqa: BLE001 — 고정 줄 검사 = StoreError(400) raise DocError(str(getattr(e, "detail", e))) from e - if head["고정"].get("비율"): - raise DocError(f"{where} · 비율 고정 줄은 단가산출에 못 담음 — 덧줄 율로") + if head["고정"].get("비율"): # 율 줄(778-4) + unit = unit or "%" elif raw.get("종류") == "로직": code = str(raw.get("로직") or "") row = _logic(whole, code, where) @@ -339,6 +341,7 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str out["더한줄"] = [ _added(one, f"더한줄 {at}째", whole) for at, one in enumerate(doc.get("더한줄") or [], 1) ] + _rate_keys(out["더한줄"]) drop = [_cid(x, "뺀줄") for x in doc.get("뺀줄") or []] if len(set(drop)) != len(drop): raise DocError("뺀줄에 같은 줄 두 번") @@ -348,6 +351,25 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str return out +def is_rate(one: Any) -> bool: + """율 더한줄(778-4) — 고정 `비율`.""" + return isinstance(one, dict) and bool((one.get("고정") or {}).get("비율")) + + +def _rate_keys(adds: list[dict]) -> None: + """율 더한줄 기준 목록(778-4) — 열쇠 = 바탕 cid · `add:#k` · 자기 · 뒤 율 줄 못 가리킴(율 위 율 = + 앞 율 줄만 · 순환 없음).""" + for at, one in enumerate(adds): + basis = ((one.get("고정") or {}).get("비율") or {}).get("기준") + for key in basis if isinstance(basis, list) else []: + hit = re.match(r"^add:#(\d+)$", key) + if not hit and not _CID.match(key): + raise DocError(f"더한줄 {at + 1}째 · 율 기준 줄 열쇠 꼴 아님 「{key}」") + k = int(hit[1]) if hit else -1 + if k == at or (k > at and k < len(adds) and is_rate(adds[k])): + raise DocError(f"더한줄 {at + 1}째 · 율 기준에 자기 · 뒤 율 줄 「{key}」") + + def _swap_how(raw: Any) -> dict[str, Any]: """단위 환산 칸(728-2) — 계수(양수) · 단위중량 {종별, 형상} 중 있는 것만.""" if not isinstance(raw, dict): diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts index f5b0a1a44..8cd386119 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts @@ -257,7 +257,7 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR const one = added === null ? undefined : doc.더한줄?.[added]; const owner = at ? doc.더한줄?.[at.k] : undefined; const cover = at ? owner?.덮개?.[at.cid] : added === null ? doc.덮개?.[cid] : undefined; - const pct = (at?.cid ?? cid).startsWith("pct:"); + const pct = (at?.cid ?? cid).startsWith("pct:") || extra.율 !== undefined; // 덧줄 · 율 줄(778-4) const keys = Object.keys(parts); const shown = added === null && !at ? (was.get(cid) ?? null) : null; const base = at diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Undo.ts b/M02_MasterTemplete/logic/M02_Sangeun_Undo.ts index 112df8804..73a7387bc 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Undo.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Undo.ts @@ -2,7 +2,8 @@ * M02_MasterTemplete/logic/M02_Sangeun_Undo.ts * 단가산출(SK) 되돌리기 기억(PLAN 744-1 서버 · 748-0 화면) — 더한 기초 · 더한 항목 빼기 → 문서 `뺀더한줄` * `[{차례, 줄}]`(되살림 = 그 차례에 다시 끼움) · 더한 항목 수량 → `처음수량`(넣을 때 값) 되돌림. - * 더한줄 지움 · 끼움 때 뒤 더한줄 열쇠 `add:#j[/…]`(덮개 · 고름 · 자재표시)를 한 칸씩 당김 · 밂. + * 더한줄 지움 · 끼움 때 뒤 더한줄 열쇠 `add:#j[/…]`(덮개 · 고름 · 자재표시 · 율 줄 기준 목록 778-4)를 + * 한 칸씩 당김 · 밂(지운 줄을 가리킨 기준 열쇠는 버림 — 빈 목록 = 서버 짝잃음 알림). * 금액은 늘 서버가 셈 — 여기는 문서 칸 옮김만 · 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ @@ -32,6 +33,17 @@ function moveKeys(doc: SkDoc, mode: { drop: number } | { push: number }): void { } (doc as Record)[name] = out; } + for (const one of doc.더한줄 ?? []) { + const rate = (one.고정 as { 비율?: { 기준?: unknown } } | undefined)?.비율; + if (!rate || !Array.isArray(rate.기준)) continue; + rate.기준 = (rate.기준 as string[]).flatMap((key) => { + const hit = KEY_RE.exec(key); + if (!hit) return [key]; + const j = Number(hit[1]); + if ("drop" in mode) return j === mode.drop ? [] : [j > mode.drop ? `add:#${j - 1}` : key]; + return [j >= mode.push ? `add:#${j + 1}` : key]; + }); + } } /** 더한줄 k 를 빼고 기억에 둠 — 이름은 되살림 띠 글(서버가 저장 때 다시 적음) */ diff --git a/resources/tester/helper_m02_labor_calc.cjs b/resources/tester/helper_m02_labor_calc.cjs index 66e0d6d7f..be4aeeab6 100644 --- a/resources/tester/helper_m02_labor_calc.cjs +++ b/resources/tester/helper_m02_labor_calc.cjs @@ -1,6 +1,6 @@ /* M02 일위대가 수량 · 반영 · 합(`M02_MasterTemplete_Labor_Calc.ts` `settle`) 거울 헬퍼 — * 그 자리에서 트랜스파일해 Node 로 돌림(상대 경로 .ts 가져옴도 · B09 `rounddown` 751-3) · - * stdin = {답, 문서, 값, 열?} · stdout = settle 결과 JSON. */ + * stdin = {답, 문서, 값, 열?} · stdout = settle 결과 JSON · {밑: {기준, 줄}} = `rateBase`(778-4). */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); @@ -25,4 +25,7 @@ function load(at) { const box = { exports: load(file) }; const input = JSON.parse(fs.readFileSync(0, "utf8")); -process.stdout.write(JSON.stringify(box.exports.settle(input.답, input.문서, input.값, input.열))); +const out = input.밑 + ? box.exports.rateBase(input.밑.기준, input.밑.줄) + : box.exports.settle(input.답, input.문서, input.값, input.열); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_778_4_rate_line.py b/resources/tester/test_778_4_rate_line.py new file mode 100644 index 000000000..41ea29b49 --- /dev/null +++ b/resources/tester/test_778_4_rate_line.py @@ -0,0 +1,341 @@ +"""778-4 단가산출(SK) 율 줄 — 고정 비율 더한줄 · 기준 = 비목 · 묶음 · 줄 목록 · 율 위 율 +(요구 623 · 분석 621-D 2-4 · 5-2) · M01 읽기만 · 쓰는 일 없음. + +- 서버 셈(`M02_Sangeun_Calc._rated`) = 손셈 · 묶음 인력 = 인력 줄만(기계 노무 섞인 블록 = 노무비 합과 다름) +- 밑 공용 함수 `M02_Labor_Fixed.rate_base` ↔ `Labor_Calc.ts` `rateBase` 거울 · 일위대가 비율 줄 settle 거울(1e-9) +- 검사 — 자기 · 뒤 율 줄 · 꼴 틀린 열쇠 · 모르는 기준 막음 · 율 줄 없는 문서 = 엔진 한 번(값 그대로) +- B09 — 율 기준 → pct 밑수(`part:` · `bundle:` · `rows:`) · 파이썬 = TS · SK 블록 밑수 꼴 +- 굳힘 — 바탕 없는 SK 율 줄 = 한 줄로 안 펼침 · 겹침(`compose`) 열쇠 새 차례 +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +from decimal import Decimal +from pathlib import Path + +import pytest + +from B09_Estimation import B09_Estimation_Labor_Source as source +from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Fixed as fixed +from M02_MasterTemplete import M02_Library_Flat as flat +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +ROOT = Path(__file__).resolve().parents[2] +HERE = os.path.dirname(os.path.abspath(__file__)) +IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE) +SUB = "GC000999" # 원/주 · 조경공 · 보통인부 + 고소작업차 로직 줄 + 덧줄 공구손료 3 % +MACHINE = "GC000268" # 기계경비(손료 보정 없는 기종) — 노무비 몫 있음 + + +def _whole(): + return m01.loaded()[1] + + +def _rate(basis, q=2, part="경비", name="공구손료"): + fx = {"이름": name, "단가": {}, "이음": {}, "비율": {"기준": basis, "비목": part}} + return {"고정": fx, "수량": q} + + +def _doc(adds, **more): + return { + "키": "SK-000001", + "이름": "율 시험", + "단위": "㎥", + "바탕": None, + "더한줄": adds, + **more, + } + + +def _run(doc): + whole = _whole() + return labor._json(calc.run(sd.clean(doc, None, whole), whole)) + + +LABOR = [ + {"종류": "인력", "요소": "LB000002", "수량": 0.5}, # 보통인부 + {"종류": "인력", "요소": "LB000003", "수량": 0.2}, # 특별인부 + {"종류": "로직", "로직": MACHINE, "입력값": {}, "수량": 0.29}, +] + + +# ── 서버 셈 ────────────────────────────────────────────────────────────────── +def test_묶음_인력은_인력_줄만_노무비와_다름(): + got = _run(_doc([*LABOR, _rate("인력"), _rate("노무비", 3, name="노무 율")])) + by = {x["cid"]: x for x in got["줄"]} + people = sum(Decimal(str(by[f"add:#{k}"]["금액"])) for k in (0, 1)) + mach = Decimal(str(by["add:#2"]["비목"]["노무비"])) + assert mach > 0 # 기계 노무 섞인 블록 + tool, cost = by["add:#3"], by["add:#4"] + assert tool["금액"] == pytest.approx(float(people * 2 / 100), abs=0.1) + assert cost["금액"] == pytest.approx(float((people + mach) * 3 / 100), abs=0.1) + assert tool["율"] == 2 and tool["규격"] == "인력의 %" and tool["묶음"] == "경비" + assert tool["비목"]["경비"] == pytest.approx(tool["금액"]) + assert got["짝잃음"] == [] + + +def test_줄_목록_절_소계와_율_위_율(): + adds = [*LABOR, _rate("인력"), _rate(["add:#0", "add:#3"], 10, "재료비", "율 위 율")] + adds.append(_rate(["add:#0", "add:#1"], 5, name="절 소계")) + got = _run(_doc(adds)) + by = {x["cid"]: x for x in got["줄"]} + first = Decimal(str(by["add:#0"]["금액"])) + tool = (first + Decimal(str(by["add:#1"]["금액"]))) * 2 / 100 + assert by["add:#4"]["금액"] == pytest.approx(float((first + tool) * 10 / 100), abs=0.1) + assert by["add:#4"]["비목"]["재료비"] == pytest.approx(by["add:#4"]["금액"]) + part = first + Decimal(str(by["add:#1"]["금액"])) + assert by["add:#5"]["금액"] == pytest.approx(float(part * 5 / 100), abs=0.1) + + +def test_바탕_SK_율_줄과_바탕_덧줄(): + whole = _whole() + doc = {**_doc([_rate("인력", 2)]), "바탕": {"로직": SUB}, "단위": "주"} + got = labor._json(calc.run(sd.clean(doc, None, whole), whole)) + bare = labor.run_line(whole, SUB, {}) + people = sum(Decimal(str(bare["줄"][k]["금액"])) for k in (0, 1)) + rate = next(x for x in got["줄"] if x["cid"] == "add:#0") + assert rate["금액"] == pytest.approx(float(people * 2 / 100), abs=0.1) + assert got["기준"]["경비"] == pytest.approx( + float(Decimal(str(bare["기준"]["경비"])) + Decimal(str(rate["금액"]))), abs=1 + ) + + +def test_없는_기준_줄_짝잃음(): + got = _run(_doc([LABOR[0], _rate(["add:#0", "labor:LB000099#0"])])) + assert got["짝잃음"] == [{"cid": "add:#1", "까닭": "율 기준 줄 없음 「labor:LB000099#0」"}] + got = _run(_doc([LABOR[0], _rate([])])) # 기준 줄을 지운 뒤 = 밑 0 · 알림 + assert got["짝잃음"] == [{"cid": "add:#1", "까닭": "율 기준 줄 없음 「」"}] + assert got["줄"][1]["금액"] == 0 + + +def test_율_줄_없으면_엔진_한_번(monkeypatch): + calls = [] + real = labor.run_line + monkeypatch.setattr(labor, "run_line", lambda *a, **k: calls.append(1) or real(*a, **k)) + _run(_doc(LABOR)) + assert len(calls) == 1 + _run(_doc([*LABOR, _rate("인력")])) + assert len(calls) == 3 + + +@pytest.mark.parametrize( + "adds, why", + [ + ([LABOR[0], _rate(["add:#1"])], "자기"), + ([LABOR[0], _rate(["add:#2"]), _rate("인력")], "뒤 율"), + ([LABOR[0], _rate(["보통인부"])], "꼴 아님"), + ([LABOR[0], _rate("합계")], "비율"), + ], +) +def test_검사_막음(adds, why): + with pytest.raises(sd.DocError, match=why): + sd.clean(_doc(adds), None, _whole()) + + +# ── 밑 공용 함수 거울 ─────────────────────────────────────────────────────── +def _node(payload: dict): + got = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")], + input=json.dumps(payload, ensure_ascii=False, default=float), + capture_output=True, text=True, encoding="utf-8", check=True, + ) # fmt: skip + return json.loads(got.stdout) + + +ROWS = [ + {"키": "a", "묶음": "인력", "비목": {"노무비": 1000.5, "재료비": 0, "경비": 0}}, + {"키": "b", "묶음": "장비", "비목": {"노무비": 300.25, "재료비": 12.5, "경비": 700}}, + {"키": "c", "묶음": "재료", "비목": {"노무비": 0, "재료비": 4000, "경비": None}}, + {"키": "d", "묶음": None, "비목": {"노무비": 10, "재료비": 20, "경비": 30}}, + {"키": "r", "묶음": None, "비목": {"노무비": 0, "재료비": 0, "경비": 55.5}, "율": True}, +] + + +@pytest.mark.parametrize( + "basis", ["노무비", "재료비", "경비", "인력", "재료", "장비", ["a", "c"], ["a", "r"], ["zz"]] +) +def test_밑_파이썬과_TS_같음(basis): + py = fixed.rate_base(basis, json.loads(json.dumps(ROWS))) + assert _node({"밑": {"기준": basis, "줄": ROWS}}) == pytest.approx(float(py), rel=1e-9) + + +def _fx(name, price, link=None, rate=None): + fx = {"이름": name, "단위": "식", "단가": price, "이음": link or {}} + return fx | ({"비율": rate} if rate else {}) + + +def test_일위대가_비율_줄_묶음_목록_settle_거울(): + plain = _fx("손 노임", {"노무비": 50000}) + tool = _fx("공구손료", {}, rate={"기준": "인력", "비목": "경비"}) + doc = labor.clean( + { + "담은로직": [ + {"고정": _fx("보통인부", {"노무비": 1}, {"노임": "LB000002"}), "수량": {"손": 0.5}}, + {"고정": plain, "수량": {"손": 0.3}}, + {"고정": tool, "수량": {"손": 2}}, + {"고정": _fx("목록 율", {}, rate={"기준": [fixed.fixed_id(tool)], "비목": "재료비"}), + "수량": {"손": 10}}, + {"고정": _fx("노무 율", {}, rate={"기준": "노무비", "비목": "경비"}), "수량": {"손": 3}}, + ], + "자재": [], + } + ) # fmt: skip + answer = labor.calc(doc, {}) + py = labor.settle(answer, doc, {}) + ts = _node({"답": answer, "문서": doc, "값": {}}) + _same(json.loads(json.dumps(labor._json(py), default=float)), ts) + lines = py["줄"] + people = Decimal(str(lines[0]["반영"]["노무비"])) + assert lines[2]["기준"]["경비"] == people # 인력 = 노임 이음 줄만(손 노임 뺌) + assert lines[3]["기준"]["재료비"] == lines[2]["반영"]["계"] # 율 위 율 + assert lines[4]["기준"]["경비"] == people + Decimal(str(lines[1]["반영"]["노무비"])) + + +def _same(a, b) -> None: + if isinstance(a, dict): + assert set(a) == set(b), set(a) ^ set(b) + for k in a: + _same(a[k], b[k]) + elif isinstance(a, list): + assert len(a) == len(b) + for x, y in zip(a, b): + _same(x, y) + elif isinstance(a, (int, float)) and not isinstance(a, bool): + assert b == pytest.approx(a, rel=1e-9, abs=1e-9) + else: + assert a == b + + +# ── B09 ───────────────────────────────────────────────────────────────────── +def test_B09_율_기준_밑수_꼴(): + rate = {"이름": "공구손료", "비율": {"기준": "인력", "비목": "경비"}} + assert source.fixed_line(rate, 2)["base"] == "bundle:labor" + rate["비율"]["기준"] = "노무비" + assert source.fixed_line(rate, 2)["base"] == "part:labor" + rate["비율"]["기준"] = ["x", "y"] + assert source.fixed_line(rate, 2)["kind"] == "blank" # 일위대가 블록 = 열쇠 차례 모름 + assert source.fixed_line(rate, 2, {"y": 4, "x": 1})["base"] == "rows:1,4" + + +def test_B09_SK_블록_율_줄_pct(): + whole = _whole() + adds = [{"종류": "인력", "요소": "LB000003", "수량": 0.1}, _rate("인력")] + adds.append(_rate(["labor:LB000002#0", "add:#1"], 5, "재료비", "목록")) + doc = sd.clean({**_doc(adds), "바탕": {"로직": SUB}, "단위": "주"}, None, whole) + build = source.Builder() + bid = build.sangeun(whole, {"층": "project", "키": "SK-000001"}, {**doc, "굳힌줄": {}}, {}) + lines = build.blocks[bid]["lines"] + pct = {ln["name"]: ln for ln in lines if ln["kind"] == "pct"} + assert pct["공구손료"]["base"] == "bundle:labor" and pct["공구손료"]["qty"] == 2 + assert pct["목록"]["base"] == "rows:1,4" and pct["목록"]["part"] == "material" + assert [ln["kind"] for ln in lines[:5]] == ["labor", "labor", "machine", "labor", "pct"] + + +BLOCK = { + "id": "DGR", "cover": "S:r", "kind": "조합", "name": "율 블록", "spec": "", "unit": "㎥", + "cite": "", "origin": {}, + "lines": [ + {"kind": "labor", "key": "L1", "name": "보통인부", "qty": 0.5}, + {"kind": "fixed", "name": "특별인부", "qty": 0.2, "own": {"labor": 100000.0}, + "link": {"labor": "L9", "material": None, "machine": None}}, + {"kind": "machine", "key": "E1", "name": "굴착기", "qty": 0.29}, + {"kind": "material", "key": "M1", "name": "시멘트", "qty": 3}, + {"kind": "pct", "name": "공구손료", "part": "expense", "base": "bundle:labor", "qty": 2}, + {"kind": "pct", "name": "율 위 율", "part": "material", "base": "rows:3,4", "qty": 10}, + {"kind": "pct", "name": "장비 율", "part": "expense", "base": "bundle:machine", "qty": 1}, + ], +} # fmt: skip +BOOKS = { + "labor": [{"key": "L1", "price": 172068}], + "material": [{"key": "M1", "price": 5000}], + "machine": [{"key": "E1", "labor": 30000, "material": 5000, "expense": 70000}], +} + + +def _ts_labor(daega, sheets): + base = IMPORTS.sub("", (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text("utf-8")) + code = IMPORTS.sub( + "", (ROOT / "B09_Estimation/B09_Estimation_Calc_Labor.ts").read_text("utf-8") + ) + body = ( + f"\nconst d = {json.dumps(daega, ensure_ascii=False)};" + f"\nconst s = {json.dumps(sheets, ensure_ascii=False)};" + "\nconsole.log(JSON.stringify(computeLabor(d, s, {}, {})));" + ) + got = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", base + code + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert got.returncode == 0, got.stderr + return json.loads(got.stdout) + + +def test_B09_bundle_rows_파이썬과_TS_같음_손셈(): + lines = [{**ln, "cid": f"{ln['kind']}:{ln['name']}#0"} for ln in BLOCK["lines"]] + daega = {"blocks": [{**BLOCK, "lines": lines}], "owners": {}} + py = json.loads(json.dumps(compute_labor(daega, BOOKS, {}), ensure_ascii=False)) + _same(py, _ts_labor(daega, BOOKS)) + got = {ln["name"]: ln for ln in py["daega_blocks"][0]["lines"]} + people = 86034.0 + 20000.0 # 보통인부 + 노임 이음 고정 줄 + assert got["공구손료"]["amounts"]["expense"] == pytest.approx(people * 0.02, abs=0.1) + tool = got["공구손료"]["amounts"]["expense"] + assert got["율 위 율"]["amounts"]["material"] == pytest.approx((15000 + tool) * 0.1, abs=0.1) + mach = got["굴착기"]["amount"] + assert got["장비 율"]["amounts"]["expense"] == pytest.approx(mach * 0.01, abs=0.1) + assert py["gaps"] == [] + + +# ── 굳힘 ──────────────────────────────────────────────────────────────────── +def test_바탕_없는_SK_율_줄은_한_줄로_안_펼침(): + whole = _whole() + y = sd.clean(_doc([LABOR[0], _rate("인력")]), None, whole) + with pytest.raises(sd.DocError, match="율 줄"): + flat.sk_adds(y, Decimal(2), whole, {}) + plain = sd.clean(_doc([LABOR[0]]), None, whole) + assert flat.sk_adds(plain, Decimal(2), whole, {})[0]["수량"] == 1 # 율 없으면 그대로 + + +def test_겹침_율_열쇠_새_차례(): + whole = _whole() + under = sd.clean( + {**_doc([LABOR[0], _rate(["add:#0"])]), "바탕": {"로직": SUB}, "단위": "주"}, None, whole + ) + seen = sd.patched(whole.logic(SUB), under) + view = dict(zip(seen["정체"], sd.line_ids(seen["줄"]))) # 아래 차례 → 위 문서 cid + over = {"더한줄": [LABOR[1], _rate(["add:#0", view["add:#1"], view["add:#0"]], 3)]} + out = flat.compose(under, over, whole) + keys = [((a.get("고정") or {}).get("비율") or {}).get("기준") for a in out["더한줄"]] + assert keys == [None, ["add:#0"], None, ["add:#2", "add:#1", "add:#0"]] + + +# ── 화면 — 더한줄 지움 · 되살림 때 율 기준 열쇠 옮김(`logic/M02_Sangeun_Undo.ts`) ────────── +def test_화면_더한줄_지움_되살림_율_열쇠(): + code = IMPORTS.sub( + "", (ROOT / "M02_MasterTemplete/logic/M02_Sangeun_Undo.ts").read_text("utf-8") + ) + doc = _doc([LABOR[0], LABOR[1], _rate(["add:#1", "add:#0", "labor:LB000038#0"])]) + body = ( + f"\nconst doc = {json.dumps(doc, ensure_ascii=False)};" + "\nconst key = () => doc.더한줄.at(-1).고정.비율.기준;" + "\nforgetAdd(doc, 0, ''); const a = key(); reviveAdd(doc, 0);" + "\nconsole.log(JSON.stringify([a, key()]));" + ) + got = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", code + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert got.returncode == 0, got.stderr + assert json.loads(got.stdout) == [ + ["add:#0", "labor:LB000038#0"], # 지운 줄 열쇠 버림 · 뒤 줄 당김 + ["add:#1", "labor:LB000038#0"], # 되살림 = 뒤 줄 밂 + ] diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index b3c8429c7..0afe57941 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -347,4 +347,28 @@ export const ui_locales_m2 = { M02_CmpColLook: ["꼴", "Result"], M02_CmpLost: ["못옮김 {n}", "{n} unmoved"], M02_ElemTitle: ["M01 요소 후보", "M01 element candidates"], + M02_SkRateAdd: ["율 줄 추가", "Add % line"], + M02_SkRateTitle: ["율 줄 — 기준 × 율 %", "% line — base × rate %"], + M02_SkRateName: ["이름", "Name"], + M02_SkRateDefault: ["공구손료", "Tool cost"], + M02_SkRateRate: ["율", "Rate"], + M02_SkRateBasis: ["기준", "Base"], + M02_SkRatePart: ["금액이 드는 칸", "Cost item"], + M02_SkRateLines: ["기준 줄", "Base lines"], + M02_SkRateBasisLines: ["줄 고르기", "Pick lines"], + M02_SkRateGroupLabor: ["인력 줄 합", "Labor lines"], + M02_SkRateGroupMat: ["재료 줄 합", "Material lines"], + M02_SkRateGroupMach: ["장비 줄 합", "Equipment lines"], + M02_SkRateCostLabor: ["노무비 전체", "All labor cost"], + M02_SkRateCostMat: ["재료비 전체", "All material cost"], + M02_SkRateCostExp: ["경비 전체", "All expense"], + M02_SkRatePartLabor: ["노무비", "Labor"], + M02_SkRatePartMat: ["재료비", "Material"], + M02_SkRatePartExp: ["경비", "Expense"], + M02_SkRateOk: ["넣기", "Add"], + M02_SkRateClose: ["닫기", "Close"], + M02_SkRateNoLines: ["고를 줄 없음 — 먼저 항목 추가", "No lines yet — add items first"], + M02_SkRateNeedLine: ["줄을 하나 이상 고름", "Pick at least one line"], + M02_SkRateBadRate: ["율은 0 이상 수", "Rate must be a number ≥ 0"], + M02_SkRateNeedName: ["이름이 빔", "Name is empty"], } as const;