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