Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -0,0 +1,286 @@
|
|||||||
|
"""B09 기성 단계 — 계약내역 → 기성내역 · 기성 제잡비 계산서 (PLAN 12장 · 2026-09-14 배정).
|
||||||
|
|
||||||
|
근거: STmate 분석 `27_계약_실행_기성_단계.md` §3 (`wM_KanJub_K` 「기성 제잡비 계산서」) ·
|
||||||
|
`22_UI_폼_분석.md` §3.5 · `35_형식과_단계의_공통과_차이.md` §3 ·
|
||||||
|
원자료 `ui_form_catalog.txt` 133~137행.
|
||||||
|
· ⭐ 간접비를 설계처럼 줄마다 밑수 × 요율로 다시 셈하지 **않음** —
|
||||||
|
「금회직접공사비 × 계약제잡비율」
|
||||||
|
· 계약(도급액) · 전회 · 금회 · 누계 네 칸과 각 비율(전율·금율·누율)
|
||||||
|
· 부가세 (0) 직접입력 · (1) 공급가액의 10% · (2) 재료비의 10% · (3) 재료비+산출경비의 10%
|
||||||
|
|
||||||
|
⚠ **계약을 안 건드린다** — 계약내역 줄(`contract_bill` 결과)과 계약 원가계산서(같은 엔진에 계약
|
||||||
|
직접비)를 **읽기만** 해서 파생한다.
|
||||||
|
⚠ **값이 맞다가 아니라 구조가 선다까지** — 기성 표본 0건(27번 §9 · 35번 「회차별 값과
|
||||||
|
`QTY1_B/N/O/H` 뜻 미확인」). 그 네 칸은 쓰지 않고, 아래는 **구조로 읽은 것**이라 확인 대기:
|
||||||
|
① 계약잡비율 = 계약 원가계산서 그 줄 금액 ÷ 계약 직접공사비(격자 `계약잡비율` 열이 줄마다 있음)
|
||||||
|
② 회차마다 금회 금액을 원 미만 절사해 쌓고 전회 = 앞 회차 금회의 합 · 누계 = 전회 + 금회
|
||||||
|
③ 제잡비 줄 = 간접노무비 ~ 부가세 직전(11번 §10) — 간접재료비·관급·분리발주 폐기물은 밖
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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
|
||||||
|
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_PriceBook import Money3
|
||||||
|
|
||||||
|
_ZERO = Decimal(0)
|
||||||
|
_HUNDRED = Decimal(100)
|
||||||
|
|
||||||
|
#: 저장 자리 — `estimation` 구획 안 한 칸.
|
||||||
|
SETTINGS_KEY = "progress"
|
||||||
|
|
||||||
|
#: 부가세 — `cmBx_Buga` 표기 그대로. 키는 설계 원가계산서 `VAT_MODES` 와 같은 뜻이면 같은 키.
|
||||||
|
VAT_CHOICES: tuple[tuple[str, str], ...] = (
|
||||||
|
("manual", "(0) 직접입력"),
|
||||||
|
("supply", "(1) 공급가액의 10%"),
|
||||||
|
("material", "(2) 재료비의 10%"),
|
||||||
|
("forest_coop", "(3) 재료비+산출경비의 10%"),
|
||||||
|
)
|
||||||
|
#: 화면 표기 「10%」 그대로.
|
||||||
|
_VAT_PERCENT = Decimal(10)
|
||||||
|
|
||||||
|
#: 제잡비 줄 — 간접노무비 ~ 부가세 직전. 설계 원가계산서 서식 차례.
|
||||||
|
_ITEM_ORDER: tuple[tuple[str, str], ...] = (
|
||||||
|
("indirect_labor_cost", "간접노무비"),
|
||||||
|
*EXPENSE_ORDER,
|
||||||
|
("general_overhead", "일반관리비"),
|
||||||
|
("profit", "이윤"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_COLUMNS = ("previous", "current", "cumulative")
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any) -> Decimal | None:
|
||||||
|
try:
|
||||||
|
number = Decimal(str(value))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return None
|
||||||
|
return number if number.is_finite() and number >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _floor(value: Decimal) -> Decimal:
|
||||||
|
return value.to_integral_value(rounding=ROUND_FLOOR)
|
||||||
|
|
||||||
|
|
||||||
|
def _pct(part: Decimal, whole: Decimal) -> str | None:
|
||||||
|
return str((part / whole * _HUNDRED).quantize(Decimal("0.001"))) if whole else None
|
||||||
|
|
||||||
|
|
||||||
|
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}")
|
||||||
|
rounds.append(
|
||||||
|
{
|
||||||
|
"quantities": quantities,
|
||||||
|
"vat_mode": mode,
|
||||||
|
"vat_manual_krw": str(_number(manual)) if _number(manual) is not None else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"rounds": rounds}, errors
|
||||||
|
|
||||||
|
|
||||||
|
def _leaf(row: dict[str, Any]) -> bool:
|
||||||
|
return (
|
||||||
|
not row.get("is_group")
|
||||||
|
and row.get("in_bill", True)
|
||||||
|
and row.get("contract_amount_krw") is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unit(row: dict[str, Any]) -> Money3:
|
||||||
|
return Money3(
|
||||||
|
material=_money(row.get("contract_unit_material_krw")),
|
||||||
|
labor=_money(row.get("contract_unit_labor_krw")),
|
||||||
|
expense=_money(row.get("contract_unit_expense_krw")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cost_items(cost_data: Any, cost_result: Any) -> list[tuple[str, str, Decimal]]:
|
||||||
|
"""계약 원가계산서의 제잡비 줄 (키, 이름, 도급액) — 분리발주 폐기물은 도급 밖이라 뺌."""
|
||||||
|
items = []
|
||||||
|
for key, name in _ITEM_ORDER:
|
||||||
|
if not cost_result.has(key):
|
||||||
|
continue
|
||||||
|
if key == "waste_disposal" and cost_data.waste_separate_order:
|
||||||
|
continue
|
||||||
|
items.append((key, name, cost_result.amount(key)))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _round_money(
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
entry: dict[str, Any],
|
||||||
|
items: list[tuple[str, str, Decimal]],
|
||||||
|
contract_direct: Decimal,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""한 회차의 금회 — 줄 금액 · 직접공사비 · 제잡비 줄 · 공급가액 · 부가세."""
|
||||||
|
lines: dict[str, Money3] = {}
|
||||||
|
direct = Money3()
|
||||||
|
for row in rows:
|
||||||
|
qty = entry["quantities"].get(str(row.get("item_no")))
|
||||||
|
if qty is None or not _leaf(row):
|
||||||
|
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:
|
||||||
|
base = vat_base(entry["vat_mode"], supply, direct.material, direct.expense, _ZERO)
|
||||||
|
vat = _floor(base * _VAT_PERCENT / _HUNDRED)
|
||||||
|
return {
|
||||||
|
"lines": lines,
|
||||||
|
"direct": direct,
|
||||||
|
"items": item_amounts,
|
||||||
|
"supply": supply,
|
||||||
|
"vat": vat,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def progress_sheet(
|
||||||
|
contract_rows: list[dict[str, Any]],
|
||||||
|
cost_data: Any,
|
||||||
|
cost_result: Any,
|
||||||
|
settings: dict[str, Any],
|
||||||
|
round_no: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""기성내역 · 기성 제잡비 계산서 한 장 — `round_no`(1부터) 회차를 금회로. 없으면 마지막 회차."""
|
||||||
|
rounds = settings.get("rounds") or []
|
||||||
|
current = len(rounds) if round_no is None else max(0, min(round_no, len(rounds)))
|
||||||
|
contract_direct = sum(
|
||||||
|
(_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}
|
||||||
|
before = per_round[: max(current - 1, 0)]
|
||||||
|
now = per_round[current - 1] if current else empty
|
||||||
|
|
||||||
|
def four(pick) -> dict[str, Decimal]:
|
||||||
|
"""전회(앞 회차 금회의 합) · 금회 · 누계(전회 + 금회)."""
|
||||||
|
previous = sum((pick(r) for r in before), _ZERO)
|
||||||
|
return {"previous": previous, "current": pick(now), "cumulative": previous + pick(now)}
|
||||||
|
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for source in contract_rows:
|
||||||
|
row = dict(source)
|
||||||
|
rows.append(row)
|
||||||
|
if not _leaf(row):
|
||||||
|
continue
|
||||||
|
item_no = str(row.get("item_no"))
|
||||||
|
qty_col = {
|
||||||
|
"previous": sum(
|
||||||
|
(Decimal(rounds[i]["quantities"].get(item_no, "0")) for i in range(len(before))),
|
||||||
|
_ZERO,
|
||||||
|
),
|
||||||
|
"current": Decimal(rounds[current - 1]["quantities"].get(item_no, "0"))
|
||||||
|
if current
|
||||||
|
else _ZERO,
|
||||||
|
}
|
||||||
|
qty_col["cumulative"] = qty_col["previous"] + qty_col["current"]
|
||||||
|
money = {
|
||||||
|
"previous": sum((r["lines"].get(item_no, Money3()) for r in before), Money3()),
|
||||||
|
"current": now["lines"].get(item_no, Money3()),
|
||||||
|
}
|
||||||
|
money["cumulative"] = money["previous"] + money["current"]
|
||||||
|
contract_qty = _money(row.get("quantity"))
|
||||||
|
for col in _COLUMNS:
|
||||||
|
row.update(
|
||||||
|
{
|
||||||
|
f"progress_{col}_quantity": str(qty_col[col]),
|
||||||
|
f"progress_{col}_material_krw": str(money[col].material),
|
||||||
|
f"progress_{col}_labor_krw": str(money[col].labor),
|
||||||
|
f"progress_{col}_expense_krw": str(money[col].expense),
|
||||||
|
f"progress_{col}_amount_krw": str(money[col].total),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
row["progress_pct"] = _pct(qty_col["cumulative"], contract_qty)
|
||||||
|
row["progress_remaining_quantity"] = str(contract_qty - qty_col["cumulative"])
|
||||||
|
row["progress_note"] = (
|
||||||
|
"누계 기성수량이 계약 수량을 넘음" if qty_col["cumulative"] > contract_qty else ""
|
||||||
|
)
|
||||||
|
for col in _COLUMNS:
|
||||||
|
_group_sums(rows, f"progress_{col}")
|
||||||
|
|
||||||
|
contract_items_total = sum((amount for _, _, amount in items), _ZERO)
|
||||||
|
item_rows = []
|
||||||
|
for key, name, amount in items:
|
||||||
|
values = four(lambda r, k=key: r["items"].get(k, _ZERO))
|
||||||
|
item_rows.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"name": name,
|
||||||
|
"contract_krw": str(amount),
|
||||||
|
"contract_ratio_pct": _pct(amount, contract_direct),
|
||||||
|
**{f"{col}_krw": str(values[col]) for col in _COLUMNS},
|
||||||
|
**{f"{col}_pct": _pct(values[col], amount) for col in _COLUMNS},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
contract_supply = contract_direct + contract_items_total
|
||||||
|
summary = []
|
||||||
|
for key, name, contract_amount, pick in (
|
||||||
|
("direct", "직접공사비", contract_direct, lambda r: r["direct"].total),
|
||||||
|
("items", "제잡비 계", contract_items_total, lambda r: sum(r["items"].values(), _ZERO)),
|
||||||
|
("supply", "공급가액", contract_supply, lambda r: r["supply"]),
|
||||||
|
("vat", "부가가치세", cost_result.amount("vat"), lambda r: r["vat"]),
|
||||||
|
(
|
||||||
|
"total",
|
||||||
|
"기성금액",
|
||||||
|
contract_supply + cost_result.amount("vat"),
|
||||||
|
lambda r: r["supply"] + r["vat"],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
values = four(pick)
|
||||||
|
summary.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"name": name,
|
||||||
|
"contract_krw": str(contract_amount),
|
||||||
|
**{f"{col}_krw": str(values[col]) for col in _COLUMNS},
|
||||||
|
**{f"{col}_pct": _pct(values[col], contract_amount) for col in _COLUMNS},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
notes = []
|
||||||
|
if cost_data.indirect_material_krw:
|
||||||
|
notes.append(
|
||||||
|
f"계약 간접재료비 {cost_data.indirect_material_krw:,.0f}원은 기성 제잡비 줄 밖"
|
||||||
|
"(원자료 「간접노무비부터 부가세 직전」) — 확인 대기"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"rows": rows,
|
||||||
|
"items": item_rows,
|
||||||
|
"summary": summary,
|
||||||
|
"contract_overhead_ratio_pct": _pct(contract_items_total, contract_direct),
|
||||||
|
"round": current,
|
||||||
|
"round_count": len(rounds),
|
||||||
|
"vat": rounds[current - 1] if current else None,
|
||||||
|
"notes": notes,
|
||||||
|
}
|
||||||
@@ -32,9 +32,13 @@ async def _root(project_id: UUID) -> str | None:
|
|||||||
return await _project_root_of(project_id)
|
return await _project_root_of(project_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/estimation/contract")
|
async def contract_for(
|
||||||
async def get_contract(project_id: UUID) -> JSONResponse:
|
project_id: UUID,
|
||||||
"""계약내역 한 장 — 설계 줄 옆에 계약단가·계약금액 · 설계↔계약 합계 · 옵션."""
|
) -> tuple[str, dict[str, Any], dict[str, Any], dict[str, Any]] | JSONResponse:
|
||||||
|
"""(저장 폴더, 설계 내역, 계약 조건, 계약내역) — 못 서면 그 까닭 응답.
|
||||||
|
|
||||||
|
기성도 이 길로 계약본을 받음(계약 값을 따로 셈하지 않음).
|
||||||
|
"""
|
||||||
from B09_Estimation.B09_Estimation_Router import _build_for, get_bill
|
from B09_Estimation.B09_Estimation_Router import _build_for, get_bill
|
||||||
from common_util.common_util_project_settings import estimation_settings
|
from common_util.common_util_project_settings import estimation_settings
|
||||||
|
|
||||||
@@ -51,7 +55,16 @@ async def get_contract(project_id: UUID) -> JSONResponse:
|
|||||||
stored, _ = clean_settings(dict(estimation_settings(root).get(SETTINGS_KEY) or {}))
|
stored, _ = clean_settings(dict(estimation_settings(root).get(SETTINGS_KEY) or {}))
|
||||||
# 조립본은 캐시 공유본 — `contract_bill` 이 단가표 복사본에만 적용률을 얹음(설계 불변).
|
# 조립본은 캐시 공유본 — `contract_bill` 이 단가표 복사본에만 적용률을 얹음(설계 불변).
|
||||||
build = await _build_for(project_id) if stored.get("apply_to_base_prices") else None
|
build = await _build_for(project_id) if stored.get("apply_to_base_prices") else None
|
||||||
result = contract_bill(bill["rows"], stored, build=build)
|
return root, bill, stored, contract_bill(bill["rows"], stored, build=build)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/estimation/contract")
|
||||||
|
async def get_contract(project_id: UUID) -> JSONResponse:
|
||||||
|
"""계약내역 한 장 — 설계 줄 옆에 계약단가·계약금액 · 설계↔계약 합계 · 옵션."""
|
||||||
|
found = await contract_for(project_id)
|
||||||
|
if isinstance(found, JSONResponse):
|
||||||
|
return found
|
||||||
|
_, bill, stored, result = found
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"status": "success",
|
"status": "success",
|
||||||
|
|||||||
@@ -69,6 +69,20 @@ def _waste(bill: dict[str, Any], quantity: dict[str, Any]) -> tuple[Decimal, boo
|
|||||||
return amount, separate, f"임목폐기물 {tons:,.2f}톤 × {price:,.0f}원 — ⚠ 수동 단가(미확정)"
|
return amount, separate, f"임목폐기물 {tons:,.2f}톤 × {price:,.0f}원 — ⚠ 수동 단가(미확정)"
|
||||||
|
|
||||||
|
|
||||||
|
def cost_from_bill(root: str, bill: dict[str, Any], direct: dict[str, Decimal]):
|
||||||
|
"""(엔진 입력, 원가계산 결과, 폐기물 사유) — 직접비만 갈아 끼우면 계약·기성도 같은 길로 섬.
|
||||||
|
|
||||||
|
기준 입력·요율 덮어쓰기·폐기물 톤은 프로젝트 저장값 그대로. `RateLookupError` 는 부른 쪽이 받음.
|
||||||
|
"""
|
||||||
|
from common_util.common_util_project_settings import estimation_settings, quantity_settings
|
||||||
|
|
||||||
|
stored = dict(estimation_settings(root).get(SETTINGS_KEY) or {})
|
||||||
|
waste, separate, waste_note = _waste(bill, quantity_settings(root))
|
||||||
|
overrides = tuple(estimation_settings(root).get(OVERRIDES_KEY) or ())
|
||||||
|
data = cost_input(direct, stored, waste, separate, overrides)
|
||||||
|
return data, calculate_cost(data), waste_note
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/estimation/cost-sheet")
|
@router.get("/{project_id}/estimation/cost-sheet")
|
||||||
async def get_cost_sheet(project_id: UUID, form: str = "general") -> JSONResponse:
|
async def get_cost_sheet(project_id: UUID, form: str = "general") -> JSONResponse:
|
||||||
"""원가계산서 한 장 — 서식 줄 · 기준 입력 선택지 · 상태줄.
|
"""원가계산서 한 장 — 서식 줄 · 기준 입력 선택지 · 상태줄.
|
||||||
@@ -77,7 +91,7 @@ async def get_cost_sheet(project_id: UUID, form: str = "general") -> JSONRespons
|
|||||||
막고 사유 · 0 원으로 안 채움).
|
막고 사유 · 0 원으로 안 채움).
|
||||||
"""
|
"""
|
||||||
from B09_Estimation.B09_Estimation_Router import get_bill
|
from B09_Estimation.B09_Estimation_Router import get_bill
|
||||||
from common_util.common_util_project_settings import estimation_settings, quantity_settings
|
from common_util.common_util_project_settings import estimation_settings
|
||||||
|
|
||||||
root = await _root(project_id)
|
root = await _root(project_id)
|
||||||
if root is None:
|
if root is None:
|
||||||
@@ -97,12 +111,10 @@ async def get_cost_sheet(project_id: UUID, form: str = "general") -> JSONRespons
|
|||||||
for part in ("material", "labor", "expense")
|
for part in ("material", "labor", "expense")
|
||||||
}
|
}
|
||||||
stored = dict(estimation_settings(root).get(SETTINGS_KEY) or {})
|
stored = dict(estimation_settings(root).get(SETTINGS_KEY) or {})
|
||||||
waste, separate, waste_note = _waste(bill, quantity_settings(root))
|
|
||||||
overrides = tuple(estimation_settings(root).get(OVERRIDES_KEY) or ())
|
overrides = tuple(estimation_settings(root).get(OVERRIDES_KEY) or ())
|
||||||
data = cost_input(direct, stored, waste, separate, overrides)
|
|
||||||
try:
|
try:
|
||||||
|
data, result, waste_note = cost_from_bill(root, bill, direct)
|
||||||
dataset = load_rate_dataset(data.rate_file_name)
|
dataset = load_rate_dataset(data.rate_file_name)
|
||||||
result = calculate_cost(data)
|
|
||||||
except RateLookupError as error:
|
except RateLookupError as error:
|
||||||
return JSONResponse(status_code=422, content={"status": "error", "message": str(error)})
|
return JSONResponse(status_code=422, content={"status": "error", "message": str(error)})
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""B09 기성 단계 탭 API — 계약내역 → 기성내역 · 기성 제잡비 계산서 (PLAN 12장 · 랩탑 메인).
|
||||||
|
|
||||||
|
⚠ 계약내역(`contract_for`)과 계약 원가계산서(`cost_from_bill` 에 계약 직접비)를 **읽기만** 한다.
|
||||||
|
저장은 `estimation.progress` 한 칸(회차 목록) — 설계·계약·원가계산서 저장본은 안 건드린다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_Progress import (
|
||||||
|
SETTINGS_KEY,
|
||||||
|
VAT_CHOICES,
|
||||||
|
clean_settings,
|
||||||
|
progress_sheet,
|
||||||
|
)
|
||||||
|
from B09_Estimation.B09_Estimation_Rates import RateLookupError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation — Progress"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/estimation/progress")
|
||||||
|
async def get_progress(project_id: UUID, round: int | None = None) -> JSONResponse:
|
||||||
|
"""기성 한 장 — `round` 회차(1부터)를 금회로 · 없으면 마지막 회차."""
|
||||||
|
from B09_Estimation.B09_Estimation_Router_Contract import contract_for
|
||||||
|
from B09_Estimation.B09_Estimation_Router_CostSheet import cost_from_bill
|
||||||
|
from common_util.common_util_project_settings import estimation_settings
|
||||||
|
|
||||||
|
found = await contract_for(project_id)
|
||||||
|
if isinstance(found, JSONResponse):
|
||||||
|
return found
|
||||||
|
root, bill, _, contract = found
|
||||||
|
totals = contract["totals"]["contract"]
|
||||||
|
direct = {part: Decimal(totals[f"{part}_krw"]) for part in ("material", "labor", "expense")}
|
||||||
|
try:
|
||||||
|
cost_data, cost_result, _ = cost_from_bill(root, bill, direct)
|
||||||
|
except RateLookupError as error:
|
||||||
|
return JSONResponse(status_code=422, content={"status": "error", "message": str(error)})
|
||||||
|
stored, _ = clean_settings(dict(estimation_settings(root).get(SETTINGS_KEY) or {}))
|
||||||
|
sheet = progress_sheet(contract["rows"], cost_data, cost_result, stored, round)
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"status": "success",
|
||||||
|
**sheet,
|
||||||
|
"settings": stored,
|
||||||
|
"fields": {"vat": [{"key": k, "label": label} for k, label in VAT_CHOICES]},
|
||||||
|
"limit_note": (
|
||||||
|
"기성 표본 0건 — 구조가 서는지까지만 확인됨 · 계약잡비율(도급액 ÷ 계약 직접공사비)·"
|
||||||
|
"회차별 절사는 구조로 읽음 · 사정·절사 선택 미구현"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{project_id}/estimation/progress")
|
||||||
|
async def put_progress(project_id: UUID, body: dict[str, Any]) -> JSONResponse:
|
||||||
|
"""회차 목록 저장 — 틀린 칸이 있으면 아무것도 안 저장."""
|
||||||
|
from B09_Estimation.B09_Estimation_Router import _project_root_of
|
||||||
|
from common_util.common_util_project_settings import save_section
|
||||||
|
|
||||||
|
root = await _project_root_of(project_id)
|
||||||
|
if root is None:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
|
||||||
|
)
|
||||||
|
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,370 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* B09_Estimation_UI_Tab_Progress.ts
|
||||||
|
* 기성 탭 — STmate 「기성 제잡비 계산서」(wM_KanJub_K)와 기성내역서(계약|전회|금회|누계)를 본뜸 (PLAN 12장 · 랩탑 메인).
|
||||||
|
*
|
||||||
|
* - 좌측 = 회차 고르기 · [회차 추가] · 부가세 방식 넷(직접입력이면 금액) · [저장].
|
||||||
|
* - 본문 위 = 기성 제잡비 계산서 — 도급액 · 계약잡비율 · 전회/금회/누계 금액과 율.
|
||||||
|
* - 본문 아래 = 기성내역 — 계약 · 전회 · **금회 기성수량 칸** · 누계 · 기성(%) · 잔량.
|
||||||
|
* - ⚠ 값은 서버(`/estimation/progress`)가 계약내역·계약 원가계산서에서 파생 — 계약은 안 바뀜.
|
||||||
|
* - ⚠ 기성 표본이 없어 「구조가 선다」까지만 확인된 화면 — 머리에 그 한계를 적음.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Column = "previous" | "current" | "cumulative";
|
||||||
|
const COLUMNS: [Column, string, string][] = [
|
||||||
|
["previous", "전회금액", "전율"],
|
||||||
|
["current", "금회금액", "금율"],
|
||||||
|
["cumulative", "누계금액", "누율"],
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Round {
|
||||||
|
quantities: Record<string, string>;
|
||||||
|
vat_mode: string;
|
||||||
|
vat_manual_krw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AmountRow {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
contract_krw: string;
|
||||||
|
contract_ratio_pct?: string | null;
|
||||||
|
[column: string]: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProgressRow {
|
||||||
|
item_no: string;
|
||||||
|
name: string;
|
||||||
|
spec: string;
|
||||||
|
unit: string;
|
||||||
|
quantity: string | null;
|
||||||
|
is_group: boolean;
|
||||||
|
in_bill: boolean;
|
||||||
|
contract_amount_krw?: string;
|
||||||
|
progress_pct?: string | null;
|
||||||
|
progress_remaining_quantity?: string;
|
||||||
|
progress_note?: string;
|
||||||
|
[column: string]: string | boolean | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProgressDto {
|
||||||
|
status: string;
|
||||||
|
message?: string;
|
||||||
|
rows: ProgressRow[];
|
||||||
|
items: AmountRow[];
|
||||||
|
summary: AmountRow[];
|
||||||
|
contract_overhead_ratio_pct: string | null;
|
||||||
|
round: number;
|
||||||
|
round_count: number;
|
||||||
|
notes: string[];
|
||||||
|
settings: { rounds: Round[] };
|
||||||
|
fields: { vat: { key: string; label: string }[] };
|
||||||
|
limit_note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STYLE_ID = "b09-progress-styles";
|
||||||
|
function injectStyles(): void {
|
||||||
|
if (document.getElementById(STYLE_ID)) return;
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = STYLE_ID;
|
||||||
|
style.textContent = `
|
||||||
|
.b09pg { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; }
|
||||||
|
.b09pg__meta { font-size: 12px; color: var(--color-text-secondary); }
|
||||||
|
.b09pg__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); }
|
||||||
|
.b09pg__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.b09pg__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; }
|
||||||
|
.b09pg__table th, .b09pg__table td { border: 1px solid var(--color-border); padding: 2px 6px; }
|
||||||
|
.b09pg__table td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.b09pg__table tr.is-group td, .b09pg__table tr.is-total td { font-weight: 600; }
|
||||||
|
.b09pg__table input { width: 7em; text-align: right; }
|
||||||
|
.b09pg__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
|
||||||
|
.b09pg__panel label { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||||
|
`;
|
||||||
|
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 | boolean | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || value === "" || typeof value === "boolean")
|
||||||
|
return "";
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(value: string | boolean | null | undefined): string {
|
||||||
|
return typeof value === "string" ? `${value}%` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 프로젝트별 입력 캐시 — [저장] 전 값(지침 5장 · 자동저장 없음). 고른 회차도 함께. */
|
||||||
|
const drafts = new Map<string, { rounds: Round[]; round: number }>();
|
||||||
|
|
||||||
|
function draftOf(projectId: string, data: ProgressDto) {
|
||||||
|
let draft = drafts.get(projectId);
|
||||||
|
if (!draft) {
|
||||||
|
draft = { rounds: structuredClone(data.settings.rounds), round: data.round };
|
||||||
|
drafts.set(projectId, draft);
|
||||||
|
}
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpoint(projectId: string): string {
|
||||||
|
return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/progress`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProgress(projectId: string, round: number | null): Promise<ProgressDto> {
|
||||||
|
const query = round ? `?round=${round}` : "";
|
||||||
|
const response = await fetch(`${endpoint(projectId)}${query}`, { credentials: "include" });
|
||||||
|
const body = (await response.json()) as ProgressDto;
|
||||||
|
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProgress(projectId: string, rounds: Round[]): Promise<void> {
|
||||||
|
const response = await fetch(endpoint(projectId), {
|
||||||
|
method: "PUT",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ rounds }),
|
||||||
|
});
|
||||||
|
const body = (await response.json()) as { message?: string };
|
||||||
|
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
drafts.delete(projectId);
|
||||||
|
showToast("기성 회차 저장 — 계약 내역은 그대로", "success");
|
||||||
|
reload(round);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : "저장 못 함", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pick = el("label");
|
||||||
|
pick.append(el("span", "", "회차"));
|
||||||
|
const select = el("select");
|
||||||
|
for (let n = 1; n <= draft.rounds.length; n += 1) {
|
||||||
|
const option = el("option", "", `${n}회`);
|
||||||
|
option.value = String(n);
|
||||||
|
select.append(option);
|
||||||
|
}
|
||||||
|
select.value = String(data.round);
|
||||||
|
select.addEventListener("change", () => reload(Number(select.value)));
|
||||||
|
pick.append(select);
|
||||||
|
box.append(pick);
|
||||||
|
box.append(
|
||||||
|
createButton({
|
||||||
|
label: "회차 추가(저장)",
|
||||||
|
variant: "ghost",
|
||||||
|
onClick: () => {
|
||||||
|
const last = draft.rounds[draft.rounds.length - 1];
|
||||||
|
draft.rounds.push({
|
||||||
|
quantities: {},
|
||||||
|
vat_mode: last?.vat_mode ?? "supply",
|
||||||
|
vat_manual_krw: "",
|
||||||
|
});
|
||||||
|
void save(draft.rounds.length);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const current = draft.rounds[data.round - 1];
|
||||||
|
if (current) {
|
||||||
|
const vat = el("label");
|
||||||
|
vat.append(el("span", "", "부가세"));
|
||||||
|
const mode = el("select");
|
||||||
|
for (const choice of data.fields.vat) {
|
||||||
|
const option = el("option", "", choice.label);
|
||||||
|
option.value = choice.key;
|
||||||
|
mode.append(option);
|
||||||
|
}
|
||||||
|
mode.value = current.vat_mode;
|
||||||
|
const manual = el("input");
|
||||||
|
manual.type = "number";
|
||||||
|
manual.min = "0";
|
||||||
|
manual.placeholder = "직접입력 금액";
|
||||||
|
manual.value = current.vat_manual_krw;
|
||||||
|
manual.hidden = current.vat_mode !== "manual";
|
||||||
|
mode.addEventListener("change", () => {
|
||||||
|
current.vat_mode = mode.value;
|
||||||
|
manual.hidden = mode.value !== "manual";
|
||||||
|
});
|
||||||
|
manual.addEventListener("input", () => (current.vat_manual_krw = manual.value));
|
||||||
|
vat.append(mode, manual);
|
||||||
|
box.append(vat, createButton({ label: "저장", onClick: () => save(data.round) }));
|
||||||
|
} else {
|
||||||
|
box.append(el("div", "b09pg__meta", "회차 없음 — [회차 추가]로 1회부터"));
|
||||||
|
}
|
||||||
|
ctx.panel.append(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기성 제잡비 계산서 — 제잡비 줄 + 합계 줄(직접공사비 · 제잡비 계 · 공급가액 · 부가세 · 기성금액). */
|
||||||
|
function drawJab(data: ProgressDto): HTMLElement {
|
||||||
|
const box = el("div");
|
||||||
|
box.append(
|
||||||
|
el(
|
||||||
|
"strong",
|
||||||
|
"",
|
||||||
|
`기성 제잡비 계산서 — 금회직접공사비 × 계약잡비율 · 계약 제잡비율 ${pct(data.contract_overhead_ratio_pct)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const table = el("table", "b09pg__table");
|
||||||
|
const head = el("tr");
|
||||||
|
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)),
|
||||||
|
);
|
||||||
|
for (const [col] of COLUMNS) {
|
||||||
|
tr.append(el("td", "num", won(row[`${col}_krw`])), el("td", "num", pct(row[`${col}_pct`])));
|
||||||
|
}
|
||||||
|
table.append(tr);
|
||||||
|
};
|
||||||
|
for (const row of data.items) line(row, false);
|
||||||
|
for (const row of data.summary) line(row, true);
|
||||||
|
box.append(table);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBill(data: ProgressDto, rounds: Round[]): HTMLElement {
|
||||||
|
const box = el("div");
|
||||||
|
box.append(el("strong", "", `기성내역 — ${data.round ? `${data.round}회` : "회차 없음"}`));
|
||||||
|
const table = el("table", "b09pg__table");
|
||||||
|
const head = el("tr");
|
||||||
|
for (const label of [
|
||||||
|
"공종번호",
|
||||||
|
"명칭",
|
||||||
|
"규격",
|
||||||
|
"단위",
|
||||||
|
"계약수량",
|
||||||
|
"계약금액",
|
||||||
|
"전회수량",
|
||||||
|
"전회금액",
|
||||||
|
"금회수량",
|
||||||
|
"금회금액",
|
||||||
|
"누계수량",
|
||||||
|
"누계금액",
|
||||||
|
"기성(%)",
|
||||||
|
"잔량",
|
||||||
|
"비고",
|
||||||
|
]) {
|
||||||
|
head.append(el("th", "", label));
|
||||||
|
}
|
||||||
|
table.append(head);
|
||||||
|
const current = rounds[data.round - 1];
|
||||||
|
for (const row of data.rows) {
|
||||||
|
const tr = el("tr", row.is_group ? "is-group" : "");
|
||||||
|
const leaf = !row.is_group && row.in_bill && row.progress_current_quantity !== undefined;
|
||||||
|
tr.append(
|
||||||
|
el("td", "", row.item_no),
|
||||||
|
el("td", "", row.name),
|
||||||
|
el("td", "", row.spec ?? ""),
|
||||||
|
el("td", "", row.unit ?? ""),
|
||||||
|
el("td", "num", leaf ? (row.quantity ?? "") : ""),
|
||||||
|
el("td", "num", won(row.contract_amount_krw)),
|
||||||
|
el("td", "num", leaf ? String(row.progress_previous_quantity) : ""),
|
||||||
|
el("td", "num", won(row.progress_previous_amount_krw)),
|
||||||
|
);
|
||||||
|
const cell = el("td");
|
||||||
|
if (leaf && current) {
|
||||||
|
const qty = el("input");
|
||||||
|
qty.type = "number";
|
||||||
|
qty.min = "0";
|
||||||
|
qty.step = "any";
|
||||||
|
qty.value = current.quantities[row.item_no] ?? "";
|
||||||
|
qty.addEventListener("input", () => {
|
||||||
|
if (qty.value === "") delete current.quantities[row.item_no];
|
||||||
|
else current.quantities[row.item_no] = qty.value;
|
||||||
|
});
|
||||||
|
cell.append(qty);
|
||||||
|
}
|
||||||
|
tr.append(
|
||||||
|
cell,
|
||||||
|
el("td", "num", won(row.progress_current_amount_krw)),
|
||||||
|
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)),
|
||||||
|
el("td", "num", row.progress_remaining_quantity ?? ""),
|
||||||
|
el("td", "", row.progress_note ?? ""),
|
||||||
|
);
|
||||||
|
table.append(tr);
|
||||||
|
}
|
||||||
|
box.append(table);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBody(ctx: B09TabContext, data: ProgressDto): void {
|
||||||
|
const draft = draftOf(ctx.projectId as string, data);
|
||||||
|
const wrap = el("div", "b09pg");
|
||||||
|
wrap.append(el("div", "b09pg__warn", `⚠ ${data.limit_note}`));
|
||||||
|
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));
|
||||||
|
wrap.append(scroll);
|
||||||
|
ctx.body.append(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(ctx: B09TabContext): void {
|
||||||
|
injectStyles();
|
||||||
|
if (!ctx.projectId) {
|
||||||
|
ctx.body.append(el("div", "b09pg__meta", "프로젝트를 고르세요"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const load = (round: number | null): void => {
|
||||||
|
ctx.body.replaceChildren(
|
||||||
|
el("div", "b09pg__meta", `${L("B09_Estimation_Tab_Progress")} 계산 중…`),
|
||||||
|
);
|
||||||
|
ctx.panel.replaceChildren();
|
||||||
|
fetchProgress(ctx.projectId as string, round)
|
||||||
|
.then((data) => {
|
||||||
|
const draft = drafts.get(ctx.projectId as string);
|
||||||
|
if (draft) draft.round = data.round;
|
||||||
|
ctx.body.replaceChildren();
|
||||||
|
drawPanel(ctx, data, load);
|
||||||
|
drawBody(ctx, data);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
ctx.body.replaceChildren(
|
||||||
|
el(
|
||||||
|
"div",
|
||||||
|
"b09pg__warn",
|
||||||
|
`기성을 세우지 못함 — ${error instanceof Error ? error.message : ""}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
load(drafts.get(ctx.projectId)?.round ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const progressTab: B09Tab = {
|
||||||
|
key: "progress",
|
||||||
|
label: () => L("B09_Estimation_Tab_Progress"),
|
||||||
|
render,
|
||||||
|
};
|
||||||
@@ -67,6 +67,7 @@ from B08_Quantity.B08_Quantity_Router_StructureSheet import router as b08_struct
|
|||||||
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_Execution import router as b09_execution_router
|
||||||
|
from B09_Estimation.B09_Estimation_Router_Progress import router as b09_progress_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
|
||||||
@@ -644,6 +645,7 @@ 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_execution_router, dependencies=protected_with_company)
|
||||||
|
app.include_router(b09_progress_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,158 @@
|
|||||||
|
"""기성 단계 — 계약 → 기성내역 · 기성 제잡비 계산서 **구조**가 서는가 (PLAN 12장 · 2026-09-14 배정).
|
||||||
|
|
||||||
|
⚠ 값이 맞는지는 못 잰다 — 기성 표본 0건(STmate 27번 §9 · 35번). 여기서 재는 것:
|
||||||
|
① ⭐ 제잡비 줄 = 금회직접공사비 × 계약잡비율(도급액 ÷ 계약 직접공사비)
|
||||||
|
— 줄마다 밑수×요율 다시 안 셈
|
||||||
|
② 계약 · 전회(앞 회차 금회의 합) · 금회 · 누계 네 칸과 각 비율 · 기성(%) · 잔량
|
||||||
|
③ 부가세 넷 — 직접입력 · 공급가액 · 재료비 · 재료비+산출경비
|
||||||
|
④ 계약 줄(입력) 불변 · 분리발주 폐기물은 제잡비 밖 · 틀린 칸 거름
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import sys
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_Engine_Cost import CostLine, CostResult # noqa: E402
|
||||||
|
from B09_Estimation.B09_Estimation_Progress import clean_settings, progress_sheet # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _contract_row(item_no: str, qty: str, unit: tuple[int, int, int]) -> dict:
|
||||||
|
total = sum(unit) * int(qty)
|
||||||
|
return {
|
||||||
|
"item_no": item_no,
|
||||||
|
"is_group": False,
|
||||||
|
"in_bill": True,
|
||||||
|
"quantity": qty,
|
||||||
|
"contract_unit_material_krw": str(unit[0]),
|
||||||
|
"contract_unit_labor_krw": str(unit[1]),
|
||||||
|
"contract_unit_expense_krw": str(unit[2]),
|
||||||
|
"contract_amount_krw": str(total),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CONTRACT_ROWS = [
|
||||||
|
{"item_no": "1", "is_group": True, "in_bill": True},
|
||||||
|
_contract_row("1.1", "100", (1000, 2000, 500)), # 350,000
|
||||||
|
_contract_row("1.2", "10", (5000, 0, 0)), # 50,000 → 계약 직접공사비 400,000
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _cost(separate_waste: bool = False) -> tuple[SimpleNamespace, CostResult]:
|
||||||
|
result = CostResult()
|
||||||
|
for key, amount in (
|
||||||
|
("indirect_labor_cost", 20000),
|
||||||
|
("industrial_accident_insurance", 7000),
|
||||||
|
("waste_disposal", 5000),
|
||||||
|
("general_overhead", 24000),
|
||||||
|
("profit", 30000),
|
||||||
|
("vat", 48100),
|
||||||
|
):
|
||||||
|
result.lines.append(
|
||||||
|
CostLine(key, key, "", Decimal(amount), None, Decimal(0), Decimal(amount))
|
||||||
|
)
|
||||||
|
data = SimpleNamespace(waste_separate_order=separate_waste, indirect_material_krw=Decimal(0))
|
||||||
|
return data, result
|
||||||
|
|
||||||
|
|
||||||
|
SETTINGS = {
|
||||||
|
"rounds": [
|
||||||
|
{"quantities": {"1.1": "40", "1.2": "2"}, "vat_mode": "supply"},
|
||||||
|
{"quantities": {"1.1": "30"}, "vat_mode": "material"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sheet(settings=SETTINGS, round_no=None, separate_waste=True) -> dict:
|
||||||
|
data, result = _cost(separate_waste)
|
||||||
|
return progress_sheet(CONTRACT_ROWS, data, result, clean_settings(settings)[0], round_no)
|
||||||
|
|
||||||
|
|
||||||
|
def test_제잡비는_금회직접공사비_곱하기_계약잡비율() -> None:
|
||||||
|
before = copy.deepcopy(CONTRACT_ROWS)
|
||||||
|
sheet = _sheet()
|
||||||
|
assert CONTRACT_ROWS == before # 계약 줄 불변
|
||||||
|
items = {item["key"]: item for item in sheet["items"]}
|
||||||
|
labor = items["indirect_labor_cost"]
|
||||||
|
# 도급액 20,000 ÷ 계약 직접 400,000 = 5% · 1회 150,000 → 7,500 · 2회 105,000 → 5,250
|
||||||
|
assert (labor["contract_ratio_pct"], labor["previous_krw"], labor["current_krw"]) == (
|
||||||
|
"5.000",
|
||||||
|
"7500",
|
||||||
|
"5250",
|
||||||
|
)
|
||||||
|
assert (labor["cumulative_krw"], labor["cumulative_pct"]) == ("12750", "63.750")
|
||||||
|
# 7,000 × 105,000 ÷ 400,000 = 1,837.5 → 회차마다 원 미만 절사
|
||||||
|
assert items["industrial_accident_insurance"]["current_krw"] == "1837"
|
||||||
|
assert "waste_disposal" not in items # 분리발주면 도급 밖
|
||||||
|
assert sheet["contract_overhead_ratio_pct"] == "20.250" # 81,000 ÷ 400,000
|
||||||
|
|
||||||
|
|
||||||
|
def test_네_칸과_기성율_잔량() -> None:
|
||||||
|
sheet = _sheet()
|
||||||
|
row = {r["item_no"]: r for r in sheet["rows"]}["1.1"]
|
||||||
|
assert (
|
||||||
|
row["progress_previous_quantity"],
|
||||||
|
row["progress_current_quantity"],
|
||||||
|
row["progress_cumulative_quantity"],
|
||||||
|
) == ("40", "30", "70")
|
||||||
|
assert (row["progress_cumulative_amount_krw"], row["progress_pct"]) == ("245000", "70.000")
|
||||||
|
assert row["progress_remaining_quantity"] == "30"
|
||||||
|
group = sheet["rows"][0]
|
||||||
|
assert group["progress_cumulative_amount_krw"] == "255000" # 245,000 + 1.2 의 10,000
|
||||||
|
summary = {s["key"]: s for s in sheet["summary"]}
|
||||||
|
assert (summary["direct"]["previous_krw"], summary["direct"]["current_krw"]) == (
|
||||||
|
"150000",
|
||||||
|
"105000",
|
||||||
|
)
|
||||||
|
assert summary["direct"]["cumulative_pct"] == "63.750"
|
||||||
|
|
||||||
|
|
||||||
|
def test_부가세_넷() -> None:
|
||||||
|
summary = {s["key"]: s for s in _sheet()["summary"]}
|
||||||
|
# 1회 공급가액 180,375 × 10% = 18,037 · 2회 재료비 30,000 × 10% = 3,000
|
||||||
|
assert (summary["supply"]["previous_krw"], summary["vat"]["previous_krw"]) == (
|
||||||
|
"180375",
|
||||||
|
"18037",
|
||||||
|
)
|
||||||
|
assert summary["vat"]["current_krw"] == "3000"
|
||||||
|
coop = {"rounds": [{"quantities": {"1.1": "40"}, "vat_mode": "forest_coop"}]}
|
||||||
|
vat = {s["key"]: s for s in _sheet(coop)["summary"]}["vat"]["current_krw"]
|
||||||
|
assert vat == "6000" # (재료비 40,000 + 산출경비 20,000) × 10%
|
||||||
|
manual = {
|
||||||
|
"rounds": [{"quantities": {"1.1": "40"}, "vat_mode": "manual", "vat_manual_krw": "12345"}]
|
||||||
|
}
|
||||||
|
assert {s["key"]: s for s in _sheet(manual)["summary"]}["vat"]["current_krw"] == "12345"
|
||||||
|
|
||||||
|
|
||||||
|
def test_회차를_고르면_그_앞이_전회() -> None:
|
||||||
|
first = _sheet(round_no=1)
|
||||||
|
row = {r["item_no"]: r for r in first["rows"]}["1.1"]
|
||||||
|
assert (row["progress_previous_quantity"], row["progress_current_quantity"]) == ("0", "40")
|
||||||
|
assert first["round"] == 1 and first["round_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_계약_수량_넘으면_알림과_분리발주_아니면_폐기물도_제잡비() -> None:
|
||||||
|
over = {"rounds": [{"quantities": {"1.2": "12"}, "vat_mode": "supply"}]}
|
||||||
|
row = {r["item_no"]: r for r in _sheet(over)["rows"]}["1.2"]
|
||||||
|
assert row["progress_remaining_quantity"] == "-2" and "넘음" in row["progress_note"]
|
||||||
|
items = {item["key"] for item in _sheet(separate_waste=False)["items"]}
|
||||||
|
assert "waste_disposal" in items
|
||||||
|
|
||||||
|
|
||||||
|
def test_틀린_칸은_거른다() -> None:
|
||||||
|
_, errors = clean_settings(
|
||||||
|
{
|
||||||
|
"rounds": [
|
||||||
|
{"quantities": {"1.1": "-1"}, "vat_mode": "supply"},
|
||||||
|
{"quantities": {}, "vat_mode": "manual", "vat_manual_krw": ""},
|
||||||
|
{"quantities": {}, "vat_mode": "없는방식"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert len(errors) == 3
|
||||||
@@ -10,4 +10,5 @@ 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"],
|
B09_Estimation_Tab_Execution: ["실행예산", "Execution Budget"],
|
||||||
|
B09_Estimation_Tab_Progress: ["기성", "Progress Payment"],
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user