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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user