feat(b09): 산근 텍스트 수식 칸 · 일위대가화 글 · % 덧줄 표기(155-5)
- 산근 블록마다 서버가 글 지음(sub4 render_sangeun_text · B09 굳힌 단가) — head · 줄마다 text · foot(계) - 단가산출근거 탭 = 엑셀 꼴(공종 · 산출내역 · 합계 · 노무비 · 재료비 · 경비 · 비고) · 제목 · 총계 굵게 - 중기 = 노 · 재 · 경 세 줄(서버 글 줄 수와 같게) - % 덧줄 = 규격 「노무비의 %」 · 수량 = 율 · 단위 %(sub3 pct_extras) - 산근목록 안내 글 = 단가산출 줄 · 사전조합 안 줄 · 일위대가화 줄의 하위 로직(「단가승격」 글 지움) - 시험: 145-1 화면 글 대조 새 갈래 · test_155_b09_sources · test_155_b09_tabs 덧붙임 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
@@ -20,6 +20,9 @@
|
||||
+ 「연결 없음」.
|
||||
조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(만들지 않음 · 목록 안내 한 줄).
|
||||
대가(145-2) · 내역서(145-3)가 읽는 길 = `sangeun_key` + `price_of`.
|
||||
텍스트 수식(155-5) = `B09_Estimation_Sangeun_Text`(같은 B09 단가) — `head`(머리 · 입력 · 중간) ·
|
||||
줄 `text`(줄마다 · 중기 = 노 · 재 · 경 세 줄) · `foot`(계) · % 덧줄 = 규격 「노무비의 %」 ·
|
||||
수량 = 율 · 단위 %(149-4 `pct_extras`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -35,6 +38,7 @@ from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, roundd
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import (
|
||||
PICKS_FILE,
|
||||
VALUES_FILE,
|
||||
@@ -297,6 +301,7 @@ class Solver:
|
||||
return self._stop(code, name, str(e) or type(e).__name__)
|
||||
sums = block_totals(lines)
|
||||
why = next((ln["why"] for ln in lines if ln.get("why")), None)
|
||||
head, foot = self._text(whole, src, times, lines)
|
||||
return {
|
||||
"logic": code,
|
||||
"name": name,
|
||||
@@ -306,12 +311,47 @@ class Solver:
|
||||
"lines": lines,
|
||||
**sums,
|
||||
"why": why,
|
||||
"head": head,
|
||||
"foot": foot,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _stop(code: str, name: str, why: str) -> dict[str, Any]:
|
||||
return {"logic": code, "name": name, "spec": "", "unit": "", "source": "", "lines": [],
|
||||
**dict.fromkeys(PARTS), "sum": None, "why": why} # fmt: skip
|
||||
**dict.fromkeys(PARTS), "sum": None, "why": why,
|
||||
"head": [], "foot": ""} # fmt: skip
|
||||
|
||||
def price_of(self, key: str) -> Any:
|
||||
"""글 짓기 단가(`Sangeun_Text` 꼴) — 노무 · 자재 = 수 · 중기 · 산근 = 비목 단가 묶음."""
|
||||
if key in self.prices.labor:
|
||||
return self.prices.labor[key]
|
||||
if key in self.prices.material:
|
||||
return self.prices.material[key]
|
||||
if key in self.prices.machine:
|
||||
return {p: num(self.prices.machine[key].get(p)) for p in PARTS}
|
||||
block = self.blocks.get(key)
|
||||
return {k: block.get(k) for k in (*PARTS, "sum", "no")} if block else None
|
||||
|
||||
def _text(self, whole, src, times, lines) -> tuple[list[str], str]:
|
||||
"""텍스트 수식(155-5) → (머리 줄들, 계) · 줄마다 `text` 를 얹음(중기 = 세 줄 · 그 밖 한 줄).
|
||||
줄 수가 안 맞으면 모두 머리에(값 칸과 어긋나게 얹지 않음)."""
|
||||
from B09_Estimation.B09_Estimation_Sangeun_Text import render_sangeun_text
|
||||
|
||||
try:
|
||||
text = render_sangeun_text(whole, src["code"], src["given"], self.price_of,
|
||||
times=times, rate=src.get("rate"),
|
||||
picks=src.get("picks")) # fmt: skip
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
|
||||
return [], ""
|
||||
counts = [3 if ln["kind"] == "machine" and not ln.get("why") else 1 for ln in lines]
|
||||
start = len(text) - 1 - sum(counts)
|
||||
if start < 1:
|
||||
return text, ""
|
||||
at = start
|
||||
for ln, n in zip(lines, counts):
|
||||
ln["text"] = text[at : at + n]
|
||||
at += n
|
||||
return text[:start], text[-1]
|
||||
|
||||
def _blank(self, item: dict, why: str) -> dict[str, Any]:
|
||||
part = COST_PART.get(item.get("비목"), "material")
|
||||
@@ -382,12 +422,19 @@ class Solver:
|
||||
"price": price, **{p: _money(price[p], q) for p in PARTS}, "why": why} # fmt: skip
|
||||
|
||||
def _extras(self, whole, row, env, lines) -> None:
|
||||
"""% 줄(덧줄) — 엔진 식 그대로 · 밑수 = 그때까지 B09 비목 합 · 줄 금액 = 이름별."""
|
||||
for extra in row.get("덧줄", []):
|
||||
"""% 줄(덧줄) — 엔진 식 그대로 · 밑수 = 그때까지 B09 비목 합 · 줄 금액 = 이름별 ·
|
||||
「X * Y / 100」 덧줄 = 규격 「노무비의 %」 · 수량 = 율 · 단위 %(149-4 `pct_extras`)."""
|
||||
heads = [{} for _ in row.get("호표", [])]
|
||||
pcts = labor_doc.pct_extras(
|
||||
whole, row, heads + [{} for _ in row.get("덧줄", [])], dict(env)
|
||||
)
|
||||
for extra, pct in zip(row.get("덧줄", []), pcts[len(heads) :]):
|
||||
sums = {name: total([ln[p] for ln in lines]) for name, p in COST_PART.items()}
|
||||
part = COST_PART.get(extra.get("비목"), "material")
|
||||
line = {"kind": "extra", "name": extra["이름"], "spec": extra["식"], "qty": 1.0,
|
||||
"unit": "식", "ref": None, **dict.fromkeys(PARTS, 0.0)} # fmt: skip
|
||||
if "율" in pct:
|
||||
line.update(spec=pct.get("규격") or extra["식"], qty=float(pct["율"]), unit="%")
|
||||
if any(v is None for v in sums.values()):
|
||||
lines.append({**line, part: None, "price": {}, "why": "밑수 빈칸"})
|
||||
continue
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* (서버 단독 · 이 탭엔 조작 없음 · [저장] · [단가 갱신] 때 서버가 다시 셈).
|
||||
*
|
||||
* 목록표 = 산근 호표 한 줄(합계 · 노무비 · 재료비 · 경비 · 1 단위) · 줄 누름 = 그 블록
|
||||
* 풀이표 = 블록 머리(제 n 호표) · 줄(노무 · 중기 노 · 재 · 경 · 자재 · 하위 산근 · % 줄) · 총계
|
||||
* 줄 누름 = 단가를 낸 탭 · 줄(노무비목록표 · 중기목록표 · 자재단가대비표 · 하위 산근)
|
||||
* 풀이표(155-5 · 엑셀 단가산출근거 꼴) = 공종 · 산출내역(서버가 지은 텍스트 수식 한 칸) · 합계 ·
|
||||
* 노무비 · 재료비 · 경비 — 머리(제 n 호표 · 출처 · 입력 · 중간 Q) → 줄(중기 = 노 · 재 · 경
|
||||
* 세 줄) → 총계(계) · 줄 누름 = 단가를 낸 탭 · 줄(노무비목록표 · 중기목록표 · 자재단가대비표 ·
|
||||
* 하위 산근)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
@@ -50,7 +52,7 @@ export const buildSgList: TabBuild = ({ sheets }): TabTable => {
|
||||
rows,
|
||||
frozen: 2,
|
||||
hint:
|
||||
"산근 = 연결된 구조물 조합 사본의 「단가승격」 로직 줄 · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" +
|
||||
"산근 = 연결된 구조물 조합 사본의 단가산출 로직 줄 · 사전조합 안 줄 · 일위대가화 줄의 하위 로직(노무 · 자재 · 중기 줄은 각 목록표) · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" +
|
||||
notes(sheets),
|
||||
link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null),
|
||||
};
|
||||
@@ -63,79 +65,85 @@ const LINE_TAB: Record<string, TabId> = {
|
||||
sg: "sg",
|
||||
};
|
||||
|
||||
/** 공종 칸(엑셀 A 열) — 줄 꼴 이름 · 하위 산근 = 「산근 n호표」 */
|
||||
const KIND_NAME: Record<string, string> = { labor: "노무", machine: "중기", material: "자재" };
|
||||
|
||||
export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
|
||||
const rows: DataRow[] = [];
|
||||
const links: Array<Link | null> = [];
|
||||
const push = (row: DataRow, link: Link | null = null): void => {
|
||||
const titles = new Set<number>();
|
||||
const push = (row: DataRow, link: Link | null = null, title = false): void => {
|
||||
if (title) titles.add(rows.length);
|
||||
rows.push(row);
|
||||
links.push(link);
|
||||
};
|
||||
for (const b of (sheets.sg as Row[]) || []) {
|
||||
push({
|
||||
key: b.key,
|
||||
name: `제 ${b.no} 호표 ${b.name}`,
|
||||
spec: b.spec,
|
||||
unit: b.unit,
|
||||
sum: b.sum,
|
||||
labor: b.labor,
|
||||
material: b.material,
|
||||
expense: b.expense,
|
||||
});
|
||||
if (b.source) push({ key: `${b.key}|출처`, name: ` ${b.source}` });
|
||||
const blocks = (sheets.sg as Row[]) || [];
|
||||
const noOf: Record<string, number> = Object.fromEntries(blocks.map((b) => [b.key, b.no]));
|
||||
for (const b of blocks) {
|
||||
const head: string[] = b.head?.length ? b.head : [`${b.name} / ${b.unit}`];
|
||||
push({ key: b.key, kind: `제 ${b.no} 호표`, text: head[0], note: b.spec }, null, true);
|
||||
head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: ` ${line}` }));
|
||||
(b.lines as Row[]).forEach((ln, n) => {
|
||||
const link = ln.ref?.key ? { tab: LINE_TAB[ln.ref.tab] ?? "compare", key: ln.ref.key } : null;
|
||||
// 중기 = 노 · 재 · 경 세 줄(엑셀 꼴) · 그 밖 = 한 줄
|
||||
const parts = ln.kind === "machine" ? PARTS.filter((p) => ln.price?.[p] !== 0) : [null];
|
||||
for (const part of parts) {
|
||||
const kind =
|
||||
ln.kind === "sangeun" && noOf[ln.ref?.key]
|
||||
? `산근 ${noOf[ln.ref.key]}호표`
|
||||
: (KIND_NAME[ln.kind] ?? "");
|
||||
// 중기 = 노 · 재 · 경 세 줄(엑셀 꼴 · 서버 글 줄과 같은 수) · 그 밖 = 한 줄
|
||||
const parts = ln.kind === "machine" && !ln.why ? PARTS : [null];
|
||||
const texts: string[] = ln.text || [];
|
||||
parts.forEach((part, k) => {
|
||||
const at = (p: string): number | null => (part === null || part === p ? ln[p] : 0);
|
||||
const price = part ? ln.price?.[part] : (Object.values(ln.price || {})[0] ?? null);
|
||||
const fallback = `${ln.name}${part ? ` ${PART_NAME[part]}` : ""} · ${ln.qty ?? ""} ${ln.unit ?? ""}`;
|
||||
push(
|
||||
{
|
||||
key: `${b.key}|${n}${part ? `|${part}` : ""}`,
|
||||
name: ` ${ln.name}${part ? ` ${PART_NAME[part]}` : ""}`,
|
||||
spec: ln.why ? `${ln.spec ? `${ln.spec} · ` : ""}${ln.why}` : ln.spec,
|
||||
qty: ln.qty,
|
||||
unit: ln.unit,
|
||||
price: price as number | null,
|
||||
kind: k === 0 ? kind : "",
|
||||
text: ` ${texts[k] ?? fallback}`,
|
||||
sum: PARTS.some((p) => at(p) === null)
|
||||
? null
|
||||
: PARTS.reduce((s, p) => s + (at(p) as number), 0),
|
||||
labor: at("labor"),
|
||||
material: at("material"),
|
||||
expense: at("expense"),
|
||||
note: ln.why ?? "",
|
||||
},
|
||||
link,
|
||||
);
|
||||
}
|
||||
});
|
||||
push({
|
||||
key: `${b.key}|총계`,
|
||||
name: " 총 계",
|
||||
spec: b.why ?? "",
|
||||
sum: b.sum,
|
||||
labor: b.labor,
|
||||
material: b.material,
|
||||
expense: b.expense,
|
||||
});
|
||||
});
|
||||
push(
|
||||
{
|
||||
key: `${b.key}|총계`,
|
||||
kind: "총 계",
|
||||
text: b.foot || "",
|
||||
sum: b.sum,
|
||||
labor: b.labor,
|
||||
material: b.material,
|
||||
expense: b.expense,
|
||||
note: b.why ?? "",
|
||||
},
|
||||
null,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (!rows.length) return ((sheets.sg_notes as string[]) || []).join(" · ") || "산근 없음";
|
||||
return {
|
||||
columns: [
|
||||
text("name", "공종 · 산출내역", 260),
|
||||
text("spec", "규격 · 까닭", 180),
|
||||
qty("qty", "수량"),
|
||||
text("unit", "단위", 56),
|
||||
money("price", "단가", undefined, 1),
|
||||
text("kind", "공종", 90),
|
||||
text("text", "산출내역", 520),
|
||||
money("sum", "합계", undefined, 1),
|
||||
money("labor", "노무비", undefined, 1),
|
||||
money("material", "재료비", undefined, 1),
|
||||
money("expense", "경비", undefined, 1),
|
||||
text("note", "비고 · 까닭", 180),
|
||||
],
|
||||
rows,
|
||||
frozen: 1,
|
||||
hint:
|
||||
"수량 = 로직 품(1 단위 · 작업량 Q = ROUND 2) · 단가 = 굳힌 B09 단가 · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0 · 자재 적용 단가를 고치면 [저장] 뒤 반영 · 줄 누름 = 단가를 낸 탭" +
|
||||
"산출내역 = 값이 든 식(B09 굳힌 단가 * 수량 = 금액 · 작업량 Q = ROUND 2) · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0 · 자재 적용 단가를 고치면 [저장] 뒤 반영 · 줄 누름 = 단가를 낸 탭" +
|
||||
notes(sheets),
|
||||
link: (i: number) => links[i] ?? null,
|
||||
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -183,4 +183,5 @@ def test_화면_탭_둘_서버_산근_그대로():
|
||||
assert "gaps: [...base.gaps, ...(server.sg_gaps || [])" in shell # 뒤에 대가(145-2) gaps 이음
|
||||
tabs = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Sangeun.ts").read_text("utf-8")
|
||||
assert 'link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null),' in tabs
|
||||
assert "PARTS.filter((p) => ln.price?.[p] !== 0)" in tabs # 중기 = 노 · 재 · 경 세 줄
|
||||
# 155-5 — 중기 = 노 · 재 · 경 세 줄(서버 글 줄 수와 같게 · 값 0 줄도 둠)
|
||||
assert 'const parts = ln.kind === "machine" && !ln.why ? PARTS : [null];' in tabs
|
||||
|
||||
@@ -107,3 +107,27 @@ def test_155_3_비고_산근_n호표_거울():
|
||||
ln.get("ref_no") for b in py["daega_blocks"] for ln in b["lines"] if ln["kind"] == "sangeun"
|
||||
]
|
||||
assert notes and notes[0] == "산근 1호표" and all(n and n.startswith("산근 ") for n in notes)
|
||||
|
||||
|
||||
def test_155_5_산근_글_줄마다_덧줄_퍼센트_표기(tmp_path):
|
||||
lines = [{"로직": "GF000143", "수량": {"손": 1}}, {"로직": PILE, "수량": {"손": 20}}]
|
||||
root = t145._project(tmp_path / "p", [(100, "SS000901")])
|
||||
lines = [{"로직": "GF000143", "수량": {"손": 1}}, {"로직": PILE, "수량": {"손": 20}}]
|
||||
t145._snap(root, "pipe@100.0", "SS000901", lines, {})
|
||||
inputs, sheets = _sheets_of(root)
|
||||
crane, pile = sheets["sg"]
|
||||
assert crane["head"][:2] == ["케이블 크레인 운반대 설치 / 1기", "산림품셈 10-8-2 적용"]
|
||||
extra = crane["lines"][-1]
|
||||
assert (extra["kind"], extra["spec"], extra["qty"], extra["unit"]) == (
|
||||
"extra", "재료비의 %", 20.0, "%",
|
||||
) # fmt: skip
|
||||
assert extra["text"][0].startswith("기타비용 (재료비의 %) : ")
|
||||
for b in (crane, pile):
|
||||
for ln in b["lines"]:
|
||||
money = sum(ln[p] or 0 for p in sg.PARTS)
|
||||
assert ln["text"][-1].endswith(f"= {money:,.1f}") or ln["kind"] == "machine"
|
||||
assert b["foot"] == (
|
||||
f"계 : 노무비 {b['labor']:,.0f} + 재료비 {b['material']:,.0f}"
|
||||
f" + 경비 {b['expense']:,.0f} = {b['sum']:,.0f}"
|
||||
)
|
||||
assert " / 10 = " in pile["lines"][0]["text"][0] # 배 10 로직 꼬리
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""155 B09 탭 화면 꼴 — 일위대가표 줄 꼴(155-2).
|
||||
"""155 B09 탭 화면 꼴 — 일위대가표 줄 꼴(155-2) · 단가산출근거 텍스트 수식 칸(155-5).
|
||||
|
||||
`B09_Estimation_UI_Tabs.ts` + `B09_Estimation_UI_Tabs_Labor.ts` 를 Node(타입 지움)로 돌려
|
||||
견본 블록 하나(머리 · 품셈 인용 · 줄 둘 · 합계)의 표 줄 글 · 줄 꼴을 판정.
|
||||
@@ -34,10 +34,10 @@ BLOCK = {
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def _node(js_body):
|
||||
def _node(js_body, tab="UI_Tabs_Labor"):
|
||||
src = "".join(
|
||||
IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8"))
|
||||
for n in ("UI_Tabs", "UI_Tabs_Labor")
|
||||
for n in ("UI_Tabs", tab)
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
@@ -62,3 +62,43 @@ def test_155_2_일위대가표_제목_굵게_하위_ㄴ():
|
||||
" 합 계",
|
||||
]
|
||||
assert out["cls"] == ["is-title", None, None, None, "is-title"]
|
||||
|
||||
|
||||
SG = {
|
||||
"key": "SG:A", "no": 1, "name": "뒷채움", "unit": "m3", "spec": "규격=150",
|
||||
"head": ["뒷채움 / m3", "산림품셈 13-3-2 적용", "규격 : 150 mm"],
|
||||
"lines": [
|
||||
{"kind": "labor", "name": "보통인부", "ref": {"tab": "labor", "key": "LB2"}, "why": None,
|
||||
"labor": 2979.8, "material": 0.0, "expense": 0.0,
|
||||
"text": ["보통인부 : 165,545 * 0.018 = 2,979.8"]},
|
||||
{"kind": "machine", "name": "굴착기", "ref": {"tab": "machine", "key": "EQ1"}, "why": None,
|
||||
"labor": 3899.0, "material": 538.6, "expense": 912.8,
|
||||
"text": ["굴착기 노무비 : 55,700 * 0.07 = 3,899.0", "굴착기 재료비 : 7,695 * 0.07 = 538.6",
|
||||
"굴착기 경비 : 13,041 * 0.07 = 912.8"]},
|
||||
],
|
||||
"labor": 6878, "material": 538, "expense": 912, "sum": 8328, "why": None,
|
||||
"foot": "계 : 노무비 6,878 + 재료비 538 + 경비 912 = 8,328",
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def test_155_5_단가산출근거_엑셀_꼴_칸():
|
||||
out = _node(
|
||||
f"\nconst t = buildSg({{ sheets: {{ sg: [{json.dumps(SG, ensure_ascii=False)}] }} }});"
|
||||
"\nconsole.log(JSON.stringify({ rows: t.rows, cols: t.columns.map((c) => c.key),"
|
||||
" cls: t.rows.map((_, i) => t.rowClass(i) ?? null) }));",
|
||||
"UI_Tabs_Sangeun",
|
||||
)
|
||||
assert out["cols"] == ["kind", "text", "sum", "labor", "material", "expense", "note"]
|
||||
rows = out["rows"]
|
||||
assert [(r.get("kind"), r["text"]) for r in rows] == [
|
||||
("제 1 호표", "뒷채움 / m3"),
|
||||
(None, " 산림품셈 13-3-2 적용"),
|
||||
(None, " 규격 : 150 mm"),
|
||||
("노무", " 보통인부 : 165,545 * 0.018 = 2,979.8"),
|
||||
("중기", " 굴착기 노무비 : 55,700 * 0.07 = 3,899.0"),
|
||||
("", " 굴착기 재료비 : 7,695 * 0.07 = 538.6"),
|
||||
("", " 굴착기 경비 : 13,041 * 0.07 = 912.8"),
|
||||
("총 계", "계 : 노무비 6,878 + 재료비 538 + 경비 912 = 8,328"),
|
||||
]
|
||||
assert [r.get("sum") for r in rows[3:7]] == [2979.8, 3899.0, 538.6, 912.8]
|
||||
assert out["cls"] == ["is-title"] + [None] * 6 + ["is-title"]
|
||||
|
||||
Reference in New Issue
Block a user