diff --git a/A00_Common/router.ts b/A00_Common/router.ts index 1adace4a..a3c56318 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -52,7 +52,7 @@ const routeTable: Partial Promise>> = { [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 () => diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index ff463c61..3681c01b 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -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 { diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 50394419..45c237b3 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_PriceBasis.py b/B09_Estimation/B09_Estimation_PriceBasis.py index e2441423..546ce507 100644 --- a/B09_Estimation/B09_Estimation_PriceBasis.py +++ b/B09_Estimation/B09_Estimation_PriceBasis.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 9d98763a..77b58cb8 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -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": []} + ), } ) ) diff --git a/B09_Estimation/B09_Estimation_UI_Detail.ts b/B09_Estimation/B09_Estimation_UI_Detail.ts new file mode 100644 index 00000000..d23c3b39 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Detail.ts @@ -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)); + }); +} diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 984a7b14..40a7f58a 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -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 { * 페이지 진입점 * -------------------------------------------------------------------------- */ -export async function renderB09Estimation(root: HTMLElement): Promise { +/** 옛 화면을 새 틀(`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 { 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); + }; } diff --git a/B09_Estimation/B09_Estimation_UI_Sheet.ts b/B09_Estimation/B09_Estimation_UI_Sheet.ts new file mode 100644 index 00000000..c76f1cc1 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Sheet.ts @@ -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( + 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); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts new file mode 100644 index 00000000..ffd8eca6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -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 { + 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); +} diff --git a/B09_Estimation/B09_Estimation_UI_Store.ts b/B09_Estimation/B09_Estimation_UI_Store.ts new file mode 100644 index 00000000..c3158e1c --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Store.ts @@ -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>(); + +async function getJson(path: string): Promise { + 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 { + if (force || !bills.has(projectId)) { + const request = getJson(`/projects/${encodeURIComponent(projectId)}/estimation/bill`); + request.catch(() => bills.delete(projectId)); + bills.set(projectId, request); + } + return bills.get(projectId) as Promise; +} + +/** 호표 본표 — 일위대가(B)·시간당 중기(X)는 `unit-prices`, 단가산출(D)은 `price-basis`. */ +export function loadDetail(projectId: string, code: string): Promise { + const kind = code.startsWith("D-") ? "price-basis" : "unit-prices"; + return getJson( + `/projects/${encodeURIComponent(projectId)}/estimation/${kind}/${encodeURIComponent(code)}`, + ); +} diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts new file mode 100644 index 00000000..6573f0e1 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts @@ -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(); +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); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Legacy.ts b/B09_Estimation/B09_Estimation_UI_Tab_Legacy.ts new file mode 100644 index 00000000..855d5554 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Legacy.ts @@ -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); + }, + }; +} diff --git a/B09_Estimation/B09_Estimation_UI_Tab_PriceBasis.ts b/B09_Estimation/B09_Estimation_UI_Tab_PriceBasis.ts new file mode 100644 index 00000000..dcb47e96 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_PriceBasis.ts @@ -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)); + }); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_UnitPrice.ts b/B09_Estimation/B09_Estimation_UI_Tab_UnitPrice.ts new file mode 100644 index 00000000..7eacc813 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_UnitPrice.ts @@ -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)); + }); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UnitPriceSheet.py b/B09_Estimation/B09_Estimation_UnitPriceSheet.py new file mode 100644 index 00000000..643fccfc --- /dev/null +++ b/B09_Estimation/B09_Estimation_UnitPriceSheet.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_UnitPrice_View.py b/B09_Estimation/B09_Estimation_UnitPrice_View.py index 3d88c18f..b9f3279a 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice_View.py +++ b/B09_Estimation/B09_Estimation_UnitPrice_View.py @@ -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"], # 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다. diff --git a/resources/tester/test_b09_bill_sheet_view.py b/resources/tester/test_b09_bill_sheet_view.py new file mode 100644 index 00000000..4dbfbbc6 --- /dev/null +++ b/resources/tester/test_b09_bill_sheet_view.py @@ -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]) diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 6d822373..df2909ad 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -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; diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts new file mode 100644 index 00000000..c9fcf9d8 --- /dev/null +++ b/ui_template/ui_template_locale_b3.ts @@ -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;