feat(b09): 표 화면 넷 — 중기(목록표·중기사용료) · 자재단가대비표 · 집계표 넷 · 목록표 일곱

- 서버: 내역 응답에 집계표 넷(resources)·목록표(lists) — 내역이 쓴 자원을 처음 쓰인 차례로 되모음
  (묶음 줄·구조물도 호표 줄도 구성까지 풀어 셈) · 재료비 집계표에 자재대(사급·관급) 줄 이어 붙임
- 중기시간금액집계표: 단가 열 없이 합계·노무·재료·경비 — 성분마다 반올림한 뒤 합(골든셋 131/144, 합계 한 번 반올림 107/144)
- 재료·노무·경비 집계표: 반올림(수량 × 단가) 골든셋 279/279 — 시험 한 벌 더함
- 중기사용료 호표: 잡품 줄은 수량 칸 율(%) · 단가 칸 밑수(주연료비) · 금액 — 실무 표 그대로. 제잡비·공구손료 줄도 밑수를 단가 칸에
- 자재단가대비표: 슬롯 1~6 단가·페이지 · 채택 칸 굵게 · 최소단가 표시(서버 min_slot)
- 목록표 일곱(일위대가·단가산출근거·중기·재료비·노무비·경비·일식견적): 색인만 — 서버 번호·단가 그대로, 일위대가·산근·중기 줄은 누르면 들어감
- 옛 중기 탭 줄을 새 탭으로 바꿔 끼움(중기경비계산서는 옛 표를 아래에 이어 보임)
- 검증: ORCA — 중기 목록 9 · 굴착기 0.7 호표 합계 109,704 · 잡품 22 % × 21,418.1 = 4,711.9 · 노무 집계 벌목부 9,686,529 · 중기 집계 성분 합 · 시험 1638 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-14 01:56:02 +09:00
co-authored by Claude Opus 5
parent b7af20f76d
commit 3ce997fa69
17 changed files with 780 additions and 32 deletions
@@ -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"]
@@ -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 {
@@ -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
+110 -6
View File
@@ -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()
@@ -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,
+4
View File
@@ -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],
+10 -6
View File
@@ -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),
+27
View File
@@ -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);
}
+8 -1
View File
@@ -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"),
+59
View File
@@ -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<ResourceGroup, ResourceRowDto[]>;
missing: string[];
note: string;
};
lists: Record<ResourceGroup, ResourceRowDto[]>;
rows: BillRowDto[];
excluded: BillRowDto[];
summary: {
@@ -140,6 +167,38 @@ export function loadBill(projectId: string, force = false): Promise<BillDto> {
return bills.get(projectId) as Promise<BillDto>;
}
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<PriceCompareDto> {
return getJson<PriceCompareDto>(
`/projects/${encodeURIComponent(projectId)}/estimation/price-sources`,
);
}
/** 호표 본표 — 일위대가(B)·시간당 중기(X)는 `unit-prices`, 단가산출(D)은 `price-basis`. */
export function loadDetail(projectId: string, code: string): Promise<DetailDto> {
const kind = code.startsWith("D-") ? "price-basis" : "unit-prices";
@@ -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<typeof L>[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));
});
},
};
@@ -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));
});
},
};
@@ -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));
});
},
};
@@ -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<typeof L>[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));
});
},
};
@@ -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",
+49 -1
View File
@@ -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)
+16
View File
@@ -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;