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

This commit is contained in:
2026-09-14 01:33:45 +09:00
19 changed files with 1397 additions and 24 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
[ROUTES.B08_QUANTITY]: async () =>
(await import("../B08_Quantity/B08_Quantity_UI_Page")).renderB08Quantity,
[ROUTES.B09_ESTIMATION]: async () =>
(await import("../B09_Estimation/B09_Estimation_UI_Page")).renderB09Estimation,
(await import("../B09_Estimation/B09_Estimation_UI_Shell")).renderB09Estimation,
[ROUTES.B10_PAYMENT]: async () =>
(await import("../B10_Payment/B10_Payment_UI_Page")).renderB10Payment,
[ROUTES.B11_STATUS]: async () =>
@@ -165,6 +165,14 @@ class BillRow:
in_bill: bool = True
#: 금액을 낸 단가 코드(`B-…#갈래`) — 단산 번호를 그 코드로 찾음. 안 선 줄은 빈 글.
price_code: str = ""
#: 성분 단가 — 실무 내역서 「노무비·재료비·경비 단가」 칸(화면이 곱하지 않게 서버가 실음).
unit_material_krw: Decimal | None = None
unit_labor_krw: Decimal | None = None
unit_expense_krw: Decimal | None = None
#: 수동 단가로 선 몫 — 화면 빨간 테두리(구조물도 호표 줄).
unconfirmed: int = 0
#: 「단산 N 참조」 — 비고 첫 조각과 같은 글. 번호는 낼 때마다 매김(저장 안 함).
price_basis_label: str = ""
#: 줄 사유 **조각** — `(닿는 열 키, 글)`. 화면 「비고」는 이것을 이어 붙인 것이고,
#: 근거 호버는 열 키로 걸러 **그 사유가 닿는 칸에만** 띄운다(PLAN 8-36 ㉮).
#: ⚠ 종전엔 `note` 한 칸에 덮어썼다 — 한 줄에 사유가 둘이면 **하나가 조용히 사라졌다**
@@ -210,6 +218,12 @@ class BillRow:
"material_krw": str(self.material_krw),
"labor_krw": str(self.labor_krw),
"expense_krw": str(self.expense_krw),
"unit_material_krw": money(self.unit_material_krw),
"unit_labor_krw": money(self.unit_labor_krw),
"unit_expense_krw": money(self.unit_expense_krw),
"price_code": self.price_code,
"unconfirmed": self.unconfirmed,
"price_basis_label": self.price_basis_label,
"is_group": self.is_group,
"in_bill": self.in_bill,
"note": self.note,
@@ -235,6 +249,8 @@ class BillResult:
notes: list[str] = field(default_factory=list)
#: ③ 단가산출서 한 벌 — 조판할 때 번호가 매겨진다.
price_basis: Any = None
#: 일위대가 호표 번호 한 벌 — 내역에 처음 쓰인 차례(저장 안 함).
unit_price_sheet: Any = None
#: 자재대 표 — 사급·관급·미정 셋으로 갈린다(PLAN 8-7 「금액은 B09」).
material_sheet: Any = None
#: 수동 단가로 선 자리 — 내역서 끝 「미확정 N건」(PLAN 확정 ⑦). 줄마다 `{name, count}`.
@@ -600,7 +616,14 @@ def build_bill(
if label:
# 종전처럼 **맨 앞**에 놓는다 — 실무 참조번호(「단산 46」)가 먼저 읽혀야 한다.
row.notes.insert(0, ("unit_price_krw", label))
row.price_basis_label = label
result.price_basis = sheet
from B09_Estimation.B09_Estimation_UnitPriceSheet import build_unit_price_sheet
result.unit_price_sheet = build_unit_price_sheet(
result.rows, unit_prices.book, structure_prices
)
_sum_groups(result.rows)
if any(m.surcharge_pct is None for m in materials):
result.notes.append(
@@ -610,6 +633,24 @@ 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,13 @@ def bill_line(unit: Money3, quantity) -> Money3:
)
def _set_unit(row: BillRow, unit: Money3) -> None:
"""성분 단가 칸 — 금액을 낸 바로 그 3분할(내역 서식 「노무비·재료비·경비 단가」)."""
row.unit_material_krw = unit.material
row.unit_labor_krw = unit.labor
row.unit_expense_krw = unit.expense
def _composite_row(
item_no: str,
item: HandoffWorkItem,
@@ -104,6 +111,7 @@ def _composite_row(
money = money.floored(Decimal(1))
line = bill_line(money, item.quantity)
_set_unit(row, money)
row.unit_price_krw = round_at(money.total, OutputPlace.UNIT_PRICE_ROW)
row.amount_krw = line.total
row.material_krw = line.material
@@ -160,6 +168,9 @@ def _structure_price_row(
# 단가 = 호표 계금(구조물도 화면과 같은 값) · 금액 = 호표 성분 소계 × 수량(명세 7장).
line = bill_line(entry["money"], item.quantity)
_set_unit(row, entry["money"])
row.price_code = ref
row.unconfirmed = int(entry["unconfirmed"] or 0)
row.unit_price_krw = entry["total"]
row.amount_krw = line.total
row.material_krw = line.material
@@ -462,6 +473,7 @@ def _leaf_row(
unit_money = unit_prices.book.resolve(price_code)
line = bill_line(unit_money, item.quantity)
_set_unit(row, unit_money)
row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW)
# 내역서 **본체** 행은 성분마다 절사 — 집계표(반올림)와 어긋나는 것이 정상.
row.amount_krw = line.total
+10 -2
View File
@@ -24,7 +24,7 @@ from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceKind
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBook, PriceKind
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
@@ -43,6 +43,8 @@ class PriceBasisEntry:
ref_code: str
#: 이 산출서를 부르는 내역 일위대가 전부(갈래·단계 합산 부모 포함).
unit_price_codes: list[str] = field(default_factory=list)
#: 성분 금액 — 목록표 「노무비·재료비·경비」 칸(머리 성분 원 미만 절사 값).
money: Money3 = field(default_factory=Money3)
@property
def label(self) -> str:
@@ -59,6 +61,10 @@ class PriceBasisEntry:
"unit": self.unit,
"unit_price_krw": str(self.unit_price_krw),
"ref_code": self.ref_code,
"unit_price_codes": list(self.unit_price_codes),
"material_krw": str(self.money.material),
"labor_krw": str(self.money.labor),
"expense_krw": str(self.money.expense),
}
@@ -116,14 +122,16 @@ def build_price_basis(
entry = numbered.get(basis)
if entry is None:
title = book.title(basis)
money = book.resolve(basis)
entry = PriceBasisEntry(
number=len(sheet.entries) + 1,
code=basis,
name=title.name,
spec=title.spec,
unit=title.unit,
unit_price_krw=round_at(book.resolve(basis).total, OutputPlace.UNIT_PRICE_ROW),
unit_price_krw=round_at(money.total, OutputPlace.UNIT_PRICE_ROW),
ref_code=code,
money=money,
)
sheet.entries.append(entry)
numbered[basis] = entry
+5
View File
@@ -807,6 +807,11 @@ async def get_bill(project_id: UUID) -> JSONResponse:
"price_basis": (
result.price_basis.as_dict() if result.price_basis else {"entries": []}
),
"unit_price_sheet": (
result.unit_price_sheet.as_dict()
if result.unit_price_sheet
else {"entries": []}
),
}
)
)
+142
View File
@@ -0,0 +1,142 @@
/* =============================================================================
* B09_Estimation_UI_Detail.ts
* (·· ) + ** ** (PLAN 12 1)
*
* - STmate = ( ). N
* (··) , .
* - `일위대가표`·`단가산출근거` 시트: 명칭··· · ///(·) · .
* - ( ) .
* ========================================================================== */
import type { B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import {
L,
el,
hint,
linkButton,
moneyCells,
numberCell,
quantity,
sheetHead,
sheetTable,
} from "./B09_Estimation_UI_Sheet";
import { loadDetail, type DetailDto, type DetailRowDto } from "./B09_Estimation_UI_Store";
interface Crumb {
tab: string;
code: string;
label: string;
}
const crumbs: Crumb[] = [];
let drilling = false;
/** 본표 안 줄을 눌러 들어갈 때 — 자취를 이어 쌓음. 목록·내역에서 들어가면 자취가 새로 시작. */
export function drill(ctx: B09TabContext, tab: string, code: string): void {
drilling = true;
ctx.open(tab, code);
}
function visit(tab: string, code: string, label: string): void {
if (!drilling) crumbs.length = 0;
drilling = false;
const at = crumbs.findIndex((crumb) => crumb.tab === tab && crumb.code === code);
if (at >= 0) crumbs.length = at;
crumbs.push({ tab, code, label });
}
function trail(ctx: B09TabContext): HTMLElement {
const box = el("div", "b09s-trail");
crumbs.forEach((crumb, index) => {
if (index > 0) box.append(el("span", "b09s-hint", ""));
if (index === crumbs.length - 1) {
box.append(el("span", "b09s-title", crumb.label));
return;
}
box.append(linkButton(crumb.label, () => drill(ctx, crumb.tab, crumb.code)));
});
return box;
}
/** 줄이 가리키는 곳 — 단산(D)은 단가산출근거 탭, 일위대가·중기는 일위대가 탭. */
function targetTab(row: DetailRowDto): string | null {
if (!row.drillable || !row.ref_code) return null;
return row.kind === "price_basis" ? "price_basis" : "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 ?? ""];
tr.append(
el("td", "", row.name),
el("td", "", row.spec),
numberCell(quantity(row.quantity)),
el("td", "", row.unit),
...moneyCells(unit, [row.total, row.labor, row.material, row.expense]),
);
const note = el("td", "b09s-note");
const source = row.source_label || row.source || "";
if (source) note.append(el("span", "b09s-hint", `[${source}] `));
if (row.note) note.append(el("span", "b09s-formula", row.note));
tr.append(note);
const tab = targetTab(row);
if (tab && row.ref_code) {
const code = row.ref_code;
tr.classList.add("is-clickable");
tr.title = L("B09_Sheet_Drill");
tr.addEventListener("click", () => drill(ctx, tab, code));
}
return tr;
}
function drawDetail(ctx: B09TabContext, box: HTMLElement, detail: DetailDto, label: string): void {
const title = el(
"div",
"b09s-title",
`${label} ${detail.name}${detail.spec ? ` · ${detail.spec}` : ""}`,
);
if (detail.unit) title.append(el("span", "b09s-hint", ` (${detail.unit})`));
const { wrap, tbody } = sheetTable(
sheetHead([
L("B09_Sheet_Col_Name"),
L("B09_Sheet_Col_Spec"),
L("B09_Sheet_Col_Quantity"),
L("B09_Sheet_Col_Unit"),
]),
);
for (const row of detail.rows) tbody.append(detailRow(row, ctx));
const sum = el("tr", "is-sum");
sum.append(el("td", "", L("B09_Sheet_Sum")), el("td"), el("td"), el("td"));
sum.append(
...moneyCells(null, [detail.total, detail.labor, detail.material, detail.expense]),
el("td"),
);
tbody.append(sum);
box.append(title, wrap);
if (detail.unattached_note) box.append(hint(detail.unattached_note.replace(/\*\*/g, ""), true));
if (detail.known_gap_note) box.append(hint(detail.known_gap_note, true));
}
/** 본표 한 장을 `box` 에 — 자취를 쌓고 서버 본표를 받아 그림. */
export function renderDetail(
ctx: B09TabContext,
box: HTMLElement,
tab: string,
code: string,
label: string,
): void {
if (!ctx.projectId) return;
visit(tab, code, label);
box.replaceChildren(trail(ctx), hint(L("B09_Sheet_Loading")));
loadDetail(ctx.projectId, code)
.then((detail) => {
box.replaceChildren(trail(ctx));
drawDetail(ctx, box, detail, label);
})
.catch((error: Error) => {
box.replaceChildren(trail(ctx), hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
});
}
+26 -14
View File
@@ -15,7 +15,6 @@
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import {
attachProvenance,
@@ -47,7 +46,6 @@ import {
type PriceSourcesDto,
} from "./B09_Estimation_UI_BaseData";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
function L(key: keyof typeof ui_locales): string {
@@ -900,7 +898,15 @@ async function confirmEstimationStage(projectId: string): Promise<void> {
*
* -------------------------------------------------------------------------- */
export async function renderB09Estimation(root: HTMLElement): Promise<void> {
/** (`B09_Estimation_UI_Shell`) `select` .
* (PLAN 12 · ). */
export interface LegacyEstimation {
main: HTMLElement;
panel: HTMLElement;
select: (key: string) => void;
}
export function createLegacyEstimation(root: HTMLElement): LegacyEstimation {
injectStyles();
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const form: CostFormState = { ...INITIAL_FORM };
@@ -1546,16 +1552,22 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
drawTabs();
drawBody();
const layout = createWorkflowLayout({
title: L("B09_Estimation_Title"),
steps: workflowSteps(),
activeStep: 6,
leftPanel: panel.root,
mainContent: main,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
return {
main,
panel: panel.root,
select: (key: string) => {
activeTab = key;
drawTabs();
drawBody();
// 관급·사급 표는 내역 응답에 실려 옴 — 옛 내역 탭을 안 거치므로 여기서 받음.
if (key === "supply" && !bill && projectId) {
void fetchBill(projectId)
.then((data) => {
bill = data;
drawBody();
})
.catch(() => showToast(L("B09_Estimation_Boq_Failed"), "error"));
}
},
});
root.append(layout.root);
};
}
+175
View File
@@ -0,0 +1,175 @@
/* =============================================================================
* B09_Estimation_UI_Sheet.ts
* B09 ·· (PLAN 12)
*
* - STmate 그대로: 명칭··· · /// (·) · .
* - . ( ) .
* - (`min-width:0` · `overflow-x:auto`) 2026-09-08 .
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
export function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 금액 글 → 「1,234,567」 · 소수부가 있으면 한 자리(일위대가 금액란 0.1원). 숫자가 아니면 그대로. */
export function won(value: string | null | undefined): string {
if (value === null || value === undefined || value === "") return "";
const number = Number(value);
if (!Number.isFinite(number)) return String(value);
const fraction = Math.abs(number % 1) > 1e-9;
return number.toLocaleString("ko-KR", {
minimumFractionDigits: fraction ? 1 : 0,
maximumFractionDigits: fraction ? 1 : 0,
});
}
/** (`digits`). ,
* (`digits` ) (0.0115 hr) . */
export function quantity(value: string | null | undefined, digits?: number | null): string {
if (value === null || value === undefined || value === "") return "";
const number = Number(value);
if (!Number.isFinite(number)) return String(value);
if (digits === undefined) {
return number.toLocaleString("ko-KR", { maximumFractionDigits: 4 });
}
if (digits === null) {
return number.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
}
return number.toLocaleString("ko-KR", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
export 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;
}
/** 실무 시트 머리 두 줄 — 앞 칸(명칭 따위)은 두 줄을 차지, 성분 넷은 「단가·금액」 두 칸. */
export function sheetHead(
front: string[],
back: string[] = [L("B09_Sheet_Col_Note")],
): HTMLElement {
const thead = el("thead");
const top = el("tr");
const bottom = el("tr");
for (const label of front) {
const th = el("th", "", label);
th.rowSpan = 2;
top.append(th);
}
for (const key of [
"B09_Sheet_Col_Total",
"B09_Sheet_Col_Labor",
"B09_Sheet_Col_Material",
"B09_Sheet_Col_Expense",
] as const) {
const th = el("th", "", L(key));
th.colSpan = 2;
top.append(th);
bottom.append(
el("th", "", L("B09_Sheet_Col_UnitPrice")),
el("th", "", L("B09_Sheet_Col_Amount")),
);
}
for (const label of back) {
const th = el("th", "", label);
th.rowSpan = 2;
top.append(th);
}
thead.append(top, bottom);
return thead;
}
/** 숫자 칸 하나. */
export function numberCell(text: string): HTMLTableCellElement {
return el("td", "b09s-num", text);
}
/** 성분 넷 × (단가·금액) 여덟 칸 — 합계·노무·재료·경비 차례(실무 시트). */
export function moneyCells(
unit: [string, string, string, string] | null,
amount: [string, string, string, string] | null,
): HTMLTableCellElement[] {
const cells: HTMLTableCellElement[] = [];
for (let i = 0; i < 4; i += 1) {
cells.push(numberCell(unit ? won(unit[i]) : ""), numberCell(amount ? won(amount[i]) : ""));
}
return cells;
}
/** 표 한 장 — 가로로 넘치면 이 칸 안에서만 밀림. */
export function sheetTable(head: HTMLElement): { wrap: HTMLElement; tbody: HTMLElement } {
const wrap = el("div", "b09s-wrap");
const table = el("table", "b09s-table");
const tbody = el("tbody");
table.append(head, tbody);
wrap.append(table);
return { wrap, tbody };
}
/** 누르면 들어가는 글 — 「제 3 호표」·「단산 2」. */
export function linkButton(text: string, onClick: () => void): HTMLButtonElement {
const button = el("button", "b09s-link", text);
button.type = "button";
button.addEventListener("click", (event) => {
event.stopPropagation();
onClick();
});
return button;
}
/** 수동 단가 표시 — 어느 화면에서든 같게(빨간 테두리 + 「미확정 N건」). */
export function unconfirmedBadge(count: number): HTMLElement {
return el("span", "b09s-badge", `${L("B09_Sheet_Unconfirmed")} ${count}${L("B09_Sheet_Count")}`);
}
export function hint(text: string, warn = false): HTMLElement {
return el("div", warn ? "b09s-hint b09s-hint--warn" : "b09s-hint", text);
}
export function injectSheetStyles(): void {
if (document.getElementById("b09-sheet-styles")) return;
const style = document.createElement("style");
style.id = "b09-sheet-styles";
style.textContent = `
.b09s-page { display:flex; flex-direction:column; gap:8px; min-width:0; min-height:0; flex:1; }
.b09s-tabs { display:flex; flex-wrap:wrap; gap:4px; border-bottom:1px solid var(--ui-border, #d0d4dc); padding-bottom:4px; }
.b09s-tab { border:1px solid var(--ui-border, #d0d4dc); background:var(--ui-surface, #fff); padding:4px 10px; border-radius:6px 6px 0 0; cursor:pointer; font-size:13px; }
.b09s-tab.is-active { background:var(--ui-accent, #2f6fed); color:#fff; border-color:var(--ui-accent, #2f6fed); }
.b09s-body { display:flex; flex-direction:column; gap:8px; min-width:0; min-height:0; flex:1; overflow:auto; }
.b09s-bar { display:flex; flex-wrap:wrap; align-items:center; gap:8px; font-size:13px; }
.b09s-wrap { overflow-x:auto; max-width:100%; }
.b09s-table { border-collapse:collapse; font-size:12px; white-space:nowrap; }
.b09s-table th, .b09s-table td { border:1px solid var(--ui-border, #d0d4dc); padding:2px 6px; }
.b09s-table th { background:var(--ui-surface-muted, #f1f3f7); font-weight:600; text-align:center; }
.b09s-num { text-align:right; font-variant-numeric:tabular-nums; }
.b09s-table tr.is-group td { font-weight:600; background:var(--ui-surface-muted, #f7f8fb); }
.b09s-table tr.is-sum td { font-weight:700; background:var(--ui-surface-muted, #eef1f6); }
.b09s-table tr.is-clickable { cursor:pointer; }
.b09s-table tr.is-clickable:hover td { background:rgba(47,111,237,0.08); }
.b09s-table tr.is-selected td { background:rgba(47,111,237,0.16); }
.b09s-table tr.is-manual td { box-shadow:inset 0 0 0 1px #d93025; }
.b09s-table td.b09s-note { white-space:normal; min-width:160px; max-width:420px; }
.b09s-toggle { border:none; background:none; cursor:pointer; padding:0 4px 0 0; font-size:11px; }
.b09s-link { border:none; background:none; color:var(--ui-accent, #2f6fed); cursor:pointer; padding:0 4px 0 0; text-decoration:underline; font-size:12px; }
.b09s-badge { display:inline-block; border:1px solid #d93025; color:#d93025; border-radius:10px; padding:0 6px; font-size:11px; margin-left:4px; }
.b09s-hint { font-size:12px; color:var(--ui-text-muted, #5f6673); }
.b09s-hint--warn { color:#b3261e; }
.b09s-trail { display:flex; flex-wrap:wrap; gap:4px; align-items:center; font-size:13px; }
.b09s-split { display:flex; flex-direction:column; gap:12px; min-width:0; }
.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; }
`;
document.head.append(style);
}
+83
View File
@@ -0,0 +1,83 @@
/* =============================================================================
* B09_Estimation_UI_Shell.ts
* 09: 6차 () **** (PLAN 12 · 2026-09-14 )
*
* - . (`B09_Estimation_UI_Tab_<이름>.ts`) `_Shell_Types`.
* - = _서브. .
* - · `render(ctx, arg)`. `ctx.open(key, arg)` = .
* ========================================================================== */
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import { L, el, injectSheetStyles } from "./B09_Estimation_UI_Sheet";
import { legacyTab } from "./B09_Estimation_UI_Tab_Legacy";
import { billTab } from "./B09_Estimation_UI_Tab_Bill";
import { unitPriceTab } from "./B09_Estimation_UI_Tab_UnitPrice";
import { priceBasisTab } from "./B09_Estimation_UI_Tab_PriceBasis";
/** 탭 등록 — 한 줄에 탭 하나. 옛 탭(`legacyTab`)은 새 탭 파일이 서면 그 줄만 바꿈. */
const TABS: B09Tab[] = [
legacyTab("cost_sheet", "B09_Estimation_Tab_CostSheet"), // → 랩탑_메인 원가계산서 탭
billTab,
unitPriceTab,
priceBasisTab,
legacyTab("machine", "B09_Estimation_Tab_Machine"),
legacyTab("supply", "B09_Estimation_Tab_Supply"),
legacyTab("base_data", "B09_Estimation_Tab_BaseData"),
legacyTab("design_doc", "B09_Estimation_Tab_DesignDoc"),
legacyTab("basis_sheet", "B09_Estimation_Tab_BasisSheet"),
];
export async function renderB09Estimation(root: HTMLElement): Promise<void> {
injectSheetStyles();
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const page = el("div", "b09s-page");
const bar = el("div", "b09s-tabs");
const body = el("div", "b09s-body");
const panel = el("div");
page.append(bar, body);
let active = TABS[0].key;
const open = (key: string, arg?: string): void => {
const tab = TABS.find((item) => item.key === key);
if (!tab) return;
active = key;
drawBar();
body.replaceChildren();
panel.replaceChildren();
const ctx: B09TabContext = { projectId, body, panel, root, open };
tab.render(ctx, arg);
};
const drawBar = (): void => {
bar.replaceChildren(
...TABS.map((tab) => {
const button = el(
"button",
`b09s-tab${tab.key === active ? " is-active" : ""}`,
tab.label(),
);
button.type = "button";
button.dataset.tab = tab.key;
button.addEventListener("click", () => open(tab.key));
return button;
}),
);
};
const layout = createWorkflowLayout({
title: L("B09_Estimation_Title"),
steps: workflowSteps(),
activeStep: 6,
leftPanel: panel,
mainContent: page,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
});
root.append(layout.root);
open(active);
}
+149
View File
@@ -0,0 +1,149 @@
/* =============================================================================
* B09_Estimation_UI_Store.ts
* B09 ·· ** ** (PLAN 12)
*
* - (`unit_price_sheet`)· (`price_basis`)
* ( ). ( ).
* - () [ ] . ( 1).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
export interface BillNoteDto {
column: string;
text: string;
}
export interface BillRowDto {
item_no: string;
level: number;
code: string | null;
name: string;
spec: string;
unit: string;
quantity: string | null;
quantity_shown: string | null;
quantity_digits: number | null;
unit_price_krw: string | null;
amount_krw: string | null;
material_krw: string;
labor_krw: string;
expense_krw: string;
unit_material_krw: string | null;
unit_labor_krw: string | null;
unit_expense_krw: string | null;
price_code: string;
unconfirmed: number;
price_basis_label: string;
is_group: boolean;
in_bill: boolean;
note: string;
notes: BillNoteDto[];
}
export interface SheetEntryDto {
number: number;
label: string;
code: string;
name: string;
spec: string;
unit: string;
material_krw: string;
labor_krw: string;
expense_krw: string;
total_krw?: string;
unit_price_krw?: string;
source?: string;
unconfirmed?: number;
ref_code?: string;
unit_price_codes?: string[];
}
export interface MissingDto {
name: string;
unit?: string;
quantity?: string;
reason: string;
code?: string;
blocked_kind?: string;
}
export interface BillDto {
rows: BillRowDto[];
excluded: BillRowDto[];
summary: {
body_total_krw: string;
detail_rows: number;
missing: MissingDto[];
unconfirmed_count: number;
notes: string[];
};
price_basis: { entries: SheetEntryDto[] };
unit_price_sheet: { entries: SheetEntryDto[] };
}
export interface DetailRowDto {
ref_code?: string;
code?: string;
name: string;
spec: string;
unit: string;
kind?: string;
source_label?: string;
source?: string;
drillable: boolean;
quantity: string;
unit_material?: string;
unit_labor?: string;
unit_expense?: string;
unit_total?: string;
material: string;
labor: string;
expense: string;
total: string;
note: string;
}
export interface DetailDto {
code: string;
name: string;
spec: string;
unit: string;
kind: string;
material: string;
labor: string;
expense: string;
total: string;
rows: DetailRowDto[];
unattached_note: string;
known_gap_note: string;
}
const bills = new Map<string, Promise<BillDto>>();
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(String((body as { message?: string }).message || response.status));
}
return body as T;
}
/** 설계내역서 한 벌 — 세 탭이 같은 응답을 봄. `force` 면 다시 받음. */
export function loadBill(projectId: string, force = false): Promise<BillDto> {
if (force || !bills.has(projectId)) {
const request = getJson<BillDto>(`/projects/${encodeURIComponent(projectId)}/estimation/bill`);
request.catch(() => bills.delete(projectId));
bills.set(projectId, request);
}
return bills.get(projectId) as Promise<BillDto>;
}
/** 호표 본표 — 일위대가(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";
return getJson<DetailDto>(
`/projects/${encodeURIComponent(projectId)}/estimation/${kind}/${encodeURIComponent(code)}`,
);
}
@@ -0,0 +1,233 @@
/* =============================================================================
* B09_Estimation_UI_Tab_Bill.ts
* B09 STmate `wBoq` (PLAN 12 1차: 표시 + )
*
* - 그대로: 공종 · · · · · ///(·) · .
* - / , (STmate `소계~총대계` ).
* - () ( ).
* - = + N건.
* - · ( ).
* ========================================================================== */
import { createButton, showToast } from "@ui/ui_template_elements";
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import {
L,
el,
hint,
linkButton,
moneyCells,
numberCell,
quantity,
sheetHead,
sheetTable,
unconfirmedBadge,
won,
} from "./B09_Estimation_UI_Sheet";
import { loadBill, type BillDto, type BillRowDto } from "./B09_Estimation_UI_Store";
/** 접힌 머리글 줄(공종 번호) — 탭을 오가도 남음. */
const collapsed = new Set<string>();
let levelLimit = 0; // 0 = 모두
function rowMoney(row: BillRowDto): {
unit: [string, string, string, string] | null;
amount: [string, string, string, string] | null;
} {
const amount: [string, string, string, string] | null =
row.amount_krw === null
? null
: [row.amount_krw, row.labor_krw, row.material_krw, row.expense_krw];
if (row.is_group || row.unit_price_krw === null) return { unit: null, amount };
return {
unit: [
row.unit_price_krw,
row.unit_labor_krw ?? "",
row.unit_material_krw ?? "",
row.unit_expense_krw ?? "",
],
amount,
};
}
function isHidden(row: BillRowDto, rows: BillRowDto[]): boolean {
if (levelLimit > 0 && row.level > levelLimit) return true;
for (const group of rows) {
if (
group.is_group &&
collapsed.has(group.item_no) &&
row.item_no.startsWith(`${group.item_no}-`)
) {
return true;
}
}
return false;
}
function noteCell(row: BillRowDto, bill: BillDto, ctx: B09TabContext): HTMLTableCellElement {
const cell = el("td", "b09s-note");
const sheet = bill.unit_price_sheet.entries.find((entry) => entry.code === row.price_code);
if (sheet) cell.append(linkButton(sheet.label, () => ctx.open("unit_price", sheet.code)));
const basis = bill.price_basis.entries.filter((entry) =>
(entry.unit_price_codes ?? []).includes(row.price_code),
);
for (const entry of basis) {
cell.append(
linkButton(`${L("B09_Sheet_Basis_Short")} ${entry.number}`, () =>
ctx.open("price_basis", entry.code),
),
);
}
if (row.unconfirmed > 0) cell.append(unconfirmedBadge(row.unconfirmed));
// 비고 글 — 단산 번호는 위 단추로 보였으니 그 조각만 뺌.
const rest = row.notes
.map((note) => note.text)
.filter((text) => text && text !== row.price_basis_label)
.join(" / ");
if (rest) cell.append(el("span", "", rest));
return cell;
}
function drawRows(tbody: HTMLElement, bill: BillDto, ctx: B09TabContext, redraw: () => void): void {
for (const row of bill.rows) {
if (isHidden(row, bill.rows)) continue;
const tr = el("tr", row.is_group ? "is-group" : "");
const numberTd = el("td");
if (row.is_group) {
const toggle = el("button", "b09s-toggle", collapsed.has(row.item_no) ? "▸" : "▾");
toggle.type = "button";
toggle.addEventListener("click", (event) => {
event.stopPropagation();
if (collapsed.has(row.item_no)) collapsed.delete(row.item_no);
else collapsed.add(row.item_no);
redraw();
});
numberTd.append(toggle);
}
numberTd.append(document.createTextNode(row.item_no));
const name = el("td", "", row.name);
name.style.paddingLeft = `${6 + Math.max(0, row.level - 1) * 12}px`;
const { unit, amount } = rowMoney(row);
tr.append(
numberTd,
name,
el("td", "", row.spec),
numberCell(
row.is_group ? "" : quantity(row.quantity_shown ?? row.quantity, row.quantity_digits),
),
el("td", "", row.unit),
...moneyCells(unit, amount),
);
if (row.is_group) {
tr.append(el("td"));
} else {
tr.append(noteCell(row, bill, ctx));
if (row.unconfirmed > 0) tr.classList.add("is-manual");
if (row.price_code) {
tr.classList.add("is-clickable");
tr.title = L("B09_Sheet_Open_UnitPrice");
tr.addEventListener("click", () => ctx.open("unit_price", row.price_code));
}
}
tbody.append(tr);
}
}
function levelPicker(bill: BillDto, redraw: () => void): HTMLElement {
const select = el("select", "ui-input");
const deepest = Math.max(1, ...bill.rows.map((row) => row.level));
const all = el("option", "", L("B09_Sheet_Level_All"));
all.value = "0";
select.append(all);
for (let level = 1; level < deepest; level += 1) {
const option = el("option", "", `${level}${L("B09_Sheet_Level_Upto")}`);
option.value = String(level);
select.append(option);
}
select.value = String(levelLimit);
select.addEventListener("change", () => {
levelLimit = Number(select.value);
redraw();
});
const label = el("label", "b09s-bar", L("B09_Sheet_Level"));
label.append(select);
return label;
}
function drawBill(ctx: B09TabContext, bill: BillDto, reload: () => void): void {
const redraw = (): void => {
ctx.body.replaceChildren();
drawBill(ctx, bill, reload);
};
const bar = el("div", "b09s-bar");
bar.append(
createButton({ label: L("B09_Sheet_Reload"), variant: "ghost", onClick: reload }),
levelPicker(bill, redraw),
el(
"span",
"",
`${L("B09_Sheet_BodyTotal")} ${won(bill.summary.body_total_krw)}${L("B09_Sheet_Won")}`,
),
);
if (bill.summary.unconfirmed_count > 0)
bar.append(unconfirmedBadge(bill.summary.unconfirmed_count));
ctx.body.append(bar);
const { wrap, tbody } = sheetTable(
sheetHead([
L("B09_Sheet_Col_ItemNo"),
L("B09_Sheet_Col_Name"),
L("B09_Sheet_Col_Spec"),
L("B09_Sheet_Col_Quantity"),
L("B09_Sheet_Col_Unit"),
]),
);
drawRows(tbody, bill, ctx, redraw);
const sum = el("tr", "is-sum");
sum.append(el("td"), el("td", "", L("B09_Sheet_BodyTotal")), el("td"), el("td"), el("td"));
sum.append(...moneyCells(null, [bill.summary.body_total_krw, "", "", ""]), el("td"));
tbody.append(sum);
ctx.body.append(wrap);
// 금액을 못 세운 줄 — 0 으로 안 때우고 이름째 보임(서버 `missing`).
if (bill.summary.missing.length > 0) {
const details = el("details");
details.append(
el(
"summary",
"b09s-hint b09s-hint--warn",
`${L("B09_Sheet_Missing")} ${bill.summary.missing.length}${L("B09_Sheet_Count")}`,
),
);
for (const item of bill.summary.missing) {
details.append(hint(`${item.name}${item.reason}`));
}
ctx.body.append(details);
}
for (const note of bill.summary.notes) ctx.body.append(hint(note));
}
export const billTab: B09Tab = {
key: "boq",
label: () => L("B09_Estimation_Tab_Boq"),
render(ctx) {
if (!ctx.projectId) {
ctx.body.append(hint(L("B09_Sheet_NoProject")));
return;
}
const projectId = ctx.projectId;
const show = (force: boolean): void => {
ctx.body.replaceChildren(hint(L("B09_Sheet_Loading")));
loadBill(projectId, force)
.then((bill) => {
ctx.body.replaceChildren();
drawBill(ctx, bill, () => show(true));
})
.catch((error: Error) => {
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
showToast(L("B09_Sheet_LoadFailed"), "error");
});
};
show(false);
},
};
@@ -0,0 +1,30 @@
/* =============================================================================
* B09_Estimation_UI_Tab_Legacy.ts
* B09 ** ** (PLAN 12 · 2026-09-14 )
*
* - ( _메인 ) · · · · ( = ) ·
* · , .
* - ( · ).
* ========================================================================== */
import type { ui_locales } from "@ui/ui_template_locale";
import type { B09Tab } from "./B09_Estimation_UI_Shell_Types";
import { L } from "./B09_Estimation_UI_Sheet";
import { createLegacyEstimation, type LegacyEstimation } from "./B09_Estimation_UI_Page";
let legacy: LegacyEstimation | null = null;
export function legacyTab(key: string, labelKey: keyof typeof ui_locales): B09Tab {
return {
key,
label: () => L(labelKey),
render(ctx) {
legacy ??= createLegacyEstimation(ctx.root);
legacy.main.classList.add("b09s-legacy");
ctx.body.append(legacy.main);
// 옛 좌측 칸은 원가계산서 입력 — 그 탭에서만 보임.
if (key === "cost_sheet") ctx.panel.append(legacy.panel);
legacy.select(key);
},
};
}
@@ -0,0 +1,76 @@
/* =============================================================================
* B09_Estimation_UI_Tab_PriceBasis.ts
* B09 + (STmate `단가산출근거목록표` · `wM_Edit_San` · PLAN 12 1)
*
* - = D , ** ** N호표( N ).
* - Q · ** ** ( `note`).
* - .
* - Q 2( ).
* ========================================================================== */
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import { renderDetail } from "./B09_Estimation_UI_Detail";
import { L, el, hint, linkButton, sheetTable } from "./B09_Estimation_UI_Sheet";
import { listHead, listRow } from "./B09_Estimation_UI_Tab_UnitPrice";
import { loadBill, type BillDto } from "./B09_Estimation_UI_Store";
let selected = "";
function draw(ctx: B09TabContext, bill: BillDto): void {
const entries = bill.price_basis.entries;
const page = el("div", "b09s-split");
const { wrap, tbody } = sheetTable(listHead(L("B09_Sheet_Col_Basis")));
const detail = el("div", "b09s-split");
for (const entry of entries) {
const row = {
...entry,
label: `${L("B09_Sheet_Basis_Long")} ${entry.number}${L("B09_Sheet_Basis_Suffix")}`,
};
const tr = listRow(row, entry.unit_price_krw ?? "", entry.code === selected);
const note = tr.lastElementChild as HTMLElement;
for (const code of entry.unit_price_codes ?? []) {
const sheet = bill.unit_price_sheet.entries.find((item) => item.code === code);
if (sheet) note.append(linkButton(sheet.label, () => ctx.open("unit_price", code)));
}
tr.addEventListener("click", () => {
selected = entry.code;
ctx.body.replaceChildren();
draw(ctx, bill);
});
tbody.append(tr);
}
page.append(el("div", "b09s-title", L("B09_Sheet_BasisList")), wrap);
if (entries.length === 0) page.append(hint(L("B09_Sheet_EmptyList")));
page.append(detail);
ctx.body.append(page);
if (!selected) {
detail.append(hint(L("B09_Sheet_PickSheet")));
return;
}
const picked = entries.find((entry) => entry.code === selected);
const label = picked
? `${L("B09_Sheet_Basis_Long")} ${picked.number}${L("B09_Sheet_Basis_Suffix")}`
: selected;
renderDetail(ctx, detail, "price_basis", selected, label);
}
export const priceBasisTab: B09Tab = {
key: "price_basis",
label: () => L("B09_Estimation_Tab_PriceBasis"),
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,126 @@
/* =============================================================================
* B09_Estimation_UI_Tab_UnitPrice.ts
* B09 + (STmate `일위대가목록표` · `wM_Edit_iLWi` · PLAN 12 1)
*
* - = , ** ** N ( ).
* - ( ··) .
* - B08 ·.
* - ( ) 2 B09 ( ).
* ========================================================================== */
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import { renderDetail } from "./B09_Estimation_UI_Detail";
import {
L,
el,
hint,
numberCell,
sheetTable,
unconfirmedBadge,
won,
} from "./B09_Estimation_UI_Sheet";
import { loadBill, type BillDto, type SheetEntryDto } from "./B09_Estimation_UI_Store";
let selected = "";
/** 목록표 머리 — 호표 · 명칭 · 규격 · 단위 · 합계 · 노무비 · 재료비 · 경비 · 비고(실무 목록표). */
export function listHead(first: string): HTMLElement {
const thead = el("thead");
const tr = el("tr");
for (const label of [
first,
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"),
]) {
tr.append(el("th", "", label));
}
thead.append(tr);
return thead;
}
export function listRow(entry: SheetEntryDto, total: string, isSelected: boolean): HTMLElement {
const tr = el("tr", `is-clickable${isSelected ? " is-selected" : ""}`);
tr.append(
el("td", "", entry.label),
el("td", "", entry.name),
el("td", "", entry.spec),
el("td", "", entry.unit),
numberCell(won(total)),
numberCell(won(entry.labor_krw)),
numberCell(won(entry.material_krw)),
numberCell(won(entry.expense_krw)),
);
const note = el("td", "b09s-note");
if (entry.unconfirmed) {
note.append(unconfirmedBadge(entry.unconfirmed));
tr.classList.add("is-manual");
}
tr.append(note);
return tr;
}
function labelFor(bill: BillDto, code: string): string {
const entry = bill.unit_price_sheet.entries.find((item) => item.code === code);
if (entry) return entry.label;
return code.startsWith("X-") ? `${L("B09_Sheet_Machine")} ${code.slice(2)}` : code;
}
function draw(ctx: B09TabContext, bill: BillDto): void {
const entries = bill.unit_price_sheet.entries;
const page = el("div", "b09s-split");
const { wrap, tbody } = sheetTable(listHead(L("B09_Sheet_Col_Sheet")));
const detail = el("div", "b09s-split");
const select = (code: string): void => {
selected = code;
ctx.body.replaceChildren();
draw(ctx, bill);
};
for (const entry of entries) {
const tr = listRow(entry, entry.total_krw ?? "", entry.code === selected);
tr.addEventListener("click", () => select(entry.code));
tbody.append(tr);
}
page.append(el("div", "b09s-title", L("B09_Sheet_UnitPriceList")), wrap);
if (entries.length === 0) page.append(hint(L("B09_Sheet_EmptyList")));
page.append(detail);
ctx.body.append(page);
if (!selected) {
detail.append(hint(L("B09_Sheet_PickSheet")));
return;
}
const picked = entries.find((entry) => entry.code === selected);
if (picked?.source === "structure") {
detail.append(el("div", "b09s-title", `${picked.label} ${picked.name} · ${picked.spec}`));
detail.append(hint(L("B09_Sheet_StructureSheet")));
return;
}
renderDetail(ctx, detail, "unit_price", selected, labelFor(bill, selected));
}
export const unitPriceTab: B09Tab = {
key: "unit_price",
label: () => L("B09_Estimation_Tab_UnitPrice"),
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,115 @@
"""B09 원가계산 — 일위대가표 **호표 번호** (PLAN 12장 · 실무 `일위대가목록표`).
실무 내역서는 일위대가를 N 호표 부르고, 목록표가 번호마다 명칭·규격·성분 금액을 보인다.
**번호는 저장하지 않음**(2026-09-14 브레인 판정) 내역이 바뀌면 번호도 바뀌므로 때마다 매김.
단산 번호(`B09_Estimation_PriceBasis`) 같은 규칙: **내역에 처음 쓰인 차례**.
내역 줄이 부르지 않고 **다른 일위대가 안에서만** 불리는 호표(찰쌓기 안의 모르타르 배합 따위)
부르는 호표 **바로 ** 번호가 붙음( 차례대로 깊이 우선).
구조물도 호표(`B-AX-ST-#키`)는 단가표 제목이 아니라 B08 일위대가 표로 섬 — 금액은 그 표 값,
안의 하위 호표 번호는 아직 매김(구조물도 탭이 표를 보임).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBook, PriceKind
STRUCTURE_SOURCE = "structure"
@dataclass
class UnitPriceSheetEntry:
number: int
code: str
name: str
spec: str
unit: str
money: Money3
#: 「book」 단가표 제목 · 「structure」 구조물도 일위대가 표.
source: str = "book"
unconfirmed: int = 0
@property
def label(self) -> str:
return f"{self.number} 호표"
def as_dict(self) -> dict[str, Any]:
return {
"number": self.number,
"label": self.label,
"code": self.code,
"name": self.name,
"spec": self.spec,
"unit": self.unit,
"material_krw": str(self.money.material),
"labor_krw": str(self.money.labor),
"expense_krw": str(self.money.expense),
"total_krw": str(self.money.total),
"source": self.source,
"unconfirmed": self.unconfirmed,
}
@dataclass
class UnitPriceSheet:
entries: list[UnitPriceSheetEntry] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
return {"entries": [entry.as_dict() for entry in self.entries]}
def _nested(book: PriceBook, code: str, seen: tuple[str, ...]) -> list[str]:
"""호표 안에서 부르는 일위대가 — 줄 차례대로 깊이 우선."""
found: list[str] = []
for detail in book.details.get(code, []):
ref = detail.ref_code
title = book.titles.get(ref)
if title is None or title.kind is not PriceKind.UNIT_PRICE or ref in seen:
continue
found.append(ref)
found.extend(_nested(book, ref, (*seen, ref)))
return found
def build_unit_price_sheet(
rows: list[Any],
book: PriceBook,
structure_prices: dict[str, dict[str, Any]] | None = None,
) -> UnitPriceSheet:
"""내역 줄(`BillRow`) 차례 → 호표 번호 한 벌. 같은 호표를 두 줄이 불러도 한 번만."""
sheet = UnitPriceSheet()
numbered: set[str] = set()
def add(code: str, **values: Any) -> None:
numbered.add(code)
sheet.entries.append(
UnitPriceSheetEntry(number=len(sheet.entries) + 1, code=code, **values)
)
for row in rows:
code = row.price_code
if not code or code in numbered:
continue
structure = (structure_prices or {}).get(code)
if structure is not None:
add(
code,
name=row.name,
spec=row.spec,
unit=row.unit,
money=structure["money"],
source=STRUCTURE_SOURCE,
unconfirmed=int(structure.get("unconfirmed") or 0),
)
continue
if code not in book.titles:
continue
for ref in (code, *_nested(book, code, (code,))):
if ref in numbered:
continue
title = book.title(ref)
add(ref, name=title.name, spec=title.spec, unit=title.unit, money=book.resolve(ref))
return sheet
@@ -14,7 +14,7 @@ from decimal import Decimal
from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price
from B09_Estimation.B09_Estimation_MaterialCatalog import catalog_summary, load_material_catalog
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
from B09_Estimation.B09_Estimation_PriceBook import TRUNCATED_KINDS, PriceKind
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_UnitPrice import (
DRILLABLE_KINDS,
@@ -202,7 +202,7 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
"quantity": str(detail.percent_of_labor),
"material": "0",
"labor": "0",
"expense": str(amount),
"expense": _money_text(amount),
"total": _money_text(amount),
"source": "품셈 [주]",
"drillable": False,
@@ -233,7 +233,7 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
"spec": f"{'주연료비' if misc else '주재료비'}{detail.percent_of_material}%",
"unit": "%",
"quantity": str(detail.percent_of_material),
"material": str(amount),
"material": _money_text(amount),
"labor": "0",
"expense": "0",
"total": _money_text(amount),
@@ -285,16 +285,22 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
# ㉤ 열 방향 검사 — 같은 성분을 두 층에서 세면 여기서 멈춘다.
# 행 방향(`TC=NC+GC+JC`)만으로는 안 잡히는 어긋남이다.
check_column_sums(rows=rows, totals=summed, label=f"{title.name} 본표")
# 합계 줄 — 호표 안에서 자르는 층(X·D·B)은 **성분 소계 원 미만 절사** 값(실무 표 합계 줄 · 명세 7장).
shown = (
{"material": money.material, "labor": money.labor, "expense": money.expense}
if title.kind in TRUNCATED_KINDS
else {key: summed[key] for key in ("material", "labor", "expense")}
)
return {
"code": code,
"name": title.name,
"spec": title.spec,
"unit": title.unit,
"kind": title.kind.value,
"material": str(summed["material"]),
"labor": str(summed["labor"]),
"expense": str(summed["expense"]),
"total": str(summed["total"]),
"material": str(shown["material"]),
"labor": str(shown["labor"]),
"expense": str(shown["expense"]),
"total": str(sum(shown.values(), Decimal(0))),
# TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다.
"sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"],
# 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다.
@@ -0,0 +1,98 @@
"""B09 내역서 화면 자료 — 화면이 곱하거나 더하지 않게 서버가 싣는 칸 (PLAN 12장 1차).
겨누는
내역 성분 단가 × 수량 = 성분 금액(성분마다 절사) 화면은 실린 값만 찍음
머리글 금액 = 아래 금액의 (성분마다) · 본체 합계엔 들어감
일위대가 호표 번호 내역에 처음 쓰인 차례 · 같은 호표는 · 안에서 부르는 호표는 뒤에
단산 목록 부르는 일위대가 전부 · 성분 금액
"""
from __future__ import annotations
from decimal import Decimal
from functools import lru_cache
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line, build_bill
from B09_Estimation.B09_Estimation_PriceBook import (
Money3,
PriceBook,
PriceDetail,
PriceKind,
PriceTitle,
)
from B09_Estimation.B09_Estimation_UnitPrice import _slots, build_unit_prices
from B09_Estimation.B09_Estimation_UnitPriceSheet import build_unit_price_sheet
@lru_cache(maxsize=1)
def _bill():
payload = {
"work_items": [
{"work_item_code": "FP-09-03-02", "name": "토사깍기", "unit": "", "quantity": 123.4},
{"work_item_code": "FP-09-12-01", "name": "측구터파기", "unit": "", "quantity": 7},
{"work_item_code": "FP-09-03-02", "name": "토사깍기", "unit": "", "quantity": 5},
],
"materials": [],
}
return build_bill(payload, build=build_unit_prices())
def test_줄_성분_단가와_금액이_실린다() -> None:
priced = [r for r in _bill().rows if not r.is_group and r.amount_krw is not None]
assert priced
for row in priced:
unit = Money3(row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw)
line = bill_line(unit, row.quantity)
assert (line.material, line.labor, line.expense) == (
row.material_krw,
row.labor_krw,
row.expense_krw,
)
assert row.amount_krw == line.total
body = row.as_dict()
assert body["price_code"] == row.price_code and body["unit_labor_krw"] is not None
def test_머리글_줄은_아래_줄_합이고_본체엔_안_든다() -> None:
bill = _bill()
detail_total = sum(r.amount_krw or 0 for r in bill.rows if not r.is_group)
assert bill.body_total_krw == detail_total
for group in (r for r in bill.rows if r.is_group):
children = [
r
for r in bill.rows
if not r.is_group and r.item_no.startswith(f"{group.item_no}-") and r.amount_krw
]
assert group.amount_krw == sum(r.amount_krw for r in children)
assert group.labor_krw == sum(r.labor_krw for r in children)
def test_호표_번호는_처음_쓰인_차례이고_안에서_부르는_호표는_뒤에() -> None:
book = PriceBook()
book.add_title(PriceTitle("L", PriceKind.LABOR, "보통인부", slots=_slots(Decimal(100000))))
for code in ("B-A", "B-B", "B-C"):
book.add_title(PriceTitle(code, PriceKind.UNIT_PRICE, code))
book.add_detail(PriceDetail("B-C", "L", Decimal(1)))
book.add_detail(PriceDetail("B-A", "B-C", Decimal(2))) # A 안에서 C 를 부름
book.add_detail(PriceDetail("B-B", "L", Decimal(1)))
class Row:
def __init__(self, code: str) -> None:
self.price_code, self.name, self.spec, self.unit = code, code, "", ""
sheet = build_unit_price_sheet([Row("B-B"), Row("B-A"), Row("B-B"), Row("")], book)
assert [(e.number, e.code) for e in sheet.entries] == [(1, "B-B"), (2, "B-A"), (3, "B-C")]
assert sheet.entries[1].as_dict()["label"] == "제 2 호표"
assert sheet.entries[1].money.labor == 200000
def test_내역에_호표_목록과_단산_목록이_함께_실린다() -> None:
bill = _bill()
codes = [e.code for e in bill.unit_price_sheet.entries]
used = list(dict.fromkeys(r.price_code for r in bill.rows if r.price_code))
assert [c for c in codes if c in used] == used # 안에서 부르는 호표가 끼어도 차례는 그대로
for entry in bill.price_basis.entries:
body = entry.as_dict()
assert body["unit_price_codes"] and Money3(
*(int(body[k]) for k in ("material_krw", "labor_krw", "expense_krw"))
).total == int(body["unit_price_krw"].split(".")[0])
+2
View File
@@ -23,6 +23,7 @@ import { ui_locales_common } from "./ui_template_locale_common";
import { ui_locales_a } from "./ui_template_locale_a";
import { ui_locales_b1 } from "./ui_template_locale_b1";
import { ui_locales_b2 } from "./ui_template_locale_b2";
import { ui_locales_b3 } from "./ui_template_locale_b3";
/** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */
export const LANGUAGES = ["ko", "en"] as const;
@@ -56,6 +57,7 @@ export const ui_locales = {
...ui_locales_a,
...ui_locales_b1,
...ui_locales_b2,
...ui_locales_b3,
} as const;
export type LocaleKey = keyof typeof ui_locales;
+60
View File
@@ -0,0 +1,60 @@
/* =============================================================================
* ui_template_locale_b3.ts
* B 3 B09 (··, PLAN 12)
*
* b2 700 . .
* ========================================================================== */
export const ui_locales_b3 = {
/* --- B09 실무 서식 표 --- */
B09_Sheet_Col_ItemNo: ["공종", "Item"],
B09_Sheet_Col_Name: ["명칭", "Name"],
B09_Sheet_Col_Spec: ["규격", "Spec"],
B09_Sheet_Col_Quantity: ["수량", "Qty"],
B09_Sheet_Col_Unit: ["단위", "Unit"],
B09_Sheet_Col_Total: ["합계", "Total"],
B09_Sheet_Col_Labor: ["노무비", "Labor"],
B09_Sheet_Col_Material: ["재료비", "Material"],
B09_Sheet_Col_Expense: ["경비", "Expense"],
B09_Sheet_Col_UnitPrice: ["단가", "Unit price"],
B09_Sheet_Col_Amount: ["금액", "Amount"],
B09_Sheet_Col_Note: ["비고", "Note"],
B09_Sheet_Col_Sheet: ["호표", "Sheet"],
B09_Sheet_Col_Basis: ["산근", "Basis"],
B09_Sheet_Sum: ["합계", "Total"],
B09_Sheet_BodyTotal: ["내역 본체 합계", "Bill body total"],
B09_Sheet_Won: ["원", " KRW"],
B09_Sheet_Count: ["건", ""],
B09_Sheet_Unconfirmed: ["미확정", "Unconfirmed"],
B09_Sheet_Missing: ["금액을 못 세운 줄", "Rows without a price"],
B09_Sheet_Reload: ["다시 불러오기", "Reload"],
B09_Sheet_Level: ["보이는 레벨", "Show levels"],
B09_Sheet_Level_All: ["모두", "All"],
B09_Sheet_Level_Upto: ["레벨까지", " level(s)"],
B09_Sheet_Loading: ["불러오는 중…", "Loading…"],
B09_Sheet_LoadFailed: ["불러오지 못했습니다.", "Could not load."],
B09_Sheet_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."],
B09_Sheet_Open_UnitPrice: [
"누르면 이 줄의 일위대가표로 들어갑니다",
"Click to open this row's unit price sheet",
],
B09_Sheet_Drill: ["누르면 이 줄의 호표로 들어갑니다", "Click to open this row's sheet"],
B09_Sheet_Basis_Short: ["단산", "Basis"],
B09_Sheet_Basis_Long: ["산근", "Basis"],
B09_Sheet_Basis_Suffix: ["호표", ""],
B09_Sheet_Machine: ["시간당 중기", "Machine hourly"],
B09_Sheet_UnitPriceList: ["일위대가 목록표", "Unit price list"],
B09_Sheet_BasisList: ["단가산출근거 목록표", "Price basis list"],
B09_Sheet_EmptyList: [
"내역에 쓰인 호표가 없습니다 — 설계내역서가 먼저 서야 합니다.",
"No sheets used by the bill yet.",
],
B09_Sheet_PickSheet: [
"목록표에서 호표를 누르면 본표가 아래에 섭니다.",
"Click a sheet above to see its table.",
],
B09_Sheet_StructureSheet: [
"구조물도 일위대가는 수량산출 › 구조물도 탭의 일위대가 표가 본표입니다.",
"Structure unit prices are shown on the structure drawing tab.",
],
} as const;