merge(707): sub_laptop_8(sub5 707-1 내역 줄 불러오기) 합침 — 불러오기 길 · 화면 한 파일 · file_key 하나 (PLAN 707-1 · 707-2 · 707-3)

- Router_Import = import/bill · bill/apply(sub5) + import/rates · import/prices · 올린 파일 = 프로젝트 import/{표지} 한 자리
- UI_Import = [엑셀 내역 불러오기] + [엑셀에서 요율 · 단가표 불러오기] · 마지막 표지 함께 씀
- test_145_3 · test_166_2 TS 거울 = 글 stdin(Calc_Base · Calc_Bill 길어져 node -e 32 K 넘김)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 16:30:43 +09:00
co-authored by Claude Opus 5.5
16 changed files with 1208 additions and 96 deletions
+183 -12
View File
@@ -1,23 +1,194 @@
/* =============================================================================
* B09_Estimation_UI_Import.ts
* 경쟁사 결과물 불러오기 단추 · 확인 창(PLAN 707-2 요율 · 707-3 기초 단가표) — 저장 안 함.
* B09 설계내역서 [엑셀 내역 불러오기](PLAN 707-1) — 경쟁사 내역 엑셀(STmate · EST) 줄을 화면 캐시로.
*
* 누름 = 파일 고름 → 서버 읽기 · 미리 보기(`POST …/estimation/import/bill`) → 창 하나에서 한 번 고름
* 수량 갈래(가 넘겨받기 · 나 단가만) · 이름 같은 자동 줄에 붙임(두 번 셈 막음) · 남는 자동 줄 지움
* [불러오기] = `POST …/import/bill/apply` → 받은 원가 자료 · 새 캐시(내역 덮개 · 조달수수료율) → [저장]해야 남음
* 어긋난 줄(엑셀 금액 ≠ 우리 금액) = 같은 창 목록(줄 · 엑셀 · 우리 · 까닭) · 없으면 창 닫고 알림
* DRM 파일 = 서버 400 「읽을 수 없음」 알림
*
* 요율 · 기초 단가표 불러오기(PLAN 707-2 · 707-3) — 저장 안 함.
* 단추 = 보조 탭 줄 도구 자리(`…_UI_TabBar` setTools) — 총괄 · 원가계산서 탭 [엑셀에서 요율 불러오기] ·
* 노무비목록표 · 중기목록표 · 자재단가대비표 탭 [엑셀에서 단가표 불러오기]
* 길 = `B09_Estimation_Router_Import.py`(file | file_key · user = 화면 캐시) → 확인 창(짝 표 · 식 다름 ·
* 짝 없음 n) → [적용] = 답 `patch` 를 캐시에 얹음(바로 다시 셈) · [저장] 때 서버가 정본으로 셈
* 올린 파일 = 서버가 한 시간 기억(file_key) — 같은 파일로 요율 · 단가표를 다시 올리지 않음
* 길 = `B09_Estimation_Router_Import.py` import/rates · import/prices(file | file_key · user = 화면 캐시) →
* 확인 창(짝 표 · 식 다름 · 짝 없음 n) → [적용] = 답 `patch` 를 캐시에 얹음 · [저장] 때 서버 정본
* 올린 파일 = 내역 줄 · 요율 · 단가표 세 길이 같은 표지(file_key · `last`) — 같은 파일 다시 안 올림
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createCheckbox } from "@ui/ui_template_checkbox";
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 {
createButton,
el,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
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";
const won = (v: unknown): string => Number(v || 0).toLocaleString("ko-KR");
async function post(projectId: string, path: string, body: BodyInit, json = false): Promise<Row> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/import/${path}`, {
method: "POST",
credentials: "include",
...(json ? { headers: { "Content-Type": "application/json" } } : {}),
body,
});
const got = (await response.json().catch(() => null)) as Row | null;
if (!response.ok || !got) {
const detail = got?.detail;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
}
return got;
}
/** 받은 캐시 = 지금 캐시 + 내역 덮개 · 조달수수료율(서버가 빈 때만 채움) */
function merged(user: Row, made: Row): Row {
const pct = made.estimate?.procurement_fee_pct;
const estimate = { ...(user.estimate || {}), ...(pct != null && { procurement_fee_pct: pct }) };
return { ...user, bill: made.bill, estimate };
}
function differences(rows: Row[]): HTMLElement {
return createDataTable({
columns: [
{ key: "행", label: "엑셀 행", kind: "number", width: 48 },
{ key: "이름", label: "명칭", width: 150 },
{ key: "엑셀", label: "엑셀 금액", kind: "number", width: 96 },
{ key: "우리", label: "우리 금액", kind: "number", width: 96 },
{ key: "까닭", label: "까닭", width: 130 },
],
rows,
}).root;
}
/** [엑셀 내역 불러오기] 단추 — user = 지금 화면 캐시 · done = 받은 원가 자료 + 새 캐시 */
export function createBillImport(
projectId: string,
user: () => Row,
done: (payload: Row, user: Row) => void,
): HTMLButtonElement {
const open = (seen: Row, mode: { value: string }): void => {
const total = seen.합계 || {};
const lines = [
`${seen.파일} · ${seen.꼴} 「${seen.시트}」 · 공종 ${seen.묶음} · 항목 ${seen.항목}`,
`엑셀 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` +
(seen.조달수수료율 != null ? ` · 조달수수료율 ${seen.조달수수료율} %` : ""),
seen.이음
? `같은 파일로 들여온 일위대가 ${seen.이음} 호표 — 그 줄은 일위대가에 이음`
: "같은 파일로 들여온 일위대가 없음 — 모든 줄 = 엑셀 단가(고정)",
];
const take = createCheckbox("넘겨받기 — 불러온 수량을 씀(수량 산출 값은 곁에)", {
radio: "b09-import-mode",
value: "take",
checked: mode.value === "take",
});
const price = createCheckbox(
"단가만 — 수량 산출 수량을 씀(불러온 수량은 비교 · 차이 큰 줄 붉게)",
{
radio: "b09-import-mode",
value: "price",
checked: mode.value === "price",
},
);
const attach = createCheckbox(`이름이 같은 자동 줄 ${seen.같은이름} 개에 붙임(두 번 셈 막음)`, {
checked: seen.같은이름 > 0,
disabled: !seen.같은이름,
});
const drop = createCheckbox(`그 밖 자동 줄 ${seen.남는자동} 개 지움`, {
disabled: !seen.남는자동,
});
const run = createButton({ label: "불러오기", variant: "filled" });
run.dataset.b09ImportRun = "";
openModal({
title: "엑셀 내역 불러오기",
closeLabel: "닫기",
width: "40rem",
mount: (body, close, actions) => {
body.append(
...lines.map((t) => createText(t, "muted", 14)),
el("div", { className: "b09-import__choices", children: [take.root, price.root] }),
el("div", { className: "b09-import__choices", children: [attach.root, drop.root] }),
);
actions.append(run);
run.addEventListener("click", async () => {
mode.value = price.input.checked ? "price" : "take";
const draft = user();
const ask = {
file_key: seen.file_key,
mode: mode.value,
attach: attach.input.checked,
drop: drop.input.checked,
user: draft,
};
showLoadingOverlay();
try {
const got = await post(projectId, "bill/apply", JSON.stringify(ask), true);
// 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈
close();
done(got.payload, merged(draft, got.user));
const n = got.알림 || {};
const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음} — [저장]해야 남음`;
const off: Row[] = got.어긋남 || [];
if (!off.length) return void showToast(`${head} · 엑셀 금액과 모두 같음`, "success");
// 어긋난 줄 = 표 높이를 잰 뒤(다음 화면) 보기 창
const result = (): void =>
void openModal({
title: "엑셀 내역 불러오기 — 결과",
closeLabel: "닫기",
width: "40rem",
mount: (box) =>
box.append(
createText(head, "success", 14),
createText(`엑셀 금액과 다른 줄 ${off.length}`, "warning", 14),
differences(off),
),
});
requestAnimationFrame(() => requestAnimationFrame(result));
} catch (error) {
showToast(`불러오지 못함 — ${error instanceof Error ? error.message : error}`, "error");
} finally {
hideLoadingOverlay();
}
});
},
});
};
const mode = { value: "take" };
const button = createButton({
label: "엑셀 내역 불러오기",
variant: "ghost",
onClick: () =>
pickFiles({ accept: ".xls,.xlsx" }, ([file]) => {
const form = new FormData();
form.append("file", file);
form.append("user", JSON.stringify(user()));
showLoadingOverlay();
post(projectId, "bill", form)
.then((seen) => {
last = { key: String(seen.file_key), name: String(seen.파일) }; // 요율 · 단가표가 다시 씀
open(seen, mode);
})
.catch((error: unknown) =>
showToast(`읽지 못함 — ${error instanceof Error ? error.message : error}`, "error"),
)
.finally(hideLoadingOverlay);
}),
});
button.dataset.b09Import = "";
return button;
}
/* ── 707-2 · 3 요율 · 기초 단가표 ── */
type Kind = "rates" | "prices";
const TABS: Partial<Record<TabId, Kind>> = {
total: "rates",
@@ -35,7 +206,7 @@ const PART: Record<string, string> = { labor: "노임", material: "자재", mach
/** 마지막에 올린 파일(서버 기억 열쇠) — 같은 파일 다시 씀 */
let last: { key: string; name: string } | null = null;
async function post(kind: Kind, ctx: TabContext, file: File | null): Promise<Row> {
async function postSheet(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);
@@ -51,7 +222,7 @@ async function post(kind: Kind, ctx: TabContext, file: File | null): Promise<Row
return body!;
}
const won = (v: unknown): string =>
const wonOrBlank = (v: unknown): string =>
typeof v === "number" ? Math.round(v).toLocaleString("en-US") : "";
function ratesView(got: Row): { table: DataRow[]; lines: string[]; columns: DataColumn[] } {
@@ -76,7 +247,7 @@ function ratesView(got: Row): { table: DataRow[]; lines: string[]; columns: Data
}));
const grand = got.check?.grand;
const lines = [
`율 ${Object.keys(got.rates || {}).length} 줄 실음 · 총공사비 우리 ${won(grand)} / 엑셀 ${won(
`율 ${Object.keys(got.rates || {}).length} 줄 실음 · 총공사비 우리 ${wonOrBlank(grand)} / 엑셀 ${wonOrBlank(
got.check?.excel_grand,
)}`,
...(got.notes as string[]),
@@ -94,8 +265,8 @@ function ratesView(got: Row): { table: DataRow[]; lines: string[]; columns: Data
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);
? ["labor", "material", "expense"].map((p) => wonOrBlank((v as Row)[p])).join(" / ")
: wonOrBlank(v);
const table = (got.linked as Row[]).map((x, i) => ({
key: String(i),
part: PART[x.kind] || x.kind,
@@ -162,7 +333,7 @@ const fail = (error: unknown): void => showToast(`불러오지 못함 — ${Stri
function pick(kind: Kind, ctx: TabContext): void {
pickFiles(
{ accept: ".xlsx,.xls" },
([file]) => void post(kind, ctx, file).then((got) => openResult(kind, ctx, got), fail),
([file]) => void postSheet(kind, ctx, file).then((got) => openResult(kind, ctx, got), fail),
);
}
@@ -176,7 +347,7 @@ export function importTools(tab: TabId, ctx: TabContext): HTMLElement[] {
size: "sm",
onClick: () =>
last
? void post(kind, ctx, null).then(
? void postSheet(kind, ctx, null).then(
(got) => openResult(kind, ctx, got),
() => pick(kind, ctx),
)