Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1
This commit is contained in:
@@ -113,13 +113,13 @@ DESIGN_DOC_ITEMS: tuple[dict[str, Any], ...] = (
|
|||||||
{
|
{
|
||||||
"order": 9,
|
"order": 9,
|
||||||
"name": "각종 중기경비계산서",
|
"name": "각종 중기경비계산서",
|
||||||
"status": STATUS_PARTIAL,
|
"status": STATUS_READY,
|
||||||
"owner": OWNER_PROGRAM,
|
"owner": OWNER_PROGRAM,
|
||||||
"where": "원가계산 → 중기(중기목록표)",
|
"where": "원가계산 → 중기(중기목록표 + 기종별 계산서)",
|
||||||
"note": (
|
"note": (
|
||||||
"기종별 시간당 사용료 3분할은 서 있고 줄을 누르면 무엇으로 이루어졌는지 "
|
"목록표가 「얼마」라면 계산서는 「왜 그 값인가」다 — 기종마다 취득가·내용시간·"
|
||||||
"파고들 수 있다. ⚠ 다만 실무 서식의 기종마다 한 장짜리 계산서"
|
"손료계수(상각·정비·관리) · 주연료와 유가 · 조종원 일당과 환산을 그대로 보인다. "
|
||||||
"(취득가·손료계수·상각비·정비비·관리비 + 운전경비 + 수송비)는 아직 없다."
|
"수송비는 「회당」으로 서는 별개 공종이라 이 장에서는 어디서 서는지만 가리킨다."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""B09 — **각종 중기경비계산서** (별표2 (5)(가) 아홉째 · 건설품셈 8-1-6).
|
||||||
|
|
||||||
|
**무엇인가** — 기종마다 **한 장**으로 「이 기계 한 시간이 왜 이 값인가」를 보이는 표다.
|
||||||
|
|
||||||
|
기계경비 = 기계손료 + 운전경비 + 수송비 (건설품셈 8-1-6의 1)
|
||||||
|
├ 손료 = 취득가격(천원) × 1,000 × 시간당 손료계수 (상각·정비·관리 계수의 합)
|
||||||
|
├ 운전경비 = 주연료 × 유가(+잡재료 %) + 조종원 일당 ÷ 8 × 제수당 계수
|
||||||
|
└ 수송비 = **기종에 붙지 않는다** — 「회당」으로 서는 별개 공종(산림품셈 10-4)이라
|
||||||
|
이 장에서는 **어디서 서는지만** 가리킨다.
|
||||||
|
|
||||||
|
⚠ **중기목록표와 다르다.** 목록표는 「기종별 시간당 사용료 얼마」 한 줄이고, 이 장은
|
||||||
|
**그 값이 나온 과정**이다. 별표2 가 둘을 따로 적지 않았지만 실무 서식은 계산 과정을
|
||||||
|
기종마다 한 장으로 남긴다.
|
||||||
|
|
||||||
|
⚠ **못 채운 성분은 0 으로 안 때운다** — 연료·조종원이 없으면 그 사실을 줄에 남긴다
|
||||||
|
(`HourlyMachineCost.gaps` 와 같은 규칙).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from decimal import Decimal
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_MachineCost import (
|
||||||
|
OPERATOR_ALLOWANCE_FACTOR,
|
||||||
|
OPERATOR_HOURS_PER_DAY,
|
||||||
|
load_machine_catalog,
|
||||||
|
)
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
|
||||||
|
|
||||||
|
_CATALOG_SUBPATH = ("resources", "data_cost_input_value")
|
||||||
|
_THOUSAND = Decimal(1000)
|
||||||
|
_ZERO = Decimal(0)
|
||||||
|
|
||||||
|
#: 부착 장비 — **제 엔진이 없어** 연료·조종원이 본체에 든다(품셈 제8장 [주]⑤).
|
||||||
|
#: ⚠ 그 셋을 「성분이 빔」으로 세면 **정상인 줄을 결함으로 읽는다** — 손료만 있는 것이 맞다.
|
||||||
|
_ATTACHMENT_PREFIXES = ("0103-", "0230-", "0240-", "7206-")
|
||||||
|
ATTACHMENT_NOTE = (
|
||||||
|
"부착 장비라 손료만 듭니다 — 제 엔진이 없어 연료·조종원이 본체(굴착기·불도저)에"
|
||||||
|
" 들어갑니다(건설품셈 제8장 [주]⑤)."
|
||||||
|
)
|
||||||
|
|
||||||
|
TRANSPORT_NOTE = (
|
||||||
|
"수송비는 이 장에 안 붙습니다 — 산림품셈 10-4 가 「회당」으로 세는 별개 공종이라"
|
||||||
|
" 「산출 조건」에서 거리를 넣으면 중기운반 줄로 섭니다(건설품셈 8-1-6의 2)."
|
||||||
|
)
|
||||||
|
LOSS_NOTE = (
|
||||||
|
"손료 = 취득가격(천원) × 1,000 × 시간당 손료계수. 계수는 상각비·정비비·관리비"
|
||||||
|
" 계수의 합이며 원문이 10⁻⁷ 단위로 줍니다(건설품셈 8-1-5·8-1-6)."
|
||||||
|
)
|
||||||
|
OPERATOR_NOTE = (
|
||||||
|
"조종원 노임 = 일당 ÷ 8시간 × 16/12 × 25/20. 공표 노임이 기본급여액뿐이라"
|
||||||
|
" 제수당·상여금·퇴직급여충당금을 따로 계상합니다."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_root() -> str:
|
||||||
|
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _loss_records() -> dict[str, dict[str, Any]]:
|
||||||
|
"""기종코드 → 손료계수 원문 줄(상각·정비·관리 계수까지)."""
|
||||||
|
path = os.path.join(_project_root(), *_CATALOG_SUBPATH, "mach_base_2026.json")
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
records = payload["variables"]["mach_loss_coef"]["records"]
|
||||||
|
return {str(row["machine_code"]): row for row in records}
|
||||||
|
|
||||||
|
|
||||||
|
def _money(value: Decimal | None) -> str | None:
|
||||||
|
return None if value is None else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
|
||||||
|
"""**내역에 실제로 선 기종만** 한 장씩. 안 쓰는 613 기종을 다 뿌리지 않는다."""
|
||||||
|
from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price
|
||||||
|
from B09_Estimation.B09_Estimation_MachineOperating import (
|
||||||
|
load_operating_records,
|
||||||
|
load_operator_wages,
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog = load_machine_catalog()
|
||||||
|
operating = {row.machine_code: row for row in load_operating_records().records}
|
||||||
|
wages = load_operator_wages()
|
||||||
|
fuel_price, fuel_meta = load_fuel_price()
|
||||||
|
loss = _loss_records()
|
||||||
|
|
||||||
|
sheets: list[dict[str, Any]] = []
|
||||||
|
for code, title in sorted(build.book.titles.items()):
|
||||||
|
if title.kind is not PriceKind.MACHINE_HOURLY:
|
||||||
|
continue
|
||||||
|
machine_code = code[2:].split("#")[0]
|
||||||
|
machine = catalog.machines.get(machine_code)
|
||||||
|
if machine is None:
|
||||||
|
continue
|
||||||
|
record = operating.get(machine_code)
|
||||||
|
raw = loss.get(machine_code) or {}
|
||||||
|
money = build.book.resolve(code)
|
||||||
|
|
||||||
|
gaps: list[str] = []
|
||||||
|
attachment = machine_code.startswith(_ATTACHMENT_PREFIXES)
|
||||||
|
fuel_liters = getattr(record, "fuel_liters_per_hour", None)
|
||||||
|
misc_percent = getattr(record, "misc_material_percent", None)
|
||||||
|
occupation = getattr(record, "operator_occupation_code", "") or ""
|
||||||
|
wage = wages.get(occupation)
|
||||||
|
if not attachment:
|
||||||
|
if fuel_liters is None:
|
||||||
|
gaps.append("주연료 소요량(L/hr)이 없습니다 — 재료비 성분이 비어 있습니다")
|
||||||
|
if wage is None:
|
||||||
|
gaps.append("조종원 직종·일당이 없습니다 — 노무비 성분이 비어 있습니다")
|
||||||
|
if machine.loss_coefficient_per_hour is None:
|
||||||
|
gaps.append("손료계수가 없습니다 — 취득가만 있는 기종입니다")
|
||||||
|
|
||||||
|
sheets.append(
|
||||||
|
{
|
||||||
|
"code": code,
|
||||||
|
"machine_code": machine_code,
|
||||||
|
"name": machine.name,
|
||||||
|
"spec": machine.specification,
|
||||||
|
# ① 손료
|
||||||
|
"price_thousand_krw": _money(machine.price_thousand_krw),
|
||||||
|
"economic_life_hours": raw.get("economic_life_hours"),
|
||||||
|
"annual_standard_hours": raw.get("annual_standard_hours"),
|
||||||
|
"depreciation_coefficient": raw.get("depreciation_coefficient_1e_minus_7"),
|
||||||
|
"maintenance_coefficient": raw.get("maintenance_coefficient_1e_minus_7"),
|
||||||
|
"management_coefficient": raw.get("management_coefficient_1e_minus_7"),
|
||||||
|
"loss_coefficient": raw.get("source_coefficient_1e_minus_7"),
|
||||||
|
"loss_krw_per_hour": _money(
|
||||||
|
machine.price_thousand_krw * _THOUSAND * machine.loss_coefficient_per_hour
|
||||||
|
if machine.loss_coefficient_per_hour is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
# ② 운전경비
|
||||||
|
"fuel_liters_per_hour": _money(fuel_liters),
|
||||||
|
"fuel_price_per_liter": _money(fuel_price),
|
||||||
|
"fuel_scope": fuel_meta.get("region_name") or "전국 공시가",
|
||||||
|
"misc_material_percent": _money(misc_percent),
|
||||||
|
"operator_code": occupation,
|
||||||
|
"operator_daily_wage": _money(wage),
|
||||||
|
"operator_krw_per_hour": _money(
|
||||||
|
(wage / Decimal(OPERATOR_HOURS_PER_DAY)) * OPERATOR_ALLOWANCE_FACTOR
|
||||||
|
if wage is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
# ③ 시간당 사용료 — 조립된 값(이 장의 결론)
|
||||||
|
"material_krw": _money(money.material),
|
||||||
|
"labor_krw": _money(money.labor),
|
||||||
|
"expense_krw": _money(money.expense),
|
||||||
|
"total_krw": _money(money.total),
|
||||||
|
"attachment": attachment,
|
||||||
|
"attachment_note": ATTACHMENT_NOTE if attachment else "",
|
||||||
|
"gaps": gaps,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sheets
|
||||||
|
|
||||||
|
|
||||||
|
def machine_expense_report(build: Any) -> dict[str, Any]:
|
||||||
|
"""중기경비계산서 한 벌 — 장 목록 + 근거 문구."""
|
||||||
|
sheets = machine_expense_sheets(build)
|
||||||
|
incomplete = [sheet["name"] for sheet in sheets if sheet["gaps"]]
|
||||||
|
return {
|
||||||
|
"sheets": sheets,
|
||||||
|
"notes": [LOSS_NOTE, OPERATOR_NOTE, TRANSPORT_NOTE, ATTACHMENT_NOTE],
|
||||||
|
"summary": (
|
||||||
|
f"내역에 선 기종 {len(sheets)} 종의 계산 과정입니다"
|
||||||
|
+ (f" — 성분이 빈 것 {len(incomplete)} 종." if incomplete else ".")
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -319,6 +319,26 @@ async def get_price_sources(project_id: UUID) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/estimation/machine-expense")
|
||||||
|
async def get_machine_expense(project_id: UUID) -> JSONResponse:
|
||||||
|
"""**각종 중기경비계산서** — 기종마다 한 장(별표2 (5)(가) 아홉째).
|
||||||
|
|
||||||
|
중기목록표가 「얼마」라면 이 장은 **「왜 그 값인가」**다 — 취득가·손료계수·연료·조종원을
|
||||||
|
그대로 보인다.
|
||||||
|
"""
|
||||||
|
from B09_Estimation.B09_Estimation_MachineExpenseSheet import machine_expense_report
|
||||||
|
|
||||||
|
try:
|
||||||
|
build = await _build_for(project_id)
|
||||||
|
return JSONResponse(content={"status": "success", **machine_expense_report(build)})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("B09 중기경비계산서 실패: project_id=%s", project_id)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"status": "error", "message": "중기경비계산서를 못 만들었습니다."},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/estimation/design-doc-index")
|
@router.get("/{project_id}/estimation/design-doc-index")
|
||||||
async def get_design_doc_index(project_id: UUID) -> JSONResponse:
|
async def get_design_doc_index(project_id: UUID) -> JSONResponse:
|
||||||
"""**설계서 구성표** — 법이 정한 목차와 우리가 내는 것을 맞대 본다(별표2 (5)(가)).
|
"""**설계서 구성표** — 법이 정한 목차와 우리가 내는 것을 맞대 본다(별표2 (5)(가)).
|
||||||
|
|||||||
@@ -240,6 +240,112 @@ export function drawDesignDocTab(body: HTMLElement, data: DesignDocDto): void {
|
|||||||
for (const line of data.notes) body.append(note(line));
|
for (const line of data.notes) body.append(note(line));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
* 각종 중기경비계산서 — 기종마다 한 장(별표2 (5)(가) 아홉째).
|
||||||
|
* ⚠ 목록표가 「얼마」라면 이 장은 **왜 그 값인가**다. 계산 과정을 감추지 않는다.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
export interface MachineExpenseDto {
|
||||||
|
status: string;
|
||||||
|
summary: string;
|
||||||
|
notes: string[];
|
||||||
|
sheets: Array<{
|
||||||
|
machine_code: string;
|
||||||
|
name: string;
|
||||||
|
spec: string;
|
||||||
|
price_thousand_krw: string | null;
|
||||||
|
economic_life_hours: number | null;
|
||||||
|
annual_standard_hours: number | null;
|
||||||
|
depreciation_coefficient: number | null;
|
||||||
|
maintenance_coefficient: number | null;
|
||||||
|
management_coefficient: number | null;
|
||||||
|
loss_coefficient: number | null;
|
||||||
|
loss_krw_per_hour: string | null;
|
||||||
|
fuel_liters_per_hour: string | null;
|
||||||
|
fuel_price_per_liter: string | null;
|
||||||
|
fuel_scope: string;
|
||||||
|
misc_material_percent: string | null;
|
||||||
|
operator_code: string;
|
||||||
|
operator_daily_wage: string | null;
|
||||||
|
operator_krw_per_hour: string | null;
|
||||||
|
material_krw: string | null;
|
||||||
|
labor_krw: string | null;
|
||||||
|
expense_krw: string | null;
|
||||||
|
total_krw: string | null;
|
||||||
|
attachment: boolean;
|
||||||
|
attachment_note: string;
|
||||||
|
gaps: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMachineExpense(projectId: string): Promise<MachineExpenseDto> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/machine-expense`,
|
||||||
|
{ credentials: "include" },
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(`machine-expense ${response.status}`);
|
||||||
|
return (await response.json()) as MachineExpenseDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기종 한 장 — 손료·운전경비·시간당 사용료를 차례로. */
|
||||||
|
function machineExpenseSheet(sheet: MachineExpenseDto["sheets"][number]): HTMLElement {
|
||||||
|
const box = document.createElement("div");
|
||||||
|
box.className = "b09-panel__group";
|
||||||
|
const title = document.createElement("p");
|
||||||
|
title.className = "b09-panel__legend";
|
||||||
|
title.textContent = `${sheet.machine_code} ${sheet.name} ${sheet.spec}`.trim();
|
||||||
|
box.append(title);
|
||||||
|
|
||||||
|
const coefficient = (value: number | null) => (value === null ? "—" : String(value));
|
||||||
|
box.append(
|
||||||
|
table(
|
||||||
|
["구 분", "내 용", "값"],
|
||||||
|
[
|
||||||
|
["① 손료", "취득가격(천원)", money(sheet.price_thousand_krw)],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"내용시간 / 연간표준가동시간",
|
||||||
|
`${coefficient(sheet.economic_life_hours)} / ${coefficient(sheet.annual_standard_hours)}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"상각비·정비비·관리비 계수 (10⁻⁷)",
|
||||||
|
`${coefficient(sheet.depreciation_coefficient)} + ${coefficient(sheet.maintenance_coefficient)} + ${coefficient(sheet.management_coefficient)} = ${coefficient(sheet.loss_coefficient)}`,
|
||||||
|
],
|
||||||
|
["", "시간당 손료(원)", money(sheet.loss_krw_per_hour)],
|
||||||
|
[
|
||||||
|
"② 운전경비",
|
||||||
|
`주연료(L/hr) × 유가(${sheet.fuel_scope})`,
|
||||||
|
`${sheet.fuel_liters_per_hour ?? "—"} × ${money(sheet.fuel_price_per_liter)}`,
|
||||||
|
],
|
||||||
|
["", "잡재료(주연료의 %)", sheet.misc_material_percent ?? "—"],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
`조종원(${sheet.operator_code || "—"}) 일당 → 시간당`,
|
||||||
|
`${money(sheet.operator_daily_wage)} → ${money(sheet.operator_krw_per_hour)}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"③ 시간당 사용료",
|
||||||
|
"재료비 / 노무비 / 경비",
|
||||||
|
`${money(sheet.material_krw)} / ${money(sheet.labor_krw)} / ${money(sheet.expense_krw)}`,
|
||||||
|
],
|
||||||
|
["", "합 계", money(sheet.total_krw)],
|
||||||
|
],
|
||||||
|
[0, 1],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (sheet.attachment_note) box.append(note(sheet.attachment_note));
|
||||||
|
for (const gap of sheet.gaps) box.append(note(`⚠ ${gap}`));
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawMachineExpense(body: HTMLElement, data: MachineExpenseDto): void {
|
||||||
|
body.append(head(`각종 중기경비계산서 (${data.sheets.length})`));
|
||||||
|
body.append(note(data.summary));
|
||||||
|
for (const line of data.notes) body.append(note(line));
|
||||||
|
for (const sheet of data.sheets) body.append(machineExpenseSheet(sheet));
|
||||||
|
}
|
||||||
|
|
||||||
/** 두 탭이 함께 쓰는 「아직 못 불러왔습니다」 문구. */
|
/** 두 탭이 함께 쓰는 「아직 못 불러왔습니다」 문구. */
|
||||||
export function drawBaseDataError(body: HTMLElement): void {
|
export function drawBaseDataError(body: HTMLElement): void {
|
||||||
body.append(note(L("B09_Estimation_Tab_Pending")));
|
body.append(note(L("B09_Estimation_Tab_Pending")));
|
||||||
|
|||||||
@@ -20,15 +20,18 @@ import {
|
|||||||
drawBaseDataTab,
|
drawBaseDataTab,
|
||||||
drawFactorChoices,
|
drawFactorChoices,
|
||||||
drawDesignDocTab,
|
drawDesignDocTab,
|
||||||
|
drawMachineExpense,
|
||||||
drawMachineTab,
|
drawMachineTab,
|
||||||
drawPriceSourcesPending,
|
drawPriceSourcesPending,
|
||||||
drawPriceSourcesSections,
|
drawPriceSourcesSections,
|
||||||
fetchBaseData,
|
fetchBaseData,
|
||||||
fetchDesignDocIndex,
|
fetchDesignDocIndex,
|
||||||
|
fetchMachineExpense,
|
||||||
fetchFactorChoices,
|
fetchFactorChoices,
|
||||||
fetchPriceSources,
|
fetchPriceSources,
|
||||||
type BaseDataDto,
|
type BaseDataDto,
|
||||||
type DesignDocDto,
|
type DesignDocDto,
|
||||||
|
type MachineExpenseDto,
|
||||||
type FactorChoicesDto,
|
type FactorChoicesDto,
|
||||||
type PriceSourcesDto,
|
type PriceSourcesDto,
|
||||||
} from "./B09_Estimation_UI_BaseData";
|
} from "./B09_Estimation_UI_BaseData";
|
||||||
@@ -812,6 +815,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
|||||||
let priceSources: PriceSourcesDto | null = null;
|
let priceSources: PriceSourcesDto | null = null;
|
||||||
let factorChoices: FactorChoicesDto | null = null;
|
let factorChoices: FactorChoicesDto | null = null;
|
||||||
let designDoc: DesignDocDto | null = null;
|
let designDoc: DesignDocDto | null = null;
|
||||||
|
let machineExpense: MachineExpenseDto | null = null;
|
||||||
let sheet: CostSheetDto | null = null;
|
let sheet: CostSheetDto | null = null;
|
||||||
let unitPriceList: UnitPriceListDto | null = null;
|
let unitPriceList: UnitPriceListDto | null = null;
|
||||||
let unitPriceDetail: UnitPriceDetailDto | null = null;
|
let unitPriceDetail: UnitPriceDetailDto | null = null;
|
||||||
@@ -1216,6 +1220,21 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
|||||||
}
|
}
|
||||||
if (activeTab === "machine") {
|
if (activeTab === "machine") {
|
||||||
drawMachineTab(body, baseData);
|
drawMachineTab(body, baseData);
|
||||||
|
// 계산서는 목록표 **아래**에 붙는다 — 「얼마」를 보고 「왜」로 내려간다.
|
||||||
|
if (machineExpense) {
|
||||||
|
drawMachineExpense(body, machineExpense);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (projectId) {
|
||||||
|
void fetchMachineExpense(projectId)
|
||||||
|
.then((data) => {
|
||||||
|
machineExpense = data;
|
||||||
|
drawBody();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* 못 받아도 목록표는 그대로 선다. */
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 산출 조건이 목록표보다 **먼저** 선다 — 값을 낳는 자리가 값보다 아래 있으면
|
// 산출 조건이 목록표보다 **먼저** 선다 — 값을 낳는 자리가 값보다 아래 있으면
|
||||||
|
|||||||
@@ -61,8 +61,12 @@ def test_설계자_몫과_우리_몫을_가른다() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_반쪽은_반쪽이라_적는다() -> None:
|
def test_반쪽은_반쪽이라_적는다() -> None:
|
||||||
|
"""⚠ 2026-09-09 밤 — 중기경비계산서를 세우면서 **반쪽이 하나 줄었다**(기종마다 한 장).
|
||||||
|
|
||||||
|
남은 반쪽은 산출기초 하나 — 근거 문구는 줄마다 있으나 한 장으로 묶는 자리가 없다.
|
||||||
|
"""
|
||||||
partial = {item["name"] for item in DESIGN_DOC_ITEMS if item["status"] == STATUS_PARTIAL}
|
partial = {item["name"] for item in DESIGN_DOC_ITEMS if item["status"] == STATUS_PARTIAL}
|
||||||
assert partial == {"각종 중기경비계산서", "산출기초"}
|
assert partial == {"산출기초"}
|
||||||
|
|
||||||
|
|
||||||
def test_서는_것은_어디서_나오는지_적혀_있다() -> None:
|
def test_서는_것은_어디서_나오는지_적혀_있다() -> None:
|
||||||
@@ -80,7 +84,7 @@ def test_못_내는_것에는_무엇이_필요한지_적혀_있다() -> None:
|
|||||||
|
|
||||||
def test_요약이_셈과_맞는다() -> None:
|
def test_요약이_셈과_맞는다() -> None:
|
||||||
data = design_doc_index()
|
data = design_doc_index()
|
||||||
assert data["counts"][STATUS_READY] == 7
|
assert data["counts"][STATUS_READY] == 8
|
||||||
assert sum(data["counts"].values()) == len(DESIGN_DOC_ITEMS)
|
assert sum(data["counts"].values()) == len(DESIGN_DOC_ITEMS)
|
||||||
ours_missing = [
|
ours_missing = [
|
||||||
item["name"]
|
item["name"]
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""각종 중기경비계산서 — 기종마다 한 장 (2026-09-09).
|
||||||
|
|
||||||
|
별표2 (5)(가) 가 설계서에 「각종 중기경비계산서」를 넣으라 한다. 중기목록표는 「얼마」 한 줄
|
||||||
|
이고, 이 장은 **그 값이 나온 과정**이다.
|
||||||
|
|
||||||
|
⚠ 겨누는 것 다섯
|
||||||
|
① **내역에 선 기종만** 낸다 — 카탈로그 613 을 다 뿌리지 않는다
|
||||||
|
② 손료 = 취득가(천원) × 1,000 × 계수 — 천원 단위를 놓치면 1,000배 틀린다
|
||||||
|
③ 계산서의 시간당 합계가 **일위대가가 실제로 쓰는 값과 같다**(두 벌이면 안 된다)
|
||||||
|
④ 부착 장비(브레이커·콤팩터·집게)는 **손료만 드는 것이 정상** — 결함으로 세지 않는다
|
||||||
|
⑤ 수송비는 이 장에 안 붙는다 — 「회당」으로 서는 별개 공종이라 자리만 가리킨다
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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_MachineExpenseSheet import ( # noqa: E402
|
||||||
|
machine_expense_report,
|
||||||
|
)
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import PriceKind # noqa: E402
|
||||||
|
from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _report():
|
||||||
|
return machine_expense_report(cached_build())
|
||||||
|
|
||||||
|
|
||||||
|
def test_내역에_선_기종만_낸다() -> None:
|
||||||
|
build = cached_build()
|
||||||
|
hourly = {c for c, t in build.book.titles.items() if t.kind is PriceKind.MACHINE_HOURLY}
|
||||||
|
sheets = _report()["sheets"]
|
||||||
|
assert sheets, "계산서가 한 장도 없습니다"
|
||||||
|
assert len(sheets) <= len(hourly)
|
||||||
|
assert {sheet["code"] for sheet in sheets} <= hourly
|
||||||
|
|
||||||
|
|
||||||
|
def test_손료가_취득가와_계수로_맞는다() -> None:
|
||||||
|
"""② 천원 단위를 놓치면 1,000배 틀린다."""
|
||||||
|
for sheet in _report()["sheets"]:
|
||||||
|
if sheet["loss_krw_per_hour"] is None or sheet["loss_coefficient"] is None:
|
||||||
|
continue
|
||||||
|
price = Decimal(sheet["price_thousand_krw"]) * Decimal(1000)
|
||||||
|
coefficient = Decimal(sheet["loss_coefficient"]) / Decimal(10**7)
|
||||||
|
assert abs(Decimal(sheet["loss_krw_per_hour"]) - price * coefficient) < Decimal("0.01")
|
||||||
|
|
||||||
|
|
||||||
|
def test_계산서_합계가_일위대가와_같은_값이다() -> None:
|
||||||
|
"""③ 두 벌로 세면 화면과 금액이 어긋난다."""
|
||||||
|
build = cached_build()
|
||||||
|
for sheet in machine_expense_report(build)["sheets"]:
|
||||||
|
used = build.book.resolve(sheet["code"]).total
|
||||||
|
assert Decimal(sheet["total_krw"]) == used, sheet["code"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_부착_장비는_결함이_아니다() -> None:
|
||||||
|
"""④ 제 엔진이 없어 연료·조종원이 본체에 든다(품셈 제8장 [주]⑤)."""
|
||||||
|
sheets = _report()["sheets"]
|
||||||
|
attachments = [sheet for sheet in sheets if sheet["attachment"]]
|
||||||
|
assert attachments, "부착 장비가 한 대도 안 잡혔습니다"
|
||||||
|
for sheet in attachments:
|
||||||
|
assert sheet["gaps"] == []
|
||||||
|
assert sheet["attachment_note"]
|
||||||
|
assert sheet["loss_krw_per_hour"] is not None # 손료는 있어야 한다
|
||||||
|
|
||||||
|
|
||||||
|
def test_수송비는_이_장에_안_붙는다() -> None:
|
||||||
|
"""⑤ 「회당」으로 서는 별개 공종이라 자리만 가리킨다."""
|
||||||
|
report = _report()
|
||||||
|
assert any("수송비" in line and "회당" in line for line in report["notes"])
|
||||||
|
for sheet in report["sheets"]:
|
||||||
|
assert "transport" not in sheet
|
||||||
Reference in New Issue
Block a user