feat(b09): 실행예산 단계 1벌 — 중기 실행단가(1단위당 사용료 ÷ 시간 · 성분 비율 · 절사 · 차액 보정) · 실행수량
- 단가표 복사본의 중기(X) 호표만 실행단가로 갈아 끼워 일위대가 다시 조립 — 설계·계약 불변 - 조립값과 다른 줄은 설계 단가 그대로 + 비고에 까닭 - 「기본보정」은 뜻 미확인 — 차액을 안 몰고 남김(확인 대기) - 계약 모듈의 다시 조립·묶음 합 조각을 두 단계가 같이 씀 - 실행예산 탭 파일 · 사전 키(등록은 서브) · 시험 7건 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
@@ -277,15 +277,28 @@ def _contract_unit(
|
|||||||
).floored(Decimal(1))
|
).floored(Decimal(1))
|
||||||
if scaled_book is None or book is None:
|
if scaled_book is None or book is None:
|
||||||
return scaled, ""
|
return scaled, ""
|
||||||
code = str(row.get("price_code") or "")
|
unit, why = reassembled_unit(str(row.get("price_code") or ""), unit_design, book, scaled_book)
|
||||||
|
if unit is None:
|
||||||
|
return scaled, f"기초단가로 못 풂({why}) — 성분에 적용"
|
||||||
|
return unit, "기초단가 적용 — 단가표 다시 조립"
|
||||||
|
|
||||||
|
|
||||||
|
def reassembled_unit(
|
||||||
|
code: str, unit_design: Money3, book: PriceBook, new_book: PriceBook
|
||||||
|
) -> tuple[Money3 | None, str]:
|
||||||
|
"""고친 단가표 복사본으로 그 줄 단가를 다시 조립 — 못 풀면 (None, 까닭).
|
||||||
|
|
||||||
|
⚠ 설계 단가가 설계 단가표 조립값과 같을 때만 — 다르면(할증·수동 단가·구조물도 호표)
|
||||||
|
다시 조립한 값이 그 줄 단가가 아님(계약·실행예산 공용).
|
||||||
|
"""
|
||||||
if code not in book.titles:
|
if code not in book.titles:
|
||||||
return scaled, "기초단가로 못 풂(단가표 밖 코드) — 성분에 적용"
|
return None, "단가표 밖 코드"
|
||||||
try:
|
try:
|
||||||
if book.resolve(code).floored(Decimal(1)) != unit_design:
|
if book.resolve(code).floored(Decimal(1)) != unit_design:
|
||||||
return scaled, "설계 단가가 단가표 조립값과 다름(할증·수동 단가) — 성분에 적용"
|
return None, "설계 단가가 단가표 조립값과 다름 — 할증·수동 단가"
|
||||||
return scaled_book.resolve(code).floored(Decimal(1)), "기초단가 적용 — 단가표 다시 조립"
|
return new_book.resolve(code).floored(Decimal(1)), ""
|
||||||
except PriceBookError as error:
|
except PriceBookError as error:
|
||||||
return scaled, f"기초단가로 못 풂({error}) — 성분에 적용"
|
return None, str(error)
|
||||||
|
|
||||||
|
|
||||||
def _totals(money: Money3) -> dict[str, str]:
|
def _totals(money: Money3) -> dict[str, str]:
|
||||||
@@ -297,8 +310,8 @@ def _totals(money: Money3) -> dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _group_sums(rows: list[dict[str, Any]]) -> None:
|
def _group_sums(rows: list[dict[str, Any]], stage: str = "contract") -> None:
|
||||||
"""묶음 줄 계약 금액 — 아래 줄의 합(설계 내역 `_group_sums` 와 같은 꼴)."""
|
"""묶음 줄 단계 금액(`{stage}_…_krw`) — 아래 줄의 합(설계 내역 `_group_sums` 와 같은 꼴)."""
|
||||||
for group in rows:
|
for group in rows:
|
||||||
if not group.get("is_group"):
|
if not group.get("is_group"):
|
||||||
continue
|
continue
|
||||||
@@ -308,9 +321,9 @@ def _group_sums(rows: list[dict[str, Any]]) -> None:
|
|||||||
for r in rows
|
for r in rows
|
||||||
if not r.get("is_group")
|
if not r.get("is_group")
|
||||||
and str(r.get("item_no", "")).startswith(prefix)
|
and str(r.get("item_no", "")).startswith(prefix)
|
||||||
and r.get("contract_amount_krw") is not None
|
and r.get(f"{stage}_amount_krw") is not None
|
||||||
]
|
]
|
||||||
for part in ("material", "labor", "expense", "amount"):
|
for part in ("material", "labor", "expense", "amount"):
|
||||||
group[f"contract_{part}_krw"] = str(
|
group[f"{stage}_{part}_krw"] = str(
|
||||||
sum((Decimal(r[f"contract_{part}_krw"]) for r in children), _ZERO)
|
sum((Decimal(r[f"{stage}_{part}_krw"]) for r in children), _ZERO)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""B09 실행예산 단계 — 설계 내역 → 실행예산 (PLAN 12장 「설계 뒤 네 단계」 · 2026-09-14 배정).
|
||||||
|
|
||||||
|
근거: STmate 분석 `27_계약_실행_기성_단계.md` §4 (`wM_Boq_ExecX` 「시간/단위당 중기실행단가 계산 및
|
||||||
|
입력」) · `35_형식과_단계의_공통과_차이.md` §3 · 원자료 `ui_form_catalog.txt` 65~67행.
|
||||||
|
· 설계 중기사용료와 **별도로** 「1단위당 중기사용료(원/단위)」 · 「1단위 = 시간」
|
||||||
|
· 최초단가(노무비·재료비·경비) → 실행단가 · 절사 1원 ~ 100,000원 미만
|
||||||
|
· 차액 보정 : 기본보정 / 노무비 / 재료비 / 경비
|
||||||
|
· 최초수량 → 실행수량
|
||||||
|
|
||||||
|
⚠ **설계·계약을 안 건드린다** — 설계 단가표를 **복사해** 중기(X) 호표만 실행단가로 갈아 끼우고
|
||||||
|
그 위 일위대가를 다시 조립한다. 설계 내역·원가계산서·골든셋은 그대로다.
|
||||||
|
⚠ **값이 맞다가 아니라 구조가 선다까지** — 실행예산 표본 0건(27번 §9 · 35번 「중기 화면 구조
|
||||||
|
확인 · 전체 실행예산 및 실제 값 미확인」). 아래 둘은 **구조로 읽은 것**이라 확인 대기:
|
||||||
|
① 실행단가 성분 = 시간당 실행단가 × 설계 성분 비율(최초단가 → 실행단가 한 줄에서 읽음)
|
||||||
|
② 절사는 시간당 성분마다 · 차액 = 절사한 시간당 합계 − 성분 합
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from decimal import ROUND_FLOOR, Decimal, InvalidOperation
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line
|
||||||
|
from B09_Estimation.B09_Estimation_Contract import _group_sums, _money, _totals, reassembled_unit
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import (
|
||||||
|
DEFAULT_ADOPTED_SLOT,
|
||||||
|
PRICE_SLOT_COUNT,
|
||||||
|
Money3,
|
||||||
|
PriceBook,
|
||||||
|
PriceBookError,
|
||||||
|
PriceDetail,
|
||||||
|
PriceKind,
|
||||||
|
PriceTitle,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ZERO = Decimal(0)
|
||||||
|
|
||||||
|
#: 저장 자리 — `estimation` 구획 안 한 칸.
|
||||||
|
SETTINGS_KEY = "execution"
|
||||||
|
|
||||||
|
#: 절사 기준 — `wCm_Jeol` 표기 차례(원 미만 절사 단위).
|
||||||
|
CUT_UNITS: tuple[str, ...] = ("1", "10", "100", "1000", "10000", "100000")
|
||||||
|
|
||||||
|
#: 차액 보정 — `wCm_OPT` 표기 차례.
|
||||||
|
CORRECTIONS: tuple[tuple[str, str], ...] = (
|
||||||
|
("basic", "기본보정"),
|
||||||
|
("labor", "노무비"),
|
||||||
|
("material", "재료비"),
|
||||||
|
("expense", "경비"),
|
||||||
|
)
|
||||||
|
#: 뜻이 확인 안 된 보정 — 조용히 한쪽에 몰지 않고 차액을 그대로 보임.
|
||||||
|
CORRECTION_NOT_KNOWN = {
|
||||||
|
"basic": "「기본보정」의 뜻이 분석 자료에서 확인 안 됨(27번 §4) — 차액을 안 몰고 그대로 보임",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: 실행단가를 받치는 기초단가 줄 — 성분마다 한 줄(자재 = 재료 · 노임 = 노무 · 중기 취득가 = 경비).
|
||||||
|
_PART_KIND = (
|
||||||
|
("material", PriceKind.MATERIAL, "M"),
|
||||||
|
("labor", PriceKind.LABOR, "L"),
|
||||||
|
("expense", PriceKind.MACHINE_BASE, "S"),
|
||||||
|
)
|
||||||
|
_PART_LABEL = {"material": "재료비", "labor": "노무비", "expense": "경비"}
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any) -> Decimal | None:
|
||||||
|
try:
|
||||||
|
number = Decimal(str(value))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return None
|
||||||
|
return number if number.is_finite() else None
|
||||||
|
|
||||||
|
|
||||||
|
def clean_settings(values: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
||||||
|
"""저장값과 거른 까닭. 사용료가 빈 중기는 설계 사용료 그대로 · 수량이 빈 줄은 설계 수량."""
|
||||||
|
errors: list[str] = []
|
||||||
|
machines: dict[str, dict[str, str]] = {}
|
||||||
|
for code, entry in (values.get("machines") or {}).items():
|
||||||
|
entry = entry or {}
|
||||||
|
if entry.get("unit_price_krw") in (None, ""):
|
||||||
|
continue
|
||||||
|
price = _number(entry.get("unit_price_krw"))
|
||||||
|
hours = _number(entry.get("hours_per_unit"))
|
||||||
|
if price is None or price < 0:
|
||||||
|
errors.append(
|
||||||
|
f"{code} 1단위당 중기사용료가 0 이상 수가 아님 — {entry.get('unit_price_krw')}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if hours is None or hours <= 0:
|
||||||
|
errors.append(
|
||||||
|
f"{code} 「1단위 = 시간」이 0 보다 큰 수가 아님 — {entry.get('hours_per_unit')}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
cut = str(entry.get("cut_unit_krw") or "1")
|
||||||
|
correction = str(entry.get("correction") or "basic")
|
||||||
|
machines[str(code)] = {
|
||||||
|
"unit_price_krw": str(price),
|
||||||
|
"hours_per_unit": str(hours),
|
||||||
|
"cut_unit_krw": cut if cut in CUT_UNITS else "1",
|
||||||
|
"correction": correction if correction in dict(CORRECTIONS) else "basic",
|
||||||
|
}
|
||||||
|
quantities: dict[str, str] = {}
|
||||||
|
for item_no, raw in (values.get("quantities") or {}).items():
|
||||||
|
if raw in (None, ""):
|
||||||
|
continue
|
||||||
|
qty = _number(raw)
|
||||||
|
if qty is None or qty < 0:
|
||||||
|
errors.append(f"{item_no} 실행수량이 0 이상 수가 아님 — {raw}")
|
||||||
|
continue
|
||||||
|
quantities[str(item_no)] = str(qty)
|
||||||
|
return {"machines": machines, "quantities": quantities}, errors
|
||||||
|
|
||||||
|
|
||||||
|
def execution_hourly(
|
||||||
|
design: Money3, unit_price: Decimal, hours: Decimal, cut: Decimal, correction: str
|
||||||
|
) -> tuple[Money3 | None, Decimal, str]:
|
||||||
|
"""시간당 실행단가(성분) · 차액 · 까닭. 못 가르면 (None, 0, 까닭).
|
||||||
|
|
||||||
|
시간당 실행단가 = 1단위당 중기사용료 ÷ 1단위 시간 → 설계 성분 비율로 가름 → 성분마다 절사 →
|
||||||
|
차액(절사한 합계 − 성분 합)을 고른 비목에 더함. 「기본보정」은 뜻 미확인이라 차액을 남김.
|
||||||
|
"""
|
||||||
|
if design.total <= 0:
|
||||||
|
return None, _ZERO, "설계 중기사용료 성분 합이 0 — 비율로 못 가름"
|
||||||
|
per_hour = unit_price / hours
|
||||||
|
parts = {
|
||||||
|
part: (per_hour * getattr(design, part) / design.total / cut).to_integral_value(
|
||||||
|
rounding=ROUND_FLOOR
|
||||||
|
)
|
||||||
|
* cut
|
||||||
|
for part, _, _ in _PART_KIND
|
||||||
|
}
|
||||||
|
target = (per_hour / cut).to_integral_value(rounding=ROUND_FLOOR) * cut
|
||||||
|
diff = target - sum(parts.values(), _ZERO)
|
||||||
|
note = ""
|
||||||
|
if diff and correction in parts:
|
||||||
|
parts[correction] += diff
|
||||||
|
note = f"차액 {diff}원 → {dict(CORRECTIONS)[correction]}"
|
||||||
|
elif diff:
|
||||||
|
note = f"차액 {diff}원 남음 — {CORRECTION_NOT_KNOWN['basic']}"
|
||||||
|
return Money3(**parts), diff, note
|
||||||
|
|
||||||
|
|
||||||
|
def execution_book(book: PriceBook, hourly: dict[str, Money3]) -> PriceBook:
|
||||||
|
"""중기(X) 호표를 실행단가로 갈아 끼운 **복사본** — 원본 단가표(캐시 공유)는 안 건드림.
|
||||||
|
|
||||||
|
X 상세 줄을 비우고 성분마다 기초단가 한 줄(수량 1)을 달아 그 위 일위대가가 설계와 같은
|
||||||
|
규칙(`PriceBook.resolve`)으로 다시 조립되게 함.
|
||||||
|
"""
|
||||||
|
copied = copy.deepcopy(book)
|
||||||
|
for code, money in hourly.items():
|
||||||
|
copied.details[code] = []
|
||||||
|
for part, kind, prefix in _PART_KIND:
|
||||||
|
slots: list[Decimal | None] = [None] * PRICE_SLOT_COUNT
|
||||||
|
slots[DEFAULT_ADOPTED_SLOT - 1] = getattr(money, part)
|
||||||
|
ref = f"{prefix}-실행-{code}"
|
||||||
|
copied.titles[ref] = PriceTitle(
|
||||||
|
code=ref, kind=kind, name=f"중기실행단가 {_PART_LABEL[part]}", slots=slots
|
||||||
|
)
|
||||||
|
copied.add_detail(PriceDetail(code, ref, Decimal(1), note="실행예산 중기실행단가"))
|
||||||
|
return copied
|
||||||
|
|
||||||
|
|
||||||
|
def machines_under(book: PriceBook, codes: list[str]) -> dict[str, set[str]]:
|
||||||
|
"""내역 단가코드 → 그 아래 중기(X) 코드들 — X 안으로는 안 내려감."""
|
||||||
|
found: dict[str, set[str]] = {}
|
||||||
|
|
||||||
|
def walk(code: str, seen: tuple[str, ...]) -> set[str]:
|
||||||
|
if code in found:
|
||||||
|
return found[code]
|
||||||
|
title = book.titles.get(code)
|
||||||
|
if title is None or code in seen:
|
||||||
|
return set()
|
||||||
|
if title.kind is PriceKind.MACHINE_HOURLY:
|
||||||
|
return {code}
|
||||||
|
result: set[str] = set()
|
||||||
|
for detail in book.details.get(code, []):
|
||||||
|
if detail.ref_code != code:
|
||||||
|
result |= walk(detail.ref_code, (*seen, code))
|
||||||
|
found[code] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
return {code: walk(code, ()) for code in codes}
|
||||||
|
|
||||||
|
|
||||||
|
def execution_bill(
|
||||||
|
bill_rows: list[dict[str, Any]],
|
||||||
|
settings: dict[str, Any],
|
||||||
|
build: Any = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""설계 내역 줄 → 실행예산 줄 · 중기 실행단가 표 · 합계.
|
||||||
|
|
||||||
|
`build` = 설계 일위대가 조립본(캐시 공유본이라 **안 고침**) — 없으면 중기 표 없이 수량만.
|
||||||
|
"""
|
||||||
|
entries = settings.get("machines") or {}
|
||||||
|
quantities = settings.get("quantities") or {}
|
||||||
|
book = build.book if build is not None else None
|
||||||
|
codes = [str(row.get("price_code") or "") for row in bill_rows if not row.get("is_group")]
|
||||||
|
under = machines_under(book, codes) if book is not None else {}
|
||||||
|
|
||||||
|
machines: list[dict[str, Any]] = []
|
||||||
|
hourly: dict[str, Money3] = {}
|
||||||
|
for code in sorted(set().union(*under.values())):
|
||||||
|
title = book.titles[code]
|
||||||
|
item: dict[str, Any] = {"code": code, "name": title.name, "spec": title.spec}
|
||||||
|
try:
|
||||||
|
design = book.resolve(code)
|
||||||
|
except PriceBookError as error:
|
||||||
|
machines.append({**item, "note": f"설계 중기사용료가 안 섬({error})"})
|
||||||
|
continue
|
||||||
|
item.update(design=_totals(design), **entries.get(code, {}))
|
||||||
|
entry = entries.get(code)
|
||||||
|
if entry:
|
||||||
|
money, diff, note = execution_hourly(
|
||||||
|
design,
|
||||||
|
Decimal(entry["unit_price_krw"]),
|
||||||
|
Decimal(entry["hours_per_unit"]),
|
||||||
|
Decimal(entry["cut_unit_krw"]),
|
||||||
|
entry["correction"],
|
||||||
|
)
|
||||||
|
item.update(note=note, diff_krw=str(diff))
|
||||||
|
if money is not None:
|
||||||
|
hourly[code] = money
|
||||||
|
item["execution"] = _totals(money)
|
||||||
|
machines.append(item)
|
||||||
|
exec_book = execution_book(book, hourly) if hourly else None
|
||||||
|
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
design_sum = Money3()
|
||||||
|
exec_sum = Money3()
|
||||||
|
for source in bill_rows:
|
||||||
|
row = dict(source)
|
||||||
|
if row.get("is_group") or not row.get("in_bill", True):
|
||||||
|
rows.append(row)
|
||||||
|
continue
|
||||||
|
quantity = row.get("quantity")
|
||||||
|
if quantity in (None, "") or row.get("unit_material_krw") is None:
|
||||||
|
row.update(execution_note="설계 단가가 안 선 줄 — 실행단가도 못 섬")
|
||||||
|
rows.append(row)
|
||||||
|
continue
|
||||||
|
unit_design = Money3(
|
||||||
|
material=_money(row.get("unit_material_krw")),
|
||||||
|
labor=_money(row.get("unit_labor_krw")),
|
||||||
|
expense=_money(row.get("unit_expense_krw")),
|
||||||
|
)
|
||||||
|
design_sum += bill_line(unit_design, Decimal(str(quantity)))
|
||||||
|
code = str(row.get("price_code") or "")
|
||||||
|
unit, note = unit_design, ""
|
||||||
|
changed = under.get(code, set()) & set(hourly)
|
||||||
|
if exec_book is not None and changed:
|
||||||
|
rebuilt, why = reassembled_unit(code, unit_design, book, exec_book)
|
||||||
|
if rebuilt is None:
|
||||||
|
note = f"중기 실행단가를 못 얹음({why}) — 설계 단가 그대로"
|
||||||
|
else:
|
||||||
|
unit, note = rebuilt, f"중기 실행단가 적용 — {', '.join(sorted(changed))}"
|
||||||
|
item_no = str(row.get("item_no"))
|
||||||
|
qty = Decimal(quantities.get(item_no, str(quantity)))
|
||||||
|
line = bill_line(unit, qty)
|
||||||
|
exec_sum += line
|
||||||
|
row.update(
|
||||||
|
execution_quantity=str(qty),
|
||||||
|
execution_quantity_changed=item_no in quantities,
|
||||||
|
execution_unit_material_krw=str(unit.material),
|
||||||
|
execution_unit_labor_krw=str(unit.labor),
|
||||||
|
execution_unit_expense_krw=str(unit.expense),
|
||||||
|
execution_unit_price_krw=str(unit.total),
|
||||||
|
execution_material_krw=str(line.material),
|
||||||
|
execution_labor_krw=str(line.labor),
|
||||||
|
execution_expense_krw=str(line.expense),
|
||||||
|
execution_amount_krw=str(line.total),
|
||||||
|
execution_note=note,
|
||||||
|
)
|
||||||
|
rows.append(row)
|
||||||
|
_group_sums(rows, "execution")
|
||||||
|
return {
|
||||||
|
"rows": rows,
|
||||||
|
"machines": machines,
|
||||||
|
"totals": {"design": _totals(design_sum), "execution": _totals(exec_sum)},
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""B09 실행예산 단계 탭 API — 설계 내역 → 실행예산 (PLAN 12장 · 랩탑 메인).
|
||||||
|
|
||||||
|
⚠ 설계 내역·단가표를 **읽기만** 한다(복사본에 중기 실행단가·실행수량을 얹음). 저장은
|
||||||
|
`estimation.execution` 한 칸 — 설계 내역·계약·원가계산서 저장본은 안 건드린다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_Execution import (
|
||||||
|
CORRECTIONS,
|
||||||
|
CUT_UNITS,
|
||||||
|
SETTINGS_KEY,
|
||||||
|
clean_settings,
|
||||||
|
execution_bill,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation — Execution"])
|
||||||
|
|
||||||
|
_NOT_FOUND = {"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}
|
||||||
|
|
||||||
|
|
||||||
|
async def _root(project_id: UUID) -> str | None:
|
||||||
|
from B09_Estimation.B09_Estimation_Router import _project_root_of
|
||||||
|
|
||||||
|
return await _project_root_of(project_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/estimation/execution")
|
||||||
|
async def get_execution(project_id: UUID) -> JSONResponse:
|
||||||
|
"""실행예산 한 장 — 설계 줄 옆에 실행수량·실행단가·실행금액 · 중기 실행단가 표 · 합계."""
|
||||||
|
from B09_Estimation.B09_Estimation_Router import _build_for, get_bill
|
||||||
|
from common_util.common_util_project_settings import estimation_settings
|
||||||
|
|
||||||
|
root = await _root(project_id)
|
||||||
|
if root is None:
|
||||||
|
return JSONResponse(status_code=404, content=_NOT_FOUND)
|
||||||
|
response = await get_bill(project_id)
|
||||||
|
bill = json.loads(bytes(response.body).decode("utf-8"))
|
||||||
|
if response.status_code != 200 or "rows" not in bill:
|
||||||
|
return JSONResponse(status_code=response.status_code or 502, content=bill)
|
||||||
|
stored, _ = clean_settings(dict(estimation_settings(root).get(SETTINGS_KEY) or {}))
|
||||||
|
# 조립본은 캐시 공유본 — `execution_bill` 이 단가표 복사본에만 중기 실행단가를 얹음(설계 불변).
|
||||||
|
result = execution_bill(bill["rows"], stored, build=await _build_for(project_id))
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"status": "success",
|
||||||
|
**result,
|
||||||
|
"settings": stored,
|
||||||
|
"fields": {
|
||||||
|
"cut_units": list(CUT_UNITS),
|
||||||
|
"corrections": [{"key": k, "label": label} for k, label in CORRECTIONS],
|
||||||
|
},
|
||||||
|
"bill_missing_count": len((bill.get("summary") or {}).get("missing") or []),
|
||||||
|
"limit_note": (
|
||||||
|
"실행예산 표본 0건 — 구조가 서는지까지만 확인됨 · 성분 가르기·기본보정은 확인 대기"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{project_id}/estimation/execution")
|
||||||
|
async def put_execution(project_id: UUID, body: dict[str, Any]) -> JSONResponse:
|
||||||
|
"""중기 실행단가 입력·실행수량 저장 — 틀린 칸이 있으면 아무것도 안 저장."""
|
||||||
|
from common_util.common_util_project_settings import save_section
|
||||||
|
|
||||||
|
root = await _root(project_id)
|
||||||
|
if root is None:
|
||||||
|
return JSONResponse(status_code=404, content=_NOT_FOUND)
|
||||||
|
cleaned, errors = clean_settings(body)
|
||||||
|
if errors:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"status": "error", "message": " · ".join(errors), "errors": errors},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
save_section(root, "estimation", {SETTINGS_KEY: cleaned}, replace_keys=(SETTINGS_KEY,))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("B09 실행예산 저장 실패: project_id=%s", project_id)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"status": "error", "message": "실행예산을 저장하지 못했습니다."},
|
||||||
|
)
|
||||||
|
return JSONResponse(content={"status": "success", "settings": cleaned})
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* B09_Estimation_UI_Tab_Execution.ts
|
||||||
|
* 실행예산 탭 — STmate 「시간/단위당 중기실행단가 계산 및 입력」(wM_Boq_ExecX)을 본뜸 (PLAN 12장 · 랩탑 메인).
|
||||||
|
*
|
||||||
|
* - 본문 위 = 중기 실행단가 표 — 최초단가(노·재·경) · 1단위당 중기사용료 · 1단위 = 시간 ·
|
||||||
|
* 절사 · 차액 보정 → 실행단가.
|
||||||
|
* - 본문 아래 = 설계 내역 줄 옆에 **실행수량** 칸 · 실행 단가 · 실행 금액.
|
||||||
|
* - ⚠ 값은 서버(`/estimation/execution`)가 설계 단가표를 복사해 셈 — 설계·계약은 안 바뀜.
|
||||||
|
* - ⚠ 실행예산 표본이 없어 「구조가 선다」까지만 확인된 화면 — 머리에 그 한계를 적음.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||||
|
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||||
|
import { API_BASE_URL } from "@config/config_frontend";
|
||||||
|
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
|
||||||
|
|
||||||
|
function L(key: keyof typeof ui_locales): string {
|
||||||
|
return ui_locales[key][currentLanguageIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Money {
|
||||||
|
material_krw: string;
|
||||||
|
labor_krw: string;
|
||||||
|
expense_krw: string;
|
||||||
|
total_krw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MachineEntry {
|
||||||
|
unit_price_krw: string;
|
||||||
|
hours_per_unit: string;
|
||||||
|
cut_unit_krw: string;
|
||||||
|
correction: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MachineRow {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
spec: string;
|
||||||
|
design?: Money;
|
||||||
|
execution?: Money;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecutionRow {
|
||||||
|
item_no: string;
|
||||||
|
name: string;
|
||||||
|
spec: string;
|
||||||
|
unit: string;
|
||||||
|
quantity: string | null;
|
||||||
|
is_group: boolean;
|
||||||
|
in_bill: boolean;
|
||||||
|
amount_krw: string | null;
|
||||||
|
execution_quantity?: string;
|
||||||
|
execution_unit_price_krw?: string;
|
||||||
|
execution_amount_krw?: string;
|
||||||
|
execution_note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
machines: Record<string, MachineEntry>;
|
||||||
|
quantities: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecutionDto {
|
||||||
|
status: string;
|
||||||
|
message?: string;
|
||||||
|
rows: ExecutionRow[];
|
||||||
|
machines: MachineRow[];
|
||||||
|
totals: { design: Money; execution: Money };
|
||||||
|
settings: Settings;
|
||||||
|
fields: { cut_units: string[]; corrections: { key: string; label: string }[] };
|
||||||
|
bill_missing_count: number;
|
||||||
|
limit_note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STYLE_ID = "b09-execution-styles";
|
||||||
|
function injectStyles(): void {
|
||||||
|
if (document.getElementById(STYLE_ID)) return;
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = STYLE_ID;
|
||||||
|
style.textContent = `
|
||||||
|
.b09ex { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; }
|
||||||
|
.b09ex__meta { font-size: 12px; color: var(--color-text-secondary); }
|
||||||
|
.b09ex__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); }
|
||||||
|
.b09ex__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.b09ex__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; }
|
||||||
|
.b09ex__table th, .b09ex__table td { border: 1px solid var(--color-border); padding: 2px 6px; }
|
||||||
|
.b09ex__table td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.b09ex__table tr.is-group td { font-weight: 600; }
|
||||||
|
.b09ex__table input { width: 7em; text-align: right; }
|
||||||
|
.b09ex__table input.is-changed { font-weight: 600; }
|
||||||
|
.b09ex__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||||
|
`;
|
||||||
|
document.head.append(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function el<K extends keyof HTMLElementTagNameMap>(
|
||||||
|
tag: K,
|
||||||
|
className = "",
|
||||||
|
text = "",
|
||||||
|
): HTMLElementTagNameMap[K] {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function won(value: string | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || value === "") return "";
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 프로젝트별 입력 캐시 — [저장] 전 값(지침 5장 · 자동저장 없음). */
|
||||||
|
const drafts = new Map<string, Settings>();
|
||||||
|
|
||||||
|
function draftOf(projectId: string, data: ExecutionDto): Settings {
|
||||||
|
let draft = drafts.get(projectId);
|
||||||
|
if (!draft) {
|
||||||
|
draft = {
|
||||||
|
machines: structuredClone(data.settings.machines),
|
||||||
|
quantities: { ...data.settings.quantities },
|
||||||
|
};
|
||||||
|
drafts.set(projectId, draft);
|
||||||
|
}
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpoint(projectId: string): string {
|
||||||
|
return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/execution`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchExecution(projectId: string): Promise<ExecutionDto> {
|
||||||
|
const response = await fetch(endpoint(projectId), { credentials: "include" });
|
||||||
|
const body = (await response.json()) as ExecutionDto;
|
||||||
|
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveExecution(projectId: string, values: Settings): Promise<void> {
|
||||||
|
const response = await fetch(endpoint(projectId), {
|
||||||
|
method: "PUT",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
const body = (await response.json()) as { message?: string };
|
||||||
|
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPanel(ctx: B09TabContext, data: ExecutionDto, reload: () => void): void {
|
||||||
|
const projectId = ctx.projectId as string;
|
||||||
|
const box = el("div", "b09ex__panel");
|
||||||
|
box.append(
|
||||||
|
createButton({
|
||||||
|
label: "저장",
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await saveExecution(projectId, draftOf(projectId, data));
|
||||||
|
drafts.delete(projectId);
|
||||||
|
showToast("실행예산 저장 — 설계·계약 내역은 그대로", "success");
|
||||||
|
reload();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : "저장 못 함", "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const t = data.totals;
|
||||||
|
for (const [label, part] of [
|
||||||
|
["재료비", "material"],
|
||||||
|
["노무비", "labor"],
|
||||||
|
["경비", "expense"],
|
||||||
|
["직접공사비", "total"],
|
||||||
|
] as const) {
|
||||||
|
box.append(
|
||||||
|
el(
|
||||||
|
"div",
|
||||||
|
"",
|
||||||
|
`${label} ${won(t.design[`${part}_krw`])} → ${won(t.execution[`${part}_krw`])}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ctx.panel.append(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
function input(value: string, onInput: (value: string) => void): HTMLInputElement {
|
||||||
|
const node = el("input");
|
||||||
|
node.type = "number";
|
||||||
|
node.min = "0";
|
||||||
|
node.value = value;
|
||||||
|
node.addEventListener("input", () => onInput(node.value));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(
|
||||||
|
options: { key: string; label: string }[],
|
||||||
|
value: string,
|
||||||
|
onChange: (value: string) => void,
|
||||||
|
): HTMLSelectElement {
|
||||||
|
const node = el("select");
|
||||||
|
for (const option of options) {
|
||||||
|
const item = el("option", "", option.label);
|
||||||
|
item.value = option.key;
|
||||||
|
node.append(item);
|
||||||
|
}
|
||||||
|
node.value = value;
|
||||||
|
node.addEventListener("change", () => onChange(node.value));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 중기 실행단가 표 — 사용료가 빈 중기는 설계 사용료 그대로. */
|
||||||
|
function drawMachines(data: ExecutionDto, draft: Settings): HTMLElement {
|
||||||
|
const box = el("div");
|
||||||
|
box.append(el("strong", "", `시간/단위당 중기실행단가 — 중기 ${data.machines.length}종`));
|
||||||
|
const table = el("table", "b09ex__table");
|
||||||
|
const head = el("tr");
|
||||||
|
for (const label of [
|
||||||
|
"코드",
|
||||||
|
"명칭",
|
||||||
|
"규격",
|
||||||
|
"최초 노무비",
|
||||||
|
"최초 재료비",
|
||||||
|
"최초 경비",
|
||||||
|
"최초 계",
|
||||||
|
"1단위당 중기사용료(원)",
|
||||||
|
"1단위 = 시간",
|
||||||
|
"절사",
|
||||||
|
"차액 보정",
|
||||||
|
"실행 노무비",
|
||||||
|
"실행 재료비",
|
||||||
|
"실행 경비",
|
||||||
|
"실행 계",
|
||||||
|
"비고",
|
||||||
|
]) {
|
||||||
|
head.append(el("th", "", label));
|
||||||
|
}
|
||||||
|
table.append(head);
|
||||||
|
const cuts = data.fields.cut_units.map((unit) => ({
|
||||||
|
key: unit,
|
||||||
|
label: `${won(unit)}원 미만절사`,
|
||||||
|
}));
|
||||||
|
for (const machine of data.machines) {
|
||||||
|
const entry = (): MachineEntry =>
|
||||||
|
(draft.machines[machine.code] ??= {
|
||||||
|
unit_price_krw: "",
|
||||||
|
hours_per_unit: "",
|
||||||
|
cut_unit_krw: "1",
|
||||||
|
correction: "basic",
|
||||||
|
});
|
||||||
|
const saved = draft.machines[machine.code];
|
||||||
|
const tr = el("tr");
|
||||||
|
const d = machine.design;
|
||||||
|
const x = machine.execution;
|
||||||
|
tr.append(
|
||||||
|
el("td", "", machine.code),
|
||||||
|
el("td", "", machine.name),
|
||||||
|
el("td", "", machine.spec ?? ""),
|
||||||
|
el("td", "num", won(d?.labor_krw)),
|
||||||
|
el("td", "num", won(d?.material_krw)),
|
||||||
|
el("td", "num", won(d?.expense_krw)),
|
||||||
|
el("td", "num", won(d?.total_krw)),
|
||||||
|
);
|
||||||
|
const cells: HTMLElement[] = [
|
||||||
|
input(saved?.unit_price_krw ?? "", (v) => (entry().unit_price_krw = v)),
|
||||||
|
input(saved?.hours_per_unit ?? "", (v) => (entry().hours_per_unit = v)),
|
||||||
|
select(cuts, saved?.cut_unit_krw ?? "1", (v) => (entry().cut_unit_krw = v)),
|
||||||
|
select(
|
||||||
|
data.fields.corrections,
|
||||||
|
saved?.correction ?? "basic",
|
||||||
|
(v) => (entry().correction = v),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (const cell of cells) {
|
||||||
|
const td = el("td");
|
||||||
|
td.append(cell);
|
||||||
|
tr.append(td);
|
||||||
|
}
|
||||||
|
tr.append(
|
||||||
|
el("td", "num", won(x?.labor_krw)),
|
||||||
|
el("td", "num", won(x?.material_krw)),
|
||||||
|
el("td", "num", won(x?.expense_krw)),
|
||||||
|
el("td", "num", won(x?.total_krw)),
|
||||||
|
el("td", "", machine.note ?? ""),
|
||||||
|
);
|
||||||
|
table.append(tr);
|
||||||
|
}
|
||||||
|
box.append(table);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBill(data: ExecutionDto, draft: Settings): HTMLElement {
|
||||||
|
const box = el("div");
|
||||||
|
box.append(el("strong", "", "실행예산 내역 — 최초수량 → 실행수량"));
|
||||||
|
const table = el("table", "b09ex__table");
|
||||||
|
const head = el("tr");
|
||||||
|
for (const label of [
|
||||||
|
"공종번호",
|
||||||
|
"명칭",
|
||||||
|
"규격",
|
||||||
|
"단위",
|
||||||
|
"최초수량",
|
||||||
|
"금액(설계)",
|
||||||
|
"실행수량",
|
||||||
|
"실행 단가",
|
||||||
|
"실행 금액",
|
||||||
|
"비고",
|
||||||
|
]) {
|
||||||
|
head.append(el("th", "", label));
|
||||||
|
}
|
||||||
|
table.append(head);
|
||||||
|
for (const row of data.rows) {
|
||||||
|
const tr = el("tr", row.is_group ? "is-group" : "");
|
||||||
|
tr.append(
|
||||||
|
el("td", "", row.item_no),
|
||||||
|
el("td", "", row.name),
|
||||||
|
el("td", "", row.spec ?? ""),
|
||||||
|
el("td", "", row.unit ?? ""),
|
||||||
|
el("td", "num", row.quantity ?? ""),
|
||||||
|
el("td", "num", won(row.amount_krw)),
|
||||||
|
);
|
||||||
|
const cell = el("td");
|
||||||
|
if (!row.is_group && row.in_bill && row.execution_quantity !== undefined) {
|
||||||
|
const qty = input(draft.quantities[row.item_no] ?? row.execution_quantity, (v) => {
|
||||||
|
if (v === "" || v === row.quantity) delete draft.quantities[row.item_no];
|
||||||
|
else draft.quantities[row.item_no] = v;
|
||||||
|
});
|
||||||
|
qty.step = "any";
|
||||||
|
qty.classList.toggle("is-changed", row.item_no in draft.quantities);
|
||||||
|
cell.append(qty);
|
||||||
|
}
|
||||||
|
tr.append(
|
||||||
|
cell,
|
||||||
|
el("td", "num", won(row.execution_unit_price_krw)),
|
||||||
|
el("td", "num", won(row.execution_amount_krw)),
|
||||||
|
el("td", "", row.execution_note ?? ""),
|
||||||
|
);
|
||||||
|
table.append(tr);
|
||||||
|
}
|
||||||
|
box.append(table);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBody(ctx: B09TabContext, data: ExecutionDto): void {
|
||||||
|
const draft = draftOf(ctx.projectId as string, data);
|
||||||
|
const wrap = el("div", "b09ex");
|
||||||
|
wrap.append(el("div", "b09ex__warn", `⚠ ${data.limit_note}`));
|
||||||
|
if (data.bill_missing_count) {
|
||||||
|
wrap.append(
|
||||||
|
el(
|
||||||
|
"div",
|
||||||
|
"b09ex__warn",
|
||||||
|
`⚠ 설계 내역 ${L("B09_Sheet_Missing")} ${data.bill_missing_count}${L("B09_Sheet_Count")} — 실행단가도 못 섬`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
wrap.append(
|
||||||
|
el("div", "b09ex__meta", "1단위당 중기사용료를 비우면 설계 사용료 그대로 — [저장]하면 반영"),
|
||||||
|
);
|
||||||
|
const scroll = el("div", "b09ex__scroll");
|
||||||
|
scroll.append(drawMachines(data, draft), drawBill(data, draft));
|
||||||
|
wrap.append(scroll);
|
||||||
|
ctx.body.append(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(ctx: B09TabContext): void {
|
||||||
|
injectStyles();
|
||||||
|
if (!ctx.projectId) {
|
||||||
|
ctx.body.append(el("div", "b09ex__meta", "프로젝트를 고르세요"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const load = (): void => {
|
||||||
|
ctx.body.replaceChildren(el("div", "b09ex__meta", "실행예산 계산 중…"));
|
||||||
|
ctx.panel.replaceChildren();
|
||||||
|
fetchExecution(ctx.projectId as string)
|
||||||
|
.then((data) => {
|
||||||
|
ctx.body.replaceChildren();
|
||||||
|
drawPanel(ctx, data, load);
|
||||||
|
drawBody(ctx, data);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
ctx.body.replaceChildren(
|
||||||
|
el(
|
||||||
|
"div",
|
||||||
|
"b09ex__warn",
|
||||||
|
`실행예산을 세우지 못함 — ${error instanceof Error ? error.message : ""}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const executionTab: B09Tab = {
|
||||||
|
key: "execution",
|
||||||
|
label: () => L("B09_Estimation_Tab_Execution"),
|
||||||
|
render,
|
||||||
|
};
|
||||||
@@ -66,6 +66,7 @@ from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_rou
|
|||||||
from B08_Quantity.B08_Quantity_Router_StructureSheet import router as b08_structure_sheet_router
|
from B08_Quantity.B08_Quantity_Router_StructureSheet import router as b08_structure_sheet_router
|
||||||
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
|
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
|
||||||
from B09_Estimation.B09_Estimation_Router_Contract import router as b09_contract_router
|
from B09_Estimation.B09_Estimation_Router_Contract import router as b09_contract_router
|
||||||
|
from B09_Estimation.B09_Estimation_Router_Execution import router as b09_execution_router
|
||||||
from B09_Estimation.B09_Estimation_Router_CostSheet import router as b09_cost_sheet_router
|
from B09_Estimation.B09_Estimation_Router_CostSheet import router as b09_cost_sheet_router
|
||||||
from B09_Estimation.B09_Estimation_Router_Edits import router as b09_edits_router
|
from B09_Estimation.B09_Estimation_Router_Edits import router as b09_edits_router
|
||||||
from B09_Estimation.B09_Estimation_Router_Factors import router as b09_factors_router
|
from B09_Estimation.B09_Estimation_Router_Factors import router as b09_factors_router
|
||||||
@@ -642,6 +643,7 @@ app.include_router(b08_structure_sheet_router, dependencies=protected_with_compa
|
|||||||
app.include_router(b09_estimation_router, dependencies=protected_with_company)
|
app.include_router(b09_estimation_router, dependencies=protected_with_company)
|
||||||
app.include_router(b09_cost_sheet_router, dependencies=protected_with_company)
|
app.include_router(b09_cost_sheet_router, dependencies=protected_with_company)
|
||||||
app.include_router(b09_contract_router, dependencies=protected_with_company)
|
app.include_router(b09_contract_router, dependencies=protected_with_company)
|
||||||
|
app.include_router(b09_execution_router, dependencies=protected_with_company)
|
||||||
app.include_router(b09_edits_router, dependencies=protected_with_company)
|
app.include_router(b09_edits_router, dependencies=protected_with_company)
|
||||||
app.include_router(b09_factors_router, dependencies=protected_with_company)
|
app.include_router(b09_factors_router, dependencies=protected_with_company)
|
||||||
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
|
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""실행예산 단계 — 설계 → 실행예산 **구조**가 서는가 (PLAN 12장 · 2026-09-14 브레인 배정).
|
||||||
|
|
||||||
|
⚠ 값이 맞는지는 못 잰다 — 실행예산 표본 0건(STmate 27번 §9 · 35번). 여기서 재는 것:
|
||||||
|
① 시간당 실행단가 = 1단위당 사용료 ÷ 시간 → 설계 성분 비율 → 성분마다 절사 → 차액 보정
|
||||||
|
② 「기본보정」은 뜻 미확인 — 차액을 안 몰고 남김
|
||||||
|
③ 중기(X)를 갈아 끼운 **복사본**으로 일위대가 다시 조립 · 설계 단가표·입력 줄 불변
|
||||||
|
④ 실행수량 · 조립값과 다른 줄은 설계 단가 + 까닭 · 묶음 줄 합 · 틀린 칸 거름
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import sys
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_Execution import ( # noqa: E402
|
||||||
|
clean_settings,
|
||||||
|
execution_bill,
|
||||||
|
execution_hourly,
|
||||||
|
)
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import ( # noqa: E402
|
||||||
|
Money3,
|
||||||
|
PriceBook,
|
||||||
|
PriceDetail,
|
||||||
|
PriceKind,
|
||||||
|
PriceTitle,
|
||||||
|
)
|
||||||
|
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild # noqa: E402
|
||||||
|
|
||||||
|
DESIGN_X = Money3(material=Decimal(2000), labor=Decimal(3000), expense=Decimal(1500))
|
||||||
|
|
||||||
|
|
||||||
|
def _parts(money: Money3) -> tuple[str, str, str]:
|
||||||
|
return (str(money.material), str(money.labor), str(money.expense))
|
||||||
|
|
||||||
|
|
||||||
|
def test_시간당_실행단가를_설계_성분_비율로_가르고_차액을_고른_비목에() -> None:
|
||||||
|
# 80,000원/일 ÷ 8시간 = 10,000 · 비율 2000:3000:1500 → 3076.9 / 4615.3 / 2307.6 → 절사 합 9,998
|
||||||
|
money, diff, note = execution_hourly(DESIGN_X, Decimal(80000), Decimal(8), Decimal(1), "labor")
|
||||||
|
assert _parts(money) == ("3076", "4617", "2307") and diff == 2 and "노무비" in note
|
||||||
|
money, _, _ = execution_hourly(DESIGN_X, Decimal(80000), Decimal(8), Decimal(100), "expense")
|
||||||
|
assert _parts(money) == ("3000", "4600", "2400") # 100원 미만 절사 · 차액 100 → 경비
|
||||||
|
|
||||||
|
|
||||||
|
def test_기본보정은_뜻_미확인이라_차액을_남긴다() -> None:
|
||||||
|
money, diff, note = execution_hourly(DESIGN_X, Decimal(80000), Decimal(8), Decimal(1), "basic")
|
||||||
|
assert money.total == 9998 and diff == 2 and "기본보정" in note
|
||||||
|
|
||||||
|
|
||||||
|
def _build() -> UnitPriceBuild:
|
||||||
|
"""중기 X-G(연료 2 · 운전원 1 · 취득가 0.0003) · B-T(X-G 0.5 + 노임 1) · B-N(연료만)."""
|
||||||
|
book = PriceBook()
|
||||||
|
for code, kind, price in (
|
||||||
|
("M-F", PriceKind.MATERIAL, "1000"),
|
||||||
|
("L-O", PriceKind.LABOR, "3000"),
|
||||||
|
("L-B", PriceKind.LABOR, "2000"),
|
||||||
|
("S-G", PriceKind.MACHINE_BASE, "5000000"),
|
||||||
|
):
|
||||||
|
slots = [None] * 6
|
||||||
|
slots[5] = Decimal(price)
|
||||||
|
book.add_title(PriceTitle(code=code, kind=kind, name=code, slots=slots))
|
||||||
|
book.add_title(PriceTitle(code="X-G", kind=PriceKind.MACHINE_HOURLY, name="굴삭기"))
|
||||||
|
book.add_title(PriceTitle(code="B-T", kind=PriceKind.UNIT_PRICE, name="흙깎기"))
|
||||||
|
book.add_title(PriceTitle(code="B-N", kind=PriceKind.UNIT_PRICE, name="자재만"))
|
||||||
|
for parent, ref, qty in (
|
||||||
|
("X-G", "M-F", "2"),
|
||||||
|
("X-G", "L-O", "1"),
|
||||||
|
("X-G", "S-G", "0.0003"),
|
||||||
|
("B-T", "X-G", "0.5"),
|
||||||
|
("B-T", "L-B", "1"),
|
||||||
|
("B-N", "M-F", "1"),
|
||||||
|
):
|
||||||
|
book.add_detail(PriceDetail(parent, ref, Decimal(qty)))
|
||||||
|
return UnitPriceBuild(book=book)
|
||||||
|
|
||||||
|
|
||||||
|
def _line(item_no: str, code: str, unit: tuple[str, str, str], qty: str = "3") -> dict:
|
||||||
|
return {
|
||||||
|
"item_no": item_no,
|
||||||
|
"is_group": False,
|
||||||
|
"in_bill": True,
|
||||||
|
"quantity": qty,
|
||||||
|
"price_code": code,
|
||||||
|
"unit_material_krw": unit[0],
|
||||||
|
"unit_labor_krw": unit[1],
|
||||||
|
"unit_expense_krw": unit[2],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ROWS = [
|
||||||
|
{"item_no": "2", "is_group": True, "in_bill": True},
|
||||||
|
_line("2.1", "B-T", ("1000", "3500", "750")), # 설계 = 조립값
|
||||||
|
_line("2.2", "B-N", ("1000", "0", "0")), # 중기 없음
|
||||||
|
_line("2.3", "B-T", ("1200", "3500", "750")), # 수동 단가 — 조립값과 다름
|
||||||
|
]
|
||||||
|
SETTINGS = {
|
||||||
|
"machines": {
|
||||||
|
"X-G": {
|
||||||
|
"unit_price_krw": "80000",
|
||||||
|
"hours_per_unit": "8",
|
||||||
|
"cut_unit_krw": "1",
|
||||||
|
"correction": "labor",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quantities": {"2.2": "5"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_중기를_갈아_끼운_복사본으로_다시_조립하고_설계는_그대로() -> None:
|
||||||
|
build = _build()
|
||||||
|
before = (build.book.resolve("X-G"), build.book.resolve("B-T"), len(build.book.titles))
|
||||||
|
rows_before = copy.deepcopy(ROWS)
|
||||||
|
result = execution_bill(ROWS, clean_settings(SETTINGS)[0], build=build)
|
||||||
|
assert (build.book.resolve("X-G"), build.book.resolve("B-T"), len(build.book.titles)) == before
|
||||||
|
assert ROWS == rows_before
|
||||||
|
rows = {row["item_no"]: row for row in result["rows"]}
|
||||||
|
# X-G 실행 3076/4617/2307 × 0.5 = 1538 / 2308.5 / 1153.5 + 노임 2000 → 원 미만 절사
|
||||||
|
assert (
|
||||||
|
rows["2.1"]["execution_unit_material_krw"],
|
||||||
|
rows["2.1"]["execution_unit_labor_krw"],
|
||||||
|
rows["2.1"]["execution_unit_expense_krw"],
|
||||||
|
) == ("1538", "4308", "1153")
|
||||||
|
assert "X-G" in rows["2.1"]["execution_note"]
|
||||||
|
machine = result["machines"][0]
|
||||||
|
assert machine["code"] == "X-G" and machine["execution"]["total_krw"] == "10000"
|
||||||
|
assert machine["design"]["total_krw"] == "6500"
|
||||||
|
|
||||||
|
|
||||||
|
def test_실행수량과_중기_없는_줄() -> None:
|
||||||
|
rows = {r["item_no"]: r for r in execution_bill(ROWS, SETTINGS, build=_build())["rows"]}
|
||||||
|
assert (
|
||||||
|
rows["2.2"]["execution_quantity"] == "5" and rows["2.2"]["execution_amount_krw"] == "5000"
|
||||||
|
)
|
||||||
|
assert rows["2.2"]["execution_note"] == "" and rows["2.1"]["execution_quantity"] == "3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_조립값과_다른_줄은_설계_단가와_까닭() -> None:
|
||||||
|
row = execution_bill(ROWS, SETTINGS, build=_build())["rows"][3]
|
||||||
|
assert row["execution_unit_material_krw"] == "1200" and "못 얹음" in row["execution_note"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_설정이_없으면_설계와_같고_묶음_줄_합() -> None:
|
||||||
|
result = execution_bill(ROWS, {}, build=_build())
|
||||||
|
assert result["totals"]["design"] == result["totals"]["execution"]
|
||||||
|
group = result["rows"][0]
|
||||||
|
children = [r for r in result["rows"] if not r.get("is_group")]
|
||||||
|
assert Decimal(group["execution_amount_krw"]) == sum(
|
||||||
|
Decimal(r["execution_amount_krw"]) for r in children
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_틀린_칸은_거른다() -> None:
|
||||||
|
cleaned, errors = clean_settings(
|
||||||
|
{
|
||||||
|
"machines": {
|
||||||
|
"X-A": {"unit_price_krw": ""},
|
||||||
|
"X-B": {"unit_price_krw": "100", "hours_per_unit": "0"},
|
||||||
|
"X-C": {"unit_price_krw": "100", "hours_per_unit": "8", "cut_unit_krw": "7"},
|
||||||
|
},
|
||||||
|
"quantities": {"1.1": "-1", "1.2": ""},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert len(errors) == 2 and list(cleaned["machines"]) == ["X-C"]
|
||||||
|
assert cleaned["machines"]["X-C"]["cut_unit_krw"] == "1" and cleaned["quantities"] == {}
|
||||||
@@ -9,4 +9,5 @@
|
|||||||
export const ui_locales_b4 = {
|
export const ui_locales_b4 = {
|
||||||
B09_Estimation_Tab_RateTable: ["제비율 요율표", "Overhead Rate Table"],
|
B09_Estimation_Tab_RateTable: ["제비율 요율표", "Overhead Rate Table"],
|
||||||
B09_Estimation_Tab_Contract: ["계약내역", "Contract Bill"],
|
B09_Estimation_Tab_Contract: ["계약내역", "Contract Bill"],
|
||||||
|
B09_Estimation_Tab_Execution: ["실행예산", "Execution Budget"],
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user