feat(B09 불러오기 화면): 총괄 · 원가계산서 [엑셀에서 요율 불러오기] · 노무 · 중기 · 자재단가대비표 [엑셀에서 단가표 불러오기] · 확인 창 · [적용] = 캐시 (PLAN 707-2 · 707-3)

- 확인 창 = 짝 표 · 우리 식 대조(같음 · 식 다름 · 안 고른 쪽) · 짝 없음 n · 마지막 파일 열쇠 다시 씀 · [다른 파일]
- 단가표 이음 수요 = 화면 단가 목록(GET)과 같은 사본 전부
- test_243_1 B09 표 목록 — 702-1 펼침 표(받기 전부터 실패) · 707 확인 창(보기 전용) 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 16:06:42 +09:00
co-authored by Claude Opus 5.5
parent 4cd4fdb3a3
commit a5f87812e6
4 changed files with 203 additions and 4 deletions
@@ -134,7 +134,7 @@ async def import_price_sheets(
read = await asyncio.to_thread(import_prices.read_prices, name, data)
except ExcelError as error:
raise HTTPException(400, str(error)) from error
keys = await main._demand(root, draft)
keys = await main._demand(root) # 화면 단가 목록(GET)과 같은 수요 — 사본 전부
b08 = await main._b08_for(project_id, [], True)
book = await asyncio.to_thread(store.read_book, root)
inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"])
+187
View File
@@ -0,0 +1,187 @@
/* =============================================================================
* B09_Estimation_UI_Import.ts
* 경쟁사 결과물 불러오기 단추 · 확인 창(PLAN 707-2 요율 · 707-3 기초 단가표) — 저장 안 함.
*
* 단추 = 보조 탭 줄 도구 자리(`…_UI_TabBar` setTools) — 총괄 · 원가계산서 탭 [엑셀에서 요율 불러오기] ·
* 노무비목록표 · 중기목록표 · 자재단가대비표 탭 [엑셀에서 단가표 불러오기]
* 길 = `B09_Estimation_Router_Import.py`(file | file_key · user = 화면 캐시) → 확인 창(짝 표 · 식 다름 ·
* 짝 없음 n) → [적용] = 답 `patch` 를 캐시에 얹음(바로 다시 셈) · [저장] 때 서버가 정본으로 셈
* 올린 파일 = 서버가 한 시간 기억(file_key) — 같은 파일로 요율 · 단가표를 다시 올리지 않음
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
import { pickFiles } from "@ui/ui_template_drop";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { currentProjectId } from "../A00_Common/b_project_switch";
import type { Row } from "./B09_Estimation_Calc_Base";
import { money, qty, text, type TabContext, type TabId } from "./B09_Estimation_UI_Tabs";
type Kind = "rates" | "prices";
const TABS: Partial<Record<TabId, Kind>> = {
total: "rates",
cost: "rates",
labor: "prices",
machine: "prices",
compare: "prices",
};
const LABEL: Record<Kind, string> = {
rates: "엑셀에서 요율 불러오기",
prices: "엑셀에서 단가표 불러오기",
};
const PART: Record<string, string> = { labor: "노임", material: "자재", machine: "기계" };
/** 마지막에 올린 파일(서버 기억 열쇠) — 같은 파일 다시 씀 */
let last: { key: string; name: string } | null = null;
async function post(kind: Kind, ctx: TabContext, file: File | null): Promise<Row> {
const form = new FormData();
if (file) form.append("file", file);
else if (last) form.append("file_key", last.key);
form.append("user", JSON.stringify(ctx.user));
const url = `${API_BASE_URL}/projects/${currentProjectId()}/estimation/import/${kind}`;
const response = await fetch(url, { method: "POST", body: form, credentials: "include" });
const body = (await response.json().catch(() => null)) as Row | null;
if (!response.ok) {
if (response.status === 404 && !file) last = null; // 지난 파일 = 다시 고름
throw new Error(typeof body?.detail === "string" ? body.detail : `HTTP ${response.status}`);
}
last = { key: String(body!.file_key), name: String(body!.name) };
return body!;
}
const won = (v: unknown): string =>
typeof v === "number" ? Math.round(v).toLocaleString("en-US") : "";
function ratesView(got: Row): { table: DataRow[]; lines: string[]; columns: DataColumn[] } {
const check: Row[] = got.check?.lines || [];
const ours = Object.fromEntries(check.map((ln) => [ln.key, ln]));
const table = (got.rows as Row[])
.filter((r) => r.key && r.key !== "subtotal")
.map((r, i) => ({
key: String(i),
name: r.name,
rate: r.rate,
excel: r.amount,
ours: ours[r.key]?.ours ?? null,
// 산업안전보건관리비 A · B 두 줄(EST) = 작은 쪽만 우리 식과 견줌
same: !ours[r.key]
? ""
: Math.abs((r.amount ?? NaN) - ours[r.key].ours) < 1
? "같음"
: ours[r.key].same
? "안 고른 쪽"
: "식 다름",
}));
const grand = got.check?.grand;
const lines = [
`율 ${Object.keys(got.rates || {}).length} 줄 실음 · 총공사비 우리 ${won(grand)} / 엑셀 ${won(
got.check?.excel_grand,
)}`,
...(got.notes as string[]),
];
const columns = [
text("name", "행 이름", 180),
qty("rate", "율(%)", 4),
money("excel", "엑셀 금액"),
money("ours", "우리 식"),
text("same", "대조", 72),
];
return { table, lines, columns };
}
function pricesView(got: Row): { table: DataRow[]; lines: string[]; columns: DataColumn[] } {
const show = (v: unknown): string =>
v && typeof v === "object"
? ["labor", "material", "expense"].map((p) => won((v as Row)[p])).join(" / ")
: won(v);
const table = (got.linked as Row[]).map((x, i) => ({
key: String(i),
part: PART[x.kind] || x.kind,
from: x.from,
name: x.name,
excel: show(x.excel),
book: show(x.book),
same: x.same ? "같음" : "덮음",
}));
const counts: Row = got.counts || {};
const lines = Object.entries(PART).map(([k, name]) => {
const c: Row = counts[k] || {};
const miss: string[] = got.missing?.[k] || [];
return `${name} — 읽음 ${c.read ?? 0} · 이음 ${c.linked ?? 0}(덮음 ${c.covered ?? 0}) · 이 공사 줄 ${
c.book ?? 0
} · 짝 없음 ${c.missing ?? 0}${miss.length ? `(${miss.slice(0, 8).join(", ")}${miss.length > 8 ? " …" : ""})` : ""}`;
});
const columns = [
text("part", "갈래", 56),
text("from", "엑셀 이름", 200),
text("name", "이 공사 줄", 160),
text("excel", "엑셀 단가", 150),
text("book", "이 공사 단가", 150),
text("same", "", 56),
];
return { table, lines, columns };
}
function openResult(kind: Kind, ctx: TabContext, got: Row): void {
const view = kind === "rates" ? ratesView(got) : pricesView(got);
openModal({
title: `${LABEL[kind]} — ${got.name}`,
closeLabel: "닫기",
width: "960px",
mount: (body, close, actions) => {
const apply = createButton({
label: "적용",
variant: "filled",
size: "sm",
onClick: () => {
ctx.setUser({ ...ctx.user, ...got.patch });
showToast("적용 — [저장] 해야 남음", "success");
close();
},
});
const other = createButton({
label: "다른 파일",
variant: "ghost",
size: "sm",
onClick: () => {
close();
pick(kind, ctx);
},
});
actions.append(other, apply);
const table = createDataTable({ columns: view.columns, rows: view.table, empty: "" });
body.append(...view.lines.map((t) => el("p", { text: t })), table.root);
},
});
}
const fail = (error: unknown): void => showToast(`불러오지 못함 — ${String(error)}`, "error");
function pick(kind: Kind, ctx: TabContext): void {
pickFiles(
{ accept: ".xlsx,.xls" },
([file]) => void post(kind, ctx, file).then((got) => openResult(kind, ctx, got), fail),
);
}
/** 탭 도구 자리 단추 — 불러오기 없는 탭 = 빈 목록 */
export function importTools(tab: TabId, ctx: TabContext): HTMLElement[] {
const kind = TABS[tab];
if (!kind) return [];
const button = createButton({
label: LABEL[kind],
variant: "ghost",
size: "sm",
onClick: () =>
last
? void post(kind, ctx, null).then(
(got) => openResult(kind, ctx, got),
() => pick(kind, ctx),
)
: pick(kind, ctx),
});
if (last) button.title = `마지막 파일 ${last.name} 로 · 다른 파일은 창에서`;
return [button];
}
@@ -42,6 +42,7 @@ import { createCheckPanel } from "./B09_Estimation_UI_Check";
import { createLiveCalc, markWaiting, waitingKeys, withoutWaiting } from "./B09_Estimation_UI_Live";
import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders";
import { createDock, linkMode } from "./B09_Estimation_UI_Dock";
import { importTools } from "./B09_Estimation_UI_Import";
import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup";
import {
DRAFT,
@@ -191,6 +192,7 @@ export function renderB09Estimation(root: HTMLElement): void {
const spec = tabTableOf(tab, ctx);
if (!spec) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움"));
if (typeof spec === "string") return void panel.replaceChildren(note(spec));
bars.setTools(importTools(tab, ctx)); // 707-2 · 3 엑셀에서 요율 · 단가표 불러오기
// 303 행 누름 = 팝업(기본 · 누른 줄 곁 표 조각) · 하이퍼링크(그 탭 그 줄로)
const goto = (link: Link | null, anchor?: Element | null): void => {
if (!link) return;
@@ -112,9 +112,19 @@ def test_B09_표_만드는_길_한_곳_더블클릭_기본():
calls = [
p.name for p in _sources("B09_Estimation") if "createDataTable({" in p.read_text("utf-8")
]
# 303-2 팝업 창 = 보기 전용(고칠 칸 없음) · 옵션 = 같은 표 짜기(Builders)
assert calls == ["B09_Estimation_UI_Popup.ts", "B09_Estimation_UI_Shell.ts"]
shell = (ROOT / "B09_Estimation" / calls[1]).read_text("utf-8")
# 303-2 팝업 창 = 보기 전용(고칠 칸 없음) · 옵션 = 같은 표 짜기(Builders) · 702-1 펼침 표 = 제 표(두 번 눌러 고침)
# 707-2 · 3 불러오기 확인 창 = 보기 전용(고칠 칸 없음)
assert calls == [
"B09_Estimation_UI_Import.ts",
"B09_Estimation_UI_Popup.ts",
"B09_Estimation_UI_Shell.ts",
"B09_Estimation_UI_Tabs_Bill_Fold.ts",
]
fold = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Fold.ts").read_text("utf-8")
assert "editOnDouble: true," in fold
view = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Import.ts").read_text("utf-8")
assert "editable" not in view
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert "...tableOptionsOf(spec, tab)," in shell # 303-2 옵션 짜기 = Builders 한 곳
builders = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Builders.ts").read_text("utf-8")
assert "editOnDouble: spec.editOnDouble ?? true," in builders