diff --git a/B09_Estimation/B09_Estimation_Router_Import.py b/B09_Estimation/B09_Estimation_Router_Import.py index aefb067c7..c83c75314 100644 --- a/B09_Estimation/B09_Estimation_Router_Import.py +++ b/B09_Estimation/B09_Estimation_Router_Import.py @@ -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"]) diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts new file mode 100644 index 000000000..c04a06d53 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -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> = { + total: "rates", + cost: "rates", + labor: "prices", + machine: "prices", + compare: "prices", +}; +const LABEL: Record = { + rates: "엑셀에서 요율 불러오기", + prices: "엑셀에서 단가표 불러오기", +}; +const PART: Record = { labor: "노임", material: "자재", machine: "기계" }; + +/** 마지막에 올린 파일(서버 기억 열쇠) — 같은 파일 다시 씀 */ +let last: { key: string; name: string } | null = null; + +async function post(kind: Kind, ctx: TabContext, file: File | null): Promise { + 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]; +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 25811e184..c41cf9333 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -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; diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index f03bdb57e..1d5c8fa84 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -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