diff --git a/B09_Estimation/B09_Estimation_Progress.py b/B09_Estimation/B09_Estimation_Progress.py index a002d90d..e0ee08d5 100644 --- a/B09_Estimation/B09_Estimation_Progress.py +++ b/B09_Estimation/B09_Estimation_Progress.py @@ -15,6 +15,11 @@ ① 계약잡비율 = 계약 원가계산서 그 줄 금액 ÷ 계약 직접공사비(격자 `계약잡비율` 열이 줄마다 있음) ② 회차마다 금회 금액을 원 미만 절사해 쌓고 전회 = 앞 회차 금회의 합 · 누계 = 전회 + 금회 ③ 제잡비 줄 = 간접노무비 ~ 부가세 직전(11번 §10) — 간접재료비·관급·분리발주 폐기물은 밖 + 원문 대조: 예정가격작성기준 제39조②(표준시장단가 장) 간접공사비 「1. 간접노무비」~「10.」 — + 간접재료비 없음 · 제17조(원가계산 장)는 간접재료비를 재료비 안에 둠. 원가계산 장에는 + 「간접공사비」 묶음이 없어 기성 범위를 직접 정한 글은 아님. + ①′ 계약잡비율 직접입력(사유 필수)이 역산값을 이김 — 계약서에 제잡비율이 명시됨(브레인 판정). + ④ 사정 — 「기성내역서(사정)」 열 이름뿐이라 칸만 받고 계산에 안 씀. """ from __future__ import annotations @@ -25,7 +30,12 @@ from typing import Any from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line from B09_Estimation.B09_Estimation_Contract import _group_sums, _money from B09_Estimation.B09_Estimation_CostSheet import EXPENSE_ORDER -from B09_Estimation.B09_Estimation_Engine_Cost_Options import vat_base +from B09_Estimation.B09_Estimation_Engine_Cost_Options import ( + CUT_UNITS_KRW, + cut_gap, + profit_cut, + vat_base, +) from B09_Estimation.B09_Estimation_PriceBook import Money3 _ZERO = Decimal(0) @@ -54,6 +64,11 @@ _ITEM_ORDER: tuple[tuple[str, str], ...] = ( _COLUMNS = ("previous", "current", "cumulative") +#: 절사 — `cmBx_JeolSa`(공급가액)·`cmBx_Tot_Jeol`(총공사비) 9택. 첫 칸(빈 값)은 각각 +#: 「총공사비에서 조정」·「이윤금액 직접입력」. 떨어지는 몫은 설계 원가계산서처럼 **이윤에서** 뺌. +CUT_CHOICES: tuple[str, ...] = ("", *(str(unit) for unit in CUT_UNITS_KRW)) +_CUT_MAX_PASSES = 3 + def _number(value: Any) -> Decimal | None: try: @@ -71,36 +86,70 @@ def _pct(part: Decimal, whole: Decimal) -> str | None: return str((part / whole * _HUNDRED).quantize(Decimal("0.001"))) if whole else None +def _amounts(raw: dict[str, Any] | None, label: str, errors: list[str]) -> dict[str, str]: + """{줄: 0 이상 수} — 빈 칸은 뺌 · 틀린 칸은 까닭.""" + kept: dict[str, str] = {} + for key, value in (raw or {}).items(): + if value in (None, ""): + continue + number = _number(value) + if number is None: + errors.append(f"{label} {key} 값이 0 이상 수가 아님 — {value}") + continue + kept[str(key)] = str(number) + return kept + + +def _optional(value: Any, label: str, errors: list[str]) -> str: + if value in (None, ""): + return "" + if _number(value) is None: + errors.append(f"{label}이 0 이상 수가 아님 — {value}") + return "" + return str(_number(value)) + + def clean_settings(values: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: - """회차 목록 저장값과 거른 까닭 — 회차마다 금회 기성수량 · 부가세 방식(직접입력이면 금액).""" + """회차 목록 · 계약잡비율 직접입력 저장값과 거른 까닭.""" errors: list[str] = [] rounds: list[dict[str, Any]] = [] for number, entry in enumerate(values.get("rounds") or [], start=1): entry = entry or {} - quantities: dict[str, str] = {} - for item_no, raw in (entry.get("quantities") or {}).items(): - if raw in (None, ""): - continue - qty = _number(raw) - if qty is None: - errors.append(f"{number}회 {item_no} 기성수량이 0 이상 수가 아님 — {raw}") - continue - quantities[str(item_no)] = str(qty) mode = str(entry.get("vat_mode") or "supply") if mode not in dict(VAT_CHOICES): errors.append(f"{number}회 부가세 방식을 모름 — {mode}") mode = "supply" - manual = entry.get("vat_manual_krw") - if mode == "manual" and _number(manual) is None: - errors.append(f"{number}회 부가세 직접입력 금액이 0 이상 수가 아님 — {manual}") + manual = _optional(entry.get("vat_manual_krw"), f"{number}회 부가세 직접입력 금액", errors) + if mode == "manual" and manual == "": + errors.append(f"{number}회 부가세 직접입력 금액이 비었음") + cuts = {} + for key in ("supply_cut_krw", "total_cut_krw"): + unit = str(entry.get(key) or "") + cuts[key] = unit if unit in CUT_CHOICES else "" rounds.append( { - "quantities": quantities, + "quantities": _amounts(entry.get("quantities"), f"{number}회 기성수량", errors), "vat_mode": mode, - "vat_manual_krw": str(_number(manual)) if _number(manual) is not None else "", + "vat_manual_krw": manual, + **cuts, + "profit_manual_krw": _optional( + entry.get("profit_manual_krw"), f"{number}회 이윤금액 직접입력", errors + ), + "assessed": _amounts(entry.get("assessed"), f"{number}회 사정", errors), } ) - return {"rounds": rounds}, errors + overrides: dict[str, dict[str, str]] = {} + for key, entry in (values.get("rate_overrides") or {}).items(): + entry = entry or {} + rate = _optional(entry.get("rate_pct"), f"{key} 계약잡비율", errors) + reason = str(entry.get("reason") or "").strip() + if rate == "": + continue + if not reason: + errors.append(f"{key} 계약잡비율을 고친 사유가 비었음") + continue + overrides[str(key)] = {"rate_pct": rate, "reason": reason} + return {"rounds": rounds, "rate_overrides": overrides}, errors def _leaf(row: dict[str, Any]) -> bool: @@ -131,13 +180,29 @@ def cost_items(cost_data: Any, cost_result: Any) -> list[tuple[str, str, Decimal return items +def contract_ratios( + items: list[tuple[str, str, Decimal]], + contract_direct: Decimal, + overrides: dict[str, dict[str, str]], +) -> dict[str, tuple[Decimal, Decimal]]: + """줄 → (적용 비율, 역산 비율) — 기본은 도급액 ÷ 계약 직접공사비, 직접입력(사유 있음)이 이김.""" + ratios = {} + for key, _, amount in items: + derived = amount / contract_direct if contract_direct else _ZERO + override = overrides.get(key) + ratios[key] = ( + Decimal(override["rate_pct"]) / _HUNDRED if override else derived, + derived, + ) + return ratios + + def _round_money( rows: list[dict[str, Any]], entry: dict[str, Any], - items: list[tuple[str, str, Decimal]], - contract_direct: Decimal, + ratios: dict[str, tuple[Decimal, Decimal]], ) -> dict[str, Any]: - """한 회차의 금회 — 줄 금액 · 직접공사비 · 제잡비 줄 · 공급가액 · 부가세.""" + """한 회차의 금회 — 줄 금액 · 직접공사비 · 제잡비 줄 · 공급가액 · 부가세 · 절사 몫.""" lines: dict[str, Money3] = {} direct = Money3() for row in rows: @@ -146,22 +211,59 @@ def _round_money( continue lines[str(row["item_no"])] = bill_line(_unit(row), Decimal(qty)) direct += lines[str(row["item_no"])] - item_amounts = { - key: _floor(direct.total * amount / contract_direct) if contract_direct else _ZERO - for key, _, amount in items - } - supply = direct.total + sum(item_amounts.values(), _ZERO) - if entry["vat_mode"] == "manual": - vat = Decimal(entry["vat_manual_krw"] or 0) - else: + raw = {key: _floor(direct.total * ratio) for key, (ratio, _) in ratios.items()} + items = dict(raw) + notes: list[str] = [] + profit_manual = entry.get("profit_manual_krw") or "" + if profit_manual and not entry.get("total_cut_krw") and "profit" in items: + items["profit"] = Decimal(profit_manual) + notes.append(f"이윤금액 직접입력 {Decimal(profit_manual):,.0f}원") + + def vat_of(supply: Decimal) -> Decimal: + if entry["vat_mode"] == "manual": + return Decimal(entry["vat_manual_krw"] or 0) base = vat_base(entry["vat_mode"], supply, direct.material, direct.expense, _ZERO) - vat = _floor(base * _VAT_PERCENT / _HUNDRED) + return _floor(base * _VAT_PERCENT / _HUNDRED) + + def take_from_profit(amount: Decimal, label: str) -> bool: + if "profit" not in items: + notes.append(f"{label} {amount:,.0f}원 — 이윤 줄이 없어 못 뺌") + return False + items["profit"] -= amount + return True + + supply = direct.total + sum(items.values(), _ZERO) + if entry.get("supply_cut_krw"): + gap = cut_gap(supply, int(entry["supply_cut_krw"])) + if gap and take_from_profit(gap, "공급가액 절사"): + supply -= gap + notes.append( + f"공급가액 {int(entry['supply_cut_krw']):,}원 미만 절사 — 이윤에서 {gap:,.0f}원" + ) + vat = vat_of(supply) + if entry.get("total_cut_krw"): + # 설계 원가계산서와 같은 식 — 첫 차례는 공급가액 비례 부가세면 ÷1.1, 그 뒤는 잔차 그대로. + unit, taken = int(entry["total_cut_krw"]), _ZERO + for _ in range(_CUT_MAX_PASSES): + gap = cut_gap(supply + vat, unit) + if gap == 0: + break + step = profit_cut(gap, "grand_total", entry["vat_mode"]) if taken == 0 else gap + if not take_from_profit(step, "총공사비 절사"): + break + taken += step + supply -= step + vat = vat_of(supply) + if taken: + notes.append(f"총공사비 {unit:,}원 미만 절사 — 이윤에서 {taken:,.0f}원") return { "lines": lines, "direct": direct, - "items": item_amounts, + "raw_items": raw, + "items": items, "supply": supply, "vat": vat, + "notes": notes, } @@ -179,8 +281,18 @@ def progress_sheet( (_money(r["contract_amount_krw"]) for r in contract_rows if _leaf(r)), _ZERO ) items = cost_items(cost_data, cost_result) - per_round = [_round_money(contract_rows, entry, items, contract_direct) for entry in rounds] - empty = {"lines": {}, "direct": Money3(), "items": {}, "supply": _ZERO, "vat": _ZERO} + overrides = settings.get("rate_overrides") or {} + ratios = contract_ratios(items, contract_direct, overrides) + per_round = [_round_money(contract_rows, entry, ratios) for entry in rounds] + empty = { + "lines": {}, + "direct": Money3(), + "raw_items": {}, + "items": {}, + "supply": _ZERO, + "vat": _ZERO, + "notes": [], + } before = per_round[: max(current - 1, 0)] now = per_round[current - 1] if current else empty @@ -227,6 +339,10 @@ def progress_sheet( row["progress_note"] = ( "누계 기성수량이 계약 수량을 넘음" if qty_col["cumulative"] > contract_qty else "" ) + # 사정 — 뜻 미확인(27번 §3.2 「기성내역서(사정)」 열 이름뿐)이라 칸만 받고 계산에 안 씀. + row["progress_assessed_krw"] = ( + rounds[current - 1]["assessed"].get(item_no, "") if current else "" + ) for col in _COLUMNS: _group_sums(rows, f"progress_{col}") @@ -234,12 +350,15 @@ def progress_sheet( item_rows = [] for key, name, amount in items: values = four(lambda r, k=key: r["items"].get(k, _ZERO)) + ratio, derived = ratios[key] item_rows.append( { "key": key, "name": name, "contract_krw": str(amount), - "contract_ratio_pct": _pct(amount, contract_direct), + "contract_ratio_pct": str((ratio * _HUNDRED).quantize(Decimal("0.001"))), + "default_ratio_pct": str((derived * _HUNDRED).quantize(Decimal("0.001"))), + "override": overrides.get(key), **{f"{col}_krw": str(values[col]) for col in _COLUMNS}, **{f"{col}_pct": _pct(values[col], amount) for col in _COLUMNS}, } @@ -268,17 +387,33 @@ def progress_sheet( **{f"{col}_pct": _pct(values[col], contract_amount) for col in _COLUMNS}, } ) - notes = [] + # 단일율 곱과의 차이 — 줄마다 원 미만 절사해 쌓아서 벌어짐(절사 보정 전 값끼리 댐). + single = sum((ratio for ratio, _ in ratios.values()), _ZERO) + raw_sum = four(lambda r: sum(r["raw_items"].values(), _ZERO)) + direct_sum = four(lambda r: r["direct"].total) + gaps = { + col: _floor(direct_sum[col] * single) - raw_sum[col] for col in ("current", "cumulative") + } + notes = list(now["notes"]) + if any(gaps.values()): + notes.append( + f"줄별 절사 누적으로 단일율 곱과 금회 {gaps['current']:,.0f}원 · " + f"누계 {gaps['cumulative']:,.0f}원 차이" + ) if cost_data.indirect_material_krw: notes.append( f"계약 간접재료비 {cost_data.indirect_material_krw:,.0f}원은 기성 제잡비 줄 밖" "(원자료 「간접노무비부터 부가세 직전」) — 확인 대기" ) + unknown = sorted(set(overrides) - set(ratios)) + if unknown: + notes.append(f"계약잡비율 직접입력이 원가계산서에 없는 줄을 가리킴 — {', '.join(unknown)}") return { "rows": rows, "items": item_rows, "summary": summary, - "contract_overhead_ratio_pct": _pct(contract_items_total, contract_direct), + "single_rate_gap_krw": {col: str(gap) for col, gap in gaps.items()}, + "contract_overhead_ratio_pct": str((single * _HUNDRED).quantize(Decimal("0.001"))), "round": current, "round_count": len(rounds), "vat": rounds[current - 1] if current else None, diff --git a/B09_Estimation/B09_Estimation_Router_Progress.py b/B09_Estimation/B09_Estimation_Router_Progress.py index ad5199dc..feb8ab8f 100644 --- a/B09_Estimation/B09_Estimation_Router_Progress.py +++ b/B09_Estimation/B09_Estimation_Router_Progress.py @@ -15,6 +15,7 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from B09_Estimation.B09_Estimation_Progress import ( + CUT_CHOICES, SETTINGS_KEY, VAT_CHOICES, clean_settings, @@ -26,6 +27,13 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B09 Estimation — Progress"]) +def _cut_options(first: str) -> list[dict[str, str]]: + """절사 9택 — 첫 칸은 화면 표기 그대로(빈 값).""" + return [ + {"key": unit, "label": f"{int(unit):,}원 미만" if unit else first} for unit in CUT_CHOICES + ] + + @router.get("/{project_id}/estimation/progress") async def get_progress(project_id: UUID, round: int | None = None) -> JSONResponse: """기성 한 장 — `round` 회차(1부터)를 금회로 · 없으면 마지막 회차.""" @@ -50,10 +58,14 @@ async def get_progress(project_id: UUID, round: int | None = None) -> JSONRespon "status": "success", **sheet, "settings": stored, - "fields": {"vat": [{"key": k, "label": label} for k, label in VAT_CHOICES]}, + "fields": { + "vat": [{"key": k, "label": label} for k, label in VAT_CHOICES], + "supply_cut": _cut_options("총공사비에서 조정"), + "total_cut": _cut_options("이윤금액 직접입력"), + }, "limit_note": ( - "기성 표본 0건 — 구조가 서는지까지만 확인됨 · 계약잡비율(도급액 ÷ 계약 직접공사비)·" - "회차별 절사는 구조로 읽음 · 사정·절사 선택 미구현" + "기성 표본 0건 — 구조가 서는지까지만 확인됨 · 계약잡비율 역산(도급액 ÷ 계약 " + "직접공사비)·회차별 절사는 구조로 읽음 · 사정은 뜻 미확인이라 칸만" ), } ) diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Progress.ts b/B09_Estimation/B09_Estimation_UI_Tab_Progress.ts index 359e6401..6ec42e84 100644 --- a/B09_Estimation/B09_Estimation_UI_Tab_Progress.ts +++ b/B09_Estimation/B09_Estimation_UI_Tab_Progress.ts @@ -29,14 +29,25 @@ interface Round { quantities: Record; vat_mode: string; vat_manual_krw: string; + supply_cut_krw: string; + total_cut_krw: string; + profit_manual_krw: string; + /** 사정 — 뜻 미확인이라 칸만(계산에 안 씀). */ + assessed: Record; } +type Override = { rate_pct: string; reason: string }; +type Overrides = Record; +type Choice = { key: string; label: string }; + interface AmountRow { key: string; name: string; contract_krw: string; contract_ratio_pct?: string | null; - [column: string]: string | null | undefined; + default_ratio_pct?: string; + override?: Override | null; + [column: string]: string | null | undefined | Override; } interface ProgressRow { @@ -64,8 +75,8 @@ interface ProgressDto { round: number; round_count: number; notes: string[]; - settings: { rounds: Round[] }; - fields: { vat: { key: string; label: string }[] }; + settings: { rounds: Round[]; rate_overrides: Overrides }; + fields: { vat: Choice[]; supply_cut: Choice[]; total_cut: Choice[] }; limit_note: string; } @@ -113,12 +124,16 @@ function pct(value: string | boolean | null | undefined): string { } /** 프로젝트별 입력 캐시 — [저장] 전 값(지침 5장 · 자동저장 없음). 고른 회차도 함께. */ -const drafts = new Map(); +const drafts = new Map(); function draftOf(projectId: string, data: ProgressDto) { let draft = drafts.get(projectId); if (!draft) { - draft = { rounds: structuredClone(data.settings.rounds), round: data.round }; + draft = { + rounds: structuredClone(data.settings.rounds), + overrides: structuredClone(data.settings.rate_overrides), + round: data.round, + }; drafts.set(projectId, draft); } return draft; @@ -136,24 +151,70 @@ async function fetchProgress(projectId: string, round: number | null): Promise

{ +async function saveProgress( + projectId: string, + rounds: Round[], + overrides: Overrides, +): Promise { const response = await fetch(endpoint(projectId), { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ rounds }), + body: JSON.stringify({ rounds, rate_overrides: overrides }), }); const body = (await response.json()) as { message?: string }; if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); } +function choiceSelect(choices: Choice[], value: string, onChange: (v: string) => void) { + const node = el("select"); + for (const choice of choices) { + const option = el("option", "", choice.label); + option.value = choice.key; + node.append(option); + } + node.value = value; + node.addEventListener("change", () => onChange(node.value)); + return node; +} + +/** 공급가액 절사 · 총공사비 절사(9택) · 이윤금액 직접입력 — 떨어지는 몫은 이윤에서. */ +function drawCuts(data: ProgressDto, current: Round): HTMLElement[] { + const supply = el("label"); + supply.append( + el("span", "", "공급가액 절사"), + choiceSelect( + data.fields.supply_cut, + current.supply_cut_krw, + (v) => (current.supply_cut_krw = v), + ), + ); + const total = el("label"); + const profit = el("input"); + profit.type = "number"; + profit.min = "0"; + profit.placeholder = "이윤금액(비우면 계약잡비율 값)"; + profit.value = current.profit_manual_krw; + profit.hidden = current.total_cut_krw !== ""; + profit.addEventListener("input", () => (current.profit_manual_krw = profit.value)); + total.append( + el("span", "", "총공사비 절사"), + choiceSelect(data.fields.total_cut, current.total_cut_krw, (v) => { + current.total_cut_krw = v; + profit.hidden = v !== ""; + }), + profit, + ); + return [supply, total]; +} + function drawPanel(ctx: B09TabContext, data: ProgressDto, reload: (round: number) => void): void { const projectId = ctx.projectId as string; const draft = draftOf(projectId, data); const box = el("div", "b09pg__panel"); const save = async (round: number): Promise => { try { - await saveProgress(projectId, draft.rounds); + await saveProgress(projectId, draft.rounds, draft.overrides); drafts.delete(projectId); showToast("기성 회차 저장 — 계약 내역은 그대로", "success"); reload(round); @@ -183,6 +244,10 @@ function drawPanel(ctx: B09TabContext, data: ProgressDto, reload: (round: number quantities: {}, vat_mode: last?.vat_mode ?? "supply", vat_manual_krw: "", + supply_cut_krw: last?.supply_cut_krw ?? "", + total_cut_krw: last?.total_cut_krw ?? "", + profit_manual_krw: "", + assessed: {}, }); void save(draft.rounds.length); }, @@ -224,7 +289,8 @@ function drawPanel(ctx: B09TabContext, data: ProgressDto, reload: (round: number }); manual.addEventListener("input", () => (current.vat_manual_krw = manual.value)); vat.append(mode, manual); - box.append(vat, createButton({ label: "저장", onClick: () => save(data.round) })); + box.append(vat, ...drawCuts(data, current)); + box.append(createButton({ label: "저장", onClick: () => save(data.round) })); } else { box.append(el("div", "b09pg__meta", "회차 없음 — [회차 추가]로 1회부터")); } @@ -232,7 +298,7 @@ function drawPanel(ctx: B09TabContext, data: ProgressDto, reload: (round: number } /** 기성 제잡비 계산서 — 제잡비 줄 + 합계 줄(직접공사비 · 제잡비 계 · 공급가액 · 부가세 · 기성금액). */ -function drawJab(data: ProgressDto): HTMLElement { +function drawJab(data: ProgressDto, overrides: Overrides): HTMLElement { const box = el("div"); box.append( el( @@ -240,22 +306,67 @@ function drawJab(data: ProgressDto): HTMLElement { "", `기성 제잡비 계산서 — 금회직접공사비 × 계약잡비율 · 계약 제잡비율 ${pct(data.contract_overhead_ratio_pct)}`, ), + el( + "div", + "b09pg__meta", + "계약잡비율 기본 = 역산(도급액 ÷ 계약 직접공사비) · 계약서 값이 다르면 직접입력 + 사유 → [저장] · ↺ 로 역산값", + ), ); const table = el("table", "b09pg__table"); const head = el("tr"); - for (const label of ["명칭", "도급액", "계약잡비율", ...COLUMNS.flatMap(([, a, r]) => [a, r])]) { + for (const label of [ + "명칭", + "도급액", + "계약잡비율", + "직접입력(%)", + "사유", + "", + ...COLUMNS.flatMap(([, a, r]) => [a, r]), + ]) { head.append(el("th", "", label)); } table.append(head); const line = (row: AmountRow, total: boolean): void => { const tr = el("tr", total ? "is-total" : ""); - tr.append( - el("td", "", row.name), - el("td", "num", won(row.contract_krw)), - el("td", "num", pct(row.contract_ratio_pct)), - ); + const ratio = el("td", "num", pct(row.contract_ratio_pct)); + if (row.override) ratio.title = `역산값 ${row.default_ratio_pct}% — 직접입력이 이김`; + tr.append(el("td", "", row.name), el("td", "num", won(row.contract_krw)), ratio); + const cells = [el("td"), el("td"), el("td")]; + if (!total) { + const entry = (): Override => (overrides[row.key] ??= { rate_pct: "", reason: "" }); + const rate = el("input"); + rate.type = "number"; + rate.min = "0"; + rate.step = "any"; + rate.placeholder = row.default_ratio_pct ?? ""; + rate.value = overrides[row.key]?.rate_pct ?? ""; + rate.addEventListener("input", () => (entry().rate_pct = rate.value)); + const reason = el("input"); + reason.placeholder = "사유(계약서 등)"; + reason.style.width = "12em"; + reason.style.textAlign = "left"; + reason.value = overrides[row.key]?.reason ?? ""; + reason.addEventListener("input", () => (entry().reason = reason.value)); + const revert = el("button", "", "↺"); + revert.type = "button"; + revert.title = "역산값으로 되돌림 — [저장]하면 반영"; + revert.disabled = !overrides[row.key]; + revert.addEventListener("click", () => { + delete overrides[row.key]; + rate.value = ""; + reason.value = ""; + }); + cells[0].append(rate); + cells[1].append(reason); + cells[2].append(revert); + } + tr.append(...cells); for (const [col] of COLUMNS) { - tr.append(el("td", "num", won(row[`${col}_krw`])), el("td", "num", pct(row[`${col}_pct`]))); + const text = (key: string): string | null => { + const value = row[key]; + return typeof value === "string" ? value : null; + }; + tr.append(el("td", "num", won(text(`${col}_krw`))), el("td", "num", pct(text(`${col}_pct`)))); } table.append(tr); }; @@ -281,6 +392,7 @@ function drawBill(data: ProgressDto, rounds: Round[]): HTMLElement { "전회금액", "금회수량", "금회금액", + "사정(칸만)", "누계수량", "누계금액", "기성(%)", @@ -317,9 +429,24 @@ function drawBill(data: ProgressDto, rounds: Round[]): HTMLElement { }); cell.append(qty); } + // 사정 — 뜻 미확인(「기성내역서(사정)」 열 이름뿐) · 계산에 안 씀. + const assessed = el("td"); + if (leaf && current) { + const amount = el("input"); + amount.type = "number"; + amount.min = "0"; + amount.title = "사정 — 뜻 확인 대기 · 금액에 안 섞임"; + amount.value = current.assessed[row.item_no] ?? ""; + amount.addEventListener("input", () => { + if (amount.value === "") delete current.assessed[row.item_no]; + else current.assessed[row.item_no] = amount.value; + }); + assessed.append(amount); + } tr.append( cell, el("td", "num", won(row.progress_current_amount_krw)), + assessed, el("td", "num", leaf ? String(row.progress_cumulative_quantity) : ""), el("td", "num", won(row.progress_cumulative_amount_krw)), el("td", "num", pct(row.progress_pct)), @@ -339,7 +466,7 @@ function drawBody(ctx: B09TabContext, data: ProgressDto): void { for (const note of data.notes) wrap.append(el("div", "b09pg__warn", `⚠ ${note}`)); wrap.append(el("div", "b09pg__meta", "금회 기성수량을 넣고 [저장]하면 반영")); const scroll = el("div", "b09pg__scroll"); - scroll.append(drawJab(data), drawBill(data, draft.rounds)); + scroll.append(drawJab(data, draft.overrides), drawBill(data, draft.rounds)); wrap.append(scroll); ctx.body.append(wrap); } diff --git a/resources/tester/test_b09_progress.py b/resources/tester/test_b09_progress.py index 9edd502a..4003a365 100644 --- a/resources/tester/test_b09_progress.py +++ b/resources/tester/test_b09_progress.py @@ -145,6 +145,62 @@ def test_계약_수량_넘으면_알림과_분리발주_아니면_폐기물도_ assert "waste_disposal" in items +def _items(sheet: dict) -> dict: + return {item["key"]: item for item in sheet["items"]} + + +def _summary(sheet: dict) -> dict: + return {s["key"]: s for s in sheet["summary"]} + + +def test_계약잡비율_직접입력이_이기고_사유가_없으면_거른다() -> None: + settings = { + **SETTINGS, + "rate_overrides": {"indirect_labor_cost": {"rate_pct": "10", "reason": "계약서 제잡비율"}}, + } + labor = _items(_sheet(settings))["indirect_labor_cost"] + assert (labor["contract_ratio_pct"], labor["default_ratio_pct"]) == ("10.000", "5.000") + assert labor["current_krw"] == "10500" and labor["override"]["reason"] == "계약서 제잡비율" + _, errors = clean_settings({"rate_overrides": {"profit": {"rate_pct": "7", "reason": " "}}}) + assert len(errors) == 1 + + +def test_단일율_곱과_벌어진_차이를_알린다() -> None: + # 1.2 × 0.1998 = 999원 → 줄별 49 + 17 + 59 + 74 = 199 · 단일율 20.25% 곱 202 → 3원 + sheet = _sheet({"rounds": [{"quantities": {"1.2": "0.1998"}}]}) + assert sheet["single_rate_gap_krw"]["current"] == "3" + assert any("단일율 곱과 금회 3원" in note for note in sheet["notes"]) + + +def test_공급가액_절사와_총공사비_절사는_이윤에서() -> None: + first = {"quantities": {"1.1": "40", "1.2": "2"}, "vat_mode": "supply"} + cut = _sheet({"rounds": [{**first, "supply_cut_krw": "100"}]}) + # 공급가액 180,375 → 180,300 · 이윤 11,250 − 75 · 부가세 18,030 + assert _items(cut)["profit"]["current_krw"] == "11175" + assert (_summary(cut)["supply"]["current_krw"], _summary(cut)["vat"]["current_krw"]) == ( + "180300", + "18030", + ) + total = _sheet({"rounds": [{**first, "total_cut_krw": "1000"}]}) + # 총공사비 198,412 → 412 ÷ 1.1 = 375 을 이윤에서 → 공급가액 180,000 · 부가세 18,000 + assert _summary(total)["total"]["current_krw"] == "198000" + assert _items(total)["profit"]["current_krw"] == "10875" + + +def test_이윤금액_직접입력과_사정_칸() -> None: + entry = { + "quantities": {"1.1": "40", "1.2": "2"}, + "profit_manual_krw": "10000", + "assessed": {"1.1": "130000"}, + } + sheet = _sheet({"rounds": [entry]}) + assert _items(sheet)["profit"]["current_krw"] == "10000" + assert _summary(sheet)["supply"]["current_krw"] == "179125" + row = {r["item_no"]: r for r in sheet["rows"]}["1.1"] + assert row["progress_assessed_krw"] == "130000" # 칸만 — 금액에 안 섞임 + assert row["progress_current_amount_krw"] == "140000" + + def test_틀린_칸은_거른다() -> None: _, errors = clean_settings( {