From b9d0ac0866f51aeefd1ea83215657a3412fb3125 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 04:06:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=202=EC=B0=A8=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=E2=91=A3=20=EB=82=B4=EC=97=AD=20=ED=95=A0=EC=A6=9D=EC=9C=A8?= =?UTF-8?q?=C2=B7=EC=9D=BC=EA=B4=84=EB=B3=B4=EC=A0=95=20=E2=80=94=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84(=EC=84=A0=ED=83=9D=20=EC=A4=84=C2=B7?= =?UTF-8?q?=EB=82=B4=EC=97=AD=EC=84=9C=20=EC=A0=84=EC=B2=B4)=20=C2=B7=20?= =?UTF-8?q?=EB=B9=84=EB=AA=A9=EB=B3=84=20=EC=A0=88=EC=82=AC/=EB=B0=98?= =?UTF-8?q?=EC=98=AC=EB=A6=BC=20=C2=B7=20=EC=86=8C=EC=88=98=200~4=20=C2=B7?= =?UTF-8?q?=20=EC=A4=84=20=E2=86=BA=20=C2=B7=20=ED=95=A0=EC=A6=9D=20?= =?UTF-8?q?=EC=A0=84=EB=B6=80=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버가 내역 줄 성분 단가에 율을 얹어 다시 셈(줄 칸이 전체 칸보다 우선) · 수량은 그대로 - 공용 작은 버튼 글자색 상속 — 어두운 화면에서 검정 글씨로 묻히던 것 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B09_Estimation_BillOfQuantities.py | 10 ++ B09_Estimation/B09_Estimation_BillRates.py | 133 +++++++++++++++ B09_Estimation/B09_Estimation_Edits.py | 13 +- B09_Estimation/B09_Estimation_Router.py | 10 +- B09_Estimation/B09_Estimation_UI_BillRates.ts | 152 ++++++++++++++++++ B09_Estimation/B09_Estimation_UI_Sheet.ts | 2 +- B09_Estimation/B09_Estimation_UI_Store.ts | 3 + B09_Estimation/B09_Estimation_UI_Tab_Bill.ts | 47 +++++- resources/tester/test_b09_edits.py | 65 ++++++++ ui_template/ui_template_locale_b3.ts | 12 ++ 10 files changed, 439 insertions(+), 8 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_BillRates.py create mode 100644 B09_Estimation/B09_Estimation_UI_BillRates.ts diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index d6c75708..4712202a 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -175,6 +175,9 @@ class BillRow: price_basis_label: str = "" #: 단가표 제목이 아닌 줄(묶음·구조물도 호표)의 **단위당 구성** `(단가 코드, 수량)` — 집계표가 자원까지 풂. parts: list[tuple[str, Decimal]] = field(default_factory=list) + #: 할증 칸 이름·얹힌 율(사용자) — `B09_Estimation_BillRates`(2차 ④). + rate_key: str = "" + rate: dict[str, str] | None = None #: 줄 사유 **조각** — `(닿는 열 키, 글)`. 화면 「비고」는 이것을 이어 붙인 것이고, #: 근거 호버는 열 키로 걸러 **그 사유가 닿는 칸에만** 띄운다(PLAN 8-36 ㉮). #: ⚠ 종전엔 `note` 한 칸에 덮어썼다 — 한 줄에 사유가 둘이면 **하나가 조용히 사라졌다** @@ -226,6 +229,8 @@ class BillRow: "price_code": self.price_code, "unconfirmed": self.unconfirmed, "price_basis_label": self.price_basis_label, + "rate_key": self.rate_key, + "rate": self.rate, "is_group": self.is_group, "in_bill": self.in_bill, "note": self.note, @@ -410,6 +415,7 @@ def build_bill( build: UnitPriceBuild | None = None, master: dict[str, Any] | None = None, structure_prices: dict[str, dict[str, Any]] | None = None, + rates: dict[str, Any] | None = None, ) -> BillResult: """인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다. @@ -637,6 +643,10 @@ def build_bill( result.unit_price_sheet = build_unit_price_sheet( result.rows, unit_prices.book, structure_prices ) + # 할증율·일괄보정(사용자 · 2차 ④) — 줄 금액이 선 뒤, 머리글 합 앞. + from B09_Estimation.B09_Estimation_BillRates import apply_bill_rates + + apply_bill_rates(result.rows, rates, bill_line) _sum_groups(result.rows) if any(m.surcharge_pct is None for m in materials): diff --git a/B09_Estimation/B09_Estimation_BillRates.py b/B09_Estimation/B09_Estimation_BillRates.py new file mode 100644 index 00000000..8d6f48b0 --- /dev/null +++ b/B09_Estimation/B09_Estimation_BillRates.py @@ -0,0 +1,133 @@ +"""B09 원가계산 — 내역 **할증율·일괄보정** (PLAN 12장 2차 ④ · STmate `wBoqRate`·`wM_Rate`). + +사용자가 고친 값(`estimation.edits.bill_rates`)을 내역 줄 금액에 얹는다 — 프로젝트 단위. + + 칸 "<단가 코드 또는 명칭>|<규격>" = 그 줄 하나(선택 항목만 — 고른 줄마다 한 칸) + "*" = 내역서 전체 + 값 {"all": "10"} 재·노·경 같은 비율(%) + {"material": "5", "labor": "0", "expense": "3"} 비목별(%) + + "rounding": {"material": {"mode": "floor"|"round", "digits": 0~4}, …} 비목별 절사/반올림 · 소수 자리 + 차례 줄 칸이 있으면 줄 칸, 없으면 "*" + +⚠ 성분 단가 × (1 + 율) → 고른 자리로 절사/반올림(안 고르면 원 미만 절사 — 호표 성분 소계 규칙) → + 줄 금액은 종전대로 성분마다 절사(명세 7장 · 내역 줄 자리 규칙은 안 바꿈). +⚠ 할증은 **금액만** 움직임 — 수량·자원 집계표 수량은 그대로. +⚠ 되돌리기 — 칸을 지우면 계산값으로. 여러 줄을 한꺼번에 바꾸므로 화면이 「할증 전부 되돌리기」도 줌. +""" + +from __future__ import annotations + +from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal, InvalidOperation +from typing import Any + +from B09_Estimation.B09_Estimation_PriceBook import Money3 + +ALL_ROWS = "*" +PARTS = ("material", "labor", "expense") +_LABELS = {"material": "재", "labor": "노", "expense": "경"} +_MODES = {"floor": ROUND_FLOOR, "round": ROUND_HALF_UP} +_DEFAULT_ROUNDING = {"mode": "floor", "digits": 0} + + +def rate_key(row: Any) -> str: + """줄 칸 이름 — 단가 코드(없으면 명칭) + 규격. 내역이 다시 서도 같은 줄이면 같은 이름.""" + return f"{row.price_code or row.name}|{row.spec}" + + +def parse_rate(value: Any) -> tuple[dict[str, Decimal], dict[str, dict[str, Any]]] | None: + """저장본 한 칸 → (성분별 율 %, 성분별 자리). 모양이 틀리면 `None`.""" + if not isinstance(value, dict): + return None + try: + if "all" in value: + rates = dict.fromkeys(PARTS, Decimal(str(value["all"]))) + else: + rates = {part: Decimal(str(value.get(part) or 0)) for part in PARTS} + except (InvalidOperation, ValueError): + return None + if any(not rate.is_finite() or rate <= -100 or rate > 1000 for rate in rates.values()): + return None + raw = value.get("rounding") or {} + if isinstance(raw, dict) and "mode" in raw: + raw = dict.fromkeys(PARTS, raw) + rounding: dict[str, dict[str, Any]] = {} + for part in PARTS: + chosen = raw.get(part) if isinstance(raw, dict) else None + chosen = chosen if isinstance(chosen, dict) else _DEFAULT_ROUNDING + mode, digits = str(chosen.get("mode") or "floor"), chosen.get("digits", 0) + if mode not in _MODES or not str(digits).isdigit() or not 0 <= int(digits) <= 4: + return None + rounding[part] = {"mode": mode, "digits": int(digits)} + return rates, rounding + + +def _round(value: Decimal, rule: dict[str, Any]) -> Decimal: + return value.quantize(Decimal(1).scaleb(-rule["digits"]), rounding=_MODES[rule["mode"]]) + + +def apply_bill_rates(rows: list[Any], raw: dict[str, Any] | None, bill_line: Any) -> None: + """금액이 선 내역 줄에 율을 얹음 — `bill_line` 은 줄 금액 규칙(순환 import 를 피해 받음).""" + for row in rows: + row.rate_key = rate_key(row) + if not raw: + return + for row in rows: + if row.is_group or row.amount_krw is None or row.unit_material_krw is None: + continue + source = row.rate_key if row.rate_key in raw else ALL_ROWS if ALL_ROWS in raw else None + parsed = parse_rate(raw.get(source)) if source else None + if not parsed: + continue + rates, rounding = parsed + before = { + "material": row.unit_material_krw, + "labor": row.unit_labor_krw, + "expense": row.unit_expense_krw, + } + unit = Money3( + **{ + part: _round(before[part] * (1 + rates[part] / 100), rounding[part]) + for part in PARTS + } + ) + line = bill_line(unit, row.quantity) + row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw = ( + unit.material, + unit.labor, + unit.expense, + ) + row.unit_price_krw = unit.total + row.amount_krw = line.total + row.material_krw, row.labor_krw, row.expense_krw = line.material, line.labor, line.expense + same = len(set(rates.values())) == 1 + text = ( + f"{rates['material']}%" + if same + else " · ".join(f"{_LABELS[part]} {rates[part]}%" for part in PARTS) + ) + row.rate = { + "source": source, + **{part: str(rates[part]) for part in PARTS}, + "was_unit_price_krw": str(sum(before.values(), Decimal(0))), + } + row.add_note( + "unit_price_krw", + f"할증 {text} (사용자{' · 내역서 전체' if source == ALL_ROWS else ''})", + ) + + +def validate_rate(value: Any) -> dict[str, Any]: + """저장 전 검사 — 받을 수 있는 모양(글)으로. 틀리면 `ValueError`.""" + parsed = parse_rate(value) + if parsed is None: + raise ValueError( + "할증율은 -100 보다 크고 1000 이하인 수(%) · 자리는 절사/반올림 × 소수 0~4 여야 합니다" + ) + rates, rounding = parsed + body: dict[str, Any] = ( + {"all": str(rates["material"])} + if isinstance(value, dict) and "all" in value + else {part: str(rates[part]) for part in PARTS} + ) + body["rounding"] = rounding + return body diff --git a/B09_Estimation/B09_Estimation_Edits.py b/B09_Estimation/B09_Estimation_Edits.py index 155f3348..17f5b185 100644 --- a/B09_Estimation/B09_Estimation_Edits.py +++ b/B09_Estimation/B09_Estimation_Edits.py @@ -40,7 +40,9 @@ from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build EDITS_KEY = "edits" #: 화면·근거 표기 — B08 구조물도 집계표와 같은 말. USER_SOURCE = "user" -SECTIONS = ("adopted_slots", "sheet_rows", "price_basis_q") +SECTIONS = ("adopted_slots", "sheet_rows", "price_basis_q", "bill_rates") +#: 단가표(조립)에 얹는 구획 — 캐시 키는 이것만. `bill_rates` 는 내역 줄에 얹음(`B09_Estimation_BillRates`). +BUILD_SECTIONS = ("adopted_slots", "sheet_rows", "price_basis_q") #: 구성행을 고칠 수 있는 본표 — 일위대가(B) · 단가산출(D). EDITABLE_KINDS = (PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS) #: 줄 더하기로 부를 수 있는 단가표 층. @@ -72,7 +74,7 @@ def normalize(raw: Any) -> dict[str, dict[str, Any]]: def edits_key(raw: Any) -> str: """캐시 키 — 같은 고친 값이면 같은 글(차례 무관).""" - edits = normalize(raw) + edits = {key: value for key, value in normalize(raw).items() if key in BUILD_SECTIONS} return json.dumps(edits, sort_keys=True, ensure_ascii=False) if edits else "" @@ -256,6 +258,13 @@ def validate_change(build: UnitPriceBuild, section: str, key: str, value: Any) - if not 1 <= number <= PRICE_SLOT_COUNT or title.slots[number - 1] is None: raise EditError(f"{title.name}: {value}번 원천에 값이 없어 채택할 수 없습니다") return number + if section == "bill_rates": + from B09_Estimation.B09_Estimation_BillRates import validate_rate + + try: + return validate_rate(value) + except ValueError as error: + raise EditError(str(error)) from error code, tail = split_key(key) title = build.book.titles.get(code) if title is None or title.kind not in EDITABLE_KINDS or not isinstance(value, dict): diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index ae2d34c0..8700c744 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -483,7 +483,15 @@ async def get_bill(project_id: UUID) -> JSONResponse: structure_prices = {} try: - result = build_bill(payload, build=build, structure_prices=structure_prices) + # 할증율·일괄보정(사용자 · 12장 2차 ④) — 프로젝트 고친 값에서. + from B09_Estimation.B09_Estimation_Edits import normalize + from common_util.common_util_project_settings import estimation_settings + + root = await _project_root_of(project_id) + rates = normalize((estimation_settings(root) if root else {}).get("edits")).get( + "bill_rates" + ) + result = build_bill(payload, build=build, structure_prices=structure_prices, rates=rates) except DoubleCountError as error: # 이중계상 감시에 걸린 경우 — 표를 그리지 않고 멈춘다. logger.warning("B09 내역서 이중계상 감지: project_id=%s, %s", project_id, error) diff --git a/B09_Estimation/B09_Estimation_UI_BillRates.ts b/B09_Estimation/B09_Estimation_UI_BillRates.ts new file mode 100644 index 00000000..d717c70f --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_BillRates.ts @@ -0,0 +1,152 @@ +/* ============================================================================= + * B09_Estimation_UI_BillRates.ts + * 설계내역서 **할증율·일괄보정** 칸 — STmate `wBoqRate`·`wM_Rate` 본 (PLAN 12장 2차 ④) + * + * - 범위: 선택 항목만(줄마다 한 칸) / 내역서 전체(`*`) · 방식: 같은 비율 / 비목별(재·노·경) + * - 비목마다 절사·반올림 × 소수 0~4 자리(안 고르면 원 미만 절사). + * - ⚠ 여러 줄을 한꺼번에 바꾸므로 「할증 전부 되돌리기」를 둠 — 줄마다 ↺ 도 있음. + * - 화면은 고른 값만 보냄 — 금액은 서버가 다시 셈(내역 한 벌을 새로 받음). + * ========================================================================== */ + +import { showToast } from "@ui/ui_template_elements"; +import { L, el, userMark } from "./B09_Estimation_UI_Sheet"; +import { loadEdits, saveEdits, type BillRowDto, type EditChange } from "./B09_Estimation_UI_Store"; + +const SECTION = "bill_rates"; +const PARTS = [ + ["material", "B09_Sheet_Col_Material"], + ["labor", "B09_Sheet_Col_Labor"], + ["expense", "B09_Sheet_Col_Expense"], +] as const; + +function save(projectId: string, changes: EditChange[], reload: () => void): void { + if (changes.length === 0) return; + void saveEdits(projectId, changes) + .then(() => { + showToast(L("B09_Sheet_Saved"), "success"); + reload(); + }) + .catch((error: Error) => showToast(`${L("B09_Sheet_SaveFailed")} ${error.message}`, "error")); +} + +function numberBox(placeholder: string): HTMLInputElement { + const input = el("input", "b09s-edit-input"); + input.type = "text"; + input.inputMode = "decimal"; + input.placeholder = placeholder; + return input; +} + +function choice(options: Array<[string, string]>): HTMLSelectElement { + const select = el("select"); + for (const [value, label] of options) { + const option = el("option", "", label); + option.value = value; + select.append(option); + } + return select; +} + +/** 할증·일괄보정 칸 한 벌 — `selected` = 고른 줄의 할증 칸 이름. */ +export function rateForm( + projectId: string, + selected: Set, + reload: () => void, +): HTMLElement { + const box = el("div", "b09s-group"); + const scope = choice([ + ["selected", L("B09_Sheet_Rate_Selected")], + ["all", L("B09_Sheet_Rate_All")], + ]); + const way = choice([ + ["same", L("B09_Sheet_Rate_Same")], + ["parts", L("B09_Sheet_Rate_Parts")], + ]); + const same = numberBox("%"); + const parts = PARTS.map(([key, label]) => { + const rate = numberBox("%"); + const mode = choice([ + ["floor", L("B09_Sheet_Rate_Floor")], + ["round", L("B09_Sheet_Rate_Round")], + ]); + const digits = choice( + [0, 1, 2, 3, 4].map((n): [string, string] => [ + String(n), + `${L("B09_Sheet_Rate_Digits")} ${n}`, + ]), + ); + const line = el("span", "b09s-inline"); + line.append(el("span", "b09s-head", L(label)), rate, mode, digits); + return { key, rate, mode, digits, line }; + }); + + const apply = el("button", "b09s-undo", L("B09_Sheet_Apply")); + apply.type = "button"; + apply.addEventListener("click", () => { + const rounding = Object.fromEntries( + parts.map((part) => [part.key, { mode: part.mode.value, digits: Number(part.digits.value) }]), + ); + const value = + way.value === "same" + ? { all: same.value.trim() || "0", rounding } + : { + ...Object.fromEntries(parts.map((part) => [part.key, part.rate.value.trim() || "0"])), + rounding, + }; + const keys = scope.value === "all" ? ["*"] : [...selected]; + if (keys.length === 0) { + showToast(L("B09_Sheet_Rate_PickRows"), "info"); + return; + } + save( + projectId, + keys.map((key) => ({ section: SECTION, key, value })), + reload, + ); + }); + + const clear = el("button", "b09s-undo", L("B09_Sheet_Rate_ClearAll")); + clear.type = "button"; + clear.addEventListener("click", () => { + void loadEdits(projectId).then((stored) => + save( + projectId, + Object.keys(stored.edits[SECTION] ?? {}).map((key) => ({ + section: SECTION, + key, + value: null, + })), + reload, + ), + ); + }); + + const head = el("div", "b09s-bar"); + head.append( + el("span", "b09s-head", L("B09_Sheet_Rate")), + scope, + way, + same, + apply, + clear, + el("span", "b09s-hint", `${L("B09_Sheet_Rate_SelectedCount")} ${selected.size}`), + ); + const partLine = el("div", "b09s-bar"); + partLine.append(...parts.map((part) => part.line)); + box.append(head, partLine); + return box; +} + +/** 줄 비고의 할증 표시 — 그 줄 칸으로 얹혔으면 「사용자」+↺, 내역서 전체로 얹혔으면 표시만. */ +export function rateMark( + projectId: string, + row: BillRowDto, + reload: () => void, +): HTMLElement | null { + if (!row.rate) return null; + if (row.rate.source === "*") + return el("span", "b09s-badge b09s-badge--user", L("B09_Sheet_Rate_AllBadge")); + return userMark(() => + save(projectId, [{ section: SECTION, key: row.rate_key, value: null }], reload), + ); +} diff --git a/B09_Estimation/B09_Estimation_UI_Sheet.ts b/B09_Estimation/B09_Estimation_UI_Sheet.ts index 02d5283b..f85a1441 100644 --- a/B09_Estimation/B09_Estimation_UI_Sheet.ts +++ b/B09_Estimation/B09_Estimation_UI_Sheet.ts @@ -215,7 +215,7 @@ export function injectSheetStyles(): void { .b09s-edit-formula { width:220px; } .b09s-table tr.is-user td:first-child { box-shadow:inset 3px 0 0 var(--color-accent, #6c8ebf); } .b09s-badge--user { border-color:var(--color-accent, #6c8ebf); color:var(--color-accent, #6c8ebf); } - .b09s-undo { border:1px solid var(--ui-border, #d0d4dc); background:none; cursor:pointer; padding:0 4px; margin-left:4px; font-size:12px; } + .b09s-undo { border:1px solid var(--ui-border, #d0d4dc); background:none; color:inherit; cursor:pointer; padding:0 4px; margin-left:4px; font-size:12px; } .b09s-info td { text-align:right; font-variant-numeric:tabular-nums; } .b09s-info td.b09s-left, .b09s-info th.b09s-left { text-align:left; white-space:normal; } .b09s-group { display:flex; flex-direction:column; gap:4px; border-top:1px solid var(--ui-border, #d0d4dc); padding-top:6px; } diff --git a/B09_Estimation/B09_Estimation_UI_Store.ts b/B09_Estimation/B09_Estimation_UI_Store.ts index 843bdf45..1473748e 100644 --- a/B09_Estimation/B09_Estimation_UI_Store.ts +++ b/B09_Estimation/B09_Estimation_UI_Store.ts @@ -37,6 +37,9 @@ export interface BillRowDto { price_code: string; unconfirmed: number; price_basis_label: string; + /** 할증 칸 이름 · 얹힌 율(사용자, 12장 2차 ④) — `source` = 줄 칸 또는 `*`(내역서 전체). */ + rate_key: string; + rate: { source: string; material: string; labor: string; expense: string } | null; is_group: boolean; in_bill: boolean; note: string; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts index 6573f0e1..6332de5a 100644 --- a/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts @@ -25,10 +25,14 @@ import { won, } from "./B09_Estimation_UI_Sheet"; import { loadBill, type BillDto, type BillRowDto } from "./B09_Estimation_UI_Store"; +import { rateForm, rateMark } from "./B09_Estimation_UI_BillRates"; /** 접힌 머리글 줄(공종 번호) — 탭을 오가도 남음. */ const collapsed = new Set(); let levelLimit = 0; // 0 = 모두 +/** 할증·일괄보정 칸을 켰는가 · 고른 줄(할증 칸 이름) — 2차 ④. */ +let rateMode = false; +const selectedRates = new Set(); function rowMoney(row: BillRowDto): { unit: [string, string, string, string] | null; @@ -64,8 +68,15 @@ function isHidden(row: BillRowDto, rows: BillRowDto[]): boolean { return false; } -function noteCell(row: BillRowDto, bill: BillDto, ctx: B09TabContext): HTMLTableCellElement { +function noteCell( + row: BillRowDto, + bill: BillDto, + ctx: B09TabContext, + reload: () => void, +): HTMLTableCellElement { const cell = el("td", "b09s-note"); + const mark = ctx.projectId ? rateMark(ctx.projectId, row, reload) : null; + if (mark) cell.append(mark); const sheet = bill.unit_price_sheet.entries.find((entry) => entry.code === row.price_code); if (sheet) cell.append(linkButton(sheet.label, () => ctx.open("unit_price", sheet.code))); const basis = bill.price_basis.entries.filter((entry) => @@ -88,7 +99,13 @@ function noteCell(row: BillRowDto, bill: BillDto, ctx: B09TabContext): HTMLTable return cell; } -function drawRows(tbody: HTMLElement, bill: BillDto, ctx: B09TabContext, redraw: () => void): void { +function drawRows( + tbody: HTMLElement, + bill: BillDto, + ctx: B09TabContext, + redraw: () => void, + reload: () => void, +): void { for (const row of bill.rows) { if (isHidden(row, bill.rows)) continue; const tr = el("tr", row.is_group ? "is-group" : ""); @@ -104,6 +121,19 @@ function drawRows(tbody: HTMLElement, bill: BillDto, ctx: B09TabContext, redraw: }); numberTd.append(toggle); } + if (rateMode && !row.is_group && row.amount_krw !== null) { + // 할증 범위 「선택 항목만」 — 고른 줄의 할증 칸 이름을 모음. + const pick = el("input"); + pick.type = "checkbox"; + pick.checked = selectedRates.has(row.rate_key); + pick.addEventListener("click", (event) => event.stopPropagation()); + pick.addEventListener("change", () => { + if (pick.checked) selectedRates.add(row.rate_key); + else selectedRates.delete(row.rate_key); + redraw(); + }); + numberTd.append(pick); + } numberTd.append(document.createTextNode(row.item_no)); const name = el("td", "", row.name); name.style.paddingLeft = `${6 + Math.max(0, row.level - 1) * 12}px`; @@ -121,7 +151,8 @@ function drawRows(tbody: HTMLElement, bill: BillDto, ctx: B09TabContext, redraw: if (row.is_group) { tr.append(el("td")); } else { - tr.append(noteCell(row, bill, ctx)); + tr.append(noteCell(row, bill, ctx, reload)); + if (row.rate) tr.classList.add("is-user"); if (row.unconfirmed > 0) tr.classList.add("is-manual"); if (row.price_code) { tr.classList.add("is-clickable"); @@ -171,7 +202,15 @@ function drawBill(ctx: B09TabContext, bill: BillDto, reload: () => void): void { ); if (bill.summary.unconfirmed_count > 0) bar.append(unconfirmedBadge(bill.summary.unconfirmed_count)); + const rateToggle = el("button", "b09s-undo", L("B09_Sheet_Rate")); + rateToggle.type = "button"; + rateToggle.addEventListener("click", () => { + rateMode = !rateMode; + redraw(); + }); + bar.append(rateToggle); ctx.body.append(bar); + if (rateMode && ctx.projectId) ctx.body.append(rateForm(ctx.projectId, selectedRates, reload)); const { wrap, tbody } = sheetTable( sheetHead([ @@ -182,7 +221,7 @@ function drawBill(ctx: B09TabContext, bill: BillDto, reload: () => void): void { L("B09_Sheet_Col_Unit"), ]), ); - drawRows(tbody, bill, ctx, redraw); + drawRows(tbody, bill, ctx, redraw, reload); const sum = el("tr", "is-sum"); sum.append(el("td"), el("td", "", L("B09_Sheet_BodyTotal")), el("td"), el("td"), el("td")); sum.append(...moneyCells(null, [bill.summary.body_total_krw, "", "", ""]), el("td")); diff --git a/resources/tester/test_b09_edits.py b/resources/tester/test_b09_edits.py index d865ab5b..ab43f0ca 100644 --- a/resources/tester/test_b09_edits.py +++ b/resources/tester/test_b09_edits.py @@ -9,6 +9,7 @@ from __future__ import annotations from decimal import Decimal +from typing import Any import pytest @@ -144,3 +145,67 @@ def test_단가표에서_사라진_코드는_버리지_않고_남긴다() -> Non edited = apply_edits(_build(), {"adopted_slots": {"M-없음": 1, "M-1": 2}}) assert len(edited.edit_skipped) == 2 assert edited.book.titles["M-1"].adopted_slot == 6 + + +def _bill_row(code: str, spec: str = "", quantity: str = "10") -> Any: + from B09_Estimation.B09_Estimation_BillOfQuantities import BillRow + + row = BillRow(item_no="1", level=1, code=code, name=code, spec=spec, quantity=Decimal(quantity)) + row.price_code = f"B-{code}" + row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw = ( + Decimal(1000), + Decimal(2000), + Decimal(333), + ) + row.unit_price_krw = Decimal(3333) + row.amount_krw = Decimal(33330) + return row + + +def test_할증율은_줄_칸이_이기고_없으면_내역서_전체가_얹힌다() -> None: + from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line + from B09_Estimation.B09_Estimation_BillRates import apply_bill_rates + + first, second = _bill_row("A"), _bill_row("B") + rates = { + "B-A|": {"all": "10"}, + "*": { + "material": "0", + "labor": "5", + "expense": "3", + "rounding": {"expense": {"mode": "round", "digits": 1}}, + }, + } + apply_bill_rates([first, second], rates, bill_line) + # 줄 칸 10% — 성분마다 원 미만 절사: 1,100 · 2,200 · 366.3 → 366 + assert (first.unit_material_krw, first.unit_labor_krw, first.unit_expense_krw) == ( + Decimal(1100), + Decimal(2200), + Decimal(366), + ) + assert first.amount_krw == Decimal(36660) and first.rate["source"] == "B-A|" + # 전체 칸 — 경비는 반올림 소수 1자리: 333 × 1.03 = 342.99 → 343.0 · 노무 2,100 + assert second.unit_expense_krw == Decimal("343.0") and second.unit_labor_krw == Decimal(2100) + assert second.amount_krw == Decimal(10000 + 21000 + 3430) + assert "내역서 전체" in second.note + + +def test_할증율_모양이_틀리면_받지_않고_지우면_돌아온다() -> None: + base = _build() + with pytest.raises(EditError): + merge_changes({}, base, [{"section": "bill_rates", "key": "*", "value": {"all": "-100"}}]) + with pytest.raises(EditError): + merge_changes( + {}, + base, + [ + { + "section": "bill_rates", + "key": "*", + "value": {"all": "5", "rounding": {"mode": "ceil", "digits": 0}}, + } + ], + ) + edits = merge_changes({}, base, [{"section": "bill_rates", "key": "*", "value": {"all": "5"}}]) + assert edits["bill_rates"]["*"]["all"] == "5" and edits_key(edits) == "" # 조립 캐시엔 안 듦 + assert merge_changes(edits, base, [{"section": "bill_rates", "key": "*", "value": None}]) == {} diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 7410713e..5b7c9ef9 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -94,4 +94,16 @@ export const ui_locales_b3 = { "노임·자재·중기·일위대가 이름으로 찾기", "Search labor, material, machine or unit price", ], + B09_Sheet_Rate: ["할증·일괄보정", "Surcharge / bulk adjust"], + B09_Sheet_Rate_Selected: ["선택 항목만", "Selected rows"], + B09_Sheet_Rate_All: ["내역서 전체", "Whole bill"], + B09_Sheet_Rate_Same: ["재·노·경 같은 비율", "Same rate"], + B09_Sheet_Rate_Parts: ["비목별", "Per component"], + B09_Sheet_Rate_Floor: ["절사", "Floor"], + B09_Sheet_Rate_Round: ["반올림", "Round"], + B09_Sheet_Rate_Digits: ["소수", "Decimals"], + B09_Sheet_Rate_PickRows: ["할증할 줄을 먼저 고르세요.", "Pick rows first."], + B09_Sheet_Rate_ClearAll: ["할증 전부 되돌리기", "Clear all surcharges"], + B09_Sheet_Rate_SelectedCount: ["고른 줄", "Selected"], + B09_Sheet_Rate_AllBadge: ["전체 할증", "Bill-wide"], } as const;