Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-14 02:43:18 +09:00
22 changed files with 1289 additions and 35 deletions
@@ -160,6 +160,12 @@ def price_sheets(
"total": Decimal(str(table["total"])),
"blocked": table["blocked"],
"unconfirmed": table["unconfirmed"],
# 단위당 구성(단가표 코드, 수량) — B09 집계표가 자원까지 풂. 수동 단가 줄은 못 풂.
"parts": [
(str(row["ref_code"]), Decimal(str(row["quantity"])))
for row in table["rows"]
if row.get("ref_code") and "total" in row and not row.get("manual")
],
"reasons": [
f"{row['name']}: {row['reason']}"
for row in table["rows"]
@@ -173,6 +173,8 @@ class BillRow:
unconfirmed: int = 0
#: 「단산 N 참조」 — 비고 첫 조각과 같은 글. 번호는 낼 때마다 매김(저장 안 함).
price_basis_label: str = ""
#: 단가표 제목이 아닌 줄(묶음·구조물도 호표)의 **단위당 구성** `(단가 코드, 수량)` — 집계표가 자원까지 풂.
parts: list[tuple[str, Decimal]] = field(default_factory=list)
#: 줄 사유 **조각** — `(닿는 열 키, 글)`. 화면 「비고」는 이것을 이어 붙인 것이고,
#: 근거 호버는 열 키로 걸러 **그 사유가 닿는 칸에만** 띄운다(PLAN 8-36 ㉮).
#: ⚠ 종전엔 `note` 한 칸에 덮어썼다 — 한 줄에 사유가 둘이면 **하나가 조용히 사라졌다**
@@ -268,6 +270,17 @@ class BillResult:
def direct_expense_krw(self) -> Decimal:
return sum((r.expense_krw for r in self.rows if not r.is_group), _ZERO)
def resource_quantities(self) -> dict[str, Decimal]:
"""집계표에 넘길 `{단가 코드: 수량}` — 내역 줄 차례 그대로(처음 쓰인 차례가 호표 번호)."""
found: dict[str, Decimal] = {}
for row in self.rows:
if row.is_group or row.amount_krw is None or row.quantity is None:
continue
for code, amount in row.parts or [(row.price_code, Decimal(1))]:
if code:
found[code] = found.get(code, _ZERO) + amount * row.quantity
return found
@property
def body_total_krw(self) -> Decimal:
"""내역서 **본체** 합계 — 줄마다 절사한 금액의 합.
@@ -386,6 +399,7 @@ from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import ( # noqa: E402
_leaf_row,
_material_row,
_structure_price_row,
_sum_groups,
bill_line, # noqa: F401 — 내역 줄 성분별 절사(골든셋 시험이 여기서 부름)
)
@@ -633,24 +647,6 @@ def build_bill(
return result
def _sum_groups(rows: list[BillRow]) -> None:
"""머리글 줄 금액 = 그 아래 줄 금액의 합(성분마다) — 실무 내역서 계 줄. 화면은 더하지 않음.
⚠ `direct_*`·`body_total_krw` 는 머리글을 빼고 더하므로 두 번 안 셈.
"""
for group in rows:
if not group.is_group:
continue
prefix = f"{group.item_no}-"
children = [
r for r in rows if not r.is_group and r.item_no.startswith(prefix) and r.amount_krw
]
group.material_krw = sum((r.material_krw for r in children), _ZERO)
group.labor_krw = sum((r.labor_krw for r in children), _ZERO)
group.expense_krw = sum((r.expense_krw for r in children), _ZERO)
group.amount_krw = sum((r.amount_krw for r in children), _ZERO)
def bill_summary(result: BillResult) -> dict[str, Any]:
"""화면에 낼 요약 — **무엇이 비었는지**를 함께 낸다."""
return {
@@ -40,6 +40,24 @@ def bill_line(unit: Money3, quantity) -> Money3:
)
def _sum_groups(rows: list[BillRow]) -> None:
"""머리글 줄 금액 = 그 아래 줄 금액의 합(성분마다) — 실무 내역서 계 줄. 화면은 더하지 않음.
⚠ `direct_*`·`body_total_krw` 는 머리글을 빼고 더하므로 두 번 안 셈.
"""
for group in rows:
if not group.is_group:
continue
prefix = f"{group.item_no}-"
children = [
r for r in rows if not r.is_group and r.item_no.startswith(prefix) and r.amount_krw
]
group.material_krw = sum((r.material_krw for r in children), Decimal(0))
group.labor_krw = sum((r.labor_krw for r in children), Decimal(0))
group.expense_krw = sum((r.expense_krw for r in children), Decimal(0))
group.amount_krw = sum((r.amount_krw for r in children), Decimal(0))
def _set_unit(row: BillRow, unit: Money3) -> None:
"""성분 단가 칸 — 금액을 낸 바로 그 3분할(내역 서식 「노무비·재료비·경비 단가」)."""
row.unit_material_krw = unit.material
@@ -80,6 +98,7 @@ def _composite_row(
# 묶음도 호표 한 장 — 조각 줄 0.1원 · 성분 소계 원 미만 절사(아래 `floored`, 명세 7장).
scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount).floored(Decimal("0.1"))
money = scaled if money is None else money + scaled
row.parts.append((f"B-{code}", amount))
reasons: list[str] = []
for pending in item.composite_not_ready:
@@ -170,6 +189,7 @@ def _structure_price_row(
line = bill_line(entry["money"], item.quantity)
_set_unit(row, entry["money"])
row.price_code = ref
row.parts = list(entry.get("parts") or [])
row.unconfirmed = int(entry["unconfirmed"] or 0)
row.unit_price_krw = entry["total"]
row.amount_krw = line.total
+110 -6
View File
@@ -140,6 +140,17 @@ def machine_list(build: UnitPriceBuild) -> list[dict[str, Any]]:
return rows
def machine_summary_amounts(unit_money: Any, quantity: Decimal) -> dict[str, Decimal]:
"""중기시간금액집계표 금액 — **성분마다 반올림한 뒤 합**(단가 열 없이 합계·노무·재료·경비).
근거 실무 6건 골든셋 — 성분마다 131/144 · 합계를 한 번에 반올림하면 107/144.
"""
return {
key: round_at(getattr(unit_money, key) * quantity, OutputPlace.RESOURCE_SUMMARY)
for key in ("labor", "material", "expense")
}
def resource_summary(
quantities: dict[str, Decimal],
build: UnitPriceBuild | None = None,
@@ -181,19 +192,26 @@ def resource_summary(
slot[0] += detail.quantity * amount
for raw_code, quantity in quantities.items():
code = raw_code if raw_code.startswith("B-") else f"B-{raw_code}"
code = raw_code if raw_code in book.titles or raw_code.startswith("B-") else f"B-{raw_code}"
if code not in book.titles:
missing.append(raw_code)
continue
walk(code, Decimal(str(quantity)), (code,))
if book.titles[code].kind in unfold:
walk(code, Decimal(str(quantity)), (code,))
else:
# 구조물도 표 줄이 자원(노임·자재·중기)을 바로 부른 자리 — 그대로 한 줄.
slot = picked.setdefault(code, [_ZERO, book.titles[code]])
slot[0] += Decimal(str(quantity))
groups: dict[str, list[dict[str, Any]]] = {
"labor": [],
"material": [],
"expense": [],
"machine": [],
"lumpsum": [],
}
for ref, (amount, title) in sorted(picked.items()):
# ⚠ 차례 = **처음 쓰인 차례**(내역 줄 → 호표 안 줄) — 호표 번호가 그 차례(2026-09-14 브레인 판정).
for ref, (amount, title) in picked.items():
if title is None:
missing.append(ref)
continue
@@ -202,12 +220,12 @@ def resource_summary(
PriceKind.MATERIAL: "material",
PriceKind.MACHINE_BASE: "expense",
PriceKind.MACHINE_HOURLY: "machine",
PriceKind.LUMPSUM: "lumpsum",
}.get(title.kind)
if bucket is None:
# 일식(W)은 단가 0 이라 자원이 아님 — 그 밖의 종류가 오면 조용히 버리지 않고 드러냄.
if title.kind is not PriceKind.LUMPSUM:
missing.append(f"{ref} (집계 칸 없는 종류 {title.kind.value})")
missing.append(f"{ref} (집계 칸 없는 종류 {title.kind.value})")
continue
row: dict[str, Any] = {}
try:
unit_money = book.resolve(ref)
unit_price: Decimal | None = unit_money.total
@@ -215,11 +233,17 @@ def resource_summary(
money: Decimal | None = round_at(
unit_money.total * amount, OutputPlace.RESOURCE_SUMMARY
)
if bucket == "machine":
parts = machine_summary_amounts(unit_money, amount)
money = sum(parts.values(), _ZERO)
row = {f"{key}_krw": str(value) for key, value in parts.items()}
row.update({f"unit_{key}_krw": str(getattr(unit_money, key)) for key in parts})
note = ""
except Exception as error:
unit_price, money, note = None, None, str(error)
groups[bucket].append(
{
"number": len(groups[bucket]) + 1,
"code": ref,
"name": title.name,
"spec": title.spec,
@@ -227,6 +251,7 @@ def resource_summary(
"unit": title.unit,
"unit_price_krw": _money(unit_price),
"amount_krw": _money(money),
**row,
"note": note,
}
)
@@ -238,6 +263,85 @@ def resource_summary(
}
def add_material_rows(summary: dict[str, Any], sheet: Any) -> None:
"""재료비 집계표에 **자재대 줄**(사급·관급)을 이어 붙임 — 실무 집계표는 내역 자재 줄까지 한 표.
값은 자재대 표의 단가·수량 그대로, 금액만 집계표 자리(반올림)로.
⚠ 단가 못 세운 줄·공급 미정 줄은 안 실음(자재대 표가 이미 이름째 드러냄).
"""
if sheet is None:
return
rows = summary["groups"]["material"]
for supply, items in (("사급", sheet.contractor_rows), ("관급", sheet.owner_rows)):
for item in items:
if item.unit_price_krw is None:
continue
rows.append(
{
"number": len(rows) + 1,
"code": "",
"name": item.name,
"spec": item.spec,
"quantity": str(item.total_amount),
"unit": item.unit,
"unit_price_krw": str(item.unit_price_krw),
"amount_krw": str(
round_at(
item.unit_price_krw * item.total_amount, OutputPlace.RESOURCE_SUMMARY
)
),
"note": f"자재대({supply})",
}
)
def bill_lists(summary: dict[str, Any]) -> dict[str, Any]:
"""내역이 쓴 자원의 **목록표**(색인) — 집계표와 같은 코드·같은 차례, 수량·금액만 뺌.
⚠ 값을 새로 세지 않음 — 집계표 줄의 단가 칸을 그대로 옮김. 경비목록표만 **기계 취득가(천원)**를
더 실음(실무 서식: 중기 호표가 부르는 `S` 층) — 카탈로그 값 그대로.
"""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
groups = summary["groups"]
keep = ("number", "code", "name", "spec", "unit", "unit_price_krw", "note")
def index(rows: list[dict[str, Any]], extra: tuple[str, ...] = ()) -> list[dict[str, Any]]:
return [{key: row.get(key) for key in (*keep, *extra)} for row in rows]
catalog = load_machine_catalog()
machines = dict.fromkeys(row["code"][2:].split("#")[0] for row in groups["machine"])
expense = [
{
"code": f"S-{code}",
"name": catalog.machines[code].name,
"spec": catalog.machines[code].specification,
"unit": "천원",
"unit_price_krw": _money(catalog.machines[code].price_thousand_krw),
"note": "",
}
for code in machines
if code in catalog.machines
] + index(groups["expense"])
for number, row in enumerate(expense, start=1):
row["number"] = number
unit_keys = ("unit_labor_krw", "unit_material_krw", "unit_expense_krw")
return {
"labor": index(groups["labor"]),
"material": index(groups["material"]),
"expense": expense,
"machine": index(groups["machine"], unit_keys),
"lumpsum": index(groups["lumpsum"]),
}
def bill_sheets(result: Any, build: UnitPriceBuild) -> dict[str, Any]:
"""내역 한 벌의 집계표 넷(`resources`)과 목록표(`lists`) — 내역 응답에 함께 실음."""
resources = resource_summary(result.resource_quantities(), build)
add_material_rows(resources, result.material_sheet)
return {"resources": resources, "lists": bill_lists(resources)}
def all_lists(build: UnitPriceBuild | None = None) -> dict[str, Any]:
"""목록표 넷을 한 번에 — 화면이 탭 하나에서 다 쓴다."""
prices = build or cached_build()
@@ -110,6 +110,8 @@ def material_price_comparison(build: UnitPriceBuild | None = None) -> dict[str,
note = ""
except Exception as error: # 채택 슬롯이 비었다 — 지어내지 않는다
adopted, note = None, str(error)
# 최소단가 — 값이 선 원천 가운데 가장 싼 칸(STmate `wM_Boxa` 「최소단가」 표시 · 채택 규칙 17번 §5.5).
filled = [(value, i + 1) for i, value in enumerate(title.slots[:5]) if value]
rows.append(
{
"code": code,
@@ -117,6 +119,7 @@ def material_price_comparison(build: UnitPriceBuild | None = None) -> dict[str,
"spec": title.spec,
"unit": title.unit,
"slots": slots,
"min_slot": min(filled)[1] if filled else None,
"adopted_slot": title.adopted_slot,
"adopted_price_krw": _money(adopted),
"note": note,
+4
View File
@@ -796,10 +796,14 @@ async def get_bill(project_id: UUID) -> JSONResponse:
content={"status": "error", "message": "예산내역서를 세우지 못했습니다."},
)
from B09_Estimation.B09_Estimation_Lists import bill_sheets
return JSONResponse(
content=_with_provenance(
{
"status": "success",
# 집계표 넷·목록표 — 이 내역이 쓴 자원을 처음 쓰인 차례로 되모음(값은 새로 안 셈).
**bill_sheets(result, build),
"rows": [row.as_dict() for row in result.rows],
"excluded": [row.as_dict() for row in result.excluded],
"materials": [row.as_dict() for row in result.material_rows],
+10 -6
View File
@@ -58,18 +58,22 @@ function trail(ctx: B09TabContext): HTMLElement {
return box;
}
/** 줄이 가리키는 곳 — 단산(D)은 단가산출근거 탭, 일위대가·중기는 일위대가 탭. */
/** 줄이 가리키는 곳 — 단산(D)은 단가산출근거 탭, 시간당 중기(X)는 중기 탭, 일위대가는 일위대가 탭. */
function targetTab(row: DetailRowDto): string | null {
if (!row.drillable || !row.ref_code) return null;
return row.kind === "price_basis" ? "price_basis" : "unit_price";
if (row.kind === "price_basis") return "price_basis";
return row.kind === "machine_hourly" ? "machine" : "unit_price";
}
function detailRow(row: DetailRowDto, ctx: B09TabContext): HTMLElement {
const tr = el("tr");
const percent = row.unit === "%";
const unit: [string, string, string, string] | null = percent
? null
: [row.unit_total ?? "", row.unit_labor ?? "", row.unit_material ?? "", row.unit_expense ?? ""];
// 비율 줄(잡품·공구손료·제잡비)은 단가 칸에 **밑수**가 옴 — 「21 % × 주연료비 6,365」 꼴(실무 표).
const unit: [string, string, string, string] = [
row.unit_total ?? "",
row.unit_labor ?? "",
row.unit_material ?? "",
row.unit_expense ?? "",
];
tr.append(
el("td", "", row.name),
el("td", "", row.spec),
@@ -0,0 +1,122 @@
/* =============================================================================
* B09_Estimation_UI_MachineExpense.ts
* 각종 중기경비계산서 — 기종마다 한 장(별표2 (5)(가) 아홉째) · 중기 탭 아래에 붙음
*
* - 옛 `B09_Estimation_UI_BaseData.ts` 의 중기경비계산서를 그대로 옮김(PLAN 12장 옛 탭 옮기기).
* - ⚠ 목록표가 「얼마」라면 이 장은 **왜 그 값인가** — 계산 과정을 감추지 않음.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { el } from "./B09_Estimation_UI_Sheet";
import { head, infoTable, money, note } from "./B09_Estimation_UI_Table";
export interface MachineExpenseDto {
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;
variant: string;
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 = el("div", "b09s-group");
box.append(
el(
"div",
"b09s-head",
`${sheet.machine_code} ${sheet.name} ${sheet.spec}`.trim() +
(sheet.variant ? `${sheet.variant}` : ""),
),
);
const coefficient = (value: number | null) => (value === null ? "—" : String(value));
box.append(
infoTable(
["구 분", "내 용", "값"],
[
["① 손료", "취득가격(천원)", 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.variant) {
box.append(
note(
"같은 기종이라도 조합 사용이면 잡재료가 16% 로 줄어 재료비가 달라집니다 —" +
" 그래서 층이 따로 섭니다(건설품셈 제8장 [주]⑤).",
),
);
}
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));
}
+32
View File
@@ -117,6 +117,31 @@ export function sheetTable(head: HTMLElement): { wrap: HTMLElement; tbody: HTMLE
return { wrap, tbody };
}
/** 머리 한 줄짜리 표(목록표·집계표) — 실무 시트 칸 이름 그대로 넘김. */
export function plainTable(headers: string[]): { wrap: HTMLElement; tbody: HTMLElement } {
const thead = el("thead");
const tr = el("tr");
for (const label of headers) tr.append(el("th", "", label));
thead.append(tr);
return sheetTable(thead);
}
/** 한 탭 안의 갈래 고르개(재료비·노무비…) — 고른 갈래를 돌려줌. */
export function segmented(
items: Array<[string, string]>,
active: string,
onPick: (key: string) => void,
): HTMLElement {
const bar = el("div", "b09s-bar");
for (const [key, label] of items) {
const button = el("button", `b09s-tab${key === active ? " is-active" : ""}`, label);
button.type = "button";
button.addEventListener("click", () => onPick(key));
bar.append(button);
}
return bar;
}
/** 누르면 들어가는 글 — 「제 3 호표」·「단산 2」. */
export function linkButton(text: string, onClick: () => void): HTMLButtonElement {
const button = el("button", "b09s-link", text);
@@ -170,6 +195,13 @@ export function injectSheetStyles(): void {
.b09s-title { font-weight:700; font-size:14px; }
.b09s-formula { white-space:pre-wrap; font-size:12px; color:var(--ui-text, #1f2430); }
.b09s-legacy .b09-tabs { display:none; }
.b09s-head { font-weight:600; margin-top:6px; }
.b09s-info td { text-align:right; font-variant-numeric:tabular-nums; }
.b09s-info td.b09s-left, .b09s-info th.b09s-left { text-align:left; white-space:normal; }
.b09s-group { display:flex; flex-direction:column; gap:4px; border-top:1px solid var(--ui-border, #d0d4dc); padding-top:6px; }
.b09s-inline { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.b09s-table td.b09s-adopted { font-weight:700; color:var(--ui-accent, #2f6fed); background:rgba(47,111,237,0.08); }
.b09s-min { display:inline-block; margin-left:4px; font-size:10px; color:#1e8e3e; border:1px solid #1e8e3e; border-radius:8px; padding:0 4px; }
`;
document.head.append(style);
}
+14 -4
View File
@@ -19,6 +19,13 @@ import { rateTableTab } from "./B09_Estimation_UI_Tab_RateTable";
import { billTab } from "./B09_Estimation_UI_Tab_Bill";
import { unitPriceTab } from "./B09_Estimation_UI_Tab_UnitPrice";
import { priceBasisTab } from "./B09_Estimation_UI_Tab_PriceBasis";
import { machineTab } from "./B09_Estimation_UI_Tab_Machine";
import { priceCompareTab } from "./B09_Estimation_UI_Tab_PriceCompare";
import { summaryTab } from "./B09_Estimation_UI_Tab_Summary";
import { listsTab } from "./B09_Estimation_UI_Tab_Lists";
import { designDocTab } from "./B09_Estimation_UI_Tab_DesignDoc";
import { basisSheetTab } from "./B09_Estimation_UI_Tab_BasisSheet";
import { supplyTab } from "./B09_Estimation_UI_Tab_Supply";
/** 탭 등록 — 한 줄에 탭 하나. 옛 탭(`legacyTab`)은 새 탭 파일이 서면 그 줄만 바꿈. */
const TABS: B09Tab[] = [
@@ -27,11 +34,14 @@ const TABS: B09Tab[] = [
billTab,
unitPriceTab,
priceBasisTab,
legacyTab("machine", "B09_Estimation_Tab_Machine"),
legacyTab("supply", "B09_Estimation_Tab_Supply"),
machineTab,
priceCompareTab,
summaryTab,
listsTab,
supplyTab,
legacyTab("base_data", "B09_Estimation_Tab_BaseData"),
legacyTab("design_doc", "B09_Estimation_Tab_DesignDoc"),
legacyTab("basis_sheet", "B09_Estimation_Tab_BasisSheet"),
designDocTab,
basisSheetTab,
];
export async function renderB09Estimation(root: HTMLElement): Promise<void> {
+82
View File
@@ -8,6 +8,7 @@
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { ProvenancePayload } from "@ui/ui_template_provenance";
export interface BillNoteDto {
column: string;
@@ -68,7 +69,55 @@ export interface MissingDto {
blocked_kind?: string;
}
/** 집계표·목록표 한 줄 — 서버 `resource_summary`·`bill_lists` 그대로. */
export interface ResourceRowDto {
number: number;
code: string;
name: string;
spec: string;
unit: string;
quantity?: string;
unit_price_krw: string | null;
amount_krw?: string | null;
labor_krw?: string;
material_krw?: string;
expense_krw?: string;
unit_labor_krw?: string;
unit_material_krw?: string;
unit_expense_krw?: string;
note: string;
}
export type ResourceGroup = "labor" | "material" | "expense" | "machine" | "lumpsum";
export interface MaterialSheetRowDto {
name: string;
spec: string;
unit: string;
total_amount: string;
unit_price_krw: string | null;
amount_krw: string | null;
note: string;
}
export interface MaterialSheetDto {
contractor: MaterialSheetRowDto[];
owner: MaterialSheetRowDto[];
unknown: MaterialSheetRowDto[];
contractor_total_krw: string;
owner_total_krw: string;
notes: string[];
}
export interface BillDto {
/** 근거 사전 — 개발환경에서만 옴. */
provenance?: ProvenancePayload;
resources: {
groups: Record<ResourceGroup, ResourceRowDto[]>;
missing: string[];
note: string;
};
lists: Record<ResourceGroup, ResourceRowDto[]>;
rows: BillRowDto[];
excluded: BillRowDto[];
summary: {
@@ -77,6 +126,7 @@ export interface BillDto {
missing: MissingDto[];
unconfirmed_count: number;
notes: string[];
material_sheet: MaterialSheetDto | null;
};
price_basis: { entries: SheetEntryDto[] };
unit_price_sheet: { entries: SheetEntryDto[] };
@@ -140,6 +190,38 @@ export function loadBill(projectId: string, force = false): Promise<BillDto> {
return bills.get(projectId) as Promise<BillDto>;
}
export interface PriceSlotDto {
name: string;
price_krw: string | null;
source_note: string;
adopted: boolean;
}
export interface PriceCompareDto {
material_comparison: {
slot_names: string[];
rows: Array<{
code: string;
name: string;
spec: string;
unit: string;
slots: PriceSlotDto[];
min_slot: number | null;
adopted_slot: number;
adopted_price_krw: string | null;
note: string;
}>;
notes: string[];
};
}
/** 자재단가대비표 — 슬롯 여섯 · 채택 · 최소단가(서버 `material_price_comparison`). */
export function loadPriceCompare(projectId: string): Promise<PriceCompareDto> {
return getJson<PriceCompareDto>(
`/projects/${encodeURIComponent(projectId)}/estimation/price-sources`,
);
}
/** 호표 본표 — 일위대가(B)·시간당 중기(X)는 `unit-prices`, 단가산출(D)은 `price-basis`. */
export function loadDetail(projectId: string, code: string): Promise<DetailDto> {
const kind = code.startsWith("D-") ? "price-basis" : "unit-prices";
@@ -0,0 +1,120 @@
/* =============================================================================
* B09_Estimation_UI_Tab_BasisSheet.ts
* B09 (2 (5)() )
*
* - `B09_Estimation_UI_BaseData.ts` (PLAN 12 ).
* - . 구획: · · · .
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { ProvenancePayload } from "@ui/ui_template_provenance";
import type { B09Tab } from "./B09_Estimation_UI_Shell_Types";
import { L, hint } from "./B09_Estimation_UI_Sheet";
import { head, infoTable, note } from "./B09_Estimation_UI_Table";
interface BasisSheetDto {
provenance?: ProvenancePayload;
note: string;
summary: string;
dataset_versions: Array<{
dataset_id: string;
file: string;
effective_date: string;
sha256: string;
}>;
chosen_conditions: Array<{ item: string; value: string }>;
work_items: Array<{ code: string; name: string; unit: string; notes: string[] }>;
gaps: Array<{ kind: string; code: string; reason: string }>;
}
function draw(body: HTMLElement, data: BasisSheetDto): void {
const sheets = data.provenance?.sheets;
body.append(head("산출기초"));
body.append(note(data.summary));
body.append(note(data.note));
body.append(head(`① 어느 판으로 계산했나 (${data.dataset_versions.length})`));
body.append(
infoTable(
["자료", "파일", "기준일", "지문(앞 12)"],
data.dataset_versions.map((row) => [
row.dataset_id,
row.file,
row.effective_date,
row.sha256 || "—",
]),
[0, 1, 2, 3],
["dataset_id", "file", "effective_date", "sha256"],
sheets?.basis_versions,
),
);
body.append(head(`② 무엇을 골랐나 (${data.chosen_conditions.length})`));
if (data.chosen_conditions.length === 0) {
body.append(note("고른 값이 없습니다 — 전부 확정 기본값으로 돌고 있습니다."));
} else {
body.append(
infoTable(
["항 목", "고른 값"],
data.chosen_conditions.map((row) => [row.item, row.value]),
[0, 1],
["item", "value"],
sheets?.basis_chosen,
),
);
}
body.append(head(`③ 공종마다 무엇을 근거로 했나 (${data.work_items.length})`));
body.append(
infoTable(
["코드", "공 종", "단위", "근 거"],
data.work_items.map((row) => [row.code, row.name, row.unit, row.notes.join(" · ")]),
[0, 1, 2, 3],
["code", "name", "unit", "notes"],
sheets?.basis_items,
),
);
body.append(head(`④ 못 채운 자리 (${data.gaps.length})`));
if (data.gaps.length === 0) {
body.append(note("못 채운 자리가 없습니다."));
return;
}
body.append(
infoTable(
["갈 래", "코드", "사 유"],
data.gaps.map((row) => [row.kind, row.code, row.reason]),
[0, 1, 2],
["kind", "code", "reason"],
sheets?.basis_gaps,
),
);
body.append(note("⚠ 여기 있는 것은 0 으로 때우지 않고 남겨 둔 자리입니다."));
}
export const basisSheetTab: B09Tab = {
key: "basis_sheet",
label: () => L("B09_Estimation_Tab_BasisSheet"),
render(ctx) {
if (!ctx.projectId) {
ctx.body.append(hint(L("B09_Sheet_NoProject")));
return;
}
// 고른 값이 바뀌면 근거도 바뀜 — 고를 때마다 새로 받음.
ctx.body.append(hint(L("B09_Sheet_Loading")));
fetch(`${API_BASE_URL}/projects/${encodeURIComponent(ctx.projectId)}/estimation/basis-sheet`, {
credentials: "include",
})
.then((response) => {
if (!response.ok) throw new Error(String(response.status));
return response.json() as Promise<BasisSheetDto>;
})
.then((data) => {
ctx.body.replaceChildren();
draw(ctx.body, data);
})
.catch((error: Error) => {
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
});
},
};
@@ -0,0 +1,82 @@
/* =============================================================================
* B09_Estimation_UI_Tab_DesignDoc.ts
* B09 (2 (5)())
*
* - `B09_Estimation_UI_BaseData.ts` (PLAN 12 ).
* - .
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { B09Tab } from "./B09_Estimation_UI_Shell_Types";
import { L, hint } from "./B09_Estimation_UI_Sheet";
import { head, infoTable, note } from "./B09_Estimation_UI_Table";
interface DesignDocDto {
law: string;
summary: string;
items: Array<{
order: number;
name: string;
status: string;
owner: string;
where: string;
note: string;
}>;
notes: string[];
}
let cached: DesignDocDto | null = null;
function draw(body: HTMLElement, data: DesignDocDto): void {
body.append(head(`설계서 구성 (법이 정한 ${data.items.length})`));
body.append(note(data.summary));
body.append(note(data.law));
body.append(
infoTable(
["차례", "이 름", "상 태", "누가 만드나", "어디서 나오나", "비 고"],
data.items.map((row) => [
String(row.order),
row.name,
row.status,
row.owner,
row.where || "—",
row.note,
]),
[0, 1, 2, 3, 4, 5],
),
);
for (const line of data.notes) body.append(note(line));
}
export const designDocTab: B09Tab = {
key: "design_doc",
label: () => L("B09_Estimation_Tab_DesignDoc"),
render(ctx) {
if (!ctx.projectId) {
ctx.body.append(hint(L("B09_Sheet_NoProject")));
return;
}
// 프로젝트 값이 아니라 **우리가 무엇을 내는가**의 표 — 한 번 받으면 그대로 씀.
if (cached) {
draw(ctx.body, cached);
return;
}
ctx.body.append(hint(L("B09_Sheet_Loading")));
fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(ctx.projectId)}/estimation/design-doc-index`,
{ credentials: "include" },
)
.then((response) => {
if (!response.ok) throw new Error(String(response.status));
return response.json() as Promise<DesignDocDto>;
})
.then((data) => {
cached = data;
ctx.body.replaceChildren();
draw(ctx.body, data);
})
.catch((error: Error) => {
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
});
},
};
@@ -0,0 +1,152 @@
/* =============================================================================
* B09_Estimation_UI_Tab_Lists.ts
* B09 ( `…목록표` · PLAN 12)
*
* · · · · · · · · · ·
* · · · · · · · ·
*
* - **** . ·· .
* ( ).
* - ·· .
* ========================================================================== */
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import { L, el, hint, numberCell, plainTable, segmented, won } from "./B09_Estimation_UI_Sheet";
import { loadBill, type BillDto, type ResourceGroup } from "./B09_Estimation_UI_Store";
type ListKey = "unit_price" | "price_basis" | ResourceGroup;
let active: ListKey = "unit_price";
const LISTS: Array<[ListKey, Parameters<typeof L>[0]]> = [
["unit_price", "B09_Sheet_UnitPriceList"],
["price_basis", "B09_Sheet_BasisList"],
["machine", "B09_Sheet_MachineList"],
["material", "B09_Sheet_List_Material"],
["labor", "B09_Sheet_List_Labor"],
["expense", "B09_Sheet_List_Expense"],
["lumpsum", "B09_Sheet_List_Lumpsum"],
];
interface ListRow {
label: string;
name: string;
spec: string;
unit: string;
money: string[];
note: string;
open?: [string, string];
}
function rowsOf(bill: BillDto, key: ListKey): ListRow[] {
if (key === "unit_price") {
return bill.unit_price_sheet.entries.map((entry) => ({
label: entry.label,
name: entry.name,
spec: entry.spec,
unit: entry.unit,
money: [entry.total_krw ?? "", entry.labor_krw, entry.material_krw, entry.expense_krw],
note: entry.unconfirmed ? `${L("B09_Sheet_Unconfirmed")} ${entry.unconfirmed}` : "",
open: ["unit_price", entry.code],
}));
}
if (key === "price_basis") {
return bill.price_basis.entries.map((entry) => ({
label: `${L("B09_Sheet_Basis_Long")} ${entry.number}${L("B09_Sheet_Basis_Suffix")}`,
name: entry.name,
spec: entry.spec,
unit: entry.unit,
money: [entry.unit_price_krw ?? "", entry.labor_krw, entry.material_krw, entry.expense_krw],
note: "",
open: ["price_basis", entry.code],
}));
}
return bill.lists[key].map((row) => ({
label: String(row.number),
name: row.name,
spec: row.spec,
unit: row.unit,
money:
key === "machine"
? [
row.unit_price_krw ?? "",
row.unit_labor_krw ?? "",
row.unit_material_krw ?? "",
row.unit_expense_krw ?? "",
]
: [row.unit_price_krw ?? ""],
note: row.note,
open: key === "machine" ? ["machine", row.code] : undefined,
}));
}
function draw(ctx: B09TabContext, bill: BillDto): void {
ctx.body.append(
segmented(
LISTS.map(([key, label]) => [key, L(label)]),
active,
(key) => {
active = key as ListKey;
ctx.body.replaceChildren();
draw(ctx, bill);
},
),
);
const fourWay = active === "unit_price" || active === "price_basis" || active === "machine";
const money = fourWay
? [
L("B09_Sheet_Col_Total"),
L("B09_Sheet_Col_Labor"),
L("B09_Sheet_Col_Material"),
L("B09_Sheet_Col_Expense"),
]
: [L("B09_Sheet_Col_UnitPrice")];
const { wrap, tbody } = plainTable([
L("B09_Sheet_Col_Sheet"),
L("B09_Sheet_Col_Name"),
L("B09_Sheet_Col_Spec"),
L("B09_Sheet_Col_Unit"),
...money,
L("B09_Sheet_Col_Note"),
]);
const rows = rowsOf(bill, active);
for (const row of rows) {
const tr = el("tr");
tr.append(
el("td", "", row.label),
el("td", "", row.name),
el("td", "", row.spec),
el("td", "", row.unit),
);
for (const value of row.money) tr.append(numberCell(won(value)));
tr.append(el("td", "b09s-note", row.note));
if (row.open) {
const [tab, code] = row.open;
tr.classList.add("is-clickable");
tr.title = L("B09_Sheet_Drill");
tr.addEventListener("click", () => ctx.open(tab, code));
}
tbody.append(tr);
}
ctx.body.append(wrap);
if (rows.length === 0) ctx.body.append(hint(L("B09_Sheet_EmptyGroup")));
}
export const listsTab: B09Tab = {
key: "lists",
label: () => L("B09_Sheet_Tab_Lists"),
render(ctx) {
if (!ctx.projectId) {
ctx.body.append(hint(L("B09_Sheet_NoProject")));
return;
}
ctx.body.append(hint(L("B09_Sheet_Loading")));
loadBill(ctx.projectId)
.then((bill) => {
ctx.body.replaceChildren();
draw(ctx, bill);
})
.catch((error: Error) => {
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
});
},
};
@@ -0,0 +1,91 @@
/* =============================================================================
* B09_Estimation_UI_Tab_Machine.ts
* B09 + ( `중기목록표`·`중기사용료` · PLAN 12)
*
* - = , ( `lists.machine`).
* - () · () · () · ().
* × (%), () ( ).
* - (··· ) _UI_MachineExpense.
* ========================================================================== */
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import { renderDetail } from "./B09_Estimation_UI_Detail";
import { drawMachineExpense, fetchMachineExpense } from "./B09_Estimation_UI_MachineExpense";
import { L, el, hint, numberCell, plainTable, won } from "./B09_Estimation_UI_Sheet";
import { loadBill, type BillDto } from "./B09_Estimation_UI_Store";
let selected = "";
function draw(ctx: B09TabContext, bill: BillDto): void {
const rows = bill.lists.machine;
const { wrap, tbody } = plainTable([
L("B09_Sheet_Col_Sheet"),
L("B09_Sheet_Col_Name"),
L("B09_Sheet_Col_Spec"),
L("B09_Sheet_Col_Unit"),
L("B09_Sheet_Col_Total"),
L("B09_Sheet_Col_Labor"),
L("B09_Sheet_Col_Material"),
L("B09_Sheet_Col_Expense"),
L("B09_Sheet_Col_Note"),
]);
for (const row of rows) {
const tr = el("tr", `is-clickable${row.code === selected ? " is-selected" : ""}`);
tr.append(
el("td", "", String(row.number)),
el("td", "", row.name),
el("td", "", row.spec),
el("td", "", row.unit),
numberCell(won(row.unit_price_krw)),
numberCell(won(row.unit_labor_krw)),
numberCell(won(row.unit_material_krw)),
numberCell(won(row.unit_expense_krw)),
el("td", "b09s-note", row.note),
);
tr.addEventListener("click", () => {
selected = row.code;
ctx.body.replaceChildren();
draw(ctx, bill);
});
tbody.append(tr);
}
const detail = el("div", "b09s-split");
const expense = el("div", "b09s-split");
ctx.body.append(el("div", "b09s-title", L("B09_Sheet_MachineList")), wrap);
if (rows.length === 0) ctx.body.append(hint(L("B09_Sheet_EmptyList")));
ctx.body.append(detail, expense);
if (selected) {
const picked = rows.find((row) => row.code === selected);
const label = picked ? `${L("B09_Sheet_Col_Sheet")} ${picked.number}` : selected;
renderDetail(ctx, detail, "machine", selected, label);
} else {
detail.append(hint(L("B09_Sheet_PickSheet")));
}
if (ctx.projectId) {
// 중기경비계산서 — 「그 값이 왜 나왔나」(옛 표를 옮겨 온 _UI_MachineExpense).
void fetchMachineExpense(ctx.projectId)
.then((data) => drawMachineExpense(expense, data))
.catch(() => expense.append(hint(L("B09_Sheet_LoadFailed"), true)));
}
}
export const machineTab: B09Tab = {
key: "machine",
label: () => L("B09_Estimation_Tab_Machine"),
render(ctx, arg) {
if (!ctx.projectId) {
ctx.body.append(hint(L("B09_Sheet_NoProject")));
return;
}
if (arg) selected = arg;
ctx.body.append(hint(L("B09_Sheet_Loading")));
loadBill(ctx.projectId)
.then((bill) => {
ctx.body.replaceChildren();
draw(ctx, bill);
})
.catch((error: Error) => {
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
});
},
};
@@ -0,0 +1,87 @@
/* =============================================================================
* B09_Estimation_UI_Tab_PriceCompare.ts
* B09 · · ( `자재단가대비표` · STmate `wM_Boxa` )
*
* - : 호표 · · · · 1~5( · ) · (6) · .
* - = · · = ( `min_slot`). (0 ).
* - ( / 1 ~5 / ) 2 .
* ========================================================================== */
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import { L, el, hint, numberCell, sheetTable, won } from "./B09_Estimation_UI_Sheet";
import { loadPriceCompare, type PriceCompareDto } from "./B09_Estimation_UI_Store";
function head(slotNames: string[]): HTMLElement {
const thead = el("thead");
const top = el("tr");
const bottom = el("tr");
for (const label of [
L("B09_Sheet_Col_Sheet"),
L("B09_Sheet_Col_Name"),
L("B09_Sheet_Col_Spec"),
L("B09_Sheet_Col_Unit"),
]) {
const th = el("th", "", label);
th.rowSpan = 2;
top.append(th);
}
slotNames.forEach((name, index) => {
const th = el("th", "", `${index + 1} ${name}`);
th.colSpan = 2;
top.append(th);
bottom.append(
el("th", "", L("B09_Sheet_Col_UnitPrice")),
el("th", "", L("B09_Sheet_Col_Page")),
);
});
const note = el("th", "", L("B09_Sheet_Col_Note"));
note.rowSpan = 2;
top.append(note);
thead.append(top, bottom);
return thead;
}
function draw(ctx: B09TabContext, data: PriceCompareDto): void {
const table = data.material_comparison;
const { wrap, tbody } = sheetTable(head(table.slot_names));
table.rows.forEach((row, index) => {
const tr = el("tr");
tr.append(
el("td", "", String(index + 1)),
el("td", "", row.name),
el("td", "", row.spec),
el("td", "", row.unit),
);
row.slots.forEach((slot, slotIndex) => {
const price = numberCell(won(slot.price_krw));
if (slot.adopted) price.classList.add("b09s-adopted");
if (row.min_slot === slotIndex + 1) price.append(el("span", "b09s-min", L("B09_Sheet_Min")));
tr.append(price, el("td", "", slot.source_note));
});
tr.append(el("td", "b09s-note", row.note));
tbody.append(tr);
});
ctx.body.append(el("div", "b09s-title", L("B09_Sheet_Tab_PriceCompare")), wrap);
if (table.rows.length === 0) ctx.body.append(hint(L("B09_Sheet_EmptyGroup")));
for (const note of table.notes) ctx.body.append(hint(note));
}
export const priceCompareTab: B09Tab = {
key: "price_compare",
label: () => L("B09_Sheet_Tab_PriceCompare"),
render(ctx) {
if (!ctx.projectId) {
ctx.body.append(hint(L("B09_Sheet_NoProject")));
return;
}
ctx.body.append(hint(L("B09_Sheet_Loading")));
loadPriceCompare(ctx.projectId)
.then((data) => {
ctx.body.replaceChildren();
draw(ctx, data);
})
.catch((error: Error) => {
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
});
},
};
@@ -0,0 +1,118 @@
/* =============================================================================
* B09_Estimation_UI_Tab_Summary.ts
* B09 ·· + ( · PLAN 12)
*
* - :
* ·· · · · · · · ·
* · · · · · · · · · ( )
* - (`resources`) **** ()
* ( ).
* - .
* ========================================================================== */
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import {
L,
el,
hint,
numberCell,
plainTable,
quantity,
segmented,
won,
} from "./B09_Estimation_UI_Sheet";
import { loadBill, type BillDto, type ResourceGroup } from "./B09_Estimation_UI_Store";
let group: ResourceGroup = "material";
const GROUPS: Array<[ResourceGroup, Parameters<typeof L>[0]]> = [
["material", "B09_Sheet_Sum_Material"],
["labor", "B09_Sheet_Sum_Labor"],
["expense", "B09_Sheet_Sum_Expense"],
["machine", "B09_Sheet_Sum_Machine"],
];
function draw(ctx: B09TabContext, bill: BillDto): void {
ctx.body.append(
segmented(
GROUPS.map(([key, label]) => [key, L(label)]),
group,
(key) => {
group = key as ResourceGroup;
ctx.body.replaceChildren();
draw(ctx, bill);
},
),
);
const machine = group === "machine";
const front = [
L("B09_Sheet_Col_Sheet"),
L("B09_Sheet_Col_Name"),
L("B09_Sheet_Col_Spec"),
L("B09_Sheet_Col_Quantity"),
L("B09_Sheet_Col_Unit"),
];
const money = machine
? [
L("B09_Sheet_Col_Total"),
L("B09_Sheet_Col_Labor"),
L("B09_Sheet_Col_Material"),
L("B09_Sheet_Col_Expense"),
]
: [L("B09_Sheet_Col_UnitPrice"), L("B09_Sheet_Col_Amount")];
const { wrap, tbody } = plainTable([...front, ...money, L("B09_Sheet_Col_Note")]);
const rows = bill.resources.groups[group];
for (const row of rows) {
const tr = el("tr");
tr.append(
el("td", "", String(row.number)),
el("td", "", row.name),
el("td", "", row.spec),
numberCell(quantity(row.quantity, null)),
el("td", "", row.unit),
);
if (machine) {
tr.append(
numberCell(won(row.amount_krw)),
numberCell(won(row.labor_krw)),
numberCell(won(row.material_krw)),
numberCell(won(row.expense_krw)),
);
tr.classList.add("is-clickable");
tr.title = L("B09_Sheet_Drill");
tr.addEventListener("click", () => ctx.open("machine", row.code));
} else {
tr.append(numberCell(won(row.unit_price_krw)), numberCell(won(row.amount_krw)));
}
tr.append(el("td", "b09s-note", row.note));
tbody.append(tr);
}
ctx.body.append(wrap);
if (rows.length === 0) ctx.body.append(hint(L("B09_Sheet_EmptyGroup")));
ctx.body.append(hint(bill.resources.note));
if (bill.resources.missing.length > 0) {
ctx.body.append(
hint(`${L("B09_Sheet_SumMissing")} ${bill.resources.missing.join(", ")}`, true),
);
}
}
export const summaryTab: B09Tab = {
key: "summary",
label: () => L("B09_Sheet_Tab_Summary"),
render(ctx) {
if (!ctx.projectId) {
ctx.body.append(hint(L("B09_Sheet_NoProject")));
return;
}
ctx.body.append(hint(L("B09_Sheet_Loading")));
loadBill(ctx.projectId)
.then((bill) => {
ctx.body.replaceChildren();
draw(ctx, bill);
})
.catch((error: Error) => {
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
});
},
};
@@ -0,0 +1,85 @@
/* =============================================================================
* B09_Estimation_UI_Tab_Supply.ts
* B09 · 자재대: B08 · (PLAN 8-7 B09)
*
* - `B09_Estimation_UI_Page.ts` (PLAN 12 ).
* - ( ) · ( ) · ( ) .
* ** ** (PLAN 8-36 ).
* - (`summary.material_sheet`) .
* ========================================================================== */
import type { ui_locales } from "@ui/ui_template_locale";
import type { B09Tab } from "./B09_Estimation_UI_Shell_Types";
import { L, hint, quantity } from "./B09_Estimation_UI_Sheet";
import { infoTable, note } from "./B09_Estimation_UI_Table";
import { loadBill, type BillDto, type MaterialSheetRowDto } from "./B09_Estimation_UI_Store";
const MATERIAL_KEYS = [
"name",
"spec",
"unit",
"total_amount",
"unit_price_krw",
"amount_krw",
"note",
];
function draw(body: HTMLElement, bill: BillDto): void {
const sheet = bill.summary.material_sheet;
if (!sheet) {
body.append(hint(L("B09_Estimation_Mat_Empty")));
return;
}
// 자재가 아예 없으면 **빈 표 셋을 늘어놓지 않음** — 「없다」 한 줄이면 됨(2026-09-08 화면 전수).
if (sheet.contractor.length === 0 && sheet.owner.length === 0 && sheet.unknown.length === 0) {
body.append(hint(L("B09_Estimation_Mat_None")));
return;
}
const groups: Array<[keyof typeof ui_locales, MaterialSheetRowDto[], string | null, string]> = [
["B09_Estimation_Mat_Contractor", sheet.contractor, sheet.contractor_total_krw, "material"],
["B09_Estimation_Mat_Owner", sheet.owner, sheet.owner_total_krw, "material"],
["B09_Estimation_Mat_Unknown", sheet.unknown, null, "material_unknown"],
];
for (const [labelKey, rows, total, sheetName] of groups) {
body.append(note(`${L(labelKey)} (${rows.length})` + (total === null ? "" : `${total}`)));
if (rows.length === 0) continue;
body.append(
infoTable(
["자재", "규격", "단위", "수량", "단가", "금액", "비고"],
rows.map((row) => [
row.name,
row.spec,
row.unit,
quantity(row.total_amount, 2),
row.unit_price_krw ?? "",
row.amount_krw ?? "",
row.note,
]),
[0, 1, 2, 6],
MATERIAL_KEYS,
bill.provenance?.sheets?.[sheetName],
),
);
}
for (const line of sheet.notes) body.append(note(line.replace(/\*\*/g, "")));
}
export const supplyTab: B09Tab = {
key: "supply",
label: () => L("B09_Estimation_Tab_Supply"),
render(ctx) {
if (!ctx.projectId) {
ctx.body.append(hint(L("B09_Sheet_NoProject")));
return;
}
ctx.body.append(hint(L("B09_Sheet_Loading")));
loadBill(ctx.projectId)
.then((bill) => {
ctx.body.replaceChildren();
draw(ctx.body, bill);
})
.catch((error: Error) => {
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
});
},
};
+66
View File
@@ -0,0 +1,66 @@
/* =============================================================================
* B09_Estimation_UI_Table.ts
* B09 ** ** · · · (PLAN 12)
*
* - `B09_Estimation_UI_BaseData.ts` `head`·`note`·`table` ( ).
* - (`keys`·`sheet`) **** , .
* - , `leftCols` ( `.b09-left` ).
* ========================================================================== */
import {
attachProvenance,
markProvenanceCell,
type ProvenanceSheet,
} from "@ui/ui_template_provenance";
import { el } from "./B09_Estimation_UI_Sheet";
/** 금액 글 → 천 단위 쉼표(옛 `money` 그대로 — 소수부를 자르지 않음). */
export function money(value: string | null | undefined): string {
if (value === null || value === undefined || value === "") return "";
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed.toLocaleString("ko-KR") : value;
}
/** 구역 제목. */
export function head(text: string): HTMLElement {
return el("div", "b09s-hint b09s-head", text);
}
/** 안내 한 줄. */
export function note(text: string): HTMLElement {
return el("div", "b09s-hint", text);
}
/** 설명 표 한 장 — 가로로 넘치면 제 칸 안에서만 밀림. */
export function infoTable(
headers: string[],
rows: string[][],
leftCols: number[],
keys?: string[],
sheet?: ProvenanceSheet,
): HTMLElement {
const wrap = el("div", "b09s-wrap");
const table = el("table", "b09s-table b09s-info");
const thead = el("thead");
const headRow = el("tr");
headers.forEach((text, index) => {
headRow.append(el("th", leftCols.includes(index) ? "b09s-left" : "", text));
});
thead.append(headRow);
const tbody = el("tbody");
for (const cells of rows) {
const tr = el("tr");
cells.forEach((text, index) => {
const td = el("td", leftCols.includes(index) ? "b09s-left" : "", text);
const key = keys?.[index];
const column = key ? sheet?.columns[key] : undefined;
if (key && column) markProvenanceCell(td, key, column.tier);
tr.append(td);
});
tbody.append(tr);
}
table.append(thead, tbody);
attachProvenance(table, sheet);
wrap.append(table);
return wrap;
}
@@ -200,6 +200,9 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
"spec": f"노무비의 {detail.percent_of_labor}%",
"unit": "%",
"quantity": str(detail.percent_of_labor),
# 단가 칸 = 밑수(노무비 합) — 실무 표가 「밑수 × %」를 그 칸에 적음.
"unit_expense": _money_text(labor_so_far),
"unit_total": _money_text(labor_so_far),
"material": "0",
"labor": "0",
"expense": _money_text(amount),
@@ -233,6 +236,9 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
"spec": f"{'주연료비' if misc else '주재료비'}{detail.percent_of_material}%",
"unit": "%",
"quantity": str(detail.percent_of_material),
# 단가 칸 = 밑수(주연료비·주재료비) — 실무 중기사용료 「잡품 21 % × 6,365」 꼴.
"unit_material": _money_text(material_so_far),
"unit_total": _money_text(material_so_far),
"material": _money_text(amount),
"labor": "0",
"expense": "0",
+49 -1
View File
@@ -39,7 +39,18 @@ PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서"
def _workbooks() -> tuple[tuple[str, dict[str, list[tuple]]], ...]:
"""실무 XLSX 마다 쓰는 시트만 값으로 읽어 둠(한 번). `(상대 경로, {시트: 줄들})`."""
openpyxl = pytest.importorskip("openpyxl")
wanted = ("환율및기초자료", "중기사용료", "단가산출근거", "일위대가표", "설계내역서")
wanted = (
"환율및기초자료",
"중기사용료",
"단가산출근거",
"일위대가표",
"설계내역서",
"재료비수량금액집계표",
"노무비수량금액집계표",
"경비수량금액집계표",
"중기시간금액집계표",
"중기목록표",
)
found = []
for path in sorted(PRACTICE.rglob("*.xlsx")):
if path.name.startswith("~$"):
@@ -391,3 +402,40 @@ def test_내역_줄_금액은_성분마다_절사한_합() -> None:
misses.append((name, row[1], want, got))
assert len(rows) >= 300, len(rows)
assert not misses, (len(misses), misses[:5])
def test_집계표_금액은_반올림_중기는_성분마다_반올림한_합() -> None:
"""집계표 넷 — 재료·노무·경비는 `반올림(수량 × 단가)`, 중기는 **성분마다 반올림한 뒤 합**(단가 열 없음).
실측 자원 279/279 · 중기 131/144(합계 번에 반올림 107/144). 중기 짝은 목록표 명칭·규격으로 찾음
(같은 명칭·규격이 갈래로 둘인 기종은 짝이 흔들림 규칙 아님).
"""
from B09_Estimation.B09_Estimation_Lists import machine_summary_amounts
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
resource, machine, machine_hits = 0, 0, 0
for _name, sheets in _workbooks():
for sheet in ("재료비수량금액집계표", "노무비수량금액집계표", "경비수량금액집계표"):
for row in sheets.get(sheet, []):
quantity, price, amount = _num(row[3]), _num(row[5]), _num(row[6])
if quantity is None or price is None or amount is None:
continue
resource += 1
assert round_at(quantity * price, OutputPlace.RESOURCE_SUMMARY) == amount, row
units = {
(row[1], row[2]): Money3(_num(row[6]), _num(row[5]), _num(row[7]))
for row in sheets.get("중기목록표", [])
if _num(row[4]) is not None
}
for row in sheets.get("중기시간금액집계표", []):
quantity = _num(row[3])
if quantity is None or (row[1], row[2]) not in units:
continue
machine += 1
parts = machine_summary_amounts(units[(row[1], row[2])], quantity)
got = (sum(parts.values()), parts["labor"], parts["material"], parts["expense"])
machine_hits += got == tuple(_num(v) or Decimal(0) for v in row[5:9])
if not resource:
pytest.skip("실무 원본 XLSX 가 없음")
assert resource >= 250 and machine >= 100, (resource, machine)
assert machine_hits >= machine * 0.9, (machine_hits, machine)
+16
View File
@@ -57,4 +57,20 @@ export const ui_locales_b3 = {
"구조물도 일위대가는 수량산출 › 구조물도 탭의 일위대가 표가 본표입니다.",
"Structure unit prices are shown on the structure drawing tab.",
],
B09_Sheet_MachineList: ["중기 목록표", "Machine list"],
B09_Sheet_List_Material: ["재료비 목록표", "Material list"],
B09_Sheet_List_Labor: ["노무비 목록표", "Labor list"],
B09_Sheet_List_Expense: ["경비 목록표", "Expense list"],
B09_Sheet_List_Lumpsum: ["일식견적 목록표", "Lump-sum list"],
B09_Sheet_Sum_Material: ["재료비 수량금액집계표", "Material summary"],
B09_Sheet_Sum_Labor: ["노무비 수량금액집계표", "Labor summary"],
B09_Sheet_Sum_Expense: ["경비 수량금액집계표", "Expense summary"],
B09_Sheet_Sum_Machine: ["중기 시간금액집계표", "Machine hours summary"],
B09_Sheet_SumMissing: ["집계에 못 넣은 코드:", "Codes left out of the summary:"],
B09_Sheet_EmptyGroup: ["이 표에 실릴 줄이 없습니다.", "No rows for this table."],
B09_Sheet_Tab_Summary: ["집계표", "Summaries"],
B09_Sheet_Tab_Lists: ["목록표", "Lists"],
B09_Sheet_Tab_PriceCompare: ["자재단가대비표", "Material price comparison"],
B09_Sheet_Col_Page: ["페이지", "Page"],
B09_Sheet_Min: ["최소", "min"],
} as const;