diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py index 93eb2a404..7af6393b9 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.py +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -23,6 +23,8 @@ 짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만. 복사본(703-2) = 덮개 `K:…` 의 `@copy` {from · name · spec} — 원본 cover 블록을 본뜬 새 호표 (id `DG:K:…` · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}. +붙인 줄(705-2 · 엑셀 붙여넣기) = 덮개 `@add` [블록 줄 `fixed` · `pct` · cid `@add|…`] — 그 cover 블록 끝에 더함 + (복사본 앞 · 뒤 두 번 · 같은 cid 는 한 번 · 복사본이 원본 붙인 줄을 이어받음) · 고친 칸 덮개는 다른 줄과 같음. """ from __future__ import annotations @@ -35,6 +37,7 @@ PARTS = ("labor", "material", "expense") COVER_KEYS = ("qty", *PARTS) TAB = "dg" COPY = "@copy" +ADD = "@add" WHY = { "labor": "노임 단가 없음", "material": "자재 적용 단가 없음", @@ -182,6 +185,26 @@ def with_copies(blocks: list[dict[str, Any]], cover: dict[str, Any]) -> list[dic return out +def with_added(blocks: list[dict[str, Any]], cover: dict[str, Any]) -> list[dict[str, Any]]: + """붙인 줄(705-2) — 블록 cover 의 `@add` 줄을 끝에 더함(이미 있는 cid 는 뺌).""" + out = [] + for b in blocks: + add = (cover.get(b.get("cover")) or {}).get(ADD) + have = {ln.get("cid") for ln in b["lines"]} + more = [ln for ln in add if ln.get("cid") not in have] if isinstance(add, list) else [] + out.append({**b, "lines": [*b["lines"], *more]} if more else b) + return out + + +def added_keys(cover: dict[str, Any] | None) -> list[str]: + """붙인 줄이 잇는 단가 책 키(705-2 · 수요에 더함).""" + out: list[str] = [] + for lines in (cover or {}).values(): + for ln in (lines or {}).get(ADD) or []: + out += [v for v in (ln.get("link") or {}).values() if v and v not in out] + return out + + def compute_labor( daega: dict[str, Any] | None, sheets: dict[str, Any], @@ -201,7 +224,7 @@ def compute_labor( "sangeun": sangeun or {}, } cover = cover or {} - blocks = with_copies(blocks, cover) + blocks = with_added(with_copies(with_added(blocks, cover), cover), cover) by_id = {b["id"]: b for b in blocks} no = {b["id"]: n for n, b in enumerate(blocks, 1)} done: dict[str, dict[str, Any]] = {} @@ -252,7 +275,9 @@ def compute_labor( {"cover": key, "cid": cid} for key, lines in cover.items() for cid in lines - if cid not in cids.get(key, ()) and not (cid == COPY and f"DG:{key}" in by_id) + if cid not in cids.get(key, ()) + and not (cid == COPY and f"DG:{key}" in by_id) + and cid != ADD ] return { "daega_blocks": out_blocks, diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts index 722b6979a..4cb4bcd1d 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.ts +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -19,6 +19,7 @@ * 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만) * 복사본(703-2) = 덮개 K:… 의 @copy {from · name · spec} — 원본 cover 블록을 본뜬 새 호표 * (id DG:K:… · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy} + * 붙인 줄(705-2) = 덮개 @add [블록 줄 fixed · pct · cid @add|…] — 그 cover 블록 끝에 더함(복사본 앞 · 뒤 · 같은 cid 한 번) * ========================================================================== */ import { @@ -36,6 +37,8 @@ const PARTS = ["labor", "material", "expense"] as const; const COVER_KEYS = ["qty", ...PARTS] as const; const TAB = "dg"; export const COPY = "@copy"; +/** 붙인 줄 덮개 열쇠(705-2) */ +export const ADD = "@add"; const WHY: Record = { labor: "노임 단가 없음", material: "자재 적용 단가 없음", @@ -183,6 +186,16 @@ function withCopies(blocks: Row[], covers: Row): Row[] { return out; } +/** 붙인 줄(705-2) — 블록 cover 의 @add 줄을 끝에 더함(이미 있는 cid 는 뺌) */ +function withAdded(blocks: Row[], covers: Row): Row[] { + return blocks.map((b) => { + const add: unknown = (covers[b.cover] || {})[ADD]; + const have = new Set((b.lines as Row[]).map((ln) => ln.cid)); + const more = Array.isArray(add) ? (add as Row[]).filter((ln) => !have.has(ln.cid)) : []; + return more.length ? { ...b, lines: [...b.lines, ...more] } : b; + }); +} + /** 대가 탭 둘 — daega_blocks(일위대가표) · daega(목록표) · daega_owners + 바탕 gaps 이어 붙임 */ export function computeLabor( daega: Row | null | undefined, @@ -191,7 +204,10 @@ export function computeLabor( covers: Row = {}, ): Row { covers = covers || {}; - const blocks: Row[] = withCopies((daega || {}).blocks || [], covers); + const blocks: Row[] = withAdded( + withCopies(withAdded((daega || {}).blocks || [], covers), covers), + covers, + ); const index = (rows: Row[] | undefined): Row => Object.fromEntries((rows || []).map((r) => [r.key, r])); const books: Row = { @@ -244,7 +260,7 @@ export function computeLabor( const orphans: Row[] = []; for (const [key, lines] of Object.entries(covers)) for (const cid of Object.keys(lines || {})) - if (!cids.get(key)?.has(cid) && !(cid === COPY && `DG:${key}` in byId)) + if (!cids.get(key)?.has(cid) && !(cid === COPY && `DG:${key}` in byId) && cid !== ADD) orphans.push({ cover: key, cid }); return { daega_blocks: outBlocks, diff --git a/B09_Estimation/B09_Estimation_Daega_Paste.py b/B09_Estimation/B09_Estimation_Daega_Paste.py new file mode 100644 index 000000000..c14fb7b5c --- /dev/null +++ b/B09_Estimation/B09_Estimation_Daega_Paste.py @@ -0,0 +1,39 @@ +"""B09 펼친 일위대가 엑셀 붙여넣기(PLAN 705-2) — 붙인 글 → 지금 호표 끝 고정 줄(덮개 `@add`). + +글 해석 · 고정 줄 = M02 와 한 벌(`M02_Hopyo_Paste.read_text` · `fixed_lines` · 705-3 이음) · 블록 줄 꼴 = +`Labor_Source.fixed_line`(fixed · 비율 = pct) · cid `@add|고정|이름|규격|단위`(같은 이름 = 「 (2)」 가름) · +여러 호표를 붙여도 지금 호표에 줄로만 · 사용자 값(화면 캐시)에 더해 돌려줌 — 저장은 B09 [저장](서버가 다시 셈). +""" + +from __future__ import annotations + +from typing import Any + +from B09_Estimation import B09_Estimation_Store as store +from B09_Estimation.B09_Estimation_Calc_Labor import ADD +from B09_Estimation.B09_Estimation_Labor_Source import fixed_line +from M02_MasterTemplete import M02_Hopyo_Paste as hp +from M02_MasterTemplete import M02_Labor_Fixed as fixed + + +def paste(user: dict[str, Any], cover: str, text: str, cols: Any) -> dict[str, Any]: + """→ `{갈래: 물음, 열, 보기}` · `{갈래: 줄, 줄, 엑셀, 이음, 이음후보, user}` — 틀린 글 = ValueError.""" + parsed = hp.read_text(text, cols) + if parsed["물음"]: + look = [[c[:24] for c in row[:16]] for row in hp.cells(text)[:8]] + return {"갈래": "물음", "열": parsed["열"], "짐작": True, "보기": look} + user = store.clean_user(user) + lines = dict((user.get("daega_cover") or {}).get(cover) or {}) + have = list(lines.get(ADD) or []) + taken = {str(x["cid"]).removeprefix(f"{ADD}|") for x in have} + got = hp.fixed_lines(parsed, taken) + new = [ + {**fixed_line(x["고정"], x["수량"]["손"]), "cid": f"{ADD}|{fixed.fixed_id(x['고정'])}"} + for x in got["줄"] + ] + lines[ADD] = [*have, *new] + user = store.clean_user( + {**user, "daega_cover": {**(user.get("daega_cover") or {}), cover: lines}} + ) + head = {k: got[k] for k in ("엑셀", "이음", "이음후보")} + return {"갈래": "줄", "열": parsed["열"], "줄": len(new), **head, "user": user} diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 07236d324..ad0f6cd53 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -72,6 +72,7 @@ from B08_Quantity.B08_Quantity_Router_Material import material_sheets from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milestone from B08_Quantity.B08_Quantity_Router_Milestone import site_info from B09_Estimation import B09_Estimation_Bill_Library as library +from B09_Estimation import B09_Estimation_Daega_Paste as daega_paste from B09_Estimation import B09_Estimation_Daega_Personal as personal from B09_Estimation import B09_Estimation_Past as past from B09_Estimation import B09_Estimation_Prices as prices @@ -81,7 +82,7 @@ from B09_Estimation import B09_Estimation_Store as store from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices from B09_Estimation.B09_Estimation_Calc_Base import compute_base from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation -from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor, daega_price +from B09_Estimation.B09_Estimation_Calc_Labor import added_keys, compute_labor, daega_price from B09_Estimation.B09_Estimation_Calc_Total import compute_total from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs from B09_Estimation.B09_Estimation_Linked import linked_resource_keys @@ -211,7 +212,8 @@ async def _demand(root: Path, draft: dict[str, Any] | None = None) -> list[str]: keep = None if draft is None else library.used_folders(user) keys = await asyncio.to_thread(linked_resource_keys, root, keep) hand = await asyncio.to_thread(sangeun.hand_resource_keys, user) - return [*keys, *(k for k in hand if k not in keys)] + hand = [*hand, *added_keys(user.get("daega_cover"))] # 705-2 붙인 줄 이음 + return [*keys, *dict.fromkeys(k for k in hand if k not in keys)] async def payload( @@ -499,6 +501,34 @@ async def save_personal( } +class DaegaPasteBody(BaseModel): + """705-2 — `cover` = 펼친 호표 덮개 자리 · `글` = 붙인 글 그대로 · `열` = 기억한 열 짝 · `user` = 화면 캐시.""" + + cover: str = Field(min_length=1, max_length=200) + 글: str = Field(max_length=400_000) + 열: dict[str, int] | None = None + user: dict[str, Any] | None = None + + +@router.post("/{project_id}/estimation/daega/paste") +async def daega_paste_run( + project_id: UUID, body: DaegaPasteBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + """[Ctrl+V] 펼친 일위대가 — 지금 호표 끝에 고정 줄(저장 안 함) · 답 = 새 사용자 값 + 그 값으로 셈한 원가 + 자료(붙인 줄 이음 키가 단가 책 수요에 듦) · 물음 = 열 짝.""" + root = await _project_root(project_id) + raw = ( + body.user if isinstance(body.user, dict) else await asyncio.to_thread(store.read_user, root) + ) + try: + got = await asyncio.to_thread(daega_paste.paste, raw, body.cover, body.글, body.열) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + if got["갈래"] == "줄": + got["payload"] = await payload(project_id, root, got["user"]) + return got + + def _past_rows(projects: list[dict[str, Any]], here: str) -> list[dict[str, Any]]: out = [] for other in projects: diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index b3305c860..0b04d5235 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -13,6 +13,7 @@ "daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}} (702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`) + 복사본 `K:…` 의 `@copy` {from · name · spec}(703-2 · 글만) + + 붙인 줄 `@add` [블록 줄 fixed · pct](705-2 · `clean_added`) quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) · 읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함 @@ -32,7 +33,7 @@ from urllib.parse import unquote from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY from B09_Estimation.B09_Estimation_Calc_Bill import SECTIONS -from B09_Estimation.B09_Estimation_Calc_Labor import COVER_KEYS +from B09_Estimation.B09_Estimation_Calc_Labor import ADD, COVER_KEYS, PARTS try: # 252 공기 초안(draft 폴더 · 지우면 공기 값 없이) from B09_Estimation.draft.B09_Draft_Schedule_Source import clean_schedule @@ -253,6 +254,10 @@ def clean_cover(raw: Any) -> dict[str, Any]: kept = {} for cid, cell in (lines if isinstance(lines, dict) else {}).items(): cell = cell if isinstance(cell, dict) else {} + if cid == ADD: + if got := clean_added(lines.get(ADD)): + kept[cid] = got + continue if cid == "@copy": if _text(cell.get("from")): kept[cid] = {k: _text(cell[k]) for k in ("from", "name", "spec") if k in cell} @@ -265,6 +270,33 @@ def clean_cover(raw: Any) -> dict[str, Any]: return out +ADD_MAX = 500 + + +def clean_added(raw: Any) -> list[dict[str, Any]]: + """붙인 줄(705-2) — fixed = 이름 · 규격 · 단위 · 수량 · 붙인 단가(own) · 이음 키(link) · pct = 비목 · 밑 · + 율 · cid `@add|…` 만 · 모르는 칸 버림 · 숫자 아닌 단가 = 0.""" + out: list[dict[str, Any]] = [] + for ln in raw[:ADD_MAX] if isinstance(raw, list) else []: + if not isinstance(ln, dict) or not str(ln.get("cid") or "").startswith(f"{ADD}|"): + continue + head = {k: _text(ln.get(k))[:200] for k in ("cid", "name", "spec", "unit")} + if ln.get("kind") == "pct" and ln.get("part") in PARTS: + base = str(ln.get("base") or "") + if base.removeprefix("part:") in PARTS: + out.append({**head, "kind": "pct", "part": ln["part"], "base": base, + "qty": _num(ln.get("qty"))}) # fmt: skip + continue + if ln.get("kind") != "fixed": + continue + own, link = ln.get("own") or {}, ln.get("link") or {} + out.append({**head, "kind": "fixed", "qty": _num(ln.get("qty")), + "own": {p: _num(own.get(p)) or 0 for p in PARTS if isinstance(own, dict)}, + "link": {k: _text(link.get(k))[:40] or None for k in ("labor", "material", "machine") + if isinstance(link, dict)}}) # fmt: skip + return out + + def clean_user(raw: Any) -> dict[str, Any]: """사용자 값 다듬기 — 모르는 칸 버림 · 수 칸은 수 · null(빈칸)만.""" raw = raw if isinstance(raw, dict) else {} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts index c419f16f6..7479f52e0 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts @@ -10,6 +10,8 @@ * 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림 * 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로) * 우클릭 덧메뉴(703-2) = 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나(`…_Bill_Fold_Menu`) + * 엑셀 붙여넣기(705-2) = 표를 누르고 Ctrl+V → 서버가 붙인 글을 읽어 지금 호표 끝 고정 줄(덮개 `@add` · 캐시 · + * [저장] 때 서버가 다시 셈) · 열 짝 = M02 와 같은 물음 · 기억 · 알림 누름 = 「이 공사 단가로 다시 잇기」(이 공사 단가 책) * ========================================================================== */ import { createButton } from "@ui/ui_template_elements"; @@ -22,6 +24,16 @@ import type { Row } from "./B09_Estimation_Calc_Base"; import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs"; import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { foldMenu } from "./B09_Estimation_UI_Tabs_Bill_Fold_Menu"; +import { API_BASE_URL } from "@config/config_frontend"; +import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_toast"; +import { currentProjectId } from "../A00_Common/b_project_switch"; +import { ADD } from "./B09_Estimation_Calc_Labor"; +import { withCols } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Paste"; +import { + openRelink, + type RelinkSource, +} from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink"; +import { relinkRows, type PasteCols } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api"; /** 고칠 칸 → 덮개 칸 */ const FIELD: Record = { @@ -210,9 +222,126 @@ function foldCard( ], }); card.classList.add("b09-fold"); + if (block.cover) { + table.root.tabIndex = 0; // 705-2 — 눌러 고르면 Ctrl+V 받음 + card.addEventListener("paste", (ev) => { + if ((ev.target as Element | null)?.closest("input, textarea")) return; + const text = ev.clipboardData?.getData("text/plain") ?? ""; + if (!text.trim()) return; + ev.preventDefault(); + void pasteFold(ctx, block.cover, text); + }); + } return card; } +async function post(path: string, body: Row): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${currentProjectId()}/estimation/${path}`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + const got = ((await response.json().catch(() => null)) as Row | null) ?? {}; + if (!response.ok) + throw new Error(typeof got.detail === "string" ? got.detail : `HTTP ${response.status}`); + return got; +} + +const COST_PART = { 노무비: "labor", 재료비: "material", 경비: "expense" } as const; + +/** 붙인 줄 다시 잇기(705-2) — 후보 = 서버(M02 이음 짝) · 단가 = 이 공사 단가 책 · 바꿈 = 캐시 + 서버 셈 */ +function relinkFold(ctx: TabContext, cover: string): RelinkSource { + const added = (): Row[] => ((ctx.user.daega_cover || {})[cover] || {})[ADD] || []; + const book = (kind: "노임" | "자재", key: string): number | null | undefined => + (ctx.payload.inputs?.[kind === "노임" ? "labor" : "materials"] || []).find( + (r: Row) => r.key === key, + )?.price; + return { + title: "이 공사 단가로 다시 잇기 — 붙인 줄", + priceLabel: "이 공사 단가", + price: book, + rows: () => + relinkRows( + { layer: "personal", owner: null }, + { + 문서: { + 담은로직: added() + .filter((ln) => ln.kind === "fixed") + .map((ln) => ({ + 고정: { + 이름: ln.name, + 규격: ln.spec, + 단위: ln.unit, + 단가: Object.fromEntries( + Object.entries(COST_PART).map(([k, p]) => [k, ln.own?.[p] ?? 0]), + ), + 이음: { 노임: ln.link?.labor ?? null, 자재: ln.link?.material ?? null }, + }, + 수량: { 손: ln.qty }, + })), + }, + }, + ), + apply: async (changes) => { + const lines: Row[] = JSON.parse(JSON.stringify(added())); + const back = []; + for (const c of changes) { + const ln = lines.find((x) => x.cid === `${ADD}|${c.줄}`); + if (!ln) continue; + back.push({ + 키: "", + 줄: c.줄, + 노임: ln.link?.labor ?? null, + 자재: ln.link?.material ?? null, + }); + ln.link = { ...(ln.link || {}) }; + if ("노임" in c) ln.link.labor = c.노임 ?? null; + if ("자재" in c) ln.link.material = c.자재 ?? null; + } + const all: Row = JSON.parse(JSON.stringify(ctx.user.daega_cover || {})); + all[cover] = { ...(all[cover] || {}), [ADD]: lines }; + const user = { ...ctx.user, daega_cover: all }; + Object.assign(ctx.payload, await post("calc", { user })); // 새 이음 키 = 이 공사 단가 책 수요 + ctx.setUser(user); + return { back, note: `바뀐 줄 ${back.length} — [저장]으로 남김` }; + }, + }; +} + +/** 붙여넣기(705-2) — 붙인 글 그대로 서버로 · 답 = 새 캐시 + 그 캐시로 셈한 원가 자료 */ +async function pasteFold(ctx: TabContext, cover: string, text: string): Promise { + showLoadingOverlay(); + try { + const got = await withCols( + (cols: PasteCols | null) => + post("daega/paste", { + cover, + 글: text, + ...(cols ? { 열: cols } : {}), + user: ctx.user, + }) as Promise, + ); + if (!got) return; + Object.assign(ctx.payload, got.payload); + ctx.setUser(got.user); + const won = Math.round(Number(got.엑셀) || 0).toLocaleString("ko-KR"); + showToast( + `고정 줄 ${got.줄} 붙임 · 엑셀 합계 ${won} · 이음 ${got.이음} · 후보 ${got.이음후보} — [저장]으로 남김 · 이 알림을 누르면 이 공사 단가로 다시 잇기`, + "success", + 9000, + () => void openRelink(relinkFold(ctx, cover)), + ); + } catch (error) { + showToast(`붙여넣기 실패 — ${error instanceof Error ? error.message : String(error)}`, "error"); + } finally { + hideLoadingOverlay(); + } +} + /** 펼침 고리 — 설계내역서 표 root 줄 누름 = 그 줄 펼침 · 돌려준 자리 = 표 아래 */ export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement { const host = document.createElement("div"); diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index 9f214d653..38aaf6f1a 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -221,7 +221,10 @@ class Builder: "확인": sure, } - def build(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> dict: + def build( + self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]], save: bool = True + ) -> dict: + """`save` 거짓 = 사전조합 안 씀 · `{문서, 빠진줄, 줄수}`(704-4 [구조물 조합으로]).""" stamp = utc_now_text() hopyo, file = self.hopyo, self.doc.get("파일") or "" lines, mats, missing, used, mat_lines = [], {}, [], set(), {} @@ -329,12 +332,15 @@ class Builder: if nation: doc["본뜬"]["전국자재"] = list(nation.values()) doc["본뜬"]["품셈"] = poomsem - ref, again = _save(doc) - wrote = ledger.write(self.folder, book, self.who, file or None) fixed_n = sum(1 for x in lines if "고정" in x) links = sum(1 for x in lines if any(((x.get("고정") or {}).get("이음") or {}).values())) count = {"품셈": len(poomsem), "고정": fixed_n, "호표": kid_n} count |= {"빠짐": sum(1 for x in missing if x["까닭"] != TWICE), "이음": links} + if not save: + ledger.write(self.folder, book, self.who, file or None) + return {"문서": doc, "빠진줄": missing, "줄수": {**count, "이음후보": linker.near}} + ref, again = _save(doc) + wrote = ledger.write(self.folder, book, self.who, file or None) out = {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}} return {**out, "줄수": {**count, "이음후보": linker.near}} @@ -462,7 +468,7 @@ def _order(units: dict[int, dict[str, Any]]) -> list[int]: def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str, Any]: """[모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 하위 먼저 · 줄 = 고정(하위 = 참조) · 이미 확인된 호표는 덮지 않음(`그대로`) → `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보, - 같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}]}`.""" + 같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}], 만듦 [{번, 이름, 키}]}`.""" units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []} sums = dict.fromkeys(("품셈", "고정", "호표", "빠짐", "이음", "이음후보"), 0) out: dict[str, Any] = { @@ -472,6 +478,7 @@ def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str "그대로": 0, "다름": [], "실패": [], + "만듦": [], } for no in _order(units): out["줄"] += len(units[no].get("줄") or []) @@ -487,6 +494,7 @@ def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str for k in sums: sums[k] += made["줄수"][k] got = compare(ref, maker.hopyo) + out["만듦"].append({"번": no, "이름": units[no].get("이름"), "키": ref["키"]}) except store.StoreError as e: out["실패"].append({"번": no, "까닭": str(e.detail)}) continue diff --git a/M02_MasterTemplete/M02_Hopyo_Item.py b/M02_MasterTemplete/M02_Hopyo_Item.py new file mode 100644 index 000000000..dec7e4710 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Item.py @@ -0,0 +1,145 @@ +"""실무 호표 → 구조물 조합 항목 한 단계(PLAN 704-4 뒷부분 · `_제안_704-4_규격표.md` 6장 끝) — 사전조합 안 거침. + +[구조물 조합으로] = 확인 창 몸(체크한 줄 · 통째 · `M02_Hopyo_Build.check` 그대로)으로 줄을 만들어(`build(save=False)` · + 사전조합 SC 문서 안 씀 · 옛 [사전조합 생성] 길은 그대로) 이 층 구조물집계표에 새 항목(SU · `M02_Item_Personal.create_item` + · 경로 「실무자료」) + 그 일위대가 문서에 줄을 담음(수량 = 1 단위 몫 `{손}`). +규격 묶음 = 같은 파일에서 이름 · 단위 같고 줄 구성(줄 이름 · 단위 차례) 같은 · 규격만 다른 호표(5-A 「구성 같은 묶음」) → + 한 항목의 규격 열(704-4) — 열 이름 = 규격 글에서 달라지는 칸(「H=1.5」 → 이름 H · 열 1.5) · 아니면 이름 「규격」 · + 열마다 같은 몸으로 만든 줄 수량(줄 차례 같음 · 다르면 400) · 기본 열 = 연 호표. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Build as hb +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Item_Personal as personal +from M02_MasterTemplete import M02_Labor_Spec as spec +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +PATH = ["실무자료"] +_TOKEN = re.compile(r"[\s,]+") +_PAIR = re.compile(r"^([A-Za-z가-힣Φφ]+)\s*[=:]?\s*([\d.]+)\D{0,3}$") + + +def _flat(text: Any) -> str: + return re.sub(r"\s+", "", str(text or "")) + + +def _shape(h: dict[str, Any]) -> tuple: + return tuple((_flat(x.get("이름")), _flat(x.get("단위"))) for x in h.get("줄") or []) + + +def columns(specs: list[str]) -> tuple[str, list[str]]: + """규격 글들 → (규격 이름, 열 이름) — 처음 달라지는 칸이 모두 「H=1.5」 꼴이면 (H, [1.5 …]) · + 아니면 (「규격」, 그 칸 글) · 20 자 자름 · 겹치면 뒤에 차례.""" + parts = [[t for t in _TOKEN.split(s.strip()) if t] for s in specs] + width = max((len(p) for p in parts), default=0) + at = next((i for i in range(width) if len({p[i] if i < len(p) else "" for p in parts}) > 1), 0) + cells = [p[at] if at < len(p) else "" for p in parts] + hits = [_PAIR.match(c) for c in cells] + if all(hits) and len({h.group(1) for h in hits}) == 1: + name, cols = hits[0].group(1), [h.group(2) for h in hits] + else: + name, cols = "규격", [c or s for c, s in zip(cells, specs, strict=True)] + out: list[str] = [] + for c in cols: + c = (c.strip() or "열")[: spec.NAME_MAX] + while c in out: + c = f"{c[: spec.NAME_MAX - 2]}·{len(out) + 1}" + out.append(c) + return name[: spec.NAME_MAX], out + + +def siblings(source: Path, key: str, no: int) -> dict[str, Any]: + """규격 묶음 제안 — 같은 이름 · 단위 · 줄 구성 · 규격 다른 호표(연 호표 먼저) → + `{묶음 [{번, 규격, 열}], 이름}` · 없으면 묶음 빈 것.""" + units = hopyo_store.read(source, key).get("호표") or [] + me = next((h for h in units if int(h["번"]) == int(no)), None) + if me is None: + raise store.StoreError(404, f"없는 호표 「제{no}호표」") + same = [ + h + for h in units + if _flat(h.get("이름")) == _flat(me.get("이름")) + and _flat(h.get("단위")) == _flat(me.get("단위")) + and _shape(h) == _shape(me) + ] + same.sort(key=lambda h: (int(h["번"]) != int(no), int(h["번"]))) + specs = list(dict.fromkeys(_flat(h.get("규격")) for h in same)) + if len(same) < 2 or len(specs) != len(same): + return {"묶음": [], "이름": None} + name, cols = columns([str(h.get("규격") or "") for h in same]) + rows = [ + {"번": int(h["번"]), "규격": h.get("규격") or "", "열": c} + for h, c in zip(same, cols, strict=True) + ] + return {"묶음": rows, "이름": name} + + +def _doc(folder: Path, source: Path, key: str, no: int, lines: list, whole: Any, who: Any) -> dict: + maker = hb.Builder(folder, source, key, no, who) + checked, pairs = maker.check(list(lines or []), whole) + return {**maker.build(checked, pairs, save=False), "호표": maker.hopyo} + + +def make_item( + folder: Path, + source: Path, + key: str, + no: int, + lines: list[dict[str, Any]] | None = None, + whole: dict[str, Any] | None = None, + bundle: list[int] | None = None, + name: str | None = None, + who: Any = None, +) -> dict[str, Any]: + """[구조물 조합으로] → `{key, 열, 이름, 줄수, 열수, 빠진줄}` — `bundle` = 규격 열로 묶을 호표 번(연 호표 빼고).""" + main = _doc(folder, source, key, no, lines, whole, who) + doc, hopyo = main["문서"], main["호표"] + title = (name or "").strip() or doc["이름"] + cols_doc = None + if bundle: + found = {r["번"]: r for r in siblings(source, key, no)["묶음"]} + want = [int(no), *(int(b) for b in bundle if int(b) != int(no))] + if any(b not in found for b in want): + raise store.StoreError(400, "규격 묶음은 이름 · 단위 · 줄 구성이 같은 호표만") + title = (name or "").strip() or str(hopyo.get("이름") or doc["이름"]) + qty: dict[str, dict[str, Any]] = {} + for b in want: + got = ( + main["문서"] + if b == int(no) + else _doc(folder, source, key, b, lines, whole, who)["문서"] + ) + col = found[b]["열"] + for part in ("담은로직", "자재"): + mine, theirs = doc[part], got[part] + if len(mine) != len(theirs): + raise store.StoreError(400, f"제{b}호표 줄 구성이 다름 — 묶지 않고 따로 만듦") + for one, other in zip(mine, theirs, strict=True): + hand = (other.get("수량") or {}).get("손") + if hand is not None: + qty.setdefault(str(spec.line_key(one)), {})[col] = hand + cols_doc = { + "이름": siblings(source, key, no)["이름"], + "열": [found[b]["열"] for b in want], + "기본": found[int(no)]["열"], + "수량": qty, + } + with store.at(folder): + made = personal.create_item(folder, PATH, title, doc.get("단위")) + have = store.read("labor", made["열"]) + body = {**have["문서"], "담은로직": doc["담은로직"], "자재": doc["자재"]} + body["단위"] = doc.get("단위") or body.get("단위") + if cols_doc: + body["규격"] = cols_doc + store.write("labor", made["열"], have["판"], body) + count = { + "줄수": len(doc["담은로직"]) + len(doc["자재"]), + "열수": len(cols_doc["열"]) if cols_doc else 1, + } + return {**made, **count, "빠진줄": main["빠진줄"]} diff --git a/M02_MasterTemplete/M02_Hopyo_Paste.py b/M02_MasterTemplete/M02_Hopyo_Paste.py new file mode 100644 index 000000000..ff1ed6591 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Paste.py @@ -0,0 +1,295 @@ +"""M02 일위대가 엑셀 붙여넣기(PLAN 705-2 · `_분석_내역5-A` ⑤-2) — 붙인 글(탭 · 줄 나뉨)을 서버가 읽어 +실무자료 꼴(`M02_Hopyo_Excel.read_book` 과 같은 `{꼴, 시트, 호표, 단산, 검산}`)로 · 브라우저 숫자 안 받음. + +열 짝 `열` = {품명, 규격, 수량, 단위, 금액, 노, 재, 경} → 칸 차례(0부터) — 노 · 재 · 경 = 단가 칸(다음 칸 = 금액) · + 금액 = 합계 금액 칸(호표 1 단위 값 · 금액만 줄) · 합계 단가 = 금액 앞 칸 · 비고 = 경 + 2. + 머리 글 줄(「품명|명칭」 · 「수량」 · 「단위」)이 붙어 오면 그 줄로(물음 없음) · 없으면 받은 `열` · + 둘 다 없으면 짐작(엑셀꼴 · STmate 꼴 차례) + `물음`(화면이 한 번 묻고 기억). +호표 = 「제N호표」 칸이 있는 줄 — 같은 줄 품명 = 머리(엑셀꼴) · 품명 칸이 그 글이면 다음 줄이 머리(STmate 꼴) · + 「제N호표」 없는 글 = 이름 없는 호표 하나(번 0 · 줄만). +거름 = 머리 글 줄 · 「합계 · 계 · 소계 · 계약단가」 줄(STmate 합계 줄 금액 = 호표 1 단위 값) · + 근거 줄(단위 · 단가 · 금액 빔 — 「건설표준품셈 | 6-7-2 | 0」) · 빈 줄. +수 = 쉼표 · 빈칸 · 「-」(`M02_Hopyo_Excel._num`) · 갈래 = `M02_Hopyo_Excel.kind_of`(비고 「단산N참조」 · 「일위N참조」) · + 단위 섞임(㎥ · M3)은 이음 쪽 `factor` 가 맞춤 · % 줄 = 단가 계 = 밑수(705-3 `M02_Hopyo_Fixed._ratio`). +""" + +from __future__ import annotations + +import re +from collections import Counter +from datetime import datetime +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Build as hb +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Fixed as hfix +from M02_MasterTemplete import M02_Hopyo_Store as hstore +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +FIELDS = ("품명", "규격", "수량", "단위", "금액", "노", "재", "경") +TEXT_MAX = 400_000 +ROWS_MAX = 5000 +SHEET = "붙여넣기" +_SKIP = ("합계", "계", "소계", "계약단가") +_HEAD_NAMES = {"품명": ("품명", "명칭"), "규격": ("규격",), "수량": ("수량",), "단위": ("단위",)} +_HEAD_COST = {"노": "노무비", "재": "재료비", "경": "경비"} + + +class PasteError(ValueError): + """붙인 글이 비거나 꼴이 틀림 — 길이 400.""" + + +def cells(text: str) -> list[list[str]]: + """붙인 글 → 줄마다 칸(탭) — 엑셀 따옴표 칸(줄바꿈 든 칸)은 따옴표만 벗김 · 끝 빈 줄 뺌.""" + if not isinstance(text, str) or not text.strip(): + raise PasteError("붙인 글이 빔") + if len(text) > TEXT_MAX: + raise PasteError("붙인 글이 너무 김") + rows = [ + [c.strip().strip('"').strip() for c in line.split("\t")] + for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n") + ] + while rows and not any(rows[-1]): + rows.pop() + if len(rows) > ROWS_MAX: + raise PasteError(f"줄은 {ROWS_MAX} 개 안") + return rows + + +def _flat(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")) + + +def _header(rows: list[list[str]]) -> tuple[int, dict[str, int]] | None: + """머리 글 줄 → (줄 차례, 열 짝) — 품명 · 수량 · 단위 다 있는 줄 · 비목 = 그 줄(또는 다음 줄) 글.""" + for at, row in enumerate(rows[:20]): + flat = [_flat(v) for v in row] + cols = {k: next((i for i, v in enumerate(flat) if v in names), -1) + for k, names in _HEAD_NAMES.items()} # fmt: skip + if min(cols["품명"], cols["수량"], cols["단위"]) < 0: + continue + for k, word in _HEAD_COST.items(): + cols[k] = next((i for i, v in enumerate(flat) if v.startswith(word)), -1) + total = next((i for i, v in enumerate(flat) if v == "합계"), -1) + cols["금액"] = total + 1 if total >= 0 else -1 + return at, {k: v for k, v in cols.items() if v >= 0} + return None + + +def guess(rows: list[list[str]]) -> dict[str, int]: + """머리 없는 글 열 짐작 — 「제N호표」 칸이 따로면(엑셀꼴 B 열부터) 품명 = 그 다음 칸 · 아니면 첫 칸 · + 차례 = 품명 · 규격 · 수량 · 단위 · 합계 단가 / 금액 · 노 · 재 · 경(단가 / 금액 쌍).""" + marks = Counter(i for row in rows for i, v in enumerate(row) if hx._HOPYO.fullmatch(_flat(v))) + first = marks.most_common(1)[0][0] if marks else 0 + beside = any( + hx._HOPYO.fullmatch(_flat(r[first])) and len(r) > first + 1 and r[first + 1].strip() + for r in rows + if len(r) > first + ) + name = first + 1 if beside else first + if not marks and rows and not any(r[0].strip() for r in rows if r) and len(rows[0]) > 1: + name = 1 # 첫 칸이 모두 빔(호표 칸만 끼어 옴) + return {"품명": name, "규격": name + 1, "수량": name + 2, "단위": name + 3, "금액": name + 5, + "노": name + 6, "재": name + 8, "경": name + 10} # fmt: skip + + +def check_cols(cols: Any) -> dict[str, int]: + """받은 열 짝 정리 — 품명 · 수량 · 단위 있어야 · 칸 차례 0~60 · 같은 칸 둘 = 400.""" + if not isinstance(cols, dict): + raise PasteError("열 짝은 {품명, 규격, …: 칸 차례}") + out = {} + for k in FIELDS: + v = cols.get(k) + if v is None or v == "": + continue + if isinstance(v, bool) or not isinstance(v, int) or not 0 <= v <= 60: + raise PasteError(f"열 짝 「{k}」 칸 차례가 틀림") + out[k] = v + if any(k not in out for k in ("품명", "수량", "단위")): + raise PasteError("열 짝에 품명 · 수량 · 단위는 꼭") + if len(set(out.values())) != len(out): + raise PasteError("열 짝에 같은 칸이 둘") + return out + + +def _money_cols(cols: dict[str, int]) -> dict[str, int]: + out = {"노무비": cols.get("노"), "재료비": cols.get("재"), "경비": cols.get("경")} + if cols.get("금액") is not None: + out["합계"] = cols["금액"] - 1 + return {k: v for k, v in out.items() if v is not None and v >= 0} + + +def _cell(row: list[str], col: int | None) -> str: + return row[col] if col is not None and 0 <= col < len(row) else "" + + +def _hopyo_at(row: list[str]) -> tuple[int, int] | None: + """(번, 칸) — 줄에 「제N호표」 칸.""" + for i, v in enumerate(row): + hit = hx._HOPYO.fullmatch(_flat(v)) + if hit: + return int(hit.group(1)), i + return None + + +def _money(row: list[str], money: dict[str, int], offset: int) -> dict[str, Any]: + return hx._money(row, money, offset) + + +def read_text(text: str, cols: Any = None) -> dict[str, Any]: + """붙인 글 → `{꼴, 시트, 호표, 단산, 검산, 열, 짐작, 물음}` — `물음` = 머리 없음 · 열 짝 안 받음 + (짐작으로 읽은 결과 · 화면이 열 짝을 묻고 다시 부름).""" + rows = cells(text) + head = _header(rows) + start, asked = 0, False + if head is not None: + start, used = head[0] + 1, head[1] + below = [_flat(v) for v in rows[start]] if start < len(rows) else [] + start += "단가" in below or "금액" in below + elif cols is not None: + used = check_cols(cols) + else: + used, asked = guess(rows), True + money = _money_cols(used) + note = (used["경"] + 2) if used.get("경") is not None else None + hopyos: list[dict[str, Any]] = [] + lines: list[list[dict[str, Any]]] = [] + want_head = False + + def open_hopyo(no: int, row: list[str] | None) -> None: + hopyos.append({"번": no, "이름": "", "규격": "", "단위": "", "금액": {}, "행": [0, 0]}) + lines.append([]) + if row is not None: + fill(row) + + def fill(row: list[str]) -> None: + hopyos[-1].update( + 이름=_cell(row, used["품명"]), + 규격=_cell(row, used.get("규격")), + 단위=_cell(row, used["단위"]), + 금액=_money(row, money, 1), + ) + + for at, row in enumerate(rows[start:], start + 1): + if not any(row): + continue + hit = _hopyo_at(row) + name = _cell(row, used["품명"]) + if hit: + same = hit[1] != used["품명"] and name + open_hopyo(hit[0], row if same else None) + hopyos[-1]["행"] = [at, at] + want_head = not same + continue + if want_head: + if name: + fill(row) + want_head = False + continue + flat = _flat(name) + if ( + not flat + or flat in ("품명", "명칭") + or any(flat == w for w in _SKIP) + or flat.startswith("합계") + ): + if ( + hopyos + and (flat.startswith("합계") or flat == "계") + and not hopyos[-1]["금액"].get("계") + ): + hopyos[-1]["금액"] = _money(row, money, 1) # STmate 합계 줄 = 1 단위 값 + continue + price, amount = _money(row, money, 0), _money(row, money, 1) + unit = _cell(row, used["단위"]) + qty = hx._num(_cell(row, used["수량"])) + if not unit and not any(v for v in (*price.values(), *amount.values())): + continue # 근거 줄 「건설표준품셈 | 6-7-2 | 0」 + if not hopyos: # 첫 줄 수량 없음 = 호표 머리(「제N호표」 칸 없이 복사) + open_hopyo(0, row if qty is None else None) + if qty is None: + continue + remark = _cell(row, note) + ref = hx._REF.match(_flat(remark)) + line = { + "차례": len(lines[-1]), + "이름": name, + "규격": _cell(row, used.get("규격")), + "수량": qty, + "단위": unit, + "단가": price, + "금액": amount, + "비고": remark, + "참조": {"종류": "단산" if ref.group(1) else "일위", "번": int(ref.group(3))} + if ref + else None, + "행": at, + } + line["갈래"] = hx.kind_of(line) + hopyos[-1]["행"][1] = at + lines[-1].append(line) + if not any(lines): + raise PasteError("붙인 글에 일위대가 줄이 없음 — 품명 · 수량 · 단위 열을 확인") + for hopyo, mine in zip(hopyos, lines, strict=True): + if not hopyo["이름"]: + hopyo["이름"] = "붙여넣기" if hopyo["번"] == 0 else f"제{hopyo['번']}호표" + if not hopyo["금액"].get( + "계" + ): # 머리 · 합계 줄 금액 없음 = 줄 금액 합(엑셀 끝수 · 원 내림) + sums = { + k: sum((x["금액"].get(k) or Decimal(0) for x in mine), Decimal(0)) for k in hx.COSTS + } + hopyo["금액"] = {**{k: hx._floor(v, 0) for k, v in sums.items()}, + "계": sum(hx._floor(v, 0) for v in sums.values())} # fmt: skip + out = [ + {**hx._json(h), "줄": [hx._json(x) for x in mine]} + for h, mine in zip(hopyos, lines, strict=True) + if mine + ] + return { + "꼴": SHEET, + "시트": SHEET, + "호표": out, + "단산": [], + "검산": {"어긋남": [], "총괄표": 0}, + "열": used, + "짐작": head is None and cols is None, + "물음": asked, + } + + +def fixed_lines(parsed: dict[str, Any], taken: Any = ()) -> dict[str, Any]: + """줄만(M02 열린 문서 · B09 지금 호표) — 호표 모두의 줄 → 고정 줄(705-3 `line_of` · 하위 호표 줄도 + 금액 그대로) · `taken` = 문서에 이미 있는 고정 줄 가름(이름 끝 「 (2)」) → + `{줄, 엑셀, 이음, 이음후보}`(엑셀 = 붙인 호표 1 단위 값 합).""" + linker, have, out = hfix.Linker(), {str(x) for x in taken}, [] + for h in parsed["호표"]: + where = {"파일": SHEET, "호표": h["번"]} + for line in h["줄"]: + others = [x for x in h["줄"] if x is not line] + out.append(hfix.line_of(line, others, where, have, linker)) + excel = sum((h["금액"].get("계") or 0) for h in parsed["호표"]) + links = sum(1 for x in out if any(x["고정"]["이음"].values())) + return {"줄": out, "엑셀": excel, "이음": links, "이음후보": linker.near} + + +def paste( + folder: Any, text: str, cols: Any, who: Any, lines_only: bool = False, taken: Any = () +) -> dict[str, Any]: + """[Ctrl+V] — `갈래` 「물음」(열 짝 짐작 + 보기 · 안 씀) · 「줄」(호표 머리 없음 · `lines_only`) · + 「만듦」(호표 머리 있음 = 실무자료 「붙여넣기 …」 + 호표마다 사전조합 · 705-3 `build_all` 그대로).""" + try: + parsed = read_text(text, cols) + except PasteError as error: + raise store.StoreError(400, str(error)) from error + head = {"열": parsed["열"], "짐작": parsed["짐작"]} + if parsed["물음"]: + look = [[c[:24] for c in row[:16]] for row in cells(text)[:8]] + return {**head, "갈래": "물음", "보기": look} + named = any(h["번"] or h["이름"] != SHEET for h in parsed["호표"]) + if lines_only or not named: + return {**head, "갈래": "줄", **fixed_lines(parsed, taken)} + keep = {k: parsed[k] for k in ("꼴", "시트", "호표", "단산", "검산")} + row = hstore.save(folder, f"{SHEET} {datetime.now():%m-%d %H:%M}", keep, who) + return {**head, "갈래": "만듦", "키": row["키"], **hb.build_all(folder, folder, row["키"], who)} diff --git a/M02_MasterTemplete/M02_Hopyo_Relink.py b/M02_MasterTemplete/M02_Hopyo_Relink.py new file mode 100644 index 000000000..bf2054d2c --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Relink.py @@ -0,0 +1,148 @@ +"""「이 공사 단가로 다시 잇기」(PLAN 705-2 · 요구서 4장 2 · EST 「신 · 구 단가 비교」 꼴) — 고정 줄마다 붙인 단가 ↔ +단가 책(M02 = M01 최신 판 노임 「값」 · 자재 기준단가) · 이음 후보 · 고른 줄만 이음 바꿈 · 되돌리기. + +후보 = 705-3 이음과 같은 짝(`M02_Hopyo_Fixed.Linker`) — 노임 = 이름이 우리 직종(단위 인) · + 자재 = 이름 · 규격 같은 품목 + 단위 배 1 · 단가가 달라도 후보(바꿀지는 사람이 고름) · 기계 = 없음(B09 중기 몫). +줄 = 비율 줄 뺀 고정 줄 · 줄 가름 = `fixed_id` · 붙인 이름 끝 「 (2)」(줄 가름)은 떼고 찾음. +바꿈 = `{키, 줄, 노임?, 자재?}` — 키 = 이 층 사전조합(SC) · 값 null = 이음 풂(붙인 단가로) · + 서버가 문서를 읽어 이음 칸만 고쳐 씀(숫자 안 받음) · 전후 1 단위 값(`Labor_Ref.Expand`) · `되돌림` = 옛 이음. +열린 문서(저장 전) = `doc` 로 줄 · 후보만 — 바꿈은 화면이 문서에 넣고 [저장]. +""" + +from __future__ import annotations + +import re +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Fixed as hfix +from M02_MasterTemplete import M02_Hopyo_Shares as shares +from M02_MasterTemplete import M02_Labor_Fixed as fixed +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +KINDS = ("노임", "자재") +CHANGES_MAX = 2000 +_SUFFIX = re.compile(r"\s*\(\d+\)$") + + +def _num(raw: Any) -> float | None: + got = fixed._num(raw) + return float(got) if got is not None else None + + +class Finder: + """후보 찾기 — 단가 책(M01 최신 판) 한 번 읽음.""" + + def __init__(self) -> None: + self.linker = hfix.Linker() + self.whole = self.linker.whole + + def _name(self, code: str) -> dict[str, str]: + try: + row = self.whole.get(code) or {} + except fixed.mf.FormulaError: + row = {} + return {k: str(row.get(k) or "") for k in ("이름", "규격", "단위")} + + def price(self, kind: str, code: str | None, unit: str | None) -> float | None: + if not code: + return None + try: + if kind == "노임": + return _num((self.whole.get(code) or {}).get("값")) + return _num(fixed.mm.element(self.whole, code, {}, unit or None)[0]) + except fixed.mf.FormulaError: + return None + + def candidates(self, fx: dict[str, Any]) -> list[dict[str, Any]]: + name = _SUFFIX.sub("", str(fx.get("이름") or "")) + line = {"이름": name, "규격": fx.get("규격") or "", "단위": fx.get("단위") or ""} + out = [] + trade = shares.trade(name) if line["단위"].strip() == "인" else None + for code, value in self.linker.labor.get(trade or "", []): + out.append({"비목": "노임", "키": code, **self._name(code), "단가": _num(value)}) + price = fx.get("단가") or {} + if not trade and (_num(price.get("재료비")) or not any(_num(v) for v in price.values())): + for code, value in self.linker._mats({**line, "갈래": "자재"}): + out.append({"비목": "자재", "키": code, **self._name(code), "단가": _num(value)}) + return out + + def row(self, key: str | None, one: dict[str, Any]) -> dict[str, Any]: + fx = one[fixed.FIXED] + link = fx.get("이음") or {} + qty = _num((one.get("수량") or {}).get("손")) + now = { + k: self.price(k, link.get(k), fx.get("단위")) + for k in KINDS + if link.get(k) # 이은 비목 지금 단가 + } + return { + "키": key, + "줄": fixed.fixed_id(fx), + **{k: fx.get(k) or "" for k in ("이름", "규격", "단위")}, + "수량": qty, + "붙인": {k: _num((fx.get("단가") or {}).get(k)) or 0 for k in fixed.COSTS}, + "이음": {k: link.get(k) for k in KINDS}, + "지금": now, + "후보": self.candidates(fx), + } + + +def _lines(doc: dict[str, Any]) -> list[dict[str, Any]]: + return [ + one + for one in doc.get("담은로직") or [] + if fixed.is_fixed(one) and not one[fixed.FIXED].get("비율") + ] + + +def rows(keys: list[str] | None = None, doc: dict[str, Any] | None = None) -> dict[str, Any]: + """고정 줄 표 — `keys` = 이 층 사전조합들 · `doc` = 열린 문서 → `{줄 [...], 문서 [{키, 이름}]}`.""" + finder, out, docs = Finder(), [], [] + if doc is not None: + out = [finder.row(None, one) for one in _lines(doc)] + for key in keys or []: + refs.check_key("precombo", key) + got = store.read("precombo", key)["문서"] + docs.append({"키": key, "이름": got.get("이름") or key}) + out += [finder.row(key, one) for one in _lines(got)] + return {"줄": out, "문서": docs} + + +def _total(key: str) -> float | None: + at = refs.here() + got = refs.Expand(m01.loaded()[1])({"층": at[0], "주인": at[1], "키": key}) + return _num((got.get("기준") or {}).get("계")) + + +def apply(changes: list[dict[str, Any]]) -> dict[str, Any]: + """바꿈 → 이 층 사전조합 이음 칸만 고쳐 씀 · M01 에 없는 키 400(`Labor_Fixed.clean`) → + `{바뀜, 문서 [{키, 이름, 전, 후}], 되돌림 [{키, 줄, 노임, 자재}]}`.""" + if len(changes) > CHANGES_MAX: + raise store.StoreError(400, f"바꿈은 {CHANGES_MAX} 줄 안") + by_key: dict[str, list[dict[str, Any]]] = {} + for one in changes: + if not isinstance(one, dict) or not isinstance(one.get("줄"), str): + raise store.StoreError(400, "바꿈 줄은 {키, 줄, 노임?, 자재?}") + refs.check_key("precombo", str(one.get("키") or "")) + by_key.setdefault(str(one["키"]), []).append(one) + whole, done, back, docs = m01.loaded()[1], 0, [], [] + for key, wants in by_key.items(): + got = store.read("precombo", key) + doc, before = got["문서"], _total(key) + lines = {fixed.fixed_id(x[fixed.FIXED]): x for x in _lines(doc)} + for want in wants: + one = lines.get(want["줄"]) + if one is None: + raise store.StoreError(404, f"없는 고정 줄 「{want['줄']}」 ({key})") + link = dict(one[fixed.FIXED].get("이음") or {}) + back.append({"키": key, "줄": want["줄"], **{k: link.get(k) for k in KINDS}}) + for k in KINDS: + if k in want: + link[k] = want[k] or None + one[fixed.FIXED] = fixed.clean({**one[fixed.FIXED], "이음": link}, want["줄"], whole) + done += 1 + store.write("precombo", key, got["판"], doc) + docs.append({"키": key, "이름": doc.get("이름") or key, "전": before, "후": _total(key)}) + return {"바뀜": done, "문서": docs, "되돌림": back} diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 76a3429b2..25ed692aa 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -25,8 +25,11 @@ from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid +from M02_MasterTemplete import M02_Hopyo_Item as hopyo_item from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match +from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste +from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs @@ -442,3 +445,101 @@ def hopyo_delete( folder = _root(session, layer, owner, write=True) _call(folder, hopyo_store.delete, folder, key) return {"지움": key} + + +class PasteBody(_Strict): + """엑셀 붙여넣기(705-2) — `글` = 붙인 글 그대로 · `열` = 기억한 열 짝(없으면 머리 글 · 짐작 + 물음) · + `줄만` = 열린 문서에 줄로 · `있음` = 문서의 고정 줄 가름(이름 겹침 피함).""" + + 글: str = Field(max_length=hopyo_paste.TEXT_MAX) + 열: dict[str, int] | None = None + 줄만: bool = False + 있음: list[str] = Field(default_factory=list, max_length=500) + + +@router.post("/layers/{layer}/hopyo-paste") +def hopyo_paste_run( + layer: Layer, + body: PasteBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """[Ctrl+V] — `갈래` 물음(열 짝) · 줄(고정 줄 · 저장은 문서 [저장]) · 만듦(실무자료 + 호표마다 사전조합 · + 층 쓰기 · `build_all` 답 + 키).""" + folder = _root(session, layer, owner, write=True) + args = (folder, body.글, body.열, _who(session), body.줄만, body.있음) + return _call(folder, hopyo_paste.paste, *args) + + +class RelinkRowsBody(BaseModel): + """다시 잇기 표(705-2) — `키` = 이 층 사전조합들 · `문서` = 열린 문서(저장 전 · 줄 · 후보만).""" + + 키: list[str] = Field(default_factory=list, max_length=500) + 문서: dict[str, Any] | None = None + + +@router.post("/layers/{layer}/hopyo-relink/rows") +def hopyo_relink_rows( + layer: Layer, + body: RelinkRowsBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """「이 공사 단가로 다시 잇기」 표 — 고정 줄마다 붙인 단가 · 이은 단가 · 후보(층 읽기).""" + folder = _root(session, layer, owner) + return _call(folder, hopyo_relink.rows, body.키, body.문서) + + +class RelinkBody(_Strict): + """바꿈 `[{키, 줄, 노임?, 자재?}]` — 이음 키만(숫자 칸 없음 · 값 null = 풂).""" + + 바꿈: list[dict[str, Any]] = Field(max_length=hopyo_relink.CHANGES_MAX) + + +@router.post("/layers/{layer}/hopyo-relink") +def hopyo_relink_apply( + layer: Layer, + body: RelinkBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """[바꿈] · [되돌리기] — 이 층 사전조합 이음 칸 고쳐 씀(층 쓰기) → `{바뀜, 문서, 되돌림}`.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, hopyo_relink.apply, body.바꿈) + + +@router.get("/layers/{layer}/hopyo/{key}/{no}/siblings") +def hopyo_siblings( + layer: Layer, + key: str, + no: int, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """규격 묶음 제안(704-4) — 이름 · 단위 · 줄 구성 같고 규격만 다른 호표 `{묶음 [{번, 규격, 열}], 이름}`.""" + folder = _root(session, layer, owner) + return _call(folder, hopyo_item.siblings, folder, key, no) + + +class ItemBody(BuildBody): + """[구조물 조합으로](704-4) — 확인 창 몸 그대로 + `묶음` = 규격 열로 묶을 호표 번 · `이름` = 항목 이름.""" + + 묶음: list[int] = Field(default_factory=list, max_length=20) + 이름: str | None = Field(None, max_length=120) + + +@router.post("/layers/{layer}/hopyo-item") +def hopyo_to_item( + layer: Layer, + body: ItemBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """실무 호표 → 이 층 구조물 조합 항목(사전조합 안 만듦 · 이 층 쓰기 · 파일 층 읽기) → + `{key, 열, 이름, 줄수, 열수, 빠진줄}`.""" + folder = _root(session, layer, owner, write=True) + source = _root(session, body.층, body.주인) + lines = [one.model_dump(exclude_none=True) for one in body.줄] + whole = body.통째.model_dump(exclude_none=True) if body.통째 else None + args = (folder, source, body.키, body.번, lines, whole, body.묶음, body.이름, _who(session)) + return _call(folder, hopyo_item.make_item, *args) diff --git a/M02_MasterTemplete/M02_Labor_Spec.py b/M02_MasterTemplete/M02_Labor_Spec.py index a7d3b168f..dcd7a8c0a 100644 --- a/M02_MasterTemplete/M02_Labor_Spec.py +++ b/M02_MasterTemplete/M02_Labor_Spec.py @@ -16,6 +16,7 @@ from decimal import Decimal, InvalidOperation from pathlib import Path from typing import Any +from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Ref as refs NAME_MAX = 20 # 규격 이름 · 열 이름 글자 수 상한 — 화면 칸과 같음 @@ -46,7 +47,10 @@ def _text(raw: Any, what: str) -> str: def line_key(one: dict) -> Any: - """줄 id — 참조 줄 = `ref_id` · 로직 줄 = 로직 키 · 자재 줄 = 코드.""" + """줄 id — 참조 줄 = `ref_id` · 고정 줄 = `fixed_id`(705 · 짝 TS `lineId`) · 로직 줄 = 로직 키 · + 자재 줄 = 코드.""" + if fixed.is_fixed(one): + return fixed.fixed_id(one[fixed.FIXED]) if isinstance(one.get("참조"), dict): return refs.ref_id(one["참조"]) return one.get("로직") or one.get("코드") diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 27f12f6bc..d9f8188c9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -8,7 +8,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { call, getActiveLayer, getActiveOwner, type Layer } from "./M02_MasterTemplete_Api_Fetch"; -import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import type { MatMarks } from "./M02_MasterTemplete_Labor_Api"; /** 층 · 주인(null = 자기 층) */ @@ -275,6 +275,8 @@ export interface HopyoBuiltAll { 차이: number | null; }[]; 실패: { 번: number; 까닭: string }[]; + /** 만든 · 그대로 둔 사전조합(705-2 · 다시 잇기) */ + 만듦?: { 번: number; 이름: string; 키: string }[]; } /** [로직 추가] 「실무 호표」 줄 하나 — 확인 = 문서 층에 확인된 사전조합 */ @@ -411,3 +413,104 @@ export async function uploadHopyo(scope: HopyoScope, file: File): Promise +>; + +/** 붙여넣기 답 — 물음(열 짝 짐작 + 보기) · 줄(고정 줄 · 문서에 넣음) · 만듦(호표마다 사전조합) */ +export type PasteResult = + | { 갈래: "물음"; 열: PasteCols; 짐작: boolean; 보기: string[][] } + | { + 갈래: "줄"; + 열: PasteCols; + 줄: { 고정: LaborFixed; 별칭: string | null; 메모: string; 수량: { 손: number | null } }[]; + 엑셀: number; + 이음: number; + 이음후보: number; + } + | ({ 갈래: "만듦"; 열: PasteCols; 키: string } & HopyoBuiltAll); + +/** [Ctrl+V](705-2) — 붙인 글 그대로(숫자는 서버가 글에서) */ +export const pasteHopyo = ( + scope: HopyoScope, + 글: string, + 열: PasteCols | null, + opt: { 줄만?: boolean; 있음?: string[] } = {}, +): Promise => + call(at(scope, "/hopyo-paste"), { + method: "POST", + body: JSON.stringify({ 글, ...(열 ? { 열 } : {}), ...opt }), + }); + +/** 다시 잇기 표 한 줄(705-2) — 붙인 단가 · 이은 단가(지금) · 후보 */ +export interface RelinkRow { + 키: string | null; + 줄: string; + 이름: string; + 규격: string; + 단위: string; + 수량: number | null; + 붙인: Record<"노무비" | "재료비" | "경비", number>; + 이음: Record<"노임" | "자재", string | null>; + 지금: Partial>; + 후보: { + 비목: "노임" | "자재"; + 키: string; + 이름: string; + 규격: string; + 단위: string; + 단가: number | null; + }[]; +} + +/** 바꿈 한 줄 — 값 null = 이음 풂 */ +export interface RelinkChange { + 키: string; + 줄: string; + 노임?: string | null; + 자재?: string | null; +} + +export const relinkRows = async ( + scope: HopyoScope, + body: { 키?: string[]; 문서?: unknown }, +): Promise => + ( + await call<{ 줄: RelinkRow[] }>(at(scope, "/hopyo-relink/rows"), { + method: "POST", + body: JSON.stringify(body), + }) + ).줄; + +export const relinkApply = ( + scope: HopyoScope, + 바꿈: RelinkChange[], +): Promise<{ + 바뀜: number; + 문서: { 키: string; 이름: string; 전: number | null; 후: number | null }[]; + 되돌림: RelinkChange[]; +}> => call(at(scope, "/hopyo-relink"), { method: "POST", body: JSON.stringify({ 바꿈 }) }); + +/** 규격 묶음 제안(704-4) — 이름 · 단위 · 줄 구성 같고 규격만 다른 호표(연 호표 먼저) */ +export const hopyoSiblings = ( + scope: HopyoScope, + key: string, + no: number, +): Promise<{ 묶음: { 번: number; 규격: string; 열: string }[]; 이름: string | null }> => + call(at(scope, `/hopyo/${enc(key)}/${no}/siblings`)); + +/** [구조물 조합으로](704-4) — 확인 창 몸 그대로 → 이 층 구조물 조합 항목(사전조합 안 만듦) */ +export const buildHopyoItem = ( + target: HopyoScope, + from: { 층: Layer; 주인: string | null; 키: string; 번: number }, + 줄: BuildLine[], + 통째: { 대상: BuildTarget[] } | null, + 묶음: number[], + 이름: string, +): Promise<{ key: string; 열: string; 이름: string; 줄수: number; 열수: number }> => + call(at(target, "/hopyo-item"), { + method: "POST", + body: JSON.stringify({ ...from, ...(통째 ? { 통째 } : { 줄 }), 묶음, 이름 }), + }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index 92867ef87..f293e9ce3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -12,6 +12,7 @@ * · 705-3 — 체크 안 한 줄 = 고정 줄(엑셀 숫자 그대로) · 만든 사전조합이 있으면 첫 체크 = 그 품셈 줄(`만든.품셈`) · * [품셈으로 바꾸기 (n)] = 맞음 · 별칭 줄 체크 + 생성 · [모두 고정으로] = 체크 모두 끔 + 생성(되돌리기) · * 생성 물음에 전후 금액(품셈 = 우리 미리 셈 · 고정 = 엑셀) · 품 다른 줄 수. + * · 704-4 — [구조물 조합으로] = 같은 몸으로 구조물 조합 항목 바로(`Hopyo_ToItem` · 규격 묶음 제안) * 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build` · `/labor/{열}/calc`) — 화면은 보이기 · 체크 · 고름만. * ========================================================================== */ @@ -49,6 +50,7 @@ import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet"; import { calcLabor, fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; import { draftDoc } from "./logic/M02_MasterTemplete_Hopyo_Draft"; +import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem"; import "./M02_MasterTemplete_Hopyo.css"; export const POLL_MS = 3000; @@ -500,6 +502,15 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { }); } + const toItemButton = (h: HopyoOne["호표"], whole: boolean): HTMLElement => + createButton({ + label: "구조물 조합으로", + size: "sm", + onClick: () => + void toItem({ scope: opt.scope, target, key: opt.key, no: opt.no, hopyo: h, + lines: whole ? [] : bodyOf(h.줄, d), whole: whole ? { 대상: h.통째!.대상.map(toTarget) } : null }), // prettier-ignore + }); + function draw(): void { if (!got) return; const h = got.호표; @@ -545,7 +556,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { }); tools.push(poom, back); } - tools.push(go); + tools.push(go, toItemButton(h, whole)); // 704-4 — 사전조합 안 거치고 구조물 조합 } const head = el("div", { className: "ui-toolbar m02-hopyo__bar", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts new file mode 100644 index 000000000..5b4fd7eb5 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts @@ -0,0 +1,234 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Relink.ts + * 「이 공사 단가로 다시 잇기」(PLAN 705-2 · EST 「신 · 구 단가 비교」 꼴) — 고정 줄마다 붙인 단가 · 지금 이은 단가 · + * 이음 후보(이름 · 규격 · 단위 맞춤 · 705-3 이음과 같은 짝) · 차이 → 줄마다 · 모두 골라 [바꿈] · [되돌리기]. + * 후보 칸 누름 = 다음 후보(끝 = 그대로 · 이은 줄은 「풂」) · 고름 칸 누름 = 켬 · 끔 + * 출처 셋 — 사전조합 여럿(`relinkKeys` · 서버가 이음 칸만 고쳐 씀 · 전후 1 단위 값) · + * 열린 문서(`relinkDoc` · 문서에 넣고 [저장]) · B09 지금 호표(부르는 쪽 · 이 공사 단가 책) + * 숫자는 서버 단가 책 값 — 화면은 이음 키만 고름 + * ========================================================================== */ + +import { createButton, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { createDataTable, type DataRow } from "@ui/ui_template_data_table"; +import { createText } from "@ui/ui_template_text"; +import { + pageScope, + relinkApply, + relinkRows, + type RelinkChange, + type RelinkRow, +} from "./M02_MasterTemplete_Hopyo_Api"; +import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api"; +import { lineId } from "./M02_MasterTemplete_Labor_Calc"; + +type Kind = "노임" | "자재"; +const COST: Record = { 노임: "노무비", 자재: "재료비" }; + +export interface RelinkSource { + title: string; + /** 이은 단가 칸 머리 — 「최신 판 단가」 · 「이 공사 단가」 */ + priceLabel: string; + rows: () => Promise; + /** 바꿈 → 되돌릴 바꿈 · 알림 글 */ + apply: (changes: RelinkChange[]) => Promise<{ back: RelinkChange[]; note: string }>; + /** 단가 덮기(B09 = 이 공사 단가 책) — 없으면 서버 값 */ + price?: (kind: Kind, key: string) => number | null | undefined; +} + +const won = (v: number | null | undefined): string => + v == null ? "-" : Math.round(v).toLocaleString("ko-KR"); + +/** 사전조합 여럿(붙여넣기 · 실무자료 들여오기 뒤) — 서버가 쓰고 전후 1 단위 값 */ +export function relinkKeys(keys: string[]): RelinkSource { + const scope = pageScope(); + return { + title: `이 공사 단가로 다시 잇기 — 사전조합 ${keys.length}`, + priceLabel: "최신 판 단가", + rows: () => relinkRows(scope, { 키: keys }), + apply: async (changes) => { + const got = await relinkApply(scope, changes); + const sum = (k: "전" | "후"): number => got.문서.reduce((a, d) => a + (d[k] ?? 0), 0); + return { + back: got.되돌림, + note: `바뀐 줄 ${got.바뀜} · 사전조합 ${got.문서.length} · 1 단위 값 합 ${won(sum("전"))} → ${won(sum("후"))}`, + }; + }, + }; +} + +/** 열린 문서(저장 전) — 이음을 문서에 넣고 `changed`(서버 셈 · [저장]) */ +export function relinkDoc(doc: LaborDoc, changed: () => void): RelinkSource { + const scope = pageScope(); + return { + title: "이 공사 단가로 다시 잇기 — 이 일위대가", + priceLabel: "최신 판 단가", + rows: () => relinkRows(scope, { 문서: doc }), + apply: async (changes) => { + const back: RelinkChange[] = []; + for (const c of changes) { + const row = doc.담은로직.find((r) => r.고정 && lineId(r) === c.줄); + if (!row?.고정) continue; + const link = { 노임: null, 자재: null, 기계: null, ...(row.고정.이음 ?? {}) }; + back.push({ 키: c.키, 줄: c.줄, 노임: link.노임, 자재: link.자재 }); + if ("노임" in c) link.노임 = c.노임 ?? null; + if ("자재" in c) link.자재 = c.자재 ?? null; + row.고정 = { ...row.고정, 이음: link }; + } + changed(); + return { back, note: `바뀐 줄 ${back.length} — [저장]으로 남김` }; + }, + }; +} + +/** 다시 잇기 창 */ +export async function openRelink(src: RelinkSource): Promise { + let rows: RelinkRow[] = []; + /** 줄마다 고른 후보 차례(후보 수 = 그대로 · 이은 줄의 +1 = 풂) · 켬 */ + let pick: number[] = []; + let on: boolean[] = []; + let back: RelinkChange[] = []; + const host = document.createElement("div"); + const info = createText("", "muted"); + + const linked = (r: RelinkRow): boolean => !!(r.이음.노임 || r.이음.자재); + const priceOf = (kind: Kind, key: string, given: number | null | undefined): number | null => + src.price?.(kind, key) ?? given ?? null; + const load = async (): Promise => { + rows = await src.rows(); + pick = rows.map((r) => (r.후보.length && !linked(r) ? 0 : r.후보.length)); + on = rows.map((r) => !!r.후보.length && !linked(r)); + }; + const choice = (i: number): RelinkRow["후보"][number] | "keep" | "free" => { + const r = rows[i]; + if (pick[i] < r.후보.length) return r.후보[pick[i]]; + return pick[i] === r.후보.length ? "keep" : "free"; + }; + const changes = (): RelinkChange[] => + rows.flatMap((r, i): RelinkChange[] => { + const c = choice(i); + if (!on[i] || c === "keep") return []; + if (c === "free") return [{ 키: r.키 ?? "", 줄: r.줄, 노임: null, 자재: null }]; + return [{ 키: r.키 ?? "", 줄: r.줄, [c.비목]: c.키 }]; + }); + const nowPrice = (r: RelinkRow): string => + (["노임", "자재"] as Kind[]) + .filter((k) => r.이음[k]) + .map((k) => won(priceOf(k, r.이음[k] as string, r.지금[k]))) + .join(" · ") || "손 단가"; + const draw = (): void => { + let gapSum = 0; + const data: DataRow[] = rows.map((r, i) => { + const c = choice(i); + const cand = typeof c === "object" ? c : null; + const price = cand ? priceOf(cand.비목, cand.키, cand.단가) : null; + const gap = + cand && price != null && r.수량 != null ? (price - r.붙인[COST[cand.비목]]) * r.수량 : null; + if (on[i] && gap != null) gapSum += gap; + return { + key: `${r.키 ?? ""}|${r.줄}`, + name: [r.이름, r.규격].filter(Boolean).join(" · "), + unit: r.단위, + qty: r.수량, + pasted: r.붙인.노무비 + r.붙인.재료비 + r.붙인.경비, + now: nowPrice(r), + cand: cand + ? `${cand.비목} ${cand.이름} ${cand.규격} (${pick[i] + 1}/${r.후보.length})` + : c === "free" + ? "이음 풂 — 붙인 단가로" + : r.후보.length + ? `그대로 (후보 ${r.후보.length})` + : "후보 없음", + price, + gap, + pick: on[i] ? "✓" : "", + }; + }); + const table = createDataTable({ + columns: [ + { key: "name", label: "품명 · 규격", width: 220 }, + { key: "unit", label: "단위", width: 48 }, + { key: "qty", label: "수량", kind: "number", decimals: 4, trim: true, width: 70 }, + { key: "pasted", label: "붙인 단가", kind: "number", decimals: 1, width: 90 }, + { key: "now", label: src.priceLabel, width: 100 }, + { key: "cand", label: "이음 후보 (누름 = 다음)", width: 260 }, + { key: "price", label: "후보 단가", kind: "number", decimals: 1, width: 90 }, + { key: "gap", label: "금액 차이", kind: "number", decimals: 1, width: 90 }, + { key: "pick", label: "고름", width: 48 }, + ], + rows: data, + frozen: 1, + empty: "고정 줄 없음", + onCellClick: (i, key) => { + const r = rows[i]; + if (key === "cand") { + pick[i] = (pick[i] + 1) % (r.후보.length + (linked(r) ? 2 : 1)); + on[i] = choice(i) !== "keep"; + } else if (key === "pick") on[i] = !on[i]; + else return; + draw(); + }, + cellClass: (i, key) => (key === "pick" && on[i] ? "is-edited" : null), + }); + const n = changes().length; + info.textContent = + `고정 줄 ${rows.length} · 후보 있는 줄 ${rows.filter((r) => r.후보.length).length} · ` + + `이은 줄 ${rows.filter(linked).length} · 고른 줄 ${n} · 금액 차이 합 ${won(gapSum)}`; + apply.disabled = !n; + undo.disabled = !back.length; + host.replaceChildren(table.root); + }; + const run = async (list: RelinkChange[], undoing: boolean): Promise => { + try { + const got = await src.apply(list); + back = undoing ? [] : got.back; + showToast(undoing ? `되돌림 — ${got.note}` : got.note, "success", 6000); + await load(); + draw(); + } catch (error) { + showToast(`실패 — ${error instanceof Error ? error.message : String(error)}`, "error"); + } + }; + const all = createButton({ + label: "모두 고름", + variant: "ghost", + onClick: () => { + const want = !rows.every((r, i) => !r.후보.length || on[i]); + on = rows.map((r, i) => (want ? !!r.후보.length && choice(i) !== "keep" : false)); + draw(); + }, + }); + const apply = createButton({ label: "바꿈", onClick: () => void run(changes(), false) }); + const undo = createButton({ + label: "되돌리기", + variant: "ghost", + onClick: () => void run(back, true), + }); + try { + await load(); + } catch (error) { + showToast( + `다시 잇기 표 실패 — ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + return; + } + openModal({ + title: src.title, + closeLabel: "닫기", + wide: true, + width: "1100px", + actions: [all, undo, apply], + mount: (body) => { + body.append( + createText( + "붙인 단가 ↔ 단가 책 — 후보 칸을 눌러 고르고 [바꿈] · 품(수량)은 그대로 · 단가만 이음 · [되돌리기] = 바로 앞 바꿈", + "muted", + ), + info, + host, + ); + draw(); + }, + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index 95e4c47d0..ddb3f3de5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -55,6 +55,7 @@ import { toTarget, } from "./M02_MasterTemplete_Hopyo_Check"; import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose"; +import { openRelink, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink"; import "./M02_MasterTemplete_Hopyo.css"; export interface HopyoSheetOptions { @@ -537,7 +538,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { const next = "다음 — 호표 [수정]에서 [품셈으로 바꾸기]" + (got.이음후보 - ? ` · 이음 후보 ${got.이음후보} 줄(이름은 같고 단가가 다름)은 엑셀 단가 그대로 — 「이 공사 단가로 다시 잇기」에서 바꿈` + ? ` · 이음 후보 ${got.이음후보} 줄(이름은 같고 단가가 다름)은 엑셀 단가 그대로 — 아래 [다음 — 이 공사 단가로 다시 잇기]에서 바꿈` : ""); const rows = [ ...got.다름.map((x) => @@ -556,9 +557,15 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { el("tr", { children: [el("td", { text: `제${x.번}호표` }), el("td", { text: x.까닭 })] }), ), ]; + const relink = createButton({ + label: "다음 — 이 공사 단가로 다시 잇기", + size: "sm", + onClick: () => void openRelink(relinkKeys((got.만듦 ?? []).map((x) => x.키))), + }); // 705-2 result.replaceChildren( note(allText(got)), note(next), + relink, ...(rows.length ? [table(["호표", "합계 다름 · 실패", "차이"], rows)] : []), ); showToast(allText(got), got.빠짐 || got.실패.length ? "warning" : "success"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts new file mode 100644 index 000000000..429e3c8ba --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts @@ -0,0 +1,112 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_ToItem.ts + * [구조물 조합으로](PLAN 704-4 뒷부분) — 실무자료 확인 창 몸 그대로 구조물 조합 항목(SU)을 바로 만듦(사전조합 안 거침). + * 창 = 항목 이름(기본 = 호표 이름 · 규격) · 규격 묶음 제안(같은 이름 · 줄 구성 · 규격만 다른 호표 → 한 항목의 규격 열 · + * 기본 켬 · 끄면 이 호표 하나) · [만들기] → 서버 `/hopyo-item`(이 층 쓰기 · 「실무자료」 폴더) → 나무 다시 + * 숫자는 서버가 파일에서 — 화면은 체크 · 고름만 + * ========================================================================== */ + +import { + createButton, + el, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { createInputField } from "@ui/ui_template_input"; +import { openModal } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import { + buildHopyoItem, + hopyoSiblings, + type BuildLine, + type BuildTarget, + type HopyoOne, + type HopyoScope, +} from "./M02_MasterTemplete_Hopyo_Api"; + +/** 항목을 만들었음(document 알림) — 구조물 조합 나무 · 옆 목록 다시 */ +export const ITEM_BUILT = "m02-hopyo-item-built"; + +export interface ToItemOptions { + scope: HopyoScope; + target: HopyoScope; + key: string; + no: number; + hopyo: HopyoOne["호표"]; + lines: BuildLine[]; + whole: { 대상: BuildTarget[] } | null; +} + +export async function toItem(o: ToItemOptions): Promise { + let sib: Awaited>; + try { + sib = await hopyoSiblings(o.scope, o.key, o.no); + } catch (error) { + showToast( + `묶음 제안 실패 — ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + return; + } + const h = o.hopyo; + const one = [h.이름, h.규격].filter(Boolean).join(" "); + const bundle = sib.묶음.length > 1; + const name = createInputField({ label: "구조물 조합 이름", value: bundle ? h.이름 : one }); + const pack = bundle + ? createCheckbox( + `규격 열로 묶음 — ${sib.이름}: ${sib.묶음.map((r) => `${r.열}(제${r.번}호표)`).join(" · ")}`, + { + checked: true, + onChange: (on) => (name.input.value = on ? h.이름 : one), + }, + ) + : null; + openModal({ + title: `구조물 조합으로 — 제${o.no}호표`, + closeLabel: "취소", + width: "40rem", + mount: (body, close, actions) => { + const go = createButton({ + label: "만들기", + onClick: async () => { + const label = name.input.value.trim(); + if (!label) return name.setError("이름이 빔"); + const from = { 층: o.scope.layer, 주인: o.scope.owner, 키: o.key, 번: o.no }; + const with_ = pack?.input.checked + ? sib.묶음.map((r) => r.번).filter((n) => n !== o.no) + : []; + showLoadingOverlay(); + try { + const got = await buildHopyoItem(o.target, from, o.lines, o.whole, with_, label); + close(); + document.dispatchEvent(new Event(ITEM_BUILT)); + showToast( + `구조물 조합 「${got.이름}」 만듦 — 「실무자료」 폴더 · 줄 ${got.줄수}` + + (got.열수 > 1 ? ` · 규격 열 ${got.열수}` : ""), + "success", + 6000, + ); + } catch (error) { + showToast( + `못 만듦 — ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + } finally { + hideLoadingOverlay(); + } + }, + }); + actions.append(go); + body.append( + createText( + "체크한 줄 = 품셈 줄 · 나머지 = 고정 줄(엑셀 숫자 그대로) — 사전조합을 거치지 않고 구조물 조합 항목을 바로 만듦", + "muted", + ), + name.root, + ...(pack ? [el("div", { children: [pack.root] })] : []), + ); + }, + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 6005b80f2..e18842fb5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -11,7 +11,7 @@ import { type LogicSummary, type SubBrief, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import type { LaborCalc, LaborRef, LaborSpec } from "./M02_MasterTemplete_Labor_Calc"; +import type { LaborCalc, LaborFixed, LaborRef, LaborSpec } from "./M02_MasterTemplete_Labor_Calc"; import { call, getActiveLayer, @@ -182,6 +182,8 @@ export interface LaborRow { 승격?: boolean; /** 로직 자재 세 칸(148-4) — {호표 차례: 세 칸} · 금액 무관 */ 자재표시?: Record; + /** 고정 줄(705-1 · 705-2 붙여넣기) — 숫자 그대로 · 로직 · 참조 없음 */ + 고정?: LaborFixed; } /** 자재 세 칸(148-4 · 서버 `M02_Labor_Doc.MARKS` 와 같음) — 빈 칸 = 읽을 때 기본값 · 금액 무관 */ export const MAT_MARKS = { @@ -260,7 +262,8 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc { ...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}), ...(r.고름 && Object.keys(r.고름).length ? { 고름: { ...r.고름 } } : {}), ...(r.승격 === true && !r.참조 ? { 승격: true } : {}), - ...(r.참조 ? {} : cleanShown(r.자재표시)), + ...(r.참조 || r.고정 ? {} : cleanShown(r.자재표시)), + ...(r.고정 ? { 고정: r.고정 } : {}), // 705-1 — 읽을 때 떨구면 [저장]에서 줄이 사라짐 })), 자재: (d.자재 ?? []).map((m) => ({ 코드: m.코드, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 5c7b65d42..280c3b2d6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -218,7 +218,7 @@ export function mountLaborDetail( ), ); const draw = (): void => { - gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && !lib && changed)); + gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && changed, !lib)); drawLogic(); matHost.replaceChildren( matTable( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts index b2addf083..b4f6bd348 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts @@ -22,6 +22,7 @@ import { lineId } from "./M02_MasterTemplete_Labor_Calc"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { valueLine } from "./M02_Labor_Values_Model"; import { specBar, specCells, withSpecs, type SpecEdit } from "./M02_MasterTemplete_Labor_Spec"; +import { pasteInto } from "./M02_MasterTemplete_Labor_Paste"; const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney => m @@ -46,6 +47,20 @@ export function laborBlock( const layer = row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : ""; const qty = line?.수량 ?? null; + const fx = row.고정; // 705-1 고정 줄 — 이름 · 규격 · 단위 = 줄 것 · 비고 = 이음 · 손 단가 + if (fx) + return { + name: fx.이름, + spec: fx.규격 ?? "", + qty, + unit: fx.비율 ? "%" : (fx.단위 ?? ""), + prices: money(line?.기준, line?.배 || 1), + amounts: money(line?.반영), + note: + line?.까닭 ?? + (Object.values(fx.이음 ?? {}).some(Boolean) ? tc("Grid_Linked") : tc("Grid_Hand")), + tag: HOPYO_TAG.fix, + }; return { name: row.별칭 || ((row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직), spec: [valueLine(logic?.입력 ?? [], row.입력값).text, layer].filter(Boolean).join(" · "), @@ -90,16 +105,19 @@ export function laborBlock( return withSpecs(block, doc, calc); } -/** 격자 하나 — `edit` 있으면 위에 규격 줄 · 규격 수량 칸 고침(704-4) */ +/** 격자 하나 — `edit` 있으면 Ctrl+V 붙여넣기(705-2 · `Labor_Paste`) · `spec` 이면 위에 규격 줄 · 규격 수량 칸 고침(704-4) */ export const laborGrid = ( doc: LaborDoc, calc: LaborCalc | null, logics: ReadonlyMap, edit?: SpecEdit | false, + spec = true, ): HTMLElement => { const block = laborBlock(doc, calc, logics); if (!edit) return createHopyoGrid([block]).root; - const grid = createHopyoGrid([block], specCells(block, doc, edit)).root; + const paste = (text: string): void => void pasteInto(doc, text, () => edit(false)); + if (!spec) return createHopyoGrid([block], {}, paste).root; + const grid = createHopyoGrid([block], specCells(block, doc, edit), paste).root; const box = document.createElement("div"); box.append(specBar(doc, calc, edit), grid); return box; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Paste.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Paste.ts new file mode 100644 index 000000000..cf00010b3 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Paste.ts @@ -0,0 +1,213 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Paste.ts + * 엑셀 붙여넣기(PLAN 705-2) — 일위대가 격자 Ctrl+V → 붙인 글 그대로 서버(`/hopyo-paste` · 숫자는 서버가 글에서 읽음). + * 열 짝 = 처음 한 번 묻고 기억(이 브라우저 `localStorage` · 사용자 설정) · 엑셀 머리 글이 붙어 오면 묻지 않음 + * 답 「줄」(호표 머리 없음) = 열린 문서에 고정 줄로 더함 → [저장] · 알림을 누르면 「이 공사 단가로 다시 잇기」 + * 답 「만듦」(호표 머리 있음) = 호표마다 사전조합(실무자료 「붙여넣기 …」) · 결과 창 [이 공사 단가로 다시 잇기] + * `askCols` · `withCols` = B09 펼친 일위대가 붙여넣기도 씀 + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { createSelectField } from "@ui/ui_template_input"; +import { openModal } from "@ui/ui_template_modal"; +import { createDataTable } from "@ui/ui_template_data_table"; +import { createText } from "@ui/ui_template_text"; +import { hideLoadingOverlay, showLoadingOverlay } from "@ui/ui_template_toast"; +import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check"; +import { + pageScope, + pasteHopyo, + type HopyoBuiltAll, + type PasteCols, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { openRelink, relinkDoc, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink"; +import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api"; +import { lineId } from "./M02_MasterTemplete_Labor_Calc"; + +const COLS_KEY = "aislo:hopyo-paste-cols"; +const FIELDS = ["품명", "규격", "수량", "단위", "금액", "노", "재", "경"] as const; +const LABEL: Record<(typeof FIELDS)[number], string> = { + 품명: "품명", + 규격: "규격", + 수량: "수량", + 단위: "단위", + 금액: "합계 금액", + 노: "노무비 단가", + 재: "재료비 단가", + 경: "경비 단가", +}; +const NEED = new Set(["품명", "수량", "단위"]); + +const letter = (n: number): string => + n < 26 ? String.fromCharCode(65 + n) : `${letter(Math.floor(n / 26) - 1)}${letter(n % 26)}`; +const won = (v: number | null | undefined): string => + v == null ? "-" : Math.round(v).toLocaleString("ko-KR"); + +export function rememberedCols(): PasteCols | null { + try { + const got = JSON.parse(localStorage.getItem(COLS_KEY) ?? "null") as PasteCols | null; + return got && typeof got === "object" ? got : null; + } catch { + return null; + } +} + +/** 열 짝 묻기 — 짐작 값이 미리 골라짐 · 보기 = 붙인 첫 줄들(칸 머리 A · B · C …) · 취소 = null */ +export function askCols(guess: PasteCols, look: string[][]): Promise { + return new Promise((done) => { + const width = Math.max(0, ...look.map((r) => r.length)); + const options = [ + { value: "", text: "없음" }, + ...Array.from({ length: width }, (_, n) => ({ + value: String(n), + text: `${letter(n)} 칸 · ${look.find((r) => r[n])?.[n] ?? ""}`.slice(0, 40), + })), + ]; + const picks = FIELDS.map((f) => + createSelectField({ + label: `${LABEL[f]}${NEED.has(f) ? " *" : ""}`, + options, + value: guess[f] != null ? String(guess[f]) : "", + compact: true, + }), + ); + const table = createDataTable({ + columns: Array.from({ length: width }, (_, n) => ({ key: `c${n}`, label: letter(n) })), + rows: look.map((r, i) => ({ + key: String(i), + ...Object.fromEntries(r.map((c, n) => [`c${n}`, c])), + })), + empty: "", + }); + let answered = false; + const { close } = openModal({ + title: "붙인 엑셀 열 짝 — 한 번만 묻고 기억", + closeLabel: "취소", + width: "960px", + beforeClose: () => { + if (!answered) done(null); + return true; + }, + mount: (body, _close, actions) => { + const ok = createButton({ + label: "확인", + onClick: () => { + const cols: PasteCols = {}; + FIELDS.forEach((f, i) => { + const v = picks[i].select.value; + if (v !== "") cols[f] = Number(v); + }); + if ([...NEED].some((f) => cols[f as keyof PasteCols] == null)) + return showToast("품명 · 수량 · 단위 칸은 꼭 고름", "warning"); + localStorage.setItem(COLS_KEY, JSON.stringify(cols)); + answered = true; + done(cols); + close(); + }, + }); + actions.append(ok); + body.append( + createText( + "엑셀 머리 글 없이 붙여 열 짝을 모름 — 칸마다 무엇인지 고름(짐작 값이 골라져 있음) · 다음부터 묻지 않음", + "muted", + ), + el("div", { className: "ui-toolbar", children: picks.map((p) => p.root) }), + table.root, + ); + }, + }); + }); +} + +/** 열 짝 고리 — 기억한 짝으로 부름 · 「물음」이면 묻고 다시 부름 · 취소 = null */ +export async function withCols( + run: (cols: PasteCols | null) => Promise, +): Promise { + let got = await run(rememberedCols()); + if (got.갈래 === "물음") { + const ask = got as { 열: PasteCols; 보기: string[][] }; + hideLoadingOverlay(); + const cols = await askCols(ask.열, ask.보기); + if (!cols) return null; + showLoadingOverlay(); + got = await run(cols); + } + return got as T; +} + +/** 만든 결과 창 — 엑셀 합계 = 우리 합계 · 다음 걸음 [이 공사 단가로 다시 잇기] */ +export function builtNotice(got: HopyoBuiltAll, keys: string[]): void { + const text = + `사전조합 ${got.만듦?.length ?? 0} 만듦(호표 ${got.호표} · 줄 ${got.줄} · 고정 줄 ${got.고정} · ` + + `하위 호표 참조 ${got.호표참조} · 빠짐 ${got.빠짐}) · 엑셀 합계 = 우리 합계 ${got.같음}/${got.호표}` + + ` · 저절로 이음 ${got.이음} · 이음 후보 ${got.이음후보}`; + const rows = got.다름.map((x) => ({ + key: String(x.번), + name: `제${x.번}호표 ${x.이름}`, + excel: x.엑셀, + ours: x.우리, + gap: x.차이, + })); + openModal({ + title: "엑셀 붙여넣기 — 만듦", + closeLabel: "닫기", + mount: (body, close, actions) => { + actions.append( + createButton({ + label: "다음 — 이 공사 단가로 다시 잇기", + onClick: () => { + close(); + void openRelink(relinkKeys(keys)); + }, + }), + ); + body.append(createText(text, got.빠짐 || got.실패.length ? "error" : "muted")); + if (rows.length) + body.append( + createDataTable({ + columns: [ + { key: "name", label: "합계 다른 호표", width: 260 }, + { key: "excel", label: "엑셀", kind: "number" }, + { key: "ours", label: "우리", kind: "number" }, + { key: "gap", label: "차이", kind: "number", decimals: 1 }, + ], + rows, + }).root, + ); + }, + }); +} + +/** 격자 Ctrl+V(M02 일위대가 상세 · 사전조합) — 줄 = 문서에 더함(`changed`) · 만듦 = 사전조합 여럿 */ +export async function pasteInto(doc: LaborDoc, text: string, changed: () => void): Promise { + const scope = pageScope(); + const have = doc.담은로직.map((r) => lineId(r) ?? "").filter((id) => id.startsWith("고정|")); + showLoadingOverlay(); + try { + const got = await withCols((cols) => pasteHopyo(scope, text, cols, { 있음: have })); + if (!got || got.갈래 === "물음") return; + if (got.갈래 === "만듦") { + document.dispatchEvent(new Event(HOPYO_BUILT)); + builtNotice( + got, + (got.만듦 ?? []).map((x) => x.키), + ); + return; + } + for (const one of got.줄) + doc.담은로직.push({ 로직: "", 고정: one.고정, 별칭: one.별칭, 메모: one.메모, 입력값: {}, + 수량: one.수량.손 == null ? null : { 손: one.수량.손 } }); // prettier-ignore + changed(); + showToast( + `고정 줄 ${got.줄.length} 붙임 · 엑셀 합계 ${won(got.엑셀)} · 이음 ${got.이음} · 후보 ${got.이음후보}` + + " — [저장]으로 남김 · 이 알림을 누르면 이 공사 단가로 다시 잇기", + "success", + 9000, + () => void openRelink(relinkDoc(doc, changed)), + ); + } catch (error) { + showToast(`붙여넣기 실패 — ${error instanceof Error ? error.message : String(error)}`, "error"); + } finally { + hideLoadingOverlay(); + } +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index a48088c51..84f9ca0fd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -13,6 +13,7 @@ * ========================================================================== */ import { createButton, createSelectField, el } from "@ui/ui_template_elements"; +import { HOPYO_TAG } from "@ui/ui_template_hopyo_grid"; import { pctView } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; @@ -46,7 +47,8 @@ const SPAN = 3; /** 구분(50-4) — 로직 줄 = `승격`(134-1 · 단가승격 · 없음 = 단가산출) · 참조 줄 = 키 머리(SC- = 일위조합 · * 옛 SP- = 단가승격 글 고정 · 134-2) */ -export const kindOf = (row: Pick): string => { +export const kindOf = (row: Pick): string => { + if (row.고정) return HOPYO_TAG.fix; // 705-1 고정 줄 const key = row.참조?.키 ?? row.로직; return tc( row.승격 || key.startsWith("SP-") @@ -343,7 +345,8 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement const logic = data.logics.get(row.로직); const line = lineOf(id); // 참조 줄 — 키 · 이름 · 단위 = 가리킨 문서 것(calc 답) · 넣을 값 없음(승격 때 고정) - const unit = row.참조 ? (line?.분모 ?? "") : (logic?.결과단위 ?? ""); + const fx = row.고정; // 705-1 고정 줄 — 이름 · 단위 = 줄 것 · [옵션] 없음 + const unit = fx ? (fx.단위 ?? "") : row.참조 ? (line?.분모 ?? "") : (logic?.결과단위 ?? ""); const values = valueLine(logic?.입력 ?? [], row.입력값); const layer = row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : ""; @@ -393,7 +396,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement el("td", { className: "m02-labrow__kind", children: [ - on?.promote && !row.참조 + on?.promote && !row.참조 && !fx ? kindPick(!!row.승격, (v) => on.promote?.(i, v)) : kindOf(row), // 102-3 — 단가산출 줄만 품셈 책(출처 첫 낱말) @@ -404,9 +407,14 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement el("td", { className: "m02-labrow__name", children: [ - el("span", { className: "m02-labrow__key", text: row.참조?.키 ?? row.로직 }), + el("span", { + className: "m02-labrow__key", + text: fx + ? [fx.규격, fx.이음?.노임 ?? fx.이음?.자재].filter(Boolean).join(" · ") + : (row.참조?.키 ?? row.로직), + }), el("strong", { - text: (row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직, + text: fx?.이름 ?? (row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직, }), ...(values.empty ? [ @@ -456,7 +464,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement ? ref && !row.참조.키.startsWith("SP-") ? [small(tc("Labor_Edit"), () => ref(i))] : [] - : option + : option && !fx ? [small(tc("Labor_Option"), () => option(i))] : []), ...(remove ? [small(tc("Remove"), () => remove(i), "danger")] : []), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 17a29f798..c453025c4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -177,6 +177,8 @@ const TEXT = { Grid_Title: ["일위대가표", "Unit-price sheet"], Grid_NoQty: ["수량 없음", "No quantity"], Grid_Apart: ["별도계상 · 합계 밖", "Separate · not in total"], + Grid_Linked: ["이 공사 단가에 이음", "Linked to project price"], + Grid_Hand: ["손 단가(붙인 값)", "Hand price (pasted)"], } as const satisfies Record; export type ComboTextKey = keyof typeof TEXT; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index e64672796..69d869319 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -50,6 +50,7 @@ import { } from "./M02_MasterTemplete_TreePanel_Roots"; import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main"; import { buildSide } from "./M02_MasterTemplete_UI_Side"; +import { ITEM_BUILT } from "./M02_MasterTemplete_Hopyo_ToItem"; import { precomboList, type PrecomboScope } from "./M02_MasterTemplete_UI_Side_Precombo"; import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick"; import "./M02_MasterTemplete_UI_Style.css"; @@ -156,6 +157,12 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void go("personal", next, open), }) : panel; + // 704-4 [구조물 조합으로] 만든 항목 → 나무 · 옆 목록 다시(페이지가 내려가면 듣기 끝) + const onItem = (): void => + tree.root.isConnected + ? void Promise.all([side.refresh(), tree.refresh()]) + : document.removeEventListener(ITEM_BUILT, onItem); + document.addEventListener(ITEM_BUILT, onItem); const main = createMain( isAdmin, () => void Promise.all([side.refresh(), tree.refresh()]), diff --git a/resources/tester/test_705_2_hopyo_paste.py b/resources/tester/test_705_2_hopyo_paste.py new file mode 100644 index 000000000..65456b3f6 --- /dev/null +++ b/resources/tester/test_705_2_hopyo_paste.py @@ -0,0 +1,359 @@ +"""705-2 엑셀 붙여넣기 — 붙인 글(탭 · 줄)을 서버가 읽어 고정 줄 · 호표마다 사전조합(요구서 3장 10 · 5-A ⑤-2). + +- 실무 xlsx 넷(거창 · 영월 · 울진 대흥 · 소광) 일위대가 시트를 엑셀 보이는 글(쉼표 · 0 빈칸)로 복사한 꼴 + → 시트 통째 = 파일 올리기와 호표 · 줄 · 합계 같음 · 만듦 = 엑셀 합계 = 우리 합계 +- 호표 하나 · 머리 글 없음(열 짝 짐작 + 물음) · 기억한 열 짝 · 줄만(열린 문서) · 합계 · 근거 줄 거름 · % 줄 +층은 임시 폴더(공용 storage 안 씀). +""" + +import json +import warnings +from decimal import Decimal +from pathlib import Path + +import openpyxl +import pytest + +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Paste as hp + +# ruff: noqa: F811 +from resources.tester.test_m02_hopyo_excel import client, master_hash # noqa: F401 + +URL = "/api/m02/layers/personal/hopyo-paste" +DOCS = Path(__file__).resolve().parents[2] / "resources" / "knowledge" / "original" / "실무문서" +FILES = { # 이름 · 엑셀 합계 = 우리 합계 수(영월 23 — 붙인 글엔 하위 호표 코드가 없어 엑셀 값 그대로 · 파일은 22) + "거창": ( + "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx", + 14, + ), + "영월": ("05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx", 23), + "대흥": ( + "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx", + 34, + ), + "소광": ( + "25년 산불진화임도(기번8)(울진소광)/" + "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx", + 25, + ), +} + + +def _shown(v): + """엑셀이 클립보드에 내는 글 — 천 단위 쉼표 · 0 = 빈칸(STmate 표시꼴) · 줄바꿈 = 띄움.""" + if v is None or v == 0: + return "" + if isinstance(v, (int, float)): + return f"{int(v):,}" if float(v).is_integer() else f"{v:,.6f}".rstrip("0").rstrip(".") + return str(v).replace("\n", " ") + + +def _rows(tag): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + book = openpyxl.load_workbook(DOCS / FILES[tag][0], read_only=True, data_only=True) + sheet = "일위대가" if "일위대가" in book.sheetnames else "일위대가표" + first = 1 if sheet == "일위대가" else 0 # 엑셀꼴 = B 열(호표)부터 · STmate = A 열부터 + width = 14 if first else 13 + return [ + [_shown(v) for v in list(r)[first : first + width]] + for r in book[sheet].iter_rows(values_only=True) + ] + + +def _text(rows): + return "\n".join("\t".join(r) for r in rows) + + +def _paste(client, text, **body): + res = client.post(URL, json={"글": text, **body}) + assert res.status_code == 200, res.text + return res.json() + + +@pytest.mark.parametrize("tag", list(FILES)) +def test_시트_통째_붙여넣기_파일과_같음(tag): + got = hp.read_text(_text(_rows(tag))) + book = hx.read_book(FILES[tag][0], (DOCS / FILES[tag][0]).read_bytes()) + assert got["물음"] is False # 머리 글이 붙어 옴 = 열 짝 저절로 + key = lambda h: (h["번"], h["이름"], h["규격"], h["단위"], h["금액"].get("계"), len(h["줄"])) # noqa: E731 + assert [key(h) for h in got["호표"]] == [key(h) for h in book["호표"]] + zero = lambda m: {k: round(v or 0, 4) for k, v in m.items()} # noqa: E731 · 엑셀 0 = 빈칸으로 옴 + line = lambda x: (x["이름"], x["수량"] or 0, x["단위"], zero(x["단가"]), zero(x["금액"])) # noqa: E731 + assert [line(x) for h in got["호표"] for x in h["줄"]] == [ + line(x) for h in book["호표"] for x in h["줄"] + ] + + +@pytest.mark.parametrize("tag", list(FILES)) +def test_실무_xlsx_넷_붙여넣기_만듦_합계_같음(client, tag): + before = master_hash() + got = _paste(client, _text(_rows(tag))) + assert got["갈래"] == "만듦" and got["빠짐"] == 0 and got["실패"] == [] + assert got["같음"] == FILES[tag][1] and all(abs(x["차이"]) <= 1 for x in got["다름"]) + assert len(got["만듦"]) == got["호표"] and got["고정"] + got["호표참조"] == got["줄"] + assert master_hash() == before + rows = client.get("/api/m02/layers/personal/hopyo").json()["줄"] + assert rows[0]["파일"].startswith("붙여넣기") and rows[0]["시트"] == "붙여넣기" + + +def _block(rows, no): + """호표 하나 줄 — 「제N호표」 줄부터 다음 호표 앞까지(머리 글 없이).""" + at = next(i for i, r in enumerate(rows) if any(hx._HOPYO.fullmatch(c.replace(" ", "")) and int(hx._HOPYO.fullmatch(c.replace(" ", "")).group(1)) == no for c in r)) # fmt: skip + end = next((i for i in range(at + 1, len(rows)) if any(hx._HOPYO.fullmatch(c.replace(" ", "")) for c in rows[i])), len(rows)) # fmt: skip + return rows[at:end] + + +def test_호표_하나_머리_없음_물음_뒤_기억한_열_짝(client): + rows = _block(_rows("대흥"), 17) # 돌기슭막이(찰쌓기) H=1.5 + ask = _paste(client, _text(rows)) + assert ask["갈래"] == "물음" and ask["짐작"] is True and ask["보기"] + cols = ask["열"] + assert (cols["품명"], cols["수량"], cols["단위"], cols["노"]) == (0, 2, 3, 6) + got = _paste(client, _text(rows), 열=cols) # 화면이 한 번 묻고 기억한 열 짝 + assert got["갈래"] == "만듦" and got["호표"] == 1 and got["같음"] == 1 + assert got["만듦"][0]["이름"].startswith("돌기슭막이") + + +def test_엑셀꼴_호표_여럿_머리_없음(client): + rows = _rows("거창") + some = _block(rows, 2) + _block(rows, 3) + cols = hp.read_text(_text(some))["열"] + assert cols["품명"] == 1 # 「제N호표」 칸이 따로 = 그 다음 칸이 품명 + got = _paste(client, _text(some), 열=cols) + assert (got["호표"], got["같음"]) == (2, 2) + + +def test_줄만_고정_줄_합계_근거_거름(): + text = "\n".join( + [ + "석공\t\t0.08\t인\t268,908\t21,512.6\t268,908\t21,512.6\t\t\t\t\t", + "보통인부\t\t0.04\t인\t169,804\t6,792.1\t169,804\t6,792.1\t\t\t\t\t", + "공구손료\t노무비의\t3\t%\t28,304.7\t849.1\t\t\t\t\t849.1\t849.1\t", + "건설표준품셈\t6-7-2\t\t\t\t\t\t\t\t\t\t\t", + "합 계\t\t\t\t\t29,153\t\t28,304\t\t\t\t849\t", + ] + ) + cols = {"품명": 0, "규격": 1, "수량": 2, "단위": 3, "금액": 5, "노": 6, "재": 8, "경": 10} + parsed = hp.read_text(text, cols) + (one,) = parsed["호표"] + assert [x["이름"] for x in one["줄"]] == ["석공", "보통인부", "공구손료"] # 근거 · 합계 줄 뺌 + assert one["금액"]["계"] == 29153 and one["줄"][0]["수량"] == 0.08 + out = hp.fixed_lines(parsed, {"고정|석공||인"}) + names = [x["고정"]["이름"] for x in out["줄"]] + assert names == ["석공 (2)", "보통인부", "공구손료"] # 있는 줄 가름 피함 + assert out["줄"][2]["고정"]["비율"] == {"기준": "노무비", "비목": "경비"} + assert out["줄"][0]["고정"]["단가"]["노무비"] == 268908 and out["엑셀"] == 29153 + + +def test_줄만_길_저장_없음(client): + text = "석공\t\t0.08\t인\t268,908\t21,512.6\t268,908\t21,512.6" + cols = {"품명": 0, "규격": 1, "수량": 2, "단위": 3, "금액": 5, "노": 6, "재": 8, "경": 10} + got = _paste(client, text, 열=cols, 줄만=True) + assert got["갈래"] == "줄" and len(got["줄"]) == 1 and got["줄"][0]["수량"] == {"손": 0.08} + assert client.get("/api/m02/layers/personal/hopyo").json()["줄"] == [] + + +@pytest.mark.parametrize( + ("text", "cols", "word"), + [ + ("", None, "빔"), + ("가\t나", {"품명": 0, "수량": 0, "단위": 1}, "같은 칸"), + ("가\t나", {"품명": 0, "규격": 1}, "꼭"), + ("합계\t\t\t\t1,000", {"품명": 0, "수량": 2, "단위": 3, "금액": 4}, "줄이 없음"), + ], +) +def test_틀린_글_400(client, text, cols, word): + res = client.post(URL, json={"글": text, **({"열": cols} if cols else {})}) + assert res.status_code == 400 and word in res.json()["detail"] + + +def test_쉼표_빈칸_수(): + assert hx._num("1,415,169") == Decimal("1415169") and hx._num("") is None + + +# ── 이 공사 단가로 다시 잇기 ────────────────────────────────────────────────── +RELINK = "/api/m02/layers/personal/hopyo-relink" + + +def test_다시_잇기_후보_바꿈_되돌리기(client): + from resources.tester.test_m02_hopyo_excel import _post + + assert _post(client).status_code == 200 # 북부청 — 이음 13 · 이음 후보 31(705-3) + made = client.post( + "/api/m02/layers/personal/hopyo-build-all", json={"층": "personal", "키": "HP-000001"} + ).json() + keys = [x["키"] for x in made["만듦"]] + assert len(keys) == 68 + rows = client.post(f"{RELINK}/rows", json={"키": keys}).json()["줄"] + near = [r for r in rows if r["후보"] and not any(r["이음"].values())] + assert near, "단가 다른 이음 후보 줄" + linked = [r for r in rows if any(r["이음"].values())] + assert len(linked) >= 13 and all(r["지금"] for r in linked) + one = near[0] + pick = one["후보"][0] + change = {"키": one["키"], "줄": one["줄"], pick["비목"]: pick["키"]} + got = client.post(RELINK, json={"바꿈": [change]}) + assert got.status_code == 200, got.text + done = got.json() + (doc,) = done["문서"] + assert done["바뀜"] == 1 and doc["전"] != doc["후"] # 붙인 단가 → 단가 책 값 + again = client.post(f"{RELINK}/rows", json={"키": [one["키"]]}).json()["줄"] + assert next(r for r in again if r["줄"] == one["줄"])["이음"][pick["비목"]] == pick["키"] + back = client.post(RELINK, json={"바꿈": done["되돌림"]}).json() # 되돌리기 + assert back["문서"][0]["후"] == doc["전"] + bad = {"키": one["키"], "줄": one["줄"], "노임": "LB999999"} + assert client.post(RELINK, json={"바꿈": [bad]}).status_code == 400 + assert client.post(RELINK, json={"바꿈": [{**change, "줄": "고정|없음||"}]}).status_code == 404 + num = client.post(RELINK, json={"바꿈": [change], "금액": 1}) + assert num.status_code == 422 # 숫자 칸 안 받음 + + +def test_다시_잇기_열린_문서_후보만(client): + fx = {"이름": "보통인부", "규격": "", "단위": "인", "단가": {"노무비": 1}, "이음": {}} + doc = {"담은로직": [{"고정": fx, "수량": {"손": 0.5}}]} + (row,) = client.post(f"{RELINK}/rows", json={"문서": doc}).json()["줄"] + assert row["키"] is None and row["후보"][0]["비목"] == "노임" and row["후보"][0]["단가"] > 1 + + +# ── B09 펼친 일위대가 붙여넣기(덮개 @add) ───────────────────────────────────── +def _b09(): + from resources.tester import test_145_2_b09_labor_calc as b09 + + return b09 + + +def _added(user): + from B09_Estimation import B09_Estimation_Daega_Paste as dp + + text = "\n".join( + [ + "석공\t\t0.08\t인\t258,935\t20,714.8\t258,935\t20,714.8\t\t\t\t\t", + "잡재료\t\t1\t식\t1,000\t1,000\t\t\t1,000\t1,000\t\t\t", + "공구손료\t노무비의\t3\t%\t20,714.8\t621.4\t\t\t\t\t621.4\t621.4\t", + ] + ) + cols = {"품명": 0, "규격": 1, "수량": 2, "단위": 3, "금액": 5, "노": 6, "재": 8, "경": 10} + return dp.paste(user, "C:DG9", text, cols) + + +def test_B09_붙인_줄_거울_정리_수요(): + from B09_Estimation.B09_Estimation_Calc_Labor import ADD, added_keys, compute_labor + from B09_Estimation.B09_Estimation_Store import clean_user + + b09 = _b09() + got = _added({}) + assert got["갈래"] == "줄" and got["줄"] == 3 and got["엑셀"] == 22335 + cover = got["user"]["daega_cover"] + lines = cover["C:DG9"][ADD] + assert [ln["kind"] for ln in lines] == ["fixed", "fixed", "pct"] + assert all(ln["cid"].startswith("@add|고정|") for ln in lines) + again = _added(got["user"]) # 같은 글 두 번 = 이름 「 (2)」 가름 · 줄 더함 + names = [ln["name"] for ln in again["user"]["daega_cover"]["C:DG9"][ADD]] + assert names[3:] == ["석공 (2)", "잡재료 (2)", "공구손료 (2)"] + assert clean_user(again["user"]) == again["user"] # 저장 정리 그대로 + bad = clean_user( + {"daega_cover": {"C:x": {ADD: [{"cid": "x", "kind": "fixed"}, {"kind": "?"}]}}} + ) + assert "daega_cover" not in bad # cid 머리 틀림 · 모르는 갈래 버림 + linked = {**lines[0], "link": {"labor": "L00005", "material": None, "machine": None}} + cover = {"C:DG9": {ADD: [linked, *lines[1:]], linked["cid"]: {"qty": 0.1}}} + assert added_keys(cover) == ["L00005"] + blocks = json.loads(json.dumps(b09.EXCEL)) + for b in blocks["blocks"]: + b["cover"] = f"C:{b['id']}" + for n, ln in enumerate(b["lines"]): + ln["cid"] = f"{ln['kind']}:{ln.get('key')}#{n}" + py = json.loads(json.dumps(compute_labor(blocks, b09.SHEETS, b09.SANGEUN, cover))) + ts = b09._ts(blocks, b09.SHEETS, b09.SANGEUN, cover) + assert b09._same(py, ts["out"]) + dg9 = b09._block(py, "DG9") + stone = next(ln for ln in dg9["lines"] if ln["cid"] == linked["cid"]) + assert stone["prices"]["labor"] == 258935 and stone["qty"] == 0.1 # 이은 단가 · 덮개 + pct = next(ln for ln in dg9["lines"] if ln["kind"] == "pct" and ln["cid"].startswith(ADD)) + assert pct["amounts"]["expense"] > 0 and py["daega_orphans"] == [] + + +# ── 704-4 뒷부분 — [구조물 조합으로] · 규격 묶음 ───────────────────────────────── +def _upload(client, tag): + from resources.tester.test_m02_hopyo_excel import _post + + path = DOCS / FILES[tag][0] + res = _post(client, data=path.read_bytes(), name=path.name) + assert res.status_code == 200, res.text + return res.json()["키"] + + +def _item_doc(column): + from M02_MasterTemplete import M02_MasterTemplete_Store as store + from M02_MasterTemplete import M02_Template_Layers as layers + + with store.at(layers.personal_dir(7, 42)): + table = store.read("table", store.STRUCTURE_TABLE)["문서"] + col = next(c for c in table["열"] if c.get("id") == column) + return store.read("labor", column)["문서"], col + + +def _total(doc, col=None): + from M02_MasterTemplete import M02_Labor_Doc as labor + from M02_MasterTemplete import M02_Labor_Spec as spec + + doc = {**doc, "규격": {**doc["규격"], "기본": col}} if col else doc # 셈 = 기본 열 + return labor.calc(labor.clean(spec.at(doc, col)), {})["반영합"]["계"] + + +def layers_dir(): + from M02_MasterTemplete import M02_Template_Layers as layers + + return layers.personal_dir(7, 42) + + +def test_구조물_조합으로_규격_묶음_울진_돌기슭막이(client): + before = master_hash() + key = _upload(client, "대흥") + sib = client.get(f"/api/m02/layers/personal/hopyo/{key}/17/siblings").json() + assert sib["이름"] == "H" and [(r["번"], r["열"]) for r in sib["묶음"]] == [ + (17, "1.5"), (24, "2.0"), (25, "2.5") + ] # fmt: skip + body = {"층": "personal", "키": key, "번": 17, "묶음": [24, 25]} + res = client.post("/api/m02/layers/personal/hopyo-item", json=body) + assert res.status_code == 200, res.text + got = res.json() + assert got["열수"] == 3 and got["이름"].endswith("돌기슭막이(찰쌓기,기초무,구입)") + doc, col = _item_doc(got["열"]) + assert col["경로"] == ["실무자료", "돌기슭막이(찰쌓기,기초무,구입)"] and doc["규격"]["열"] == [ + "1.5", + "2.0", + "2.5", + ] + assert doc["규격"]["기본"] == "1.5" and all("고정" in x for x in doc["담은로직"]) + excel = { + h["번"]: h["금액"]["계"] + for h in client.get(f"/api/m02/layers/personal/hopyo/{key}").json()["호표"] + if h["번"] in (17, 24, 25) + } + for no, c in ((17, "1.5"), (24, "2.0"), (25, "2.5")): + assert abs(_total(doc, c) - excel[no]) <= excel[no] * 1e-4 # 열마다 = 그 호표 엑셀 합계(끝수 차) + one = client.post("/api/m02/layers/personal/hopyo-item", json={**body, "묶음": []}).json() + single, _ = _item_doc(one["열"]) + assert not single.get("규격") and one["이름"].endswith("H=1.5 L3=45cm") + bad = client.post("/api/m02/layers/personal/hopyo-item", json={**body, "묶음": [3]}) + assert bad.status_code == 400 # 줄 구성 다른 호표 + precombo = layers_dir() / "precombo" + assert not list(precombo.glob("SC-*.json")) # 사전조합 문서 안 만듦 + assert master_hash() == before + + +def test_구조물_조합으로_남의_층_403(client): + key = _upload(client, "대흥") + body = {"층": "personal", "키": key, "번": 17} + assert client.post("/api/m02/layers/company/hopyo-item", json=body).status_code == 403 + + +def test_규격_열_이름(): + from M02_MasterTemplete import M02_Hopyo_Item as item + + assert item.columns(["H=1.5 L3=45cm", "H=2.0 L3=45cm"]) == ("H", ["1.5", "2.0"]) + assert item.columns(["일반형", "ㄷ형"]) == ("규격", ["일반형", "ㄷ형"]) + assert item.columns(["D800", "D1000"]) == ("D", ["800", "1000"]) diff --git a/ui_template/ui_template_hopyo_grid.css b/ui_template/ui_template_hopyo_grid.css index f31819c12..0dda4188d 100644 --- a/ui_template/ui_template_hopyo_grid.css +++ b/ui_template/ui_template_hopyo_grid.css @@ -19,3 +19,10 @@ color: var(--color-danger); font-weight: var(--font-weight-bold); } + +/* 붙여넣기 받는 격자(705-2) — 눌러 고르면 테두리 · Ctrl+V 자리 */ +.ui-hopyo-grid[data-paste]:focus { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + border-radius: var(--radius-sm); +} diff --git a/ui_template/ui_template_hopyo_grid.ts b/ui_template/ui_template_hopyo_grid.ts index 8d5eedc4a..7a2b4a5dd 100644 --- a/ui_template/ui_template_hopyo_grid.ts +++ b/ui_template/ui_template_hopyo_grid.ts @@ -13,6 +13,7 @@ * 값은 부르는 쪽이 준 것 그대로(서버 계산 값) — 여기서 셈하지 않음 * * const g = createHopyoGrid([block]); host.append(g.root); g.set([block2]); + * 붙여넣기(705-2) = `createHopyoGrid(blocks, {}, (text) => …)` — 격자 누른 뒤 Ctrl+V 글을 넘김 * B09 처럼 탭 표가 따로 그리면 `hopyoGrid(blocks)` 의 열 · 줄 · 제목 줄만 씀 * ========================================================================== */ @@ -248,13 +249,26 @@ export interface HopyoGridHandle { set: (blocks: HopyoBlock[]) => void; } -/** 격자 하나(읽기 전용) — 빈 호표 목록 = 빈 표 */ +/** 격자 하나(읽기 전용) — 빈 호표 목록 = 빈 표 · `onPaste`(705-2) = 격자를 누르고 Ctrl+V → 붙인 글 그대로 + * (엑셀 칸 = 탭 · 줄 = 줄바꿈 · 해석은 부르는 쪽 · 서버) */ export function createHopyoGrid( blocks: HopyoBlock[], extra: Partial = {}, + onPaste?: (text: string) => void, ): HopyoGridHandle { const root = document.createElement("div"); root.className = "ui-hopyo-grid"; + if (onPaste) { + root.tabIndex = 0; // 눌러 고르면 붙여넣기를 받음 + root.dataset.paste = ""; + root.addEventListener("paste", (ev) => { + if ((ev.target as Element | null)?.closest("input, textarea")) return; // 칸 고침 중은 그 칸 몫 + const text = ev.clipboardData?.getData("text/plain") ?? ""; + if (!text.trim()) return; + ev.preventDefault(); + onPaste(text); + }); + } const set = (next: HopyoBlock[]): void => { root.replaceChildren(createDataTable(hopyoTableOptions(next, extra)).root); };