feat(B09 펼친 일위대가 붙여넣기): Ctrl+V = 지금 호표 끝 고정 줄 · 이 공사 단가로 다시 잇기 (PLAN 705-2)

- 펼친 일위대가 표를 누르고 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 16:04:16 +09:00
co-authored by Claude Opus 5.5
parent 32de488401
commit 175857de81
7 changed files with 337 additions and 7 deletions
+27 -2
View File
@@ -23,6 +23,8 @@
짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만. 짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만.
복사본(703-2) = 덮개 `K:…` 의 `@copy` {from · name · spec} — 원본 cover 블록을 본뜬 새 호표 복사본(703-2) = 덮개 `K:…` 의 `@copy` {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
(id `DG:K:…` · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}. (id `DG:K:…` · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}.
붙인 줄(705-2 · 엑셀 붙여넣기) = 덮개 `@add` [블록 줄 `fixed` · `pct` · cid `@add|…`] — 그 cover 블록 끝에 더함
(복사본 앞 · 뒤 두 번 · 같은 cid 는 한 번 · 복사본이 원본 붙인 줄을 이어받음) · 고친 칸 덮개는 다른 줄과 같음.
""" """
from __future__ import annotations from __future__ import annotations
@@ -35,6 +37,7 @@ PARTS = ("labor", "material", "expense")
COVER_KEYS = ("qty", *PARTS) COVER_KEYS = ("qty", *PARTS)
TAB = "dg" TAB = "dg"
COPY = "@copy" COPY = "@copy"
ADD = "@add"
WHY = { WHY = {
"labor": "노임 단가 없음", "labor": "노임 단가 없음",
"material": "자재 적용 단가 없음", "material": "자재 적용 단가 없음",
@@ -182,6 +185,26 @@ def with_copies(blocks: list[dict[str, Any]], cover: dict[str, Any]) -> list[dic
return out 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( def compute_labor(
daega: dict[str, Any] | None, daega: dict[str, Any] | None,
sheets: dict[str, Any], sheets: dict[str, Any],
@@ -201,7 +224,7 @@ def compute_labor(
"sangeun": sangeun or {}, "sangeun": sangeun or {},
} }
cover = cover 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} by_id = {b["id"]: b for b in blocks}
no = {b["id"]: n for n, b in enumerate(blocks, 1)} no = {b["id"]: n for n, b in enumerate(blocks, 1)}
done: dict[str, dict[str, Any]] = {} done: dict[str, dict[str, Any]] = {}
@@ -252,7 +275,9 @@ def compute_labor(
{"cover": key, "cid": cid} {"cover": key, "cid": cid}
for key, lines in cover.items() for key, lines in cover.items()
for cid in lines 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 { return {
"daega_blocks": out_blocks, "daega_blocks": out_blocks,
+18 -2
View File
@@ -19,6 +19,7 @@
* 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만) * 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만)
* 복사본(703-2) = 덮개 K:… 의 @copy {from · name · spec} — 원본 cover 블록을 본뜬 새 호표 * 복사본(703-2) = 덮개 K:… 의 @copy {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
* (id DG:K:… · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy} * (id DG:K:… · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}
* 붙인 줄(705-2) = 덮개 @add [블록 줄 fixed · pct · cid @add|…] — 그 cover 블록 끝에 더함(복사본 앞 · 뒤 · 같은 cid 한 번)
* ========================================================================== */ * ========================================================================== */
import { import {
@@ -36,6 +37,8 @@ const PARTS = ["labor", "material", "expense"] as const;
const COVER_KEYS = ["qty", ...PARTS] as const; const COVER_KEYS = ["qty", ...PARTS] as const;
const TAB = "dg"; const TAB = "dg";
export const COPY = "@copy"; export const COPY = "@copy";
/** 붙인 줄 덮개 열쇠(705-2) */
export const ADD = "@add";
const WHY: Record<string, string> = { const WHY: Record<string, string> = {
labor: "노임 단가 없음", labor: "노임 단가 없음",
material: "자재 적용 단가 없음", material: "자재 적용 단가 없음",
@@ -183,6 +186,16 @@ function withCopies(blocks: Row[], covers: Row): Row[] {
return out; 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 이어 붙임 */ /** 대가 탭 둘 — daega_blocks(일위대가표) · daega(목록표) · daega_owners + 바탕 gaps 이어 붙임 */
export function computeLabor( export function computeLabor(
daega: Row | null | undefined, daega: Row | null | undefined,
@@ -191,7 +204,10 @@ export function computeLabor(
covers: Row = {}, covers: Row = {},
): Row { ): Row {
covers = covers || {}; 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 => const index = (rows: Row[] | undefined): Row =>
Object.fromEntries((rows || []).map((r) => [r.key, r])); Object.fromEntries((rows || []).map((r) => [r.key, r]));
const books: Row = { const books: Row = {
@@ -244,7 +260,7 @@ export function computeLabor(
const orphans: Row[] = []; const orphans: Row[] = [];
for (const [key, lines] of Object.entries(covers)) for (const [key, lines] of Object.entries(covers))
for (const cid of Object.keys(lines || {})) 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 }); orphans.push({ cover: key, cid });
return { return {
daega_blocks: outBlocks, daega_blocks: outBlocks,
@@ -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}
+32 -2
View File
@@ -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 read_data as read_milestone
from B08_Quantity.B08_Quantity_Router_Milestone import site_info 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_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_Daega_Personal as personal
from B09_Estimation import B09_Estimation_Past as past from B09_Estimation import B09_Estimation_Past as past
from B09_Estimation import B09_Estimation_Prices as prices 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_Bill_Items import bill_items, hopyo_prices
from B09_Estimation.B09_Estimation_Calc_Base import compute_base 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_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_Calc_Total import compute_total
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys 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) keep = None if draft is None else library.used_folders(user)
keys = await asyncio.to_thread(linked_resource_keys, root, keep) keys = await asyncio.to_thread(linked_resource_keys, root, keep)
hand = await asyncio.to_thread(sangeun.hand_resource_keys, user) 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( 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]]: def _past_rows(projects: list[dict[str, Any]], here: str) -> list[dict[str, Any]]:
out = [] out = []
for other in projects: for other in projects:
+33 -1
View File
@@ -13,6 +13,7 @@
"daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}} "daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}}
(702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`) (702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`)
+ 복사본 `K:…` 의 `@copy` {from · name · spec}(703-2 · 글만) + 복사본 `K:…` 의 `@copy` {from · name · spec}(703-2 · 글만)
+ 붙인 줄 `@add` [블록 줄 fixed · pct](705-2 · `clean_added`)
quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) · quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) ·
읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함 읽을 때 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_Base import SOURCES, SUPPLY
from B09_Estimation.B09_Estimation_Calc_Bill import SECTIONS 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 폴더 · 지우면 공기 값 없이) try: # 252 공기 초안(draft 폴더 · 지우면 공기 값 없이)
from B09_Estimation.draft.B09_Draft_Schedule_Source import clean_schedule from B09_Estimation.draft.B09_Draft_Schedule_Source import clean_schedule
@@ -235,6 +236,10 @@ def clean_cover(raw: Any) -> dict[str, Any]:
kept = {} kept = {}
for cid, cell in (lines if isinstance(lines, dict) else {}).items(): for cid, cell in (lines if isinstance(lines, dict) else {}).items():
cell = cell if isinstance(cell, dict) else {} 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 cid == "@copy":
if _text(cell.get("from")): if _text(cell.get("from")):
kept[cid] = {k: _text(cell[k]) for k in ("from", "name", "spec") if k in cell} 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 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]: def clean_user(raw: Any) -> dict[str, Any]:
"""사용자 값 다듬기 — 모르는 칸 버림 · 수 칸은 수 · null(빈칸)만.""" """사용자 값 다듬기 — 모르는 칸 버림 · 수 칸은 수 · null(빈칸)만."""
raw = raw if isinstance(raw, dict) else {} raw = raw if isinstance(raw, dict) else {}
@@ -10,6 +10,8 @@
* 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림 * 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림
* 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로) * 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로)
* 우클릭 덧메뉴(703-2) = 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나(`…_Bill_Fold_Menu`) * 우클릭 덧메뉴(703-2) = 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나(`…_Bill_Fold_Menu`)
* 엑셀 붙여넣기(705-2) = 표를 누르고 Ctrl+V → 서버가 붙인 글을 읽어 지금 호표 끝 고정 줄(덮개 `@add` · 캐시 ·
* [저장] 때 서버가 다시 셈) · 열 짝 = M02 와 같은 물음 · 기억 · 알림 누름 = 「이 공사 단가로 다시 잇기」(이 공사 단가 책)
* ========================================================================== */ * ========================================================================== */
import { createButton } from "@ui/ui_template_elements"; 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 { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs";
import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit";
import { foldMenu } from "./B09_Estimation_UI_Tabs_Bill_Fold_Menu"; 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<string, string> = { const FIELD: Record<string, string> = {
@@ -210,9 +222,126 @@ function foldCard(
], ],
}); });
card.classList.add("b09-fold"); 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; return card;
} }
async function post(path: string, body: Row): Promise<Row> {
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<void> {
showLoadingOverlay();
try {
const got = await withCols(
(cols: PasteCols | null) =>
post("daega/paste", {
cover,
글: text,
...(cols ? { 열: cols } : {}),
user: ctx.user,
}) as Promise<Row & { 갈래: "줄" }>,
);
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 줄 누름 = 그 줄 펼침 · 돌려준 자리 = 표 아래 */ /** 펼침 고리 — 설계내역서 표 root 줄 누름 = 그 줄 펼침 · 돌려준 자리 = 표 아래 */
export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement { export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement {
const host = document.createElement("div"); const host = document.createElement("div");
@@ -6,6 +6,7 @@
층은 임시 폴더(공용 storage 안 씀). 층은 임시 폴더(공용 storage 안 씀).
""" """
import json
import warnings import warnings
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
@@ -214,3 +215,61 @@ def test_다시_잇기_열린_문서_후보만(client):
doc = {"담은로직": [{"고정": fx, "수량": {"손": 0.5}}]} doc = {"담은로직": [{"고정": fx, "수량": {"손": 0.5}}]}
(row,) = client.post(f"{RELINK}/rows", json={"문서": doc}).json()["줄"] (row,) = client.post(f"{RELINK}/rows", json={"문서": doc}).json()["줄"]
assert row["키"] is None and row["후보"][0]["비목"] == "노임" and row["후보"][0]["단가"] > 1 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"] == []