From 175857de813f1595eae3a2d21eb1be9b73c44121 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 16:04:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09=20=ED=8E=BC=EC=B9=9C=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=20=EB=B6=99=EC=97=AC=EB=84=A3?= =?UTF-8?q?=EA=B8=B0):=20Ctrl+V=20=3D=20=EC=A7=80=EA=B8=88=20=ED=98=B8?= =?UTF-8?q?=ED=91=9C=20=EB=81=9D=20=EA=B3=A0=EC=A0=95=20=EC=A4=84=20=C2=B7?= =?UTF-8?q?=20=EC=9D=B4=20=EA=B3=B5=EC=82=AC=20=EB=8B=A8=EA=B0=80=EB=A1=9C?= =?UTF-8?q?=20=EB=8B=A4=EC=8B=9C=20=EC=9E=87=EA=B8=B0=20(PLAN=20705-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 펼친 일위대가 표를 누르고 Ctrl+V → 서버 /estimation/daega/paste(붙인 글 · M02 와 같은 해석 · 열 짝 물음 · 기억) → 덮개 @add 줄(캐시 · [저장] 때 서버가 다시 셈) - 셈 짝(파이썬 · TS) — @add 줄을 그 cover 블록 끝에 더함(복사본 앞 · 뒤 · 같은 cid 한 번) · 짝 잃음에서 뺌 · 저장 정리 clean_added - 붙인 줄 이음 키 = 단가 책 수요(added_keys) · 답에 그 캐시로 셈한 원가 자료 - 알림 누름 = 다시 잇기 창(후보 = M02 이음 짝 · 단가 = 이 공사 단가 책 · 바꿈 = 캐시 + 서버 셈) - 시험: 거울(파이썬 = TS) · 같은 글 두 번 = 「 (2)」 가름 · 정리 · 수요 키 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Calc_Labor.py | 29 +++- B09_Estimation/B09_Estimation_Calc_Labor.ts | 20 ++- B09_Estimation/B09_Estimation_Daega_Paste.py | 39 ++++++ B09_Estimation/B09_Estimation_Router.py | 34 ++++- B09_Estimation/B09_Estimation_Store.py | 34 ++++- .../B09_Estimation_UI_Tabs_Bill_Fold.ts | 129 ++++++++++++++++++ resources/tester/test_705_2_hopyo_paste.py | 59 ++++++++ 7 files changed, 337 insertions(+), 7 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Daega_Paste.py 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 d15bd3353..21843b910 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -71,6 +71,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 @@ -80,7 +81,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 @@ -209,7 +210,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( @@ -497,6 +499,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 e228e2de4..27325502c 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 @@ -235,6 +236,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} @@ -247,6 +252,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/resources/tester/test_705_2_hopyo_paste.py b/resources/tester/test_705_2_hopyo_paste.py index b70d299e6..a88248d15 100644 --- a/resources/tester/test_705_2_hopyo_paste.py +++ b/resources/tester/test_705_2_hopyo_paste.py @@ -6,6 +6,7 @@ 층은 임시 폴더(공용 storage 안 씀). """ +import json import warnings from decimal import Decimal from pathlib import Path @@ -214,3 +215,61 @@ def test_다시_잇기_열린_문서_후보만(client): 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"] == []