diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index ffec0daf9..cc0764578 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -133,7 +133,7 @@ class Builder: logic = whole.logic(code) except mf.FormulaError: return None - got = labor_doc.run_line(whole, code, given, rate, picks) + got = labor_doc.run_line(whole, code, given, rate, picks, raw=True) # B09 끝수는 제 단가로 if got.get("기준") is None: return None # 돈 아닌 로직 · 못 돈 로직 — 조합 줄이 까닭을 냄 items = logic.get("호표") or [] diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts index 3e7f83396..c7df158cc 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts @@ -7,8 +7,7 @@ * 어느 박스도 안 쓰는 입력(공통 옵션)도 모든 박스에 같이(737-3 · 한 값 · 어디서 바꿔도 모두 따라감) * 「계산식」 열 = 값 든 식(「268,908 × 0.12」 · 서버 `글`) + 표 · 요소 · 로직 알약 + 그 줄 입력 칩 · 변수 이름 식(`이름글`)은 마우스 올리면 * 맨 아래 = 한 줄 4 칸 — 비목 소계 셋(노무비 · 재료비 · 경비 — 장비 노무는 여기서 인력과 더해짐) + 계 · 화면(모달) 바닥에 붙음(737-1) - * 줄 누름 = 그 줄 자료 모달(`_LogicLab_Modal.ts`) · 고침 고리(`edit`) 있으면 모달 안 「그 자리 고침」(`_LogicLab_Cover.ts`) - * 값 = 서버 셈 그대로(박스 소계 · 비목 소계만 줄 금액 합) · 화면에서 다시 셈 안 함 + * 줄 누름 = 그 줄 자료 모달(`_LogicLab_Modal.ts`) · 고침 고리(`edit`) 있으면 모달 안 「그 자리 고침」(`_LogicLab_Cover.ts`) · 값 = 서버 셈 그대로(박스 소계 · 비목 소계만 줄 금액 합 1 원 버림 751-3) * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; @@ -30,7 +29,7 @@ import type { } from "./M01_MasterData_UI_Logic_Api"; import { pctView } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; -import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money"; +import { formatLineMoney, formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick"; import { coverForm, type CoverPart, type LineCover } from "./M01_MasterData_UI_LogicLab_Cover"; @@ -44,6 +43,7 @@ import { type PickCond, } from "./M01_MasterData_UI_LogicLab_Modal"; import { buildFoot, FOOT_KEYS, type FootKey } from "./M01_MasterData_UI_LogicLab_Foot"; +import { rounddown } from "../B09_Estimation/B09_Estimation_Calc_Base"; import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; @@ -428,7 +428,7 @@ const COLS: Column[] = [ { label: tl("Col_Amount"), width: "13%", align: "end" }, ]; -const money = (v: number | null): string => (v === null ? "" : formatMoney(v)); +const money = (v: number | null): string => (v === null ? "" : formatLineMoney(v)); // 줄 0.1 원(751-3) /** 계산식 칸 — 값 든 식(계산 뒤) · 계산 전 = 수량 식 · 알약 · 이름 식은 풍선(748-3 옵션 값 꼬리표 없음) */ function formulaCell(e: Entry, text = ""): HTMLElement { @@ -508,7 +508,7 @@ type Unfoldable = TextLine & { 펼침?: SheetUnfold | null }; function mergedRows(m: HoMerged, ctx: HoContext): HTMLTableRowElement[] { const first = m.몫[0]; const share = (cost: string): string => - formatMoney( + formatLineMoney( m.몫 .filter((x) => (x.share ?? COST_OF[x.group]) === cost) .reduce((s, x) => s + (x.amount ?? 0), 0), @@ -622,7 +622,7 @@ function boxCard( ); const sub = tableRow(COLS, [ { content: `${name} ${tl("Subtotal")}`, colSpan: 5 }, - calculated ? formatMoney(b.소계) : "—", + calculated ? formatMoney(rounddown(b.소계, 0)) : "—", // 751-3 소계 1 원 버림 ]); sub.classList.add("m01lab__subtotal"); sub.dataset.box = b.묶음; @@ -668,10 +668,10 @@ export function buildHo(ctx: HoContext): HTMLElement[] { const sums = Object.fromEntries( FOOT_KEYS.map((g) => [ g, - all.filter((e) => e.group === g).reduce((s, e) => s + (e.amount ?? 0), 0), + rounddown(all.filter((e) => e.group === g).reduce((s, e) => s + (e.amount ?? 0), 0)), ]), - ) as Record; - const total = all.reduce((s, e) => s + (e.amount ?? 0), 0); + ) as Record; // 751-3 비목 소계 = 줄 합 1 원 버림 · 계 = 셋 합(서버와 같은 값) + const total = FOOT_KEYS.reduce((s, g) => s + sums[g], 0); return [ ...(boxes.length || !common.length ? [] : [commonCard(optionRows(common, ctx))]), ...boxes.map((b) => boxCard(b, ctx, calculated, common)), diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index a8ae1d129..5b282de38 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -25,7 +25,7 @@ import { showItems, } from "./M01_MasterData_UI_Logic_CalcPick"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; -import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; +import { formatLineMoney, formatMoney } from "./M01_MasterData_UI_Logic_Money"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; import { createBadge } from "@ui/ui_template_badge"; import { createDivider } from "@ui/ui_template_divider"; @@ -278,7 +278,7 @@ export function answerView(answer: CalcAnswer, draft: boolean, money: boolean): ], }), el("td", { text: `${formatNumber(pctView(line).qty)} ${pctView(line).unit}` }), - el("td", { className: "m01-logic__money", text: formatMoney(line.금액) }), + el("td", { className: "m01-logic__money", text: formatLineMoney(line.금액) }), ], }), ); @@ -334,7 +334,7 @@ export function answerView(answer: CalcAnswer, draft: boolean, money: boolean): el("dd", { text: money && typeof v === "number" && (Math.abs(v) >= 1000 || MONEY_NAME.test(k)) - ? formatMoney(v) + ? formatLineMoney(v) // 751-3 중간 돈 = 버린 줄 합(0.1 원) : formatNumber(v), }), ]), diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 2ae60b5a7..f61547dc2 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -50,6 +50,8 @@ from M02_MasterTemplete import M02_Material_Own as own mf = m01.cm.mf mm = mf.mp.mm +import master_cut as mcut # noqa: E402 — 끝수(751-3 · 품셈 1-2-2) · scripts 길은 `m01` 이 둠 + COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비 MATERIAL_HEAD = "자재|" _MAT_COLS = ("코드", "이름", "규격", "단위") @@ -265,14 +267,9 @@ def drop_apart(got: dict[str, Any], shown: Any) -> dict[str, Any]: } if not out or not got.get("기준"): return got - base = {k: _number(v) or Decimal(0) for k, v in got["기준"].items()} - for n in out: - for k, v in (lines[n].get("비목") or {}).items(): - cut = _number(v) or Decimal(0) - base[k] = base.get(k, Decimal(0)) - cut - base["계"] -= cut - kept = [x for i, x in enumerate(lines) if i not in out] - return {**got, "기준": _json(base), "줄": kept} + kept = [x for i, x in enumerate(lines) if i not in out] # 기준 = 남은 줄 합(751-3 끝수) + got = mcut.plain({**got, "줄": kept}) + return {**got, "기준": _json(got["기준"])} def _mat_marks(value: Any, logic: dict) -> dict[str, dict[str, str]] | None: @@ -403,10 +400,8 @@ def _money(value: Any) -> dict[str, Decimal] | None: def reflect(base: dict[str, Decimal], qty: Decimal | None, times: Decimal) -> dict | None: - """반영 = 기준 × 수량 ÷ 배 — 수량이 없으면 None.""" - if qty is None: - return None - return {k: base[k] * qty / times for k in (*COSTS, "계")} + """반영 = 기준 × 수량 ÷ 배 — 비목마다 0.1 원 버림 · 계 = 셋 합(751-3) · 수량이 없으면 None.""" + return None if qty is None else mcut.row({k: base[k] * qty / times for k in COSTS}) def _sum(rows: list[dict | None]) -> dict[str, Decimal]: @@ -453,14 +448,14 @@ def settle(result: dict, doc: dict, given: dict[str, Any], col: Any = None) -> d for one in result.get("자재") or []: qty, source = _pick(mhands.get(one.get("코드")), one.get("알약"), have) price = _number(one.get("기준단가")) - cost = price * qty if price is not None and qty is not None else None + cost = mcut.down(price * qty, 1) if price is not None and qty is not None else None mats.append({**one, "수량": qty, "수량출처": source, "반영": cost}) if one.get("코드") in aside: continue bases.append({**zero, "재료비": price, "계": price} if price is not None else None) reflects.append({**zero, "재료비": cost, "계": cost} if cost is not None else None) fixed.ratios(lines, reflects, bases) # 비율 고정 줄(705-3) — 다른 줄 반영합 × 율 - return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)}) + return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": mcut.sums(reflects)}) def material_price(master: Any, code: str, unit: str | None) -> dict[str, Any]: @@ -545,15 +540,17 @@ def calc( def run_line( - whole: Any, code: str, given: Any, rate: Any = None, picks: Any = None + whole: Any, code: str, given: Any, rate: Any = None, picks: Any = None, raw: bool = False ) -> dict[str, Any]: """로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율 · 고른 재료 = 엔진 - `choosing`) — `{기준, 줄, 재료, 까닭}` · 돈 아닌 로직은 `{결과, 기준: None}`(반영 없음).""" + `choosing`) — `{기준, 줄, 재료, 까닭}` · 돈 아닌 로직은 `{결과, 기준: None}`(반영 없음) · + 끝수 = 품셈 1-2-2(`master_cut` · 751-3) · `raw` = 엔진 날 값(B09 = 제 단가로 버림).""" try: logic = whole.logic(code) auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None} with mf.mp.choosing(picks): - got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate) + got = mf.run(whole, code, {**auto, **m01._dec(given or {})}) + got = layered(got if raw else mcut.engine(whole, code, got), rate) except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: return {"기준": None, "까닭": str(e) or type(e).__name__} if "결과" in got: @@ -562,7 +559,8 @@ def run_line( money = {k: got[k] for k in (*COSTS, "계")} mats = logic_mats(whole, logic, got["줄"], picks) lines = pct_extras(whole, logic, got["줄"], got.get("중간") or {}) - return {"기준": money, "줄": lines, "재료": mats, "까닭": None} + out = {"기준": money, "줄": lines, "재료": mats, "까닭": None} + return out if raw or rate in (None, 100) else mcut.plain(out) # 켜쌓기율 곱 뒤 다시 버림 def pct_extras(whole: Any, logic: dict, lines: list[dict], env: dict) -> list[dict]: diff --git a/M02_MasterTemplete/M02_Labor_Expand.py b/M02_MasterTemplete/M02_Labor_Expand.py index a0180cf07..904b60a98 100644 --- a/M02_MasterTemplete/M02_Labor_Expand.py +++ b/M02_MasterTemplete/M02_Labor_Expand.py @@ -165,7 +165,7 @@ class Expand: inner[-1]["까닭"] = NO_QTY why = why or f"「{sub.get('이름')}」 {NO_QTY}" continue - rows.append({k: v * qty for k, v in sub["기준"].items()}) + rows.append(labor.mcut.row({k: v * qty for k, v in sub["기준"].items()})) for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 수량(이은 값) if labor.apart(mat): # 별도계상 = 안 셈 · 안 줄에도 없음(222-1) continue @@ -189,7 +189,7 @@ class Expand: inner[-1]["까닭"] = got["까닭"] or NO_QTY # 단가 까닭 먼저 why = why or f"「{name}」 {inner[-1]['까닭']}" continue - rows.append({k: v * qty for k, v in base.items()}) + 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("비율"): @@ -199,7 +199,7 @@ class Expand: "이름": doc.get("이름"), "단위": doc.get("단위"), "머리": head_of(doc), - "기준": None if why else labor._sum(rows), + "기준": None if why else labor.mcut.sums(rows), # 줄 0.1 원 · 합 1 원 버림(751-3) "까닭": why, "안": inner, } diff --git a/M02_MasterTemplete/M02_Labor_Fixed.py b/M02_MasterTemplete/M02_Labor_Fixed.py index 3f87a65e3..9ff01ecd8 100644 --- a/M02_MasterTemplete/M02_Labor_Fixed.py +++ b/M02_MasterTemplete/M02_Labor_Fixed.py @@ -20,6 +20,8 @@ from typing import Any from M01_MasterData import M01_MasterData_Store as m01 mf = m01.cm.mf +import master_cut as mcut # noqa: E402 — 끝수(751-3) · scripts 길은 `m01` 이 둠 + mm = mf.mp.mm COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비 FIXED = "고정" @@ -135,10 +137,11 @@ def line(whole: Any, fx: dict) -> dict[str, Any]: def ratio_row(rate: dict, qty: Decimal | None, under: dict) -> tuple[dict, dict | None]: - """비율 줄 (기준, 반영) — 기준[비목] = 밑[기준] · 반영 = 기준 × 율 ÷ 100(수량 없음 = None).""" + """비율 줄 (기준, 반영) — 기준[비목] = 밑[기준] · 반영 = 기준 × 율 ÷ 100 · + 0.1 원 버림(751-3) · 수량 없음 = None.""" base = dict.fromkeys((*COSTS, "계"), Decimal(0)) base[rate["비목"]] = base["계"] = under[rate["기준"]] - return base, None if qty is None else {k: v * qty / 100 for k, v in base.items()} + 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: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index b30b6879c..9946ce8c6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -4,6 +4,7 @@ * 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. * 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에 * 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. + * 끝수(751-3 · 품셈 1-2-2) = B09 `rounddown` — 반영 · 자재 금액 비목마다 0.1 원 · 반영합 비목마다 1 원 버림. * 별도계상 자재(222-1 · 빈 칸 = 기본 별도계상) = 반영은 보이되 합에 안 더함(짝 `apart`). * 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`) · * 고정 줄(705-1)은 「고정|이름|규격|단위」(짝 `M02_Labor_Fixed.fixed_id`) · 기준 = 서버가 낸 줄 단가. @@ -11,8 +12,10 @@ import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import type { LogicMat } from "./M02_Labor_LogicMats_Model"; +import { rounddown } from "../B09_Estimation/B09_Estimation_Calc_Base"; export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const; +const COSTS = ["노무비", "재료비", "경비"] as const; export type Money = Record<(typeof MONEY_KEYS)[number], number>; export type QtySource = "손" | "칸" | "없음"; /** 문서의 수량 칸 — null = 면적산출 알약 · {손} = 손 입력 */ @@ -175,14 +178,22 @@ function pick( return [null, "없음"]; } -/** 반영 = 기준 × 수량 ÷ 배 — 수량이 없으면 null */ +/** 반영 = 기준 × 수량 ÷ 배 — 비목마다 0.1 원 버림 · 계 = 셋 합 · 수량이 없으면 null */ export function reflect(base: Money, qty: number | null, times: number): Money | null { if (qty === null) return null; const out = zero(); - for (const k of MONEY_KEYS) out[k] = (base[k] * qty) / times; + for (const k of COSTS) out[k] = rounddown((base[k] * qty) / times, 1); + out.계 = out.노무비 + out.재료비 + out.경비; return out; } +/** 반영합 끝수 — 비목마다 1 원 버림 · 계 = 셋 합(짝 `master_cut.sums`) */ +function cutSums(sum: Money): Money { + for (const k of COSTS) sum[k] = rounddown(sum[k], 0); + sum.계 = sum.노무비 + sum.재료비 + sum.경비; + return sum; +} + /** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 · * `col` = 규격 열(없으면 기본 · 704-4) */ export function settle( @@ -218,12 +229,13 @@ export function settle( const 자재 = result.자재.map((one) => { const [qty, source] = pick(mhands.get(one.코드), one.알약, have); const price = num(one.기준단가); - const cost = price !== null && qty !== null ? price * qty : null; + const cost = price !== null && qty !== null ? rounddown(price * qty, 1) : null; if (price !== null && !aside.has(one.코드)) add(base, { ...zero(), 재료비: price, 계: price }); if (cost !== null && !aside.has(one.코드)) add(done, { ...zero(), 재료비: cost, 계: cost }); return { ...one, 수량: qty, 수량출처: source, 반영: cost }; }); - return { 줄: ratioLines(줄, done, base), 자재, 기준합: base, 반영합: done }; + const lines = ratioLines(줄, done, base); + return { 줄: lines, 자재, 기준합: base, 반영합: cutSums(done) }; } /** 비율 고정 줄(705-3 · 짝 `M02_Labor_Fixed.ratios`) — 밑 = 비율 줄 뺀 반영합 · 기준[비목] = 밑[기준] · diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index ac137bf38..00610d9e0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -15,7 +15,7 @@ import { createButton, createSelectField, el } from "@ui/ui_template_elements"; import { HOPYO_TAG } from "@ui/ui_template_hopyo_grid"; import { pctView } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import { formatLineMoney, formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; import type { LaborCalc, @@ -157,8 +157,8 @@ export function moneyCell( wait: boolean, label: string, ): HTMLElement { - const show = (v: number | null | undefined): string => - wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); + const show = (v: number | null | undefined, f = formatMoney): string => + wait ? "…" : v === null || v === undefined ? "—" : f(v); return el("td", { className: "m02-labrow__money", attrs: { "data-label": label }, @@ -170,7 +170,7 @@ export function moneyCell( }), el("span", { className: "m02-labrow__applied", - text: show(applied), + text: show(applied, formatLineMoney), // 반영 = 줄 0.1 원(751-3) attrs: { title: tc("Labor_Applied") }, }), ], diff --git a/M02_MasterTemplete/M02_Sangeun_Views.py b/M02_MasterTemplete/M02_Sangeun_Views.py index bc7a8584f..04c9adb90 100644 --- a/M02_MasterTemplete/M02_Sangeun_Views.py +++ b/M02_MasterTemplete/M02_Sangeun_Views.py @@ -31,7 +31,7 @@ def text_of(over: Any, key: str, values: dict, picks: Any, ids: list, eng: list) try: with mf.mp.choosing(picks or None): text = msl.annotate(over, key, given, mtx.lines(over, key, given)) - mid = mf.run(over, key, given).get("중간") or {} + mid = labor.mcut.engine(over, key, mf.run(over, key, given)).get("중간") or {} # 751-3 except _ERRORS as e: return {"글": {"ok": False, "reason": str(e) or type(e).__name__}, "중간": {}} if text.get("ok") and "groups" in text and "결과" not in row: diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts index 1f8130c45..665688e37 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts @@ -16,6 +16,7 @@ import type { CalcLine, TextAnswer } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { bornOfCid } from "./M02_Born_Tag"; import type { SheetGroup } from "../../A00_Common/calc_sheet"; +import { rounddown } from "../../B09_Estimation/B09_Estimation_Calc_Base"; import { forgetAdd, goneName, reviveAdd, setQty, resetQty } from "./M02_Sangeun_Undo"; export type CoverPart = "labor" | "material" | "expense"; @@ -425,7 +426,9 @@ export function sheetGroups(rows: CoverRow[]): SheetGroup[] { } } const order = Object.keys(PART_OF); - return [...groups.values()].sort((a, b) => order.indexOf(a.비목) - order.indexOf(b.비목)); + return [...groups.values()] // 751-3 소계 = 줄 합 1 원 버림(품셈 1-2-2 · 서버 `master_cut` 와 같은 값) + .map((g) => ({ ...g, 소계: rounddown(g.소계 ?? 0, 0) })) + .sort((a, b) => order.indexOf(a.비목) - order.indexOf(b.비목)); } /* ── 공용 상세(Ho) 줄 ── */ diff --git a/resources/master_data/scripts/check_master.py b/resources/master_data/scripts/check_master.py index daf4d4019..fac26431a 100644 --- a/resources/master_data/scripts/check_master.py +++ b/resources/master_data/scripts/check_master.py @@ -173,8 +173,12 @@ def master(folder: Path = MASTER) -> mf.Master: def calc(files: dict[str, dict], ref: str, given: dict) -> dict: - """시험 계산(M01) — `ref` = 로직 키 · 멈추면 FormulaError.""" - return mf.run(mf.Master(files), ref, given) + """시험 계산(M01) — `ref` = 로직 키 · 멈추면 FormulaError · + 금액 끝수 = 품셈 1-2-2(`master_cut` · 751-3).""" + import master_cut as mcut # B09 끝수 함수를 부름 — 검사만 돌 때는 안 읽음 + + master = mf.Master(files) + return mcut.engine(master, ref, mf.run(master, ref, given)) # ── (1) 틀 ──────────────────────────────────────────────────────────── diff --git a/resources/master_data/scripts/master_combo.py b/resources/master_data/scripts/master_combo.py index 9f2f634f3..29ed36934 100644 --- a/resources/master_data/scripts/master_combo.py +++ b/resources/master_data/scripts/master_combo.py @@ -125,7 +125,9 @@ def run(master: mf.Master, row: dict, given: dict[str, dict] | None = None) -> d {k: target.get(k) for k in ("이름", "원문번호", "결과단위", "구분", "상세구분", "출처")} ) try: - got = mf.run(master, key, dict(given.get(key) or {})) + import master_cut as mcut # 끝수(751-3) = 시험 계산(`check_master.calc`)과 같음 + + got = mcut.engine(master, key, mf.run(master, key, dict(given.get(key) or {}))) except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: reason = str(e) or type(e).__name__ lines.append({**head, "ok": False, "까닭": reason}) diff --git a/resources/master_data/scripts/master_cut.py b/resources/master_data/scripts/master_cut.py new file mode 100644 index 000000000..dba59b443 --- /dev/null +++ b/resources/master_data/scripts/master_cut.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +"""M01 · M02 셈 끝수(PLAN 751-3) — 품셈 1-2-2 금액의 단위표준 +(일위대가 금액란 0.1 원 · 계금 1 원 · 미만버림). + +규칙 = B09 일위대가 · 산근과 같은 함수 `rounddown`(`B09_Estimation_Calc_Base` · 엑셀 ROUNDDOWN): + 줄 금액 = 비목마다 ROUNDDOWN(·, 1) · 금액 = 그 합 + 하위 로직 줄(값묶음 없음) = 하위 로직도 같은 끝수로 다시 셈한 노 · 재 · 경 × 수량 + 덧줄 = 버린 줄 합(노무비 · 재료비 · 경비 · `줄.'이름'`)으로 식을 다시 셈 → ROUNDDOWN 1 + 비목 합 = ROUNDDOWN(Σ 줄, 0)(+ 로직 `끝수` 묶음) · 계 = 셋 합 +엔진 줄 덧칸(단위 · 수량 · 단가 · 출처 · 까닭 · 묶음 · 옵션꺼짐 …) · 수량 · 품 자리는 그대로. +쓰는 곳 = M01 시험 계산(`check_master.calc`) · 읽는 식(`master_text.lines`) · M02 로직 줄 +(`M02_Labor_Doc.run_line` — 단가산출 · 일위대가 · 펼침이 모두 거침) · 일위대가 반영(`row` · `sums` · +TS 짝 `M02_MasterTemplete_Labor_Calc.ts` · 거울 시험 `test_m02_labor_calc_mirror.py`). +B09 는 제 단가로 다시 버리므로 엔진 날 값(`run_line(raw=True)`)을 받음. +ponytail: 흐름 로직 · 돈 결과 로직(결과 하나)은 끝수 없음 — 그 로직이 돈을 내면 여기에 더함. +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +import master_formula as mf + +_ROOT = str(Path(__file__).resolve().parents[3]) +if _ROOT not in sys.path: # 스크립트 · 시험 길 — 서버는 이미 뿌리에서 돎 + sys.path.insert(0, _ROOT) +from B09_Estimation.B09_Estimation_Calc_Base import rounddown # noqa: E402 + +COSTS = mf.COST_ITEMS +_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) + + +def down(value, places: int): + """ROUNDDOWN(value, places) — Decimal 로 · 빈 값 = None.""" + if value is None: + return None + return Decimal(str(rounddown(float(value), places))) + + +def _parts(split: dict) -> tuple[dict, Decimal | None]: + """줄 비목 → (비목마다 0.1 원 버림, 금액 = 그 합 · 모두 빈 값 = None).""" + cut = {k: down(v, 1) for k, v in split.items()} + kept = [v for v in cut.values() if v is not None] + return cut, (sum(kept, Decimal(0)) if kept else None) + + +def row(money: dict | None) -> dict | None: + """반영 한 줄 `{노무비, 재료비, 경비, 계}` — 비목마다 0.1 원 버림 · 계 = 셋 합.""" + if money is None: + return None + cut = {k: down(money.get(k) or 0, 1) for k in COSTS} + return {**cut, "계": sum(cut.values(), Decimal(0))} + + +def sums(rows: list) -> dict: + """비목 합 — 비목마다 ROUNDDOWN(Σ 줄, 0) · 계 = 셋 합(빈 줄 = 뺌).""" + cut = {k: down(sum((Decimal(str(r[k])) for r in rows if r), Decimal(0)), 0) for k in COSTS} + return {**cut, "계": sum(cut.values(), Decimal(0))} + + +def _sub(master, item: dict, env: dict, depth: int) -> dict | None: + """하위 로직 줄 노 · 재 · 경 — 같은 끝수로 다시 셈 · 로직 부르기가 아니거나 못 셈 = None.""" + if item.get("값묶음") is not None or item.get("종류") != "로직": + return None + try: + node = mf.parse(item["요소"]) + if node[0] != "logic": + return None + given = {k: mf.evaluate(v, env, master, depth) for k, v in node[2]} + ref = mf.fill(node[1], env) + got = engine(master, ref, mf.run(master, ref, given, depth + 1), depth + 1) + except _ERRORS: + return None + return None if "결과" in got else got + + +def engine(master, ref: str, got: dict, depth: int = 0) -> dict: + """엔진 답(`mf.run` 꼴 `{줄, 노무비, 재료비, 경비, 계, 중간}`) → 끝수 입힌 답(새 묶음).""" + if "결과" in got or "줄" not in got: + return got + logic = master.logic(ref) + if isinstance(logic.get("흐름"), dict): + return got + items, adds = logic.get("호표") or [], logic.get("덧줄") or [] + env = dict(got.get("중간") or {}) + total = dict.fromkeys(COSTS, Decimal(0)) + lines = [] + for n, line in enumerate(got["줄"]): + split, more = dict(line.get("비목") or {}), {} + if n == len(items): # 덧줄이 부르는 `줄.'이름'` = 버린 호표 줄 금액(엔진과 같이 한 번) + env["줄"] = {x["이름"]: x["금액"] for x in lines if x["이름"] and x["금액"] is not None} + if n < len(items): + sub = _sub(master, items[n], env, depth) if line.get("수량") is not None else None + if sub is not None: + qty, one = line["수량"], items[n].get("비목") + split = {one: qty * sub["계"]} if one else {k: qty * sub[k] for k in COSTS} + more = {"단가": sub["계"]} # 하위 로직 계(1 원 버림) = 이 줄 단가 + elif n - len(items) < len(adds) and line.get("금액") is not None: + add = adds[n - len(items)] + env.update(total) + try: + value = mf.evaluate(mf.parse(add["식"]), env, master, depth) + except _ERRORS: + value = line["금액"] + env[add["이름"]] = down(value, 1) # 뒤 덧줄 · 중간 칸 = 버린 값 + split = {add["비목"]: value} + cut, money = _parts(split) + for k, v in cut.items(): + total[k] += v or 0 + if n >= len(items) and money is not None: + more = {"단가": money} + lines.append({**line, "비목": cut, "금액": money, **more}) + costs = {k: mf.cut(logic, down(v, 0), "계") for k, v in total.items()} + # 중간 = 덧줄이 본 값(버린 줄 합 · 덧줄 값 · `줄`) — 시험 계산 중간 칸 + return {**got, "줄": lines, **costs, "계": sum(costs.values()), "중간": env} + + +def plain(got: dict) -> dict: + """로직 줄 답(`run_line` 꼴 `{기준, 줄, 재료}`)을 줄만 다시 버림 — + 켜쌓기율 곱 · 별도계상 뺀 뒤 · 기준 = 남은 줄 비목 합 ROUNDDOWN 0.""" + if not got.get("기준"): + return got + lines, total = [], dict.fromkeys(COSTS, Decimal(0)) + for line in got.get("줄") or []: + cut, money = _parts(dict(line.get("비목") or {})) + for k, v in cut.items(): + total[k] += Decimal(str(v or 0)) + lines.append({**line, "비목": cut, "금액": money}) + mats = [{**m, "금액": down(m.get("금액"), 1)} for m in got.get("재료") or []] + base = {k: down(v, 0) for k, v in total.items()} + return {**got, "줄": lines, "재료": mats, "기준": {**base, "계": sum(base.values())}} diff --git a/resources/master_data/scripts/master_text.py b/resources/master_data/scripts/master_text.py index b7f5bafa0..789f310f9 100644 --- a/resources/master_data/scripts/master_text.py +++ b/resources/master_data/scripts/master_text.py @@ -12,18 +12,22 @@ 계산이 안 되면(FormulaError 따위) 갈래 양쪽을 다 내보내고 깊이를 적어 접을 수 있게 함. 호표 줄 계산 자체가 막혀도(재료 단가 없음 따위) 이름 식·조각은 `row` 구조만으로 그리고 `글`·`금액`만 비움 — `만약() 과 무관` 인 그 60개 로직도 이름 식은 보이게. -끝수는 줄이 아니라 소계에 녹여 적음. 계산 자체는 `master_formula.run` 이 한 벌만 함. +금액 = 품셈 1-2-2 끝수 입힌 엔진 답(`master_cut.engine` · 줄 0.1 원 · 소계 1 원 버림) · 수 꼴 = +`common_util_money`(줄 금액 `fmt_line_money` · 단가 `fmt_price` · 수량 `fmt`). +계산은 `master_formula.run` 한 벌. """ from __future__ import annotations -from decimal import ROUND_HALF_UP, Decimal +from decimal import Decimal import master_copy as mc +import master_cut as mcut # 끝수(751-3) — 뿌리 길도 둠(아래 common_util) import master_formula as mf import master_text_item as mti -PLACES = Decimal("0.0001") # 화면 `formatNumber` 와 같음(소수 넷째 자리까지) +from common_util.common_util_money import fmt, fmt_line_money, fmt_money, fmt_price # noqa: F401 + PRICE_TAIL = {"인력": "노임", "기계": "손료"} # 단가 자리의 이름 꼬리(그 밖은 「단가」) ELEM_KEYS = ("LB", "EQ", "MT", "MP", "MO") # 요소 테이블 — 그 밖의 열쇠(QC · QF …)는 별칭 이름 ELEM_TAIL = {"LB": "노임", "EQ": "손료"} # 식 안 요소 키의 이름 꼬리(그 밖은 「단가」) @@ -33,34 +37,6 @@ _RIGHT_TIGHT = ("-", "/") # 오른쪽 짝은 같은 세기라도 괄호가 필 _CALC_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) # 계산 못 함 -def fmt(value) -> str: - """보이는 자릿수 — 소수 넷째 자리·유효숫자 4 자리 중 많은 쪽 · 뒤 0 뗌 · 천 단위 쉼표.""" - if not isinstance(value, Decimal): - return str(value) - places = max(4, 3 - value.adjusted()) if value else 4 # 0.00167 → 0.001667 - cut = value - if value.as_tuple().exponent < -places: - cut = value.quantize(Decimal(1).scaleb(-places)) - sign, digits = ("-", -cut) if cut < 0 else ("", cut) - whole, _, rest = f"{digits.normalize():f}".partition(".") - if "E" in whole or "e" in whole: # 아주 큰 수 — 지수 꼴을 펴서 씀 - whole, _, rest = f"{digits:f}".rstrip("0").rstrip(".").partition(".") - return sign + f"{int(whole):,}" + (f".{rest}" if rest else "") - - -def fmt_money(value) -> str: - """돈(단가 · 금액)은 원 단위 정수로 보임 — 글만 그렇고 계산 값은 소수 그대로.""" - if isinstance(value, Decimal): - value = value.quantize(Decimal(1), rounding=ROUND_HALF_UP) - return fmt(value) - - -def fmt_price(value) -> str: - """단가 — 정수로 보이되 200 원 미만은 반올림 오차가 커서 유효숫자로.""" - small = isinstance(value, Decimal) and abs(value) < 200 - return fmt(value) if small else fmt_money(value) - - # ── 식을 글로 — 값 꼴과 이름 꼴 ──────────────────────────────────────── def render(node, env: dict, master, depth: int = 0) -> str: """식 한 마디 → 값 꼴 글. 값이 정해지는 마디(요소 · 찾기 · 로직 · 이름)는 그 수로 바뀜.""" @@ -358,7 +334,7 @@ def _pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int = 0 piece["글"] = show[2] try: value = _value(real, env, master, 0) - if value == "None": # 값이 빈 줄 — 「None」 을 글로 안 찍음 + if value in ("None", ""): # 값이 빈 줄 — 빈 글 · 「None」 을 글로 안 찍음 raise mf.FormulaError("값 없음") piece.update({"값": value} if kind == "name" else {"글": value}) except _CALC_ERRORS: @@ -444,7 +420,7 @@ def _line_text(item: dict, line: dict, env: dict, master, part: str | None) -> s qty = _times(render(mf.parse(item["수량"]), env, master), item, env, master) money = line["비목"][part] if part else line["금액"] price = line["단가"] if part is None or line["수량"] == 0 else money / line["수량"] - return f"{fmt_price(price)} * {qty} = {fmt_money(money)}" + return f"{fmt_price(price)} * {qty} = {fmt_line_money(money)}" def _item_names(items: list) -> list[tuple[str, str]]: @@ -472,7 +448,7 @@ def _line_names( def _extra_text(extra: dict, line: dict, env: dict, master) -> str: if line["금액"] is None: # 빈 덧줄 — 식을 다시 셈하지 않음 return "" - return f"{render(mf.parse(extra['식']), env, master)} = {fmt_money(line['금액'])}" + return f"{render(mf.parse(extra['식']), env, master)} = {fmt_line_money(line['금액'])}" def _extra_names(extra: dict, env: dict, master, name_of: dict) -> str: @@ -501,7 +477,7 @@ def lines(files: dict, key: str, given: dict) -> dict: name_of = mc.names(marks) alias_by_name = _alias_index(marks) try: - result = mf.run(master, key, given) + result = mcut.engine(master, key, mf.run(master, key, given)) except _CALC_ERRORS: result = None diff --git a/resources/tester/helper_m02_labor_calc.cjs b/resources/tester/helper_m02_labor_calc.cjs index bdc7c2957..66e0d6d7f 100644 --- a/resources/tester/helper_m02_labor_calc.cjs +++ b/resources/tester/helper_m02_labor_calc.cjs @@ -1,5 +1,6 @@ /* M02 일위대가 수량 · 반영 · 합(`M02_MasterTemplete_Labor_Calc.ts` `settle`) 거울 헬퍼 — - * 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {답, 문서, 값, 열?} · stdout = settle 결과 JSON. */ + * 그 자리에서 트랜스파일해 Node 로 돌림(상대 경로 .ts 가져옴도 · B09 `rounddown` 751-3) · + * stdin = {답, 문서, 값, 열?} · stdout = settle 결과 JSON. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); @@ -11,11 +12,17 @@ const file = path.join( "M02_MasterTemplete", "M02_MasterTemplete_Labor_Calc.ts", ); -const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, require); +function load(at) { + const js = ts.transpileModule(fs.readFileSync(at, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const need = (p) => + p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p); + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} +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.열))); diff --git a/resources/tester/test_149_4_pct_extra.py b/resources/tester/test_149_4_pct_extra.py index d794487a0..f3a99f00a 100644 --- a/resources/tester/test_149_4_pct_extra.py +++ b/resources/tester/test_149_4_pct_extra.py @@ -29,7 +29,8 @@ def test_큰돌쌓기_찰쌓기_제잡비_율_설치_9_안함_3_금액_그대로 assert x["율"] == Decimal(rate) and x["규격"] == "노무비의 %" assert x["단위"] == "식" and x["수량"] == 1 # 엔진 줄은 그대로 · 표기는 화면 labor_sum = x["_기준"]["노무비"] - assert abs(x["금액"] - labor_sum * rate / 100) < Decimal("0.01") + # 751-3 — % 밑 = 버린 줄 합(소계 1 원 버림 전) · 금액 0.1 원 버림 + assert abs(x["금액"] - labor_sum * rate / 100) <= Decimal(rate) / 100 + Decimal("0.1") def test_인건비_호표줄_밑수_금액식은_덧칸_없음(): diff --git a/resources/tester/test_704_3_hopyo_grid.py b/resources/tester/test_704_3_hopyo_grid.py index fc847ea37..93ce81009 100644 --- a/resources/tester/test_704_3_hopyo_grid.py +++ b/resources/tester/test_704_3_hopyo_grid.py @@ -60,6 +60,8 @@ def test_머리_구성_합계_줄_꼬리표_빈_줄(): STUBS = r""" const tc = (k) => ({ Grid_NoQty: "수량 없음", Grid_Apart: "별도계상 · 합계 밖", Labor_Layer: "켜쌓기 {n}%", Grid_Head: "일위대가", Grid_Mine: "내 자료집", Lib_Company: "회사", Lib_System: "마스터" })[k] ?? k; const valueLine = (inputs, values) => ({ text: Object.values(values ?? {}).join(" · "), empty: 0 }); +// 751-3 `Labor_Calc.ts` 가 가져오는 B09 `rounddown`(이어 붙이기라 import 가 빠짐 · 같은 식) +const rounddown = (v, p = 0) => { const s = 10 ** p; const a = Math.floor(Math.abs(v) * s + 1e-7) / s; return v < 0 ? -a : a; }; """ DOC = { diff --git a/resources/tester/test_704_4_spec.py b/resources/tester/test_704_4_spec.py index 27471fdc1..60347aeac 100644 --- a/resources/tester/test_704_4_spec.py +++ b/resources/tester/test_704_4_spec.py @@ -194,8 +194,8 @@ def test_격자_규격_열_수량_칸_열마다(): mt1 = next(r for r in out["rows"] if r["name"] == "ㄴ 큰돌") assert mt1["qty_0"] == 4 and mt1["qty_1"] == 5 # 2.0 빈 열 = 공통 수량 t15, t20 = out["specs"]["totals"] - assert t20 == pytest.approx(112559.55) # 기본 열 = 금액 칸 합 - assert t15 == pytest.approx(529950 * 1.5 / 10 + 1800) # 열 수량 · 별도계상 MT1 = 합 밖 + assert t20 == 112559 # 기본 열 = 금액 칸 합 비목마다 1 원 버림(751-3 · 옛 112,559.55) + assert t15 == 81292 # 529,950 × 1.5 ÷ 10 + 1,800 = 81,292.5 → 1 원 버림 · 별도계상 MT1 = 합 밖 def test_고르기_창_spec_돌려줌(): diff --git a/resources/tester/test_705_3_hopyo_fixed.py b/resources/tester/test_705_3_hopyo_fixed.py index a35f48770..09ba41250 100644 --- a/resources/tester/test_705_3_hopyo_fixed.py +++ b/resources/tester/test_705_3_hopyo_fixed.py @@ -50,7 +50,7 @@ FILES = { # 실무 xlsx 넷 — EST 꼴 거창 · STmate 꼴 영월 · 울진 # (호표, 줄, 고정, 하위 참조, 이음, 엑셀 = 우리) — 영월 제22호표 = STmate 하위 호표 비목 반올림 1 원 끝수 EXPECT = { "거창": (14, 68, 56, 12, 11, 14), - "영월": (23, 138, 135, 3, 0, 22), + "영월": (23, 138, 135, 3, 0, 23), # 751-3 끝수 뒤 엑셀 금액과 같음 하나 늚(23 개 전부) "대흥": (34, 171, 143, 28, 0, 34), "소광": (25, 117, 98, 19, 0, 25), } @@ -116,12 +116,11 @@ def test_비율_고정_줄_파이썬과_TS_가_같은_답(values): misc, tool = [ln for ln in py["줄"] if (ln.get("고정") or {}).get("비율")] others = [ln for ln in py["줄"] if ln not in (misc, tool)] under = sum((ln["반영"] or {}).get("재료비", 0) for ln in others) # 자재 = 별도계상(합 밖) - assert misc["반영"]["재료비"] == pytest.approx(under * 3 / 100) and misc["배"] == 100 + assert misc["반영"]["재료비"] == pytest.approx(under * 3 / 100, abs=0.1) and misc["배"] == 100 assert misc["반영"]["노무비"] == misc["반영"]["경비"] == 0 assert tool["반영"]["경비"] == 0 # 인부 수량 없음 = 노무비 밑 0 - assert py["반영합"]["계"] == pytest.approx( - sum((ln["반영"] or {}).get("계", 0) for ln in py["줄"]) - ) + gap = sum((ln["반영"] or {}).get("계", 0) for ln in py["줄"]) - py["반영합"]["계"] + assert -1e-6 <= gap < 3 # 751-3 반영합 = 비목마다 1 원 버림 def test_비율_정리_막힘(): diff --git a/resources/tester/test_715_3_labor_patch.py b/resources/tester/test_715_3_labor_patch.py index 1ac5bdf78..5941a54b6 100644 --- a/resources/tester/test_715_3_labor_patch.py +++ b/resources/tester/test_715_3_labor_patch.py @@ -48,10 +48,11 @@ def test_logic_line_cover_in_precombo(world): kept = store.read("precombo", sc)["문서"]["담은로직"][0] assert kept["패치"] == COVER got = _value(world["personal"], sc)["기준"] - labor_sum = Decimal("0.5") * Decimal(str(bare["줄"][0]["단가"])) + Decimal( + cut = labor.mcut.down # 751-3 — 줄 0.1 원 · 비목 합 1 원 버림 + labor_sum = cut(Decimal("0.5") * Decimal(str(bare["줄"][0]["단가"])), 1) + Decimal( str(bare["줄"][1]["금액"]) ) - assert got["노무비"] == pytest.approx(labor_sum) + assert got["노무비"] == pytest.approx(cut(labor_sum, 0)) assert got["경비"] == pytest.approx(labor_sum * 3 / 100, abs=1) diff --git a/resources/tester/test_715_sangeun_engine.py b/resources/tester/test_715_sangeun_engine.py index f7b17cdd9..bf3758ad1 100644 --- a/resources/tester/test_715_sangeun_engine.py +++ b/resources/tester/test_715_sangeun_engine.py @@ -38,8 +38,9 @@ def test_price_cover_is_hand_price_and_pct_follows(): got = _run(바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"labor": 200000}}) line = got["줄"][0] assert line["단가"] == 200000 and line["출처"] == "손 단가" - want = Decimal(str(first["수량"])) * 200000 + Decimal(str(second["금액"])) - assert got["기준"]["노무비"] == pytest.approx(float(want)) + cut = labor.mcut.down # 751-3 — 줄 0.1 원 · 비목 합 1 원 버림 + want = cut(Decimal(str(first["수량"])) * 200000, 1) + Decimal(str(second["금액"])) + assert got["기준"]["노무비"] == pytest.approx(float(cut(want, 0))) assert got["기준"]["경비"] == pytest.approx(float(want * 3 / 100), abs=1) diff --git a/resources/tester/test_715_sangeun_store.py b/resources/tester/test_715_sangeun_store.py index 1f46efaf0..2fc2b7098 100644 --- a/resources/tester/test_715_sangeun_store.py +++ b/resources/tester/test_715_sangeun_store.py @@ -57,8 +57,9 @@ def test_qty_cover_hand_and_pct_recomputed(world): assert doc["덮개"]["labor:LB000036#0"]["원값"]["qty"] == pytest.approx(float(first["수량"])) assert len(doc["바탕판"]) == 16 got = _calc(world["personal"], key) - labor_sum = Decimal("0.5") * Decimal(str(first["단가"])) + Decimal(str(second["금액"])) - assert got["기준"]["노무비"] == pytest.approx(float(labor_sum)) + cut = calc.labor.mcut.down # 751-3 — 줄 0.1 원 · 비목 합 1 원 버림 + labor_sum = cut(Decimal("0.5") * Decimal(str(first["단가"])), 1) + Decimal(str(second["금액"])) + assert got["기준"]["노무비"] == pytest.approx(float(cut(labor_sum, 0))) assert got["기준"]["경비"] == pytest.approx(float(labor_sum * 3 / 100), abs=1) assert got["줄"][0]["덮개"] == ["qty"] and got["줄"][0]["cid"] == "labor:LB000036#0" assert got["짝잃음"] == [] @@ -116,7 +117,9 @@ def test_chain_base_flattened_delete_free(world): assert _doc(world["personal"], b)["바탕"] == {"로직": PCT} got = _calc(world["personal"], b) bare = _bare(PCT) - assert got["기준"]["노무비"] == pytest.approx(float(bare["줄"][0]["금액"])) + assert got["기준"]["노무비"] == pytest.approx( + float(calc.labor.mcut.down(bare["줄"][0]["금액"], 0)) + ) with store.at(world["personal"]): store.delete("sangeun", a) # 쓰는 곳 없음(서로 독립) assert _calc(world["personal"], b)["기준"] == got["기준"] diff --git a/resources/tester/test_717_1_logic_lib.py b/resources/tester/test_717_1_logic_lib.py index aa97c99c2..555263a36 100644 --- a/resources/tester/test_717_1_logic_lib.py +++ b/resources/tester/test_717_1_logic_lib.py @@ -87,7 +87,8 @@ def test_저장_안_한_줄_셈_쓰기_없음(user: TestClient) -> None: double = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}, "row": row2}).json() text = user.post(f"{BASE}/text", json={"key": KEY, "inputs": {}, "row": row2}).json() assert saved["ok"] and same["sums"] == saved["sums"] - assert double["sums"]["노무비"] == pytest.approx(saved["sums"]["노무비"] * 2) + # 751-3 — 줄 0.1 원 · 소계 1 원 버림이라 두 배는 1 원 안 + assert double["sums"]["노무비"] == pytest.approx(saved["sums"]["노무비"] * 2, abs=2) assert text["ok"] and text["계"] == pytest.approx(double["sums"]["계"], rel=1e-6) assert store.logic(KEY)["logic"]["호표"][0]["수량"] == line["수량"] # 정본 그대로 assert _prints() == before diff --git a/resources/tester/test_717_2b_sangeun_detail.py b/resources/tester/test_717_2b_sangeun_detail.py index dacadac94..eaf7b1cbf 100644 --- a/resources/tester/test_717_2b_sangeun_detail.py +++ b/resources/tester/test_717_2b_sangeun_detail.py @@ -36,7 +36,11 @@ def _node(body: str) -> object: born = _text(M02 / "logic" / "M02_Born_Tag.ts") # 740-2 — 모델이 쓰는 꼬리표 글(import 줄은 뺌) model = re.sub(r"^import .*$", "", _text(MODEL), flags=re.M) undo = re.sub(r"^import .*$", "", _text(M02 / "logic" / "M02_Sangeun_Undo.ts"), flags=re.M) - src = born + "\n" + undo + "\n" + model + "\n" + body + # 751-3 모델이 가져오는 B09 `rounddown`(import 줄을 빼므로 같은 식을 둠) + cut = "const rounddown = (v, p = 0) => { const s = 10 ** p; " + ( + "const a = Math.floor(Math.abs(v) * s + 1e-7) / s; return v < 0 ? -a : a; };" + ) + src = "\n".join((born, undo, cut, model, body)) out = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", src], @@ -230,11 +234,13 @@ def test_화면_문서_꼴_서버_저장_셈(world): # noqa: F811 — 가져온 by = {x["cid"]: x for x in got["줄"]} assert "labor:LB000002#1" not in by assert by["labor:LB000018#0"]["금액"] == pytest.approx(0.02 * a["단가"]) - assert by["logic:GC000267#0"]["금액"] == pytest.approx(g["수량"] * 90000, rel=1e-6) + # 751-3 — 비목마다 0.1 원 버림(셋) + assert by["logic:GC000267#0"]["금액"] == pytest.approx(g["수량"] * 90000, abs=0.3) added = by["add:#0"] # 더한 보통인부 = 바탕 보통인부 단가 × 0.01 assert added["수량"] == pytest.approx(0.01) assert added["단가"] == pytest.approx(lines["labor:LB000002#0"]["단가"]) - assert got["기준"]["계"] == pytest.approx(sum(x["금액"] for x in got["줄"])) + gap = sum(x["금액"] for x in got["줄"]) - got["기준"]["계"] # 751-3 비목마다 1 원 버림 + assert -1e-6 <= gap < 3 def test_725_바탕_없으면_개정_안_뜸_새_SK_나무_다시_읽음_더한줄_단가_찾음(): diff --git a/resources/tester/test_725_1_sangeun_add_inputs.py b/resources/tester/test_725_1_sangeun_add_inputs.py index 4625f0ece..14ca4156a 100644 --- a/resources/tester/test_725_1_sangeun_add_inputs.py +++ b/resources/tester/test_725_1_sangeun_add_inputs.py @@ -104,7 +104,7 @@ def test_no_base_common_option_seoul(world): # noqa: F811 assert got["빠진줄"] == [], got assert got["기준"], {k: v for k, v in got.items() if k != "줄"} assert got["기준"]["계"] == pytest.approx(_m01(GF, {"지역": "서울", "보정작업": "해당 없음"})) - assert round(float(got["기준"]["계"]), 2) == 5297.37 + assert float(got["기준"]["계"]) == 5295 # 751-3 끝수(옛 날 값 5,297.37) def test_screen_no_base_inputs_and_gap_dash(): diff --git a/resources/tester/test_727_sk_base_add.py b/resources/tester/test_727_sk_base_add.py index 897e56073..7caa62374 100644 --- a/resources/tester/test_727_sk_base_add.py +++ b/resources/tester/test_727_sk_base_add.py @@ -125,7 +125,9 @@ def test_727_2_기초_둘_세부_펼침_계(world): # noqa: F811 "add:#0/pct:공구손료 및 경장비#0", ] assert sub["add:#0/labor:LB000036#0"]["수량"] == pytest.approx(0.018) - assert sum(x["금액"] for x in add["펼침"]) == pytest.approx(add["금액"], rel=1e-9) + # 머리 = 수량 × 기초 끝수 계(751-3 · 비목 1 원 · 줄 0.1 원 버림) — 펼침 합과 비목마다 수량 × 1 원 안 + q = add["수량"] + assert sum(x["금액"] for x in add["펼침"]) == pytest.approx(add["금액"], abs=0.3 + 3 * q) assert add["금액"] == pytest.approx(_m01(PCT), rel=1e-9) assert got["기준"]["계"] == pytest.approx(bare["기준"]["계"] + _m01(PCT), rel=1e-9) with store.at(world["personal"]): # 펼침 없는 셈(쓰는 자리) = 같은 계 @@ -144,8 +146,9 @@ def test_727_2_둘째_기초_덮개_뺀줄_저장(world): # noqa: F811 assert saved["뺀줄"] == DROP and saved["바탕판"] got = _run(world["personal"], doc) add = _line(got, "add:#0") - want = 2 * (0.036 * 233250 + 0.012 * 172068) - assert add["금액"] == pytest.approx(want, rel=1e-9) + cut = labor.mcut.down # 751-3 — 기초 줄 0.1 원 · 비목 합 1 원 → × 수량 0.1 원 + want = cut(2 * cut(cut(0.036 * 233250, 1) + cut(0.012 * 172068, 1), 0), 1) + assert add["금액"] == pytest.approx(float(want), rel=1e-9) sub = {x["cid"]: x for x in add["펼침"]} assert sub["add:#0/labor:LB000036#0"]["덮개"] == ["qty"] assert sub["add:#0/labor:LB000036#0"]["수량"] == pytest.approx(0.072) diff --git a/resources/tester/test_728_2_sk_unit.py b/resources/tester/test_728_2_sk_unit.py index 545e47808..7820bb326 100644 --- a/resources/tester/test_728_2_sk_unit.py +++ b/resources/tester/test_728_2_sk_unit.py @@ -40,7 +40,7 @@ def _shown(whole, doc): def test_단목베기_100본_제안_본으로_바꾸면_1본_계(whole): full = _shown(whole, _doc("GF000287", "100본")) - assert full["배"] == 1 and round(full["기준"]["계"], 2) == Decimal("153889.40") + assert full["배"] == 1 and full["기준"]["계"] == Decimal("153889") # 751-3 1 원 버림 assert sc.head_of({"단위": "100본"}) == "100본 당" one = _shown(whole, _doc("GF000287", "본")) assert one["배"] == 100 and round(one["기준"]["계"], 2) == Decimal("1538.89") @@ -51,7 +51,7 @@ def test_단목베기_100본_제안_본으로_바꾸면_1본_계(whole): def test_사석_수상고르기_10제곱미터_에서_1제곱미터(whole): one = _shown(whole, _doc("GC000421", "㎡")) - assert round(one["기준"]["계"], 2) == Decimal("44791.32") + assert one["기준"]["계"] == Decimal("44791.1") # 10㎡ 447,911(1 원 버림) ÷10 def test_성격_다르면_계수_칸_없으면_셈_막음(whole): @@ -73,7 +73,7 @@ def test_성격_다르면_계수_칸_없으면_셈_막음(whole): def test_식_빈_단위는_옛_셈(whole): old = _shown(whole, _doc("GF000287", "식")) assert old["배"] == 100 and round(old["1단위"]["계"], 2) == Decimal("1538.89") - assert "화면배" not in old and old["기준"]["계"] == Decimal("153889.4") # 화면 = 바탕 몫 그대로 + assert "화면배" not in old and old["기준"]["계"] == Decimal("153889") # 화면 = 바탕 몫 그대로 def test_둘째_기초_수량_처음값_배(whole): @@ -85,7 +85,8 @@ def test_둘째_기초_수량_처음값_배(whole): got = sc.run(sd.clean(_doc("GC000421", "㎡", 더한줄=[one]), None, whole), whole) bare = sc.run(sd.clean(_doc("GC000421", "㎡"), None, whole), whole) added = got["1단위"]["계"] - bare["1단위"]["계"] - assert abs(added - alone * qty / 10) < Decimal("0.01") # ㎡ 몫 = 100㎡ 값 ÷100 + # ㎡ 몫 = 100㎡ 값 ÷100 · 비목 합 1 원 버림(751-3)이라 바탕 몫 ÷10 = 0.1 원 안 + assert abs(added - alone * qty / 10) <= Decimal("0.1") src = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts").read_text( encoding="utf-8" ) diff --git a/resources/tester/test_744_1_sk_detail_undo.py b/resources/tester/test_744_1_sk_detail_undo.py index 9da898dbf..bd3e50dd4 100644 --- a/resources/tester/test_744_1_sk_detail_undo.py +++ b/resources/tester/test_744_1_sk_detail_undo.py @@ -46,8 +46,12 @@ def test_둘째_기초_세부_원문_품_머리_수량(world): for row, line in zip(head["펼침"], m01_lines): assert row["원문"]["수량"] == pytest.approx(float(line["수량"]), rel=1e-9) # 원문 그대로 assert row["수량"] == pytest.approx(float(line["수량"]) * 0.01, rel=1e-9) # 상자 몫 - assert sum(x["금액"] for x in head["펼침"]) == pytest.approx(head["금액"], rel=1e-9) - assert sum(x["원문"]["금액"] for x in head["펼침"]) * 0.01 == pytest.approx(head["금액"]) + # 머리 = 수량 × 기초 끝수 계(751-3 · 비목 1 원 · 줄 0.1 원 버림) — 펼침 합과 0.3 + 수량 × 3 원 안 + near = 0.3 + 3 * 0.01 + assert sum(x["금액"] for x in head["펼침"]) == pytest.approx(head["금액"], abs=near) + assert sum(x["원문"]["금액"] for x in head["펼침"]) * 0.01 == pytest.approx( + head["금액"], abs=near + ) def test_처음수량_넣을때_값_셈_지문_안_듦(world): diff --git a/resources/tester/test_750_1_sk_views.py b/resources/tester/test_750_1_sk_views.py index d1e2c440f..0bf99b44b 100644 --- a/resources/tester/test_750_1_sk_views.py +++ b/resources/tester/test_750_1_sk_views.py @@ -50,7 +50,9 @@ def _same(a: dict) -> None: for x in a["줄"]: assert money[x["cid"]] == pytest.approx(x["금액"] or 0) assert text["계"] == pytest.approx(base["계"]) - assert sum(x["금액"] or 0 for x in a["줄"]) == pytest.approx(base["계"]) # 시험 계산 표 합 + # 시험 계산 표 합 — 줄 0.1 원 · 비목 합 1 원 버림(751-3) = 줄 합 아래 비목마다 1 원(× 화면 배) 안 + gap = sum(x["금액"] or 0 for x in a["줄"]) - base["계"] + assert -1e-6 <= gap < 3 / (a.get("화면배") or 1) assert a["중간"] and all(not isinstance(v, (dict, list)) for v in a["중간"].values()) diff --git a/resources/tester/test_751_3_money_cut.py b/resources/tester/test_751_3_money_cut.py new file mode 100644 index 000000000..1c6f49e6e --- /dev/null +++ b/resources/tester/test_751_3_money_cut.py @@ -0,0 +1,140 @@ +"""751-3 셈 끝수 = 품셈 1-2-2(줄 0.1 원 · 소계 1 원 미만버림) — `master_cut` 지킴 시험. + +M01 시험 계산 · 읽는 식 · M02 단가산출(SK) · 일위대가 반영이 B09 끝수(`rounddown`)와 같은 값 · +줄 합 = 소계(비목마다 ROUNDDOWN(Σ 줄, 0)) · B09 는 엔진 날 값(`raw`)을 받아 제 단가로 다시 버림. +""" + +from decimal import Decimal + +from B09_Estimation import B09_Estimation_Sangeun as sg +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +mcut, mf = labor.mcut, labor.mf # scripts 길은 `m01` 이 둠 +KEY = "GC000211" # 합판거푸집(테스트1 바탕) — 인력 · 재료 · % 덧줄 둘 +COSTS = ("노무비", "재료비", "경비") + + +def _whole(): + return m01.loaded()[1] + + +def _tenth(v) -> bool: + return v is None or Decimal(str(v)) == Decimal(str(v)).quantize(Decimal("0.1")) + + +def _sums_of_lines(lines) -> dict: + """비목마다 ROUNDDOWN(Σ 줄, 0) — B09 `block_totals` 규칙.""" + total = dict.fromkeys(COSTS, Decimal(0)) + for x in lines: + for k, v in (x.get("비목") or {}).items(): + total[k] += Decimal(str(v or 0)) + return {k: mcut.down(v, 0) for k, v in total.items()} + + +def test_줄은_0점1원_소계는_1원_버림_줄합이_소계(): + got = labor.run_line(_whole(), KEY, {}) + base = got["기준"] + assert all(_tenth(x.get("금액")) for x in got["줄"]) + assert all(Decimal(str(base[k])) == int(base[k]) for k in COSTS) + assert {k: Decimal(str(base[k])) for k in COSTS} == _sums_of_lines(got["줄"]) + assert base["계"] == sum(base[k] for k in COSTS) + + +def test_B09_산근_끝수와_같음(): + """엔진 날 줄을 B09 규칙(줄 ROUNDDOWN 1 · % = 버린 밑 × 율 · 비목 ROUNDDOWN 0)으로 = 끝수 답.""" + whole = _whole() + raw = labor.run_line(whole, KEY, {}, raw=True) + cut = labor.run_line(whole, KEY, {}) + lines = [] + for x in raw["줄"][: len(whole.logic(KEY)["호표"])]: + lines.append( + {k: sg._money(float(x["단가"] or 0), float(x["수량"] or 0)) for k in x["비목"]} + ) + under = {k: sum(ln.get(k, 0) for ln in lines) for k in COSTS} + for x in raw["줄"][len(lines) :]: # % 덧줄 = 버린 밑 × 율 / 100 → ROUNDDOWN 1 + (part,) = x["비목"] + base = under["재료비"] if "재료" in x["이름"] or "소모" in x["이름"] else under["노무비"] + lines.append({part: sg.rounddown(base * float(x["율"]) / 100, 1)}) + b09 = sg.block_totals([{"labor": ln.get("노무비", 0), "material": ln.get("재료비", 0), + "expense": ln.get("경비", 0)} for ln in lines]) # fmt: skip + assert (b09["labor"], b09["material"], b09["expense"], b09["sum"]) == tuple( + float(cut["기준"][k]) for k in (*COSTS, "계") + ) + assert raw["기준"]["계"] != cut["기준"]["계"] # 날 값은 버리기 전(B09 몫) + + +def test_화면_넷_같은_값_SK_시험계산_읽는식(): + whole = _whole() + doc = sd.clean( + {"키": "SK-000000", "이름": "끝수", "단위": "㎡", "바탕": {"로직": KEY}}, None, whole + ) + sk = calc.run(doc, whole)["기준"] + auto = {k: v for k, v in (m01.auto(KEY)["값"] or {}).items() if v is not None} + m1 = m01.calc(KEY, auto) + text = m01.text(KEY, auto) + assert float(sk["계"]) == m1["sums"]["계"] == float(text["계"]) + assert {g["비목"]: float(g["소계"]) for g in text["groups"]} == {k: float(sk[k]) for k in COSTS} + line = next(x for g in text["groups"] for x in g["줄"] if x["이름"].startswith("형틀")) + assert line["글"].endswith(" = " + _line_text(line["금액"])) and line["글"].endswith(".9") + + +def _line_text(v) -> str: + from common_util.common_util_money import fmt_line_money + + return fmt_line_money(Decimal(str(v))) + + +def test_하위_로직_줄은_하위_계를_버려_단가로(): + """기계 · 하위 로직 줄 단가 = 하위 로직 끝수 계(1 원) · + 비목마다 하위 비목 × 수량 0.1 원 버림.""" + whole = _whole() + key = next( + str(r["키"]) + for _, d in m01._logic_files(m01.cm.load(folder=m01.FOLDER)) + for r in d.get("줄") or [] + if any(i.get("종류") == "로직" for i in r.get("호표") or []) + and "결과" not in r + and not isinstance(r.get("흐름"), dict) + and labor.run_line(whole, str(r["키"]), {}).get("기준") + ) + got = labor.run_line(whole, key, {}) + for item, x in zip(whole.logic(key)["호표"], got["줄"]): + if item.get("종류") == "로직" and x.get("단가") is not None and x.get("수량") is not None: + assert Decimal(str(x["단가"])) == int(x["단가"]) + parts = [ + Decimal(str(v)) for v in x["비목"].values() + ] # 비목마다 0.1 원 버림 · 금액 = 합 + assert all(_tenth(v) for v in parts) and Decimal(str(x["금액"])) == sum(parts) + assert abs(Decimal(str(x["단가"])) * x["수량"] - sum(parts)) < Decimal("0.3") + + +def test_날_값은_엔진_그대로(): + whole = _whole() + eng = mf.run(whole, KEY, {**m01.mau.values(whole, whole.logic(KEY))}) + raw = labor.run_line(whole, KEY, {}, raw=True) + assert raw["기준"]["계"] == eng["계"] + + +def test_일위대가_반영_줄_0점1원_합_1원(): + doc = labor.clean({"담은로직": [{"로직": KEY, "수량": {"손": 3.3}}]}) + out = labor.calc(doc, {}) + line = out["줄"][0]["반영"] + assert all(_tenth(line[k]) for k in COSTS) and line["계"] == sum(line[k] for k in COSTS) + total = out["반영합"] + assert all(float(total[k]) == int(total[k]) for k in COSTS) + assert {k: Decimal(str(total[k])) for k in COSTS} == { + k: mcut.down(Decimal(str(line[k])), 0) for k in COSTS + } + + +def test_별도계상_뺀_기준은_남은_줄_합_버림(): + whole = _whole() + got = labor.run_line(whole, KEY, {}) + marks = {str(m["차례"]): {"별도계상": "직접계산"} for m in got["재료"]} + marks[str(got["재료"][0]["차례"])] = {"별도계상": "별도계상"} # 첫 재료만 뺌 + out = labor.drop_apart(got, marks) + assert len(out["줄"]) == len(got["줄"]) - 1 + assert {k: Decimal(str(out["기준"][k])) for k in COSTS} == _sums_of_lines(out["줄"]) diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index 563ecb494..bda787dce 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -164,10 +164,11 @@ def test_로직_목록_하나_시험계산(client: TestClient) -> None: got = _calc(client) assert got["ok"] is True lines = {x["이름"]: x for x in got["lines"]} - assert Decimal(str(lines["화약취급공"]["금액"])) == Decimal( - str(lines["화약취급공"]["수량"]) - ) * Decimal(str(lines["화약취급공"]["단가"])) - assert got["sums"]["계"] == pytest.approx(sum(x["금액"] for x in got["lines"])) + one = lines["화약취급공"] # 751-3 — 줄 0.1 원 · 비목 합 1 원 버림 + raw = Decimal(str(one["수량"])) * Decimal(str(one["단가"])) + assert Decimal(str(one["금액"])) == Decimal(str(int(raw * 10))) / 10 + gap = sum(x["금액"] for x in got["lines"]) - got["sums"]["계"] + assert -1e-6 <= gap < 3 stop = _calc(client, 없는입력=1) assert stop["ok"] is False and "모르는 입력" in stop["reason"] diff --git a/resources/tester/test_m01_auto.py b/resources/tester/test_m01_auto.py index a7ba5cd50..7b4f1611b 100644 --- a/resources/tester/test_m01_auto.py +++ b/resources/tester/test_m01_auto.py @@ -59,8 +59,9 @@ def test_없는_로직은_404(client) -> None: def test_견본_금액은_앞과_같음(client) -> None: - assert Decimal(str(_calc(client, "GF000160")["sums"]["계"])) == Decimal("117485.212") - assert Decimal(str(_calc(client, "GF000219")["sums"]["계"])) == Decimal("79279.668") + # 751-3 끝수(줄 0.1 원 · 비목 합 1 원 버림) — 옛 날 값 117,485.212 · 79,279.668 + assert Decimal(str(_calc(client, "GF000160")["sums"]["계"])) == Decimal("117484") + assert Decimal(str(_calc(client, "GF000219")["sums"]["계"])) == Decimal("79279") def test_빈_줄을_부르는_덧줄도_비움(client) -> None: @@ -83,7 +84,8 @@ def test_빈_입력을_쓰는_줄만_비움(client) -> None: for one in blank.values(): assert one["단가"] is None and one["금액"] is None and "비어 있음" in one["까닭"] rest = sum(Decimal(str(one["금액"])) for one in got["lines"] if one["금액"] is not None) - assert Decimal(str(got["sums"]["계"])) == rest > 0 # 빈 줄은 빼고 더함 + gap = rest - Decimal(str(got["sums"]["계"])) # 빈 줄은 빼고 더함 · 비목마다 1 원 버림(751-3) + assert rest > 0 and 0 <= gap < 3 assert got["sums"]["경비"] == 0 @@ -107,8 +109,8 @@ def test_체인톱_가격_오일은_자재품목(client, key) -> None: assert lines["체인오일"]["단가"] == 4612 gas = Decimal(str(lines["휘발유(체인톱)"]["금액"])) assert abs(Decimal(str(lines["잡품"]["금액"])) - gas * Decimal("0.4")) < Decimal( - "0.001" - ) # 잡품 = 휘발유값 40% 그대로 + "0.1" + ) # 잡품 = 휘발유값 40% 그대로(0.1 원 버림 · 751-3) def test_체인톱_규격은_고를_수_있음(client) -> None: diff --git a/resources/tester/test_m01_draft.py b/resources/tester/test_m01_draft.py index 4d3152271..ee5ada1fb 100644 --- a/resources/tester/test_m01_draft.py +++ b/resources/tester/test_m01_draft.py @@ -286,7 +286,8 @@ def test_서버_길이_저장까지_하고_그_로직이_돈다(client: TestClie calc = res.json() assert calc["ok"] is True, calc 노무 = Decimal(str(calc["sums"]["노무비"])) - assert 노무 > 0 and Decimal(str(calc["sums"]["재료비"])) == (노무 * Decimal("0.05")) + 재료 = Decimal(str(calc["sums"]["재료비"])) # 751-3 — % 밑 = 버린 줄 합 · 소계 1 원 버림 + assert 노무 > 0 and abs(재료 - 노무 * Decimal("0.05")) <= 1 assert Decimal(str(calc["middle"]["넓이"])) == 6 diff --git a/resources/tester/test_m01_make.py b/resources/tester/test_m01_make.py index c0847a8bd..cf0d35e05 100644 --- a/resources/tester/test_m01_make.py +++ b/resources/tester/test_m01_make.py @@ -75,14 +75,14 @@ def test_끝수_묶음이_계에_붙고_글자는_안_붙는다(client: TestClie plain = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": _draft()}) assert plain["ok"] is True, plain raw = Decimal(str(plain["sums"]["계"])) - assert raw != raw.to_integral_value() # 172068 / 7 — 원 미만이 남음 + assert raw == 24581 # 172068 / 7 = 24,581.14 → 줄 0.1 원 · 소계 1 원 버림(751-3 · 품셈 1-2-2) cut = _post( client, "/api/m01/calc", - {"key": "", "inputs": {}, "row": _draft({"대상": "계", "자리": 0, "방법": "버림"})}, + {"key": "", "inputs": {}, "row": _draft({"대상": "계", "자리": -1, "방법": "올림"})}, ) - assert Decimal(str(cut["sums"]["계"])) == raw.to_integral_value(rounding="ROUND_DOWN") + assert Decimal(str(cut["sums"]["계"])) == 24590 # 로직 끝수 묶음 = 1-2-2 버림 뒤 한 번 더 assert cut["sums"]["노무비"] == cut["sums"]["계"] # 비목 합마다 끊고 계 = 그 합 text = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": _draft("원 미만 버림")}) diff --git a/resources/tester/test_m01_text.py b/resources/tester/test_m01_text.py index a7472a7e5..dbc16e16f 100644 --- a/resources/tester/test_m01_text.py +++ b/resources/tester/test_m01_text.py @@ -82,7 +82,10 @@ def test_합판거푸집은_비목마다_묶이고_안_쓰는_줄에_까닭이_ assert [g["비목"] for g in got["groups"]] == ["노무비", "재료비"] for group in got["groups"]: assert Decimal(str(group["소계"])) == Decimal(str(calc["sums"][group["비목"]])) - assert sum(Decimal(str(one["금액"])) for one in group["줄"]) == Decimal(str(group["소계"])) + lines = sum( + Decimal(str(one["금액"])) for one in group["줄"] + ) # 751-3 소계 = 줄 합 1 원 버림 + assert 0 <= lines - Decimal(str(group["소계"])) < 1 why = [one for one in _all_lines(got) if one.get("까닭")] assert why and all("0" in one["까닭"] for one in why) @@ -95,7 +98,7 @@ def test_펌프카는_하위로직이_비목마다_한_줄씩이고_덧줄이_ assert len(pump) == 3 # 노무비 · 재료비 · 경비 몫이 따로 extra = next(one for one in _all_lines(got) if one["이름"].startswith("공구손료")) assert ( - extra["글"].endswith(f"= {mt.fmt_money(Decimal(str(extra['금액'])))}") + extra["글"].endswith(f"= {mt.fmt_line_money(Decimal(str(extra['금액'])))}") and "%" in extra["글"] ) @@ -398,8 +401,10 @@ def test_글_안의_돈은_정수_계산_값은_소수_그대로(client: TestCli assert mt.fmt_money(Decimal("0.4")) == "0" got, _ = _both(client, FORM) first = _all_lines(got)[0] - assert first["글"] == "275,790 * 0.22 * 1.31 = 79,483" # 수량·비율은 소수 그대로 - assert Decimal(str(first["금액"])) == Decimal("79482.678") # 값은 그대로 + assert first["글"] == "275,790 * 0.22 * 1.31 = 79,482.6" # 수량·비율은 소수 그대로 + assert Decimal(str(first["금액"])) == Decimal( + "79482.6" + ) # 줄 0.1 원 버림(751-3 · 옛 79,482.678) def test_자동값으로_모든_로직을_돌려도_멈추지_않는다() -> None: @@ -482,9 +487,11 @@ def test_값_식_작은_수는_유효숫자로_보인_수끼리_곱해도_금액 row = store.cm.master(REAL).logic("GC001064") got = mt.lines(files, "GC001064", dict(store.cm.시험입력(row))) first = _all_lines(got)[0]["글"] - assert first == "273,308 * 0.001667 = 456" + assert first == "273,308 * 0.001667 = 455.5" # 줄 0.1 원 버림(751-3) assert mt.fmt(Decimal("0.00166667")) == "0.001667" and mt.fmt(Decimal("12.34567")) == "12.3457" - assert mt.fmt_price(Decimal("3.5")) == "3.5" and mt.fmt_price(Decimal("3500.4")) == "3,500" + assert ( + mt.fmt_price(Decimal("3.5")) == "3.5" and mt.fmt_price(Decimal("3500.4")) == "3,500.4" + ) # 751-2 단가 그대로 def _text_of(key: str, **given) -> list[dict]: @@ -500,7 +507,7 @@ def test_빈_줄을_부른_덧줄_값_조각은_None_대신_그_줄_이름() -> def test_만약이_고른_갈래가_덧셈이면_곱셈_안에서_괄호로_묶임() -> None: line = next(x for x in _text_of("GC000376", 면="BOX 형상 내면") if x["이름"] == "도장공") - assert line["글"] == "263,017 * 0.031 * (1 + 60 %) = 13,046" + assert line["글"] == "263,017 * 0.031 * (1 + 60 %) = 13,045.6" # 줄 0.1 원(751-3) outer = next(x for x in _text_of("GC000376", 면="외면") if x["이름"] == "도장공") assert "(" not in outer["글"] diff --git a/resources/tester/test_m02_item_labor.py b/resources/tester/test_m02_item_labor.py index 76cf3c1ac..db18354d7 100644 --- a/resources/tester/test_m02_item_labor.py +++ b/resources/tester/test_m02_item_labor.py @@ -181,12 +181,12 @@ def test_계산_반영_합은_손셈_원본과_같음(world): assert Decimal(str(line["반영"]["계"])) == pytest.approx(expect) (mat,) = got["자재"] assert mat["코드"] == MATERIAL and mat["수량"] == MAT_QTY and mat["기준단가"] - assert mat["반영"] == pytest.approx(mat["기준단가"] * MAT_QTY) + assert mat["반영"] == pytest.approx(mat["기준단가"] * MAT_QTY, abs=0.1) # 0.1 원 버림(751-3) assert ( mat["가격"][mat["기준칸"]] == mat["기준단가"] ) # 복사본 단가 묶음으로도 가격 다섯 · 기준칸(43-3) total = hand["반영"]["계"] + area["반영"]["계"] + mat["반영"] - assert got["반영합"]["계"] == pytest.approx(total) + assert 0 <= total - got["반영합"]["계"] < 3 # 반영합 = 비목마다 1 원 버림(751-3) # 원본 층 문서 계산(마스터 같은 사본)과 같음 — 입력값은 프로젝트에 채운 것 values = _read(root / "templates/structures" / FOLDER / "입력값.json") doc = { diff --git a/resources/tester/test_m02_labor_doc.py b/resources/tester/test_m02_labor_doc.py index c42ce06ed..16333d009 100644 --- a/resources/tester/test_m02_labor_doc.py +++ b/resources/tester/test_m02_labor_doc.py @@ -85,16 +85,16 @@ def test_반영은_기준_곱_수량_나누기_배_돈_아닌_로직은_반영_ ten, plain = got["줄"] assert (ten["배"], ten["수량"], ten["수량출처"]) == (10, 4, "손") for k in ("노무비", "재료비", "경비", "계"): - assert ten["반영"][k] == pytest.approx(ten["기준"][k] * 4 / 10) + assert ten["반영"][k] == pytest.approx(ten["기준"][k] * 4 / 10, abs=0.3) # 0.1 원 버림(751-3) assert plain["기준"] is None and plain["반영"] is None and "결과" in plain - assert got["반영합"]["계"] == pytest.approx(ten["반영"]["계"]) + assert 0 <= ten["반영"]["계"] - got["반영합"]["계"] < 3 # 반영합 = 비목마다 1 원 버림(751-3) def test_돈_로직_줄에_재료_노무_경비_줄_실림_합은_기준(): got = labor.calc(labor.clean(_doc(담은로직=[{"로직": TEN}, {"로직": PLAIN}])), {}) ten, plain = got["줄"] assert ten["줄"] and all({"이름", "수량", "단위", "금액"} <= set(x) for x in ten["줄"]) - assert sum(x["금액"] for x in ten["줄"]) == pytest.approx(ten["기준"]["계"]) + assert 0 <= sum(x["금액"] for x in ten["줄"]) - ten["기준"]["계"] < 3 # 751-3 비목 1 원 버림 assert "줄" not in plain diff --git a/resources/tester/test_m02_labor_layer.py b/resources/tester/test_m02_labor_layer.py index 25de3f108..991b64561 100644 --- a/resources/tester/test_m02_labor_layer.py +++ b/resources/tester/test_m02_labor_layer.py @@ -39,13 +39,14 @@ def test_90퍼센트는_100퍼센트_곱하기_0점9() -> None: full = labor.run_line(whole, CODE, {}) part = labor.run_line(whole, CODE, {}, 90) for k in (*labor.COSTS, "계"): - assert part["기준"][k] == pytest.approx(full["기준"][k] * Decimal("0.9")) + # 751-3 — 곱한 뒤 줄 0.1 원 · 비목 합 1 원 다시 버림 + assert part["기준"][k] == pytest.approx(full["기준"][k] * Decimal("0.9"), abs=5) for a, b in zip(full["줄"], part["줄"]): assert b["단가"] == a["단가"] assert b["수량"] == pytest.approx(a["수량"] * Decimal("0.9")) - assert b["금액"] == pytest.approx(a["금액"] * Decimal("0.9")) + assert b["금액"] == pytest.approx(a["금액"] * Decimal("0.9"), abs=Decimal("0.3")) for k, v in a["비목"].items(): - assert b["비목"][k] == pytest.approx(v * Decimal("0.9")) + assert b["비목"][k] == pytest.approx(v * Decimal("0.9"), abs=Decimal("0.1")) assert labor.run_line(whole, CODE, {}, 100) == full @@ -80,7 +81,9 @@ def test_문서_계산과_프로젝트_사본_같음() -> None: (line,) = got["줄"] assert line["기준"]["계"] == pytest.approx(origin["줄"][0]["기준"]["계"]) # 층 셈 꼴(142-1) full = labor.calc({**doc, "담은로직": [{**doc["담은로직"][0], "켜쌓기율": None}]}, {}) - assert _d(line["기준"]["계"]) == pytest.approx(_d(full["줄"][0]["기준"]["계"]) * Decimal("0.9")) + assert _d(line["기준"]["계"]) == pytest.approx( + _d(full["줄"][0]["기준"]["계"]) * Decimal("0.9"), abs=5 + ) # 751-3 다시 버림 def test_옛_단가승격도_율() -> None: @@ -90,7 +93,7 @@ def test_옛_단가승격도_율() -> None: got = expand({"층": "system", "주인": None, "키": "SP-900001"}) full = labor.run_line(whole, CODE, {}) times = _d(doc["배"]) - assert got["기준"]["계"] == pytest.approx(full["기준"]["계"] * Decimal("0.9") / times) + assert got["기준"]["계"] == pytest.approx(full["기준"]["계"] * Decimal("0.9") / times, abs=5) RATE_SCRIPT = r""" diff --git a/resources/tester/test_m02_labor_logic_mats.py b/resources/tester/test_m02_labor_logic_mats.py index 4a26f251a..34fff0dea 100644 --- a/resources/tester/test_m02_labor_logic_mats.py +++ b/resources/tester/test_m02_labor_logic_mats.py @@ -63,7 +63,8 @@ def test_고름이_단가와_로직_재료비를_바꿈_고름_없으면_지금_ assert (row["품목"]["키"], row["고름"]) == (WIRE, True) assert row["단가"] == picked["재료"][1]["단가"] != plain["재료"][0]["단가"] gap = (plain["재료"][0]["단가"] - row["단가"]) * row["수량"] - assert plain["기준"]["재료비"] - picked["기준"]["재료비"] == pytest.approx(gap) + # 751-3 — 줄 0.1 원 · 재료비 합 1 원 버림이라 단가 차 × 수량 과 1 원 안 + assert plain["기준"]["재료비"] - picked["기준"]["재료비"] == pytest.approx(gap, abs=1.2) assert picked["기준"]["노무비"] == plain["기준"]["노무비"] diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index cce49275a..7c14351ce 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -232,16 +232,19 @@ def test_calc_는_참조를_펼침_승격은_기준_나누기_배_사전조합 got = labor.calc(doc, {f"{refs.ref_id(ref)}#0": 2}) # 안 줄 알약 = 구조물 조합 칸 값 fed = labor.calc(doc, {f"{refs.ref_id(ref)}#0": 4})["줄"][0] # 알약이 저장보다 앞섬 want = Decimal(str(_base(TEN)["계"])) / 10 * 2 - assert bare["기준"]["계"] == pytest.approx(float(want)) and bare["안"][0]["수량"] == 2 - assert fed["기준"]["계"] == pytest.approx(float(want * 2)) and fed["안"][0]["수량"] == 4 + near = 3 # 751-3 — 안 줄 0.1 원 · 비목 합 1 원 버림 + assert bare["기준"]["계"] == pytest.approx(float(want), abs=near) and bare["안"][0]["수량"] == 2 + assert ( + fed["기준"]["계"] == pytest.approx(float(want * 2), abs=near) and fed["안"][0]["수량"] == 4 + ) one = got["줄"][0] - assert one["기준"]["계"] == pytest.approx(float(want)) + assert one["기준"]["계"] == pytest.approx(float(want), abs=near) assert (one["분모"], one["배"], one["알약"]) == ( "㎡", 1, refs.ref_id(ref), ) # 단위 = 그 사전조합 것 - assert one["반영"]["계"] == pytest.approx(float(want * 5)) + assert one["반영"]["계"] == pytest.approx(float(want * 5), abs=20) # 751-3 끝수 × 5 assert one["안"][0]["수량"] == 2 and one["안"][0]["참조"]["키"] == sp pills = labor.pills(doc) assert pills[0] == {"키": refs.ref_id(ref), "이름": "둘", "단위": "㎡", "로직": [], "참조": ref} @@ -257,8 +260,8 @@ def test_승격_줄_상세는_원_로직_키_표_사전조합은_안_줄만(worl promote, combo = labor.calc(doc, {})["줄"] assert promote["원로직"] == TEN and promote["줄"] assert sum(x["금액"] for x in promote["줄"]) == pytest.approx( - promote["기준"]["계"] - ) # 1 단위(51-2) + promote["기준"]["계"], abs=0.3 + ) # 1 단위(51-2) · 비목 합 1 원 버림 ÷ 배(751-3) assert "줄" not in combo and [x["참조"]["키"] for x in combo["안"]] == [sp] @@ -455,7 +458,9 @@ def test_층_목록_서고_줄은_이름_단위_기준금액을_실음(world): refs.summaries(world["system"], rows) # 742-1 — 목록 기준금액 = 안 줄 저장 수량(손 3)으로 want = Decimal(str(_base(TEN)["계"])) / 10 * 3 - assert rows[0]["제목"] == "셋" and rows[0]["기준금액"] == pytest.approx(float(want)) + assert rows[0]["제목"] == "셋" and rows[0]["기준금액"] == pytest.approx( + float(want), abs=3 + ) # 751-3 assert "기준금액" not in rows[1] @@ -483,8 +488,8 @@ def test_사전조합_로직_줄_섞임_합은_손셈_지우기_409_굳힘(world with store.at(world["system"]): doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 5}}]}) one = labor.calc(doc, given)["줄"][0] - assert one["기준"]["계"] == pytest.approx(float(want)) - assert one["반영"]["계"] == pytest.approx(float(want * 5)) + assert one["기준"]["계"] == pytest.approx(float(want), abs=4) # 751-3 안 줄 셋 · 비목 합 버림 + assert one["반영"]["계"] == pytest.approx(float(want * 5), abs=20) # 751-3 끝수 × 5 assert [x.get("로직") or x["참조"]["키"] for x in one["안"]] == [sp, STONE, TEN] class Gone: # STONE 을 지운 마스터 @@ -500,7 +505,7 @@ def test_사전조합_로직_줄_섞임_합은_손셈_지우기_409_굳힘(world for key in (sc, sp): # 원본이 사라져도 굳힌 사본으로 같은 값 (world["system"] / refs.kind_of(key) / f"{key}.json").unlink() got = prices.calc(rows, bundle, {}, {}, _area(given))["줄"][0] - assert got["기준"]["계"] == pytest.approx(float(want), rel=1e-9) + assert got["기준"]["계"] == pytest.approx(float(want), abs=4) # 751-3 안 줄 셋 · 비목 합 버림 def test_사전조합이_자기_자신을_담으면_422_수량_없음(world): @@ -564,4 +569,4 @@ def test_사전조합_굳힘은_안_로직_알약도_사본_값_142_2(world, mon assert again == pytest.approx(before) with store.at(world["system"]): live = labor.calc(doc, given)["줄"][0]["기준"]["계"] - assert live == pytest.approx(before * 10) # 층 셈은 지금 M01(배 1) + assert live == pytest.approx(before * 10, abs=30) # 층 셈은 지금 M01(배 1) · 751-3 끝수 × 10