feat(B09): 예산내역서 화면 탭 신설 + ITEM NO. 겹침 수정

화면
- 「설계내역서」 탭을 켜고 [B08 수량 불러오기] 로 표를 세움.
  계층은 들여쓰기, 머리글 줄은 굵게
- 못 세운 줄은 0 으로 채우지 않고 **사유째** 표와 목록에 보임
  (「일위대가 없음」·「하위 공종에 있음(후보 2건)」·「밑수를 못 찾은 표」)
- 검산용 줄(보정량계·무대)은 수량만 보이고 금액칸이 빔
- ⚠ 「사급 자재 단가가 없어 자재비가 빠져 있음 — 지금 합계는 모자란 값」을
  합계 옆에 상시 표시

ITEM NO. 겹침 수정
- 같은 공종코드가 지반만 달리해 두 번 오면 번호가 겹치고 있었음
  (실물: 도자운반 토사/리핑암이 둘 다 `3-1`). 잎 줄은 번호를 재사용하지 않음

실측(공용 브라우저 5174, 운반이 실린 실물 자료):
  16줄 · 겹친 번호 0 · 합계 488,926원
  운반 4줄이 3-1~3-4 로 서고 무대 2줄은 검산줄로 빠짐 (㉡ 가 실물에서 돎)

검증: pytest 175 통과(신규 1), tsc 0건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 01:36:11 +09:00
co-authored by Claude Opus 5
parent a9749a7e91
commit 573d7bbae2
3 changed files with 236 additions and 234 deletions
@@ -314,8 +314,11 @@ def build_bill(
)
)
leaf = chain[-1]
item_no = emitted.get(leaf.code) or next_number(parent_no)
emitted[leaf.code] = item_no
# ⚠ **잎 줄은 번호를 재사용하지 않는다.** 같은 공종코드가 지반·규격만 달리해
# 두 번 올 수 있고(2026-09-08 실물: 도자운반 토사/리핑암 두 줄), 그때 번호를
# 물려주면 ITEM NO. 가 겹쳐 어느 줄인지 못 가린다. 머리글만 물려준다.
item_no = next_number(parent_no)
emitted.setdefault(leaf.code, item_no)
result.rows.append(_leaf_row(item_no, leaf, item, unit_prices, result))
# ── 2) 공종을 못 고른 줄 — 이름째 남긴다 ────────────────────────────────────
+170 -64
View File
@@ -14,18 +14,11 @@
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
showToast,
} from "@ui/ui_template_elements";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
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";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -234,10 +227,8 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement {
for (const line of sheet.lines) {
const tr = document.createElement("tr");
if (TOTAL_KEYS.has(line.key)) tr.classList.add("is-total");
if (line.note === L("B09_Estimation_Adopted"))
tr.classList.add("is-adopted");
if (line.note === L("B09_Estimation_NotAdopted"))
tr.classList.add("is-dropped");
if (line.note === L("B09_Estimation_Adopted")) tr.classList.add("is-adopted");
if (line.note === L("B09_Estimation_NotAdopted")) tr.classList.add("is-dropped");
const name = document.createElement("td");
name.className = "b09-left";
@@ -247,8 +238,7 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement {
amount.textContent = formatWon(line.amount_krw);
const rate = document.createElement("td");
rate.textContent =
line.rate_percent === null ? "" : `${line.rate_percent}%`;
rate.textContent = line.rate_percent === null ? "" : `${line.rate_percent}%`;
const basis = document.createElement("td");
basis.className = "b09-left";
@@ -394,13 +384,7 @@ function buildUnitPriceDetail(
unit.className = "b09-left";
unit.textContent = row.unit;
tr.append(name, spec, source, unit);
for (const value of [
row.quantity,
row.material,
row.labor,
row.expense,
row.total,
]) {
for (const value of [row.quantity, row.material, row.labor, row.expense, row.total]) {
const cell = document.createElement("td");
cell.textContent = formatWon(value);
tr.append(cell);
@@ -415,12 +399,7 @@ function buildUnitPriceDetail(
label.colSpan = 5;
label.textContent = L("B09_Estimation_Col_Total");
sum.append(label);
for (const value of [
detail.material,
detail.labor,
detail.expense,
detail.total,
]) {
for (const value of [detail.material, detail.labor, detail.expense, detail.total]) {
const cell = document.createElement("td");
cell.textContent = formatWon(value);
sum.append(cell);
@@ -548,12 +527,7 @@ function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void {
if (!sheet) return;
const rows: Array<[string, string]> = [
["적용일", sheet.rate_version.effective_date || "—"],
[
"지문",
sheet.rate_version.sha256
? `${sheet.rate_version.sha256.slice(0, 8)}`
: "—",
],
["지문", sheet.rate_version.sha256 ? `${sheet.rate_version.sha256.slice(0, 8)}` : "—"],
];
for (const [label, value] of rows) {
const row = document.createElement("div");
@@ -573,7 +547,7 @@ function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void {
const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [
["cost_sheet", "B09_Estimation_Tab_CostSheet", true],
["boq", "B09_Estimation_Tab_Boq", false],
["boq", "B09_Estimation_Tab_Boq", true],
["unit_price", "B09_Estimation_Tab_UnitPrice", true],
["price_basis", "B09_Estimation_Tab_PriceBasis", false],
["machine", "B09_Estimation_Tab_Machine", false],
@@ -582,10 +556,7 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [
["base_data", "B09_Estimation_Tab_BaseData", false],
];
function buildTabs(
active: string,
onSelect: (key: string) => void,
): HTMLElement {
function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement {
const bar = document.createElement("div");
bar.className = "b09-tabs";
for (const [key, labelKey, enabled] of TAB_KEYS) {
@@ -623,8 +594,7 @@ function parseQuantities(text: string): Record<string, string> {
}
function toRequestBody(form: CostFormState): Record<string, unknown> {
const num = (value: string): string =>
value.trim() === "" ? "0" : value.trim();
const num = (value: string): string => (value.trim() === "" ? "0" : value.trim());
const body: Record<string, unknown> = {
direct_material_krw: num(form.direct_material_krw),
direct_labor_krw: num(form.direct_labor_krw),
@@ -642,10 +612,7 @@ function toRequestBody(form: CostFormState): Record<string, unknown> {
return body;
}
async function fetchCostSheet(
projectId: string,
form: CostFormState,
): Promise<CostSheetDto> {
async function fetchCostSheet(projectId: string, form: CostFormState): Promise<CostSheetDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost`,
{
@@ -655,43 +622,72 @@ async function fetchCostSheet(
body: JSON.stringify(toRequestBody(form)),
},
);
if (!response.ok)
throw new Error(`estimation cost failed: ${response.status}`);
if (!response.ok) throw new Error(`estimation cost failed: ${response.status}`);
return (await response.json()) as CostSheetDto;
}
async function fetchUnitPriceList(
projectId: string,
): Promise<UnitPriceListDto> {
async function fetchUnitPriceList(projectId: string): Promise<UnitPriceListDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices`,
{ credentials: "include" },
);
if (!response.ok)
throw new Error(`unit price list failed: ${response.status}`);
if (!response.ok) throw new Error(`unit price list failed: ${response.status}`);
return (await response.json()) as UnitPriceListDto;
}
async function fetchUnitPriceDetail(
projectId: string,
code: string,
): Promise<UnitPriceDetailDto> {
async function fetchUnitPriceDetail(projectId: string, code: string): Promise<UnitPriceDetailDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices/${encodeURIComponent(code)}`,
{ credentials: "include" },
);
if (!response.ok)
throw new Error(`unit price detail failed: ${response.status}`);
if (!response.ok) throw new Error(`unit price detail failed: ${response.status}`);
return (await response.json()) as UnitPriceDetailDto;
}
/** ④ 예산내역서 한 줄. 금액이 `null` 이면 **못 세운 것**이지 0 이 아니다. */
interface BillRowDto {
item_no: string;
level: number;
code: string | null;
name: string;
spec: string;
unit: string;
quantity: string | null;
unit_price_krw: string | null;
amount_krw: string | null;
is_group: boolean;
in_bill: boolean;
note: string;
}
interface BillDto {
rows: BillRowDto[];
excluded: BillRowDto[];
materials: BillRowDto[];
summary: {
rows: number;
detail_rows: number;
body_total_krw: string;
missing: Array<{ name: string; reason: string; unit?: string; quantity?: string }>;
notes: string[];
};
}
async function fetchBill(projectId: string): Promise<BillDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/bill`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`estimation bill failed: ${response.status}`);
return (await response.json()) as BillDto;
}
async function confirmEstimationStage(projectId: string): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/confirm`,
{ method: "POST", credentials: "include" },
);
if (!response.ok)
throw new Error(`estimation confirm failed: ${response.status}`);
if (!response.ok) throw new Error(`estimation confirm failed: ${response.status}`);
}
/* -----------------------------------------------------------------------------
@@ -707,6 +703,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
let unitPriceList: UnitPriceListDto | null = null;
let unitPriceDetail: UnitPriceDetailDto | null = null;
let selectedUnitPrice: string | null = null;
let bill: BillDto | null = null;
const main = document.createElement("div");
main.className = "b09-main";
@@ -762,12 +759,123 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
}
};
/** ④ 예산내역서 — B08 수량에 단가를 붙인 표. 못 세운 줄은 **그대로 보인다**. */
const drawBoqTab = (): void => {
if (!bill) {
if (!projectId) {
const empty = document.createElement("div");
empty.className = "b09-empty";
empty.textContent = L("B09_Estimation_Boq_Failed");
body.append(empty);
return;
}
const load = document.createElement("button");
load.type = "button";
load.className = "b09-btn";
load.textContent = L("B09_Estimation_Boq_Load");
load.addEventListener("click", () => {
void (async () => {
try {
bill = await fetchBill(projectId);
} catch {
bill = null;
window.alert(L("B09_Estimation_Boq_Failed"));
}
drawBody();
})();
});
body.append(load);
return;
}
const table = document.createElement("table");
table.className = "b09-sheet";
const head = document.createElement("thead");
head.innerHTML =
"<tr><th>No.</th><th>공종</th><th>규격</th><th>단위</th>" +
"<th>수량</th><th>단가</th><th>금액</th><th>비고</th></tr>";
const tbody = document.createElement("tbody");
for (const row of bill.rows) {
const tr = document.createElement("tr");
// 계층은 들여쓰기로 보인다 — 번호만으로는 깊이가 안 읽힌다.
const indent = " ".repeat(Math.max(0, (row.level - 1) * 2));
const cells = row.is_group
? [row.item_no, indent + row.name, "", "", "", "", "", ""]
: [
row.item_no,
indent + row.name,
row.spec,
row.unit,
row.quantity ?? "",
row.unit_price_krw ?? "",
row.amount_krw ?? "",
row.note,
];
for (const text of cells) {
const td = document.createElement("td");
td.textContent = text;
tr.append(td);
}
if (row.is_group) tr.style.fontWeight = "600";
tbody.append(tr);
}
table.append(head, tbody);
body.append(table);
const total = document.createElement("div");
total.className = "b09-hint";
total.textContent = `${L("B09_Estimation_Boq_Total")}: ${bill.summary.body_total_krw}`;
body.append(total);
// ⚠ 자재비가 빠진 채 선 합계임을 숨기지 않는다.
const shortfall = document.createElement("div");
shortfall.className = "b09-hint";
shortfall.textContent = L("B09_Estimation_Boq_NoMaterialPrice");
body.append(shortfall);
if (bill.excluded.length > 0) {
const note = document.createElement("div");
note.className = "b09-hint";
note.textContent =
`${L("B09_Estimation_Boq_Excluded")}: ` +
bill.excluded.map((row) => `${row.name} ${row.quantity ?? ""}${row.unit}`).join(", ");
body.append(note);
}
if (bill.summary.missing.length > 0) {
const note = document.createElement("div");
note.className = "b09-hint";
note.textContent = `${L("B09_Estimation_Boq_Missing")} (${bill.summary.missing.length})`;
body.append(note);
const list = document.createElement("ul");
for (const item of bill.summary.missing) {
const li = document.createElement("li");
li.textContent = `${item.name}${item.reason}`;
list.append(li);
}
body.append(list);
}
if (bill.materials.length > 0) {
const note = document.createElement("div");
note.className = "b09-hint";
note.textContent =
`${L("B09_Estimation_Boq_Materials")}: ` +
bill.materials.map((row) => `${row.name} ${row.quantity ?? ""}${row.unit}`).join(", ");
body.append(note);
}
};
const drawBody = (): void => {
body.replaceChildren();
if (activeTab === "unit_price") {
drawUnitPriceTab();
return;
}
if (activeTab === "boq") {
drawBoqTab();
return;
}
if (activeTab !== "cost_sheet") {
const empty = document.createElement("div");
empty.className = "b09-empty";
@@ -835,8 +943,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
sheet = await fetchCostSheet(projectId, form);
renderRateVersion(panel.rateVersionBox, sheet);
panel.hintBox.textContent =
sheet.suggested_profit_adjustment_krw &&
sheet.suggested_profit_adjustment_krw !== "0"
sheet.suggested_profit_adjustment_krw && sheet.suggested_profit_adjustment_krw !== "0"
? `${L("B09_Estimation_Suggest_Adjust")} ${formatWon(sheet.suggested_profit_adjustment_krw)}`
: "";
drawBody();
@@ -868,8 +975,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
mainContent: main,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (projectId)
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
});
root.append(layout.root);