diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index df636e54..b34b4c90 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -26,7 +26,14 @@ from B09_Estimation.B09_Estimation_Engine_Cost import ( proposed_profit_adjustment, ) from B09_Estimation.B09_Estimation_Rates import RateLookupError +from B09_Estimation.B09_Estimation_PriceBook import PriceBookError from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS +from B09_Estimation.B09_Estimation_UnitPrice import ( + build_summary, + cached_build, + detail_of, + list_unit_prices, +) from common_util.common_util_workflow_state import complete_stage from config.config_db import get_db_pool @@ -154,6 +161,45 @@ async def list_items(project_id: UUID) -> JSONResponse: ) +@router.get("/{project_id}/estimation/unit-prices") +async def list_unit_price_titles(project_id: UUID) -> JSONResponse: + """일위대가 **목록표** — 「무엇이 있나」 한 줄씩 + 산출 요약. + + 요약을 같이 보내는 까닭은 사용자가 **「무엇이 안 선 상태인가」를 화면에서** + 알아야 하기 때문이다(자재 카탈로그 미확보로 구조물 계열이 안 섬). + """ + try: + build = cached_build() + return JSONResponse( + content={ + "status": "success", + "summary": build_summary(build), + "rows": list_unit_prices(build), + } + ) + except Exception: + logger.exception("B09 일위대가 목록 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "일위대가 목록을 못 만들었습니다."}, + ) + + +@router.get("/{project_id}/estimation/unit-prices/{code}") +async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse: + """일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다.""" + try: + return JSONResponse(content={"status": "success", **detail_of(cached_build(), code)}) + except PriceBookError as error: + return JSONResponse(status_code=404, content={"status": "error", "message": str(error)}) + except Exception: + logger.exception("B09 일위대가 본표 실패: project_id=%s, code=%s", project_id, code) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "일위대가 본표를 못 만들었습니다."}, + ) + + @router.post("/{project_id}/estimation/confirm") async def confirm_estimation(project_id: UUID) -> JSONResponse: """원가계산 단계 확정 — 워크플로 stage 6(ESTIMATION)을 COMPLETE 로 전이한다.""" diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 7e0a684c..fc8bcbb5 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -49,6 +49,47 @@ interface CostSheetDto { suggested_profit_adjustment_krw?: string; } +interface UnitPriceRow { + code: string; + name: string; + spec: string; + unit: string; + material: string; + labor: string; + expense: string; + total: string; +} + +interface UnitPriceListDto { + status: string; + summary: { titles: number; unit_prices: number; machine_hourly: number; notes: string[] }; + rows: UnitPriceRow[]; +} + +interface UnitPriceDetailRow extends UnitPriceRow { + ref_code: string; + source_label: string; + source_index: number; + drillable: boolean; + quantity: string; + unit_total: string; + note: string; +} + +interface UnitPriceDetailDto { + status: string; + code: string; + name: string; + spec: string; + unit: string; + material: string; + labor: string; + expense: string; + total: string; + sum_matches: boolean; + rows: UnitPriceDetailRow[]; +} + /** 좌측 입력 상태 — 화면이 들고 있는 값. 저장은 [확정] 때만. */ interface CostFormState { direct_material_krw: string; @@ -128,6 +169,10 @@ function injectStyles(): void { .b09-sheet tr.is-total td { font-weight: 600; background: var(--color-surface); } .b09-sheet tr.is-adopted td { background: var(--color-surface); } .b09-sheet tr.is-dropped td { color: var(--color-text-secondary); text-decoration: line-through; } +.b09-clickable { cursor: pointer; } +.b09-clickable:hover td { background: var(--color-surface); } +.b09-up-list { max-height: 45%; } +.b09-up-detail { border-top: 2px solid var(--color-border); padding-top: 6px; } .b09-empty { padding: var(--space-lg, 16px); color: var(--color-text-secondary); font-size: var(--font-size-sm, 13px); } `; document.head.append(style); @@ -199,6 +244,152 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement { return wrap; } +/** 일위대가 **목록표** — 「무엇이 있나」. 고르면 아래에 본표가 뜬다(9-3 제목+상세). */ +function buildUnitPriceList( + list: UnitPriceListDto, + selected: string | null, + onPick: (code: string) => void, +): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b09-sheet b09-up-list"; + + const caption = document.createElement("div"); + caption.className = "b09-hint"; + caption.textContent = `${L("B09_Estimation_UP_List")} · ${list.summary.unit_prices}`; + wrap.append(caption); + + const table = document.createElement("table"); + const head = document.createElement("tr"); + for (const [key, left] of [ + ["B09_Estimation_Col_Name", true], + ["B09_Estimation_Col_Unit", true], + ["B09_Estimation_Col_Material", false], + ["B09_Estimation_Col_Labor", false], + ["B09_Estimation_Col_Expense", false], + ["B09_Estimation_Col_Total", false], + ] as Array<[keyof typeof ui_locales, boolean]>) { + const th = document.createElement("th"); + th.textContent = L(key); + if (left) th.className = "b09-left"; + head.append(th); + } + const thead = document.createElement("thead"); + thead.append(head); + table.append(thead); + + const body = document.createElement("tbody"); + for (const row of list.rows) { + const tr = document.createElement("tr"); + tr.className = "b09-clickable"; + if (row.code === selected) tr.classList.add("is-adopted"); + tr.addEventListener("click", () => onPick(row.code)); + + const name = document.createElement("td"); + name.className = "b09-left"; + name.textContent = row.name; + const unit = document.createElement("td"); + unit.className = "b09-left"; + unit.textContent = row.unit; + tr.append(name, unit); + for (const value of [row.material, row.labor, row.expense, row.total]) { + const cell = document.createElement("td"); + cell.textContent = formatWon(value); + tr.append(cell); + } + body.append(tr); + } + table.append(body); + wrap.append(table); + return wrap; +} + +/** 일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천과 파고들기가 붙는다. */ +function buildUnitPriceDetail( + detail: UnitPriceDetailDto, + onDrill: (code: string) => void, +): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b09-sheet b09-up-detail"; + + const caption = document.createElement("div"); + caption.className = "b09-hint"; + caption.textContent = + `${L("B09_Estimation_UP_Detail")} · ${detail.name}` + + (detail.spec ? ` (${detail.spec})` : "") + + ` · ${formatWon(detail.total)}` + + ` · ${detail.sum_matches ? L("B09_Estimation_UP_SumOk") : L("B09_Estimation_UP_SumBad")}`; + wrap.append(caption); + + const table = document.createElement("table"); + const head = document.createElement("tr"); + for (const [key, left] of [ + ["B09_Estimation_Col_Name", true], + ["B09_Estimation_Col_Spec", true], + ["B09_Estimation_Col_Source", true], + ["B09_Estimation_Col_Unit", true], + ["B09_Estimation_Col_Qty", false], + ["B09_Estimation_Col_Material", false], + ["B09_Estimation_Col_Labor", false], + ["B09_Estimation_Col_Expense", false], + ["B09_Estimation_Col_Total", false], + ] as Array<[keyof typeof ui_locales, boolean]>) { + const th = document.createElement("th"); + th.textContent = L(key); + if (left) th.className = "b09-left"; + head.append(th); + } + const thead = document.createElement("thead"); + thead.append(head); + table.append(thead); + + const body = document.createElement("tbody"); + for (const row of detail.rows) { + const tr = document.createElement("tr"); + if (row.drillable) { + tr.className = "b09-clickable"; + tr.title = L("B09_Estimation_UP_Drill"); + tr.addEventListener("click", () => onDrill(row.ref_code)); + } + const name = document.createElement("td"); + name.className = "b09-left"; + name.textContent = row.drillable ? `▸ ${row.name}` : row.name; + const spec = document.createElement("td"); + spec.className = "b09-left"; + spec.textContent = row.spec; + const source = document.createElement("td"); + source.className = "b09-left"; + source.textContent = `${row.source_label} (${row.source_index})`; + const unit = document.createElement("td"); + unit.className = "b09-left"; + unit.textContent = row.unit; + tr.append(name, spec, source, unit); + for (const value of [row.quantity, row.material, row.labor, row.expense, row.total]) { + const cell = document.createElement("td"); + cell.textContent = formatWon(value); + tr.append(cell); + } + body.append(tr); + } + + const sum = document.createElement("tr"); + sum.className = "is-total"; + const label = document.createElement("td"); + label.className = "b09-left"; + label.colSpan = 5; + label.textContent = L("B09_Estimation_Col_Total"); + sum.append(label); + for (const value of [detail.material, detail.labor, detail.expense, detail.total]) { + const cell = document.createElement("td"); + cell.textContent = formatWon(value); + sum.append(cell); + } + body.append(sum); + + table.append(body); + wrap.append(table); + return wrap; +} + /* ----------------------------------------------------------------------------- * 좌측 패널 * -------------------------------------------------------------------------- */ @@ -310,7 +501,7 @@ function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void { const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [ ["cost_sheet", "B09_Estimation_Tab_CostSheet", true], ["boq", "B09_Estimation_Tab_Boq", false], - ["unit_price", "B09_Estimation_Tab_UnitPrice", false], + ["unit_price", "B09_Estimation_Tab_UnitPrice", true], ["price_basis", "B09_Estimation_Tab_PriceBasis", false], ["machine", "B09_Estimation_Tab_Machine", false], ["duration", "B09_Estimation_Tab_Duration", false], @@ -371,6 +562,27 @@ async function fetchCostSheet(projectId: string, form: CostFormState): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`unit price list failed: ${response.status}`); + return (await response.json()) as UnitPriceListDto; +} + +async function fetchUnitPriceDetail( + projectId: string, + code: string, +): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices/${encodeURIComponent(code)}`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`unit price detail failed: ${response.status}`); + return (await response.json()) as UnitPriceDetailDto; +} + async function confirmEstimationStage(projectId: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/confirm`, @@ -389,6 +601,9 @@ export async function renderB09Estimation(root: HTMLElement): Promise { const form: CostFormState = { ...INITIAL_FORM }; let activeTab = "cost_sheet"; let sheet: CostSheetDto | null = null; + let unitPriceList: UnitPriceListDto | null = null; + let unitPriceDetail: UnitPriceDetailDto | null = null; + let selectedUnitPrice: string | null = null; const main = document.createElement("div"); main.className = "b09-main"; @@ -398,8 +613,58 @@ export async function renderB09Estimation(root: HTMLElement): Promise { body.style.display = "flex"; body.style.flexDirection = "column"; + /** 일위대가 본표를 불러 다시 그린다 — 기계 줄을 누르면 그 층으로 파고든다. */ + const openUnitPrice = async (code: string): Promise => { + if (!projectId) return; + try { + unitPriceDetail = await fetchUnitPriceDetail(projectId, code); + selectedUnitPrice = code; + drawBody(); + } catch { + showToast(L("B09_Estimation_UP_Load_Failed"), "error"); + } + }; + + const drawUnitPriceTab = (): void => { + if (!unitPriceList) { + const empty = document.createElement("div"); + empty.className = "b09-empty"; + empty.textContent = L("B09_Estimation_Tab_Pending"); + body.append(empty); + return; + } + // 산출 요약을 **화면에도** 낸다 — 무엇이 안 선 상태인지 사용자가 알아야 한다. + for (const note of unitPriceList.summary.notes) { + const line = document.createElement("div"); + line.className = "b09-hint"; + line.textContent = note; + body.append(line); + } + body.append( + buildUnitPriceList(unitPriceList, selectedUnitPrice, (code) => { + void openUnitPrice(code); + }), + ); + if (unitPriceDetail) { + body.append( + buildUnitPriceDetail(unitPriceDetail, (code) => { + void openUnitPrice(code); + }), + ); + } else { + const hint = document.createElement("div"); + hint.className = "b09-empty"; + hint.textContent = L("B09_Estimation_UP_Pick"); + body.append(hint); + } + }; + const drawBody = (): void => { body.replaceChildren(); + if (activeTab === "unit_price") { + drawUnitPriceTab(); + return; + } if (activeTab !== "cost_sheet") { const empty = document.createElement("div"); empty.className = "b09-empty"; @@ -428,6 +693,14 @@ export async function renderB09Estimation(root: HTMLElement): Promise { activeTab = key; drawTabs(); drawBody(); + if (key === "unit_price" && !unitPriceList && projectId) { + void fetchUnitPriceList(projectId) + .then((data) => { + unitPriceList = data; + drawBody(); + }) + .catch(() => showToast(L("B09_Estimation_UP_Load_Failed"), "error")); + } }); const old = main.querySelector(".b09-tabs"); if (old) old.replaceWith(bar); diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 19c6a8b2..1aed4d1d 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -19,6 +19,7 @@ from __future__ import annotations from dataclasses import dataclass, field from decimal import Decimal +from functools import lru_cache from B09_Estimation.B09_Estimation_Guards import check_surcharge_once from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog @@ -177,8 +178,11 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: 공종 하나에 붙은 자원 줄들을 그 공종의 상세로 삼는다. 자원이 하나도 안 붙은 공종은 **빈 줄로 세우지 않고 건너뛴다** — 0 원 일위대가가 내역에 서면 안 된다. """ + master = load_work_item_master() if axis is None: - axis = build_resource_axis(load_work_item_master(), load_combined_catalog()) + axis = build_resource_axis(master, load_combined_catalog()) + # 일위대가 이름은 **공종명**이어야 한다 — 코드만 보이면 사람이 못 읽는다. + names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])} build = UnitPriceBuild() wages = load_operator_wages() @@ -197,7 +201,13 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: continue unit = next((r.amount_unit for r in rows if r.amount_unit), "") build.book.add_title( - PriceTitle(code=title_code, kind=PriceKind.UNIT_PRICE, name=work_item_code, unit=unit) + PriceTitle( + code=title_code, + kind=PriceKind.UNIT_PRICE, + name=names.get(work_item_code) or work_item_code, + spec=work_item_code, + unit=unit, + ) ) added = 0 for row in rows: @@ -230,3 +240,122 @@ def verify_surcharge_once( surcharge_rate_percent=surcharge_rate_percent, label=code, ) + + +#: 상세 줄이 **어느 층에서 왔는지** 보이는 표시 (PLAN 9-3, ESTX `LinkIndex` 와 같은 축). +SOURCE_INDEX: dict[PriceKind, int] = { + PriceKind.MATERIAL: 5, + PriceKind.LABOR: 6, + PriceKind.MACHINE_BASE: 105, + PriceKind.MACHINE_HOURLY: 105, + PriceKind.UNIT_PRICE: 103, + PriceKind.PRICE_BASIS: 104, + PriceKind.LUMPSUM: 0, +} +SOURCE_LABEL: dict[PriceKind, str] = { + PriceKind.MATERIAL: "자재", + PriceKind.LABOR: "노임", + PriceKind.MACHINE_BASE: "기계경비", + PriceKind.MACHINE_HOURLY: "기계경비", + PriceKind.UNIT_PRICE: "일위대가", + PriceKind.PRICE_BASIS: "단가산출", + PriceKind.LUMPSUM: "일식·견적", +} + +#: 상세를 파고들 수 있는 층 — 이 종류의 줄을 누르면 그 본표가 열린다. +DRILLABLE_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS}) + + +@lru_cache(maxsize=1) +def cached_build() -> UnitPriceBuild: + """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.""" + return build_unit_prices() + + +def build_summary(build: UnitPriceBuild) -> dict: + """산출 요약 — **화면에도 낸다.** 사용자가 「무엇이 안 선 상태인가」를 알아야 한다.""" + kinds: dict[str, int] = {} + for title in build.book.titles.values(): + kinds[title.kind.value] = kinds.get(title.kind.value, 0) + 1 + return { + "titles": len(build.book.titles), + "unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0), + "machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0), + "skipped_work_items": len(build.skipped), + "incomplete_machines": len(build.incomplete_machines), + "kinds": kinds, + # ⚠ 지금 상태를 화면에 그대로 알린다 (PLAN 9-6 미결). + "notes": [ + "자재 카탈로그가 아직 없어 **구조물 계열 일위대가가 서지 않습니다** — " + "지금 선 것은 노무·기계 성분뿐입니다(연료만 자재로 섭니다).", + "사급 잡자재 단가는 미결입니다 — 값을 지어내지 않고 비워 둡니다.", + ], + } + + +def list_unit_prices(build: UnitPriceBuild) -> list[dict]: + """목록표 — 「무엇이 있나」 한 줄씩.""" + rows: list[dict] = [] + for code, title in sorted(build.book.titles.items()): + if title.kind is not PriceKind.UNIT_PRICE: + continue + money = build.book.resolve(code) + rows.append( + { + "code": code, + "name": title.name, + "spec": title.spec, + "unit": title.unit, + "material": str(money.material), + "labor": str(money.labor), + "expense": str(money.expense), + "total": str(money.total), + } + ) + return rows + + +def detail_of(build: UnitPriceBuild, code: str) -> dict: + """본표 — 「그것이 무엇으로 이루어졌나」. 줄마다 원천과 파고들기 여부를 함께 낸다.""" + title = build.book.title(code) + money = build.book.resolve(code) + rows: list[dict] = [] + for detail in build.book.details.get(code, []): + child = build.book.title(detail.ref_code) + unit_money = build.book.resolve(detail.ref_code) + line = unit_money.scaled(detail.quantity) + rows.append( + { + "ref_code": detail.ref_code, + "name": child.name, + "spec": child.spec, + "unit": child.unit, + "source_index": SOURCE_INDEX.get(child.kind, 0), + "source_label": SOURCE_LABEL.get(child.kind, ""), + "drillable": child.kind in DRILLABLE_KINDS, + "quantity": str(detail.quantity), + "unit_material": str(unit_money.material), + "unit_labor": str(unit_money.labor), + "unit_expense": str(unit_money.expense), + "unit_total": str(unit_money.total), + "material": str(line.material), + "labor": str(line.labor), + "expense": str(line.expense), + "total": str(line.total), + "note": detail.note, + } + ) + return { + "code": code, + "name": title.name, + "spec": title.spec, + "unit": title.unit, + "kind": title.kind.value, + "material": str(money.material), + "labor": str(money.labor), + "expense": str(money.expense), + "total": str(money.total), + # TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다. + "sum_matches": money.total == money.material + money.labor + money.expense, + "rows": rows, + } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 75fceeee..b9ee8d06 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -669,6 +669,22 @@ export const ui_locales_b2 = { "Failed to confirm the cost estimate stage.", ], B09_Estimation_Tab_Pending: ["준비 중", "Coming soon"], + B09_Estimation_UP_List: ["일위대가 목록표", "Unit Price Index"], + B09_Estimation_UP_Detail: ["일위대가표", "Unit Price Sheet"], + B09_Estimation_UP_Pick: ["목록에서 항목을 고르세요.", "Pick an item from the index."], + B09_Estimation_UP_Drill: ["펼쳐 보기", "Open"], + B09_Estimation_Col_Name: ["명칭", "Name"], + B09_Estimation_Col_Spec: ["규격", "Spec"], + B09_Estimation_Col_Unit: ["단위", "Unit"], + B09_Estimation_Col_Qty: ["수량", "Qty"], + B09_Estimation_Col_Source: ["원천", "Source"], + B09_Estimation_Col_Material: ["재료비", "Material"], + B09_Estimation_Col_Labor: ["노무비", "Labor"], + B09_Estimation_Col_Expense: ["경비", "Expense"], + B09_Estimation_Col_Total: ["합계", "Total"], + B09_Estimation_UP_SumOk: ["합계 = 재료+노무+경비 일치", "Total = M+L+E ✓"], + B09_Estimation_UP_SumBad: ["⚠ 합계가 재료+노무+경비와 다릅니다", "⚠ Total ≠ M+L+E"], + B09_Estimation_UP_Load_Failed: ["일위대가를 못 불러왔습니다.", "Failed to load unit prices."], /* --- B10_Payment 결재 --- */ B10_Payment_Title: ["결재", "Payment"],