diff --git a/B09_Estimation/B09_Estimation_Calc_Base.py b/B09_Estimation/B09_Estimation_Calc_Base.py index 1e821a325..88218def0 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.py +++ b/B09_Estimation/B09_Estimation_Calc_Base.py @@ -8,7 +8,9 @@ 노무비목록표 · 경비목록표 = 굳힌 단가 그대로(사용자 고침 없음 · [단가 갱신] 때만 새로) 환율및기초자료 = 환율 · 운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0) (분자 · 분모 = M01 CC000336 1/8 × 16/12 × 25/20 — 서버가 넣어 줌) - 자재단가대비표 = 출처 단가 중 최저(0 · 빈칸 뺌) 자동 · 사용자 적용 단가가 있으면 그 값 + 자재단가대비표 = 출처 단가 중 최저(0 · 빈칸 뺌 · 견적 포함 = 엑셀 MIN) 자동 · + 적용 차례 = 고른 출처(pick · 견적이 높아도) › 사용자 적용 단가 › 최저 + · 견적 = 사용자 손입력(quote · quote_page · 견적서 파일은 `B09_Estimation_Router_Quotes`) · 관급 · 별도계상 = 사용자 표시 → 재료비목록표 단가 = 적용 단가 재료비목록표 = 수량(B08 총괄자재수량집계표 합계 열) × 적용 단가 = 총액 ROUND(·, 0) (엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸) @@ -28,8 +30,8 @@ from typing import Any EPS = 1e-7 #: 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료(같은 값이면 앞 칸) -# ↔ M01 재료_자재품목 값 열 · 유가 = 오피넷 -SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가") +# ↔ M01 재료_자재품목 값 열 · 유가 = 오피넷 · 견적 = 사용자 손입력(엑셀 M/N 열) +SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가", "견적") #: 자재 줄 사용자 표시 SUPPLY = ("관급", "별도") @@ -144,9 +146,12 @@ def compare_sheet( rows = [] for no, m in enumerate(materials, 1): mine = user.get(m["key"]) or {} - prices = m.get("prices") or {} + prices = {**(m.get("prices") or {}), "견적": mine.get("quote")} + pages = {**(m.get("pages") or {}), "견적": mine.get("quote_page")} low, low_from = lowest(prices) - if "applied" in mine: + if mine.get("pick") in SOURCES: + applied, applied_from = num(prices.get(mine["pick"])), mine["pick"] + elif "applied" in mine: applied, applied_from = num(mine["applied"]), "사용자" else: applied, applied_from = low, low_from @@ -159,11 +164,12 @@ def compare_sheet( "unit": m.get("unit") or "", "origin": m.get("origin") or "", "prices": {s: num(prices.get(s)) for s in SOURCES}, - "pages": {s: (m.get("pages") or {}).get(s) for s in SOURCES}, + "pages": {s: pages.get(s) for s in SOURCES}, "lowest": low, "lowest_from": low_from, "applied": applied, "applied_from": applied_from if applied is not None else None, + "pick": mine.get("pick") if mine.get("pick") in SOURCES else None, "supply": supply, "qty": num(m.get("qty")), } diff --git a/B09_Estimation/B09_Estimation_Calc_Base.ts b/B09_Estimation/B09_Estimation_Calc_Base.ts index a93f53147..5d4789743 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.ts +++ b/B09_Estimation/B09_Estimation_Calc_Base.ts @@ -7,7 +7,8 @@ * 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈. * * 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0) - * 자재단가대비표 = 출처 중 최저 자동 · 사용자 적용 단가 앞섬 · 관급 · 별도 표시 → 재료비목록표 + * 자재단가대비표 = 출처 중 최저 자동(견적 포함) · 적용 = 고른 출처 › 사용자 단가 › 최저 · + * 관급 · 별도 표시 → 재료비목록표 * 재료비목록표 = 수량(B08 총괄 합계 열) × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸 * 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표 * 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김 @@ -27,7 +28,15 @@ export interface Gap { const EPS = 1e-7; /** 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료(같은 값이면 앞 칸) */ -export const SOURCES = ["관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가"] as const; +export const SOURCES = [ + "관급", + "물가정보", + "물가자료", + "유통물가", + "거래가격", + "유가", + "견적", +] as const; /** 자재 줄 사용자 표시 */ export const SUPPLY = ["관급", "별도"] as const; @@ -119,15 +128,17 @@ export function lowest(prices: Row): [Num, string | null] { function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] { return materials.map((m, i) => { const mine: Row = user[m.key] || {}; - const prices: Row = m.prices || {}; + const prices: Row = { ...(m.prices || {}), 견적: mine.quote }; + const pages: Row = { ...(m.pages || {}), 견적: mine.quote_page }; const [low, lowFrom] = lowest(prices); + const pick = (SOURCES as readonly string[]).includes(mine.pick) ? mine.pick : null; let applied: Num; let appliedFrom: string | null; - if ("applied" in mine) [applied, appliedFrom] = [num(mine.applied), "사용자"]; + if (pick) [applied, appliedFrom] = [num(prices[pick]), pick]; + else if ("applied" in mine) [applied, appliedFrom] = [num(mine.applied), "사용자"]; else [applied, appliedFrom] = [low, lowFrom]; if (applied === null) gaps.push(gap("compare", m.key, m.name, "적용 단가 없음(출처 없음)")); const supply = (SUPPLY as readonly string[]).includes(mine.supply) ? mine.supply : null; - const pages: Row = m.pages || {}; return { ...head(m, i + 1), unit: m.unit || "", @@ -138,6 +149,7 @@ function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] { lowest_from: lowFrom, applied, applied_from: applied !== null ? appliedFrom : null, + pick, supply, qty: num(m.qty), }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index 4567f9a5f..a86daea13 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -5,11 +5,14 @@ * 재료비목록표 = 엑셀 꼴 + 수량(B08 합계) · 총액 열(166-3) * * 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈 + * · 견적 단가 · 면 손입력 · 적용 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처만 · 견적이 높아도) + * · 「견적서」 칸 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4) * 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표 * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; import { SOURCES, type Row } from "./B09_Estimation_Calc_Base"; +import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote"; import { money, parseNumber, @@ -104,9 +107,16 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => { const SUPPLY_NEXT: Record = { "": "관급", 관급: "별도", 별도: null }; +/** 고를 출처 돌림 — 자동 → 값 있는 출처 차례 → 자동 */ +function nextPick(r: Row): string | null { + const have = SOURCES.filter((s) => (r.prices[s] ?? 0) > 0); + return have[have.indexOf(r.pick) + 1] ?? null; +} + export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { const rows: Row[] = sheets.compare || []; const out: DataRow[] = rows.map((r) => { + const files: string[] = (user.materials || {})[r.key]?.files || []; const row: DataRow = { no: r.no, key: r.key, @@ -115,8 +125,9 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { unit: r.unit, lowest: r.lowest, applied: r.applied, - applied_from: r.applied_from === "사용자" ? "사용자" : (r.applied_from ?? ""), + applied_from: r.pick ? `${r.pick} 고름` : (r.applied_from ?? ""), supply: r.supply ?? "", + files: files.length ? `${files.length} 파일` : "첨부", }; for (const s of SOURCES) { row[`p:${s}`] = r.prices[s]; @@ -142,25 +153,52 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { money("applied", "적용 단가", ["적용"]), text("applied_from", "출처", 80, ["적용"]), text("supply", "관급 · 별도", 80), + text("files", "견적서", 72), ], rows: out, frozen: 2, - hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도", - editable: (_i, key) => key === "applied", + hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 · 면 = 손입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기", + editable: (_i, key) => key === "applied" || key === "p:견적" || key === "g:견적", onEdit: (i, key, value) => { const row = out[i]; - if (!row || key !== "applied") return; + if (!row) return; + const cell = mine(String(row.key)); + if (key === "g:견적") { + if (value.trim()) cell.quote_page = value.trim(); + else delete cell.quote_page; + return put(String(row.key), cell); + } const v = parseNumber(value); if (v === undefined) return; - const cell = mine(String(row.key)); - if (v === null) delete cell.applied; - else cell.applied = v; + const field = key === "applied" ? "applied" : key === "p:견적" ? "quote" : null; + if (!field) return; + if (v === null) delete cell[field]; + else cell[field] = v; + if (field === "applied") delete cell.pick; // 손 수 = 고른 출처를 풂 put(String(row.key), cell); }, onCell: (i, key) => { const row = out[i]; - if (!row || key !== "supply") return; + const r = rows[i]; + if (!row || !r) return; const cell = mine(String(row.key)); + if (key === "files") { + const label = `${r.name} ${r.spec}`.trim(); + return openQuotes(r.key, label, (names) => { + const now = mine(r.key); + if (JSON.stringify(now.files || []) === JSON.stringify(names)) return; + if (names.length) now.files = names; + else delete now.files; + put(r.key, now); + }); + } + if (key === "applied_from") { + const pick = nextPick(r); + if (pick) cell.pick = pick; + else delete cell.pick; + return put(String(row.key), cell); + } + if (key !== "supply") return; const next = SUPPLY_NEXT[String(row.supply ?? "")] ?? null; if (next) cell.supply = next; else delete cell.supply; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts new file mode 100644 index 000000000..6525253d6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts @@ -0,0 +1,100 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Base_Quote.ts + * 자재단가대비표 「견적서」 칸 작은 모달(PLAN 166-4) — 자재 줄 하나의 견적서 여러 파일 · + * 목록 · 올리기(여러 개) · 내려받기 · 지우기. 파일은 올리는 즉시 서버에 둠([저장]과 따로). + * + * 길 = `B09_Estimation_Router_Quotes.py` · 바뀐 목록은 onChange(이름들)로 표 칸 「n 파일」에 반영 + * ========================================================================== */ + +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { openModal } from "@ui/ui_template_modal"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; + +interface QuoteFile { + name: string; + size: number; + at: string; +} + +const url = (key: string, name?: string): string => { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + const q = new URLSearchParams({ key, ...(name ? { name } : {}) }); + return `${API_BASE_URL}/projects/${projectId}/estimation/quotes${name ? "/file" : ""}?${q}`; +}; + +async function call(input: string, init: RequestInit = {}): Promise { + const response = await fetch(input, { credentials: "include", ...init }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; + throw new Error(typeof body?.detail === "string" ? body.detail : `HTTP ${response.status}`); + } + return response; +} + +async function download(key: string, name: string): Promise { + const blob = await (await call(url(key, name))).blob(); + const a = el("a", { attrs: { href: URL.createObjectURL(blob), download: name } }); + a.click(); + URL.revokeObjectURL(a.href); +} + +/** 견적서 모달 — label = 「품명 규격」 */ +export function openQuotes(key: string, label: string, onChange: (names: string[]) => void): void { + openModal({ + title: `견적서 — ${label}`, + closeLabel: "닫기", + mount: (body) => { + const list = el("ul", { className: "b09-quotes" }); + const show = (files: QuoteFile[]): void => { + onChange(files.map((f) => f.name)); + list.replaceChildren( + ...(files.length + ? files.map((f) => + el("li", { + children: [ + createButton({ + label: `${f.name} (${Math.ceil(f.size / 1024).toLocaleString()} KB)`, + variant: "ghost", + size: "sm", + onClick: () => void download(key, f.name).catch(fail), + }), + createButton({ + label: "지우기", + variant: "danger", + size: "sm", + onClick: () => + void call(url(key, f.name).replace("/file?", "?"), { method: "DELETE" }) + .then((r) => r.json()) + .then(show, fail), + }), + ], + }), + ) + : [el("li", { text: "올린 견적서 없음" })]), + ); + }; + const fail = (error: unknown): void => + showToast(`하지 못했습니다 — ${String(error)}`, "error"); + const input = el("input", { attrs: { type: "file", multiple: "" } }); + input.addEventListener("change", () => { + const form = new FormData(); + for (const file of Array.from(input.files ?? [])) form.append("files", file); + input.value = ""; + void call(url(key), { method: "POST", body: form }) + .then((r) => r.json()) + .then(show, fail); + }); + const pick = createButton({ + label: "견적서 올리기(여러 개)", + variant: "filled", + size: "sm", + onClick: () => input.click(), + }); + input.hidden = true; + body.append(pick, input, list); + void call(url(key)) + .then((r) => r.json()) + .then(show, fail); + }, + }); +}