feat(m02): % 덧줄 표기 — 율 · 규격 덧칸 · 하위 행 · M01 시험계산(149-4)

- 서버 `M02_Labor_Doc.pct_extras` = 식 「X * Y / 100」 덧줄에 율(Y) · 규격(노무비의 % · 인건비의 % · 줄 이름의 %) · `run_line` 에서 부름
- M01 `/calc` 도 같은 도우미(늦은 부름)
- 화면 `pctView` 한 벌 = 이름 (규격) · 수량 = 율 · 단위 % — M02 하위 행 · M01 시험계산 표
- 금액 · 엔진 줄(식 · 1) 그대로 · 금액 식 덧줄(기계손료 등) 그대로 · 마스터 안 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
This commit is contained in:
2026-10-09 19:11:36 +09:00
co-authored by Claude Opus 5.5
parent 8ad8516717
commit 651dc4b818
6 changed files with 151 additions and 11 deletions
+17 -1
View File
@@ -234,7 +234,23 @@ def post_calc(body: CalcBody) -> dict:
if got is not None:
return got
with store.cm.mf.mp.choosing(body.고름):
return _call(store.calc, body.key, body.inputs, body.row, body.file)
got = _call(store.calc, body.key, body.inputs, body.row, body.file)
return _pct(got, body)
def _pct(got: dict, body: CalcBody) -> dict:
"""% 덧줄 표기(149-4) — 줄에 율 · 규격 덧칸(M02 일위대가 하위 행과 같은 도우미 · 금액 그대로)."""
if not got.get("lines"):
return got
from M02_MasterTemplete import M02_Labor_Doc as labor_doc # M02 가 M01 을 부름 — 늦게
whole = store.loaded()[1]
try:
logic = body.row or whole.logic(body.key)
except store.cm.mf.FormulaError:
return got
lines = labor_doc.pct_extras(whole, logic, got["lines"], got.get("middle") or {})
return {**got, "lines": lines}
@router.post("/text")
@@ -92,7 +92,18 @@ export interface CalcLine {
단위경고?: string;
/** 재료 줄이 잡은 품목 — 키 · 이름 · 규격 · 단위 · 값 칸 */
품목?: { 키: string; 이름?: string; 규격?: string; 단위?: string; 값칸?: string };
/** % 덧줄(149-4 · 서버 `M02_Labor_Doc.pct_extras`) — 율 · 밑수 글(「노무비의 %」 · 복합 = 빈 글) */
율?: number;
규격?: string;
}
/** % 덧줄 표기(149-4) — 율 있으면 수량 = 율 · 단위 % · 이름 뒤 규격 괄호 · 금액은 그대로(M01 · M02 한 벌) */
export const pctView = (
x: Pick<CalcLine, "이름" | "수량" | "단위" | "율" | "규격">,
): { name: string; qty: number; unit: string } =>
x.율 == null
? { name: x.이름, qty: x.수량, unit: x.단위 }
: { name: x.규격 ? `${x.이름} (${x.규격})` : x.이름, qty: x.율, unit: "%" };
export type CalcAnswer =
| {
ok: true;
@@ -7,6 +7,7 @@
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import {
fetchAuto,
pctView,
runCalc,
runText,
searchElements,
@@ -226,8 +227,8 @@ export function answerView(answer: CalcAnswer, draft: boolean, money: boolean):
children: [
el("span", {
text: line.출처
? `${line.이름} (${line.출처.replace(/[A-Z]{2}\d{6}\./g, "")})`
: line.이름,
? `${pctView(line).name} (${line.출처.replace(/[A-Z]{2}\d{6}\./g, "")})`
: pctView(line).name,
}),
...(line.단위경고
? [
@@ -239,7 +240,7 @@ export function answerView(answer: CalcAnswer, draft: boolean, money: boolean):
: []),
],
}),
el("td", { text: `${formatNumber(line.수량)} ${line.단위}` }),
el("td", { text: `${formatNumber(pctView(line).qty)} ${pctView(line).unit}` }),
el("td", { className: "m01-logic__money", text: formatMoney(line.금액) }),
],
}),
+50 -1
View File
@@ -18,6 +18,8 @@
고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와
같은 품목 두 번은 [저장] 때 400.
승격(134-1) = 로직 줄 「단가승격」 표시(`true` 만 · 없음 = 단가산출) · 금액 무관 · B09 호표 몫.
% 덧줄(149-4) = 식 「X * Y / 100」 덧줄에 덧칸 `율`(Y) · `규격`(「노무비의 %」 등 · `pct_extras`) — 표기만 ·
단위 · 수량 · 금액 그대로(「식 · 1」) · 화면이 율 · % 로 그림 · M01 시험계산 표도 이 함수.
"""
from __future__ import annotations
@@ -442,7 +444,54 @@ def run_line(
# 「줄」 = 그 로직 재료 · 노무 · 경비 줄(줄 누름 상세 · 50-4)
money = {k: got[k] for k in (*COSTS, "계")}
mats = logic_mats(whole, logic, got["줄"], picks)
return {"기준": money, "줄": got["줄"], "재료": mats, "까닭": None}
lines = pct_extras(whole, logic, got["줄"], got.get("중간") or {})
return {"기준": money, "줄": lines, "재료": mats, "까닭": None}
def pct_extras(whole: Any, logic: dict, lines: list[dict], env: dict) -> list[dict]:
"""% 덧줄(149-4) — 덧줄 식이 「X * Y / 100」 이면 그 엔진 줄(호표 수 뒤 같은 차례)에 덧칸
`율` = Y(엔진 마지막 env 로 셈) · `규격` = X 이름(「노무비의 %」 · 인력 줄 합 「인건비의 %」 ·
`줄.'이름'` · 중간값 「이름의 %」 · 그 밖 복합 = 빈 글). 다른 덧줄 · 흐름 로직 · 못 셈 = 그대로."""
if isinstance(logic.get("흐름"), dict):
return lines
out, head = list(lines), len(logic.get("호표") or [])
for n, add in enumerate(logic.get("덧줄") or []):
if head + n >= len(out):
break
try:
node = mf.parse(add.get("식") or "")
except mf.FormulaError:
continue
if not (
node[0] == "bin"
and node[1] == "/"
and node[3] == ("num", Decimal(100))
and node[2][0] == "bin"
and node[2][1] == "*"
):
continue # 금액 식(기계손료 등) · 조건부 % = 「식」 그대로
base, part = node[2][2], node[2][3]
try:
rate = mf.evaluate(part, dict(env), whole)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
continue
if isinstance(rate, Decimal):
out[head + n] = {**out[head + n], "율": rate, "규격": _pct_base(base)}
return out
def _pct_base(node: tuple) -> str:
"""% 덧줄 밑수 이름 — B09 대가(`B09_Estimation_Labor_Source._base_of`)와 같은 말."""
if node[0] == "name":
return f"{node[1]}의 %" # 노무비 · 재료비 · 경비 · 중간값 이름
if node[0] == "field" and node[1] == ("name", "줄"):
return f"{node[2]}의 %"
parts = list(mf.walk(node))
if any(x[0] == "ref" and str(x[1]).startswith("LB") for x in parts) and not any(
x[0] == "name" and x[1] in COSTS for x in parts
):
return "인건비의 %"
return ""
def logic_mats(whole: Any, logic: dict, lines: list[dict], picks: Any) -> list[dict[str, Any]]:
@@ -13,6 +13,7 @@
* ========================================================================== */
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
import { pctView } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import type {
@@ -278,12 +279,15 @@ function subRows(line: LaborCalcLine | undefined, wait: boolean, tools: boolean)
cost: x.기준 ? Object.fromEntries(COSTS.map((c) => [c, x.기준![c] * x.수량])) : null,
};
})
: (line?.줄 ?? []).map((x) => ({
name: x.출처 ? `${x.이름} (${x.출처.replace(/[A-Z]{2}\d{6}\./g, "")})` : x.이름,
qty: x.수량,
unit: x.단위,
cost: { ...x.비목, 계: x.금액 } as Record<string, number>,
}));
: (line?.줄 ?? []).map((x) => {
const shown = pctView(x); // % 덧줄 = 율 · % · 규격(149-4)
return {
name: x.출처 ? `${shown.name} (${x.출처.replace(/[A-Z]{2}\d{6}\./g, "")})` : shown.name,
qty: shown.qty,
unit: shown.unit,
cost: { ...x.비목, 계: x.금액 } as Record<string, number>,
};
});
const blank = (className: string): HTMLElement => el("td", { className, text: "" });
const none = wait ? "…" : (line?.까닭 ?? "—");
return (items.length ? items : [null]).map((x) =>
+59
View File
@@ -0,0 +1,59 @@
"""% 덧줄 표기(PLAN 149-4) — 식 「X * Y / 100」 덧줄 = 덧칸 율 · 규격 · 금액 · 단위 · 수량 그대로.
- M02 `run_line` → 큰돌쌓기 찰쌓기 제잡비 설치 9 · 안 함 3 · 「노무비의 %」
- 인력 줄 합 밑수 = 「인건비의 %」 · 호표 줄 밑수 = 「그 줄 이름의 %」 · 금액 식(기계손료) = 덧칸 없음
- M01 시험계산(`post_calc`) 도 같은 도우미 · 화면 두 곳은 `pctView` 한 벌
"""
from decimal import Decimal
from pathlib import Path
from M01_MasterData import M01_MasterData_Router as m01_router
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Doc as labor
ROOT = Path(__file__).resolve().parents[2]
def _extra(code: str, given: dict | None = None, name: str | None = None) -> dict:
got = labor.run_line(m01.loaded()[1], code, given or {})
assert got["까닭"] is None
lines = [x for x in got["줄"] if x.get("단위") == "식" and (name is None or x["이름"] == name)]
assert lines, code
return {**lines[-1], "_기준": got["기준"]}
def test_큰돌쌓기_찰쌓기_제잡비_율_설치_9_안함_3_금액_그대로():
for pipe, rate in (("설치", 9), ("설치 안 함", 3)):
x = _extra("GF000227", {"물빼기파이프": pipe}, "제잡비")
assert x["율"] == Decimal(rate) and x["규격"] == "노무비의 %"
assert x["단위"] == "식" and x["수량"] == 1 # 엔진 줄은 그대로 · 표기는 화면
labor_sum = x["_기준"]["노무비"]
assert abs(x["금액"] - labor_sum * rate / 100) < Decimal("0.01")
def test_인건비_호표줄_밑수_금액식은_덧칸_없음():
x = _extra("GC000198")
assert x["율"] == 2 and x["규격"] == "인건비의 %"
x = _extra("GF000002", name="잡품")
assert x["규격"] == "휘발유(체인톱)의 %" and x["율"] > 0
mach = _extra("GC000267", name="기계손료")
assert "율" not in mach and "규격" not in mach
mat = _extra("GC000267", name="잡재료비")
assert mat["규격"] == "재료비의 %"
def test_M01_시험계산도_율_규격():
given = {"물빼기파이프": "설치", "직경": "60", "지역": "전국평균", "보정작업": "해당 없음"}
got = m01_router.post_calc(m01_router.CalcBody(key="GF000227", inputs=given))
extra = [x for x in got["lines"] if x["이름"] == "제잡비"][0]
assert extra["율"] == 9 and extra["규격"] == "노무비의 %"
def test_화면_두_곳_pctView_한_벌():
api = (ROOT / "M01_MasterData/M01_MasterData_UI_Logic_Api.ts").read_text("utf-8")
assert "x.율 == null" in api and 'qty: x.율, unit: "%"' in api
rows = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts").read_text("utf-8")
assert "const shown = pctView(x);" in rows and "qty: shown.qty," in rows
calc = (ROOT / "M01_MasterData/M01_MasterData_UI_Logic_Calc.ts").read_text("utf-8")
assert "formatNumber(pctView(line).qty)" in calc