diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts index 3b6323be5..48a4063b1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts @@ -12,6 +12,7 @@ import { createBadge } from "@ui/ui_template_badge"; import { createButton } from "@ui/ui_template_button"; +import { createCheckbox } from "@ui/ui_template_checkbox"; import { el } from "@ui/ui_template_elements"; import { createInputField } from "@ui/ui_template_input"; import { t as L } from "@ui/ui_template_locale"; @@ -181,6 +182,8 @@ export interface SkCoverOptions { onPrice: (row: CoverRow, value: number | null) => void; onDrop: (row: CoverRow) => void; onRestore: (cid: string) => void; + /** 더한 재료 줄 별도계상 켬 · 끔 */ + onApart: (row: CoverRow, on: boolean) => void; } const ORDER = ["인력", "재료", "경비", "하위"]; @@ -243,7 +246,18 @@ function rowCells(r: CoverRow, o: SkCoverOptions): (string | Node)[] { ...(r.covered.qty || r.covered.price ? [createBadge(o.tag, "warning")] : []), ...(r.added !== null ? [createBadge(L("M02_SangeunAddedTag"), "accent")] : []), ...(r.pct ? [createBadge(L("M02_SangeunPct"), "neutral")] : []), - ...(r.apart ? [createBadge(L("M02_SangeunApart"), "neutral")] : []), + ...(can && r.apartPick + ? [ + createCheckbox(L("M02_SangeunApart"), { + chip: true, + size: 22, + checked: r.apart, + onChange: (on) => o.onApart(r, on), + }).root, + ] + : r.apart + ? [createBadge(L("M02_SangeunApart"), "neutral")] + : []), ...(r.revised ? [createBadge(L("M02_SangeunRevised"), "danger")] : []), ]; const drop = createButton({ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index f375e4bd3..274ab9d44 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -33,6 +33,7 @@ import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick"; import { dropLine, restoreLine, + setApart, rowsOf, setPrice, setQty, @@ -231,6 +232,7 @@ export function mountSangeunDetail( onPrice: (r, v) => (setPrice(doc, r, v), touch()), onDrop: (r) => (dropLine(doc, r), touch()), onRestore: (cid) => (restoreLine(doc, cid), touch()), + onApart: (r, on) => (setApart(doc, r, on), touch()), }); const row = base?.logic ?? standIn(); const est = el("div", { diff --git a/M02_MasterTemplete/M02_Sangeun_Doc.py b/M02_MasterTemplete/M02_Sangeun_Doc.py index f9c04e50d..0a0427eb3 100644 --- a/M02_MasterTemplete/M02_Sangeun_Doc.py +++ b/M02_MasterTemplete/M02_Sangeun_Doc.py @@ -6,7 +6,8 @@ 입력값 {이름: 값}, 열린입력 [이름](설계 때 정함 · 그 SK 품 덮개 = 배율만), 켜쌓기율?, 고름 {cid: MT키}, 자재표시 {cid: {세 칸}}, 덮개 {cid: {qty | 배율, labor, material, expense, 원값}}, - 더한줄 [{종류: 인력|재료|기계, 요소, 이름, 단위, 수량, 비목?, 단가?} + 더한줄 [{종류: 인력|재료|기계, 요소, 이름, 단위, 수량, 비목?, 단가?, + 별도계상?(재료 · 없음 = 계 안)} | {종류: 로직, 로직, 입력값, 수량} | {참조 | 고정, 이름?, 단위?, 수량}], 뺀줄 [cid], 바탕판} 줄 정체 cid = `kind:키#n`(B09 702 덮개 꼴) — kind = labor · material · machine · logic · pct(덧줄) · @@ -179,6 +180,8 @@ def _added(raw: Any, where: str, whole: Any) -> dict: head = {"종류": raw["종류"], "요소": key, "비목": part} if raw.get("단가") is not None: head["단가"] = plain(_num(raw["단가"], f"{where} 단가")) + if raw["종류"] == "재료" and raw.get("별도계상") is True: + head["별도계상"] = True # 더한 재료 기본 = 계 안 · 이 칸만 별도계상(`patched` 자재표시) name = name or str((got or {}).get("이름") or key) unit = unit or str((got or {}).get("단위") or "") else: @@ -360,6 +363,10 @@ def patched( new_items.append(item) cids.append(cid) for k, one in enumerate(doc.get("더한줄") or []): + if one.get("종류") == "재료": # 더한 재료 = 기본 계 안(직접계산) · `별도계상` 칸만 밖 + out_marks[str(len(new_items))] = { + "별도계상": "별도계상" if one.get("별도계상") else "직접계산" + } new_items.append(_add_item(one)) cids.append(f"add:#{k}") cids += [c for at, c in enumerate(ids) if at >= len(items) and c not in drop] diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts index 13a288be4..22cd4cfff 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts @@ -107,6 +107,8 @@ export interface CoverRow { /** 저장 때 적힌 원값이 지금 바탕 값과 다름(M01 개정) */ revised: boolean; priceEdit: boolean; + /** 더한 재료 줄 — 별도계상 고름(기본 계 안) */ + apartPick: boolean; } /* ── 순수 셈 ── */ @@ -183,6 +185,7 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR ((old.qty != null && !near(old.qty, base.qty)) || (old.단가 != null && !near(old.단가, base.단가))), priceEdit: !pct && (added === null || ["인력", "재료", "기계"].includes(String(one?.종류))), + apartPick: one?.종류 === "재료", }; }; for (const l of ans.줄) rows.push(row(l.cid, l, partsOf(l), l)); @@ -263,6 +266,14 @@ export function dropLine(doc: SkDoc, r: CoverRow): void { } } +/** 더한 재료 줄 별도계상 — 켬 = 계 밖(재료 목록만 · 서버 자재표시) · 끔 = 칸 지움(기본 계 안) */ +export function setApart(doc: SkDoc, r: CoverRow, on: boolean): void { + const one = r.added === null ? undefined : doc.더한줄?.[r.added]; + if (!one || one.종류 !== "재료") return; + if (on) one.별도계상 = true; + else delete one.별도계상; +} + /** 뺀 줄 되살림 */ export function restoreLine(doc: SkDoc, cid: string): void { doc.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== cid); diff --git a/resources/tester/test_715_sangeun_engine.py b/resources/tester/test_715_sangeun_engine.py index c3b6847f3..4961a6020 100644 --- a/resources/tester/test_715_sangeun_engine.py +++ b/resources/tester/test_715_sangeun_engine.py @@ -84,3 +84,17 @@ def test_added_ref_and_fixed_lines(world): assert got["기준"]["계"] == pytest.approx(unit * 2 + 5000, abs=1) expense = labor.run_line(_whole(), PCT, {})["기준"]["경비"] assert got["기준"]["경비"] == pytest.approx(5000 + 2 * float(expense), abs=1) + + +def test_added_material_counts_unless_apart(): + """사용자가 더한 재료 줄 = 기본 계 안(재료비 · 계에 더함) · 줄 「별도계상」 칸만 계 밖(재료 목록만).""" + one = {"종류": "재료", "요소": "MT000010", "수량": 3, "단가": 1200} + bare = _run(바탕={"로직": PCT}) + inside = _run(바탕={"로직": PCT}, 더한줄=[one]) + assert inside["기준"]["재료비"] == pytest.approx(bare["기준"]["재료비"] + 3600) + assert inside["기준"]["계"] == pytest.approx(bare["기준"]["계"] + 3600, abs=1) + assert any(x["cid"] == "add:#0" for x in inside["줄"]) + apart = _run(바탕={"로직": PCT}, 더한줄=[{**one, "별도계상": True}]) + assert apart["기준"]["계"] == pytest.approx(bare["기준"]["계"]) + assert not any(x["cid"] == "add:#0" for x in apart["줄"]) + assert any(m["금액"] == pytest.approx(3600) for m in apart["재료"]) diff --git a/resources/tester/test_717_2b_sangeun_detail.py b/resources/tester/test_717_2b_sangeun_detail.py index 1318a36ab..685c70b5f 100644 --- a/resources/tester/test_717_2b_sangeun_detail.py +++ b/resources/tester/test_717_2b_sangeun_detail.py @@ -74,6 +74,11 @@ out.split = doc.덮개["logic:G#0"]; setQty(doc, by("add:#0"), 3, false); setPrice(doc, by("add:#0"), 350); out.added = doc.더한줄[0]; +out.pick = [by("add:#0").apartPick, by("labor:A#0").apartPick]; +setApart(doc, by("add:#0"), true); +out.apartOn = doc.더한줄[0].별도계상 ?? null; +setApart(doc, by("add:#0"), false); +out.apartOff = doc.더한줄[0].별도계상 ?? null; const opened = { 덮개: {} }; setQty(opened, by("labor:A#0"), 1.5, true); out.scale = opened.덮개["labor:A#0"]; @@ -105,6 +110,9 @@ console.log(JSON.stringify(out)); assert got["split"]["labor"] == 60 and got["split"]["material"] == 30 assert got["split"]["expense"] == 10 assert got["added"]["수량"] == 3 and got["added"]["단가"] == 350 + # 더한 재료 줄만 별도계상 고름 · 켬 = 칸 true · 끔 = 칸 지움(기본 계 안) + assert got["pick"] == [True, False] + assert got["apartOn"] is True and got["apartOff"] is None assert got["scale"]["배율"] == 1.5 and "qty" not in got["scale"] assert got["scaleBack"] is None assert got["drop"] == [["labor:A#0"], None]