Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string>();
|
||||
let levelLimit = 0; // 0 = 모두
|
||||
/** 할증·일괄보정 칸을 켰는가 · 고른 줄(할증 칸 이름) — 2차 ④. */
|
||||
let rateMode = false;
|
||||
const selectedRates = new Set<string>();
|
||||
|
||||
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"));
|
||||
|
||||
@@ -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}]) == {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user