feat(b09): 2차 편집 ④ 내역 할증율·일괄보정 — 범위(선택 줄·내역서 전체) · 비목별 절사/반올림 · 소수 0~4 · 줄 ↺ · 할증 전부 되돌리기

- 서버가 내역 줄 성분 단가에 율을 얹어 다시 셈(줄 칸이 전체 칸보다 우선) · 수량은 그대로
- 공용 작은 버튼 글자색 상속 — 어두운 화면에서 검정 글씨로 묻히던 것

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-14 04:06:40 +09:00
co-authored by Claude Opus 5
parent d4ab146e02
commit b9d0ac0866
10 changed files with 439 additions and 8 deletions
@@ -175,6 +175,9 @@ class BillRow:
price_basis_label: str = "" price_basis_label: str = ""
#: 단가표 제목이 아닌 줄(묶음·구조물도 호표)의 **단위당 구성** `(단가 코드, 수량)` — 집계표가 자원까지 풂. #: 단가표 제목이 아닌 줄(묶음·구조물도 호표)의 **단위당 구성** `(단가 코드, 수량)` — 집계표가 자원까지 풂.
parts: list[tuple[str, Decimal]] = field(default_factory=list) 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 ㉮). #: 근거 호버는 열 키로 걸러 **그 사유가 닿는 칸에만** 띄운다(PLAN 8-36 ㉮).
#: ⚠ 종전엔 `note` 한 칸에 덮어썼다 — 한 줄에 사유가 둘이면 **하나가 조용히 사라졌다** #: ⚠ 종전엔 `note` 한 칸에 덮어썼다 — 한 줄에 사유가 둘이면 **하나가 조용히 사라졌다**
@@ -226,6 +229,8 @@ class BillRow:
"price_code": self.price_code, "price_code": self.price_code,
"unconfirmed": self.unconfirmed, "unconfirmed": self.unconfirmed,
"price_basis_label": self.price_basis_label, "price_basis_label": self.price_basis_label,
"rate_key": self.rate_key,
"rate": self.rate,
"is_group": self.is_group, "is_group": self.is_group,
"in_bill": self.in_bill, "in_bill": self.in_bill,
"note": self.note, "note": self.note,
@@ -410,6 +415,7 @@ def build_bill(
build: UnitPriceBuild | None = None, build: UnitPriceBuild | None = None,
master: dict[str, Any] | None = None, master: dict[str, Any] | None = None,
structure_prices: dict[str, dict[str, Any]] | None = None, structure_prices: dict[str, dict[str, Any]] | None = None,
rates: dict[str, Any] | None = None,
) -> BillResult: ) -> BillResult:
"""인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다. """인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다.
@@ -637,6 +643,10 @@ def build_bill(
result.unit_price_sheet = build_unit_price_sheet( result.unit_price_sheet = build_unit_price_sheet(
result.rows, unit_prices.book, structure_prices 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) _sum_groups(result.rows)
if any(m.surcharge_pct is None for m in materials): if any(m.surcharge_pct is None for m in materials):
+133
View File
@@ -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
+11 -2
View File
@@ -40,7 +40,9 @@ from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
EDITS_KEY = "edits" EDITS_KEY = "edits"
#: 화면·근거 표기 — B08 구조물도 집계표와 같은 말. #: 화면·근거 표기 — B08 구조물도 집계표와 같은 말.
USER_SOURCE = "user" 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). #: 구성행을 고칠 수 있는 본표 — 일위대가(B) · 단가산출(D).
EDITABLE_KINDS = (PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS) 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: 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 "" 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: if not 1 <= number <= PRICE_SLOT_COUNT or title.slots[number - 1] is None:
raise EditError(f"{title.name}: {value}번 원천에 값이 없어 채택할 수 없습니다") raise EditError(f"{title.name}: {value}번 원천에 값이 없어 채택할 수 없습니다")
return number 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) code, tail = split_key(key)
title = build.book.titles.get(code) title = build.book.titles.get(code)
if title is None or title.kind not in EDITABLE_KINDS or not isinstance(value, dict): if title is None or title.kind not in EDITABLE_KINDS or not isinstance(value, dict):
+9 -1
View File
@@ -483,7 +483,15 @@ async def get_bill(project_id: UUID) -> JSONResponse:
structure_prices = {} structure_prices = {}
try: 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: except DoubleCountError as error:
# 이중계상 감시에 걸린 경우 — 표를 그리지 않고 멈춘다. # 이중계상 감시에 걸린 경우 — 표를 그리지 않고 멈춘다.
logger.warning("B09 내역서 이중계상 감지: project_id=%s, %s", project_id, error) logger.warning("B09 내역서 이중계상 감지: project_id=%s, %s", project_id, error)
@@ -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<string>,
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),
);
}
+1 -1
View File
@@ -215,7 +215,7 @@ export function injectSheetStyles(): void {
.b09s-edit-formula { width:220px; } .b09s-edit-formula { width:220px; }
.b09s-table tr.is-user td:first-child { box-shadow:inset 3px 0 0 var(--color-accent, #6c8ebf); } .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-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 { 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-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; } .b09s-group { display:flex; flex-direction:column; gap:4px; border-top:1px solid var(--ui-border, #d0d4dc); padding-top:6px; }
@@ -37,6 +37,9 @@ export interface BillRowDto {
price_code: string; price_code: string;
unconfirmed: number; unconfirmed: number;
price_basis_label: string; price_basis_label: string;
/** 할증 칸 이름 · 얹힌 율(사용자, 12장 2차 ④) — `source` = 줄 칸 또는 `*`(내역서 전체). */
rate_key: string;
rate: { source: string; material: string; labor: string; expense: string } | null;
is_group: boolean; is_group: boolean;
in_bill: boolean; in_bill: boolean;
note: string; note: string;
+43 -4
View File
@@ -25,10 +25,14 @@ import {
won, won,
} from "./B09_Estimation_UI_Sheet"; } from "./B09_Estimation_UI_Sheet";
import { loadBill, type BillDto, type BillRowDto } from "./B09_Estimation_UI_Store"; import { loadBill, type BillDto, type BillRowDto } from "./B09_Estimation_UI_Store";
import { rateForm, rateMark } from "./B09_Estimation_UI_BillRates";
/** 접힌 머리글 줄(공종 번호) — 탭을 오가도 남음. */ /** 접힌 머리글 줄(공종 번호) — 탭을 오가도 남음. */
const collapsed = new Set<string>(); const collapsed = new Set<string>();
let levelLimit = 0; // 0 = 모두 let levelLimit = 0; // 0 = 모두
/** 할증·일괄보정 칸을 켰는가 · 고른 줄(할증 칸 이름) — 2차 ④. */
let rateMode = false;
const selectedRates = new Set<string>();
function rowMoney(row: BillRowDto): { function rowMoney(row: BillRowDto): {
unit: [string, string, string, string] | null; unit: [string, string, string, string] | null;
@@ -64,8 +68,15 @@ function isHidden(row: BillRowDto, rows: BillRowDto[]): boolean {
return false; 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 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); 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))); if (sheet) cell.append(linkButton(sheet.label, () => ctx.open("unit_price", sheet.code)));
const basis = bill.price_basis.entries.filter((entry) => const basis = bill.price_basis.entries.filter((entry) =>
@@ -88,7 +99,13 @@ function noteCell(row: BillRowDto, bill: BillDto, ctx: B09TabContext): HTMLTable
return cell; 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) { for (const row of bill.rows) {
if (isHidden(row, bill.rows)) continue; if (isHidden(row, bill.rows)) continue;
const tr = el("tr", row.is_group ? "is-group" : ""); 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); 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)); numberTd.append(document.createTextNode(row.item_no));
const name = el("td", "", row.name); const name = el("td", "", row.name);
name.style.paddingLeft = `${6 + Math.max(0, row.level - 1) * 12}px`; 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) { if (row.is_group) {
tr.append(el("td")); tr.append(el("td"));
} else { } 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.unconfirmed > 0) tr.classList.add("is-manual");
if (row.price_code) { if (row.price_code) {
tr.classList.add("is-clickable"); tr.classList.add("is-clickable");
@@ -171,7 +202,15 @@ function drawBill(ctx: B09TabContext, bill: BillDto, reload: () => void): void {
); );
if (bill.summary.unconfirmed_count > 0) if (bill.summary.unconfirmed_count > 0)
bar.append(unconfirmedBadge(bill.summary.unconfirmed_count)); 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); ctx.body.append(bar);
if (rateMode && ctx.projectId) ctx.body.append(rateForm(ctx.projectId, selectedRates, reload));
const { wrap, tbody } = sheetTable( const { wrap, tbody } = sheetTable(
sheetHead([ sheetHead([
@@ -182,7 +221,7 @@ function drawBill(ctx: B09TabContext, bill: BillDto, reload: () => void): void {
L("B09_Sheet_Col_Unit"), L("B09_Sheet_Col_Unit"),
]), ]),
); );
drawRows(tbody, bill, ctx, redraw); drawRows(tbody, bill, ctx, redraw, reload);
const sum = el("tr", "is-sum"); const sum = el("tr", "is-sum");
sum.append(el("td"), el("td", "", L("B09_Sheet_BodyTotal")), el("td"), el("td"), el("td")); 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")); sum.append(...moneyCells(null, [bill.summary.body_total_krw, "", "", ""]), el("td"));
+65
View File
@@ -9,6 +9,7 @@
from __future__ import annotations from __future__ import annotations
from decimal import Decimal from decimal import Decimal
from typing import Any
import pytest import pytest
@@ -144,3 +145,67 @@ def test_단가표에서_사라진_코드는_버리지_않고_남긴다() -> Non
edited = apply_edits(_build(), {"adopted_slots": {"M-없음": 1, "M-1": 2}}) edited = apply_edits(_build(), {"adopted_slots": {"M-없음": 1, "M-1": 2}})
assert len(edited.edit_skipped) == 2 assert len(edited.edit_skipped) == 2
assert edited.book.titles["M-1"].adopted_slot == 6 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}]) == {}
+12
View File
@@ -94,4 +94,16 @@ export const ui_locales_b3 = {
"노임·자재·중기·일위대가 이름으로 찾기", "노임·자재·중기·일위대가 이름으로 찾기",
"Search labor, material, machine or unit price", "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; } as const;