Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -52,7 +52,7 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
|
||||
[ROUTES.B08_QUANTITY]: async () =>
|
||||
(await import("../B08_Quantity/B08_Quantity_UI_Page")).renderB08Quantity,
|
||||
[ROUTES.B09_ESTIMATION]: async () =>
|
||||
(await import("../B09_Estimation/B09_Estimation_UI_Page")).renderB09Estimation,
|
||||
(await import("../B09_Estimation/B09_Estimation_UI_Shell")).renderB09Estimation,
|
||||
[ROUTES.B10_PAYMENT]: async () =>
|
||||
(await import("../B10_Payment/B10_Payment_UI_Page")).renderB10Payment,
|
||||
[ROUTES.B11_STATUS]: async () =>
|
||||
|
||||
@@ -165,6 +165,14 @@ class BillRow:
|
||||
in_bill: bool = True
|
||||
#: 금액을 낸 단가 코드(`B-…#갈래`) — 단산 번호를 그 코드로 찾음. 안 선 줄은 빈 글.
|
||||
price_code: str = ""
|
||||
#: 성분 단가 — 실무 내역서 「노무비·재료비·경비 단가」 칸(화면이 곱하지 않게 서버가 실음).
|
||||
unit_material_krw: Decimal | None = None
|
||||
unit_labor_krw: Decimal | None = None
|
||||
unit_expense_krw: Decimal | None = None
|
||||
#: 수동 단가로 선 몫 — 화면 빨간 테두리(구조물도 호표 줄).
|
||||
unconfirmed: int = 0
|
||||
#: 「단산 N 참조」 — 비고 첫 조각과 같은 글. 번호는 낼 때마다 매김(저장 안 함).
|
||||
price_basis_label: str = ""
|
||||
#: 줄 사유 **조각** — `(닿는 열 키, 글)`. 화면 「비고」는 이것을 이어 붙인 것이고,
|
||||
#: 근거 호버는 열 키로 걸러 **그 사유가 닿는 칸에만** 띄운다(PLAN 8-36 ㉮).
|
||||
#: ⚠ 종전엔 `note` 한 칸에 덮어썼다 — 한 줄에 사유가 둘이면 **하나가 조용히 사라졌다**
|
||||
@@ -210,6 +218,12 @@ class BillRow:
|
||||
"material_krw": str(self.material_krw),
|
||||
"labor_krw": str(self.labor_krw),
|
||||
"expense_krw": str(self.expense_krw),
|
||||
"unit_material_krw": money(self.unit_material_krw),
|
||||
"unit_labor_krw": money(self.unit_labor_krw),
|
||||
"unit_expense_krw": money(self.unit_expense_krw),
|
||||
"price_code": self.price_code,
|
||||
"unconfirmed": self.unconfirmed,
|
||||
"price_basis_label": self.price_basis_label,
|
||||
"is_group": self.is_group,
|
||||
"in_bill": self.in_bill,
|
||||
"note": self.note,
|
||||
@@ -235,6 +249,8 @@ class BillResult:
|
||||
notes: list[str] = field(default_factory=list)
|
||||
#: ③ 단가산출서 한 벌 — 조판할 때 번호가 매겨진다.
|
||||
price_basis: Any = None
|
||||
#: 일위대가 호표 번호 한 벌 — 내역에 처음 쓰인 차례(저장 안 함).
|
||||
unit_price_sheet: Any = None
|
||||
#: 자재대 표 — 사급·관급·미정 셋으로 갈린다(PLAN 8-7 「금액은 B09」).
|
||||
material_sheet: Any = None
|
||||
#: 수동 단가로 선 자리 — 내역서 끝 「미확정 N건」(PLAN 확정 ⑦). 줄마다 `{name, count}`.
|
||||
@@ -600,7 +616,14 @@ def build_bill(
|
||||
if label:
|
||||
# 종전처럼 **맨 앞**에 놓는다 — 실무 참조번호(「단산 46」)가 먼저 읽혀야 한다.
|
||||
row.notes.insert(0, ("unit_price_krw", label))
|
||||
row.price_basis_label = label
|
||||
result.price_basis = sheet
|
||||
from B09_Estimation.B09_Estimation_UnitPriceSheet import build_unit_price_sheet
|
||||
|
||||
result.unit_price_sheet = build_unit_price_sheet(
|
||||
result.rows, unit_prices.book, structure_prices
|
||||
)
|
||||
_sum_groups(result.rows)
|
||||
|
||||
if any(m.surcharge_pct is None for m in materials):
|
||||
result.notes.append(
|
||||
@@ -610,6 +633,24 @@ 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,13 @@ def bill_line(unit: Money3, quantity) -> Money3:
|
||||
)
|
||||
|
||||
|
||||
def _set_unit(row: BillRow, unit: Money3) -> None:
|
||||
"""성분 단가 칸 — 금액을 낸 바로 그 3분할(내역 서식 「노무비·재료비·경비 단가」)."""
|
||||
row.unit_material_krw = unit.material
|
||||
row.unit_labor_krw = unit.labor
|
||||
row.unit_expense_krw = unit.expense
|
||||
|
||||
|
||||
def _composite_row(
|
||||
item_no: str,
|
||||
item: HandoffWorkItem,
|
||||
@@ -104,6 +111,7 @@ def _composite_row(
|
||||
|
||||
money = money.floored(Decimal(1))
|
||||
line = bill_line(money, item.quantity)
|
||||
_set_unit(row, money)
|
||||
row.unit_price_krw = round_at(money.total, OutputPlace.UNIT_PRICE_ROW)
|
||||
row.amount_krw = line.total
|
||||
row.material_krw = line.material
|
||||
@@ -160,6 +168,9 @@ def _structure_price_row(
|
||||
|
||||
# 단가 = 호표 계금(구조물도 화면과 같은 값) · 금액 = 호표 성분 소계 × 수량(명세 7장).
|
||||
line = bill_line(entry["money"], item.quantity)
|
||||
_set_unit(row, entry["money"])
|
||||
row.price_code = ref
|
||||
row.unconfirmed = int(entry["unconfirmed"] or 0)
|
||||
row.unit_price_krw = entry["total"]
|
||||
row.amount_krw = line.total
|
||||
row.material_krw = line.material
|
||||
@@ -462,6 +473,7 @@ def _leaf_row(
|
||||
|
||||
unit_money = unit_prices.book.resolve(price_code)
|
||||
line = bill_line(unit_money, item.quantity)
|
||||
_set_unit(row, unit_money)
|
||||
row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW)
|
||||
# 내역서 **본체** 행은 성분마다 절사 — 집계표(반올림)와 어긋나는 것이 정상.
|
||||
row.amount_krw = line.total
|
||||
|
||||
@@ -24,7 +24,7 @@ from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceKind
|
||||
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBook, PriceKind
|
||||
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
|
||||
|
||||
@@ -43,6 +43,8 @@ class PriceBasisEntry:
|
||||
ref_code: str
|
||||
#: 이 산출서를 부르는 내역 일위대가 전부(갈래·단계 합산 부모 포함).
|
||||
unit_price_codes: list[str] = field(default_factory=list)
|
||||
#: 성분 금액 — 목록표 「노무비·재료비·경비」 칸(머리 성분 원 미만 절사 값).
|
||||
money: Money3 = field(default_factory=Money3)
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
@@ -59,6 +61,10 @@ class PriceBasisEntry:
|
||||
"unit": self.unit,
|
||||
"unit_price_krw": str(self.unit_price_krw),
|
||||
"ref_code": self.ref_code,
|
||||
"unit_price_codes": list(self.unit_price_codes),
|
||||
"material_krw": str(self.money.material),
|
||||
"labor_krw": str(self.money.labor),
|
||||
"expense_krw": str(self.money.expense),
|
||||
}
|
||||
|
||||
|
||||
@@ -116,14 +122,16 @@ def build_price_basis(
|
||||
entry = numbered.get(basis)
|
||||
if entry is None:
|
||||
title = book.title(basis)
|
||||
money = book.resolve(basis)
|
||||
entry = PriceBasisEntry(
|
||||
number=len(sheet.entries) + 1,
|
||||
code=basis,
|
||||
name=title.name,
|
||||
spec=title.spec,
|
||||
unit=title.unit,
|
||||
unit_price_krw=round_at(book.resolve(basis).total, OutputPlace.UNIT_PRICE_ROW),
|
||||
unit_price_krw=round_at(money.total, OutputPlace.UNIT_PRICE_ROW),
|
||||
ref_code=code,
|
||||
money=money,
|
||||
)
|
||||
sheet.entries.append(entry)
|
||||
numbered[basis] = entry
|
||||
|
||||
@@ -807,6 +807,11 @@ async def get_bill(project_id: UUID) -> JSONResponse:
|
||||
"price_basis": (
|
||||
result.price_basis.as_dict() if result.price_basis else {"entries": []}
|
||||
),
|
||||
"unit_price_sheet": (
|
||||
result.unit_price_sheet.as_dict()
|
||||
if result.unit_price_sheet
|
||||
else {"entries": []}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Detail.ts
|
||||
* 호표 본표(일위대가표·단가산출근거·시간당 중기) 한 장 + **들어가기 자취** (PLAN 12장 1차)
|
||||
*
|
||||
* - ⭐ STmate 를 익숙하게 만드는 동작 = 타고 들어가기(브레인 판정). 내역 줄 → 제 N 호표 →
|
||||
* 호표 안 줄(일위대가·단산·중기) → … 을 누를 때마다 자취가 쌓이고, 자취 글을 누르면 그 자리로 되돌아감.
|
||||
* - 표 모양은 실무 `일위대가표`·`단가산출근거` 시트: 명칭·규격·수량·단위 · 합계/노무/재료/경비(단가·금액) · 비고.
|
||||
* - ⚠ 합계 줄은 서버 값(호표 성분 소계 원 미만 절사) — 여기서 안 더함.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { B09TabContext } from "./B09_Estimation_UI_Shell_Types";
|
||||
import {
|
||||
L,
|
||||
el,
|
||||
hint,
|
||||
linkButton,
|
||||
moneyCells,
|
||||
numberCell,
|
||||
quantity,
|
||||
sheetHead,
|
||||
sheetTable,
|
||||
} from "./B09_Estimation_UI_Sheet";
|
||||
import { loadDetail, type DetailDto, type DetailRowDto } from "./B09_Estimation_UI_Store";
|
||||
|
||||
interface Crumb {
|
||||
tab: string;
|
||||
code: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const crumbs: Crumb[] = [];
|
||||
let drilling = false;
|
||||
|
||||
/** 본표 안 줄을 눌러 들어갈 때 — 자취를 이어 쌓음. 목록·내역에서 들어가면 자취가 새로 시작. */
|
||||
export function drill(ctx: B09TabContext, tab: string, code: string): void {
|
||||
drilling = true;
|
||||
ctx.open(tab, code);
|
||||
}
|
||||
|
||||
function visit(tab: string, code: string, label: string): void {
|
||||
if (!drilling) crumbs.length = 0;
|
||||
drilling = false;
|
||||
const at = crumbs.findIndex((crumb) => crumb.tab === tab && crumb.code === code);
|
||||
if (at >= 0) crumbs.length = at;
|
||||
crumbs.push({ tab, code, label });
|
||||
}
|
||||
|
||||
function trail(ctx: B09TabContext): HTMLElement {
|
||||
const box = el("div", "b09s-trail");
|
||||
crumbs.forEach((crumb, index) => {
|
||||
if (index > 0) box.append(el("span", "b09s-hint", "›"));
|
||||
if (index === crumbs.length - 1) {
|
||||
box.append(el("span", "b09s-title", crumb.label));
|
||||
return;
|
||||
}
|
||||
box.append(linkButton(crumb.label, () => drill(ctx, crumb.tab, crumb.code)));
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 줄이 가리키는 곳 — 단산(D)은 단가산출근거 탭, 일위대가·중기는 일위대가 탭. */
|
||||
function targetTab(row: DetailRowDto): string | null {
|
||||
if (!row.drillable || !row.ref_code) return null;
|
||||
return row.kind === "price_basis" ? "price_basis" : "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 ?? ""];
|
||||
tr.append(
|
||||
el("td", "", row.name),
|
||||
el("td", "", row.spec),
|
||||
numberCell(quantity(row.quantity)),
|
||||
el("td", "", row.unit),
|
||||
...moneyCells(unit, [row.total, row.labor, row.material, row.expense]),
|
||||
);
|
||||
const note = el("td", "b09s-note");
|
||||
const source = row.source_label || row.source || "";
|
||||
if (source) note.append(el("span", "b09s-hint", `[${source}] `));
|
||||
if (row.note) note.append(el("span", "b09s-formula", row.note));
|
||||
tr.append(note);
|
||||
const tab = targetTab(row);
|
||||
if (tab && row.ref_code) {
|
||||
const code = row.ref_code;
|
||||
tr.classList.add("is-clickable");
|
||||
tr.title = L("B09_Sheet_Drill");
|
||||
tr.addEventListener("click", () => drill(ctx, tab, code));
|
||||
}
|
||||
return tr;
|
||||
}
|
||||
|
||||
function drawDetail(ctx: B09TabContext, box: HTMLElement, detail: DetailDto, label: string): void {
|
||||
const title = el(
|
||||
"div",
|
||||
"b09s-title",
|
||||
`${label} ${detail.name}${detail.spec ? ` · ${detail.spec}` : ""}`,
|
||||
);
|
||||
if (detail.unit) title.append(el("span", "b09s-hint", ` (${detail.unit})`));
|
||||
const { wrap, tbody } = sheetTable(
|
||||
sheetHead([
|
||||
L("B09_Sheet_Col_Name"),
|
||||
L("B09_Sheet_Col_Spec"),
|
||||
L("B09_Sheet_Col_Quantity"),
|
||||
L("B09_Sheet_Col_Unit"),
|
||||
]),
|
||||
);
|
||||
for (const row of detail.rows) tbody.append(detailRow(row, ctx));
|
||||
const sum = el("tr", "is-sum");
|
||||
sum.append(el("td", "", L("B09_Sheet_Sum")), el("td"), el("td"), el("td"));
|
||||
sum.append(
|
||||
...moneyCells(null, [detail.total, detail.labor, detail.material, detail.expense]),
|
||||
el("td"),
|
||||
);
|
||||
tbody.append(sum);
|
||||
box.append(title, wrap);
|
||||
if (detail.unattached_note) box.append(hint(detail.unattached_note.replace(/\*\*/g, ""), true));
|
||||
if (detail.known_gap_note) box.append(hint(detail.known_gap_note, true));
|
||||
}
|
||||
|
||||
/** 본표 한 장을 `box` 에 — 자취를 쌓고 서버 본표를 받아 그림. */
|
||||
export function renderDetail(
|
||||
ctx: B09TabContext,
|
||||
box: HTMLElement,
|
||||
tab: string,
|
||||
code: string,
|
||||
label: string,
|
||||
): void {
|
||||
if (!ctx.projectId) return;
|
||||
visit(tab, code, label);
|
||||
box.replaceChildren(trail(ctx), hint(L("B09_Sheet_Loading")));
|
||||
loadDetail(ctx.projectId, code)
|
||||
.then((detail) => {
|
||||
box.replaceChildren(trail(ctx));
|
||||
drawDetail(ctx, box, detail, label);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
box.replaceChildren(trail(ctx), hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,6 @@
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import {
|
||||
attachProvenance,
|
||||
@@ -47,7 +46,6 @@ import {
|
||||
type PriceSourcesDto,
|
||||
} from "./B09_Estimation_UI_BaseData";
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
@@ -900,7 +898,15 @@ async function confirmEstimationStage(projectId: string): Promise<void> {
|
||||
* 페이지 진입점
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
/** 옛 화면을 새 틀(`B09_Estimation_UI_Shell`) 안에 이어 붙이는 자리 — 옛 탭 줄은 숨고 틀이 `select` 로 고름.
|
||||
* ⚠ 옛 탭을 새 탭 파일로 다 바꾸면 이 파일째 지움(PLAN 12장 · 브레인 판정). */
|
||||
export interface LegacyEstimation {
|
||||
main: HTMLElement;
|
||||
panel: HTMLElement;
|
||||
select: (key: string) => void;
|
||||
}
|
||||
|
||||
export function createLegacyEstimation(root: HTMLElement): LegacyEstimation {
|
||||
injectStyles();
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
const form: CostFormState = { ...INITIAL_FORM };
|
||||
@@ -1546,16 +1552,22 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
drawTabs();
|
||||
drawBody();
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
title: L("B09_Estimation_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 6,
|
||||
leftPanel: panel.root,
|
||||
mainContent: main,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
onStepClick: (stepIndex) => {
|
||||
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
return {
|
||||
main,
|
||||
panel: panel.root,
|
||||
select: (key: string) => {
|
||||
activeTab = key;
|
||||
drawTabs();
|
||||
drawBody();
|
||||
// 관급·사급 표는 내역 응답에 실려 옴 — 옛 내역 탭을 안 거치므로 여기서 받음.
|
||||
if (key === "supply" && !bill && projectId) {
|
||||
void fetchBill(projectId)
|
||||
.then((data) => {
|
||||
bill = data;
|
||||
drawBody();
|
||||
})
|
||||
.catch(() => showToast(L("B09_Estimation_Boq_Failed"), "error"));
|
||||
}
|
||||
},
|
||||
});
|
||||
root.append(layout.root);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Sheet.ts
|
||||
* B09 실무 서식 표 공용 — 내역서·일위대가표·단가산출근거가 같은 모양으로 섬 (PLAN 12장)
|
||||
*
|
||||
* - 표 모양은 STmate 출력 시트 그대로: 명칭·규격·수량·단위 · 합계/노무비/재료비/경비 (단가·금액) · 비고.
|
||||
* - ⚠ 여기서 곱하거나 더하지 않음 — 서버가 실은 칸을 찍기만. 숫자 꼴(천 단위 쉼표)만 바꿈.
|
||||
* - 가로 넘침은 표 칸 안에서만(`min-width:0` · `overflow-x:auto`) — 옛 화면 2026-09-08 실측 교훈.
|
||||
* ========================================================================== */
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
export function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** 금액 글 → 「1,234,567」 · 소수부가 있으면 한 자리(일위대가 금액란 0.1원). 숫자가 아니면 그대로. */
|
||||
export function won(value: string | null | undefined): string {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return String(value);
|
||||
const fraction = Math.abs(number % 1) > 1e-9;
|
||||
return number.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: fraction ? 1 : 0,
|
||||
maximumFractionDigits: fraction ? 1 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
/** 수량 — 서버가 준 표시 자리(`digits`). 내역 줄인데 자리를 모르면 옛 화면처럼 둘째 자리,
|
||||
* 호표 안 줄(`digits` 안 줌)은 품이 작아(0.0115 hr) 넷째 자리까지. */
|
||||
export function quantity(value: string | null | undefined, digits?: number | null): string {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return String(value);
|
||||
if (digits === undefined) {
|
||||
return number.toLocaleString("ko-KR", { maximumFractionDigits: 4 });
|
||||
}
|
||||
if (digits === null) {
|
||||
return number.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
|
||||
}
|
||||
return number.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
export function el<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
className = "",
|
||||
text = "",
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 실무 시트 머리 두 줄 — 앞 칸(명칭 따위)은 두 줄을 차지, 성분 넷은 「단가·금액」 두 칸. */
|
||||
export function sheetHead(
|
||||
front: string[],
|
||||
back: string[] = [L("B09_Sheet_Col_Note")],
|
||||
): HTMLElement {
|
||||
const thead = el("thead");
|
||||
const top = el("tr");
|
||||
const bottom = el("tr");
|
||||
for (const label of front) {
|
||||
const th = el("th", "", label);
|
||||
th.rowSpan = 2;
|
||||
top.append(th);
|
||||
}
|
||||
for (const key of [
|
||||
"B09_Sheet_Col_Total",
|
||||
"B09_Sheet_Col_Labor",
|
||||
"B09_Sheet_Col_Material",
|
||||
"B09_Sheet_Col_Expense",
|
||||
] as const) {
|
||||
const th = el("th", "", L(key));
|
||||
th.colSpan = 2;
|
||||
top.append(th);
|
||||
bottom.append(
|
||||
el("th", "", L("B09_Sheet_Col_UnitPrice")),
|
||||
el("th", "", L("B09_Sheet_Col_Amount")),
|
||||
);
|
||||
}
|
||||
for (const label of back) {
|
||||
const th = el("th", "", label);
|
||||
th.rowSpan = 2;
|
||||
top.append(th);
|
||||
}
|
||||
thead.append(top, bottom);
|
||||
return thead;
|
||||
}
|
||||
|
||||
/** 숫자 칸 하나. */
|
||||
export function numberCell(text: string): HTMLTableCellElement {
|
||||
return el("td", "b09s-num", text);
|
||||
}
|
||||
|
||||
/** 성분 넷 × (단가·금액) 여덟 칸 — 합계·노무·재료·경비 차례(실무 시트). */
|
||||
export function moneyCells(
|
||||
unit: [string, string, string, string] | null,
|
||||
amount: [string, string, string, string] | null,
|
||||
): HTMLTableCellElement[] {
|
||||
const cells: HTMLTableCellElement[] = [];
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
cells.push(numberCell(unit ? won(unit[i]) : ""), numberCell(amount ? won(amount[i]) : ""));
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
/** 표 한 장 — 가로로 넘치면 이 칸 안에서만 밀림. */
|
||||
export function sheetTable(head: HTMLElement): { wrap: HTMLElement; tbody: HTMLElement } {
|
||||
const wrap = el("div", "b09s-wrap");
|
||||
const table = el("table", "b09s-table");
|
||||
const tbody = el("tbody");
|
||||
table.append(head, tbody);
|
||||
wrap.append(table);
|
||||
return { wrap, tbody };
|
||||
}
|
||||
|
||||
/** 누르면 들어가는 글 — 「제 3 호표」·「단산 2」. */
|
||||
export function linkButton(text: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = el("button", "b09s-link", text);
|
||||
button.type = "button";
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
/** 수동 단가 표시 — 어느 화면에서든 같게(빨간 테두리 + 「미확정 N건」). */
|
||||
export function unconfirmedBadge(count: number): HTMLElement {
|
||||
return el("span", "b09s-badge", `${L("B09_Sheet_Unconfirmed")} ${count}${L("B09_Sheet_Count")}`);
|
||||
}
|
||||
|
||||
export function hint(text: string, warn = false): HTMLElement {
|
||||
return el("div", warn ? "b09s-hint b09s-hint--warn" : "b09s-hint", text);
|
||||
}
|
||||
|
||||
export function injectSheetStyles(): void {
|
||||
if (document.getElementById("b09-sheet-styles")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "b09-sheet-styles";
|
||||
style.textContent = `
|
||||
.b09s-page { display:flex; flex-direction:column; gap:8px; min-width:0; min-height:0; flex:1; }
|
||||
.b09s-tabs { display:flex; flex-wrap:wrap; gap:4px; border-bottom:1px solid var(--ui-border, #d0d4dc); padding-bottom:4px; }
|
||||
.b09s-tab { border:1px solid var(--ui-border, #d0d4dc); background:var(--ui-surface, #fff); padding:4px 10px; border-radius:6px 6px 0 0; cursor:pointer; font-size:13px; }
|
||||
.b09s-tab.is-active { background:var(--ui-accent, #2f6fed); color:#fff; border-color:var(--ui-accent, #2f6fed); }
|
||||
.b09s-body { display:flex; flex-direction:column; gap:8px; min-width:0; min-height:0; flex:1; overflow:auto; }
|
||||
.b09s-bar { display:flex; flex-wrap:wrap; align-items:center; gap:8px; font-size:13px; }
|
||||
.b09s-wrap { overflow-x:auto; max-width:100%; }
|
||||
.b09s-table { border-collapse:collapse; font-size:12px; white-space:nowrap; }
|
||||
.b09s-table th, .b09s-table td { border:1px solid var(--ui-border, #d0d4dc); padding:2px 6px; }
|
||||
.b09s-table th { background:var(--ui-surface-muted, #f1f3f7); font-weight:600; text-align:center; }
|
||||
.b09s-num { text-align:right; font-variant-numeric:tabular-nums; }
|
||||
.b09s-table tr.is-group td { font-weight:600; background:var(--ui-surface-muted, #f7f8fb); }
|
||||
.b09s-table tr.is-sum td { font-weight:700; background:var(--ui-surface-muted, #eef1f6); }
|
||||
.b09s-table tr.is-clickable { cursor:pointer; }
|
||||
.b09s-table tr.is-clickable:hover td { background:rgba(47,111,237,0.08); }
|
||||
.b09s-table tr.is-selected td { background:rgba(47,111,237,0.16); }
|
||||
.b09s-table tr.is-manual td { box-shadow:inset 0 0 0 1px #d93025; }
|
||||
.b09s-table td.b09s-note { white-space:normal; min-width:160px; max-width:420px; }
|
||||
.b09s-toggle { border:none; background:none; cursor:pointer; padding:0 4px 0 0; font-size:11px; }
|
||||
.b09s-link { border:none; background:none; color:var(--ui-accent, #2f6fed); cursor:pointer; padding:0 4px 0 0; text-decoration:underline; font-size:12px; }
|
||||
.b09s-badge { display:inline-block; border:1px solid #d93025; color:#d93025; border-radius:10px; padding:0 6px; font-size:11px; margin-left:4px; }
|
||||
.b09s-hint { font-size:12px; color:var(--ui-text-muted, #5f6673); }
|
||||
.b09s-hint--warn { color:#b3261e; }
|
||||
.b09s-trail { display:flex; flex-wrap:wrap; gap:4px; align-items:center; font-size:13px; }
|
||||
.b09s-split { display:flex; flex-direction:column; gap:12px; min-width:0; }
|
||||
.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; }
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Shell.ts
|
||||
* 로그인 후 09: 6차 워크플로우 (원가계산) — 화면 **틀** (PLAN 12장 · 2026-09-14 브레인 판정)
|
||||
*
|
||||
* - 틀은 탭 줄과 등록만. 탭마다 파일 하나(`B09_Estimation_UI_Tab_<이름>.ts`) — 계약은 `_Shell_Types`.
|
||||
* - ⚠ 등록 권한 = 랩탑_서브. 다른 창은 탭 파일을 만들고 이름을 알려 등록을 부탁함.
|
||||
* - 탭을 고를 때마다 본문·좌측 칸을 비우고 `render(ctx, arg)`. `ctx.open(key, arg)` = 다른 탭으로 들어가기.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
|
||||
import { L, el, injectSheetStyles } from "./B09_Estimation_UI_Sheet";
|
||||
import { legacyTab } from "./B09_Estimation_UI_Tab_Legacy";
|
||||
import { billTab } from "./B09_Estimation_UI_Tab_Bill";
|
||||
import { unitPriceTab } from "./B09_Estimation_UI_Tab_UnitPrice";
|
||||
import { priceBasisTab } from "./B09_Estimation_UI_Tab_PriceBasis";
|
||||
|
||||
/** 탭 등록 — 한 줄에 탭 하나. 옛 탭(`legacyTab`)은 새 탭 파일이 서면 그 줄만 바꿈. */
|
||||
const TABS: B09Tab[] = [
|
||||
legacyTab("cost_sheet", "B09_Estimation_Tab_CostSheet"), // → 랩탑_메인 원가계산서 탭
|
||||
billTab,
|
||||
unitPriceTab,
|
||||
priceBasisTab,
|
||||
legacyTab("machine", "B09_Estimation_Tab_Machine"),
|
||||
legacyTab("supply", "B09_Estimation_Tab_Supply"),
|
||||
legacyTab("base_data", "B09_Estimation_Tab_BaseData"),
|
||||
legacyTab("design_doc", "B09_Estimation_Tab_DesignDoc"),
|
||||
legacyTab("basis_sheet", "B09_Estimation_Tab_BasisSheet"),
|
||||
];
|
||||
|
||||
export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
injectSheetStyles();
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
const page = el("div", "b09s-page");
|
||||
const bar = el("div", "b09s-tabs");
|
||||
const body = el("div", "b09s-body");
|
||||
const panel = el("div");
|
||||
page.append(bar, body);
|
||||
|
||||
let active = TABS[0].key;
|
||||
const open = (key: string, arg?: string): void => {
|
||||
const tab = TABS.find((item) => item.key === key);
|
||||
if (!tab) return;
|
||||
active = key;
|
||||
drawBar();
|
||||
body.replaceChildren();
|
||||
panel.replaceChildren();
|
||||
const ctx: B09TabContext = { projectId, body, panel, root, open };
|
||||
tab.render(ctx, arg);
|
||||
};
|
||||
const drawBar = (): void => {
|
||||
bar.replaceChildren(
|
||||
...TABS.map((tab) => {
|
||||
const button = el(
|
||||
"button",
|
||||
`b09s-tab${tab.key === active ? " is-active" : ""}`,
|
||||
tab.label(),
|
||||
);
|
||||
button.type = "button";
|
||||
button.dataset.tab = tab.key;
|
||||
button.addEventListener("click", () => open(tab.key));
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
title: L("B09_Estimation_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 6,
|
||||
leftPanel: panel,
|
||||
mainContent: page,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
onStepClick: (stepIndex) => {
|
||||
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
},
|
||||
});
|
||||
root.append(layout.root);
|
||||
open(active);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Store.ts
|
||||
* B09 내역서·일위대가·단가산출근거 탭이 **한 벌로** 쓰는 서버 자료 (PLAN 12장)
|
||||
*
|
||||
* - 설계내역서 응답 한 벌에 호표 목록(`unit_price_sheet`)·단산 목록(`price_basis`)이 함께 옴 —
|
||||
* 세 탭이 같은 응답을 봄(번호가 탭끼리 갈리지 않게). 번호는 서버가 낼 때마다 매김(저장 안 함).
|
||||
* - 캐시는 메모리 한 벌(프로젝트별) — [다시 불러오기] 가 비움. 값을 저장하지 않음(보기 전용 1차).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
export interface BillNoteDto {
|
||||
column: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface BillRowDto {
|
||||
item_no: string;
|
||||
level: number;
|
||||
code: string | null;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
quantity: string | null;
|
||||
quantity_shown: string | null;
|
||||
quantity_digits: number | null;
|
||||
unit_price_krw: string | null;
|
||||
amount_krw: string | null;
|
||||
material_krw: string;
|
||||
labor_krw: string;
|
||||
expense_krw: string;
|
||||
unit_material_krw: string | null;
|
||||
unit_labor_krw: string | null;
|
||||
unit_expense_krw: string | null;
|
||||
price_code: string;
|
||||
unconfirmed: number;
|
||||
price_basis_label: string;
|
||||
is_group: boolean;
|
||||
in_bill: boolean;
|
||||
note: string;
|
||||
notes: BillNoteDto[];
|
||||
}
|
||||
|
||||
export interface SheetEntryDto {
|
||||
number: number;
|
||||
label: string;
|
||||
code: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
material_krw: string;
|
||||
labor_krw: string;
|
||||
expense_krw: string;
|
||||
total_krw?: string;
|
||||
unit_price_krw?: string;
|
||||
source?: string;
|
||||
unconfirmed?: number;
|
||||
ref_code?: string;
|
||||
unit_price_codes?: string[];
|
||||
}
|
||||
|
||||
export interface MissingDto {
|
||||
name: string;
|
||||
unit?: string;
|
||||
quantity?: string;
|
||||
reason: string;
|
||||
code?: string;
|
||||
blocked_kind?: string;
|
||||
}
|
||||
|
||||
export interface BillDto {
|
||||
rows: BillRowDto[];
|
||||
excluded: BillRowDto[];
|
||||
summary: {
|
||||
body_total_krw: string;
|
||||
detail_rows: number;
|
||||
missing: MissingDto[];
|
||||
unconfirmed_count: number;
|
||||
notes: string[];
|
||||
};
|
||||
price_basis: { entries: SheetEntryDto[] };
|
||||
unit_price_sheet: { entries: SheetEntryDto[] };
|
||||
}
|
||||
|
||||
export interface DetailRowDto {
|
||||
ref_code?: string;
|
||||
code?: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
kind?: string;
|
||||
source_label?: string;
|
||||
source?: string;
|
||||
drillable: boolean;
|
||||
quantity: string;
|
||||
unit_material?: string;
|
||||
unit_labor?: string;
|
||||
unit_expense?: string;
|
||||
unit_total?: string;
|
||||
material: string;
|
||||
labor: string;
|
||||
expense: string;
|
||||
total: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface DetailDto {
|
||||
code: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
kind: string;
|
||||
material: string;
|
||||
labor: string;
|
||||
expense: string;
|
||||
total: string;
|
||||
rows: DetailRowDto[];
|
||||
unattached_note: string;
|
||||
known_gap_note: string;
|
||||
}
|
||||
|
||||
const bills = new Map<string, Promise<BillDto>>();
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(String((body as { message?: string }).message || response.status));
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
/** 설계내역서 한 벌 — 세 탭이 같은 응답을 봄. `force` 면 다시 받음. */
|
||||
export function loadBill(projectId: string, force = false): Promise<BillDto> {
|
||||
if (force || !bills.has(projectId)) {
|
||||
const request = getJson<BillDto>(`/projects/${encodeURIComponent(projectId)}/estimation/bill`);
|
||||
request.catch(() => bills.delete(projectId));
|
||||
bills.set(projectId, request);
|
||||
}
|
||||
return bills.get(projectId) as Promise<BillDto>;
|
||||
}
|
||||
|
||||
/** 호표 본표 — 일위대가(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";
|
||||
return getJson<DetailDto>(
|
||||
`/projects/${encodeURIComponent(projectId)}/estimation/${kind}/${encodeURIComponent(code)}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tab_Bill.ts
|
||||
* B09 설계내역서 탭 — STmate `wBoq` 본 (PLAN 12장 1차: 표시 + 들어가기)
|
||||
*
|
||||
* - 열은 실무 설계내역서 그대로: 공종 · 명칭 · 규격 · 수량 · 단위 · 합계/노무비/재료비/경비(단가·금액) · 비고.
|
||||
* - 머리글 줄은 ▸/▾ 로 접고, 「레벨」 고르개로 한 번에 접음(STmate `소계~총대계` 고르기의 우리 꼴).
|
||||
* - ⭐ 줄을 누르면 그 줄 호표(일위대가) → 호표 안 줄을 누르면 단가산출근거로 들어감(브레인 판정 핵심).
|
||||
* - 수동 단가로 선 줄 = 빨간 테두리 + 「미확정 N건」.
|
||||
* - ⚠ 금액·합계는 서버가 실은 값 — 여기서 더하지 않음(머리글 줄 금액도 서버 합).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
|
||||
import {
|
||||
L,
|
||||
el,
|
||||
hint,
|
||||
linkButton,
|
||||
moneyCells,
|
||||
numberCell,
|
||||
quantity,
|
||||
sheetHead,
|
||||
sheetTable,
|
||||
unconfirmedBadge,
|
||||
won,
|
||||
} from "./B09_Estimation_UI_Sheet";
|
||||
import { loadBill, type BillDto, type BillRowDto } from "./B09_Estimation_UI_Store";
|
||||
|
||||
/** 접힌 머리글 줄(공종 번호) — 탭을 오가도 남음. */
|
||||
const collapsed = new Set<string>();
|
||||
let levelLimit = 0; // 0 = 모두
|
||||
|
||||
function rowMoney(row: BillRowDto): {
|
||||
unit: [string, string, string, string] | null;
|
||||
amount: [string, string, string, string] | null;
|
||||
} {
|
||||
const amount: [string, string, string, string] | null =
|
||||
row.amount_krw === null
|
||||
? null
|
||||
: [row.amount_krw, row.labor_krw, row.material_krw, row.expense_krw];
|
||||
if (row.is_group || row.unit_price_krw === null) return { unit: null, amount };
|
||||
return {
|
||||
unit: [
|
||||
row.unit_price_krw,
|
||||
row.unit_labor_krw ?? "",
|
||||
row.unit_material_krw ?? "",
|
||||
row.unit_expense_krw ?? "",
|
||||
],
|
||||
amount,
|
||||
};
|
||||
}
|
||||
|
||||
function isHidden(row: BillRowDto, rows: BillRowDto[]): boolean {
|
||||
if (levelLimit > 0 && row.level > levelLimit) return true;
|
||||
for (const group of rows) {
|
||||
if (
|
||||
group.is_group &&
|
||||
collapsed.has(group.item_no) &&
|
||||
row.item_no.startsWith(`${group.item_no}-`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function noteCell(row: BillRowDto, bill: BillDto, ctx: B09TabContext): HTMLTableCellElement {
|
||||
const cell = el("td", "b09s-note");
|
||||
const sheet = bill.unit_price_sheet.entries.find((entry) => entry.code === row.price_code);
|
||||
if (sheet) cell.append(linkButton(sheet.label, () => ctx.open("unit_price", sheet.code)));
|
||||
const basis = bill.price_basis.entries.filter((entry) =>
|
||||
(entry.unit_price_codes ?? []).includes(row.price_code),
|
||||
);
|
||||
for (const entry of basis) {
|
||||
cell.append(
|
||||
linkButton(`${L("B09_Sheet_Basis_Short")} ${entry.number}`, () =>
|
||||
ctx.open("price_basis", entry.code),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (row.unconfirmed > 0) cell.append(unconfirmedBadge(row.unconfirmed));
|
||||
// 비고 글 — 단산 번호는 위 단추로 보였으니 그 조각만 뺌.
|
||||
const rest = row.notes
|
||||
.map((note) => note.text)
|
||||
.filter((text) => text && text !== row.price_basis_label)
|
||||
.join(" / ");
|
||||
if (rest) cell.append(el("span", "", rest));
|
||||
return cell;
|
||||
}
|
||||
|
||||
function drawRows(tbody: HTMLElement, bill: BillDto, ctx: B09TabContext, redraw: () => void): void {
|
||||
for (const row of bill.rows) {
|
||||
if (isHidden(row, bill.rows)) continue;
|
||||
const tr = el("tr", row.is_group ? "is-group" : "");
|
||||
const numberTd = el("td");
|
||||
if (row.is_group) {
|
||||
const toggle = el("button", "b09s-toggle", collapsed.has(row.item_no) ? "▸" : "▾");
|
||||
toggle.type = "button";
|
||||
toggle.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
if (collapsed.has(row.item_no)) collapsed.delete(row.item_no);
|
||||
else collapsed.add(row.item_no);
|
||||
redraw();
|
||||
});
|
||||
numberTd.append(toggle);
|
||||
}
|
||||
numberTd.append(document.createTextNode(row.item_no));
|
||||
const name = el("td", "", row.name);
|
||||
name.style.paddingLeft = `${6 + Math.max(0, row.level - 1) * 12}px`;
|
||||
const { unit, amount } = rowMoney(row);
|
||||
tr.append(
|
||||
numberTd,
|
||||
name,
|
||||
el("td", "", row.spec),
|
||||
numberCell(
|
||||
row.is_group ? "" : quantity(row.quantity_shown ?? row.quantity, row.quantity_digits),
|
||||
),
|
||||
el("td", "", row.unit),
|
||||
...moneyCells(unit, amount),
|
||||
);
|
||||
if (row.is_group) {
|
||||
tr.append(el("td"));
|
||||
} else {
|
||||
tr.append(noteCell(row, bill, ctx));
|
||||
if (row.unconfirmed > 0) tr.classList.add("is-manual");
|
||||
if (row.price_code) {
|
||||
tr.classList.add("is-clickable");
|
||||
tr.title = L("B09_Sheet_Open_UnitPrice");
|
||||
tr.addEventListener("click", () => ctx.open("unit_price", row.price_code));
|
||||
}
|
||||
}
|
||||
tbody.append(tr);
|
||||
}
|
||||
}
|
||||
|
||||
function levelPicker(bill: BillDto, redraw: () => void): HTMLElement {
|
||||
const select = el("select", "ui-input");
|
||||
const deepest = Math.max(1, ...bill.rows.map((row) => row.level));
|
||||
const all = el("option", "", L("B09_Sheet_Level_All"));
|
||||
all.value = "0";
|
||||
select.append(all);
|
||||
for (let level = 1; level < deepest; level += 1) {
|
||||
const option = el("option", "", `${level}${L("B09_Sheet_Level_Upto")}`);
|
||||
option.value = String(level);
|
||||
select.append(option);
|
||||
}
|
||||
select.value = String(levelLimit);
|
||||
select.addEventListener("change", () => {
|
||||
levelLimit = Number(select.value);
|
||||
redraw();
|
||||
});
|
||||
const label = el("label", "b09s-bar", L("B09_Sheet_Level"));
|
||||
label.append(select);
|
||||
return label;
|
||||
}
|
||||
|
||||
function drawBill(ctx: B09TabContext, bill: BillDto, reload: () => void): void {
|
||||
const redraw = (): void => {
|
||||
ctx.body.replaceChildren();
|
||||
drawBill(ctx, bill, reload);
|
||||
};
|
||||
const bar = el("div", "b09s-bar");
|
||||
bar.append(
|
||||
createButton({ label: L("B09_Sheet_Reload"), variant: "ghost", onClick: reload }),
|
||||
levelPicker(bill, redraw),
|
||||
el(
|
||||
"span",
|
||||
"",
|
||||
`${L("B09_Sheet_BodyTotal")} ${won(bill.summary.body_total_krw)}${L("B09_Sheet_Won")}`,
|
||||
),
|
||||
);
|
||||
if (bill.summary.unconfirmed_count > 0)
|
||||
bar.append(unconfirmedBadge(bill.summary.unconfirmed_count));
|
||||
ctx.body.append(bar);
|
||||
|
||||
const { wrap, tbody } = sheetTable(
|
||||
sheetHead([
|
||||
L("B09_Sheet_Col_ItemNo"),
|
||||
L("B09_Sheet_Col_Name"),
|
||||
L("B09_Sheet_Col_Spec"),
|
||||
L("B09_Sheet_Col_Quantity"),
|
||||
L("B09_Sheet_Col_Unit"),
|
||||
]),
|
||||
);
|
||||
drawRows(tbody, bill, ctx, redraw);
|
||||
const sum = el("tr", "is-sum");
|
||||
sum.append(el("td"), el("td", "", L("B09_Sheet_BodyTotal")), el("td"), el("td"), el("td"));
|
||||
sum.append(...moneyCells(null, [bill.summary.body_total_krw, "", "", ""]), el("td"));
|
||||
tbody.append(sum);
|
||||
ctx.body.append(wrap);
|
||||
|
||||
// 금액을 못 세운 줄 — 0 으로 안 때우고 이름째 보임(서버 `missing`).
|
||||
if (bill.summary.missing.length > 0) {
|
||||
const details = el("details");
|
||||
details.append(
|
||||
el(
|
||||
"summary",
|
||||
"b09s-hint b09s-hint--warn",
|
||||
`${L("B09_Sheet_Missing")} ${bill.summary.missing.length}${L("B09_Sheet_Count")}`,
|
||||
),
|
||||
);
|
||||
for (const item of bill.summary.missing) {
|
||||
details.append(hint(`${item.name} — ${item.reason}`));
|
||||
}
|
||||
ctx.body.append(details);
|
||||
}
|
||||
for (const note of bill.summary.notes) ctx.body.append(hint(note));
|
||||
}
|
||||
|
||||
export const billTab: B09Tab = {
|
||||
key: "boq",
|
||||
label: () => L("B09_Estimation_Tab_Boq"),
|
||||
render(ctx) {
|
||||
if (!ctx.projectId) {
|
||||
ctx.body.append(hint(L("B09_Sheet_NoProject")));
|
||||
return;
|
||||
}
|
||||
const projectId = ctx.projectId;
|
||||
const show = (force: boolean): void => {
|
||||
ctx.body.replaceChildren(hint(L("B09_Sheet_Loading")));
|
||||
loadBill(projectId, force)
|
||||
.then((bill) => {
|
||||
ctx.body.replaceChildren();
|
||||
drawBill(ctx, bill, () => show(true));
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
|
||||
showToast(L("B09_Sheet_LoadFailed"), "error");
|
||||
});
|
||||
};
|
||||
show(false);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tab_Legacy.ts
|
||||
* 옛 B09 화면의 탭을 새 틀에 **그대로 이어 붙이는** 자리 (PLAN 12장 · 2026-09-14 브레인 판정)
|
||||
*
|
||||
* - 원가계산서(→ 랩탑_메인 새 탭 파일) · 중기 · 관급·사급 · 기초자료(계수 고르개 = 계산 입력) ·
|
||||
* 설계서 구성 · 산출기초 — 새 탭 파일이 서면 틀 등록 한 줄을 바꾸고, 다 바뀌면 이 파일과 옛 파일을 지움.
|
||||
* - 옛 화면 한 벌을 처음 고를 때 한 번 세우고 탭끼리 나눠 씀(옛 상태·캐시 그대로).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { ui_locales } from "@ui/ui_template_locale";
|
||||
import type { B09Tab } from "./B09_Estimation_UI_Shell_Types";
|
||||
import { L } from "./B09_Estimation_UI_Sheet";
|
||||
import { createLegacyEstimation, type LegacyEstimation } from "./B09_Estimation_UI_Page";
|
||||
|
||||
let legacy: LegacyEstimation | null = null;
|
||||
|
||||
export function legacyTab(key: string, labelKey: keyof typeof ui_locales): B09Tab {
|
||||
return {
|
||||
key,
|
||||
label: () => L(labelKey),
|
||||
render(ctx) {
|
||||
legacy ??= createLegacyEstimation(ctx.root);
|
||||
legacy.main.classList.add("b09s-legacy");
|
||||
ctx.body.append(legacy.main);
|
||||
// 옛 좌측 칸은 원가계산서 입력 — 그 탭에서만 보임.
|
||||
if (key === "cost_sheet") ctx.panel.append(legacy.panel);
|
||||
legacy.select(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tab_PriceBasis.ts
|
||||
* B09 단가산출근거 탭 — 목록표 + 단가산출근거 (STmate `단가산출근거목록표` · `wM_Edit_San` 보기 · PLAN 12장 1차)
|
||||
*
|
||||
* - 목록표 = 내역 일위대가가 품은 D 만, **내역에 처음 쓰인 차례**로 「산근 N호표」(내역 비고 「단산 N」과 한 번호).
|
||||
* - 본표 줄의 비고에 Q 식·품셈 근거를 **글자 그대로** 보임(서버 줄 `note`).
|
||||
* - 「부르는 호표」 칸의 글을 누르면 그 일위대가표로 되돌아 들어감.
|
||||
* - Q 식 편집은 2차(브레인 판정).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
|
||||
import { renderDetail } from "./B09_Estimation_UI_Detail";
|
||||
import { L, el, hint, linkButton, sheetTable } from "./B09_Estimation_UI_Sheet";
|
||||
import { listHead, listRow } from "./B09_Estimation_UI_Tab_UnitPrice";
|
||||
import { loadBill, type BillDto } from "./B09_Estimation_UI_Store";
|
||||
|
||||
let selected = "";
|
||||
|
||||
function draw(ctx: B09TabContext, bill: BillDto): void {
|
||||
const entries = bill.price_basis.entries;
|
||||
const page = el("div", "b09s-split");
|
||||
const { wrap, tbody } = sheetTable(listHead(L("B09_Sheet_Col_Basis")));
|
||||
const detail = el("div", "b09s-split");
|
||||
for (const entry of entries) {
|
||||
const row = {
|
||||
...entry,
|
||||
label: `${L("B09_Sheet_Basis_Long")} ${entry.number}${L("B09_Sheet_Basis_Suffix")}`,
|
||||
};
|
||||
const tr = listRow(row, entry.unit_price_krw ?? "", entry.code === selected);
|
||||
const note = tr.lastElementChild as HTMLElement;
|
||||
for (const code of entry.unit_price_codes ?? []) {
|
||||
const sheet = bill.unit_price_sheet.entries.find((item) => item.code === code);
|
||||
if (sheet) note.append(linkButton(sheet.label, () => ctx.open("unit_price", code)));
|
||||
}
|
||||
tr.addEventListener("click", () => {
|
||||
selected = entry.code;
|
||||
ctx.body.replaceChildren();
|
||||
draw(ctx, bill);
|
||||
});
|
||||
tbody.append(tr);
|
||||
}
|
||||
page.append(el("div", "b09s-title", L("B09_Sheet_BasisList")), wrap);
|
||||
if (entries.length === 0) page.append(hint(L("B09_Sheet_EmptyList")));
|
||||
page.append(detail);
|
||||
ctx.body.append(page);
|
||||
if (!selected) {
|
||||
detail.append(hint(L("B09_Sheet_PickSheet")));
|
||||
return;
|
||||
}
|
||||
const picked = entries.find((entry) => entry.code === selected);
|
||||
const label = picked
|
||||
? `${L("B09_Sheet_Basis_Long")} ${picked.number}${L("B09_Sheet_Basis_Suffix")}`
|
||||
: selected;
|
||||
renderDetail(ctx, detail, "price_basis", selected, label);
|
||||
}
|
||||
|
||||
export const priceBasisTab: B09Tab = {
|
||||
key: "price_basis",
|
||||
label: () => L("B09_Estimation_Tab_PriceBasis"),
|
||||
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,126 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tab_UnitPrice.ts
|
||||
* B09 일위대가 탭 — 목록표 + 일위대가표 (STmate `일위대가목록표` · `wM_Edit_iLWi` 보기 · PLAN 12장 1차)
|
||||
*
|
||||
* - 목록표 = 내역에 쓰인 호표만, **내역에 처음 쓰인 차례**로 「제 N 호표」(번호는 서버가 낼 때마다 매김).
|
||||
* - 줄을 누르면 아래에 그 호표의 일위대가표 — 호표 안 줄(하위 호표·단산·중기)을 누르면 더 들어감.
|
||||
* - 구조물도 호표는 B08 구조물도 탭의 일위대가 표가 본표 — 여기선 금액·안내만.
|
||||
* - 편집(구성행 수정)은 2차 — B09 에 수정 저장 자리가 아직 없음(브레인 판정).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
|
||||
import { renderDetail } from "./B09_Estimation_UI_Detail";
|
||||
import {
|
||||
L,
|
||||
el,
|
||||
hint,
|
||||
numberCell,
|
||||
sheetTable,
|
||||
unconfirmedBadge,
|
||||
won,
|
||||
} from "./B09_Estimation_UI_Sheet";
|
||||
import { loadBill, type BillDto, type SheetEntryDto } from "./B09_Estimation_UI_Store";
|
||||
|
||||
let selected = "";
|
||||
|
||||
/** 목록표 머리 — 호표 · 명칭 · 규격 · 단위 · 합계 · 노무비 · 재료비 · 경비 · 비고(실무 목록표). */
|
||||
export function listHead(first: string): HTMLElement {
|
||||
const thead = el("thead");
|
||||
const tr = el("tr");
|
||||
for (const label of [
|
||||
first,
|
||||
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"),
|
||||
]) {
|
||||
tr.append(el("th", "", label));
|
||||
}
|
||||
thead.append(tr);
|
||||
return thead;
|
||||
}
|
||||
|
||||
export function listRow(entry: SheetEntryDto, total: string, isSelected: boolean): HTMLElement {
|
||||
const tr = el("tr", `is-clickable${isSelected ? " is-selected" : ""}`);
|
||||
tr.append(
|
||||
el("td", "", entry.label),
|
||||
el("td", "", entry.name),
|
||||
el("td", "", entry.spec),
|
||||
el("td", "", entry.unit),
|
||||
numberCell(won(total)),
|
||||
numberCell(won(entry.labor_krw)),
|
||||
numberCell(won(entry.material_krw)),
|
||||
numberCell(won(entry.expense_krw)),
|
||||
);
|
||||
const note = el("td", "b09s-note");
|
||||
if (entry.unconfirmed) {
|
||||
note.append(unconfirmedBadge(entry.unconfirmed));
|
||||
tr.classList.add("is-manual");
|
||||
}
|
||||
tr.append(note);
|
||||
return tr;
|
||||
}
|
||||
|
||||
function labelFor(bill: BillDto, code: string): string {
|
||||
const entry = bill.unit_price_sheet.entries.find((item) => item.code === code);
|
||||
if (entry) return entry.label;
|
||||
return code.startsWith("X-") ? `${L("B09_Sheet_Machine")} ${code.slice(2)}` : code;
|
||||
}
|
||||
|
||||
function draw(ctx: B09TabContext, bill: BillDto): void {
|
||||
const entries = bill.unit_price_sheet.entries;
|
||||
const page = el("div", "b09s-split");
|
||||
const { wrap, tbody } = sheetTable(listHead(L("B09_Sheet_Col_Sheet")));
|
||||
const detail = el("div", "b09s-split");
|
||||
const select = (code: string): void => {
|
||||
selected = code;
|
||||
ctx.body.replaceChildren();
|
||||
draw(ctx, bill);
|
||||
};
|
||||
for (const entry of entries) {
|
||||
const tr = listRow(entry, entry.total_krw ?? "", entry.code === selected);
|
||||
tr.addEventListener("click", () => select(entry.code));
|
||||
tbody.append(tr);
|
||||
}
|
||||
page.append(el("div", "b09s-title", L("B09_Sheet_UnitPriceList")), wrap);
|
||||
if (entries.length === 0) page.append(hint(L("B09_Sheet_EmptyList")));
|
||||
page.append(detail);
|
||||
ctx.body.append(page);
|
||||
|
||||
if (!selected) {
|
||||
detail.append(hint(L("B09_Sheet_PickSheet")));
|
||||
return;
|
||||
}
|
||||
const picked = entries.find((entry) => entry.code === selected);
|
||||
if (picked?.source === "structure") {
|
||||
detail.append(el("div", "b09s-title", `${picked.label} ${picked.name} · ${picked.spec}`));
|
||||
detail.append(hint(L("B09_Sheet_StructureSheet")));
|
||||
return;
|
||||
}
|
||||
renderDetail(ctx, detail, "unit_price", selected, labelFor(bill, selected));
|
||||
}
|
||||
|
||||
export const unitPriceTab: B09Tab = {
|
||||
key: "unit_price",
|
||||
label: () => L("B09_Estimation_Tab_UnitPrice"),
|
||||
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,115 @@
|
||||
"""B09 원가계산 — 일위대가표 **호표 번호** (PLAN 12장 · 실무 `일위대가목록표`).
|
||||
|
||||
실무 내역서는 일위대가를 「제 N 호표」로 부르고, 목록표가 번호마다 명칭·규격·성분 금액을 보인다.
|
||||
|
||||
⚠ **번호는 저장하지 않음**(2026-09-14 브레인 판정) — 내역이 바뀌면 번호도 바뀌므로 낼 때마다 매김.
|
||||
단산 번호(`B09_Estimation_PriceBasis`)와 같은 규칙: **내역에 처음 쓰인 차례**.
|
||||
⚠ 내역 줄이 부르지 않고 **다른 일위대가 안에서만** 불리는 호표(찰쌓기 안의 모르타르 배합 따위)는
|
||||
부르는 호표 **바로 뒤**에 번호가 붙음(줄 차례대로 깊이 우선).
|
||||
⚠ 구조물도 호표(`B-AX-ST-…#키`)는 단가표 제목이 아니라 B08 일위대가 표로 섬 — 금액은 그 표 값,
|
||||
안의 하위 호표 번호는 아직 안 매김(구조물도 탭이 그 표를 보임).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBook, PriceKind
|
||||
|
||||
STRUCTURE_SOURCE = "structure"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UnitPriceSheetEntry:
|
||||
number: int
|
||||
code: str
|
||||
name: str
|
||||
spec: str
|
||||
unit: str
|
||||
money: Money3
|
||||
#: 「book」 단가표 제목 · 「structure」 구조물도 일위대가 표.
|
||||
source: str = "book"
|
||||
unconfirmed: int = 0
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return f"제 {self.number} 호표"
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"number": self.number,
|
||||
"label": self.label,
|
||||
"code": self.code,
|
||||
"name": self.name,
|
||||
"spec": self.spec,
|
||||
"unit": self.unit,
|
||||
"material_krw": str(self.money.material),
|
||||
"labor_krw": str(self.money.labor),
|
||||
"expense_krw": str(self.money.expense),
|
||||
"total_krw": str(self.money.total),
|
||||
"source": self.source,
|
||||
"unconfirmed": self.unconfirmed,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class UnitPriceSheet:
|
||||
entries: list[UnitPriceSheetEntry] = field(default_factory=list)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"entries": [entry.as_dict() for entry in self.entries]}
|
||||
|
||||
|
||||
def _nested(book: PriceBook, code: str, seen: tuple[str, ...]) -> list[str]:
|
||||
"""호표 안에서 부르는 일위대가 — 줄 차례대로 깊이 우선."""
|
||||
found: list[str] = []
|
||||
for detail in book.details.get(code, []):
|
||||
ref = detail.ref_code
|
||||
title = book.titles.get(ref)
|
||||
if title is None or title.kind is not PriceKind.UNIT_PRICE or ref in seen:
|
||||
continue
|
||||
found.append(ref)
|
||||
found.extend(_nested(book, ref, (*seen, ref)))
|
||||
return found
|
||||
|
||||
|
||||
def build_unit_price_sheet(
|
||||
rows: list[Any],
|
||||
book: PriceBook,
|
||||
structure_prices: dict[str, dict[str, Any]] | None = None,
|
||||
) -> UnitPriceSheet:
|
||||
"""내역 줄(`BillRow`) 차례 → 호표 번호 한 벌. 같은 호표를 두 줄이 불러도 한 번만."""
|
||||
sheet = UnitPriceSheet()
|
||||
numbered: set[str] = set()
|
||||
|
||||
def add(code: str, **values: Any) -> None:
|
||||
numbered.add(code)
|
||||
sheet.entries.append(
|
||||
UnitPriceSheetEntry(number=len(sheet.entries) + 1, code=code, **values)
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
code = row.price_code
|
||||
if not code or code in numbered:
|
||||
continue
|
||||
structure = (structure_prices or {}).get(code)
|
||||
if structure is not None:
|
||||
add(
|
||||
code,
|
||||
name=row.name,
|
||||
spec=row.spec,
|
||||
unit=row.unit,
|
||||
money=structure["money"],
|
||||
source=STRUCTURE_SOURCE,
|
||||
unconfirmed=int(structure.get("unconfirmed") or 0),
|
||||
)
|
||||
continue
|
||||
if code not in book.titles:
|
||||
continue
|
||||
for ref in (code, *_nested(book, code, (code,))):
|
||||
if ref in numbered:
|
||||
continue
|
||||
title = book.title(ref)
|
||||
add(ref, name=title.name, spec=title.spec, unit=title.unit, money=book.resolve(ref))
|
||||
return sheet
|
||||
@@ -14,7 +14,7 @@ from decimal import Decimal
|
||||
|
||||
from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price
|
||||
from B09_Estimation.B09_Estimation_MaterialCatalog import catalog_summary, load_material_catalog
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
|
||||
from B09_Estimation.B09_Estimation_PriceBook import TRUNCATED_KINDS, PriceKind
|
||||
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import (
|
||||
DRILLABLE_KINDS,
|
||||
@@ -202,7 +202,7 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
|
||||
"quantity": str(detail.percent_of_labor),
|
||||
"material": "0",
|
||||
"labor": "0",
|
||||
"expense": str(amount),
|
||||
"expense": _money_text(amount),
|
||||
"total": _money_text(amount),
|
||||
"source": "품셈 [주]",
|
||||
"drillable": False,
|
||||
@@ -233,7 +233,7 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
|
||||
"spec": f"{'주연료비' if misc else '주재료비'}의 {detail.percent_of_material}%",
|
||||
"unit": "%",
|
||||
"quantity": str(detail.percent_of_material),
|
||||
"material": str(amount),
|
||||
"material": _money_text(amount),
|
||||
"labor": "0",
|
||||
"expense": "0",
|
||||
"total": _money_text(amount),
|
||||
@@ -285,16 +285,22 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
|
||||
# ㉤ 열 방향 검사 — 같은 성분을 두 층에서 세면 여기서 멈춘다.
|
||||
# 행 방향(`TC=NC+GC+JC`)만으로는 안 잡히는 어긋남이다.
|
||||
check_column_sums(rows=rows, totals=summed, label=f"{title.name} 본표")
|
||||
# 합계 줄 — 호표 안에서 자르는 층(X·D·B)은 **성분 소계 원 미만 절사** 값(실무 표 합계 줄 · 명세 7장).
|
||||
shown = (
|
||||
{"material": money.material, "labor": money.labor, "expense": money.expense}
|
||||
if title.kind in TRUNCATED_KINDS
|
||||
else {key: summed[key] for key in ("material", "labor", "expense")}
|
||||
)
|
||||
return {
|
||||
"code": code,
|
||||
"name": title.name,
|
||||
"spec": title.spec,
|
||||
"unit": title.unit,
|
||||
"kind": title.kind.value,
|
||||
"material": str(summed["material"]),
|
||||
"labor": str(summed["labor"]),
|
||||
"expense": str(summed["expense"]),
|
||||
"total": str(summed["total"]),
|
||||
"material": str(shown["material"]),
|
||||
"labor": str(shown["labor"]),
|
||||
"expense": str(shown["expense"]),
|
||||
"total": str(sum(shown.values(), Decimal(0))),
|
||||
# TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다.
|
||||
"sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"],
|
||||
# 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""B09 내역서 화면 자료 — 화면이 곱하거나 더하지 않게 서버가 싣는 칸 (PLAN 12장 1차).
|
||||
|
||||
겨누는 것
|
||||
① 내역 줄 성분 단가 × 수량 = 성분 금액(성분마다 절사) — 화면은 실린 값만 찍음
|
||||
② 머리글 줄 금액 = 아래 줄 금액의 합(성분마다) · 본체 합계엔 안 들어감
|
||||
③ 일위대가 호표 번호 — 내역에 처음 쓰인 차례 · 같은 호표는 한 번 · 안에서 부르는 호표는 뒤에
|
||||
④ 단산 목록 — 부르는 일위대가 전부 · 성분 금액
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line, build_bill
|
||||
from B09_Estimation.B09_Estimation_PriceBook import (
|
||||
Money3,
|
||||
PriceBook,
|
||||
PriceDetail,
|
||||
PriceKind,
|
||||
PriceTitle,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import _slots, build_unit_prices
|
||||
from B09_Estimation.B09_Estimation_UnitPriceSheet import build_unit_price_sheet
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _bill():
|
||||
payload = {
|
||||
"work_items": [
|
||||
{"work_item_code": "FP-09-03-02", "name": "토사깍기", "unit": "㎥", "quantity": 123.4},
|
||||
{"work_item_code": "FP-09-12-01", "name": "측구터파기", "unit": "㎥", "quantity": 7},
|
||||
{"work_item_code": "FP-09-03-02", "name": "토사깍기", "unit": "㎥", "quantity": 5},
|
||||
],
|
||||
"materials": [],
|
||||
}
|
||||
return build_bill(payload, build=build_unit_prices())
|
||||
|
||||
|
||||
def test_줄_성분_단가와_금액이_실린다() -> None:
|
||||
priced = [r for r in _bill().rows if not r.is_group and r.amount_krw is not None]
|
||||
assert priced
|
||||
for row in priced:
|
||||
unit = Money3(row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw)
|
||||
line = bill_line(unit, row.quantity)
|
||||
assert (line.material, line.labor, line.expense) == (
|
||||
row.material_krw,
|
||||
row.labor_krw,
|
||||
row.expense_krw,
|
||||
)
|
||||
assert row.amount_krw == line.total
|
||||
body = row.as_dict()
|
||||
assert body["price_code"] == row.price_code and body["unit_labor_krw"] is not None
|
||||
|
||||
|
||||
def test_머리글_줄은_아래_줄_합이고_본체엔_안_든다() -> None:
|
||||
bill = _bill()
|
||||
detail_total = sum(r.amount_krw or 0 for r in bill.rows if not r.is_group)
|
||||
assert bill.body_total_krw == detail_total
|
||||
for group in (r for r in bill.rows if r.is_group):
|
||||
children = [
|
||||
r
|
||||
for r in bill.rows
|
||||
if not r.is_group and r.item_no.startswith(f"{group.item_no}-") and r.amount_krw
|
||||
]
|
||||
assert group.amount_krw == sum(r.amount_krw for r in children)
|
||||
assert group.labor_krw == sum(r.labor_krw for r in children)
|
||||
|
||||
|
||||
def test_호표_번호는_처음_쓰인_차례이고_안에서_부르는_호표는_뒤에() -> None:
|
||||
book = PriceBook()
|
||||
book.add_title(PriceTitle("L", PriceKind.LABOR, "보통인부", slots=_slots(Decimal(100000))))
|
||||
for code in ("B-A", "B-B", "B-C"):
|
||||
book.add_title(PriceTitle(code, PriceKind.UNIT_PRICE, code))
|
||||
book.add_detail(PriceDetail("B-C", "L", Decimal(1)))
|
||||
book.add_detail(PriceDetail("B-A", "B-C", Decimal(2))) # A 안에서 C 를 부름
|
||||
book.add_detail(PriceDetail("B-B", "L", Decimal(1)))
|
||||
|
||||
class Row:
|
||||
def __init__(self, code: str) -> None:
|
||||
self.price_code, self.name, self.spec, self.unit = code, code, "", "㎥"
|
||||
|
||||
sheet = build_unit_price_sheet([Row("B-B"), Row("B-A"), Row("B-B"), Row("")], book)
|
||||
assert [(e.number, e.code) for e in sheet.entries] == [(1, "B-B"), (2, "B-A"), (3, "B-C")]
|
||||
assert sheet.entries[1].as_dict()["label"] == "제 2 호표"
|
||||
assert sheet.entries[1].money.labor == 200000
|
||||
|
||||
|
||||
def test_내역에_호표_목록과_단산_목록이_함께_실린다() -> None:
|
||||
bill = _bill()
|
||||
codes = [e.code for e in bill.unit_price_sheet.entries]
|
||||
used = list(dict.fromkeys(r.price_code for r in bill.rows if r.price_code))
|
||||
assert [c for c in codes if c in used] == used # 안에서 부르는 호표가 끼어도 차례는 그대로
|
||||
for entry in bill.price_basis.entries:
|
||||
body = entry.as_dict()
|
||||
assert body["unit_price_codes"] and Money3(
|
||||
*(int(body[k]) for k in ("material_krw", "labor_krw", "expense_krw"))
|
||||
).total == int(body["unit_price_krw"].split(".")[0])
|
||||
@@ -23,6 +23,7 @@ import { ui_locales_common } from "./ui_template_locale_common";
|
||||
import { ui_locales_a } from "./ui_template_locale_a";
|
||||
import { ui_locales_b1 } from "./ui_template_locale_b1";
|
||||
import { ui_locales_b2 } from "./ui_template_locale_b2";
|
||||
import { ui_locales_b3 } from "./ui_template_locale_b3";
|
||||
|
||||
/** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */
|
||||
export const LANGUAGES = ["ko", "en"] as const;
|
||||
@@ -56,6 +57,7 @@ export const ui_locales = {
|
||||
...ui_locales_a,
|
||||
...ui_locales_b1,
|
||||
...ui_locales_b2,
|
||||
...ui_locales_b3,
|
||||
} as const;
|
||||
|
||||
export type LocaleKey = keyof typeof ui_locales;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/* =============================================================================
|
||||
* ui_template_locale_b3.ts
|
||||
* B 그룹 사전 3 — B09 실무 서식 화면(설계내역서·일위대가표·단가산출근거, PLAN 12장)
|
||||
*
|
||||
* b2 가 700줄을 넘어 새 벌로 뗌. 신규 문구는 이 파일 최하단에 추가.
|
||||
* ========================================================================== */
|
||||
|
||||
export const ui_locales_b3 = {
|
||||
/* --- B09 실무 서식 표 --- */
|
||||
B09_Sheet_Col_ItemNo: ["공종", "Item"],
|
||||
B09_Sheet_Col_Name: ["명칭", "Name"],
|
||||
B09_Sheet_Col_Spec: ["규격", "Spec"],
|
||||
B09_Sheet_Col_Quantity: ["수량", "Qty"],
|
||||
B09_Sheet_Col_Unit: ["단위", "Unit"],
|
||||
B09_Sheet_Col_Total: ["합계", "Total"],
|
||||
B09_Sheet_Col_Labor: ["노무비", "Labor"],
|
||||
B09_Sheet_Col_Material: ["재료비", "Material"],
|
||||
B09_Sheet_Col_Expense: ["경비", "Expense"],
|
||||
B09_Sheet_Col_UnitPrice: ["단가", "Unit price"],
|
||||
B09_Sheet_Col_Amount: ["금액", "Amount"],
|
||||
B09_Sheet_Col_Note: ["비고", "Note"],
|
||||
B09_Sheet_Col_Sheet: ["호표", "Sheet"],
|
||||
B09_Sheet_Col_Basis: ["산근", "Basis"],
|
||||
B09_Sheet_Sum: ["합계", "Total"],
|
||||
B09_Sheet_BodyTotal: ["내역 본체 합계", "Bill body total"],
|
||||
B09_Sheet_Won: ["원", " KRW"],
|
||||
B09_Sheet_Count: ["건", ""],
|
||||
B09_Sheet_Unconfirmed: ["미확정", "Unconfirmed"],
|
||||
B09_Sheet_Missing: ["금액을 못 세운 줄", "Rows without a price"],
|
||||
B09_Sheet_Reload: ["다시 불러오기", "Reload"],
|
||||
B09_Sheet_Level: ["보이는 레벨", "Show levels"],
|
||||
B09_Sheet_Level_All: ["모두", "All"],
|
||||
B09_Sheet_Level_Upto: ["레벨까지", " level(s)"],
|
||||
B09_Sheet_Loading: ["불러오는 중…", "Loading…"],
|
||||
B09_Sheet_LoadFailed: ["불러오지 못했습니다.", "Could not load."],
|
||||
B09_Sheet_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."],
|
||||
B09_Sheet_Open_UnitPrice: [
|
||||
"누르면 이 줄의 일위대가표로 들어갑니다",
|
||||
"Click to open this row's unit price sheet",
|
||||
],
|
||||
B09_Sheet_Drill: ["누르면 이 줄의 호표로 들어갑니다", "Click to open this row's sheet"],
|
||||
B09_Sheet_Basis_Short: ["단산", "Basis"],
|
||||
B09_Sheet_Basis_Long: ["산근", "Basis"],
|
||||
B09_Sheet_Basis_Suffix: ["호표", ""],
|
||||
B09_Sheet_Machine: ["시간당 중기", "Machine hourly"],
|
||||
B09_Sheet_UnitPriceList: ["일위대가 목록표", "Unit price list"],
|
||||
B09_Sheet_BasisList: ["단가산출근거 목록표", "Price basis list"],
|
||||
B09_Sheet_EmptyList: [
|
||||
"내역에 쓰인 호표가 없습니다 — 설계내역서가 먼저 서야 합니다.",
|
||||
"No sheets used by the bill yet.",
|
||||
],
|
||||
B09_Sheet_PickSheet: [
|
||||
"목록표에서 호표를 누르면 본표가 아래에 섭니다.",
|
||||
"Click a sheet above to see its table.",
|
||||
],
|
||||
B09_Sheet_StructureSheet: [
|
||||
"구조물도 일위대가는 수량산출 › 구조물도 탭의 일위대가 표가 본표입니다.",
|
||||
"Structure unit prices are shown on the structure drawing tab.",
|
||||
],
|
||||
} as const;
|
||||
Reference in New Issue
Block a user