Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
# Conflicts: # ui_template/ui_template_locale_m2.ts
This commit is contained in:
@@ -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,35 @@ 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@labor` 블록 그 차례 줄(`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": # `3@labor` = 그 줄 그 비목 몫
|
||||
out: list[float | None] = []
|
||||
for one in dict.fromkeys(name.split(",")):
|
||||
at, _, part = one.partition("@")
|
||||
if not at.isdigit() or int(at) >= len(src):
|
||||
continue
|
||||
cid = src[int(at)].get("cid")
|
||||
out += [
|
||||
ln["amounts"].get(part) if part else total(list(ln["amounts"].values()))
|
||||
for ln in lines
|
||||
if ln.get("cid") == cid
|
||||
]
|
||||
return total(out)
|
||||
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 +169,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 {}
|
||||
|
||||
@@ -88,12 +88,31 @@ 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") {
|
||||
// `3@labor` = 그 줄 그 비목 몫
|
||||
const out: Num[] = [];
|
||||
for (const one of new Set(name.split(","))) {
|
||||
const [at, part] = one.split("@");
|
||||
if (!/^\d+$/.test(at) || Number(at) >= src.length) continue;
|
||||
const cid = src[Number(at)].cid;
|
||||
for (const ln of lines)
|
||||
if (ln.cid === cid) out.push(part ? (ln.amounts[part] ?? null) : whole(ln));
|
||||
}
|
||||
return total(out);
|
||||
}
|
||||
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 +152,7 @@ function block(b: Row, books: Row, sums: Record<string, Row>, 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"] } : {};
|
||||
|
||||
@@ -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(_row_of(k, where) for k in basis if _row_of(k, 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",
|
||||
@@ -529,6 +546,16 @@ def fixed_line(fx: dict, qty: Any) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _row_of(key: str, where: dict) -> str:
|
||||
"""율 기준 열쇠 → 블록 차례 글(`열쇠@비목` = `n@labor`) · 없는 열쇠 = ""."""
|
||||
from M02_MasterTemplete.M02_Labor_Fixed import split_key
|
||||
|
||||
line, cost = split_key(key)
|
||||
if line not in where:
|
||||
return ""
|
||||
return f"{where[line]}@{PART[cost]}" if cost else str(where[line])
|
||||
|
||||
|
||||
def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]:
|
||||
"""자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가."""
|
||||
row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {}
|
||||
|
||||
@@ -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("단위"),
|
||||
|
||||
@@ -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,56 @@ 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):
|
||||
out = Decimal(0)
|
||||
for one in dict.fromkeys(basis):
|
||||
key, cost = split_key(one)
|
||||
for r in rows:
|
||||
if r.get("키") == key:
|
||||
out += (
|
||||
Decimal(str((r["비목"] or {}).get(cost) or 0)) if cost else _all(r["비목"])
|
||||
)
|
||||
return out
|
||||
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 split_key(one: str) -> tuple[str, str]:
|
||||
"""목록 열쇠 → (줄 열쇠, 비목 | "") — `add:#3@노무비` = 그 줄 노무비 몫."""
|
||||
key, at, cost = one.rpartition("@")
|
||||
return (key, cost) if at and cost in COSTS else (one, "")
|
||||
|
||||
|
||||
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 +191,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))
|
||||
|
||||
@@ -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,40 @@ 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 = []
|
||||
for x in rate["기준"]:
|
||||
line, cost = fixed.split_key(x) # `열쇠@비목` = 비목 꼬리 그대로
|
||||
if got := move(line):
|
||||
keys.append(f"{got}@{cost}" if cost else got)
|
||||
fx = {**one["고정"], "비율": {**rate, "기준": keys}}
|
||||
return {**one, "고정": fx}
|
||||
|
||||
|
||||
# ── SC ────────────────────────────────────────────────────────────────
|
||||
def sk_line(y: dict, one: dict, whole: Any, tag: dict) -> dict:
|
||||
"""펼친 SK → 일위대가 로직 줄 하나(+ 패치) — 옛 참조 줄 `one` 의 별칭 · 메모 · 수량 · 패치
|
||||
|
||||
@@ -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<string, string | number>;
|
||||
/** 비율 줄(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<Record<CostKey, number | null>> | null;
|
||||
율?: boolean;
|
||||
}
|
||||
const GROUPS: Record<"노임" | "자재" | "기계", RateGroup> = {
|
||||
노임: "인력",
|
||||
자재: "재료",
|
||||
기계: "장비",
|
||||
};
|
||||
|
||||
export interface CalcLine {
|
||||
/** 참조 줄은 서버 답에 없음(빈 글로 다룸) */
|
||||
@@ -234,27 +251,82 @@ 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);
|
||||
|
||||
/** 목록 열쇠 → [줄 열쇠, 비목 | null] — `add:#3@노무비` = 그 줄 노무비 몫(짝 `M02_Labor_Fixed.split_key`) */
|
||||
export function splitKey(one: string): [string, CostKey | null] {
|
||||
const at = one.lastIndexOf("@");
|
||||
const cost = one.slice(at + 1);
|
||||
return at >= 0 && (COSTS as readonly string[]).includes(cost)
|
||||
? [one.slice(0, at), cost as CostKey]
|
||||
: [one, null];
|
||||
}
|
||||
|
||||
/** 율 줄 밑(778-4 · 짝 `M02_Labor_Fixed.rate_base`) — 비목 = 율 줄 뺀 그 비목 합 ·
|
||||
* 묶음 = 율 줄 뺀 그 묶음 줄 셋 합 · 목록 = 그 열쇠 줄 셋 합(앞 율 줄 됨 · `열쇠@비목` = 그 비목 몫) */
|
||||
export function rateBase(basis: RateBasis, rows: RateRow[]): number {
|
||||
if (Array.isArray(basis)) {
|
||||
let sum = 0;
|
||||
for (const one of new Set(basis)) {
|
||||
const [key, cost] = splitKey(one);
|
||||
for (const r of rows) if (r.키 === key) sum += cost ? (r.비목?.[cost] ?? 0) : all(r.비목);
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,8 @@ 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` 수량 그대로)
|
||||
mark |= {"율": Decimal(str(one["수량"])), "규격": _rate_text(one["고정"]["비율"])}
|
||||
lines.append(
|
||||
{**line, "cid": cid, **mark, **({"원값": mine["원값"]} if "원값" in mine else {})}
|
||||
)
|
||||
@@ -380,6 +389,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 fixed.split_key(k)[0] 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("단위")}
|
||||
|
||||
@@ -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,28 @@ 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` · 자기 · 뒤 율 줄 못 가리킴(율 위 율 =
|
||||
앞 율 줄만 · 순환 없음)."""
|
||||
from M02_MasterTemplete.M02_Labor_Fixed import split_key
|
||||
|
||||
for at, one in enumerate(adds):
|
||||
basis = ((one.get("고정") or {}).get("비율") or {}).get("기준")
|
||||
for key in basis if isinstance(basis, list) else []:
|
||||
key = split_key(key)[0] # `열쇠@비목` = 그 줄 그 비목 몫
|
||||
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):
|
||||
@@ -414,6 +439,8 @@ def _add_item(one: dict) -> dict:
|
||||
qty = text(one["수량"])
|
||||
if "참조" in one or "고정" in one:
|
||||
kept = {k: one[k] for k in ("참조", "고정") if k in one}
|
||||
if is_rate(one): # 율 줄(778-4) = 그 비목 한 칸(엔진 묶음 · 보기 상자 = 비목)
|
||||
kept["비목"] = one["고정"]["비율"]["비목"]
|
||||
return {**head, "종류": "로직", "요소": None, "수량": qty, **kept, "값묶음": None}
|
||||
if one.get("종류") == "로직":
|
||||
args = "".join(
|
||||
|
||||
@@ -78,6 +78,8 @@ export interface SkLine {
|
||||
묶음?: string | null;
|
||||
cid: string;
|
||||
율?: number;
|
||||
/** % 줄 밑 이름(「노무비의 %」 · 율 줄 「인력의 %」) */
|
||||
규격?: string;
|
||||
/** 못 셈 줄(725) — 금액 없음 · 계에 안 듦 */
|
||||
까닭?: string;
|
||||
/** 둘째 기초(727-2) — 세부 줄 · 뺀 세부 · 그 기초 지문 · 원문번호 */
|
||||
@@ -179,6 +181,13 @@ function flatIds(ans: SkAnswer): string[] {
|
||||
return ans.정체.flatMap((c) => by.get(c)?.펼침?.map((x) => x.cid) ?? [c]);
|
||||
}
|
||||
|
||||
/** 율 더한줄(778-4) 비율 — `add:#k` 의 고정 `비율` · 아니면 undefined */
|
||||
const rateOf = (doc: SkDoc, cid: string): { 비목: string } | undefined => {
|
||||
const hit = ADD_RE.exec(cid);
|
||||
const one = hit ? doc.더한줄?.[Number(hit[1])] : undefined;
|
||||
return (one?.고정 as { 비율?: { 비목: string } } | undefined)?.비율;
|
||||
};
|
||||
|
||||
/** 기초 수 — 로직 바탕 · 더한줄 로직 */
|
||||
const baseCount = (doc: SkDoc): number =>
|
||||
(doc.바탕?.로직 ? 1 : 0) + (doc.더한줄 ?? []).filter((x) => x.종류 === "로직").length;
|
||||
@@ -257,7 +266,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:") || !!rateOf(doc, cid); // 덧줄 · 율 줄(778-4)
|
||||
const keys = Object.keys(parts);
|
||||
const shown = added === null && !at ? (was.get(cid) ?? null) : null;
|
||||
const base = at
|
||||
@@ -514,6 +523,11 @@ export function hoView(
|
||||
extra.push([cid, { ...((known.get(cid) as HoExtra) ?? { 이름: name, 식: "" }), ...tag }]);
|
||||
continue;
|
||||
}
|
||||
const rate = rateOf(doc, cid); // 율 더한줄(778-4) = 비목 상자 % 줄(식 = 「인력의 %」)
|
||||
if (rate) {
|
||||
extra.push([cid, { 이름: name, 식: String(l?.규격 ?? ""), 비목: rate.비목, ...tag }]);
|
||||
continue;
|
||||
}
|
||||
if (at) {
|
||||
const kind = KIND_OF[at.cid.split(":")[0]] ?? "로직";
|
||||
const qty = String(l?.율 ?? l?.원문?.수량 ?? l?.수량 ?? ""); // 세부 품 = M01 원문(744-1)
|
||||
@@ -544,7 +558,8 @@ export function hoView(
|
||||
const hit = ADD_RE.exec(cid);
|
||||
const a = hit ? doc.더한줄?.[Number(hit[1])] : undefined;
|
||||
const part = PART_OF[String(a?.비목 ?? "")];
|
||||
if (a) covers[i] = { qty: a.수량 / t, ...(a.단가 != null && part ? { [part]: a.단가 } : {}) };
|
||||
const own = rateOf(doc, cid) ? 1 : t; // 율 = 그대로
|
||||
if (a) covers[i] = { qty: a.수량 / own, ...(a.단가 != null && part ? { [part]: a.단가 } : {}) };
|
||||
const at = subOf(cid);
|
||||
const owner = at ? doc.더한줄?.[at.k] : undefined;
|
||||
const c = hit ? undefined : at ? owner?.덮개?.[at.cid] : doc.덮개?.[cid];
|
||||
|
||||
@@ -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,18 @@ function moveKeys(doc: SkDoc, mode: { drop: number } | { push: number }): void {
|
||||
}
|
||||
(doc as Record<string, unknown>)[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}${hit[2]}` : key];
|
||||
return [j >= mode.push ? `add:#${j + 1}${hit[2]}` : key];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 더한줄 k 를 빼고 기억에 둠 — 이름은 되살림 띠 글(서버가 저장 때 다시 적음) */
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""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_줄_목록_비목_몫_절_노무비():
|
||||
adds = [*LABOR, _rate(["add:#0", "add:#2@노무비"], 6, name="제잡비")]
|
||||
by = {x["cid"]: x for x in _run(_doc(adds))["줄"]}
|
||||
want = Decimal(str(by["add:#0"]["금액"])) + Decimal(str(by["add:#2"]["비목"]["노무비"]))
|
||||
assert by["add:#3"]["금액"] == pytest.approx(float(want * 6 / 100), abs=0.1)
|
||||
with pytest.raises(sd.DocError, match="자기"):
|
||||
sd.clean(_doc([LABOR[0], _rate(["add:#1@노무비"])]), None, _whole())
|
||||
|
||||
|
||||
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"],
|
||||
["a", "b@노무비"],
|
||||
["b@경비", "c@재료비", "c@재료비"],
|
||||
["a@없는칸"],
|
||||
], # fmt: skip
|
||||
)
|
||||
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"
|
||||
rate["비율"]["기준"] = ["x", "y@노무비", "z"]
|
||||
assert source.fixed_line(rate, 2, {"y": 4, "x": 1})["base"] == "rows:1,4@labor"
|
||||
|
||||
|
||||
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},
|
||||
{"kind": "pct", "name": "절 노무", "part": "expense", "base": "rows:0,2@labor", "qty": 6},
|
||||
],
|
||||
} # 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)
|
||||
labor_part = got["굴착기"]["amounts"]["labor"]
|
||||
assert got["절 노무"]["amounts"]["expense"] == pytest.approx(
|
||||
(86034.0 + labor_part) * 0.06, 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"], # 되살림 = 뒤 줄 밂
|
||||
]
|
||||
@@ -362,4 +362,28 @@ export const ui_locales_m2 = {
|
||||
M02_ToneDanger: ["못 이음", "Not linked"],
|
||||
M02_ToneMuted: ["밖", "Outside"],
|
||||
M02_ToneMine: ["내가 고름", "My pick"],
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user