feat(b09): 기성 2벌 — 계약잡비율 직접입력(사유·↺) · 단일율 곱 차이 표시 · 공급가액/총공사비 절사 9택 · 사정 칸
- 계약잡비율: 기본 역산값(도급액 ÷ 계약 직접공사비) · 계약서 값 직접입력이 이김(사유 필수) - 줄별 절사 누적으로 단일율 곱과 벌어진 금회·누계 차이를 비고에 적음 - 공급가액 절사(총공사비에서 조정 + 10원~1억) · 총공사비 절사(이윤금액 직접입력 + 10원~1억) — 떨어지는 몫은 설계 원가계산서와 같은 식으로 이윤에서 - 사정: 뜻 미확인이라 칸만 받고 계산에 안 씀 - 시험 4건 보탬 · 전체 1723 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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건 — 구조가 서는지까지만 확인됨 · 계약잡비율 역산(도급액 ÷ 계약 "
|
||||
"직접공사비)·회차별 절사는 구조로 읽음 · 사정은 뜻 미확인이라 칸만"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -29,14 +29,25 @@ interface Round {
|
||||
quantities: Record<string, string>;
|
||||
vat_mode: string;
|
||||
vat_manual_krw: string;
|
||||
supply_cut_krw: string;
|
||||
total_cut_krw: string;
|
||||
profit_manual_krw: string;
|
||||
/** 사정 — 뜻 미확인이라 칸만(계산에 안 씀). */
|
||||
assessed: Record<string, string>;
|
||||
}
|
||||
|
||||
type Override = { rate_pct: string; reason: string };
|
||||
type Overrides = Record<string, Override>;
|
||||
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<string, { rounds: Round[]; round: number }>();
|
||||
const drafts = new Map<string, { rounds: Round[]; overrides: Overrides; round: number }>();
|
||||
|
||||
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<P
|
||||
return body;
|
||||
}
|
||||
|
||||
async function saveProgress(projectId: string, rounds: Round[]): Promise<void> {
|
||||
async function saveProgress(
|
||||
projectId: string,
|
||||
rounds: Round[],
|
||||
overrides: Overrides,
|
||||
): Promise<void> {
|
||||
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<void> => {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user