feat(B09): ③ 단가산출서(D층) 신설 — 내역 줄에서 검산 경로가 이어짐

실무 내역서는 줄마다 비고에 「단산 46 참조」를 적고 그 산출서를 펴서 검산함(8-13).
STC 실측도 `D` 가 `B`(일위대가)를 참조하는 한 층 위였음(9-3)

- `PriceBook` 의 「제목 + 상세」 한 쌍에 `kind=PRICE_BASIS` 만 얹음 —
  표를 세 벌 만들지 않음. 화면도 일위대가 탭과 **같은 2단 모양**
- 번호는 **코드에 안 박음** — 실무 참조번호는 그 내역서 안의 차례라
  프로젝트마다 다름. 코드는 공종을 가리키고 번호는 조판할 때 매김
- 같은 일위대가가 두 줄에 쓰이면 **산출서는 한 장**, 두 줄이 같은 번호를 가리킴
- 내역 줄 비고에 「단산 N 참조」를 달음
- 지금은 일위대가를 그대로 한 줄로 참조 — 할증·기타 비용이 붙을 자리를 미리 열어 둠
- `GET …/estimation/price-basis/{code}` 로 본표 조회

화면 실측: 내역 줄 「2-2-1 측구터파기 488,926 / 단산 1 참조」,
  산출서 탭에 1장(토사 FP-09-12-01 ㎥ 5,288.6), 눌러 본표·참조 코드 확인

검증: pytest 195 통과, tsc 0건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 04:51:34 +09:00
co-authored by Claude Opus 5
parent 3a3374d0ff
commit d8cbd48baf
5 changed files with 286 additions and 1 deletions
+78 -1
View File
@@ -549,7 +549,7 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [
["cost_sheet", "B09_Estimation_Tab_CostSheet", true],
["boq", "B09_Estimation_Tab_Boq", true],
["unit_price", "B09_Estimation_Tab_UnitPrice", true],
["price_basis", "B09_Estimation_Tab_PriceBasis", false],
["price_basis", "B09_Estimation_Tab_PriceBasis", true],
["machine", "B09_Estimation_Tab_Machine", false],
["duration", "B09_Estimation_Tab_Duration", false],
["supply", "B09_Estimation_Tab_Supply", false],
@@ -660,6 +660,17 @@ interface BillRowDto {
note: string;
}
interface PriceBasisEntryDto {
number: number;
label: string;
code: string;
name: string;
spec: string;
unit: string;
unit_price_krw: string;
ref_code: string;
}
interface BillDto {
rows: BillRowDto[];
excluded: BillRowDto[];
@@ -677,6 +688,7 @@ interface BillDto {
}>;
notes: string[];
};
price_basis: { entries: PriceBasisEntryDto[] };
}
/**
@@ -727,6 +739,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
let unitPriceDetail: UnitPriceDetailDto | null = null;
let selectedUnitPrice: string | null = null;
let bill: BillDto | null = null;
let priceBasis: string | null = null;
const main = document.createElement("div");
main.className = "b09-main";
@@ -914,6 +927,66 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
}
};
/** ③ 단가산출서 — 내역 줄의 단가가 **어떻게 나왔는지** 보이는 표(실무 「단산 46 참조」). */
const drawPriceBasisTab = (): void => {
if (!bill) {
const empty = document.createElement("div");
empty.className = "b09-empty";
empty.textContent = L("B09_Estimation_PB_Empty");
body.append(empty);
return;
}
const entries = bill.price_basis?.entries ?? [];
// 일위대가 탭과 **같은 모양**으로 — 목록 위, 본표 아래 2단(PLAN 9-3 「표를 세 벌
// 만들지 않는다」와 같은 뜻: 화면도 한 벌로 쓴다).
const split = document.createElement("div");
const list = document.createElement("table");
list.className = "b09-sheet b09-up-list";
list.innerHTML = "<thead><tr><th>번호</th><th>공종</th><th>단위</th><th>단가</th></tr></thead>";
const tbody = document.createElement("tbody");
for (const entry of entries) {
const tr = document.createElement("tr");
for (const text of [
String(entry.number),
`${entry.name} ${entry.spec}`.trim(),
entry.unit,
entry.unit_price_krw,
]) {
const td = document.createElement("td");
td.textContent = text;
tr.append(td);
}
tr.style.cursor = "pointer";
if (entry.code === priceBasis) tr.style.fontWeight = "600";
tr.addEventListener("click", () => {
priceBasis = entry.code;
drawBody();
});
tbody.append(tr);
}
list.append(tbody);
split.append(list);
const picked = entries.find((entry) => entry.code === priceBasis) ?? null;
const panel = document.createElement("div");
panel.className = "b09-up-detail";
if (picked === null) {
panel.textContent = L("B09_Estimation_PB_Pick");
} else {
const head = document.createElement("div");
head.className = "b09-hint";
head.textContent = `${picked.label} — ${picked.name} ${picked.spec} (${picked.unit}) ${picked.unit_price_krw}`;
const ref = document.createElement("div");
ref.className = "b09-hint";
// 한 층 아래(일위대가)를 가리킨다 — 그 표는 일위대가 탭에서 그대로 본다.
ref.textContent = `${L("B09_Estimation_PB_Ref")}: ${picked.ref_code}`;
panel.append(head, ref);
}
split.append(panel);
body.append(split);
};
const drawBody = (): void => {
body.replaceChildren();
if (activeTab === "unit_price") {
@@ -924,6 +997,10 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
drawBoqTab();
return;
}
if (activeTab === "price_basis") {
drawPriceBasisTab();
return;
}
if (activeTab !== "cost_sheet") {
const empty = document.createElement("div");
empty.className = "b09-empty";