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_Sheet.ts b/B09_Estimation/B09_Estimation_UI_Sheet.ts index c76f1cc1..76c88b56 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,8 @@ 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-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..8cacd05a 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -19,6 +19,10 @@ 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"; /** 탭 등록 — 한 줄에 탭 하나. 옛 탭(`legacyTab`)은 새 탭 파일이 서면 그 줄만 바꿈. */ const TABS: B09Tab[] = [ @@ -27,7 +31,10 @@ const TABS: B09Tab[] = [ billTab, unitPriceTab, priceBasisTab, - legacyTab("machine", "B09_Estimation_Tab_Machine"), + machineTab, + priceCompareTab, + summaryTab, + listsTab, legacyTab("supply", "B09_Estimation_Tab_Supply"), legacyTab("base_data", "B09_Estimation_Tab_BaseData"), legacyTab("design_doc", "B09_Estimation_Tab_DesignDoc"), diff --git a/B09_Estimation/B09_Estimation_UI_Store.ts b/B09_Estimation/B09_Estimation_UI_Store.ts index c3158e1c..c152598b 100644 --- a/B09_Estimation/B09_Estimation_UI_Store.ts +++ b/B09_Estimation/B09_Estimation_UI_Store.ts @@ -68,7 +68,34 @@ 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 BillDto { + resources: { + groups: Record; + missing: string[]; + note: string; + }; + lists: Record; rows: BillRowDto[]; excluded: BillRowDto[]; summary: { @@ -140,6 +167,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_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..df15a90d --- /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`). + * - 줄을 누르면 아래에 그 기종의 중기사용료 호표 — 손료(경비) · 운전원(노무) · 주연료(재료) · 잡품(재료). + * ⚠ 잡품은 「주연료비 × 율」 가산 행 — 수량 칸에 율(%), 단가 칸에 밑수(주연료비)가 옴(실무 표 그대로). + * - 그 아래 중기경비계산서(취득가·손료계수·연료·조종원 과정)는 옛 표를 그대로 이어 보임. + * ========================================================================== */ + +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { renderDetail } from "./B09_Estimation_UI_Detail"; +import { drawMachineExpense, fetchMachineExpense } from "./B09_Estimation_UI_BaseData"; +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) { + // 중기경비계산서 — 「그 값이 왜 나왔나」(옛 표 그대로, 옛 파일을 걷을 때 함께 옮김). + 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_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;