diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts index 5593f0a1f..babe52457 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts @@ -54,6 +54,8 @@ export interface HoRowLike { bundle: string; merge: boolean; amount: number | null; + /** 합계 밖 줄(엔진 `합밖`) — 보이되 소계에 안 더함 */ + out?: boolean; } export interface HoMerged { idx: number; @@ -80,7 +82,7 @@ export function boxesOf(list: T[], always: string[] = []): const merged = new Map>(); for (const e of list) { const b = take(e.bundle); - b.소계 += e.amount ?? 0; + if (!e.out) b.소계 += e.amount ?? 0; if (!e.merge) { b.줄.push(e); continue; @@ -112,6 +114,7 @@ const TEXT = { Per: ["{n} — {v} 원/{u}", "{n} — {v} KRW/{u}"], Edited: ["시험 고침", "Trial edit"], EditedM02: ["변경 항목", "Override"], + Out: ["합 밖", "Outside total"], } as const satisfies Record; const th = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; const boxName = (b: string): string => (b in TEXT ? th(b as keyof typeof TEXT) : b); @@ -333,6 +336,7 @@ function entries(ctx: HoContext): Entry[] { price: shown, linePrice: shown, amount: line ? line.금액 : null, + out: !!line?.합밖, warn: line?.단위경고, extra: false, line, @@ -360,6 +364,7 @@ function entries(ctx: HoContext): Entry[] { price: "", linePrice: "", amount: line ? line.금액 : null, + out: !!line?.합밖, extra: true, line, logic: false, @@ -463,6 +468,7 @@ function nameCell(e: Entry, label: string, tag: string): HTMLElement { : el("div", { text: label }), ...(e.share && label === e.name ? [createBadge(`${e.share} ${tl("Share")}`, "accent")] : []), ...(e.spec ? [el("div", { className: "ui-text ui-text--muted", text: e.spec })] : []), + ...(e.out ? [createBadge(th("Out"), "warning")] : []), ...(e.warn ? [createBadge(`${tx("Calc_UnitWarn")} · ${e.warn}`, "warning")] : []), ...(tag ? [createBadge(tag, "accent")] : []), ], @@ -671,7 +677,9 @@ export function buildHo(ctx: HoContext): HTMLElement[] { const sums = Object.fromEntries( FOOT_KEYS.map((g) => [ g, - rounddown(all.filter((e) => e.group === g).reduce((s, e) => s + (e.amount ?? 0), 0)), + rounddown( + all.filter((e) => e.group === g && !e.out).reduce((s, e) => s + (e.amount ?? 0), 0), + ), ]), ) as Record; // 751-3 비목 소계 = 줄 합 1 원 버림 · 계 = 셋 합(서버와 같은 값) const total = FOOT_KEYS.reduce((s, g) => s + sums[g], 0); diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 6830edfb8..042407623 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -109,6 +109,8 @@ export interface CalcLine { 원문?: { 비목?: Record }; /** (753-1) 옵션으로 빠진 줄 — 화면은 안 그림 */ 옵션꺼짐?: boolean; + /** (796-2d) 엔진이 합계에서 빼는 줄(합밖 · 제외 단계) — 보이되 소계 · 합계에 안 더함 */ + 합밖?: boolean; } /** % 덧줄 표기(149-4) — 율 있으면 수량 = 율 · 단위 % · 이름 뒤 규격 괄호 · 금액은 그대로(M01 · M02 한 벌) */ diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts index d8452ecfb..0eb520245 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts @@ -93,6 +93,8 @@ export interface SkLine { 품목?: CalcLine["품목"]; /** (753-1) 옵션으로 빠진 줄 — 화면은 안 그림 */ 옵션꺼짐?: boolean; + /** (796-2d) 합계에 안 드는 줄 — 엔진 표지 */ + 합밖?: boolean; } export interface SkMat { diff --git a/resources/master_data/scripts/master_cut.py b/resources/master_data/scripts/master_cut.py index 6bf98c2bd..90aa33161 100644 --- a/resources/master_data/scripts/master_cut.py +++ b/resources/master_data/scripts/master_cut.py @@ -127,7 +127,8 @@ def engine(master, ref: str, got: dict, depth: int = 0) -> dict: total[k] += 0 if n in skip else v or 0 if n >= len(items) and money is not None: more = {"단가": money} - lines.append({**line, "비목": cut, "금액": money, **more}) + out = {"합밖": True} if n in skip else {} # 합계에 안 드는 줄 표지(796-2d · 보이기만) + lines.append({**line, "비목": cut, "금액": money, **more, **out}) if not new: costs = {k: mf.cut(logic, down(v, 0), "계") for k, v in total.items()} # 중간 = 덧줄이 본 값(버린 줄 합 · 덧줄 값 · `줄`) — 시험 계산 중간 칸 diff --git a/resources/tester/test_796_2d_detail_subtotal.py b/resources/tester/test_796_2d_detail_subtotal.py new file mode 100644 index 000000000..99539d043 --- /dev/null +++ b/resources/tester/test_796_2d_detail_subtotal.py @@ -0,0 +1,95 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""796-2d 단가산출 상세 탭 소계 · 합계 = 엔진 합계 — 합밖 · 제외 단계 줄은 보이되 소계에 안 더함. + +- 서버: 엔진 줄 표지 `합밖`(`master_cut.engine` — `master_formula_sum.skipped` 한 판정) · + 표지 줄 빼고 더하면 SK 셈 답 `기준` · 표지 없는 견본은 줄 표지 없음(금액 전후 같음) +- 화면: `boxesOf` 소계가 `out` 줄을 안 더함 · 맨 아래 합계 · 줄 `합밖` → `out` 이음(원문 확인) +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from decimal import Decimal as D +from pathlib import Path + +import pytest + +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Imported as imp +from resources.tester.test_795_1_sk_copy import _calc, _doc, _imported_row +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +ROOT = Path(__file__).resolve().parents[2] +HO = ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Ho.ts" +COSTS = ("노무비", "재료비", "경비") + + +def _answer(world, name, outside: bool): + me = world["personal"] + row = _imported_row() + if outside: + row["호표"][1] = {**row["호표"][1], "합밖": True} + with store.at(me): + key = imp.create(row, None, name)["이름"] + return _calc(me, _doc(me, key)) + + +def _sum(lines, skip_out: bool) -> dict: + return { + k: sum( + ( + D(str((x.get("비목") or {}).get(k) or 0)) + for x in lines + if not (skip_out and x.get("합밖")) + ), + D(0), + ) + for k in COSTS + } + + +def test_outside_line_tagged_and_sum_equals_engine(world): + ans = _answer(world, "sub2임시 합밖상세", True) + out = [x for x in ans["줄"] if x.get("합밖")] + assert len(out) == 1 and sum(D(str(v or 0)) for v in out[0]["비목"].values()) > 0 + kept = _sum(ans["줄"], True) + for k in COSTS: # 표지 줄 빼고 더해 1 원 버림 = 엔진 합계 + assert int(kept[k]) == int(D(str(ans["기준"][k]))), k + assert sum(_sum(ans["줄"], False).values()) > sum(kept.values()) # 옛 소계(전부 더함)는 큼 + + +def test_plain_sample_has_no_tag(world): + ans = _answer(world, "sub2임시 합밖없음", False) + assert not any("합밖" in x for x in ans["줄"]) + kept = _sum(ans["줄"], True) + assert all(int(kept[k]) == int(D(str(ans["기준"][k]))) for k in COSTS) + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_box_subtotal_skips_outside(): + src = HO.read_text(encoding="utf-8") + pure = src[src.index("/* ── 순수 셈 ── */") : src.index("/* ── 순수 셈 끝 ── */")] + body = """ +const e = (idx, bundle, amount, out) => ({ idx, bundle, merge: false, amount, out }); +const got = boxesOf([e(0, "인력", 10, false), e(1, "인력", 7, true), e(2, "재료", 5)], ["경비"]); +console.log(JSON.stringify(got.map((b) => [b.묶음, b.소계, b.줄.length]))); +""" + run = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", pure + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert run.returncode == 0, run.stderr + assert json.loads(run.stdout) == [["인력", 10, 2], ["재료", 5, 1], ["경비", 0, 0]] # 줄은 보임 + + +def test_screen_wiring(): + src = HO.read_text(encoding="utf-8") + assert src.count("out: !!line?.합밖,") == 2 # 호표 줄 · 덧줄 + assert "e.group === g && !e.out" in src # 맨 아래 합계 + assert 'createBadge(th("Out")' in src and len(src.splitlines()) <= 700 + assert "합밖?: boolean" in ( + ROOT / "M01_MasterData" / "M01_MasterData_UI_Logic_Api.ts" + ).read_text(encoding="utf-8")