From cb2db4ff4979f34b121156d48fed86073d7cd2f9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:10:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EC=9A=B4=EB=B0=98=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EB=AA=A8=EB=8B=AC=20=C2=B7=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EA=B8=B8=20(253-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/projects/{id}/estimation/logic-preview — 로직 한 산근을 프로젝트 단가로 풀어 계산 절차 글 · 단가(저장 안 함) - 「운반 단가」 모달 — 단가산출 이름 · [변경] · [다른 종류] · 옵션 값(로직 입력 + 사이클 입력 · 표 고르기 CC000054~058) · 계산 절차(사이클 식 + 미리보기) - 여는 곳 = B08 거리이정표 표 우클릭 [단가연결] · B09 설계내역서 운반공 줄 단가연결 · 운반 항목에 거리(dist) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- B08_Quantity/B08_Quantity_UI_Milestone.ts | 26 +- B09_Estimation/B09_Estimation_Bill_Items.py | 6 +- B09_Estimation/B09_Estimation_Router.py | 42 +- .../B09_Estimation_UI_Haul_Modal.ts | 380 ++++++++++++++++++ .../B09_Estimation_UI_Tabs_Bill_Edit.ts | 8 +- .../B09_Estimation_UI_Tabs_Bill_Link.ts | 33 +- resources/tester/test_253_3_haul_preview.py | 62 +++ 7 files changed, 537 insertions(+), 20 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Haul_Modal.ts create mode 100644 resources/tester/test_253_3_haul_preview.py diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index 148f27c34..310d1bef4 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -10,7 +10,8 @@ * 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌) * 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State` * 단가연결(251-4) = 항목 칸 앞 램프(초록 = 연결 · 노랑 = 없음 · 호버 = 무엇) · 줄 우클릭 [단가연결] · - * [단가연결 풀기] — 창 = B09 설계내역서와 같은 `openPriceLink` · 경로 `ref` · B09 운반공 줄이 받음 + * [단가연결 풀기] — 창 = 「운반 단가」 모달(253-3 · B09 운반공 줄과 같음 · 그 행 거리) · 경로 `ref` · + * B09 운반공 줄이 받음 * ========================================================================== */ import "./B08_Quantity_UI_Milestone.css"; @@ -18,6 +19,7 @@ import { createDataTable, type DataColumn } from "@ui/ui_template_data_table"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; import type { Row } from "../B09_Estimation/B09_Estimation_Calc_Base"; +import { openHaulModal } from "../B09_Estimation/B09_Estimation_UI_Haul_Modal"; import { openPriceLink } from "../B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link"; import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram"; @@ -100,7 +102,7 @@ export function addRoute(projectId: string, init: Partial = {}): /** [거리이정표] 하위 탭 — 위 다이어그램 + 표 */ function tableView(projectId: string): MilestoneView { const diagram = createDiagram(projectId, (init) => addRoute(projectId, init)); - let keys: Array<{ route: string; index: number }> = []; + let keys: Array<{ route: string; index: number; L1: number; L2: number }> = []; const routeAt = (i: number) => liveMilestone(projectId).routes.find((r) => r.id === keys[i]?.route); const makeTable = (columns: DataColumn[]) => { @@ -151,8 +153,19 @@ function tableView(projectId: string): MilestoneView { // 호버 글 이름(사전조합 · 구조물 조합 = 들여온 대가 이름) — B09 는 tab · key 만 읽음 r.ref = ref && { ...ref, name: ref.name ?? from?.name ?? "" }; }); + const at = keys[Number(tr?.dataset.i)]; pickAtCell([ - ["단가연결", () => void openPriceLink({ apply: link })], + [ + "단가연결", + () => + openHaulModal({ + ref: (route.ref as Row | null) ?? null, + dist: at && { L1: at.L1, L2: at.L2 }, + name: route.rows[at?.index ?? 0]?.item || route.name || "운반", + apply: link, + other: () => void openPriceLink({ apply: link }), + }), + ], ...(route.ref ? [["단가연결 풀기", () => link(null)] as [string, () => void]] : []), ]); }); @@ -180,7 +193,12 @@ function tableView(projectId: string): MilestoneView { table.root.replaceWith(fresh.root); table = fresh; } - keys = rows.map(({ route, index }) => ({ route, index })); + keys = rows.map(({ route, index, paved, unpaved }) => ({ + route, + index, + L1: paved, + L2: unpaved, + })); none.hidden = rows.length > 0; table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` }))); diagram.refresh(); diff --git a/B09_Estimation/B09_Estimation_Bill_Items.py b/B09_Estimation/B09_Estimation_Bill_Items.py index d8a80c3df..72ad89487 100644 --- a/B09_Estimation/B09_Estimation_Bill_Items.py +++ b/B09_Estimation/B09_Estimation_Bill_Items.py @@ -112,7 +112,9 @@ def haul_items( ) -> list[dict[str, Any]]: """운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로) + 거리이정표 사용자 상자 행(253-1 · `B08_Quantity_Haul.haul_rows` · 수량 빈칸) · - 연결 = 거리이정표 단가연결(251-5 · `refs{줄 키: ref}` · 설계내역서 연결 덮개가 이김).""" + 연결 = 거리이정표 단가연결(251-5 · `refs{줄 키: ref}` · 설계내역서 연결 덮개가 이김) · + `dist{L1, L2}` = 그 행 거리(253-3 「운반 단가」 모달 · 없으면 None).""" + dists = {r["key"]: {"L1": r.get("L1"), "L2": r.get("L2")} for r in routes or []} out = [] for g in haul_groups(materials): qty = g["qty"] @@ -128,6 +130,7 @@ def haul_items( "qty": qty, "note": "B08 총괄자재" if qty is not None else "", "ref": ref, + "dist": dists.get(bill_key(g["key"])), "why": WHY_LOGIC if logic else WHY_HAUL, } ) @@ -146,6 +149,7 @@ def haul_items( "qty": None, "note": "B08 거리이정표", "ref": ref, + "dist": dists.get(r["key"]), "why": WHY_LOGIC if logic else WHY_HAUL, } ) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index d23353b28..7b0e48a41 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -6,6 +6,9 @@ [저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈 POST /api/projects/{id}/estimation/prices [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 + POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks} + 「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 · + 저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) POST /api/projects/{id}/estimation/library {kind, layer, owner, key} 설계내역서 연결 고르기(204-2) — 서고 구조물조합 · 사전조합을 프로젝트 사본으로 (있으면 그대로) → {폴더, 만듦, key = 대가 블록 id, payload} · @@ -25,7 +28,7 @@ from typing import Any from uuid import UUID from fastapi import APIRouter, Body, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from B08_Quantity.B08_Quantity_Haul import haul_rows from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone @@ -206,6 +209,43 @@ async def refresh_prices(project_id: UUID) -> dict[str, Any]: return await payload(project_id, root) +class PreviewBody(BaseModel): + code: str = Field(min_length=1, max_length=40) + given: dict[str, Any] = Field(default_factory=dict) + rate: float | None = None + picks: dict[str, Any] | None = None + + +def logic_preview(root: Path, body: PreviewBody) -> dict[str, Any]: + """로직 한 산근 미리보기 — 단가 = 굳힌 책(없는 줄 = 지금 M01) · 사용자 대비표 고름.""" + src = {"code": body.code, "given": sangeun._clean_given(body.given), "rate": body.rate, + "picks": body.picks} # fmt: skip + hands = [("preview", "미리보기", src)] + keys = sangeun.hand_resource_keys({"haul_refs": {"preview": {"tab": "logic", **src}}}) + user = store.read_user(root) + base = compute_base(prices.build_inputs(store.read_book(root), keys), user) + block = sangeun.Solver(base).add(sangeun.hand_whole(hands), src, None) + text = [ + *block["head"], + *(t for ln in block["lines"] for t in ln.get("text") or []), + block["foot"], + ] + return { + "name": block["name"], + "lines": [t for t in text if t], + "unit": block["unit"], + "price": block["sum"], + "parts": {p: block[p] for p in sangeun.PARTS}, + "why": block["why"], + } + + +@router.post("/{project_id}/estimation/logic-preview") +async def preview_logic(project_id: UUID, body: PreviewBody) -> dict[str, Any]: + root = await _project_root(project_id) + return await asyncio.to_thread(logic_preview, root, body) + + class LibraryBody(BaseModel): kind: str layer: str diff --git a/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts new file mode 100644 index 000000000..e5291bed2 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts @@ -0,0 +1,380 @@ +/* ============================================================================= + * B09_Estimation_UI_Haul_Modal.ts + * 「운반 단가」 모달(PLAN 253-3 · 요구 162-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결. + * 위 = 단가산출 이름 · [변경](`openLogicPicker`) · [다른 종류](옛 단가연결 창) · + * 왼 = 옵션 값 — 로직 입력(`fetchLogicInputs`) + 사이클 입력(L1 · L2 · V1~V4 · t1 · t3~t6 · t1 셈 값) · + * 오른 = 계산 절차 — 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle`) + 로직 산출 줄 + * (서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만). + * 로직 입력 Cm · 사이클시간 · t2 · 운반거리 = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) · + * 빈칸으로 지우면 다시 거리에서. + * 연결 없는 줄도 같은 모달(단가산출 빈칸 → [고르기]) · [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본). + * 여는 곳 = B08 거리이정표 표 우클릭 · B09 설계내역서 운반공 줄 단가연결. + * ========================================================================== */ + +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { + CYCLE_DEFAULTS, + CYCLE_FILL, + cycleInputs, + cycleTime, + fillGiven, + type Dist, +} from "../B08_Quantity/B08_Quantity_Haul_Cycle"; +import { loadTable, type TableRow } from "../M01_MasterData/M01_MasterData_UI_Logic_Note"; +import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; +import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick"; +import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values"; +import type { Row } from "./B09_Estimation_Calc_Base"; + +type Apply = (ref: Row | null, from?: Row) => void; +export interface HaulModalOpts { + /** 지금 연결(없으면 null) */ + ref: Row | null; + /** 그 행 거리 — L1 포장 계 · L2 비포장(km) */ + dist: Dist; + /** 줄 이름(제목) */ + name: string; + apply: Apply; + /** 옛 단가연결 창(일위대가 · 구조물 조합 등 다른 종류) */ + other?: () => void; +} + +/** 사이클 입력 칸 — [이름, 글, 단위] · 무리 = 화면 묶음 */ +const CYCLE_FIELDS: Array<[string, string, string][]> = [ + [ + ["L1", "포장 운반거리 L1", "km"], + ["L2", "비포장 운반거리 L2", "km"], + ], + [ + ["V1", "포장 적재 V1", "km/hr"], + ["V2", "포장 공차 V2", "km/hr"], + ["V3", "비포장 적재 V3", "km/hr"], + ["V4", "비포장 공차 V4", "km/hr"], + ], + [ + ["t1", "적재 t1", "분"], + ["T", "적재량 T", "ton"], + ["rt", "단위중량 rt", "t/㎥"], + ["f", "체적환산 f", ""], + ["qs", "버킷용량 qs", "㎥"], + ["k", "버킷계수 k", ""], + ["Es", "작업효율 Es", ""], + ["Cms", "적재기계 Cms", "초"], + ], + [ + ["t3", "적하 t3", "분"], + ["t4", "대기 t4", "분"], + ["t5", "덮개 t5", "분"], + ["t6", "세륜 t6", "분"], + ], +]; +/** 표 고르기(건설품셈 8-2-8 · master_data 읽기만) — [표 키, 채울 칸(값칸 → 입력), 조건 칸들] */ +const PICKS: Array<[string, Record, string]> = [ + ["CC000054", { 적재: "V1", 공차: "V2" }, "포장 도로상태"], + ["CC000054", { 적재: "V3", 공차: "V4" }, "비포장 도로상태"], + ["CC000055", { 시간: "t3" }, "적하(토질 · 작업조건)"], + ["CC000056", { 시간: "t4" }, "대기(적재장소)"], + ["CC000057", { 시간: "t5" }, "덮개"], + ["CC000058", { 시간: "t6" }, "세륜"], +]; +const OWNABLE = new Set(["L1", "L2", "t1"]); +const FROM_DIST = "거리에서"; + +const fmt = (v: number | null | undefined): string => + v == null || !Number.isFinite(v) ? "—" : String(v); + +/** 계산 절차 — 사이클 식 줄(거창 단가산출 꼴) */ +export function cycleLines(input: Record): string[] { + const v: Record = { ...CYCLE_DEFAULTS }; + for (const k of Object.keys(v)) if (typeof input[k] === "number") v[k] = input[k] as number; + const r = cycleTime(input); + const t1 = + typeof input.t1 === "number" + ? `t1 = ${fmt(r.t1)} 분(손 입력)` + : `t1 = Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es}) = ${r.t1} 분`; + return [ + "사이클시간 — 건설품셈 8-2-8 덤프트럭", + `q = T / rt × f = ${v.T} / ${v.rt} × ${v.f} = ${r.q} ㎥`, + `n = q / (qs × k) = ${r.q} / (${v.qs} × ${v.k}) = ${r.n} 회`, + t1, + `t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 = (${v.L1}/${v.V1} + ${v.L1}/${v.V2} + ${v.L2}/${v.V3} + ${v.L2}/${v.V4}) × 60 = ${r.t2} 분`, + `Cm = t1 + t2 + t3 + t4 + t5 + t6 = ${r.t1} + ${r.t2} + ${v.t3} + ${v.t4} + ${v.t5} + ${v.t6} = ${r.cm} 분`, + `운반거리 = L1 + L2 = ${r.L} km`, + ]; +} + +interface Preview { + lines: string[]; + unit: string; + price: number | null; + why: string | null; +} + +async function preview(code: string, given: Row, rate: unknown, picks: unknown): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + if (!projectId) throw new Error("프로젝트 없음"); + const res = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/logic-preview`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, given, rate: rate ?? null, picks: picks ?? null }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return (await res.json()) as Preview; +} + +const label = (text: string, unit: string): string => (unit ? `${text} (${unit})` : text); + +/** 「운반 단가」 모달 */ +export function openHaulModal(o: HaulModalOpts): void { + const logicRef = o.ref?.tab === "logic" ? o.ref : null; + const st = { + code: String(logicRef?.code ?? ""), + name: String(logicRef?.name ?? ""), + given: { ...((logicRef?.given as Row) || {}) } as Row, + cycle: { ...((logicRef?.cycle as Row) || {}) } as Row, + own: new Set((logicRef?.own as string[]) || []), + rate: logicRef?.rate ?? null, + picks: logicRef?.picks ?? null, + inputs: [] as ItemInput[], + unit: "", + }; + let seq = 0; + let timer = 0; + openModal({ + title: `운반 단가 — ${o.name}`, + closeLabel: "닫기", + wide: true, + dialogClass: "b09-haul-modal", + mount: (body, close, head) => { + const title = el("strong"); + const linkBox = el("div", { + attrs: { style: "display:flex;gap:8px;align-items:center;margin-bottom:8px" }, + }); + const left = el("div", { attrs: { style: "overflow:auto;min-width:0" } }); + const steps = el("pre", { + attrs: { style: "margin:0;white-space:pre-wrap;font-size:12px;line-height:1.6" }, + }); + const right = el("div", { attrs: { style: "overflow:auto;min-width:0" }, children: [steps] }); + const ref = (): Row => ({ own: [...st.own], cycle: st.cycle, given: st.given }); + const names = (): string[] => st.inputs.map((i) => i.이름); + const filled = (): Row => fillGiven(ref(), names(), o.dist) as Row; + + const redraw = (): void => { + const input = cycleInputs(ref(), o.dist); + const head2 = cycleLines(input); + steps.textContent = [...head2, "", st.code ? "단가산출 — 셈 중…" : "단가산출 — 없음"].join( + "\n", + ); + window.clearTimeout(timer); + if (!st.code) return; + const my = ++seq; + timer = window.setTimeout(() => { + preview(st.code, filled(), st.rate, st.picks).then( + (got) => { + if (my !== seq) return; // 최신 응답만 + st.unit = got.unit; + const price = got.price == null ? `빈칸 — ${got.why ?? ""}` : `${got.price} 원`; + steps.textContent = [...head2, "", ...got.lines, "", `단가 = ${price}`].join("\n"); + }, + (error: unknown) => { + if (my === seq) + steps.textContent = [...head2, "", `못 셈 — ${String(error)}`].join("\n"); + }, + ); + }, 250); + }; + + const numberField = (name: string, text: string, unit: string): HTMLElement => { + const input = el("input", { + className: "ui-input m02-inputval__control", + attrs: { type: "number", step: "any", "data-haul-cycle": name }, + }) as HTMLInputElement; + const tag = el("span", { className: "m02-labin__muted" }); + const show = (): void => { + const now = cycleInputs(ref(), o.dist); + const auto = + name === "t1" ? cycleTime(now).t1 : ((now[name] as number) ?? CYCLE_DEFAULTS[name]); + const mine = OWNABLE.has(name) ? st.own.has(name) : name in st.cycle; + if (document.activeElement !== input) + input.value = mine ? String(st.cycle[name] ?? "") : ""; + input.placeholder = String(auto ?? ""); + tag.textContent = (name === "L1" || name === "L2") && !mine && o.dist ? FROM_DIST : ""; + }; + input.addEventListener("change", () => { + const v = input.value.trim() === "" ? null : Number(input.value); + if (v === null || !Number.isFinite(v)) { + delete st.cycle[name]; + st.own.delete(name); + } else { + st.cycle[name] = v; + if (OWNABLE.has(name)) st.own.add(name); + } + update(); + }); + shows.push(show); + return row(label(text, unit), [input, tag]); + }; + const row = (text: string, cells: HTMLElement[]): HTMLElement => + el("div", { + className: "m02-inputval__row", + children: [ + el("span", { className: "m02-inputval__name", text }), + el("div", { className: "m02-inputval__cell", children: cells }), + ], + }); + const shows: Array<() => void> = []; + + const tablePick = (key: string, map: Record, text: string): HTMLElement => { + const select = el("select", { + className: "ui-select ui-select--sm m02-inputval__control", + attrs: { "data-haul-pick": Object.values(map).join(",") }, + }) as HTMLSelectElement; + select.append(el("option", { text: "표에서 고르기", attrs: { value: "" } })); + void loadTable(key).then((t: TableRow | null) => { + const conds = Object.keys(t?.조건 || {}); + (t?.줄 || []).forEach((line, i) => { + const what = conds.map((c) => String(line[c] ?? "")).join(" · "); + const vals = Object.keys(map) + .map((col) => `${map[col]} ${String(line[col] ?? "")}`) + .join(" · "); + select.append(el("option", { text: `${what} — ${vals}`, attrs: { value: String(i) } })); + }); + select.onchange = () => { + const line = t?.줄?.[Number(select.value)]; + if (!line) return; + for (const [col, name] of Object.entries(map)) + if (typeof line[col] === "number") st.cycle[name] = line[col] as number; + select.value = ""; + update(); + }; + }); + return row(text, [select]); + }; + + const logicRows = (): HTMLElement[] => + st.inputs.map((input) => { + const fill = input.이름 in CYCLE_FILL && !st.own.has(input.이름); + const value = fill ? filled()[input.이름] : st.given[input.이름]; + const field = createValueControl( + input, + (value as string | number | null) ?? null, + (next, bad) => { + if (bad) return; + if (next === null || next === "") { + delete st.given[input.이름]; + st.own.delete(input.이름); + } else { + st.given[input.이름] = + typeof next === "string" && Number.isFinite(Number(next)) ? Number(next) : next; + if (input.이름 in CYCLE_FILL) st.own.add(input.이름); + } + update(); + }, + ); + field.control.setAttribute("data-labor-input", input.이름); + const tag = el("span", { className: "m02-labin__muted" }); + shows.push(() => { + const auto = input.이름 in CYCLE_FILL && !st.own.has(input.이름); + tag.textContent = auto ? FROM_DIST : ""; + if (auto && document.activeElement !== field.control) + field.control.value = String(filled()[input.이름] ?? ""); + }); + return row(label(input.이름, input.단위 || ""), [field.control, tag, field.error]); + }); + + /** 값 바뀜 — 칸은 그대로 두고 표시 · 계산 절차만 */ + const update = (): void => { + shows.forEach((show) => show()); + redraw(); + }; + /** 로직 바뀜 — 왼쪽 칸을 새로 */ + const refresh = (): void => { + shows.splice(0); + const sec = (text: string): HTMLElement => + el("h4", { text, attrs: { style: "margin:12px 0 4px" } }); + left.replaceChildren( + sec("단가산출 옵션 값"), + ...(st.code + ? logicRows() + : [el("p", { className: "m02-labin__muted", text: "단가산출 없음 — [고르기]" })]), + sec("사이클시간 입력"), + ...CYCLE_FIELDS.flatMap((group) => group.map(([n, t, u]) => numberField(n, t, u))), + sec("표에서 고르기(건설품셈 8-2-8)"), + ...PICKS.map(([key, map, text]) => tablePick(key, map, text)), + ); + title.textContent = st.code ? `단가산출 ${st.code} ${st.name}` : "단가산출 — 없음"; + change.querySelector(".ui-btn__label")!.textContent = st.code ? "변경" : "고르기"; + update(); + }; + + const useLogic = (code: string, name: string): void => { + fetchLogicInputs([code]).then( + (got) => { + st.code = code; + st.name = name; + st.inputs = got.로직[0]?.입력 ?? []; + const keep = new Set(st.inputs.map((i) => i.이름)); + for (const k of Object.keys(st.given)) if (!keep.has(k)) delete st.given[k]; + refresh(); + }, + (error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"), + ); + }; + const change = createButton({ + label: "변경", + variant: "ghost", + size: "sm", + onClick: () => openLogicPicker((logic) => useLogic(logic.키, logic.이름)), + }); + linkBox.append(title, change); + if (o.other) + linkBox.append( + createButton({ + label: "다른 종류", + variant: "ghost", + size: "sm", + onClick: () => { + close(); + o.other?.(); + }, + }), + ); + const ok = createButton({ + label: "적용", + onClick: () => { + if (!st.code) return showToast("단가산출을 먼저 고르세요", "error"); + const next: Row = { + tab: "logic", + code: st.code, + name: st.name, + given: filled(), + rate: st.rate, + picks: st.picks, + cycle: st.cycle, + own: [...st.own], + }; + o.apply(next, { name: st.name, unit: st.unit.replace(/^원\//, "") }); + showToast("적용함 — [저장]하면 서버가 거리로 다시 셈", "success"); + close(); + }, + }); + head.append(ok); + body.append( + linkBox, + el("div", { + attrs: { + style: + "display:grid;grid-template-columns:minmax(320px,1fr) minmax(360px,1.4fr);gap:16px;min-height:60vh", + }, + children: [left, right], + }), + ); + if (st.code) useLogic(st.code, st.name); + else refresh(); + }, + }); +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 35a5dcb81..99243bdb4 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -225,7 +225,13 @@ function linkFor(ctx: TabContext, rows: DataRow[], i: number) { const row = rows[i]; const e = entryOf(freeze(ctx.user, rows), String(row.key)); const ref: Row | null = e && "ref" in e ? e.ref : (row.ref ?? null); - return linkItems(ctx, ref, applyRef(ctx, rows, i)); + const key = String(row.key); + // 253-3 운반공 줄 = 「운반 단가」 모달(거리 = 서버가 준 항목 `dist`) + const item = key.startsWith("haul:") + ? ((ctx.payload.inputs?.bill?.items as Row[]) || []).find((it) => it.key === key) + : undefined; + const haul = item && { dist: item.dist ?? null, name: String(row.name ?? item.name ?? "") }; + return linkItems(ctx, ref, applyRef(ctx, rows, i), haul); } /** 줄 우클릭 메뉴 — 더함 · 연결 · 옮김 · 레벨 · 자동값으로 · 지움(모든 줄 · 지킬 것 안만) */ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index c8293beb6..5831e654c 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -19,7 +19,9 @@ import { itemName, type PickerLayer } from "@ui/ui_template_item_picker_data"; import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input"; import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick"; +import type { Dist } from "../B08_Quantity/B08_Quantity_Haul_Cycle"; import type { Row } from "./B09_Estimation_Calc_Base"; +import { openHaulModal } from "./B09_Estimation_UI_Haul_Modal"; import type { TabContext } from "./B09_Estimation_UI_Tabs"; /** 연결 적용 — ref = null 이면 풀기 · from = 고른 호표(명칭 · 규격 · 단위 채움) */ @@ -95,22 +97,27 @@ async function importLib( } } -/** 항목 줄 단가연결 메뉴(251-6) — [단가연결 모달, 그 밖(로직 입력값 · 단가연결 풀기)] · ref = 지금 연결 */ -export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [Leaf[], Leaf[]] { +/** 항목 줄 단가연결 메뉴(251-6) — [단가연결 모달, 그 밖(로직 입력값 · 단가연결 풀기)] · ref = 지금 연결 · + * haul = 운반공 줄(253-3 · 그 행 거리 · 이름) → 「운반 단가」 모달(옛 창은 [다른 종류]) */ +export function linkItems( + ctx: TabContext, + ref: Row | null, + apply: ApplyRef, + haul?: { dist: Dist; name: string }, +): [Leaf[], Leaf[]] { const more: Leaf[] = []; - if (ref?.tab === "logic") + const priceLink = (): void => + void openPriceLink({ + apply, + hopyo: { dg: ctx.sheets.daega || [], sg: ctx.sheets.sg_list || [] }, + onPayload: (p) => Object.assign(ctx.payload, p), + }); + const haulModal = (): void => + openHaulModal({ ref, dist: haul?.dist, name: haul?.name || "", apply, other: priceLink }); + if (ref?.tab === "logic" && !haul) more.push(["로직 입력값 고치기", () => askInputs(apply, ref.code, ref.name || "", ref)]); if (ref) more.push(["단가연결 풀기", () => apply(null)]); - const open: Leaf = [ - "단가연결", - () => - openPriceLink({ - apply, - hopyo: { dg: ctx.sheets.daega || [], sg: ctx.sheets.sg_list || [] }, - onPayload: (p) => Object.assign(ctx.payload, p), - }), - ]; - return [[open], more]; + return [[["단가연결", haul ? haulModal : priceLink]], more]; } /** 단가연결 모달(251 · 159 · 160) — 한 목록에 단가산출 로직 · 일위대가 사전조합 · 구조물 조합(마스터 · 회사 · diff --git a/resources/tester/test_253_3_haul_preview.py b/resources/tester/test_253_3_haul_preview.py new file mode 100644 index 000000000..730812d0c --- /dev/null +++ b/resources/tester/test_253_3_haul_preview.py @@ -0,0 +1,62 @@ +"""253-3 미리보기 길 `POST …/estimation/logic-preview` + 「운반 단가」 모달 — 거리이정표 우클릭 · +설계내역서 운반공 줄에서 엶.""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul_Cycle import cycle_time # noqa: E402 +from B09_Estimation.B09_Estimation_Bill_Items import haul_items # noqa: E402 +from B09_Estimation.B09_Estimation_Router import PreviewBody, logic_preview, router # noqa: E402 + + +def _src(name: str) -> str: + return (ROOT / name).read_text(encoding="utf-8") + + +def test_미리보기_계산_절차(tmp_path): + cm = cycle_time({"L1": 25.1, "L2": 0.8})["cm"] + given = {"qt": 15, "f": 1, "E": 0.9, "Cm": cm, "지역": "전국평균"} + got = logic_preview(tmp_path, PreviewBody(code="GF000134", given=given)) + assert got["unit"] == "㎥" and got["price"] and got["price"] > 0 + assert "Cm : 109.78 분" in got["lines"] + assert any("60 * 15 * 1 * 0.9 / 109.78" in line for line in got["lines"]) + assert got["lines"][-1].startswith("계 :") + # 거리 늘면 단가도 늘음 + far = logic_preview(tmp_path, PreviewBody(code="GF000134", given={**given, "Cm": 200})) + assert far["price"] > got["price"] + + +def test_미리보기_길(): + paths = {(r.path, tuple(r.methods)) for r in router.routes} + assert ("/api/projects/{project_id}/estimation/logic-preview", ("POST",)) in paths + + +def test_내역_줄_거리(): + one = {"ref": None} + routes = [ + {**one, "key": "haul:3", "name": "모래운반", "user": False, "L1": 25.1, "L2": 0.8}, + {**one, "key": "haul:r-u1", "name": "깬돌운반", "user": True, "L1": 3, "L2": 1}, + ] + items = {i["key"]: i for i in haul_items(None, {}, routes)} + assert items["haul:3"]["dist"] == {"L1": 25.1, "L2": 0.8} + assert items["haul:r-u1"]["dist"] == {"L1": 3, "L2": 1} + assert items["haul:1"]["dist"] is None + + +def test_모달_여는_곳(): + modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") + for want in ("/estimation/logic-preview", "openLogicPicker", "fetchLogicInputs", "fillGiven", + "cycleInputs", "my !== seq", "CC000054", "거리에서", "[고르기]"): # fmt: skip + assert want in modal, want + milestone = _src("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert "openHaulModal({" in milestone and "L1: paved" in milestone + link = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts") + assert '[["단가연결", haul ? haulModal : priceLink]]' in link + edit = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts") + assert ( + 'key.startsWith("haul:")' in edit + and "linkItems(ctx, ref, applyRef(ctx, rows, i), haul)" in edit + )